Implement CONFIG mixer and native profile persistence
This commit is contained in:
219
engine/Age.Engine.Tests/AudioMixerOpcodeTests.cs
Normal file
219
engine/Age.Engine.Tests/AudioMixerOpcodeTests.cs
Normal file
@@ -0,0 +1,219 @@
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Persistence;
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Vm;
|
||||
using System.Text;
|
||||
|
||||
public class AudioMixerOpcodeTests
|
||||
{
|
||||
private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
private static Operand G(int address) => new(3, address);
|
||||
private static Operand I(long value) => new(0, value);
|
||||
private static (int, Operand[]) Exit() => (0x2, Array.Empty<Operand>());
|
||||
|
||||
[Fact]
|
||||
public void NativeDefaultsUseUnconfiguredVolumesAndEnabledRoutes()
|
||||
{
|
||||
var settings = new AudioMixerSettings();
|
||||
|
||||
for (int category = 0; category < AudioMixerSettings.CategoryCount; category++)
|
||||
{
|
||||
Assert.True(settings.TryGetVolume(category, out int volume));
|
||||
Assert.Equal(AudioMixerSettings.UnconfiguredVolume, volume);
|
||||
}
|
||||
for (int category = (int)AudioMixerCategory.Music;
|
||||
category < AudioMixerSettings.CategoryCount;
|
||||
category++)
|
||||
{
|
||||
Assert.True(settings.TryGetRouteEnabled(category, out bool enabled));
|
||||
Assert.True(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MixerOpcodesRoundTripCategoriesAndApplyLiveChanges()
|
||||
{
|
||||
var settings = new AudioMixerSettings();
|
||||
var host = new RecordingHost();
|
||||
Script scene = ScriptAssembler.Assemble(Table, "CONFIG_MIXER",
|
||||
[
|
||||
(0xc6, [I(0), I(8000)]),
|
||||
(0xc6, [I(3), I(4500)]),
|
||||
(0x1ba, [I(1), I(0)]),
|
||||
(0xc5, [I(0), G(10)]),
|
||||
(0xc5, [I(3), G(11)]),
|
||||
(0xc5, [I(2), G(12)]),
|
||||
(0xc7, [I(1), G(13)]),
|
||||
(0xc7, [I(2), G(14)]),
|
||||
Exit(),
|
||||
], []);
|
||||
var vm = new VirtualMachine(scene, Table, host, audioMixerSettings: settings);
|
||||
|
||||
vm.Run();
|
||||
|
||||
Assert.Equal(8000, vm.Globals[10]);
|
||||
Assert.Equal(4500, vm.Globals[11]);
|
||||
Assert.Equal(AudioMixerSettings.UnconfiguredVolume, vm.Globals[12]);
|
||||
Assert.Equal(0, vm.Globals[13]);
|
||||
Assert.Equal(1, vm.Globals[14]);
|
||||
Assert.Equal([(0, 8000), (3, 4500)], host.AudioVolumeChanges);
|
||||
Assert.Equal([(1, false)], host.AudioRouteChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteSetterIsIdempotentLikeNativeWorkers()
|
||||
{
|
||||
var host = new RecordingHost();
|
||||
Script scene = ScriptAssembler.Assemble(Table, "CONFIG_ROUTE",
|
||||
[
|
||||
(0x1ba, [I(2), I(0)]),
|
||||
(0x1ba, [I(2), I(0)]),
|
||||
(0x1ba, [I(2), I(1)]),
|
||||
(0x1ba, [I(2), I(1)]),
|
||||
Exit(),
|
||||
], []);
|
||||
|
||||
new VirtualMachine(scene, Table, host).Run();
|
||||
|
||||
Assert.Equal([(2, false), (2, true)], host.AudioRouteChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameSessionCarriesMixerStateAcrossFreshSceneVms()
|
||||
{
|
||||
var session = new GameSession();
|
||||
Script setter = ScriptAssembler.Assemble(Table, "CONFIG_SET",
|
||||
[
|
||||
(0xc6, [I(2), I(6250)]),
|
||||
(0x1ba, [I(3), I(0)]),
|
||||
Exit(),
|
||||
], []);
|
||||
Script getter = ScriptAssembler.Assemble(Table, "CONFIG_GET",
|
||||
[
|
||||
(0xc5, [I(2), G(20)]),
|
||||
(0xc7, [I(3), G(21)]),
|
||||
Exit(),
|
||||
], []);
|
||||
|
||||
session.RunScene(setter, Table, new RecordingHost());
|
||||
session.RunScene(getter, Table, new RecordingHost());
|
||||
|
||||
Assert.Equal(6250, session.Globals[20]);
|
||||
Assert.Equal(0, session.Globals[21]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeSys4RegIniRoundTripsAudioAndPreservesOtherOptions()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), $"age-audio-settings-{Guid.NewGuid():N}");
|
||||
string path = Path.Combine(directory, Sys4RegIniStore.FileName);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
Encoding cp932 = Encoding.GetEncoding(932);
|
||||
const string original =
|
||||
"[display]\r\nScreenMode=1\r\n\r\n" +
|
||||
"[sound]\r\nSound=1\r\nMusic=2\r\nSE=7\r\nVoice=1\r\nMovie=1\r\n" +
|
||||
"Volume0=5500\r\nVolume1=2500\r\nVolume2=3500\r\nVolume3=3500\r\nVolume4=-1\r\n" +
|
||||
"UseDirectSound=1\r\n\r\n" +
|
||||
"[message]\r\nFont=MS 明朝\r\nMessageSpeed=50\r\n";
|
||||
File.WriteAllText(path, original, cp932);
|
||||
|
||||
var store = new Sys4RegIniStore(path, defaultMusicRouteValue: 2);
|
||||
AudioMixerSettings settings = store.Load();
|
||||
settings.Changed += store.Save;
|
||||
Assert.True(settings.TrySetVolume((int)AudioMixerCategory.Master, 9000));
|
||||
Assert.True(settings.TrySetVolume((int)AudioMixerCategory.Movie, 3750));
|
||||
Assert.True(settings.TrySetRouteEnabled(
|
||||
(int)AudioMixerCategory.Voice, false, out bool changed));
|
||||
Assert.True(changed);
|
||||
Assert.True(settings.TrySetRouteEnabled(
|
||||
(int)AudioMixerCategory.Music, false, out changed));
|
||||
Assert.True(changed);
|
||||
Assert.Contains("Music=-1\r\n", File.ReadAllText(path, cp932));
|
||||
Assert.True(settings.TrySetRouteEnabled(
|
||||
(int)AudioMixerCategory.Music, true, out changed));
|
||||
Assert.True(changed);
|
||||
|
||||
AudioMixerSettings loaded = store.Load();
|
||||
string rewritten = File.ReadAllText(path, cp932);
|
||||
|
||||
Assert.True(loaded.TryGetVolume((int)AudioMixerCategory.Master, out int master));
|
||||
Assert.True(loaded.TryGetVolume((int)AudioMixerCategory.Movie, out int movie));
|
||||
Assert.True(loaded.TryGetRouteEnabled((int)AudioMixerCategory.Voice, out bool voice));
|
||||
Assert.Equal(9000, master);
|
||||
Assert.Equal(3750, movie);
|
||||
Assert.False(voice);
|
||||
Assert.Contains("[display]\r\nScreenMode=1\r\n", rewritten);
|
||||
Assert.Contains("Sound=1\r\n", rewritten);
|
||||
Assert.Contains("Music=2\r\n", rewritten);
|
||||
Assert.Contains("SE=7\r\n", rewritten);
|
||||
Assert.Contains("UseDirectSound=1\r\n", rewritten);
|
||||
Assert.Contains("[message]\r\nFont=MS 明朝\r\nMessageSpeed=50\r\n", rewritten);
|
||||
Assert.DoesNotContain("engine-settings", rewritten);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativePersistencePathsComeFromIndependentSys4IniProfileValues()
|
||||
{
|
||||
Sys4AssetCatalog catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
||||
string localAppData = Path.Combine(Path.GetTempPath(), "native-appdata-root");
|
||||
|
||||
Sys4PersistencePaths paths = Sys4PersistencePaths.ResolveNative(
|
||||
catalog.StartupSettings, "C:\\unused-game-root", localAppData);
|
||||
|
||||
Assert.Equal(
|
||||
Path.Combine(localAppData, "Eushully", "姫狩りダンジョンマイスター", "SAVE"),
|
||||
paths.SaveDirectory);
|
||||
Assert.Equal(
|
||||
Path.Combine(localAppData, "Eushully", "姫狩りダンジョンマイスター", "SYS4REG.INI"),
|
||||
paths.Sys4RegIniPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProfileOverrideRedirectsSaveAndSettingsAsOneNativeLayout()
|
||||
{
|
||||
Sys4AssetCatalog catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
||||
string profileRoot = Path.Combine(Path.GetTempPath(), "redirected-profile-root");
|
||||
|
||||
Sys4PersistencePaths paths = Sys4PersistencePaths.ResolveProfileOverride(
|
||||
catalog.StartupSettings, profileRoot);
|
||||
Sys4RegIniStore store = Sys4RegIniStore.ForPath(
|
||||
catalog.StartupSettings, paths.Sys4RegIniPath);
|
||||
|
||||
Assert.Equal(Path.Combine(profileRoot, "SAVE"), paths.SaveDirectory);
|
||||
Assert.Equal(Path.Combine(profileRoot, "SYS4REG.INI"), paths.Sys4RegIniPath);
|
||||
Assert.Equal(paths.Sys4RegIniPath, store.FilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidMixerCategoriesWarnAndLeaveOutputsUntouched()
|
||||
{
|
||||
var host = new RecordingHost();
|
||||
Script scene = ScriptAssembler.Assemble(Table, "CONFIG_INVALID",
|
||||
[
|
||||
(0xc5, [I(5), G(30)]),
|
||||
(0xc6, [I(-1), I(5000)]),
|
||||
(0xc7, [I(0), G(31)]),
|
||||
(0x1ba, [I(5), I(1)]),
|
||||
Exit(),
|
||||
], []);
|
||||
var vm = new VirtualMachine(scene, Table, host);
|
||||
vm.Globals[30] = 123;
|
||||
vm.Globals[31] = 456;
|
||||
|
||||
vm.Run();
|
||||
|
||||
Assert.Equal(123, vm.Globals[30]);
|
||||
Assert.Equal(456, vm.Globals[31]);
|
||||
Assert.Equal(4, host.Warnings.Count);
|
||||
Assert.Empty(host.AudioVolumeChanges);
|
||||
Assert.Empty(host.AudioRouteChanges);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ internal class RecordingHost : IHost
|
||||
public readonly List<int> SfxReleases = new();
|
||||
public readonly List<long> BgmTracks = new();
|
||||
public readonly List<(int Target, long Duration)> BgmFades = new();
|
||||
public readonly List<(int Category, int BasisPoints)> AudioVolumeChanges = new();
|
||||
public readonly List<(int Category, bool Enabled)> AudioRouteChanges = new();
|
||||
public readonly List<(long Resource, int Surface, long Flags, long SyncMask)> Movies = new();
|
||||
public System.Action? OnPlayMovie;
|
||||
public long? MovieStopTimeMs;
|
||||
@@ -182,6 +184,10 @@ internal class RecordingHost : IHost
|
||||
=> ScheduledSfxStarts.Add((channel, startMode, delayMs));
|
||||
public void ReleaseSoundEffect(int channel) => SfxReleases.Add(channel);
|
||||
public void FadeBgm(int targetPercent, long durationMs) => BgmFades.Add((targetPercent, durationMs));
|
||||
public void ApplyAudioVolume(int category, int basisPoints)
|
||||
=> AudioVolumeChanges.Add((category, basisPoints));
|
||||
public void ApplyAudioRouteEnabled(int category, bool enabled)
|
||||
=> AudioRouteChanges.Add((category, enabled));
|
||||
public long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask)
|
||||
{
|
||||
Movies.Add((resourceId, surfaceSlot, movieFlags, syncMask));
|
||||
|
||||
@@ -141,6 +141,9 @@ public interface IHost
|
||||
void ScheduleSoundEffectStart(int channel, int startMode, long delayMs) { }
|
||||
void ReleaseSoundEffect(int channel) { }
|
||||
void FadeBgm(int targetPercent, long durationMs) { }
|
||||
// AGE's sound:* settings registry is VM-owned; the host applies changes to active playback.
|
||||
void ApplyAudioVolume(int category, int basisPoints) { }
|
||||
void ApplyAudioRouteEnabled(int category, bool enabled) { }
|
||||
// Native op 0x236 binds a movie decoder to an existing retained texture surface.
|
||||
// Playback is non-modal: the VM advances to the following instruction while the host publishes frames.
|
||||
/// <returns>The initialized movie graph's stop position in truncated integer milliseconds, or null
|
||||
|
||||
131
engine/Age.Engine/Model/AudioMixerSettings.cs
Normal file
131
engine/Age.Engine/Model/AudioMixerSettings.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
namespace Age.Engine.Model;
|
||||
|
||||
public enum AudioMixerCategory
|
||||
{
|
||||
Master = 0,
|
||||
Music = 1,
|
||||
SoundEffect = 2,
|
||||
Voice = 3,
|
||||
Movie = 4,
|
||||
}
|
||||
|
||||
public sealed record AudioMixerSettingsSnapshot(int[] Volumes, bool[] Routes);
|
||||
|
||||
/// <summary>
|
||||
/// Profile-lifetime projection of AGE's sound:* settings registry. Volume -1 is the native
|
||||
/// unconfigured sentinel; nonnegative values are basis points. Route zero is unused because master
|
||||
/// has no independent enable switch.
|
||||
/// </summary>
|
||||
public sealed class AudioMixerSettings
|
||||
{
|
||||
public const int CategoryCount = 5;
|
||||
public const int UnconfiguredVolume = -1;
|
||||
public const int MaximumVolume = 10_000;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private readonly int[] _volumes =
|
||||
[
|
||||
UnconfiguredVolume, UnconfiguredVolume, UnconfiguredVolume,
|
||||
UnconfiguredVolume, UnconfiguredVolume,
|
||||
];
|
||||
private readonly bool[] _routes = [false, true, true, true, true];
|
||||
|
||||
public event Action<AudioMixerSettingsSnapshot>? Changed;
|
||||
|
||||
public bool TryGetVolume(int category, out int basisPoints)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if ((uint)category >= CategoryCount)
|
||||
{
|
||||
basisPoints = default;
|
||||
return false;
|
||||
}
|
||||
basisPoints = _volumes[category];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TrySetVolume(int category, long basisPoints)
|
||||
{
|
||||
AudioMixerSettingsSnapshot snapshot;
|
||||
lock (_lock)
|
||||
{
|
||||
if ((uint)category >= CategoryCount) return false;
|
||||
_volumes[category] = checked((int)basisPoints);
|
||||
snapshot = SnapshotLocked();
|
||||
}
|
||||
Changed?.Invoke(snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetRouteEnabled(int category, out bool enabled)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (category is < (int)AudioMixerCategory.Music or >= CategoryCount)
|
||||
{
|
||||
enabled = default;
|
||||
return false;
|
||||
}
|
||||
enabled = _routes[category];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TrySetRouteEnabled(int category, bool enabled, out bool changed)
|
||||
{
|
||||
AudioMixerSettingsSnapshot? snapshot = null;
|
||||
lock (_lock)
|
||||
{
|
||||
if (category is < (int)AudioMixerCategory.Music or >= CategoryCount)
|
||||
{
|
||||
changed = false;
|
||||
return false;
|
||||
}
|
||||
changed = _routes[category] != enabled;
|
||||
if (changed)
|
||||
{
|
||||
_routes[category] = enabled;
|
||||
snapshot = SnapshotLocked();
|
||||
}
|
||||
}
|
||||
if (snapshot != null) Changed?.Invoke(snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
public AudioMixerSettingsSnapshot Snapshot()
|
||||
{
|
||||
lock (_lock) return SnapshotLocked();
|
||||
}
|
||||
|
||||
public void Replace(AudioMixerSettingsSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
ValidateSnapshot(snapshot);
|
||||
lock (_lock)
|
||||
{
|
||||
snapshot.Volumes.CopyTo(_volumes, 0);
|
||||
snapshot.Routes.CopyTo(_routes, 0);
|
||||
_routes[(int)AudioMixerCategory.Master] = false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void ValidateSnapshot(AudioMixerSettingsSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
if (snapshot.Volumes.Length != CategoryCount)
|
||||
throw new InvalidDataException($"Audio mixer volume table must contain {CategoryCount} values.");
|
||||
if (snapshot.Routes.Length != CategoryCount)
|
||||
throw new InvalidDataException($"Audio mixer route table must contain {CategoryCount} values.");
|
||||
if (snapshot.Routes[(int)AudioMixerCategory.Master])
|
||||
throw new InvalidDataException("Audio mixer master route flag must be false.");
|
||||
foreach (int value in snapshot.Volumes)
|
||||
if (value is < UnconfiguredVolume or > MaximumVolume)
|
||||
throw new InvalidDataException(
|
||||
$"Audio mixer volume {value} is outside {UnconfiguredVolume}..{MaximumVolume}.");
|
||||
}
|
||||
|
||||
private AudioMixerSettingsSnapshot SnapshotLocked()
|
||||
=> new(_volumes.ToArray(), _routes.ToArray());
|
||||
}
|
||||
98
engine/Age.Engine/Persistence/Sys4PersistencePaths.cs
Normal file
98
engine/Age.Engine/Persistence/Sys4PersistencePaths.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
278
engine/Age.Engine/Persistence/Sys4RegIniStore.cs
Normal file
278
engine/Age.Engine/Persistence/Sys4RegIniStore.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,13 +28,17 @@ public sealed class GameSession
|
||||
public SharedProfile SharedProfile { get; }
|
||||
/// <summary>Native shared/numbered save directory service used by persistence opcodes.</summary>
|
||||
public INativeDatStore? NativeDatStore { get; }
|
||||
/// <summary>AGE's profile-lifetime sound:* settings registry, independent of SAVE.DAT.</summary>
|
||||
public AudioMixerSettings AudioMixerSettings { get; }
|
||||
/// <summary>The live retained ADV backlog shared by every VM run in this session.</summary>
|
||||
public AdvTextHistory TextHistory { get; } = new();
|
||||
|
||||
public GameSession(SharedProfile? sharedProfile = null, INativeDatStore? nativeDatStore = null)
|
||||
public GameSession(SharedProfile? sharedProfile = null, INativeDatStore? nativeDatStore = null,
|
||||
AudioMixerSettings? audioMixerSettings = null)
|
||||
{
|
||||
SharedProfile = sharedProfile ?? new SharedProfile();
|
||||
NativeDatStore = nativeDatStore;
|
||||
AudioMixerSettings = audioMixerSettings ?? new AudioMixerSettings();
|
||||
}
|
||||
|
||||
public void Seed(int addr, long value) => Globals[addr] = value;
|
||||
@@ -46,7 +50,8 @@ public sealed class GameSession
|
||||
ITraceSink? sink = null)
|
||||
{
|
||||
var vm = new VirtualMachine(
|
||||
script, table, host, options, provider, sink, TextHistory, SharedProfile, NativeDatStore);
|
||||
script, table, host, options, provider, sink, TextHistory, SharedProfile, NativeDatStore,
|
||||
AudioMixerSettings);
|
||||
foreach (var kv in Globals) vm.Globals[kv.Key] = kv.Value;
|
||||
foreach (var kv in GlobalFloats) vm.GlobalFloats[kv.Key] = kv.Value;
|
||||
foreach (var kv in GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value;
|
||||
|
||||
@@ -40,6 +40,7 @@ public sealed class VirtualMachine
|
||||
private readonly Encoding _nativeStringEncoding;
|
||||
private readonly IScriptProvider? _provider;
|
||||
private readonly SharedProfile _sharedProfile;
|
||||
private readonly AudioMixerSettings _audioMixerSettings;
|
||||
private readonly INativeDatStore? _nativeDatStore;
|
||||
private static readonly bool _diagSetTexture = System.Environment.GetEnvironmentVariable("AGE_DIAG_SETTEX") == "1";
|
||||
private ExecFrame _cur = null!;
|
||||
@@ -149,13 +150,15 @@ public sealed class VirtualMachine
|
||||
public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null,
|
||||
IScriptProvider? provider = null, ITraceSink? sink = null,
|
||||
AdvTextHistory? textHistory = null, SharedProfile? sharedProfile = null,
|
||||
INativeDatStore? nativeDatStore = null)
|
||||
INativeDatStore? nativeDatStore = null,
|
||||
AudioMixerSettings? audioMixerSettings = null)
|
||||
{
|
||||
_s = s; _t = t; _host = host; _o = o ?? new VmOptions(); _provider = provider;
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
_nativeStringEncoding = Encoding.GetEncoding(_o.NativeStringCodePage);
|
||||
_sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory();
|
||||
_sharedProfile = sharedProfile ?? new SharedProfile();
|
||||
_audioMixerSettings = audioMixerSettings ?? new AudioMixerSettings();
|
||||
_nativeDatStore = nativeDatStore;
|
||||
_messageWindowAlphaSetting = host.MessageWindowAlphaSetting;
|
||||
_messageGlyphDelayMilliseconds = System.Math.Max(0, host.MessageGlyphDelayMilliseconds);
|
||||
@@ -2419,6 +2422,46 @@ public sealed class VirtualMachine
|
||||
if (targetPercent == 0) _currentBgmTrackId = 0;
|
||||
return pc + 1;
|
||||
}
|
||||
case "get-audio-volume": // 0xc5 (category)(out basis points)
|
||||
{
|
||||
int category = unchecked((int)Read(a[0]));
|
||||
if (_audioMixerSettings.TryGetVolume(category, out int basisPoints))
|
||||
Write(a[1], basisPoints);
|
||||
else
|
||||
_host.ReportWarning($"audio volume category out of range: {category}");
|
||||
return pc + 1;
|
||||
}
|
||||
case "set-audio-volume": // 0xc6 (category)(basis points)
|
||||
{
|
||||
int category = unchecked((int)Read(a[0]));
|
||||
long basisPoints = Read(a[1]);
|
||||
if (_audioMixerSettings.TrySetVolume(category, basisPoints))
|
||||
_host.ApplyAudioVolume(category, checked((int)basisPoints));
|
||||
else
|
||||
_host.ReportWarning($"audio volume category out of range: {category}");
|
||||
return pc + 1;
|
||||
}
|
||||
case "get-audio-route-enabled": // 0xc7 (category)(out boolean)
|
||||
{
|
||||
int category = unchecked((int)Read(a[0]));
|
||||
if (_audioMixerSettings.TryGetRouteEnabled(category, out bool enabled))
|
||||
Write(a[1], enabled ? 1 : 0);
|
||||
else
|
||||
_host.ReportWarning($"audio route category out of range: {category}");
|
||||
return pc + 1;
|
||||
}
|
||||
case "set-audio-route-enabled": // 0x1ba (category)(enabled)
|
||||
{
|
||||
int category = unchecked((int)Read(a[0]));
|
||||
bool enabled = Read(a[1]) != 0;
|
||||
if (_audioMixerSettings.TrySetRouteEnabled(category, enabled, out bool changed))
|
||||
{
|
||||
if (changed) _host.ApplyAudioRouteEnabled(category, enabled);
|
||||
}
|
||||
else
|
||||
_host.ReportWarning($"audio route category out of range: {category}");
|
||||
return pc + 1;
|
||||
}
|
||||
case "u00415880": // 0xd9 / semantics: clear-run-state-0x1000
|
||||
return pc + 1;
|
||||
case "get-initial-root-run": // 0x130 (out)
|
||||
|
||||
Reference in New Issue
Block a user