using MelonLoader; using UnityEngine; using FMODUnity; using FMOD.Studio; using HarmonyLib; using Rhythm; using static Rhythm.BeatmapIndex; using Arcade.UI; using System.Runtime.InteropServices; using FMOD; using static UnbeatableSongHack.TreeExplorer; using Arcade.UI.SongSelect; using static Arcade.UI.SongSelect.ArcadeSongDatabase; using UnityEngine.UIElements; using System.Xml.Linq; using static Dreamteck.Splines.SplineSampleModifier; [assembly: MelonInfo(typeof(UnbeatableSongHack.Core), "SUnbeatable-Loader", "1.0.0", "Serena Sunskimmer82", null)] [assembly: MelonGame("D-CELL GAMES", "UNBEATABLE [DEMO]")] namespace UnbeatableSongHack { public class Core : MelonMod { public override void OnInitializeMelon() { LoggerInstance.Msg("Initialized Song Hack!"); } public override void OnLateUpdate() { if (Input.GetKeyDown(KeyCode.N)) { // Stop all events RuntimeManager.GetBus("bus:/").stopAllEvents(FMOD.Studio.STOP_MODE.ALLOWFADEOUT); } if (Input.GetKeyDown(KeyCode.M)) { //FetchAllEvents(); } } public List eventDescriptions = new List(); public List strings = new List(); // Get the events from the bank // These can be used to play single audios in game through the tree menu FMOD.Studio.EventDescription[] GetBankEvents(Bank bank) { LoggerInstance.Msg("\n> Getting Bank Events"); bank.getEventList(out FMOD.Studio.EventDescription[] eventDescs); foreach (FMOD.Studio.EventDescription eventDesc in eventDescs) { eventDesc.getPath(out string eventPath); LoggerInstance.Msg(eventPath); eventDescriptions.Add(eventDesc); } return eventDescs; } // Read all string events from the bank void GetBankStrings(Bank bank) { LoggerInstance.Msg("\n> Getting Bank Strings"); bank.getStringCount(out int stringCount); LoggerInstance.Msg("String Count: " + stringCount); for (int i = 0; i < stringCount; i++) { bank.getStringInfo(i, out FMOD.GUID Id, out string stringPath); LoggerInstance.Msg("String ID: " + Id); LoggerInstance.Msg("String Path: " + stringPath); LoggerInstance.Msg("---"); // Strings zur Liste hinzufügen if (!string.IsNullOrEmpty(stringPath)) { strings.Add(stringPath); } } } // Build the event tree from the event descriptions and strings void FetchAllEvents() { // Listen leeren eventDescriptions.Clear(); strings.Clear(); rootNode = new TreeNode("Root"); RuntimeManager.StudioSystem.getBankList(out Bank[] banks); foreach (Bank bank in banks) { // Get the name of the bank bank.getPath(out string bankPath); // Log the name of the bank LoggerInstance.Msg("--- Bank Path: " + bankPath); // It's Master.strings! Read the strings from it! if (bankPath.EndsWith("strings")) { GetBankStrings(bank); } try { GetBankEvents(bank); } catch (System.Exception e) { LoggerInstance.Msg("Error fetching events: " + e.Message); } } BuildEventTree(out rootNode, eventDescriptions, strings); } // "Progessions" are some kind of class public class CustomProgression: IProgression { public string stageScene = "TrainStationRhythm"; public string filePath = "./none.txt"; public string audioKey = "EMPTY DIARY"; public CustomProgression(string textFile) { this.filePath = textFile; // Look for an audio.mp3 in the same directory as the beatmap // Might as well use the audioFilename property from the beatmap itself later string directory = Path.GetDirectoryName(filePath); var audioPath = Path.Combine(directory, "audio.mp3"); // If an audio is found, set it to the audio path var audioTitle = "EMPTY DIARY"; if (File.Exists(audioPath)) { audioTitle = audioPath; } this.audioKey = audioTitle; this.stageScene = "TrainStationRhythm"; } public string GetBeatmapPath() { // Placeholder return "__CUSTOM/CUSTOMDIFF"; } public string GetSongName() { return audioKey; } public string GetDifficulty() { // Placeholder return "CUSTOMDIFF"; } public void Finish(string sceneIndex) { LevelManager.LoadLevel("ScoreScreenArcadeMode"); } public void Retry() { LevelManager.LoadLevel(stageScene); } public void Back() { LevelManager.LoadLevel(JeffBezosController.arcadeMenuScene); } } void LoadBeatmapFromFile(string filePath="") { if (filePath.Length==0) return; CustomProgression customProgression = new CustomProgression(filePath); JeffBezosController.rhythmProgression = customProgression; LevelManager.LoadLevel(customProgression.stageScene); } // Detect if the beatmap is a custom one // Since ParseBeatmap attempts to load a TextAsset (which we cant create) // we have to patch the function and make it load from text contents instead. [HarmonyPatch(typeof(BeatmapParser), "ParseBeatmap", new Type[] {})] public class BeatmapParserPatch { public static bool Prefix(ref BeatmapParser __instance) { if (JeffBezosController.rhythmProgression.GetBeatmapPath().StartsWith("__CUSTOM")) { var progression = JeffBezosController.rhythmProgression as CustomProgression; __instance.beatmapIndex = BeatmapIndex.defaultIndex; var contents = File.ReadAllText(progression.filePath); BeatmapParserEngine beatmapParserEngine = new BeatmapParserEngine(); var beatmap = ScriptableObject.CreateInstance(); beatmapParserEngine.ReadBeatmap(contents, ref beatmap); __instance.beatmap = beatmap; __instance.audioKey = progression.GetSongName(); __instance.beatmapPath = progression.GetBeatmapPath(); return false; } return true; } } // Patch to load beatmaps from file // Since the game really wants to load from its own audio table, // we need to patch this to load from a file instead // That way we can make the game load any sound file (and in this case, // the one from our custom level) [HarmonyPatch(typeof(RhythmTracker), "PreloadFromTable")] public class RhythmTrackerLoadPatch { public static bool Prefix(string key, ref RhythmTracker __instance) { if (key.Contains(".")) { if (File.Exists(key)) { __instance.PreloadFromFile(key); return false; } else { return true; } } return true; } } void PlayFromKey(string name) { var arcadeBGMManger = ArcadeBGMManager.Instance; var songList = ArcadeSongDatabase.Instance; var beatmapIndex = BeatmapIndex.defaultIndex; if (arcadeBGMManger != null && songList != null) { LoggerInstance.Msg("Adding key: " + name); var beatmapItem = new BeatmapItem(); beatmapItem.Path = "__CUSTOM" + name + "/Beginner"; var key = beatmapItem.Path; beatmapItem.Song = new Song(name); beatmapItem.Song.stageScene = "TrainStationRhythm"; beatmapItem.Unlocked = true; beatmapItem.BeatmapInfo = new BeatmapInfo(null, "Beginner"); beatmapItem.Highscore = new HighScoreItem(key, 0, 0f, 0, cleared: false, new Dictionary(), Modifiers.None); LoggerInstance.Msg("Adding song to list: " + beatmapItem.Path); // This whole part would be needed for AddSongList() beatmapItem.Beatmap = ScriptableObject.CreateInstance(); beatmapItem.Beatmap.metadata.title = "Custom " + name; beatmapItem.Beatmap.metadata.titleUnicode = "Custom " + name; beatmapItem.Beatmap.metadata.artist = "Not You"; beatmapItem.Beatmap.metadata.artistUnicode = "Not You"; beatmapItem.Beatmap.metadata.tagData.Level = 10; // This was a test and is not needed //AddSongToArcadeList(songList,beatmapItem); LoggerInstance.Msg("Added Key: " + key); arcadeBGMManger.PlaySongPreview(beatmapItem); } } public static string lastEventPath = ""; public TreeNode rootNode = new TreeNode("Root"); public Dictionary expandedStates = new Dictionary(); public Rect windowRect = new Rect(20, 20, 400, 600); public Vector2 scrollPosition = new Vector2(0, 0); public string textInput = ""; public override void OnGUI() { windowRect = GUI.Window(0, windowRect, DoWindow, "Song Hack"); if (windowRect.width == 0) { if (GUI.Button(new Rect(10, 10, 32, 32), "O")) { windowRect = new Rect(20, 20, 400, 600); } } } // Draw MOD UI void DoWindow(int windowId) { GUIStyle compactButtonStyle = new GUIStyle(GUI.skin.button); compactButtonStyle.alignment = TextAnchor.MiddleLeft; compactButtonStyle.fontSize = 14; GUIStyle compactEnabledButtonStyle = new GUIStyle(compactButtonStyle); compactEnabledButtonStyle.normal.textColor = Color.green; GUIStyle folderStyle = new GUIStyle(GUI.skin.button); folderStyle.alignment = TextAnchor.MiddleLeft; folderStyle.fontSize = 14; folderStyle.normal.textColor = Color.yellow; GUIStyle leafStyle = new GUIStyle(GUI.skin.button); leafStyle.alignment = TextAnchor.MiddleLeft; leafStyle.fontSize = 14; leafStyle.normal.textColor = Color.white; GUIStyle stringStyle = new GUIStyle(GUI.skin.button); stringStyle.alignment = TextAnchor.MiddleLeft; stringStyle.fontSize = 14; stringStyle.normal.textColor = Color.cyan; GUIStyle playableStringStyle = new GUIStyle(GUI.skin.button); playableStringStyle.alignment = TextAnchor.MiddleLeft; playableStringStyle.fontSize = 14; playableStringStyle.normal.textColor = Color.green; GUI.DragWindow(new Rect(0, 0, 10000, 20)); // Create button to stop all events if (GUI.Button(new Rect(10, 25, 110, 30), "Stop All Events")) { RuntimeManager.GetBus("bus:/").stopAllEvents(FMOD.Studio.STOP_MODE.ALLOWFADEOUT); } // Create button to get all events if (GUI.Button(new Rect(120, 25, 110, 30), "Get All Events")) { FetchAllEvents(); LoggerInstance.Msg($"Fetched {eventDescriptions.Count} events and {strings.Count} strings."); } // Play an event from the text input if (GUI.Button(new Rect(230, 25, 110, 30), "Sound from Text")) { if (!string.IsNullOrEmpty(textInput)) { RuntimeManager.PlayOneShot(textInput); lastEventPath = textInput; LoggerInstance.Msg("Playing: " + textInput); } } if (GUI.Button(new Rect(230, 55, 110, 25), "Sound from Key")) { if (!string.IsNullOrEmpty(textInput)) { PlayFromKey(textInput); lastEventPath = "Key: " + textInput; LoggerInstance.Msg("Playing Key: " + textInput); } } if (GUI.Button(new Rect(130, 55, 100, 25), "Play File Level")) { if (!string.IsNullOrEmpty(textInput)) { LoadBeatmapFromFile(textInput); } else { LoadBeatmapFromFile(); } } if (GUI.Button(new Rect(10,120,100,25), "Add Custom Songs To Arcade")) { var db = Arcade.UI.SongSelect.ArcadeSongDatabase.Instance; string customSongDir = $"{Application.dataPath.Substring(0, Application.dataPath.LastIndexOf('/'))}/SUnbeatable-Loader/Songs/"; if (!Directory.Exists(customSongDir)) Directory.CreateDirectory(customSongDir); LoggerInstance.Msg($"SUnbeatable-Loader Song Directory: {customSongDir}"); string[] dirs = Directory.GetDirectories(customSongDir); foreach (var dir in dirs) { string songDir = Path.GetFileNameWithoutExtension(dir); LoggerInstance.Msg($"{songDir}"); //new demo maps are by default stored as .dat files string[] songFiles = Directory.GetFiles(customSongDir + songDir, "*.dat"); foreach (var songFile in songFiles) { string beatmapName = Path.GetFileNameWithoutExtension(songFile); string beatmapContent = File.ReadAllText(songFile); string difficulty = beatmapContent.Substring(beatmapContent.IndexOf("Version:")+8 /* adding 8, the length of the string "Version:" */ , beatmapContent.IndexOf(" ", beatmapContent.IndexOf("Version:"))-(beatmapContent.IndexOf("Version:")+8)); LoggerInstance.Msg($"{beatmapName} has difficulty {difficulty}"); BeatmapItem item = new BeatmapItem(); item.Path = "__CUSTOM" + songFile; item.Song = new Song(beatmapName); TextAsset asset = new TextAsset(beatmapContent); asset.name = beatmapName; item.BeatmapInfo = new BeatmapInfo(asset,difficulty); item.Beatmap = ScriptableObject.CreateInstance(); item.Beatmap.name = beatmapName; item.Beatmap.metadata.title = beatmapName; item.Beatmap.metadata.titleUnicode = beatmapName; item.Beatmap.metadata.artist = "ARTIST"; item.Beatmap.metadata.artistUnicode = "ARTIST"; item.Beatmap.metadata.tagData.Level = 10; item.Beatmap.metadata.tagData.SongLength = 100; AddSongToArcadeList(db, item); LoggerInstance.Msg("adding beatmap " + beatmapName + " at " + songDir); } } } if (GUI.Button(new Rect(110, 120, 100, 25), "Add To Arcade")) { if (!string.IsNullOrEmpty(textInput)) { CustomProgression progression = new CustomProgression(textInput); var beatmapItem = new BeatmapItem(); beatmapItem.Path = "__CUSTOM" + progression.GetSongName() + "/Beginner"; var key = beatmapItem.Path; beatmapItem.Song = /*new Song(progression.GetSongName().Substring(progression.GetSongName().LastIndexOf("/")+1,progression.GetSongName().LastIndexOf(".")))*/null; beatmapItem.Song.stageScene = "TrainStationRhythm"; beatmapItem.Unlocked = true; beatmapItem.BeatmapInfo = new BeatmapInfo(null, "Beginner"); beatmapItem.Highscore = new HighScoreItem(key, 0, 0f, 0, cleared: false, new Dictionary(), Modifiers.None); LoggerInstance.Msg("Adding song to list: " + beatmapItem.Path); beatmapItem.Beatmap = new Beatmap(); beatmapItem.Beatmap.metadata.title = "Custom " + progression.GetSongName(); beatmapItem.Beatmap.metadata.titleUnicode = "Custom " + progression.GetSongName(); beatmapItem.Beatmap.metadata.artist = "Not You"; beatmapItem.Beatmap.metadata.artistUnicode = "Not You"; beatmapItem.Beatmap.metadata.tagData.Level = 10; var db = Arcade.UI.SongSelect.ArcadeSongDatabase.Instance; AddSongToArcadeList(db, beatmapItem); } } if (GUI.Button(new Rect(340, 25, 50, 30), "X")) { windowRect = new Rect(-100, -100, 0, 0); } textInput = GUI.TextField(new Rect(10, 80, 360, 25), textInput); int totalHeight = CalculateTreeHeight(rootNode); scrollPosition = GUI.BeginScrollView( new Rect(0, 180, 400, 440), scrollPosition, new Rect(0, 0, 380, totalHeight) ); int yPos = 0; DrawTreeNode(rootNode, 0, ref yPos, folderStyle, leafStyle, stringStyle, playableStringStyle); GUI.EndScrollView(); } // Let other classes access the logger public static MelonLogger.Instance GetLogger() { MelonBase core = Core.FindMelon("SUnbeatable-Loader", "Serena Sunskimmer82"); return core.LoggerInstance; } // Patch the song function to return all (also hidden) songs, // so we can access hidden beatmaps [HarmonyPatch(typeof(BeatmapIndex), "GetVisibleSongs")] public class BeatmapIndexPatch { public static bool Prefix(ref BeatmapIndex __instance, ref List __result) { __result = __instance.GetAllSongs(); return false; } } [HarmonyPatch(typeof(RhythmTracker), "HandleCreateProgrammerSound", new Type[] { typeof(EventInstance), typeof(IntPtr) })] public static class RhythmTrackerPatch { public static bool Prefix(EventInstance instance, IntPtr parameterPtr) { var LoggerInstance = Core.GetLogger(); //LoggerInstance.Msg("HandleCreateProgrammerSound called"); instance.getUserData(out var h_userdata); PlayInfo h_playInfo = GCHandle.FromIntPtr(h_userdata).Target as PlayInfo; if (h_playInfo.source == PlaySource.FromTable) { string key = Marshal.PtrToStringUni(h_playInfo.key); // Log the key of the sound being played // Useful since we can't find the sound name otherwise // Was used to match the names to the unnamed soundtrack files LoggerInstance.Msg("Now playing: " + key); //SOUND_INFO h_info; //RESULT h_soundInfo = RuntimeManager.StudioSystem.getSoundInfo(key, out h_info); //LoggerInstance.Msg("SoundInfo: " + (h_info.name)); } //instance.getVolume(out float volume); //LoggerInstance.Msg("Volume: " + volume.ToString()); return true; } } // Doesn't really work, but it sometimes logs when something is played [HarmonyPatch(typeof(FMODUnity.RuntimeManager), "PlayOneShot", new Type[] { typeof(string), typeof(Vector3) })] public class RuntimeManagerPatch { public static bool Prefix(string path) { var LoggerInstance = Core.GetLogger(); LoggerInstance.Msg("[Patch] Playing: " + path); return true; } } // Function to add arbitrary beatmap songs into the arcade list // Might be useful for some custom beatmaps, although some info is fetched from // TextAssets that we cannot create/access through mods. // It may be a mess. public static void AddSongToArcadeList(ArcadeSongDatabase instance, ArcadeSongDatabase.BeatmapItem item) { var LoggerInstance = Core.GetLogger(); string key = item.Path; item.Unlocked = true; Traverse traverse = Traverse.Create(instance); Dictionary songList = traverse.Field("_songDatabase").GetValue>(); LoggerInstance.Msg("trying to add " + key); var success = songList.TryAdd(key, item); LoggerInstance.Msg(success); traverse.Field("_songDatabase").SetValue(songList); instance.RefreshSongList(); LoggerInstance.Msg("Adding song to list: " + item.Path); } [HarmonyPatch(typeof(ArcadeSongDatabase), "Awake")] public class ArcadeSongDatabasePatch { public static void Postfix(ref ArcadeSongDatabase __instance) { var LoggerInstance = Core.GetLogger(); Traverse traverse = Traverse.Create(__instance); BeatmapIndex beatmapIndex = traverse.Field("_beatmapIndex").GetValue(); Dictionary songDatabase = traverse.Field("_songDatabase").GetValue>(); List songList = traverse.Field("_songList").GetValue>(); string beatmapPath = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\UNBEATABLE Demo\\SUnbeatable-Loader\\Songs\\Empty Diary (Custom)\\beatmap.dat"; //test song - Empty Diaryaaa string beatmap = File.ReadAllText(beatmapPath); string songName = "audio.mp3"; using (var songNameReader = new StringReader(beatmap.Substring(beatmap.IndexOf("Title:") + 6).Trim())) { //songName = songNameReader.ReadLine(); } LoggerInstance.Msg(songName); string difficulty = beatmap.Substring(beatmap.IndexOf("Version:") + 8 /* adding 8, the length of the string "Version:" */ , beatmap.IndexOf(" ", beatmap.IndexOf("Version:")) - (beatmap.IndexOf("Version:") + 8)); LoggerInstance.Msg(difficulty); string path = songName+"/"+difficulty; BeatmapIndex.Song mapSong = new Song(songName); mapSong.stageScene = "TrainStationRhythm"; TextAsset asset = new TextAsset(beatmap); BeatmapInfo info = new BeatmapInfo(asset,difficulty); Beatmap map = ScriptableObject.CreateInstance(); map.metadata.title = songName; map.metadata.titleUnicode = songName; map.metadata.artist = "Not You"; map.metadata.artistUnicode = "Not You"; map.metadata.tagData.Level = 10; //map.metadata.tagData.SongLength = float.Parse(beatmap.Substring(beatmap.IndexOf("SongLength\":")+13,beatmap.IndexOf("}", beatmap.IndexOf("SongLength\":") + 13))); BeatmapItem item = new BeatmapItem(); item.Path = path; item.BeatmapInfo = info; item.Beatmap = map; item.Highscore = new HighScoreItem(path, 0, 0f, 0, cleared: false, new Dictionary(), Modifiers.None); item.Unlocked = true; item.Song = mapSong; LoggerInstance.Msg("about to parse beatmap"); BeatmapParserEngine engine = new BeatmapParserEngine(); engine.ReadBeatmap(info.text, ref map, BeatmapParserEngine.SectionTypes.Everything); songList.Add(item); songDatabase.TryAdd(songName, item); LoggerInstance.Msg("added " + songName + " " + difficulty); /*public string Path; public BeatmapIndex.Song Song; public BeatmapInfo BeatmapInfo; public Beatmap Beatmap; public HighScoreItem Highscore; public bool Unlocked; public override string ToString() { return Path; }*/ } } } }