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; }
+
+}