Controller Audio (Composite USB Personas)¶
A real USB DualSense presents four interfaces: USB Audio Class speaker/haptics out, microphone in, and HID. The standard dualsense profile presents the HID interface only, because that is the one interface UMDF2 can create. Three profiles present the full composite:
| Profile | Audio out | Audio in | Haptics lane |
|---|---|---|---|
dualsense-composite |
4 ch / 16-bit / 48 kHz (channels 1/2 speaker, 3/4 voice-coil actuators) | 2 ch / 48 kHz microphone | Yes |
dualsense-edge-composite |
Same 4-ch stream as the base pad, with the Edge's 1 ms USB input polling | 2 ch / 48 kHz microphone | Yes |
dualshock-4-v2-composite |
2 ch / 16-bit / 32 kHz headset | 1 ch / 16 kHz microphone | No (hardware has none) |
In a profile picker these carry the Full marker, which is how the catalog flags the most capable profile for a given device. A device never has more than one. Not every Full profile is a composite: xbox-one-s-bt-full wears the same marker on the plain HID path.
Every descriptor byte comes verbatim from a real pad's hardware dump, and the UAC volume/mute ranges are real wire values captured from live control transfers. Windows surfaces the same audio endpoints it would for the physical controller: Speakers (Wireless Controller) and Headset Microphone (Wireless Controller).
The 4-channel OUT stream matters because channels 3/4 are the DualSense's voice-coil actuators. That stream is the only path on Windows by which a game hands a controller its authored haptic waveforms. Games that render audio to the controller endpoint reach the SDK, and the consumer routes the haptic lanes to real hardware.
The original DS4 v1 (054C:05C4) has no composite variant deliberately: real hardware probes show a single HID interface over USB and no audio class. USB audio arrived with the v2.
Nothing to install¶
using var ctx = new HMContext();
ctx.LoadDefaultProfiles();
ctx.InstallDriver();
using var ctrl = ctx.CreateController(ctx.GetProfile("dualsense-composite")!);
That is the entire setup. A composite persona is created the same way as any other profile.
Composite personas use the bundled usbip-win2 0.9.8.1 host controller. Its driver files come from the x64 release asset and, for ARM64, from the same tag's build from the project's signing service (usbip-win2#13). HIDMaestro.Core.dll carries them unmodified, with their Microsoft signatures.
The build checks each installer against its pinned hash and each file it unpacks against its own, and the SDK checks the staged files again before Windows installs them. On a machine with no host controller, the SDK installs one from those files (v2.0.1). It never runs usbip-win2's installer and never installs its root-hub filter, so no USB device drops. A machine that HIDMaestro put on an older usbip-win2, 0.9.7.7 through v1.8.1 or 0.9.7.5 in v1.9.0 and v1.9.1, is moved to 0.9.8.1 automatically. The SDK carries the host controller's own signed driver files and puts them on the existing controller with one Windows driver update. It never runs the vendor installer for this, and it leaves the root-hub filter alone, so no USB device drops and no window appears. It happens only in a Windows session where nothing has used the controller yet, because a used controller does not release its driver. Until then the older transport keeps working. Any other usbip-win2 version was installed by another program and is left as it is. HIDMAESTRO_KEEP_TRANSPORT=1 turns the move off.
See platform support for the version choice and validation limits. Two optional APIs exist for consumers that want control over when the one-time install happens:
// Informational: has this machine already got the transport?
bool ready = HMContext.IsUsbipBackendAvailable;
// Do the one-time install now (onboarding step, settings toggle) instead
// of on the first composite create. Idempotent.
HMContext.InstallUsbipBackend(status => Console.WriteLine(status));
Neither is a gate. CreateController works either way. HMProfile.RequiresUsbipBackend marks which profiles are composites, useful for labeling picker entries that bring controller audio.
Everything else stays in HIDMaestro's own user-mode code. The SDK runs an in-process USB/IP device server on loopback (fixed port range 18509-18524, then 28509-28524 when the first is full, then a port Windows assigns): descriptor service, HID reports through the same shared-memory contract every other profile uses, and a highest-priority pacing thread completing isochronous URBs at the endpoint's 1 ms service interval. Measured idle cost with the transport installed and no device attached: indistinguishable from baseline (0.35% vs 0.24% CPU on an Atom Z8350).
As of v2.0.0 the server admits an import only from the host controller, whose connection the System process owns, and answers an import from any other process with an error before it looks up the device. The device list still answers anyone, so another program's cleanup can see which devices a server exports. A connection must finish its handshake within 5 s, and past 16 connections in their handshake a new one from a user-mode process is closed at once. An imported connection may then sit idle.
A composite persona answers GET_REPORT(Input) with the latest bytes of the input report whose ID the host asks for, or that ID and zeros before the first report of it. An ID its descriptor does not declare stalls. The Switch 2 Pro persona answers a declared ID with the report it would send next, and report ID 0 with its selected report. A device with a command protocol answers as its profile's getReport says: the GameCube adapter, set to "reportId", answers the requested report ID alone and stalls report ID 0. See Profile System.
HMController.SubmitState, SubmitRawReport, OutputReceived, OutputDecoded, and the Sony feature-report behavior are identical across backends. The HID interface of each composite is byte-identical to its UMDF2 sibling.
The audio surfaces¶
Composite controllers expose HMController.UsbAudio (null on every UMDF2 profile):
var audio = controller.UsbAudio!;
// Host → consumer: the game's speaker + haptic PCM, paced at the wire rate.
audio.Output.FramesReceived += (output, pcm) =>
{
// Interleaved 16-bit little-endian PCM. output.ChannelRoles maps the
// channels: ["speakerLeft", "speakerRight", "hapticLeft", "hapticRight"].
// The memory is only valid during the callback; copy to retain.
};
audio.Output.StreamingChanged += (_, open) => { /* audio session opened/parked */ };
// Consumer → host: feed the microphone. Silence when the buffer runs dry.
int accepted = audio.Microphone.Submit(pcmBytes); // interleaved 16-bit LE at the declared format
if (accepted < pcmBytes.Length) { /* buffer full: the rest was dropped */ }
// Volume/mute writes from Windows (volume mixer, control panel):
audio.ControlChanged += (_, e) =>
Console.WriteLine($"{e.Function}: {(e.IsMute ? $"mute={e.MuteValue}" : $"{e.VolumeDb:F1} dB")}");
FramesReceived fires on the backend's pacing thread with a few milliseconds of PCM per window, at the cadence the host renders. Handlers must be quick and thread-safe, the same contract as OutputReceived.
As of v2.0.0 ControlChanged and both StreamingChanged events fire on the device's own audio event thread, in the order the host made the changes, so a handler that takes its time delays the events after it and no transfer. While a handler blocks, a newer write to a control replaces the one of that control still waiting, so a handler gets each control's latest value. Stream opens and parks are never merged. A change the host made before the controller is disposed is still raised, after Dispose returns if a handler is still running then.
Submit returns the bytes it accepted. The microphone buffer holds roughly a quarter second, and a producer that outruns the 1 ms service interval will eventually fill it, at which point the excess is dropped rather than allowed to grow capture latency without bound. Feeding one continuous stream is what the buffer expects, so chunk sizes need not be frame-aligned and a sample may span two calls. What a short return means is that audio was lost, and a consumer that wants to know should compare it against the length submitted. Bytes are only ever dropped on a sample-frame boundary, so a full buffer costs you a click, never a permanently misaligned stream.
StreamingChanged and IsStreaming follow the host's SET_INTERFACE. usbip-win2 0.9.8.1's host controller driver does not recognize the interface notifications of an older usbip-win2 filter driver, so on a machine where the 0.9.8.1 driver sits beside such a filter, SET_INTERFACE never arrives while the audio itself flows. Since v2.0.1 a machine where HIDMaestro installs the transport has no filter at all, and neither SET_CONFIGURATION nor SET_INTERFACE arrives there. On both kinds of machine the stream counts as open from its first isochronous transfer, and the close is raised within about half a second of the last one, as it has been beside an older filter since v1.10.0. On both, IsStreaming also reads false while a stream is paused with no transfers.
Recognizing your own persona¶
A composite persona is byte-for-byte a real Sony pad at every level a filter can inspect. That is what the USB Audio Class driver binds against, so it cannot carry a HIDMaestro marker of its own. An application that enumerates HID devices will therefore see the persona as an ordinary controller, which for a host that created it means seeing itself: SDL, for one, will treat it as a second gamepad and drive the lightbar and player pips from its index.
The token lives on the one node HIDMaestro owns, the emulated host controller the persona sits behind:
It is added alongside the upstream id, never in place of it, so driver binding is unchanged. A consumer that already filters its own virtual pads by testing hardware IDs for HIDMAESTRO needs only to walk far enough up the device tree: from the persona's HID interface, that node is four parents away, so a depth limit of five or more finds it. Filtering on the transport's usbip2_ude service instead would also catch an unrelated usbip install, and would break outright if the backend is ever replaced.
The same absence of a marker changes how a persona is cleaned up. The device sweep behind RemoveAllVirtualControllers walks the ROOT and SWD enumerators looking for exactly that HIDMAESTRO token, so it can never see a composite, and before v1.4.5 a consumer that created one and exited left a USB DualSense enumerated on the machine with nothing left running to feed it. The sweep now detaches personas from the emulated host controller before it walks the enumerators. As of v2.0.0 it detaches only the personas of programs that have exited, matched by the server port in each program's owner record, along with any import on the SDK's port ranges whose server no longer listens, and RemoveAllVirtualControllers(HMCleanupScope.Everything, preserveInstall) detaches every one. See Programs Side by Side.
The order matters and is not an implementation detail a consumer can ignore. A persona's input pump maps the shared input section directly and reads it in a loop, taking no part in the stop-event drain the UMDF2 controllers use, so the pump has to be joined before the sweep destroys those sections. Detaching without stopping it first is not a leak, it is an access violation on a background thread that takes the process down. If you build your own teardown path over the internals rather than calling the SDK's, stop the pumps first.
One fidelity note worth knowing: Windows persists per-endpoint enable/disable state by device identity. Because the composite presents the real pad's exact identity, it inherits whatever state the machine last had for the real controller's endpoints. If the real pad's speaker endpoint was ever disabled in the Sound control panel, the virtual one arrives disabled too. Re-enable it there once.
Profile schema¶
Composite personas add these fields to the profile JSON:
"backend": "usbip"selects the create path. It is a property of the device, never a mode toggle on an existing profile: a profile that presented four interfaces on some machines and one on others would name a device plus a machine state."usbConfiguration"carries the full USB identity: the structured interface/endpoint model the backend routes with, plus the verbatim wire blobs (deviceDescriptor,configurationDescriptor,otherSpeedConfigurationDescriptor), thebusSpeed("low","full"or"high", the default), andaudioControlswith the pad's real UAC volume ranges. The blobs are served byte-for-byte and cross-checked against the structured model at create time.- An interface's alt setting may carry its own
"reportDescriptor", for a persona presenting more than one HID interface. The primary interface (the one whosefunctionis"hid") serves the profile's owndescriptoras always; the others serve theirs from here. - An interface whose
functionis"vendor"may declare"bulk"endpoints. The backend serves them for a persona with a responder behind them, the Switch 2 Pro Controller, and stalls them for any other. "microsoftOs"carries the Microsoft OS 1.0 descriptors a device answers Windows with, as hex:string, the 18 bytes Windows reads at string index 0xEE, andextendedCompatIdandextendedProperties, the feature descriptors a vendor request with wIndex 4 and 5 reads. With them Windows binds WinUSB to the interface they name, with no driver install. A profile without the block stalls all three."winUsb"and"sharedSerial"(v2.0.0) insideusbConfigurationdescribe a WinUSB binding on request and a serial every unit shares. A device's command protocol is"protocol"at the profile's top level, andusbConfiguration.protocolis refused at load. See Devices with ports and protocols."featureStubs"declares the answers the persona gives toGET_REPORT(Feature), for a device whose claiming software interrogates it before it will use it.messageBytesays where the message id sits in a write's payload, and a report may declare theparamit answers for, when one message carries several. A report may also carry apatchthat writes a value from one of the profile'svalueSourcesinto its bytes.steam-controller-2patches the four bytes at offset 19 with the firmware buildTRITON_FW_TSnames in Steam'shardwareupdater.cfg, little-endian, through akeyedHexFilesource, and keeps the captured build without Steam.steamFirmwareStamp, the older spelling, is refused at load with its replacement named.
See Profile System for the rest of the schema.
A second kind of persona: the Valve devices¶
The audio personas above exist because UMDF2 can present exactly one HID interface and a DualSense has four. The three Valve personas use the same backend for a different reason: what a device is to Steam.
The Steam Deck¶
The plain steam-deck profile carries Valve's real ids over a standard gamepad descriptor. Steam files it under Generic DirectInput, and none of Steam Input's Valve-device treatment applies: no gyro lane, no trackpads, no HD haptics, no Valve button prompts. The ids alone are not what earns that treatment; the device behind them is.
So the persona presents the device, reproduced from a real unit's lsusb dump rather than assembled, because the identity Steam inspects is the whole configuration and not just the controller interface: bcdDevice 3.00, product string Steam Controller, a serial string at index 3, bmAttributes 0x80, wTotalLength 150 and five interfaces. Mouse on 0 (endpoint 0x81), keyboard on 1 (0x82, boot protocol), the vendor-page controller on 2 (06 FF FF, 0x83, 64-byte input and feature reports), then an Interface Association Descriptor and the CDC ACM pair on 3 and 4. An earlier revision of this page described a three-interface device with bcdDevice 2.00 and the product string Steam Deck Controller, taken from a different unit's dump with the CDC pair dropped. Steam claimed that device, read its attributes and named it, and then decoded every input as zero.
The interrogation¶
Steam does not simply read input from a Deck. It asks the device what it is, over the feature-report channel, and the Deck's protocol declares no report ids at all: the host writes a message with SET_REPORT(Feature) and reads its answer with GET_REPORT(Feature). ID_GET_ATTRIBUTES_VALUES (0x83) returns a block of (tag, u32) records: the product id, the firmware build time, the board revision, and the connection interval Steam uses to pace its own reads.
That is what featureStubs answers, keyed match: "lastMessage" so a lookup follows the message the preceding write carried. The shipped values come from a capture of a real Deck answering a real Steam client, with the connection interval matching the persona's own 4 ms endpoint rather than being copied blindly.
Driving it¶
The persona's input report is the 64-byte Neptune frame (ID_CONTROLLER_DECK_STATE, type 0x09), and extendedReport packs it from SteamDeckStatePacket_t, so an ordinary SubmitState call drives it. That matters more than it sounds: a profile whose descriptor is an opaque vendor blob declares no axes, and every layer that keys off declared axes will quietly emit nothing rather than fail. The profile must also set alwaysArmed, or the encoder is built and never armed and SubmitState falls through to the descriptor-driven builder, which has nothing to fill. Consumers can still submit the frame whole through SubmitRawReport. Steam's rumble (0xEB), haptic (0xEA) and pulse (0x8F) writes arrive as HID feature output on OutputReceived.
The real device also exposes a CDC ACM debug serial pair, which the persona omits: it carries no controller data and nothing in the input stack reads it.
The 2015 Steam Controller¶
steam-controller-composite is the wired D0G at 28DE:1102, and it presents three interfaces for a blunter reason than the Deck's: SDL's driver refuses the pad on any interface but number 2. A single-interface profile carrying the same descriptor and the same ids is skipped outright. So the persona reproduces the real unit's whole configuration from its lsusb dump, wTotalLength 0x54 and all: keyboard on interface 0 (endpoint 0x81, 8 bytes, bInterval 10), mouse on interface 1 (0x82, 4 bytes, interval 6), and the controller on interface 2 (0x83, 64 bytes, interval 6), which is where the driver looks.
Every descriptor is verbatim from a real unit: the device and configuration blobs, the 63-byte keyboard and 56-byte mouse report descriptors its lizard mode drives, and the 33-byte vendor-page controller descriptor. Its input frame is ValveControllerStatePacket_t. The pad has one stick and the wire carries no separate stick field: with STEAM_LEFTPAD_FINGERDOWN clear, SDL's FormatStatePacketUntilGyro reads sLeftPadX/Y as the joystick, so that is where the stick is written. Its analog triggers come from the 8-bit values inside the ButtonTriggerData union, which SDL remaps against 26000 rather than full scale, not from the redundant 16-bit copy further down the packet.
Its featureStubs answer the same 0x83 interrogation the Deck's do, since both speak the same Valve protocol with no report ids, keyed off the message the preceding SET_REPORT carried. The attribute block carries three records in the order two real Valve captures use: the product id, a zero capabilities word (zero on both of those real devices too), and the 9000 µs connection interval, which is SDL's documented fallback for this family and what it turns into the gyro and accelerometer sample rate.
Three records, not six. A real block also carries a firmware build timestamp, a bootloader build timestamp and a board revision, and no public capture of a 2015 unit's exists. Those are absent rather than invented: SDL ignores all three, and a wrong firmware timestamp is what makes Steam re-ask forever.
The 2026 Steam Controller¶
steam-controller-2 is the wired unit at 28DE:1302, the one SDL calls Triton. It is built differently from both of the above: one HID interface carries everything, addressed by report id rather than split across interfaces. The mouse (0x40) and keyboard (0x41) reports its lizard mode drives live in the same descriptor as the 53-byte controller state (0x42), the BLE state (0x45), battery (0x43), wireless status (0x79), the 0x80–0x89 haptic and rumble output reports, and a 63-byte command channel on feature reports 1 and 2. The command channel is why this persona's featureStubs set "messageByte": 1: the message id sits after the feature report id rather than at byte 0.
The 372-byte descriptor is verbatim from OpenPuck's ReversePuckFirmware, an emulation of this controller that a live Steam client already accepts, and every report id in it matches SDL's own ID_TRITON_* constants.
The feature answers are grounded on two independent reads of real hardware that agree record for record: OpenPuck's identity.cpp, and sc2-research, which logs the same five attribute tags in the same order off a live controller and matches Steam's own firmware-update JSON on two of the values. The 0x83 block is those 25 bytes: product id 0x1302, a zero capabilities word, the bootloader and firmware build timestamps, and the board revision. Steam validates them byte for byte, so none of them is a guess.
The firmware build is the one value that moves. Steam's updater, bin\hardwareupdater\hardwareupdater.exe in its install, offers an update to any unit whose build differs from the one it ships, so a fixed capture goes stale with Valve's next firmware release. With Steam installed, the persona answers with the build TRITON_FW_TS names in the updater's hardwareupdater.cfg, which is what a real unit reports once it takes that update. The file is read on each request, so a Steam update that ships new firmware is followed without recreating the controller. Without Steam, the captured build stands. Battery scenario S62 runs Steam's updater against the persona and requires it to find no update.
0xAE reads a string by index, and the persona declares one answer per index the real device provisions: the board serial, the unit serial, and the constant at index 3 that Steam checks. Every other index reads back 0xFF where the index would be, which is how a real unit says a string is not provisioned, and the byte Steam's own updater tests.
Its input frame is the 54-byte TritonMTUFull_t on report 0x42, packed by extendedReport so an ordinary SubmitState drives it. Consumers can still submit it whole through SubmitRawReport. As of v2.0.0 the frame's timestamp counts elapsed microseconds, clock: "accumulate" with divisor 3 over the SDK's 1/3 microsecond ticks, and holds still while the IMU is off, as a real Triton's does: the field's holdWhile names the imuMode register the profile's protocol sets from the host's settings write. A port reset keeps the IMU setting, and an attach and LOAD_DEFAULT_SETTINGS clear it. Through v1.12.0 it added 4000 per report whatever the time. Steam's haptic writes arrive on OutputReceived as output reports 0x80 and up.
SDL binds Triton on any interface of a wired unit, so nothing here depends on interface numbering the way the 2015 controller does.
All three report on their own clock, as the hardware does: every 4 ms for the Deck and the 2026 controller and every 8 ms for the 2015 one, whether or not the consumer has anything new to send. While the consumer is quiet, the persona repeats the consumer's last frame, raw bytes included. SDL's Deck driver reads once with a 16 ms timeout before it accepts a Deck, so a persona that fell silent would be passed over.
Using them without Steam¶
These pads speak only Valve's vendor protocol. Nothing generic reads it: not DirectInput, not XInput, not joy.cpl. That is equally true of real hardware, which is what lizard mode exists to cover. What makes a real Steam Controller work with Steam closed is SDL, which implements the protocol directly and turns lizard mode off itself.
The personas never emit keyboard or mouse reports at all, so they sit permanently in the gamepad state and nothing needs disabling. An SDL application reads them with Steam not running.
Battery scenario S52 proves this without a client: it creates each persona, drives it through SubmitState, reads the frame back off the real HID stack, and decodes it with SDL's own per-device arithmetic, asserting both stick extremes and a trigger pulled and released. It also measures each persona's clock and feeds it raw frames, asserting that only the consumer's frames reach the wire. ValveWireCheck.exe --monitor <persona> runs the same path interactively and prints what an SDL application would read.
A third kind of persona: the Switch 2 Pro Controller¶
switch2-pro-controller-composite uses the backend for a third reason: the host has to talk to the pad before it sends anything. A Switch 2 Pro Controller has HID on interface 0 and a vendor bulk pair on interface 1, and Steam and SDL start it with commands on the second one. The persona answers those commands, and Windows binds WinUSB to interface 1 from the persona's own Microsoft OS 1.0 descriptors. See Switch 2 Pro with Motion.