From a52a99ea07ebf1e8513a55861f0c30de5cb70ca6 Mon Sep 17 00:00:00 2001 From: leonardo Date: Mon, 1 Jun 2026 17:42:02 +0200 Subject: [PATCH 1/4] TEST: Add failing repro for UUM-143659 Adds Devices_CanCreateGenericHID_FromGamepadWithEightBitHatSwitch to HIDTests, modelled on the ESP32-BLE-Gamepad descriptor from the ticket: an HID gamepad whose report descriptor declares a hat switch with Report Size 8 (instead of the standard 4 bits). Currently fails on develop: InputDeviceBuilder.InsertControlBitRangeNode goes into infinite recursion while constructing the control tree; InputManager catches the resulting IndexOutOfRangeException and logs "Could not create a device for ...", so the device never appears. Co-Authored-By: Claude Opus 4.7 (1M context) --- Assets/Tests/InputSystem/Plugins/HIDTests.cs | 72 ++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/Assets/Tests/InputSystem/Plugins/HIDTests.cs b/Assets/Tests/InputSystem/Plugins/HIDTests.cs index 10485e33d6..ef87ba0b0c 100644 --- a/Assets/Tests/InputSystem/Plugins/HIDTests.cs +++ b/Assets/Tests/InputSystem/Plugins/HIDTests.cs @@ -415,6 +415,78 @@ public void Devices_CanParseHIDDescriptor_WithSignedLogicalMinAndMaxValues(byte } } + // Regression test for https://jira.unity3d.com/browse/UUM-143659. + // + // HID devices whose report descriptor declares a hat switch with Report Size 8 + // (rather than the standard 4 bits, e.g. ESP32-BLE-Gamepad) caused an + // IndexOutOfRangeException inside InputDeviceBuilder.InsertControlBitRangeNode + // while constructing the control tree. InputManager caught the exception and + // logged "Could not create a device for ...", so the device never showed up. + [Test] + [Category("Devices")] + public void Devices_CanCreateGenericHID_FromGamepadWithEightBitHatSwitch() + { + // Minimal Gamepad collection that contains a hat switch whose Report Size + // is declared as 8 bits (instead of the usual 4). Modelled on the + // descriptor reported in UUM-143659 (ESP32-BLE-Gamepad library). + var reportDescriptor = new byte[] + { + 0x05, 0x01, // Usage Page (Generic Desktop Ctrls) + 0x09, 0x05, // Usage (Game Pad) + 0xA1, 0x01, // Collection (Application) + + // Two 8-bit axes so the control tree has more than one leaf. + 0x09, 0x30, // Usage (X) + 0x09, 0x31, // Usage (Y) + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x00, // Logical Maximum (255) + 0x75, 0x08, // Report Size (8) + 0x95, 0x02, // Report Count (2) + 0x81, 0x02, // Input (Data,Var,Abs) + + // Hat switch with the problematic 8-bit Report Size. + 0xA1, 0x00, // Collection (Physical) + 0x05, 0x01, // Usage Page (Generic Desktop Ctrls) + 0x09, 0x39, // Usage (Hat switch) + 0x15, 0x01, // Logical Minimum (1) + 0x25, 0x08, // Logical Maximum (8) + 0x35, 0x00, // Physical Minimum (0) + 0x46, 0x3B, 0x01, // Physical Maximum (315) + 0x65, 0x12, // Unit (SI Rotation, Length: Centimeter) + 0x75, 0x08, // Report Size (8) <-- bug: 8 instead of 4 + 0x95, 0x01, // Report Count (1) + 0x81, 0x42, // Input (Data,Var,Abs,Null State) + 0xC0, // End Collection + + 0xC0, // End Collection + }; + + var deviceId = runtime.AllocateDeviceId(); + SetDeviceCommandCallbackToReturnReportDescriptor(deviceId, reportDescriptor); + + runtime.ReportNewInputDevice( + new InputDeviceDescription + { + interfaceName = HID.kHIDInterface, + manufacturer = "TestVendor", + product = "TestEightBitHatGamepad", + capabilities = new HID.HIDDeviceDescriptor + { + vendorId = 0xE502, + productId = 0xBBAB + }.ToJson() + }.ToJson(), deviceId); + + // Device construction runs through InputDeviceBuilder. Before the fix + // this raises IndexOutOfRangeException in InsertControlBitRangeNode, + // which InputManager catches and turns into a LogType.Error + // ("Could not create a device for ...") — that error fails the test. + InputSystem.Update(); + + Assert.That(InputSystem.GetDeviceById(deviceId), Is.Not.Null, + "Device should be created without throwing IndexOutOfRangeException."); + } + [Test] [Category("Devices")] public void Devices_CanCreateGenericHID_FromDeviceWithParsedReportDescriptor() From 59b1317c98561caa64ab6bf38b172e626a0640cb Mon Sep 17 00:00:00 2001 From: leonardo Date: Mon, 1 Jun 2026 17:42:26 +0200 Subject: [PATCH 2/4] FIX: Anchor HID hat-switch sub-controls and skip synthetics in bit-range tree (UUM-143659) For an HID hat switch, the HID layer adds four directional sub-controls (hat/up, hat/right, hat/down, hat/left) that are meant to overlap the hat itself and only differ in their DiscreteButton value ranges. Those sub-controls were added with WithBitOffset() but no WithByteOffset(), so the layout system's auto-allocator placed each one in a fresh byte. For a 4-bit hat that's harmless: sizeInBits=4 is bit-addressing and all four pack into the parent's byte via the bitfield path. For an 8-bit hat (sizeInBits=8 is byte-addressing) each sub-control consumed its own byte, pushing them past the device's HID-derived report size. The dangling controls then sent InputDeviceBuilder.InsertControlBitRangeNode into infinite recursion because no partition could ever contain them. Fixes: 1. HID.cs: add WithByteOffset(0) to each of the four hat sub-controls so they're anchored to the hat parent's byte and overlap it after FinalizeControlHierarchy bakes in the parent's byteOffset. 2. InputDeviceBuilder.cs: skip synthetic controls when building the control bit-range tree. Synthetic controls (e.g. DpadControl's x/y, StickControl's up/down/left/right) get their state block aliased to a sibling/parent in FinishSetup -- but FinishSetup runs *after* FinalizeControlHierarchy, so the tree was being built with the pre-FinishSetup offsets, which could also fall outside the device's bit range. Their bits are already covered by the non-synthetic control they alias, so they don't need their own tree entry. Either fix alone is not sufficient: the HID fix resolves the non-synthetic hat sub-controls; the synthetic skip resolves DpadAxis hat/x and hat/y, which suffer from the same overrun via a different mechanism. Adds a CHANGELOG entry. Test: Devices_CanCreateGenericHID_FromGamepadWithEightBitHatSwitch. Co-Authored-By: Claude Opus 4.7 (1M context) --- Packages/com.unity.inputsystem/CHANGELOG.md | 1 + .../Runtime/Devices/InputDeviceBuilder.cs | 10 ++++++++-- .../InputSystem/Runtime/Plugins/HID/HID.cs | 12 ++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Packages/com.unity.inputsystem/CHANGELOG.md b/Packages/com.unity.inputsystem/CHANGELOG.md index f64be23383..737356015b 100644 --- a/Packages/com.unity.inputsystem/CHANGELOG.md +++ b/Packages/com.unity.inputsystem/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Fixed InputSystem.onAnyButtonPress fails to trigger when the device receives a touch [UUM-137930](https://issuetracker.unity3d.com/product/unity/issues/guid/UUM-137930). - Fixed an incorrect ArraysHelper.HaveDuplicateReferences implementation that didn't use its arguments right [ISXB-1792] (https://github.com/Unity-Technologies/InputSystem/pull/2376) - Fixed `InputAction.IsPressed`, `WasPressedThisFrame`, and `WasReleasedThisFrame` using a `ButtonControl`'s `pressPoint` when a binding also had an explicit `PressInteraction` with its own `pressPoint`, which could make those APIs disagree with the interaction's press and release behavior. Action-level press APIs now follow the interaction threshold when both are set explicitly. +- Fixed `IndexOutOfRangeException` in `InputDeviceBuilder` when connecting an HID gamepad whose report descriptor declares a hat switch with Report Size 8 (e.g. ESP32-BLE-Gamepad). The HID layer now anchors the hat's directional sub-controls to the hat's own byte instead of letting the layout system auto-allocate a fresh byte for each, and the bit-range tree builder skips synthetic controls whose state block is aliased in `FinishSetup` [UUM-143659](https://jira.unity3d.com/browse/UUM-143659). ### Changed - Action-level `IsPressed`, `WasPressedThisFrame`, and `WasReleasedThisFrame` for bindings to `Vector2Control` / `StickControl` no longer consult a per-control `pressPoint` on the vector (that field was removed). Use a `Press` interaction to set a custom threshold, or rely on `defaultButtonPressPoint`. diff --git a/Packages/com.unity.inputsystem/InputSystem/Runtime/Devices/InputDeviceBuilder.cs b/Packages/com.unity.inputsystem/InputSystem/Runtime/Devices/InputDeviceBuilder.cs index 806b9f35dd..874146690e 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Runtime/Devices/InputDeviceBuilder.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Runtime/Devices/InputDeviceBuilder.cs @@ -925,8 +925,14 @@ private void FinalizeControlHierarchyRecursive(InputControl control, int control throw new NotSupportedException($"Control '{control}' exceeds maximum supported state bit size of {(1U << InputDevice.kStateSizeBits) - 1} (bit offset {control.stateBlock.sizeInBits})"); } - // Construct control bit range tree - if (control != m_Device) + // Construct control bit range tree. Skip synthetic controls (e.g. DpadControl's + // x/y axes, StickControl's up/down/left/right) — they don't have their own state + // bits (FinishSetup will alias their state block to a non-synthetic sibling/parent), + // so any state-change detection on their bits is covered by that sibling/parent + // already being in the tree. Including them was UUM-143659: synthetic controls + // can carry pre-FinishSetup offsets that fall outside the device's bit range, + // sending InsertControlBitRangeNode into infinite recursion. + if (control != m_Device && !control.synthetic) InsertControlBitRangeNode(ref m_Device.m_ControlTreeNodes[0], control, ref controlIndiciesNextFreeIndex, 0); // Add all leaf controls to state offset mapping. diff --git a/Packages/com.unity.inputsystem/InputSystem/Runtime/Plugins/HID/HID.cs b/Packages/com.unity.inputsystem/InputSystem/Runtime/Plugins/HID/HID.cs index 9becfbe872..a73d64e88d 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Runtime/Plugins/HID/HID.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Runtime/Plugins/HID/HID.cs @@ -868,12 +868,21 @@ internal void AddChildControls(ref HIDElementDescriptor element, string controlN ////REVIEW: this probably only works with hatswitches that have their null value at logicalMax+1 + // All four sub-controls overlap the hat itself — they read the same value + // and only differ in the DiscreteButton range parameters above. + // WithByteOffset(0) anchors them to the hat parent's byte (FinalizeControlHierarchy + // adds the parent's byteOffset afterwards). Without it, the layout system + // auto-allocates a fresh byte for each — fine for 4-bit hats (bit-addressing, + // they pack into one byte) but for 8-bit hats each sub-control claims its own + // byte, pushes them past the device's report size, and InputDeviceBuilder's + // bit-range tree builder infinite-loops on the dangling controls. See UUM-143659. builder.AddControl(controlName + "/up") .WithFormat(InputStateBlock.FormatBit) .WithLayout("DiscreteButton") .WithParameters(string.Format(CultureInfo.InvariantCulture, "minValue={0},maxValue={1},nullValue={2},wrapAtValue={3}", logicalMax, logicalMin + 1, nullValue.ToString(), logicalMax)) + .WithByteOffset(0) .WithBitOffset((uint)element.reportOffsetInBits % 8) .WithSizeInBits((uint)reportSizeInBits); @@ -883,6 +892,7 @@ internal void AddChildControls(ref HIDElementDescriptor element, string controlN .WithParameters(string.Format(CultureInfo.InvariantCulture, "minValue={0},maxValue={1}", logicalMin + 1, logicalMin + 3)) + .WithByteOffset(0) .WithBitOffset((uint)element.reportOffsetInBits % 8) .WithSizeInBits((uint)reportSizeInBits); @@ -892,6 +902,7 @@ internal void AddChildControls(ref HIDElementDescriptor element, string controlN .WithParameters(string.Format(CultureInfo.InvariantCulture, "minValue={0},maxValue={1}", logicalMin + 3, logicalMin + 5)) + .WithByteOffset(0) .WithBitOffset((uint)element.reportOffsetInBits % 8) .WithSizeInBits((uint)reportSizeInBits); @@ -901,6 +912,7 @@ internal void AddChildControls(ref HIDElementDescriptor element, string controlN .WithParameters(string.Format(CultureInfo.InvariantCulture, "minValue={0},maxValue={1}", logicalMin + 5, logicalMin + 7)) + .WithByteOffset(0) .WithBitOffset((uint)element.reportOffsetInBits % 8) .WithSizeInBits((uint)reportSizeInBits); } From 3ef68ea1760e07afba1d48ad1c63240c3fe12bbb Mon Sep 17 00:00:00 2001 From: leonardo Date: Tue, 2 Jun 2026 12:51:04 +0200 Subject: [PATCH 3/4] adding description attribute regarding regression case --- Assets/Tests/InputSystem/Plugins/HIDTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Assets/Tests/InputSystem/Plugins/HIDTests.cs b/Assets/Tests/InputSystem/Plugins/HIDTests.cs index ef87ba0b0c..cbcc2402c2 100644 --- a/Assets/Tests/InputSystem/Plugins/HIDTests.cs +++ b/Assets/Tests/InputSystem/Plugins/HIDTests.cs @@ -424,6 +424,7 @@ public void Devices_CanParseHIDDescriptor_WithSignedLogicalMinAndMaxValues(byte // logged "Could not create a device for ...", so the device never showed up. [Test] [Category("Devices")] + [Description("Regression test for case UUM-143659")] public void Devices_CanCreateGenericHID_FromGamepadWithEightBitHatSwitch() { // Minimal Gamepad collection that contains a hat switch whose Report Size From b46369762ee2a788ad3f6c353a555a4699487bf6 Mon Sep 17 00:00:00 2001 From: leonardo Date: Tue, 2 Jun 2026 18:13:25 +0200 Subject: [PATCH 4/4] FIX: Drop synthetic-skip from UUM-143659 fix; HID byte-offset is sufficient The HID.cs WithByteOffset(0) change makes hat/right and hat/up land at the hat's own byte. The synthetic hat/x and hat/y inherit those offsets via useStateFrom, so the pre-FinishSetup tree-build no longer sees out-of-range synthetics. The InputDeviceBuilder synthetic-skip is no longer needed to fix UUM-143659. Keeping that skip broke APIVerificationTests.API_PrecompiledLayoutsAreUpToDate for Keyboard/Mouse/Touchscreen: removing synthetic controls from the bit-range tree changed the serialized layout of every device, so the checked-in Fast{Keyboard,Mouse,Touchscreen}.cs no longer match what the builder produces. Verified locally: - Devices_CanCreateGenericHID_FromGamepadWithEightBitHatSwitch still passes with the HID-only fix. - API_PrecompiledLayoutsAreUpToDate passes for all three devices. Co-Authored-By: Claude Opus 4.7 (1M context) --- Packages/com.unity.inputsystem/CHANGELOG.md | 2 +- .../InputSystem/Runtime/Devices/InputDeviceBuilder.cs | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/Packages/com.unity.inputsystem/CHANGELOG.md b/Packages/com.unity.inputsystem/CHANGELOG.md index 737356015b..4dc383736f 100644 --- a/Packages/com.unity.inputsystem/CHANGELOG.md +++ b/Packages/com.unity.inputsystem/CHANGELOG.md @@ -29,7 +29,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Fixed InputSystem.onAnyButtonPress fails to trigger when the device receives a touch [UUM-137930](https://issuetracker.unity3d.com/product/unity/issues/guid/UUM-137930). - Fixed an incorrect ArraysHelper.HaveDuplicateReferences implementation that didn't use its arguments right [ISXB-1792] (https://github.com/Unity-Technologies/InputSystem/pull/2376) - Fixed `InputAction.IsPressed`, `WasPressedThisFrame`, and `WasReleasedThisFrame` using a `ButtonControl`'s `pressPoint` when a binding also had an explicit `PressInteraction` with its own `pressPoint`, which could make those APIs disagree with the interaction's press and release behavior. Action-level press APIs now follow the interaction threshold when both are set explicitly. -- Fixed `IndexOutOfRangeException` in `InputDeviceBuilder` when connecting an HID gamepad whose report descriptor declares a hat switch with Report Size 8 (e.g. ESP32-BLE-Gamepad). The HID layer now anchors the hat's directional sub-controls to the hat's own byte instead of letting the layout system auto-allocate a fresh byte for each, and the bit-range tree builder skips synthetic controls whose state block is aliased in `FinishSetup` [UUM-143659](https://jira.unity3d.com/browse/UUM-143659). +- Fixed `IndexOutOfRangeException` in `InputDeviceBuilder` when connecting an HID gamepad whose report descriptor declares a hat switch with Report Size 8 (e.g. ESP32-BLE-Gamepad). The HID layer now anchors the hat's directional sub-controls to the hat's own byte instead of letting the layout system auto-allocate a fresh byte for each [UUM-143659](https://jira.unity3d.com/browse/UUM-143659). ### Changed - Action-level `IsPressed`, `WasPressedThisFrame`, and `WasReleasedThisFrame` for bindings to `Vector2Control` / `StickControl` no longer consult a per-control `pressPoint` on the vector (that field was removed). Use a `Press` interaction to set a custom threshold, or rely on `defaultButtonPressPoint`. diff --git a/Packages/com.unity.inputsystem/InputSystem/Runtime/Devices/InputDeviceBuilder.cs b/Packages/com.unity.inputsystem/InputSystem/Runtime/Devices/InputDeviceBuilder.cs index 874146690e..806b9f35dd 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Runtime/Devices/InputDeviceBuilder.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Runtime/Devices/InputDeviceBuilder.cs @@ -925,14 +925,8 @@ private void FinalizeControlHierarchyRecursive(InputControl control, int control throw new NotSupportedException($"Control '{control}' exceeds maximum supported state bit size of {(1U << InputDevice.kStateSizeBits) - 1} (bit offset {control.stateBlock.sizeInBits})"); } - // Construct control bit range tree. Skip synthetic controls (e.g. DpadControl's - // x/y axes, StickControl's up/down/left/right) — they don't have their own state - // bits (FinishSetup will alias their state block to a non-synthetic sibling/parent), - // so any state-change detection on their bits is covered by that sibling/parent - // already being in the tree. Including them was UUM-143659: synthetic controls - // can carry pre-FinishSetup offsets that fall outside the device's bit range, - // sending InsertControlBitRangeNode into infinite recursion. - if (control != m_Device && !control.synthetic) + // Construct control bit range tree + if (control != m_Device) InsertControlBitRangeNode(ref m_Device.m_ControlTreeNodes[0], control, ref controlIndiciesNextFreeIndex, 0); // Add all leaf controls to state offset mapping.