Parcourir la source

Xbox360 console support (#296)

* test: add native XInput auth and crypto regressions

* Add standalone Xbox 360 console authentication

---------

Co-authored-by: Jari <safijari@isu.edu>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Altenator2 il y a 1 semaine
Parent
commit
37444c24c8

+ 2 - 2
Makefile

@@ -14,7 +14,7 @@ CLANG_FORMAT ?= $(shell command -v clang-format-18 \
 # gitignored and excluded here so formatting never touches them.
 FORMAT_FILES := $(shell find OpenPuck ReversePuckFirmware puck_sniffer pairtui \
 	\( -name '*.c' -o -name '*.cpp' -o -name '*.h' -o -name '*.hpp' -o -name '*.ino' \) \
-	-not -name 'git_version.h')
+	-not -name 'git_version.h' -not -path 'OpenPuck/src/libxsm3/*')
 
 # --- firmware build ---------------------------------------------------------
 # The OpenPuck firmware needs two TinyUSB config values that differ from the Adafruit nRF52 core defaults:
@@ -46,7 +46,7 @@ EXTRA_FLAGS ?=
 # Overriding the weak symbol would claim the whole hook and force a copy of Adafruit's WebUSB body,
 # so we wrap: webusb_config.cpp takes XID and passes everything else to __real_.
 RF_JOURNAL_BASE ?= 0x86000
-OPENPUCK_LINK_FLAGS ?= -Wl,--wrap=tud_vendor_control_xfer_cb -Wl,--section-start=.rf_journal_guard=$(RF_JOURNAL_BASE)
+OPENPUCK_LINK_FLAGS ?= -Wl,--wrap=tud_vendor_control_xfer_cb -Wl,--wrap=tud_descriptor_string_cb -Wl,--wrap=usbd_control_xfer_cb -Wl,--section-start=.rf_journal_guard=$(RF_JOURNAL_BASE)
 # {build.flags.usb} is expanded by arduino-cli (VID/PID/strings); pass it through verbatim.
 USB_EXTRA_FLAGS = -DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=$(CFG_TUD_HID) -DCFG_TUD_TASK_QUEUE_SZ=$(CFG_TUD_TASK_QUEUE_SZ) -DCFG_TUD_VENDOR_TX_BUFSIZE=$(CFG_TUD_VENDOR_TX_BUFSIZE) -DOPK_RF_JOURNAL_BASE=$(RF_JOURNAL_BASE) $(EXTRA_FLAGS)
 # When BUILD_PATH is set, --clean + path flags are injected; omitted for fast incremental dev builds.

+ 3 - 2
OpenPuck/OpenPuck.ino

@@ -185,6 +185,7 @@ void setup()
 	// clean-PS modes skip BOTH the wake mouse and WebUSB -- no config panel / host-wake; chord back to Steam
 	// (back-paddle 4 + A) to reach the panel. Normal MODE_PS5 / MODE_HIDGYRO keep wake + panel.
 	const bool psClean = modeIsCleanPS(g_usbMode);
+	const bool xboxClean = g_usbMode == MODE_XBOX;
 	const bool dynamic = g_active->dynamicMount();
 
 	if (dynamic) {
@@ -246,12 +247,12 @@ void setup()
 		// Boot-mouse wake interface for clean (non-puck) modes, and for puck on the one-shot debug boot (CDC on,
 		// no endpoint room for wake mouse on a normal puck boot -- wake is registered above instead). Skipped for PS
 		// modes so the device stays a single clean HID gamepad (see psClean above).
-		if (!puckMode && !keepCdc && !psClean)
+		if (!puckMode && !keepCdc && !psClean && !xboxClean)
 			wakeHidBegin();
 
 		// WebUSB config panel -- every mode EXCEPT the PlayStation modes. Puck: registered above (IF 0) before wake +
 		// slots; other clean modes after controller. PS modes omit it to present a genuine single-HID PS controller.
-		if (!puckMode && !psClean)
+		if (!puckMode && !psClean && !xboxClean)
 			usb_web.begin();
 		// bmAttributes: required(0x80) | remote_wakeup(0x20). Remote Wakeup lets us signal wake-from-sleep.
 		// The PS3/Sixaxis (the only clean-PS mode on this static path) advertises 0x80 only -- match it so a

+ 322 - 314
OpenPuck/mode_xinput.cpp

@@ -1,21 +1,12 @@
-// mode_xinput.cpp -- Xbox 360 wired (XInput) personality (MODE_XBOX): one XInput gamepad per bonded controller.
-//
-// Real Xbox 360 pads are NOT HID: they use a vendor interface (class 0xFF / sub 0x5D / proto 0x01) carrying a
-// 20-byte XInput report. A custom TinyUSB class driver serves that interface plus a second boot-mouse interface
-// for the right trackpad. Host binds by VID/PID (045E:028E) + the FF/5D/01 interface. The OUT endpoint carries
-// rumble, relayed to the controller as a haptic by task().
-//
-// Multi-controller: one XInput interface per CONNECTED controller (dynamic mount -- see usb_mount.h; the device
-// re-enumerates without rebooting as controllers connect/disconnect). The single class driver routes xfer/open
-// callbacks by endpoint address into the per-bond-slot state; xi_open claims slots in connection order. The
-// right-pad mouse is a single shared interface (only bond slot 0's right-pad drives it).
+// One wired Xbox 360 pad, independent of RF controller joins and departures.
 #include "mode_xinput.h"
+#include "xinput_auth.h"
+#include "xinput_descriptors.h"
 #include "triton.h"
 #include "gamepad_util.h"
 #include "config.h"
 #include "haptics.h"
 #include "bonds.h"
-#include "usb_mount.h"
 #include "usb_tx.h"
 #include "usb_app_drivers.h"
 #include <Adafruit_TinyUSB.h>
@@ -24,7 +15,6 @@
 
 XboxController g_xboxCtl;
 
-// XInput button bits
 enum {
 	XB_DUP = 0x0001,
 	XB_DDOWN = 0x0002,
@@ -43,141 +33,180 @@ enum {
 	XB_Y = 0x8000
 };
 
-// ===================== XInput custom TinyUSB class driver =====================
-// Custom class driver + an Adafruit_USBD_Interface subclass emitting the interface + 0x21-blob + 2 endpoints.
-// Per-slot state lives in g_xiSlot[]: each XInput interface gets one. The class driver dispatches open/xfer
-// by endpoint address; xinputSend() targets a specific slot's IN endpoint.
-#define XINPUT_DESC_LEN \
-	(9 + 17 + 7 + 7) // interface(9)+vendor0x21(17)+IN ep(7)+OUT ep(7) = 40
+#define XINPUT_CONN_MS 1200u
+#define RUMBLE_STUCK_MS 2500u
 
-// per-slot state. `inUse` is set in xi_open from the USB ISR and read in xinputSend from the loop -- mark
-// volatile so the loop doesn't observe a stale 0 across a freshly-attached interface.
-struct XiSlot {
-	uint8_t itf, epIn, epOut;
-	uint8_t inBuf[32], outBuf[32];
-	volatile uint16_t rumbleLow,
-		rumbleHigh; // last host rumble values, x 257
-	volatile unsigned long
-		rumbleMs; // millis of last OUT packet (stuck-rumble watchdog)
+struct XiState {
 	volatile bool inUse;
-	// inBuf holds a built-but-not-yet-sent 20B XInput report; the actual usbd_edpt_xfer is issued from the
-	// usbd task (usbTxDrainHook), never from loop(), so the cross-task blocking-defer can't stall the loop.
+	volatile int8_t bond;
 	volatile bool txPending;
+	volatile bool neutralPending;
+	volatile bool outRearm;
+	uint8_t claimedInterfaces;
+	uint8_t rhport;
+	uint8_t latest[20];
+	CFG_TUD_MEM_ALIGN uint8_t inBuf[32];
+	CFG_TUD_MEM_ALIGN uint8_t outBuf[32];
+	volatile uint16_t rumbleLow, rumbleHigh;
+	volatile unsigned long rumbleMs;
+	volatile bool rumblePending;
+	volatile int8_t rumbleBond;
+	volatile uint8_t player;
 };
-static XiSlot g_xiSlot[NSLOT];
-// Dynamic mount: xi_open is called once per XInput interface in descriptor order; this counts them so the
-// u-th interface claims the u-th connected controller (g_usbToBond[u]). Reset on every (re)enumeration.
-static uint8_t g_xiOpenU = 0;
-// release a held rumble if no OUT packet refreshes it for this long (covers a lost stop)
-#define RUMBLE_STUCK_MS 2500u
+
+static XiState g_xi;
+static volatile uint32_t g_xiGeneration;
+// Reset callbacks must stop the old RF destination later, never a replacement.
+static volatile uint8_t g_stopBonds;
+
+static void xiNeutral(uint8_t *report)
+{
+	memset(report, 0, 20);
+	report[1] = 20;
+}
+
+// Only used before attach or after TinyUSB has cancelled endpoint DMA.
+static void xiClear(void)
+{
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	if (g_xi.inUse && g_xi.bond >= 0)
+		g_stopBonds |= 1u << g_xi.bond;
+	g_xiGeneration++;
+	memset(&g_xi, 0, sizeof g_xi);
+	g_xi.bond = g_xi.rumbleBond = -1;
+	xiNeutral(g_xi.latest);
+	xiNeutral(g_xi.inBuf);
+	g_xi.neutralPending = true;
+	__set_PRIMASK(pm);
+	xinputAuthReset();
+}
 
 static void xi_init(void)
 {
-	for (int s = 0; s < NSLOT; s++)
-		g_xiSlot[s].inUse = false;
+	xiClear();
 }
+
 static bool xi_deinit(void)
 {
+	xiClear();
 	return true;
 }
+
 static void xi_reset(uint8_t rhport)
 {
 	(void)rhport;
-	g_xiOpenU =
-		0; // restart per-interface claim ordering for this enumeration
-	for (int s = 0; s < NSLOT; s++) {
-		g_xiSlot[s].itf = 0;
-		g_xiSlot[s].epIn = g_xiSlot[s].epOut = 0;
-		g_xiSlot[s].inUse = false;
-	}
+	xiClear();
 }
-// TinyUSB calls xi_open once per XInput interface in the config descriptor. Claim the first free BONDED
-// slot so the xiSlot index matches the bond-slot index (begin() registers them in ascending bond-slot order).
-// Fallback to any free slot when there are no bonds (fresh device).
+
 static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const *itf,
 			uint16_t max_len)
 {
-	if (!(itf->bInterfaceClass == 0xFF && itf->bInterfaceSubClass == 0x5D &&
-	      itf->bInterfaceProtocol == 0x01))
+	if (!itf || max_len < sizeof(tusb_desc_interface_t) ||
+	    itf->bInterfaceNumber >= 4)
 		return 0;
-	// The u-th XInput interface in the descriptor serves the u-th connected controller. g_xiSlot is keyed by
-	// BOND slot so onReport45(bond)/rumble route straight through; only connected bonds get an interface.
-	uint8_t u = g_xiOpenU++;
-	int slot = (u < g_usbMountCount) ? g_usbToBond[u] : -1;
-	if (slot < 0) { // fallback (e.g. map not built): first free slot
-		for (int s = 0; s < NSLOT; s++)
-			if (!g_xiSlot[s].inUse) {
-				slot = s;
-				break;
-			}
-	}
-	if (slot < 0 || slot >= NSLOT || g_xiSlot[slot].inUse)
+	static const uint16_t starts[] = { 0, 40, 104, 129, 144 };
+	uint8_t number = itf->bInterfaceNumber;
+	uint16_t start = starts[number];
+	uint16_t length = starts[number + 1] - start;
+	if (max_len < length || (g_xi.claimedInterfaces & (1u << number)))
 		return 0;
-	XiSlot &S = g_xiSlot[slot];
-	S.itf = itf->bInterfaceNumber;
-	uint8_t const *p = (uint8_t const *)itf;
-	uint8_t const *end = p + max_len;
-	uint16_t used = itf->bLength;
-	p += itf->bLength;
-	uint8_t opened = 0;
-	while (p < end && opened < itf->bNumEndpoints) {
-		uint8_t blen = p[0], btype = p[1];
-		if (btype == TUSB_DESC_ENDPOINT) {
-			tusb_desc_endpoint_t const *ep =
-				(tusb_desc_endpoint_t const *)p;
-			usbd_edpt_open(rhport, ep);
-			if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN)
-				S.epIn = ep->bEndpointAddress;
-			else
-				S.epOut = ep->bEndpointAddress;
-			opened++;
+
+	const uint8_t *body = (const uint8_t *)itf;
+	const tusb_desc_endpoint_t *epIn = nullptr, *epOut = nullptr;
+	uint16_t offset = 0;
+	while (offset < length) {
+		if (length - offset < 2)
+			return 0;
+		uint8_t size = body[offset], type = body[offset + 1];
+		if (size < 2 || size > length - offset ||
+		    size != XINPUT_CONFIG_BODY[start + offset])
+			return 0;
+		// The fixed personality needs exact interface/endpoint numbers and
+		// vendor blobs. Only the security string is allocated by Adafruit.
+		for (uint16_t i = offset; i < offset + size; i++) {
+			if (start + i != XINPUT_SECURITY_STRING_OFFSET &&
+			    body[i] != XINPUT_CONFIG_BODY[start + i])
+				return 0;
 		}
-		used += blen;
-		p += blen;
+		if (type == TUSB_DESC_ENDPOINT && number == 0) {
+			const tusb_desc_endpoint_t *ep =
+				(const tusb_desc_endpoint_t *)(body + offset);
+			if (ep->bEndpointAddress == 0x81)
+				epIn = ep;
+			else if (ep->bEndpointAddress == 0x02)
+				epOut = ep;
+		}
+		offset += size;
+	}
+
+	// The accessory interfaces identify the genuine pad layout, but no
+	// headset or chatpad is present. Do not open or feed their endpoints;
+	// input's reserved bytes stay zero (no accessory-presence flags).
+	// Claim separately: older TinyUSB versions bind only the first interface
+	// of a returned descriptor span when there is no IAD.
+	if (number != 0) {
+		g_xi.claimedInterfaces |= 1u << number;
+		return length;
 	}
-	S.inUse = true;
-	// arm OUT (rumble/LED)
-	if (S.epOut)
-		usbd_edpt_xfer(rhport, S.epOut, S.outBuf, sizeof S.outBuf);
-	return used;
+	if (!epIn || !epOut)
+		return 0;
+	if (!usbd_edpt_open(rhport, epIn))
+		return 0;
+	if (!usbd_edpt_open(rhport, epOut)) {
+		usbd_edpt_close(rhport, 0x81);
+		return 0;
+	}
+	g_xi.claimedInterfaces |= 1u << number;
+	g_xi.rhport = rhport;
+	g_xi.inUse = true;
+	g_xi.neutralPending = true;
+	g_xi.outRearm =
+		!usbd_edpt_xfer(rhport, 0x02, g_xi.outBuf, sizeof g_xi.outBuf);
+	return length;
 }
+
 static bool xi_ctrl(uint8_t rhport, uint8_t stage,
-		    tusb_control_request_t const *req)
+		    tusb_control_request_t const *request)
 {
-	(void)rhport;
-	(void)req;
-	return stage != CONTROL_STAGE_SETUP;
+	return xinputAuthControl(rhport, stage, request);
 }
-// Route an endpoint xfer callback to the slot that owns that endpoint.
-static bool xi_xfer(uint8_t rhport, uint8_t ep, xfer_result_t res, uint32_t n)
+
+static bool xi_xfer(uint8_t rhport, uint8_t ep, xfer_result_t result,
+		    uint32_t length)
 {
-	(void)res;
-	for (int s = 0; s < NSLOT; s++) {
-		XiSlot &S = g_xiSlot[s];
-		if (!S.inUse)
-			continue;
-		if (ep == S.epOut) {
-			// XInput rumble packet: [00][08][00][bigMotor][smallMotor][00][00][00]; LED pkt is [01][03][led]
-			if (n >= 5 && S.outBuf[0] == 0x00 &&
-			    S.outBuf[1] == 0x08) {
-				uint8_t big = S.outBuf[3], sml = S.outBuf[4];
-				// skip tracking+relay when rumble is disabled for this type
-				if (g_rumble) {
-					uint16_t lo = (uint16_t)big * 257u;
-					uint16_t hi = (uint16_t)sml * 257u;
-					S.rumbleLow = lo;
-					S.rumbleHigh = hi;
-					S.rumbleMs = millis();
-					hapticSteamRumble(lo, hi, (uint8_t)s);
-				}
-			}
-			usbd_edpt_xfer(rhport, S.epOut, S.outBuf,
-				       sizeof S.outBuf);
-			return true;
+	if (!g_xi.inUse || rhport != g_xi.rhport)
+		return false;
+	if (ep == 0x81) {
+		if (result != XFER_RESULT_SUCCESS)
+			g_xi.neutralPending = true;
+		return true;
+	}
+	if (ep != 0x02)
+		return false;
+	if (result == XFER_RESULT_SUCCESS) {
+		const uint8_t *data = g_xi.outBuf;
+		if (length == 8 && data[0] == 0 && data[1] == 8 &&
+		    data[2] == 0 && data[5] == 0 && data[6] == 0 &&
+		    data[7] == 0) {
+			g_xi.rumbleLow = (uint16_t)data[3] * 257u;
+			g_xi.rumbleHigh = (uint16_t)data[4] * 257u;
+			g_xi.rumbleMs = millis();
+			g_xi.rumbleBond = g_xi.bond;
+			g_xi.rumblePending = true;
+		} else if (length == 3 && data[0] == 1 && data[1] == 3) {
+			// 2..5 blink then settle; 6..9 immediately select a player.
+			uint8_t led = data[2];
+			g_xi.player = led >= 2 && led <= 5 ? led - 1 :
+				      led >= 6 && led <= 9 ? led - 5 :
+							     0;
 		}
 	}
+	g_xi.outRearm =
+		!usbd_edpt_xfer(rhport, ep, g_xi.outBuf, sizeof g_xi.outBuf);
 	return true;
 }
+
 static const usbd_class_driver_t g_xiDriver = {
 #if CFG_TUSB_DEBUG >= 2
 	.name = "XINPUT",
@@ -190,6 +219,7 @@ static const usbd_class_driver_t g_xiDriver = {
 	.xfer_cb = xi_xfer,
 	.sof = NULL
 };
+
 const usbd_class_driver_t *xinputClassDriver(void)
 {
 	return &g_xiDriver;
@@ -197,98 +227,110 @@ const usbd_class_driver_t *xinputClassDriver(void)
 
 class Adafruit_USBD_XInput : public Adafruit_USBD_Interface {
     public:
-	uint16_t getInterfaceDescriptor(uint8_t, uint8_t *buf,
+	uint16_t getInterfaceDescriptor(uint8_t itfnum, uint8_t *buf,
 					uint16_t bufsize) override
 	{
 		if (!buf)
-			return XINPUT_DESC_LEN;
-		if (bufsize < XINPUT_DESC_LEN)
+			return sizeof XINPUT_CONFIG_BODY;
+		if (itfnum != 0 || bufsize < sizeof XINPUT_CONFIG_BODY)
 			return 0;
-		uint8_t itfnum = TinyUSBDevice.allocInterface(1);
-		uint8_t epin = TinyUSBDevice.allocEndpoint(TUSB_DIR_IN),
-			epout = TinyUSBDevice.allocEndpoint(TUSB_DIR_OUT);
-		const uint8_t t[XINPUT_DESC_LEN] = {
-			9, TUSB_DESC_INTERFACE, itfnum, 0x00, 0x02, 0xFF, 0x5D,
-			0x01, _strid, 0x11, 0x21, 0x00, 0x01, 0x01, 0x25, epin,
-			0x14, 0x00, 0x00, 0x00, 0x00, 0x13, epout, 0x08, 0x00,
-			0x00, 7, TUSB_DESC_ENDPOINT, epin, TUSB_XFER_INTERRUPT,
-			U16_TO_U8S_LE(0x20),
-
-			// bInterval 1ms (1000Hz) so the RF rate is the only limit
-			1, 7, TUSB_DESC_ENDPOINT, epout, TUSB_XFER_INTERRUPT,
-			U16_TO_U8S_LE(0x20), 8
-		};
-		memcpy(buf, t, XINPUT_DESC_LEN);
-		return XINPUT_DESC_LEN;
+		TinyUSBDevice.allocInterface(4);
+		// Reserve through endpoint 6 in each direction, including holes.
+		// No other USB interfaces may be added to this personality.
+		for (uint8_t ep = 1; ep <= 6; ep++) {
+			TinyUSBDevice.allocEndpoint(TUSB_DIR_IN);
+			TinyUSBDevice.allocEndpoint(TUSB_DIR_OUT);
+		}
+		memcpy(buf, XINPUT_CONFIG_BODY, sizeof XINPUT_CONFIG_BODY);
+		buf[XINPUT_SECURITY_STRING_OFFSET] = _strid;
+		return sizeof XINPUT_CONFIG_BODY;
 	}
 	bool begin()
 	{
+		setStringDescriptor(XINPUT_SECURITY_STRING);
 		return TinyUSBDevice.addInterface(*this);
 	}
 };
-// NSLOT instances: each registers its own XInput interface (own itfnum + IN/OUT ep pair) during begin().
-// begin() in XboxController::begin() runs in order 0..NSLOT-1, so xi_open's first-free assignment lines up.
-static Adafruit_USBD_XInput g_xinput[NSLOT];
-static void xinputSend(uint8_t slot, uint16_t buttons, uint8_t lt, uint8_t rt,
-		       int16_t lx, int16_t ly, int16_t rx, int16_t ry)
+
+static Adafruit_USBD_XInput g_xinput;
+
+static void xinputSend(uint8_t slot, uint32_t generation, uint16_t buttons,
+		       uint8_t lt, uint8_t rt, int16_t lx, int16_t ly,
+		       int16_t rx, int16_t ry)
 {
-	if (slot >= NSLOT || !g_xiSlot[slot].inUse)
-		return;
-	XiSlot &S = g_xiSlot[slot];
-	// Only refill inBuf when the IN endpoint is idle: while a transfer is in flight (busy) the DMA is still
-	// reading inBuf, so leave it untouched (this also coalesces -- newest state wins, like a real pad).
-	if (!tud_mounted() || S.epIn == 0 || usbd_edpt_busy(0, S.epIn))
-		return;
-	uint8_t *r = S.inBuf;
-	r[0] = 0x00;
-	r[1] = 0x14;
-	r[2] = buttons & 0xFF;
-	r[3] = buttons >> 8;
-	r[4] = lt;
-	r[5] = rt;
-	r[6] = lx & 0xFF;
-	r[7] = lx >> 8;
-	r[8] = ly & 0xFF;
-	r[9] = ly >> 8;
-	r[10] = rx & 0xFF;
-	r[11] = rx >> 8;
-	r[12] = ry & 0xFF;
-	r[13] = ry >> 8;
-	memset(r + 14, 0, 6);
-	// Hand off to the usbd task: the xfer is issued from xiSofDrain() (SOF), not here, so loop() never
-	// calls into the dcd transfer path (which can block on the device event queue under load).
-	S.txPending = true;
+	uint8_t report[20] = { 0, 20 };
+	report[2] = buttons & 0xFF;
+	report[3] = buttons >> 8;
+	report[4] = lt;
+	report[5] = rt;
+	report[6] = lx & 0xFF;
+	report[7] = lx >> 8;
+	report[8] = ly & 0xFF;
+	report[9] = ly >> 8;
+	report[10] = rx & 0xFF;
+	report[11] = rx >> 8;
+	report[12] = ry & 0xFF;
+	report[13] = ry >> 8;
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	if (g_xiGeneration == generation && g_xi.bond == slot) {
+		memcpy(g_xi.latest, report, sizeof report);
+		g_xi.txPending = true;
+	}
+	__set_PRIMASK(pm);
 }
 
-// usbd-task drain (registered with usbTxRegisterDrain, called from tud_sof_cb). Issues the deferred XInput IN
-// transfers. Runs at higher priority than loop(), so the claim->xfer is atomic w.r.t. xinputSend() refilling
-// inBuf -- no torn DMA.
-static void xiSofDrain(void)
+// usbTxPump calls this from the loop with the USB priority-inversion guard.
+static void xiTxDrain(void)
 {
-	if (!tud_mounted())
+	if (!tud_mounted() || !g_xi.inUse)
 		return;
-	for (int s = 0; s < NSLOT; s++) {
-		XiSlot &S = g_xiSlot[s];
-		if (!S.inUse || !S.txPending || S.epIn == 0)
-			continue;
-		if (usbd_edpt_busy(0, S.epIn))
-			continue;
-		if (usbd_edpt_claim(0, S.epIn)) {
-			if (usbd_edpt_xfer(0, S.epIn, S.inBuf, 20))
-				S.txPending = false;
+	uint32_t generation = g_xiGeneration;
+	uint8_t rhport = g_xi.rhport;
+	if (g_xi.outRearm && !usbd_edpt_busy(rhport, 0x02) &&
+	    usbd_edpt_claim(rhport, 0x02)) {
+		g_xi.outRearm = false;
+		if (!usbd_edpt_xfer(rhport, 0x02, g_xi.outBuf,
+				    sizeof g_xi.outBuf)) {
+			if (generation == g_xiGeneration)
+				g_xi.outRearm = true;
+			usbd_edpt_release(rhport, 0x02);
+		}
+	}
+	if (generation != g_xiGeneration || !g_xi.inUse ||
+	    (!g_xi.txPending && !g_xi.neutralPending) ||
+	    usbd_edpt_busy(rhport, 0x81) || !usbd_edpt_claim(rhport, 0x81))
+		return;
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	if (generation != g_xiGeneration) {
+		__set_PRIMASK(pm);
+		usbd_edpt_release(rhport, 0x81);
+		return;
+	}
+	bool neutral = g_xi.neutralPending;
+	if (neutral) {
+		xiNeutral(g_xi.inBuf);
+		g_xi.neutralPending = false;
+	} else {
+		memcpy(g_xi.inBuf, g_xi.latest, sizeof g_xi.latest);
+		g_xi.txPending = false;
+	}
+	__set_PRIMASK(pm);
+	if (!usbd_edpt_xfer(rhport, 0x81, g_xi.inBuf, 20)) {
+		pm = __get_PRIMASK();
+		__disable_irq();
+		if (generation == g_xiGeneration) {
+			if (neutral)
+				g_xi.neutralPending = true;
 			else
-				usbd_edpt_release(0, S.epIn);
+				g_xi.txPending = true;
 		}
+		__set_PRIMASK(pm);
+		usbd_edpt_release(rhport, 0x81);
 	}
 }
 
-// ===================== right-pad mouse interface =====================
-static const uint8_t MOUSE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
-static Adafruit_USBD_HID
-	g_mouse; // Xbox-mode mouse interface (right trackpad, slot 0 only)
-
-// ===================== report 0x45 -> XInput + mouse =====================
-// button code (g_back[], g_abSwap targets) -> legacy XInput bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright
 static uint16_t codeToXB(uint8_t c)
 {
 	switch (c) {
@@ -326,7 +368,8 @@ static uint16_t codeToXB(uint8_t c)
 		return 0;
 	}
 }
-static void rfXboxGamepad(uint8_t slot, const uint8_t *r)
+
+static void rfXboxGamepad(uint8_t slot, uint32_t generation, const uint8_t *r)
 {
 	uint32_t b = btnsOf(r);
 	if (g_qamMap && (b & TB_QAM)) {
@@ -342,6 +385,7 @@ static void rfXboxGamepad(uint8_t slot, const uint8_t *r)
 		btn |= XB_DLEFT;
 	if (b & TB_DRT)
 		btn |= XB_DRIGHT;
+	// TB_VIEW is the physical Menu/Start side; see triton.h.
 	if (b & TB_VIEW)
 		btn |= XB_START;
 	if (b & TB_MENU)
@@ -356,7 +400,6 @@ static void rfXboxGamepad(uint8_t slot, const uint8_t *r)
 		btn |= XB_L3;
 	if (b & TB_R3)
 		btn |= XB_R3;
-	// face buttons, with optional A/B + X/Y swap (Nintendo layout)
 	uint16_t fA = g_abSwap ? XB_B : XB_A, fB = g_abSwap ? XB_A : XB_B,
 		 fX = g_abSwap ? XB_Y : XB_X, fY = g_abSwap ? XB_X : XB_Y;
 	if (b & TB_A)
@@ -367,7 +410,6 @@ static void rfXboxGamepad(uint8_t slot, const uint8_t *r)
 		btn |= fX;
 	if (b & TB_Y)
 		btn |= fY;
-	// back paddles -> configurable mapping (default L4->LB, R4->RB, L5->L3, R5->R3)
 	if (b & TB_L4)
 		btn |= codeToXB(g_back[0]);
 	if (b & TB_R4)
@@ -376,12 +418,8 @@ static void rfXboxGamepad(uint8_t slot, const uint8_t *r)
 		btn |= codeToXB(g_back[2]);
 	if (b & TB_R5)
 		btn |= codeToXB(g_back[3]);
-	uint8_t lt = trigU8(u16off(r, 4)),
-		rt = trigU8(u16off(
-			r, 6)); // triggers u16 (half-scale) -> full-range u8
-	// Trigger remaps (codes 19=LT, 20=RT): XInput triggers are analog bytes, not buttons, so a back paddle /
-	// QAM mapped to a trigger pulls it full. QAM arrives as TB_L2/TB_R2 (folded into b via tritonFromCode);
-	// back paddles are matched by their configured code.
+	uint8_t lt = trigU8(u16off(r, 4)), rt = trigU8(u16off(r, 6));
+	// Remapped triggers pull the analog byte full; they have no button bit.
 	if (b & TB_L2)
 		lt = 0xFF;
 	if (b & TB_R2)
@@ -396,148 +434,118 @@ static void rfXboxGamepad(uint8_t slot, const uint8_t *r)
 		else if (bc[i] == 20)
 			rt = 0xFF;
 	}
-	// Raw-report offsets, not slotSticks(): this mode already decodes 0x45 in place and never
-	// touches g_in. 16/18 = left pad X/Y, 22/24 = right pad X/Y (same pair rfXboxMouse reads).
 	int16_t lx = (int16_t)s16off(r, 8), ly = (int16_t)s16off(r, 10),
 		rx = (int16_t)s16off(r, 12), ry = (int16_t)s16off(r, 14);
 	padStickBlend(b, (int16_t)s16off(r, 16), (int16_t)s16off(r, 18),
 		      (int16_t)s16off(r, 22), (int16_t)s16off(r, 24), &lx, &ly,
 		      &rx, &ry);
-	xinputSend(slot, btn, lt, rt, lx, ly, rx, ry);
-}
-// Right pad -> mouse on a second HID-mouse interface alongside the XInput gamepad. Same glide model as Lizard's
-// right pad; RPad click = left button, LPad click = right. SINGLE shared mouse: the desktop can only consume
-// one mouse. Slot 0's right-pad drives it; other slots' right-pad input is intentionally ignored here.
-static void rfXboxMouse(const uint8_t *r)
-{
-	uint32_t b = btnsOf(r);
-	static int prx = 0, pry = 0;
-	static bool prt = false;
-	static float vx = 0, vy = 0, rmx = 0, rmy = 0;
-	static uint8_t pmb = 0;
-	// The right pad drives a stick now, so it must not also glide the mouse -- treat it as untouched and let
-	// the residual velocity decay away instead of stopping dead.
-	bool rtouch = (g_padStick[1] == PS_OFF) && (b & TB_RPADT);
-	int rx = s16off(r, 22), ry = s16off(r, 24);
-	if (rtouch) {
-		if (prt) {
-			vx += (rx - prx);
-			vy += (ry - pry);
-		}
-		prx = rx;
-		pry = ry;
-	}
-	prt = rtouch;
-	float mxf = vx / (float)(g_mDiv * 10) + rmx,
-	      myf = -(vy / (float)(g_mDiv * 10)) +
-		    rmy; // Y inverted for screen coords
-	int dx = (int)mxf, dy = (int)myf;
-	rmx = mxf - dx;
-	rmy = myf - dy; // sub-pixel carry
-	if (dx > 127)
-		dx = 127;
-	if (dx < -127)
-		dx = -127;
-	if (dy > 127)
-		dy = 127;
-	if (dy < -127)
-		dy = -127;
-	float f = g_mFric / 100.0f;
-	vx *= f;
-	vy *= f;
-	if (vx > -1 && vx < 1)
-		vx = 0;
-	if (vy > -1 && vy < 1)
-		vy = 0; // friction = glide/decay
-	uint8_t mb = ((b & TB_RPADC) ? 1 : 0) |
-		     ((b & TB_LPADC) ?
-			      2 :
-			      0); // RPad click = left, LPad click = right
-	if (dx || dy || mb != pmb) {
-		pmb = mb;
-		hid_mouse_report_t m;
-		m.buttons = mb;
-		m.x = (int8_t)dx;
-		m.y = (int8_t)dy;
-		m.wheel = 0;
-		m.pan = 0;
-		if (g_mouse.ready())
-			usbTxHid(&g_mouse, 0, &m, sizeof m);
-	}
+	xinputSend(slot, generation, btn, lt, rt, lx, ly, rx, ry);
 }
 
-// ===================== IController =====================
-// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
-void XboxController::begin()
+static bool xiBondAlive(int slot, unsigned long now)
 {
+	return slot >= 0 && slot < NSLOT && g_slot[slot].used &&
+	       g_connReplyMs[slot] &&
+	       now - g_connReplyMs[slot] <= XINPUT_CONN_MS;
 }
-// XInput slot interfaces are a custom (non-HID) class, so they don't draw on the CFG_TUD_HID budget (only the
-// wake mouse + right-pad mouse do). Slots are limited by NSLOT / USB endpoints, not HID instances.
-uint8_t XboxController::maxSlots() const
+
+static void xiDisconnect(unsigned long now)
 {
-	return (uint8_t)NSLOT;
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	int slot = g_xi.bond;
+	if (slot >= 0 && !xiBondAlive(slot, now)) {
+		g_stopBonds |= 1u << slot;
+		g_xi.bond = g_xi.rumbleBond = -1;
+		g_xi.rumbleLow = g_xi.rumbleHigh = 0;
+		g_xi.rumbleMs = 0;
+		g_xi.rumblePending = false;
+		g_xi.txPending = false;
+		g_xi.neutralPending = true;
+		xiNeutral(g_xi.latest);
+	}
+	__set_PRIMASK(pm);
 }
+
 void XboxController::usbIdentity()
 {
-	// 045E:028E -> Windows xusb / SDL / Linux xpad all bind it
 	USBDevice.setID(0x045E, 0x028E);
 	USBDevice.setVersion(0x0200);
-	USBDevice.setDeviceVersion(0x0120);
-	USBDevice.setManufacturerDescriptor("Microsoft");
+	USBDevice.setDeviceVersion(0x0114);
+	USBDevice.setManufacturerDescriptor("\xc2\xa9Microsoft Corporation");
 	USBDevice.setProductDescriptor("Controller");
+	USBDevice.setSerialDescriptor(xinputAuthSerial());
+	USBDevice.setConfigurationAttribute(0xA0);
+	USBDevice.setConfigurationMaxPower(500);
+	// Adafruit exposes no class setter; its callback returns mutable backing
+	// storage. Identity is set only while detached, before enumeration.
+	tusb_desc_device_t *device =
+		(tusb_desc_device_t *)tud_descriptor_device_cb();
+	device->bDeviceClass = 0xFF;
+	device->bDeviceSubClass = 0xFF;
+	device->bDeviceProtocol = 0xFF;
 }
-void XboxController::beginPool()
-{
-	// Lock the right-pad mouse HID instance (wake mouse was begun first by setup -> this is HID instance 1).
-	g_mouse.setStringDescriptor("OpenPuck Mouse");
-	g_mouse.setBootProtocol(HID_ITF_PROTOCOL_MOUSE);
-	g_mouse.setReportDescriptor(MOUSE_HID_DESC, sizeof MOUSE_HID_DESC);
-	g_mouse.setPollInterval(1);
-	g_mouse.begin();
-	for (int s = 0; s < NSLOT; s++)
-		g_xinput[s].setStringDescriptor("Controller");
-	// Drain the deferred XInput IN transfers from the usbd task every SOF (only registered in this mode).
-	usbTxRegisterDrain(xiSofDrain);
-}
-void XboxController::mountSlots(uint8_t k)
+
+void XboxController::begin()
 {
-	// Right-pad mouse first (fixed HID), then one XInput interface per connected controller (claimed in
-	// order by xi_open). The wake mouse + WebUSB are added around this by usbReenumerate.
-	USBDevice.addInterface(g_mouse);
-	for (uint8_t u = 0; u < k; u++)
-		USBDevice.addInterface(g_xinput[u]);
+	xiClear();
+	xinputAuthPrepare();
+	usbIdentity();
+	g_xinput.begin();
+	usbTxRegisterDrain(xiTxDrain);
 }
+
 void XboxController::onReport45(int slot, const uint8_t *rep, bool fresh,
 				uint8_t bodyTlen)
 {
-	(void)fresh;
-	(void)bodyTlen;
-	if (slot < 0 || slot >= NSLOT)
+	if (slot < 0 || slot >= NSLOT || !rep || bodyTlen < 26 ||
+	    (rep[0] != 0x45 && rep[0] != 0x42))
 		return;
-	// rfXboxGamepad/rfXboxMouse read report 0x45 field offsets. The new-firmware report 0x42 is VERIFIED
-	// byte-identical over [0..45] (buttons/triggers/sticks/pads/IMU at the same offsets; it only appends 8
-	// trailing bytes and sets always-on status bits 28/29 that no TB_ mask reads -- see the rf_link decode
-	// note), so both ids decode here. Anything else must not be decoded as gamepad input.
-	if (rep[0] != 0x45 && rep[0] != 0x42)
-		return;
-	rfXboxGamepad((uint8_t)slot, rep);
-	// Shared desktop mouse: slot 0's right-pad only. Other slots' right-pad is still part of the
-	// per-slot XInput (TB_RPADT in the gamepad report is unused today but kept for forward-compat).
-	if (slot == 0)
-		rfXboxMouse(rep);
+	unsigned long now = millis();
+	xiDisconnect(now);
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	// rf_link also injects a synthetic neutral after 300ms of silence; it
+	// may release input, but must never acquire an inactive RF destination.
+	if (g_xi.inUse && g_xi.bond < 0 && fresh && xiBondAlive(slot, now) &&
+	    now - g_connReplyMs[slot] < 300u) {
+		g_xi.bond = slot;
+		g_xi.rumblePending = false;
+		g_xi.rumbleBond = -1;
+		g_xi.rumbleLow = g_xi.rumbleHigh = 0;
+	}
+	bool selected = g_xi.bond == slot;
+	uint32_t generation = g_xiGeneration;
+	__set_PRIMASK(pm);
+	if (selected)
+		rfXboxGamepad((uint8_t)slot, generation, rep);
 }
-// Lost-stop watchdog per slot: Steam/Triton rumble is latched, so force a zero report if the host stops
-// refreshing on a particular XInput. The haptics.cpp global watchdog fires too, but it skips while the slot
-// is in the post-reconnect block -- this catches a host that kept streaming while we were blocked.
+
 void XboxController::task()
 {
-	for (int s = 0; s < NSLOT; s++) {
-		if (!g_xiSlot[s].inUse)
-			continue;
-		if ((g_xiSlot[s].rumbleLow || g_xiSlot[s].rumbleHigh) &&
-		    millis() - g_xiSlot[s].rumbleMs > RUMBLE_STUCK_MS) {
-			g_xiSlot[s].rumbleLow = g_xiSlot[s].rumbleHigh = 0;
-			hapticSteamRumble(0, 0, (uint8_t)s);
-		}
+	unsigned long now = millis();
+	xiDisconnect(now);
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	uint8_t stops = g_stopBonds;
+	g_stopBonds = 0;
+	int slot = g_xi.bond;
+	bool pending = g_xi.rumblePending && g_xi.rumbleBond == slot;
+	uint16_t low = g_xi.rumbleLow, high = g_xi.rumbleHigh;
+	if (!g_rumble || now - g_xi.rumbleMs > RUMBLE_STUCK_MS) {
+		pending |= low || high;
+		low = high = 0;
+		g_xi.rumbleLow = g_xi.rumbleHigh = 0;
+	}
+	g_xi.rumblePending = false;
+	uint32_t generation = g_xiGeneration;
+	__set_PRIMASK(pm);
+	for (uint8_t s = 0; s < NSLOT; s++) {
+		if (stops & (1u << s))
+			hapticSteamRumble(0, 0, s);
 	}
+	if (pending && xiBondAlive(slot, now) && g_xiGeneration == generation &&
+	    g_xi.bond == slot)
+		hapticSteamRumble(low, high, (uint8_t)slot);
+	xinputAuthYield();
 }

+ 2 - 12
OpenPuck/mode_xinput.h

@@ -1,9 +1,4 @@
-// mode_xinput.h -- Xbox 360 wired (XInput) personality (MODE_XBOX): XInput gamepad + right-pad mouse.
-//
-// Real Xbox 360 pads are NOT HID: they use a vendor interface (class 0xFF / sub 0x5D / proto 0x01) carrying a
-// 20-byte XInput report. A custom TinyUSB class driver serves that interface plus a second boot-mouse interface
-// for the right trackpad. Host binds by VID/PID (045E:028E) + the FF/5D/01 interface. The OUT endpoint carries
-// rumble, relayed to the controller as a haptic by task().
+// Static wired Xbox 360 personality; one selected RF bond supplies input.
 #pragma once
 #include "controllers.h"
 #include <stdint.h>
@@ -13,16 +8,11 @@ class XboxController : public IController {
 	void begin() override;
 	void onReport45(int slot, const uint8_t *rep, bool fresh,
 			uint8_t bodyTlen) override;
-
-	// legacy XInput rumble -> haptic relay + stuck-rumble watchdog
 	void task() override;
 	bool dynamicMount() const override
 	{
-		return true;
+		return false;
 	}
-	uint8_t maxSlots() const override;
 	void usbIdentity() override;
-	void beginPool() override;
-	void mountSlots(uint8_t k) override;
 };
 extern XboxController g_xboxCtl;

+ 502 - 0
OpenPuck/src/libxsm3/LICENSE.txt

@@ -0,0 +1,502 @@
+                  GNU LESSER GENERAL PUBLIC LICENSE
+                       Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+ 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL.  It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.]
+
+                            Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+  This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it.  You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+  When we speak of free software, we are referring to freedom of use,
+not price.  Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+  To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights.  These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+  For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you.  You must make sure that they, too, receive or can get the source
+code.  If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it.  And you must show them these terms so they know their rights.
+
+  We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+  To protect each distributor, we want to make it very clear that
+there is no warranty for the free library.  Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+  Finally, software patents pose a constant threat to the existence of
+any free program.  We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder.  Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+  Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License.  This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License.  We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+  When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library.  The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom.  The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+  We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License.  It also provides other free software developers Less
+of an advantage over competing non-free programs.  These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries.  However, the Lesser license provides advantages in certain
+special circumstances.
+
+  For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard.  To achieve this, non-free programs must be
+allowed to use the library.  A more frequent case is that a free
+library does the same job as widely used non-free libraries.  In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+  In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software.  For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+  Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.  Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library".  The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+                  GNU LESSER GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+  A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+  The "Library", below, refers to any such software library or work
+which has been distributed under these terms.  A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language.  (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+  "Source code" for a work means the preferred form of the work for
+making modifications to it.  For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+  Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it).  Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+  1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+  You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+  2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) The modified work must itself be a software library.
+
+    b) You must cause the files modified to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    c) You must cause the whole of the work to be licensed at no
+    charge to all third parties under the terms of this License.
+
+    d) If a facility in the modified Library refers to a function or a
+    table of data to be supplied by an application program that uses
+    the facility, other than as an argument passed when the facility
+    is invoked, then you must make a good faith effort to ensure that,
+    in the event an application does not supply such function or
+    table, the facility still operates, and performs whatever part of
+    its purpose remains meaningful.
+
+    (For example, a function in a library to compute square roots has
+    a purpose that is entirely well-defined independent of the
+    application.  Therefore, Subsection 2d requires that any
+    application-supplied function or table used by this function must
+    be optional: if the application does not supply it, the square
+    root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library.  To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License.  (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.)  Do not make any other change in
+these notices.
+
+  Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+  This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+  4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+  If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library".  Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+  However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library".  The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+  When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library.  The
+threshold for this to be true is not precisely defined by law.
+
+  If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work.  (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+  Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+  6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+  You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License.  You must supply a copy of this License.  If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License.  Also, you must do one
+of these things:
+
+    a) Accompany the work with the complete corresponding
+    machine-readable source code for the Library including whatever
+    changes were used in the work (which must be distributed under
+    Sections 1 and 2 above); and, if the work is an executable linked
+    with the Library, with the complete machine-readable "work that
+    uses the Library", as object code and/or source code, so that the
+    user can modify the Library and then relink to produce a modified
+    executable containing the modified Library.  (It is understood
+    that the user who changes the contents of definitions files in the
+    Library will not necessarily be able to recompile the application
+    to use the modified definitions.)
+
+    b) Use a suitable shared library mechanism for linking with the
+    Library.  A suitable mechanism is one that (1) uses at run time a
+    copy of the library already present on the user's computer system,
+    rather than copying library functions into the executable, and (2)
+    will operate properly with a modified version of the library, if
+    the user installs one, as long as the modified version is
+    interface-compatible with the version that the work was made with.
+
+    c) Accompany the work with a written offer, valid for at
+    least three years, to give the same user the materials
+    specified in Subsection 6a, above, for a charge no more
+    than the cost of performing this distribution.
+
+    d) If distribution of the work is made by offering access to copy
+    from a designated place, offer equivalent access to copy the above
+    specified materials from the same place.
+
+    e) Verify that the user has already received a copy of these
+    materials or that you have already sent this user a copy.
+
+  For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it.  However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+  It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system.  Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+  7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+    a) Accompany the combined library with a copy of the same work
+    based on the Library, uncombined with any other library
+    facilities.  This must be distributed under the terms of the
+    Sections above.
+
+    b) Give prominent notice with the combined library of the fact
+    that part of it is a work based on the Library, and explaining
+    where to find the accompanying uncombined form of the same work.
+
+  8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License.  Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License.  However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+  9. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Library or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+  10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+  11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded.  In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+  13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation.  If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+  14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission.  For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this.  Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+                            NO WARRANTY
+
+  15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU.  SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+                     END OF TERMS AND CONDITIONS
+
+           How to Apply These Terms to Your New Libraries
+
+  If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change.  You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+  To apply these terms, attach the following notices to the library.  It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the library's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This library is free software; you can redistribute it and/or
+    modify it under the terms of the GNU Lesser General Public
+    License as published by the Free Software Foundation; either
+    version 2.1 of the License, or (at your option) any later version.
+
+    This library is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+    Lesser General Public License for more details.
+
+    You should have received a copy of the GNU Lesser General Public
+    License along with this library; if not, write to the Free Software
+    Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the
+  library `Frob' (a library for tweaking knobs) written by James Random Hacker.
+
+  <signature of Ty Coon>, 1 April 1990
+  Ty Coon, President of Vice
+
+That's all there is to it!

+ 162 - 0
OpenPuck/src/libxsm3/NOTICE.md

@@ -0,0 +1,162 @@
+# Vendored libxsm3
+
+Upstream: https://github.com/InvoxiPlayGames/libxsm3
+
+Pinned git revision: **a085709cce5fe0da19f4a5213f7b6f325d3aff7a**
+
+Vendored and locally modified for OpenPuck on **2026-09-17**. This is not an
+unmodified upstream release. All 12 upstream C source/header files and
+[LICENSE.txt](LICENSE.txt) are included; upstream README and git metadata are
+not required for compilation. Original source formatting is retained rather
+than applying the parent project's formatter. Final newlines are normalized.
+
+## Licensing and attribution
+
+libxsm3 is licensed under **GNU LGPL version 2.1 or (at your option) any later
+version**. See the complete [LICENSE.txt](LICENSE.txt) and the original notices
+in [xsm3.c](xsm3.c), [xsm3.h](xsm3.h), [usbdsec.c](usbdsec.c) and
+[usbdsec.h](usbdsec.h):
+
+- Copyright (C) 2022-2023 InvoxiPlayGames (xsm3.c).
+- Copyright (C) 2022 InvoxiPlayGames (xsm3.h, usbdsec.c, usbdsec.h).
+- Copyright (C) 2013 oct0xor (usbdsec.c).
+
+The files prefixed `excrypt` originate from
+[emoose/ExCrypt](https://github.com/emoose/ExCrypt) and are licensed under the
+**BSD 3-Clause License**, reproduced in full below. The authoritative notice is
+from [ExCrypt revision b2e037c3102de22d1107d1e362df4ce407d964ac](https://github.com/emoose/ExCrypt/blob/b2e037c3102de22d1107d1e362df4ce407d964ac/LICENSE),
+the exact license revision linked by libxsm3's upstream README. This identifies
+the license source, not an independently established revision for every
+vendored ExCrypt source file; the source pin is the libxsm3 revision above.
+
+Upstream credits retained here:
+
+- [oct0xor](https://github.com/oct0xor) for reversing, documenting and
+  implementing much of XSM3 ([write-up](https://oct0xor.github.io/2017/05/03/xsm3/),
+  [implementation](https://github.com/oct0xor/xbox_security_method_3)).
+- [emoose](https://github.com/emoose) for reimplementing XeCrypt functions in
+  ExCrypt.
+- [sanjay900](https://github.com/sanjay900) and an anonymous contributor for
+  helping discover the retail keys.
+- Upstream DES and SHA source attribution links to
+  [fffaraz/cppDES](https://github.com/fffaraz/cppDES) and
+  [mohaps/TinySHA1](https://github.com/mohaps/TinySHA1) are retained in source.
+
+Distributions must retain these notices and the license texts. Distributing a
+statically linked firmware also requires satisfying the LGPL's applicable
+source/relinking requirements; this notice alone is not a substitute for them.
+
+## Local changes and integration contract
+
+- [xsm3.c](xsm3.c), [xsm3.h](xsm3.h): `xsm3_initialise_state(void)` resets
+  `xsm3_has_kv_keys` along with the key buffers. Call this when starting a new
+  authentication session, then set identification data and optionally import
+  console-specific keys. The library retains upstream singleton state and is
+  not reentrant; firmware must serialize calls.
+- Firmware must provide `bool xsm3_random_bytes(uint8_t *buffer, size_t length);`
+  with C linkage (declared in [xsm3.h](xsm3.h)). It must fill the entire requested
+  buffer with fresh cryptographically suitable random bytes and return true;
+  return false on failure, which aborts the challenge without a response.
+  There is no predictable fallback and no `srand`, `rand`, `time` or debug
+  printing dependency.
+- `bool xsm3_do_challenge_init(uint8_t challenge_packet[0x22]);` and
+  `bool xsm3_do_challenge_verify(uint8_t challenge_packet[0x16]);` reject incorrect
+  length fields, XOR checksums and MACs with `false`. Both clear all 48 response
+  bytes before validation, so no stale or partial response is published on
+  failure. Init checks the MAC before changing session data. Verify uses a local
+  salt copy and commits it only after the incoming MAC passes.
+- **Firmware must check actual received lengths:** exactly 34 bytes for init
+  and 22 for verify. C array parameter declarations do not enforce lengths.
+  The library checks byte 4 equals 28 (init) or 16 (verify) *before* calling the
+  checksum routine; it does not validate USB transfer lengths or impose an
+  authentication state machine. Inputs must be non-NULL and must not alias the
+  global response buffer. The caller must require successful init before verify,
+  abort authentication on `false`, and never report completion or transmit a
+  response on failure. Successful responses occupy 46 bytes (init) or 22 bytes
+  (verify), including header and checksum; the remaining response bytes are zero.
+- Identification data likewise requires an actual 29-byte buffer; invalid
+  length field (not 23) or checksum clears stored identification data and returns
+  without importing it. Its existing `void` API is retained. Checksum indexing
+  uses `size_t` rather than an overflowing 8-bit temporary.
+- [usbdsec.c](usbdsec.c), [usbdsec.h](usbdsec.h): salt increment and MAC/ACR XORs
+  use bytes, preserving big-endian counter carry/wraparound and in-place salt
+  mutation. The declaration now correctly marks salt mutable and includes
+  `<stdint.h>`; both XSM3 and USB security headers have C++ linkage guards.
+- [excrypt_des.c](excrypt_des.c): key/IV loads and IV stores use `memcpy` instead
+  of integer dereferences. The existing byte-safe ECB/CBC input/output copies
+  are retained, including in-place decryption behavior.
+- [excrypt_parve.c](excrypt_parve.c), [excrypt_parve.h](excrypt_parve.h):
+  `ExCryptChainAndSumMac` now accepts byte pointers for `cd`, `ab`, `input` and
+  `output` (its count is still in 32-bit words). Big-endian loads use `memcpy`
+  plus byte swap; output uses exact 32-bit stores. ACR no longer casts byte
+  buffers (including response + 5) to integer pointers. Other Parve accesses
+  already use byte operations or `memcpy` and are retained.
+- [excrypt_sha.c](excrypt_sha.c): remove the unused duplicate `state`
+  declaration that prevents upstream compilation. Its word buffer is accessed
+  through permitted byte aliases and `memcpy`. The upstream single-block SHA-1
+  implementation is retained: **only inputs of 0 through 55 bytes are supported**
+  (XSM3 uses 8 and 32); it is not a general-purpose SHA-1 API. DES/CBC and MAC
+  helpers likewise require whole 8-byte blocks and adequately sized buffers.
+
+These changes target GCC/Clang and the little-endian ARM Cortex-M4. They do not
+add hardware RNG glue, USB request handling, firmware authentication state,
+console tests or changes to parent build/format configuration.
+
+## Native validation (2026-09-17)
+
+Validation used transient compiler-stdin harnesses, not firmware glue or
+committed test sources:
+
+- GCC 16 C11 compilation at `-O2` with `-Wall -Wextra -Werror`, strict aliasing,
+  `-Wcast-align=strict` and `-Wstrict-aliasing=2`; C++11 linkage and bool API
+  type checks; object symbols confirm no printing, libc PRNG or clock dependency.
+- Standard DES known-answer vector and all SHA-1 lengths 0..55 against Python
+  `hashlib`; 128 differential cases each for 3DES, salted/unsalted MAC, ACR,
+  Parve ECB/CBC and chain-and-sum, including unaligned buffers, in-place CBC
+  decryption and salt carry/wraparound.
+- 24 init responses and 96 successive verify responses matched upstream
+  byte-for-byte. Both harnesses used identical deterministic test-only random
+  bytes. The upstream baseline required removing its duplicate SHA declaration
+  in compiler input and explicitly refreshing its KV keys between sessions
+  because its reset flag is broken; no upstream files were edited.
+- UBSan trap-mode execution at offsets 0..7 covered exact-size packet buffers,
+  every header-length byte, bad checksums, each ciphertext/MAC byte corrupted
+  with recomputed checksums, all-zero failed responses, unchanged session data
+  on rejection, RNG invocation, imported/generated key reset, repeated valid
+  challenges and crypto buffer alignment. All checks passed.
+- ASan execution was unavailable because the host's GCC sanitizer shared
+  libraries are missing. UBSan trap mode needs no runtime and passed; it is not
+  a substitute for ASan heap-bound checking. ARM firmware builds and physical
+  console validation remain integration tasks.
+
+## ExCrypt BSD 3-Clause notice
+
+BSD 3-Clause License
+
+Copyright (c) 2020, emoose
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 43 - 0
OpenPuck/src/libxsm3/excrypt.h

@@ -0,0 +1,43 @@
+#ifndef EXCRYPT_H_
+#define EXCRYPT_H_
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+#include <stdint.h>
+
+#if(__BYTE_ORDER__ == __ORDER_BIG_ENDIAN__)
+#define SWAP16(i) i
+#define SWAP32(i) i
+#define SWAP64(i) i
+#elif defined(__clang__) || defined(__GNUC__)
+#define SWAP16(i) __builtin_bswap16(i)
+#define SWAP32(i) __builtin_bswap32(i)
+#define SWAP64(i) __builtin_bswap64(i)
+#else
+#define SWAP16(i) ((((i) & 0xFF) << 8 | ((i) >> 8) & 0xFF) & 0xFFFF)
+#define SWAP32(i) (((i) & 0xff) << 24 | ((i) & 0xff00) << 8 | ((i) & 0xff0000) >> 8 | ((i) >> 24) & 0xff)
+#define SWAP64(i) (SWAP32((i) & 0xFFFFFFFF) << 32 | SWAP32(((i) >> 32) & 0xFFFFFFFF))
+#endif
+
+#define U8V(data) ((uint8_t)(data) & 0xFF)
+#define ROTL8(data, bits) (U8V((data) << (bits)) | ((data) >> (8 - (bits))))
+
+#define U16V(data) ((uint16_t)(data) & 0xFFFF)
+#define ROTL16(data, bits) (U16V((data) << (bits)) | ((data) >> (16 - (bits))))
+
+#define U32V(data) ((uint32_t)(data) & 0xFFFFFFFF)
+#define ROTL32(data, bits) (U32V((data) << (bits)) | ((data) >> (32 - (bits))))
+
+#define ROTL64(data, bits) (((data) << (bits)) | ((data) >> (64 - (bits))))
+
+typedef int BOOL;
+
+#include "excrypt_des.h"
+#include "excrypt_sha.h"
+#include "excrypt_parve.h"
+#ifdef __cplusplus
+}
+#endif
+
+#endif // EXCRYPT_H_

+ 174 - 0
OpenPuck/src/libxsm3/excrypt_des.c

@@ -0,0 +1,174 @@
+/* OpenPuck modifications, 2026-09-17: memcpy key/IV accesses for alignment
+   and strict-aliasing safety. See NOTICE.md. */
+
+#include "excrypt.h"
+#include "excrypt_des_data.h"
+#include <string.h>
+
+#define LODWORD(_qw)    ((uint32_t)(_qw))
+#define HIDWORD(_qw)    ((uint32_t)(((_qw) >> 32) & 0xffffffff))
+
+// DES code based on https://github.com/fffaraz/cppDES
+
+void ExCryptDesParity(const uint8_t* input, uint32_t input_size, uint8_t* output)
+{
+  for (uint32_t i = 0; i < input_size; i++)
+  {
+    uint8_t parity = input[i];
+
+    parity ^= parity >> 4;
+    parity ^= parity >> 2;
+    parity ^= parity >> 1;
+
+    output[i] = (input[i] & 0xFE) | (~parity & 1);
+  }
+}
+
+uint64_t permutation(uint64_t block, uint8_t bits, uint8_t shift, const char* table)
+{
+  uint64_t result = 0;
+  for (int i = 0; i < bits; i++)
+  {
+    result <<= 1;
+    result |= (block >> (shift - table[i])) & LB64_MASK;
+  }
+  return result;
+}
+
+void ExCryptDesKey(EXCRYPT_DES_STATE* state, const uint64_t* key)
+{
+  uint64_t qkey;
+  memcpy(&qkey, key, sizeof(qkey));
+  qkey = SWAP64(qkey);
+
+  // initial key schedule calculation
+  uint64_t permuted_choice_1 = permutation(qkey, 56, 64, PC1); // 56 bits
+
+  // 28 bits
+  uint32_t C = (uint32_t)((permuted_choice_1 >> 28) & 0x000000000fffffff);
+  uint32_t D = (uint32_t)(permuted_choice_1 & 0x000000000fffffff);
+
+  // Calculation of the 16 keys
+  for (int i = 0; i < 16; i++)
+  {
+    // key schedule, shifting Ci and Di
+    for (int j = 0; j < ITERATION_SHIFT[i]; j++)
+    {
+      C = (0x0fffffff & (C << 1)) | (0x00000001 & (C >> 27));
+      D = (0x0fffffff & (D << 1)) | (0x00000001 & (D >> 27));
+    }
+
+    uint64_t permuted_choice_2 = (((uint64_t)C) << 28) | (uint64_t)D;
+
+    state->keytab[i] = permutation(permuted_choice_2, 48, 56, PC2); // 48 bits (2*24)
+  }
+}
+
+uint32_t f(uint32_t R, uint64_t k)
+{
+  // applying expansion permutation and returning 48-bit data
+  uint64_t s_input = permutation(R, 48, 32, EXPANSION);
+
+  // XORing expanded Ri with Ki, the round key
+  s_input = s_input ^ k;
+
+  // applying S-Boxes function and returning 32-bit data
+  uint32_t s_output = 0;
+  for (int i = 0; i < 8; i++)
+  {
+    // Outer bits
+    char row = (char)((s_input & (0x0000840000000000 >> 6 * i)) >> (42 - 6 * i));
+    row = (row >> 4) | (row & 0x01);
+
+    // Middle 4 bits of input
+    char column = (char)((s_input & (0x0000780000000000 >> 6 * i)) >> (43 - 6 * i));
+
+    s_output <<= 4;
+    s_output |= (uint32_t)(SBOX[i][16 * row + column] & 0x0f);
+  }
+
+  // applying the round permutation
+  return permutation(s_output, 32, 32, PBOX);
+}
+
+void ExCryptDesEcb(const EXCRYPT_DES_STATE* state, const uint8_t* input, uint8_t* output, uint8_t encrypt)
+{
+  uint64_t block;
+  memcpy(&block, input, sizeof(uint64_t));
+  block = SWAP64(block);
+
+  // initial permutation
+  uint64_t result = permutation(block, 64, 64, IP);
+
+  // dividing T' into two 32-bit parts
+  uint32_t L = HIDWORD(result);
+  uint32_t R = LODWORD(result);
+
+  // 16 rounds
+  for (int i = 0; i < 16; i++)
+  {
+    uint32_t F = L ^ f(R, state->keytab[!encrypt ? (15 - i) : i]);
+    L = R;
+    R = F;
+  }
+
+  // swapping the two parts
+  block = (((uint64_t)R) << 32) | (uint64_t)L;
+
+  // inverse initial permutation
+  result = permutation(block, 64, 64, FP);
+  result = SWAP64(result);
+  memcpy(output, &result, sizeof(result));
+}
+
+void ExCryptDes3Key(EXCRYPT_DES3_STATE* state, const uint64_t* keys)
+{
+  ExCryptDesKey(&state->des_state[0], &keys[0]);
+  ExCryptDesKey(&state->des_state[1], &keys[1]);
+  ExCryptDesKey(&state->des_state[2], &keys[2]);
+}
+
+void ExCryptDes3Ecb(const EXCRYPT_DES3_STATE* state, const uint8_t* input, uint8_t* output, uint8_t encrypt)
+{
+  if (encrypt)
+  {
+    ExCryptDesEcb(&state->des_state[0], input, output, encrypt);
+    ExCryptDesEcb(&state->des_state[1], output, output, !encrypt);
+    ExCryptDesEcb(&state->des_state[2], output, output, encrypt);
+  }
+  else
+  {
+    ExCryptDesEcb(&state->des_state[2], input, output, encrypt);
+    ExCryptDesEcb(&state->des_state[1], output, output, !encrypt);
+    ExCryptDesEcb(&state->des_state[0], output, output, encrypt);
+  }
+}
+
+void ExCryptDes3Cbc(const EXCRYPT_DES3_STATE* state, const uint8_t* input, uint32_t input_size, uint8_t* output, uint8_t* feed, uint8_t encrypt)
+{
+  uint64_t last_block;
+  memcpy(&last_block, feed, sizeof(last_block));
+  uint64_t intemp;
+  uint64_t outtemp;
+  for (uint32_t i = 0; i < input_size / 8; i++)
+  {
+    memcpy(&intemp, input, sizeof(intemp));
+    if (encrypt) {
+      intemp = intemp ^ last_block;
+      memcpy(output, &intemp, sizeof(intemp));
+      ExCryptDes3Ecb(state, output, output, encrypt);
+      memcpy(&last_block, output, sizeof(last_block));
+    }
+    else
+    {
+      ExCryptDes3Ecb(state, input, output, encrypt);
+      memcpy(&outtemp, output, sizeof(outtemp));
+      outtemp = outtemp ^ last_block;
+      memcpy(output, &outtemp, sizeof(outtemp));
+      last_block = intemp;
+    }
+    input += 8;
+    output += 8;
+  }
+  memcpy(feed, &last_block, sizeof(last_block));
+}

+ 24 - 0
OpenPuck/src/libxsm3/excrypt_des.h

@@ -0,0 +1,24 @@
+#ifndef EXCRYPT_DES_H_
+#define EXCRYPT_DES_H_
+// DES & 3DES functions
+
+typedef struct _EXCRYPT_DES_STATE
+{
+  uint64_t keytab[16];
+} EXCRYPT_DES_STATE;
+
+void ExCryptDesParity(const uint8_t* input, uint32_t input_size, uint8_t* output);
+
+void ExCryptDesKey(EXCRYPT_DES_STATE* state, const uint64_t* key);
+void ExCryptDesEcb(const EXCRYPT_DES_STATE* state, const uint8_t* input, uint8_t* output, uint8_t encrypt);
+
+typedef struct _EXCRYPT_DES3_STATE
+{
+  EXCRYPT_DES_STATE des_state[3];
+} EXCRYPT_DES3_STATE;
+
+void ExCryptDes3Key(EXCRYPT_DES3_STATE* state, const uint64_t* keys);
+void ExCryptDes3Ecb(const EXCRYPT_DES3_STATE* state, const uint8_t* input, uint8_t* output, uint8_t encrypt);
+void ExCryptDes3Cbc(const EXCRYPT_DES3_STATE* state, const uint8_t* input, uint32_t input_size, uint8_t* output, uint8_t* feed, uint8_t encrypt);
+
+#endif // EXCRYPT_DES_H_

+ 157 - 0
OpenPuck/src/libxsm3/excrypt_des_data.h

@@ -0,0 +1,157 @@
+#ifndef EXCRYPT_DES_DATA_H_
+#define EXCRYPT_DES_DATA_H_
+// Data needed by DES/3DES functions
+// (only included by excrypt_des.c - no headers should include this!)
+
+#define LB32_MASK 0x00000001
+#define LB64_MASK 0x0000000000000001
+#define L64_MASK  0x00000000ffffffff
+
+// Initial Permutation Table [8*8]
+static const char IP[] =
+{
+    58, 50, 42, 34, 26, 18, 10, 2,
+    60, 52, 44, 36, 28, 20, 12, 4,
+    62, 54, 46, 38, 30, 22, 14, 6,
+    64, 56, 48, 40, 32, 24, 16, 8,
+    57, 49, 41, 33, 25, 17,  9, 1,
+    59, 51, 43, 35, 27, 19, 11, 3,
+    61, 53, 45, 37, 29, 21, 13, 5,
+    63, 55, 47, 39, 31, 23, 15, 7
+};
+
+// Inverse Initial Permutation Table [8*8]
+static const char FP[] =
+{
+    40, 8, 48, 16, 56, 24, 64, 32,
+    39, 7, 47, 15, 55, 23, 63, 31,
+    38, 6, 46, 14, 54, 22, 62, 30,
+    37, 5, 45, 13, 53, 21, 61, 29,
+    36, 4, 44, 12, 52, 20, 60, 28,
+    35, 3, 43, 11, 51, 19, 59, 27,
+    34, 2, 42, 10, 50, 18, 58, 26,
+    33, 1, 41,  9, 49, 17, 57, 25
+};
+
+// Expansion table [6*8]
+static const char EXPANSION[] =
+{
+    32,  1,  2,  3,  4,  5,
+     4,  5,  6,  7,  8,  9,
+     8,  9, 10, 11, 12, 13,
+    12, 13, 14, 15, 16, 17,
+    16, 17, 18, 19, 20, 21,
+    20, 21, 22, 23, 24, 25,
+    24, 25, 26, 27, 28, 29,
+    28, 29, 30, 31, 32,  1
+};
+
+// The S-Box tables [8*16*4]
+static const char SBOX[8][64] =
+{
+    {
+    // S1
+    14,  4, 13,  1,  2, 15, 11,  8,  3, 10,  6, 12,  5,  9,  0,  7,
+     0, 15,  7,  4, 14,  2, 13,  1, 10,  6, 12, 11,  9,  5,  3,  8,
+     4,  1, 14,  8, 13,  6,  2, 11, 15, 12,  9,  7,  3, 10,  5,  0,
+    15, 12,  8,  2,  4,  9,  1,  7,  5, 11,  3, 14, 10,  0,  6, 13
+},
+{
+  // S2
+  15,  1,  8, 14,  6, 11,  3,  4,  9,  7,  2, 13, 12,  0,  5, 10,
+   3, 13,  4,  7, 15,  2,  8, 14, 12,  0,  1, 10,  6,  9, 11,  5,
+   0, 14,  7, 11, 10,  4, 13,  1,  5,  8, 12,  6,  9,  3,  2, 15,
+  13,  8, 10,  1,  3, 15,  4,  2, 11,  6,  7, 12,  0,  5, 14,  9
+},
+{
+  // S3
+  10,  0,  9, 14,  6,  3, 15,  5,  1, 13, 12,  7, 11,  4,  2,  8,
+  13,  7,  0,  9,  3,  4,  6, 10,  2,  8,  5, 14, 12, 11, 15,  1,
+  13,  6,  4,  9,  8, 15,  3,  0, 11,  1,  2, 12,  5, 10, 14,  7,
+   1, 10, 13,  0,  6,  9,  8,  7,  4, 15, 14,  3, 11,  5,  2, 12
+},
+{
+  // S4
+   7, 13, 14,  3,  0,  6,  9, 10,  1,  2,  8,  5, 11, 12,  4, 15,
+  13,  8, 11,  5,  6, 15,  0,  3,  4,  7,  2, 12,  1, 10, 14,  9,
+  10,  6,  9,  0, 12, 11,  7, 13, 15,  1,  3, 14,  5,  2,  8,  4,
+   3, 15,  0,  6, 10,  1, 13,  8,  9,  4,  5, 11, 12,  7,  2, 14
+},
+{
+  // S5
+   2, 12,  4,  1,  7, 10, 11,  6,  8,  5,  3, 15, 13,  0, 14,  9,
+  14, 11,  2, 12,  4,  7, 13,  1,  5,  0, 15, 10,  3,  9,  8,  6,
+   4,  2,  1, 11, 10, 13,  7,  8, 15,  9, 12,  5,  6,  3,  0, 14,
+  11,  8, 12,  7,  1, 14,  2, 13,  6, 15,  0,  9, 10,  4,  5,  3
+},
+{
+  // S6
+  12,  1, 10, 15,  9,  2,  6,  8,  0, 13,  3,  4, 14,  7,  5, 11,
+  10, 15,  4,  2,  7, 12,  9,  5,  6,  1, 13, 14,  0, 11,  3,  8,
+   9, 14, 15,  5,  2,  8, 12,  3,  7,  0,  4, 10,  1, 13, 11,  6,
+   4,  3,  2, 12,  9,  5, 15, 10, 11, 14,  1,  7,  6,  0,  8, 13
+},
+{
+  // S7
+   4, 11,  2, 14, 15,  0,  8, 13,  3, 12,  9,  7,  5, 10,  6,  1,
+  13,  0, 11,  7,  4,  9,  1, 10, 14,  3,  5, 12,  2, 15,  8,  6,
+   1,  4, 11, 13, 12,  3,  7, 14, 10, 15,  6,  8,  0,  5,  9,  2,
+   6, 11, 13,  8,  1,  4, 10,  7,  9,  5,  0, 15, 14,  2,  3, 12
+},
+{
+  // S8
+  13,  2,  8,  4,  6, 15, 11,  1, 10,  9,  3, 14,  5,  0, 12,  7,
+   1, 15, 13,  8, 10,  3,  7,  4, 12,  5,  6, 11,  0, 14,  9,  2,
+   7, 11,  4,  1,  9, 12, 14,  2,  0,  6, 10, 13, 15,  3,  5,  8,
+   2,  1, 14,  7,  4, 10,  8, 13, 15, 12,  9,  0,  3,  5,  6, 11
+}
+};
+
+// Post S-Box permutation [4*8]
+static const char PBOX[] =
+{
+    16,  7, 20, 21,
+    29, 12, 28, 17,
+     1, 15, 23, 26,
+     5, 18, 31, 10,
+     2,  8, 24, 14,
+    32, 27,  3,  9,
+    19, 13, 30,  6,
+    22, 11,  4, 25
+};
+
+// Permuted Choice 1 Table [7*8]
+static const char PC1[] =
+{
+    57, 49, 41, 33, 25, 17,  9,
+     1, 58, 50, 42, 34, 26, 18,
+    10,  2, 59, 51, 43, 35, 27,
+    19, 11,  3, 60, 52, 44, 36,
+
+    63, 55, 47, 39, 31, 23, 15,
+     7, 62, 54, 46, 38, 30, 22,
+    14,  6, 61, 53, 45, 37, 29,
+    21, 13,  5, 28, 20, 12,  4
+};
+
+// Permuted Choice 2 Table [6*8]
+static const char PC2[] =
+{
+    14, 17, 11, 24,  1,  5,
+     3, 28, 15,  6, 21, 10,
+    23, 19, 12,  4, 26,  8,
+    16,  7, 27, 20, 13,  2,
+    41, 52, 31, 37, 47, 55,
+    30, 40, 51, 45, 33, 48,
+    44, 49, 39, 56, 34, 53,
+    46, 42, 50, 36, 29, 32
+};
+
+// Iteration Shift Array
+static const char ITERATION_SHIFT[] =
+{
+  //  1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  16
+      1,  1,  2,  2,  2,  2,  2,  2,  1,  2,  2,  2,  2,  2,  2,  1
+};
+
+#endif // EXCRYPT_DES_DATA_H_

+ 88 - 0
OpenPuck/src/libxsm3/excrypt_parve.c

@@ -0,0 +1,88 @@
+/* OpenPuck modifications, 2026-09-17: byte-buffer chain-and-sum accesses
+   and 32-bit result stores for alignment/endian safety. See NOTICE.md. */
+
+#include <stdlib.h>
+#include <string.h>
+
+#include "excrypt.h"
+
+void ExCryptParveEcb(const uint8_t* key, const uint8_t* sbox, const uint8_t* input, uint8_t* output)
+{
+  uint8_t block[9];
+
+  memcpy(block, input, 8);
+  block[8] = block[0];
+
+  for (int i = 8; i > 0; i--)
+  {
+    for (int j = 0; j < 8; j++)
+    {
+      uint8_t x = key[j] + block[j] + i;
+      uint8_t y = sbox[x] + block[j + 1];
+      block[j + 1] = ROTL8(y, 1);
+    }
+
+    block[0] = block[8];
+  }
+
+  memcpy(output, block, 8);
+}
+
+void ExCryptParveCbcMac(const uint8_t* key, const uint8_t* sbox, const uint8_t* iv, const uint8_t* input, uint32_t input_size, uint8_t* output)
+{
+  uint64_t block;
+  uint64_t temp;
+  memcpy(&block, iv, 8);
+
+  if (input_size >= 8)
+  {
+    for (uint32_t i = 0; i < input_size / 8; i++)
+    {
+      memcpy(&temp, input + (i * 8), sizeof(temp));
+      block ^= temp;
+      ExCryptParveEcb(key, sbox, (uint8_t*)&block, (uint8_t*)&block);
+    }
+  }
+
+  memcpy(output, &block, 8);
+}
+
+static uint32_t load_be32(const uint8_t* input)
+{
+  uint32_t value;
+  memcpy(&value, input, sizeof(value));
+  return SWAP32(value);
+}
+
+void ExCryptChainAndSumMac(const uint8_t* cd, const uint8_t* ab, const uint8_t* input, uint32_t input_dwords, uint8_t* output)
+{
+  uint64_t out0 = 0;
+  uint64_t out1 = 0;
+
+  uint32_t ab0 = load_be32(ab) % 0x7FFFFFFF;
+  uint32_t ab1 = load_be32(ab + 4) % 0x7FFFFFFF;
+  uint32_t cd0 = load_be32(cd) % 0x7FFFFFFF;
+  uint32_t cd1 = load_be32(cd + 4) % 0x7FFFFFFF;
+
+  for (uint32_t i = 0; i < input_dwords / 2; i++)
+  {
+    out0 += (uint64_t)load_be32(input) * 0xE79A9C1;
+    out0 = (out0 % 0x7FFFFFFF) * ab0;
+    out0 += ab1;
+    out0 = out0 % 0x7FFFFFFF;
+
+    out1 += out0;
+
+    out0 = (uint64_t)(load_be32(input + 4) + out0) * cd0;
+    out0 = (out0 % 0x7FFFFFFF) + cd1;
+    out0 = out0 % 0x7FFFFFFF;
+
+    out1 += out0;
+
+    input += 8;
+  }
+  uint32_t result0 = SWAP32((uint32_t)((out0 + ab1) % 0x7FFFFFFF));
+  uint32_t result1 = SWAP32((uint32_t)((out1 + cd1) % 0x7FFFFFFF));
+  memcpy(output, &result0, sizeof(result0));
+  memcpy(output + 4, &result1, sizeof(result1));
+}

+ 12 - 0
OpenPuck/src/libxsm3/excrypt_parve.h

@@ -0,0 +1,12 @@
+/* OpenPuck modifications, 2026-09-17: byte-buffer chain-and-sum interface
+   avoids unaligned integer pointer casts. See NOTICE.md. */
+
+#ifndef EXCRYPT_PARVE_H_
+#define EXCRYPT_PARVE_H_
+// "Parve" functions, seem to be used during controller auth
+
+void ExCryptParveEcb(const uint8_t* key, const uint8_t* sbox, const uint8_t* input, uint8_t* output);
+void ExCryptParveCbcMac(const uint8_t* key, const uint8_t* sbox, const uint8_t* iv, const uint8_t* input, uint32_t input_size, uint8_t* output);
+void ExCryptChainAndSumMac(const uint8_t* cd, const uint8_t* ab, const uint8_t* input, uint32_t input_dwords, uint8_t* output);
+
+#endif // EXCRYPT_PARVE_H_

+ 75 - 0
OpenPuck/src/libxsm3/excrypt_sha.c

@@ -0,0 +1,75 @@
+/* OpenPuck modifications, 2026-09-17: remove duplicate state declaration
+   that prevents compilation. See NOTICE.md. */
+
+#include <stdlib.h>
+#include <string.h>
+
+#include "excrypt.h"
+
+// SHA1 code based on https://github.com/mohaps/TinySHA1
+
+const uint32_t intermediate[5] = {0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0};
+void ExCryptSha(const uint8_t *input, uint32_t input_size, uint8_t *output)
+{
+  uint32_t w[80] = {0};
+  memcpy(w, input, input_size);
+
+  uint8_t *buffer = (uint8_t *)w;
+  buffer[input_size] = 0x80;
+
+  for (size_t i = 0; i < 15; i++)
+  {
+    w[i] = SWAP32(w[i]);
+  }
+
+  w[15] = input_size * 8;
+  uint32_t a = intermediate[0];
+  uint32_t b = intermediate[1];
+  uint32_t c = intermediate[2];
+  uint32_t d = intermediate[3];
+  uint32_t e = intermediate[4];
+  uint32_t temp, f, k;
+
+  for (int i = 0; i < 80; ++i)
+  {
+    if (i >= 16) {
+      w[i] = ROTL32((w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]), 1);
+    }
+
+    if (i < 20)
+    {
+      f = (b & c) | (~b & d);
+      k = 0x5A827999;
+    }
+    else if (i < 40)
+    {
+      f = b ^ c ^ d;
+      k = 0x6ED9EBA1;
+    }
+    else if (i < 60)
+    {
+      f = (b & c) | (b & d) | (c & d);
+      k = 0x8F1BBCDC;
+    }
+    else
+    {
+      f = b ^ c ^ d;
+      k = 0xCA62C1D6;
+    }
+    temp = ROTL32(a, 5) + f + e + k + w[i];
+    e = d;
+    d = c;
+    c = ROTL32(b, 30);
+    b = a;
+    a = temp;
+  }
+
+  a += intermediate[0];
+  b += intermediate[1];
+  c += intermediate[2];
+  d += intermediate[3];
+  e += intermediate[4];
+  uint32_t state[5] = {SWAP32(a), SWAP32(b), SWAP32(c), SWAP32(d), SWAP32(e)};
+
+  memcpy(output, state, sizeof(state));
+}

+ 7 - 0
OpenPuck/src/libxsm3/excrypt_sha.h

@@ -0,0 +1,7 @@
+#ifndef EXCRYPT_SHA_H_
+#define EXCRYPT_SHA_H_
+// SHA1 hash & HMAC algorithm
+
+void ExCryptSha(const uint8_t* input1, uint32_t input1_size, uint8_t* output);
+
+#endif // EXCRYPT_SHA_H_

+ 156 - 0
OpenPuck/src/libxsm3/usbdsec.c

@@ -0,0 +1,156 @@
+/*
+    usbdsec.c - part of libxsm3
+    Copyright (C) 2013 oct0xor
+    Copyright (C) 2022 InvoxiPlayGames
+
+    This library is free software; you can redistribute it and/or
+    modify it under the terms of the GNU Lesser General Public
+    License as published by the Free Software Foundation; either
+    version 2.1 of the License, or (at your option) any later version.
+
+    This library is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+    Lesser General Public License for more details.
+
+    You should have received a copy of the GNU Lesser General Public
+    License along with this library; if not, write to the Free Software
+    Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+*/
+
+/* OpenPuck modifications, 2026-09-17: bytewise salt/XOR/ACR accesses,
+   matching header inclusion and no stdio dependency. See NOTICE.md. */
+
+#include <string.h>
+#include "excrypt.h"
+#include "usbdsec.h"
+
+static const uint8_t UsbdSecSboxData[256] __attribute__ ((aligned(4))) = {
+	0xB0, 0x3D, 0x9B, 0x70, 0xF3, 0xC7, 0x80, 0x60,
+	0x73, 0x9F, 0x6C, 0xC0, 0xF1, 0x3D, 0xBB, 0x40,
+	0xB3, 0xC8, 0x37, 0x14, 0xDF, 0x49, 0xDA, 0xD4,
+	0x48, 0x22, 0x78, 0x80, 0x6E, 0xCD, 0xE7, 0x00,
+	0x81, 0x86, 0x68, 0xE1, 0x5D, 0x7C, 0x54, 0x2C,
+	0x55, 0x7B, 0xEF, 0x48, 0x42, 0x7B, 0x3B, 0x68,
+	0xE3, 0xDB, 0xAA, 0xC0, 0x0F, 0xA9, 0x96, 0x20,
+	0x95, 0x05, 0x93, 0x94, 0x9A, 0xF6, 0xA3, 0x64,
+	0x5D, 0xCC, 0x76, 0x00, 0xE5, 0x08, 0x19, 0xE8,
+	0x8D, 0x29, 0xD7, 0x4C, 0x21, 0x91, 0x17, 0xF4,
+	0xBC, 0x6A, 0xB3, 0x80, 0x83, 0xC6, 0xD4, 0x90,
+	0x9B, 0xAE, 0x0E, 0xFE, 0x2E, 0x4A, 0xF2, 0x00,
+	0x73, 0x88, 0xD9, 0x40, 0x66, 0xC5, 0xD4, 0x08,
+	0x57, 0xB1, 0x89, 0x48, 0xDC, 0x54, 0xFC, 0x43,
+	0x6A, 0x26, 0x87, 0xB8, 0x09, 0x5F, 0xCE, 0x80,
+	0xE4, 0x0B, 0x05, 0x9C, 0x24, 0xF3, 0xDE, 0xE2,
+	0x3E, 0xEC, 0x38, 0x8A, 0xA2, 0x55, 0xA4, 0x50,
+	0x4E, 0x4B, 0xE9, 0x58, 0x7F, 0x9F, 0x7D, 0x80,
+	0x23, 0x0C, 0x4D, 0x80, 0x05, 0x44, 0x26, 0xB8,
+	0xE9, 0xD8, 0xBC, 0xE6, 0x76, 0x3A, 0x6E, 0xA4,
+	0x19, 0xDE, 0xC2, 0xD0, 0xC4, 0xBC, 0xC3, 0x5C,
+	0x59, 0xDF, 0x16, 0x46, 0x39, 0x70, 0xF4, 0xEE,
+	0x2D, 0x58, 0x5A, 0xA8, 0x17, 0x86, 0x6B, 0x60,
+	0x29, 0x58, 0x4D, 0xD2, 0x5F, 0x28, 0x7A, 0xD8,
+	0x8E, 0x79, 0xEA, 0x82, 0x94, 0x33, 0x31, 0x81,
+	0xD9, 0x22, 0xD5, 0x10, 0xDA, 0x92, 0xA0, 0x7D,
+	0x3D, 0xDA, 0xAC, 0x1C, 0xA2, 0x53, 0x31, 0xB8,
+	0x3C, 0x96, 0x52, 0x00, 0x82, 0x6B, 0x56, 0xA0,
+	0xD3, 0xC2, 0x40, 0xC7, 0x1B, 0x7F, 0xDC, 0x01,
+	0x72, 0x70, 0xB1, 0x8C, 0x01, 0x09, 0x09, 0x36,
+	0xFC, 0x97, 0xEA, 0xDE, 0xE3, 0x0D, 0xAE, 0x7E,
+	0xE3, 0x0D, 0xAE, 0x7E, 0x33, 0x69, 0x80, 0x40
+};
+
+static const uint8_t UsbdSecPlainTextData[128] __attribute__ ((aligned(4))) = {
+	0xD1, 0xD2, 0xF2, 0x80, 0x6E, 0xBA, 0x0C, 0xC0,
+	0xB6, 0xC4, 0xC9, 0xD8, 0x61, 0x75, 0x1D, 0x1A,
+	0x3F, 0x95, 0x58, 0xBE, 0xD8, 0x0D, 0xE2, 0xC0,
+	0xD0, 0x21, 0x79, 0x20, 0x65, 0x2D, 0x99, 0x40,
+	0x3C, 0x96, 0x52, 0x00, 0x1B, 0x7F, 0xDC, 0x01,
+	0x82, 0x1C, 0x13, 0xD8, 0x33, 0x69, 0x80, 0x40,
+	0xFC, 0x97, 0xEA, 0xDE, 0x08, 0xEA, 0x14, 0xDC,
+	0xEB, 0x0F, 0x6A, 0x18, 0x6F, 0x78, 0x2C, 0xB0,
+	0xD3, 0xC2, 0x40, 0xC7, 0x82, 0x6B, 0x56, 0xA0,
+	0x19, 0x09, 0x36, 0xE0, 0x72, 0x70, 0xB1, 0x8C,
+	0xE3, 0x0D, 0xAE, 0x7E, 0x50, 0xA5, 0x2B, 0xE2,
+	0xC9, 0xAF, 0xC7, 0x70, 0x1C, 0x29, 0x80, 0x56,
+	0x24, 0xF0, 0x66, 0xFA, 0x02, 0x2B, 0x58, 0x98,
+	0x8F, 0xE4, 0xD1, 0x3C, 0x6E, 0x38, 0x2A, 0xFF,
+	0xB8, 0xFA, 0x35, 0xB0, 0x52, 0x49, 0xC5, 0xB4,
+	0x66, 0xFA, 0x47, 0x55, 0x6C, 0x8D, 0x40, 0x08
+};
+
+void UsbdSecXSM3AuthenticationCrypt(const uint8_t *key, const uint8_t *input, size_t length, uint8_t *output, uint8_t encrypt) {
+	EXCRYPT_DES3_STATE des;
+	uint64_t sk[3];
+	uint8_t iv[8];
+
+	// clear local variables
+    memset(iv, 0, sizeof(iv));
+	// run parity on the key
+	ExCryptDesParity(key, 0x10, (uint8_t *)sk);
+	sk[2] = sk[0];
+	// set the key in the state and run triple-des cbc en/decryption on it
+	ExCryptDes3Key(&des, sk);
+	ExCryptDes3Cbc(&des, input, length, output, iv, encrypt);
+}
+
+void UsbdSecXSM3AuthenticationMac(const uint8_t *key, uint8_t *salt, uint8_t *input, size_t length, uint8_t *output) {
+	EXCRYPT_DES3_STATE des3;
+	EXCRYPT_DES_STATE des;
+	uint64_t sk[3];
+	uint8_t iv[8];
+	uint8_t temp[8];
+	size_t i;
+
+	// clear iv + temp value of stack junk
+	memset(iv, 0, sizeof(iv));
+	memset(temp, 0, sizeof(temp));
+	// run parity on the key
+	ExCryptDesParity(key, 0x10, (uint8_t *)sk);
+	sk[2] = sk[0];
+	// set the key in our initial des state
+	ExCryptDesKey(&des, &sk[0]);
+	// if we have a salt, encrypt it into the temp value
+	if (salt) {
+		// The salt is a big-endian counter, including carry and wraparound.
+		for (int j = 7; j >= 0; j--) {
+			if (++salt[j] != 0) break;
+		}
+		ExCryptDesEcb(&des, salt, temp, 1);
+	}
+	// for every 8 byte input block, xor the temp value with it and encrypt over itself
+	for (i = 0; i < length; i += 8) {
+		for (size_t j = 0; j < sizeof(temp); j++) {
+			temp[j] ^= input[i + j];
+		}
+		ExCryptDesEcb(&des, temp, temp, 1);
+	}
+	// xor the highest bit of the temp value
+	temp[0] ^= 0x80;
+	// set the key and perform the final triple-des encryption
+	ExCryptDes3Key(&des3, sk);
+	ExCryptDes3Cbc(&des3, temp, 8, output, iv, 1);
+	// real kernel does the following, but the above works:
+	// XeCryptDesEcb(des_state_1, temp, temp, 1);
+	// XeCryptDesEcb(des_state_2, temp, temp, 0);
+	// XeCryptDesEcb(des_state_1, temp, output, 1);
+}
+
+void UsbdSecXSMAuthenticationAcr(const uint8_t *console_id, const uint8_t *input, const uint8_t *key, uint8_t *output) {
+	uint8_t block[8];
+	uint8_t iv[8];
+	uint8_t ab[8];
+	uint8_t cd[8];
+
+	// fill in the input block with the first 4 bytes of input data and the first 4 bytes of the console ID
+	memcpy(block, input, 4);
+	memcpy(block+4, console_id, 4);
+	// run custom "parve" crypto algorithms. idk whar they do
+	ExCryptParveEcb(key, UsbdSecSboxData, input + 0x10, iv);
+	ExCryptParveEcb(key, UsbdSecSboxData, block, cd);
+	ExCryptParveCbcMac(key, UsbdSecSboxData, iv, UsbdSecPlainTextData, 0x80, ab);
+	ExCryptChainAndSumMac(cd, ab, UsbdSecPlainTextData, 0x20, output);
+	for (size_t i = 0; i < sizeof(ab); i++) {
+		output[i] ^= ab[i];
+	}
+}

+ 42 - 0
OpenPuck/src/libxsm3/usbdsec.h

@@ -0,0 +1,42 @@
+/*
+    usbdsec.h - part of libxsm3
+    Copyright (C) 2022 InvoxiPlayGames
+
+    This library is free software; you can redistribute it and/or
+    modify it under the terms of the GNU Lesser General Public
+    License as published by the Free Software Foundation; either
+    version 2.1 of the License, or (at your option) any later version.
+
+    This library is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+    Lesser General Public License for more details.
+
+    You should have received a copy of the GNU Lesser General Public
+    License along with this library; if not, write to the Free Software
+    Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+*/
+
+/* OpenPuck modifications, 2026-09-17: self-contained header, C++ linkage,
+   and salt mutability matching the implementation. See NOTICE.md. */
+
+#ifndef USBDSEC_H_
+#define USBDSEC_H_
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+void UsbdSecXSM3AuthenticationCrypt(const uint8_t *key, const uint8_t *input, size_t length, uint8_t *output, uint8_t encrypt);
+// A non-NULL salt is incremented in place as a big-endian 64-bit counter.
+void UsbdSecXSM3AuthenticationMac(const uint8_t *key, uint8_t *salt, uint8_t *input, size_t length, uint8_t *output);
+void UsbdSecXSMAuthenticationAcr(const uint8_t *console_id, const uint8_t *input, const uint8_t *key, uint8_t *output);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // USBDSEC_H_

+ 286 - 0
OpenPuck/src/libxsm3/xsm3.c

@@ -0,0 +1,286 @@
+/*
+    xsm3.c - part of libxsm3
+    Copyright (C) 2022-2023 InvoxiPlayGames
+
+    This library is free software; you can redistribute it and/or
+    modify it under the terms of the GNU Lesser General Public
+    License as published by the Free Software Foundation; either
+    version 2.1 of the License, or (at your option) any later version.
+
+    This library is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+    Lesser General Public License for more details.
+
+    You should have received a copy of the GNU Lesser General Public
+    License along with this library; if not, write to the Free Software
+    Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+*/
+
+/* OpenPuck modifications, 2026-09-17: firmware RNG, fail-closed validation,
+   state reset and removal of debug printing. See NOTICE.md. */
+
+#include <stddef.h>
+#include <string.h>
+#include <stdbool.h>
+#include "xsm3.h"
+#include "excrypt.h"
+#include "usbdsec.h"
+
+// constant variables
+const uint8_t xsm3_id_data_ms_controller[0x1D] = {
+    0x49, 0x4B, 0x00, 0x00, 0x17, 0x41, 0x41, 0x41,
+    0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
+    0x00, 0x00, 0x80, 0x02, 0x5E, 0x04, 0x8E, 0x02,
+    0x03, 0x00, 0x01, 0x01, 0x16
+};
+
+// static global keys from the keyvault (shared across every retail system)
+static const uint8_t xsm3_key_0x1D[0x10] = {
+    0xE3, 0x5B, 0xFB, 0x1C, 0xCD, 0xAD, 0x32, 0x5B,
+	0xF7, 0x0E, 0x07, 0xFD, 0x62, 0x3D, 0xA7, 0xC4
+};
+static const uint8_t xsm3_key_0x1E[0x10] = {
+	0x8F, 0x29, 0x08, 0x38, 0x0B, 0x5B, 0xFE, 0x68,
+	0x7C, 0x26, 0x46, 0x2A, 0x51, 0xF2, 0xBC, 0x19
+};
+
+// retail keys for generating 0x23/0x24 keys from console ID
+static const uint8_t xsm3_root_key_0x23[0x10] = {
+    0x82, 0x80, 0x78, 0x68, 0x3A, 0x52, 0x3A, 0x98,
+    0x10, 0xF4, 0x0C, 0x12, 0x70, 0x66, 0xDC, 0xBA
+};
+static const uint8_t xsm3_root_key_0x24[0x10] = {
+	0x66, 0x62, 0x1A, 0x78, 0xF8, 0x60, 0x9C, 0x8A,
+    0x26, 0x9A, 0x04, 0xAE, 0xD8, 0x5C, 0x1E, 0xC8
+};
+
+/*
+global device keys from devkit keyvaults
+static const uint8_t xsm3_key_0x1D[0x10] = {
+    0xC2, 0x15, 0xE5, 0x5E, 0xE5, 0x51, 0x94, 0x2A,
+    0xEC, 0x3D, 0x45, 0xEC, 0xB6, 0xE6, 0xF2, 0x16
+};
+static const uint8_t xsm3_key_0x1E[0x10] = {
+    0xC7, 0x45, 0xAD, 0x1F, 0x08, 0x0B, 0xD9, 0xE9,
+    0x9B, 0x1C, 0x34, 0xE3, 0xA4, 0x6D, 0xC8, 0xC4
+};
+*/
+
+// devkit recovery keys for generating 0x23/0x24 keys from console ID
+// static const uint8_t xsm3_root_key_0x23[0x10] = {
+//     0xB9, 0xE0, 0x9E, 0x68, 0x04, 0x83, 0x91, 0xB3,
+//     0x32, 0x45, 0x7A, 0xDA, 0x43, 0x6B, 0x80, 0xAD
+// };
+// static const uint8_t xsm3_root_key_0x24[0x10] = {
+// 	0x92, 0x5D, 0x29, 0x6E, 0xB0, 0x61, 0x0B, 0xF1,
+//     0xD6, 0x29, 0x3B, 0xC8, 0xC7, 0xD9, 0x32, 0xBC
+// };
+
+// response to give to the given challenge command
+uint8_t xsm3_challenge_response[0x30] __attribute__((aligned(8)));
+// console id fetched from challenge init packet
+uint8_t xsm3_console_id[0x8];
+
+// buffer to store the first key fetched from the KV (0x23)
+static uint8_t xsm3_kv_2des_key_1[0x10];
+// buffer to store the second key fetched from the KV (0x24)
+static uint8_t xsm3_kv_2des_key_2[0x10];
+// have we been given a KV key yet?
+static bool xsm3_has_kv_keys = false;
+
+// temporary buffer for decrypting packets
+static uint8_t xsm3_decryption_buffer[0x30] __attribute__((aligned(8)));
+// identification data for the current device
+static uint8_t xsm3_identification_data[0x20];
+// random data sent from the console during the challenge init stage
+static uint8_t xsm3_random_console_data[0x10] __attribute__((aligned(8)));
+static uint8_t xsm3_random_console_data_enc[0x10];
+static uint8_t xsm3_random_console_data_swap[0x10];
+static uint8_t xsm3_random_console_data_swap_enc[0x10];
+// random data set by the controller during the challenge init stage
+static uint8_t xsm3_random_controller_data[0x10];
+// hash of the decrypted data sent by the controller during challenge init
+static uint8_t xsm3_challenge_init_hash[0x14];
+
+void xsm3_initialise_state(void) {
+    xsm3_has_kv_keys = false;
+    // set all variables to all zeroes
+    memset(xsm3_challenge_response, 0, sizeof(xsm3_challenge_response));
+    memset(xsm3_console_id, 0, sizeof(xsm3_console_id));
+    memset(xsm3_kv_2des_key_1, 0, sizeof(xsm3_kv_2des_key_1));
+    memset(xsm3_kv_2des_key_2, 0, sizeof(xsm3_kv_2des_key_2));
+    memset(xsm3_decryption_buffer, 0, sizeof(xsm3_decryption_buffer));
+    memset(xsm3_identification_data, 0, sizeof(xsm3_identification_data));
+    memset(xsm3_random_console_data, 0, sizeof(xsm3_random_console_data));
+    memset(xsm3_random_console_data_enc, 0, sizeof(xsm3_random_console_data_enc));
+    memset(xsm3_random_console_data_swap, 0, sizeof(xsm3_random_console_data_swap));
+    memset(xsm3_random_console_data_swap_enc, 0, sizeof(xsm3_random_console_data_swap_enc));
+    memset(xsm3_random_controller_data, 0, sizeof(xsm3_random_controller_data));
+    memset(xsm3_challenge_init_hash, 0, sizeof(xsm3_challenge_init_hash));
+}
+
+static uint8_t xsm3_calculate_checksum(const uint8_t * packet) {
+    // packet length in header doesn't include the header itself
+    size_t packet_length = packet[0x4] + 0x5;
+    uint8_t checksum = 0x00;
+    size_t i = 0;
+    // checksum is just a XOR over all packet bytes
+	for(i = 0x5; i < packet_length; i++) {
+		checksum ^= packet[i];
+    }
+    // last byte of the packet is the checksum
+	return checksum;
+}
+
+static bool xsm3_verify_checksum(const uint8_t * packet) {
+    // packet length in header doesn't include the header itself
+    size_t packet_length = packet[0x4] + 0x5;
+    // last byte of the packet is the checksum
+	return (xsm3_calculate_checksum(packet) == packet[packet_length]);
+}
+
+void xsm3_set_identification_data(const uint8_t id_data[0x1D]) {
+    // The fixed-size buffer must be checked before trusting its length field.
+    memset(xsm3_identification_data, 0, sizeof(xsm3_identification_data));
+    if (id_data[0x4] != 0x17 || !xsm3_verify_checksum(id_data)) {
+        return;
+    }
+
+    // skip over the packet header
+    id_data += 0x5;
+
+    // prepare the xsm3_identification_data buffer
+
+    // contains serial number (len: 0xC), unknown (len: 0x2) and the "category node" to use (len: 0x1)
+    memcpy(xsm3_identification_data, id_data, 0xF);
+    // vendor ID
+    memcpy(xsm3_identification_data + 0x10, id_data + 0xF, sizeof(unsigned short));
+    // product ID
+    memcpy(xsm3_identification_data + 0x12, id_data + 0x11, sizeof(unsigned short));
+    // unknown
+    memcpy(xsm3_identification_data + 0x14, id_data + 0x13, sizeof(unsigned char));
+    // unknown
+    memcpy(xsm3_identification_data + 0x15, id_data + 0x16, sizeof(unsigned char));
+    // unknown
+    memcpy(xsm3_identification_data + 0x16, id_data + 0x14, sizeof(unsigned short));
+}
+
+void xsm3_import_kv_keys(const uint8_t key1[0x10], const uint8_t key2[0x10]) {
+    xsm3_has_kv_keys = true;
+    // copy the provided keys into our buffers
+    memcpy(xsm3_kv_2des_key_1, key1, sizeof(xsm3_kv_2des_key_1));
+    memcpy(xsm3_kv_2des_key_2, key2, sizeof(xsm3_kv_2des_key_2));
+}
+
+void xsm3_generate_kv_keys(const uint8_t console_id[0x8]) {
+    xsm3_has_kv_keys = true;
+    // make a sha-1 hash of the console id
+    uint8_t console_id_hash[0x14];
+    ExCryptSha(console_id, 0x8, console_id_hash);
+    // encrypt it with the root keys for 1st party controllers
+    UsbdSecXSM3AuthenticationCrypt(xsm3_root_key_0x23, console_id_hash, 0x10, xsm3_kv_2des_key_1, 1);
+    UsbdSecXSM3AuthenticationCrypt(xsm3_root_key_0x24, console_id_hash + 0x4, 0x10, xsm3_kv_2des_key_2, 1);
+}
+
+bool xsm3_do_challenge_init(uint8_t challenge_packet[0x22]) {
+    uint8_t incoming_packet_mac[0x8];
+    uint8_t response_packet_mac[0x8];
+
+    // Do not expose a previous response after a failed challenge.
+    memset(xsm3_challenge_response, 0, sizeof(xsm3_challenge_response));
+    if (challenge_packet[0x4] != 0x1C || !xsm3_verify_checksum(challenge_packet)) {
+        return false;
+    }
+
+    // last 4 bytes of the packet are the last 4 bytes of the MAC
+    UsbdSecXSM3AuthenticationMac(xsm3_key_0x1E, NULL, challenge_packet + 5, 0x18, incoming_packet_mac);
+    // validate the MAC before changing session state
+    if (memcmp(incoming_packet_mac + 4, challenge_packet + 0x5 + 0x18, 0x4) != 0) {
+        return false;
+    }
+    // decrypt the packet content using the static key from the keyvault
+    UsbdSecXSM3AuthenticationCrypt(xsm3_key_0x1D, challenge_packet + 0x5, 0x18, xsm3_decryption_buffer, 0);
+    // first 0x10 bytes are random data
+    memcpy(xsm3_random_console_data, xsm3_decryption_buffer, 0x10);
+    // next 0x8 bytes are from the console certificate
+    memcpy(xsm3_console_id, xsm3_decryption_buffer + 0x10, 0x8);
+
+    // if we haven't got our KV keys yet, generate it
+    if (!xsm3_has_kv_keys) xsm3_generate_kv_keys(xsm3_console_id);
+
+    // the random value is swapped at an 8 byte boundary
+    memcpy(xsm3_random_console_data_swap, xsm3_random_console_data + 0x8, 0x8);
+    memcpy(xsm3_random_console_data_swap + 0x8, xsm3_random_console_data, 0x8);
+    // and then encrypted - the regular value encrypted with key 1, the swapped value encrypted with key 2
+    UsbdSecXSM3AuthenticationCrypt(xsm3_kv_2des_key_1, xsm3_random_console_data, 0x10, xsm3_random_console_data_enc, 1);
+    UsbdSecXSM3AuthenticationCrypt(xsm3_kv_2des_key_2, xsm3_random_console_data_swap, 0x10, xsm3_random_console_data_swap_enc, 1);
+
+    if (!xsm3_random_bytes(xsm3_random_controller_data, sizeof(xsm3_random_controller_data)))
+        return false;
+
+    // clear response buffers
+    memset(xsm3_challenge_response, 0, sizeof(xsm3_challenge_response));
+    memset(xsm3_decryption_buffer, 0, sizeof(xsm3_decryption_buffer));
+    // set header and packet length of challenge response
+    xsm3_challenge_response[0] = 0x49; // packet magic
+    xsm3_challenge_response[1] = 0x4C;
+    xsm3_challenge_response[4] = 0x28; // packet length
+    // copy random controller, random console data to the encryption buffer
+    memcpy(xsm3_decryption_buffer, xsm3_random_controller_data, 0x10);
+    memcpy(xsm3_decryption_buffer + 0x10, xsm3_random_console_data, 0x10);
+    // save the sha1 hash of the decrypted contents for later
+    ExCryptSha(xsm3_decryption_buffer, 0x20, xsm3_challenge_init_hash);
+
+    // encrypt challenge response packet using the encrypted random key
+    UsbdSecXSM3AuthenticationCrypt(xsm3_random_console_data_enc, xsm3_decryption_buffer, 0x20, xsm3_challenge_response + 0x5, 1);
+    // calculate MAC using the encrypted swapped random key and use it to calculate ACR
+    UsbdSecXSM3AuthenticationMac(xsm3_random_console_data_swap_enc, NULL, xsm3_challenge_response + 0x5, 0x20, response_packet_mac);
+    // calculate ACR and append to the end of the xsm3_challenge_response
+    UsbdSecXSMAuthenticationAcr(xsm3_console_id, xsm3_identification_data, response_packet_mac, xsm3_challenge_response + 0x5 + 0x20);
+    // calculate the checksum for the response packet
+    xsm3_challenge_response[0x5 + 0x28] = xsm3_calculate_checksum(xsm3_challenge_response);
+
+    // the console random value changes slightly after this point
+    memcpy(xsm3_random_console_data, xsm3_random_controller_data + 0xC, 0x4);
+    memcpy(xsm3_random_console_data + 0x4, xsm3_random_console_data + 0xC, 0x4);
+    return true;
+}
+
+bool xsm3_do_challenge_verify(uint8_t challenge_packet[0x16]) {
+    uint8_t incoming_packet_mac[0x8] __attribute__((aligned(8)));
+    uint8_t random_console_data[0x10];
+
+    memset(xsm3_challenge_response, 0, sizeof(xsm3_challenge_response));
+    if (challenge_packet[0x4] != 0x10 || !xsm3_verify_checksum(challenge_packet)) {
+        return false;
+    }
+
+    // Keep the session salt unchanged unless the incoming MAC is valid.
+    memcpy(random_console_data, xsm3_random_console_data, sizeof(random_console_data));
+    // decrypt the packet using the controller generated random value
+    UsbdSecXSM3AuthenticationCrypt(xsm3_random_controller_data, challenge_packet + 0x5, 0x8, random_console_data + 0x8, 0);
+
+    // calculate the MAC of the incoming packet
+    UsbdSecXSM3AuthenticationMac(xsm3_challenge_init_hash, random_console_data, challenge_packet + 0x5, 0x8, incoming_packet_mac);
+    // validate the MAC
+    if (memcmp(incoming_packet_mac, challenge_packet + 0x5 + 0x8, 0x8) != 0) {
+        return false;
+    }
+    memcpy(xsm3_random_console_data, random_console_data, sizeof(random_console_data));
+    // clear response buffers
+    memset(xsm3_challenge_response, 0, sizeof(xsm3_challenge_response));
+    memset(xsm3_decryption_buffer, 0, sizeof(xsm3_decryption_buffer));
+    // set header and packet length of challenge response
+    xsm3_challenge_response[0] = 0x49; // packet magic
+    xsm3_challenge_response[1] = 0x4C;
+    xsm3_challenge_response[4] = 0x10; // packet length
+    // calculate the ACR value and encrypt it into the outgoing packet using the encrypted random
+    UsbdSecXSMAuthenticationAcr(xsm3_console_id, xsm3_identification_data, xsm3_random_console_data + 0x8, xsm3_decryption_buffer);
+    UsbdSecXSM3AuthenticationCrypt(xsm3_random_console_data_enc, xsm3_decryption_buffer, 0x8, xsm3_challenge_response + 0x5, 1);
+    // calculate the MAC of the encrypted packet and append it to the end
+    UsbdSecXSM3AuthenticationMac(xsm3_random_console_data_swap_enc, xsm3_random_console_data, xsm3_challenge_response + 0x5, 0x8, xsm3_challenge_response + 0x5 + 0x8);
+    // calculate the checksum for the response packet
+    xsm3_challenge_response[0x5 + 0x10] = xsm3_calculate_checksum(xsm3_challenge_response);
+    return true;
+}

+ 69 - 0
OpenPuck/src/libxsm3/xsm3.h

@@ -0,0 +1,69 @@
+/*
+    xsm3.h - part of libxsm3
+    Copyright (C) 2022 InvoxiPlayGames
+
+    This library is free software; you can redistribute it and/or
+    modify it under the terms of the GNU Lesser General Public
+    License as published by the Free Software Foundation; either
+    version 2.1 of the License, or (at your option) any later version.
+
+    This library is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+    Lesser General Public License for more details.
+
+    You should have received a copy of the GNU Lesser General Public
+    License along with this library; if not, write to the Free Software
+    Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+*/
+
+/* OpenPuck modifications, 2026-09-17: RNG hook, bool results and C++ linkage.
+   See NOTICE.md. */
+
+#ifndef XSM3_H_
+#define XSM3_H_
+
+#include <stdint.h>
+#include <stddef.h>
+#include <stdbool.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+// Identification data taken from an official wired controller. (Serial number is static.)
+extern const uint8_t xsm3_id_data_ms_controller[0x1D];
+
+// The response data from the previously completed challenge.
+extern uint8_t xsm3_challenge_response[0x30];
+
+// The console ID fetched from the console after request 0x82.
+extern uint8_t xsm3_console_id[0x8];
+
+// Supplied by firmware: fill the entire buffer with fresh cryptographic random bytes.
+// Return false on failure; never substitute predictable data.
+bool xsm3_random_bytes(uint8_t *buffer, size_t length);
+
+// Clears the state of the XSM3 internal variables, including imported/generated keys.
+void xsm3_initialise_state(void);
+
+// Caller supplies 29 bytes. Invalid length field/checksum clears identification data.
+void xsm3_set_identification_data(const uint8_t id_data[0x1D]);
+
+// Imports the console-specific keys (GetKey index 0x23 and 0x24 for wired controllers) into the current state.
+void xsm3_import_kv_keys(const uint8_t key1[0x10], const uint8_t key2[0x10]);
+
+// Initialises the XSM3 state using information from the challenge init packet (0x82) and places a response in xsm3_challenge_response.
+// Caller must supply 34 actual bytes. Requires packet[4] == 28; false clears response.
+bool xsm3_do_challenge_init(uint8_t challenge_packet[0x22]);
+
+// Completes a verify challenge passed from request 0x87 and places the response data in xsm3_challenge_response.
+// Caller must supply 22 actual bytes. Requires packet[4] == 16; false clears response.
+// Caller must enforce a successful init before verify and abort authentication on false.
+bool xsm3_do_challenge_verify(uint8_t challenge_packet[0x16]);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // XSM3_H_

+ 3 - 0
OpenPuck/webusb_config.cpp

@@ -13,6 +13,7 @@
 #include "fw_update.h"
 #include "usb_tx.h"
 #include "usb_mount.h" // modeSwitchReboot()
+#include "xinput_auth.h"
 #include <Arduino.h>
 #include <string.h>
 
@@ -32,6 +33,8 @@ extern "C" bool
 __wrap_tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage,
 				  const tusb_control_request_t *request)
 {
+	if (g_usbMode == MODE_XBOX)
+		return xinputAuthControl(rhport, stage, request);
 	if (g_usbMode == MODE_XBOX_OG &&
 	    xboxOgVendorControlXfer(rhport, stage, request))
 		return true;

+ 222 - 0
OpenPuck/xinput_auth.cpp

@@ -0,0 +1,222 @@
+#include "xinput_auth.h"
+#include "src/libxsm3/xsm3.h"
+#include <string.h>
+
+enum AuthState : uint8_t {
+	AUTH_IDLE,
+	AUTH_RECEIVE_INIT,
+	AUTH_PENDING_INIT,
+	AUTH_WORKING_INIT,
+	AUTH_INIT_READY,
+	AUTH_RECEIVE_VERIFY,
+	AUTH_PENDING_VERIFY,
+	AUTH_WORKING_VERIFY,
+	AUTH_VERIFY_READY,
+	AUTH_FAILED,
+};
+
+static volatile AuthState g_state = AUTH_IDLE;
+static volatile uint32_t g_generation;
+static bool g_workerReady;
+static char g_serial[13];
+static uint8_t g_identification[29];
+static uint8_t g_challenge[34];
+static uint8_t g_response[46];
+static uint8_t g_responseLength;
+// EP0 owns this buffer until DATA/ACK or the next SETUP. The worker never
+// modifies it, even if a reset or new challenge arrives during crypto.
+CFG_TUD_MEM_ALIGN static uint8_t g_control[46];
+
+bool xinputAuthBusy()
+{
+	AuthState state = g_state;
+	return state == AUTH_PENDING_INIT || state == AUTH_PENDING_VERIFY ||
+	       state == AUTH_WORKING_INIT || state == AUTH_WORKING_VERIFY;
+}
+
+void xinputAuthReset()
+{
+	uint32_t mask = xinputAuthLock();
+	g_generation++;
+	g_state = AUTH_IDLE;
+	g_responseLength = 0;
+	memset(g_response, 0, sizeof g_response);
+	xinputAuthUnlock(mask);
+}
+
+const char *xinputAuthSerial()
+{
+	return g_serial;
+}
+
+void xinputAuthPrepare()
+{
+	xinputAuthBoardSerial(g_serial);
+	memcpy(g_identification, xsm3_id_data_ms_controller,
+	       sizeof g_identification);
+	memcpy(g_identification + 5, g_serial, 12);
+	g_identification[28] = 0;
+	for (unsigned i = 5; i < 28; i++)
+		g_identification[28] ^= g_identification[i];
+	xinputAuthReset();
+	g_workerReady = xinputAuthStartWorker();
+}
+
+void xinputAuthProcess()
+{
+	uint8_t challenge[34];
+	uint32_t mask = xinputAuthLock();
+	AuthState state = g_state;
+	if (state != AUTH_PENDING_INIT && state != AUTH_PENDING_VERIFY) {
+		xinputAuthUnlock(mask);
+		return;
+	}
+	uint32_t generation = g_generation;
+	memcpy(challenge, g_challenge, sizeof challenge);
+	g_state = state == AUTH_PENDING_INIT ? AUTH_WORKING_INIT :
+					       AUTH_WORKING_VERIFY;
+	xinputAuthUnlock(mask);
+
+	static uint32_t cryptoGeneration;
+	static bool initialized;
+	bool ok;
+	if (state == AUTH_PENDING_INIT) {
+		xsm3_initialise_state();
+		xsm3_set_identification_data(g_identification);
+		ok = xsm3_do_challenge_init(challenge);
+		initialized = ok;
+		cryptoGeneration = generation;
+	} else {
+		ok = initialized && cryptoGeneration == generation &&
+		     xsm3_do_challenge_verify(challenge);
+		if (!ok)
+			initialized = false;
+	}
+
+	mask = xinputAuthLock();
+	if (generation == g_generation) {
+		if (ok) {
+			g_responseLength = state == AUTH_PENDING_INIT ? 46 : 22;
+			memcpy(g_response, xsm3_challenge_response,
+			       g_responseLength);
+			g_state = state == AUTH_PENDING_INIT ?
+					  AUTH_INIT_READY :
+					  AUTH_VERIFY_READY;
+		} else {
+			g_responseLength = 0;
+			memset(g_response, 0, sizeof g_response);
+			g_state = AUTH_FAILED;
+		}
+	}
+	xinputAuthUnlock(mask);
+}
+
+static bool authRecipient(const tusb_control_request_t *request)
+{
+	uint8_t type = request->bmRequestType & 0x7F;
+	// Retail requests use interface 3, sometimes with 0x01 in the high
+	// byte of wIndex. Device-recipient requests have no interface index.
+	return (type == 0x41 &&
+		(request->wIndex == 3 || request->wIndex == 0x0103)) ||
+	       (type == 0x40 && request->wIndex == 0);
+}
+
+bool xinputAuthControl(uint8_t rhport, uint8_t stage,
+		       const tusb_control_request_t *request)
+{
+	if (!g_workerReady || !authRecipient(request))
+		return false;
+	bool input = request->bmRequestType & 0x80;
+	uint8_t command = request->bRequest;
+	// The console's OUT keepalive has no data stage. Acknowledge it without
+	// resetting the session; subsequent verify challenges reuse its keys.
+	if (!input && command == 0x84) {
+		if (request->wLength != 0)
+			return false;
+		if (stage == CONTROL_STAGE_SETUP)
+			return tud_control_xfer(rhport, request, g_control, 0);
+		return true;
+	}
+	if (input) {
+		if (command != 0x81 && command != 0x83 && command != 0x84 &&
+		    command != 0x86)
+			return false;
+		if (stage != CONTROL_STAGE_SETUP)
+			return true;
+		uint16_t length = 0;
+		uint32_t mask = xinputAuthLock();
+		switch (command) {
+		case 0x81:
+			xinputAuthReset();
+			memcpy(g_control, g_identification,
+			       sizeof g_identification);
+			length = sizeof g_identification;
+			break;
+		case 0x83:
+			if (g_state != AUTH_INIT_READY &&
+			    g_state != AUTH_VERIFY_READY) {
+				xinputAuthUnlock(mask);
+				return false;
+			}
+			length = g_responseLength;
+			memcpy(g_control, g_response, length);
+			break;
+		case 0x86:
+			if (g_state == AUTH_FAILED) {
+				xinputAuthUnlock(mask);
+				return false;
+			}
+			g_control[0] = (g_state == AUTH_INIT_READY ||
+					g_state == AUTH_VERIFY_READY) ?
+					       2 :
+					       1;
+			g_control[1] = 0;
+			length = 2;
+			break;
+		case 0x84:
+			break;
+		}
+		xinputAuthUnlock(mask);
+		return tud_control_xfer(rhport, request, g_control, length);
+	}
+
+	bool init = command == 0x82;
+	if ((!init && command != 0x87) || request->wLength != (init ? 34 : 22))
+		return false;
+	if (stage == CONTROL_STAGE_SETUP) {
+		uint32_t mask = xinputAuthLock();
+		if (!init && g_state != AUTH_INIT_READY &&
+		    g_state != AUTH_VERIFY_READY) {
+			xinputAuthUnlock(mask);
+			return false;
+		}
+		if (init)
+			xinputAuthReset();
+		g_state = init ? AUTH_RECEIVE_INIT : AUTH_RECEIVE_VERIFY;
+		g_responseLength = 0;
+		memset(g_response, 0, sizeof g_response);
+		memset(g_control, 0, sizeof g_control);
+		xinputAuthUnlock(mask);
+		return tud_control_xfer(rhport, request, g_control,
+					request->wLength);
+	}
+	if (stage == CONTROL_STAGE_DATA) {
+		uint32_t mask = xinputAuthLock();
+		if (!xinputAuthReceived(request->wLength)) {
+			g_generation++;
+			g_state = AUTH_FAILED;
+			xinputAuthUnlock(mask);
+			return false;
+		}
+		if (g_state !=
+		    (init ? AUTH_RECEIVE_INIT : AUTH_RECEIVE_VERIFY)) {
+			xinputAuthUnlock(mask);
+			return false;
+		}
+		memcpy(g_challenge, g_control, request->wLength);
+		g_state = init ? AUTH_PENDING_INIT : AUTH_PENDING_VERIFY;
+		xinputAuthUnlock(mask);
+		xinputAuthNotify();
+	}
+	return true;
+}

+ 19 - 0
OpenPuck/xinput_auth.h

@@ -0,0 +1,19 @@
+#pragma once
+#include <Adafruit_TinyUSB.h>
+
+void xinputAuthPrepare();
+void xinputAuthReset();
+const char *xinputAuthSerial();
+bool xinputAuthControl(uint8_t rhport, uint8_t stage,
+		       const tusb_control_request_t *request);
+
+// Only the dedicated worker may call the singleton crypto library.
+void xinputAuthProcess();
+bool xinputAuthBusy();
+void xinputAuthYield();
+bool xinputAuthReceived(uint16_t length);
+bool xinputAuthStartWorker();
+void xinputAuthNotify();
+void xinputAuthBoardSerial(char serial[13]);
+uint32_t xinputAuthLock();
+void xinputAuthUnlock(uint32_t mask);

+ 78 - 0
OpenPuck/xinput_auth_platform.cpp

@@ -0,0 +1,78 @@
+#include "xinput_auth.h"
+#include "src/libxsm3/xsm3.h"
+#include <Arduino.h>
+#include <stdio.h>
+
+static TaskHandle_t g_authWorker;
+
+uint32_t xinputAuthLock()
+{
+	uint32_t mask = __get_PRIMASK();
+	__disable_irq();
+	return mask;
+}
+
+void xinputAuthUnlock(uint32_t mask)
+{
+	__set_PRIMASK(mask);
+}
+
+void xinputAuthBoardSerial(char serial[13])
+{
+	snprintf(serial, 13, "%04lX%08lX",
+		 (unsigned long)(NRF_FICR->DEVICEID[1] & 0xFFFF),
+		 (unsigned long)NRF_FICR->DEVICEID[0]);
+}
+
+static void authWorker(void *)
+{
+	for (;;) {
+		ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
+		xinputAuthProcess();
+	}
+}
+
+bool xinputAuthStartWorker()
+{
+	if (g_authWorker)
+		return true;
+	// Adafruit's USB task has only 200 words of stack. Crypto gets its own
+	// stack below the RF loop's priority, leaving USB polling responsive.
+	return xTaskCreate(authWorker, "xsm3", 2048, nullptr, tskIDLE_PRIORITY,
+			   &g_authWorker) == pdPASS;
+}
+
+void xinputAuthNotify()
+{
+	if (g_authWorker)
+		xTaskNotifyGive(g_authWorker);
+}
+
+void xinputAuthYield()
+{
+	// taskYIELD() cannot schedule below the continuously runnable RF loop.
+	// Lend the worker one tick between polls, never inside an RF RX window.
+	if (xinputAuthBusy())
+		delay(1);
+}
+
+extern "C" bool xsm3_random_bytes(uint8_t *buffer, size_t length)
+{
+	// OpenPuck uses the radio directly, without enabling the SoftDevice;
+	// no other firmware subsystem owns RNG. Do not use Arduino random().
+	NRF_RNG->CONFIG = RNG_CONFIG_DERCEN_Enabled << RNG_CONFIG_DERCEN_Pos;
+	NRF_RNG->EVENTS_VALRDY = 0;
+	NRF_RNG->TASKS_START = 1;
+	unsigned long started = millis();
+	for (size_t i = 0; i < length;) {
+		if (NRF_RNG->EVENTS_VALRDY) {
+			buffer[i++] = (uint8_t)NRF_RNG->VALUE;
+			NRF_RNG->EVENTS_VALRDY = 0;
+		} else if (millis() - started > 100) {
+			NRF_RNG->TASKS_STOP = 1;
+			return false;
+		}
+	}
+	NRF_RNG->TASKS_STOP = 1;
+	return true;
+}

+ 54 - 0
OpenPuck/xinput_descriptors.h

@@ -0,0 +1,54 @@
+/*
+ * SPDX-License-Identifier: MIT
+ * SPDX-FileCopyrightText: Copyright (c) 2021 Jason Skuby (mytechtoybox.com)
+ *
+ * Configuration body adapted from GP2040-CE's XInputDescriptors.h.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ */
+#pragma once
+#include <stdint.h>
+
+static const uint8_t XINPUT_CONFIG_BODY[] = {
+	9,    4,    0,	  0,	2,    0xFF, 0x5D, 0x01, 0,    17,   0x21,
+	0,    1,    1,	  0x25, 0x81, 0x14, 0,	  0,	0,    0,    0x13,
+	0x02, 0x08, 0,	  0,	7,    5,    0x81, 3,	0x20, 0,    1,
+	7,    5,    0x02, 3,	0x20, 0,    8,
+
+	9,    4,    1,	  0,	4,    0xFF, 0x5D, 0x03, 0,    27,   0x21,
+	0,    1,    1,	  1,	0x83, 0x40, 1,	  0x04, 0x20, 0x16, 0x85,
+	0,    0,    0,	  0,	0,    0,    0x16, 0x06, 0,    0,    0,
+	0,    0,    0,	  7,	5,    0x83, 3,	  0x20, 0,    2,    7,
+	5,    0x04, 3,	  0x20, 0,    4,    7,	  5,	0x85, 3,    0x20,
+	0,    0x40, 7,	  5,	0x06, 3,    0x20, 0,	0x10,
+
+	9,    4,    2,	  0,	1,    0xFF, 0x5D, 0x02, 0,    9,    0x21,
+	0,    1,    1,	  0x22, 0x86, 3,    0,	  7,	5,    0x86, 3,
+	0x20, 0,    0x10,
+
+	9,    4,    3,	  0,	0,    0xFF, 0xFD, 0x13, 4,    6,    0x41,
+	0,    1,    1,	  3,
+};
+
+static_assert(sizeof XINPUT_CONFIG_BODY == 144,
+	      "Xbox 360 configuration body must be 144 bytes");
+static const uint16_t XINPUT_SECURITY_STRING_OFFSET = 137;
+static const char XINPUT_SECURITY_STRING[] =
+	"Xbox Security Method 3, Version 1.00, \xc2\xa9 2005 Microsoft "
+	"Corporation. All rights reserved.";

+ 23 - 0
OpenPuck/xinput_strings.cpp

@@ -0,0 +1,23 @@
+#include "config.h"
+#include <Adafruit_TinyUSB.h>
+
+extern "C" const uint16_t *__real_tud_descriptor_string_cb(uint8_t index,
+							   uint16_t langid);
+
+extern "C" const uint16_t *__wrap_tud_descriptor_string_cb(uint8_t index,
+							   uint16_t langid)
+{
+	// Adafruit truncates interface strings at 32 characters. The XSM3
+	// descriptor is 88 UTF-16 code units and must survive a multi-packet GET.
+	static const char security[] =
+		"Xbox Security Method 3, Version 1.00, \xa9 2005 Microsoft "
+		"Corporation. All rights reserved.";
+	static uint16_t descriptor[sizeof security / sizeof security[0]];
+	if (g_usbMode != MODE_XBOX || index != 4)
+		return __real_tud_descriptor_string_cb(index, langid);
+	constexpr size_t count = sizeof security / sizeof security[0] - 1;
+	descriptor[0] = (TUSB_DESC_STRING << 8) | sizeof descriptor;
+	for (size_t i = 0; i < count; i++)
+		descriptor[i + 1] = (uint8_t)security[i];
+	return descriptor;
+}

+ 26 - 0
OpenPuck/xinput_usb.cpp

@@ -0,0 +1,26 @@
+#include "xinput_auth.h"
+
+static uint32_t g_received;
+static bool g_receiveOk;
+
+bool xinputAuthReceived(uint16_t length)
+{
+	return g_receiveOk && g_received == length;
+}
+
+extern "C" bool __real_usbd_control_xfer_cb(uint8_t rhport, uint8_t ep,
+					    xfer_result_t result,
+					    uint32_t length);
+
+extern "C" bool __wrap_usbd_control_xfer_cb(uint8_t rhport, uint8_t ep,
+					    xfer_result_t result,
+					    uint32_t length)
+{
+	// TinyUSB's DATA callback omits actual length and also runs for a short
+	// OUT packet. XSM3 challenges fit in one EP0 packet (34/22 < 64).
+	g_received = length;
+	g_receiveOk = ep == 0 && result == XFER_RESULT_SUCCESS;
+	bool ok = __real_usbd_control_xfer_cb(rhport, ep, result, length);
+	g_receiveOk = false;
+	return ok;
+}

+ 9 - 3
README.md

@@ -26,7 +26,9 @@ There are two fundamental problems with the controller:
 
 OpenPuck uses a [Pro Micro NRF52840](https://www.amazon.com/dp/B0GSZ7FD6T) ($8 on Amazon, possibly cheaper elsewhere) which uses a radio similar to the one being used by the controller and the puck. Once the arduino sketch is uploaded it emulates the puck over USB to Steam by default and allows pairing the controller normally (almost, the lizard mode for when Steam is off might not be 1:1). Latency [has been measured to be within 1ms of the official puck](https://www.reddit.com/r/SteamController/comments/1u754ze/complete_latency_testing_of_openpuck_project/).
 
-At any point you can hold all 4 back buttons and press X to switch over to ***Xbox mode** which maps all canonical inputs to their expected counterparts (plus L4 -> LB, L5 -> L3, etc which are configurable). In this mode the right trackpad acts as a mouse but at present this only works in Android and SteamOS.
+At any point you can hold all 4 back buttons and press X (the default assignment) to switch over to **Xbox mode**, which maps canonical inputs to their expected counterparts (plus configurable back-button mappings). It presents **one static wired Xbox 360 controller**, with input and rumble, but no right-trackpad mouse, WebUSB, or wake HID. Retail authentication runs in software on the existing nRF52840 OpenPuck; no donor controller or extra hardware is required.
+
+**Stock Xbox 360 console support is experimental and awaiting physical validation.** The default firmware build passed with Adafruit nRF52 core 1.7.0, but this firmware has **not been flashed or tested on a console**. Native tests use independent synthetic fixtures, not console captures or proof of compatibility. Headsets/chatpads are not emulated, and physical player-ring behavior is not equivalent to a genuine pad. See [docs/XBOX360.md](docs/XBOX360.md) for constraints and the hardware test checklist.
 
 Similarly you can hold all 4 back buttons and press Y to switch (teehee) over to a **Switch mode**. This emulates a pro controller full with gyro and haptics. There's other modes as well:
 
@@ -34,7 +36,7 @@ Similarly you can hold all 4 back buttons and press Y to switch (teehee) over to
 |---|---|---|
 | back-4 + A | Steam | Steam Controller Mode |
 | back-4 + B | Lizard | Lizard mode, even if Steam is open |
-| back-4 + X | Xbox | Xbox 360 Controller |
+| back-4 + X | Xbox | One wired Xbox 360 pad; stock-console support experimental, untested |
 | back-4 + Y | Switch Pro | Switch Controller + Gyro + Haptics |
 | WebUSB panel → mode 4 | Hori Pad | Switch mode with no gyro or haptics |
 | WebUSB panel → mode 5 | DualSense + Gyro + Trackpad | PC only |
@@ -99,6 +101,8 @@ Switching slots requires turning the controller off (Steam + Y if steam is runni
 # Configuration
 A webusb based configuration UI is available [here](https://safijari.github.io/openpuck/). It allows Switching the mode manually and changing the back button mapping for other modes among other things. This will likely only work in Chrome and Edge and needs the pro micro to be connected via USB to the same computer for it to function. Note that it might not work in all modes on all machines but should always work in the Steam Controller mode (which you can revert to with back-4 + A). Note that in some modes the webusb connection might not work. If you're encountering that try going back to the Steam Controller mode and unplugging and replugging the dongle.
 
+Xbox mode deliberately has no WebUSB interface, including for firmware updates. Hold **L4 + R4 + L5 + R5 + A** to return to Steam mode on a PC, configure or update there, then use **back-4 + X** to return to Xbox (unless X has been reassigned). The A recovery chord is fixed; the B/X/Y assignments are configurable. Pair controllers in Steam mode first. Xbox selects the first fresh RF input source and keeps it until disconnect; four stored bonds do not become four Xbox gamepads.
+
 If you're running Linux and your browser still shows "disconnected" after selecting the OpenPuck in the device selector, it's probably a permissions issue. Check [this document](./docs/WEBUSB_LINUX.md) for more details.
 
 You can copy configurations between OpenPucks using the export/import card in this webusb UI as well. This allows for some interesting [hotswapping capability](https://www.youtube.com/watch?v=6RnsXVlHAoM) where controllers can switch between pucks without needing to swap slots.
@@ -123,7 +127,7 @@ This needs a little extra wiring (one resistor, one transistor, two wires to the
 Bill of materials, schematic, and step-by-step wiring instructions: [How to wire up an NRF52 board for cold boot](https://github.com/safijari/openpuck/wiki/How-to-wire-up-an-NRF52-board-for-cold-boot).
 
 # Future work
-- Find a way to make Xinput mode and mouse work together on all platforms
+- Validate experimental wired Xbox 360 mode on stock consoles (the clean USB layout intentionally omits a mouse)
 - Design the charging portion (and make it short proof)
 - Make ReversePuck into a system that you can plug into most controllers and allow them to talk to OpenPuck
 - A BLE version of OpenPuck that can allow Steam Controllers and other BLE controllers to coexist
@@ -142,6 +146,8 @@ I have tested this software fairly extensively but I have limited resources. Ple
 - Thanks to Lawstorant from a mutual discord server for constructive criticism of the repo's state
 - Everyone that participated in [issue #17](https://github.com/safijari/openpuck/issues/72) or reported/tested stability issues on various boards
 
+Xbox authentication includes InvoxiPlayGames' **libxsm3 (LGPL-2.1-or-later)** and **ExCrypt (BSD-3-Clause)**; its USB descriptor layout is adapted from **GP2040-CE (MIT)**. Retain the notices and license texts when redistributing, and satisfy the LGPL source/relinking obligations for statically linked firmware. See [OpenPuck/src/libxsm3/NOTICE.md](OpenPuck/src/libxsm3/NOTICE.md), [OpenPuck/src/libxsm3/LICENSE.txt](OpenPuck/src/libxsm3/LICENSE.txt), the MIT notice in [OpenPuck/xinput_descriptors.h](OpenPuck/xinput_descriptors.h), and [docs/XBOX360.md](docs/XBOX360.md#licensing-and-redistribution).
+
 # * On LLM Use 
 Everything from discovery of the protocol to writing the arduino sketch and running various automated benchmarks invovled Claude and Codex. This readme is the only organic, single origin, ethically sourced and humanely slaughtered assemblage of words in this project. I have done my level best to review the code and I invite anyone concerned about the stability or security of this project to do the same. I test thoroughly and obsessively as the primary purpose of this project is to bring some much needed QOL to my own use of the Steam Controller.
 

+ 50 - 5
docs/BUILD_AND_DEPLOY.md

@@ -66,13 +66,49 @@ make build EXTRA_FLAGS="-DOPK_LOG=1"                  # add your own defines
 make build FQBN=adafruit:nrf52:somethingelse          # a different nRF52840 board
 ```
 
-**Calling `arduino-cli` directly** instead of `make`? Then you must supply the flags yourself — the build
-`#error`s without them (so a forgotten flag fails loudly instead of shipping a broken/deadlock-prone image):
+**Calling `arduino-cli` directly** instead of `make`? Supply both the USB defines and
+all three linker wrappers. Compile-time checks guard the USB defines; they do not
+replace the required linker flags:
 
 ```bash
-arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=6 -DCFG_TUD_TASK_QUEUE_SZ=512 -DCFG_TUD_VENDOR_TX_BUFSIZE=256" --build-property "compiler.c.elf.extra_flags=-Wl,--wrap=tud_vendor_control_xfer_cb" OpenPuck
+arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=6 -DCFG_TUD_TASK_QUEUE_SZ=512 -DCFG_TUD_VENDOR_TX_BUFSIZE=256" --build-property "compiler.c.elf.extra_flags=-Wl,--wrap=tud_vendor_control_xfer_cb -Wl,--wrap=tud_descriptor_string_cb -Wl,--wrap=usbd_control_xfer_cb" OpenPuck
 ```
 
+The wrappers route vendor control requests, preserve the full 178-byte Xbox
+security string, and validate actual EP0 OUT transfer lengths respectively.
+`make build` includes all three through `OPENPUCK_LINK_FLAGS`; do not drop any
+when overriding that variable.
+
+### Xbox 360 build and validation status
+
+The default `adafruit:nrf52:feather52840` build passed with **Adafruit nRF52 core
+1.7.0**. The Xbox 360 firmware has **not been flashed or tested on a console**;
+stock-console support remains experimental and awaiting validation. It runs
+retail authentication in software on the existing nRF52840 OpenPuck, with no
+donor controller or added hardware.
+
+From the repository root, the non-hardware checks are:
+
+```bash
+python3 tools/test-xinput.py --check-fixtures
+make check
+make build
+```
+
+Native tests require Python 3, C11/C++17 compilers, and OpenSSL for independent
+fixture regeneration. These are **synthetic fixtures, not console captures**;
+passing tests and a firmware build do not prove physical USB operation or stock
+console compatibility. See [../tests/xinput/README.md](../tests/xinput/README.md)
+for harness limits and [XBOX360.md](XBOX360.md) for the physical test checklist.
+
+Before distributing firmware, retain the libxsm3 **LGPL-2.1-or-later**, ExCrypt
+**BSD-3-Clause**, and descriptor **MIT** notices and license texts. Static
+firmware redistribution must also satisfy applicable LGPL source/relinking
+requirements, not just include a license notice. See
+[../OpenPuck/src/libxsm3/NOTICE.md](../OpenPuck/src/libxsm3/NOTICE.md),
+[../OpenPuck/src/libxsm3/LICENSE.txt](../OpenPuck/src/libxsm3/LICENSE.txt), and
+[XBOX360.md](XBOX360.md#licensing-and-redistribution).
+
 ### 4a. Raytac MDBT50Q-CX-40
 
 Retail listings may shorten MDBT50Q-CX-40 to `MDBT50Q-CX`. This target is only
@@ -165,6 +201,12 @@ Use the port for your OS: macOS `/dev/cu.usbmodem*`, Linux `/dev/ttyACM0`, Windo
 
 > **DFU note:** in puck (Steam/Lizard) mode the firmware drops the CDC serial port to free a USB endpoint, so `arduino-cli` can't auto-reset the board into its bootloader. If the upload can't connect, put the board in DFU mode first by **double-tapping RST**, then `make flash <bootloader-port>` (re-check `arduino-cli board list` — the port can change in DFU mode). The drag-and-drop UF2 path in §5b also works.
 
+Xbox 360 mode also has no CDC serial port, and deliberately omits WebUSB and wake
+HID. For configuration or WebUSB updates, connect to a PC and hold all four back
+buttons (**L4 + R4 + L5 + R5**) plus **A** to return to Steam mode. Afterward,
+**back-4 + X** returns to Xbox by default (X can be reassigned; A is fixed).
+Physical DFU remains the recovery path if no paired controller is available.
+
 ### Manual upload (without `make`)
 
 Find the board port with `arduino-cli board list`, then:
@@ -230,6 +272,9 @@ Replace the port (`/dev/ttyACM0` / `COM5`) with the actual board port.
 
 ### Flashing from the WebUSB panel (no tools, no drag-and-drop)
 
+In Xbox 360 mode, first return to Steam mode with **back-4 + A** on a PC; the
+Xbox USB layout has no configuration/update interface.
+
 A board already running OpenPuck (status protocol v15+) can be updated entirely from the
 [WebUSB configurator](https://safijari.github.io/openpuck/)'s **Firmware update** tab: drag-and-drop a
 `.uf2` (or click to browse), or pick a version from the built-in **releases list** — each release offers the
@@ -274,12 +319,12 @@ Re-flashing firmware does **not** erase the board's internal LittleFS. The paire
   (`make build-recovery` is just `make build` plus `-DOPK_FACTORY_RESET=1`; the usual USB flags are still baked in.)
 
   It is **not** a wipe-every-boot image: after the one-time reset it stamps a tag file with the build's git hash, so subsequent boots skip the wipe and persist normally. Flashing this same image again won't re-wipe (the tag matches); flashing a **different** build (different git hash) re-triggers the one-time reset. For an on-demand wipe at any time, use the WebUSB button or serial `ERASE-ALL` below. Re-pair the controller after a reset.
-- **WebUSB panel (any mode):** open the panel (§8), and in the maintenance card click **⚠ Factory erase**. Confirm the two warning dialogs and type `ERASE` when prompted. The board reformats its filesystem and reboots to factory defaults. This works in every USB mode.
+- **WebUSB panel (a mode exposing WebUSB):** open the panel (§8), and in the maintenance card click **⚠ Factory erase**. Confirm the two warning dialogs and type `ERASE` when prompted. The board reformats its filesystem and reboots to factory defaults. From Xbox 360 mode, first return to Steam with **back-4 + A**.
 - **Serial console (CDC):** connect to the board's serial port at 115200 baud and send the line `ERASE-ALL` (exact, all caps). Same effect: reformat + reboot.
 
 Both reformat the entire internal filesystem (`cfg.bin` + `bonds.bin` and anything else), so the action is irreversible and the controller must be **re-paired afterwards** (see §7).
 
-Note on the serial method: puck (Steam/Lizard) mode drops the CDC console by default to free a USB endpoint for the wake-mouse interface, so the serial port may not be present in that mode. Either arm the one-shot debug CDC first (panel debug-CDC toggle / `D` console command, which keeps the console for the next boot), or just use the WebUSB **Factory erase** button, which is available in all modes.
+Note on the serial method: puck (Steam/Lizard) mode drops the CDC console by default to free a USB endpoint for the wake-mouse interface, so the serial port may not be present in that mode. Either arm the one-shot debug CDC first (panel debug-CDC toggle / `D` console command, which keeps the console for the next boot), or use the WebUSB **Factory erase** button in Steam mode. Xbox 360 mode exposes neither CDC nor WebUSB.
 
 ## 7. Pair and verify
 

+ 52 - 15
docs/PROTOCOL.md

@@ -22,7 +22,7 @@ offset  size  meaning
 0x08    16    controller serial, ASCII, not null-terminated if fully filled
 ```
 
-The puck stores four independent bond slots. Slot `N` is exposed as USB HID interface `N`.
+The puck stores four independent bond slots. In Steam mode each slot has its own USB HID interface; Xbox mode selects only one RF controller for one static USB gamepad (§9.2).
 
 An empty slot is all zeroes.
 
@@ -303,12 +303,12 @@ offset  size  meaning
 
 The RF side stays the same across modes. Only USB enumeration changes.
 
-There are three switchable modes (`0=Steam 1=Xbox 2=Switch`). Steam mode is a CDC + WebUSB composite;
-Xbox and Switch are **clean controller-only** devices — the auto-added CDC/WebUSB
-interfaces are torn down (`clearConfiguration`) and `bcdUSB` stays `0x0200` (no USB-2.1 BOS). This is
-required because Windows' `xusb` driver expects the Xbox 360 controller on the primary device/interface,
-and a real Switch console rejects composite devices. Every boot/mode-switch
-does a `detach -> rebuild -> attach` so the host re-reads the descriptor cleanly.
+USB personalities include Steam (`0`), Xbox 360 (`1`), Hori (`2`), Switch Pro (`4`),
+and the other modes listed in [../README.md](../README.md). Xbox 360 uses a static
+wired-controller layout: `clearConfiguration` removes the auto-added interfaces,
+and `bcdUSB` remains `0x0200` (no USB-2.1 BOS). It has no CDC, WebUSB, mouse, or wake
+HID. Mode switches detach and reboot to rebuild the USB descriptors; Xbox RF
+joins/departures do not change its USB layout or trigger re-enumeration.
 
 ### 9.1 Steam mode
 
@@ -359,9 +359,45 @@ from the feature-`0x01` **command** space even though the numbers overlap. Groun
 
 ### 9.2 Xbox mode
 
-- VID:PID `045E:028E`, clean device (no CDC/WebUSB)
-- Custom XInput-compatible vendor interface on `MI_00` + HID boot mouse on `MI_01` (right-pad emulation)
-- `0x45` is converted into a 20-byte XInput report
+- `MODE_XBOX` (`1`): one static wired Xbox 360 pad, Microsoft VID:PID `045E:028E`,
+  `bcdDevice=0x0114`, device class/subclass/protocol `FF/FF/FF`.
+- Four vendor interfaces: control (`0`, `FF/5D/01`), audio (`1`, `FF/5D/03`,
+  no accessory), plugin (`2`, `FF/5D/02`, no accessory), security (`3`,
+  `FF/FD/13`). Only gamepad endpoints `0x81` IN and `0x02` OUT are opened;
+  the advertised accessory endpoints are not serviced. Configuration length
+  is 153 bytes (9-byte header + 144-byte body).
+- The first fresh report from a live RF bond selects the controller. Selection
+  stays fixed through brief gaps, until more than 1200 ms without an RF reply
+  (or bond removal). RF injects synthetic neutral input after 300 ms of silence;
+  that releases held inputs but cannot acquire an unselected bond. A replacement
+  controller does not require USB re-enumeration.
+- `0x45` is converted into a 20-byte input report, including Guide. Valid rumble
+  OUT packets are relayed only to the selected RF controller. Headset/chatpad
+  functions are **not emulated**. LED commands are parsed, but the physical
+  player ring is not equivalent to a genuine Xbox 360 pad.
+- Retail XSM3 authentication runs in software using libxsm3 on the nRF52840:
+  no donor controller, USB host adapter, or additional hardware. This is wired
+  USB authentication, not Xbox wireless RF pairing.
+- Vendor requests `0x81` (identification), `0x82` (init), `0x86` (status),
+  `0x83` (response), and `0x87` (verify) are handled through EP0. Init/verify
+  require exactly 34/22 received bytes; invalid lengths, checksums, or MACs fail
+  closed without a ready/stale response. USB RESET invalidates pending work so
+  a worker finishing afterward cannot publish its old result. USB and XSM3
+  identification use a stable, hardware-derived 12-character serial.
+- A dedicated idle-priority crypto task has a 2048-word (8 KiB) stack, separate
+  from the small USB-task stack. The main loop lends it 1 ms between RF polls
+  only while authentication work is pending/running, never inside an RX window.
+- The string callback wrapper supplies the full **178-byte** security string
+  descriptor at index 4 (88 UTF-16 code units plus header), avoiding the core's
+  truncation. Vendor-routing and EP0 received-length wrappers are also required;
+  see [BUILD_AND_DEPLOY.md](BUILD_AND_DEPLOY.md).
+
+Configure in Steam mode using fixed **back-4 + A**, then return with **back-4 + X**
+(default, configurable). Xbox has no mouse, WebUSB, CDC, or wake HID. See
+[XBOX360.md](XBOX360.md) for licensing and physical tests. **Stock-console support
+is experimental, not verified compatibility:** the default Adafruit nRF52 1.7.0
+build passed, but the firmware has not been flashed or console-tested. Independent
+synthetic native fixtures are not evidence of acceptance by a real console.
 
 ### 9.3 Switch mode
 
@@ -383,8 +419,9 @@ from the feature-`0x01` **command** space even though the numbers overlap. Groun
 
 ## 10. WebUSB control channel
 
-The WebUSB vendor interface is present only in Steam mode (Xbox/Switch are clean controllers with no
-config interface — configure them from Steam mode or switch via the back-paddle chord).
+The WebUSB vendor interface is absent in Xbox 360 and clean PlayStation modes.
+For Xbox configuration or updates, return to Steam mode with **back-4 + A**, use
+the panel on a PC, then **back-4 + X** (default assignment) to return to Xbox.
 
 Messages:
 
@@ -443,8 +480,8 @@ Status blob payload:
 ```text
 [0]  version
 [1]  usb mode
-[2]  xbox mouse sensitivity divisor
-[3]  xbox mouse friction
+[2]  legacy xbox mouse sensitivity divisor (no mouse in MODE_XBOX)
+[3]  legacy xbox mouse friction (no mouse in MODE_XBOX)
 [4]  steam pad smoothing
 [5]  A/B + X/Y swap
 [6]  back mapping 0
@@ -527,6 +564,6 @@ To build a compatible puck from scratch:
 6. Poll with `E7` then `E3 GET 0x45`.
 7. Parse `F1` and unpack report `0x45`.
 8. Relay host feature writes to the controller as `E3` SET sub-TLVs.
-9. Re-enumerate USB cleanly when switching Steam/Xbox/Switch modes (Xbox/Switch as clean non-composite devices).
+9. Re-enumerate USB cleanly when switching modes; Xbox uses the fixed four-vendor-interface layout in §9.2, not a gamepad/mouse composite or a dynamic multi-pad layout.
 
 If those pieces match, the controller-to-puck protocol is fully reimplemented.

+ 183 - 0
docs/XBOX360.md

@@ -0,0 +1,183 @@
+# Xbox 360 mode (experimental)
+
+**Stock-console support is awaiting physical validation, not verified
+compatibility.** The default `adafruit:nrf52:feather52840` build passed with
+Adafruit nRF52 core **1.7.0**. The updated firmware has been flashed and passed
+physical PC USB authentication tests; console acceptance remains unverified
+after a reported failure with the earlier build. Native regressions and the PC
+probe use synthetic challenges, not real-console captures.
+
+`MODE_XBOX` (`1`) implements a wired Xbox 360 USB personality and libxsm3 retail
+authentication in software on the **existing nRF52840 OpenPuck**. No donor
+controller, USB host adapter, second board, or additional wiring is required.
+The Steam Controller still connects by OpenPuck's RF protocol; this is not an
+Xbox 360 wireless receiver. Original Xbox mode (`MODE_XBOX_OG`, `10`) is separate.
+
+## Use and constraints
+
+1. Pair the Steam Controller with OpenPuck in Steam mode on a PC first.
+2. In the WebUSB panel, enable **Persist last mode** before selecting Xbox
+  mode. It is off by default: unplugging the puck otherwise boots it back
+  into Steam mode, even if Xbox mode was selected before unplugging.
+3. Hold **all four back buttons (L4 + R4 + L5 + R5) + X** to enter Xbox mode
+   with the default chord assignment.
+4. Unplug from the PC and connect to the console. With persistence enabled,
+  the puck boots into Xbox mode. Alternatively, leave persistence off and
+  select Xbox mode with the controller chord after connecting to the console.
+5. To configure mappings/rumble or update firmware, connect OpenPuck to a PC and
+   use **back-4 + A** for Steam mode, then open the WebUSB panel. Return with
+   **back-4 + X**. A is a fixed recovery chord; B/X/Y are configurable, so a saved
+   reassignment may change the Xbox shortcut. Mode switching reboots the puck.
+
+- **One USB gamepad**, even with four stored RF bonds. The first fresh report
+  from a live controller selects its bond. It stays selected until bond removal
+  or more than **1200 ms** without an RF reply; another controller cannot steal
+  it during a brief gap. RF supplies synthetic neutral input after **300 ms** of
+  silence to release held controls, without selecting an inactive bond.
+- The USB layout stays present with no RF controller and does not re-enumerate
+  when a controller joins, disconnects, or is replaced.
+- Gamepad input (including Guide) and host rumble are implemented. Rumble goes
+  only to the selected RF controller and uses the saved translated-mode tuning.
+- **No right-trackpad mouse, CDC serial, WebUSB, or wake HID** in Xbox mode.
+  Use Steam/Lizard for desktop mouse input; configure Xbox settings from Steam.
+- **Headset/audio and chatpad/plugin functions are not emulated.** Their USB
+  interfaces describe empty accessory roles; no accessory endpoints are opened
+  or serviced. LED commands are parsed, but physical player-ring behavior is
+  not equivalent to a genuine pad. This is not an indistinguishable replacement.
+
+## USB and authentication implementation
+
+Identity: Microsoft `045E:028E`, `bcdDevice=0x0114`, `bcdUSB=0x0200`, device
+class/subclass/protocol **`FF/FF/FF`**. A fixed 153-byte configuration contains
+four vendor interfaces:
+
+| Interface | Class/subclass/protocol | Role |
+|---|---|---|
+| 0 | `FF/5D/01` | Control: 20-byte gamepad input on `0x81`, rumble/LED OUT on `0x02` |
+| 1 | `FF/5D/03` | Audio, no accessory emulation |
+| 2 | `FF/5D/02` | Plugin, no accessory emulation |
+| 3 | `FF/FD/13` | XSM3 security via EP0 |
+
+The security string at index 4 is **178 bytes** (header + 88 UTF-16 code units,
+no trailing terminator). A linker wrapper avoids Adafruit's shorter string
+buffer; the full multi-packet USB transfer still needs hardware validation.
+USB serial and XSM3 identification share a stable 12-character serial derived
+from the nRF52840 hardware ID, independent of RF bond count or USB resets.
+
+Authentication handles vendor requests to interface 3 (`wIndex=3` or `0x0103`)
+and device-recipient requests (`wIndex=0`):
+
+| Request | Direction | Purpose / full payload length |
+|---|---|---|
+| `0x81` | IN | Identification, 29 bytes; starts a fresh session |
+| `0x82` | OUT | Init challenge, exactly 34 bytes |
+| `0x86` | IN | Status, 2 bytes: state 1 pending, state 2 ready |
+| `0x83` | IN | Completed response, 46 bytes for init or 22 for verify |
+| `0x87` | OUT | Verify challenge, exactly 22 bytes; requires a completed session |
+
+Invalid actual transfer lengths, packet length fields, checksums, and MACs fail
+closed: no successful readiness or stale response is published. Hardware RNG
+or worker startup failure also fails closed. USB **RESET** invalidates the
+session generation; an in-flight crypto operation may finish, but its old
+result is discarded rather than reused for a new host/session.
+
+Crypto runs in a dedicated **idle-priority FreeRTOS task with a 2048-word
+(8 KiB) stack**, not on the small USB callback stack. The main loop lends it
+**1 ms only while authentication is pending/running**, between RF polls and
+never inside an RX window. This keeps crypto out of USB callbacks but does not
+establish real-time timing on hardware.
+
+All three linker wrappers are required: `tud_vendor_control_xfer_cb` for vendor
+routing, `tud_descriptor_string_cb` for the full security string, and
+`usbd_control_xfer_cb` for actual EP0 received lengths. Use `make build` or the
+complete direct Arduino command in [BUILD_AND_DEPLOY.md](BUILD_AND_DEPLOY.md).
+
+## Validation
+
+Non-hardware checks, from the repository root:
+
+```sh
+python3 tools/test-xinput.py --check-fixtures
+make check
+make build
+```
+
+The native harness checks production auth, descriptor/string callbacks, input,
+rumble, controller selection, and reset/failure handling against independent
+OpenSSL/Python-generated synthetic exchanges. It does **not** prove physical
+enumeration, actual TinyUSB transfers, linker wrapping, FreeRTOS scheduling,
+hardware RNG, or stock-console compatibility. See
+[../tests/xinput/README.md](../tests/xinput/README.md) for coverage and limits.
+
+Separately, a physically connected **ProMicro nRF52840** (`045E:028E`) passed
+PC PyUSB transport testing against the independent OpenSSL/Python oracle on
+2026-09-17 after flashing the keepalive fix: descriptor/string checks passed,
+init became ready in **64.3 ms**, **zero-data OUT `0x84` passed in 1.1 ms**,
+and all **three verifies became ready in 28.3–29.0 ms**. Responses were checked
+using the controller nonce recovered from the hardware reply. This confirms
+the fix for the older build's reproducible OUT `0x84` stall.
+Stock-console compatibility remains
+**unverified**, with a user-reported console failure; these PC results do not
+establish console acceptance.
+
+For an explicit manual repeat, use
+[../tools/check-xinput-usb.py](../tools/check-xinput-usb.py) with `--run`
+(optionally `--bus` and `--address`, in decimal). It requires PyUSB, a libusb
+backend, USB permissions, and OpenSSL; it installs nothing. It refuses ambiguous
+selection or unexpected descriptors/strings before authenticating, but those
+checks cannot prove device provenance: connect only the intended OpenPuck, not
+a genuine controller. Authentication **invalidates any active session;
+unplug/replug afterward even on failure**. It never detaches drivers, sets the
+USB configuration, resets, or flashes the device. Default success requires
+zero-data OUT `0x84` after init and before all three verifies.
+`--skip-keepalive` is **only an old-build diagnostic**, explicitly reports the
+skip, and does not validate the fix. The default script run produced the
+physical PC test results above.
+
+### Physical test checklist (not yet performed)
+
+Record firmware revision, board/core version, console model/dashboard version,
+USB port/cable, and results. Keep captures of failed as well as successful runs.
+
+- [ ] Cold boot with the console powered off and OpenPuck already attached,
+      then power on; also attach OpenPuck to an already-powered-on console.
+- [ ] Repeat with the RF controller connected **before USB** and powered on
+      **after USB**. Check the static gamepad remains present without RF.
+- [ ] Capture enumeration: identity, all four interfaces, and the full 178-byte
+      security string. Trace `0x81 → 0x82 → 0x86 → 0x83`, then `0x87` with
+      subsequent status/response reads. Confirm no ready response before crypto
+      completes, and repeat authentication where requested by the host.
+- [ ] Exercise USB reset and unplug/replug, including during authentication.
+      Move the same puck to a **different console** and confirm a fresh session
+      without stale responses or a changed hardware serial.
+- [ ] Test dashboard navigation and in-game buttons, D-pad, sticks, analog
+      triggers, **Guide**, and **rumble**. Note player assignment separately from
+      the non-equivalent physical ring; do not treat it as an accessory test.
+- [ ] Test brief RF loss (neutral at 300 ms), disconnect beyond 1200 ms, reconnect,
+      and takeover by a second paired controller without USB re-enumeration or
+      rumble sent to the wrong controller. Run **more than 10 minutes**, including
+      reconnects and repeated rumble; check for auth loss, stuck inputs or resets.
+- [ ] Check desktop binding on Windows (`xusb`/XInput) and Linux (`xpad`/SDL),
+      input and rumble, plus back-4+A configuration and return to Xbox. Confirm
+      Xbox mode exposes no mouse, WebUSB, CDC, or wake HID.
+
+## Licensing and redistribution
+
+- **libxsm3: LGPL-2.1-or-later**, Copyright 2022–2023 InvoxiPlayGames, with
+  oct0xor attribution retained in the vendored sources.
+- **ExCrypt: BSD-3-Clause**, Copyright 2020 emoose; the full BSD notice is in
+  [../OpenPuck/src/libxsm3/NOTICE.md](../OpenPuck/src/libxsm3/NOTICE.md).
+- **GP2040-CE-derived USB descriptors: MIT**, Copyright 2021 Jason Skuby;
+  the full permission/copyright notice is in
+  [../OpenPuck/xinput_descriptors.h](../OpenPuck/xinput_descriptors.h).
+
+Retain the copyright notices, attribution, disclaimers, and license texts in
+source and binary distributions as required. Review the vendored revision and
+local modifications in
+[../OpenPuck/src/libxsm3/NOTICE.md](../OpenPuck/src/libxsm3/NOTICE.md) and the full
+[../OpenPuck/src/libxsm3/LICENSE.txt](../OpenPuck/src/libxsm3/LICENSE.txt).
+For statically linked firmware, satisfy the applicable LGPL source and
+relinking obligations: provide the corresponding library source (including
+modifications) and the object files or complete buildable source and build
+materials needed to relink with a modified library, using a distribution method
+permitted by the license. A binary plus a notice alone is not sufficient.

+ 92 - 0
tests/xinput/Adafruit_TinyUSB.h

@@ -0,0 +1,92 @@
+#pragma once
+#include <stddef.h>
+#include <stdint.h>
+
+#define CFG_TUD_MEM_ALIGN __attribute__((aligned(4)))
+#define CFG_TUSB_DEBUG 0
+
+enum xfer_result_t {
+	XFER_RESULT_SUCCESS,
+	XFER_RESULT_FAILED,
+	XFER_RESULT_STALLED,
+	XFER_RESULT_TIMEOUT,
+	XFER_RESULT_INVALID,
+};
+
+enum {
+	TUSB_DESC_STRING = 3,
+	TUSB_DESC_ENDPOINT = 5,
+	TUSB_DIR_OUT = 0,
+	TUSB_DIR_IN = 1,
+};
+
+struct __attribute__((packed)) tusb_desc_interface_t {
+	uint8_t bLength, bDescriptorType, bInterfaceNumber, bAlternateSetting;
+	uint8_t bNumEndpoints, bInterfaceClass, bInterfaceSubClass;
+	uint8_t bInterfaceProtocol, iInterface;
+};
+
+struct __attribute__((packed)) tusb_desc_endpoint_t {
+	uint8_t bLength, bDescriptorType, bEndpointAddress, bmAttributes;
+	uint16_t wMaxPacketSize;
+	uint8_t bInterval;
+};
+
+struct __attribute__((packed)) tusb_desc_device_t {
+	uint8_t bLength, bDescriptorType;
+	uint16_t bcdUSB;
+	uint8_t bDeviceClass, bDeviceSubClass, bDeviceProtocol, bMaxPacketSize0;
+	uint16_t idVendor, idProduct, bcdDevice;
+	uint8_t iManufacturer, iProduct, iSerialNumber, bNumConfigurations;
+};
+
+struct tusb_control_request_t {
+	uint8_t bmRequestType;
+	uint8_t bRequest;
+	uint16_t wValue;
+	uint16_t wIndex;
+	uint16_t wLength;
+};
+
+enum {
+	CONTROL_STAGE_SETUP,
+	CONTROL_STAGE_DATA,
+	CONTROL_STAGE_ACK,
+};
+
+extern "C" {
+bool tud_control_xfer(uint8_t rhport, const tusb_control_request_t *request,
+		      void *buffer, uint16_t length);
+
+bool tud_mounted();
+const uint8_t *tud_descriptor_device_cb();
+}
+
+class Adafruit_USBD_Interface {
+    protected:
+	uint8_t _strid = 0;
+
+    public:
+	virtual ~Adafruit_USBD_Interface() = default;
+	virtual uint16_t getInterfaceDescriptor(uint8_t itfnum, uint8_t *buf,
+						uint16_t bufsize) = 0;
+	void setStringDescriptor(const char *str);
+};
+
+class Adafruit_USBD_Device {
+    public:
+	uint8_t allocInterface(uint8_t count);
+	uint8_t allocEndpoint(uint8_t direction);
+	bool addInterface(Adafruit_USBD_Interface &interface);
+	void setID(uint16_t vendor, uint16_t product);
+	void setVersion(uint16_t version);
+	void setDeviceVersion(uint16_t version);
+	void setManufacturerDescriptor(const char *str);
+	void setProductDescriptor(const char *str);
+	void setSerialDescriptor(const char *str);
+	void setConfigurationAttribute(uint8_t attributes);
+	void setConfigurationMaxPower(uint16_t milliamps);
+};
+
+extern Adafruit_USBD_Device TinyUSBDevice;
+#define USBDevice TinyUSBDevice

+ 7 - 0
tests/xinput/Arduino.h

@@ -0,0 +1,7 @@
+#pragma once
+#include <stdint.h>
+
+unsigned long millis();
+uint32_t __get_PRIMASK();
+void __disable_irq();
+void __set_PRIMASK(uint32_t mask);

+ 139 - 0
tests/xinput/README.md

@@ -0,0 +1,139 @@
+# Native XInput regressions
+
+From the repository root:
+
+```sh
+python3 tools/test-xinput.py
+```
+
+Requires Python 3, a C11 compiler (`CC`, default `cc`), and a C++17 compiler
+(`CXX`, default `c++`). Compiler variables accept arguments, such as a compiler
+wrapper. No Arduino SDK, hardware, Python packages, OpenSSL, or shared UBSan
+runtime is needed for the default run. All objects, the generated fixture
+header, and the executable live in a `TemporaryDirectory` that is cleaned up
+on success or failure. Compiler, linker, sanitizer, or assertion failures
+produce a nonzero exit status. UBSan uses trap mode for both C and C++.
+
+Optional checks:
+
+```sh
+python3 tools/test-xinput.py --check-fixtures
+CC=clang CXX=clang++ python3 tools/test-xinput.py --asan
+```
+
+Fixture regeneration requires the `openssl` executable with two-key 3DES-CBC
+support. ASan requires a compiler with a usable host ASan runtime and a matching
+C++ standard library. Neither optional check is silently skipped on failure.
+
+## What is compiled and simulated
+
+The runner compiles the on-disk production `xinput_auth.cpp`, `xinput_usb.cpp`,
+`xinput_strings.cpp`, `mode_xinput.cpp`, and all five vendor crypto C sources,
+without patching or copying their implementations. Production local headers
+(including `config.h`, `mode_xinput.h`, and `xinput_descriptors.h`) are used as-is;
+there is no shadow config or controller implementation.
+
+The SDK substitutes are `Adafruit_TinyUSB.h`, `Arduino.h`, and
+`device/usbd_pvt.h`. Linkage stubs supply configuration/bond globals, platform
+auth hooks, deterministic `xsm3_random_bytes`, clock/interrupt primitives,
+USB identity/interface allocation, endpoints, the TX drain registration, and
+the haptic sink. `padStickBlend` records its input coordinates but leaves
+sticks unchanged; the shared blending implementation is not tested.
+
+The USB stub retains the actual transfer buffer. Every simulated DATA event
+enters the production `__wrap_usbd_control_xfer_cb`; the test's
+`__real_usbd_control_xfer_cb` copies only the received OUT payload prefix, then
+invokes DATA through the production class driver's auth callback. SETUP and ACK
+call auth directly. The wrapper's received-count validity is checked inside
+and outside the callback. Impossible oversized event counts are passed intact
+to the wrapper, while the simulated DMA copy is bounded by the offered buffer.
+IN snapshots are limited to the smaller of the offered length and `wLength`.
+Nested locks are checked for LIFO restoration;
+RNG and worker notification hooks must execute outside the lock. Scheduling is
+explicit, not a mock crypto completion: `xinputAuthProcess()` performs real
+DES/3DES, SHA-1, MAC and ACR operations.
+
+Coverage includes:
+
+- Identification framing/checksum and stable 12-byte serial across resets.
+- Interface recipients 3 and 0x0103, device recipient 0, wrong directions,
+  recipients, indices and commands; 21 selected lengths from 0 through 65535.
+- Exact 34/22-byte OUT lengths; 29/46/22/2/0-byte IN offers and truncation.
+- Every actual short OUT count (init 0..33, verify 0..21), oversized counts
+  through UINT32_MAX, all four non-success results, and wrong endpoints:
+  90 rejection cases, each with no notification/crypto/stale readiness and a
+  successful independent-console recovery.
+- No reply before completion; state 1 while queued, state 2 after processing;
+  DATA/ACK notification behavior, duplicate DATA rejection and idempotent work.
+- Two distinct console IDs/nonces, reinitialization without an explicit reset,
+  explicit resets, three consecutive verify challenges, and repeated IN reads.
+- Invalid packet lengths, checksum and MAC (with a repaired checksum), failure
+  without stale readiness, RNG/worker startup failure, recovery on a new init.
+- Reset before DATA, while queued, and inside the RNG crypto callback; a new
+  console challenge injected during crypto must supersede the old result.
+- EP0 response ownership across reset and a worker finishing during an IN poll.
+- Descriptor body/total lengths, four interfaces, class descriptor lengths,
+  all seven endpoint addresses, packet sizes, intervals and security string.
+- Actual string callback: all 178 bytes of index 4, including the copyright
+  code unit and final period, no terminator; all 256 indices in all configured
+  modes with four language IDs; exact delegation and null-return propagation.
+- Actual mode identity setters, descriptor builder bounds, every truncated
+  interface span and every altered descriptor byte except the dynamic string
+  index; four separate claims, only gamepad endpoints opened, endpoint-open
+  failure cleanup, and endpoint-number reservations in both directions.
+- Neutral input with no RF; every `bodyTlen` below 26 and exact 26-byte bodies
+  for both report IDs, invalid IDs/slots/null input, button/trigger/stick
+  serialization and reserved zeros. Short bodies use exact-sized allocations
+  so optional ASan can detect overreads.
+- Fresh-only RF selection, 299/300 ms acquisition boundary, synthetic neutral
+  on the selected bond, 1200/1201 ms disconnect boundary, and replacement without
+  USB re-registration.
+- Rumble byte scaling/routing, short/oversized/stale/malformed/failed OUT,
+  all LED commands isolated from rumble, disable/watchdog/disconnect stops,
+  no relay without a bond, endpoint retry, and mounted gating.
+
+## Independent oracle
+
+`fixtures.json` contains synthetic retail-key exchanges, **not hardware
+captures**. `reference.py` reproduces them without loading any production C
+code: OpenSSL supplies DES/3DES, hashlib supplies SHA-1, and Python implements
+framing, key derivation, salted MAC and PARVE/ACR arithmetic. Public constants
+and algorithm provenance are recorded in that script. A standard DES known
+answer checks its backend before generation.
+
+The native console fixture constructs its own encrypted init, derives session
+keys from its console ID and nonce, decrypts the **actual init response**, then
+uses the recovered controller nonce and plaintext hash to construct successive
+verify requests. It never calls the production XSM3 state machine as the
+console. Although it uses the low-level vendor primitives, every challenge and
+response must also match the independently generated JSON oracle byte for
+byte, including ACRs and checksums. Thus shared primitive bugs cannot pass
+merely by round-tripping against themselves. `--check-fixtures` regenerates
+and compares the JSON in memory; it never overwrites the committed fixture.
+
+Standalone known answers cover DES, SHA-1 (empty, `abc`, 32- and 55-byte input),
+and salted MAC with full 64-bit counter wrap. DES parity conversion is checked
+for preservation of the effective key schedule.
+
+## Limits
+
+The vendor SHA-1 helper supports only single-block inputs (at most 55 bytes);
+the standard 56-byte SHA-1 test fails. Auth hashes 8 or 32 bytes. The vendor DES
+parity helper preserves effective key bits but does not consistently produce
+odd parity; DES ignores those bits. Tests document these existing constraints
+rather than changing firmware or pretending the helpers are general-purpose.
+
+This harness does not validate physical USB enumeration, the firmware linker's
+`--wrap` flags, the actual TinyUSB EP0 engine, FreeRTOS scheduling, board identity
+derivation, hardware RNG, real-time timing, or physical Xbox 360 compatibility.
+Wrapper entry points are called explicitly. Failed/non-EP0 callbacks that the
+real stack may not forward to auth DATA are deliberately forwarded to stress
+the receive guard. It is not a thread-race detector. The descriptor total-length
+check is body + the standard nine-byte configuration header, not an
+Arduino-generated configuration. The string callback's full backing descriptor
+is checked, not an actual three-packet USB transaction. The endpoint model is
+synchronous and does not emulate USB DMA or RTOS concurrency.
+
+Only these tests and `tools/test-xinput.py` are owned by this change; Makefile,
+CI, and firmware sources are intentionally untouched. Handwritten C++ and the
+stub follow the repository's clang-format 18 style.

+ 23 - 0
tests/xinput/device/usbd_pvt.h

@@ -0,0 +1,23 @@
+#pragma once
+#include <Adafruit_TinyUSB.h>
+
+struct usbd_class_driver_t {
+	void (*init)();
+	bool (*deinit)();
+	void (*reset)(uint8_t rhport);
+	uint16_t (*open)(uint8_t rhport, const tusb_desc_interface_t *interface,
+			 uint16_t max_len);
+	bool (*control_xfer_cb)(uint8_t rhport, uint8_t stage,
+				const tusb_control_request_t *request);
+	bool (*xfer_cb)(uint8_t rhport, uint8_t ep, xfer_result_t result,
+			uint32_t length);
+	void (*sof)(uint8_t rhport, uint32_t frame_count);
+};
+
+bool usbd_edpt_open(uint8_t rhport, const tusb_desc_endpoint_t *descriptor);
+void usbd_edpt_close(uint8_t rhport, uint8_t ep);
+bool usbd_edpt_xfer(uint8_t rhport, uint8_t ep, uint8_t *buffer,
+		    uint16_t length);
+bool usbd_edpt_busy(uint8_t rhport, uint8_t ep);
+bool usbd_edpt_claim(uint8_t rhport, uint8_t ep);
+bool usbd_edpt_release(uint8_t rhport, uint8_t ep);

+ 40 - 0
tests/xinput/fixtures.json

@@ -0,0 +1,40 @@
+{
+  "consoles": [
+    {
+      "console": "1011121314151617",
+      "nonce": "000102030405060708090a0b0c0d0e0f",
+      "identification": "494b0000173031323334354142434445460080025e048e020300010151",
+      "init": "494c00001c541280ea2d96df8eb0a817123512d24a3971623b925dea10b00ae9b8ab",
+      "init_response": "494c0000285d90bf22e323944c5001308a9d226c0924e9cfe8be690206e6280c2cfd3172634a17b851f98c049b2d",
+      "verify": [
+        "494c0000103466063aef556782acf2e1e2ae81ec1bb4",
+        "494c00001080a82bee1a0b6d1c5414f3b7ece286d8d9",
+        "494c000010e78989cce699f7efe1c1a7eefd219a284b"
+      ],
+      "verify_response": [
+        "494c00001014b76f8e43d050e721ac6adce73a052eab",
+        "494c0000106f65fe11ace05ce6a5d70fb63e89c2973a",
+        "494c0000106b851bc19fd5bcb35cfbccfb6e848ab031"
+      ]
+    },
+    {
+      "console": "3031323334353637",
+      "nonce": "202122232425262728292a2b2c2d2e2f",
+      "identification": "494b0000173031323334354142434445460080025e048e020300010151",
+      "init": "494c00001c5fda7fe9222e370f6214f92bf3cd97efd8316e6d5e888d6e1821977dc9",
+      "init_response": "494c0000286b6c13dd22e0e461a3851fe31231aad9624fead9694bd331459b3a688389fdf113f136184c36de655d",
+      "verify": [
+        "494c0000103466063aef5567824798378d4e1049b5f6",
+        "494c00001080a82bee1a0b6d1cecd37e947a857d65bf",
+        "494c000010e78989cce699f7efa1357b13e213821fdc"
+      ],
+      "verify_response": [
+        "494c0000108b33b17cb1fc61d7b1e5fbd84153cd3e18",
+        "494c0000103f64615fdf60c5368ada43a14f82412b3c",
+        "494c0000103721722282f2de49b6423844a2f08e8075"
+      ]
+    }
+  ],
+  "mac_wrap": "0def310994ea853e",
+  "salt_wrap": "00000000000000001020304050607080"
+}

+ 576 - 0
tests/xinput/mode_test.cpp

@@ -0,0 +1,576 @@
+#include "mode_xinput.h"
+#include "config.h"
+#include "gamepad_util.h"
+#include "haptics.h"
+#include "usb_app_drivers.h"
+#include "usb_tx.h"
+#include "xinput_auth.h"
+#include "xinput_descriptors.h"
+#include <Arduino.h>
+#include <algorithm>
+#include <array>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <string>
+#include <vector>
+
+#define CHECK(condition) check(bool(condition), #condition, __LINE__)
+
+static void check(bool condition, const char *expression, int line)
+{
+	if (!condition) {
+		fprintf(stderr, "%s:%d: %s\n", __FILE__, line, expression);
+		abort();
+	}
+}
+
+using Bytes = std::vector<uint8_t>;
+Adafruit_USBD_Device TinyUSBDevice;
+uint8_t g_usbMode = MODE_XBOX;
+uint8_t g_abSwap, g_back[4], g_qamMap, g_rumble = 1;
+Slot g_slot[NSLOT];
+unsigned long g_connReplyMs[NSLOT];
+static unsigned long now = 1000;
+static uint32_t interruptMask;
+static bool mounted = true;
+static tusb_desc_device_t device;
+static std::string manufacturer, product, serial, interfaceString;
+static uint8_t attributes, interfaceCount, endpointCount[2];
+static uint16_t maxPower;
+static unsigned additions, registrations;
+static Adafruit_USBD_Interface *registeredInterface;
+static usbTxDrainFn drain;
+static Bytes configuration;
+
+struct Endpoint {
+	bool opened = false, busy = false, claimed = false;
+	uint8_t *buffer = nullptr;
+	uint16_t length = 0;
+};
+
+static Endpoint endpoints[256];
+static std::vector<uint8_t> opened, closed;
+static std::vector<Bytes> reports;
+static uint8_t failOpen;
+static bool failTransfer;
+
+struct Rumble {
+	uint16_t low, high;
+	uint8_t slot;
+};
+
+static std::vector<Rumble> rumbles;
+static std::array<int16_t, 4> padCoordinates;
+
+unsigned long millis()
+{
+	return now;
+}
+
+uint32_t __get_PRIMASK()
+{
+	return interruptMask;
+}
+
+void __disable_irq()
+{
+	interruptMask = 1;
+}
+
+void __set_PRIMASK(uint32_t mask)
+{
+	CHECK(mask <= 1);
+	interruptMask = mask;
+}
+
+bool tud_mounted()
+{
+	return mounted;
+}
+
+const uint8_t *tud_descriptor_device_cb()
+{
+	return reinterpret_cast<const uint8_t *>(&device);
+}
+
+void Adafruit_USBD_Interface::setStringDescriptor(const char *str)
+{
+	interfaceString = str;
+	_strid = 4;
+}
+
+uint8_t Adafruit_USBD_Device::allocInterface(uint8_t count)
+{
+	uint8_t first = interfaceCount;
+	interfaceCount += count;
+	return first;
+}
+
+uint8_t Adafruit_USBD_Device::allocEndpoint(uint8_t direction)
+{
+	CHECK(direction <= 1);
+	return ++endpointCount[direction] | (direction ? 0x80 : 0);
+}
+
+bool Adafruit_USBD_Device::addInterface(Adafruit_USBD_Interface &interface)
+{
+	additions++;
+	registeredInterface = &interface;
+	uint16_t length = interface.getInterfaceDescriptor(0, nullptr, 0);
+	CHECK(length == 144);
+	configuration.resize(length);
+	CHECK(interface.getInterfaceDescriptor(0, configuration.data(),
+					       length) == length);
+	return true;
+}
+
+void Adafruit_USBD_Device::setID(uint16_t vendor, uint16_t productID)
+{
+	device.idVendor = vendor;
+	device.idProduct = productID;
+}
+
+void Adafruit_USBD_Device::setVersion(uint16_t version)
+{
+	device.bcdUSB = version;
+}
+
+void Adafruit_USBD_Device::setDeviceVersion(uint16_t version)
+{
+	device.bcdDevice = version;
+}
+
+void Adafruit_USBD_Device::setManufacturerDescriptor(const char *str)
+{
+	manufacturer = str;
+}
+
+void Adafruit_USBD_Device::setProductDescriptor(const char *str)
+{
+	product = str;
+}
+
+void Adafruit_USBD_Device::setSerialDescriptor(const char *str)
+{
+	serial = str;
+}
+
+void Adafruit_USBD_Device::setConfigurationAttribute(uint8_t value)
+{
+	attributes = value;
+}
+
+void Adafruit_USBD_Device::setConfigurationMaxPower(uint16_t value)
+{
+	maxPower = value;
+}
+
+void usbTxRegisterDrain(usbTxDrainFn fn)
+{
+	registrations++;
+	drain = fn;
+}
+
+void xinputAuthYield()
+{
+	CHECK(interruptMask == 0);
+}
+
+bool hapticSteamRumble(uint16_t low, uint16_t high, uint8_t slot)
+{
+	CHECK(interruptMask == 0 && slot < NSLOT);
+	rumbles.push_back({ low, high, slot });
+	return true;
+}
+
+void padStickBlend(uint32_t, int16_t lpx, int16_t lpy, int16_t rpx, int16_t rpy,
+		   int16_t *, int16_t *, int16_t *, int16_t *)
+{
+	// Record the caller's reads; shared trackpad blending is not under test.
+	padCoordinates = { lpx, lpy, rpx, rpy };
+}
+
+extern "C" bool usbd_edpt_open(uint8_t rhport,
+			       const tusb_desc_endpoint_t *descriptor)
+{
+	CHECK(rhport == 0 && descriptor != nullptr);
+	uint8_t ep = descriptor->bEndpointAddress;
+	CHECK(!endpoints[ep].opened);
+	if (ep == failOpen)
+		return false;
+	CHECK(descriptor->wMaxPacketSize == 32);
+	endpoints[ep].opened = true;
+	opened.push_back(ep);
+	return true;
+}
+
+extern "C" void usbd_edpt_close(uint8_t rhport, uint8_t ep)
+{
+	CHECK(rhport == 0 && endpoints[ep].opened);
+	endpoints[ep] = {};
+	closed.push_back(ep);
+}
+
+extern "C" bool usbd_edpt_xfer(uint8_t rhport, uint8_t ep, uint8_t *buffer,
+			       uint16_t length)
+{
+	CHECK(rhport == 0 && buffer != nullptr);
+	auto &endpoint = endpoints[ep];
+	CHECK(endpoint.opened && !endpoint.busy);
+	CHECK((ep == 0x81 && length == 20) || (ep == 2 && length == 32));
+	if (failTransfer) {
+		failTransfer = false;
+		return false;
+	}
+	endpoint.busy = true;
+	endpoint.claimed = false;
+	endpoint.buffer = buffer;
+	endpoint.length = length;
+	if (ep == 0x81)
+		reports.emplace_back(buffer, buffer + length);
+	return true;
+}
+
+extern "C" bool usbd_edpt_busy(uint8_t rhport, uint8_t ep)
+{
+	CHECK(rhport == 0);
+	return endpoints[ep].busy;
+}
+
+extern "C" bool usbd_edpt_claim(uint8_t rhport, uint8_t ep)
+{
+	CHECK(rhport == 0);
+	auto &endpoint = endpoints[ep];
+	if (endpoint.busy || endpoint.claimed)
+		return false;
+	endpoint.claimed = true;
+	return true;
+}
+
+extern "C" bool usbd_edpt_release(uint8_t rhport, uint8_t ep)
+{
+	CHECK(rhport == 0 && endpoints[ep].claimed);
+	endpoints[ep].claimed = false;
+	return true;
+}
+
+static uint16_t openInterface(const Bytes &body, size_t offset, uint16_t length)
+{
+	return xinputClassDriver()->open(
+		0,
+		reinterpret_cast<const tusb_desc_interface_t *>(body.data() +
+								offset),
+		length);
+}
+
+static void resetUSB()
+{
+	for (auto &endpoint : endpoints)
+		endpoint = {};
+	xinputClassDriver()->reset(0);
+	g_xboxCtl.task();
+	opened.clear();
+	closed.clear();
+	reports.clear();
+	rumbles.clear();
+}
+
+static void enumerate()
+{
+	static const uint16_t starts[] = { 0, 40, 104, 129, 144 };
+	for (unsigned i = 0; i < 4; i++) {
+		uint16_t length = starts[i + 1] - starts[i];
+		CHECK(openInterface(configuration, starts[i], length) ==
+		      length);
+		CHECK(openInterface(configuration, starts[i], length) == 0);
+	}
+	CHECK(opened == Bytes({ 0x81, 0x02 }));
+	CHECK(endpoints[2].busy && endpoints[2].length == 32);
+}
+
+static void completeInput(xfer_result_t result = XFER_RESULT_SUCCESS)
+{
+	CHECK(endpoints[0x81].busy);
+	endpoints[0x81].busy = false;
+	CHECK(xinputClassDriver()->xfer_cb(0, 0x81, result, 20));
+}
+
+static void output(const Bytes &packet, uint32_t actual,
+		   xfer_result_t result = XFER_RESULT_SUCCESS)
+{
+	auto &ep = endpoints[2];
+	CHECK(ep.busy);
+	CHECK(packet.size() <= ep.length);
+	// Keep the old tail, as DMA does, so short rumble cannot use stale bytes.
+	if (!packet.empty())
+		memcpy(ep.buffer, packet.data(), packet.size());
+	ep.busy = false;
+	CHECK(xinputClassDriver()->xfer_cb(0, 2, result, actual));
+}
+
+static Bytes neutral()
+{
+	Bytes report(20);
+	report[1] = 20;
+	return report;
+}
+
+static void enumerationTests()
+{
+	static_assert(sizeof(tusb_desc_interface_t) == 9, "Interface size");
+	static_assert(sizeof(tusb_desc_endpoint_t) == 7, "Endpoint size");
+	static_assert(sizeof(tusb_desc_device_t) == 18, "Device size");
+	xinputClassDriver()->init();
+	g_xboxCtl.begin();
+	CHECK(!g_xboxCtl.dynamicMount());
+	CHECK(additions == 1 && registrations == 1 && drain != nullptr);
+	CHECK(device.idVendor == 0x045E && device.idProduct == 0x028E);
+	CHECK(device.bcdUSB == 0x0200 && device.bcdDevice == 0x0114);
+	CHECK(device.bDeviceClass == 0xFF && device.bDeviceSubClass == 0xFF);
+	CHECK(device.bDeviceProtocol == 0xFF);
+	CHECK(manufacturer == "\xc2\xa9Microsoft Corporation");
+	CHECK(product == "Controller" && serial == "012345ABCDEF");
+	CHECK(attributes == 0xA0 && maxPower == 500);
+	CHECK(interfaceString == XINPUT_SECURITY_STRING);
+	CHECK(interfaceCount == 4 && endpointCount[0] == 6 &&
+	      endpointCount[1] == 6);
+	CHECK(configuration ==
+	      Bytes(XINPUT_CONFIG_BODY,
+		    XINPUT_CONFIG_BODY + sizeof XINPUT_CONFIG_BODY));
+	Bytes guard(160, 0xA5);
+	for (unsigned length = 0; length < 144; length++)
+		CHECK(registeredInterface->getInterfaceDescriptor(
+			      0, guard.data(), length) == 0);
+	CHECK(registeredInterface->getInterfaceDescriptor(1, guard.data(),
+							  160) == 0);
+	CHECK(std::all_of(guard.begin(), guard.end(),
+			  [](uint8_t b) { return b == 0xA5; }));
+	CHECK(interfaceCount == 4 && endpointCount[0] == 6 &&
+	      endpointCount[1] == 6);
+	CHECK(xinputClassDriver()->open(0, nullptr, 144) == 0);
+	static const uint16_t starts[] = { 0, 40, 104, 129, 144 };
+	for (unsigned i = 0; i < 4; i++) {
+		uint16_t length = starts[i + 1] - starts[i];
+		for (uint16_t shortLength = 0; shortLength < length;
+		     shortLength++)
+			CHECK(openInterface(configuration, starts[i],
+					    shortLength) == 0);
+		for (size_t byte = starts[i]; byte < starts[i + 1]; byte++) {
+			if (byte == XINPUT_SECURITY_STRING_OFFSET)
+				continue;
+			Bytes corrupt = configuration;
+			corrupt[byte] ^= 0x80;
+			CHECK(openInterface(corrupt, starts[i], length) == 0);
+		}
+	}
+	CHECK(opened.empty());
+	failOpen = 0x81;
+	CHECK(openInterface(configuration, 0, 40) == 0);
+	CHECK(opened.empty());
+	failOpen = 2;
+	CHECK(openInterface(configuration, 0, 40) == 0);
+	CHECK(opened == Bytes({ 0x81 }) && closed == Bytes({ 0x81 }));
+	failOpen = 0;
+	resetUSB();
+	enumerate();
+	drain();
+	CHECK(reports.size() == 1 && reports.back() == neutral());
+	completeInput();
+	for (unsigned i = 0; i < 3; i++) {
+		g_xboxCtl.task();
+		drain();
+	}
+	CHECK(reports.size() == 1 && additions == 1);
+	CHECK(!xinputClassDriver()->xfer_cb(1, 2, XFER_RESULT_SUCCESS, 8));
+	CHECK(!xinputClassDriver()->xfer_cb(0, 0x83, XFER_RESULT_SUCCESS, 8));
+	puts("PASS actual mode: static identity, descriptor builder/parser, "
+	     "endpoint reservations, accessory claims, neutral without RF");
+}
+
+static Bytes rfReport(uint8_t id = 0x45)
+{
+	Bytes report(28);
+	report[0] = id;
+	uint32_t buttons = TB_A | TB_VIEW | TB_MENU | TB_DUP | TB_L2;
+	for (unsigned i = 0; i < 4; i++)
+		report[2 + i] = buttons >> (i * 8);
+	le16(report.data() + 8, 0x4000);
+	le16(report.data() + 10, -32768);
+	le16(report.data() + 12, 32767);
+	le16(report.data() + 14, -1);
+	le16(report.data() + 16, 1234);
+	le16(report.data() + 18, 101);
+	le16(report.data() + 20, -102);
+	le16(report.data() + 24, 103);
+	le16(report.data() + 26, -104);
+	return report;
+}
+
+static void rfTests()
+{
+	g_slot[0].used = g_slot[1].used = true;
+	g_connReplyMs[0] = g_connReplyMs[1] = now;
+	auto report = rfReport();
+	for (unsigned length = 0; length < 26; length++) {
+		// Report includes ID and sequence before the body. Exact allocations
+		// let ASan detect reads beyond the advertised body, if enabled.
+		Bytes shortReport(report.begin(), report.begin() + length + 2);
+		g_xboxCtl.onReport45(0, shortReport.data(), true, length);
+	}
+	g_xboxCtl.onReport45(0, nullptr, true, 26);
+	for (int slot : { -1, NSLOT, 255 })
+		g_xboxCtl.onReport45(slot, report.data(), true, 26);
+	for (uint8_t id : { 0, 0x43, 0x44, 0x47, 0xFF }) {
+		auto bad = rfReport(id);
+		g_xboxCtl.onReport45(0, bad.data(), true, 26);
+	}
+	g_xboxCtl.onReport45(0, report.data(), false, 26);
+	drain();
+	CHECK(reports.size() == 1);
+	g_connReplyMs[0] = now - 300;
+	g_xboxCtl.onReport45(0, report.data(), true, 26);
+	drain();
+	CHECK(reports.size() == 1);
+	g_connReplyMs[0] = now - 299;
+	g_xboxCtl.onReport45(0, report.data(), true, 26);
+	drain();
+	Bytes expected = { 0,	 20,   0x31, 0x10, 0xFF, 0x80, 0,
+			   0x80, 0xFF, 0x7F, 0xFF, 0xFF, 0xD2, 4,
+			   0,	 0,    0,    0,	   0,	 0 };
+	CHECK(reports.size() == 2 && reports.back() == expected);
+	CHECK((padCoordinates ==
+	       std::array<int16_t, 4>{ 101, -102, 103, -104 }));
+	completeInput();
+	g_xboxCtl.onReport45(1, report.data(), true, 26);
+	drain();
+	CHECK(reports.size() == 2);
+	Bytes synthetic(28);
+	synthetic[0] = 0x45;
+	g_xboxCtl.onReport45(0, synthetic.data(), false, 26);
+	drain();
+	CHECK(reports.size() == 3 && reports.back() == neutral());
+	completeInput();
+	report[0] = 0x42;
+	g_xboxCtl.onReport45(0, report.data(), true, 26);
+	drain();
+	CHECK(reports.size() == 4 && reports.back() == expected);
+	completeInput();
+	g_connReplyMs[0] = now - 1200;
+	g_xboxCtl.task();
+	drain();
+	CHECK(reports.size() == 4);
+	now++;
+	g_xboxCtl.task();
+	drain();
+	CHECK(reports.size() == 5 && reports.back() == neutral());
+	CHECK(rumbles.size() == 1 && rumbles.back().slot == 0);
+	CHECK(rumbles.back().low == 0 && rumbles.back().high == 0);
+	completeInput();
+	g_connReplyMs[1] = now;
+	g_xboxCtl.onReport45(1, report.data(), false, 26);
+	drain();
+	CHECK(reports.size() == 5);
+	g_xboxCtl.onReport45(1, report.data(), true, 26);
+	drain();
+	CHECK(reports.size() == 6 && reports.back() == expected);
+	completeInput();
+	CHECK(additions == 1 && registrations == 1 && opened.size() == 2);
+	puts("PASS actual mode: bodyTlen 0..25, exact 26-byte body, RF selection, "
+	     "synthetic neutral, disconnect and replacement without enumeration");
+}
+
+static void rumbleTests()
+{
+	rumbles.clear();
+	Bytes valid = { 0, 8, 0, 0x12, 0xFE, 0, 0, 0 };
+	output(valid, 8);
+	g_xboxCtl.task();
+	CHECK(rumbles.size() == 1 && rumbles.back().slot == 1);
+	CHECK(rumbles.back().low == 0x1212 && rumbles.back().high == 0xFEFE);
+	for (uint32_t length : { 0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, 9u, 20u, 32u,
+				 33u, 65536u, UINT32_MAX }) {
+		Bytes packet = valid;
+		packet.resize(std::min<uint32_t>(length, 32));
+		output(packet, length);
+		g_xboxCtl.task();
+		CHECK(rumbles.size() == 1 && endpoints[2].busy);
+	}
+	for (unsigned byte : { 0, 1, 2, 5, 6, 7 }) {
+		Bytes malformed = valid;
+		malformed[byte] ^= 1;
+		output(malformed, 8);
+		g_xboxCtl.task();
+		CHECK(rumbles.size() == 1);
+	}
+	for (auto result : { XFER_RESULT_FAILED, XFER_RESULT_STALLED,
+			     XFER_RESULT_TIMEOUT, XFER_RESULT_INVALID }) {
+		output(valid, 8, result);
+		g_xboxCtl.task();
+		CHECK(rumbles.size() == 1);
+	}
+	for (unsigned led = 0; led <= 255; led++) {
+		output({ 1, 3, uint8_t(led) }, 3);
+		g_xboxCtl.task();
+		CHECK(rumbles.size() == 1);
+	}
+	g_rumble = 0;
+	output(valid, 8);
+	g_xboxCtl.task();
+	CHECK(rumbles.size() == 2 && rumbles.back().low == 0 &&
+	      rumbles.back().high == 0);
+	g_rumble = 1;
+	output(valid, 8);
+	g_xboxCtl.task();
+	now += 2500;
+	g_connReplyMs[1] = now;
+	g_xboxCtl.task();
+	CHECK(rumbles.size() == 3);
+	now++;
+	g_connReplyMs[1] = now;
+	g_xboxCtl.task();
+	CHECK(rumbles.size() == 4 && rumbles.back().low == 0 &&
+	      rumbles.back().high == 0);
+	g_xboxCtl.task();
+	CHECK(rumbles.size() == 4);
+	failTransfer = true;
+	output({}, 0);
+	CHECK(!endpoints[2].busy);
+	drain();
+	CHECK(endpoints[2].busy);
+	output(valid, 8);
+	g_slot[1].used = false;
+	g_xboxCtl.task();
+	CHECK(rumbles.size() == 5 && rumbles.back().slot == 1);
+	CHECK(rumbles.back().low == 0 && rumbles.back().high == 0);
+	failTransfer = true;
+	drain();
+	CHECK(!endpoints[0x81].busy);
+	drain();
+	CHECK(reports.back() == neutral());
+	completeInput(XFER_RESULT_FAILED);
+	mounted = false;
+	size_t before = reports.size();
+	drain();
+	CHECK(reports.size() == before);
+	mounted = true;
+	drain();
+	CHECK(reports.size() == before + 1 && reports.back() == neutral());
+	completeInput();
+	output(valid, 8);
+	g_xboxCtl.task();
+	CHECK(rumbles.size() == 5);
+	CHECK(additions == 1 && registrations == 1);
+	puts("PASS actual mode: rumble routing, malformed/stale/failed OUT, LED "
+	     "isolation, disable/watchdog/disconnect stops, endpoint retries");
+}
+
+void modeTests()
+{
+	enumerationTests();
+	rfTests();
+	rumbleTests();
+	resetUSB();
+	CHECK(xinputClassDriver()->deinit());
+	CHECK(interruptMask == 0);
+}

+ 163 - 0
tests/xinput/reference.py

@@ -0,0 +1,163 @@
+"""Independent fixture generator; needs OpenSSL only when regenerating.
+
+No production code is imported or called. DES/3DES use OpenSSL; SHA-1 uses
+hashlib. Packet framing, key derivation, salted MAC and ACR are implemented
+here. Public protocol constants and PARVE/ACR definitions are from
+InvoxiPlayGames/libxsm3 (LGPL-2.1-or-later), copyright 2013 oct0xor and
+2022-2023 InvoxiPlayGames; see OpenPuck/src/libxsm3/LICENSE.txt.
+
+Prints JSON to stdout; the checked-in fixture is consumed without OpenSSL.
+These are synthetic retail-key transcripts, not captures from hardware.
+"""
+
+import hashlib
+import json
+import subprocess
+
+
+KEY_D = bytes.fromhex("e35bfb1ccdad325bf70e07fd623da7c4")
+KEY_E = bytes.fromhex("8f2908380b5bfe687c26462a51f2bc19")
+ROOT_23 = bytes.fromhex("828078683a523a9810f40c127066dcba")
+ROOT_24 = bytes.fromhex("66621a78f8609c8a269a04aed85c1ec8")
+SERIAL = b"012345ABCDEF"
+RANDOM = bytes(range(0x90, 0xA0))
+SBOX = bytes.fromhex(
+    "b03d9b70f3c78060739f6cc0f13dbb40b3c83714df49dad4482278806ecde700"
+    "818668e15d7c542c557bef48427b3b68e3dbaac00fa99620950593949af6a364"
+    "5dcc7600e50819e88d29d74c219117f4bc6ab38083c6d4909bae0efe2e4af200"
+    "7388d94066c5d40857b18948dc54fc436a2687b8095fce80e40b059c24f3dee2"
+    "3eec388aa255a4504e4be9587f9f7d80230c4d80054426b8e9d8bce6763a6ea4"
+    "19dec2d0c4bcc35c59df16463970f4ee2d585aa817866b6029584dd25f287ad8"
+    "8e79ea8294333181d922d510da92a07d3ddaac1ca25331b83c965200826b56a0"
+    "d3c240c71b7fdc017270b18c01090936fc97eadee30dae7ee30dae7e33698040"
+)
+PLAINTEXT = bytes.fromhex(
+    "d1d2f2806eba0cc0b6c4c9d861751d1a3f9558bed80de2c0d0217920652d9940"
+    "3c9652001b7fdc01821c13d833698040fc97eade08ea14dceb0f6a186f782cb0"
+    "d3c240c7826b56a0190936e07270b18ce30dae7e50a52be2c9afc7701c298056"
+    "24f066fa022b58988fe4d13c6e382affb8fa35b05249c5b466fa47556c8d4008"
+)
+
+
+def crypt(key, data, encrypt=True):
+    # DES parity bits do not affect OpenSSL's key schedule.
+    args = ["openssl", "enc", "-des-ede-cbc", "-nopad", "-nosalt",
+            "-K", key[:16].hex(), "-iv", "0000000000000000"]
+    if not encrypt:
+        args.append("-d")
+    return subprocess.run(args, input=data, capture_output=True,
+                          check=True).stdout
+
+
+def xor(a, b):
+    return bytes(x ^ y for x, y in zip(a, b))
+
+
+def mac(key, data, salt=None):
+    block = bytes(8)
+    des_key = key[:8] * 2
+    if salt is not None:
+        counter = (int.from_bytes(salt[:8], "big") + 1) % (1 << 64)
+        salt[:8] = counter.to_bytes(8, "big")
+        block = crypt(des_key, salt[:8])
+    for offset in range(0, len(data), 8):
+        block = crypt(des_key, xor(block, data[offset:offset + 8]))
+    return crypt(key, xor(block, b"\x80" + bytes(7)))
+
+
+def parve(key, data):
+    state = list(data) + [data[0]]
+    for round_number in range(8, 0, -1):
+        for index in range(8):
+            value = SBOX[(key[index] + state[index] + round_number) & 255]
+            value = (value + state[index + 1]) & 255
+            state[index + 1] = ((value << 1) | (value >> 7)) & 255
+        state[0] = state[8]
+    return bytes(state[:8])
+
+
+def acr(console, identity, key):
+    cd = parve(key, identity[:4] + console[:4])
+    ab = parve(key, identity[16:24])
+    for offset in range(0, 128, 8):
+        ab = parve(key, xor(ab, PLAINTEXT[offset:offset + 8]))
+    modulus = 0x7FFFFFFF
+    a, b = (int.from_bytes(ab[i:i + 4], "big") % modulus for i in (0, 4))
+    c, d = (int.from_bytes(cd[i:i + 4], "big") % modulus for i in (0, 4))
+    accumulator = total = 0
+    for offset in range(0, 128, 8):
+        left = int.from_bytes(PLAINTEXT[offset:offset + 4], "big")
+        right = int.from_bytes(PLAINTEXT[offset + 4:offset + 8], "big")
+        accumulator = ((accumulator + left * 0xE79A9C1) * a + b) % modulus
+        total += accumulator
+        accumulator = ((right + accumulator) * c + d) % modulus
+        total += accumulator
+    result = ((accumulator + b) % modulus).to_bytes(4, "big")
+    result += ((total + d) % modulus).to_bytes(4, "big")
+    return xor(result, ab)
+
+
+def packet(payload, magic=b"IL"):
+    checksum = 0
+    for value in payload:
+        checksum ^= value
+    return magic + b"\0\0" + bytes([len(payload)]) + payload + bytes([checksum])
+
+
+def transcript(number):
+    console = bytes(range(0x10 + number * 0x20, 0x18 + number * 0x20))
+    nonce = bytes(range(number * 0x20, number * 0x20 + 16))
+    identification = packet(SERIAL + bytes.fromhex("0080025e048e0203000101"), b"IK")
+    identity = bytearray(32)
+    identity[:15] = identification[5:20]
+    identity[16:21] = identification[20:25]
+    identity[21] = identification[27]
+    identity[22:24] = identification[25:27]
+    digest = hashlib.sha1(console).digest()
+    key1 = crypt(ROOT_23, digest[:16])
+    key2 = crypt(ROOT_24, digest[4:20])
+    session1 = crypt(key1, nonce)
+    session2 = crypt(key2, nonce[8:] + nonce[:8])
+    encrypted = crypt(KEY_D, nonce + console)
+    init = packet(encrypted + mac(KEY_E, encrypted)[4:])
+    encrypted = crypt(session1, RANDOM + nonce)
+    init_response = packet(encrypted + acr(console, identity,
+                                           mac(session2, encrypted)))
+
+    # The console recovers the controller nonce and hash from the reply.
+    recovered = crypt(session1, init_response[5:37], False)
+    assert recovered[16:] == nonce
+    controller = recovered[:16]
+    init_hash = hashlib.sha1(recovered).digest()
+    salt = bytearray(controller[12:] + nonce[12:] + nonce[8:])
+    verifies = []
+    replies = []
+    for iteration in range(3):
+        fresh = bytes(range(0x60 + iteration * 8, 0x68 + iteration * 8))
+        salt[8:] = fresh
+        encrypted = crypt(controller, fresh)
+        verifies.append(packet(encrypted + mac(init_hash, encrypted, salt)))
+        encrypted = crypt(session1, acr(console, identity, fresh))
+        replies.append(packet(encrypted + mac(session2, encrypted, salt)))
+    return {"console": console.hex(), "nonce": nonce.hex(),
+            "identification": identification.hex(), "init": init.hex(),
+            "init_response": init_response.hex(),
+            "verify": [v.hex() for v in verifies],
+            "verify_response": [v.hex() for v in replies]}
+
+
+def main():
+    # FIPS DES example, exercised through two-key EDE with identical keys.
+    key = bytes.fromhex("133457799bbcdff1") * 2
+    assert crypt(key, bytes.fromhex("0123456789abcdef")).hex() == (
+        "85e813540f0ab405")
+    key = bytes(range(16))
+    salt = bytearray.fromhex("ffffffffffffffff1020304050607080")
+    salted_mac = mac(key, bytes(range(24)), salt)
+    fixture = {"consoles": [transcript(0), transcript(1)],
+               "mac_wrap": salted_mac.hex(), "salt_wrap": salt.hex()}
+    print(json.dumps(fixture, indent=2))
+
+
+if __name__ == "__main__":
+    main()

+ 889 - 0
tests/xinput/test.cpp

@@ -0,0 +1,889 @@
+#include "xinput_auth.h"
+#include "xinput_descriptors.h"
+#include "config.h"
+#include "usb_app_drivers.h"
+#include "src/libxsm3/excrypt.h"
+#include "src/libxsm3/usbdsec.h"
+#include "src/libxsm3/xsm3.h"
+#include <algorithm>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <vector>
+
+#define CHECK(condition) check(bool(condition), #condition, __LINE__)
+
+static void check(bool condition, const char *expression, int line)
+{
+	if (!condition) {
+		fprintf(stderr, "%s:%d: %s\n", __FILE__, line, expression);
+		abort();
+	}
+}
+
+using Bytes = std::vector<uint8_t>;
+
+struct Fixture {
+	const char *console;
+	const char *nonce;
+	const char *identification;
+	const char *init;
+	const char *init_response;
+	const char *verify[3];
+	const char *verify_response[3];
+};
+
+#include "fixtures.h"
+
+static Bytes hex(const char *text)
+{
+	CHECK(strlen(text) % 2 == 0);
+	Bytes result;
+	while (*text) {
+		unsigned byte;
+		CHECK(sscanf(text, "%2x", &byte) == 1);
+		result.push_back(byte);
+		text += 2;
+	}
+	return result;
+}
+
+static void checksum(Bytes &packet)
+{
+	CHECK(packet.size() == size_t(packet[4]) + 6);
+	packet.back() = 0;
+	for (size_t i = 5; i + 1 < packet.size(); i++)
+		packet.back() ^= packet[i];
+}
+
+static void checkPacket(const Bytes &packet, size_t length)
+{
+	CHECK(packet.size() == length);
+	CHECK(packet[0] == 0x49 && packet[1] == 0x4C);
+	CHECK(packet[2] == 0 && packet[3] == 0);
+	Bytes copy = packet;
+	checksum(copy);
+	CHECK(copy == packet);
+}
+
+static uint32_t lockDepth;
+static unsigned notifications, rngCalls, workerStarts;
+static bool workerAvailable = true, rngAvailable = true;
+static bool resetInRng, replaceInRng;
+static uint8_t *ep0;
+static uint16_t offered, transferred;
+static unsigned transfers;
+static const tusb_control_request_t *callbackRequest;
+static const Bytes *callbackPayload;
+static uint8_t callbackEndpoint;
+static xfer_result_t callbackResult;
+static uint32_t callbackLength;
+static unsigned callbacks;
+
+extern "C" bool __wrap_usbd_control_xfer_cb(uint8_t rhport, uint8_t ep,
+					    xfer_result_t result,
+					    uint32_t length);
+
+extern "C" bool __real_usbd_control_xfer_cb(uint8_t rhport, uint8_t ep,
+					    xfer_result_t result,
+					    uint32_t length)
+{
+	CHECK(rhport == 0 && ep == callbackEndpoint);
+	CHECK(result == callbackResult && length == callbackLength);
+	CHECK(xinputAuthReceived(callbackRequest->wLength) ==
+	      (ep == 0 && result == XFER_RESULT_SUCCESS &&
+	       length == callbackRequest->wLength));
+	callbacks++;
+	// Model TinyUSB copying only received bytes, never the SETUP wLength.
+	if (ep == 0 && !callbackPayload->empty()) {
+		CHECK(ep0 != nullptr);
+		size_t copied = std::min<size_t>(length, offered);
+		copied = std::min(copied, callbackPayload->size());
+		memcpy(ep0, callbackPayload->data(), copied);
+	}
+	return xinputClassDriver()->control_xfer_cb(0, CONTROL_STAGE_DATA,
+						    callbackRequest);
+}
+
+static bool data(const tusb_control_request_t &req, const Bytes &payload,
+		 uint32_t actual, uint8_t ep = 0,
+		 xfer_result_t result = XFER_RESULT_SUCCESS)
+{
+	CHECK(!xinputAuthReceived(req.wLength));
+	callbackRequest = &req;
+	callbackPayload = &payload;
+	callbackEndpoint = ep;
+	callbackResult = result;
+	callbackLength = actual;
+	unsigned before = callbacks;
+	bool ok = __wrap_usbd_control_xfer_cb(0, ep, result, actual);
+	CHECK(callbacks == before + 1);
+	CHECK(!xinputAuthReceived(req.wLength));
+	callbackRequest = nullptr;
+	callbackPayload = nullptr;
+	return ok;
+}
+
+static bool data(const tusb_control_request_t &req, const Bytes &payload = {})
+{
+	bool input = req.bmRequestType & 0x80;
+	return data(req, payload, input ? transferred : req.wLength,
+		    input ? 0x80 : 0);
+}
+
+static tusb_control_request_t request(uint8_t command, uint16_t length,
+				      uint8_t type = 0xC1,
+				      uint16_t index = 0x0103)
+{
+	return { type, command, 0, index, length };
+}
+
+bool tud_control_xfer(uint8_t rhport, const tusb_control_request_t *req,
+		      void *buffer, uint16_t length)
+{
+	CHECK(rhport == 0);
+	CHECK(lockDepth == 0);
+	CHECK(buffer != nullptr);
+	CHECK(length <= 46);
+	ep0 = static_cast<uint8_t *>(buffer);
+	offered = length;
+	transferred = std::min(length, req->wLength);
+	transfers++;
+	return true;
+}
+
+uint32_t xinputAuthLock()
+{
+	return lockDepth++;
+}
+
+void xinputAuthUnlock(uint32_t mask)
+{
+	CHECK(lockDepth == mask + 1);
+	lockDepth = mask;
+}
+
+bool xinputAuthStartWorker()
+{
+	workerStarts++;
+	return workerAvailable;
+}
+
+void xinputAuthNotify()
+{
+	CHECK(lockDepth == 0);
+	notifications++;
+}
+
+void xinputAuthBoardSerial(char serial[13])
+{
+	memcpy(serial, "012345ABCDEF", 13);
+}
+
+static bool setup(const tusb_control_request_t &req)
+{
+	ep0 = nullptr;
+	offered = transferred = 0;
+	unsigned before = transfers;
+	bool ok = xinputAuthControl(0, CONTROL_STAGE_SETUP, &req);
+	CHECK(lockDepth == 0);
+	CHECK(transfers == before + unsigned(ok));
+	return ok;
+}
+
+static void out(uint8_t command, const Bytes &packet, uint16_t index = 0x0103,
+		uint8_t type = 0x41)
+{
+	auto req = request(command, packet.size(), type, index);
+	unsigned before = notifications;
+	CHECK(setup(req));
+	CHECK(offered == packet.size() && transferred == packet.size());
+	CHECK(notifications == before);
+	CHECK(data(req, packet));
+	CHECK(notifications == before + 1);
+	CHECK(xinputAuthControl(0, CONTROL_STAGE_ACK, &req));
+	CHECK(notifications == before + 1);
+}
+
+bool xsm3_random_bytes(uint8_t *buffer, size_t length)
+{
+	CHECK(lockDepth == 0);
+	CHECK(length == 16);
+	rngCalls++;
+	for (size_t i = 0; i < length; i++)
+		buffer[i] = 0x90 + i;
+	if (resetInRng) {
+		resetInRng = false;
+		xinputAuthReset();
+	}
+	if (replaceInRng) {
+		replaceInRng = false;
+		out(0x82, hex(fixtures[1].init));
+	}
+	return rngAvailable;
+}
+
+static Bytes in(uint8_t command, uint16_t length = 65535,
+		uint16_t index = 0x0103, uint8_t type = 0xC1)
+{
+	auto req = request(command, length, type, index);
+	CHECK(setup(req));
+	Bytes result(ep0, ep0 + transferred);
+	unsigned before = transfers;
+	CHECK(data(req));
+	CHECK(xinputAuthControl(0, CONTROL_STAGE_ACK, &req));
+	CHECK(transfers == before);
+	return result;
+}
+
+static void state(uint8_t expected)
+{
+	CHECK(in(0x86) == Bytes({ expected, 0 }));
+}
+
+static void noReply()
+{
+	CHECK(!setup(request(0x83, 46)));
+}
+
+static void failed()
+{
+	noReply();
+	CHECK(!setup(request(0x86, 2)));
+	CHECK(!setup(request(0x87, 22, 0x41)));
+	xinputAuthProcess();
+	noReply();
+}
+
+/*
+ * This console uses only primitives, never xsm3_do_challenge_* or its globals.
+ * Every packet is also compared with the independent OpenSSL/Python fixture,
+ * so matching bugs in the shared primitives cannot bless a broken exchange.
+ */
+class Console {
+    public:
+	const Fixture &fixture;
+	Bytes nonce, id, key1, key2, controller, hash, salt;
+
+	explicit Console(const Fixture &data)
+		: fixture(data)
+		, nonce(hex(data.nonce))
+		, id(hex(data.console))
+		, key1(16)
+		, key2(16)
+		, controller(16)
+		, hash(20)
+		, salt(16)
+	{
+		uint8_t digest[20], kv1[16], kv2[16];
+		ExCryptSha(id.data(), id.size(), digest);
+		auto root1 = hex("828078683a523a9810f40c127066dcba");
+		auto root2 = hex("66621a78f8609c8a269a04aed85c1ec8");
+		UsbdSecXSM3AuthenticationCrypt(root1.data(), digest, 16, kv1,
+					       1);
+		UsbdSecXSM3AuthenticationCrypt(root2.data(), digest + 4, 16,
+					       kv2, 1);
+		Bytes swapped(nonce.begin() + 8, nonce.end());
+		swapped.insert(swapped.end(), nonce.begin(), nonce.begin() + 8);
+		UsbdSecXSM3AuthenticationCrypt(kv1, nonce.data(), 16,
+					       key1.data(), 1);
+		UsbdSecXSM3AuthenticationCrypt(kv2, swapped.data(), 16,
+					       key2.data(), 1);
+	}
+
+	Bytes init()
+	{
+		Bytes plain = nonce;
+		plain.insert(plain.end(), id.begin(), id.end());
+		Bytes packet(34);
+		packet[0] = 0x49;
+		packet[1] = 0x4C;
+		packet[4] = 28;
+		auto cryptKey = hex("e35bfb1ccdad325bf70e07fd623da7c4");
+		auto macKey = hex("8f2908380b5bfe687c26462a51f2bc19");
+		uint8_t mac[8];
+		UsbdSecXSM3AuthenticationCrypt(cryptKey.data(), plain.data(),
+					       24, packet.data() + 5, 1);
+		UsbdSecXSM3AuthenticationMac(macKey.data(), nullptr,
+					     packet.data() + 5, 24, mac);
+		memcpy(packet.data() + 29, mac + 4, 4);
+		checksum(packet);
+		CHECK(packet == hex(fixture.init));
+		return packet;
+	}
+
+	void acceptInit(const Bytes &response)
+	{
+		checkPacket(response, 46);
+		CHECK(response == hex(fixture.init_response));
+		uint8_t plain[32];
+		UsbdSecXSM3AuthenticationCrypt(key1.data(), response.data() + 5,
+					       32, plain, 0);
+		CHECK(std::equal(nonce.begin(), nonce.end(), plain + 16));
+		memcpy(controller.data(), plain, 16);
+		ExCryptSha(plain, sizeof plain, hash.data());
+		memcpy(salt.data(), controller.data() + 12, 4);
+		memcpy(salt.data() + 4, nonce.data() + 12, 4);
+	}
+
+	Bytes verify(unsigned iteration)
+	{
+		CHECK(iteration < 3);
+		for (unsigned i = 0; i < 8; i++)
+			salt[8 + i] = 0x60 + iteration * 8 + i;
+		Bytes packet(22);
+		packet[0] = 0x49;
+		packet[1] = 0x4C;
+		packet[4] = 16;
+		UsbdSecXSM3AuthenticationCrypt(controller.data(),
+					       salt.data() + 8, 8,
+					       packet.data() + 5, 1);
+		UsbdSecXSM3AuthenticationMac(hash.data(), salt.data(),
+					     packet.data() + 5, 8,
+					     packet.data() + 13);
+		checksum(packet);
+		CHECK(packet == hex(fixture.verify[iteration]));
+		return packet;
+	}
+
+	void acceptVerify(Bytes response, unsigned iteration)
+	{
+		checkPacket(response, 22);
+		CHECK(response == hex(fixture.verify_response[iteration]));
+		uint8_t mac[8];
+		UsbdSecXSM3AuthenticationMac(key2.data(), salt.data(),
+					     response.data() + 5, 8, mac);
+		CHECK(memcmp(mac, response.data() + 13, 8) == 0);
+	}
+};
+
+static void begin(Console &console, uint16_t index = 0x0103,
+		  uint8_t type = 0x41)
+{
+	unsigned before = rngCalls;
+	out(0x82, console.init(), index, type);
+	CHECK(rngCalls == before);
+	state(1);
+	noReply();
+	xinputAuthProcess();
+	CHECK(rngCalls == before + 1);
+	state(2);
+	console.acceptInit(in(0x83));
+	xinputAuthProcess();
+	CHECK(rngCalls == before + 1);
+}
+
+static void exchange(Console &console)
+{
+	for (unsigned i = 0; i < 3; i++) {
+		unsigned before = rngCalls;
+		out(0x87, console.verify(i));
+		state(1);
+		noReply();
+		xinputAuthProcess();
+		CHECK(rngCalls == before);
+		state(2);
+		Bytes response = in(0x83);
+		CHECK(in(0x83) == response);
+		console.acceptVerify(response, i);
+	}
+}
+
+static void cryptoKnownAnswers()
+{
+	uint8_t digest[20];
+	ExCryptSha(reinterpret_cast<const uint8_t *>("abc"), 3, digest);
+	CHECK(Bytes(digest, digest + 20) ==
+	      hex("a9993e364706816aba3e25717850c26c9cd0d89d"));
+	ExCryptSha(reinterpret_cast<const uint8_t *>(""), 0, digest);
+	CHECK(Bytes(digest, digest + 20) ==
+	      hex("da39a3ee5e6b4b0d3255bfef95601890afd80709"));
+	// This vendor helper only supports single-block SHA-1 (at most 55 bytes).
+	uint8_t message[55];
+	for (unsigned i = 0; i < sizeof message; i++)
+		message[i] = i;
+	ExCryptSha(message, 32, digest);
+	CHECK(Bytes(digest, digest + 20) ==
+	      hex("ae5bd8efea5322c4d9986d06680a781392f9a642"));
+	ExCryptSha(message, sizeof message, digest);
+	CHECK(Bytes(digest, digest + 20) ==
+	      hex("8ae2d46729cfe68ff927af5eec9c7d1b66d65ac2"));
+	uint64_t key;
+	auto keyBytes = hex("133457799bbcdff1");
+	memcpy(&key, keyBytes.data(), 8);
+	EXCRYPT_DES_STATE des;
+	ExCryptDesKey(&des, &key);
+	auto plain = hex("0123456789abcdef");
+	uint8_t encrypted[8], recovered[8];
+	ExCryptDesEcb(&des, plain.data(), encrypted, 1);
+	CHECK(Bytes(encrypted, encrypted + 8) == hex("85e813540f0ab405"));
+	ExCryptDesEcb(&des, encrypted, recovered, 0);
+	CHECK(Bytes(recovered, recovered + 8) == plain);
+	uint8_t parity[256], input[256];
+	for (unsigned i = 0; i < 256; i++)
+		input[i] = i;
+	ExCryptDesParity(input, sizeof input, parity);
+	for (unsigned i = 0; i < 256; i++)
+		CHECK((parity[i] & 0xFE) == (i & 0xFE));
+	// DES ignores parity bits; this helper does not normalize odd parity.
+	for (unsigned i = 0; i < 256; i += 8) {
+		EXCRYPT_DES_STATE original, adjusted;
+		memcpy(&key, input + i, 8);
+		ExCryptDesKey(&original, &key);
+		memcpy(&key, parity + i, 8);
+		ExCryptDesKey(&adjusted, &key);
+		CHECK(memcmp(&original, &adjusted, sizeof original) == 0);
+	}
+	uint8_t macKey[16], data[24];
+	for (unsigned i = 0; i < sizeof macKey; i++)
+		macKey[i] = i;
+	for (unsigned i = 0; i < sizeof data; i++)
+		data[i] = i;
+	auto salt = hex("ffffffffffffffff1020304050607080");
+	UsbdSecXSM3AuthenticationMac(macKey, salt.data(), data, sizeof data,
+				     encrypted);
+	CHECK(Bytes(encrypted, encrypted + 8) == hex(mac_wrap));
+	CHECK(salt == hex(salt_wrap));
+	puts("PASS SHA-1/DES known answers, DES parity, salted MAC wrap");
+}
+
+static void descriptors()
+{
+	static_assert(sizeof(tusb_control_request_t) == 8, "SETUP size");
+	CHECK(sizeof XINPUT_CONFIG_BODY == 144);
+	CHECK(sizeof XINPUT_CONFIG_BODY + 9 == 153);
+	const uint8_t counts[] = { 2, 4, 1, 0 };
+	const uint8_t protocols[] = { 1, 3, 2, 0x13 };
+	const uint8_t classLengths[] = { 17, 27, 9, 6 };
+	const uint8_t endpoints[] = { 0x81, 0x02, 0x83, 0x04, 0x85, 0x06, 0x86 };
+	const uint8_t intervals[] = { 1, 8, 2, 4, 0x40, 0x10, 0x10 };
+	unsigned interfaces = 0, endpoint = 0, localEndpoints = 0;
+	unsigned classDescriptors = 0;
+	for (size_t offset = 0; offset < sizeof XINPUT_CONFIG_BODY;) {
+		const uint8_t *d = XINPUT_CONFIG_BODY + offset;
+		CHECK(d[0] >= 2 && offset + d[0] <= sizeof XINPUT_CONFIG_BODY);
+		switch (d[1]) {
+		case 4:
+			if (interfaces)
+				CHECK(localEndpoints == counts[interfaces - 1]);
+			CHECK(interfaces < 4 && d[0] == 9);
+			CHECK(d[2] == interfaces && d[3] == 0);
+			CHECK(d[4] == counts[interfaces] && d[5] == 0xFF);
+			CHECK(d[6] == (interfaces == 3 ? 0xFD : 0x5D));
+			CHECK(d[7] == protocols[interfaces]);
+			CHECK(d[8] == (interfaces == 3 ? 4 : 0));
+			if (interfaces == 3)
+				CHECK(offset + 8 ==
+				      XINPUT_SECURITY_STRING_OFFSET);
+			interfaces++;
+			localEndpoints = 0;
+			break;
+		case 5:
+			CHECK(interfaces > 0 && endpoint < 7);
+			CHECK(d[0] == 7 && d[2] == endpoints[endpoint]);
+			CHECK(d[3] == 3 && d[4] == 32 && d[5] == 0);
+			CHECK(d[6] == intervals[endpoint]);
+			endpoint++;
+			localEndpoints++;
+			break;
+		case 0x21:
+		case 0x41:
+			CHECK(interfaces > 0 &&
+			      classDescriptors == interfaces - 1);
+			CHECK(d[0] == classLengths[classDescriptors]);
+			CHECK(d[1] == (interfaces == 4 ? 0x41 : 0x21));
+			classDescriptors++;
+			break;
+		default:
+			CHECK(false);
+		}
+		offset += d[0];
+	}
+	CHECK(interfaces == 4 && endpoint == 7 && classDescriptors == 4);
+	CHECK(localEndpoints == 0);
+	CHECK(XINPUT_SECURITY_STRING_OFFSET == 137);
+	CHECK(strcmp(XINPUT_SECURITY_STRING,
+		     "Xbox Security Method 3, Version 1.00, \xc2\xa9 2005 Microsoft "
+		     "Corporation. All rights reserved.") == 0);
+	puts("PASS descriptor lengths, interfaces, endpoints, security string");
+}
+
+static const uint16_t lengths[] = {
+	0,  1,	2,  5,	21,  22,  23,	 28,	29,    33,    34,
+	35, 45, 46, 47, 255, 256, 32767, 32768, 65534, 65535,
+};
+
+static void identificationAndBounds()
+{
+	xinputAuthPrepare();
+	CHECK(workerStarts == 1);
+	CHECK(strlen(xinputAuthSerial()) == 12);
+	CHECK(strcmp(xinputAuthSerial(), "012345ABCDEF") == 0);
+	auto expected = hex(fixtures[0].identification);
+	for (uint16_t index : { 3, 0x0103 }) {
+		for (uint16_t length : lengths) {
+			Bytes id = in(0x81, length, index);
+			CHECK(offered == 29);
+			Bytes truncated = expected;
+			truncated.resize(std::min<size_t>(length, 29));
+			CHECK(id == truncated);
+		}
+	}
+	CHECK(in(0x81, 65535, 0, 0xC0) == expected);
+	CHECK(in(0x81) == expected);
+	CHECK(memcmp(expected.data() + 5, xinputAuthSerial(), 12) == 0);
+	Bytes checked = expected;
+	checksum(checked);
+	CHECK(checked == expected);
+	state(1);
+	noReply();
+	CHECK(!setup(request(0x87, 22, 0x41)));
+	Console console(fixtures[0]);
+	begin(console);
+	for (uint8_t command : { 0x82, 0x87 }) {
+		for (uint16_t length : lengths) {
+			if (length == (command == 0x82 ? 34 : 22))
+				continue;
+			CHECK(!setup(request(command, length, 0x41)));
+			CHECK(in(0x83) == hex(fixtures[0].init_response));
+		}
+	}
+	for (uint8_t command : { 0x83, 0x84, 0x86 }) {
+		Bytes full = in(command);
+		for (uint16_t length : lengths) {
+			Bytes reply = in(command, length);
+			CHECK(offered == full.size());
+			Bytes truncated = full;
+			truncated.resize(std::min<size_t>(length, full.size()));
+			CHECK(reply == truncated);
+		}
+	}
+	for (uint8_t command : { 0x81, 0x82, 0x83, 0x84, 0x86, 0x87 }) {
+		bool output = command == 0x82 || command == 0x87;
+		uint16_t length = command == 0x82 ? 34 : 22;
+		uint8_t correct = output ? 0x41 : 0xC1;
+		CHECK(!setup(request(command, length, correct ^ 0x80)));
+		for (uint8_t type : { 0x00, 0x01, 0x21, 0x42, 0x43, 0x80, 0x81,
+				      0xA1, 0xC2, 0xC3 })
+			CHECK(!setup(request(command, length, type)));
+		for (uint16_t index : { 0, 1, 2, 4, 0x0100, 0x0203, 65535 })
+			CHECK(!setup(request(command, length, correct, index)));
+		CHECK(!setup(request(command, length, correct - 1, 3)));
+	}
+	for (uint8_t command : { 0x00, 0x80, 0x85, 0x88, 0xFF }) {
+		CHECK(!setup(request(command, 34, 0x41)));
+		CHECK(!setup(request(command, 46)));
+	}
+	CHECK(in(0x83) == hex(fixtures[0].init_response));
+	puts("PASS identification, serial stability, lengths and request routing");
+}
+
+static void sessions()
+{
+	for (unsigned round = 0; round < 2; round++) {
+		for (const auto &fixture : fixtures) {
+			Console console(fixture);
+			if (round)
+				xinputAuthReset();
+			begin(console, round ? 3 : 0x0103);
+			auto keepalive =
+				request(0x84, 0, 0x41, round ? 3 : 0x0103);
+			keepalive.wValue = 3;
+			unsigned notified = notifications;
+			CHECK(setup(keepalive));
+			CHECK(offered == 0);
+			CHECK(xinputAuthControl(0, CONTROL_STAGE_ACK,
+						&keepalive));
+			CHECK(notifications == notified);
+			state(2);
+			CHECK(in(0x83) == hex(fixture.init_response));
+			exchange(console);
+			for (uint16_t length : lengths) {
+				auto expected = hex(fixture.verify_response[2]);
+				expected.resize(std::min<size_t>(length, 22));
+				CHECK(in(0x83, length) == expected);
+				CHECK(offered == 22);
+			}
+		}
+	}
+	Console device(fixtures[0]);
+	begin(device, 0, 0x40);
+	CHECK(in(0x83, 65535, 0, 0xC0) == hex(fixtures[0].init_response));
+	exchange(device);
+	CHECK(in(0x81) == hex(fixtures[0].identification));
+	state(1);
+	noReply();
+	CHECK(strcmp(xinputAuthSerial(), "012345ABCDEF") == 0);
+	puts("PASS two consoles, OUT keepalive, reinit/reset, repeated verification, IN truncation");
+}
+
+static void stagesAndReset()
+{
+	xinputAuthReset();
+	auto req = request(0x82, 34, 0x41);
+	CHECK(!data(req));
+	CHECK(setup(req));
+	unsigned before = notifications, randomBefore = rngCalls;
+	CHECK(xinputAuthControl(0, CONTROL_STAGE_ACK, &req));
+	xinputAuthProcess();
+	CHECK(notifications == before && rngCalls == randomBefore);
+	auto init = hex(fixtures[0].init);
+	CHECK(data(req, init));
+	CHECK(notifications == before + 1);
+	CHECK(!data(req, init));
+	xinputAuthReset();
+	xinputAuthProcess();
+	CHECK(rngCalls == randomBefore);
+	state(1);
+	noReply();
+	CHECK(setup(req));
+	xinputAuthReset();
+	CHECK(!data(req, init));
+
+	Console console(fixtures[0]);
+	begin(console);
+	auto replyReq = request(0x83, 46);
+	CHECK(setup(replyReq));
+	uint8_t *owned = ep0;
+	Bytes snapshot(owned, owned + offered);
+	xinputAuthReset();
+	CHECK(Bytes(owned, owned + snapshot.size()) == snapshot);
+	CHECK(data(replyReq));
+	CHECK(xinputAuthControl(0, CONTROL_STAGE_ACK, &replyReq));
+	noReply();
+
+	out(0x82, console.init());
+	CHECK(setup(request(0x86, 2)));
+	owned = ep0;
+	CHECK(owned[0] == 1 && owned[1] == 0);
+	xinputAuthProcess();
+	CHECK(owned[0] == 1 && owned[1] == 0);
+	state(2);
+
+	resetInRng = true;
+	out(0x82, console.init());
+	xinputAuthProcess();
+	CHECK(!resetInRng);
+	state(1);
+	noReply();
+	CHECK(!setup(request(0x87, 22, 0x41)));
+	begin(console);
+	exchange(console);
+
+	replaceInRng = true;
+	out(0x82, console.init());
+	xinputAuthProcess();
+	CHECK(!replaceInRng);
+	state(1);
+	noReply();
+	xinputAuthProcess();
+	Console replacement(fixtures[1]);
+	replacement.acceptInit(in(0x83));
+	exchange(replacement);
+	puts("PASS EP0 stages, buffer ownership, reset/replacement during crypto");
+}
+
+static void invalidChallenges()
+{
+	for (bool verify : { false, true }) {
+		for (unsigned corruption = 0; corruption < 4; corruption++) {
+			Console console(fixtures[0]);
+			begin(console);
+			Bytes packet = verify ? console.verify(0) :
+						console.init();
+			switch (corruption) {
+			case 0:
+				packet.back() ^= 1;
+				break;
+			case 1:
+				packet[verify ? 13 : 29] ^= 1;
+				checksum(packet);
+				break;
+			case 2:
+				packet[4] = 0;
+				break;
+			case 3:
+				packet[4] = 255;
+				break;
+			}
+			unsigned before = rngCalls;
+			out(verify ? 0x87 : 0x82, packet);
+			noReply();
+			state(1);
+			xinputAuthProcess();
+			CHECK(rngCalls == before);
+			failed();
+			CHECK(std::all_of(xsm3_challenge_response,
+					  xsm3_challenge_response + 48,
+					  [](uint8_t b) { return b == 0; }));
+			Console recovery(fixtures[1]);
+			begin(recovery);
+			exchange(recovery);
+		}
+	}
+	Console console(fixtures[0]);
+	begin(console);
+	rngAvailable = false;
+	out(0x82, console.init());
+	xinputAuthProcess();
+	failed();
+	rngAvailable = true;
+	begin(console);
+	exchange(console);
+	workerAvailable = false;
+	xinputAuthPrepare();
+	CHECK(!setup(request(0x81, 29)));
+	CHECK(!setup(request(0x82, 34, 0x41)));
+	workerAvailable = true;
+	xinputAuthPrepare();
+	CHECK(in(0x81) == hex(fixtures[0].identification));
+	begin(console);
+	exchange(console);
+	puts("PASS malformed packets, checksum/MAC failures, RNG/worker failures");
+}
+
+static void receivedLengths()
+{
+	unsigned rejected = 0;
+	for (bool verify : { false, true }) {
+		uint16_t expected = verify ? 22 : 34;
+		auto reject = [&](uint32_t actual, uint8_t endpoint,
+				  xfer_result_t result) {
+			Console console(fixtures[0]);
+			begin(console);
+			Bytes packet = verify ? console.verify(0) :
+						console.init();
+			auto req =
+				request(verify ? 0x87 : 0x82, expected, 0x41);
+			CHECK(setup(req));
+			uint8_t *buffer = ep0;
+			unsigned before = notifications,
+				 randomBefore = rngCalls;
+			CHECK(!data(req, packet, actual, endpoint, result));
+			if (endpoint == 0) {
+				size_t copied =
+					std::min<uint32_t>(actual, expected);
+				CHECK(std::equal(packet.begin(),
+						 packet.begin() + copied,
+						 buffer));
+				CHECK(std::all_of(
+					buffer + copied, buffer + expected,
+					[](uint8_t b) { return b == 0; }));
+			}
+			CHECK(notifications == before && !xinputAuthBusy());
+			CHECK(xinputAuthControl(0, CONTROL_STAGE_ACK, &req));
+			CHECK(notifications == before);
+			failed();
+			CHECK(rngCalls == randomBefore);
+			// A later full-length callback cannot revive the rejected job.
+			CHECK(!data(req));
+			CHECK(notifications == before);
+			Console recovery(fixtures[1]);
+			begin(recovery);
+			exchange(recovery);
+			rejected++;
+		};
+		for (uint32_t actual = 0; actual < expected; actual++)
+			reject(actual, 0, XFER_RESULT_SUCCESS);
+		for (uint32_t actual : { uint32_t(expected + 1), 46u, 64u, 255u,
+					 256u, 65535u, 65536u, UINT32_MAX })
+			reject(actual, 0, XFER_RESULT_SUCCESS);
+		for (auto result : { XFER_RESULT_FAILED, XFER_RESULT_STALLED,
+				     XFER_RESULT_TIMEOUT, XFER_RESULT_INVALID })
+			reject(expected, 0, result);
+		for (uint8_t endpoint : { 1, 2, 0x80, 0x81, 0xFF })
+			reject(expected, endpoint, XFER_RESULT_SUCCESS);
+	}
+	CHECK(rejected == 90);
+	puts("PASS 90 EP0 rejects: every short init/verify, oversized counts, "
+	     "failed results, wrong endpoints, recovery");
+}
+
+extern "C" const uint16_t *__wrap_tud_descriptor_string_cb(uint8_t index,
+							   uint16_t langid);
+
+static unsigned stringDelegations;
+static uint8_t delegatedIndex;
+static uint16_t delegatedLanguage;
+static const uint16_t fallbackString[] = { 0x0304, 'Z' };
+
+extern "C" const uint16_t *__real_tud_descriptor_string_cb(uint8_t index,
+							   uint16_t langid)
+{
+	stringDelegations++;
+	delegatedIndex = index;
+	delegatedLanguage = langid;
+	return index == 0xFF ? nullptr : fallbackString;
+}
+
+static void stringDescriptors()
+{
+	static const char expected[] =
+		"Xbox Security Method 3, Version 1.00, \xA9 2005 Microsoft "
+		"Corporation. All rights reserved.";
+	static_assert(sizeof expected == 89, "88 security code units plus NUL");
+	for (uint8_t mode = 0; mode <= MODE_MAX; mode++) {
+		g_usbMode = mode;
+		for (unsigned index = 0; index <= 255; index++) {
+			for (uint16_t language :
+			     { 0, 0x0409, 0x0411, 0xFFFF }) {
+				unsigned before = stringDelegations;
+				const uint16_t *descriptor =
+					__wrap_tud_descriptor_string_cb(
+						index, language);
+				if (mode == MODE_XBOX && index == 4) {
+					CHECK(stringDelegations == before);
+					CHECK(descriptor != nullptr);
+					CHECK(descriptor[0] == 0x03B2);
+					for (size_t i = 0; i < 88; i++) {
+						uint16_t code =
+							uint8_t(expected[i]);
+						CHECK(descriptor[i + 1] ==
+						      code);
+					}
+					// Include the tail beyond two EP0 packets; USB
+					// strings have no terminating UTF-16 NUL.
+					Bytes bytes;
+					for (size_t i = 0; i < 89; i++) {
+						bytes.push_back(descriptor[i] &
+								0xFF);
+						bytes.push_back(descriptor[i] >>
+								8);
+					}
+					CHECK(bytes.size() == 178 &&
+					      bytes[0] == 178);
+					CHECK(bytes[176] == '.' &&
+					      bytes[177] == 0);
+				} else {
+					CHECK(stringDelegations == before + 1);
+					CHECK(delegatedIndex == index);
+					CHECK(delegatedLanguage == language);
+					CHECK(descriptor ==
+					      (index == 255 ? nullptr :
+							      fallbackString));
+				}
+			}
+		}
+	}
+	g_usbMode = MODE_XBOX;
+	puts("PASS actual string wrapper: full 178 bytes, all indices/modes, "
+	     "language and null delegation");
+}
+
+void modeTests();
+
+int main()
+{
+	cryptoKnownAnswers();
+	descriptors();
+	identificationAndBounds();
+	sessions();
+	stagesAndReset();
+	invalidChallenges();
+	receivedLengths();
+	stringDescriptors();
+	modeTests();
+	CHECK(lockDepth == 0);
+	puts("PASS all native XInput regression tests");
+	return 0;
+}

+ 269 - 0
tools/check-xinput-usb.py

@@ -0,0 +1,269 @@
+#!/usr/bin/env python3
+"""Opt-in physical OpenPuck XSM3 transport check, not a console acceptance test."""
+
+import argparse
+import hashlib
+import importlib.util
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import sys
+from threading import Event
+import time
+
+
+USB_TIMEOUT_MS = 1500
+READY_TIMEOUT_S = 5.0
+POLL_INTERVAL_S = 0.010
+SECURITY = (
+    "Xbox Security Method 3, Version 1.00, © 2005 Microsoft "
+    "Corporation. All rights reserved."
+)
+
+
+def require(condition, message):
+    if not condition:
+        raise RuntimeError(message)
+
+
+def positive_int(value):
+    number = int(value, 10)
+    if number <= 0:
+        raise argparse.ArgumentTypeError("must be a positive decimal integer")
+    return number
+
+
+def load_reference():
+    path = Path(__file__).resolve().parents[1] / "tests/xinput/reference.py"
+    spec = importlib.util.spec_from_file_location("xinput_usb_reference", path)
+    require(spec is not None and spec.loader is not None,
+            "cannot load independent reference")
+    reference = importlib.util.module_from_spec(spec)
+    # Execute source directly so the manual checker never creates a pycache.
+    exec(compile(path.read_bytes(), str(path), "exec"), reference.__dict__)
+    return reference
+
+
+class Probe:
+    def __init__(self):
+        self.stage = "setup"
+        self.device = None
+        self.waiter = Event()
+
+    def progress(self, stage):
+        self.stage = stage
+        print(f"[{stage}]", flush=True)
+
+    def descriptor(self, kind, index, length, language=0):
+        return bytes(self.device.ctrl_transfer(
+            0x80, 6, (kind << 8) | index, language, length,
+            timeout=USB_TIMEOUT_MS))
+
+    def string(self, index):
+        require(index != 0, "missing required string index")
+        raw = self.descriptor(3, index, 255, 0x0409)
+        require(len(raw) >= 2 and raw[0] == len(raw) and raw[1] == 3
+                and len(raw) % 2 == 0, "malformed or truncated string")
+        return raw[2:].decode("utf-16-le")
+
+    def gate(self):
+        dev = self.device
+        require((dev.bcdUSB, dev.bcdDevice, dev.bDeviceClass,
+                 dev.bDeviceSubClass, dev.bDeviceProtocol,
+                 dev.bMaxPacketSize0, dev.bNumConfigurations)
+                == (0x0200, 0x0114, 0xFF, 0xFF, 0xFF, 64, 1),
+                "device descriptor is not the expected OpenPuck personality")
+        config = self.descriptor(2, 0, 153)
+        require(len(config) == 153 and config[:9] == bytes(
+            [9, 2, 153, 0, 4, 1, 0, 0xA0, 250]),
+            "expected a 153-byte, four-interface OpenPuck configuration")
+        interfaces = []
+        offset = 9
+        while offset < len(config):
+            size = config[offset]
+            require(size >= 2 and offset + size <= len(config),
+                    "malformed configuration descriptor")
+            if config[offset + 1] == 4:
+                require(size == 9, "malformed interface descriptor")
+                interfaces.append(tuple(config[offset + 2:offset + 9]))
+            offset += size
+        require(interfaces == [
+            (0, 0, 2, 0xFF, 0x5D, 1, 0),
+            (1, 0, 4, 0xFF, 0x5D, 3, 0),
+            (2, 0, 1, 0xFF, 0x5D, 2, 0),
+            (3, 0, 0, 0xFF, 0xFD, 0x13, 4),
+        ], "interface layout is not the expected OpenPuck personality")
+        require(self.string(dev.iManufacturer) == "©Microsoft Corporation"
+                and self.string(dev.iProduct) == "Controller",
+                "manufacturer/product strings do not match")
+        require(self.string(4) == SECURITY, "full security string mismatch")
+        serial = self.string(dev.iSerialNumber)
+        # Firmware formats the hardware ID as %04lX%08lX; no fixed prefix.
+        require(re.fullmatch(r"[0-9A-F]{12}", serial) is not None,
+                "expected a 12-character uppercase hexadecimal serial")
+        print("PASS descriptor/string gates (serial withheld)", flush=True)
+        return serial.encode("ascii")
+
+    def read(self, request, length, value=0, timeout=USB_TIMEOUT_MS):
+        data = bytes(self.device.ctrl_transfer(
+            0xC1, request, value, 0x0103, length, timeout=timeout))
+        require(len(data) == length,
+                f"IN 0x{request:02x}: expected {length} bytes, got {len(data)}")
+        return data
+
+    def write(self, request, data):
+        count = self.device.ctrl_transfer(
+            0x41, request, 3, 0x0103, data, timeout=USB_TIMEOUT_MS)
+        require(count == len(data), f"OUT 0x{request:02x}: short transfer")
+
+    def exchange(self, request, challenge, length):
+        started = time.monotonic()
+        self.write(request, challenge)
+        deadline = started + READY_TIMEOUT_S
+        polls = 0
+        while True:
+            remaining = deadline - time.monotonic()
+            require(remaining > 0, "authentication readiness deadline expired")
+            status = self.read(0x86, 2, timeout=min(
+                USB_TIMEOUT_MS, max(1, int(remaining * 1000))))
+            polls += 1
+            require(time.monotonic() <= deadline,
+                    "authentication readiness deadline expired")
+            if status == b"\x02\x00":
+                ready_ms = (time.monotonic() - started) * 1000
+                response = self.read(0x83, length, 0x5C00 | (length - 6))
+                print(f"ready in {ready_ms:.1f} ms ({polls} polls); "
+                      f"response in {(time.monotonic() - started) * 1000:.1f} ms",
+                      flush=True)
+                return response
+            require(status == b"\x01\x00",
+                    f"unexpected authentication status {status.hex()}")
+            self.waiter.wait(min(POLL_INTERVAL_S,
+                                 max(0, deadline - time.monotonic())))
+
+    def authenticate(self, reference, serial, skip_keepalive):
+        r = reference
+        self.progress("identification: IN 0x81 (invalidates active session)")
+        identification = self.read(0x81, 29, 0x5B17)
+        require(identification == r.packet(
+            serial + bytes.fromhex("0080025e048e0203000101"), b"IK"),
+            "identification framing, serial or identity mismatch")
+        identity = bytearray(32)
+        identity[:15] = identification[5:20]
+        identity[16:21] = identification[20:25]
+        identity[21] = identification[27]
+        identity[22:24] = identification[25:27]
+
+        # Synthetic public-key oracle inputs, never console secrets/captures.
+        console = bytes(range(16, 24))
+        nonce = bytes(range(16))
+        digest = hashlib.sha1(console).digest()
+        session1 = r.crypt(r.crypt(r.ROOT_23, digest[:16]), nonce)
+        session2 = r.crypt(r.crypt(r.ROOT_24, digest[4:]), nonce[8:] + nonce[:8])
+        encrypted = r.crypt(r.KEY_D, nonce + console)
+        self.progress("init: OUT 0x82 → status → response")
+        reply = self.exchange(
+            0x82, r.packet(encrypted + r.mac(r.KEY_E, encrypted)[4:]), 46)
+        recovered = r.crypt(session1, reply[5:37], False)
+        require(recovered[16:] == nonce, "init recovered console nonce mismatch")
+        require(reply == r.packet(reply[5:37] + r.acr(
+            console, identity, r.mac(session2, reply[5:37]))),
+            "init response differs from independent OpenSSL/Python oracle")
+        print("PASS init", flush=True)
+
+        self.progress("keepalive: zero-data OUT 0x84")
+        if skip_keepalive:
+            print("SKIPPED: --skip-keepalive diagnostic old-build test; "
+                  "keepalive NOT validated", flush=True)
+        else:
+            started = time.monotonic()
+            self.write(0x84, b"")
+            print(f"PASS keepalive ({(time.monotonic() - started) * 1000:.1f} ms)",
+                  flush=True)
+
+        controller = recovered[:16]
+        init_hash = hashlib.sha1(recovered).digest()
+        salt = bytearray(controller[12:] + nonce[12:] + nonce[8:])
+        for iteration in range(3):
+            self.progress(f"verify {iteration + 1}/3: OUT 0x87 → status → response")
+            fresh = bytes(range(0x60 + iteration * 8, 0x68 + iteration * 8))
+            salt[8:] = fresh
+            encrypted = r.crypt(controller, fresh)
+            challenge = r.packet(encrypted + r.mac(init_hash, encrypted, salt))
+            reply = self.exchange(0x87, challenge, 22)
+            encrypted = r.crypt(session1, r.acr(console, identity, fresh))
+            require(reply == r.packet(encrypted + r.mac(session2, encrypted, salt)),
+                    "verify response differs from independent OpenSSL/Python oracle")
+            print(f"PASS verify {iteration + 1}/3", flush=True)
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Manual PC USB authentication check for an attached OpenPuck "
+        "045e:028e, not a genuine controller. --run invalidates any active "
+        "authentication session; unplug/replug afterward, even on failure.",
+        epilog="Requires PyUSB, a libusb backend, USB permissions, and OpenSSL. "
+        "Never detaches drivers, sets configuration, resets or flashes hardware. "
+        "Descriptor/string gates reduce accidental targeting but cannot prove "
+        "device provenance: connect only the intended OpenPuck.")
+    parser.add_argument("--run", action="store_true",
+                        help="explicitly permit device access and authentication")
+    parser.add_argument("--bus", type=positive_int, help="USB bus (decimal)")
+    parser.add_argument("--address", type=positive_int, help="USB address (decimal)")
+    parser.add_argument("--skip-keepalive", action="store_true",
+                        help="DIAGNOSTIC old-build test only: skip zero-data OUT "
+                        "0x84; does not validate the keepalive fix")
+    args = parser.parse_args()
+    if not args.run:
+        print("No USB access performed. Use --run explicitly; see --help.")
+        return 1
+
+    probe = Probe()
+    try:
+        try:
+            import usb.core
+            import usb.util
+        except ImportError:
+            raise RuntimeError("PyUSB unavailable; install the pyusb package "
+                               "in your Python environment") from None
+        require(shutil.which("openssl") is not None, "OpenSSL executable missing")
+        reference = load_reference()
+        probe.progress("select exactly one 045e:028e (optional bus/address filter)")
+        devices = list(usb.core.find(
+            find_all=True, idVendor=0x045E, idProduct=0x028E))
+        devices = [dev for dev in devices
+                   if (args.bus is None or dev.bus == args.bus)
+                   and (args.address is None or dev.address == args.address)]
+        require(len(devices) == 1,
+                f"found {len(devices)} matching devices; require exactly one "
+                "(use --bus/--address to disambiguate)")
+        probe.device = devices[0]
+        probe.progress("read-only descriptor/string gates")
+        try:
+            serial = probe.gate()
+            print("WARNING: authentication invalidates the active session. "
+                  "Unplug/replug afterward, including on failure.", flush=True)
+            probe.authenticate(reference, serial, args.skip_keepalive)
+        finally:
+            usb.util.dispose_resources(probe.device)
+        print("PASS PC USB synthetic authentication" +
+              (" (DIAGNOSTIC: keepalive skipped)" if args.skip_keepalive else "") +
+              "; console compatibility remains unverified. Unplug/replug now.")
+        return 0
+    except KeyboardInterrupt:
+        print(f"ERROR [{probe.stage}]: interrupted; unplug/replug afterward.",
+              file=sys.stderr)
+        return 1
+    except Exception as error:
+        # CalledProcessError includes the OpenSSL command/key; don't log it.
+        detail = ("OpenSSL reference operation failed"
+                  if isinstance(error, subprocess.CalledProcessError)
+                  else str(error))
+        print(f"ERROR [{probe.stage}]: {detail}; unplug/replug if authentication "
+              "started.", file=sys.stderr)
+        return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 86 - 0
tools/test-xinput.py

@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+"""Build and run native XInput regression tests without the Arduino SDK."""
+
+import argparse
+import json
+import os
+from pathlib import Path
+import shlex
+import subprocess
+import tempfile
+
+
+ROOT = Path(__file__).resolve().parents[1]
+TESTS = ROOT / "tests" / "xinput"
+
+
+def run(command):
+    print("+", shlex.join(str(arg) for arg in command), flush=True)
+    subprocess.run(command, check=True)
+
+
+def fixture_header(destination):
+    data = json.loads((TESTS / "fixtures.json").read_text())
+    rows = []
+    for console in data["consoles"]:
+        fields = [json.dumps(console[key]) for key in
+                  ("console", "nonce", "identification", "init", "init_response")]
+        for key in ("verify", "verify_response"):
+            fields.append("{" + ",".join(map(json.dumps, console[key])) + "}")
+        rows.append("{" + ",".join(fields) + "}")
+    text = "static const Fixture fixtures[] = {" + ",\n".join(rows) + "};\n"
+    for key in ("mac_wrap", "salt_wrap"):
+        text += f"static const char {key}[] = {json.dumps(data[key])};\n"
+    destination.write_text(text)
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--asan", action="store_true",
+                        help="also enable AddressSanitizer (requires host runtime)")
+    parser.add_argument("--check-fixtures", action="store_true",
+                        help="regenerate/compare independent fixtures; needs OpenSSL")
+    args = parser.parse_args()
+    if args.check_fixtures:
+        actual = subprocess.check_output(
+            [os.sys.executable, str(TESTS / "reference.py")], text=True)
+        if json.loads(actual) != json.loads((TESTS / "fixtures.json").read_text()):
+            raise SystemExit("Independent fixture regeneration differs")
+        print("PASS independent OpenSSL/Python fixture regeneration", flush=True)
+
+    cc = shlex.split(os.environ.get("CC", "cc"))
+    cxx = shlex.split(os.environ.get("CXX", "c++"))
+    flags = ["-O1", "-g", "-Wall", "-Wextra", "-Werror",
+             "-fno-omit-frame-pointer", "-fsanitize=undefined",
+             "-fsanitize-undefined-trap-on-error"]
+    if args.asan:
+        flags.append("-fsanitize=address")
+    with tempfile.TemporaryDirectory(prefix="openpuck-xinput-") as name:
+        build = Path(name)
+        fixture_header(build / "fixtures.h")
+        includes = ["-I" + str(TESTS), "-I" + str(ROOT / "OpenPuck"),
+                    "-I" + str(build)]
+        sources = [ROOT / "OpenPuck" / "src" / "libxsm3" / name for name in
+                   ("excrypt_des.c", "excrypt_sha.c", "excrypt_parve.c",
+                    "usbdsec.c", "xsm3.c")]
+        sources += [ROOT / "OpenPuck" / name for name in
+                ("xinput_auth.cpp", "xinput_usb.cpp", "xinput_strings.cpp",
+                 "mode_xinput.cpp")]
+        sources += [TESTS / "test.cpp", TESTS / "mode_test.cpp"]
+        objects = []
+        for source in sources:
+            obj = build / (source.name + ".o")
+            compiler = cc if source.suffix == ".c" else cxx
+            standard = "-std=c11" if source.suffix == ".c" else "-std=c++17"
+            run(compiler + [standard] + flags + includes +
+                ["-c", str(source), "-o", str(obj)])
+            objects.append(str(obj))
+        executable = build / "test-xinput"
+        run(cxx + flags + objects + ["-o", str(executable)])
+        run([str(executable)])
+    print("PASS native XInput tests (UBSan trap" +
+          (" + ASan" if args.asan else "") + ")", flush=True)
+
+
+if __name__ == "__main__":
+    main()