Status 2026-08-17: implemented and validated live on a Sony DualShock 4
($054C:$09CC) in standard HID mode. Bind-time parse + poll-once interrupt IN
as designed; joy_poll uses last_rx_len() (the host int_in return is a
boolean, not a byte count) and does not take the kbd/mouse hid_bus.
The problem
Keyboards and mice have a boot protocol — a fixed report layout any host can assume. Gamepads and joysticks do not: a pad's buttons, sticks, hats, triggers and pedals live wherever its HID report descriptor says they live, at arbitrary bit offsets, with arbitrary logical ranges and optional report-ID prefixes. Two pads from one vendor can differ. "Support gamepads" therefore means "parse report descriptors" — there is no shortcut that isn't a per-device hardcode.
Revisiting the 2010 decision
Ticket 2010 excluded a report-descriptor interpreter from the one-cog host
path — correctly, for the runtime path in the dogfood era. The distinction
that unlocks this feature: parse once at bind time, on the caller's cog,
in Spin. The PHY cog never sees the parser; the runtime poll stays
exactly what HID polling already is (one poll-once interrupt IN), plus a
handful of shift-and-mask field extractions from precomputed offsets. Cog
budget: zero. Hub budget: the parser object + its tables (measured in the
README footprint table; USB_JOY-gated so kbd/mouse users pay nothing).
Architecture
usb_app (USB_JOY gate) joy_* facade + mapping primitive
├── usb_hub_host +$21 HID-descriptor length capture,
│ +get_report_desc() into cfg[]
└── usb_hid_report.spin2 (NEW) bind-time parser + field extractor
no cog, no wire access - pure descriptor/report byte-walking
usb_hub_host: the existing one-walk config parse now also records each HID interface's report-descriptor length (from the type-$21 HID descriptor, bytes 7–8) — gettercls_hid_rdlen_i(i)— and gainsget_report_desc(da, ifn, n): GET_DESCRIPTOR(Report=$22) to the interface, fetched into the existing 512 Bcfg[]scratch.usb_hid_report: short-item parser tracking the global state (usage page, logical min/max, report size/count, report ID, 2-deep push/pop) and local usages (list + min/max), walking Input main items with a per-report-ID bit cursor. Records:- axes (≤ 8): Generic Desktop X/Y/Z/Rx/Ry/Rz/slider/dial/hat + Simulation Controls page — usage, bit offset/size, logical range, report ID;
- button ranges (≤ 4, ≤ 32 buttons total): Button-page variable bitfields;
- the application collection usage (joystick $04 / gamepad $05 / multi-axis $08) used for bind classification;
- the primary report ID = the one carrying the first Generic Desktop axis; fields from other report IDs are ignored (v1 cap, honest — feature reports and secondary collections are out of scope). Array-type inputs (as opposed to variable) advance the cursor but are not mapped (v1 cap; hats are variable on every pad surveyed).
- Binding: inside the existing HID auto-bind walk, a protocol-0
interface is offered to the parser first; only a descriptor whose
application collection is joystick/gamepad/multi-axis and that
yields at least one axis or button binds as
joy— anything else falls through to the keyboard path unchanged. Boot-protocol interfaces are untouched, so composite receivers keep working. No SET_PROTOCOL is sent to the pad (report protocol is the device default). - Polling:
joy_poll()= one poll-once interrupt IN into a 64 B buffer; when the device uses report IDs the first byte must match the primary ID (others are dropped). Extraction is LSB-first bit slicing per HID 1.11 §8.
The user mapping API (the point of the feature)
Introspection — the application can discover the pad:
| Call | Meaning |
|---|---|
joy_kind() |
Application usage: $04 joystick / $05 gamepad / $08 multi-axis. |
joy_axes(), joy_axis_usage(i) |
How many axes, and which each one is (page<<16 | usage — $30=X, $31=Y, $39=hat, …). |
joy_axis_min/max(i) |
The device's declared logical range. |
joy_button_count() |
Total buttons (≤ 32). |
Reading:
| Call | Meaning |
|---|---|
joy_poll() |
One poll-once IN; nonzero = fresh report. |
joy_axis(i) / joy_axis_scaled(i) |
Raw (sign-extended per logical min) / normalized to −32768..+32767 across the declared range. |
joy_buttons() / joy_button(i) |
32-bit mask / single button. |
joy_hat() |
First hat as 0..7 (N=0, clockwise), −1 centered. |
Mapping — the primitive that lets an application run a "press the control you want for FIRE" wizard:
| Call | Meaning |
|---|---|
joy_mark() |
Snapshot the current state as the baseline. |
joy_changed() |
First control now differing from the baseline: (kind<<8) \| index, kind 1 = button, 2 = axis (past a per-axis threshold of ¼ span — big enough to ignore stick noise/drift, small enough that any deliberate motion trips it), 3 = hat. 0 = nothing yet. |
Wizard shape (worked in examples/top_joy_example.spin2): for each game
action — joy_mark(), prompt, poll until joy_changed() returns nonzero,
store the code, wait for release. The stored codes are then read back
through one joy_control(code) accessor. The example also demonstrates
plain introspection ("this pad: gamepad, 6 axes: X Y Z Rz + hat, 12
buttons") and a live mapped-state display.
v1 caps (all stated in code)
- 8 axis slots, 4 button ranges / 32 buttons, 4 report IDs tracked, descriptor ≤ 512 B, report ≤ 64 B — covers every commodity pad surveyed in the HID literature; a device exceeding a cap binds with what fits.
- One joystick device bound at a time (same policy as every other class).
- Output reports (rumble/LEDs) are not in v1 —
usb_class_hid.set_reportalready exists for applications that need to try. - Vendor-page (page ≥ $FF00) fields are skipped (cursor-advanced) — PS/Xbox pads in their vendor modes need vendor drivers; in their standard HID modes they parse like any pad.
Validation plan (bench, when free)
Any commodity USB pad behind the hub: introspection matches the physical
device (count the buttons), every control moves its field, wizard maps a
4-action set, kbd/mouse regression (top_hid_example) unchanged. The
usb_emu_hid_joy device emulator (ticket 3030) can dogfood the poll path
but NOT the parser — its min-peer reports are fixed-layout; a real pad's
descriptor is the acceptance gate.