feat(A.5 T22.5): QualityPreset schema + tests (commit 1/2)

Add QualityPreset enum + QualitySettings readonly record struct with
From(preset) table and WithEnvOverrides() env-var override layer.
Four presets (Low/Medium/High/Ultra) drive NearRadius, FarRadius,
MsaaSamples, AnisotropicLevel, AlphaToCoverage, MaxCompletionsPerFrame.
Env vars (ACDREAM_NEAR_RADIUS, ACDREAM_FAR_RADIUS, ACDREAM_MSAA_SAMPLES,
ACDREAM_ANISOTROPIC, ACDREAM_A2C, ACDREAM_MAX_COMPLETIONS_PER_FRAME)
override individual preset fields for dev spot-testing.

DisplaySettings gains a Quality: QualityPreset field (default High);
SettingsStore persists/loads it under display."quality" as an enum
name string with Enum.TryParse fallback. 12 new QualityPresetTests
cover the preset table (radii, msaa, aniso, a2c, completions) and all
six env-var override paths. 415 UI.Abstractions tests passing.

Wiring into GameWindow / WbDrawDispatcher / TerrainAtlas follows in
commit 2 of this task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-10 08:37:17 +02:00
parent c473feedb3
commit afa4200107
5 changed files with 272 additions and 9 deletions

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using AcDream.UI.Abstractions.Settings;
namespace AcDream.UI.Abstractions.Panels.Settings; namespace AcDream.UI.Abstractions.Panels.Settings;
@ -20,7 +21,8 @@ public sealed record DisplaySettings(
bool VSync, bool VSync,
float FieldOfView, float FieldOfView,
float Gamma, float Gamma,
bool ShowFps) bool ShowFps,
QualityPreset Quality)
{ {
/// <summary>Values used on first launch / when settings.json is absent. /// <summary>Values used on first launch / when settings.json is absent.
/// All defaults pinned to the pre-L.0 runtime state — Resolution /// All defaults pinned to the pre-L.0 runtime state — Resolution
@ -35,7 +37,8 @@ public sealed record DisplaySettings(
VSync: false, VSync: false,
FieldOfView: 60f, FieldOfView: 60f,
Gamma: 1.0f, Gamma: 1.0f,
ShowFps: true); ShowFps: true,
Quality: QualityPreset.High);
/// <summary>16:9 resolution presets offered in the dropdown.</summary> /// <summary>16:9 resolution presets offered in the dropdown.</summary>
public static IReadOnlyList<string> AvailableResolutions { get; } = new[] public static IReadOnlyList<string> AvailableResolutions { get; } = new[]

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using AcDream.UI.Abstractions.Settings;
namespace AcDream.UI.Abstractions.Panels.Settings; namespace AcDream.UI.Abstractions.Panels.Settings;
@ -62,12 +63,13 @@ public sealed class SettingsStore
var d = DisplaySettings.Default; var d = DisplaySettings.Default;
return new DisplaySettings( return new DisplaySettings(
Resolution: ReadString (disp, "resolution", d.Resolution), Resolution: ReadString (disp, "resolution", d.Resolution),
Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen), Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen),
VSync: ReadBool (disp, "vsync", d.VSync), VSync: ReadBool (disp, "vsync", d.VSync),
FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView), FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView),
Gamma: ReadFloat (disp, "gamma", d.Gamma), Gamma: ReadFloat (disp, "gamma", d.Gamma),
ShowFps: ReadBool (disp, "showFps", d.ShowFps)); ShowFps: ReadBool (disp, "showFps", d.ShowFps),
Quality: ReadQuality (disp, "quality", d.Quality));
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -327,6 +329,7 @@ public sealed class SettingsStore
["fieldOfView"] = d.FieldOfView, ["fieldOfView"] = d.FieldOfView,
["fullscreen"] = d.Fullscreen, ["fullscreen"] = d.Fullscreen,
["gamma"] = d.Gamma, ["gamma"] = d.Gamma,
["quality"] = d.Quality.ToString(),
["resolution"] = d.Resolution, ["resolution"] = d.Resolution,
["showFps"] = d.ShowFps, ["showFps"] = d.ShowFps,
["vsync"] = d.VSync, ["vsync"] = d.VSync,
@ -405,4 +408,12 @@ public sealed class SettingsStore
private static float ReadFloat(JsonElement obj, string name, float fallback) private static float ReadFloat(JsonElement obj, string name, float fallback)
=> obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number
? el.GetSingle() : fallback; ? el.GetSingle() : fallback;
private static QualityPreset ReadQuality(JsonElement obj, string name, QualityPreset fallback)
{
if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String)
return fallback;
var s = el.GetString();
return Enum.TryParse<QualityPreset>(s, ignoreCase: true, out var v) ? v : fallback;
}
} }

View file

@ -0,0 +1,67 @@
namespace AcDream.UI.Abstractions.Settings;
/// <summary>
/// A.5 T22.5: single user-facing quality knob that drives streaming radii,
/// MSAA samples, anisotropic level, alpha-to-coverage, and max completions
/// per frame in a single setting. Individual fields can still be overridden
/// by env vars (see <see cref="QualitySettings.WithEnvOverrides"/>).
/// </summary>
public enum QualityPreset { Low, Medium, High, Ultra }
/// <summary>
/// Resolved per-preset quality parameters. Constructed via
/// <see cref="From(QualityPreset)"/> then optionally overridden with
/// <see cref="WithEnvOverrides(QualitySettings)"/> before applying to the
/// renderer and streaming controller.
/// </summary>
public readonly record struct QualitySettings(
int NearRadius,
int FarRadius,
int MsaaSamples, // 0 = off, 2, 4, 8
int AnisotropicLevel, // 1 = off, 4, 8, 16
bool AlphaToCoverage,
int MaxCompletionsPerFrame)
{
/// <summary>
/// Return the default <see cref="QualitySettings"/> for <paramref name="preset"/>.
/// Unknown enum values fall back to <see cref="QualityPreset.High"/>.
/// </summary>
public static QualitySettings From(QualityPreset preset) => preset switch
{
QualityPreset.Low => new(NearRadius: 2, FarRadius: 5, MsaaSamples: 0, AnisotropicLevel: 4, AlphaToCoverage: false, MaxCompletionsPerFrame: 2),
QualityPreset.Medium => new(NearRadius: 3, FarRadius: 8, MsaaSamples: 2, AnisotropicLevel: 8, AlphaToCoverage: false, MaxCompletionsPerFrame: 3),
QualityPreset.High => new(NearRadius: 4, FarRadius: 12, MsaaSamples: 4, AnisotropicLevel: 16, AlphaToCoverage: true, MaxCompletionsPerFrame: 4),
QualityPreset.Ultra => new(NearRadius: 5, FarRadius: 15, MsaaSamples: 4, AnisotropicLevel: 16, AlphaToCoverage: true, MaxCompletionsPerFrame: 6),
_ => From(QualityPreset.High),
};
/// <summary>
/// Apply env-var overrides to a preset's resolved settings. Per-field
/// env vars beat the preset (so devs can spot-test a single dimension).
/// Unset or empty env vars leave the preset default unchanged.
/// </summary>
public static QualitySettings WithEnvOverrides(QualitySettings baseSettings)
{
int nearRadius = TryParseEnvInt("ACDREAM_NEAR_RADIUS", baseSettings.NearRadius);
int farRadius = TryParseEnvInt("ACDREAM_FAR_RADIUS", baseSettings.FarRadius);
int msaa = TryParseEnvInt("ACDREAM_MSAA_SAMPLES", baseSettings.MsaaSamples);
int aniso = TryParseEnvInt("ACDREAM_ANISOTROPIC", baseSettings.AnisotropicLevel);
// Bool override: any non-empty value other than "0"/"false" enables A2C.
// Empty / unset → keep preset default.
var a2cEnv = System.Environment.GetEnvironmentVariable("ACDREAM_A2C");
bool a2c = a2cEnv switch
{
null or "" => baseSettings.AlphaToCoverage,
"0" or "false" or "False" or "FALSE" => false,
_ => true,
};
int completions = TryParseEnvInt("ACDREAM_MAX_COMPLETIONS_PER_FRAME", baseSettings.MaxCompletionsPerFrame);
return new QualitySettings(nearRadius, farRadius, msaa, aniso, a2c, completions);
}
private static int TryParseEnvInt(string name, int defaultValue)
{
var s = System.Environment.GetEnvironmentVariable(name);
return s is not null && int.TryParse(s, out var v) ? v : defaultValue;
}
}

View file

@ -0,0 +1,181 @@
using AcDream.UI.Abstractions.Settings;
using Xunit;
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
/// <summary>
/// A.5 T22.5: <see cref="QualitySettings"/> preset table + env-var override
/// coverage. Env-var tests clear their variables in <c>finally</c> blocks so
/// parallel runners cannot bleed state between tests.
/// </summary>
public class QualityPresetTests
{
[Theory]
[InlineData(QualityPreset.Low, 2, 5, 0)]
[InlineData(QualityPreset.Medium, 3, 8, 2)]
[InlineData(QualityPreset.High, 4, 12, 4)]
[InlineData(QualityPreset.Ultra, 5, 15, 4)]
public void From_Preset_ProducesExpectedRadiiAndMsaa(
QualityPreset preset, int n1, int n2, int msaa)
{
var s = QualitySettings.From(preset);
Assert.Equal(n1, s.NearRadius);
Assert.Equal(n2, s.FarRadius);
Assert.Equal(msaa, s.MsaaSamples);
}
[Theory]
[InlineData(QualityPreset.Low, 4, false)]
[InlineData(QualityPreset.Medium, 8, false)]
[InlineData(QualityPreset.High, 16, true)]
[InlineData(QualityPreset.Ultra, 16, true)]
public void From_Preset_ProducesExpectedAnisoAndA2C(
QualityPreset preset, int aniso, bool a2c)
{
var s = QualitySettings.From(preset);
Assert.Equal(aniso, s.AnisotropicLevel);
Assert.Equal(a2c, s.AlphaToCoverage);
}
[Theory]
[InlineData(QualityPreset.Low, 2)]
[InlineData(QualityPreset.Medium, 3)]
[InlineData(QualityPreset.High, 4)]
[InlineData(QualityPreset.Ultra, 6)]
public void From_Preset_ProducesExpectedMaxCompletions(
QualityPreset preset, int expected)
{
var s = QualitySettings.From(preset);
Assert.Equal(expected, s.MaxCompletionsPerFrame);
}
[Fact]
public void EnvVar_NearRadius_OverridesPreset()
{
System.Environment.SetEnvironmentVariable("ACDREAM_NEAR_RADIUS", "2");
try
{
var s = QualitySettings.From(QualityPreset.High); // High = NearRadius=4 normally
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.Equal(2, resolved.NearRadius);
Assert.Equal(12, resolved.FarRadius); // FarRadius unaffected
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_NEAR_RADIUS", null); }
}
[Fact]
public void EnvVar_FarRadius_OverridesPreset()
{
System.Environment.SetEnvironmentVariable("ACDREAM_FAR_RADIUS", "20");
try
{
var s = QualitySettings.From(QualityPreset.High);
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.Equal(4, resolved.NearRadius); // NearRadius unaffected
Assert.Equal(20, resolved.FarRadius);
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_FAR_RADIUS", null); }
}
[Fact]
public void EnvVar_AlphaToCoverage_BooleanParsing()
{
// Ensure "0" and "false" disable; other values enable.
System.Environment.SetEnvironmentVariable("ACDREAM_A2C", "0");
try
{
var s = QualitySettings.From(QualityPreset.High); // High has A2C=true
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.False(resolved.AlphaToCoverage);
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null); }
}
[Fact]
public void EnvVar_AlphaToCoverage_FalseString_Disables()
{
System.Environment.SetEnvironmentVariable("ACDREAM_A2C", "false");
try
{
var s = QualitySettings.From(QualityPreset.High);
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.False(resolved.AlphaToCoverage);
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null); }
}
[Fact]
public void EnvVar_AlphaToCoverage_NonZeroEnables()
{
System.Environment.SetEnvironmentVariable("ACDREAM_A2C", "1");
try
{
var s = QualitySettings.From(QualityPreset.Low); // Low has A2C=false
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.True(resolved.AlphaToCoverage);
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null); }
}
[Fact]
public void EnvVar_Unset_LeavesPresetDefault()
{
// Ensure no env vars are set for this test's fields.
System.Environment.SetEnvironmentVariable("ACDREAM_NEAR_RADIUS", null);
System.Environment.SetEnvironmentVariable("ACDREAM_FAR_RADIUS", null);
System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null);
var s = QualitySettings.From(QualityPreset.High);
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.Equal(s, resolved);
}
[Fact]
public void From_UndefinedPreset_FallsBackToHigh()
{
var s = QualitySettings.From((QualityPreset)99);
Assert.Equal(4, s.NearRadius); // High default
Assert.Equal(12, s.FarRadius);
Assert.Equal(4, s.MsaaSamples);
Assert.True(s.AlphaToCoverage);
}
[Fact]
public void EnvVar_MaxCompletionsPerFrame_OverridesPreset()
{
System.Environment.SetEnvironmentVariable("ACDREAM_MAX_COMPLETIONS_PER_FRAME", "8");
try
{
var s = QualitySettings.From(QualityPreset.High); // High = 4
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.Equal(8, resolved.MaxCompletionsPerFrame);
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_MAX_COMPLETIONS_PER_FRAME", null); }
}
[Fact]
public void EnvVar_MsaaSamples_OverridesPreset()
{
System.Environment.SetEnvironmentVariable("ACDREAM_MSAA_SAMPLES", "8");
try
{
var s = QualitySettings.From(QualityPreset.High); // High = 4
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.Equal(8, resolved.MsaaSamples);
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_MSAA_SAMPLES", null); }
}
[Fact]
public void EnvVar_Anisotropic_OverridesPreset()
{
System.Environment.SetEnvironmentVariable("ACDREAM_ANISOTROPIC", "4");
try
{
var s = QualitySettings.From(QualityPreset.High); // High = 16
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.Equal(4, resolved.AnisotropicLevel);
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_ANISOTROPIC", null); }
}
}

View file

@ -44,7 +44,8 @@ public sealed class SettingsStoreTests : System.IDisposable
VSync: false, VSync: false,
FieldOfView: 100f, FieldOfView: 100f,
Gamma: 1.4f, Gamma: 1.4f,
ShowFps: true); ShowFps: true,
Quality: AcDream.UI.Abstractions.Settings.QualityPreset.Ultra);
store.SaveDisplay(original); store.SaveDisplay(original);
var loaded = store.LoadDisplay(); var loaded = store.LoadDisplay();