Browse Source

Add Original Xbox (Controller S) mode (#212)

* Centralize custom USB class-driver registration

TinyUSB collects application class drivers through a single
usbd_app_driver_get_cb, so only one translation unit can define it.
mode_xinput.cpp owned that definition, leaving no room for a second
mode to register a class driver of its own without a duplicate-symbol
link error.

Move the callback into a small registry, usb_app_drivers.cpp, which
builds the driver array. mode_xinput.cpp now publishes its driver
through xinputClassDriver() instead.

The registered set is unchanged -- one driver, count still 1 -- so
this is a refactor with no behaviour change.

* Add Original Xbox (Controller S) mode

New mode_xbox_og.{cpp,h} presents the puck as a Microsoft Controller S.
It keeps the wake mouse and the WebUSB panel, so the panel stays
reachable on a PC while the mode is active. A real console enumerates
it, with buttons, sticks and rumble working in game.

Before it accepts a controller, the console makes three XID requests
over the control endpoint. Unanswered, they stall and the console
refuses the controller. Those are vendor-type control requests, and
TinyUSB routes every one to a single global callback -- one the Adafruit
core already owns for WebUSB, and that a sketch can replace but not
extend. Linking with -Wl,--wrap=tud_vendor_control_xfer_cb gives this
mode first refusal and leaves everything else to the core router. The
flag is on both build recipes and the direct arduino-cli line in the
docs.

* Add Original Xbox mode to the config panel and README

Expose Original Xbox as a mode button in the WebUSB panel, route it
through the existing Xbox mapping tab, and describe it in the
mode-switching note. Add the corresponding row to README's mode table.

* Add Original Xbox mode subsection in PROTOCOL.md

Covers identity and composite, the XID interface and its endpoints, the
three 0xC1 control requests the console requires, and the EP0 report
path.

---------

Co-authored-by: Jari <safijari@isu.edu>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nicola 2 tháng trước cách đây
mục cha
commit
308565d108

+ 8 - 2
Makefile

@@ -41,6 +41,11 @@ CFG_TUD_TASK_QUEUE_SZ ?= 512
 # too small and would silently drop every frame (blank panel / stale mappings).
 CFG_TUD_VENDOR_TX_BUFSIZE ?= 256
 EXTRA_FLAGS ?=
+# TinyUSB dispatches every vendor-type control request through one global callback, which the Adafruit core
+# already defines to serve WebUSB. Original Xbox mode needs the XID requests that arrive on it.
+# 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_.
+OPENPUCK_LINK_FLAGS ?= -Wl,--wrap=tud_vendor_control_xfer_cb
 # {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) $(EXTRA_FLAGS)
 # When BUILD_PATH is set, --clean + path flags are injected; omitted for fast incremental dev builds.
@@ -68,7 +73,7 @@ RP_UPLOAD = arduino-cli upload -b $(FQBN) -p "$(FLASH_PORT)" ReversePuckFirmware
 ## Compile the firmware with the required USB flags baked in. Override CFG_TUD_HID / CFG_TUD_TASK_QUEUE_SZ /
 ## EXTRA_FLAGS / FQBN as make variables if needed.
 build:
-	arduino-cli compile -b $(FQBN) $(_PATH_FLAGS) --build-property "build.extra_flags=$(USB_EXTRA_FLAGS)" OpenPuck
+	arduino-cli compile -b $(FQBN) $(_PATH_FLAGS) --build-property "build.extra_flags=$(USB_EXTRA_FLAGS)" --build-property "compiler.c.elf.extra_flags=$(OPENPUCK_LINK_FLAGS)" OpenPuck
 
 ## Build for the Raytac MDBT50Q-CX-40 without replacing its Open DFU bootloader.
 ## Like `build`, this does NOT run gen_version.sh -- run it yourself first if you want version provenance
@@ -79,7 +84,8 @@ build-raytac:
 	arduino-cli compile --clean -b adafruit:nrf52:mdbt50qrx \
 		--build-path build/cache/raytac \
 		--output-dir build/raytac \
-		--build-property "build.extra_flags=$(USB_EXTRA_FLAGS) -DOPK_BOARD_MDBT50Q_CX_40=1" OpenPuck
+		--build-property "build.extra_flags=$(USB_EXTRA_FLAGS) -DOPK_BOARD_MDBT50Q_CX_40=1" \
+		--build-property "compiler.c.elf.extra_flags=$(OPENPUCK_LINK_FLAGS)" OpenPuck
 
 ## Package an existing Raytac build for its factory Nordic Open DFU bootloader.
 package-raytac:

+ 15 - 14
OpenPuck/OpenPuck.ino

@@ -65,11 +65,11 @@ using namespace Adafruit_LittleFS_Namespace;
 // puck composite (4 HID + WebUSB) exceeds the default 256 B config buffer
 static uint8_t g_usbCfgDesc[512];
 
-// Per-mode USB serial suffix (modes 1..9: X=xbox N=hori L=lizard P=swpro S=ps5 G=hidgyro Q=ps5game D=ds4game 3=ps3).
+// Per-mode USB serial suffix (modes 1..MODE_MAX: X=xbox N=hori L=lizard P=swpro S=ps5 G=hidgyro Q=ps5game
+// D=ds4game 3=ps3 O=original-xbox).
 // 'C' is reserved for the CDC mode, see puck_hid.cpp
-static const char MODE_SUFFIX[] = {
-	'X', 'N', 'L', 'P', 'S', 'G', 'Q', 'D', '3'
-};
+static const char MODE_SUFFIX[] = { 'X', 'N', 'L', 'P', 'S',
+				    'G', 'Q', 'D', '3', 'O' };
 // Fixed-interface flags captured at boot so usbReenumerate (dynamic mount, no reboot) replays them.
 static bool s_dynWantWebusb = false, s_dynWantWakeMouse = false;
 
@@ -85,11 +85,11 @@ void usbReenumerate(uint8_t k)
 	USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);
 	g_active->usbIdentity(); // clearConfiguration reset VID/PID/strings -- restore them
 	// serial carries the mounted count so the host invalidates its cached config descriptor on a change
-	snprintf(
-		g_usbSerial, sizeof g_usbSerial, "%s%c%u", g_unit,
-		MODE_SUFFIX[(g_usbMode >= 1 && g_usbMode <= 9) ? g_usbMode - 1 :
-								 0],
-		(unsigned)k);
+	snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c%u", g_unit,
+		 MODE_SUFFIX[(g_usbMode >= 1 && g_usbMode <= MODE_MAX) ?
+				     g_usbMode - 1 :
+				     0],
+		 (unsigned)k);
 	USBDevice.setSerialDescriptor(g_usbSerial);
 	if (s_dynWantWakeMouse)
 		wakeHidAddInterface(); // HID instance 0
@@ -270,11 +270,12 @@ void setup()
 	webusbInit(); // also drain the WebUSB status blob from the usbd task (its flush() can block loop() too)
 	hapticInit();
 	static const char *MODE_NAME[] = {
-		"STEAM(puck)",	       "XBOX(xinput+mouse)",
-		"SWITCH(horipad)",     "LIZARD(puck kb/mouse)",
-		"SWITCH(pro+gyro)",    "PS5(dualsense)",
-		"HIDGYRO(ds4+motion)", "PS5(dualsense,game/clean)",
-		"DS4(ds4,game/clean)", "PS3(dualshock3/sixaxis)"
+		"STEAM(puck)",		"XBOX(xinput+mouse)",
+		"SWITCH(horipad)",	"LIZARD(puck kb/mouse)",
+		"SWITCH(pro+gyro)",	"PS5(dualsense)",
+		"HIDGYRO(ds4+motion)",	"PS5(dualsense,game/clean)",
+		"DS4(ds4,game/clean)",	"PS3(dualshock3/sixaxis)",
+		"XBOX-OG(controller s)"
 	};
 	Serial.printf("# copycat up: unit=%s board=%s, mode=%s\n", g_unit,
 		      g_board,

+ 5 - 1
OpenPuck/config.h

@@ -41,7 +41,9 @@
 // console wants a bare Sixaxis HID, not a composite). Answers the PS3's GET_REPORT(0xF2/0xF5/0xEF/0x01)
 // enable handshake. + gyro/accel + rumble.
 #define MODE_PS3 9
-#define MODE_MAX 9
+// Microsoft Original Xbox Controller S (045E:0289)
+#define MODE_XBOX_OG 10
+#define MODE_MAX 10
 
 // The two "game" personalities drop the wake-mouse + WebUSB interfaces so the device is a genuine single-HID PS
 // controller (some PC games -- e.g. Fortnite/UE GameInput -- refuse PS classification when extra interfaces are
@@ -74,6 +76,8 @@ static inline uint8_t etypeForMode(uint8_t m)
 {
 	switch (m) {
 	case MODE_XBOX:
+	// OG Xbox Controller S shares the Xbox layout / button config minus guide button
+	case MODE_XBOX_OG:
 		return ET_XBOX;
 	case MODE_SW_HORI:
 	case MODE_SW_PRO:

+ 3 - 0
OpenPuck/controllers.cpp

@@ -7,6 +7,7 @@
 #include "mode_ps5.h"
 #include "mode_hidgyro.h"
 #include "mode_ps3.h"
+#include "mode_xbox_og.h"
 
 IController *g_active = nullptr;
 
@@ -21,6 +22,8 @@ IController *controllerFor(uint8_t mode)
 		return &g_steamPuck;
 	case MODE_XBOX:
 		return &g_xboxCtl;
+	case MODE_XBOX_OG:
+		return &g_xboxOgCtl;
 	case MODE_SW_HORI:
 		return &g_switchHori;
 	case MODE_SW_PRO:

+ 804 - 0
OpenPuck/mode_xbox_og.cpp

@@ -0,0 +1,804 @@
+#include "mode_xbox_og.h"
+#include "triton.h"
+#include "gamepad_util.h"
+#include "config.h"
+#include "haptics.h"
+#include "bonds.h"
+#include "usb_tx.h"
+#include "usb_app_drivers.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+#define XBOX_OG_DESC_LEN (9 + 7 + 7)
+#define XBOX_OG_PACKET_SIZE 32
+#define XBOX_OG_CONN_MS 1200u
+
+enum {
+	XBOX_OG_DUP = 0x01,
+	XBOX_OG_DDOWN = 0x02,
+	XBOX_OG_DLEFT = 0x04,
+	XBOX_OG_DRIGHT = 0x08,
+	XBOX_OG_START = 0x10,
+	XBOX_OG_BACK = 0x20,
+	XBOX_OG_L3 = 0x40,
+	XBOX_OG_R3 = 0x80,
+};
+
+#pragma pack(push, 1)
+struct XboxOgInputReport {
+	uint8_t reserved;
+	uint8_t length;
+	uint8_t buttons;
+	uint8_t reserved2;
+	uint8_t a;
+	uint8_t b;
+	uint8_t x;
+	uint8_t y;
+	uint8_t black;
+	uint8_t white;
+	uint8_t left_trigger;
+	uint8_t right_trigger;
+	int16_t left_x;
+	int16_t left_y;
+	int16_t right_x;
+	int16_t right_y;
+};
+
+struct XboxOgOutputReport {
+	uint8_t reserved;
+	uint8_t length;
+	uint16_t left_motor;
+	uint16_t right_motor;
+};
+#pragma pack(pop)
+
+static const uint8_t XID_DESCRIPTOR[] = {
+	0x10, 0x42, 0x00, 0x01, 0x01, 0x02, 0x14, 0x06,
+	0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+static const uint8_t XID_CAPABILITIES_IN[] = {
+	0x00, 0x14, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+	0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+static const uint8_t XID_CAPABILITIES_OUT[] = {
+	0x00, 0x06, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+
+static_assert(sizeof(XboxOgInputReport) == 20,
+	      "Original Xbox input report must be 20 bytes");
+static_assert(sizeof(XboxOgOutputReport) == 6,
+	      "Original Xbox output report must be 6 bytes");
+static_assert(sizeof(XID_DESCRIPTOR) == 16, "XID descriptor must be 16 bytes");
+static_assert(sizeof(XID_CAPABILITIES_IN) == 20,
+	      "XID input capabilities must be 20 bytes");
+static_assert(sizeof(XID_CAPABILITIES_OUT) == 6,
+	      "XID output capabilities must be 6 bytes");
+
+struct XboxOgSlot {
+	uint8_t interface_number;
+	uint8_t endpoint_in;
+	uint8_t endpoint_out;
+	// The loop owns bond selection and is its only reader -- it snapshots
+	// bond_slot when relaying rumble; lifecycle callbacks only clear it.
+	volatile int8_t bond_slot;
+	// TinyUSB callbacks publish endpoint readiness from the usbd task; the
+	// loop owns pending input state.
+	volatile bool in_use;
+	volatile bool tx_pending;
+	// TinyUSB ignores xfer_cb's return value, so the loop drain must remember
+	// and recover a failed runtime OUT rearm.
+	volatile bool output_rearm_pending;
+
+	// The loop publishes complete reports under PRIMASK; USB only snapshots it.
+	XboxOgInputReport latest_report;
+	// These four are owned by the USB peripheral, not by a lock, so the
+	// rules below hold only while the endpoints are live -- bus reset and
+	// deinit clear them wholesale. See xboxOgClearSlot().
+	// EP0 IN owns this snapshot until the control transfer completes.
+	uint8_t control_in_buffer[sizeof(XboxOgInputReport)];
+	// The loop may replace this only while the interrupt IN endpoint is idle.
+	CFG_TUD_MEM_ALIGN uint8_t tx_buffer[XBOX_OG_PACKET_SIZE];
+	// Interrupt OUT DMA owns this buffer between arm and completion.
+	CFG_TUD_MEM_ALIGN uint8_t output_buffer[XBOX_OG_PACKET_SIZE];
+	// EP0 OUT owns this buffer from SET_REPORT setup through its data stage.
+	uint8_t control_out_buffer[sizeof(XboxOgOutputReport)];
+
+	// USB output callbacks publish this mailbox; the loop consumes it and
+	// relays to whichever slot is currently bonded when it does.
+	volatile uint16_t rumble_low;
+	volatile uint16_t rumble_high;
+	volatile bool rumble_pending;
+};
+
+static XboxOgSlot g_xboxOgSlot;
+static volatile uint32_t g_xboxOgGeneration;
+static unsigned long g_xboxOgNeutralMs;
+
+static void xboxOgNeutralReport(XboxOgInputReport &report)
+{
+	memset(&report, 0, sizeof report);
+	report.length = sizeof report;
+}
+
+// Wholesale clear, DMA buffers included. Every caller runs at a point where
+// no transfer is armed against them: mode setup runs before attach, init /
+// deinit / bus reset arrive after the peripheral has dropped the endpoints,
+// and xboxOgOpen clears before it arms the OUT endpoint (or after closing
+// both on the failure path).
+static void xboxOgClearSlot(void)
+{
+	uint32_t primask = __get_PRIMASK();
+
+	__disable_irq();
+	g_xboxOgGeneration++;
+	memset(&g_xboxOgSlot, 0, sizeof g_xboxOgSlot);
+	g_xboxOgSlot.bond_slot = -1;
+	xboxOgNeutralReport(g_xboxOgSlot.latest_report);
+	__set_PRIMASK(primask);
+}
+
+static void xboxOgSnapshotReport(uint8_t *destination)
+{
+	uint32_t primask = __get_PRIMASK();
+
+	__disable_irq();
+	memcpy(destination, &g_xboxOgSlot.latest_report,
+	       sizeof g_xboxOgSlot.latest_report);
+	__set_PRIMASK(primask);
+}
+
+static void xboxOgPublishReport(const XboxOgInputReport &report)
+{
+	uint32_t primask = __get_PRIMASK();
+
+	__disable_irq();
+	memcpy(&g_xboxOgSlot.latest_report, &report, sizeof report);
+	g_xboxOgSlot.tx_pending = true;
+	__set_PRIMASK(primask);
+}
+
+static bool xboxOgPublishBondReport(const XboxOgInputReport &report, int slot,
+				    uint32_t generation)
+{
+	uint32_t primask = __get_PRIMASK();
+	bool published = false;
+
+	__disable_irq();
+	if (g_xboxOgGeneration == generation &&
+	    g_xboxOgSlot.bond_slot == slot) {
+		memcpy(&g_xboxOgSlot.latest_report, &report, sizeof report);
+		g_xboxOgSlot.tx_pending = true;
+		published = true;
+	}
+	__set_PRIMASK(primask);
+	return published;
+}
+
+static bool xboxOgApplyRumble(XboxOgSlot &slot, const uint8_t *data,
+			      uint16_t length)
+{
+	if (!data || length < sizeof(XboxOgOutputReport) || data[0] != 0x00 ||
+	    data[1] != sizeof(XboxOgOutputReport))
+		return false;
+
+	uint16_t low = (uint16_t)data[2] | ((uint16_t)data[3] << 8);
+	uint16_t high = (uint16_t)data[4] | ((uint16_t)data[5] << 8);
+	uint32_t primask = __get_PRIMASK();
+
+	__disable_irq();
+	slot.rumble_low = low;
+	slot.rumble_high = high;
+	slot.rumble_pending = true;
+	__set_PRIMASK(primask);
+	return true;
+}
+
+static void xboxOgRetryOutputRearm(void)
+{
+	XboxOgSlot &slot = g_xboxOgSlot;
+	uint32_t generation = g_xboxOgGeneration;
+	uint8_t endpoint = slot.endpoint_out;
+
+	if (!tud_mounted() || !slot.in_use || !slot.output_rearm_pending ||
+	    endpoint == 0 || usbd_edpt_busy(0, endpoint) ||
+	    !usbd_edpt_claim(0, endpoint))
+		return;
+	if (usbd_edpt_xfer(0, endpoint, slot.output_buffer,
+			   sizeof slot.output_buffer)) {
+		uint32_t primask = __get_PRIMASK();
+
+		__disable_irq();
+		if (g_xboxOgGeneration == generation && slot.in_use &&
+		    slot.endpoint_out == endpoint)
+			slot.output_rearm_pending = false;
+		__set_PRIMASK(primask);
+	} else {
+		usbd_edpt_release(0, endpoint);
+	}
+}
+
+static void xboxOgTxDrain(void)
+{
+	XboxOgSlot &slot = g_xboxOgSlot;
+
+	xboxOgRetryOutputRearm();
+
+	if (!tud_mounted() || !slot.in_use || !slot.tx_pending ||
+	    slot.endpoint_in == 0 || usbd_edpt_busy(0, slot.endpoint_in))
+		return;
+	if (!usbd_edpt_claim(0, slot.endpoint_in))
+		return;
+
+	xboxOgSnapshotReport(slot.tx_buffer);
+	if (usbd_edpt_xfer(0, slot.endpoint_in, slot.tx_buffer,
+			   sizeof(XboxOgInputReport))) {
+		slot.tx_pending = false;
+	} else {
+		usbd_edpt_release(0, slot.endpoint_in);
+	}
+}
+
+// The console asks for these three before it accepts the controller: 0x06
+// with wValue 0x4200 is the XID descriptor, 0x01 with 0x0100/0x0200 the
+// input and output capabilities. Stalling them makes it refuse the pad.
+static bool xboxOgVendorRequest(const tusb_control_request_t *request)
+{
+	if (!g_xboxOgSlot.in_use || request->bmRequestType != 0xC1 ||
+	    request->wIndex != g_xboxOgSlot.interface_number)
+		return false;
+
+	if (request->bRequest == 0x06 && request->wValue == 0x4200)
+		return true;
+	if (request->bRequest != 0x01)
+		return false;
+	return request->wValue == 0x0100 || request->wValue == 0x0200;
+}
+
+bool xboxOgVendorControlXfer(uint8_t rhport, uint8_t stage,
+			     const tusb_control_request_t *request)
+{
+	if (!request || !xboxOgVendorRequest(request))
+		return false;
+	if (stage != CONTROL_STAGE_SETUP)
+		return true;
+
+	if (request->bRequest == 0x06)
+		return tud_control_xfer(rhport, request,
+					const_cast<uint8_t *>(XID_DESCRIPTOR),
+					sizeof XID_DESCRIPTOR);
+	if (request->wValue == 0x0100)
+		return tud_control_xfer(
+			rhport, request,
+			const_cast<uint8_t *>(XID_CAPABILITIES_IN),
+			sizeof XID_CAPABILITIES_IN);
+	return tud_control_xfer(rhport, request,
+				const_cast<uint8_t *>(XID_CAPABILITIES_OUT),
+				sizeof XID_CAPABILITIES_OUT);
+}
+
+// Controller S also carries input and rumble over EP0, in parallel with the
+// interrupt endpoints: GET_REPORT (0xA1/0x01, wValue 0x0100) and SET_REPORT
+// (0x21/0x09, wValue 0x0200).
+static bool xboxOgClassControlXfer(uint8_t rhport, uint8_t stage,
+				   const tusb_control_request_t *request)
+{
+	if (!request || !g_xboxOgSlot.in_use ||
+	    request->wIndex != g_xboxOgSlot.interface_number)
+		return false;
+
+	if (request->bmRequestType == 0xA1 && request->bRequest == 0x01 &&
+	    request->wValue == 0x0100) {
+		if (stage == CONTROL_STAGE_SETUP) {
+			xboxOgSnapshotReport(g_xboxOgSlot.control_in_buffer);
+			return tud_control_xfer(
+				rhport, request, g_xboxOgSlot.control_in_buffer,
+				sizeof g_xboxOgSlot.control_in_buffer);
+		}
+		return true;
+	}
+
+	if (request->bmRequestType != 0x21 || request->bRequest != 0x09 ||
+	    request->wValue != 0x0200 ||
+	    request->wLength != sizeof(XboxOgOutputReport))
+		return false;
+
+	if (stage == CONTROL_STAGE_SETUP) {
+		memset(g_xboxOgSlot.control_out_buffer, 0,
+		       sizeof g_xboxOgSlot.control_out_buffer);
+		return tud_control_xfer(rhport, request,
+					g_xboxOgSlot.control_out_buffer,
+					sizeof g_xboxOgSlot.control_out_buffer);
+	}
+	if (stage == CONTROL_STAGE_DATA)
+		return xboxOgApplyRumble(
+			g_xboxOgSlot, g_xboxOgSlot.control_out_buffer,
+			sizeof g_xboxOgSlot.control_out_buffer);
+	return true;
+}
+
+static void xboxOgInit(void)
+{
+	xboxOgClearSlot();
+}
+
+static bool xboxOgDeinit(void)
+{
+	xboxOgClearSlot();
+	return true;
+}
+
+static void xboxOgReset(uint8_t rhport)
+{
+	(void)rhport;
+	xboxOgClearSlot();
+}
+
+static bool xboxOgEndpointValid(const tusb_desc_endpoint_t *endpoint)
+{
+	return endpoint->bLength == sizeof(tusb_desc_endpoint_t) &&
+	       endpoint->bDescriptorType == TUSB_DESC_ENDPOINT &&
+	       endpoint->bmAttributes.xfer == TUSB_XFER_INTERRUPT &&
+	       endpoint->bmAttributes.sync == 0 &&
+	       endpoint->bmAttributes.usage == 0 &&
+	       (endpoint->bEndpointAddress & 0x70) == 0 &&
+	       tu_edpt_number(endpoint->bEndpointAddress) != 0 &&
+	       tu_le16toh(endpoint->wMaxPacketSize) == XBOX_OG_PACKET_SIZE &&
+	       endpoint->bInterval == 4;
+}
+
+static uint16_t xboxOgOpen(uint8_t rhport,
+			   const tusb_desc_interface_t *interface,
+			   uint16_t max_length)
+{
+	if (!interface || g_xboxOgSlot.in_use ||
+	    max_length < sizeof(tusb_desc_interface_t) ||
+	    interface->bLength != sizeof(tusb_desc_interface_t) ||
+	    interface->bDescriptorType != TUSB_DESC_INTERFACE ||
+	    interface->bAlternateSetting != 0 || interface->iInterface != 0 ||
+	    interface->bNumEndpoints != 2 ||
+	    interface->bInterfaceClass != 0x58 ||
+	    interface->bInterfaceSubClass != 0x42 ||
+	    interface->bInterfaceProtocol != 0x00)
+		return 0;
+
+	const uint8_t *start = reinterpret_cast<const uint8_t *>(interface);
+	const uint8_t *cursor = start + interface->bLength;
+	const uint8_t *end = start + max_length;
+	const tusb_desc_endpoint_t *endpoint_in = nullptr;
+	const tusb_desc_endpoint_t *endpoint_out = nullptr;
+	uint8_t endpoint_count = 0;
+
+	while (cursor < end) {
+		uint16_t remaining = (uint16_t)(end - cursor);
+
+		if (remaining < 2 || cursor[0] < 2 || cursor[0] > remaining)
+			return 0;
+		if (cursor[1] == TUSB_DESC_INTERFACE) {
+			if (cursor[0] < sizeof(tusb_desc_interface_t))
+				return 0;
+			break;
+		}
+		if (cursor[1] == TUSB_DESC_INTERFACE_ASSOCIATION) {
+			if (cursor[0] < sizeof(tusb_desc_interface_assoc_t))
+				return 0;
+			break;
+		}
+		if (cursor[1] == TUSB_DESC_ENDPOINT) {
+			if (cursor[0] < sizeof(tusb_desc_endpoint_t))
+				return 0;
+			const tusb_desc_endpoint_t *endpoint =
+				reinterpret_cast<const tusb_desc_endpoint_t *>(
+					cursor);
+
+			if (!xboxOgEndpointValid(endpoint) ||
+			    ++endpoint_count > interface->bNumEndpoints)
+				return 0;
+			if (tu_edpt_dir(endpoint->bEndpointAddress) ==
+			    TUSB_DIR_IN) {
+				if (endpoint_in)
+					return 0;
+				endpoint_in = endpoint;
+			} else {
+				if (endpoint_out)
+					return 0;
+				endpoint_out = endpoint;
+			}
+		}
+		cursor += cursor[0];
+	}
+
+	if (endpoint_count != 2 || !endpoint_in || !endpoint_out)
+		return 0;
+	if (!usbd_edpt_open(rhport, endpoint_in))
+		return 0;
+	if (!usbd_edpt_open(rhport, endpoint_out)) {
+		usbd_edpt_close(rhport, endpoint_in->bEndpointAddress);
+		return 0;
+	}
+
+	xboxOgClearSlot();
+	g_xboxOgSlot.interface_number = interface->bInterfaceNumber;
+	g_xboxOgSlot.endpoint_in = endpoint_in->bEndpointAddress;
+	g_xboxOgSlot.endpoint_out = endpoint_out->bEndpointAddress;
+	g_xboxOgSlot.in_use = true;
+	if (!usbd_edpt_xfer(rhport, g_xboxOgSlot.endpoint_out,
+			    g_xboxOgSlot.output_buffer,
+			    sizeof g_xboxOgSlot.output_buffer)) {
+		usbd_edpt_close(rhport, g_xboxOgSlot.endpoint_in);
+		usbd_edpt_close(rhport, g_xboxOgSlot.endpoint_out);
+		xboxOgClearSlot();
+		return 0;
+	}
+
+	return (uint16_t)(cursor - start);
+}
+
+static bool xboxOgXfer(uint8_t rhport, uint8_t endpoint, xfer_result_t result,
+		       uint32_t transferred)
+{
+	if (!g_xboxOgSlot.in_use)
+		return false;
+	if (endpoint == g_xboxOgSlot.endpoint_in)
+		return true;
+	if (endpoint != g_xboxOgSlot.endpoint_out)
+		return false;
+
+	if (result == XFER_RESULT_SUCCESS)
+		xboxOgApplyRumble(g_xboxOgSlot, g_xboxOgSlot.output_buffer,
+				  (uint16_t)transferred);
+	if (!g_xboxOgSlot.in_use)
+		return true;
+	if (!usbd_edpt_xfer(rhport, g_xboxOgSlot.endpoint_out,
+			    g_xboxOgSlot.output_buffer,
+			    sizeof g_xboxOgSlot.output_buffer)) {
+		uint32_t primask = __get_PRIMASK();
+
+		__disable_irq();
+		if (g_xboxOgSlot.in_use &&
+		    endpoint == g_xboxOgSlot.endpoint_out)
+			g_xboxOgSlot.output_rearm_pending = true;
+		__set_PRIMASK(primask);
+	}
+	return true;
+}
+
+static const usbd_class_driver_t g_xboxOgDriver = {
+#if CFG_TUSB_DEBUG >= 2
+	.name = "XID",
+#endif
+	.init = xboxOgInit,
+	.deinit = xboxOgDeinit,
+	.reset = xboxOgReset,
+	.open = xboxOgOpen,
+	.control_xfer_cb = xboxOgClassControlXfer,
+	.xfer_cb = xboxOgXfer,
+	.sof = nullptr,
+};
+
+const usbd_class_driver_t *xboxOgClassDriver(void)
+{
+	return &g_xboxOgDriver;
+}
+
+class Adafruit_USBD_XboxOg : public Adafruit_USBD_Interface {
+    public:
+	uint16_t getInterfaceDescriptor(uint8_t, uint8_t *buffer,
+					uint16_t buffer_size) override
+	{
+		if (!buffer)
+			return XBOX_OG_DESC_LEN;
+		if (buffer_size < XBOX_OG_DESC_LEN)
+			return 0;
+
+		uint8_t interface_number = TinyUSBDevice.allocInterface(1);
+		uint8_t endpoint_in = TinyUSBDevice.allocEndpoint(TUSB_DIR_IN);
+		uint8_t endpoint_out =
+			TinyUSBDevice.allocEndpoint(TUSB_DIR_OUT);
+		const uint8_t descriptor[XBOX_OG_DESC_LEN] = {
+			9,
+			TUSB_DESC_INTERFACE,
+			interface_number,
+			0,
+			2,
+			0x58,
+			0x42,
+			0x00,
+			0,
+			7,
+			TUSB_DESC_ENDPOINT,
+			endpoint_in,
+			TUSB_XFER_INTERRUPT,
+			U16_TO_U8S_LE(XBOX_OG_PACKET_SIZE),
+			4,
+			7,
+			TUSB_DESC_ENDPOINT,
+			endpoint_out,
+			TUSB_XFER_INTERRUPT,
+			U16_TO_U8S_LE(XBOX_OG_PACKET_SIZE),
+			4,
+		};
+
+		memcpy(buffer, descriptor, sizeof descriptor);
+		return sizeof descriptor;
+	}
+
+	bool begin()
+	{
+		return TinyUSBDevice.addInterface(*this);
+	}
+};
+
+static Adafruit_USBD_XboxOg g_xboxOgInterface;
+
+XboxOgController g_xboxOgCtl;
+
+enum XboxOgRemapTarget : uint8_t {
+	XBOX_OG_TARGET_NONE,
+	XBOX_OG_TARGET_A,
+	XBOX_OG_TARGET_B,
+	XBOX_OG_TARGET_X,
+	XBOX_OG_TARGET_Y,
+	XBOX_OG_TARGET_WHITE,
+	XBOX_OG_TARGET_BLACK,
+	XBOX_OG_TARGET_L3,
+	XBOX_OG_TARGET_R3,
+	XBOX_OG_TARGET_BACK,
+	XBOX_OG_TARGET_START,
+	XBOX_OG_TARGET_DUP,
+	XBOX_OG_TARGET_DDOWN,
+	XBOX_OG_TARGET_DLEFT,
+	XBOX_OG_TARGET_DRIGHT,
+};
+
+// Remaps target Controller S outputs independently of the RF button masks.
+// Codes come from the shared remap code space: 5/6 (LB/RB) drive White/Black,
+// 9/10 are semantic Back/Start, and 11 (Guide) has no Controller S target.
+static constexpr XboxOgRemapTarget XBOX_OG_REMAP[] = {
+	XBOX_OG_TARGET_NONE,   XBOX_OG_TARGET_A,     XBOX_OG_TARGET_B,
+	XBOX_OG_TARGET_X,      XBOX_OG_TARGET_Y,     XBOX_OG_TARGET_WHITE,
+	XBOX_OG_TARGET_BLACK,  XBOX_OG_TARGET_L3,    XBOX_OG_TARGET_R3,
+	XBOX_OG_TARGET_BACK,   XBOX_OG_TARGET_START, XBOX_OG_TARGET_NONE,
+	XBOX_OG_TARGET_DUP,    XBOX_OG_TARGET_DDOWN, XBOX_OG_TARGET_DLEFT,
+	XBOX_OG_TARGET_DRIGHT,
+};
+
+static_assert(sizeof XBOX_OG_REMAP / sizeof XBOX_OG_REMAP[0] == 16,
+	      "Xbox remap table must cover codes 0 through 15");
+
+static void xboxOgApplyRemap(XboxOgInputReport &report, uint8_t code)
+{
+	uint32_t mapped = tritonFromCode(code);
+
+	// LT/RT are codes 19/20, outside the table -- these two must stay above
+	// the bounds check or a paddle mapped to a trigger does nothing.
+	if (mapped & TB_L2)
+		report.left_trigger = 0xFF;
+	if (mapped & TB_R2)
+		report.right_trigger = 0xFF;
+	if (code >= sizeof XBOX_OG_REMAP / sizeof XBOX_OG_REMAP[0])
+		return;
+
+	switch (XBOX_OG_REMAP[code]) {
+	case XBOX_OG_TARGET_A:
+		report.a = 0xFF;
+		break;
+	case XBOX_OG_TARGET_B:
+		report.b = 0xFF;
+		break;
+	case XBOX_OG_TARGET_X:
+		report.x = 0xFF;
+		break;
+	case XBOX_OG_TARGET_Y:
+		report.y = 0xFF;
+		break;
+	case XBOX_OG_TARGET_WHITE:
+		report.white = 0xFF;
+		break;
+	case XBOX_OG_TARGET_BLACK:
+		report.black = 0xFF;
+		break;
+	case XBOX_OG_TARGET_L3:
+		report.buttons |= XBOX_OG_L3;
+		break;
+	case XBOX_OG_TARGET_R3:
+		report.buttons |= XBOX_OG_R3;
+		break;
+	case XBOX_OG_TARGET_BACK:
+		report.buttons |= XBOX_OG_BACK;
+		break;
+	case XBOX_OG_TARGET_START:
+		report.buttons |= XBOX_OG_START;
+		break;
+	case XBOX_OG_TARGET_DUP:
+		report.buttons |= XBOX_OG_DUP;
+		break;
+	case XBOX_OG_TARGET_DDOWN:
+		report.buttons |= XBOX_OG_DDOWN;
+		break;
+	case XBOX_OG_TARGET_DLEFT:
+		report.buttons |= XBOX_OG_DLEFT;
+		break;
+	case XBOX_OG_TARGET_DRIGHT:
+		report.buttons |= XBOX_OG_DRIGHT;
+		break;
+	case XBOX_OG_TARGET_NONE:
+	default:
+		break;
+	}
+}
+
+static void xboxOgBuildReport(XboxOgInputReport &report, const uint8_t *raw)
+{
+	uint32_t buttons = btnsOf(raw);
+
+	xboxOgNeutralReport(report);
+	if (buttons & TB_DUP)
+		report.buttons |= XBOX_OG_DUP;
+	if (buttons & TB_DDN)
+		report.buttons |= XBOX_OG_DDOWN;
+	if (buttons & TB_DLF)
+		report.buttons |= XBOX_OG_DLEFT;
+	if (buttons & TB_DRT)
+		report.buttons |= XBOX_OG_DRIGHT;
+	// The bit names are the reverse of the physical labels: the left
+	// button (View) arrives as TB_MENU and the right one as TB_VIEW.
+	// Back is the left button on an Original Xbox, so this is positional.
+	if (buttons & TB_VIEW)
+		report.buttons |= XBOX_OG_START;
+	if (buttons & TB_MENU)
+		report.buttons |= XBOX_OG_BACK;
+	if (buttons & TB_L3)
+		report.buttons |= XBOX_OG_L3;
+	if (buttons & TB_R3)
+		report.buttons |= XBOX_OG_R3;
+
+	// A/B/X/Y and White/Black are analog pressure bytes, so a digital press
+	// writes full scale; the shoulders have no button bit at all.
+	if (buttons & TB_A)
+		(g_abSwap ? report.b : report.a) = 0xFF;
+	if (buttons & TB_B)
+		(g_abSwap ? report.a : report.b) = 0xFF;
+	if (buttons & TB_X)
+		(g_abSwap ? report.y : report.x) = 0xFF;
+	if (buttons & TB_Y)
+		(g_abSwap ? report.x : report.y) = 0xFF;
+	if (buttons & TB_LB)
+		report.white = 0xFF;
+	if (buttons & TB_RB)
+		report.black = 0xFF;
+
+	report.left_trigger = trigU8(u16off(raw, 4));
+	report.right_trigger = trigU8(u16off(raw, 6));
+	if (buttons & TB_L2)
+		report.left_trigger = 0xFF;
+	if (buttons & TB_R2)
+		report.right_trigger = 0xFF;
+
+	const uint32_t paddle_buttons[] = { TB_L4, TB_R4, TB_L5, TB_R5 };
+
+	for (int i = 0; i < 4; i++)
+		if (buttons & paddle_buttons[i])
+			xboxOgApplyRemap(report, g_back[i]);
+	if ((buttons & TB_QAM) && g_qamMap)
+		xboxOgApplyRemap(report, g_qamMap);
+
+	// Raw-report offsets, not slotSticks(): like mode_xinput this mode decodes 0x45 in place and never
+	// touches g_in. 16/18 = left pad X/Y, 22/24 = right pad X/Y.
+	int16_t lx = (int16_t)s16off(raw, 8), ly = (int16_t)s16off(raw, 10),
+		rx = (int16_t)s16off(raw, 12), ry = (int16_t)s16off(raw, 14);
+	padStickBlend(buttons, (int16_t)s16off(raw, 16),
+		      (int16_t)s16off(raw, 18), (int16_t)s16off(raw, 22),
+		      (int16_t)s16off(raw, 24), &lx, &ly, &rx, &ry);
+	le16(reinterpret_cast<uint8_t *>(&report.left_x), lx);
+	le16(reinterpret_cast<uint8_t *>(&report.left_y), ly);
+	le16(reinterpret_cast<uint8_t *>(&report.right_x), rx);
+	le16(reinterpret_cast<uint8_t *>(&report.right_y), ry);
+}
+
+static bool xboxOgBondRecent(int slot, unsigned long now)
+{
+	return slot >= 0 && slot < NSLOT && g_slot[slot].used &&
+	       g_connReplyMs[slot] != 0 &&
+	       (unsigned long)(now - g_connReplyMs[slot]) < XBOX_OG_CONN_MS;
+}
+
+static void xboxOgUpdateBond(void)
+{
+	XboxOgSlot &state = g_xboxOgSlot;
+	unsigned long now = millis();
+	int current = state.bond_slot;
+
+	if (xboxOgBondRecent(current, now))
+		return;
+
+	if (current >= 0) {
+		XboxOgInputReport neutral;
+
+		state.bond_slot = -1;
+		xboxOgNeutralReport(neutral);
+		xboxOgPublishReport(neutral);
+		g_xboxOgNeutralMs = now;
+	}
+	for (int slot = 0; slot < NSLOT; slot++)
+		if (xboxOgBondRecent(slot, now)) {
+			state.bond_slot = (int8_t)slot;
+			break;
+		}
+}
+
+// Fire-and-forget: no stop is tracked or sent here. The shared relay in
+// haptics.cpp (burst, reconnect scrub, per-slot watchdog) stops the motor.
+static void xboxOgHandleRumble(void)
+{
+	XboxOgSlot &state = g_xboxOgSlot;
+	uint16_t low, high;
+	bool pending;
+	int8_t target;
+	uint32_t primask = __get_PRIMASK();
+
+	__disable_irq();
+	pending = state.rumble_pending;
+	low = state.rumble_low;
+	high = state.rumble_high;
+	target = state.bond_slot;
+	state.rumble_pending = false;
+	__set_PRIMASK(primask);
+
+	if (!pending || target < 0 || !g_rumble)
+		return;
+	hapticSteamRumble(low, high, (uint8_t)target);
+}
+
+void XboxOgController::begin()
+{
+	xboxOgClearSlot();
+	g_xboxOgNeutralMs = 0;
+	usbIdentity();
+	USBDevice.setConfigurationMaxPower(100);
+	g_xboxOgInterface.begin();
+	usbTxRegisterDrain(xboxOgTxDrain);
+}
+
+void XboxOgController::onReport45(int slot, const uint8_t *rep, bool fresh,
+				  uint8_t bodyTlen)
+{
+	(void)fresh;
+	if (!rep || slot < 0 || slot >= NSLOT || bodyTlen < 18 ||
+	    (rep[0] != 0x45 && rep[0] != 0x42) ||
+	    g_xboxOgSlot.bond_slot != slot)
+		return;
+
+	uint32_t generation = g_xboxOgGeneration;
+	XboxOgInputReport report;
+
+	xboxOgBuildReport(report, rep);
+	xboxOgPublishBondReport(report, slot, generation);
+}
+
+void XboxOgController::task()
+{
+	xboxOgUpdateBond();
+	xboxOgHandleRumble();
+
+	if (g_xboxOgSlot.bond_slot >= 0 ||
+	    (unsigned long)(millis() - g_xboxOgNeutralMs) < USB_STREAM_MS)
+		return;
+
+	XboxOgInputReport neutral;
+
+	g_xboxOgNeutralMs = millis();
+	xboxOgNeutralReport(neutral);
+	xboxOgPublishReport(neutral);
+}
+
+void XboxOgController::usbIdentity()
+{
+	USBDevice.setID(0x045E, 0x0289);
+	USBDevice.setDeviceVersion(0x0121);
+	// A real Controller S sends no string descriptors (iManufacturer /
+	// iProduct / iSerial = 0); hosts name it from usb.ids. TinyUSB pins
+	// those indices non-zero, so mirror what usb.ids shows for 045E:0289.
+	USBDevice.setManufacturerDescriptor("Microsoft Corp.");
+	USBDevice.setProductDescriptor("Xbox Controller S");
+}

+ 15 - 0
OpenPuck/mode_xbox_og.h

@@ -0,0 +1,15 @@
+// mode_xbox_og.h -- Original Xbox Controller S XID personality.
+#pragma once
+
+#include "controllers.h"
+
+class XboxOgController : public IController {
+    public:
+	void begin() override;
+	void onReport45(int slot, const uint8_t *rep, bool fresh,
+			uint8_t bodyTlen) override;
+	void task() override;
+	void usbIdentity() override;
+};
+
+extern XboxOgController g_xboxOgCtl;

+ 2 - 6
OpenPuck/mode_xinput.cpp

@@ -17,13 +17,10 @@
 #include "bonds.h"
 #include "usb_mount.h"
 #include "usb_tx.h"
+#include "usb_app_drivers.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
-// custom class-driver API for the XInput interface
-extern "C" {
-#include "device/usbd_pvt.h"
-}
 
 XboxController g_xboxCtl;
 
@@ -193,9 +190,8 @@ static const usbd_class_driver_t g_xiDriver = {
 	.xfer_cb = xi_xfer,
 	.sof = NULL
 };
-extern "C" const usbd_class_driver_t *usbd_app_driver_get_cb(uint8_t *count)
+const usbd_class_driver_t *xinputClassDriver(void)
 {
-	*count = 1;
 	return &g_xiDriver;
 }
 

+ 1 - 1
OpenPuck/serial_console.cpp

@@ -154,7 +154,7 @@ void serialConsolePoll()
 					g_lizKeep ? "ON" : "off");
 			}
 
-			// switch USB mode: 0=steam 1=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro 7=ps5-game/clean 8=ds4-game/clean 9=ps3(dualshock3)
+			// switch USB mode: 0=steam 1=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro 7=ps5-game/clean 8=ds4-game/clean 9=ps3(dualshock3) 10=original xbox
 			else if (line[0] == 'x') {
 				uint8_t m = strtoul(line + 1, 0, 10);
 				if (modeValid(m)) {

+ 12 - 0
OpenPuck/usb_app_drivers.cpp

@@ -0,0 +1,12 @@
+#include "usb_app_drivers.h"
+
+extern "C" const usbd_class_driver_t *usbd_app_driver_get_cb(uint8_t *count)
+{
+	static const usbd_class_driver_t drivers[] = {
+		*xinputClassDriver(),
+		*xboxOgClassDriver(),
+	};
+
+	*count = sizeof(drivers) / sizeof(drivers[0]);
+	return drivers;
+}

+ 9 - 0
OpenPuck/usb_app_drivers.h

@@ -0,0 +1,9 @@
+// usb_app_drivers.h -- internal registry for custom TinyUSB class drivers.
+#pragma once
+
+extern "C" {
+#include "device/usbd_pvt.h"
+}
+
+const usbd_class_driver_t *xinputClassDriver(void);
+const usbd_class_driver_t *xboxOgClassDriver(void);

+ 1 - 0
OpenPuck/usb_tx.h

@@ -46,6 +46,7 @@ void usbTxUnboost(void);
 // Register a callback to run every USB frame ON THE usbd TASK (from tud_sof_cb, right after usbTxDrain()).
 // This is how senders that DON'T go through Adafruit_USBD_HID get their transmits off the loop task too:
 //   - mode_xinput flushes its raw custom-class IN endpoints;
+//   - mode_xbox_og flushes its raw custom-class IN endpoint;
 //   - webusb_config sends the status blob (its usb_web.flush() can otherwise block the loop on a full queue).
 // Call once at setup() (before usbTxBegin()). Safe no-op if usb isn't up yet. Capacity is small + fixed.
 typedef void (*usbTxDrainFn)(void);

+ 20 - 0
OpenPuck/webusb_config.cpp

@@ -17,6 +17,26 @@
 
 Adafruit_USBD_WebUSB usb_web;
 
+// Vendor-type control requests and the vendor interface's class requests both land on one global TinyUSB
+// callback, which the Adafruit core defines for WebUSB. Linker wrapping (-Wl,--wrap, set in the Makefile)
+// gives MODE_XBOX_OG first refusal on the XID requests without copying that router; whatever it declines, and
+// every other mode, must still reach __real_ -- that answers WebUSB's 0x22, whose connected flag gates
+// the panel sends.
+bool xboxOgVendorControlXfer(uint8_t rhport, uint8_t stage,
+			     const tusb_control_request_t *request);
+extern "C" bool
+__real_tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage,
+				  const tusb_control_request_t *request);
+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_OG &&
+	    xboxOgVendorControlXfer(rhport, stage, request))
+		return true;
+	return __real_tud_vendor_control_xfer_cb(rhport, stage, request);
+}
+
 // Panel-blob send is deferred to the usbd task: webusbPoll() (loop) just sets this, and webusbSofDrain()
 // (registered with usb_tx, runs from tud_sof_cb on the usbd task) does the actual write+flush. usb_web.flush()
 // goes through the same blocking osd_queue_send path as HID sends, so doing it from loop() could stall the

+ 2 - 1
README.md

@@ -2,7 +2,7 @@
 
 [![BuyMeACoffee](https://raw.githubusercontent.com/pachadotdev/buymeacoffee-badges/main/bmc-yellow.svg)](https://github.com/safijari/openpuck/discussions/211)
 
-OpenPuck is an opensource firmware for NRF52840 Pro Micro that copycats the Steam Controller 2 Puck and allows emulation of Xbox, Switch, and PS3/4/5 controllers and also includes an independant lizard mode (which can work on UAC prompts/task manager/etc). The Switch and PS3 modes have been verified to work on real consoles and Switch, PS4/5 modes have gyro (and touchpad where available) hooked in. Back 4 buttons are mappable for all emulated modes.
+OpenPuck is an opensource firmware for NRF52840 Pro Micro that copycats the Steam Controller 2 Puck and allows emulation of Xbox, Original Xbox, Switch, and PS3/4/5 controllers and also includes an independant lizard mode (which can work on UAC prompts/task manager/etc). The Switch, PS3, and Original Xbox modes have been verified to work on real consoles and Switch, PS4/5 modes have gyro (and touchpad where available) hooked in. Back 4 buttons are mappable for all emulated modes.
 
 > [!WARNING]
 > Every part of this project _HEAVILY_ used LLMs*
@@ -40,6 +40,7 @@ Similarly you can hold all 4 back buttons and press Y to switch (teehee) over to
 | WebUSB panel → mode 5 | DualSense + Gyro + Trackpad | PC only |
 | WebUSB panel → mode 6 | DS4/HIDGYRO + Gyro + Trackpad | PC only |
 | WebUSB panel → mode 9 | PS3 DualShock 3 / Sixaxis | Enumerates on a real PS3 (+ gyro/haptics) |
+| WebUSB panel → mode 10 | Original Xbox Controller S | Enumerates on a real Original Xbox |
 
 I'm also adding various QOL items as I go as well. For example having to hold the Steam button for like 6 seconds feels like an eternity. If Steam is open you can do Steam + Y for a shutdown. I'm adding Steam + Y for 2 seconds as a shutdown chort in ALL modes now.
 

+ 1 - 1
docs/BUILD_AND_DEPLOY.md

@@ -70,7 +70,7 @@ make build FQBN=adafruit:nrf52:somethingelse          # a different nRF52840 boa
 `#error`s without them (so a forgotten flag fails loudly instead of shipping a broken/deadlock-prone image):
 
 ```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" 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" OpenPuck
 ```
 
 ### 4a. Raytac MDBT50Q-CX-40

+ 12 - 0
docs/PROTOCOL.md

@@ -367,6 +367,18 @@ from the feature-`0x01` **command** space even though the numbers overlap. Groun
 - Single HID interface with the canonical HORIPAD descriptor (interrupt IN + OUT endpoints), accepted by
   a real Switch console with no handshake; an 8-byte report is streamed at ~250 Hz
 
+### 9.4 Original Xbox mode
+
+- VID:PID `045E:0289` (Microsoft Controller S). Composite: the XID interface plus the wake mouse and
+  the WebUSB panel, so the config panel stays reachable on a PC
+- Proprietary XID interface (class `0x58`, subclass `0x42`, protocol `0x00`) with interrupt IN +
+  OUT endpoints; 20-byte input reports, rumble applied from the OUT endpoint
+- The console refuses the controller until three `0xC1` vendor control requests are answered:
+  `bRequest 0x06` / `wValue 0x4200` (XID descriptor), and `bRequest 0x01` with `wValue 0x0100` /
+  `0x0200` (input and output capabilities)
+- Also answers the XID report requests the protocol carries on EP0, in parallel with the interrupt
+  endpoints: `GET_REPORT` (`0xA1 0x01`, `wValue 0x0100`) and `SET_REPORT` (`0x21 0x09`, `wValue 0x0200`)
+
 ## 10. WebUSB control channel
 
 The WebUSB vendor interface is present only in Steam mode (Xbox/Switch are clean controllers with no

+ 4 - 3
docs/index.html

@@ -163,8 +163,9 @@
         <button data-mode="5" class="modebtn">PS5 DualSense</button>
         <button data-mode="6" class="modebtn">HID gyro (DS4)</button>
         <button data-mode="9" class="modebtn">PS3 (DualShock 3)</button>
+        <button data-mode="10" class="modebtn">Original Xbox</button>
       </div>
-      <p class="note">Switching reboots the copycat and re-enumerates USB (~2 s). WebUSB works in every mode, including Steam/Lizard with Steam running (Chrome claims the vendor interface; Steam keeps the HID slots). Switch HORIPAD + WebUSB is PC-only.</p>
+      <p class="note">Switching reboots the copycat and re-enumerates USB (~2 s). WebUSB works in every mode, including Steam/Lizard with Steam running (Chrome claims the vendor interface; Steam keeps the HID slots). Switch HORIPAD + WebUSB is PC-only. Original Xbox emulates one Microsoft Controller S and uses the Xbox mapping tab; LB/RB become White/Black. It's for a real Original Xbox console — a PC needs a driver for the original Xbox controller to see it as a gamepad.</p>
       <div class="row"><label>Persist last mode</label>
         <button id="persistMode">off</button>
         <span class="note" style="flex:1">Off (default): every restart / fresh reconnect boots into Steam mode. On: remembers the last mode you selected.</span>
@@ -385,7 +386,7 @@
 </div>
 
 <script>
-const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)","PS3 (DualShock 3)"];
+const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)","PS3 (DualShock 3)","Original Xbox"];
 const CHORD_FIELD = [17, 18, 19];          // back4 + B/X/Y
 const CHORD_DPAD_FIELD = [34, 35, 36, 37]; // back4 + D-pad left/up/right/down (firmware protocol >= 18)
 const CHORD_DPAD_DEF = [9, 8, 7, 2];       // firmware defaults: PS3, DS4 game, PS5 game, Switch HORIPAD
@@ -545,7 +546,7 @@ const TYPE_DEFS = [
   {key:"DS5", name:"DS5", labels:{1:"Cross",2:"Circle",3:"Square",4:"Triangle",5:"L1",6:"R1",7:"L3",8:"R3",9:"Create",10:"Options",11:"PS",19:"L2",20:"R2",...DPAD,16:"Touchpad Click",17:"Mute"}},
 ];
 // Which emulated type a USB mode belongs to (mirror of firmware etypeForMode); -1 = puck mode (no type).
-function etypeForMode(m){ switch(m){case 1:return 0; case 2:case 4:return 1; case 6:case 8:case 9:return 2; case 5:case 7:return 3; default:return -1;} }
+function etypeForMode(m){ switch(m){case 1:case 10:return 0; case 2:case 4:return 1; case 6:case 8:case 9:return 2; case 5:case 7:return 3; default:return -1;} }
 
 // ===================== Lizard (desktop) binding map =====================
 // Mirrors firmware lizard_map.h. A binding is {outType, od:[7], trig, hold}. Output types and the