using System.Globalization; using System.Text; namespace WiiCompiled.Setup; internal sealed record RuntimeConfigSnapshot(bool Existed, byte[] Contents); /// /// Reads and writes the runtime's Config.toml. Every entry point takes the file it operates on /// since its location depends on the installation (portable UserData vs. per-user app data); /// callers obtain it once via . /// internal static class RuntimeConfiguration { public const string ConfigFileName = "Config.toml"; /// /// Ordinal, matching the runtime parser/writer (runtime_config.h) exactly. Case-insensitive /// matching here would let setup rewrite a [Paths]/Dvd_Root spelling the runtime ignores. /// private const StringComparison KeyComparison = StringComparison.Ordinal; /// The per-user configuration an ordinary (non-portable) installation shares. public static string ApplicationDataConfigPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WiiCompiled", ConfigFileName); /// /// The configuration file that governs . This mirrors /// RuntimeConfigFile::ResolveConfigPath in the runtime: portable roots win, everything /// else is per-user application data. /// public static string ResolveConfigPath(string? installDirectory) { if (!string.IsNullOrWhiteSpace(installDirectory) && PortableRoot.TryFind(installDirectory) is { } portableRoot) return Path.Combine(PortableRoot.UserDataDirectory(portableRoot), ConfigFileName); return ApplicationDataConfigPath; } public static RuntimeConfigSnapshot Capture(string configPath) => File.Exists(configPath) ? new RuntimeConfigSnapshot(true, File.ReadAllBytes(configPath)) : new RuntimeConfigSnapshot(false, []); public static void Restore(string configPath, RuntimeConfigSnapshot snapshot) { if (snapshot.Existed) FileSystemUtilities.WriteAtomic(configPath, snapshot.Contents); else File.Delete(configPath); } internal static void SetDvdRoot(string configPath, string dvdRoot) => SetPath(configPath, "dvd_root", dvdRoot); /// /// Records the one canonical Retro Rewind installation Wheel Wizard owns. The runtime builds its /// asset overlay by scanning this directory live at launch, so an asset-only Retro Rewind update /// needs no backend work: the next launch simply reads the new files. /// internal static void SetRetroRewindRoot(string configPath, string retroRewindRoot) => SetPath(configPath, "retro_rewind_root", retroRewindRoot); /// The canonical Retro Rewind root, or null when no installation has recorded one. public static string? GetRetroRewindRoot(string configPath) => GetResolvedPath(configPath, "retro_rewind_root"); internal static void RemoveRetroRewindRootIfOwned(string configPath, string retroRewindRoot) => RemovePathIfOwned(configPath, "retro_rewind_root", retroRewindRoot); internal static void RemoveDvdRootIfOwned(string configPath, string dvdRoot) => RemovePathIfOwned(configPath, "dvd_root", dvdRoot); /// The raw stored text of a [paths] key, exactly as the file holds it. internal static string? GetPath(string configPath, string key) => TryUnquoteToml(GetRawValue(configPath, "paths", key) ?? "", out var value) ? value : null; /// /// A [paths] value as the runtime will actually use it. The runtime resolves every relative /// [paths] value against the directory holding Config.toml (never the working /// directory), so the host must resolve it the same way before comparing or reading it. /// internal static string? GetResolvedPath(string configPath, string key) { var stored = GetPath(configPath, key); return string.IsNullOrWhiteSpace(stored) ? null : ResolveAgainstConfig(configPath, stored); } internal static string ConfigDirectory(string configPath) => Path.GetDirectoryName(Path.GetFullPath(configPath)) ?? throw new InvalidOperationException($"{configPath} has no containing directory."); internal static string ResolveAgainstConfig(string configPath, string value) => Path.GetFullPath(value, ConfigDirectory(configPath)); private static void SetPath(string configPath, string key, string value) { var lines = ReadLinesOrDefault(configPath); SetSectionValue(lines, "paths", key, QuoteToml(FormatPathValue(configPath, value))); WriteLines(configPath, lines); } /// /// Paths inside the portable root are stored relative to the config directory (forward slashes, /// needing no TOML escaping) so the tree keeps working if the user moves or renames the root. /// Everything else, including all non-portable paths, is stored absolute. /// private static string FormatPathValue(string configPath, string value) { var full = Path.GetFullPath(value); var configDirectory = ConfigDirectory(configPath); if (PortableRoot.TryFind(configDirectory) is not { } root || !PortableRoot.Contains(root, full)) return full; var relative = Path.GetRelativePath(configDirectory, full); return Path.IsPathRooted(relative) ? full : relative.Replace(Path.DirectorySeparatorChar, '/'); } private static void RemovePathIfOwned(string configPath, string key, string expectedValue) { if (!File.Exists(configPath)) return; var lines = File.ReadAllLines(configPath).ToList(); var inPaths = false; var changed = false; for (var index = 0; index < lines.Count; index++) { var trimmed = RemoveComment(lines[index]).Trim(); if (IsSection(trimmed)) { inPaths = trimmed.Equals("[paths]", KeyComparison); continue; } if (!inPaths || trimmed.Length == 0) continue; var equals = trimmed.IndexOf('='); if (equals < 0 || !trimmed[..equals].Trim().Equals(key, KeyComparison)) continue; if (!TryUnquoteToml(trimmed[(equals + 1)..].Trim(), out var configured)) return; // A portable installation stores this relative, so ownership is decided on the path the // runtime would actually resolve, not on the stored text. if (!ResolveAgainstConfig(configPath, configured) .Equals(Path.GetFullPath(expectedValue), StringComparison.OrdinalIgnoreCase)) return; lines.RemoveAt(index); changed = true; break; } if (changed) WriteLines(configPath, lines); } /// The raw TOML literal stored for a key, or null when the section or key is absent. internal static string? GetRawValue(string configPath, string section, string key) { if (!File.Exists(configPath)) return null; var header = $"[{section}]"; var inSection = false; foreach (var line in File.ReadLines(configPath)) { var trimmed = RemoveComment(line).Trim(); if (IsSection(trimmed)) { inSection = trimmed.Equals(header, KeyComparison); continue; } if (!inSection || trimmed.Length == 0) continue; var equals = trimmed.IndexOf('='); if (equals < 0 || !trimmed[..equals].Trim().Equals(key, KeyComparison)) continue; return trimmed[(equals + 1)..].Trim(); } return null; } /// /// Replaces one key in one section, preserving comments, ordering, and every unrelated setting - /// this file belongs to the user and to the in-game settings bar as much as to setup. A /// commented-out key is deliberately not matched, matching the runtime's own writer. /// private static void SetSectionValue(List lines, string section, string key, string value) { var header = $"[{section}]"; var sectionStart = -1; var sectionEnd = lines.Count; for (var index = 0; index < lines.Count; index++) { var trimmed = RemoveComment(lines[index]).Trim(); if (!IsSection(trimmed)) continue; if (sectionStart >= 0) { sectionEnd = index; break; } if (trimmed.Equals(header, KeyComparison)) sectionStart = index; } if (sectionStart < 0) { if (lines.Count > 0 && lines[^1].Length != 0) lines.Add(""); lines.Add(header); lines.Add($"{key} = {value}"); return; } for (var index = sectionStart + 1; index < sectionEnd; index++) { var trimmed = RemoveComment(lines[index]).Trim(); if (trimmed.Length == 0) continue; var equals = trimmed.IndexOf('='); if (equals >= 0 && trimmed[..equals].Trim().Equals(key, KeyComparison)) { lines[index] = $"{key} = {value}"; return; } } // Append after the section's last real line, not after the blank line that separates it from // the next header, so a generated file stays readable for the user who also edits it by hand. var insertAt = sectionEnd; while (insertAt > sectionStart + 1 && lines[insertAt - 1].Trim().Length == 0) insertAt--; lines.Insert(insertAt, $"{key} = {value}"); } private static List ReadLinesOrDefault(string configPath) => File.Exists(configPath) ? File.ReadAllLines(configPath).ToList() : DefaultConfigLines(); private static void WriteLines(string configPath, List lines) => FileSystemUtilities.WriteAtomic(configPath, Encoding.UTF8.GetBytes(string.Join(Environment.NewLine, lines) + Environment.NewLine)); private static List DefaultConfigLines() => [ "# WiiCompiled user configuration", "", "[paths]" ]; private static bool IsSection(string value) => value.Length >= 2 && value[0] == '[' && value[^1] == ']'; /// /// Line-for-line port of RemoveComment in runtime_config.h, including its backslash /// escaping inside basic strings: without it a Windows path ending in \\" reads as /// re-opening the quote, and the two implementations disagree about which # starts a comment. /// private static string RemoveComment(string line) { var inSingle = false; var inDouble = false; var escaped = false; for (var index = 0; index < line.Length; index++) { var character = line[index]; if (inDouble && character == '\\' && !escaped) { escaped = true; continue; } if (character == '\'' && !inDouble) inSingle = !inSingle; else if (character == '"' && !inSingle && !escaped) inDouble = !inDouble; else if (character == '#' && !inSingle && !inDouble) return line[..index]; escaped = false; } return line; } private static string QuoteToml(string value) => "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; internal static bool TryUnquoteToml(string value, out string result) { result = ""; if (value.Length < 2) return false; // Literal strings carry no escapes; the runtime's parser treats them the same way. if (value[0] == '\'' && value[^1] == '\'') { result = value[1..^1]; return true; } if (value[0] != '"' || value[^1] != '"') return false; var builder = new StringBuilder(value.Length - 2); for (var index = 1; index < value.Length - 1; index++) { if (value[index] == '\\' && index + 1 < value.Length - 1) { var escaped = value[++index]; builder.Append(escaped switch { '\\' => '\\', '"' => '"', _ => escaped }); } else { builder.Append(value[index]); } } result = builder.ToString(); return true; } }