using System; using System.Collections.Generic; using System.IO; using System.Linq; using ADOFAIRenderer.Core.Data; using ADOFAIRenderer.Data; using MelonLoader; using MelonLoader.Utils; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace ADOFAIRenderer.Core; /// A named render configuration, stored as one JSON file in the presets directory. public class RenderPreset { /// Display name of the preset. A file without a name uses the file name (without extension). public string Name { get; set; } = string.Empty; /// Render configuration of the preset. public RenderConfig Config { get; set; } = null!; /// Returns a copy with a copied configuration. public RenderPreset Clone() { return new RenderPreset { Name = Name, Config = Config.Clone() }; } } /// /// Loads, saves and deletes render presets. Presets are JSON files (one per preset, file name derived from the preset /// name) in <game root>/UserData/ADOFAIRenderer/Presets. The built-in presets are written once, on first use /// (GlobalConfig.DefaultPresetsInitialized), so the user can edit or delete them. Main thread. /// public static class PresetManager { /// /// Built-in presets (H.264 CBR, 48 kHz stereo AAC at 256 kbps, mkv): 1080p, 1440p and 2160p at 60 FPS video. /// "(4x)" presets render 240 game frames per second (4 per video frame); "(1x)" renders 60. /// Video bitrates are in kbps. /// private static readonly List DefaultPresets = new() { new RenderPreset { Name = "FHD 60fps (1x)", Config = new RenderConfig { RenderWidth = 1920, RenderHeight = 1080, RenderFPS = 60, VideoFPS = 60, DelayAfterEnd = 15, ShowJudgementText = false, VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 20000, MaxBitrate = 35000, QP = 18, KeyframeInterval = 2.0 }, AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 }, AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" } } }, new RenderPreset { Name = "FHD 60fps (4x)", Config = new RenderConfig { RenderWidth = 1920, RenderHeight = 1080, RenderFPS = 240, VideoFPS = 60, DelayAfterEnd = 15, ShowJudgementText = false, VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 30000, MaxBitrate = 50000, QP = 18, KeyframeInterval = 2.0 }, AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 }, AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" } } }, new RenderPreset { Name = "QHD 60fps (4x)", Config = new RenderConfig { RenderWidth = 2560, RenderHeight = 1440, RenderFPS = 240, VideoFPS = 60, DelayAfterEnd = 15, ShowJudgementText = false, VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 35000, MaxBitrate = 60000, QP = 18, KeyframeInterval = 2.0 }, AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 }, AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" } } }, new RenderPreset { Name = "4K 60fps (4x)", Config = new RenderConfig { RenderWidth = 3840, RenderHeight = 2160, RenderFPS = 240, VideoFPS = 60, DelayAfterEnd = 15, ShowJudgementText = false, VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 60000, MaxBitrate = 100000, QP = 18, KeyframeInterval = 2.0 }, AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 }, AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" } } } }; /// /// <root>/UserData/ADOFAIRenderer/Presets, where root is MelonLoader's game root directory, or the application /// base directory when that is unavailable. /// private static string GetPresetsDirectory() { string? root = null; try { root = MelonEnvironment.GameRootDirectory; } catch { // Outside MelonLoader host environment (e.g. tests) } if (string.IsNullOrEmpty(root)) { root = AppDomain.CurrentDomain.BaseDirectory; } return Path.Combine(root, "UserData", "ADOFAIRenderer", "Presets"); } /// /// File name (without extension) of the preset file for the given preset name: every character that is invalid /// in a file name is replaced with '_'. Different preset names can map to the same file name (e.g. "a/b" and "a_b"). /// internal static string GetSafeFileName(string name) { return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_')); } /// /// Creates the presets directory and, once per installation (GlobalConfig.DefaultPresetsInitialized), writes the /// built-in presets whose files do not exist yet, then saves the flag. Errors are logged, not thrown. /// public static void EnsureDefaultPresetsCreated() { try { var dir = GetPresetsDirectory(); if (!Directory.Exists(dir)) { Directory.CreateDirectory(dir); } if (!GlobalSettings.Current.DefaultPresetsInitialized) { var settings = new JsonSerializerSettings { Formatting = Formatting.Indented }; settings.Converters.Add(new ColorJsonConverter()); foreach (var def in DefaultPresets) { var safeName = GetSafeFileName(def.Name); var filePath = Path.Combine(dir, safeName + ".json"); if (!File.Exists(filePath)) { var json = JsonConvert.SerializeObject(def, settings); File.WriteAllText(filePath, json); } } GlobalSettings.Current.DefaultPresetsInitialized = true; GlobalSettings.Save(); } } catch (Exception ex) { MelonLogger.Warning($"Error initializing default presets: {ex.Message}"); } } /// /// Saves a copy of as preset , overwriting the file of the same /// safe file name (see GetSafeFileName). /// /// The name is empty or white space. /// The directory or file cannot be written (not caught). public static void SavePreset(string name, RenderConfig config) { if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("Preset name cannot be empty."); } string dir = GetPresetsDirectory(); if (!Directory.Exists(dir)) { Directory.CreateDirectory(dir); } var preset = new RenderPreset { Name = name, Config = config.Clone() }; var settings = new JsonSerializerSettings { Formatting = Formatting.Indented }; settings.Converters.Add(new ColorJsonConverter()); string json = JsonConvert.SerializeObject(preset, settings); string safeName = GetSafeFileName(name); string filePath = Path.Combine(dir, safeName + ".json"); File.WriteAllText(filePath, json); } /// /// Loads every *.json preset file (after creating the built-in presets on first use). Files written by older /// versions are migrated in memory (old key names and flat properties moved into the nested configs); the files are /// not rewritten. A file that cannot be read is logged and skipped. When two files have the same preset name /// (case-insensitive), the first one in directory order is used. Returns copies of the built-in presets when the /// directory does not exist. /// public static List GetPresets() { EnsureDefaultPresetsCreated(); var presets = new List(); var dir = GetPresetsDirectory(); if (!Directory.Exists(dir)) { foreach (var b in DefaultPresets) { presets.Add(b.Clone()); } return presets; } try { var files = Directory.GetFiles(dir, "*.json"); var serializerSettings = new JsonSerializerSettings(); serializerSettings.Converters.Add(new ColorJsonConverter()); var serializer = JsonSerializer.Create(serializerSettings); foreach (var file in files) { try { var json = File.ReadAllText(file); var jObject = JsonConvert.DeserializeObject(json); if (jObject == null) continue; var configToken = jObject["Config"]; if (configToken is JObject configObj) { TryChangeKey(configObj, "DelayAfterLevelEnd", "DelayAfterEnd"); // 1. legacy EncodingConfig -> VideoConfig if (configObj["EncodingConfig"] is JObject oldEncodingConfigObj) { configObj["VideoConfig"] ??= oldEncodingConfigObj; configObj.Remove("EncodingConfig"); } // 2. legacy top-level Codec -> VideoConfig.Codec var legacyCodecToken = configObj["Codec"]; if (legacyCodecToken != null && legacyCodecToken.Type != JTokenType.Null) { if (configObj["VideoConfig"] == null || configObj["VideoConfig"]!.Type == JTokenType.Null) { configObj["VideoConfig"] = new JObject(); } var videoConfigObj = (JObject)configObj["VideoConfig"]!; videoConfigObj["Codec"] ??= legacyCodecToken; configObj.Remove("Codec"); } // 3. legacy top-level Audio properties -> AudioConfig string[] legacyAudioProps = { "RenderAudio", "AudioSampleRate", "AudioChannels", "AudioBitrate" }; foreach (var prop in legacyAudioProps) { var legacyToken = configObj[prop]; if (legacyToken != null && legacyToken.Type != JTokenType.Null) { if (configObj["AudioConfig"] == null || configObj["AudioConfig"]!.Type == JTokenType.Null) { configObj["AudioConfig"] = new JObject(); } var audioConfigObj = (JObject)configObj["AudioConfig"]!; audioConfigObj[prop] ??= legacyToken; } configObj.Remove(prop); } if (configObj["AudioConfig"] != null && configObj["AudioConfig"]!.Type == JTokenType.Null) { configObj.Remove("AudioConfig"); } // 4. legacy top-level Advanced properties -> AdvancedConfig string[] legacyAdvancedProps = { "RandomSeed", "ProcessAudio", "NonTile" }; foreach (var prop in legacyAdvancedProps) { var legacyToken = configObj[prop]; if (legacyToken != null && legacyToken.Type != JTokenType.Null) { if (configObj["AdvancedConfig"] == null || configObj["AdvancedConfig"]!.Type == JTokenType.Null) { configObj["AdvancedConfig"] = new JObject(); } var advancedConfigObj = (JObject)configObj["AdvancedConfig"]!; advancedConfigObj[prop] ??= legacyToken; } configObj.Remove(prop); } if (configObj["AdvancedConfig"] != null && configObj["AdvancedConfig"]!.Type == JTokenType.Null) { configObj.Remove("AdvancedConfig"); } } var preset = jObject.ToObject(serializer); if (preset != null) { var fileBaseName = Path.GetFileNameWithoutExtension(file); if (string.IsNullOrWhiteSpace(preset.Name)) { preset.Name = fileBaseName; } if (presets.Any(p => string.Equals(p.Name, preset.Name, StringComparison.OrdinalIgnoreCase))) { continue; } presets.Add(preset); } } catch (Exception ex) { MelonLogger.Warning($"Error loading preset file '{Path.GetFileName(file)}': {ex.Message}"); } } } catch (Exception ex) { MelonLogger.Error($"Error retrieving preset list: {ex.Message}"); } return presets; } /// /// Renames key to : a non-null old value overwrites the target /// key, and the old key is always removed. /// private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey) { var legacyToken = configObj[keyBefore]; if (legacyToken != null && legacyToken.Type != JTokenType.Null) configObj[targetKey] = legacyToken; configObj.Remove(keyBefore); } /// /// Deletes the file of preset : first the file with the safe file name; otherwise the first /// file whose base name or stored "Name" matches (case-insensitive). Does nothing for an empty name or when no file /// matches. /// /// The file with the safe file name exists but cannot be deleted (not caught). public static void DeletePreset(string name) { if (string.IsNullOrWhiteSpace(name)) return; var dir = GetPresetsDirectory(); if (!Directory.Exists(dir)) return; var safeName = GetSafeFileName(name); var filePath = Path.Combine(dir, safeName + ".json"); if (File.Exists(filePath)) { File.Delete(filePath); return; } foreach (var file in Directory.GetFiles(dir, "*.json")) { try { var fileBaseName = Path.GetFileNameWithoutExtension(file); if (string.Equals(fileBaseName, name, StringComparison.OrdinalIgnoreCase)) { File.Delete(file); return; } var json = File.ReadAllText(file); var jObject = JsonConvert.DeserializeObject(json); var pName = (string?)jObject?["Name"]; if (string.Equals(pName, name, StringComparison.OrdinalIgnoreCase)) { File.Delete(file); return; } } catch { // Ignore read errors during cleanup search } } } }