Skip to content

DSU Protocol Implementation

hifihedgehog edited this page Mar 3, 2026 · 8 revisions

DSU Protocol Implementation

PadForge implements the cemuhook DSU (DualShock UDP) protocol to stream controller motion data (gyroscope and accelerometer) to emulators. The server is compatible with Cemu, Dolphin, Yuzu, Ryujinx, and any client supporting the cemuhook protocol.

File: PadForge.App/Services/DsuMotionServer.cs Namespace: PadForge.Services Protocol Spec: https://github.com/v1993/cemuhook-protocol


MotionSnapshot

public struct MotionSnapshot
{
    public float AccelX, AccelY, AccelZ;
    public float GyroPitch, GyroYaw, GyroRoll;
    public long TimestampUs;
    public bool HasMotion;
}

Snapshot of a single slot's motion data, ready for DSU transmission. Units are already converted to DSU conventions:

  • Accel: g-force (1g = 9.80665 m/s^2)
  • Gyro: degrees per second

SDL-to-DSU Axis Mapping

AccelX  = -ax     (inverted)
AccelY  = -ay     (inverted)
AccelZ  = -az     (inverted)
GyroPitch = -gx   (inverted)
GyroYaw   =  gy   (not inverted)
GyroRoll  = -gz   (inverted)

DS4 uses inverted signs versus SDL's standard coordinate system. This mapping was derived from Switch Pro Controller's known-working BetterJoy-to-DSU mapping, translated through SDL standard coordinates. Verified with DualSense: all axes match DS4/DSU convention perfectly across both DualSense and Switch 2 Pro Controller.

Key insight: Accel and gyro must be in the same coordinate frame. AccelX and GyroPitch must reference the same physical axis.


DsuMotionServer

public sealed class DsuMotionServer : IDisposable

Constants

Constant Value Description
MaxSlots 4 DSU protocol slot limit (slots 4-15 skip DSU broadcast)
ProtocolVersion 1001 cemuhook protocol version
HeaderSize 16 Packet header size in bytes
MsgTypeVersion 0x100000 Server -> client version response
MsgTypeControllerInfo 0x100001 Server -> client controller info
MsgTypePadData 0x100002 Server -> client pad data
ClientTimeoutMs 5000 Client subscription expiry (5 seconds)
SIO_UDP_CONNRESET 0x9800000C IOControl to suppress ICMP port-unreachable resets

State

Field Type Description
_socket Socket UDP socket bound to loopback
_receiveThread Thread Background receive loop thread
_running volatile bool Server running flag
_serverId uint Unique server ID (from Environment.TickCount)
_port int Listening port
_packetCounters uint[4] Per-slot packet counter for protocol
_subscriptions Dictionary<(EndPoint, int), long> Per-slot client subscriptions with timestamp
_allSlotSubscriptions Dictionary<EndPoint, long> All-slot client subscriptions
_slotConnected bool[4] Per-slot connection state
_slotHasMotion bool[4] Per-slot motion capability

Events

public event EventHandler<string> StatusChanged;

Raised when server status changes. Values: "Listening on :{port}", "Port {port} in use", "Failed to start", "Stopped".

Lifecycle

public bool Start(int port = 26760)
  1. Creates UDP socket on IPAddress.Loopback.
  2. Applies SIO_UDP_CONNRESET IOControl to suppress ICMP port-unreachable exceptions on Windows.
  3. Binds to port and starts background receive thread (PadForge.DsuServer).
  4. Returns false if port is already in use (SocketError.AddressAlreadyInUse).
public void Stop()

Sets _running = false, closes socket, joins receive thread (2s timeout), clears subscriptions and packet counters.

Public API

public void BroadcastMotion(int slot, MotionSnapshot snapshot, bool connected)

Called from the InputManager polling thread at ~1000Hz. Updates slot state and broadcasts motion data to all subscribed clients for the given slot. Only builds and sends packets if there are active subscribers (via GetSubscribers()).


Packet Format

Header (16 bytes, all messages)

Offset  Size  Field
[0..3]  4     Magic: "DSUS" (server->client) or "DSUC" (client->server)
[4..5]  2     Protocol version (1001)
[6..7]  2     Payload length (excluding header)
[8..11] 4     CRC32 (zeroed before computation)
[12..15] 4    Server ID (server->client) or Client ID (client->server)
private void WriteHeader(byte[] packet, int payloadLength, uint msgType)

Message Types

Version Request (Client -> Server)

Message type: 0x100000

No additional payload beyond the message type.

Version Response (Server -> Client)

Offset (payload)  Size  Field
[+0..3]           4     Message type (0x100000)
[+4..5]           2     Protocol version (1001)
[+6..7]           2     Padding (zero)

Total payload: 8 bytes.

Controller Info Request (Client -> Server)

Offset (payload)  Size  Field
[+0..3]           4     Message type (0x100001)
[+4..7]           4     Number of ports requested
[+8..N]           N     Slot indices (one byte each)

Controller Info Response (Server -> Client)

Offset (payload)  Size  Field
[+0..3]           4     Message type (0x100001)
[+4]              1     Slot number
[+5]              1     Slot state (0=not connected, 2=connected)
[+6]              1     Device model (0=N/A, 2=full gyro)
[+7]              1     Connection type (0=N/A)
[+8..13]          6     MAC address (fake, unique per slot: 00:00:00:00:00:{slot})
[+14]             1     Battery status (0x05 = charged)
[+15]             1     Padding

Total payload: 16 bytes.

Pad Data Request / Subscription (Client -> Server)

Offset (payload)  Size  Field
[+0..3]           4     Message type (0x100002)
[+4]              1     Flags (0=all pads, 0x01=by slot ID, 0x02=by MAC)
[+5]              1     Slot number
[+6..11]          6     MAC address

Pad Data Response (Server -> Client)

The largest message type. Contains controller info header, button state, analog inputs, touch data, and motion sensor data.

Offset (payload)  Size  Field
[+0..3]           4     Message type (0x100002)
[+4]              1     Slot number
[+5]              1     Slot state (0=disconnected, 2=connected)
[+6]              1     Device model (0=N/A, 2=full gyro)
[+7]              1     Connection type (0=N/A)
[+8..13]          6     MAC address
[+14]             1     Battery status (0x05)
[+15]             1     Connected flag (1=connected)
[+16..19]         4     Packet counter (uint32, incremented per packet)
[+20]             1     Buttons bitmask 1 (DPad + Options + L3/R3 + Share)
[+21]             1     Buttons bitmask 2 (face + shoulders + triggers)
[+22]             1     Home button
[+23]             1     Touch button
[+24]             1     Left stick X (0-255, 128=center)
[+25]             1     Left stick Y (0-255, 128=center)
[+26]             1     Right stick X
[+27]             1     Right stick Y
[+28..31]         4     Analog D-Pad (left, down, right, up)
[+32..39]         8     Analog buttons
[+40..45]         6     Touch 1 data (active, id, x16, y16)
[+46..51]         6     Touch 2 data
[+52..59]         8     Motion timestamp (uint64, microseconds)
[+60..63]         4     Accel X (float, little-endian)
[+64..67]         4     Accel Y (float)
[+68..71]         4     Accel Z (float)
[+72..75]         4     Gyro Pitch (float)
[+76..79]         4     Gyro Yaw (float)
[+80..83]         4     Gyro Roll (float)

Total payload: 84 bytes (4 bytes message type + 80 bytes data).

Note: PadForge is a motion-only server. Button bitmasks, analog sticks, D-pad, analog buttons, and touch data are all zeroed (sticks centered at 128). Only the motion timestamp and accel/gyro fields carry real data.


Subscription Management

private List<EndPoint> GetSubscribers(int slot)

Returns a list of endpoints subscribed to the given slot. Maintains two subscription dictionaries:

  1. Per-slot subscriptions (_subscriptions): Keyed by (EndPoint, slotIndex).
  2. All-slot subscriptions (_allSlotSubscriptions): Keyed by EndPoint.

Subscription flags in pad data requests:

  • flags == 0: Subscribe to all pads.
  • flags & 0x01: Subscribe to specific slot by ID.
  • flags & 0x02: Subscribe by MAC (treated as all-slot).

Subscriptions expire after ClientTimeoutMs (5 seconds). GetSubscribers() prunes expired entries during iteration using Stopwatch.GetTimestamp() for high-resolution timing.

All subscription access is protected by lock(_subscriptions).


CRC32

private static uint ComputeCrc32(byte[] data, int length)
private static void FinalizeCrc(byte[] packet)

Standard CRC32 with polynomial 0xEDB88320. Lookup table generated at static init via GenerateCrc32Table().

To compute: zero the CRC field [8..11], compute CRC over the entire packet, write result back to [8..11].

To verify: read CRC from [8..11], zero the field, compute, compare.


Receive Loop

private void ReceiveLoop()

Background thread (IsBackground = true, name "PadForge.DsuServer"). Loops ReceiveFrom() until _running is false. Each received packet is validated:

  1. Check magic bytes: "DSUC" (client -> server).
  2. Check protocol version (must be <= 1001).
  3. Validate payload length against actual received bytes.
  4. Verify CRC32.
  5. Dispatch based on message type to HandleVersionRequest, HandleControllerInfoRequest, or HandlePadDataRequest.

Exceptions during receive are caught silently (SocketException when !_running means shutdown; ObjectDisposedException means socket closed; all others are malformed packets).


Threading Model

  • Receive thread: Background thread reading from UDP socket. Handles client requests and manages subscriptions.
  • Polling thread: InputManager thread calls BroadcastMotion() at ~1000Hz. Builds and sends pad data packets directly (no queue).
  • Synchronization: _subscriptions dictionary protected by lock. _running is volatile bool. Packet counters accessed only from the broadcast path (single caller per slot).

Slot Limits

The DSU protocol supports a maximum of 4 slots. PadForge supports up to 16 virtual controller slots, but only slots 0-3 participate in DSU broadcasts. Slots 4-15 skip DSU entirely.


DsuDiag Tool

Location: tools/DsuDiag/

Standalone DSU client diagnostic tool that connects to the DSU server and displays received motion data per-slot in real-time. Used for debugging axis mapping and verifying protocol compliance.

Clone this wiki locally