Prechádzať zdrojové kódy

Pure HID and Sinput modes (#200)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jari 2 mesiacov pred
rodič
commit
7ebbdc9aac

+ 2 - 0
ARCHITECTURE.md

@@ -81,6 +81,8 @@ just points at the `OpenPuck` directory). Modules are layered low → high:
 | `mode_switch_pro.{h,cpp}` | **Switch Pro** personality: the full Nintendo USB handshake/subcommand state machine + SPI calibration + gyro. |
 | `mode_ps5.{h,cpp}` | **PS5 DualSense** personality: gyro + split trackpad. |
 | `mode_hidgyro.{h,cpp}` | **DS4-layout** generic HID gyro personality (motion-aware PC games). |
+| `mode_dinput.{h,cpp}` | **DirectInput** personality: two joystick collections so every analog input (sticks, triggers, both trackpads, gyro) is bindable at once in flight/space sims. |
+| `mode_sinput.{h,cpp}` | **SInput** personality: the open SDL-native gamepad protocol (sticks, analog triggers, IMU, two touchpads, battery, rumble). |
 | `rf_link.{h,cpp}` | The operational puck protocol: host-frame beacons, connected-mode poll loop, `0xF1` decode + dispatch, chord detection, remote wakeup, QoS hopping, stats. |
 | `rf_diag.{h,cpp}` | RF reverse-engineering / calibration tooling: raw capture, CRC-validating config sweeps, frame replay, address listen, scan-then-respond, live-session sniffer. Not used in normal operation. |
 | `webusb_config.{h,cpp}` | The WebUSB binary config channel for the browser panel. |

+ 34 - 0
CODE_MAP.md

@@ -351,6 +351,40 @@ DS4-layout (054C:05C4) + gyro. Same structure as PS5.
 calib helper `psNeutralCalib` writes 34 bytes (buf[6..33]) while callers return 36/40
 (remainder zeroed by the prior memset).
 
+## 10b. Open / generic personalities
+
+### `mode_dinput.cpp` / `mode_dinput.h`  (`g_dinputCtl`, MODE_DINPUT)
+Generic DirectInput joystick (1209:4F50). Dynamic-mount, STREAM-style, **no report
+callbacks → no usbd-task user code** (input-only: DirectInput force feedback is the PID
+class, not the vendor rumble reports the other modes decode).
+- **Descriptor**: ONE `DI_HID_DESC` with **two top-level Application collections** →
+  Windows makes one HIDClass PDO (= one DirectInput device) per collection, which is how
+  13 analog inputs fit a format capped at 8 axes/device. Report 1 = sticks/triggers/hat +
+  26 buttons; report 2 = both trackpads + gyro XYZ + 4 pad buttons. Both 15 payload bytes.
+- **loop task**: `task()` rate-gated (`USB_STREAM_MS`), `diBuildStick` + `diBuildMotion`
+  from `g_in[bond]`, two `usbTxHid` sends per slot per tick.
+- **State**: `g_padAxis[NSLOT]` — LATCHED trackpad axes (a pad only reports while touched,
+  so the axis holds the last position and a pad CLICK re-centres it). Loop-context only.
+- Buttons are the RAW `TB_*` bits (no `psButtonsFromSteam` remap; `etypeForMode` = ET_NONE).
+
+### `mode_sinput.cpp` / `mode_sinput.h`  (`g_sinputCtl`, MODE_SINPUT)
+SInput, the open SDL-native gamepad protocol (2E8A:10C6 = the generic ID SDL's SInput
+driver matches). Dynamic-mount, STREAM-style. **Registers a set-report callback.**
+- **Wire format** (SDL `SDL_hidapi_sinput.c` + the MIT-0 reference lib): 64-byte input
+  `0x01` = plug/charge, 4 button bytes, 6×s16 sticks+triggers, u32 IMU timestamp, 6×s16
+  accel+gyro, 2×(s16 x, y, pressure) touchpads; 64-byte input `0x02` = command replies;
+  48-byte output `0x03` = `[cmd]` HAPTIC(1) / FEATURES(2) / PLAYERLED(3) / RGB(4).
+- **usbd task**: `sinSetCommon` → FEATURES sets `g_sinFeatReq[slot]`; HAPTIC type 1
+  (freq/amp pairs, louder band per side) or type 2 (ERM amplitudes) →
+  `hapticSteamRumble(lo, hi, bond)`.
+- **loop task**: `task()` answers a pending FEATURES **ahead of the stream gate** (SDL's
+  init gives up after ~100 ms) via `sinBuildFeatures` → `usbTxHid(0x02,…)`, then the
+  rate-gated `sinBuild` → `usbTxHid(0x01,…)`.
+- **Cross-task**: `g_sinFeatReq[NSLOT]` (`volatile`, set usbd / cleared loop);
+  `g_usbToBond[]` read in usbd (bounds-checked), as in the PS modes.
+- IMU passes through raw: the SInput wire frame (+x left, +y forward, +z up) IS the SC2's
+  own frame, and accel/gyro keep the SAME permutation (the fusion-handedness constraint).
+
 ### `gamepad_util.cpp` / `gamepad_util.h` — shared report-builders (called from loop task)
 `swStick`, `psNeutralCalib` (writes through `buf[33]`), trackpad→touch mappers
 (`touchPackPads` writes 8 bytes = two 4-byte points), `psButtonsFromSteam` (back-paddle/

+ 10 - 9
OpenPuck/OpenPuck.ino

@@ -66,10 +66,10 @@ using namespace Adafruit_LittleFS_Namespace;
 static uint8_t g_usbCfgDesc[512];
 
 // Per-mode USB serial suffix (modes 1..MODE_MAX: X=xbox N=hori L=lizard P=swpro S=ps5 G=hidgyro Q=ps5game
-// D=ds4game 3=ps3 O=original-xbox).
+// D=ds4game 3=ps3 O=original-xbox J=dinput I=sinput).
 // 'C' is reserved for the CDC mode, see puck_hid.cpp
-static const char MODE_SUFFIX[] = { 'X', 'N', 'L', 'P', 'S',
-				    'G', 'Q', 'D', '3', 'O' };
+static const char MODE_SUFFIX[] = { 'X', 'N', 'L', 'P', 'S', 'G',
+				    'Q', 'D', '3', 'O', 'J', 'I' };
 // Fixed-interface flags captured at boot so usbReenumerate (dynamic mount, no reboot) replays them.
 static bool s_dynWantWebusb = false, s_dynWantWakeMouse = false;
 
@@ -270,12 +270,13 @@ void setup()
 	webusbInit(); // also drain the WebUSB status blob from the usbd task (its flush() can block loop() too)
 	hapticInit();
 	static const char *MODE_NAME[] = {
-		"STEAM(puck)",		"XBOX(xinput+mouse)",
-		"SWITCH(horipad)",	"LIZARD(puck kb/mouse)",
-		"SWITCH(pro+gyro)",	"PS5(dualsense)",
-		"HIDGYRO(ds4+motion)",	"PS5(dualsense,game/clean)",
-		"DS4(ds4,game/clean)",	"PS3(dualshock3/sixaxis)",
-		"XBOX-OG(controller s)"
+		"STEAM(puck)",		 "XBOX(xinput+mouse)",
+		"SWITCH(horipad)",	 "LIZARD(puck kb/mouse)",
+		"SWITCH(pro+gyro)",	 "PS5(dualsense)",
+		"HIDGYRO(ds4+motion)",	 "PS5(dualsense,game/clean)",
+		"DS4(ds4,game/clean)",	 "PS3(dualshock3/sixaxis)",
+		"XBOX-OG(controller s)", "DINPUT(joystick+motion)",
+		"SINPUT(sdl-native)"
 	};
 	Serial.printf("# copycat up: unit=%s board=%s, mode=%s\n", g_unit,
 		      g_board,

+ 11 - 2
OpenPuck/config.h

@@ -43,7 +43,13 @@
 #define MODE_PS3 9
 // Microsoft Original Xbox Controller S (045E:0289)
 #define MODE_XBOX_OG 10
-#define MODE_MAX 10
+// Generic DirectInput joystick -- presents EVERY analog input at once (sticks, triggers, both trackpads, gyro)
+// as two DirectInput devices, for flight/space sims that bind axes through DirectInput rather than XInput.
+#define MODE_DINPUT 11
+// SInput: the open SDL-native gamepad protocol (docs.handheldlegend.com/s/sinput). Sticks + analog triggers +
+// gyro/accel + BOTH trackpads + battery, all bound natively by SDL3 / Steam Input with no impersonation.
+#define MODE_SINPUT 12
+#define MODE_MAX 12
 
 // The two "game" personalities drop the wake-mouse + WebUSB interfaces so the device is a genuine single-HID PS
 // controller (some PC games -- e.g. Fortnite/UE GameInput -- refuse PS classification when extra interfaces are
@@ -90,7 +96,10 @@ static inline uint8_t etypeForMode(uint8_t m)
 	case MODE_PS5_GAME:
 		return ET_DS5;
 	default:
-		return ET_NONE; // Steam / Lizard
+		// Steam / Lizard forward raw input for the host to remap. DirectInput and SInput expose every
+		// physical button as its own bindable button (paddles included), so they have nothing to remap
+		// either -- binding happens in the sim / in SDL.
+		return ET_NONE;
 	}
 }
 

+ 6 - 0
OpenPuck/controllers.cpp

@@ -7,6 +7,8 @@
 #include "mode_ps5.h"
 #include "mode_hidgyro.h"
 #include "mode_ps3.h"
+#include "mode_dinput.h"
+#include "mode_sinput.h"
 #include "mode_xbox_og.h"
 
 IController *g_active = nullptr;
@@ -42,6 +44,10 @@ IController *controllerFor(uint8_t mode)
 		return &g_hidGyroCtl;
 	case MODE_PS3:
 		return &g_ps3Ctl;
+	case MODE_DINPUT:
+		return &g_dinputCtl;
+	case MODE_SINPUT:
+		return &g_sinputCtl;
 	default:
 		return &g_steamPuck;
 	}

+ 4 - 3
OpenPuck/gamepad_util.cpp

@@ -246,9 +246,10 @@ uint8_t psShouldersByte(uint32_t b, uint8_t lt, uint8_t rt)
 	return ((b & TB_LB) ? 0x01 : 0) | ((b & TB_RB) ? 0x02 : 0) |
 	       ((lt > SW_TRIG_ON || (b & 0x8000000u)) ? 0x04 : 0) |
 	       ((rt > SW_TRIG_ON || (b & 0x800000u)) ? 0x08 : 0) |
-	       ((b & TB_MENU) ? 0x10 : 0) |
-	       ((b & TB_VIEW) ? 0x20 : 0) // Menu=Options, View=Create/Share
-	       | ((b & TB_L3) ? 0x40 : 0) | ((b & TB_R3) ? 0x80 : 0);
+	       // 0x10 = Create/Share, 0x20 = Options; TB_MENU/TB_VIEW are the
+	       // Select/Start-side buttons respectively (see triton.h)
+	       ((b & TB_MENU) ? 0x10 : 0) | ((b & TB_VIEW) ? 0x20 : 0) |
+	       ((b & TB_L3) ? 0x40 : 0) | ((b & TB_R3) ? 0x80 : 0);
 }
 uint8_t psHatNibble(uint32_t b)
 {

+ 288 - 0
OpenPuck/mode_dinput.cpp

@@ -0,0 +1,288 @@
+#include "mode_dinput.h"
+#include "triton.h"
+#include "gamepad_util.h"
+#include "config.h"
+#include "usb_mount.h"
+#include "usb_tx.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+DInputController g_dinputCtl;
+
+// Two top-level Application collections, one HID interface. DirectInput can only surface 8 axes per device
+// (DIJOYSTATE2: X/Y/Z/Rx/Ry/Rz + 2 sliders), and the SC2 has 13 analog inputs -- so the exotic ones live on a
+// second collection, which Windows exposes as a second joystick (one HIDClass PDO per top-level collection):
+//
+//   report 1 "stick" device: X/Y = left stick, Rx/Ry = right stick, Z/Rz = LT/RT, hat = D-pad, buttons 1-26
+//   report 2 "motion" device: X/Y = left pad, Rx/Ry = right pad, Z/Rz/Slider = gyro X/Y/Z, buttons 1-4 = pad
+//                             click/touch
+//
+// Both reports are 15 payload bytes. Nothing here needs an OUT endpoint: DirectInput force feedback is the PID
+// class (not the vendor rumble reports our other modes decode), so this mode is input-only.
+//
+// If a host ever refuses to split the collections, device #1 still works and the fallback is to mount the two
+// collections as two separate HID interfaces per slot (halves the controller count: 2 interfaces per slot
+// against the CFG_TUD_HID budget).
+static const uint8_t DI_HID_DESC[] = {
+	// clang-format off
+	0x05, 0x01,		// Usage Page (Generic Desktop)
+	0x09, 0x04,		// Usage (Joystick)
+	0xA1, 0x01,		// Collection (Application)
+	0x85, 0x01,		//   Report ID (1)
+	0x09, 0x30,		//   Usage (X)   left stick X
+	0x09, 0x31,		//   Usage (Y)   left stick Y
+	0x09, 0x33,		//   Usage (Rx)  right stick X
+	0x09, 0x34,		//   Usage (Ry)  right stick Y
+	0x16, 0x00, 0x80,	//   Logical Minimum (-32768)
+	0x26, 0xFF, 0x7F,	//   Logical Maximum (32767)
+	0x75, 0x10,		//   Report Size (16)
+	0x95, 0x04,		//   Report Count (4)
+	0x81, 0x02,		//   Input (Data,Var,Abs)
+	0x09, 0x32,		//   Usage (Z)   left trigger
+	0x09, 0x35,		//   Usage (Rz)  right trigger
+	0x15, 0x00,		//   Logical Minimum (0)
+	0x26, 0xFF, 0x00,	//   Logical Maximum (255)
+	0x75, 0x08,		//   Report Size (8)
+	0x95, 0x02,		//   Report Count (2)
+	0x81, 0x02,		//   Input (Data,Var,Abs)
+	0x09, 0x39,		//   Usage (Hat switch)
+	0x15, 0x00,		//   Logical Minimum (0)
+	0x25, 0x07,		//   Logical Maximum (7)
+	0x35, 0x00,		//   Physical Minimum (0)
+	0x46, 0x3B, 0x01,	//   Physical Maximum (315)
+	0x65, 0x14,		//   Unit (English rotation, degrees)
+	0x75, 0x04,		//   Report Size (4)
+	0x95, 0x01,		//   Report Count (1)
+	// Null State: a 4-bit value outside 0..7 (we send 8) reads as "hat centred"
+	0x81, 0x42,		//   Input (Data,Var,Abs,Null State)
+	0x65, 0x00,		//   Unit (None)
+	0x75, 0x04,		//   Report Size (4)
+	0x95, 0x01,		//   Report Count (1)
+	0x81, 0x03,		//   Input (Const) -- pad the hat byte
+	0x05, 0x09,		//   Usage Page (Button)
+	0x19, 0x01,		//   Usage Minimum (Button 1)
+	0x29, 0x1A,		//   Usage Maximum (Button 26)
+	0x15, 0x00,		//   Logical Minimum (0)
+	0x25, 0x01,		//   Logical Maximum (1)
+	0x75, 0x01,		//   Report Size (1)
+	0x95, 0x1A,		//   Report Count (26)
+	0x81, 0x02,		//   Input (Data,Var,Abs)
+	0x75, 0x01,		//   Report Size (1)
+	0x95, 0x06,		//   Report Count (6)
+	0x81, 0x03,		//   Input (Const) -- pad to 4 button bytes
+	0xC0,			// End Collection
+
+	0x05, 0x01,		// Usage Page (Generic Desktop)
+	0x09, 0x04,		// Usage (Joystick)
+	0xA1, 0x01,		// Collection (Application)
+	0x85, 0x02,		//   Report ID (2)
+	0x09, 0x30,		//   Usage (X)      left pad X
+	0x09, 0x31,		//   Usage (Y)      left pad Y
+	0x09, 0x33,		//   Usage (Rx)     right pad X
+	0x09, 0x34,		//   Usage (Ry)     right pad Y
+	0x09, 0x32,		//   Usage (Z)      gyro X
+	0x09, 0x35,		//   Usage (Rz)     gyro Y
+	0x09, 0x36,		//   Usage (Slider) gyro Z
+	0x16, 0x00, 0x80,	//   Logical Minimum (-32768)
+	0x26, 0xFF, 0x7F,	//   Logical Maximum (32767)
+	0x75, 0x10,		//   Report Size (16)
+	0x95, 0x07,		//   Report Count (7)
+	0x81, 0x02,		//   Input (Data,Var,Abs)
+	0x05, 0x09,		//   Usage Page (Button)
+	0x19, 0x01,		//   Usage Minimum (Button 1)
+	0x29, 0x04,		//   Usage Maximum (Button 4)
+	0x15, 0x00,		//   Logical Minimum (0)
+	0x25, 0x01,		//   Logical Maximum (1)
+	0x75, 0x01,		//   Report Size (1)
+	0x95, 0x04,		//   Report Count (4)
+	0x81, 0x02,		//   Input (Data,Var,Abs)
+	0x75, 0x04,		//   Report Size (4)
+	0x95, 0x01,		//   Report Count (1)
+	0x81, 0x03,		//   Input (Const) -- pad to a whole byte
+	0xC0			// End Collection
+	// clang-format on
+};
+
+#define DI_RID_STICK 0x01
+#define DI_RID_MOTION 0x02
+#define DI_REPLEN 15
+
+static Adafruit_USBD_HID g_di[NSLOT];
+static unsigned long g_diLastMs[NSLOT] = { 0 };
+
+// Latched trackpad axes. A pad only reports a position while it is touched, so a raw pass-through would snap
+// the axis to centre the moment the finger lifts -- useless for the throttle/trim bindings sim users want. The
+// axis therefore HOLDS the last touched position; a pad CLICK re-centres it (the deliberate reset gesture).
+struct PadAxis {
+	int16_t lx, ly, rx, ry;
+};
+static PadAxis g_padAxis[NSLOT];
+
+// HID axes are down-positive; the SC2 sticks/pads are up-positive. Negating INT16_MIN overflows, so clamp.
+static inline int16_t diInv(int16_t v)
+{
+	return (int16_t)(v == INT16_MIN ? INT16_MAX : -v);
+}
+
+// Buttons 1..26, raw SC2 bits -- no back-paddle/QAM remap: DirectInput bindings are done in the sim, and a
+// paddle that duplicates a face button would just cost the user two bindable inputs.
+static uint32_t diButtons(uint32_t b)
+{
+	uint32_t o = 0;
+	if (b & TB_A)
+		o |= 1u << 0;
+	if (b & TB_B)
+		o |= 1u << 1;
+	if (b & TB_X)
+		o |= 1u << 2;
+	if (b & TB_Y)
+		o |= 1u << 3;
+	if (b & TB_LB)
+		o |= 1u << 4;
+	if (b & TB_RB)
+		o |= 1u << 5;
+	if (b & TB_L2)
+		o |= 1u << 6;
+	if (b & TB_R2)
+		o |= 1u << 7;
+	// button 9 = Start-side, button 10 = Select-side (TB_VIEW/TB_MENU are named backwards -- see triton.h)
+	if (b & TB_VIEW)
+		o |= 1u << 8;
+	if (b & TB_MENU)
+		o |= 1u << 9;
+	if (b & TB_L3)
+		o |= 1u << 10;
+	if (b & TB_R3)
+		o |= 1u << 11;
+	if (b & TB_DUP)
+		o |= 1u << 12;
+	if (b & TB_DDN)
+		o |= 1u << 13;
+	if (b & TB_DLF)
+		o |= 1u << 14;
+	if (b & TB_DRT)
+		o |= 1u << 15;
+	if (b & TB_L4)
+		o |= 1u << 16;
+	if (b & TB_R4)
+		o |= 1u << 17;
+	if (b & TB_L5)
+		o |= 1u << 18;
+	if (b & TB_R5)
+		o |= 1u << 19;
+	if (b & TB_LPADC)
+		o |= 1u << 20;
+	if (b & TB_RPADC)
+		o |= 1u << 21;
+	if (b & TB_LPADT)
+		o |= 1u << 22;
+	if (b & TB_RPADT)
+		o |= 1u << 23;
+	if (b & TB_STEAM)
+		o |= 1u << 24;
+	if (b & TB_QAM)
+		o |= 1u << 25;
+	return o;
+}
+
+static void diBuildStick(uint8_t bond, uint8_t out[DI_REPLEN])
+{
+	const PuckInput &in = g_in[bond];
+	memset(out, 0, DI_REPLEN);
+	le16(out + 0, in.lx);
+	le16(out + 2, diInv(in.ly));
+	le16(out + 4, in.rx);
+	le16(out + 6, diInv(in.ry));
+	out[8] = in.lt;
+	out[9] = in.rt;
+	out[10] = psHatNibble(in.buttons);
+	uint32_t btn = diButtons(in.buttons);
+	out[11] = (uint8_t)(btn & 0xFF);
+	out[12] = (uint8_t)((btn >> 8) & 0xFF);
+	out[13] = (uint8_t)((btn >> 16) & 0xFF);
+	out[14] = (uint8_t)((btn >> 24) & 0xFF);
+}
+
+static void diBuildMotion(uint8_t bond, uint8_t out[DI_REPLEN])
+{
+	const PuckInput &in = g_in[bond];
+	PadAxis &p = g_padAxis[bond];
+	if (in.buttons & TB_LPADC) {
+		p.lx = 0;
+		p.ly = 0;
+	} else if (in.buttons & TB_LPADT) {
+		p.lx = in.lpx;
+		p.ly = in.lpy;
+	}
+	if (in.buttons & TB_RPADC) {
+		p.rx = 0;
+		p.ry = 0;
+	} else if (in.buttons & TB_RPADT) {
+		p.rx = in.rpx;
+		p.ry = in.rpy;
+	}
+	memset(out, 0, DI_REPLEN);
+	le16(out + 0, p.lx);
+	le16(out + 2, diInv(p.ly));
+	le16(out + 4, p.rx);
+	le16(out + 6, diInv(p.ry));
+	le16(out + 8, in.gx);
+	le16(out + 10, in.gy);
+	le16(out + 12, in.gz);
+	out[14] = (uint8_t)(((in.buttons & TB_LPADC) ? 0x01 : 0) |
+			    ((in.buttons & TB_RPADC) ? 0x02 : 0) |
+			    ((in.buttons & TB_LPADT) ? 0x04 : 0) |
+			    ((in.buttons & TB_RPADT) ? 0x08 : 0));
+}
+
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
+void DInputController::begin()
+{
+}
+uint8_t DInputController::maxSlots() const
+{
+	uint8_t cap = (uint8_t)(CFG_TUD_HID - 1); // wake mouse holds HID 0
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void DInputController::usbIdentity()
+{
+	// Deliberately NOT a cloned identity: nothing about DirectInput keys off VID/PID, and a generic ID keeps
+	// Steam Input / the console whitelists from claiming the device out from under the sim.
+	USBDevice.setID(0x1209, 0x4F50); // pid.codes community VID, "OP"
+	USBDevice.setDeviceVersion(0x0100);
+	USBDevice.setManufacturerDescriptor("OpenPuck");
+	USBDevice.setProductDescriptor("OpenPuck Joystick");
+}
+void DInputController::beginPool()
+{
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
+		g_di[s].setReportDescriptor(DI_HID_DESC, sizeof DI_HID_DESC);
+		g_di[s].setPollInterval(1);
+		g_di[s].begin();
+	}
+}
+void DInputController::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_di[u]);
+}
+void DInputController::task()
+{
+	for (uint8_t u = 0; u < g_usbMountCount; u++) {
+		if (!g_di[u].ready())
+			continue;
+		if (millis() - g_diLastMs[u] < USB_STREAM_MS)
+			continue;
+		int bond = g_usbToBond[u];
+		if (bond < 0)
+			continue;
+		g_diLastMs[u] = millis();
+		uint8_t p[DI_REPLEN];
+		diBuildStick((uint8_t)bond, p);
+		usbTxHid(&g_di[u], DI_RID_STICK, p, sizeof p);
+		diBuildMotion((uint8_t)bond, p);
+		usbTxHid(&g_di[u], DI_RID_MOTION, p, sizeof p);
+	}
+}

+ 24 - 0
OpenPuck/mode_dinput.h

@@ -0,0 +1,24 @@
+// mode_dinput.h -- generic DirectInput joystick personality (MODE_DINPUT).
+//
+// Flight/space sims bind axes through DirectInput, which is limited to 8 axes per device (DIJOYSTATE2), while
+// the SC2 offers 13 analog inputs. This mode therefore presents each controller as TWO joysticks (two top-level
+// HID collections on one interface -- Windows creates one HIDClass PDO, and thus one DirectInput device, per
+// top-level collection) so every axis is bindable at the same time. See mode_dinput.cpp for the axis map.
+#pragma once
+#include "controllers.h"
+
+class DInputController : public IController {
+    public:
+	void begin() override;
+	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
+};
+
+extern DInputController g_dinputCtl;

+ 384 - 0
OpenPuck/mode_sinput.cpp

@@ -0,0 +1,384 @@
+#include "mode_sinput.h"
+#include "triton.h"
+#include "gamepad_util.h"
+#include "config.h"
+#include "haptics.h"
+#include "bonds.h"
+#include "identity.h"
+#include "rf_link.h" // g_battery / g_batteryState
+#include "usb_mount.h"
+#include "usb_tx.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+SInputController g_sinputCtl;
+
+// Report descriptor per the SInput reference implementation (HandHeldLegend/SINPUT-LIB-HID, MIT-0): three
+// reports on one interface -- 64-byte input 0x01 (gamepad state), 64-byte input 0x02 (command replies),
+// 48-byte output 0x03 (haptics + commands). SDL talks to the device through hidapi, so the descriptor only has
+// to make the host expose those three sizes; the field usages are cosmetic padding around the wire layout.
+static const uint8_t SIN_HID_DESC[] = {
+	// clang-format off
+	0x05, 0x01,		// Usage Page (Generic Desktop)
+	0x09, 0x05,		// Usage (Gamepad)
+	0xA1, 0x01,		// Collection (Application)
+	0x85, 0x01,		//   Report ID (1)
+	0x06, 0x00, 0xFF,	//   Usage Page (Vendor Defined)
+	0x09, 0x01,		//   Usage (Vendor 1) -- plug status + charge percent
+	0x15, 0x00,		//   Logical Minimum (0)
+	0x25, 0xFF,		//   Logical Maximum (255)
+	0x75, 0x08,		//   Report Size (8)
+	0x95, 0x02,		//   Report Count (2)
+	0x81, 0x02,		//   Input (Data,Var,Abs)
+	0x05, 0x09,		//   Usage Page (Button)
+	0x19, 0x01,		//   Usage Minimum (Button 1)
+	0x29, 0x20,		//   Usage Maximum (Button 32)
+	0x15, 0x00,		//   Logical Minimum (0)
+	0x25, 0x01,		//   Logical Maximum (1)
+	0x75, 0x01,		//   Report Size (1)
+	0x95, 0x20,		//   Report Count (32)
+	0x81, 0x02,		//   Input (Data,Var,Abs)
+	0x05, 0x01,		//   Usage Page (Generic Desktop)
+	0x09, 0x30,		//   Usage (X)   left stick X
+	0x09, 0x31,		//   Usage (Y)   left stick Y
+	0x09, 0x32,		//   Usage (Z)   right stick X
+	0x09, 0x35,		//   Usage (Rz)  right stick Y
+	0x09, 0x33,		//   Usage (Rx)  left trigger
+	0x09, 0x34,		//   Usage (Ry)  right trigger
+	0x16, 0x00, 0x80,	//   Logical Minimum (-32768)
+	0x26, 0xFF, 0x7F,	//   Logical Maximum (32767)
+	0x75, 0x10,		//   Report Size (16)
+	0x95, 0x06,		//   Report Count (6)
+	0x81, 0x02,		//   Input (Data,Var,Abs)
+	0x06, 0x00, 0xFF,	//   Usage Page (Vendor Defined)
+	0x09, 0x20,		//   Usage (Vendor 0x20) -- IMU timestamp (us)
+	0x15, 0x00,		//   Logical Minimum (0)
+	0x26, 0xFF, 0xFF,	//   Logical Maximum (65535)
+	0x75, 0x20,		//   Report Size (32)
+	0x95, 0x01,		//   Report Count (1)
+	0x81, 0x02,		//   Input (Data,Var,Abs)
+	0x09, 0x21,		//   Usage (Vendor 0x21) -- accel XYZ + gyro XYZ
+	0x16, 0x00, 0x80,	//   Logical Minimum (-32768)
+	0x26, 0xFF, 0x7F,	//   Logical Maximum (32767)
+	0x75, 0x10,		//   Report Size (16)
+	0x95, 0x06,		//   Report Count (6)
+	0x81, 0x02,		//   Input (Data,Var,Abs)
+	0x09, 0x22,		//   Usage (Vendor 0x22) -- touchpads + reserved
+	0x15, 0x00,		//   Logical Minimum (0)
+	0x26, 0xFF, 0x00,	//   Logical Maximum (255)
+	0x75, 0x08,		//   Report Size (8)
+	0x95, 0x1D,		//   Report Count (29)
+	0x81, 0x02,		//   Input (Data,Var,Abs)
+	0x85, 0x02,		//   Report ID (2) -- command replies
+	0x09, 0x23,		//   Usage (Vendor 0x23)
+	0x15, 0x00,		//   Logical Minimum (0)
+	0x26, 0xFF, 0x00,	//   Logical Maximum (255)
+	0x75, 0x08,		//   Report Size (8)
+	0x95, 0x3F,		//   Report Count (63)
+	0x81, 0x02,		//   Input (Data,Var,Abs)
+	0x85, 0x03,		//   Report ID (3) -- host commands
+	0x09, 0x24,		//   Usage (Vendor 0x24)
+	0x15, 0x00,		//   Logical Minimum (0)
+	0x26, 0xFF, 0x00,	//   Logical Maximum (255)
+	0x75, 0x08,		//   Report Size (8)
+	0x95, 0x2F,		//   Report Count (47)
+	0x91, 0x02,		//   Output (Data,Var,Abs)
+	0xC0			// End Collection
+	// clang-format on
+};
+
+#define SIN_RID_INPUT 0x01
+#define SIN_RID_CMDIN 0x02
+#define SIN_RID_CMDOUT 0x03
+#define SIN_REPLEN 63 // payload after the report id (64-byte reports)
+
+#define SIN_CMD_HAPTIC 0x01
+#define SIN_CMD_FEATURES 0x02
+#define SIN_CMD_PLAYERLED 0x03
+#define SIN_CMD_JOYSTICKRGB 0x04
+
+// SDL_GamepadType / SInput face-layout hints reported in the features reply.
+#define SIN_GAMEPAD_TYPE_STEAM 12
+#define SIN_FACE_STYLE_ABXY 1
+
+// SC2 accelerometer is +/-2g full scale (16384 counts/g -- see mode_switch_pro.cpp). The gyro full scale is
+// not reverse-engineered; declare the SInput default so SDL's rad/s conversion is in the right ballpark.
+#define SIN_ACCEL_G_RANGE 2
+#define SIN_GYRO_DPS_RANGE 2000
+
+// plug_status wire values (SDL reads 1..4): 1 = no battery, 2 = charging, 3 = charged, 4 = on battery.
+#define SIN_PLUG_CHARGING 2
+#define SIN_PLUG_CHARGED 3
+#define SIN_PLUG_ON_BATTERY 4
+
+static Adafruit_USBD_HID g_sin[NSLOT];
+static unsigned long g_sinLastMs[NSLOT] = { 0 };
+
+// Set by the host's FEATURES command in the usbd callback, answered from task() in loop context (the reply is
+// an INPUT report, and every device->host send in this firmware goes through the usb_tx queue).
+static volatile uint8_t g_sinFeatReq[NSLOT] = { 0 };
+
+// Per-slot serial the host shows for the device (SDL formats it as a MAC). Derived from the board's FICR-based
+// unit id so it is stable across reboots, with the slot in the last byte so four controllers stay distinct.
+static uint8_t g_sinMac[NSLOT][6];
+static bool g_sinMacInit = false;
+static void initSinMacs()
+{
+	if (g_sinMacInit)
+		return;
+	for (uint8_t s = 0; s < NSLOT; s++) {
+		// locally-administered, unicast OUI
+		g_sinMac[s][0] = 0x02;
+		g_sinMac[s][1] = 'O';
+		g_sinMac[s][2] = 'P';
+		g_sinMac[s][3] = (uint8_t)g_unit[3];
+		g_sinMac[s][4] = (uint8_t)g_unit[4];
+		g_sinMac[s][5] = s;
+	}
+	g_sinMacInit = true;
+}
+
+static inline int16_t sinInv(int16_t v)
+{
+	return (int16_t)(v == INT16_MIN ? INT16_MAX : -v);
+}
+
+// SInput triggers are full-range signed axes: released = INT16_MIN, fully pulled = INT16_MAX.
+static inline int16_t sinTrigger(uint8_t v)
+{
+	return (int16_t)(-32768 + (int32_t)v * 257);
+}
+
+// Static capability + identity block SDL reads once at open (command 0x02). Layout: payload[0] = command id,
+// then the reference lib's out[2..] shifted down by the report id TinyUSB writes for us.
+static void sinBuildFeatures(uint8_t slot, uint8_t out[SIN_REPLEN])
+{
+	memset(out, 0, SIN_REPLEN);
+	out[0] = SIN_CMD_FEATURES;
+	out[1] = 0x01; // protocol version 0x0001
+	// flags 1: rumble, accel, gyro, both sticks, both analog triggers (no player LEDs)
+	out[3] = 0x01 | 0x04 | 0x08 | 0x10 | 0x20 | 0x40 | 0x80;
+	out[4] = 0x01; // flags 2: touchpads supported
+	out[5] = SIN_GAMEPAD_TYPE_STEAM;
+	out[6] = (uint8_t)(SIN_FACE_STYLE_ABXY << 5);
+	// polling interval: our host stream cadence, what SDL uses as the sensor rate
+	out[7] = (uint8_t)((USB_STREAM_MS * 1000u) & 0xFF);
+	out[8] = (uint8_t)((USB_STREAM_MS * 1000u) >> 8);
+	out[9] = (uint8_t)(SIN_ACCEL_G_RANGE & 0xFF);
+	out[10] = (uint8_t)(SIN_ACCEL_G_RANGE >> 8);
+	out[11] = (uint8_t)(SIN_GYRO_DPS_RANGE & 0xFF);
+	out[12] = (uint8_t)(SIN_GYRO_DPS_RANGE >> 8);
+	// Which of the 32 button bits actually exist -- SDL derives its mapping from these. ABXY + D-pad; stick
+	// clicks, bumpers, digital triggers, upper grips (L4/R4); start/select/guide/share, lower grips (L5/R5),
+	// both touchpad clicks. No power or misc buttons.
+	out[13] = 0xFF;
+	out[14] = 0xFF;
+	out[15] = 0xFF;
+	out[16] = 0x00;
+	out[17] = 2; // two touchpads
+	out[18] = 1; // one finger each
+	// Key the serial off the BOND slot, not the USB slot: the USB prefix is packed by connection order, so a
+	// USB-slot serial would follow plug order instead of staying with the controller across sessions.
+	int bond = (slot < NSLOT) ? g_usbToBond[slot] : -1;
+	memcpy(out + 19, g_sinMac[bond >= 0 ? (uint8_t)bond : 0], 6);
+}
+
+static void sinBuild(uint8_t bond, uint8_t out[SIN_REPLEN])
+{
+	const PuckInput &in = g_in[bond];
+	uint32_t b = in.buttons;
+	memset(out, 0, SIN_REPLEN);
+	// EChargeState (0x43 body[0]): 2 = charging, 4 = charge complete; anything else reads as discharging.
+	uint8_t st = g_batteryState[bond];
+	out[0] = (st == 2) ?
+			 SIN_PLUG_CHARGING :
+			 ((st == 4) ? SIN_PLUG_CHARGED : SIN_PLUG_ON_BATTERY);
+	out[1] = g_battery[bond] > 100 ? 100 : g_battery[bond];
+	// Face-button bit order follows SDL's SINPUT_BUTTONMASK_* (east, south, north, west) -- the reference
+	// lib's struct declares south, east, west, north, and the two disagree. SDL is the consumer, and its
+	// order is the one that reads correctly on hardware (the other way round swaps A/B and X/Y).
+	out[2] = (uint8_t)(((b & TB_B) ? 0x01 : 0) | // east
+			   ((b & TB_A) ? 0x02 : 0) | // south
+			   ((b & TB_Y) ? 0x04 : 0) | // north
+			   ((b & TB_X) ? 0x08 : 0) | // west
+			   ((b & TB_DUP) ? 0x10 : 0) |
+			   ((b & TB_DDN) ? 0x20 : 0) |
+			   ((b & TB_DLF) ? 0x40 : 0) |
+			   ((b & TB_DRT) ? 0x80 : 0));
+	out[3] = (uint8_t)(((b & TB_L3) ? 0x01 : 0) | ((b & TB_R3) ? 0x02 : 0) |
+			   ((b & TB_LB) ? 0x04 : 0) | ((b & TB_RB) ? 0x08 : 0) |
+			   ((b & TB_L2) ? 0x10 : 0) | // digital trigger
+			   ((b & TB_R2) ? 0x20 : 0) |
+			   ((b & TB_L4) ? 0x40 : 0) | // upper grips
+			   ((b & TB_R4) ? 0x80 : 0));
+	// TB_VIEW / TB_MENU are named backwards relative to the physical buttons (see triton.h).
+	out[4] = (uint8_t)(((b & TB_VIEW) ? 0x01 : 0) | // start
+			   ((b & TB_MENU) ? 0x02 : 0) | // select
+			   ((b & TB_STEAM) ? 0x04 : 0) | // guide
+			   ((b & TB_QAM) ? 0x08 : 0) | // share/capture
+			   ((b & TB_L5) ? 0x10 : 0) | // lower grips
+			   ((b & TB_R5) ? 0x20 : 0) |
+			   ((b & TB_LPADC) ? 0x40 : 0) |
+			   ((b & TB_RPADC) ? 0x80 : 0));
+	le16(out + 6, in.lx);
+	le16(out + 8, sinInv(in.ly)); // SDL axes are down-positive
+	le16(out + 10, in.rx);
+	le16(out + 12, sinInv(in.ry));
+	le16(out + 14, sinTrigger(in.lt));
+	le16(out + 16, sinTrigger(in.rt));
+	uint32_t us = micros();
+	out[18] = (uint8_t)(us & 0xFF);
+	out[19] = (uint8_t)((us >> 8) & 0xFF);
+	out[20] = (uint8_t)((us >> 16) & 0xFF);
+	out[21] = (uint8_t)((us >> 24) & 0xFF);
+	// The SInput wire IMU frame is (+x left, +y away from the user, +z up) -- SDL's driver rotates it into its
+	// own (+x right, +y up, +z toward the user) convention. That is the SC2's own frame (derived from the
+	// hardware-verified Switch Pro mapping in mode_switch_pro.cpp: gz = yaw about the up axis, gy = roll about
+	// the forward axis, -gx = pitch about the right axis), so both vectors pass through raw -- and, crucially,
+	// with the SAME permutation, which is what hosts that FUSE accel with gyro require.
+	le16(out + 22, in.ax);
+	le16(out + 24, in.ay);
+	le16(out + 26, in.az);
+	le16(out + 28, in.gx);
+	le16(out + 30, in.gy);
+	le16(out + 32, in.gz);
+	// Touchpads: full-scale signed coordinates (SDL normalises x/65536 + 0.5), pressure 0 = no contact.
+	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC);
+	bool rTouch = (b & TB_RPADT) || (b & TB_RPADC);
+	if (lTouch) {
+		le16(out + 34, in.lpx);
+		le16(out + 36, sinInv(in.lpy));
+		le16(out + 38, INT16_MAX);
+	}
+	if (rTouch) {
+		le16(out + 40, in.rpx);
+		le16(out + 42, sinInv(in.rpy));
+		le16(out + 44, INT16_MAX);
+	}
+}
+
+// Host command channel (output report 0x03), usbd task.
+static void sinSetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			 uint8_t const *b, uint16_t n)
+{
+	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
+		return;
+	const uint8_t *p;
+	uint16_t pn;
+	if (rid == 0) { // OUT endpoint: report id leads the buffer
+		if (b[0] != SIN_RID_CMDOUT || n < 2)
+			return;
+		p = b + 1;
+		pn = (uint16_t)(n - 1);
+	} else if (rid == SIN_RID_CMDOUT) {
+		p = b;
+		pn = n;
+	} else {
+		return;
+	}
+	if (p[0] == SIN_CMD_FEATURES) {
+		if (slot < NSLOT)
+			g_sinFeatReq[slot] = 1;
+		return;
+	}
+	if (p[0] != SIN_CMD_HAPTIC || pn < 2)
+		return; // player LED / RGB: not advertised
+	// `slot` is the USB slot the report arrived on -> route rumble to its mapped bond slot.
+	int bond = (slot < NSLOT) ? g_usbToBond[slot] : -1;
+	if (bond < 0)
+		return;
+	uint16_t lo = 0, hi = 0;
+	if (p[1] == 0x01 && pn >= 18) {
+		// stereo frequency/amplitude pairs: we can only render amplitude, so take the louder band per side
+		uint16_t l1 = (uint16_t)(p[4] | (p[5] << 8));
+		uint16_t l2 = (uint16_t)(p[8] | (p[9] << 8));
+		uint16_t r1 = (uint16_t)(p[12] | (p[13] << 8));
+		uint16_t r2 = (uint16_t)(p[16] | (p[17] << 8));
+		lo = l1 > l2 ? l1 : l2;
+		hi = r1 > r2 ? r1 : r2;
+	} else if (p[1] == 0x02 && pn >= 6) {
+		lo = (uint16_t)p[2] * 257u; // ERM model: left/right amplitude
+		hi = (uint16_t)p[4] * 257u;
+	} else {
+		return;
+	}
+	hapticSteamRumble(lo, hi,
+			  (uint8_t)bond); // SInput: left = low, right = high
+}
+// Per-slot trampolines: the Adafruit HID class shares one callback pair across instances and doesn't pass the
+// interface index, so close over the slot (same pattern as puck_hid.cpp / mode_ps5.cpp).
+#define SINPUTCB(N)                                           \
+	static void sinSet##N(uint8_t r, hid_report_type_t t, \
+			      uint8_t const *b, uint16_t n)   \
+	{                                                     \
+		sinSetCommon(N, r, t, b, n);                  \
+	}
+// clang-format off
+SINPUTCB(0)
+SINPUTCB(1)
+SINPUTCB(2)
+SINPUTCB(3)
+// clang-format on
+typedef void (*sin_setcb_t)(uint8_t, hid_report_type_t, uint8_t const *,
+			    uint16_t);
+static sin_setcb_t const SIN_SETCB[NSLOT] = { sinSet0, sinSet1, sinSet2,
+					      sinSet3 };
+
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
+void SInputController::begin()
+{
+}
+uint8_t SInputController::maxSlots() const
+{
+	uint8_t cap = (uint8_t)(CFG_TUD_HID - 1); // wake mouse holds HID 0
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void SInputController::usbIdentity()
+{
+	// The generic SInput device ID SDL's SInput driver matches on (2E8A = Raspberry Pi, the VID Hand Held
+	// Legend register their gamepads under; 10C6 = generic SInput/HOJA gamepad).
+	USBDevice.setID(0x2E8A, 0x10C6);
+	USBDevice.setDeviceVersion(0x0210);
+	USBDevice.setManufacturerDescriptor("OpenPuck");
+	USBDevice.setProductDescriptor("OpenPuck SInput");
+}
+void SInputController::beginPool()
+{
+	initSinMacs();
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
+		g_sin[s].enableOutEndpoint(true);
+		g_sin[s].setReportCallback(NULL, SIN_SETCB[s]);
+		g_sin[s].setReportDescriptor(SIN_HID_DESC, sizeof SIN_HID_DESC);
+		g_sin[s].setPollInterval(1);
+		g_sin[s].begin();
+	}
+}
+void SInputController::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_sin[u]);
+}
+void SInputController::task()
+{
+	for (uint8_t u = 0; u < g_usbMountCount; u++) {
+		if (!g_sin[u].ready())
+			continue;
+		uint8_t p[SIN_REPLEN];
+		// The features reply gates SDL's whole init (it gives up after ~100 ms), so it jumps the stream gate.
+		if (g_sinFeatReq[u]) {
+			g_sinFeatReq[u] = 0;
+			sinBuildFeatures(u, p);
+			usbTxHid(&g_sin[u], SIN_RID_CMDIN, p, sizeof p);
+		}
+		if (millis() - g_sinLastMs[u] < USB_STREAM_MS)
+			continue;
+		int bond = g_usbToBond[u];
+		if (bond < 0)
+			continue;
+		g_sinLastMs[u] = millis();
+		sinBuild((uint8_t)bond, p);
+		usbTxHid(&g_sin[u], SIN_RID_INPUT, p, sizeof p);
+	}
+}

+ 24 - 0
OpenPuck/mode_sinput.h

@@ -0,0 +1,24 @@
+// mode_sinput.h -- SInput personality (MODE_SINPUT): the open SDL-native gamepad protocol.
+//
+// SInput (Hand Held Legend, docs.handheldlegend.com/s/sinput) is a HID protocol SDL3 and Steam Input bind
+// natively via a dedicated driver, so a device can expose analog sticks, analog triggers, gyro/accel, TWO
+// touchpads and battery at once without pretending to be somebody else's controller -- which is exactly the
+// SC2's shape. Enumerates as the generic SInput device 2E8A:10C6 (the ID SDL's driver matches on).
+#pragma once
+#include "controllers.h"
+
+class SInputController : public IController {
+    public:
+	void begin() override;
+	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
+};
+
+extern SInputController g_sinputCtl;

+ 4 - 0
OpenPuck/triton.h

@@ -14,6 +14,10 @@
 #define TB_Y 0x8u
 #define TB_QAM 0x10u
 #define TB_R3 0x20u
+// TB_VIEW / TB_MENU are named BACKWARDS with respect to the physical buttons: TB_VIEW is the Start-side
+// button (Xbox Start / Switch +, Options on PlayStation) and TB_MENU is the Select-side one (Xbox Back /
+// Switch -, Create/Share). Every hardware-verified mode maps them that way (mode_xinput, mode_switch_pro,
+// psShouldersByte); reading the names literally swaps Start and Select on the host.
 #define TB_VIEW 0x40u
 #define TB_R4 0x80u
 #define TB_R5 0x100u

+ 25 - 0
README.md

@@ -41,6 +41,31 @@ Similarly you can hold all 4 back buttons and press Y to switch (teehee) over to
 | WebUSB panel → mode 6 | DS4/HIDGYRO + Gyro + Trackpad | PC only |
 | WebUSB panel → mode 9 | PS3 DualShock 3 / Sixaxis | Enumerates on a real PS3 (+ gyro/haptics) |
 | WebUSB panel → mode 10 | Original Xbox Controller S | Enumerates on a real Original Xbox |
+| WebUSB panel → mode 11 | DirectInput (flight/space sims) | Every axis at once, as two DirectInput joysticks |
+| WebUSB panel → mode 12 | SInput (SDL-native) | Sticks + analog triggers + gyro + both trackpads + battery |
+
+**DirectInput mode** exists because Steam Input funnels everything through XInput, so only a handful of the
+controller's analog inputs can be live at once — a problem for flight and space sims, which bind axes through
+DirectInput. DirectInput itself caps a device at 8 axes, so this mode presents the controller as **two**
+joysticks (two HID collections, one USB interface):
+
+| Device | Axes | Buttons |
+|---|---|---|
+| #1 | X/Y = left stick, Rx/Ry = right stick, Z/Rz = left/right trigger, hat = D-pad | 1-26: A B X Y, LB RB, LT RT, Start, Select, L3 R3, D-pad U D L R, L4 R4 L5 R5, pad clicks, pad touches, Steam, QAM |
+| #2 | X/Y = left trackpad, Rx/Ry = right trackpad, Z/Rz/Slider = gyro X/Y/Z | 1-4: left/right pad click, left/right pad touch |
+
+Trackpad axes **latch**: they hold the last touched position (so a pad works as a throttle/trim slider) and
+re-centre when you *click* that pad. No remapping is applied in this mode — every physical button, paddles
+included, is its own bindable button, since the sim does the binding. The mode is input-only: DirectInput
+force feedback is a separate HID class, so rumble is not wired up here (use another mode if you want rumble).
+DirectInput is a Windows API — on Linux/SteamOS the SInput mode below is the one that exposes everything.
+
+**SInput mode** speaks [SInput](https://docs.handheldlegend.com/s/sinput), Hand Held Legend's open gamepad
+protocol that SDL3 and Steam Input bind with a dedicated driver. It is the one mode that doesn't impersonate
+anybody: sticks, *both* analog triggers, gyro + accelerometer, **both** trackpads (as two touchpads) and the
+battery level are all reported natively and simultaneously, with rumble coming back from the host. It needs an
+SDL build that ships the SInput driver (SDL 3.4+ / a current Steam client); older hosts fall back to seeing a
+plain HID gamepad.
 
 I'm also adding various QOL items as I go as well. For example having to hold the Steam button for like 6 seconds feels like an eternity. If Steam is open you can do Steam + Y for a shutdown. I'm adding Steam + Y for 2 seconds as a shutdown chort in ALL modes now.
 

+ 32 - 0
docs/TESTING_GUIDE.md

@@ -12,3 +12,35 @@
 - Is signal quality shown?
 - Is battery level shown and correct?
 - Is the polling rate and response rate the same? Is it around 250hz?
+
+### Testing the DirectInput mode (mode 11)
+
+Windows has a built-in DirectInput tester: run `joy.cpl` (or Settings → Bluetooth & devices → Devices → More
+devices → *Set up USB game controllers*). **Two** entries should appear — that's the point of the mode; one
+carries the sticks/triggers/hat and 26 buttons, the other the trackpad and gyro axes. Open *Properties* on
+each and watch the axis crosshairs/sliders move. Note that joy.cpl's page only draws the first few axes; a
+tool like [DIView](https://github.com/pklaus/DIView) or *GamepadTester* lists every axis a device reports.
+Things to confirm:
+
+- both devices enumerate (if only one does, Windows merged the collections — see the note in `mode_dinput.cpp`)
+- trackpad axes hold their position after you lift a finger, and re-centre when you *click* that pad
+- gyro axes rest at centre and move with rotation
+- all 26 buttons register, one per physical input, with no duplicates
+
+DirectInput is Windows-only. On Linux the same descriptor shows up through evdev (`evtest`, `jstest`), but how
+the two collections are split is kernel-dependent — use SInput mode there.
+
+### Testing the SInput mode (mode 12)
+
+SInput needs a host with SDL's SInput driver (SDL 3.4+ / a current Steam client). Practical checks:
+
+- **Steam**: Settings → Controller → the device is listed; open its test/calibration page and confirm face
+  buttons land on the right glyphs (an A/B or X/Y swap means the face bit order is wrong), Start vs Select,
+  the paddles show as paddle1-4, both analog triggers move independently, gyro/accel report, and *both*
+  trackpads show as touchpads.
+- **battery**: the percentage/charging state comes from the controller's own 0x43 report, so it should match
+  what the panel shows in Steam mode.
+- **rumble**: any game/tool that rumbles (Steam's controller test has a haptic test) — SDL sends the ERM-style
+  haptic command, which the firmware turns into the usual 0x80 rumble relay.
+- **SDL directly**: SDL's own `testgamepad` (from an SDL 3.4+ build) prints axes, sensors and touchpads, and
+  is the least ambiguous way to see exactly what the driver decoded.

+ 16 - 2
docs/WEBUSB_LINUX.md

@@ -4,7 +4,7 @@ By default, not every user is allowed to access raw USB devices like the OpenPuc
 
 You can check the error log by going to `chrome://device-log/`. (or `chromium://device-log`, `edge://device-log`, ... depending on your browser.)
 
-If it shows "Failed to open /dev/bus/usb/xxx/xxx: Operation not permitted", you need to follow these instructions to give the current user raw access to Valve hardware (vendor ID 28de).
+If it shows "Failed to open /dev/bus/usb/xxx/xxx: Operation not permitted", you need to follow these instructions to give the current user raw access to the vendor IDs the OpenPuck enumerates under.
 
 
 
@@ -13,10 +13,24 @@ If it shows "Failed to open /dev/bus/usb/xxx/xxx: Operation not permitted", you
 As root, create the file `/etc/udev/rules.d/50-openpuck.rules` with the following content: 
 
 ```
+# Steam puck / Lizard (Valve)
 SUBSYSTEM=="usb", ATTR{idVendor}=="28de", MODE="0664", GROUP="plugdev"
+# Xbox mode (Microsoft)
+SUBSYSTEM=="usb", ATTR{idVendor}=="045e", MODE="0664", GROUP="plugdev"
+# Switch Pro (Nintendo) and HORIPAD (Hori)
+SUBSYSTEM=="usb", ATTR{idVendor}=="057e", MODE="0664", GROUP="plugdev"
+SUBSYSTEM=="usb", ATTR{idVendor}=="0f0d", MODE="0664", GROUP="plugdev"
+# PS5 / DS4 / PS3 modes (Sony)
+SUBSYSTEM=="usb", ATTR{idVendor}=="054c", MODE="0664", GROUP="plugdev"
+# DirectInput mode (pid.codes)
+SUBSYSTEM=="usb", ATTR{idVendor}=="1209", MODE="0664", GROUP="plugdev"
+# SInput mode
+SUBSYSTEM=="usb", ATTR{idVendor}=="2e8a", MODE="0664", GROUP="plugdev"
 ```
 
-This ensures that all members of the `plugdev` group can access all Valve hardware directly. 
+This ensures that all members of the `plugdev` group can access those devices directly. Only the first rule is
+needed if you only ever use Steam/Lizard mode; the rest cover the emulated personalities, whose USB identity
+(and therefore udev match) changes with the mode. 
 
 Then, run `sudo usermod -a -G plugdev $USER` to make sure your user is a member of that group, and then run `sudo udevadm control --reload-rules` (or reboot your machine) to enable the new rule. 
 

+ 7 - 2
docs/index.html

@@ -164,8 +164,11 @@
         <button data-mode="6" class="modebtn">HID gyro (DS4)</button>
         <button data-mode="9" class="modebtn">PS3 (DualShock 3)</button>
         <button data-mode="10" class="modebtn">Original Xbox</button>
+        <button data-mode="11" class="modebtn">DirectInput (sims)</button>
+        <button data-mode="12" class="modebtn">SInput (SDL native)</button>
       </div>
       <p class="note">Switching reboots the copycat and re-enumerates USB (~2 s). WebUSB works in every mode, including Steam/Lizard with Steam running (Chrome claims the vendor interface; Steam keeps the HID slots). Switch HORIPAD + WebUSB is PC-only. Original Xbox emulates one Microsoft Controller S and uses the Xbox mapping tab; LB/RB become White/Black. It's for a real Original Xbox console — a PC needs a driver for the original Xbox controller to see it as a gamepad.</p>
+      <p class="note"><b>DirectInput</b> is for flight/space sims: every analog input is live at once, split over two joysticks (#1 sticks + triggers + 26 buttons, #2 trackpads + gyro). Trackpad axes hold their last position and re-centre on a pad click. <b>SInput</b> is the open SDL-native protocol — SDL3 / Steam Input read sticks, both analog triggers, gyro, both trackpads and battery from it natively, with rumble back from the host.</p>
       <div class="row"><label>Persist last mode</label>
         <button id="persistMode">off</button>
         <span class="note" style="flex:1">Off (default): every restart / fresh reconnect boots into Steam mode. On: remembers the last mode you selected.</span>
@@ -386,7 +389,7 @@
 </div>
 
 <script>
-const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)","PS3 (DualShock 3)","Original Xbox"];
+const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)","PS3 (DualShock 3)","Original Xbox","DirectInput (sims)","SInput (SDL native)"];
 const CHORD_FIELD = [17, 18, 19];          // back4 + B/X/Y
 const CHORD_DPAD_FIELD = [34, 35, 36, 37]; // back4 + D-pad left/up/right/down (firmware protocol >= 18)
 const CHORD_DPAD_DEF = [9, 8, 7, 2];       // firmware defaults: PS3, DS4 game, PS5 game, Switch HORIPAD
@@ -731,7 +734,9 @@ function renderSlotTabs(bondedCount) {
   updateSlotDisplay(g_activeSlot);
 }
 
-const USB_FILTERS=[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}];
+// Every VID an OpenPuck can enumerate under — the picker/auto-reconnect can only see a device whose VID is
+// listed here, so a new mode with a new identity MUST be added (1209 = DirectInput, 2E8A = SInput).
+const USB_FILTERS=[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C},{vendorId:0x1209},{vendorId:0x2E8A}];
 // Acquire+set up an already-chosen USBDevice (shared by the manual picker and the no-picker auto-reconnect).
 async function openDevice(d){
   try{