From e29062d101043ede6e84f9101e39b9d4ad7a02c7 Mon Sep 17 00:00:00 2001 From: Super Balls Date: Sat, 17 May 2025 17:40:10 -0700 Subject: [PATCH 01/27] Changed pack format to nested ZIP --- Data/Album.cs | 86 ++++++++++++++++++++++++++++++++++++- Managers/AlbumManager.cs | 85 ++++++++++++++++++++++++++---------- Managers/PackManager.cs | 7 +-- Utilities/PackExtensions.cs | 21 +++++++++ 4 files changed, 172 insertions(+), 27 deletions(-) create mode 100644 Utilities/PackExtensions.cs diff --git a/Data/Album.cs b/Data/Album.cs index 0fae3f4..e0a527b 100644 --- a/Data/Album.cs +++ b/Data/Album.cs @@ -10,6 +10,33 @@ public class Album { private static readonly Logger Logger = new(nameof(Album)); + public Album(string directory, ZipArchiveEntry mdm, int index, string packName = null) + { + if (!string.IsNullOrEmpty(packName)) PackName = packName; + + using var mdmStream = mdm.Open(); + using var openedZip = new ZipArchive(mdmStream); + + var info = openedZip.GetEntry("info.json"); + if (info == null) + { + Logger.Error($"Could not find info.json in package: {mdm.Name}"); + throw new FileNotFoundException(); + } + + using var stream = info.Open(); + Info = Json.Deserialize(stream); + IsPackaged = true; + + // CurrentPack will always be null if album is not in a pack + IsPack = AlbumManager.CurrentPack != null; + + Index = index; + Path = directory; + PackAlbumName = System.IO.Path.GetFileNameWithoutExtension(mdm.Name); + GetSheets(); + } + public Album(string path, int index, string packName = null) { // If packName is not null then it's a file path, not a folder chart @@ -56,6 +83,7 @@ public Album(string path, int index, string packName = null) GetSheets(); } + public string PackAlbumName { get; } = string.Empty; public int Index { get; } public string Path { get; } public bool IsPackaged { get; } @@ -69,12 +97,26 @@ public Album(string path, int index, string packName = null) public Dictionary Sheets { get; } = new(); public string AlbumName => IsPackaged ? - $"album_{System.IO.Path.GetFileNameWithoutExtension(Path)}{(PackName != null ? $"_{PackName}" : string.Empty)}" + $"album_{(string.IsNullOrEmpty(PackAlbumName) ? System.IO.Path.GetFileNameWithoutExtension(Path) : string.Empty)}{(PackName != null ? $"{PackAlbumName}_{PackName}" : string.Empty)}" : $"album_{System.IO.Path.GetFileName(Path)}_folder"; public string Uid => $"{AlbumManager.Uid}-{Index}"; public bool HasFile(string name) { + if (IsPack && !string.IsNullOrEmpty(PackAlbumName)) + { + if (!File.Exists(Path)) return false; + try + { + using var mdp = ZipFile.OpenRead(Path); + using var openedMdm = mdp.GetNestedZip(PackAlbumName + ".mdm"); + return openedMdm.GetEntry(name) != null; + } + catch (IOException) + { + return false; + } + } if (IsPackaged) { if (!File.Exists(Path)) return false; @@ -96,6 +138,20 @@ public bool HasFile(string name) public Stream OpenFileStreamIfPossible(string file) { + if (IsPack && !string.IsNullOrEmpty(PackAlbumName)) + { + using var mdp = ZipFile.OpenRead(Path); + using var openedMdm = mdp.GetNestedZip(PackAlbumName + ".mdm"); + var entry = openedMdm.GetEntry(file); + + if (entry != null) + { + return entry.Open().ToMemoryStream(); + } + + Logger.Error($"Could not find file in package: {file}"); + throw new FileNotFoundException(); + } if (IsPackaged) { using var zip = ZipFile.OpenRead(Path); @@ -120,6 +176,19 @@ public Stream OpenFileStreamIfPossible(string file) public Stream OpenNullableStream(string file) { + if (IsPack && !string.IsNullOrEmpty(PackAlbumName)) + { + using var mdp = ZipFile.OpenRead(Path); + using var openedMdm = mdp.GetNestedZip(PackAlbumName + ".mdm"); + var entry = openedMdm.GetEntry(file); + + if (entry != null) + { + return entry.Open().ToMemoryStream(); + } + + return null; + } if (IsPackaged) { using var zip = ZipFile.OpenRead(Path); @@ -142,6 +211,21 @@ public Stream OpenNullableStream(string file) public MemoryStream OpenMemoryStream(string file) { + if (IsPack && !string.IsNullOrEmpty(PackAlbumName)) + { + using var mdp = ZipFile.OpenRead(Path); + using var openedMdm = mdp.GetNestedZip(PackAlbumName + ".mdm"); + var entry = openedMdm.GetEntry(file); + + if (entry != null) + { + return entry.Open().ToMemoryStream(); + } + + Logger.Error($"Could not find file in package: {file}"); + throw new FileNotFoundException(); + } + if (IsPackaged) { using var zip = ZipFile.OpenRead(Path); diff --git a/Managers/AlbumManager.cs b/Managers/AlbumManager.cs index 03a2522..8fad7e4 100644 --- a/Managers/AlbumManager.cs +++ b/Managers/AlbumManager.cs @@ -4,6 +4,7 @@ using Il2CppAssets.Scripts.PeroTools.Commons; using Il2CppAssets.Scripts.PeroTools.GeneralLocalization; using Il2CppPeroTools2.Resources; +using System.IO.Compression; using UnityEngine; using Logger = CustomAlbums.Utilities.Logger; @@ -14,6 +15,7 @@ public static class AlbumManager public const int Uid = 999; public const string SearchPath = "Custom_Albums"; public const string SearchPattern = "*.mdm"; + public const string PackSearchPattern = "*.mdp"; public static readonly string JsonName = $"ALBUM{Uid + 1}"; public static readonly string MusicPackage = $"music_package_{Uid}"; @@ -35,26 +37,68 @@ public static class AlbumManager public static Dictionary LoadedAlbums { get; } = new(); - public static void LoadMany(string directory) + public static void LoadPack(string directory) { // Get the files from the directory - var files = Directory.EnumerateFiles(directory); + try + { + var zipFiles = ZipFile.OpenRead(directory); + + // Filter for .mdm files and find the pack.json file + var mdms = zipFiles.Entries.Where(file => file.Name.EndsWith(".mdm")); + var json = zipFiles.Entries.FirstOrDefault(file => file.Name.EndsWith(".json")); + + // Initialize pack and variables + var pack = PackManager.CreatePack(json); + CurrentPack = pack.Title; + pack.StartIndex = MaxCount; + + // Count successfully loaded .mdm files + pack.Length = mdms.Count(mdm => LoadOne(directory, mdm, mdm.Name) != null); + + // Set the current pack to null and add the pack to the pack list + CurrentPack = null; + PackManager.AddPack(pack); + } + catch (Exception ex) + { + Logger.Warning($"Failed to load album at {directory}. Reason: {ex.Message}"); + Logger.Warning(ex.StackTrace); + } + } + + public static Album LoadOne(string directory, ZipArchiveEntry mdm, string fullFileName) + { + var fileName = Path.GetFileNameWithoutExtension(fullFileName); + MaxCount = Math.Max(LoadedAlbums.Count, MaxCount); + + if (LoadedAlbums.ContainsKey(fileName)) return null; + + try + { + var album = new Album(directory, mdm, MaxCount, CurrentPack); + if (album.Info is null) return null; - // Filter for .mdm files and find the pack.json file - var mdms = files.Where(file => Path.GetExtension(file).EqualsCaseInsensitive(".mdm")).ToList(); - var json = files.FirstOrDefault(file => Path.GetFileName(file).EqualsCaseInsensitive("pack.json")); + var albumName = album.AlbumName; + Logger.Msg("Adding " + albumName + " as a pack!"); - // Initialize pack and variables - var pack = PackManager.CreatePack(json); - CurrentPack = pack.Title; - pack.StartIndex = MaxCount; + LoadedAlbums.Add(albumName, album); + + if (album.HasFile("cover.png") || album.HasFile("cover.gif")) + ResourcesManager.instance.LoadFromName($"{albumName}_cover").hideFlags |= + HideFlags.DontUnloadUnusedAsset; - // Count successfully loaded .mdm files - pack.Length = mdms.Count(file => LoadOne(file) != null); + Logger.Msg($"Loaded {albumName}: {album.Info.Name}"); + OnAlbumLoaded?.Invoke(typeof(AlbumManager), new AlbumEventArgs(album)); + return album; + } + catch (Exception ex) + { + Logger.Warning($"Failed to load album at {fileName}. Reason: {ex.Message}"); + Logger.Warning(ex.StackTrace); + } - // Set the current pack to null and add the pack to the pack list - CurrentPack = null; - PackManager.AddPack(pack); + return null; } public static Album LoadOne(string path) @@ -67,14 +111,6 @@ public static Album LoadOne(string path) try { - if (isDirectory && Directory.EnumerateFiles(path) - .Any(file => Path.GetFileName(file) - .EqualsCaseInsensitive("pack.json"))) - { - LoadMany(path); - return null; - } - var album = new Album(path, MaxCount, CurrentPack); if (album.Info is null) return null; @@ -102,12 +138,15 @@ public static Album LoadOne(string path) public static void LoadAlbums() { LoadedAlbums.Clear(); - + + var packs = new List(); var files = new List(); files.AddRange(Directory.GetFiles(SearchPath, SearchPattern)); files.AddRange(Directory.GetDirectories(SearchPath)); + packs.AddRange(Directory.GetFiles(SearchPath, PackSearchPattern)); foreach (var file in files) LoadOne(file); + foreach (var pack in packs) LoadPack(pack); Logger.Msg($"Finished loading {LoadedAlbums.Count} albums.", false); } diff --git a/Managers/PackManager.cs b/Managers/PackManager.cs index 87b636e..1f8549f 100644 --- a/Managers/PackManager.cs +++ b/Managers/PackManager.cs @@ -1,5 +1,6 @@ using CustomAlbums.Data; using CustomAlbums.Utilities; +using System.IO.Compression; namespace CustomAlbums.Managers { @@ -14,15 +15,15 @@ internal static Pack GetPackFromUid(string uid) // Retrieve the pack that the uid belongs to var pack = Packs.FirstOrDefault(pack => - uidIndex >= pack.StartIndex && uidIndex < pack.StartIndex + pack.Length); + uidIndex > pack.StartIndex && uidIndex <= pack.StartIndex + pack.Length); // If the pack has no albums in it return null, otherwise return pack (will be null if it doesn't exist) return pack?.Length == 0 ? null : pack; } - internal static Pack CreatePack(string file) + internal static Pack CreatePack(ZipArchiveEntry json) { - return Json.Deserialize(File.OpenRead(file)); + return Json.Deserialize(json.Open()); } internal static void AddPack(Pack pack) diff --git a/Utilities/PackExtensions.cs b/Utilities/PackExtensions.cs new file mode 100644 index 0000000..e82fcb1 --- /dev/null +++ b/Utilities/PackExtensions.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CustomAlbums.Utilities +{ + internal static class PackExtensions + { + private static readonly Logger Logger = new(nameof(PackExtensions)); + public static ZipArchive GetNestedZip(this ZipArchive mdp, string entryName) + { + var mdm = mdp.GetEntry(entryName); + using var mdmStream = mdm.Open(); + var openedMdm = new ZipArchive(mdmStream); + return openedMdm; + } + } +} From 9801dfd633faec90f224ac51080457ef3901589f Mon Sep 17 00:00:00 2001 From: Super Balls Date: Tue, 27 May 2025 18:18:02 -0700 Subject: [PATCH 02/27] CAM-74 Fixed pack index logic --- Data/Album.cs | 10 ++++++++++ Managers/AlbumManager.cs | 6 ++++-- Managers/PackManager.cs | 6 +++--- Patches/PackPatch.cs | 3 ++- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Data/Album.cs b/Data/Album.cs index e0a527b..77488d7 100644 --- a/Data/Album.cs +++ b/Data/Album.cs @@ -31,6 +31,9 @@ public Album(string directory, ZipArchiveEntry mdm, int index, string packName = // CurrentPack will always be null if album is not in a pack IsPack = AlbumManager.CurrentPack != null; + HasGif = openedZip.GetEntry("cover.gif") != null; + HasPng = openedZip.GetEntry("cover.png") != null; + Index = index; Path = directory; PackAlbumName = System.IO.Path.GetFileNameWithoutExtension(mdm.Name); @@ -51,6 +54,8 @@ public Album(string path, int index, string packName = null) using var fileStream = File.OpenRead($"{path}\\info.json"); Info = Json.Deserialize(fileStream); + HasGif = File.Exists(System.IO.Path.Combine(path, "cover.gif")); + HasPng = File.Exists(System.IO.Path.Combine(path, "cover.png")); } else if (File.Exists(path)) { @@ -70,6 +75,9 @@ public Album(string path, int index, string packName = null) // CurrentPack will always be null if album is not in a pack IsPack = AlbumManager.CurrentPack != null; + + HasGif = zip.GetEntry("cover.gif") != null; + HasPng = zip.GetEntry("cover.png") != null; } else { @@ -88,6 +96,8 @@ public Album(string path, int index, string packName = null) public string Path { get; } public bool IsPackaged { get; } public bool IsPack { get; } + public bool HasPng { get; } + public bool HasGif { get; } public string PackName { get; } public AlbumInfo Info { get; } public Sprite Cover => this.GetCover(); diff --git a/Managers/AlbumManager.cs b/Managers/AlbumManager.cs index 8fad7e4..3fc9c16 100644 --- a/Managers/AlbumManager.cs +++ b/Managers/AlbumManager.cs @@ -53,6 +53,8 @@ public static void LoadPack(string directory) CurrentPack = pack.Title; pack.StartIndex = MaxCount; + MaxCount = Math.Max(LoadedAlbums.Count, MaxCount); + // Count successfully loaded .mdm files pack.Length = mdms.Count(mdm => LoadOne(directory, mdm, mdm.Name) != null); @@ -84,7 +86,7 @@ public static Album LoadOne(string directory, ZipArchiveEntry mdm, string fullFi LoadedAlbums.Add(albumName, album); - if (album.HasFile("cover.png") || album.HasFile("cover.gif")) + if (album.HasPng || album.HasGif) ResourcesManager.instance.LoadFromName($"{albumName}_cover").hideFlags |= HideFlags.DontUnloadUnusedAsset; @@ -118,7 +120,7 @@ public static Album LoadOne(string path) LoadedAlbums.Add(albumName, album); - if (album.HasFile("cover.png") || album.HasFile("cover.gif")) + if (album.HasPng || album.HasGif) ResourcesManager.instance.LoadFromName($"{albumName}_cover").hideFlags |= HideFlags.DontUnloadUnusedAsset; diff --git a/Managers/PackManager.cs b/Managers/PackManager.cs index 1f8549f..3732ad8 100644 --- a/Managers/PackManager.cs +++ b/Managers/PackManager.cs @@ -14,11 +14,11 @@ internal static Pack GetPackFromUid(string uid) !uid[4..].TryParseAsInt(out var uidIndex)) return null; // Retrieve the pack that the uid belongs to - var pack = Packs.FirstOrDefault(pack => - uidIndex > pack.StartIndex && uidIndex <= pack.StartIndex + pack.Length); + var retrievedPack = Packs.FirstOrDefault(pack => + uidIndex >= pack.StartIndex && uidIndex < pack.StartIndex + pack.Length); // If the pack has no albums in it return null, otherwise return pack (will be null if it doesn't exist) - return pack?.Length == 0 ? null : pack; + return retrievedPack?.Length is 0 ? null : retrievedPack; } internal static Pack CreatePack(ZipArchiveEntry json) diff --git a/Patches/PackPatch.cs b/Patches/PackPatch.cs index a2f8995..c02ab66 100644 --- a/Patches/PackPatch.cs +++ b/Patches/PackPatch.cs @@ -12,7 +12,8 @@ internal class PackPatch private static readonly Logger Logger = new(nameof(PackPatch)); [HarmonyPatch(typeof(LongSongNameController), nameof(LongSongNameController.Refresh), new[] { typeof(string), typeof(bool), typeof(float) })] - internal class RefreshPatch { + internal class RefreshPatch + { private static void SetColor(string colorHex, LongSongNameController instance) { var fixedColor = UnityEngine.ColorUtility.TryParseHtmlString(colorHex, out var color) ? color : UnityEngine.Color.white; From 54b42ad05b3663eff21082e1888b098fd080f06a Mon Sep 17 00:00:00 2001 From: Super Balls Date: Tue, 27 May 2025 18:20:25 -0700 Subject: [PATCH 03/27] Implement memcopy for managed->IL2CPP struct arrays, improved performance loading .PNG covers, albums are now aware if they have PNG/GIF covers --- Managers/CoverManager.cs | 14 +++++++++----- Utilities/Il2CppExtensions.cs | 28 +++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/Managers/CoverManager.cs b/Managers/CoverManager.cs index 294faa5..2c557a8 100644 --- a/Managers/CoverManager.cs +++ b/Managers/CoverManager.cs @@ -20,10 +20,11 @@ public static class CoverManager public static Sprite GetCover(this Album album) { - if (!album.HasFile("cover.png")) return null; + if (!album.HasPng) return null; if (CachedCovers.TryGetValue(album.Index, out var cached)) return cached; - using var stream = album.OpenMemoryStream("cover.png"); + using var stream = album.OpenNullableStream("cover.png")?.ToMemoryStream(); + if (stream is null) return null; var bytes = stream.ReadFully(); @@ -32,7 +33,7 @@ public static Sprite GetCover(this Album album) { wrapMode = TextureWrapMode.MirrorOnce }; - texture.LoadImage(bytes); + texture.LoadImage(bytes.MemCopyFromManaged()); var cover = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f)); CachedCovers.Add(album.Index, cover); @@ -42,14 +43,17 @@ public static Sprite GetCover(this Album album) public static unsafe AnimatedCover GetAnimatedCover(this Album album) { + return null; // Early return statements - if (!album.HasFile("cover.gif")) return null; + if (!album.HasGif) return null; if (CachedAnimatedCovers.TryGetValue(album.Index, out var cached)) return cached; Config.PreferContiguousImageBuffers = true; // Open and load the gif - using var stream = album.OpenMemoryStream("cover.gif"); + using var stream = album.OpenNullableStream("cover.gif").ToMemoryStream(); + if (stream is null) return null; + using var gif = Image.Load(new DecoderOptions { Configuration = Config }, stream); // For some reason Unity loads textures upside down? diff --git a/Utilities/Il2CppExtensions.cs b/Utilities/Il2CppExtensions.cs index 16593aa..ef1e35a 100644 --- a/Utilities/Il2CppExtensions.cs +++ b/Utilities/Il2CppExtensions.cs @@ -1,4 +1,5 @@ -using System.Collections.ObjectModel; +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using System.Collections.ObjectModel; namespace CustomAlbums.Utilities { @@ -71,5 +72,30 @@ public static bool TryGetValuePossibleNullKey( outValue = dict[key]; return true; } + + public static unsafe Il2CppStructArray MemCopyFromManaged(this T[] arr, long? size = null) where T : unmanaged + { + var len = arr.Length; + if (size is not null && size < 0) + throw new ArgumentOutOfRangeException(nameof(size), "The size to copy cannot be negative."); + + var lenCopy = size ?? len; + if (lenCopy > len) + throw new ArgumentOutOfRangeException(nameof(size), "The size to copy is larger than the array length."); + + + var il2CppArray = new Il2CppStructArray(len); + + fixed (void* managedArrayBase = &arr[0]) + { + Buffer.MemoryCopy( + managedArrayBase, + IntPtr.Add(il2CppArray.Pointer, 4 * IntPtr.Size).ToPointer(), + checked(il2CppArray.Length * sizeof(T)), + checked(lenCopy * sizeof(T))); + } + + return il2CppArray; + } } } \ No newline at end of file From e2dd166cb4fb058fb9d30562111b4350f8fd56c8 Mon Sep 17 00:00:00 2001 From: Super Balls Date: Tue, 27 May 2025 18:21:14 -0700 Subject: [PATCH 04/27] Misc bug fixes and optimizations --- Managers/CoverManager.cs | 1 - Patches/AnimatedCoverPatch.cs | 6 +++++- Patches/DifficultyGradeIconPatch.cs | 4 +++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Managers/CoverManager.cs b/Managers/CoverManager.cs index 2c557a8..9484162 100644 --- a/Managers/CoverManager.cs +++ b/Managers/CoverManager.cs @@ -43,7 +43,6 @@ public static Sprite GetCover(this Album album) public static unsafe AnimatedCover GetAnimatedCover(this Album album) { - return null; // Early return statements if (!album.HasGif) return null; if (CachedAnimatedCovers.TryGetValue(album.Index, out var cached)) return cached; diff --git a/Patches/AnimatedCoverPatch.cs b/Patches/AnimatedCoverPatch.cs index 4ab74b5..355a6be 100644 --- a/Patches/AnimatedCoverPatch.cs +++ b/Patches/AnimatedCoverPatch.cs @@ -22,7 +22,11 @@ internal static class MusicStageCellPatch public static void AnimateCoversUpdate() { - if (CurrentScene is not "UISystem_PC") return; + if (CurrentScene is not "UISystem_PC") + { + Cells.Clear(); + return; + } var dbMusicTag = GlobalDataBase.dbMusicTag; if (dbMusicTag == null) return; diff --git a/Patches/DifficultyGradeIconPatch.cs b/Patches/DifficultyGradeIconPatch.cs index e619284..98faa44 100644 --- a/Patches/DifficultyGradeIconPatch.cs +++ b/Patches/DifficultyGradeIconPatch.cs @@ -29,7 +29,9 @@ private static void Postfix(MusicInfo musicInfo, PnlStage __instance) if (uid.StartsWith($"{AlbumManager.Uid}-")) { // Gets the highest data from save data - var customHighest = SaveManager.SaveData.GetChartSaveDataFromUid(uid).Highest; + var customHighest = SaveManager.SaveData.GetChartSaveDataFromUid(uid)?.Highest; + + if (customHighest == null) return; // Get the Evaluate value from each, set diff3 to diff4 if hidden is invoked var diff1 = GetEvaluate(customHighest, 1); From c7e1340700c0f5826cba11390a3db4d6842bf492 Mon Sep 17 00:00:00 2001 From: Super Balls Date: Wed, 18 Jun 2025 19:06:56 -0700 Subject: [PATCH 05/27] CAM-59 Improved gif/png cover loading performance using parallel library, asynchronous methods and main thread dispatcher --- Main.cs | 6 ++ Managers/AlbumManager.cs | 21 +++---- Managers/CoverManager.cs | 102 ++++++++++++++++++++++------------ Utilities/Il2CppExtensions.cs | 3 +- 4 files changed, 84 insertions(+), 48 deletions(-) diff --git a/Main.cs b/Main.cs index 4c995ba..bd42bf8 100644 --- a/Main.cs +++ b/Main.cs @@ -52,6 +52,12 @@ public override void OnFixedUpdate() base.OnFixedUpdate(); // TODO: Actually write HotReload // HotReloadManager.FixedUpdate(); + + // Dispatcher for GIF covers + if (CoverManager.GifAlbumDatas.TryDequeue(out var gifData)) + { + CoverManager.LoadAnimatedCover(gifData); + } } public override void OnSceneWasLoaded(int buildIndex, string sceneName) diff --git a/Managers/AlbumManager.cs b/Managers/AlbumManager.cs index 3fc9c16..ef04daa 100644 --- a/Managers/AlbumManager.cs +++ b/Managers/AlbumManager.cs @@ -51,10 +51,11 @@ public static void LoadPack(string directory) // Initialize pack and variables var pack = PackManager.CreatePack(json); CurrentPack = pack.Title; - pack.StartIndex = MaxCount; MaxCount = Math.Max(LoadedAlbums.Count, MaxCount); + pack.StartIndex = MaxCount; + // Count successfully loaded .mdm files pack.Length = mdms.Count(mdm => LoadOne(directory, mdm, mdm.Name) != null); @@ -86,10 +87,12 @@ public static Album LoadOne(string directory, ZipArchiveEntry mdm, string fullFi LoadedAlbums.Add(albumName, album); - if (album.HasPng || album.HasGif) + if (album.HasPng) ResourcesManager.instance.LoadFromName($"{albumName}_cover").hideFlags |= HideFlags.DontUnloadUnusedAsset; + if (album.HasGif) Task.Run(() => CoverManager.LoadGif(album)); + Logger.Msg($"Loaded {albumName}: {album.Info.Name}"); OnAlbumLoaded?.Invoke(typeof(AlbumManager), new AlbumEventArgs(album)); return album; @@ -120,10 +123,12 @@ public static Album LoadOne(string path) LoadedAlbums.Add(albumName, album); - if (album.HasPng || album.HasGif) + if (album.HasPng) ResourcesManager.instance.LoadFromName($"{albumName}_cover").hideFlags |= HideFlags.DontUnloadUnusedAsset; + if (album.HasGif) Task.Run(() => CoverManager.LoadGif(album)); + Logger.Msg($"Loaded {albumName}: {album.Info.Name}"); OnAlbumLoaded?.Invoke(typeof(AlbumManager), new AlbumEventArgs(album)); return album; @@ -147,21 +152,17 @@ public static void LoadAlbums() files.AddRange(Directory.GetDirectories(SearchPath)); packs.AddRange(Directory.GetFiles(SearchPath, PackSearchPattern)); - foreach (var file in files) LoadOne(file); foreach (var pack in packs) LoadPack(pack); + foreach (var file in files) LoadOne(file); Logger.Msg($"Finished loading {LoadedAlbums.Count} albums.", false); } public static IEnumerable GetAllUid() - { - return LoadedAlbums.Select(album => $"{Uid}-{album.Value.Index}"); - } + => LoadedAlbums.Select(album => $"{Uid}-{album.Value.Index}"); public static Album GetByUid(string uid) - { - return LoadedAlbums.FirstOrDefault(album => album.Value.Index == uid[4..].ParseAsInt()).Value; - } + => LoadedAlbums.FirstOrDefault(album => album.Value.Index == uid[4..].ParseAsInt()).Value; public static string GetAlbumNameFromUid(string uid) { var album = GetByUid(uid); diff --git a/Managers/CoverManager.cs b/Managers/CoverManager.cs index 9484162..c7cb33e 100644 --- a/Managers/CoverManager.cs +++ b/Managers/CoverManager.cs @@ -1,4 +1,6 @@ -using System.Runtime.CompilerServices; +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using CustomAlbums.Data; using CustomAlbums.Utilities; using SixLabors.ImageSharp; @@ -10,8 +12,35 @@ namespace CustomAlbums.Managers { + public readonly struct RawFrame + { + public readonly int Width; + public readonly int Height; + public readonly byte[] Buffer; + + public RawFrame(int width, int height, byte[] buffer) + { + Width = width; + Height = height; + Buffer = buffer; + } + } + + public class GifAlbumData + { + public readonly Album Album; + public readonly RawFrame[] Frames; + public readonly int FramesPerSecond; + public GifAlbumData(Album album, RawFrame[] frames, int fps) + { + Album = album; + Frames = frames; + FramesPerSecond = fps; + } + } public static class CoverManager { + internal static readonly ConcurrentQueue GifAlbumDatas = new(); internal static readonly Dictionary CachedCovers = new(); internal static readonly Dictionary CachedAnimatedCovers = new(); private static readonly Logger Logger = new(nameof(CoverManager)); @@ -33,7 +62,7 @@ public static Sprite GetCover(this Album album) { wrapMode = TextureWrapMode.MirrorOnce }; - texture.LoadImage(bytes.MemCopyFromManaged()); + texture.LoadImage(bytes.CopyFromManaged()); var cover = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f)); CachedCovers.Add(album.Index, cover); @@ -41,54 +70,55 @@ public static Sprite GetCover(this Album album) return cover; } - public static unsafe AnimatedCover GetAnimatedCover(this Album album) + public static async Task LoadGif(this Album album) { - // Early return statements - if (!album.HasGif) return null; - if (CachedAnimatedCovers.TryGetValue(album.Index, out var cached)) return cached; - + if (!album.HasGif) return; + Config.PreferContiguousImageBuffers = true; - // Open and load the gif - using var stream = album.OpenNullableStream("cover.gif").ToMemoryStream(); - if (stream is null) return null; - - using var gif = Image.Load(new DecoderOptions { Configuration = Config }, stream); + using var stream = album.OpenNullableStream("cover.gif"); + if (stream is null) return; - // For some reason Unity loads textures upside down? - // Flip the frames + using var gif = await Image.LoadAsync(new DecoderOptions { Configuration = Config }, stream); gif.Mutate(c => c.Flip(FlipMode.Vertical)); - var sprites = new Sprite[gif.Frames.Count]; - - for (var i = 0; i < gif.Frames.Count; i++) + var rawFrames = new RawFrame[gif.Frames.Count]; + + Parallel.For(0, gif.Frames.Count, i => { - // Get frame data var frame = gif.Frames[i]; - var width = frame.Width; - var height = frame.Height; - - // Get frame pixel data - // - // This should really be done with CopyPixelData and a byte array - // but that causes a 6MB+ copy of an array that slows things down by a bit - // The more efficient way is to retrieve an IntPtr that stores the data and pass that with a size instead - var getPixelDataResult = frame.DangerousTryGetSinglePixelMemory(out var memory); - if (!getPixelDataResult) + if (frame.DangerousTryGetSinglePixelMemory(out var memory)) { - Logger.Error("Failed to get pixel data."); - return null; + var buffer = new byte[memory.Length * Unsafe.SizeOf()]; + memory.Span.CopyTo(MemoryMarshal.Cast(buffer.AsSpan())); + rawFrames[i] = new RawFrame(frame.Width, frame.Height, buffer); } + }); + + GifAlbumDatas.Enqueue(new(album, rawFrames, gif.Frames.RootFrame.Metadata.GetGifMetadata().FrameDelay * 10)); + } - using var handle = memory.Pin(); + public static AnimatedCover GetAnimatedCover(this Album album) + => CachedAnimatedCovers.GetValueOrDefault(album.Index); + + public static AnimatedCover LoadAnimatedCover(GifAlbumData data) + { + if (CachedAnimatedCovers.TryGetValue(data.Album.Index, out var cached)) return cached; + + var rawFrames = data.Frames; + var sprites = new Sprite[rawFrames.Length]; + + for (var i = 0; i < rawFrames.Length; i++) + { + var frame = rawFrames[i]; // Create the textures - var texture = new Texture2D(width, height, TextureFormat.RGBA32, false) + var texture = new Texture2D(frame.Width, frame.Height, TextureFormat.RGBA32, false) { wrapMode = TextureWrapMode.MirrorOnce }; - texture.LoadRawTextureData((IntPtr)handle.Pointer, memory.Length * Unsafe.SizeOf()); - texture.Apply(false); + texture.LoadRawTextureData(frame.Buffer.CopyFromManaged()); + texture.Apply(false, true); // Create the sprite with the given texture and add it to the sprites array var sprite = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), @@ -98,8 +128,8 @@ public static unsafe AnimatedCover GetAnimatedCover(this Album album) } // Create and add cover to cache - var cover = new AnimatedCover(sprites, gif.Frames.RootFrame.Metadata.GetGifMetadata().FrameDelay * 10); - CachedAnimatedCovers.Add(album.Index, cover); + var cover = new AnimatedCover(sprites, data.FramesPerSecond); + CachedAnimatedCovers.Add(data.Album.Index, cover); return cover; } diff --git a/Utilities/Il2CppExtensions.cs b/Utilities/Il2CppExtensions.cs index ef1e35a..270cf19 100644 --- a/Utilities/Il2CppExtensions.cs +++ b/Utilities/Il2CppExtensions.cs @@ -73,7 +73,7 @@ public static bool TryGetValuePossibleNullKey( return true; } - public static unsafe Il2CppStructArray MemCopyFromManaged(this T[] arr, long? size = null) where T : unmanaged + public static unsafe Il2CppStructArray CopyFromManaged(this T[] arr, long? size = null) where T : unmanaged { var len = arr.Length; if (size is not null && size < 0) @@ -83,7 +83,6 @@ public static unsafe Il2CppStructArray MemCopyFromManaged(this T[] arr, lo if (lenCopy > len) throw new ArgumentOutOfRangeException(nameof(size), "The size to copy is larger than the array length."); - var il2CppArray = new Il2CppStructArray(len); fixed (void* managedArrayBase = &arr[0]) From 74b071262ca5d2453b9873c0f5e922e736437496 Mon Sep 17 00:00:00 2001 From: Super Balls Date: Wed, 18 Jun 2025 19:07:40 -0700 Subject: [PATCH 06/27] Misc minor bug fixes and optimizations --- Managers/AudioManager.cs | 18 ++++++------------ Patches/ReportCardPatch.cs | 10 ++-------- Utilities/DataExtensions.cs | 7 +++++-- Utilities/PackExtensions.cs | 6 +++--- 4 files changed, 16 insertions(+), 25 deletions(-) diff --git a/Managers/AudioManager.cs b/Managers/AudioManager.cs index 5039716..7c351a8 100644 --- a/Managers/AudioManager.cs +++ b/Managers/AudioManager.cs @@ -1,4 +1,5 @@ using CustomAlbums.Data; +using CustomAlbums.Utilities; using Il2CppAssets.Scripts.PeroTools.Commons; using Il2CppAssets.Scripts.PeroTools.Managers; using NAudio.Vorbis; @@ -169,19 +170,12 @@ private static Coroutine CreateCoroutine(Il2CppSystem.Func update) public static AudioClip GetAudio(this Album album, string name = "music") { var key = $"{album.AlbumName}_{name}"; - if (album.HasFile($"{name}.ogg")) - { - // Load music.ogg - var stream = album.OpenMemoryStream($"{name}.ogg"); - return LoadClipFromOgg(stream, key); - } - if (album.HasFile($"{name}.mp3")) - { - // Load music.mp3 - var stream = album.OpenMemoryStream($"{name}.mp3"); - return LoadClipFromMp3(stream, key); - } + if (album.OpenNullableStream($"{name}.ogg") is { } oggStream) + return LoadClipFromOgg(oggStream.ToMemoryStream(), key); + + if (album.OpenNullableStream($"{name}.mp3") is { } mp3Stream) + return LoadClipFromMp3(mp3Stream.ToMemoryStream(), key); // No music file found Logger.Error($"Could not find audio file for {name} in {album.Info.Name}"); diff --git a/Patches/ReportCardPatch.cs b/Patches/ReportCardPatch.cs index 797139b..58eb2da 100644 --- a/Patches/ReportCardPatch.cs +++ b/Patches/ReportCardPatch.cs @@ -25,7 +25,7 @@ private static bool Prefix(PnlReportCard __instance) var album = AlbumManager.GetByUid(musicInfo.uid); if (album == null) return false; - var save = SaveManager.SaveData.Highest[album.AlbumName][mapDifficulty]; + var save = SaveManager.SaveData.Highest.GetValueOrDefault(album.AlbumName)?.GetValueOrDefault(mapDifficulty); if (save == null) return false; __instance.RefreshRecord(musicInfo, mapDifficulty, save.Score, save.Combo, save.AccuracyStr, save.Evaluate, save.Clear.ToString(CultureInfo.InvariantCulture), curMusicBestRankOrder); @@ -64,13 +64,7 @@ private static void Postfix(PnlPreparation __instance) return; } - if (!chart.ContainsKey(mapDifficulty)) - { - gameObject.SetActive(false); - return; - } - - gameObject.SetActive(true); + gameObject.SetActive(chart.ContainsKey(mapDifficulty)); } } } diff --git a/Utilities/DataExtensions.cs b/Utilities/DataExtensions.cs index d20e1bb..6d7f7f3 100644 --- a/Utilities/DataExtensions.cs +++ b/Utilities/DataExtensions.cs @@ -26,11 +26,12 @@ public static string GetUid(this IData data) public static int GetIndexByUid(this Il2CppSystem.Collections.Generic.List dataList, string uid, int difficulty) { var i = 0; + var fullUid = $"{uid}_{difficulty}"; // For loop doesn't work here foreach (var data in dataList) { - if (data.GetUid() == $"{uid}_{difficulty}") + if (data.GetUid() == fullUid) { return i; } @@ -50,9 +51,11 @@ public static int GetIndexByUid(this Il2CppSystem.Collections.Generic.ListThe IData object, or null if not found. public static IData GetIDataByUid(this Il2CppSystem.Collections.Generic.List dataList, string uid, int difficulty) { + var fullUid = $"{uid}_{difficulty}"; + foreach (var data in dataList) { - if (data.GetUid() == $"{uid}_{difficulty}") + if (data.GetUid() == fullUid) { return data; } diff --git a/Utilities/PackExtensions.cs b/Utilities/PackExtensions.cs index e82fcb1..8f78376 100644 --- a/Utilities/PackExtensions.cs +++ b/Utilities/PackExtensions.cs @@ -12,9 +12,9 @@ internal static class PackExtensions private static readonly Logger Logger = new(nameof(PackExtensions)); public static ZipArchive GetNestedZip(this ZipArchive mdp, string entryName) { - var mdm = mdp.GetEntry(entryName); - using var mdmStream = mdm.Open(); - var openedMdm = new ZipArchive(mdmStream); + var mdm = mdp.GetEntry(entryName) ?? throw new ArgumentException($"Entry {entryName} not found."); + var mdmStream = mdm.Open(); + var openedMdm = new ZipArchive(mdmStream, ZipArchiveMode.Read, false); return openedMdm; } } From b16ea91d08aee51d19ec62a27337cac17762424e Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Fri, 11 Jul 2025 23:49:15 -0500 Subject: [PATCH 07/27] Additional properties and methods for Packs --- Data/Pack.cs | 41 ++++++++++++++++++++++++++++++++++++++++ Managers/AlbumManager.cs | 2 +- Managers/PackManager.cs | 10 ++++++---- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/Data/Pack.cs b/Data/Pack.cs index b6957c7..e5fc2cc 100644 --- a/Data/Pack.cs +++ b/Data/Pack.cs @@ -1,4 +1,6 @@ using CustomAlbums.Managers; +using CustomAlbums.Utilities; +using System.IO.Compression; namespace CustomAlbums.Data { @@ -8,7 +10,46 @@ public class Pack public string TitleColorHex { get; set; } = "#ffffff"; public bool LongTextScroll { get; set; } = false; + public string Path { get; set; } = string.Empty; + internal int StartIndex; internal int Length; + + public bool HasFile(string name) + { + if (string.IsNullOrEmpty(Path)) return false; + + try + { + using var zip = ZipFile.OpenRead(Path); + return zip.GetEntry(name) != null; + } + catch + { + return false; + } + } + + public Stream OpenNullableStream(string file) + { + if (string.IsNullOrEmpty(Path)) return null; + + try + { + using var zip = ZipFile.OpenRead(Path); + var entry = zip.GetEntry(file); + + if (entry != null) + { + return entry.Open().ToMemoryStream(); + } + + return null; + } + catch + { + return null; + } + } } } diff --git a/Managers/AlbumManager.cs b/Managers/AlbumManager.cs index ef04daa..5f90030 100644 --- a/Managers/AlbumManager.cs +++ b/Managers/AlbumManager.cs @@ -49,7 +49,7 @@ public static void LoadPack(string directory) var json = zipFiles.Entries.FirstOrDefault(file => file.Name.EndsWith(".json")); // Initialize pack and variables - var pack = PackManager.CreatePack(json); + var pack = PackManager.CreatePack(json, directory); CurrentPack = pack.Title; MaxCount = Math.Max(LoadedAlbums.Count, MaxCount); diff --git a/Managers/PackManager.cs b/Managers/PackManager.cs index 3732ad8..1f338b1 100644 --- a/Managers/PackManager.cs +++ b/Managers/PackManager.cs @@ -4,10 +4,10 @@ namespace CustomAlbums.Managers { - internal class PackManager + public class PackManager { private static readonly List Packs = new(); - internal static Pack GetPackFromUid(string uid) + public static Pack GetPackFromUid(string uid) { // If the uid is not custom or parsing the index fails if (!uid.StartsWith($"{AlbumManager.Uid}-") || @@ -21,9 +21,11 @@ internal static Pack GetPackFromUid(string uid) return retrievedPack?.Length is 0 ? null : retrievedPack; } - internal static Pack CreatePack(ZipArchiveEntry json) + internal static Pack CreatePack(ZipArchiveEntry json, string path) { - return Json.Deserialize(json.Open()); + var pack = Json.Deserialize(json.Open()); + pack.Path = path; + return pack; } internal static void AddPack(Pack pack) From 7a060a20e8bbf398dc49e4d5f43c8ec58991ef16 Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Mon, 11 Aug 2025 19:41:12 -0500 Subject: [PATCH 08/27] Update dependencies --- CustomAlbums.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CustomAlbums.csproj b/CustomAlbums.csproj index d7f825e..7a4d11f 100644 --- a/CustomAlbums.csproj +++ b/CustomAlbums.csproj @@ -20,7 +20,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -28,7 +28,7 @@ contentFiles - + From ed5b9e36a4b0e0b92af8fc42b256492aca20d816 Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:12:08 -0500 Subject: [PATCH 09/27] Change accessibility of SaveData --- Managers/SaveManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Managers/SaveManager.cs b/Managers/SaveManager.cs index 7d53de0..a45ed96 100644 --- a/Managers/SaveManager.cs +++ b/Managers/SaveManager.cs @@ -6,10 +6,10 @@ namespace CustomAlbums.Managers { - internal class SaveManager + public class SaveManager { private const string SaveLocation = "UserData"; - internal static CustomAlbumsSave SaveData; + public static CustomAlbumsSave SaveData { get; private set; } internal static Logger Logger = new(nameof(SaveManager)); internal static string PreviousScore { get; set; } = "-"; From 0dd8b1999e5e3934f1dc331e9f685dc6b858ee33 Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:15:53 -0500 Subject: [PATCH 10/27] Have packs store included albums, make LoadPack return the loaded pack --- Data/Pack.cs | 1 + Managers/AlbumManager.cs | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Data/Pack.cs b/Data/Pack.cs index e5fc2cc..9507ca3 100644 --- a/Data/Pack.cs +++ b/Data/Pack.cs @@ -11,6 +11,7 @@ public class Pack public bool LongTextScroll { get; set; } = false; public string Path { get; set; } = string.Empty; + public List Albums { get; set; } = new(); internal int StartIndex; internal int Length; diff --git a/Managers/AlbumManager.cs b/Managers/AlbumManager.cs index 5f90030..9da5b36 100644 --- a/Managers/AlbumManager.cs +++ b/Managers/AlbumManager.cs @@ -37,7 +37,7 @@ public static class AlbumManager public static Dictionary LoadedAlbums { get; } = new(); - public static void LoadPack(string directory) + public static Pack LoadPack(string directory) { // Get the files from the directory try @@ -57,11 +57,22 @@ public static void LoadPack(string directory) pack.StartIndex = MaxCount; // Count successfully loaded .mdm files - pack.Length = mdms.Count(mdm => LoadOne(directory, mdm, mdm.Name) != null); + pack.Length = mdms.Count(mdm => + { + var album = LoadOne(directory, mdm, mdm.FullName); + if (album is not null) + { + pack.Albums.Add(album); + return true; + } + return false; + }); // Set the current pack to null and add the pack to the pack list CurrentPack = null; PackManager.AddPack(pack); + + return pack; } catch (Exception ex) { From dd922a328d65180e5a516e168718b3d3f36b81a3 Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:22:51 -0500 Subject: [PATCH 11/27] Fix return on exception in LoadPack --- Managers/AlbumManager.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Managers/AlbumManager.cs b/Managers/AlbumManager.cs index 9da5b36..f4d5e98 100644 --- a/Managers/AlbumManager.cs +++ b/Managers/AlbumManager.cs @@ -78,6 +78,8 @@ public static Pack LoadPack(string directory) { Logger.Warning($"Failed to load album at {directory}. Reason: {ex.Message}"); Logger.Warning(ex.StackTrace); + + return null; } } From c2084d9ef5634a59340771a43ff75b6d094d5cca Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Sat, 6 Jun 2026 14:44:14 -0500 Subject: [PATCH 12/27] Improve Bms and BmsLoader --- BmsLoader.cs | 450 +++++++++++++++++++----------- Data/Bms.cs | 80 +++--- Data/BmsInfo.cs | 44 +++ Data/BmsTypes.cs | 19 ++ Data/Sheet.cs | 4 +- Utilities/ConfigDataExtensions.cs | 20 +- 6 files changed, 398 insertions(+), 219 deletions(-) create mode 100644 Data/BmsInfo.cs create mode 100644 Data/BmsTypes.cs diff --git a/BmsLoader.cs b/BmsLoader.cs index 65b875a..04e1547 100644 --- a/BmsLoader.cs +++ b/BmsLoader.cs @@ -1,4 +1,3 @@ -using System.Text.Json.Nodes; using CustomAlbums.Data; using CustomAlbums.Managers; using CustomAlbums.Utilities; @@ -12,6 +11,7 @@ using Il2CppPeroPeroGames.GlobalDefines; using Il2CppPeroTools2.Resources; using Il2CppSpine.Unity; +using System.Diagnostics; using UnityEngine; using static CustomAlbums.Data.BmsStates; using Animation = Il2CppSpine.Animation; @@ -27,21 +27,26 @@ internal static class BmsLoader private static Decimal _delay; private static readonly Logger Logger = new(nameof(BmsLoader)); + private static readonly Dictionary StartDelayCache = new(); + private static readonly Dictionary<(string scene, string animation), float> AnimationDurationCache = new(); + + private static readonly HashSet SceneAgnosticPrefixes = new() { "00", "em", "bo" }; + /// /// Creates a Bms object from a BMS file. /// /// MemoryStream of BMS file. /// Name of BMS score. - /// Loaded Bms object. + /// Loaded Bms object, or null if critical data is missing. internal static Bms Load(Stream stream, string bmsName) { Logger.Msg($"Loading bms {bmsName}..."); var bpmDict = new Dictionary(); - var notePercents = new Dictionary(); - var dataList = new List(); - var notesArray = new JsonArray(); - var info = new JsonObject(); + var timeSigEntries = new Dictionary(); + var bpmEntries = new List(); + var rawNotes = new List(); + var info = new BmsInfo(); using var streamReader = new StreamReader(stream); while (streamReader.ReadLine()?.Trim() is { } line) @@ -54,50 +59,58 @@ internal static Bms Load(Stream stream, string bmsName) if (line.Contains(' ')) { // Parse header - var split = line.Split(' '); - var key = split[0]; - var value = split[1]; - - info[key] = value; + var spaceIndex = line.IndexOf(' '); + var key = line[..spaceIndex]; + var value = line[(spaceIndex + 1)..]; - if (!key.Contains("BPM")) continue; + // Skip WAV definition + if (key.StartsWith("WAV")) continue; - var bpmKey = string.IsNullOrEmpty(key[3..]) ? "00" : key[3..]; - bpmDict.Add(bpmKey, value.ParseAsFloat()); - - if (bpmKey != "00") continue; - - var freq = 60f / value.ParseAsFloat() * 4f; - var obj = new JsonObject + // Map known headers to typed properties + if (!ParseHeader(info, key, value, bpmDict, bpmEntries)) { - { "tick", 0f }, - { "freq", freq } - }; - dataList.Add(obj); + // Store unrecognized headers in fallback dictionary + info.Extra[key] = value; + } } else if (line.Contains(':')) { // Parse data field - var split = line.Split(':'); - var key = split[0]; - var value = split[1]; + var colonIndex = line.IndexOf(':'); + if (colonIndex < 5) + { + Logger.Warning($"Malformed data line (key too short): #{line}"); + continue; + } + + var key = line[..colonIndex]; + var value = line[(colonIndex + 1)..]; + + if (!key[..3].TryParseAsInt(out var beat)) + { + Logger.Warning($"Malformed measure number in: #{line}"); + continue; + } - var beat = key[..3].ParseAsInt(); var typeCode = key.Substring(3, 2); - + if (!Bms.Channels.TryGetValue(typeCode, out var type)) continue; if (type is Bms.ChannelType.SpTimesig) { - var obj = new JsonObject - { - { "beat", beat }, - { "percent", value.ParseAsFloat() } - }; - notePercents.Add(beat, obj); + if (value.TryParseAsFloat(out var percent)) + timeSigEntries[beat] = new TimeSigEntry(beat, percent); + else + Logger.Warning($"Invalid time signature value: {value}"); } else { + if (value.Length % 2 != 0) + { + Logger.Warning($"Odd-length data field (must be pairs of 2 chars): #{line}"); + continue; + } + var objLength = value.Length / 2; for (var i = 0; i < objLength; i++) { @@ -109,122 +122,203 @@ internal static Bms Load(Stream stream, string bmsName) if (type is Bms.ChannelType.SpBpmDirect or Bms.ChannelType.SpBpmLookup) { // Handle BPM changes - var freqDivide = type == Bms.ChannelType.SpBpmLookup && - bpmDict.TryGetValue(note, out var bpm) - ? bpm - : Convert.ToInt32(note, 16); - var freq = 60f / freqDivide * 4f; - - var obj = new JsonObject - { - { "tick", tick }, - { "freq", freq } - }; - dataList.Add(obj); - dataList.Sort((l, r) => + float freqDivide; + if (type == Bms.ChannelType.SpBpmLookup && bpmDict.TryGetValue(note, out var bpm)) { - var tickL = l["tick"].GetValue(); - var tickR = r["tick"].GetValue(); - - return tickR.CompareTo(tickL); - }); - } - else - { - // Parse other note data - var time = 0f; // num3 - var totalOffset = 0f; // num4 - - var data = dataList.FindAll(d => d["tick"].GetValue() < tick); - for (var j = data.Count - 1; j >= 0; j--) + freqDivide = bpm; + } + else { - var obj = data[j]; - var offset = 0f; // num5 - var freq = obj["freq"].GetValue(); // num6 - - if (j - 1 >= 0) + try { - var prevObj = data[j - 1]; - offset = prevObj["tick"].GetValue() - obj["tick"].GetValue(); + freqDivide = Convert.ToInt32(note, 16); } - - if (j == 0) offset = tick - obj["tick"].GetValue(); - - var localOffset = totalOffset; // num7 - totalOffset += offset; - var floorOffset = Mathf.FloorToInt(localOffset); // num8 - var ceilOffset = Mathf.CeilToInt(totalOffset); // num9 - - for (var k = floorOffset; k < ceilOffset; k++) + catch (FormatException) { - var off = 1f; // num10 - - if (k == floorOffset) - off = k + 1 - localOffset; - if (k == ceilOffset - 1) - off = totalOffset - (ceilOffset - 1); - if (ceilOffset == floorOffset + 1) - off = totalOffset - localOffset; - - notePercents.TryGetValue(k, out var node); - var percent = node?["percent"].GetValue() ?? 1f; - time += Mathf.RoundToInt(off * percent * freq / 1E-06f) * 1E-06F; + Logger.Warning($"Invalid hex BPM value: {note}"); + continue; } } - var noteObj = new JsonObject - { - { "time", time }, - { "value", note }, - { "tone", typeCode } - }; - notesArray.Add(noteObj); + var freq = 60f / freqDivide * 4f; + bpmEntries.Add(new BpmEntry(tick, freq)); + } + else + { + var noteObj = new RawNote( + Time: CalculateNoteTime(tick, bpmEntries, timeSigEntries), + Value: note, + Tone: typeCode + ); + rawNotes.Add(noteObj); } } } } } - var list = notesArray.ToList(); - list.Sort((l, r) => - { - var lTime = l["time"]!.GetValue(); - var rTime = r["time"]!.GetValue(); - var lTone = l["tone"]!.GetValue(); - var rTone = r["tone"]!.GetValue(); + // Validate required headers + if (string.IsNullOrEmpty(info.Genre)) + Logger.Warning($"BMS '{bmsName}' is missing required GENRE header."); + if (info.Bpm <= 0) + Logger.Warning($"BMS '{bmsName}' has no valid BPM."); + // Sort notes by time, with events (channel "15") sorted before notes at the same timestamp + rawNotes.Sort((l, r) => + { // Accurate for note sorting up to 6 decimal places - var lScore = (long)(lTime * 1000000) * 10 + (lTone == "15" ? 0 : 1); - var rScore = (long)(rTime * 1000000) * 10 + (rTone == "15" ? 0 : 1); - - return Math.Sign(lScore - rScore); + var lScore = (long)(l.Time * 1000000) * 10 + (l.Tone == "15" ? 0 : 1); + var rScore = (long)(r.Time * 1000000) * 10 + (r.Tone == "15" ? 0 : 1); + return lScore.CompareTo(rScore); }); - notesArray.Clear(); - list.ForEach(notesArray.Add); - - var percentsArray = new JsonArray(); - notePercents.Values.ToList().ForEach(percentsArray.Add); var bms = new Bms { Info = info, - Notes = notesArray, - NotesPercent = percentsArray, + Notes = rawNotes, + NotesPercent = timeSigEntries.Values.ToList(), Md5 = stream.GetHash() }; - bms.Info["NAME"] = bmsName; - bms.Info["NEW"] = true; + bms.Info.Name = bmsName; - if (bms.Info.TryGetPropertyValue("BANNER", out var banner)) - bms.Info["BANNER"] = "cover/" + banner; - else - bms.Info["BANNER"] = "cover/none_cover.png"; + if (!string.IsNullOrEmpty(info.Banner)) + bms.Info.Banner = "cover/" + info.Banner; Logger.Msg($"Loaded bms {bmsName}."); return bms; } + /// + /// Parses a known BMS header into the object. + /// + /// true if the header was recognized and parsed, false otherwise. + private static bool ParseHeader(BmsInfo info, string key, string value, + Dictionary bpmDict, List bpmEntries) + { + // Handle BPMxx headers (BPM, BPM01, BPM02, etc.) + if (key.StartsWith("BPM")) + { + if (!value.TryParseAsFloat(out var bpmValue)) + { + Logger.Warning($"Invalid BPM value: {value}"); + return true; // Recognized but invalid + } + + var bpmKey = key.Length > 3 ? key[3..] : "00"; + bpmDict[bpmKey] = bpmValue; + + if (bpmKey == "00") + { + info.Bpm = bpmValue; + var freq = 60f / bpmValue * 4f; + bpmEntries.Add(new BpmEntry(0f, freq)); + } + + return true; + } + + switch (key) + { + case "PLAYER": + if (value.TryParseAsInt(out var player)) info.Player = player; + return true; + case "GENRE": + info.Genre = value; + return true; + case "TITLE": + info.Title = value; + return true; + case "ARTIST": + info.Artist = value; + return true; + case "LEVELDESIGN": + info.LevelDesign = value; + return true; + case "BANNER": + info.Banner = value; + return true; + case "RANK": + if (value.TryParseAsInt(out var rank)) info.Rank = rank; + return true; + case "LNTYPE": + if (value.TryParseAsInt(out var lnType)) info.LnType = lnType; + return true; + case "PLAYLEVEL": + info.PlayLevel = value; + return true; + default: + return false; + } + } + + /// + /// Calculates the absolute time (in seconds) for a note at a given tick position, + /// accounting for BPM changes and time signature changes. + /// Reverse-engineered from the official game's BMS-to-time conversion. + /// + /// The beat position of the note. + /// All BPM change entries parsed so far, in insertion order. + /// Time signature changes by measure number. + /// Absolute time in seconds. + private static float CalculateNoteTime(float tick, List bpmEntries, + Dictionary timeSigEntries) + { + // Build a sorted (ascending by tick) list of BPM entries that occur before this note + // We must sort because BPM entries may have been added in any order from the BMS data + var relevantBpms = new List(); + foreach (var entry in bpmEntries) + { + if (entry.Tick < tick) + relevantBpms.Add(entry); + } + + if (relevantBpms.Count == 0) return 0f; + relevantBpms.Sort(); + + var accumulatedTime = 0f; + var totalBeatOffset = 0f; + + // Walk through BPM segments from earliest to latest + for (var j = 0; j < relevantBpms.Count; j++) + { + var entry = relevantBpms[j]; + var beatFrequency = entry.Freq; + + // Calculate how many beats this segment covers + float segmentBeats; + if (j < relevantBpms.Count - 1) + segmentBeats = relevantBpms[j + 1].Tick - entry.Tick; + else + segmentBeats = tick - entry.Tick; + + var previousOffset = totalBeatOffset; + totalBeatOffset += segmentBeats; + var floorBeat = Mathf.FloorToInt(previousOffset); + var ceilBeat = Mathf.CeilToInt(totalBeatOffset); + + for (var k = floorBeat; k < ceilBeat; k++) + { + var beatFraction = 1f; + + if (k == floorBeat) + beatFraction = k + 1 - previousOffset; + if (k == ceilBeat - 1) + beatFraction = totalBeatOffset - (ceilBeat - 1); + if (ceilBeat == floorBeat + 1) + beatFraction = totalBeatOffset - previousOffset; + + var timeSigMultiplier = timeSigEntries.TryGetValue(k, out var timeSig) + ? timeSig.Percent + : 1f; + accumulatedTime += + Mathf.RoundToInt(beatFraction * timeSigMultiplier * beatFrequency / 1E-06f) * 1E-06F; + } + } + + return accumulatedTime; + } + /// /// Transmutes Bms data into StageInfo data. /// @@ -232,6 +326,8 @@ internal static Bms Load(Stream stream, string bmsName) /// The transmuted StageInfo object. internal static StageInfo TransmuteData(Bms bms) { + var stopwatch = Stopwatch.StartNew(); + if (NoteData.Count == 0) InitNoteData(); MusicDataManager.Clear(); _delay = 0; @@ -264,6 +360,9 @@ internal static StageInfo TransmuteData(Bms bms) stageInfo.musicDatas.Add(musicData); stageInfo.delay = _delay; + stopwatch.Stop(); + Logger.Msg($"Transmuted BMS in {stopwatch.Elapsed}", false); + MusicDataManager.Clear(); return stageInfo; } @@ -283,10 +382,10 @@ private static void InitNoteData() } } - private static void LoadMusicData(JsonArray noteData) + private static void LoadMusicData(List noteData) { short noteId = 1; - foreach (var node in noteData) + foreach (var note in noteData) { if (noteId == short.MaxValue) { @@ -295,7 +394,7 @@ private static void LoadMusicData(JsonArray noteData) break; } - var configData = node.ToMusicConfigData(); + var configData = note.ToMusicConfigData(); if (configData.time < 0) continue; // Create a new note for each configData @@ -349,7 +448,7 @@ private static void LoadMusicData(JsonArray noteData) private static void ProcessBossData(Bms bms) { - var scene = bms.Info["GENRE"]?.GetValue() ?? string.Empty; + var scene = bms.Info.Genre; var bossData = MusicDataManager.Data.Where(mData => mData.isBossNote).ToList(); // If the boss is not used for some reason, no need to process animations. @@ -389,8 +488,8 @@ private static void ProcessBossData(Bms bms) } // Fix incorrect phase gears - var phaseGearConfig = Interop.CreateTypeValue(); - phaseGearConfig.ibms_id = ""; + NoteConfigData cachedGearConfig = null; + var allNoteData = SingletonScriptableObject.instance.noteDatas; for (var i = 0; i < bossData.Count; i++) { @@ -399,9 +498,7 @@ private static void ProcessBossData(Bms bms) if (data.noteData.GetNoteType() != NoteType.Block) continue; // Find the next boss animation that is not a gear - var bossAnimAhead = Interop.CreateTypeValue(); - bossAnimAhead.configData.time = Decimal.MinValue; - + MusicData bossAnimAhead = null; for (var j = i + 1; j < bossData.Count; j++) { var dataAhead = bossData[j]; @@ -411,25 +508,23 @@ private static void ProcessBossData(Bms bms) break; } - MusicData bossAnimBefore; - if (i > 0) - { - bossAnimBefore = bossData[i - 1]; - } - else - { - bossAnimBefore = Interop.CreateTypeValue(); - bossAnimBefore.configData.time = Decimal.MinValue; - } + MusicData bossAnimBefore = i > 0 ? bossData[i - 1] : null; + + var aheadTime = bossAnimAhead?.configData.time ?? Decimal.MinValue; + var beforeTime = bossAnimBefore?.configData.time ?? Decimal.MinValue; - var diffToAhead = Math.Abs((float)data.configData.time - (float)bossAnimAhead.configData.time); - var diffToBefore = Math.Abs((float)data.configData.time - (float)bossAnimBefore.configData.time); + var diffToAhead = Math.Abs((float)data.configData.time - (float)aheadTime); + var diffToBefore = Math.Abs((float)data.configData.time - (float)beforeTime); var ahead = diffToAhead < diffToBefore; - var stateBehind = i > 0 ? AnimStatesRight[bossAnimBefore.noteData.boss_action] : BossState.OffScreen; - var stateAhead = AnimStatesLeft.TryGetValue(bossAnimAhead.noteData.boss_action, out var state) - ? state + var stateBehind = bossAnimBefore != null + ? AnimStatesRight[bossAnimBefore.noteData.boss_action] : BossState.OffScreen; + + var stateAhead = BossState.OffScreen; + if (bossAnimAhead != null && AnimStatesLeft.TryGetValue(bossAnimAhead.noteData.boss_action, out var parsedState)) + stateAhead = parsedState; + var usedState = ahead ? stateAhead : stateBehind; var correctState = usedState is BossState.Phase1 or BossState.Phase2; if (!correctState) @@ -448,13 +543,14 @@ private static void ProcessBossData(Bms bms) var phase = usedState == BossState.Phase1 ? 1 : 2; - var noteData = SingletonScriptableObject.instance.noteDatas; - if (phaseGearConfig.ibms_id != data.noteData.ibms_id - || phaseGearConfig.pathway != data.noteData.pathway - || phaseGearConfig.scene != data.noteData.scene - || phaseGearConfig.speed != data.noteData.speed - || !phaseGearConfig.boss_action.StartsWith($"boss_far_atk_{phase}")) - foreach (var d in noteData) + // Use cached gear config if it matches, otherwise search for a new one + if (cachedGearConfig == null + || cachedGearConfig.ibms_id != data.noteData.ibms_id + || cachedGearConfig.pathway != data.noteData.pathway + || cachedGearConfig.scene != data.noteData.scene + || cachedGearConfig.speed != data.noteData.speed + || !cachedGearConfig.boss_action.StartsWith($"boss_far_atk_{phase}")) + foreach (var d in allNoteData) { if (d.ibms_id != data.noteData.ibms_id || d.pathway != data.noteData.pathway @@ -462,10 +558,13 @@ private static void ProcessBossData(Bms bms) || d.speed != data.noteData.speed || !d.boss_action.StartsWith($"boss_far_atk_{phase}")) continue; - phaseGearConfig = d; + cachedGearConfig = d; break; } + if (cachedGearConfig == null) continue; + var phaseGearConfig = cachedGearConfig; + var fixedConfigData = Interop.CreateTypeValue(); fixedConfigData.blood = data.configData.blood; fixedConfigData.id = data.configData.id; @@ -545,7 +644,7 @@ private static void ProcessBossData(Bms bms) private static void ProcessDelay(Bms bms) { - var scene = bms.Info["GENRE"]?.GetValue() ?? string.Empty; + var scene = bms.Info.Genre; var sceneIndex = scene.Split('_')[1].ParseAsInt(); var sceneInfo = Singleton.instance.sceneInfo; var delayCache = new Dictionary(); @@ -565,7 +664,7 @@ private static void ProcessDelay(Bms bms) if (type != NoteType.Hp && type != NoteType.Music) { var prefix = prefabName[..2]; - if (!new[] { "00", "em", "bo" }.Contains(prefix)) + if (prefix is not "00" and not "em" and not "bo") prefabName = prefabName.Remove(0, 2).Insert(0, $"{sceneIndex:D2}"); } @@ -612,14 +711,14 @@ private static void ProcessGeminis() if (geminiCache.TryGetValue(mData.tick, out var geminiList)) { - var isNoteGemini = Bms.BmsIds[mData.noteData.ibms_id ?? "00"] == Bms.BmsId.Gemini; + var isNoteGemini = Bms.BmsIds[mData.noteData.ibms_id] == Bms.BmsId.Gemini; var isTargetGemini = false; var target = Interop.CreateTypeValue(); foreach (var gemini in geminiList.Where(gemini => mData.isAir != gemini.isAir)) { target = gemini; - isTargetGemini = Bms.BmsIds[gemini.noteData.ibms_id ?? "00"] == Bms.BmsId.Gemini; + isTargetGemini = Bms.BmsIds[gemini.noteData.ibms_id] == Bms.BmsId.Gemini; if (isNoteGemini && isTargetGemini) break; if (!isNoteGemini) break; @@ -649,25 +748,40 @@ private static void ProcessGeminis() private static float GetStartDelay(string prefab) { - return ResourcesManager.instance.LoadFromName(prefab).GetComponent() - .startDelay; + if (StartDelayCache.TryGetValue(prefab, out var cached)) + return cached; + + var delay = ResourcesManager.instance.LoadFromName(prefab) + .GetComponent().startDelay; + StartDelayCache[prefab] = delay; + return delay; } private static float GetAnimationDuration(string scene, string animation) { - var controller = ResourcesManager.instance.LoadFromName(Boss.Instance.BossFestival($"{scene.Split("_")[1]}01_boss")) - .GetComponent(); + var cacheKey = (scene, animation); + if (AnimationDurationCache.TryGetValue(cacheKey, out var cached)) + return cached; + + var controller = ResourcesManager.instance + .LoadFromName(Boss.Instance.BossFestival($"{scene.Split("_")[1]}01_boss")) + .GetComponent(); var animations = controller.gameObject.GetComponent().skeletonDataAsset .GetSkeletonData(true).Animations; - - var arr = new SkeletActionData[controller.actionData.Count]; - controller.actionData.CopyTo(arr, 0); - var actionData = new List(arr).Find(dd => dd.name == animation); - + var animName = animation; - if (actionData is { actionIdx: not null } && actionData.actionIdx.Length != 0) - animName = actionData.actionIdx[0]; - return animations.Find((Il2CppSystem.Predicate)((Animation a) => a.Name == animName)).Duration; + foreach (var actionData in controller.actionData) + { + if (actionData.name != animation) continue; + if (actionData.actionIdx is { Length: > 0 }) + animName = actionData.actionIdx[0]; + break; + } + + var duration = animations + .Find((Il2CppSystem.Predicate)((Animation a) => a.Name == animName)).Duration; + AnimationDurationCache[cacheKey] = duration; + return duration; } } } \ No newline at end of file diff --git a/Data/Bms.cs b/Data/Bms.cs index 17fc36b..85a6053 100644 --- a/Data/Bms.cs +++ b/Data/Bms.cs @@ -1,9 +1,9 @@ -using System.Text.Json.Nodes; using CustomAlbums.Utilities; using Il2CppAssets.Scripts.GameCore.Managers; using Il2CppAssets.Scripts.PeroTools.Commons; using Il2CppGameLogic; using Il2CppPeroPeroGames.GlobalDefines; +using Decimal = Il2CppSystem.Decimal; namespace CustomAlbums.Data { @@ -306,20 +306,13 @@ public enum ChannelType ["3F"] = BmsId.BossBullet2LaneShift }; - public JsonObject Info { get; set; } - public JsonArray Notes { get; set; } - public JsonArray NotesPercent { get; set; } + public BmsInfo Info { get; set; } + public List Notes { get; set; } + public List NotesPercent { get; set; } public string Md5 { get; set; } public Dictionary NoteData { get; set; } - public float Bpm - { - get - { - var bpmString = Info["BPM"]?.GetValue() ?? Info["BPM01"]?.GetValue() ?? string.Empty; - return bpmString.TryParseAsFloat(out var bpm) ? bpm : 0f; - } - } + public float Bpm => Info.Bpm; public static string GetNoteDataKey(string bmsId, int pathway, int speed, string scene) { @@ -384,48 +377,52 @@ public Il2CppSystem.Collections.Generic.List GetSceneEvents() foreach (var note in Notes) { - var bmsKey = note["value"]?.GetValue() ?? string.Empty; - if (string.IsNullOrEmpty(bmsKey)) continue; + if (string.IsNullOrEmpty(note.Value)) continue; - var channelTone = note["tone"]?.GetValue() ?? string.Empty; - if (!Channels.TryGetValue(channelTone, out var channel)) continue; + if (!Channels.TryGetValue(note.Tone, out var channel)) continue; if (channel.HasFlag(ChannelType.Scene)) sceneEvents.Add(new SceneEvent { - time = note["time"].GetValueAsIl2CppDecimal(), - uid = $"SceneEvent/{bmsKey}" + time = (Decimal)(float)(decimal)note.Time, + uid = $"SceneEvent/{note.Value}" }); else if (channel.HasFlag(ChannelType.SpBpmDirect) || channel.HasFlag(ChannelType.SpBpmLookup)) sceneEvents.Add(new SceneEvent { - time = note["time"].GetValueAsIl2CppDecimal(), + time = (Decimal)(float)(decimal)note.Time, uid = "SceneEvent/OnBPMChanged", - value = note["value"]?.GetValue() ?? string.Empty + value = note.Value }); } return sceneEvents; } - public JsonArray GetNoteData() + public List GetNoteData() { if (NoteData is null || NoteData.Count == 0) InitNoteData(); - var processed = new JsonArray(); + var processed = new List(); - var speedAir = (Info["PLAYER"]?.GetValue() ?? "1").ParseAsInt(); + var speedAir = Info.Player; var speedGround = speedAir; var objectId = 1; + // Track which note indices have been consumed as hold-note endpoints + var consumedIndices = new HashSet(); + for (var i = 0; i < Notes.Count; i++) { + if (consumedIndices.Contains(i)) continue; + var note = Notes[i]; - if (note is null) continue; - var bmsKey = note["value"]?.GetValue() ?? "00"; + var bmsKey = note.Value; + if (string.IsNullOrEmpty(bmsKey)) continue; + var bmsId = BmsIds.GetValueOrDefault(bmsKey, BmsId.None); - var channel = note["tone"]?.GetValue() ?? string.Empty; + var channel = note.Tone; var channelType = Channels.GetValueOrDefault(channel, ChannelType.None); // Handle lane type @@ -475,12 +472,12 @@ public JsonArray GetNoteData() } var speed = pathway == 1 ? speedAir : speedGround; - var scene = Info["GENRE"]?.GetValue(); + var scene = Info.Genre; if (!NoteData!.TryGetValue(GetNoteDataKey(bmsKey, pathway, speed, scene), out var configData)) continue; - var time = note["time"]?.GetValueAsDecimal() ?? 0M; + var time = (decimal)note.Time; // Hold note & masher var holdLength = 0M; @@ -492,27 +489,28 @@ public JsonArray GetNoteData() else for (var j = i + 1; j < Notes.Count; j++) { + if (consumedIndices.Contains(j)) continue; + var holdEndNote = Notes[j]; - var holdEndTime = holdEndNote?["time"]?.GetValueAsDecimal() ?? 0M; - var holdEndBmsKey = holdEndNote?["value"]?.GetValue() ?? string.Empty; - var holdEndChannel = holdEndNote?["tone"]?.GetValue() ?? string.Empty; + var holdEndTime = (decimal)holdEndNote.Time; + var holdEndBmsKey = holdEndNote.Value; + var holdEndChannel = holdEndNote.Tone; if (holdEndBmsKey != bmsKey || holdEndChannel != channel) continue; holdLength = holdEndTime - time; - Notes[j]!["value"] = ""; + consumedIndices.Add(j); break; } } - processed.Add(new JsonObject - { - ["id"] = objectId++, - ["time"] = time, - ["note_uid"] = configData.uid, - ["length"] = holdLength, - ["pathway"] = pathway, - ["blood"] = !isHold && channelType.HasFlag(ChannelType.SpBlood) - }); + processed.Add(new ProcessedNote( + Id: objectId++, + Time: time, + NoteUid: configData.uid, + Length: holdLength, + Pathway: pathway, + Blood: !isHold && channelType.HasFlag(ChannelType.SpBlood) + )); } return processed; diff --git a/Data/BmsInfo.cs b/Data/BmsInfo.cs new file mode 100644 index 0000000..6bd54c0 --- /dev/null +++ b/Data/BmsInfo.cs @@ -0,0 +1,44 @@ +namespace CustomAlbums.Data +{ + public class BmsInfo + { + /// BMS chart title (e.g. song name). Parsed from #TITLE header. + public string Title { get; set; } = string.Empty; + + /// Artist name. Parsed from #ARTIST header. + public string Artist { get; set; } = string.Empty; + + /// Scene identifier (e.g. "scene_08"). Parsed from #GENRE header. + public string Genre { get; set; } = string.Empty; + + /// Banner image path. Parsed from #BANNER header, prefixed with "cover/". + public string Banner { get; set; } = "cover/none_cover.png"; + + /// Chart name. Set by the loader, not from a BMS header. + public string Name { get; set; } = string.Empty; + + /// Player/speed lane mode. Parsed from #PLAYER header. Defaults to 1. + public int Player { get; set; } = 1; + + /// Base BPM of the chart. Parsed from #BPM header. + public float Bpm { get; set; } + + /// Difficulty rank (1-4). Parsed from #RANK header. + public int Rank { get; set; } + + /// Long note type. Parsed from #LNTYPE header. + public int LnType { get; set; } + + /// Level designer name. Parsed from #LEVELDESIGN header. + public string LevelDesign { get; set; } = string.Empty; + + /// Play level. Parsed from #PLAYLEVEL header. + public string PlayLevel { get; set; } = string.Empty; + + /// + /// Fallback storage for any unrecognized BMS headers. + /// Key is the header name (without #), value is the full string value. + /// + public Dictionary Extra { get; } = new(); + } +} diff --git a/Data/BmsTypes.cs b/Data/BmsTypes.cs new file mode 100644 index 0000000..37c25d1 --- /dev/null +++ b/Data/BmsTypes.cs @@ -0,0 +1,19 @@ +namespace CustomAlbums.Data +{ + public readonly record struct BpmEntry(float Tick, float Freq) : IComparable + { + public int CompareTo(BpmEntry other) => Tick.CompareTo(other.Tick); + } + + public readonly record struct TimeSigEntry(int Beat, float Percent); + + public readonly record struct RawNote(float Time, string Value, string Tone); + + public readonly record struct ProcessedNote( + int Id, + decimal Time, + string NoteUid, + decimal Length, + int Pathway, + bool Blood); +} diff --git a/Data/Sheet.cs b/Data/Sheet.cs index 9fcb073..4aadbcb 100644 --- a/Data/Sheet.cs +++ b/Data/Sheet.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Nodes; +using System.Text.Json.Nodes; using CustomAlbums.Utilities; using Il2CppAssets.Scripts.Database; using Il2CppAssets.Scripts.GameCore; @@ -42,7 +42,7 @@ public StageInfo GetStage() var stageInfo = BmsLoader.TransmuteData(bms); stageInfo.mapName = MapName; - stageInfo.scene = bms.Info["GENRE"]?.GetValue() ?? string.Empty; + stageInfo.scene = bms.Info.Genre; stageInfo.music = $"{ParentAlbum.Index}"; stageInfo.difficulty = Difficulty; stageInfo.bpm = bms.Bpm; diff --git a/Utilities/ConfigDataExtensions.cs b/Utilities/ConfigDataExtensions.cs index c5a81e1..e7d555d 100644 --- a/Utilities/ConfigDataExtensions.cs +++ b/Utilities/ConfigDataExtensions.cs @@ -1,6 +1,7 @@ -using System.Text.Json.Nodes; +using CustomAlbums.Data; using Il2CppGameLogic; using Il2CppPeroPeroGames.GlobalDefines; +using Decimal = Il2CppSystem.Decimal; namespace CustomAlbums.Utilities { @@ -39,15 +40,18 @@ public static bool IsAnySpeed(this NoteConfigData config) || config.ibms_id == "17"; } - public static MusicConfigData ToMusicConfigData(this JsonNode node) + /// + /// Converts a to a Il2Cpp struct. + /// + public static MusicConfigData ToMusicConfigData(this ProcessedNote note) { var config = Interop.CreateTypeValue(); - config.id = node["id"]?.GetValue() ?? -1; - config.time = node["time"].GetValueAsIl2CppDecimal(); - config.note_uid = node["note_uid"]?.GetValue() ?? string.Empty; - config.length = node["length"].GetValueAsIl2CppDecimal(); - config.pathway = node["pathway"]?.GetValue() ?? 0; - config.blood = node["blood"]?.GetValue() ?? false; + config.id = note.Id; + config.time = (Decimal)(float)note.Time; + config.note_uid = note.NoteUid; + config.length = (Decimal)(float)note.Length; + config.pathway = note.Pathway; + config.blood = note.Blood; return config; } From a7b72456fd69bfec2cdac1399412ed3a640ff2ca Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Sat, 6 Jun 2026 14:45:08 -0500 Subject: [PATCH 13/27] Remove support for GIF cover arts and speed up cover art loading with caching --- CustomAlbums.csproj | 3 +- Data/Album.cs | 80 ++++++++++++++++++-- Data/AnimatedCover.cs | 19 ----- Directory.Build.props | 6 ++ ILRepack.targets | 1 - Main.cs | 37 +++------ Managers/AlbumManager.cs | 12 +-- Managers/CoverManager.cs | 137 ---------------------------------- Patches/AnimatedCoverPatch.cs | 85 --------------------- Patches/AssetPatch.cs | 12 ++- 10 files changed, 108 insertions(+), 284 deletions(-) delete mode 100644 Data/AnimatedCover.cs delete mode 100644 Managers/CoverManager.cs delete mode 100644 Patches/AnimatedCoverPatch.cs diff --git a/CustomAlbums.csproj b/CustomAlbums.csproj index 7a4d11f..efff348 100644 --- a/CustomAlbums.csproj +++ b/CustomAlbums.csproj @@ -1,4 +1,4 @@ - + net6.0 enable @@ -28,7 +28,6 @@ contentFiles - diff --git a/Data/Album.cs b/Data/Album.cs index 77488d7..fc1bb3d 100644 --- a/Data/Album.cs +++ b/Data/Album.cs @@ -1,7 +1,9 @@ -using System.IO.Compression; +using System.Collections; +using System.IO.Compression; using CustomAlbums.Managers; using CustomAlbums.Utilities; using UnityEngine; +using UnityEngine.Networking; using Logger = CustomAlbums.Utilities.Logger; namespace CustomAlbums.Data @@ -31,7 +33,6 @@ public Album(string directory, ZipArchiveEntry mdm, int index, string packName = // CurrentPack will always be null if album is not in a pack IsPack = AlbumManager.CurrentPack != null; - HasGif = openedZip.GetEntry("cover.gif") != null; HasPng = openedZip.GetEntry("cover.png") != null; Index = index; @@ -54,7 +55,6 @@ public Album(string path, int index, string packName = null) using var fileStream = File.OpenRead($"{path}\\info.json"); Info = Json.Deserialize(fileStream); - HasGif = File.Exists(System.IO.Path.Combine(path, "cover.gif")); HasPng = File.Exists(System.IO.Path.Combine(path, "cover.png")); } else if (File.Exists(path)) @@ -76,7 +76,6 @@ public Album(string path, int index, string packName = null) // CurrentPack will always be null if album is not in a pack IsPack = AlbumManager.CurrentPack != null; - HasGif = zip.GetEntry("cover.gif") != null; HasPng = zip.GetEntry("cover.png") != null; } else @@ -97,11 +96,78 @@ public Album(string path, int index, string packName = null) public bool IsPackaged { get; } public bool IsPack { get; } public bool HasPng { get; } - public bool HasGif { get; } public string PackName { get; } public AlbumInfo Info { get; } - public Sprite Cover => this.GetCover(); - public AnimatedCover AnimatedCover => this.GetAnimatedCover(); + + private Sprite _cover; + private bool _isCoverLoading; + private static Sprite _defaultCover; + public static Sprite DefaultCover + { + get + { + if (_defaultCover != null) return _defaultCover; + var tex = new Texture2D(2, 2); + _defaultCover = Sprite.Create(tex, new Rect(0, 0, 2, 2), new Vector2(0.5f, 0.5f)); + _defaultCover.hideFlags |= HideFlags.DontUnloadUnusedAsset; + return _defaultCover; + } + } + + public Sprite Cover + { + get + { + if (_cover != null) return _cover; + if (!_isCoverLoading && HasPng) + { + _isCoverLoading = true; + MelonLoader.MelonCoroutines.Start(LoadCoverAsync()); + } + return DefaultCover; + } + } + + private IEnumerator LoadCoverAsync() + { + string url; + string cacheDir = System.IO.Path.Combine(MelonLoader.Utils.MelonEnvironment.UserDataDirectory, "CustomAlbums", "Cache"); + if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir); + + if (IsPack || IsPackaged) + { + string cacheFile = System.IO.Path.Combine(cacheDir, $"{Uid}_cover.png"); + if (System.IO.File.Exists(cacheFile)) File.Delete(cacheFile); + + using var stream = OpenNullableStream("cover.png"); + if (stream != null) + { + using var fs = File.OpenWrite(cacheFile); + stream.CopyTo(fs); + } + + url = "file:///" + cacheFile.Replace('\\', '/'); + } + else + { + url = "file:///" + System.IO.Path.Combine(Path, "cover.png").Replace('\\', '/'); + } + + var request = UnityWebRequestTexture.GetTexture(url); + yield return request.SendWebRequest(); + + if (!request.isHttpError && !request.isNetworkError) + { + var texture = DownloadHandlerTexture.GetContent(request); + texture.wrapMode = TextureWrapMode.MirrorOnce; + _cover = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f)); + _cover.hideFlags |= HideFlags.DontUnloadUnusedAsset; + + CustomAlbums.Patches.AssetPatch.UpdateCache($"{AlbumName}_cover", _cover); + } + + _isCoverLoading = false; + } public AudioClip Music => this.GetAudio(); public AudioClip Demo => this.GetAudio("demo"); public Dictionary Sheets { get; } = new(); diff --git a/Data/AnimatedCover.cs b/Data/AnimatedCover.cs deleted file mode 100644 index 1e92631..0000000 --- a/Data/AnimatedCover.cs +++ /dev/null @@ -1,19 +0,0 @@ -using UnityEngine; - -namespace CustomAlbums.Data -{ - public class AnimatedCover - { - public AnimatedCover(Sprite[] frames, int framesPerSecond) - { - Frames = frames; - FramesPerSecond = framesPerSecond; - } - - public Sprite[] Frames { get; } - public int FrameCount => Frames.Length; - public int Width => Frames[0].texture.width; - public int Height => Frames[0].texture.height; - public int FramesPerSecond { get; } - } -} \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 8f8cf5d..3428610 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -179,5 +179,11 @@ false $(MelonAssemblies)\UnityEngine.UnityWebRequestModule.dll + + false + false + $(MelonAssemblies)\UnityEngine.UnityWebRequestTextureModule.dll + \ No newline at end of file diff --git a/ILRepack.targets b/ILRepack.targets index b189cb7..e971865 100644 --- a/ILRepack.targets +++ b/ILRepack.targets @@ -9,7 +9,6 @@ - diff --git a/Main.cs b/Main.cs index bd42bf8..36e43ff 100644 --- a/Main.cs +++ b/Main.cs @@ -1,9 +1,8 @@ -using CustomAlbums.Managers; +using CustomAlbums.Managers; using CustomAlbums.Patches; using CustomAlbums.Utilities; using MelonLoader; -using static CustomAlbums.Patches.AnimatedCoverPatch; - +using MelonLoader.Utils; namespace CustomAlbums { public class Main : MelonMod @@ -20,6 +19,9 @@ public override void OnInitializeMelon() if (!Directory.Exists(AlbumManager.SearchPath)) Directory.CreateDirectory(AlbumManager.SearchPath); + var cacheDir = Path.Combine(MelonEnvironment.UserDataDirectory, "CustomAlbums", "Cache"); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, true); + ModSettings.Register(); AssetPatch.AttachHook(); SavePatch.AttachHook(); @@ -32,38 +34,21 @@ public override void OnLateInitializeMelon() { base.OnLateInitializeMelon(); // TODO: Actually write HotReload - // HotReloadManager.OnLateInitializeMelon(); - } - - /// - /// This override adds support for animated covers. - /// - public override void OnUpdate() - { - base.OnUpdate(); - MusicStageCellPatch.AnimateCoversUpdate(); + HotReloadManager.OnLateInitializeMelon(); } - /// - /// This override adds support for hot reloading. - /// public override void OnFixedUpdate() { base.OnFixedUpdate(); // TODO: Actually write HotReload - // HotReloadManager.FixedUpdate(); - - // Dispatcher for GIF covers - if (CoverManager.GifAlbumDatas.TryDequeue(out var gifData)) - { - CoverManager.LoadAnimatedCover(gifData); - } + HotReloadManager.FixedUpdate(); } - public override void OnSceneWasLoaded(int buildIndex, string sceneName) + public override void OnDeinitializeMelon() { - base.OnSceneWasLoaded(buildIndex, sceneName); - MusicStageCellPatch.CurrentScene = sceneName; + base.OnDeinitializeMelon(); + var cacheDir = Path.Combine(MelonEnvironment.UserDataDirectory, "CustomAlbums", "Cache"); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, true); } } } \ No newline at end of file diff --git a/Managers/AlbumManager.cs b/Managers/AlbumManager.cs index f4d5e98..ef21ea2 100644 --- a/Managers/AlbumManager.cs +++ b/Managers/AlbumManager.cs @@ -1,9 +1,10 @@ -using CustomAlbums.Data; +using CustomAlbums.Data; using CustomAlbums.ModExtensions; using CustomAlbums.Utilities; using Il2CppAssets.Scripts.PeroTools.Commons; using Il2CppAssets.Scripts.PeroTools.GeneralLocalization; using Il2CppPeroTools2.Resources; +using System.Diagnostics; using System.IO.Compression; using UnityEngine; using Logger = CustomAlbums.Utilities.Logger; @@ -104,7 +105,6 @@ public static Album LoadOne(string directory, ZipArchiveEntry mdm, string fullFi ResourcesManager.instance.LoadFromName($"{albumName}_cover").hideFlags |= HideFlags.DontUnloadUnusedAsset; - if (album.HasGif) Task.Run(() => CoverManager.LoadGif(album)); Logger.Msg($"Loaded {albumName}: {album.Info.Name}"); OnAlbumLoaded?.Invoke(typeof(AlbumManager), new AlbumEventArgs(album)); @@ -140,8 +140,6 @@ public static Album LoadOne(string path) ResourcesManager.instance.LoadFromName($"{albumName}_cover").hideFlags |= HideFlags.DontUnloadUnusedAsset; - if (album.HasGif) Task.Run(() => CoverManager.LoadGif(album)); - Logger.Msg($"Loaded {albumName}: {album.Info.Name}"); OnAlbumLoaded?.Invoke(typeof(AlbumManager), new AlbumEventArgs(album)); return album; @@ -158,6 +156,8 @@ public static Album LoadOne(string path) public static void LoadAlbums() { LoadedAlbums.Clear(); + + var stopwatch = Stopwatch.StartNew(); var packs = new List(); var files = new List(); @@ -168,7 +168,9 @@ public static void LoadAlbums() foreach (var pack in packs) LoadPack(pack); foreach (var file in files) LoadOne(file); - Logger.Msg($"Finished loading {LoadedAlbums.Count} albums.", false); + stopwatch.Stop(); + + Logger.Msg($"Finished loading {LoadedAlbums.Count} albums in {stopwatch.Elapsed}", false); } public static IEnumerable GetAllUid() diff --git a/Managers/CoverManager.cs b/Managers/CoverManager.cs deleted file mode 100644 index c7cb33e..0000000 --- a/Managers/CoverManager.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.Collections.Concurrent; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using CustomAlbums.Data; -using CustomAlbums.Utilities; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Formats; -using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; -using UnityEngine; -using Logger = CustomAlbums.Utilities.Logger; - -namespace CustomAlbums.Managers -{ - public readonly struct RawFrame - { - public readonly int Width; - public readonly int Height; - public readonly byte[] Buffer; - - public RawFrame(int width, int height, byte[] buffer) - { - Width = width; - Height = height; - Buffer = buffer; - } - } - - public class GifAlbumData - { - public readonly Album Album; - public readonly RawFrame[] Frames; - public readonly int FramesPerSecond; - public GifAlbumData(Album album, RawFrame[] frames, int fps) - { - Album = album; - Frames = frames; - FramesPerSecond = fps; - } - } - public static class CoverManager - { - internal static readonly ConcurrentQueue GifAlbumDatas = new(); - internal static readonly Dictionary CachedCovers = new(); - internal static readonly Dictionary CachedAnimatedCovers = new(); - private static readonly Logger Logger = new(nameof(CoverManager)); - - private static readonly Configuration Config = Configuration.Default; - - public static Sprite GetCover(this Album album) - { - if (!album.HasPng) return null; - if (CachedCovers.TryGetValue(album.Index, out var cached)) return cached; - - using var stream = album.OpenNullableStream("cover.png")?.ToMemoryStream(); - if (stream is null) return null; - - var bytes = stream.ReadFully(); - - // Create the textures - var texture = new Texture2D(2, 2, TextureFormat.ARGB32, false) - { - wrapMode = TextureWrapMode.MirrorOnce - }; - texture.LoadImage(bytes.CopyFromManaged()); - - var cover = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f)); - CachedCovers.Add(album.Index, cover); - - return cover; - } - - public static async Task LoadGif(this Album album) - { - if (!album.HasGif) return; - - Config.PreferContiguousImageBuffers = true; - - using var stream = album.OpenNullableStream("cover.gif"); - if (stream is null) return; - - using var gif = await Image.LoadAsync(new DecoderOptions { Configuration = Config }, stream); - gif.Mutate(c => c.Flip(FlipMode.Vertical)); - - var rawFrames = new RawFrame[gif.Frames.Count]; - - Parallel.For(0, gif.Frames.Count, i => - { - var frame = gif.Frames[i]; - if (frame.DangerousTryGetSinglePixelMemory(out var memory)) - { - var buffer = new byte[memory.Length * Unsafe.SizeOf()]; - memory.Span.CopyTo(MemoryMarshal.Cast(buffer.AsSpan())); - rawFrames[i] = new RawFrame(frame.Width, frame.Height, buffer); - } - }); - - GifAlbumDatas.Enqueue(new(album, rawFrames, gif.Frames.RootFrame.Metadata.GetGifMetadata().FrameDelay * 10)); - } - - public static AnimatedCover GetAnimatedCover(this Album album) - => CachedAnimatedCovers.GetValueOrDefault(album.Index); - - public static AnimatedCover LoadAnimatedCover(GifAlbumData data) - { - if (CachedAnimatedCovers.TryGetValue(data.Album.Index, out var cached)) return cached; - - var rawFrames = data.Frames; - var sprites = new Sprite[rawFrames.Length]; - - for (var i = 0; i < rawFrames.Length; i++) - { - var frame = rawFrames[i]; - - // Create the textures - var texture = new Texture2D(frame.Width, frame.Height, TextureFormat.RGBA32, false) - { - wrapMode = TextureWrapMode.MirrorOnce - }; - texture.LoadRawTextureData(frame.Buffer.CopyFromManaged()); - texture.Apply(false, true); - - // Create the sprite with the given texture and add it to the sprites array - var sprite = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), - new Vector2(0.5f, 0.5f)); - sprite.hideFlags |= HideFlags.DontUnloadUnusedAsset; - sprites[i] = sprite; - } - - // Create and add cover to cache - var cover = new AnimatedCover(sprites, data.FramesPerSecond); - CachedAnimatedCovers.Add(data.Album.Index, cover); - - return cover; - } - } -} \ No newline at end of file diff --git a/Patches/AnimatedCoverPatch.cs b/Patches/AnimatedCoverPatch.cs deleted file mode 100644 index 355a6be..0000000 --- a/Patches/AnimatedCoverPatch.cs +++ /dev/null @@ -1,85 +0,0 @@ -using CustomAlbums.Managers; -using HarmonyLib; -using Il2Cpp; -using Il2CppAssets.Scripts.Database; -using Il2CppAssets.Scripts.PeroTools.Nice.Interface; -using UnityEngine; -using Logger = CustomAlbums.Utilities.Logger; - -namespace CustomAlbums.Patches -{ - internal class AnimatedCoverPatch - { - /// - /// Enables animated album covers. - /// - [HarmonyPatch(typeof(MusicStageCell), nameof(MusicStageCell.Awake))] - internal static class MusicStageCellPatch - { - private static readonly Logger Logger = new(nameof(MusicStageCellPatch)); - private static readonly LinkedList Cells = new(); - internal static string CurrentScene { get; set; } - - public static void AnimateCoversUpdate() - { - if (CurrentScene is not "UISystem_PC") - { - Cells.Clear(); - return; - } - var dbMusicTag = GlobalDataBase.dbMusicTag; - - if (dbMusicTag == null) return; - - for (var node = Cells.First; node is not null;) - { - var next = node.Next; - var cell = node.Value; - var index = cell?.m_VariableBehaviour?.Cast().GetResult() ?? -1; - - var uid = dbMusicTag?.GetShowStageUidByIndex(index) ?? "?"; - - // If uid isn't defined - if (uid is "?") - { - Cells.Remove(node); - node = next; - continue; - } - - var musicInfo = dbMusicTag?.GetMusicInfoFromAll(uid); - - // If cell is not custom - if (musicInfo?.albumJsonIndex < AlbumManager.Uid) - { - Cells.Remove(node); - node = next; - continue; - } - - var album = AlbumManager.GetByUid(uid); - var animatedCover = album?.AnimatedCover; - - // If cell is not animated - if (animatedCover is null || animatedCover.FramesPerSecond is 0) - { - Cells.Remove(node); - node = next; - continue; - } - - // Animate the cell, with one last null check - var frame = (int)Mathf.Floor(Time.time * 1000) % - (animatedCover.FramesPerSecond * animatedCover.FrameCount) / animatedCover.FramesPerSecond; - if (cell != null) cell.m_StageImg.sprite = animatedCover.Frames[frame]; - node = next; - } - } - - private static void Prefix(MusicStageCell __instance) - { - Cells.AddLast(__instance); - } - } - } -} \ No newline at end of file diff --git a/Patches/AssetPatch.cs b/Patches/AssetPatch.cs index 1818ec2..b879cc7 100644 --- a/Patches/AssetPatch.cs +++ b/Patches/AssetPatch.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Nodes; @@ -46,6 +46,14 @@ internal static bool ModifyCacheKey(string oldAssetName, string newAssetName) return success && AssetCache.TryAdd(newAssetName, asset); } + /// + /// Updates the cache with a new asset. + /// + internal static void UpdateCache(string key, Object value) + { + AssetCache[key] = value; + } + /// /// Removes a KeyValuePair from the asset cache. /// @@ -224,7 +232,7 @@ internal static void InitializeHandler() newAsset = album?.Music; break; case "_cover": - newAsset = album?.AnimatedCover?.Frames[0] ?? album?.Cover; + newAsset = album?.Cover; break; default: Logger.Error($"Unknown suffix: {suffix}"); From b05efff44f8538c3173910494bae695fbc22100f Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:02:32 -0500 Subject: [PATCH 14/27] Apply "Assisted screening" filters to custom charts --- Patches/FilterPatch.cs | 110 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 Patches/FilterPatch.cs diff --git a/Patches/FilterPatch.cs b/Patches/FilterPatch.cs new file mode 100644 index 0000000..6a9614f --- /dev/null +++ b/Patches/FilterPatch.cs @@ -0,0 +1,110 @@ +using System; +using CustomAlbums.Managers; +using HarmonyLib; +using Il2Cpp; +using Il2CppAssets.Scripts.Database; +using Il2CppAssets.Scripts.Helpers; + +namespace CustomAlbums.Patches +{ + internal class FilterPatch + { + /// + /// Applies "Assisted screening" filters to custom charts + /// + [HarmonyPatch(typeof(MusicTagHelper), nameof(MusicTagHelper.AdditionalFilter))] + internal static class AdditionalFilterPatch + { + private static bool AllMatchingMeetCondition(string albumName, MusicInfo musicInfo, int starLevel, Func condition) + { + var starStr = starLevel.ToString(); + var foundMatchingStar = false; + var allMeet = true; + + for (var i = 1; i <= 4; i++) + { + var diffStr = i switch + { + 1 => musicInfo.difficulty1, + 2 => musicInfo.difficulty2, + 3 => musicInfo.difficulty3, + 4 => musicInfo.difficulty4, + _ => "0" + }; + + if (diffStr == starStr || (starLevel == 0 && diffStr == "?")) + { + foundMatchingStar = true; + if (!condition(albumName, i)) + { + allMeet = false; + } + } + } + + return foundMatchingStar && allMeet; + } + + private static void Postfix(Il2CppSystem.Collections.Generic.List buffer, int difficultyIndex) + { + var filter = MusicTagHelper.GetAdditionalConditions(); + if (filter == DifficultyFilterDefines.All) return; + + // Iterate backward to safely remove elements + for (var i = buffer.Count - 1; i >= 0; i--) + { + var musicInfo = buffer[i]; + if (!musicInfo.uid.StartsWith($"{AlbumManager.Uid}-")) continue; + + var albumName = AlbumManager.GetAlbumNameFromUid(musicInfo.uid); + var remove = false; + + switch (filter) + { + case DifficultyFilterDefines.NoClear: + remove = AllMatchingMeetCondition(albumName, musicInfo, difficultyIndex, (album, slot) => + { + return SaveManager.SaveData.Highest.TryGetValue(album, out var highest) + && highest.ContainsKey(slot); + }); + break; + + case DifficultyFilterDefines.NoFullCombo: + remove = AllMatchingMeetCondition(albumName, musicInfo, difficultyIndex, (album, slot) => + { + return SaveManager.SaveData.FullCombo.TryGetValue(album, out var fcs) + && fcs.Contains(slot); + }); + break; + + case DifficultyFilterDefines.NoRankS: + remove = AllMatchingMeetCondition(albumName, musicInfo, difficultyIndex, (album, slot) => + { + return SaveManager.SaveData.Highest.TryGetValue(album, out var highest) + && highest.TryGetValue(slot, out var chartSave) + && chartSave.Evaluate >= 4; + }); + break; + + case DifficultyFilterDefines.NoAllPerfect: + remove = AllMatchingMeetCondition(albumName, musicInfo, difficultyIndex, (album, slot) => + { + return SaveManager.SaveData.Highest.TryGetValue(album, out var highest) + && highest.TryGetValue(slot, out var chartSave) + && chartSave.Accuracy >= 100f; + }); + break; + + case DifficultyFilterDefines.NoLevelAchievement: + break; + } + + if (remove) + { + buffer.RemoveAt(i); + } + } + } + } + } +} From 37a0d2eda03a76feb37065bf85d3ba40b7645b9f Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:12:50 -0500 Subject: [PATCH 15/27] Modify Headquarters not loaded message --- Patches/SavePatch.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Patches/SavePatch.cs b/Patches/SavePatch.cs index a059573..c658139 100644 --- a/Patches/SavePatch.cs +++ b/Patches/SavePatch.cs @@ -30,7 +30,7 @@ internal static class SavePatch internal static bool? _hqPresent = null; internal static bool HQPresent => _hqPresent ??= MelonBase.FindMelon("Headquarters", "AshtonMemer") is not null; - private static string OriginalNoNetText; + private static string OriginalRepairingText; internal static readonly Logger Logger = new(nameof(SavePatch)); @@ -194,10 +194,10 @@ internal class PnlRankPatch private static bool FirstRun = true; private static bool Prefix(string uid, PnlRank __instance) { - var noNetComp = __instance.noNet.GetComponent(); + var repairingComp = __instance.repairing.GetComponent(); if (FirstRun) { - OriginalNoNetText = noNetComp.text; + OriginalRepairingText = repairingComp.text; FirstRun = false; } // Check first run case when on a custom and HQ is not present @@ -209,13 +209,13 @@ private static bool Prefix(string uid, PnlRank __instance) // Vanilla chart or HQ present if (!uid.StartsWith($"{AlbumManager.Uid}-") || HQPresent) { - noNetComp.text = OriginalNoNetText; + repairingComp.text = OriginalRepairingText; return true; } // Custom and HQ not present - noNetComp.text = "Headquarters mod is not loaded! ~(*´Д`)"; - __instance.noNet.SetActive(true); + repairingComp.text = "Headquarters mod is not loaded! ~(*´Д`)"; + __instance.repairing.SetActive(true); return false; } } From c7a64b3de71202ba3c8a8ef1f4b535050c7d635f Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:13:13 -0500 Subject: [PATCH 16/27] Include a default cover image --- Resources/default_cover.png | Bin 0 -> 150031 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 Resources/default_cover.png diff --git a/Resources/default_cover.png b/Resources/default_cover.png new file mode 100644 index 0000000000000000000000000000000000000000..2043bd1936605c2621586e521d6ed921eb230bab GIT binary patch literal 150031 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 L#b`HYo?Cu1-0!9%|_&zJP-r;E0UqJ7_r z-#=X0#Jsmb*?r-b$$P~A2(^eB5s-3%m{w?RvBKd)7qYjxla4|x4cR8RS+Oizu+sD~ zEa(IO^&ik!WA0;$@2eOPQXf1&-4i`?dN$hiwnllsc!^B)bzW#Z<%cJK_dS@qU?lr& z{_Jvi;dcmQ)Wk^{cps;*QQ>A_yXUFVFbt3Xc()d|{NKm$Rh@@Dj135T{ zq*z%517T1Gza}Q9{sWqt@&5^IR6i+MRd)@g zUyuG?E5FT3bsB{C`zG3&Pn|v(lxl^+_@1qfOl6<4j|MR64f|;CUlrbVGiLq5^-ic3 zJD5MZ!hjpe(yY=NpPM#KAzCTlPWktR|M}%_e)+i3nmh~YOm=lhA2+prfzL0{L~GN} zjpd0(e#w9Jd1t%ukz~b{jgIJ>Hn9Bz< zM>yaYxrW!$)`K(C+STlV1?atPLoaS(mK}Vyf$Vb{AxHC_o_y^pW)GyavqdfQucHUm zTn-EGv_ttOD=dDo5vJYof9OWE-!I{bF?#^Em}5}kya}AAhr;lSiy-v^^U5VI>O(;- zye9|Bw`4-*U>8~~tiA2^xyceK41-^e+!3xnG4)Z*y*^hn~#^zoMyUISpbQJc#bpkR58NWfd^XUgp7<5%C493)d za1vBk)?>m(%EvB*55Hep<%aDxAsmD_*=Z`*z7*0!s05spR2%fe^ht#a>P~p@8si{d zgtGPLZ%^O`S9*2vc!59p_u(+|x>VTrjuXnZ)IinyE=Umr3XWCqrPSHZ|eJd;9YNE@DPNKw5t3^0X;WJm2E)JHYl`TenzgFw#Wu?urbxCwcueQ0D>* z%z5z`6z#)27>SF6eb9>+nwz{Prx$#?Yu`oJJZ9F*T z#?WqhJk9Vh0*>HDYKi4=IreE2OuzRM7&9gb8kI(-ezs_(W-HlTtG^n`H(H_k6AR=_ z^+NXG16a!2ul3D+c*V1MD}P>Sb*XdIySu+&M}iyau?Kg;EC0(kFCBMd1~|p) z^%EuyfPbBL1za&E1C}n>0e}9VkD%u8S8(YNBgS>?(^GI!>IHCZUM5_dp9vSG+2Jp* z9)$fnk3(`l8%t+SH$$t)13FqPG3IO5?pJ!4QshlFxtpPmI&@JEY~{r^TlQ<}fU$2I(>#E_4qn-fV>d<3q7K>dwkDh_@wI`tVQ(s^=b`m)( zp|t(^Bb1l$>l;7L38P25S(-l01w#i}pfHz1rs{AE>z+FJDPGTCc>1)C=lB1- z>4I(Oy-XTuz>Ue<%m7DXfYS}EmS4J>mHf3OTA&%fKl6ne$WFh2ZA%mDz)Yic7txz% z;gGXx0a5bu=s_o8J!@%ZfUBzYF|T!SIyRccSo|~D@!x=8$gwuR2~j<`egLoiwax@M^5CTRHt(6CkT$^9Z5|u}F2QSfaJ%a3!4dPC zc>_{Stn8jxBlXTh4&h5n#|$a+LXaYO)3iI!BwEa z&KYad-Ww?b4^EXne_8!++RehzFOH3RXGk1)z8wb+IpJCJyZLS>Bl*t)f!O^7

?y8hU>lBJ$&3*WgqxAq3)eTofx}7QIgN%)tVlEe2@W5E8)@R?3z=oN9Gn&-Q2Xw8gS7^~uQ(Md;7A*7aqz?(qtTgt zwGEc;Fn0Oqa=_6)&9LIK#6JJdKImqh6~GTBHK5qns*icVNpQwwCK#KGE`qQx1oM4Cy`W(iMN zX-t&1w%VcYATOLhdl6gIQ&Tz(G_87=M#YgTH~#_V-Zl;jrXPisuOb9$_&loM@_-j> zI!tl7`XW|t?V~!iD_VX)5Y`FIlqB z1t6Ah=x;al383a8+CKIY-yfYd=5srcoS_3+H9WVmT!BUBwm&wCgD|I)WKFba*i z8sOOn4G$Skn*WZbb*NG|Af5TU8rXlx2P=!amc>OSIC3$ z9EH@Kt%qKb4F69|DxUws5!OF3Z`?&N^Ev}EEy5YF;De?9fMtu*zxZ{2F%DfD2q~_jl20i+W6@K*W8M`o9^rP!1AO|#-*@~o05?)gi~0BTD$}-n z7=KS|Z8n_nL8GggCEC_0KHdTrrBBb&*A0DQeF4br`sg$qJaPsmU!Me7122Go%}#{z z%CLXc;~_6=+1fe-Zlsgu9oJ0_x)b=Y@cU!G>w$jBEim*lALQTYf&3dhFesBh_`!pk z;nQlPmVR`^0|!5EhT`44TxO-10T-(kaOz_st;1{k|qlG4mI+UGH< z%&75cP;bIP%^|>wY34 zDpKdtgTseKTLAeIENm}1CULz4H}c}*@zzZqX9qkuRhDormoj65RD$*I!9~Wng9R%G z!G!6?^9^-6iY8E(xoM!YxjN{5&XX>)ckkryvjMnb1ly#u1-AAEkVr)WIMTEsyV;Qd zH{ynL1~*#Kgy!a{>I~p+{9QFPxA6BW;MUa}OLMg~V3CQwr`~FuzZMdF`ytVP6zo1@ z%bK|l{Fr%gkpj-E1UV(#oQ|I$;CR@Q0GE?)3>DA3x;g+ik_m81;rE{Lvw2{{$D}dO z?URq{q3Dm*;BmwU;6{oDa1^Sn{sIBVr@93NoXf@US9)+N;7Bd8+>@6gz!BK)d2}S~ z-BAn67wr!8gOPN=RUhH!&ls2lqi^UBbDuDt?a~E1VONn6q9&!NbF@%r6L5a?;xbY) z7~l#fB(hLtFu<7xfowHT8v%!y22dve*K`b9KWL@Ndn3sTCZ*d1?fG~rvtbK*Z_l-mw+Td&|DM9~*H%5&+RRj_+g9i(4Q zHI6E$sIXd&wZ?E0<2vn!%zjo_`{U3HHp}X{qpAshwb=#sBqIb+vFjAvzhorLoNvrc zYSLPq=IY&69vMUz8WFotypl3okhyawLh+`du=2$sD42>~Yq%d!Rwf}G2Xpyc_`_YW z>#YJvxwsZmQft9SGqW|L%p?kaTwfd1)$t!4n&p5={Vh=ZQ3DiLpphM(2*3PZKS&#B z3rw)KX?>V#p9|^jKJP(k3GdG;Ev0?x%=ep5mgtB03q!QX!bz`N+WXe8 zl>RG}K4>M?ZVvfbF@NR6>y>w)QPav_QBpG$t*>Kg)yr8>$oic1Jfljd#4?-l|2?8y zp5)Uuz{Nei>eJ@6v@$Q!LY&lx>JSjc?`;2jB5dEq&rQbz%Bpe&H#Wg3k8y*m9&>}^ zV(~`GW$r_T{pRxAM!JR6fe`H0RjFm4E{&E3qsJ}5s{H*!uhqhOY*6HVHxZVLO=WGg zJyBOm_h9w%BbEv*-rE3kSMjCmNQ+*~g6pTIKzq$!xjwK9|K7AZPB{D90+WVxqZg+? zzAja3D_>BVzv~_az$RJ-;XJhVL=8)NF2Mmua1$-ib^s@}VrnJ~{+4D%6v^WH)aH8F zS>*`;j^H+YY$B}MKAydo>ImQxTlPUxE2aHlvuPE&8v*xTnHL_uJv9I~l27YHV)-YZ z2|lX^tneu*Ht2_mv}FhP3JGq{-i{swJ`Z*EkoEvq@;(CX12lRcnD3A7H*fMMkarUg zIbWA=4r$pud{D&O+c@ZU{*_MzF5fD{GUoHC80CeZ`~o<4~>#JpwuzI$+jjTOokwLZLEW5?y8Cka7XHVu*QtJ(5Zl1ygnOp3e6rxq<~9=U66#ydUC(`gsVNkm2BoaSZprI3;>QP)Z(+K z7`xdZTzwM<0>SP11#YOW$!s5R^in<#k!`eYF#(QLRpnwkman`m115{mlIKe=*hCFt zMm3=l6eU2d;vF-tJxR=O_G3DdX*{ULvD1-V{Kn0K*w9m3mV(g5cn?@-Ze z8xo;tJFkCqlp*QD8@XFCb({qXFiE)_wGiiYfc+o5&$o?q3#kF_sZHFcc_dHNGyQ=@ z0k{u7Z-QI?*H|frJUJV!twpcD!=Fcfm_6*mN_if;4mV)~(_G> z!ju%4_fnQ=Y8T}`1DiNa)b^MEeYbgWdUki2y32bDfK5Iqt-^HFgOj2Ox>D1VcLxtq zDu%jMnd(nr%0)MF53ck>ZrCgqqeAXIf-O~-xdpF=D2IC=ra;FF5?Dd_un`;2V|Ut(k+IO z*6ni}gQQ6UfV?77)gHz8pc>B594_M?1Avn@#cankTqp4XnENbszN;kBPysLOxUw$JBy0zdDL{Wg|%?lpU)$q62qaAP%0n8@#I!VCed zIr>f0k?GoIFs%M`B7U!o6Pe)FQ2a@B6YpGXanl)PnzJt zM|j6y9yS`5-z`Jgs4P53i;}-bo51tM8L(ls^^3=ZyRx=mjM5A(5ImC8OQncrvDx0#f#3;N!$@yusQeoKDcA)VTD%i}WwF468LC8%) z$aT3F*Ij_?GVe_X8qU( zm|9p3R;|x22CYeHu@d&e7Gi0UISeM1cS}9w6y_J?ODanBri7yM8J)cg)wT+b^?uie5737S67K@;96?Zm0uR{ksv4 zYtzT0Sp->pH|}eIyAW{q%uI*nFDYU4E(Y8IG5@r1I(mt(Vna&z_-JLIK1%r#i^I;x z#%Sp#SHJ_2kR!mUN=@cBw6Tc)@b))3=t{Q;Pg^N}d6OM5E}NIj$zyp`VdOO4fI9G= zDD#)|LqH#6La@P6ej$Es-egF?CC0%^`(5zFDklTnqJK+;>K1;lyZ*u(R1K{F+ltIl zlTaeSP5izw($$t|g`K4?7?;Nz0I{MzjxJ1LiS*vKdbmc+9W<9t1gzGe>MFpYx}`6! z%K?XQqnd15wWK4`!6wz&43*}U?RBMB9@GaV+(V+)HR*%D^1|dg!3(->fTrDMgt&4h zRbzqTCAOgOQ+90A=@F})K`Ssc+NGq(*x|i&>u7%5zzbY`BUXL^xS|cIP&n-~OhzA5 z?qmZXBs~PzQNT&rzq4Vf_az5>I83kEoa=@M`Rw{e%-;r44&O#KQa`|w_6zZxFZ zXEg5WCa`_W3Ar~_vBcD<<*%0C4Y7ln2iM>ADtqs?4K(29m<{gOVUXH@UY&P8^W@^> z%yE>p`6$9I3tUOFz;@;Xo?>+4!U5;)`(t*rdPQHRl<;uNwhUsR+q~ZJ_z845Q&J&U z*`rM_UtI+_5kzOJ0*<`5O$fOSuVq6%f;pfr1RQDJ9lYVf;6@4t zI8|clI|(>iBeQsu$ME73%pN}+HyVIUN^vRKj2F|K3Zo7@G*iyG{X} zZP`-;Azw}&KP1Y_K>^5m@x%u36GsKOel~dNojL|M(wnO?AwAc;+ry5x%;k@kgi{r# zWvEB&?UtLz%bmv@Pyi49W+>_Z+g*U8sRpPa=*4yE>L%R*xK4X-?M>1qbp_xEZq)fq z?m2}JE4J4|#a~@4Rf;8^0z!{LdT^9RUCj&M=rMdf!x1-V6-KvE9RQq33A{7EWCJgm z>;GV9U?Z(sQvrpyZfCEhJRUQ^x*KrQCr^X@`_c`-`OGVK=xZ+$;0SI%za;@5#0G!mTCBI;er|oEr zNmLdmBH##el=Q3ZO2Cm8jN1oAd-!h?UL!K-_?HyltG z1FlV@GeW?rAxhHWdI5$g0LRp9F7KZAJaGTg8U{Cl-0I(OPrL-Z98$hEPZ1K$qe?pJ z!I2itNrf4Y7>!+$y7U@JAJuEqMxE+?YCRQjLS62~bt&M4_eK^_FP0IBJT~VU8km3? zCm(z#E08WxUzwh|vjW_;wXpwPwI7X_fV`V|OEUNRPa*A+1{gVx0}W(I^0aug4S4ie z@s4H1Q-7h06pvOQ9$ScgG1W9deatBKadOLE2FQl^QE)8fUiMjb4L8`HI1VzKkM|e2 z5~qRZ0yNf*#b|T)gFlAfrzJ)=sYa3Z#PZ5+`uo1d-xqJnhKJ@(htz?efZZVsf2hT! zQ4Y%h7<-i)#$N4)UGLlB5Vm+ri)g+YmyJ3s6uDg7P|4?E>0^PM{6m1!dbp$IHq;Zf>< z6Dz;C`rd@aWGJM;v+Vr``_nL`!_L251>4s1JbLoa{{H_v{2p5`xXe6$wsT3!uPu5-Xk4_?LrLXT8_wX+sfy&il% zyAfC`{;vl1e9!=EmkomvKTR|ByR|6~_0f+`Vd29GVdJcjA38(f`Hc{m@hr zn#)FZ*fgh@r00J)>G>a8&7K&@zM>0v8t7`k{RR*7_`AqOV3TtzsEevo85%~?-0s-u zgkRl7?ix36Bd$G(sSbt<4=sO+Fj!gzYU44qDKch*-t{uKKx>ljfm402VXrglo`a;O zy$al@tq~8$`~8BwPdoWbc9gIpg#sKC8feqsD}b9jdm8L{Hx&VAgh;`wJTMDB)OG;3 z>jOLN#-#pQUg2kuJEaA3CiCAY-(rKJKlAgIzQIFQ>L~G?yh9L&*F%GG@&h4Zeh5ea z&TY*GXA(jlpLd4q1xONMN*!=c$Bz(j*MQyg9;CXS$4_)02zAkqv9WO#HgY}y`0@|p zLm?G#elIo>`s4H9{d$hmoADoW{<>^4VZqEe2DypT{IF!*s4fRw1{z~I@=Xp(Dce*B z58qvdztseV-*aF}hQbM$7eN5`z<<_2>Go5w{ILv}v5*E~^jyHTXfY-cLLaf*lbR9KND;N180yn6&^)M9Yp{s!@^0;d{9TpCJu>hMcGhZstm#rAzwD)6g6#HC5*pb z8R&r)%Zc8r8@*RM=7X*2KmcwHwh{Q6d$@9>B@OyWbv=iGlgpVYfQuiFUfZ4gb=?|( zCec}l@>}`!Sz54x)Pi{sgXKT~j%4$RMt}ym*S7o^9=?M&y}R0jQ^6)B0Vg$Y@;)f~ zEB7)9bTh6_h8#KM9rfxLUgo`#D&NWm$2gkS^7RK?Fu0LyG^}2A{JIfv^Jevje_fSz z)&Un%(b{9}e4?+dZKzg=yi5dKmvO1fPK0dxDIQkvccI0=CM&U4AkP-yNE~t^^cawX zg~Rf0uHs%?4+I>AD34fq2yKv`du=2NRm#`L9ejx{$LV6Qc@1crys;J+xZ_8F(>@*S z-UE<&njjb%;K+L`eq$J9U$=|B4@d%z#znVS!Rxhww~7CzuPGUhyF4(4LX;v*IpQ|+ z_uB)Ult<)$dC4F)0lWN7V?-+{bD#^xP2kWRhoBqzJqv?dssKFAL*rmGJh&PJn=#Vz zes(k@jQvG3&}*Z=!QT%8H~$4$U^!ETOs~OAQes6SL>Z6*xQFHy!1_P%DIE-M(_5e( zjI}CBks2gwb(0eDrwM90*ras8+1_zM(e`?nvzYhAW?qhf8)SpUV&Za71{_U``=p`) z_WX_a`|kPMDHt}~#zK_Ya-w>a_JjZ@;YOI9-mKuBe= z8NI0i@ZjC!un}++oMLG~soG=aK_Y4=_{2$v;Fd`i$hvMHq+BAVFMOGT)|hWP3TMy@ zt1rPafq7;%hYkaZ)qoLegY9o+22yQ}Ilsq!?=#3PsDj+dJfCb1WeyjrHeotD+CBTw zb6bu1t%W4};@_w8eob)i132+}{#wYDa63Ep_3$Y?mOfUnv?|xA4GM4p<~eG?Y&&dw z!wPBrJTPx%7L1^oN6r#4r*o(s7H+wc zO;HT26v7WZSK9rl5d~TSrN78OEfOA(m{@)g0cW1C(?N9!;1F&hCw8CBKDKab)8bJ8 zL!hG;-1hZG$j#<3Jf569C)oPdvFQNpJsrClaPHVN)3EkB+gM2S?1BCWH!cGN9&0w# zCd~{AH@}4+t-epe8=C>A1tcAfVq!DGjT91aj!euS7tEhM4ocq`5E0g7x*oQ`!PO28$@@rVM!jpQ(N`swm_lI%U*T8_xQ|$we z41J3ejp_Z&m>OONEj`6s{Ty`3Y?P!iki?id<+ff0mY5W5E` zi5&qkE%jh+LAVJIj=_z*%4QEd{X!$;PT|ehNC77syODrRuG308=Lhrb)W1pB^2>-< z0Qcmuk$1K{xL2O5g`eHWjeCKZ{GQb8N3a=~yIxsaloSkbMeF$-wP*mh@%58%*KJi$ zFggjI{dZ-hqOJlQ{SMK|j{n&3;kmHhQ0E$6-o9!t9C!I4&0&F~ZXZ1H2Cu6GxTi`u z;GCiWoG*YQxY4Z+(05;GN955_(jp2&uCRA*%0O2DjwbfpN3ZDuv4Iun$Yw)L&oaO| zE0f(o#w_E|!3O|9h ztN4l+Ub)A6Q|^WM#%f4&EoPt5h*deJ#(sJ?xciO) z`$cAp`^u*KVc{Lzi_1Z;V%p=GFm|{NPJSi67-uF`n_>Qs^XHM4!DVu1`A?Q6p1V0InXv<{-E+R}(KVs{Md^nDmCRx)|x9kK8cl z+U;h*$+dEP{Ifw9nM>%lJ!TJO%%r0MxRG?gN$-lm4V8J48&gBPh&vO}leyq3NN-qc z8YSt8`wphT2Hb)`B(&NAoZ}{b^y2kNxWypYk`QKR5O}A=XC@yYdP%^kV54-(b`2zY z-h<@Eas=Fm3~r=wfUA$q#7e^lmN;VfL$a+BJIHn%XYqysB3;Pd6WkWB%|rvHz>O3JaN+vcRIs%NEvlQczk|*f z3T*VZvMy6-srVHRI8u4-2m~DY5QWIMU*O+Y9AuoB_yr`Y3b2Q1@J$0l8PVK-v2-Q1*ev1h~b^OW^h&nERwuz!BWY zD>ILR)SqvC0B)Hl$*3AWChLc@WP-z8!r?~h4B(obSOc%ZQdfvK`U(EPW^Qmu1h*K3 zTMK$PUUXZnBJ@bR33NgPI0?7oj)$P$md(JH`gNFf1-->vp!bFg7zQu2d9 zw|>J_ux$%pv4wO~VHGS{WvtiMV7(SSvuhAy??LL9H1Qj+QJF~Zj>!hMoiC?mckf~z z+}v5?5Nv#PmYiuEZaFmI0o8a$3o?ZOT&TW_Iy4BB{FvJF?$kc;Q0F2Ax)sEG5`E|N zDG%hN^SoQMY9P$`F?kbb5pcr83aA?ZC%reeL%CPWM7=o2;04o;3y}z!MbWJL|HsMT zMxxqE;a*Cl93}j!t9(m6xLYjn@GM>)CnDe;LC~q{Nq|emtVxM5P~^c2r}=%dLnX6) zGxZ+`Iniejy*dTB1E*U54zcEmOJ8@ut$nT%RzFxB5^y92H%zqn8?CrOVMnPv|ZV5S<(0IGh|7Po%F;n#{nKdg2)tqI~#)Q31FW-0~SL zeC2xfS}GLaBI%STRbe&}+*0sw(=Y`C8zoXwq+k<&ulAW!e_!lr4?I4n8nzd?1mNh| zE6=zG18(_4ePPCb6A(fJPI_>p%>MC^BQ_T$O}>(sqpVabq|itm0$h@~KH9C<+Nz#%GUmq;%mA%2hT zC5eT(Af0b6N*XYXKhdkMt%DEVO@Z`p`Jl9j7r?y2YMA%qV#qG!g|y+Hen^T@+dQb| zS{71t+4)?siyQvTR>P3P`+b%aW>Cqny6n>-#$StY6GPfj?%1K=bj$<0XAh*()PcA` zcn=Xaqey*>%FNUx`g8~K5GfpAiPwl88ch(V`Drw&&RdVq4Ksq?2Pw4C$SYhzEB=Nz zHVsZA;7M`KpW!|24CI59MbE#0OR0(a`AB+RsO62yn%ywgibx}CyxVYS4 zeH`jw1ez9rHo08|LXntF>Ux6~^?Bm!c+9WI@2q0GI@5-i&%JEko~hFfSmpb3Z?DG2 zfeQ}*PI;zhqBG&16gzC(?S;FRpN1FbBjA>W0i5{#)xUAV{ylDJIOApBoRqwh!drPH z#(NrI>P<#e=K2fG55b{yqRm}d1MAA2EcD6J^nUFY*MRivQ{Geltb45%mcKk4@~889 zLDAcDD&_iUBDc1^p$Xs=ek6y%fq5Pbb|EwbA=jb7CLIS{6JK}on*=x#IbcuDse+wi zPbLDKgd6F@clijcWvj{|9|0#v*c04HI^aAA7=oM4dk7q-OWGQAgq?(&!@VxxJ&>G< zv)Nu|1U3Sk+cp|K8f`WIa8d!+RDmwjXta#8&|0Dgc>=ve0-CnGmr0#9aLq0THeYNy zOL5I#FtCwmMSlx{R8ho$YfpN4Q`XjGvZce^JI6usCSl02LauzF1YyTRzAE6#HdX8?-IM{?5wHB>@^`uZEBV`{2iH;ZJ^#tofQ|G%y1U8Wmdc?&9I26SP-riUT zPu#^loCBwLe`Y|Peg4Y#2{OO@`7u~|cN`Rq6q#|B04F7v>LRa>L~C}EieBSKwX{s3 z$@yOyCN%#VJ0x9z*M4mh6u;YmfI9|FjZIM7#2al)od6u^&L43*|Le6pRN0dNN7DO{ z20}&KNlKvb>fg1~w9(kJiM%CZz(7t@DW2=dz6hTk}ztx&v^e;*A4%+QP5p@Cm*P z00EA`Mrqq7OJI;d-n2NDuAag-MwJQ$xJbYz;TEI=o=L7yLJA2sJP!*+iuY2H0FIQC z%%c`FW;6_+>{p6t(T${)s zM}Ygy^OtbXO{$ZCd-YEZF#nEvPFbv@b@)h z)F!Q#PoloKQ0k0&){fDc|qOfD5Qb9EDsvTu|~J?_;B50zQzHbNe$7j?&64#*`GMkHy-n z4)p#8(gFnZ1Imx;*QdRAHQOLNz8uS3D)ZuKA))Rzks&3nPhAU@)J0%ZH3=aX^~xq4 z0^GqyW#y7+b60y{x=TwHpCpGhcN(CYl%gJxY)P}nqF2R+{Of}}sJ>dzOQKadTCreTsBkNPBR1${>qMd) zjj!NS!vCifV*^j@FxC%e_3cBWpWenGg%wShjzXBc-Bn#Gsm$7o|C+v?FRqYOPS!c z+DwZBL|NLyOS_)-%1gL;nq8o+2)L+MHfg7NgjJ_@><%WX__QHo05oSw1XMCO0icF? zK=bb$4iDZnoWU(`iW7d0V3Tko5!@V&>p9#)iQq;FsBoJh;+2N7E=D>Oq3~n@;n-;RA z@=X0~G$*w^xV0zw``KFSZk$*Ux^#UVl!)c*x>Cxruji4-a8NxqlVI9UvtjfkV?`7O zw_y4h>*B|JF@Z=$aFaX2TZ1%~&Zg9|+Kq4{b%;4Zsz_*|_eR^|bcrY=Nm46*d#g-$ zhh&&2lHV-D3cX-v6$2ZI;I{mw3K);x8)?T|JQTs86i$9IT~mUKi7vtew0u?^!jaaA zWHS9t7f6L0iQsk`y*GjziQpy&mC#Y_>K&0`_4cQgn?O5NH-zLw=%4M~!B zMKDsqM$(Oi1lwtX6@rSU1%v{dIwv(+F$inFujzq!OjPP+FIF(PNfF#6=%N>`mQJlP^2i*_^9$?!byRY1 zGJ@M_tWY&#?=uNEDS}+wKzu+53$cb}ShXMkEC?S;9e^AgP-DwsVDngqBG}#l$LS&i zY$$r&Mg%rW31jdN&;a`*^x$IATG)^Z{qbHYU4-AGgd(ub|JgKn@S$leEGb1^+|O1x zSfVf`iTb5zgdu^BL?JyEM#MymeyHVJih&JJtHOv{!sIcj#)7;%**{GEaQcGD^c$T4 zn`~gJ-eovrwd`dMIRe~#Z17iYW74Gyg2A)3oCzu$pB**MC#!YkFXb1p^5&q z0j6#9C*Zk>ulQassUj4ov~IgX(p-_@A>*JM2E8+X@sEn2Sgc)0gAAVjpF?18=97Ni zv7^8jdp-1ZuLG;E61;KQU}a&)$mQ?DONq~t3URb3a%%@FwkqAwso_QhfJYmIViRjM z21KK5yjbac2wXdQdzAlbKVpk!G+M;CJld6jQp@M~kP+H6M~hE5Z1^{YBaE;%iy3O>E6GvG5Qqd4*5Jm$E74i3X6kF{vIK{lC82Ka~I~r<8T= z;UiFX@mwA4W1~9Q-b8S<506B@K@Cz`jQsIgRj_>nZ|pt!{3VdDj1p`!C?i_%e(8bB zF5ZWA&4DQy3cOnR?4#9>R`bPs#;3#l1x_d}bHjoCnP3}!F@7p*Ju2GkiB`(_i(Yra zx;0MNwAHvqwY0vx+zZpGxYT2VHW};AAZ%!A(_-}cDwN-2D1WHghQb zwbbY*;j%$qA+_OX%10F>_w~Vq8;y~%^$q;{$=~K+_jqhjRx}76o+CzD{?G!8R~w-` z0+>27Qq4^Y2Syv-p>nJCQMXw+NK-JtF|diGD*!feUiJLcg1=BW9ujh%#wO;~Nyv2t z;O5RgjEzbc*11e5py-BxEK?}JVQXMxLlT3V0XW+2LAi)%uP0iG^Ap_W+*X05GaaT( z^21b2B)K^E`VxNKYZr^C#AR5AF2b@H5^ymHxE9E{gm*KX4Z!sfu&Mef0&b;v9ahl- zj%vP9dr5i)(j);VM}B2!6-xq+M36fi>NDBz^J)Pp0q5!hHl57|`|@Zbn;bI>Sf_N#}dSCkl# zixhAKHyYWPl#8!xg2RoZl@9_O!Oh(_2YeI)_11$r4I)lXG!ila&WrHsXIdE!#q+yt z3lH`9TlxK!3V_k|mKO2o0Et!_Q8o2eZ0)1>mUj#PUf!fV42Dv=y_SHJiob}{MnaA} z;$0`ku+K=e*4`2En-pfGl^sF>rac8lS&H{k(EyGaD@s9rH&`@-q@O>buXuqX$d&$q zkDKQsD~EPH;F7QmtCPJ#0WJ{%cOnTML=P@6*AB~uhzlB+YAG@@}+kIa3fX6E(Lq@dmL^k zuireVp+^CZ0Aj<+L+eP2wIn?@4g!K3L5=`N+ILV4I;jyOar^GW@6+Btn#H24to*Tifue+SVjzn-Hg#=vENZ9*M4D8*5_k`NJr$1bj%qPEZ zE9Rb^N86uI->>i58;$5j>6XC|AM1f-Yic0>7J&|$hM-8d*DB!1iz5$?v|vsi3sn-} za&ArucyY3iSC)HPKe9t$ll4IRZYaQ!$0e6eB7>!m;3TyNHmOj6BRxL-gTR0jg4}|q zFNMs(O0I=CcTgeViZ|86+`Fq;m~!PEXh0$b96FI3YdGMRA>8s2a_16oOh?3^E-Brf zU2Zgnm_hVHAG=$BYH5WPdc;UqdjO%ZpqgoFs3l`jIV8O$4BbEn*xo-5o~zTq z^^=iMe}fqy=Js>+(l0+*sf*S5t5V?(!P=(*+p%Rks_XW~!T1ncz1Vz4!?13zsR zin3@W+OI9fW(hkFix+5G)x?6{ho#K`4gHF-#M2fwv0^dd_Hvv?6H@rlg`kQ@E7l~e zo9IItdj%h0;t?}jC+50g$r2}nkV&yy%u@?!Lb{w3UiT`88+y3`=@ncxst6nM;94xn zU=zD&o4y-Lwn0dVe3(D00!p_E?~J4w42QD8Y|f+_h!^>jJ)B#~X}5FGSJ!4k`TNuH zdDlbXluuyQOFW9;J#Ef!t5th`N_kcd!ssNU&l4g*<;dVv8QoGy*)zN#IQQe&Q_-SVEhlqHM8R zADTyd-m!W}nKxAA?%MUgH_uB9r#rXAuUbC#?b4qst|CyHX#u6^z)9vekCXvsM@0+*6nzbW!z!T)o$ zz`S9Jeau~D?I>cv1txcg$pAeAgJqlwHgZ=cb0NhBO0-WEA=d!aEP2&7K)DsqP&2BUb5^kh06+|f@ObEF0 z(m}BBA>L;s0Y_qxdr}*zNPv^NXg1bOG(e{-u%(S~qZLr}9l*5;X#$)l8T>KzSV&!X zJ8h~6RfMZ}oZ;cXGH~u*)@0JuOn0avkZX|_=5h(yRk&kUWG(Eqx|5rmyk$7xj z&mTeza^ePQs>F6m6xK%(}t`kaR1*LfK!_<majJI?DxY-&wpLo|Ihm2At>A6WN;(MHAWE)AO*9}!OF?m6qR>* zY!gio_Dl9xQ-iU#8MY$eBAWCa5#SJRgZ+KrAbN0Z?5!>}1NF?)E3Z@{!mU3-jwAs` zBFKTm1`P8UOj2W|09_P-BTo*SKns7C2jzd^UeM!D z9EGwnJ8Z*5n()0QSpJO63zP&L!L9E|fG?J#H-~^DxRD|STrB1bdT?@)f2pWfVzKf3 zE!j%mlY|U{4gvSjqwhlYC%kGURQcD->h`ii$zd4<3sR%$@9WW(*~4Lmt`e` zpt#uhO)eJ$8>M_q`mY(^4y}m$*!27o|F15U6s#p4X&+m(20cMDuqV_O6KaFXG@k)i<{St5D5bM3KFS!YMQ zpkU=viY%NQkSI6zrG*YqLoQ1QAEt~5aDnh7DNKe)bF0HGT&zEPJl_IF1sutC#AqSc zAmrS+nb3j{wWE-u%U9vHa8@+}&iIXGFT5Rq8*PX=bjrN}xRF8vuGs}1TQpp&|fW(Z(#8b5|kv;$D4S{0E|IZzjBVq%VSPFl;O3P*c|`%qu(udAE*bV3X2+ zEgayY?qk#Q3;&Dn~d-zDcbU-i+!K| zk-zJMioI^A_&ZPQUo-9{Z;AsUN6`Ul#f8XpRpI_j`Txn+RKmXZ>jT~!DH6a*u+d%6 zSc7xru~FUdhAHn-a(_8F%|?S8jq8XgOc@d24mTNVPwM8njd{h3P=`Ms$My`?#4pnL}S{|1~rf6z^UuaeREPV+TyHMi@8G3JRYgXXH-Gy zpLpxBVEQ4fh?vHeL4$P^9800fO|r-z4KJhm|it$47XHV;IQa$B!NgKa^; z)q};)3x3oFTrco65;6ccl*iaEhe`i?{r9v2RJN%A9$v`vcw$ZyEcqpWI6HP);8#yu zq5A*l?n~gJth)b?AUJ^FAW9%^xS%MxWtygGq=G1}EtX}PWtL`XzLw?N;%ke(re(Ig zW?80Xiz|tsMw+IXrfDdOC@v_7AR`Ejtp9WF^W2#`cb=JfSk!+1Z=cg_GV?rlIp1^6 zJ@?!UrGXZ$0EixePfTmsYYXIRw04kYk0BqXPZZ+nL@kN!8YeL|CF3_;XcR1Jg2Rw8Bg^U1M{Eifc;yaQI0lR?dGS&f?)NT9yFOL)0x6 z-s(Wj0XUa#gmY&|{GoA>lD)bOz47fRz3)qd_>EQ%Sgf0n~j zF9hPa6+@uvqYBxhw1b1sH?LRLaJO$ULE?I$_yPX=0izVM*}g6t-u}8X^dDgzvq6$U zW(ODZmnTiJwP*a@&>$~Zo?=Z*5fe-Z@)|6M#bcu=oCpU5qZf8}C$*m(fF|`Jjz_KQ z74tPHu_ROd5mv#+`tP^2@^T%fG@`i9laN;%q&bo})IHVim1V;$%^${v8 zwDx#OYAqBfL5}=>p^%RxClj4Bf}_FB7RC`oZnR!tE(>MkwsehBPcox03JU7o$;ho~ z!Kc_^UPx*IQ0^W86?FKhSvfX@_HgkGKq9undY!@N`eE=XIbvB~BGicBD5J{ITv76r z1<5=)TMxIY1lR3$HTw}Ue_kF8yia|Bc;{VD`1(7=+9n($Ae0Ygr5oS-?$=_R;Xv!c+0Ma9aiy{O;>Hvp~kfg_CQ zWmKQk2y&RdsveX$-{dfp{N03_dT2775gcu;nfHv+?~L4HPvPW1ZB=ZYgR%#12X#2a zZ)nXPSuJA$6>3O7euF}SK@EZ#u^sxe0-l)U0dwB7k|TpO{`C@EoJwd3Y64Nb`p6CB zDD$Tqm^$R zm;B}OAE5U}@ho;U4#Ac_Y$qINUUEUH#vZ|j+z8TZk>HpYHxs?Mwoa6YN^l(c-9jLL zP#Y+SQU^8;Xg50ASOq6I9+s?wpL22}L=G>vkQ*VTY>t)O5akuh{uClO_wrok&57ie z?fV!bx4`_BcH~B=5y5#qga`wDzJni)z=(VAt35``saFxjpIfUTwo(b6LG>Ia-;1v2J8Clv}(vGs@bS+E8rW^Wr9mstAt1iiR(;ISXqMP zrsxI&|FsxOAVP6BctW&FY6S6ah~!=$uiQ5$xf8!A3cufAKiIh?pFP^ZMgMI0A>Tr7 zM5wdA5S9lJjtI_<*f+S|zAqdHjzGL3SG4+CpvwPH-IM?tT_>GZ`mB zUfpmDxe;oW;K*8)*W{>vqEUU%gyfflBZPjE4;=aT1O+knfzgn1TxlD{d!x3qwiQE5 zf0#ZVC%Z~=y}RVW^7TSU))~PuZwZBnh{%l*ow5#W@#56}CX$OnPcC{qwp*^ZbsGZ* z9Vf)Auo%ZE-$^j7!S4{lloG+w26=IQn-}AHK^bM458f_5e~SQ}hMx z(IfcXrbH-qZ&OT-^y9YI(`Tfz(JxkASEc3}GE!a7BSbd8qZ>OJ;5o#mBAA9;+&rJ z?ULX$tCy&t#cgU(A~(y$VS_mfNy-rWfsA?Igmh^7rqBug{K!QW*f2KWcaVJzClg)( zHX7eU$8e`LD=TlxE*QH~A+iDYrNe@E@plT$kaDkb$Y)6M8t^l2LxrOJ4SVzucbVcO zue{72v%Q~yLeFjxnxRy^2QT$TD{U2ZxwI(mUdowBMS(OV0;L`nY9BI~@ZTq@R@PR0 zd=d%dP9%aWr2Y_cd)HUKFX5-wFyonMFyQwMxCgzrf2-udE0_4=ceN&BQeI9Ua{>}s z2shYrast6nCanRQ)sQB6n6yr42`Q=})kezw?Mw`XxicdnT{7eXQ1Ar1$QQD-_kyzY+>R%{ccG?*cn`E ze_J3aJbzmo9d>=a3$|_XgY77U;2;C^i%P@(Z3C|@PJn)WlzpQdX7#jo3)E6~93o%D zqfzqxPch7s;`n^|9*7xk?IXT%pAvswnjQ`Q#!MJ9(gPyGl@`d%E?4BTa^r`Oka?is zz~NHM5(Niczlhdh?l5qWGNuPc`G8SMdP`Rg>Rmr6_mOot9}XN;<`_M8?X1khL-<<+ z$^K%)moRT(2t-EtfkFCyI`3@<66DTJikQvC&~H>AytY~~@N9d-7P2Q~hhK2={fP57 zKxzbztYRqX9tcHGSiMA>!W^XICH)(`EgigfTPL-rWwBR9nWFE^?E=6wydC7J9WL*S z7T1hL{;*r46UJwV0a6=yIIn8rl;F^N3qlJ)UMFNAa-%&k-uMlo6<+vQ{^SI=!Kg>HE1 zc;cXbX_GQp2MobJ|1`-d!JRl32!n?Vgc!8;ms$?=tU5$Xl)A$q`RD+B4JSCu z=2rni&uc@Pk5whOLu(b~m9!2$G^$vlo_IcBu#y}ydYtu`mwwUcZ3$Vc3G2e(hj^tQ zV@9OI5EMrQN8~mYJ@4W&GkRM_h(?+I=ovI$3~>6gVw^Cj1lKKG$!zX>*CiM;$`g`P z${EQKxdl`Dor*jwSHk|kN|+Z)IDAy;pv$?oy_3%Cs;S@vHDHMHB;qau>_&3iy;=EU zb6u-`>fX)6QfrbxWalFd*RNMvJJ4Sy2AgXY z=L$jQ4=sY@wT#(PSQ!L&@Tz02&LKgj2OA*4jYoo8p}w>MRTd|q`d}hzLU07$f#LV6 zgSv_YXAY>RKEuiLR~8(~vW_q!x)m4DLK~3Igg^wx$&Ek+$H~nT4kGC)N3{^S(en!p zUI(9&gAiEs1$!ULrP@Ve)@B(!S0+MP0NH(`uIq>EMn>-4(Z&t#UL~=_$J;a_{AP^j(Rj87LQynjk<^p96i*QLmF5syWnMw}VJ-jjiNHa7J*ZVPRmr z%ev$vMmRW}5gfZ;61)aRZqyicVFD7Ix~^ppR|K~P3GN~Fr3HM+eO_B^$2;6fjw;6^Q^++y%C1Sa_Zq)2QW0>KfU z89#-2Zsf%+{?Z?IY+MU7o>aX@MAAt`5gI){0fYMiRG5;-RYig`gAry;j63^ zyKN79&L9!o)EB2gJbEfsAvl}U0_6FiK2{fkDWr+&5FBNs`Kwck6j;ihd{qiy&o9E* zKtv8w3^*b;f=7`uUj_~?fLYT*;OL=17NXQ5IP%~oJpCXnU7?Uu{Dx5YB~j_WsF<^` z_@mv7;93PM5s{_KlA!m)>KTP9_N@L0rsDWmipCGY!`%l4Ml0*OL~`{@ZO<~SD!~~f zU7uTIOHjrTZ`k&ewN1!_6VcRN8gI5QH3^Q8v^5{40D2Fx5=6}XfzT~ln2U&F`R`Gx zOl%ykLX-rR;0UHAk(va@$&FAIf+G(uhzfUL7s+dF9F7Q1^inP4MyLzlBf;5m6o*rS zJ62qoiWA7uVe2;p;(h!7k`6(0Rm+V-Bs+pz6?>WAF~t%nTv&kvE?50dFMO{7N)Enm zkFE`QT1ZefsH=o#7(!8b(^xqv5Y{zWwp zbOIn6tv@q0>-0ajiQTC|d_HZ)pZZ4k_)$& z5T(%v2C8+0NQzTJ@82)Wf+NY+HVkcPfYz#zFyP_AjA`BwcyvSB02@KJ#* z^cuf580Ygy1m*eYF^>WCzr0Pj?#-&1%n%QVjJ)Y2E%9svlV4VluM+QLi9ASPU zDqL(6DWpym;I0y)Wo;0rg}-Wh7GKBvlZTu1ePW1`kobpH!4pc#JYit05w^vdU^~iy z;l8k7jpGoc93hhn*1Lo}AJvLc7Sy%XBjx4F0CvwagZa*2NS|PvWEH$69gN?mSi+Ka zgeF7SR+7E|=RID#8C9#BdJj}vQiNSd5}~y(PTV%o%FFR~0f!$TxzVgYDXZ%n4SD?d z?y{EgVyG>Ga|E%4iRdK4aGBO__p$W%L>j)rpsy?Sa6{qu_u*%>2!1yzqnFk2A73+b z1nGKbO&bH-e^Cf{z`cdAU><%yksIMI+9ydDEc}f)A!rj)#ZL8wLUKEQwvu^dOg=39 z=m+(c3TZ+m{L&m|Oj5Q8ELs)^(Sv_oz2D~?~FM7qwIfGeQy&1U~ z44I7Rh~OfJ2f(~9)iq6Zd~CpLA)?!f5;HmwK2nvuoW{>Rpx(ZFArcA9aGw#9g0N;s zAc7l+#7>|`Nn2P5PWL*S4B`6naJ+hceHeUBseMmyKyU;ixr`?xpg`R^;CnC|0@o^M zC35H`xWZ0>ko{-`t7Ia=8Ntc-%sfAAP7Apal;>?7oi5;v;GV;WCAXU3Xu^uMDxonB z5OFj2jU+fi9jOQENfTaDC@d}72GZZl8qg8J5xJ2kHwHbfKEi->L~w3sQPJtz0>LT! z-mHa*52CREZmRdz7KtE0%5Wn{0}vz+E&jVgm^0NHM;{QA0Si7-!&3y|@8dRx!%H)* z;YUtz1Ws~~J?;(*xbj1oG7iC+x>%e%}@anxSH8W=-g2ya+UPttjau`U(R;I=pi^x zZUg!mAo_7SQQV5cYwpZ@BajEjw=oa~AkhyQ9}GQ(jof88zN!_DL{KP?ZzC|FCrlFZ zdja=U;eN95LYc`}sv+v+#H1V`jXB&V0)G|3I8x`S;4(~#ga%XT>< zI98tPpFnT~ha~s^8^N7aAD_qXxeM>DjT8cu366-(6A6b79hC@f>;t19<+u_KW!~Fd zRB$x69FZGA8h|pvnXhF*(q>m|DcfArYJjJ+|I- z|0<59ufaC6CpZENQ$An~(NN3g2;@firw|-pSEDtP!#na&OmJkU2~vKyW-U^>xqE53 zh1>{+YqlPc3{G%FZhQ8cVc{pi7IGuhl;Gs~cy@lm!Le~rvBy3Uc zIT0T-FDe~$L`~c0zt2gpX>+9oLxSC)X%lxSt#F4Rv;m3ah|Fm3EDKGNVBd3sOG_<* z#Gi`5(8vIheLNsk{T^zrm)@h9v$+7_1w+cqoYyd4b@LTDSs!|NFp z0Mp(Nv#Twt2SjWnwD;1i{=Jdwy~}0@{aBdOP6&>`Nsi!$Bv&1RGmld-{&^m{lA7~%m7K2^LjV~|xLl;pkxFFiL964zTj zwti7=5H-RbqEQHjx>BW*9BbM-cz4GcL!$1bBjn71gr7oT#xsg%Neb&=rh3>bB4KkB z%upxeqE*Ro*KoB0iwdqVIEp2M!%H|mhfr*=hRX=$(ge4I)E~4VR)mK-@j3j3HKrXK zb6~@#wrgs=`x>Bmh#QJKv_PQ)Buj7%6{*?k?o#Q7a>*w&cv>@Vr_KxoBW2<8!*LPu z31KjGLM$v@5eG3N$beBlP%(At6Ogpg1bgz79l-w)*JJ z39aVJqPW$NFOL%z;qDyh_QoLyZgrKt{QTuUU@E8wq3r~v$;TJf9CJr&uN+N6mD~tr zb(Jst--kWmbXr3+kjr5Hf)Z%mTJ4xN0=W@fbujG8t30NC|Qb5=~3aL;`O0=W^a&zm$agCD+Y2B%L}K&vn}w#n8|U%8L+qFv~r zeFH-u>jK%3MFb(HZ*N-yN|R?m|(j$qt&|_%T*0zJ|T+W6H-v;U{r5T+O6T<0;`1r1ne!>hBUsep4vP#KdbAh@-a}vRIAJ>!-Th#4d5Y)gO znyLCZr4aiAhgLjZ+TMJH+;VBSmX$$h&-xJ9*1#SI4pAZ+-I26N!ebgjXSHg{ZQAfH*)B|>@ulv2w9De;Ii`6ZitlqC(2U_lj zKEMl9$3y3CUNC5gFSNz`BAh%{%;e`S`8d{#EhHz7KSCBx_Z-v-Fw{dk4BRGzjk=YhuD8lOvd`5XrAYv;#fbXLagplR*$&s-0XJzsY zh)sh9%M$Shy2A9a5s@BF zcyNK}sqm9I_TX>tK|ph<*U{~WR$g!xd%6IMJPm~M^jx7j>wI?IP&1e^|88anMiCz zZYioqDyQYU7po1X!VL-RPL<>cygfPgsC`RF$LHk15jeq-2TUMxBN%W39NU}&>!zkM zFD~WRBCGdCkmr|rKNiU2LB0O?A97G~A!&~a5iwx2;74saw}n?0rLxBE4@{jC3fFnV!zbtxSY zoTdJx^V_ip5gd^lUlX{b-cWT(&Jn?Jgg+Y(aL#H`{+}VZ98`p~HDCxTD2dnzpG zxdklUB^5iC3C^8cRtWUuD5BY|t#vQa2Mh9H;6P#2RF&Z9^E%j>oa8hJj-z+8LpB80 z=ialh;A8m*YzfX}9tGW+DawOFmCMvB@fMWXh9J$?!XaTpIV6&35#3`CC~wHb#v!fA zb!%%~zni}-0R}$cr%7?|IO|P zqzjO9GN{9NDHQ_Ge&G<*akU^fH`U5}d#qa31F_(uL>9i1OD)Dc2-3$jhTNjk1cG8g zA-;syeV6S8m{pgK6z_n+>9FK`w3suTCk189b%)<9A0 zA|VkZ{cJPg*8Z3S|9<8?T8=Gk&)5>7{(D+5e`%6%61a*!{jS2mq34(o4AL4Ey(6xP zEnH!Q81YCNPMGq3CdB6-X2OBr%%}jY-+Axgs}K{L3PUKN1>x-e2-|)^Z&%GUB`1CymK|mKD*gGQ~k`|}K(p7W>mpX>kLJ<~-m5vBfVF;)c zKBuR6KMCveV1{~tEI~Yu$5B$XBXENA{VN-q(y0P+4uhOj=K^*~DVfS#%$U>_3c3fo zB($1>w-#Q;$+q?E*Wu*;WO)9SH|U1*=TZt`?5m;B#^VIIofig`ZDAAq0!>b@J;+}#cW@at#@%Q+eY_8# zIZh7RXwG3if*YpK2hczSXfw5ApK z+pPUwCS4D$LD@hU-*3%54_WD@?AVbu?Ul6wJE;Huf&RypCkRS52@Nx_HM!KIL9l&u z3pkb9fRS8X4Gk%-iEa8W3)*%tps2q`>wY8}=+@I01`PFw*6o${Id#GeDaTtvd|W#? znHqqPVQg`l)`l#Q=5OoU^Vk~PX;nhQ@*zFV8`4iHp3&z^Z57mjdyC=eN3WsbV1j{t z%@Fy3KSU0$YmtG2>si*}PN(5y!D~O4YKF5YePjKhjPGz&A=8mKKPU|O<@_vI_JIjb zpDcv|F+MP9f)69MK(*eY36(7o8>N#I!9J{4%aGdJ!qGgE6k@$XUy=+@(a+g zoewl@ug)86K=G0qV6${VG77-^@W$s9;w3^$#&*ia+o3F2Dq2k?&`9Jh!m@s$X04>( zz`rMf0z-h5MiX@T6I6rPN(4hzw~1-9c{R+tE$4-SeX3(yG?)ruhm^4?fz zvblPyLKQ*Y+q$*6K*#Vq^xo9rGi#D<*m2&cp-hPAIEduBH5&`t5|v|(WGw#F8v2U= zQTFnjgRXD!msG?Vy}#HKOo-%2ral=9Qzs5(62DP1(Mnl+Q<0Ql4L^|=#}PT$1NNLW zT8PaV1`c+I1xq|(zOajQcbo~{mfFb~7EVls_nuU|(D_RPnG8gNyIEl5hFJQE)8n@6 zK%N_Ua71#G#*Sg6M&27oO;!*(_qUH^koRW5?=<31P?Znz+Q>^wMca|0$(-0Y_=yZR z67lN<{o`JHeHJxRxqmv2XNRy>SY70Iw(dWy4X`Z*NNyLNuAa`RDHwN2(CTj8rMYO| z0)dE)Hs#5i5os?5ZHjeNcdVxP`c1`c?=vcpksC)vA2XQaG8Lk`SBOva;8P9bnWJ-m#n4ILr9xqo8~(=*z|yx_PSN%Mksf)=Po;Y>UeSG zdK`lz)R&5nE`aZ`AkbQyANG^hF8kUlWh8$!9nnLWH8vTxqxZJ?q%}*&w`@N&t*f5Y zNp&RyR3%TfBGXh^DUpY?hGiFm5f@$bz*qggp6ZUyhj22sR|$Z~HQ$za%#Cc{J2{sYMzozt^pqk}Q##lLyPb4};hTR3$CN_{}BmuoemK z(MMBZ)TjVh`<*pQQGpp+`6}c`Cm1jN21%3pQ8i}7T6{@yc`#F08=%GY*fC^i*pus= z$?1@^*<`cPb^bE+N*=J1A7S>y61@Kw?D?YQyC8bBLi!fCN#TGJMcH-mPe%{V2R%7D zGtP>ANiAXD9wjVBk9+q@{g2HzV8{tr@aa#m?WZt!{a@BSb7V0u=Hq4CI)K}d5DjX^ zDs7bKo8@!Rh+>juvnIt5(G8Ne6vCWo%5_p5zSmZUL4P$hT~2by+M5O0bn3E-O3Gpt z;QEmMs=>oxJD$G(B?mUHZwkXlX26##|3D_AeT70;u`Cm^eJ4U;y>1YiGX?Kg3ONe* z|K8iZ;iDDS@x64}K8Sp1446cnBF{IC2oMK(X_Il>rpD#M%uHn+Dg&n$jR^WmYAFp> zbW`Ag#z7HwfATAbU}qfm@iKsZu>hfII4&AuUx%s2C(cj8hK)+cj~K6&c4r%ZBp4w#8Yi4O&+tnTB4&H%JiA zrz%3^cEMYb0_P^>jxBzS+z3Q)3}#_L(*dYjK1b{W0akKzfYGBHF>+hCE*B=Dp%91d z$;F1;R;rWIL0Eo{kW@l@f0WEI?J35>mgwCLh_za>FHOou577FWu=sPOXfGjglQj8V zAZmo|cM*E_&Vp$(4>4K2E}m7kC!r%;NQm494ozj`M#8aZWlJl$5jyoi>4}O8EmRUB zw_H?S$B_VzA5zH80U`(cz#jFK1j5{Lsjx#RFGa}yBO8*JCPO+E!@fm0_*c0txiN?e zG|xhAf%(hT_XJ4B)Qhj|K`+|6je#I$MarP%alPu|gj!3J|6(nwnQEaF4Hu)308Auj z!4oSnSjY`=tH@1-9e3H- zgU1Rg13c=2U}GZ$Ym0ktAw+DFr6p%ea@4ex5$8`Lw_r7d!o9ZvqXJ?2{5H1aCZ&Pf z9G6cBRu$}IeO!k6 zS&ZDGM><${%tZ^Fh>aJl*788;lm_nI)w4Drd2#VSDEHkD+xT_#UflS*u}z5JINF3W?~Mp<#d0Hzn5fRza&y`xXj}Iy&*HTCq)qv>1d0r@h{JWc5$c=EL zvnE}kv;-P;Xi&$};5KyQ;Q?LLr*@!5q?B5j+=$r7W4lr7Y#e-li%4z@R!7)-Zq-Bu zdU7PtPmq7p9yK*7goFsLuO!t3kz0Ym8m}gDs}j6$;uLwH*J1Sx=s?e^VD;*DuxwQX z#9~`=S&B;6LT)nDxi?2jmKf-Coz5N$mIcDW%uvajtIxbSBDd(_=g@;yLYI64xgJET z9^B#ILty0xJz(Y|qhQHGDJ@+CvnCH!pDCW3Lvp)>EGZQV3 zfY)u^wjsy$HZ~3Lo)$+rd2)I|#6~cU47VY-e*`?~fJ!TDhSurg%E{QIpQtZwpjF+O zgbqD*S}UxokVdTBfsxG=<+^A|GDK6@F)JR~&#L6hrI@rkKT+E)1idqyXtIkTABQ9D zQ-XXX623P@+@qow(*CVHx8`uBNLg3vYJ3sQb$g>?k3f$`QGQfwE(VEeDbFgDL*jnc z?@Nel4Ktp&i#;DO61}LEp_VjnhY-V!>~>@iQ62u=6qIgx=Gb&d+GI7G=Pe6`n1=-g z>`LQ^pQq-)+&3@7&LrzSWnRO+ea8sV0}L?zWfLUsG(*(A9I{DiRVMFT6^*;YW}XKm3djBzqX)Na`96pp&Xug_kpN7cJcT8-5SD)15@N>CM3=?{Arr~% zFZ67Dg?nv;eddp^Zxf6YZo%fN#t(Twc`A|l2f(@=2ADHb>D0(U9x!JuKaWqr{*Th2 z;8X$lwpP~q_<@}C9>K%cw!i57G!gO$m1|kbS?TWTd1~9X=EJ~2eh~j_AtbKDK3IhY z13GI4lSF3Mr1>Gc4AKSg+KC;SO@t^M*YEsjTN9Ux6PF6tWW>vHv_{9yhr{omMe9&& zvtj;I-e8Skl!r`B^U}&=|P0e;>a#2z_j%r-G{<4F9a6cT>HK}X;Q3Ox$D*5kIC zA^7%8MsAB%gfNL3t|o*yK-|VKTXJI?%$@XWP>4cm?a^xO6RA9k~FTO;_j_Op;1fryP09!aT~eX%XW@R zY%h0gHxhd807Pzyn?oV{g4&NN4Oc2$-~KJlM*p~ zIO-Ac=mPO9tec5#KW4M!62fBi-YjsiNK52aZU_gHVH9|jUr@X^4G^i3OrIPDGoFb? za*IZClgdTefF?0@t^-To2~vU%Uwg|4ugx<;)O`wpCH`z?Lf#vDjU?v?v4hY{TWDl*EH50QNBA+} zuzGK{h})znp_tK9^%N}xBawCOsgN3xoCqSeBm3|Xf9DGJA&C=kf+N@wTpK?NsS!d3 zg)rfu*Y{dTzvwZ^@Vwb0_k%Z8R|(IB8b>X3Omx-aJOz7fGY%I6NlHgj}*vlN*7& zIqtm;L~SL&fb6M$`u+qaIF&r7M%* zTDd#iGb|kkXy%Vk8x+WNa z*7CpyVqsfC1W;{Q;Sgm2Z+t_L_=M^3%Cx({U@$S6{#r1L)^TwOTC)*_28k#%NCcsM zTN}Q`NrsOk{*6%it!ZifJ^Allf!1V5k~0A^P0){yw`qyqFiA>2^uL%610#*F_@}P$ z*Jt%0#+b+Cs@ z8sRmf42Z(N!&}JDDPGB;HM}d% zc}3`XUYUu>gzyfGWTe&R6pb1@t%=moUeIQp5M@cE|h5IfofNiH7^X>1hID~(Z4wTT3C%WyCU z1X~3^Bf*wKJ%9#F{X8ZiWZ&=yGMZ2BKIee z8VQjZB|MT>8i3F7K~W2+B`Yi0ac>MXWEhah+R$Nt+>PZdPmRG(2e{erQ+ppXhQH>05(uFYuG^p{kSmqEwzMVm6OJ3HHVz}< z$Vd~`s}P=#c7ugm!r`%3LZEYB+r@5Ih+n5%bI*J1z4uvDl#t(mn0)9pP#sIv0#1z9 z?G(?9gq}CWz6?O`!kdTAalBdt|Gf&HdAb`d{aq*`Qol)s)NfNEH(7Z<`*1EKuS{i+ z92brkFd<^=bAL+|WenwC5;87aVEI&zrD0 zXH7z3y8uZ4S1-t%ZY}KmPXO(|=J%#m7E*;h!L>9%TXORVVmOkB?N*Q*$L0;bIHCMt z=?XQ}l#7bN3zdZixt&N?YB?rs@IgWgWJ08txUmfr4yutzn~wLK@5+dcNRE)CmdF}0 z%yBs-y(sjIfb>T57`X*XnP{^nD@Ugg1`M+*Sn;_QrVHupkwbhSa;WzDV{L)Xq!P(( zK&-KL$W25WCn?mfAa8;aK*~5~lo5LNvn9A~n|)z>yh4@>KC&mje<~uzg)t!zv4ynv zg`Oir7_l|)p!CVPV|j4lhzWdev!5F>h}=G1S;R=~wUzdvr-UD~A#rUsBeo%<{h82n z!qg`tVd_MCCGHHd!%smhFJ4eZP^gVWt|QqiDge%GwO!NmxQXFPpJ2rRCvr<(mkkl4 z0%7r&j>CAd_tOzfVGuP+8S{zja$)K-r7&f3i4D=snC=NtQRNUFEik%@cqBOXh$J^< zviVxn2Ld3hPmW+{|@0>Q-ylT0!31 zvEyc#I-x1V49|xlBh=JyBDVnS*rr*EAc)xFet8Uj+A&TgwLl~^A0`}8!_$}$v9UtH z;^tu$oZxB-BDZY6F%X!)#)jOojT0HUQQ=_%)oA42n=2&z6b6Z#t^3pFeQIlM*EWdU zraovrdWgtvfqLZ2KOOOFZNp%MeLF*7?7&g5O(nPaOSRYLoP&b$L>@G4=y~rzJldlRHxlJDD?U3B2 zKaAeU=s-qn9E{|aCqc}xbco*+%DlO^-nKo^(oxkLjwCcZk~I2%K>996#HG7Yt%=w5f7f$eUFUy7!kCss6Ga9@L*Mes$ zdV;hjs3IXQ1YVh8C8<5>U6l!k*tQ`LUYc+Q%;q5UR^uRMgi0=T>447wByIKY%JN{* zw_y-{Kd&{ZJX9M}|I%6vVfwfekcjPSY^0dPNhg*4leAuG1EIYcTv=E6@5>KlK>44F z_vCqZb97IFl{LBVDNPz1d{gueH>qt{jrFHKcO-fzcR!=c}cAm#z}M&#?4ATGmh+nC2HxorE% z1OtZ}QT(xe)DJma^@q^jL&8%a^Nbn#;5dCn&A7|M>k&N_QtIgkav^D1Dumt<0KJDa zN5!o@CU<|64(CppZPsE9zP4In&TM6_%w8S_eTSnbA>BXy=?mzQB%;zzDUJjq`!1mo z)PJ9VBsJsFGmwe%WB6z{7;(Ql6EJe{E3<^E2f&&OtV9tyA`r~-9(`%N8l?U- zNaLN;`NA>DuopdCLwzrpzaq@C`MRnY`%p5ZU|WZy_eYNgHDe}pNC1Qm#r__k+|TEe zGT=~L0Sx$02#lHI3CX{f!H#}{AyW}Ou%#^_lEQuJ*E=J)>EqFR+o;UEMPE0zk{jU$ z5F95rYR7%MiY(+ts4apkUG@jK-4OzwPxn;buPVW%AhB(reP;MvA~+&9A~_;Ba}ij` z?O?hA2}$xop+C` z@5hfh0UOsUH%&x0dTjK~(vR z_4`7R#6ru;VGnw^L~gyXea4Ilt~R;Rwx^enD2d!gqxW|VU#F}$LHcH^!JdWlv1_a^ zBey4(g+TA86msKuf4EZL@}RozM>!MXlG6Vrf}=2`A4&CzjpHN|9Gg%U?R%`SvXYD+ zn+!gJZ~?Uek>KR52n0Tzii^r&?TT#p_QNxffnGS(;G>JBOe%Wbq@^49H>v}TZfgM1 zYH{I#L~63ak~9Jl94EID?_L%UZqme25Vxs=h2V;d-Qm|?n!s;6o59g!Bc!GIL7O)D z&@sXUclWsj_w>&KFRyY&aLjWvMBwBI1jDt>?E4tG2_77IZ!;$KM2pV^kugS?_mQe- z)$7wDxC`ffVEV*pNIs;5T5oG|9YVv5aMe@}`FSY#Scn#?4=OP~BZ|K}G@wUgUCZ-` zJG|kZs0QGr755?7yzK!SzRrXhLLT)ib{9w)o z+sY|&|8@0Bf+$qD@2lZ9BDmtB3ivhQD(u^F4boD~aP(ji7_iOgpxHLF7D z13zu3#NkYpV-l<+r$KPEHRB_7YX&J~@>nT>gwnlvDaOq|aB1=q@wLxaF&v7I6k_>exuN!ec*HXJw|Q>nc$Xv@*VuVH3)X? zXo4i?SX+B}l|kQrS@5r!zd_^HEg89an3Iw0&RWQ=(0dg4cpkTq8-WOpKqN;}jvY%x z!3b{r87L_+Lhn9laA2<-`>H-jf%hHc2ZQf7+LN74|Jo89_uy_ z{*7%%DtSPPB2+pe^lB~(PgbCKphxT8y(xR&CBd~r<7Id|D0cH?%67|-CfM~;9u$`< zim1A|0bS{z|5$GY6>c671q`R?&|3Q9Xoo!Pg{yC%o!hS0ymaW!eq47n@jmC5F!W0+7KM+8%iJ{ zP&k%G)079(ZHTRR{}TA?w>t3KRyS-T*Zm#Rqdvr}2m?RV2R!7?aV@8&}C zfsbWc#W8OhDxo8Kf47fn1YuD|@bq@K5FAJ559iQ(%Vz`^F`z#D@=Y$B+EK{Jt?W9d z-5Z0tNZ!rcdo!}@8fk>?1C2jh2&z z>?G7J^xTfnr2dy{f7yjJWypW8zNSnc85fY;9v^g+P5P>W$nC4G9U&m39p|@}hpST8S0bXarfVyDHK`UHpui6Gz=>K{r1VWnQ2$TC82jJXk>9BK)qF|^5 zqX=Fb(GkRuF~KgRDJ&3mZ-czmR(YLvPO(%A%j-b*UOsRq3c+wD5B4ROKz+2z<9A($ zLn&o&{RT~JGL{;k=X*)m0Xu4f5ym_j2oc@Xgryqxj~Q?C&)o&E=Cd65S;w}J8UbCD zax`QgoEi)h|J@Al4=r@{BaS_56Cr+`55$a848WOBDxuPs|9cX?T5Ld)b2-CpU~E(u zT6Fe?1SD00vOZUWqO|GM$D2Y@d@k%sSJThyT9YJpY&OG;@yhd<*fb`c?>UP)AqI7V z^RjC)Dk>|W*8pD#dpHO>j}C+@s8fFW_ZdjpZi3QUFeGXlblJ0pv&@o&?E@mJLU05V zo&O;~R=|~bY1b89c~jxv$Q`E435G|WZmhne!qs?pg8jP+;PL3{rOMY7MBY6H8R7Z2 zTC#aU*tQ`TUV8W(d%kp4HhN2i5Vyex-w%N8Y+bE-gOkS9*cSp}=P%b_;}1&z4o6Rn zh^~KhUzjr`m62RzOaRRLpF*HSZgZznK>;HyU6z34rg(k>x{rX17pk`%hDgrs`gLeO zxDlK`avchDC2ji;M13T;hju6t3}ZtaGw+>--EUv2sa;j82y9jl8siw6{&5=!Y-+1( zwFs^jl14PaNzEox6n~GBB+DydTKoESKI8=V0TSFa_7CI>6#kg@B~+k?$`%9hk-nU~ zC8tiS@b^S+@^L#HFFbMrwywXTzOqGdD+9FcR-X~q>BEI^>aRk$N@vn*pfNW7r$_AT zrG;mym18vsZqnGHsIUXU)7_q2Z);HxLL+>jX_yzBODTd=M+zbLqO>xO7!;KO?|-n* zhTyhs35A!QwWd+;p&+m!9Ja;f!S`R}u(b@mVX3+vb)Z2b4@DX)k7O-Vkonrxawxi5 z#vU_Io8fYXej|aD(N|mB!odRyi^LAMjk$?gsWPa9m%OirZ?NXze6}-QeZL9>N8T(Y z4WXXW1+V(<;C~yoOQ0uv^v6dx9GrPsC6Jp{3Rm!vp6dyH6L{O$7yvr2MZ^X4P6vh` zRbT0YH%>51e^Xy$pZj5d7gAs2#H01Ot2w1`KDC&wv*<+-&Y_RCbcE!C*WjfIso22= z3%L=@_}`zq4e-=pX`ZNPb-NF=yxp6v*<45~hO|S4Y`sv%ppiy+|C>{e-ycD2@>4%x(JRD8wx42K)er5ZWPI(f3An%h}_6Mr9%s~(7|i3`pk;@4eG*_ z4_d+9JmX8i4~s9r*K-x?Q^wq%Izq?0t$Tz*&~x)c501fx;L?%cf*HZ>j+b07#E>V0 z;HmkcHgn@Tx~!kgIs;q3zNEfm#j)dSEdqDn|8nDUS86WlH8cz1}$BQBL&ucjOO>ij5c_@s$mF%1nD1uVBP+(iRz!||2WCbNB znQ)J8$L2ifKg1u>Ke63JpLEie@H~P%VfQZuaNu`+6*;JVy7Wi6*T~ii^q?TG4E{*S zgI#NLSo=5w^K_Bm-uX=RbRA4AM<8;0JnAG|R70?iP~TXW-Am_t{NQ$cY}!`H=5c{b zXRgEf6GiaX&a3bz-p>UjJ!ha$)79&EBRKm{=Ve0tS|1jsBt$(>2wT^>!_Vl+i6D}l z@SheCImXtbrQiPVlc{X1$v9Tr7017FLi~mwJs8pb@w3u^L~?{ZTXUgbjDbo2AqFs( zy~o}syzo?C*!q+0sqSRNytSzVwCQRa2HN>U4!k#sr4i^x`#XK1OJoCR5a0n`wVmbQ z1hjP_C$j{Scjv?LKWwu)2`!@=!hIjMgB>rNfK!|8jeautJM{ErUKDvyZfN{E15;me zWE1~{59Nx|N~YH+5v7DZDhVnl(Fh`O1tPb%<2r&rdY?4^xdT)iP0C44?|wc_f;(MY z8IKRBe#wH9pei_WGsV&`e3?Ry(93D5d`(IGdl_5T=b{IvX3DAX^{)&6+0X%6b#kn< zXfnTfzkSsaYVhTUy0NpeC0t(rh9A^u0%|8b#5 z7(6~L-)X3tvb^qpF$C@z5x_FPYJzO^c6YC(a#=ZW%1PTWZ!QtNIegR$J>=GFXhRq^ ztqHV4Wm{V~n^FYZ&?}3_{w^z#HJb`_ZRadC!6Sn=v6qMc3WBd!c7u2-cA%a~CgMNq zIzZ=pwT&#w?tCKRPv%*Qc>n8A7<506QPbo%L)@Aii2E@Y;;_vLqem--Xw3cPD5xyV z);*{l8rz@O^Do-sFxuYWCG1C|CuITuUd&uw`qCiq>0Y&po2TNICuMVoqd%M4Wk8Pqm8 z6e|DFp}4HHn5<7E2-;vvE3Z1rp+nEldE<3_V53Gh*olo>d)vC1r-k_{s~7P=MIx6n zmEh<*9O2tHoHZCaoQJl@EHCzaeRe2nsHOKKJE80$QCP zFILKiMGZITA~zzlM`kyJm%a>xwrHJFs&Q?>13RKM8VXV4Z-da=eIVyT2@@?`z|O57 z;09gqP%Y$|T4AEya4HXAyP8!;efOEvkwmJ@Vk5=8r!B3TA(R=3z!uvqu^>e{2X@`?i*$VzXq-w#TB`c*GWqD zff*s}cWVs zf!khl!!U0+y~S3)P(*3s+c*AbED_$m?$D-7eds%?5!^c_5K7SVrzIj8WZdjS0*o5g zkZoJ33dkG{QS*j^&MO2@#LJ5n_t7aFl9+==rzQ-@m8BS z_Im*=nVJR{Puaz17@CA!gYCOFvggm?*!?-dHZ1u3(l&74SSi^?0o-Pgui~d3X3M61QVTFA%U{lx1X@jfsjGv#7OJ| zw6@?OM+;oszNj70gR8^-x%s*x1R^$G`%4DZH>(QhtS4vWM$?~+^)l&DC|Xac3da50 zc0v-e&tHdEh8=}VYN)Hw&{GkC+$@m1w}wHUE(emakxcFA&YupjA-6@J>|&43$n8nA z$i~0f;-(MWDT`adQ}0SuJyetwSHc64u0uUl#wpbZA@5DVge?VacUytcsG(Vu(DVH#kQ<@Py$xVNLIi~ID31VITG+1NRd^7@n?1?(78^wzvqvW$rU^dZctAw8i+_f)(FgI6O*@> z8KM9E<6!@uYnsZAOpAYg5eCDa(au)88Hjy4h^;eH{9J}9w;XnL_4z7VClNX`bx z%u_6Q(OJfU{`fl;n&d`RA71{d9gLXLM1AMxf!4-muWYB8d`JDcf4BDHyCZVzdY=*A zj_(M~+UZXosZCH4%PXte!GkmH=LuoLKjw*4Ql_wrtU?i83z z6?8T&6Ix>tB@24NxG}!4c5OcN8SMe{)(wO%_t}Tx?s2g0CW8`)oWZA-*0pOBCo~dt zWN2-Dx|Ik*vY7r!8y1m}vHBC`C@hN3Wnp)ak8uu%5IctsUsJLo7xLFdA&*TIW*do8 z1su%GYwc$A^k@<4=L7yi5mOm7X3pTnRVB3QEUAQUoI;(H8ACT&CY@VPao?)q(K*dE zEeayJnw0CNq>|{7{!G!$e(EfHPsTf&BUmEItp+92yoc6(YcvvM zJo`Zj^GIqP9jzYR{@>65$hL2PS{s|cB?5X6ySd(u2+Cf48A(nHTK!6-r$uhG-t*>< z;VdioR^!oEo5Re{gvtXdXs_9VcW;NWW0l&5B92IF+>UU2Uq1*5vnBAWXeiy@_;+|= zqW$+0W{pW^H5mtv4ul13l#*44ezR|%%H@%L}d)4kX3 zZViK^c~fsl#6Adbya#q{u^yEZ+4hhfxeB~_=>I??82nU_E`=$Aj3i3w7QxzOwbCvF!l??6y&>@G>h@JmN2k4I+rQ0*Kk;6O ztQ~=@`T>sy!k}@1j7X~j+CK1HpTjOQ4mpVABOh{}XhJyscOkrR&k^;N6|a5T3=)v! zQva}?PC*3s%zsy)(*)<4MV6)r%A~xLz8h zoc_*n7wVyZebr70GireEt)_jnL~K-tl$NaW(5PnH0XV9tZ$$sxWHLSBR;RSc;p`(f zlj7u*ctY8cT1PLCGbRcz%OZk@B#GSY*6`@NB@eD9c=)BJ5Z=>XdGSysRbnAwHG1(n z?-_W)hTLk3yYFqpigmpHti3f)ar9@ZXw8$te(}D)Vaao+U=MmFj>wHb?YL`QE?R}h z;Dvri;LK5}P_hd=@^UklGB0E623kD9C)IHhiEBt&7;(hm7f|8`jSrOBPusWA-6I03 zL~glRC9v?Rld$l~lWcN3BR2wB%`_QT&OKKpB{O=u{n{047lfnGBq`>qk{cOlle!&* z?-pb@B{u?P@{%z(p~IhSle42NNi9$zT}p7$i{EkV=>b(b(F%93`(G+NIBXBRzT_(E zls!!1Q;MMvk{eMh3F(=gKiG#uX+2aAb_wS~))d#!J{ z!p|*IGx*b2Xr>kMlfA1NGZoj}#3R$4LC&JADJDAb{#d9I2bzd77uQx*{$1J% z@A21~gO83qR2<*^Hv{HAbX-?80U4){6v8yL&{w~0@69^HQx25gRtL2wtBgAoqR?t1 zcQoCqVgS(M{7fYOUj^@9FMeEn0p<)p3YVnu;0Vpayx3L+8ANhgp~>6On9<{PaOCHf z!OQ`FGs2RRdUb&+dAu_8C>-18D)HJ13NMYAsxN_uHu+MBOAoX{R)_yI!qg|DVaco_ z*tgBL-|5K2P&9bRl9$W%QFv0%Cin|!X5DLr|5Q*eKyb%fzt%>E5*gILZPaftbcwXj ze5a)WE$~yi$=2o059bK|+EEu8X%Za$sNT{jBIu$yh}`rZnO;V9ylN4YZ1Sjstpjt0 zS<7SvdZX7q<;@P#=c7rMm%Rv z1}}_F*ED8^KCM4HmdI`HLn)f&uBrk`SeQNJC@L2H>rhYpr-km!&fV+u84|r??L97c zx9hNUc>;UBZ=01U15pD7s#=XI8p-H2R9|w*E}hr*PlP*q)4oA

?D$Ts^AaK)KQQ z>}ALM31xKZYb}k)@wb%R=Zx5H5E#MPp*lZG7f)bb8(p~mXi2SmaD)eEguv~6>>Hg( z?&zL;@Ikkjq^y<+F7I*~PE>nurz#3B>D$Y-LT(&ezq|y?Ub0=+a0aS)Lf({!zYpqL ztI-RsHj163uZ?I6%KY)EaOG0<$&DlHjG1lh)&r{h*C@pGS{J2GJFEO5WAQU5ost_J zaWrUTL)dn_8w`Kc`8rr-c_qC2_=zf!<-o^m_a!>QuR2MD)^fCxsOq|#Vf7&+Oj}?L zXNfy);?WD0aTlF>uLD$9^q&MeIF#14f?64%WhZY4>{1^>diz1!p$(zqgN>ofgdpgF z-gDHlFqoKfCyYCECq#YI2|B)DZ(QCgkioP|Eve=Tw0~5_1$BFgOl*3hdbI^NHxFZw zWt{{i4UJhhya>Jf5NM5#jBZc7_-uThd0pD;fd57mEzpAZjex^Y<+Eu}c=kHDVMCXf zxmH+J!4=vpD1lBSphN&Y$ihMK>A$qer~>N19e4Y}hu??7x_#~7FH>jOcd-+!+uII4 z{4Nw;UZB0li9p$wDH?@cwLp8O254OG)^|?e7{E^DV}ZmK#D7bIV`{5(;s7D)@!MEC z>A}i3&uS(|G->VuFV7Ew55L9s*@Nwq(;4oHt`GUw%@BU40bc&EdMK`hgWFB8`Ez|9 zYFIbd;W9F2==29Y_*T z?X`#;o(yTDFFZ-5$mmps8;!3LJHV@pTEbh)TC;xn-^TXvNqk56^4CuA{hwXHOe=}K z8^H3SyWpdop77q;?l59oSGaF=XNdj!c9f1V=#zHP|GhSF_uNpp^Q9KhW@G?(2eCrS<}z~7 zc;v)J;Kat3zVNk{)GF#iFwG8&IYk#h(L%|1cL?M`OU9(=wN-W9t~QfQL{On-t#uGu zoTszr^0BdNdkyZtuUJctaSYYznGimmmClZsmqFxq_w4{*Z*K$l#TubKT7{nV+`zYi zJ9~cLP$P_+5(vi%yTVrqZCFVu8LQsX?-dSu)SlozkE8Xfni~Z1KghYX)JFO|9$I5s zCzN&LAaeUEu{Df)HjuSN``f)?d)yV+vf(n6S60IK=R#oI)L?cft`^CCg9e@f}>%A8``f1gF?1_l3kFIkiO4O(T^x#wtAt2?@HCRWb);#NLk{p#&(witi zS#(J3lo^(<=)>|DxJ_VjdPRYiR2 zyR*HG4gBlEusf8($dg}hivO$*?Ef{NNhvxE9^Q&LLgubKIQVutd-T>()ucMO^B!Lu zOa9QghYtjWc(Mo$J<{t}=Qn2=!9}z$NCF)ZR$XMEu}+*qV%hhM@86g^)C8;j!e%U8 zM0Ju%2)u+BWl-uRM-ew7M9D#!Y+6~BR8r=59DubQqSQjiUiP6V zhn)Jbaze$j?;{}g0Y!n67xd0&t=V%C=E4g2>J8Uk51j|-4DEaPG7ne}wrbo9fv%6P z(;1R@p>HI&ZTmJulcx69wQAmZ-Ns{=`}CE^)4L8w+ZMWNBpz(tVxJ+q;YT<4d%yj4 z(v})mAg4oX{cY&ij!y!qF32dR)9(K@{vhTCFFU$nc_S++Cep{T$mg&X}tzxp5H5b?;+e+2!{tY=rHmrJ&Z^dtX(7 zOl}16>^^6pjjLK~E$=LAW>0Qol*$=IY@FOU?!2b~3;)TWY)ogFW3)-u)%kHmaC$&{ zp|r@Fu8XeE8Rm>ng(USlkEMSOAx<opNI4n1a1-Tt-0riQpC@nxUmj%xK4#f>yTO& z{i|KF;GI!Lkd53|*n&cx`mgu7IrgD4TJNFC26{k6Rb~8L!%V+FaH7fR&|4qDQFu(= z2iU!DeYmq9UMC&hO@emx)Ujz0PXvJD`I%RgDkOO(+k1ZJGYaF{dr2QoJH-x*)xz>u z&bZveq?h36w1QH}u%qjt>v9BI@1FAD9{BvLWav7u68^Ic`?r25fZt@eGv;MY(xx-g z^ni*bYk6E>ZBU;G&>Ir$t+5WbbxU)I-_-)*x4N^(xp6Ha_lo_P?y0jNHEr;har

  • )k1cud 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 IM6N<$f^pj|IRF3v literal 0 HcmV?d00001 From 9f761d8feb3e5631174dc70b0adbfca43b1f149c Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:13:41 -0500 Subject: [PATCH 17/27] Embed default cover art --- CustomAlbums.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CustomAlbums.csproj b/CustomAlbums.csproj index efff348..89ce4d4 100644 --- a/CustomAlbums.csproj +++ b/CustomAlbums.csproj @@ -19,6 +19,10 @@ + + + + all From 3387fed51769ba30403e79eeb3ea5b3e0bc7fcb4 Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:17:38 -0500 Subject: [PATCH 18/27] Implement hot reloading --- Data/Album.cs | 34 +- Managers/HotReloadManager.cs | 723 +++++++++++++++++++++++++++++++---- 2 files changed, 682 insertions(+), 75 deletions(-) diff --git a/Data/Album.cs b/Data/Album.cs index fc1bb3d..4c468eb 100644 --- a/Data/Album.cs +++ b/Data/Album.cs @@ -96,6 +96,19 @@ public Album(string path, int index, string packName = null) public bool IsPackaged { get; } public bool IsPack { get; } public bool HasPng { get; } + public bool HasCover + { + get + { + if (HasPng) + { + CustomAlbums.Managers.HotReloadManager.PnlStageInstance?.RefreshStageUI(); + return true; + } + + return false; + } + } public string PackName { get; } public AlbumInfo Info { get; } @@ -107,8 +120,24 @@ public static Sprite DefaultCover get { if (_defaultCover != null) return _defaultCover; - var tex = new Texture2D(2, 2); - _defaultCover = Sprite.Create(tex, new Rect(0, 0, 2, 2), new Vector2(0.5f, 0.5f)); + + var assembly = System.Reflection.Assembly.GetExecutingAssembly(); + using var stream = assembly.GetManifestResourceStream("CustomAlbums.Resources.default_cover.png"); + + Texture2D tex; + if (stream != null) + { + using var ms = new System.IO.MemoryStream(); + stream.CopyTo(ms); + tex = new Texture2D(2, 2, TextureFormat.ARGB32, false); + tex.LoadImage(ms.ToArray()); + } + else + { + tex = new Texture2D(2, 2); + } + + _defaultCover = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f)); _defaultCover.hideFlags |= HideFlags.DontUnloadUnusedAsset; return _defaultCover; } @@ -164,6 +193,7 @@ private IEnumerator LoadCoverAsync() _cover.hideFlags |= HideFlags.DontUnloadUnusedAsset; CustomAlbums.Patches.AssetPatch.UpdateCache($"{AlbumName}_cover", _cover); + CustomAlbums.Patches.CoverRefreshPatch.Refresh($"{CustomAlbums.Managers.AlbumManager.Uid}-{Index}"); } _isCoverLoading = false; diff --git a/Managers/HotReloadManager.cs b/Managers/HotReloadManager.cs index 04c0bdf..dde78f3 100644 --- a/Managers/HotReloadManager.cs +++ b/Managers/HotReloadManager.cs @@ -1,29 +1,49 @@ -using CustomAlbums.Patches; +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Nodes; +using CustomAlbums.Data; +using CustomAlbums.Patches; using CustomAlbums.Utilities; using HarmonyLib; +using Il2Cpp; using Il2CppAssets.Scripts.Database; +using Il2CppAssets.Scripts.Database.DataClass; +using Il2CppAssets.Scripts.PeroTools.Commons; +using Il2CppAssets.Scripts.PeroTools.GeneralLocalization; +using Il2CppAssets.Scripts.PeroTools.Managers; using Il2CppAssets.Scripts.UI.Panels; +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using Il2CppPeroTools2.Resources; +using static Il2CppAssets.Scripts.Database.DBConfigCustomTags; namespace CustomAlbums.Managers { - // TODO: Fix all of this with album.AlbumName internal static class HotReloadManager { private static readonly Logger Logger = new(nameof(HotReloadManager)); - private static Queue AlbumsAdded { get; } = new(); - private static Queue AlbumsDeleted { get; } = new(); - private static List AlbumUidsAdded { get; } = new(); - private static PnlStage PnlStageInstance { get; set; } + // Thread-safe queues for FileSystemWatcher background events + private static ConcurrentQueue AlbumsToAdd { get; } = new(); + + private static ConcurrentQueue AlbumsToDelete { get; } = new(); + private static ConcurrentDictionary LastFileEvent { get; } = new(); + internal static PnlStage PnlStageInstance { get; set; } + + // Hot-loaded MusicInfo cache: uid -> MusicInfo + // Used by GetMusicInfoFromAll Harmony Postfix + private static readonly Dictionary HotLoadedMusicInfos = new(); + private static readonly Dictionary HotLoadedMusicNames = new(); + private static readonly Dictionary HotLoadedMusicAuthors = new(); + + /// + /// Checks if a file is fully written and no longer locked by other processes. + /// private static bool IsFileUnlocked(string path) { try { - using var fileStream = File.Open(path, FileMode.Open); - if (fileStream.Length <= 0) return false; - - Logger.Msg("The added album is ready to be read!"); - return true; + using var fileStream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.None); + return fileStream.Length > 0; } catch (Exception ex) when (ex is FileNotFoundException or IOException) { @@ -31,107 +51,664 @@ private static bool IsFileUnlocked(string path) } } - private static void RemoveAllCachedAssets(string albumName) + + private static int ParseDifficulty(string difficulty) { - Logger.Msg("Removing " + albumName + "!"); + return int.TryParse(difficulty, out var value) ? value : 0; + } + + private static MusicExInfo CreateMusicExInfo(Album album) + { + var albumInfo = album.Info; + var changedDiff = new Il2CppStructArray(5); + changedDiff[0] = ParseDifficulty(albumInfo.Difficulty1); + changedDiff[1] = ParseDifficulty(albumInfo.Difficulty2); + changedDiff[2] = ParseDifficulty(albumInfo.Difficulty3); + changedDiff[3] = ParseDifficulty(albumInfo.Difficulty4); + changedDiff[4] = ParseDifficulty(albumInfo.Difficulty5); + + var musicExInfo = new MusicExInfo(); + musicExInfo.m_AlbumIndex = AlbumManager.Uid + 1; + musicExInfo.m_AlbumUidIndex = AlbumManager.Uid; + musicExInfo.m_MusicIndex = album.Index; + musicExInfo.m_AlbumUidName = $"music_package_{AlbumManager.Uid}"; + musicExInfo.m_AlbumJsonName = AlbumManager.JsonName; + musicExInfo.m_ChangedDiff = changedDiff; + return musicExInfo; + } + + /// + /// Hot-add: Injects a new .mdm file into the game's runtime database. + /// + private static int ProcessAdditions() + { + var addedCount = 0; + + while (AlbumsToAdd.TryDequeue(out var path)) + { + try + { + // 1. Load album into AlbumManager + var album = AlbumManager.LoadOne(path); + if (album == null) + { + Logger.Warning($"Failed to load album from {path}"); + continue; + } + + var albumName = album.AlbumName; + var uid = $"{AlbumManager.Uid}-{album.Index}"; + var albumInfo = album.Info; + Logger.Msg($"Adding {albumName} (UID: {uid})"); + + // 2. Transmute: Native DBObject generation via JSON + // Instead of reflection, re-serialize ALL custom albums and let the game natively deserialize them! + var masterAlbums = Singleton.instance.GetConfigObject(-1); + var albumsInfo = masterAlbums?.GetAlbumsInfoByUid(AlbumManager.MusicPackage); + var globalAlbumConfig = albumsInfo != null + ? Singleton.instance.GetConfigObject(albumsInfo.albumJsonIndex) + : null; + + if (globalAlbumConfig != null) + { + var jsonArray = new JsonArray(); + var localJsonArray = new JsonArray(); + + var firstSongs = new HashSet(AlbumManager.LoadedAlbums.Values + .GroupBy(a => PackManager.GetPackFromUid(a.Uid)) + .Select(g => g.OrderBy(a => a.Index).First().Uid)); + + foreach (var (albumStr, albumObj) in AlbumManager.LoadedAlbums) + { + var aInfo = albumObj.Info; + var pack = PackManager.GetPackFromUid(albumObj.Uid); + var isFirstSong = firstSongs.Contains(albumObj.Uid); + var titleString = pack?.Title ?? "Unclassified"; + + var displayName = aInfo.Name ?? ""; + if (isFirstSong) + { + displayName = $"【{titleString}】 {aInfo.Name}"; + } + + var customChartJson = new + { + uid = albumObj.Uid, + name = displayName, + author = aInfo.Author ?? "", + bpm = aInfo.Bpm ?? "0", + music = $"{albumStr}_music", + demo = $"{albumStr}_demo", + cover = $"{albumStr}_cover", + noteJson = $"{albumStr}_map", + scene = aInfo.Scene ?? "scene_01", + unlockLevel = "0", + levelDesigner = aInfo.LevelDesigner ?? "", + levelDesigner1 = aInfo.LevelDesigner1 ?? aInfo.LevelDesigner ?? "", + levelDesigner2 = aInfo.LevelDesigner2 ?? aInfo.LevelDesigner ?? "", + levelDesigner3 = aInfo.LevelDesigner3 ?? aInfo.LevelDesigner ?? "", + levelDesigner4 = aInfo.LevelDesigner4 ?? aInfo.LevelDesigner ?? "", + levelDesigner5 = aInfo.LevelDesigner5 ?? aInfo.LevelDesigner ?? "", + difficulty1 = aInfo.Difficulty1 ?? "0", + difficulty2 = aInfo.Difficulty2 ?? "0", + difficulty3 = aInfo.Difficulty3 ?? "0", + difficulty4 = aInfo.Difficulty4 ?? "0", + difficulty5 = aInfo.Difficulty5 ?? "0" + }; + jsonArray.Add(JsonSerializer.SerializeToNode(customChartJson)); - if (!AlbumManager.LoadedAlbums.TryGetValue($"album_{albumName}", out var album)) return; + localJsonArray.Add(JsonSerializer.SerializeToNode(new + { + name = displayName, + author = aInfo.Author ?? "" + })); + } - // Remove cached album information, not needed anymore since the album has been deleted - AlbumManager.LoadedAlbums.Remove($"album_{albumName}"); - CoverManager.CachedAnimatedCovers.Remove(album.Index); - CoverManager.CachedCovers.Remove(album.Index); - AssetPatch.RemoveFromCache($"album_{albumName}_demo"); - AssetPatch.RemoveFromCache($"album_{albumName}_music"); - AssetPatch.RemoveFromCache($"album_{albumName}_cover"); + var fullJsonStr = JsonSerializer.Serialize(jsonArray); + var fullLocalJsonStr = JsonSerializer.Serialize(localJsonArray); - // Get the music info from the UID, remove it from the ShowMusic list, and refresh the UI - var musicInfo = GlobalDataBase.s_DbMusicTag.GetMusicInfoFromAll($"{AlbumManager.Uid}-{album.Index}"); - GlobalDataBase.s_DbMusicTag.RemoveShowMusicUid(musicInfo); - PnlStageInstance.m_MusicRootAnimator?.Play(PnlStageInstance.animNameAlbumIn); - PnlStageInstance.RefreshMusicFSV(); + // 1. Re-deserialize the entire list of custom albums into the global config + globalAlbumConfig.Deserialize(fullJsonStr); + + // 2. Bypass engine cache completely by manually instantiating and injecting the localized databases + var localDicProp = typeof(BaseDBConfigLocalObject).GetProperty("m_LocalDic", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (localDicProp != null) + { + Il2CppSystem.Collections.Generic.Dictionary globalLocalDic = localDicProp.GetValue(globalAlbumConfig) as Il2CppSystem.Collections.Generic.Dictionary; + if (globalLocalDic != null) + { + globalLocalDic.Clear(); + for (int i = 0; i <= 15; i++) + { + var newLocalAlbum = new DBConfigLocalALBUM(); + newLocalAlbum.Deserialize(fullLocalJsonStr); + globalLocalDic.Add(i, newLocalAlbum); + } + } + } - // TODO: Remove the album information from the Custom Albums tag menu + // 4. Update the global dictionaries + GlobalDataBase.s_DbMusicTag.AddAllMusicInfo(globalAlbumConfig); - // TODO: Only change the selected album if the selected album was the album that was deleted - Logger.Msg("Successfully removed from cache!"); + // 4. Re-initialize ExInfo for all the newly created MusicInfo objects + var newMusicInfoList = new Il2CppSystem.Collections.Generic.List(); + globalAlbumConfig.GetAllMusicInfo(newMusicInfoList); + int idx = 0; + foreach (var m in newMusicInfoList) + { + var uidSplit = m.uid.Split('-'); + if (uidSplit.Length < 2) { idx++; continue; } + if (!int.TryParse(uidSplit[1], out var parsedIndex)) { idx++; continue; } + + var aObj = AlbumManager.LoadedAlbums.Values.FirstOrDefault(a => a.Index == parsedIndex); + if (aObj != null) + { + m.Init(idx); + m.InitExInfo(); + m.m_MusicExInfo = CreateMusicExInfo(aObj); + + HotLoadedMusicInfos[m.uid] = m; + HotLoadedMusicNames[m.uid] = aObj.Info.Name ?? ""; + HotLoadedMusicAuthors[m.uid] = aObj.Info.Author ?? ""; + } + idx++; + } + + Logger.Msg($"Transmuted MusicInfo for {uid}"); + } + else + { + Logger.Error("globalAlbumConfig is null! Cannot inject metadata."); + } + + // Add to the front-end view list so UI updates correctly + try + { + var dhColBase = Il2CppAssets.Scripts.Database.DataHelper.collections; + var dhColPtr = dhColBase != null ? dhColBase.Pointer : IntPtr.Zero; + var uids = GlobalDataBase.s_DbMusicTag.m_StageShowMusicUids; + if (uids != null) + { + if (!uids.Contains(uid)) + { + // Only add if it's not currently displaying the collections list to avoid alias pollution + if (dhColPtr == IntPtr.Zero || uids.Pointer != dhColPtr) + { + uids.Add(uid); + Logger.Msg($"Added {uid} to m_StageShowMusicUids"); + } + } + } + // Add to All Music tag (Index 0) + + var allMusicTag = GlobalDataBase.dbMusicTag.GetAlbumTagInfo(0); + if (allMusicTag != null) + { + if (allMusicTag.m_MusicUids != null && !allMusicTag.m_MusicUids.Contains(uid)) + { + if (allMusicTag.m_MusicUids.Pointer != dhColPtr) + allMusicTag.m_MusicUids.Add(uid); + } + if (allMusicTag.m_DisplayMusicUids != null) + { + foreach (var d in allMusicTag.m_DisplayMusicUids) + { + if (d.musicUids != null && !d.musicUids.Contains(uid)) + { + if (d.musicUids.Pointer != dhColPtr) + d.musicUids.Add(uid); + } + } + } + } + + // 5. Removed dicLevelConfig injection. It was causing Headquarters to crash by supplying an incorrect difficulty level. + } + catch (Exception ex) + { + Logger.Warning($"Failed to add to view lists: {ex.Message}"); + } + + // 3. Inject search tags into ConfigManager + try + { + var config = Singleton.instance.GetConfigObject(); + var searchTag = new MusicSearchTagInfo + { + uid = uid, + listIndex = config.count + }; + + var tags = new List { "custom albums" }; + if (albumInfo.SearchTags != null) tags.AddRange(albumInfo.SearchTags); + if (!string.IsNullOrEmpty(albumInfo.NameRomanized)) tags.Add(albumInfo.NameRomanized); + for (var i = 0; i < tags.Count; i++) tags[i] = tags[i].ToLower(); + searchTag.tag = new Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStringArray(tags.ToArray()); + + if (!config.m_Dictionary.ContainsKey(uid)) + { + config.m_Dictionary.Add(uid, searchTag); + config.list.Add(searchTag); + } + } + catch (Exception ex) + { + Logger.Warning($"Failed to add search tags: {ex.Message}"); + } + + // 4. Preload cover resources + try + { + if (album.HasFile("cover.png") || album.HasFile("cover.gif")) + { + ResourcesManager.instance + .LoadFromName($"{albumName}_cover") + .hideFlags |= UnityEngine.HideFlags.DontUnloadUnusedAsset; + } + } + catch (Exception ex) + { + Logger.Warning($"Failed to preload cover: {ex.Message}"); + } + + // UI Hijack will handle rendering the name and author without needing these native injections. + addedCount++; + Logger.Msg($"Successfully added {albumInfo.Name}"); + } + catch (Exception ex) + { + Logger.Warning($"Error adding album: {ex.Message}"); + Logger.Warning(ex.StackTrace); + } + } + + return addedCount; } - private static void RenameAllCachedAssets(string oldAlbumName, string newAlbumName) + /// + /// Hot-delete: Removes a specified album from the game's runtime database. + /// + private static int ProcessDeletions() { - Logger.Msg($"Renaming {oldAlbumName} to {newAlbumName}!"); - var success = AlbumManager.LoadedAlbums.Remove(oldAlbumName, out var album); - if (!success) return; + var deletedCount = 0; + + while (AlbumsToDelete.TryDequeue(out var albumFileName)) + { + try + { + var albumKey = $"album_{albumFileName}"; + Logger.Msg($"Removing {albumKey}"); + + if (!AlbumManager.LoadedAlbums.TryGetValue(albumKey, out var album)) + { + Logger.Warning($"Album {albumKey} not found"); + continue; + } + + var uid = $"{AlbumManager.Uid}-{album.Index}"; - // rename the assets to the new name - AlbumManager.LoadedAlbums.TryAdd(newAlbumName, album); - AssetPatch.ModifyCacheKey($"{oldAlbumName}_demo", $"{newAlbumName}_demo"); - AssetPatch.ModifyCacheKey($"{oldAlbumName}_music", $"{newAlbumName}_music"); - AssetPatch.ModifyCacheKey($"{oldAlbumName}_cover", $"{newAlbumName}_cover"); + HotLoadedMusicInfos.Remove(uid); + HotLoadedMusicNames.Remove(uid); + HotLoadedMusicAuthors.Remove(uid); - Logger.Msg("Successfully modified cache!"); + // Remove from game music database + try + { + var musicInfo = GlobalDataBase.s_DbMusicTag.GetMusicInfoFromAll(uid); + if (musicInfo != null) + { + GlobalDataBase.s_DbMusicTag.RemoveShowMusicUid(musicInfo); + } + + var allMusicInfo = GlobalDataBase.s_DbMusicTag.m_AllMusicInfo; + if (allMusicInfo != null && allMusicInfo.ContainsKey(uid)) + { + allMusicInfo.Remove(uid); + } + + // Remove from all tags (like "All Music" or "Favorites") + if (GlobalDataBase.s_DbMusicTag.m_AllAlbumTagData != null) + { + foreach (var tag in GlobalDataBase.s_DbMusicTag.m_AllAlbumTagData) + { + var tagInfo = tag.Value; + if (tagInfo.m_MusicUids != null && tagInfo.m_MusicUids.Contains(uid)) + { + tagInfo.m_MusicUids.Remove(uid); + } + if (tagInfo.m_DisplayMusicUids != null) + { + foreach (var d in tagInfo.m_DisplayMusicUids) + { + if (d.musicUids != null && d.musicUids.Contains(uid)) + { + d.musicUids.Remove(uid); + } + } + } + } + } + } + catch (Exception ex) + { + Logger.Warning($"Failed to remove from database: {ex.Message}"); + } + + // Remove from ConfigManager + try + { + var config = Singleton.instance.GetConfigObject(); + if (config != null) + { + if (config.m_Dictionary.ContainsKey(uid)) + { + var tagInfo = config.m_Dictionary[uid]; + config.m_Dictionary.Remove(uid); + config.list.Remove(tagInfo); + } + } + } + catch (Exception ex) + { + Logger.Warning($"Failed to remove search tags: {ex.Message}"); + } + + // Clear resource cache + AlbumManager.LoadedAlbums.Remove(albumKey); + AssetPatch.RemoveFromCache($"{albumKey}_demo"); + AssetPatch.RemoveFromCache($"{albumKey}_music"); + AssetPatch.RemoveFromCache($"{albumKey}_cover"); + + deletedCount++; + Logger.Msg($"Removed {albumKey}"); + } + catch (Exception ex) + { + Logger.Warning($"Error removing: {ex.Message}"); + Logger.Warning(ex.StackTrace); + } + } + + return deletedCount; } - private static void AddNewAlbums(int previousSize) + /// + /// Rebuild Custom Albums tag to update the UID list. + /// + private static void RebuildCustomAlbumsTag() { - var index = previousSize; - for (var i = previousSize; i < AlbumManager.LoadedAlbums.Count; i++) + try + { + var existingTag = GlobalDataBase.dbMusicTag.GetAlbumTagInfo(AlbumManager.Uid); + if (existingTag != null) + { + var uids = AlbumManager.GetAllUid().ToList(); + + if (existingTag.customInfo != null) + { + existingTag.customInfo.music_list = uids.ToIl2Cpp(); + } + + if (existingTag.m_MusicUids != null) + { + existingTag.m_MusicUids.Clear(); + foreach (var uid in uids) existingTag.m_MusicUids.Add(uid); + } + + if (existingTag.m_DisplayMusicUids != null) + { + foreach (var d in existingTag.m_DisplayMusicUids) + { + if (d.musicUids != null) + { + d.musicUids.Clear(); + foreach (var uid in uids) d.musicUids.Add(uid); + } + } + } + + Logger.Msg($"Tag updated ({uids.Count} albums)"); + } + } + catch (Exception ex) { - // TODO: Write added album hot reloading logic here + Logger.Warning($"Tag rebuild failed: {ex.Message}"); } + } - PnlStageInstance.m_MusicRootAnimator?.Play(PnlStageInstance.animNameAlbumIn); - PnlStageInstance.RefreshMusicFSV(); + private static void RefreshUI() + { + try + { + var stage = UnityEngine.Object.FindObjectOfType(); + if (stage != null && stage.gameObject.activeInHierarchy) + { + stage.RefreshStageUI(); + Logger.Msg("UI refreshed"); + } + } + catch (Exception ex) + { + Logger.Warning($"Failed to refresh UI: {ex.Message}"); + } } /// - /// Runs the logic for hot reloading on Unity's FixedUpdate + /// Consume the queue in Unity's FixedUpdate (main thread safe). /// internal static void FixedUpdate() { - var previousSize = AlbumManager.LoadedAlbums.Count; - while (AlbumsAdded.Count > 0) AlbumManager.LoadOne(AlbumsAdded.Dequeue()); - while (AlbumsDeleted.Count > 0) RemoveAllCachedAssets(AlbumsDeleted.Dequeue()); + if (AlbumsToAdd.IsEmpty && AlbumsToDelete.IsEmpty) return; + + if (PnlStageInstance == null) return; + + Logger.Msg($"Processing queue (add={AlbumsToAdd.Count}, del={AlbumsToDelete.Count})"); + + var oldSelectedUid = DataHelper.selectedMusicUidFromInfoList; + var oldSelectedAlbumName = AlbumManager.GetAlbumNameFromUid(oldSelectedUid); + + var deletedCount = ProcessDeletions(); + var addedCount = ProcessAdditions(); + + if (deletedCount > 0 || addedCount > 0) + { + PnlStageInstance?.m_MusicRootAnimator?.Play(PnlStageInstance.animNameAlbumIn); + + if (!string.IsNullOrEmpty(oldSelectedAlbumName) && oldSelectedUid != "0-0") + { + if (AlbumManager.LoadedAlbums.TryGetValue(oldSelectedAlbumName, out var newAlbum)) + { + var newUid = $"{AlbumManager.Uid}-{newAlbum.Index}"; + if (newUid != oldSelectedUid) + { + DataHelper.selectedMusicUidFromInfoList = newUid; + Logger.Msg($"Updated selected UID from {oldSelectedUid} to {newUid}"); + } + } + else + { + DataHelper.selectedMusicUidFromInfoList = "0-0"; + Logger.Msg($"Selected album was deleted. Resetting selection to 0-0"); + } + } + + RebuildCustomAlbumsTag(); + RefreshUI(); + } } /// - /// Initializes the AlbumWatcher for adding/deleting/renaming new custom charts. + /// Initialize FileSystemWatcher to monitor Custom_Albums directory changes. /// internal static void OnLateInitializeMelon() { - AlbumManager.AlbumWatcher.Path = AlbumManager.SearchPath; - AlbumManager.AlbumWatcher.Filter = AlbumManager.SearchPattern; - - AlbumManager.AlbumWatcher.Created += (_, e) => - { - Logger.Msg("Added file " + e.Name); - while (!IsFileUnlocked(e.FullPath)) - // Thread sleep added to not poll the drive a ton - Thread.Sleep(200); - AlbumsAdded.Enqueue(e.FullPath); - }; - AlbumManager.AlbumWatcher.Deleted += (s, e) => + try { - Logger.Msg("Deleted file " + e.Name); - AlbumsDeleted.Enqueue(Path.GetFileNameWithoutExtension(e.Name)); - }; - AlbumManager.AlbumWatcher.Renamed += (s, e) => + + var watchPath = Path.GetFullPath(AlbumManager.SearchPath); + Logger.Msg($"Watching directory: {watchPath}"); + + if (!Directory.Exists(watchPath)) + { + Logger.Warning($"Directory does not exist: {watchPath}"); + return; + } + + AlbumManager.AlbumWatcher.Path = watchPath; + AlbumManager.AlbumWatcher.Filter = AlbumManager.SearchPattern; + + AlbumManager.AlbumWatcher.Created += (_, e) => + { + var now = DateTime.Now; + if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && (now - lastTime).TotalMilliseconds < 500) return; + LastFileEvent[e.FullPath] = now; + + Logger.Msg($"Detected new file: {e.Name}"); + Task.Run(() => + { + var attempts = 0; + while (!IsFileUnlocked(e.FullPath) && attempts < 50) + { + Thread.Sleep(200); + attempts++; + } + + if (attempts < 50) + { + AlbumsToAdd.Enqueue(e.FullPath); + Logger.Msg($"Queued for addition: {e.Name}"); + } + else + { + Logger.Warning($"Timed out waiting for file: {e.Name}"); + } + }); + }; + + AlbumManager.AlbumWatcher.Deleted += (_, e) => + { + var now = DateTime.Now; + if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && (now - lastTime).TotalMilliseconds < 500) return; + LastFileEvent[e.FullPath] = now; + + Logger.Msg($"Detected deletion: {e.Name}"); + AlbumsToDelete.Enqueue(Path.GetFileNameWithoutExtension(e.Name)); + }; + + AlbumManager.AlbumWatcher.Changed += (_, e) => + { + if (e.ChangeType != WatcherChangeTypes.Changed) return; + var now = DateTime.Now; + if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && (now - lastTime).TotalMilliseconds < 500) return; + LastFileEvent[e.FullPath] = now; + + Logger.Msg($"Detected change: {e.Name}"); + Task.Run(() => + { + var attempts = 0; + while (!IsFileUnlocked(e.FullPath) && attempts < 50) + { + Thread.Sleep(200); + attempts++; + } + + if (attempts < 50) + { + AlbumsToDelete.Enqueue(Path.GetFileNameWithoutExtension(e.Name)); + AlbumsToAdd.Enqueue(e.FullPath); + Logger.Msg($"Queued for reload: {e.Name}"); + } + else + { + Logger.Warning($"Timed out waiting for file: {e.Name}"); + } + }); + }; + + AlbumManager.AlbumWatcher.Renamed += (_, e) => + { + var now = DateTime.Now; + if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && (now - lastTime).TotalMilliseconds < 500) return; + LastFileEvent[e.FullPath] = now; + + Logger.Msg($"Detected rename: {e.OldName} -> {e.Name}"); + var oldKey = $"album_{Path.GetFileNameWithoutExtension(e.OldName)}"; + var newKey = $"album_{Path.GetFileNameWithoutExtension(e.Name)}"; + + if (AlbumManager.LoadedAlbums.Remove(oldKey, out var album)) + { + AlbumManager.LoadedAlbums.TryAdd(newKey, album); + AssetPatch.ModifyCacheKey($"{oldKey}_demo", $"{newKey}_demo"); + AssetPatch.ModifyCacheKey($"{oldKey}_music", $"{newKey}_music"); + AssetPatch.ModifyCacheKey($"{oldKey}_cover", $"{newKey}_cover"); + Logger.Msg($"Renamed {oldKey} -> {newKey}"); + } + else + { + // Old album was not loaded (e.g. didn't match 'test*.mdm'). Treat this rename as a new creation event. + Logger.Msg($"Old file was not loaded, treating as new file: {e.Name}"); + Task.Run(() => + { + var attempts = 0; + while (!IsFileUnlocked(e.FullPath) && attempts < 50) + { + Thread.Sleep(200); + attempts++; + } + + if (attempts < 50) + { + AlbumsToAdd.Enqueue(e.FullPath); + Logger.Msg($"Queued for addition: {e.Name}"); + } + else + { + Logger.Warning($"Timed out waiting for file: {e.Name}"); + } + }); + } + }; + + AlbumManager.AlbumWatcher.EnableRaisingEvents = true; + Logger.Msg("FileSystemWatcher initialized!"); + } + catch (Exception ex) { - Logger.Msg("Renamed file " + e.OldName + " -> " + e.Name); - RenameAllCachedAssets($"album_{Path.GetFileNameWithoutExtension(e.OldName)}", - $"album_{Path.GetFileNameWithoutExtension(e.Name)}"); - }; + Logger.Warning($"Init failed: {ex.Message}"); + Logger.Warning(ex.StackTrace); + } + } + + // ============================================================ + // Harmony Patches + // ============================================================ - // Start the AlbumWatcher - AlbumManager.AlbumWatcher.EnableRaisingEvents = true; + /// + /// Intercepts GetMusicInfoFromAll: Returns our manually created MusicInfo + /// when the game looks up a hot-loaded song. + /// + [HarmonyPatch(typeof(DBMusicTag), nameof(DBMusicTag.GetMusicInfoFromAll))] + internal static class GetMusicInfoFromAllPatch + { + private static void Postfix(string musicUid, ref MusicInfo __result) + { + if (string.IsNullOrEmpty(musicUid)) return; + + if (HotLoadedMusicInfos.TryGetValue(musicUid, out var hotMusicInfo)) + { + __result = hotMusicInfo; + } + } } + /// + /// Capture PnlStage instance reference. + /// [HarmonyPatch(typeof(PnlStage), nameof(PnlStage.PreWarm))] internal static class StagePreWarmPatch { private static void Postfix(PnlStage __instance) { - Logger.Msg($"PnlStage instance retrieved. Null = {__instance == null}"); PnlStageInstance = __instance; } } From 6b04daeed3527ec6855b8dd672c15b9a1da1bfb8 Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:17:48 -0500 Subject: [PATCH 19/27] Help cover arts refresh during hot reload --- Patches/CoverRefreshPatch.cs | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 Patches/CoverRefreshPatch.cs diff --git a/Patches/CoverRefreshPatch.cs b/Patches/CoverRefreshPatch.cs new file mode 100644 index 0000000..7d65523 --- /dev/null +++ b/Patches/CoverRefreshPatch.cs @@ -0,0 +1,42 @@ +using HarmonyLib; +using System.Collections.Generic; +using Il2Cpp; + +namespace CustomAlbums.Patches +{ + internal static class CoverRefreshPatch + { + private static readonly Dictionary ActiveCells = new(); + + [HarmonyPatch(typeof(MusicStageCell), nameof(MusicStageCell.SetCoverLogic))] + internal static class SetCoverLogicPatch + { + private static void Postfix(MusicStageCell __instance, MusicStageCell.MusicStageCellInfo cellInfo) + { + ActiveCells[__instance] = cellInfo.musicUid; + } + } + + [HarmonyPatch(typeof(MusicStageCell), nameof(MusicStageCell.OnDestroy))] + internal static class OnDestroyPatch + { + private static void Postfix(MusicStageCell __instance) + { + ActiveCells.Remove(__instance); + } + } + + internal static void Refresh(string musicUid) + { + // Iterate over a copy to avoid enumeration modification exceptions if a cell destroys itself + var cells = new Dictionary(ActiveCells); + foreach (var kvp in cells) + { + if (kvp.Value == musicUid && kvp.Key != null && kvp.Key.gameObject.activeInHierarchy) + { + kvp.Key.ForceRefreshData(); + } + } + } + } +} From a3c6d39208741f4ab8f887c92997289d19c5a101 Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:50:53 -0500 Subject: [PATCH 20/27] Fix compatibility issue with Headquarters --- Data/Album.cs | 2 +- Data/Sheet.cs | 2 +- Managers/AlbumManager.cs | 42 ++++++++++++++++++++++++++++------------ 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/Data/Album.cs b/Data/Album.cs index 4c468eb..237b5a3 100644 --- a/Data/Album.cs +++ b/Data/Album.cs @@ -91,7 +91,7 @@ public Album(string path, int index, string packName = null) } public string PackAlbumName { get; } = string.Empty; - public int Index { get; } + public int Index { get; internal set; } public string Path { get; } public bool IsPackaged { get; } public bool IsPack { get; } diff --git a/Data/Sheet.cs b/Data/Sheet.cs index 4aadbcb..13bad2c 100644 --- a/Data/Sheet.cs +++ b/Data/Sheet.cs @@ -43,7 +43,7 @@ public StageInfo GetStage() var stageInfo = BmsLoader.TransmuteData(bms); stageInfo.mapName = MapName; stageInfo.scene = bms.Info.Genre; - stageInfo.music = $"{ParentAlbum.Index}"; + stageInfo.music = ParentAlbum.Uid; stageInfo.difficulty = Difficulty; stageInfo.bpm = bms.Bpm; stageInfo.md5 = Md5; diff --git a/Managers/AlbumManager.cs b/Managers/AlbumManager.cs index ef21ea2..3120297 100644 --- a/Managers/AlbumManager.cs +++ b/Managers/AlbumManager.cs @@ -34,6 +34,7 @@ public static class AlbumManager internal static Events.LoadAlbumEvent OnAlbumLoaded; private static int MaxCount { get; set; } + private static readonly Dictionary UidHistory = new(); internal static string CurrentPack { get; set; } = null; public static Dictionary LoadedAlbums { get; } = new(); @@ -53,8 +54,6 @@ public static Pack LoadPack(string directory) var pack = PackManager.CreatePack(json, directory); CurrentPack = pack.Title; - MaxCount = Math.Max(LoadedAlbums.Count, MaxCount); - pack.StartIndex = MaxCount; // Count successfully loaded .mdm files @@ -87,16 +86,25 @@ public static Pack LoadPack(string directory) public static Album LoadOne(string directory, ZipArchiveEntry mdm, string fullFileName) { var fileName = Path.GetFileNameWithoutExtension(fullFileName); - MaxCount = Math.Max(LoadedAlbums.Count, MaxCount); - - if (LoadedAlbums.ContainsKey(fileName)) return null; - try { - var album = new Album(directory, mdm, MaxCount, CurrentPack); + var album = new Album(directory, mdm, 0, CurrentPack); if (album.Info is null) return null; var albumName = album.AlbumName; + if (LoadedAlbums.ContainsKey(albumName)) return null; + + if (UidHistory.TryGetValue(albumName, out var oldIndex)) + { + album.Index = oldIndex; + } + else + { + album.Index = MaxCount; + MaxCount++; + UidHistory[albumName] = album.Index; + } + Logger.Msg("Adding " + albumName + " as a pack!"); LoadedAlbums.Add(albumName, album); @@ -121,18 +129,26 @@ public static Album LoadOne(string directory, ZipArchiveEntry mdm, string fullFi public static Album LoadOne(string path) { - MaxCount = Math.Max(LoadedAlbums.Count, MaxCount); var isDirectory = File.GetAttributes(path).HasFlag(FileAttributes.Directory); var fileName = isDirectory ? Path.GetFileName(path) : Path.GetFileNameWithoutExtension(path); - - if (LoadedAlbums.ContainsKey(fileName)) return null; - try { - var album = new Album(path, MaxCount, CurrentPack); + var album = new Album(path, 0, CurrentPack); if (album.Info is null) return null; var albumName = album.AlbumName; + if (LoadedAlbums.ContainsKey(albumName)) return null; + + if (UidHistory.TryGetValue(albumName, out var oldIndex)) + { + album.Index = oldIndex; + } + else + { + album.Index = MaxCount; + MaxCount++; + UidHistory[albumName] = album.Index; + } LoadedAlbums.Add(albumName, album); @@ -156,6 +172,8 @@ public static Album LoadOne(string path) public static void LoadAlbums() { LoadedAlbums.Clear(); + UidHistory.Clear(); + MaxCount = 0; var stopwatch = Stopwatch.StartNew(); From 69d0fedae81da699b557c900534b84aa49658882 Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:22:01 -0500 Subject: [PATCH 21/27] Short dequeue delay to avoid instant crashes for 300+ charts --- Managers/HotReloadManager.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Managers/HotReloadManager.cs b/Managers/HotReloadManager.cs index dde78f3..542d686 100644 --- a/Managers/HotReloadManager.cs +++ b/Managers/HotReloadManager.cs @@ -84,7 +84,7 @@ private static int ProcessAdditions() { var addedCount = 0; - while (AlbumsToAdd.TryDequeue(out var path)) + if (AlbumsToAdd.TryDequeue(out var path)) { try { @@ -93,7 +93,6 @@ private static int ProcessAdditions() if (album == null) { Logger.Warning($"Failed to load album from {path}"); - continue; } var albumName = album.AlbumName; @@ -332,7 +331,7 @@ private static int ProcessDeletions() { var deletedCount = 0; - while (AlbumsToDelete.TryDequeue(out var albumFileName)) + if (AlbumsToDelete.TryDequeue(out var albumFileName)) { try { @@ -342,7 +341,6 @@ private static int ProcessDeletions() if (!AlbumManager.LoadedAlbums.TryGetValue(albumKey, out var album)) { Logger.Warning($"Album {albumKey} not found"); - continue; } var uid = $"{AlbumManager.Uid}-{album.Index}"; @@ -493,6 +491,8 @@ private static void RefreshUI() } } + private static float _lastProcessTime; + /// /// Consume the queue in Unity's FixedUpdate (main thread safe). /// @@ -502,13 +502,18 @@ internal static void FixedUpdate() if (PnlStageInstance == null) return; - Logger.Msg($"Processing queue (add={AlbumsToAdd.Count}, del={AlbumsToDelete.Count})"); + if (UnityEngine.Time.unscaledTime - _lastProcessTime < 0.02f) return; + _lastProcessTime = UnityEngine.Time.unscaledTime; var oldSelectedUid = DataHelper.selectedMusicUidFromInfoList; var oldSelectedAlbumName = AlbumManager.GetAlbumNameFromUid(oldSelectedUid); var deletedCount = ProcessDeletions(); - var addedCount = ProcessAdditions(); + var addedCount = 0; + if (deletedCount == 0) + { + addedCount = ProcessAdditions(); + } if (deletedCount > 0 || addedCount > 0) { From f66b7157d21765d5d8b34e611b5c4bc7245fe5c9 Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:22:24 -0500 Subject: [PATCH 22/27] Reworked MD5 computing --- Data/Sheet.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Data/Sheet.cs b/Data/Sheet.cs index 13bad2c..658c141 100644 --- a/Data/Sheet.cs +++ b/Data/Sheet.cs @@ -21,12 +21,25 @@ public Sheet(Album parentAlbum, int difficulty) public string MapName { get; } public int Difficulty { get; } public bool TalkFileVersion2 { get; set; } + private string _md5; public string Md5 { get { - using var stream = ParentAlbum.OpenMemoryStream($"map{Difficulty}.bms"); - return stream.GetHash(); + if (!string.IsNullOrEmpty(_md5)) return _md5; + try + { + using var stream = ParentAlbum.OpenMemoryStream($"map{Difficulty}.bms"); + if (stream != null) + { + _md5 = stream.GetHash(); + } + } + catch (Exception e) + { + Logger.Warning($"Could not compute MD5 for {MapName}: {e.Message}"); + } + return _md5 ?? string.Empty; } } From ccc71643c9c05fb44b265ffa051611b0e7308329 Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:43:16 -0500 Subject: [PATCH 23/27] Full cleanup --- BmsLoader.cs | 1221 +++++++++++++-------------- CustomAlbums.csproj | 38 +- Data/Album.cs | 537 ++++++------ Data/AlbumInfo.cs | 88 +- Data/AssetIdentifiers.cs | 27 +- Data/Bms.cs | 942 +++++++++++---------- Data/BmsInfo.cs | 63 +- Data/BmsStates.cs | 189 +++-- Data/BmsTypes.cs | 28 +- Data/ChartSave.cs | 23 +- Data/CustomAlbumsSave.cs | 43 +- Data/Pack.cs | 84 +- Data/SceneEggs.cs | 31 +- Data/Sheet.cs | 136 ++- Data/Talk.cs | 43 +- Directory.Build.props | 42 +- Main.cs | 89 +- Managers/AlbumManager.cs | 340 ++++---- Managers/AudioManager.cs | 266 +++--- Managers/HotReloadManager.cs | 1082 ++++++++++++------------ Managers/MusicDataManager.cs | 93 +- Managers/PackManager.cs | 54 +- Managers/SaveManager.cs | 353 ++++---- ModExtensions/AlbumEventArgs.cs | 17 +- ModExtensions/AssetEventArgs.cs | 23 +- ModExtensions/Events.cs | 32 +- ModSettings.cs | 31 +- Patches/AnalyticsPatch.cs | 158 ++-- Patches/AssetPatch.cs | 584 ++++++------- Patches/ChartDialogPatch.cs | 99 ++- Patches/CoverRefreshPatch.cs | 54 +- Patches/DifficultyGradeIconPatch.cs | 104 +-- Patches/FilterPatch.cs | 159 ++-- Patches/HiddenSupportPatch.cs | 325 ++++--- Patches/LongChartPatch.cs | 21 +- Patches/MikuSceneSupportPatch.cs | 36 +- Patches/MusicTagManagerPatch.cs | 31 +- Patches/PackPatch.cs | 58 +- Patches/ReportCardPatch.cs | 92 +- Patches/SavePatch.cs | 896 ++++++++++---------- Patches/SceneEggPatch.cs | 186 ++-- Patches/SilenceLogPatch.cs | 26 +- Patches/TagPatch.cs | 43 +- Patches/TreeItemPatch.cs | 49 +- Properties/AssemblyInfo.cs | 2 +- Utilities/Backup.cs | 225 +++-- Utilities/ConfigDataExtensions.cs | 99 ++- Utilities/Converters.cs | 21 +- Utilities/DataExtensions.cs | 124 ++- Utilities/Debug.cs | 21 +- Utilities/Formatting.cs | 51 +- Utilities/Il2CppExtensions.cs | 157 ++-- Utilities/Interop.cs | 25 +- Utilities/Json.cs | 85 +- Utilities/Logger.cs | 67 +- Utilities/PackExtensions.cs | 29 +- Utilities/SaveExtensions.cs | 53 +- Utilities/StreamExtensions.cs | 69 +- Utilities/StringExtensions.cs | 26 +- 59 files changed, 4915 insertions(+), 4975 deletions(-) diff --git a/BmsLoader.cs b/BmsLoader.cs index 04e1547..1fb07d8 100644 --- a/BmsLoader.cs +++ b/BmsLoader.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using CustomAlbums.Data; using CustomAlbums.Managers; using CustomAlbums.Utilities; @@ -5,783 +6,773 @@ using Il2CppAssets.Scripts.GameCore; using Il2CppAssets.Scripts.GameCore.Managers; using Il2CppAssets.Scripts.PeroTools.Commons; -using Il2CppAssets.Scripts.PeroTools.Managers; using Il2CppFormulaBase; using Il2CppGameLogic; using Il2CppPeroPeroGames.GlobalDefines; using Il2CppPeroTools2.Resources; using Il2CppSpine.Unity; -using System.Diagnostics; using UnityEngine; using static CustomAlbums.Data.BmsStates; using Animation = Il2CppSpine.Animation; using Decimal = Il2CppSystem.Decimal; using Logger = CustomAlbums.Utilities.Logger; -namespace CustomAlbums +namespace CustomAlbums; + +internal static class BmsLoader { - internal static class BmsLoader + private static readonly Dictionary NoteData = new(); + private static readonly Dictionary> BossData = new(); + private static Decimal _delay; + private static readonly Logger Logger = new(nameof(BmsLoader)); + + private static readonly Dictionary StartDelayCache = new(); + private static readonly Dictionary<(string scene, string animation), float> AnimationDurationCache = new(); + + private static readonly HashSet SceneAgnosticPrefixes = new() { "00", "em", "bo" }; + + /// + /// Creates a Bms object from a BMS file. + /// + /// MemoryStream of BMS file. + /// Name of BMS score. + /// Loaded Bms object, or null if critical data is missing. + internal static Bms Load(Stream stream, string bmsName) { - private static readonly Dictionary NoteData = new(); - private static readonly Dictionary> BossData = new(); - private static Decimal _delay; - private static readonly Logger Logger = new(nameof(BmsLoader)); - - private static readonly Dictionary StartDelayCache = new(); - private static readonly Dictionary<(string scene, string animation), float> AnimationDurationCache = new(); - - private static readonly HashSet SceneAgnosticPrefixes = new() { "00", "em", "bo" }; - - /// - /// Creates a Bms object from a BMS file. - /// - /// MemoryStream of BMS file. - /// Name of BMS score. - /// Loaded Bms object, or null if critical data is missing. - internal static Bms Load(Stream stream, string bmsName) + Logger.Msg($"Loading bms {bmsName}..."); + + var bpmDict = new Dictionary(); + var timeSigEntries = new Dictionary(); + var bpmEntries = new List(); + var rawNotes = new List(); + var info = new BmsInfo(); + + using var streamReader = new StreamReader(stream); + while (streamReader.ReadLine()?.Trim() is { } line) { - Logger.Msg($"Loading bms {bmsName}..."); + if (string.IsNullOrEmpty(line) || !line.StartsWith("#")) continue; - var bpmDict = new Dictionary(); - var timeSigEntries = new Dictionary(); - var bpmEntries = new List(); - var rawNotes = new List(); - var info = new BmsInfo(); + // Remove # from beginning of line + line = line[1..]; - using var streamReader = new StreamReader(stream); - while (streamReader.ReadLine()?.Trim() is { } line) + if (line.Contains(' ')) + { + // Parse header + var spaceIndex = line.IndexOf(' '); + var key = line[..spaceIndex]; + var value = line[(spaceIndex + 1)..]; + + // Skip WAV definition + if (key.StartsWith("WAV")) continue; + + // Map known headers to typed properties + if (!ParseHeader(info, key, value, bpmDict, bpmEntries)) + // Store unrecognized headers in fallback dictionary + info.Extra[key] = value; + } + else if (line.Contains(':')) { - if (string.IsNullOrEmpty(line) || !line.StartsWith("#")) continue; + // Parse data field + var colonIndex = line.IndexOf(':'); + if (colonIndex < 5) + { + Logger.Warning($"Malformed data line (key too short): #{line}"); + continue; + } - // Remove # from beginning of line - line = line[1..]; + var key = line[..colonIndex]; + var value = line[(colonIndex + 1)..]; - if (line.Contains(' ')) + if (!key[..3].TryParseAsInt(out var beat)) { - // Parse header - var spaceIndex = line.IndexOf(' '); - var key = line[..spaceIndex]; - var value = line[(spaceIndex + 1)..]; + Logger.Warning($"Malformed measure number in: #{line}"); + continue; + } - // Skip WAV definition - if (key.StartsWith("WAV")) continue; + var typeCode = key.Substring(3, 2); - // Map known headers to typed properties - if (!ParseHeader(info, key, value, bpmDict, bpmEntries)) - { - // Store unrecognized headers in fallback dictionary - info.Extra[key] = value; - } + if (!Bms.Channels.TryGetValue(typeCode, out var type)) continue; + + if (type is Bms.ChannelType.SpTimesig) + { + if (value.TryParseAsFloat(out var percent)) + timeSigEntries[beat] = new TimeSigEntry(beat, percent); + else + Logger.Warning($"Invalid time signature value: {value}"); } - else if (line.Contains(':')) + else { - // Parse data field - var colonIndex = line.IndexOf(':'); - if (colonIndex < 5) + if (value.Length % 2 != 0) { - Logger.Warning($"Malformed data line (key too short): #{line}"); + Logger.Warning($"Odd-length data field (must be pairs of 2 chars): #{line}"); continue; } - var key = line[..colonIndex]; - var value = line[(colonIndex + 1)..]; - - if (!key[..3].TryParseAsInt(out var beat)) + var objLength = value.Length / 2; + for (var i = 0; i < objLength; i++) { - Logger.Warning($"Malformed measure number in: #{line}"); - continue; - } - - var typeCode = key.Substring(3, 2); + var note = value.Substring(i * 2, 2); + if (note is "00") continue; - if (!Bms.Channels.TryGetValue(typeCode, out var type)) continue; - - if (type is Bms.ChannelType.SpTimesig) - { - if (value.TryParseAsFloat(out var percent)) - timeSigEntries[beat] = new TimeSigEntry(beat, percent); - else - Logger.Warning($"Invalid time signature value: {value}"); - } - else - { - if (value.Length % 2 != 0) - { - Logger.Warning($"Odd-length data field (must be pairs of 2 chars): #{line}"); - continue; - } + var tick = (float)i / objLength + beat; - var objLength = value.Length / 2; - for (var i = 0; i < objLength; i++) + if (type is Bms.ChannelType.SpBpmDirect or Bms.ChannelType.SpBpmLookup) { - var note = value.Substring(i * 2, 2); - if (note is "00") continue; - - var tick = (float)i / objLength + beat; - - if (type is Bms.ChannelType.SpBpmDirect or Bms.ChannelType.SpBpmLookup) - { - // Handle BPM changes - float freqDivide; - if (type == Bms.ChannelType.SpBpmLookup && bpmDict.TryGetValue(note, out var bpm)) + // Handle BPM changes + float freqDivide; + if (type == Bms.ChannelType.SpBpmLookup && bpmDict.TryGetValue(note, out var bpm)) + freqDivide = bpm; + else + try { - freqDivide = bpm; + freqDivide = Convert.ToInt32(note, 16); } - else + catch (FormatException) { - try - { - freqDivide = Convert.ToInt32(note, 16); - } - catch (FormatException) - { - Logger.Warning($"Invalid hex BPM value: {note}"); - continue; - } + Logger.Warning($"Invalid hex BPM value: {note}"); + continue; } - var freq = 60f / freqDivide * 4f; - bpmEntries.Add(new BpmEntry(tick, freq)); - } - else - { - var noteObj = new RawNote( - Time: CalculateNoteTime(tick, bpmEntries, timeSigEntries), - Value: note, - Tone: typeCode - ); - rawNotes.Add(noteObj); - } + var freq = 60f / freqDivide * 4f; + bpmEntries.Add(new BpmEntry(tick, freq)); + } + else + { + var noteObj = new RawNote( + CalculateNoteTime(tick, bpmEntries, timeSigEntries), + note, + typeCode + ); + rawNotes.Add(noteObj); } } } } + } - // Validate required headers - if (string.IsNullOrEmpty(info.Genre)) - Logger.Warning($"BMS '{bmsName}' is missing required GENRE header."); - if (info.Bpm <= 0) - Logger.Warning($"BMS '{bmsName}' has no valid BPM."); + // Validate required headers + if (string.IsNullOrEmpty(info.Genre)) + Logger.Warning($"BMS '{bmsName}' is missing required GENRE header."); + if (info.Bpm <= 0) + Logger.Warning($"BMS '{bmsName}' has no valid BPM."); - // Sort notes by time, with events (channel "15") sorted before notes at the same timestamp - rawNotes.Sort((l, r) => - { - // Accurate for note sorting up to 6 decimal places - var lScore = (long)(l.Time * 1000000) * 10 + (l.Tone == "15" ? 0 : 1); - var rScore = (long)(r.Time * 1000000) * 10 + (r.Tone == "15" ? 0 : 1); - return lScore.CompareTo(rScore); - }); + // Sort notes by time, with events (channel "15") sorted before notes at the same timestamp + rawNotes.Sort((l, r) => + { + // Accurate for note sorting up to 6 decimal places + var lScore = (long)(l.Time * 1000000) * 10 + (l.Tone == "15" ? 0 : 1); + var rScore = (long)(r.Time * 1000000) * 10 + (r.Tone == "15" ? 0 : 1); + return lScore.CompareTo(rScore); + }); - var bms = new Bms - { - Info = info, - Notes = rawNotes, - NotesPercent = timeSigEntries.Values.ToList(), - Md5 = stream.GetHash() - }; - bms.Info.Name = bmsName; + var bms = new Bms + { + Info = info, + Notes = rawNotes, + NotesPercent = timeSigEntries.Values.ToList(), + Md5 = stream.GetHash() + }; + bms.Info.Name = bmsName; - if (!string.IsNullOrEmpty(info.Banner)) - bms.Info.Banner = "cover/" + info.Banner; + if (!string.IsNullOrEmpty(info.Banner)) + bms.Info.Banner = "cover/" + info.Banner; - Logger.Msg($"Loaded bms {bmsName}."); + Logger.Msg($"Loaded bms {bmsName}."); - return bms; - } + return bms; + } - /// - /// Parses a known BMS header into the object. - /// - /// true if the header was recognized and parsed, false otherwise. - private static bool ParseHeader(BmsInfo info, string key, string value, - Dictionary bpmDict, List bpmEntries) + /// + /// Parses a known BMS header into the object. + /// + /// true if the header was recognized and parsed, false otherwise. + private static bool ParseHeader(BmsInfo info, string key, string value, + Dictionary bpmDict, List bpmEntries) + { + // Handle BPMxx headers (BPM, BPM01, BPM02, etc.) + if (key.StartsWith("BPM")) { - // Handle BPMxx headers (BPM, BPM01, BPM02, etc.) - if (key.StartsWith("BPM")) + if (!value.TryParseAsFloat(out var bpmValue)) { - if (!value.TryParseAsFloat(out var bpmValue)) - { - Logger.Warning($"Invalid BPM value: {value}"); - return true; // Recognized but invalid - } - - var bpmKey = key.Length > 3 ? key[3..] : "00"; - bpmDict[bpmKey] = bpmValue; - - if (bpmKey == "00") - { - info.Bpm = bpmValue; - var freq = 60f / bpmValue * 4f; - bpmEntries.Add(new BpmEntry(0f, freq)); - } - - return true; + Logger.Warning($"Invalid BPM value: {value}"); + return true; // Recognized but invalid } - switch (key) - { - case "PLAYER": - if (value.TryParseAsInt(out var player)) info.Player = player; - return true; - case "GENRE": - info.Genre = value; - return true; - case "TITLE": - info.Title = value; - return true; - case "ARTIST": - info.Artist = value; - return true; - case "LEVELDESIGN": - info.LevelDesign = value; - return true; - case "BANNER": - info.Banner = value; - return true; - case "RANK": - if (value.TryParseAsInt(out var rank)) info.Rank = rank; - return true; - case "LNTYPE": - if (value.TryParseAsInt(out var lnType)) info.LnType = lnType; - return true; - case "PLAYLEVEL": - info.PlayLevel = value; - return true; - default: - return false; - } - } + var bpmKey = key.Length > 3 ? key[3..] : "00"; + bpmDict[bpmKey] = bpmValue; - /// - /// Calculates the absolute time (in seconds) for a note at a given tick position, - /// accounting for BPM changes and time signature changes. - /// Reverse-engineered from the official game's BMS-to-time conversion. - /// - /// The beat position of the note. - /// All BPM change entries parsed so far, in insertion order. - /// Time signature changes by measure number. - /// Absolute time in seconds. - private static float CalculateNoteTime(float tick, List bpmEntries, - Dictionary timeSigEntries) - { - // Build a sorted (ascending by tick) list of BPM entries that occur before this note - // We must sort because BPM entries may have been added in any order from the BMS data - var relevantBpms = new List(); - foreach (var entry in bpmEntries) + if (bpmKey == "00") { - if (entry.Tick < tick) - relevantBpms.Add(entry); + info.Bpm = bpmValue; + var freq = 60f / bpmValue * 4f; + bpmEntries.Add(new BpmEntry(0f, freq)); } - if (relevantBpms.Count == 0) return 0f; - relevantBpms.Sort(); + return true; + } - var accumulatedTime = 0f; - var totalBeatOffset = 0f; + switch (key) + { + case "PLAYER": + if (value.TryParseAsInt(out var player)) info.Player = player; + return true; + case "GENRE": + info.Genre = value; + return true; + case "TITLE": + info.Title = value; + return true; + case "ARTIST": + info.Artist = value; + return true; + case "LEVELDESIGN": + info.LevelDesign = value; + return true; + case "BANNER": + info.Banner = value; + return true; + case "RANK": + if (value.TryParseAsInt(out var rank)) info.Rank = rank; + return true; + case "LNTYPE": + if (value.TryParseAsInt(out var lnType)) info.LnType = lnType; + return true; + case "PLAYLEVEL": + info.PlayLevel = value; + return true; + default: + return false; + } + } - // Walk through BPM segments from earliest to latest - for (var j = 0; j < relevantBpms.Count; j++) - { - var entry = relevantBpms[j]; - var beatFrequency = entry.Freq; + /// + /// Calculates the absolute time (in seconds) for a note at a given tick position, + /// accounting for BPM changes and time signature changes. + /// Reverse-engineered from the official game's BMS-to-time conversion. + /// + /// The beat position of the note. + /// All BPM change entries parsed so far, in insertion order. + /// Time signature changes by measure number. + /// Absolute time in seconds. + private static float CalculateNoteTime(float tick, List bpmEntries, + Dictionary timeSigEntries) + { + // Build a sorted (ascending by tick) list of BPM entries that occur before this note + // We must sort because BPM entries may have been added in any order from the BMS data + var relevantBpms = new List(); + foreach (var entry in bpmEntries) + if (entry.Tick < tick) + relevantBpms.Add(entry); - // Calculate how many beats this segment covers - float segmentBeats; - if (j < relevantBpms.Count - 1) - segmentBeats = relevantBpms[j + 1].Tick - entry.Tick; - else - segmentBeats = tick - entry.Tick; + if (relevantBpms.Count == 0) return 0f; + relevantBpms.Sort(); - var previousOffset = totalBeatOffset; - totalBeatOffset += segmentBeats; - var floorBeat = Mathf.FloorToInt(previousOffset); - var ceilBeat = Mathf.CeilToInt(totalBeatOffset); + var accumulatedTime = 0f; + var totalBeatOffset = 0f; - for (var k = floorBeat; k < ceilBeat; k++) - { - var beatFraction = 1f; - - if (k == floorBeat) - beatFraction = k + 1 - previousOffset; - if (k == ceilBeat - 1) - beatFraction = totalBeatOffset - (ceilBeat - 1); - if (ceilBeat == floorBeat + 1) - beatFraction = totalBeatOffset - previousOffset; - - var timeSigMultiplier = timeSigEntries.TryGetValue(k, out var timeSig) - ? timeSig.Percent - : 1f; - accumulatedTime += - Mathf.RoundToInt(beatFraction * timeSigMultiplier * beatFrequency / 1E-06f) * 1E-06F; - } + // Walk through BPM segments from earliest to latest + for (var j = 0; j < relevantBpms.Count; j++) + { + var entry = relevantBpms[j]; + var beatFrequency = entry.Freq; + + // Calculate how many beats this segment covers + float segmentBeats; + if (j < relevantBpms.Count - 1) + segmentBeats = relevantBpms[j + 1].Tick - entry.Tick; + else + segmentBeats = tick - entry.Tick; + + var previousOffset = totalBeatOffset; + totalBeatOffset += segmentBeats; + var floorBeat = Mathf.FloorToInt(previousOffset); + var ceilBeat = Mathf.CeilToInt(totalBeatOffset); + + for (var k = floorBeat; k < ceilBeat; k++) + { + var beatFraction = 1f; + + if (k == floorBeat) + beatFraction = k + 1 - previousOffset; + if (k == ceilBeat - 1) + beatFraction = totalBeatOffset - (ceilBeat - 1); + if (ceilBeat == floorBeat + 1) + beatFraction = totalBeatOffset - previousOffset; + + var timeSigMultiplier = timeSigEntries.TryGetValue(k, out var timeSig) + ? timeSig.Percent + : 1f; + accumulatedTime += + Mathf.RoundToInt(beatFraction * timeSigMultiplier * beatFrequency / 1E-06f) * 1E-06F; } - - return accumulatedTime; } - /// - /// Transmutes Bms data into StageInfo data. - /// - /// The Bms object to transmute. - /// The transmuted StageInfo object. - internal static StageInfo TransmuteData(Bms bms) - { - var stopwatch = Stopwatch.StartNew(); + return accumulatedTime; + } + + /// + /// Transmutes Bms data into StageInfo data. + /// + /// The Bms object to transmute. + /// The transmuted StageInfo object. + internal static StageInfo TransmuteData(Bms bms) + { + var stopwatch = Stopwatch.StartNew(); - if (NoteData.Count == 0) InitNoteData(); - MusicDataManager.Clear(); - _delay = 0; + if (NoteData.Count == 0) InitNoteData(); + MusicDataManager.Clear(); + _delay = 0; - var noteData = bms.GetNoteData(); - Logger.Msg("Got note data"); + var noteData = bms.GetNoteData(); + Logger.Msg("Got note data"); - LoadMusicData(noteData); - MusicDataManager.Sort(); + LoadMusicData(noteData); + MusicDataManager.Sort(); - ProcessBossData(bms); - ProcessDelay(bms); - MusicDataManager.Sort(); + ProcessBossData(bms); + ProcessDelay(bms); + MusicDataManager.Sort(); - ProcessGeminis(); + ProcessGeminis(); - // Process the delay for each MusicData - foreach (var mData in MusicDataManager.Data) - { - mData.tick -= _delay; - mData.showTick = Decimal.Round(mData.tick - mData.dt, 2); - if (mData.isLongPressType) - mData.endIndex -= (int)(_delay / (Decimal)0.001f); - } + // Process the delay for each MusicData + foreach (var mData in MusicDataManager.Data) + { + mData.tick -= _delay; + mData.showTick = Decimal.Round(mData.tick - mData.dt, 2); + if (mData.isLongPressType) + mData.endIndex -= (int)(_delay / (Decimal)0.001f); + } - // Transmute the MusicData to a new StageInfo object - var stageInfo = ScriptableObject.CreateInstance(); - stageInfo.musicDatas = new Il2CppSystem.Collections.Generic.List(); - foreach (var musicData in MusicDataManager.Data) - stageInfo.musicDatas.Add(musicData); - stageInfo.delay = _delay; + // Transmute the MusicData to a new StageInfo object + var stageInfo = ScriptableObject.CreateInstance(); + stageInfo.musicDatas = new Il2CppSystem.Collections.Generic.List(); + foreach (var musicData in MusicDataManager.Data) + stageInfo.musicDatas.Add(musicData); + stageInfo.delay = _delay; - stopwatch.Stop(); - Logger.Msg($"Transmuted BMS in {stopwatch.Elapsed}", false); + stopwatch.Stop(); + Logger.Msg($"Transmuted BMS in {stopwatch.Elapsed}", false); - MusicDataManager.Clear(); - return stageInfo; - } + MusicDataManager.Clear(); + return stageInfo; + } - private static void InitNoteData() + private static void InitNoteData() + { + foreach (var nData in SingletonScriptableObject.instance.noteDatas) { - foreach (var nData in SingletonScriptableObject.instance.noteDatas) - { - NoteData.TryAdd(nData.uid, nData); - NoteData.TryAdd(Bms.GetNoteDataKey(nData.ibms_id, nData.pathway, nData.speed, nData.scene), nData); + NoteData.TryAdd(nData.uid, nData); + NoteData.TryAdd(Bms.GetNoteDataKey(nData.ibms_id, nData.pathway, nData.speed, nData.scene), nData); - if (nData.GetNoteType() != NoteType.None || string.IsNullOrEmpty(nData.boss_action) || - nData.boss_action == "0") continue; + if (nData.GetNoteType() != NoteType.None || string.IsNullOrEmpty(nData.boss_action) || + nData.boss_action == "0") continue; - BossData.TryAdd(nData.scene, new Dictionary()); - BossData[nData.scene].TryAdd(nData.boss_action, nData); - } + BossData.TryAdd(nData.scene, new Dictionary()); + BossData[nData.scene].TryAdd(nData.boss_action, nData); } + } - private static void LoadMusicData(List noteData) + private static void LoadMusicData(List noteData) + { + short noteId = 1; + foreach (var note in noteData) { - short noteId = 1; - foreach (var note in noteData) + if (noteId == short.MaxValue) { - if (noteId == short.MaxValue) - { - Logger.Warning( - $"Cannot process full chart, there are too many objects. Max objects is {short.MaxValue}."); - break; - } + Logger.Warning( + $"Cannot process full chart, there are too many objects. Max objects is {short.MaxValue}."); + break; + } - var configData = note.ToMusicConfigData(); - if (configData.time < 0) continue; + var configData = note.ToMusicConfigData(); + if (configData.time < 0) continue; - // Create a new note for each configData - var newNote = Interop.CreateTypeValue(); - newNote.objId = noteId++; - newNote.tick = Decimal.Round(configData.time, 3); - newNote.configData = configData; - newNote.isLongPressEnd = false; - newNote.isLongPressing = false; + // Create a new note for each configData + var newNote = Interop.CreateTypeValue(); + newNote.objId = noteId++; + newNote.tick = Decimal.Round(configData.time, 3); + newNote.configData = configData; + newNote.isLongPressEnd = false; + newNote.isLongPressing = false; - if (NoteData.TryGetValue(newNote.configData.note_uid, out var newNoteData)) - newNote.noteData = newNoteData; + if (NoteData.TryGetValue(newNote.configData.note_uid, out var newNoteData)) + newNote.noteData = newNoteData; - MusicDataManager.Add(newNote); + MusicDataManager.Add(newNote); - // Create ticks for hold notes. If it isn't a hold note, there is no need to continue. - if (!newNote.isLongPressStart) continue; + // Create ticks for hold notes. If it isn't a hold note, there is no need to continue. + if (!newNote.isLongPressStart) continue; - // Calculate the index in which the hold note ends - var endIndex = (int)(Decimal.Round( - newNote.tick + newNote.configData.length - newNote.noteData.left_great_range - - newNote.noteData.left_perfect_range, - 3) / (Decimal)0.001f); + // Calculate the index in which the hold note ends + var endIndex = (int)(Decimal.Round( + newNote.tick + newNote.configData.length - newNote.noteData.left_great_range - + newNote.noteData.left_perfect_range, + 3) / (Decimal)0.001f); - for (var i = 1; i <= newNote.longPressCount; i++) - { - var holdTick = Interop.CreateTypeValue(); - holdTick.objId = noteId++; - holdTick.tick = i == newNote.longPressCount - ? newNote.tick + newNote.configData.length - : newNote.tick + (Decimal)0.1f * i; - holdTick.configData = newNote.configData; - - // ACTUALLY REQUIRED TO WORK - var dataCopy = holdTick.configData; - dataCopy.length = 0; - holdTick.configData = dataCopy; - - holdTick.isLongPressing = i != newNote.longPressCount; - holdTick.isLongPressEnd = i == newNote.longPressCount; - holdTick.noteData = newNote.noteData; - holdTick.longPressPTick = newNote.configData.time; - holdTick.endIndex = endIndex; - - MusicDataManager.Add(holdTick); - } + for (var i = 1; i <= newNote.longPressCount; i++) + { + var holdTick = Interop.CreateTypeValue(); + holdTick.objId = noteId++; + holdTick.tick = i == newNote.longPressCount + ? newNote.tick + newNote.configData.length + : newNote.tick + (Decimal)0.1f * i; + holdTick.configData = newNote.configData; + + // ACTUALLY REQUIRED TO WORK + var dataCopy = holdTick.configData; + dataCopy.length = 0; + holdTick.configData = dataCopy; + + holdTick.isLongPressing = i != newNote.longPressCount; + holdTick.isLongPressEnd = i == newNote.longPressCount; + holdTick.noteData = newNote.noteData; + holdTick.longPressPTick = newNote.configData.time; + holdTick.endIndex = endIndex; + + MusicDataManager.Add(holdTick); } - - Logger.Msg("Loaded music data!"); } - private static void ProcessBossData(Bms bms) - { - var scene = bms.Info.Genre; - var bossData = MusicDataManager.Data.Where(mData => mData.isBossNote).ToList(); + Logger.Msg("Loaded music data!"); + } + + private static void ProcessBossData(Bms bms) + { + var scene = bms.Info.Genre; + var bossData = MusicDataManager.Data.Where(mData => mData.isBossNote).ToList(); - // If the boss is not used for some reason, no need to process animations. - if (bossData.Count == 0) return; + // If the boss is not used for some reason, no need to process animations. + if (bossData.Count == 0) return; - // Add a boss exit animation if it is missing. - var finalData = bossData[^1]; - if (AnimStatesRight[finalData.noteData.boss_action] != BossState.OffScreen) + // Add a boss exit animation if it is missing. + var finalData = bossData[^1]; + if (AnimStatesRight[finalData.noteData.boss_action] != BossState.OffScreen) + { + var finalNote = MusicDataManager.Data[^1]; + var tick = finalNote.tick; + + if (finalNote.isBossNote) { - var finalNote = MusicDataManager.Data[^1]; - var tick = finalNote.tick; + var startDelay = Decimal.Round((Decimal)GetStartDelay(finalNote.noteData.prefab_name), 3); + var duration = (Decimal)GetAnimationDuration(scene, finalNote.noteData.boss_action); + tick += Decimal.Round(-startDelay + duration, 3); + } - if (finalNote.isBossNote) - { - var startDelay = Decimal.Round((Decimal)GetStartDelay(finalNote.noteData.prefab_name), 3); - var duration = (Decimal)GetAnimationDuration(scene, finalNote.noteData.boss_action); - tick += Decimal.Round(-startDelay + duration, 3); - } + tick = Decimal.Round(tick + (Decimal)0.1f, 3); - tick = Decimal.Round(tick + (Decimal)0.1f, 3); + var exitNoteData = BossData[scene]["out"]; - var exitNoteData = BossData[scene]["out"]; + var exitConfig = Interop.CreateTypeValue(); + exitConfig.note_uid = exitNoteData.uid; + exitConfig.time = tick; - var exitConfig = Interop.CreateTypeValue(); - exitConfig.note_uid = exitNoteData.uid; - exitConfig.time = tick; + var exitMusicData = Interop.CreateTypeValue(); + exitMusicData.objId = 0; + exitMusicData.tick = exitConfig.time; + exitMusicData.configData = exitConfig; + exitMusicData.noteData = exitNoteData; - var exitMusicData = Interop.CreateTypeValue(); - exitMusicData.objId = 0; - exitMusicData.tick = exitConfig.time; - exitMusicData.configData = exitConfig; - exitMusicData.noteData = exitNoteData; + MusicDataManager.Add(exitMusicData); + bossData.Add(exitMusicData); + Logger.Msg("Added missing boss exit at " + exitConfig.time); + } - MusicDataManager.Add(exitMusicData); - bossData.Add(exitMusicData); - Logger.Msg("Added missing boss exit at " + exitConfig.time); - } + // Fix incorrect phase gears + NoteConfigData cachedGearConfig = null; + var allNoteData = SingletonScriptableObject.instance.noteDatas; - // Fix incorrect phase gears - NoteConfigData cachedGearConfig = null; - var allNoteData = SingletonScriptableObject.instance.noteDatas; + for (var i = 0; i < bossData.Count; i++) + { + var data = bossData[i]; - for (var i = 0; i < bossData.Count; i++) - { - var data = bossData[i]; + if (data.noteData.GetNoteType() != NoteType.Block) continue; - if (data.noteData.GetNoteType() != NoteType.Block) continue; + // Find the next boss animation that is not a gear + MusicData bossAnimAhead = null; + for (var j = i + 1; j < bossData.Count; j++) + { + var dataAhead = bossData[j]; + if (dataAhead.noteData.GetNoteType() == NoteType.Block) continue; - // Find the next boss animation that is not a gear - MusicData bossAnimAhead = null; - for (var j = i + 1; j < bossData.Count; j++) - { - var dataAhead = bossData[j]; - if (dataAhead.noteData.GetNoteType() == NoteType.Block) continue; + bossAnimAhead = dataAhead; + break; + } - bossAnimAhead = dataAhead; - break; - } + var bossAnimBefore = i > 0 ? bossData[i - 1] : null; - MusicData bossAnimBefore = i > 0 ? bossData[i - 1] : null; + var aheadTime = bossAnimAhead?.configData.time ?? Decimal.MinValue; + var beforeTime = bossAnimBefore?.configData.time ?? Decimal.MinValue; - var aheadTime = bossAnimAhead?.configData.time ?? Decimal.MinValue; - var beforeTime = bossAnimBefore?.configData.time ?? Decimal.MinValue; + var diffToAhead = Math.Abs((float)data.configData.time - (float)aheadTime); + var diffToBefore = Math.Abs((float)data.configData.time - (float)beforeTime); + var ahead = diffToAhead < diffToBefore; - var diffToAhead = Math.Abs((float)data.configData.time - (float)aheadTime); - var diffToBefore = Math.Abs((float)data.configData.time - (float)beforeTime); - var ahead = diffToAhead < diffToBefore; + var stateBehind = bossAnimBefore != null + ? AnimStatesRight[bossAnimBefore.noteData.boss_action] + : BossState.OffScreen; - var stateBehind = bossAnimBefore != null - ? AnimStatesRight[bossAnimBefore.noteData.boss_action] - : BossState.OffScreen; - - var stateAhead = BossState.OffScreen; - if (bossAnimAhead != null && AnimStatesLeft.TryGetValue(bossAnimAhead.noteData.boss_action, out var parsedState)) - stateAhead = parsedState; + var stateAhead = BossState.OffScreen; + if (bossAnimAhead != null && + AnimStatesLeft.TryGetValue(bossAnimAhead.noteData.boss_action, out var parsedState)) + stateAhead = parsedState; - var usedState = ahead ? stateAhead : stateBehind; - var correctState = usedState is BossState.Phase1 or BossState.Phase2; - if (!correctState) - { - ahead = !ahead; - usedState = ahead ? stateAhead : stateBehind; - correctState = usedState is BossState.Phase1 or BossState.Phase2; - } + var usedState = ahead ? stateAhead : stateBehind; + var correctState = usedState is BossState.Phase1 or BossState.Phase2; + if (!correctState) + { + ahead = !ahead; + usedState = ahead ? stateAhead : stateBehind; + correctState = usedState is BossState.Phase1 or BossState.Phase2; + } - if (!correctState) continue; - if (usedState is not (BossState.Phase1 or BossState.Phase2)) continue; + if (!correctState) continue; + if (usedState is not (BossState.Phase1 or BossState.Phase2)) continue; - if ((ahead && AnimStatesLeft[data.noteData.boss_action] == usedState) || - (!ahead && AnimStatesRight[data.noteData.boss_action] == usedState)) - continue; + if ((ahead && AnimStatesLeft[data.noteData.boss_action] == usedState) || + (!ahead && AnimStatesRight[data.noteData.boss_action] == usedState)) + continue; - var phase = usedState == BossState.Phase1 ? 1 : 2; + var phase = usedState == BossState.Phase1 ? 1 : 2; - // Use cached gear config if it matches, otherwise search for a new one - if (cachedGearConfig == null - || cachedGearConfig.ibms_id != data.noteData.ibms_id - || cachedGearConfig.pathway != data.noteData.pathway - || cachedGearConfig.scene != data.noteData.scene - || cachedGearConfig.speed != data.noteData.speed - || !cachedGearConfig.boss_action.StartsWith($"boss_far_atk_{phase}")) - foreach (var d in allNoteData) - { - if (d.ibms_id != data.noteData.ibms_id - || d.pathway != data.noteData.pathway - || d.scene != data.noteData.scene - || d.speed != data.noteData.speed - || !d.boss_action.StartsWith($"boss_far_atk_{phase}")) continue; - - cachedGearConfig = d; - break; - } + // Use cached gear config if it matches, otherwise search for a new one + if (cachedGearConfig == null + || cachedGearConfig.ibms_id != data.noteData.ibms_id + || cachedGearConfig.pathway != data.noteData.pathway + || cachedGearConfig.scene != data.noteData.scene + || cachedGearConfig.speed != data.noteData.speed + || !cachedGearConfig.boss_action.StartsWith($"boss_far_atk_{phase}")) + foreach (var d in allNoteData) + { + if (d.ibms_id != data.noteData.ibms_id + || d.pathway != data.noteData.pathway + || d.scene != data.noteData.scene + || d.speed != data.noteData.speed + || !d.boss_action.StartsWith($"boss_far_atk_{phase}")) continue; - if (cachedGearConfig == null) continue; - var phaseGearConfig = cachedGearConfig; - - var fixedConfigData = Interop.CreateTypeValue(); - fixedConfigData.blood = data.configData.blood; - fixedConfigData.id = data.configData.id; - fixedConfigData.length = data.configData.length; - fixedConfigData.note_uid = phaseGearConfig.uid; - fixedConfigData.pathway = data.configData.pathway; - fixedConfigData.time = data.configData.time; - - var fixedGear = Interop.CreateTypeValue(); - fixedGear.objId = data.objId; - fixedGear.tick = data.tick; - fixedGear.configData = fixedConfigData; - fixedGear.isLongPressEnd = data.isLongPressEnd; - fixedGear.isLongPressing = data.isLongPressing; - fixedGear.noteData = phaseGearConfig; - - MusicDataManager.Set(fixedGear.objId, fixedGear); - bossData[i] = fixedGear; - Logger.Msg($"Fixed gear at tick {data.tick}."); - } + cachedGearConfig = d; + break; + } - // Resolve state changes in list - var bossState = BossState.OffScreen; - for (var i = 0; i < bossData.Count; i++) - { - var anim = bossData[i].noteData.boss_action; - var nextState = AnimStatesLeft[anim]; + if (cachedGearConfig == null) continue; + var phaseGearConfig = cachedGearConfig; + + var fixedConfigData = Interop.CreateTypeValue(); + fixedConfigData.blood = data.configData.blood; + fixedConfigData.id = data.configData.id; + fixedConfigData.length = data.configData.length; + fixedConfigData.note_uid = phaseGearConfig.uid; + fixedConfigData.pathway = data.configData.pathway; + fixedConfigData.time = data.configData.time; + + var fixedGear = Interop.CreateTypeValue(); + fixedGear.objId = data.objId; + fixedGear.tick = data.tick; + fixedGear.configData = fixedConfigData; + fixedGear.isLongPressEnd = data.isLongPressEnd; + fixedGear.isLongPressing = data.isLongPressing; + fixedGear.noteData = phaseGearConfig; + + MusicDataManager.Set(fixedGear.objId, fixedGear); + bossData[i] = fixedGear; + Logger.Msg($"Fixed gear at tick {data.tick}."); + } - if (bossState != nextState) - { - var transfer = StateTransferAnims[bossState][nextState]; - var transferNoteData = BossData[scene][transfer]; - var alignment = TransferAlignment[transfer]; + // Resolve state changes in list + var bossState = BossState.OffScreen; + for (var i = 0; i < bossData.Count; i++) + { + var anim = bossData[i].noteData.boss_action; + var nextState = AnimStatesLeft[anim]; - var alignData = alignment == AnimAlignment.Right ? bossData[i] : bossData[i - 1]; + if (bossState != nextState) + { + var transfer = StateTransferAnims[bossState][nextState]; + var transferNoteData = BossData[scene][transfer]; + var alignment = TransferAlignment[transfer]; - var rightDelay = (Decimal)GetStartDelay(bossData[i].noteData.prefab_name); - var leftDelay = i == 0 ? 0 : (Decimal)GetStartDelay(bossData[i - 1].noteData.prefab_name); - var alignDelay = alignment == AnimAlignment.Left ? leftDelay : rightDelay; + var alignData = alignment == AnimAlignment.Right ? bossData[i] : bossData[i - 1]; - var duration = (Decimal)GetAnimationDuration(scene, transfer); + var rightDelay = (Decimal)GetStartDelay(bossData[i].noteData.prefab_name); + var leftDelay = i == 0 ? 0 : (Decimal)GetStartDelay(bossData[i - 1].noteData.prefab_name); + var alignDelay = alignment == AnimAlignment.Left ? leftDelay : rightDelay; - var mConfig = Interop.CreateTypeValue(); - mConfig.note_uid = transferNoteData.uid; - mConfig.time = Decimal.Round(alignData.tick - alignDelay - duration * (int)alignment, 3); + var duration = (Decimal)GetAnimationDuration(scene, transfer); - var mData = Interop.CreateTypeValue(); - mData.tick = mConfig.time; - mData.configData = mConfig; - mData.noteData = transferNoteData; + var mConfig = Interop.CreateTypeValue(); + mConfig.note_uid = transferNoteData.uid; + mConfig.time = Decimal.Round(alignData.tick - alignDelay - duration * (int)alignment, 3); - var tolerance = (Decimal)0.300f; - var fits = alignment switch - { - AnimAlignment.Left => mData.tick + duration < bossData[i].tick - rightDelay + tolerance, - _ => i == 0 || mData.tick > bossData[i - 1].tick - leftDelay - }; + var mData = Interop.CreateTypeValue(); + mData.tick = mConfig.time; + mData.configData = mConfig; + mData.noteData = transferNoteData; - if (!fits) - { - bossState = AnimStatesRight[bossData[i].noteData.boss_action]; - continue; - } + var tolerance = (Decimal)0.300f; + var fits = alignment switch + { + AnimAlignment.Left => mData.tick + duration < bossData[i].tick - rightDelay + tolerance, + _ => i == 0 || mData.tick > bossData[i - 1].tick - leftDelay + }; - MusicDataManager.Add(mData); - bossData.Insert(i, mData); - i--; - } - else + if (!fits) { bossState = AnimStatesRight[bossData[i].noteData.boss_action]; + continue; } - } - Logger.Msg("Processed boss animations!"); + MusicDataManager.Add(mData); + bossData.Insert(i, mData); + i--; + } + else + { + bossState = AnimStatesRight[bossData[i].noteData.boss_action]; + } } - private static void ProcessDelay(Bms bms) - { - var scene = bms.Info.Genre; - var sceneIndex = scene.Split('_')[1].ParseAsInt(); - var sceneInfo = Singleton.instance.sceneInfo; - var delayCache = new Dictionary(); + Logger.Msg("Processed boss animations!"); + } - for (var i = 0; i < MusicDataManager.Data.Count; i++) + private static void ProcessDelay(Bms bms) + { + var scene = bms.Info.Genre; + var sceneIndex = scene.Split('_')[1].ParseAsInt(); + var sceneInfo = Singleton.instance.sceneInfo; + var delayCache = new Dictionary(); + + for (var i = 0; i < MusicDataManager.Data.Count; i++) + { + var mData = MusicDataManager.Data[i]; + if (!string.IsNullOrEmpty(mData.noteData.ibms_id)) { - var mData = MusicDataManager.Data[i]; - if (!string.IsNullOrEmpty(mData.noteData.ibms_id)) + var type = mData.noteData.GetNoteType(); + if (type == NoteType.SceneChange) sceneIndex = sceneInfo[mData.noteData.ibms_id]; + + var prefabName = mData.noteData.prefab_name; + if (!string.IsNullOrEmpty(prefabName)) { - var type = mData.noteData.GetNoteType(); - if (type == NoteType.SceneChange) sceneIndex = sceneInfo[mData.noteData.ibms_id]; + // If not a pickup type, convert to most recent scene + if (type != NoteType.Hp && type != NoteType.Music) + { + var prefix = prefabName[..2]; + if (prefix is not "00" and not "em" and not "bo") + prefabName = prefabName.Remove(0, 2).Insert(0, $"{sceneIndex:D2}"); + } - var prefabName = mData.noteData.prefab_name; - if (!string.IsNullOrEmpty(prefabName)) + if (!delayCache.ContainsKey(prefabName)) { - // If not a pickup type, convert to most recent scene - if (type != NoteType.Hp && type != NoteType.Music) - { - var prefix = prefabName[..2]; - if (prefix is not "00" and not "em" and not "bo") - prefabName = prefabName.Remove(0, 2).Insert(0, $"{sceneIndex:D2}"); - } + var gameObject = ResourcesManager.instance.LoadFromName(prefabName); - if (!delayCache.ContainsKey(prefabName)) + if (gameObject != null) { - var gameObject = ResourcesManager.instance.LoadFromName(prefabName); - - if (gameObject != null) - { - var spineActionController = gameObject.GetComponent(); - delayCache[prefabName] = (Decimal)spineActionController.startDelay; - } + var spineActionController = gameObject.GetComponent(); + delayCache[prefabName] = (Decimal)spineActionController.startDelay; } + } - if (delayCache.TryGetValue(prefabName, out var delay)) - { - mData.dt = delay; - MusicDataManager.Set(i, mData); - } + if (delayCache.TryGetValue(prefabName, out var delay)) + { + mData.dt = delay; + MusicDataManager.Set(i, mData); } } - - var showTick = mData.tick - mData.dt; - _delay = showTick < _delay ? showTick : _delay; } - // Round delay - _delay = Decimal.Round(_delay, 3); - Logger.Msg("Processed delay!"); + var showTick = mData.tick - mData.dt; + _delay = showTick < _delay ? showTick : _delay; } - private static void ProcessGeminis() - { - var geminiCache = new Dictionary>(); + // Round delay + _delay = Decimal.Round(_delay, 3); + Logger.Msg("Processed delay!"); + } - for (var i = 1; i < MusicDataManager.Data.Count; i++) - { - var mData = MusicDataManager.Data[i]; - mData.doubleIdx = -1; - MusicDataManager.Set(i, mData); + private static void ProcessGeminis() + { + var geminiCache = new Dictionary>(); - if (mData.noteData.GetNoteType() != NoteType.Monster && mData.noteData.GetNoteType() != NoteType.Hide) - continue; + for (var i = 1; i < MusicDataManager.Data.Count; i++) + { + var mData = MusicDataManager.Data[i]; + mData.doubleIdx = -1; + MusicDataManager.Set(i, mData); - if (geminiCache.TryGetValue(mData.tick, out var geminiList)) - { - var isNoteGemini = Bms.BmsIds[mData.noteData.ibms_id] == Bms.BmsId.Gemini; - var isTargetGemini = false; - var target = Interop.CreateTypeValue(); + if (mData.noteData.GetNoteType() != NoteType.Monster && mData.noteData.GetNoteType() != NoteType.Hide) + continue; - foreach (var gemini in geminiList.Where(gemini => mData.isAir != gemini.isAir)) - { - target = gemini; - isTargetGemini = Bms.BmsIds[gemini.noteData.ibms_id] == Bms.BmsId.Gemini; - - if (isNoteGemini && isTargetGemini) break; - if (!isNoteGemini) break; - } + if (geminiCache.TryGetValue(mData.tick, out var geminiList)) + { + var isNoteGemini = Bms.BmsIds[mData.noteData.ibms_id] == Bms.BmsId.Gemini; + var isTargetGemini = false; + var target = Interop.CreateTypeValue(); - if (target.objId > 0) - { - mData.isDouble = isNoteGemini && isTargetGemini; - mData.doubleIdx = target.objId; - target.isDouble = isNoteGemini && isTargetGemini; - target.doubleIdx = mData.objId; + foreach (var gemini in geminiList.Where(gemini => mData.isAir != gemini.isAir)) + { + target = gemini; + isTargetGemini = Bms.BmsIds[gemini.noteData.ibms_id] == Bms.BmsId.Gemini; - MusicDataManager.Set(mData.objId, mData); - MusicDataManager.Set(target.objId, target); - } + if (isNoteGemini && isTargetGemini) break; + if (!isNoteGemini) break; } - else + + if (target.objId > 0) { - geminiCache[mData.tick] = new List(); - } + mData.isDouble = isNoteGemini && isTargetGemini; + mData.doubleIdx = target.objId; + target.isDouble = isNoteGemini && isTargetGemini; + target.doubleIdx = mData.objId; - geminiCache[mData.tick].Add(mData); + MusicDataManager.Set(mData.objId, mData); + MusicDataManager.Set(target.objId, target); + } + } + else + { + geminiCache[mData.tick] = new List(); } - Logger.Msg("Processed geminis!"); + geminiCache[mData.tick].Add(mData); } - private static float GetStartDelay(string prefab) - { - if (StartDelayCache.TryGetValue(prefab, out var cached)) - return cached; + Logger.Msg("Processed geminis!"); + } - var delay = ResourcesManager.instance.LoadFromName(prefab) - .GetComponent().startDelay; - StartDelayCache[prefab] = delay; - return delay; - } + private static float GetStartDelay(string prefab) + { + if (StartDelayCache.TryGetValue(prefab, out var cached)) + return cached; - private static float GetAnimationDuration(string scene, string animation) - { - var cacheKey = (scene, animation); - if (AnimationDurationCache.TryGetValue(cacheKey, out var cached)) - return cached; - - var controller = ResourcesManager.instance - .LoadFromName(Boss.Instance.BossFestival($"{scene.Split("_")[1]}01_boss")) - .GetComponent(); - var animations = controller.gameObject.GetComponent().skeletonDataAsset - .GetSkeletonData(true).Animations; - - var animName = animation; - foreach (var actionData in controller.actionData) - { - if (actionData.name != animation) continue; - if (actionData.actionIdx is { Length: > 0 }) - animName = actionData.actionIdx[0]; - break; - } + var delay = ResourcesManager.instance.LoadFromName(prefab) + .GetComponent().startDelay; + StartDelayCache[prefab] = delay; + return delay; + } - var duration = animations - .Find((Il2CppSystem.Predicate)((Animation a) => a.Name == animName)).Duration; - AnimationDurationCache[cacheKey] = duration; - return duration; + private static float GetAnimationDuration(string scene, string animation) + { + var cacheKey = (scene, animation); + if (AnimationDurationCache.TryGetValue(cacheKey, out var cached)) + return cached; + + var controller = ResourcesManager.instance + .LoadFromName(Boss.Instance.BossFestival($"{scene.Split("_")[1]}01_boss")) + .GetComponent(); + var animations = controller.gameObject.GetComponent().skeletonDataAsset + .GetSkeletonData(true).Animations; + + var animName = animation; + foreach (var actionData in controller.actionData) + { + if (actionData.name != animation) continue; + if (actionData.actionIdx is { Length: > 0 }) + animName = actionData.actionIdx[0]; + break; } + + var duration = animations + .Find((Il2CppSystem.Predicate)((Animation a) => a.Name == animName)).Duration; + AnimationDurationCache[cacheKey] = duration; + return duration; } } \ No newline at end of file diff --git a/CustomAlbums.csproj b/CustomAlbums.csproj index 89ce4d4..5474414 100644 --- a/CustomAlbums.csproj +++ b/CustomAlbums.csproj @@ -9,18 +9,18 @@ true - - - - - - - - - + + + + + + + + + - + @@ -28,20 +28,20 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - contentFiles - - - - + + contentFiles + + + + - - + + - + \ No newline at end of file diff --git a/Data/Album.cs b/Data/Album.cs index 237b5a3..c135c15 100644 --- a/Data/Album.cs +++ b/Data/Album.cs @@ -1,28 +1,79 @@ using System.Collections; using System.IO.Compression; +using System.Reflection; using CustomAlbums.Managers; +using CustomAlbums.Patches; using CustomAlbums.Utilities; +using MelonLoader; +using MelonLoader.Utils; using UnityEngine; using UnityEngine.Networking; using Logger = CustomAlbums.Utilities.Logger; -namespace CustomAlbums.Data +namespace CustomAlbums.Data; + +public class Album { - public class Album + private static readonly Logger Logger = new(nameof(Album)); + private static Sprite _defaultCover; + + private Sprite _cover; + private bool _isCoverLoading; + + public Album(string directory, ZipArchiveEntry mdm, int index, string packName = null) { - private static readonly Logger Logger = new(nameof(Album)); + if (!string.IsNullOrEmpty(packName)) PackName = packName; + + using var mdmStream = mdm.Open(); + using var openedZip = new ZipArchive(mdmStream); - public Album(string directory, ZipArchiveEntry mdm, int index, string packName = null) + var info = openedZip.GetEntry("info.json"); + if (info == null) { - if (!string.IsNullOrEmpty(packName)) PackName = packName; - - using var mdmStream = mdm.Open(); - using var openedZip = new ZipArchive(mdmStream); + Logger.Error($"Could not find info.json in package: {mdm.Name}"); + throw new FileNotFoundException(); + } + + using var stream = info.Open(); + Info = Json.Deserialize(stream); + IsPackaged = true; + + // CurrentPack will always be null if album is not in a pack + IsPack = AlbumManager.CurrentPack != null; - var info = openedZip.GetEntry("info.json"); + HasPng = openedZip.GetEntry("cover.png") != null; + + Index = index; + Path = directory; + PackAlbumName = System.IO.Path.GetFileNameWithoutExtension(mdm.Name); + GetSheets(); + } + + public Album(string path, int index, string packName = null) + { + // If packName is not null then it's a file path, not a folder chart + if (Directory.Exists(path) && packName == null) + { + // Load album from directory + if (!File.Exists($"{path}\\info.json")) + { + Logger.Error($"Could not find info.json at: {path}\\info.json"); + throw new FileNotFoundException(); + } + + using var fileStream = File.OpenRead($"{path}\\info.json"); + Info = Json.Deserialize(fileStream); + HasPng = File.Exists(System.IO.Path.Combine(path, "cover.png")); + } + else if (File.Exists(path)) + { + // Load album from package + PackName = packName; + using var zip = ZipFile.OpenRead(path); + var info = zip.GetEntry("info.json"); if (info == null) { - Logger.Error($"Could not find info.json in package: {mdm.Name}"); + Logger.Error($"Could not find info.json in package: {path}"); throw new FileNotFoundException(); } @@ -33,333 +84,281 @@ public Album(string directory, ZipArchiveEntry mdm, int index, string packName = // CurrentPack will always be null if album is not in a pack IsPack = AlbumManager.CurrentPack != null; - HasPng = openedZip.GetEntry("cover.png") != null; - - Index = index; - Path = directory; - PackAlbumName = System.IO.Path.GetFileNameWithoutExtension(mdm.Name); - GetSheets(); + HasPng = zip.GetEntry("cover.png") != null; } + else + { + Logger.Error($"Could not find album at: {path}"); + throw new FileNotFoundException(); + } + + Index = index; + Path = path; - public Album(string path, int index, string packName = null) + GetSheets(); + } + + public string PackAlbumName { get; } = string.Empty; + public int Index { get; internal set; } + public string Path { get; } + public bool IsPackaged { get; } + public bool IsPack { get; } + public bool HasPng { get; } + + public bool HasCover + { + get { - // If packName is not null then it's a file path, not a folder chart - if (Directory.Exists(path) && packName == null) + if (HasPng) { - // Load album from directory - if (!File.Exists($"{path}\\info.json")) - { - Logger.Error($"Could not find info.json at: {path}\\info.json"); - throw new FileNotFoundException(); - } - - using var fileStream = File.OpenRead($"{path}\\info.json"); - Info = Json.Deserialize(fileStream); - HasPng = File.Exists(System.IO.Path.Combine(path, "cover.png")); + HotReloadManager.PnlStageInstance?.RefreshStageUI(); + return true; } - else if (File.Exists(path)) + + return false; + } + } + + public string PackName { get; } + public AlbumInfo Info { get; } + + public static Sprite DefaultCover + { + get + { + if (_defaultCover != null) return _defaultCover; + + var assembly = Assembly.GetExecutingAssembly(); + using var stream = assembly.GetManifestResourceStream("CustomAlbums.Resources.default_cover.png"); + + Texture2D tex; + if (stream != null) { - // Load album from package - PackName = packName; - using var zip = ZipFile.OpenRead(path); - var info = zip.GetEntry("info.json"); - if (info == null) - { - Logger.Error($"Could not find info.json in package: {path}"); - throw new FileNotFoundException(); - } - - using var stream = info.Open(); - Info = Json.Deserialize(stream); - IsPackaged = true; - - // CurrentPack will always be null if album is not in a pack - IsPack = AlbumManager.CurrentPack != null; - - HasPng = zip.GetEntry("cover.png") != null; + using var ms = new MemoryStream(); + stream.CopyTo(ms); + tex = new Texture2D(2, 2, TextureFormat.ARGB32, false); + tex.LoadImage(ms.ToArray()); } else { - Logger.Error($"Could not find album at: {path}"); - throw new FileNotFoundException(); + tex = new Texture2D(2, 2); } - Index = index; - Path = path; - - GetSheets(); + _defaultCover = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f)); + _defaultCover.hideFlags |= HideFlags.DontUnloadUnusedAsset; + return _defaultCover; } + } - public string PackAlbumName { get; } = string.Empty; - public int Index { get; internal set; } - public string Path { get; } - public bool IsPackaged { get; } - public bool IsPack { get; } - public bool HasPng { get; } - public bool HasCover + public Sprite Cover + { + get { - get + if (_cover != null) return _cover; + if (!_isCoverLoading && HasPng) { - if (HasPng) - { - CustomAlbums.Managers.HotReloadManager.PnlStageInstance?.RefreshStageUI(); - return true; - } - - return false; + _isCoverLoading = true; + MelonCoroutines.Start(LoadCoverAsync()); } + + return DefaultCover; } - public string PackName { get; } - public AlbumInfo Info { get; } + } + + public AudioClip Music => this.GetAudio(); + public AudioClip Demo => this.GetAudio("demo"); + public Dictionary Sheets { get; } = new(); + + public string AlbumName => + IsPackaged + ? $"album_{(string.IsNullOrEmpty(PackAlbumName) ? System.IO.Path.GetFileNameWithoutExtension(Path) : string.Empty)}{(PackName != null ? $"{PackAlbumName}_{PackName}" : string.Empty)}" + : $"album_{System.IO.Path.GetFileName(Path)}_folder"; + + public string Uid => $"{AlbumManager.Uid}-{Index}"; - private Sprite _cover; - private bool _isCoverLoading; - private static Sprite _defaultCover; - public static Sprite DefaultCover + private IEnumerator LoadCoverAsync() + { + string url; + var cacheDir = System.IO.Path.Combine(MelonEnvironment.UserDataDirectory, "CustomAlbums", "Cache"); + if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir); + + if (IsPack || IsPackaged) { - get + var cacheFile = System.IO.Path.Combine(cacheDir, $"{Uid}_cover.png"); + if (File.Exists(cacheFile)) File.Delete(cacheFile); + + using var stream = OpenNullableStream("cover.png"); + if (stream != null) { - if (_defaultCover != null) return _defaultCover; - - var assembly = System.Reflection.Assembly.GetExecutingAssembly(); - using var stream = assembly.GetManifestResourceStream("CustomAlbums.Resources.default_cover.png"); - - Texture2D tex; - if (stream != null) - { - using var ms = new System.IO.MemoryStream(); - stream.CopyTo(ms); - tex = new Texture2D(2, 2, TextureFormat.ARGB32, false); - tex.LoadImage(ms.ToArray()); - } - else - { - tex = new Texture2D(2, 2); - } - - _defaultCover = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f)); - _defaultCover.hideFlags |= HideFlags.DontUnloadUnusedAsset; - return _defaultCover; + using var fs = File.OpenWrite(cacheFile); + stream.CopyTo(fs); } - } - public Sprite Cover + url = "file:///" + cacheFile.Replace('\\', '/'); + } + else { - get - { - if (_cover != null) return _cover; - if (!_isCoverLoading && HasPng) - { - _isCoverLoading = true; - MelonLoader.MelonCoroutines.Start(LoadCoverAsync()); - } - return DefaultCover; - } + url = "file:///" + System.IO.Path.Combine(Path, "cover.png").Replace('\\', '/'); } - private IEnumerator LoadCoverAsync() - { - string url; - string cacheDir = System.IO.Path.Combine(MelonLoader.Utils.MelonEnvironment.UserDataDirectory, "CustomAlbums", "Cache"); - if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir); - - if (IsPack || IsPackaged) - { - string cacheFile = System.IO.Path.Combine(cacheDir, $"{Uid}_cover.png"); - if (System.IO.File.Exists(cacheFile)) File.Delete(cacheFile); - - using var stream = OpenNullableStream("cover.png"); - if (stream != null) - { - using var fs = File.OpenWrite(cacheFile); - stream.CopyTo(fs); - } - - url = "file:///" + cacheFile.Replace('\\', '/'); - } - else - { - url = "file:///" + System.IO.Path.Combine(Path, "cover.png").Replace('\\', '/'); - } + var request = UnityWebRequestTexture.GetTexture(url); + yield return request.SendWebRequest(); - var request = UnityWebRequestTexture.GetTexture(url); - yield return request.SendWebRequest(); + if (!request.isHttpError && !request.isNetworkError) + { + var texture = DownloadHandlerTexture.GetContent(request); + texture.wrapMode = TextureWrapMode.MirrorOnce; + _cover = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f)); + _cover.hideFlags |= HideFlags.DontUnloadUnusedAsset; - if (!request.isHttpError && !request.isNetworkError) - { - var texture = DownloadHandlerTexture.GetContent(request); - texture.wrapMode = TextureWrapMode.MirrorOnce; - _cover = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f)); - _cover.hideFlags |= HideFlags.DontUnloadUnusedAsset; - - CustomAlbums.Patches.AssetPatch.UpdateCache($"{AlbumName}_cover", _cover); - CustomAlbums.Patches.CoverRefreshPatch.Refresh($"{CustomAlbums.Managers.AlbumManager.Uid}-{Index}"); - } - - _isCoverLoading = false; + AssetPatch.UpdateCache($"{AlbumName}_cover", _cover); + CoverRefreshPatch.Refresh($"{AlbumManager.Uid}-{Index}"); } - public AudioClip Music => this.GetAudio(); - public AudioClip Demo => this.GetAudio("demo"); - public Dictionary Sheets { get; } = new(); - public string AlbumName => - IsPackaged ? - $"album_{(string.IsNullOrEmpty(PackAlbumName) ? System.IO.Path.GetFileNameWithoutExtension(Path) : string.Empty)}{(PackName != null ? $"{PackAlbumName}_{PackName}" : string.Empty)}" - : $"album_{System.IO.Path.GetFileName(Path)}_folder"; - public string Uid => $"{AlbumManager.Uid}-{Index}"; - - public bool HasFile(string name) + + _isCoverLoading = false; + } + + public bool HasFile(string name) + { + if (IsPack && !string.IsNullOrEmpty(PackAlbumName)) { - if (IsPack && !string.IsNullOrEmpty(PackAlbumName)) + if (!File.Exists(Path)) return false; + try { - if (!File.Exists(Path)) return false; - try - { - using var mdp = ZipFile.OpenRead(Path); - using var openedMdm = mdp.GetNestedZip(PackAlbumName + ".mdm"); - return openedMdm.GetEntry(name) != null; - } - catch (IOException) - { - return false; - } + using var mdp = ZipFile.OpenRead(Path); + using var openedMdm = mdp.GetNestedZip(PackAlbumName + ".mdm"); + return openedMdm.GetEntry(name) != null; } - if (IsPackaged) + catch (IOException) { - if (!File.Exists(Path)) return false; - try - { - using var zip = ZipFile.OpenRead(Path); - return zip.GetEntry(name) != null; - } - catch (IOException) - { - // This is expected in the case of deleting an album - return false; - } + return false; } - - var path = $"{Path}\\{name}"; - return File.Exists(path); } - public Stream OpenFileStreamIfPossible(string file) + if (IsPackaged) { - if (IsPack && !string.IsNullOrEmpty(PackAlbumName)) + if (!File.Exists(Path)) return false; + try { - using var mdp = ZipFile.OpenRead(Path); - using var openedMdm = mdp.GetNestedZip(PackAlbumName + ".mdm"); - var entry = openedMdm.GetEntry(file); - - if (entry != null) - { - return entry.Open().ToMemoryStream(); - } - - Logger.Error($"Could not find file in package: {file}"); - throw new FileNotFoundException(); + using var zip = ZipFile.OpenRead(Path); + return zip.GetEntry(name) != null; } - if (IsPackaged) + catch (IOException) { - using var zip = ZipFile.OpenRead(Path); - var entry = zip.GetEntry(file); + // This is expected in the case of deleting an album + return false; + } + } - if (entry != null) - { - return entry.Open().ToMemoryStream(); - } + var path = $"{Path}\\{name}"; + return File.Exists(path); + } - Logger.Error($"Could not find file in package: {file}"); - throw new FileNotFoundException(); - } + public Stream OpenFileStreamIfPossible(string file) + { + if (IsPack && !string.IsNullOrEmpty(PackAlbumName)) + { + using var mdp = ZipFile.OpenRead(Path); + using var openedMdm = mdp.GetNestedZip(PackAlbumName + ".mdm"); + var entry = openedMdm.GetEntry(file); - var path = $"{Path}\\{file}"; - if (File.Exists(path)) - return File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + if (entry != null) return entry.Open().ToMemoryStream(); - Logger.Error($"Could not find file: {path}"); + Logger.Error($"Could not find file in package: {file}"); throw new FileNotFoundException(); } - public Stream OpenNullableStream(string file) + if (IsPackaged) { - if (IsPack && !string.IsNullOrEmpty(PackAlbumName)) - { - using var mdp = ZipFile.OpenRead(Path); - using var openedMdm = mdp.GetNestedZip(PackAlbumName + ".mdm"); - var entry = openedMdm.GetEntry(file); + using var zip = ZipFile.OpenRead(Path); + var entry = zip.GetEntry(file); - if (entry != null) - { - return entry.Open().ToMemoryStream(); - } + if (entry != null) return entry.Open().ToMemoryStream(); - return null; - } - if (IsPackaged) - { - using var zip = ZipFile.OpenRead(Path); - var entry = zip.GetEntry(file); + Logger.Error($"Could not find file in package: {file}"); + throw new FileNotFoundException(); + } - if (entry != null) - { - return entry.Open().ToMemoryStream(); - } + var path = $"{Path}\\{file}"; + if (File.Exists(path)) + return File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - return null; - } + Logger.Error($"Could not find file: {path}"); + throw new FileNotFoundException(); + } + + public Stream OpenNullableStream(string file) + { + if (IsPack && !string.IsNullOrEmpty(PackAlbumName)) + { + using var mdp = ZipFile.OpenRead(Path); + using var openedMdm = mdp.GetNestedZip(PackAlbumName + ".mdm"); + var entry = openedMdm.GetEntry(file); - var path = $"{Path}\\{file}"; - if (File.Exists(path)) - return File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + if (entry != null) return entry.Open().ToMemoryStream(); return null; } - public MemoryStream OpenMemoryStream(string file) + if (IsPackaged) { - if (IsPack && !string.IsNullOrEmpty(PackAlbumName)) - { - using var mdp = ZipFile.OpenRead(Path); - using var openedMdm = mdp.GetNestedZip(PackAlbumName + ".mdm"); - var entry = openedMdm.GetEntry(file); + using var zip = ZipFile.OpenRead(Path); + var entry = zip.GetEntry(file); - if (entry != null) - { - return entry.Open().ToMemoryStream(); - } + if (entry != null) return entry.Open().ToMemoryStream(); - Logger.Error($"Could not find file in package: {file}"); - throw new FileNotFoundException(); - } + return null; + } - if (IsPackaged) - { - using var zip = ZipFile.OpenRead(Path); - var entry = zip.GetEntry(file); + var path = $"{Path}\\{file}"; + if (File.Exists(path)) + return File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - if (entry != null) - { - return entry.Open().ToMemoryStream(); - } + return null; + } - Logger.Error($"Could not find file in package: {file}"); - throw new FileNotFoundException(); - } + public MemoryStream OpenMemoryStream(string file) + { + if (IsPack && !string.IsNullOrEmpty(PackAlbumName)) + { + using var mdp = ZipFile.OpenRead(Path); + using var openedMdm = mdp.GetNestedZip(PackAlbumName + ".mdm"); + var entry = openedMdm.GetEntry(file); - var path = $"{Path}\\{file}"; - if (File.Exists(path)) - return File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite).ToMemoryStream(); + if (entry != null) return entry.Open().ToMemoryStream(); - Logger.Error($"Could not find file: {path}"); + Logger.Error($"Could not find file in package: {file}"); throw new FileNotFoundException(); } - private void GetSheets() + + if (IsPackaged) { - // Adds to the Sheets dictionary - foreach (var difficulty in Info.Difficulties.Keys.Where(difficulty => HasFile($"map{difficulty}.bms"))) - Sheets.Add(difficulty, new Sheet(this, difficulty)); + using var zip = ZipFile.OpenRead(Path); + var entry = zip.GetEntry(file); + + if (entry != null) return entry.Open().ToMemoryStream(); + + Logger.Error($"Could not find file in package: {file}"); + throw new FileNotFoundException(); } - public bool HasDifficulty(int difficulty) => Sheets.ContainsKey(difficulty); + var path = $"{Path}\\{file}"; + if (File.Exists(path)) + return File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite).ToMemoryStream(); + + Logger.Error($"Could not find file: {path}"); + throw new FileNotFoundException(); + } + + private void GetSheets() + { + // Adds to the Sheets dictionary + foreach (var difficulty in Info.Difficulties.Keys.Where(difficulty => HasFile($"map{difficulty}.bms"))) + Sheets.Add(difficulty, new Sheet(this, difficulty)); + } + + public bool HasDifficulty(int difficulty) + { + return Sheets.ContainsKey(difficulty); } } \ No newline at end of file diff --git a/Data/AlbumInfo.cs b/Data/AlbumInfo.cs index 9fba157..2bf66ee 100644 --- a/Data/AlbumInfo.cs +++ b/Data/AlbumInfo.cs @@ -2,58 +2,58 @@ using CustomAlbums.Utilities; using static CustomAlbums.Data.SceneEgg; -namespace CustomAlbums.Data +namespace CustomAlbums.Data; + +public class AlbumInfo { - public class AlbumInfo + public enum HiddenMode { - public enum HiddenMode - { - Click, - Press, - Toggle - } + Click, + Press, + Toggle + } + + public string Name { get; set; } = string.Empty; - public string Name { get; set; } = string.Empty; - [JsonPropertyName("name_romanized")] - public string NameRomanized { get; set; } = string.Empty; - public string Author { get; set; } = string.Empty; - public string LevelDesigner { get; set; } = string.Empty; - public string LevelDesigner1 { get; set; } = string.Empty; - public string LevelDesigner2 { get; set; } = string.Empty; - public string LevelDesigner3 { get; set; } = string.Empty; - public string LevelDesigner4 { get; set; } = string.Empty; - public string LevelDesigner5 { get; set; } = string.Empty; - public string Bpm { get; set; } = "0"; - public string Scene { get; set; } = "scene_01"; - public SceneEggs SceneEgg { get; set; } = SceneEggs.None; - - public Dictionary Difficulties + [JsonPropertyName("name_romanized")] public string NameRomanized { get; set; } = string.Empty; + + public string Author { get; set; } = string.Empty; + public string LevelDesigner { get; set; } = string.Empty; + public string LevelDesigner1 { get; set; } = string.Empty; + public string LevelDesigner2 { get; set; } = string.Empty; + public string LevelDesigner3 { get; set; } = string.Empty; + public string LevelDesigner4 { get; set; } = string.Empty; + public string LevelDesigner5 { get; set; } = string.Empty; + public string Bpm { get; set; } = "0"; + public string Scene { get; set; } = "scene_01"; + public SceneEggs SceneEgg { get; set; } = SceneEggs.None; + + public Dictionary Difficulties + { + get { - get - { - var dict = new Dictionary(); - if (Difficulty1 != "0") dict.Add(1, Difficulty1); - if (Difficulty2 != "0") dict.Add(2, Difficulty2); - if (Difficulty3 != "0") dict.Add(3, Difficulty3); - if (Difficulty4 != "0") dict.Add(4, Difficulty4); - if (Difficulty5 != "0") dict.Add(5, Difficulty5); - return dict; - } + var dict = new Dictionary(); + if (Difficulty1 != "0") dict.Add(1, Difficulty1); + if (Difficulty2 != "0") dict.Add(2, Difficulty2); + if (Difficulty3 != "0") dict.Add(3, Difficulty3); + if (Difficulty4 != "0") dict.Add(4, Difficulty4); + if (Difficulty5 != "0") dict.Add(5, Difficulty5); + return dict; } + } - public string Difficulty1 { get; set; } = "0"; - public string Difficulty2 { get; set; } = "0"; - public string Difficulty3 { get; set; } = "0"; - public string Difficulty4 { get; set; } = "0"; - public string Difficulty5 { get; set; } = "0"; + public string Difficulty1 { get; set; } = "0"; + public string Difficulty2 { get; set; } = "0"; + public string Difficulty3 { get; set; } = "0"; + public string Difficulty4 { get; set; } = "0"; + public string Difficulty5 { get; set; } = "0"; - public string HideBmsMode { get; set; } = "CLICK"; + public string HideBmsMode { get; set; } = "CLICK"; - [JsonConverter(typeof(Converters.NumberConverter))] - public string HideBmsDifficulty { get; set; } = "0"; + [JsonConverter(typeof(Converters.NumberConverter))] + public string HideBmsDifficulty { get; set; } = "0"; - public string HideBmsMessage { get; set; } = string.Empty; + public string HideBmsMessage { get; set; } = string.Empty; - public string[] SearchTags { get; set; } = Array.Empty(); - } + public string[] SearchTags { get; set; } = Array.Empty(); } \ No newline at end of file diff --git a/Data/AssetIdentifiers.cs b/Data/AssetIdentifiers.cs index 9ee879b..9ca56be 100644 --- a/Data/AssetIdentifiers.cs +++ b/Data/AssetIdentifiers.cs @@ -1,17 +1,16 @@ -namespace CustomAlbums.Data +namespace CustomAlbums.Data; + +internal class AssetIdentifiers { - internal class AssetIdentifiers + internal static readonly List AssetSuffixes = new() { - internal static readonly List AssetSuffixes = new() - { - "_demo", - "_music", - "_cover", - "_map1", - "_map2", - "_map3", - "_map4", - "_map5" - }; - } + "_demo", + "_music", + "_cover", + "_map1", + "_map2", + "_map3", + "_map4", + "_map5" + }; } \ No newline at end of file diff --git a/Data/Bms.cs b/Data/Bms.cs index 85a6053..f7aee96 100644 --- a/Data/Bms.cs +++ b/Data/Bms.cs @@ -1,519 +1,517 @@ using CustomAlbums.Utilities; using Il2CppAssets.Scripts.GameCore.Managers; -using Il2CppAssets.Scripts.PeroTools.Commons; using Il2CppGameLogic; using Il2CppPeroPeroGames.GlobalDefines; using Decimal = Il2CppSystem.Decimal; -namespace CustomAlbums.Data +namespace CustomAlbums.Data; + +public class Bms { - public class Bms + public enum BmsId { - public enum BmsId - { - None, - Small, - SmallUp, - SmallDown, - Medium1, - Medium1Up, - Medium1Down, - Medium2, - Medium2Up, - Medium2Down, - Large1, - Large2, - Raider, - Hammer, - Gemini, - Hold, - Masher, - Gear, - UpsideDownRaider, - UpsideDownHammer, - Speed1Both, - Speed2Both, - Speed3Both, - Speed1Low, - Speed2Low, - Speed3Low, - Speed1High, - Speed2High, - Speed3High, - Music, - BossMelee1, - BossMelee2, - BossProjectile1, - BossProjectile2, - BossProjectile3, - BossMasher1, - BossMasher2, - BossGear, - BossEntrance, - BossExit, - BossReadyPhase1, - BossEndPhase1, - BossReadyPhase2, - BossEndPhase2, - BossSwapPhase12, - BossSwapPhase21, - HideNotes, - UnhideNotes, - HideBoss, - UnhideBoss, - SceneSwitchSpaceStation, - SceneSwitchRetroCity, - SceneSwitchCastle, - SceneSwitchRainyNight, - SceneSwitchOriental, - SceneSwitchGrooveCoaster, - SceneSwitchTouhou, - SceneSwitchDjmax, - SceneSwitchMiku, - PItem, - Ghost, - Heart, - Note, - HideBackground, - UnhideBackground, - ScreenScrollUp, - ScreenScrollDown, - ScreenScrollOff, - ScanlineRipplesOn, - ScanlineRipplesOff, - ChromaticAberrationOn, - ChromaticAberrationOff, - VignetteOn, - VignetteOff, - TvStaticOn, - TvStaticOff, - FlashbangStart, - FlashbangMid, - FlashbangEnd, - BgStopOn, - BgStopOff, - MosaicOn, - MosaicOff, - SepiaOn, - SepiaOff, - MediumBullet, - MediumBulletUp, - MediumBulletDown, - MediumBulletLaneShift, - SmallBullet, - SmallBulletUp, - SmallBulletDown, - SmallBulletLaneShift, - LargeBullet, - LargeBulletUp, - LargeBulletDown, - LargeBulletLaneShift, - BossBullet1, - BossBullet1LaneShift, - BossBullet2, - BossBullet2LaneShift - } + None, + Small, + SmallUp, + SmallDown, + Medium1, + Medium1Up, + Medium1Down, + Medium2, + Medium2Up, + Medium2Down, + Large1, + Large2, + Raider, + Hammer, + Gemini, + Hold, + Masher, + Gear, + UpsideDownRaider, + UpsideDownHammer, + Speed1Both, + Speed2Both, + Speed3Both, + Speed1Low, + Speed2Low, + Speed3Low, + Speed1High, + Speed2High, + Speed3High, + Music, + BossMelee1, + BossMelee2, + BossProjectile1, + BossProjectile2, + BossProjectile3, + BossMasher1, + BossMasher2, + BossGear, + BossEntrance, + BossExit, + BossReadyPhase1, + BossEndPhase1, + BossReadyPhase2, + BossEndPhase2, + BossSwapPhase12, + BossSwapPhase21, + HideNotes, + UnhideNotes, + HideBoss, + UnhideBoss, + SceneSwitchSpaceStation, + SceneSwitchRetroCity, + SceneSwitchCastle, + SceneSwitchRainyNight, + SceneSwitchOriental, + SceneSwitchGrooveCoaster, + SceneSwitchTouhou, + SceneSwitchDjmax, + SceneSwitchMiku, + PItem, + Ghost, + Heart, + Note, + HideBackground, + UnhideBackground, + ScreenScrollUp, + ScreenScrollDown, + ScreenScrollOff, + ScanlineRipplesOn, + ScanlineRipplesOff, + ChromaticAberrationOn, + ChromaticAberrationOff, + VignetteOn, + VignetteOff, + TvStaticOn, + TvStaticOff, + FlashbangStart, + FlashbangMid, + FlashbangEnd, + BgStopOn, + BgStopOff, + MosaicOn, + MosaicOff, + SepiaOn, + SepiaOff, + MediumBullet, + MediumBulletUp, + MediumBulletDown, + MediumBulletLaneShift, + SmallBullet, + SmallBulletUp, + SmallBulletDown, + SmallBulletLaneShift, + LargeBullet, + LargeBulletUp, + LargeBulletDown, + LargeBulletLaneShift, + BossBullet1, + BossBullet1LaneShift, + BossBullet2, + BossBullet2LaneShift + } - [Flags] - public enum ChannelType - { - /// - /// Channel does not support anything. - /// - None = 0, - - /// - /// Channel supports the Ground Lane. - /// - Ground = 1, - - /// - /// Channel supports the Air Lane. - /// - Air = 2, - - /// - /// Channel supports standard events. - /// - Event = 4, - - /// - /// Channel supports scene events. - /// - Scene = 8, - - /// - /// Notes in this channel become Heart Enemies if possible. - /// - SpBlood = 16, - - /// - /// Notes in this channel become Tap Holds if possible. - /// - SpTapHolds = 32, - - /// - /// This channel is used for BPM changes, with the value directly present. - /// - SpBpmDirect = 64, - - /// - /// This channel is used for BPM changes, with the value placed in a lookup table. - /// - SpBpmLookup = 128, - - /// - /// This channel is used for time signature changes. - /// - SpTimesig = 256, - - /// - /// This channel is used for scroll speed changes. - /// - SpScroll = 512 - } + [Flags] + public enum ChannelType + { + /// + /// Channel does not support anything. + /// + None = 0, + + /// + /// Channel supports the Ground Lane. + /// + Ground = 1, + + /// + /// Channel supports the Air Lane. + /// + Air = 2, + + /// + /// Channel supports standard events. + /// + Event = 4, + + /// + /// Channel supports scene events. + /// + Scene = 8, + + /// + /// Notes in this channel become Heart Enemies if possible. + /// + SpBlood = 16, + + /// + /// Notes in this channel become Tap Holds if possible. + /// + SpTapHolds = 32, + + /// + /// This channel is used for BPM changes, with the value directly present. + /// + SpBpmDirect = 64, + + /// + /// This channel is used for BPM changes, with the value placed in a lookup table. + /// + SpBpmLookup = 128, + + /// + /// This channel is used for time signature changes. + /// + SpTimesig = 256, + + /// + /// This channel is used for scroll speed changes. + /// + SpScroll = 512 + } - public static readonly Dictionary Channels = new() - { - ["01"] = ChannelType.Scene, - ["02"] = ChannelType.SpTimesig, - ["03"] = ChannelType.SpBpmDirect, - ["08"] = ChannelType.SpBpmLookup, - ["15"] = ChannelType.Event, - ["16"] = ChannelType.Event, - ["18"] = ChannelType.Event, - ["11"] = ChannelType.Air | ChannelType.Event, - ["13"] = ChannelType.Air | ChannelType.Event, - ["31"] = ChannelType.Air | ChannelType.Event | ChannelType.SpBlood, - ["33"] = ChannelType.Air | ChannelType.Event | ChannelType.SpBlood, - ["51"] = ChannelType.Air | ChannelType.Event, - ["53"] = ChannelType.Air | ChannelType.Event, - ["D1"] = ChannelType.Air | ChannelType.Event | ChannelType.SpTapHolds, - ["D3"] = ChannelType.Air | ChannelType.Event | ChannelType.SpTapHolds, - ["12"] = ChannelType.Ground | ChannelType.Event, - ["14"] = ChannelType.Ground | ChannelType.Event, - ["32"] = ChannelType.Ground | ChannelType.Event | ChannelType.SpBlood, - ["34"] = ChannelType.Ground | ChannelType.Event | ChannelType.SpBlood, - ["52"] = ChannelType.Ground | ChannelType.Event, - ["54"] = ChannelType.Ground | ChannelType.Event, - ["D2"] = ChannelType.Ground | ChannelType.Event | ChannelType.SpTapHolds, - ["D4"] = ChannelType.Ground | ChannelType.Event | ChannelType.SpTapHolds, - ["SC"] = ChannelType.SpScroll - }; - - public static readonly Dictionary BmsIds = new() - { - ["00"] = BmsId.None, - ["01"] = BmsId.Small, - ["02"] = BmsId.SmallUp, - ["03"] = BmsId.SmallDown, - ["04"] = BmsId.Medium1, - ["05"] = BmsId.Medium1Up, - ["06"] = BmsId.Medium1Down, - ["07"] = BmsId.Medium2, - ["08"] = BmsId.Medium2Up, - ["09"] = BmsId.Medium2Down, - ["0A"] = BmsId.Large1, - ["0B"] = BmsId.Large2, - ["0C"] = BmsId.Raider, - ["0D"] = BmsId.Hammer, - ["0E"] = BmsId.Gemini, - ["0F"] = BmsId.Hold, - ["0G"] = BmsId.Masher, - ["0H"] = BmsId.Gear, - ["0I"] = BmsId.UpsideDownRaider, - ["0J"] = BmsId.UpsideDownHammer, - ["0O"] = BmsId.Speed1Both, - ["0P"] = BmsId.Speed2Both, - ["0Q"] = BmsId.Speed3Both, - ["0R"] = BmsId.Speed1Low, - ["0S"] = BmsId.Speed2Low, - ["0T"] = BmsId.Speed3Low, - ["0U"] = BmsId.Speed1High, - ["0V"] = BmsId.Speed2High, - ["0W"] = BmsId.Speed3High, - ["10"] = BmsId.Music, - ["11"] = BmsId.BossMelee1, - ["12"] = BmsId.BossMelee2, - ["13"] = BmsId.BossProjectile1, - ["14"] = BmsId.BossProjectile2, - ["15"] = BmsId.BossProjectile3, - ["16"] = BmsId.BossMasher1, - ["17"] = BmsId.BossMasher2, - ["18"] = BmsId.BossGear, - ["1A"] = BmsId.BossEntrance, - ["1B"] = BmsId.BossExit, - ["1C"] = BmsId.BossReadyPhase1, - ["1D"] = BmsId.BossEndPhase1, - ["1E"] = BmsId.BossReadyPhase2, - ["1F"] = BmsId.BossEndPhase2, - ["1G"] = BmsId.BossSwapPhase12, - ["1H"] = BmsId.BossSwapPhase21, - ["1J"] = BmsId.HideNotes, - ["1K"] = BmsId.UnhideNotes, - ["1L"] = BmsId.HideBoss, - ["1M"] = BmsId.UnhideBoss, - ["1O"] = BmsId.SceneSwitchSpaceStation, - ["1P"] = BmsId.SceneSwitchRetroCity, - ["1Q"] = BmsId.SceneSwitchCastle, - ["1R"] = BmsId.SceneSwitchRainyNight, - ["1S"] = BmsId.SceneSwitchOriental, - ["1T"] = BmsId.SceneSwitchGrooveCoaster, - ["1U"] = BmsId.SceneSwitchTouhou, - ["1V"] = BmsId.SceneSwitchDjmax, - ["1X"] = BmsId.SceneSwitchMiku, - ["20"] = BmsId.PItem, - ["21"] = BmsId.Ghost, - ["22"] = BmsId.Heart, - ["23"] = BmsId.Note, - ["25"] = BmsId.HideBackground, - ["26"] = BmsId.UnhideBackground, - ["27"] = BmsId.ScreenScrollUp, - ["28"] = BmsId.ScreenScrollDown, - ["29"] = BmsId.ScreenScrollOff, - ["2A"] = BmsId.ScanlineRipplesOn, - ["2B"] = BmsId.ScanlineRipplesOff, - ["2C"] = BmsId.ChromaticAberrationOn, - ["2D"] = BmsId.ChromaticAberrationOff, - ["2E"] = BmsId.VignetteOn, - ["2F"] = BmsId.VignetteOff, - ["2G"] = BmsId.TvStaticOn, - ["2H"] = BmsId.TvStaticOff, - ["2I"] = BmsId.FlashbangStart, - ["2J"] = BmsId.FlashbangMid, - ["2K"] = BmsId.FlashbangEnd, - ["2N"] = BmsId.BgStopOn, - ["2O"] = BmsId.BgStopOff, - ["2P"] = BmsId.MosaicOn, - ["2Q"] = BmsId.MosaicOff, - ["2R"] = BmsId.SepiaOn, - ["2S"] = BmsId.SepiaOff, - ["30"] = BmsId.MediumBullet, - ["31"] = BmsId.MediumBulletUp, - ["32"] = BmsId.MediumBulletDown, - ["33"] = BmsId.MediumBulletLaneShift, - ["34"] = BmsId.SmallBullet, - ["35"] = BmsId.SmallBulletUp, - ["36"] = BmsId.SmallBulletDown, - ["37"] = BmsId.SmallBulletLaneShift, - ["38"] = BmsId.LargeBullet, - ["39"] = BmsId.LargeBulletUp, - ["3A"] = BmsId.LargeBulletDown, - ["3B"] = BmsId.LargeBulletLaneShift, - ["3C"] = BmsId.BossBullet1, - ["3D"] = BmsId.BossBullet1LaneShift, - ["3E"] = BmsId.BossBullet2, - ["3F"] = BmsId.BossBullet2LaneShift - }; - - public BmsInfo Info { get; set; } - public List Notes { get; set; } - public List NotesPercent { get; set; } - public string Md5 { get; set; } - public Dictionary NoteData { get; set; } - - public float Bpm => Info.Bpm; - - public static string GetNoteDataKey(string bmsId, int pathway, int speed, string scene) - { - return $"{bmsId}-{pathway}-{speed}-{scene}"; - } + public static readonly Dictionary Channels = new() + { + ["01"] = ChannelType.Scene, + ["02"] = ChannelType.SpTimesig, + ["03"] = ChannelType.SpBpmDirect, + ["08"] = ChannelType.SpBpmLookup, + ["15"] = ChannelType.Event, + ["16"] = ChannelType.Event, + ["18"] = ChannelType.Event, + ["11"] = ChannelType.Air | ChannelType.Event, + ["13"] = ChannelType.Air | ChannelType.Event, + ["31"] = ChannelType.Air | ChannelType.Event | ChannelType.SpBlood, + ["33"] = ChannelType.Air | ChannelType.Event | ChannelType.SpBlood, + ["51"] = ChannelType.Air | ChannelType.Event, + ["53"] = ChannelType.Air | ChannelType.Event, + ["D1"] = ChannelType.Air | ChannelType.Event | ChannelType.SpTapHolds, + ["D3"] = ChannelType.Air | ChannelType.Event | ChannelType.SpTapHolds, + ["12"] = ChannelType.Ground | ChannelType.Event, + ["14"] = ChannelType.Ground | ChannelType.Event, + ["32"] = ChannelType.Ground | ChannelType.Event | ChannelType.SpBlood, + ["34"] = ChannelType.Ground | ChannelType.Event | ChannelType.SpBlood, + ["52"] = ChannelType.Ground | ChannelType.Event, + ["54"] = ChannelType.Ground | ChannelType.Event, + ["D2"] = ChannelType.Ground | ChannelType.Event | ChannelType.SpTapHolds, + ["D4"] = ChannelType.Ground | ChannelType.Event | ChannelType.SpTapHolds, + ["SC"] = ChannelType.SpScroll + }; + + public static readonly Dictionary BmsIds = new() + { + ["00"] = BmsId.None, + ["01"] = BmsId.Small, + ["02"] = BmsId.SmallUp, + ["03"] = BmsId.SmallDown, + ["04"] = BmsId.Medium1, + ["05"] = BmsId.Medium1Up, + ["06"] = BmsId.Medium1Down, + ["07"] = BmsId.Medium2, + ["08"] = BmsId.Medium2Up, + ["09"] = BmsId.Medium2Down, + ["0A"] = BmsId.Large1, + ["0B"] = BmsId.Large2, + ["0C"] = BmsId.Raider, + ["0D"] = BmsId.Hammer, + ["0E"] = BmsId.Gemini, + ["0F"] = BmsId.Hold, + ["0G"] = BmsId.Masher, + ["0H"] = BmsId.Gear, + ["0I"] = BmsId.UpsideDownRaider, + ["0J"] = BmsId.UpsideDownHammer, + ["0O"] = BmsId.Speed1Both, + ["0P"] = BmsId.Speed2Both, + ["0Q"] = BmsId.Speed3Both, + ["0R"] = BmsId.Speed1Low, + ["0S"] = BmsId.Speed2Low, + ["0T"] = BmsId.Speed3Low, + ["0U"] = BmsId.Speed1High, + ["0V"] = BmsId.Speed2High, + ["0W"] = BmsId.Speed3High, + ["10"] = BmsId.Music, + ["11"] = BmsId.BossMelee1, + ["12"] = BmsId.BossMelee2, + ["13"] = BmsId.BossProjectile1, + ["14"] = BmsId.BossProjectile2, + ["15"] = BmsId.BossProjectile3, + ["16"] = BmsId.BossMasher1, + ["17"] = BmsId.BossMasher2, + ["18"] = BmsId.BossGear, + ["1A"] = BmsId.BossEntrance, + ["1B"] = BmsId.BossExit, + ["1C"] = BmsId.BossReadyPhase1, + ["1D"] = BmsId.BossEndPhase1, + ["1E"] = BmsId.BossReadyPhase2, + ["1F"] = BmsId.BossEndPhase2, + ["1G"] = BmsId.BossSwapPhase12, + ["1H"] = BmsId.BossSwapPhase21, + ["1J"] = BmsId.HideNotes, + ["1K"] = BmsId.UnhideNotes, + ["1L"] = BmsId.HideBoss, + ["1M"] = BmsId.UnhideBoss, + ["1O"] = BmsId.SceneSwitchSpaceStation, + ["1P"] = BmsId.SceneSwitchRetroCity, + ["1Q"] = BmsId.SceneSwitchCastle, + ["1R"] = BmsId.SceneSwitchRainyNight, + ["1S"] = BmsId.SceneSwitchOriental, + ["1T"] = BmsId.SceneSwitchGrooveCoaster, + ["1U"] = BmsId.SceneSwitchTouhou, + ["1V"] = BmsId.SceneSwitchDjmax, + ["1X"] = BmsId.SceneSwitchMiku, + ["20"] = BmsId.PItem, + ["21"] = BmsId.Ghost, + ["22"] = BmsId.Heart, + ["23"] = BmsId.Note, + ["25"] = BmsId.HideBackground, + ["26"] = BmsId.UnhideBackground, + ["27"] = BmsId.ScreenScrollUp, + ["28"] = BmsId.ScreenScrollDown, + ["29"] = BmsId.ScreenScrollOff, + ["2A"] = BmsId.ScanlineRipplesOn, + ["2B"] = BmsId.ScanlineRipplesOff, + ["2C"] = BmsId.ChromaticAberrationOn, + ["2D"] = BmsId.ChromaticAberrationOff, + ["2E"] = BmsId.VignetteOn, + ["2F"] = BmsId.VignetteOff, + ["2G"] = BmsId.TvStaticOn, + ["2H"] = BmsId.TvStaticOff, + ["2I"] = BmsId.FlashbangStart, + ["2J"] = BmsId.FlashbangMid, + ["2K"] = BmsId.FlashbangEnd, + ["2N"] = BmsId.BgStopOn, + ["2O"] = BmsId.BgStopOff, + ["2P"] = BmsId.MosaicOn, + ["2Q"] = BmsId.MosaicOff, + ["2R"] = BmsId.SepiaOn, + ["2S"] = BmsId.SepiaOff, + ["30"] = BmsId.MediumBullet, + ["31"] = BmsId.MediumBulletUp, + ["32"] = BmsId.MediumBulletDown, + ["33"] = BmsId.MediumBulletLaneShift, + ["34"] = BmsId.SmallBullet, + ["35"] = BmsId.SmallBulletUp, + ["36"] = BmsId.SmallBulletDown, + ["37"] = BmsId.SmallBulletLaneShift, + ["38"] = BmsId.LargeBullet, + ["39"] = BmsId.LargeBulletUp, + ["3A"] = BmsId.LargeBulletDown, + ["3B"] = BmsId.LargeBulletLaneShift, + ["3C"] = BmsId.BossBullet1, + ["3D"] = BmsId.BossBullet1LaneShift, + ["3E"] = BmsId.BossBullet2, + ["3F"] = BmsId.BossBullet2LaneShift + }; + + public BmsInfo Info { get; set; } + public List Notes { get; set; } + public List NotesPercent { get; set; } + public string Md5 { get; set; } + public Dictionary NoteData { get; set; } + + public float Bpm => Info.Bpm; + + public static string GetNoteDataKey(string bmsId, int pathway, int speed, string scene) + { + return $"{bmsId}-{pathway}-{speed}-{scene}"; + } - public void InitNoteData() - { - NoteData = new Dictionary(); + public void InitNoteData() + { + NoteData = new Dictionary(); - // NOTE: PPG has officially gone mad. The new scene 12 does not appear in the scene list, so we can't really use the scene list anymore. - Span sceneSpan = stackalloc int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }; + // NOTE: PPG has officially gone mad. The new scene 12 does not appear in the scene list, so we can't really use the scene list anymore. + Span sceneSpan = stackalloc int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }; - foreach (var config in NoteDataMananger.instance.noteDatas) + foreach (var config in NoteDataMananger.instance.noteDatas) + { + // Ignore april fools variants (these are handled elsewhere) + if (config.IsAprilFools()) continue; + // Ignore phase 2 boss gears + if (config.IsPhase2BossGear()) continue; + + // Scene setting of "0" is a wildcard + var anyScene = config.IsAnyScene(); + // Notes with these values are extremely likely to be events, and get registered to all pathways + var anyPathway = config.IsAnyPathway(); + // Boss type, None type, boss mashers, and events get registered to all speeds + var anySpeed = config.IsAnySpeed(); + + var speeds = new List { config.speed }; + var scenes = new List { config.scene }; + var pathways = new List { config.pathway }; + + // Use all speeds if any speed + if (anySpeed) speeds = new List { 1, 2, 3 }; + // Use all scenes if any scene + if (anyScene) { - // Ignore april fools variants (these are handled elsewhere) - if (config.IsAprilFools()) continue; - // Ignore phase 2 boss gears - if (config.IsPhase2BossGear()) continue; - - // Scene setting of "0" is a wildcard - var anyScene = config.IsAnyScene(); - // Notes with these values are extremely likely to be events, and get registered to all pathways - var anyPathway = config.IsAnyPathway(); - // Boss type, None type, boss mashers, and events get registered to all speeds - var anySpeed = config.IsAnySpeed(); - - var speeds = new List { config.speed }; - var scenes = new List { config.scene }; - var pathways = new List { config.pathway }; - - // Use all speeds if any speed - if (anySpeed) speeds = new List { 1, 2, 3 }; - // Use all scenes if any scene - if (anyScene) - { - scenes = new List(); + scenes = new List(); - foreach (var scene in sceneSpan) - { - // Special handling for collectibles in touhou scene - if (config.GetNoteType() is NoteType.Hp or NoteType.Music && scene == 8) - continue; + foreach (var scene in sceneSpan) + { + // Special handling for collectibles in touhou scene + if (config.GetNoteType() is NoteType.Hp or NoteType.Music && scene == 8) + continue; - var sceneSuffix = scene.ToString().PadLeft(2, '0'); - scenes.Add($"scene_{sceneSuffix}"); - } + var sceneSuffix = scene.ToString().PadLeft(2, '0'); + scenes.Add($"scene_{sceneSuffix}"); } + } - // Use all pathways if any pathway - if (anyPathway) pathways = new List { 0, 1 }; + // Use all pathways if any pathway + if (anyPathway) pathways = new List { 0, 1 }; - foreach (var key in pathways.SelectMany(pathway => speeds.SelectMany(speed => - scenes.Select(scene => GetNoteDataKey(config.ibms_id, pathway, speed, scene))))) - NoteData.TryAdd(key, config); - } + foreach (var key in pathways.SelectMany(pathway => speeds.SelectMany(speed => + scenes.Select(scene => GetNoteDataKey(config.ibms_id, pathway, speed, scene))))) + NoteData.TryAdd(key, config); } + } + + public Il2CppSystem.Collections.Generic.List GetSceneEvents() + { + var sceneEvents = new Il2CppSystem.Collections.Generic.List(); - public Il2CppSystem.Collections.Generic.List GetSceneEvents() + foreach (var note in Notes) { - var sceneEvents = new Il2CppSystem.Collections.Generic.List(); + if (string.IsNullOrEmpty(note.Value)) continue; - foreach (var note in Notes) - { - if (string.IsNullOrEmpty(note.Value)) continue; + if (!Channels.TryGetValue(note.Tone, out var channel)) continue; - if (!Channels.TryGetValue(note.Tone, out var channel)) continue; + if (channel.HasFlag(ChannelType.Scene)) + sceneEvents.Add(new SceneEvent + { + time = (Decimal)(float)(decimal)note.Time, + uid = $"SceneEvent/{note.Value}" + }); + else if (channel.HasFlag(ChannelType.SpBpmDirect) || channel.HasFlag(ChannelType.SpBpmLookup)) + sceneEvents.Add(new SceneEvent + { + time = (Decimal)(float)(decimal)note.Time, + uid = "SceneEvent/OnBPMChanged", + value = note.Value + }); + } - if (channel.HasFlag(ChannelType.Scene)) - sceneEvents.Add(new SceneEvent - { - time = (Decimal)(float)(decimal)note.Time, - uid = $"SceneEvent/{note.Value}" - }); - else if (channel.HasFlag(ChannelType.SpBpmDirect) || channel.HasFlag(ChannelType.SpBpmLookup)) - sceneEvents.Add(new SceneEvent - { - time = (Decimal)(float)(decimal)note.Time, - uid = "SceneEvent/OnBPMChanged", - value = note.Value - }); - } + return sceneEvents; + } - return sceneEvents; - } + public List GetNoteData() + { + if (NoteData is null || NoteData.Count == 0) InitNoteData(); + var processed = new List(); - public List GetNoteData() - { - if (NoteData is null || NoteData.Count == 0) InitNoteData(); - var processed = new List(); + var speedAir = Info.Player; + var speedGround = speedAir; - var speedAir = Info.Player; - var speedGround = speedAir; + var objectId = 1; - var objectId = 1; + // Track which note indices have been consumed as hold-note endpoints + var consumedIndices = new HashSet(); - // Track which note indices have been consumed as hold-note endpoints - var consumedIndices = new HashSet(); + for (var i = 0; i < Notes.Count; i++) + { + if (consumedIndices.Contains(i)) continue; - for (var i = 0; i < Notes.Count; i++) - { - if (consumedIndices.Contains(i)) continue; + var note = Notes[i]; - var note = Notes[i]; + var bmsKey = note.Value; + if (string.IsNullOrEmpty(bmsKey)) continue; - var bmsKey = note.Value; - if (string.IsNullOrEmpty(bmsKey)) continue; + var bmsId = BmsIds.GetValueOrDefault(bmsKey, BmsId.None); + var channel = note.Tone; + var channelType = Channels.GetValueOrDefault(channel, ChannelType.None); - var bmsId = BmsIds.GetValueOrDefault(bmsKey, BmsId.None); - var channel = note.Tone; - var channelType = Channels.GetValueOrDefault(channel, ChannelType.None); + // Handle lane type + var pathway = -1; + if (channelType.HasFlag(ChannelType.Air)) + pathway = 1; + else if (channelType.HasFlag(ChannelType.Ground) || channelType.HasFlag(ChannelType.Event)) + pathway = 0; - // Handle lane type - var pathway = -1; - if (channelType.HasFlag(ChannelType.Air)) - pathway = 1; - else if (channelType.HasFlag(ChannelType.Ground) || channelType.HasFlag(ChannelType.Event)) - pathway = 0; + if (pathway == -1) continue; - if (pathway == -1) continue; + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault + switch (bmsId) + { + // Handle speed changes + case BmsId.Speed1Both: + speedGround = 1; + speedAir = 1; + break; + case BmsId.Speed2Both: + speedGround = 2; + speedAir = 2; + break; + case BmsId.Speed3Both: + speedGround = 3; + speedAir = 3; + break; + case BmsId.Speed1Low: + speedGround = 1; + break; + case BmsId.Speed1High: + speedAir = 1; + break; + case BmsId.Speed2Low: + speedGround = 2; + break; + case BmsId.Speed2High: + speedAir = 2; + break; + case BmsId.Speed3Low: + speedGround = 3; + break; + case BmsId.Speed3High: + speedAir = 3; + break; + } - // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault - // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault - switch (bmsId) - { - // Handle speed changes - case BmsId.Speed1Both: - speedGround = 1; - speedAir = 1; - break; - case BmsId.Speed2Both: - speedGround = 2; - speedAir = 2; - break; - case BmsId.Speed3Both: - speedGround = 3; - speedAir = 3; - break; - case BmsId.Speed1Low: - speedGround = 1; - break; - case BmsId.Speed1High: - speedAir = 1; - break; - case BmsId.Speed2Low: - speedGround = 2; - break; - case BmsId.Speed2High: - speedAir = 2; - break; - case BmsId.Speed3Low: - speedGround = 3; - break; - case BmsId.Speed3High: - speedAir = 3; - break; - } + var speed = pathway == 1 ? speedAir : speedGround; + var scene = Info.Genre; - var speed = pathway == 1 ? speedAir : speedGround; - var scene = Info.Genre; + if (!NoteData!.TryGetValue(GetNoteDataKey(bmsKey, pathway, speed, scene), out var configData)) + continue; - if (!NoteData!.TryGetValue(GetNoteDataKey(bmsKey, pathway, speed, scene), out var configData)) - continue; + var time = (decimal)note.Time; - var time = (decimal)note.Time; + // Hold note & masher + var holdLength = 0M; + var isHold = configData.GetNoteType() is NoteType.Press or NoteType.Mul; + if (isHold) + { + if (channelType.HasFlag(ChannelType.SpTapHolds)) + holdLength = 0.001M; + else + for (var j = i + 1; j < Notes.Count; j++) + { + if (consumedIndices.Contains(j)) continue; - // Hold note & masher - var holdLength = 0M; - var isHold = configData.GetNoteType() is NoteType.Press or NoteType.Mul; - if (isHold) - { - if (channelType.HasFlag(ChannelType.SpTapHolds)) - holdLength = 0.001M; - else - for (var j = i + 1; j < Notes.Count; j++) - { - if (consumedIndices.Contains(j)) continue; - - var holdEndNote = Notes[j]; - var holdEndTime = (decimal)holdEndNote.Time; - var holdEndBmsKey = holdEndNote.Value; - var holdEndChannel = holdEndNote.Tone; - - if (holdEndBmsKey != bmsKey || holdEndChannel != channel) continue; - holdLength = holdEndTime - time; - consumedIndices.Add(j); - break; - } - } + var holdEndNote = Notes[j]; + var holdEndTime = (decimal)holdEndNote.Time; + var holdEndBmsKey = holdEndNote.Value; + var holdEndChannel = holdEndNote.Tone; - processed.Add(new ProcessedNote( - Id: objectId++, - Time: time, - NoteUid: configData.uid, - Length: holdLength, - Pathway: pathway, - Blood: !isHold && channelType.HasFlag(ChannelType.SpBlood) - )); + if (holdEndBmsKey != bmsKey || holdEndChannel != channel) continue; + holdLength = holdEndTime - time; + consumedIndices.Add(j); + break; + } } - return processed; + processed.Add(new ProcessedNote( + objectId++, + time, + configData.uid, + holdLength, + pathway, + !isHold && channelType.HasFlag(ChannelType.SpBlood) + )); } + + return processed; } } \ No newline at end of file diff --git a/Data/BmsInfo.cs b/Data/BmsInfo.cs index 6bd54c0..9e5770e 100644 --- a/Data/BmsInfo.cs +++ b/Data/BmsInfo.cs @@ -1,44 +1,43 @@ -namespace CustomAlbums.Data +namespace CustomAlbums.Data; + +public class BmsInfo { - public class BmsInfo - { - /// BMS chart title (e.g. song name). Parsed from #TITLE header. - public string Title { get; set; } = string.Empty; + /// BMS chart title (e.g. song name). Parsed from #TITLE header. + public string Title { get; set; } = string.Empty; - /// Artist name. Parsed from #ARTIST header. - public string Artist { get; set; } = string.Empty; + /// Artist name. Parsed from #ARTIST header. + public string Artist { get; set; } = string.Empty; - /// Scene identifier (e.g. "scene_08"). Parsed from #GENRE header. - public string Genre { get; set; } = string.Empty; + /// Scene identifier (e.g. "scene_08"). Parsed from #GENRE header. + public string Genre { get; set; } = string.Empty; - /// Banner image path. Parsed from #BANNER header, prefixed with "cover/". - public string Banner { get; set; } = "cover/none_cover.png"; + /// Banner image path. Parsed from #BANNER header, prefixed with "cover/". + public string Banner { get; set; } = "cover/none_cover.png"; - /// Chart name. Set by the loader, not from a BMS header. - public string Name { get; set; } = string.Empty; + /// Chart name. Set by the loader, not from a BMS header. + public string Name { get; set; } = string.Empty; - /// Player/speed lane mode. Parsed from #PLAYER header. Defaults to 1. - public int Player { get; set; } = 1; + /// Player/speed lane mode. Parsed from #PLAYER header. Defaults to 1. + public int Player { get; set; } = 1; - /// Base BPM of the chart. Parsed from #BPM header. - public float Bpm { get; set; } + /// Base BPM of the chart. Parsed from #BPM header. + public float Bpm { get; set; } - /// Difficulty rank (1-4). Parsed from #RANK header. - public int Rank { get; set; } + /// Difficulty rank (1-4). Parsed from #RANK header. + public int Rank { get; set; } - /// Long note type. Parsed from #LNTYPE header. - public int LnType { get; set; } + /// Long note type. Parsed from #LNTYPE header. + public int LnType { get; set; } - /// Level designer name. Parsed from #LEVELDESIGN header. - public string LevelDesign { get; set; } = string.Empty; + /// Level designer name. Parsed from #LEVELDESIGN header. + public string LevelDesign { get; set; } = string.Empty; - /// Play level. Parsed from #PLAYLEVEL header. - public string PlayLevel { get; set; } = string.Empty; + /// Play level. Parsed from #PLAYLEVEL header. + public string PlayLevel { get; set; } = string.Empty; - /// - /// Fallback storage for any unrecognized BMS headers. - /// Key is the header name (without #), value is the full string value. - /// - public Dictionary Extra { get; } = new(); - } -} + /// + /// Fallback storage for any unrecognized BMS headers. + /// Key is the header name (without #), value is the full string value. + /// + public Dictionary Extra { get; } = new(); +} \ No newline at end of file diff --git a/Data/BmsStates.cs b/Data/BmsStates.cs index a079e80..7126c89 100644 --- a/Data/BmsStates.cs +++ b/Data/BmsStates.cs @@ -1,109 +1,108 @@ -namespace CustomAlbums.Data +namespace CustomAlbums.Data; + +internal class BmsStates { - internal class BmsStates + public enum AnimAlignment { - public enum AnimAlignment - { - Left = -1, - Right = 1 - } + Left = -1, + Right = 1 + } - public enum BossState - { - OffScreen, - Idle, - Phase1, - Phase2 - } + public enum BossState + { + OffScreen, + Idle, + Phase1, + Phase2 + } - public static readonly Dictionary AnimStatesLeft = new() - { - { "in", BossState.OffScreen }, - { "out", BossState.Idle }, - { "boss_close_atk_1", BossState.Idle }, - { "boss_close_atk_2", BossState.Idle }, - { "multi_atk_48", BossState.Idle }, - { "multi_atk_48_end", BossState.Idle }, - { "boss_far_atk_1_L", BossState.Phase1 }, - { "boss_far_atk_1_R", BossState.Phase1 }, - { "boss_far_atk_2", BossState.Phase2 }, - { "boss_far_atk_1_start", BossState.Idle }, - { "boss_far_atk_2_start", BossState.Idle }, - { "boss_far_atk_1_end", BossState.Phase1 }, - { "boss_far_atk_2_end", BossState.Phase2 }, - { "atk_1_to_2", BossState.Phase1 }, - { "atk_2_to_1", BossState.Phase2 } - }; + public static readonly Dictionary AnimStatesLeft = new() + { + { "in", BossState.OffScreen }, + { "out", BossState.Idle }, + { "boss_close_atk_1", BossState.Idle }, + { "boss_close_atk_2", BossState.Idle }, + { "multi_atk_48", BossState.Idle }, + { "multi_atk_48_end", BossState.Idle }, + { "boss_far_atk_1_L", BossState.Phase1 }, + { "boss_far_atk_1_R", BossState.Phase1 }, + { "boss_far_atk_2", BossState.Phase2 }, + { "boss_far_atk_1_start", BossState.Idle }, + { "boss_far_atk_2_start", BossState.Idle }, + { "boss_far_atk_1_end", BossState.Phase1 }, + { "boss_far_atk_2_end", BossState.Phase2 }, + { "atk_1_to_2", BossState.Phase1 }, + { "atk_2_to_1", BossState.Phase2 } + }; - public static readonly Dictionary AnimStatesRight = new() - { - { "in", BossState.Idle }, - { "out", BossState.OffScreen }, - { "boss_close_atk_1", BossState.Idle }, - { "boss_close_atk_2", BossState.Idle }, - { "multi_atk_48", BossState.Idle }, - { "multi_atk_48_end", BossState.OffScreen }, - { "boss_far_atk_1_L", BossState.Phase1 }, - { "boss_far_atk_1_R", BossState.Phase1 }, - { "boss_far_atk_2", BossState.Phase2 }, - { "boss_far_atk_1_start", BossState.Phase1 }, - { "boss_far_atk_2_start", BossState.Phase2 }, - { "boss_far_atk_1_end", BossState.Idle }, - { "boss_far_atk_2_end", BossState.Idle }, - { "atk_1_to_2", BossState.Phase2 }, - { "atk_2_to_1", BossState.Phase1 } - }; + public static readonly Dictionary AnimStatesRight = new() + { + { "in", BossState.Idle }, + { "out", BossState.OffScreen }, + { "boss_close_atk_1", BossState.Idle }, + { "boss_close_atk_2", BossState.Idle }, + { "multi_atk_48", BossState.Idle }, + { "multi_atk_48_end", BossState.OffScreen }, + { "boss_far_atk_1_L", BossState.Phase1 }, + { "boss_far_atk_1_R", BossState.Phase1 }, + { "boss_far_atk_2", BossState.Phase2 }, + { "boss_far_atk_1_start", BossState.Phase1 }, + { "boss_far_atk_2_start", BossState.Phase2 }, + { "boss_far_atk_1_end", BossState.Idle }, + { "boss_far_atk_2_end", BossState.Idle }, + { "atk_1_to_2", BossState.Phase2 }, + { "atk_2_to_1", BossState.Phase1 } + }; - public static readonly Dictionary> StateTransferAnims = new() + public static readonly Dictionary> StateTransferAnims = new() + { { + BossState.OffScreen, new Dictionary { - BossState.OffScreen, new Dictionary - { - { BossState.OffScreen, "0" }, - { BossState.Idle, "in" }, - { BossState.Phase1, "boss_far_atk_1_start" }, - { BossState.Phase2, "boss_far_atk_2_start" } - } - }, + { BossState.OffScreen, "0" }, + { BossState.Idle, "in" }, + { BossState.Phase1, "boss_far_atk_1_start" }, + { BossState.Phase2, "boss_far_atk_2_start" } + } + }, + { + BossState.Idle, new Dictionary { - BossState.Idle, new Dictionary - { - { BossState.OffScreen, "out" }, - { BossState.Idle, "0" }, - { BossState.Phase1, "boss_far_atk_1_start" }, - { BossState.Phase2, "boss_far_atk_2_start" } - } - }, + { BossState.OffScreen, "out" }, + { BossState.Idle, "0" }, + { BossState.Phase1, "boss_far_atk_1_start" }, + { BossState.Phase2, "boss_far_atk_2_start" } + } + }, + { + BossState.Phase1, new Dictionary { - BossState.Phase1, new Dictionary - { - { BossState.OffScreen, "out" }, - { BossState.Idle, "boss_far_atk_1_end" }, - { BossState.Phase1, "0" }, - { BossState.Phase2, "atk_1_to_2" } - } - }, + { BossState.OffScreen, "out" }, + { BossState.Idle, "boss_far_atk_1_end" }, + { BossState.Phase1, "0" }, + { BossState.Phase2, "atk_1_to_2" } + } + }, + { + BossState.Phase2, new Dictionary { - BossState.Phase2, new Dictionary - { - { BossState.OffScreen, "out" }, - { BossState.Idle, "boss_far_atk_2_end" }, - { BossState.Phase1, "atk_2_to_1" }, - { BossState.Phase2, "0" } - } + { BossState.OffScreen, "out" }, + { BossState.Idle, "boss_far_atk_2_end" }, + { BossState.Phase1, "atk_2_to_1" }, + { BossState.Phase2, "0" } } - }; + } + }; - public static readonly Dictionary TransferAlignment = new() - { - { "in", AnimAlignment.Right }, - { "out", AnimAlignment.Left }, - { "boss_far_atk_1_start", AnimAlignment.Right }, - { "boss_far_atk_2_start", AnimAlignment.Right }, - { "boss_far_atk_1_end", AnimAlignment.Left }, - { "boss_far_atk_2_end", AnimAlignment.Left }, - { "atk_1_to_2", AnimAlignment.Right }, - { "atk_2_to_1", AnimAlignment.Right } - }; - } + public static readonly Dictionary TransferAlignment = new() + { + { "in", AnimAlignment.Right }, + { "out", AnimAlignment.Left }, + { "boss_far_atk_1_start", AnimAlignment.Right }, + { "boss_far_atk_2_start", AnimAlignment.Right }, + { "boss_far_atk_1_end", AnimAlignment.Left }, + { "boss_far_atk_2_end", AnimAlignment.Left }, + { "atk_1_to_2", AnimAlignment.Right }, + { "atk_2_to_1", AnimAlignment.Right } + }; } \ No newline at end of file diff --git a/Data/BmsTypes.cs b/Data/BmsTypes.cs index 37c25d1..4816159 100644 --- a/Data/BmsTypes.cs +++ b/Data/BmsTypes.cs @@ -1,19 +1,21 @@ -namespace CustomAlbums.Data +namespace CustomAlbums.Data; + +public readonly record struct BpmEntry(float Tick, float Freq) : IComparable { - public readonly record struct BpmEntry(float Tick, float Freq) : IComparable + public int CompareTo(BpmEntry other) { - public int CompareTo(BpmEntry other) => Tick.CompareTo(other.Tick); + return Tick.CompareTo(other.Tick); } +} - public readonly record struct TimeSigEntry(int Beat, float Percent); +public readonly record struct TimeSigEntry(int Beat, float Percent); - public readonly record struct RawNote(float Time, string Value, string Tone); +public readonly record struct RawNote(float Time, string Value, string Tone); - public readonly record struct ProcessedNote( - int Id, - decimal Time, - string NoteUid, - decimal Length, - int Pathway, - bool Blood); -} +public readonly record struct ProcessedNote( + int Id, + decimal Time, + string NoteUid, + decimal Length, + int Pathway, + bool Blood); \ No newline at end of file diff --git a/Data/ChartSave.cs b/Data/ChartSave.cs index 324dc58..e95f210 100644 --- a/Data/ChartSave.cs +++ b/Data/ChartSave.cs @@ -1,14 +1,13 @@ -namespace CustomAlbums.Data +namespace CustomAlbums.Data; + +public class ChartSave { - public class ChartSave - { - public int Evaluate { get; set; } = 0; - public int Score { get; set; } = 0; - public int Combo { get; set; } = 0; - public float Accuracy { get; set; } = 0f; - public string AccuracyStr { get; set; } = "0"; - public float Clear { get; set; } = 0f; - public int FailCount { get; set; } = 0; - public bool Passed { get; set; } = false; - } + public int Evaluate { get; set; } = 0; + public int Score { get; set; } = 0; + public int Combo { get; set; } = 0; + public float Accuracy { get; set; } = 0f; + public string AccuracyStr { get; set; } = "0"; + public float Clear { get; set; } = 0f; + public int FailCount { get; set; } = 0; + public bool Passed { get; set; } = false; } \ No newline at end of file diff --git a/Data/CustomAlbumsSave.cs b/Data/CustomAlbumsSave.cs index aa408b3..8ac14ff 100644 --- a/Data/CustomAlbumsSave.cs +++ b/Data/CustomAlbumsSave.cs @@ -1,26 +1,25 @@ -namespace CustomAlbums.Data +namespace CustomAlbums.Data; + +public class CustomAlbumsSave { - public class CustomAlbumsSave - { - public string SelectedAlbum { get; set; } = string.Empty; - public float Ability { get; set; } = 0; - public HashSet UnlockedMasters { get; set; } = new(); - public HashSet Collections { get; set; } = new(); - public HashSet Hides { get; set; } = new(); - public List History { get; set; } = new(); - public Dictionary> Highest { get; set; } = new(); - public Dictionary> FullCombo { get; set; } = new(); + public string SelectedAlbum { get; set; } = string.Empty; + public float Ability { get; set; } = 0; + public HashSet UnlockedMasters { get; set; } = new(); + public HashSet Collections { get; set; } = new(); + public HashSet Hides { get; set; } = new(); + public List History { get; set; } = new(); + public Dictionary> Highest { get; set; } = new(); + public Dictionary> FullCombo { get; set; } = new(); - internal bool IsEmpty() - { - return SelectedAlbum == string.Empty - && Ability == 0 - && UnlockedMasters.Count == 0 - && Collections.Count == 0 - && Hides.Count == 0 - && History.Count == 0 - && Highest.Count == 0 - && FullCombo.Count == 0; - } + internal bool IsEmpty() + { + return SelectedAlbum == string.Empty + && Ability == 0 + && UnlockedMasters.Count == 0 + && Collections.Count == 0 + && Hides.Count == 0 + && History.Count == 0 + && Highest.Count == 0 + && FullCombo.Count == 0; } } \ No newline at end of file diff --git a/Data/Pack.cs b/Data/Pack.cs index 9507ca3..40a5511 100644 --- a/Data/Pack.cs +++ b/Data/Pack.cs @@ -1,56 +1,52 @@ -using CustomAlbums.Managers; +using System.IO.Compression; +using CustomAlbums.Managers; using CustomAlbums.Utilities; -using System.IO.Compression; -namespace CustomAlbums.Data +namespace CustomAlbums.Data; + +public class Pack { - public class Pack - { - public string Title { get; set; } = AlbumManager.GetCustomAlbumsTitle(); - public string TitleColorHex { get; set; } = "#ffffff"; - public bool LongTextScroll { get; set; } = false; + internal int Length; - public string Path { get; set; } = string.Empty; - public List Albums { get; set; } = new(); + internal int StartIndex; + public string Title { get; set; } = AlbumManager.GetCustomAlbumsTitle(); + public string TitleColorHex { get; set; } = "#ffffff"; + public bool LongTextScroll { get; set; } = false; - internal int StartIndex; - internal int Length; + public string Path { get; set; } = string.Empty; + public List Albums { get; set; } = new(); + + public bool HasFile(string name) + { + if (string.IsNullOrEmpty(Path)) return false; - public bool HasFile(string name) + try { - if (string.IsNullOrEmpty(Path)) return false; - - try - { - using var zip = ZipFile.OpenRead(Path); - return zip.GetEntry(name) != null; - } - catch - { - return false; - } + using var zip = ZipFile.OpenRead(Path); + return zip.GetEntry(name) != null; } + catch + { + return false; + } + } - public Stream OpenNullableStream(string file) + public Stream OpenNullableStream(string file) + { + if (string.IsNullOrEmpty(Path)) return null; + + try + { + using var zip = ZipFile.OpenRead(Path); + var entry = zip.GetEntry(file); + + if (entry != null) return entry.Open().ToMemoryStream(); + + return null; + } + catch { - if (string.IsNullOrEmpty(Path)) return null; - - try - { - using var zip = ZipFile.OpenRead(Path); - var entry = zip.GetEntry(file); - - if (entry != null) - { - return entry.Open().ToMemoryStream(); - } - - return null; - } - catch - { - return null; - } + return null; } } -} +} \ No newline at end of file diff --git a/Data/SceneEggs.cs b/Data/SceneEggs.cs index e29a451..e7fb969 100644 --- a/Data/SceneEggs.cs +++ b/Data/SceneEggs.cs @@ -1,20 +1,19 @@ -namespace CustomAlbums.Data +namespace CustomAlbums.Data; + +public class SceneEgg { - public class SceneEgg + public enum SceneEggs { - public enum SceneEggs - { - None = -1, - Cytus = 0, - Wacca = 1, - Queen = 2, - Touhou = 3, - Arknights = 4, - Miku = 5, - RinLen = 6, - BlueArchive = 7, - BadApple = 129, - Christmas = 999 - } + None = -1, + Cytus = 0, + Wacca = 1, + Queen = 2, + Touhou = 3, + Arknights = 4, + Miku = 5, + RinLen = 6, + BlueArchive = 7, + BadApple = 129, + Christmas = 999 } } \ No newline at end of file diff --git a/Data/Sheet.cs b/Data/Sheet.cs index 658c141..72db3ad 100644 --- a/Data/Sheet.cs +++ b/Data/Sheet.cs @@ -4,91 +4,89 @@ using Il2CppAssets.Scripts.GameCore; using Il2CppAssets.Scripts.Structs; -namespace CustomAlbums.Data +namespace CustomAlbums.Data; + +public class Sheet { - public class Sheet + private static readonly Logger Logger = new(nameof(Sheet)); + private string _md5; + + public Sheet(Album parentAlbum, int difficulty) { - private static readonly Logger Logger = new(nameof(Sheet)); + ParentAlbum = parentAlbum; + Difficulty = difficulty; + MapName = $"{parentAlbum.AlbumName}_map{difficulty}"; + } - public Sheet(Album parentAlbum, int difficulty) - { - ParentAlbum = parentAlbum; - Difficulty = difficulty; - MapName = $"{parentAlbum.AlbumName}_map{difficulty}"; - } + public Album ParentAlbum { get; } + public string MapName { get; } + public int Difficulty { get; } + public bool TalkFileVersion2 { get; set; } - public Album ParentAlbum { get; } - public string MapName { get; } - public int Difficulty { get; } - public bool TalkFileVersion2 { get; set; } - private string _md5; - public string Md5 + public string Md5 + { + get { - get + if (!string.IsNullOrEmpty(_md5)) return _md5; + try { - if (!string.IsNullOrEmpty(_md5)) return _md5; - try - { - using var stream = ParentAlbum.OpenMemoryStream($"map{Difficulty}.bms"); - if (stream != null) - { - _md5 = stream.GetHash(); - } - } - catch (Exception e) - { - Logger.Warning($"Could not compute MD5 for {MapName}: {e.Message}"); - } - return _md5 ?? string.Empty; + using var stream = ParentAlbum.OpenMemoryStream($"map{Difficulty}.bms"); + if (stream != null) _md5 = stream.GetHash(); } + catch (Exception e) + { + Logger.Warning($"Could not compute MD5 for {MapName}: {e.Message}"); + } + + return _md5 ?? string.Empty; } + } - public StageInfo GetStage() - { - // If opening a FileStream is possible (i.e. reading from a folder) then open it as FileStream - // Otherwise open it as a MemoryStream - // This allows writing to the map BMS file while it is being read - using var stream = ParentAlbum.OpenFileStreamIfPossible($"map{Difficulty}.bms"); + public StageInfo GetStage() + { + // If opening a FileStream is possible (i.e. reading from a folder) then open it as FileStream + // Otherwise open it as a MemoryStream + // This allows writing to the map BMS file while it is being read + using var stream = ParentAlbum.OpenFileStreamIfPossible($"map{Difficulty}.bms"); - var bms = BmsLoader.Load(stream, MapName); - if (bms is null) return null; + var bms = BmsLoader.Load(stream, MapName); + if (bms is null) return null; - var stageInfo = BmsLoader.TransmuteData(bms); - stageInfo.mapName = MapName; - stageInfo.scene = bms.Info.Genre; - stageInfo.music = ParentAlbum.Uid; - stageInfo.difficulty = Difficulty; - stageInfo.bpm = bms.Bpm; - stageInfo.md5 = Md5; - stageInfo.sceneEvents = bms.GetSceneEvents(); - stageInfo.name = ParentAlbum.Info.Name; + var stageInfo = BmsLoader.TransmuteData(bms); + stageInfo.mapName = MapName; + stageInfo.scene = bms.Info.Genre; + stageInfo.music = ParentAlbum.Uid; + stageInfo.difficulty = Difficulty; + stageInfo.bpm = bms.Bpm; + stageInfo.md5 = Md5; + stageInfo.sceneEvents = bms.GetSceneEvents(); + stageInfo.name = ParentAlbum.Info.Name; - if (ParentAlbum.HasFile($"map{Difficulty}.talk")) + if (ParentAlbum.HasFile($"map{Difficulty}.talk")) + { + using var talkStream = ParentAlbum.OpenFileStreamIfPossible($"map{Difficulty}.talk"); + if (talkStream.Length > 0) { - using var talkStream = ParentAlbum.OpenFileStreamIfPossible($"map{Difficulty}.talk"); - if (talkStream.Length > 0) + var talkFile = Json.Deserialize(talkStream); + if (talkFile.TryGetPropertyValue("version", out var node) && node?.GetValue() == 2) { - var talkFile = Json.Deserialize(talkStream); - if (talkFile.TryGetPropertyValue("version", out var node) && node?.GetValue() == 2) - { - Logger.Msg("Version 2 talk file!"); - TalkFileVersion2 = true; - } - - talkFile.Remove("version"); - var dict = Json - .Il2CppJsonDeserialize< - Il2CppSystem.Collections.Generic.Dictionary>>(talkFile.ToJsonString()); - stageInfo.dialogEvents = - new Il2CppSystem.Collections.Generic.Dictionary>(); - foreach (var dialogEvent in dict) stageInfo.dialogEvents.Add(dialogEvent.Key, dialogEvent.Value); + Logger.Msg("Version 2 talk file!"); + TalkFileVersion2 = true; } - } - GlobalDataBase.dbStageInfo.SetStageInfo(stageInfo); - return stageInfo; + talkFile.Remove("version"); + var dict = Json + .Il2CppJsonDeserialize< + Il2CppSystem.Collections.Generic.Dictionary>>(talkFile.ToJsonString()); + stageInfo.dialogEvents = + new Il2CppSystem.Collections.Generic.Dictionary>(); + foreach (var dialogEvent in dict) stageInfo.dialogEvents.Add(dialogEvent.Key, dialogEvent.Value); + } } + + GlobalDataBase.dbStageInfo.SetStageInfo(stageInfo); + return stageInfo; } } \ No newline at end of file diff --git a/Data/Talk.cs b/Data/Talk.cs index 949f268..12a049f 100644 --- a/Data/Talk.cs +++ b/Data/Talk.cs @@ -1,28 +1,27 @@ using Il2CppAssets.Scripts.Structs; -namespace CustomAlbums.Data +namespace CustomAlbums.Data; + +public class Talk { - public class Talk - { - public int Version { get; set; } = 1; - public Il2CppSystem.Collections.Generic.List English { get; set; } - public Il2CppSystem.Collections.Generic.List ChineseS { get; set; } - public Il2CppSystem.Collections.Generic.List ChineseT { get; set; } - public Il2CppSystem.Collections.Generic.List Japanese { get; set; } - public Il2CppSystem.Collections.Generic.List Korean { get; set; } + public int Version { get; set; } = 1; + public Il2CppSystem.Collections.Generic.List English { get; set; } + public Il2CppSystem.Collections.Generic.List ChineseS { get; set; } + public Il2CppSystem.Collections.Generic.List ChineseT { get; set; } + public Il2CppSystem.Collections.Generic.List Japanese { get; set; } + public Il2CppSystem.Collections.Generic.List Korean { get; set; } - public Il2CppSystem.Collections.Generic.Dictionary>.Enumerator GetEnumerator() - { - var dict = - new Il2CppSystem.Collections.Generic.Dictionary>(5); - dict["English"] = English; - dict["ChineseS"] = ChineseS; - dict["ChineseT"] = ChineseT; - dict["Japanese"] = Japanese; - dict["Korean"] = Korean; - return dict.GetEnumerator(); - } + public Il2CppSystem.Collections.Generic.Dictionary>. + Enumerator GetEnumerator() + { + var dict = + new Il2CppSystem.Collections.Generic.Dictionary>(5); + dict["English"] = English; + dict["ChineseS"] = ChineseS; + dict["ChineseT"] = ChineseT; + dict["Japanese"] = Japanese; + dict["Korean"] = Korean; + return dict.GetEnumerator(); } } \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 3428610..eb640a3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -20,13 +20,13 @@ $(MelonAssemblies)\Assembly-CSharp.dll + Include="Assembly-CSharp-firstpass, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\Assembly-CSharp-firstpass.dll + Include="Il2Cppcom.prpr.log, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\Il2Cppcom.prpr.log.dll @@ -37,25 +37,25 @@ $(MelonAssemblies)\Il2CppDOTween.dll + Include="Il2CppGoogle.Protobuf, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\Il2CppGoogle.Protobuf.dll + Include="Il2CppInterop.Common, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonNET6)\Il2CppInterop.Common.dll + Include="Il2CppInterop.Generator, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonNET6)\Il2CppInterop.Generator.dll + Include="Il2CppInterop.Runtime, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonNET6)\Il2CppInterop.Runtime.dll @@ -71,7 +71,7 @@ $(MelonAssemblies)\Il2CppNAudio.dll + Include="Il2CppNewtonsoft.Json, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\Il2CppNewtonsoft.Json.dll @@ -87,25 +87,25 @@ $(MelonAssemblies)\Il2CppPeroTools2.dll + Include="Il2CppPeroTools2.Account, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\Il2CppPeroTools2.Account.dll + Include="Il2CppPeroTools2.Resources, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\Il2CppPeroTools2.Resources.dll + Include="Il2CppSirenix.Serialization, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\Il2CppSirenix.Serialization.dll + Include="Il2Cppspine-unity, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\Il2Cppspine-unity.dll @@ -116,13 +116,13 @@ $(MelonAssemblies)\Il2CppSystem.dll + Include="Il2CppSystem.Core, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\Il2CppSystem.Core.dll + Include="Il2CppSystem.Runtime.InteropServices, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\Il2CppSystem.Runtime.InteropServices.dll @@ -133,37 +133,37 @@ $(MelonNET6)\MelonLoader.dll + Include="Unity.Addressables, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\Unity.Addressables.dll + Include="Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\Unity.ResourceManager.dll + Include="UnityEngine.AnimationModule, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\UnityEngine.AnimationModule.dll + Include="UnityEngine.AudioModule, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\UnityEngine.AudioModule.dll + Include="UnityEngine.CoreModule, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\UnityEngine.CoreModule.dll + Include="UnityEngine.ImageConversionModule, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\UnityEngine.ImageConversionModule.dll @@ -174,13 +174,13 @@ $(MelonAssemblies)\UnityEngine.UI.dll + Include="UnityEngine.UnityWebRequestModule, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\UnityEngine.UnityWebRequestModule.dll + Include="UnityEngine.UnityWebRequestTextureModule, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"> false false $(MelonAssemblies)\UnityEngine.UnityWebRequestTextureModule.dll diff --git a/Main.cs b/Main.cs index 36e43ff..e932cc3 100644 --- a/Main.cs +++ b/Main.cs @@ -3,52 +3,51 @@ using CustomAlbums.Utilities; using MelonLoader; using MelonLoader.Utils; -namespace CustomAlbums + +namespace CustomAlbums; + +public class Main : MelonMod { - public class Main : MelonMod + public const string MelonName = "CustomAlbums"; + public const string MelonAuthor = "Two Fellas"; + public const string MelonVersion = "4.1.9"; + private static readonly Logger Logger = new("CustomAlbums"); + + public override void OnInitializeMelon() + { + base.OnInitializeMelon(); + + if (!Directory.Exists(AlbumManager.SearchPath)) Directory.CreateDirectory(AlbumManager.SearchPath); + + var cacheDir = Path.Combine(MelonEnvironment.UserDataDirectory, "CustomAlbums", "Cache"); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, true); + + ModSettings.Register(); + AssetPatch.AttachHook(); + SavePatch.AttachHook(); + AlbumManager.LoadAlbums(); + SaveManager.LoadSaveFile(); + Logger.Msg("Initialized CustomAlbums!", false); + } + + public override void OnLateInitializeMelon() + { + base.OnLateInitializeMelon(); + // TODO: Actually write HotReload + HotReloadManager.OnLateInitializeMelon(); + } + + public override void OnFixedUpdate() + { + base.OnFixedUpdate(); + // TODO: Actually write HotReload + HotReloadManager.FixedUpdate(); + } + + public override void OnDeinitializeMelon() { - private static readonly Logger Logger = new("CustomAlbums"); - - public const string MelonName = "CustomAlbums"; - public const string MelonAuthor = "Two Fellas"; - public const string MelonVersion = "4.1.9"; - - public override void OnInitializeMelon() - { - base.OnInitializeMelon(); - - if (!Directory.Exists(AlbumManager.SearchPath)) Directory.CreateDirectory(AlbumManager.SearchPath); - - var cacheDir = Path.Combine(MelonEnvironment.UserDataDirectory, "CustomAlbums", "Cache"); - if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, true); - - ModSettings.Register(); - AssetPatch.AttachHook(); - SavePatch.AttachHook(); - AlbumManager.LoadAlbums(); - SaveManager.LoadSaveFile(); - Logger.Msg("Initialized CustomAlbums!", false); - } - - public override void OnLateInitializeMelon() - { - base.OnLateInitializeMelon(); - // TODO: Actually write HotReload - HotReloadManager.OnLateInitializeMelon(); - } - - public override void OnFixedUpdate() - { - base.OnFixedUpdate(); - // TODO: Actually write HotReload - HotReloadManager.FixedUpdate(); - } - - public override void OnDeinitializeMelon() - { - base.OnDeinitializeMelon(); - var cacheDir = Path.Combine(MelonEnvironment.UserDataDirectory, "CustomAlbums", "Cache"); - if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, true); - } + base.OnDeinitializeMelon(); + var cacheDir = Path.Combine(MelonEnvironment.UserDataDirectory, "CustomAlbums", "Cache"); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, true); } } \ No newline at end of file diff --git a/Managers/AlbumManager.cs b/Managers/AlbumManager.cs index 3120297..03988f4 100644 --- a/Managers/AlbumManager.cs +++ b/Managers/AlbumManager.cs @@ -1,222 +1,228 @@ +using System.Diagnostics; +using System.IO.Compression; using CustomAlbums.Data; using CustomAlbums.ModExtensions; using CustomAlbums.Utilities; using Il2CppAssets.Scripts.PeroTools.Commons; using Il2CppAssets.Scripts.PeroTools.GeneralLocalization; using Il2CppPeroTools2.Resources; -using System.Diagnostics; -using System.IO.Compression; using UnityEngine; using Logger = CustomAlbums.Utilities.Logger; -namespace CustomAlbums.Managers +namespace CustomAlbums.Managers; + +public static class AlbumManager { - public static class AlbumManager + public const int Uid = 999; + public const string SearchPath = "Custom_Albums"; + public const string SearchPattern = "*.mdm"; + public const string PackSearchPattern = "*.mdp"; + public static readonly string JsonName = $"ALBUM{Uid + 1}"; + public static readonly string MusicPackage = $"music_package_{Uid}"; + + public static readonly Dictionary Languages = new() { - public const int Uid = 999; - public const string SearchPath = "Custom_Albums"; - public const string SearchPattern = "*.mdm"; - public const string PackSearchPattern = "*.mdp"; - public static readonly string JsonName = $"ALBUM{Uid + 1}"; - public static readonly string MusicPackage = $"music_package_{Uid}"; - - public static readonly Dictionary Languages = new() - { - { "English", "Custom Albums" }, - { "ChineseS", "自定义" }, - { "ChineseT", "自定義" }, - { "Japanese", "カスタムアルバム" }, - { "Korean", "커스텀앨범" } - }; + { "English", "Custom Albums" }, + { "ChineseS", "自定义" }, + { "ChineseT", "自定義" }, + { "Japanese", "カスタムアルバム" }, + { "Korean", "커스텀앨범" } + }; - private static readonly Logger Logger = new(nameof(AlbumManager)); - internal static readonly FileSystemWatcher AlbumWatcher = new(); - internal static Events.LoadAlbumEvent OnAlbumLoaded; + private static readonly Logger Logger = new(nameof(AlbumManager)); + internal static readonly FileSystemWatcher AlbumWatcher = new(); + internal static Events.LoadAlbumEvent OnAlbumLoaded; + private static readonly Dictionary UidHistory = new(); - private static int MaxCount { get; set; } - private static readonly Dictionary UidHistory = new(); - internal static string CurrentPack { get; set; } = null; - public static Dictionary LoadedAlbums { get; } = new(); + private static int MaxCount { get; set; } + internal static string CurrentPack { get; set; } + public static Dictionary LoadedAlbums { get; } = new(); - public static Pack LoadPack(string directory) + public static Pack LoadPack(string directory) + { + // Get the files from the directory + try { - // Get the files from the directory - try - { - var zipFiles = ZipFile.OpenRead(directory); + var zipFiles = ZipFile.OpenRead(directory); - // Filter for .mdm files and find the pack.json file - var mdms = zipFiles.Entries.Where(file => file.Name.EndsWith(".mdm")); - var json = zipFiles.Entries.FirstOrDefault(file => file.Name.EndsWith(".json")); + // Filter for .mdm files and find the pack.json file + var mdms = zipFiles.Entries.Where(file => file.Name.EndsWith(".mdm")); + var json = zipFiles.Entries.FirstOrDefault(file => file.Name.EndsWith(".json")); - // Initialize pack and variables - var pack = PackManager.CreatePack(json, directory); - CurrentPack = pack.Title; + // Initialize pack and variables + var pack = PackManager.CreatePack(json, directory); + CurrentPack = pack.Title; - pack.StartIndex = MaxCount; + pack.StartIndex = MaxCount; - // Count successfully loaded .mdm files - pack.Length = mdms.Count(mdm => - { - var album = LoadOne(directory, mdm, mdm.FullName); - if (album is not null) - { - pack.Albums.Add(album); - return true; - } - return false; - }); - - // Set the current pack to null and add the pack to the pack list - CurrentPack = null; - PackManager.AddPack(pack); - - return pack; - } - catch (Exception ex) + // Count successfully loaded .mdm files + pack.Length = mdms.Count(mdm => { - Logger.Warning($"Failed to load album at {directory}. Reason: {ex.Message}"); - Logger.Warning(ex.StackTrace); + var album = LoadOne(directory, mdm, mdm.FullName); + if (album is not null) + { + pack.Albums.Add(album); + return true; + } - return null; - } - } + return false; + }); - public static Album LoadOne(string directory, ZipArchiveEntry mdm, string fullFileName) - { - var fileName = Path.GetFileNameWithoutExtension(fullFileName); - try - { - var album = new Album(directory, mdm, 0, CurrentPack); - if (album.Info is null) return null; - - var albumName = album.AlbumName; - if (LoadedAlbums.ContainsKey(albumName)) return null; + // Set the current pack to null and add the pack to the pack list + CurrentPack = null; + PackManager.AddPack(pack); - if (UidHistory.TryGetValue(albumName, out var oldIndex)) - { - album.Index = oldIndex; - } - else - { - album.Index = MaxCount; - MaxCount++; - UidHistory[albumName] = album.Index; - } - - Logger.Msg("Adding " + albumName + " as a pack!"); + return pack; + } + catch (Exception ex) + { + Logger.Warning($"Failed to load album at {directory}. Reason: {ex.Message}"); + Logger.Warning(ex.StackTrace); - LoadedAlbums.Add(albumName, album); + return null; + } + } - if (album.HasPng) - ResourcesManager.instance.LoadFromName($"{albumName}_cover").hideFlags |= - HideFlags.DontUnloadUnusedAsset; + public static Album LoadOne(string directory, ZipArchiveEntry mdm, string fullFileName) + { + var fileName = Path.GetFileNameWithoutExtension(fullFileName); + try + { + var album = new Album(directory, mdm, 0, CurrentPack); + if (album.Info is null) return null; + var albumName = album.AlbumName; + if (LoadedAlbums.ContainsKey(albumName)) return null; - Logger.Msg($"Loaded {albumName}: {album.Info.Name}"); - OnAlbumLoaded?.Invoke(typeof(AlbumManager), new AlbumEventArgs(album)); - return album; + if (UidHistory.TryGetValue(albumName, out var oldIndex)) + { + album.Index = oldIndex; } - catch (Exception ex) + else { - Logger.Warning($"Failed to load album at {fileName}. Reason: {ex.Message}"); - Logger.Warning(ex.StackTrace); + album.Index = MaxCount; + MaxCount++; + UidHistory[albumName] = album.Index; } - return null; - } + Logger.Msg("Adding " + albumName + " as a pack!"); + + LoadedAlbums.Add(albumName, album); + + if (album.HasPng) + ResourcesManager.instance.LoadFromName($"{albumName}_cover").hideFlags |= + HideFlags.DontUnloadUnusedAsset; - public static Album LoadOne(string path) + + Logger.Msg($"Loaded {albumName}: {album.Info.Name}"); + OnAlbumLoaded?.Invoke(typeof(AlbumManager), new AlbumEventArgs(album)); + return album; + } + catch (Exception ex) { - var isDirectory = File.GetAttributes(path).HasFlag(FileAttributes.Directory); - var fileName = isDirectory ? Path.GetFileName(path) : Path.GetFileNameWithoutExtension(path); - try - { - var album = new Album(path, 0, CurrentPack); - if (album.Info is null) return null; + Logger.Warning($"Failed to load album at {fileName}. Reason: {ex.Message}"); + Logger.Warning(ex.StackTrace); + } - var albumName = album.AlbumName; - if (LoadedAlbums.ContainsKey(albumName)) return null; + return null; + } - if (UidHistory.TryGetValue(albumName, out var oldIndex)) - { - album.Index = oldIndex; - } - else - { - album.Index = MaxCount; - MaxCount++; - UidHistory[albumName] = album.Index; - } - - LoadedAlbums.Add(albumName, album); + public static Album LoadOne(string path) + { + var isDirectory = File.GetAttributes(path).HasFlag(FileAttributes.Directory); + var fileName = isDirectory ? Path.GetFileName(path) : Path.GetFileNameWithoutExtension(path); + try + { + var album = new Album(path, 0, CurrentPack); + if (album.Info is null) return null; - if (album.HasPng) - ResourcesManager.instance.LoadFromName($"{albumName}_cover").hideFlags |= - HideFlags.DontUnloadUnusedAsset; + var albumName = album.AlbumName; + if (LoadedAlbums.ContainsKey(albumName)) return null; - Logger.Msg($"Loaded {albumName}: {album.Info.Name}"); - OnAlbumLoaded?.Invoke(typeof(AlbumManager), new AlbumEventArgs(album)); - return album; + if (UidHistory.TryGetValue(albumName, out var oldIndex)) + { + album.Index = oldIndex; } - catch (Exception ex) + else { - Logger.Warning($"Failed to load album at {fileName}. Reason: {ex.Message}"); - Logger.Warning(ex.StackTrace); + album.Index = MaxCount; + MaxCount++; + UidHistory[albumName] = album.Index; } - return null; - } + LoadedAlbums.Add(albumName, album); - public static void LoadAlbums() + if (album.HasPng) + ResourcesManager.instance.LoadFromName($"{albumName}_cover").hideFlags |= + HideFlags.DontUnloadUnusedAsset; + + Logger.Msg($"Loaded {albumName}: {album.Info.Name}"); + OnAlbumLoaded?.Invoke(typeof(AlbumManager), new AlbumEventArgs(album)); + return album; + } + catch (Exception ex) { - LoadedAlbums.Clear(); - UidHistory.Clear(); - MaxCount = 0; + Logger.Warning($"Failed to load album at {fileName}. Reason: {ex.Message}"); + Logger.Warning(ex.StackTrace); + } - var stopwatch = Stopwatch.StartNew(); - - var packs = new List(); - var files = new List(); - files.AddRange(Directory.GetFiles(SearchPath, SearchPattern)); - files.AddRange(Directory.GetDirectories(SearchPath)); - packs.AddRange(Directory.GetFiles(SearchPath, PackSearchPattern)); + return null; + } - foreach (var pack in packs) LoadPack(pack); - foreach (var file in files) LoadOne(file); + public static void LoadAlbums() + { + LoadedAlbums.Clear(); + UidHistory.Clear(); + MaxCount = 0; - stopwatch.Stop(); + var stopwatch = Stopwatch.StartNew(); - Logger.Msg($"Finished loading {LoadedAlbums.Count} albums in {stopwatch.Elapsed}", false); - } + var packs = new List(); + var files = new List(); + files.AddRange(Directory.GetFiles(SearchPath, SearchPattern)); + files.AddRange(Directory.GetDirectories(SearchPath)); + packs.AddRange(Directory.GetFiles(SearchPath, PackSearchPattern)); - public static IEnumerable GetAllUid() - => LoadedAlbums.Select(album => $"{Uid}-{album.Value.Index}"); + foreach (var pack in packs) LoadPack(pack); + foreach (var file in files) LoadOne(file); - public static Album GetByUid(string uid) - => LoadedAlbums.FirstOrDefault(album => album.Value.Index == uid[4..].ParseAsInt()).Value; - public static string GetAlbumNameFromUid(string uid) - { - var album = GetByUid(uid); - return album is null ? string.Empty : album.AlbumName; - } - public static IEnumerable GetAlbumUidsFromNames(this IEnumerable albumNames) - { - return albumNames.Where(name => LoadedAlbums.ContainsKey(name)) - .Select(name => $"{Uid}-{LoadedAlbums[name].Index}"); - } + stopwatch.Stop(); - /// - /// Gets the current "Custom Albums" title based on language. - /// - /// The current "Custom Albums" title based on language. - public static string GetCustomAlbumsTitle() - { - return Languages.GetValueOrDefault( - SingletonScriptableObject + Logger.Msg($"Finished loading {LoadedAlbums.Count} albums in {stopwatch.Elapsed}", false); + } + + public static IEnumerable GetAllUid() + { + return LoadedAlbums.Select(album => $"{Uid}-{album.Value.Index}"); + } + + public static Album GetByUid(string uid) + { + return LoadedAlbums.FirstOrDefault(album => album.Value.Index == uid[4..].ParseAsInt()).Value; + } + + public static string GetAlbumNameFromUid(string uid) + { + var album = GetByUid(uid); + return album is null ? string.Empty : album.AlbumName; + } + + public static IEnumerable GetAlbumUidsFromNames(this IEnumerable albumNames) + { + return albumNames.Where(name => LoadedAlbums.ContainsKey(name)) + .Select(name => $"{Uid}-{LoadedAlbums[name].Index}"); + } + + /// + /// Gets the current "Custom Albums" title based on language. + /// + /// The current "Custom Albums" title based on language. + public static string GetCustomAlbumsTitle() + { + return Languages.GetValueOrDefault( + SingletonScriptableObject .instance? .GetActiveOption("Language") ?? "English"); - } } } \ No newline at end of file diff --git a/Managers/AudioManager.cs b/Managers/AudioManager.cs index 7c351a8..35da6e2 100644 --- a/Managers/AudioManager.cs +++ b/Managers/AudioManager.cs @@ -5,181 +5,179 @@ using NAudio.Vorbis; using NLayer; using UnityEngine; -using Action = Il2CppSystem.Action; using Logger = CustomAlbums.Utilities.Logger; // ReSharper disable AccessToModifiedClosure -namespace CustomAlbums.Managers +namespace CustomAlbums.Managers; + +public static class AudioManager { - public static class AudioManager - { - public const int AsyncReadSpeed = 4096; + public const int AsyncReadSpeed = 4096; - private static Coroutine _currentCoroutine; - private static readonly Dictionary Coroutines = new(); - private static readonly Logger Logger = new(nameof(AudioManager)); + private static Coroutine _currentCoroutine; + private static readonly Dictionary Coroutines = new(); + private static readonly Logger Logger = new(nameof(AudioManager)); - public static bool SwitchLoad(string name) - { - if (!Coroutines.TryGetValue(name, out var routine)) return false; - _currentCoroutine = routine; + public static bool SwitchLoad(string name) + { + if (!Coroutines.TryGetValue(name, out var routine)) return false; + _currentCoroutine = routine; - Logger.Msg($"Switching to async load of {name}"); - return true; - } + Logger.Msg($"Switching to async load of {name}"); + return true; + } - public static AudioClip LoadClipFromMp3(MemoryStream stream, string name) - { - var mp3 = new MpegFile(stream); - var sampleCount = mp3.Length / sizeof(float); - var audioClip = - AudioClip.Create(name, (int)sampleCount / mp3.Channels, mp3.Channels, mp3.SampleRate, false); + public static AudioClip LoadClipFromMp3(MemoryStream stream, string name) + { + var mp3 = new MpegFile(stream); + var sampleCount = mp3.Length / sizeof(float); + var audioClip = + AudioClip.Create(name, (int)sampleCount / mp3.Channels, mp3.Channels, mp3.SampleRate, false); - var remainingSamples = sampleCount; - var index = 0; + var remainingSamples = sampleCount; + var index = 0; - if (name.EndsWith("_music") && mp3.SampleRate != 44100) - Logger.Warning( - $"{name}.mp3 is not 44.1khz, desyncs may occur! Consider switching to .ogg format or using 44.1khz"); + if (name.EndsWith("_music") && mp3.SampleRate != 44100) + Logger.Warning( + $"{name}.mp3 is not 44.1khz, desyncs may occur! Consider switching to .ogg format or using 44.1khz"); - Coroutine coroutine = null; - coroutine = CreateCoroutine((Il2CppSystem.Func)delegate + Coroutine coroutine = null; + coroutine = CreateCoroutine(new Func(delegate + { + // Stop coroutine if the asset is unloaded + if (audioClip == null) { - // Stop coroutine if the asset is unloaded - if (audioClip == null) - { - Coroutines.Remove(name); - if (_currentCoroutine == coroutine) _currentCoroutine = null; + Coroutines.Remove(name); + if (_currentCoroutine == coroutine) _currentCoroutine = null; - Logger.Msg($"Aborting async load of {name}.mp3"); - return true; - } + Logger.Msg($"Aborting async load of {name}.mp3"); + return true; + } - // Pause coroutine if it is not active - if (coroutine != _currentCoroutine) return false; + // Pause coroutine if it is not active + if (coroutine != _currentCoroutine) return false; - var sampleArray = new float[Math.Min(AsyncReadSpeed, remainingSamples)]; - var readCount = mp3.ReadSamples(sampleArray, 0, sampleArray.Length); + var sampleArray = new float[Math.Min(AsyncReadSpeed, remainingSamples)]; + var readCount = mp3.ReadSamples(sampleArray, 0, sampleArray.Length); - audioClip.SetData(sampleArray, index / mp3.Channels); + audioClip.SetData(sampleArray, index / mp3.Channels); - index += readCount; - remainingSamples -= readCount; + index += readCount; + remainingSamples -= readCount; - if (remainingSamples > 0 && readCount != 0) return false; + if (remainingSamples > 0 && readCount != 0) return false; - mp3.Dispose(); - stream.Dispose(); + mp3.Dispose(); + stream.Dispose(); - Coroutines.Remove(name); - _currentCoroutine = null; + Coroutines.Remove(name); + _currentCoroutine = null; - Logger.Msg($"Finished async load of {name}.mp3"); - return true; - }); + Logger.Msg($"Finished async load of {name}.mp3"); + return true; + })); + + Coroutines[name] = coroutine; + _currentCoroutine = coroutine; - Coroutines[name] = coroutine; - _currentCoroutine = coroutine; + return audioClip; + } + + public static AudioClip LoadClipFromOgg(MemoryStream stream, string name) + { + var ogg = new VorbisWaveReader(stream); + var sampleCount = (int)(ogg.Length / (ogg.WaveFormat.BitsPerSample / 8)); + var audioClip = AudioClip.Create(name, sampleCount / ogg.WaveFormat.Channels, ogg.WaveFormat.Channels, + ogg.WaveFormat.SampleRate, false); - return audioClip; - } + var remainingSamples = sampleCount; + var index = 0; - public static AudioClip LoadClipFromOgg(MemoryStream stream, string name) + Coroutine coroutine = null; + coroutine = CreateCoroutine(new Func(delegate { - var ogg = new VorbisWaveReader(stream); - var sampleCount = (int)(ogg.Length / (ogg.WaveFormat.BitsPerSample / 8)); - var audioClip = AudioClip.Create(name, sampleCount / ogg.WaveFormat.Channels, ogg.WaveFormat.Channels, - ogg.WaveFormat.SampleRate, false); + // Stop coroutine if the asset is unloaded + if (audioClip == null) + { + Coroutines.Remove(name); + if (_currentCoroutine == coroutine) _currentCoroutine = null; + + Logger.Msg($"Aborting async load of {name}.ogg"); + return true; + } + + // Pause coroutine if it is not active + if (coroutine != _currentCoroutine) return false; - var remainingSamples = sampleCount; - var index = 0; + var sampleArray = new float[Math.Min(AsyncReadSpeed, remainingSamples)]; + var readCount = ogg.Read(sampleArray, 0, sampleArray.Length); - Coroutine coroutine = null; - coroutine = CreateCoroutine((Il2CppSystem.Func)delegate + try { - // Stop coroutine if the asset is unloaded - if (audioClip == null) - { - Coroutines.Remove(name); - if (_currentCoroutine == coroutine) _currentCoroutine = null; - - Logger.Msg($"Aborting async load of {name}.ogg"); - return true; - } - - // Pause coroutine if it is not active - if (coroutine != _currentCoroutine) return false; - - var sampleArray = new float[Math.Min(AsyncReadSpeed, remainingSamples)]; - var readCount = ogg.Read(sampleArray, 0, sampleArray.Length); - - try - { - audioClip.SetData(sampleArray, index / ogg.WaveFormat.Channels); - } - catch (Exception e) - { - if ((double)ogg.Position / 1000 > ogg.Length) - Logger.Warning( - "Possible over-read of audio file. This is a file-dependent anomaly. Consider making a minimal edit and re-saving."); - Logger.Error( - $"Exception while reading at offset {index} of {(double)ogg.Position / 1000 / ogg.Length} in {name}, aborting: {e.Message}"); - - ogg.Dispose(); - stream.Dispose(); - - Coroutines.Remove(name); - _currentCoroutine = null; - return true; - } - - index += readCount; - remainingSamples -= readCount; - - if (remainingSamples > 0 && readCount != 0) return false; + audioClip.SetData(sampleArray, index / ogg.WaveFormat.Channels); + } + catch (Exception e) + { + if ((double)ogg.Position / 1000 > ogg.Length) + Logger.Warning( + "Possible over-read of audio file. This is a file-dependent anomaly. Consider making a minimal edit and re-saving."); + Logger.Error( + $"Exception while reading at offset {index} of {(double)ogg.Position / 1000 / ogg.Length} in {name}, aborting: {e.Message}"); ogg.Dispose(); stream.Dispose(); Coroutines.Remove(name); _currentCoroutine = null; - - Logger.Msg($"Finished async load of {name}.ogg"); return true; - }); + } - Coroutines[name] = coroutine; - _currentCoroutine = coroutine; + index += readCount; + remainingSamples -= readCount; - return audioClip; - } + if (remainingSamples > 0 && readCount != 0) return false; - private static Coroutine CreateCoroutine(Il2CppSystem.Func update) - { - return SingletonMonoBehaviour.instance.StartCoroutine( - (Action)delegate { }, update); - } - - /// - /// Gets the audio clip for the specified album. - /// - /// The specified album. - /// This is "music" or "demo". Defaults to "music". - /// - public static AudioClip GetAudio(this Album album, string name = "music") - { - var key = $"{album.AlbumName}_{name}"; + ogg.Dispose(); + stream.Dispose(); + + Coroutines.Remove(name); + _currentCoroutine = null; + + Logger.Msg($"Finished async load of {name}.ogg"); + return true; + })); + + Coroutines[name] = coroutine; + _currentCoroutine = coroutine; + + return audioClip; + } + + private static Coroutine CreateCoroutine(Il2CppSystem.Func update) + { + return SingletonMonoBehaviour.instance.StartCoroutine( + new Action(delegate { }), update); + } + + /// + /// Gets the audio clip for the specified album. + /// + /// The specified album. + /// This is "music" or "demo". Defaults to "music". + /// + public static AudioClip GetAudio(this Album album, string name = "music") + { + var key = $"{album.AlbumName}_{name}"; - if (album.OpenNullableStream($"{name}.ogg") is { } oggStream) - return LoadClipFromOgg(oggStream.ToMemoryStream(), key); + if (album.OpenNullableStream($"{name}.ogg") is { } oggStream) + return LoadClipFromOgg(oggStream.ToMemoryStream(), key); - if (album.OpenNullableStream($"{name}.mp3") is { } mp3Stream) - return LoadClipFromMp3(mp3Stream.ToMemoryStream(), key); + if (album.OpenNullableStream($"{name}.mp3") is { } mp3Stream) + return LoadClipFromMp3(mp3Stream.ToMemoryStream(), key); - // No music file found - Logger.Error($"Could not find audio file for {name} in {album.Info.Name}"); - return null; - } + // No music file found + Logger.Error($"Could not find audio file for {name} in {album.Info.Name}"); + return null; } } \ No newline at end of file diff --git a/Managers/HotReloadManager.cs b/Managers/HotReloadManager.cs index 542d686..15d404d 100644 --- a/Managers/HotReloadManager.cs +++ b/Managers/HotReloadManager.cs @@ -1,613 +1,619 @@ using System.Collections.Concurrent; +using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; using CustomAlbums.Data; using CustomAlbums.Patches; using CustomAlbums.Utilities; using HarmonyLib; -using Il2Cpp; using Il2CppAssets.Scripts.Database; -using Il2CppAssets.Scripts.Database.DataClass; using Il2CppAssets.Scripts.PeroTools.Commons; -using Il2CppAssets.Scripts.PeroTools.GeneralLocalization; using Il2CppAssets.Scripts.PeroTools.Managers; using Il2CppAssets.Scripts.UI.Panels; using Il2CppInterop.Runtime.InteropTypes.Arrays; using Il2CppPeroTools2.Resources; -using static Il2CppAssets.Scripts.Database.DBConfigCustomTags; +using UnityEngine; +using Logger = CustomAlbums.Utilities.Logger; +using Object = UnityEngine.Object; -namespace CustomAlbums.Managers +namespace CustomAlbums.Managers; + +internal static class HotReloadManager { - internal static class HotReloadManager - { - private static readonly Logger Logger = new(nameof(HotReloadManager)); + private static readonly Logger Logger = new(nameof(HotReloadManager)); - // Thread-safe queues for FileSystemWatcher background events - private static ConcurrentQueue AlbumsToAdd { get; } = new(); + // Hot-loaded MusicInfo cache: uid -> MusicInfo + // Used by GetMusicInfoFromAll Harmony Postfix + private static readonly Dictionary HotLoadedMusicInfos = new(); + private static readonly Dictionary HotLoadedMusicNames = new(); + private static readonly Dictionary HotLoadedMusicAuthors = new(); - private static ConcurrentQueue AlbumsToDelete { get; } = new(); - private static ConcurrentDictionary LastFileEvent { get; } = new(); - internal static PnlStage PnlStageInstance { get; set; } + private static float _lastProcessTime; - // Hot-loaded MusicInfo cache: uid -> MusicInfo - // Used by GetMusicInfoFromAll Harmony Postfix - private static readonly Dictionary HotLoadedMusicInfos = new(); - private static readonly Dictionary HotLoadedMusicNames = new(); - private static readonly Dictionary HotLoadedMusicAuthors = new(); + // Thread-safe queues for FileSystemWatcher background events + private static ConcurrentQueue AlbumsToAdd { get; } = new(); - /// - /// Checks if a file is fully written and no longer locked by other processes. - /// - private static bool IsFileUnlocked(string path) - { - try - { - using var fileStream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.None); - return fileStream.Length > 0; - } - catch (Exception ex) when (ex is FileNotFoundException or IOException) - { - return false; - } - } + private static ConcurrentQueue AlbumsToDelete { get; } = new(); + private static ConcurrentDictionary LastFileEvent { get; } = new(); + internal static PnlStage PnlStageInstance { get; set; } - - private static int ParseDifficulty(string difficulty) + /// + /// Checks if a file is fully written and no longer locked by other processes. + /// + private static bool IsFileUnlocked(string path) + { + try { - return int.TryParse(difficulty, out var value) ? value : 0; + using var fileStream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.None); + return fileStream.Length > 0; } - - private static MusicExInfo CreateMusicExInfo(Album album) + catch (Exception ex) when (ex is FileNotFoundException or IOException) { - var albumInfo = album.Info; - var changedDiff = new Il2CppStructArray(5); - changedDiff[0] = ParseDifficulty(albumInfo.Difficulty1); - changedDiff[1] = ParseDifficulty(albumInfo.Difficulty2); - changedDiff[2] = ParseDifficulty(albumInfo.Difficulty3); - changedDiff[3] = ParseDifficulty(albumInfo.Difficulty4); - changedDiff[4] = ParseDifficulty(albumInfo.Difficulty5); - - var musicExInfo = new MusicExInfo(); - musicExInfo.m_AlbumIndex = AlbumManager.Uid + 1; - musicExInfo.m_AlbumUidIndex = AlbumManager.Uid; - musicExInfo.m_MusicIndex = album.Index; - musicExInfo.m_AlbumUidName = $"music_package_{AlbumManager.Uid}"; - musicExInfo.m_AlbumJsonName = AlbumManager.JsonName; - musicExInfo.m_ChangedDiff = changedDiff; - return musicExInfo; + return false; } + } - /// - /// Hot-add: Injects a new .mdm file into the game's runtime database. - /// - private static int ProcessAdditions() - { - var addedCount = 0; - if (AlbumsToAdd.TryDequeue(out var path)) + private static int ParseDifficulty(string difficulty) + { + return int.TryParse(difficulty, out var value) ? value : 0; + } + + private static MusicExInfo CreateMusicExInfo(Album album) + { + var albumInfo = album.Info; + var changedDiff = new Il2CppStructArray(5); + changedDiff[0] = ParseDifficulty(albumInfo.Difficulty1); + changedDiff[1] = ParseDifficulty(albumInfo.Difficulty2); + changedDiff[2] = ParseDifficulty(albumInfo.Difficulty3); + changedDiff[3] = ParseDifficulty(albumInfo.Difficulty4); + changedDiff[4] = ParseDifficulty(albumInfo.Difficulty5); + + var musicExInfo = new MusicExInfo(); + musicExInfo.m_AlbumIndex = AlbumManager.Uid + 1; + musicExInfo.m_AlbumUidIndex = AlbumManager.Uid; + musicExInfo.m_MusicIndex = album.Index; + musicExInfo.m_AlbumUidName = $"music_package_{AlbumManager.Uid}"; + musicExInfo.m_AlbumJsonName = AlbumManager.JsonName; + musicExInfo.m_ChangedDiff = changedDiff; + return musicExInfo; + } + + /// + /// Hot-add: Injects a new .mdm file into the game's runtime database. + /// + private static int ProcessAdditions() + { + var addedCount = 0; + + if (AlbumsToAdd.TryDequeue(out var path)) + try { - try + // 1. Load album into AlbumManager + var album = AlbumManager.LoadOne(path); + if (album == null) Logger.Warning($"Failed to load album from {path}"); + + var albumName = album.AlbumName; + var uid = $"{AlbumManager.Uid}-{album.Index}"; + var albumInfo = album.Info; + Logger.Msg($"Adding {albumName} (UID: {uid})"); + + // 2. Transmute: Native DBObject generation via JSON + // Instead of reflection, re-serialize ALL custom albums and let the game natively deserialize them! + var masterAlbums = Singleton.instance.GetConfigObject(); + var albumsInfo = masterAlbums?.GetAlbumsInfoByUid(AlbumManager.MusicPackage); + var globalAlbumConfig = albumsInfo != null + ? Singleton.instance.GetConfigObject(albumsInfo.albumJsonIndex) + : null; + + if (globalAlbumConfig != null) { - // 1. Load album into AlbumManager - var album = AlbumManager.LoadOne(path); - if (album == null) - { - Logger.Warning($"Failed to load album from {path}"); - } + var jsonArray = new JsonArray(); + var localJsonArray = new JsonArray(); - var albumName = album.AlbumName; - var uid = $"{AlbumManager.Uid}-{album.Index}"; - var albumInfo = album.Info; - Logger.Msg($"Adding {albumName} (UID: {uid})"); + var firstSongs = new HashSet(AlbumManager.LoadedAlbums.Values + .GroupBy(a => PackManager.GetPackFromUid(a.Uid)) + .Select(g => g.OrderBy(a => a.Index).First().Uid)); - // 2. Transmute: Native DBObject generation via JSON - // Instead of reflection, re-serialize ALL custom albums and let the game natively deserialize them! - var masterAlbums = Singleton.instance.GetConfigObject(-1); - var albumsInfo = masterAlbums?.GetAlbumsInfoByUid(AlbumManager.MusicPackage); - var globalAlbumConfig = albumsInfo != null - ? Singleton.instance.GetConfigObject(albumsInfo.albumJsonIndex) - : null; - - if (globalAlbumConfig != null) + foreach (var (albumStr, albumObj) in AlbumManager.LoadedAlbums) { - var jsonArray = new JsonArray(); - var localJsonArray = new JsonArray(); + var aInfo = albumObj.Info; + var pack = PackManager.GetPackFromUid(albumObj.Uid); + var isFirstSong = firstSongs.Contains(albumObj.Uid); + var titleString = pack?.Title ?? "Unclassified"; - var firstSongs = new HashSet(AlbumManager.LoadedAlbums.Values - .GroupBy(a => PackManager.GetPackFromUid(a.Uid)) - .Select(g => g.OrderBy(a => a.Index).First().Uid)); + var displayName = aInfo.Name ?? ""; + if (isFirstSong) displayName = $"【{titleString}】 {aInfo.Name}"; - foreach (var (albumStr, albumObj) in AlbumManager.LoadedAlbums) + var customChartJson = new { - var aInfo = albumObj.Info; - var pack = PackManager.GetPackFromUid(albumObj.Uid); - var isFirstSong = firstSongs.Contains(albumObj.Uid); - var titleString = pack?.Title ?? "Unclassified"; + uid = albumObj.Uid, + name = displayName, + author = aInfo.Author ?? "", + bpm = aInfo.Bpm ?? "0", + music = $"{albumStr}_music", + demo = $"{albumStr}_demo", + cover = $"{albumStr}_cover", + noteJson = $"{albumStr}_map", + scene = aInfo.Scene ?? "scene_01", + unlockLevel = "0", + levelDesigner = aInfo.LevelDesigner ?? "", + levelDesigner1 = aInfo.LevelDesigner1 ?? aInfo.LevelDesigner ?? "", + levelDesigner2 = aInfo.LevelDesigner2 ?? aInfo.LevelDesigner ?? "", + levelDesigner3 = aInfo.LevelDesigner3 ?? aInfo.LevelDesigner ?? "", + levelDesigner4 = aInfo.LevelDesigner4 ?? aInfo.LevelDesigner ?? "", + levelDesigner5 = aInfo.LevelDesigner5 ?? aInfo.LevelDesigner ?? "", + difficulty1 = aInfo.Difficulty1 ?? "0", + difficulty2 = aInfo.Difficulty2 ?? "0", + difficulty3 = aInfo.Difficulty3 ?? "0", + difficulty4 = aInfo.Difficulty4 ?? "0", + difficulty5 = aInfo.Difficulty5 ?? "0" + }; + jsonArray.Add(JsonSerializer.SerializeToNode(customChartJson)); - var displayName = aInfo.Name ?? ""; - if (isFirstSong) - { - displayName = $"【{titleString}】 {aInfo.Name}"; - } + localJsonArray.Add(JsonSerializer.SerializeToNode(new + { + name = displayName, + author = aInfo.Author ?? "" + })); + } - var customChartJson = new - { - uid = albumObj.Uid, - name = displayName, - author = aInfo.Author ?? "", - bpm = aInfo.Bpm ?? "0", - music = $"{albumStr}_music", - demo = $"{albumStr}_demo", - cover = $"{albumStr}_cover", - noteJson = $"{albumStr}_map", - scene = aInfo.Scene ?? "scene_01", - unlockLevel = "0", - levelDesigner = aInfo.LevelDesigner ?? "", - levelDesigner1 = aInfo.LevelDesigner1 ?? aInfo.LevelDesigner ?? "", - levelDesigner2 = aInfo.LevelDesigner2 ?? aInfo.LevelDesigner ?? "", - levelDesigner3 = aInfo.LevelDesigner3 ?? aInfo.LevelDesigner ?? "", - levelDesigner4 = aInfo.LevelDesigner4 ?? aInfo.LevelDesigner ?? "", - levelDesigner5 = aInfo.LevelDesigner5 ?? aInfo.LevelDesigner ?? "", - difficulty1 = aInfo.Difficulty1 ?? "0", - difficulty2 = aInfo.Difficulty2 ?? "0", - difficulty3 = aInfo.Difficulty3 ?? "0", - difficulty4 = aInfo.Difficulty4 ?? "0", - difficulty5 = aInfo.Difficulty5 ?? "0" - }; - jsonArray.Add(JsonSerializer.SerializeToNode(customChartJson)); - - localJsonArray.Add(JsonSerializer.SerializeToNode(new - { - name = displayName, - author = aInfo.Author ?? "" - })); - } + var fullJsonStr = JsonSerializer.Serialize(jsonArray); + var fullLocalJsonStr = JsonSerializer.Serialize(localJsonArray); - var fullJsonStr = JsonSerializer.Serialize(jsonArray); - var fullLocalJsonStr = JsonSerializer.Serialize(localJsonArray); + // 1. Re-deserialize the entire list of custom albums into the global config + globalAlbumConfig.Deserialize(fullJsonStr); - // 1. Re-deserialize the entire list of custom albums into the global config - globalAlbumConfig.Deserialize(fullJsonStr); - - // 2. Bypass engine cache completely by manually instantiating and injecting the localized databases - var localDicProp = typeof(BaseDBConfigLocalObject).GetProperty("m_LocalDic", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (localDicProp != null) + // 2. Bypass engine cache completely by manually instantiating and injecting the localized databases + var localDicProp = + typeof(BaseDBConfigLocalObject).GetProperty("m_LocalDic", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (localDicProp != null) + { + var globalLocalDic = + localDicProp.GetValue(globalAlbumConfig) as + Il2CppSystem.Collections.Generic.Dictionary; + if (globalLocalDic != null) { - Il2CppSystem.Collections.Generic.Dictionary globalLocalDic = localDicProp.GetValue(globalAlbumConfig) as Il2CppSystem.Collections.Generic.Dictionary; - if (globalLocalDic != null) + globalLocalDic.Clear(); + for (var i = 0; i <= 15; i++) { - globalLocalDic.Clear(); - for (int i = 0; i <= 15; i++) - { - var newLocalAlbum = new DBConfigLocalALBUM(); - newLocalAlbum.Deserialize(fullLocalJsonStr); - globalLocalDic.Add(i, newLocalAlbum); - } + var newLocalAlbum = new DBConfigLocalALBUM(); + newLocalAlbum.Deserialize(fullLocalJsonStr); + globalLocalDic.Add(i, newLocalAlbum); } } + } - // 4. Update the global dictionaries - GlobalDataBase.s_DbMusicTag.AddAllMusicInfo(globalAlbumConfig); + // 4. Update the global dictionaries + GlobalDataBase.s_DbMusicTag.AddAllMusicInfo(globalAlbumConfig); - // 4. Re-initialize ExInfo for all the newly created MusicInfo objects - var newMusicInfoList = new Il2CppSystem.Collections.Generic.List(); - globalAlbumConfig.GetAllMusicInfo(newMusicInfoList); - int idx = 0; - foreach (var m in newMusicInfoList) + // 4. Re-initialize ExInfo for all the newly created MusicInfo objects + var newMusicInfoList = new Il2CppSystem.Collections.Generic.List(); + globalAlbumConfig.GetAllMusicInfo(newMusicInfoList); + var idx = 0; + foreach (var m in newMusicInfoList) + { + var uidSplit = m.uid.Split('-'); + if (uidSplit.Length < 2) { - var uidSplit = m.uid.Split('-'); - if (uidSplit.Length < 2) { idx++; continue; } - if (!int.TryParse(uidSplit[1], out var parsedIndex)) { idx++; continue; } - - var aObj = AlbumManager.LoadedAlbums.Values.FirstOrDefault(a => a.Index == parsedIndex); - if (aObj != null) - { - m.Init(idx); - m.InitExInfo(); - m.m_MusicExInfo = CreateMusicExInfo(aObj); - - HotLoadedMusicInfos[m.uid] = m; - HotLoadedMusicNames[m.uid] = aObj.Info.Name ?? ""; - HotLoadedMusicAuthors[m.uid] = aObj.Info.Author ?? ""; - } idx++; + continue; } - Logger.Msg($"Transmuted MusicInfo for {uid}"); - } - else - { - Logger.Error("globalAlbumConfig is null! Cannot inject metadata."); - } - - // Add to the front-end view list so UI updates correctly - try - { - var dhColBase = Il2CppAssets.Scripts.Database.DataHelper.collections; - var dhColPtr = dhColBase != null ? dhColBase.Pointer : IntPtr.Zero; - var uids = GlobalDataBase.s_DbMusicTag.m_StageShowMusicUids; - if (uids != null) + if (!int.TryParse(uidSplit[1], out var parsedIndex)) { - if (!uids.Contains(uid)) - { - // Only add if it's not currently displaying the collections list to avoid alias pollution - if (dhColPtr == IntPtr.Zero || uids.Pointer != dhColPtr) - { - uids.Add(uid); - Logger.Msg($"Added {uid} to m_StageShowMusicUids"); - } - } + idx++; + continue; } - // Add to All Music tag (Index 0) - var allMusicTag = GlobalDataBase.dbMusicTag.GetAlbumTagInfo(0); - if (allMusicTag != null) + var aObj = AlbumManager.LoadedAlbums.Values.FirstOrDefault(a => a.Index == parsedIndex); + if (aObj != null) { - if (allMusicTag.m_MusicUids != null && !allMusicTag.m_MusicUids.Contains(uid)) - { - if (allMusicTag.m_MusicUids.Pointer != dhColPtr) - allMusicTag.m_MusicUids.Add(uid); - } - if (allMusicTag.m_DisplayMusicUids != null) - { - foreach (var d in allMusicTag.m_DisplayMusicUids) - { - if (d.musicUids != null && !d.musicUids.Contains(uid)) - { - if (d.musicUids.Pointer != dhColPtr) - d.musicUids.Add(uid); - } - } - } + m.Init(idx); + m.InitExInfo(); + m.m_MusicExInfo = CreateMusicExInfo(aObj); + + HotLoadedMusicInfos[m.uid] = m; + HotLoadedMusicNames[m.uid] = aObj.Info.Name ?? ""; + HotLoadedMusicAuthors[m.uid] = aObj.Info.Author ?? ""; } - // 5. Removed dicLevelConfig injection. It was causing Headquarters to crash by supplying an incorrect difficulty level. + idx++; } - catch (Exception ex) - { - Logger.Warning($"Failed to add to view lists: {ex.Message}"); - } - - // 3. Inject search tags into ConfigManager - try - { - var config = Singleton.instance.GetConfigObject(); - var searchTag = new MusicSearchTagInfo - { - uid = uid, - listIndex = config.count - }; - var tags = new List { "custom albums" }; - if (albumInfo.SearchTags != null) tags.AddRange(albumInfo.SearchTags); - if (!string.IsNullOrEmpty(albumInfo.NameRomanized)) tags.Add(albumInfo.NameRomanized); - for (var i = 0; i < tags.Count; i++) tags[i] = tags[i].ToLower(); - searchTag.tag = new Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStringArray(tags.ToArray()); + Logger.Msg($"Transmuted MusicInfo for {uid}"); + } + else + { + Logger.Error("globalAlbumConfig is null! Cannot inject metadata."); + } - if (!config.m_Dictionary.ContainsKey(uid)) - { - config.m_Dictionary.Add(uid, searchTag); - config.list.Add(searchTag); - } - } - catch (Exception ex) + // Add to the front-end view list so UI updates correctly + try + { + var dhColBase = DataHelper.collections; + var dhColPtr = dhColBase != null ? dhColBase.Pointer : IntPtr.Zero; + var uids = GlobalDataBase.s_DbMusicTag.m_StageShowMusicUids; + if (uids != null) + if (!uids.Contains(uid)) + // Only add if it's not currently displaying the collections list to avoid alias pollution + if (dhColPtr == IntPtr.Zero || uids.Pointer != dhColPtr) + { + uids.Add(uid); + Logger.Msg($"Added {uid} to m_StageShowMusicUids"); + } + + // Add to All Music tag (Index 0) + var allMusicTag = GlobalDataBase.dbMusicTag.GetAlbumTagInfo(0); + if (allMusicTag != null) { - Logger.Warning($"Failed to add search tags: {ex.Message}"); + if (allMusicTag.m_MusicUids != null && !allMusicTag.m_MusicUids.Contains(uid)) + if (allMusicTag.m_MusicUids.Pointer != dhColPtr) + allMusicTag.m_MusicUids.Add(uid); + if (allMusicTag.m_DisplayMusicUids != null) + foreach (var d in allMusicTag.m_DisplayMusicUids) + if (d.musicUids != null && !d.musicUids.Contains(uid)) + if (d.musicUids.Pointer != dhColPtr) + d.musicUids.Add(uid); } + // 5. Removed dicLevelConfig injection. It was causing Headquarters to crash by supplying an incorrect difficulty level. + } + catch (Exception ex) + { + Logger.Warning($"Failed to add to view lists: {ex.Message}"); + } - // 4. Preload cover resources - try + // 3. Inject search tags into ConfigManager + try + { + var config = Singleton.instance.GetConfigObject(); + var searchTag = new MusicSearchTagInfo { - if (album.HasFile("cover.png") || album.HasFile("cover.gif")) - { - ResourcesManager.instance - .LoadFromName($"{albumName}_cover") - .hideFlags |= UnityEngine.HideFlags.DontUnloadUnusedAsset; - } - } - catch (Exception ex) + uid = uid, + listIndex = config.count + }; + + var tags = new List { "custom albums" }; + if (albumInfo.SearchTags != null) tags.AddRange(albumInfo.SearchTags); + if (!string.IsNullOrEmpty(albumInfo.NameRomanized)) tags.Add(albumInfo.NameRomanized); + for (var i = 0; i < tags.Count; i++) tags[i] = tags[i].ToLower(); + searchTag.tag = new Il2CppStringArray(tags.ToArray()); + + if (!config.m_Dictionary.ContainsKey(uid)) { - Logger.Warning($"Failed to preload cover: {ex.Message}"); + config.m_Dictionary.Add(uid, searchTag); + config.list.Add(searchTag); } + } + catch (Exception ex) + { + Logger.Warning($"Failed to add search tags: {ex.Message}"); + } - // UI Hijack will handle rendering the name and author without needing these native injections. - addedCount++; - Logger.Msg($"Successfully added {albumInfo.Name}"); + // 4. Preload cover resources + try + { + if (album.HasFile("cover.png") || album.HasFile("cover.gif")) + ResourcesManager.instance + .LoadFromName($"{albumName}_cover") + .hideFlags |= HideFlags.DontUnloadUnusedAsset; } catch (Exception ex) { - Logger.Warning($"Error adding album: {ex.Message}"); - Logger.Warning(ex.StackTrace); + Logger.Warning($"Failed to preload cover: {ex.Message}"); } + + // UI Hijack will handle rendering the name and author without needing these native injections. + addedCount++; + Logger.Msg($"Successfully added {albumInfo.Name}"); + } + catch (Exception ex) + { + Logger.Warning($"Error adding album: {ex.Message}"); + Logger.Warning(ex.StackTrace); } - return addedCount; - } + return addedCount; + } - /// - /// Hot-delete: Removes a specified album from the game's runtime database. - /// - private static int ProcessDeletions() - { - var deletedCount = 0; + /// + /// Hot-delete: Removes a specified album from the game's runtime database. + /// + private static int ProcessDeletions() + { + var deletedCount = 0; - if (AlbumsToDelete.TryDequeue(out var albumFileName)) + if (AlbumsToDelete.TryDequeue(out var albumFileName)) + try { - try - { - var albumKey = $"album_{albumFileName}"; - Logger.Msg($"Removing {albumKey}"); + var albumKey = $"album_{albumFileName}"; + Logger.Msg($"Removing {albumKey}"); - if (!AlbumManager.LoadedAlbums.TryGetValue(albumKey, out var album)) - { - Logger.Warning($"Album {albumKey} not found"); - } + if (!AlbumManager.LoadedAlbums.TryGetValue(albumKey, out var album)) + Logger.Warning($"Album {albumKey} not found"); - var uid = $"{AlbumManager.Uid}-{album.Index}"; + var uid = $"{AlbumManager.Uid}-{album.Index}"; - HotLoadedMusicInfos.Remove(uid); - HotLoadedMusicNames.Remove(uid); - HotLoadedMusicAuthors.Remove(uid); + HotLoadedMusicInfos.Remove(uid); + HotLoadedMusicNames.Remove(uid); + HotLoadedMusicAuthors.Remove(uid); - // Remove from game music database - try - { - var musicInfo = GlobalDataBase.s_DbMusicTag.GetMusicInfoFromAll(uid); - if (musicInfo != null) - { - GlobalDataBase.s_DbMusicTag.RemoveShowMusicUid(musicInfo); - } - - var allMusicInfo = GlobalDataBase.s_DbMusicTag.m_AllMusicInfo; - if (allMusicInfo != null && allMusicInfo.ContainsKey(uid)) - { - allMusicInfo.Remove(uid); - } - - // Remove from all tags (like "All Music" or "Favorites") - if (GlobalDataBase.s_DbMusicTag.m_AllAlbumTagData != null) - { - foreach (var tag in GlobalDataBase.s_DbMusicTag.m_AllAlbumTagData) - { - var tagInfo = tag.Value; - if (tagInfo.m_MusicUids != null && tagInfo.m_MusicUids.Contains(uid)) - { - tagInfo.m_MusicUids.Remove(uid); - } - if (tagInfo.m_DisplayMusicUids != null) - { - foreach (var d in tagInfo.m_DisplayMusicUids) - { - if (d.musicUids != null && d.musicUids.Contains(uid)) - { - d.musicUids.Remove(uid); - } - } - } - } - } - } - catch (Exception ex) - { - Logger.Warning($"Failed to remove from database: {ex.Message}"); - } + // Remove from game music database + try + { + var musicInfo = GlobalDataBase.s_DbMusicTag.GetMusicInfoFromAll(uid); + if (musicInfo != null) GlobalDataBase.s_DbMusicTag.RemoveShowMusicUid(musicInfo); - // Remove from ConfigManager - try - { - var config = Singleton.instance.GetConfigObject(); - if (config != null) + var allMusicInfo = GlobalDataBase.s_DbMusicTag.m_AllMusicInfo; + if (allMusicInfo != null && allMusicInfo.ContainsKey(uid)) allMusicInfo.Remove(uid); + + // Remove from all tags (like "All Music" or "Favorites") + if (GlobalDataBase.s_DbMusicTag.m_AllAlbumTagData != null) + foreach (var tag in GlobalDataBase.s_DbMusicTag.m_AllAlbumTagData) { - if (config.m_Dictionary.ContainsKey(uid)) - { - var tagInfo = config.m_Dictionary[uid]; - config.m_Dictionary.Remove(uid); - config.list.Remove(tagInfo); - } + var tagInfo = tag.Value; + if (tagInfo.m_MusicUids != null && tagInfo.m_MusicUids.Contains(uid)) + tagInfo.m_MusicUids.Remove(uid); + if (tagInfo.m_DisplayMusicUids != null) + foreach (var d in tagInfo.m_DisplayMusicUids) + if (d.musicUids != null && d.musicUids.Contains(uid)) + d.musicUids.Remove(uid); } - } - catch (Exception ex) - { - Logger.Warning($"Failed to remove search tags: {ex.Message}"); - } - - // Clear resource cache - AlbumManager.LoadedAlbums.Remove(albumKey); - AssetPatch.RemoveFromCache($"{albumKey}_demo"); - AssetPatch.RemoveFromCache($"{albumKey}_music"); - AssetPatch.RemoveFromCache($"{albumKey}_cover"); - - deletedCount++; - Logger.Msg($"Removed {albumKey}"); } catch (Exception ex) { - Logger.Warning($"Error removing: {ex.Message}"); - Logger.Warning(ex.StackTrace); + Logger.Warning($"Failed to remove from database: {ex.Message}"); } - } - return deletedCount; - } - - /// - /// Rebuild Custom Albums tag to update the UID list. - /// - private static void RebuildCustomAlbumsTag() - { - try - { - var existingTag = GlobalDataBase.dbMusicTag.GetAlbumTagInfo(AlbumManager.Uid); - if (existingTag != null) + // Remove from ConfigManager + try { - var uids = AlbumManager.GetAllUid().ToList(); - - if (existingTag.customInfo != null) - { - existingTag.customInfo.music_list = uids.ToIl2Cpp(); - } - - if (existingTag.m_MusicUids != null) - { - existingTag.m_MusicUids.Clear(); - foreach (var uid in uids) existingTag.m_MusicUids.Add(uid); - } - - if (existingTag.m_DisplayMusicUids != null) - { - foreach (var d in existingTag.m_DisplayMusicUids) + var config = Singleton.instance.GetConfigObject(); + if (config != null) + if (config.m_Dictionary.ContainsKey(uid)) { - if (d.musicUids != null) - { - d.musicUids.Clear(); - foreach (var uid in uids) d.musicUids.Add(uid); - } + var tagInfo = config.m_Dictionary[uid]; + config.m_Dictionary.Remove(uid); + config.list.Remove(tagInfo); } - } - - Logger.Msg($"Tag updated ({uids.Count} albums)"); } + catch (Exception ex) + { + Logger.Warning($"Failed to remove search tags: {ex.Message}"); + } + + // Clear resource cache + AlbumManager.LoadedAlbums.Remove(albumKey); + AssetPatch.RemoveFromCache($"{albumKey}_demo"); + AssetPatch.RemoveFromCache($"{albumKey}_music"); + AssetPatch.RemoveFromCache($"{albumKey}_cover"); + + deletedCount++; + Logger.Msg($"Removed {albumKey}"); } catch (Exception ex) { - Logger.Warning($"Tag rebuild failed: {ex.Message}"); + Logger.Warning($"Error removing: {ex.Message}"); + Logger.Warning(ex.StackTrace); } - } - private static void RefreshUI() + return deletedCount; + } + + /// + /// Rebuild Custom Albums tag to update the UID list. + /// + private static void RebuildCustomAlbumsTag() + { + try { - try + var existingTag = GlobalDataBase.dbMusicTag.GetAlbumTagInfo(AlbumManager.Uid); + if (existingTag != null) { - var stage = UnityEngine.Object.FindObjectOfType(); - if (stage != null && stage.gameObject.activeInHierarchy) + var uids = AlbumManager.GetAllUid().ToList(); + + if (existingTag.customInfo != null) existingTag.customInfo.music_list = uids.ToIl2Cpp(); + + if (existingTag.m_MusicUids != null) { - stage.RefreshStageUI(); - Logger.Msg("UI refreshed"); + existingTag.m_MusicUids.Clear(); + foreach (var uid in uids) existingTag.m_MusicUids.Add(uid); } + + if (existingTag.m_DisplayMusicUids != null) + foreach (var d in existingTag.m_DisplayMusicUids) + if (d.musicUids != null) + { + d.musicUids.Clear(); + foreach (var uid in uids) d.musicUids.Add(uid); + } + + Logger.Msg($"Tag updated ({uids.Count} albums)"); } - catch (Exception ex) + } + catch (Exception ex) + { + Logger.Warning($"Tag rebuild failed: {ex.Message}"); + } + } + + private static void RefreshUI() + { + try + { + var stage = Object.FindObjectOfType(); + if (stage != null && stage.gameObject.activeInHierarchy) { - Logger.Warning($"Failed to refresh UI: {ex.Message}"); + stage.RefreshStageUI(); + Logger.Msg("UI refreshed"); } } + catch (Exception ex) + { + Logger.Warning($"Failed to refresh UI: {ex.Message}"); + } + } - private static float _lastProcessTime; + /// + /// Consume the queue in Unity's FixedUpdate (main thread safe). + /// + internal static void FixedUpdate() + { + if (AlbumsToAdd.IsEmpty && AlbumsToDelete.IsEmpty) return; - /// - /// Consume the queue in Unity's FixedUpdate (main thread safe). - /// - internal static void FixedUpdate() - { - if (AlbumsToAdd.IsEmpty && AlbumsToDelete.IsEmpty) return; + if (PnlStageInstance == null) return; - if (PnlStageInstance == null) return; + if (Time.unscaledTime - _lastProcessTime < 0.02f) return; + _lastProcessTime = Time.unscaledTime; - if (UnityEngine.Time.unscaledTime - _lastProcessTime < 0.02f) return; - _lastProcessTime = UnityEngine.Time.unscaledTime; - - var oldSelectedUid = DataHelper.selectedMusicUidFromInfoList; - var oldSelectedAlbumName = AlbumManager.GetAlbumNameFromUid(oldSelectedUid); + var oldSelectedUid = DataHelper.selectedMusicUidFromInfoList; + var oldSelectedAlbumName = AlbumManager.GetAlbumNameFromUid(oldSelectedUid); - var deletedCount = ProcessDeletions(); - var addedCount = 0; - if (deletedCount == 0) - { - addedCount = ProcessAdditions(); - } + var deletedCount = ProcessDeletions(); + var addedCount = 0; + if (deletedCount == 0) addedCount = ProcessAdditions(); - if (deletedCount > 0 || addedCount > 0) - { - PnlStageInstance?.m_MusicRootAnimator?.Play(PnlStageInstance.animNameAlbumIn); + if (deletedCount > 0 || addedCount > 0) + { + PnlStageInstance?.m_MusicRootAnimator?.Play(PnlStageInstance.animNameAlbumIn); - if (!string.IsNullOrEmpty(oldSelectedAlbumName) && oldSelectedUid != "0-0") + if (!string.IsNullOrEmpty(oldSelectedAlbumName) && oldSelectedUid != "0-0") + { + if (AlbumManager.LoadedAlbums.TryGetValue(oldSelectedAlbumName, out var newAlbum)) { - if (AlbumManager.LoadedAlbums.TryGetValue(oldSelectedAlbumName, out var newAlbum)) + var newUid = $"{AlbumManager.Uid}-{newAlbum.Index}"; + if (newUid != oldSelectedUid) { - var newUid = $"{AlbumManager.Uid}-{newAlbum.Index}"; - if (newUid != oldSelectedUid) - { - DataHelper.selectedMusicUidFromInfoList = newUid; - Logger.Msg($"Updated selected UID from {oldSelectedUid} to {newUid}"); - } - } - else - { - DataHelper.selectedMusicUidFromInfoList = "0-0"; - Logger.Msg($"Selected album was deleted. Resetting selection to 0-0"); + DataHelper.selectedMusicUidFromInfoList = newUid; + Logger.Msg($"Updated selected UID from {oldSelectedUid} to {newUid}"); } } - - RebuildCustomAlbumsTag(); - RefreshUI(); + else + { + DataHelper.selectedMusicUidFromInfoList = "0-0"; + Logger.Msg("Selected album was deleted. Resetting selection to 0-0"); + } } + + RebuildCustomAlbumsTag(); + RefreshUI(); } + } - /// - /// Initialize FileSystemWatcher to monitor Custom_Albums directory changes. - /// - internal static void OnLateInitializeMelon() + /// + /// Initialize FileSystemWatcher to monitor Custom_Albums directory changes. + /// + internal static void OnLateInitializeMelon() + { + try { - try + var watchPath = Path.GetFullPath(AlbumManager.SearchPath); + Logger.Msg($"Watching directory: {watchPath}"); + + if (!Directory.Exists(watchPath)) { + Logger.Warning($"Directory does not exist: {watchPath}"); + return; + } - var watchPath = Path.GetFullPath(AlbumManager.SearchPath); - Logger.Msg($"Watching directory: {watchPath}"); + AlbumManager.AlbumWatcher.Path = watchPath; + AlbumManager.AlbumWatcher.Filter = AlbumManager.SearchPattern; - if (!Directory.Exists(watchPath)) - { - Logger.Warning($"Directory does not exist: {watchPath}"); - return; - } - - AlbumManager.AlbumWatcher.Path = watchPath; - AlbumManager.AlbumWatcher.Filter = AlbumManager.SearchPattern; + AlbumManager.AlbumWatcher.Created += (_, e) => + { + var now = DateTime.Now; + if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && + (now - lastTime).TotalMilliseconds < 500) return; + LastFileEvent[e.FullPath] = now; - AlbumManager.AlbumWatcher.Created += (_, e) => + Logger.Msg($"Detected new file: {e.Name}"); + Task.Run(() => { - var now = DateTime.Now; - if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && (now - lastTime).TotalMilliseconds < 500) return; - LastFileEvent[e.FullPath] = now; + var attempts = 0; + while (!IsFileUnlocked(e.FullPath) && attempts < 50) + { + Thread.Sleep(200); + attempts++; + } - Logger.Msg($"Detected new file: {e.Name}"); - Task.Run(() => + if (attempts < 50) { - var attempts = 0; - while (!IsFileUnlocked(e.FullPath) && attempts < 50) - { - Thread.Sleep(200); - attempts++; - } + AlbumsToAdd.Enqueue(e.FullPath); + Logger.Msg($"Queued for addition: {e.Name}"); + } + else + { + Logger.Warning($"Timed out waiting for file: {e.Name}"); + } + }); + }; - if (attempts < 50) - { - AlbumsToAdd.Enqueue(e.FullPath); - Logger.Msg($"Queued for addition: {e.Name}"); - } - else - { - Logger.Warning($"Timed out waiting for file: {e.Name}"); - } - }); - }; + AlbumManager.AlbumWatcher.Deleted += (_, e) => + { + var now = DateTime.Now; + if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && + (now - lastTime).TotalMilliseconds < 500) return; + LastFileEvent[e.FullPath] = now; - AlbumManager.AlbumWatcher.Deleted += (_, e) => + Logger.Msg($"Detected deletion: {e.Name}"); + AlbumsToDelete.Enqueue(Path.GetFileNameWithoutExtension(e.Name)); + }; + + AlbumManager.AlbumWatcher.Changed += (_, e) => + { + if (e.ChangeType != WatcherChangeTypes.Changed) return; + var now = DateTime.Now; + if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && + (now - lastTime).TotalMilliseconds < 500) return; + LastFileEvent[e.FullPath] = now; + + Logger.Msg($"Detected change: {e.Name}"); + Task.Run(() => { - var now = DateTime.Now; - if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && (now - lastTime).TotalMilliseconds < 500) return; - LastFileEvent[e.FullPath] = now; + var attempts = 0; + while (!IsFileUnlocked(e.FullPath) && attempts < 50) + { + Thread.Sleep(200); + attempts++; + } - Logger.Msg($"Detected deletion: {e.Name}"); - AlbumsToDelete.Enqueue(Path.GetFileNameWithoutExtension(e.Name)); - }; + if (attempts < 50) + { + AlbumsToDelete.Enqueue(Path.GetFileNameWithoutExtension(e.Name)); + AlbumsToAdd.Enqueue(e.FullPath); + Logger.Msg($"Queued for reload: {e.Name}"); + } + else + { + Logger.Warning($"Timed out waiting for file: {e.Name}"); + } + }); + }; - AlbumManager.AlbumWatcher.Changed += (_, e) => - { - if (e.ChangeType != WatcherChangeTypes.Changed) return; - var now = DateTime.Now; - if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && (now - lastTime).TotalMilliseconds < 500) return; - LastFileEvent[e.FullPath] = now; + AlbumManager.AlbumWatcher.Renamed += (_, e) => + { + var now = DateTime.Now; + if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && + (now - lastTime).TotalMilliseconds < 500) return; + LastFileEvent[e.FullPath] = now; - Logger.Msg($"Detected change: {e.Name}"); + Logger.Msg($"Detected rename: {e.OldName} -> {e.Name}"); + var oldKey = $"album_{Path.GetFileNameWithoutExtension(e.OldName)}"; + var newKey = $"album_{Path.GetFileNameWithoutExtension(e.Name)}"; + + if (AlbumManager.LoadedAlbums.Remove(oldKey, out var album)) + { + AlbumManager.LoadedAlbums.TryAdd(newKey, album); + AssetPatch.ModifyCacheKey($"{oldKey}_demo", $"{newKey}_demo"); + AssetPatch.ModifyCacheKey($"{oldKey}_music", $"{newKey}_music"); + AssetPatch.ModifyCacheKey($"{oldKey}_cover", $"{newKey}_cover"); + Logger.Msg($"Renamed {oldKey} -> {newKey}"); + } + else + { + // Old album was not loaded (e.g. didn't match 'test*.mdm'). Treat this rename as a new creation event. + Logger.Msg($"Old file was not loaded, treating as new file: {e.Name}"); Task.Run(() => { var attempts = 0; @@ -619,103 +625,55 @@ internal static void OnLateInitializeMelon() if (attempts < 50) { - AlbumsToDelete.Enqueue(Path.GetFileNameWithoutExtension(e.Name)); AlbumsToAdd.Enqueue(e.FullPath); - Logger.Msg($"Queued for reload: {e.Name}"); + Logger.Msg($"Queued for addition: {e.Name}"); } else { Logger.Warning($"Timed out waiting for file: {e.Name}"); } }); - }; - - AlbumManager.AlbumWatcher.Renamed += (_, e) => - { - var now = DateTime.Now; - if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && (now - lastTime).TotalMilliseconds < 500) return; - LastFileEvent[e.FullPath] = now; - - Logger.Msg($"Detected rename: {e.OldName} -> {e.Name}"); - var oldKey = $"album_{Path.GetFileNameWithoutExtension(e.OldName)}"; - var newKey = $"album_{Path.GetFileNameWithoutExtension(e.Name)}"; - - if (AlbumManager.LoadedAlbums.Remove(oldKey, out var album)) - { - AlbumManager.LoadedAlbums.TryAdd(newKey, album); - AssetPatch.ModifyCacheKey($"{oldKey}_demo", $"{newKey}_demo"); - AssetPatch.ModifyCacheKey($"{oldKey}_music", $"{newKey}_music"); - AssetPatch.ModifyCacheKey($"{oldKey}_cover", $"{newKey}_cover"); - Logger.Msg($"Renamed {oldKey} -> {newKey}"); - } - else - { - // Old album was not loaded (e.g. didn't match 'test*.mdm'). Treat this rename as a new creation event. - Logger.Msg($"Old file was not loaded, treating as new file: {e.Name}"); - Task.Run(() => - { - var attempts = 0; - while (!IsFileUnlocked(e.FullPath) && attempts < 50) - { - Thread.Sleep(200); - attempts++; - } - - if (attempts < 50) - { - AlbumsToAdd.Enqueue(e.FullPath); - Logger.Msg($"Queued for addition: {e.Name}"); - } - else - { - Logger.Warning($"Timed out waiting for file: {e.Name}"); - } - }); - } - }; + } + }; - AlbumManager.AlbumWatcher.EnableRaisingEvents = true; - Logger.Msg("FileSystemWatcher initialized!"); - } - catch (Exception ex) - { - Logger.Warning($"Init failed: {ex.Message}"); - Logger.Warning(ex.StackTrace); - } + AlbumManager.AlbumWatcher.EnableRaisingEvents = true; + Logger.Msg("FileSystemWatcher initialized!"); + } + catch (Exception ex) + { + Logger.Warning($"Init failed: {ex.Message}"); + Logger.Warning(ex.StackTrace); } + } - // ============================================================ - // Harmony Patches - // ============================================================ + // ============================================================ + // Harmony Patches + // ============================================================ - /// - /// Intercepts GetMusicInfoFromAll: Returns our manually created MusicInfo - /// when the game looks up a hot-loaded song. - /// - [HarmonyPatch(typeof(DBMusicTag), nameof(DBMusicTag.GetMusicInfoFromAll))] - internal static class GetMusicInfoFromAllPatch + /// + /// Intercepts GetMusicInfoFromAll: Returns our manually created MusicInfo + /// when the game looks up a hot-loaded song. + /// + [HarmonyPatch(typeof(DBMusicTag), nameof(DBMusicTag.GetMusicInfoFromAll))] + internal static class GetMusicInfoFromAllPatch + { + private static void Postfix(string musicUid, ref MusicInfo __result) { - private static void Postfix(string musicUid, ref MusicInfo __result) - { - if (string.IsNullOrEmpty(musicUid)) return; - - if (HotLoadedMusicInfos.TryGetValue(musicUid, out var hotMusicInfo)) - { - __result = hotMusicInfo; - } - } + if (string.IsNullOrEmpty(musicUid)) return; + + if (HotLoadedMusicInfos.TryGetValue(musicUid, out var hotMusicInfo)) __result = hotMusicInfo; } + } - /// - /// Capture PnlStage instance reference. - /// - [HarmonyPatch(typeof(PnlStage), nameof(PnlStage.PreWarm))] - internal static class StagePreWarmPatch + /// + /// Capture PnlStage instance reference. + /// + [HarmonyPatch(typeof(PnlStage), nameof(PnlStage.PreWarm))] + internal static class StagePreWarmPatch + { + private static void Postfix(PnlStage __instance) { - private static void Postfix(PnlStage __instance) - { - PnlStageInstance = __instance; - } + PnlStageInstance = __instance; } } } \ No newline at end of file diff --git a/Managers/MusicDataManager.cs b/Managers/MusicDataManager.cs index 55382fe..edc6bdd 100644 --- a/Managers/MusicDataManager.cs +++ b/Managers/MusicDataManager.cs @@ -3,68 +3,67 @@ using Il2CppGameLogic; using Decimal = Il2CppSystem.Decimal; -namespace CustomAlbums.Managers +namespace CustomAlbums.Managers; + +internal static class MusicDataManager { - internal static class MusicDataManager - { - private static readonly List MusicDataList = new(); - private static readonly Logger Logger = new(nameof(MusicDataManager)); + private static readonly List MusicDataList = new(); + private static readonly Logger Logger = new(nameof(MusicDataManager)); - static MusicDataManager() - { - // Add an empty music data to the list as a placeholder - MusicDataList.Add(Interop.CreateTypeValue()); - } + static MusicDataManager() + { + // Add an empty music data to the list as a placeholder + MusicDataList.Add(Interop.CreateTypeValue()); + } - public static ReadOnlyCollection Data => MusicDataList.AsReadOnly(); + public static ReadOnlyCollection Data => MusicDataList.AsReadOnly(); - public static void Add(MusicData data) + public static void Add(MusicData data) + { + if (MusicDataList.Count >= short.MaxValue - 1) { - if (MusicDataList.Count >= short.MaxValue - 1) - { - Logger.Warning($"BMS is too large to load. Max MusicData allowed is {short.MaxValue}!"); - return; - } - - MusicDataList.Add(data); + Logger.Warning($"BMS is too large to load. Max MusicData allowed is {short.MaxValue}!"); + return; } - public static void Sort() - { - // Remove placeholder music data - MusicDataList.RemoveAt(0); + MusicDataList.Add(data); + } - // Sort the list - MusicDataList.Sort((l, r) => !(r.tick - r.dt - (l.tick - l.dt) > 0) ? 1 : -1); + public static void Sort() + { + // Remove placeholder music data + MusicDataList.RemoveAt(0); - // Add the placeholder music data back - MusicDataList.Insert(0, Interop.CreateTypeValue()); + // Sort the list + MusicDataList.Sort((l, r) => !(r.tick - r.dt - (l.tick - l.dt) > 0) ? 1 : -1); - // Reapply object IDs and round tick to nearest 3 decimal places - for (var i = 1; i < MusicDataList.Count; i++) - { - var musicData = MusicDataList[i]; - musicData.tick = Decimal.Round(musicData.tick, 3); - musicData.objId = (short)i; + // Add the placeholder music data back + MusicDataList.Insert(0, Interop.CreateTypeValue()); - MusicDataList[i] = musicData; - } + // Reapply object IDs and round tick to nearest 3 decimal places + for (var i = 1; i < MusicDataList.Count; i++) + { + var musicData = MusicDataList[i]; + musicData.tick = Decimal.Round(musicData.tick, 3); + musicData.objId = (short)i; - Logger.Msg("Sorted MusicData"); + MusicDataList[i] = musicData; } - public static void Set(int index, MusicData data) - { - MusicDataList[index] = data; - } + Logger.Msg("Sorted MusicData"); + } - public static void Clear() - { - // Clear the list - MusicDataList.Clear(); + public static void Set(int index, MusicData data) + { + MusicDataList[index] = data; + } - // Add an empty music data to the list as a placeholder - MusicDataList.Add(Interop.CreateTypeValue()); - } + public static void Clear() + { + // Clear the list + MusicDataList.Clear(); + + // Add an empty music data to the list as a placeholder + MusicDataList.Add(Interop.CreateTypeValue()); } } \ No newline at end of file diff --git a/Managers/PackManager.cs b/Managers/PackManager.cs index 1f338b1..dbe35b3 100644 --- a/Managers/PackManager.cs +++ b/Managers/PackManager.cs @@ -1,36 +1,36 @@ -using CustomAlbums.Data; +using System.IO.Compression; +using CustomAlbums.Data; using CustomAlbums.Utilities; -using System.IO.Compression; -namespace CustomAlbums.Managers +namespace CustomAlbums.Managers; + +public class PackManager { - public class PackManager + private static readonly List Packs = new(); + + public static Pack GetPackFromUid(string uid) { - private static readonly List Packs = new(); - public static Pack GetPackFromUid(string uid) - { - // If the uid is not custom or parsing the index fails - if (!uid.StartsWith($"{AlbumManager.Uid}-") || - !uid[4..].TryParseAsInt(out var uidIndex)) return null; + // If the uid is not custom or parsing the index fails + if (!uid.StartsWith($"{AlbumManager.Uid}-") || + !uid[4..].TryParseAsInt(out var uidIndex)) return null; - // Retrieve the pack that the uid belongs to - var retrievedPack = Packs.FirstOrDefault(pack => - uidIndex >= pack.StartIndex && uidIndex < pack.StartIndex + pack.Length); + // Retrieve the pack that the uid belongs to + var retrievedPack = Packs.FirstOrDefault(pack => + uidIndex >= pack.StartIndex && uidIndex < pack.StartIndex + pack.Length); - // If the pack has no albums in it return null, otherwise return pack (will be null if it doesn't exist) - return retrievedPack?.Length is 0 ? null : retrievedPack; - } + // If the pack has no albums in it return null, otherwise return pack (will be null if it doesn't exist) + return retrievedPack?.Length is 0 ? null : retrievedPack; + } - internal static Pack CreatePack(ZipArchiveEntry json, string path) - { - var pack = Json.Deserialize(json.Open()); - pack.Path = path; - return pack; - } + internal static Pack CreatePack(ZipArchiveEntry json, string path) + { + var pack = Json.Deserialize(json.Open()); + pack.Path = path; + return pack; + } - internal static void AddPack(Pack pack) - { - Packs.Add(pack); - } + internal static void AddPack(Pack pack) + { + Packs.Add(pack); } -} +} \ No newline at end of file diff --git a/Managers/SaveManager.cs b/Managers/SaveManager.cs index a45ed96..c6c3f61 100644 --- a/Managers/SaveManager.cs +++ b/Managers/SaveManager.cs @@ -1,228 +1,233 @@ -using System.Text; +using System.IO.Compression; +using System.Text; using System.Text.Json; using CustomAlbums.Data; using CustomAlbums.Utilities; -using System.IO.Compression; -namespace CustomAlbums.Managers +namespace CustomAlbums.Managers; + +public class SaveManager { - public class SaveManager + private const string SaveLocation = "UserData"; + internal static Logger Logger = new(nameof(SaveManager)); + public static CustomAlbumsSave SaveData { get; private set; } + internal static string PreviousScore { get; set; } = "-"; + + /// + /// Fixes the save file since this version of CAM uses a different naming scheme. + /// This allows cross-compatibility between CAM 3 and CAM 4, but not from CAM 4 to CAM 3. + /// + internal static void FixSaveFile() { - private const string SaveLocation = "UserData"; - public static CustomAlbumsSave SaveData { get; private set; } - internal static Logger Logger = new(nameof(SaveManager)); - internal static string PreviousScore { get; set; } = "-"; - - /// - /// Fixes the save file since this version of CAM uses a different naming scheme. - /// This allows cross-compatibility between CAM 3 and CAM 4, but not from CAM 4 to CAM 3. - /// - internal static void FixSaveFile() + if (!ModSettings.SavingEnabled) return; + var firstHistory = SaveData.History.FirstOrDefault(); + var firstHighest = SaveData.Highest.FirstOrDefault(); + var firstFullCombo = SaveData.FullCombo.FirstOrDefault(); + var stringBuilder = new StringBuilder(); + + // If we need to fix the history + if (firstHistory != null && firstHistory.StartsWith("pkg_")) { - if (!ModSettings.SavingEnabled) return; - var firstHistory = SaveData.History.FirstOrDefault(); - var firstHighest = SaveData.Highest.FirstOrDefault(); - var firstFullCombo = SaveData.FullCombo.FirstOrDefault(); - var stringBuilder = new StringBuilder(); - - // If we need to fix the history - if (firstHistory != null && firstHistory.StartsWith("pkg_")) - { - var fixedList = new List(SaveData.History.Count); - foreach (var history in SaveData.History.Where(history => history.StartsWith("pkg_"))) - { - stringBuilder.Clear(); - stringBuilder.Append(history); - stringBuilder.Remove(0, 4); - stringBuilder.Insert(0, "album_"); - fixedList.Add(stringBuilder.ToString()); - } - - SaveData.History = fixedList; - } - - // If we need to fix the highest - if (!firstHighest.Equals(default(KeyValuePair>)) && - firstHighest.Key.StartsWith("pkg_")) - { - var fixedDictionaryHighest = - new Dictionary>(SaveData.Highest.Count); - foreach (var (key, value) in SaveData.Highest.Where(kv => kv.Key.StartsWith("pkg_"))) - { - stringBuilder.Clear(); - stringBuilder.Append(key); - stringBuilder.Remove(0, 4); - stringBuilder.Insert(0, "album_"); - fixedDictionaryHighest.Add(stringBuilder.ToString(), value); - } - - SaveData.Highest = fixedDictionaryHighest; - } - - if (!SaveData.UnlockedMasters.Any()) + var fixedList = new List(SaveData.History.Count); + foreach (var history in SaveData.History.Where(history => history.StartsWith("pkg_"))) { - var unlockedHighest = SaveData.Highest.Where(kv => - kv.Value.ContainsKey(3) && kv.Value.TryGetValue(2, out var chartSave) && chartSave.Evaluate >= 4).Select(kv => kv.Key); - var folderCharts = AlbumManager.LoadedAlbums.Where(kv => kv.Value.HasDifficulty(2) && kv.Value.HasDifficulty(3) && !kv.Value.IsPackaged).Select(kv => kv.Key); - var concat = unlockedHighest.Concat(folderCharts); - SaveData.UnlockedMasters.UnionWith(concat); + stringBuilder.Clear(); + stringBuilder.Append(history); + stringBuilder.Remove(0, 4); + stringBuilder.Insert(0, "album_"); + fixedList.Add(stringBuilder.ToString()); } - // If we don't need to fix the FullCombo then return - if (!firstFullCombo.Equals(default(KeyValuePair>)) && - !firstFullCombo.Key.StartsWith("pkg_")) return; + SaveData.History = fixedList; + } - var fixedDictionaryFc = new Dictionary>(SaveData.FullCombo.Count); - foreach (var (key, value) in SaveData.FullCombo.Where(kv => kv.Key.StartsWith("pkg_"))) + // If we need to fix the highest + if (!firstHighest.Equals(default(KeyValuePair>)) && + firstHighest.Key.StartsWith("pkg_")) + { + var fixedDictionaryHighest = + new Dictionary>(SaveData.Highest.Count); + foreach (var (key, value) in SaveData.Highest.Where(kv => kv.Key.StartsWith("pkg_"))) { - if (!key.StartsWith("pkg_")) continue; stringBuilder.Clear(); stringBuilder.Append(key); stringBuilder.Remove(0, 4); stringBuilder.Insert(0, "album_"); - fixedDictionaryFc.Add(stringBuilder.ToString(), value); + fixedDictionaryHighest.Add(stringBuilder.ToString(), value); } - SaveData.FullCombo = fixedDictionaryFc; + SaveData.Highest = fixedDictionaryHighest; } - private static void RestoreBackup() + if (!SaveData.UnlockedMasters.Any()) { - var backupPath = Path.Join(SaveLocation, "Backups", "Backups.zip"); - - // If a backup does not exist at all - if (!File.Exists(backupPath)) - { - Logger.Fail("No backups found. Please delete the CustomAlbums.json file in UserData folder to create a new save."); - return; - } + var unlockedHighest = SaveData.Highest.Where(kv => + kv.Value.ContainsKey(3) && kv.Value.TryGetValue(2, out var chartSave) && chartSave.Evaluate >= 4) + .Select(kv => kv.Key); + var folderCharts = AlbumManager.LoadedAlbums + .Where(kv => kv.Value.HasDifficulty(2) && kv.Value.HasDifficulty(3) && !kv.Value.IsPackaged) + .Select(kv => kv.Key); + var concat = unlockedHighest.Concat(folderCharts); + SaveData.UnlockedMasters.UnionWith(concat); + } - // Traverse the .zip file, trying every single backup in this archive - using var backupEntries = ZipFile.OpenRead(backupPath); - foreach (var backup in backupEntries.Entries.OrderByDescending(bak => bak.LastWriteTime) - .Where(bak => bak.Name.EndsWith("CustomAlbums.json.bak"))) - { - try - { - SaveData = Json.Deserialize(backup.Open()); - } - catch (Exception) - { - continue; - } - - // If our backup that we are trying to load is valid but empty, continue to try and find the last save with data in it - if (SaveData.IsEmpty()) continue; - Logger.Success($"Restored backup from {backup.LastWriteTime.DateTime}."); - return; - } - Logger.Fail("Could not restore save file. Please delete the CustomAlbums.json file in UserData folder to create a new save."); + // If we don't need to fix the FullCombo then return + if (!firstFullCombo.Equals(default(KeyValuePair>)) && + !firstFullCombo.Key.StartsWith("pkg_")) return; + + var fixedDictionaryFc = new Dictionary>(SaveData.FullCombo.Count); + foreach (var (key, value) in SaveData.FullCombo.Where(kv => kv.Key.StartsWith("pkg_"))) + { + if (!key.StartsWith("pkg_")) continue; + stringBuilder.Clear(); + stringBuilder.Append(key); + stringBuilder.Remove(0, 4); + stringBuilder.Insert(0, "album_"); + fixedDictionaryFc.Add(stringBuilder.ToString(), value); } - internal static void LoadSaveFile() + SaveData.FullCombo = fixedDictionaryFc; + } + + private static void RestoreBackup() + { + var backupPath = Path.Join(SaveLocation, "Backups", "Backups.zip"); + + // If a backup does not exist at all + if (!File.Exists(backupPath)) + { + Logger.Fail( + "No backups found. Please delete the CustomAlbums.json file in UserData folder to create a new save."); + return; + } + + // Traverse the .zip file, trying every single backup in this archive + using var backupEntries = ZipFile.OpenRead(backupPath); + foreach (var backup in backupEntries.Entries.OrderByDescending(bak => bak.LastWriteTime) + .Where(bak => bak.Name.EndsWith("CustomAlbums.json.bak"))) { - if (!ModSettings.SavingEnabled) return; try { - using var fileStream = File.OpenRead(Path.Join(SaveLocation, "CustomAlbums.json")); - SaveData = Json.Deserialize(fileStream); - FixSaveFile(); + SaveData = Json.Deserialize(backup.Open()); } - catch (Exception ex) + catch (Exception) { - if (ex is FileNotFoundException) - { - SaveData = new CustomAlbumsSave(); - } - else - { - Logger.Warning("Could not load save file. Attempting to restore backup..."); - RestoreBackup(); - } + continue; } + + // If our backup that we are trying to load is valid but empty, continue to try and find the last save with data in it + if (SaveData.IsEmpty()) continue; + Logger.Success($"Restored backup from {backup.LastWriteTime.DateTime}."); + return; } - internal static void SaveSaveFile() + Logger.Fail( + "Could not restore save file. Please delete the CustomAlbums.json file in UserData folder to create a new save."); + } + + internal static void LoadSaveFile() + { + if (!ModSettings.SavingEnabled) return; + try { - if (!ModSettings.SavingEnabled) return; - try + using var fileStream = File.OpenRead(Path.Join(SaveLocation, "CustomAlbums.json")); + SaveData = Json.Deserialize(fileStream); + FixSaveFile(); + } + catch (Exception ex) + { + if (ex is FileNotFoundException) { - if (SaveData is null) - { - Logger.Warning("Trying to save null data, not saving."); - return; - } - - File.WriteAllText(Path.Join(SaveLocation, "CustomAlbums.json"), JsonSerializer.Serialize(SaveData)); + SaveData = new CustomAlbumsSave(); } - catch (Exception ex) + else { - Logger.Warning("Failed to save save file. " + ex.StackTrace); + Logger.Warning("Could not load save file. Attempting to restore backup..."); + RestoreBackup(); } } + } - /// - /// Saves custom score given scoring information. - /// - /// The UID of the chart. - /// The difficulty index of the chart played. - /// The score of the play. - /// The accuracy of the play. - /// The maximum combo of the play. - /// The judgement ranking of the play. - /// The amount of misses in the play. - internal static void SaveScore(string uid, int musicDifficulty, int score, float accuracy, int maxCombo, - string evaluate, int miss) + internal static void SaveSaveFile() + { + if (!ModSettings.SavingEnabled) return; + try { - if (!ModSettings.SavingEnabled) return; + if (SaveData is null) + { + Logger.Warning("Trying to save null data, not saving."); + return; + } + + File.WriteAllText(Path.Join(SaveLocation, "CustomAlbums.json"), JsonSerializer.Serialize(SaveData)); + } + catch (Exception ex) + { + Logger.Warning("Failed to save save file. " + ex.StackTrace); + } + } - var album = AlbumManager.GetByUid(uid); - if (!album?.IsPackaged ?? true) return; + /// + /// Saves custom score given scoring information. + /// + /// The UID of the chart. + /// The difficulty index of the chart played. + /// The score of the play. + /// The accuracy of the play. + /// The maximum combo of the play. + /// The judgement ranking of the play. + /// The amount of misses in the play. + internal static void SaveScore(string uid, int musicDifficulty, int score, float accuracy, int maxCombo, + string evaluate, int miss) + { + if (!ModSettings.SavingEnabled) return; - var newEvaluate = evaluate switch - { - "sss" => 6, - "ss" => 5, - "s" => 4, - "a" => 3, - "b" => 2, - "c" => 1, - _ => 0 - }; + var album = AlbumManager.GetByUid(uid); + if (!album?.IsPackaged ?? true) return; - var albumName = album.AlbumName; + var newEvaluate = evaluate switch + { + "sss" => 6, + "ss" => 5, + "s" => 4, + "a" => 3, + "b" => 2, + "c" => 1, + _ => 0 + }; - // Create new album save - SaveData.Highest.TryAdd(albumName, new Dictionary()); + var albumName = album.AlbumName; - var currChartScore = SaveData.Highest[albumName]; + // Create new album save + SaveData.Highest.TryAdd(albumName, new Dictionary()); - // Create new save data if the difficulty doesn't exist - currChartScore.TryAdd(musicDifficulty, new ChartSave()); + var currChartScore = SaveData.Highest[albumName]; - // Set previous score for PnlVictory logic - var newScore = currChartScore[musicDifficulty]; - PreviousScore = newScore.Passed ? newScore.Score.ToString() : "-"; + // Create new save data if the difficulty doesn't exist + currChartScore.TryAdd(musicDifficulty, new ChartSave()); - // Set the correct new score, taking the max of everything - newScore.Passed = true; - newScore.Accuracy = Math.Max(accuracy, newScore.Accuracy); - newScore.Score = Math.Max(score, newScore.Score); - newScore.Combo = Math.Max(maxCombo, newScore.Combo); - newScore.Evaluate = Math.Max(newEvaluate, newScore.Evaluate); - newScore.AccuracyStr = (newScore.Accuracy / 100).ToStringInvariant("P2"); - newScore.Clear++; + // Set previous score for PnlVictory logic + var newScore = currChartScore[musicDifficulty]; + PreviousScore = newScore.Passed ? newScore.Score.ToString() : "-"; - if (musicDifficulty is 2 && AlbumManager.LoadedAlbums[albumName].HasDifficulty(3) && newScore.Evaluate >= 4) - SaveData.UnlockedMasters.Add(albumName); + // Set the correct new score, taking the max of everything + newScore.Passed = true; + newScore.Accuracy = Math.Max(accuracy, newScore.Accuracy); + newScore.Score = Math.Max(score, newScore.Score); + newScore.Combo = Math.Max(maxCombo, newScore.Combo); + newScore.Evaluate = Math.Max(newEvaluate, newScore.Evaluate); + newScore.AccuracyStr = (newScore.Accuracy / 100).ToStringInvariant("P2"); + newScore.Clear++; - // If there were no misses then add the chart/difficulty to the FullCombo list - if (miss != 0) return; + if (musicDifficulty is 2 && AlbumManager.LoadedAlbums[albumName].HasDifficulty(3) && newScore.Evaluate >= 4) + SaveData.UnlockedMasters.Add(albumName); - SaveData.FullCombo.TryAdd(albumName, new List()); + // If there were no misses then add the chart/difficulty to the FullCombo list + if (miss != 0) return; - if (!SaveData.FullCombo[albumName].Contains(musicDifficulty)) - SaveData.FullCombo[albumName].Add(musicDifficulty); - } + SaveData.FullCombo.TryAdd(albumName, new List()); + + if (!SaveData.FullCombo[albumName].Contains(musicDifficulty)) + SaveData.FullCombo[albumName].Add(musicDifficulty); } } \ No newline at end of file diff --git a/ModExtensions/AlbumEventArgs.cs b/ModExtensions/AlbumEventArgs.cs index f407596..ea21d08 100644 --- a/ModExtensions/AlbumEventArgs.cs +++ b/ModExtensions/AlbumEventArgs.cs @@ -1,14 +1,13 @@ using CustomAlbums.Data; -namespace CustomAlbums.ModExtensions +namespace CustomAlbums.ModExtensions; + +public class AlbumEventArgs : EventArgs { - public class AlbumEventArgs : EventArgs - { - public Album Album; + public Album Album; - public AlbumEventArgs(Album album) - { - Album = album; - } + public AlbumEventArgs(Album album) + { + Album = album; } -} +} \ No newline at end of file diff --git a/ModExtensions/AssetEventArgs.cs b/ModExtensions/AssetEventArgs.cs index bb2b084..839a5f5 100644 --- a/ModExtensions/AssetEventArgs.cs +++ b/ModExtensions/AssetEventArgs.cs @@ -1,14 +1,13 @@ -namespace CustomAlbums.ModExtensions -{ - public class AssetEventArgs : EventArgs - { - public string AssetName; - public IntPtr AssetPtr; +namespace CustomAlbums.ModExtensions; + +public class AssetEventArgs : EventArgs +{ + public string AssetName; + public IntPtr AssetPtr; - public AssetEventArgs(string assetName, IntPtr assetPtr) - { - AssetName = assetName; - AssetPtr = assetPtr; - } + public AssetEventArgs(string assetName, IntPtr assetPtr) + { + AssetName = assetName; + AssetPtr = assetPtr; } -} +} \ No newline at end of file diff --git a/ModExtensions/Events.cs b/ModExtensions/Events.cs index 97fb71d..66137fe 100644 --- a/ModExtensions/Events.cs +++ b/ModExtensions/Events.cs @@ -1,25 +1,23 @@ using CustomAlbums.Managers; using CustomAlbums.Patches; -namespace CustomAlbums.ModExtensions -{ - public static class Events - { - public delegate void LoadAssetEvent(object s, AssetEventArgs e); +namespace CustomAlbums.ModExtensions; - public static event LoadAssetEvent OnAssetLoaded - { - add => AssetPatch.OnAssetLoaded += value; - remove => AssetPatch.OnAssetLoaded -= value; - } +public static class Events +{ + public delegate void LoadAlbumEvent(object s, AlbumEventArgs e); - public delegate void LoadAlbumEvent(object s, AlbumEventArgs e); + public delegate void LoadAssetEvent(object s, AssetEventArgs e); - public static event LoadAlbumEvent OnAlbumLoaded - { - add => AlbumManager.OnAlbumLoaded += value; - remove => AlbumManager.OnAlbumLoaded -= value; - } + public static event LoadAssetEvent OnAssetLoaded + { + add => AssetPatch.OnAssetLoaded += value; + remove => AssetPatch.OnAssetLoaded -= value; + } + public static event LoadAlbumEvent OnAlbumLoaded + { + add => AlbumManager.OnAlbumLoaded += value; + remove => AlbumManager.OnAlbumLoaded -= value; } -} +} \ No newline at end of file diff --git a/ModSettings.cs b/ModSettings.cs index aab8ecf..9a7bd06 100644 --- a/ModSettings.cs +++ b/ModSettings.cs @@ -1,24 +1,23 @@ using MelonLoader; -namespace CustomAlbums +namespace CustomAlbums; + +internal static class ModSettings { - internal static class ModSettings - { - private static MelonPreferences_Entry _verboseLogging; - private static MelonPreferences_Entry _savingEnabled; - private static MelonPreferences_Entry _enableLogWriteToFile; - public static bool VerboseLogging => _verboseLogging.Value; - public static bool SavingEnabled => _savingEnabled.Value; - public static bool LoggingToFileEnabled => _enableLogWriteToFile.Value; + private static MelonPreferences_Entry _verboseLogging; + private static MelonPreferences_Entry _savingEnabled; + private static MelonPreferences_Entry _enableLogWriteToFile; + public static bool VerboseLogging => _verboseLogging.Value; + public static bool SavingEnabled => _savingEnabled.Value; + public static bool LoggingToFileEnabled => _enableLogWriteToFile.Value; - internal static void Register() - { - var category = MelonPreferences.CreateCategory("CustomAlbums", "Custom Albums"); + internal static void Register() + { + var category = MelonPreferences.CreateCategory("CustomAlbums", "Custom Albums"); - _verboseLogging = category.CreateEntry("VerboseLogging", false, "Verbose Logging"); - _savingEnabled = category.CreateEntry("SavingEnabled", true, "Enable Saving"); - _enableLogWriteToFile = category.CreateEntry("LogWriteToFile", false, "Enable Log Write to File"); - } + _verboseLogging = category.CreateEntry("VerboseLogging", false, "Verbose Logging"); + _savingEnabled = category.CreateEntry("SavingEnabled", true, "Enable Saving"); + _enableLogWriteToFile = category.CreateEntry("LogWriteToFile", false, "Enable Log Write to File"); } } \ No newline at end of file diff --git a/Patches/AnalyticsPatch.cs b/Patches/AnalyticsPatch.cs index 994f7c3..08eb6b3 100644 --- a/Patches/AnalyticsPatch.cs +++ b/Patches/AnalyticsPatch.cs @@ -1,109 +1,107 @@ -using CustomAlbums.Managers; +using System.Reflection; +using CustomAlbums.Managers; using HarmonyLib; using Il2CppAssets.Scripts.Database; using Il2CppPeroPeroGames.DataStatistics; -using System.Reflection; using UnityEngine; using Logger = CustomAlbums.Utilities.Logger; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +internal class AnalyticsPatch { - internal class AnalyticsPatch + internal static readonly Logger Logger = new(nameof(AnalyticsPatch)); + + /// + /// Blocks tag analytics from being sent if tag is CustomAlbums. + /// + [HarmonyPatch(typeof(ThinkingDataPeripheralHelper), nameof(ThinkingDataPeripheralHelper.CommonSendString))] + internal class CommonSendStringPatch { - internal static readonly Logger Logger = new(nameof(AnalyticsPatch)); - - /// - /// Blocks tag analytics from being sent if tag is CustomAlbums. - /// - [HarmonyPatch(typeof(ThinkingDataPeripheralHelper), nameof(ThinkingDataPeripheralHelper.CommonSendString))] - internal class CommonSendStringPatch + private static bool Prefix(string eventName, string propertyName, string info) { - private static bool Prefix(string eventName, string propertyName, string info) - { - var runCond = info != AlbumManager.Languages["ChineseS"]; - if (!runCond) Logger.Msg("Blocked custom albums tag analytics."); - return runCond; - } + var runCond = info != AlbumManager.Languages["ChineseS"]; + if (!runCond) Logger.Msg("Blocked custom albums tag analytics."); + return runCond; } + } - /// - /// Blocks chart analytics from being sent if the chart is custom. - /// - [HarmonyPatch(typeof(ThinkingDataBattleHelper))] - internal class SendMDPatch + /// + /// Blocks chart analytics from being sent if the chart is custom. + /// + [HarmonyPatch(typeof(ThinkingDataBattleHelper))] + internal class SendMDPatch + { + private static IEnumerable TargetMethods() { - private static IEnumerable TargetMethods() - { - return typeof(ThinkingDataBattleHelper).GetMethods(BindingFlags.Instance | BindingFlags.Public) - .Where(method => method.Name.StartsWith("Send")); - } + return typeof(ThinkingDataBattleHelper).GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Where(method => method.Name.StartsWith("Send")); + } - private static bool Prefix() - { - var runCond = !BattleHelper.MusicInfo().uid.StartsWith($"{AlbumManager.Uid}-"); - if (!runCond) Logger.Msg("Blocked sending analytics of custom chart."); - return runCond; - } + private static bool Prefix() + { + var runCond = !BattleHelper.MusicInfo().uid.StartsWith($"{AlbumManager.Uid}-"); + if (!runCond) Logger.Msg("Blocked sending analytics of custom chart."); + return runCond; } + } - /// - /// Prevents the game from sending any analytics if the musicInfo is custom. - /// - [HarmonyPatch(typeof(ThinkingDataPeripheralHelper), nameof(ThinkingDataPeripheralHelper.PostToThinkingData))] - internal class PostToThinkingDataPatch + /// + /// Prevents the game from sending any analytics if the musicInfo is custom. + /// + [HarmonyPatch(typeof(ThinkingDataPeripheralHelper), nameof(ThinkingDataPeripheralHelper.PostToThinkingData))] + internal class PostToThinkingDataPatch + { + private static bool Prefix(string dataStatisticsEventDefinesName, MusicInfo musicInfo) { - private static bool Prefix(string dataStatisticsEventDefinesName, MusicInfo musicInfo) - { - var runCond = !musicInfo.uid.StartsWith($"{AlbumManager.Uid}-"); - if (!runCond) Logger.Msg("Blocked thinking data post of custom chart."); - return runCond; - } + var runCond = !musicInfo.uid.StartsWith($"{AlbumManager.Uid}-"); + if (!runCond) Logger.Msg("Blocked thinking data post of custom chart."); + return runCond; } + } - /// - /// Prevents the game from sending any analytics if the favorited chart is custom. - /// - [HarmonyPatch(typeof(ThinkingDataPeripheralHelper), - nameof(ThinkingDataPeripheralHelper.SendFavoriteMusicBehavior))] - internal class SendFavoriteMusicBehaviorPatch + /// + /// Prevents the game from sending any analytics if the favorited chart is custom. + /// + [HarmonyPatch(typeof(ThinkingDataPeripheralHelper), + nameof(ThinkingDataPeripheralHelper.SendFavoriteMusicBehavior))] + internal class SendFavoriteMusicBehaviorPatch + { + private static bool Prefix(string dataStatisticsEventDefinesNameMusicInfo, MusicInfo musicInfo) { - private static bool Prefix(string dataStatisticsEventDefinesNameMusicInfo, MusicInfo musicInfo) - { - var runCond = !musicInfo.uid.StartsWith($"{AlbumManager.Uid}-"); - if (!runCond) Logger.Msg("Blocked sending favorite chart analytics of custom chart."); - return runCond; - } + var runCond = !musicInfo.uid.StartsWith($"{AlbumManager.Uid}-"); + if (!runCond) Logger.Msg("Blocked sending favorite chart analytics of custom chart."); + return runCond; } + } - /// - /// Prevents the game from sending any analytics if the musicInfo is custom. - /// - [HarmonyPatch(typeof(ThinkingDataPeripheralHelper), nameof(ThinkingDataPeripheralHelper.PostMusicChooseInfo))] - internal class PostMusicChooseInfoPatch + /// + /// Prevents the game from sending any analytics if the musicInfo is custom. + /// + [HarmonyPatch(typeof(ThinkingDataPeripheralHelper), nameof(ThinkingDataPeripheralHelper.PostMusicChooseInfo))] + internal class PostMusicChooseInfoPatch + { + private static bool Prefix(Vector2Int diffValue, string chooseMusicType, string searchName, MusicInfo musicInfo) { - private static bool Prefix(Vector2Int diffValue, string chooseMusicType, string searchName, MusicInfo musicInfo) - { - var runCond = !musicInfo.uid.StartsWith($"{AlbumManager.Uid}-"); - if (!runCond) Logger.Msg("Blocked sending chosen music from search analytics of custom chart."); - return runCond; - } + var runCond = !musicInfo.uid.StartsWith($"{AlbumManager.Uid}-"); + if (!runCond) Logger.Msg("Blocked sending chosen music from search analytics of custom chart."); + return runCond; } + } - /// - /// Cleans search result analytics of custom charts. - /// - [HarmonyPatch(typeof(ThinkingDataPeripheralHelper), nameof(ThinkingDataPeripheralHelper.GetSearchResultInfo))] - internal class GetSearchResultInfoPatch + /// + /// Cleans search result analytics of custom charts. + /// + [HarmonyPatch(typeof(ThinkingDataPeripheralHelper), nameof(ThinkingDataPeripheralHelper.GetSearchResultInfo))] + internal class GetSearchResultInfoPatch + { + private static bool Prefix(MusicInfo musicInfo) { - - private static bool Prefix(MusicInfo musicInfo) - { - var runCond = !musicInfo.uid.StartsWith($"{AlbumManager.Uid}-"); - if (!runCond) Logger.Msg("Blocking custom album from being added to search analytics."); - return runCond; - } + var runCond = !musicInfo.uid.StartsWith($"{AlbumManager.Uid}-"); + if (!runCond) Logger.Msg("Blocking custom album from being added to search analytics."); + return runCond; } } -} +} \ No newline at end of file diff --git a/Patches/AssetPatch.cs b/Patches/AssetPatch.cs index b879cc7..8c84f5e 100644 --- a/Patches/AssetPatch.cs +++ b/Patches/AssetPatch.cs @@ -21,351 +21,351 @@ using Logger = CustomAlbums.Utilities.Logger; using Object = UnityEngine.Object; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +internal class AssetPatch { - internal class AssetPatch + private static readonly NativeHook Hook = new(); + private static readonly Dictionary AssetCache = new(); + private static readonly Dictionary> AssetHandler = new(); + private static readonly Logger Logger = new(nameof(AssetPatch)); + + private static int _latestAlbumNum; + private static bool _doneLoadingAlbumsFlag; + internal static Events.LoadAssetEvent OnAssetLoaded; + + /// + /// Binds the asset to a new key, while removing the old asset. + /// + /// The old assetName. + /// The new assetName where the value should be bound. + /// if the key was successfully bound; otherwise, . + internal static bool ModifyCacheKey(string oldAssetName, string newAssetName) { - private static readonly NativeHook Hook = new(); - private static readonly Dictionary AssetCache = new(); - private static readonly Dictionary> AssetHandler = new(); - private static readonly Logger Logger = new(nameof(AssetPatch)); - - private static int _latestAlbumNum; - private static bool _doneLoadingAlbumsFlag; - internal static Events.LoadAssetEvent OnAssetLoaded; - - /// - /// Binds the asset to a new key, while removing the old asset. - /// - /// The old assetName. - /// The new assetName where the value should be bound. - /// if the key was successfully bound; otherwise, . - internal static bool ModifyCacheKey(string oldAssetName, string newAssetName) - { - var success = AssetCache.Remove(oldAssetName, out var asset); - return success && AssetCache.TryAdd(newAssetName, asset); - } + var success = AssetCache.Remove(oldAssetName, out var asset); + return success && AssetCache.TryAdd(newAssetName, asset); + } - /// - /// Updates the cache with a new asset. - /// - internal static void UpdateCache(string key, Object value) - { - AssetCache[key] = value; - } + /// + /// Updates the cache with a new asset. + /// + internal static void UpdateCache(string key, Object value) + { + AssetCache[key] = value; + } - /// - /// Removes a KeyValuePair from the asset cache. - /// - /// The key corresponding to the value to be removed. - /// if the entry was successfully removed; otherwise, . - internal static bool RemoveFromCache(string key) - { - return AssetCache.Remove(key); - } + /// + /// Removes a KeyValuePair from the asset cache. + /// + /// The key corresponding to the value to be removed. + /// if the entry was successfully removed; otherwise, . + internal static bool RemoveFromCache(string key) + { + return AssetCache.Remove(key); + } - /// - /// Adds methods to the AssetHandler. - /// The AssetHandler modifies certain assets based on their name. - /// - internal static void InitializeHandler() + /// + /// Adds methods to the AssetHandler. + /// The AssetHandler modifies certain assets based on their name. + /// + internal static void InitializeHandler() + { + AssetHandler.Add("albums", (assetName, assetPtr, _) => { - AssetHandler.Add("albums", (assetName, assetPtr, _) => + var jsonArray = Json.Deserialize(new TextAsset(assetPtr).text); + + // adds the CustomAlbums "dlc" to the game + jsonArray.Add(new { - var jsonArray = Json.Deserialize(new TextAsset(assetPtr).text); + uid = AlbumManager.MusicPackage, + title = "Custom Albums", + prefabsName = $"AlbumDisco{AlbumManager.Uid}", + price = "¥25.00", + jsonName = AlbumManager.JsonName, + needPurchase = false, + free = true + }); - // adds the CustomAlbums "dlc" to the game - jsonArray.Add(new - { - uid = AlbumManager.MusicPackage, - title = "Custom Albums", - prefabsName = $"AlbumDisco{AlbumManager.Uid}", - price = "¥25.00", - jsonName = AlbumManager.JsonName, - needPurchase = false, - free = true - }); + // Create and add the new asset with the CustomAlbums "dlc" + var newAsset = CreateTextAsset(assetName, JsonSerializer.Serialize(jsonArray)); + if (!Singleton.instance.m_Dictionary.ContainsKey(assetName)) + Singleton.instance.Add(assetName, newAsset.text); - // Create and add the new asset with the CustomAlbums "dlc" - var newAsset = CreateTextAsset(assetName, JsonSerializer.Serialize(jsonArray)); - if (!Singleton.instance.m_Dictionary.ContainsKey(assetName)) - Singleton.instance.Add(assetName, newAsset.text); + // Set cache and return newAsset's pointer if it non-null + AssetCache[assetName] = newAsset; + return newAsset?.Pointer ?? assetPtr; + }); - // Set cache and return newAsset's pointer if it non-null - AssetCache[assetName] = newAsset; - return newAsset?.Pointer ?? assetPtr; - }); + AssetHandler.Add(AlbumManager.JsonName, (assetName, assetPtr, _) => + { + var jsonArray = new JsonArray(); - AssetHandler.Add(AlbumManager.JsonName, (assetName, assetPtr, _) => + // Add each custom charts' data to the album + foreach (var (albumStr, albumObj) in AlbumManager.LoadedAlbums) { - var jsonArray = new JsonArray(); - - // Add each custom charts' data to the album - foreach (var (albumStr, albumObj) in AlbumManager.LoadedAlbums) + var albumInfo = albumObj.Info; + var customChartJson = new { - var albumInfo = albumObj.Info; - var customChartJson = new - { - uid = albumObj.Uid, - name = albumInfo.Name, - author = albumInfo.Author, - bpm = albumInfo.Bpm, - music = $"{albumStr}_music", - demo = $"{albumStr}_demo", - cover = $"{albumStr}_cover", - noteJson = $"{albumStr}_map", - scene = albumInfo.Scene, - unlockLevel = "0", - levelDesigner = albumInfo.LevelDesigner, - levelDesigner1 = albumInfo.LevelDesigner1 ?? albumInfo.LevelDesigner, - levelDesigner2 = albumInfo.LevelDesigner2 ?? albumInfo.LevelDesigner, - levelDesigner3 = albumInfo.LevelDesigner3 ?? albumInfo.LevelDesigner, - levelDesigner4 = albumInfo.LevelDesigner4 ?? albumInfo.LevelDesigner, - levelDesigner5 = albumInfo.LevelDesigner5 ?? albumInfo.LevelDesigner, - difficulty1 = albumInfo.Difficulty1 ?? "0", - difficulty2 = albumInfo.Difficulty2, - difficulty3 = albumInfo.Difficulty3 ?? "0", - difficulty4 = albumInfo.Difficulty4 ?? "0", - difficulty5 = albumInfo.Difficulty5 ?? "0" - }; - jsonArray.Add(customChartJson); - - // Configure the searchtaginfo - var config = Singleton.instance.GetConfigObject(); - var searchTag = new MusicSearchTagInfo - { - uid = customChartJson.uid, - listIndex = config.count - }; + uid = albumObj.Uid, + name = albumInfo.Name, + author = albumInfo.Author, + bpm = albumInfo.Bpm, + music = $"{albumStr}_music", + demo = $"{albumStr}_demo", + cover = $"{albumStr}_cover", + noteJson = $"{albumStr}_map", + scene = albumInfo.Scene, + unlockLevel = "0", + levelDesigner = albumInfo.LevelDesigner, + levelDesigner1 = albumInfo.LevelDesigner1 ?? albumInfo.LevelDesigner, + levelDesigner2 = albumInfo.LevelDesigner2 ?? albumInfo.LevelDesigner, + levelDesigner3 = albumInfo.LevelDesigner3 ?? albumInfo.LevelDesigner, + levelDesigner4 = albumInfo.LevelDesigner4 ?? albumInfo.LevelDesigner, + levelDesigner5 = albumInfo.LevelDesigner5 ?? albumInfo.LevelDesigner, + difficulty1 = albumInfo.Difficulty1 ?? "0", + difficulty2 = albumInfo.Difficulty2, + difficulty3 = albumInfo.Difficulty3 ?? "0", + difficulty4 = albumInfo.Difficulty4 ?? "0", + difficulty5 = albumInfo.Difficulty5 ?? "0" + }; + jsonArray.Add(customChartJson); + + // Configure the searchtaginfo + var config = Singleton.instance.GetConfigObject(); + var searchTag = new MusicSearchTagInfo + { + uid = customChartJson.uid, + listIndex = config.count + }; - // Create the search tag itself - var tags = new List { "custom albums" }; - if (albumInfo.SearchTags != null) tags.AddRange(albumInfo.SearchTags); - if (!string.IsNullOrEmpty(albumInfo.NameRomanized)) tags.Add(albumInfo.NameRomanized); - for (var i = 0; i < tags.Count; i++) tags[i] = tags[i].ToLower(); + // Create the search tag itself + var tags = new List { "custom albums" }; + if (albumInfo.SearchTags != null) tags.AddRange(albumInfo.SearchTags); + if (!string.IsNullOrEmpty(albumInfo.NameRomanized)) tags.Add(albumInfo.NameRomanized); + for (var i = 0; i < tags.Count; i++) tags[i] = tags[i].ToLower(); - searchTag.tag = new Il2CppStringArray(tags.ToArray()); + searchTag.tag = new Il2CppStringArray(tags.ToArray()); - // Add it to the game - config.m_Dictionary.Add(searchTag.uid, searchTag); - config.list.Add(searchTag); - } + // Add it to the game + config.m_Dictionary.Add(searchTag.uid, searchTag); + config.list.Add(searchTag); + } - // Create and add the new asset with the custom charts' data - var newAsset = CreateTextAsset(assetName, JsonSerializer.Serialize(jsonArray)); - if (!Singleton.instance.m_Dictionary.ContainsKey(assetName)) - Singleton.instance.Add(assetName, newAsset.text); + // Create and add the new asset with the custom charts' data + var newAsset = CreateTextAsset(assetName, JsonSerializer.Serialize(jsonArray)); + if (!Singleton.instance.m_Dictionary.ContainsKey(assetName)) + Singleton.instance.Add(assetName, newAsset.text); - // Set cache and return newAsset's pointer if it non-null - AssetCache[assetName] = newAsset; - return newAsset?.Pointer ?? assetPtr; - }); + // Set cache and return newAsset's pointer if it non-null + AssetCache[assetName] = newAsset; + return newAsset?.Pointer ?? assetPtr; + }); + + AssetHandler.Add("albums_", (assetName, assetPtr, language) => + { + var jsonArray = Json.Deserialize(new TextAsset(assetPtr).text); - AssetHandler.Add("albums_", (assetName, assetPtr, language) => + // Adds the correct language for the "Custom Albums" album + jsonArray.Add(new { - var jsonArray = Json.Deserialize(new TextAsset(assetPtr).text); + title = AlbumManager.Languages[language] + }); + + // Create and add the new asset with the correct lingual name of "Custom Albums" + var newAsset = CreateTextAsset(assetName, JsonSerializer.Serialize(jsonArray)); + if (!Singleton.instance.m_Dictionary.ContainsKey(assetName)) + Singleton.instance.Add(assetName, newAsset.text); + + // Set cache and return newAsset's pointer if it non-null + AssetCache[assetName] = newAsset; + return newAsset?.Pointer ?? assetPtr; + }); + + AssetHandler.Add($"{AlbumManager.JsonName}_", (assetName, assetPtr, _) => + { + var jsonArray = new JsonArray(); - // Adds the correct language for the "Custom Albums" album + // This should technically be written to retrieve and add the name of the chart based on your selected language + // However this only grabs whatever name is given in the info.json + // Very likely not a big deal + foreach (var (_, value) in AlbumManager.LoadedAlbums) jsonArray.Add(new { - title = AlbumManager.Languages[language] + name = value.Info.Name, + author = value.Info.Author }); - // Create and add the new asset with the correct lingual name of "Custom Albums" - var newAsset = CreateTextAsset(assetName, JsonSerializer.Serialize(jsonArray)); - if (!Singleton.instance.m_Dictionary.ContainsKey(assetName)) - Singleton.instance.Add(assetName, newAsset.text); - - // Set cache and return newAsset's pointer if it non-null - AssetCache[assetName] = newAsset; - return newAsset?.Pointer ?? assetPtr; - }); - - AssetHandler.Add($"{AlbumManager.JsonName}_", (assetName, assetPtr, _) => - { - var jsonArray = new JsonArray(); + // Create and add the new asset with the names of each custom chart + var newAsset = CreateTextAsset(assetName, JsonSerializer.Serialize(jsonArray)); + if (!Singleton.instance.m_Dictionary.ContainsKey(assetName)) + Singleton.instance.Add(assetName, newAsset.text); - // This should technically be written to retrieve and add the name of the chart based on your selected language - // However this only grabs whatever name is given in the info.json - // Very likely not a big deal - foreach (var (_, value) in AlbumManager.LoadedAlbums) - jsonArray.Add(new - { - name = value.Info.Name, - author = value.Info.Author - }); - - // Create and add the new asset with the names of each custom chart - var newAsset = CreateTextAsset(assetName, JsonSerializer.Serialize(jsonArray)); - if (!Singleton.instance.m_Dictionary.ContainsKey(assetName)) - Singleton.instance.Add(assetName, newAsset.text); - - // Set cache and return newAsset's pointer if it non-null - AssetCache[assetName] = newAsset; - return newAsset?.Pointer ?? assetPtr; - }); + // Set cache and return newAsset's pointer if it non-null + AssetCache[assetName] = newAsset; + return newAsset?.Pointer ?? assetPtr; + }); - AssetHandler.Add("album_", (assetName, assetPtr, _) => + AssetHandler.Add("album_", (assetName, assetPtr, _) => + { + var cache = true; + Object newAsset = null; + var suffix = AssetIdentifiers.AssetSuffixes.FirstOrDefault(assetName.EndsWith); + if (!string.IsNullOrEmpty(suffix)) { - var cache = true; - Object newAsset = null; - var suffix = AssetIdentifiers.AssetSuffixes.FirstOrDefault(assetName.EndsWith); - if (!string.IsNullOrEmpty(suffix)) + var albumKey = assetName.Remove(assetName.Length - suffix.Length); + AlbumManager.LoadedAlbums.TryGetValue(albumKey, out var album); + if (suffix.StartsWith("_map")) { - var albumKey = assetName.Remove(assetName.Length - suffix.Length); - AlbumManager.LoadedAlbums.TryGetValue(albumKey, out var album); - if (suffix.StartsWith("_map")) - { - newAsset = album?.Sheets.GetValueOrDefault(suffix[^1].ToString().ParseAsInt())?.GetStage(); - // Do not cache the StageInfos, this should be loaded into memory only when we need it - cache = false; - } - else + newAsset = album?.Sheets.GetValueOrDefault(suffix[^1].ToString().ParseAsInt())?.GetStage(); + // Do not cache the StageInfos, this should be loaded into memory only when we need it + cache = false; + } + else + { + // Set the newAsset to the objects held in the album + switch (suffix) { - // Set the newAsset to the objects held in the album - switch (suffix) - { - case "_demo": - newAsset = album?.Demo; - break; - case "_music": - newAsset = album?.Music; - break; - case "_cover": - newAsset = album?.Cover; - break; - default: - Logger.Error($"Unknown suffix: {suffix}"); - break; - } + case "_demo": + newAsset = album?.Demo; + break; + case "_music": + newAsset = album?.Music; + break; + case "_cover": + newAsset = album?.Cover; + break; + default: + Logger.Error($"Unknown suffix: {suffix}"); + break; } } + } - // Set cache if we should and return newAsset's pointer if it non-null - if (cache) AssetCache[assetName] = newAsset; - return newAsset?.Pointer ?? assetPtr; - }); - } + // Set cache if we should and return newAsset's pointer if it non-null + if (cache) AssetCache[assetName] = newAsset; + return newAsset?.Pointer ?? assetPtr; + }); + } - /// - /// Gets where T : and detours it using a - /// where T : to LoadFromNamePatch. - /// - internal static unsafe void AttachHook() - { - var loadFromNameMethod = AccessTools.Method(typeof(ResourcesManager), - nameof(ResourcesManager.LoadFromName), new[] { typeof(string) }, - new[] { typeof(TextAsset) }); + /// + /// Gets where T : and detours it using a + /// where T : to LoadFromNamePatch. + /// + internal static unsafe void AttachHook() + { + var loadFromNameMethod = AccessTools.Method(typeof(ResourcesManager), + nameof(ResourcesManager.LoadFromName), new[] { typeof(string) }, + new[] { typeof(TextAsset) }); - if (loadFromNameMethod is null) - { - Logger.Error("FATAL ERROR: AssetPatch failed."); - Thread.Sleep(1000); - Environment.Exit(1); - } + if (loadFromNameMethod is null) + { + Logger.Error("FATAL ERROR: AssetPatch failed."); + Thread.Sleep(1000); + Environment.Exit(1); + } - // AttachHook should only be run once; create the handler - InitializeHandler(); + // AttachHook should only be run once; create the handler + InitializeHandler(); - var loadFromNamePointer = *(IntPtr*)(IntPtr)Il2CppInteropUtils - .GetIl2CppMethodInfoPointerFieldForGeneratedMethod(loadFromNameMethod).GetValue(null)!; + var loadFromNamePointer = *(IntPtr*)(IntPtr)Il2CppInteropUtils + .GetIl2CppMethodInfoPointerFieldForGeneratedMethod(loadFromNameMethod).GetValue(null)!; - // Create a pointer for our new method to be called instead - // This is Cdecl because this is going to be called in an unmanaged context - delegate* unmanaged[Cdecl] detourPointer = &LoadFromNamePatch; + // Create a pointer for our new method to be called instead + // This is Cdecl because this is going to be called in an unmanaged context + delegate* unmanaged[Cdecl] detourPointer = &LoadFromNamePatch; - // Set the hook so that LoadFromNamePatch runs instead of the original LoadFromName - Hook.Detour = (IntPtr)detourPointer; - Hook.Target = loadFromNamePointer; - Hook.Attach(); - } + // Set the hook so that LoadFromNamePatch runs instead of the original LoadFromName + Hook.Detour = (IntPtr)detourPointer; + Hook.Target = loadFromNamePointer; + Hook.Attach(); + } - /// - /// Modifies certain game data as it get loaded. - /// The game data that is modified directly adds support for custom albums. - /// - /// The instance of ResourceManager calling LoadFromName. - /// The pointer to the string assetName. - /// Method info used by the original method. - /// - /// An of either a newly created asset if it exists or the original asset if a new one was not - /// created. - /// - [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] - private static IntPtr LoadFromNamePatch(IntPtr instance, IntPtr assetNamePtr, IntPtr nativeMethodInfo) - { - // Retrieve the pointer of the asset and the name of the asset - var assetPtr = Hook.Trampoline(instance, assetNamePtr, nativeMethodInfo); - var assetName = IL2CPP.Il2CppStringToManaged(assetNamePtr) ?? string.Empty; + /// + /// Modifies certain game data as it get loaded. + /// The game data that is modified directly adds support for custom albums. + /// + /// The instance of ResourceManager calling LoadFromName. + /// The pointer to the string assetName. + /// Method info used by the original method. + /// + /// An of either a newly created asset if it exists or the original asset + /// if a new one was not + /// created. + /// + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static IntPtr LoadFromNamePatch(IntPtr instance, IntPtr assetNamePtr, IntPtr nativeMethodInfo) + { + // Retrieve the pointer of the asset and the name of the asset + var assetPtr = Hook.Trampoline(instance, assetNamePtr, nativeMethodInfo); + var assetName = IL2CPP.Il2CppStringToManaged(assetNamePtr) ?? string.Empty; - Logger.Msg($"Loading {assetName}!"); + Logger.Msg($"Loading {assetName}!"); - OnAssetLoaded?.Invoke(typeof(AssetPatch), new AssetEventArgs(assetName, assetPtr)); + OnAssetLoaded?.Invoke(typeof(AssetPatch), new AssetEventArgs(assetName, assetPtr)); - if (assetName.StartsWith("ALBUM") && assetName[5..].TryParseAsInt(out var albumNum) && - albumNum != AlbumManager.Uid + 1) - { - // If done loading albums, we've found the maximum actual album - // If there's an attempt to load other albums (there will be if you open the tag menu), early return zero pointer - // This provides a noticeable speedup when opening tags for the first time - if (_doneLoadingAlbumsFlag && albumNum > _latestAlbumNum) - return IntPtr.Zero; - - // Otherwise get the maximum number X of ALBUMX - _latestAlbumNum = Math.Max(_latestAlbumNum, albumNum); - } + if (assetName.StartsWith("ALBUM") && assetName[5..].TryParseAsInt(out var albumNum) && + albumNum != AlbumManager.Uid + 1) + { + // If done loading albums, we've found the maximum actual album + // If there's an attempt to load other albums (there will be if you open the tag menu), early return zero pointer + // This provides a noticeable speedup when opening tags for the first time + if (_doneLoadingAlbumsFlag && albumNum > _latestAlbumNum) + return IntPtr.Zero; + + // Otherwise get the maximum number X of ALBUMX + _latestAlbumNum = Math.Max(_latestAlbumNum, albumNum); + } - // If we're loading music_search_tag we're done loading albums - if (assetName == "music_search_tag") _doneLoadingAlbumsFlag = true; + // If we're loading music_search_tag we're done loading albums + if (assetName == "music_search_tag") _doneLoadingAlbumsFlag = true; - // If the asset exists in the cache then retrieve it - if (AssetCache.TryGetValue(assetName, out var cachedAsset)) + // If the asset exists in the cache then retrieve it + if (AssetCache.TryGetValue(assetName, out var cachedAsset)) + { + if (cachedAsset != null) { - if (cachedAsset != null) - { - Logger.Msg($"Returning {assetName} from cache"); - AudioManager.SwitchLoad(assetName); - return cachedAsset.Pointer; - } - - Logger.Msg("Removing null asset from cache"); - AssetCache.Remove(assetName); + Logger.Msg($"Returning {assetName} from cache"); + AudioManager.SwitchLoad(assetName); + return cachedAsset.Pointer; } - // Allow original LoadFromName to run with LocalizationSettings - if (assetName is "LocalizationSettings") return assetPtr; + Logger.Msg("Removing null asset from cache"); + AssetCache.Remove(assetName); + } - var language = SingletonScriptableObject.instance.GetActiveOption("Language"); + // Allow original LoadFromName to run with LocalizationSettings + if (assetName is "LocalizationSettings") return assetPtr; - // Programmatically alters the asset name to remove the language if the language exists - var handledAssetName = assetName.StartsWith("albums_") ? "albums_" : assetName; - handledAssetName = handledAssetName.StartsWith($"{AlbumManager.JsonName}_") - ? $"{AlbumManager.JsonName}_" - : handledAssetName; - handledAssetName = handledAssetName.StartsWith("album_") ? "album_" : handledAssetName; + var language = SingletonScriptableObject.instance.GetActiveOption("Language"); - // Get the method from the AssetHandler if it exists and run it, otherwise return assetPtr - return AssetHandler.TryGetValue(handledAssetName, out var value) - ? value(assetName, assetPtr, language) - : assetPtr; - } + // Programmatically alters the asset name to remove the language if the language exists + var handledAssetName = assetName.StartsWith("albums_") ? "albums_" : assetName; + handledAssetName = handledAssetName.StartsWith($"{AlbumManager.JsonName}_") + ? $"{AlbumManager.JsonName}_" + : handledAssetName; + handledAssetName = handledAssetName.StartsWith("album_") ? "album_" : handledAssetName; - /// - /// Simple helper method to create a TextAsset. - /// - /// - /// - /// A new initialized with the parameters. - private static TextAsset CreateTextAsset(string name, string text) - { - return new TextAsset(text) - { - name = name - }; - } + // Get the method from the AssetHandler if it exists and run it, otherwise return assetPtr + return AssetHandler.TryGetValue(handledAssetName, out var value) + ? value(assetName, assetPtr, language) + : assetPtr; + } - [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - private delegate IntPtr LoadFromNameDelegate(IntPtr instance, IntPtr assetNamePtr, IntPtr nativeMethodInfo); + /// + /// Simple helper method to create a TextAsset. + /// + /// + /// + /// A new initialized with the parameters. + private static TextAsset CreateTextAsset(string name, string text) + { + return new TextAsset(text) + { + name = name + }; } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate IntPtr LoadFromNameDelegate(IntPtr instance, IntPtr assetNamePtr, IntPtr nativeMethodInfo); } \ No newline at end of file diff --git a/Patches/ChartDialogPatch.cs b/Patches/ChartDialogPatch.cs index a368675..c6e24ae 100644 --- a/Patches/ChartDialogPatch.cs +++ b/Patches/ChartDialogPatch.cs @@ -6,69 +6,68 @@ using Il2CppAssets.Scripts.GameCore; using Il2CppAssets.Scripts.Structs; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +internal class ChartDialogPatch { - internal class ChartDialogPatch + /// + /// This method runs once when the chart loads, so init basic values to avoid computing again + /// + [HarmonyPatch(typeof(DialogMasterControl), nameof(DialogMasterControl.Init))] + internal class InitPatch { - /// - /// This method runs once when the chart loads, so init basic values to avoid computing again - /// - [HarmonyPatch(typeof(DialogMasterControl), nameof(DialogMasterControl.Init))] - internal class InitPatch + private static readonly Logger Logger = new(nameof(InitPatch)); + + private static void Prefix(DialogMasterControl __instance) { - private static readonly Logger Logger = new(nameof(InitPatch)); + Logger.Msg("Setting talk file values."); + var currentStageUid = GlobalDataBase.dbBattleStage.musicUid; + PlayDialogAnimPatch.CurrentStageInfo = GlobalDataBase.dbStageInfo.m_StageInfo; - private static void Prefix(DialogMasterControl __instance) + if (!currentStageUid.StartsWith($"{AlbumManager.Uid}-")) { - Logger.Msg("Setting talk file values."); - var currentStageUid = GlobalDataBase.dbBattleStage.musicUid; - PlayDialogAnimPatch.CurrentStageInfo = GlobalDataBase.dbStageInfo.m_StageInfo; - - if (!currentStageUid.StartsWith($"{AlbumManager.Uid}-")) - { - PlayDialogAnimPatch.HasVersion2 = false; - return; - } + PlayDialogAnimPatch.HasVersion2 = false; + return; + } - var currentAlbum = AlbumManager.GetByUid(currentStageUid); + var currentAlbum = AlbumManager.GetByUid(currentStageUid); - // Reset values that may have changed - PlayDialogAnimPatch.HasVersion2 = - (currentAlbum?.Sheets.TryGetValue(PlayDialogAnimPatch.CurrentStageInfo.difficulty, out var sheet) ?? - false) && sheet.TalkFileVersion2; - PlayDialogAnimPatch.Index = 0; - PlayDialogAnimPatch.CurrentLanguage = DataHelper.userLanguage; + // Reset values that may have changed + PlayDialogAnimPatch.HasVersion2 = + (currentAlbum?.Sheets.TryGetValue(PlayDialogAnimPatch.CurrentStageInfo.difficulty, out var sheet) ?? + false) && sheet.TalkFileVersion2; + PlayDialogAnimPatch.Index = 0; + PlayDialogAnimPatch.CurrentLanguage = DataHelper.userLanguage; - if (PlayDialogAnimPatch.CurrentStageInfo.dialogEvents == null) return; - // Set this here to avoid repeatedly checking a dictionary - PlayDialogAnimPatch.DialogEvents = - PlayDialogAnimPatch.CurrentStageInfo.dialogEvents.ContainsKey(PlayDialogAnimPatch.CurrentLanguage) - ? PlayDialogAnimPatch.CurrentStageInfo.dialogEvents[PlayDialogAnimPatch.CurrentLanguage] - : PlayDialogAnimPatch.CurrentStageInfo.dialogEvents["English"]; - } + if (PlayDialogAnimPatch.CurrentStageInfo.dialogEvents == null) return; + // Set this here to avoid repeatedly checking a dictionary + PlayDialogAnimPatch.DialogEvents = + PlayDialogAnimPatch.CurrentStageInfo.dialogEvents.ContainsKey(PlayDialogAnimPatch.CurrentLanguage) + ? PlayDialogAnimPatch.CurrentStageInfo.dialogEvents[PlayDialogAnimPatch.CurrentLanguage] + : PlayDialogAnimPatch.CurrentStageInfo.dialogEvents["English"]; } + } - /// - /// This patch allows support for setting the transparency of dialog boxes. Eventually, this patch will allow stronger - /// control of dialog boxes using the "version": 2 property. - /// - [HarmonyPatch(typeof(DialogSubControl), nameof(DialogSubControl.PlayDialogAnim))] - internal class PlayDialogAnimPatch - { - private static readonly Logger Logger = new(nameof(PlayDialogAnimPatch)); - internal static int Index { get; set; } - internal static StageInfo CurrentStageInfo { get; set; } - internal static bool HasVersion2 { get; set; } - internal static string CurrentLanguage { get; set; } = string.Empty; - internal static Il2CppSystem.Collections.Generic.List DialogEvents { get; set; } + /// + /// This patch allows support for setting the transparency of dialog boxes. Eventually, this patch will allow stronger + /// control of dialog boxes using the "version": 2 property. + /// + [HarmonyPatch(typeof(DialogSubControl), nameof(DialogSubControl.PlayDialogAnim))] + internal class PlayDialogAnimPatch + { + private static readonly Logger Logger = new(nameof(PlayDialogAnimPatch)); + internal static int Index { get; set; } + internal static StageInfo CurrentStageInfo { get; set; } + internal static bool HasVersion2 { get; set; } + internal static string CurrentLanguage { get; set; } = string.Empty; + internal static Il2CppSystem.Collections.Generic.List DialogEvents { get; set; } - private static void Prefix(DialogSubControl __instance) - { - if (!HasVersion2 || DialogEvents == null) return; + private static void Prefix(DialogSubControl __instance) + { + if (!HasVersion2 || DialogEvents == null) return; - __instance.m_BgImg.color = DialogEvents[Index++].bgColor; - } + __instance.m_BgImg.color = DialogEvents[Index++].bgColor; } } } \ No newline at end of file diff --git a/Patches/CoverRefreshPatch.cs b/Patches/CoverRefreshPatch.cs index 7d65523..38735ad 100644 --- a/Patches/CoverRefreshPatch.cs +++ b/Patches/CoverRefreshPatch.cs @@ -1,42 +1,36 @@ using HarmonyLib; -using System.Collections.Generic; using Il2Cpp; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +internal static class CoverRefreshPatch { - internal static class CoverRefreshPatch - { - private static readonly Dictionary ActiveCells = new(); + private static readonly Dictionary ActiveCells = new(); - [HarmonyPatch(typeof(MusicStageCell), nameof(MusicStageCell.SetCoverLogic))] - internal static class SetCoverLogicPatch - { - private static void Postfix(MusicStageCell __instance, MusicStageCell.MusicStageCellInfo cellInfo) - { - ActiveCells[__instance] = cellInfo.musicUid; - } - } + internal static void Refresh(string musicUid) + { + // Iterate over a copy to avoid enumeration modification exceptions if a cell destroys itself + var cells = new Dictionary(ActiveCells); + foreach (var kvp in cells) + if (kvp.Value == musicUid && kvp.Key != null && kvp.Key.gameObject.activeInHierarchy) + kvp.Key.ForceRefreshData(); + } - [HarmonyPatch(typeof(MusicStageCell), nameof(MusicStageCell.OnDestroy))] - internal static class OnDestroyPatch + [HarmonyPatch(typeof(MusicStageCell), nameof(MusicStageCell.SetCoverLogic))] + internal static class SetCoverLogicPatch + { + private static void Postfix(MusicStageCell __instance, MusicStageCell.MusicStageCellInfo cellInfo) { - private static void Postfix(MusicStageCell __instance) - { - ActiveCells.Remove(__instance); - } + ActiveCells[__instance] = cellInfo.musicUid; } + } - internal static void Refresh(string musicUid) + [HarmonyPatch(typeof(MusicStageCell), nameof(MusicStageCell.OnDestroy))] + internal static class OnDestroyPatch + { + private static void Postfix(MusicStageCell __instance) { - // Iterate over a copy to avoid enumeration modification exceptions if a cell destroys itself - var cells = new Dictionary(ActiveCells); - foreach (var kvp in cells) - { - if (kvp.Value == musicUid && kvp.Key != null && kvp.Key.gameObject.activeInHierarchy) - { - kvp.Key.ForceRefreshData(); - } - } + ActiveCells.Remove(__instance); } } -} +} \ No newline at end of file diff --git a/Patches/DifficultyGradeIconPatch.cs b/Patches/DifficultyGradeIconPatch.cs index 98faa44..c1b38f9 100644 --- a/Patches/DifficultyGradeIconPatch.cs +++ b/Patches/DifficultyGradeIconPatch.cs @@ -1,4 +1,5 @@ -using CustomAlbums.Managers; +using CustomAlbums.Data; +using CustomAlbums.Managers; using CustomAlbums.Utilities; using HarmonyLib; using Il2Cpp; @@ -6,66 +7,67 @@ using Il2CppAssets.Scripts.PeroTools.Commons; using Il2CppAssets.Scripts.UI.Panels; -namespace CustomAlbums.Patches -{ +namespace CustomAlbums.Patches; - [HarmonyPatch(typeof(PnlStage), nameof(PnlStage.RefreshDiffUI))] - internal class DifficultyGradeIconPatch - { - private static int GetEvaluate(Dictionary highest, int diff) => highest.GetValueOrDefault(diff)?.Evaluate ?? -1; +[HarmonyPatch(typeof(PnlStage), nameof(PnlStage.RefreshDiffUI))] +internal class DifficultyGradeIconPatch +{ + private static int GetEvaluate(Dictionary highest, int diff) + { + return highest.GetValueOrDefault(diff)?.Evaluate ?? -1; + } - /// - /// Enables the S logos on the difficulties for custom charts. - /// - // ReSharper disable once InconsistentNaming - private static void Postfix(MusicInfo musicInfo, PnlStage __instance) - { - var uid = musicInfo?.uid; - var specialSongInstance = Singleton.instance; + /// + /// Enables the S logos on the difficulties for custom charts. + /// + // ReSharper disable once InconsistentNaming + private static void Postfix(MusicInfo musicInfo, PnlStage __instance) + { + var uid = musicInfo?.uid; + var specialSongInstance = Singleton.instance; - if (string.IsNullOrEmpty(uid)) return; + if (string.IsNullOrEmpty(uid)) return; - // For custom charts, we need to set the S logos for each difficulty - if (uid.StartsWith($"{AlbumManager.Uid}-")) - { - // Gets the highest data from save data - var customHighest = SaveManager.SaveData.GetChartSaveDataFromUid(uid)?.Highest; + // For custom charts, we need to set the S logos for each difficulty + if (uid.StartsWith($"{AlbumManager.Uid}-")) + { + // Gets the highest data from save data + var customHighest = SaveManager.SaveData.GetChartSaveDataFromUid(uid)?.Highest; - if (customHighest == null) return; + if (customHighest == null) return; - // Get the Evaluate value from each, set diff3 to diff4 if hidden is invoked - var diff1 = GetEvaluate(customHighest, 1); - var diff2 = GetEvaluate(customHighest, 2); - var diff3 = Singleton.instance.IsInvokeHideBms(uid) - ? GetEvaluate(customHighest, 4) - : GetEvaluate(customHighest, 3); + // Get the Evaluate value from each, set diff3 to diff4 if hidden is invoked + var diff1 = GetEvaluate(customHighest, 1); + var diff2 = GetEvaluate(customHighest, 2); + var diff3 = Singleton.instance.IsInvokeHideBms(uid) + ? GetEvaluate(customHighest, 4) + : GetEvaluate(customHighest, 3); - // Set the S logos for each difficulty - __instance.m_Diff1Item.ChangeSchemeByEvalue(diff1, __instance.globalData); - __instance.m_Diff2Item.ChangeSchemeByEvalue(diff2, __instance.globalData); - __instance.m_Diff3Item.ChangeSchemeByEvalue(diff3, __instance.globalData); - } + // Set the S logos for each difficulty + __instance.m_Diff1Item.ChangeSchemeByEvalue(diff1, __instance.globalData); + __instance.m_Diff2Item.ChangeSchemeByEvalue(diff2, __instance.globalData); + __instance.m_Diff3Item.ChangeSchemeByEvalue(diff3, __instance.globalData); + } - // For vanilla and custom charts, this fixes the hidden difficulty icon for non-master charts - if (!specialSongInstance.IsInvokeHideBms(uid)) return; - if (!specialSongInstance.m_HideBmsInfos.TryGetValue(uid, out var info)) return; + // For vanilla and custom charts, this fixes the hidden difficulty icon for non-master charts + if (!specialSongInstance.IsInvokeHideBms(uid)) return; + if (!specialSongInstance.m_HideBmsInfos.TryGetValue(uid, out var info)) return; - // Get the difficulty evaluates - var hiddenEval = DataHelper.highest.GetIDataByUid(uid, 4).ToChartSave().Evaluate; - var masterEval = DataHelper.highest.GetIDataByUid(uid, 3).ToChartSave().Evaluate; + // Get the difficulty evaluates + var hiddenEval = DataHelper.highest.GetIDataByUid(uid, 4).ToChartSave().Evaluate; + var masterEval = DataHelper.highest.GetIDataByUid(uid, 3).ToChartSave().Evaluate; - // The game handles case 3 already, don't need to reinvent the wheel here - switch (info.triggerDiff) - { - case 1: - __instance.m_Diff3Item.ChangeSchemeByEvalue(masterEval, __instance.globalData); - __instance.m_Diff1Item.ChangeSchemeByEvalue(hiddenEval, __instance.globalData); - break; - case 2: - __instance.m_Diff3Item.ChangeSchemeByEvalue(masterEval, __instance.globalData); - __instance.m_Diff2Item.ChangeSchemeByEvalue(hiddenEval, __instance.globalData); - break; - } + // The game handles case 3 already, don't need to reinvent the wheel here + switch (info.triggerDiff) + { + case 1: + __instance.m_Diff3Item.ChangeSchemeByEvalue(masterEval, __instance.globalData); + __instance.m_Diff1Item.ChangeSchemeByEvalue(hiddenEval, __instance.globalData); + break; + case 2: + __instance.m_Diff3Item.ChangeSchemeByEvalue(masterEval, __instance.globalData); + __instance.m_Diff2Item.ChangeSchemeByEvalue(hiddenEval, __instance.globalData); + break; } } } \ No newline at end of file diff --git a/Patches/FilterPatch.cs b/Patches/FilterPatch.cs index 6a9614f..778fbc3 100644 --- a/Patches/FilterPatch.cs +++ b/Patches/FilterPatch.cs @@ -1,110 +1,103 @@ -using System; using CustomAlbums.Managers; using HarmonyLib; using Il2Cpp; using Il2CppAssets.Scripts.Database; using Il2CppAssets.Scripts.Helpers; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +internal class FilterPatch { - internal class FilterPatch + /// + /// Applies "Assisted screening" filters to custom charts + /// + [HarmonyPatch(typeof(MusicTagHelper), nameof(MusicTagHelper.AdditionalFilter))] + internal static class AdditionalFilterPatch { - /// - /// Applies "Assisted screening" filters to custom charts - /// - [HarmonyPatch(typeof(MusicTagHelper), nameof(MusicTagHelper.AdditionalFilter))] - internal static class AdditionalFilterPatch + private static bool AllMatchingMeetCondition(string albumName, MusicInfo musicInfo, int starLevel, + Func condition) { - private static bool AllMatchingMeetCondition(string albumName, MusicInfo musicInfo, int starLevel, Func condition) - { - var starStr = starLevel.ToString(); - var foundMatchingStar = false; - var allMeet = true; + var starStr = starLevel.ToString(); + var foundMatchingStar = false; + var allMeet = true; - for (var i = 1; i <= 4; i++) + for (var i = 1; i <= 4; i++) + { + var diffStr = i switch { - var diffStr = i switch - { - 1 => musicInfo.difficulty1, - 2 => musicInfo.difficulty2, - 3 => musicInfo.difficulty3, - 4 => musicInfo.difficulty4, - _ => "0" - }; + 1 => musicInfo.difficulty1, + 2 => musicInfo.difficulty2, + 3 => musicInfo.difficulty3, + 4 => musicInfo.difficulty4, + _ => "0" + }; - if (diffStr == starStr || (starLevel == 0 && diffStr == "?")) - { - foundMatchingStar = true; - if (!condition(albumName, i)) - { - allMeet = false; - } - } + if (diffStr == starStr || (starLevel == 0 && diffStr == "?")) + { + foundMatchingStar = true; + if (!condition(albumName, i)) allMeet = false; } - - return foundMatchingStar && allMeet; } - private static void Postfix(Il2CppSystem.Collections.Generic.List buffer, int difficultyIndex) - { - var filter = MusicTagHelper.GetAdditionalConditions(); - if (filter == DifficultyFilterDefines.All) return; + return foundMatchingStar && allMeet; + } - // Iterate backward to safely remove elements - for (var i = buffer.Count - 1; i >= 0; i--) - { - var musicInfo = buffer[i]; - if (!musicInfo.uid.StartsWith($"{AlbumManager.Uid}-")) continue; + private static void Postfix(Il2CppSystem.Collections.Generic.List buffer, int difficultyIndex) + { + var filter = MusicTagHelper.GetAdditionalConditions(); + if (filter == DifficultyFilterDefines.All) return; - var albumName = AlbumManager.GetAlbumNameFromUid(musicInfo.uid); - var remove = false; + // Iterate backward to safely remove elements + for (var i = buffer.Count - 1; i >= 0; i--) + { + var musicInfo = buffer[i]; + if (!musicInfo.uid.StartsWith($"{AlbumManager.Uid}-")) continue; - switch (filter) - { - case DifficultyFilterDefines.NoClear: - remove = AllMatchingMeetCondition(albumName, musicInfo, difficultyIndex, (album, slot) => - { - return SaveManager.SaveData.Highest.TryGetValue(album, out var highest) - && highest.ContainsKey(slot); - }); - break; + var albumName = AlbumManager.GetAlbumNameFromUid(musicInfo.uid); + var remove = false; - case DifficultyFilterDefines.NoFullCombo: - remove = AllMatchingMeetCondition(albumName, musicInfo, difficultyIndex, (album, slot) => - { - return SaveManager.SaveData.FullCombo.TryGetValue(album, out var fcs) - && fcs.Contains(slot); - }); - break; + switch (filter) + { + case DifficultyFilterDefines.NoClear: + remove = AllMatchingMeetCondition(albumName, musicInfo, difficultyIndex, (album, slot) => + { + return SaveManager.SaveData.Highest.TryGetValue(album, out var highest) + && highest.ContainsKey(slot); + }); + break; - case DifficultyFilterDefines.NoRankS: - remove = AllMatchingMeetCondition(albumName, musicInfo, difficultyIndex, (album, slot) => - { - return SaveManager.SaveData.Highest.TryGetValue(album, out var highest) - && highest.TryGetValue(slot, out var chartSave) - && chartSave.Evaluate >= 4; - }); - break; + case DifficultyFilterDefines.NoFullCombo: + remove = AllMatchingMeetCondition(albumName, musicInfo, difficultyIndex, (album, slot) => + { + return SaveManager.SaveData.FullCombo.TryGetValue(album, out var fcs) + && fcs.Contains(slot); + }); + break; - case DifficultyFilterDefines.NoAllPerfect: - remove = AllMatchingMeetCondition(albumName, musicInfo, difficultyIndex, (album, slot) => - { - return SaveManager.SaveData.Highest.TryGetValue(album, out var highest) - && highest.TryGetValue(slot, out var chartSave) - && chartSave.Accuracy >= 100f; - }); - break; + case DifficultyFilterDefines.NoRankS: + remove = AllMatchingMeetCondition(albumName, musicInfo, difficultyIndex, (album, slot) => + { + return SaveManager.SaveData.Highest.TryGetValue(album, out var highest) + && highest.TryGetValue(slot, out var chartSave) + && chartSave.Evaluate >= 4; + }); + break; - case DifficultyFilterDefines.NoLevelAchievement: - break; - } + case DifficultyFilterDefines.NoAllPerfect: + remove = AllMatchingMeetCondition(albumName, musicInfo, difficultyIndex, (album, slot) => + { + return SaveManager.SaveData.Highest.TryGetValue(album, out var highest) + && highest.TryGetValue(slot, out var chartSave) + && chartSave.Accuracy >= 100f; + }); + break; - if (remove) - { - buffer.RemoveAt(i); - } + case DifficultyFilterDefines.NoLevelAchievement: + break; } + + if (remove) buffer.RemoveAt(i); } } } -} +} \ No newline at end of file diff --git a/Patches/HiddenSupportPatch.cs b/Patches/HiddenSupportPatch.cs index f5cb7e8..87f48e4 100644 --- a/Patches/HiddenSupportPatch.cs +++ b/Patches/HiddenSupportPatch.cs @@ -8,210 +8,209 @@ using Il2CppAssets.Scripts.UI.Tips; using Il2CppInterop.Runtime.InteropTypes.Arrays; using Il2CppPeroPeroGames.GlobalDefines; -using MelonLoader; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +internal class HiddenSupportPatch { - internal class HiddenSupportPatch + internal static HashSet LoadedHiddens = new(); + + internal static void UpdateHiddenCharts() { - internal static HashSet LoadedHiddens = new(); + HideBmsInfoDicPatch.HasUpdate = true; + MusicTagPatch.HasUpdate = true; + } - internal static void UpdateHiddenCharts() - { - HideBmsInfoDicPatch.HasUpdate = true; - MusicTagPatch.HasUpdate = true; - } + [HarmonyPatch(typeof(SpecialSongManager), nameof(SpecialSongManager.InitHideBmsInfoDic))] + internal static class HideBmsInfoDicPatch + { + private static readonly Logger Logger = new(nameof(HideBmsInfoDicPatch)); + internal static bool HasUpdate { get; set; } = true; - [HarmonyPatch(typeof(SpecialSongManager), nameof(SpecialSongManager.InitHideBmsInfoDic))] - internal static class HideBmsInfoDicPatch + private static void Postfix(SpecialSongManager __instance) { - internal static bool HasUpdate { get; set; } = true; - private static readonly Logger Logger = new(nameof(HideBmsInfoDicPatch)); + if (!HasUpdate) return; - private static void Postfix(SpecialSongManager __instance) + foreach (var (key, value) in AlbumManager.LoadedAlbums.Where(kv => + kv.Value.HasDifficulty(4) || kv.Value.HasDifficulty(5))) { - if (!HasUpdate) return; + // Quick check for Touhou charts + if (value.HasDifficulty(5)) + { + var newTouhouArray = new Il2CppStringArray(DBMusicTagDefine.s_BarrageModeSongUid.Length + 1); + for (var i = 0; i < DBMusicTagDefine.s_BarrageModeSongUid.Length; i++) + newTouhouArray[i] = DBMusicTagDefine.s_BarrageModeSongUid[i]; + newTouhouArray[^1] = value.Uid; + DBMusicTagDefine.s_BarrageModeSongUid = newTouhouArray; + } + + // Enable hidden mode for charts containing map4 + if (!LoadedHiddens.Contains(value.Uid)) continue; - foreach (var (key, value) in AlbumManager.LoadedAlbums.Where(kv => kv.Value.HasDifficulty(4) || kv.Value.HasDifficulty(5))) + var albumUid = value.Uid; + + __instance.m_HideBmsInfos.Add(albumUid, + new SpecialSongManager.HideBmsInfo( + albumUid, + value.Info.HideBmsDifficulty == "0" + ? value.HasDifficulty(3) ? 3 : 2 + : value.Info.HideBmsDifficulty.ParseAsInt(), + 4, + $"{key}_map4", + new Func(() => __instance.IsInvokeHideBms(albumUid)) + )); + + // Add chart to the appropriate list for their hidden type + var hideMusicInfo = new HideMusicInfo { - // Quick check for Touhou charts - if (value.HasDifficulty(5)) + musicUid = albumUid, + invokeType = value.Info.HideBmsMode switch { - var newTouhouArray = new Il2CppStringArray(DBMusicTagDefine.s_BarrageModeSongUid.Length + 1); - for (var i = 0; i < DBMusicTagDefine.s_BarrageModeSongUid.Length; i++) - newTouhouArray[i] = DBMusicTagDefine.s_BarrageModeSongUid[i]; - newTouhouArray[^1] = value.Uid; - DBMusicTagDefine.s_BarrageModeSongUid = newTouhouArray; + "CLICK" => (int)HiddenInvokeType.Click, + "PRESS" => (int)HiddenInvokeType.LongPress, + "TOGGLE" => (int)HiddenInvokeType.Toggle, + _ => (int)HiddenInvokeType.None } - // Enable hidden mode for charts containing map4 - if (!LoadedHiddens.Contains(value.Uid)) continue; - - var albumUid = value.Uid; - - __instance.m_HideBmsInfos.Add(albumUid, - new SpecialSongManager.HideBmsInfo( - albumUid, - value.Info.HideBmsDifficulty == "0" - ? value.HasDifficulty(3) ? 3 : 2 - : value.Info.HideBmsDifficulty.ParseAsInt(), - 4, - $"{key}_map4", - new Func(() => __instance.IsInvokeHideBms(albumUid)) - )); - - // Add chart to the appropriate list for their hidden type - var hideMusicInfo = new HideMusicInfo - { - musicUid = albumUid, - invokeType = value.Info.HideBmsMode switch - { - "CLICK" => (int)HiddenInvokeType.Click, - "PRESS" => (int)HiddenInvokeType.LongPress, - "TOGGLE" => (int)HiddenInvokeType.Toggle, - _ => (int)HiddenInvokeType.None - } - }; - - __instance.m_ConfigHideMusic.m_HideMusicObjectMapping[albumUid] = hideMusicInfo; - } - HasUpdate = false; + }; + + __instance.m_ConfigHideMusic.m_HideMusicObjectMapping[albumUid] = hideMusicInfo; } - } - /// - /// Activates hidden charts when the conditions are met - /// - [HarmonyPatch(typeof(SpecialSongManager), nameof(SpecialSongManager.InvokeHideBms))] - internal static class InvokeHideBmsPatch - { - private static readonly Logger Logger = new(nameof(InvokeHideBmsPatch)); + HasUpdate = false; + } + } - private static bool Prefix(MusicInfo musicInfo, SpecialSongManager __instance) - { - if (!musicInfo.uid.StartsWith($"{AlbumManager.Uid}-") || - !LoadedHiddens.Contains(musicInfo.uid)) return true; + /// + /// Activates hidden charts when the conditions are met + /// + [HarmonyPatch(typeof(SpecialSongManager), nameof(SpecialSongManager.InvokeHideBms))] + internal static class InvokeHideBmsPatch + { + private static readonly Logger Logger = new(nameof(InvokeHideBmsPatch)); - var hideBms = __instance.m_HideBmsInfos[musicInfo.uid]; - __instance.m_IsInvokeHideDic[hideBms.uid] = true; + private static bool Prefix(MusicInfo musicInfo, SpecialSongManager __instance) + { + if (!musicInfo.uid.StartsWith($"{AlbumManager.Uid}-") || + !LoadedHiddens.Contains(musicInfo.uid)) return true; - if (!hideBms.extraCondition.Invoke()) return false; + var hideBms = __instance.m_HideBmsInfos[musicInfo.uid]; + __instance.m_IsInvokeHideDic[hideBms.uid] = true; - var album = AlbumManager.LoadedAlbums.FirstOrDefault(kv => kv.Value.Index == musicInfo.musicIndex) - .Value; + if (!hideBms.extraCondition.Invoke()) return false; - ActivateHidden(hideBms); + var album = AlbumManager.LoadedAlbums.FirstOrDefault(kv => kv.Value.Index == musicInfo.musicIndex) + .Value; - if (album.Info.HideBmsMessage != null) - { - var pnlTips = PnlTipsManager.instance; - var msgBox = pnlTips.GetMessageBox("PnlSpecialsBmsAsk"); - msgBox.Show("TIPS", album.Info.HideBmsMessage); - } + ActivateHidden(hideBms); - SpecialSongManager.onTriggerHideBmsEvent?.Invoke(); - if (album.Info.HideBmsMode == "PRESS") Singleton.instance.Invoke("UI/OnSpecialsMusic"); - return false; + if (album.Info.HideBmsMessage != null) + { + var pnlTips = PnlTipsManager.instance; + var msgBox = pnlTips.GetMessageBox("PnlSpecialsBmsAsk"); + msgBox.Show("TIPS", album.Info.HideBmsMessage); } - private static void ActivateHidden(SpecialSongManager.HideBmsInfo hideBms) - { - if (hideBms == null) return; + SpecialSongManager.onTriggerHideBmsEvent?.Invoke(); + if (album.Info.HideBmsMode == "PRESS") Singleton.instance.Invoke("UI/OnSpecialsMusic"); + return false; + } - var info = GlobalDataBase.dbMusicTag.GetMusicInfoFromAll(hideBms.uid); - if (hideBms.triggerDiff == 0) return; + private static void ActivateHidden(SpecialSongManager.HideBmsInfo hideBms) + { + if (hideBms == null) return; - var targetDifficulty = hideBms.triggerDiff; + var info = GlobalDataBase.dbMusicTag.GetMusicInfoFromAll(hideBms.uid); + if (hideBms.triggerDiff == 0) return; - if (targetDifficulty == -1) - { - targetDifficulty = 2; + var targetDifficulty = hideBms.triggerDiff; - // Disable the other difficulty options - info.AddMaskValue("difficulty1", "0"); - info.AddMaskValue("difficulty3", "0"); - } + if (targetDifficulty == -1) + { + targetDifficulty = 2; - var difficultyToHide = "difficulty" + targetDifficulty; - var levelDesignToHide = "levelDesigner" + targetDifficulty; - var difficulty = "?"; - var levelDesignStr = info.levelDesigner; - switch (hideBms.m_HideDiff) - { - case 1: - difficulty = info.difficulty1; - levelDesignStr = info.levelDesigner1 ?? info.levelDesigner; - break; - case 2: - difficulty = info.difficulty2; - levelDesignStr = info.levelDesigner2 ?? info.levelDesigner; - break; - case 3: - difficulty = info.difficulty3; - levelDesignStr = info.levelDesigner3 ?? info.levelDesigner; - break; - case 4: - difficulty = info.difficulty4; - levelDesignStr = info.levelDesigner4 ?? info.levelDesigner; - break; - case 5: - difficulty = info.difficulty5; - levelDesignStr = info.levelDesigner5 ?? info.levelDesigner; - break; - } + // Disable the other difficulty options + info.AddMaskValue("difficulty1", "0"); + info.AddMaskValue("difficulty3", "0"); + } - info.AddMaskValue(difficultyToHide, difficulty); - info.AddMaskValue(levelDesignToHide, levelDesignStr); - info.SetDifficulty(targetDifficulty, hideBms.m_HideDiff); + var difficultyToHide = "difficulty" + targetDifficulty; + var levelDesignToHide = "levelDesigner" + targetDifficulty; + var difficulty = "?"; + var levelDesignStr = info.levelDesigner; + switch (hideBms.m_HideDiff) + { + case 1: + difficulty = info.difficulty1; + levelDesignStr = info.levelDesigner1 ?? info.levelDesigner; + break; + case 2: + difficulty = info.difficulty2; + levelDesignStr = info.levelDesigner2 ?? info.levelDesigner; + break; + case 3: + difficulty = info.difficulty3; + levelDesignStr = info.levelDesigner3 ?? info.levelDesigner; + break; + case 4: + difficulty = info.difficulty4; + levelDesignStr = info.levelDesigner4 ?? info.levelDesigner; + break; + case 5: + difficulty = info.difficulty5; + levelDesignStr = info.levelDesigner5 ?? info.levelDesigner; + break; } + + info.AddMaskValue(difficultyToHide, difficulty); + info.AddMaskValue(levelDesignToHide, levelDesignStr); + info.SetDifficulty(targetDifficulty, hideBms.m_HideDiff); } + } + + [HarmonyPatch(typeof(MusicTagManager), nameof(MusicTagManager.InitDefaultInfo))] + internal class MusicTagPatch + { + private const int HiddenId = 32776; + private static readonly Logger Logger = new(nameof(MusicTagPatch)); + internal static bool HasUpdate { get; set; } = true; - [HarmonyPatch(typeof(MusicTagManager), nameof(MusicTagManager.InitDefaultInfo))] - internal class MusicTagPatch + private static void Postfix() { - private const int HiddenId = 32776; - private static readonly Logger Logger = new(nameof(MusicTagPatch)); - internal static bool HasUpdate { get; set; } = true; + if (!HasUpdate) return; - private static void Postfix() - { - if (!HasUpdate) return; + Il2CppSystem.Collections.Generic.List newHiddenAlbums = new(); + foreach (var (_, value) in AlbumManager.LoadedAlbums) + if (value.HasDifficulty(DifficultyDefine.hide) && LoadedHiddens.Add(value.Uid)) + newHiddenAlbums.Add(value.Uid); - Il2CppSystem.Collections.Generic.List newHiddenAlbums = new(); - foreach (var (_, value) in AlbumManager.LoadedAlbums) - { - if (value.HasDifficulty(DifficultyDefine.hide) && LoadedHiddens.Add(value.Uid)) - newHiddenAlbums.Add(value.Uid); - } + var newHiddenArray = + new Il2CppStringArray(DBMusicTagDefine.s_HiddenLocal.Length + newHiddenAlbums.Count); - var newHiddenArray = - new Il2CppStringArray(DBMusicTagDefine.s_HiddenLocal.Length + newHiddenAlbums.Count); + // copying s_HiddenLocal to newHiddenArray + for (var i = 0; i < DBMusicTagDefine.s_HiddenLocal.Length; ++i) + newHiddenArray[i] = DBMusicTagDefine.s_HiddenLocal[i]; - // copying s_HiddenLocal to newHiddenArray - for (var i = 0; i < DBMusicTagDefine.s_HiddenLocal.Length; ++i) - newHiddenArray[i] = DBMusicTagDefine.s_HiddenLocal[i]; + // adding all new charts to newHiddenArray + for (var i = 0; i < newHiddenAlbums.Count; i++) + newHiddenArray[i + DBMusicTagDefine.s_HiddenLocal.Length] = newHiddenAlbums[i]; - // adding all new charts to newHiddenArray - for (var i = 0; i < newHiddenAlbums.Count; i++) - newHiddenArray[i + DBMusicTagDefine.s_HiddenLocal.Length] = newHiddenAlbums[i]; + // setting the local hidden data to our new one that has custom charts + DBMusicTagDefine.s_HiddenLocal = newHiddenArray; - // setting the local hidden data to our new one that has custom charts - DBMusicTagDefine.s_HiddenLocal = newHiddenArray; - - // adding that data to the tag - var tagInfo = GlobalDataBase.dbMusicTag.GetAlbumTagInfo(HiddenId); - tagInfo.AddTagUids(newHiddenAlbums); - HasUpdate = false; - } + // adding that data to the tag + var tagInfo = GlobalDataBase.dbMusicTag.GetAlbumTagInfo(HiddenId); + tagInfo.AddTagUids(newHiddenAlbums); + HasUpdate = false; } } +} - internal enum HiddenInvokeType - { - None = 0, - LongPress = 1, - Toggle = 2, - Click = 3, - Special = 4 - } +internal enum HiddenInvokeType +{ + None = 0, + LongPress = 1, + Toggle = 2, + Click = 3, + Special = 4 } \ No newline at end of file diff --git a/Patches/LongChartPatch.cs b/Patches/LongChartPatch.cs index 8c42f9f..8d426be 100644 --- a/Patches/LongChartPatch.cs +++ b/Patches/LongChartPatch.cs @@ -1,18 +1,17 @@ using HarmonyLib; using Il2CppDYUnityLib; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +/// +/// Changes the maximum length of a chart from 4 minutes to about 357913 minutes. +/// This does not change the fact that the maximum amount of chart elements is 32767. +/// +[HarmonyPatch(typeof(FixUpdateTimer), "Run")] +internal class LongChartPatch { - /// - /// Changes the maximum length of a chart from 4 minutes to about 357913 minutes. - /// This does not change the fact that the maximum amount of chart elements is 32767. - /// - [HarmonyPatch(typeof(FixUpdateTimer), "Run")] - internal class LongChartPatch + private static void Prefix(FixUpdateTimer __instance) { - private static void Prefix(FixUpdateTimer __instance) - { - if (__instance.totalTick is >= 24000 and < int.MaxValue) __instance.totalTick = int.MaxValue; - } + if (__instance.totalTick is >= 24000 and < int.MaxValue) __instance.totalTick = int.MaxValue; } } \ No newline at end of file diff --git a/Patches/MikuSceneSupportPatch.cs b/Patches/MikuSceneSupportPatch.cs index 733bf4e..e3c4138 100644 --- a/Patches/MikuSceneSupportPatch.cs +++ b/Patches/MikuSceneSupportPatch.cs @@ -2,27 +2,25 @@ using HarmonyLib; using Il2CppGameLogic; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +internal class MikuSceneSupportPatch { - internal class MikuSceneSupportPatch + /// + /// Fixes the purple background issue when using the BMS ID "1X" to switch to the Miku scene. + /// + [HarmonyPatch(typeof(GameMusicScene), nameof(GameMusicScene.SceneFestival))] + internal class MikuFixPatch { - - /// - /// Fixes the purple background issue when using the BMS ID "1X" to switch to the Miku scene. - /// - [HarmonyPatch(typeof(GameMusicScene), nameof(GameMusicScene.SceneFestival))] - internal class MikuFixPatch + private static void Finalizer(string sceneFestivalName, ref string __result) { - private static void Finalizer(string sceneFestivalName, ref string __result) - { - var subStr = sceneFestivalName[6..]; - - if (subStr.Length <= 2 || !subStr.TryParseAsInt(out var sceneIndex)) return; - - // For some reason, scene change 1X sets scene to scene_010, which is invalid - // Just trim off "scene_" and parse the numbers after the underscore to int again to get 10 instead of 010 - __result = $"scene_{sceneIndex}"; - } + var subStr = sceneFestivalName[6..]; + + if (subStr.Length <= 2 || !subStr.TryParseAsInt(out var sceneIndex)) return; + + // For some reason, scene change 1X sets scene to scene_010, which is invalid + // Just trim off "scene_" and parse the numbers after the underscore to int again to get 10 instead of 010 + __result = $"scene_{sceneIndex}"; } } -} +} \ No newline at end of file diff --git a/Patches/MusicTagManagerPatch.cs b/Patches/MusicTagManagerPatch.cs index b44557a..843fb68 100644 --- a/Patches/MusicTagManagerPatch.cs +++ b/Patches/MusicTagManagerPatch.cs @@ -1,25 +1,24 @@ -using Il2Cpp; +using HarmonyLib; +using Il2Cpp; using Il2CppAssets.Scripts.Database; -using HarmonyLib; using Il2CppAssets.Scripts.PeroTools.Commons; using Il2CppAssets.Scripts.PeroTools.Managers; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +internal class MusicTagManagerPatch { - internal class MusicTagManagerPatch + /// + /// Makes the game think (correctly) that there are not 1000 albums upon loading the tag menu. + /// Increases performance substantially upon loading the tag menu. + /// + [HarmonyPatch(typeof(MusicTagManager), nameof(MusicTagManager.InitDatas))] + internal static class Fix1000AlbumsPatch { - /// - /// Makes the game think (correctly) that there are not 1000 albums upon loading the tag menu. - /// Increases performance substantially upon loading the tag menu. - /// - [HarmonyPatch(typeof(MusicTagManager), nameof(MusicTagManager.InitDatas))] - internal static class Fix1000AlbumsPatch + private static void Postfix() { - private static void Postfix() - { - var configObject = Singleton.instance.GetConfigObject(); - configObject.m_MaxAlbumUid = configObject.count - 3; - } + var configObject = Singleton.instance.GetConfigObject(); + configObject.m_MaxAlbumUid = configObject.count - 3; } } -} +} \ No newline at end of file diff --git a/Patches/PackPatch.cs b/Patches/PackPatch.cs index c02ab66..38aec6f 100644 --- a/Patches/PackPatch.cs +++ b/Patches/PackPatch.cs @@ -1,41 +1,43 @@ using CustomAlbums.Data; using CustomAlbums.Managers; -using CustomAlbums.Utilities; using HarmonyLib; using Il2Cpp; using Il2CppAssets.Scripts.Database; +using UnityEngine; +using Logger = CustomAlbums.Utilities.Logger; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +internal class PackPatch { - internal class PackPatch + private static readonly Logger Logger = new(nameof(PackPatch)); + + [HarmonyPatch(typeof(LongSongNameController), nameof(LongSongNameController.Refresh), typeof(string), typeof(bool), + typeof(float))] + internal class RefreshPatch { - private static readonly Logger Logger = new(nameof(PackPatch)); - - [HarmonyPatch(typeof(LongSongNameController), nameof(LongSongNameController.Refresh), new[] { typeof(string), typeof(bool), typeof(float) })] - internal class RefreshPatch + private static void SetColor(string colorHex, LongSongNameController instance) { - private static void SetColor(string colorHex, LongSongNameController instance) - { - var fixedColor = UnityEngine.ColorUtility.TryParseHtmlString(colorHex, out var color) ? color : UnityEngine.Color.white; - if (instance.m_MidSimpleName != null) instance.m_MidSimpleName.color = fixedColor; - if (instance.m_TxtBackupName != null) instance.m_TxtBackupName.color = fixedColor; - if (instance.m_TxtSimpleName != null) instance.m_TxtSimpleName.color = fixedColor; - } + var fixedColor = ColorUtility.TryParseHtmlString(colorHex, out var color) ? color : Color.white; + if (instance.m_MidSimpleName != null) instance.m_MidSimpleName.color = fixedColor; + if (instance.m_TxtBackupName != null) instance.m_TxtBackupName.color = fixedColor; + if (instance.m_TxtSimpleName != null) instance.m_TxtSimpleName.color = fixedColor; + } - private static void Prefix(ref string text, ref Pack __state) - { - var currentUid = DataHelper.selectedMusicUid; - if (currentUid == null || !currentUid.StartsWith($"{AlbumManager.Uid}-") || text != AlbumManager.GetCustomAlbumsTitle()) return; - - __state = PackManager.GetPackFromUid(currentUid); - text = __state?.Title ?? text; - } + private static void Prefix(ref string text, ref Pack __state) + { + var currentUid = DataHelper.selectedMusicUid; + if (currentUid == null || !currentUid.StartsWith($"{AlbumManager.Uid}-") || + text != AlbumManager.GetCustomAlbumsTitle()) return; - private static void Postfix(ref Pack __state, LongSongNameController __instance) - { - if (__instance == null) return; - SetColor(__state?.TitleColorHex, __instance); - } + __state = PackManager.GetPackFromUid(currentUid); + text = __state?.Title ?? text; + } + + private static void Postfix(ref Pack __state, LongSongNameController __instance) + { + if (__instance == null) return; + SetColor(__state?.TitleColorHex, __instance); } } -} +} \ No newline at end of file diff --git a/Patches/ReportCardPatch.cs b/Patches/ReportCardPatch.cs index 58eb2da..273ffa7 100644 --- a/Patches/ReportCardPatch.cs +++ b/Patches/ReportCardPatch.cs @@ -8,64 +8,64 @@ using Il2CppAssets.Scripts.PeroTools.Commons; using Il2CppAssets.Scripts.UI.Panels; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +[HarmonyPatch(typeof(PnlReportCard), nameof(PnlReportCard.RefreshBestRecord))] +internal static class ReportCardPatch { - [HarmonyPatch(typeof(PnlReportCard), nameof(PnlReportCard.RefreshBestRecord))] - internal static class ReportCardPatch + // ReSharper disable once InconsistentNaming + private static bool Prefix(PnlReportCard __instance) { - // ReSharper disable once InconsistentNaming - private static bool Prefix(PnlReportCard __instance) - { - var musicInfo = GlobalDataBase.s_DbMusicTag.CurMusicInfo(); - if (musicInfo.albumIndex != 999) return true; + var musicInfo = GlobalDataBase.s_DbMusicTag.CurMusicInfo(); + if (musicInfo.albumIndex != 999) return true; - var mapDifficulty = GlobalDataBase.s_DbBattleStage.m_MapDifficulty; - var curMusicBestRankOrder = Singleton.instance.GetCurMusicBestRankOrder(); + var mapDifficulty = GlobalDataBase.s_DbBattleStage.m_MapDifficulty; + var curMusicBestRankOrder = Singleton.instance.GetCurMusicBestRankOrder(); - var album = AlbumManager.GetByUid(musicInfo.uid); - if (album == null) return false; + var album = AlbumManager.GetByUid(musicInfo.uid); + if (album == null) return false; - var save = SaveManager.SaveData.Highest.GetValueOrDefault(album.AlbumName)?.GetValueOrDefault(mapDifficulty); - if (save == null) return false; + var save = SaveManager.SaveData.Highest.GetValueOrDefault(album.AlbumName)?.GetValueOrDefault(mapDifficulty); + if (save == null) return false; - __instance.RefreshRecord(musicInfo, mapDifficulty, save.Score, save.Combo, save.AccuracyStr, save.Evaluate, save.Clear.ToString(CultureInfo.InvariantCulture), curMusicBestRankOrder); - return false; - } + __instance.RefreshRecord(musicInfo, mapDifficulty, save.Score, save.Combo, save.AccuracyStr, save.Evaluate, + save.Clear.ToString(CultureInfo.InvariantCulture), curMusicBestRankOrder); + return false; } +} - [HarmonyPatch] - internal static class TogglePatch +[HarmonyPatch] +internal static class TogglePatch +{ + private static IEnumerable TargetMethods() { - private static IEnumerable TargetMethods() - { - return new[] { nameof(PnlPreparation.OnDiffTglChanged), nameof(PnlPreparation.OnEnable) } - .Select(methodName => typeof(PnlPreparation).GetMethod(methodName)) - .ToArray(); - } - // ReSharper disable once InconsistentNaming - private static void Postfix(PnlPreparation __instance) - { - var musicInfo = GlobalDataBase.s_DbMusicTag.CurMusicInfo(); - if (musicInfo.albumIndex != 999) return; + return new[] { nameof(PnlPreparation.OnDiffTglChanged), nameof(PnlPreparation.OnEnable) } + .Select(methodName => typeof(PnlPreparation).GetMethod(methodName)) + .ToArray(); + } - var mapDifficulty = GlobalDataBase.s_DbBattleStage.m_MapDifficulty; - var gameObject = __instance.btnDownloadReport.gameObject; + // ReSharper disable once InconsistentNaming + private static void Postfix(PnlPreparation __instance) + { + var musicInfo = GlobalDataBase.s_DbMusicTag.CurMusicInfo(); + if (musicInfo.albumIndex != 999) return; - var album = AlbumManager.GetByUid(musicInfo.uid); - if (album == null) - { - gameObject.SetActive(false); - return; - } + var mapDifficulty = GlobalDataBase.s_DbBattleStage.m_MapDifficulty; + var gameObject = __instance.btnDownloadReport.gameObject; - if (!SaveManager.SaveData.Highest.TryGetValue(album.AlbumName, out var chart)) - { - gameObject.SetActive(false); - return; - } + var album = AlbumManager.GetByUid(musicInfo.uid); + if (album == null) + { + gameObject.SetActive(false); + return; + } - gameObject.SetActive(chart.ContainsKey(mapDifficulty)); + if (!SaveManager.SaveData.Highest.TryGetValue(album.AlbumName, out var chart)) + { + gameObject.SetActive(false); + return; } - } -} + gameObject.SetActive(chart.ContainsKey(mapDifficulty)); + } +} \ No newline at end of file diff --git a/Patches/SavePatch.cs b/Patches/SavePatch.cs index c658139..1d9c714 100644 --- a/Patches/SavePatch.cs +++ b/Patches/SavePatch.cs @@ -17,569 +17,589 @@ using Il2CppInterop.Common; using MelonLoader; using MelonLoader.NativeUtils; +using UnityEngine.UI; using static CustomAlbums.Managers.SaveManager; using ArgumentOutOfRangeException = System.ArgumentOutOfRangeException; using Environment = System.Environment; using IntPtr = System.IntPtr; using Logger = CustomAlbums.Utilities.Logger; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +internal static class SavePatch { - internal static class SavePatch - { - internal static bool? _hqPresent = null; - internal static bool HQPresent => _hqPresent ??= MelonBase.FindMelon("Headquarters", "AshtonMemer") is not null; + internal static bool? _hqPresent; - private static string OriginalRepairingText; + private static string OriginalRepairingText; - internal static readonly Logger Logger = new(nameof(SavePatch)); + internal static readonly Logger Logger = new(nameof(SavePatch)); - // A mapping of Evaluate->letter grade - internal static readonly string[] EvalToGrade = - { - "D", - "C", - "B", - "A", - "S", - "S", - "S" - }; - - private static readonly NativeHook Hook = new(); - - // - // PANEL INJECTION SECTION - // - - /// - /// Sets the (score, combo, accuracy) to the custom chart data. - /// - /// The instance to set. - /// The custom chart data. - /// If the selected chart has been FCed. - private static void SetPanelWithData(PnlRecord panel, ChartSave data, bool isFullCombo) + // A mapping of Evaluate->letter grade + internal static readonly string[] EvalToGrade = + { + "D", + "C", + "B", + "A", + "S", + "S", + "S" + }; + + private static readonly NativeHook Hook = new(); + internal static bool HQPresent => _hqPresent ??= MelonBase.FindMelon("Headquarters", "AshtonMemer") is not null; + + // + // PANEL INJECTION SECTION + // + + /// + /// Sets the (score, combo, accuracy) to the custom chart data. + /// + /// The instance to set. + /// The custom chart data. + /// If the selected chart has been FCed. + private static void SetPanelWithData(PnlRecord panel, ChartSave data, bool isFullCombo) + { + // Enables the FC icon if chart has been FCed + // Also sets the combo text to a gold color if it has been FCed + if (isFullCombo) { - // Enables the FC icon if chart has been FCed - // Also sets the combo text to a gold color if it has been FCed - if (isFullCombo) - { - panel.imgIconFc.SetActive(true); - panel.txtCombo.color = panel.gradeColor[6]; - } - - // Sets all the PnlRecord data to custom chart data. - var evaluate = data.Evaluate; - panel.txtAccuracy.text = data.AccuracyStr; - panel.txtClear.text = data.Clear.ToStringInvariant(); - panel.txtCombo.text = data.Combo.ToStringInvariant(); - panel.txtGrade.text = EvalToGrade[evaluate]; - panel.txtGrade.color = panel.gradeColor[evaluate]; - panel.txtScore.text = data.Score.ToStringInvariant(); + panel.imgIconFc.SetActive(true); + panel.txtCombo.color = panel.gradeColor[6]; } - /// - /// Clears the current pnlRecord and refreshes the panel if needed. - /// - /// The instance. - /// Whether panelPreparation should force reload the leaderboards. - /// - private static void ClearAndRefreshPanels(PnlPreparation panelPreparation, bool reload) - { - panelPreparation.pnlRecord.Clear(); - panelPreparation.pnlRecord.imgIconFc.SetActive(false); - foreach (var panel in panelPreparation.pnlRanks) panel.Refresh(reload); - } + // Sets all the PnlRecord data to custom chart data. + var evaluate = data.Evaluate; + panel.txtAccuracy.text = data.AccuracyStr; + panel.txtClear.text = data.Clear.ToStringInvariant(); + panel.txtCombo.text = data.Combo.ToStringInvariant(); + panel.txtGrade.text = EvalToGrade[evaluate]; + panel.txtGrade.color = panel.gradeColor[evaluate]; + panel.txtScore.text = data.Score.ToStringInvariant(); + } - /// - /// Gets the correct current difficulty for the selected chart, accounting for hidden activation. - /// - /// The uid of the chart. - /// The current difficulty of the selected chart. - private static int GetDifficulty(string uid) - { - // Get current index and null-check m_HideBmsInfos - var selectedIndex = GlobalDataBase.s_DbMusicTag.selectedDiffTglIndex; - var hideBmsInfos = Singleton.instance.m_HideBmsInfos; - if (hideBmsInfos == null || !hideBmsInfos.ContainsKey(uid)) return selectedIndex; + /// + /// Clears the current pnlRecord and refreshes the panel if needed. + /// + /// The instance. + /// Whether panelPreparation should force reload the leaderboards. + private static void ClearAndRefreshPanels(PnlPreparation panelPreparation, bool reload) + { + panelPreparation.pnlRecord.Clear(); + panelPreparation.pnlRecord.imgIconFc.SetActive(false); + foreach (var panel in panelPreparation.pnlRanks) panel.Refresh(reload); + } - // Null-check the invoked hidden dictionary and make sure that if it's non-null the hideBms exists and is non-null - var invokedHides = Singleton.instance.m_IsInvokeHideDic; - if (invokedHides == null || !invokedHides.ContainsKey(uid) || !hideBmsInfos.TryGetValue(uid, out var hideBms) || hideBms == null) return selectedIndex; + /// + /// Gets the correct current difficulty for the selected chart, accounting for hidden activation. + /// + /// The uid of the chart. + /// The current difficulty of the selected chart. + private static int GetDifficulty(string uid) + { + // Get current index and null-check m_HideBmsInfos + var selectedIndex = GlobalDataBase.s_DbMusicTag.selectedDiffTglIndex; + var hideBmsInfos = Singleton.instance.m_HideBmsInfos; + if (hideBmsInfos == null || !hideBmsInfos.ContainsKey(uid)) return selectedIndex; - return invokedHides[uid] && selectedIndex == hideBms.triggerDiff ? hideBms.m_HideDiff : selectedIndex; - } + // Null-check the invoked hidden dictionary and make sure that if it's non-null the hideBms exists and is non-null + var invokedHides = Singleton.instance.m_IsInvokeHideDic; + if (invokedHides == null || !invokedHides.ContainsKey(uid) || !hideBmsInfos.TryGetValue(uid, out var hideBms) || + hideBms == null) return selectedIndex; - /// - /// Grabs the custom chart data and injects the PnlRecord with the chart data. - /// - /// The instance. - /// Whether the instance should force reload the leaderboards. - /// - private static bool InjectPnlPreparation(PnlPreparation __instance, bool forceReload) - { - var currentMusicInfo = GlobalDataBase.s_DbMusicTag.CurMusicInfo(); + return invokedHides[uid] && selectedIndex == hideBms.triggerDiff ? hideBms.m_HideDiff : selectedIndex; + } - // If the chart is not custom, run the original method; otherwise, run our modified one - if (currentMusicInfo.albumJsonIndex != AlbumManager.Uid + 1) return true; + /// + /// Grabs the custom chart data and injects the PnlRecord with the chart data. + /// + /// The instance. + /// Whether the instance should force reload the leaderboards. + /// + private static bool InjectPnlPreparation(PnlPreparation __instance, bool forceReload) + { + var currentMusicInfo = GlobalDataBase.s_DbMusicTag.CurMusicInfo(); - // Reset the panel to its default - ClearAndRefreshPanels(__instance, forceReload); - __instance.designerLongNameController?.Refresh(); + // If the chart is not custom, run the original method; otherwise, run our modified one + if (currentMusicInfo.albumJsonIndex != AlbumManager.Uid + 1) return true; - if (!ModSettings.SavingEnabled) return false; + // Reset the panel to its default + ClearAndRefreshPanels(__instance, forceReload); + __instance.designerLongNameController?.Refresh(); - var recordPanel = __instance.pnlRecord; - var currentChartData = SaveData.GetChartSaveDataFromUid(currentMusicInfo.uid); + if (!ModSettings.SavingEnabled) return false; - // If no highest data exists then early return - if ((currentChartData.Highest?.Count ?? 0) == 0) - { - Logger.Msg($"No save data found for {currentMusicInfo.uid}, nothing to inject"); - return false; - } + var recordPanel = __instance.pnlRecord; + var currentChartData = SaveData.GetChartSaveDataFromUid(currentMusicInfo.uid); - var difficulty = GetDifficulty(currentMusicInfo.uid); + // If no highest data exists then early return + if ((currentChartData.Highest?.Count ?? 0) == 0) + { + Logger.Msg($"No save data found for {currentMusicInfo.uid}, nothing to inject"); + return false; + } - var isFullCombo = currentChartData.FullCombo?.Contains(difficulty) ?? false; - var currentChartHighest = currentChartData.Highest.GetValueOrDefault(difficulty); + var difficulty = GetDifficulty(currentMusicInfo.uid); - if (currentChartHighest is null) - { - Logger.Msg($"Save data was found for the chart, but not for difficulty {difficulty}"); - return false; - } + var isFullCombo = currentChartData.FullCombo?.Contains(difficulty) ?? false; + var currentChartHighest = currentChartData.Highest.GetValueOrDefault(difficulty); - // Set the panel with the custom score data - SetPanelWithData(recordPanel, currentChartHighest, isFullCombo); - Logger.Msg($"Injecting {currentMusicInfo.uid} with difficulty {difficulty}"); - + if (currentChartHighest is null) + { + Logger.Msg($"Save data was found for the chart, but not for difficulty {difficulty}"); return false; } - /// - /// Adds proper support for different levelDesigners as well as fixes the levelDesigner sticking issue. - /// - [HarmonyPatch(typeof(MusicInfo), nameof(MusicInfo.GetLevelDesignerStringByIndex))] - internal class LevelDesignerPatch - { - private static bool Prefix(MusicInfo __instance, ref string __result, int index) - { - - __result = index switch - { - 1 => __instance.m_MaskValue.TryGetValue("levelDesigner1", out var d1) ? d1.ToString() : __instance.levelDesigner1, - 2 => __instance.m_MaskValue.TryGetValue("levelDesigner2", out var d2) ? d2.ToString() : __instance.levelDesigner2, - 3 => __instance.m_MaskValue.TryGetValue("levelDesigner3", out var d3) ? d3.ToString() : __instance.levelDesigner3, - 4 => __instance.m_MaskValue.TryGetValue("levelDesigner4", out var d4) ? d4.ToString() : __instance.levelDesigner4, - 5 => __instance.m_MaskValue.TryGetValue("levelDesigner5", out var d5) ? d5.ToString() : __instance.levelDesigner5, - _ => throw new ArgumentOutOfRangeException(nameof(index), index, "The difficulty is not a valid index.") - }; + // Set the panel with the custom score data + SetPanelWithData(recordPanel, currentChartHighest, isFullCombo); + Logger.Msg($"Injecting {currentMusicInfo.uid} with difficulty {difficulty}"); - if (string.IsNullOrEmpty(__result) || __result == "?") __result = __instance.m_MaskValue.TryGetValue("levelDesigner", out var d) ? d.ToString() : __instance.levelDesigner; - if (!string.IsNullOrEmpty(__result)) return false; + return false; + } - __result = "?????"; - return false; + // + // HACK SECTION + // - } - } + // TODO: Find a way to inject hidden and favorite charts without using vanilla save -- below are workarounds for quick release. + private static void CleanCustomData() + { + DataHelper.hides.RemoveAll((Il2CppSystem.Predicate)(uid => uid.StartsWith($"{AlbumManager.Uid}-"))); + DataHelper.history.RemoveAll( + (Il2CppSystem.Predicate)(uid => uid.StartsWith($"{AlbumManager.Uid}-"))); + DataHelper.collections.RemoveAll( + (Il2CppSystem.Predicate)(uid => uid.StartsWith($"{AlbumManager.Uid}-"))); + DataHelper.highest.RemoveAll( + (Il2CppSystem.Predicate)(data => data.GetUid().StartsWith($"{AlbumManager.Uid}-"))); - /// - /// Stops the game from loading leaderboards on custom charts if Headquarters is not installed. - /// - [HarmonyPatch(typeof(PnlRank), nameof(PnlRank.UIRefresh))] - internal class PnlRankPatch - { - private static bool FirstRun = true; - private static bool Prefix(string uid, PnlRank __instance) - { - var repairingComp = __instance.repairing.GetComponent(); - if (FirstRun) - { - OriginalRepairingText = repairingComp.text; - FirstRun = false; - } - // Check first run case when on a custom and HQ is not present - if (uid.StartsWith($"{AlbumManager.Uid}-") && _hqPresent == null && !HQPresent) - { - Logger.Warning("Headquarters is not installed! Custom chart leaderboards will not function."); - } - - // Vanilla chart or HQ present - if (!uid.StartsWith($"{AlbumManager.Uid}-") || HQPresent) - { - repairingComp.text = OriginalRepairingText; - return true; - } - - // Custom and HQ not present - repairingComp.text = "Headquarters mod is not loaded! ~(*´Д`)"; - __instance.repairing.SetActive(true); - return false; - } - } + var keysToRemove = new List(); - // - // HACK SECTION - // + foreach (var kvp in DataHelper.dicLevelConfig) + if (kvp.key.StartsWith("999")) + keysToRemove.Add(kvp.key); - // TODO: Find a way to inject hidden and favorite charts without using vanilla save -- below are workarounds for quick release. - private static void CleanCustomData() - { + foreach (var key in keysToRemove) + DataHelper.dicLevelConfig.Remove(key); - DataHelper.hides.RemoveAll((Il2CppSystem.Predicate)(uid => uid.StartsWith($"{AlbumManager.Uid}-"))); - DataHelper.history.RemoveAll( - (Il2CppSystem.Predicate)(uid => uid.StartsWith($"{AlbumManager.Uid}-"))); - DataHelper.collections.RemoveAll( - (Il2CppSystem.Predicate)(uid => uid.StartsWith($"{AlbumManager.Uid}-"))); - DataHelper.highest.RemoveAll( - (Il2CppSystem.Predicate)(data => data.GetUid().StartsWith($"{AlbumManager.Uid}-"))); + if (DataHelper.selectedAlbumUid == "music_package_999") + DataHelper.selectedAlbumUid = "music_package_0"; - var keysToRemove = new List(); + if (DataHelper.selectedAlbumTagIndex == AlbumManager.Uid) + DataHelper.selectedAlbumTagIndex = 0; - foreach (var kvp in DataHelper.dicLevelConfig) - if (kvp.key.StartsWith("999")) - keysToRemove.Add(kvp.key); + if (!DataHelper.selectedMusicUidFromInfoList.StartsWith($"{AlbumManager.Uid}-")) return; - foreach (var key in keysToRemove) - DataHelper.dicLevelConfig.Remove(key); + if (ModSettings.SavingEnabled) + SaveData.SelectedAlbum = AlbumManager.GetAlbumNameFromUid(DataHelper.selectedMusicUidFromInfoList); + DataHelper.selectedMusicUidFromInfoList = "0-0"; + } - if (DataHelper.selectedAlbumUid == "music_package_999") - DataHelper.selectedAlbumUid = "music_package_0"; + private static void InjectCustomData() + { + if (!ModSettings.SavingEnabled) return; - if (DataHelper.selectedAlbumTagIndex == AlbumManager.Uid) - DataHelper.selectedAlbumTagIndex = 0; + DataHelper.hides.AddManagedRange(SaveData.Hides.GetAlbumUidsFromNames()); + DataHelper.history.AddManagedRange(SaveData.History.GetAlbumUidsFromNames()); + DataHelper.collections.AddManagedRange(SaveData.Collections.GetAlbumUidsFromNames()); - if (!DataHelper.selectedMusicUidFromInfoList.StartsWith($"{AlbumManager.Uid}-")) return; - - if (ModSettings.SavingEnabled) SaveData.SelectedAlbum = AlbumManager.GetAlbumNameFromUid(DataHelper.selectedMusicUidFromInfoList); - DataHelper.selectedMusicUidFromInfoList = "0-0"; - } + if (!SaveData.SelectedAlbum.StartsWith("album_")) return; + DataHelper.selectedAlbumUid = "music_package_999"; + DataHelper.selectedAlbumTagIndex = 999; + DataHelper.selectedMusicUidFromInfoList = + AlbumManager.LoadedAlbums.TryGetValue(SaveData.SelectedAlbum, out var album) ? album.Uid : "0-0"; + } - private static void InjectCustomData() - { - if (!ModSettings.SavingEnabled) return; + // + // SCORE SECTION + // - DataHelper.hides.AddManagedRange(SaveData.Hides.GetAlbumUidsFromNames()); - DataHelper.history.AddManagedRange(SaveData.History.GetAlbumUidsFromNames()); - DataHelper.collections.AddManagedRange(SaveData.Collections.GetAlbumUidsFromNames()); + /// + /// Stops the game from saving custom chart score data. + /// + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static IntPtr RecordBattleArgsPatch(IntPtr instance, IntPtr args, IntPtr isSuccess, IntPtr nativeMethodInfo) + { + return !GlobalDataBase.s_DbBattleStage.musicUid.StartsWith($"{AlbumManager.Uid}-") + ? Hook.Trampoline(instance, args, isSuccess, nativeMethodInfo) + : IntPtr.Zero; + } - if (!SaveData.SelectedAlbum.StartsWith("album_")) return; - DataHelper.selectedAlbumUid = "music_package_999"; - DataHelper.selectedAlbumTagIndex = 999; - DataHelper.selectedMusicUidFromInfoList = AlbumManager.LoadedAlbums.TryGetValue(SaveData.SelectedAlbum, out var album) ? album.Uid : "0-0"; - } + /// + /// Gets RecordBattleArgs and detours it using a + /// NativeHook<RecordBattleArgsDelegate> to RecordBattleArgsPatch. + /// + internal static unsafe void AttachHook() + { + // If you are interested in what's happening here, check out AssetPatch + var recordBattleArgsMethod = AccessTools.Method(typeof(AchievementManager), + nameof(AchievementManager.RecordBattleArgs), + new[] { typeof(GlobalAchievementArgs).MakeByRefType(), typeof(bool) }); - [HarmonyPatch(typeof(DataHelper), nameof(DataHelper.CheckMusicUnlockMaster), new Type[] { typeof(MusicInfo), typeof(bool) })] - internal class CheckUnlockMasterPatch + if (recordBattleArgsMethod is null) { - private static bool Prefix(MusicInfo musicInfo, ref bool __result) - { - SaveData.Ability = Math.Max(SaveData.Ability, GlobalDataBase.dbUi.ability); - var ability = GameAccountSystem.instance.IsLoggedIn() ? SaveData.Ability : 0; - var uid = musicInfo?.uid; - - // If musicInfo or uid is null, run original - if (uid is null) return true; - - // Bugged vanilla state, do manual logic - if (GlobalDataBase.dbUi.ability == 0 && ability != 0) - { - Logger.Msg("Fixing bugged vanilla state for master lock"); - var vanillaParse = Formatting.TryParseAsInt(musicInfo.difficulty3, out var difficulty); - var cond = !vanillaParse || ability >= difficulty; - __result = cond || DataHelper.unlockMasters.Contains(uid) || SaveData.UnlockedMasters.Contains(AlbumManager.GetAlbumNameFromUid(uid)) || (!AlbumManager.GetByUid(musicInfo.uid)?.IsPackaged ?? false); - return false; - } - - // Non-bugged vanilla case - if (!uid.StartsWith($"{AlbumManager.Uid}-")) return true; - - // Non-bugged custom case - var successParse = Formatting.TryParseAsInt(musicInfo.difficulty3, out var diffNum); - var abilityConditionOrGimmick = !successParse || ability >= diffNum; - - __result = abilityConditionOrGimmick || SaveData.UnlockedMasters.Contains(AlbumManager.GetAlbumNameFromUid(uid)) || !AlbumManager.GetByUid(musicInfo.uid).IsPackaged; - return false; - } + Logger.Error("FATAL ERROR: SavePatch failed."); + Thread.Sleep(1000); + Environment.Exit(1); } - [HarmonyPatch(typeof(PnlPreparation), nameof(PnlPreparation.OnEnable))] - internal class PnlPreparationEnablePatch + var recordBattleArgsPointer = *(IntPtr*)(IntPtr)Il2CppInteropUtils + .GetIl2CppMethodInfoPointerFieldForGeneratedMethod(recordBattleArgsMethod).GetValue(null)!; + + delegate* unmanaged[Cdecl] detourPointer = &RecordBattleArgsPatch; + + Hook.Detour = (IntPtr)detourPointer; + Hook.Target = recordBattleArgsPointer; + Hook.Attach(); + } + + /// + /// Adds proper support for different levelDesigners as well as fixes the levelDesigner sticking issue. + /// + [HarmonyPatch(typeof(MusicInfo), nameof(MusicInfo.GetLevelDesignerStringByIndex))] + internal class LevelDesignerPatch + { + private static bool Prefix(MusicInfo __instance, ref string __result, int index) { - // Inject and force load all leaderboards - private static bool Prefix(PnlPreparation __instance) + __result = index switch { - return InjectPnlPreparation(__instance, true); - } + 1 => __instance.m_MaskValue.TryGetValue("levelDesigner1", out var d1) + ? d1.ToString() + : __instance.levelDesigner1, + 2 => __instance.m_MaskValue.TryGetValue("levelDesigner2", out var d2) + ? d2.ToString() + : __instance.levelDesigner2, + 3 => __instance.m_MaskValue.TryGetValue("levelDesigner3", out var d3) + ? d3.ToString() + : __instance.levelDesigner3, + 4 => __instance.m_MaskValue.TryGetValue("levelDesigner4", out var d4) + ? d4.ToString() + : __instance.levelDesigner4, + 5 => __instance.m_MaskValue.TryGetValue("levelDesigner5", out var d5) + ? d5.ToString() + : __instance.levelDesigner5, + _ => throw new ArgumentOutOfRangeException(nameof(index), index, "The difficulty is not a valid index.") + }; + + if (string.IsNullOrEmpty(__result) || __result == "?") + __result = __instance.m_MaskValue.TryGetValue("levelDesigner", out var d) + ? d.ToString() + : __instance.levelDesigner; + if (!string.IsNullOrEmpty(__result)) return false; + + __result = "?????"; + return false; } + } + + /// + /// Stops the game from loading leaderboards on custom charts if Headquarters is not installed. + /// + [HarmonyPatch(typeof(PnlRank), nameof(PnlRank.UIRefresh))] + internal class PnlRankPatch + { + private static bool FirstRun = true; - [HarmonyPatch(typeof(PnlPreparation), nameof(PnlPreparation.OnDiffTglChanged))] - internal class PnlPreparationDiffTogglePatch + private static bool Prefix(string uid, PnlRank __instance) { - // Inject and load all leaderboards if needed - private static bool Prefix(PnlPreparation __instance) + var repairingComp = __instance.repairing.GetComponent(); + if (FirstRun) { - return InjectPnlPreparation(__instance, false); + OriginalRepairingText = repairingComp.text; + FirstRun = false; } - } - // - // ACCOUNT SECTION - // + // Check first run case when on a custom and HQ is not present + if (uid.StartsWith($"{AlbumManager.Uid}-") && _hqPresent == null && !HQPresent) + Logger.Warning("Headquarters is not installed! Custom chart leaderboards will not function."); - // TODO: Figure all of this out without using vanilla save - [HarmonyPatch(typeof(DBMusicTag), nameof(DBMusicTag.AddHide))] - internal class AddHidePatch - { - private static bool Prefix(MusicInfo musicInfo) + // Vanilla chart or HQ present + if (!uid.StartsWith($"{AlbumManager.Uid}-") || HQPresent) { - if (!musicInfo.uid.StartsWith($"{AlbumManager.Uid}-")) return true; - if (!ModSettings.SavingEnabled) return false; - - SaveData.Hides.Add(AlbumManager.GetAlbumNameFromUid(musicInfo.uid)); + repairingComp.text = OriginalRepairingText; return true; } + + // Custom and HQ not present + repairingComp.text = "Headquarters mod is not loaded! ~(*´Д`)"; + __instance.repairing.SetActive(true); + return false; } + } - [HarmonyPatch(typeof(DBMusicTag), nameof(DBMusicTag.RemoveHide))] - internal class RemoveHidePatch + [HarmonyPatch(typeof(DataHelper), nameof(DataHelper.CheckMusicUnlockMaster), typeof(MusicInfo), typeof(bool))] + internal class CheckUnlockMasterPatch + { + private static bool Prefix(MusicInfo musicInfo, ref bool __result) { - private static bool Prefix(MusicInfo musicInfo) - { - if (!musicInfo.uid.StartsWith($"{AlbumManager.Uid}-")) return true; - if (!ModSettings.SavingEnabled) return false; + SaveData.Ability = Math.Max(SaveData.Ability, GlobalDataBase.dbUi.ability); + var ability = GameAccountSystem.instance.IsLoggedIn() ? SaveData.Ability : 0; + var uid = musicInfo?.uid; - SaveData.Hides.Remove(AlbumManager.GetAlbumNameFromUid(musicInfo.uid)); - return true; + // If musicInfo or uid is null, run original + if (uid is null) return true; + + // Bugged vanilla state, do manual logic + if (GlobalDataBase.dbUi.ability == 0 && ability != 0) + { + Logger.Msg("Fixing bugged vanilla state for master lock"); + var vanillaParse = musicInfo.difficulty3.TryParseAsInt(out var difficulty); + var cond = !vanillaParse || ability >= difficulty; + __result = cond || DataHelper.unlockMasters.Contains(uid) || + SaveData.UnlockedMasters.Contains(AlbumManager.GetAlbumNameFromUid(uid)) || + (!AlbumManager.GetByUid(musicInfo.uid)?.IsPackaged ?? false); + return false; } + + // Non-bugged vanilla case + if (!uid.StartsWith($"{AlbumManager.Uid}-")) return true; + + // Non-bugged custom case + var successParse = musicInfo.difficulty3.TryParseAsInt(out var diffNum); + var abilityConditionOrGimmick = !successParse || ability >= diffNum; + + __result = abilityConditionOrGimmick || + SaveData.UnlockedMasters.Contains(AlbumManager.GetAlbumNameFromUid(uid)) || + !AlbumManager.GetByUid(musicInfo.uid).IsPackaged; + return false; } + } - [HarmonyPatch(typeof(DBMusicTag), nameof(DBMusicTag.AddCollection))] - internal class AddCollectionPatch + [HarmonyPatch(typeof(PnlPreparation), nameof(PnlPreparation.OnEnable))] + internal class PnlPreparationEnablePatch + { + // Inject and force load all leaderboards + private static bool Prefix(PnlPreparation __instance) { - private static bool Prefix(MusicInfo musicInfo) - { - if (!musicInfo.uid.StartsWith($"{AlbumManager.Uid}-")) return true; - if (!ModSettings.SavingEnabled) return false; + return InjectPnlPreparation(__instance, true); + } + } - SaveData.Collections.Add(AlbumManager.GetAlbumNameFromUid(musicInfo.uid)); - return true; - } + [HarmonyPatch(typeof(PnlPreparation), nameof(PnlPreparation.OnDiffTglChanged))] + internal class PnlPreparationDiffTogglePatch + { + // Inject and load all leaderboards if needed + private static bool Prefix(PnlPreparation __instance) + { + return InjectPnlPreparation(__instance, false); } + } - [HarmonyPatch(typeof(DBMusicTag), nameof(DBMusicTag.RemoveCollection))] - internal class RemoveCollectionPatch + // + // ACCOUNT SECTION + // + + // TODO: Figure all of this out without using vanilla save + [HarmonyPatch(typeof(DBMusicTag), nameof(DBMusicTag.AddHide))] + internal class AddHidePatch + { + private static bool Prefix(MusicInfo musicInfo) { - private static bool Prefix(MusicInfo musicInfo) - { - if (!musicInfo.uid.StartsWith($"{AlbumManager.Uid}-")) return true; - if (!ModSettings.SavingEnabled) return false; + if (!musicInfo.uid.StartsWith($"{AlbumManager.Uid}-")) return true; + if (!ModSettings.SavingEnabled) return false; - SaveData.Collections.Remove(AlbumManager.GetAlbumNameFromUid(musicInfo.uid)); - return true; - } + SaveData.Hides.Add(AlbumManager.GetAlbumNameFromUid(musicInfo.uid)); + return true; } + } - [HarmonyPatch(typeof(DBMusicTag), nameof(DBMusicTag.AddHistory))] - internal class AddHistoryPatch + [HarmonyPatch(typeof(DBMusicTag), nameof(DBMusicTag.RemoveHide))] + internal class RemoveHidePatch + { + private static bool Prefix(MusicInfo musicInfo) { - private static bool Prefix(string musicUid) - { - if (!musicUid.StartsWith($"{AlbumManager.Uid}-")) return true; - if (!ModSettings.SavingEnabled) return false; - - var albumName = AlbumManager.GetAlbumNameFromUid(musicUid); + if (!musicInfo.uid.StartsWith($"{AlbumManager.Uid}-")) return true; + if (!ModSettings.SavingEnabled) return false; - // Remove album from history if it exists - SaveData.History.Remove(albumName); - SaveData.History.Add(albumName); + SaveData.Hides.Remove(AlbumManager.GetAlbumNameFromUid(musicInfo.uid)); + return true; + } + } - if (SaveData.History.Count > 10) - SaveData.History.RemoveAt(0); + [HarmonyPatch(typeof(DBMusicTag), nameof(DBMusicTag.AddCollection))] + internal class AddCollectionPatch + { + private static bool Prefix(MusicInfo musicInfo) + { + if (!musicInfo.uid.StartsWith($"{AlbumManager.Uid}-")) return true; + if (!ModSettings.SavingEnabled) return false; - return true; - } + SaveData.Collections.Add(AlbumManager.GetAlbumNameFromUid(musicInfo.uid)); + return true; } + } - // - // SCORE SECTION - // - - /// - /// Stops the game from saving custom chart score data. - /// - [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] - private static IntPtr RecordBattleArgsPatch(IntPtr instance, IntPtr args, IntPtr isSuccess, IntPtr nativeMethodInfo) + [HarmonyPatch(typeof(DBMusicTag), nameof(DBMusicTag.RemoveCollection))] + internal class RemoveCollectionPatch + { + private static bool Prefix(MusicInfo musicInfo) { - return !GlobalDataBase.s_DbBattleStage.musicUid.StartsWith($"{AlbumManager.Uid}-") ? Hook.Trampoline(instance, args, isSuccess, nativeMethodInfo) : IntPtr.Zero; + if (!musicInfo.uid.StartsWith($"{AlbumManager.Uid}-")) return true; + if (!ModSettings.SavingEnabled) return false; + + SaveData.Collections.Remove(AlbumManager.GetAlbumNameFromUid(musicInfo.uid)); + return true; } + } - /// - /// Gets RecordBattleArgs and detours it using a - /// NativeHook<RecordBattleArgsDelegate> to RecordBattleArgsPatch. - /// - internal static unsafe void AttachHook() + [HarmonyPatch(typeof(DBMusicTag), nameof(DBMusicTag.AddHistory))] + internal class AddHistoryPatch + { + private static bool Prefix(string musicUid) { - // If you are interested in what's happening here, check out AssetPatch - var recordBattleArgsMethod = AccessTools.Method(typeof(AchievementManager), - nameof(AchievementManager.RecordBattleArgs), - new[] { typeof(GlobalAchievementArgs).MakeByRefType(), typeof(bool) }); + if (!musicUid.StartsWith($"{AlbumManager.Uid}-")) return true; + if (!ModSettings.SavingEnabled) return false; - if (recordBattleArgsMethod is null) - { - Logger.Error("FATAL ERROR: SavePatch failed."); - Thread.Sleep(1000); - Environment.Exit(1); - } + var albumName = AlbumManager.GetAlbumNameFromUid(musicUid); - var recordBattleArgsPointer = *(IntPtr*)(IntPtr)Il2CppInteropUtils - .GetIl2CppMethodInfoPointerFieldForGeneratedMethod(recordBattleArgsMethod).GetValue(null)!; + // Remove album from history if it exists + SaveData.History.Remove(albumName); + SaveData.History.Add(albumName); - delegate* unmanaged[Cdecl] detourPointer = &RecordBattleArgsPatch; + if (SaveData.History.Count > 10) + SaveData.History.RemoveAt(0); - Hook.Detour = (IntPtr)detourPointer; - Hook.Target = recordBattleArgsPointer; - Hook.Attach(); + return true; } + } - /// - /// Gets the score data of the custom chart and sends it to SaveManager for processing. - /// - [HarmonyPatch(typeof(GameAccountSystem), nameof(GameAccountSystem.UploadScore))] - internal class UploadScorePatch + /// + /// Gets the score data of the custom chart and sends it to SaveManager for processing. + /// + [HarmonyPatch(typeof(GameAccountSystem), nameof(GameAccountSystem.UploadScore))] + internal class UploadScorePatch + { + private static void Postfix(SceneUploadResultData resData) { - private static void Postfix(SceneUploadResultData resData) - { - if (!resData.musicUid.StartsWith($"{AlbumManager.Uid}-")) return; - SaveScore(resData.musicUid, resData.musicDifficulty, resData.score, resData.accuracy, resData.combo, resData.evaluate, resData.miss); - } + if (!resData.musicUid.StartsWith($"{AlbumManager.Uid}-")) return; + SaveScore(resData.musicUid, resData.musicDifficulty, resData.score, resData.accuracy, resData.combo, + resData.evaluate, resData.miss); } + } - /// - /// Enables the PnlVictory screen logic when the chart is custom. - /// - [HarmonyPatch(typeof(PnlVictory), nameof(PnlVictory.SetScore))] - internal class SetScorePatch + /// + /// Enables the PnlVictory screen logic when the chart is custom. + /// + [HarmonyPatch(typeof(PnlVictory), nameof(PnlVictory.SetScore))] + internal class SetScorePatch + { + private static void Postfix(PnlVictory __instance) { - private static void Postfix(PnlVictory __instance) + // Fix for DJMax scrolling text bug + if (__instance.m_CurControls.mainPnl.transform.parent.name is "Djmax") { - // Fix for DJMax scrolling text bug - if (__instance.m_CurControls.mainPnl.transform.parent.name is "Djmax") - { - var titleMask = __instance.m_CurControls.mainPnl.transform - .Find("PnlVictory_3D").Find("SongTittle").Find("ImgSongTittleMask"); + var titleMask = __instance.m_CurControls.mainPnl.transform + .Find("PnlVictory_3D").Find("SongTittle").Find("ImgSongTittleMask"); - var titleText = titleMask.Find("TxtSongTittle").gameObject; - if (!titleText.active) titleMask.Find("MaskPos").gameObject.SetActive(true); - } + var titleText = titleMask.Find("TxtSongTittle").gameObject; + if (!titleText.active) titleMask.Find("MaskPos").gameObject.SetActive(true); + } - if (!ModSettings.SavingEnabled || !GlobalDataBase.dbBattleStage.musicUid.StartsWith($"{AlbumManager.Uid}-")) return; + if (!ModSettings.SavingEnabled || + !GlobalDataBase.dbBattleStage.musicUid.StartsWith($"{AlbumManager.Uid}-")) return; - var albumName = AlbumManager.GetAlbumNameFromUid(GlobalDataBase.dbBattleStage.musicUid); + var albumName = AlbumManager.GetAlbumNameFromUid(GlobalDataBase.dbBattleStage.musicUid); - if (!SaveData.Highest.TryGetValue(albumName, out var highest)) - return; + if (!SaveData.Highest.TryGetValue(albumName, out var highest)) + return; - // If the chart has been played then enable the "HI-SCORE" UI element - var difficulty = GlobalDataBase.s_DbBattleStage.m_MapDifficulty; - __instance.m_CurControls.highScoreTitle.enabled = PreviousScore != "-"; - __instance.m_CurControls.highScoreTxt.enabled = PreviousScore != "-"; + // If the chart has been played then enable the "HI-SCORE" UI element + var difficulty = GlobalDataBase.s_DbBattleStage.m_MapDifficulty; + __instance.m_CurControls.highScoreTitle.enabled = PreviousScore != "-"; + __instance.m_CurControls.highScoreTxt.enabled = PreviousScore != "-"; - // Gets the current run score - var score = Singleton.instance.GetScore(); + // Gets the current run score + var score = Singleton.instance.GetScore(); - // Enable "New Best!!" UI element if the chart hasn't been played, or the new play is a higher score - if (!highest.ContainsKey(difficulty)) - __instance.m_CurControls.newBest.SetActive(true); - if (PreviousScore != "-") - __instance.m_CurControls.newBest.SetActive(score > int.Parse(PreviousScore)); + // Enable "New Best!!" UI element if the chart hasn't been played, or the new play is a higher score + if (!highest.ContainsKey(difficulty)) + __instance.m_CurControls.newBest.SetActive(true); + if (PreviousScore != "-") + __instance.m_CurControls.newBest.SetActive(score > int.Parse(PreviousScore)); - __instance.m_CurControls.highScoreTxt.text = PreviousScore; - } + __instance.m_CurControls.highScoreTxt.text = PreviousScore; } + } - [HarmonyPatch(typeof(PnlPreparation), nameof(PnlPreparation.OnBattleStart))] - internal class OnBattleStartPatch + [HarmonyPatch(typeof(PnlPreparation), nameof(PnlPreparation.OnBattleStart))] + internal class OnBattleStartPatch + { + private static void Postfix() { - private static void Postfix() - { - if (ModSettings.SavingEnabled && !DataHelper.selectedMusicUidFromInfoList.StartsWith("album_")) - SaveData.SelectedAlbum = DataHelper.selectedMusicUidFromInfoList; - } + if (ModSettings.SavingEnabled && !DataHelper.selectedMusicUidFromInfoList.StartsWith("album_")) + SaveData.SelectedAlbum = DataHelper.selectedMusicUidFromInfoList; } + } - // - // HACKS SECTION - TODO: Remove all the methods below once we don't put data into the official game at all - // - - [HarmonyPatch(typeof(SteamSync), nameof(SteamSync.SaveLocal))] - internal class SaveLocalPatch - { - private static bool BackedUp { get; set; } - - private static void Prefix() - { + // + // HACKS SECTION - TODO: Remove all the methods below once we don't put data into the official game at all + // - if (ModSettings.SavingEnabled && DataHelper.selectedMusicUidFromInfoList.StartsWith($"{AlbumManager.Uid}-")) - SaveData.SelectedAlbum = - $"{AlbumManager.GetAlbumNameFromUid(DataHelper.selectedMusicUidFromInfoList)}"; + [HarmonyPatch(typeof(SteamSync), nameof(SteamSync.SaveLocal))] + internal class SaveLocalPatch + { + private static bool BackedUp { get; set; } - if (ModSettings.SavingEnabled) SaveSaveFile(); - CleanCustomData(); - } + private static void Prefix() + { + if (ModSettings.SavingEnabled && DataHelper.selectedMusicUidFromInfoList.StartsWith($"{AlbumManager.Uid}-")) + SaveData.SelectedAlbum = + $"{AlbumManager.GetAlbumNameFromUid(DataHelper.selectedMusicUidFromInfoList)}"; - private static void Postfix() - { - if (!ModSettings.SavingEnabled) return; - if (!BackedUp) Backup.InitBackups(); - BackedUp = true; - InjectCustomData(); - } + if (ModSettings.SavingEnabled) SaveSaveFile(); + CleanCustomData(); } - [HarmonyPatch(typeof(SteamSync), nameof(SteamSync.LoadLocal))] - internal class LoadLocalPatch + private static void Postfix() { - private static void Postfix() - { - CleanCustomData(); - if (ModSettings.SavingEnabled) InjectCustomData(); - } + if (!ModSettings.SavingEnabled) return; + if (!BackedUp) Backup.InitBackups(); + BackedUp = true; + InjectCustomData(); } + } - [HarmonyPatch(typeof(GameAccountSystem), nameof(GameAccountSystem.RefreshDatas))] - internal class RefreshDatasPatch + [HarmonyPatch(typeof(SteamSync), nameof(SteamSync.LoadLocal))] + internal class LoadLocalPatch + { + private static void Postfix() { - private static void Prefix() - { - Backup.InitBackups(); - SaveSaveFile(); - CleanCustomData(); - if (ModSettings.SavingEnabled) InjectCustomData(); - } + CleanCustomData(); + if (ModSettings.SavingEnabled) InjectCustomData(); } + } - [HarmonyPatch(typeof(GameAccountSystem), nameof(GameAccountSystem.OnSaveSelectCallback))] - internal class OnSaveSelectPatch + [HarmonyPatch(typeof(GameAccountSystem), nameof(GameAccountSystem.RefreshDatas))] + internal class RefreshDatasPatch + { + private static void Prefix() { - private static void Prefix(ref bool isLocal) - { - if (!isLocal) return; + Backup.InitBackups(); + SaveSaveFile(); + CleanCustomData(); + if (ModSettings.SavingEnabled) InjectCustomData(); + } + } - if (ModSettings.SavingEnabled) SaveSaveFile(); - CleanCustomData(); - } + [HarmonyPatch(typeof(GameAccountSystem), nameof(GameAccountSystem.OnSaveSelectCallback))] + internal class OnSaveSelectPatch + { + private static void Prefix(ref bool isLocal) + { + if (!isLocal) return; - private static void Postfix(ref bool isLocal) - { - if (!ModSettings.SavingEnabled || !isLocal) return; - InjectCustomData(); - } + if (ModSettings.SavingEnabled) SaveSaveFile(); + CleanCustomData(); } - [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - private delegate IntPtr RecordBattleArgsDelegate(IntPtr instance, IntPtr args, IntPtr isSuccess, IntPtr nativeMethodInfo); + private static void Postfix(ref bool isLocal) + { + if (!ModSettings.SavingEnabled || !isLocal) return; + InjectCustomData(); + } } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate IntPtr RecordBattleArgsDelegate(IntPtr instance, IntPtr args, IntPtr isSuccess, + IntPtr nativeMethodInfo); } \ No newline at end of file diff --git a/Patches/SceneEggPatch.cs b/Patches/SceneEggPatch.cs index 735d741..f8bc252 100644 --- a/Patches/SceneEggPatch.cs +++ b/Patches/SceneEggPatch.cs @@ -10,121 +10,121 @@ using Il2CppPeroPeroGames.GlobalDefines; using static CustomAlbums.Data.SceneEgg; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +internal class SceneEggPatch { - internal class SceneEggPatch + private static readonly Logger Logger = new(nameof(SceneEggPatch)); + + internal static bool IgnoreSceneEggs(out Album outAlbum, params SceneEggs[] sceneEggs) { - private static readonly Logger Logger = new(nameof(SceneEggPatch)); + outAlbum = null; + // If the chart is not custom then leave + var uid = DataHelper.selectedMusicUid; + if (!uid.StartsWith($"{AlbumManager.Uid}-")) return true; + + // If the album doesn't exist (?) or if there are no SceneEggs or if it's christmas SceneEgg (not really a SceneEgg) then leave + var album = AlbumManager.GetByUid(uid); + if (album is null) return true; + + outAlbum = album; + return sceneEggs.Any(sceneEgg => sceneEgg == album.Info.SceneEgg); + } - internal static bool IgnoreSceneEggs(out Album outAlbum, params SceneEggs[] sceneEggs) + /// + /// Adds support for SceneEggs. + /// + [HarmonyPatch(typeof(SceneEggAbstractController), nameof(SceneEggAbstractController.SceneEggHandle))] + internal class ControllerPatch + { + private static void Prefix(Il2CppSystem.Collections.Generic.List sceneEggIdsBuffer) { - outAlbum = null; - // If the chart is not custom then leave - var uid = DataHelper.selectedMusicUid; - if (!uid.StartsWith($"{AlbumManager.Uid}-")) return true; + // Return if there are no scene eggs or if the scene eggs have special logic + if (IgnoreSceneEggs(out var album, SceneEggs.None, SceneEggs.Christmas, SceneEggs.BadApple)) return; - // If the album doesn't exist (?) or if there are no SceneEggs or if it's christmas SceneEgg (not really a SceneEgg) then leave - var album = AlbumManager.GetByUid(uid); - if (album is null) return true; + // Adds the scene egg to the buffer + sceneEggIdsBuffer.Add((int)album.Info.SceneEgg); + Logger.Msg("Added SceneEgg " + Enum.GetName(typeof(SceneEggs), album.Info.SceneEgg)); - outAlbum = album; - return sceneEggs.Any(sceneEgg => sceneEgg == album.Info.SceneEgg); + // Removes all scene eggs from the buffer that are not the one that was added + // This prevents a hierarchical issue where character choice would be used over chart choice + sceneEggIdsBuffer.RemoveAll((Il2CppSystem.Predicate)(eggId => eggId != (int)album.Info.SceneEgg)); } + } - /// - /// Adds support for SceneEggs. - /// - [HarmonyPatch(typeof(SceneEggAbstractController), nameof(SceneEggAbstractController.SceneEggHandle))] - internal class ControllerPatch + /// + /// Makes scene_05 be scene_05_christmas if the Christmas SceneEgg is enabled. + /// + [HarmonyPatch(typeof(GameMusicScene), nameof(GameMusicScene.SceneFestival))] + internal class SceneFestivalPatch + { + private static bool Prefix(string sceneFestivalName, ref string __result) { - private static void Prefix(Il2CppSystem.Collections.Generic.List sceneEggIdsBuffer) - { - // Return if there are no scene eggs or if the scene eggs have special logic - if (IgnoreSceneEggs(out var album, SceneEggs.None, SceneEggs.Christmas, SceneEggs.BadApple)) return; - - // Adds the scene egg to the buffer - sceneEggIdsBuffer.Add((int)album.Info.SceneEgg); - Logger.Msg("Added SceneEgg " + Enum.GetName(typeof(SceneEggs), album.Info.SceneEgg)); - - // Removes all scene eggs from the buffer that are not the one that was added - // This prevents a hierarchical issue where character choice would be used over chart choice - sceneEggIdsBuffer.RemoveAll((Il2CppSystem.Predicate)(eggId => eggId != (int)album.Info.SceneEgg)); - } - } + // If the scene is not scene_05 or scene_08 then there is no Christmas + if (sceneFestivalName != "scene_05") return true; - /// - /// Makes scene_05 be scene_05_christmas if the Christmas SceneEgg is enabled. - /// - [HarmonyPatch(typeof(GameMusicScene), nameof(GameMusicScene.SceneFestival))] - internal class SceneFestivalPatch - { - private static bool Prefix(string sceneFestivalName, ref string __result) - { - // If the scene is not scene_05 or scene_08 then there is no Christmas - if (sceneFestivalName != "scene_05") return true; - - // Ignore the actual SceneEggs - if (IgnoreSceneEggs(out _, SceneEggs.Arknights, SceneEggs.Cytus, SceneEggs.None, - SceneEggs.Queen, SceneEggs.Touhou, SceneEggs.Wacca, SceneEggs.Miku, SceneEggs.BadApple, SceneEggs.RinLen)) return true; - - if (sceneFestivalName == "scene_05") __result = "scene_05_christmas"; - return false; - } + // Ignore the actual SceneEggs + if (IgnoreSceneEggs(out _, SceneEggs.Arknights, SceneEggs.Cytus, SceneEggs.None, + SceneEggs.Queen, SceneEggs.Touhou, SceneEggs.Wacca, SceneEggs.Miku, SceneEggs.BadApple, + SceneEggs.RinLen)) return true; + + if (sceneFestivalName == "scene_05") __result = "scene_05_christmas"; + return false; } + } - /// - /// Makes the boss be the christmas boss if the Christmas SceneEgg is enabled. - /// - [HarmonyPatch(typeof(Boss), nameof(Boss.BossFestival))] - internal class BossFestivalPatch + /// + /// Makes the boss be the christmas boss if the Christmas SceneEgg is enabled. + /// + [HarmonyPatch(typeof(Boss), nameof(Boss.BossFestival))] + internal class BossFestivalPatch + { + private static bool Prefix(string bossFestivalName, ref string __result) { - private static bool Prefix(string bossFestivalName, ref string __result) - { - // If the boss is not 0501_boss then there is no Christmas - if (bossFestivalName != "0501_boss") return true; - if (IgnoreSceneEggs(out _, SceneEggs.Arknights, SceneEggs.Cytus, SceneEggs.None, - SceneEggs.Queen, SceneEggs.Touhou, SceneEggs.Wacca, SceneEggs.Miku, SceneEggs.BadApple, SceneEggs.RinLen)) return true; - - __result = "0501_boss_christmas"; - return false; - } + // If the boss is not 0501_boss then there is no Christmas + if (bossFestivalName != "0501_boss") return true; + if (IgnoreSceneEggs(out _, SceneEggs.Arknights, SceneEggs.Cytus, SceneEggs.None, + SceneEggs.Queen, SceneEggs.Touhou, SceneEggs.Wacca, SceneEggs.Miku, SceneEggs.BadApple, + SceneEggs.RinLen)) return true; + + __result = "0501_boss_christmas"; + return false; } + } - /// - /// Makes the game think (temporarily) that the chart is Bad Apple when the BadApple SceneEgg is enabled to load all - /// the assets properly. - /// - [HarmonyPatch(typeof(DBTouhou), nameof(DBTouhou.AwakeInit))] - internal class BadApplePatch + /// + /// Makes the game think (temporarily) that the chart is Bad Apple when the BadApple SceneEgg is enabled to load all + /// the assets properly. + /// + [HarmonyPatch(typeof(DBTouhou), nameof(DBTouhou.AwakeInit))] + internal class BadApplePatch + { + private static void Postfix() { - private static void Postfix() - { - if (IgnoreSceneEggs(out _, SceneEggs.Arknights, SceneEggs.Cytus, SceneEggs.None, - SceneEggs.Queen, SceneEggs.Touhou, SceneEggs.Wacca, SceneEggs.Miku, SceneEggs.Christmas, SceneEggs.RinLen, SceneEggs.BlueArchive)) return; + if (IgnoreSceneEggs(out _, SceneEggs.Arknights, SceneEggs.Cytus, SceneEggs.None, + SceneEggs.Queen, SceneEggs.Touhou, SceneEggs.Wacca, SceneEggs.Miku, SceneEggs.Christmas, + SceneEggs.RinLen, SceneEggs.BlueArchive)) return; - GlobalDataBase.dbTouhou.isBadApple = true; + GlobalDataBase.dbTouhou.isBadApple = true; - GlobalDataBase.s_DbOther.m_HpFx = TouhouLogic.ReplaceBadAppleString("fx_hp_ground"); - GlobalDataBase.s_DbOther.m_MusicFx = TouhouLogic.ReplaceBadAppleString("fx_score_ground"); - GlobalDataBase.s_DbOther.m_DustFx = TouhouLogic.ReplaceBadAppleString("dust_fx"); - } + GlobalDataBase.s_DbOther.m_HpFx = TouhouLogic.ReplaceBadAppleString("fx_hp_ground"); + GlobalDataBase.s_DbOther.m_MusicFx = TouhouLogic.ReplaceBadAppleString("fx_score_ground"); + GlobalDataBase.s_DbOther.m_DustFx = TouhouLogic.ReplaceBadAppleString("dust_fx"); } + } - /// - /// Makes the game think that the chart is a RinLen chart when the RinLen SceneEgg is enabled to load all - /// the assets properly. - /// - [HarmonyPatch(typeof(DBMusicTagDefine), nameof(DBMusicTagDefine.IsRinLenEggSong))] - internal class RinLenPatch + /// + /// Makes the game think that the chart is a RinLen chart when the RinLen SceneEgg is enabled to load all + /// the assets properly. + /// + [HarmonyPatch(typeof(DBMusicTagDefine), nameof(DBMusicTagDefine.IsRinLenEggSong))] + internal class RinLenPatch + { + private static void Postfix(string uid, ref bool __result) { - private static void Postfix(string uid, ref bool __result) - { - if (uid.StartsWith($"{AlbumManager.Uid}-") && AlbumManager.GetByUid(uid).Info.SceneEgg is SceneEggs.RinLen) - { - __result = true; - } - } + if (uid.StartsWith($"{AlbumManager.Uid}-") && AlbumManager.GetByUid(uid).Info.SceneEgg is SceneEggs.RinLen) + __result = true; } } } \ No newline at end of file diff --git a/Patches/SilenceLogPatch.cs b/Patches/SilenceLogPatch.cs index 52eb069..32a647d 100644 --- a/Patches/SilenceLogPatch.cs +++ b/Patches/SilenceLogPatch.cs @@ -1,22 +1,20 @@ using HarmonyLib; using Il2CppPeroTools2.Log; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +internal class SilenceLogPatch { - internal class SilenceLogPatch + /// + /// Disables log write-to-file if it is set in MelonPreferences (it is by default). + /// + [HarmonyPatch(typeof(PeroLogConfig), nameof(PeroLogConfig.instance), MethodType.Getter)] + internal class LoadConfigPatch { - /// - /// Disables log write-to-file if it is set in MelonPreferences (it is by default). - /// - [HarmonyPatch(typeof(PeroLogConfig), nameof(PeroLogConfig.instance), MethodType.Getter)] - internal class LoadConfigPatch + private static void Postfix(ref PeroLogConfig __result) { - private static void Postfix(ref PeroLogConfig __result) - { - if (ModSettings.LoggingToFileEnabled) return; - __result.m_IsLogToFile = false; - } + if (ModSettings.LoggingToFileEnabled) return; + __result.m_IsLogToFile = false; } - } -} +} \ No newline at end of file diff --git a/Patches/TagPatch.cs b/Patches/TagPatch.cs index c89dcef..bf80fc7 100644 --- a/Patches/TagPatch.cs +++ b/Patches/TagPatch.cs @@ -6,34 +6,33 @@ using Il2CppAssets.Scripts.Database.DataClass; using static Il2CppAssets.Scripts.Database.DBConfigCustomTags; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +internal class TagPatch { - internal class TagPatch + [HarmonyPatch(typeof(MusicTagManager), nameof(MusicTagManager.InitAlbumTagInfo))] + internal class MusicTagPatch { - [HarmonyPatch(typeof(MusicTagManager), nameof(MusicTagManager.InitAlbumTagInfo))] - internal class MusicTagPatch + private static void Postfix() { - private static void Postfix() + var info = new AlbumTagInfo + { + name = AlbumManager.Languages["English"], + tagUid = "tag-custom-albums", + iconName = "IconCustomAlbums" + }; + var customInfo = new CustomTagInfo { - var info = new AlbumTagInfo - { - name = AlbumManager.Languages["English"], - tagUid = "tag-custom-albums", - iconName = "IconCustomAlbums" - }; - var customInfo = new CustomTagInfo - { - tag_name = AlbumManager.Languages.ToIl2Cpp(), - tag_picture = "https://cdn.mdmc.moe/static/melon.png", - music_list = AlbumManager.GetAllUid().ToIl2Cpp() - }; + tag_name = AlbumManager.Languages.ToIl2Cpp(), + tag_picture = "https://cdn.mdmc.moe/static/melon.png", + music_list = AlbumManager.GetAllUid().ToIl2Cpp() + }; - info.InitCustomTagInfo(customInfo); + info.InitCustomTagInfo(customInfo); - GlobalDataBase.dbMusicTag.m_AlbumTagsSort.Insert(GlobalDataBase.dbMusicTag.m_AlbumTagsSort.Count - 4, - AlbumManager.Uid); - GlobalDataBase.dbMusicTag.AddAlbumTagData(AlbumManager.Uid, info); - } + GlobalDataBase.dbMusicTag.m_AlbumTagsSort.Insert(GlobalDataBase.dbMusicTag.m_AlbumTagsSort.Count - 4, + AlbumManager.Uid); + GlobalDataBase.dbMusicTag.AddAlbumTagData(AlbumManager.Uid, info); } } } \ No newline at end of file diff --git a/Patches/TreeItemPatch.cs b/Patches/TreeItemPatch.cs index 5699ea6..f4eba81 100644 --- a/Patches/TreeItemPatch.cs +++ b/Patches/TreeItemPatch.cs @@ -1,36 +1,35 @@ -using Il2Cpp; -using CustomAlbums.Utilities; +using CustomAlbums.Utilities; using HarmonyLib; +using Il2Cpp; -namespace CustomAlbums.Patches +namespace CustomAlbums.Patches; + +internal class TreeItemPatch { - internal class TreeItemPatch - { - // TODO: Finish "Album" support - private static readonly Logger Logger = new(nameof(TreeItemPatch)); + // TODO: Finish "Album" support + private static readonly Logger Logger = new(nameof(TreeItemPatch)); - [HarmonyPatch(typeof(PnlMusicTagItem), nameof(PnlMusicTagItem.OnTagClicked))] - internal class OnMusicTagClickedPatch + [HarmonyPatch(typeof(PnlMusicTagItem), nameof(PnlMusicTagItem.OnTagClicked))] + internal class OnMusicTagClickedPatch + { + private static void Prefix(int tagIndex, PnlMusicTagItem __instance) { - private static void Prefix(int tagIndex, PnlMusicTagItem __instance) - { - // STUB - } + // STUB } + } - [HarmonyPatch(typeof(PnlMusicTagItem), nameof(PnlMusicTagItem.Enable))] - internal class EnablePatch + [HarmonyPatch(typeof(PnlMusicTagItem), nameof(PnlMusicTagItem.Enable))] + internal class EnablePatch + { + private static void Prefix(PnlMusicTagItem __instance) { - private static void Prefix(PnlMusicTagItem __instance) - { - // STUB - } + // STUB } + } - [HarmonyPatch(typeof(PnlMusicTagItem), nameof(PnlMusicTagItem.AddDataToMgr))] - internal class AddDataPatch - { - // STUB - } + [HarmonyPatch(typeof(PnlMusicTagItem), nameof(PnlMusicTagItem.AddDataToMgr))] + internal class AddDataPatch + { + // STUB } -} +} \ No newline at end of file diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index c985376..66516dc 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -20,4 +20,4 @@ [assembly: AssemblyFileVersion($"{Main.MelonVersion}.0")] [assembly: MelonInfo(typeof(Main), Main.MelonName, Main.MelonVersion, Main.MelonAuthor)] [assembly: MelonGame("PeroPeroGames", "MuseDash")] -[assembly: MelonColor(255, 0, 255, 150)] +[assembly: MelonColor(255, 0, 255, 150)] \ No newline at end of file diff --git a/Utilities/Backup.cs b/Utilities/Backup.cs index d76e2ef..09fc970 100644 --- a/Utilities/Backup.cs +++ b/Utilities/Backup.cs @@ -8,142 +8,141 @@ using Il2CppAssets.Scripts.PeroTools.Nice.Interface; using Il2CppInterop.Runtime.InteropTypes.Arrays; -namespace CustomAlbums.Utilities +namespace CustomAlbums.Utilities; + +internal class Backup { - internal class Backup + private static readonly Logger Logger = new(nameof(Backup)); + private static string BackupPath => Path.Combine(Directory.GetCurrentDirectory(), "UserData/Backups"); + private static string BackupVanilla => Path.Combine(BackupPath, "Vanilla.sav.bak"); + private static string BackupVanillaDebug => Path.Combine(BackupPath, "VanillaDebug.json"); + private static string BackupCustom => Path.Combine(BackupPath, "CustomAlbums.json.bak"); + private static string BackupZip => Path.Combine(BackupPath, "Backups.zip"); + private static TimeSpan MaxBackupTime => TimeSpan.FromDays(30); + + internal static void InitBackups() { - private static readonly Logger Logger = new(nameof(Backup)); - private static string BackupPath => Path.Combine(Directory.GetCurrentDirectory(), "UserData/Backups"); - private static string BackupVanilla => Path.Combine(BackupPath, "Vanilla.sav.bak"); - private static string BackupVanillaDebug => Path.Combine(BackupPath, "VanillaDebug.json"); - private static string BackupCustom => Path.Combine(BackupPath, "CustomAlbums.json.bak"); - private static string BackupZip => Path.Combine(BackupPath, "Backups.zip"); - private static TimeSpan MaxBackupTime => TimeSpan.FromDays(30); - - internal static void InitBackups() - { - Directory.CreateDirectory(BackupPath); - - CompressBackups(); - CreateBackup(BackupVanilla, Singleton.instance.ToBytes()); - CreateBackup(BackupVanillaDebug, - JsonSerializer.Serialize(ToJsonDict(Singleton.instance.datas))); - CreateBackup(BackupCustom, SaveManager.SaveData); - ClearOldBackups(); - } + Directory.CreateDirectory(BackupPath); + + CompressBackups(); + CreateBackup(BackupVanilla, Singleton.instance.ToBytes()); + CreateBackup(BackupVanillaDebug, + JsonSerializer.Serialize(ToJsonDict(Singleton.instance.datas))); + CreateBackup(BackupCustom, SaveManager.SaveData); + ClearOldBackups(); + } - private static void CreateBackup(string filePath, object data) + private static void CreateBackup(string filePath, object data) + { + try { - try + if (data is null) { - if (data is null) - { - Logger.Warning("Could not create backup of null data!"); - return; - } + Logger.Warning("Could not create backup of null data!"); + return; + } - var wroteFile = false; + var wroteFile = false; - switch (data) - { - case string str: - File.WriteAllText(filePath, str); - wroteFile = true; - break; - - case byte[] bytes: - File.WriteAllBytes(filePath, bytes); - wroteFile = true; - break; - - case Il2CppStructArray ilBytes: - File.WriteAllBytes(filePath, ilBytes); - wroteFile = true; - break; - - case CustomAlbumsSave save: - File.WriteAllText(filePath, JsonSerializer.Serialize(save)); - wroteFile = true; - break; - - default: - Logger.Warning("Could not create backup for unsupported data type " + data.GetType().FullName); - break; - } - - if (wroteFile) Logger.Msg($"Saved backup: {filePath}"); - } - catch (Exception e) + switch (data) { - Logger.Error("Backup failed: " + e); + case string str: + File.WriteAllText(filePath, str); + wroteFile = true; + break; + + case byte[] bytes: + File.WriteAllBytes(filePath, bytes); + wroteFile = true; + break; + + case Il2CppStructArray ilBytes: + File.WriteAllBytes(filePath, ilBytes); + wroteFile = true; + break; + + case CustomAlbumsSave save: + File.WriteAllText(filePath, JsonSerializer.Serialize(save)); + wroteFile = true; + break; + + default: + Logger.Warning("Could not create backup for unsupported data type " + data.GetType().FullName); + break; } + + if (wroteFile) Logger.Msg($"Saved backup: {filePath}"); + } + catch (Exception e) + { + Logger.Error("Backup failed: " + e); } + } - private static void ClearOldBackups() + private static void ClearOldBackups() + { + try { - try + var backups = Directory.EnumerateFiles(BackupPath).ToList(); + foreach (var backup in from backup in backups + let backupDate = Directory.GetLastWriteTime(backup) + where (DateTime.Now - backupDate).Duration() > MaxBackupTime.Duration() + select backup) { - var backups = Directory.EnumerateFiles(BackupPath).ToList(); - foreach (var backup in from backup in backups - let backupDate = Directory.GetLastWriteTime(backup) - where (DateTime.Now - backupDate).Duration() > MaxBackupTime.Duration() - select backup) - { - Logger.Msg("Removing old backup: " + backup); - File.Delete(backup); - } + Logger.Msg("Removing old backup: " + backup); + File.Delete(backup); + } - if (!File.Exists(BackupZip)) return; + if (!File.Exists(BackupZip)) return; - using var zip = ZipFile.Open(BackupZip, ZipArchiveMode.Update); - foreach (var entry in zip.Entries.ToList().Where(entry => - (DateTime.Now - entry.LastWriteTime).Duration() > MaxBackupTime.Duration())) - { - Logger.Msg("Removing compressed old backup: " + entry.Name); - zip.GetEntry(entry.Name)?.Delete(); - } - } - catch (Exception e) + using var zip = ZipFile.Open(BackupZip, ZipArchiveMode.Update); + foreach (var entry in zip.Entries.ToList().Where(entry => + (DateTime.Now - entry.LastWriteTime).Duration() > MaxBackupTime.Duration())) { - Logger.Error("Clearing old backups failed: " + e); + Logger.Msg("Removing compressed old backup: " + entry.Name); + zip.GetEntry(entry.Name)?.Delete(); } } - - private static void CompressBackups() + catch (Exception e) { - try - { - using var zip = ZipFile.Open(BackupZip, ZipArchiveMode.Update); - - var filesList = Directory.EnumerateFiles(BackupPath).Where(name => Path.GetExtension(name) != ".zip") - .ToList(); - if (filesList.Any()) - filesList.ForEach(entry => - { - var newFileName = Directory.GetLastWriteTime(entry).ToString("yyyy_MM_dd_H_mm_ss-") + - Path.GetFileName(Path.GetFileName(entry)); - zip.CreateEntryFromFile(entry, newFileName); - File.Delete(entry); - }); - } - catch (Exception e) - { - Logger.Error("Compressing previous backups failed: " + e); - } + Logger.Error("Clearing old backups failed: " + e); } + } - private static Dictionary ToJsonDict( - Il2CppSystem.Collections.Generic.Dictionary dataList) + private static void CompressBackups() + { + try { - var dictionary = new Dictionary(); - foreach (var keyValuePair in dataList) - { - var singletonDataObject = keyValuePair.Value?.TryCast(); - if (singletonDataObject != null) - dictionary.Add(keyValuePair.Key, Json.Deserialize(singletonDataObject.ToJson())); - } + using var zip = ZipFile.Open(BackupZip, ZipArchiveMode.Update); - return dictionary; + var filesList = Directory.EnumerateFiles(BackupPath).Where(name => Path.GetExtension(name) != ".zip") + .ToList(); + if (filesList.Any()) + filesList.ForEach(entry => + { + var newFileName = Directory.GetLastWriteTime(entry).ToString("yyyy_MM_dd_H_mm_ss-") + + Path.GetFileName(Path.GetFileName(entry)); + zip.CreateEntryFromFile(entry, newFileName); + File.Delete(entry); + }); + } + catch (Exception e) + { + Logger.Error("Compressing previous backups failed: " + e); } } + + private static Dictionary ToJsonDict( + Il2CppSystem.Collections.Generic.Dictionary dataList) + { + var dictionary = new Dictionary(); + foreach (var keyValuePair in dataList) + { + var singletonDataObject = keyValuePair.Value?.TryCast(); + if (singletonDataObject != null) + dictionary.Add(keyValuePair.Key, Json.Deserialize(singletonDataObject.ToJson())); + } + + return dictionary; + } } \ No newline at end of file diff --git a/Utilities/ConfigDataExtensions.cs b/Utilities/ConfigDataExtensions.cs index e7d555d..81cf054 100644 --- a/Utilities/ConfigDataExtensions.cs +++ b/Utilities/ConfigDataExtensions.cs @@ -3,57 +3,56 @@ using Il2CppPeroPeroGames.GlobalDefines; using Decimal = Il2CppSystem.Decimal; -namespace CustomAlbums.Utilities +namespace CustomAlbums.Utilities; + +public static class ConfigDataExtensions { - public static class ConfigDataExtensions + public static bool IsAprilFools(this NoteConfigData config) + { + return config.prefab_name.EndsWith("_fool"); + } + + public static NoteType GetNoteType(this NoteConfigData config) + { + return (NoteType)config.type; + } + + public static bool IsAnyScene(this NoteConfigData config) + { + return config.scene == "0"; + } + + public static bool IsAnyPathway(this NoteConfigData config) + { + return config.pathway == 0 && config.score == 0 && config.fever == 0 && config.damage == 0; + } + + public static bool IsPhase2BossGear(this NoteConfigData config) + { + return config.GetNoteType() == NoteType.Block && config.boss_action.EndsWith("_atk_2"); + } + + public static bool IsAnySpeed(this NoteConfigData config) + { + return config.GetNoteType() == NoteType.Boss + || config.GetNoteType() == NoteType.None + || config.ibms_id == "16" + || config.ibms_id == "17"; + } + + /// + /// Converts a to a Il2Cpp struct. + /// + public static MusicConfigData ToMusicConfigData(this ProcessedNote note) { - public static bool IsAprilFools(this NoteConfigData config) - { - return config.prefab_name.EndsWith("_fool"); - } - - public static NoteType GetNoteType(this NoteConfigData config) - { - return (NoteType)config.type; - } - - public static bool IsAnyScene(this NoteConfigData config) - { - return config.scene == "0"; - } - - public static bool IsAnyPathway(this NoteConfigData config) - { - return config.pathway == 0 && config.score == 0 && config.fever == 0 && config.damage == 0; - } - - public static bool IsPhase2BossGear(this NoteConfigData config) - { - return config.GetNoteType() == NoteType.Block && config.boss_action.EndsWith("_atk_2"); - } - - public static bool IsAnySpeed(this NoteConfigData config) - { - return config.GetNoteType() == NoteType.Boss - || config.GetNoteType() == NoteType.None - || config.ibms_id == "16" - || config.ibms_id == "17"; - } - - /// - /// Converts a to a Il2Cpp struct. - /// - public static MusicConfigData ToMusicConfigData(this ProcessedNote note) - { - var config = Interop.CreateTypeValue(); - config.id = note.Id; - config.time = (Decimal)(float)note.Time; - config.note_uid = note.NoteUid; - config.length = (Decimal)(float)note.Length; - config.pathway = note.Pathway; - config.blood = note.Blood; - - return config; - } + var config = Interop.CreateTypeValue(); + config.id = note.Id; + config.time = (Decimal)(float)note.Time; + config.note_uid = note.NoteUid; + config.length = (Decimal)(float)note.Length; + config.pathway = note.Pathway; + config.blood = note.Blood; + + return config; } } \ No newline at end of file diff --git a/Utilities/Converters.cs b/Utilities/Converters.cs index c952081..4cafba2 100644 --- a/Utilities/Converters.cs +++ b/Utilities/Converters.cs @@ -1,21 +1,20 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace CustomAlbums.Utilities +namespace CustomAlbums.Utilities; + +internal class Converters { - internal class Converters + public class NumberConverter : JsonConverter { - public class NumberConverter : JsonConverter + public override string Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) { - public override string Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) - { - return reader.TokenType is JsonTokenType.Number ? reader.GetInt32().ToString() : reader.GetString(); - } + return reader.TokenType is JsonTokenType.Number ? reader.GetInt32().ToString() : reader.GetString(); + } - public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) - { - writer.WriteStringValue(value); - } + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); } } } \ No newline at end of file diff --git a/Utilities/DataExtensions.cs b/Utilities/DataExtensions.cs index 6d7f7f3..cf7e1ba 100644 --- a/Utilities/DataExtensions.cs +++ b/Utilities/DataExtensions.cs @@ -1,80 +1,74 @@ using CustomAlbums.Data; using Il2CppAssets.Scripts.PeroTools.Nice.Interface; -namespace CustomAlbums.Utilities +namespace CustomAlbums.Utilities; + +internal static class DataExtensions { - internal static class DataExtensions + /// + /// Gets the uid field of the IData object. + /// + /// The IData object. + /// The uid or an empty string if not found. + public static string GetUid(this IData data) { - /// - /// Gets the uid field of the IData object. - /// - /// The IData object. - /// The uid or an empty string if not found. - public static string GetUid(this IData data) - { - var uidField = data.fields["uid"]; - return uidField == null ? string.Empty : uidField.GetResult(); - } - - /// - /// Gets the index of a chart in an IData list by its uid and difficulty. - /// - /// The IData list. - /// The uid of the chart. - /// The difficulty of the chart. - /// The index of the chart in the list, or -1 if not found. - public static int GetIndexByUid(this Il2CppSystem.Collections.Generic.List dataList, string uid, int difficulty) - { - var i = 0; - var fullUid = $"{uid}_{difficulty}"; + var uidField = data.fields["uid"]; + return uidField == null ? string.Empty : uidField.GetResult(); + } - // For loop doesn't work here - foreach (var data in dataList) - { - if (data.GetUid() == fullUid) - { - return i; - } + /// + /// Gets the index of a chart in an IData list by its uid and difficulty. + /// + /// The IData list. + /// The uid of the chart. + /// The difficulty of the chart. + /// The index of the chart in the list, or -1 if not found. + public static int GetIndexByUid(this Il2CppSystem.Collections.Generic.List dataList, string uid, + int difficulty) + { + var i = 0; + var fullUid = $"{uid}_{difficulty}"; - i++; - } + // For loop doesn't work here + foreach (var data in dataList) + { + if (data.GetUid() == fullUid) return i; - return -1; + i++; } - /// - /// Gets the IData object from a list by its uid and difficulty. - /// - /// The IData list. - /// The uid of the chart. - /// The difficulty of the chart. - /// The IData object, or null if not found. - public static IData GetIDataByUid(this Il2CppSystem.Collections.Generic.List dataList, string uid, int difficulty) - { - var fullUid = $"{uid}_{difficulty}"; + return -1; + } - foreach (var data in dataList) - { - if (data.GetUid() == fullUid) - { - return data; - } - } + /// + /// Gets the IData object from a list by its uid and difficulty. + /// + /// The IData list. + /// The uid of the chart. + /// The difficulty of the chart. + /// The IData object, or null if not found. + public static IData GetIDataByUid(this Il2CppSystem.Collections.Generic.List dataList, string uid, + int difficulty) + { + var fullUid = $"{uid}_{difficulty}"; - return null; - } + foreach (var data in dataList) + if (data.GetUid() == fullUid) + return data; - public static ChartSave ToChartSave(this IData data) + return null; + } + + public static ChartSave ToChartSave(this IData data) + { + return new ChartSave { - return new ChartSave - { - Evaluate = data.fields["evaluate"].GetResult(), - Score = data.fields["score"].GetResult(), - Combo = data.fields["combo"].GetResult(), - Accuracy = data.fields["accuracy"].GetResult(), - AccuracyStr = data.fields["accuracyStr"].GetResult(), - Clear = data.fields["clear"].GetResult() - }; - } + Evaluate = data.fields["evaluate"].GetResult(), + Score = data.fields["score"].GetResult(), + Combo = data.fields["combo"].GetResult(), + Accuracy = data.fields["accuracy"].GetResult(), + AccuracyStr = data.fields["accuracyStr"].GetResult(), + Clear = data.fields["clear"].GetResult() + }; } -} +} \ No newline at end of file diff --git a/Utilities/Debug.cs b/Utilities/Debug.cs index eaf39b7..9aec671 100644 --- a/Utilities/Debug.cs +++ b/Utilities/Debug.cs @@ -1,19 +1,18 @@ using HarmonyLib; -namespace CustomAlbums.Utilities +namespace CustomAlbums.Utilities; + +internal class Debug { - internal class Debug + [HarmonyPatch("Il2CppInterop.HarmonySupport.Il2CppDetourMethodPatcher", "ReportException")] + internal static class Il2CppDetourMethodPatcherPatch { - [HarmonyPatch("Il2CppInterop.HarmonySupport.Il2CppDetourMethodPatcher", "ReportException")] - internal static class Il2CppDetourMethodPatcherPatch - { - private static readonly Logger Logger = new(nameof(Il2CppDetourMethodPatcherPatch)); + private static readonly Logger Logger = new(nameof(Il2CppDetourMethodPatcherPatch)); - private static bool Prefix(Exception ex) - { - Logger.Msg("During invoking native->managed trampoline: " + ex); - return false; - } + private static bool Prefix(Exception ex) + { + Logger.Msg("During invoking native->managed trampoline: " + ex); + return false; } } } \ No newline at end of file diff --git a/Utilities/Formatting.cs b/Utilities/Formatting.cs index 0aa3c1b..f866330 100644 --- a/Utilities/Formatting.cs +++ b/Utilities/Formatting.cs @@ -2,31 +2,50 @@ namespace CustomAlbums.Utilities; -public static class Formatting { +public static class Formatting +{ public static int ParseAsInt(this string value) - => int.Parse(value, NumberStyles.Number, CultureInfo.InvariantCulture); + { + return int.Parse(value, NumberStyles.Number, CultureInfo.InvariantCulture); + } public static float ParseAsFloat(this string value) - => float.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture); + { + return float.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture); + } public static decimal ParseAsDecimal(this string value) - => decimal.Parse(value, NumberStyles.Number, CultureInfo.InvariantCulture); - + { + return decimal.Parse(value, NumberStyles.Number, CultureInfo.InvariantCulture); + } + public static bool TryParseAsInt(this string value, out int result) - => int.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out result); - + { + return int.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out result); + } + public static bool TryParseAsFloat(this string value, out float result) - => float.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out result); - + { + return float.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out result); + } + public static bool TryParseAsDecimal(this string value, out decimal result) - => decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out result); - + { + return decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out result); + } + public static string ToStringInvariant(this int value, string format = "") - => value.ToString(format, CultureInfo.InvariantCulture); - + { + return value.ToString(format, CultureInfo.InvariantCulture); + } + public static string ToStringInvariant(this float value, string format = "") - => value.ToString(format, CultureInfo.InvariantCulture); - + { + return value.ToString(format, CultureInfo.InvariantCulture); + } + public static string ToStringInvariant(this decimal value, string format = "") - => value.ToString(format, CultureInfo.InvariantCulture); + { + return value.ToString(format, CultureInfo.InvariantCulture); + } } \ No newline at end of file diff --git a/Utilities/Il2CppExtensions.cs b/Utilities/Il2CppExtensions.cs index 270cf19..f12469e 100644 --- a/Utilities/Il2CppExtensions.cs +++ b/Utilities/Il2CppExtensions.cs @@ -1,100 +1,99 @@ -using Il2CppInterop.Runtime.InteropTypes.Arrays; -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; +using Il2CppInterop.Runtime.InteropTypes.Arrays; -namespace CustomAlbums.Utilities +namespace CustomAlbums.Utilities; + +public static class Il2CppExtensions { - public static class Il2CppExtensions + public static Il2CppSystem.Collections.Generic.List ToIl2Cpp(this List list) { - public static Il2CppSystem.Collections.Generic.List ToIl2Cpp(this List list) - { - var il2Cpp = new Il2CppSystem.Collections.Generic.List(list.Count); - foreach (var item in list) il2Cpp.Add(item); + var il2Cpp = new Il2CppSystem.Collections.Generic.List(list.Count); + foreach (var item in list) il2Cpp.Add(item); - return il2Cpp; - } + return il2Cpp; + } - public static Il2CppSystem.Collections.Generic.List ToIl2Cpp(this IEnumerable list) - { - var array = list.ToArray(); - var il2Cpp = new Il2CppSystem.Collections.Generic.List(array.Length); - foreach (var item in array) il2Cpp.Add(item); + public static Il2CppSystem.Collections.Generic.List ToIl2Cpp(this IEnumerable list) + { + var array = list.ToArray(); + var il2Cpp = new Il2CppSystem.Collections.Generic.List(array.Length); + foreach (var item in array) il2Cpp.Add(item); - return il2Cpp; - } + return il2Cpp; + } - public static Il2CppSystem.Collections.Generic.List ToIl2Cpp(this ReadOnlyCollection collection) - { - var il2Cpp = new Il2CppSystem.Collections.Generic.List(collection.Count); - foreach (var item in collection) il2Cpp.Add(item); + public static Il2CppSystem.Collections.Generic.List ToIl2Cpp(this ReadOnlyCollection collection) + { + var il2Cpp = new Il2CppSystem.Collections.Generic.List(collection.Count); + foreach (var item in collection) il2Cpp.Add(item); - return il2Cpp; - } + return il2Cpp; + } - public static Il2CppSystem.Collections.Generic.Dictionary ToIl2Cpp( - this Dictionary dictionary) - { - var il2Cpp = new Il2CppSystem.Collections.Generic.Dictionary(dictionary.Count); - foreach (var (key, value) in dictionary) il2Cpp.Add(key, value); + public static Il2CppSystem.Collections.Generic.Dictionary ToIl2Cpp( + this Dictionary dictionary) + { + var il2Cpp = new Il2CppSystem.Collections.Generic.Dictionary(dictionary.Count); + foreach (var (key, value) in dictionary) il2Cpp.Add(key, value); - return il2Cpp; - } + return il2Cpp; + } - public static List ToManaged(this Il2CppSystem.Collections.Generic.List list) - { - var managed = new List(list.Count); - foreach (var item in list) managed.Add(item); + public static List ToManaged(this Il2CppSystem.Collections.Generic.List list) + { + var managed = new List(list.Count); + foreach (var item in list) managed.Add(item); - return managed; - } + return managed; + } - public static Dictionary ToManaged( - this Il2CppSystem.Collections.Generic.Dictionary dictionary) - { - var managed = new Dictionary(dictionary.Count); - foreach (var entry in dictionary) managed.Add(entry.Key, entry.Value); + public static Dictionary ToManaged( + this Il2CppSystem.Collections.Generic.Dictionary dictionary) + { + var managed = new Dictionary(dictionary.Count); + foreach (var entry in dictionary) managed.Add(entry.Key, entry.Value); - return managed; - } + return managed; + } - public static void AddManagedRange(this Il2CppSystem.Collections.Generic.List il2cpp, - IEnumerable managed) - { - var array = managed.ToArray(); - il2cpp.Capacity += array.Length; - foreach (var item in array) il2cpp.Add(item); - } + public static void AddManagedRange(this Il2CppSystem.Collections.Generic.List il2cpp, + IEnumerable managed) + { + var array = managed.ToArray(); + il2cpp.Capacity += array.Length; + foreach (var item in array) il2cpp.Add(item); + } - public static bool TryGetValuePossibleNullKey( - this Il2CppSystem.Collections.Generic.Dictionary dict, TKey key, out TValue outValue) - { - outValue = default; - if (key == null || !dict.ContainsKey(key)) return false; - outValue = dict[key]; - return true; - } + public static bool TryGetValuePossibleNullKey( + this Il2CppSystem.Collections.Generic.Dictionary dict, TKey key, out TValue outValue) + { + outValue = default; + if (key == null || !dict.ContainsKey(key)) return false; + outValue = dict[key]; + return true; + } - public static unsafe Il2CppStructArray CopyFromManaged(this T[] arr, long? size = null) where T : unmanaged + public static unsafe Il2CppStructArray CopyFromManaged(this T[] arr, long? size = null) where T : unmanaged + { + var len = arr.Length; + if (size is not null && size < 0) + throw new ArgumentOutOfRangeException(nameof(size), "The size to copy cannot be negative."); + + var lenCopy = size ?? len; + if (lenCopy > len) + throw new ArgumentOutOfRangeException(nameof(size), "The size to copy is larger than the array length."); + + var il2CppArray = new Il2CppStructArray(len); + + fixed (void* managedArrayBase = &arr[0]) { - var len = arr.Length; - if (size is not null && size < 0) - throw new ArgumentOutOfRangeException(nameof(size), "The size to copy cannot be negative."); - - var lenCopy = size ?? len; - if (lenCopy > len) - throw new ArgumentOutOfRangeException(nameof(size), "The size to copy is larger than the array length."); - - var il2CppArray = new Il2CppStructArray(len); - - fixed (void* managedArrayBase = &arr[0]) - { - Buffer.MemoryCopy( - managedArrayBase, - IntPtr.Add(il2CppArray.Pointer, 4 * IntPtr.Size).ToPointer(), - checked(il2CppArray.Length * sizeof(T)), - checked(lenCopy * sizeof(T))); - } - - return il2CppArray; + Buffer.MemoryCopy( + managedArrayBase, + IntPtr.Add(il2CppArray.Pointer, 4 * IntPtr.Size).ToPointer(), + checked(il2CppArray.Length * sizeof(T)), + checked(lenCopy * sizeof(T))); } + + return il2CppArray; } } \ No newline at end of file diff --git a/Utilities/Interop.cs b/Utilities/Interop.cs index 806bf25..ec9bc70 100644 --- a/Utilities/Interop.cs +++ b/Utilities/Interop.cs @@ -1,19 +1,18 @@ using Il2CppInterop.Runtime; -namespace CustomAlbums.Utilities +namespace CustomAlbums.Utilities; + +public class Interop { - public class Interop + /// + /// A workaround to a memory corruption issue of creating Il2CppSystem.TypeValue types using C#'s new operator. + /// This will likely be deleted/become deprecated in the future. + /// + /// An Il2CppSystem.TypeValue type + /// A new object of type T + public static T CreateTypeValue() { - /// - /// A workaround to a memory corruption issue of creating Il2CppSystem.TypeValue types using C#'s new operator. - /// This will likely be deleted/become deprecated in the future. - /// - /// An Il2CppSystem.TypeValue type - /// A new object of type T - public static T CreateTypeValue() - { - return (T)Activator.CreateInstance(typeof(T), - IL2CPP.il2cpp_object_new(Il2CppClassPointerStore.NativeClassPtr)); - } + return (T)Activator.CreateInstance(typeof(T), + IL2CPP.il2cpp_object_new(Il2CppClassPointerStore.NativeClassPtr)); } } \ No newline at end of file diff --git a/Utilities/Json.cs b/Utilities/Json.cs index c8feddd..1435529 100644 --- a/Utilities/Json.cs +++ b/Utilities/Json.cs @@ -5,60 +5,59 @@ using Decimal = Il2CppSystem.Decimal; using JsonSerializer = System.Text.Json.JsonSerializer; -namespace CustomAlbums.Utilities +namespace CustomAlbums.Utilities; + +public static class Json { - public static class Json + private static readonly JsonSerializerOptions DeserializeOptions = new() { - private static readonly JsonSerializerOptions DeserializeOptions = new() - { - PropertyNameCaseInsensitive = true, - AllowTrailingCommas = true, + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, - // God this sucks - ReadCommentHandling = JsonCommentHandling.Skip, + // God this sucks + ReadCommentHandling = JsonCommentHandling.Skip, - Converters = - { - new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) - } - }; - - public static T Deserialize(string json) + Converters = { - return JsonSerializer.Deserialize(json, DeserializeOptions); + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; - public static T Deserialize(Stream stream) - { - return JsonSerializer.Deserialize(stream, DeserializeOptions); - } + public static T Deserialize(string json) + { + return JsonSerializer.Deserialize(json, DeserializeOptions); + } - public static JsonArray ToJsonArray(this IEnumerable list) - { - var array = new JsonArray(); - foreach (var item in list) array.Add(item); + public static T Deserialize(Stream stream) + { + return JsonSerializer.Deserialize(stream, DeserializeOptions); + } - return array; - } + public static JsonArray ToJsonArray(this IEnumerable list) + { + var array = new JsonArray(); + foreach (var item in list) array.Add(item); - public static T Il2CppJsonDeserialize(string text) - { - return JsonConvert.DeserializeObject(text); - } + return array; + } - /// - /// Fixes strange issue where getting a single as a decimal does not work. - /// - /// A - /// The parsed value - public static decimal GetValueAsDecimal(this JsonNode node) - { - return node.ToString().TryParseAsDecimal(out var result) ? result : 0M; - } + public static T Il2CppJsonDeserialize(string text) + { + return JsonConvert.DeserializeObject(text); + } - public static Decimal GetValueAsIl2CppDecimal(this JsonNode node) - { - return (Decimal)(float)node.GetValueAsDecimal(); - } + /// + /// Fixes strange issue where getting a single as a decimal does not work. + /// + /// A + /// The parsed value + public static decimal GetValueAsDecimal(this JsonNode node) + { + return node.ToString().TryParseAsDecimal(out var result) ? result : 0M; + } + + public static Decimal GetValueAsIl2CppDecimal(this JsonNode node) + { + return (Decimal)(float)node.GetValueAsDecimal(); } } \ No newline at end of file diff --git a/Utilities/Logger.cs b/Utilities/Logger.cs index 8df8450..7517168 100644 --- a/Utilities/Logger.cs +++ b/Utilities/Logger.cs @@ -1,41 +1,40 @@ using System.Drawing; using MelonLoader; -namespace CustomAlbums.Utilities +namespace CustomAlbums.Utilities; + +public class Logger { - public class Logger + private readonly MelonLogger.Instance _logger; + + public Logger(string className) + { + _logger = new MelonLogger.Instance(className, Color.FromArgb(255, 0, 255, 150)); + } + + public void Msg(string message, bool verbose = true) + { + if (verbose && !ModSettings.VerboseLogging) return; + _logger.Msg(message); + } + + public void Success(string message) + { + _logger.Msg(ConsoleColor.Green, "Success: " + message); + } + + public void Warning(string message) + { + _logger.Msg(ConsoleColor.Yellow, "Warning: " + message); + } + + public void Fail(string message) + { + _logger.Msg(ConsoleColor.Red, "FAILED: " + message); + } + + public void Error(string message) { - private readonly MelonLogger.Instance _logger; - - public Logger(string className) - { - _logger = new MelonLogger.Instance(className, Color.FromArgb(255, 0, 255, 150)); - } - - public void Msg(string message, bool verbose = true) - { - if (verbose && !ModSettings.VerboseLogging) return; - _logger.Msg(message); - } - - public void Success(string message) - { - _logger.Msg(ConsoleColor.Green, "Success: " + message); - } - - public void Warning(string message) - { - _logger.Msg(ConsoleColor.Yellow, "Warning: " + message); - } - - public void Fail(string message) - { - _logger.Msg(ConsoleColor.Red, "FAILED: " + message); - } - - public void Error(string message) - { - _logger.Msg(ConsoleColor.Red, "ERROR: " + message); - } + _logger.Msg(ConsoleColor.Red, "ERROR: " + message); } } \ No newline at end of file diff --git a/Utilities/PackExtensions.cs b/Utilities/PackExtensions.cs index 8f78376..98768a5 100644 --- a/Utilities/PackExtensions.cs +++ b/Utilities/PackExtensions.cs @@ -1,21 +1,16 @@ -using System; -using System.Collections.Generic; -using System.IO.Compression; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.IO.Compression; -namespace CustomAlbums.Utilities +namespace CustomAlbums.Utilities; + +internal static class PackExtensions { - internal static class PackExtensions + private static readonly Logger Logger = new(nameof(PackExtensions)); + + public static ZipArchive GetNestedZip(this ZipArchive mdp, string entryName) { - private static readonly Logger Logger = new(nameof(PackExtensions)); - public static ZipArchive GetNestedZip(this ZipArchive mdp, string entryName) - { - var mdm = mdp.GetEntry(entryName) ?? throw new ArgumentException($"Entry {entryName} not found."); - var mdmStream = mdm.Open(); - var openedMdm = new ZipArchive(mdmStream, ZipArchiveMode.Read, false); - return openedMdm; - } + var mdm = mdp.GetEntry(entryName) ?? throw new ArgumentException($"Entry {entryName} not found."); + var mdmStream = mdm.Open(); + var openedMdm = new ZipArchive(mdmStream, ZipArchiveMode.Read, false); + return openedMdm; } -} +} \ No newline at end of file diff --git a/Utilities/SaveExtensions.cs b/Utilities/SaveExtensions.cs index c42ae04..2c5008a 100644 --- a/Utilities/SaveExtensions.cs +++ b/Utilities/SaveExtensions.cs @@ -1,38 +1,37 @@ using CustomAlbums.Data; using CustomAlbums.Managers; -namespace CustomAlbums.Utilities +namespace CustomAlbums.Utilities; + +public static class SaveExtensions { - public static class SaveExtensions + private static readonly Logger Logger = new(nameof(SaveExtensions)); + + /// + /// Gets the chart save data given the chart UID. + /// + /// The save file data class. + /// The chart UID. + /// A object consisting of score information from the current chart's UID. + public static SaveData GetChartSaveDataFromUid(this CustomAlbumsSave save, string uid) { - private static readonly Logger Logger = new(nameof(SaveExtensions)); + var album = AlbumManager.GetByUid(uid); - // This class is only needed when you're accessing GetChartSaveDataFromUid - // No need to make this part of the Data area since it's a fragment of other data classes - public class SaveData - { - public Dictionary Highest { get; set; } - public List FullCombo { get; set; } - } + if (album is null) return null; - /// - /// Gets the chart save data given the chart UID. - /// - /// The save file data class. - /// The chart UID. - /// A object consisting of score information from the current chart's UID. - public static SaveData GetChartSaveDataFromUid(this CustomAlbumsSave save, string uid) + var key = album!.AlbumName; + return new SaveData { - var album = AlbumManager.GetByUid(uid); - - if (album is null) return null; + Highest = save.Highest.GetValueOrDefault(key), + FullCombo = save.FullCombo.GetValueOrDefault(key) + }; + } - var key = album!.AlbumName; - return new SaveData - { - Highest = save.Highest.GetValueOrDefault(key), - FullCombo = save.FullCombo.GetValueOrDefault(key) - }; - } + // This class is only needed when you're accessing GetChartSaveDataFromUid + // No need to make this part of the Data area since it's a fragment of other data classes + public class SaveData + { + public Dictionary Highest { get; set; } + public List FullCombo { get; set; } } } \ No newline at end of file diff --git a/Utilities/StreamExtensions.cs b/Utilities/StreamExtensions.cs index 38f01d0..5220359 100644 --- a/Utilities/StreamExtensions.cs +++ b/Utilities/StreamExtensions.cs @@ -1,46 +1,47 @@ using System.Security.Cryptography; -namespace CustomAlbums.Utilities +namespace CustomAlbums.Utilities; + +public static class StreamExtensions { - public static class StreamExtensions + private static readonly Logger Logger = new(nameof(StreamExtensions)); + + public static string GetHash(this Stream stream) { - private static readonly Logger Logger = new(nameof(StreamExtensions)); - public static string GetHash(this Stream stream) - { - byte[] hash; - if (stream is MemoryStream ms) - hash = MD5.Create().ComputeHash(ms.ToArray()); - else - hash = MD5.Create().ComputeHash(stream.ToMemoryStream().ToArray()); - - return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); - } + byte[] hash; + if (stream is MemoryStream ms) + hash = MD5.Create().ComputeHash(ms.ToArray()); + else + hash = MD5.Create().ComputeHash(stream.ToMemoryStream().ToArray()); - public static byte[] ReadFully(this MemoryStream stream) - { - var buffer = new byte[1024 * 16]; - int read; + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } - using var ms = new MemoryStream(); - while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) - ms.Write(buffer, 0, read); + public static byte[] ReadFully(this MemoryStream stream) + { + var buffer = new byte[1024 * 16]; + int read; - return ms.ToArray(); + using var ms = new MemoryStream(); + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + ms.Write(buffer, 0, read); + + return ms.ToArray(); + } + + public static MemoryStream ToMemoryStream(this Stream stream) + { + var ms = new MemoryStream(); + try + { + stream.CopyTo(ms); + ms.Position = 0; } - public static MemoryStream ToMemoryStream(this Stream stream) + catch (Exception ex) { - var ms = new MemoryStream(); - try - { - stream.CopyTo(ms); - ms.Position = 0; - } - catch (Exception ex) - { - Logger.Warning(ex.Message); - } - - return ms; + Logger.Warning(ex.Message); } + + return ms; } } \ No newline at end of file diff --git a/Utilities/StringExtensions.cs b/Utilities/StringExtensions.cs index bcd4449..e79e6bd 100644 --- a/Utilities/StringExtensions.cs +++ b/Utilities/StringExtensions.cs @@ -1,14 +1,18 @@ -namespace CustomAlbums.Utilities +namespace CustomAlbums.Utilities; + +public static class StringExtensions { - public static class StringExtensions + /// + /// Compares two strings using the comparison type. + /// + /// First string to compare + /// + /// + /// if the value of the is the same as this string; otherwise, + /// . + /// + public static bool EqualsCaseInsensitive(this string str1, string str2) { - /// - /// Compares two strings using the comparison type. - /// - /// First string to compare - /// - /// if the value of the is the same as this string; otherwise, . - public static bool EqualsCaseInsensitive(this string str1, string str2) - => str1.Equals(str2, StringComparison.OrdinalIgnoreCase); + return str1.Equals(str2, StringComparison.OrdinalIgnoreCase); } -} +} \ No newline at end of file From b5776f1e8009674412ad217c71b886ff9c85fa0f Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:47:12 -0500 Subject: [PATCH 24/27] Undo pack reliancy because it was a stupid idea --- Managers/HotReloadManager.cs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Managers/HotReloadManager.cs b/Managers/HotReloadManager.cs index 15d404d..fd9ea6d 100644 --- a/Managers/HotReloadManager.cs +++ b/Managers/HotReloadManager.cs @@ -111,19 +111,10 @@ private static int ProcessAdditions() var jsonArray = new JsonArray(); var localJsonArray = new JsonArray(); - var firstSongs = new HashSet(AlbumManager.LoadedAlbums.Values - .GroupBy(a => PackManager.GetPackFromUid(a.Uid)) - .Select(g => g.OrderBy(a => a.Index).First().Uid)); - foreach (var (albumStr, albumObj) in AlbumManager.LoadedAlbums) { var aInfo = albumObj.Info; - var pack = PackManager.GetPackFromUid(albumObj.Uid); - var isFirstSong = firstSongs.Contains(albumObj.Uid); - var titleString = pack?.Title ?? "Unclassified"; - var displayName = aInfo.Name ?? ""; - if (isFirstSong) displayName = $"【{titleString}】 {aInfo.Name}"; var customChartJson = new { From 89da92e666d75203f39b9e064ae5c42e09a52776 Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:56:41 -0500 Subject: [PATCH 25/27] Folder support for hot reloading --- Managers/HotReloadManager.cs | 214 ++++++++++++++++++++++------------- 1 file changed, 133 insertions(+), 81 deletions(-) diff --git a/Managers/HotReloadManager.cs b/Managers/HotReloadManager.cs index fd9ea6d..24ab785 100644 --- a/Managers/HotReloadManager.cs +++ b/Managers/HotReloadManager.cs @@ -30,30 +30,44 @@ internal static class HotReloadManager private static float _lastProcessTime; - // Thread-safe queues for FileSystemWatcher background events private static ConcurrentQueue AlbumsToAdd { get; } = new(); private static ConcurrentQueue AlbumsToDelete { get; } = new(); private static ConcurrentDictionary LastFileEvent { get; } = new(); internal static PnlStage PnlStageInstance { get; set; } - /// - /// Checks if a file is fully written and no longer locked by other processes. - /// private static bool IsFileUnlocked(string path) { try { - using var fileStream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.None); - return fileStream.Length > 0; + if (Directory.Exists(path)) + { + var infoPath = Path.Combine(path, "info.json"); + if (!File.Exists(infoPath)) return false; + + using var fileStream = File.Open(infoPath, FileMode.Open, FileAccess.Read, FileShare.None); + return fileStream.Length > 0; + } + else + { + if (!File.Exists(path)) return false; + using var fileStream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.None); + return fileStream.Length > 0; + } } - catch (Exception ex) when (ex is FileNotFoundException or IOException) + catch (Exception ex) when (ex is FileNotFoundException or IOException or UnauthorizedAccessException) { return false; } } + private static string GetTopLevelName(string relativePath) + { + var separatorIndex = relativePath.IndexOfAny(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }); + return separatorIndex >= 0 ? relativePath.Substring(0, separatorIndex) : relativePath; + } + private static int ParseDifficulty(string difficulty) { return int.TryParse(difficulty, out var value) ? value : 0; @@ -79,9 +93,6 @@ private static MusicExInfo CreateMusicExInfo(Album album) return musicExInfo; } - /// - /// Hot-add: Injects a new .mdm file into the game's runtime database. - /// private static int ProcessAdditions() { var addedCount = 0; @@ -89,7 +100,6 @@ private static int ProcessAdditions() if (AlbumsToAdd.TryDequeue(out var path)) try { - // 1. Load album into AlbumManager var album = AlbumManager.LoadOne(path); if (album == null) Logger.Warning($"Failed to load album from {path}"); @@ -98,8 +108,6 @@ private static int ProcessAdditions() var albumInfo = album.Info; Logger.Msg($"Adding {albumName} (UID: {uid})"); - // 2. Transmute: Native DBObject generation via JSON - // Instead of reflection, re-serialize ALL custom albums and let the game natively deserialize them! var masterAlbums = Singleton.instance.GetConfigObject(); var albumsInfo = masterAlbums?.GetAlbumsInfoByUid(AlbumManager.MusicPackage); var globalAlbumConfig = albumsInfo != null @@ -152,10 +160,8 @@ private static int ProcessAdditions() var fullJsonStr = JsonSerializer.Serialize(jsonArray); var fullLocalJsonStr = JsonSerializer.Serialize(localJsonArray); - // 1. Re-deserialize the entire list of custom albums into the global config globalAlbumConfig.Deserialize(fullJsonStr); - // 2. Bypass engine cache completely by manually instantiating and injecting the localized databases var localDicProp = typeof(BaseDBConfigLocalObject).GetProperty("m_LocalDic", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); @@ -176,10 +182,8 @@ private static int ProcessAdditions() } } - // 4. Update the global dictionaries GlobalDataBase.s_DbMusicTag.AddAllMusicInfo(globalAlbumConfig); - // 4. Re-initialize ExInfo for all the newly created MusicInfo objects var newMusicInfoList = new Il2CppSystem.Collections.Generic.List(); globalAlbumConfig.GetAllMusicInfo(newMusicInfoList); var idx = 0; @@ -220,7 +224,6 @@ private static int ProcessAdditions() Logger.Error("globalAlbumConfig is null! Cannot inject metadata."); } - // Add to the front-end view list so UI updates correctly try { var dhColBase = DataHelper.collections; @@ -228,14 +231,12 @@ private static int ProcessAdditions() var uids = GlobalDataBase.s_DbMusicTag.m_StageShowMusicUids; if (uids != null) if (!uids.Contains(uid)) - // Only add if it's not currently displaying the collections list to avoid alias pollution if (dhColPtr == IntPtr.Zero || uids.Pointer != dhColPtr) { uids.Add(uid); Logger.Msg($"Added {uid} to m_StageShowMusicUids"); } - // Add to All Music tag (Index 0) var allMusicTag = GlobalDataBase.dbMusicTag.GetAlbumTagInfo(0); if (allMusicTag != null) { @@ -248,14 +249,13 @@ private static int ProcessAdditions() if (d.musicUids.Pointer != dhColPtr) d.musicUids.Add(uid); } - // 5. Removed dicLevelConfig injection. It was causing Headquarters to crash by supplying an incorrect difficulty level. } catch (Exception ex) { Logger.Warning($"Failed to add to view lists: {ex.Message}"); } - // 3. Inject search tags into ConfigManager + // Inject search tags into ConfigManager try { var config = Singleton.instance.GetConfigObject(); @@ -282,10 +282,10 @@ private static int ProcessAdditions() Logger.Warning($"Failed to add search tags: {ex.Message}"); } - // 4. Preload cover resources + // Preload cover resource try { - if (album.HasFile("cover.png") || album.HasFile("cover.gif")) + if (album.HasFile("cover.png")) ResourcesManager.instance .LoadFromName($"{albumName}_cover") .hideFlags |= HideFlags.DontUnloadUnusedAsset; @@ -295,7 +295,6 @@ private static int ProcessAdditions() Logger.Warning($"Failed to preload cover: {ex.Message}"); } - // UI Hijack will handle rendering the name and author without needing these native injections. addedCount++; Logger.Msg($"Successfully added {albumInfo.Name}"); } @@ -308,17 +307,13 @@ private static int ProcessAdditions() return addedCount; } - /// - /// Hot-delete: Removes a specified album from the game's runtime database. - /// private static int ProcessDeletions() { var deletedCount = 0; - if (AlbumsToDelete.TryDequeue(out var albumFileName)) + if (AlbumsToDelete.TryDequeue(out var albumKey)) try { - var albumKey = $"album_{albumFileName}"; Logger.Msg($"Removing {albumKey}"); if (!AlbumManager.LoadedAlbums.TryGetValue(albumKey, out var album)) @@ -339,7 +334,6 @@ private static int ProcessDeletions() var allMusicInfo = GlobalDataBase.s_DbMusicTag.m_AllMusicInfo; if (allMusicInfo != null && allMusicInfo.ContainsKey(uid)) allMusicInfo.Remove(uid); - // Remove from all tags (like "All Music" or "Favorites") if (GlobalDataBase.s_DbMusicTag.m_AllAlbumTagData != null) foreach (var tag in GlobalDataBase.s_DbMusicTag.m_AllAlbumTagData) { @@ -392,9 +386,6 @@ private static int ProcessDeletions() return deletedCount; } - /// - /// Rebuild Custom Albums tag to update the UID list. - /// private static void RebuildCustomAlbumsTag() { try @@ -446,9 +437,6 @@ private static void RefreshUI() } } - /// - /// Consume the queue in Unity's FixedUpdate (main thread safe). - /// internal static void FixedUpdate() { if (AlbumsToAdd.IsEmpty && AlbumsToDelete.IsEmpty) return; @@ -492,9 +480,6 @@ internal static void FixedUpdate() } } - /// - /// Initialize FileSystemWatcher to monitor Custom_Albums directory changes. - /// internal static void OnLateInitializeMelon() { try @@ -509,20 +494,26 @@ internal static void OnLateInitializeMelon() } AlbumManager.AlbumWatcher.Path = watchPath; - AlbumManager.AlbumWatcher.Filter = AlbumManager.SearchPattern; + AlbumManager.AlbumWatcher.IncludeSubdirectories = true; AlbumManager.AlbumWatcher.Created += (_, e) => { + var topLevelName = GetTopLevelName(e.Name); + var fullPath = Path.Combine(watchPath, topLevelName); + var now = DateTime.Now; - if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && + if (LastFileEvent.TryGetValue(topLevelName, out var lastTime) && (now - lastTime).TotalMilliseconds < 500) return; - LastFileEvent[e.FullPath] = now; + LastFileEvent[topLevelName] = now; + + var isMdm = topLevelName.EndsWith(".mdm"); + if (!isMdm && !Directory.Exists(fullPath)) return; - Logger.Msg($"Detected new file: {e.Name}"); + Logger.Msg($"Detected new file or folder: {topLevelName}"); Task.Run(() => { var attempts = 0; - while (!IsFileUnlocked(e.FullPath) && attempts < 50) + while (!IsFileUnlocked(fullPath) && attempts < 50) { Thread.Sleep(200); attempts++; @@ -530,40 +521,77 @@ internal static void OnLateInitializeMelon() if (attempts < 50) { - AlbumsToAdd.Enqueue(e.FullPath); - Logger.Msg($"Queued for addition: {e.Name}"); + AlbumsToAdd.Enqueue(fullPath); + Logger.Msg($"Queued for addition: {topLevelName}"); } else { - Logger.Warning($"Timed out waiting for file: {e.Name}"); + Logger.Warning($"Timed out waiting for file: {topLevelName}"); } }); }; AlbumManager.AlbumWatcher.Deleted += (_, e) => { + var topLevelName = GetTopLevelName(e.Name); + var isInnerFile = e.Name != topLevelName; + var fullPath = Path.Combine(watchPath, topLevelName); + var now = DateTime.Now; - if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && + if (LastFileEvent.TryGetValue(topLevelName, out var lastTime) && (now - lastTime).TotalMilliseconds < 500) return; - LastFileEvent[e.FullPath] = now; + LastFileEvent[topLevelName] = now; + + var isMdm = topLevelName.EndsWith(".mdm"); + var albumKey = isMdm ? $"album_{Path.GetFileNameWithoutExtension(topLevelName)}" : $"album_{topLevelName}_folder"; + if (!isMdm && !AlbumManager.LoadedAlbums.ContainsKey(albumKey)) return; - Logger.Msg($"Detected deletion: {e.Name}"); - AlbumsToDelete.Enqueue(Path.GetFileNameWithoutExtension(e.Name)); + if (isInnerFile) + { + Logger.Msg($"Detected inner deletion: {e.Name}, reloading {topLevelName}"); + Task.Run(() => + { + var attempts = 0; + while (!IsFileUnlocked(fullPath) && attempts < 50) + { + Thread.Sleep(200); + attempts++; + } + if (attempts < 50) + { + AlbumsToDelete.Enqueue(albumKey); + AlbumsToAdd.Enqueue(fullPath); + Logger.Msg($"Queued for reload: {topLevelName}"); + } + }); + } + else + { + Logger.Msg($"Detected deletion: {topLevelName}"); + AlbumsToDelete.Enqueue(albumKey); + } }; AlbumManager.AlbumWatcher.Changed += (_, e) => { if (e.ChangeType != WatcherChangeTypes.Changed) return; + var topLevelName = GetTopLevelName(e.Name); + var fullPath = Path.Combine(watchPath, topLevelName); + var now = DateTime.Now; - if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && + if (LastFileEvent.TryGetValue(topLevelName, out var lastTime) && (now - lastTime).TotalMilliseconds < 500) return; - LastFileEvent[e.FullPath] = now; + LastFileEvent[topLevelName] = now; + + var isMdm = topLevelName.EndsWith(".mdm"); + var albumKey = isMdm ? $"album_{Path.GetFileNameWithoutExtension(topLevelName)}" : $"album_{topLevelName}_folder"; + if (!isMdm && !AlbumManager.LoadedAlbums.ContainsKey(albumKey)) return; Logger.Msg($"Detected change: {e.Name}"); Task.Run(() => { var attempts = 0; - while (!IsFileUnlocked(e.FullPath) && attempts < 50) + while (!IsFileUnlocked(fullPath) && attempts < 50) { Thread.Sleep(200); attempts++; @@ -571,27 +599,63 @@ internal static void OnLateInitializeMelon() if (attempts < 50) { - AlbumsToDelete.Enqueue(Path.GetFileNameWithoutExtension(e.Name)); - AlbumsToAdd.Enqueue(e.FullPath); - Logger.Msg($"Queued for reload: {e.Name}"); + AlbumsToDelete.Enqueue(albumKey); + AlbumsToAdd.Enqueue(fullPath); + Logger.Msg($"Queued for reload: {topLevelName}"); } else { - Logger.Warning($"Timed out waiting for file: {e.Name}"); + Logger.Warning($"Timed out waiting for file: {topLevelName}"); } }); }; AlbumManager.AlbumWatcher.Renamed += (_, e) => { + var oldTopLevelName = GetTopLevelName(e.OldName); + var newTopLevelName = GetTopLevelName(e.Name); + var isInnerRename = e.OldName != oldTopLevelName || e.Name != newTopLevelName; + var fullPath = Path.Combine(watchPath, newTopLevelName); + var now = DateTime.Now; - if (LastFileEvent.TryGetValue(e.FullPath, out var lastTime) && + if (LastFileEvent.TryGetValue(newTopLevelName, out var lastTime) && (now - lastTime).TotalMilliseconds < 500) return; - LastFileEvent[e.FullPath] = now; + LastFileEvent[newTopLevelName] = now; - Logger.Msg($"Detected rename: {e.OldName} -> {e.Name}"); - var oldKey = $"album_{Path.GetFileNameWithoutExtension(e.OldName)}"; - var newKey = $"album_{Path.GetFileNameWithoutExtension(e.Name)}"; + if (isInnerRename) + { + var isMdm = newTopLevelName.EndsWith(".mdm"); + var albumKey = isMdm ? $"album_{Path.GetFileNameWithoutExtension(newTopLevelName)}" : $"album_{newTopLevelName}_folder"; + if (!isMdm && !AlbumManager.LoadedAlbums.ContainsKey(albumKey)) return; + + Logger.Msg($"Detected inner rename: {e.OldName} -> {e.Name}, reloading {newTopLevelName}"); + Task.Run(() => + { + var attempts = 0; + while (!IsFileUnlocked(fullPath) && attempts < 50) + { + Thread.Sleep(200); + attempts++; + } + if (attempts < 50) + { + AlbumsToDelete.Enqueue(albumKey); + AlbumsToAdd.Enqueue(fullPath); + Logger.Msg($"Queued for reload: {newTopLevelName}"); + } + }); + return; + } + + var isOldMdm = oldTopLevelName.EndsWith(".mdm"); + var isNewMdm = newTopLevelName.EndsWith(".mdm"); + + var oldKey = isOldMdm ? $"album_{Path.GetFileNameWithoutExtension(oldTopLevelName)}" : $"album_{oldTopLevelName}_folder"; + var newKey = isNewMdm ? $"album_{Path.GetFileNameWithoutExtension(newTopLevelName)}" : $"album_{newTopLevelName}_folder"; + + if (!isOldMdm && !isNewMdm && !AlbumManager.LoadedAlbums.ContainsKey(oldKey) && !Directory.Exists(fullPath)) return; + + Logger.Msg($"Detected rename: {oldTopLevelName} -> {newTopLevelName}"); if (AlbumManager.LoadedAlbums.Remove(oldKey, out var album)) { @@ -603,12 +667,11 @@ internal static void OnLateInitializeMelon() } else { - // Old album was not loaded (e.g. didn't match 'test*.mdm'). Treat this rename as a new creation event. - Logger.Msg($"Old file was not loaded, treating as new file: {e.Name}"); + Logger.Msg($"Old file was not loaded, treating as new file: {newTopLevelName}"); Task.Run(() => { var attempts = 0; - while (!IsFileUnlocked(e.FullPath) && attempts < 50) + while (!IsFileUnlocked(fullPath) && attempts < 50) { Thread.Sleep(200); attempts++; @@ -616,12 +679,12 @@ internal static void OnLateInitializeMelon() if (attempts < 50) { - AlbumsToAdd.Enqueue(e.FullPath); - Logger.Msg($"Queued for addition: {e.Name}"); + AlbumsToAdd.Enqueue(fullPath); + Logger.Msg($"Queued for addition: {newTopLevelName}"); } else { - Logger.Warning($"Timed out waiting for file: {e.Name}"); + Logger.Warning($"Timed out waiting for file: {newTopLevelName}"); } }); } @@ -637,14 +700,6 @@ internal static void OnLateInitializeMelon() } } - // ============================================================ - // Harmony Patches - // ============================================================ - - /// - /// Intercepts GetMusicInfoFromAll: Returns our manually created MusicInfo - /// when the game looks up a hot-loaded song. - /// [HarmonyPatch(typeof(DBMusicTag), nameof(DBMusicTag.GetMusicInfoFromAll))] internal static class GetMusicInfoFromAllPatch { @@ -656,9 +711,6 @@ private static void Postfix(string musicUid, ref MusicInfo __result) } } - /// - /// Capture PnlStage instance reference. - /// [HarmonyPatch(typeof(PnlStage), nameof(PnlStage.PreWarm))] internal static class StagePreWarmPatch { From c844774548011a149ba8c2e66521d414800381c3 Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:18:27 -0500 Subject: [PATCH 26/27] Bump version --- Main.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Main.cs b/Main.cs index e932cc3..59d5849 100644 --- a/Main.cs +++ b/Main.cs @@ -10,7 +10,7 @@ public class Main : MelonMod { public const string MelonName = "CustomAlbums"; public const string MelonAuthor = "Two Fellas"; - public const string MelonVersion = "4.1.9"; + public const string MelonVersion = "4.2.0"; private static readonly Logger Logger = new("CustomAlbums"); public override void OnInitializeMelon() @@ -33,14 +33,12 @@ public override void OnInitializeMelon() public override void OnLateInitializeMelon() { base.OnLateInitializeMelon(); - // TODO: Actually write HotReload HotReloadManager.OnLateInitializeMelon(); } public override void OnFixedUpdate() { base.OnFixedUpdate(); - // TODO: Actually write HotReload HotReloadManager.FixedUpdate(); } From ce9eac40df608de21bc3566fc06b123be2e1c704 Mon Sep 17 00:00:00 2001 From: ALLMarvelous <53633401+ALLMarvelous@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:24:49 -0500 Subject: [PATCH 27/27] Fixed mass destruction caused by merge --- Patches/SceneEggPatch.cs | 41 +++++++++++++--------------------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/Patches/SceneEggPatch.cs b/Patches/SceneEggPatch.cs index f4c7c20..b03dcb7 100644 --- a/Patches/SceneEggPatch.cs +++ b/Patches/SceneEggPatch.cs @@ -63,24 +63,12 @@ private static bool Prefix(string sceneFestivalName, ref string __result) // If the scene is not scene_05 or scene_08 then there is no Christmas if (sceneFestivalName != "scene_05") return true; - /// - /// Makes scene_05 be scene_05_christmas if the Christmas SceneEgg is enabled. - /// - [HarmonyPatch(typeof(GameMusicScene), nameof(GameMusicScene.SceneFestival))] - internal class SceneFestivalPatch - { - private static bool Prefix(string sceneFestivalName, ref string __result) - { - // If the scene is not scene_05 or scene_08 then there is no Christmas - if (sceneFestivalName != "scene_05") return true; - - // Ignore the actual SceneEggs - if (IgnoreSceneEggs(out _, SceneEggs.Arknights, SceneEggs.Cytus, SceneEggs.None, - SceneEggs.Queen, SceneEggs.Touhou, SceneEggs.Wacca, SceneEggs.Miku, SceneEggs.BadApple, SceneEggs.RinLen, SceneEggs.BlueArchive)) return true; - - if (sceneFestivalName == "scene_05") __result = "scene_05_christmas"; - return false; - } + // Ignore the actual SceneEggs + if (IgnoreSceneEggs(out _, SceneEggs.Arknights, SceneEggs.Cytus, SceneEggs.None, + SceneEggs.Queen, SceneEggs.Touhou, SceneEggs.Wacca, SceneEggs.Miku, SceneEggs.BadApple, SceneEggs.RinLen, SceneEggs.BlueArchive)) return true; + + if (sceneFestivalName == "scene_05") __result = "scene_05_christmas"; + return false; } } @@ -92,16 +80,13 @@ internal class BossFestivalPatch { private static bool Prefix(string bossFestivalName, ref string __result) { - private static bool Prefix(string bossFestivalName, ref string __result) - { - // If the boss is not 0501_boss then there is no Christmas - if (bossFestivalName != "0501_boss") return true; - if (IgnoreSceneEggs(out _, SceneEggs.Arknights, SceneEggs.Cytus, SceneEggs.None, - SceneEggs.Queen, SceneEggs.Touhou, SceneEggs.Wacca, SceneEggs.Miku, SceneEggs.BadApple, SceneEggs.RinLen, SceneEggs.BlueArchive)) return true; - - __result = "0501_boss_christmas"; - return false; - } + // If the boss is not 0501_boss then there is no Christmas + if (bossFestivalName != "0501_boss") return true; + if (IgnoreSceneEggs(out _, SceneEggs.Arknights, SceneEggs.Cytus, SceneEggs.None, + SceneEggs.Queen, SceneEggs.Touhou, SceneEggs.Wacca, SceneEggs.Miku, SceneEggs.BadApple, SceneEggs.RinLen, SceneEggs.BlueArchive)) return true; + + __result = "0501_boss_christmas"; + return false; } }