Implement CONFIG mixer and native profile persistence

This commit is contained in:
gamer147
2026-07-29 10:22:22 -04:00
parent bcfb3848bc
commit 1678459b05
16 changed files with 1037 additions and 32 deletions

View File

@@ -0,0 +1,98 @@
using System.Globalization;
using Age.Engine.Sys4;
namespace Age.Engine.Persistence;
/// <summary>
/// Resolves the two native AGE persistence locations from one SYS4INI profile. Native mode follows
/// USEAPPDATAFOLDER plus the independent SAVEPATH and REGFILEPATH values. A profile override replaces
/// the resolved REGFILEPATH directory while preserving SAVEPATH's relative tail beneath it.
/// </summary>
public readonly record struct Sys4PersistencePaths(
string SaveDirectory,
string Sys4RegIniPath)
{
public static Sys4PersistencePaths ResolveNative(
Sys4StartupSettings startupSettings,
string gameRoot,
string? localApplicationDataRoot = null)
{
ArgumentNullException.ThrowIfNull(startupSettings);
ArgumentException.ThrowIfNullOrWhiteSpace(gameRoot);
bool useAppData = int.TryParse(
startupSettings.GetValueOrDefault("USEAPPDATAFOLDER"),
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int useAppDataValue)
&& useAppDataValue != 0;
string root = useAppData
? localApplicationDataRoot
?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)
: gameRoot;
if (string.IsNullOrWhiteSpace(root))
throw new InvalidDataException("Native AGE persistence root is unavailable.");
string saveDirectory = CombineWindowsRelativePath(
root, startupSettings.GetValueOrDefault("SAVEPATH"), "SAVEPATH");
string settingsDirectory = CombineWindowsRelativePath(
root, startupSettings.GetValueOrDefault("REGFILEPATH"), "REGFILEPATH");
return new Sys4PersistencePaths(
saveDirectory,
Path.Combine(settingsDirectory, Sys4RegIniStore.FileName));
}
public static Sys4PersistencePaths ResolveProfileOverride(
Sys4StartupSettings startupSettings,
string profileRoot)
{
ArgumentNullException.ThrowIfNull(startupSettings);
ArgumentException.ThrowIfNullOrWhiteSpace(profileRoot);
IReadOnlyList<string> settingsComponents = ParseWindowsRelativePath(
startupSettings.GetValueOrDefault("REGFILEPATH"), "REGFILEPATH");
IReadOnlyList<string> saveComponents = ParseWindowsRelativePath(
startupSettings.GetValueOrDefault("SAVEPATH"), "SAVEPATH");
if (settingsComponents.Count > saveComponents.Count
|| !settingsComponents
.Select((component, index) => string.Equals(
component, saveComponents[index], StringComparison.OrdinalIgnoreCase))
.All(matches => matches))
{
throw new InvalidDataException(
"SAVEPATH is not beneath REGFILEPATH; one profile-root override cannot safely " +
"represent both native AGE persistence locations.");
}
string root = Path.GetFullPath(profileRoot);
string saveDirectory = saveComponents
.Skip(settingsComponents.Count)
.Aggregate(root, Path.Combine);
return new Sys4PersistencePaths(
saveDirectory,
Path.Combine(root, Sys4RegIniStore.FileName));
}
private static string CombineWindowsRelativePath(
string root,
string? relative,
string settingName)
=> ParseWindowsRelativePath(relative, settingName)
.Aggregate(Path.GetFullPath(root), Path.Combine);
private static IReadOnlyList<string> ParseWindowsRelativePath(
string? relative,
string settingName)
{
string[] components = (relative ?? "").Split(
['\\', '/'],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (string component in components)
{
if (component is "." or ".." || component.Contains(':'))
throw new InvalidDataException(
$"{settingName} contains unsafe component '{component}'.");
}
return components;
}
}

View File

@@ -0,0 +1,278 @@
using System.Globalization;
using System.Text;
using Age.Engine.Model;
using Age.Engine.Sys4;
namespace Age.Engine.Persistence;
/// <summary>
/// Preserving reader/writer for AGE's native SYS4REG.INI engine-settings file. The current projection
/// owns only the audio keys; all other sections, keys, comments, ordering, and newline style survive.
/// </summary>
public sealed class Sys4RegIniStore
{
public const string FileName = "SYS4REG.INI";
private const string SoundSection = "sound";
private static readonly Encoding Cp932 = CreateCp932();
private static readonly string[] RouteKeys = ["", "Music", "SE", "Voice", "Movie"];
private readonly string _path;
private readonly object _lock = new();
private readonly int _defaultMusicRouteValue;
private readonly int[] _routeValues = [0, 0, 1, 1, 1];
private IniDocument? _document;
public Sys4RegIniStore(string path, int defaultMusicRouteValue)
{
if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Settings path is required.", nameof(path));
_path = Path.GetFullPath(path);
_defaultMusicRouteValue = defaultMusicRouteValue;
_routeValues[(int)AudioMixerCategory.Music] = defaultMusicRouteValue;
}
public string FilePath => _path;
/// <summary>
/// Create a store at an already-resolved location while retaining SYS4INI's native defaults.
/// Path policy belongs to Sys4PersistencePaths so save and settings locations are selected together.
/// </summary>
public static Sys4RegIniStore ForPath(
Sys4StartupSettings startupSettings,
string path)
{
ArgumentNullException.ThrowIfNull(startupSettings);
ArgumentException.ThrowIfNullOrWhiteSpace(path);
int defaultMusic = int.TryParse(
startupSettings.GetValueOrDefault("NOSETMUSIC"),
NumberStyles.Integer, CultureInfo.InvariantCulture, out int noSetMusic)
&& noSetMusic != 0
? noSetMusic - 1
: 0;
return new Sys4RegIniStore(path, defaultMusic);
}
public AudioMixerSettings Load()
{
var settings = new AudioMixerSettings();
lock (_lock)
{
_document = LoadDocument();
int[] volumes = Enumerable.Repeat(
AudioMixerSettings.UnconfiguredVolume, AudioMixerSettings.CategoryCount).ToArray();
bool[] routes = [false, true, true, true, true];
for (int category = 0; category < AudioMixerSettings.CategoryCount; category++)
{
if (_document.TryGetInt(SoundSection, $"Volume{category}", out int value)
&& value is >= AudioMixerSettings.UnconfiguredVolume
and <= AudioMixerSettings.MaximumVolume)
volumes[category] = value;
}
_routeValues[(int)AudioMixerCategory.Music] = _document.TryGetInt(
SoundSection, RouteKeys[(int)AudioMixerCategory.Music], out int music)
? music
: _defaultMusicRouteValue;
routes[(int)AudioMixerCategory.Music] =
_routeValues[(int)AudioMixerCategory.Music] >= 0;
for (int category = (int)AudioMixerCategory.SoundEffect;
category < AudioMixerSettings.CategoryCount;
category++)
{
_routeValues[category] = _document.TryGetInt(
SoundSection, RouteKeys[category], out int value)
? value
: 1;
routes[category] = _routeValues[category] != 0;
}
settings.Replace(new AudioMixerSettingsSnapshot(volumes, routes));
}
return settings;
}
public void Save(AudioMixerSettingsSnapshot snapshot)
{
AudioMixerSettings.ValidateSnapshot(snapshot);
lock (_lock)
{
_document ??= LoadDocument();
for (int category = (int)AudioMixerCategory.Music;
category < AudioMixerSettings.CategoryCount;
category++)
{
bool wasEnabled = category == (int)AudioMixerCategory.Music
? _routeValues[category] >= 0
: _routeValues[category] != 0;
bool enabled = snapshot.Routes[category];
if (wasEnabled != enabled)
{
_routeValues[category] = category == (int)AudioMixerCategory.Music
? checked(_routeValues[category] + (enabled ? 3 : -3))
: enabled ? 1 : 0;
}
_document.SetInt(SoundSection, RouteKeys[category], _routeValues[category]);
}
for (int category = 0; category < AudioMixerSettings.CategoryCount; category++)
_document.SetInt(SoundSection, $"Volume{category}", snapshot.Volumes[category]);
string? directory = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
string temporary = _path + ".$tmp";
File.WriteAllText(temporary, _document.Serialize(), Cp932);
File.Move(temporary, _path, overwrite: true);
}
}
private IniDocument LoadDocument()
=> File.Exists(_path)
? IniDocument.Parse(File.ReadAllText(_path, Cp932))
: IniDocument.Empty();
private static Encoding CreateCp932()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(932);
}
private sealed class IniDocument
{
private readonly List<string> _lines;
private readonly string _newline;
private readonly bool _trailingNewline;
private IniDocument(List<string> lines, string newline, bool trailingNewline)
{
_lines = lines;
_newline = newline;
_trailingNewline = trailingNewline;
}
public static IniDocument Empty() => new([], "\r\n", true);
public static IniDocument Parse(string text)
{
if (text.Length == 0) return Empty();
string newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n"
: text.Contains('\n') ? "\n"
: text.Contains('\r') ? "\r"
: "\r\n";
bool trailing = text.EndsWith("\r\n", StringComparison.Ordinal)
|| text.EndsWith('\n') || text.EndsWith('\r');
string normalized = text.Replace("\r\n", "\n").Replace('\r', '\n');
var lines = normalized.Split('\n').ToList();
if (trailing && lines.Count > 0 && lines[^1].Length == 0) lines.RemoveAt(lines.Count - 1);
return new IniDocument(lines, newline, trailing);
}
public bool TryGetInt(string section, string key, out int value)
{
value = default;
bool found = false;
string? currentSection = null;
foreach (string line in _lines)
{
if (TryParseSection(line, out string? parsedSection))
{
currentSection = parsedSection;
continue;
}
if (!string.Equals(currentSection, section, StringComparison.OrdinalIgnoreCase)
|| !TryParseKey(line, out string? parsedKey, out string? rawValue)
|| !string.Equals(parsedKey, key, StringComparison.OrdinalIgnoreCase))
continue;
if (int.TryParse(rawValue!.Trim(), NumberStyles.Integer,
CultureInfo.InvariantCulture, out int parsed))
{
value = parsed;
found = true;
}
}
return found;
}
public void SetInt(string section, string key, int value)
{
string rendered = value.ToString(CultureInfo.InvariantCulture);
int sectionStart = -1;
int sectionEnd = _lines.Count;
string? currentSection = null;
bool replaced = false;
for (int index = 0; index < _lines.Count; index++)
{
string line = _lines[index];
if (TryParseSection(line, out string? parsedSection))
{
if (sectionStart >= 0)
{
sectionEnd = index;
break;
}
currentSection = parsedSection;
if (string.Equals(currentSection, section, StringComparison.OrdinalIgnoreCase))
sectionStart = index;
continue;
}
if (sectionStart < 0
|| !string.Equals(currentSection, section, StringComparison.OrdinalIgnoreCase)
|| !TryParseKey(line, out string? parsedKey, out _)
|| !string.Equals(parsedKey, key, StringComparison.OrdinalIgnoreCase))
continue;
int equals = line.IndexOf('=');
_lines[index] = line[..(equals + 1)] + rendered;
replaced = true;
}
if (replaced) return;
if (sectionStart < 0)
{
if (_lines.Count > 0 && _lines[^1].Length != 0) _lines.Add("");
_lines.Add($"[{section}]");
_lines.Add($"{key}={rendered}");
}
else
{
_lines.Insert(sectionEnd, $"{key}={rendered}");
}
}
public string Serialize()
{
string text = string.Join(_newline, _lines);
return _trailingNewline ? text + _newline : text;
}
private static bool TryParseSection(string line, out string? section)
{
string trimmed = line.Trim();
if (trimmed.Length >= 2 && trimmed[0] == '[' && trimmed[^1] == ']')
{
section = trimmed[1..^1].Trim();
return true;
}
section = null;
return false;
}
private static bool TryParseKey(string line, out string? key, out string? value)
{
string trimmed = line.TrimStart();
if (trimmed.Length == 0 || trimmed[0] is ';' or '#')
{
key = null;
value = null;
return false;
}
int equals = line.IndexOf('=');
if (equals < 0)
{
key = null;
value = null;
return false;
}
key = line[..equals].Trim();
value = line[(equals + 1)..];
return key.Length != 0;
}
}
}