From 20bade5c28ea125433d9edea965ad81ca3939172 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 24 May 2026 22:56:03 -0400 Subject: [PATCH 01/17] Add platform driver abstraction with Windows backend Introduce IRawAccelDriver / IAccelEvaluator / ISystemDevicesRetriever behind the BackEnd, backed by the portable RawAccel.Contracts POCO project as the cross-OS JSON contract. Move the concrete Windows implementations (WindowsRawAccelDriver, ManagedAccelEvaluator, WindowsSystemDevicesRetriever) under Driver/Windows/. BackEndComposer registers the Windows impls; the curve preview and apply paths now talk to the interfaces. Windows-only: contains no Linux backend, agent, or cross-OS build scaffolding. The Linux HID-BPF implementation lands in a follow-up PR layered on top of this one. --- .gitignore | 5 +- RawAccel.Contracts/Constants.cs | 30 ++ RawAccel.Contracts/Enums.cs | 29 ++ RawAccel.Contracts/RawAccel.Contracts.csproj | 15 + RawAccel.Contracts/RawAccelAccelArgs.cs | 48 +++ RawAccel.Contracts/RawAccelConfig.cs | 18 + RawAccel.Contracts/RawAccelDeviceConfig.cs | 33 ++ RawAccel.Contracts/RawAccelDeviceSettings.cs | 17 + RawAccel.Contracts/RawAccelProfile.cs | 52 +++ RawAccel.Contracts/RawAccelSpeedArgs.cs | 22 ++ RawAccel.Contracts/Vec2.cs | 10 + userinterface/App.axaml.cs | 17 + .../Properties/Resources/Strings.Designer.cs | 9 + .../Properties/Resources/Strings.ja-JP.resx | 3 + .../Properties/Resources/Strings.resx | 3 + userinterface/Services/SettingsService.cs | 6 +- .../ViewModels/MainWindowViewModel.cs | 4 +- userinterface/Views/MainWindow.axaml.cs | 14 +- .../Views/Profile/ProfileChartView.axaml | 1 + .../IOTests/BackEndLoaderRoundTripTests.cs | 236 +++++++++++++ .../IOTests/DevicesReaderWriterTests.cs | 2 +- .../IOTests/MappingReaderWriterTests.cs | 2 +- .../IOTests/ProfileReaderWriterTests.cs | 2 +- .../ModelTests/BackEndApplyTests.cs | 317 ++++++++++++++++-- .../ModelTests/SystemDevicesTests.cs | 32 +- .../ModelTests/WindowsSystemDevicesTests.cs | 32 ++ .../AccelerationSerializationTests.cs | 26 ++ userspace-backend/BackEnd.cs | 89 +++-- userspace-backend/BackEndComposer.cs | 16 +- userspace-backend/Common/DriverHelpers.cs | 7 +- .../Calculations/CurveCalculationHelpers.cs | 14 +- userspace-backend/Display/CurvePreview.cs | 13 +- userspace-backend/Driver/IAccelEvaluator.cs | 30 ++ userspace-backend/Driver/IRawAccelDriver.cs | 35 ++ .../Driver/Windows/ManagedAccelEvaluator.cs | 41 +++ .../Driver/Windows/WindowsRawAccelDriver.cs | 89 +++++ .../Windows/WindowsSystemDevicesRetriever.cs | 33 ++ userspace-backend/IO/ProfileReaderWriter.cs | 2 + .../AccelerationJsonConverter.cs | 44 +++ .../AccelDefinitions/AccelDefinitionModel.cs | 1 + .../AccelDefinitions/AccelerationModel.cs | 5 +- .../ClassicAccelerationDefinitionModel.cs | 6 +- .../JumpAccelerationDefinitionModel.cs | 5 +- .../LinearAccelerationDefinitionModel.cs | 6 +- .../NaturalAccelerationDefinitionModel.cs | 2 + .../PowerAccelerationDefinitionModel.cs | 6 +- .../SynchronousAccelerationDefinitionModel.cs | 2 + .../AccelDefinitions/FormulaAccelModel.cs | 4 +- .../LookupTableDefinitionModel.cs | 2 + .../NoAccelDefinitionModel.cs | 2 + .../EditableSettingsSelector.cs | 6 +- .../ProfileComponents/AnisotropyModel.cs | 18 +- userspace-backend/Model/ProfileModel.cs | 1 + userspace-backend/Model/SystemDevices.cs | 37 +- userspace-backend/userspace-backend.csproj | 3 + 55 files changed, 1352 insertions(+), 152 deletions(-) create mode 100644 RawAccel.Contracts/Constants.cs create mode 100644 RawAccel.Contracts/Enums.cs create mode 100644 RawAccel.Contracts/RawAccel.Contracts.csproj create mode 100644 RawAccel.Contracts/RawAccelAccelArgs.cs create mode 100644 RawAccel.Contracts/RawAccelConfig.cs create mode 100644 RawAccel.Contracts/RawAccelDeviceConfig.cs create mode 100644 RawAccel.Contracts/RawAccelDeviceSettings.cs create mode 100644 RawAccel.Contracts/RawAccelProfile.cs create mode 100644 RawAccel.Contracts/RawAccelSpeedArgs.cs create mode 100644 RawAccel.Contracts/Vec2.cs create mode 100644 userspace-backend-tests/IOTests/BackEndLoaderRoundTripTests.cs create mode 100644 userspace-backend-tests/ModelTests/WindowsSystemDevicesTests.cs create mode 100644 userspace-backend/Driver/IAccelEvaluator.cs create mode 100644 userspace-backend/Driver/IRawAccelDriver.cs create mode 100644 userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs create mode 100644 userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs create mode 100644 userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs diff --git a/.gitignore b/.gitignore index 2a7c213a..712abfc3 100644 --- a/.gitignore +++ b/.gitignore @@ -351,4 +351,7 @@ ASALocalRun/ .localhistory/ # BeatPulse healthcheck temp database -healthchecksdb \ No newline at end of file +healthchecksdb + +# Linux build directory +linux/build/ diff --git a/RawAccel.Contracts/Constants.cs b/RawAccel.Contracts/Constants.cs new file mode 100644 index 00000000..8af2c401 --- /dev/null +++ b/RawAccel.Contracts/Constants.cs @@ -0,0 +1,30 @@ +namespace RawAccel.Contracts +{ + // Mirrors common/rawaccel-base.hpp. Values must stay in sync with the + // native side; see common/rawaccel-base.hpp and wrapper/wrapper.cpp. + public static class RawAccelConstants + { + public const int PollRateMin = 125; + public const int PollRateMax = 8000; + + public const double DefaultTimeMin = 1000.0 / PollRateMax / 2.0; + public const double DefaultTimeMax = 100.0; + + public const double WriteDelayMs = 1000.0; + + public const int MaxDevIdLen = 200; + public const int MaxNameLen = 256; + + public const int LutRawDataCapacity = 514; + public const int LutPointsCapacity = LutRawDataCapacity / 2; + + public const string SettingsKey = "Driver settings"; + + // Mirrors RA_VER_* in common/rawaccel-version.h. Bump when the + // settings shape or wire protocol changes incompatibly. + public const int VersionMajor = 1; + public const int VersionMinor = 7; + public const int VersionPatch = 0; + public const string VersionString = "1.7.0"; + } +} diff --git a/RawAccel.Contracts/Enums.cs b/RawAccel.Contracts/Enums.cs new file mode 100644 index 00000000..5e039650 --- /dev/null +++ b/RawAccel.Contracts/Enums.cs @@ -0,0 +1,29 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace RawAccel.Contracts +{ + // JSON values: classic, jump, natural, synchronous, power, lut, noaccel. + // Names match wrapper/wrapper.cpp exactly so existing settings.json files + // round-trip unchanged. + [JsonConverter(typeof(StringEnumConverter))] + public enum AccelMode + { + classic, + jump, + natural, + synchronous, + power, + lut, + noaccel, + } + + // JSON values: in_out, input, output. Same shape as wrapper/wrapper.cpp. + [JsonConverter(typeof(StringEnumConverter))] + public enum CapMode + { + in_out, + input, + output, + } +} diff --git a/RawAccel.Contracts/RawAccel.Contracts.csproj b/RawAccel.Contracts/RawAccel.Contracts.csproj new file mode 100644 index 00000000..4bbd8d0d --- /dev/null +++ b/RawAccel.Contracts/RawAccel.Contracts.csproj @@ -0,0 +1,15 @@ + + + + netstandard2.0 + RawAccel.Contracts + 10 + enable + false + + + + + + + diff --git a/RawAccel.Contracts/RawAccelAccelArgs.cs b/RawAccel.Contracts/RawAccelAccelArgs.cs new file mode 100644 index 00000000..8e919ffc --- /dev/null +++ b/RawAccel.Contracts/RawAccelAccelArgs.cs @@ -0,0 +1,48 @@ +using Newtonsoft.Json; + +namespace RawAccel.Contracts +{ + // Mirrors AccelArgs in wrapper/wrapper.cpp. JsonProperty names are the + // contract; do not rename without bumping settings.json compatibility. + public class RawAccelAccelArgs + { + // Maximum number of LUT (input, output) sample pairs the native side + // accepts; mirrors wrapper.cpp's literal AccelArgs.MaxLutPoints which + // resolves to ra::LUT_POINTS_CAPACITY. + public const int MaxLutPoints = RawAccelConstants.LutPointsCapacity; + + public AccelMode mode { get; set; } = AccelMode.noaccel; + + [JsonProperty("Gain / Velocity")] + public bool gain { get; set; } = true; + + public double inputOffset { get; set; } + public double outputOffset { get; set; } + public double acceleration { get; set; } = 0.005; + public double decayRate { get; set; } = 0.1; + public double gamma { get; set; } = 1.0; + public double motivity { get; set; } = 1.5; + public double exponentClassic { get; set; } = 2.0; + public double scale { get; set; } = 1.0; + public double exponentPower { get; set; } = 0.05; + public double limit { get; set; } = 1.5; + public double syncSpeed { get; set; } = 5.0; + public double smooth { get; set; } = 0.5; + + [JsonProperty("Cap / Jump")] + public Vec2 cap { get; set; } = new Vec2 { x = 15.0, y = 1.5 }; + + [JsonProperty("Cap mode")] + public CapMode capMode { get; set; } = CapMode.output; + + // length is internal bookkeeping for native marshalling; not in JSON. + [JsonIgnore] + public int length { get; set; } + + // Raw LUT data. On the wire this carries only the populated points + // (length samples); native side resizes to LUT_RAW_DATA_CAPACITY. + // Defaulted to empty (not null) because wrapper.cpp's OnDeserialized + // dereferences data->Length without a null check. + public float[] data { get; set; } = System.Array.Empty(); + } +} diff --git a/RawAccel.Contracts/RawAccelConfig.cs b/RawAccel.Contracts/RawAccelConfig.cs new file mode 100644 index 00000000..2b2dd3e4 --- /dev/null +++ b/RawAccel.Contracts/RawAccelConfig.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace RawAccel.Contracts +{ + // Root JSON contract. Mirrors DriverConfig in wrapper/wrapper.cpp. + // The Windows wrapper IOCTL path consumes this exact shape; do not + // introduce divergent fields. + public class RawAccelConfig + { + public string version { get; set; } = string.Empty; + + public RawAccelDeviceConfig defaultDeviceConfig { get; set; } = new RawAccelDeviceConfig(); + + public List profiles { get; set; } = new List(); + + public List devices { get; set; } = new List(); + } +} diff --git a/RawAccel.Contracts/RawAccelDeviceConfig.cs b/RawAccel.Contracts/RawAccelDeviceConfig.cs new file mode 100644 index 00000000..36c9a481 --- /dev/null +++ b/RawAccel.Contracts/RawAccelDeviceConfig.cs @@ -0,0 +1,33 @@ +using Newtonsoft.Json; + +namespace RawAccel.Contracts +{ + // Mirrors DeviceConfig in wrapper/wrapper.cpp. ShouldSerialize* methods + // hide default-valued fields so the JSON stays compact and matches what + // the wrapper produces today. + public class RawAccelDeviceConfig + { + public bool disable { get; set; } + + public bool setExtraInfo { get; set; } + + [JsonProperty("Use constant time interval based on polling rate")] + public bool pollTimeLock { get; set; } + + [JsonProperty("DPI (normalizes input speed unit: counts/ms -> in/s)")] + public int dpi { get; set; } = 1000; + + [JsonProperty("Polling rate Hz (keep at 0 for automatic adjustment)")] + public int pollingRate { get; set; } + + public double minimumTime { get; set; } = RawAccelConstants.DefaultTimeMin; + + public double maximumTime { get; set; } = RawAccelConstants.DefaultTimeMax; + + public bool ShouldSerializesetExtraInfo() => setExtraInfo; + + public bool ShouldSerializeminimumTime() => minimumTime != RawAccelConstants.DefaultTimeMin; + + public bool ShouldSerializemaximumTime() => maximumTime != RawAccelConstants.DefaultTimeMax; + } +} diff --git a/RawAccel.Contracts/RawAccelDeviceSettings.cs b/RawAccel.Contracts/RawAccelDeviceSettings.cs new file mode 100644 index 00000000..a5f74a74 --- /dev/null +++ b/RawAccel.Contracts/RawAccelDeviceSettings.cs @@ -0,0 +1,17 @@ +namespace RawAccel.Contracts +{ + // Mirrors DeviceSettings in wrapper/wrapper.cpp. The native side enforces + // length limits via fixed-size buffers; we keep them as plain strings here + // and let the conversion layer truncate/validate against + // RawAccelConstants.MaxNameLen / MaxDevIdLen. + public class RawAccelDeviceSettings + { + public string name { get; set; } = string.Empty; + + public string profile { get; set; } = string.Empty; + + public string id { get; set; } = string.Empty; + + public RawAccelDeviceConfig config { get; set; } = new RawAccelDeviceConfig(); + } +} diff --git a/RawAccel.Contracts/RawAccelProfile.cs b/RawAccel.Contracts/RawAccelProfile.cs new file mode 100644 index 00000000..9283caa4 --- /dev/null +++ b/RawAccel.Contracts/RawAccelProfile.cs @@ -0,0 +1,52 @@ +using Newtonsoft.Json; + +namespace RawAccel.Contracts +{ + // Mirrors Profile in wrapper/wrapper.cpp. JsonObject(ItemRequired=Always) + // on the wrapper side enforces all-fields-present on read; we relax that + // here so a partial JSON can be tolerated client-side and the native side + // does the strict validation. + public class RawAccelProfile + { + public string name { get; set; } = "default"; + + [JsonProperty("Stretches domain for horizontal vs vertical inputs")] + public Vec2 domainXY { get; set; } = new Vec2 { x = 1.0, y = 1.0 }; + + [JsonProperty("Stretches accel range for horizontal vs vertical inputs")] + public Vec2 rangeXY { get; set; } = new Vec2 { x = 1.0, y = 1.0 }; + + [JsonProperty("Whole or horizontal accel parameters")] + public RawAccelAccelArgs argsX { get; set; } = new RawAccelAccelArgs(); + + [JsonProperty("Vertical accel parameters")] + public RawAccelAccelArgs argsY { get; set; } = new RawAccelAccelArgs(); + + [JsonProperty("Input speed calculation parameters")] + public RawAccelSpeedArgs inputSpeedArgs { get; set; } = new RawAccelSpeedArgs(); + + [JsonProperty("Output DPI")] + public double outputDPI { get; set; } = 1000.0; + + [JsonProperty("Y/X output DPI ratio (vertical sens multiplier)")] + public double yxOutputDPIRatio { get; set; } = 1.0; + + [JsonProperty("L/R output DPI ratio (left sens multiplier)")] + public double lrOutputDPIRatio { get; set; } = 1.0; + + [JsonProperty("U/D output DPI ratio (up sens multiplier)")] + public double udOutputDPIRatio { get; set; } = 1.0; + + [JsonProperty("Degrees of rotation")] + public double rotation { get; set; } + + [JsonProperty("Degrees of angle snapping")] + public double snap { get; set; } + + [JsonIgnore] + public double minimumSpeed { get; set; } + + [JsonProperty("Input Speed Cap")] + public double maximumSpeed { get; set; } + } +} diff --git a/RawAccel.Contracts/RawAccelSpeedArgs.cs b/RawAccel.Contracts/RawAccelSpeedArgs.cs new file mode 100644 index 00000000..26e74908 --- /dev/null +++ b/RawAccel.Contracts/RawAccelSpeedArgs.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace RawAccel.Contracts +{ + // Mirrors SpeedArgs in wrapper/wrapper.cpp. + public class RawAccelSpeedArgs + { + [JsonProperty("Whole/combined accel (set false for 'by component' mode)")] + public bool combineMagnitudes { get; set; } = true; + + public double lpNorm { get; set; } = 2.0; + + [JsonProperty("Time in ms after which an input is weighted at half its original value.")] + public double inputSmoothHalflife { get; set; } + + [JsonProperty("Time in ms after which scale is weighted at half its original value.")] + public double scaleSmoothHalflife { get; set; } + + [JsonProperty("Time in ms after which an output is weighted at half its original value.")] + public double outputSmoothHalflife { get; set; } + } +} diff --git a/RawAccel.Contracts/Vec2.cs b/RawAccel.Contracts/Vec2.cs new file mode 100644 index 00000000..849dc56e --- /dev/null +++ b/RawAccel.Contracts/Vec2.cs @@ -0,0 +1,10 @@ +namespace RawAccel.Contracts +{ + // 2D vector container used for cap, domainXY, rangeXY in the JSON + // contract. Matches the layout produced by wrapper/wrapper.cpp Vec2. + public struct Vec2 + { + public T x; + public T y; + } +} diff --git a/userinterface/App.axaml.cs b/userinterface/App.axaml.cs index 23acc90f..8ef2f20b 100644 --- a/userinterface/App.axaml.cs +++ b/userinterface/App.axaml.cs @@ -173,6 +173,23 @@ public override void OnFrameworkInitializationCompleted() } }; + // Avalonia's ShutdownRequested only fires when the window closes + // normally. Under `dotnet run` a Ctrl+C in the terminal sends + // SIGINT, which bypasses the window lifecycle but still triggers + // .NET's ProcessExit. Mirror the save there so dev sessions do + // not silently drop unsaved edits. + AppDomain.CurrentDomain.ProcessExit += (_, _) => + { + try + { + backEnd.SaveToDisk(); + } + catch (Exception ex) + { + Debug.WriteLine($"[PROCESS_EXIT] SaveToDisk failed: {ex.Message}"); + } + }; + // Preload libraries that cause first-page stutter _ = PreloadLibrariesAsync(); diff --git a/userinterface/Properties/Resources/Strings.Designer.cs b/userinterface/Properties/Resources/Strings.Designer.cs index fc7cedd9..dfddc0f5 100644 --- a/userinterface/Properties/Resources/Strings.Designer.cs +++ b/userinterface/Properties/Resources/Strings.Designer.cs @@ -779,6 +779,15 @@ public static string MainWindowSettingsAppliedSuccess { return ResourceManager.GetString("MainWindowSettingsAppliedSuccess", resourceCulture); } } + + /// + /// Looks up a localized string similar to Failed to apply settings. Check the log for details.. + /// + public static string MainWindowSettingsAppliedFailure { + get { + return ResourceManager.GetString("MainWindowSettingsAppliedFailure", resourceCulture); + } + } /// /// Looks up a localized string similar to Add Entry. diff --git a/userinterface/Properties/Resources/Strings.ja-JP.resx b/userinterface/Properties/Resources/Strings.ja-JP.resx index b270df74..e4799256 100644 --- a/userinterface/Properties/Resources/Strings.ja-JP.resx +++ b/userinterface/Properties/Resources/Strings.ja-JP.resx @@ -194,6 +194,9 @@ 設定を反映しました! + + 設定の反映に失敗しました。ログを確認してください。 + 言語を{0}に変更しました diff --git a/userinterface/Properties/Resources/Strings.resx b/userinterface/Properties/Resources/Strings.resx index 13cb5595..b356aa53 100644 --- a/userinterface/Properties/Resources/Strings.resx +++ b/userinterface/Properties/Resources/Strings.resx @@ -194,6 +194,9 @@ Settings applied successfully! + + Failed to apply settings. Check the log for details. + Language changed to {0} diff --git a/userinterface/Services/SettingsService.cs b/userinterface/Services/SettingsService.cs index 882af62e..745e6296 100644 --- a/userinterface/Services/SettingsService.cs +++ b/userinterface/Services/SettingsService.cs @@ -83,7 +83,11 @@ public bool TrySave(out string? errorMessage) errorMessage = null; try { - backEnd.Apply(); + if (!backEnd.Apply()) + { + errorMessage = "Failed to apply settings to driver."; + return false; + } return true; } catch (Exception ex) diff --git a/userinterface/ViewModels/MainWindowViewModel.cs b/userinterface/ViewModels/MainWindowViewModel.cs index 6f974e87..d8f383fb 100644 --- a/userinterface/ViewModels/MainWindowViewModel.cs +++ b/userinterface/ViewModels/MainWindowViewModel.cs @@ -219,9 +219,9 @@ private async void CollapseProfiles() } } - public void Apply() + public bool Apply() { - BackEnd.Apply(); + return BackEnd.Apply(); } private void ToggleTheme() diff --git a/userinterface/Views/MainWindow.axaml.cs b/userinterface/Views/MainWindow.axaml.cs index d49df169..4631aefa 100644 --- a/userinterface/Views/MainWindow.axaml.cs +++ b/userinterface/Views/MainWindow.axaml.cs @@ -90,10 +90,7 @@ public async void ApplyButtonHandler(object? sender, RoutedEventArgs args) LoadingProgressBar.IsVisible = true; } - if (viewModel.ApplyCommand.CanExecute(null)) - { - viewModel.ApplyCommand.Execute(null); - } + bool success = viewModel.Apply(); await Task.Delay(1000); @@ -102,7 +99,14 @@ public async void ApplyButtonHandler(object? sender, RoutedEventArgs args) LoadingProgressBar.IsVisible = false; } - NotificationService.ShowSuccessToast("MainWindowSettingsAppliedSuccess"); + if (success) + { + NotificationService.ShowSuccessToast("MainWindowSettingsAppliedSuccess"); + } + else + { + NotificationService.ShowErrorToast("MainWindowSettingsAppliedFailure"); + } if (ApplyButtonControl != null) { diff --git a/userinterface/Views/Profile/ProfileChartView.axaml b/userinterface/Views/Profile/ProfileChartView.axaml index 90a8386f..6e0d3f5d 100644 --- a/userinterface/Views/Profile/ProfileChartView.axaml +++ b/userinterface/Views/Profile/ProfileChartView.axaml @@ -85,6 +85,7 @@ XAxes="{Binding XAxes}" YAxes="{Binding YAxes}" TooltipPosition="Top" + TooltipFindingStrategy="CompareOnlyXTakeClosest" TooltipTextPaint="{Binding TooltipTextPaint}" TooltipBackgroundPaint="{Binding TooltipBackgroundPaint}" TooltipTextSize="12" diff --git a/userspace-backend-tests/IOTests/BackEndLoaderRoundTripTests.cs b/userspace-backend-tests/IOTests/BackEndLoaderRoundTripTests.cs new file mode 100644 index 00000000..09dc1b9d --- /dev/null +++ b/userspace-backend-tests/IOTests/BackEndLoaderRoundTripTests.cs @@ -0,0 +1,236 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using userspace_backend; +using userspace_backend.Data; +using userspace_backend.Data.Profiles; +using userspace_backend.Data.Profiles.Accel; +using userspace_backend.Data.Profiles.Accel.Formula; +using userspace_backend.IO; + +namespace userspace_backend_tests.IOTests +{ + // End-to-end: write devices/mappings/profiles/settings via BackEndLoader, + // verify the files actually land in the target directory, then read them + // back with a fresh BackEndLoader and verify values survive the trip. + // Catches regressions where a file silently fails to write (path missing, + // wrong format) or fields are dropped by the serializer. + [TestClass] + public class BackEndLoaderRoundTripTests + { + private string tempDir = null!; + + [TestInitialize] + public void Setup() + { + tempDir = Path.Combine(Path.GetTempPath(), + $"rawaccel-roundtrip-{System.Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + } + + [TestCleanup] + public void Teardown() + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + + private BackEndLoader MakeLoader() => new( + tempDir, + new DevicesReaderWriter(), + new MappingsReaderWriter(), + new ProfileReaderWriter(), + new SettingsReaderWriter()); + + [TestMethod] + public void WriteSettings_LandsOnDisk_WithCorrectValues() + { + var loader = MakeLoader(); + var settings = new Settings + { + Theme = "Dark", + Language = "fr-FR", + ShowConfirmModals = false, + ShowToastNotifications = true, + }; + + loader.WriteSettings(settings); + + var settingsFile = Path.Combine(tempDir, "settings.json"); + Assert.IsTrue(File.Exists(settingsFile), + $"settings.json not created at {settingsFile}"); + + var loaded = MakeLoader().LoadSettings(); + Assert.IsNotNull(loaded); + Assert.AreEqual("Dark", loaded!.Theme); + Assert.AreEqual("fr-FR", loaded.Language); + Assert.IsFalse(loaded.ShowConfirmModals); + Assert.IsTrue(loaded.ShowToastNotifications); + } + + [TestMethod] + public void WriteProfiles_LandsOnDisk_WithClassicAccel() + { + var loader = MakeLoader(); + var profile = new Profile + { + Name = "TestProfile", + OutputDPI = 1600, + YXRatio = 1.25, + Acceleration = new ClassicAccel + { + Acceleration = 0.05, + Exponent = 2.3, + Offset = 1.5, + Cap = 4.0, + Gain = true, + }, + Hidden = new Hidden + { + RotationDegrees = 0, + AngleSnappingDegrees = 0, + LeftRightRatio = 1, + UpDownRatio = 1, + SpeedCap = 0, + OutputSmoothingHalfLife = 0, + }, + }; + + var profilesDir = Path.Combine(tempDir, "profiles"); + Directory.CreateDirectory(profilesDir); + var profileRW = new ProfileReaderWriter(); + File.WriteAllText( + Path.Combine(profilesDir, "TestProfile.json"), + profileRW.Serialize(profile)); + + var profileFile = Path.Combine(profilesDir, "TestProfile.json"); + Assert.IsTrue(File.Exists(profileFile), + $"profile JSON not created at {profileFile}"); + var contents = File.ReadAllText(profileFile); + StringAssert.Contains(contents, "TestProfile"); + StringAssert.Contains(contents, "Classic"); + + var loaded = MakeLoader().LoadProfiles().ToList(); + Assert.AreEqual(1, loaded.Count); + var p = loaded[0]; + Assert.AreEqual("TestProfile", p.Name); + Assert.AreEqual(1600, p.OutputDPI); + Assert.AreEqual(1.25, p.YXRatio); + Assert.IsInstanceOfType(p.Acceleration, typeof(ClassicAccel)); + var ca = (ClassicAccel)p.Acceleration; + Assert.AreEqual(0.05, ca.Acceleration); + Assert.AreEqual(2.3, ca.Exponent); + Assert.AreEqual(1.5, ca.Offset); + Assert.AreEqual(4.0, ca.Cap); + Assert.IsTrue(ca.Gain); + } + + [TestMethod] + public void WriteDevicesAndMappings_LandOnDisk() + { + var devicesRW = new DevicesReaderWriter(); + var mappingsRW = new MappingsReaderWriter(); + + var devices = new List + { + new() + { + Name = "Test Mouse", + HWID = "HID\\VID_ABCD&PID_1234", + DPI = 1600, + PollingRate = 1000, + DeviceGroup = "Default", + }, + }; + + File.WriteAllText( + Path.Combine(tempDir, "devices.json"), + devicesRW.Serialize(devices)); + + var mappings = new MappingSet + { + Mappings = new[] + { + new Mapping + { + Name = "Default", + GroupsToProfiles = new Mapping.GroupsToProfilesMapping + { + { "Default", "TestProfile" }, + }, + }, + }, + ActiveMappingIndex = 0, + }; + + File.WriteAllText( + Path.Combine(tempDir, "mappings.json"), + mappingsRW.Serialize(mappings)); + + Assert.IsTrue(File.Exists(Path.Combine(tempDir, "devices.json"))); + Assert.IsTrue(File.Exists(Path.Combine(tempDir, "mappings.json"))); + + var fresh = MakeLoader(); + var loadedDevices = fresh.LoadDevices().ToList(); + Assert.AreEqual(1, loadedDevices.Count); + Assert.AreEqual("Test Mouse", loadedDevices[0].Name); + Assert.AreEqual(1600, loadedDevices[0].DPI); + + var loadedMappings = fresh.LoadMappings(); + Assert.AreEqual(1, loadedMappings.Mappings.Length); + Assert.AreEqual("Default", loadedMappings.Mappings[0].Name); + Assert.AreEqual("TestProfile", + loadedMappings.Mappings[0].GroupsToProfiles["Default"]); + } + + [TestMethod] + public void LoadSettings_ReturnsNull_WhenFileMissing() + { + var loader = MakeLoader(); + Assert.IsNull(loader.LoadSettings()); + } + + // Regression probe: prove that a ClassicAccel serialized via the + // ProfileReaderWriter actually round-trips with its curve-specific + // fields intact. The previous behavior was that Profile.Acceleration + // serialized as the *base* class (no curve params, no formula + // discriminator), so a Classic profile written to disk and read back + // came back as NoAcceleration with all settings lost. + [TestMethod] + public void Profile_ClassicAccel_SurvivesRoundTrip() + { + var profile = new Profile + { + Name = "Classic", + OutputDPI = 1600, + YXRatio = 1, + Acceleration = new ClassicAccel + { + Acceleration = 0.05, + Exponent = 2.3, + Offset = 1.5, + Cap = 4.0, + Gain = true, + }, + Hidden = new Hidden(), + }; + + var rw = new ProfileReaderWriter(); + string json = rw.Serialize(profile); + + StringAssert.Contains(json, "Formula/Classic", + "Serialized profile missing formula discriminator: " + json); + StringAssert.Contains(json, "\"Exponent\"", + "Serialized profile missing curve params: " + json); + + var roundTripped = rw.Deserialize(json); + Assert.IsInstanceOfType(roundTripped.Acceleration, typeof(ClassicAccel)); + var ca = (ClassicAccel)roundTripped.Acceleration; + Assert.AreEqual(0.05, ca.Acceleration); + Assert.AreEqual(2.3, ca.Exponent); + Assert.AreEqual(1.5, ca.Offset); + Assert.AreEqual(4.0, ca.Cap); + Assert.IsTrue(ca.Gain); + } + } +} diff --git a/userspace-backend-tests/IOTests/DevicesReaderWriterTests.cs b/userspace-backend-tests/IOTests/DevicesReaderWriterTests.cs index 6b191a58..399c1151 100644 --- a/userspace-backend-tests/IOTests/DevicesReaderWriterTests.cs +++ b/userspace-backend-tests/IOTests/DevicesReaderWriterTests.cs @@ -11,7 +11,7 @@ namespace userspace_backend_tests.IOTests [TestClass] public class DevicesReaderWriterTests { - public static string TestDirectory = Path.Combine(Directory.GetCurrentDirectory(), @"TestFiles\DevicesReaderWriter"); + public static string TestDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestFiles", "DevicesReaderWriter"); public static string ExpectedOutputs = Path.Combine(TestDirectory, "ExpectedOutputs"); public static string TestInputs = Path.Combine(TestDirectory, "Inputs"); diff --git a/userspace-backend-tests/IOTests/MappingReaderWriterTests.cs b/userspace-backend-tests/IOTests/MappingReaderWriterTests.cs index 0a5149bf..3e2826c6 100644 --- a/userspace-backend-tests/IOTests/MappingReaderWriterTests.cs +++ b/userspace-backend-tests/IOTests/MappingReaderWriterTests.cs @@ -13,7 +13,7 @@ namespace userspace_backend_tests.IOTests [TestClass] public class MappingReaderWriterTests { - public static string TestDirectory = Path.Combine(Directory.GetCurrentDirectory(), @"TestFiles\MappingReaderWriter"); + public static string TestDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestFiles", "MappingReaderWriter"); public static string ExpectedOutputs = Path.Combine(TestDirectory, "ExpectedOutputs"); public static string TestInputs = Path.Combine(TestDirectory, "Inputs"); diff --git a/userspace-backend-tests/IOTests/ProfileReaderWriterTests.cs b/userspace-backend-tests/IOTests/ProfileReaderWriterTests.cs index b268735b..dbdaf841 100644 --- a/userspace-backend-tests/IOTests/ProfileReaderWriterTests.cs +++ b/userspace-backend-tests/IOTests/ProfileReaderWriterTests.cs @@ -15,7 +15,7 @@ namespace userspace_backend_tests.IOTests [TestClass] public class ProfileReaderWriterTests { - public static string TestDirectory = Path.Combine(Directory.GetCurrentDirectory(), @"TestFiles\ProfileReaderWriter"); + public static string TestDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestFiles", "ProfileReaderWriter"); public static string ExpectedOutputs = Path.Combine(TestDirectory, "ExpectedOutputs"); public static string TestInputs = Path.Combine(TestDirectory, "Inputs"); diff --git a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs index beb8dd8b..c1f8c7f9 100644 --- a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs +++ b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs @@ -1,11 +1,14 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; +using RawAccel.Contracts; using System; using System.Collections.Generic; using System.Linq; using userspace_backend; using userspace_backend.Data.Profiles; using userspace_backend.Data.Profiles.Accel; +using userspace_backend.Data.Profiles.Accel.Formula; +using userspace_backend.Driver; using userspace_backend.IO; using userspace_backend.Model; using userspace_backend.Model.AccelDefinitions; @@ -56,19 +59,31 @@ private sealed class StubSystemDevice : ISystemDevice public string HWID { get; init; } = string.Empty; } - private sealed class CapturingDriverConfigActivator : IDriverConfigActivator + // Captures whatever the BackEnd hands to its driver. Implements + // IRawAccelDriver so these tests exercise the apply path without + // touching wrapper.dll or a real backend. + private sealed class CapturingDriver : IRawAccelDriver { - public DriverConfig? CapturedConfig { get; private set; } - public int WriteCount { get; private set; } + public RawAccelConfig? CapturedConfig { get; private set; } + public int ApplyCount { get; private set; } - public void Write(DriverConfig config) + public bool IsAvailable => true; + + public bool Apply(RawAccelConfig config) { CapturedConfig = config; - WriteCount++; + ApplyCount++; + return true; } + + public RawAccelConfig Read() => CapturedConfig ?? new RawAccelConfig(); + + public void Deactivate() { } + + public double GetCurrentMouseSpeed() => 0; } - private static (IBackEnd backEnd, CapturingDriverConfigActivator activator) BuildBackEndWithDefaults( + private static (IBackEnd backEnd, CapturingDriver driver) BuildBackEndWithDefaults( IList? systemDevices = null) { var services = new ServiceCollection(); @@ -78,26 +93,26 @@ private static (IBackEnd backEnd, CapturingDriverConfigActivator activator) Buil { Devices = systemDevices ?? new List(), }); - var activator = new CapturingDriverConfigActivator(); - services.AddSingleton(activator); + var driver = new CapturingDriver(); + services.AddSingleton(driver); var sp = BackEndComposer.Compose(services); var backEnd = sp.GetRequiredService(); backEnd.Load(); - return (backEnd, activator); + return (backEnd, driver); } - private static DriverConfig ApplyAndCapture(IBackEnd backEnd, CapturingDriverConfigActivator activator) + private static RawAccelConfig ApplyAndCapture(IBackEnd backEnd, CapturingDriver driver) { backEnd.Apply(); - Assert.IsNotNull(activator.CapturedConfig, "Apply should have written a DriverConfig to the activator."); - return activator.CapturedConfig!; + Assert.IsNotNull(driver.CapturedConfig, "Apply should have handed a RawAccelConfig to the driver."); + return driver.CapturedConfig!; } [TestMethod] public void EnsureDefaultMapping_FreshInstall_CreatesMappingWithDefaultEntry() { - var (backEnd, activator) = BuildBackEndWithDefaults(); + var (backEnd, driver) = BuildBackEndWithDefaults(); Assert.IsTrue( backEnd.Mappings.TryGetMapping("Default", out MappingModel? mapping) && mapping != null, @@ -108,7 +123,7 @@ public void EnsureDefaultMapping_FreshInstall_CreatesMappingWithDefaultEntry() Assert.AreEqual(DeviceGroups.DefaultDeviceGroup, mapping.IndividualMappings[0].DeviceGroup); Assert.AreEqual("Default", mapping.IndividualMappings[0].Profile.Name.ModelValue); - var cfg = ApplyAndCapture(backEnd, activator); + var cfg = ApplyAndCapture(backEnd, driver); Assert.AreEqual(1, cfg.profiles.Count); Assert.AreEqual(1, cfg.devices.Count); } @@ -121,8 +136,8 @@ public void EnsureDefaultMapping_StaleEmptyMapping_SelfHealsWithDefaultEntry() var services = new ServiceCollection(); services.AddSingleton(staleLoader); services.AddSingleton(new StubSystemDevicesRetriever()); - var activator = new CapturingDriverConfigActivator(); - services.AddSingleton(activator); + var driver = new CapturingDriver(); + services.AddSingleton(driver); var sp = BackEndComposer.Compose(services); var backEnd = sp.GetRequiredService(); backEnd.Load(); @@ -134,7 +149,7 @@ public void EnsureDefaultMapping_StaleEmptyMapping_SelfHealsWithDefaultEntry() 1, mapping!.IndividualMappings.Count, "Stale empty Default mapping must self-heal to one DefaultDeviceGroup -> Default entry."); - var cfg = ApplyAndCapture(backEnd, activator); + var cfg = ApplyAndCapture(backEnd, driver); Assert.AreEqual(1, cfg.profiles.Count); Assert.AreEqual(1, cfg.devices.Count); } @@ -168,8 +183,8 @@ public void WriteSettings(DATA.Settings settings) { } [TestMethod] public void Apply_DefaultState_ProducesOneProfileAndOneDevice() { - var (backEnd, activator) = BuildBackEndWithDefaults(); - var cfg = ApplyAndCapture(backEnd, activator); + var (backEnd, driver) = BuildBackEndWithDefaults(); + var cfg = ApplyAndCapture(backEnd, driver); Assert.AreEqual(1, cfg.profiles.Count, "Expected exactly one profile in the DriverConfig."); Assert.AreEqual(1, cfg.devices.Count, "Expected exactly one device in the DriverConfig."); @@ -183,8 +198,8 @@ public void Apply_DefaultState_ProducesOneProfileAndOneDevice() [TestMethod] public void Apply_DefaultState_DeviceReferencesDefaultProfileByName() { - var (backEnd, activator) = BuildBackEndWithDefaults(); - var cfg = ApplyAndCapture(backEnd, activator); + var (backEnd, driver) = BuildBackEndWithDefaults(); + var cfg = ApplyAndCapture(backEnd, driver); var device = cfg.devices[0]; var profile = cfg.profiles[0]; @@ -197,25 +212,25 @@ public void Apply_DefaultState_DeviceReferencesDefaultProfileByName() [TestMethod] public void Apply_ProfileOutputDpiEdit_FlowsIntoDriverConfig() { - var (backEnd, activator) = BuildBackEndWithDefaults(); + var (backEnd, driver) = BuildBackEndWithDefaults(); var profile = backEnd.Profiles.Elements[0]; Assert.IsTrue(profile.OutputDPI.TryUpdateModelDirectly(1600), "OutputDPI update should succeed."); - var cfg = ApplyAndCapture(backEnd, activator); + var cfg = ApplyAndCapture(backEnd, driver); Assert.AreEqual(1600, cfg.profiles[0].outputDPI); } [TestMethod] public void Apply_DeviceDpiEdit_DoesNotAffectPollingRate() { - var (backEnd, activator) = BuildBackEndWithDefaults(); + var (backEnd, driver) = BuildBackEndWithDefaults(); var device = backEnd.Devices.Elements[0]; Assert.IsTrue(device.DPI.TryUpdateModelDirectly(3200), "DPI update should succeed."); Assert.IsTrue(device.PollRate.TryUpdateModelDirectly(500), "PollRate update should succeed."); - var cfg = ApplyAndCapture(backEnd, activator); + var cfg = ApplyAndCapture(backEnd, driver); Assert.AreEqual(3200, cfg.devices[0].config.dpi); Assert.AreEqual(500, cfg.devices[0].config.pollingRate); } @@ -280,7 +295,7 @@ public void ReloadSystemDevices_RemovesDisconnectedAndAddsNew() services.AddSingleton(new StubBackEndLoader()); var retrieverStub = new StubSystemDevicesRetriever { Devices = initial }; services.AddSingleton(retrieverStub); - services.AddSingleton(new CapturingDriverConfigActivator()); + services.AddSingleton(new CapturingDriver()); var sp = BackEndComposer.Compose(services); var backEnd = sp.GetRequiredService(); @@ -313,7 +328,7 @@ public void Apply_ProfileCurveCoefficientEdit_FlowsIntoDriverConfig() // must propagate through EditableSettingsSelector.AnySettingChanged up to // ProfileModel.RecalculateDriverData so CurrentValidatedDriverProfile refreshes // before BackEnd.Apply() reads it via MapToDriverConfig. - var (backEnd, activator) = BuildBackEndWithDefaults(); + var (backEnd, driver) = BuildBackEndWithDefaults(); var profile = backEnd.Profiles.Elements[0]; Assert.IsTrue( @@ -337,7 +352,7 @@ public void Apply_ProfileCurveCoefficientEdit_FlowsIntoDriverConfig() classic.Acceleration.TryUpdateModelDirectly(expectedAcceleration), "Classic.Acceleration update should succeed."); - var cfg = ApplyAndCapture(backEnd, activator); + var cfg = ApplyAndCapture(backEnd, driver); Assert.AreEqual(AccelMode.classic, cfg.profiles[0].argsX.mode, "DriverConfig should reflect the chosen Classic formula."); Assert.AreEqual(expectedAcceleration, cfg.profiles[0].argsX.acceleration, @@ -346,6 +361,252 @@ public void Apply_ProfileCurveCoefficientEdit_FlowsIntoDriverConfig() "propagating nested sub-model changes up to ProfileModel."); } + // Regression: a saved ClassicAccel used to StackOverflow on Load via + // EditableSettingsSelectable.TryMapFromData recursing into itself. + private sealed class ClassicAccelLoader : IBackEndLoader + { + public IEnumerable LoadDevices() => Array.Empty(); + public DATA.MappingSet LoadMappings() => new DATA.MappingSet + { + Mappings = Array.Empty(), + ActiveMappingIndex = 0, + }; + public IEnumerable LoadProfiles() => new[] + { + new DATA.Profile + { + Name = "Default", + OutputDPI = 1000, + YXRatio = 1, + Acceleration = new ClassicAccel + { + Acceleration = 0.05, + Exponent = 2.3, + Offset = 1.5, + Cap = 4.0, + Gain = true, + }, + Hidden = new Hidden(), + }, + }; + public DATA.Settings? LoadSettings() => null; + public void WriteSettingsToDisk( + IEnumerable devices, + MappingsModel mappings, + IEnumerable profiles) { } + public void WriteSettings(DATA.Settings settings) { } + } + + [TestMethod] + public void Load_ProfileWithClassicAccel_DoesNotRecurse() + { + var services = new ServiceCollection(); + services.AddSingleton(new ClassicAccelLoader()); + services.AddSingleton(new StubSystemDevicesRetriever()); + services.AddSingleton(new CapturingDriver()); + var sp = BackEndComposer.Compose(services); + var backEnd = sp.GetRequiredService(); + + backEnd.Load(); + + var profile = backEnd.Profiles.Elements.Single(); + Assert.AreEqual(Acceleration.AccelerationDefinitionType.Formula, + profile.Acceleration.DefinitionType.ModelValue); + var formula = (FormulaAccelModel)profile.Acceleration.GetSelectable( + Acceleration.AccelerationDefinitionType.Formula); + Assert.AreEqual(FormulaAccel.AccelerationFormulaType.Classic, + formula.FormulaType.ModelValue); + var classic = (ClassicAccelerationDefinitionModel)formula.GetSelectable( + FormulaAccel.AccelerationFormulaType.Classic); + Assert.AreEqual(0.05, classic.Acceleration.ModelValue); + Assert.AreEqual(2.3, classic.Exponent.ModelValue); + Assert.AreEqual(1.5, classic.Offset.ModelValue); + Assert.AreEqual(4.0, classic.Cap.ModelValue); + } + + // Regression: an older AccelerationModel fallback wrote + // Anisotropy.Domain={0,0} / Range={0,0} when the on-disk profile had a + // missing Anisotropy block. Those zeros then round-tripped back to disk + // and degenerated the preview curve to a flat line (domain=0 collapses + // input speed to 0; range=0 collapses scale to 1). Loading a profile + // with the legacy all-zero Anisotropy must sanitize back to identity + // weights so the curve preview is meaningful. + private sealed class ZeroAnisotropyLoader : IBackEndLoader + { + public IEnumerable LoadDevices() => Array.Empty(); + public DATA.MappingSet LoadMappings() => new DATA.MappingSet + { + Mappings = Array.Empty(), + ActiveMappingIndex = 0, + }; + public IEnumerable LoadProfiles() => new[] + { + new DATA.Profile + { + Name = "Default", + OutputDPI = 1000, + YXRatio = 1, + Acceleration = new ClassicAccel + { + Acceleration = 0.01, + Anisotropy = new Anisotropy + { + Domain = new Vector2 { X = 0, Y = 0 }, + Range = new Vector2 { X = 0, Y = 0 }, + LPNorm = 2, + CombineXYComponents = false, + }, + }, + Hidden = new Hidden(), + }, + }; + public DATA.Settings? LoadSettings() => null; + public void WriteSettingsToDisk( + IEnumerable devices, + MappingsModel mappings, + IEnumerable profiles) { } + public void WriteSettings(DATA.Settings settings) { } + } + + [TestMethod] + public void Load_ProfileWithZeroAnisotropy_SanitizesToIdentityWeights() + { + var services = new ServiceCollection(); + services.AddSingleton(new ZeroAnisotropyLoader()); + services.AddSingleton(new StubSystemDevicesRetriever()); + services.AddSingleton(new CapturingDriver()); + var sp = BackEndComposer.Compose(services); + var backEnd = sp.GetRequiredService(); + + backEnd.Load(); + + var aniso = backEnd.Profiles.Elements.Single().Acceleration.Anisotropy; + Assert.AreEqual(1.0, aniso.DomainX.ModelValue, + "DomainX must be sanitized to identity; zero collapses input speed to 0."); + Assert.AreEqual(1.0, aniso.DomainY.ModelValue); + Assert.AreEqual(1.0, aniso.RangeX.ModelValue, + "RangeX must be sanitized to identity; zero collapses curve scale to 1."); + Assert.AreEqual(1.0, aniso.RangeY.ModelValue); + } + + // Simulates the user-reported flow: app boots with a Default profile of + // Type=None on disk, user switches DefinitionType to Formula then picks + // Classic and edits a coefficient. The chained Apply must see the + // Classic args in the RawAccelConfig the driver receives. + [TestMethod] + public void TypeChange_FromNoneToClassic_FlowsThroughApply() + { + var (backEnd, driver) = BuildBackEndWithDefaults(); + var profile = backEnd.Profiles.Elements[0]; + + Assert.AreEqual(Acceleration.AccelerationDefinitionType.None, + profile.Acceleration.DefinitionType.ModelValue, + "Test precondition: fresh-install profile is Type=None."); + + Assert.IsTrue(profile.Acceleration.DefinitionType.TryUpdateModelDirectly( + Acceleration.AccelerationDefinitionType.Formula)); + + var formula = (FormulaAccelModel)profile.Acceleration.GetSelectable( + Acceleration.AccelerationDefinitionType.Formula); + + Assert.IsTrue(formula.FormulaType.TryUpdateModelDirectly( + FormulaAccel.AccelerationFormulaType.Classic)); + + var classic = (ClassicAccelerationDefinitionModel)formula.GetSelectable( + FormulaAccel.AccelerationFormulaType.Classic); + Assert.IsTrue(classic.Acceleration.TryUpdateModelDirectly(0.42)); + + var cfg = ApplyAndCapture(backEnd, driver); + Assert.AreEqual(RawAccel.Contracts.AccelMode.classic, cfg.profiles[0].argsX.mode, + "Apply must see Classic mode after the user-driven type change."); + Assert.AreEqual(0.42, cfg.profiles[0].argsX.acceleration, + "Apply must see the edited Classic coefficient, not stale state."); + } + + // Regression: a user-created device group (e.g. "DeviceGroup0") was lost + // on reload because DeviceGroups.DeviceGroupModels is the master list + // backing the UI dropdown and MappingModel.TryAddMapping, but is never + // rehydrated from devices.json or mappings.json. Symptoms: + // 1. devices.json keeps device.DeviceGroup="DeviceGroup0" - this part + // survives, but the UI dropdown only shows "Default". + // 2. mappings.json has DeviceGroup0 -> Some Profile, but TryAddMapping + // rejects the row because "DeviceGroup0" isn't registered. + private sealed class CustomDeviceGroupLoader : IBackEndLoader + { + public IEnumerable LoadDevices() => new[] + { + new DATA.Device + { + Name = "Mouse In Custom Group", + HWID = @"HID\VID_1111&PID_2222", + DPI = 1000, + PollingRate = 1000, + DeviceGroup = "DeviceGroup0", + }, + }; + public DATA.MappingSet LoadMappings() => new DATA.MappingSet + { + Mappings = new[] + { + new DATA.Mapping + { + Name = "Default", + GroupsToProfiles = new DATA.Mapping.GroupsToProfilesMapping + { + { "Default", "Default" }, + { "DeviceGroup0", "Default" }, + }, + }, + }, + ActiveMappingIndex = 0, + }; + public IEnumerable LoadProfiles() => new[] + { + new DATA.Profile + { + Name = "Default", + OutputDPI = 1000, + YXRatio = 1, + Acceleration = new userspace_backend.Data.Profiles.Accel.NoAcceleration(), + Hidden = new Hidden(), + }, + }; + public DATA.Settings? LoadSettings() => null; + public void WriteSettingsToDisk( + IEnumerable devices, + MappingsModel mappings, + IEnumerable profiles) { } + public void WriteSettings(DATA.Settings settings) { } + } + + [TestMethod] + public void Load_CustomDeviceGroupInDevicesAndMappings_RestoresGroupList() + { + var services = new ServiceCollection(); + services.AddSingleton(new CustomDeviceGroupLoader()); + services.AddSingleton(new StubSystemDevicesRetriever()); + services.AddSingleton(new CapturingDriver()); + var sp = BackEndComposer.Compose(services); + var backEnd = sp.GetRequiredService(); + + backEnd.Load(); + + CollectionAssert.Contains( + backEnd.Devices.DeviceGroups.DeviceGroupModels, + "DeviceGroup0", + "DeviceGroup0 should be restored into the master list from devices.json / mappings.json."); + CollectionAssert.Contains( + backEnd.Devices.DeviceGroups.DeviceGroupModels, + DeviceGroups.DefaultDeviceGroup); + + Assert.IsTrue( + backEnd.Mappings.TryGetMapping("Default", out MappingModel? mapping) && mapping != null); + var groupNames = mapping!.IndividualMappings.Select(m => m.DeviceGroup).ToList(); + CollectionAssert.Contains(groupNames, "DeviceGroup0", + "MappingModel must keep the DeviceGroup0 row after Load (was silently dropped before restore)."); + CollectionAssert.Contains(groupNames, DeviceGroups.DefaultDeviceGroup); + } + [TestMethod] public void ImportSystemDevices_SyncsInterfaceValueSoUiReflectsRealValues() { diff --git a/userspace-backend-tests/ModelTests/SystemDevicesTests.cs b/userspace-backend-tests/ModelTests/SystemDevicesTests.cs index 6120402a..893d2845 100644 --- a/userspace-backend-tests/ModelTests/SystemDevicesTests.cs +++ b/userspace-backend-tests/ModelTests/SystemDevicesTests.cs @@ -1,30 +1,29 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using userspace_backend.Model; namespace userspace_backend_tests.ModelTests { + // Cross-platform tests for the SystemDevices abstraction. The Windows-only + // SystemDevicesRetriever (RawInput-based) is exercised in + // WindowsSystemDevicesTests, which is excluded from non-Windows builds. [TestClass] public class SystemDevicesTests { private class TestDevicesRetriever : ISystemDevicesRetriever { - public List Devices { get; set; } + public List Devices { get; set; } = new List(); public IList GetSystemDevices() => Devices; } private class TestSystemDevice : ISystemDevice { - public string Name { get; set; } + public string Name { get; set; } = string.Empty; - public string HWID { get; set; } + public string HWID { get; set; } = string.Empty; } [TestMethod] @@ -52,27 +51,10 @@ public void SystemDevicesProvider_ProvidesDevices() var serviceProvider = services.BuildServiceProvider(); SystemDevicesProvider testObject = serviceProvider.GetRequiredService(); - + Assert.IsNotNull(testObject); Assert.AreEqual(testSystemDevices.Count, testObject.SystemDevices.Count); CollectionAssert.AreEquivalent(testSystemDevices, testObject.SystemDevices); } - - // This test may need to be excluded if built from deviceless server. - [TestMethod] - public void SystemDevicesRetriever_RetrievesDevices() - { - var services = new ServiceCollection(); - services.AddSingleton(); - var serviceProvider = services.BuildServiceProvider(); - - SystemDevicesRetriever testObject = serviceProvider.GetRequiredService(); - Assert.IsNotNull(testObject); - - // These devices will be different per user, but should not be null as long as the user has something giving mouse input. - IList retrievedDevices = testObject.GetSystemDevices(); - Assert.IsNotNull(retrievedDevices); - Assert.IsTrue(retrievedDevices.Count > 0); - } } } diff --git a/userspace-backend-tests/ModelTests/WindowsSystemDevicesTests.cs b/userspace-backend-tests/ModelTests/WindowsSystemDevicesTests.cs new file mode 100644 index 00000000..f7a6fc23 --- /dev/null +++ b/userspace-backend-tests/ModelTests/WindowsSystemDevicesTests.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using userspace_backend.Driver.Windows; +using userspace_backend.Model; + +namespace userspace_backend_tests.ModelTests +{ + // Windows-only retriever test: asserts the Windows RawInput-based + // retriever returns at least one mouse. Skip on a headless build server + // where no mouse is connected. + [TestClass] + public class WindowsSystemDevicesTests + { + [TestMethod] + public void WindowsSystemDevicesRetriever_RetrievesDevices() + { + var services = new ServiceCollection(); + services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); + + var testObject = serviceProvider.GetRequiredService(); + Assert.IsNotNull(testObject); + + // These devices will be different per user, but should not be null + // as long as the user has something giving mouse input. + IList retrievedDevices = testObject.GetSystemDevices(); + Assert.IsNotNull(retrievedDevices); + Assert.IsTrue(retrievedDevices.Count > 0); + } + } +} diff --git a/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs b/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs index fa7f6ae0..5673d30d 100644 --- a/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs +++ b/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs @@ -82,6 +82,32 @@ public void DeserializeFormulaLinearAccel() Assert.AreEqual(0.001, actualLinearAccel.Acceleration); } + // Regression: the converter only handled Linear and Classic. + // A Default profile saved with Type=Formula/Synchronous (the default + // formula picked when a user switches DefinitionType -> Formula in the + // UI without picking a specific formula) threw "Unknown formula type + // Synchronous" at startup, crashing the app on the next launch. + [TestMethod] + [DataRow("Synchronous", typeof(SynchronousAccel))] + [DataRow("Power", typeof(PowerAccel))] + [DataRow("Natural", typeof(NaturalAccel))] + [DataRow("Jump", typeof(JumpAccel))] + public void DeserializeFormulaAccel_AllSubtypes(string formulaType, Type expectedRuntimeType) + { + string textToDeserialize = $$""" + { + "Acceleration": { + "Type": "Formula/{{formulaType}}", + "Gain": false + } + } + """; + + var deserialized = JsonSerializer.Deserialize(textToDeserialize); + Assert.IsNotNull(deserialized.Acceleration); + Assert.IsInstanceOfType(deserialized.Acceleration, expectedRuntimeType); + } + [TestMethod] public void DeserializeLookupTableVelocity() { diff --git a/userspace-backend/BackEnd.cs b/userspace-backend/BackEnd.cs index 68a08548..05cdb52c 100644 --- a/userspace-backend/BackEnd.cs +++ b/userspace-backend/BackEnd.cs @@ -4,10 +4,15 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using RawAccel.Contracts; using userspace_backend.Data.Profiles; +using userspace_backend.Driver; using userspace_backend.IO; using userspace_backend.Model; using DATA = userspace_backend.Data; +using Profile = RawAccel.Contracts.RawAccelProfile; +using DeviceSettings = RawAccel.Contracts.RawAccelDeviceSettings; +using DeviceConfig = RawAccel.Contracts.RawAccelDeviceConfig; namespace userspace_backend { @@ -15,7 +20,7 @@ public interface IBackEnd { void Load(); - void Apply(); + bool Apply(); void SaveToDisk(); @@ -35,11 +40,11 @@ public interface IBackEnd public class BackEnd : IBackEnd { private readonly ILogger logger; - private readonly IDriverConfigActivator driverConfigActivator; + private readonly IRawAccelDriver driver; public BackEnd( IBackEndLoader backEndLoader, - IDriverConfigActivator driverConfigActivator, + IRawAccelDriver driver, IProfilesModel profilesModel, DevicesModel devicesModel, MappingsModel mappingsModel, @@ -47,7 +52,7 @@ public BackEnd( ILogger? logger = null) { BackEndLoader = backEndLoader; - this.driverConfigActivator = driverConfigActivator; + this.driver = driver; Devices = devicesModel; Mappings = mappingsModel; Profiles = profilesModel; @@ -69,13 +74,21 @@ public BackEnd( public void Load() { - IEnumerable devicesData = BackEndLoader.LoadDevices(); + List devicesData = BackEndLoader.LoadDevices().ToList(); LoadDevicesFromData(devicesData); IEnumerable profilesData = BackEndLoader.LoadProfiles(); LoadProfilesFromData(profilesData); DATA.MappingSet mappingData = BackEndLoader.LoadMappings(); + + // DeviceGroups.DeviceGroupModels is the master list the UI and + // MappingModel.TryAddMapping look up against. It is not serialized + // directly: group names live implicitly inside devices.json (per + // device) and mappings.json (as map keys). Restore the list before + // applying mappings so non-Default rows are not silently dropped. + RestoreDeviceGroupsFromData(devicesData, mappingData); + LoadMappingsFromData(mappingData); Settings = BackEndLoader.LoadSettings() ?? new DATA.Settings(); @@ -86,6 +99,30 @@ public void Load() EnsureDefaultMappingExists(); } + protected void RestoreDeviceGroupsFromData( + IEnumerable devicesData, + DATA.MappingSet mappingData) + { + foreach (DATA.Device device in devicesData) + { + if (!string.IsNullOrEmpty(device.DeviceGroup)) + { + Devices.DeviceGroups.AddOrGetDeviceGroup(device.DeviceGroup); + } + } + + foreach (DATA.Mapping mapping in mappingData?.Mappings ?? []) + { + foreach (string group in mapping.GroupsToProfiles.Keys) + { + if (!string.IsNullOrEmpty(group)) + { + Devices.DeviceGroups.AddOrGetDeviceGroup(group); + } + } + } + } + protected void LoadDevicesFromData(IEnumerable devicesData) { Devices.TryMapFromData(devicesData); @@ -221,7 +258,7 @@ protected void EnsureDefaultMappingExists() } } - public void Apply() + public bool Apply() { logger.LogInformation("Apply clicked"); @@ -230,10 +267,10 @@ public void Apply() { logger.LogWarning("Apply: no active mapping to apply"); WriteSettingsToDisk(); - return; + return false; } - DriverConfig? config = null; + RawAccelConfig? config = null; try { config = MapToDriverConfig(mappingToApply); @@ -242,26 +279,28 @@ public void Apply() } catch (Exception ex) { - logger.LogError(ex, "Apply: error building DriverConfig"); + logger.LogError(ex, "Apply: error building RawAccelConfig"); } + bool driverApplied = false; if (config != null) { - try + driverApplied = driver.Apply(config); + if (driverApplied) { - driverConfigActivator.Write(config); - logger.LogInformation("Apply: driver.Activate() succeeded"); + logger.LogInformation("Apply: driver.Apply() succeeded"); } - catch (Exception ex) + else { - logger.LogError(ex, "Apply: driver.Activate() failed"); + logger.LogError("Apply: driver.Apply() failed"); } } WriteSettingsToDisk(); + return driverApplied; } - private void LogDriverConfigSummary(MappingModel mapping, DriverConfig config) + private void LogDriverConfigSummary(MappingModel mapping, RawAccelConfig config) { int profileCount = config.profiles?.Count ?? 0; int deviceCount = config.devices?.Count ?? 0; @@ -296,18 +335,18 @@ private void LogDriverConfigSummary(MappingModel mapping, DriverConfig config) } } - private void LogDriverConfigJson(DriverConfig config) + private void LogDriverConfigJson(RawAccelConfig config) { try { string json = Newtonsoft.Json.JsonConvert.SerializeObject( config, Newtonsoft.Json.Formatting.Indented); - logger.LogDebug("Apply: DriverConfig JSON{NewLine}{Json}", Environment.NewLine, json); + logger.LogDebug("Apply: RawAccelConfig JSON{NewLine}{Json}", Environment.NewLine, json); } catch (Exception ex) { - logger.LogWarning(ex, "Apply: could not serialize DriverConfig to JSON"); + logger.LogWarning(ex, "Apply: could not serialize RawAccelConfig to JSON"); } } @@ -334,16 +373,18 @@ public void SaveToDisk() } } - protected DriverConfig MapToDriverConfig(MappingModel mappingModel) + protected RawAccelConfig MapToDriverConfig(MappingModel mappingModel) { IEnumerable configDevices = MapToDriverDevices(mappingModel); IEnumerable configProfiles = MapToDriverProfiles(mappingModel); - DriverConfig config = DriverConfig.GetDefault(); - config.profiles = configProfiles.ToList(); - config.devices = configDevices.ToList(); - config.accels = configProfiles.Select(p => new ManagedAccel(p)).ToList(); - return config; + return new RawAccelConfig + { + version = RawAccelConstants.VersionString, + defaultDeviceConfig = new DeviceConfig(), + profiles = configProfiles.ToList(), + devices = configDevices.ToList(), + }; } protected IEnumerable MapToDriverDevices(MappingModel mapping) diff --git a/userspace-backend/BackEndComposer.cs b/userspace-backend/BackEndComposer.cs index d8f3655e..3c12d98c 100644 --- a/userspace-backend/BackEndComposer.cs +++ b/userspace-backend/BackEndComposer.cs @@ -3,6 +3,8 @@ using System; using DATA = userspace_backend.Data; using userspace_backend.Display; +using userspace_backend.Driver; +using userspace_backend.Driver.Windows; using userspace_backend.IO; using userspace_backend.Model; using userspace_backend.Model.AccelDefinitions; @@ -20,7 +22,7 @@ public static class BackEndComposer { public static IServiceProvider Compose(IServiceCollection services) { - services.TryAddSingleton(); + RegisterPlatformServices(services); services.TryAddSingleton(); #region Parsers @@ -588,13 +590,21 @@ public static IServiceProvider Compose(IServiceCollection services) services.AddSingleton(); - services.TryAddSingleton(); - services.AddSingleton(); #endregion BackEnd return services.BuildServiceProvider(); } + + // Driver/evaluator/device-enumeration registration. The concrete impls + // (WindowsRawAccelDriver, ManagedAccelEvaluator, WindowsSystemDevicesRetriever) + // live under Driver/Windows/ and depend on wrapper.dll (C++/CLI). + private static void RegisterPlatformServices(IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + } } } diff --git a/userspace-backend/Common/DriverHelpers.cs b/userspace-backend/Common/DriverHelpers.cs index d5687f5e..32dfbd92 100644 --- a/userspace-backend/Common/DriverHelpers.cs +++ b/userspace-backend/Common/DriverHelpers.cs @@ -4,6 +4,9 @@ using System.Text; using System.Threading.Tasks; using userspace_backend.Model; +using Profile = RawAccel.Contracts.RawAccelProfile; +using SpeedArgs = RawAccel.Contracts.RawAccelSpeedArgs; +using Vec2D = RawAccel.Contracts.Vec2; namespace userspace_backend.Common { @@ -17,12 +20,12 @@ public static Profile MapProfileModelToDriver(ProfileModel model) outputDPI = model.OutputDPI.ModelValue, yxOutputDPIRatio = model.YXRatio.ModelValue, argsX = model.Acceleration.MapToDriver(), - domainXY = new Vec2 + domainXY = new Vec2D { x = model.Acceleration.Anisotropy.DomainX.ModelValue, y = model.Acceleration.Anisotropy.DomainY.ModelValue, }, - rangeXY = new Vec2 + rangeXY = new Vec2D { x = model.Acceleration.Anisotropy.RangeX.ModelValue, y = model.Acceleration.Anisotropy.RangeY.ModelValue, diff --git a/userspace-backend/Display/Calculations/CurveCalculationHelpers.cs b/userspace-backend/Display/Calculations/CurveCalculationHelpers.cs index 32b738dc..4daa2450 100644 --- a/userspace-backend/Display/Calculations/CurveCalculationHelpers.cs +++ b/userspace-backend/Display/Calculations/CurveCalculationHelpers.cs @@ -14,17 +14,13 @@ public static class CurveCalculationHelpers public static ICollection CalculateCurvePointSpeeds() { - List curvePointSpeeds = new List(); + int count = (int)CurvePointsResolution; + List curvePointSpeeds = new List(count); - double ratio = FastestHandSpeed / SlowestHandSpeed; - double sqrtRatio = Math.Sqrt(ratio); - double middle = sqrtRatio * SlowestHandSpeed; - double increment = 2.0 / (CurvePointsResolution - 1.0); - - for (double i = -1; i <= 1; i += increment) + double step = (FastestHandSpeed - SlowestHandSpeed) / (count - 1); + for (int i = 0; i < count; i++) { - double speed = middle * Math.Pow(sqrtRatio, i); - curvePointSpeeds.Add(speed); + curvePointSpeeds.Add(SlowestHandSpeed + i * step); } return curvePointSpeeds; diff --git a/userspace-backend/Display/CurvePreview.cs b/userspace-backend/Display/CurvePreview.cs index 1f29c1ca..c7a17fac 100644 --- a/userspace-backend/Display/CurvePreview.cs +++ b/userspace-backend/Display/CurvePreview.cs @@ -6,6 +6,8 @@ using System.Text; using System.Threading.Tasks; using userspace_backend.Display.Calculations; +using userspace_backend.Driver; +using Profile = RawAccel.Contracts.RawAccelProfile; namespace userspace_backend.Display { @@ -20,8 +22,11 @@ public interface ICurvePreview public class CurvePreview : ICurvePreview { - public CurvePreview() + private readonly IAccelEvaluator evaluator; + + public CurvePreview(IAccelEvaluator evaluator) { + this.evaluator = evaluator; Points = new ObservableCollection(); InitPoints(); } @@ -30,12 +35,12 @@ public CurvePreview() public void GeneratePoints(Profile profile) { - ManagedAccel accel = new ManagedAccel(profile).CreateStatelessCopy(); + IAccelInstance instance = evaluator.CreateInstance(profile); foreach (CurvePoint point in Points) { - var output = accel.Accelerate(point.MouseSpeed, 0, 1, 1); - var outputSpeed = Math.Sqrt(Math.Pow(output.Item1, 2) + Math.Pow(output.Item2, 2)); + var (ox, oy) = instance.Accelerate(point.MouseSpeed, 0, 1, 1); + var outputSpeed = Math.Sqrt(ox * ox + oy * oy); point.Output = outputSpeed / point.MouseSpeed; } } diff --git a/userspace-backend/Driver/IAccelEvaluator.cs b/userspace-backend/Driver/IAccelEvaluator.cs new file mode 100644 index 00000000..bf40f171 --- /dev/null +++ b/userspace-backend/Driver/IAccelEvaluator.cs @@ -0,0 +1,30 @@ +using RawAccel.Contracts; + +namespace userspace_backend.Driver +{ + // Stateless per-sample acceleration evaluator used by the live curve + // preview in Display/CurvePreview.cs. Decoupled from IRawAccelDriver + // because preview is a pure-math read that doesn't touch the backend. + // + // Two-step usage (matches how ManagedAccel is used today): + // var instance = evaluator.CreateInstance(profile); + // foreach (var point in points) { + // var (ox, oy) = instance.Accelerate(point.x, point.y, 1, 1); + // } + // + // The Windows implementation wraps wrapper.ManagedAccel.CreateStatelessCopy + // against the same common/ math the driver uses. + public interface IAccelEvaluator + { + IAccelInstance CreateInstance(RawAccelProfile profile); + } + + public interface IAccelInstance + { + // dpiFactor is device DPI normalized against NORMALIZED_DPI (1000); + // timeMs is the time slice attributed to this sample (1.0 in the + // existing preview). Returns the post-acceleration (x, y) in the + // same units as input. + (double x, double y) Accelerate(double x, double y, double dpiFactor, double timeMs); + } +} diff --git a/userspace-backend/Driver/IRawAccelDriver.cs b/userspace-backend/Driver/IRawAccelDriver.cs new file mode 100644 index 00000000..cc8be4d6 --- /dev/null +++ b/userspace-backend/Driver/IRawAccelDriver.cs @@ -0,0 +1,35 @@ +using RawAccel.Contracts; + +namespace userspace_backend.Driver +{ + // Platform-agnostic apply/read/deactivate surface for the Raw Accel + // backend. The Windows implementation talks to the kernel filter driver + // via IOCTL. Implementations consume and produce the same RawAccelConfig + // POCO; the JSON contract for that POCO is the source of truth in + // RawAccel.Contracts. + public interface IRawAccelDriver + { + // True if this implementation can talk to its backend right now. + // Windows: driver service installed and reachable. UI gates Apply + // on this. + bool IsAvailable { get; } + + // Push a configuration. Returns true on success, false if the + // backend rejected the config or the transport failed. Implementations + // should log the underlying error rather than letting it surface as + // an exception so callers can render a simple success/fail toast. + // The 1s WriteDelay anti-abuse mitigation is enforced by the backend + // (driver / agent), not by this method. + bool Apply(RawAccelConfig config); + + // Read the currently active configuration from the backend. + RawAccelConfig Read(); + + // Reset the backend to a no-op configuration without uninstalling. + void Deactivate(); + + // Optional telemetry: current input speed (counts/ms or in/s, + // implementation-defined). Returns 0 when unsupported. + double GetCurrentMouseSpeed(); + } +} diff --git a/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs b/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs new file mode 100644 index 00000000..b7dbab68 --- /dev/null +++ b/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs @@ -0,0 +1,41 @@ +using System; +using Newtonsoft.Json; +using RawAccel.Contracts; + +namespace userspace_backend.Driver.Windows +{ + // IAccelEvaluator backed by wrapper.ManagedAccel against the same + // common/ math the kernel driver runs. Converts the RawAccelProfile + // POCO into a wrapper.Profile via JSON round-trip (matching JsonProperty + // names on both sides) so this evaluator stays in lockstep with whatever + // the apply path produces. + public sealed class ManagedAccelEvaluator : IAccelEvaluator + { + public IAccelInstance CreateInstance(RawAccelProfile profile) + { + var json = JsonConvert.SerializeObject(profile); + var nativeProfile = JsonConvert.DeserializeObject(json) + ?? throw new InvalidOperationException( + "POCO -> wrapper.Profile deserialization returned null"); + var accel = new ManagedAccel(nativeProfile).CreateStatelessCopy(); + return new ManagedAccelInstance(accel); + } + + private sealed class ManagedAccelInstance : IAccelInstance + { + private readonly ManagedAccel accel; + + public ManagedAccelInstance(ManagedAccel accel) + { + this.accel = accel; + } + + public (double x, double y) Accelerate( + double x, double y, double dpiFactor, double timeMs) + { + var t = accel.Accelerate(x, y, dpiFactor, timeMs); + return (t.Item1, t.Item2); + } + } + } +} diff --git a/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs b/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs new file mode 100644 index 00000000..9b566b8c --- /dev/null +++ b/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json; +using RawAccel.Contracts; + +namespace userspace_backend.Driver.Windows +{ + // IRawAccelDriver implementation backed by the C++/CLI wrapper.dll's + // DriverConfig.Activate() IOCTL path. RawAccelConfig POCO is converted + // to the wrapper's managed types via JSON round-trip: both sides share + // identical [JsonProperty] names so Newtonsoft can deserialize one into + // the other without manual field mapping. + // + // ManagedAccel instances are constructed per profile after the + // round-trip, since wrapper.DriverConfig.accels is [NonSerialized] and + // wrapper.DriverConfig.Activate() requires accels.Count == profiles.Count. + public sealed class WindowsRawAccelDriver : IRawAccelDriver + { + private readonly ILogger logger; + + public WindowsRawAccelDriver(ILogger? logger = null) + { + this.logger = logger ?? NullLogger.Instance; + } + + public bool IsAvailable + { + get + { + try + { + VersionHelper.ValidOrThrow(); + return true; + } + catch (Exception ex) + { + logger.LogDebug(ex, "driver version probe failed"); + return false; + } + } + } + + public bool Apply(RawAccelConfig config) + { + try + { + var json = JsonConvert.SerializeObject(config); + var native = JsonConvert.DeserializeObject(json) + ?? throw new InvalidOperationException( + "POCO -> wrapper.DriverConfig deserialization returned null"); + native.accels = native.profiles + .Select(p => new ManagedAccel(p)) + .ToList(); + native.Activate(); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "driver apply failed"); + return false; + } + } + + public RawAccelConfig Read() + { + var native = DriverConfig.GetActive(); + var json = native.ToJSON(); + return JsonConvert.DeserializeObject(json) + ?? throw new InvalidOperationException( + "wrapper.DriverConfig -> POCO deserialization returned null"); + } + + public void Deactivate() + { + DriverConfig.GetDefault().Deactivate(); + } + + public double GetCurrentMouseSpeed() + { + // wrapper exposes per-profile speed via SpeedCalculator; the UI + // gauge reads from that path directly today, so this telemetry + // hook stays a no-op until consolidated. + return 0; + } + } +} diff --git a/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs b/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs new file mode 100644 index 00000000..6b3987ab --- /dev/null +++ b/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Linq; +using userspace_backend.Model; + +namespace userspace_backend.Driver.Windows +{ + // Windows-only system device enumeration. Uses the wrapper.dll's + // MultiHandleDevice (RawInput-based) which is why this file lives under + // Driver/Windows/ and is excluded from non-Windows builds via the + // csproj Compile Remove rule. + public sealed class WindowsSystemDevicesRetriever : ISystemDevicesRetriever + { + public IList GetSystemDevices() + { + IList rawDevices = MultiHandleDevice.GetList(); + return rawDevices.Select(d => new WindowsSystemDevice(d) as ISystemDevice).ToList(); + } + } + + public sealed class WindowsSystemDevice : ISystemDevice + { + public WindowsSystemDevice(MultiHandleDevice multiHandleDevice) + { + RawDevice = multiHandleDevice; + } + + public string Name => RawDevice.name; + + public string HWID => RawDevice.id; + + private MultiHandleDevice RawDevice { get; } + } +} diff --git a/userspace-backend/IO/ProfileReaderWriter.cs b/userspace-backend/IO/ProfileReaderWriter.cs index 175c7067..a1ea4bad 100644 --- a/userspace-backend/IO/ProfileReaderWriter.cs +++ b/userspace-backend/IO/ProfileReaderWriter.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using DATA = userspace_backend.Data; +using userspace_backend.IO.Serialization; namespace userspace_backend.IO { @@ -13,6 +14,7 @@ public class ProfileReaderWriter : ReaderWriterBase Converters = { new JsonStringEnumConverter(), + new AccelerationJsonConverter(), } }; diff --git a/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs b/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs index 7a86db15..2c27de18 100644 --- a/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs +++ b/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Threading.Tasks; using userspace_backend.Data.Profiles; @@ -115,10 +116,18 @@ private static FormulaAccel CreateFormulaAccel(string[] defnSplit, ref Utf8JsonR switch (formulaType) { + case AccelerationFormulaType.Synchronous: + return JsonSerializer.Deserialize(ref readerFromStart); case AccelerationFormulaType.Linear: return JsonSerializer.Deserialize(ref readerFromStart); case AccelerationFormulaType.Classic: return JsonSerializer.Deserialize(ref readerFromStart); + case AccelerationFormulaType.Power: + return JsonSerializer.Deserialize(ref readerFromStart); + case AccelerationFormulaType.Natural: + return JsonSerializer.Deserialize(ref readerFromStart); + case AccelerationFormulaType.Jump: + return JsonSerializer.Deserialize(ref readerFromStart); default: throw new JsonException($"Unknown formula type {formulaTypeString}"); } @@ -142,6 +151,41 @@ private static LookupTableAccel CreateLookupTableAccel(ref Utf8JsonReader reader public override void Write(Utf8JsonWriter writer, Acceleration value, JsonSerializerOptions options) { + // Serialize the runtime type's own fields, then patch the "Type" + // property to the discriminator string the Read side expects + // (e.g. "Formula/Classic", "LookupTable", "None"). Using a fresh + // options instance that excludes this converter avoids recursing + // into ourselves and hanging. + JsonNode? node = JsonSerializer.SerializeToNode( + value, value.GetType(), WriteOptionsWithoutSelf(options)); + + if (node is JsonObject obj) + { + obj["Type"] = GetDiscriminator(value); + // FormulaType is encoded inside the Type discriminator + // ("Formula/Classic"), so do not also emit it as a sibling. + obj.Remove("FormulaType"); + } + node?.WriteTo(writer); + } + + private static string GetDiscriminator(Acceleration v) => v switch + { + NoAcceleration => "None", + LookupTableAccel => "LookupTable", + FormulaAccel f => $"Formula/{f.FormulaType}", + _ => v.Type.ToString(), + }; + + private static JsonSerializerOptions WriteOptionsWithoutSelf(JsonSerializerOptions src) + { + var copy = new JsonSerializerOptions(src); + for (int i = copy.Converters.Count - 1; i >= 0; --i) + { + if (copy.Converters[i] is AccelerationJsonConverter) + copy.Converters.RemoveAt(i); + } + return copy; } } } diff --git a/userspace-backend/Model/AccelDefinitions/AccelDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/AccelDefinitionModel.cs index 17b194f1..560f5479 100644 --- a/userspace-backend/Model/AccelDefinitions/AccelDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/AccelDefinitionModel.cs @@ -1,5 +1,6 @@ using userspace_backend.Data.Profiles; using userspace_backend.Model.EditableSettings; +using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; namespace userspace_backend.Model.AccelDefinitions { diff --git a/userspace-backend/Model/AccelDefinitions/AccelerationModel.cs b/userspace-backend/Model/AccelDefinitions/AccelerationModel.cs index 36676d7d..1e050b2e 100644 --- a/userspace-backend/Model/AccelDefinitions/AccelerationModel.cs +++ b/userspace-backend/Model/AccelDefinitions/AccelerationModel.cs @@ -4,6 +4,7 @@ using userspace_backend.Model.EditableSettings; using userspace_backend.Model.ProfileComponents; using static userspace_backend.Data.Profiles.Acceleration; +using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; namespace userspace_backend.Model.AccelDefinitions { @@ -77,8 +78,8 @@ protected override bool TryMapEditableSettingsCollectionsFromData(Acceleration d else result &= Anisotropy.TryMapFromData(new Anisotropy { - Domain = new Vector2(), - Range = new Vector2(), + Domain = new Vector2 { X = 1, Y = 1 }, + Range = new Vector2 { X = 1, Y = 1 }, LPNorm = 2.0, CombineXYComponents = false }); diff --git a/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs index b1f0fb70..8aff0deb 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs @@ -2,6 +2,10 @@ using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; +using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using AccelMode = RawAccel.Contracts.AccelMode; +using CapMode = RawAccel.Contracts.CapMode; +using Vec2D = RawAccel.Contracts.Vec2; namespace userspace_backend.Model.AccelDefinitions.Formula { @@ -48,7 +52,7 @@ public AccelArgs MapToDriver() acceleration = Acceleration.ModelValue, exponentClassic = Exponent.ModelValue, inputOffset = Offset.ModelValue, - cap = new Vec2 { x = 0, y = Cap.ModelValue }, + cap = new Vec2D { x = 0, y = Cap.ModelValue }, capMode = CapMode.output }; } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs index a38c6131..0fdb76c5 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs @@ -2,6 +2,9 @@ using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; +using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using AccelMode = RawAccel.Contracts.AccelMode; +using Vec2D = RawAccel.Contracts.Vec2; namespace userspace_backend.Model.AccelDefinitions.Formula { @@ -40,7 +43,7 @@ public AccelArgs MapToDriver() { mode = AccelMode.jump, smooth = Smooth.ModelValue, - cap = new Vec2 { x = Input.ModelValue, y = Output.ModelValue }, + cap = new Vec2D { x = Input.ModelValue, y = Output.ModelValue }, }; } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs index 086a8bba..e1eee793 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs @@ -2,6 +2,10 @@ using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; +using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using AccelMode = RawAccel.Contracts.AccelMode; +using CapMode = RawAccel.Contracts.CapMode; +using Vec2D = RawAccel.Contracts.Vec2; namespace userspace_backend.Model.AccelDefinitions.Formula { @@ -42,7 +46,7 @@ public AccelArgs MapToDriver() acceleration = Acceleration.ModelValue, exponentClassic = 2, inputOffset = Offset.ModelValue, - cap = new Vec2 { x = 0, y = Cap.ModelValue }, + cap = new Vec2D { x = 0, y = Cap.ModelValue }, capMode = CapMode.output, }; } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs index a4abde4c..f28169f9 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs @@ -2,6 +2,8 @@ using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; +using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using AccelMode = RawAccel.Contracts.AccelMode; namespace userspace_backend.Model.AccelDefinitions.Formula { diff --git a/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs index 25c4df61..d8c034d6 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs @@ -3,6 +3,10 @@ using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; +using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using AccelMode = RawAccel.Contracts.AccelMode; +using CapMode = RawAccel.Contracts.CapMode; +using Vec2D = RawAccel.Contracts.Vec2; namespace userspace_backend.Model.AccelDefinitions.Formula { @@ -48,7 +52,7 @@ public AccelArgs MapToDriver() scale = Scale.ModelValue, exponentPower = Exponent.ModelValue, outputOffset = OutputOffset.ModelValue, - cap = new Vec2 { x = 0, y = Cap.ModelValue }, + cap = new Vec2D { x = 0, y = Cap.ModelValue }, capMode = CapMode.output, }; } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs index f8caf3b8..2ef2b089 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs @@ -2,6 +2,8 @@ using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; +using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using AccelMode = RawAccel.Contracts.AccelMode; namespace userspace_backend.Model.AccelDefinitions.Formula { diff --git a/userspace-backend/Model/AccelDefinitions/FormulaAccelModel.cs b/userspace-backend/Model/AccelDefinitions/FormulaAccelModel.cs index 22477d9f..9cb9379b 100644 --- a/userspace-backend/Model/AccelDefinitions/FormulaAccelModel.cs +++ b/userspace-backend/Model/AccelDefinitions/FormulaAccelModel.cs @@ -8,6 +8,7 @@ using userspace_backend.Model.AccelDefinitions.Formula; using userspace_backend.Model.EditableSettings; using static userspace_backend.Data.Profiles.Accel.FormulaAccel; +using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; namespace userspace_backend.Model.AccelDefinitions { @@ -49,7 +50,8 @@ protected override bool TryMapEditableSettingsCollectionsFromData(FormulaAccel d protected override bool TryMapEditableSettingsFromData(FormulaAccel data) { - return Gain.TryUpdateModelDirectly(data.Gain); + return FormulaType.TryUpdateModelDirectly(data.FormulaType) + & Gain.TryUpdateModelDirectly(data.Gain); } } } diff --git a/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs index a414a2fe..eb8ac4e7 100644 --- a/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs @@ -5,6 +5,8 @@ using userspace_backend.Data.Profiles.Accel; using userspace_backend.Model.EditableSettings; using static userspace_backend.Data.Profiles.Accel.LookupTableAccel; +using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using AccelMode = RawAccel.Contracts.AccelMode; namespace userspace_backend.Model.AccelDefinitions { diff --git a/userspace-backend/Model/AccelDefinitions/NoAccelDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/NoAccelDefinitionModel.cs index 5155103e..1022f478 100644 --- a/userspace-backend/Model/AccelDefinitions/NoAccelDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/NoAccelDefinitionModel.cs @@ -1,6 +1,8 @@ using userspace_backend.Data.Profiles; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Model.EditableSettings; +using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using AccelMode = RawAccel.Contracts.AccelMode; namespace userspace_backend.Model.AccelDefinitions { diff --git a/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs b/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs index 07ef4d30..d59be2da 100644 --- a/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs +++ b/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs @@ -46,7 +46,8 @@ protected EditableSettingsSelectable( public bool TryMapFromData(U data) { T dataCasted = data as T; - return dataCasted == null ? false : TryMapFromData(dataCasted); + // base. avoids re-binding to this U overload (T converts to U). + return dataCasted == null ? false : base.TryMapFromData(dataCasted); } U IEditableSettingsCollectionSpecific.MapToData() @@ -116,7 +117,8 @@ protected EditableSettingsSelectableSelector( public bool TryMapFromData(V data) { U dataCasted = data as U; - return dataCasted == null ? false : TryMapFromData(dataCasted); + // base. avoids re-binding to this V overload (U converts to V). + return dataCasted == null ? false : base.TryMapFromData(dataCasted); } V IEditableSettingsCollectionSpecific.MapToData() diff --git a/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs b/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs index 9476adad..075fb68a 100644 --- a/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs +++ b/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs @@ -77,10 +77,20 @@ protected override bool TryMapEditableSettingsFromData(Anisotropy data) { if (data == null) return false; - return DomainX.TryUpdateModelDirectly(data.Domain.X) - & DomainY.TryUpdateModelDirectly(data.Domain.Y) - & RangeX.TryUpdateModelDirectly(data.Range.X) - & RangeY.TryUpdateModelDirectly(data.Range.Y) + // Identity weights ((1,1),(1,1)) are the only meaningful defaults; a (0,0) vector + // was written by an older buggy fallback and produces a degenerate flat curve. + // Substitute identity for that specific corrupted shape on load. + Vector2 domain = (data.Domain == null || (data.Domain.X == 0 && data.Domain.Y == 0)) + ? new Vector2 { X = 1, Y = 1 } + : data.Domain; + Vector2 range = (data.Range == null || (data.Range.X == 0 && data.Range.Y == 0)) + ? new Vector2 { X = 1, Y = 1 } + : data.Range; + + return DomainX.TryUpdateModelDirectly(domain.X) + & DomainY.TryUpdateModelDirectly(domain.Y) + & RangeX.TryUpdateModelDirectly(range.X) + & RangeY.TryUpdateModelDirectly(range.Y) & LPNorm.TryUpdateModelDirectly(data.LPNorm) & CombineXYComponents.TryUpdateModelDirectly(data.CombineXYComponents); } diff --git a/userspace-backend/Model/ProfileModel.cs b/userspace-backend/Model/ProfileModel.cs index 329ad060..64c48358 100644 --- a/userspace-backend/Model/ProfileModel.cs +++ b/userspace-backend/Model/ProfileModel.cs @@ -12,6 +12,7 @@ using userspace_backend.Model.EditableSettings; using userspace_backend.Model.ProfileComponents; using DATA = userspace_backend.Data; +using Profile = RawAccel.Contracts.RawAccelProfile; namespace userspace_backend.Model { diff --git a/userspace-backend/Model/SystemDevices.cs b/userspace-backend/Model/SystemDevices.cs index 28fc8fd3..1831e08f 100644 --- a/userspace-backend/Model/SystemDevices.cs +++ b/userspace-backend/Model/SystemDevices.cs @@ -50,7 +50,9 @@ public void RefreshSystemDevices() } /// - /// Retrieves list of devices from operating system + /// Retrieves list of devices from operating system. Concrete impls are + /// per-platform; WindowsSystemDevicesRetriever reads RawInput via + /// wrapper.dll. /// public interface ISystemDevicesRetriever { @@ -58,20 +60,8 @@ public interface ISystemDevicesRetriever } /// - /// Application implementation of - /// - public sealed class SystemDevicesRetriever : ISystemDevicesRetriever - { - public IList GetSystemDevices() - { - IList rawDevices = MultiHandleDevice.GetList(); - return rawDevices.Select(d => new SystemDevice(d) as ISystemDevice).ToList(); - } - } - - /// - /// Interface to represent devices as they come from windows. - /// The actual class from windows is non-trivial to construct and test. + /// Interface to represent devices as they come from the operating system. + /// Backing impls live next to their per-platform retrievers. /// public interface ISystemDevice { @@ -79,21 +69,4 @@ public interface ISystemDevice public string HWID { get; } } - - /// - /// Data class to wrap - /// - public class SystemDevice : ISystemDevice - { - public SystemDevice(MultiHandleDevice multiHandleDevice) - { - RawDevice = multiHandleDevice; - } - - public string Name { get => RawDevice.name; } - - public string HWID { get => RawDevice.id; } - - private MultiHandleDevice RawDevice { get; } - } } diff --git a/userspace-backend/userspace-backend.csproj b/userspace-backend/userspace-backend.csproj index 87f76344..59c29e57 100644 --- a/userspace-backend/userspace-backend.csproj +++ b/userspace-backend/userspace-backend.csproj @@ -17,7 +17,10 @@ + + From abee9b03c7cc72989d7217fb553603e7f282d22a Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 27 May 2026 17:24:35 -0400 Subject: [PATCH 02/17] Transplant userspace-backend + GUI changes from userinterface-linux-hid-bpf Brings the backend hardening/cleanup and GUI work from userinterface-linux-hid-bpf onto the Windows abstraction branch, EXCLUDING the Linux driver implementations (userspace-backend/Driver/Linux/**). Faithful tree match of RawAccel.Contracts, userspace-backend, userspace-backend-tests, and userinterface to the hid-bpf branch, minus the 6 Linux driver files. Known dangling references to the excluded Linux classes (do not compile until reconciled): - userspace-backend/BackEndComposer.cs (Linux DI registration branch) - userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs --- RawAccel.Contracts/RawAccelConfig.cs | 4 +- userinterface/App.axaml.cs | 31 +- .../Services/MouseSpeedPollingService.cs | 112 ++++ userinterface/Services/SettingsService.cs | 6 +- .../Controls/EditableBoolViewModel.cs | 22 +- .../AnisotropyProfileSettingsViewModel.cs | 6 + .../Profile/ProfileChartViewModel.cs | 261 ++++++++- .../AnisotropyProfileSettingsView.axaml | 2 + .../Views/Profile/ProfileChartView.axaml | 7 + userinterface/userinterface.csproj | 12 +- .../DisplayTests/CurvePreviewDisposalTests.cs | 127 +++++ .../IOTests/ProfileReaderWriterTests.cs | 3 +- .../ModelTests/BackEndApplyTests.cs | 112 +++- .../ModelTests/EditableSettingsListTests.cs | 30 ++ .../ModelTests/EditableSettingsTests.cs | 14 + .../ModelTests/LookupTableDataTests.cs | 38 ++ .../ModelTests/TestParsersAndValidators.cs | 23 + .../ModelTests/ValidationTests.cs | 62 +++ .../ModelTests/WindowsSystemDevicesTests.cs | 9 +- .../AccelerationSerializationTests.cs | 8 +- .../userspace-backend-tests.csproj | 18 +- userspace-backend/BackEnd.cs | 69 ++- userspace-backend/BackEndComposer.cs | 506 +++++------------- userspace-backend/BackEndLoader.cs | 58 +- userspace-backend/Bootstrapper.cs | 8 +- userspace-backend/Common/DriverHelpers.cs | 51 -- userspace-backend/Data/Device.cs | 18 +- userspace-backend/Data/Mapping.cs | 59 +- userspace-backend/Data/Profile.cs | 9 +- .../Profiles/Accel/Formula/ClassicAccel.cs | 6 - .../Data/Profiles/Accel/Formula/JumpAccel.cs | 7 - .../Profiles/Accel/Formula/LinearAccel.cs | 6 - .../Profiles/Accel/Formula/NaturalAccel.cs | 10 +- .../Data/Profiles/Accel/Formula/PowerAccel.cs | 6 - .../Accel/Formula/SynchronousAccel.cs | 6 - .../Data/Profiles/Accel/FormulaAccel.cs | 12 +- .../Data/Profiles/Accel/LookupTableAccel.cs | 10 +- .../Data/Profiles/Accel/NoAcceleration.cs | 8 +- .../Data/Profiles/Acceleration.cs | 24 +- userspace-backend/Data/Profiles/Anisotropy.cs | 12 +- userspace-backend/Data/Profiles/Coalescion.cs | 6 - userspace-backend/Data/Profiles/Hidden.cs | 10 +- userspace-backend/Data/Profiles/Vector2.cs | 6 - userspace-backend/Data/Settings.cs | 73 +-- .../Calculations/CurveCalculationHelpers.cs | 19 +- userspace-backend/Display/CurvePreview.cs | 13 +- userspace-backend/Driver/IAccelEvaluator.cs | 27 +- userspace-backend/Driver/IRawAccelDriver.cs | 29 +- userspace-backend/Driver/MouseSpeedSample.cs | 12 + .../Driver/Windows/ManagedAccelEvaluator.cs | 4 + .../Driver/Windows/WindowsRawAccelDriver.cs | 32 +- .../Windows/WindowsSystemDevicesRetriever.cs | 7 +- userspace-backend/DriverConfigActivator.cs | 12 - userspace-backend/IO/DevicesReaderWriter.cs | 4 +- userspace-backend/IO/MappingsReaderWriter.cs | 4 +- userspace-backend/IO/ProfileReaderWriter.cs | 4 +- userspace-backend/IO/ReaderWriterBase.cs | 46 +- .../AccelerationJsonConverter.cs | 40 +- userspace-backend/IO/SettingsReaderWriter.cs | 17 +- .../Logging/EditableSettingLog.cs | 15 - .../Logging/FileLoggerProvider.cs | 9 +- .../ClassicAccelerationDefinitionModel.cs | 13 +- .../FormulaAccelerationDefinitionModel.cs | 29 + .../JumpAccelerationDefinitionModel.cs | 17 +- .../LinearAccelerationDefinitionModel.cs | 13 +- .../NaturalAccelerationDefinitionModel.cs | 11 +- .../PowerAccelerationDefinitionModel.cs | 19 +- .../SynchronousAccelerationDefinitionModel.cs | 11 +- .../LookupTableDefinitionModel.cs | 17 +- userspace-backend/Model/DevicesModel.cs | 6 +- .../Model/EditableSettings/EditableSetting.cs | 181 +------ .../EditableSettingsCollection.cs | 179 +++---- .../EditableSettings/EditableSettingsList.cs | 25 +- .../EditableSettings/IEditableSetting.cs | 2 +- .../EditableSettings/ModelValueValidator.cs | 48 +- .../Model/EditableSettings/UserInputParser.cs | 45 +- userspace-backend/Model/MappingModel.cs | 51 +- userspace-backend/Model/MappingsModel.cs | 5 +- .../ProfileComponents/AnisotropyModel.cs | 10 + userspace-backend/Model/ProfileModel.cs | 43 +- userspace-backend/Model/ProfilesModel.cs | 3 +- userspace-backend/Model/SystemDevices.cs | 5 +- userspace-backend/userspace-backend.csproj | 13 +- 83 files changed, 1643 insertions(+), 1284 deletions(-) create mode 100644 userinterface/Services/MouseSpeedPollingService.cs create mode 100644 userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs create mode 100644 userspace-backend-tests/ModelTests/LookupTableDataTests.cs create mode 100644 userspace-backend-tests/ModelTests/TestParsersAndValidators.cs create mode 100644 userspace-backend-tests/ModelTests/ValidationTests.cs delete mode 100644 userspace-backend/Common/DriverHelpers.cs create mode 100644 userspace-backend/Driver/MouseSpeedSample.cs delete mode 100644 userspace-backend/DriverConfigActivator.cs delete mode 100644 userspace-backend/Logging/EditableSettingLog.cs create mode 100644 userspace-backend/Model/AccelDefinitions/Formula/FormulaAccelerationDefinitionModel.cs diff --git a/RawAccel.Contracts/RawAccelConfig.cs b/RawAccel.Contracts/RawAccelConfig.cs index 2b2dd3e4..79dbf742 100644 --- a/RawAccel.Contracts/RawAccelConfig.cs +++ b/RawAccel.Contracts/RawAccelConfig.cs @@ -3,8 +3,8 @@ namespace RawAccel.Contracts { // Root JSON contract. Mirrors DriverConfig in wrapper/wrapper.cpp. - // The Windows wrapper IOCTL path consumes this exact shape; do not - // introduce divergent fields. + // Both the Windows wrapper IOCTL path and the Linux agent unix-socket + // path consume this exact shape; do not introduce divergent fields. public class RawAccelConfig { public string version { get; set; } = string.Empty; diff --git a/userinterface/App.axaml.cs b/userinterface/App.axaml.cs index 8ef2f20b..0ed33311 100644 --- a/userinterface/App.axaml.cs +++ b/userinterface/App.axaml.cs @@ -36,7 +36,7 @@ public override void Initialize() AvaloniaXamlLoader.Load(this); } -#if DEBUG +#if DEBUG && WINDOWS [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool AllocConsole(); @@ -82,7 +82,7 @@ private static void AttachConsoleStreams() public override void OnFrameworkInitializationCompleted() { -#if DEBUG +#if DEBUG && WINDOWS // Attach a console so backend ILogger output is visible alongside the UI window. AllocConsole(); AttachConsoleStreams(); @@ -104,7 +104,7 @@ public override void OnFrameworkInitializationCompleted() #endif }); - string settingsDirectory = System.AppDomain.CurrentDomain.BaseDirectory; + string settingsDirectory = ResolveSettingsDirectory(); services.AddSingleton(sp => { var devicesRW = sp.GetRequiredService(); @@ -123,6 +123,7 @@ public override void OnFrameworkInitializationCompleted() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddTransient(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -131,8 +132,6 @@ public override void OnFrameworkInitializationCompleted() Services = BackEndComposer.Compose(services); - EditableSettingLog.Configure(Services.GetRequiredService()); - IBackEnd backEnd = Services.GetRequiredService(); backEnd.Load(); backEnd.ImportSystemDevices(); @@ -434,6 +433,28 @@ await Task.Run(() => } } + // On Linux, settings live under $XDG_CONFIG_HOME/rawaccel (or + // $HOME/.config/rawaccel when XDG_CONFIG_HOME is unset/empty). On other + // OSes we keep the original behavior of writing next to the executable. + private static string ResolveSettingsDirectory() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return AppDomain.CurrentDomain.BaseDirectory; + } + + var xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + if (string.IsNullOrEmpty(xdg) || !Path.IsPathRooted(xdg)) + { + var home = Environment.GetFolderPath( + Environment.SpecialFolder.UserProfile); + xdg = Path.Combine(home, ".config"); + } + var dir = Path.Combine(xdg, "rawaccel"); + Directory.CreateDirectory(dir); + return dir; + } + private void ApplyStartupSettings() { try diff --git a/userinterface/Services/MouseSpeedPollingService.cs b/userinterface/Services/MouseSpeedPollingService.cs new file mode 100644 index 00000000..3fccb056 --- /dev/null +++ b/userinterface/Services/MouseSpeedPollingService.cs @@ -0,0 +1,112 @@ +using Avalonia.Threading; +using Microsoft.Extensions.Logging; +using System; +using System.Threading; +using System.Threading.Tasks; +using userspace_backend.Driver; + +namespace userinterface.Services +{ + // Background poller for the driver's current input-speed telemetry. + // Registered transient so each chart ViewModel owns its own instance. + // + // The poll runs on a background loop, NOT on FrameTimerService: that + // service is a UI-thread DispatcherTimer used to detect UI stalls, so a + // blocking unix-socket RPC there would stall the very thread it monitors. + // Here the RPC happens off the UI thread and only the parsed sample + // (a struct) is marshalled back via Dispatcher.UIThread.Post. + // + // No-op when the driver is null (headless/unsupported). When the agent is + // down the driver returns MouseSpeedSample.Zero cheaply (a File.Exists + // check, not a slow connect), so the loop is safe to keep running. + public sealed class MouseSpeedPollingService : IDisposable + { + // ~30 Hz. The chart's own animations are ~100 ms, so the indicator + // only needs to feel live, not be frame-perfect. Each poll opens a + // fresh AF_UNIX socket; 30 Hz keeps that churn modest. Drop to ~20 Hz + // if the per-call connect ever shows up in profiling. + private const int PollIntervalMs = 33; + + private readonly IRawAccelDriver? driver; + private readonly ILogger logger; + private readonly object gate = new(); + + private CancellationTokenSource? cts; + private Action? onSample; + + public MouseSpeedPollingService( + IRawAccelDriver? driver, + ILogger logger) + { + this.driver = driver; + this.logger = logger; + } + + public bool IsRunning { get; private set; } + + // Begins polling. onSample is always invoked on the UI thread. Idempotent. + public void Start(Action onSample) + { + lock (gate) + { + if (IsRunning) return; + if (driver is null) return; + + this.onSample = onSample; + cts = new CancellationTokenSource(); + IsRunning = true; + var token = cts.Token; + _ = Task.Run(() => LoopAsync(token)); + } + } + + public void Stop() + { + lock (gate) + { + if (!IsRunning) return; + IsRunning = false; + try { cts?.Cancel(); } catch { /* already disposed */ } + cts?.Dispose(); + cts = null; + onSample = null; + } + } + + private async Task LoopAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + MouseSpeedSample sample = MouseSpeedSample.Zero; + try + { + sample = driver!.GetCurrentMouseSpeedSample(); + } + catch (Exception ex) + { + logger.LogDebug(ex, "mouse speed poll failed"); + } + + if (token.IsCancellationRequested) break; + + var cb = onSample; + if (cb != null) + { + // Post (not Invoke) so the loop never blocks on UI work. + Dispatcher.UIThread.Post(() => cb(sample)); + } + + try + { + await Task.Delay(PollIntervalMs, token); + } + catch (TaskCanceledException) + { + break; + } + } + } + + public void Dispose() => Stop(); + } +} diff --git a/userinterface/Services/SettingsService.cs b/userinterface/Services/SettingsService.cs index 745e6296..313e9c3d 100644 --- a/userinterface/Services/SettingsService.cs +++ b/userinterface/Services/SettingsService.cs @@ -83,11 +83,7 @@ public bool TrySave(out string? errorMessage) errorMessage = null; try { - if (!backEnd.Apply()) - { - errorMessage = "Failed to apply settings to driver."; - return false; - } + backEnd.SaveToDisk(); return true; } catch (Exception ex) diff --git a/userinterface/ViewModels/Controls/EditableBoolViewModel.cs b/userinterface/ViewModels/Controls/EditableBoolViewModel.cs index a817a38a..8d4ea97f 100644 --- a/userinterface/ViewModels/Controls/EditableBoolViewModel.cs +++ b/userinterface/ViewModels/Controls/EditableBoolViewModel.cs @@ -13,10 +13,17 @@ public partial class EditableBoolViewModel : ViewModelBase private readonly LocalizationService localizationService; - public EditableBoolViewModel(BE.IEditableSetting settingBE, LocalizationService localizationService) + // When true, toggling the checkbox commits straight to the backend + // setting (so dependent logic, e.g. the chart, reacts immediately). + // Default false preserves the deferred-commit behavior other callers rely on. + private readonly bool autoCommit; + private bool suppressAutoCommit; + + public EditableBoolViewModel(BE.IEditableSetting settingBE, LocalizationService localizationService, bool autoCommit = false) { SettingBE = settingBE; this.localizationService = localizationService; + this.autoCommit = autoCommit; ResetValueFromBackEnd(); // Subscribe to language changes to update the Name property @@ -40,8 +47,15 @@ public bool TrySetFromInterface() return wasSet; } - private void ResetValueFromBackEnd() => + private void ResetValueFromBackEnd() + { + // Suppress auto-commit while we mirror the backend value into the + // display property, otherwise the resulting change event would + // re-commit (and recurse). + suppressAutoCommit = true; ValueInDisplay = bool.TryParse(SettingBE.InterfaceValue, out bool result) && result; + suppressAutoCommit = false; + } private string GetLocalizedName() { @@ -68,6 +82,10 @@ private void OnLanguageChanged(object? sender, PropertyChangedEventArgs e) partial void OnValueInDisplayChanged(bool value) { OnPropertyChanged(nameof(Value)); + if (autoCommit && !suppressAutoCommit) + { + TrySetFromInterface(); + } } } } \ No newline at end of file diff --git a/userinterface/ViewModels/Profile/AnisotropyProfileSettingsViewModel.cs b/userinterface/ViewModels/Profile/AnisotropyProfileSettingsViewModel.cs index 2b4fe88e..9e9622e6 100644 --- a/userinterface/ViewModels/Profile/AnisotropyProfileSettingsViewModel.cs +++ b/userinterface/ViewModels/Profile/AnisotropyProfileSettingsViewModel.cs @@ -14,6 +14,10 @@ public AnisotropyProfileSettingsViewModel(BE.IAnisotropyModel anisotropyBE, Loca RangeX = new EditableFieldViewModel(AnisotropyBE.RangeX); RangeY = new EditableFieldViewModel(AnisotropyBE.RangeY); LPNorm = new NamedEditableFieldViewModel(AnisotropyBE.LPNorm, localizationService); + // autoCommit so toggling the checkbox flows to the backend immediately; + // the chart subscribes to this setting to switch between one and two + // current-speed lines. + CombineXY = new EditableBoolViewModel(AnisotropyBE.CombineXYComponents, localizationService, autoCommit: true); } protected BE.IAnisotropyModel AnisotropyBE { get; } @@ -27,5 +31,7 @@ public AnisotropyProfileSettingsViewModel(BE.IAnisotropyModel anisotropyBE, Loca public EditableFieldViewModel RangeY { get; set; } public NamedEditableFieldViewModel LPNorm { get; set; } + + public EditableBoolViewModel CombineXY { get; set; } } } \ No newline at end of file diff --git a/userinterface/ViewModels/Profile/ProfileChartViewModel.cs b/userinterface/ViewModels/Profile/ProfileChartViewModel.cs index ffbdc07c..9613055b 100644 --- a/userinterface/ViewModels/Profile/ProfileChartViewModel.cs +++ b/userinterface/ViewModels/Profile/ProfileChartViewModel.cs @@ -2,6 +2,7 @@ using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Media.Immutable; +using Avalonia.Threading; using LiveChartsCore; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; @@ -18,6 +19,7 @@ using userinterface.Interfaces; using userinterface.Services; using userspace_backend.Display; +using userspace_backend.Driver; using userspace_backend.Model.EditableSettings; using BE = userspace_backend.Model; @@ -45,6 +47,15 @@ public partial class ProfileChartViewModel : ViewModelBase, IAsyncInitializable private const int StandardStrokeThickness = 1; private const float SubStrokeThickness = 0.5f; + // Speed-line smoothing: the poller delivers ~30 Hz targets; a UI-thread + // timer eases the displayed line position toward the latest target so it + // glides instead of teleporting. TimeConstant sets the glide speed (a + // larger value is smoother/laggier); Settle is the chart-unit threshold + // at which a line is treated as arrived (and a fading line snaps to 0). + private const int TweenIntervalMs = 16; // ~60 Hz + private const double TweenTimeConstantMs = 60.0; + private const double SpeedSettleEpsilon = 0.05; + // Color transparency values private const byte SubSeparatorAlpha = 100; @@ -65,33 +76,81 @@ public partial class ProfileChartViewModel : ViewModelBase, IAsyncInitializable private readonly IThemeService themeService; private readonly LocalizationService localizationService; private readonly PreviewChartRenderer previewRenderer; + private readonly MouseSpeedPollingService speedPoller; private BE.IProfileModel currentProfileModel = null!; - + + // Speed-line tween state. target* is the latest poller sample; disp* is the + // eased position actually rendered. The tweenTimer pumps disp -> target and + // self-stops once settled (restarted by ApplySpeedSample on a new target). + private DispatcherTimer? tweenTimer; + private DateTime lastTweenTick; + private double targetSpeedX, targetSpeedY, targetSpeedCombined; + private double dispSpeedX, dispSpeedY, dispSpeedCombined; + // Cached paint objects to avoid recreation private SolidColorPaint? cachedXStroke; private SolidColorPaint? cachedYStroke; - + + // Anisotropy "combine X and Y" flag for the active profile: drives whether + // one (combined) or two (per-axis) current-speed lines are shown. The + // section instances and their paints are rebuilt fresh each update so + // reassigning the bound Sections collection forces a chart redraw. + private IEditableSettingSpecific CombineXY { get; set; } = null!; + // Sync object for thread safety - single allocation private readonly object syncObject = new object(); - public ProfileChartViewModel(IThemeService themeService, LocalizationService localizationService, PreviewChartRenderer previewRenderer) + public ProfileChartViewModel(IThemeService themeService, LocalizationService localizationService, PreviewChartRenderer previewRenderer, MouseSpeedPollingService speedPoller) { this.themeService = themeService ?? throw new ArgumentNullException(nameof(themeService)); this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); this.previewRenderer = previewRenderer ?? throw new ArgumentNullException(nameof(previewRenderer)); + this.speedPoller = speedPoller ?? throw new ArgumentNullException(nameof(speedPoller)); RecreateAxesCommand = new RelayCommand(() => { EnsureInteractiveChartLoaded(); RecreateAxes(); }); - FitToDataCommand = new RelayCommand(() => + FitToDataCommand = new RelayCommand(() => { EnsureInteractiveChartLoaded(); FitToData(); }); + ToggleSpeedLinesCommand = new RelayCommand(() => ShowSpeedLines = !ShowSpeedLines); } + private bool showSpeedLines = true; + + // Whether the live current-speed indicator line(s) are shown. Toggled + // from the chart's button bar; hides/restores the lines immediately. + public bool ShowSpeedLines + { + get => showSpeedLines; + set + { + if (showSpeedLines == value) return; + showSpeedLines = value; + OnPropertyChanged(nameof(ShowSpeedLines)); + OnPropertyChanged(nameof(SpeedLinesIconOpacity)); + if (showSpeedLines) + { + // Reflect current positions immediately, then ease toward target. + RebuildSpeedSections(); + EnsureTweenRunning(); + } + else + { + StopTween(); + Sections = Array.Empty(); + OnPropertyChanged(nameof(Sections)); + } + } + } + + // Dims the toggle button's icon when the lines are hidden. + public double SpeedLinesIconOpacity => ShowSpeedLines ? 1.0 : 0.35; + public bool IsInitialized { get; private set; } public bool IsInitializing { get; private set; } @@ -123,6 +182,10 @@ public void Initialize(BE.IProfileModel profileModel) YXRatio = profileModel.YXRatio; YXRatio.PropertyChanged += OnYXRatioChanged; + + CombineXY = profileModel.Acceleration.Anisotropy.CombineXYComponents; + CombineXY.PropertyChanged += OnCombineXYChanged; + RebuildSpeedSections(); } @@ -293,10 +356,13 @@ private async void TransitionToInteractiveMode() // Small delay to ensure chart is rendered await Task.Delay(100); - + // Fade in interactive chart ChartOpacity = 1.0; OnPropertyChanged(nameof(ChartOpacity)); + + // Begin polling live mouse speed now that the chart is on screen. + StartSpeedPollingIfPossible(); } public Task SwitchToProfileAsync(BE.IProfileModel profileModel) @@ -304,6 +370,11 @@ public Task SwitchToProfileAsync(BE.IProfileModel profileModel) if (currentProfileModel == profileModel && IsInitialized) return Task.CompletedTask; + if (YXRatio != null) + YXRatio.PropertyChanged -= OnYXRatioChanged; + if (CombineXY != null) + CombineXY.PropertyChanged -= OnCombineXYChanged; + currentProfileModel = profileModel; XCurvePreview = profileModel.XCurvePreview; YCurvePreview = profileModel.YCurvePreview; @@ -311,6 +382,10 @@ public Task SwitchToProfileAsync(BE.IProfileModel profileModel) YXRatio.PropertyChanged += OnYXRatioChanged; + CombineXY = profileModel.Acceleration.Anisotropy.CombineXYComponents; + CombineXY.PropertyChanged += OnCombineXYChanged; + RebuildSpeedSections(); + // Update chart data synchronously for instant response CreateSeries(); @@ -319,6 +394,13 @@ public Task SwitchToProfileAsync(BE.IProfileModel profileModel) public ObservableCollection Series { get; set; } = new ObservableCollection(); + // Vertical current-speed indicator line(s). One section in combined mode, + // two (X and Y) in separate mode. Bound to CartesianChart.Sections. + // Reassigned (not mutated in place) on every update so the chart's + // property-change path re-renders: LiveCharts does not reliably redraw + // when a section already in the collection has its Xi/Xj mutated. + public IEnumerable Sections { get; private set; } = Array.Empty(); + public Axis[] XAxes { get; set; } = new Axis[] { new Axis { Name = "Loading...", MinLimit = 0, MaxLimit = 1 } }; public Axis[] YAxes { get; set; } = new Axis[] { new Axis { Name = "Loading...", MinLimit = 0, MaxLimit = 1 } }; @@ -331,6 +413,8 @@ public Task SwitchToProfileAsync(BE.IProfileModel profileModel) public ICommand FitToDataCommand { get; } + public ICommand ToggleSpeedLinesCommand { get; } + // ================================================================================================ // PUBLIC METHODS // ================================================================================================ @@ -380,7 +464,18 @@ public void Dispose() localizationService.PropertyChanged -= OnLocalizationChanged; if (YXRatio != null) YXRatio.PropertyChanged -= OnYXRatioChanged; - + if (CombineXY != null) + CombineXY.PropertyChanged -= OnCombineXYChanged; + + // Stop and release the live-speed poller and its tween pump. + speedPoller.Dispose(); + if (tweenTimer != null) + { + tweenTimer.Stop(); + tweenTimer.Tick -= OnTweenTick; + tweenTimer = null; + } + // Dispose cached paint objects if (cachedXStroke != null) { @@ -392,7 +487,7 @@ public void Dispose() cachedYStroke.Dispose(); cachedYStroke = null; } - + // Clear preview renderer cache for memory cleanup previewRenderer.ClearCache(); } @@ -481,19 +576,167 @@ private void CreateSeries() } } + // ================================================================================================ + // LIVE CURRENT-SPEED INDICATOR LINES + // ================================================================================================ + + // Builds a vertical line at the given speed: a zero-width section + // (Xi == Xj) stroked at the same thickness as the curve lines. A + // non-positive speed yields NaN bounds, which render nothing (hidden). + // A FRESH paint is created per call on purpose: reusing a paint across + // Sections reassignments makes LiveCharts dispose it when the previous + // section is removed, so a shared paint stops drawing after one frame. + private static RectangularSection MakeSpeedLine(double speed, SKColor color) + { + double x = speed > 0 ? speed : double.NaN; + return new RectangularSection + { + Xi = x, + Xj = x, + Fill = null, + Stroke = new SolidColorPaint(color) { StrokeThickness = MainStrokeThickness }, + }; + } + + // Builds the indicator line(s) for the given sample and reassigns the + // bound Sections collection. We hand the chart FRESH section instances + // each update because LiveCharts does not redraw when an existing + // section's Xi/Xj are mutated in place. + private void PublishSpeedSections(MouseSpeedSample sample) + { + if (!ShowSpeedLines) + { + Sections = Array.Empty(); + OnPropertyChanged(nameof(Sections)); + return; + } + + bool combined = CombineXY?.CurrentValidatedValue ?? true; + + // X/Y lines match the curve colors; combined uses a neutral theme color. + Sections = combined + ? new[] { MakeSpeedLine(sample.Combined, themeService.GetCachedColor(AxisLabelsBrush)) } + : new[] + { + MakeSpeedLine(sample.X, SKColors.CornflowerBlue), + MakeSpeedLine(sample.Y, SKColors.OrangeRed), + }; + OnPropertyChanged(nameof(Sections)); + } + + // Republishes the section(s) for the current mode at the current displayed + // (eased) positions. Called on init (disp* are 0, so hidden) and when the + // combine-X/Y mode or the show toggle changes, so the switch is seamless. + private void RebuildSpeedSections() => + PublishSpeedSections(new MouseSpeedSample(dispSpeedX, dispSpeedY, dispSpeedCombined)); + + // Called on the UI thread by the poller: record the new target and let the + // tween timer ease the displayed line(s) toward it (no direct publish). + private void ApplySpeedSample(MouseSpeedSample sample) + { + targetSpeedX = sample.X; + targetSpeedY = sample.Y; + targetSpeedCombined = sample.Combined; + EnsureTweenRunning(); + } + + // Starts the tween pump if there is anything to animate and the lines are + // visible/interactive. Cheap to call every poll: a no-op once settled. + private void EnsureTweenRunning() + { + if (!IsInteractiveMode || !ShowSpeedLines) return; + if (IsSpeedSettled()) return; + + if (tweenTimer == null) + { + tweenTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(TweenIntervalMs) }; + tweenTimer.Tick += OnTweenTick; + } + if (!tweenTimer.IsEnabled) + { + lastTweenTick = DateTime.UtcNow; + tweenTimer.Start(); + } + } + + private void StopTween() => tweenTimer?.Stop(); + + // True when every axis' displayed position has effectively reached its target. + private bool IsSpeedSettled() => + SpeedAxisSettled(dispSpeedX, targetSpeedX) && + SpeedAxisSettled(dispSpeedY, targetSpeedY) && + SpeedAxisSettled(dispSpeedCombined, targetSpeedCombined); + + private static bool SpeedAxisSettled(double disp, double target) => + Math.Abs(disp - target) < SpeedSettleEpsilon; + + // Frame-rate-independent exponential ease toward the target. A line fading + // out (target <= 0) snaps to 0 once close so MakeSpeedLine hides it cleanly. + private static double EaseSpeedAxis(double disp, double target, double alpha) + { + double next = disp + (target - disp) * alpha; + if (target <= 0 && next < SpeedSettleEpsilon) next = 0; + return next; + } + + private void OnTweenTick(object? sender, EventArgs e) + { + var now = DateTime.UtcNow; + double dtMs = (now - lastTweenTick).TotalMilliseconds; + lastTweenTick = now; + if (dtMs <= 0) dtMs = TweenIntervalMs; + + double alpha = 1.0 - Math.Exp(-dtMs / TweenTimeConstantMs); + if (alpha < 0) alpha = 0; + else if (alpha > 1) alpha = 1; + + dispSpeedX = EaseSpeedAxis(dispSpeedX, targetSpeedX, alpha); + dispSpeedY = EaseSpeedAxis(dispSpeedY, targetSpeedY, alpha); + dispSpeedCombined = EaseSpeedAxis(dispSpeedCombined, targetSpeedCombined, alpha); + + PublishSpeedSections(new MouseSpeedSample(dispSpeedX, dispSpeedY, dispSpeedCombined)); + + if (IsSpeedSettled()) + { + // Snap off residual sub-epsilon error, then idle until the next target. + dispSpeedX = targetSpeedX; + dispSpeedY = targetSpeedY; + dispSpeedCombined = targetSpeedCombined; + StopTween(); + } + } + + private void StartSpeedPollingIfPossible() + { + if (IsInteractiveMode) + { + speedPoller.Start(ApplySpeedSample); + } + } + // ================================================================================================ // EVENT HANDLERS // ================================================================================================ private void OnYXRatioChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(EditableSetting.CurrentValidatedValue)) + if (e.PropertyName == nameof(IEditableSettingSpecific.CurrentValidatedValue)) { CreateSeries(); OnPropertyChanged(nameof(Series)); } } + private void OnCombineXYChanged(object? sender, PropertyChangedEventArgs e) + { + // ModelValue is the observable property that raises change events; + // CurrentValidatedValue is a plain getter that never notifies. + if (e.PropertyName != nameof(IEditableSettingSpecific.ModelValue)) + return; + + Avalonia.Threading.Dispatcher.UIThread.Post(RebuildSpeedSections); + } + // ================================================================================================ // CHART AXES CREATION // ================================================================================================ @@ -598,6 +841,8 @@ private void OnThemeChanged(object? sender, EventArgs e) { TooltipTextPaint.Color = themeService.GetCachedColor(AxisTitleBrush); TooltipBackgroundPaint.Color = themeService.GetCachedColor(TooltipBackgroundBrush).WithAlpha(TooltipBackgroundAlpha); + // The combined-speed line reads its theme color fresh on each poll + // (see PublishSpeedSections), so no paint update is needed here. var currentXMin = XAxes?[0]?.MinLimit; var currentXMax = XAxes?[0]?.MaxLimit; diff --git a/userinterface/Views/Profile/AnisotropyProfileSettingsView.axaml b/userinterface/Views/Profile/AnisotropyProfileSettingsView.axaml index fe4dd823..3c502f36 100644 --- a/userinterface/Views/Profile/AnisotropyProfileSettingsView.axaml +++ b/userinterface/Views/Profile/AnisotropyProfileSettingsView.axaml @@ -73,6 +73,8 @@ + + diff --git a/userinterface/Views/Profile/ProfileChartView.axaml b/userinterface/Views/Profile/ProfileChartView.axaml index 6e0d3f5d..570df364 100644 --- a/userinterface/Views/Profile/ProfileChartView.axaml +++ b/userinterface/Views/Profile/ProfileChartView.axaml @@ -82,6 +82,7 @@ Classes="interactive-chart" SyncContext="{Binding Sync}" Series="{Binding Series}" + Sections="{Binding Sections, Mode=OneWay}" XAxes="{Binding XAxes}" YAxes="{Binding YAxes}" TooltipPosition="Top" @@ -109,6 +110,12 @@ ToolTip.Tip="{ext:Localized ProfileFitToData}"> + diff --git a/userinterface/userinterface.csproj b/userinterface/userinterface.csproj index a2d034f6..ccab82c4 100644 --- a/userinterface/userinterface.csproj +++ b/userinterface/userinterface.csproj @@ -1,17 +1,21 @@ WinExe - net8.0-windows10.0.22621.0 + net8.0-windows10.0.22621.0 + net8.0 enable - true true rawaccel - Assets\mouse.ico - app.manifest ..\x64\ x64 + + true + Assets\mouse.ico + app.manifest + + diff --git a/userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs b/userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs new file mode 100644 index 00000000..924a8b81 --- /dev/null +++ b/userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using RawAccel.Contracts; +using userspace_backend.Display; +using userspace_backend.Driver; +using userspace_backend.Driver.Linux; + +namespace userspace_backend_tests.DisplayTests +{ + // Regression tests for the IAccelInstance disposal contract. + // + // The bug: IAccelInstance had no IDisposable, so CurvePreview.GeneratePoints + // created a fresh instance on every refresh and could not free it. On Linux + // that instance (ShimInstance) holds a native ra_curve handle, so the preview + // leaked one handle per refresh until finalization. These tests lock in that + // GeneratePoints disposes what it creates and that the contract stays on the + // interface, with no native shim or GUI required. + [TestClass] + public class CurvePreviewDisposalTests + { + private sealed class FakeInstance : IAccelInstance + { + public int AccelerateCount { get; private set; } + public int DisposeCount { get; private set; } + public bool ThrowOnAccelerate { get; set; } + + public (double x, double y) Accelerate( + double x, double y, double dpiFactor, double timeMs) + { + AccelerateCount++; + if (ThrowOnAccelerate) throw new InvalidOperationException("boom"); + return (x, y); + } + + public void Dispose() => DisposeCount++; + } + + private sealed class FakeEvaluator : IAccelEvaluator + { + public List Created { get; } = new(); + public bool NextThrowsOnAccelerate { get; set; } + + public IAccelInstance CreateInstance(RawAccelProfile profile) + { + var instance = new FakeInstance { ThrowOnAccelerate = NextThrowsOnAccelerate }; + Created.Add(instance); + return instance; + } + } + + [TestMethod] + public void IAccelInstance_ImplementsIDisposable() + { + // Guards the contract: removing ": IDisposable" from the interface + // (which reintroduces the leak) fails here instead of silently. + Assert.IsTrue(typeof(IDisposable).IsAssignableFrom(typeof(IAccelInstance))); + } + + [TestMethod] + public void GeneratePoints_DisposesInstanceItCreated() + { + var evaluator = new FakeEvaluator(); + var preview = new CurvePreview(evaluator); + + preview.GeneratePoints(new RawAccelProfile()); + + Assert.AreEqual(1, evaluator.Created.Count, "expected one instance per call"); + FakeInstance instance = evaluator.Created[0]; + Assert.IsTrue(instance.AccelerateCount > 0, "instance should have been used"); + Assert.AreEqual(1, instance.DisposeCount, "instance must be disposed exactly once"); + } + + [TestMethod] + public void GeneratePoints_DisposesOneInstancePerCall() + { + var evaluator = new FakeEvaluator(); + var preview = new CurvePreview(evaluator); + + const int refreshes = 5; + for (int i = 0; i < refreshes; i++) + { + preview.GeneratePoints(new RawAccelProfile()); + } + + Assert.AreEqual(refreshes, evaluator.Created.Count); + foreach (FakeInstance instance in evaluator.Created) + { + Assert.AreEqual(1, instance.DisposeCount, + "every refresh's instance must be disposed exactly once"); + } + } + + [TestMethod] + public void GeneratePoints_DisposesInstance_WhenAccelerateThrows() + { + // Proves the call site uses using/try-finally, not just a trailing + // Dispose() that an exception would skip past. + var evaluator = new FakeEvaluator { NextThrowsOnAccelerate = true }; + var preview = new CurvePreview(evaluator); + preview.SetPoints(new[] { new CurvePoint { MouseSpeed = 5.0 } }); + + Assert.ThrowsException( + () => preview.GeneratePoints(new RawAccelProfile())); + + Assert.AreEqual(1, evaluator.Created.Count); + Assert.AreEqual(1, evaluator.Created[0].DisposeCount, + "instance must be disposed even when evaluation throws"); + } + + [TestMethod] + public void AccelInstance_DisposeIsIdempotent() + { + // Guards the ShimInstance double-free guard. With the native shim + // present this disposes a real ra_curve handle; without it, the + // evaluator returns the identity instance. Either way a second + // Dispose() must be a safe no-op (no double native Destroy). + var evaluator = new LinuxAccelEvaluator(); + IAccelInstance instance = evaluator.CreateInstance(new RawAccelProfile()); + + instance.Dispose(); + instance.Dispose(); + + Assert.IsNotNull(instance); + } + } +} diff --git a/userspace-backend-tests/IOTests/ProfileReaderWriterTests.cs b/userspace-backend-tests/IOTests/ProfileReaderWriterTests.cs index dbdaf841..7276d809 100644 --- a/userspace-backend-tests/IOTests/ProfileReaderWriterTests.cs +++ b/userspace-backend-tests/IOTests/ProfileReaderWriterTests.cs @@ -27,9 +27,8 @@ public void GivenValidInput_Writes() Name = "default", OutputDPI = 1200, YXRatio = 1.3333, - Acceleration = new Acceleration() + Acceleration = new NoAcceleration() { - Type = Acceleration.AccelerationDefinitionType.None, Anisotropy = new Anisotropy() { Domain = new Vector2() diff --git a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs index c1f8c7f9..9a3ef6b7 100644 --- a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs +++ b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs @@ -13,6 +13,7 @@ using userspace_backend.Model; using userspace_backend.Model.AccelDefinitions; using userspace_backend.Model.AccelDefinitions.Formula; +using userspace_backend.Model.EditableSettings; using DATA = userspace_backend.Data; namespace userspace_backend_tests.ModelTests @@ -59,9 +60,9 @@ private sealed class StubSystemDevice : ISystemDevice public string HWID { get; init; } = string.Empty; } - // Captures whatever the BackEnd hands to its driver. Implements - // IRawAccelDriver so these tests exercise the apply path without - // touching wrapper.dll or a real backend. + // Captures whatever the BackEnd hands to its driver. Cross-platform: + // implements IRawAccelDriver so the same tests run on Windows and Linux + // builds without touching wrapper.dll or the agent socket. private sealed class CapturingDriver : IRawAccelDriver { public RawAccelConfig? CapturedConfig { get; private set; } @@ -80,7 +81,7 @@ public bool Apply(RawAccelConfig config) public void Deactivate() { } - public double GetCurrentMouseSpeed() => 0; + public MouseSpeedSample GetCurrentMouseSpeedSample() => MouseSpeedSample.Zero; } private static (IBackEnd backEnd, CapturingDriver driver) BuildBackEndWithDefaults( @@ -109,6 +110,62 @@ private static RawAccelConfig ApplyAndCapture(IBackEnd backEnd, CapturingDriver return driver.CapturedConfig!; } + [TestMethod] + public void FormulaDIKeys_AreDistinctPerFormula_SoExponentDefaultsDoNotCollide() + { + // Regression: Power (and Jump) prefixed their DI keys with + // nameof(ClassicAccelerationDefinitionModel), so Power.ExponentDIKey + // equalled Classic.ExponentDIKey. AddEditableSetting uses + // AddKeyedTransient (last registration wins), so Classic's Exponent + // silently resolved to Power's default (0.05) instead of its own (2). + Assert.AreNotEqual( + ClassicAccelerationDefinitionModel.ExponentDIKey, + PowerAccelerationDefinitionModel.ExponentDIKey, + "Classic and Power exponent DI keys must be distinct."); + Assert.AreNotEqual( + ClassicAccelerationDefinitionModel.CapDIKey, + PowerAccelerationDefinitionModel.CapDIKey, + "Classic and Power cap DI keys must be distinct."); + + var services = new ServiceCollection(); + services.AddSingleton(new StubBackEndLoader()); + services.AddSingleton(new StubSystemDevicesRetriever()); + services.AddSingleton(new CapturingDriver()); + var sp = BackEndComposer.Compose(services); + + var classicExponent = sp.GetRequiredKeyedService>( + ClassicAccelerationDefinitionModel.ExponentDIKey); + var powerExponent = sp.GetRequiredKeyedService>( + PowerAccelerationDefinitionModel.ExponentDIKey); + + Assert.AreEqual(2.0, classicExponent.ModelValue, "Classic exponent default should be 2."); + Assert.AreEqual(0.05, powerExponent.ModelValue, "Power exponent default should be 0.05."); + } + + [TestMethod] + public void RemovingReferencedProfile_ReassignsMappingToDefault() + { + var (backEnd, _) = BuildBackEndWithDefaults(); + + Assert.IsTrue(backEnd.Profiles.TryAddNewDefaultProfile("Gaming")); + backEnd.Devices.DeviceGroups.AddOrGetDeviceGroup("MyGroup"); + + MappingModel mapping = backEnd.Mappings.GetActiveMapping()!; + Assert.IsNotNull(mapping); + Assert.IsTrue(mapping.TryAddMapping("MyGroup", "Gaming")); + + MappingGroup group = mapping.IndividualMappings.Single(g => + string.Equals(g.DeviceGroup, "MyGroup", StringComparison.InvariantCultureIgnoreCase)); + Assert.AreEqual("Gaming", group.Profile.Name.ModelValue); + + // Delete the referenced profile. + Assert.IsTrue(backEnd.Profiles.TryGetProfile("Gaming", out IProfileModel? gaming) && gaming != null); + Assert.IsTrue(backEnd.Profiles.RemoveProfile(gaming!)); + + // The mapping entry must fall back to the default profile, not keep a dangling reference. + Assert.AreEqual("Default", group.Profile.Name.ModelValue); + } + [TestMethod] public void EnsureDefaultMapping_FreshInstall_CreatesMappingWithDefaultEntry() { @@ -361,6 +418,53 @@ public void Apply_ProfileCurveCoefficientEdit_FlowsIntoDriverConfig() "propagating nested sub-model changes up to ProfileModel."); } + [TestMethod] + public void Apply_SingleCurve_PopulatesBothAxes() + { + // Regression: ProfileModel.MapToDriver used to set only argsX, leaving argsY + // at its noaccel default. With the default by-component anisotropy mode + // (CombineXYComponents == false) the native math indexes Y through argsY, + // so vertical acceleration was silently dead while horizontal worked and + // the flat sens multipliers still applied. The single model curve must + // drive BOTH argsX and argsY. + var (backEnd, driver) = BuildBackEndWithDefaults(); + var profile = backEnd.Profiles.Elements[0]; + + Assert.IsTrue( + profile.Acceleration.DefinitionType.TryUpdateModelDirectly( + Acceleration.AccelerationDefinitionType.Formula), + "Flipping DefinitionType to Formula should succeed."); + + var formulaAccel = (FormulaAccelModel)profile.Acceleration.GetSelectable( + Acceleration.AccelerationDefinitionType.Formula); + + Assert.IsTrue( + formulaAccel.FormulaType.TryUpdateModelDirectly( + FormulaAccel.AccelerationFormulaType.Classic), + "Flipping FormulaType to Classic should succeed."); + + var classic = (ClassicAccelerationDefinitionModel)formulaAccel.GetSelectable( + FormulaAccel.AccelerationFormulaType.Classic); + + const double expectedAcceleration = 0.123; + Assert.IsTrue( + classic.Acceleration.TryUpdateModelDirectly(expectedAcceleration), + "Classic.Acceleration update should succeed."); + + var cfg = ApplyAndCapture(backEnd, driver); + + // X is the historically-tested axis; Y is the regression guard. + Assert.AreEqual(AccelMode.classic, cfg.profiles[0].argsY.mode, + "argsY must carry the same accel mode as argsX, or vertical acceleration " + + "is dead in by-component mode (argsY left at the noaccel default)."); + Assert.AreEqual(expectedAcceleration, cfg.profiles[0].argsY.acceleration, + "argsY must carry the same curve coefficient as argsX."); + Assert.AreEqual(cfg.profiles[0].argsX.mode, cfg.profiles[0].argsY.mode, + "The single model curve must drive both axes identically."); + Assert.AreEqual(cfg.profiles[0].argsX.acceleration, cfg.profiles[0].argsY.acceleration, + "The single model curve must drive both axes identically."); + } + // Regression: a saved ClassicAccel used to StackOverflow on Load via // EditableSettingsSelectable.TryMapFromData recursing into itself. private sealed class ClassicAccelLoader : IBackEndLoader diff --git a/userspace-backend-tests/ModelTests/EditableSettingsListTests.cs b/userspace-backend-tests/ModelTests/EditableSettingsListTests.cs index 2eaf163b..141a61d7 100644 --- a/userspace-backend-tests/ModelTests/EditableSettingsListTests.cs +++ b/userspace-backend-tests/ModelTests/EditableSettingsListTests.cs @@ -222,6 +222,36 @@ public void EditableSettingsList_AddRemoveElements() Assert.AreEqual(2, testObject.Elements.Count); } + [TestMethod] + public void EditableSettingsList_MapFromData_RemovesStaleElementsAndUpdatesKept() + { + // Regression: the list previously only added/updated on data load and + // never removed elements that the new data no longer contained. + (IEditableSettingsTestList testObject, _) = InitTestObject("Property", 2, "Name", "seed"); + + testObject.TryMapFromData(new[] + { + new TestData { Name = "A", Property = 1 }, + new TestData { Name = "B", Property = 2 }, + new TestData { Name = "C", Property = 3 }, + }); + Assert.AreEqual(3, testObject.Elements.Count); + + // Reload with a subset: C must be dropped, A and B kept and updated. + testObject.TryMapFromData(new[] + { + new TestData { Name = "A", Property = 10 }, + new TestData { Name = "B", Property = 20 }, + }); + + Assert.AreEqual(2, testObject.Elements.Count); + CollectionAssert.AreEquivalent( + new[] { "A", "B" }, + testObject.Elements.Select(e => e.NameSetting.ModelValue).ToList()); + Assert.IsFalse(testObject.TryGetElement("C", out _), "Stale element C should have been removed."); + Assert.IsTrue(testObject.TryGetElement("A", out var a) && a!.PropertySetting.ModelValue == 10); + } + #endregion Tests } } diff --git a/userspace-backend-tests/ModelTests/EditableSettingsTests.cs b/userspace-backend-tests/ModelTests/EditableSettingsTests.cs index 517e06ca..56495f60 100644 --- a/userspace-backend-tests/ModelTests/EditableSettingsTests.cs +++ b/userspace-backend-tests/ModelTests/EditableSettingsTests.cs @@ -138,6 +138,20 @@ void TestObject_PropertyChanged(object? sender, System.ComponentModel.PropertyCh Assert.AreEqual(0, propertyChangedHookCalls); } + [TestMethod] + public void EditableSetting_HasChanged_ReflectsWhetherValueDiffersFromLastWritten() + { + // Regression: HasChanged() previously returned CompareTo == 0, i.e. true + // when the value was UNCHANGED (inverted relative to its name). + EditableSettingV2 testObject = InitTestObject("Test Setting", 0); + + Assert.IsFalse(testObject.HasChanged(), "An untouched setting must not report as changed."); + + Assert.IsTrue(testObject.TryUpdateModelDirectly(500)); + + Assert.IsTrue(testObject.HasChanged(), "After changing away from the initial value, must report as changed."); + } + #endregion Tests } } diff --git a/userspace-backend-tests/ModelTests/LookupTableDataTests.cs b/userspace-backend-tests/ModelTests/LookupTableDataTests.cs new file mode 100644 index 00000000..a1ea6347 --- /dev/null +++ b/userspace-backend-tests/ModelTests/LookupTableDataTests.cs @@ -0,0 +1,38 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using userspace_backend.Model.AccelDefinitions; + +namespace userspace_backend_tests.ModelTests +{ + [TestClass] + public class LookupTableDataTests + { + // Regression: CompareTo previously did `obj as double[]`, but the value + // passed in is always a LookupTableData, so the cast was always null and + // CompareTo always returned -1 ("not equal"), even for identical tables. + [TestMethod] + public void CompareTo_EqualData_ReportsEqual() + { + var a = new LookupTableData(new double[] { 1, 2, 3, 4 }); + var b = new LookupTableData(new double[] { 1, 2, 3, 4 }); + + Assert.AreEqual(0, a.CompareTo(b)); + } + + [TestMethod] + public void CompareTo_DifferentData_ReportsNotEqual() + { + var a = new LookupTableData(new double[] { 1, 2, 3, 4 }); + var c = new LookupTableData(new double[] { 1, 2, 3, 5 }); + + Assert.AreNotEqual(0, a.CompareTo(c)); + } + + [TestMethod] + public void CompareTo_Null_ReportsNotEqual() + { + var a = new LookupTableData(new double[] { 1, 2 }); + + Assert.AreNotEqual(0, a.CompareTo(null)); + } + } +} diff --git a/userspace-backend-tests/ModelTests/TestParsersAndValidators.cs b/userspace-backend-tests/ModelTests/TestParsersAndValidators.cs new file mode 100644 index 00000000..2dfe86b0 --- /dev/null +++ b/userspace-backend-tests/ModelTests/TestParsersAndValidators.cs @@ -0,0 +1,23 @@ +using userspace_backend.Model.EditableSettings; + +namespace userspace_backend_tests.ModelTests +{ + /// + /// Shared parser instances for model tests. These were lost during the DI + /// refactor, which left the EditableSettings* test files uncompilable (and + /// therefore excluded from the build). Restoring them revives that coverage. + /// + internal static class UserInputParsers + { + public static IUserInputParser IntParser { get; } = new IntParser(); + + public static IUserInputParser StringParser { get; } = new StringParser(); + } + + internal static class ModelValueValidators + { + public static IModelValueValidator DefaultIntValidator { get; } = new DefaultModelValueValidator(); + + public static IModelValueValidator DefaultStringValidator { get; } = new DefaultModelValueValidator(); + } +} diff --git a/userspace-backend-tests/ModelTests/ValidationTests.cs b/userspace-backend-tests/ModelTests/ValidationTests.cs new file mode 100644 index 00000000..bf0dc313 --- /dev/null +++ b/userspace-backend-tests/ModelTests/ValidationTests.cs @@ -0,0 +1,62 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using userspace_backend.Model.EditableSettings; + +namespace userspace_backend_tests.ModelTests +{ + [TestClass] + public class ValidationTests + { + [TestMethod] + public void RangeValidator_InclusiveMin_RejectsBelowAcceptsAtAndAbove() + { + var v = new RangeValidator(min: 1); + + Assert.IsFalse(v.Validate(0), "0 is below an inclusive min of 1."); + Assert.IsFalse(v.Validate(-5)); + Assert.IsTrue(v.Validate(1), "1 equals an inclusive min of 1."); + Assert.IsTrue(v.Validate(1000)); + } + + [TestMethod] + public void RangeValidator_ExclusiveMin_RejectsBoundary() + { + var v = new RangeValidator(min: 0, minInclusive: false); + + Assert.IsFalse(v.Validate(0), "0 is excluded when min is exclusive."); + Assert.IsFalse(v.Validate(-0.1)); + Assert.IsTrue(v.Validate(0.0001)); + Assert.IsTrue(v.Validate(2)); + } + + [TestMethod] + public void RangeValidator_InclusiveMinZero_AllowsZero() + { + var v = new RangeValidator(min: 0); + + Assert.IsTrue(v.Validate(0)); + Assert.IsFalse(v.Validate(-1)); + } + + [TestMethod] + public void RangeValidator_MaxBound_IsInclusiveByDefault() + { + var v = new RangeValidator(min: 1, max: 10); + + Assert.IsTrue(v.Validate(10)); + Assert.IsFalse(v.Validate(11)); + } + + [TestMethod] + public void DoubleParser_RejectsNaNAndInfinity() + { + var parser = new DoubleParser(); + + Assert.IsFalse(parser.TryParse("NaN", out _)); + Assert.IsFalse(parser.TryParse("Infinity", out _)); + Assert.IsFalse(parser.TryParse("-Infinity", out _)); + + Assert.IsTrue(parser.TryParse("1.5", out double value)); + Assert.AreEqual(1.5, value); + } + } +} diff --git a/userspace-backend-tests/ModelTests/WindowsSystemDevicesTests.cs b/userspace-backend-tests/ModelTests/WindowsSystemDevicesTests.cs index f7a6fc23..019a3c1f 100644 --- a/userspace-backend-tests/ModelTests/WindowsSystemDevicesTests.cs +++ b/userspace-backend-tests/ModelTests/WindowsSystemDevicesTests.cs @@ -6,9 +6,12 @@ namespace userspace_backend_tests.ModelTests { - // Windows-only retriever test: asserts the Windows RawInput-based - // retriever returns at least one mouse. Skip on a headless build server - // where no mouse is connected. + // Windows-only retriever test. Lives in a separate file from + // SystemDevicesTests so the cross-platform provider test can run on Linux. + // Excluded from non-Windows builds via the same csproj Compile Remove rule + // that hides BackEndApplyTests.cs (kept Windows-only originally). + // Asserts the Windows RawInput-based retriever returns at least one mouse; + // skip on a headless build server where no mouse is connected. [TestClass] public class WindowsSystemDevicesTests { diff --git a/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs b/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs index 5673d30d..17355bf2 100644 --- a/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs +++ b/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs @@ -183,12 +183,12 @@ public void SerializeLookupTableVelocity() ], "Anisotropy": { "Domain": { - "X": 0, - "Y": 0 + "X": 1, + "Y": 1 }, "Range": { - "X": 0, - "Y": 0 + "X": 1, + "Y": 1 }, "LPNorm": 2, "CombineXYComponents": false diff --git a/userspace-backend-tests/userspace-backend-tests.csproj b/userspace-backend-tests/userspace-backend-tests.csproj index 38203f6e..9332b22d 100644 --- a/userspace-backend-tests/userspace-backend-tests.csproj +++ b/userspace-backend-tests/userspace-backend-tests.csproj @@ -25,20 +25,20 @@ + + + + - - - - - - + + + Always diff --git a/userspace-backend/BackEnd.cs b/userspace-backend/BackEnd.cs index 05cdb52c..03ef39e4 100644 --- a/userspace-backend/BackEnd.cs +++ b/userspace-backend/BackEnd.cs @@ -5,9 +5,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using RawAccel.Contracts; -using userspace_backend.Data.Profiles; using userspace_backend.Driver; -using userspace_backend.IO; using userspace_backend.Model; using DATA = userspace_backend.Data; using Profile = RawAccel.Contracts.RawAccelProfile; @@ -75,18 +73,13 @@ public BackEnd( public void Load() { List devicesData = BackEndLoader.LoadDevices().ToList(); - LoadDevicesFromData(devicesData); + Devices.TryMapFromData(devicesData); - IEnumerable profilesData = BackEndLoader.LoadProfiles(); - LoadProfilesFromData(profilesData); + List profilesData = BackEndLoader.LoadProfiles().ToList(); + Profiles.TryMapFromData(profilesData); DATA.MappingSet mappingData = BackEndLoader.LoadMappings(); - // DeviceGroups.DeviceGroupModels is the master list the UI and - // MappingModel.TryAddMapping look up against. It is not serialized - // directly: group names live implicitly inside devices.json (per - // device) and mappings.json (as map keys). Restore the list before - // applying mappings so non-Default rows are not silently dropped. RestoreDeviceGroupsFromData(devicesData, mappingData); LoadMappingsFromData(mappingData); @@ -123,19 +116,9 @@ protected void RestoreDeviceGroupsFromData( } } - protected void LoadDevicesFromData(IEnumerable devicesData) - { - Devices.TryMapFromData(devicesData); - } - - protected void LoadProfilesFromData(IEnumerable profileData) - { - Profiles.TryMapFromData(profileData); - } - protected void LoadMappingsFromData(DATA.MappingSet mappingData) { - // Clear existing mappings and reload from data + // Clear existing mappings and reload Mappings.Mappings.Clear(); foreach (var mapping in mappingData.Mappings) { @@ -145,7 +128,6 @@ protected void LoadMappingsFromData(DATA.MappingSet mappingData) protected void EnsureDefaultDeviceGroupExists() { - // If no device groups exist, create a "Default" group if (Devices.DeviceGroups.DeviceGroupModels.Count == 0) { Devices.DeviceGroups.AddOrGetDeviceGroup(DeviceGroups.DefaultDeviceGroup); @@ -166,12 +148,13 @@ protected void EnsureDefaultDeviceExists() return; } + // TODO: This case is very niche, considering just not adding a + // default at all to show that something is wrong. var defaultDevice = ServiceProvider.GetRequiredService(); defaultDevice.Name.TryUpdateModelDirectly("Default"); defaultDevice.HardwareID.TryUpdateModelDirectly("DEFAULT_DEVICE_ID"); defaultDevice.DeviceGroup.TryUpdateModelDirectly(DeviceGroups.DefaultDeviceGroup); - // DPI, PollRate, and Ignore already have sensible defaults from DI (1000, 1000, false) - + Devices.TryInsert(0, defaultDevice); } @@ -184,6 +167,7 @@ public void ImportSystemDevices() continue; } + // When reloading new devices list this will trigger bool alreadyPresent = Devices.Elements.Any(d => string.Equals(d.HardwareID.ModelValue, systemDevice.HWID, StringComparison.OrdinalIgnoreCase)); if (alreadyPresent) @@ -195,7 +179,7 @@ public void ImportSystemDevices() device.Name.TryUpdateModelDirectly(systemDevice.Name); device.HardwareID.TryUpdateModelDirectly(systemDevice.HWID); device.DeviceGroup.TryUpdateModelDirectly(DeviceGroups.DefaultDeviceGroup); - // DPI / PollRate / Ignore keep their DI-provided defaults. + // DPI / PollRate / Use their DI-provided defaults. Devices.TryAdd(device); } } @@ -224,7 +208,6 @@ public void ReloadSystemDevices() protected void EnsureDefaultProfileExists() { - // If no profiles exist, create a default profile if (Profiles.Elements.Count == 0) { var defaultProfile = ServiceProvider.GetRequiredService(); @@ -235,8 +218,8 @@ protected void EnsureDefaultProfileExists() protected void EnsureDefaultMappingExists() { - // Ensure a Default mapping object exists in the list. - if (!Mappings.TryGetMapping("Default", out _)) + // Create a Default mapping when none exist at all (fresh install). + if (Mappings.Mappings.Count == 0) { Mappings.TryAddMapping(new DATA.Mapping { @@ -245,7 +228,9 @@ protected void EnsureDefaultMappingExists() }); } - // Explicitly wire the DefaultDeviceGroup to "Default" profile entry. + // Self-heal: a Default mapping that exists but lacks the DefaultDeviceGroup + // entry (e.g. a stale mappings.json with an empty GroupsToProfiles) must get + // one. TryAddMapping is idempotent, so this no-ops when it is already mapped. if (Mappings.TryGetMapping("Default", out MappingModel? defaultMapping) && defaultMapping != null) { defaultMapping.TryAddMapping(DeviceGroups.DefaultDeviceGroup, "Default"); @@ -265,7 +250,7 @@ public bool Apply() MappingModel? mappingToApply = Mappings.GetMappingToSetActive(); if (mappingToApply == null) { - logger.LogWarning("Apply: no active mapping to apply"); + logger.LogError("Apply: Invalid state, no active mapping to apply"); WriteSettingsToDisk(); return false; } @@ -285,14 +270,21 @@ public bool Apply() bool driverApplied = false; if (config != null) { - driverApplied = driver.Apply(config); - if (driverApplied) + try { - logger.LogInformation("Apply: driver.Apply() succeeded"); + driverApplied = driver.Apply(config); + if (driverApplied) + { + logger.LogInformation("Apply: driver.Apply() succeeded"); + } + else + { + logger.LogError("Apply: driver.Apply() failed"); + } } - else + catch (Exception ex) { - logger.LogError("Apply: driver.Apply() failed"); + logger.LogError(ex, "Apply: driver.Apply() threw"); } } @@ -350,6 +342,8 @@ private void LogDriverConfigJson(RawAccelConfig config) } } + // TODO: These functions can be factored out later + // Leave here for test/debug protected void WriteSettingsToDisk() { BackEndLoader.WriteSettingsToDisk( @@ -417,6 +411,11 @@ protected DeviceSettings MapToDriverDevice(IDeviceModel deviceModel, string prof disable = deviceModel.Ignore.ModelValue, dpi = deviceModel.DPI.ModelValue, pollingRate = deviceModel.PollRate.ModelValue, + // Not yet surfaced in the UI/device model: these are the driver's + // expected defaults for poll-time clamping and extra-info passthrough. + // maximumTime/minimumTime bound the per-packet time delta (ms) the + // driver will trust. Keep in sync with the driver-side defaults if + // they ever become user-configurable. pollTimeLock = false, setExtraInfo = false, maximumTime = 200, diff --git a/userspace-backend/BackEndComposer.cs b/userspace-backend/BackEndComposer.cs index 3c12d98c..289ff1fb 100644 --- a/userspace-backend/BackEndComposer.cs +++ b/userspace-backend/BackEndComposer.cs @@ -1,10 +1,12 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using System; +using System.Runtime.InteropServices; using DATA = userspace_backend.Data; using userspace_backend.Display; using userspace_backend.Driver; -using userspace_backend.Driver.Windows; +using userspace_backend.Driver.Linux; using userspace_backend.IO; using userspace_backend.Model; using userspace_backend.Model.AccelDefinitions; @@ -31,9 +33,9 @@ public static IServiceProvider Compose(IServiceCollection services) services.AddSingleton, IntParser>(); services.AddSingleton, DoubleParser>(); services.AddSingleton, BoolParser>(); - services.AddSingleton, AccelerationDefinitionTypeParser>(); - services.AddSingleton, AccelerationFormulaTypeParser>(); - services.AddSingleton, LookupTableTypeParser>(); + services.AddSingleton, EnumParser>(); + services.AddSingleton, EnumParser>(); + services.AddSingleton, EnumParser>(); services.AddSingleton, LookupTableDataParser>(); #endregion Parsers @@ -60,129 +62,44 @@ public static IServiceProvider Compose(IServiceCollection services) #region Hidden services.AddTransient(); - services.AddKeyedTransient>( - HiddenModel.RotationDegreesDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Rotation", - initialValue: 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - HiddenModel.AngleSnappingDegreesDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Angle Snapping", - initialValue: 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - HiddenModel.LeftRightRatioDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "L/R Ratio", - initialValue: 1, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - HiddenModel.UpDownRatioDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "U/D Ratio", - initialValue: 1, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - HiddenModel.SpeedCapDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Speed Cap", - initialValue: 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - HiddenModel.OutputSmoothingHalfLifeDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Output Smoothing Half-Life", - initialValue: 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, HiddenModel.RotationDegreesDIKey, "Rotation", 0); + AddEditableSetting(services, HiddenModel.AngleSnappingDegreesDIKey, "Angle Snapping", 0); + AddEditableSetting(services, HiddenModel.LeftRightRatioDIKey, "L/R Ratio", 1); + AddEditableSetting(services, HiddenModel.UpDownRatioDIKey, "U/D Ratio", 1); + AddEditableSetting(services, HiddenModel.SpeedCapDIKey, "Speed Cap", 0); + AddEditableSetting(services, HiddenModel.OutputSmoothingHalfLifeDIKey, "Output Smoothing Half-Life", 0, + validatorFactory: sp => new RangeValidator(min: 0)); #endregion Hidden #region Coalescion services.AddTransient(); - services.AddKeyedTransient>( - CoalescionModel.InputSmoothingHalfLifeDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Input Smoothing Half-Life", - initialValue: 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - CoalescionModel.ScaleSmoothingHalfLifeDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Scale Smoothing Half-Life", - initialValue: 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, CoalescionModel.InputSmoothingHalfLifeDIKey, "Input Smoothing Half-Life", 0, + validatorFactory: sp => new RangeValidator(min: 0)); + AddEditableSetting(services, CoalescionModel.ScaleSmoothingHalfLifeDIKey, "Scale Smoothing Half-Life", 0, + validatorFactory: sp => new RangeValidator(min: 0)); #endregion Coalescion #region Anisotropy services.AddTransient(); - services.AddKeyedTransient>( - AnisotropyModel.DomainXDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Domain X", - initialValue: 1, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - AnisotropyModel.DomainYDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Domain Y", - initialValue: 1, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - AnisotropyModel.RangeXDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Range X", - initialValue: 1, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - AnisotropyModel.RangeYDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Range Y", - initialValue: 1, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - AnisotropyModel.LPNormDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "LP Norm", - initialValue: 2, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - AnisotropyModel.CombineXYComponentsDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Combine X and Y Components", - initialValue: false, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, AnisotropyModel.DomainXDIKey, "Domain X", 1); + AddEditableSetting(services, AnisotropyModel.DomainYDIKey, "Domain Y", 1); + AddEditableSetting(services, AnisotropyModel.RangeXDIKey, "Range X", 1); + AddEditableSetting(services, AnisotropyModel.RangeYDIKey, "Range Y", 1); + AddEditableSetting(services, AnisotropyModel.LPNormDIKey, "LP Norm", 2, + validatorFactory: sp => new RangeValidator(min: 0, minInclusive: false)); + AddEditableSetting(services, AnisotropyModel.CombineXYComponentsDIKey, "Combine X and Y Components", false, + localizationKey: "AnisotropyCombineXY"); #endregion Anisotropy #region Acceleration services.AddTransient(); - services.AddKeyedTransient>( - AccelerationModel.SelectionDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Definition Type", - initialValue: AccelerationDefinitionType.None, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, AccelerationModel.SelectionDIKey, "Definition Type", AccelerationDefinitionType.None); // Register selector options for AccelerationDefinitionType services.AddKeyedTransient>( @@ -200,21 +117,9 @@ public static IServiceProvider Compose(IServiceCollection services) #region FormulaAccel services.AddTransient(); - services.AddKeyedTransient>( - FormulaAccelModel.SelectionDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Formula Type", - initialValue: AccelerationFormulaType.Synchronous, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>(), - autoUpdateFromInterface: true)); - services.AddKeyedTransient>( - FormulaAccelModel.GainDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Apply to Gain", - initialValue: false, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, FormulaAccelModel.SelectionDIKey, "Formula Type", AccelerationFormulaType.Synchronous, + autoUpdateFromInterface: true); + AddEditableSetting(services, FormulaAccelModel.GainDIKey, "Apply to Gain", false); // Register selector options for AccelerationFormulaType services.AddKeyedTransient>( @@ -241,20 +146,8 @@ public static IServiceProvider Compose(IServiceCollection services) #region LookupTable services.AddTransient(); - services.AddKeyedTransient>( - LookupTableDefinitionModel.ApplyAsDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Apply as", - initialValue: LookupTableType.Velocity, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - LookupTableDefinitionModel.DataDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Data", - initialValue: new LookupTableData(), - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, LookupTableDefinitionModel.ApplyAsDIKey, "Apply as", LookupTableType.Velocity); + AddEditableSetting(services, LookupTableDefinitionModel.DataDIKey, "Data", new LookupTableData()); #endregion LookupTable @@ -267,210 +160,67 @@ public static IServiceProvider Compose(IServiceCollection services) #region SynchronousAccel services.AddTransient(); - services.AddKeyedTransient>( - SynchronousAccelerationDefinitionModel.SyncSpeedDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Sync Speed", - 15, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - SynchronousAccelerationDefinitionModel.MotivityDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Motivity", - 1.4, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - SynchronousAccelerationDefinitionModel.GammaDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Gamma", - 1, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - SynchronousAccelerationDefinitionModel.SmoothnessDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Smoothness", - 0.5, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, SynchronousAccelerationDefinitionModel.SyncSpeedDIKey, "Sync Speed", 15); + AddEditableSetting(services, SynchronousAccelerationDefinitionModel.MotivityDIKey, "Motivity", 1.4); + AddEditableSetting(services, SynchronousAccelerationDefinitionModel.GammaDIKey, "Gamma", 1); + AddEditableSetting(services, SynchronousAccelerationDefinitionModel.SmoothnessDIKey, "Smoothness", 0.5); #endregion SynchronousAccel #region LinearAccel services.AddTransient(); - services.AddKeyedTransient>( - LinearAccelerationDefinitionModel.AccelerationDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Acceleration", - 0.01, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - LinearAccelerationDefinitionModel.OffsetDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Offset", - 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - LinearAccelerationDefinitionModel.CapDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Cap", - 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, LinearAccelerationDefinitionModel.AccelerationDIKey, "Acceleration", 0.01); + AddEditableSetting(services, LinearAccelerationDefinitionModel.OffsetDIKey, "Offset", 0); + AddEditableSetting(services, LinearAccelerationDefinitionModel.CapDIKey, "Cap", 0); #endregion LinearAccel #region ClassicAccel services.AddTransient(); - services.AddKeyedTransient>( - ClassicAccelerationDefinitionModel.AccelerationDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Acceleration", - 0.01, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - ClassicAccelerationDefinitionModel.ExponentDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Exponent", - 2, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - ClassicAccelerationDefinitionModel.OffsetDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Offset", - 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - ClassicAccelerationDefinitionModel.CapDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Cap", - 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, ClassicAccelerationDefinitionModel.AccelerationDIKey, "Acceleration", 0.01); + AddEditableSetting(services, ClassicAccelerationDefinitionModel.ExponentDIKey, "Exponent", 2); + AddEditableSetting(services, ClassicAccelerationDefinitionModel.OffsetDIKey, "Offset", 0); + AddEditableSetting(services, ClassicAccelerationDefinitionModel.CapDIKey, "Cap", 0); #endregion ClassicAccel #region PowerAccel services.AddTransient(); - services.AddKeyedTransient>( - PowerAccelerationDefinitionModel.ScaleDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Scale", - 1, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - PowerAccelerationDefinitionModel.ExponentDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Exponent", - 0.05, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - PowerAccelerationDefinitionModel.OutputOffsetDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Output Offset", - 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - PowerAccelerationDefinitionModel.CapDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Cap", - 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, PowerAccelerationDefinitionModel.ScaleDIKey, "Scale", 1); + AddEditableSetting(services, PowerAccelerationDefinitionModel.ExponentDIKey, "Exponent", 0.05); + AddEditableSetting(services, PowerAccelerationDefinitionModel.OutputOffsetDIKey, "Output Offset", 0); + AddEditableSetting(services, PowerAccelerationDefinitionModel.CapDIKey, "Cap", 0); #endregion PowerAccel #region JumpAccel services.AddTransient(); - services.AddKeyedTransient>( - JumpAccelerationDefinitionModel.SmoothDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Smooth", - 0.5, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - JumpAccelerationDefinitionModel.InputDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Input", - 15, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - JumpAccelerationDefinitionModel.OutputDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Output", - 1.5, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, JumpAccelerationDefinitionModel.SmoothDIKey, "Smooth", 0.5); + AddEditableSetting(services, JumpAccelerationDefinitionModel.InputDIKey, "Input", 15); + AddEditableSetting(services, JumpAccelerationDefinitionModel.OutputDIKey, "Output", 1.5); #endregion JumpAccel #region NaturalAccel services.AddTransient(); - services.AddKeyedTransient>( - NaturalAccelerationDefinitionModel.DecayRateDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Decay Rate", - 0.1, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - NaturalAccelerationDefinitionModel.InputOffsetDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Input Offset", - 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - NaturalAccelerationDefinitionModel.LimitDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Limit", - 1.5, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, NaturalAccelerationDefinitionModel.DecayRateDIKey, "Decay Rate", 0.1); + AddEditableSetting(services, NaturalAccelerationDefinitionModel.InputOffsetDIKey, "Input Offset", 0); + AddEditableSetting(services, NaturalAccelerationDefinitionModel.LimitDIKey, "Limit", 1.5); #endregion NaturalAccel #region Profile services.AddTransient(); - services.AddKeyedTransient>( - ProfileModel.NameDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Name", - "Empty", - parser: services.GetRequiredService>(), - validator: services.GetRequiredKeyedService>(ProfileModel.NameDIKey))); - services.AddKeyedTransient>( - ProfileModel.OutputDPIDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Output DPI", - 1000, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - ProfileModel.YXRatioDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Y/X Ratio", - 1.0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, ProfileModel.NameDIKey, "Name", "Empty", + validatorFactory: sp => sp.GetRequiredKeyedService>(ProfileModel.NameDIKey)); + AddEditableSetting(services, ProfileModel.OutputDPIDIKey, "Output DPI", 1000); + AddEditableSetting(services, ProfileModel.YXRatioDIKey, "Y/X Ratio", 1.0); #endregion Profile @@ -498,54 +248,19 @@ public static IServiceProvider Compose(IServiceCollection services) { var systemDevicesProvider = sp.GetRequiredService(); var deviceGroups = sp.GetRequiredService(); - var devicesModel = new DevicesModel(sp, systemDevicesProvider); - devicesModel.DeviceGroups = deviceGroups; - return devicesModel; + return new DevicesModel(sp, systemDevicesProvider, deviceGroups); }); + // TODO: HWID should never be exposed to user. services.AddTransient(); - services.AddKeyedTransient>( - DeviceModel.NameDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Name", - initialValue: "name", - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - DeviceModel.HardwareIDDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Hardware ID", - initialValue: "hwid", - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - DeviceModel.DPIDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "DPI", - initialValue: 1000, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - DeviceModel.PollRateDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Polling Rate", - initialValue: 1000, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - DeviceModel.IgnoreDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Ignore", - initialValue: false, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - DeviceModel.DeviceGroupDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Device Group", - initialValue: "default", - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, DeviceModel.NameDIKey, "Name", "name"); + AddEditableSetting(services, DeviceModel.HardwareIDDIKey, "Hardware ID", "hwid"); + AddEditableSetting(services, DeviceModel.DPIDIKey, "DPI", 1000, + validatorFactory: sp => new RangeValidator(min: 1)); + AddEditableSetting(services, DeviceModel.PollRateDIKey, "Polling Rate", 1000, + validatorFactory: sp => new RangeValidator(min: 1)); + AddEditableSetting(services, DeviceModel.IgnoreDIKey, "Ignore", false); + AddEditableSetting(services, DeviceModel.DeviceGroupDIKey, "Device Group", "default"); #endregion DeviceGroup @@ -567,13 +282,8 @@ public static IServiceProvider Compose(IServiceCollection services) }); services.AddTransient(); - services.AddKeyedTransient>( - MappingModel.NameDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Name", - initialValue: "name", - parser: services.GetRequiredService>(), - validator: services.GetRequiredService())); + AddEditableSetting(services, MappingModel.NameDIKey, "Name", "name", + validatorFactory: sp => sp.GetRequiredService()); #endregion Mapping @@ -597,14 +307,82 @@ public static IServiceProvider Compose(IServiceCollection services) return services.BuildServiceProvider(); } - // Driver/evaluator/device-enumeration registration. The concrete impls - // (WindowsRawAccelDriver, ManagedAccelEvaluator, WindowsSystemDevicesRetriever) - // live under Driver/Windows/ and depend on wrapper.dll (C++/CLI). + // Registers a keyed EditableSettingV2 built from the DI-provided parser and + // validator. Collapses the dozens of otherwise-identical registration blocks. + // Pass validatorFactory to override the default (type-keyed) validator. + private static void AddEditableSetting( + IServiceCollection services, + object diKey, + string displayName, + T initialValue, + bool autoUpdateFromInterface = false, + string? localizationKey = null, + Func>? validatorFactory = null) + where T : IComparable + { + services.AddKeyedTransient>( + diKey, + (sp, key) => new EditableSettingV2( + displayName: displayName, + initialValue: initialValue, + parser: sp.GetRequiredService>(), + validator: validatorFactory is null + ? sp.GetRequiredService>() + : validatorFactory(sp), + autoUpdateFromInterface: autoUpdateFromInterface, + localizationKey: localizationKey!, + logger: sp.GetService() + ?.CreateLogger(EditableSettingV2.LoggerCategoryName))); + } + + // TODO: This reflection-based registration exists only because wrapper.dll + // is .NET Framework 4.7.2 mixed-mode C++/CLI (cannot be loaded + // in process by net8.0), and Driver/Windows/*.cs is Compile-Removed on + // non-Windows. Once wrapper is migrated to net8.0-windows + // (NetCore), replace this with + // compile safe registration and delete RegisterWindowsServicesByReflection private static void RegisterPlatformServices(IServiceCollection services) { - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Windows-side impls (WindowsRawAccelDriver, ManagedAccelEvaluator, + // WindowsSystemDevicesRetriever) live under Driver/Windows/ and + // are excluded from non-Windows builds via csproj. They depend + // on wrapper.dll (C++/CLI). Registered via reflection so this + // method can compile on Linux where those types do not exist. + RegisterWindowsServicesByReflection(services); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + } + else + { + throw new PlatformNotSupportedException( + $"Raw Accel backend supports Windows and Linux only; current platform: {RuntimeInformation.OSDescription}"); + } + } + + private static void RegisterWindowsServicesByReflection(IServiceCollection services) + { + var asm = typeof(BackEndComposer).Assembly; + + var driverType = asm.GetType("userspace_backend.Driver.Windows.WindowsRawAccelDriver"); + var evaluatorType = asm.GetType("userspace_backend.Driver.Windows.ManagedAccelEvaluator"); + var devicesType = asm.GetType("userspace_backend.Driver.Windows.WindowsSystemDevicesRetriever"); + + if (driverType is null || evaluatorType is null || devicesType is null) + { + throw new InvalidOperationException( + "Windows driver/evaluator/devices types missing from this build; " + + "ensure userspace-backend was built on Windows so wrapper.dll is referenced."); + } + + services.TryAddSingleton(typeof(IRawAccelDriver), driverType); + services.TryAddSingleton(typeof(IAccelEvaluator), evaluatorType); + services.TryAddSingleton(typeof(ISystemDevicesRetriever), devicesType); } } } diff --git a/userspace-backend/BackEndLoader.cs b/userspace-backend/BackEndLoader.cs index 47385a4c..d02b84e5 100644 --- a/userspace-backend/BackEndLoader.cs +++ b/userspace-backend/BackEndLoader.cs @@ -58,8 +58,7 @@ public BackEndLoader( return []; } string devicesText = File.ReadAllText(devicesFile); - IEnumerable devicesData = DevicesReaderWriter.Deserialize(devicesText); - return devicesData; + return DevicesReaderWriter.Deserialize(devicesText) ?? []; } public DATA.MappingSet LoadMappings() @@ -70,8 +69,7 @@ public DATA.MappingSet LoadMappings() return new DATA.MappingSet { Mappings = [] }; } string mappingsText = File.ReadAllText(mappingsFile); - DATA.MappingSet mappingsData = MappingsReaderWriter.Deserialize(mappingsText); - return mappingsData; + return MappingsReaderWriter.Deserialize(mappingsText) ?? new DATA.MappingSet { Mappings = [] }; } public IEnumerable LoadProfiles() @@ -87,8 +85,11 @@ public DATA.MappingSet LoadMappings() foreach (string profileFile in profileFiles) { string profileText = File.ReadAllText(profileFile); - DATA.Profile profileData = ProfileReaderWriter.Deserialize(profileText); - profiles.Add(profileData); + DATA.Profile? profileData = ProfileReaderWriter.Deserialize(profileText); + if (profileData != null) + { + profiles.Add(profileData); + } } return profiles; @@ -133,39 +134,70 @@ protected void WriteDevices(IEnumerable devices) { IEnumerable devicesData = devices.Select(d => d.MapToData()); string devicesFileText = DevicesReaderWriter.Serialize(devicesData); - string devicesFilePath = GetDevicesFile(SettingsDirectory); - File.WriteAllText(devicesFilePath, devicesFileText); + WriteFileAtomic(GetDevicesFile(SettingsDirectory), devicesFileText); } protected void WriteMappings(MappingsModel mappings) { DATA.MappingSet mappingsData = mappings.MapToData(); string mappingsFileText = MappingsReaderWriter.Serialize(mappingsData); - string mappingsFilePath = GetMappingsFile(SettingsDirectory); - File.WriteAllText(mappingsFilePath, mappingsFileText); + WriteFileAtomic(GetMappingsFile(SettingsDirectory), mappingsFileText); } - + protected void WriteProfiles(IEnumerable profiles) { string profilesDirectory = GetProfilesDirectory(SettingsDirectory); Directory.CreateDirectory(profilesDirectory); + HashSet writtenFiles = []; foreach (var profile in profiles) { DATA.Profile profileData = profile.MapToData(); string profileFileText = ProfileReaderWriter.Serialize(profileData); string profileFilePath = GetProfileFile(profilesDirectory, profileData.Name); - File.WriteAllText(profileFilePath, profileFileText); + WriteFileAtomic(profileFilePath, profileFileText); + writtenFiles.Add(profileFilePath); + } + + // Remove profile files left behind by profiles that no longer exist. + foreach (string existing in Directory.GetFiles(profilesDirectory, "*.json")) + { + if (!writtenFiles.Contains(existing)) + { + File.Delete(existing); + } } } + // Write via a temp file then rename so a crash mid-write cannot leave a + // half-written (corrupt) file in place of the previous good one. + private static void WriteFileAtomic(string path, string contents) + { + string tempPath = path + ".tmp"; + File.WriteAllText(tempPath, contents); + File.Move(tempPath, path, overwrite: true); + } + protected static string GetDevicesFile(string settingsDirectory) => Path.Combine(settingsDirectory, "devices.json"); protected static string GetMappingsFile(string settingsDirectory) => Path.Combine(settingsDirectory, "mappings.json"); protected static string GetProfilesDirectory(string settingsDirectory) => Path.Combine(settingsDirectory, "profiles"); - protected static string GetProfileFile(string profileDirectory, string profileName) => Path.Combine(profileDirectory, $"{profileName}.json"); + protected static string GetProfileFile(string profileDirectory, string profileName) => Path.Combine(profileDirectory, $"{SanitizeFileName(profileName)}.json"); + + // Profile names are user-supplied and may contain characters that are + // illegal in a filename (e.g. '/', '\\', ':'); replace those so the + // write does not throw. The on-disk name is not authoritative: load + // reads the profile's Name from the file contents, not the filename. + private static string SanitizeFileName(string name) + { + foreach (char invalid in Path.GetInvalidFileNameChars()) + { + name = name.Replace(invalid, '_'); + } + return name; + } protected static string GetSettingsFile(string settingsDirectory) => Path.Combine(settingsDirectory, "settings.json"); } diff --git a/userspace-backend/Bootstrapper.cs b/userspace-backend/Bootstrapper.cs index 9829c5d6..c66a4c62 100644 --- a/userspace-backend/Bootstrapper.cs +++ b/userspace-backend/Bootstrapper.cs @@ -11,13 +11,13 @@ namespace userspace_backend // TODO: remove before release public class Bootstrapper : IBackEndLoader { - public DATA.Device[] DevicesToLoad { get; set; } + public DATA.Device[] DevicesToLoad { get; set; } = []; - public DATA.MappingSet MappingsToLoad { get; set; } + public DATA.MappingSet MappingsToLoad { get; set; } = new DATA.MappingSet { Mappings = [] }; - public DATA.Profile[] ProfilesToLoad { get; set; } + public DATA.Profile[] ProfilesToLoad { get; set; } = []; - public DATA.Settings SettingsToLoad { get; set; } + public DATA.Settings? SettingsToLoad { get; set; } // Allows us to test parts of BackEndLoader as desired public BackEndLoader BackEndLoader { get; set; } diff --git a/userspace-backend/Common/DriverHelpers.cs b/userspace-backend/Common/DriverHelpers.cs deleted file mode 100644 index 32dfbd92..00000000 --- a/userspace-backend/Common/DriverHelpers.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using userspace_backend.Model; -using Profile = RawAccel.Contracts.RawAccelProfile; -using SpeedArgs = RawAccel.Contracts.RawAccelSpeedArgs; -using Vec2D = RawAccel.Contracts.Vec2; - -namespace userspace_backend.Common -{ - public static class DriverHelpers - { - public static Profile MapProfileModelToDriver(ProfileModel model) - { - return new Profile() - { - name = model.Name.ModelValue, - outputDPI = model.OutputDPI.ModelValue, - yxOutputDPIRatio = model.YXRatio.ModelValue, - argsX = model.Acceleration.MapToDriver(), - domainXY = new Vec2D - { - x = model.Acceleration.Anisotropy.DomainX.ModelValue, - y = model.Acceleration.Anisotropy.DomainY.ModelValue, - }, - rangeXY = new Vec2D - { - x = model.Acceleration.Anisotropy.RangeX.ModelValue, - y = model.Acceleration.Anisotropy.RangeY.ModelValue, - }, - rotation = model.Hidden.RotationDegrees.ModelValue, - lrOutputDPIRatio = model.Hidden.LeftRightRatio.ModelValue, - udOutputDPIRatio = model.Hidden.UpDownRatio.ModelValue, - snap = model.Hidden.AngleSnappingDegrees.ModelValue, - maximumSpeed = model.Hidden.SpeedCap.ModelValue, - minimumSpeed = 0, - inputSpeedArgs = new SpeedArgs - { - combineMagnitudes = model.Acceleration.Anisotropy.CombineXYComponents.ModelValue, - lpNorm = model.Acceleration.Anisotropy.LPNorm.ModelValue, - outputSmoothHalflife = model.Hidden.OutputSmoothingHalfLife.ModelValue, - inputSmoothHalflife = model.Acceleration.Coalescion.InputSmoothingHalfLife.ModelValue, - scaleSmoothHalflife = model.Acceleration.Coalescion.ScaleSmoothingHalfLife.ModelValue, - } - }; - - } - } -} diff --git a/userspace-backend/Data/Device.cs b/userspace-backend/Data/Device.cs index 4144652f..126849e2 100644 --- a/userspace-backend/Data/Device.cs +++ b/userspace-backend/Data/Device.cs @@ -1,5 +1,4 @@ -using System; -using System.ComponentModel; +using System; using System.Text.Json.Serialization; namespace userspace_backend.Data @@ -22,17 +21,24 @@ public class Device public override bool Equals(object? obj) { return obj is Device device && - Name == device.Name && - HWID == device.HWID && + string.Equals(Name, device.Name, StringComparison.OrdinalIgnoreCase) && + string.Equals(HWID, device.HWID, StringComparison.OrdinalIgnoreCase) && DPI == device.DPI && PollingRate == device.PollingRate && Ignore == device.Ignore && - DeviceGroup == device.DeviceGroup; + string.Equals(DeviceGroup, device.DeviceGroup, StringComparison.OrdinalIgnoreCase); } public override int GetHashCode() { - return HashCode.Combine(Name, HWID, DPI, PollingRate, DeviceGroup); + HashCode hash = new HashCode(); + hash.Add(Name, StringComparer.OrdinalIgnoreCase); + hash.Add(HWID, StringComparer.OrdinalIgnoreCase); + hash.Add(DPI); + hash.Add(PollingRate); + hash.Add(Ignore); + hash.Add(DeviceGroup, StringComparer.OrdinalIgnoreCase); + return hash.ToHashCode(); } } } diff --git a/userspace-backend/Data/Mapping.cs b/userspace-backend/Data/Mapping.cs index 7f3647b0..f2efe685 100644 --- a/userspace-backend/Data/Mapping.cs +++ b/userspace-backend/Data/Mapping.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -18,16 +18,16 @@ public class Mapping public override bool Equals(object? obj) { - bool isEqual = obj is Mapping mapping - && string.Equals(Name, mapping.Name, StringComparison.InvariantCultureIgnoreCase) - && mapping.GroupsToProfiles.Equals(this.GroupsToProfiles); - - return isEqual; + return obj is Mapping mapping + && string.Equals(Name, mapping.Name, StringComparison.OrdinalIgnoreCase) + && (GroupsToProfiles?.Equals(mapping.GroupsToProfiles) ?? mapping.GroupsToProfiles is null); } public override int GetHashCode() { - return HashCode.Combine(Name.ToUpperInvariant(), GroupsToProfiles); + return HashCode.Combine( + Name is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(Name), + GroupsToProfiles?.GetHashCode() ?? 0); } public class GroupsToProfilesMapping : Dictionary @@ -36,21 +36,27 @@ public override bool Equals(object? obj) { return obj is GroupsToProfilesMapping mapping && Count == mapping.Count && - this.All(kvp => - mapping.TryGetValue(kvp.Key, out string mappingValue) - && string.Equals(mappingValue, kvp.Value, StringComparison.InvariantCultureIgnoreCase)); + this.All(kvp => + mapping.TryGetValue(kvp.Key, out string? mappingValue) + && string.Equals(mappingValue, kvp.Value, StringComparison.OrdinalIgnoreCase)); } public override int GetHashCode() { - HashCode hash = new HashCode(); + // XOR per-entry hashes so the result is order-independent, + // matching the order-independent Equals above. Keys use the + // dictionary's (ordinal) comparer; values are case-insensitive. + int hash = 0; foreach (var kvp in this) { - hash.Add(kvp.GetHashCode()); + int valueHash = kvp.Value is null + ? 0 + : StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Value); + hash ^= HashCode.Combine(kvp.Key, valueHash); } - return hash.ToHashCode(); + return hash; } } } @@ -59,6 +65,8 @@ public class MappingEqualityComparer : IEqualityComparer { public bool Equals(Mapping? x, Mapping? y) { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; return x.Equals(y); } @@ -77,16 +85,29 @@ public class MappingSet public override bool Equals(object? obj) { - MappingSet test = obj as MappingSet; - return obj is MappingSet set - && set.Mappings.Length == this.Mappings.Length - && set.ActiveMappingIndex == this.ActiveMappingIndex - && !set.Mappings.Except(this.Mappings, Mapping.EqualityComparer).Any(); + if (obj is not MappingSet set) return false; + if (ActiveMappingIndex != set.ActiveMappingIndex) return false; + if (ReferenceEquals(Mappings, set.Mappings)) return true; + if (Mappings is null || set.Mappings is null) return false; + + return Mappings.Length == set.Mappings.Length + && !set.Mappings.Except(Mappings, Mapping.EqualityComparer).Any(); } public override int GetHashCode() { - return HashCode.Combine(Mappings, ActiveMappingIndex); + // XOR element hashes so the hash is order-independent, consistent + // with the set-based Equals; combine with the positional index. + int mappingsHash = 0; + if (Mappings is not null) + { + foreach (Mapping mapping in Mappings) + { + mappingsHash ^= mapping?.GetHashCode() ?? 0; + } + } + + return HashCode.Combine(mappingsHash, ActiveMappingIndex); } } } diff --git a/userspace-backend/Data/Profile.cs b/userspace-backend/Data/Profile.cs index d5e69629..599f378b 100644 --- a/userspace-backend/Data/Profile.cs +++ b/userspace-backend/Data/Profile.cs @@ -1,22 +1,21 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Text.Json.Serialization; using userspace_backend.Data.Profiles; namespace userspace_backend.Data { public class Profile { + [JsonRequired] public string Name { get; set; } public int OutputDPI { get; set; } public double YXRatio { get; set; } + [JsonRequired] public Acceleration Acceleration { get; set; } + [JsonRequired] public Hidden Hidden { get; set; } } } diff --git a/userspace-backend/Data/Profiles/Accel/Formula/ClassicAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/ClassicAccel.cs index 62c2aef3..90edef2b 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/ClassicAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/ClassicAccel.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel.Formula { public class ClassicAccel : FormulaAccel diff --git a/userspace-backend/Data/Profiles/Accel/Formula/JumpAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/JumpAccel.cs index b18bf4a1..41a85a6b 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/JumpAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/JumpAccel.cs @@ -1,13 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel.Formula { public class JumpAccel : FormulaAccel - { public override AccelerationFormulaType FormulaType => AccelerationFormulaType.Jump; diff --git a/userspace-backend/Data/Profiles/Accel/Formula/LinearAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/LinearAccel.cs index fbf8ccf0..8f6a1ef4 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/LinearAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/LinearAccel.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel.Formula { public class LinearAccel : FormulaAccel diff --git a/userspace-backend/Data/Profiles/Accel/Formula/NaturalAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/NaturalAccel.cs index 81743c0a..31693b43 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/NaturalAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/NaturalAccel.cs @@ -1,19 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel.Formula { public class NaturalAccel : FormulaAccel { public override AccelerationFormulaType FormulaType => AccelerationFormulaType.Natural; - public double DecayRate { get; set; } + public double DecayRate { get; set; } = 0.1; public double InputOffset { get; set; } - public double Limit { get; set; } + public double Limit { get; set; } = 1.5; } } diff --git a/userspace-backend/Data/Profiles/Accel/Formula/PowerAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/PowerAccel.cs index bd615970..c0be8083 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/PowerAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/PowerAccel.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel.Formula { public class PowerAccel : FormulaAccel diff --git a/userspace-backend/Data/Profiles/Accel/Formula/SynchronousAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/SynchronousAccel.cs index 822a79fe..0bc4d34c 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/SynchronousAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/SynchronousAccel.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel.Formula { public class SynchronousAccel : FormulaAccel diff --git a/userspace-backend/Data/Profiles/Accel/FormulaAccel.cs b/userspace-backend/Data/Profiles/Accel/FormulaAccel.cs index f8eaf254..43865df2 100644 --- a/userspace-backend/Data/Profiles/Accel/FormulaAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/FormulaAccel.cs @@ -1,12 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel { - public class FormulaAccel : Acceleration + public abstract class FormulaAccel : Acceleration { public enum AccelerationFormulaType { @@ -18,9 +12,9 @@ public enum AccelerationFormulaType Jump = 5, } - public override AccelerationDefinitionType Type { get => AccelerationDefinitionType.Formula; } + public override AccelerationDefinitionType Type => AccelerationDefinitionType.Formula; - public virtual AccelerationFormulaType FormulaType { get; } + public abstract AccelerationFormulaType FormulaType { get; } public bool Gain { get; set; } } diff --git a/userspace-backend/Data/Profiles/Accel/LookupTableAccel.cs b/userspace-backend/Data/Profiles/Accel/LookupTableAccel.cs index dc4e4847..19f5280e 100644 --- a/userspace-backend/Data/Profiles/Accel/LookupTableAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/LookupTableAccel.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel { public class LookupTableAccel : Acceleration @@ -14,10 +8,10 @@ public enum LookupTableType Sensitivity = 1, } - public override AccelerationDefinitionType Type { get => AccelerationDefinitionType.LookupTable; } + public override AccelerationDefinitionType Type => AccelerationDefinitionType.LookupTable; public LookupTableType ApplyAs { get; set; } - public double[] Data { get; set; } + public double[] Data { get; set; } = []; } } diff --git a/userspace-backend/Data/Profiles/Accel/NoAcceleration.cs b/userspace-backend/Data/Profiles/Accel/NoAcceleration.cs index 37eb57e2..d887f9ec 100644 --- a/userspace-backend/Data/Profiles/Accel/NoAcceleration.cs +++ b/userspace-backend/Data/Profiles/Accel/NoAcceleration.cs @@ -1,13 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel { public class NoAcceleration : Acceleration { - public override AccelerationDefinitionType Type { get => AccelerationDefinitionType.None; } + public override AccelerationDefinitionType Type => AccelerationDefinitionType.None; } } diff --git a/userspace-backend/Data/Profiles/Acceleration.cs b/userspace-backend/Data/Profiles/Acceleration.cs index f9656487..771afd00 100644 --- a/userspace-backend/Data/Profiles/Acceleration.cs +++ b/userspace-backend/Data/Profiles/Acceleration.cs @@ -1,18 +1,6 @@ -using System.Text.Json.Serialization; -using userspace_backend.Data.Profiles.Accel; -using userspace_backend.Data.Profiles.Accel.Formula; - namespace userspace_backend.Data.Profiles { - [JsonDerivedType(typeof(SynchronousAccel))] - [JsonDerivedType(typeof(LinearAccel))] - [JsonDerivedType(typeof(ClassicAccel))] - [JsonDerivedType(typeof(NaturalAccel))] - [JsonDerivedType(typeof(PowerAccel))] - [JsonDerivedType(typeof(JumpAccel))] - [JsonDerivedType(typeof(LookupTableAccel))] - [JsonDerivedType(typeof(NoAcceleration))] - public class Acceleration + public abstract class Acceleration { public enum AccelerationDefinitionType { @@ -21,15 +9,9 @@ public enum AccelerationDefinitionType LookupTable, } - public virtual AccelerationDefinitionType Type { get; init; } + public abstract AccelerationDefinitionType Type { get; } - public Anisotropy Anisotropy { get; set; } = new Anisotropy - { - Domain = new Vector2(), - Range = new Vector2(), - LPNorm = 2.0, - CombineXYComponents = false - }; + public Anisotropy Anisotropy { get; set; } = new Anisotropy(); public Coalescion Coalescion { get; set; } = new Coalescion(); } diff --git a/userspace-backend/Data/Profiles/Anisotropy.cs b/userspace-backend/Data/Profiles/Anisotropy.cs index 93c443ba..7dbc6ecf 100644 --- a/userspace-backend/Data/Profiles/Anisotropy.cs +++ b/userspace-backend/Data/Profiles/Anisotropy.cs @@ -1,18 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles { public class Anisotropy { - public Vector2 Domain { get; set; } = new Vector2(); + public Vector2 Domain { get; set; } = new Vector2 { X = 1, Y = 1 }; - public Vector2 Range { get; set; } = new Vector2(); + public Vector2 Range { get; set; } = new Vector2 { X = 1, Y = 1 }; - public double LPNorm { get; set; } + public double LPNorm { get; set; } = 2.0; public bool CombineXYComponents { get; set; } } diff --git a/userspace-backend/Data/Profiles/Coalescion.cs b/userspace-backend/Data/Profiles/Coalescion.cs index 79f7c0f2..b44d0157 100644 --- a/userspace-backend/Data/Profiles/Coalescion.cs +++ b/userspace-backend/Data/Profiles/Coalescion.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles { public class Coalescion diff --git a/userspace-backend/Data/Profiles/Hidden.cs b/userspace-backend/Data/Profiles/Hidden.cs index 5cb10a21..85617c79 100644 --- a/userspace-backend/Data/Profiles/Hidden.cs +++ b/userspace-backend/Data/Profiles/Hidden.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles { public class Hidden @@ -12,9 +6,9 @@ public class Hidden public double AngleSnappingDegrees { get; set; } - public double LeftRightRatio { get; set; } + public double LeftRightRatio { get; set; } = 1.0; - public double UpDownRatio { get; set; } + public double UpDownRatio { get; set; } = 1.0; public double SpeedCap { get; set; } diff --git a/userspace-backend/Data/Profiles/Vector2.cs b/userspace-backend/Data/Profiles/Vector2.cs index cca3dace..4d4eb93c 100644 --- a/userspace-backend/Data/Profiles/Vector2.cs +++ b/userspace-backend/Data/Profiles/Vector2.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles { public class Vector2 diff --git a/userspace-backend/Data/Settings.cs b/userspace-backend/Data/Settings.cs index a7b70a68..938c2209 100644 --- a/userspace-backend/Data/Settings.cs +++ b/userspace-backend/Data/Settings.cs @@ -1,72 +1,19 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; +using CommunityToolkit.Mvvm.ComponentModel; namespace userspace_backend.Data { - public class Settings : INotifyPropertyChanged + public partial class Settings : ObservableObject { + [ObservableProperty] private bool showToastNotifications = true; - private bool showConfirmModals = true; - private string theme = "System"; - private string language = "en-US"; - - public bool ShowToastNotifications - { - get => showToastNotifications; - set - { - if (showToastNotifications != value) - { - showToastNotifications = value; - OnPropertyChanged(); - } - } - } - - public string Theme - { - get => theme; - set - { - if (theme != value) - { - theme = value; - OnPropertyChanged(); - } - } - } - public bool ShowConfirmModals - { - get => showConfirmModals; - set - { - if (showConfirmModals != value) - { - showConfirmModals = value; - OnPropertyChanged(); - } - } - } - - public string Language - { - get => language; - set - { - if (language != value) - { - language = value; - OnPropertyChanged(); - } - } - } + [ObservableProperty] + private bool showConfirmModals = true; - public event PropertyChangedEventHandler? PropertyChanged; + [ObservableProperty] + private string theme = "System"; - protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } + [ObservableProperty] + private string language = "en-US"; } -} \ No newline at end of file +} diff --git a/userspace-backend/Display/Calculations/CurveCalculationHelpers.cs b/userspace-backend/Display/Calculations/CurveCalculationHelpers.cs index 4daa2450..296d32e6 100644 --- a/userspace-backend/Display/Calculations/CurveCalculationHelpers.cs +++ b/userspace-backend/Display/Calculations/CurveCalculationHelpers.cs @@ -1,26 +1,25 @@ -using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace userspace_backend.Display.Calculations { + // TODO: Replace with curvature-adaptive sampling or adjacent. public static class CurveCalculationHelpers { + // Non-zero floor: CurvePreview divides output by MouseSpeed, and any log grid + // needs a positive minimum. Do not lower this to 0. public const double SlowestHandSpeed = 0.05; - public const double FastestHandSpeed = 200; - public const double CurvePointsResolution = 256; + public const double FastestHandSpeed = 200; // max charted hand speed + public const int CurvePointsResolution = 256; - public static ICollection CalculateCurvePointSpeeds() + public static IReadOnlyList CalculateCurvePointSpeeds() { - int count = (int)CurvePointsResolution; + int count = CurvePointsResolution; List curvePointSpeeds = new List(count); - double step = (FastestHandSpeed - SlowestHandSpeed) / (count - 1); for (int i = 0; i < count; i++) { - curvePointSpeeds.Add(SlowestHandSpeed + i * step); + double t = (double)i / (count - 1); + curvePointSpeeds.Add(SlowestHandSpeed + t * (FastestHandSpeed - SlowestHandSpeed)); } return curvePointSpeeds; diff --git a/userspace-backend/Display/CurvePreview.cs b/userspace-backend/Display/CurvePreview.cs index c7a17fac..1927faa6 100644 --- a/userspace-backend/Display/CurvePreview.cs +++ b/userspace-backend/Display/CurvePreview.cs @@ -2,9 +2,6 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using userspace_backend.Display.Calculations; using userspace_backend.Driver; using Profile = RawAccel.Contracts.RawAccelProfile; @@ -35,13 +32,13 @@ public CurvePreview(IAccelEvaluator evaluator) public void GeneratePoints(Profile profile) { - IAccelInstance instance = evaluator.CreateInstance(profile); + using IAccelInstance instance = evaluator.CreateInstance(profile); foreach (CurvePoint point in Points) { - var (ox, oy) = instance.Accelerate(point.MouseSpeed, 0, 1, 1); + var (ox, oy) = instance.Accelerate(point.MouseSpeed, 0, dpiFactor: 1, timeMs: 1); var outputSpeed = Math.Sqrt(ox * ox + oy * oy); - point.Output = outputSpeed / point.MouseSpeed; + point.Output = point.MouseSpeed > 0 ? outputSpeed / point.MouseSpeed : 0.0; } } @@ -54,9 +51,9 @@ public void SetPoints(IEnumerable points) } } - protected void InitPoints() + private void InitPoints() { - ICollection speeds = CurveCalculationHelpers.CalculateCurvePointSpeeds(); + IReadOnlyList speeds = CurveCalculationHelpers.CalculateCurvePointSpeeds(); foreach (double speed in speeds) { diff --git a/userspace-backend/Driver/IAccelEvaluator.cs b/userspace-backend/Driver/IAccelEvaluator.cs index bf40f171..db2fd993 100644 --- a/userspace-backend/Driver/IAccelEvaluator.cs +++ b/userspace-backend/Driver/IAccelEvaluator.cs @@ -1,30 +1,27 @@ +using System; using RawAccel.Contracts; namespace userspace_backend.Driver { - // Stateless per-sample acceleration evaluator used by the live curve - // preview in Display/CurvePreview.cs. Decoupled from IRawAccelDriver - // because preview is a pure-math read that doesn't touch the backend. + // Decoupled from IRawAccelDriver because preview is a pure-math + // read that doesn't touch the backend. // - // Two-step usage (matches how ManagedAccel is used today): - // var instance = evaluator.CreateInstance(profile); - // foreach (var point in points) { - // var (ox, oy) = instance.Accelerate(point.x, point.y, 1, 1); - // } - // - // The Windows implementation wraps wrapper.ManagedAccel.CreateStatelessCopy - // against the same common/ math the driver uses. + // Windows: wraps wrapper.ManagedAccel.CreateStatelessCopy against the + // same common/ math the driver uses. + // Linux: P/Invokes a stripped libcommon.so (deferred); identity stub + // initially so the chart still renders. public interface IAccelEvaluator { IAccelInstance CreateInstance(RawAccelProfile profile); } - public interface IAccelInstance + // IDisposable because a native-backed instance (Linux ShimInstance) holds an + // unmanaged ra_curve handle; preview callers create one per refresh. + public interface IAccelInstance : IDisposable { // dpiFactor is device DPI normalized against NORMALIZED_DPI (1000); - // timeMs is the time slice attributed to this sample (1.0 in the - // existing preview). Returns the post-acceleration (x, y) in the - // same units as input. + // timeMs is the time slice attributed to this sample (1 currently) + // Returns same units as input. (double x, double y) Accelerate(double x, double y, double dpiFactor, double timeMs); } } diff --git a/userspace-backend/Driver/IRawAccelDriver.cs b/userspace-backend/Driver/IRawAccelDriver.cs index cc8be4d6..739d0735 100644 --- a/userspace-backend/Driver/IRawAccelDriver.cs +++ b/userspace-backend/Driver/IRawAccelDriver.cs @@ -2,34 +2,25 @@ namespace userspace_backend.Driver { - // Platform-agnostic apply/read/deactivate surface for the Raw Accel - // backend. The Windows implementation talks to the kernel filter driver - // via IOCTL. Implementations consume and produce the same RawAccelConfig - // POCO; the JSON contract for that POCO is the source of truth in - // RawAccel.Contracts. + // Platform-agnostic apply/read/deactivate surface for the driver. + // + // Error contract (mirrored by both the Windows and Linux implementations): + // - Apply returns false on failure (logged); it does not throw. + // - Read and Deactivate throw on failure (they are expected to succeed once + // IsAvailable is true). + // - GetCurrentMouseSpeedSample returns MouseSpeedSample.Zero on error; it + // never throws, so callers can't distinguish "idle" from "unavailable". public interface IRawAccelDriver { - // True if this implementation can talk to its backend right now. - // Windows: driver service installed and reachable. UI gates Apply - // on this. + // UI gates Apply on this. bool IsAvailable { get; } - // Push a configuration. Returns true on success, false if the - // backend rejected the config or the transport failed. Implementations - // should log the underlying error rather than letting it surface as - // an exception so callers can render a simple success/fail toast. - // The 1s WriteDelay anti-abuse mitigation is enforced by the backend - // (driver / agent), not by this method. bool Apply(RawAccelConfig config); - // Read the currently active configuration from the backend. RawAccelConfig Read(); - // Reset the backend to a no-op configuration without uninstalling. void Deactivate(); - // Optional telemetry: current input speed (counts/ms or in/s, - // implementation-defined). Returns 0 when unsupported. - double GetCurrentMouseSpeed(); + MouseSpeedSample GetCurrentMouseSpeedSample(); } } diff --git a/userspace-backend/Driver/MouseSpeedSample.cs b/userspace-backend/Driver/MouseSpeedSample.cs new file mode 100644 index 00000000..455292c4 --- /dev/null +++ b/userspace-backend/Driver/MouseSpeedSample.cs @@ -0,0 +1,12 @@ +namespace userspace_backend.Driver +{ + // Live input speed reported by the driver/agent, in counts/ms normalized to + // 1000 DPI (the same units the curve math consumes). X and Y are per-axis; + // Combined is the magnitude the agent computed (not necessarily hypot(X, Y)). + public readonly record struct MouseSpeedSample(double X, double Y, double Combined) + { + public static readonly MouseSpeedSample Zero = new(0, 0, 0); + + public bool IsZero => X == 0 && Y == 0 && Combined == 0; + } +} diff --git a/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs b/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs index b7dbab68..81edbdeb 100644 --- a/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs +++ b/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs @@ -13,6 +13,7 @@ public sealed class ManagedAccelEvaluator : IAccelEvaluator { public IAccelInstance CreateInstance(RawAccelProfile profile) { + if (profile == null) throw new ArgumentNullException(nameof(profile)); var json = JsonConvert.SerializeObject(profile); var nativeProfile = JsonConvert.DeserializeObject(json) ?? throw new InvalidOperationException( @@ -36,6 +37,9 @@ public ManagedAccelInstance(ManagedAccel accel) var t = accel.Accelerate(x, y, dpiFactor, timeMs); return (t.Item1, t.Item2); } + + // ManagedAccel is a C++/CLI ref type; dispose it if it owns native state. + public void Dispose() => (accel as IDisposable)?.Dispose(); } } } diff --git a/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs b/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs index 9b566b8c..f17572e1 100644 --- a/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs +++ b/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Newtonsoft.Json; @@ -8,15 +7,6 @@ namespace userspace_backend.Driver.Windows { - // IRawAccelDriver implementation backed by the C++/CLI wrapper.dll's - // DriverConfig.Activate() IOCTL path. RawAccelConfig POCO is converted - // to the wrapper's managed types via JSON round-trip: both sides share - // identical [JsonProperty] names so Newtonsoft can deserialize one into - // the other without manual field mapping. - // - // ManagedAccel instances are constructed per profile after the - // round-trip, since wrapper.DriverConfig.accels is [NonSerialized] and - // wrapper.DriverConfig.Activate() requires accels.Count == profiles.Count. public sealed class WindowsRawAccelDriver : IRawAccelDriver { private readonly ILogger logger; @@ -48,12 +38,13 @@ public bool Apply(RawAccelConfig config) try { var json = JsonConvert.SerializeObject(config); - var native = JsonConvert.DeserializeObject(json) - ?? throw new InvalidOperationException( - "POCO -> wrapper.DriverConfig deserialization returned null"); - native.accels = native.profiles - .Select(p => new ManagedAccel(p)) - .ToList(); + + var (native, errors) = DriverConfig.Convert(json); + if (errors != null) + { + logger.LogError("driver rejected settings: {Errors}", errors); + return false; + } native.Activate(); return true; } @@ -78,12 +69,7 @@ public void Deactivate() DriverConfig.GetDefault().Deactivate(); } - public double GetCurrentMouseSpeed() - { - // wrapper exposes per-profile speed via SpeedCalculator; the UI - // gauge reads from that path directly today, so this telemetry - // hook stays a no-op until consolidated. - return 0; - } + // TODO: plug in mouse speeds from the OS layer. + public MouseSpeedSample GetCurrentMouseSpeedSample() => MouseSpeedSample.Zero; } } diff --git a/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs b/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs index 6b3987ab..87034868 100644 --- a/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs +++ b/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs @@ -4,16 +4,13 @@ namespace userspace_backend.Driver.Windows { - // Windows-only system device enumeration. Uses the wrapper.dll's - // MultiHandleDevice (RawInput-based) which is why this file lives under - // Driver/Windows/ and is excluded from non-Windows builds via the - // csproj Compile Remove rule. + // Windows device enumeration. Uses the wrapper.dll's MultiHandleDevice. public sealed class WindowsSystemDevicesRetriever : ISystemDevicesRetriever { public IList GetSystemDevices() { IList rawDevices = MultiHandleDevice.GetList(); - return rawDevices.Select(d => new WindowsSystemDevice(d) as ISystemDevice).ToList(); + return rawDevices.Select(d => new WindowsSystemDevice(d)).ToList(); } } diff --git a/userspace-backend/DriverConfigActivator.cs b/userspace-backend/DriverConfigActivator.cs deleted file mode 100644 index 03d78489..00000000 --- a/userspace-backend/DriverConfigActivator.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace userspace_backend -{ - public interface IDriverConfigActivator - { - void Write(DriverConfig config); - } - - public sealed class DriverConfigActivator : IDriverConfigActivator - { - public void Write(DriverConfig config) => config.Activate(); - } -} diff --git a/userspace-backend/IO/DevicesReaderWriter.cs b/userspace-backend/IO/DevicesReaderWriter.cs index 964f7e8c..6db902aa 100644 --- a/userspace-backend/IO/DevicesReaderWriter.cs +++ b/userspace-backend/IO/DevicesReaderWriter.cs @@ -8,7 +8,7 @@ namespace userspace_backend.IO { public class DevicesReaderWriter : ReaderWriterBase> { - public static JsonSerializerOptions JsonOptions = new JsonSerializerOptions { WriteIndented = true }; + public static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { WriteIndented = true }; protected override string FileType => "Devices"; @@ -17,7 +17,7 @@ public override string Serialize(IEnumerable devices) return JsonSerializer.Serialize(devices, JsonOptions); } - public override IEnumerable Deserialize(string toRead) + public override IEnumerable? Deserialize(string toRead) { return JsonSerializer.Deserialize>(toRead, JsonOptions); } diff --git a/userspace-backend/IO/MappingsReaderWriter.cs b/userspace-backend/IO/MappingsReaderWriter.cs index 5c4d5018..c7574392 100644 --- a/userspace-backend/IO/MappingsReaderWriter.cs +++ b/userspace-backend/IO/MappingsReaderWriter.cs @@ -11,7 +11,7 @@ namespace userspace_backend.IO { public class MappingsReaderWriter : ReaderWriterBase { - public static JsonSerializerOptions JsonOptions = new JsonSerializerOptions + public static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { WriteIndented = true, }; @@ -23,7 +23,7 @@ public override string Serialize(MappingSet toWrite) return JsonSerializer.Serialize(toWrite, JsonOptions); } - public override MappingSet Deserialize(string toRead) + public override MappingSet? Deserialize(string toRead) { return JsonSerializer.Deserialize(toRead, JsonOptions); } diff --git a/userspace-backend/IO/ProfileReaderWriter.cs b/userspace-backend/IO/ProfileReaderWriter.cs index a1ea4bad..39444f06 100644 --- a/userspace-backend/IO/ProfileReaderWriter.cs +++ b/userspace-backend/IO/ProfileReaderWriter.cs @@ -7,7 +7,7 @@ namespace userspace_backend.IO { public class ProfileReaderWriter : ReaderWriterBase { - public static JsonSerializerOptions JsonOptions = new JsonSerializerOptions + public static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, @@ -20,7 +20,7 @@ public class ProfileReaderWriter : ReaderWriterBase protected override string FileType => "Profile"; - public override DATA.Profile Deserialize(string toRead) + public override DATA.Profile? Deserialize(string toRead) { return JsonSerializer.Deserialize(toRead, JsonOptions); } diff --git a/userspace-backend/IO/ReaderWriterBase.cs b/userspace-backend/IO/ReaderWriterBase.cs index 8735fc34..3655b48f 100644 --- a/userspace-backend/IO/ReaderWriterBase.cs +++ b/userspace-backend/IO/ReaderWriterBase.cs @@ -1,9 +1,5 @@ -using System; -using System.Collections.Generic; +using System; using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace userspace_backend.IO { @@ -20,18 +16,19 @@ public void Write(string path, T toWrite) var parent = Directory.GetParent(path)?.FullName; - if (!Directory.Exists(parent)) + if (parent != null && !Directory.Exists(parent)) { Directory.CreateDirectory(parent); } - var devicesText = Serialize(toWrite); + var serialized = Serialize(toWrite); using (StreamWriter outputFile = new StreamWriter(path)) { - outputFile.Write(devicesText); + outputFile.Write(serialized); } } + public T Read(string path) { if (!File.Exists(path)) @@ -39,25 +36,30 @@ public T Read(string path) throw new FileNotFoundException(path); } - T readIn = default; - - try + string fileText; + using (StreamReader fileToRead = new StreamReader(path)) { - using (StreamReader fileToRead = new StreamReader(path)) - { - var fileText = fileToRead.ReadToEnd(); + fileText = fileToRead.ReadToEnd(); + } - if (string.IsNullOrWhiteSpace(fileText)) - { - throw new Exception($"{FileType} file is empty."); - } + if (string.IsNullOrWhiteSpace(fileText)) + { + throw new Exception($"{FileType} file is empty."); + } - readIn = Deserialize(fileText); - } + T? readIn; + try + { + readIn = Deserialize(fileText); } catch (Exception ex) { - throw new Exception($"Error parsing devices file at path {path}", ex); + throw new Exception($"Error parsing {FileType} file at path {path}", ex); + } + + if (readIn is null) + { + throw new Exception($"{FileType} file deserialized to null at path {path}."); } return readIn; @@ -65,6 +67,6 @@ public T Read(string path) public abstract string Serialize(T toWrite); - public abstract T Deserialize(string toRead); + public abstract T? Deserialize(string toRead); } } diff --git a/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs b/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs index 2c27de18..2df9dac3 100644 --- a/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs +++ b/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs @@ -1,11 +1,7 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using System.Threading.Tasks; using userspace_backend.Data.Profiles; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; @@ -77,8 +73,8 @@ private static AccelerationDefinitionType DetermineDefinitionType(string typeStr { if (!Enum.TryParse(typeStringFromJson, ignoreCase: true, out AccelerationDefinitionType result)) { - throw new JsonException($"Acceleration base type [\"{typeStringFromJson}\"] not valid." + - $"Valid values: [{string.Join(", ", Enum.GetNames(typeof(AccelerationDefinitionType)))}"); + throw new JsonException($"Acceleration base type [\"{typeStringFromJson}\"] not valid. " + + $"Valid values: [{string.Join(", ", Enum.GetNames(typeof(AccelerationDefinitionType)))}]"); } return result; @@ -100,7 +96,7 @@ private static Acceleration CreateAccelerationOfType(AccelerationDefinitionType private static FormulaAccel CreateFormulaAccel(string[] defnSplit, ref Utf8JsonReader readerFromStart) { - if (defnSplit.Length < 1) + if (defnSplit.Length < 2) { throw new JsonException("Type \"Formula\" must be followed by a forward slash and formula type. Example: \"Type\": \"Formula/Classic\""); } @@ -137,8 +133,8 @@ private static AccelerationFormulaType DetermineFormulaType(string formulaTypeFr { if (!Enum.TryParse(formulaTypeFromJson, ignoreCase: true, out AccelerationFormulaType result)) { - throw new JsonException($"Acceleration formula type [\"{formulaTypeFromJson}\"] not valid." + - $"Valid values: [{string.Join(", ", Enum.GetNames(typeof(AccelerationFormulaType)))}"); + throw new JsonException($"Acceleration formula type [\"{formulaTypeFromJson}\"] not valid. " + + $"Valid values: [{string.Join(", ", Enum.GetNames(typeof(AccelerationFormulaType)))}]"); } return result; @@ -177,15 +173,29 @@ public override void Write(Utf8JsonWriter writer, Acceleration value, JsonSerial _ => v.Type.ToString(), }; - private static JsonSerializerOptions WriteOptionsWithoutSelf(JsonSerializerOptions src) + // Cache the derived options keyed by the source instance so we do not + // rebuild a copy on every Write. The same JsonSerializerOptions instance + // is reused for the lifetime of a reader/writer, so this is a near-perfect + // hit; if a different source ever arrives we simply re-derive. A concurrent + // first-call race only wastes one redundant copy, so no locking is needed. + private JsonSerializerOptions? cachedSource; + private JsonSerializerOptions? cachedWriteOptions; + + private JsonSerializerOptions WriteOptionsWithoutSelf(JsonSerializerOptions src) { - var copy = new JsonSerializerOptions(src); - for (int i = copy.Converters.Count - 1; i >= 0; --i) + if (!ReferenceEquals(src, cachedSource)) { - if (copy.Converters[i] is AccelerationJsonConverter) - copy.Converters.RemoveAt(i); + var copy = new JsonSerializerOptions(src); + for (int i = copy.Converters.Count - 1; i >= 0; --i) + { + if (copy.Converters[i] is AccelerationJsonConverter) + copy.Converters.RemoveAt(i); + } + cachedSource = src; + cachedWriteOptions = copy; } - return copy; + + return cachedWriteOptions!; } } } diff --git a/userspace-backend/IO/SettingsReaderWriter.cs b/userspace-backend/IO/SettingsReaderWriter.cs index 4e8cdcf9..a363def6 100644 --- a/userspace-backend/IO/SettingsReaderWriter.cs +++ b/userspace-backend/IO/SettingsReaderWriter.cs @@ -6,10 +6,9 @@ namespace userspace_backend.IO { public class SettingsReaderWriter : ReaderWriterBase { - public static JsonSerializerOptions JsonOptions = new JsonSerializerOptions - { + public static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; protected override string FileType => "Settings"; @@ -21,14 +20,10 @@ public override string Serialize(Settings settings) public override Settings Deserialize(string toRead) { - try - { - return JsonSerializer.Deserialize(toRead, JsonOptions) ?? new Settings(); - } - catch (JsonException) - { - return new Settings(); - } + // A literal "null" payload maps to defaults; malformed JSON is left to + // throw so the caller (BackEndLoader.LoadSettings) can decide, the same + // way the sibling reader/writers behave. + return JsonSerializer.Deserialize(toRead, JsonOptions) ?? new Settings(); } } } \ No newline at end of file diff --git a/userspace-backend/Logging/EditableSettingLog.cs b/userspace-backend/Logging/EditableSettingLog.cs deleted file mode 100644 index 22debcdf..00000000 --- a/userspace-backend/Logging/EditableSettingLog.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace userspace_backend.Logging -{ - public static class EditableSettingLog - { - public static ILogger Logger { get; private set; } = NullLogger.Instance; - - public static void Configure(ILoggerFactory factory) - { - Logger = factory.CreateLogger("userspace_backend.EditableSetting"); - } - } -} diff --git a/userspace-backend/Logging/FileLoggerProvider.cs b/userspace-backend/Logging/FileLoggerProvider.cs index aa0dcdbf..0739bfa9 100644 --- a/userspace-backend/Logging/FileLoggerProvider.cs +++ b/userspace-backend/Logging/FileLoggerProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Diagnostics; using System.IO; using System.Text; using Microsoft.Extensions.Logging; @@ -30,7 +31,7 @@ public ILogger CreateLogger(string categoryName) internal void Append(string categoryName, LogLevel level, string message, Exception? exception) { var sb = new StringBuilder(); - sb.Append('[').Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")).Append("] "); + sb.Append('[').Append(DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fffZ")).Append("] "); sb.Append('[').Append(level).Append("] "); sb.Append('[').Append(categoryName).Append("] "); sb.Append(message); @@ -47,9 +48,11 @@ internal void Append(string categoryName, LogLevel level, string message, Except { File.AppendAllText(filePath, sb.ToString(), Encoding.UTF8); } - catch + catch (Exception ex) { - // Never crash the app because of a log write failure. + // Never crash the app because of a log write failure, but make the + // failure observable instead of silently losing log output. + Debug.WriteLine($"FileLoggerProvider failed to write to '{filePath}': {ex}"); } } } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs index 8aff0deb..67d2fa9e 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs @@ -15,20 +15,20 @@ public interface IClassicAccelerationDefinitionModel : IAccelDefinitionModelSpec } public class ClassicAccelerationDefinitionModel - : EditableSettingsSelectable, + : FormulaAccelerationDefinitionModel, IClassicAccelerationDefinitionModel { public const string AccelerationDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Acceleration)}"; public const string ExponentDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Exponent)}"; public const string OffsetDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Offset)}"; - public const string CapDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(CapDIKey)}"; + public const string CapDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Cap)}"; public ClassicAccelerationDefinitionModel( [FromKeyedServices(AccelerationDIKey)]IEditableSettingSpecific acceleration, [FromKeyedServices(ExponentDIKey)]IEditableSettingSpecific exponent, [FromKeyedServices(OffsetDIKey)]IEditableSettingSpecific offset, [FromKeyedServices(CapDIKey)]IEditableSettingSpecific cap) - : base([acceleration, exponent, offset, cap], []) + : base([acceleration, exponent, offset, cap]) { Acceleration = acceleration; Exponent = exponent; @@ -44,7 +44,7 @@ public ClassicAccelerationDefinitionModel( public IEditableSettingSpecific Cap { get; set; } - public AccelArgs MapToDriver() + public override AccelArgs MapToDriver() { return new AccelArgs { @@ -75,10 +75,5 @@ protected override bool TryMapEditableSettingsFromData(ClassicAccel data) & Offset.TryUpdateModelDirectly(data.Offset) & Cap.TryUpdateModelDirectly(data.Cap); } - - protected override bool TryMapEditableSettingsCollectionsFromData(ClassicAccel data) - { - return true; - } } } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/FormulaAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/FormulaAccelerationDefinitionModel.cs new file mode 100644 index 00000000..3d69fc5c --- /dev/null +++ b/userspace-backend/Model/AccelDefinitions/Formula/FormulaAccelerationDefinitionModel.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using userspace_backend.Data.Profiles.Accel; +using userspace_backend.Model.EditableSettings; +using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; + +namespace userspace_backend.Model.AccelDefinitions.Formula +{ + /// + /// Shared base for the per-formula definition models (Classic, Jump, Linear, + /// Natural, Power, Synchronous). Every formula is a flat set of leaf + /// double settings with no nested collections, so the collection mapping is a + /// no-op for all of them. Subclasses supply the formula-specific + /// , MapToData, and per-field MapFromData. + /// + public abstract class FormulaAccelerationDefinitionModel + : EditableSettingsSelectable + where TData : FormulaAccel + { + protected FormulaAccelerationDefinitionModel(IEnumerable curveParameters) + : base(curveParameters, []) + { + } + + public abstract AccelArgs MapToDriver(); + + // No formula has nested settings collections; its parameters are all leaf settings. + protected sealed override bool TryMapEditableSettingsCollectionsFromData(TData data) => true; + } +} diff --git a/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs index 0fdb76c5..d3e9b5ba 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs @@ -13,18 +13,18 @@ public interface IJumpAccelerationDefinitionModel : IAccelDefinitionModelSpecifi } public class JumpAccelerationDefinitionModel - : EditableSettingsSelectable, + : FormulaAccelerationDefinitionModel, IJumpAccelerationDefinitionModel { - public const string SmoothDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Smooth)}"; - public const string InputDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Input)}"; - public const string OutputDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Output)}"; + public const string SmoothDIKey = $"{nameof(JumpAccelerationDefinitionModel)}.{nameof(Smooth)}"; + public const string InputDIKey = $"{nameof(JumpAccelerationDefinitionModel)}.{nameof(Input)}"; + public const string OutputDIKey = $"{nameof(JumpAccelerationDefinitionModel)}.{nameof(Output)}"; public JumpAccelerationDefinitionModel( [FromKeyedServices(SmoothDIKey)]IEditableSettingSpecific smooth, [FromKeyedServices(InputDIKey)]IEditableSettingSpecific input, [FromKeyedServices(OutputDIKey)]IEditableSettingSpecific output) - : base([smooth, input, output], []) + : base([smooth, input, output]) { Smooth = smooth; Input = input; @@ -37,7 +37,7 @@ public JumpAccelerationDefinitionModel( public IEditableSettingSpecific Output { get; set; } - public AccelArgs MapToDriver() + public override AccelArgs MapToDriver() { return new AccelArgs { @@ -63,10 +63,5 @@ protected override bool TryMapEditableSettingsFromData(JumpAccel data) & Input.TryUpdateModelDirectly(data.Input) & Output.TryUpdateModelDirectly(data.Output); } - - protected override bool TryMapEditableSettingsCollectionsFromData(JumpAccel data) - { - return true; - } } } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs index e1eee793..5c40ba04 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs @@ -14,18 +14,18 @@ public interface ILinearAccelerationDefinitionModel : IAccelDefinitionModelSpeci } public class LinearAccelerationDefinitionModel - : EditableSettingsSelectable, + : FormulaAccelerationDefinitionModel, ILinearAccelerationDefinitionModel { public const string AccelerationDIKey = $"{nameof(LinearAccelerationDefinitionModel)}.{nameof(Acceleration)}"; public const string OffsetDIKey = $"{nameof(LinearAccelerationDefinitionModel)}.{nameof(Offset)}"; - public const string CapDIKey = $"{nameof(LinearAccelerationDefinitionModel)}.{nameof(CapDIKey)}"; + public const string CapDIKey = $"{nameof(LinearAccelerationDefinitionModel)}.{nameof(Cap)}"; public LinearAccelerationDefinitionModel( [FromKeyedServices(AccelerationDIKey)]IEditableSettingSpecific acceleration, [FromKeyedServices(OffsetDIKey)]IEditableSettingSpecific offset, [FromKeyedServices(CapDIKey)]IEditableSettingSpecific cap) - : base([acceleration, offset, cap], []) + : base([acceleration, offset, cap]) { Acceleration = acceleration; Offset = offset; @@ -38,7 +38,7 @@ public LinearAccelerationDefinitionModel( public IEditableSettingSpecific Cap { get; set; } - public AccelArgs MapToDriver() + public override AccelArgs MapToDriver() { return new AccelArgs { @@ -67,10 +67,5 @@ protected override bool TryMapEditableSettingsFromData(LinearAccel data) & Offset.TryUpdateModelDirectly(data.Offset) & Cap.TryUpdateModelDirectly(data.Cap); } - - protected override bool TryMapEditableSettingsCollectionsFromData(LinearAccel data) - { - return true; - } } } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs index f28169f9..7e49e1cb 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs @@ -12,7 +12,7 @@ public interface INaturalAccelerationDefinitionModel : IAccelDefinitionModelSpec } public class NaturalAccelerationDefinitionModel - : EditableSettingsSelectable, + : FormulaAccelerationDefinitionModel, INaturalAccelerationDefinitionModel { public const string DecayRateDIKey = $"{nameof(NaturalAccelerationDefinitionModel)}.{nameof(DecayRate)}"; @@ -23,7 +23,7 @@ public NaturalAccelerationDefinitionModel( [FromKeyedServices(DecayRateDIKey)]IEditableSettingSpecific decayRate, [FromKeyedServices(InputOffsetDIKey)]IEditableSettingSpecific inputOffset, [FromKeyedServices(LimitDIKey)]IEditableSettingSpecific limit) - : base([decayRate, inputOffset, limit], []) + : base([decayRate, inputOffset, limit]) { DecayRate = decayRate; InputOffset = inputOffset; @@ -36,7 +36,7 @@ public NaturalAccelerationDefinitionModel( public IEditableSettingSpecific Limit { get; set; } - public AccelArgs MapToDriver() + public override AccelArgs MapToDriver() { return new AccelArgs { @@ -63,10 +63,5 @@ protected override bool TryMapEditableSettingsFromData(NaturalAccel data) & InputOffset.TryUpdateModelDirectly(data.InputOffset) & Limit.TryUpdateModelDirectly(data.Limit); } - - protected override bool TryMapEditableSettingsCollectionsFromData(NaturalAccel data) - { - return true; - } } } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs index d8c034d6..a986283d 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs @@ -15,20 +15,20 @@ public interface IPowerAccelerationDefinitionModel : IAccelDefinitionModelSpecif } public class PowerAccelerationDefinitionModel - : EditableSettingsSelectable, + : FormulaAccelerationDefinitionModel, IPowerAccelerationDefinitionModel { - public const string ScaleDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Scale)}"; - public const string ExponentDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Exponent)}"; - public const string OutputOffsetDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(OutputOffset)}"; - public const string CapDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(CapDIKey)}"; + public const string ScaleDIKey = $"{nameof(PowerAccelerationDefinitionModel)}.{nameof(Scale)}"; + public const string ExponentDIKey = $"{nameof(PowerAccelerationDefinitionModel)}.{nameof(Exponent)}"; + public const string OutputOffsetDIKey = $"{nameof(PowerAccelerationDefinitionModel)}.{nameof(OutputOffset)}"; + public const string CapDIKey = $"{nameof(PowerAccelerationDefinitionModel)}.{nameof(Cap)}"; public PowerAccelerationDefinitionModel( [FromKeyedServices(ScaleDIKey)]IEditableSettingSpecific scale, [FromKeyedServices(ExponentDIKey)]IEditableSettingSpecific exponent, [FromKeyedServices(OutputOffsetDIKey)]IEditableSettingSpecific outputOffset, [FromKeyedServices(CapDIKey)]IEditableSettingSpecific cap) - : base([scale, exponent, outputOffset, cap], []) + : base([scale, exponent, outputOffset, cap]) { Scale = scale; Exponent = exponent; @@ -44,7 +44,7 @@ public PowerAccelerationDefinitionModel( public IEditableSettingSpecific Cap { get; set; } - public AccelArgs MapToDriver() + public override AccelArgs MapToDriver() { return new AccelArgs { @@ -75,10 +75,5 @@ protected override bool TryMapEditableSettingsFromData(PowerAccel data) & OutputOffset.TryUpdateModelDirectly(data.OutputOffset) & Cap.TryUpdateModelDirectly(data.Cap); } - - protected override bool TryMapEditableSettingsCollectionsFromData(PowerAccel data) - { - return true; - } } } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs index 2ef2b089..2415b176 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs @@ -19,7 +19,7 @@ public interface ISynchronousAccelerationDefinitionModel : IAccelDefinitionModel } public class SynchronousAccelerationDefinitionModel - : EditableSettingsSelectable, + : FormulaAccelerationDefinitionModel, ISynchronousAccelerationDefinitionModel { public const string SyncSpeedDIKey = $"{nameof(SynchronousAccelerationDefinitionModel)}.{nameof(SyncSpeed)}"; @@ -32,7 +32,7 @@ public SynchronousAccelerationDefinitionModel( [FromKeyedServices(MotivityDIKey)]IEditableSettingSpecific motivity, [FromKeyedServices(GammaDIKey)]IEditableSettingSpecific gamma, [FromKeyedServices(SmoothnessDIKey)]IEditableSettingSpecific smoothness) - : base([syncSpeed, motivity, gamma, smoothness], []) + : base([syncSpeed, motivity, gamma, smoothness]) { SyncSpeed = syncSpeed; Motivity = motivity; @@ -48,7 +48,7 @@ public SynchronousAccelerationDefinitionModel( public IEditableSettingSpecific Smoothness { get; set; } - public AccelArgs MapToDriver() + public override AccelArgs MapToDriver() { return new AccelArgs { @@ -78,10 +78,5 @@ protected override bool TryMapEditableSettingsFromData(SynchronousAccel data) & Gamma.TryUpdateModelDirectly(data.Gamma) & Smoothness.TryUpdateModelDirectly(data.Smoothness); } - - protected override bool TryMapEditableSettingsCollectionsFromData(SynchronousAccel data) - { - return true; - } } } diff --git a/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs index eb8ac4e7..96668ce1 100644 --- a/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs @@ -39,8 +39,12 @@ public LookupTableDefinitionModel( public AccelArgs MapToDriver() { // data in driver profile must be predefined length for marshalling purposes + double[] lutData = Data.ModelValue.Data; var accelArgsData = new float[AccelArgs.MaxLutPoints*2]; - Data.ModelValue.Data.Select(Convert.ToSingle).ToArray().CopyTo(accelArgsData, 0); + for (int i = 0; i < lutData.Length; i++) + { + accelArgsData[i] = (float)lutData[i]; + } return new AccelArgs { @@ -82,20 +86,13 @@ public LookupTableData(double[]? data = null) public int CompareTo(object? obj) { - if (obj == null) - { - return -1; - } - - double[]? compareTo = obj as double[]; - - if (compareTo == null) + if (obj is not LookupTableData other) { return -1; } // We are using CompareTo as a stand-in for equality - return Data.SequenceEqual(compareTo) ? 0 : -1; + return Data.SequenceEqual(other.Data) ? 0 : -1; } } } diff --git a/userspace-backend/Model/DevicesModel.cs b/userspace-backend/Model/DevicesModel.cs index f3db5cdd..22c39483 100644 --- a/userspace-backend/Model/DevicesModel.cs +++ b/userspace-backend/Model/DevicesModel.cs @@ -16,13 +16,15 @@ public class DevicesModel : EditableSettingsList, IDevices { public DevicesModel( IServiceProvider serviceProvider, - ISystemDevicesProvider systemDevicesProvider) + ISystemDevicesProvider systemDevicesProvider, + DeviceGroups deviceGroups) : base(serviceProvider, [], []) { SystemDevices = systemDevicesProvider; + DeviceGroups = deviceGroups; } - public DeviceGroups DeviceGroups { get; set; } + public DeviceGroups DeviceGroups { get; } public ISystemDevicesProvider SystemDevices { get; protected set; } diff --git a/userspace-backend/Model/EditableSettings/EditableSetting.cs b/userspace-backend/Model/EditableSettings/EditableSetting.cs index dfc4b4f1..fcc60a5d 100644 --- a/userspace-backend/Model/EditableSettings/EditableSetting.cs +++ b/userspace-backend/Model/EditableSettings/EditableSetting.cs @@ -1,11 +1,11 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using System; -using userspace_backend.Logging; namespace userspace_backend.Model.EditableSettings { - public partial class EditableSetting : ObservableObject, IEditableSettingSpecific where T : IComparable + public partial class EditableSettingV2 : ObservableObject, IEditableSettingSpecific where T : IComparable { /// /// This value can be bound in UI for direct editing @@ -21,164 +21,9 @@ public partial class EditableSetting : ObservableObject, IEditableSettingSpec public T CurrentValidatedValue => ModelValue; - public EditableSetting( - string displayName, - T initialValue, - IUserInputParser parser, - IModelValueValidator validator, - bool autoUpdateFromInterface = false, - string localizationKey = null) - { - DisplayName = displayName; - LocalizationKey = localizationKey; - LastWrittenValue = initialValue; - Parser = parser; - Validator = validator; - UpdateModelValueFromLastKnown(); - UpdateInterfaceValue(); - AutoUpdateFromInterface = autoUpdateFromInterface; - } - - /// - /// Display name for this setting in UI - /// - /// TODO: Make private and only use DisplayText for UI - public string DisplayName { get; } - - /// - /// Optional localization key for this setting. If provided, DisplayText will use localized string instead of DisplayName - /// - public string LocalizationKey { get; set; } - - /// - /// Gets the display text for this setting. Returns LocalizationKey if provided, otherwise DisplayName. - /// UI layer should handle actual localization of the key. - /// - public string DisplayText => - !string.IsNullOrEmpty(LocalizationKey) - ? LocalizationKey - : DisplayName ?? string.Empty; - - public string EditedValueForDiplay => InterfaceValue; + public const string LoggerCategoryName = "userspace_backend.EditableSetting"; - public T LastWrittenValue { get; protected set; } - - /// - /// Interface can set this for cases when new value arrives all at once (such as menu selection) - /// instead of cases where new value arrives in parts (typing) - /// - public bool AutoUpdateFromInterface { get; set; } - - private IUserInputParser Parser { get; } - - //TODO: change settings collections init so that this can be made private for non-static validators - public IModelValueValidator Validator { get; set; } - - public bool HasChanged() => ModelValue.CompareTo(LastWrittenValue) == 0; - - public bool TryUpdateFromInterface() - { - if (string.IsNullOrEmpty(InterfaceValue)) - { - UpdateInterfaceValue(); - return false; - } - - if (!Parser.TryParse(InterfaceValue.Trim(), out T parsedValue)) - { - UpdateInterfaceValue(); - return false; - } - - if (parsedValue.CompareTo(ModelValue) == 0) - { - return true; - } - - if (Validator == null) - { - throw new InvalidOperationException( - $"Validator is null for EditableSetting '{DisplayName}'. " + - $"InterfaceValue: '{InterfaceValue}', " + - $"ParsedValue: '{parsedValue}', " + - $"ModelValue: '{ModelValue}', " + - $"Parser: {Parser?.GetType().Name ?? "null"}"); - } - - if (!Validator.Validate(parsedValue)) - { - UpdateInterfaceValue(); - return false; - } - - UpdatedModeValue(parsedValue); - return true; - } - - protected void UpdateInterfaceValue() - { - InterfaceValue = ModelValue?.ToString(); - } - - protected void UpdateModelValueFromLastKnown() - { - UpdatedModeValue(LastWrittenValue); - } - - protected void UpdatedModeValue(T value) - { - ModelValue = value; - } - - partial void OnInterfaceValueChanged(string value) - { - if (AutoUpdateFromInterface) - { - TryUpdateFromInterface(); - } - } - - public bool TryUpdateModelDirectly(T data) - { - if (data.CompareTo(ModelValue) == 0) - { - return true; - } - - if (!Validator.Validate(data)) - { - UpdateInterfaceValue(); - EditableSettingLog.Logger.LogDebug( - "Setting '{Name}' ({Type}) [v1] rejected direct update: value {Value} failed validation", - DisplayName, typeof(T).Name, data); - return false; - } - - T previous = ModelValue; - UpdatedModeValue(data); - UpdateInterfaceValue(); - EditableSettingLog.Logger.LogDebug( - "Setting '{Name}' ({Type}) [v1] changed: {Old} -> {New}", - DisplayName, typeof(T).Name, previous, data); - return true; - } - } - - public partial class EditableSettingV2 : ObservableObject, IEditableSettingSpecific where T : IComparable - { - /// - /// This value can be bound in UI for direct editing - /// - [ObservableProperty] - public string interfaceValue; - - /// - /// This value can be bound in UI for logic based on validated input - /// - [ObservableProperty] - public T modelValue; - - public T CurrentValidatedValue => ModelValue; + private readonly ILogger logger; public EditableSettingV2( string displayName, @@ -186,7 +31,8 @@ public EditableSettingV2( IUserInputParser parser, IModelValueValidator validator, bool autoUpdateFromInterface = false, - string localizationKey = null) + string localizationKey = null, + ILogger? logger = null) { DisplayName = displayName; LocalizationKey = localizationKey; @@ -196,6 +42,7 @@ public EditableSettingV2( UpdateModelValueFromLastKnown(); SetInterfaceToModel(); AutoUpdateFromInterface = autoUpdateFromInterface; + this.logger = logger ?? NullLogger.Instance; } /// @@ -210,7 +57,7 @@ public EditableSettingV2( ? LocalizationKey : DisplayName ?? string.Empty; - public string EditedValueForDiplay => InterfaceValue; + public string EditedValueForDisplay => InterfaceValue; public T LastWrittenValue { get; protected set; } @@ -227,7 +74,7 @@ public EditableSettingV2( private bool AllowAutoUpdateFromInterface { get; set; } = true; - public bool HasChanged() => ModelValue.CompareTo(LastWrittenValue) == 0; + public bool HasChanged() => ModelValue.CompareTo(LastWrittenValue) != 0; public bool TryUpdateFromInterface() { @@ -247,7 +94,7 @@ protected bool TryUpdateFromInterfaceImpl(out bool editedInterfaceNeedsReset) if (string.IsNullOrEmpty(InterfaceValue)) { - EditableSettingLog.Logger.LogDebug( + logger.LogDebug( "Setting '{Name}' ({Type}) commit skipped: InterfaceValue is empty", DisplayName, typeof(T).Name); return false; @@ -255,7 +102,7 @@ protected bool TryUpdateFromInterfaceImpl(out bool editedInterfaceNeedsReset) if (!Parser.TryParse(InterfaceValue.Trim(), out T parsedValue)) { - EditableSettingLog.Logger.LogDebug( + logger.LogDebug( "Setting '{Name}' ({Type}) commit rejected: parser could not parse '{Value}'", DisplayName, typeof(T).Name, InterfaceValue); return false; @@ -309,7 +156,7 @@ private bool TryUpdateModelDirectlyImpl(T data, out bool editedInterfaceNeedsRes if (!Validator.Validate(data)) { editedInterfaceNeedsReset = true; - EditableSettingLog.Logger.LogDebug( + logger.LogDebug( "Setting '{Name}' ({Type}) rejected direct update: value {Value} failed validation", DisplayName, typeof(T).Name, data); return false; @@ -318,7 +165,7 @@ private bool TryUpdateModelDirectlyImpl(T data, out bool editedInterfaceNeedsRes T previous = ModelValue; UpdateModeValue(data); SetInterfaceToModel(); - EditableSettingLog.Logger.LogDebug( + logger.LogDebug( "Setting '{Name}' ({Type}) changed: {Old} -> {New}", DisplayName, typeof(T).Name, previous, data); return true; diff --git a/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs b/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs index 64c15d1f..fb405eef 100644 --- a/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs +++ b/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs @@ -1,4 +1,4 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; using System; using System.Collections.Generic; using System.ComponentModel; @@ -6,36 +6,88 @@ namespace userspace_backend.Model.EditableSettings { - public abstract class EditableSettingsCollection : ObservableObject, IEditableSettingsCollectionV2 + /// + /// Internal node of the settings tree. + /// + /// + /// The settings model for this backend is a composed object containing other composed objects and settings. + /// In this way, the objects form a tree. The root node is the base model class, the internal nodes are objects containing other objects + /// and settings, and the settings themselves are the leaf nodes. + /// This interface is then an internal node of the tree. It can contain other settings collections (internal nodes) and also contain + /// settings themselves. + /// + public interface IEditableSettingsCollectionV2 { - public EditableSettingsCollection(T dataObject) - { - InitEditableSettingsAndCollections(dataObject); - GatherEditableSettings(); - GatherEditableSettingsCollections(); - } + public bool HasChanged { get; } public EventHandler AnySettingChanged { get; set; } + } - public IEnumerable AllContainedEditableSettings { get; set; } + public interface IEditableSettingsCollectionSpecific : IEditableSettingsCollectionV2 + { + T MapToData(); + + bool TryMapFromData(T data); + } + + public interface INamedEditableSettingsCollectionSpecific : IEditableSettingsCollectionSpecific + { + public IEditableSettingSpecific Name { get; } + } - public IEnumerable AllContainedEditableSettingsCollections { get; set; } + /// + /// Shared base for the settings-collection internal nodes. Holds the change-tracking + /// state and the event plumbing common to both the original () + /// and the dependency-injected () flavors. + /// Subclasses are responsible for populating and + /// and for subscribing the change handlers. + /// + public abstract class EditableSettingsCollectionBase : ObservableObject, IEditableSettingsCollectionV2 + { + public EventHandler AnySettingChanged { get; set; } + + public IEnumerable AllContainedEditableSettings { get; protected set; } + + public IEnumerable AllContainedEditableSettingsCollections { get; protected set; } public bool HasChanged { get; protected set; } public void EvaluateWhetherHasChanged() { - if (AllContainedEditableSettings.Any(s => s.HasChanged()) || - AllContainedEditableSettingsCollections.Any(c => c.HasChanged)) - { - HasChanged = true; - } - else + HasChanged = AllContainedEditableSettings.Any(s => s.HasChanged()) + || AllContainedEditableSettingsCollections.Any(c => c.HasChanged); + } + + protected void EditableSettingChangedEventHandler(object? sender, PropertyChangedEventArgs e) + { + if (string.Equals(e.PropertyName, nameof(IEditableSettingSpecific.ModelValue))) { - HasChanged = false; + OnAnySettingChanged(); } } + protected void EditableSettingsCollectionChangedEventHandler(object? sender, EventArgs e) + { + OnAnySettingChanged(); + } + + protected void OnAnySettingChanged() + { + AnySettingChanged?.Invoke(this, new EventArgs()); + } + + public abstract T MapToData(); + } + + public abstract class EditableSettingsCollection : EditableSettingsCollectionBase + { + public EditableSettingsCollection(T dataObject) + { + InitEditableSettingsAndCollections(dataObject); + GatherEditableSettings(); + GatherEditableSettingsCollections(); + } + public void GatherEditableSettings() { AllContainedEditableSettings = EnumerateEditableSettings(); @@ -61,61 +113,11 @@ public void GatherEditableSettingsCollections() } } - protected void EditableSettingChangedEventHandler(object? sender, PropertyChangedEventArgs e) - { - if (string.Equals(e.PropertyName, nameof(IEditableSettingSpecific.ModelValue))) - { - OnAnySettingChanged(); - } - } - - protected void EditableSettingsCollectionChangedEventHandler(object? sender, EventArgs e) - { - OnAnySettingChanged(); - } - - protected void OnAnySettingChanged() - { - AnySettingChanged?.Invoke(this, new EventArgs()); - } - protected abstract void InitEditableSettingsAndCollections(T dataObject); protected abstract IEnumerable EnumerateEditableSettings(); protected abstract IEnumerable EnumerateEditableSettingsCollections(); - - public abstract T MapToData(); - } - - /// - /// Collection of editable settings and other collections. - /// Internal node of settings tree. - /// - /// - /// The settings model for this backend is a composed object containing other composed objects and settings. - /// In this way, the objects form a tree. The root node is the base model class, the internal nodes are objects containing other objects - /// and settings, and the settings themselves are the leaf nodes. - /// This interface is then an internal node of the tree. It can contain other settings collections (internal nodes) and also contain - /// settings themselves. - /// - public interface IEditableSettingsCollectionV2 - { - public bool HasChanged { get; } - - public EventHandler AnySettingChanged { get; set; } - } - - public interface IEditableSettingsCollectionSpecific : IEditableSettingsCollectionV2 - { - T MapToData(); - - bool TryMapFromData(T data); - } - - public interface INamedEditableSettingsCollectionSpecific : IEditableSettingsCollectionSpecific - { - public IEditableSettingSpecific Name { get; } } /// @@ -128,7 +130,7 @@ public interface INamedEditableSettingsCollectionSpecific : IEditableSettings /// The actual settings collections in the model do not need to each be tested beyond composition. /// /// - public abstract class EditableSettingsCollectionV2 : ObservableObject, IEditableSettingsCollectionSpecific + public abstract class EditableSettingsCollectionV2 : EditableSettingsCollectionBase, IEditableSettingsCollectionSpecific { public EditableSettingsCollectionV2( IEnumerable editableSettings, @@ -154,14 +156,6 @@ public EditableSettingsCollectionV2( } } - public EventHandler AnySettingChanged { get; set; } - - public IEnumerable AllContainedEditableSettings { get; set; } - - public IEnumerable AllContainedEditableSettingsCollections { get; set; } - - public bool HasChanged { get; protected set; } - public bool TryMapFromData(T data) { bool result = true; @@ -172,39 +166,6 @@ public bool TryMapFromData(T data) return result; } - public void EvaluateWhetherHasChanged() - { - if (AllContainedEditableSettings.Any(s => s.HasChanged()) || - AllContainedEditableSettingsCollections.Any(c => c.HasChanged)) - { - HasChanged = true; - } - else - { - HasChanged = false; - } - } - - protected void EditableSettingChangedEventHandler(object? sender, PropertyChangedEventArgs e) - { - if (string.Equals(e.PropertyName, nameof(IEditableSettingSpecific.ModelValue))) - { - OnAnySettingChanged(); - } - } - - protected void EditableSettingsCollectionChangedEventHandler(object? sender, EventArgs e) - { - OnAnySettingChanged(); - } - - protected void OnAnySettingChanged() - { - AnySettingChanged?.Invoke(this, new EventArgs()); - } - - public abstract T MapToData(); - protected abstract bool TryMapEditableSettingsFromData(T data); protected abstract bool TryMapEditableSettingsCollectionsFromData(T data); diff --git a/userspace-backend/Model/EditableSettings/EditableSettingsList.cs b/userspace-backend/Model/EditableSettings/EditableSettingsList.cs index 7afdc0ef..db9a3546 100644 --- a/userspace-backend/Model/EditableSettings/EditableSettingsList.cs +++ b/userspace-backend/Model/EditableSettings/EditableSettingsList.cs @@ -68,6 +68,7 @@ public bool TryInsert(int index, T element) if (!ContainsElementWithName(name)) { + element.AnySettingChanged += EditableSettingsCollectionChangedEventHandler; ElementsInternal.Insert(index, element); return true; } @@ -111,7 +112,14 @@ public bool TryGetElement(string name, out T? element) public bool TryRemoveElement(T element) { - return ElementsInternal.Remove(element); + bool removed = ElementsInternal.Remove(element); + + if (removed) + { + element.AnySettingChanged -= EditableSettingsCollectionChangedEventHandler; + } + + return removed; } protected bool ContainsElementWithName(string name) => TryGetElement(name, out T? _); @@ -120,6 +128,7 @@ public bool TryRemoveElement(T element) protected void AddElement(T element) { + element.AnySettingChanged += EditableSettingsCollectionChangedEventHandler; ElementsInternal.Add(element); } @@ -141,20 +150,32 @@ protected override bool TryMapEditableSettingsCollectionsFromData(IEnumerable { bool result = true; + var incomingNames = new HashSet(StringComparer.InvariantCultureIgnoreCase); + foreach (U dataElement in data) { string elementName = GetNameFromData(dataElement); + incomingNames.Add(elementName); if (!TryGetElement(elementName, out T? element)) { element = GenerateDefaultElement(elementName); - + AddElement(element); } result &= element!.TryMapFromData(dataElement); } + // The incoming data is the source of truth: drop elements it no longer contains. + // OS-detected devices are merged separately (ImportSystemDevices), not through here. + foreach (T stale in ElementsInternal + .Where(e => !incomingNames.Contains(GetNameFromElement(e))) + .ToList()) + { + TryRemoveElement(stale); + } + return result; } } diff --git a/userspace-backend/Model/EditableSettings/IEditableSetting.cs b/userspace-backend/Model/EditableSettings/IEditableSetting.cs index 562f79ab..73771680 100644 --- a/userspace-backend/Model/EditableSettings/IEditableSetting.cs +++ b/userspace-backend/Model/EditableSettings/IEditableSetting.cs @@ -11,7 +11,7 @@ public interface IEditableSetting : INotifyPropertyChanged string DisplayText { get; } - string EditedValueForDiplay { get; } + string EditedValueForDisplay { get; } string InterfaceValue { get; set; } diff --git a/userspace-backend/Model/EditableSettings/ModelValueValidator.cs b/userspace-backend/Model/EditableSettings/ModelValueValidator.cs index 22ffcb0e..fed8fddc 100644 --- a/userspace-backend/Model/EditableSettings/ModelValueValidator.cs +++ b/userspace-backend/Model/EditableSettings/ModelValueValidator.cs @@ -1,10 +1,56 @@ -namespace userspace_backend.Model.EditableSettings +using System; + +namespace userspace_backend.Model.EditableSettings { public interface IModelValueValidator { bool Validate(T value); } + /// + /// Rejects values outside an optional [min, max] range. Either bound may be + /// omitted (open on that side) and either bound may be inclusive or exclusive. + /// Rejection keeps the last good value (the validator simply returns false). + /// + public class RangeValidator : IModelValueValidator where T : struct, IComparable + { + private readonly T? min; + private readonly T? max; + private readonly bool minInclusive; + private readonly bool maxInclusive; + + public RangeValidator(T? min = null, T? max = null, bool minInclusive = true, bool maxInclusive = true) + { + this.min = min; + this.max = max; + this.minInclusive = minInclusive; + this.maxInclusive = maxInclusive; + } + + public bool Validate(T value) + { + if (min.HasValue) + { + int cmp = value.CompareTo(min.Value); + if (minInclusive ? cmp < 0 : cmp <= 0) + { + return false; + } + } + + if (max.HasValue) + { + int cmp = value.CompareTo(max.Value); + if (maxInclusive ? cmp > 0 : cmp >= 0) + { + return false; + } + } + + return true; + } + } + public class DefaultModelValueValidator : IModelValueValidator { public const string AllChangeInvalidDIKey = nameof(AllChangeInvalidDIKey); diff --git a/userspace-backend/Model/EditableSettings/UserInputParser.cs b/userspace-backend/Model/EditableSettings/UserInputParser.cs index a3f6aa11..2d33181b 100644 --- a/userspace-backend/Model/EditableSettings/UserInputParser.cs +++ b/userspace-backend/Model/EditableSettings/UserInputParser.cs @@ -42,7 +42,9 @@ public class DoubleParser : IUserInputParser { public bool TryParse(string input, out double parsedValue) { - if (double.TryParse(input, out parsedValue)) + if (double.TryParse(input, out parsedValue) && + !double.IsNaN(parsedValue) && + !double.IsInfinity(parsedValue)) { return true; } @@ -66,31 +68,12 @@ public bool TryParse(string input, out bool parsedValue) } } - public class AccelerationDefinitionTypeParser : IUserInputParser + public class EnumParser : IUserInputParser where T : struct, Enum { - public bool TryParse(string input, out Acceleration.AccelerationDefinitionType parsedValue) + public bool TryParse(string input, out T parsedValue) { - if (Enum.TryParse(input, ignoreCase: true, out parsedValue)) - { - return true; - } - - parsedValue = default; - return false; - } - } - - public class LookupTableTypeParser : IUserInputParser - { - public bool TryParse(string input, out LookupTableAccel.LookupTableType parsedValue) - { - if (Enum.TryParse(input, ignoreCase: true, out parsedValue)) - { - return true; - } - - parsedValue = default; - return false; + // Enum.TryParse sets parsedValue to default on failure. + return Enum.TryParse(input, ignoreCase: true, out parsedValue); } } @@ -131,18 +114,4 @@ IEnumerable ToDoubles(string[] splitInput) return false; } } - - public class AccelerationFormulaTypeParser : IUserInputParser - { - public bool TryParse(string input, out FormulaAccel.AccelerationFormulaType parsedValue) - { - if (Enum.TryParse(input, ignoreCase: true, out parsedValue)) - { - return true; - } - - parsedValue = default; - return false; - } - } } diff --git a/userspace-backend/Model/MappingModel.cs b/userspace-backend/Model/MappingModel.cs index f8a85ebb..3f5ba819 100644 --- a/userspace-backend/Model/MappingModel.cs +++ b/userspace-backend/Model/MappingModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.ObjectModel; using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; using userspace_backend.Model.EditableSettings; using userspace_backend.Data; using System.Collections.Specialized; @@ -42,6 +43,9 @@ public MappingModel( { dg.DeviceGroupModels.CollectionChanged += OnIndividualMappingsChanged; } + + // When a referenced profile is deleted, reassign affected entries to the default profile. + ((INotifyCollectionChanged)Profiles.Profiles).CollectionChanged += OnProfilesChanged; } private bool setActive; @@ -132,6 +136,43 @@ protected void OnIndividualMappingsChanged(object? sender, NotifyCollectionChang FindDeviceGroupsStillUnmapped(); } + protected void OnProfilesChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.OldItems == null) + { + return; + } + + foreach (IProfileModel removed in e.OldItems.OfType()) + { + ReassignGroupsFromRemovedProfile(removed); + } + } + + private void ReassignGroupsFromRemovedProfile(IProfileModel removedProfile) + { + IProfileModel? fallback = Profiles.DefaultProfile; + + // If the default profile itself was the one removed, fall back to any remaining profile. + if (fallback == null || ReferenceEquals(fallback, removedProfile)) + { + fallback = Profiles.Profiles.FirstOrDefault(p => !ReferenceEquals(p, removedProfile)); + } + + if (fallback == null) + { + return; + } + + foreach (MappingGroup group in IndividualMappings) + { + if (ReferenceEquals(group.Profile, removedProfile)) + { + group.Profile = fallback; + } + } + } + protected override bool TryMapEditableSettingsFromData(Mapping data) { return Name.TryUpdateModelDirectly(data.Name); @@ -143,11 +184,17 @@ protected override bool TryMapEditableSettingsCollectionsFromData(Mapping data) } } - public class MappingGroup + public class MappingGroup : ObservableObject { public string DeviceGroup { get; set; } - public IProfileModel Profile { get; set; } + private IProfileModel profile; + + public IProfileModel Profile + { + get => profile; + set => SetProperty(ref profile, value); + } // This is here for easy binding public IProfilesModel Profiles { get; set; } diff --git a/userspace-backend/Model/MappingsModel.cs b/userspace-backend/Model/MappingsModel.cs index 75a79825..0cc56e75 100644 --- a/userspace-backend/Model/MappingsModel.cs +++ b/userspace-backend/Model/MappingsModel.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -184,7 +185,9 @@ public bool TryAddMapping(DATA.Mapping? mappingToAdd = null) displayName: "Name", initialValue: mappingToAdd.Name, parser: ServiceProvider.GetRequiredService>(), - validator: NameValidator); + validator: NameValidator, + logger: ServiceProvider.GetService() + ?.CreateLogger(EditableSettingV2.LoggerCategoryName)); // Construct MappingModel with DI pattern MappingModel mapping = new MappingModel(nameSetting, NameValidator, DeviceGroups, Profiles, mappingToAdd); diff --git a/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs b/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs index 075fb68a..24b1f871 100644 --- a/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs +++ b/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using userspace_backend.Data.Profiles; using userspace_backend.Model.EditableSettings; +using Vec2D = RawAccel.Contracts.Vec2; namespace userspace_backend.Model.ProfileComponents { @@ -17,6 +18,10 @@ public interface IAnisotropyModel : IEditableSettingsCollectionSpecific LPNorm { get; } IEditableSettingSpecific CombineXYComponents { get; } + + Vec2D MapDomainToDriver(); + + Vec2D MapRangeToDriver(); } public class AnisotropyModel : EditableSettingsCollectionV2, IAnisotropyModel @@ -57,6 +62,10 @@ public AnisotropyModel( public IEditableSettingSpecific CombineXYComponents { get; set; } + public Vec2D MapDomainToDriver() => new Vec2D { x = DomainX.ModelValue, y = DomainY.ModelValue }; + + public Vec2D MapRangeToDriver() => new Vec2D { x = RangeX.ModelValue, y = RangeY.ModelValue }; + public override Anisotropy MapToData() { return new Anisotropy() @@ -64,6 +73,7 @@ public override Anisotropy MapToData() Domain = new Vector2() { X = DomainX.ModelValue, Y = DomainY.ModelValue }, Range = new Vector2() { X = RangeX.ModelValue, Y = RangeY.ModelValue }, LPNorm = LPNorm.ModelValue, + CombineXYComponents = CombineXYComponents.ModelValue, }; } diff --git a/userspace-backend/Model/ProfileModel.cs b/userspace-backend/Model/ProfileModel.cs index 64c48358..efdabca9 100644 --- a/userspace-backend/Model/ProfileModel.cs +++ b/userspace-backend/Model/ProfileModel.cs @@ -6,13 +6,13 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using userspace_backend.Common; using userspace_backend.Display; using userspace_backend.Model.AccelDefinitions; using userspace_backend.Model.EditableSettings; using userspace_backend.Model.ProfileComponents; using DATA = userspace_backend.Data; using Profile = RawAccel.Contracts.RawAccelProfile; +using SpeedArgs = RawAccel.Contracts.RawAccelSpeedArgs; namespace userspace_backend.Model { @@ -98,8 +98,6 @@ public ProfileModel( [Obsolete("Use XCurvePreview instead")] public ICurvePreview CurvePreview => XCurvePreview; - protected IModelValueValidator NameValidator { get; } - public override DATA.Profile MapToData() { return new DATA.Profile() @@ -112,6 +110,43 @@ public override DATA.Profile MapToData() }; } + public Profile MapToDriver() + { + return new Profile() + { + name = Name.ModelValue, + outputDPI = OutputDPI.ModelValue, + yxOutputDPIRatio = YXRatio.ModelValue, + + // Both axes get the same single UI curve, but argsX and argsY MUST be independent + // instances: the native wrapper mutates each axis's data array separately, so sharing + // one reference would corrupt it. Do not collapse these two calls into a shared + // variable. Pinned by BackEndApplyTests.Apply_SingleCurve_PopulatesBothAxes. + argsX = Acceleration.MapToDriver(), + argsY = Acceleration.MapToDriver(), + + domainXY = Acceleration.Anisotropy.MapDomainToDriver(), + rangeXY = Acceleration.Anisotropy.MapRangeToDriver(), + rotation = Hidden.RotationDegrees.ModelValue, + lrOutputDPIRatio = Hidden.LeftRightRatio.ModelValue, + udOutputDPIRatio = Hidden.UpDownRatio.ModelValue, + snap = Hidden.AngleSnappingDegrees.ModelValue, + maximumSpeed = Hidden.SpeedCap.ModelValue, + + // The driver supports a speed floor (common/rawaccel-base.hpp), but the UI + // deliberately does not expose one; keep it pinned at 0. + minimumSpeed = 0, + inputSpeedArgs = new SpeedArgs + { + combineMagnitudes = Acceleration.Anisotropy.CombineXYComponents.ModelValue, + lpNorm = Acceleration.Anisotropy.LPNorm.ModelValue, + outputSmoothHalflife = Hidden.OutputSmoothingHalfLife.ModelValue, + inputSmoothHalflife = Acceleration.Coalescion.InputSmoothingHalfLife.ModelValue, + scaleSmoothHalflife = Acceleration.Coalescion.ScaleSmoothingHalfLife.ModelValue, + } + }; + } + protected void AnyNonPreviewPropertyChangedEventHandler(object? send, PropertyChangedEventArgs e) { if (string.Equals(e.PropertyName, nameof(IEditableSettingSpecific.ModelValue))) @@ -139,7 +174,7 @@ protected void AnyCurveSettingCollectionChangedEventHandler(object? sender, Even protected void RecalculateDriverData() { - CurrentValidatedDriverProfile = DriverHelpers.MapProfileModelToDriver(this); + CurrentValidatedDriverProfile = MapToDriver(); logger.LogDebug( "RecalculateDriverData for profile {Name}: outputDPI={OutputDPI} argsX.mode={Mode} argsX.accel={Accel}", Name?.ModelValue ?? "", diff --git a/userspace-backend/Model/ProfilesModel.cs b/userspace-backend/Model/ProfilesModel.cs index cde732cd..b5ef66ab 100644 --- a/userspace-backend/Model/ProfilesModel.cs +++ b/userspace-backend/Model/ProfilesModel.cs @@ -49,7 +49,8 @@ public ProfilesModel(IServiceProvider serviceProvider) public ReadOnlyObservableCollection Profiles => Elements; - public IProfileModel? DefaultProfile => Elements.FirstOrDefault(p => p.Name.ModelValue == "default"); + public IProfileModel? DefaultProfile => + Elements.FirstOrDefault(p => string.Equals(p.Name.ModelValue, "default", StringComparison.InvariantCultureIgnoreCase)); public bool TryGetProfile(string name, out IProfileModel? profile) => TryGetElement(name, out profile); diff --git a/userspace-backend/Model/SystemDevices.cs b/userspace-backend/Model/SystemDevices.cs index 1831e08f..f31ec813 100644 --- a/userspace-backend/Model/SystemDevices.cs +++ b/userspace-backend/Model/SystemDevices.cs @@ -51,8 +51,9 @@ public void RefreshSystemDevices() /// /// Retrieves list of devices from operating system. Concrete impls are - /// per-platform; WindowsSystemDevicesRetriever reads RawInput via - /// wrapper.dll. + /// per-platform: WindowsSystemDevicesRetriever (RawInput via wrapper.dll) + /// or LinuxSystemDevicesRetriever (currently a stub; future: query the + /// agent or read /dev/input directly). /// public interface ISystemDevicesRetriever { diff --git a/userspace-backend/userspace-backend.csproj b/userspace-backend/userspace-backend.csproj index 59c29e57..4c350e18 100644 --- a/userspace-backend/userspace-backend.csproj +++ b/userspace-backend/userspace-backend.csproj @@ -17,11 +17,20 @@ - + + + + + + + + From 6f3eb0b03cea0e5564dae9e722afc7ecdd5cde6b Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 27 May 2026 17:29:27 -0400 Subject: [PATCH 03/17] userspace-backend: reconcile dangling Linux refs after transplant Make the transplanted backend compile without the excluded Linux driver code (userspace-backend/Driver/Linux/**): - BackEndComposer: drop the Linux DI registration branch; platform switch is now Windows-or-throw (message updated to "Windows only"). - CurvePreviewDisposalTests: remove AccelInstance_DisposeIsIdempotent (it instantiated LinuxAccelEvaluator); the 4 FakeEvaluator-based disposal-contract tests still cover the regression. --- .../DisplayTests/CurvePreviewDisposalTests.cs | 17 ----------------- userspace-backend/BackEndComposer.cs | 9 +-------- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs b/userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs index 924a8b81..85405365 100644 --- a/userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs +++ b/userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs @@ -4,7 +4,6 @@ using RawAccel.Contracts; using userspace_backend.Display; using userspace_backend.Driver; -using userspace_backend.Driver.Linux; namespace userspace_backend_tests.DisplayTests { @@ -107,21 +106,5 @@ public void GeneratePoints_DisposesInstance_WhenAccelerateThrows() Assert.AreEqual(1, evaluator.Created[0].DisposeCount, "instance must be disposed even when evaluation throws"); } - - [TestMethod] - public void AccelInstance_DisposeIsIdempotent() - { - // Guards the ShimInstance double-free guard. With the native shim - // present this disposes a real ra_curve handle; without it, the - // evaluator returns the identity instance. Either way a second - // Dispose() must be a safe no-op (no double native Destroy). - var evaluator = new LinuxAccelEvaluator(); - IAccelInstance instance = evaluator.CreateInstance(new RawAccelProfile()); - - instance.Dispose(); - instance.Dispose(); - - Assert.IsNotNull(instance); - } } } diff --git a/userspace-backend/BackEndComposer.cs b/userspace-backend/BackEndComposer.cs index 289ff1fb..79b0c018 100644 --- a/userspace-backend/BackEndComposer.cs +++ b/userspace-backend/BackEndComposer.cs @@ -6,7 +6,6 @@ using DATA = userspace_backend.Data; using userspace_backend.Display; using userspace_backend.Driver; -using userspace_backend.Driver.Linux; using userspace_backend.IO; using userspace_backend.Model; using userspace_backend.Model.AccelDefinitions; @@ -352,16 +351,10 @@ private static void RegisterPlatformServices(IServiceCollection services) // method can compile on Linux where those types do not exist. RegisterWindowsServicesByReflection(services); } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - } else { throw new PlatformNotSupportedException( - $"Raw Accel backend supports Windows and Linux only; current platform: {RuntimeInformation.OSDescription}"); + $"Raw Accel backend supports Windows only; current platform: {RuntimeInformation.OSDescription}"); } } From b7eaa5478a67efe6b7d52115c7ce161b428ea959 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 27 May 2026 20:43:02 -0400 Subject: [PATCH 04/17] userspace-backend-tests: make full-backend tests green on non-Windows The BackEndApplyTests compose the real backend graph and were designed to run on Windows and Linux by injecting their own IRawAccelDriver + ISystemDevicesRetriever before Compose. They relied on the platform branch to supply IAccelEvaluator (the now-excluded LinuxAccelEvaluator), so they broke once the Linux driver code was dropped. - BackEndComposer: on non-Windows, register no platform defaults instead of throwing; callers inject what they need and a missing service fails at point of use, not at composition. - BackEndApplyTests: add an identity FakeAccelEvaluator and register it before each Compose so CurvePreview resolves without a platform driver. 70/70 backend tests pass on Linux; userspace-backend and userinterface build. --- .../ModelTests/BackEndApplyTests.cs | 22 +++++++++++++++++++ userspace-backend/BackEndComposer.cs | 10 ++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs index 9a3ef6b7..2ad76a28 100644 --- a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs +++ b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs @@ -84,6 +84,21 @@ public void Deactivate() { } public MouseSpeedSample GetCurrentMouseSpeedSample() => MouseSpeedSample.Zero; } + // Identity evaluator so CurvePreview composes in tests without a platform + // driver (Windows binds wrapper.dll; the Linux impl was excluded here). + private sealed class FakeAccelEvaluator : IAccelEvaluator + { + public IAccelInstance CreateInstance(RawAccelProfile profile) => new IdentityInstance(); + + private sealed class IdentityInstance : IAccelInstance + { + public (double x, double y) Accelerate( + double x, double y, double dpiFactor, double timeMs) => (x, y); + + public void Dispose() { } + } + } + private static (IBackEnd backEnd, CapturingDriver driver) BuildBackEndWithDefaults( IList? systemDevices = null) { @@ -97,6 +112,7 @@ private static (IBackEnd backEnd, CapturingDriver driver) BuildBackEndWithDefaul var driver = new CapturingDriver(); services.AddSingleton(driver); + services.AddSingleton(new FakeAccelEvaluator()); var sp = BackEndComposer.Compose(services); var backEnd = sp.GetRequiredService(); backEnd.Load(); @@ -131,6 +147,7 @@ public void FormulaDIKeys_AreDistinctPerFormula_SoExponentDefaultsDoNotCollide() services.AddSingleton(new StubBackEndLoader()); services.AddSingleton(new StubSystemDevicesRetriever()); services.AddSingleton(new CapturingDriver()); + services.AddSingleton(new FakeAccelEvaluator()); var sp = BackEndComposer.Compose(services); var classicExponent = sp.GetRequiredKeyedService>( @@ -195,6 +212,7 @@ public void EnsureDefaultMapping_StaleEmptyMapping_SelfHealsWithDefaultEntry() services.AddSingleton(new StubSystemDevicesRetriever()); var driver = new CapturingDriver(); services.AddSingleton(driver); + services.AddSingleton(new FakeAccelEvaluator()); var sp = BackEndComposer.Compose(services); var backEnd = sp.GetRequiredService(); backEnd.Load(); @@ -354,6 +372,7 @@ public void ReloadSystemDevices_RemovesDisconnectedAndAddsNew() services.AddSingleton(retrieverStub); services.AddSingleton(new CapturingDriver()); + services.AddSingleton(new FakeAccelEvaluator()); var sp = BackEndComposer.Compose(services); var backEnd = sp.GetRequiredService(); backEnd.Load(); @@ -508,6 +527,7 @@ public void Load_ProfileWithClassicAccel_DoesNotRecurse() services.AddSingleton(new ClassicAccelLoader()); services.AddSingleton(new StubSystemDevicesRetriever()); services.AddSingleton(new CapturingDriver()); + services.AddSingleton(new FakeAccelEvaluator()); var sp = BackEndComposer.Compose(services); var backEnd = sp.GetRequiredService(); @@ -579,6 +599,7 @@ public void Load_ProfileWithZeroAnisotropy_SanitizesToIdentityWeights() services.AddSingleton(new ZeroAnisotropyLoader()); services.AddSingleton(new StubSystemDevicesRetriever()); services.AddSingleton(new CapturingDriver()); + services.AddSingleton(new FakeAccelEvaluator()); var sp = BackEndComposer.Compose(services); var backEnd = sp.GetRequiredService(); @@ -690,6 +711,7 @@ public void Load_CustomDeviceGroupInDevicesAndMappings_RestoresGroupList() services.AddSingleton(new CustomDeviceGroupLoader()); services.AddSingleton(new StubSystemDevicesRetriever()); services.AddSingleton(new CapturingDriver()); + services.AddSingleton(new FakeAccelEvaluator()); var sp = BackEndComposer.Compose(services); var backEnd = sp.GetRequiredService(); diff --git a/userspace-backend/BackEndComposer.cs b/userspace-backend/BackEndComposer.cs index 79b0c018..99397a41 100644 --- a/userspace-backend/BackEndComposer.cs +++ b/userspace-backend/BackEndComposer.cs @@ -351,11 +351,11 @@ private static void RegisterPlatformServices(IServiceCollection services) // method can compile on Linux where those types do not exist. RegisterWindowsServicesByReflection(services); } - else - { - throw new PlatformNotSupportedException( - $"Raw Accel backend supports Windows only; current platform: {RuntimeInformation.OSDescription}"); - } + // On non-Windows no platform driver is bundled (the Windows impls bind + // to wrapper.dll). Callers that need a driver/evaluator/devices + // retriever must register their own before Compose (the test fixtures + // do); an unregistered service surfaces a clear DI error at point of + // use rather than failing the whole composition up front. } private static void RegisterWindowsServicesByReflection(IServiceCollection services) From 1e6ff30b4010e372da275f8ce5608723a8051057 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 27 May 2026 20:53:32 -0400 Subject: [PATCH 05/17] userspace-backend-tests: drop FakeAccelEvaluator comment --- userspace-backend-tests/ModelTests/BackEndApplyTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs index 2ad76a28..ed88cd82 100644 --- a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs +++ b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs @@ -84,8 +84,6 @@ public void Deactivate() { } public MouseSpeedSample GetCurrentMouseSpeedSample() => MouseSpeedSample.Zero; } - // Identity evaluator so CurvePreview composes in tests without a platform - // driver (Windows binds wrapper.dll; the Linux impl was excluded here). private sealed class FakeAccelEvaluator : IAccelEvaluator { public IAccelInstance CreateInstance(RawAccelProfile profile) => new IdentityInstance(); From 6575825592fc19246a943f262e4b6b927659501f Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 28 May 2026 17:25:03 -0700 Subject: [PATCH 06/17] userspace-backend: rename Contracts aliases to clear wrapper-global collisions wrapper.vcxproj exposes Profile/AccelArgs/DeviceSettings/DeviceConfig/ AccelMode/CapMode/SpeedArgs as public C++/CLI types in the global namespace. On Windows, where userspace-backend references the wrapper, the using X = RawAccel.Contracts.RawAccelX; aliases introduced by the Linux transplant collide with those global types (CS0576). Linux builds weren't affected because wrapper isn't referenced there. Prefix the aliases with Ra (RaProfile, RaAccelArgs, etc.) and update in-file usages. Also: - WindowsRawAccelDriver: DriverConfig.Deactivate is static, call it directly instead of via GetDefault(). - userspace-backend-tests: qualify bare Profile/AccelMode references that were silently resolving to the wrapper's globals. Full solution now builds on Windows (driver.vcxproj still requires WDK). userspace-backend-tests: 71/71 pass. wrapper-tests: 10/10 pass. --- .../IOTests/BackEndLoaderRoundTripTests.cs | 4 +-- .../ModelTests/BackEndApplyTests.cs | 4 +-- userspace-backend/BackEnd.cs | 30 +++++++++---------- userspace-backend/Display/CurvePreview.cs | 8 ++--- .../Driver/Windows/WindowsRawAccelDriver.cs | 2 +- .../AccelDefinitions/AccelDefinitionModel.cs | 6 ++-- .../AccelDefinitions/AccelerationModel.cs | 8 ++--- .../ClassicAccelerationDefinitionModel.cs | 16 +++++----- .../FormulaAccelerationDefinitionModel.cs | 4 +-- .../JumpAccelerationDefinitionModel.cs | 12 ++++---- .../LinearAccelerationDefinitionModel.cs | 16 +++++----- .../NaturalAccelerationDefinitionModel.cs | 12 ++++---- .../PowerAccelerationDefinitionModel.cs | 16 +++++----- .../SynchronousAccelerationDefinitionModel.cs | 12 ++++---- .../AccelDefinitions/FormulaAccelModel.cs | 6 ++-- .../LookupTableDefinitionModel.cs | 14 ++++----- .../NoAccelDefinitionModel.cs | 12 ++++---- userspace-backend/Model/ProfileModel.cs | 16 +++++----- 18 files changed, 99 insertions(+), 99 deletions(-) diff --git a/userspace-backend-tests/IOTests/BackEndLoaderRoundTripTests.cs b/userspace-backend-tests/IOTests/BackEndLoaderRoundTripTests.cs index 09dc1b9d..ce2b7dbc 100644 --- a/userspace-backend-tests/IOTests/BackEndLoaderRoundTripTests.cs +++ b/userspace-backend-tests/IOTests/BackEndLoaderRoundTripTests.cs @@ -72,7 +72,7 @@ public void WriteSettings_LandsOnDisk_WithCorrectValues() public void WriteProfiles_LandsOnDisk_WithClassicAccel() { var loader = MakeLoader(); - var profile = new Profile + var profile = new userspace_backend.Data.Profile { Name = "TestProfile", OutputDPI = 1600, @@ -199,7 +199,7 @@ public void LoadSettings_ReturnsNull_WhenFileMissing() [TestMethod] public void Profile_ClassicAccel_SurvivesRoundTrip() { - var profile = new Profile + var profile = new userspace_backend.Data.Profile { Name = "Classic", OutputDPI = 1600, diff --git a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs index ed88cd82..7f0c79ee 100644 --- a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs +++ b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs @@ -427,7 +427,7 @@ public void Apply_ProfileCurveCoefficientEdit_FlowsIntoDriverConfig() "Classic.Acceleration update should succeed."); var cfg = ApplyAndCapture(backEnd, driver); - Assert.AreEqual(AccelMode.classic, cfg.profiles[0].argsX.mode, + Assert.AreEqual(RawAccel.Contracts.AccelMode.classic, cfg.profiles[0].argsX.mode, "DriverConfig should reflect the chosen Classic formula."); Assert.AreEqual(expectedAcceleration, cfg.profiles[0].argsX.acceleration, "DriverConfig should reflect the tweaked Classic.Acceleration coefficient. " + @@ -471,7 +471,7 @@ public void Apply_SingleCurve_PopulatesBothAxes() var cfg = ApplyAndCapture(backEnd, driver); // X is the historically-tested axis; Y is the regression guard. - Assert.AreEqual(AccelMode.classic, cfg.profiles[0].argsY.mode, + Assert.AreEqual(RawAccel.Contracts.AccelMode.classic, cfg.profiles[0].argsY.mode, "argsY must carry the same accel mode as argsX, or vertical acceleration " + "is dead in by-component mode (argsY left at the noaccel default)."); Assert.AreEqual(expectedAcceleration, cfg.profiles[0].argsY.acceleration, diff --git a/userspace-backend/BackEnd.cs b/userspace-backend/BackEnd.cs index 03ef39e4..427cda6a 100644 --- a/userspace-backend/BackEnd.cs +++ b/userspace-backend/BackEnd.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Microsoft.Extensions.DependencyInjection; @@ -8,9 +8,9 @@ using userspace_backend.Driver; using userspace_backend.Model; using DATA = userspace_backend.Data; -using Profile = RawAccel.Contracts.RawAccelProfile; -using DeviceSettings = RawAccel.Contracts.RawAccelDeviceSettings; -using DeviceConfig = RawAccel.Contracts.RawAccelDeviceConfig; +using RaProfile = RawAccel.Contracts.RawAccelProfile; +using RaDeviceSettings = RawAccel.Contracts.RawAccelDeviceSettings; +using RaDeviceConfig = RawAccel.Contracts.RawAccelDeviceConfig; namespace userspace_backend { @@ -305,7 +305,7 @@ private void LogDriverConfigSummary(MappingModel mapping, RawAccelConfig config) if (config.profiles != null) { - foreach (Profile p in config.profiles) + foreach (RaProfile p in config.profiles) { logger.LogInformation( " profile: name={Name} outputDPI={OutputDPI} yxRatio={YxRatio} rotation={Rotation} " + @@ -318,7 +318,7 @@ private void LogDriverConfigSummary(MappingModel mapping, RawAccelConfig config) if (config.devices != null) { - foreach (DeviceSettings d in config.devices) + foreach (RaDeviceSettings d in config.devices) { logger.LogInformation( " device: id={Id} name={Name} profile={Profile} disable={Disable} dpi={Dpi} pollingRate={PollingRate}", @@ -369,44 +369,44 @@ public void SaveToDisk() protected RawAccelConfig MapToDriverConfig(MappingModel mappingModel) { - IEnumerable configDevices = MapToDriverDevices(mappingModel); - IEnumerable configProfiles = MapToDriverProfiles(mappingModel); + IEnumerable configDevices = MapToDriverDevices(mappingModel); + IEnumerable configProfiles = MapToDriverProfiles(mappingModel); return new RawAccelConfig { version = RawAccelConstants.VersionString, - defaultDeviceConfig = new DeviceConfig(), + defaultDeviceConfig = new RaDeviceConfig(), profiles = configProfiles.ToList(), devices = configDevices.ToList(), }; } - protected IEnumerable MapToDriverDevices(MappingModel mapping) + protected IEnumerable MapToDriverDevices(MappingModel mapping) { return mapping.IndividualMappings.SelectMany( dg => MapToDriverDevices(dg.DeviceGroup, dg.Profile.Name.ModelValue)); } - protected IEnumerable MapToDriverProfiles(MappingModel mapping) + protected IEnumerable MapToDriverProfiles(MappingModel mapping) { IEnumerable ProfilesToMap = mapping.IndividualMappings.Select(m => m.Profile).Distinct(); return ProfilesToMap.Select(p => p.CurrentValidatedDriverProfile); } - protected IEnumerable MapToDriverDevices(string dg, string profileName) + protected IEnumerable MapToDriverDevices(string dg, string profileName) { IEnumerable deviceModels = Devices.Elements.Where(d => d.DeviceGroup.ModelValue.Equals(dg)); return deviceModels.Select(dm => MapToDriverDevice(dm, profileName)); } - protected DeviceSettings MapToDriverDevice(IDeviceModel deviceModel, string profileName) + protected RaDeviceSettings MapToDriverDevice(IDeviceModel deviceModel, string profileName) { - return new DeviceSettings() + return new RaDeviceSettings() { id = deviceModel.HardwareID.ModelValue, name = deviceModel.Name.ModelValue, profile = profileName, - config = new DeviceConfig() + config = new RaDeviceConfig() { disable = deviceModel.Ignore.ModelValue, dpi = deviceModel.DPI.ModelValue, diff --git a/userspace-backend/Display/CurvePreview.cs b/userspace-backend/Display/CurvePreview.cs index 1927faa6..707d7f35 100644 --- a/userspace-backend/Display/CurvePreview.cs +++ b/userspace-backend/Display/CurvePreview.cs @@ -1,10 +1,10 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using userspace_backend.Display.Calculations; using userspace_backend.Driver; -using Profile = RawAccel.Contracts.RawAccelProfile; +using RaProfile = RawAccel.Contracts.RawAccelProfile; namespace userspace_backend.Display { @@ -12,7 +12,7 @@ public interface ICurvePreview { ObservableCollection Points { get; } - void GeneratePoints(Profile profile); + void GeneratePoints(RaProfile profile); void SetPoints(IEnumerable points); } @@ -30,7 +30,7 @@ public CurvePreview(IAccelEvaluator evaluator) public ObservableCollection Points { get; } - public void GeneratePoints(Profile profile) + public void GeneratePoints(RaProfile profile) { using IAccelInstance instance = evaluator.CreateInstance(profile); diff --git a/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs b/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs index f17572e1..4a17678c 100644 --- a/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs +++ b/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs @@ -66,7 +66,7 @@ public RawAccelConfig Read() public void Deactivate() { - DriverConfig.GetDefault().Deactivate(); + DriverConfig.Deactivate(); } // TODO: plug in mouse speeds from the OS layer. diff --git a/userspace-backend/Model/AccelDefinitions/AccelDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/AccelDefinitionModel.cs index 560f5479..dd985872 100644 --- a/userspace-backend/Model/AccelDefinitions/AccelDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/AccelDefinitionModel.cs @@ -1,12 +1,12 @@ -using userspace_backend.Data.Profiles; +using userspace_backend.Data.Profiles; using userspace_backend.Model.EditableSettings; -using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; namespace userspace_backend.Model.AccelDefinitions { public interface IAccelDefinitionModel : IEditableSettingsCollectionV2 { - AccelArgs MapToDriver(); + RaAccelArgs MapToDriver(); } public interface IAccelDefinitionModelSpecific : IAccelDefinitionModel, IEditableSettingsCollectionSpecific where T : Acceleration diff --git a/userspace-backend/Model/AccelDefinitions/AccelerationModel.cs b/userspace-backend/Model/AccelDefinitions/AccelerationModel.cs index 1e050b2e..80bf33eb 100644 --- a/userspace-backend/Model/AccelDefinitions/AccelerationModel.cs +++ b/userspace-backend/Model/AccelDefinitions/AccelerationModel.cs @@ -1,10 +1,10 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using System; using userspace_backend.Data.Profiles; using userspace_backend.Model.EditableSettings; using userspace_backend.Model.ProfileComponents; using static userspace_backend.Data.Profiles.Acceleration; -using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; namespace userspace_backend.Model.AccelDefinitions { @@ -16,7 +16,7 @@ public interface IAccelerationModel : IEditableSettingsSelector, IAccelerationModel @@ -62,7 +62,7 @@ public override Acceleration MapToData() return acceleration; } - public AccelArgs MapToDriver() => ((IAccelDefinitionModel)Selected)?.MapToDriver() ?? new AccelArgs(); + public RaAccelArgs MapToDriver() => ((IAccelDefinitionModel)Selected)?.MapToDriver() ?? new RaAccelArgs(); protected override bool TryMapEditableSettingsFromData(Acceleration data) { diff --git a/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs index 67d2fa9e..27d65585 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs @@ -1,10 +1,10 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; -using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; -using AccelMode = RawAccel.Contracts.AccelMode; -using CapMode = RawAccel.Contracts.CapMode; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; +using RaCapMode = RawAccel.Contracts.CapMode; using Vec2D = RawAccel.Contracts.Vec2; namespace userspace_backend.Model.AccelDefinitions.Formula @@ -44,16 +44,16 @@ public ClassicAccelerationDefinitionModel( public IEditableSettingSpecific Cap { get; set; } - public override AccelArgs MapToDriver() + public override RaAccelArgs MapToDriver() { - return new AccelArgs + return new RaAccelArgs { - mode = AccelMode.classic, + mode = RaAccelMode.classic, acceleration = Acceleration.ModelValue, exponentClassic = Exponent.ModelValue, inputOffset = Offset.ModelValue, cap = new Vec2D { x = 0, y = Cap.ModelValue }, - capMode = CapMode.output + capMode = RaCapMode.output }; } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/FormulaAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/FormulaAccelerationDefinitionModel.cs index 3d69fc5c..0a84f05e 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/FormulaAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/FormulaAccelerationDefinitionModel.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Model.EditableSettings; -using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; namespace userspace_backend.Model.AccelDefinitions.Formula { @@ -21,7 +21,7 @@ protected FormulaAccelerationDefinitionModel(IEnumerable curve { } - public abstract AccelArgs MapToDriver(); + public abstract RaAccelArgs MapToDriver(); // No formula has nested settings collections; its parameters are all leaf settings. protected sealed override bool TryMapEditableSettingsCollectionsFromData(TData data) => true; diff --git a/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs index d3e9b5ba..9db8fa5c 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs @@ -1,9 +1,9 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; -using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; -using AccelMode = RawAccel.Contracts.AccelMode; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; using Vec2D = RawAccel.Contracts.Vec2; namespace userspace_backend.Model.AccelDefinitions.Formula @@ -37,11 +37,11 @@ public JumpAccelerationDefinitionModel( public IEditableSettingSpecific Output { get; set; } - public override AccelArgs MapToDriver() + public override RaAccelArgs MapToDriver() { - return new AccelArgs + return new RaAccelArgs { - mode = AccelMode.jump, + mode = RaAccelMode.jump, smooth = Smooth.ModelValue, cap = new Vec2D { x = Input.ModelValue, y = Output.ModelValue }, }; diff --git a/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs index 5c40ba04..a4764bba 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs @@ -1,10 +1,10 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; -using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; -using AccelMode = RawAccel.Contracts.AccelMode; -using CapMode = RawAccel.Contracts.CapMode; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; +using RaCapMode = RawAccel.Contracts.CapMode; using Vec2D = RawAccel.Contracts.Vec2; namespace userspace_backend.Model.AccelDefinitions.Formula @@ -38,16 +38,16 @@ public LinearAccelerationDefinitionModel( public IEditableSettingSpecific Cap { get; set; } - public override AccelArgs MapToDriver() + public override RaAccelArgs MapToDriver() { - return new AccelArgs + return new RaAccelArgs { - mode = AccelMode.classic, + mode = RaAccelMode.classic, acceleration = Acceleration.ModelValue, exponentClassic = 2, inputOffset = Offset.ModelValue, cap = new Vec2D { x = 0, y = Cap.ModelValue }, - capMode = CapMode.output, + capMode = RaCapMode.output, }; } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs index 7e49e1cb..4526d83b 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs @@ -1,9 +1,9 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; -using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; -using AccelMode = RawAccel.Contracts.AccelMode; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; namespace userspace_backend.Model.AccelDefinitions.Formula { @@ -36,11 +36,11 @@ public NaturalAccelerationDefinitionModel( public IEditableSettingSpecific Limit { get; set; } - public override AccelArgs MapToDriver() + public override RaAccelArgs MapToDriver() { - return new AccelArgs + return new RaAccelArgs { - mode = AccelMode.natural, + mode = RaAccelMode.natural, decayRate = DecayRate.ModelValue, inputOffset = InputOffset.ModelValue, limit = Limit.ModelValue, diff --git a/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs index a986283d..dbd5bf15 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs @@ -1,11 +1,11 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using userspace_backend.Data.Profiles; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; -using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; -using AccelMode = RawAccel.Contracts.AccelMode; -using CapMode = RawAccel.Contracts.CapMode; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; +using RaCapMode = RawAccel.Contracts.CapMode; using Vec2D = RawAccel.Contracts.Vec2; namespace userspace_backend.Model.AccelDefinitions.Formula @@ -44,16 +44,16 @@ public PowerAccelerationDefinitionModel( public IEditableSettingSpecific Cap { get; set; } - public override AccelArgs MapToDriver() + public override RaAccelArgs MapToDriver() { - return new AccelArgs + return new RaAccelArgs { - mode = AccelMode.power, + mode = RaAccelMode.power, scale = Scale.ModelValue, exponentPower = Exponent.ModelValue, outputOffset = OutputOffset.ModelValue, cap = new Vec2D { x = 0, y = Cap.ModelValue }, - capMode = CapMode.output, + capMode = RaCapMode.output, }; } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs index 2415b176..2fb2a6db 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs @@ -1,9 +1,9 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; -using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; -using AccelMode = RawAccel.Contracts.AccelMode; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; namespace userspace_backend.Model.AccelDefinitions.Formula { @@ -48,11 +48,11 @@ public SynchronousAccelerationDefinitionModel( public IEditableSettingSpecific Smoothness { get; set; } - public override AccelArgs MapToDriver() + public override RaAccelArgs MapToDriver() { - return new AccelArgs + return new RaAccelArgs { - mode = AccelMode.synchronous, + mode = RaAccelMode.synchronous, syncSpeed = SyncSpeed.ModelValue, motivity = Motivity.ModelValue, gamma = Gamma.ModelValue, diff --git a/userspace-backend/Model/AccelDefinitions/FormulaAccelModel.cs b/userspace-backend/Model/AccelDefinitions/FormulaAccelModel.cs index 9cb9379b..42be959b 100644 --- a/userspace-backend/Model/AccelDefinitions/FormulaAccelModel.cs +++ b/userspace-backend/Model/AccelDefinitions/FormulaAccelModel.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; using System.ComponentModel; @@ -8,7 +8,7 @@ using userspace_backend.Model.AccelDefinitions.Formula; using userspace_backend.Model.EditableSettings; using static userspace_backend.Data.Profiles.Accel.FormulaAccel; -using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; namespace userspace_backend.Model.AccelDefinitions { @@ -41,7 +41,7 @@ public FormulaAccelModel( public IEditableSettingSpecific Gain { get; set; } - public AccelArgs MapToDriver() => ((IAccelDefinitionModel)Selected)?.MapToDriver() ?? new AccelArgs(); + public RaAccelArgs MapToDriver() => ((IAccelDefinitionModel)Selected)?.MapToDriver() ?? new RaAccelArgs(); protected override bool TryMapEditableSettingsCollectionsFromData(FormulaAccel data) { diff --git a/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs index 96668ce1..c2f98713 100644 --- a/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs @@ -1,12 +1,12 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using System; using System.Linq; using userspace_backend.Data.Profiles; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Model.EditableSettings; using static userspace_backend.Data.Profiles.Accel.LookupTableAccel; -using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; -using AccelMode = RawAccel.Contracts.AccelMode; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; namespace userspace_backend.Model.AccelDefinitions { @@ -36,19 +36,19 @@ public LookupTableDefinitionModel( public IEditableSettingSpecific Data { get; set; } - public AccelArgs MapToDriver() + public RaAccelArgs MapToDriver() { // data in driver profile must be predefined length for marshalling purposes double[] lutData = Data.ModelValue.Data; - var accelArgsData = new float[AccelArgs.MaxLutPoints*2]; + var accelArgsData = new float[RaAccelArgs.MaxLutPoints*2]; for (int i = 0; i < lutData.Length; i++) { accelArgsData[i] = (float)lutData[i]; } - return new AccelArgs + return new RaAccelArgs { - mode = AccelMode.lut, + mode = RaAccelMode.lut, data = accelArgsData, length = Data.ModelValue.Data.Length, }; diff --git a/userspace-backend/Model/AccelDefinitions/NoAccelDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/NoAccelDefinitionModel.cs index 1022f478..e6149f25 100644 --- a/userspace-backend/Model/AccelDefinitions/NoAccelDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/NoAccelDefinitionModel.cs @@ -1,8 +1,8 @@ -using userspace_backend.Data.Profiles; +using userspace_backend.Data.Profiles; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Model.EditableSettings; -using AccelArgs = RawAccel.Contracts.RawAccelAccelArgs; -using AccelMode = RawAccel.Contracts.AccelMode; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; namespace userspace_backend.Model.AccelDefinitions { @@ -20,11 +20,11 @@ public NoAccelDefinitionModel() public NoAcceleration NoAcceleration { get; protected set; } - public AccelArgs MapToDriver() + public RaAccelArgs MapToDriver() { - return new AccelArgs() + return new RaAccelArgs() { - mode = AccelMode.noaccel, + mode = RaAccelMode.noaccel, }; } diff --git a/userspace-backend/Model/ProfileModel.cs b/userspace-backend/Model/ProfileModel.cs index efdabca9..e469745d 100644 --- a/userspace-backend/Model/ProfileModel.cs +++ b/userspace-backend/Model/ProfileModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; @@ -11,8 +11,8 @@ using userspace_backend.Model.EditableSettings; using userspace_backend.Model.ProfileComponents; using DATA = userspace_backend.Data; -using Profile = RawAccel.Contracts.RawAccelProfile; -using SpeedArgs = RawAccel.Contracts.RawAccelSpeedArgs; +using RaProfile = RawAccel.Contracts.RawAccelProfile; +using RaSpeedArgs = RawAccel.Contracts.RawAccelSpeedArgs; namespace userspace_backend.Model { @@ -37,7 +37,7 @@ public interface IProfileModel : IEditableSettingsCollectionSpecific, IProfileModel @@ -89,7 +89,7 @@ public ProfileModel( public IHiddenModel Hidden { get; set; } - public Profile CurrentValidatedDriverProfile { get; protected set; } + public RaProfile CurrentValidatedDriverProfile { get; protected set; } public ICurvePreview XCurvePreview { get; protected set; } @@ -110,9 +110,9 @@ public override DATA.Profile MapToData() }; } - public Profile MapToDriver() + public RaProfile MapToDriver() { - return new Profile() + return new RaProfile() { name = Name.ModelValue, outputDPI = OutputDPI.ModelValue, @@ -136,7 +136,7 @@ public Profile MapToDriver() // The driver supports a speed floor (common/rawaccel-base.hpp), but the UI // deliberately does not expose one; keep it pinned at 0. minimumSpeed = 0, - inputSpeedArgs = new SpeedArgs + inputSpeedArgs = new RaSpeedArgs { combineMagnitudes = Acceleration.Anisotropy.CombineXYComponents.ModelValue, lpNorm = Acceleration.Anisotropy.LPNorm.ModelValue, From c533d2c2fdf682bdd8ee9a77dfb7d35972684a1f Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 28 May 2026 17:25:26 -0700 Subject: [PATCH 07/17] grapher: restore catch variable name in LUTPanelOptions e6ae79e "Fix warnings" dropped the ex identifier from two catch (Exception) blocks but left the , ex inner-exception argument inside the rethrown ApplicationException, producing CS0103. Restore the variable name. --- grapher/Models/Options/LUT/LUTPanelOptions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/grapher/Models/Options/LUT/LUTPanelOptions.cs b/grapher/Models/Options/LUT/LUTPanelOptions.cs index 5955ee77..eedcfa80 100644 --- a/grapher/Models/Options/LUT/LUTPanelOptions.cs +++ b/grapher/Models/Options/LUT/LUTPanelOptions.cs @@ -185,7 +185,7 @@ private static (Vec2[], int length) UserTextToPoints(string userText) { x = float.Parse(pointSplit[0]); } - catch (Exception) + catch (Exception ex) { throw new ApplicationException($"X-value for point at index {index} is malformed. Expected: float. Given: {pointSplit[0]}", ex); } @@ -206,7 +206,7 @@ private static (Vec2[], int length) UserTextToPoints(string userText) { y = float.Parse(pointSplit[1]); } - catch (Exception) + catch (Exception ex) { throw new ApplicationException($"Y-value for point at index {index} is malformed. Expected: float. Given: {pointSplit[1]}", ex); } From b1f554f8479550a70babdd9bd1e4f85c59b7044e Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 28 May 2026 18:04:53 -0700 Subject: [PATCH 08/17] userspace-backend: capture live mouse speed on Windows for the chart speed line WindowsRawAccelDriver.GetCurrentMouseSpeedSample returned Zero, so the chart speed line never moved on Windows (unlike Linux, where the agent reports speed). Add RawInputMouseListener, which captures raw input (WM_INPUT) on its own message-only window and thread and reports the current speed in the chart units (counts/ms normalized to 1000 DPI). Per-device DPI normalization joins each WM_INPUT handle to its hardware-id via wrapper.dll MultiHandleDevice and looks up the applied config DPI, defaulting for unconfigured handles. The driver creates the listener lazily on first poll and disposes it via IDisposable. --- .../Driver/Windows/RawInputMouseListener.cs | 465 ++++++++++++++++++ .../Driver/Windows/WindowsRawAccelDriver.cs | 52 +- 2 files changed, 514 insertions(+), 3 deletions(-) create mode 100644 userspace-backend/Driver/Windows/RawInputMouseListener.cs diff --git a/userspace-backend/Driver/Windows/RawInputMouseListener.cs b/userspace-backend/Driver/Windows/RawInputMouseListener.cs new file mode 100644 index 00000000..f2795664 --- /dev/null +++ b/userspace-backend/Driver/Windows/RawInputMouseListener.cs @@ -0,0 +1,465 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using RawAccel.Contracts; + +namespace userspace_backend.Driver.Windows +{ + // Captures live mouse speed via Win32 raw input on its own message-only window + // and thread (the Avalonia UI exposes no WndProc to hook). Reports speed in the + // chart's units: counts/ms normalized to 1000 DPI, matching the curve math. + // + // Raw input identifies devices by HANDLE; config keys DPI by hardware-id. We map + // handle -> hardware-id via wrapper.dll's MultiHandleDevice to pick the per-mouse + // normalization factor, defaulting for handles absent from the config. + internal sealed class RawInputMouseListener : IDisposable + { + private const double NormalizedDpi = 1000.0; + + // No movement for this long => report Zero (line eases back to rest). + private const double FreshnessMs = 150.0; + + // Clamp the inter-event interval so bursts/stalls can't spike or flatline + // the speed. 0.1 ms is a 10 kHz ceiling, above any real polling rate. + private const double MinIntervalMs = 0.1; + private const double MaxIntervalMs = 100.0; + + // Win32 constants. + private const uint WM_DESTROY = 0x0002; + private const uint WM_CLOSE = 0x0010; + private const uint WM_QUIT = 0x0012; + private const uint WM_INPUT = 0x00FF; + private const uint WM_INPUT_DEVICE_CHANGE = 0x00FE; + private const uint RID_INPUT = 0x10000003; + private const uint RIDEV_INPUTSINK = 0x00000100; + private const uint RIDEV_DEVNOTIFY = 0x00002000; + private const uint RIM_TYPEMOUSE = 0; + private const ushort MOUSE_MOVE_ABSOLUTE = 0x01; + private const uint RAWINPUT_ERROR = unchecked((uint)-1); + private static readonly IntPtr HWND_MESSAGE = new(-3); + + // Distinct window-class name per instance, so a re-created listener never + // collides with a not-yet-freed class name. + private static int instanceCounter; + + private readonly ILogger logger; + private readonly string className; + private readonly object gate = new(); + private readonly object lifecycleGate = new(); + private readonly ManualResetEventSlim ready = new(false); + + private Thread? thread; + private uint nativeThreadId; + private IntPtr hwnd; + private WndProcDelegate? wndProc; // kept alive against GC for RegisterClass + private volatile bool running; + private volatile bool disposed; + + // Latest speed (guarded by gate); lastEventTimestamp is the freshness/ + // inter-event clock (Interlocked). + private double lastX, lastY, lastCombined; + private long lastEventTimestamp; + + // handle -> normalization factor (1000 / dpi); defaultFactor for the rest. + private Dictionary handleFactors = new(); + private double defaultFactor = 1.0; + + // Applied config's DPI-by-hardware-id, used to rebuild handleFactors. + private Dictionary dpiById = new(StringComparer.OrdinalIgnoreCase); + private int defaultDpi = 1000; + + public RawInputMouseListener(ILogger? logger = null) + { + this.logger = logger ?? NullLogger.Instance; + int id = Interlocked.Increment(ref instanceCounter); + className = $"RawAccelRawInputSink_{Environment.ProcessId}_{id}"; + } + + // Starts the capture thread. Idempotent. Blocks briefly until setup is done. + public void Start() + { + lock (lifecycleGate) + { + if (disposed || thread != null) return; + thread = new Thread(ThreadMain) + { + IsBackground = true, + Name = "RawAccelRawInput", + }; + thread.Start(); + } + ready.Wait(2000); + } + + // Feeds the applied config for per-device DPI. Safe to call before Start. + public void UpdateDevices(RawAccelConfig config) + { + var byId = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (config.devices != null) + { + foreach (var dev in config.devices) + { + if (string.IsNullOrEmpty(dev.id)) continue; + byId[dev.id] = dev.config?.dpi ?? defaultDpi; + } + } + int defDpi = config.defaultDeviceConfig?.dpi ?? 1000; + + lock (gate) + { + dpiById = byId; + defaultDpi = defDpi > 0 ? defDpi : 1000; + } + RebuildHandleMap(); + } + + // The current normalized input speed, or Zero when idle/unavailable. + public MouseSpeedSample CurrentSample() + { + if (!running) return MouseSpeedSample.Zero; + + long last = Interlocked.Read(ref lastEventTimestamp); + if (last == 0) return MouseSpeedSample.Zero; + + double sinceMs = (Stopwatch.GetTimestamp() - last) * 1000.0 / Stopwatch.Frequency; + if (sinceMs > FreshnessMs) return MouseSpeedSample.Zero; + + lock (gate) + { + return new MouseSpeedSample(lastX, lastY, lastCombined); + } + } + + public void Dispose() + { + lock (lifecycleGate) + { + if (disposed) return; + disposed = true; + running = false; + } + + uint tid = nativeThreadId; + if (tid != 0) + { + // Wake the loop; the thread tears down its own window/class. + PostThreadMessageW(tid, WM_QUIT, IntPtr.Zero, IntPtr.Zero); + } + thread?.Join(2000); + ready.Dispose(); + } + + // ---------------------------------------------------------------------------- + // Capture thread + // ---------------------------------------------------------------------------- + + private void ThreadMain() + { + // Captured first so Dispose can always signal WM_QUIT. + nativeThreadId = GetCurrentThreadId(); + + IntPtr hInstance = GetModuleHandleW(null); + wndProc = WindowProc; + + var wc = new WNDCLASS + { + lpfnWndProc = wndProc, + hInstance = hInstance, + lpszClassName = className, + }; + + try + { + if (RegisterClassW(ref wc) == 0) + { + logger.LogDebug("RegisterClass failed (err {Err}); speed line disabled", + Marshal.GetLastWin32Error()); + ready.Set(); + return; + } + + hwnd = CreateWindowExW(0, className, string.Empty, 0, 0, 0, 0, 0, + HWND_MESSAGE, IntPtr.Zero, hInstance, IntPtr.Zero); + if (hwnd == IntPtr.Zero) + { + logger.LogDebug("CreateWindowEx failed (err {Err}); speed line disabled", + Marshal.GetLastWin32Error()); + UnregisterClassW(className, hInstance); + ready.Set(); + return; + } + + var devices = new[] + { + new RAWINPUTDEVICE + { + UsagePage = 0x01, // generic desktop + Usage = 0x02, // mouse + Flags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, + hwndTarget = hwnd, + }, + }; + if (!RegisterRawInputDevices(devices, 1, (uint)Marshal.SizeOf())) + { + logger.LogDebug("RegisterRawInputDevices failed (err {Err}); speed line disabled", + Marshal.GetLastWin32Error()); + } + + RebuildHandleMap(); + running = true; + ready.Set(); + + logger.LogDebug( + "raw input listener active: hwnd=0x{Hwnd:x}, mapped handles={Count}", + hwnd.ToInt64(), handleFactors.Count); + + while (GetMessageW(out MSG msg, IntPtr.Zero, 0, 0) > 0) + { + TranslateMessage(ref msg); + DispatchMessageW(ref msg); + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "raw input listener thread failed"); + ready.Set(); + } + finally + { + running = false; + if (hwnd != IntPtr.Zero) + { + DestroyWindow(hwnd); + hwnd = IntPtr.Zero; + } + UnregisterClassW(className, hInstance); + } + } + + private IntPtr WindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) + { + switch (msg) + { + case WM_INPUT: + try { HandleRawInput(lParam); } + catch (Exception ex) { logger.LogTrace(ex, "WM_INPUT handling failed"); } + return DefWindowProcW(hWnd, msg, wParam, lParam); + + case WM_INPUT_DEVICE_CHANGE: + try { RebuildHandleMap(); } + catch (Exception ex) { logger.LogTrace(ex, "device map rebuild failed"); } + return IntPtr.Zero; + + case WM_CLOSE: + DestroyWindow(hWnd); + return IntPtr.Zero; + + case WM_DESTROY: + PostQuitMessage(0); + return IntPtr.Zero; + + default: + return DefWindowProcW(hWnd, msg, wParam, lParam); + } + } + + private void HandleRawInput(IntPtr hRawInput) + { + uint size = (uint)Marshal.SizeOf(); + uint headerSize = (uint)(2 * sizeof(uint) + 2 * IntPtr.Size); + if (GetRawInputData(hRawInput, RID_INPUT, out RAWINPUTMOUSE data, ref size, headerSize) + == RAWINPUT_ERROR) + { + return; + } + + if (data.Type != RIM_TYPEMOUSE) return; + if ((data.MouseFlags & MOUSE_MOVE_ABSOLUTE) != 0) return; // only relative motion + if (data.LastX == 0 && data.LastY == 0) return; + + double factor = FactorForHandle(data.Device); + + long now = Stopwatch.GetTimestamp(); + long prev = Interlocked.Exchange(ref lastEventTimestamp, now); + double dtMs = prev == 0 + ? MaxIntervalMs + : (now - prev) * 1000.0 / Stopwatch.Frequency; + if (dtMs < MinIntervalMs) dtMs = MinIntervalMs; + else if (dtMs > MaxIntervalMs) dtMs = MaxIntervalMs; + + double dx = data.LastX; + double dy = data.LastY; + double speedX = Math.Abs(dx) * factor / dtMs; + double speedY = Math.Abs(dy) * factor / dtMs; + double speedCombined = Math.Sqrt(dx * dx + dy * dy) * factor / dtMs; + + lock (gate) + { + lastX = speedX; + lastY = speedY; + lastCombined = speedCombined; + } + } + + private double FactorForHandle(IntPtr handle) + { + lock (gate) + { + return handleFactors.TryGetValue(handle, out double f) ? f : defaultFactor; + } + } + + // Rebuilds handle -> normalization-factor from the live device list and config. + private void RebuildHandleMap() + { + var map = new Dictionary(); + Dictionary byId; + int defDpi; + lock (gate) + { + byId = dpiById; + defDpi = defaultDpi; + } + double defFactor = defDpi > 0 ? NormalizedDpi / defDpi : 1.0; + + try + { + foreach (MultiHandleDevice device in MultiHandleDevice.GetList()) + { + int dpi = (device.id != null && byId.TryGetValue(device.id, out int d) && d > 0) + ? d + : defDpi; + double factor = dpi > 0 ? NormalizedDpi / dpi : 1.0; + foreach (IntPtr handle in device.handles) + { + map[handle] = factor; + } + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "enumerating raw input devices failed"); + } + + lock (gate) + { + handleFactors = map; + defaultFactor = defFactor; + } + } + + // ---------------------------------------------------------------------------- + // P/Invoke + // ---------------------------------------------------------------------------- + + private delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WNDCLASS + { + public uint style; + public WndProcDelegate lpfnWndProc; + public int cbClsExtra; + public int cbWndExtra; + public IntPtr hInstance; + public IntPtr hIcon; + public IntPtr hCursor; + public IntPtr hbrBackground; + [MarshalAs(UnmanagedType.LPWStr)] public string? lpszMenuName; + [MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MSG + { + public IntPtr hwnd; + public uint message; + public IntPtr wParam; + public IntPtr lParam; + public uint time; + public int ptX; + public int ptY; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RAWINPUTDEVICE + { + public ushort UsagePage; + public ushort Usage; + public uint Flags; + public IntPtr hwndTarget; + } + + // Flattened RAWINPUTHEADER + RAWMOUSE; Padding mirrors the native union's + // 4-byte alignment after MouseFlags. + [StructLayout(LayoutKind.Sequential)] + private struct RAWINPUTMOUSE + { + // RAWINPUTHEADER + public uint Type; + public uint Size; + public IntPtr Device; + public IntPtr wParam; + // RAWMOUSE + public ushort MouseFlags; + public ushort Padding; + public ushort ButtonFlags; + public ushort ButtonData; + public uint RawButtons; + public int LastX; + public int LastY; + public uint ExtraInformation; + } + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern ushort RegisterClassW(ref WNDCLASS lpWndClass); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool UnregisterClassW(string lpClassName, IntPtr hInstance); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr CreateWindowExW(uint dwExStyle, string lpClassName, + string lpWindowName, uint dwStyle, int x, int y, int nWidth, int nHeight, + IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam); + + [DllImport("user32.dll")] + private static extern bool DestroyWindow(IntPtr hWnd); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr DefWindowProcW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetMessageW(out MSG lpMsg, IntPtr hWnd, + uint wMsgFilterMin, uint wMsgFilterMax); + + [DllImport("user32.dll")] + private static extern bool TranslateMessage(ref MSG lpMsg); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr DispatchMessageW(ref MSG lpMsg); + + [DllImport("user32.dll")] + private static extern void PostQuitMessage(int nExitCode); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool PostThreadMessageW(uint idThread, uint msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool RegisterRawInputDevices( + [In] RAWINPUTDEVICE[] pRawInputDevices, uint uiNumDevices, uint cbSize); + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint GetRawInputData(IntPtr hRawInput, uint uiCommand, + out RAWINPUTMOUSE pData, ref uint pcbSize, uint cbSizeHeader); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandleW(string? lpModuleName); + + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + } +} diff --git a/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs b/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs index 4a17678c..e2b8819e 100644 --- a/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs +++ b/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs @@ -7,9 +7,17 @@ namespace userspace_backend.Driver.Windows { - public sealed class WindowsRawAccelDriver : IRawAccelDriver + public sealed class WindowsRawAccelDriver : IRawAccelDriver, IDisposable { private readonly ILogger logger; + private readonly object listenerGate = new(); + + // Speed-line capture, created lazily on first poll so unused paths never + // spin up a window + thread. + private RawInputMouseListener? listener; + + // Last applied config, replayed into the listener for per-device DPI. + private RawAccelConfig? lastConfig; public WindowsRawAccelDriver(ILogger? logger = null) { @@ -46,6 +54,10 @@ public bool Apply(RawAccelConfig config) return false; } native.Activate(); + + lastConfig = config; + listener?.UpdateDevices(config); + return true; } catch (Exception ex) @@ -69,7 +81,41 @@ public void Deactivate() DriverConfig.Deactivate(); } - // TODO: plug in mouse speeds from the OS layer. - public MouseSpeedSample GetCurrentMouseSpeedSample() => MouseSpeedSample.Zero; + public MouseSpeedSample GetCurrentMouseSpeedSample() + { + try + { + return EnsureListener().CurrentSample(); + } + catch (Exception ex) + { + logger.LogDebug(ex, "mouse speed sample failed"); + return MouseSpeedSample.Zero; + } + } + + private RawInputMouseListener EnsureListener() + { + var existing = listener; + if (existing != null) return existing; + + lock (listenerGate) + { + if (listener == null) + { + var created = new RawInputMouseListener(logger); + created.Start(); + if (lastConfig != null) created.UpdateDevices(lastConfig); + listener = created; + } + return listener; + } + } + + public void Dispose() + { + listener?.Dispose(); + listener = null; + } } } From 1439a0fb343dc30af3f9f4a8081aa0d8a37d0b97 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 28 May 2026 18:35:56 -0700 Subject: [PATCH 09/17] userspace-backend: audit-driven fixes to Windows driver layer Real fixes (not just cleanup): - ManagedAccelEvaluator: dispose the seed ManagedAccel after CreateStatelessCopy so its native instance pair frees deterministically instead of waiting on the finalizer. - RawInputMouseListener.UpdateDevices: per-device DPI fallback now uses the new config's default rather than the stale defaultDpi field. - RawInputMouseListener: bail (not silently continue) when RegisterRawInputDevices fails so running reflects reality. - RawInputMouseListener.FactorForHandle: drop the gate; handleFactors is volatile + build-once-publish so the WM_INPUT hot path is lock-free. - WindowsRawAccelDriver.Apply: don't fail Apply when only the post-apply listener.UpdateDevices throws (driver is already active); also IsNullOrEmpty for the wrapper errors string. - WindowsRawAccelDriver.Dispose: take listenerGate, flip a disposed flag, dispose outside the lock; closes a race where EnsureListener could resurrect a listener after Dispose nulled the field. Cleanups: - RawAccelConstants: add NormalizedDpi (mirrors common/rawaccel-base.hpp). - WindowsSystemDevice: capture name/HWID strings directly (??= empty) and drop the dead RawDevice ref-rooting field. - RawInputMouseListener: cache Marshal.SizeOf/OffsetOf results; drop the unused TranslateMessage call+import (message-only sink); upgrade setup failures to LogWarning and thread-loop catch to LogError; gate UnregisterClassW on a classRegistered flag; name LifecycleTimeoutMs; Volatile.Read/Write the cross-thread nativeThreadId. - WindowsRawAccelDriver: mark listener/lastConfig volatile for the EnsureListener fast-path and the Apply -> EnsureListener cross-thread read. --- RawAccel.Contracts/Constants.cs | 4 + .../Driver/Windows/ManagedAccelEvaluator.cs | 7 +- .../Driver/Windows/RawInputMouseListener.cs | 79 ++++++++++--------- .../Driver/Windows/WindowsRawAccelDriver.cs | 35 +++++--- .../Windows/WindowsSystemDevicesRetriever.cs | 9 +-- 5 files changed, 79 insertions(+), 55 deletions(-) diff --git a/RawAccel.Contracts/Constants.cs b/RawAccel.Contracts/Constants.cs index 8af2c401..e8121e3e 100644 --- a/RawAccel.Contracts/Constants.cs +++ b/RawAccel.Contracts/Constants.cs @@ -7,6 +7,10 @@ public static class RawAccelConstants public const int PollRateMin = 125; public const int PollRateMax = 8000; + // Mirrors NORMALIZED_DPI in common/rawaccel-base.hpp; the unit all + // curve math is expressed in (counts/ms at 1000 DPI). + public const double NormalizedDpi = 1000.0; + public const double DefaultTimeMin = 1000.0 / PollRateMax / 2.0; public const double DefaultTimeMax = 100.0; diff --git a/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs b/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs index 81edbdeb..83e82393 100644 --- a/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs +++ b/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs @@ -18,7 +18,9 @@ public IAccelInstance CreateInstance(RawAccelProfile profile) var nativeProfile = JsonConvert.DeserializeObject(json) ?? throw new InvalidOperationException( "POCO -> wrapper.Profile deserialization returned null"); - var accel = new ManagedAccel(nativeProfile).CreateStatelessCopy(); + // Dispose the seed; CreateStatelessCopy allocates a fresh native pair. + using var seed = new ManagedAccel(nativeProfile); + var accel = seed.CreateStatelessCopy(); return new ManagedAccelInstance(accel); } @@ -38,8 +40,7 @@ public ManagedAccelInstance(ManagedAccel accel) return (t.Item1, t.Item2); } - // ManagedAccel is a C++/CLI ref type; dispose it if it owns native state. - public void Dispose() => (accel as IDisposable)?.Dispose(); + public void Dispose() => accel.Dispose(); } } } diff --git a/userspace-backend/Driver/Windows/RawInputMouseListener.cs b/userspace-backend/Driver/Windows/RawInputMouseListener.cs index f2795664..586da835 100644 --- a/userspace-backend/Driver/Windows/RawInputMouseListener.cs +++ b/userspace-backend/Driver/Windows/RawInputMouseListener.cs @@ -18,8 +18,6 @@ namespace userspace_backend.Driver.Windows // normalization factor, defaulting for handles absent from the config. internal sealed class RawInputMouseListener : IDisposable { - private const double NormalizedDpi = 1000.0; - // No movement for this long => report Zero (line eases back to rest). private const double FreshnessMs = 150.0; @@ -28,6 +26,8 @@ internal sealed class RawInputMouseListener : IDisposable private const double MinIntervalMs = 0.1; private const double MaxIntervalMs = 100.0; + private const int LifecycleTimeoutMs = 2000; + // Win32 constants. private const uint WM_DESTROY = 0x0002; private const uint WM_CLOSE = 0x0010; @@ -64,13 +64,14 @@ internal sealed class RawInputMouseListener : IDisposable private double lastX, lastY, lastCombined; private long lastEventTimestamp; - // handle -> normalization factor (1000 / dpi); defaultFactor for the rest. - private Dictionary handleFactors = new(); + // handle -> normalization factor (NormalizedDpi / dpi); defaultFactor for the rest. + // Volatile + build-once-publish lets HandleRawInput read lock-free on the hot path. + private volatile Dictionary handleFactors = new(); private double defaultFactor = 1.0; // Applied config's DPI-by-hardware-id, used to rebuild handleFactors. private Dictionary dpiById = new(StringComparer.OrdinalIgnoreCase); - private int defaultDpi = 1000; + private int defaultDpi = (int)RawAccelConstants.NormalizedDpi; public RawInputMouseListener(ILogger? logger = null) { @@ -92,27 +93,29 @@ public void Start() }; thread.Start(); } - ready.Wait(2000); + ready.Wait(LifecycleTimeoutMs); } // Feeds the applied config for per-device DPI. Safe to call before Start. public void UpdateDevices(RawAccelConfig config) { + int defDpi = config.defaultDeviceConfig?.dpi ?? (int)RawAccelConstants.NormalizedDpi; + if (defDpi <= 0) defDpi = (int)RawAccelConstants.NormalizedDpi; + var byId = new Dictionary(StringComparer.OrdinalIgnoreCase); if (config.devices != null) { foreach (var dev in config.devices) { if (string.IsNullOrEmpty(dev.id)) continue; - byId[dev.id] = dev.config?.dpi ?? defaultDpi; + byId[dev.id] = dev.config?.dpi ?? defDpi; } } - int defDpi = config.defaultDeviceConfig?.dpi ?? 1000; lock (gate) { dpiById = byId; - defaultDpi = defDpi > 0 ? defDpi : 1000; + defaultDpi = defDpi; } RebuildHandleMap(); } @@ -143,13 +146,14 @@ public void Dispose() running = false; } - uint tid = nativeThreadId; + // Paired Volatile.Read/Write: ThreadMain writes nativeThreadId outside any lock. + uint tid = Volatile.Read(ref nativeThreadId); if (tid != 0) { // Wake the loop; the thread tears down its own window/class. PostThreadMessageW(tid, WM_QUIT, IntPtr.Zero, IntPtr.Zero); } - thread?.Join(2000); + thread?.Join(LifecycleTimeoutMs); ready.Dispose(); } @@ -159,8 +163,7 @@ public void Dispose() private void ThreadMain() { - // Captured first so Dispose can always signal WM_QUIT. - nativeThreadId = GetCurrentThreadId(); + Volatile.Write(ref nativeThreadId, GetCurrentThreadId()); IntPtr hInstance = GetModuleHandleW(null); wndProc = WindowProc; @@ -172,23 +175,25 @@ private void ThreadMain() lpszClassName = className, }; + bool classRegistered = false; + try { if (RegisterClassW(ref wc) == 0) { - logger.LogDebug("RegisterClass failed (err {Err}); speed line disabled", + logger.LogWarning("RegisterClass failed (err {Err}); speed line disabled", Marshal.GetLastWin32Error()); ready.Set(); return; } + classRegistered = true; hwnd = CreateWindowExW(0, className, string.Empty, 0, 0, 0, 0, 0, HWND_MESSAGE, IntPtr.Zero, hInstance, IntPtr.Zero); if (hwnd == IntPtr.Zero) { - logger.LogDebug("CreateWindowEx failed (err {Err}); speed line disabled", + logger.LogWarning("CreateWindowEx failed (err {Err}); speed line disabled", Marshal.GetLastWin32Error()); - UnregisterClassW(className, hInstance); ready.Set(); return; } @@ -205,8 +210,10 @@ private void ThreadMain() }; if (!RegisterRawInputDevices(devices, 1, (uint)Marshal.SizeOf())) { - logger.LogDebug("RegisterRawInputDevices failed (err {Err}); speed line disabled", + logger.LogWarning("RegisterRawInputDevices failed (err {Err}); speed line disabled", Marshal.GetLastWin32Error()); + ready.Set(); + return; } RebuildHandleMap(); @@ -219,13 +226,12 @@ private void ThreadMain() while (GetMessageW(out MSG msg, IntPtr.Zero, 0, 0) > 0) { - TranslateMessage(ref msg); DispatchMessageW(ref msg); } } catch (Exception ex) { - logger.LogDebug(ex, "raw input listener thread failed"); + logger.LogError(ex, "raw input listener thread failed"); ready.Set(); } finally @@ -236,7 +242,7 @@ private void ThreadMain() DestroyWindow(hwnd); hwnd = IntPtr.Zero; } - UnregisterClassW(className, hInstance); + if (classRegistered) UnregisterClassW(className, hInstance); } } @@ -267,11 +273,15 @@ private IntPtr WindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) } } + // Cached; Marshal reflection per WM_INPUT would burn the hot path. + private static readonly uint RawInputMouseSize = (uint)Marshal.SizeOf(); + private static readonly uint RawInputHeaderSize = + (uint)Marshal.OffsetOf(nameof(RAWINPUTMOUSE.MouseFlags)); + private void HandleRawInput(IntPtr hRawInput) { - uint size = (uint)Marshal.SizeOf(); - uint headerSize = (uint)(2 * sizeof(uint) + 2 * IntPtr.Size); - if (GetRawInputData(hRawInput, RID_INPUT, out RAWINPUTMOUSE data, ref size, headerSize) + uint size = RawInputMouseSize; + if (GetRawInputData(hRawInput, RID_INPUT, out RAWINPUTMOUSE data, ref size, RawInputHeaderSize) == RAWINPUT_ERROR) { return; @@ -305,12 +315,13 @@ private void HandleRawInput(IntPtr hRawInput) } } + private static double FactorFor(int dpi) => + dpi > 0 ? RawAccelConstants.NormalizedDpi / dpi : 1.0; + private double FactorForHandle(IntPtr handle) { - lock (gate) - { - return handleFactors.TryGetValue(handle, out double f) ? f : defaultFactor; - } + var map = handleFactors; + return map.TryGetValue(handle, out double f) ? f : Volatile.Read(ref defaultFactor); } // Rebuilds handle -> normalization-factor from the live device list and config. @@ -324,7 +335,7 @@ private void RebuildHandleMap() byId = dpiById; defDpi = defaultDpi; } - double defFactor = defDpi > 0 ? NormalizedDpi / defDpi : 1.0; + double defFactor = FactorFor(defDpi); try { @@ -333,7 +344,7 @@ private void RebuildHandleMap() int dpi = (device.id != null && byId.TryGetValue(device.id, out int d) && d > 0) ? d : defDpi; - double factor = dpi > 0 ? NormalizedDpi / dpi : 1.0; + double factor = FactorFor(dpi); foreach (IntPtr handle in device.handles) { map[handle] = factor; @@ -345,11 +356,8 @@ private void RebuildHandleMap() logger.LogDebug(ex, "enumerating raw input devices failed"); } - lock (gate) - { - handleFactors = map; - defaultFactor = defFactor; - } + Volatile.Write(ref defaultFactor, defFactor); + handleFactors = map; // volatile store publishes the new map } // ---------------------------------------------------------------------------- @@ -436,9 +444,6 @@ private static extern IntPtr CreateWindowExW(uint dwExStyle, string lpClassName, private static extern int GetMessageW(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax); - [DllImport("user32.dll")] - private static extern bool TranslateMessage(ref MSG lpMsg); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr DispatchMessageW(ref MSG lpMsg); diff --git a/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs b/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs index e2b8819e..cc7589ff 100644 --- a/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs +++ b/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs @@ -13,11 +13,14 @@ public sealed class WindowsRawAccelDriver : IRawAccelDriver, IDisposable private readonly object listenerGate = new(); // Speed-line capture, created lazily on first poll so unused paths never - // spin up a window + thread. - private RawInputMouseListener? listener; + // spin up a window + thread. Volatile for EnsureListener's lock-free fast path. + private volatile RawInputMouseListener? listener; // Last applied config, replayed into the listener for per-device DPI. - private RawAccelConfig? lastConfig; + // Volatile: written by Apply, read by EnsureListener under a different lock. + private volatile RawAccelConfig? lastConfig; + + private volatile bool disposed; public WindowsRawAccelDriver(ILogger? logger = null) { @@ -48,23 +51,25 @@ public bool Apply(RawAccelConfig config) var json = JsonConvert.SerializeObject(config); var (native, errors) = DriverConfig.Convert(json); - if (errors != null) + if (!string.IsNullOrEmpty(errors)) { logger.LogError("driver rejected settings: {Errors}", errors); return false; } native.Activate(); - lastConfig = config; - listener?.UpdateDevices(config); - - return true; } catch (Exception ex) { logger.LogError(ex, "driver apply failed"); return false; } + + // Driver is already active; don't fail Apply for a listener-side hiccup. + try { listener?.UpdateDevices(config); } + catch (Exception ex) { logger.LogDebug(ex, "listener device update failed after apply"); } + + return true; } public RawAccelConfig Read() @@ -101,6 +106,8 @@ private RawInputMouseListener EnsureListener() lock (listenerGate) { + if (disposed) + throw new ObjectDisposedException(nameof(WindowsRawAccelDriver)); if (listener == null) { var created = new RawInputMouseListener(logger); @@ -114,8 +121,16 @@ private RawInputMouseListener EnsureListener() public void Dispose() { - listener?.Dispose(); - listener = null; + RawInputMouseListener? toDispose; + lock (listenerGate) + { + if (disposed) return; + disposed = true; + toDispose = listener; + listener = null; + } + // Disposed outside the lock so the thread-join can't block EnsureListener. + toDispose?.Dispose(); } } } diff --git a/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs b/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs index 87034868..da3bfa0c 100644 --- a/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs +++ b/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs @@ -18,13 +18,12 @@ public sealed class WindowsSystemDevice : ISystemDevice { public WindowsSystemDevice(MultiHandleDevice multiHandleDevice) { - RawDevice = multiHandleDevice; + Name = multiHandleDevice.name ?? string.Empty; + HWID = multiHandleDevice.id ?? string.Empty; } - public string Name => RawDevice.name; + public string Name { get; } - public string HWID => RawDevice.id; - - private MultiHandleDevice RawDevice { get; } + public string HWID { get; } } } From 0e96510a3096cecdcd0b1273964c9d2cc515ff27 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 28 May 2026 18:48:48 -0700 Subject: [PATCH 10/17] userspace-backend: TODO to namespace wrapper globals on net8 migration The Ra-prefixed Contracts aliases (RaProfile, RaAccelArgs, etc.) are a workaround for wrapper.cpp declaring its public C++/CLI types in the global namespace, which collides on Windows (CS0576). Note in the existing net8.0-migration TODO that namespacing the wrapper types is the real fix and should land with that migration. --- userspace-backend/BackEndComposer.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/userspace-backend/BackEndComposer.cs b/userspace-backend/BackEndComposer.cs index 99397a41..e5f13111 100644 --- a/userspace-backend/BackEndComposer.cs +++ b/userspace-backend/BackEndComposer.cs @@ -340,6 +340,16 @@ private static void AddEditableSetting( // non-Windows. Once wrapper is migrated to net8.0-windows // (NetCore), replace this with // compile safe registration and delete RegisterWindowsServicesByReflection + // + // TODO: While migrating the wrapper, also fix the root cause of the + // RaProfile/RaAccelArgs/etc. alias renames in this project: wrapper.cpp + // declares its public C++/CLI types (Profile, AccelArgs, DeviceSettings, + // DeviceConfig, AccelMode, CapMode, SpeedArgs) in the GLOBAL namespace, + // which collides with the Contracts aliases on Windows (CS0576) and lets + // bare references silently bind to the wrapper globals. Wrap those types + // in a namespace (e.g. namespace RawAccel) and update consumers + // (grapher, writer, wrapper-tests, wrapper-deps, this backend) so the + // Ra-prefixed aliases can revert to clean names. private static void RegisterPlatformServices(IServiceCollection services) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) From 241558c0754da393a1c56759f0c8e886ce4df610 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 28 May 2026 19:03:04 -0700 Subject: [PATCH 11/17] userspace-backend: trim padded and verbose comments Comment blocks across the backend had been padded so every line landed at roughly the same column, which left filler words behind. Strip the padding and collapse the longest blocks (the wrapper-migration TODOs, the ProfilesModel circular-dep TODO, several XML doc ) without losing substance or test/file references. --- userspace-backend/BackEnd.cs | 22 ++++----- userspace-backend/BackEndComposer.cs | 43 +++++++---------- userspace-backend/BackEndLoader.cs | 10 ++-- userspace-backend/Data/Mapping.cs | 5 +- userspace-backend/Driver/IAccelEvaluator.cs | 16 +++---- userspace-backend/Driver/IRawAccelDriver.cs | 13 +++--- userspace-backend/Driver/MouseSpeedSample.cs | 5 +- .../Driver/Windows/ManagedAccelEvaluator.cs | 10 ++-- .../Driver/Windows/RawInputMouseListener.cs | 46 +++++++++---------- .../Driver/Windows/WindowsRawAccelDriver.cs | 10 ++-- .../Windows/WindowsSystemDevicesRetriever.cs | 2 +- .../AccelerationJsonConverter.cs | 21 ++++----- userspace-backend/IO/SettingsReaderWriter.cs | 5 +- .../FormulaAccelerationDefinitionModel.cs | 9 ++-- userspace-backend/Model/DeviceGroups.cs | 3 +- .../Model/EditableSettings/EditableSetting.cs | 5 +- .../EditableSettingsCollection.cs | 33 ++++++------- .../EditableSettingsSelector.cs | 9 ++-- .../EditableSettings/IEditableSetting.cs | 8 ++-- .../EditableSettings/ModelValueValidator.cs | 6 +-- .../ProfileComponents/AnisotropyModel.cs | 6 +-- userspace-backend/Model/ProfileModel.cs | 22 ++++----- userspace-backend/Model/ProfilesModel.cs | 24 +++------- userspace-backend/Model/SystemDevices.cs | 11 ++--- 24 files changed, 146 insertions(+), 198 deletions(-) diff --git a/userspace-backend/BackEnd.cs b/userspace-backend/BackEnd.cs index 427cda6a..67b45ccb 100644 --- a/userspace-backend/BackEnd.cs +++ b/userspace-backend/BackEnd.cs @@ -141,15 +141,15 @@ protected void EnsureDefaultDeviceExists() return; } - // When the OS reports connected input devices, skip the placeholder: - // ImportSystemDevices will populate real devices instead. + // OS reported devices => skip the placeholder; ImportSystemDevices + // populates real ones. if (Devices.SystemDevices.SystemDevices.Count > 0) { return; } - // TODO: This case is very niche, considering just not adding a - // default at all to show that something is wrong. + // TODO: Niche case -- maybe skip the default entirely to surface + // that something is wrong. var defaultDevice = ServiceProvider.GetRequiredService(); defaultDevice.Name.TryUpdateModelDirectly("Default"); defaultDevice.HardwareID.TryUpdateModelDirectly("DEFAULT_DEVICE_ID"); @@ -228,9 +228,8 @@ protected void EnsureDefaultMappingExists() }); } - // Self-heal: a Default mapping that exists but lacks the DefaultDeviceGroup - // entry (e.g. a stale mappings.json with an empty GroupsToProfiles) must get - // one. TryAddMapping is idempotent, so this no-ops when it is already mapped. + // Self-heal: an existing Default mapping missing the DefaultDeviceGroup + // entry (e.g. stale mappings.json) gets one. TryAddMapping is idempotent. if (Mappings.TryGetMapping("Default", out MappingModel? defaultMapping) && defaultMapping != null) { defaultMapping.TryAddMapping(DeviceGroups.DefaultDeviceGroup, "Default"); @@ -411,11 +410,10 @@ protected RaDeviceSettings MapToDriverDevice(IDeviceModel deviceModel, string pr disable = deviceModel.Ignore.ModelValue, dpi = deviceModel.DPI.ModelValue, pollingRate = deviceModel.PollRate.ModelValue, - // Not yet surfaced in the UI/device model: these are the driver's - // expected defaults for poll-time clamping and extra-info passthrough. - // maximumTime/minimumTime bound the per-packet time delta (ms) the - // driver will trust. Keep in sync with the driver-side defaults if - // they ever become user-configurable. + // Driver defaults for poll-time clamping + extra-info passthrough, + // not yet exposed in the UI. maximumTime/minimumTime bound the + // per-packet time delta (ms) the driver trusts. Keep in sync if + // these ever become user-configurable. pollTimeLock = false, setExtraInfo = false, maximumTime = 200, diff --git a/userspace-backend/BackEndComposer.cs b/userspace-backend/BackEndComposer.cs index e5f13111..2dc7e256 100644 --- a/userspace-backend/BackEndComposer.cs +++ b/userspace-backend/BackEndComposer.cs @@ -306,9 +306,8 @@ public static IServiceProvider Compose(IServiceCollection services) return services.BuildServiceProvider(); } - // Registers a keyed EditableSettingV2 built from the DI-provided parser and - // validator. Collapses the dozens of otherwise-identical registration blocks. - // Pass validatorFactory to override the default (type-keyed) validator. + // Registers a keyed EditableSettingV2 with DI-supplied parser and validator. + // Override the type-keyed default validator with validatorFactory. private static void AddEditableSetting( IServiceCollection services, object diKey, @@ -334,38 +333,28 @@ private static void AddEditableSetting( ?.CreateLogger(EditableSettingV2.LoggerCategoryName))); } - // TODO: This reflection-based registration exists only because wrapper.dll - // is .NET Framework 4.7.2 mixed-mode C++/CLI (cannot be loaded - // in process by net8.0), and Driver/Windows/*.cs is Compile-Removed on - // non-Windows. Once wrapper is migrated to net8.0-windows - // (NetCore), replace this with - // compile safe registration and delete RegisterWindowsServicesByReflection + // TODO: Reflection because wrapper.dll is .NET Framework 4.7.2 C++/CLI + // (net8.0 can't load it in-proc) and Driver/Windows/*.cs is Compile-Removed + // off Windows. After wrapper moves to net8.0-windows, reference directly + // and delete RegisterWindowsServicesByReflection. // - // TODO: While migrating the wrapper, also fix the root cause of the - // RaProfile/RaAccelArgs/etc. alias renames in this project: wrapper.cpp - // declares its public C++/CLI types (Profile, AccelArgs, DeviceSettings, - // DeviceConfig, AccelMode, CapMode, SpeedArgs) in the GLOBAL namespace, - // which collides with the Contracts aliases on Windows (CS0576) and lets - // bare references silently bind to the wrapper globals. Wrap those types - // in a namespace (e.g. namespace RawAccel) and update consumers + // TODO: While migrating wrapper, namespace its public C++/CLI types + // (Profile, AccelArgs, DeviceSettings, DeviceConfig, AccelMode, CapMode, + // SpeedArgs) -- they sit in the global namespace, collide with Contracts + // on Windows (CS0576), and force the Ra-prefixed aliases. Update consumers // (grapher, writer, wrapper-tests, wrapper-deps, this backend) so the - // Ra-prefixed aliases can revert to clean names. + // aliases can revert. private static void RegisterPlatformServices(IServiceCollection services) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - // Windows-side impls (WindowsRawAccelDriver, ManagedAccelEvaluator, - // WindowsSystemDevicesRetriever) live under Driver/Windows/ and - // are excluded from non-Windows builds via csproj. They depend - // on wrapper.dll (C++/CLI). Registered via reflection so this - // method can compile on Linux where those types do not exist. + // Windows impls live under Driver/Windows/ and depend on wrapper.dll; + // reflection so this method compiles where those types are absent. RegisterWindowsServicesByReflection(services); } - // On non-Windows no platform driver is bundled (the Windows impls bind - // to wrapper.dll). Callers that need a driver/evaluator/devices - // retriever must register their own before Compose (the test fixtures - // do); an unregistered service surfaces a clear DI error at point of - // use rather than failing the whole composition up front. + // No platform driver on non-Windows. Callers (e.g. test fixtures) must + // register driver/evaluator/devices retriever before Compose; otherwise + // DI fails at first use rather than at composition. } private static void RegisterWindowsServicesByReflection(IServiceCollection services) diff --git a/userspace-backend/BackEndLoader.cs b/userspace-backend/BackEndLoader.cs index d02b84e5..42671c3e 100644 --- a/userspace-backend/BackEndLoader.cs +++ b/userspace-backend/BackEndLoader.cs @@ -169,8 +169,7 @@ protected void WriteProfiles(IEnumerable profiles) } } - // Write via a temp file then rename so a crash mid-write cannot leave a - // half-written (corrupt) file in place of the previous good one. + // Temp-then-rename so a crash mid-write can't corrupt the previous good file. private static void WriteFileAtomic(string path, string contents) { string tempPath = path + ".tmp"; @@ -186,10 +185,9 @@ private static void WriteFileAtomic(string path, string contents) protected static string GetProfileFile(string profileDirectory, string profileName) => Path.Combine(profileDirectory, $"{SanitizeFileName(profileName)}.json"); - // Profile names are user-supplied and may contain characters that are - // illegal in a filename (e.g. '/', '\\', ':'); replace those so the - // write does not throw. The on-disk name is not authoritative: load - // reads the profile's Name from the file contents, not the filename. + // User-supplied profile names may contain filename-illegal chars + // ('/', '\\', ':', ...); replace so the write doesn't throw. The on-disk + // name isn't authoritative -- load reads Name from the file body. private static string SanitizeFileName(string name) { foreach (char invalid in Path.GetInvalidFileNameChars()) diff --git a/userspace-backend/Data/Mapping.cs b/userspace-backend/Data/Mapping.cs index f2efe685..3e26913f 100644 --- a/userspace-backend/Data/Mapping.cs +++ b/userspace-backend/Data/Mapping.cs @@ -43,9 +43,8 @@ public override bool Equals(object? obj) public override int GetHashCode() { - // XOR per-entry hashes so the result is order-independent, - // matching the order-independent Equals above. Keys use the - // dictionary's (ordinal) comparer; values are case-insensitive. + // XOR per-entry hashes for order-independence (matches Equals). + // Keys: dictionary's ordinal comparer; values: case-insensitive. int hash = 0; foreach (var kvp in this) diff --git a/userspace-backend/Driver/IAccelEvaluator.cs b/userspace-backend/Driver/IAccelEvaluator.cs index db2fd993..f676252a 100644 --- a/userspace-backend/Driver/IAccelEvaluator.cs +++ b/userspace-backend/Driver/IAccelEvaluator.cs @@ -3,24 +3,20 @@ namespace userspace_backend.Driver { - // Decoupled from IRawAccelDriver because preview is a pure-math - // read that doesn't touch the backend. + // Separate from IRawAccelDriver: preview is pure math, doesn't touch the backend. // - // Windows: wraps wrapper.ManagedAccel.CreateStatelessCopy against the - // same common/ math the driver uses. - // Linux: P/Invokes a stripped libcommon.so (deferred); identity stub - // initially so the chart still renders. + // Windows: wraps wrapper.ManagedAccel.CreateStatelessCopy over the same common/ math. + // Linux: P/Invokes a stripped libcommon.so (deferred); identity stub for now. public interface IAccelEvaluator { IAccelInstance CreateInstance(RawAccelProfile profile); } - // IDisposable because a native-backed instance (Linux ShimInstance) holds an - // unmanaged ra_curve handle; preview callers create one per refresh. + // IDisposable: the Linux ShimInstance holds an unmanaged ra_curve handle, + // and preview callers create one per refresh. public interface IAccelInstance : IDisposable { - // dpiFactor is device DPI normalized against NORMALIZED_DPI (1000); - // timeMs is the time slice attributed to this sample (1 currently) + // dpiFactor: device DPI / NORMALIZED_DPI (1000). timeMs: slice for this sample. // Returns same units as input. (double x, double y) Accelerate(double x, double y, double dpiFactor, double timeMs); } diff --git a/userspace-backend/Driver/IRawAccelDriver.cs b/userspace-backend/Driver/IRawAccelDriver.cs index 739d0735..66ee77ae 100644 --- a/userspace-backend/Driver/IRawAccelDriver.cs +++ b/userspace-backend/Driver/IRawAccelDriver.cs @@ -2,14 +2,13 @@ namespace userspace_backend.Driver { - // Platform-agnostic apply/read/deactivate surface for the driver. + // Platform-agnostic driver surface. // - // Error contract (mirrored by both the Windows and Linux implementations): - // - Apply returns false on failure (logged); it does not throw. - // - Read and Deactivate throw on failure (they are expected to succeed once - // IsAvailable is true). - // - GetCurrentMouseSpeedSample returns MouseSpeedSample.Zero on error; it - // never throws, so callers can't distinguish "idle" from "unavailable". + // Error contract (Windows and Linux impls both): + // - Apply: returns false on failure, logs, never throws. + // - Read, Deactivate: throw on failure (expected to succeed once IsAvailable). + // - GetCurrentMouseSpeedSample: Zero on error, never throws -- callers + // can't distinguish idle from unavailable. public interface IRawAccelDriver { // UI gates Apply on this. diff --git a/userspace-backend/Driver/MouseSpeedSample.cs b/userspace-backend/Driver/MouseSpeedSample.cs index 455292c4..14fd0edb 100644 --- a/userspace-backend/Driver/MouseSpeedSample.cs +++ b/userspace-backend/Driver/MouseSpeedSample.cs @@ -1,8 +1,7 @@ namespace userspace_backend.Driver { - // Live input speed reported by the driver/agent, in counts/ms normalized to - // 1000 DPI (the same units the curve math consumes). X and Y are per-axis; - // Combined is the magnitude the agent computed (not necessarily hypot(X, Y)). + // Live input speed in counts/ms normalized to 1000 DPI (curve-math units). + // Combined is the agent-computed magnitude (not necessarily hypot(X, Y)). public readonly record struct MouseSpeedSample(double X, double Y, double Combined) { public static readonly MouseSpeedSample Zero = new(0, 0, 0); diff --git a/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs b/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs index 83e82393..36300d7b 100644 --- a/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs +++ b/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs @@ -4,11 +4,9 @@ namespace userspace_backend.Driver.Windows { - // IAccelEvaluator backed by wrapper.ManagedAccel against the same - // common/ math the kernel driver runs. Converts the RawAccelProfile - // POCO into a wrapper.Profile via JSON round-trip (matching JsonProperty - // names on both sides) so this evaluator stays in lockstep with whatever - // the apply path produces. + // IAccelEvaluator over wrapper.ManagedAccel (same common/ math as the driver). + // POCO -> wrapper.Profile via JSON round-trip (JsonProperty names match on + // both sides), so this stays in lockstep with the apply path. public sealed class ManagedAccelEvaluator : IAccelEvaluator { public IAccelInstance CreateInstance(RawAccelProfile profile) @@ -18,7 +16,7 @@ public IAccelInstance CreateInstance(RawAccelProfile profile) var nativeProfile = JsonConvert.DeserializeObject(json) ?? throw new InvalidOperationException( "POCO -> wrapper.Profile deserialization returned null"); - // Dispose the seed; CreateStatelessCopy allocates a fresh native pair. + // Seed is disposed; CreateStatelessCopy allocates a fresh native pair. using var seed = new ManagedAccel(nativeProfile); var accel = seed.CreateStatelessCopy(); return new ManagedAccelInstance(accel); diff --git a/userspace-backend/Driver/Windows/RawInputMouseListener.cs b/userspace-backend/Driver/Windows/RawInputMouseListener.cs index 586da835..248cb7ac 100644 --- a/userspace-backend/Driver/Windows/RawInputMouseListener.cs +++ b/userspace-backend/Driver/Windows/RawInputMouseListener.cs @@ -9,20 +9,20 @@ namespace userspace_backend.Driver.Windows { - // Captures live mouse speed via Win32 raw input on its own message-only window - // and thread (the Avalonia UI exposes no WndProc to hook). Reports speed in the - // chart's units: counts/ms normalized to 1000 DPI, matching the curve math. + // Captures live mouse speed via Win32 raw input on its own message-only + // window + thread (Avalonia exposes no WndProc to hook). Reports in chart + // units: counts/ms normalized to 1000 DPI. // - // Raw input identifies devices by HANDLE; config keys DPI by hardware-id. We map - // handle -> hardware-id via wrapper.dll's MultiHandleDevice to pick the per-mouse - // normalization factor, defaulting for handles absent from the config. + // Raw input keys devices by HANDLE; config keys DPI by hardware-id. We map + // handle -> id via wrapper.dll's MultiHandleDevice; unmapped handles use the + // default factor. internal sealed class RawInputMouseListener : IDisposable { - // No movement for this long => report Zero (line eases back to rest). + // No movement for this long => Zero (line eases back to rest). private const double FreshnessMs = 150.0; - // Clamp the inter-event interval so bursts/stalls can't spike or flatline - // the speed. 0.1 ms is a 10 kHz ceiling, above any real polling rate. + // Clamp inter-event interval so bursts/stalls don't spike or flatline. + // 0.1 ms = 10 kHz ceiling, above any real polling rate. private const double MinIntervalMs = 0.1; private const double MaxIntervalMs = 100.0; @@ -42,8 +42,8 @@ internal sealed class RawInputMouseListener : IDisposable private const uint RAWINPUT_ERROR = unchecked((uint)-1); private static readonly IntPtr HWND_MESSAGE = new(-3); - // Distinct window-class name per instance, so a re-created listener never - // collides with a not-yet-freed class name. + // Distinct class name per instance: a re-created listener can't collide + // with a not-yet-freed class. private static int instanceCounter; private readonly ILogger logger; @@ -59,17 +59,17 @@ internal sealed class RawInputMouseListener : IDisposable private volatile bool running; private volatile bool disposed; - // Latest speed (guarded by gate); lastEventTimestamp is the freshness/ + // Latest speed (under gate); lastEventTimestamp is the freshness + // inter-event clock (Interlocked). private double lastX, lastY, lastCombined; private long lastEventTimestamp; - // handle -> normalization factor (NormalizedDpi / dpi); defaultFactor for the rest. - // Volatile + build-once-publish lets HandleRawInput read lock-free on the hot path. + // handle -> NormalizedDpi/dpi; defaultFactor for the rest. Volatile + + // build-once-publish keeps HandleRawInput lock-free on the hot path. private volatile Dictionary handleFactors = new(); private double defaultFactor = 1.0; - // Applied config's DPI-by-hardware-id, used to rebuild handleFactors. + // Applied config's DPI-by-id, used to rebuild handleFactors. private Dictionary dpiById = new(StringComparer.OrdinalIgnoreCase); private int defaultDpi = (int)RawAccelConstants.NormalizedDpi; @@ -80,7 +80,7 @@ public RawInputMouseListener(ILogger? logger = null) className = $"RawAccelRawInputSink_{Environment.ProcessId}_{id}"; } - // Starts the capture thread. Idempotent. Blocks briefly until setup is done. + // Starts the capture thread. Idempotent. Blocks briefly until ready. public void Start() { lock (lifecycleGate) @@ -96,7 +96,7 @@ public void Start() ready.Wait(LifecycleTimeoutMs); } - // Feeds the applied config for per-device DPI. Safe to call before Start. + // Feeds per-device DPI from the applied config. Safe before Start. public void UpdateDevices(RawAccelConfig config) { int defDpi = config.defaultDeviceConfig?.dpi ?? (int)RawAccelConstants.NormalizedDpi; @@ -120,7 +120,7 @@ public void UpdateDevices(RawAccelConfig config) RebuildHandleMap(); } - // The current normalized input speed, or Zero when idle/unavailable. + // Current normalized input speed; Zero if idle/unavailable. public MouseSpeedSample CurrentSample() { if (!running) return MouseSpeedSample.Zero; @@ -146,7 +146,7 @@ public void Dispose() running = false; } - // Paired Volatile.Read/Write: ThreadMain writes nativeThreadId outside any lock. + // Paired with ThreadMain's Volatile.Write outside any lock. uint tid = Volatile.Read(ref nativeThreadId); if (tid != 0) { @@ -273,7 +273,7 @@ private IntPtr WindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) } } - // Cached; Marshal reflection per WM_INPUT would burn the hot path. + // Cached: Marshal reflection per WM_INPUT would burn the hot path. private static readonly uint RawInputMouseSize = (uint)Marshal.SizeOf(); private static readonly uint RawInputHeaderSize = (uint)Marshal.OffsetOf(nameof(RAWINPUTMOUSE.MouseFlags)); @@ -324,7 +324,7 @@ private double FactorForHandle(IntPtr handle) return map.TryGetValue(handle, out double f) ? f : Volatile.Read(ref defaultFactor); } - // Rebuilds handle -> normalization-factor from the live device list and config. + // Rebuilds handle -> factor from the live device list + config. private void RebuildHandleMap() { var map = new Dictionary(); @@ -402,8 +402,8 @@ private struct RAWINPUTDEVICE public IntPtr hwndTarget; } - // Flattened RAWINPUTHEADER + RAWMOUSE; Padding mirrors the native union's - // 4-byte alignment after MouseFlags. + // Flattened RAWINPUTHEADER + RAWMOUSE; Padding mirrors the native + // union's 4-byte alignment after MouseFlags. [StructLayout(LayoutKind.Sequential)] private struct RAWINPUTMOUSE { diff --git a/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs b/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs index cc7589ff..719aeecc 100644 --- a/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs +++ b/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs @@ -12,11 +12,11 @@ public sealed class WindowsRawAccelDriver : IRawAccelDriver, IDisposable private readonly ILogger logger; private readonly object listenerGate = new(); - // Speed-line capture, created lazily on first poll so unused paths never - // spin up a window + thread. Volatile for EnsureListener's lock-free fast path. + // Lazy: unused paths skip the window + thread. + // Volatile for EnsureListener's lock-free fast path. private volatile RawInputMouseListener? listener; - // Last applied config, replayed into the listener for per-device DPI. + // Replayed into the listener for per-device DPI. // Volatile: written by Apply, read by EnsureListener under a different lock. private volatile RawAccelConfig? lastConfig; @@ -65,7 +65,7 @@ public bool Apply(RawAccelConfig config) return false; } - // Driver is already active; don't fail Apply for a listener-side hiccup. + // Driver is already active; don't fail Apply for a listener hiccup. try { listener?.UpdateDevices(config); } catch (Exception ex) { logger.LogDebug(ex, "listener device update failed after apply"); } @@ -129,7 +129,7 @@ public void Dispose() toDispose = listener; listener = null; } - // Disposed outside the lock so the thread-join can't block EnsureListener. + // Outside the lock so thread-join can't block EnsureListener. toDispose?.Dispose(); } } diff --git a/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs b/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs index da3bfa0c..40d4d82d 100644 --- a/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs +++ b/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs @@ -4,7 +4,7 @@ namespace userspace_backend.Driver.Windows { - // Windows device enumeration. Uses the wrapper.dll's MultiHandleDevice. + // Windows device enumeration via wrapper.dll's MultiHandleDevice. public sealed class WindowsSystemDevicesRetriever : ISystemDevicesRetriever { public IList GetSystemDevices() diff --git a/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs b/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs index 2df9dac3..416eed31 100644 --- a/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs +++ b/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs @@ -147,19 +147,17 @@ private static LookupTableAccel CreateLookupTableAccel(ref Utf8JsonReader reader public override void Write(Utf8JsonWriter writer, Acceleration value, JsonSerializerOptions options) { - // Serialize the runtime type's own fields, then patch the "Type" - // property to the discriminator string the Read side expects - // (e.g. "Formula/Classic", "LookupTable", "None"). Using a fresh - // options instance that excludes this converter avoids recursing - // into ourselves and hanging. + // Serialize the runtime type's fields, then overwrite "Type" with + // the discriminator Read expects (e.g. "Formula/Classic", "LookupTable", + // "None"). The fresh options instance excludes this converter to + // avoid recursing into ourselves. JsonNode? node = JsonSerializer.SerializeToNode( value, value.GetType(), WriteOptionsWithoutSelf(options)); if (node is JsonObject obj) { obj["Type"] = GetDiscriminator(value); - // FormulaType is encoded inside the Type discriminator - // ("Formula/Classic"), so do not also emit it as a sibling. + // FormulaType is folded into Type ("Formula/Classic"); don't emit twice. obj.Remove("FormulaType"); } node?.WriteTo(writer); @@ -173,11 +171,10 @@ public override void Write(Utf8JsonWriter writer, Acceleration value, JsonSerial _ => v.Type.ToString(), }; - // Cache the derived options keyed by the source instance so we do not - // rebuild a copy on every Write. The same JsonSerializerOptions instance - // is reused for the lifetime of a reader/writer, so this is a near-perfect - // hit; if a different source ever arrives we simply re-derive. A concurrent - // first-call race only wastes one redundant copy, so no locking is needed. + // Cache derived options keyed by source identity. A reader/writer reuses + // one JsonSerializerOptions for its lifetime, so this is a near-perfect hit; + // a different source just re-derives. The first-call race wastes at most + // one copy -- no locking needed. private JsonSerializerOptions? cachedSource; private JsonSerializerOptions? cachedWriteOptions; diff --git a/userspace-backend/IO/SettingsReaderWriter.cs b/userspace-backend/IO/SettingsReaderWriter.cs index a363def6..58971a6f 100644 --- a/userspace-backend/IO/SettingsReaderWriter.cs +++ b/userspace-backend/IO/SettingsReaderWriter.cs @@ -20,9 +20,8 @@ public override string Serialize(Settings settings) public override Settings Deserialize(string toRead) { - // A literal "null" payload maps to defaults; malformed JSON is left to - // throw so the caller (BackEndLoader.LoadSettings) can decide, the same - // way the sibling reader/writers behave. + // Literal "null" -> defaults; malformed JSON throws for the caller + // (BackEndLoader.LoadSettings) to handle, matching sibling readers. return JsonSerializer.Deserialize(toRead, JsonOptions) ?? new Settings(); } } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/FormulaAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/FormulaAccelerationDefinitionModel.cs index 0a84f05e..95ce625d 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/FormulaAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/FormulaAccelerationDefinitionModel.cs @@ -6,10 +6,9 @@ namespace userspace_backend.Model.AccelDefinitions.Formula { /// - /// Shared base for the per-formula definition models (Classic, Jump, Linear, - /// Natural, Power, Synchronous). Every formula is a flat set of leaf - /// double settings with no nested collections, so the collection mapping is a - /// no-op for all of them. Subclasses supply the formula-specific + /// Shared base for per-formula definition models (Classic, Jump, Linear, + /// Natural, Power, Synchronous). Each formula is a flat set of leaf double + /// settings, so collection mapping is a no-op. Subclasses supply /// , MapToData, and per-field MapFromData. /// public abstract class FormulaAccelerationDefinitionModel @@ -23,7 +22,7 @@ protected FormulaAccelerationDefinitionModel(IEnumerable curve public abstract RaAccelArgs MapToDriver(); - // No formula has nested settings collections; its parameters are all leaf settings. + // No formula has nested collections -- params are all leaf settings. protected sealed override bool TryMapEditableSettingsCollectionsFromData(TData data) => true; } } diff --git a/userspace-backend/Model/DeviceGroups.cs b/userspace-backend/Model/DeviceGroups.cs index aa908b5c..470e5a67 100644 --- a/userspace-backend/Model/DeviceGroups.cs +++ b/userspace-backend/Model/DeviceGroups.cs @@ -105,8 +105,7 @@ protected override IEnumerable EnumerateEditableS protected override void InitEditableSettingsAndCollections(IEnumerable dataObject) { - // This initialization does not set up all device group models. - // That is done in backend construction in order to point the devices to their groups. + // Backend construction sets up the rest, pointing devices at their groups. DeviceGroupModels = new ObservableCollection() { DefaultDeviceGroup }; } } diff --git a/userspace-backend/Model/EditableSettings/EditableSetting.cs b/userspace-backend/Model/EditableSettings/EditableSetting.cs index fcc60a5d..44efc7f3 100644 --- a/userspace-backend/Model/EditableSettings/EditableSetting.cs +++ b/userspace-backend/Model/EditableSettings/EditableSetting.cs @@ -62,14 +62,13 @@ public EditableSettingV2( public T LastWrittenValue { get; protected set; } /// - /// Interface can set this for cases when new value arrives all at once (such as menu selection) - /// instead of cases where new value arrives in parts (typing) + /// Set when the value arrives whole (e.g. menu selection) rather than piecewise (typing). /// public bool AutoUpdateFromInterface { get; set; } private IUserInputParser Parser { get; } - //TODO: change settings collections init so that this can be made private for non-static validators + //TODO: rework settings-collection init to make this private for non-static validators. public IModelValueValidator Validator { get; set; } private bool AllowAutoUpdateFromInterface { get; set; } = true; diff --git a/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs b/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs index fb405eef..0235528e 100644 --- a/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs +++ b/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs @@ -10,11 +10,9 @@ namespace userspace_backend.Model.EditableSettings /// Internal node of the settings tree. /// /// - /// The settings model for this backend is a composed object containing other composed objects and settings. - /// In this way, the objects form a tree. The root node is the base model class, the internal nodes are objects containing other objects - /// and settings, and the settings themselves are the leaf nodes. - /// This interface is then an internal node of the tree. It can contain other settings collections (internal nodes) and also contain - /// settings themselves. + /// Settings form a tree: the root model is the root node, collections are internal + /// nodes (this interface), and individual settings are leaves. Collections may hold + /// other collections and/or settings. /// public interface IEditableSettingsCollectionV2 { @@ -36,11 +34,11 @@ public interface INamedEditableSettingsCollectionSpecific : IEditableSettings } /// - /// Shared base for the settings-collection internal nodes. Holds the change-tracking - /// state and the event plumbing common to both the original () - /// and the dependency-injected () flavors. - /// Subclasses are responsible for populating and - /// and for subscribing the change handlers. + /// Shared base for settings-collection internal nodes. Holds change tracking + /// and event plumbing common to + /// and . Subclasses populate + /// and + /// and wire the change handlers. /// public abstract class EditableSettingsCollectionBase : ObservableObject, IEditableSettingsCollectionV2 { @@ -105,8 +103,8 @@ public void GatherEditableSettingsCollections() { AllContainedEditableSettingsCollections = EnumerateEditableSettingsCollections(); - // TODO: separate "All" and "currently selected" settings collections - // so that incorrect assignment is not done here for collections that alter this through use + // TODO: split "All" vs "currently selected" so collections that mutate + // this via use don't get wired up incorrectly here. foreach (var settingsCollection in AllContainedEditableSettingsCollections) { settingsCollection.AnySettingChanged += EditableSettingsCollectionChangedEventHandler; @@ -124,10 +122,9 @@ public void GatherEditableSettingsCollections() /// Base class for settings collections. /// /// - /// Each unique set of collection logic should be generalized into a class that is either this class or a child of this class, - /// but not the actual class in the model. - /// This class, and any child class that is a parent to actual settings collections in the model, requires unit tests. - /// The actual settings collections in the model do not need to each be tested beyond composition. + /// Unique collection logic belongs in this class or a child of it, not in + /// concrete model collections. This class and any such parent requires unit + /// tests; concrete model collections only need composition tests. /// /// public abstract class EditableSettingsCollectionV2 : EditableSettingsCollectionBase, IEditableSettingsCollectionSpecific @@ -148,8 +145,8 @@ public EditableSettingsCollectionV2( } } - // TODO: separate "All" and "currently selected" settings collections - // so that incorrect assignment is not done here for collections that alter this through use + // TODO: split "All" vs "currently selected" so collections that mutate + // this via use don't get wired up incorrectly here. foreach (var settingsCollection in AllContainedEditableSettingsCollections) { settingsCollection.AnySettingChanged += EditableSettingsCollectionChangedEventHandler; diff --git a/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs b/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs index d59be2da..01f9b1d2 100644 --- a/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs +++ b/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs @@ -46,7 +46,7 @@ protected EditableSettingsSelectable( public bool TryMapFromData(U data) { T dataCasted = data as T; - // base. avoids re-binding to this U overload (T converts to U). + // base. avoids re-binding to the U overload (T converts to U). return dataCasted == null ? false : base.TryMapFromData(dataCasted); } @@ -88,9 +88,8 @@ protected void InitSelectionLookup(IServiceProvider serviceProvider) var subModel = serviceProvider.GetRequiredKeyedService>(key); SelectionLookup.Add(value, subModel); - // Bubble AnySettingChanged from every sub model up through this selector so - // enclosing models (e.g. ProfileModel) recompute derived state when nested - // parameters change. + // Bubble AnySettingChanged so enclosing models (e.g. ProfileModel) + // recompute derived state when nested params change. subModel.AnySettingChanged += EditableSettingsCollectionChangedEventHandler; } } @@ -117,7 +116,7 @@ protected EditableSettingsSelectableSelector( public bool TryMapFromData(V data) { U dataCasted = data as U; - // base. avoids re-binding to this V overload (U converts to V). + // base. avoids re-binding to the V overload (U converts to V). return dataCasted == null ? false : base.TryMapFromData(dataCasted); } diff --git a/userspace-backend/Model/EditableSettings/IEditableSetting.cs b/userspace-backend/Model/EditableSettings/IEditableSetting.cs index 73771680..9458baca 100644 --- a/userspace-backend/Model/EditableSettings/IEditableSetting.cs +++ b/userspace-backend/Model/EditableSettings/IEditableSetting.cs @@ -29,12 +29,10 @@ public interface IEditableSettingSpecific : IEditableSetting where T : ICompa public T CurrentValidatedValue { get; } /// - /// Attempts to update the model directly. Validates the input as if it had been parsed from interface. - /// This method should probably not be called from the interface. Instead, set InterfaceValue and - /// call TryUpdateFromInterface(). + /// Updates the model directly, validating as if parsed from the interface. + /// Prefer setting InterfaceValue + TryUpdateFromInterface() from UI code. /// - /// Value to which model should be tried to be set. - /// bool indicating success + /// true on success. public bool TryUpdateModelDirectly(T data); } } diff --git a/userspace-backend/Model/EditableSettings/ModelValueValidator.cs b/userspace-backend/Model/EditableSettings/ModelValueValidator.cs index fed8fddc..242669d4 100644 --- a/userspace-backend/Model/EditableSettings/ModelValueValidator.cs +++ b/userspace-backend/Model/EditableSettings/ModelValueValidator.cs @@ -8,9 +8,9 @@ public interface IModelValueValidator } /// - /// Rejects values outside an optional [min, max] range. Either bound may be - /// omitted (open on that side) and either bound may be inclusive or exclusive. - /// Rejection keeps the last good value (the validator simply returns false). + /// Rejects values outside [min, max]. Either bound may be omitted (open) and + /// either may be inclusive or exclusive. Rejection returns false; caller keeps + /// the last good value. /// public class RangeValidator : IModelValueValidator where T : struct, IComparable { diff --git a/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs b/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs index 24b1f871..07230c0e 100644 --- a/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs +++ b/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs @@ -87,9 +87,9 @@ protected override bool TryMapEditableSettingsFromData(Anisotropy data) { if (data == null) return false; - // Identity weights ((1,1),(1,1)) are the only meaningful defaults; a (0,0) vector - // was written by an older buggy fallback and produces a degenerate flat curve. - // Substitute identity for that specific corrupted shape on load. + // Identity ((1,1),(1,1)) is the only meaningful default; (0,0) was + // written by an older buggy fallback and produces a flat curve. + // Substitute identity for that shape on load. Vector2 domain = (data.Domain == null || (data.Domain.X == 0 && data.Domain.Y == 0)) ? new Vector2 { X = 1, Y = 1 } : data.Domain; diff --git a/userspace-backend/Model/ProfileModel.cs b/userspace-backend/Model/ProfileModel.cs index e469745d..4c2865e3 100644 --- a/userspace-backend/Model/ProfileModel.cs +++ b/userspace-backend/Model/ProfileModel.cs @@ -67,11 +67,11 @@ public ProfileModel( XCurvePreview = xCurvePreview; YCurvePreview = yCurvePreview; - // Name and Output DPI do not need to generate a new curve preview + // Name + OutputDPI don't affect the curve preview. Name!.PropertyChanged += AnyNonPreviewPropertyChangedEventHandler; OutputDPI.PropertyChanged += AnyNonPreviewPropertyChangedEventHandler; - // The rest of settings should generate a new curve preview + // Everything else does. YXRatio.PropertyChanged += AnyCurvePreviewPropertyChangedEventHandler; Acceleration.AnySettingChanged += AnyCurveSettingCollectionChangedEventHandler; Hidden.AnySettingChanged += AnyCurveSettingCollectionChangedEventHandler; @@ -118,10 +118,10 @@ public RaProfile MapToDriver() outputDPI = OutputDPI.ModelValue, yxOutputDPIRatio = YXRatio.ModelValue, - // Both axes get the same single UI curve, but argsX and argsY MUST be independent - // instances: the native wrapper mutates each axis's data array separately, so sharing - // one reference would corrupt it. Do not collapse these two calls into a shared - // variable. Pinned by BackEndApplyTests.Apply_SingleCurve_PopulatesBothAxes. + // Both axes use the same UI curve, but argsX and argsY MUST be distinct + // instances -- the native wrapper mutates each axis's data array in place. + // Don't collapse to a shared variable. Pinned by + // BackEndApplyTests.Apply_SingleCurve_PopulatesBothAxes. argsX = Acceleration.MapToDriver(), argsY = Acceleration.MapToDriver(), @@ -133,8 +133,8 @@ public RaProfile MapToDriver() snap = Hidden.AngleSnappingDegrees.ModelValue, maximumSpeed = Hidden.SpeedCap.ModelValue, - // The driver supports a speed floor (common/rawaccel-base.hpp), but the UI - // deliberately does not expose one; keep it pinned at 0. + // Driver supports a speed floor (common/rawaccel-base.hpp); UI doesn't + // expose one, keep pinned at 0. minimumSpeed = 0, inputSpeedArgs = new RaSpeedArgs { @@ -168,7 +168,7 @@ protected void AnyCurvePreviewPropertyChangedEventHandler(object? send, Property protected void AnyCurveSettingCollectionChangedEventHandler(object? sender, EventArgs e) { logger.LogDebug("Curve-setting collection changed: {Sender}", sender?.GetType().Name); - // All settings collections currently require curve preview to be re-generated + // All settings collections currently force a preview regen. RecalculateDriverDataAndCurvePreview(); } @@ -187,10 +187,8 @@ protected void RecalculateDriverDataAndCurvePreview() { RecalculateDriverData(); - // Generate X curve points (original behavior) XCurvePreview.GeneratePoints(CurrentValidatedDriverProfile); - - // Generate Y curve points by multiplying X curve outputs by YX ratio + // Y points = X outputs * YX ratio. GenerateYCurvePoints(); } diff --git a/userspace-backend/Model/ProfilesModel.cs b/userspace-backend/Model/ProfilesModel.cs index b5ef66ab..f79c5077 100644 --- a/userspace-backend/Model/ProfilesModel.cs +++ b/userspace-backend/Model/ProfilesModel.cs @@ -6,22 +6,12 @@ using userspace_backend.Model.EditableSettings; using DATA = userspace_backend.Data; -/** - * TODO: Fix circular dependency and initialization order issues with ProfileNameValidator - * - * - ProfilesModel needs ProfileNameValidator to create ProfileModel instances - * - ProfileNameValidator needs ProfilesModel to check for duplicate names - * - * - Base constructor calls InitEditableSettingsAndCollections() BEFORE derived constructor can set NameValidator property, causing validator to be null - * - * - SOLUTION (Implement after DI PR from _m00se): - * - * Create IProfileNameChecker interface for duplicate name validation - * Have ProfilesModel implement IProfileNameChecker - * Inject IProfileNameChecker into ProfileNameValidator constructor - * Register ProfileNameValidator in DI container - * Inject ProfileNameValidator into ProfilesModel constructor - */ +// TODO: Break circular dep between ProfilesModel and ProfileNameValidator. +// (Base ctor's InitEditableSettingsAndCollections runs before derived ctors +// can set NameValidator, so the validator is null at init.) Plan, after the +// DI PR from _m00se: add IProfileNameChecker (implemented by ProfilesModel), +// inject into ProfileNameValidator, register ProfileNameValidator in DI, and +// inject it into ProfilesModel. namespace userspace_backend.Model { @@ -40,7 +30,7 @@ public interface IProfilesModel : IEditableSettingsList, IProfilesModel { - // Default profile is created during BackEnd.Load() if it doesn't exist + // Default profile is created by BackEnd.Load() if absent. public ProfilesModel(IServiceProvider serviceProvider) : base(serviceProvider, [], []) diff --git a/userspace-backend/Model/SystemDevices.cs b/userspace-backend/Model/SystemDevices.cs index f31ec813..64b5f66c 100644 --- a/userspace-backend/Model/SystemDevices.cs +++ b/userspace-backend/Model/SystemDevices.cs @@ -8,7 +8,7 @@ namespace userspace_backend.Model { /// - /// Holds system devices in observable collection and refreshes list when desired. + /// Observable system-device collection with on-demand refresh. /// public interface ISystemDevicesProvider { @@ -50,10 +50,8 @@ public void RefreshSystemDevices() } /// - /// Retrieves list of devices from operating system. Concrete impls are - /// per-platform: WindowsSystemDevicesRetriever (RawInput via wrapper.dll) - /// or LinuxSystemDevicesRetriever (currently a stub; future: query the - /// agent or read /dev/input directly). + /// Per-OS device enumeration. Windows: RawInput via wrapper.dll. Linux: + /// stub (TODO: query the agent or read /dev/input). /// public interface ISystemDevicesRetriever { @@ -61,8 +59,7 @@ public interface ISystemDevicesRetriever } /// - /// Interface to represent devices as they come from the operating system. - /// Backing impls live next to their per-platform retrievers. + /// OS-supplied device. Backing impls live next to their per-platform retrievers. /// public interface ISystemDevice { From 5ba4ce8dbb6a3efc7b71be31d3414b1fd34fe613 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 28 May 2026 19:12:25 -0700 Subject: [PATCH 12/17] userspace-backend: enforce profile name uniqueness via ProfileNameValidator Profile names were validated only for length/non-emptiness, so two profiles could share a name (unlike mappings). Add ProfileNameValidator mirroring MappingNameValidator: it checks case-insensitive uniqueness via IProfilesModel.TryGetProfile plus the prior non-empty/max-length guard, and register it as the keyed validator for the profile Name setting. No DI cycle exists despite the removed TODO's premise: ProfilesModel's ctor builds no ProfileModel instances, so the validator's IProfilesModel dependency resolves against the already-built singleton. --- .../ModelTests/BackEndApplyTests.cs | 33 +++++++++++++++++++ userspace-backend/BackEndComposer.cs | 5 +-- userspace-backend/Model/ProfilesModel.cs | 19 +++++++---- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs index 7f0c79ee..89f18699 100644 --- a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs +++ b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs @@ -181,6 +181,39 @@ public void RemovingReferencedProfile_ReassignsMappingToDefault() Assert.AreEqual("Default", group.Profile.Name.ModelValue); } + [TestMethod] + public void RenamingProfileToExistingName_IsRejected_CaseInsensitive() + { + // ProfileNameValidator enforces uniqueness across profiles. A "Default" + // profile already exists after Load. + var (backEnd, _) = BuildBackEndWithDefaults(); + Assert.IsTrue(backEnd.Profiles.TryAddNewDefaultProfile("Gaming")); + Assert.IsTrue(backEnd.Profiles.TryGetProfile("Gaming", out IProfileModel? gaming) && gaming != null); + + Assert.IsFalse(gaming!.Name.TryUpdateModelDirectly("Default"), "Renaming onto an existing name must be rejected."); + Assert.IsFalse(gaming.Name.TryUpdateModelDirectly("default"), "Uniqueness must be case-insensitive."); + Assert.AreEqual("Gaming", gaming.Name.ModelValue); + + // A genuinely unique name is still accepted. + Assert.IsTrue(gaming.Name.TryUpdateModelDirectly("Gaming2")); + Assert.AreEqual("Gaming2", gaming.Name.ModelValue); + } + + [TestMethod] + public void ProfileName_EmptyOrTooLong_IsRejected() + { + // ProfileNameValidator keeps the prior non-empty / max-length guard. + var (backEnd, _) = BuildBackEndWithDefaults(); + Assert.IsTrue(backEnd.Profiles.TryAddNewDefaultProfile("Gaming")); + Assert.IsTrue(backEnd.Profiles.TryGetProfile("Gaming", out IProfileModel? gaming) && gaming != null); + + Assert.IsFalse(gaming!.Name.TryUpdateModelDirectly(string.Empty), "Empty name must be rejected."); + Assert.IsFalse( + gaming.Name.TryUpdateModelDirectly(new string('a', MaxNameLengthValidator.MaxNameLength + 1)), + "Name longer than the max length must be rejected."); + Assert.AreEqual("Gaming", gaming.Name.ModelValue); + } + [TestMethod] public void EnsureDefaultMapping_FreshInstall_CreatesMappingWithDefaultEntry() { diff --git a/userspace-backend/BackEndComposer.cs b/userspace-backend/BackEndComposer.cs index 2dc7e256..dbd49ba1 100644 --- a/userspace-backend/BackEndComposer.cs +++ b/userspace-backend/BackEndComposer.cs @@ -53,8 +53,9 @@ public static IServiceProvider Compose(IServiceCollection services) services.AddKeyedSingleton, DefaultModelValueValidator>( DefaultModelValueValidator.AllChangeInvalidDIKey); - services.AddKeyedSingleton, MaxNameLengthValidator>( - ProfileModel.NameDIKey); + services.AddKeyedSingleton>( + ProfileModel.NameDIKey, + (sp, key) => new ProfileNameValidator(sp.GetRequiredService())); #endregion Validators diff --git a/userspace-backend/Model/ProfilesModel.cs b/userspace-backend/Model/ProfilesModel.cs index f79c5077..1dc5cf3e 100644 --- a/userspace-backend/Model/ProfilesModel.cs +++ b/userspace-backend/Model/ProfilesModel.cs @@ -6,13 +6,6 @@ using userspace_backend.Model.EditableSettings; using DATA = userspace_backend.Data; -// TODO: Break circular dep between ProfilesModel and ProfileNameValidator. -// (Base ctor's InitEditableSettingsAndCollections runs before derived ctors -// can set NameValidator, so the validator is null at init.) Plan, after the -// DI PR from _m00se: add IProfileNameChecker (implemented by ProfilesModel), -// inject into ProfileNameValidator, register ProfileNameValidator in DI, and -// inject it into ProfilesModel. - namespace userspace_backend.Model { public interface IProfilesModel : IEditableSettingsList @@ -80,4 +73,16 @@ protected override string GetNameFromData(DATA.Profile data) return data.Name; } } + + public class ProfileNameValidator(IProfilesModel profiles) : IModelValueValidator + { + protected IProfilesModel Profiles { get; } = profiles; + + public bool Validate(string value) + { + return !string.IsNullOrEmpty(value) + && value.Length <= MaxNameLengthValidator.MaxNameLength + && !Profiles.TryGetProfile(value, out _); + } + } } From ebd4d808ae978f611f3fc1fc7f6ba7718ec6f143 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 28 May 2026 23:22:24 -0400 Subject: [PATCH 13/17] RawAccel.Contracts: trim repeated comments; add versioning TODO State the shared mirror/JSON-contract rule once on RawAccelConfig instead of repeating it (plus its 'do not rename' elaboration) on every type; reduce the rest to a one-line wrapper.cpp pointer. Replace the version comment with a TODO to unify version bumps (1.7.1 vs driver-still-1.7.0 mismatch). --- RawAccel.Contracts/Constants.cs | 10 ++++------ RawAccel.Contracts/Enums.cs | 5 +---- RawAccel.Contracts/RawAccelAccelArgs.cs | 15 +++++---------- RawAccel.Contracts/RawAccelConfig.cs | 7 ++++--- RawAccel.Contracts/RawAccelDeviceConfig.cs | 5 ++--- RawAccel.Contracts/RawAccelDeviceSettings.cs | 6 ++---- RawAccel.Contracts/RawAccelProfile.cs | 6 ++---- RawAccel.Contracts/RawAccelSpeedArgs.cs | 2 +- RawAccel.Contracts/Vec2.cs | 3 +-- 9 files changed, 22 insertions(+), 37 deletions(-) diff --git a/RawAccel.Contracts/Constants.cs b/RawAccel.Contracts/Constants.cs index e8121e3e..79797e1c 100644 --- a/RawAccel.Contracts/Constants.cs +++ b/RawAccel.Contracts/Constants.cs @@ -1,14 +1,12 @@ namespace RawAccel.Contracts { - // Mirrors common/rawaccel-base.hpp. Values must stay in sync with the - // native side; see common/rawaccel-base.hpp and wrapper/wrapper.cpp. + // Mirrors common/rawaccel-base.hpp; keep values in sync with the native side. public static class RawAccelConstants { public const int PollRateMin = 125; public const int PollRateMax = 8000; - // Mirrors NORMALIZED_DPI in common/rawaccel-base.hpp; the unit all - // curve math is expressed in (counts/ms at 1000 DPI). + // NORMALIZED_DPI: the unit curve math uses (counts/ms at 1000 DPI). public const double NormalizedDpi = 1000.0; public const double DefaultTimeMin = 1000.0 / PollRateMax / 2.0; @@ -24,8 +22,8 @@ public static class RawAccelConstants public const string SettingsKey = "Driver settings"; - // Mirrors RA_VER_* in common/rawaccel-version.h. Bump when the - // settings shape or wire protocol changes incompatibly. + // TODO: make a versioning system to update all of the versions so + // 1.7.1 issue where driver is still on 1.7.0 doesnt happen again. public const int VersionMajor = 1; public const int VersionMinor = 7; public const int VersionPatch = 0; diff --git a/RawAccel.Contracts/Enums.cs b/RawAccel.Contracts/Enums.cs index 5e039650..0924f000 100644 --- a/RawAccel.Contracts/Enums.cs +++ b/RawAccel.Contracts/Enums.cs @@ -3,9 +3,7 @@ namespace RawAccel.Contracts { - // JSON values: classic, jump, natural, synchronous, power, lut, noaccel. - // Names match wrapper/wrapper.cpp exactly so existing settings.json files - // round-trip unchanged. + // JSON names match wrapper.cpp so settings.json round-trips unchanged. [JsonConverter(typeof(StringEnumConverter))] public enum AccelMode { @@ -18,7 +16,6 @@ public enum AccelMode noaccel, } - // JSON values: in_out, input, output. Same shape as wrapper/wrapper.cpp. [JsonConverter(typeof(StringEnumConverter))] public enum CapMode { diff --git a/RawAccel.Contracts/RawAccelAccelArgs.cs b/RawAccel.Contracts/RawAccelAccelArgs.cs index 8e919ffc..515737af 100644 --- a/RawAccel.Contracts/RawAccelAccelArgs.cs +++ b/RawAccel.Contracts/RawAccelAccelArgs.cs @@ -2,13 +2,10 @@ namespace RawAccel.Contracts { - // Mirrors AccelArgs in wrapper/wrapper.cpp. JsonProperty names are the - // contract; do not rename without bumping settings.json compatibility. + // Mirrors AccelArgs in wrapper.cpp. public class RawAccelAccelArgs { - // Maximum number of LUT (input, output) sample pairs the native side - // accepts; mirrors wrapper.cpp's literal AccelArgs.MaxLutPoints which - // resolves to ra::LUT_POINTS_CAPACITY. + // Max LUT (input, output) pairs; resolves to ra::LUT_POINTS_CAPACITY. public const int MaxLutPoints = RawAccelConstants.LutPointsCapacity; public AccelMode mode { get; set; } = AccelMode.noaccel; @@ -35,14 +32,12 @@ public class RawAccelAccelArgs [JsonProperty("Cap mode")] public CapMode capMode { get; set; } = CapMode.output; - // length is internal bookkeeping for native marshalling; not in JSON. + // Native marshalling bookkeeping; not serialized. [JsonIgnore] public int length { get; set; } - // Raw LUT data. On the wire this carries only the populated points - // (length samples); native side resizes to LUT_RAW_DATA_CAPACITY. - // Defaulted to empty (not null) because wrapper.cpp's OnDeserialized - // dereferences data->Length without a null check. + // Carries only the populated points (length samples); native resizes + // to capacity. Empty not null: wrapper's OnDeserialized derefs Length. public float[] data { get; set; } = System.Array.Empty(); } } diff --git a/RawAccel.Contracts/RawAccelConfig.cs b/RawAccel.Contracts/RawAccelConfig.cs index 79dbf742..f8af9a92 100644 --- a/RawAccel.Contracts/RawAccelConfig.cs +++ b/RawAccel.Contracts/RawAccelConfig.cs @@ -2,9 +2,10 @@ namespace RawAccel.Contracts { - // Root JSON contract. Mirrors DriverConfig in wrapper/wrapper.cpp. - // Both the Windows wrapper IOCTL path and the Linux agent unix-socket - // path consume this exact shape; do not introduce divergent fields. + // Root JSON contract, consumed unchanged by both the Windows wrapper + // (IOCTL) and Linux agent (socket) paths. These POCOs mirror the + // wrapper.cpp types; the JsonProperty names are the wire format + // (settings.json + driver), so renaming a field breaks compatibility. public class RawAccelConfig { public string version { get; set; } = string.Empty; diff --git a/RawAccel.Contracts/RawAccelDeviceConfig.cs b/RawAccel.Contracts/RawAccelDeviceConfig.cs index 36c9a481..0ccfa5f3 100644 --- a/RawAccel.Contracts/RawAccelDeviceConfig.cs +++ b/RawAccel.Contracts/RawAccelDeviceConfig.cs @@ -2,9 +2,8 @@ namespace RawAccel.Contracts { - // Mirrors DeviceConfig in wrapper/wrapper.cpp. ShouldSerialize* methods - // hide default-valued fields so the JSON stays compact and matches what - // the wrapper produces today. + // Mirrors DeviceConfig in wrapper.cpp. ShouldSerialize* hide default + // values to keep the JSON compact. public class RawAccelDeviceConfig { public bool disable { get; set; } diff --git a/RawAccel.Contracts/RawAccelDeviceSettings.cs b/RawAccel.Contracts/RawAccelDeviceSettings.cs index a5f74a74..513c3e85 100644 --- a/RawAccel.Contracts/RawAccelDeviceSettings.cs +++ b/RawAccel.Contracts/RawAccelDeviceSettings.cs @@ -1,9 +1,7 @@ namespace RawAccel.Contracts { - // Mirrors DeviceSettings in wrapper/wrapper.cpp. The native side enforces - // length limits via fixed-size buffers; we keep them as plain strings here - // and let the conversion layer truncate/validate against - // RawAccelConstants.MaxNameLen / MaxDevIdLen. + // Mirrors DeviceSettings in wrapper.cpp. Length limits (MaxNameLen / + // MaxDevIdLen) are enforced by the conversion layer, not here. public class RawAccelDeviceSettings { public string name { get; set; } = string.Empty; diff --git a/RawAccel.Contracts/RawAccelProfile.cs b/RawAccel.Contracts/RawAccelProfile.cs index 9283caa4..74e8bc5f 100644 --- a/RawAccel.Contracts/RawAccelProfile.cs +++ b/RawAccel.Contracts/RawAccelProfile.cs @@ -2,10 +2,8 @@ namespace RawAccel.Contracts { - // Mirrors Profile in wrapper/wrapper.cpp. JsonObject(ItemRequired=Always) - // on the wrapper side enforces all-fields-present on read; we relax that - // here so a partial JSON can be tolerated client-side and the native side - // does the strict validation. + // Mirrors Profile in wrapper.cpp. Relaxed here (partial JSON tolerated); + // the native side does the strict all-fields-present validation. public class RawAccelProfile { public string name { get; set; } = "default"; diff --git a/RawAccel.Contracts/RawAccelSpeedArgs.cs b/RawAccel.Contracts/RawAccelSpeedArgs.cs index 26e74908..09d311d6 100644 --- a/RawAccel.Contracts/RawAccelSpeedArgs.cs +++ b/RawAccel.Contracts/RawAccelSpeedArgs.cs @@ -2,7 +2,7 @@ namespace RawAccel.Contracts { - // Mirrors SpeedArgs in wrapper/wrapper.cpp. + // Mirrors SpeedArgs in wrapper.cpp. public class RawAccelSpeedArgs { [JsonProperty("Whole/combined accel (set false for 'by component' mode)")] diff --git a/RawAccel.Contracts/Vec2.cs b/RawAccel.Contracts/Vec2.cs index 849dc56e..c94bfec2 100644 --- a/RawAccel.Contracts/Vec2.cs +++ b/RawAccel.Contracts/Vec2.cs @@ -1,7 +1,6 @@ namespace RawAccel.Contracts { - // 2D vector container used for cap, domainXY, rangeXY in the JSON - // contract. Matches the layout produced by wrapper/wrapper.cpp Vec2. + // 2D vector for cap, domainXY, rangeXY. Mirrors Vec2 in wrapper.cpp. public struct Vec2 { public T x; From 0485f3c78d87362f39aae03a3ec5d414fc30e369 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 28 May 2026 23:55:25 -0400 Subject: [PATCH 14/17] userinterface+tests: trim padded comments to match codebase style Collapse multi-line/padded comments (and the ====-banner dividers unique to ProfileChartViewModel) down to terse 1-3 line notes, matching the single-line comment style used across the rest of the codebase. Drop the repeated "reassign fresh Sections so LiveCharts redraws" rationale to a single statement on the Sections property. No behavior change. --- userinterface/App.axaml.cs | 33 ++----- userinterface/Program.cs | 10 +- .../Services/MouseSpeedPollingService.cs | 21 +---- .../Controls/EditableBoolViewModel.cs | 10 +- .../AnisotropyProfileSettingsViewModel.cs | 5 +- .../Profile/ProfileChartViewModel.cs | 91 ++++++------------- .../DisplayTests/CurvePreviewDisposalTests.cs | 11 +-- .../IOTests/BackEndLoaderRoundTripTests.cs | 17 ++-- .../ModelTests/BackEndApplyTests.cs | 66 +++++--------- .../ModelTests/LookupTableDataTests.cs | 5 +- .../ModelTests/SystemDevicesTests.cs | 3 +- .../ModelTests/TestParsersAndValidators.cs | 6 +- .../ModelTests/WindowsSystemDevicesTests.cs | 9 +- .../AccelerationSerializationTests.cs | 8 +- 14 files changed, 97 insertions(+), 198 deletions(-) diff --git a/userinterface/App.axaml.cs b/userinterface/App.axaml.cs index 0ed33311..9a3ec38c 100644 --- a/userinterface/App.axaml.cs +++ b/userinterface/App.axaml.cs @@ -60,10 +60,8 @@ private static extern IntPtr CreateFile( private static void AttachConsoleStreams() { - // After AllocConsole, open CONOUT$ / CONIN$ directly. Bypasses the CLR's cached - // Stream.Null for Console.Out that was set when we started as a WinExe with no - // attached console. Console.SetOut with a writer over CONOUT$ is the canonical - // workaround for GUI-process logging. + // After AllocConsole, open CONOUT$ / CONIN$ directly: the CLR cached Stream.Null + // for Console.Out at WinExe startup. Writing over CONOUT$ is the standard fix. var stdoutPtr = CreateFile("CONOUT$", GENERIC_WRITE, FILE_SHARE_WRITE, IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero); if (stdoutPtr != IntPtr.Zero && stdoutPtr.ToInt64() != -1) @@ -172,11 +170,9 @@ public override void OnFrameworkInitializationCompleted() } }; - // Avalonia's ShutdownRequested only fires when the window closes - // normally. Under `dotnet run` a Ctrl+C in the terminal sends - // SIGINT, which bypasses the window lifecycle but still triggers - // .NET's ProcessExit. Mirror the save there so dev sessions do - // not silently drop unsaved edits. + // ShutdownRequested only fires on a normal window close. A Ctrl+C under + // `dotnet run` sends SIGINT, which skips that but still hits ProcessExit; + // mirror the save there so dev sessions don't drop unsaved edits. AppDomain.CurrentDomain.ProcessExit += (_, _) => { try @@ -384,17 +380,9 @@ public static void OpenDiscordUrl() } } - /* - * This was originally intended to preload libraries that cause stutter - * but it seems to not have much effect. Will leave it here for now. - * - * Could also do these - * System.Runtime.Intrinsics - * System.Text.Json - * System.Text.Encodings.Web - * System.Text.Encoding.Extensions - * System.IO.Pipelines - */ + // Preloads libraries that can cause first-use stutter. Limited effect in + // practice; kept for now. Candidates: System.Runtime.Intrinsics, + // System.Text.Json/Encodings.Web/Encoding.Extensions, System.IO.Pipelines. private async Task PreloadLibrariesAsync() { try @@ -433,9 +421,8 @@ await Task.Run(() => } } - // On Linux, settings live under $XDG_CONFIG_HOME/rawaccel (or - // $HOME/.config/rawaccel when XDG_CONFIG_HOME is unset/empty). On other - // OSes we keep the original behavior of writing next to the executable. + // On Linux, settings live under $XDG_CONFIG_HOME/rawaccel ($HOME/.config/rawaccel + // if unset); other OSes write next to the executable. private static string ResolveSettingsDirectory() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) diff --git a/userinterface/Program.cs b/userinterface/Program.cs index b0cf6d8f..14188053 100644 --- a/userinterface/Program.cs +++ b/userinterface/Program.cs @@ -7,15 +7,13 @@ namespace userinterface; internal sealed class Program { - // Initialization code. Don't use any Avalonia, third-party APIs or any - // SynchronizationContext-reliant code before AppMain is called: things aren't initialized - // yet and stuff might break. + // Don't use Avalonia, third-party APIs, or SynchronizationContext-reliant + // code before AppMain is called: nothing is initialized yet. [STAThread] public static void Main(string[] args) { - // This is for crash logging. It Installs global exception sinks BEFORE - // Avalonia starts so a crash during startup or on a worker thread should still get - // written to logs/crash.log before the process exits. + // Install global exception sinks before Avalonia starts so startup or + // worker-thread crashes still reach logs/crash.log. AppDomain.CurrentDomain.UnhandledException += (_, e) => WriteCrashLog("AppDomain.UnhandledException", e.ExceptionObject as Exception); diff --git a/userinterface/Services/MouseSpeedPollingService.cs b/userinterface/Services/MouseSpeedPollingService.cs index 3fccb056..37c39fd9 100644 --- a/userinterface/Services/MouseSpeedPollingService.cs +++ b/userinterface/Services/MouseSpeedPollingService.cs @@ -7,24 +7,13 @@ namespace userinterface.Services { - // Background poller for the driver's current input-speed telemetry. - // Registered transient so each chart ViewModel owns its own instance. - // - // The poll runs on a background loop, NOT on FrameTimerService: that - // service is a UI-thread DispatcherTimer used to detect UI stalls, so a - // blocking unix-socket RPC there would stall the very thread it monitors. - // Here the RPC happens off the UI thread and only the parsed sample - // (a struct) is marshalled back via Dispatcher.UIThread.Post. - // - // No-op when the driver is null (headless/unsupported). When the agent is - // down the driver returns MouseSpeedSample.Zero cheaply (a File.Exists - // check, not a slow connect), so the loop is safe to keep running. + // Background poller for the driver's current input-speed telemetry. Transient, + // so each chart ViewModel owns one. No-op when the driver is null; when the + // agent is down the driver returns Zero cheaply, so the loop is safe to run. public sealed class MouseSpeedPollingService : IDisposable { - // ~30 Hz. The chart's own animations are ~100 ms, so the indicator - // only needs to feel live, not be frame-perfect. Each poll opens a - // fresh AF_UNIX socket; 30 Hz keeps that churn modest. Drop to ~20 Hz - // if the per-call connect ever shows up in profiling. + // ~30 Hz. The chart animates at ~100 ms, so the indicator only needs to + // feel live, not frame-perfect. Each poll opens a fresh AF_UNIX socket. private const int PollIntervalMs = 33; private readonly IRawAccelDriver? driver; diff --git a/userinterface/ViewModels/Controls/EditableBoolViewModel.cs b/userinterface/ViewModels/Controls/EditableBoolViewModel.cs index 8d4ea97f..1a7a1100 100644 --- a/userinterface/ViewModels/Controls/EditableBoolViewModel.cs +++ b/userinterface/ViewModels/Controls/EditableBoolViewModel.cs @@ -13,9 +13,8 @@ public partial class EditableBoolViewModel : ViewModelBase private readonly LocalizationService localizationService; - // When true, toggling the checkbox commits straight to the backend - // setting (so dependent logic, e.g. the chart, reacts immediately). - // Default false preserves the deferred-commit behavior other callers rely on. + // When true, toggling commits straight to the backend (so the chart reacts + // immediately). Default false keeps the deferred-commit behavior. private readonly bool autoCommit; private bool suppressAutoCommit; @@ -49,9 +48,8 @@ public bool TrySetFromInterface() private void ResetValueFromBackEnd() { - // Suppress auto-commit while we mirror the backend value into the - // display property, otherwise the resulting change event would - // re-commit (and recurse). + // Suppress auto-commit while mirroring the backend value in, or the + // change event would re-commit and recurse. suppressAutoCommit = true; ValueInDisplay = bool.TryParse(SettingBE.InterfaceValue, out bool result) && result; suppressAutoCommit = false; diff --git a/userinterface/ViewModels/Profile/AnisotropyProfileSettingsViewModel.cs b/userinterface/ViewModels/Profile/AnisotropyProfileSettingsViewModel.cs index 9e9622e6..360ccf42 100644 --- a/userinterface/ViewModels/Profile/AnisotropyProfileSettingsViewModel.cs +++ b/userinterface/ViewModels/Profile/AnisotropyProfileSettingsViewModel.cs @@ -14,9 +14,8 @@ public AnisotropyProfileSettingsViewModel(BE.IAnisotropyModel anisotropyBE, Loca RangeX = new EditableFieldViewModel(AnisotropyBE.RangeX); RangeY = new EditableFieldViewModel(AnisotropyBE.RangeY); LPNorm = new NamedEditableFieldViewModel(AnisotropyBE.LPNorm, localizationService); - // autoCommit so toggling the checkbox flows to the backend immediately; - // the chart subscribes to this setting to switch between one and two - // current-speed lines. + // autoCommit so the toggle reaches the backend immediately; the chart + // watches this to switch between one and two current-speed lines. CombineXY = new EditableBoolViewModel(AnisotropyBE.CombineXYComponents, localizationService, autoCommit: true); } diff --git a/userinterface/ViewModels/Profile/ProfileChartViewModel.cs b/userinterface/ViewModels/Profile/ProfileChartViewModel.cs index 9613055b..c266ea0f 100644 --- a/userinterface/ViewModels/Profile/ProfileChartViewModel.cs +++ b/userinterface/ViewModels/Profile/ProfileChartViewModel.cs @@ -47,11 +47,9 @@ public partial class ProfileChartViewModel : ViewModelBase, IAsyncInitializable private const int StandardStrokeThickness = 1; private const float SubStrokeThickness = 0.5f; - // Speed-line smoothing: the poller delivers ~30 Hz targets; a UI-thread - // timer eases the displayed line position toward the latest target so it - // glides instead of teleporting. TimeConstant sets the glide speed (a - // larger value is smoother/laggier); Settle is the chart-unit threshold - // at which a line is treated as arrived (and a fading line snaps to 0). + // Speed-line smoothing: a UI-thread timer eases the displayed line toward + // the latest ~30 Hz poll target. TimeConstant = glide speed (larger is + // smoother/laggier); SettleEpsilon = chart-unit "arrived" threshold. private const int TweenIntervalMs = 16; // ~60 Hz private const double TweenTimeConstantMs = 60.0; private const double SpeedSettleEpsilon = 0.05; @@ -79,9 +77,8 @@ public partial class ProfileChartViewModel : ViewModelBase, IAsyncInitializable private readonly MouseSpeedPollingService speedPoller; private BE.IProfileModel currentProfileModel = null!; - // Speed-line tween state. target* is the latest poller sample; disp* is the - // eased position actually rendered. The tweenTimer pumps disp -> target and - // self-stops once settled (restarted by ApplySpeedSample on a new target). + // Tween state: target* is the latest poll sample, disp* the eased position + // rendered. tweenTimer pumps disp -> target and self-stops once settled. private DispatcherTimer? tweenTimer; private DateTime lastTweenTick; private double targetSpeedX, targetSpeedY, targetSpeedCombined; @@ -91,10 +88,8 @@ public partial class ProfileChartViewModel : ViewModelBase, IAsyncInitializable private SolidColorPaint? cachedXStroke; private SolidColorPaint? cachedYStroke; - // Anisotropy "combine X and Y" flag for the active profile: drives whether - // one (combined) or two (per-axis) current-speed lines are shown. The - // section instances and their paints are rebuilt fresh each update so - // reassigning the bound Sections collection forces a chart redraw. + // Active profile's "combine X and Y" flag: one (combined) or two (per-axis) + // current-speed lines. private IEditableSettingSpecific CombineXY { get; set; } = null!; // Sync object for thread safety - single allocation @@ -122,8 +117,7 @@ public ProfileChartViewModel(IThemeService themeService, LocalizationService loc private bool showSpeedLines = true; - // Whether the live current-speed indicator line(s) are shown. Toggled - // from the chart's button bar; hides/restores the lines immediately. + // Whether the live current-speed line(s) are shown; toggled from the button bar. public bool ShowSpeedLines { get => showSpeedLines; @@ -189,9 +183,7 @@ public void Initialize(BE.IProfileModel profileModel) } - // ================================================================================================ - // INITIALIZATION & SETUP - // ================================================================================================ + // --- Initialization & setup --- public Task InitializeAsync() { @@ -394,11 +386,9 @@ public Task SwitchToProfileAsync(BE.IProfileModel profileModel) public ObservableCollection Series { get; set; } = new ObservableCollection(); - // Vertical current-speed indicator line(s). One section in combined mode, - // two (X and Y) in separate mode. Bound to CartesianChart.Sections. - // Reassigned (not mutated in place) on every update so the chart's - // property-change path re-renders: LiveCharts does not reliably redraw - // when a section already in the collection has its Xi/Xj mutated. + // Vertical current-speed line(s) bound to CartesianChart.Sections: one in + // combined mode, two (X/Y) in separate. Reassigned fresh each update because + // LiveCharts won't redraw a section mutated in place. public IEnumerable Sections { get; private set; } = Array.Empty(); public Axis[] XAxes { get; set; } = new Axis[] { new Axis { Name = "Loading...", MinLimit = 0, MaxLimit = 1 } }; @@ -415,9 +405,7 @@ public Task SwitchToProfileAsync(BE.IProfileModel profileModel) public ICommand ToggleSpeedLinesCommand { get; } - // ================================================================================================ - // PUBLIC METHODS - // ================================================================================================ + // --- Public methods --- public void FitToData() { @@ -454,9 +442,7 @@ public void RecreateAxes(double? xMinLimit = null, double? xMaxLimit = null, dou OnPropertyChanged(nameof(YAxes)); } - // ================================================================================================ - // CLEANUP & DISPOSAL - // ================================================================================================ + // --- Cleanup & disposal --- public void Dispose() { @@ -492,9 +478,7 @@ public void Dispose() previewRenderer.ClearCache(); } - // ================================================================================================ - // CHART DATA MANAGEMENT - // ================================================================================================ + // --- Chart data management --- private ISeries[] CreateSeriesData() { @@ -576,16 +560,11 @@ private void CreateSeries() } } - // ================================================================================================ - // LIVE CURRENT-SPEED INDICATOR LINES - // ================================================================================================ + // --- Live current-speed indicator lines --- - // Builds a vertical line at the given speed: a zero-width section - // (Xi == Xj) stroked at the same thickness as the curve lines. A - // non-positive speed yields NaN bounds, which render nothing (hidden). - // A FRESH paint is created per call on purpose: reusing a paint across - // Sections reassignments makes LiveCharts dispose it when the previous - // section is removed, so a shared paint stops drawing after one frame. + // Vertical zero-width line (Xi == Xj) at the given speed; non-positive + // speed -> NaN bounds, which render nothing. Fresh paint per call: a shared + // one gets disposed by LiveCharts when its section is removed. private static RectangularSection MakeSpeedLine(double speed, SKColor color) { double x = speed > 0 ? speed : double.NaN; @@ -598,10 +577,7 @@ private static RectangularSection MakeSpeedLine(double speed, SKColor color) }; } - // Builds the indicator line(s) for the given sample and reassigns the - // bound Sections collection. We hand the chart FRESH section instances - // each update because LiveCharts does not redraw when an existing - // section's Xi/Xj are mutated in place. + // Builds the indicator line(s) for the sample and reassigns Sections. private void PublishSpeedSections(MouseSpeedSample sample) { if (!ShowSpeedLines) @@ -624,14 +600,12 @@ private void PublishSpeedSections(MouseSpeedSample sample) OnPropertyChanged(nameof(Sections)); } - // Republishes the section(s) for the current mode at the current displayed - // (eased) positions. Called on init (disp* are 0, so hidden) and when the - // combine-X/Y mode or the show toggle changes, so the switch is seamless. + // Republishes section(s) at the current eased positions. Called on init and + // when the combine-X/Y mode or show toggle changes. private void RebuildSpeedSections() => PublishSpeedSections(new MouseSpeedSample(dispSpeedX, dispSpeedY, dispSpeedCombined)); - // Called on the UI thread by the poller: record the new target and let the - // tween timer ease the displayed line(s) toward it (no direct publish). + // Poller callback (UI thread): record the new target; the tween eases toward it. private void ApplySpeedSample(MouseSpeedSample sample) { targetSpeedX = sample.X; @@ -640,8 +614,7 @@ private void ApplySpeedSample(MouseSpeedSample sample) EnsureTweenRunning(); } - // Starts the tween pump if there is anything to animate and the lines are - // visible/interactive. Cheap to call every poll: a no-op once settled. + // Starts the tween pump if there's anything to animate; no-op once settled. private void EnsureTweenRunning() { if (!IsInteractiveMode || !ShowSpeedLines) return; @@ -670,8 +643,8 @@ private bool IsSpeedSettled() => private static bool SpeedAxisSettled(double disp, double target) => Math.Abs(disp - target) < SpeedSettleEpsilon; - // Frame-rate-independent exponential ease toward the target. A line fading - // out (target <= 0) snaps to 0 once close so MakeSpeedLine hides it cleanly. + // Frame-rate-independent exponential ease toward target; a fading line + // (target <= 0) snaps to 0 so MakeSpeedLine hides it. private static double EaseSpeedAxis(double disp, double target, double alpha) { double next = disp + (target - disp) * alpha; @@ -714,9 +687,7 @@ private void StartSpeedPollingIfPossible() } } - // ================================================================================================ - // EVENT HANDLERS - // ================================================================================================ + // --- Event handlers --- private void OnYXRatioChanged(object? sender, PropertyChangedEventArgs e) { @@ -737,9 +708,7 @@ private void OnCombineXYChanged(object? sender, PropertyChangedEventArgs e) Avalonia.Threading.Dispatcher.UIThread.Post(RebuildSpeedSections); } - // ================================================================================================ - // CHART AXES CREATION - // ================================================================================================ + // --- Chart axes creation --- private Axis[] CreateXAxes(double? minLimit = null, double? maxLimit = null) { @@ -794,9 +763,7 @@ private Axis[] CreateYAxes(double? minLimit = null, double? maxLimit = null) } - // ================================================================================================ - // AXIS LIMITS MANAGEMENT - // ================================================================================================ + // --- Axis limits management --- private void SetDefaultLimits() { diff --git a/userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs b/userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs index 85405365..535dc149 100644 --- a/userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs +++ b/userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs @@ -7,14 +7,9 @@ namespace userspace_backend_tests.DisplayTests { - // Regression tests for the IAccelInstance disposal contract. - // - // The bug: IAccelInstance had no IDisposable, so CurvePreview.GeneratePoints - // created a fresh instance on every refresh and could not free it. On Linux - // that instance (ShimInstance) holds a native ra_curve handle, so the preview - // leaked one handle per refresh until finalization. These tests lock in that - // GeneratePoints disposes what it creates and that the contract stays on the - // interface, with no native shim or GUI required. + // Regression tests for the IAccelInstance disposal contract. With no IDisposable, + // CurvePreview.GeneratePoints leaked a native ra_curve handle (Linux ShimInstance) + // per refresh. These pin dispose-what-you-create and that IDisposable stays. [TestClass] public class CurvePreviewDisposalTests { diff --git a/userspace-backend-tests/IOTests/BackEndLoaderRoundTripTests.cs b/userspace-backend-tests/IOTests/BackEndLoaderRoundTripTests.cs index ce2b7dbc..4d92c9d9 100644 --- a/userspace-backend-tests/IOTests/BackEndLoaderRoundTripTests.cs +++ b/userspace-backend-tests/IOTests/BackEndLoaderRoundTripTests.cs @@ -11,11 +11,9 @@ namespace userspace_backend_tests.IOTests { - // End-to-end: write devices/mappings/profiles/settings via BackEndLoader, - // verify the files actually land in the target directory, then read them - // back with a fresh BackEndLoader and verify values survive the trip. - // Catches regressions where a file silently fails to write (path missing, - // wrong format) or fields are dropped by the serializer. + // End-to-end: write devices/mappings/profiles/settings via BackEndLoader, then + // read them back with a fresh loader and verify values survive. Catches files + // that silently fail to write or fields dropped by the serializer. [TestClass] public class BackEndLoaderRoundTripTests { @@ -190,12 +188,9 @@ public void LoadSettings_ReturnsNull_WhenFileMissing() Assert.IsNull(loader.LoadSettings()); } - // Regression probe: prove that a ClassicAccel serialized via the - // ProfileReaderWriter actually round-trips with its curve-specific - // fields intact. The previous behavior was that Profile.Acceleration - // serialized as the *base* class (no curve params, no formula - // discriminator), so a Classic profile written to disk and read back - // came back as NoAcceleration with all settings lost. + // Regression: Profile.Acceleration serialized as the base class (no curve + // params or formula discriminator), so a Classic profile round-tripped back + // as NoAcceleration with all settings lost. Prove the fields now survive. [TestMethod] public void Profile_ClassicAccel_SurvivesRoundTrip() { diff --git a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs index 89f18699..b1de479a 100644 --- a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs +++ b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs @@ -60,9 +60,8 @@ private sealed class StubSystemDevice : ISystemDevice public string HWID { get; init; } = string.Empty; } - // Captures whatever the BackEnd hands to its driver. Cross-platform: - // implements IRawAccelDriver so the same tests run on Windows and Linux - // builds without touching wrapper.dll or the agent socket. + // Captures what BackEnd hands its driver. Cross-platform IRawAccelDriver so + // tests run on Windows and Linux without wrapper.dll or the agent socket. private sealed class CapturingDriver : IRawAccelDriver { public RawAccelConfig? CapturedConfig { get; private set; } @@ -127,11 +126,9 @@ private static RawAccelConfig ApplyAndCapture(IBackEnd backEnd, CapturingDriver [TestMethod] public void FormulaDIKeys_AreDistinctPerFormula_SoExponentDefaultsDoNotCollide() { - // Regression: Power (and Jump) prefixed their DI keys with - // nameof(ClassicAccelerationDefinitionModel), so Power.ExponentDIKey - // equalled Classic.ExponentDIKey. AddEditableSetting uses - // AddKeyedTransient (last registration wins), so Classic's Exponent - // silently resolved to Power's default (0.05) instead of its own (2). + // Regression: Power/Jump keyed their DI settings under + // nameof(ClassicAccelerationDefinitionModel), so last-registration-wins + // made Classic's Exponent resolve to Power's default (0.05, not 2). Assert.AreNotEqual( ClassicAccelerationDefinitionModel.ExponentDIKey, PowerAccelerationDefinitionModel.ExponentDIKey, @@ -430,11 +427,9 @@ public void ReloadSystemDevices_RemovesDisconnectedAndAddsNew() [TestMethod] public void Apply_ProfileCurveCoefficientEdit_FlowsIntoDriverConfig() { - // Regression for the "stale curve on Apply" bug: editing a coefficient inside - // the currently-selected curve sub-model (Formula -> Classic -> Acceleration) - // must propagate through EditableSettingsSelector.AnySettingChanged up to - // ProfileModel.RecalculateDriverData so CurrentValidatedDriverProfile refreshes - // before BackEnd.Apply() reads it via MapToDriverConfig. + // Regression ("stale curve on Apply"): editing a coefficient in the selected + // sub-model must propagate via AnySettingChanged to RecalculateDriverData so + // CurrentValidatedDriverProfile refreshes before Apply reads it. var (backEnd, driver) = BuildBackEndWithDefaults(); var profile = backEnd.Profiles.Elements[0]; @@ -471,12 +466,9 @@ public void Apply_ProfileCurveCoefficientEdit_FlowsIntoDriverConfig() [TestMethod] public void Apply_SingleCurve_PopulatesBothAxes() { - // Regression: ProfileModel.MapToDriver used to set only argsX, leaving argsY - // at its noaccel default. With the default by-component anisotropy mode - // (CombineXYComponents == false) the native math indexes Y through argsY, - // so vertical acceleration was silently dead while horizontal worked and - // the flat sens multipliers still applied. The single model curve must - // drive BOTH argsX and argsY. + // Regression: MapToDriver set only argsX, leaving argsY at noaccel. In the + // default by-component mode the native math reads Y from argsY, so vertical + // accel was dead. The single model curve must drive both argsX and argsY. var (backEnd, driver) = BuildBackEndWithDefaults(); var profile = backEnd.Profiles.Elements[0]; @@ -579,13 +571,9 @@ public void Load_ProfileWithClassicAccel_DoesNotRecurse() Assert.AreEqual(4.0, classic.Cap.ModelValue); } - // Regression: an older AccelerationModel fallback wrote - // Anisotropy.Domain={0,0} / Range={0,0} when the on-disk profile had a - // missing Anisotropy block. Those zeros then round-tripped back to disk - // and degenerated the preview curve to a flat line (domain=0 collapses - // input speed to 0; range=0 collapses scale to 1). Loading a profile - // with the legacy all-zero Anisotropy must sanitize back to identity - // weights so the curve preview is meaningful. + // Regression: an old fallback wrote Anisotropy Domain/Range = {0,0} for a + // missing block, flattening the preview curve (domain=0 -> input 0, range=0 + // -> scale 1). Loading legacy all-zero Anisotropy must sanitize to identity. private sealed class ZeroAnisotropyLoader : IBackEndLoader { public IEnumerable LoadDevices() => Array.Empty(); @@ -645,10 +633,9 @@ public void Load_ProfileWithZeroAnisotropy_SanitizesToIdentityWeights() Assert.AreEqual(1.0, aniso.RangeY.ModelValue); } - // Simulates the user-reported flow: app boots with a Default profile of - // Type=None on disk, user switches DefinitionType to Formula then picks - // Classic and edits a coefficient. The chained Apply must see the - // Classic args in the RawAccelConfig the driver receives. + // User-reported flow: boot with a Default profile of Type=None, switch + // DefinitionType to Formula, pick Classic, edit a coefficient. The chained + // Apply must see the Classic args in the driver's RawAccelConfig. [TestMethod] public void TypeChange_FromNoneToClassic_FlowsThroughApply() { @@ -679,14 +666,10 @@ public void TypeChange_FromNoneToClassic_FlowsThroughApply() "Apply must see the edited Classic coefficient, not stale state."); } - // Regression: a user-created device group (e.g. "DeviceGroup0") was lost - // on reload because DeviceGroups.DeviceGroupModels is the master list - // backing the UI dropdown and MappingModel.TryAddMapping, but is never - // rehydrated from devices.json or mappings.json. Symptoms: - // 1. devices.json keeps device.DeviceGroup="DeviceGroup0" - this part - // survives, but the UI dropdown only shows "Default". - // 2. mappings.json has DeviceGroup0 -> Some Profile, but TryAddMapping - // rejects the row because "DeviceGroup0" isn't registered. + // Regression: a user-created device group (e.g. "DeviceGroup0") was lost on + // reload because DeviceGroupModels (the master list behind the UI dropdown and + // TryAddMapping) is never rehydrated from devices.json/mappings.json. The + // dropdown showed only "Default" and TryAddMapping rejected the saved mapping. private sealed class CustomDeviceGroupLoader : IBackEndLoader { public IEnumerable LoadDevices() => new[] @@ -767,10 +750,9 @@ public void Load_CustomDeviceGroupInDevicesAndMappings_RestoresGroupList() [TestMethod] public void ImportSystemDevices_SyncsInterfaceValueSoUiReflectsRealValues() { - // Regression: EditableSettingV2.TryUpdateModelDirectly used to update ModelValue - // but not InterfaceValue. The UI binds to InterfaceValue via EditableFieldViewModel, - // so imported devices showed the DI placeholder ("name", "hwid") even though - // ModelValue was correct. Guard against that by asserting both properties update. + // Regression: TryUpdateModelDirectly updated ModelValue but not InterfaceValue, + // which the UI binds to, so imported devices showed the DI placeholder. Assert + // both properties update. var systemDevices = new List { new StubSystemDevice { Name = "RealMouseName", HWID = @"HID\VID_1234&PID_5678" }, diff --git a/userspace-backend-tests/ModelTests/LookupTableDataTests.cs b/userspace-backend-tests/ModelTests/LookupTableDataTests.cs index a1ea6347..6a430c48 100644 --- a/userspace-backend-tests/ModelTests/LookupTableDataTests.cs +++ b/userspace-backend-tests/ModelTests/LookupTableDataTests.cs @@ -6,9 +6,8 @@ namespace userspace_backend_tests.ModelTests [TestClass] public class LookupTableDataTests { - // Regression: CompareTo previously did `obj as double[]`, but the value - // passed in is always a LookupTableData, so the cast was always null and - // CompareTo always returned -1 ("not equal"), even for identical tables. + // Regression: CompareTo did `obj as double[]` on a LookupTableData, so the + // cast was always null and it returned -1 even for identical tables. [TestMethod] public void CompareTo_EqualData_ReportsEqual() { diff --git a/userspace-backend-tests/ModelTests/SystemDevicesTests.cs b/userspace-backend-tests/ModelTests/SystemDevicesTests.cs index 893d2845..0877d61d 100644 --- a/userspace-backend-tests/ModelTests/SystemDevicesTests.cs +++ b/userspace-backend-tests/ModelTests/SystemDevicesTests.cs @@ -7,8 +7,7 @@ namespace userspace_backend_tests.ModelTests { // Cross-platform tests for the SystemDevices abstraction. The Windows-only - // SystemDevicesRetriever (RawInput-based) is exercised in - // WindowsSystemDevicesTests, which is excluded from non-Windows builds. + // RawInput retriever is covered by WindowsSystemDevicesTests. [TestClass] public class SystemDevicesTests { diff --git a/userspace-backend-tests/ModelTests/TestParsersAndValidators.cs b/userspace-backend-tests/ModelTests/TestParsersAndValidators.cs index 2dfe86b0..643ed36f 100644 --- a/userspace-backend-tests/ModelTests/TestParsersAndValidators.cs +++ b/userspace-backend-tests/ModelTests/TestParsersAndValidators.cs @@ -2,11 +2,7 @@ namespace userspace_backend_tests.ModelTests { - /// - /// Shared parser instances for model tests. These were lost during the DI - /// refactor, which left the EditableSettings* test files uncompilable (and - /// therefore excluded from the build). Restoring them revives that coverage. - /// + /// Shared parser instances for model tests. internal static class UserInputParsers { public static IUserInputParser IntParser { get; } = new IntParser(); diff --git a/userspace-backend-tests/ModelTests/WindowsSystemDevicesTests.cs b/userspace-backend-tests/ModelTests/WindowsSystemDevicesTests.cs index 019a3c1f..48804f8f 100644 --- a/userspace-backend-tests/ModelTests/WindowsSystemDevicesTests.cs +++ b/userspace-backend-tests/ModelTests/WindowsSystemDevicesTests.cs @@ -6,12 +6,9 @@ namespace userspace_backend_tests.ModelTests { - // Windows-only retriever test. Lives in a separate file from - // SystemDevicesTests so the cross-platform provider test can run on Linux. - // Excluded from non-Windows builds via the same csproj Compile Remove rule - // that hides BackEndApplyTests.cs (kept Windows-only originally). - // Asserts the Windows RawInput-based retriever returns at least one mouse; - // skip on a headless build server where no mouse is connected. + // Windows-only RawInput retriever test, split from SystemDevicesTests so the + // cross-platform test runs on Linux (excluded from non-Windows builds via csproj). + // Asserts at least one mouse is returned; skip on headless build servers. [TestClass] public class WindowsSystemDevicesTests { diff --git a/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs b/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs index 17355bf2..a6097466 100644 --- a/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs +++ b/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs @@ -82,11 +82,9 @@ public void DeserializeFormulaLinearAccel() Assert.AreEqual(0.001, actualLinearAccel.Acceleration); } - // Regression: the converter only handled Linear and Classic. - // A Default profile saved with Type=Formula/Synchronous (the default - // formula picked when a user switches DefinitionType -> Formula in the - // UI without picking a specific formula) threw "Unknown formula type - // Synchronous" at startup, crashing the app on the next launch. + // Regression: the converter only handled Linear and Classic. A Default profile + // saved as Type=Formula/Synchronous (the default formula) threw "Unknown formula + // type Synchronous" at startup, crashing on the next launch. [TestMethod] [DataRow("Synchronous", typeof(SynchronousAccel))] [DataRow("Power", typeof(PowerAccel))] From 40768c4ef50fe5e1d76836a2ed1e8b9fd75ad50b Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 29 May 2026 18:34:48 -0400 Subject: [PATCH 15/17] userspace-backend: unify formula defaults into FormulaDefaults Default values for acceleration-formula settings were defined twice: as the EditableSetting initial values in BackEndComposer DI registration, and as auto-property initializers in the Data DTOs (only NaturalAccel had these). Add a single FormulaDefaults const class referenced by both. The five DTOs that previously lacked initializers now fall back to the proper default instead of 0 when a field is absent from partial JSON; fully-written profiles are unaffected. FormulaDefaults is intentionally independent of native/Contracts. --- userspace-backend/BackEndComposer.cs | 43 ++++++++++--------- .../Profiles/Accel/Formula/ClassicAccel.cs | 8 ++-- .../Profiles/Accel/Formula/FormulaDefaults.cs | 40 +++++++++++++++++ .../Data/Profiles/Accel/Formula/JumpAccel.cs | 6 +-- .../Profiles/Accel/Formula/LinearAccel.cs | 6 +-- .../Profiles/Accel/Formula/NaturalAccel.cs | 6 +-- .../Data/Profiles/Accel/Formula/PowerAccel.cs | 8 ++-- .../Accel/Formula/SynchronousAccel.cs | 8 ++-- 8 files changed, 83 insertions(+), 42 deletions(-) create mode 100644 userspace-backend/Data/Profiles/Accel/Formula/FormulaDefaults.cs diff --git a/userspace-backend/BackEndComposer.cs b/userspace-backend/BackEndComposer.cs index dbd49ba1..67199f42 100644 --- a/userspace-backend/BackEndComposer.cs +++ b/userspace-backend/BackEndComposer.cs @@ -12,6 +12,7 @@ using userspace_backend.Model.AccelDefinitions.Formula; using userspace_backend.Model.EditableSettings; using userspace_backend.Model.ProfileComponents; +using userspace_backend.Data.Profiles.Accel.Formula; using static userspace_backend.Data.Profiles.Accel.FormulaAccel; using static userspace_backend.Data.Profiles.Accel.LookupTableAccel; using static userspace_backend.Data.Profiles.Acceleration; @@ -160,57 +161,57 @@ public static IServiceProvider Compose(IServiceCollection services) #region SynchronousAccel services.AddTransient(); - AddEditableSetting(services, SynchronousAccelerationDefinitionModel.SyncSpeedDIKey, "Sync Speed", 15); - AddEditableSetting(services, SynchronousAccelerationDefinitionModel.MotivityDIKey, "Motivity", 1.4); - AddEditableSetting(services, SynchronousAccelerationDefinitionModel.GammaDIKey, "Gamma", 1); - AddEditableSetting(services, SynchronousAccelerationDefinitionModel.SmoothnessDIKey, "Smoothness", 0.5); + AddEditableSetting(services, SynchronousAccelerationDefinitionModel.SyncSpeedDIKey, "Sync Speed", FormulaDefaults.SyncSpeed); + AddEditableSetting(services, SynchronousAccelerationDefinitionModel.MotivityDIKey, "Motivity", FormulaDefaults.Motivity); + AddEditableSetting(services, SynchronousAccelerationDefinitionModel.GammaDIKey, "Gamma", FormulaDefaults.Gamma); + AddEditableSetting(services, SynchronousAccelerationDefinitionModel.SmoothnessDIKey, "Smoothness", FormulaDefaults.Smoothness); #endregion SynchronousAccel #region LinearAccel services.AddTransient(); - AddEditableSetting(services, LinearAccelerationDefinitionModel.AccelerationDIKey, "Acceleration", 0.01); - AddEditableSetting(services, LinearAccelerationDefinitionModel.OffsetDIKey, "Offset", 0); - AddEditableSetting(services, LinearAccelerationDefinitionModel.CapDIKey, "Cap", 0); + AddEditableSetting(services, LinearAccelerationDefinitionModel.AccelerationDIKey, "Acceleration", FormulaDefaults.LinearAcceleration); + AddEditableSetting(services, LinearAccelerationDefinitionModel.OffsetDIKey, "Offset", FormulaDefaults.LinearOffset); + AddEditableSetting(services, LinearAccelerationDefinitionModel.CapDIKey, "Cap", FormulaDefaults.LinearCap); #endregion LinearAccel #region ClassicAccel services.AddTransient(); - AddEditableSetting(services, ClassicAccelerationDefinitionModel.AccelerationDIKey, "Acceleration", 0.01); - AddEditableSetting(services, ClassicAccelerationDefinitionModel.ExponentDIKey, "Exponent", 2); - AddEditableSetting(services, ClassicAccelerationDefinitionModel.OffsetDIKey, "Offset", 0); - AddEditableSetting(services, ClassicAccelerationDefinitionModel.CapDIKey, "Cap", 0); + AddEditableSetting(services, ClassicAccelerationDefinitionModel.AccelerationDIKey, "Acceleration", FormulaDefaults.ClassicAcceleration); + AddEditableSetting(services, ClassicAccelerationDefinitionModel.ExponentDIKey, "Exponent", FormulaDefaults.ClassicExponent); + AddEditableSetting(services, ClassicAccelerationDefinitionModel.OffsetDIKey, "Offset", FormulaDefaults.ClassicOffset); + AddEditableSetting(services, ClassicAccelerationDefinitionModel.CapDIKey, "Cap", FormulaDefaults.ClassicCap); #endregion ClassicAccel #region PowerAccel services.AddTransient(); - AddEditableSetting(services, PowerAccelerationDefinitionModel.ScaleDIKey, "Scale", 1); - AddEditableSetting(services, PowerAccelerationDefinitionModel.ExponentDIKey, "Exponent", 0.05); - AddEditableSetting(services, PowerAccelerationDefinitionModel.OutputOffsetDIKey, "Output Offset", 0); - AddEditableSetting(services, PowerAccelerationDefinitionModel.CapDIKey, "Cap", 0); + AddEditableSetting(services, PowerAccelerationDefinitionModel.ScaleDIKey, "Scale", FormulaDefaults.PowerScale); + AddEditableSetting(services, PowerAccelerationDefinitionModel.ExponentDIKey, "Exponent", FormulaDefaults.PowerExponent); + AddEditableSetting(services, PowerAccelerationDefinitionModel.OutputOffsetDIKey, "Output Offset", FormulaDefaults.PowerOutputOffset); + AddEditableSetting(services, PowerAccelerationDefinitionModel.CapDIKey, "Cap", FormulaDefaults.PowerCap); #endregion PowerAccel #region JumpAccel services.AddTransient(); - AddEditableSetting(services, JumpAccelerationDefinitionModel.SmoothDIKey, "Smooth", 0.5); - AddEditableSetting(services, JumpAccelerationDefinitionModel.InputDIKey, "Input", 15); - AddEditableSetting(services, JumpAccelerationDefinitionModel.OutputDIKey, "Output", 1.5); + AddEditableSetting(services, JumpAccelerationDefinitionModel.SmoothDIKey, "Smooth", FormulaDefaults.JumpSmooth); + AddEditableSetting(services, JumpAccelerationDefinitionModel.InputDIKey, "Input", FormulaDefaults.JumpInput); + AddEditableSetting(services, JumpAccelerationDefinitionModel.OutputDIKey, "Output", FormulaDefaults.JumpOutput); #endregion JumpAccel #region NaturalAccel services.AddTransient(); - AddEditableSetting(services, NaturalAccelerationDefinitionModel.DecayRateDIKey, "Decay Rate", 0.1); - AddEditableSetting(services, NaturalAccelerationDefinitionModel.InputOffsetDIKey, "Input Offset", 0); - AddEditableSetting(services, NaturalAccelerationDefinitionModel.LimitDIKey, "Limit", 1.5); + AddEditableSetting(services, NaturalAccelerationDefinitionModel.DecayRateDIKey, "Decay Rate", FormulaDefaults.NaturalDecayRate); + AddEditableSetting(services, NaturalAccelerationDefinitionModel.InputOffsetDIKey, "Input Offset", FormulaDefaults.NaturalInputOffset); + AddEditableSetting(services, NaturalAccelerationDefinitionModel.LimitDIKey, "Limit", FormulaDefaults.NaturalLimit); #endregion NaturalAccel diff --git a/userspace-backend/Data/Profiles/Accel/Formula/ClassicAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/ClassicAccel.cs index 90edef2b..7617efdf 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/ClassicAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/ClassicAccel.cs @@ -4,12 +4,12 @@ public class ClassicAccel : FormulaAccel { public override AccelerationFormulaType FormulaType => AccelerationFormulaType.Classic; - public double Acceleration { get; set; } + public double Acceleration { get; set; } = FormulaDefaults.ClassicAcceleration; - public double Exponent { get; set; } + public double Exponent { get; set; } = FormulaDefaults.ClassicExponent; - public double Offset { get; set; } + public double Offset { get; set; } = FormulaDefaults.ClassicOffset; - public double Cap { get; set; } + public double Cap { get; set; } = FormulaDefaults.ClassicCap; } } diff --git a/userspace-backend/Data/Profiles/Accel/Formula/FormulaDefaults.cs b/userspace-backend/Data/Profiles/Accel/Formula/FormulaDefaults.cs new file mode 100644 index 00000000..d432f74e --- /dev/null +++ b/userspace-backend/Data/Profiles/Accel/Formula/FormulaDefaults.cs @@ -0,0 +1,40 @@ +namespace userspace_backend.Data.Profiles.Accel.Formula +{ + // Single source of truth for formula defaults: used by BackEndComposer DI and the Data DTOs. + // UI defaults intentionally differ from native/Contracts; do not unify with Contracts. + public static class FormulaDefaults + { + // Synchronous + public const double SyncSpeed = 15; + public const double Motivity = 1.4; + public const double Gamma = 1; + public const double Smoothness = 0.5; + + // Linear + public const double LinearAcceleration = 0.01; + public const double LinearOffset = 0; + public const double LinearCap = 0; + + // Classic + public const double ClassicAcceleration = 0.01; + public const double ClassicExponent = 2; + public const double ClassicOffset = 0; + public const double ClassicCap = 0; + + // Power + public const double PowerScale = 1; + public const double PowerExponent = 0.05; + public const double PowerOutputOffset = 0; + public const double PowerCap = 0; + + // Jump + public const double JumpSmooth = 0.5; + public const double JumpInput = 15; + public const double JumpOutput = 1.5; + + // Natural + public const double NaturalDecayRate = 0.1; + public const double NaturalInputOffset = 0; + public const double NaturalLimit = 1.5; + } +} diff --git a/userspace-backend/Data/Profiles/Accel/Formula/JumpAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/JumpAccel.cs index 41a85a6b..b515e403 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/JumpAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/JumpAccel.cs @@ -4,10 +4,10 @@ public class JumpAccel : FormulaAccel { public override AccelerationFormulaType FormulaType => AccelerationFormulaType.Jump; - public double Smooth { get; set; } + public double Smooth { get; set; } = FormulaDefaults.JumpSmooth; - public double Input { get; set; } + public double Input { get; set; } = FormulaDefaults.JumpInput; - public double Output { get; set; } + public double Output { get; set; } = FormulaDefaults.JumpOutput; } } diff --git a/userspace-backend/Data/Profiles/Accel/Formula/LinearAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/LinearAccel.cs index 8f6a1ef4..c191e52b 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/LinearAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/LinearAccel.cs @@ -4,10 +4,10 @@ public class LinearAccel : FormulaAccel { public override AccelerationFormulaType FormulaType => AccelerationFormulaType.Linear; - public double Acceleration { get; set; } + public double Acceleration { get; set; } = FormulaDefaults.LinearAcceleration; - public double Offset { get; set; } + public double Offset { get; set; } = FormulaDefaults.LinearOffset; - public double Cap { get; set; } + public double Cap { get; set; } = FormulaDefaults.LinearCap; } } diff --git a/userspace-backend/Data/Profiles/Accel/Formula/NaturalAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/NaturalAccel.cs index 31693b43..790bfb1e 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/NaturalAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/NaturalAccel.cs @@ -4,10 +4,10 @@ public class NaturalAccel : FormulaAccel { public override AccelerationFormulaType FormulaType => AccelerationFormulaType.Natural; - public double DecayRate { get; set; } = 0.1; + public double DecayRate { get; set; } = FormulaDefaults.NaturalDecayRate; - public double InputOffset { get; set; } + public double InputOffset { get; set; } = FormulaDefaults.NaturalInputOffset; - public double Limit { get; set; } = 1.5; + public double Limit { get; set; } = FormulaDefaults.NaturalLimit; } } diff --git a/userspace-backend/Data/Profiles/Accel/Formula/PowerAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/PowerAccel.cs index c0be8083..cf103735 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/PowerAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/PowerAccel.cs @@ -4,12 +4,12 @@ public class PowerAccel : FormulaAccel { public override AccelerationFormulaType FormulaType => AccelerationFormulaType.Power; - public double Scale { get; set; } + public double Scale { get; set; } = FormulaDefaults.PowerScale; - public double Exponent { get; set; } + public double Exponent { get; set; } = FormulaDefaults.PowerExponent; - public double OutputOffset { get; set; } + public double OutputOffset { get; set; } = FormulaDefaults.PowerOutputOffset; - public double Cap { get; set; } + public double Cap { get; set; } = FormulaDefaults.PowerCap; } } diff --git a/userspace-backend/Data/Profiles/Accel/Formula/SynchronousAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/SynchronousAccel.cs index 0bc4d34c..a314b61b 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/SynchronousAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/SynchronousAccel.cs @@ -4,12 +4,12 @@ public class SynchronousAccel : FormulaAccel { public override AccelerationFormulaType FormulaType => AccelerationFormulaType.Synchronous; - public double SyncSpeed { get; set; } + public double SyncSpeed { get; set; } = FormulaDefaults.SyncSpeed; - public double Motivity { get; set; } + public double Motivity { get; set; } = FormulaDefaults.Motivity; - public double Gamma { get; set; } + public double Gamma { get; set; } = FormulaDefaults.Gamma; - public double Smoothness { get; set; } + public double Smoothness { get; set; } = FormulaDefaults.Smoothness; } } From 4e85aae340ac29cc781b4f74b467ac5924cb2b70 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 30 May 2026 18:30:00 -0400 Subject: [PATCH 16/17] userspace-backend-tests: extend formula subtype test to cover all six types Adds Classic and Linear DataRows to DeserializeFormulaAccel_AllSubtypes so the parameterized test covers every formula type, and trims the three-line regression comment to one line. --- .../SerializationTests/AccelerationSerializationTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs b/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs index a6097466..9f11b282 100644 --- a/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs +++ b/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs @@ -82,10 +82,10 @@ public void DeserializeFormulaLinearAccel() Assert.AreEqual(0.001, actualLinearAccel.Acceleration); } - // Regression: the converter only handled Linear and Classic. A Default profile - // saved as Type=Formula/Synchronous (the default formula) threw "Unknown formula - // type Synchronous" at startup, crashing on the next launch. + // Ensures all formula types deserialize to their expected runtime type. [TestMethod] + [DataRow("Classic", typeof(ClassicAccel))] + [DataRow("Linear", typeof(LinearAccel))] [DataRow("Synchronous", typeof(SynchronousAccel))] [DataRow("Power", typeof(PowerAccel))] [DataRow("Natural", typeof(NaturalAccel))] From 9a4b186370d5fa4d8c4f36638b4b58fb49528fb4 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 30 May 2026 19:17:48 -0400 Subject: [PATCH 17/17] Revert trimmed comments; extend gitignore for linux build artifacts Restore original comment wording in ProfileChartViewModel, Program, App.axaml, BackEndApplyTests, EditableSetting, EditableSettingsCollection, EditableSettingsSelector, IEditableSetting, ProfileModel, and SystemDevices. Also extend .gitignore to cover linux/build-*/, linux/target/, and Testing/ so CMake/Rust build artifacts are never staged again. --- .gitignore | 5 ++- userinterface/App.axaml.cs | 6 ++-- userinterface/Program.cs | 10 +++--- .../Profile/ProfileChartViewModel.cs | 32 ++++++++++++++----- .../ModelTests/BackEndApplyTests.cs | 9 +++--- .../Model/EditableSettings/EditableSetting.cs | 5 +-- .../EditableSettingsCollection.cs | 8 ++--- .../EditableSettingsSelector.cs | 5 +-- .../EditableSettings/IEditableSetting.cs | 8 +++-- userspace-backend/Model/ProfileModel.cs | 8 +++-- userspace-backend/Model/SystemDevices.cs | 2 +- 11 files changed, 64 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index 712abfc3..25c6b40c 100644 --- a/.gitignore +++ b/.gitignore @@ -353,5 +353,8 @@ ASALocalRun/ # BeatPulse healthcheck temp database healthchecksdb -# Linux build directory +# Linux build directories and Rust/CMake artifacts linux/build/ +linux/build-*/ +linux/target/ +Testing/ diff --git a/userinterface/App.axaml.cs b/userinterface/App.axaml.cs index 9a3ec38c..87799ec9 100644 --- a/userinterface/App.axaml.cs +++ b/userinterface/App.axaml.cs @@ -60,8 +60,10 @@ private static extern IntPtr CreateFile( private static void AttachConsoleStreams() { - // After AllocConsole, open CONOUT$ / CONIN$ directly: the CLR cached Stream.Null - // for Console.Out at WinExe startup. Writing over CONOUT$ is the standard fix. + // After AllocConsole, open CONOUT$ / CONIN$ directly. Bypasses the CLR's cached + // Stream.Null for Console.Out that was set when we started as a WinExe with no + // attached console. Console.SetOut with a writer over CONOUT$ is the canonical + // workaround for GUI-process logging. var stdoutPtr = CreateFile("CONOUT$", GENERIC_WRITE, FILE_SHARE_WRITE, IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero); if (stdoutPtr != IntPtr.Zero && stdoutPtr.ToInt64() != -1) diff --git a/userinterface/Program.cs b/userinterface/Program.cs index 14188053..c282fe0c 100644 --- a/userinterface/Program.cs +++ b/userinterface/Program.cs @@ -7,13 +7,15 @@ namespace userinterface; internal sealed class Program { - // Don't use Avalonia, third-party APIs, or SynchronizationContext-reliant - // code before AppMain is called: nothing is initialized yet. + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. [STAThread] public static void Main(string[] args) { - // Install global exception sinks before Avalonia starts so startup or - // worker-thread crashes still reach logs/crash.log. + // This is for crash logging. It Installs global exception sinks BEFORE + // Avalonia starts so a crash during startup or on a worker thread should still get + // written to logs/crash.log before the process exits. AppDomain.CurrentDomain.UnhandledException += (_, e) => WriteCrashLog("AppDomain.UnhandledException", e.ExceptionObject as Exception); diff --git a/userinterface/ViewModels/Profile/ProfileChartViewModel.cs b/userinterface/ViewModels/Profile/ProfileChartViewModel.cs index c266ea0f..aae3ac25 100644 --- a/userinterface/ViewModels/Profile/ProfileChartViewModel.cs +++ b/userinterface/ViewModels/Profile/ProfileChartViewModel.cs @@ -183,7 +183,9 @@ public void Initialize(BE.IProfileModel profileModel) } - // --- Initialization & setup --- + // ================================================================================================ + // INITIALIZATION & SETUP + // ================================================================================================ public Task InitializeAsync() { @@ -405,7 +407,9 @@ public Task SwitchToProfileAsync(BE.IProfileModel profileModel) public ICommand ToggleSpeedLinesCommand { get; } - // --- Public methods --- + // ================================================================================================ + // PUBLIC METHODS + // ================================================================================================ public void FitToData() { @@ -442,7 +446,9 @@ public void RecreateAxes(double? xMinLimit = null, double? xMaxLimit = null, dou OnPropertyChanged(nameof(YAxes)); } - // --- Cleanup & disposal --- + // ================================================================================================ + // CLEANUP & DISPOSAL + // ================================================================================================ public void Dispose() { @@ -478,7 +484,9 @@ public void Dispose() previewRenderer.ClearCache(); } - // --- Chart data management --- + // ================================================================================================ + // CHART DATA MANAGEMENT + // ================================================================================================ private ISeries[] CreateSeriesData() { @@ -560,7 +568,9 @@ private void CreateSeries() } } - // --- Live current-speed indicator lines --- + // ================================================================================================ + // LIVE CURRENT-SPEED INDICATOR LINES + // ================================================================================================ // Vertical zero-width line (Xi == Xj) at the given speed; non-positive // speed -> NaN bounds, which render nothing. Fresh paint per call: a shared @@ -687,7 +697,9 @@ private void StartSpeedPollingIfPossible() } } - // --- Event handlers --- + // ================================================================================================ + // EVENT HANDLERS + // ================================================================================================ private void OnYXRatioChanged(object? sender, PropertyChangedEventArgs e) { @@ -708,7 +720,9 @@ private void OnCombineXYChanged(object? sender, PropertyChangedEventArgs e) Avalonia.Threading.Dispatcher.UIThread.Post(RebuildSpeedSections); } - // --- Chart axes creation --- + // ================================================================================================ + // CHART AXES CREATION + // ================================================================================================ private Axis[] CreateXAxes(double? minLimit = null, double? maxLimit = null) { @@ -763,7 +777,9 @@ private Axis[] CreateYAxes(double? minLimit = null, double? maxLimit = null) } - // --- Axis limits management --- + // ================================================================================================ + // AXIS LIMITS MANAGEMENT + // ================================================================================================ private void SetDefaultLimits() { diff --git a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs index b1de479a..8d7498bb 100644 --- a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs +++ b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs @@ -633,7 +633,7 @@ public void Load_ProfileWithZeroAnisotropy_SanitizesToIdentityWeights() Assert.AreEqual(1.0, aniso.RangeY.ModelValue); } - // User-reported flow: boot with a Default profile of Type=None, switch + // Boot with a Default profile of Type=None, switch // DefinitionType to Formula, pick Classic, edit a coefficient. The chained // Apply must see the Classic args in the driver's RawAccelConfig. [TestMethod] @@ -750,9 +750,10 @@ public void Load_CustomDeviceGroupInDevicesAndMappings_RestoresGroupList() [TestMethod] public void ImportSystemDevices_SyncsInterfaceValueSoUiReflectsRealValues() { - // Regression: TryUpdateModelDirectly updated ModelValue but not InterfaceValue, - // which the UI binds to, so imported devices showed the DI placeholder. Assert - // both properties update. + // Regression: EditableSettingV2.TryUpdateModelDirectly used to update ModelValue + // but not InterfaceValue. The UI binds to InterfaceValue via EditableFieldViewModel, + // so imported devices showed the DI placeholder ("name", "hwid") even though + // ModelValue was correct. Guard against that by asserting both properties update. var systemDevices = new List { new StubSystemDevice { Name = "RealMouseName", HWID = @"HID\VID_1234&PID_5678" }, diff --git a/userspace-backend/Model/EditableSettings/EditableSetting.cs b/userspace-backend/Model/EditableSettings/EditableSetting.cs index 44efc7f3..fcc60a5d 100644 --- a/userspace-backend/Model/EditableSettings/EditableSetting.cs +++ b/userspace-backend/Model/EditableSettings/EditableSetting.cs @@ -62,13 +62,14 @@ public EditableSettingV2( public T LastWrittenValue { get; protected set; } /// - /// Set when the value arrives whole (e.g. menu selection) rather than piecewise (typing). + /// Interface can set this for cases when new value arrives all at once (such as menu selection) + /// instead of cases where new value arrives in parts (typing) /// public bool AutoUpdateFromInterface { get; set; } private IUserInputParser Parser { get; } - //TODO: rework settings-collection init to make this private for non-static validators. + //TODO: change settings collections init so that this can be made private for non-static validators public IModelValueValidator Validator { get; set; } private bool AllowAutoUpdateFromInterface { get; set; } = true; diff --git a/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs b/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs index 0235528e..6be5bd7b 100644 --- a/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs +++ b/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs @@ -103,8 +103,8 @@ public void GatherEditableSettingsCollections() { AllContainedEditableSettingsCollections = EnumerateEditableSettingsCollections(); - // TODO: split "All" vs "currently selected" so collections that mutate - // this via use don't get wired up incorrectly here. + // TODO: separate "All" and "currently selected" settings collections + // so that incorrect assignment is not done here for collections that alter this through use foreach (var settingsCollection in AllContainedEditableSettingsCollections) { settingsCollection.AnySettingChanged += EditableSettingsCollectionChangedEventHandler; @@ -145,8 +145,8 @@ public EditableSettingsCollectionV2( } } - // TODO: split "All" vs "currently selected" so collections that mutate - // this via use don't get wired up incorrectly here. + // TODO: separate "All" and "currently selected" settings collections + // so that incorrect assignment is not done here for collections that alter this through use foreach (var settingsCollection in AllContainedEditableSettingsCollections) { settingsCollection.AnySettingChanged += EditableSettingsCollectionChangedEventHandler; diff --git a/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs b/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs index 01f9b1d2..e7dba657 100644 --- a/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs +++ b/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs @@ -88,8 +88,9 @@ protected void InitSelectionLookup(IServiceProvider serviceProvider) var subModel = serviceProvider.GetRequiredKeyedService>(key); SelectionLookup.Add(value, subModel); - // Bubble AnySettingChanged so enclosing models (e.g. ProfileModel) - // recompute derived state when nested params change. + // Bubble AnySettingChanged from every sub model up through this selector so + // enclosing models (e.g. ProfileModel) recompute derived state when nested + // parameters change. subModel.AnySettingChanged += EditableSettingsCollectionChangedEventHandler; } } diff --git a/userspace-backend/Model/EditableSettings/IEditableSetting.cs b/userspace-backend/Model/EditableSettings/IEditableSetting.cs index 9458baca..73771680 100644 --- a/userspace-backend/Model/EditableSettings/IEditableSetting.cs +++ b/userspace-backend/Model/EditableSettings/IEditableSetting.cs @@ -29,10 +29,12 @@ public interface IEditableSettingSpecific : IEditableSetting where T : ICompa public T CurrentValidatedValue { get; } /// - /// Updates the model directly, validating as if parsed from the interface. - /// Prefer setting InterfaceValue + TryUpdateFromInterface() from UI code. + /// Attempts to update the model directly. Validates the input as if it had been parsed from interface. + /// This method should probably not be called from the interface. Instead, set InterfaceValue and + /// call TryUpdateFromInterface(). /// - /// true on success. + /// Value to which model should be tried to be set. + /// bool indicating success public bool TryUpdateModelDirectly(T data); } } diff --git a/userspace-backend/Model/ProfileModel.cs b/userspace-backend/Model/ProfileModel.cs index 4c2865e3..c80b3c3f 100644 --- a/userspace-backend/Model/ProfileModel.cs +++ b/userspace-backend/Model/ProfileModel.cs @@ -67,11 +67,11 @@ public ProfileModel( XCurvePreview = xCurvePreview; YCurvePreview = yCurvePreview; - // Name + OutputDPI don't affect the curve preview. + // Name and Output DPI do not need to generate a new curve preview Name!.PropertyChanged += AnyNonPreviewPropertyChangedEventHandler; OutputDPI.PropertyChanged += AnyNonPreviewPropertyChangedEventHandler; - // Everything else does. + // The rest of settings should generate a new curve preview YXRatio.PropertyChanged += AnyCurvePreviewPropertyChangedEventHandler; Acceleration.AnySettingChanged += AnyCurveSettingCollectionChangedEventHandler; Hidden.AnySettingChanged += AnyCurveSettingCollectionChangedEventHandler; @@ -187,8 +187,10 @@ protected void RecalculateDriverDataAndCurvePreview() { RecalculateDriverData(); + // Generate X curve points (original behavior) XCurvePreview.GeneratePoints(CurrentValidatedDriverProfile); - // Y points = X outputs * YX ratio. + + // Generate Y curve points by multiplying X curve outputs by YX ratio GenerateYCurvePoints(); } diff --git a/userspace-backend/Model/SystemDevices.cs b/userspace-backend/Model/SystemDevices.cs index 64b5f66c..7f36ee82 100644 --- a/userspace-backend/Model/SystemDevices.cs +++ b/userspace-backend/Model/SystemDevices.cs @@ -8,7 +8,7 @@ namespace userspace_backend.Model { /// - /// Observable system-device collection with on-demand refresh. + /// Holds system devices in observable collection and refreshes list when desired. /// public interface ISystemDevicesProvider {