SHA256
432 lines
17 KiB
C#
432 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>A named render configuration, stored as one JSON file in the presets directory.</summary>
|
|
public class RenderPreset
|
|
{
|
|
/// <summary>Display name of the preset. A file without a name uses the file name (without extension).</summary>
|
|
public string Name { get; set; } = string.Empty;
|
|
|
|
/// <summary>Render configuration of the preset.</summary>
|
|
public RenderConfig Config { get; set; } = null!;
|
|
|
|
/// <summary>Returns a copy with a copied configuration.</summary>
|
|
public RenderPreset Clone()
|
|
{
|
|
return new RenderPreset
|
|
{
|
|
Name = Name,
|
|
Config = Config.Clone()
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static class PresetManager
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static readonly List<RenderPreset> 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" }
|
|
}
|
|
}
|
|
};
|
|
|
|
/// <summary>
|
|
/// <root>/UserData/ADOFAIRenderer/Presets, where root is MelonLoader's game root directory, or the application
|
|
/// base directory when that is unavailable.
|
|
/// </summary>
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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").
|
|
/// </summary>
|
|
internal static string GetSafeFileName(string name)
|
|
{
|
|
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves a copy of <paramref name="config"/> as preset <paramref name="name"/>, overwriting the file of the same
|
|
/// safe file name (see GetSafeFileName).
|
|
/// </summary>
|
|
/// <exception cref="ArgumentException">The name is empty or white space.</exception>
|
|
/// <exception cref="IOException">The directory or file cannot be written (not caught).</exception>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static List<RenderPreset> GetPresets()
|
|
{
|
|
EnsureDefaultPresetsCreated();
|
|
|
|
var presets = new List<RenderPreset>();
|
|
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<JObject>(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<RenderPreset>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renames key <paramref name="keyBefore"/> to <paramref name="targetKey"/>: a non-null old value overwrites the target
|
|
/// key, and the old key is always removed.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes the file of preset <paramref name="name"/>: 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.
|
|
/// </summary>
|
|
/// <exception cref="IOException">The file with the safe file name exists but cannot be deleted (not caught).</exception>
|
|
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<JObject>(json);
|
|
var pName = (string?)jObject?["Name"];
|
|
if (string.Equals(pName, name, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
File.Delete(file);
|
|
return;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Ignore read errors during cleanup search
|
|
}
|
|
}
|
|
}
|
|
} |