Преглед изворни кода

Controller Emulation using a Steam Deck (#81)

* Add firmware and deck side app

* All inputs work now

* multiple attempts for real puck failed

* gyro?

* rename to reversepuck

* Finish name change

* Robustness fixes
Jari пре 4 месеци
родитељ
комит
1c454de213

+ 1 - 1
Makefile

@@ -12,7 +12,7 @@ CLANG_FORMAT ?= $(shell command -v clang-format-18 \
 
 # All hand-written C/C++ sources. Generated headers (git_version.h) are
 # gitignored and excluded here so formatting never touches them.
-FORMAT_FILES := $(shell find OpenPuck puck_sniffer pairtui \
+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')
 

+ 4 - 2
OpenPuck/rf_link.cpp

@@ -28,8 +28,10 @@ uint8_t g_e3mode = 1;
 // (a bare E3) was answered by F1 immediately. That contradicts the earlier RE "recipe" (rf_link.h) which
 // assumed E7 + GET-0x45 were required. So these now default to the real-puck behavior; flip them at runtime
 // ('d'/'n' console cmds) to fall back to the legacy GET/E7 path for an A/B comparison on hardware.
-bool g_pollGet = false; // false = bare E3 poll (real puck); true = append GET-report-0x45 TLV (legacy)
-bool g_e7announce = false; // false = no E7 awake-announce (real puck); true = announce host-awake (legacy)
+bool g_pollGet =
+	false; // false = bare E3 poll (real puck); true = append GET-report-0x45 TLV (legacy)
+bool g_e7announce =
+	false; // false = no E7 awake-announce (real puck); true = announce host-awake (legacy)
 // Session-channel E1 host-frame keepalive. The real puck sends NO E1 on its session channel (the bonded
 // controller already knows the per-bond address and just resumes). OpenPuck still needs E1 because it runs
 // the SHARED "ibex" address, not the (un-reversed) per-bond address -- E1 is how the controller learns this

+ 6 - 3
OpenPuck/rf_link.h

@@ -34,9 +34,12 @@ extern uint8_t g_getParam; // GET report 0x45 param byte
 extern uint8_t g_e3mode;
 
 // Real-puck alignment toggles (default to the captured real-puck behavior; flip on the CDC console to A/B):
-extern bool g_pollGet; // 'd': false=bare E3 poll (real puck), true=append GET-report-0x45 TLV (legacy)
-extern bool g_e7announce; // 'n': false=no E7 awake-announce (real puck), true=announce host-awake (legacy)
-extern bool g_e1keepalive; // 'm': session-channel E1 host-frame keepalive (ON by default; needed for shared addr)
+extern bool
+	g_pollGet; // 'd': false=bare E3 poll (real puck), true=append GET-report-0x45 TLV (legacy)
+extern bool
+	g_e7announce; // 'n': false=no E7 awake-announce (real puck), true=announce host-awake (legacy)
+extern bool
+	g_e1keepalive; // 'm': session-channel E1 host-frame keepalive (ON by default; needed for shared addr)
 
 // full multi-line debug dump vs compact "I45 <hex>" stream
 extern bool g_connVerbose;

+ 5 - 3
OpenPuck/serial_console.cpp

@@ -177,7 +177,8 @@ void serialConsolePoll()
 				Serial.printf(
 					"# CONN mode %s: %s%s poll on ch%u (param=%02X). F1 seen=%lu\n",
 					g_connOn ? "ON" : "off",
-					g_e7announce ? "E7-awake[00 00] -> " : "",
+					g_e7announce ? "E7-awake[00 00] -> " :
+						       "",
 					g_pollGet ? "E3+GET-report-0x45" :
 						    "bare E3",
 					g_rfCh, g_getParam,
@@ -382,8 +383,9 @@ void serialConsolePoll()
 				g_pollGet = !g_pollGet;
 				Serial.printf(
 					"# poll = %s (real puck sends BARE E3) - watch F1=/s new=/s\n",
-					g_pollGet ? "E3 + GET-report-0x45 TLV (legacy)" :
-						    "bare E3");
+					g_pollGet ?
+						"E3 + GET-report-0x45 TLV (legacy)" :
+						"bare E3");
 			} else if (line[0] == 'n') {
 				g_e7announce = !g_e7announce;
 				Serial.printf(

+ 131 - 0
ReversePuck/README.md

@@ -0,0 +1,131 @@
+# ReversePuck — Steam Deck forwarder
+
+Turn a Steam Deck into a Steam Controller 2. The Deck's built-in controls are **grabbed** (detached) and
+streamed over USB-CDC to a **ReversePuck** nRF52840 (firmware in
+[`../ReversePuckFirmware/`](../ReversePuckFirmware)), which relays them over 2.4 GHz to a puck — a real
+Valve puck **or** an [OpenPuck](../OpenPuck) — so the puck presents a Steam Controller 2 to whatever host
+it's plugged into.
+
+```
+Deck controls ──libusb detach + raw DeckState──▶ openctrl.py ──USB-CDC──▶ nRF (28DE:1302) ──RF──▶ puck ──USB──▶ host
+```
+
+The nRF "dongle" (the ReversePuck firmware, USB `28DE:1302`) is **hot-pluggable**: launch the app with or
+without it plugged in, and it appears/disappears live. Unplug it mid-forward and control returns to the
+Deck automatically; plug it back and the tile reappears — no restart needed.
+
+## Input: USB-level detach (the right way)
+The Deck controller is USB `28DE:1205`; Steam reads its 64-byte "DeckState" report on interface 2.
+You **can't** block Steam by reading `/dev/hidraw3` (it's broadcast), so `openctrl` uses **libusb
+(pyusb)** to **detach the kernel driver from the controller's HID interfaces and read EP `0x83`
+directly** — that genuinely takes the pad away from Steam (so **Steam/QAM forward** instead of opening
+the Deck UI) and decodes the **raw report correctly** (trackpads, sticks, triggers, gyro, all buttons —
+no more trackpad→stick/dpad bleed or Start/Select swap). Stopping forwarding reattaches it to Steam.
+Modeled on `~/Work/joverlay`. (`--evdev` is a lower-fidelity fallback that can't detach at the USB level.)
+
+**Calibrate the report layout for your unit:** `sudo python3 input_source.py` live-dumps the raw
+DeckState and shows which bytes/bits change as you press each control — edit `DECK_BTN_MAP` / the
+analog offsets in `input_source.py` if anything's off. The IMU axis remap (`IMU_GYRO_MAP` /
+`IMU_ACCEL_MAP`) is calibrated in Steam's gyro test — see the comments above those constants.
+
+## Pieces
+
+| File | What |
+|---|---|
+| `openctrl.py` | Main app: opens the nRF CDC port, shows the touchscreen UI, forwards input. |
+| `ui.py` | Fullscreen pygame UI — a tappable tile per paired puck. |
+| `input_source.py` | Reads the Deck controls — **libusb DeckSource (detach + raw decode)** + evdev fallback + capture tool. |
+| `frame.py` | The USB-CDC wire protocol (kept in lock-step with `../ReversePuckFirmware/deck_input.*`). |
+| `scpair.py` | Linux hidraw pairing tool (the on-Deck equivalent of `pairtui`). |
+| `ReversePuck` | `uv` launcher (`pyserial`+`pygame`+`pyusb`) — add this as a non-Steam game. |
+| `setup.sh` | Per-boot `sudo`: opens `/dev/ttyACM*` + the controller's usbfs node (read-only SteamOS). |
+
+## Run with `uv` (no system install, read-only SteamOS friendly)
+
+The `ReversePuck` launcher runs the app via [`uv`](https://docs.astral.sh/uv/), which provisions its own
+Python and the three deps (`pyserial` + `pygame` + `pyusb`, all prebuilt wheels; `pyusb` uses the system
+`libusb-1.0.so`) into uv's cache — **nothing touches the read-only rootfs**.
+
+```bash
+# one-time: install uv (lands in ~/.local/bin, no root)
+curl -LsSf https://astral.sh/uv/install.sh | sh
+
+./ReversePuck            # fullscreen touchscreen UI (first run pulls deps)
+./ReversePuck --debug    # headless status print (bring-up)
+./ReversePuck --evdev    # evdev fallback: no USB detach (Steam/QAM won't forward, lower-fidelity pads)
+```
+
+### Add as a non-Steam game
+Add the `ReversePuck` launcher as a non-Steam game (Target = `ReversePuck`, **Start In** = this folder).
+In the shortcut's **Properties → Controller**, set **Disable Steam Input** so Steam doesn't fight us for
+the built-in pad while we grab it.
+
+### Device permissions (read-only rootfs, no persistent udev)
+`/dev/input/event*` is already granted to the active-session user (logind ACL), so the `--evdev` fallback
+works without any setup. The two devices the **default** (libusb) path needs are not, so run once per
+boot from Desktop Mode:
+
+```bash
+sudo ./setup.sh     # chmod a+rw /dev/ttyACM* (nRF CDC) + the Deck controller's usbfs node; not persistent
+```
+
+`setup.sh` opens the nRF CDC (`/dev/ttyACM*`) and the Deck controller's `/dev/bus/usb/...` node (so the
+libusb `DeckSource` can detach + read it). We can't install a udev rule because `/etc` is read-only.
+Without it you'll get a permission error and the app falls back to `--evdev`.
+
+## Pair the controller to a puck
+
+Pairing writes a shared key to **both** devices, so both must be on USB **at the same time**, on one
+computer. Two ways:
+
+- **Steam / PairTUI (any Mac/PC):** plug in the nRF (enumerates as Valve `28DE:1302`, a "Steam
+  Controller") and the puck (`28DE:1304`), then pair as usual — `../pairtui/pairtui.py` already treats
+  `1302` as a dockable controller and `1304` as the puck.
+- **On the Deck (Linux):** plug both into the Deck and run
+  ```bash
+  sudo python3 scpair.py list           # show puck slots + controllers
+  sudo python3 scpair.py pair           # pair into the first free slot
+  sudo python3 scpair.py unpair --slot 0
+  ```
+  `pair` mints a fresh key, writes the puck slot (`0xA2`) and the controller bond (`esb/bond` via
+  `0xEE`/`0xEF`), then reboots the controller into wireless (`0x95`). Move the puck to your host.
+
+### "Steam says pairing failed" (but it actually works)
+Steam's pairing IS the USB bond-write path above (`0xA2`/`0xEE`/`0xEF` then a `0x95` reboot-to-wireless),
+verified end-to-end. If Steam's dialog reports failure even though the puck connects afterwards, the
+suspect is Steam's post-`0x95` confirmation racing the controller's reboot (the reboot drops USB).
+
+The firmware now **defers the `0x95` reset until the host has read the command's ACK back** (or a short
+grace window lapses), so the USB drop no longer lands mid-transaction — see
+`../ReversePuckFirmware/ctrl_usb.cpp` (`g_respRead` / `REBOOT_GRACE_MS`).
+
+To pin down exactly what Steam polls before declaring failure, watch the firmware's serial monitor during
+a pairing attempt: it logs every feature-report `SET` **and** `GET` (`# SET …` / `# GET rid=… -> …`). The
+last command Steam issues before the dialog fails is the one to reproduce. (The Deck app's framed protocol
+resyncs past these `#` lines, so they're harmless when forwarding.)
+
+## Using it
+
+Tap a puck tile that shows **● available** to detach the Deck controls and start forwarding. Tap
+anywhere (or unplug the puck/dongle) to hand control back to the Deck. `Esc`/`Q` quits. The app survives
+the dongle or the Deck pad being unplugged and replugged mid-session — it tears down cleanly and rebuilds
+when the device reappears, so a transient USB hiccup never wedges it or leaves the pad detached.
+
+Validate the RF link first with `./ReversePuck --debug` (headless): you want a line like
+`link=True fwd=False ch=18 [0 FXB99602xxxxx LIVE]`. Keep the puck on USB (so it beacons) and the nRF
+plugged in.
+
+## Notes / known iteration points
+
+- **IMU (gyro + accel)** is read straight from the libusb `DeckSource` and remapped to the SC2 axes via
+  `IMU_GYRO_MAP` / `IMU_ACCEL_MAP` in `input_source.py`. Accel and gyro must share handedness or the
+  host's sensor fusion mixes axes — calibrate both together in Steam's gyro test.
+- **Game Mode / gamescope** — the libusb detach and evdev `EVIOCGRAB` both behave cleanly in **Desktop
+  Mode**. In Game Mode, Steam Input owns the controller, so detach behaves differently; run from Desktop
+  Mode for now.
+- **Button mapping** lives in `input_source.DECK_BTN_MAP` (libusb) / `KEYMAP` (evdev); adjust if your
+  Deck maps face buttons or back paddles differently.
+- **Real Valve puck interop** — the RF reconnect (`../ReversePuckFirmware/ctrl_link.cpp`) adopts the
+  session address the puck advertises in its `E1` host frame; this matches OpenPuck exactly and should
+  match a real puck (OpenPuck reproduces the real puck's `E1`). If a real puck won't connect, the `E1`
+  field offsets are the first thing to check on a capture.

+ 23 - 0
ReversePuck/ReversePuck

@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+# ReversePuck Steam Deck forwarder launcher (uv-based; nothing installed system-wide).
+#
+# Add as a NON-STEAM GAME:
+#   Target:      /home/deck/…/ReversePuck/ReversePuck
+#   Start In:    /home/deck/…/ReversePuck/    (the directory this script is in)
+# Then, in that shortcut's gear -> Properties -> Controller, set "Disable Steam Input" so Steam doesn't
+# fight us for the built-in pad while we grab it.
+#
+# Dependencies are pulled by uv on first run (pyserial + pygame + pyusb -- prebuilt wheels; pyusb uses
+# the system libusb-1.0.so). uv also provisions its own Python, so nothing touches the read-only rootfs.
+#
+# USB-level detach (DeckSource) needs access to the controller's usbfs node, and the CDC link needs
+# /dev/ttyACM*. Run `sudo ./setup.sh` once per boot to open both (see README). Without it, fall back to
+# `./ReversePuck --evdev` (no USB detach; Steam/QAM won't forward, trackpads are lower fidelity).
+set -euo pipefail
+cd "$(dirname "$(readlink -f "$0")")"
+
+UV="$(command -v uv || true)"
+[ -n "$UV" ] || UV="$HOME/.local/bin/uv"
+[ -x "$UV" ] || { echo "uv not found. Install it: https://docs.astral.sh/uv/  (curl -LsSf https://astral.sh/uv/install.sh | sh)"; exit 1; }
+
+exec "$UV" run --with pyserial --with pygame --with pyusb python openctrl.py "$@"

+ 162 - 0
ReversePuck/frame.py

@@ -0,0 +1,162 @@
+"""frame.py -- the USB-CDC wire protocol shared with ReversePuckFirmware/deck_input.cpp.
+
+    frame = [0xA5][0x5A][type][len][payload(len)][sum8(type,len,payload...)]
+
+The reader resynchronizes on the 2-byte sync word, so any stray bytes (a boot banner, a serial
+monitor) are skipped harmlessly. Keep this file in lock-step with deck_input.{h,cpp}.
+"""
+import struct
+
+SYNC0 = 0xA5
+SYNC1 = 0x5A
+
+# Deck -> nRF
+T_INPUT = 0x01
+T_CONTROL = 0x02
+T_REQ_STATUS = 0x03
+# nRF -> Deck
+T_STATUS = 0x10
+T_TEXT = 0x1F
+# nRF -> Deck haptic: [intensity u16][left u16][right u16][lgain u8][rgain u8] (Deck rumble cmd 0xEB)
+T_HAPTIC = 0x11
+
+# INPUT payload: buttons u32, lx ly rx ry s16, lt rt u8, lpx lpy rpx rpy s16, lpp rpp u16, axyz/gxyz s16.
+# 4 + 8 + 2 + 8 + 4 + 12 = 38 bytes, little-endian. Matches applyInput() in deck_input.cpp.
+_INPUT = struct.Struct("<I4hBB4hHH6h")
+INPUT_LEN = _INPUT.size  # 38
+
+
+def _sum8(type_, payload):
+    s = (type_ + len(payload)) & 0xFF
+    for b in payload:
+        s = (s + b) & 0xFF
+    return s
+
+
+def build(type_, payload=b""):
+    """Serialize one frame."""
+    payload = bytes(payload)
+    return bytes([SYNC0, SYNC1, type_, len(payload)]) + payload + bytes([_sum8(type_, payload)])
+
+
+def build_input(st):
+    """st: an object/dict with Triton fields -> an INPUT frame."""
+    g = st.get if isinstance(st, dict) else (lambda k, d=0: getattr(st, k, d))
+    payload = _INPUT.pack(
+        g("buttons", 0) & 0xFFFFFFFF,
+        g("lx", 0), g("ly", 0), g("rx", 0), g("ry", 0),
+        g("lt", 0) & 0xFF, g("rt", 0) & 0xFF,
+        g("lpx", 0), g("lpy", 0), g("rpx", 0), g("rpy", 0),
+        g("lpp", 0) & 0xFFFF, g("rpp", 0) & 0xFFFF,
+        g("ax", 0), g("ay", 0), g("az", 0), g("gx", 0), g("gy", 0), g("gz", 0),
+    )
+    return build(T_INPUT, payload)
+
+
+def parse_haptic(payload):
+    """Decode a T_HAPTIC payload -> dict for the Deck rumble cmd. Mirrors deck_input.cpp."""
+    if len(payload) < 8:
+        return None
+    intensity, left, right = struct.unpack_from("<HHH", payload, 0)
+    lgain, rgain = payload[6], payload[7]
+    return {"intensity": intensity, "left": left, "right": right,
+            "lgain": lgain, "rgain": rgain}
+
+
+def build_set_forwarding(on):
+    return build(T_CONTROL, bytes([0x01, 1 if on else 0]))
+
+
+def build_clear_bond(slot):
+    """Tell the firmware to un-bond (remove) slot `slot` -- the GUI "remove" affordance."""
+    return build(T_CONTROL, bytes([0x02, slot & 0xFF]))
+
+
+def build_req_status():
+    return build(T_REQ_STATUS)
+
+
+def parse_status(payload):
+    """Decode a T_STATUS payload into a dict. Mirrors deckStatusTask() in deck_input.cpp.
+    Only USED bonds are sent; each carries its firmware slot index (for removal)."""
+    if len(payload) < 5:
+        return None
+    flags, link_slot, sess_ch, nbond_max, count = payload[:5]
+    bonds = []
+    off = 5
+    for _ in range(count):
+        if off + 26 > len(payload):
+            break
+        slot = payload[off]
+        alive = payload[off + 1]
+        puuid = struct.unpack_from("<I", payload, off + 2)[0]
+        iuuid = struct.unpack_from("<I", payload, off + 6)[0]
+        serial = payload[off + 10:off + 26].split(b"\x00")[0].decode("latin1", "replace")
+        bonds.append({"slot": slot, "used": True, "alive": bool(alive),
+                      "puuid": puuid, "iuuid": iuuid, "serial": serial})
+        off += 26
+    return {
+        "forwarding": bool(flags & 1),
+        "link_up": bool(flags & 2),
+        "link_slot": None if link_slot == 0xFF else link_slot,
+        "sess_ch": sess_ch,
+        "nbond_max": nbond_max,
+        "bonds": bonds,
+    }
+
+
+class Reader:
+    """Feed raw bytes; yields (type, payload) tuples for each complete, checksum-valid frame.
+
+    Bytes that fall *between* frames are the firmware's plain-text `#` diagnostic lines (Serial.printf,
+    which the framed protocol resyncs past). We capture those too and surface them as (T_TEXT, line) so
+    the app can show the firmware log without a serial monitor.
+    """
+
+    def __init__(self):
+        self._st = 0
+        self._type = 0
+        self._len = 0
+        self._buf = bytearray()
+        self._sum = 0
+        self._text = bytearray()
+
+    def _flush_text(self, out):
+        line = self._text.rstrip(b"\r\n")
+        self._text = bytearray()
+        if line and all(9 <= b < 127 or b in (10, 13) for b in line):
+            out.append((T_TEXT, bytes(line)))
+
+    def feed(self, data):
+        out = []
+        for c in data:
+            st = self._st
+            if st == 0:
+                if c == SYNC0:
+                    self._st = 1
+                else:
+                    # inter-frame byte: accumulate the firmware's plain-text diagnostic lines
+                    self._text.append(c)
+                    if c == 0x0A or len(self._text) > 240:
+                        self._flush_text(out)
+            elif st == 1:
+                self._st = 2 if c == SYNC1 else (1 if c == SYNC0 else 0)
+            elif st == 2:
+                self._type = c
+                self._sum = c
+                self._st = 3
+            elif st == 3:
+                self._len = c
+                self._sum = (self._sum + c) & 0xFF
+                self._buf = bytearray()
+                self._st = 5 if c == 0 else 4
+            elif st == 4:
+                self._buf.append(c)
+                self._sum = (self._sum + c) & 0xFF
+                if len(self._buf) >= self._len:
+                    self._st = 5
+            elif st == 5:
+                if c == self._sum:
+                    out.append((self._type, bytes(self._buf)))
+                self._st = 0
+        return out

+ 466 - 0
ReversePuck/input_source.py

@@ -0,0 +1,466 @@
+"""input_source.py -- read the Steam Deck's built-in controls and normalize to Triton fields.
+
+TWO sources:
+
+* **DeckSource (default, libusb/pyusb)** -- the CORRECT way, modeled on ~/Work/joverlay. The Deck
+  controller is USB 28DE:1205; Steam reads its 64-byte "DeckState" vendor report on interface 2,
+  EP 0x83. We **detach the kernel driver from interfaces 0/1/2 and claim them** -> the controller
+  vanishes from the kernel/Steam (true USB-level detach, so Steam stops eating Steam/QAM), and we read
+  EP 0x83 ourselves and decode the raw report (trackpads, sticks, triggers, gyro, ALL buttons incl.
+  Steam/QAM). Release + reattach hands it back to Steam. Needs access to the usbfs node (see setup.sh:
+  `chmod` the /dev/bus/usb node, or run as root).
+
+* **EvdevSource (fallback, --evdev)** -- pure-stdlib evdev. Works without libusb but the Deck exposes
+  trackpads as sticks/dpad and can't see Steam/QAM, hence the original mapping problems.
+
+DeckState layout: buttons in bytes 8-19 (the bits below are from joverlay's live captures + hid-steam.c /
+SDL_hidapi_steamdeck.c); analog (pads/sticks/triggers/IMU) at byte 20+. Run `python3 input_source.py`
+to live-dump the raw report and verify/correct the offsets on your unit.
+"""
+from __future__ import annotations
+import fcntl
+import glob
+import os
+import struct
+
+# ---- Triton button masks (mirror triton.h) ----
+TB_A = 0x1
+TB_B = 0x2
+TB_X = 0x4
+TB_Y = 0x8
+TB_QAM = 0x10
+TB_R3 = 0x20
+TB_VIEW = 0x40
+TB_R4 = 0x80
+TB_R5 = 0x100
+TB_RB = 0x200
+TB_DDN = 0x400
+TB_DRT = 0x800
+TB_DLF = 0x1000
+TB_DUP = 0x2000
+TB_MENU = 0x4000
+TB_L3 = 0x8000
+TB_STEAM = 0x10000
+TB_L4 = 0x20000
+TB_L5 = 0x40000
+TB_LB = 0x80000
+
+NEUTRAL = {
+    "buttons": 0, "lx": 0, "ly": 0, "rx": 0, "ry": 0, "lt": 0, "rt": 0,
+    "lpx": 0, "lpy": 0, "rpx": 0, "rpy": 0, "lpp": 0, "rpp": 0,
+    "ax": 0, "ay": 0, "az": 0, "gx": 0, "gy": 0, "gz": 0,
+}
+
+DECK_VID = 0x28DE
+DECK_PID = 0x1205
+
+# ─────────────────────────── DeckState decode ───────────────────────────
+# Each entry: (byte_offset, bit_mask, Triton mask). EXACT byte/bit from the Linux kernel
+# drivers/hid/hid-steam.c steam_do_deck_input_event() -- authoritative, not guessed.
+DECK_BTN_MAP = [
+    # byte 8 -- digital triggers (full-pull) + bumpers + face buttons
+    (8, 0x01, 0x00800000),  # R2 full-pull -> RTrig click (triton bit 23)
+    (8, 0x02, 0x08000000),  # L2 full-pull -> LTrig click (triton bit 27)
+    (8, 0x04, TB_RB),       # R1
+    (8, 0x08, TB_LB),       # L1
+    (8, 0x10, TB_Y),
+    (8, 0x20, TB_B),
+    (8, 0x40, TB_X),
+    (8, 0x80, TB_A),
+    # byte 9 -- dpad + system buttons + lower-left grip
+    (9, 0x01, TB_DUP),
+    (9, 0x02, TB_DRT),
+    (9, 0x04, TB_DLF),
+    (9, 0x08, TB_DDN),
+    # SELECT (byte9 bit4) and START (byte9 bit6) are SWAPPED vs the Triton View/Menu so they read
+    # right on the host (kernel calls bit4 "menu left"/SELECT, bit6 "menu right"/START).
+    (9, 0x10, TB_MENU),    # ⊟  (BTN_SELECT)  -> Triton Menu
+    (9, 0x20, TB_STEAM),   # Steam (BTN_MODE)
+    (9, 0x40, TB_VIEW),    # ☰  (BTN_START)   -> Triton View
+    (9, 0x80, TB_L5),      # GRIPL2 -- lower-left back paddle
+    # byte 10 -- lower-right grip + pad click/TOUCH + left stick click
+    (10, 0x01, TB_R5),         # GRIPR2 -- lower-right back paddle
+    (10, 0x02, 0x04000000),    # BTN_THUMB  -> left pad CLICK  (TB_LPADC)
+    (10, 0x04, 0x00400000),    # BTN_THUMB2 -> right pad CLICK (TB_RPADC)
+    (10, 0x08, 0x02000000),    # left pad TOUCH  (TB_LPADT bit25) -- needed for pad->mouse
+    (10, 0x10, 0x00200000),    # right pad TOUCH (TB_RPADT bit21)
+    (10, 0x40, TB_L3),         # BTN_THUMBL -> left stick click
+    # byte 11 -- right stick click
+    (11, 0x04, TB_R3),         # BTN_THUMBR
+    # byte 13 -- upper back paddles + thumbstick capacitive touch
+    (13, 0x02, TB_L4),         # BTN_GRIPL  (upper-left)
+    (13, 0x04, TB_R4),         # BTN_GRIPR  (upper-right)
+    (13, 0x40, 0x01000000),    # left thumbstick TOUCH  (LJoy touch, bit24)
+    (13, 0x80, 0x00100000),    # right thumbstick TOUCH (RJoy touch, bit20)
+    # byte 14 -- QAM (•••)
+    (14, 0x04, TB_QAM),        # BTN_BASE
+]
+
+# analog block: little-endian 16-bit, EXACT offsets from hid-steam.c steam_do_deck_input_event().
+_OFF_LPAD_X, _OFF_LPAD_Y = 16, 18   # s16 left trackpad
+_OFF_RPAD_X, _OFF_RPAD_Y = 20, 22   # s16 right trackpad
+_OFF_ACCEL = 24                     # s16 ax,ay,az (24/26/28)
+_OFF_GYRO = 30                      # s16 gx,gy,gz (30/32/34)
+_OFF_TRIG_L, _OFF_TRIG_R = 44, 46   # u16 analog triggers
+_OFF_LSTICK_X, _OFF_LSTICK_Y = 48, 50
+_OFF_RSTICK_X, _OFF_RSTICK_Y = 52, 54
+_OFF_LPAD_P, _OFF_RPAD_P = 56, 58   # u16 trackpad pressure
+
+# ── IMU axis remap: Deck IMU -> SC2 report-0x45 IMU ──────────────────────────
+# The Deck and the SC2 mount their IMUs in different orientations, so the raw Deck axes do NOT line up
+# with the gyro/accel convention Steam expects from an SC2. accel and gyro MUST use the SAME remap (shared
+# handedness) or the host's sensor fusion mixes axes intermittently (see memory: switch-imu-fusion).
+# Each entry = (source_axis, sign): source_axis 0=x 1=y 2=z indexes the raw Deck (x,y,z); sign flips it.
+# CALIBRATE empirically: in Steam's gyro test, rotate the Deck about one physical axis, see which on-screen
+# axis moves and which way, then swap the source / flip the sign here. Default = identity pass-through.
+IMU_GYRO_MAP = ((0, +1), (1, +1), (2, +1))    # -> (gx, gy, gz)
+IMU_ACCEL_MAP = ((0, +1), (1, +1), (2, +1))   # -> (ax, ay, az)
+# Deck->SC2 gyro sensitivity ratio. Both are int16 IMUs at similar full-scale, so 1.0 is the start point;
+# bump if the on-screen rotation is too slow, drop it if too fast/jittery.
+IMU_GYRO_SCALE = 1.0
+
+
+def _clamp16(v):
+    return -32768 if v < -32768 else 32767 if v > 32767 else int(v)
+
+
+def _remap3(src, mapping, scale=1.0):
+    """src = (x, y, z) raw; mapping = 3x (source_axis, sign). Returns the remapped, clamped triple."""
+    return tuple(_clamp16(src[ax] * sign * scale) for ax, sign in mapping)
+
+
+def _s16(b, off):
+    return struct.unpack_from("<h", b, off)[0]
+
+
+def _u16(b, off):
+    return struct.unpack_from("<H", b, off)[0]
+
+
+def decode_deck(rpt, state):
+    """Decode a 64-byte DeckState report (rpt) into the Triton `state` dict. Returns False if not a
+    0x09 state report."""
+    if len(rpt) < 60 or rpt[2] != 0x09:
+        return False
+    btn = 0
+    for off, mask, tmask in DECK_BTN_MAP:
+        if tmask and (rpt[off] & mask):
+            btn |= tmask
+    state["buttons"] = btn
+    # sticks (int16, center 0). Deck Y is up-positive; Triton expects up-positive too -> pass through.
+    state["lx"] = _s16(rpt, _OFF_LSTICK_X)
+    state["ly"] = _s16(rpt, _OFF_LSTICK_Y)
+    state["rx"] = _s16(rpt, _OFF_RSTICK_X)
+    state["ry"] = _s16(rpt, _OFF_RSTICK_Y)
+    # analog triggers u16 -> 0..255
+    state["lt"] = min(255, _u16(rpt, _OFF_TRIG_L) >> 7)
+    state["rt"] = min(255, _u16(rpt, _OFF_TRIG_R) >> 7)
+    # trackpads (int16) -> Triton pads (this is the FIX: pads no longer bleed into sticks/dpad)
+    state["lpx"] = _s16(rpt, _OFF_LPAD_X)
+    state["lpy"] = _s16(rpt, _OFF_LPAD_Y)
+    state["rpx"] = _s16(rpt, _OFF_RPAD_X)
+    state["rpy"] = _s16(rpt, _OFF_RPAD_Y)
+    state["lpp"] = _u16(rpt, _OFF_LPAD_P)   # trackpad pressure (u16)
+    state["rpp"] = _u16(rpt, _OFF_RPAD_P)
+    # IMU: raw Deck accel/gyro -> SC2 axes via the shared remap (calibrate IMU_*_MAP in Steam's gyro test).
+    araw = (_s16(rpt, _OFF_ACCEL), _s16(rpt, _OFF_ACCEL + 2), _s16(rpt, _OFF_ACCEL + 4))
+    graw = (_s16(rpt, _OFF_GYRO), _s16(rpt, _OFF_GYRO + 2), _s16(rpt, _OFF_GYRO + 4))
+    state["ax"], state["ay"], state["az"] = _remap3(araw, IMU_ACCEL_MAP)
+    state["gx"], state["gy"], state["gz"] = _remap3(graw, IMU_GYRO_MAP, IMU_GYRO_SCALE)
+    return True
+
+
+# ─────────────────────────── USB source (default) ───────────────────────────
+class DeckSource:
+    """Read + DETACH the Deck controller via libusb (pyusb). grab() detaches it from the kernel/Steam
+    and starts reading EP 0x83; ungrab() hands it back."""
+
+    IFACES = (0, 1, 2)   # claim all HID interfaces so the pad fully leaves the kernel (mouse/kbd/steam)
+    EP_IN = 0x83
+    IFACE_READ = 2
+
+    def __init__(self, path=None):
+        import time as _time
+        import usb.core  # imported here so --help / --evdev work without pyusb
+        self._time = _time
+        self._usbutil = __import__("usb.util", fromlist=["util"])
+        self.dev = usb.core.find(idVendor=DECK_VID, idProduct=DECK_PID)
+        if self.dev is None:
+            raise RuntimeError("Deck controller 28DE:1205 not found on USB")
+        self.state = dict(NEUTRAL)
+        self._claimed = False
+        self._USBError = usb.core.USBError
+        self._last_rumble = None      # last (int,left,right,lg,rg) we sent -> dedupe
+        self._last_rumble_t = 0.0     # throttle clock
+
+    @property
+    def name(self):
+        return "Steam Deck controller (libusb 28DE:1205)"
+
+    def grab(self):
+        if self._claimed:
+            return
+        for i in self.IFACES:
+            try:
+                if self.dev.is_kernel_driver_active(i):
+                    self.dev.detach_kernel_driver(i)
+            except Exception:
+                pass
+            try:
+                self._usbutil.claim_interface(self.dev, i)
+            except Exception:
+                pass
+        self._claimed = True
+
+    def ungrab(self):
+        if not self._claimed:
+            return
+        for i in self.IFACES:
+            try:
+                self._usbutil.release_interface(self.dev, i)
+            except Exception:
+                pass
+            try:
+                self.dev.attach_kernel_driver(i)
+            except Exception:
+                pass
+        self._claimed = False
+
+    def pump(self):
+        # drain whatever reports are queued; keep the freshest decoded state
+        for _ in range(8):
+            try:
+                rpt = self.dev.read(self.EP_IN, 64, timeout=2)
+            except self._USBError:
+                break  # timeout / no data
+            decode_deck(bytes(rpt), self.state)
+        return self.state
+
+    # don't issue rumble feature-reports faster than this -- protects the Deck pad's control endpoint
+    # (hammering it at the poll rate is what wedged the pad). A repeated identical state is also dropped.
+    _RUMBLE_MIN_DT = 0.015
+
+    def rumble(self, intensity, left, right, lgain=0, rgain=0):
+        """Play a rumble on the Deck's haptics via the 0xEB feature report (hid-steam SET_REPORT).
+        intensity/left/right are the SC2 rumble u16s relayed back from Steam; 0 stops it. Rate-limited
+        + deduped + short-timeout so a rumble burst can never flood or block the input loop."""
+        if not self._claimed:
+            return
+        cur = (intensity & 0xFFFF, left & 0xFFFF, right & 0xFFFF, lgain & 0xFF, rgain & 0xFF)
+        now = self._time.monotonic()
+        # always let an OFF (all-zero) through promptly; otherwise dedupe + throttle
+        if cur != (0, 0, 0, 0, 0):
+            if cur == self._last_rumble:
+                return
+            if now - self._last_rumble_t < self._RUMBLE_MIN_DT:
+                return
+        self._last_rumble = cur
+        self._last_rumble_t = now
+        buf = bytearray(64)
+        buf[0] = 0xEB          # CMD_TRIGGER_HAPTIC_RUMBLE
+        buf[1] = 0x09          # payload length
+        buf[2] = 0x00          # reserved
+        struct.pack_into("<HHH", buf, 3, cur[0], cur[1], cur[2])
+        buf[9] = cur[3]
+        buf[10] = cur[4]
+        try:
+            # SET_REPORT(Feature), report id 0, on the DeckState control interface (iface 2). Short
+            # timeout: never block the input/UI loop if the endpoint is busy.
+            self.dev.ctrl_transfer(0x21, 0x09, 0x0300, self.IFACE_READ, bytes(buf), timeout=20)
+        except Exception:
+            pass
+
+
+# ─────────────────────────── evdev fallback ───────────────────────────
+EV_SYN, EV_KEY, EV_ABS = 0x00, 0x01, 0x03
+BTN_SOUTH, BTN_EAST, BTN_NORTH, BTN_WEST = 0x130, 0x131, 0x133, 0x134
+BTN_TL, BTN_TR = 0x136, 0x137
+BTN_SELECT, BTN_START, BTN_MODE = 0x13A, 0x13B, 0x13C
+BTN_THUMBL, BTN_THUMBR = 0x13D, 0x13E
+ABS_X, ABS_Y, ABS_Z, ABS_RX, ABS_RY, ABS_RZ = 0, 1, 2, 3, 4, 5
+ABS_HAT0X, ABS_HAT0Y = 0x10, 0x11
+KEYMAP = {
+    BTN_SOUTH: TB_A, BTN_EAST: TB_B, BTN_WEST: TB_X, BTN_NORTH: TB_Y,
+    BTN_TL: TB_LB, BTN_TR: TB_RB,
+    BTN_SELECT: TB_VIEW, BTN_START: TB_MENU, BTN_MODE: TB_STEAM,
+    BTN_THUMBL: TB_L3, BTN_THUMBR: TB_R3,
+}
+_EVENT = struct.Struct("qqHHi")
+EVENT_SZ = _EVENT.size
+
+
+def _IOC(d, t, nr, size):
+    return (d << 30) | (size << 16) | (ord(t) << 8) | nr
+
+
+EVIOCGID = _IOC(2, 'E', 0x02, 8)
+
+
+def EVIOCGNAME(length):
+    return _IOC(2, 'E', 0x06, length)
+
+
+def EVIOCGBIT(ev, length):
+    return _IOC(2, 'E', 0x20 + ev, length)
+
+
+EVIOCGRAB = _IOC(1, 'E', 0x90, 4)
+
+
+def _test_bit(bm, bit):
+    return bool(bm[bit // 8] & (1 << (bit % 8)))
+
+
+class _RawDev:
+    def __init__(self, path):
+        self.path = path
+        self.fd = os.open(path, os.O_RDWR | os.O_NONBLOCK)
+
+    def close(self):
+        try:
+            os.close(self.fd)
+        except Exception:
+            pass
+
+    def ids(self):
+        buf = bytearray(8)
+        try:
+            fcntl.ioctl(self.fd, EVIOCGID, buf, True)
+            _b, vid, pid, _v = struct.unpack("<HHHH", buf)
+            return vid, pid
+        except Exception:
+            return (0, 0)
+
+    def has_key(self, code):
+        buf = bytearray(96)
+        try:
+            fcntl.ioctl(self.fd, EVIOCGBIT(EV_KEY, len(buf)), buf, True)
+            return _test_bit(buf, code)
+        except Exception:
+            return False
+
+    def grab(self):
+        fcntl.ioctl(self.fd, EVIOCGRAB, 1)
+
+    def ungrab(self):
+        try:
+            fcntl.ioctl(self.fd, EVIOCGRAB, 0)
+        except Exception:
+            pass
+
+    def read_events(self):
+        try:
+            data = os.read(self.fd, EVENT_SZ * 64)
+        except (BlockingIOError, OSError):
+            return
+        for i in range(0, len(data) - EVENT_SZ + 1, EVENT_SZ):
+            _s, _u, typ, code, val = _EVENT.unpack_from(data, i)
+            yield typ, code, val
+
+
+def find_deck_evdev():
+    for path in sorted(glob.glob("/dev/input/event*")):
+        try:
+            d = _RawDev(path)
+        except Exception:
+            continue
+        try:
+            if d.has_key(BTN_SOUTH):
+                return path
+        finally:
+            d.close()
+    return None
+
+
+class EvdevSource:
+    def __init__(self, path=None):
+        self.path = path or find_deck_evdev()
+        if not self.path:
+            raise RuntimeError("no gamepad evdev device found")
+        self.dev = _RawDev(self.path)
+        self.state = dict(NEUTRAL)
+        self._grabbed = False
+
+    @property
+    def name(self):
+        return "evdev " + self.path
+
+    def grab(self):
+        if not self._grabbed:
+            self.dev.grab()
+            self._grabbed = True
+
+    def ungrab(self):
+        if self._grabbed:
+            self.dev.ungrab()
+            self._grabbed = False
+
+    def rumble(self, intensity, left, right, lgain=0, rgain=0):
+        # evdev fallback has no haptic path (FF could be added, but the libusb source is the real one)
+        pass
+
+    def pump(self):
+        s = self.state
+        for typ, code, val in self.dev.read_events():
+            if typ == EV_KEY:
+                m = KEYMAP.get(code)
+                if m is not None:
+                    s["buttons"] = (s["buttons"] | m) if val else (s["buttons"] & ~m)
+            elif typ == EV_ABS:
+                if code == ABS_X:
+                    s["lx"] = val
+                elif code == ABS_Y:
+                    s["ly"] = -val
+                elif code == ABS_RX:
+                    s["rx"] = val
+                elif code == ABS_RY:
+                    s["ry"] = -val
+                elif code == ABS_Z:
+                    s["lt"] = max(0, min(255, val))
+                elif code == ABS_RZ:
+                    s["rt"] = max(0, min(255, val))
+        return s
+
+
+def open_source(prefer_evdev=False, path=None):
+    """Pick the best input source: libusb DeckSource by default, evdev if requested/unavailable."""
+    if not prefer_evdev:
+        try:
+            return DeckSource(path)
+        except Exception as ex:
+            print("DeckSource (libusb) unavailable (%s); falling back to evdev" % ex)
+    return EvdevSource(path)
+
+
+# ─────────────────────────── capture / calibration tool ───────────────────────────
+def _capture():
+    """Live-dump the raw DeckState report; press one control at a time and watch which bytes/bits move,
+    so you can correct DECK_BTN_MAP and the analog offsets for your unit. Ctrl-C to stop."""
+    src = DeckSource()
+    print("detaching controller (libusb) ... press ONE control at a time; Ctrl-C to stop")
+    src.grab()
+    base = None
+    try:
+        while True:
+            try:
+                rpt = bytes(src.dev.read(src.EP_IN, 64, timeout=200))
+            except Exception:
+                continue
+            if len(rpt) < 60 or rpt[2] != 0x09:
+                continue
+            if base is None:
+                base = rpt
+                print("baseline:", base.hex())
+                continue
+            diff = [(i, base[i], rpt[i]) for i in range(8, 60) if base[i] != rpt[i]]
+            if diff:
+                print(" ".join("b%d:%02x->%02x" % d for d in diff))
+    except KeyboardInterrupt:
+        pass
+    finally:
+        src.ungrab()
+        print("\nreattached controller to the kernel.")
+
+
+if __name__ == "__main__":
+    _capture()

+ 390 - 0
ReversePuck/openctrl.py

@@ -0,0 +1,390 @@
+#!/usr/bin/env python3
+"""openctrl.py -- Steam Deck forwarder for the ReversePuck emulated Steam Controller 2.
+
+Runs on the Steam Deck (Desktop Mode). Opens the nRF (Valve 28DE:1302) CDC port, reads its STATUS
+stream (which bonded pucks are live), and shows a fullscreen touchscreen UI of tappable puck tiles.
+Tap an *available* puck -> the Deck's controls are grabbed (detached) and streamed to the nRF, which
+relays them over RF so the puck presents a Steam Controller 2 to its host. Tap again, or lose the RF
+link, and control returns to the Deck.
+
+    python3 openctrl.py                 # fullscreen touchscreen UI
+    python3 openctrl.py --debug         # headless: print status, no UI (for bring-up)
+    python3 openctrl.py --port /dev/ttyACM0   # override CDC port autodetect
+    python3 openctrl.py --evdev         # use the evdev fallback input source (lower fidelity)
+
+The dongle (nRF CDC) is hot-pluggable: launch with or without it, it appears/disappears live.
+Deps: pyserial + pygame (UI) + pyusb (input/USB detach). See README.md / the ./ReversePuck launcher.
+"""
+import argparse
+import os
+import sys
+import threading
+import time
+from collections import deque
+
+import frame
+import input_source as isrc
+
+VID = 0x28DE
+PID = 0x1302  # the ReversePuck emulated controller (nRF "dongle")
+PUCK_PID = 0x1304  # a puck's own CDC -- never grab this as the dongle
+INPUT_HZ = 250.0
+
+
+def find_port():
+    try:
+        from serial.tools import list_ports
+    except Exception:
+        return None
+    for p in list_ports.comports():
+        if (p.vid, p.pid) == (VID, PID):
+            return p.device
+    # fall back: any Valve CDC except a puck's (so a puck docked on the Deck isn't mistaken for it)
+    for p in list_ports.comports():
+        if p.vid == VID and p.pid != PUCK_PID:
+            return p.device
+    return None
+
+
+class App:
+    def __init__(self, args):
+        self.args = args
+        import serial  # imported here so --help works without pyserial
+        self._serial = serial
+
+        # The dongle (nRF CDC) is HOT-PLUGGABLE: we don't require it at launch and we re-open it
+        # whenever it (re)appears, and tear down cleanly when it goes away.
+        self.ser = None
+        self.port_name = None
+        self.reader = frame.Reader()
+        self._last_open_try = 0.0
+
+        self.input = None
+        self.forwarding = False
+        self.fwd_slot = None
+        self.last_input = 0.0
+
+        # serial I/O + input forwarding run on a DEDICATED thread (see _io_loop): pygame's vsync'd
+        # flip() paces the UI render loop at the Deck's 60Hz, which was capping forwarding to 60Hz.
+        # The UI thread only draws + enqueues tap intents here; this thread owns self.ser/self.input.
+        self._cmdq = deque()
+        self._io_stop = threading.Event()
+        self._io_thread = None
+
+        # model surfaced to the UI
+        self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
+                       "bonds": [], "forwarding": False}
+        self.note = "waiting for ReversePuck dongle (28DE:1302)…"
+        self.log = []  # firmware '#' diagnostic lines (the in-app serial monitor)
+
+        # file log: everything (firmware '#' lines + app events) with timestamps, for sharing.
+        self.logf = None
+        try:
+            self.logf = open(args.logfile, "a", buffering=1)  # line-buffered
+            self._flog("==== openctrl start ====")
+        except Exception as ex:
+            print("logfile open failed (%s); continuing without it" % ex)
+
+    def _flog(self, line):
+        if self.logf:
+            try:
+                self.logf.write("%.3f %s\n" % (time.monotonic(), line))
+            except Exception:
+                pass
+
+    @property
+    def dongle_connected(self):
+        return self.ser is not None
+
+    # ---- serial I/O (hot-plug aware) ----
+    def _open_serial(self):
+        """Try to (re)open the dongle CDC port. Throttled to ~2 Hz. Sets self.ser on success."""
+        if self.ser is not None:
+            return
+        now = time.monotonic()
+        if now - self._last_open_try < 0.5:
+            return
+        self._last_open_try = now
+        port = self.args.port or find_port()
+        if not port:
+            return
+        try:
+            self.ser = self._serial.Serial(port, 115200, timeout=0)
+            self.port_name = port
+            self.reader = frame.Reader()  # fresh framer for the new connection
+            # forget any stale link state from a previous dongle session
+            self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
+                           "bonds": [], "forwarding": False}
+            self.note = "dongle connected: %s" % port
+            self._flog("dongle connected: %s" % port)
+        except Exception:
+            self.ser = None  # e.g. permission / busy; will retry
+
+    def _drop_serial(self, why):
+        """Tear down on disconnect: close the port FIRST so we never try to write to a gone dongle, then
+        release the grabbed pad locally and reset the model. (Closing first also makes send() a no-op, so
+        there's no re-entrant _drop_serial via a failing write.)"""
+        try:
+            if self.ser:
+                self.ser.close()
+        except Exception:
+            pass
+        self.ser = None
+        self.port_name = None
+        self._release_local()  # hand the pad back without trying to notify the dongle that's gone
+        self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
+                       "bonds": [], "forwarding": False}
+        self.note = "dongle disconnected (%s) — waiting…" % why
+        self._flog("dongle disconnected: %s" % why)
+
+    def pump_serial(self):
+        if self.ser is None:
+            self._open_serial()
+            return
+        # A yanked CDC often makes pyserial return b'' silently rather than raise, so the port node
+        # disappearing is the reliable disconnect signal. Check it (throttled).
+        now = time.monotonic()
+        if now - self._last_open_try >= 0.5:
+            self._last_open_try = now
+            if self.port_name and not os.path.exists(self.port_name):
+                self._drop_serial("unplugged")
+                return
+        try:
+            data = self.ser.read(512)
+        except Exception as ex:
+            # device yanked / I/O error -> clean teardown, then we'll re-detect it
+            self._drop_serial(str(ex) or "read error")
+            return
+        last_haptic = None
+        for typ, payload in self.reader.feed(data):
+            if typ == frame.T_STATUS:
+                st = frame.parse_status(payload)
+                if st:
+                    self.status = st
+            elif typ == frame.T_HAPTIC:
+                # coalesce a burst to the newest state -- replaying every frame is what lagged the rumble
+                last_haptic = frame.parse_haptic(payload)
+            elif typ == frame.T_TEXT:
+                line = payload.decode("latin1", "replace").rstrip()
+                if line:
+                    self.note = line
+                    self.log.append(line)
+                    self.log = self.log[-300:]
+                    self._flog(line)
+                    if self.args.debug:
+                        print(line)  # stream the firmware log like a serial monitor
+        # play only the newest haptic of this batch, and only while we're actually forwarding
+        if last_haptic and self.forwarding and self.input:
+            try:
+                self.input.rumble(last_haptic["intensity"], last_haptic["left"],
+                                  last_haptic["right"], last_haptic["lgain"],
+                                  last_haptic["rgain"])
+            except Exception:
+                pass
+
+    def send(self, b):
+        if self.ser is None:
+            return
+        try:
+            self.ser.write(b)
+        except Exception:
+            self._drop_serial("write error")
+
+    # ---- forwarding control ----
+    def _ensure_input(self):
+        if self.input is None:
+            # libusb DeckSource by default (detaches the pad from Steam + decodes the raw report
+            # correctly); --evdev forces the lower-fidelity fallback.
+            self.input = isrc.open_source(prefer_evdev=self.args.evdev,
+                                          path=self.args.input)
+        return self.input
+
+    def start_forwarding(self, slot):
+        try:
+            self._ensure_input().grab()
+        except Exception as ex:
+            # roll back any partial interface claim so the pad is never left half-detached, and force a
+            # clean rebuild of the source next time (the cached handle may be wedged).
+            self._drop_input("grab failed: %s" % ex)
+            self.note = "grab failed: %s" % ex
+            return
+        self.forwarding = True
+        self.fwd_slot = slot
+        self.send(frame.build_set_forwarding(True))
+        self.note = "forwarding -> slot %s (%s)" % (slot, self.input.name)
+        self._flog("APP forwarding START slot=%s input=%s" % (slot, self.input.name))
+
+    def _release_local(self):
+        """Clear forwarding state + ungrab the pad locally, WITHOUT notifying the dongle (used when the
+        dongle or input device has gone away, so there's nothing to tell)."""
+        self.forwarding = False
+        self.fwd_slot = None
+        if self.input:
+            try:
+                self.input.ungrab()
+            except Exception:
+                pass
+
+    def stop_forwarding(self):
+        """Clean, user-initiated stop: tell the firmware to stop forwarding, then release the pad."""
+        if self.forwarding:
+            self.send(frame.build_set_forwarding(False))
+        self._release_local()
+        self.note = "control returned to Deck"
+        self._flog("APP forwarding STOP")
+
+    def _drop_input(self, why):
+        """Tear down the input source and force a rebuild on the next grab, so a transient USB hiccup on
+        the Deck pad (or a failed grab) can't wedge the app behind a dead handle."""
+        self._release_local()
+        self.input = None
+        self._flog("input dropped: %s" % why)
+
+    def toggle(self, slot):
+        """Tap handler: a tappable tile is one whose puck is live (alive)."""
+        if self.forwarding:
+            self.stop_forwarding()
+        elif self.dongle_connected:
+            self.start_forwarding(slot)
+
+    def remove_bond(self, slot):
+        """GUI "remove": un-bond a puck slot on the firmware. Refused while forwarding to it."""
+        if self.forwarding and self.fwd_slot == slot:
+            return
+        self.send(frame.build_clear_bond(slot))
+        # drop it from the local model immediately so the tile disappears without waiting for STATUS
+        self.status["bonds"] = [b for b in self.status.get("bonds", []) if b["slot"] != slot]
+        self._flog("APP remove bond slot=%s" % slot)
+
+    def pump_input(self):
+        if not self.forwarding or not self.input:
+            return
+        try:
+            st = self.input.pump()
+        except Exception as ex:
+            # the pad raised (USB reset / yanked) mid-forward. Notify the firmware to stop (the dongle is
+            # likely still up, so the puck doesn't freeze on the last frame), then drop the dead handle so
+            # the next tap rebuilds it -- never let this bubble up and kill the loop.
+            self._flog("input pump error: %s" % ex)
+            self.stop_forwarding()
+            self.input = None
+            self.note = "input device error — control returned to Deck"
+            return
+        now = time.monotonic()
+        if now - self.last_input >= 1.0 / INPUT_HZ:
+            self.last_input = now
+            self.send(frame.build_input(st))
+
+    def auto_release_on_drop(self):
+        """If the RF link drops while forwarding, hand control back to the Deck."""
+        if self.forwarding and not self.status.get("link_up"):
+            # only release if the slot we're forwarding to is no longer alive
+            slot = self.fwd_slot
+            bonds = self.status.get("bonds", [])
+            alive = any(b["slot"] == slot and b["alive"] for b in bonds)
+            if not alive:
+                self.stop_forwarding()
+
+    # ---- UI -> IO thread intents (UI thread enqueues; _io_loop executes, so grab()/send() never run
+    #      concurrently with the forwarding thread's serial/libusb access) ----
+    def request_toggle(self, slot):
+        self._cmdq.append(("toggle", slot))
+
+    def request_stop(self):
+        self._cmdq.append(("stop", None))
+
+    def request_remove(self, slot):
+        self._cmdq.append(("remove", slot))
+
+    def _io_loop(self):
+        """Dedicated forwarding/serial thread, decoupled from the 60Hz UI render loop so input streams at
+        the full INPUT_HZ. SOLE owner of self.ser + self.input: the UI thread only enqueues intents."""
+        while not self._io_stop.is_set():
+            while self._cmdq:
+                try:
+                    kind, slot = self._cmdq.popleft()
+                except IndexError:
+                    break
+                try:
+                    if kind == "toggle":
+                        self.toggle(slot)
+                    elif kind == "stop":
+                        self.stop_forwarding()
+                    elif kind == "remove":
+                        self.remove_bond(slot)
+                except Exception as ex:
+                    self._flog("cmd %s error: %r" % (kind, ex))
+            try:
+                self.pump_serial()
+                self.pump_input()
+                self.auto_release_on_drop()
+            except Exception as ex:
+                self._flog("io loop error: %r" % ex)
+            # short sleep: pump_input self-throttles sends to INPUT_HZ, this just yields the CPU
+            time.sleep(0.001)
+
+    # ---- loops ----
+    def run_debug(self):
+        last = 0
+        while True:
+            try:
+                self.pump_serial()
+                self.pump_input()
+                self.auto_release_on_drop()
+            except Exception as ex:
+                self._flog("debug loop error: %r" % ex)
+            if time.monotonic() - last > 0.5:
+                last = time.monotonic()
+                s = self.status
+                tiles = ", ".join(
+                    "[%d %s%s]" % (b["slot"], b["serial"] or "?", " LIVE" if b["alive"] else "")
+                    for b in s["bonds"]) or "(no bonds)"
+                print("dongle=%s link=%s fwd=%s ch=%s %s | %s" %
+                      ("yes" if self.dongle_connected else "NO", s["link_up"],
+                       self.forwarding, s["sess_ch"], tiles, self.note))
+            time.sleep(0.002)
+
+    def run_ui(self):
+        import ui
+        self._io_stop.clear()
+        self._io_thread = threading.Thread(target=self._io_loop, daemon=True)
+        self._io_thread.start()
+        try:
+            ui.run(self)
+        finally:
+            self._io_stop.set()
+            self._io_thread.join(timeout=1.0)
+
+
+def main():
+    ap = argparse.ArgumentParser(description="Steam Deck forwarder for ReversePuck")
+    ap.add_argument("--port", help="CDC port (default: autodetect Valve 28DE:1302)")
+    ap.add_argument("--input", help="force an evdev node, e.g. /dev/input/event12 (only with --evdev)")
+    ap.add_argument("--evdev", action="store_true",
+                    help="use the evdev fallback instead of the libusb DeckSource (lower fidelity, "
+                    "no USB-level detach)")
+    ap.add_argument("--debug", action="store_true", help="headless status print, no UI")
+    ap.add_argument("--logfile", default="openctrl.log",
+                    help="append firmware log + app events here (default: openctrl.log in this dir)")
+    args = ap.parse_args()
+    app = App(args)
+    try:
+        if args.debug:
+            app.run_debug()
+        else:
+            app.run_ui()
+    except KeyboardInterrupt:
+        pass
+    finally:
+        if app.forwarding:
+            app.stop_forwarding()
+        # belt-and-suspenders: never exit with the Deck pad still detached from Steam
+        if app.input:
+            try:
+                app.input.ungrab()
+            except Exception:
+                pass
+
+
+if __name__ == "__main__":
+    main()

+ 270 - 0
ReversePuck/scpair.py

@@ -0,0 +1,270 @@
+#!/usr/bin/env python3
+"""scpair.py -- Linux hidraw steamless pairing for the Steam Controller 2 puck + (emulated) controller.
+
+The Linux equivalent of pairtui/scmd.c (which is macOS/IOKit). Talks the Valve feature-report command
+channel on the usagePage-0x01 control interface over /dev/hidraw* (HIDIOCSFEATURE / HIDIOCGFEATURE).
+Used to pair on the Steam Deck itself when BOTH the puck (PID 1304) and the ReversePuck controller
+(PID 1302) are plugged into the Deck -- or run it on any Linux box.
+
+Pairing flow (protocol/USB_COMMANDS.md, == pairtui.pair_full):
+  1. fresh 8-byte key (r1,r2)
+  2. puck   slot N  (rid 2):  0xA2 [r1][r2][controller_serial 16]
+  3. ctrl   (rid 1):          0xEE ["esb/bond"\\0][r1][r2][puck_serial 16]  then  0xEF ["esb/bond"\\0]
+  4. ctrl   (rid 1):          0x95 52 AF 27 A4   (reboot into wireless)
+
+    sudo python3 scpair.py list
+    sudo python3 scpair.py pair [--slot N]
+    sudo python3 scpair.py unpair --slot N
+Needs read/write on /dev/hidraw* (run with sudo or install the udev rule in README.md).
+"""
+import argparse
+import array
+import fcntl
+import glob
+import os
+import struct
+import sys
+import time
+
+# ---- Linux ioctl numbers for hidraw (asm-generic _IOC) ----
+_IOC_NONE, _IOC_WRITE, _IOC_READ = 0, 1, 2
+
+
+def _IOC(d, t, nr, size):
+    return (d << 30) | (size << 16) | (ord(t) << 8) | nr
+
+
+def _IOR(t, nr, size):
+    return _IOC(_IOC_READ, t, nr, size)
+
+
+def _IOC_SF(length):
+    return _IOC(_IOC_READ | _IOC_WRITE, 'H', 0x06, length)  # HIDIOCSFEATURE
+
+
+def _IOC_GF(length):
+    return _IOC(_IOC_READ | _IOC_WRITE, 'H', 0x07, length)  # HIDIOCGFEATURE
+
+
+HIDIOCGRDESCSIZE = _IOR('H', 0x01, 4)
+# HIDIOCGRDESC: struct hidraw_report_descriptor { __u32 size; __u8 value[4096]; }
+HIDIOCGRDESC = _IOR('H', 0x02, 4 + 4096)
+
+VALVE_VID = 0x28DE
+PUCK_PID = 0x1304
+CTRL_PIDS = (0x1302, 0x1301, 0x1303, 0x1205)
+FEAT_LEN = 64  # Valve feature reports are exactly 64 bytes
+
+
+def _sysfs_ids(node):
+    """Return (vid, pid) for a /dev/hidrawN node, or (None, None)."""
+    name = os.path.basename(node)
+    try:
+        # /sys/class/hidraw/hidrawN/device/uevent has HID_ID=0003:000028DE:00001304
+        ue = open("/sys/class/hidraw/%s/device/uevent" % name).read()
+    except Exception:
+        return (None, None)
+    for line in ue.splitlines():
+        if line.startswith("HID_ID="):
+            parts = line.split(":")
+            try:
+                return (int(parts[1], 16) & 0xFFFF, int(parts[2], 16) & 0xFFFF)
+            except Exception:
+                return (None, None)
+    return (None, None)
+
+
+def _first_usage_page(fd):
+    """Parse the report descriptor's first Usage Page item. 0x01=generic desktop (control), 0xFF00=vendor."""
+    try:
+        size = array.array('i', [0])
+        fcntl.ioctl(fd, HIDIOCGRDESCSIZE, size, True)
+        buf = bytearray(4 + 4096)
+        struct.pack_into("<I", buf, 0, size[0])
+        fcntl.ioctl(fd, HIDIOCGRDESC, buf, True)
+        n = struct.unpack_from("<I", buf, 0)[0]
+        d = buf[4:4 + n]
+        i = 0
+        while i < len(d):
+            b = d[i]
+            if b == 0x05:  # Usage Page, 1-byte
+                return d[i + 1]
+            if b == 0x06:  # Usage Page, 2-byte
+                return d[i + 1] | (d[i + 2] << 8)
+            # skip item by its size (low 2 bits)
+            i += 1 + (b & 0x03)
+    except Exception:
+        pass
+    return None
+
+
+class HidDev:
+    def __init__(self, node, vid, pid):
+        self.node = node
+        self.vid = vid
+        self.pid = pid
+        self.fd = os.open(node, os.O_RDWR)
+        self.usage_page = _first_usage_page(self.fd)
+
+    def close(self):
+        try:
+            os.close(self.fd)
+        except Exception:
+            pass
+
+    def set_feature(self, rid, cmd, payload=b""):
+        buf = bytearray(FEAT_LEN)
+        buf[0] = rid
+        buf[1] = cmd
+        buf[2] = len(payload)
+        buf[3:3 + len(payload)] = bytes(payload)
+        fcntl.ioctl(self.fd, _IOC_SF(FEAT_LEN), buf, True)
+
+    def get_feature(self, rid):
+        buf = bytearray(FEAT_LEN)
+        buf[0] = rid
+        fcntl.ioctl(self.fd, _IOC_GF(FEAT_LEN), buf, True)
+        return bytes(buf)
+
+    def read_serial(self, rid):
+        """0xAE idx 1 -> unit serial string."""
+        try:
+            self.set_feature(rid, 0xAE, bytes([0x01]))
+            r = self.get_feature(rid)
+            # reply [rid][AE][len][idx][serial...]
+            return r[4:4 + 16].split(b"\x00")[0].decode("latin1", "replace")
+        except Exception:
+            return ""
+
+
+def enumerate_devices():
+    """Return dict role -> list[HidDev]: {'puck':[...], 'ctrl':[...]} (control interfaces only)."""
+    out = {"puck": [], "ctrl": []}
+    for node in sorted(glob.glob("/dev/hidraw*")):
+        vid, pid = _sysfs_ids(node)
+        if vid != VALVE_VID:
+            continue
+        try:
+            dev = HidDev(node, vid, pid)
+        except PermissionError:
+            print("permission denied on %s (run with sudo or install the udev rule)" % node, file=sys.stderr)
+            continue
+        except Exception:
+            continue
+        # control interface only (usagePage 0x01); skip the vendor 0xFF00 diagnostic interface
+        if dev.usage_page not in (0x01, None):
+            dev.close()
+            continue
+        if pid == PUCK_PID:
+            out["puck"].append(dev)
+        elif pid in CTRL_PIDS:
+            out["ctrl"].append(dev)
+        else:
+            dev.close()
+    return out
+
+
+def _ser16(serial):
+    b = serial.encode("latin1", "replace")[:16]
+    return b + b"\x00" * (16 - len(b))
+
+
+def read_slots(puck):
+    """Read the puck's 4 bond slots (each puck control interface == one slot). Returns list of dicts."""
+    slots = []
+    for i, dev in enumerate(sorted(puck, key=lambda d: d.node)):
+        try:
+            dev.set_feature(2, 0xA3)
+            r = dev.get_feature(2)  # [02 A3 18 <8 uuid><16 serial>]
+            rec = r[3:3 + 24]
+            used = any(rec[0:8])
+            serial = rec[8:24].split(b"\x00")[0].decode("latin1", "replace") if used else ""
+            slots.append({"idx": i, "used": used, "serial": serial, "rec": rec})
+        except Exception as ex:
+            slots.append({"idx": i, "used": False, "serial": "", "rec": b"", "err": str(ex)})
+    return slots
+
+
+def pair(slot=None):
+    devs = enumerate_devices()
+    pucks = sorted(devs["puck"], key=lambda d: d.node)
+    ctrls = sorted(devs["ctrl"], key=lambda d: d.node)
+    if not pucks:
+        raise SystemExit("no puck (28DE:1304) control interface found")
+    if not ctrls:
+        raise SystemExit("no controller (28DE:1302) control interface found — plug the ReversePuck controller in")
+    ctrl = ctrls[0]
+    puck_serial = pucks[0].read_serial(2) or "FXB000000000"
+    ctrl_serial = ctrl.read_serial(1) or "FXA000000000"
+
+    if slot is None:
+        slots = read_slots(pucks)
+        slot = next((s["idx"] for s in slots if not s["used"]), 0)
+    if slot >= len(pucks):
+        raise SystemExit("slot %d has no puck control interface" % slot)
+
+    r = os.urandom(8)
+    key = b"esb/bond\x00" if slot == 0 else b"esb/bond_2\x00"
+    print("pairing puck %s slot %d <-> controller %s  key=%s" %
+          (puck_serial, slot, ctrl_serial, r.hex()))
+
+    # puck slot: 0xA2 [r1 r2][ctrl serial]
+    pucks[slot].set_feature(2, 0xA2, r + _ser16(ctrl_serial))
+    # controller esb/bond: 0xEE [key\0][r1 r2][puck serial] + 0xEF [key\0]
+    ctrl.set_feature(1, 0xEE, key + r + _ser16(puck_serial))
+    ctrl.set_feature(1, 0xEF, key)
+    time.sleep(0.1)
+    # reboot controller into wireless mode (magic 0xA427AF52)
+    ctrl.set_feature(1, 0x95, bytes([0x52, 0xAF, 0x27, 0xA4]))
+    print("paired. controller rebooting into wireless — move the puck to your host.")
+
+
+def unpair(slot):
+    devs = enumerate_devices()
+    pucks = sorted(devs["puck"], key=lambda d: d.node)
+    if slot >= len(pucks):
+        raise SystemExit("slot %d has no puck control interface" % slot)
+    pucks[slot].set_feature(2, 0xA2, b"\x00" * 24)  # clear puck slot
+    # also clear the controller bond if present
+    for ctrl in devs["ctrl"]:
+        key = b"esb/bond\x00" if slot == 0 else b"esb/bond_2\x00"
+        try:
+            ctrl.set_feature(1, 0xEE, key + b"\x00" * 24)
+            ctrl.set_feature(1, 0xEF, key)
+        except Exception:
+            pass
+    print("cleared slot %d" % slot)
+
+
+def cmd_list():
+    devs = enumerate_devices()
+    pucks = sorted(devs["puck"], key=lambda d: d.node)
+    ctrls = sorted(devs["ctrl"], key=lambda d: d.node)
+    print("controllers:", [(d.node, "%04X" % d.pid, d.read_serial(1)) for d in ctrls] or "(none)")
+    if pucks:
+        print("puck:", pucks[0].read_serial(2))
+        for s in read_slots(pucks):
+            print("  slot %d: %s" % (s["idx"], (s["serial"] + " (bonded)") if s["used"] else "empty"))
+    else:
+        print("puck: (none)")
+
+
+def main():
+    ap = argparse.ArgumentParser(description="Linux hidraw steamless pairing")
+    sub = ap.add_subparsers(dest="cmd", required=True)
+    sub.add_parser("list")
+    p = sub.add_parser("pair")
+    p.add_argument("--slot", type=int, default=None)
+    u = sub.add_parser("unpair")
+    u.add_argument("--slot", type=int, required=True)
+    args = ap.parse_args()
+    if args.cmd == "list":
+        cmd_list()
+    elif args.cmd == "pair":
+        pair(args.slot)
+    elif args.cmd == "unpair":
+        unpair(args.slot)
+
+
+if __name__ == "__main__":
+    main()

+ 28 - 0
ReversePuck/setup.sh

@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# Per-boot device-permission grant for SteamOS (read-only rootfs -> no persistent udev rule).
+# Run ONCE PER BOOT from Desktop Mode:   sudo ./setup.sh
+#
+# Opens, for this boot only (nothing on the read-only FS changes -- re-run after a reboot):
+#   * /dev/ttyACM*                      -- the nRF (ReversePuck) CDC link
+#   * the Deck controller's usbfs node  -- so the app's libusb DeckSource can detach + read it
+#                                          (28DE:1205); this is what lets it take the pad from Steam
+# /dev/input/event* is already granted to the active session (logind ACL) for the --evdev fallback.
+set -e
+shopt -s nullglob
+
+for f in /dev/ttyACM*; do chmod a+rw "$f" && echo "opened CDC $f"; done
+
+# Find the Steam Deck controller (Valve 28DE:1205) usbfs node and open it for libusb.
+opened=0
+for dd in /sys/bus/usb/devices/*; do
+	[ -r "$dd/idVendor" ] || continue
+	if [ "$(cat "$dd/idVendor" 2>/dev/null)" = "28de" ] &&
+	   [ "$(cat "$dd/idProduct" 2>/dev/null)" = "1205" ]; then
+		busnum=$(cat "$dd/busnum"); devnum=$(cat "$dd/devnum")
+		node=$(printf "/dev/bus/usb/%03d/%03d" "$busnum" "$devnum")
+		chmod a+rw "$node" && echo "opened controller usbfs $node ($dd)" && opened=1
+	fi
+done
+[ "$opened" = 1 ] || echo "WARNING: Deck controller 28DE:1205 not found on USB (is it the built-in pad?)"
+
+echo "done (this boot only)."

+ 172 - 0
ReversePuck/ui.py

@@ -0,0 +1,172 @@
+"""ui.py -- fullscreen touchscreen UI for the Steam Deck forwarder.
+
+Big tappable tiles, one per bonded puck. A tile is tappable when its puck is LIVE (the RF link is up).
+Tapping a live tile detaches the Deck controls and forwards to that puck; tapping anywhere while
+forwarding stops and returns control to the Deck. Sized for the Deck's 1280x800 touchscreen but scales.
+"""
+import time
+import pygame
+
+BG = (16, 18, 24)
+FG = (230, 232, 238)
+DIM = (120, 126, 140)
+ACCENT = (90, 170, 255)
+LIVE = (70, 200, 120)
+WARN = (235, 180, 70)
+STOP = (235, 90, 90)
+TILE = (32, 36, 46)
+TILE_LIVE = (28, 54, 40)
+TILE_SEL = (40, 70, 110)
+
+
+def _font(sz, bold=False):
+    f = pygame.font.SysFont("noto sans,dejavusans,sans", sz, bold=bold)
+    return f
+
+
+def run(app):
+    pygame.init()
+    pygame.display.set_caption("ReversePuck · Steam Deck")
+    screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
+    W, H = screen.get_size()
+    clock = pygame.time.Clock()
+    f_title = _font(int(H * 0.05), bold=True)
+    f_big = _font(int(H * 0.045), bold=True)
+    f_med = _font(int(H * 0.03))
+    f_small = _font(int(H * 0.022))
+
+    tile_rects = []  # (rect, slot, tappable)
+    del_rects = []   # (rect, slot) -- the per-tile "remove bond" ✕ target
+
+    def draw():
+        nonlocal tile_rects, del_rects
+        screen.fill(BG)
+        s = app.status
+        connected = app.dongle_connected
+        # compact status line (no big title banner): dongle presence first, then RF link
+        if not connected:
+            badge, col = "⨯ no dongle", WARN
+        elif s["link_up"]:
+            badge, col = "● linked  ch%d" % s["sess_ch"], LIVE
+        else:
+            badge, col = "○ searching…", DIM
+        t = f_med.render(badge, True, col)
+        screen.blit(t, (W - t.get_width() - 40, 24))
+
+        # tiles -- live pucks float to the top, then by slot index
+        tile_rects = []
+        del_rects = []
+        bonds = sorted(s["bonds"], key=lambda b: (not b["alive"], b["slot"]))
+        top = int(H * 0.16)
+        gap = int(H * 0.025)
+        th = int((H * 0.42 - gap * max(0, len(bonds) - 1)) / max(1, len(bonds)))
+        th = min(th, int(H * 0.20))
+        if not connected:
+            msg = f_big.render("Plug in the ReversePuck dongle", True, WARN)
+            screen.blit(msg, (W // 2 - msg.get_width() // 2, H // 2 - 40))
+            sub = f_small.render("Waiting for the nRF (Valve 28DE:1302) on USB… it'll appear here automatically.",
+                                 True, DIM)
+            screen.blit(sub, (W // 2 - sub.get_width() // 2, H // 2 + 20))
+        elif not bonds:
+            msg = f_big.render("No paired pucks", True, DIM)
+            screen.blit(msg, (W // 2 - msg.get_width() // 2, H // 2 - 40))
+            sub = f_small.render("Pair on a computer with Steam or PairTUI (scpair.py), then plug the puck into your host.",
+                                 True, DIM)
+            screen.blit(sub, (W // 2 - sub.get_width() // 2, H // 2 + 20))
+        for n, b in enumerate(bonds):
+            slot = b["slot"]
+            y = top + n * (th + gap)
+            rect = pygame.Rect(40, y, W - 80, th)
+            sel = app.forwarding and app.fwd_slot == slot
+            tappable = b["alive"] and not app.forwarding
+            color = TILE_SEL if sel else (TILE_LIVE if b["alive"] else TILE)
+            pygame.draw.rect(screen, color, rect, border_radius=18)
+            pygame.draw.rect(screen, (ACCENT if (tappable or sel) else DIM), rect, width=2, border_radius=18)
+            # glyph + serial
+            glyph = "●" if b["alive"] else "○"
+            gcol = LIVE if b["alive"] else DIM
+            screen.blit(f_big.render(glyph, True, gcol), (rect.x + 30, rect.y + th // 2 - 22))
+            screen.blit(f_big.render(b["serial"] or "puck %d" % slot, True, FG),
+                        (rect.x + 90, rect.y + 20))
+            state = ("FORWARDING — tap to stop" if sel
+                     else "available — tap to forward" if tappable
+                     else "live (forwarding elsewhere)" if b["alive"]
+                     else "paired · offline")
+            screen.blit(f_small.render("slot %d · %s" % (slot, state), True, DIM),
+                        (rect.x + 90, rect.y + th - 38))
+            tile_rects.append((rect, slot, tappable or sel))
+            # remove-bond ✕ on the far right (only when idle -- can't un-bond while forwarding)
+            if not app.forwarding:
+                dsz = min(th - 24, 64)
+                drect = pygame.Rect(rect.right - dsz - 18, rect.y + (th - dsz) // 2, dsz, dsz)
+                pygame.draw.rect(screen, TILE, drect, border_radius=12)
+                pygame.draw.rect(screen, STOP, drect, width=2, border_radius=12)
+                x = f_big.render("✕", True, STOP)
+                screen.blit(x, (drect.centerx - x.get_width() // 2,
+                                drect.centery - x.get_height() // 2))
+                del_rects.append((drect, slot))
+
+        # firmware log panel (the in-app serial monitor) — fills the area below the tiles
+        log_top = int(H * 0.60)
+        log_bot = H - int(H * (0.12 if app.forwarding else 0.02))
+        lh = f_small.get_height() + 2
+        nlines = max(1, (log_bot - log_top - 24) // lh)
+        screen.blit(f_small.render("firmware log", True, DIM), (40, log_top))
+        lines = app.log[-nlines:]
+        for i, ln in enumerate(lines):
+            # colorize the lines that matter for connecting
+            c = DIM
+            if "SESSION live" in ln or "CONNECTED" in ln:
+                c = LIVE
+            elif "adopted" in ln or "E1 heard" in ln:
+                c = ACCENT
+            elif "lost" in ln or "no bond" in ln or "fail" in ln:
+                c = WARN
+            screen.blit(f_small.render(ln[:120], True, c), (40, log_top + 24 + i * lh))
+
+        # forwarding banner (drawn last so it sits on top)
+        if app.forwarding:
+            bar = pygame.Rect(0, H - int(H * 0.1), W, int(H * 0.1))
+            pygame.draw.rect(screen, STOP, bar)
+            msg = f_big.render("FORWARDING — Deck controls detached · tap anywhere to stop", True, (255, 255, 255))
+            screen.blit(msg, (W // 2 - msg.get_width() // 2, bar.y + bar.height // 2 - 22))
+        pygame.display.flip()
+
+    def handle_tap(pos):
+        # Taps only ENQUEUE intents; the IO thread runs the actual grab()/send() so they never race the
+        # forwarding thread's serial/libusb access.
+        if app.forwarding:
+            app.request_stop()
+            return
+        # the remove-bond ✕ takes priority over the tile body it sits on
+        for drect, slot in del_rects:
+            if drect.collidepoint(pos):
+                app.request_remove(slot)
+                return
+        for rect, slot, tappable in tile_rects:
+            if rect.collidepoint(pos) and tappable:
+                app.request_toggle(slot)
+                return
+
+    running = True
+    while running:
+        for ev in pygame.event.get():
+            if ev.type == pygame.QUIT:
+                running = False
+            elif ev.type == pygame.KEYDOWN and ev.key in (pygame.K_ESCAPE, pygame.K_q):
+                running = False
+            elif ev.type == pygame.MOUSEBUTTONDOWN:
+                handle_tap(ev.pos)
+            elif ev.type == pygame.FINGERDOWN:
+                handle_tap((int(ev.x * W), int(ev.y * H)))
+
+        # Render only -- serial I/O + input forwarding run on app._io_loop (a separate thread) so the
+        # vsync'd flip() below can't throttle forwarding to 60Hz. A bad draw must never kill the app.
+        try:
+            draw()
+        except Exception as ex:
+            app._flog("UI draw error: %r" % ex)
+            app.note = "recovered from error: %s" % ex
+        clock.tick(60)  # UI render rate; forwarding is decoupled on the IO thread now
+
+    pygame.quit()

+ 85 - 0
ReversePuckFirmware/ReversePuckFirmware.ino

@@ -0,0 +1,85 @@
+// ReversePuckFirmware.ino -- emulated Steam Controller 2 ("Triton"/IBEX) for an nRF52840 Pro Micro.
+//
+// The mirror image of OpenPuck: instead of receiving a real controller over RF and presenting it to a
+// host, this firmware RECEIVES input from a Steam Deck over USB-CDC and TRANSMITS it over the puck RF
+// link, so a puck (real Valve or OpenPuck) presents a "Steam Controller 2026" to whatever host the puck
+// is plugged into. See ARCHITECTURE.md (OpenPuck) for the puck side; the protocol is symmetric.
+//
+//   identity / ctrl_bonds   -- FXA controller serial, the (up to 2) bonds to pucks (esb/bond[_2])
+//   radio / ctrl_link       -- shared bare-metal RADIO; the inverse RF role (answer the puck's polls)
+//   triton / report45       -- the shared input model g_in + the report-0x45 ENCODER
+//   deck_input              -- the USB-CDC frame link to the deck/ forwarder app
+//   ctrl_usb                -- the USB 28DE:1302 face + steamless pairing command channel
+//
+// Build:  arduino-cli compile -b adafruit:nrf52:feather52840 \
+//           --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb}" ReversePuckFirmware
+#include <Adafruit_TinyUSB.h>
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
+using namespace Adafruit_LittleFS_Namespace;
+
+#include "identity.h"
+#include "radio.h"
+#include "ctrl_bonds.h"
+#include "ctrl_usb.h"
+#include "ctrl_link.h"
+#include "deck_input.h"
+
+// CDC (Deck link) + one HID control interface fit well within the default config buffer; use a modest
+// fixed buffer so enumeration is deterministic.
+static uint8_t g_usbCfgDesc[256];
+
+void setup()
+{
+	genSerial();
+	// safe default; the real session addr is adopted from the puck's E1
+	rfGenSessionAddr();
+	InternalFS.begin();
+	loadCtrlBonds();
+
+	// ---- USB: keep CDC (the Deck link) + add the Valve control HID; enumerate as 28DE:1302 ----
+	USBDevice.detach();
+	delay(30);
+	USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);
+	// FXA… controller pairing identity
+	USBDevice.setSerialDescriptor(g_unit);
+	ctrlUsbBegin();
+	USBDevice.setConfigurationAttribute(
+		0x80 | 0x20); // bus-powered | remote-wakeup
+	USBDevice.attach();
+
+	Serial.begin(115200);
+	for (int i = 0; i < 300 && !USBDevice.mounted(); i++)
+		delay(10); // wait up to 3s for mount, but NEVER hang
+
+	int nb = 0;
+	for (int i = 0; i < NBOND; i++)
+		if (g_bond[i].used)
+			nb++;
+	Serial.printf(
+		"# ReversePuck controller up: unit=%s bonds=%d (28DE:1302)\n",
+		g_unit, nb);
+
+	// Hardware watchdog (~8s): a wedged RX busy-wait or blocked CDC write self-recovers without a replug.
+	NRF_WDT->CONFIG = (WDT_CONFIG_HALT_Pause << WDT_CONFIG_HALT_Pos) |
+			  (WDT_CONFIG_SLEEP_Run << WDT_CONFIG_SLEEP_Pos);
+	NRF_WDT->CRV = 8UL * 32768UL - 1;
+	NRF_WDT->RREN = WDT_RREN_RR0_Msk;
+	NRF_WDT->TASKS_START = 1;
+}
+
+void loop()
+{
+	NRF_WDT->RR[0] = WDT_RR_RR_Reload; // feed the watchdog
+
+	// flush dirty bonds / honor a pending reboot-to-wireless
+	ctrlUsbPoll();
+	// drain CDC: parse Deck input frames into g_in + forwarding state
+	deckInputPoll();
+	// RF: hunt/adopt the puck's E1, answer one poll with report 0x45
+	ctrlLinkTask();
+	// report bond + link state back to the Deck UI
+	deckStatusTask();
+	// flush the newest relayed haptic to the Deck (rate-limited, off the RF path)
+	deckHapticTask();
+}

+ 80 - 0
ReversePuckFirmware/ctrl_bonds.cpp

@@ -0,0 +1,80 @@
+#include "ctrl_bonds.h"
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
+#include <string.h>
+using namespace Adafruit_LittleFS_Namespace;
+
+CtrlBond g_bond[NBOND];
+volatile bool g_bondDirty = false;
+
+#define CTRLBOND_FILE "/ctrlbond.bin"
+
+bool ctrlRecEmpty(const uint8_t *r)
+{
+	for (int i = 0; i < 24; i++)
+		if (r[i])
+			return false;
+	return true;
+}
+
+void saveCtrlBonds()
+{
+	InternalFS.remove(CTRLBOND_FILE);
+	File f(InternalFS);
+	if (f.open(CTRLBOND_FILE, FILE_O_WRITE)) {
+		for (int i = 0; i < NBOND; i++) {
+			uint8_t u = g_bond[i].used ? 1 : 0;
+			f.write(&u, 1);
+			f.write(g_bond[i].rec, 24);
+		}
+		f.close();
+	}
+}
+
+void loadCtrlBonds()
+{
+	File f(InternalFS);
+	if (f.open(CTRLBOND_FILE, FILE_O_READ))
+		for (int i = 0; i < NBOND; i++) {
+			uint8_t u = 0;
+			if (f.read(&u, 1) == 1) {
+				g_bond[i].used = u;
+				f.read(g_bond[i].rec, 24);
+			}
+		}
+	f.close();
+}
+
+int ctrlBondMatch(const uint8_t *proteus_uuid, const uint8_t *ibex_uuid)
+{
+	for (int i = 0; i < NBOND; i++) {
+		if (!g_bond[i].used)
+			continue;
+		if (memcmp(g_bond[i].rec + 0, proteus_uuid, 4) == 0 &&
+		    memcmp(g_bond[i].rec + 4, ibex_uuid, 4) == 0)
+			return i;
+	}
+	return -1;
+}
+
+int ctrlBondFindOrAlloc(const uint8_t *rec24)
+{
+	// re-writing a known puck (same 8-byte uuid key) updates its slot in place
+	for (int i = 0; i < NBOND; i++)
+		if (g_bond[i].used && memcmp(g_bond[i].rec, rec24, 8) == 0)
+			return i;
+	// otherwise take the first free slot
+	for (int i = 0; i < NBOND; i++)
+		if (!g_bond[i].used)
+			return i;
+	return -1; // bond table full
+}
+
+void ctrlBondClear(int slot)
+{
+	if (slot < 0 || slot >= NBOND)
+		return;
+	g_bond[slot].used = false;
+	memset(g_bond[slot].rec, 0, 24);
+	g_bondDirty = true;
+}

+ 34 - 0
ReversePuckFirmware/ctrl_bonds.h

@@ -0,0 +1,34 @@
+// ctrl_bonds.h -- the emulated controller's bonds to pucks.
+//
+// A real Steam Controller bonds to up to two pucks under the Zephyr settings keys `esb/bond` (slot 0)
+// and `esb/bond_2` (slot 1). We support MORE: an array of slots, each holding the 24-byte bond record
+//   rec = [proteus_uuid 4 LE][ibex_uuid 4 LE][puck_serial 16]
+// -- the SAME 24-byte shape Steam writes to the puck via 0xA2, except the trailing serial is the
+// PUCK's (the peer), not the controller's. The 8-byte uuid key is identical on both sides and is what
+// matches the puck's E1 host frame at reconnect. Pairing a new puck find-or-allocates a free slot by
+// uuid (so an arbitrary number can be bonded); the Deck GUI can clear a slot. Persists to LittleFS
+// `/ctrlbond.bin`.
+#pragma once
+#include <stdint.h>
+
+#define NBOND 8
+
+struct CtrlBond {
+	uint8_t rec[24]; // [proteus_uuid 4][ibex_uuid 4][puck_serial 16]
+	bool used;
+	// feature-report reply staging (filled in ISR, read on GET)
+	uint8_t resp[63];
+	uint16_t resp_len;
+};
+extern CtrlBond g_bond[NBOND];
+extern volatile bool g_bondDirty; // bonds changed -> flush to flash from loop()
+
+bool ctrlRecEmpty(const uint8_t *r);
+void loadCtrlBonds();
+void saveCtrlBonds();
+// Find a bond slot matching a puck E1 host frame's [proteus_uuid 4][ibex_uuid 4]; -1 if none.
+int ctrlBondMatch(const uint8_t *proteus_uuid, const uint8_t *ibex_uuid);
+// Slot for a 24-byte record: reuse the slot whose uuid (rec[0..8]) matches, else first free; -1 if full.
+int ctrlBondFindOrAlloc(const uint8_t *rec24);
+// Clear (un-bond) a slot and mark the store dirty. Out-of-range slots are ignored.
+void ctrlBondClear(int slot);

+ 607 - 0
ReversePuckFirmware/ctrl_link.cpp

@@ -0,0 +1,607 @@
+#include "ctrl_link.h"
+#include "radio.h"
+#include "ctrl_bonds.h"
+#include "ctrl_usb.h"
+#include "report45.h"
+#include "deck_input.h"
+#include "triton.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+int g_linkSlot = -1;
+unsigned long g_linkAliveMs = 0;
+
+// Link is considered up while we've heard a poll within this window (the puck polls ~250 Hz).
+#define LINK_ALIVE_MS 1000u
+// RX dwell per loop. One transaction (catch a poll, reply) per loop -> ~200 Hz, plenty for input, and
+// short enough that USB/CDC servicing isn't starved. Tunable on hardware.
+#define RX_WIN_US 4500u
+// Time the puck's RX window stays open for our reply; keep the turnaround well under this.
+static uint8_t g_seq = 0;
+// Last time we caught a frame on the SESSION address (NOT the ch2 discovery beacon). This is the
+// truthful "we're actually exchanging with the puck" signal that drives ctrlLinkUp() / the UI
+// "available" tile -- hearing the discovery beacon alone does NOT mean the session works.
+static unsigned long g_sessRxMs = 0;
+
+// SESSION-CHANNEL HUNT. The E1 host frame advertises a "primary" channel, but that channel mostly
+// carries the puck's E1/E2 keepalive -- the ACTIVE session (E3 input poll + our F1 reply) rides a small
+// channel set, NOT necessarily the advertised primary (proven by the real-puck capture sniff1.json:
+// E1/E2 on ch68, the E3/F1 session on ch48; and by puck_sniffer.ino's session-hunt). So once we adopt a
+// session we DWELL across {primary, then these candidates} on the session address until a poll (E2/E3)
+// appears, then PARK on that channel. If the parked channel goes dry (QoS hop / puck moved), un-park and
+// hunt again. Mirrors puck_sniffer.ino HUNT_CH.
+static const uint8_t HUNT_CH[] = { 2, 80, 18, 46, 76, 22, 68, 52, 55, 56, 57 };
+static uint8_t g_huntIdx = 0;
+static uint8_t g_parkCh = 0; // 0 = hunting; non-zero = parked on this channel
+static unsigned long g_huntStepMs = 0;
+#define HUNT_DWELL_MS 40u // per-channel dwell while hunting
+#define RECONNECT_MS \
+	4000u // dry this long -> abandon the session, re-catch E1 on ch2
+
+// connected-mode frame-type diagnostics (reset + printed each second in the status line)
+static uint16_t g_cntE2, g_cntE3, g_cntE7, g_cntE4, g_cntOut, g_cntOther;
+// E4 hops we actually ADOPTED this second (followed the puck's commanded channel)
+static uint16_t g_cntE4follow;
+// of the E3 polls: how many were bare/0x45 INPUT polls vs feature/identity GETs (0x83/0xAE/0x87). Tells
+// us whether the puck is actually polling for input or stuck looping enumeration GETs.
+static uint16_t g_cntE3in, g_cntE3get;
+// last non-0x45 report id the puck asked for via an E3 GET sub-TLV (0 = none)
+static uint8_t g_getReq = 0;
+// distinct channels seen in E4 channel-map commands this second (128-bit) -> reveals the hop pattern:
+// a small fixed set is followable; a large/changing set is the unsolved fast-AFH sequence.
+static uint8_t g_e4set[16];
+// 256-bit: frame types we've already dumped a sample of
+static uint8_t g_seenDump[32];
+// 256-bit: GET report ids we've dumped a poll+response sample for
+static uint8_t g_seenReq[32];
+
+// ---- build replies into rftx (ESB DPL RAM layout: [LENGTH][S1][payload...]) ----
+static void buildF1()
+{
+	// payload = [0xF1][tlen][ttype=6][report45(46)]  -> length = 3 + REPORT45_LEN
+	rftx[0] = (uint8_t)(3 + REPORT45_LEN); // LENGTH (payload byte count)
+	rftx[1] = (uint8_t)((((g_pid++) & 3) << 1) | 1); // S1: PID<<1 | NO_ACK
+	rftx[2] = 0xF1;
+	rftx[3] = REPORT45_LEN; // TLV len
+	rftx[4] = 6; // TLV type 6 (HID report)
+	// neutral report (idle controller) when not forwarding or input has gone stale
+	bool fresh = deckForwarding() && (millis() - deckLastInputMs() < 200u);
+	if (fresh) {
+		buildReport45(rftx + 5, g_seq++);
+	} else {
+		memset(rftx + 5, 0, REPORT45_LEN);
+		rftx[5] = 0x45;
+		rftx[6] = g_seq++;
+	}
+}
+
+// Build an F1 carrying a feature/command RESPONSE. The CORRECT on-wire form (sniff1.json idx 2932/2946)
+// is a TWO-TLV F1: a type-4 feature TLV FOLLOWED BY the type-6 report-0x45 input TLV. BUT that 78-byte
+// frame destabilizes OUR link because the real puck puts our session into an aggressive E4 hop storm
+// (the captured real-controller session was calm single-channel, no E4) and the long frame overruns the
+// tight per-hop window -> e3in collapses 245->60. Until the hop storm is calmed (the real blocker), use
+// the SHORTER single type-6 TLV (keeps the link stable; Steam won't accept it for enumeration, but
+// nothing enumerates on an unstable link anyway). Flip TWO_TLV_FEATURE to 1 once the link is calm.
+#define TWO_TLV_FEATURE 1
+static void buildFeatureF1(uint8_t reqid, const uint8_t *param, uint8_t plen)
+{
+	rftx[1] = (uint8_t)((((g_pid++) & 3) << 1) | 1);
+	rftx[2] = 0xF1;
+	uint8_t v[64];
+	uint8_t vlen = ctrlFeatureResp(reqid, param, plen, v);
+	if (vlen > 60)
+		vlen = 60;
+#if TWO_TLV_FEATURE
+	rftx[3] = vlen; // TLV1 len
+	rftx[4] = 0x04; // TLV1 type (feature/bulk)
+	memcpy(rftx + 5, v, vlen);
+	uint8_t *t2 =
+		rftx + 5 + vlen; // TLV2: report-0x45 input (the link heartbeat)
+	t2[0] = REPORT45_LEN;
+	t2[1] = 6;
+	bool fresh = deckForwarding() && (millis() - deckLastInputMs() < 200u);
+	if (fresh)
+		buildReport45(t2 + 2, g_seq++);
+	else {
+		memset(t2 + 2, 0, REPORT45_LEN);
+		t2[2] = 0x45;
+		t2[3] = g_seq++;
+	}
+	rftx[0] = (uint8_t)(1 + 2 + vlen + 2 + REPORT45_LEN);
+#else
+	rftx[3] = vlen; // TLV len
+	rftx[4] = 6; // type 6 (single-TLV, stable baseline)
+	memcpy(rftx + 5, v, vlen);
+	rftx[0] = (uint8_t)(3 + vlen);
+#endif
+}
+
+static void buildF3()
+{
+	// Reply to the puck's E7 awake/version probe with a STATUS report. The real puck's RX parser
+	// (proteus FUN_0000ba38, F3 branch) reads the connected-state byte at payload[1] (== rftx[3]) and
+	// only raises its internal "controller connected" signal if that byte is NONZERO -- so leaving it 0
+	// makes the puck bond us but never ACTIVATE us (no enumeration). Set payload[1]=1 (connected). Also
+	// set payload[4] (rftx[6]=1) which is where OpenPuck reads its F3 version, so this works both ways.
+	rftx[0] = 5;
+	rftx[1] = (uint8_t)((((g_pid++) & 3) << 1) | 1);
+	rftx[2] = 0xF3;
+	// payload[1] = connected state (real puck reads param_2[6]) -- MUST be nonzero
+	rftx[3] = 1;
+	rftx[4] = 0;
+	rftx[5] = 0;
+	rftx[6] = 1; // payload[4] = version (OpenPuck reads rfrx[6])
+}
+
+// Transmit whatever is staged in rftx on the currently-configured channel/address, then return to RX
+// config. Mirrors rf_link's TX path; radio is left disabled at exit.
+static void txStaged()
+{
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_DISABLE_Msk;
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->TASKS_TXEN = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+}
+
+// CAMPED continuous RX. The radio stays armed in RX across ctrlLinkTask calls (END_START auto-re-arm) so
+// it keeps listening during the loop's CDC servicing instead of being torn down each call. This raises
+// the puck-poll catch rate: the real puck AFH-hops AWAY from a channel where our replies look unreliable,
+// and the gap-misses (radio off between calls) were making the link look bad. We only (re)configure the
+// radio when the address/channel changes or after a reply; otherwise we just poll EVENTS_END.
+static bool g_rxArmed = false;
+static uint8_t g_rxArmedCh = 0;
+static const uint8_t *g_rxArmedBase = nullptr;
+static uint8_t g_rxArmedPrefix = 0;
+
+static void armRx(const uint8_t *base, uint8_t prefix, uint8_t ch)
+{
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	rfConfig(ch);
+	rfSetAddr(base, prefix);
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	rfrx[0] = 0;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
+	g_rxArmed = true;
+	g_rxArmedCh = ch;
+	g_rxArmedBase = base;
+	g_rxArmedPrefix = prefix;
+}
+
+// One RX->maybe-reply transaction on (base,prefix,ch). Returns the received type byte (rfrx[2]) or 0.
+// `reply` selects whether we answer polls (true once connected) or just listen (discovery). The radio is
+// kept CAMPED between calls (see armRx); only re-armed on an address/channel change or after a packet.
+static uint8_t rxOnce(const uint8_t *base, uint8_t prefix, uint8_t ch,
+		      uint16_t winUs, bool reply)
+{
+	if (reply) {
+		// CONNECTED: camp the radio (END_START) so it keeps listening between calls. The dispatch reads
+		// rfrx + replies INSIDE this function (before re-arm), and the connected caller never reads rfrx
+		// afterward, so clobbering rfrx on re-arm is safe.
+		if (!g_rxArmed || g_rxArmedCh != ch || g_rxArmedBase != base ||
+		    g_rxArmedPrefix != prefix)
+			armRx(base, prefix, ch);
+	} else {
+		// DISCOVERY: fresh, NON-camped RX. discoveryScan reads rfrx AFTER we return, so it must not be
+		// clobbered by an END_START auto-re-arm -- plain READY_START (radio idles after the packet).
+		g_rxArmed = false;
+		rfConfig(ch);
+		rfSetAddr(base, prefix);
+		NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+		rfrx[0] = 0;
+		NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk;
+		NRF_RADIO->EVENTS_END = 0;
+		NRF_RADIO->TASKS_RXEN = 1;
+	}
+	uint32_t t0 = micros();
+	while (!NRF_RADIO->EVENTS_END && (uint32_t)(micros() - t0) < winUs) {
+	}
+	uint8_t type = 0;
+	if (NRF_RADIO->EVENTS_END) {
+		NRF_RADIO->EVENTS_END = 0;
+		g_rfRxCount++;
+		bool crcok = NRF_RADIO->CRCSTATUS & 1;
+		uint8_t rxlen = rfrx[0];
+		if (crcok && rxlen && rxlen <= 96) {
+			type = rfrx[2];
+			if (reply) {
+				// DIAGNOSTIC: tally what the puck polls us with, and dump a sample of any frame we
+				// DON'T answer with input -- this is how we find identity/feature queries (the likely
+				// reason Steam can't enumerate the controller). Surfaced in the 1Hz status line.
+				if (type == 0xE2)
+					g_cntE2++;
+				else if (type == 0xE3) {
+					g_cntE3++;
+					// E3 may carry a GET sub-TLV [len][01=GET][reportid]; split input vs feature GET
+					if (rxlen >= 4 && rfrx[4] == 0x01 &&
+					    rfrx[5] != 0x45) {
+						g_getReq = rfrx[5];
+						g_cntE3get++;
+					} else
+						g_cntE3in++;
+				} else if (type == 0xE7)
+					g_cntE7++;
+				else if (type == 0xE4)
+					g_cntE4++;
+				else if (type >= 0x80 && type <= 0x89)
+					g_cntOut++;
+				else if (type != 0xE1)
+					g_cntOther++;
+				// dump first sample of any non-input-poll type (E1/E4/E7/output/other)
+				if (type != 0xE2 && type != 0xE3 &&
+				    !(g_seenDump[type >> 3] &
+				      (1 << (type & 7)))) {
+					g_seenDump[type >> 3] |=
+						(1 << (type & 7));
+					if (Serial.availableForWrite() > 70) {
+						Serial.printf(
+							"# RFtype %02X len%u:",
+							type, rxlen);
+						uint8_t m = rxlen + 2 < 26 ?
+								    rxlen + 2 :
+								    26;
+						for (uint8_t i = 0; i < m; i++)
+							Serial.printf("%02X",
+								      rfrx[i]);
+						Serial.println();
+					}
+				}
+
+				// E1 = host-frame keepalive (no reply expected); follow channel hops.
+				if (type == 0xE1) {
+					uint8_t newCh = rfrx[11];
+					if (newCh && newCh != g_sessCh)
+						g_sessCh = newCh;
+				} else if (type == 0xE4) {
+					// channel-map update [E4][ch][02][50]. The real puck floods this ~80/s on ONE
+					// channel right after we report connected (F3) and will NOT advance to E3 input
+					// polls until it is ACKED -- so reply F3 on the channel we received it (turnaround,
+					// like E7), THEN adopt <ch> for subsequent polls (keeping the session base/prefix;
+					// the per-timeslot base/prefix hop generator FUN_000491e0 is NOT required to
+					// interop -- IBEX_REIMPL_SPEC §5). Earlier we replied nothing here -> the puck
+					// retransmitted E4 forever -> 4s timeout -> reconnect w/ incremented counter (flap).
+					uint8_t nc = rfrx[3];
+					if (rxlen >= 2 && nc >= 1 && nc <= 80) {
+						g_e4set[nc >> 3] |=
+							(1 << (nc & 7));
+						g_cntE4follow++;
+						if (deckForwarding()) {
+							// ACK the channel map (F3) on the RX channel
+							buildF3();
+							txStaged();
+							if (!(g_seenDump[0xE4 >>
+									 3] &
+							      (1 << (0xE4 &
+								     7))) &&
+							    Serial.availableForWrite() >
+								    90) {
+								g_seenDump[0xE4 >>
+									   3] |=
+									(1
+									 << (0xE4 &
+									     7));
+								Serial.printf(
+									"# E4 ack ch=%u poll[%02X%02X%02X%02X%02X%02X] f3[%02X%02X%02X%02X%02X%02X%02X]\n",
+									nc,
+									rfrx[0],
+									rfrx[1],
+									rfrx[2],
+									rfrx[3],
+									rfrx[4],
+									rfrx[5],
+									rftx[0],
+									rftx[1],
+									rftx[2],
+									rftx[3],
+									rftx[4],
+									rftx[5],
+									rftx[6]);
+							}
+						}
+						if (nc != g_sessCh) {
+							g_sessCh = nc;
+							g_parkCh = nc;
+							g_huntStepMs = millis();
+						}
+						g_linkAliveMs = g_sessRxMs =
+							millis();
+					}
+				} else if (!deckForwarding()) {
+					// NOT forwarding -> stay SILENT (no F3/F1). We still hear the puck's polls
+					// (so the UI shows the puck "available"), but we never answer, so the puck
+					// never marks us connected and the HOST shows no controller. The controller
+					// only appears once the user taps to forward (deckForwarding() -> true).
+				} else if (type == 0xE7) {
+					buildF3();
+					txStaged();
+				} else if (type >= 0xE0 && type <= 0xEF) {
+					// E3 GET: if it requests a NON-0x45 report (Steam enumeration -> 0x83
+					// attrs / 0xAE serial / 0x87 settings), answer THAT report; a bare poll
+					// or a 0x45 request gets the input report.
+					if (type == 0xE3 && rxlen >= 4 &&
+					    rfrx[4] == 0x05) {
+						// Relayed Steam OUTPUT (SET): E3 [tlvlen][05][rid][data]. The puck
+						// forwards Steam's rumble/haptic/LED writes this way (legacy type-05
+						// form). Hand rumble (0x80) / haptic pulses (0x82/0x8F) to the Deck so
+						// it buzzes; relays are NO_ACK so we DON'T reply.
+						uint8_t rl =
+							(rfrx[3] >= 1) ?
+								(uint8_t)(rfrx[3] -
+									  1) :
+								0;
+						deckForwardHaptic(rfrx[5],
+								  &rfrx[6], rl);
+					} else if (type == 0xE3 && rxlen >= 4 &&
+						   rfrx[4] == 0x01 &&
+						   (rfrx[5] == 0x83 ||
+						    rfrx[5] == 0xAE)) {
+						// only 0x83/0xAE get a feature TLV (matches the real controller,
+						// sniff1.json); 0x87 / bare / 0x45 -> plain input below.
+						uint8_t plen =
+							(rxlen >= 5) ?
+								(uint8_t)(rxlen -
+									  4) :
+								0;
+						buildFeatureF1(rfrx[5],
+							       &rfrx[6], plen);
+						// dump the GET poll + our response once per reqid (verify framing)
+						uint8_t rq = rfrx[5];
+						if (!(g_seenReq[rq >> 3] &
+						      (1 << (rq & 7))) &&
+						    Serial.availableForWrite() >
+							    100) {
+							g_seenReq[rq >> 3] |=
+								(1 << (rq & 7));
+							Serial.printf(
+								"# GET %02X poll[",
+								rq);
+							uint8_t m =
+								rxlen + 2 < 20 ?
+									rxlen + 2 :
+									20;
+							for (uint8_t i = 0;
+							     i < m; i++)
+								Serial.printf(
+									"%02X",
+									rfrx[i]);
+							Serial.print("] resp[");
+							uint8_t r =
+								rftx[0] + 2 < 30 ?
+									rftx[0] +
+										2 :
+									30;
+							for (uint8_t i = 0;
+							     i < r; i++)
+								Serial.printf(
+									"%02X",
+									rftx[i]);
+							Serial.println("]");
+						}
+					} else {
+						buildF1();
+					}
+					txStaged();
+				}
+			}
+			// connected: re-camp on the channel the session is NOW on. CRITICAL for hop-following:
+			// an E4 we just processed set g_parkCh to the puck's NEW channel -- re-arm there
+			// IMMEDIATELY (not on `ch`, the channel we received the E4 on), else we sit a full loop
+			// iteration behind and fall off the puck's hop sequence (e3in collapses 144->0 in ~1s).
+			// discovery: freeze rfrx.
+			if (reply)
+				armRx(base, prefix, g_parkCh ? g_parkCh : ch);
+			else {
+				NRF_RADIO->TASKS_DISABLE = 1;
+				RWAIT_DISABLED();
+				NRF_RADIO->EVENTS_DISABLED = 0;
+			}
+			return type;
+		}
+		// CRC fail. connected: END_START already re-armed -> stay camped. discovery: stop.
+		if (!reply) {
+			NRF_RADIO->TASKS_DISABLE = 1;
+			RWAIT_DISABLED();
+			NRF_RADIO->EVENTS_DISABLED = 0;
+		}
+		return type;
+	}
+	// timeout, no packet. connected: stay camped (END_START keeps RX live). discovery: stop.
+	if (!reply) {
+		NRF_RADIO->TASKS_DISABLE = 1;
+		RWAIT_DISABLED();
+		NRF_RADIO->EVENTS_DISABLED = 0;
+	}
+	return type;
+}
+
+// Listen on the shared "ibex"/ch2 rendezvous for a discovery E1 that matches a stored bond. On a match,
+// adopt the advertised session base/prefix/channel and switch to connected.
+static void discoveryScan()
+{
+	uint8_t t = rxOnce(g_rfBase, g_rfPrefix, 2, RX_WIN_US, false);
+	if (t != 0xE1)
+		return;
+	const uint8_t *proteus =
+		rfrx + 3; // payload [proteus_uuid 4][ibex_uuid 4]
+	const uint8_t *ibex = rfrx + 7;
+	int slot = ctrlBondMatch(proteus, ibex);
+	if (slot < 0) {
+		// We hear the puck's beacon but its UUIDs don't match any stored bond -- the controller
+		// side of the pairing didn't land, or we're paired to a different puck. Very useful signal.
+		static unsigned long lastMiss = 0;
+		if (Serial.availableForWrite() > 40 &&
+		    millis() - lastMiss > 1000) {
+			lastMiss = millis();
+			Serial.printf(
+				"# E1 heard, no bond match: puuid=%02X%02X%02X%02X iuuid=%02X%02X%02X%02X\n",
+				proteus[0], proteus[1], proteus[2], proteus[3],
+				ibex[0], ibex[1], ibex[2], ibex[3]);
+		}
+		return;
+	}
+	// adopt the advertised session address (rfHostFrameOnce layout: ch@[11], base@[15..19], prefix@[19])
+	uint8_t advCh = rfrx[11];
+	memcpy(g_sessBase, rfrx + 15, 4);
+	g_sessPrefix = rfrx[19];
+	if (advCh)
+		g_sessCh = advCh;
+	g_linkSlot = slot;
+	g_linkAliveMs = millis();
+	g_parkCh = 0; // start hunting the session channel set from scratch
+	g_huntIdx = 0;
+	g_huntStepMs = millis();
+	deckText("link: adopted session");
+	if (Serial.availableForWrite() > 40)
+		Serial.printf(
+			"# LINK adopted slot=%d base=%02X%02X%02X%02X/%02X ch=%u\n",
+			slot, g_sessBase[0], g_sessBase[1], g_sessBase[2],
+			g_sessBase[3], g_sessPrefix, g_sessCh);
+}
+
+bool ctrlLinkUp()
+{
+	// "up" = we caught a real SESSION-address frame recently (a poll/keepalive on the adopted
+	// base+channel), not merely the ch2 discovery beacon -- so the UI tile only goes tappable when
+	// the puck is genuinely polling us.
+	return g_linkSlot >= 0 && (millis() - g_sessRxMs) < LINK_ALIVE_MS;
+}
+
+void ctrlLinkTask()
+{
+	// nothing to do until at least one puck is bonded
+	bool anyBond = false;
+	for (int i = 0; i < NBOND; i++)
+		if (g_bond[i].used)
+			anyBond = true;
+	if (!anyBond) {
+		static unsigned long lastNb = 0;
+		if (Serial.availableForWrite() > 40 &&
+		    millis() - lastNb > 2000) {
+			lastNb = millis();
+			Serial.println(
+				"# no bonds stored -- pair first (controller never got 0xEE)");
+		}
+		g_linkSlot = -1;
+		return;
+	}
+
+	// 1 Hz status line for a serial monitor: searching vs connected, channel, RX count.
+	{
+		static unsigned long stMs = 0;
+		static uint32_t lastRx = 0;
+		if (Serial.availableForWrite() > 50 &&
+		    millis() - stMs >= 1000) {
+			stMs = millis();
+			Serial.printf(
+				"# link slot=%d %s ch=%u park=%u rx=%lu/s\n",
+				g_linkSlot,
+				ctrlLinkUp() ? "CONNECTED" : "searching",
+				g_sessCh, g_parkCh,
+				(unsigned long)(g_rfRxCount - lastRx));
+			// poll-type histogram. e3in = INPUT polls (we send 0x45); e3get = identity/feature GETs.
+			// If e3in stays ~0 while e3get is high, the puck is stuck enumerating and never asks for
+			// input -> the controller can't show as active no matter what we send.
+			Serial.printf(
+				"# polls e3in=%u e3get=%u e2=%u e7=%u e4=%u(follow=%u) out=%u oth=%u getReq=%02X\n",
+				g_cntE3in, g_cntE3get, g_cntE2, g_cntE7,
+				g_cntE4, g_cntE4follow, g_cntOut, g_cntOther,
+				g_getReq);
+			// if the puck flooded E4 this second, list the distinct channels it commanded
+			if (g_cntE4) {
+				char cb[160];
+				int n = 0;
+				for (int c = 0;
+				     c <= 100 && n < (int)sizeof(cb) - 5; c++)
+					if (g_e4set[c >> 3] & (1 << (c & 7)))
+						n += snprintf(cb + n,
+							      sizeof(cb) - n,
+							      "%d ", c);
+				cb[n] = 0;
+				Serial.printf("# E4 channels: %s\n", cb);
+			}
+			memset(g_e4set, 0, sizeof g_e4set);
+			g_cntE2 = g_cntE3 = g_cntE7 = g_cntE4 = g_cntOut =
+				g_cntOther = g_cntE3in = g_cntE3get =
+					g_cntE4follow = 0;
+			lastRx = g_rfRxCount;
+			// re-arm the one-shot frame/GET dumps every ~8s so they stay capturable in the app log
+			static uint8_t reArm = 0;
+			if (++reArm >= 8) {
+				reArm = 0;
+				memset(g_seenReq, 0, sizeof g_seenReq);
+				memset(g_seenDump, 0, sizeof g_seenDump);
+			}
+		}
+	}
+
+	// No session adopted yet -> hunt the E1 host frame on the shared ch2/"ibex" rendezvous.
+	if (g_linkSlot < 0) {
+		discoveryScan();
+		return;
+	}
+
+	// Adopted a session, but the puck has gone fully dry for a long time -> abandon it and re-catch the
+	// E1 (its primary channel may have changed). Only after RECONNECT_MS so a hunt sweep has a chance.
+	if ((millis() - g_linkAliveMs) >= RECONNECT_MS) {
+		g_linkSlot = -1;
+		g_parkCh = 0;
+		g_huntIdx = 0;
+		deckText("link: lost, rescanning");
+		if (Serial.availableForWrite() > 40)
+			Serial.println("# LINK lost, rescanning ch2");
+		return;
+	}
+
+	// Pick the channel to listen on: parked channel if we have one, else sweep {primary, HUNT_CH...}.
+	uint8_t tryCh = g_parkCh ? g_parkCh :
+				   (g_huntIdx == 0 ?
+					    g_sessCh :
+					    HUNT_CH[(g_huntIdx - 1) %
+						    (uint8_t)sizeof HUNT_CH]);
+
+	uint8_t t = rxOnce(g_sessBase, g_sessPrefix, tryCh, RX_WIN_US, true);
+
+	if (t == 0xE1) {
+		// keepalive beacon on the session base: refreshes the advertised primary channel (rxOnce already
+		// copied it into g_sessCh) + proves the puck is present, but it is NOT a poll -> keep hunting.
+		g_linkAliveMs = millis();
+	} else if (t == 0xE4) {
+		// E4 already adopted ITS commanded channel inside rxOnce (g_parkCh/g_sessCh = nc). Do NOT
+		// re-park on the receive channel here -- that would undo the hop-follow. Aliveness already set.
+	} else if (t >= 0xE0 && t <= 0xEF) {
+		// a real poll (E2/E3/E7) -> the session lives on THIS channel; PARK and answer it from here.
+		bool wasUp = (millis() - g_sessRxMs) < LINK_ALIVE_MS;
+		g_parkCh = tryCh;
+		g_sessCh = tryCh;
+		g_linkAliveMs = g_sessRxMs = millis();
+		if (!wasUp) {
+			deckText("link: session live");
+			if (Serial.availableForWrite() > 40)
+				Serial.printf(
+					"# SESSION live: type=%02X ch=%u base=%02X%02X%02X%02X/%02X\n",
+					t, tryCh, g_sessBase[0], g_sessBase[1],
+					g_sessBase[2], g_sessBase[3],
+					g_sessPrefix);
+		}
+	} else {
+		// nothing this dwell. parked channel gone dry -> resume hunting (QoS hop / puck moved)
+		if (g_parkCh && (millis() - g_sessRxMs) >= LINK_ALIVE_MS)
+			g_parkCh = 0;
+		if (!g_parkCh && (millis() - g_huntStepMs) >= HUNT_DWELL_MS) {
+			g_huntStepMs = millis();
+			g_huntIdx++;
+		}
+	}
+}

+ 19 - 0
ReversePuckFirmware/ctrl_link.h

@@ -0,0 +1,19 @@
+// ctrl_link.h -- the inverse of OpenPuck's rf_link.cpp: the CONTROLLER side of the puck link.
+//
+// rf_link.cpp (puck) advertises an E1 host frame, then POLLS the controller (E7 awake, E3 GET 0x45) and
+// decodes the controller's 0xF1 reply. Here we run the mirror image: we RX the puck's E1 (adopt its
+// advertised session base/prefix/channel when it matches a stored bond), then ANSWER its polls -- F1
+// carrying report 0x45 built from the Deck's input (report45.cpp), F3 to the E7 awake/version probe.
+//
+// No RF pairing handshake: pairing is done over USB (Steam writes both sides), reconnect is purely
+// "hear an E1 with my UUIDs -> adopt -> answer polls" -- the same model rf_link relies on.
+#pragma once
+#include <stdint.h>
+
+// connected bond index (-1 = searching) and the last time we heard a poll on the session address.
+extern int g_linkSlot;
+extern unsigned long g_linkAliveMs;
+
+void ctrlLinkTask();
+// connected AND recently polled (drives the Deck UI "available" indicator + neutral-report decision).
+bool ctrlLinkUp();

+ 289 - 0
ReversePuckFirmware/ctrl_usb.cpp

@@ -0,0 +1,289 @@
+#include "ctrl_usb.h"
+#include "ctrl_bonds.h"
+#include "ctrl_link.h"
+#include "identity.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+// Same Valve HID descriptor shape the puck uses (mouse 0x40 + keyboard 0x41 + vendor input reports +
+// the 63-byte FEATURE command reports on id 1/2). The first collection is Generic Desktop (usagePage
+// 0x01), so the host classifies this as the usagePage-0x01 control interface Steam/PairTUI write to.
+static const uint8_t CTRL_HID_DESC[] = {
+	0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x40, 0x09, 0x01, 0xA1, 0x00,
+	0x05, 0x09, 0x19, 0x01, 0x29, 0x02, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
+	0x95, 0x02, 0x81, 0x02, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0x05, 0x01,
+	0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02,
+	0x81, 0x06, 0x95, 0x01, 0x09, 0x38, 0x81, 0x06, 0x05, 0x0C, 0x0A, 0x38,
+	0x02, 0x95, 0x01, 0x81, 0x06, 0xC0, 0xC0, 0x05, 0x01, 0x09, 0x06, 0xA1,
+	0x01, 0x85, 0x41, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25,
+	0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x81, 0x01, 0x19, 0x00, 0x29,
+	0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0,
+	0x06, 0x00, 0xFF, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x44,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x05, 0x09, 0x44, 0x81,
+	0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01,
+	0x09, 0x79, 0x81, 0x02, 0x85, 0x43, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81,
+	0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
+	0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91,
+	0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08,
+	0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91,
+	0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
+	0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09,
+	0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0
+};
+
+static Adafruit_USBD_HID g_hid;
+static uint8_t g_resp[63];
+static uint16_t g_resp_len = 0;
+static volatile bool g_rebootPending = false;
+// 0x95 reboots into wireless mode, which drops USB. If we reset before the host has read back the
+// command's ACK, Steam sees the disconnect mid-transaction and reports "pairing failed" even though the
+// bond is already on flash. So we wait for the host to GET the response (g_respRead), or REBOOT_GRACE_MS
+// for a host that fires 0x95 without reading back (scpair.py does this), before resetting.
+static volatile bool g_respRead = false;
+static uint32_t g_rebootAt = 0;
+static const uint32_t REBOOT_GRACE_MS = 400;
+
+// Select bond slot from a Zephyr settings key string: "esb/bond" -> 0, "esb/bond_2" -> 1.
+static int slotForKey(const char *key)
+{
+	if (strcmp(key, "esb/bond") == 0)
+		return 0;
+	if (strcmp(key, "esb/bond_2") == 0)
+		return 1;
+	return -1;
+}
+
+static void writeBond(int slot, const uint8_t *rec24)
+{
+	if (slot < 0 || slot >= NBOND)
+		return;
+	if (ctrlRecEmpty(rec24)) {
+		g_bond[slot].used = false;
+		memset(g_bond[slot].rec, 0, 24);
+	} else {
+		memcpy(g_bond[slot].rec, rec24, 24);
+		g_bond[slot].used = true;
+	}
+	g_bondDirty = true;
+}
+
+// 0xAE string attribute by index, matching a real controller: 0=board serial, 1=unit serial (both our
+// unique generated values), 3=the Valve constant "7054257d2da7" Steam checks, everything else "NA".
+static const char *aeString(uint8_t idx)
+{
+	return (idx == 0) ? g_board :
+	       (idx == 1) ? g_unit :
+	       (idx == 3) ? "7054257d2da7" :
+			    "NA";
+}
+
+// Build the controller's reply to a feature/command GET as [cmd][len][payload] -- the SAME shape a real
+// controller returns over USB (`83 19 …`, `AE 14 idx …`), confirmed by scmd. The inner length byte IS
+// required (these are command-channel replies, NOT the 0x45 input report). The puck relays this value
+// straight back to Steam as the feature response. ctrl_link wraps it in the F1 type-6 TLV. Returns the
+// byte count written to out (out must hold >= 63).
+uint8_t ctrlFeatureResp(uint8_t cmd, const uint8_t *param, uint8_t plen,
+			uint8_t *out)
+{
+	switch (cmd) {
+	case 0x83: { // product/attribute blob: [0x83][len][ATTR83...]
+		uint8_t alen = ATTR83_LEN > 58 ? 58 : (uint8_t)ATTR83_LEN;
+		out[0] = 0x83;
+		out[1] = alen;
+		memcpy(out + 2, ATTR83, alen);
+		return (uint8_t)(2 + alen);
+	}
+	case 0xAE: { // string attr: [0xAE][0x14][idx][serial 19]
+		uint8_t idx = plen > 0 ? param[0] : 1;
+		const char *s = aeString(idx);
+		out[0] = 0xAE;
+		out[1] = 0x14;
+		out[2] = idx;
+		memset(out + 3, 0, 0x13);
+		memcpy(out + 3, s, strlen(s) < 0x13 ? strlen(s) : 0x13);
+		return (uint8_t)(2 + 0x14);
+	}
+	case 0xB4: // wireless transport state: [0xB4][len=1][state]
+		out[0] = 0xB4;
+		out[1] = 0x01;
+		out[2] = ctrlLinkUp() ? 0x02 : 0x01;
+		return 3;
+	default: // unknown query -> [cmd][len=0]
+		out[0] = cmd;
+		out[1] = 0;
+		return 2;
+	}
+}
+
+// Feature SET: framing [cmd][len][payload...]. rid (1 or 2) is not significant for our commands.
+static void handleSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
+		      uint16_t n)
+{
+	(void)rid;
+	if (type != HID_REPORT_TYPE_FEATURE || n < 1)
+		return;
+	uint8_t cmd = b[0], len = (n > 1) ? b[1] : 0;
+	const uint8_t *pl = b + 2;
+	uint16_t pln = (n >= 2) ? n - 2 : 0;
+	(void)len;
+
+	// Plain-text diagnostics for a serial monitor during pairing. The Deck app resyncs on the frame
+	// sync word, so these unframed lines are ignored by it -- only a human monitor sees them.
+	if (Serial.availableForWrite() > 40) {
+		Serial.printf("# SET rid=%u cmd=%02X len=%u pln=%u:", rid, cmd,
+			      len, (unsigned)pln);
+		for (uint16_t i = 0; i < n && i < 12; i++)
+			Serial.printf(" %02X", b[i]);
+		Serial.println();
+	}
+
+	memset(g_resp, 0, sizeof g_resp);
+	g_resp_len = 0;
+	switch (cmd) {
+	case 0x83: { // product attributes (0x1302)
+		uint16_t alen = ATTR83_LEN > 60 ? 60 : ATTR83_LEN;
+		g_resp[0] = 0x83;
+		g_resp[1] = (uint8_t)alen;
+		memcpy(g_resp + 2, ATTR83, alen);
+		g_resp_len = 63;
+		break;
+	}
+	// string attributes: 0=board, 1=unit serial, 3="7054257d2da7"
+	case 0xAE: {
+		uint8_t idx = pln > 0 ? pl[0] : 1;
+		const char *s = aeString(idx);
+		g_resp[0] = 0xAE;
+		g_resp[1] = 0x14;
+		g_resp[2] = idx;
+		memcpy(g_resp + 3, s, strlen(s));
+		g_resp_len = 63;
+		break;
+	}
+	case 0xB4: // wireless transport state (1 = ok)
+		g_resp[0] = 0xB4;
+		g_resp[1] = 0x01;
+		g_resp[2] = ctrlLinkUp() ? 0x02 : 0x01;
+		g_resp_len = 63;
+		break;
+	case 0xEE: { // keyed bond write: ["key"\0][r1 4][r2 4][puck_serial 16]
+		char key[24];
+		uint16_t k = 0;
+		while (k < pln && k < sizeof(key) - 1 && pl[k] != 0)
+			key[k] = (char)pl[k], k++;
+		key[k] = 0;
+		const uint8_t *rec = pl + k + 1; // skip the NUL
+		uint16_t reclen =
+			(pln > (uint16_t)(k + 1)) ? (uint16_t)(pln - k - 1) : 0;
+		// Find-or-allocate by the record's uuid key rather than the Zephyr settings key, so we can
+		// bond an arbitrary number of pucks (not just the two legacy esb/bond[_2] keys).
+		int sk = (reclen >= 24) ? ctrlBondFindOrAlloc(rec) :
+					  slotForKey(key);
+		if (reclen >= 24)
+			writeBond(sk, rec);
+		if (Serial.availableForWrite() > 40)
+			Serial.printf(
+				"# EE key='%s' slot=%d reclen=%u stored=%d\n",
+				key, sk, (unsigned)reclen,
+				(sk >= 0 && reclen >= 24) ? 1 : 0);
+		g_resp[0] = 0xEE;
+		g_resp_len = 63;
+		break;
+	}
+	case 0xEF: // commit keyed value (we write-through on 0xEE, so just ack)
+		g_resp[0] = 0xEF;
+		g_resp_len = 63;
+		break;
+	case 0xA2: // direct 24-byte bond write to slot 0 (fallback shape)
+		if (pln >= 24)
+			writeBond(0, pl);
+		g_resp[0] = 0xA2;
+		g_resp_len = 63;
+		break;
+	case 0xA3: // read slot 0 bond
+		g_resp[0] = 0xA3;
+		g_resp[1] = 0x18;
+		if (g_bond[0].used)
+			memcpy(g_resp + 2, g_bond[0].rec, 24);
+		g_resp_len = 63;
+		break;
+	// reboot into wireless mode (magic 0xA427AF52); the reset happens after the flash flush AND once the
+	// host has read the ACK back (see g_respRead / REBOOT_GRACE_MS above)
+	case 0x95:
+		g_rebootPending = true;
+		g_respRead = false;
+		g_rebootAt = millis();
+		if (Serial.availableForWrite() > 40)
+			Serial.println("# 0x95 reboot-to-wireless requested");
+		g_resp[0] = 0x95;
+		g_resp_len = 63;
+		break;
+	default:
+		g_resp[0] = cmd;
+		g_resp[1] = len;
+		if (pln)
+			memcpy(g_resp + 2, pl, pln > 60 ? 60 : pln);
+		g_resp_len = 63;
+		break;
+	}
+}
+
+static uint16_t handleGet(uint8_t rid, hid_report_type_t type, uint8_t *buf,
+			  uint16_t reqlen)
+{
+	(void)rid;
+	if (type != HID_REPORT_TYPE_FEATURE)
+		return 0;
+	// The host read the staged response -> the SET/GET transaction completed. Used to know a 0x95 ACK
+	// landed before we drop USB, and as a serial-monitor trace of Steam's pairing read sequence (the SET
+	// side is logged in handleSet; a bare GET with no preceding SET shows up here as a stale g_resp[0]).
+	g_respRead = true;
+	if (Serial.availableForWrite() > 40)
+		Serial.printf("# GET rid=%u -> %02X %02X req=%u\n", rid,
+			      g_resp[0], g_resp[1], (unsigned)reqlen);
+	uint16_t nn = g_resp_len ? g_resp_len : 63;
+	if (nn > reqlen)
+		nn = reqlen;
+	memcpy(buf, g_resp, nn);
+	return nn;
+}
+
+void ctrlUsbBegin()
+{
+	USBDevice.setID(0x28DE, 0x1302);
+	USBDevice.setDeviceVersion(0x0101);
+	USBDevice.setManufacturerDescriptor("Valve Software");
+	USBDevice.setProductDescriptor("Steam Controller");
+	g_hid.setReportDescriptor(CTRL_HID_DESC, sizeof CTRL_HID_DESC);
+	g_hid.setReportCallback(handleGet, handleSet);
+	g_hid.setPollInterval(1);
+	g_hid.begin();
+}
+
+void ctrlUsbPoll()
+{
+	if (g_bondDirty) {
+		g_bondDirty = false;
+		saveCtrlBonds();
+	}
+	// reboot only AFTER the freshly-written bond is on flash (else it reloads a stale bond on boot) AND
+	// the host has read the 0x95 ACK back (or the grace window lapsed for a fire-and-forget host) so the
+	// USB drop never lands mid-transaction and reads to Steam as a pairing failure.
+	if (g_rebootPending && !g_bondDirty &&
+	    (g_respRead ||
+	     (uint32_t)(millis() - g_rebootAt) > REBOOT_GRACE_MS)) {
+		delay(20);
+		NVIC_SystemReset();
+	}
+}

+ 24 - 0
ReversePuckFirmware/ctrl_usb.h

@@ -0,0 +1,24 @@
+// ctrl_usb.h -- the emulated controller's USB face (Valve 28DE:1302) + pairing command channel.
+//
+// Presents a single Valve HID control interface (usagePage 0x01, feature reports id 1/2) so Steam and
+// PairTUI treat us as a dockable Steam Controller. We ANSWER identity reads (0x83 product, 0xAE serial,
+// 0xB4 wireless state) and ACCEPT the steamless pairing writes that target the controller:
+//   0xEE  keyed value write   payload ["esb/bond"\0 or "esb/bond_2"\0][r1 4][r2 4][puck_serial 16]
+//   0xEF  keyed value commit  payload ["esb/bond"\0]
+//   0xA2  direct 24-byte bond write to slot 0 (PairTUI/Steam fallback shape)
+//   0x95  reboot into wireless mode  (magic 0xA427AF52) -> NVIC_SystemReset after the flash flush
+// See protocol/USB_COMMANDS.md for the full steamless flow.
+#pragma once
+#include <stdint.h>
+
+// register the HID interface (call from setup, before USBDevice.attach)
+void ctrlUsbBegin();
+// flush dirty bonds + honor a pending reboot (call each loop)
+void ctrlUsbPoll();
+
+// Build the controller's reply to a feature/command GET (0x83 attrs, 0xAE serial, 0xB4 wireless state)
+// as [cmd][len][payload] -- the command-channel form a real controller returns over USB (WITH the inner
+// length byte). ctrl_link uses this to answer the queries the puck relays over RF as E3 GETs (Steam's
+// enumeration). `param`/`plen` are the GET parameters (e.g. the 0xAE index). Returns out length.
+uint8_t ctrlFeatureResp(uint8_t cmd, const uint8_t *param, uint8_t plen,
+			uint8_t *out);

+ 250 - 0
ReversePuckFirmware/deck_input.cpp

@@ -0,0 +1,250 @@
+#include "deck_input.h"
+#include "triton.h"
+#include "ctrl_bonds.h"
+#include "ctrl_link.h"
+#include "radio.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+static bool g_forwarding = false;
+static unsigned long g_lastInputMs = 0;
+static unsigned long g_lastStatusMs = 0;
+
+bool deckForwarding()
+{
+	return g_forwarding;
+}
+unsigned long deckLastInputMs()
+{
+	return g_lastInputMs;
+}
+
+// ---- little-endian payload readers ----
+static inline int16_t rd_s16(const uint8_t *p)
+{
+	return (int16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
+}
+static inline uint32_t rd_u32(const uint8_t *p)
+{
+	return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) |
+	       ((uint32_t)p[3] << 24);
+}
+static inline uint16_t rd_u16(const uint8_t *p)
+{
+	return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
+}
+
+// INPUT payload = buttons u32, lx ly rx ry s16, lt rt u8, lpx lpy rpx rpy s16, lpp rpp u16, IMU 6h.
+#define DECK_INPUT_LEN 38
+static void applyInput(const uint8_t *p, uint8_t n)
+{
+	if (n < DECK_INPUT_LEN)
+		return;
+	g_in.buttons = rd_u32(p + 0);
+	g_in.lx = rd_s16(p + 4);
+	g_in.ly = rd_s16(p + 6);
+	g_in.rx = rd_s16(p + 8);
+	g_in.ry = rd_s16(p + 10);
+	g_in.lt = p[12];
+	g_in.rt = p[13];
+	g_in.lpx = rd_s16(p + 14);
+	g_in.lpy = rd_s16(p + 16);
+	g_in.rpx = rd_s16(p + 18);
+	g_in.rpy = rd_s16(p + 20);
+	g_in.lpp = rd_u16(p + 22);
+	g_in.rpp = rd_u16(p + 24);
+	g_in.ax = rd_s16(p + 26);
+	g_in.ay = rd_s16(p + 28);
+	g_in.az = rd_s16(p + 30);
+	g_in.gx = rd_s16(p + 32);
+	g_in.gy = rd_s16(p + 34);
+	g_in.gz = rd_s16(p + 36);
+	g_lastInputMs = millis();
+}
+
+static void handleFrame(uint8_t type, const uint8_t *p, uint8_t n)
+{
+	switch (type) {
+	case DECK_T_INPUT:
+		applyInput(p, n);
+		break;
+	case DECK_T_CONTROL:
+		if (n >= 2 && p[0] == 0x01)
+			g_forwarding = (p[1] != 0);
+		else if (n >= 2 &&
+			 p[0] == 0x02) // clear-bond [slot] (GUI "remove")
+			ctrlBondClear(p[1]);
+		break;
+	case DECK_T_REQ_STATUS:
+		g_lastStatusMs = 0; // force an immediate status emit
+		break;
+	default:
+		break;
+	}
+}
+
+// ---- RX state machine: resync on the 2-byte sync word ----
+void deckInputPoll()
+{
+	// state: 0 wait sync0, 1 wait sync1, 2 type, 3 len, 4 payload, 5 sum
+	static uint8_t st = 0;
+	static uint8_t type = 0, len = 0, idx = 0, sum = 0;
+	static uint8_t buf[64];
+	while (Serial.available()) {
+		uint8_t c = (uint8_t)Serial.read();
+		switch (st) {
+		case 0:
+			if (c == DECK_SYNC0)
+				st = 1;
+			break;
+		case 1:
+			st = (c == DECK_SYNC1) ? 2 : (c == DECK_SYNC0 ? 1 : 0);
+			break;
+		case 2:
+			type = c;
+			sum = c;
+			st = 3;
+			break;
+		case 3:
+			len = c;
+			sum += c;
+			idx = 0;
+			if (len > sizeof buf) { // oversized -> drop, resync
+				st = 0;
+				break;
+			}
+			st = (len == 0) ? 5 : 4;
+			break;
+		case 4:
+			buf[idx++] = c;
+			sum += c;
+			if (idx >= len)
+				st = 5;
+			break;
+		case 5:
+			if (c == sum)
+				handleFrame(type, buf, len);
+			st = 0;
+			break;
+		}
+	}
+}
+
+// ---- frame TX ----
+static void sendFrame(uint8_t type, const uint8_t *p, uint8_t n)
+{
+	// need room for the whole frame or skip (never block the RF loop on CDC backpressure)
+	if ((int)Serial.availableForWrite() < n + 5)
+		return;
+	uint8_t hdr[4] = { DECK_SYNC0, DECK_SYNC1, type, n };
+	uint8_t sum = (uint8_t)(type + n);
+	for (uint8_t i = 0; i < n; i++)
+		sum += p[i];
+	Serial.write(hdr, 4);
+	if (n)
+		Serial.write(p, n);
+	Serial.write(&sum, 1);
+}
+
+void deckText(const char *s)
+{
+	size_t n = strlen(s);
+	if (n > 60)
+		n = 60;
+	sendFrame(DECK_T_TEXT, (const uint8_t *)s, (uint8_t)n);
+}
+
+// Latest relayed haptic, STAGED by deckForwardHaptic (called from the RF poll path) and flushed over CDC
+// by deckHapticTask() at loop level. Staging-then-flushing (a) keeps the CDC write OUT of the RF receive
+// path, and (b) COALESCES a burst to the newest state -- Steam streams rumble at the poll rate, and
+// replaying every frame one-behind is what caused the lag; newest-wins removes the backlog.
+static uint8_t g_hapPayload[8];
+static volatile bool g_hapPending = false;
+static unsigned long g_lastHapMs = 0;
+// cap CDC haptic frames to ~60 Hz: enough for smooth rumble, but no flood of the CDC link / Deck USB.
+#define HAPTIC_MIN_MS 16u
+
+void deckForwardHaptic(uint8_t rid, const uint8_t *d, uint8_t n)
+{
+	// Only while forwarding -- otherwise the Deck isn't grabbed and there's nothing to buzz.
+	if (!g_forwarding)
+		return;
+	uint16_t intensity = 0, left = 0, right = 0;
+	uint8_t lgain = 0, rgain = 0;
+	if (rid == 0x80 && n >= 9) {
+		// SDL Triton rumble report 0x80: [type][intensity u16][left u16][lgain][right u16][rgain].
+		// type 0 = off/zero report (everything stays 0).
+		if (d[0] != 0) {
+			intensity = (uint16_t)(d[1] | (d[2] << 8));
+			left = (uint16_t)(d[3] | (d[4] << 8));
+			lgain = d[5];
+			right = (uint16_t)(d[6] | (d[7] << 8));
+			rgain = d[8];
+		}
+	} else if (rid == 0x82 || rid == 0x8F) {
+		// discrete haptic click/pulse (data ~ [01 01 F7]; trailing byte F7=on, 00=off). The Deck has no
+		// per-pad clicker, so synthesize a short mid-strength buzz on both motors.
+		bool on = (n >= 3) ? (d[2] != 0) : (n >= 1 && d[0] != 0);
+		if (on) {
+			intensity = 0x6000;
+			left = right = 0x6000;
+		}
+	} else {
+		return; // not a haptic the Deck can render
+	}
+	// stage newest (coalesce). The actual sendFrame happens in deckHapticTask, off the RF path.
+	g_hapPayload[0] = (uint8_t)(intensity & 0xFF);
+	g_hapPayload[1] = (uint8_t)(intensity >> 8);
+	g_hapPayload[2] = (uint8_t)(left & 0xFF);
+	g_hapPayload[3] = (uint8_t)(left >> 8);
+	g_hapPayload[4] = (uint8_t)(right & 0xFF);
+	g_hapPayload[5] = (uint8_t)(right >> 8);
+	g_hapPayload[6] = lgain;
+	g_hapPayload[7] = rgain;
+	g_hapPending = true;
+}
+
+void deckHapticTask()
+{
+	if (!g_hapPending)
+		return;
+	if (millis() - g_lastHapMs < HAPTIC_MIN_MS)
+		return; // rate-limit the newest staged haptic to ~60 Hz
+	g_hapPending = false;
+	g_lastHapMs = millis();
+	sendFrame(DECK_T_HAPTIC, g_hapPayload, sizeof g_hapPayload);
+}
+
+// STATUS payload: [flags][linkSlot][sessCh][nbondMax][count] + per USED bond
+//   [slotIdx][alive][puuid4][iuuid4][serial16] (26 B). Only used bonds are sent, so the typical 1-2-puck
+// frame stays small; the slot index lets the GUI target a bond for removal.
+void deckStatusTask()
+{
+	if (millis() - g_lastStatusMs < 250)
+		return;
+	g_lastStatusMs = millis();
+
+	uint8_t p[5 + NBOND * 26];
+	bool linkUp = ctrlLinkUp();
+	p[0] = (uint8_t)((g_forwarding ? 1 : 0) | (linkUp ? 2 : 0));
+	p[1] = (g_linkSlot >= 0) ? (uint8_t)g_linkSlot : 0xFF;
+	p[2] = g_sessCh;
+	p[3] = NBOND;
+	uint8_t count = 0;
+	uint8_t *q = p + 5;
+	for (int i = 0; i < NBOND; i++) {
+		CtrlBond &b = g_bond[i];
+		if (!b.used)
+			continue;
+		q[0] = (uint8_t)i; // firmware slot index (for GUI removal)
+		q[1] = (i == g_linkSlot && linkUp) ? 1 : 0;
+		memcpy(q + 2, b.rec + 0, 4); // proteus_uuid
+		memcpy(q + 6, b.rec + 4, 4); // ibex_uuid
+		memcpy(q + 10, b.rec + 8, 16); // puck serial
+		q += 26;
+		count++;
+	}
+	p[4] = count;
+	sendFrame(DECK_T_STATUS, p, (uint8_t)(5 + count * 26));
+}

+ 43 - 0
ReversePuckFirmware/deck_input.h

@@ -0,0 +1,43 @@
+// deck_input.h -- the USB-CDC link to the Steam Deck forwarder app.
+//
+// The Deck app (deck/) streams the Deck's grabbed controls as INPUT frames; we decode them into g_in
+// (report45.cpp then encodes the RF report 0x45). We stream STATUS frames back so the app's UI can show
+// which bonded pucks are live. One framed binary protocol both ways; the reader resynchronizes on a
+// 2-byte sync word so a serial monitor's stray text is harmless. The wire format is mirrored exactly in
+// deck/frame.py.
+//
+//   frame = [0xA5][0x5A][type][len][payload(len)][sum8(type,len,payload...)]
+//
+//   Deck -> nRF:  0x01 INPUT (38B fields), 0x02 CONTROL ([sub][..]: sub 0x01=set-forwarding[0/1],
+//                 sub 0x02=clear-bond[slot]), 0x03 REQUEST_STATUS (no payload)
+//   nRF -> Deck:  0x10 STATUS, 0x11 HAPTIC (relayed Steam rumble), 0x1F TEXT(debug)
+#pragma once
+#include <stdint.h>
+
+#define DECK_SYNC0 0xA5
+#define DECK_SYNC1 0x5A
+
+#define DECK_T_INPUT 0x01
+#define DECK_T_CONTROL 0x02
+#define DECK_T_REQ_STATUS 0x03
+#define DECK_T_STATUS 0x10
+#define DECK_T_HAPTIC 0x11
+#define DECK_T_TEXT 0x1F
+
+// Pump the CDC RX state machine: parse any complete frames, fill g_in, update forwarding state.
+void deckInputPoll();
+// Emit a STATUS frame (bond list + link state) to the Deck. Rate-limited internally; call each loop.
+void deckStatusTask();
+// Flush the newest staged haptic (from deckForwardHaptic) to the Deck. Rate-limited; call each loop.
+void deckHapticTask();
+// Send a debug text line as a 0x1F frame (skipped by the app unless it shows debug).
+void deckText(const char *s);
+
+// Forward a relayed Steam haptic/rumble to the Deck as a HAPTIC frame (the app plays it as Deck rumble).
+// rid = the relayed report id (0x80 SDL Triton rumble, 0x82/0x8F haptic pulse); d/n = its raw payload.
+void deckForwardHaptic(uint8_t rid, const uint8_t *d, uint8_t n);
+
+// Is the app currently forwarding (user tapped a puck)? When false, ctrl_link sends a neutral report.
+bool deckForwarding();
+// millis() of the last INPUT frame; ctrl_link sends neutral if input goes stale while forwarding.
+unsigned long deckLastInputMs();

+ 27 - 0
ReversePuckFirmware/identity.cpp

@@ -0,0 +1,27 @@
+#include "identity.h"
+#include <Arduino.h>
+#include <stdio.h>
+#include <string.h>
+
+char g_unit[16];
+char g_board[16];
+
+// 0x83 attributes for the CONTROLLER -- the EXACT 25-byte blob read from a real Steam Controller
+// (28DE:1302) over USB: `scmd 1302 --up 1 83` -> `01 83 19 <these 25 bytes>`. As [tag][u32-LE] records:
+// tag 01 = product 0x1302; tag 02 = capabilities; tag 0A = bootloader build 0x68D2F92E; tag 04 = fw
+// build 0x6A18D057; tag 09 = board rev 0x48. These are firmware/model values (shared across units, not
+// per-device), so verbatim is correct; the per-unit serial lives in 0xAE instead. Steam validates these
+// byte-for-byte -- two wrong bytes (the puck's bootloader build + board rev) were why it kept re-asking.
+const uint8_t ATTR83[] = { 0x01, 0x02, 0x13, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
+			   0x00, 0x0A, 0x2E, 0xF9, 0xD2, 0x68, 0x04, 0x57, 0xD0,
+			   0x18, 0x6A, 0x09, 0x48, 0x00, 0x00, 0x00 };
+const uint16_t ATTR83_LEN = sizeof ATTR83;
+
+void genSerial()
+{
+	uint32_t id = NRF_FICR->DEVICEID[0] ^ NRF_FICR->DEVICEID[1];
+	snprintf(g_unit, sizeof g_unit, "FXA99602%05lX",
+		 (unsigned long)(id & 0xFFFFF));
+	snprintf(g_board, sizeof g_board, "MXA99602%05lX",
+		 (unsigned long)(id & 0xFFFFF));
+}

+ 18 - 0
ReversePuckFirmware/identity.h

@@ -0,0 +1,18 @@
+// identity.h -- emulated-controller identity derived from the nRF52 FICR DEVICEID.
+//
+// Mirrors OpenPuck/identity.h but for the CONTROLLER (IBEX, USB PID 0x1302). A real wired Steam
+// Controller enumerates as 28DE:1302 with an "FXA99602…" unit serial (HANDOFF.md). We generate a
+// unique FXA/MXA serial so we never clash with a real controller, and expose a 0x1302 product blob.
+#pragma once
+#include <stdint.h>
+
+// "FXA99602xxxxx" -- the controller's pairing identity (USB serial)
+extern char g_unit[16];
+extern char g_board[16]; // "MXA99602xxxxx"
+
+// 0x83 attributes, returned by the feature command channel (product code 0x1302). Unsized so a
+// captured real-controller blob of any length drops in; ATTR83_LEN carries the true byte count.
+extern const uint8_t ATTR83[];
+extern const uint16_t ATTR83_LEN;
+
+void genSerial();

+ 107 - 0
ReversePuckFirmware/radio.cpp

@@ -0,0 +1,107 @@
+#include "radio.h"
+
+// *** VERBATIM COPY of OpenPuck/radio.cpp -- keep byte-identical (see radio.h header note). ***
+// On the controller emulator rfGenSessionAddr() only seeds a safe default; the operational session
+// address is ADOPTED from the puck's E1 host frame in ctrl_link.cpp.
+
+const uint8_t PAIR_BASE[4] = { 0x69, 0x62, 0x65, 0x78 }; // "ibex"
+uint8_t g_rfPrefix = 0x10;
+uint8_t g_rfCh = 2;
+uint8_t g_rfBase[4] = { 0x69, 0x62, 0x65, 0x78 }; // "ibex"
+// ch18: clean channel in the real puck's active hop set {18,2,80}. Tunable.
+uint8_t g_sessCh = 18;
+
+// Safe default; adopted from the puck's E1 host frame at connect time.
+uint8_t g_sessBase[4] = { 0x69, 0x62, 0x65, 0x78 };
+uint8_t g_sessPrefix = 0x10;
+
+void rfGenSessionAddr()
+{
+	uint32_t h = NRF_FICR->DEVICEID[0] * 0x9E3779B1u ^
+		     NRF_FICR->DEVICEID[1];
+	uint32_t h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^
+		      NRF_FICR->DEVICEID[0];
+	for (int i = 0; i < 4; i++) {
+		uint8_t b = (uint8_t)(h >> (i * 8));
+		if (b == 0x00 || b == 0xFF)
+			b ^= 0x5A;
+		g_sessBase[i] = b;
+	}
+	uint8_t p = (uint8_t)(h2 >> 16);
+	if (p == 0x00 || p == 0xFF)
+		p = 0x5C;
+	g_sessPrefix = p;
+	if (g_sessBase[0] == g_rfBase[0] && g_sessBase[1] == g_rfBase[1] &&
+	    g_sessBase[2] == g_rfBase[2] && g_sessBase[3] == g_rfBase[3])
+		g_sessBase[0] ^= 0x80;
+}
+
+uint8_t rfrx[100], rftx[100];
+uint32_t g_rfRxCount = 0;
+
+// Puck-link config decoded from real-puck capture: Ble_2Mbit, ENDIAN=Big, NO whitening, addr "ibex",
+// CRC16 0x11021/0xFFFF; static-length frame.
+uint32_t g_crcinit = 0xFFFF;
+uint8_t g_whiteiv = 37;
+uint8_t g_mode = RADIO_MODE_MODE_Ble_2Mbit;
+
+// S0LEN0, LFLEN8, S1LEN3 (ESB DPL) - CRC-VALIDATED
+uint32_t g_pcnf0 = 0x00030008UL;
+uint8_t g_statlen = 0x20;
+
+// ENDIAN=Big, BALEN4, MAXLEN=96.
+uint32_t g_pcnf1 = 0x01040060;
+uint32_t g_crcpoly = 0x11021UL;
+uint16_t g_crccnf = 0x2; // CRC16, address included
+uint8_t g_pid = 0;
+uint8_t g_balen = 4; // ESB 5-byte addr
+bool g_prefixRaw = false;
+
+uint8_t rfBitrev8(uint8_t x)
+{
+	uint8_t y = 0;
+	for (int i = 0; i < 8; i++) {
+		y = (y << 1) | (x & 1);
+		x >>= 1;
+	}
+	return y;
+}
+
+void rfSetAddr(const uint8_t b4[4], uint8_t prefix)
+{
+	uint8_t b[4];
+	for (int i = 0; i < 4; i++)
+		b[i] = rfBitrev8(b4[i]);
+	NRF_RADIO->BASE0 = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
+			   ((uint32_t)b[2] << 8) | b[3];
+	NRF_RADIO->PREFIX0 = g_prefixRaw ? prefix : rfBitrev8(prefix);
+	NRF_RADIO->TXADDRESS = 0;
+	NRF_RADIO->RXADDRESSES = 1u << 0;
+}
+
+void rfConfig(uint8_t ch)
+{
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->MODE = ((uint32_t)g_mode << RADIO_MODE_MODE_Pos);
+	NRF_RADIO->FREQUENCY = ch;
+	NRF_RADIO->TXPOWER =
+		(RADIO_TXPOWER_TXPOWER_0dBm << RADIO_TXPOWER_TXPOWER_Pos);
+#if defined(RADIO_MODECNF0_RU_Fast)
+	NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast << RADIO_MODECNF0_RU_Pos);
+#endif
+	NRF_RADIO->PCNF0 = g_pcnf0;
+
+	NRF_RADIO->PCNF1 =
+		g_pcnf1 ? g_pcnf1 :
+			  ((1u << RADIO_PCNF1_ENDIAN_Pos) |
+			   ((uint32_t)g_balen << RADIO_PCNF1_BALEN_Pos) |
+			   ((uint32_t)g_statlen << RADIO_PCNF1_STATLEN_Pos) |
+			   ((uint32_t)g_statlen << RADIO_PCNF1_MAXLEN_Pos));
+	NRF_RADIO->CRCCNF = g_crccnf; // CRC16, address included
+	NRF_RADIO->CRCPOLY = g_crcpoly;
+	NRF_RADIO->CRCINIT = g_crcinit;
+	NRF_RADIO->DATAWHITEIV = g_whiteiv;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+}

+ 64 - 0
ReversePuckFirmware/radio.h

@@ -0,0 +1,64 @@
+// radio.h -- bare-metal nRF52 RADIO (ESB-style) hardware layer.
+//
+// *** VERBATIM COPY of OpenPuck/radio.h -- keep byte-identical. ***
+// The controller emulator speaks the SAME 2.4 GHz PHY/CRC/address layer as the puck; only the
+// roles are inverted (we answer polls instead of issuing them). Arduino compiles a sketch folder
+// flat, so this file is duplicated rather than shared. If you change the radio layer in OpenPuck,
+// mirror it here (and vice-versa) -- the two MUST agree on the air.
+//
+// Register-confirmed config (decoded from real-puck capture): PHY=Ble_2Mbit, ENDIAN=Big (MSB-first),
+// whitening off, addr "ibex", CRC16 poly 0x11021 init 0xFFFF (address included). On-air address =
+// bitrev8 of stored bytes.
+#pragma once
+#include <stdint.h>
+#include <Arduino.h>
+
+// Bounded radio-disable wait: NEVER spin forever. A wedged RADIO would hang the main loop (USB stops
+// being serviced -> device "dies" until replug). On timeout we bail; the next rfConfig re-inits.
+#define RWAIT_DISABLED()                                   \
+	do {                                               \
+		uint32_t _w = micros();                    \
+		while (!NRF_RADIO->EVENTS_DISABLED &&      \
+		       (uint32_t)(micros() - _w) < 3000) { \
+		}                                          \
+	} while (0)
+
+// Discovery/reconnect address (IBEX FUN_00019000, firmware-derived, definitive):
+//   base = "ibex" (69 62 65 78), prefix = 0x10, channel = 2.
+extern const uint8_t PAIR_BASE[4]; // "ibex"
+extern uint8_t g_rfPrefix; // discovery prefix
+extern uint8_t g_rfCh; // current TX/RX channel
+extern uint8_t g_rfBase[4]; // "ibex"
+// connected-session channel: a CLEAN data channel off the congested adv ch2
+extern uint8_t g_sessCh;
+
+// Session address (base+prefix). On the puck this is per-device and advertised; on the controller
+// emulator it is ADOPTED from the puck's E1 host frame (the puck advertises it; we follow).
+extern uint8_t g_sessBase[4];
+extern uint8_t g_sessPrefix;
+void rfGenSessionAddr();
+
+// RADIO DMA buffers (>= MAXLEN+2; the controller's 0x43-augmented F1 is ~66B)
+extern uint8_t rfrx[100], rftx[100];
+extern uint32_t g_rfRxCount;
+
+// ---- tunable radio parameters ----
+extern uint32_t g_crcinit;
+extern uint8_t g_whiteiv;
+extern uint8_t g_mode; // RADIO_MODE_MODE_* (real puck = Ble_2Mbit)
+extern uint32_t g_pcnf0; // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) -- CRC-VALIDATED
+extern uint8_t g_statlen; // static-mode payload length
+extern uint32_t g_pcnf1; // ENDIAN=Big, BALEN4, MAXLEN96 -- CRC-VALIDATED
+extern uint32_t g_crcpoly;
+extern uint16_t g_crccnf; // CRC16, address included
+extern uint8_t g_pid;
+extern uint8_t g_balen; // ESB 5-byte addr
+// false = bitrev8 prefix (ESB addr_conv); true = raw prefix byte
+extern bool g_prefixRaw;
+
+uint8_t rfBitrev8(uint8_t x);
+// Access address (IBEX FUN_00037530): BASE0 = bitrev8 each base byte packed big-endian; PREFIX0 = raw
+// or bitrev8 prefix per g_prefixRaw. "ibex" 69 62 65 78 -> BASE0=0x9646A61E, PREFIX0=0x10.
+void rfSetAddr(const uint8_t b4[4], uint8_t prefix);
+// Program MODE/FREQUENCY/PCNF/CRC/whitening from the tunables above onto channel ch (radio left disabled).
+void rfConfig(uint8_t ch);

+ 71 - 0
ReversePuckFirmware/report45.cpp

@@ -0,0 +1,71 @@
+#include "report45.h"
+#include "triton.h"
+#include <Arduino.h>
+#include <string.h>
+
+// Write a signed 16-bit value little-endian at rep[2+off] (the same indexing triton.h's s16off reads).
+static inline void put_s16(uint8_t *rep, int off, int16_t v)
+{
+	rep[2 + off] = (uint8_t)(v & 0xFF);
+	rep[2 + off + 1] = (uint8_t)((v >> 8) & 0xFF);
+}
+// Write an unsigned 16-bit value little-endian at rep[2+off] (matches u16off).
+static inline void put_u16(uint8_t *rep, int off, uint16_t v)
+{
+	rep[2 + off] = (uint8_t)(v & 0xFF);
+	rep[2 + off + 1] = (uint8_t)((v >> 8) & 0xFF);
+}
+// Write an unsigned 32-bit value little-endian at rep[2+off].
+static inline void put_u32(uint8_t *rep, int off, uint32_t v)
+{
+	rep[2 + off + 0] = (uint8_t)(v & 0xFF);
+	rep[2 + off + 1] = (uint8_t)((v >> 8) & 0xFF);
+	rep[2 + off + 2] = (uint8_t)((v >> 16) & 0xFF);
+	rep[2 + off + 3] = (uint8_t)((v >> 24) & 0xFF);
+}
+
+void buildReport45(uint8_t *out, uint8_t seq)
+{
+	memset(out, 0, REPORT45_LEN);
+	out[0] = 0x45;
+	out[1] = seq;
+
+	// buttons u32 at rep[2..5] (inverse of btnsOf)
+	out[2] = (uint8_t)(g_in.buttons & 0xFF);
+	out[3] = (uint8_t)((g_in.buttons >> 8) & 0xFF);
+	out[4] = (uint8_t)((g_in.buttons >> 16) & 0xFF);
+	out[5] = (uint8_t)((g_in.buttons >> 24) & 0xFF);
+
+	// triggers: the controller's analog trigger is a u16 that trigU8 maps via >>7 (saturating). Inverse:
+	// u16 = lt << 7 (so a full 0xFF pull -> 0x7F80 -> trigU8 -> 0xFF). offset 4 = LT, offset 6 = RT.
+	put_u16(out, 4, (uint16_t)(g_in.lt << 7));
+	put_u16(out, 6, (uint16_t)(g_in.rt << 7));
+
+	// sticks (int16, center 0): lx@8 ly@10 rx@12 ry@14
+	put_s16(out, 8, g_in.lx);
+	put_s16(out, 10, g_in.ly);
+	put_s16(out, 12, g_in.rx);
+	put_s16(out, 14, g_in.ry);
+
+	// trackpads: LPad x@16 y@18 pressure@20, RPad x@22 y@24 pressure@26 (per PROTOCOL.md report 0x45)
+	put_s16(out, 16, g_in.lpx);
+	put_s16(out, 18, g_in.lpy);
+	put_u16(out, 20, g_in.lpp);
+	put_s16(out, 22, g_in.rpx);
+	put_s16(out, 24, g_in.rpy);
+	put_u16(out, 26, g_in.rpp);
+
+	// IMU timestamp: report[30..33] = free-running MICROSECOND counter (u32 LE), per IBEX_REIMPL_SPEC
+	// report-0x45 layout. CRITICAL for gyro: Steam integrates angular velocity as gyro * dt where dt is
+	// the delta of THIS timestamp between reports -- leaving it 0 makes dt=0 so the gyro never moves the
+	// view on the host, no matter how correct the gyro values are. off 28 -> rep[30].
+	put_u32(out, 28, micros());
+
+	// IMU: accel @0x22 (offset 32), gyro @0x28 (offset 38) -- inverse of imuFrom45
+	put_s16(out, 32, g_in.ax);
+	put_s16(out, 34, g_in.ay);
+	put_s16(out, 36, g_in.az);
+	put_s16(out, 38, g_in.gx);
+	put_s16(out, 40, g_in.gy);
+	put_s16(out, 42, g_in.gz);
+}

+ 15 - 0
ReversePuckFirmware/report45.h

@@ -0,0 +1,15 @@
+// report45.h -- encode g_in into the controller's HID input report 0x45.
+//
+// The exact inverse of the field offsets in triton.h (s16off/u16off/trigU8/btnsOf/imuFrom45). The
+// puck's rf_link.cpp decodes this same byte layout, so the round-trip Deck -> g_in -> 0x45 -> puck ->
+// Steam is symmetric. Output is REPORT45_LEN bytes: [0x45][seq][buttons u32][...sticks/pads/IMU].
+#pragma once
+#include <stdint.h>
+
+// 46 bytes: id + 45 payload bytes (matches the puck descriptor's report-0x45 count 0x2D and the
+// IMU tail at rep[34..46] that rf_link decodes when tlen >= 46).
+#define REPORT45_LEN 46
+
+// Build a fresh report 0x45 from g_in into out[REPORT45_LEN]. seq is the rolling frame counter
+// (rep[1]); the puck treats a changed seq as a fresh report.
+void buildReport45(uint8_t *out, uint8_t seq);

+ 17 - 0
ReversePuckFirmware/triton.cpp

@@ -0,0 +1,17 @@
+#include "triton.h"
+
+// Copied from OpenPuck/triton.cpp. g_in is the shared input struct; imuFrom45 documents the IMU byte
+// offsets that report45.cpp inverts when encoding.
+
+PuckInput g_in;
+
+void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
+	       int16_t *gx, int16_t *gy, int16_t *gz)
+{
+	*ax = s16off(r, 32);
+	*ay = s16off(r, 34);
+	*az = s16off(r, 36);
+	*gx = s16off(r, 38);
+	*gy = s16off(r, 40);
+	*gz = s16off(r, 42);
+}

+ 81 - 0
ReversePuckFirmware/triton.h

@@ -0,0 +1,81 @@
+// triton.h -- the controller's native input model + report 0x45 field offsets.
+//
+// *** Copied from OpenPuck/triton.h (the shared input contract). ***
+// On the PUCK, rf_link.cpp DECODES an incoming report 0x45 into g_in. On the CONTROLLER emulator we go
+// the other way: deck_input.cpp fills g_in from the Steam Deck, and report45.cpp ENCODES g_in back into
+// a report 0x45 to transmit. The decoders below are kept so the offsets are documented in one place and
+// the round-trip is provably symmetric. report 0x45 layout:
+//   [0]=0x45 [1]=seq [2..5]=buttons u32; analog offsets below are from the buttons low byte (rep[2]).
+#pragma once
+#include <stdint.h>
+
+// ---- Triton button masks (the 32-bit field at rep[2..5]) ----
+#define TB_A 0x1u
+#define TB_B 0x2u
+#define TB_X 0x4u
+#define TB_Y 0x8u
+#define TB_QAM 0x10u
+#define TB_R3 0x20u
+#define TB_VIEW 0x40u
+#define TB_R4 0x80u
+#define TB_R5 0x100u
+#define TB_RB 0x200u
+#define TB_DDN 0x400u
+#define TB_DRT 0x800u
+#define TB_DLF 0x1000u
+#define TB_DUP 0x2000u
+#define TB_MENU 0x4000u
+#define TB_L3 0x8000u
+#define TB_STEAM 0x10000u
+#define TB_L4 0x20000u
+#define TB_L5 0x40000u
+#define TB_LB 0x80000u
+#define TB_RPADT 0x200000u
+#define TB_RPADC 0x400000u
+#define TB_LPADT 0x2000000u
+#define TB_LPADC 0x4000000u
+
+// all four back paddles held -> mode-switch chord guard
+#define CHORD_BACK4 (TB_R4 | TB_L4 | TB_R5 | TB_L5)
+
+// analog-trigger fraction (of 0xFF) at which digital ZL/ZR (Switch) etc. trip
+#define SW_TRIG_ON 40
+
+// ---- report 0x45 field decoders (offsets relative to rep[2], the buttons low byte) ----
+static inline int s16off(const uint8_t *r, int off)
+{
+	int v = r[2 + off] | (r[2 + off + 1] << 8);
+	return (v & 0x8000) ? v - 0x10000 : v;
+}
+static inline int u16off(const uint8_t *r, int off)
+{
+	return r[2 + off] | (r[2 + off + 1] << 8);
+}
+// Controller trigger u16 tops out near half-scale (~0x8000) at a full pull, so a straight >>8 reads only
+// ~0x80. Scale x2 (>>7) and saturate so a full pull maps to 0xFF.
+static inline uint8_t trigU8(int u16v)
+{
+	int v = u16v >> 7;
+	return (uint8_t)(v > 255 ? 255 : v);
+}
+static inline uint32_t btnsOf(const uint8_t *r)
+{
+	return (uint32_t)r[2] | ((uint32_t)r[3] << 8) | ((uint32_t)r[4] << 16) |
+	       ((uint32_t)r[5] << 24);
+}
+// report 0x45 IMU offsets (PROTOCOL.md §8): accel @0x22, gyro @0x28 from report start.
+void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
+	       int16_t *gx, int16_t *gy, int16_t *gz);
+
+// ---- shared decoded input (filled by deck_input.cpp, read by report45.cpp) ----
+struct PuckInput {
+	// raw Triton buttons (TB_*)
+	uint32_t buttons;
+	int16_t lx, ly, rx, ry; // sticks (int16, center 0)
+	uint8_t lt, rt; // triggers scaled 0..255 (trigU8)
+	int16_t lpx, lpy, rpx, rpy; // left / right trackpad coords (int16)
+	uint16_t lpp, rpp; // left / right trackpad pressure (u16)
+	int16_t ax, ay, az; // accelerometer
+	int16_t gx, gy, gz; // gyroscope
+};
+extern PuckInput g_in;

+ 40 - 20
puck_sniffer/puck_sniffer.ino

@@ -55,7 +55,8 @@ struct Cap;
 #define BONDFILE "/sniffbonds.bin"
 #define BOND_MAGIC 0x53424E32u // "SBN2"
 #define NBONDS 4
-#define BOND_NCH 6 // learned channels per bond (the map is ~4; a couple of spares)
+#define BOND_NCH \
+	6 // learned channels per bond (the map is ~4; a couple of spares)
 struct Bond {
 	uint8_t uuid[4]; // ibex_uuid key; all-zero = empty slot
 	uint8_t base[4]; // session ESB base
@@ -66,7 +67,8 @@ struct Bond {
 };
 static Bond g_bonds[NBONDS];
 static int g_curBond = -1; // index of the bond we're camped on (-1 = none)
-static bool g_bondDirty = false; // a learn changed the table -> flush to flash in loop()
+static bool g_bondDirty =
+	false; // a learn changed the table -> flush to flash in loop()
 static unsigned long g_lastBondSave = 0;
 
 static bool uuidSet(const uint8_t u[4])
@@ -153,7 +155,8 @@ struct Cap {
 	uint8_t ch, flags, rssi, match, n;
 	uint8_t b[96];
 };
-#define RINGN 1024 // ~2 s of headroom at 500 fps so a burst rides out a brief host hiccup without dropping
+#define RINGN \
+	1024 // ~2 s of headroom at 500 fps so a burst rides out a brief host hiccup without dropping
 static Cap g_ring[RINGN];
 static volatile uint16_t g_head = 0, g_tail = 0;
 static inline uint16_t rnext(uint16_t i)
@@ -168,7 +171,8 @@ static void tuneSession();
 static bool g_streaming = false; // app sent START -> emit packet frames
 // g_state/g_curCh are written by loop() (retune) and READ by the RADIO END ISR (which stamps each captured frame
 // with the current channel/state) -> volatile so the ISR never sees a cached value.
-static volatile uint8_t g_state = 0; // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
+static volatile uint8_t g_state =
+	0; // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
 static volatile uint8_t g_curCh = 2;
 static uint8_t g_sBase[4] = { 0x69, 0x62, 0x65, 0x78 };
 static uint8_t g_sPrefix = 0x10;
@@ -201,7 +205,8 @@ static uint8_t g_advCh = 0, g_advBase[4] = { 0, 0, 0, 0 }, g_advPfx = 0;
 // this follows a QoS channel hop. Only after a couple of fully-dry sweeps do we fall back to discovery.
 static const uint8_t SWEEP_CH[] = { 18, 46, 76, 22, 68, 2, 80, 52, 55, 56, 57 };
 static uint8_t g_sweepIdx = 0;
-static volatile uint8_t g_sweepDry = 0; // reset in the RADIO ISR on any session frame
+static volatile uint8_t g_sweepDry =
+	0; // reset in the RADIO ISR on any session frame
 
 // Gazell session-hunt: the puck's 5Hz E1/E2 keepalive trickles in on the primary channel even when the
 // controller isn't being actively polled, so "no packets" never fires the sweep. The ACTIVE session (E3 poll +
@@ -426,17 +431,28 @@ static void emitStatus()
 	uint8_t curN = (g_curBond >= 0) ? g_bonds[g_curBond].nchans : 0;
 	uint8_t bondInfo = (uint8_t)((g_curBond >= 0 ? 0x80 : 0) |
 				     ((nbonds & 0x7) << 4) | (curN & 0xF));
-	uint8_t f[22] = { 0xC1,		  0xDE,
-			  g_state,	  g_curCh,
-			  g_sBase[0],	  g_sBase[1],
-			  g_sBase[2],	  g_sBase[3],
-			  g_sPrefix,	  (uint8_t)(g_streaming ? 1 : 0),
-			  (uint8_t)rx,	  (uint8_t)(rx >> 8),
-			  g_advCh,	  g_advBase[0],
-			  g_advBase[1],	  g_advBase[2],
-			  g_advBase[3],	  g_advPfx,
-			  g_lastMatch,	  (uint8_t)drops,
-			  (uint8_t)(drops >> 8), bondInfo };
+	uint8_t f[22] = { 0xC1,
+			  0xDE,
+			  g_state,
+			  g_curCh,
+			  g_sBase[0],
+			  g_sBase[1],
+			  g_sBase[2],
+			  g_sBase[3],
+			  g_sPrefix,
+			  (uint8_t)(g_streaming ? 1 : 0),
+			  (uint8_t)rx,
+			  (uint8_t)(rx >> 8),
+			  g_advCh,
+			  g_advBase[0],
+			  g_advBase[1],
+			  g_advBase[2],
+			  g_advBase[3],
+			  g_advPfx,
+			  g_lastMatch,
+			  (uint8_t)drops,
+			  (uint8_t)(drops >> 8),
+			  bondInfo };
 	usb_web.write(f, sizeof f);
 	usb_web.flush();
 }
@@ -451,9 +467,11 @@ static void consumeRing()
 	// bound per-call so the loop stays responsive; the 512-deep ring absorbs anything beyond this
 	uint16_t budget = 128;
 	while (budget-- && g_tail != g_head) {
-		Cap c = g_ring[g_tail]; // copy out before releasing the slot to the producer
+		Cap c = g_ring
+			[g_tail]; // copy out before releasing the slot to the producer
 		g_tail = rnext(g_tail);
-		bool retuned = processFrame(c); // may retune the radio (acquire / channel hop)
+		bool retuned = processFrame(
+			c); // may retune the radio (acquire / channel hop)
 		if (emit)
 			emitPacket(c);
 		// On a retune, stop draining this round: the remaining queued frames were captured on the OLD channel, and
@@ -586,7 +604,8 @@ void setup()
 		if (uuidSet(g_bonds[i].uuid))
 			count++;
 	if (count == 1)
-		campBond(b0); // sole known bond -> adopt immediately (unambiguous)
+		campBond(
+			b0); // sole known bond -> adopt immediately (unambiguous)
 	else
 		tuneDiscovery(); // 0 bonds, or several -> discover / wait for a uuid lock
 	g_lastRx = millis();
@@ -612,7 +631,8 @@ void loop()
 	    millis() - g_lastFrx > HUNT_NOFRX_MS &&
 	    millis() - g_huntMs > HUNT_DWELL_MS) {
 		g_huntMs = millis();
-		uint8_t learned = (g_curBond >= 0) ? g_bonds[g_curBond].nchans : 0;
+		uint8_t learned = (g_curBond >= 0) ? g_bonds[g_curBond].nchans :
+						     0;
 		uint8_t total = learned + 1 + (uint8_t)sizeof HUNT_CH;
 		uint8_t slot = g_huntIdx;
 		uint8_t ch;