acdream/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs
Erik 472525b99e fix(ui): OP6 rework — six range captions, un-invert Sound enabled flags, five font faces
Fixes all three MUST-FIX findings from the OP6 REJECT review
(docs/research/2026-08-11-op6-review.md) plus its SHOULD-FIXes and NOTEs.

M1 — the "retail ships zero range captions" claim was a Binary Ninja
constant-folding artifact (the same class the header-string globals a few
lines above already worked around). The six SetSliderLabel call sites
byte-decode to reads of runtime-filled ID_Graphics_Value_* globals, not
immediate zeros (PE-byte-verified against the PDB-paired acclient.exe,
independently re-derived in this session, not just re-asserted from the
review). ConfigOptionsPageController.BuildSliderRow gained optional
rangeLowKey/rangeHighKey parameters wired for all six idx6 sliders (Camera
Stiffness Soft/Hard, Adjustment Speed Slow/Fast, FOV Narrow/Wide, Screen
Brightness Dark/Bright, Graphics Performance Speed/Detail, Degrade Distance
Close/Far) via the same SetRangeLabel mechanism OP5's Chat opacity sliders
already established. Mouse Look Sensitivity (idx3) correctly stays
uncaptioned — the one genuine SetSliderLabel omission. Class doc corrected;
gate-script lines 535/653-equivalent corrected in place.

M2 — the three Sound "Disabled" toggles were semantically inverted:
SoundManager::effect_sounds_enabled/ambient_sounds_enabled/
interface_sounds_enabled are all compiled = 1 in .data, and
UserPreferences::RegisterPreference binds the checkbox's boolean value
DIRECTLY onto those enabled-sense statics — checked-by-default means
enabled-by-default, not disabled. AudioSettings.SfxDisabled/AmbientDisabled/
InterfaceDisabled renamed to SfxEnabled/AmbientEnabled/InterfaceEnabled
(fresh JSON keys — the rejected slice's keys never shipped in an accepted
build); RuntimeSettingsStartupTargets.ApplyAudio now computes effective
volume through the extracted, independently-unit-tested pure function
ComputeEffectiveCategoryVolumes (enabled ? slider : 0f). This closes the
blast radius the review flagged: a missing key in an EXISTING settings.json
now falls back to AudioSettings.Default, which is enabled=true, so a fresh
launch is audible, not muted. AP-199's wording and gate-script step 6
corrected; the enshrined-inversion test rewritten to assert the correct
default and a new SettingsStore test pins the legacy-file fallback path.

M3 — UI_ChatFontFace now ships all five of retail's authored choices
(Arial, CourierNew, PalatinoLinotype, Tahoma, TimesNewRoman — a fixed
compile-time array at gmClient::InitUIPreferences, PE-byte-verified
present verbatim in .rdata, not a per-machine runtime enumeration as the
rejected slice's comment claimed). Default index 2 (PalatinoLinotype) now
indexes a real entry.

S1 — Bind() now emits the sixth trailing AddSeperator retail's own
InitOptions ends with (0x0049E80D), matching retail's 39-item ListBox (6
headers + 6 separators + 27 option-widget-rows) instead of 38.

S2 — Screen Brightness gets its own DisplaySettings.ScreenBrightness field
([-1,1], default 0) instead of overloading Gamma, which has a different
unit system (default 1.0, legacy [0.5,2.0] slider) and its own live
Settings-panel consumer.

S3 — UiScrollbar and UiMenu gained a settable TooltipText surfaced through
GetTooltipText (UiButton's existing pattern). Every slider and menu row's
own interactive widget (not just toggle/trio rows) now carries retail's
"<label>_Help" tooltip, verified as a universal suffix convention across
every AttachPreference site touched by this tab.

S4 — "800x600" added to DisplaySettings.AvailableResolutions: a genuine
retail display mode (Device::ForceDisplayResolution(1,0x320,0x258) at
startup) and the Config tab's own byte-verified Resolution row default, not
an invented preset. Defaults now lands on a highlighted, re-selectable
dropdown entry instead of an orphaned value.

S5 — four new/extended tests: ComputeEffectiveCategoryVolumes gets a
dedicated pure-function value assertion (Theory + a default-profile-is-
audible Fact) in RuntimeSettingsControllerTests, closing the "only event
order was asserted" gap that let M2 ship; a label/choice-key conformance
table in ConfigOptionsPageControllerTests enumerates every key this tab
queries (traced directly from the fixed code paths, not guessed) and fails
on an invented OR a dropped key; a per-row DefaultValue pin asserts every
row's default against the retail literal directly, independent of the
underlying settings-record defaults; and the S1 separator fix gets its own
39-item stacked-ListBox count pin.

NOTEs — AP-198's row count was always ten (its own enumeration never said
nine); the commit-message inconsistency N1 flagged is reconciled in both
the row and the section-summary line, and its Screen Brightness sub-clause
now matches S2. N2: Bind() now reads the scrollbar id from
UiTemplateListBox.ScrollbarElementId (dat property 0x72) instead of a
hardcoded constant. N3 (batch Defaults writes) and N4 (AfterApply on
Config-tab entry, needs no action) are left as recorded — out of this
rework's scope per the review's own disposition.

Full Release suite: 13,125 passed / 4 skipped / 0 failed (baseline
13,117/4/0 — net +8 tests added, 0 regressions, 0 removed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 08:06:20 +02:00

1558 lines
56 KiB
C#

using AcDream.App.Diagnostics;
using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.App.Settings;
using AcDream.Core.Net.Messages;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Settings;
using Silk.NET.Input;
namespace AcDream.App.Tests.Settings;
public sealed class RuntimeSettingsControllerTests
{
[Fact]
public void ConstructionLoadsEachBagOnceAndPublishesOneStartupSnapshot()
{
var storage = new FakeStorage
{
DisplayValue = DisplaySettings.Default with
{
VSync = false,
Quality = QualityPreset.Ultra,
},
};
var resolved = new QualitySettings(7, 18, 8, 16, true, 9);
int resolveCount = 0;
var controller = new RuntimeSettingsController(
storage,
preset =>
{
resolveCount++;
Assert.Equal(QualityPreset.Ultra, preset);
return resolved;
},
static _ => { });
Assert.Equal(1, storage.DisplayLoads);
Assert.Equal(1, storage.AudioLoads);
Assert.Equal(1, storage.GameplayLoads);
Assert.Equal(1, storage.ChatLoads);
Assert.Equal(1, storage.CharacterLoads);
Assert.Equal(1, resolveCount);
Assert.Equal("default", storage.LastLoadedCharacter);
Assert.Same(storage.DisplayValue, controller.Startup.Display);
Assert.Same(storage.AudioValue, controller.Startup.Audio);
Assert.Same(storage.GameplayValue, controller.Startup.Gameplay);
Assert.Same(storage.ChatValue, controller.Startup.Chat);
Assert.Same(storage.DefaultCharacterValue, controller.Startup.Character);
Assert.Equal(resolved, controller.Startup.Quality);
Assert.Equal(resolved, controller.ResolvedQuality);
Assert.Equal("default", controller.ActiveToonKey);
}
[Fact]
public void StartupApplyIsOrderedExactlyOnceAndRuntimeBindingDoesNotReplay()
{
var events = new List<string>();
var controller = CreateController(events: events);
var startup = new FakeStartupTarget(events);
controller.ApplyStartup(startup);
Assert.Equal(["startup-display", "startup-audio"], events);
Assert.Throws<InvalidOperationException>(() => controller.ApplyStartup(startup));
events.Clear();
var runtime = new FakeRuntimeTargets(events);
controller.BindRuntimeTargets(runtime);
Assert.Empty(events);
Assert.Throws<InvalidOperationException>(() =>
controller.BindRuntimeTargets(new FakeRuntimeTargets(events)));
}
[Fact]
public void ViewModelBindingReleasesOnlyItsExpectedInstance()
{
var controller = CreateController();
using InputDispatcher dispatcher = CreateDispatcher();
RuntimeSettingsViewModelBinding first = controller.CreateViewModelBinding(
new KeyBindings(),
dispatcher,
static _ => { });
first.Dispose();
RuntimeSettingsViewModelBinding second = controller.CreateViewModelBinding(
new KeyBindings(),
dispatcher,
static _ => { });
first.Dispose();
Assert.Throws<InvalidOperationException>(() =>
controller.CreateViewModelBinding(
new KeyBindings(),
dispatcher,
static _ => { }));
second.Dispose();
}
[Fact]
public void StartupRetryResumesAfterLastSuccessfulStage()
{
var displayEvents = new List<string>();
var displayController = CreateController();
var displayTarget = new FakeStartupTarget(displayEvents)
{
RemainingDisplayFailures = 1,
};
Assert.Throws<InvalidOperationException>(() =>
displayController.ApplyStartup(displayTarget));
displayController.ApplyStartup(displayTarget);
Assert.Equal(
["startup-display", "startup-display", "startup-audio"],
displayEvents);
var audioEvents = new List<string>();
var audioController = CreateController();
var audioTarget = new FakeStartupTarget(audioEvents)
{
RemainingAudioFailures = 1,
};
Assert.Throws<InvalidOperationException>(() =>
audioController.ApplyStartup(audioTarget));
audioController.ApplyStartup(audioTarget);
Assert.Equal(
["startup-display", "startup-audio", "startup-audio"],
audioEvents);
}
[Fact]
public void ConcreteStartupTargetAppliesPacingThenWindowThenPersistedFov()
{
using var profiler = new FrameProfiler();
using var pacing = new DisplayFramePacingController(
uncappedRendering: false,
profiler,
new FramePacingController(new FakeClock(), new NullWaiter()));
var surface = new FakePacingSurface
{
VSync = true,
ActiveMonitorRefreshHz = 144,
};
pacing.InitializeStartup(requestedVSync: true);
pacing.BindSurface(surface);
var cameras = new CameraController(new OrbitCamera(), new FlyCamera());
float originalFov = cameras.Orbit.FovY;
var displayWindow = new InspectingDisplayWindowTarget(display =>
{
Assert.False(pacing.RequestedVSync);
Assert.Equal(originalFov, cameras.Orbit.FovY);
Assert.Equal("1600x900", display.Resolution);
});
var target = new RuntimeSettingsStartupTargets(
displayWindow,
pacing,
cameras,
audio: null);
target.ApplyDisplay(DisplaySettings.Default with
{
Resolution = "1600x900",
VSync = false,
FieldOfView = 83f,
});
Assert.Equal(1, displayWindow.ApplyCount);
Assert.Equal(1, surface.RefreshReadCount);
Assert.Equal(new FramePacingPolicy(false, 144d), pacing.Policy);
Assert.Equal(83f * (MathF.PI / 180f), cameras.Orbit.FovY, precision: 5);
Assert.Equal(83f * (MathF.PI / 180f), cameras.Fly.FovY, precision: 5);
}
[Fact]
public void ConcreteRuntimeTargetAppliesEveryQualityDimensionInOrder()
{
var events = new List<string>();
var qualityTarget = new RecordingQualityApplicationTarget(events);
var displayTarget = new InspectingDisplayWindowTarget(
_ => events.Add("display"));
var uiTarget = new RecordingUiLockTarget(events);
var target = new RuntimeSettingsTargets(
displayTarget,
qualityTarget,
uiTarget,
NullCommandBus.Instance,
static _ => { });
var quality = new QualitySettings(6, 17, 4, 12, true, 7);
target.ApplyQuality(quality);
Assert.Equal(
[
"a2c:True",
"aniso:12",
"range:6:17",
"stream:6:17",
"budget:7",
],
events);
Assert.Equal(quality, qualityTarget.Observed);
}
[Fact]
public void ConcreteRuntimeQualityTargetStopsAtTheThrowingStep()
{
string[] allSteps =
[
"a2c",
"aniso",
"range",
"stream",
"budget",
];
for (int failureIndex = 0; failureIndex < allSteps.Length; failureIndex++)
{
var events = new List<string>();
var target = new RuntimeSettingsTargets(
new InspectingDisplayWindowTarget(static _ => { }),
new FailingQualityApplicationTarget(events, failureIndex),
new RecordingUiLockTarget(events),
NullCommandBus.Instance,
static _ => { });
Assert.Throws<InvalidOperationException>(() =>
target.ApplyQuality(QualitySettings.From(QualityPreset.High)));
Assert.Equal(allSteps[..(failureIndex + 1)], events);
}
}
[Fact]
public void ConcreteRuntimeTargetPublishesSetSingleCharacterOptionOntoTheBus()
{
// CH3 review S5(b): SaveChatPublishesSetSingleCharacterOption... above
// only proves RuntimeSettingsController calls the IRuntimeSettingsTargets
// INTERFACE (via the FakeRuntimeTargets test double) — nothing exercised
// the CONCRETE RuntimeSettingsTargets.SetSingleCharacterOption, which is
// the code that actually reaches the wire via ICommandBus.Publish. A
// silent unwiring there (wrong record, wrong bus, dropped call) would
// pass every test that only goes through the fake.
var bus = new CaptureCommandBus();
var target = new RuntimeSettingsTargets(
new InspectingDisplayWindowTarget(static _ => { }),
new RecordingQualityApplicationTarget([]),
new RecordingUiLockTarget([]),
bus,
static _ => { });
target.SetSingleCharacterOption(
(uint)CharacterOptionId.ListenToRoleplayChat, value: false);
var cmd = Assert.IsType<SetSingleCharacterOptionRuntimeCmd>(
Assert.Single(bus.Published));
Assert.Equal((uint)CharacterOptionId.ListenToRoleplayChat, cmd.OptionId);
Assert.False(cmd.Value);
}
[Fact]
public void SettingsViewModelSavePreservesSectionAndTargetOrder()
{
var events = new List<string>();
var storage = new FakeStorage(events);
var resolved = new QualitySettings(5, 15, 4, 16, true, 6);
var controller = new RuntimeSettingsController(
storage,
_ => resolved,
static _ => { });
storage.ClearEvents();
var targets = new FakeRuntimeTargets(events);
controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
_ => events.Add("save-bindings"));
viewModel.SetDisplay(viewModel.DisplayDraft with
{
Resolution = "1920x1080",
Quality = QualityPreset.Ultra,
});
viewModel.SetAudio(viewModel.AudioDraft with { Master = 0.25f });
viewModel.SetGameplay(viewModel.GameplayDraft with { LockUI = true });
viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = true });
viewModel.SetCharacter(viewModel.CharacterDraft with { AutoAttack = true });
viewModel.Save();
Assert.Equal(
[
"save-bindings",
"save-display",
"target-display",
"target-quality",
"save-audio",
// Campaign OP slice OP6 (2026-08-11): SaveAudio now pushes the
// saved snapshot into the live engine, same shape as
// save-display's own target-display push a few lines up.
"target-audio",
"save-gameplay",
"target-ui-lock:True",
"save-chat",
"target-chat-opacity:1:1",
"save-character:default",
],
events);
Assert.Equal("1920x1080", controller.Display.Resolution);
Assert.Equal(0.25f, controller.Audio.Master);
Assert.True(controller.Gameplay.LockUI);
Assert.True(controller.Chat.ShowTimestamps);
Assert.True(controller.Character.AutoAttack);
Assert.Equal(resolved, controller.ResolvedQuality);
}
// OP6 rework (2026-08-11, review S5 / M2): pins the EFFECTIVE volume
// ApplyAudio actually computes, not just that some target was called.
// The FakeRuntimeTargets-based "target-audio" assertion above (and its
// predecessor before this rework) only ever checked that ApplyAudio
// fired, never what it fired WITH — the exact gap that let M2's
// Enabled/Disabled inversion mute every default profile unnoticed.
[Theory]
[InlineData(true, true, 0.6f, 0.9f, 0.6f, 0.9f)] // enabled: slider value passes through
[InlineData(false, false, 0.6f, 0.9f, 0f, 0f)] // disabled: forced to zero regardless of slider
[InlineData(true, false, 1.0f, 1.0f, 1.0f, 0f)] // independent per-category gating
public void ComputeEffectiveCategoryVolumes_GatesSliderValueOnEnabledFlag(
bool sfxEnabled, bool ambientEnabled,
float sfxSlider, float ambientSlider,
float expectedSfx, float expectedAmbient)
{
AudioSettings audio = AudioSettings.Default with
{
SfxEnabled = sfxEnabled,
AmbientEnabled = ambientEnabled,
Sfx = sfxSlider,
Ambient = ambientSlider,
};
(float sfx, float ambient) = RuntimeSettingsStartupTargets.ComputeEffectiveCategoryVolumes(audio);
Assert.Equal(expectedSfx, sfx);
Assert.Equal(expectedAmbient, ambient);
}
[Fact]
public void ComputeEffectiveCategoryVolumes_DefaultProfile_IsAudible_NotMuted()
{
// The exact regression M2 shipped: a fresh/default AudioSettings
// must leave sound effects and ambient audio AUDIBLE on the next
// launch, matching retail's own byte-verified enabled-by-default
// statics (SoundManager::effect_sounds_enabled/
// ambient_sounds_enabled = 1) — see AudioSettings' class doc.
(float sfx, float ambient) =
RuntimeSettingsStartupTargets.ComputeEffectiveCategoryVolumes(AudioSettings.Default);
Assert.Equal(1.0f, sfx);
Assert.Equal(1.0f, ambient);
}
[Fact]
public void SaveChatPublishesSetSingleCharacterOptionOnlyForChangedBits()
{
// CH3 (2026-08-09): SaveChat must publish SetSingleCharacterOption
// (0x0005) for exactly the Hear*Chat bits that actually changed —
// touching one checkbox must not resend the other four.
var storage = new FakeStorage();
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
static _ => { });
var targets = new FakeRuntimeTargets([]);
controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
// N4 (CH3 Opus review): ChatSettings.Default now matches ACE's real
// CharacterOptions2.Default — Roleplay/Society start FALSE (only
// General/Trade/LFG start true). Flip Roleplay ON first so the
// second edit below can flip it back off.
Assert.False(viewModel.ChatDraft.HearRoleplayChat);
viewModel.SetChat(viewModel.ChatDraft with { HearRoleplayChat = true });
viewModel.Save();
Assert.Equal(
[((uint)CharacterOptionId.ListenToRoleplayChat, true)],
targets.SingleOptionCalls);
targets.SingleOptionCalls.Clear();
viewModel.SetChat(viewModel.ChatDraft with
{
HearRoleplayChat = false,
HearSocietyChat = true,
});
viewModel.Save();
Assert.Equal(
[
((uint)CharacterOptionId.ListenToRoleplayChat, false),
((uint)CharacterOptionId.ListenToSocietyChat, true),
],
targets.SingleOptionCalls);
}
[Fact]
public void SaveChatWithNoHearOptionChangePublishesNothing()
{
var controller = CreateController();
var targets = new FakeRuntimeTargets([]);
controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
// Touch a Chat field that is NOT a Hear*Chat membership bit.
viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = false });
viewModel.Save();
Assert.Empty(targets.SingleOptionCalls);
}
[Fact]
public void SaveChat_PushesOpacityToRuntimeTargets_LiveApply_NoRestart()
{
// Campaign CH slice CH6c: unlike the Hear* options (local-only, no
// wire), opacity is ALWAYS pushed on Save (not diffed) so the linking
// invariant self-heals; the point of this test is that clicking Save
// is enough — no restart, no separate "apply" step.
var controller = CreateController();
var targets = new FakeRuntimeTargets([]);
controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
viewModel.SetChat(viewModel.ChatDraft with
{
DefaultOpacity = 0.3f,
ActiveOpacity = 0.6f,
});
viewModel.Save();
Assert.Equal([(0.3f, 0.6f)], targets.ChatOpacityCalls);
Assert.Equal(0.3f, controller.Chat.DefaultOpacity);
Assert.Equal(0.6f, controller.Chat.ActiveOpacity);
}
[Fact]
public void ConcreteRuntimeTargetForwardsChatOpacityToTheLiveController()
{
// Mirrors ConcreteRuntimeTargetPublishesSetSingleCharacterOptionOntoTheBus
// below: proves the CONCRETE RuntimeSettingsTargets.SetChatOpacity wiring,
// not just the IRuntimeSettingsTargets interface via the fake.
var recording = new RecordingChatOpacityTarget();
var target = new RuntimeSettingsTargets(
new InspectingDisplayWindowTarget(static _ => { }),
new RecordingQualityApplicationTarget([]),
new RecordingUiLockTarget([]),
NullCommandBus.Instance,
log: static _ => { },
chatOpacity: recording);
target.SetChatOpacity(0.25f, 0.75f);
Assert.Equal((0.25f, 0.75f), Assert.Single(recording.Calls));
}
private sealed class RecordingChatOpacityTarget : IRuntimeChatOpacityTarget
{
public List<(float DefaultOpacity, float ActiveOpacity)> Calls { get; } = [];
public void Apply(float defaultOpacity, float activeOpacity) =>
Calls.Add((defaultOpacity, activeOpacity));
}
[Fact]
public void SyncChatFromServerOptionsReseedsPersistedAndDraft()
{
// Research doc §5.2: ACE's CharacterOptions2.Default omits
// HearRoleplayChat/HearSocietyChat. N4 (CH3 Opus review) aligned
// ChatSettings.Default to that same stance, so this test now seeds
// storage with an explicitly stale PERSISTED value (both on — e.g.
// a save from before N4, or a user who had enabled them) to prove
// the server sync corrects local state to the server's truth,
// rather than merely observing the two already agree.
var storage = new FakeStorage
{
ChatValue = ChatSettings.Default with
{
HearRoleplayChat = true,
HearSocietyChat = true,
},
};
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
static _ => { });
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
Assert.True(controller.Chat.HearRoleplayChat);
Assert.True(viewModel.ChatDraft.HearRoleplayChat);
controller.SyncChatFromServerOptions(0x00948700u); // ACE's real default
Assert.True(controller.Chat.HearGeneralChat);
Assert.True(controller.Chat.HearTradeChat);
Assert.True(controller.Chat.HearLFGChat);
Assert.False(controller.Chat.HearRoleplayChat);
Assert.False(controller.Chat.HearSocietyChat);
Assert.False(viewModel.ChatDraft.HearRoleplayChat);
Assert.False(viewModel.ChatDraft.HearSocietyChat);
Assert.Same(controller.Chat, storage.ChatValue);
}
[Fact]
public void SyncChatFromServerOptionsPreservesUnsavedUnrelatedDraftEdits()
{
var controller = CreateController();
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
viewModel.SetChat(viewModel.ChatDraft with { FontSize = 18f });
controller.SyncChatFromServerOptions(0x00948700u);
Assert.Equal(18f, viewModel.ChatDraft.FontSize);
Assert.False(viewModel.ChatDraft.HearRoleplayChat);
}
[Fact]
public void SyncChatFromServerOptionsIsANoOpWhenUnchanged()
{
var storage = new FakeStorage();
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
static _ => { });
storage.ClearEvents();
// N4 (CH3 Opus review): ChatSettings.Default now matches ACE's real
// CharacterOptions2.Default exactly — General/Trade/LFG on,
// Roleplay/Society off. Syncing with that SAME bit pattern (any
// other bits are irrelevant, the sync only masks these five) must
// be a true no-op.
const uint aceDefaultHearBits =
(uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat
| (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat
| (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat;
controller.SyncChatFromServerOptions(aceDefaultHearBits);
Assert.Equal(0, storage.ChatSaves);
}
[Fact]
public void DraftPreviewAndExternalCommandsShareCanonicalState()
{
var events = new List<string>();
var storage = new FakeStorage(events);
var controller = CreateController(storage, events: events);
storage.ClearEvents();
var targets = new FakeRuntimeTargets(events);
controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
viewModel.SetDisplay(viewModel.DisplayDraft with
{
FieldOfView = 91f,
ParticleRange = ParticleRange.Retail,
});
viewModel.SetAudio(viewModel.AudioDraft with { Sfx = 0.33f });
viewModel.SetGameplay(viewModel.GameplayDraft with { ShowTooltips = false });
Assert.True(controller.HasDraftPreview);
Assert.Equal(91f, controller.DisplayPreview.FieldOfView);
Assert.Equal(ParticleRange.Retail, controller.DisplayPreview.ParticleRange);
Assert.Equal(0.33f, controller.AudioPreview.Sfx);
controller.ToggleFrameRate();
controller.SetUiLocked(true);
controller.SetAcceptLootPermits(true);
Assert.True(controller.Display.ShowFps);
Assert.True(controller.DisplayPreview.ShowFps);
Assert.Equal(91f, controller.DisplayPreview.FieldOfView);
Assert.True(controller.Gameplay.LockUI);
Assert.True(controller.Gameplay.AcceptLootPermits);
Assert.False(viewModel.GameplayDraft.ShowTooltips);
Assert.True(viewModel.GameplayDraft.LockUI);
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
Assert.Contains("target-ui-lock:True", events);
Assert.Equal(2, storage.GameplaySaves);
Assert.Equal(1, storage.DisplaySaves);
}
[Fact]
public void DraftCancelRestoresPreviewAndCombatTogglePreservesUnrelatedDrafts()
{
var storage = new FakeStorage();
var controller = CreateController(storage);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
viewModel.SetDisplay(viewModel.DisplayDraft with { FieldOfView = 99f });
viewModel.SetGameplay(viewModel.GameplayDraft with
{
ShowTooltips = false,
CoordinatesOnRadar = false,
});
controller.SetUiLocked(true);
controller.SetAcceptLootPermits(true);
Assert.False(viewModel.GameplayDraft.ShowTooltips);
Assert.False(viewModel.GameplayDraft.CoordinatesOnRadar);
Assert.True(viewModel.GameplayDraft.LockUI);
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
Assert.Equal(99f, controller.DisplayPreview.FieldOfView);
viewModel.Cancel();
Assert.Equal(controller.Display.FieldOfView, controller.DisplayPreview.FieldOfView);
Assert.Equal(controller.Gameplay.ShowTooltips, viewModel.GameplayDraft.ShowTooltips);
Assert.Equal(
controller.Gameplay.CoordinatesOnRadar,
viewModel.GameplayDraft.CoordinatesOnRadar);
Assert.True(viewModel.GameplayDraft.LockUI);
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
viewModel.SetGameplay(viewModel.GameplayDraft with { ShowHelm = false });
viewModel.Save();
Assert.True(storage.GameplayValue.LockUI);
Assert.True(storage.GameplayValue.AcceptLootPermits);
Assert.False(storage.GameplayValue.ShowHelm);
}
[Fact]
public void FailedExternalGameplayPersistenceDoesNotPromoteViewModelBaseline()
{
var storage = new FakeStorage { ThrowOnGameplaySave = true };
var logs = new List<string>();
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
logs.Add);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
controller.SetUiLocked(true);
Assert.Throws<IOException>(() => controller.SetAcceptLootPermits(true));
Assert.True(viewModel.GameplayDraft.LockUI);
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
viewModel.Cancel();
Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI);
Assert.Equal(
GameplaySettings.Default.AcceptLootPermits,
viewModel.GameplayDraft.AcceptLootPermits);
Assert.Contains(logs, line =>
line.Contains("radar lock save failed", StringComparison.Ordinal));
}
[Fact]
public void CharacterContextLoadsWithoutViewModelAndSynchronizesWhenBound()
{
var storage = new FakeStorage();
storage.Characters["Alice"] = CharacterSettings.Default with
{
DefaultChatChannel = "Trade",
};
storage.Characters["Bob"] = CharacterSettings.Default with
{
ConfirmSalvage = false,
};
var controller = CreateController(storage);
controller.SetActiveCharacter("Alice");
controller.LoadCharacterContext("Alice");
Assert.Equal("Alice", controller.ActiveToonKey);
Assert.Equal("Trade", controller.Character.DefaultChatChannel);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
controller.LoadCharacterContext("Bob");
Assert.Equal("Bob", controller.ActiveToonKey);
Assert.False(viewModel.CharacterDraft.ConfirmSalvage);
controller.RestoreDefaultCharacterContext();
controller.ResetActiveCharacterKey();
Assert.Equal("default", controller.ActiveToonKey);
Assert.Same(storage.DefaultCharacterValue, controller.Character);
Assert.Same(storage.DefaultCharacterValue, viewModel.CharacterDraft);
}
[Fact]
public void CharacterSaveUsesActiveToonAndDefaultSaveBecomesResetContext()
{
var storage = new FakeStorage();
var controller = CreateController(storage);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
var newDefault = CharacterSettings.Default with { AutoAttack = true };
viewModel.SetCharacter(newDefault);
viewModel.Save();
controller.LoadCharacterContext("Alice");
var alice = CharacterSettings.Default with { DefaultChatChannel = "Trade" };
viewModel.SetCharacter(alice);
viewModel.Save();
Assert.Same(newDefault, storage.Characters["default"]);
Assert.Same(alice, storage.Characters["Alice"]);
controller.RestoreDefaultCharacterContext();
Assert.Same(newDefault, controller.Character);
Assert.Same(newDefault, viewModel.CharacterDraft);
}
[Fact]
public void ViewModelAndRuntimeTargetLoansCanBeWithdrawnAndReboundPrecisely()
{
var events = new List<string>();
var controller = CreateController(events: events);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
SettingsVM foreign = CreateStandaloneViewModel(dispatcher);
controller.UnbindViewModel(foreign);
Assert.True(controller.HasDraftPreview);
Assert.Throws<InvalidOperationException>(() =>
controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { }));
controller.UnbindViewModel(viewModel);
Assert.False(controller.HasDraftPreview);
controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
var first = new FakeRuntimeTargets(events);
controller.BindRuntimeTargets(first);
controller.UnbindRuntimeTargets();
var second = new FakeRuntimeTargets(events);
controller.BindRuntimeTargets(second);
controller.SetUiLocked(true);
Assert.Equal(0, first.UiLockCalls);
Assert.Equal(1, second.UiLockCalls);
}
[Fact]
public void OwnedRuntimeTargetsReleaseExactlyAndAllowRebind()
{
var events = new List<string>();
RuntimeSettingsController controller = CreateController(events: events);
var first = new FakeRuntimeTargets(events);
var second = new FakeRuntimeTargets(events);
IDisposable firstBinding = controller.BindRuntimeTargetsOwned(first);
Assert.Throws<InvalidOperationException>(() =>
controller.BindRuntimeTargetsOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = controller.BindRuntimeTargetsOwned(second);
firstBinding.Dispose();
controller.SetUiLocked(true);
Assert.Equal(0, first.UiLockCalls);
Assert.Equal(1, second.UiLockCalls);
}
[Fact]
public void DisplayPersistenceFailureDoesNotPublishStateOrTargets()
{
var events = new List<string>();
var storage = new FakeStorage(events) { ThrowOnDisplaySave = true };
var logs = new List<string>();
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
logs.Add);
storage.ClearEvents();
controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
DisplaySettings original = controller.Display;
viewModel.SetDisplay(viewModel.DisplayDraft with
{
Resolution = "2560x1440",
Quality = QualityPreset.Ultra,
});
viewModel.Save();
Assert.Same(original, controller.Display);
Assert.DoesNotContain("target-display", events);
Assert.DoesNotContain("target-quality", events);
Assert.Contains(logs, line => line.Contains("display save failed", StringComparison.Ordinal));
}
[Fact]
public void DisplayTargetFailurePreservesEstablishedStoreThenPublishBoundary()
{
var events = new List<string>();
var storage = new FakeStorage(events);
var logs = new List<string>();
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
logs.Add);
storage.ClearEvents();
controller.BindRuntimeTargets(new FakeRuntimeTargets(events)
{
ThrowOnDisplay = true,
});
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
DisplaySettings original = controller.Display;
viewModel.SetDisplay(viewModel.DisplayDraft with
{
Resolution = "3840x2160",
Quality = QualityPreset.Ultra,
});
viewModel.Save();
Assert.Equal(1, storage.DisplaySaves);
Assert.Same(original, controller.Display);
Assert.DoesNotContain("target-quality", events);
Assert.Contains(logs, line => line.Contains("display save failed", StringComparison.Ordinal));
}
[Fact]
public void NonDisplayPersistenceFailuresContinueAndPreserveControllerState()
{
var storage = new FakeStorage
{
ThrowOnAudioSave = true,
ThrowOnGameplaySave = true,
ThrowOnChatSave = true,
ThrowOnCharacterSave = true,
};
var logs = new List<string>();
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
logs.Add);
AudioSettings originalAudio = controller.Audio;
GameplaySettings originalGameplay = controller.Gameplay;
ChatSettings originalChat = controller.Chat;
CharacterSettings originalCharacter = controller.Character;
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
viewModel.SetAudio(viewModel.AudioDraft with { Master = 0.1f });
viewModel.SetGameplay(viewModel.GameplayDraft with { LockUI = true });
viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = true });
viewModel.SetCharacter(viewModel.CharacterDraft with { AutoAttack = true });
viewModel.Save();
Assert.Same(originalAudio, controller.Audio);
Assert.Same(originalGameplay, controller.Gameplay);
Assert.Same(originalChat, controller.Chat);
Assert.Same(originalCharacter, controller.Character);
Assert.Contains(logs, line => line.Contains("audio save failed", StringComparison.Ordinal));
Assert.Contains(logs, line => line.Contains("gameplay save failed", StringComparison.Ordinal));
Assert.Contains(logs, line => line.Contains("chat save failed", StringComparison.Ordinal));
Assert.Contains(logs, line => line.Contains("character save failed", StringComparison.Ordinal));
}
[Fact]
public void MsaaChangeIsRestartRequiredWhileOtherQualityStateAdvances()
{
var logs = new List<string>();
var events = new List<string>();
var controller = new RuntimeSettingsController(
new FakeStorage(),
static preset => QualitySettings.From(preset),
logs.Add);
controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
controller.ReapplyQualityPreset(QualityPreset.Low);
Assert.Equal(QualitySettings.From(QualityPreset.Low), controller.ResolvedQuality);
Assert.Equal(["target-quality"], events);
Assert.Contains(logs, line =>
line.Contains("MSAA samples change (4 -> 0) requires a restart", StringComparison.Ordinal));
}
[Fact]
public void QualityTargetFailurePublishesResolvedQualityThenPropagates()
{
var events = new List<string>();
var controller = CreateController(events: events);
controller.BindRuntimeTargets(new FakeRuntimeTargets(events)
{
ThrowOnQuality = true,
});
QualitySettings requested = QualitySettings.From(QualityPreset.Ultra);
Assert.Throws<InvalidOperationException>(() =>
controller.ReapplyQualityPreset(QualityPreset.Ultra));
Assert.Equal(requested, controller.ResolvedQuality);
Assert.Equal(["target-quality"], events);
}
[Fact]
public void SetUiLocked_AppliesEvenWhenGameplayLockUIAlreadyMatches_IfNeverActuallyApplied()
{
// MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the
// guard used to compare the requested value against
// `Gameplay.LockUI` — valid only while `ToggleUiLock` derived
// `locked` AS `!Gameplay.LockUI`. OP4 re-pointed `ToggleUiLock` to
// read the SERVER bit (RuntimeCharacterOptionsState) instead, a
// DIFFERENT store that can already equal a value this controller
// never actually pushed to `_runtimeTargets`. A pre-existing save
// seeds Gameplay.LockUI = true; the runtime target has never seen
// `true` — the FIRST SetUiLocked(true) call must still apply.
var events = new List<string>();
var storage = new FakeStorage(events)
{
GameplayValue = GameplaySettings.Default with { LockUI = true },
};
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
static _ => { });
Assert.True(controller.Gameplay.LockUI); // already true, but never applied
var targets = new FakeRuntimeTargets(events);
controller.BindRuntimeTargets(targets);
controller.SetUiLocked(true);
Assert.Equal(1, targets.UiLockCalls);
// A second call with the SAME value now correctly no-ops — this
// time it really was applied.
controller.SetUiLocked(true);
Assert.Equal(1, targets.UiLockCalls);
}
[Fact]
public void UiLockTargetFailureCanRetryTheSameRequestedValue()
{
var events = new List<string>();
var storage = new FakeStorage(events);
var controller = CreateController(storage, events);
storage.ClearEvents();
var targets = new FakeRuntimeTargets(events)
{
RemainingUiLockFailures = 1,
};
controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
Assert.Throws<InvalidOperationException>(() => controller.SetUiLocked(true));
Assert.True(controller.Gameplay.LockUI);
Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI);
Assert.Equal(0, storage.GameplaySaves);
Assert.Equal(["target-ui-lock:True"], events);
events.Clear();
controller.SetUiLocked(true);
Assert.True(controller.Gameplay.LockUI);
Assert.True(viewModel.GameplayDraft.LockUI);
viewModel.Cancel();
Assert.True(viewModel.GameplayDraft.LockUI);
Assert.Equal(1, storage.GameplaySaves);
Assert.Equal(["target-ui-lock:True", "save-gameplay"], events);
}
[Fact]
public void UiLockPersistenceFailureCanRetryTheSameRequestedValue()
{
var events = new List<string>();
var storage = new FakeStorage(events)
{
RemainingGameplaySaveFailures = 1,
};
var logs = new List<string>();
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
logs.Add);
storage.ClearEvents();
controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
controller.SetUiLocked(true);
Assert.True(controller.Gameplay.LockUI);
Assert.True(viewModel.GameplayDraft.LockUI);
Assert.Equal(1, storage.GameplaySaves);
Assert.Contains(logs, line =>
line.Contains("radar lock save failed", StringComparison.Ordinal));
events.Clear();
controller.SetUiLocked(true);
Assert.True(controller.Gameplay.LockUI);
viewModel.Cancel();
Assert.True(viewModel.GameplayDraft.LockUI);
Assert.Equal(2, storage.GameplaySaves);
Assert.Equal(["target-ui-lock:True", "save-gameplay"], events);
}
[Fact]
public void UnbindingLoansStopsCallsButStateAndPersistenceContinue()
{
var events = new List<string>();
var storage = new FakeStorage(events);
var controller = CreateController(storage, events: events);
storage.ClearEvents();
controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
controller.UnbindRuntimeTargets();
controller.SetUiLocked(true);
controller.ReapplyQualityPreset(QualityPreset.Ultra);
Assert.True(controller.Gameplay.LockUI);
Assert.Equal(
QualitySettings.From(QualityPreset.Ultra),
controller.ResolvedQuality);
Assert.DoesNotContain(events, value => value.StartsWith("target-", StringComparison.Ordinal));
Assert.Equal(1, storage.GameplaySaves);
}
[Theory]
[InlineData("1920x1080", true, 1920, 1080)]
[InlineData(" 800x600 ", true, 800, 600)]
[InlineData("", false, 0, 0)]
[InlineData("1920", false, 0, 0)]
[InlineData("0x1080", false, 0, 1080)]
[InlineData("1920x-1", false, 1920, -1)]
public void ResolutionParserMatchesWindowTargetPolicy(
string spec,
bool expected,
int expectedWidth,
int expectedHeight)
{
bool parsed = SilkRuntimeDisplayWindowTarget.TryParseResolution(
spec,
out int width,
out int height);
Assert.Equal(expected, parsed);
Assert.Equal(expectedWidth, width);
Assert.Equal(expectedHeight, height);
}
private static RuntimeSettingsController CreateController(
FakeStorage? storage = null,
List<string>? events = null)
{
storage ??= new FakeStorage(events);
return new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
static _ => { });
}
private static InputDispatcher CreateDispatcher()
{
InputDispatcher dispatcher = InputDispatcher.CreateDetached(
new NullKeyboardSource(),
new NullMouseSource(),
new KeyBindings());
dispatcher.Attach();
return dispatcher;
}
private static SettingsVM CreateStandaloneViewModel(InputDispatcher dispatcher) =>
new(
new KeyBindings(),
dispatcher,
static _ => { },
DisplaySettings.Default,
static _ => { },
AudioSettings.Default,
static _ => { },
GameplaySettings.Default,
static _ => { },
ChatSettings.Default,
static _ => { },
CharacterSettings.Default,
static _ => { });
private sealed class FakeStartupTarget(List<string> events)
: IRuntimeSettingsStartupTarget
{
public int RemainingDisplayFailures { get; set; }
public int RemainingAudioFailures { get; set; }
public void ApplyDisplay(DisplaySettings display)
{
events.Add("startup-display");
if (RemainingDisplayFailures > 0)
{
RemainingDisplayFailures--;
throw new InvalidOperationException("display startup failed");
}
}
public void ApplyAudio(AudioSettings audio)
{
events.Add("startup-audio");
if (RemainingAudioFailures > 0)
{
RemainingAudioFailures--;
throw new InvalidOperationException("audio startup failed");
}
}
}
private sealed class FakeRuntimeTargets(List<string> events)
: IRuntimeSettingsTargets
{
public bool ThrowOnDisplay { get; init; }
public bool ThrowOnQuality { get; init; }
public int RemainingUiLockFailures { get; set; }
public int UiLockCalls { get; private set; }
public void ApplyDisplayWindowState(DisplaySettings display)
{
events.Add("target-display");
if (ThrowOnDisplay)
throw new InvalidOperationException("display target failed");
}
public void ApplyQuality(QualitySettings quality)
{
events.Add("target-quality");
if (ThrowOnQuality)
throw new InvalidOperationException("quality target failed");
}
public List<AudioSettings> AudioCalls { get; } = [];
public void ApplyAudio(AudioSettings audio)
{
AudioCalls.Add(audio);
events.Add("target-audio");
}
public void ApplyUiLock(bool locked)
{
UiLockCalls++;
events.Add($"target-ui-lock:{locked}");
if (RemainingUiLockFailures > 0)
{
RemainingUiLockFailures--;
throw new InvalidOperationException("UI-lock target failed");
}
}
public List<(uint OptionId, bool Value)> SingleOptionCalls { get; } = [];
public void SetSingleCharacterOption(uint optionId, bool value)
{
SingleOptionCalls.Add((optionId, value));
events.Add($"target-single-option:0x{optionId:X}:{value}");
}
public List<(float DefaultOpacity, float ActiveOpacity)> ChatOpacityCalls { get; } = [];
public void SetChatOpacity(float defaultOpacity, float activeOpacity)
{
ChatOpacityCalls.Add((defaultOpacity, activeOpacity));
events.Add($"target-chat-opacity:{defaultOpacity}:{activeOpacity}");
}
}
// CH3 review S5(b): records every ICommandBus.Publish call so a test can
// assert what the CONCRETE RuntimeSettingsTargets actually put on the
// bus, rather than only what the IRuntimeSettingsTargets fake recorded.
private sealed class CaptureCommandBus : ICommandBus
{
public readonly List<object> Published = new();
public void Publish<T>(T command) where T : notnull =>
Published.Add(command!);
}
private sealed class InspectingDisplayWindowTarget(
Action<DisplaySettings> apply)
: IRuntimeDisplayWindowTarget
{
public int ApplyCount { get; private set; }
public void Apply(DisplaySettings display)
{
ApplyCount++;
apply(display);
}
}
private sealed class RecordingQualityApplicationTarget(List<string> events)
: IRuntimeQualityApplicationTarget
{
private bool _alphaToCoverage;
private int _anisotropic;
private int _near;
private int _far;
private int _streamNear;
private int _streamFar;
private int _budget;
public QualitySettings Observed => new(
_near,
_far,
4,
_anisotropic,
_alphaToCoverage,
_budget);
public void SetAlphaToCoverage(bool enabled)
{
_alphaToCoverage = enabled;
events.Add($"a2c:{enabled}");
}
public void SetAnisotropic(int level)
{
_anisotropic = level;
events.Add($"aniso:{level}");
}
public void PublishRenderRange(int nearRadius, int farRadius)
{
_near = nearRadius;
_far = farRadius;
events.Add($"range:{nearRadius}:{farRadius}");
}
public void ReconfigureStreamingRadii(int nearRadius, int farRadius)
{
_streamNear = nearRadius;
_streamFar = farRadius;
events.Add($"stream:{nearRadius}:{farRadius}");
}
public void SetCompletionBudget(int maxCompletionsPerFrame)
{
Assert.Equal(_near, _streamNear);
Assert.Equal(_far, _streamFar);
_budget = maxCompletionsPerFrame;
events.Add($"budget:{maxCompletionsPerFrame}");
}
}
private sealed class FailingQualityApplicationTarget(
List<string> events,
int failureIndex)
: IRuntimeQualityApplicationTarget
{
private int _step;
public void SetAlphaToCoverage(bool enabled) => Record("a2c");
public void SetAnisotropic(int level) => Record("aniso");
public void PublishRenderRange(int nearRadius, int farRadius) => Record("range");
public void ReconfigureStreamingRadii(int nearRadius, int farRadius) =>
Record("stream");
public void SetCompletionBudget(int maxCompletionsPerFrame) => Record("budget");
private void Record(string step)
{
events.Add(step);
if (_step++ == failureIndex)
throw new InvalidOperationException($"{step} failed");
}
}
private sealed class RecordingUiLockTarget(List<string> events)
: IRuntimeUiLockTarget
{
public void Apply(bool locked) => events.Add($"ui:{locked}");
}
private sealed class FakeStorage(List<string>? events = null)
: IRuntimeSettingsStorage
{
private readonly List<string> _events = events ?? [];
public DisplaySettings DisplayValue { get; set; } = DisplaySettings.Default;
public AudioSettings AudioValue { get; set; } = AudioSettings.Default;
public GameplaySettings GameplayValue { get; set; } = GameplaySettings.Default;
public ChatSettings ChatValue { get; set; } = ChatSettings.Default;
public CharacterSettings DefaultCharacterValue { get; set; } =
CharacterSettings.Default;
public CameraTurningSettings CameraTurningValue { get; set; } =
CameraTurningSettings.Default;
public Dictionary<string, CharacterSettings> Characters { get; } =
new(StringComparer.OrdinalIgnoreCase);
public SettingsStore? LayoutStore => null;
public string Location => "memory://settings";
public int DisplayLoads { get; private set; }
public int AudioLoads { get; private set; }
public int GameplayLoads { get; private set; }
public int ChatLoads { get; private set; }
public int CharacterLoads { get; private set; }
public int DisplaySaves { get; private set; }
public int GameplaySaves { get; private set; }
public int ChatSaves { get; private set; }
public string? LastLoadedCharacter { get; private set; }
public bool ThrowOnDisplaySave { get; init; }
public bool ThrowOnAudioSave { get; init; }
public bool ThrowOnGameplaySave { get; init; }
public int RemainingGameplaySaveFailures { get; set; }
public bool ThrowOnChatSave { get; init; }
public bool ThrowOnCharacterSave { get; init; }
public DisplaySettings LoadDisplay()
{
DisplayLoads++;
return DisplayValue;
}
public AudioSettings LoadAudio()
{
AudioLoads++;
return AudioValue;
}
public GameplaySettings LoadGameplay()
{
GameplayLoads++;
return GameplayValue;
}
public ChatSettings LoadChat()
{
ChatLoads++;
return ChatValue;
}
public CharacterSettings LoadCharacter(string toonKey)
{
CharacterLoads++;
LastLoadedCharacter = toonKey;
return Characters.TryGetValue(toonKey, out CharacterSettings? value)
? value
: DefaultCharacterValue;
}
public void SaveDisplay(DisplaySettings display)
{
DisplaySaves++;
_events.Add("save-display");
if (ThrowOnDisplaySave)
throw new IOException("display persistence failed");
DisplayValue = display;
}
public void SaveAudio(AudioSettings audio)
{
_events.Add("save-audio");
if (ThrowOnAudioSave)
throw new IOException("audio persistence failed");
AudioValue = audio;
}
public void SaveGameplay(GameplaySettings gameplay)
{
GameplaySaves++;
_events.Add("save-gameplay");
if (RemainingGameplaySaveFailures > 0)
{
RemainingGameplaySaveFailures--;
throw new IOException("gameplay persistence failed");
}
if (ThrowOnGameplaySave)
throw new IOException("gameplay persistence failed");
GameplayValue = gameplay;
}
public void SaveChat(ChatSettings chat)
{
ChatSaves++;
_events.Add("save-chat");
if (ThrowOnChatSave)
throw new IOException("chat persistence failed");
ChatValue = chat;
}
public void SaveCharacter(string toonKey, CharacterSettings character)
{
_events.Add($"save-character:{toonKey}");
if (ThrowOnCharacterSave)
throw new IOException("character persistence failed");
Characters[toonKey] = character;
if (string.Equals(toonKey, "default", StringComparison.OrdinalIgnoreCase))
DefaultCharacterValue = character;
}
public CameraTurningSettings LoadCameraTurning() => CameraTurningValue;
public void SaveCameraTurning(CameraTurningSettings cameraTurning)
{
_events.Add("save-camera-turning");
CameraTurningValue = cameraTurning;
}
public void ClearEvents() => _events.Clear();
}
private sealed class FakeClock : IFramePacingClock
{
public long Frequency => 1_000;
public long GetTimestamp() => 0;
}
private sealed class NullWaiter : IFramePacingWaiter
{
public void Wait(long durationTicks, long clockFrequency)
{
}
}
private sealed class FakePacingSurface : IDisplayFramePacingSurface
{
private int? _refreshRate;
public bool VSync { get; set; }
public int RefreshReadCount { get; private set; }
public int? ActiveMonitorRefreshHz
{
get => _refreshRate;
set => _refreshRate = value;
}
public bool TryGetActiveMonitorRefreshHz(out int refreshHz)
{
RefreshReadCount++;
refreshHz = _refreshRate.GetValueOrDefault();
return _refreshRate is > 0;
}
}
private sealed class NullKeyboardSource : IKeyboardSource
{
#pragma warning disable CS0067
public event Action<Key, ModifierMask>? KeyDown;
public event Action<Key, ModifierMask>? KeyUp;
#pragma warning restore CS0067
public bool IsHeld(Key key) => false;
public ModifierMask CurrentModifiers => ModifierMask.None;
}
private sealed class NullMouseSource : IMouseSource
{
#pragma warning disable CS0067
public event Action<MouseButton, ModifierMask>? MouseDown;
public event Action<MouseButton, ModifierMask>? MouseUp;
public event Action<float, float>? MouseMove;
public event Action<float>? Scroll;
#pragma warning restore CS0067
public bool IsHeld(MouseButton button) => false;
public bool WantCaptureMouse => false;
public bool WantCaptureKeyboard => false;
}
}