Blog Internals 13 min read

HID Report Descriptors: How a Gamepad Reports Input

A gamepad sends a packed bit array described by its HID report descriptor. How that descriptor works, how to decode it, and why it caps MIDI resolution.

By Aidxn Design

When you plug a gamepad into a computer, nothing about it says "this is the left stick". The pad sends a block of bytes at a fixed interval, and the only thing that gives those bytes meaning is a HID report descriptor — a small, self-describing byte array the device handed the host during enumeration, long before you pressed anything. Every layer above it, from Apple's GameController framework to Linux evdev to the browser Gamepad API, is ultimately a parser for that descriptor plus a pile of per-device quirk tables. If you are building or tuning a gamepad-to-MIDI chain, this is the layer that decides what resolution you have to work with, and it is worth understanding properly.

TL;DR
  • What a descriptor is: a byte array declaring the bit layout, numeric range and semantic meaning of every field in a device's input reports.
  • Where it is defined: the USB Device Class Definition for HID 1.11, with the usage numbers in the HID Usage Tables.
  • Why it matters for MIDI: Logical Minimum and Logical Maximum tell you the true resolution of an axis, which determines whether 7-bit CC is enough or whether you should be sending a 14-bit pair.
  • The catch: motion sensors, touchpads and haptics usually live in vendor-defined regions that the standard says nothing about.
  • What to do: normalise against the declared range first, do all maths in floats, and quantise to MIDI only at the output stage.

What is a HID report descriptor, in one sentence?

It is a device-supplied schema that tells the host how to interpret the raw bits in every report that device will send. The Human Interface Device class exists precisely so that a host does not need a per-device driver: instead of shipping code that knows what a specific joystick means, the operating system ships a generic parser, and the device describes itself. That design is why you can plug a 2004 flight stick and a 2024 gamepad into the same machine and both work without downloads.

The descriptor is fetched during enumeration as part of the HID class descriptor set, defined in HID 1.11, section 6.2.2. Microsoft's driver documentation has a readable summary of the same material in Introduction to HID Concepts if the spec PDF is heavy going. The practical consequence for a MIDI bridge is that the descriptor is the ground truth — everything else is interpretation.

Signal flow diagram: Report descriptor to Input report to Parser to Normalised value to MIDI message. Report descriptor sent once at plug-in Input report a packed bit array Parser applies the descriptor Normalised value logical min to max MIDI message note or CC
The descriptor is not data you receive repeatedly — it is the one-off map that tells a parser how to read every report that follows.

How is a report descriptor structured?

It is a flat stream of items, where each item is a one-byte prefix followed by zero to four data bytes. The prefix packs three things: a tag identifying what the item does, a type (main, global or local), and a size field indicating how many data bytes follow. There is no nesting syntax and no length header for the whole thing; you parse it linearly, maintaining a state machine as you go.

The three item types behave very differently, and confusing them is the most common parsing bug:

Item typeExamplesLifetime
GlobalUsage Page, Logical Minimum, Logical Maximum, Report Size, Report Count, Report IDPersists until explicitly changed or until a Pop restores a previous state
LocalUsage, Usage Minimum, Usage Maximum, Designator, String IndexApplies only to the next main item, then is discarded
MainInput, Output, Feature, Collection, End CollectionConsumes the accumulated global and local state and emits an actual field

That last row is the key mechanic. Global and local items are just accumulating state. Nothing exists in the report layout until a Main item — almost always Input for a gamepad — fires and says "take the current Report Size and Report Count, apply them to the queued usages, and emit that many fields into the report, right here, at the current bit offset".

What does a gamepad descriptor actually look like?

Here is a minimal, standards-clean gamepad descriptor, annotated. Real pads are longer and messier, but this is the skeleton every one of them is built on.

05 01        Usage Page (Generic Desktop)
09 05        Usage (Game Pad)
A1 01        Collection (Application)
  09 01        Usage (Pointer)
  A1 00        Collection (Physical)
    09 30        Usage (X)              left stick horizontal
    09 31        Usage (Y)              left stick vertical
    09 32        Usage (Z)              right stick horizontal
    09 35        Usage (Rz)             right stick vertical
    15 00        Logical Minimum (0)
    26 FF 00     Logical Maximum (255)
    75 08        Report Size (8 bits)
    95 04        Report Count (4 fields)
    81 02        Input (Data, Variable, Absolute)
  C0           End Collection

  09 39        Usage (Hat switch)       d-pad
  15 00        Logical Minimum (0)
  25 07        Logical Maximum (7)
  35 00        Physical Minimum (0)
  46 3B 01     Physical Maximum (315 degrees)
  65 14        Unit (English rotation, degrees)
  75 04        Report Size (4 bits)
  95 01        Report Count (1)
  81 42        Input (Data, Variable, Absolute, Null State)

  75 04        Report Size (4 bits)
  95 01        Report Count (1)
  81 01        Input (Constant)         padding to byte boundary

  05 09        Usage Page (Button)
  19 01        Usage Minimum (Button 1)
  29 0E        Usage Maximum (Button 14)
  15 00        Logical Minimum (0)
  25 01        Logical Maximum (1)
  75 01        Report Size (1 bit)
  95 0E        Report Count (14)
  81 02        Input (Data, Variable, Absolute)

  75 01        Report Size (1)
  95 02        Report Count (2)
  81 01        Input (Constant)         padding
C0           End Collection

Read it as bit accounting. Four 8-bit axes consume bytes 0 through 3. The hat switch takes the low nibble of byte 4 and two padding bits round it out. Fourteen buttons at one bit each occupy bytes 5 and 6 minus two bits, and a final two-bit constant pads the report to seven bytes. That layout is now completely determined, and a host can read a 7-byte report with zero device-specific knowledge.

Which usage numbers matter for a gamepad?

The numbers come from the HID Usage Tables, which is a separate document from the HID spec itself and is revised far more often. The subset that covers virtually every game controller is small:

Usage PageUsageMeaningTypical gamepad control
0x01 Generic Desktop0x04JoystickApplication collection for flight sticks
0x01 Generic Desktop0x05Game PadApplication collection for gamepads
0x01 Generic Desktop0x30XLeft stick horizontal
0x01 Generic Desktop0x31YLeft stick vertical
0x01 Generic Desktop0x32ZRight stick horizontal, or a trigger
0x01 Generic Desktop0x33RxCommonly left trigger
0x01 Generic Desktop0x34RyCommonly right trigger
0x01 Generic Desktop0x35RzRight stick vertical
0x01 Generic Desktop0x39Hat switchD-pad as a direction ordinal
0x09 Button0x01 upwardButton nFace, shoulder, stick-click, menu buttons
0xFF00 and upVendor definedNo standard meaningGyro, touchpad, battery, haptics, audio

Note the ambiguity sitting in the middle of that table. Z, Rx, Ry and Rz are not consistently assigned across vendors — one pad will put the right stick on Z and Rz, another on Rx and Ry, and a third will spend Z on a combined trigger axis where left and right triggers are summed into a single value. There is no violation of the spec in any of those choices, which is why every cross-platform input library on earth ships a mapping database. If you want the lived experience of that mess, hidapi versus native gamepad drivers covers where it bites in practice.

Why do Logical Minimum and Logical Maximum decide your MIDI resolution?

Because they are the only honest statement of how many distinct values an axis can produce. A field declaring Logical Minimum (0) and Logical Maximum (255) with Report Size (8) gives you 256 steps. A field declaring a signed 16-bit range gives you far more. Those are different instruments as far as a MIDI bridge is concerned.

Standard MIDI 1.0 Control Change carries a 7-bit value, which is 128 steps, as laid out in the MIDI Association's Control Change reference. So an 8-bit HID axis mapped to a single 7-bit CC loses exactly one bit — every pair of adjacent raw values collapses into one CC value. That is usually inaudible on a filter cutoff and extremely audible on a slow eight-bar sweep, where it shows up as stepping. A 16-bit HID axis mapped to 7-bit CC throws away nine bits, which is the point at which you should be reaching for a 14-bit MSB/LSB CC pair instead. We go into that trade-off properly in analog trigger resolution and 7-bit versus 14-bit CC.

The rule that follows is simple and worth internalising: never quantise early. Read the raw integer, normalise it against the declared logical range into a float, do deadzone and curve maths in that float domain, and convert to MIDI integers only at the final output stage. Quantising at the input and then applying a response curve means applying a curve to an already-lossy signal, and the artefacts stack.

How do I dump a report descriptor on each OS?

Linux makes this trivial because the kernel exposes the raw descriptor through debugfs. The hidraw documentation covers the device-node side, and the HID core exposes the descriptor directly:

# Linux - list HID devices and dump a raw descriptor
ls /sys/kernel/debug/hid/
sudo cat /sys/kernel/debug/hid/0003:054C:0CE6.0001/rdesc

# Same device, the parsed view the kernel produced
sudo cat /sys/kernel/debug/hid/0003:054C:0CE6.0001/events

# Confirm which evdev node the kernel bound it to
cat /proc/bus/input/devices | grep -A 6 -i wireless

On macOS the descriptor is reachable through IOKit's HID family — the IOKit documentation is the entry point, and the kIOHIDReportDescriptorKey property on a matched device returns the byte array. On Windows you normally do not touch the raw bytes at all: the HID parser is a kernel service and user mode talks to it through the HID parsing API described in HID Collections and Top-Level Collections, which hands you capability structures rather than a descriptor to decode yourself.

What is a Report ID and why does it break naive parsers?

A Report ID is a global item that prefixes every report with a one-byte identifier, letting a single device multiplex several distinct report layouts over one endpoint. A gamepad commonly uses this to separate its ordinary input report from feature reports carrying calibration data, and from output reports driving rumble or lights.

The trap is that Report IDs are optional. If the descriptor declares no Report ID, reports arrive with no prefix byte and data starts at offset 0. If it declares one, every report gains a leading byte and every field offset you calculated shifts by eight bits. Parsers that hardcode an offset for "the left stick" work perfectly on one pad and silently read garbage on the next. Worse, on some stacks the ID byte is stripped before your code sees it and on others it is not — which is exactly the sort of inconsistency that makes a bridge feel haunted until you find it.

The DualSense is a good worked example of this being transport-dependent. Community reverse-engineering documented at nondebug/dualsense — which is a practitioner resource, not a Sony document, and should be read as such — shows the pad presenting a different report ID and a different report length over USB than over Bluetooth, with the Bluetooth variant carrying additional framing. That is why a bridge that works flawlessly on a cable can produce nonsense over wireless if it assumed one layout. If that sounds familiar, wired versus Bluetooth for a DualSense MIDI rig walks through the behavioural differences.

What are vendor-defined usages hiding?

Everything interesting that is not a button or a stick. Usage Pages 0xFF00 through 0xFFFF are explicitly reserved for vendor use by the HID Usage Tables, and the spec deliberately assigns them no meaning. Manufacturers use that space for the controls that make their pad distinctive, because there is no standard usage for "adaptive trigger resistance profile" or "48-point capacitive touchpad".

For a modern pad that typically includes the gyroscope and accelerometer, touchpad coordinates and contact IDs, battery level and charge state, headphone jack and internal mic routing, and the entire haptics and lighting command set. None of it is parseable from the descriptor alone. You either use the platform framework that has the vendor knowledge baked in — Apple's GameController framework exposes DualSense specifics through GCDualSenseGamepad and adaptive triggers through GCDualSenseAdaptiveTrigger — or you implement the device-specific decode yourself against community documentation.

This is the single biggest reason a pure-HID bridge feels limited. You get buttons, sticks and triggers for free on every controller ever made, and you get precisely nothing else. The macOS GameController deep dive and the Linux evdev bridge walkthrough show the two opposite answers to that problem on two platforms.

How does the descriptor interact with polling and latency?

It does not set the timing, but it sets the payload that the timing carries. The interval at which input reports arrive comes from the endpoint descriptor's bInterval field, not the report descriptor, and is governed by the frame structure defined in the USB 2.0 specification. What the report descriptor determines is how much state arrives in each of those deliveries.

That distinction matters when you are chasing responsiveness. Making the descriptor smaller does not make the pad faster. Making the polling interval shorter does — within limits, and with trade-offs we unpack in USB polling rates for gamepad MIDI. If you want measured end-to-end figures rather than mechanism, this site's own benchmark write-up at gamepad MIDI latency benchmark is where the numbers live; treat those as this site's measurements on its own rig, not as industry constants.

How do I turn a parsed HID field into a clean MIDI value?

In four stages, in this order, with floats throughout until the final line.

// 1. Extract the raw field using the declared bit offset and size
int32_t raw = extract_bits(report, field.bit_offset, field.bit_size,
                           field.is_signed);

// 2. Normalise against the DECLARED logical range, not an assumed one
double t = (double)(raw - field.logical_min) /
           (double)(field.logical_max - field.logical_min);   // 0.0 .. 1.0

// 3. Centre, deadzone and curve in float space
double centred = (t * 2.0) - 1.0;                             // -1.0 .. 1.0
centred = apply_radial_deadzone(centred, partner_axis, 0.08);
centred = apply_response_curve(centred, CURVE_GAMMA, 1.6);

// 4. Quantise ONCE, at the very end
uint16_t cc14 = (uint16_t)lround(((centred + 1.0) * 0.5) * 16383.0);
uint8_t  msb  = (cc14 >> 7) & 0x7F;
uint8_t  lsb  =  cc14       & 0x7F;

Step 2 is the one people skip. Hardcoding / 255.0 works until you meet a pad that declares a signed 16-bit range, at which point your stick appears to be permanently slammed to one corner. Reading logical_min and logical_max from the descriptor costs nothing and makes the same code correct on every device. The deadzone and curve maths in step 3 is covered in depth in deadzone and response curve mathematics.

What does Universal Controller MIDI do with all this?

It parses the descriptor where the descriptor is sufficient, and uses platform frameworks where it is not. Buttons, sticks, triggers and hat switches come from the declared usages and declared logical ranges, so an unknown controller that nobody has ever tested still produces sensible MIDI. Vendor-defined surfaces — DualSense touchpad and gyro, adaptive trigger feedback, Joy-Con motion, Steam Deck trackpads — go through device-specific decoders layered on top.

Everything downstream of extraction happens in floating point. Deadzone, curve, smoothing and scaling all operate on normalised values, and MIDI quantisation is the final operation before the message hits the virtual port. That is why a 14-bit mapping on a high-resolution axis actually sounds like 14 bits rather than 7 bits with extra bytes attached. Configure the output side in stick to CC mapping and deadzones, and if you want the device to go the other way and receive messages, haptic feedback covers the output report side.

What should I check first when a controller reports wrong?

  • Axes pinned to an extreme. Almost always a signed versus unsigned mismatch, or a hardcoded / 255.0 against a 16-bit declared range.
  • Everything offset by one byte. A Report ID is present and you are not accounting for the prefix byte — or the stack stripped it and you are.
  • D-pad behaving like a weird analog axis. You are reading a hat switch as a value instead of as a direction ordinal with a null state, which the descriptor flags with the Null State bit on the Input item.
  • Buttons shifted by one. Button page usages are one-based ordinals, so Usage Minimum of Button 1 is bit 0 of the field, not bit 1.
  • Works on USB, broken on Bluetooth. Different report ID and length on the same physical device. Parse the descriptor the transport actually gave you.
  • Gyro or touchpad missing entirely. Expected — those are vendor-defined, and no amount of standard HID parsing will surface them.

FAQ

What is a HID report descriptor?

A HID report descriptor is a byte array that a USB or Bluetooth input device hands the host during enumeration, describing the exact bit layout and meaning of every field in the reports it will later send.

Where is the HID report descriptor format specified?

It is specified in the USB Device Class Definition for Human Interface Devices version 1.11, published by the USB Implementers Forum, with the usage numbers themselves defined in the separate HID Usage Tables document.

Why does my gamepad report different axis ranges to someone else's?

Because the Logical Minimum and Logical Maximum items in the descriptor are per-device, so one pad may declare an 8-bit unsigned range while another declares a signed 16-bit range for the same physical stick.

Can I read a gamepad without parsing the report descriptor?

Yes, if you use an abstraction such as XInput, Apple's GameController framework or Linux evdev, because those layers parse the descriptor for you and hand you named controls instead of raw bits.

Why is the DualSense gyro not in the standard HID usages?

Sony places the motion, touchpad and haptics data in vendor-defined regions of its input report, so those fields carry no standard usage and must be decoded from community reverse-engineering rather than the HID Usage Tables.

Does parsing HID directly make MIDI output faster?

It does not change the transport timing, which is set by the USB polling interval or the Bluetooth connection interval, but it does give you the full declared resolution of each axis instead of a pre-quantised value from a higher-level API.

Put it into practice

You do not need to write a descriptor parser to benefit from knowing how one works. What you need is the habit it teaches: find out what range your hardware actually declares, normalise against that, and quantise once. Download Universal Controller MIDI, open the mapping editor, and watch the raw normalised value alongside the MIDI value as you move a stick — the gap between them is the whole story this post has been telling.

Keep reading

More setup walkthroughs