diff --git a/EliteAPI.Tests/BindingTests.cs b/EliteAPI.Tests/BindingTests.cs new file mode 100644 index 00000000..bc6456bb --- /dev/null +++ b/EliteAPI.Tests/BindingTests.cs @@ -0,0 +1,338 @@ +using EliteAPI.Bindings; +using FluentAssertions; + +namespace EliteAPI.Tests.Bindings; + +public class BindingParserTests +{ + + [Test] + public void Parse_Sample_File() + { + string xml = File.ReadAllText(@"EliteAPI.Tests\test.binds"); + + var content = BindingParser.Parse(xml); + + content.Should().NotBeNull(); + } + + [Test] + public void Parse_Should_Read_Simple_Primary_Keyboard_Bindings() + { + const string xml = @" + + + + + + + + + + "; + + var controls = BindingParser + .Parse(xml) + .ToDictionary(c => c.Name); + + controls.Should().ContainKeys("YawLeftButton", "YawRightButton"); + + var yawLeft = controls["YawLeftButton"]; + yawLeft.Primary.HasValue.Should().BeTrue(); + yawLeft.Primary!.Value.Device.Should().Be("Keyboard"); + yawLeft.Primary!.Value.Key.Should().Be("Key_A"); + yawLeft.Secondary.HasValue.Should().BeFalse(); + yawLeft.IsToggle.Should().BeNull(); + yawLeft.IsInverted.Should().BeNull(); + yawLeft.Deadzone.Should().BeNull(); + + var yawRight = controls["YawRightButton"]; + yawRight.Primary.HasValue.Should().BeTrue(); + yawRight.Primary!.Value.Device.Should().Be("Keyboard"); + yawRight.Primary!.Value.Key.Should().Be("Key_D"); + yawRight.Secondary.HasValue.Should().BeFalse(); + } + + [Test] + public void Parse_Should_Treat_NoDevice_EmptyKey_As_Unbound() + { + const string xml = @" + + + + + + + + + + + "; + + var controls = BindingParser + .Parse(xml) + .ToDictionary(c => c.Name); + + controls.Should().ContainKeys("MouseReset", "RollLeftButton"); + + foreach (var control in controls.Values) + { + control.Primary.HasValue.Should().BeFalse(); + control.Secondary.HasValue.Should().BeFalse(); + control.IsToggle.Should().BeNull(); + control.IsInverted.Should().BeNull(); + control.Deadzone.Should().BeNull(); + } + } + + [Test] + public void Parse_Should_Map_Binding_Element_To_Primary_And_Read_Inverted_And_Deadzone() + { + const string xml = @" + + + + + + + "; + + var control = BindingParser + .Parse(xml) + .Single(c => c.Name == "YawAxisRaw"); + + control.Primary.HasValue.Should().BeTrue(); + var binding = control.Primary!.Value; + + binding.Device.Should().Be("Joystick"); + binding.Key.Should().Be("Joy_X"); + binding.Modifiers.Should().BeNull(); + + control.Secondary.HasValue.Should().BeFalse(); + control.IsInverted.Should().BeTrue(); + control.IsToggle.Should().BeNull(); + control.Deadzone.Should().Be(0.25f); + } + + [Test] + public void Parse_Should_Handle_All_ToggleOn_Variants() + { + const string xml = @" + + + + + + + + + + + + + + + + + + + + + + + + + + + + "; + + var controls = BindingParser + .Parse(xml) + .ToDictionary(c => c.Name); + + controls["BlockMouseDecay"].IsToggle.Should().BeFalse(); + controls["ToggleFlightAssist"].IsToggle.Should().BeTrue(); + + // Presence of with no Value attribute – treat as true + controls["YawToRollButton"].IsToggle.Should().BeTrue(); + + // No element at all + controls["MouseReset"].IsToggle.Should().BeNull(); + } + + [Test] + public void Parse_Should_Read_Modifiers_On_Primary_And_Secondary() + { + const string xml = @" + + + + + + + + + + + + + + + + + + + + "; + + var controls = BindingParser + .Parse(xml) + .ToDictionary(c => c.Name); + + // Primary modifiers + var photo = controls["PhotoCameraToggle"]; + photo.Primary.HasValue.Should().BeTrue(); + var photoPrimary = photo.Primary!.Value; + + photoPrimary.Device.Should().Be("Keyboard"); + photoPrimary.Key.Should().Be("Key_Space"); + photoPrimary.Modifiers.Should().NotBeNull(); + photoPrimary.Modifiers!.Should().HaveCount(2); + + var firstPhotoMod = (Binding)photoPrimary.Modifiers![0]; + var secondPhotoMod = (Binding)photoPrimary.Modifiers![1]; + + firstPhotoMod.Device.Should().Be("Keyboard"); + firstPhotoMod.Key.Should().Be("Key_LeftControl"); + secondPhotoMod.Device.Should().Be("Keyboard"); + secondPhotoMod.Key.Should().Be("Key_LeftAlt"); + + // Secondary modifiers + var reverse = controls["ToggleReverseThrottleInput"]; + reverse.Secondary.HasValue.Should().BeTrue(); + var reverseSecondary = reverse.Secondary!.Value; + + reverseSecondary.Device.Should().Be("Keyboard"); + reverseSecondary.Key.Should().Be("Key_A"); + reverseSecondary.Modifiers.Should().NotBeNull(); + reverseSecondary.Modifiers!.Should().HaveCount(2); + + var firstReverseMod = (Binding)reverseSecondary.Modifiers![0]; + var secondReverseMod = (Binding)reverseSecondary.Modifiers![1]; + + firstReverseMod.Device.Should().Be("Keyboard"); + firstReverseMod.Key.Should().Be("Key_LeftAlt"); + secondReverseMod.Device.Should().Be("Keyboard"); + secondReverseMod.Key.Should().Be("Key_RightShift"); + } + + [Test] + public void Parse_Should_Ignore_Hold_Child_And_Still_Parse_Binding_And_Toggle() + { + const string xml = @" + + + + + + + + + "; + + var control = BindingParser + .Parse(xml) + .Single(c => c.Name == "HumanoidItemWheelButton"); + + control.Primary.HasValue.Should().BeTrue(); + var primary = control.Primary!.Value; + + primary.Device.Should().Be("Keyboard"); + primary.Key.Should().Be("Key_LeftAlt"); + + // Hold isn't currently modelled on Binding – at minimum it must not break parsing + primary.Modifiers.Should().BeNull(); + + control.IsToggle.Should().BeFalse(); // Value=""0"" + } + + [Test] + public void Parse_Should_Treat_Key_Attribute_Case_Insensitive() + { + // Based on ToggleVanityCamera in HCS, but with a meaningful secondary key + const string xml = @" + + + + + + + "; + + var control = BindingParser + .Parse(xml) + .Single(c => c.Name == "ToggleVanityCamera"); + + control.Primary.HasValue.Should().BeTrue(); + control.Primary!.Value.Key.Should().Be("Key_RightShift"); + + control.Secondary.HasValue.Should().BeTrue(); + control.Secondary!.Value.Device.Should().Be("Keyboard"); + control.Secondary!.Value.Key.Should().Be("Key_F12"); + } + + [Test] + public void Parse_Should_Handle_Float_Deadzone_Formats() + { + const string xml = @" + + + + + + + + + + + + + + + + "; + + var controls = BindingParser + .Parse(xml) + .ToDictionary(c => c.Name); + + controls["RollAxisRaw"].Deadzone.Should().Be(0.0f); + controls["LateralThrustRaw"].Deadzone.Should().Be(0.05f); + controls["VerticalThrustRaw"].Deadzone.Should().Be(7.0f); + } + + [Test] + public void Parse_Should_Ignore_Value_Only_Elements_And_Only_Return_Controls_With_Bindings() + { + const string xml = @" + + nl-NL + + + + + + + + + "; + + var controls = BindingParser.Parse(xml).ToArray(); + + controls.Should().ContainSingle(c => c.Name == "YawLeftButton"); + controls.Select(c => c.Name).Should().NotContain("MouseXMode"); + controls.Select(c => c.Name).Should().NotContain("MouseSensitivity"); + controls.Select(c => c.Name).Should().NotContain("MouseGUI"); + } +} diff --git a/EliteAPI.Tests/test.binds b/EliteAPI.Tests/test.binds new file mode 100644 index 00000000..97f307b9 --- /dev/null +++ b/EliteAPI.Tests/test.binds @@ -0,0 +1,1895 @@ + + + en-GB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EliteAPI/bindings/Binding.cs b/EliteAPI/bindings/Binding.cs new file mode 100644 index 00000000..34b4ca11 --- /dev/null +++ b/EliteAPI/bindings/Binding.cs @@ -0,0 +1,18 @@ +namespace EliteAPI.Bindings; + +public readonly struct Binding : IBinding +{ + public string Device { get; init; } + + public string Key { get; init; } + + public IBinding[]? Modifiers { get; init; } + + public Binding(string device, string key, IBinding[]? modifiers = null) + { + Device = device; + Key = key; + Modifiers = modifiers; + + } +} diff --git a/EliteAPI/bindings/BindingParser.cs b/EliteAPI/bindings/BindingParser.cs new file mode 100644 index 00000000..8b4459a7 --- /dev/null +++ b/EliteAPI/bindings/BindingParser.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; + +namespace EliteAPI.Bindings; + +public static class BindingParser +{ + public static IReadOnlyCollection Parse(string xml) + { + try + { + var doc = XDocument.Parse(xml); + var root = doc.Root; + + var controls = root.Elements() + .Select(ParseElement) + .ToArray(); + + return controls; + } + catch (Exception e) + { + return []; + } + } + + private static Control ParseElement(XElement element) + { + var primaryElement = GetChildElement(element, "Primary") ?? GetChildElement(element, "Binding"); + var secondaryElement = GetChildElement(element, "Secondary"); + + var deadzoneRaw = GetAttribute(GetChildElement(element, "Deadzone"), "Value"); + var invertedRaw = GetAttribute(GetChildElement(element, "Inverted"), "Value"); + var toggleRaw = GetAttribute(GetChildElement(element, "ToggleOn"), "Value"); + + return new Control + { + Name = element.Name.LocalName, + Primary = ParseBinding(primaryElement), + Secondary = ParseBinding(secondaryElement), + Deadzone = ParseFloat(deadzoneRaw), + IsToggle = ParseBool(toggleRaw), + IsInverted = ParseBool(invertedRaw) + }; + } + + /// + /// Parse a single binding element (Binding/Primary/Secondary), including Modifiers, + /// handling casing and "unbound" semantics. + /// + private static Binding? ParseBinding(XElement? element) + { + if (element is null) + return null; + + var device = GetAttribute(element, "Device") ?? string.Empty; + var key = GetAttribute(element, "Key") ?? string.Empty; + + // Treat unbound bindings (NoDevice / empty key) as null + if (IsUnbound(device, key)) + return null; + + // Parse child elements (case-insensitive) + var modifiers = element + .Elements() + .Where(e => string.Equals(e.Name.LocalName, "Modifier", StringComparison.OrdinalIgnoreCase)) + .Select(m => + { + var mDevice = GetAttribute(m, "Device") ?? string.Empty; + var mKey = GetAttribute(m, "Key") ?? string.Empty; + return (IBinding)new Binding(mDevice, mKey); + }) + .ToArray(); + + return new Binding(device, key, modifiers.Length == 0 ? null : modifiers); + } + + /// + /// Decide when a binding is "unbound" and should be treated as null. + /// + private static bool IsUnbound(string device, string key) + { + // Classic ED unbound pattern: {NoDevice} + empty key + if (string.Equals(device, "{NoDevice}", StringComparison.OrdinalIgnoreCase)) + return true; + + // No key at all = unbound (even if device is set) + if (string.IsNullOrWhiteSpace(key)) + return true; + + // Super defensive: both empty + if (string.IsNullOrWhiteSpace(device) && string.IsNullOrWhiteSpace(key)) + return true; + + return false; + } + + private static float? ParseFloat(string? raw) => + float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) + ? value + : null; + + private static bool? ParseBool(string? raw) => raw switch + { + "1" => true, + "0" => false, + null => null, + _ => null + }; + + private static XElement? GetChildElement(XElement parent, string name) => + parent.Elements() + .FirstOrDefault(e => string.Equals(e.Name.LocalName, name, StringComparison.OrdinalIgnoreCase)); + + private static string? GetAttribute(XElement? element, string name) + { + if (element is null) + return null; + + return element.Attributes() + .FirstOrDefault(a => string.Equals(a.Name.LocalName, name, StringComparison.OrdinalIgnoreCase)) + ?.Value; + } +} diff --git a/EliteAPI/bindings/Control.cs b/EliteAPI/bindings/Control.cs new file mode 100644 index 00000000..c63f5841 --- /dev/null +++ b/EliteAPI/bindings/Control.cs @@ -0,0 +1,16 @@ +namespace EliteAPI.Bindings; + +public readonly struct Control +{ + public string Name { get; init; } + + public Binding? Primary { get; init; } + + public Binding? Secondary { get; init; } + + public bool? IsToggle { get; init; } + + public bool? IsInverted { get; init; } + + public float? Deadzone { get; init; } +} diff --git a/EliteAPI/bindings/IBinding.cs b/EliteAPI/bindings/IBinding.cs new file mode 100644 index 00000000..d3fdadf1 --- /dev/null +++ b/EliteAPI/bindings/IBinding.cs @@ -0,0 +1,9 @@ +namespace EliteAPI.Bindings; + +public interface IBinding +{ + public string Device { get; init; } + + public string Key { get; init; } + +}