Duplicated joystick axes with SDL_dinputjoystick and 8BitDo N30 Pro 2 (with suggested fix)

I’ve been debugging some weird axis behavior under SDL with the “8BitDo N30 Pro 2” game controller connected via USB on Windows. This device reports 6 axes (but only actually uses 4).

Instead of the expected four axes:

0:leftx, 1:lefty, 2:rightx, 3:righy, 4:n/a 5:n/a

Both of the sticks’ y-axes are duplicated, basically like this:

0: leftx, 1:lefty, 2:lefty, 3:rightx, 4:righty, 5:righty

This does not happen under Linux or macOS, and I also verified that the HID device actually just sends single axis updates as expected (it does).

I’ve done some debugging in the directinput joystick driver in SDL under Windows 10, as I suspected there could be an issue with the axis enumeration. I added some debugging code, and here is the output of SDL’s enumeration of axes with IDirectInputDevice8_EnumObjects:

EnumDevObjectsCallback dwType=1282 DIDFT_AXIS INSTANCE=5
 - wUsagePage=0x01, wUsage=0x35 tszName=Z
 - GUID_RzAxis
 - SDL 2.0.10 wants in->ofs                      = 20
 - sizeof(LONG) * DIDFT_GETINSTANCE(dev->dwType) = 20

EnumDevObjectsCallback dwType=514 DIDFT_AXIS INSTANCE=2
 - wUsagePage=0x01, wUsage=0x32 tszName=Z
 - GUID_ZAxis
 - SDL 2.0.10 wants in->ofs                      = 8
 - sizeof(LONG) * DIDFT_GETINSTANCE(dev->dwType) = 8

EnumDevObjectsCallback dwType=258 DIDFT_AXIS INSTANCE=1
 - wUsagePage=0x01, wUsage=0x31 tszName=Y
 - GUID_YAxis
 - SDL 2.0.10 wants in->ofs                      = 4
 - sizeof(LONG) * DIDFT_GETINSTANCE(dev->dwType) = 4

EnumDevObjectsCallback dwType=2 DIDFT_AXIS INSTANCE=0
 - wUsagePage=0x01, wUsage=0x30 tszName=X
 - GUID_XAxis
 - SDL 2.0.10 wants in->ofs                      = 0
 - sizeof(LONG) * DIDFT_GETINSTANCE(dev->dwType) = 0

EnumDevObjectsCallback dwType=1538 DIDFT_AXIS INSTANCE=6
 - wUsagePage=0x02, wUsage=0xc5 tszName=B
 - GUID_RzAxis
 - SDL 2.0.10 wants in->ofs                      = 20
 - sizeof(LONG) * DIDFT_GETINSTANCE(dev->dwType) = 24

EnumDevObjectsCallback dwType=1794 DIDFT_AXIS INSTANCE=7
 - wUsagePage=0x02, wUsage=0xc4 tszName=A
 - GUID_YAxis
 - SDL 2.0.10 wants in->ofs                      = 4
 - sizeof(LONG) * DIDFT_GETINSTANCE(dev->dwType) = 28

As shown seen here, the underlying issue is that DirectInput reuses GUID_YAxis and GUID_RzAxis for the final two axes. And SDL uses the GUID’s to calculate the offset into the DIJOYSTATE structure. So, two and two SDL axes get the same ofs, and both will report a change when the corresponding single axis on the device changes.

I can’t really claim that there is a bug in SDL as such, it looks more like the bug is really in DirectInput/Windows (it would make sense for Windows to report guid type slider here). However, extracing the instance number of the axis via DIDFT_GETINSTANCE(dev->dwType) yields the correct axis index. For example, the last reported axis has guidType GUID_YAxis but DIDFT_GETINSTANCE(dev->dwType) is 7.

So it seems to me that using the guidType is unreliable in certain cases. One alternative is to just ditch the GUID comparisons, and instead for axes simply do:

in->ofs = sizeof(LONG) * DIDFT_GETINSTANCE(dev->dwType);

That certain works well for this device, and sounds sane. But on the other not hand, not being a Windows/DirectInput developer really, I cannot say for certain that using the DIDFT_GETINSTANCE(dev->dwType) is more reliable overall on different Windows versions (etc), so I also suggest an alternative fix: Keep the guidType comparisons as they are today, and also add the following code afterwards, to override the offset when these two instance numbers are seen:

if (DIDFT_GETINSTANCE(dev->dwType) == 6) {
    in->ofs = DIJOFS_SLIDER(0);
} else if  (DIDFT_GETINSTANCE(dev->dwType) == 7) {
    in->ofs = DIJOFS_SLIDER(1);
}

With either of these changes, the device reports axes as expected, and it also matches the behavior of the device on Linux and macOS.

Let’s take a look at this after 2.0.12 ships. Can you add a bug to bugzilla.libsdl.org so we don’t lose this info?

Thanks!

Sounds good :slight_smile: Created bugzilla report at https://bugzilla.libsdl.org/show_bug.cgi?id=5002

I wasn’t quite satisfied with my previous investigation. Since the device in question did not actually send data for the extra conflicting axes, I couldn’t verify that the suggested solution was correct. So I created my own test device (based on Arduino Pro Micro) where I created a similar HID device (using the same HID usages for axes) so I could verify that each axis was getting data from the HID device as expected. As it turned out, it didn’t.

When updating the joystick state, the extra axis data did not get stored in offsets 24 and 28, but instead in offsets 180 and 196 which is outside of the DIJOYSTATE struct. This in turn led me to discover the DIJOYSTATE2 struct, where offsets 180 and 196 are lVY and lVRz, respectively. I also then noticed IDirectInputDevice_SetDataFormat which specifies that DIJOYSTATE2 should be used.

So the question is then, how to calculate these offsets from the data given while enumerating the objects on the DirectInput Device? At first, the best way seemed be to check wUsage and wUsagePage. I created a HID device with the following “HID axes” (X, Y, Z, Rx, Ry, Rz, Rudder, Throttle, Accelerator, Brake, Steering) and checked at what offset in DIJOYSTATE2 I got tthe axis data at. I also looked up what the corresponding entry in DIJOYSTATE2 was, and logged some of the assocated data from the corresponding DIDEVICEOBJECTINSTANCE struct:

HID Usage      (Page) | OFS | MACRO                           | tszName | guidType     | dwFlags |
X            0x30 (1) |   0 | DIJOFS_X                        | X       | GUID_XAxis   | 0x100   |
Y            0x31 (1) |   4 | DIJOFS_Y                        | Y       | GUID_YAxis   | 0x100   |
Z            0x32 (1) |   8 | DIJOFS_Z                        | Z       | GUID_Zaxis   | 0x100   |
Rx           0x33 (1) |  12 | DIJOFS_RX                       | X       | GUID_RxAxis  | 0x100   |
Ry           0x34 (1) |  16 | DIJOFS_RY                       | Y       | GUID_RyAxis  | 0x100   |
Rz           0x35 (1) |  20 | DIJOFS_RZ                       | Z       | GUID_RzAxis  | 0x100   |
Rudder       0xbA (2) | 228 | FIELD_OFFSET(DIJOYSTATE2, lARz) | R       | GUID_RzAxis  | 0x100   |
Throttle     0xbB (2) |  24 | DIJOFS_SLIDER(0)                | T       | GUID_Slider  | 0x100   |
Accelerator  0xc4 (2) | 180 | FIELD_OFFSET(DIJOYSTATE2, lVY)  | A       | GUID_Yaxis   | 0x100   |
Brake        0xc5 (2) | 196 | FIELD_OFFSET(DIJOYSTATE2, lVRz) | B       | GUID_RzAxis  | 0x100   |
Steering     0xc8 (2) | 176 | FIELD_OFFSET(DIJOYSTATE2, lVX)  | S       | GUID_Xaxis   | 0x100   |

At first glance, it may seem that DirectInput internally just associates HID usage/page pairs with specific struct entries. For example Accelerator => DIJOYSTATE2.lVY. I wanted to verify this (and if so, create a table of this mapping) and so I tested with a variety of axis combinations on my test HID device. However, it wasn’t that simple. What offset a given HID usage/page were mapped to, depended also on the presence or absence of other HID usages. I’ll skip the details of the investigation and skip to the conclusion.

Based on my observations, DirectInput seems to assign axis entries in DIJOYSTATE2 like this:

  • Each HID usage/page pair is associated with one of the following “classes” of axes; X, Y, Z, RX, RY, RZ, SLIDER (corresponding to the diffent guidTypes). Note that I said classes (as in groups).
  • The axes are assigned in order, so for example if only one axis in a class is present - say the X axis, that is assigned DIJOFS_X.
  • If more than one axis in a class is present, the next “set” of axes are used, in this order: The old axis entries from DIJOYSTATE, then the velocity variants (lVX…), then the acceleration variants (IAX…), and (presumably finally, I did get to test these ones yet), the force variants (IFX…)
  • The sliders are also assigned in order. Both slots of rglSlider ar assigned before rglVSlider is used, etc.

Looking back on the example with the “HID axes” (X, Y, Z, Rx, Ry, Rz, Rudder, Throttle, Accelerator, Brake, Steering), There are three axes with guidType GUID_RzAxis, and they are assigned offsets RZ => lRZ, Brake => lVRz, Rudder => lARz.

I created a test HID device with only the Rudder control usage, no RZ or brake, and verified that the offset I got in DIJOYSTATE was then as expected lRZ, and not lARZ (since no other GUID_RzAxis was in use).

I’ve also did similar tests with sliders. HID usages “slider”, “dial” and “trottle”: all get guidType = GUID_SLIDER, and rglSlider[0] is assigned first, then rglSlider[1], and then rglVSlider[0]. If only throttle is available, only rglSlider[0] is assigned.

I plan to test some more, and then write a patch (basically need to count the occurences if the different axis classes and choose offset based on that, similar to how sliders are counted today).

Patch added to the bugzilla ticket: https://bugzilla.libsdl.org/show_bug.cgi?id=5002