diff --git a/.gitignore b/.gitignore index 303df77..15f6b4e 100644 --- a/.gitignore +++ b/.gitignore @@ -397,3 +397,11 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml .idea + +# Custom exclusions +Headquarters.dll +HQ.cs.txt +HQ_utf8.cs.txt +test.cs +metadata_injection_attempts.md + diff --git a/CustomAlbums.csproj b/CustomAlbums.csproj index d7f825e..b9c60f9 100644 --- a/CustomAlbums.csproj +++ b/CustomAlbums.csproj @@ -1,4 +1,4 @@ - + net6.0 enable @@ -7,6 +7,7 @@ true AnyCPU;x64 true + CustomAlbums @@ -20,7 +21,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -28,14 +29,14 @@ contentFiles - + - + diff --git a/Data/Album.cs b/Data/Album.cs index 0fae3f4..1143e15 100644 --- a/Data/Album.cs +++ b/Data/Album.cs @@ -1,4 +1,4 @@ -using System.IO.Compression; +using System.IO.Compression; using CustomAlbums.Managers; using CustomAlbums.Utilities; using UnityEngine; @@ -10,6 +10,36 @@ 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; + + HasGif = openedZip.GetEntry("cover.gif") != null; + 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 @@ -24,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)) { @@ -43,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 { @@ -56,10 +91,13 @@ 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; } 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(); @@ -69,12 +107,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) : PackAlbumName)}{(PackName != null && !string.IsNullOrEmpty(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 +148,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 +186,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 +221,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); @@ -165,11 +259,13 @@ public MemoryStream OpenMemoryStream(string file) } private void GetSheets() { - // Adds to the Sheets dictionary - foreach (var difficulty in Info.Difficulties.Keys.Where(difficulty => HasFile($"map{difficulty}.bms"))) + // Always populate all 5 difficulties so Headquarters doesn't throw KeyNotFoundException + for (var difficulty = 1; difficulty <= 5; difficulty++) + { Sheets.Add(difficulty, new Sheet(this, difficulty)); + } } - public bool HasDifficulty(int difficulty) => Sheets.ContainsKey(difficulty); + public bool HasDifficulty(int difficulty) => Sheets.TryGetValue(difficulty, out var sheet) && sheet.HasFile; } } \ No newline at end of file diff --git a/Data/Pack.cs b/Data/Pack.cs index b6957c7..8366853 100644 --- a/Data/Pack.cs +++ b/Data/Pack.cs @@ -1,4 +1,6 @@ -using CustomAlbums.Managers; +using CustomAlbums.Managers; +using CustomAlbums.Utilities; +using System.IO.Compression; namespace CustomAlbums.Data { @@ -8,7 +10,64 @@ public class Pack public string TitleColorHex { get; set; } = "#ffffff"; 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; + + public bool HasFile(string name) + { + if (string.IsNullOrEmpty(Path)) return false; + + // If it is a directory, check if the file exists directly on disk + if (System.IO.Directory.Exists(Path)) + { + return System.IO.File.Exists(System.IO.Path.Combine(Path, name)); + } + + 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; + + // If it is a directory, read the file stream from disk + if (System.IO.Directory.Exists(Path)) + { + var filePath = System.IO.Path.Combine(Path, file); + if (System.IO.File.Exists(filePath)) + { + return System.IO.File.OpenRead(filePath).ToMemoryStream(); + } + 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/Data/Sheet.cs b/Data/Sheet.cs index 9fcb073..2494ec8 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; @@ -21,17 +21,30 @@ public Sheet(Album parentAlbum, int difficulty) public string MapName { get; } public int Difficulty { get; } public bool TalkFileVersion2 { get; set; } + + public bool HasFile => ParentAlbum.HasFile($"map{Difficulty}.bms"); public string Md5 { get { - using var stream = ParentAlbum.OpenMemoryStream($"map{Difficulty}.bms"); - return stream.GetHash(); + if (!HasFile) return "00000000000000000000000000000000"; + try + { + using var stream = ParentAlbum.OpenMemoryStream($"map{Difficulty}.bms"); + return stream.GetHash(); + } + catch (System.Exception ex) + { + Logger.Warning($"Failed to calculate MD5 for {MapName}: {ex.Message}"); + return "00000000000000000000000000000000"; + } } } public StageInfo GetStage() { + if (!HasFile) return null; + // 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 diff --git a/Directory.Build.props b/Directory.Build.props index 8f8cf5d..d8675a8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -150,6 +150,12 @@ false $(MelonAssemblies)\UnityEngine.AnimationModule.dll + + false + false + $(MelonAssemblies)\UnityEngine.TextRenderingModule.dll + false @@ -179,5 +185,11 @@ false $(MelonAssemblies)\UnityEngine.UnityWebRequestModule.dll + + false + false + $(MelonAssemblies)\UnityEngine.InputLegacyModule.dll + \ No newline at end of file diff --git a/Main.cs b/Main.cs index 4c995ba..6743915 100644 --- a/Main.cs +++ b/Main.cs @@ -1,4 +1,4 @@ -using CustomAlbums.Managers; +using CustomAlbums.Managers; using CustomAlbums.Patches; using CustomAlbums.Utilities; using MelonLoader; @@ -20,6 +20,7 @@ public override void OnInitializeMelon() if (!Directory.Exists(AlbumManager.SearchPath)) Directory.CreateDirectory(AlbumManager.SearchPath); + TitleConfigManager.Load(); ModSettings.Register(); AssetPatch.AttachHook(); SavePatch.AttachHook(); @@ -31,8 +32,7 @@ public override void OnInitializeMelon() public override void OnLateInitializeMelon() { base.OnLateInitializeMelon(); - // TODO: Actually write HotReload - // HotReloadManager.OnLateInitializeMelon(); + HotReloadManager.OnLateInitializeMelon(); } /// @@ -50,8 +50,13 @@ public override void OnUpdate() public override void OnFixedUpdate() { base.OnFixedUpdate(); - // TODO: Actually write HotReload - // HotReloadManager.FixedUpdate(); + 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 03a2522..e099698 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.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,57 +37,157 @@ public static class AlbumManager public static Dictionary LoadedAlbums { get; } = new(); - public static void LoadMany(string directory) + public static Pack 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, directory); + CurrentPack = pack.Title; + + // StartIndex for pack + 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) + { + Logger.Warning($"Failed to load album at {directory}. Reason: {ex.Message}"); + Logger.Warning(ex.StackTrace); + + return null; + } + } + + public static Pack LoadFolderPack(string directory) + { + try + { + var packJsonPath = System.IO.Path.Combine(directory, "pack.json"); + if (!System.IO.File.Exists(packJsonPath)) return null; + + // Deserialize pack config file + Pack pack; + using (var stream = System.IO.File.OpenRead(packJsonPath)) + { + pack = Json.Deserialize(stream); + } + pack.Path = directory; + + // Set the current pack title + CurrentPack = pack.Title; + pack.StartIndex = MaxCount; + + // Scan and load all albums + var mdms = System.IO.Directory.GetFiles(directory, "*.mdm"); + pack.Length = mdms.Count(mdmPath => + { + var album = LoadOne(mdmPath); + if (album is not null) + { + pack.Albums.Add(album); + return true; + } + return false; + }); + + // Clean status and register + CurrentPack = null; + PackManager.AddPack(pack); + return pack; + } + catch (Exception ex) + { + // Failed to load folder pack + Logger.Warning($"Failed to load folder pack at {directory}. Reason: {ex.Message}"); + Logger.Warning(ex.StackTrace); + CurrentPack = null; + return null; + } + } + + public static Album LoadOne(string directory, ZipArchiveEntry mdm, string fullFileName) + { + var fileName = Path.GetFileNameWithoutExtension(fullFileName); + + if (LoadedAlbums.ContainsKey($"album_{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!"); + + LoadedAlbums.Add(albumName, album); + MaxCount++; + + if (album.HasPng) + ResourcesManager.instance.LoadFromName($"{albumName}_cover").hideFlags |= + HideFlags.DontUnloadUnusedAsset; - // Initialize pack and variables - var pack = PackManager.CreatePack(json); - CurrentPack = pack.Title; - pack.StartIndex = MaxCount; + if (album.HasGif) CoverManager.EnqueueGifToLoad(album); - // 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) { - 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; + if (LoadedAlbums.ContainsKey($"album_{fileName}")) return null; 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; var albumName = album.AlbumName; LoadedAlbums.Add(albumName, album); + MaxCount++; - if (album.HasFile("cover.png") || album.HasFile("cover.gif")) + if (album.HasPng) ResourcesManager.instance.LoadFromName($"{albumName}_cover").hideFlags |= HideFlags.DontUnloadUnusedAsset; + if (album.HasGif) CoverManager.EnqueueGifToLoad(album); + Logger.Msg($"Loaded {albumName}: {album.Info.Name}"); OnAlbumLoaded?.Invoke(typeof(AlbumManager), new AlbumEventArgs(album)); return album; @@ -102,24 +204,53 @@ public static Album LoadOne(string path) public static void LoadAlbums() { LoadedAlbums.Clear(); - + MaxCount = 0; + + var packs = new List(); var files = new List(); files.AddRange(Directory.GetFiles(SearchPath, SearchPattern)); - files.AddRange(Directory.GetDirectories(SearchPath)); + + // Scan folder packs and regular album directories + foreach (var dir in Directory.GetDirectories(SearchPath)) + { + if (System.IO.File.Exists(System.IO.Path.Combine(dir, "pack.json"))) + { + packs.Add(dir); + } + else + { + files.Add(dir); + } + } + packs.AddRange(Directory.GetFiles(SearchPath, PackSearchPattern)); + foreach (var pack in packs) + { + if (System.IO.Directory.Exists(pack)) + { + LoadFolderPack(pack); + } + else + { + 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; + if (string.IsNullOrEmpty(uid) || !uid.StartsWith($"{Uid}-")) return null; + if (int.TryParse(uid[4..], out var index)) + { + return LoadedAlbums.FirstOrDefault(album => album.Value.Index == index).Value; + } + return null; } public static string GetAlbumNameFromUid(string uid) { 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/Managers/CoverManager.cs b/Managers/CoverManager.cs index 294faa5..1d83e98 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)); @@ -20,10 +49,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 +62,7 @@ public static Sprite GetCover(this Album album) { wrapMode = TextureWrapMode.MirrorOnce }; - texture.LoadImage(bytes); + 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); @@ -40,52 +70,94 @@ public static Sprite GetCover(this Album album) return cover; } - public static unsafe AnimatedCover GetAnimatedCover(this Album album) + private static readonly System.Collections.Concurrent.ConcurrentQueue GifQueue = new(); + private static bool _isProcessingQueue = false; + + // Enqueue charts that need GIF loading to the serial queue + public static void EnqueueGifToLoad(Album album) { - // Early return statements - if (!album.HasFile("cover.gif")) return null; - if (CachedAnimatedCovers.TryGetValue(album.Index, out var cached)) return cached; + if (!album.HasGif) return; + GifQueue.Enqueue(album); + if (!_isProcessingQueue) + { + _isProcessingQueue = true; + Task.Run(ProcessGifQueue); + } + } - Config.PreferContiguousImageBuffers = true; + // Background single-thread loop to process GIF decoding in the queue + private static async Task ProcessGifQueue() + { + try + { + while (GifQueue.TryDequeue(out var album)) + { + await LoadGif(album); + } + } + finally + { + _isProcessingQueue = false; + } + } - // Open and load the gif - using var stream = album.OpenMemoryStream("cover.gif"); - using var gif = Image.Load(new DecoderOptions { Configuration = Config }, stream); + public static async Task LoadGif(this Album album) + { + try + { + if (!album.HasGif) return; + + Config.PreferContiguousImageBuffers = true; - // For some reason Unity loads textures upside down? - // Flip the frames - gif.Mutate(c => c.Flip(FlipMode.Vertical)); + using var stream = album.OpenNullableStream("cover.gif"); + if (stream is null) return; - var sprites = new Sprite[gif.Frames.Count]; + using var gif = await Image.LoadAsync(new DecoderOptions { Configuration = Config }, stream); + gif.Mutate(c => c.Flip(FlipMode.Vertical)); - for (var i = 0; i < 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) + var rawFrames = new RawFrame[gif.Frames.Count]; + + Parallel.For(0, gif.Frames.Count, i => { - Logger.Error("Failed to get pixel data."); - return null; - } + 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)); + } + catch (Exception ex) + { + // Catch exceptions to prevent crashing + Logger.Warning($"Failed to load animated cover for {album.AlbumName}. Reason: {ex.Message}"); + } + } - 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), @@ -95,8 +167,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/Managers/HotReloadManager.cs b/Managers/HotReloadManager.cs index 04c0bdf..2b6604d 100644 --- a/Managers/HotReloadManager.cs +++ b/Managers/HotReloadManager.cs @@ -1,29 +1,79 @@ -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(); + + // Localization texts for the notification banner + private static readonly Dictionary AddedTranslations = new() + { + { "English", "Added {0} charts" }, + { "ChineseS", "添加了 {0} 张谱面" }, + { "ChineseT", "添加了 {0} 張譜面" }, + { "Japanese", "{0}個の譜面を追加しました" }, + { "Korean", "{0}개의 보면을 추가했습니다" } + }; + + private static readonly Dictionary DeletedTranslations = new() + { + { "English", "Deleted {0} charts" }, + { "ChineseS", "删除了 {0} 张谱面" }, + { "ChineseT", "删除了 {0} 張譜面" }, + { "Japanese", "{0}個の譜面を削除しました" }, + { "Korean", "{0}개의 보면을 삭제했습니다" } + }; + + // Gets the formatted notification for the current language + private static string GetLocalizedMessage(Dictionary translations, int count) + { + var language = SingletonScriptableObject.instance?.GetActiveOption("Language") ?? "English"; + if (!translations.TryGetValue(language, out var format)) + { + format = translations["English"]; + } + return string.Format(format, count); + } + + // 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(); private 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,109 +81,733 @@ private static bool IsFileUnlocked(string path) } } - private static void RemoveAllCachedAssets(string albumName) + + 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() { - Logger.Msg("Removing " + albumName + "!"); + 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($"HotReload: Failed to load album from {path}"); + continue; + } + + var albumName = album.AlbumName; + var uid = $"{AlbumManager.Uid}-{album.Index}"; + var albumInfo = album.Info; + Logger.Msg($"HotReload: Adding {albumName} (UID: {uid})", false); + + // 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 (!AlbumManager.LoadedAlbums.TryGetValue($"album_{albumName}", out var album)) return; + if (globalAlbumConfig != null) + { + var jsonArray = new JsonArray(); + var localJsonArray = new JsonArray(); - // 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 firstSongs = new HashSet(AlbumManager.LoadedAlbums.Values + .GroupBy(a => PackManager.GetPackFromUid(a.Uid)) + .Select(g => g.OrderBy(a => a.Index).First().Uid)); - // 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(); + 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"; - // TODO: Remove the album information from the Custom Albums tag menu + var displayName = aInfo.Name ?? ""; + if (isFirstSong) + { + var titleConfig = TitleConfigManager.Config; + var formatStart = $"{(titleConfig.IsBold ? "" : "")}{(titleConfig.IsItalic ? "" : "")}"; + var formatEnd = $"{(titleConfig.IsItalic ? "" : "")}{(titleConfig.IsBold ? "" : "")}"; + displayName = $"{formatStart}【{titleString}】{formatEnd} {aInfo.Name}"; + } - // TODO: Only change the selected album if the selected album was the album that was deleted - Logger.Msg("Successfully removed from cache!"); + 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); + + // 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) + { + var 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); + } + } + } + + // 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) + { + 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($"HotReload: Natively transmuted MusicInfo for {uid}", false); + } + else + { + Logger.Error("HotReload: 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($"HotReload: Added {uid} to m_StageShowMusicUids", false); + } + } + } + // 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($"HotReload: 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($"HotReload: 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($"HotReload: Failed to preload cover: {ex.Message}"); + } + + // UI Hijack will handle rendering the name and author without needing these native injections. + addedCount++; + Logger.Msg($"HotReload: Successfully added {albumInfo.Name}", false); + } + catch (Exception ex) + { + Logger.Warning($"HotReload: 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($"HotReload: Removing {albumKey}", false); + + if (!AlbumManager.LoadedAlbums.TryGetValue(albumKey, out var album)) + { + Logger.Warning($"HotReload: Album {albumKey} not found"); + continue; + } - // 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"); + var uid = $"{AlbumManager.Uid}-{album.Index}"; - Logger.Msg("Successfully modified cache!"); + 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($"HotReload: Failed to remove from DB: {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($"HotReload: Failed to remove search tags: {ex.Message}"); + } + + // Clear resource cache + AlbumManager.LoadedAlbums.Remove(albumKey); + CoverManager.CachedAnimatedCovers.Remove(album.Index); + CoverManager.CachedCovers.Remove(album.Index); + AssetPatch.RemoveFromCache($"{albumKey}_demo"); + AssetPatch.RemoveFromCache($"{albumKey}_music"); + AssetPatch.RemoveFromCache($"{albumKey}_cover"); + + deletedCount++; + Logger.Msg($"HotReload: Removed {albumKey}", false); + } + catch (Exception ex) + { + Logger.Warning($"HotReload: 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($"HotReload: Tag updated ({uids.Count} albums)", false); + } + } + catch (Exception ex) { - // TODO: Write added album hot reloading logic here + Logger.Warning($"HotReload: 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("HotReload: UI refreshed (RefreshStageUI)", false); + } + } + catch (Exception ex) + { + Logger.Warning($"HotReload: 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($"HotReload: Processing queue (add={AlbumsToAdd.Count}, del={AlbumsToDelete.Count})", false); + + var oldSelectedUid = DataHelper.selectedMusicUidFromInfoList; + var oldSelectedAlbumName = AlbumManager.GetAlbumNameFromUid(oldSelectedUid); + + var deletedCount = ProcessDeletions(); + var addedCount = ProcessAdditions(); + + if (deletedCount > 0 || addedCount > 0) + { + if (addedCount > 0) + { + Il2CppAssets.Scripts.UI.Controls.ShowText.ShowInfo(GetLocalizedMessage(AddedTranslations, addedCount)); + } + if (deletedCount > 0) + { + Il2CppAssets.Scripts.UI.Controls.ShowText.ShowInfo(GetLocalizedMessage(DeletedTranslations, deletedCount)); + } + + 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($"HotReload: Updated selected UID from {oldSelectedUid} to {newUid}", false); + } + } + else + { + DataHelper.selectedMusicUidFromInfoList = "0-0"; + Logger.Msg($"HotReload: Selected album was deleted. Resetting selection to 0-0", false); + } + } + + // Registers the hidden difficulty of the hot-reloaded chart + if (addedCount > 0) + { + try + { + HiddenSupportPatch.RegisterHiddenChartsDirectly(); + Logger.Msg("HotReload: Hidden charts registered directly", false); + } + catch (Exception ex) + { + Logger.Warning($"HotReload: Failed to register hidden charts: {ex.Message}"); + } + } + + RebuildCustomAlbumsTag(); + RefreshUI(); + + // Compatibility with HiddenQoL: if one-key toggle is enabled, automatically trigger after hot reload + try + { + var hiddenQolAssembly = System.AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "HiddenQol_Fixed" || a.GetName().Name == "HiddenQol"); + + if (hiddenQolAssembly != null) + { + var saveType = hiddenQolAssembly.GetType("HiddenQol.Save"); + var settingProp = saveType?.GetProperty("Setting", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + + if (settingProp != null) + { + var settingObj = settingProp.GetValue(null); + var qolEnabledField = settingObj?.GetType().GetField("QolEnabled", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + + if (qolEnabledField != null && (bool)qolEnabledField.GetValue(settingObj)) + { + var qolManagerType = hiddenQolAssembly.GetType("HiddenQol.Managers.QoLManager"); + var activateMethod = qolManagerType?.GetMethod("ActivateAllHidden", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + + if (activateMethod != null) + { + activateMethod.Invoke(null, null); + Logger.Msg("HotReload: Triggered HiddenQoL ActivateAllHidden", false); + } + } + } + } + } + catch (Exception ex) + { + Logger.Warning($"HotReload: Failed to trigger HiddenQoL: {ex.Message}"); + } + } } /// - /// 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($"HotReload: Watching directory: {watchPath}", false); + + if (!Directory.Exists(watchPath)) + { + Logger.Warning($"HotReload: 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($"HotReload: Detected new file: {e.Name}", false); + Task.Run(() => + { + var attempts = 0; + while (!IsFileUnlocked(e.FullPath) && attempts < 50) + { + Thread.Sleep(200); + attempts++; + } + + if (attempts < 50) + { + AlbumsToAdd.Enqueue(e.FullPath); + Logger.Msg($"HotReload: Queued for addition: {e.Name}", false); + } + else + { + Logger.Warning($"HotReload: 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($"HotReload: Detected deletion: {e.Name}", false); + 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($"HotReload: Detected change: {e.Name}", false); + 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($"HotReload: Queued for reload: {e.Name}", false); + } + else + { + Logger.Warning($"HotReload: 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($"HotReload: Detected rename: {e.OldName} -> {e.Name}", false); + 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($"HotReload: Renamed {oldKey} -> {newKey}", false); + } + else + { + // Old album was not loaded (e.g. didn't match 'test*.mdm'). Treat this rename as a new creation event. + Logger.Msg($"HotReload: Old file was not loaded, treating as new file: {e.Name}", false); + Task.Run(() => + { + var attempts = 0; + while (!IsFileUnlocked(e.FullPath) && attempts < 50) + { + Thread.Sleep(200); + attempts++; + } + + if (attempts < 50) + { + AlbumsToAdd.Enqueue(e.FullPath); + Logger.Msg($"HotReload: Queued for addition: {e.Name}", false); + } + else + { + Logger.Warning($"HotReload: Timed out waiting for file: {e.Name}"); + } + }); + } + }; + + AlbumManager.AlbumWatcher.EnableRaisingEvents = true; + Logger.Msg("HotReload: FileSystemWatcher initialized!", false); + } + catch (Exception ex) { - Logger.Msg("Renamed file " + e.OldName + " -> " + e.Name); - RenameAllCachedAssets($"album_{Path.GetFileNameWithoutExtension(e.OldName)}", - $"album_{Path.GetFileNameWithoutExtension(e.Name)}"); - }; + Logger.Warning($"HotReload: Init failed: {ex.Message}"); + Logger.Warning(ex.StackTrace); + } + } - // Start the AlbumWatcher - AlbumManager.AlbumWatcher.EnableRaisingEvents = true; + // ============================================================ + // 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 + { + 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; } } } -} \ No newline at end of file +} diff --git a/Managers/PackManager.cs b/Managers/PackManager.cs index 87b636e..1f338b1 100644 --- a/Managers/PackManager.cs +++ b/Managers/PackManager.cs @@ -1,28 +1,31 @@ using CustomAlbums.Data; using CustomAlbums.Utilities; +using System.IO.Compression; 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}-") || !uid[4..].TryParseAsInt(out var uidIndex)) return null; // Retrieve the pack that the uid belongs to - var pack = Packs.FirstOrDefault(pack => + 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(string file) + internal static Pack CreatePack(ZipArchiveEntry json, string path) { - return Json.Deserialize(File.OpenRead(file)); + var pack = Json.Deserialize(json.Open()); + pack.Path = path; + return pack; } internal static void AddPack(Pack pack) 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; } = "-"; diff --git a/Managers/TitleConfigManager.cs b/Managers/TitleConfigManager.cs new file mode 100644 index 0000000..ad34551 --- /dev/null +++ b/Managers/TitleConfigManager.cs @@ -0,0 +1,59 @@ +using System.IO; +using System.Text.Json; + +namespace CustomAlbums.Managers +{ + public class TitleConfig + { + public string Color { get; set; } = "#00FFFF"; + public int Size { get; set; } = 20; + public bool IsBold { get; set; } = true; + public bool IsItalic { get; set; } = false; + } + + public static class TitleConfigManager + { + private const string ConfigLocation = "UserData"; + private const string ConfigFile = "CustomAlbums_TitleConfig.json"; + public static TitleConfig Config { get; private set; } = new TitleConfig(); + + public static void Load() + { + var path = Path.Join(ConfigLocation, ConfigFile); + if (File.Exists(path)) + { + try + { + var json = File.ReadAllText(path); + Config = JsonSerializer.Deserialize(json); + } + catch + { + // If parsing fails, overwrite with default + Save(); + } + } + else + { + Save(); + } + } + + public static void Save() + { + var path = Path.Join(ConfigLocation, ConfigFile); + try + { + if (!Directory.Exists(ConfigLocation)) + Directory.CreateDirectory(ConfigLocation); + + var json = JsonSerializer.Serialize(Config, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(path, json); + } + catch + { + // Ignore + } + } + } +} diff --git a/Patches/AnimatedCoverPatch.cs b/Patches/AnimatedCoverPatch.cs index 4ab74b5..30c767c 100644 --- a/Patches/AnimatedCoverPatch.cs +++ b/Patches/AnimatedCoverPatch.cs @@ -1,4 +1,4 @@ -using CustomAlbums.Managers; +using CustomAlbums.Managers; using HarmonyLib; using Il2Cpp; using Il2CppAssets.Scripts.Database; @@ -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; @@ -31,7 +35,15 @@ public static void AnimateCoversUpdate() { var next = node.Next; var cell = node.Value; - var index = cell?.m_VariableBehaviour?.Cast().GetResult() ?? -1; + + if (cell == null) + { + Cells.Remove(node); + node = next; + continue; + } + + var index = cell.m_VariableBehaviour?.Cast().GetResult() ?? -1; var uid = dbMusicTag?.GetShowStageUidByIndex(index) ?? "?"; diff --git a/Patches/AssetPatch.cs b/Patches/AssetPatch.cs index 1818ec2..bbd64d1 100644 --- a/Patches/AssetPatch.cs +++ b/Patches/AssetPatch.cs @@ -1,4 +1,5 @@ -using System.Runtime.CompilerServices; +using System.Linq; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Nodes; @@ -92,14 +93,32 @@ internal static void InitializeHandler() { var jsonArray = new JsonArray(); + // Pre-calculate the first song of each pack to optimize performance to O(N) + var firstSongs = new HashSet(AlbumManager.LoadedAlbums.Values + .GroupBy(a => PackManager.GetPackFromUid(a.Uid)) + .Select(g => g.OrderBy(a => a.Index).First().Uid)); + // Add each custom charts' data to the album foreach (var (albumStr, albumObj) in AlbumManager.LoadedAlbums) { var albumInfo = albumObj.Info; + var pack = PackManager.GetPackFromUid(albumObj.Uid); + var isFirstSong = firstSongs.Contains(albumObj.Uid); + var titleString = pack?.Title ?? "Unclassified"; + + var displayName = albumInfo.Name; + if (isFirstSong) + { + var titleConfig = TitleConfigManager.Config; + var formatStart = $"{(titleConfig.IsBold ? "" : "")}{(titleConfig.IsItalic ? "" : "")}"; + var formatEnd = $"{(titleConfig.IsItalic ? "" : "")}{(titleConfig.IsBold ? "" : "")}"; + displayName = $"{formatStart}【{titleString}】{formatEnd} {albumInfo.Name}"; + } + var customChartJson = new { uid = albumObj.Uid, - name = albumInfo.Name, + name = displayName, author = albumInfo.Author, bpm = albumInfo.Bpm, music = $"{albumStr}_music", @@ -177,15 +196,36 @@ internal static void InitializeHandler() { var jsonArray = new JsonArray(); + // Pre-calculate the first song of each pack to optimize performance to O(N) + var firstSongs = new HashSet(AlbumManager.LoadedAlbums.Values + .GroupBy(a => PackManager.GetPackFromUid(a.Uid)) + .Select(g => g.OrderBy(a => a.Index).First().Uid)); + // 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) + { + var albumInfo = value.Info; + var pack = PackManager.GetPackFromUid(value.Uid); + var isFirstSong = firstSongs.Contains(value.Uid); + var titleString = pack?.Title ?? "Unclassified"; + + var displayName = albumInfo.Name; + if (isFirstSong) + { + var titleConfig = TitleConfigManager.Config; + var formatStart = $"{(titleConfig.IsBold ? "" : "")}{(titleConfig.IsItalic ? "" : "")}"; + var formatEnd = $"{(titleConfig.IsItalic ? "" : "")}{(titleConfig.IsBold ? "" : "")}"; + displayName = $"{formatStart}【{titleString}】{formatEnd} {albumInfo.Name}"; + } + jsonArray.Add(new { - name = value.Info.Name, + name = displayName, author = value.Info.Author }); + } // Create and add the new asset with the names of each custom chart var newAsset = CreateTextAsset(assetName, JsonSerializer.Serialize(jsonArray)); 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); diff --git a/Patches/HiddenSupportPatch.cs b/Patches/HiddenSupportPatch.cs index f5cb7e8..865b0d0 100644 --- a/Patches/HiddenSupportPatch.cs +++ b/Patches/HiddenSupportPatch.cs @@ -1,4 +1,4 @@ -using CustomAlbums.Managers; +using CustomAlbums.Managers; using CustomAlbums.Utilities; using HarmonyLib; using Il2Cpp; @@ -15,6 +15,7 @@ namespace CustomAlbums.Patches internal class HiddenSupportPatch { internal static HashSet LoadedHiddens = new(); + private static readonly Logger Logger = new(nameof(HiddenSupportPatch)); internal static void UpdateHiddenCharts() { @@ -22,6 +23,78 @@ internal static void UpdateHiddenCharts() MusicTagPatch.HasUpdate = true; } + // Registers hidden difficulty directly, without relying on the game's init method + internal static void RegisterHiddenChartsDirectly() + { + var specialSongMgr = Singleton.instance; + if (specialSongMgr == null) return; + + Il2CppSystem.Collections.Generic.List newHiddenUids = new(); + + foreach (var (key, album) in AlbumManager.LoadedAlbums) + { + if (!album.HasDifficulty(4) && !album.HasDifficulty(5)) continue; + + var albumUid = album.Uid; + + // Touhou Barrage Mode + if (album.HasDifficulty(5) && !DBMusicTagDefine.s_BarrageModeSongUid.Contains(albumUid)) + { + var oldArr = DBMusicTagDefine.s_BarrageModeSongUid; + var newArr = new Il2CppStringArray(oldArr.Length + 1); + for (var i = 0; i < oldArr.Length; i++) newArr[i] = oldArr[i]; + newArr[^1] = albumUid; + DBMusicTagDefine.s_BarrageModeSongUid = newArr; + } + + // Hidden sheet: skip if already registered + if (!album.HasDifficulty(4)) continue; + if (!LoadedHiddens.Add(albumUid)) continue; + newHiddenUids.Add(albumUid); + + // Register into m_HideBmsInfos + if (!specialSongMgr.m_HideBmsInfos.ContainsKey(albumUid)) + { + var triggerDiff = album.Info.HideBmsDifficulty == "0" + ? (album.HasDifficulty(3) ? 3 : 2) + : album.Info.HideBmsDifficulty.ParseAsInt(); + + specialSongMgr.m_HideBmsInfos.Add(albumUid, + new SpecialSongManager.HideBmsInfo( + albumUid, triggerDiff, 4, $"{key}_map4", + new Func(() => specialSongMgr.IsInvokeHideBms(albumUid)) + )); + + var hideMusicInfo = new HideMusicInfo + { + musicUid = albumUid, + invokeType = album.Info.HideBmsMode switch + { + "CLICK" => (int)HiddenInvokeType.Click, + "PRESS" => (int)HiddenInvokeType.LongPress, + "TOGGLE" => (int)HiddenInvokeType.Toggle, + _ => (int)HiddenInvokeType.None + } + }; + specialSongMgr.m_ConfigHideMusic.m_HideMusicObjectMapping[albumUid] = hideMusicInfo; + Logger.Msg($"Registered hidden for {albumUid} (trigger={album.Info.HideBmsMode})", false); + } + } + + // Update s_HiddenLocal and hidden tag in batch + if (newHiddenUids.Count > 0) + { + var oldHidden = DBMusicTagDefine.s_HiddenLocal; + var newHidden = new Il2CppStringArray(oldHidden.Length + newHiddenUids.Count); + for (var i = 0; i < oldHidden.Length; i++) newHidden[i] = oldHidden[i]; + for (var i = 0; i < newHiddenUids.Count; i++) newHidden[i + oldHidden.Length] = newHiddenUids[i]; + DBMusicTagDefine.s_HiddenLocal = newHidden; + + var tagInfo = GlobalDataBase.dbMusicTag.GetAlbumTagInfo(32776); + tagInfo?.AddTagUids(newHiddenUids); + } + } + [HarmonyPatch(typeof(SpecialSongManager), nameof(SpecialSongManager.InitHideBmsInfoDic))] internal static class HideBmsInfoDicPatch { 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; 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/Patches/SavePatch.cs b/Patches/SavePatch.cs index a059573..271375d 100644 --- a/Patches/SavePatch.cs +++ b/Patches/SavePatch.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using CustomAlbums.Data; using CustomAlbums.Managers; @@ -200,12 +200,6 @@ private static bool Prefix(string uid, PnlRank __instance) OriginalNoNetText = noNetComp.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) { @@ -218,6 +212,73 @@ private static bool Prefix(string uid, PnlRank __instance) __instance.noNet.SetActive(true); return false; } + + private static System.Exception Finalizer(System.Exception __exception, PnlRank __instance) + { + if (__exception != null) + { + Logger.Error($"[PnlRank.UIRefresh] EXCEPTION THROWN: {__exception.Message}\n{__exception.StackTrace}"); + if (__instance != null && __instance.noNet != null) + { + var noNetComp = __instance.noNet.GetComponent(); + if (noNetComp != null) + { + noNetComp.text = $"CRASH: {__exception.Message}\nCheck MelonLoader console for details."; + } + __instance.noNet.SetActive(true); + if (__instance.server != null) __instance.server.SetActive(false); + } + } + return null; + } + } + + + /// Corrects the difficulty parameter for hot-reloaded custom charts. The game passes the star difficulty (e.g., 9) + /// instead of the difficulty index (1=Easy, 2=Hard, 3=Master) for hot-reloaded charts, causing HQ's + /// Sheets[difficulty] to throw KeyNotFoundException → AccessViolationException and crash the game. + /// Correct difficulty to selectedDiffTglIndex via ref to allow normal processing by HQ. + + [HarmonyPatch(typeof(GameAccountSystem), nameof(GameAccountSystem.GetRanks))] + internal static class SafeGetRanksPatch + { + [HarmonyPriority(Priority.First)] + private static bool Prefix(string musicUid, ref int difficulty) + { + if (!musicUid.StartsWith($"{AlbumManager.Uid}-")) return true; + + try + { + var album = AlbumManager.GetByUid(musicUid); + if (album == null) + { + Logger.Warning($"GetRanks: album does not exist (uid={musicUid}), skipping"); + return false; + } + + // difficulty is in Sheets -> valid, pass directly + if (album.Sheets.ContainsKey(difficulty)) return true; + + // difficulty is not in Sheets -> replace with the currently selected difficulty toggle index + var corrected = GlobalDataBase.s_DbMusicTag.selectedDiffTglIndex; + Logger.Warning($"GetRanks: difficulty {difficulty} → {corrected} (uid={musicUid})"); + difficulty = corrected; + + // Double check the corrected value + if (!album.Sheets.ContainsKey(difficulty)) + { + Logger.Warning($"GetRanks: still invalid after correction diff={difficulty}, skipping"); + return false; + } + } + catch (System.Exception ex) + { + Logger.Error($"GetRanks validation exception: {ex.Message}"); + return false; + } + + return true; + } } // @@ -227,7 +288,6 @@ private static bool Prefix(string uid, PnlRank __instance) // 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}-"))); @@ -552,7 +612,7 @@ private static void Postfix() [HarmonyPatch(typeof(GameAccountSystem), nameof(GameAccountSystem.RefreshDatas))] internal class RefreshDatasPatch { - private static void Prefix() + private static void Postfix() { Backup.InitBackups(); SaveSaveFile(); diff --git a/Patches/ShortcutPatch.cs b/Patches/ShortcutPatch.cs new file mode 100644 index 0000000..e22186d --- /dev/null +++ b/Patches/ShortcutPatch.cs @@ -0,0 +1,123 @@ +using HarmonyLib; +using Il2CppAssets.Scripts.Database; +using Il2CppAssets.Scripts.PeroTools.Nice.Components; +using CustomAlbums.Managers; +using CustomAlbums.Data; +using UnityEngine; + +namespace CustomAlbums.Patches +{ + internal static class ShortcutPatch + { + internal static bool HandleScroll(FancyScrollView fsv, float time, bool forward) + { + if (Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl)) + { + var dbMusicTag = GlobalDataBase.dbMusicTag; + if (dbMusicTag == null) return true; + + bool isCustomCategory = (dbMusicTag.selectedTagIndex == AlbumManager.Uid); + if (!isCustomCategory) + { + var curMusic = dbMusicTag.CurMusicInfo(); + if (curMusic == null || !curMusic.uid.StartsWith($"{AlbumManager.Uid}-")) + return true; + } + + var list = dbMusicTag.m_StageShowMusicUids; + if (list == null || list.Count <= 1) return true; + + int currentIndex = dbMusicTag.curSelectedMusicIdx; + int targetIndex = GetTargetFolderIndex(list, currentIndex, forward); + + if (targetIndex >= 0 && targetIndex < list.Count && targetIndex != currentIndex) + { + fsv.ScrollToDataIndex(targetIndex, time); + return false; // Intercept original scrolling + } + } + return true; + } + + private static string GetPackTitle(string uid) + { + if (string.IsNullOrEmpty(uid)) return "EMPTY"; + if (!uid.StartsWith($"{AlbumManager.Uid}-")) + return "RANDOM_SONG"; + + var pack = PackManager.GetPackFromUid(uid); + if (pack != null && !string.IsNullOrEmpty(pack.Title)) + { + return pack.Title; + } + + return AlbumManager.GetCustomAlbumsTitle(); + } + + private static int GetTargetFolderIndex(Il2CppSystem.Collections.Generic.List list, int currentIndex, bool forward) + { + int listCount = list.Count; + if (listCount == 0) return 0; + string currentPack = GetPackTitle(list[currentIndex]); + + if (forward) + { + for (int i = currentIndex + 1; i < listCount; i++) + { + if (GetPackTitle(list[i]) != currentPack) + { + return i; + } + } + return listCount - 1; + } + else + { + int prevPackLastIndex = -1; + for (int i = currentIndex - 1; i >= 0; i--) + { + if (GetPackTitle(list[i]) != currentPack) + { + prevPackLastIndex = i; + break; + } + } + + // If scrolling left and no different pack is found (indicating we are at the first pack), loop to the very end (random song) + if (prevPackLastIndex == -1) + { + return listCount - 1; + } + + string prevPack = GetPackTitle(list[prevPackLastIndex]); + for (int i = prevPackLastIndex - 1; i >= 0; i--) + { + if (GetPackTitle(list[i]) != prevPack) + { + return i + 1; + } + } + + return 0; + } + } + } + + [HarmonyPatch(typeof(FancyScrollView), nameof(FancyScrollView.ScrollToNext))] + internal static class ScrollToNextPatch + { + private static bool Prefix(FancyScrollView __instance, float time) + { + return ShortcutPatch.HandleScroll(__instance, time, true); + } + } + + [HarmonyPatch(typeof(FancyScrollView), nameof(FancyScrollView.ScrollToPrevious))] + internal static class ScrollToPreviousPatch + { + private static bool Prefix(FancyScrollView __instance, float time) + { + return ShortcutPatch.HandleScroll(__instance, time, false); + } + } +} diff --git a/Patches/TagPatch.cs b/Patches/TagPatch.cs index c89dcef..12a7248 100644 --- a/Patches/TagPatch.cs +++ b/Patches/TagPatch.cs @@ -1,4 +1,4 @@ -using CustomAlbums.Managers; +using CustomAlbums.Managers; using CustomAlbums.Utilities; using HarmonyLib; using Il2Cpp; diff --git a/Patches/TreeItemPatch.cs b/Patches/TreeItemPatch.cs index 5699ea6..21c0bf1 100644 --- a/Patches/TreeItemPatch.cs +++ b/Patches/TreeItemPatch.cs @@ -1,4 +1,4 @@ -using Il2Cpp; +using Il2Cpp; using CustomAlbums.Utilities; using HarmonyLib; 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/Il2CppExtensions.cs b/Utilities/Il2CppExtensions.cs index 16593aa..f517efb 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,29 @@ public static bool TryGetValuePossibleNullKey( outValue = dict[key]; return true; } + + 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]) + { + 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/PackExtensions.cs b/Utilities/PackExtensions.cs new file mode 100644 index 0000000..8f78376 --- /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) ?? throw new ArgumentException($"Entry {entryName} not found."); + var mdmStream = mdm.Open(); + var openedMdm = new ZipArchive(mdmStream, ZipArchiveMode.Read, false); + return openedMdm; + } + } +}