Преглед на файлове

Merge main and keep Xbox One mode on modular architecture

copilot-swe-agent[bot] преди 4 месеца
родител
ревизия
3e467a3df7
променени са 47 файла, в които са добавени 3514 реда и са изтрити 1978 реда
  1. 280 0
      ARCHITECTURE.md
  2. 86 1975
      OpenPuck/OpenPuck.ino
  3. 30 0
      OpenPuck/bonds.cpp
  4. 24 0
      OpenPuck/bonds.h
  5. 66 0
      OpenPuck/config.cpp
  6. 72 0
      OpenPuck/config.h
  7. 27 0
      OpenPuck/controllers.cpp
  8. 34 0
      OpenPuck/controllers.h
  9. 87 0
      OpenPuck/gamepad_util.cpp
  10. 29 0
      OpenPuck/gamepad_util.h
  11. 225 0
      OpenPuck/haptics.cpp
  12. 83 0
      OpenPuck/haptics.h
  13. 18 0
      OpenPuck/identity.cpp
  14. 15 0
      OpenPuck/identity.h
  15. 71 0
      OpenPuck/mode_hidgyro.cpp
  16. 14 0
      OpenPuck/mode_hidgyro.h
  17. 56 0
      OpenPuck/mode_lizard.cpp
  18. 13 0
      OpenPuck/mode_lizard.h
  19. 73 0
      OpenPuck/mode_ps5.cpp
  20. 14 0
      OpenPuck/mode_ps5.h
  21. 73 0
      OpenPuck/mode_switch_hori.cpp
  22. 15 0
      OpenPuck/mode_switch_hori.h
  23. 227 0
      OpenPuck/mode_switch_pro.cpp
  24. 17 0
      OpenPuck/mode_switch_pro.h
  25. 177 0
      OpenPuck/mode_xinput.cpp
  26. 18 0
      OpenPuck/mode_xinput.h
  27. 291 0
      OpenPuck/puck_hid.cpp
  28. 29 0
      OpenPuck/puck_hid.h
  29. 74 0
      OpenPuck/radio.cpp
  30. 56 0
      OpenPuck/radio.h
  31. 307 0
      OpenPuck/rf_diag.cpp
  32. 36 0
      OpenPuck/rf_diag.h
  33. 306 0
      OpenPuck/rf_link.cpp
  34. 61 0
      OpenPuck/rf_link.h
  35. 84 0
      OpenPuck/serial_console.cpp
  36. 9 0
      OpenPuck/serial_console.h
  37. 28 0
      OpenPuck/status_led.cpp
  38. 28 0
      OpenPuck/status_led.h
  39. 8 0
      OpenPuck/triton.cpp
  40. 61 0
      OpenPuck/triton.h
  41. 17 0
      OpenPuck/wake_hid.cpp
  42. 19 0
      OpenPuck/wake_hid.h
  43. 112 0
      OpenPuck/webusb_config.cpp
  44. 15 0
      OpenPuck/webusb_config.h
  45. 2 0
      README.md
  46. 23 1
      docs/PROTOCOL.md
  47. 104 2
      docs/index.html

+ 280 - 0
ARCHITECTURE.md

@@ -0,0 +1,280 @@
+# OpenPuck Architecture
+
+This document is the map of the firmware. It explains what the device *is*, how the code is laid out, how
+data flows through it, and how to extend it. If you only read one thing before touching the code, read this.
+
+## What the device is
+
+OpenPuck turns an **nRF52840 Pro Micro** into a clone of the Steam Controller 2 "puck" — the little USB radio
+dongle the controller pairs with. It does three jobs at once:
+
+1. **Speaks the puck's RF protocol** to a real Steam Controller 2 ("Triton") over a bare-metal nRF radio (no
+   SoftDevice, no BLE stack). It bonds controllers, transmits the host frame they reconnect on, and polls them
+   for input at ~250 Hz.
+2. **Presents a USB personality** to the host PC/console. The same controller input can be re-dressed as the
+   Valve puck (for Steam), an Xbox 360/Xbox One pad, a Switch controller, a PS5 DualSense, or a DS4 — selected at boot.
+3. **Exposes config surfaces** — a WebUSB channel for the browser panel and a CDC serial console for debugging
+   and the protocol reverse-engineering tooling.
+
+It runs as a **single-threaded `loop()`** plus **USB interrupt callbacks**. There is no RTOS task switching in
+our code; concurrency is cooperative. The only preemption is the USB stack's ISR (which calls our HID report
+callbacks) and the radio's hardware events (which we poll, never interrupt on).
+
+## The big picture
+
+```
+                 ┌──────────────────────── host PC / console ────────────────────────┐
+                 │  USB: one personality at a time (puck / xbox / switch / ps5 / ds4) │
+                 └───────────────▲─────────────────────────────────▲──────────────────┘
+                                 │ HID reports                      │ WebUSB / CDC
+                                 │                                  │
+        ┌────────────────────────┴─────────────┐        ┌──────────┴───────────┐
+        │  controllers.h  (IController vtable)  │        │ webusb_config        │
+        │  mode_*.cpp  — one per personality    │        │ serial_console       │
+        └───────▲───────────────────▲───────────┘        └──────────┬───────────┘
+   onReport45() │                   │ task()                        │ tunables / commands
+        (push)  │                   │ (stream)                      ▼
+        ┌───────┴───────────────────┴───────────────────────────────────────────┐
+        │  rf_link.cpp  — operational puck protocol                              │
+        │   • host-frame beacon  • connected poll (E7/E3)  • decode 0xF1 → g_in  │
+        │   • mode-switch chord  • remote wakeup  • QoS channel hop  • stats     │
+        └───────▲──────────────────────────────────────────▲────────────────────┘
+                │ rfConfig / rfConnTx                       │ relay (haptics.cpp)
+        ┌───────┴────────────┐                     ┌────────┴──────────┐
+        │  radio.cpp         │   rf_diag.cpp ─────▶│  (RE/calibration  │
+        │  nRF52 RADIO HW    │   (capture/sniff/   │   tooling, isolated)
+        └────────────────────┘    replay/sweep)    └───────────────────┘
+
+   shared state:  config (modes+tunables) · identity · bonds (slots+link) · triton (g_in) · gamepad_util
+```
+
+The **central data flow** is one direction for input:
+
+> radio RX → `rf_link` decodes report `0x45` into **`g_in`** (a `PuckInput` struct) → the active
+> `IController` reads `g_in` and emits a host HID report.
+
+and the reverse for haptics/config:
+
+> host OUTPUT report / WebUSB / CDC → queued into the haptic relay or a tunable → `rf_link` flushes the relay
+> to the controller inside the poll cadence.
+
+## Module layout (`OpenPuck/`)
+
+The Arduino build compiles every `.cpp`/`.h` in the sketch folder; the build command is unchanged (it still
+just points at the `OpenPuck` directory). Modules are layered low → high:
+
+| File | Responsibility |
+|---|---|
+| **`OpenPuck.ino`** | Entry point only: `setup()` builds the USB presentation for the persisted mode + arms the watchdog; `loop()` pumps each subsystem. |
+| `config.{h,cpp}` | USB-mode definitions (`MODE_*`), persisted settings (`cfg.bin`), all runtime tunables, mode-switch persistence policy. |
+| `identity.{h,cpp}` | Unique device serial from FICR DEVICEID; the puck `0x83` attribute blob. |
+| `bonds.{h,cpp}` | The four bond slots (`g_slot`), bond persistence (`bonds.bin`), and the live link state (`g_connSlot`, `g_connReplyMs`). |
+| `radio.{h,cpp}` | Bare-metal nRF52 `RADIO` register layer: PHY/CRC/whitening/address config (all live-tunable), `rfConfig()`/`rfSetAddr()`, the `rfrx`/`rftx` DMA buffers. |
+| `triton.{h,cpp}` | The controller's native input: report `0x45` button masks (`TB_*`) + decoders, and the shared decoded-input struct **`g_in`**. |
+| `gamepad_util.{h,cpp}` | Cross-mode helpers for building host reports: stick rescaling, Steam-trackpad → touch mapping, PlayStation-layout button packers. |
+| `haptics.{h,cpp}` | Host→controller haptic relay queue + the watchdogs that stop a latched buzz; the OUTPUT-report capture ring. |
+| **`controllers.{h,cpp}`** | The `IController` abstraction + the `mode → singleton` registry. |
+| `puck_hid.{h,cpp}` | **Steam/Lizard** personality: the puck HID slot interfaces, the feature command channel (bond read/write, attributes), the seamless auto-lizard decision, and the USB connection presentation. |
+| `mode_lizard.{h,cpp}` | The lizard keyboard+mouse mapping (rides on the puck interface; not a standalone controller). |
+| `mode_xinput.{h,cpp}` | **Xbox (360/One)** personality: a custom TinyUSB XInput class driver + right-pad mouse + rumble relay. |
+| `mode_switch_hori.{h,cpp}` | **Switch HORIPAD** personality (console-friendly, no handshake). |
+| `mode_switch_pro.{h,cpp}` | **Switch Pro** personality: the full Nintendo USB handshake/subcommand state machine + SPI calibration + gyro. |
+| `mode_ps5.{h,cpp}` | **PS5 DualSense** personality: gyro + split trackpad. |
+| `mode_hidgyro.{h,cpp}` | **DS4-layout** generic HID gyro personality (motion-aware PC games). |
+| `rf_link.{h,cpp}` | The operational puck protocol: host-frame beacons, connected-mode poll loop, `0xF1` decode + dispatch, chord detection, remote wakeup, QoS hopping, stats. |
+| `rf_diag.{h,cpp}` | RF reverse-engineering / calibration tooling: raw capture, CRC-validating config sweeps, frame replay, address listen, scan-then-respond, live-session sniffer. Not used in normal operation. |
+| `webusb_config.{h,cpp}` | The WebUSB binary config channel for the browser panel. |
+| `serial_console.{h,cpp}` | The CDC single-letter debug command line. |
+| `wake_hid.{h,cpp}` | A boot-mouse HID interface added to the clean controller modes so the host honors USB remote-wakeup (see "Wake from sleep"). |
+| `status_led.{h,cpp}` | Wake-sent LED indicator: dark in all steady states; flashes 500 ms at each `remoteWakeup()`. Drives both the Feather user LED (P1.15) and the SuperMini clone's blue LED (P0.15); pins/polarity overridable. |
+
+## The controller abstraction
+
+`IController` (in `controllers.h`) is the seam that makes each USB personality a self-contained file. Exactly
+one controller is active per boot, chosen by `g_usbMode` via `controllerFor()` and stored in `g_active`.
+
+```cpp
+class IController {
+  virtual void begin();                                              // setup(): register USB interface(s) + IDs
+  virtual void onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen);  // RF input arrived (PUSH modes)
+  virtual void task();                                               // loop(): streaming emit / handshake drains
+  virtual bool isPuck() const;                                       // Steam/Lizard keep the boot CDC composite
+};
+```
+
+Two emission styles:
+
+- **Push modes** (puck/lizard, xbox) build and send their host report inside `onReport45()`, driven by each
+  decoded RF frame. They care about `fresh` (was the report new?) and the raw report bytes.
+- **Stream modes** (switch hori/pro, ps5, ds4) ignore `onReport45()` and instead emit at a fixed ~250 Hz
+  cadence from `task()`, reading the latest `g_in`. This decouples the USB report rate from the RF rate, which
+  is what those hosts expect.
+
+`setup()` owns the shared USB lifecycle (detach, keep-or-clear the CDC composite based on `isPuck()`, set the
+per-mode serial suffix, attach); `begin()` only adds the interfaces specific to that controller.
+
+### Adding a new personality
+
+1. Create `mode_foo.{h,cpp}` defining `class FooController : public IController` and a singleton
+   `extern FooController g_fooCtl;`.
+2. Implement `begin()` (set `USBDevice` VID/PID/strings, register your HID/vendor interface) and either
+   `onReport45()` (push) or `task()` (stream) reading `g_in`.
+3. Add a `MODE_FOO` constant in `config.h` (bump `MODE_MAX`), a `MODE_SUFFIX` letter in `OpenPuck.ino`, and a
+   `case MODE_FOO: return &g_fooCtl;` in `controllerFor()` (`controllers.cpp`).
+
+Nothing in `rf_link`, `radio`, or the other modes needs to change — `g_in` is your input contract.
+
+## Shared input state: `g_in`
+
+`rf_link` decodes the controller's report `0x45` into a single `PuckInput g_in` (`triton.h`): buttons, sticks,
+triggers, both trackpad coordinates, and IMU (accel + gyro). Every controller reads from this one struct. (The
+pre-refactor firmware copied the same IMU/stick/button values into three separate per-mode global sets; that
+duplication is gone.)
+
+The raw report bytes are still passed to `onReport45()` for the modes that forward them verbatim (the Steam
+path forwards the puck's raw `0x45` body untouched so Steam does its own trackpad smoothing).
+
+## The RF protocol, briefly
+
+Full details are in `docs/PROTOCOL.md`; the register-level constants and their provenance are commented in
+`radio.cpp` and `rf_link.cpp`. The essentials:
+
+- **PHY/framing** (decoded from real-puck capture): `Ble_2Mbit`, big-endian, whitening off, 5-byte address,
+  CRC16 `0x11021`/`0xFFFF` (address included). On-air address is the bit-reversed stored bytes.
+- **Two addresses — discovery vs session**: the *discovery/rendezvous* address is the shared, controller-known
+  `"ibex"` on channel 2; every puck uses it so any controller can find any puck. The *session* address is
+  **unique per device** (`g_sessBase`/`g_sessPrefix`, derived from the FICR DEVICEID in `rfGenSessionAddr()`).
+  This mirrors the real puck and is what keeps two OpenPucks from colliding on-air (see "Multi-puck isolation").
+- **Bonding/reconnect**: we transmit a *host frame* — on the shared `"ibex"` rendezvous (ch2) for a searching
+  controller, and on our unique session address as a keepalive once connected. Both carry the bonded
+  controller's UUIDs plus the **session** base/prefix/channel we want it to adopt. The controller filters by
+  UUID, adopts the advertised session address, and the connected poll then runs there (default channel 18).
+- **Connected poll**: we are the PTX/master. We announce host-awake (`E7 00 00`), then each poll cycle send
+  `E3` + a GET-report-`0x45` sub-TLV; the controller replies with a `0xF1` input report containing TLVs
+  (type 6 = the HID report `0x45`). Cycling the ESB PID on the poll is what drains the controller's report
+  queue at full rate (~400 new reports/s instead of ~60).
+- **Haptics** ride back as a SET sub-TLV inside the poll (`haptics.cpp` queues, `rf_link` flushes).
+
+## Timing & concurrency model
+
+- One cooperative `loop()`. Each subsystem's per-loop hook (`g_active->task()`, `rfLinkTask()`, `hapticTask()`,
+  `rfDiagTask()`, `webusbPoll()`, `serialConsolePoll()`) must be **non-blocking** — anything that spins risks
+  starving USB servicing.
+- The RF poll is **busy-wait bounded**: every radio wait (`RWAIT_DISABLED`, the RX windows) has a microsecond
+  timeout and bails rather than hanging. A wedged radio must never freeze the loop.
+- A **hardware watchdog** (~8 s, armed in `setup()`) resets the chip if `loop()` ever stops feeding it — so a
+  hang re-enumerates USB and re-inits RF on its own without a physical replug.
+- USB HID report callbacks (`handleSet`/`handleGet` in `puck_hid`, `jcSet` in `mode_switch_pro`) run in **ISR
+  context**. They only stage data (into bond slots, the relay queue, or an SPSC reply ring); the actual
+  `sendReport` happens later from `task()`/`loop()` where it's safe.
+- `volatile` marks state shared across the ISR/loop boundary (the relay queue, the Switch-Pro reply ring
+  indices, the rumble level).
+
+## Persistence
+
+Two files in the nRF52 internal LittleFS:
+
+- `cfg.bin` (`config.cpp`) — USB mode, tunables, chord assignments, persistence policy. A magic byte versions
+  the layout; a mismatch falls back to clean defaults.
+- `bonds.bin` (`bonds.cpp`) — the four bond records.
+
+Mode switches (chord, WebUSB, or CDC) call `saveMode()` then reboot so the next boot enumerates the right
+interface set. By default every cold boot returns to Steam mode unless "persist last mode" is enabled.
+
+## Config & debug surfaces
+
+- **WebUSB** (`webusb_config.cpp`): present in every mode; the browser panel reads a status blob and sets
+  one-byte tunable fields or requests a mode switch.
+- **CDC console** (`serial_console.cpp`): present only in puck modes. Single-letter commands toggle RF diag
+  modes, poke radio registers live, switch USB mode, edit tunables, inject test haptics, and dump capture
+  history. It is an `else-if` chain, so the **first** matching letter wins — a couple of letters (`C`, `H`)
+  appear twice and the second occurrence is currently unreachable (preserved verbatim from the original; worth
+  cleaning up if those commands are ever needed).
+
+## Multi-puck isolation
+
+Because the discovery address is shared, two things prevent one puck from disturbing another:
+
+1. **Unique session address.** After bonding, each puck moves the controller onto its own per-device session
+   base/prefix (advertised in the host frame). The connected poll and its RX windows run there, so a puck only
+   ever hears its own controller — not a neighbor's session traffic.
+2. **Reply-type gate.** The link-alive timestamp (`g_connReplyMs`, which drives the "controller reconnected →
+   wake the host" remote-wakeup) is stamped only on genuine controller replies (F-type: `0xF1`/`0xF2`/`0xF3`),
+   never on a puck→controller frame (E-type: `0xE1` host frame, `0xE2/E3/E7`). This is belt-and-suspenders: it
+   means even a neighbor's `0xE1` discovery beacon picked up on the shared rendezvous can't be mistaken for a
+   reconnect. (Before these two fixes, plugging a second puck into another computer would wake a sleeping host
+   running the first puck.)
+
+## Wake from sleep
+
+Waking the host is an **explicit gesture, never a side effect of input**. While the bus is suspended, the puck
+personality sends *nothing* (no `0x45` forwards, no lizard mouse/keyboard, no periodic `0x79`/`0x7B` status) —
+a suspended-bus `sendReport` can itself translate into a host wake, which made the PC wake on any trackpad
+graze and nearly impossible to keep asleep. The only wake triggers are a **Steam-button short press** or a
+**controller connect**, detected in `rf_link.cpp` (guarded by `USBDevice.suspended()`). Each fires two things:
+
+1. `USBDevice.remoteWakeup()` — the device-level USB resume signal; and
+2. `g_active->wakeEvent()` — queues a **wake nudge**, because a bare resume signal is **not** enough to wake
+   some hosts (Windows especially): they only wake when real mouse/keyboard input *follows* the resume. Once
+   the bus is back up, the puck personality plays a **mouse jiggle** on its own report (`wakeNudgeTask` in
+   `puck_hid.cpp`): move a few px right, then back — net-zero cursor, no button. Real activity wakes the host
+   but clicks/activates nothing (an open Start menu stays open). It can't be sent *during* suspend, so it's
+   delivered right after resume. (Earlier versions sent a click / space / Ctrl too; the click + space were
+   activating the focused Start tile and launching the browser, so they were dropped.)
+
+Separately, `onReport45()` **mutes input forwarding for ~1.5 s after resume** (`POST_RESUME_MUTE_MS`): right
+after wake the controller's own input — a trackpad click/trigger, or residual button state from the wake
+gesture — would otherwise be forwarded as a real click/keypress into the just-woken desktop, which was what
+activated the highlighted Start tile (Edge) on every wake. The jiggle is exempt (it's sent directly, not via
+the forwarding path).
+
+The board LED is a wake debugger (`status_led.cpp`): dark in all steady states (including while armed), and a
+500 ms flash at the moment a `remoteWakeup()` is actually sent. Flash + host stays asleep = the resume was sent
+and the host ignored it (fix host-side, e.g. `powercfg /deviceenablewake`); no flash = the firmware never fired
+(it didn't see the gesture, or didn't consider the bus suspended). USB remote wakeup is a **device-level**
+signal — one wake line per device, armed by a single `SET_FEATURE(DEVICE_REMOTE_WAKEUP)`; there is no
+per-interface arming on the wire. Which Windows device *node* gets credited is host-internal policy.
+
+Every mode advertises the remote-wakeup capability in its config descriptor, so the device is always *armed*.
+The catch is that a host only *honors* a resume from an allow-listed input device class (HID keyboard/mouse) —
+a bare gamepad/vendor/composite presentation gets armed but ignored (notably Windows under Modern Standby).
+That's why Xbox mode (which exposes a boot mouse) woke Windows while the gamepad/puck modes didn't.
+
+Why a function (not just the device) must be wake-capable: device-level remote-wakeup is necessary but not
+sufficient on Windows — a *function driver* must hold a pending wait-wake request (`IRP_MN_WAIT_WAKE`) for the
+system to actually wake, and only the mouse/keyboard class drivers (`mouhid`/`kbdhid`) arm one by default
+(that's the "Allow this device to wake the computer" checkbox). The puck's generic-HID / CDC / WebUSB functions
+don't, so its armed device-level wake was getting ignored. A boot-mouse function fixes that.
+
+The fix: `wake_hid.cpp` registers a do-nothing **boot mouse** interface (the same shape Xbox mode already uses
+to wake Windows) so each mode exposes a `mouhid`-bound, wait-wake-armed function. It's added in `setup()` for
+every **clean** mode and for **puck mode on a normal boot**.
+
+**Puck mode and the endpoint budget.** The nRF52840 has only 7 data IN endpoints. The puck composite used all
+7 (CDC = 2, 4 puck HID, WebUSB = 1), leaving no room for a wake interface. So puck mode now **drops the CDC
+serial console by default** (6 IN: 4 HID + WebUSB + wake mouse) — gaining Windows wake at the cost of the
+serial debug console. When you genuinely need the console, arm the **one-shot debug CDC** (WebUSB panel field
+20, or the CDC `D` command): the *next* boot keeps CDC and skips the wake mouse, and the boot after that reverts
+automatically (`g_debugCdcThisBoot`, stored in the reused `Cfg.rsvd0` byte, consumed in `loadCfg` exactly like
+`bootMode`). The two variants use different `bcdDevice` (0x0211 normal / 0x0212 debug) so Windows re-reads when
+you switch. (Trade-off to verify on hardware: the code historically warned that clearing the puck's CDC
+composite hurt simultaneous Steam + Chrome-WebUSB on Windows; the `bcdDevice` bump + MS OS 2.0 descriptors
+should keep WebUSB binding, but confirm Steam pairing + the browser panel still work.)
+
+Note hosts differ in what they honor: Windows keys on the input device class (this fix targets it); Linux/Steam
+Deck gates wake per-device in `/sys/.../power/wakeup` regardless of class, so a mode that still won't wake the
+Deck may just need that sysfs flag enabled host-side.
+
+**`bcdDevice` rule:** Windows caches a device's configuration descriptor keyed by `VID:PID:bcdDevice`. If you
+change a mode's interfaces (as adding the wake mouse did) without bumping that mode's `setDeviceVersion(...)`,
+Windows keeps serving the stale cached descriptor — the change is invisible and wake silently breaks, with no
+fix short of the user manually uninstalling the device. So: **whenever you change a mode's USB descriptor, bump
+its `bcdDevice`.** The clean modes were bumped for the wake interface; each `begin()` documents its value.
+
+## RF reverse-engineering tooling
+
+`rf_diag.cpp` holds everything used to *discover* the protocol — promiscuous capture, config sweeps, bit-exact
+replay, the session sniffer, scan-then-respond. The protocol is solved, so none of it runs in normal use, but
+it's kept compiled and reachable (via the CDC console) because it's the only way to re-derive parameters if a
+controller firmware update ever changes them. It depends only on `radio` and `bonds`, so it stays out of the
+operational path in `rf_link`.

+ 86 - 1975
OpenPuck/OpenPuck.ino

@@ -1,2029 +1,140 @@
 // OpenPuck.ino -- Steam Controller 2 ("Triton") puck reimplementation for an nRF52840.
 //
-// This sketch impersonates the Valve puck over USB, maintains puck-style bond slots, speaks the
-// reverse-engineered RF protocol to the controller, and re-enumerates into Steam, Xbox, or Switch
-// personalities. The real puck exposes four separate HID control interfaces; interface N owns bond
-// slot N, so this sketch does the same. Build with -DCFG_TUD_HID=4 (the Adafruit nRF port defaults to 2).
+// This firmware impersonates the Valve puck over USB, maintains puck-style bond slots, speaks the
+// reverse-engineered RF protocol to the controller, and re-enumerates into Steam, Xbox, Switch, PS5, or DS4
+// personalities. Build with -DCFG_TUD_HID=4 (the Adafruit nRF port defaults to 2).
 //
-// USB presentations (7 modes): Steam puck (28DE:1304) with auto-lizard when Steam closes; Lizard = always
-// kb/mouse on the puck; Xbox = XInput+mouse; Switch HORIPAD (0F0D:0092); Switch Pro (057E:2009)+gyro;
-// PS5 DualSense (054C:0CE6)+gyro+split trackpad; HID gyro = DS4-layout (054C:05C4) for motion-aware games.
-// Every mode exposes a WebUSB config channel (USB 2.1 + BOS). Clean controller modes omit CDC Serial.
+// This file is just the entry point: setup() builds the USB presentation for the persisted mode and arms the
+// hardware watchdog; loop() pumps each subsystem. Everything substantive lives in the modules below -- see
+// ARCHITECTURE.md for the full map. The short version:
+//   config / identity / bonds  -- persisted settings, device serial, the four controller bond slots
+//   radio / rf_link / rf_diag   -- bare-metal nRF52 RADIO, the operational puck protocol, and RE tooling
+//   triton / gamepad_util       -- the decoded controller input (g_in) + shared host-report build helpers
+//   controllers + mode_*        -- one IController per USB personality (puck/lizard, xbox, switch, ps5, ds4)
+//   haptics / webusb_config / serial_console -- haptic relay, the browser config channel, the CDC debug CLI
 //
 // Protocol reference: docs/PROTOCOL.md.  Bond record = [8 uuid][16 serial].
-
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
-// custom class-driver API (usbd_class_driver_t, usbd_edpt_*) for the XInput interface
-extern "C" {
-#include "device/usbd_pvt.h"
-}
-// Bounded radio-disable wait: NEVER spin forever. A wedged RADIO peripheral would otherwise hang the whole
-// main loop (and USB stops being serviced -> device "dies" until replug). On timeout we bail and continue;
-// the next rfConfig re-inits the radio.
-#define RWAIT_DISABLED() do{ uint32_t _w=micros(); while(!NRF_RADIO->EVENTS_DISABLED && (uint32_t)(micros()-_w)<3000){} }while(0)
 using namespace Adafruit_LittleFS_Namespace;
 
+#include "config.h"
+#include "identity.h"
+#include "bonds.h"
+#include "radio.h"
+#include "controllers.h"
+#include "haptics.h"
+#include "rf_link.h"
+#include "rf_diag.h"
+#include "webusb_config.h"
+#include "serial_console.h"
+#include "wake_hid.h"
+#include "status_led.h"
+
 #if CFG_TUD_HID < 4
 #error "build with -DCFG_TUD_HID=4 (extra_flags): up to 4 HID interfaces per mode"
 #endif
 
-// ---- cloned puck HID report descriptor (verbatim): mouse(0x40)+keyboard(0x41)+vendor(FF00) with
-//      the 63-byte FEATURE command reports on report id 1/2. Each of the 4 interfaces uses this. ----
-static const uint8_t PUCK_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
-};
-
-#define NSLOT 4
 static uint8_t g_usbCfgDesc[512];   // puck composite (4 HID + WebUSB) exceeds the default 256 B config buffer
-Adafruit_USBD_HID hid[NSLOT];
-// XBOX MODE = a MUTUALLY-EXCLUSIVE USB presentation: the device enumerates as EITHER the 4 puck slot
-// interfaces (Steam mode) OR an XInput controller + a mouse (Xbox mode) — never both. The mode is persisted to
-// flash and a button combo (back4+X -> xbox, back4+A -> steam) saves it and reboots, so each boot enumerates
-// the right interface set. RF (beacon/poll/bonds) is identical in both modes; only the USB side differs.
-static const uint8_t MOUSE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
-Adafruit_USBD_HID g_mouse;   // Xbox-mode mouse interface (right trackpad). Steam-mode lizard reuses the puck's own 0x40 mouse report instead.
-// USB presentation modes (g_usbMode). RF poll/relay is identical across all; only USB enumeration + report mapping differs.
-#define MODE_STEAM    0   // Valve puck; auto-lizard when Steam closed
-#define MODE_XBOX     1   // Xbox 360 (XInput + right-pad mouse)
-#define MODE_SW_HORI  2   // HORIPAD (Switch console whitelist)
-#define MODE_LIZARD   3   // Puck HID; always keyboard+mouse (ignores Steam heartbeat)
-#define MODE_SW_PRO   4   // Nintendo Switch Pro Controller (057E:2009) + gyro
-#define MODE_PS5      5   // Sony DualSense (054C:0CE6) + gyro + split trackpad
-#define MODE_HIDGYRO  6   // DS4-layout generic HID gamepad + gyro (Fortnite-friendly)
-#define MODE_XBOX_ONE 7   // Xbox One identity (XInput + right-pad mouse)
-#define MODE_MAX      7
-static uint8_t g_usbMode = 0;   // loaded from flash at boot
-static bool    g_xbox = false;   // true for all non-puck presentations (not STEAM/LIZARD)
-static uint8_t g_chordBtn[3] = { MODE_LIZARD, MODE_XBOX, MODE_SW_HORI };   // back4+B/X/Y -> these modes (A always STEAM)
-static inline bool modeIsPuck(uint8_t m){ return m==MODE_STEAM || m==MODE_LIZARD; }
-static inline bool modeIsXbox(uint8_t m){ return m==MODE_XBOX || m==MODE_XBOX_ONE; }
-static inline bool modeValid(uint8_t m){ return m<=MODE_MAX; }
-// Mode persistence policy: by DEFAULT every fresh power-on/reconnect lands in STEAM mode (0). An explicit
-// mode switch still works for the session via a ONE-SHOT bootMode (honored once, then cleared, so the next
-// cold boot reverts to Steam). The WebUI "persist last mode" toggle (g_persistMode) instead remembers the
-// last selected mode across reboots.
-static bool    g_persistMode = false;   // false (default) = always boot Steam; true = boot into last mode
-static uint8_t g_bootMode = 0xFF;       // one-shot: boot into this mode once then clear (used when !persistMode + explicit switch)
-volatile uint8_t g_rumble = 0;   // legacy XInput rumble strength from the host's OUT packet (0=off); drives the haptic relay when the XInput descriptor is used
-volatile unsigned long g_rumbleMs = 0;   // millis of last rumble OUT packet (declared here — used by xi_xfer above the watchdog block); stuck-rumble watchdog
-// persisted, runtime-tunable config (Cfg struct + load/saveCfg below; CDC M/F/D/W/B + WebUSB set these):
-static int     g_mDiv = 64, g_mFric = 94;    // xbox mouse sensitivity divisor / friction%
-static uint8_t g_abSwap = 0;                 // 1 = swap A/B and X/Y (Nintendo face-button layout)
-static uint8_t g_back[4] = {5,6,7,8};        // back paddles L4,R4,L5,R5 -> button codes (see codeToXB; 5=LB 6=RB 7=L3 8=R3)
-#define POLL_US_DEFAULT 4000u                 // 250 Hz — matches SC2 input report rate (1000000/250 = 4000 us)
-#define USB_STREAM_MS   4u                    // host-side HID stream cadence for translated modes (~250 Hz)
-static const uint32_t g_pollUs = POLL_US_DEFAULT;   // RF poll cadence (us). FIXED — not configurable. Any rate persisted by an older build is ignored and overwritten with the default on boot (see loadCfg).
-                                             // Faster than the controller can refresh wastes airtime; slower adds latency.
-static uint32_t g_rxWin = 1200;              // poll RX-window (us): max wait for the reply. This (not pollUs) caps poll rate
-                                             // (~1e6/rxWin). Shorter=more polls/s but may miss the controller's DELAYED replies
-                                             // during trackpad. Tunable 'r<us>'. The reply normally arrives fast; 1200 is the worst-case wait.
-static uint8_t g_fwdNewOnly = 1;             // Steam mode: forward report 0x45 only when seq advanced (dedupe like the real puck).
-                                             // Sending stale repeats makes Steam's trackpad smoothing stair-step. Toggle to A/B.
-// QoS adaptive channel hopping: track link badness (crcfail+noRx) on the current channel; when it degrades,
-// hop to the next clean candidate (controller follows via rfHopTo). Conservative hysteresis to avoid blip-storms.
-static uint8_t  g_qos = 0;                   // 0=off (static g_sessCh), 1=auto-hop on degradation
-static const uint8_t g_hopCand[] = {18,46,76,22,68};  // clean, spread channels (from the puck's RSSI/PER scan)
-static uint8_t  g_hopIdx = 0;
-static volatile uint16_t g_qosBad = 0;       // crcfail+noRx accumulated since last QoS check
-static unsigned long g_qosCheckMs = 0, g_qosLastHopMs = 0;
-
-// WebUSB config channel — a dedicated vendor interface present in every mode so the browser panel
-// (copycat_config.html) can read/set the tunables above. Protocol + poll in webusbPoll() below.
-// NOTE: no setLandingPage() — that would make Chrome pop a "device detected, open <url>" notification
-// every plug-in. The interface works without it; the user just opens copycat_config.html themselves.
-Adafruit_USBD_WebUSB usb_web;
-
-// ===================== XInput (Xbox 360 wired) device — Xbox-mode gamepad =====================
-// Real Xbox 360 pads are NOT HID: vendor interface class 0xFF/sub 0x5D/proto 0x01 + 20-byte XInput report.
-// Custom TinyUSB class driver (registered via usbd_app_driver_get_cb) + an Adafruit_USBD_Interface subclass
-// that emits the interface+0x21-blob+2 endpoints. Steam/SDL bind it as "Xbox 360 Controller" by VID/PID +
-// the FF/5D/01 interface. (Verified against Adafruit nRF52 1.7.0 / TinyUSB 0.18.)  XInput button bits:
-enum { XB_DUP=0x0001,XB_DDOWN=0x0002,XB_DLEFT=0x0004,XB_DRIGHT=0x0008, XB_START=0x0010,XB_BACK=0x0020,
-       XB_L3=0x0040,XB_R3=0x0080, XB_LB=0x0100,XB_RB=0x0200,XB_GUIDE=0x0400, XB_A=0x1000,XB_B=0x2000,XB_X=0x4000,XB_Y=0x8000 };
-#define XINPUT_DESC_LEN (9 + 17 + 7 + 7)   // interface(9)+vendor0x21(17)+IN ep(7)+OUT ep(7) = 40
-static uint8_t g_xiItf=0xFF, g_xiEpIn=0, g_xiEpOut=0;
-static uint8_t g_xiInBuf[32], g_xiOutBuf[32];
-static void xi_init(void){}
-static bool xi_deinit(void){return true;}
-static void xi_reset(uint8_t rhport){(void)rhport; g_xiEpIn=g_xiEpOut=0;}
-static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const* itf, uint16_t max_len){
-  if(!(itf->bInterfaceClass==0xFF && itf->bInterfaceSubClass==0x5D && itf->bInterfaceProtocol==0x01)) return 0;
-  g_xiItf=itf->bInterfaceNumber;
-  uint8_t const* p=(uint8_t const*)itf; uint8_t const* end=p+max_len; uint16_t used=itf->bLength; p+=itf->bLength;
-  uint8_t opened=0;
-  while(p<end && opened<itf->bNumEndpoints){
-    uint8_t blen=p[0], btype=p[1];
-    if(btype==TUSB_DESC_ENDPOINT){ tusb_desc_endpoint_t const* ep=(tusb_desc_endpoint_t const*)p;
-      usbd_edpt_open(rhport,ep);
-      if(tu_edpt_dir(ep->bEndpointAddress)==TUSB_DIR_IN) g_xiEpIn=ep->bEndpointAddress; else g_xiEpOut=ep->bEndpointAddress;
-      opened++; }
-    used+=blen; p+=blen;
-  }
-  if(g_xiEpOut) usbd_edpt_xfer(rhport,g_xiEpOut,g_xiOutBuf,sizeof g_xiOutBuf);  // arm OUT (rumble/LED)
-  return used;
-}
-static bool xi_ctrl(uint8_t rhport,uint8_t stage,tusb_control_request_t const* req){(void)rhport;(void)req; return stage!=CONTROL_STAGE_SETUP;}
-static bool xi_xfer(uint8_t rhport,uint8_t ep,xfer_result_t res,uint32_t n){(void)res;
-  if(ep==g_xiEpOut){
-    // XInput rumble packet: [00][08][00][bigMotor][smallMotor][00][00][00]; LED pkt is [01][03][led]
-    if(n>=5 && g_xiOutBuf[0]==0x00 && g_xiOutBuf[1]==0x08){
-      uint8_t big=g_xiOutBuf[3], sml=g_xiOutBuf[4]; g_rumble = big>sml?big:sml; g_rumbleMs = millis();   // stamp for the stuck-rumble watchdog
-    }
-    usbd_edpt_xfer(rhport,g_xiEpOut,g_xiOutBuf,sizeof g_xiOutBuf);    // re-arm OUT
-  }
-  return true; }
-static const usbd_class_driver_t g_xiDriver = {
-#if CFG_TUSB_DEBUG>=2
-  .name="XINPUT",
-#endif
-  .init=xi_init, .deinit=xi_deinit, .reset=xi_reset, .open=xi_open,
-  .control_xfer_cb=xi_ctrl, .xfer_cb=xi_xfer, .sof=NULL };
-extern "C" const usbd_class_driver_t* usbd_app_driver_get_cb(uint8_t* count){ *count=1; return &g_xiDriver; }
-class Adafruit_USBD_XInput : public Adafruit_USBD_Interface {
-public:
-  uint16_t getInterfaceDescriptor(uint8_t, uint8_t* buf, uint16_t bufsize) override {
-    if(!buf) return XINPUT_DESC_LEN; if(bufsize<XINPUT_DESC_LEN) return 0;
-    uint8_t itfnum=TinyUSBDevice.allocInterface(1);
-    uint8_t epin=TinyUSBDevice.allocEndpoint(TUSB_DIR_IN), epout=TinyUSBDevice.allocEndpoint(TUSB_DIR_OUT);
-    const uint8_t t[XINPUT_DESC_LEN]={
-      9,TUSB_DESC_INTERFACE,itfnum,0x00,0x02,0xFF,0x5D,0x01,_strid,
-      0x11,0x21,0x00,0x01,0x01,0x25,epin,0x14,0x00,0x00,0x00,0x00,0x13,epout,0x08,0x00,0x00,
-      7,TUSB_DESC_ENDPOINT,epin, TUSB_XFER_INTERRUPT,U16_TO_U8S_LE(0x20),1,   // bInterval 1ms (1000Hz) so the RF rate is the only limit
-      7,TUSB_DESC_ENDPOINT,epout,TUSB_XFER_INTERRUPT,U16_TO_U8S_LE(0x20),8 };
-    memcpy(buf,t,XINPUT_DESC_LEN); return XINPUT_DESC_LEN;
-  }
-  bool begin(){ return TinyUSBDevice.addInterface(*this); }
-};
-Adafruit_USBD_XInput g_xinput;
-static void xinputSend(uint16_t buttons,uint8_t lt,uint8_t rt,int16_t lx,int16_t ly,int16_t rx,int16_t ry){
-  if(!tud_mounted() || g_xiEpIn==0 || usbd_edpt_busy(0,g_xiEpIn)) return;
-  uint8_t* r=g_xiInBuf;
-  r[0]=0x00; r[1]=0x14; r[2]=buttons&0xFF; r[3]=buttons>>8; r[4]=lt; r[5]=rt;
-  r[6]=lx&0xFF; r[7]=lx>>8; r[8]=ly&0xFF; r[9]=ly>>8; r[10]=rx&0xFF; r[11]=rx>>8; r[12]=ry&0xFF; r[13]=ry>>8;
-  memset(r+14,0,6);
-  if(usbd_edpt_claim(0,g_xiEpIn)){ if(!usbd_edpt_xfer(0,g_xiEpIn,r,20)) usbd_edpt_release(0,g_xiEpIn); }
-}
-
-// ---- identity: a UNIQUE serial from the FICR DEVICEID (never clashes with a real puck) ----
-static char g_unit[16];   // "FXB99602xxxxx"
-static char g_board[16];  // "MXB99602xxxxx"
-static char g_usbSerial[18];   // per-mode USB serial (Steam uses g_unit; others get a suffix — see setup)
-static const uint8_t ATTR83[] = {     // 0x83 attributes (product 0x1304 = puck)
-  0x01,0x04,0x13,0x00,0x00,0x02,0x00,0x00,0x00,0x00,0x0A,0xF2,0xF9,0xD2,0x68,
-  0x04,0x53,0xD0,0x18,0x6A,0x09,0x47,0x00,0x00,0x00 };
-
-// ---- per-slot state (record = [8 uuid][16 serial]) + per-interface response staging ----
-struct Slot { uint8_t rec[24]; bool used; uint8_t resp[63]; uint16_t resp_len; };
-static Slot g_slot[NSLOT];
-// connection tracking (defined here so the USB feature handler can report B4 connection state)
-static int           g_connSlot = -1;    // bonded slot being polled (== the USB-connected slot)
-static unsigned long g_connReplyMs = 0;  // millis of last RF reply (link-alive timestamp)
-// host->controller relay: Steam writes feature report id 0x01 (raw passthrough) carrying a controller
-// feature report [report-id][len][payload...] (e.g. 87 03 09 00 00 = settings: lizard-mode off). The real
-// dongle forwards it to the controller as a SET sub-TLV in the E3 poll; the copycat must do the same.
-static volatile bool g_relayPend = false;
-static uint8_t       g_relayBuf[24];
-static volatile uint8_t g_relayN = 0;
-static uint8_t g_relayOp  = 0xE3;   // relay frame opcode (E3 poll)
-static uint8_t g_relaySub = 0x05;   // relay sub-TLV type byte = SET (confirmed on hardware: E3 + [len][05][rid][payload] makes the controller act on it — haptics buzz + lizard-off lands). GET is 01.
-static volatile uint8_t g_testHaptic = 0;   // 't<n>' injects n test haptics (output 0x82 [01 01 F7]) for the buzz hunt
-// Haptic watchdog — stops the "stuck high-freq buzz" when the host's stop command is lost over RF.
-// Xbox rumble is a persistent LEVEL with no keepalive: a dropped stop packet leaves g_rumble set and the
-// relay re-buzzes at 40Hz forever (until a host event like the Steam button clears it). Steam glide haptics
-// can likewise latch on the controller if Steam's stop relay is lost. We auto-release on host silence.
-static unsigned long g_haptic82Ms = 0;            // millis of last 0x82 haptic OUTPUT relayed (Steam mode)
-static bool          g_haptic82On = false;        // a non-zero 0x82 haptic is currently active (awaiting host stop)
-static volatile uint8_t g_hapticStop = 0;         // pending haptic-STOP frames to relay: the controller's haptic LATCHES until told to stop, so when the host's stop is lost over RF (or goes silent, or we reconnect) we actively send 0x82-zero a few times to kill the whine
-// ---- diagnostic capture: a ring of the last OUTPUT reports Steam sends (rid/slot/bytes/ms), dumped with 'H'.
-// Reproduce the whine, press Steam to stop it, then 'H' to see the ON stream + the exact OFF frame Steam sends.
-struct HapLog { uint32_t ms; uint8_t slot, rid, n, b[12]; };
-static HapLog  g_hapLog[28];
-static uint8_t g_hapHead = 0;
-static void hapLogAdd(uint8_t slot, uint8_t rid, const uint8_t* b, uint16_t n){
-  HapLog &e = g_hapLog[g_hapHead]; e.ms = millis(); e.slot = slot; e.rid = rid; e.n = (uint8_t)(n>255?255:n);
-  for(int i=0;i<12;i++) e.b[i] = (i<(int)n) ? b[i] : 0;
-  g_hapHead = (uint8_t)((g_hapHead+1) % (sizeof g_hapLog / sizeof g_hapLog[0]));
-}
-static unsigned long g_hapticBlockUntil = 0;        // drop Steam haptics briefly during reconnect settle
-#define RUMBLE_STUCK_MS   2500u   // Xbox: release a held rumble if no OUT packet refreshes it for this long (covers a lost stop without cutting normal short rumbles)
-#define HAPTIC_QUIET_MS    300u   // Steam: after this much host silence, consider the current 0x82 haptic stream inactive
-#define HAPTIC_RECONNECT_BLOCK_MS 1500u   // drop stale host 0x82 after RF reconnect (Steam floods on attach)
-#define HAPTIC_STOP_BURST         4u      // 0x82-zero relays per event (sent at poll cadence — not loop rate)
-// Seamless lizard (Steam mode): exactly like the real puck — when Steam is running it forwards the gamepad
-// report 0x45; when Steam is closed it drives mouse(0x40)+keyboard(0x41) on the SAME puck interface. This is
-// a PURELY USB-SIDE decision — it changes nothing about the RF poll or the host->controller relay, so the
-// controller is polled identically whether Steam is up or not and can't be pushed into a non-puck state.
-// Steam, while running, re-sends settings report 0x87 (lizard-off) every ~3s as a heartbeat (captured on HW).
-static unsigned long g_steamAliveMs = 0;   // millis of last 0x87 settings write from Steam (its heartbeat); 0 at boot => lizard until Steam appears
-#define LIZARD_WD_MS 7000u          // fall back to lizard this long after the 0x87 heartbeat stops (>2x the 3s cadence, so one missed beat won't trip it mid-session)
-static bool g_autoLizard = true;    // master switch; false => Steam mode always forwards 0x45 (the prior behavior). Opening Steam also disables lizard instantly (its 0x87 heartbeat sets steamAlive).
-// Single source of truth for the seamless-lizard decision, shared by the USB input path AND the haptic relay
-// gate. Steam is "alive"/driving the gamepad while its OUTPUT/settings stream is recent; when it isn't, the
-// puck presents lizard. Gating haptics on this is what stops the buzz: if we ever relay a 0x82 to the
-// controller while presenting lizard (i.e. Steam isn't getting 0x45 back), Steam loops the same haptic.
-static inline bool steamDrivingGamepad(){ return g_steamAliveMs && (millis()-g_steamAliveMs < LIZARD_WD_MS); }
-static inline bool lizardActive(){ return modeIsPuck(g_usbMode) && (g_usbMode==MODE_LIZARD || (g_autoLizard && !steamDrivingGamepad())); }
-#define BOND_FILE "/bonds.bin"
-static volatile bool g_dirty = false;
-static bool g_pairing = false;
-
-static bool recEmpty(const uint8_t *r) { for (int i = 0; i < 24; i++) if (r[i]) return false; return true; }
-
-static void genSerial() {
-  uint32_t id = NRF_FICR->DEVICEID[0] ^ NRF_FICR->DEVICEID[1];
-  snprintf(g_unit,  sizeof g_unit,  "FXB99602%05lX", (unsigned long)(id & 0xFFFFF));
-  snprintf(g_board, sizeof g_board, "MXB99602%05lX", (unsigned long)(id & 0xFFFFF));
-}
-static void saveBonds() {
-  InternalFS.remove(BOND_FILE);
-  File f(InternalFS);
-  if (f.open(BOND_FILE, FILE_O_WRITE)) {
-    for (int i = 0; i < NSLOT; i++) { uint8_t u = g_slot[i].used ? 1 : 0; f.write(&u, 1); f.write(g_slot[i].rec, 24); }
-    f.close();
-  }
-}
-#define CFG_FILE "/cfg.bin"
-#define CFG_MAGIC 0xC7   // bumped (chordBtn[3]): old cfg ignored -> clean defaults on first boot
-struct Cfg { uint8_t magic, mode, mDiv, mFric, rsvd0, abSwap, back[4], pollU100, persistMode, bootMode, chordBtn[3]; };  // rsvd0 = ex-padSmooth (kept for layout)
-static void saveCfg(){
-  Cfg c={CFG_MAGIC,g_usbMode,(uint8_t)g_mDiv,(uint8_t)g_mFric,0,g_abSwap,
-         {g_back[0],g_back[1],g_back[2],g_back[3]},(uint8_t)(g_pollUs/100),(uint8_t)(g_persistMode?1:0),g_bootMode,
-         {g_chordBtn[0],g_chordBtn[1],g_chordBtn[2]}};
-  InternalFS.remove(CFG_FILE); File f(InternalFS);
-  if(f.open(CFG_FILE,FILE_O_WRITE)){ f.write((uint8_t*)&c,sizeof c); f.close(); }
-}
-static void loadCfg(){
-  Cfg c; File f(InternalFS); bool consume=false;
-  if(f.open(CFG_FILE,FILE_O_READ)){
-    if(f.read((uint8_t*)&c,sizeof c)==(int)sizeof c && c.magic==CFG_MAGIC){
-      g_mDiv=c.mDiv?c.mDiv:64; g_mFric=c.mFric;
-      g_abSwap=c.abSwap; for(int i=0;i<4;i++) g_back[i]=c.back[i];
-      g_persistMode = c.persistMode?true:false;
-      // poll rate is fixed (g_pollUs const = POLL_US_DEFAULT). A stale rate from an older build is never
-      // applied; rewrite cfg so the persisted byte also matches the new default.
-      if(c.pollU100 != (uint8_t)(POLL_US_DEFAULT/100)) consume=true;
-      // boot-mode policy: a one-shot bootMode (set by an explicit switch when !persist) wins once and is then
-      // cleared; otherwise persist->last mode, else->Steam. (poll rate stays POLL_US_DEFAULT — never restored from cfg.)
-      if(c.bootMode!=0xFF){ g_usbMode=modeValid(c.bootMode)?c.bootMode:0; consume=true; }
-      else                 g_usbMode = g_persistMode ? (modeValid(c.mode)?c.mode:0) : 0;
-      static const uint8_t CHORD_DEF[3]={MODE_LIZARD,MODE_XBOX,MODE_SW_HORI};
-      for(int i=0;i<3;i++) g_chordBtn[i]=modeValid(c.chordBtn[i])?c.chordBtn[i]:CHORD_DEF[i];
-    }
-    f.close();
-  }
-  if(consume){ g_bootMode=0xFF; saveCfg(); }   // clear the one-shot so the NEXT cold boot reverts to the default/persist policy
-}
-// Mode switch (chord / WebUI): persist mode if the toggle is on, else arm a one-shot so this reboot lands in
-// the new mode but the next cold boot returns to Steam. Either way saveCfg + caller reboots.
-static void saveMode(uint8_t m){
-  if(g_persistMode){ g_usbMode=m; g_bootMode=0xFF; }
-  else             { g_bootMode=m; }
-  saveCfg();
-}
-static void loadBonds() {
-  File f(InternalFS);
-  if (f.open(BOND_FILE, FILE_O_READ))
-    for (int i = 0; i < NSLOT; i++) { uint8_t u = 0; if (f.read(&u, 1) == 1) { g_slot[i].used = u; f.read(g_slot[i].rec, 24); } }
-  f.close();
-}
-
-static bool hapticLinkUp(){
-  return g_connSlot>=0 && (millis()-g_connReplyMs) < 300;
-}
-static bool haptic82Blocked(){
-  return !hapticLinkUp() || (g_hapticBlockUntil && (int32_t)(millis()-g_hapticBlockUntil) < 0);
-}
-static bool hapticRelaySlotOk(int slot){
-  return g_connSlot>=0 && slot==g_connSlot;
-}
-static bool haptic82PayloadOn(const uint8_t* p, uint16_t n){
-  if(n<3) return false;
-  for(uint16_t i=2;i<n;i++) if(p[i]) return true;   // observed form is [01 01 gain], but treat any trailing non-zero as active
-  return false;
-}
-static void hapticCancelPendingOn(){
-  if(g_relayPend && g_relayBuf[0]==0x82 && haptic82PayloadOn(g_relayBuf+2, g_relayBuf[1]))
-    g_relayPend = false;
-}
-static void haptic82HostReport(const uint8_t* p, uint16_t n){
-  if(n<3) return;
-  g_haptic82Ms = millis();
-  // Track on/off only. Do NOT synthesize a stop burst when Steam's own stop arrives: that stop is already
-  // forwarded verbatim, so adding 0x82-zero frames on top just makes the controller see the stop several times
-  // over. Each 0x82 is a discrete pad click, so the extra frames are exactly the spurious end-of-movement
-  // "click"/buzz that the real puck never produces. (Connect-time clearing still runs in loop().)
-  g_haptic82On = haptic82PayloadOn(p,n);
-}
-// Queue a pending host haptic / test-haptic / stop relay (runs inside the poll cadence — never at raw loop rate).
-static void rfConnQueueHapticRelay(){
-  if(g_relayPend) return;
-  if(g_testHaptic){
-    g_relayBuf[0]=0x82; g_relayBuf[1]=3; g_relayBuf[2]=0x01; g_relayBuf[3]=0x01; g_relayBuf[4]=0xF7; g_relayPend=true; g_testHaptic--;
-  } else if(g_hapticStop && !g_xbox){
-    g_relayBuf[0]=0x82; g_relayBuf[1]=3; g_relayBuf[2]=0x01; g_relayBuf[3]=0x01; g_relayBuf[4]=0x00; g_relayPend=true; g_hapticStop--;
-  }
-}
-static void rfConnFlushRelay(uint8_t ch){
-  if(!g_relayPend) return;
-  uint8_t rid=g_relayBuf[0], rl=g_relayBuf[1]; if(rl>18)rl=18;
-  uint8_t p[24]; p[0]=g_relayOp; p[1]=(uint8_t)(1+rl); p[2]=g_relaySub; p[3]=rid;
-  for(uint8_t i=0;i<rl;i++) p[4+i]=g_relayBuf[2+i];
-  rfConnTx(ch,0x07,p,(uint8_t)(4+rl));
-  g_relayPend=false;
-}
-
-// ---- command channel; `slot` is the interface index (interface N == bond slot N) ----
-static void handleSet(int slot, uint8_t rid, hid_report_type_t type, uint8_t const *b, uint16_t n) {
-  if (type == HID_REPORT_TYPE_OUTPUT) {   // Steam OUTPUT reports 0x80-0x89. The haptic/actuator reports (0x80-0x86)
-    // are relayed to the controller, and ONLY when they arrive on the CONNECTED slot's interface. We have one
-    // controller but expose 4 puck slots, and a report aimed at a DIFFERENT slot made the controller buzz at
-    // random -> that is what the slot gate below fixes. (This used to also clamp to 0x82-ONLY, which silently
-    // dropped the ping / grip / test haptics: those ride other report IDs such as 0x85/0x86, so they never
-    // reached the controller.) The 63-byte settings/config reports 0x87/0x88/0x89 are NOT haptics and are not
-    // pushed here — 0x87 (lizard-off/settings) reaches the controller through the feature-0x01 passthrough path.
-    if (rid >= 0x80 && rid <= 0x89) {
-      hapLogAdd((uint8_t)slot, rid, b, n);   // capture ALL OUTPUT reports (even un-relayed) for the 'H' dump
-      g_steamAliveMs = millis();   // ANY Steam OUTPUT report (not just the 0x87 heartbeat) means Steam is present and
-                                   // driving -> leave lizard for gamepad NOW, so a haptic that arrives before the
-                                   // first 0x87 doesn't get relayed while we're still presenting lizard (-> buzz loop).
-    }
-    if (rid >= 0x80 && rid <= 0x86 && n >= 1 && hapticRelaySlotOk(slot) && !lizardActive()) {  // wrap as a SET sub-TLV like the report-01 path
-      if (!haptic82Blocked()) {
-        uint8_t m = n > (uint16_t)(sizeof g_relayBuf - 2) ? (sizeof g_relayBuf - 2) : n;
-        g_relayBuf[0] = rid; g_relayBuf[1] = m; memcpy(g_relayBuf + 2, b, m);
-        g_relayN = m + 2; g_relayPend = true;
-      }
-      if (rid == 0x82) haptic82HostReport(b, n);  // on/off tracking is only meaningful for the 0x82 stream
-    }
-    if (Serial.availableForWrite() > 80) {                      // log so we can see what Steam actually sends (e.g. glide haptics)
-      Serial.printf("# OUT if%d rid=%02X n=%u:", slot, rid, n);
-      for (uint16_t i = 0; i < n && i < 14; i++) Serial.printf(" %02X", b[i]);
-      Serial.println();
-    }
-    return;
-  }
-  if (type != HID_REPORT_TYPE_FEATURE || n < 1) return;
-  Slot &S = g_slot[slot];
-  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;
-  if (cmd >= 0x80 && cmd <= 0x89) g_steamAliveMs = millis();   // any Steam settings/haptic/LED report (incl. the 0x87 lizard-off heartbeat) -> Steam present, forward gamepad, suppress auto-lizard
-  if (rid == 1 && n >= 2) {   // report 0x01 = raw passthrough -> queue for RF relay to the controller
-    if (cmd >= 0x80 && cmd <= 0x89) hapLogAdd((uint8_t)slot, cmd, b, n);   // capture feature-passthrough haptics/LED too (rid shown = cmd; bytes start [cmd][len]...)
-    bool haptic82 = (cmd == 0x82 && len <= pln);
-    bool relayOk = hapticRelaySlotOk(slot) && !(haptic82 && lizardActive());  // never push haptics to the controller while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop)
-    if (relayOk && (!haptic82 || !haptic82Blocked())) {
-      uint16_t m = n > sizeof g_relayBuf ? sizeof g_relayBuf : n;
-      memcpy(g_relayBuf, b, m); g_relayN = m; g_relayPend = true;
-    }
-    if (haptic82) haptic82HostReport(pl, len);  // Steam can send haptics through feature passthrough too
-  }
-  if (Serial.availableForWrite() > 80) {   // log host feature writes (non-blocking)
-    Serial.printf("# SET if%d rid=%02X cmd=%02X len=%u:", slot, rid, cmd, len);
-    for (uint16_t i = 0; i < n && i < 14; i++) Serial.printf(" %02X", b[i]);
-    Serial.println();
-  }
-  memset(S.resp, 0, sizeof S.resp); S.resp_len = 0;
-  switch (cmd) {
-    case 0x83:
-      S.resp[0] = 0x83; S.resp[1] = sizeof ATTR83; memcpy(S.resp + 2, ATTR83, sizeof ATTR83); S.resp_len = 63; break;
-    case 0xAE: {
-      uint8_t idx = pln > 0 ? pl[0] : 1; const char *s = (idx == 0) ? g_board : (idx == 1) ? g_unit : "NA";
-      S.resp[0] = 0xAE; S.resp[1] = 0x14; S.resp[2] = idx; memset(S.resp + 3, 0, 60); memcpy(S.resp + 3, s, strlen(s)); S.resp_len = 63; break; }
-    case 0xB4:    // connection/version state per slot: value 0x02 = controller connected, 0x01 = not
-      S.resp[0] = 0xB4; S.resp[1] = 0x01;
-      S.resp[2] = (slot == g_connSlot && !g_xbox && (millis() - g_connReplyMs < 500)) ? 0x02 : 0x01;
-      S.resp_len = 63; break;
-    case 0xAD:
-      g_pairing = (pln > 0 && pl[0] != 0); Serial.printf("# pairing %s\n", g_pairing ? "ON" : "off");
-      S.resp[0] = 0xAD; S.resp[1] = 0; S.resp_len = 63; break;
-    case 0xA2:                                   // write/clear THIS interface's slot
-      if (len >= 24 && pln >= 24) {
-        if (recEmpty(pl)) { S.used = false; memset(S.rec, 0, 24); }
-        else { memcpy(S.rec, pl, 24); S.used = true; }
-        g_dirty = true; Serial.printf("# slot %d %s\n", slot, recEmpty(pl) ? "cleared" : "bonded");
-      }
-      S.resp[0] = 0xA2; S.resp[1] = 0; S.resp_len = 63; break;
-    case 0xA3:                                   // read THIS interface's slot
-      S.resp[0] = 0xA3; S.resp[1] = 0x18; memset(S.resp + 2, 0, 24);
-      if (S.used) memcpy(S.resp + 2, S.rec, 24); S.resp_len = 63; break;
-    default:
-      S.resp[0] = cmd; S.resp[1] = len; if (pln) memcpy(S.resp + 2, pl, pln > 60 ? 60 : pln); S.resp_len = 63; break;
-  }
-}
-static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type, uint8_t *buf, uint16_t reqlen) {
-  if (type != HID_REPORT_TYPE_FEATURE) return 0;
-  Slot &S = g_slot[slot];
-  uint16_t n = S.resp_len ? S.resp_len : 63; if (n > reqlen) n = reqlen;
-  memcpy(buf, S.resp, n); return n;
-}
-
-// one callback pair per interface (the Adafruit core routes by interface to the matching object)
-#define SLOTCB(N) \
-  static void setcb##N(uint8_t r, hid_report_type_t t, uint8_t const *b, uint16_t n) { handleSet(N, r, t, b, n); } \
-  static uint16_t getcb##N(uint8_t r, hid_report_type_t t, uint8_t *bf, uint16_t rl) { return handleGet(N, r, t, bf, rl); }
-SLOTCB(0) SLOTCB(1) SLOTCB(2) SLOTCB(3)
-typedef uint16_t (*getcb_t)(uint8_t, hid_report_type_t, uint8_t *, uint16_t);
-typedef void (*setcb_t)(uint8_t, hid_report_type_t, uint8_t const *, uint16_t);
-static getcb_t GETCB[NSLOT] = { getcb0, getcb1, getcb2, getcb3 };
-static setcb_t SETCB[NSLOT] = { setcb0, setcb1, setcb2, setcb3 };
-
-// ===================== PHASE 2: ESB radio (firmware-derived) =====================
-// Register-confirmed ESB config (RESOLVED.md): 2Mbit, 5-byte addr (BALEN=4), dynamic length
-// (LFLEN=8,S1LEN=3), 16-bit preamble, big-endian, whitening off, CRC16 poly 0x11021 init 0xFFFF
-// (address included). On-air = bitrev8(stored); RADIO BASE = byteswap32(bitrev8(stored)).
-// Pairing rendezvous: ch2 (2402MHz), base 91 A2 A7 93 (FUN_0000b8b8 / PTR_DAT_0000ba30).
-// Dongle protocol (protocol/RF_CONNECTED.md): beacon [01][seq][..][E2@5]; handshake 0xF0/0xE0/0xF4.
-// Bare-metal RADIO (no SoftDevice — never start BLE); coexists with TinyUSB. Stage A: listen+log.
-// Discovery/reconnect address (IBEX FUN_00019000, firmware-derived, definitive):
-//   base = "ibex" (69 62 65 78), prefix = 0x10, channel = 2.
-// (The 0x91A2A793 bytes in IBEX rodata precede the key string but are a *different* key,
-//  NOT the discovery address — confirmed via PTR_s_ibexesb_common=0x56f99 -> "ibex...".)
-static const uint8_t PAIR_BASE[4] = {0x69, 0x62, 0x65, 0x78};  // "ibex"
-static uint8_t  g_rfPrefix = 0x10;     // discovery prefix (rodata byte @0x56f98)
-static uint8_t  g_rfCh = 2;
-static uint8_t  g_sessCh = 18;   // connected-session channel: a CLEAN data channel, off the congested BLE-adv
-                                 // ch2 (=adv37). ch18 measured clean (RSSI 95/90, PER 0) on the REAL PUCK's own
-                                 // channel scan and is in its active hop set {18,2,80}; ch52 (old) was mediocre.
-                                 // Trackpad data bursts collide on bad channels -> reply-rate crash. Tunable 'C'.
-static bool     g_rfListen = false;
-static uint8_t  g_rfBase[4] = {0x69, 0x62, 0x65, 0x78};  // "ibex"
-static uint8_t  rfrx[80], rftx[80];
-static uint32_t g_rfRxCount = 0;
-
-static 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; }
-
-// HYBRID puck-link config (firmware-derived, definitive):
-//   PHY    = Ble_1Mbit (IBEX FUN_0000f108 MODE=3 is the only RADIO MODE write; ESB inherits it)
-//   PCNF0  = 0x00100108  (S0LEN=1 [buf[0]=0x12 IS the S0 byte], LFLEN=8, S1INCL=1, 8-bit preamble)
-//   PCNF1  = 0x02030040  (WHITEEN ON, BALEN=3 [4-byte addr], little-endian, MAXLEN=64)
-//   CRC    = CRC16 poly 0x11021, init 0xFFFF, CRCCNF=2 (address INCLUDED) -- ESB override
-//            (FUN_00037000); NOT the Bluetooth radio's CRC24 0x65b/CRCCNF=0x103.
-//   whiten = ON, DATAWHITEIV=37 (RADIO FREQUENCY=2 = 2402MHz = BLE adv channel 37)
-//   addr   = ESB addr_conv (FUN_00027a78 bitrev8 each byte): "ibex" -> BASE0=0x9646A61E.
-// All tunable via CDC (M/0/2/i/w + I=crcinit) for hardware iteration.
-// DECODED from real-puck capture (definitive): puck TX = Ble_2Mbit, ENDIAN=Big (MSB-first), NO whitening,
-// addr "ibex", CRC16 0x11021/0xFFFF. Frame = FUN_00027e9a (static length). My Little-endian capture saw
-// it bit-reversed; decode (shift3+bitrev8) recovered 0xE1 + proteus_uuid_LE + ibex_uuid_LE for both bonds.
-static uint32_t g_crcinit = 0xFFFF;
-static uint8_t  g_whiteiv = 37;
-static uint8_t  g_mode    = RADIO_MODE_MODE_Ble_2Mbit;   // <-- was the bug (1Mbit). Real puck = 2Mbit.
-static uint32_t g_pcnf0   = 0x00030008UL;                // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) - CRC-VALIDATED
-static uint8_t  g_statlen = 0x20;                        // (static-mode payload length; tunable 'T')
-// PCNF1: ENDIAN=Big (puck transmits MSB-first), WHITEEN=0, BALEN3, MAXLEN64.
-static uint32_t g_pcnf1   = 0x01040040;  // ENDIAN=Big, BALEN4, MAXLEN64 - CRC-VALIDATED
-static uint32_t g_crcpoly = 0x11021UL;
-static uint16_t g_crccnf  = 0x2;          // CRC16, address included
-static uint8_t  g_pid     = 0;
-// Connected-mode poll (Phase 2b): puck (PTX) sends 0xE7 protocol-version then 0xE3 input polls on the
-// session addr/ch; controller responds in the ACK/reply with its input report (byte5==0xF1 + TLV,
-// parsed by IBEX FUN_0000ba38). Tunable for on-hardware iteration (j=type, Q=len, k=enable).
-static bool     g_connOn   = true;   // auto-start poll on boot (so a USB replug resumes acting as a puck)
-static uint8_t  g_connType = 0xE7;   // start with protocol-version handshake, then 0xE3
-static uint8_t  g_e7b = 0;           // E7 payload B-byte: 0=current(slow/awake), 1=test protocol-version-1 (ODR agent: B!=0 sets controller 'v1 fast' latch -> ~100Hz?). 'V<n>' to toggle. RISK: original RE called [00][01] "suspended" — revert to 0 if input stops.
-static uint8_t  g_connLen  = 0x08;
-static uint32_t g_stPoll=0, g_stF1=0, g_stF3=0; static unsigned long g_stMs=0;   // per-second poll/F1/F3 stat counters
-static uint8_t  g_connF3v=0xFF;   // last protocol version the controller reported in an F3 reply (0xFF=none seen)
-static uint16_t g_f1ps=0;   // last completed second's F1 rate (for the WebUSB status readout)
-static uint8_t  g_lastSeq=0; static uint32_t g_stNew=0; static uint16_t g_newps=0;  // genuine new-report rate (report 0x45 seq byte changes)
-static uint32_t g_stCrc=0, g_stNoRx=0;   // diagnostics: replies w/ bad CRC, and polls with no reply at all (timing vs RF-quality)
-static uint32_t g_chF1[3]={0,0,0};   // diagnostic: F1 replies per poll channel {sessCh,2,80}
-static uint8_t  g_chIdx=0, g_noRep=0; // (legacy hop counters; controller doesn't hop)
-static uint32_t g_lastPollUs=0;   // (g_pollUs declared in the config block near the top; fixed, not tunable)
-static unsigned long g_lastSessBeacon=0;          // session keepalive beacon timer
-static uint32_t g_connRx   = 0;
-static uint8_t  g_balen   = 4;            // ESB 5-byte addr
-// Access address (IBEX FUN_00037530 + func_0x00049e5a): BASE0 = bitrev8 each base byte, packed
-// big-endian (= byteswap(bitrev8(uint32))); PREFIX0 = raw prefix (func_0x0003756c copies it). BALEN=3
-// uses BASE0[31:8] + PREFIX0. For "ibex" 69 62 65 78 -> BASE0=0x9646A61E, PREFIX0=0x10.
-static bool g_prefixRaw = false;   // false = bitrev8 prefix (ESB addr_conv); true = raw prefix byte
-static 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;
-}
-// Promiscuous raw capture (calibration only): preamble-match on 0x55 bytes, fixed-length grab, no CRC.
-// Catches any 2Mbit packet on a channel so we can read the controller's reconnect addr/prefix/framing.
-#define RAWCAP 48
-static bool g_rfRaw = false;
-static void rfRawStart(uint8_t ch){
-  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-  NRF_RADIO->MODE=(RADIO_MODE_MODE_Nrf_2Mbit<<RADIO_MODE_MODE_Pos);
-#if defined(RADIO_MODECNF0_RU_Fast)
-  NRF_RADIO->MODECNF0=(RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
-#endif
-  NRF_RADIO->FREQUENCY=ch; NRF_RADIO->TXPOWER=(RADIO_TXPOWER_TXPOWER_0dBm<<RADIO_TXPOWER_TXPOWER_Pos);
-  NRF_RADIO->CRCCNF=0; NRF_RADIO->PCNF0=0;
-  NRF_RADIO->PCNF1=(RADIO_PCNF1_WHITEEN_Disabled<<RADIO_PCNF1_WHITEEN_Pos)
-                 |(RADIO_PCNF1_ENDIAN_Big<<RADIO_PCNF1_ENDIAN_Pos)
-                 |(2u<<RADIO_PCNF1_BALEN_Pos)|(RAWCAP<<RADIO_PCNF1_STATLEN_Pos)|(RAWCAP<<RADIO_PCNF1_MAXLEN_Pos);
-  NRF_RADIO->BASE0=0x55555555; NRF_RADIO->PREFIX0=0x00000055;
-  NRF_RADIO->TXADDRESS=0; NRF_RADIO->RXADDRESSES=1; NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
-  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
-}
-// CAPTURE: listen for the CONTROLLER's transmission (test the inverted-direction hypothesis).
-// RX on "ibex"/BLE_1Mbit with CRC DISABLED (any address-matched packet logged, whitened or not;
-// the address is never whitened so a match still fires). Sweeps channels to find where it TXes.
-static bool g_rfCap = false;
-// CRC-VALIDATING capture (2Mbit, addr "ibex", CRC16 0x11021/0xFFFF). A CRC PASS pins PHY+addr+CRC+whiten.
-// Data looked bit-reversed (not whitened), so sweep whiten-OFF combos first: BALEN{3,4} x CRCCNF{incl,skip}
-// x ENDIAN{little,big}; then whiten-ON IV sweep as fallback. Cycle index g_capV.
-struct CapCfg{ uint8_t balen; uint16_t crccnf; uint8_t endianBig; uint8_t whiten; uint8_t iv; const char*tag; };
-static const CapCfg CAPC[] = {
-  {3,0x2,0,0,0,"BAL3 incl LE whOFF"},   {4,0x2,0,0,0,"BAL4 incl LE whOFF"},
-  {3,0x2,1,0,0,"BAL3 incl BE whOFF"},   {4,0x2,1,0,0,"BAL4 incl BE whOFF"},
-  {3,0x102,0,0,0,"BAL3 skip LE whOFF"}, {4,0x102,0,0,0,"BAL4 skip LE whOFF"},
-  {3,0x102,1,0,0,"BAL3 skip BE whOFF"}, {4,0x102,1,0,0,"BAL4 skip BE whOFF"},
-  {3,0x2,0,1,2,"BAL3 incl LE whIV2"},   {3,0x2,0,1,37,"BAL3 incl LE whIV37"},
-  {4,0x2,0,1,2,"BAL4 incl LE whIV2"},   {4,0x2,0,1,37,"BAL4 incl LE whIV37"},
-};
-static uint8_t g_capV=0; static uint32_t g_capPass=0;
-// CRC-VALIDATING de-whiten sweep: BLE-radio framing PCNF0=0x100108 (S0LEN1/LFLEN8) + WHITEEN + CRC16
-// 0x11021. Sweep DATAWHITEIV (0..127) x BALEN{3,4}. A CRC PASS de-whitens & de-frames -> clean payload + IV.
-// FRAMING SWEEP: ENDIAN=Big, addr "ibex", CRC16 0x11021/0xFFFF, sweep PCNF0 x CRCCNF. A CRC PASS on the
-// real puck's frame pins the exact framing (LFLEN/S1LEN/S0LEN) -> then copycat TX uses it.
-static const uint32_t CAPP0[]={0x00030006,0x00030008,0x00100108,0x00000008,0x00000006,0x00130006,0x00130008,0x00100008};
-// CRC configs: {CRCCNF, CRCPOLY, CRCINIT}
-static const uint32_t CAPCRC[][3]={{0x2,0x11021,0xFFFF},{0x102,0x11021,0xFFFF},{0x1,0x107,0xFF},{0x101,0x107,0xFF}};
-#define NCRC (sizeof CAPCRC/sizeof CAPCRC[0])
-// FRAMING SWEEP v2: ENDIAN=Big, S1LEN=3 (ESB PID+noack -> the 3-bit shift), STATIC length swept,
-// CRC16 0x11021 / CRC8 0x107. g_capV -> (pcnf0_idx, statlen, crc_idx).
-// Firmware-correct: ESB DPL (S0L0, LFLEN6or8, S1LEN3), BALEN4 (5-byte addr), CRC8/16 addr-included.
-// Sweep PCNF0{LFLEN6,8} x CRC{8,16} x BALEN{4,3} x ENDIAN{Big,Lit} = 16 combos.
-static const uint32_t CAPP0B[]={0x00030006,0x00030008};  // S1LEN3 + LFLEN6 / LFLEN8 (dynamic length)
-static const uint32_t CAPCRCB[][3]={{0x1,0x107,0xFF},{0x2,0x11021,0xFFFF}};
-#define NCRCB 2
-static void rfCapStart(uint8_t ch){
-  uint8_t ci=g_capV&1;
-  uint8_t balen=((g_capV>>1)&1)?3:4;            // BALEN 4 first
-  uint8_t endbig=((g_capV>>2)&1)^1;             // ENDIAN Big first
-  uint32_t p0=CAPP0B[(g_capV>>3)&1];
-  const uint32_t* cr=CAPCRCB[ci];
-  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-  NRF_RADIO->MODE=(RADIO_MODE_MODE_Ble_2Mbit<<RADIO_MODE_MODE_Pos);
-#if defined(RADIO_MODECNF0_RU_Fast)
-  NRF_RADIO->MODECNF0=(RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
-#endif
-  NRF_RADIO->FREQUENCY=ch;
-  NRF_RADIO->PCNF0=p0;
-  NRF_RADIO->PCNF1=((uint32_t)endbig<<RADIO_PCNF1_ENDIAN_Pos)|((uint32_t)balen<<RADIO_PCNF1_BALEN_Pos)
-                  |(60u<<RADIO_PCNF1_MAXLEN_Pos);
-  NRF_RADIO->CRCCNF=cr[0]; NRF_RADIO->CRCPOLY=cr[1]; NRF_RADIO->CRCINIT=cr[2];
-  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(g_rfBase[i]);
-  NRF_RADIO->BASE0=((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
-  NRF_RADIO->PREFIX0=rfBitrev8(g_rfPrefix); NRF_RADIO->TXADDRESS=0; NRF_RADIO->RXADDRESSES=1;
-  NRF_RADIO->PACKETPTR=(uint32_t)rfrx; memset(rfrx,0,8);
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
-  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
-}
-// REPLAY: capture one real-puck frame (raw, ENDIAN=Little) then re-transmit it VERBATIM (same bits,
-// addr, ch) to impersonate the puck bit-for-bit -- sidesteps unknown framing/CRC. Controller must be
-// bonded to the REAL puck (replayed frame carries real-puck uuids + its valid CRC).
-static uint8_t g_replay[48]; static uint8_t g_replayLen=0;
-static bool g_rfCapOne=false, g_rfReplay=false;
-static void rfCapPoll(){
-  if(!g_rfCap) return;
-  if(NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
-    g_capPass++;
-    if(g_rfCapOne){ memcpy(g_replay,rfrx,sizeof g_replay); g_replayLen=32; g_rfCapOne=false; g_rfCap=false;
-      Serial.printf("# captured replay frame (%uB): ",g_replayLen);
-      for(int i=0;i<g_replayLen;i++)Serial.printf("%02X",g_replay[i]); Serial.println(); return; }
-    if(NRF_RADIO->CRCSTATUS&1){
-      uint8_t ci=g_capV&1; uint8_t balen=((g_capV>>1)&1)?3:4; uint8_t eb=((g_capV>>2)&1)^1;
-      uint32_t p0=CAPP0B[(g_capV>>3)&1];
-      Serial.printf("@@@ CRCOK PCNF0=%lX BALEN=%u ENDIAN=%s POLY=%lX: ",(unsigned long)p0,balen,
-        eb?"Big":"Lit",(unsigned long)CAPCRCB[ci][1]);
-      for(int i=0;i<28;i++) Serial.printf("%02X",rfrx[i]); Serial.println();
-    }
-  }
-}
-static void rfReplayOnce(){
-  if(!g_replayLen) return;
-  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-  NRF_RADIO->MODE=(RADIO_MODE_MODE_Ble_2Mbit<<RADIO_MODE_MODE_Pos);
-#if defined(RADIO_MODECNF0_RU_Fast)
-  NRF_RADIO->MODECNF0=(RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
-#endif
-  NRF_RADIO->FREQUENCY=2;                                          // ch2
-  NRF_RADIO->PCNF0=0;                                              // static, ENDIAN=Little -> reproduce on-air bits
-  NRF_RADIO->PCNF1=(3u<<RADIO_PCNF1_BALEN_Pos)|((uint32_t)g_replayLen<<RADIO_PCNF1_STATLEN_Pos)|((uint32_t)g_replayLen<<RADIO_PCNF1_MAXLEN_Pos);
-  NRF_RADIO->CRCCNF=0;                                            // CRC already inside the captured bytes
-  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(g_rfBase[i]);
-  NRF_RADIO->BASE0=((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
-  NRF_RADIO->PREFIX0=rfBitrev8(g_rfPrefix); NRF_RADIO->TXADDRESS=0;
-  NRF_RADIO->PACKETPTR=(uint32_t)g_replay;
-  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->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-}
-static 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);   // Ble_1Mbit (tunable 'M')
-  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;       // static length (tunable '0'); g_pcnf1=0 -> build from g_statlen/g_balen
-  NRF_RADIO->PCNF1 = g_pcnf1 ? g_pcnf1 :
-        ((1u<<RADIO_PCNF1_ENDIAN_Pos)              // ENDIAN=Big (puck transmits MSB-first)
-        |((uint32_t)g_balen<<RADIO_PCNF1_BALEN_Pos)
-        |((uint32_t)g_statlen<<RADIO_PCNF1_STATLEN_Pos)
-        |((uint32_t)g_statlen<<RADIO_PCNF1_MAXLEN_Pos));   // WHITEEN=0
-  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;
-}
-static void rfListenStart(){
-  rfConfig(g_rfCh); rfSetAddr(g_rfBase, g_rfPrefix);
-  NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
-  NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk | RADIO_SHORTS_END_START_Msk;
-  NRF_RADIO->EVENTS_END = 0; NRF_RADIO->TASKS_RXEN = 1;
-  g_rfListen = true;
-  Serial.printf("# RF listen ch%u base %02X%02X%02X%02X prefix %02X\n",
-                g_rfCh, g_rfBase[0],g_rfBase[1],g_rfBase[2],g_rfBase[3], g_rfPrefix);
-}
-static void rfPoll(){
-  if (g_rfRaw){
-    if (NRF_RADIO->EVENTS_END){
-      NRF_RADIO->EVENTS_END = 0;
-      // filter noise: require some non-0x55/0xAA/0x00 structure
-      int nz=0; for(int i=0;i<RAWCAP;i++){uint8_t b=rfrx[i]; if(b&&b!=0x55&&b!=0xAA&&b!=0xFF)nz++;}
-      if (nz>=6){
-        g_rfRxCount++;
-        Serial.printf("RAW#%lu ch%u: ", (unsigned long)g_rfRxCount, g_rfCh);
-        for (int i=0;i<RAWCAP;i++) Serial.printf("%02X", rfrx[i]);
-        Serial.println();
-      }
-    }
-    return;
-  }
-  if (!g_rfListen) return;
-  if (NRF_RADIO->EVENTS_END){
-    NRF_RADIO->EVENTS_END = 0;
-    bool crcok = NRF_RADIO->CRCSTATUS & 1;
-    uint8_t len = rfrx[0];
-    if (len && len < 64){
-      g_rfRxCount++;
-      Serial.printf("RF#%lu ch%u crc%d len%u: ", (unsigned long)g_rfRxCount, g_rfCh, crcok, len);
-      for (uint8_t i=0;i<=len && i<40;i++) Serial.printf("%02X ", rfrx[i]);
-      Serial.println();
-    }
-  }
-}
-// Dongle is PTX: TX a beacon [01][seq][..][E2@5] then RX the controller's response (0xF0...).
-// RAM layout / payload length are byte-level unknowns to pin live (S1INCL, plen tweakable).
-static bool    g_rfBeacon = false;
-static uint8_t g_seq = 0, g_plen = 0x18, g_s1incl = 0;
-static unsigned long g_lastBeacon = 0;
-static unsigned long g_lastDisc = 0;   // last discovery beacon (ch2) time
-static bool    g_rfSweep = false;
-static bool    g_rfHost = true;   // auto-start host beacon on boot (resumes puck role after a USB replug)
-static unsigned long g_lastHop = 0;
-
-// AUTO-SWEEP: cycle candidate radio configs while beaconing, so ONE controller search window
-// (a few seconds) covers the whole space. S0LEN=1 / CRC16 0x11021 / addr "ibex" held fixed
-// (firmware-confirmed); we sweep the residual unknowns: PHY (1M/2M), whitening (off/IV), CRC-addr.
-// Hold MODE=Ble_1Mbit, PCNF0=0x100108 (S0LEN=1), CRC16 0x11021 cnf2; sweep the UNTESTED dims:
-// BALEN (3 vs 4 = 4- vs 5-byte addr), whitening (off / iv37), prefix (bitrev vs raw), + one 2M.
-struct RfCfg { uint8_t mode; uint32_t pcnf0, pcnf1; uint8_t whiteiv; uint16_t crccnf; bool prefRaw; const char* tag; };
-static const RfCfg SWEEP[] = {
-  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02040040, 37, 0x2, false, "1M BALEN4 whiv37 pfxRev"},
-  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00040040, 0,  0x2, false, "1M BALEN4 whOFF pfxRev"},
-  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02040040, 37, 0x2, true,  "1M BALEN4 whiv37 pfxRAW"},
-  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00040040, 0,  0x2, true,  "1M BALEN4 whOFF pfxRAW"},
-  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02030040, 37, 0x2, false, "1M BALEN3 whiv37 pfxRev"},
-  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00030040, 0,  0x2, true,  "1M BALEN3 whOFF pfxRAW"},
-  {RADIO_MODE_MODE_Ble_2Mbit, 0x00100108, 0x02040040, 37, 0x2, false, "2M BALEN4 whiv37 pfxRev"},
-  {RADIO_MODE_MODE_Ble_2Mbit, 0x00100108, 0x00040040, 0,  0x2, true,  "2M BALEN4 whOFF pfxRAW"},
-};
-static bool g_rfAuto = false; static uint8_t g_cfgIdx = 0; static unsigned long g_lastCfg = 0;
-static void applyCfg(uint8_t i){
-  const RfCfg &c = SWEEP[i];
-  g_mode=c.mode; g_pcnf0=c.pcnf0; g_pcnf1=c.pcnf1; g_whiteiv=c.whiteiv; g_crccnf=c.crccnf; g_prefixRaw=c.prefRaw;
-  Serial.printf("# cfg[%u] %s\n", i, c.tag);
-}
-
-// HOST FRAME the bonded controller waits for (IBEX FUN_00019000 verify: b[0]=0x12,b[5]=0xE1,
-// b[6..10]=proteus_uuid, b[10..14]=ibex_uuid). Built like PROTEUS FUN_00027e9a. Sent on the shared
-// rendezvous addr; the controller filters by the uuids in the payload, then connects.
-static void rfHostFrameOnce(int slot){
-  if (slot<0||slot>=NSLOT||!g_slot[slot].used) return;
-  uint8_t *rec = g_slot[slot].rec;                       // [proteus_uuid 4][ibex_uuid 4][serial 16]
-  // CRC-VALIDATED frame (decoded from real puck): ESB-DPL RAM = [LENGTH][S1=PID][payload(18)].
-  // payload: [0]=0xE1, [1..5]=proteus_uuid LE, [5..9]=ibex_uuid LE, [9]=session channel,
-  // [10..13]=0, [13..17]=session base, [17]=session prefix. Radio auto-appends CRC16 0x11021.
-  memset(rftx,0,sizeof rftx);
-  rftx[0]=0x12;                        // LENGTH = 18 (controller's buf[0]==0x12 check validates this)
-  rftx[1]=(uint8_t)((g_pid++&3)<<1);   // S1 = PID<<1 | noack0  (matches real puck 00/02/04/06)
-  rftx[2]=0xE1;                        // payload[0] marker
-  memcpy(rftx+3, rec+0, 4);            // payload[1..5] proteus_uuid (LE, as bonded)
-  memcpy(rftx+7, rec+4, 4);            // payload[5..9] ibex_uuid
-  rftx[11]=g_sessCh;                   // payload[9] session channel: tell the controller to run the session on
-                                       // the clean channel (it adopts buf[0xe]); discovery beacon still TXes on ch2
-  memcpy(rftx+15, g_rfBase, 4);        // payload[13..17] session base
-  rftx[19]=g_rfPrefix;                 // payload[17] session prefix
-  rfConfig(g_rfCh); rfSetAddr(g_rfBase,g_rfPrefix);
-  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; 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 && (micros()-t0)<800){}
-  if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;   // ANY reception = controller answered our frame
-    g_rfRxCount++; bool crcok=NRF_RADIO->CRCSTATUS&1; uint8_t len=rfrx[0];
-    if (Serial.availableForWrite()>90){                  // non-blocking: don't stall the loop on CDC backpressure
-      Serial.printf("*** RESP#%lu cfg[%u] ch%u crc%d rxmatch%lu len%u: ",(unsigned long)g_rfRxCount,
-                    g_cfgIdx,g_rfCh,crcok,(unsigned long)NRF_RADIO->RXMATCH,len);
-      for(uint8_t i=0;i<(len<40?len+2:40);i++)Serial.printf("%02X ",rfrx[i]); Serial.println(); } }
-  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-}
-// Mid-session channel hop: advertise newCh in the host frame, transmitted on the CURRENT session channel
-// (where the controller is listening), a few times so it reliably adopts it; then move our poll to newCh.
-// This is the foundational test for channel-hopping/QoS — does the controller follow a live channel change?
-static void rfHopTo(uint8_t newCh){
-  if(g_connSlot<0 || newCh==g_sessCh) return;
-  uint8_t cur=g_sessCh, savedRfCh=g_rfCh;
-  g_sessCh=newCh; g_rfCh=cur;                // host frame now advertises newCh but is TXed on cur
-  for(int k=0;k<6;k++){ rfHostFrameOnce(g_connSlot); delayMicroseconds(700); }
-  g_rfCh=savedRfCh;                          // poll + session beacon now run on g_sessCh=newCh
-}
-// CONNECTED MODE (Phase 2b): copycat IS the PTX/master. It already CHOSE the session base/prefix/channel
-// (advertised in the host frame the controller connected on), so no discovery/re-sync is needed. After
-// connect: send E7 (proto-version 00 01), send E4 (channel map {ch,2,80}), then loop E3 input-poll (+E2
-// keepalive periodically) hopping the map; RX the controller's reply each cycle (0xF1 = full input report
-// w/ TLVs, 0xF3 = short status). Captured PUCK->controller bytes [LEN][S1][payload], payload[0]=type:
-//   E7 00 01 (S1=01) | E4 <ch> 02 50 (S1=05) | E2 (S1=07) | E3 (S1=07).  See FUN_0000ba38 / FUN_0001b960.
-// One TX->RX cycle: TX the connected packet, SHORTS straight into a short RX window to catch the reply.
-// ---- XBOX-mode output: map report 0x45 -> standard gamepad (g_pad) + right-trackpad -> mouse (report 0x40) ----
-// report 0x45 layout: [0]=0x45 [1]=seq [2..5]=buttons u32; analog offsets are from buttons-low-byte (rep[2]).
-static 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 int u16off(const uint8_t* r,int off){ return r[2+off]|(r[2+off+1]<<8); }
-// Controller trigger analog: the u16 in report 0x45 tops out near half-scale (~0x8000) at a full pull, so a
-// straight >>8 reads only ~0x80 (XInput sees a half-pressed trigger). Scale x2 (>>7) and saturate so a full
-// pull maps to the full 0xFF. Shared by Xbox (analog) and Switch (digital threshold).
-static inline uint8_t trigU8(int u16v){ int v=u16v>>7; return (uint8_t)(v>255?255:v); }
-static 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); }
-// Triton button masks
-#define TB_A 0x1u
-#define TB_B 0x2u
-#define TB_X 0x4u
-#define TB_Y 0x8u
-#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
-// button code (g_back[], g_abSwap targets) -> legacy XInput bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide
-static uint16_t codeToXB(uint8_t c){
-  switch(c){ case 1:return XB_A; case 2:return XB_B; case 3:return XB_X; case 4:return XB_Y;
-    case 5:return XB_LB; case 6:return XB_RB; case 7:return XB_L3; case 8:return XB_R3;
-    case 9:return XB_BACK; case 10:return XB_START; case 11:return XB_GUIDE; default:return 0; }
-}
-static void rfXboxGamepad(const uint8_t* r){
-  uint32_t b=btnsOf(r);
-  uint16_t btn=0;                                   // map Triton buttons -> XInput button word
-  if(b&TB_DUP)btn|=XB_DUP;   if(b&TB_DDN)btn|=XB_DDOWN; if(b&TB_DLF)btn|=XB_DLEFT; if(b&TB_DRT)btn|=XB_DRIGHT;
-  if(b&TB_VIEW)btn|=XB_START; if(b&TB_MENU)btn|=XB_BACK; if(b&TB_STEAM)btn|=XB_GUIDE;
-  if(b&TB_LB)btn|=XB_LB;  if(b&TB_RB)btn|=XB_RB;
-  if(b&TB_L3)btn|=XB_L3;  if(b&TB_R3)btn|=XB_R3;
-  // face buttons, with optional A/B + X/Y swap (Nintendo layout)
-  uint16_t fA=g_abSwap?XB_B:XB_A, fB=g_abSwap?XB_A:XB_B, fX=g_abSwap?XB_Y:XB_X, fY=g_abSwap?XB_X:XB_Y;
-  if(b&TB_A)btn|=fA; if(b&TB_B)btn|=fB; if(b&TB_X)btn|=fX; if(b&TB_Y)btn|=fY;
-  // back paddles -> configurable mapping (default L4->LB, R4->RB, L5->L3, R5->R3)
-  if(b&TB_L4)btn|=codeToXB(g_back[0]); if(b&TB_R4)btn|=codeToXB(g_back[1]);
-  if(b&TB_L5)btn|=codeToXB(g_back[2]); if(b&TB_R5)btn|=codeToXB(g_back[3]);
-  uint8_t lt=trigU8(u16off(r,4)), rt=trigU8(u16off(r,6));   // triggers u16 (half-scale) -> full-range u8
-  xinputSend(btn, lt, rt, (int16_t)s16off(r,8), (int16_t)s16off(r,10),   // L stick X/Y
-                          (int16_t)s16off(r,12), (int16_t)s16off(r,14)); // R stick X/Y
-}
-// Right pad -> mouse, riding ALONGSIDE the XInput gamepad on a second HID-mouse interface. Same glide model
-// as Lizard's right pad (velocity injected from pad deltas, friction decay, sub-pixel carry). This is purely
-// a USB-side translation of the received 0x45 report — it touches NOTHING about the RF poll or relay, so the
-// controller behaves exactly as it does for the bare gamepad. RPad click = left button, LPad click = right.
-// Xbox drops CDC/WebUSB, so the only active USB interfaces are XInput (MI_00) + boot mouse (MI_01).
-static void rfXboxMouse(const uint8_t* r){
-  uint32_t b=btnsOf(r);
-  static int prx=0,pry=0; static bool prt=false; static float vx=0,vy=0,rmx=0,rmy=0; static uint8_t pmb=0;
-  bool rtouch=b&TB_RPADT; int rx=s16off(r,22), ry=s16off(r,24);
-  if(rtouch){ if(prt){ vx+=(rx-prx); vy+=(ry-pry); } prx=rx; pry=ry; } prt=rtouch;
-  float mxf=vx/(float)(g_mDiv*10)+rmx, myf=-(vy/(float)(g_mDiv*10))+rmy;   // Y inverted for screen coords
-  int dx=(int)mxf, dy=(int)myf; rmx=mxf-dx; rmy=myf-dy;                    // sub-pixel carry
-  if(dx>127)dx=127; if(dx<-127)dx=-127; if(dy>127)dy=127; if(dy<-127)dy=-127;
-  float f=g_mFric/100.0f; vx*=f; vy*=f; if(vx>-1&&vx<1)vx=0; if(vy>-1&&vy<1)vy=0;   // friction = glide/decay
-  uint8_t mb=((b&TB_RPADC)?1:0)|((b&TB_LPADC)?2:0);  // RPad click = left, LPad click = right
-  if(dx||dy||mb!=pmb){ pmb=mb;
-    hid_mouse_report_t m; m.buttons=mb; m.x=(int8_t)dx; m.y=(int8_t)dy; m.wheel=0; m.pan=0;
-    if(g_mouse.ready()) g_mouse.sendReport(0,&m,sizeof m);
-  }
-}
-// ===================== Seamless LIZARD (Steam mode, when Steam is closed) — desktop keyboard + mouse =====
-// The controller's native "lizard" desktop behavior, reproduced on the copycat side so the device is a
-// driverless keyboard+mouse when Steam isn't running. Driven from the Steam path (report 0x45 -> mouse 0x40 +
-// keyboard 0x41 on the puck interface) and parameterized by the target HID device + report IDs. Canonical
-// Valve Steam Controller firmware map (SC1, the documented reference; SC2 presumed-similar): right pad ->
-// mouse (trackball glide), left pad -> scroll wheel + middle-click on press, R-trigger / R-pad-click -> left
-// mouse, L-trigger -> right mouse; A=Enter B=Esc X=PgUp Y=PgDn, D-pad & left-stick -> arrow keys, LB=LeftCtrl
-// RB=LeftAlt, Back/View=Tab, Start/Menu=Esc, Steam=none. The mouse reuses the SAME velocity+friction+sub-pixel
-// GLIDE model as Xbox mode (g_mDiv = sensitivity divisor, g_mFric = friction% — both live-tunable).
-static void rfLizard(const uint8_t* r, Adafruit_USBD_HID* mdev, Adafruit_USBD_HID* kdev, uint8_t mrid, uint8_t krid){
-  uint32_t b=btnsOf(r);
-  // --- right pad -> mouse motion with glide (mirrors rfXboxMouse) ---
-  static int prx=0,pry=0; static bool prt=false; static float vx=0,vy=0,rmx=0,rmy=0;
-  bool rtouch=b&TB_RPADT; int rx=s16off(r,22), ry=s16off(r,24);
-  if(rtouch){ if(prt){ vx+=(rx-prx); vy+=(ry-pry); } prx=rx; pry=ry; } prt=rtouch;
-  float mxf=vx/(float)(g_mDiv*10)+rmx, myf=-(vy/(float)(g_mDiv*10))+rmy;   // Y inverted; *10 = desktop-cursor sensitivity (g_mDiv 64 -> eff 640). Lower g_mDiv via WebUI slider = faster.
-  int dx=(int)mxf, dy=(int)myf; rmx=mxf-dx; rmy=myf-dy;          // sub-pixel carry
-  if(dx>127)dx=127; if(dx<-127)dx=-127; if(dy>127)dy=127; if(dy<-127)dy=-127;
-  float f=g_mFric/100.0f; vx*=f; vy*=f; if(vx>-1&&vx<1)vx=0; if(vy>-1&&vy<1)vy=0;   // friction = glide/decay
-  // --- left pad -> vertical scroll wheel (no momentum; only while touching, coarse) ---
-  static int ply=0; static bool plt=false; static float sacc=0;
-  bool ltouch=b&TB_LPADT; int ly=s16off(r,18);
-  if(ltouch){ if(plt){ sacc += (ly-ply)/(float)(g_mDiv*24); } ply=ly; } else sacc=0; plt=ltouch;
-  int dw=(int)sacc; sacc-=dw; if(dw>15)dw=15; if(dw<-15)dw=-15;   // finger up = wheel up (positive)
-  // --- mouse buttons: left=R-pad-click|R-trigger, right=L-trigger, middle=L-pad-click ---
-  // trigU8 (full-range scale + saturate) so a full pull reaches ~0xFF; a raw >>8 tops out ~0x80 and never
-  // crosses the threshold, leaving the trigger clicks dead.
-  uint8_t rtrig=trigU8(u16off(r,6)), ltrig=trigU8(u16off(r,4));
-  uint8_t mbtn=0;
-  if((b&TB_RPADC)||rtrig>180) mbtn|=1;   // right trigger -> left (primary) click
-  if(ltrig>180)               mbtn|=2;   // left trigger  -> right click
-  if(b&TB_LPADC)              mbtn|=4;
-  static uint8_t pmbtn=0;
-  if(dx||dy||dw||mbtn!=pmbtn){ pmbtn=mbtn;
-    hid_mouse_report_t m; m.buttons=mbtn; m.x=(int8_t)dx; m.y=(int8_t)dy; m.wheel=(int8_t)dw; m.pan=0;
-    if(mdev->ready()) mdev->sendReport(mrid,&m,sizeof m);
-  }
-  // --- keyboard: modifiers + up to 6 keycodes ---
-  uint8_t mod=0, kc[6]={0,0,0,0,0,0}, nk=0;
-  if(b&TB_LB) mod|=KEYBOARD_MODIFIER_LEFTCTRL;
-  if(b&TB_RB) mod|=KEYBOARD_MODIFIER_LEFTALT;
-  #define LZK(cond,code) do{ if((cond)&&nk<6) kc[nk++]=(code); }while(0)
-  LZK(b&TB_A,    HID_KEY_ENTER);
-  LZK(b&TB_B,    HID_KEY_ESCAPE);
-  LZK(b&TB_X,    HID_KEY_PAGE_UP);
-  LZK(b&TB_Y,    HID_KEY_PAGE_DOWN);
-  LZK(b&TB_VIEW, HID_KEY_TAB);
-  LZK(b&TB_MENU, HID_KEY_ESCAPE);
-  int sx=s16off(r,8), sy=s16off(r,10);   // left stick (XInput sign: +Y = up); deflect ~37% acts as a d-pad
-  LZK((b&TB_DUP)||sy> 12000, HID_KEY_ARROW_UP);
-  LZK((b&TB_DDN)||sy<-12000, HID_KEY_ARROW_DOWN);
-  LZK((b&TB_DLF)||sx<-12000, HID_KEY_ARROW_LEFT);
-  LZK((b&TB_DRT)||sx> 12000, HID_KEY_ARROW_RIGHT);
-  #undef LZK
-  static uint8_t pmod=0, pkc[6]={0,0,0,0,0,0};
-  bool chg=(mod!=pmod); for(int i=0;i<6;i++) if(kc[i]!=pkc[i]) chg=true;
-  if(chg){ pmod=mod; for(int i=0;i<6;i++) pkc[i]=kc[i];
-    uint8_t krep[8]={mod,0,kc[0],kc[1],kc[2],kc[3],kc[4],kc[5]};
-    if(kdev->ready()) kdev->sendReport(krid,krep,8);
-  }
-}
-// ===================== SWITCH mode (mode 2) — HORIPAD / Pokkén Tournament Pro Pad =====================
-// Targets a REAL Switch console: a plain HID gamepad (HORI 0F0D:0092) the console accepts on plug-in with
-// NO handshake / SPI calibration / encryption — unlike the genuine Pro Controller (057E:2009), whose USB
-// init the console handles fussily. This is the descriptor every DIY Switch controller copies (the LUFA
-// "Splatoon printer" descriptor). Also recognized by SDL/Steam on PC. 8-byte input report streamed at
-// ~250Hz stream: [btn_lo][btn_hi][hat][LX][LY][RX][RY][vendor], sticks uint8 center 0x80.
-static const uint8_t SWITCH_HID_DESC[]={
-  0x05,0x01, 0x09,0x05, 0xA1,0x01,           // Usage Page (Generic Desktop), Usage (Game Pad), Collection (App)
-  0x15,0x00, 0x25,0x01, 0x35,0x00, 0x45,0x01, 0x75,0x01, 0x95,0x10,   // 16 x 1-bit buttons
-  0x05,0x09, 0x19,0x01, 0x29,0x10, 0x81,0x02,
-  0x05,0x01, 0x25,0x07, 0x46,0x3B,0x01, 0x75,0x04, 0x95,0x01, 0x65,0x14, 0x09,0x39, 0x81,0x42,  // hat switch (4 bits)
-  0x65,0x00, 0x95,0x01, 0x81,0x01,           // 4-bit padding to a byte
-  0x26,0xFF,0x00, 0x46,0xFF,0x00, 0x09,0x30, 0x09,0x31, 0x09,0x32, 0x09,0x35, 0x75,0x08, 0x95,0x04, 0x81,0x02, // X,Y,Z,Rz
-  0x06,0x00,0xFF, 0x09,0x20, 0x95,0x01, 0x81,0x02,           // vendor byte (input)
-  0x0A,0x21,0x26, 0x95,0x08, 0x91,0x02,                      // vendor (output, 8 bytes; unused)
-  0xC0
-};
-static uint32_t g_swBtns=0; static int16_t g_swLX=0,g_swLY=0,g_swRX=0,g_swRY=0;  // latest decoded RF frame
-static uint8_t  g_swLT=0,g_swRT=0;  // latest scaled (0..255) L/R trigger pull, for the Switch digital-trigger threshold
-// Switch ZL/ZR are digital (on/off): fire once the analog pull crosses this fraction of full travel, so the
-// trigger activates far sooner than the controller's full-press click bit (~16% of 0xFF).
-#define SW_TRIG_ON 40
-static unsigned long g_swLastMs=0;
-Adafruit_USBD_HID g_switch;   // HORIPAD gamepad HID interface (mode 2)
-// HORIPAD/Switch button bits: Y=1 B=2 A=4 X=8 L=10 R=20 ZL=40 ZR=80 Minus=100 Plus=200 LClick=400 RClick=800 Home=1000 Capture=2000
-static inline uint8_t swStick(int16_t v,bool invert){   // int16 (center 0) -> uint8 (center 0x80)
-  int32_t a = 0x80 + (invert ? -((int32_t)v>>8) : ((int32_t)v>>8));
-  if(a<0)a=0; if(a>255)a=255; return (uint8_t)a;
-}
-// back-paddle code (g_back[]) -> Switch button bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back(Minus) 10=Start(Plus) 11=Guide(Home)
-static uint16_t codeToSwitch(uint8_t c, uint16_t fA,uint16_t fB,uint16_t fX,uint16_t fY){
-  switch(c){ case 1:return fA; case 2:return fB; case 3:return fX; case 4:return fY;
-    case 5:return 0x10; case 6:return 0x20; case 7:return 0x400; case 8:return 0x800;
-    case 9:return 0x100; case 10:return 0x200; case 11:return 0x1000; default:return 0; }
-}
-static void switchBuildHoripad(uint8_t out[8]){
-  uint32_t b=g_swBtns; uint16_t btn=0;
-  // Mode-switch chord (all 4 back + A/X/Y): don't pass the face press to the console while the back-4 are held.
-  if((b&(TB_R4|TB_L4|TB_R5|TB_L5))==(TB_R4|TB_L4|TB_R5|TB_L5)) b &= ~(uint32_t)(TB_A|TB_X|TB_Y);
-  // face buttons with optional A/B + X/Y swap (Nintendo physical-vs-label layout)
-  uint16_t fY = g_abSwap?0x08:0x01, fB = g_abSwap?0x04:0x02, fA = g_abSwap?0x02:0x04, fX = g_abSwap?0x01:0x08;
-  if(b&TB_Y)btn|=fY; if(b&TB_B)btn|=fB; if(b&TB_A)btn|=fA; if(b&TB_X)btn|=fX;
-  if(b&TB_LB)btn|=0x10; if(b&TB_RB)btn|=0x20;                 // L, R
-  // ZL/ZR digital: trip on the analog threshold (activates early) OR the full-press click bit
-  if((g_swLT>=SW_TRIG_ON)||(b&0x8000000u))btn|=0x40; if((g_swRT>=SW_TRIG_ON)||(b&0x800000u))btn|=0x80;
-  if(b&TB_MENU)btn|=0x100; if(b&TB_VIEW)btn|=0x200;           // Minus, Plus
-  if(b&TB_L3)btn|=0x400; if(b&TB_R3)btn|=0x800;               // LClick, RClick
-  if(b&TB_STEAM)btn|=0x1000;                                  // Home
-  // back paddles -> configurable mapping (same g_back[] as Xbox: default L4->LB R4->RB L5->L3 R5->R3)
-  if(b&TB_L4)btn|=codeToSwitch(g_back[0],fA,fB,fX,fY); if(b&TB_R4)btn|=codeToSwitch(g_back[1],fA,fB,fX,fY);
-  if(b&TB_L5)btn|=codeToSwitch(g_back[2],fA,fB,fX,fY); if(b&TB_R5)btn|=codeToSwitch(g_back[3],fA,fB,fX,fY);
-  bool u=b&TB_DUP,d=b&TB_DDN,l=b&TB_DLF,r=b&TB_DRT;           // hat: 0=N..7=NW, 8=neutral
-  uint8_t hat=8;
-  if(u&&r)hat=1; else if(r&&d)hat=3; else if(d&&l)hat=5; else if(l&&u)hat=7;
-  else if(u)hat=0; else if(r)hat=2; else if(d)hat=4; else if(l)hat=6;
-  out[0]=btn&0xFF; out[1]=btn>>8; out[2]=hat;
-  out[3]=swStick(g_swLX,false); out[4]=swStick(g_swLY,true);  // HID Y is down-positive -> invert
-  out[5]=swStick(g_swRX,false); out[6]=swStick(g_swRY,true);
-  out[7]=0;
-}
-static void swStream(){   // call from loop(); stream the 8-byte HORIPAD report at ~250Hz (no handshake needed)
-  if(g_usbMode!=MODE_SW_HORI || !g_switch.ready()) return;
-  if(millis()-g_swLastMs < USB_STREAM_MS) return; g_swLastMs=millis();
-  uint8_t p[8]; switchBuildHoripad(p);
-  g_switch.sendReport(0, p, sizeof p);   // report-id-less descriptor
-}
-
-// ---- report 0x45 IMU offsets (PROTOCOL.md §8): accel @0x22, gyro @0x28 from report start ----
-static 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);
-}
-// Map Steam trackpad s16 coords into a 0..max axis (centered touch -> mid-range).
-static uint16_t padNormU16(int16_t v, uint16_t maxv){
-  int32_t t=((int32_t)v+32768); if(t<0)t=0; if(t>65535)t=65535;
-  return (uint16_t)((t*(int32_t)maxv)/65535);
-}
-#define TOUCH_PAD_W 1920u
-static uint16_t touchHalfX(int16_t v, bool rightHalf){
-  uint16_t halfMax = TOUCH_PAD_W/2 - 1;
-  uint16_t x = padNormU16(v, halfMax);
-  return rightHalf ? (uint16_t)(TOUCH_PAD_W/2 + x) : x;
-}
-static uint16_t touchYInv(int16_t v, uint16_t height){
-  uint16_t maxy = height - 1;
-  return (uint16_t)(maxy - padNormU16(v, maxy));
-}
-static void touchPackPoint(uint8_t* base, int finger, bool touch, uint16_t x, uint16_t y){
-  uint8_t* f=base+finger*4;
-  if(!touch){ f[0]=0x80; f[1]=0; f[2]=0; f[3]=0; return; }
-  f[0]=(uint8_t)(finger&0x7F);
-  f[1]=(uint8_t)(x&0xFF);
-  f[2]=(uint8_t)(((x>>8)&0x0F)|((y&0x0F)<<4));
-  f[3]=(uint8_t)((y>>4)&0xFF);
-}
-// DualSense / DS4: many hosts only honor contact slot 0 — put a lone pad in slot 0, both pads in 0+1.
-static void touchPackPads(uint8_t* pts, bool lTouch, bool rTouch,
-    uint16_t lx, uint16_t ly, uint16_t rx, uint16_t ry){
-  touchPackPoint(pts, 0, false, 0, 0);
-  touchPackPoint(pts, 1, false, 0, 0);
-  if(lTouch && rTouch){
-    touchPackPoint(pts, 0, true, lx, ly);
-    touchPackPoint(pts, 1, true, rx, ry);
-  } else if(lTouch){
-    touchPackPoint(pts, 0, true, lx, ly);
-  } else if(rTouch){
-    touchPackPoint(pts, 0, true, rx, ry);
-  }
-}
-static void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy, int16_t rpx, int16_t rpy,
-    uint16_t* lx, uint16_t* ly, uint16_t* rx, uint16_t* ry){
-  bool lt=(b&TB_LPADT)||(b&TB_LPADC), rt=(b&TB_RPADT)||(b&TB_RPADC);
-  *lx=touchHalfX(lpx, false); *ly=touchYInv(lpy, touchH);
-  *rx=touchHalfX(rpx, true);  *ry=touchYInv(rpy, touchH);
-  if(lt && !(b&TB_LPADT)){ *lx=TOUCH_PAD_W/4; *ly=touchH/2; }
-  if(rt && !(b&TB_RPADT)){ *rx=TOUCH_PAD_W/4*3; *ry=touchH/2; }
-}
-// Map g_back[] paddle code -> Steam button flags (same codes as codeToXB / codeToSwitch).
-static void psOrBackCode(uint32_t* b, uint8_t c){
-  switch(c){ case 1:*b|=TB_A; break; case 2:*b|=TB_B; break; case 3:*b|=TB_X; break; case 4:*b|=TB_Y; break;
-    case 5:*b|=TB_LB; break; case 6:*b|=TB_RB; break; case 7:*b|=TB_L3; break; case 8:*b|=TB_R3; break;
-    case 9:*b|=TB_VIEW; break; case 10:*b|=TB_MENU; break; case 11:*b|=TB_STEAM; break; default: break; }
-}
-static uint32_t psButtonsFromSteam(uint32_t raw){
-  uint32_t b=raw;
-  if((b&(TB_R4|TB_L4|TB_R5|TB_L5))==(TB_R4|TB_L4|TB_R5|TB_L5)) b&=~(uint32_t)(TB_A|TB_B|TB_X|TB_Y);
-  if(b&TB_L4) psOrBackCode(&b, g_back[0]); if(b&TB_R4) psOrBackCode(&b, g_back[1]);
-  if(b&TB_L5) psOrBackCode(&b, g_back[2]); if(b&TB_R5) psOrBackCode(&b, g_back[3]);
-  return b;
-}
-// DualSense / DS4 buttons[1]: L1..R3, Create(Share), Options(Start)
-static uint8_t psShouldersByte(uint32_t b){
-  return ((b&TB_LB)?0x01:0)|((b&TB_RB)?0x02:0)
-         |((g_swLT>SW_TRIG_ON||(b&0x8000000u))?0x04:0)|((g_swRT>SW_TRIG_ON||(b&0x800000u))?0x08:0)
-         |((b&TB_MENU)?0x10:0)|((b&TB_VIEW)?0x20:0)   // Menu=Options, View=Create/Share
-         |((b&TB_L3)?0x40:0)|((b&TB_R3)?0x80:0);
-}
-
-// ===================== SWITCH PRO mode (4) — 057E:2009, report 0x30 + IMU (hid-nintendo layout) =====================
-// Linux hid-nintendo hardcodes joycon_input_report for USB 0x30 (not the bogus descriptor fields).
-// Payload: timer, bat_con, btn[3], sticks[3+3], vib, imu×3 — sendReport(0x30,…) prepends the report ID.
-static const uint8_t SWPRO_HID_DESC[]={
-  0x05,0x01,0x15,0x00,0x09,0x04,0xA1,0x01,0x85,0x30,0x05,0x01,0x05,0x09,0x19,0x01,
-  0x29,0x0A,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x0A,0x55,0x00,0x65,0x00,0x81,0x02,
-  0x05,0x09,0x19,0x0B,0x29,0x0E,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x04,0x81,0x02,
-  0x75,0x01,0x95,0x02,0x81,0x03,0x0B,0x01,0x00,0x01,0x00,0xA1,0x00,0x0B,0x30,0x00,
-  0x01,0x00,0x0B,0x31,0x00,0x01,0x00,0x0B,0x32,0x00,0x01,0x00,0x0B,0x35,0x00,0x01,
-  0x00,0x15,0x00,0x27,0xFF,0xFF,0x00,0x00,0x75,0x10,0x95,0x04,0x81,0x02,0xC0,0x0B,
-  0x39,0x00,0x01,0x00,0x15,0x00,0x25,0x07,0x35,0x00,0x46,0x3B,0x01,0x65,0x14,0x75,
-  0x04,0x95,0x01,0x81,0x42,0x05,0x09,0x19,0x0F,0x29,0x12,0x15,0x00,0x25,0x01,0x75,
-  0x01,0x95,0x04,0x81,0x02,0x75,0x08,0x95,0x34,0x81,0x03,0x06,0x00,0xFF,0x85,0x21,
-  0x09,0x01,0x75,0x08,0x95,0x3F,0x81,0x03,0x85,0x81,0x09,0x02,0x75,0x08,0x95,0x3F,
-  0x81,0x03,0x85,0x01,0x09,0x03,0x75,0x08,0x95,0x3F,0x91,0x83,0x85,0x10,0x09,0x04,
-  0x75,0x08,0x95,0x3F,0x91,0x83,0x85,0x80,0x09,0x05,0x75,0x08,0x95,0x3F,0x91,0x83,
-  0x85,0x82,0x09,0x06,0x75,0x08,0x95,0x3F,0x91,0x83,0xC0
-};
-#define JC_BTN_Y      (1u<<0)
-#define JC_BTN_X      (1u<<1)
-#define JC_BTN_B      (1u<<2)
-#define JC_BTN_A      (1u<<3)
-#define JC_BTN_R      (1u<<6)
-#define JC_BTN_ZR     (1u<<7)
-#define JC_BTN_MINUS  (1u<<8)
-#define JC_BTN_PLUS   (1u<<9)
-#define JC_BTN_RSTICK (1u<<10)
-#define JC_BTN_LSTICK (1u<<11)
-#define JC_BTN_HOME   (1u<<12)
-#define JC_BTN_DOWN   (1u<<16)
-#define JC_BTN_UP     (1u<<17)
-#define JC_BTN_RIGHT  (1u<<18)
-#define JC_BTN_LEFT   (1u<<19)
-#define JC_BTN_L      (1u<<22)
-#define JC_BTN_ZL     (1u<<23)
-static uint32_t codeToJc(uint8_t c, uint32_t fA,uint32_t fB,uint32_t fX,uint32_t fY){
-  switch(c){ case 1:return fA; case 2:return fB; case 3:return fX; case 4:return fY;
-    case 5:return JC_BTN_L; case 6:return JC_BTN_R; case 7:return JC_BTN_LSTICK; case 8:return JC_BTN_RSTICK;
-    case 9:return JC_BTN_MINUS; case 10:return JC_BTN_PLUS; case 11:return JC_BTN_HOME; default:return 0; }
-}
-static int16_t g_swProAX=0,g_swProAY=0,g_swProAZ=0,g_swProGX=0,g_swProGY=0,g_swProGZ=0;
-static unsigned long g_swProLastMs=0;
-Adafruit_USBD_HID g_swPro;
-static uint8_t g_swProReportMode=0;   // 0 until the host's subcommand 0x03 selects 0x30 -> THEN we stream input
-static const uint8_t JC_MAC[6]={0x7C,0xBB,0x8A,0x00,0x00,0x01};   // synthetic but stable controller MAC
-static int jcStick12(int16_t v, bool inv){   // steam int16 (center 0) -> 12-bit (center 0x800), clamped
-  int a = 2048 + (inv ? -((int)v>>4) : ((int)v>>4));
-  return a<0?0:(a>4095?4095:a);
-}
-static void jcPackStick(uint8_t s[3], int16_t x, int16_t y){   // pack two 12-bit axes into 3 bytes (both normal; Switch Y polarity is opposite DS4)
-  int X=jcStick12(x,false), Y=jcStick12(y,false);
-  s[0]=(uint8_t)(X&0xFF);
-  s[1]=(uint8_t)(((Y&0x0F)<<4)|((X>>8)&0x0F));
-  s[2]=(uint8_t)((Y>>4)&0xFF);
-}
-// Standard input-report prefix [0..11] (timer, battery/conn, 3 button bytes, both packed sticks, vibrator),
-// shared by the streamed 0x30 report and the 0x21 subcommand-reply reports the host reads during init.
-static uint8_t g_jcTimer=0;
-static void jcInputPrefix(uint8_t* out){
-  uint32_t b=g_swBtns;
-  if((b&(TB_R4|TB_L4|TB_R5|TB_L5))==(TB_R4|TB_L4|TB_R5|TB_L5)) b&=~(uint32_t)(TB_A|TB_B|TB_X|TB_Y);
-  uint32_t fA=g_abSwap?JC_BTN_B:JC_BTN_A, fB=g_abSwap?JC_BTN_A:JC_BTN_B;
-  uint32_t fX=g_abSwap?JC_BTN_Y:JC_BTN_X, fY=g_abSwap?JC_BTN_X:JC_BTN_Y;
-  uint32_t jc=0;
-  if(b&TB_Y)jc|=fY; if(b&TB_B)jc|=fB; if(b&TB_A)jc|=fA; if(b&TB_X)jc|=fX;
-  if(b&TB_LB)jc|=JC_BTN_L; if(b&TB_RB)jc|=JC_BTN_R;
-  if((g_swLT>=SW_TRIG_ON)||(b&0x8000000u))jc|=JC_BTN_ZL; if((g_swRT>=SW_TRIG_ON)||(b&0x800000u))jc|=JC_BTN_ZR;
-  if(b&TB_VIEW)jc|=JC_BTN_PLUS; if(b&TB_MENU)jc|=JC_BTN_MINUS;
-  if(b&TB_L3)jc|=JC_BTN_LSTICK; if(b&TB_R3)jc|=JC_BTN_RSTICK;
-  if(b&TB_STEAM)jc|=JC_BTN_HOME;
-  if(b&TB_DDN)jc|=JC_BTN_DOWN; if(b&TB_DUP)jc|=JC_BTN_UP;
-  if(b&TB_DRT)jc|=JC_BTN_RIGHT; if(b&TB_DLF)jc|=JC_BTN_LEFT;
-  if(b&TB_L4)jc|=codeToJc(g_back[0],fA,fB,fX,fY); if(b&TB_R4)jc|=codeToJc(g_back[1],fA,fB,fX,fY);
-  if(b&TB_L5)jc|=codeToJc(g_back[2],fA,fB,fX,fY); if(b&TB_R5)jc|=codeToJc(g_back[3],fA,fB,fX,fY);
-  out[0]=g_jcTimer++;
-  out[1]=0x81;   // full battery + USB powered (hid-nintendo bat_con)
-  out[2]=(uint8_t)(jc); out[3]=(uint8_t)(jc>>8); out[4]=(uint8_t)(jc>>16);
-  jcPackStick(out+5, g_swLX, g_swLY);
-  jcPackStick(out+8, g_swRX, g_swRY);
-  out[11]=0;
-}
-static void switchProBuild(uint8_t out[63]){
-  memset(out,0,63);
-  jcInputPrefix(out);
-  for(int k=0;k<3;k++){
-    int o=12+k*12;
-    out[o+0]=g_swProAX&0xFF; out[o+1]=g_swProAX>>8;
-    out[o+2]=g_swProAY&0xFF; out[o+3]=g_swProAY>>8;
-    out[o+4]=g_swProAZ&0xFF; out[o+5]=g_swProAZ>>8;
-    out[o+6]=g_swProGX&0xFF; out[o+7]=g_swProGX>>8;
-    out[o+8]=g_swProGY&0xFF; out[o+9]=g_swProGY>>8;
-    out[o+10]=g_swProGZ&0xFF; out[o+11]=g_swProGZ>>8;
-  }
-}
-// --- Canonical factory SPI dumps the host reads for calibration. Neutral IMU + centered sticks so a fresh
-// "device" calibrates sane; user-cal regions (0x80xx) read 0xFF so the host falls back to these factory blocks.
-static const uint8_t SPI_IMU_CAL[24]={0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x40,0x00,0x40,0x00,0x40,
-                                      0x00,0x00,0x00,0x00,0x00,0x00, 0x3B,0x34,0x3B,0x34,0x3B,0x34};
-static const uint8_t SPI_PARAMS1[24]={0x50,0xFD,0x00,0x00,0xC6,0x0F, 0x0F,0x30,0x61,0x96,0x30,0xF3,
-                                      0xD4,0x14,0x54,0x41,0x15,0x54, 0xC7,0x79,0x9C,0x33,0x36,0x63};
-static const uint8_t SPI_PARAMS2[18]={0x0F,0x30,0x61,0x96,0x30,0xF3, 0xD4,0x14,0x54,0x41,0x15,0x54,
-                                      0xC7,0x79,0x9C,0x33,0x36,0x63};
-static const uint8_t SPI_COLOR[13]={0x32,0x32,0x32, 0xE6,0xE6,0xE6, 0x32,0x32,0x32, 0x32,0x32,0x32, 0xFF};
-static uint8_t g_spiStickCal[18];   // built at boot: left[9]+right[9] factory stick calibration (packed 12-bit)
-static void jcPack12(uint8_t* o9, const uint16_t v[6]){   // pack 6 12-bit values into 9 bytes (Switch stick-cal format)
-  o9[0]=v[0]&0xFF;                       o9[1]=((v[1]&0x0F)<<4)|((v[0]>>8)&0x0F); o9[2]=(v[1]>>4)&0xFF;
-  o9[3]=v[2]&0xFF;                       o9[4]=((v[3]&0x0F)<<4)|((v[2]>>8)&0x0F); o9[5]=(v[3]>>4)&0xFF;
-  o9[6]=v[4]&0xFF;                       o9[7]=((v[5]&0x0F)<<4)|((v[4]>>8)&0x0F); o9[8]=(v[5]>>4)&0xFF;
-}
-static void jcBuildStickCal(){
-  const uint16_t C=2048, R=1800;   // center, +/- range per axis
-  uint16_t L[6]={R,R, C,C, R,R};   // left  order: max(x,y), center(x,y), min(x,y)
-  uint16_t Rr[6]={C,C, R,R, R,R};  // right order: center(x,y), min(x,y), max(x,y)
-  jcPack12(&g_spiStickCal[0], L); jcPack12(&g_spiStickCal[9], Rr);
-}
-static void spiRead(uint32_t addr, uint8_t len, uint8_t* dst){
-  for(uint8_t i=0;i<len;i++){
-    uint32_t a=addr+i; uint8_t v=0xFF;
-    if     (a>=0x6020 && a<0x6020+24) v=SPI_IMU_CAL[a-0x6020];
-    else if(a>=0x603D && a<0x603D+18) v=g_spiStickCal[a-0x603D];
-    else if(a>=0x6050 && a<0x6050+13) v=SPI_COLOR[a-0x6050];
-    else if(a>=0x6080 && a<0x6080+24) v=SPI_PARAMS1[a-0x6080];
-    else if(a>=0x6098 && a<0x6098+18) v=SPI_PARAMS2[a-0x6098];
-    dst[i]=v;   // unmapped (incl. user-cal 0x80xx) -> 0xFF = "blank flash" -> host uses factory blocks above
-  }
-}
-// Reply FIFO: the host's handshake/subcommand reports arrive in the USB ISR (jcSet); we enqueue the canonical
-// 0x81/0x21 reply and drain it from loop() (swProTask) where sendReport is safe. SPSC ring, volatile indices.
-// Every reply is sent as a full 63-byte report (64 with the id). macOS IOHIDManager silently DROPS any input
-// report shorter than the descriptor-declared length (0x3F), so short 0x81/0x21 replies never reached the host.
-#define JC_REPLEN 63
-struct JcRep { uint8_t rid; uint8_t data[JC_REPLEN]; };
-#define JCQ_N 8
-static volatile uint8_t g_jcQh=0, g_jcQt=0;
-static JcRep g_jcQ[JCQ_N];
-static void jcEnq(uint8_t rid, const uint8_t* d, uint8_t len){
-  uint8_t nt=(uint8_t)((g_jcQt+1)%JCQ_N);
-  if(nt==g_jcQh) return;                 // full -> drop; the host re-requests on timeout
-  if(len>JC_REPLEN) len=JC_REPLEN;
-  g_jcQ[g_jcQt].rid=rid;
-  memset(g_jcQ[g_jcQt].data,0,JC_REPLEN); memcpy(g_jcQ[g_jcQt].data,d,len);   // zero-pad to full report length
-  g_jcQt=nt;
-}
-// Build the 0x21 reply (standard input prefix + ACK + echoed subcommand id + reply data) for a report-0x01 subcommand.
-static void jcSubcmd(uint8_t sub, const uint8_t* args, uint16_t alen){
-  uint8_t p[JC_REPLEN]; memset(p,0,sizeof p);
-  jcInputPrefix(p);
-  p[13]=sub;
-  switch(sub){
-    case 0x02:                         // request device info
-      p[12]=0x82;
-      p[14]=0x04; p[15]=0x21;          // firmware version (4.33)
-      p[16]=0x03;                      // controller type: Pro Controller
-      p[17]=0x02;                      // fixed
-      memcpy(&p[18], JC_MAC, 6);
-      p[24]=0x01;                      // colors stored in SPI (0x6050)
-      p[25]=0x02;                      // fixed
-      break;
-    case 0x10: {                       // SPI flash read -> echo [addr][len] then the data
-      if(alen<5){ p[12]=0x80; break; }
-      uint32_t a=(uint32_t)args[0]|((uint32_t)args[1]<<8)|((uint32_t)args[2]<<16)|((uint32_t)args[3]<<24);
-      uint8_t rl=args[4]; if(rl>0x1D) rl=0x1D;
-      p[12]=0x90;
-      p[14]=args[0]; p[15]=args[1]; p[16]=args[2]; p[17]=args[3]; p[18]=rl;
-      spiRead(a, rl, &p[19]);
-      break; }
-    case 0x03:                         // set input report mode (0x30 = standard full) -> begin streaming
-      if(alen>=1 && args[0]==0x30) g_swProReportMode=0x30;
-      p[12]=0x80; break;
-    case 0x04: p[12]=0x83; break;      // trigger buttons elapsed time
-    case 0x21: p[12]=0xA0; break;      // set NFC/IR config
-    default:   p[12]=0x80; break;      // 0x06/0x08/0x30/0x38/0x40/0x41/0x48/... generic positive ACK
-  }
-  jcEnq(0x21, p, JC_REPLEN);
-}
-// USB-ISR callback for the Pro Controller's OUT endpoint. Data arrives via the interrupt OUT endpoint, where
-// this TinyUSB core passes report_id=0 and leaves the real report id in buffer[0] (it does NOT strip it). Also
-// accept the control SET_REPORT shape (id already in `rid`) for safety. report 0x80 = USB handshake; 0x01 = subcommand.
-static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const* b, uint16_t n){
-  if(type!=HID_REPORT_TYPE_OUTPUT || n<1) return;
-  uint8_t id; const uint8_t* p; uint16_t pn;
-  if(rid==0){ id=b[0]; p=b+1; pn=(uint16_t)(n-1); }   // EP-OUT: id is the first payload byte
-  else      { id=rid;  p=b;   pn=n;               }   // control SET_REPORT: id already split out
-  if(id==0x80){
-    if(pn<1) return;
-    if(p[0]==0x01){ uint8_t d[9]={0x01,0x00,0x03,JC_MAC[0],JC_MAC[1],JC_MAC[2],JC_MAC[3],JC_MAC[4],JC_MAC[5]}; jcEnq(0x81,d,9); } // device type + MAC
-    else if(p[0]==0x02){ uint8_t d[1]={0x02}; jcEnq(0x81,d,1); }   // handshake
-    else if(p[0]==0x03){ uint8_t d[1]={0x03}; jcEnq(0x81,d,1); }   // set baudrate
-    // 0x04 force-USB / 0x05 enable-timeout / 0x06 reset: no reply expected
-    return;
-  }
-  if(id==0x01){                        // [timer][rumble x8][subcmd][args...]
-    if(pn<10) return;
-    jcSubcmd(p[9], p+10, (pn>10)?(uint16_t)(pn-10):0);
-  }
-  // report 0x10 (rumble only) / 0x82: ignored
-}
-static void swProTask(){
-  if(g_usbMode!=MODE_SW_PRO || !g_swPro.ready()) return;
-  if(g_jcQh!=g_jcQt){                                   // drain handshake/subcommand replies first (ordered)
-    JcRep* r=&g_jcQ[g_jcQh];
-    if(g_swPro.sendReport(r->rid, r->data, JC_REPLEN)) g_jcQh=(uint8_t)((g_jcQh+1)%JCQ_N);
-    return;                                             // one report per call; the rest next loop
-  }
-  if(g_swProReportMode!=0x30) return;                   // not until the host has finished init + selected 0x30
-  if(millis()-g_swProLastMs<USB_STREAM_MS) return; g_swProLastMs=millis();
-  uint8_t p[63]; switchProBuild(p);
-  g_swPro.sendReport(0x30, p, sizeof p);
-}
-
-// ===================== PS5 DualSense mode (5) — 054C:0CE6 + gyro + split trackpad =====================
-static const uint8_t PS5_HID_DESC[]={
-  0x05,0x01,0x09,0x05,0xA1,0x01,0x85,0x01,0x09,0x30,0x09,0x31,0x09,0x32,0x09,0x35,
-  0x09,0x33,0x09,0x34,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x06,0x81,0x02,0x06,
-  0x00,0xFF,0x09,0x20,0x95,0x01,0x81,0x02,0x05,0x01,0x09,0x39,0x15,0x00,0x25,0x07,
-  0x35,0x00,0x46,0x3B,0x01,0x65,0x14,0x75,0x04,0x95,0x01,0x81,0x42,0x65,0x00,0x05,
-  0x09,0x19,0x01,0x29,0x0F,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x0F,0x81,0x02,0x06,
-  0x00,0xFF,0x09,0x21,0x95,0x0D,0x81,0x02,0x06,0x00,0xFF,0x09,0x22,0x15,0x00,0x26,
-  0xFF,0x00,0x75,0x08,0x95,0x34,0x81,0x02,0x85,0x02,0x09,0x23,0x95,0x2F,0x91,0x02,
-  0x85,0x05,0x09,0x33,0x95,0x28,0xB1,0x02,0x85,0x08,0x09,0x34,0x95,0x2F,0xB1,0x02,
-  0x85,0x09,0x09,0x24,0x95,0x13,0xB1,0x02,0x85,0x0A,0x09,0x25,0x95,0x1A,0xB1,0x02,
-  0x85,0x20,0x09,0x26,0x95,0x3F,0xB1,0x02,0x85,0x21,0x09,0x27,0x95,0x04,0xB1,0x02,
-  0x85,0x22,0x09,0x40,0x95,0x3F,0xB1,0x02,0x85,0x80,0x09,0x28,0x95,0x3F,0xB1,0x02,
-  0x85,0x81,0x09,0x29,0x95,0x3F,0xB1,0x02,0x85,0x82,0x09,0x2A,0x95,0x09,0xB1,0x02,
-  0x85,0x83,0x09,0x2B,0x95,0x3F,0xB1,0x02,0x85,0x84,0x09,0x2C,0x95,0x3F,0xB1,0x02,
-  0x85,0x85,0x09,0x2D,0x95,0x02,0xB1,0x02,0x85,0xA0,0x09,0x2E,0x95,0x01,0xB1,0x02,
-  0x85,0xE0,0x09,0x2F,0x95,0x3F,0xB1,0x02,0x85,0xF0,0x09,0x30,0x95,0x3F,0xB1,0x02,
-  0x85,0xF1,0x09,0x31,0x95,0x3F,0xB1,0x02,0x85,0xF2,0x09,0x32,0x95,0x0F,0xB1,0x02,
-  0x85,0xF4,0x09,0x35,0x95,0x3F,0xB1,0x02,0x85,0xF5,0x09,0x36,0x95,0x03,0xB1,0x02,0xC0
-};
-#define PS5_TOUCH_H 1080
-#define DS4_TOUCH_H 942
-#define PS5_STATUS_USB 0x1A   // charging + level 10 (~100%)
-#define DS4_STATUS_USB 0x1B   // cable + level 11 (full)
-static int16_t g_ps5AX=0,g_ps5AY=0,g_ps5AZ=0,g_ps5GX=0,g_ps5GY=0,g_ps5GZ=0;
-static int16_t g_ps5LPX=0,g_ps5LPY=0,g_ps5RPX=0,g_ps5RPY=0;
-static unsigned long g_ps5LastMs=0;
-Adafruit_USBD_HID g_ps5;
-static uint8_t psHatNibble(uint32_t b){
-  bool u=b&TB_DUP,d=b&TB_DDN,l=b&TB_DLF,r=b&TB_DRT;
-  if(u&&r)return 1; if(r&&d)return 3; if(d&&l)return 5; if(l&&u)return 7;
-  if(u)return 0; if(r)return 2; if(d)return 4; if(l)return 6; return 8;
-}
-static uint8_t psFaceNibble(uint32_t b){
-  uint8_t f=0;
-  if(g_abSwap){
-    if(b&TB_A)f|=0x40; if(b&TB_B)f|=0x20; if(b&TB_X)f|=0x80; if(b&TB_Y)f|=0x10;
-  } else {
-    if(b&TB_A)f|=0x20; if(b&TB_B)f|=0x40; if(b&TB_X)f|=0x10; if(b&TB_Y)f|=0x80;
-  }
-  return f;
-}
-static void ps5Build(uint8_t out[63]){
-  uint32_t b=psButtonsFromSteam(g_swBtns);
-  bool lTouch=(b&TB_LPADT)||(b&TB_LPADC), rTouch=(b&TB_RPADT)||(b&TB_RPADC);
-  memset(out,0,63);
-  out[0]=swStick(g_swLX,false); out[1]=swStick(g_swLY,true);
-  out[2]=swStick(g_swRX,false); out[3]=swStick(g_swRY,true);
-  out[4]=g_swLT; out[5]=g_swRT;
-  static uint8_t seq=0; out[6]=seq++;
-  out[7]=psHatNibble(b)|psFaceNibble(b);
-  out[8]=psShouldersByte(b);
-  out[9]=((b&TB_STEAM)?0x01:0)|((b&TB_LPADC||b&TB_RPADC)?0x02:0);
-  out[15]=g_ps5GX&0xFF; out[16]=g_ps5GX>>8;
-  out[17]=g_ps5GZ&0xFF; out[18]=g_ps5GZ>>8;
-  out[19]=g_ps5GY&0xFF; out[20]=g_ps5GY>>8;
-  out[21]=g_ps5AX&0xFF; out[22]=g_ps5AX>>8;
-  out[23]=g_ps5AY&0xFF; out[24]=g_ps5AY>>8;
-  out[25]=g_ps5AZ&0xFF; out[26]=g_ps5AZ>>8;
-  uint16_t lx, ly, rx, ry;
-  steamPadsToTouch(b, PS5_TOUCH_H, g_ps5LPX, g_ps5LPY, g_ps5RPX, g_ps5RPY, &lx, &ly, &rx, &ry);
-  touchPackPads(out+32, lTouch, rTouch, lx, ly, rx, ry);
-  out[52]=PS5_STATUS_USB;
-}
-static void ps5Stream(){
-  if(g_usbMode!=MODE_PS5 || !g_ps5.ready()) return;
-  if(millis()-g_ps5LastMs<USB_STREAM_MS) return; g_ps5LastMs=millis();
-  uint8_t p[63]; ps5Build(p);
-  g_ps5.sendReport(0x01, p, sizeof p);
-}
-
-// ===================== Generic HID gyro mode (6) — DS4 USB report layout + motion =====================
-// Standard DS4 054C:05C4 descriptor (input report 0x01, 64 B). Gyro/accel at bytes 13-24 (GIMX layout).
-static const uint8_t GYRO_HID_DESC[]={
-  0x05,0x01,0x09,0x05,0xA1,0x01,0x85,0x01,0x09,0x30,0x09,0x31,0x09,0x32,0x09,0x35,
-  0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x04,0x81,0x02,0x09,0x39,0x15,0x00,0x25,
-  0x07,0x35,0x00,0x46,0x3B,0x01,0x65,0x14,0x75,0x04,0x95,0x01,0x81,0x42,0x65,0x00,
-  0x05,0x09,0x19,0x01,0x29,0x0E,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x0E,0x81,0x02,
-  0x06,0x00,0xFF,0x09,0x20,0x75,0x06,0x95,0x01,0x15,0x00,0x25,0x7F,0x81,0x02,0x05,
-  0x01,0x09,0x33,0x09,0x34,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x02,0x81,0x02,
-  0x06,0x00,0xFF,0x09,0x21,0x95,0x36,0x81,0x02,0x85,0x05,0x09,0x22,0x95,0x1F,0x91,
-  0x02,0x85,0x04,0x09,0x23,0x95,0x24,0xB1,0x02,0x85,0x02,0x09,0x24,0x95,0x24,0xB1,
-  0x02,0xC0
-};
-static int16_t g_gyroAX=0,g_gyroAY=0,g_gyroAZ=0,g_gyroGX=0,g_gyroGY=0,g_gyroGZ=0;
-static unsigned long g_gyroLastMs=0;
-Adafruit_USBD_HID g_hidGyro;
-static void hidGyroBuild(uint8_t out[63]){
-  uint32_t b=psButtonsFromSteam(g_swBtns);
-  bool lTouch=(b&TB_LPADT)||(b&TB_LPADC), rTouch=(b&TB_RPADT)||(b&TB_RPADC);
-  memset(out,0,63);
-  out[0]=swStick(g_swLX,false); out[1]=swStick(g_swLY,true);
-  out[2]=swStick(g_swRX,false); out[3]=swStick(g_swRY,true);
-  out[4]=psHatNibble(b)|psFaceNibble(b);
-  out[5]=psShouldersByte(b);
-  static uint8_t ctr=0; out[6]=((ctr++&0x0F)<<4)|((b&TB_LPADC||b&TB_RPADC)?0x02:0)|((b&TB_STEAM)?0x01:0);
-  out[7]=g_swLT; out[8]=g_swRT;
-  out[12]=g_gyroGX&0xFF; out[13]=g_gyroGX>>8;
-  out[14]=g_gyroGY&0xFF; out[15]=g_gyroGY>>8;
-  out[16]=g_gyroGZ&0xFF; out[17]=g_gyroGZ>>8;
-  out[18]=g_gyroAX&0xFF; out[19]=g_gyroAX>>8;
-  out[20]=g_gyroAY&0xFF; out[21]=g_gyroAY>>8;
-  out[22]=g_gyroAZ&0xFF; out[23]=g_gyroAZ>>8;
-  out[29]=DS4_STATUS_USB;
-  if(lTouch || rTouch){
-    uint16_t lx, ly, rx, ry;
-    steamPadsToTouch(b, DS4_TOUCH_H, g_ps5LPX, g_ps5LPY, g_ps5RPX, g_ps5RPY, &lx, &ly, &rx, &ry);
-    static uint8_t tstamp=0;
-    out[32]=1; out[33]=tstamp++;
-    touchPackPads(out+34, lTouch, rTouch, lx, ly, rx, ry);
-  } else {
-    out[32]=0;
-    touchPackPads(out+34, false, false, 0, 0, 0, 0);   // contact 0x80 — memset(0) reads as touch @0,0
-  }
-}
-static void hidGyroStream(){
-  if(g_usbMode!=MODE_HIDGYRO || !g_hidGyro.ready()) return;
-  if(millis()-g_gyroLastMs<USB_STREAM_MS) return; g_gyroLastMs=millis();
-  uint8_t p[63]; hidGyroBuild(p);
-  g_hidGyro.sendReport(0x01, p, sizeof p);
-}
-
-// RECIPE (IBEX disasm @0x18d80): controller streams 0xF1 input report ONLY when (a) connstate==3 [host
-// frame E1 establishes it], (b) we mark host-awake via 0xE7 payload [00][00] (vs [00][01]=suspended,
-// handler @0x193aa), and (c) EACH 0xE3 poll carries a GET-report-0x45 sub-TLV [len=02][subtype=01][id=45]
-// [param] (parser @0x1948c; F1 builder @0x20ca4 arms a one-shot latch @0x2000d36e cleared after each F1 ->
-// no free-run stream, 1 F1 per GET). F1 reply = type 0xF1 @rfrx[2] then TLV type4(analog/buttons)+
-// type6(per-module gyro/accel/sticks).
-static uint8_t  g_connSt    = 0;         // 0=announce awake, 1=poll loop
-static uint8_t  g_connStep  = 0;         // repeat counter within a state
-static uint16_t g_connPoll  = 0;         // poll counter (re-assert awake every 64th)
-static uint32_t g_connF1    = 0;         // count of 0xF1 input reports seen
-static uint8_t  g_getParam  = 0x00;      // GET report 0x45 param byte (try 0x00, fall back 0x2D) - 'q' cmd
-static uint8_t  g_e3mode = 1, g_e3pid = 0;  // E3-poll PID/S1 mode: 0=fixed 0x07, 1=cycle PID(noack1), 2=cycle PID(noack0). DEFAULT 1: cycling the ESB PID drains the controller's report queue (~400 new/s vs ~60 with a fixed PID) — THE rate fix. 'e<n>' to A/B.
-static bool     g_connVerbose = false;   // true=full multi-line debug dump; false=compact "I45 <hex>" stream
-static unsigned long g_lastStream = 0;   // throttle for compact UI stream
-static unsigned long g_connCooldown = 0; // set on 0xF2 disconnect; pauses beacon+poll so a powering-off controller can sleep
-// (g_connReplyMs, g_connSlot declared near g_slot so the USB feature handler can read them)
-// TX one connected packet [LEN][S1][payload] on channel ch, then RX the reply into rfrx. Decodes 0xF1.
-static uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen){
-  memset(rftx,0,sizeof rftx);
-  rftx[0]=plen;                          // LENGTH = payload byte count
-  rftx[1]=s1;                            // S1 (type-specific)
-  memcpy(rftx+2, payload, plen);         // payload[0]=type byte, then data/TLVs
-  rfConfig(ch); rfSetAddr(g_rfBase,g_rfPrefix);
-  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; rfrx[0]=0;
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk;       // RXEN->READY->START; catch the reply
-  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
-  g_stPoll++;
-  uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<g_rxWin){}  // RX window (tunable 'r'): caps poll rate ~1e6/rxWin
-  uint8_t rxlen=0;
-  if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
-    bool crcok=NRF_RADIO->CRCSTATUS&1; rxlen=rfrx[0];
-    if(!crcok){ g_stCrc++; g_qosBad++; }                // reply arrived but CRC failed -> RF quality (channel/interference)
-    if (crcok && rxlen && rxlen<=64){                   // F1 report ~46B, so allow up to MAXLEN
-      g_connRx++; g_connReplyMs=millis();               // link alive -> loop() suppresses the redundant E1 beacon
-      if(rfrx[2]==0xF1) g_stF1++;
-      uint8_t rtype=rfrx[2];                            // reply type byte (proven offset from captures)
-      if(rtype==0xF2) g_connCooldown=millis();          // controller disconnecting/powering off -> back off 2.5s
-      if(rtype==0xF3){                                  // F3 = controller status/version reply (ODR agent: reply to E7 version handshake, byte[6]=version)
-        g_stF3++; g_connF3v=rfrx[6];
-        if(g_connVerbose && Serial.availableForWrite()>40){ Serial.print("  F3 "); for(uint8_t i=0;i<(rxlen+2<32?rxlen+2:32);i++)Serial.printf("%02X",rfrx[i]); Serial.println(); }
-      }
-      bool isF1=(rtype==0xF1);
-      if(isF1){
-        g_connF1++;
-        int idx=3, end=rxlen+2;                         // walk ALL type6 TLVs (= HID report 0x45). idx is INT, not
-        const uint8_t* lastRep=nullptr; uint8_t lastTlen=0;  // uint8_t: a tlen of 0xFE would make idx+=tlen+2 wrap
-        while(idx+1<end){                               // mod-256 back to itself -> infinite loop -> USB hang/"crash".
-          uint8_t tlen=rfrx[idx], ttype=rfrx[idx+1];    // pace with the real puck — taking only [0] halved our rate.
-          if(tlen==0) break;
-          // Only a FULL 0x45 report that fits entirely in rfrx: a short or late/garbled TLV must not let the
-          // decode read past the RF buffer (corrupts rftx/RAM -> eventual crash).
-          if(ttype==6 && tlen>=28 && (size_t)(idx+2)+tlen<=sizeof(rfrx) && rfrx[idx+2]==0x45){
-            const uint8_t* rep=&rfrx[idx+2];            // report 0x45: [0x45][seq][buttons u32]...
-            bool fresh=(rep[1]!=g_lastSeq); if(fresh){ g_stNew++; g_lastSeq=rep[1]; }  // genuine new report vs stale poll-repeat
-            uint32_t bb=btnsOf(rep);
-            // USB remote wakeup on Steam button short press (down + up within 1 s).
-            // A long press likely means the user is powering off the controller, so we ignore it.
-            { static bool steamWasDown=false; static unsigned long steamDownMs=0;
-              if(fresh){
-                bool steamNow=(bb & TB_STEAM)!=0;
-                if(steamNow && !steamWasDown) steamDownMs=millis();                                                            // rising edge: record press time
-                if(!steamNow && steamWasDown && millis()-steamDownMs<1000u && USBDevice.suspended()) USBDevice.remoteWakeup(); // falling edge within 1 s -> short press -> wake
-                steamWasDown=steamNow;
-              }
-            }
-            // cache the latest decoded frame for the Switch streamer + Xbox/Steam paths
-            g_swBtns=bb; g_swLX=(int16_t)s16off(rep,8); g_swLY=(int16_t)s16off(rep,10);
-            g_swRX=(int16_t)s16off(rep,12); g_swRY=(int16_t)s16off(rep,14);
-            g_swLT=trigU8(u16off(rep,4)); g_swRT=trigU8(u16off(rep,6));   // for the Switch digital-trigger threshold
-            g_ps5LPX=(int16_t)s16off(rep,16); g_ps5LPY=(int16_t)s16off(rep,18);
-            g_ps5RPX=(int16_t)s16off(rep,22); g_ps5RPY=(int16_t)s16off(rep,24);
-            imuFrom45(rep, &g_swProAX,&g_swProAY,&g_swProAZ,&g_swProGX,&g_swProGY,&g_swProGZ);
-            g_ps5AX=g_swProAX; g_ps5AY=g_swProAY; g_ps5AZ=g_swProAZ;
-            g_ps5GX=g_swProGX; g_ps5GY=g_swProGY; g_ps5GZ=g_swProGZ;
-            g_gyroAX=g_swProAX; g_gyroAY=g_swProAY; g_gyroAZ=g_swProAZ;
-            g_gyroGX=g_swProGX; g_gyroGY=g_swProGY; g_gyroGZ=g_swProGZ;
-            // Mode-switch chord (all 4 back + face): don't leak the face press to the host. g_swBtns stays intact
-            // so the chord detector still fires; per-mode builders mask the same bits while back-4 are held.
-            const uint32_t CHORD_BACK4 = TB_R4|TB_L4|TB_R5|TB_L5;
-            if((bb&CHORD_BACK4)==CHORD_BACK4)
-              ((uint8_t*)rep)[2] &= ~(uint8_t)(TB_A|TB_B|TB_X|TB_Y);
-            if(g_usbMode==MODE_SW_HORI || g_usbMode==MODE_SW_PRO || g_usbMode==MODE_PS5 || g_usbMode==MODE_HIDGYRO){
-              // streamed from loop(); nothing to send here
-            } else if(modeIsXbox(g_usbMode)){           // XBOX: standard gamepad + right-pad mouse (2nd interface)
-              rfXboxGamepad(rep); rfXboxMouse(rep);
-            } else if(g_connSlot>=0 && g_connSlot<NSLOT){  // STEAM / LIZARD (puck interface)
-              if(lizardActive()){
-                rfLizard(rep, &hid[g_connSlot], &hid[g_connSlot], 0x40, 0x41);
-              } else {
-                uint8_t blen=tlen-1; if(blen>45)blen=45;  // body after the 0x45 id byte
-                // forward the puck's raw pad coords untouched — Steam does its own interpolation/smoothing.
-                // forward only FRESH reports (the real puck dedupes -> Steam gets a clean unique stream; sending
-                // stale repeats makes Steam's velocity/smoothing stair-step). g_fwdNewOnly toggles for A/B.
-                if((fresh || !g_fwdNewOnly) && g_slot[g_connSlot].used && hid[g_connSlot].ready())
-                  hid[g_connSlot].sendReport(0x45, rep+1, blen);   // Steam/SDL: input report -> "connected"
-              }
-            }
-            lastRep=rep; lastTlen=tlen;
-          }
-          idx+=tlen+2;
-        }
-        // mode-switch chord (back4 + face): A=always Steam; B/X/Y=configurable (g_chordBtn[]). Debounced.
-        { static uint8_t chWant=0xFF, chCnt=0;
-          const uint32_t BACK4=TB_R4|TB_L4|TB_R5|TB_L5; uint8_t want=0xFF;
-          if((g_swBtns&BACK4)==BACK4){
-            if(g_swBtns&TB_A) want=MODE_STEAM;
-            else if(g_swBtns&TB_B) want=g_chordBtn[0];
-            else if(g_swBtns&TB_X) want=g_chordBtn[1];
-            else if(g_swBtns&TB_Y) want=g_chordBtn[2];
-          }
-          if(want!=0xFF && want==chWant){ if(++chCnt>=12 && want!=g_usbMode && modeValid(want) && !USBDevice.suspended()){ saveMode(want); delay(40); NVIC_SystemReset(); } }
-          else { chWant=want; chCnt=(want!=0xFF)?1:0; }
-        }
-        // compact stream for rf_controller_ui.py — NON-BLOCKING: skip if CDC TX is backed up (a blocking
-        // Serial.print stalls the RF+USB loop -> jaggy input). One line/frame using the last record.
-        if(lastRep && !g_connVerbose && Serial.availableForWrite()>110 && millis()-g_lastStream>=4){
-          g_lastStream=millis(); Serial.print("I45 ");
-          for(uint8_t i=0;i<lastTlen;i++)Serial.printf("%02X",lastRep[i]); Serial.println();
-        }
-      }
-      if(g_connVerbose){
-        Serial.printf("%s CRX#%lu txtype%02X ch%u len%u: ",isF1?"<<<F1":(rtype==0xF3?"  F3":"  rx"),
-                      (unsigned long)g_connRx,payload[0],ch,rxlen);
-        for(uint8_t i=0;i<(rxlen+2<=66?rxlen+2:66);i++)Serial.printf("%02X",rfrx[i]); Serial.println();
-      }
-    } else rxlen=0;
-  } else { g_stNoRx++; g_qosBad++; }                   // RX window expired with no packet at all -> turnaround/timing or controller silent
-  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-  return rxlen;
-}
-// Drive the connected-mode sequence one step per call (from loop() while g_connOn). Camps on g_rfCh (the
-// host-frame channel the controller connected on); the E1 beacon (cmd 'H') runs in parallel as keepalive.
-static void rfConnStep(){
-  g_connSlot=-1; for(int s=0;s<NSLOT;s++) if(g_slot[s].used){g_connSlot=s;break;}
-  if(g_connSlot<0) return;               // need a bonded slot (session established by host frame)
-  uint8_t ch=g_sessCh;
-  if(g_connSt==0){                       // announce HOST AWAKE: E7 00 00, a few times
-    uint8_t p[3]={0xE7,0x00,g_e7b}; rfConnTx(ch,0x01,p,3);
-    if(++g_connStep>=4){ g_connSt=1; g_connStep=0; Serial.println("# CONN: awake announced -> polling GET report 0x45"); }
-  } else {                               // poll loop: E3 + GET report 0x45 every poll; re-assert awake periodically
-    if((uint32_t)(micros()-g_lastPollUs) < g_pollUs) return;   // CONTROLLED CADENCE: poll ~every g_pollUs,
-    g_lastPollUs=micros();                                     // not as-fast-as-possible (over-polling starves replies)
-    if((g_connPoll & 0x1F)==0){ uint8_t pa[3]={0xE7,0x00,g_e7b}; rfConnTx(ch,0x01,pa,3); }   // re-assert awake/version
-    rfConnQueueHapticRelay();
-    rfConnFlushRelay(ch);
-    {
-      uint8_t p[5]={0xE3,0x02,0x01,0x45,g_getParam}; // E3 + TLV [len=02][subtype=01 GET][id=0x45][param]
-      uint8_t s1 = (g_e3mode==1) ? (uint8_t)((((g_e3pid++)&3)<<1)|1)   // cycle PID (S1 1,3,5,7), NO_ACK=1
-                 : (g_e3mode==2) ? (uint8_t)(((g_e3pid++)&3)<<1)       // cycle PID (S1 0,2,4,6), NO_ACK=0
-                 : 0x07;                                               // fixed (current default; matches captured puck poll)
-      uint8_t rx=rfConnTx(ch,s1,p,5);
-      if(rx) g_chF1[0]++;
-    }
-    g_connPoll++;
-  }
-}
-// SESSION SNIFFER (Phase 2b capture): phase 0 RX the real puck's host frame on "ibex"/ch2 (CRC16
-// config) to learn the random session base/prefix/channel; phase 1 RX that session address on the
-// hop channels {hostch,2,80} to capture the live connected exchange (0xE3 poll + 0xF1 input reply).
-static bool g_sniff=false; static uint8_t g_sniffPh=0; static uint32_t g_sniffN=0;
-static uint8_t g_sbase[4]={0,0,0,0}, g_sprefix=0; static uint8_t g_schan[3]={78,2,80}; static uint8_t g_schi=0;
-static unsigned long g_sniffHop=0;
-static uint8_t g_sniffPark=0;   // phase-1: 0=park on learned primary g_schan[0]; else park on this literal channel ('g<n>')
-static void rfSniffStart(){
-  uint8_t ch = (g_sniffPh==0)?2:(g_sniffPark?g_sniffPark:g_schan[0]);   // PARK (no hop): primary carries the traffic
-  const uint8_t* base = (g_sniffPh==0)?g_rfBase:g_sbase;
-  uint8_t pfx = (g_sniffPh==0)?g_rfPrefix:g_sprefix;
-  rfConfig(ch);                         // Ble_2Mbit, PCNF0=0x30008, ENDIAN big, BALEN4, CRC16 0x11021
-  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(base[i]);
-  NRF_RADIO->BASE0=((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
-  NRF_RADIO->PREFIX0=rfBitrev8(pfx); NRF_RADIO->TXADDRESS=0; NRF_RADIO->RXADDRESSES=1;
-  NRF_RADIO->PACKETPTR=(uint32_t)rfrx; memset(rfrx,0,4);
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
-  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
-}
-static void rfSniffPoll(){
-  if(!g_sniff) return;
-  if(NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
-    bool crcok=NRF_RADIO->CRCSTATUS&1; uint8_t len=rfrx[0];
-    if(crcok && len && len<40){
-      g_sniffN++;
-      if(g_sniffPh==0 && rfrx[2]==0xE1){           // host frame -> learn session params + DUMP FULL (app-data may ride here)
-        memcpy(g_sbase,rfrx+15,4); g_sprefix=rfrx[19]; g_schan[0]=rfrx[11];
-        Serial.printf("# HOSTFRAME ch=%u sbase=%02X%02X%02X%02X prefix=%02X len%u FULL: ",
-          rfrx[11],g_sbase[0],g_sbase[1],g_sbase[2],g_sbase[3],g_sprefix,len);
-        { uint8_t dn=(len+2<64)?len+2:64; for(uint8_t i=0;i<dn;i++)Serial.printf("%02X",rfrx[i]); } Serial.println();
-        g_sniffPh=1; g_schi=0; rfSniffStart();
-      } else if(g_sniffPh==1){
-        uint8_t ty=rfrx[2];                          // 0xF1=input, 0x12/0xE1/0xE4=puck host-frame w/ app-data
-        const char* tag = (ty==0xF1)?"<<<INPUT":(ty==0x12||ty==0xE1||ty==0xE4)?">>>HOSTFR":"  pkt";
-        Serial.printf("%s SNIFF#%lu ch%u len%u: ",tag,(unsigned long)g_sniffN,g_schan[g_schi],len);
-        uint8_t dn=(len+2<64)?len+2:64;              // FULL frame (app-data TLVs sit near the end)
-        for(uint8_t i=0;i<dn;i++)Serial.printf("%02X",rfrx[i]); Serial.println();
-      }
-    }
-  }
-}
-// SCAN-THEN-RESPOND (dongle role): listen on ch2/91A2A793 for the controller's advertisement,
-// and the instant one arrives, TX the host frame (its ack). Matches IBEX 'scan-before-initiating'.
-static uint8_t g_hostTx[48]; static int g_hostLen=0; static bool g_rfRespond=false; static uint32_t g_advCount=0;
-static void buildHostTx(){
-  int slot=-1; for(int i=0;i<NSLOT;i++) if(g_slot[i].used){slot=i;break;}
-  memset(g_hostTx,0,sizeof g_hostTx); g_hostLen=0; if(slot<0) return;
-  // S0LEN=1 RAM layout: [0]=S0=0x12, [1]=LENGTH, raw RAM offsets validated by controller.
-  memset(g_hostTx,0,sizeof g_hostTx);
-  g_hostTx[0]=0x12; g_hostTx[1]=0x20; g_hostTx[3]=1; g_hostTx[5]=0xE1;
-  memcpy(g_hostTx+6,g_slot[slot].rec,4); memcpy(g_hostTx+10,g_slot[slot].rec+4,4);
-  g_hostTx[0xe]=2; memcpy(g_hostTx+0x12,g_rfBase,4); g_hostTx[0x16]=g_rfPrefix;
-  g_hostLen=0x22;
-}
-static void rfRespondStart(){
-  buildHostTx();
-  rfConfig(2); rfSetAddr(g_rfBase,g_rfPrefix);            // RX ch2 / 91A2A793
-  NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
-  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1; g_rfRespond=true;
-  Serial.printf("# RESPOND mode: RX ch2/91A2A793, reply host frame (len%d)\n", g_hostLen);
-}
-static void rfRespondPoll(){
-  if(!g_rfRespond) return;
-  if(NRF_RADIO->EVENTS_END){
-    NRF_RADIO->EVENTS_END=0; uint8_t len=rfrx[0];
-    if(len && len<64){
-      g_advCount++;
-      // reply: TX the host frame immediately (ack)
-      NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-      NRF_RADIO->PACKETPTR=(uint32_t)g_hostTx;
-      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;
-      Serial.printf("ADV#%lu len%u: ",(unsigned long)g_advCount,len);
-      for(uint8_t i=0;i<=len&&i<32;i++)Serial.printf("%02X",rfrx[i]); Serial.println();
-      // re-arm RX
-      NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
-      NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
-      NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
-    }
-  }
-}
-static void rfBeaconOnce(){
-  uint8_t pl[48]; memset(pl,0,sizeof pl);
-  pl[0]=0x01; pl[1]=g_seq++; pl[5]=0xE2;                 // dongle beacon (FUN_00027ed8 format)
-  rfConfig(g_rfCh); rfSetAddr(g_rfBase,g_rfPrefix);
-  if (g_s1incl) NRF_RADIO->PCNF0 |= (1u<<RADIO_PCNF0_S1INCL_Pos);
-  // RAM: [LENGTH][ (S1 if S1INCL) ][payload]
-  rftx[0]=0; rftx[1]=g_plen; memcpy(rftx+2,pl,g_plen);
-  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;
-  // RX window for the controller's response
-  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 && (micros()-t0)<500){}
-  if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
-    bool crcok=NRF_RADIO->CRCSTATUS&1; uint8_t len=rfrx[0];
-    if (len && len<64){ g_rfRxCount++;
-      Serial.printf("RESP#%lu crc%d len%u: ",(unsigned long)g_rfRxCount,crcok,len);
-      for(uint8_t i=0;i<=len&&i<40;i++)Serial.printf("%02X ",rfrx[i]); Serial.println();
-    }
-  }
-  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-}
-
-// ===================== WebUSB config channel =====================
-// Browser panel (copycat_config.html) <-> firmware, binary framed.
-//   host->dev:  0x01                  GET  -> reply status blob
-//               0x02 <field> <value>  SET one byte field (1=mDiv 2=mFric 3=(removed, ex-padSmooth) 4=abSwap 5..8=back[0..3] 9=(removed, poll rate fixed)
-//                                     10=e7b 11=relayOp 12=relaySub 13=testHaptic 14=fwdNewOnly 15=qos 16=persistMode
-//                                     17..19=chordBtn B/X/Y mode assignments)
-//               0x03 <mode>           switch USB mode (0..7): persist + reboot
-//   dev->host:  0xA5 <len> <payload>  payload = [ver=1][mode][mDiv][mFric][rsvd=0][abSwap]
-//                                                [back0..3][connSlot(0xFF=none)][linkUp][f1ps_lo][f1ps_hi][pollU100][newps_lo][newps_hi][e7b][relayOp][relaySub]
-#define WB_PAYLEN 26
-static void webusbSendBlob(){
-  if(!usb_web.connected()) return;
-  bool up = (g_connSlot>=0 && (millis()-g_connReplyMs) < 300);
-  uint8_t p[2+WB_PAYLEN];
-  p[0]=0xA5; p[1]=WB_PAYLEN;
-  p[2]=2;                          // protocol version (2 = chordBtn[3] in blob)
-  p[3]=g_usbMode; p[4]=(uint8_t)g_mDiv; p[5]=(uint8_t)g_mFric; p[6]=0 /*rsvd: ex-padSmooth*/; p[7]=g_abSwap;
-  p[8]=g_back[0]; p[9]=g_back[1]; p[10]=g_back[2]; p[11]=g_back[3];
-  p[12]=(g_connSlot>=0)?(uint8_t)g_connSlot:0xFF;
-  p[13]=up?1:0;
-  p[14]=(uint8_t)g_f1ps; p[15]=(uint8_t)(g_f1ps>>8);
-  p[16]=(uint8_t)(g_pollUs/100);
-  p[17]=(uint8_t)g_newps; p[18]=(uint8_t)(g_newps>>8);
-  p[19]=g_e7b;
-  p[20]=g_relayOp; p[21]=g_relaySub; p[22]=g_fwdNewOnly; p[23]=g_qos; p[24]=g_persistMode?1:0;
-  p[25]=g_chordBtn[0]; p[26]=g_chordBtn[1]; p[27]=g_chordBtn[2];
-  usb_web.write(p,sizeof p); usb_web.flush();
-}
-static void webusbPoll(){
-  static uint8_t buf[16]; static uint8_t n=0;
-  while(usb_web.available()){
-    int c=usb_web.read(); if(c<0) break;
-    if(n<sizeof buf) buf[n++]=(uint8_t)c;
-    // process complete commands from the front of buf
-    for(;;){
-      if(n==0) break;
-      uint8_t op=buf[0]; uint8_t need = (op==0x02)?3 : (op==0x03)?2 : (op==0x01)?1 : 1;
-      if(op!=0x01 && op!=0x02 && op!=0x03){ // resync: drop one byte
-        memmove(buf,buf+1,--n); continue;
-      }
-      if(n<need) break;                      // wait for more bytes
-      if(op==0x01){ webusbSendBlob(); }
-      else if(op==0x02){
-        uint8_t f=buf[1], v=buf[2];
-        bool persist=true;   // every settable field persists (poll rate is no longer settable)
-        switch(f){
-          case 1: g_mDiv = v<4?4:v; break;
-          case 2: g_mFric = v>99?99:v; break;
-          // case 3 (padSmooth) removed — Steam-mode pad coords are forwarded raw; Steam does its own smoothing.
-          case 4: g_abSwap = v?1:0; break;
-          case 5: case 6: case 7: case 8: g_back[f-5]=v; break;
-          // case 9 (pollU100) removed — poll rate is fixed at POLL_US_DEFAULT and no longer configurable.
-          case 10: g_e7b = v?1:0; break;                       // E7 protocol-version B-byte (experimental v1 fast)
-          case 11: g_relayOp = v; break;                       // haptic-relay opcode (buzz hunt)
-          case 12: g_relaySub = v; break;                      // haptic-relay sub-type (buzz hunt)
-          case 13: g_testHaptic = v?v:40; break;               // inject v test haptics (0->40)
-          case 14: g_fwdNewOnly = v?1:0; break;                // Steam: forward only fresh reports (dedupe)
-          case 15: g_qos = v?1:0; g_hopIdx=0; g_qosBad=0; g_qosCheckMs=millis(); break;  // QoS adaptive channel hopping
-          case 16: g_persistMode = v?true:false; break;   // persist last mode across reboots (else always boot Steam)
-          case 17: case 18: case 19: if(modeValid(v)) g_chordBtn[f-17]=v; break;   // back4+B/X/Y mode assignments
-        }
-        if(persist) saveCfg();
-        webusbSendBlob();
-      } else if(op==0x03){
-        uint8_t m=buf[1]; if(modeValid(m) && !USBDevice.suspended()){ webusbSendBlob(); usb_web.flush(); saveMode(m); delay(40); NVIC_SystemReset(); }
-      }
-      memmove(buf,buf+need,n-need); n-=need;
-    }
-  }
-}
-
-// CDC commands: l=listen, s=stop, cN=channel, p<hex>=prefix, a<8hex>=base addr, b=bonds
-static void rfSerialPoll(){
-  static char line[24]; static uint8_t li=0;
-  while (Serial.available()){
-    char c=Serial.read();
-    if (c=='\n'||c=='\r'){ line[li]=0;
-      if (line[0]=='l') rfListenStart();
-      else if (line[0]=='B'){ g_rfBeacon=!g_rfBeacon; g_rfListen=false; Serial.printf("# beacon %s ch%u\n",g_rfBeacon?"ON":"off",g_rfCh); }
-      else if (line[0]=='L'){ g_plen=strtol(line+1,0,16); Serial.printf("# plen=%02X\n",g_plen); }
-      else if (line[0]=='1'){ g_s1incl=!g_s1incl; Serial.printf("# s1incl=%u\n",g_s1incl); }
-      else if (line[0]=='R'){ g_rfCh=atoi(line+1); g_rfRaw=true; g_rfListen=false; g_rfBeacon=false; rfRawStart(g_rfCh); Serial.printf("# raw cap ch%u\n",g_rfCh); }
-      else if (line[0]=='Z'){ g_rfRaw=true; g_rfSweep=true; g_rfListen=false; g_rfBeacon=false; g_rfCh=2; rfRawStart(g_rfCh); Serial.println("# raw SWEEP"); }
-      else if (line[0]=='H'){ g_rfHost=!g_rfHost; g_rfBeacon=false; g_rfListen=false; g_rfRaw=false; g_rfSweep=false; g_rfCh=2;
-        Serial.printf("# HOSTFRAME beacon %s base %02X%02X%02X%02X (slot0 uuids %02X%02X%02X%02X/%02X%02X%02X%02X)\n",
-          g_rfHost?"ON":"off",g_rfBase[0],g_rfBase[1],g_rfBase[2],g_rfBase[3],
-          g_slot[0].rec[0],g_slot[0].rec[1],g_slot[0].rec[2],g_slot[0].rec[3],
-          g_slot[0].rec[4],g_slot[0].rec[5],g_slot[0].rec[6],g_slot[0].rec[7]); }
-      else if (line[0]=='C'){ g_rfBeacon=g_rfListen=g_rfRaw=g_rfSweep=g_rfHost=false; rfRespondStart(); }
-      else if (line[0]=='s'){ g_rfListen=false; g_rfBeacon=false; g_rfRaw=false; g_rfSweep=false; g_rfHost=false; g_rfRespond=false; NRF_RADIO->TASKS_DISABLE=1; Serial.println("# RF off"); }
-      else if (line[0]=='x'){ uint8_t m=strtoul(line+1,0,10); if(modeValid(m)){
-        if(USBDevice.suspended()){ Serial.println("# mode change blocked: host suspended"); }
-        else { Serial.printf("# switch mode %u (reboot)\n",m); delay(20); saveMode(m); delay(40); NVIC_SystemReset(); }
-      } }   // switch USB mode: 0=steam 1=xbox360 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro 7=xboxone
-      else if (line[0]=='c'){ g_rfCh=atoi(line+1); Serial.printf("# ch=%u\n",g_rfCh); if(g_rfListen) rfListenStart(); }
-      else if (line[0]=='p'){ g_rfPrefix=strtol(line+1,0,16); Serial.printf("# prefix=%02X\n",g_rfPrefix); if(g_rfListen) rfListenStart(); }
-      else if (line[0]=='i'){ g_crcinit=strtoul(line+1,0,16); Serial.printf("# crcinit=%06lX\n",(unsigned long)g_crcinit); }
-      else if (line[0]=='w'){ g_whiteiv=atoi(line+1); Serial.printf("# whiteiv=%u\n",g_whiteiv); }
-      else if (line[0]=='0'){ g_pcnf0=strtoul(line+1,0,16); Serial.printf("# pcnf0=%08lX\n",(unsigned long)g_pcnf0); }
-      else if (line[0]=='2'){ g_pcnf1=strtoul(line+1,0,16); Serial.printf("# pcnf1=%08lX\n",(unsigned long)g_pcnf1); }
-      else if (line[0]=='M'){ g_mode=strtoul(line+1,0,16); Serial.printf("# mode=%u (3=Ble1M,4=Ble2M,0/2=Nrf)\n",g_mode); }
-      else if (line[0]=='I'){ g_crcinit=strtoul(line+1,0,16); Serial.printf("# crcinit=%08lX\n",(unsigned long)g_crcinit); }
-      else if (line[0]=='P'){ g_crcpoly=strtoul(line+1,0,16); Serial.printf("# crcpoly=%lX\n",(unsigned long)g_crcpoly); }
-      else if (line[0]=='N'){ g_crccnf=strtoul(line+1,0,16); Serial.printf("# crccnf=%X\n",g_crccnf); }
-      else if (line[0]=='Y'){ g_rfCap=true; g_rfCapOne=true; g_rfReplay=g_rfHost=g_rfAuto=false; g_rfCh=2; rfCapStart(2); Serial.println("# capture ONE frame for replay..."); }
-      else if (line[0]=='y'){ g_rfReplay=!g_rfReplay; g_rfCap=g_rfHost=g_rfAuto=false; Serial.printf("# REPLAY %s (%uB)\n",g_rfReplay?"ON":"off",g_replayLen); }
-      else if (line[0]=='T'){ g_statlen=strtoul(line+1,0,16); g_pcnf1=0; Serial.printf("# statlen=%02X\n",g_statlen); }
-      else if (line[0]=='k'){ g_connOn=!g_connOn; if(g_connOn){ g_connSt=0; g_connStep=0; g_connPoll=0; g_connF1=0; } Serial.printf("# CONN mode %s: E7-awake[00 00] -> E3+GET-report-0x45 poll on ch%u (param=%02X). F1 seen=%lu\n",g_connOn?"ON":"off",g_rfCh,g_getParam,(unsigned long)g_connF1); }
-      else if (line[0]=='q'){ g_getParam = g_getParam ? 0x00 : 0x2D; Serial.printf("# GET-0x45 param=%02X\n",g_getParam); }
-      else if (line[0]=='v'){ g_connVerbose=!g_connVerbose; Serial.printf("# conn verbose %s (F1 seen=%lu)\n",g_connVerbose?"ON":"off",(unsigned long)g_connF1); }
-      else if (line[0]=='C'){ g_sessCh=strtoul(line+1,0,10); Serial.printf("# session channel=%u (re-pair/reconnect to apply)\n",g_sessCh); }
-      else if (line[0]=='E'){ g_mDiv=strtoul(line+1,0,10); if(g_mDiv<4)g_mDiv=4; saveCfg(); Serial.printf("# xbox-mouse sensitivity divisor=%d (lower=faster)\n",g_mDiv); }
-      else if (line[0]=='F'){ g_mFric=strtoul(line+1,0,10); if(g_mFric>99)g_mFric=99; saveCfg(); Serial.printf("# xbox-mouse friction=%d%% (higher=more glide/momentum)\n",g_mFric); }
-      else if (line[0]=='W'){ g_abSwap=!g_abSwap; saveCfg(); Serial.printf("# A/B + X/Y swap %s (Nintendo layout)\n",g_abSwap?"ON":"off"); }
-      else if (line[0]=='K'){ int i=line[1]-'0'; uint8_t code=strtoul(line+2,0,10); if(i>=0&&i<4){ g_back[i]=code; saveCfg(); Serial.printf("# back[%d] (%s) -> code %u  [0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide]\n",i,(const char*[]){"L4","R4","L5","R5"}[i],code); } else Serial.println("# usage: K<0-3> <code>  (0=L4 1=R4 2=L5 3=R5)"); }
-      else if (line[0]=='J'){ char* sp=0; uint8_t id=strtoul(line+1,&sp,0); uint16_t val=sp?strtoul(sp,0,0):0;  // inject SET-SETTINGS to controller: report 0x87 [id][val u16 LE]
-        g_relayBuf[0]=0x87; g_relayBuf[1]=3; g_relayBuf[2]=id; g_relayBuf[3]=val&0xFF; g_relayBuf[4]=val>>8; g_relayN=5; g_relayPend=true;
-        Serial.printf("# queued SET-SETTINGS id=0x%02X val=%u (relay 0x87) — watch new=/s\n",id,val); }
-      else if (line[0]=='G'){ g_sniff=!g_sniff; g_sniffPh=0; g_rfHost=g_connOn=g_rfCap=g_rfAuto=g_rfReplay=false; if(g_sniff) rfSniffStart(); Serial.printf("# SESSION SNIFF %s (phase0: catch host frame on ibex/ch2; phase1 PARK ch=%s)\n",g_sniff?"ON":"off",g_sniffPark?"override":"primary"); }
-      else if (line[0]=='g'){ g_sniffPark=strtoul(line+1,0,10); if(g_sniffPh==1) rfSniffStart(); Serial.printf("# sniff park channel=%u (0=learned primary)\n",g_sniffPark); }
-      else if (line[0]=='V'){ g_e7b=strtoul(line+1,0,10); Serial.printf("# E7 protocol-version B-byte=%u (0=awake/slow, 1=try v1-fast; watch new=/s; revert if input stops)\n",g_e7b); }
-      else if (line[0]=='O'){ g_relayOp=strtoul(line+1,0,16); Serial.printf("# relay opcode=%02X\n",g_relayOp); }
-      else if (line[0]=='o'){ g_relaySub=strtoul(line+1,0,16); Serial.printf("# relay sub-type=%02X\n",g_relaySub); }
-      else if (line[0]=='h'){ uint8_t nc=strtoul(line+1,0,10); Serial.printf("# HOP %u->%u (advertise on current ch, then poll new). Watch F1=/s\n",g_sessCh,nc); rfHopTo(nc); }
-      else if (line[0]=='z'){ g_qos=!g_qos; g_hopIdx=0; g_qosBad=0; g_qosCheckMs=millis(); Serial.printf("# QoS adaptive channel hopping %s (candidates 18,46,76,22,68)\n",g_qos?"ON":"off"); }
-      else if (line[0]=='r'){ g_rxWin=strtoul(line+1,0,10); if(g_rxWin<150)g_rxWin=150; Serial.printf("# poll RX-window=%lu us (poll rate caps ~%lu/s)\n",(unsigned long)g_rxWin,(unsigned long)(1000000/g_rxWin)); }
-      else if (line[0]=='e'){ g_e3mode=strtoul(line+1,0,10); Serial.printf("# E3 poll PID mode=%u (0=fixed07, 1=cyclePID+noack1, 2=cyclePID+noack0) - watch new=/s\n",g_e3mode); }
-      else if (line[0]=='t'){ uint8_t n=line[1]?strtoul(line+1,0,10):40;     // inject n test haptics (output 0x82 [01 01 F7]) over the relay
-        g_testHaptic=n; Serial.printf("# test-haptic burst x%u queued (relay 0x82 01 01 F7 via op=%02X sub=%02X)\n",n,g_relayOp,g_relaySub); }
-      else if (line[0]=='H'){   // dump the captured OUTPUT-report history (oldest->newest) for the haptic-whine hunt
-        const uint8_t N=sizeof g_hapLog/sizeof g_hapLog[0]; uint32_t now=millis();
-        Serial.printf("# --- haptic/OUTPUT history (now=%lu, connSlot=%d) ---\n",(unsigned long)now,g_connSlot);
-        for(uint8_t i=0;i<N;i++){ HapLog &e=g_hapLog[(g_hapHead+i)%N]; if(!e.ms && !e.rid) continue;
-          Serial.printf("# -%lums if%u rid=%02X n=%u:",(unsigned long)(now-e.ms),e.slot,e.rid,e.n);
-          for(uint8_t j=0;j<12 && j<e.n;j++) Serial.printf(" %02X",e.b[j]); Serial.println(); }
-        Serial.println("# --- end ---"); }
-      else if (line[0]=='j'){ g_connType=strtoul(line+1,0,16); Serial.printf("# connType=%02X\n",g_connType); }
-      else if (line[0]=='Q'){ g_connLen=strtoul(line+1,0,16); Serial.printf("# connLen=%02X\n",g_connLen); }
-      else if (line[0]=='A'){ g_balen=strtoul(line+1,0,16); g_pcnf1=0; Serial.printf("# balen=%u\n",g_balen); }
-      else if (line[0]=='a'){ uint32_t v=strtoul(line+1,0,16); g_rfBase[0]=v>>24;g_rfBase[1]=v>>16;g_rfBase[2]=v>>8;g_rfBase[3]=v; Serial.printf("# base=%08lX\n",(unsigned long)v); if(g_rfListen) rfListenStart(); }
-      else if (line[0]=='b'){ for(int i=0;i<NSLOT;i++) if(g_slot[i].used){Serial.printf("slot%d ",i); for(int j=0;j<24;j++)Serial.printf("%02X",g_slot[i].rec[j]); Serial.println();} }
-      else if (line[0]=='X'){ g_rfCap=!g_rfCap; g_rfHost=g_rfAuto=g_rfBeacon=g_rfListen=g_rfRaw=g_rfSweep=g_rfRespond=false; g_rfCh=2; g_capV=0; if(g_rfCap) rfCapStart(g_rfCh); Serial.printf("# CAPTURE(listen) %s ch%u on \"ibex\", cycling 1M/2M x BALEN3/4 (use cN to set ch)\n", g_rfCap?"ON":"off",g_rfCh); }
-      else if (line[0]=='S'){ g_rfAuto=!g_rfAuto; g_rfHost=g_rfAuto; g_rfBeacon=g_rfListen=g_rfRaw=g_rfSweep=false; g_rfCh=2; g_cfgIdx=0; if(g_rfAuto) applyCfg(0); Serial.printf("# AUTOSWEEP %s (%u cfgs x300ms)\n", g_rfAuto?"ON":"off",(unsigned)(sizeof SWEEP/sizeof SWEEP[0])); }
-      li=0;
-    } else if (li<sizeof line-1) line[li++]=c;
-  }
-}
 
 void setup() {
   genSerial();
+  ledInit();
+  rfGenSessionAddr();   // per-device unique RF session address (advertised in the host frame; isolates pucks)
   InternalFS.begin();
   loadCfg(); g_xbox = !modeIsPuck(g_usbMode);   // load persisted config + decide USB presentation BEFORE registering interfaces
+  g_active = controllerFor(g_usbMode);
 
   // ---- USB descriptor rebuild ----
-  // Puck (Steam/Lizard): KEEP the boot CDC composite (do NOT clearConfiguration) and append 4 HID + WebUSB — this
-  // is the layout that worked with Steam running + Chrome WebUSB at the same time. Clearing puck to "save space"
-  // broke that coexistence. Clean controller modes still clear the CDC composite and rebuild bare.
-  const bool puckMode = modeIsPuck(g_usbMode);
+  // Puck mode NORMALLY drops the CDC serial console (clearConfiguration) to free a USB endpoint for the
+  // wake-mouse interface that lets it wake a sleeping Windows host. The one-shot debug arm (g_debugCdcThisBoot,
+  // set via the WebUSB panel / CDC 'D', auto-reverting after one boot) instead KEEPS the boot CDC composite
+  // for that boot and skips the wake mouse (no endpoint room for both). Clean modes always rebuild bare.
+  const bool puckMode = g_active->isPuck();
+  const bool keepCdc  = puckMode && g_debugCdcThisBoot;
   USBDevice.detach(); delay(30);
-  if (puckMode) {
-    USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);   // headroom over the default 256 B cap
+  if (keepCdc) {
+    USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);   // keep boot CDC composite (debug boot)
   } else {
     USBDevice.clearConfiguration();
-    USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);
+    USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);   // headroom over the default 256 B cap
   }
 
   // Distinct USB serial PER MODE (must be set AFTER clearConfiguration, which nulls it). Hosts cache USB
   // identity by VID:PID:serial; reusing one serial under a changing VID:PID can make a host refuse the new
   // identity. Steam keeps the exact unit serial (its pairing identity); the others get a 1-char suffix.
   static const char MODE_SUFFIX[] = {'X','N','L','P','S','G','O'};   // modes 1..7
-  if (modeIsPuck(g_usbMode)) { USBDevice.setSerialDescriptor(g_unit); }
+  if (puckMode) { USBDevice.setSerialDescriptor(g_unit); }
   else { snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c", g_unit, MODE_SUFFIX[g_usbMode-1]); USBDevice.setSerialDescriptor(g_usbSerial); }
-  if (modeIsXbox(g_usbMode)) {         // XBOX presentation: XInput vendor interface + HID boot mouse
-    if (g_usbMode == MODE_XBOX_ONE) {
-      USBDevice.setID(0x045E, 0x02D1);   // Xbox One (original) identity
-      USBDevice.setDeviceVersion(0x0500);
-      USBDevice.setProductDescriptor("Xbox One Controller");
-    } else {
-      USBDevice.setID(0x045E, 0x028E);   // Xbox 360 wired identity
-      USBDevice.setDeviceVersion(0x0114);
-      USBDevice.setProductDescriptor("Controller");
-    }
-    USBDevice.setManufacturerDescriptor("Microsoft");
-    g_xinput.setStringDescriptor("Controller"); g_xinput.begin();   // XInput vendor interface (FF/5D/01) -> MI_00
-    g_mouse.setStringDescriptor("OpenPuck Mouse");
-    g_mouse.setBootProtocol(HID_ITF_PROTOCOL_MOUSE);
-    g_mouse.setReportDescriptor(MOUSE_HID_DESC, sizeof MOUSE_HID_DESC); g_mouse.setPollInterval(1); g_mouse.begin();
-  } else if (g_usbMode == MODE_SW_HORI) {   // SWITCH HORIPAD (console whitelist; composite w/ WebUSB won't pair on-console)
-    USBDevice.setID(0x0F0D, 0x0092);
-    USBDevice.setDeviceVersion(0x0200);
-    USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
-    USBDevice.setProductDescriptor("POKKEN CONTROLLER");
-    g_switch.enableOutEndpoint(true);
-    g_switch.setReportDescriptor(SWITCH_HID_DESC, sizeof SWITCH_HID_DESC);
-    g_switch.setPollInterval(8);
-    g_switch.begin();
-  } else if (g_usbMode == MODE_SW_PRO) {    // Switch Pro Controller + gyro (057E:2009, report 0x30)
-    USBDevice.setID(0x057E, 0x2009);
-    USBDevice.setDeviceVersion(0x0210);
-    USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
-    USBDevice.setProductDescriptor("Pro Controller");
-    jcBuildStickCal();
-    g_swPro.enableOutEndpoint(true);
-    g_swPro.setReportCallback(NULL, jcSet);   // answer the Nintendo USB handshake + subcommands (else Steam never binds it)
-    g_swPro.setReportDescriptor(SWPRO_HID_DESC, sizeof SWPRO_HID_DESC);
-    g_swPro.setPollInterval(4);
-    g_swPro.begin();
-  } else if (g_usbMode == MODE_PS5) {       // DualSense + gyro + trackpad
-    USBDevice.setID(0x054C, 0x0CE6);
-    USBDevice.setDeviceVersion(0x0100);
-    USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
-    USBDevice.setProductDescriptor("DualSense Wireless Controller");
-    g_ps5.enableOutEndpoint(true);
-    g_ps5.setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
-    g_ps5.setPollInterval(4);
-    g_ps5.begin();
-  } else if (g_usbMode == MODE_HIDGYRO) {   // DS4-layout generic HID + gyro
-    USBDevice.setID(0x054C, 0x05C4);
-    USBDevice.setDeviceVersion(0x0100);
-    USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
-    USBDevice.setProductDescriptor("Wireless Controller");
-    g_hidGyro.enableOutEndpoint(true);
-    g_hidGyro.setReportDescriptor(GYRO_HID_DESC, sizeof GYRO_HID_DESC);
-    g_hidGyro.setPollInterval(4);
-    g_hidGyro.begin();
-  } else {                             // STEAM / LIZARD: 4 puck slot interfaces (+ WebUSB)
-    USBDevice.setID(0x28DE, 0x1304);
-    // Distinct bcdDevice so Windows keys a FRESH usbflags entry (cache is VID:PID:bcdDevice) and actually
-    // runs MS OS 2.0 / WinUSB binding for the WebUSB vendor interface — instead of reusing a stale "no WinUSB"
-    // entry tied to the real Steam Controller (28DE:1304), which has no WebUSB interface. Without this, the
-    // device enumerates and Steam binds it fine, but Chrome can't open WebUSB on Windows. Every other mode
-    // already sets a version (and has its own VID:PID), which is why only puck mode was affected.
-    USBDevice.setDeviceVersion(0x0210);
-    USBDevice.setManufacturerDescriptor("Valve Software");
-    USBDevice.setProductDescriptor("Steam Controller Puck");
-    for (int i = 0; i < NSLOT; i++) {
-      hid[i].setReportDescriptor(PUCK_HID_DESC, sizeof PUCK_HID_DESC);
-      hid[i].setReportCallback(GETCB[i], SETCB[i]);
-      hid[i].setPollInterval(1);       // 1ms USB poll (was default 10ms = 100/s cap -> choppy)
-      hid[i].begin();
-    }
-  }
-  // WebUSB config panel — every mode. Puck: historical CDC+HID+vendor stack (Steam + Chrome can share it).
+
+  g_active->begin();   // register this mode's USB interface(s) + set VID/PID/strings
+
+  // Boot-mouse wake interface so the host honors USBDevice.remoteWakeup() in this mode (see wake_hid.h).
+  // Added for every clean mode, and for puck mode on a normal (CDC-dropped) boot. Skipped only on the one-shot
+  // debug boot, where CDC takes the endpoint instead.
+  if (!keepCdc) wakeHidBegin();
+
+  // WebUSB config panel -- every mode. Puck: historical CDC+HID+vendor stack (Steam + Chrome can share it).
   // Clean modes: bare gamepad + WebUSB (begin() sets bcdUSB 0x0210 + BOS).
   usb_web.begin();
   // Enable USB Remote Wakeup (bit 5) so the host lets us signal wake-from-sleep. Bit 7 is always required.
-  // The host OS enables device wakeup via SET_FEATURE(DEVICE_REMOTE_WAKEUP); we assert the capability here.
   USBDevice.setConfigurationAttribute(0x80 | 0x20);  // bmAttributes: required(0x80) | remote_wakeup(0x20)
   USBDevice.attach();   // re-connect with the final descriptor (host re-reads it fresh -> deterministic enumeration)
   Serial.begin(115200);
-  for (int i=0; i<300 && !USBDevice.mounted(); i++) delay(10);   // wait up to 3s for USB mount, but NEVER hang:
-  if (USBDevice.suspended()) USBDevice.remoteWakeup();             // wake host if bus was sleeping when we (re-)attached (e.g. after a mode change)
+  for (int i=0; i<300 && !USBDevice.mounted(); i++) delay(10);   // wait up to 3s for USB mount, but NEVER hang
+  if (USBDevice.suspended()){ USBDevice.remoteWakeup(); ledWakePulse(); }   // wake host if bus was sleeping when we (re-)attached
   loadBonds();
-  g_rumble=0; g_relayPend=false; g_haptic82On=false;
-  g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;   // boot: block stale Steam 0x82 until link stable
-  g_hapticStop = HAPTIC_STOP_BURST;
+  hapticInit();   // clear relay/active flags + arm the reconnect block & initial stop burst
   static const char* MODE_NAME[]={"STEAM(puck)","XBOX360(xinput+mouse)","SWITCH(horipad)","LIZARD(puck kb/mouse)","SWITCH(pro+gyro)","PS5(dualsense)","HIDGYRO(ds4+motion)","XBOXONE(xinput+mouse)"};
   Serial.printf("# copycat up: unit=%s board=%s, mode=%s\n", g_unit, g_board, MODE_NAME[g_usbMode<=MODE_MAX?g_usbMode:0]);
+  if (puckMode) Serial.printf("# puck USB: %s\n", keepCdc ? "DEBUG boot (CDC console on, wake mouse off; reverts next boot)" : "normal (CDC off, wake mouse on)");
+  Serial.printf("# session addr %02X%02X%02X%02X/%02X ch%u (discovery on ibex/ch2)\n",
+                g_sessBase[0],g_sessBase[1],g_sessBase[2],g_sessBase[3],g_sessPrefix,g_sessCh);
   // Hardware watchdog: if loop() ever stops feeding it (a wedged radio busy-wait, a HardFault spin, a blocked
-  // CDC write) the WDT resets the nRF52 after ~8s — re-enumerating USB and re-initialising RF on its own, so a
+  // CDC write) the WDT resets the nRF52 after ~8s -- re-enumerating USB and re-initialising RF on its own, so a
   // hang no longer needs a physical replug. RUN keeps it counting in sleep; PAUSE freezes it under a debugger.
-  // 8s is far above any legitimate stall (flash writes are tens of ms) and leaves the bootloader room on DFU.
   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;     // timeout in 32.768 kHz ticks (~8 s)
   NRF_WDT->RREN    = WDT_RREN_RR0_Msk;       // arm reload register 0
   NRF_WDT->TASKS_START = 1;
 }
 
+// loop-timing diagnostics: the poll rate is capped by the loop ITERATION time (the pacing wants 4000us but a
+// slow loop fires the poll only as often as it comes around). Per-section us is accumulated and, each second,
+// the average loop period + the slowest section are published for the WebUSB panel to pinpoint the culprit.
+uint16_t g_loopPeriodUs = 0;   // avg loop iteration time last second (1e6/iterations)
+uint8_t  g_loopWorst    = 0;   // index of the slowest section: 0=webusb 1=ctrl.task 2=serial 3=rfdiag 4=rflink 5=haptic 6=led
+uint16_t g_loopWorstUs  = 0;   // that section's avg us per iteration
 void loop() {
   NRF_WDT->RR[0] = WDT_RR_RR_Reload;   // feed the watchdog each loop; if we ever stop, the ~8s WDT auto-resets us
   if (g_dirty) { g_dirty = false; saveBonds(); }
+#if OPK_LOG
+  // Diagnostic build: time each loop section so the panel can show the slowest one + the loop period.
+  static uint32_t acc[7] = {0}; static uint32_t loops = 0; static unsigned long secMs = 0;
+  uint32_t t;
+  t=micros(); webusbPoll();                   acc[0]+=(uint32_t)(micros()-t);
+  t=micros(); if (g_active) g_active->task(); acc[1]+=(uint32_t)(micros()-t);
+  t=micros(); serialConsolePoll();            acc[2]+=(uint32_t)(micros()-t);
+  t=micros(); rfDiagTask();                   acc[3]+=(uint32_t)(micros()-t);
+  t=micros(); rfLinkTask();                   acc[4]+=(uint32_t)(micros()-t);
+  t=micros(); hapticTask();                   acc[5]+=(uint32_t)(micros()-t);
+  t=micros(); ledTask();                      acc[6]+=(uint32_t)(micros()-t);
+  loops++;
+  if (millis()-secMs >= 1000) {
+    g_loopPeriodUs = loops ? (uint16_t)(1000000UL/loops) : 0;
+    uint8_t wi=0; uint32_t wv=0;
+    for (int i=0;i<7;i++){ if(acc[i]>wv){ wv=acc[i]; wi=i; } acc[i]=0; }
+    g_loopWorst = wi; g_loopWorstUs = loops ? (uint16_t)(wv/loops) : 0;
+    loops=0; secMs=millis();
+  }
+#else
   webusbPoll();
-  swStream();
-  swProTask();
-  ps5Stream();
-  hidGyroStream();
-  rfSerialPoll();
-  rfPoll();
-  rfRespondPoll();
-  rfCapPoll();
-  if (g_rfCap && millis() - g_lastHop >= 90) {    // sweep DATAWHITEIV (0..127) x BALEN{3,4} on ch2, CRC-validate
-    g_lastHop = millis(); g_capV = (uint8_t)(g_capV+1); rfCapStart(g_rfCh);
-  }
-  if (g_rfBeacon && millis() - g_lastBeacon >= 5) { g_lastBeacon = millis(); rfBeaconOnce(); }
-  if (g_rfSweep && millis() - g_lastHop >= 60) { g_lastHop = millis(); g_rfCh += 2; if (g_rfCh > 80) g_rfCh = 2; rfRawStart(g_rfCh); }
-  if (g_rfAuto && millis() - g_lastCfg >= 500) {   // advance to next candidate config every 500ms
-    g_lastCfg = millis(); g_cfgIdx = (g_cfgIdx + 1) % (sizeof SWEEP/sizeof SWEEP[0]); applyCfg(g_cfgIdx);
-  }
-  if (g_rfReplay) { rfReplayOnce(); }   // bit-perfect replay of a captured real-puck frame on ch2
-  // Host-frame beacon: sent continuously, INCLUDING while connected. The controller uses the periodic E1
-  // (the real puck's per-hop-cycle announce) to stay synced and keep answering polls at full rate;
-  // suppressing it drops the reply rate from ~210/s to ~38/s. Paused only during the post-disconnect
-  // cooldown so a controller that's powering off isn't immediately re-woken/reconnected.
-  if (g_rfHost && millis()-g_connCooldown > 2500) {
-    bool connNow = (g_connSlot>=0 && millis()-g_connReplyMs < 300);
-    // session keepalive on the clean channel: every loop while connecting (fast), every 25ms once connected
-    // (every-loop beaconing also hammers ch52 and steals reply slots from the poll)
-    if (millis()-g_lastSessBeacon >= (connNow ? 25u : 0u)) { g_lastSessBeacon=millis(); g_rfCh=g_sessCh; for (int s=0;s<NSLOT;s++) rfHostFrameOnce(s); }
-    // discovery beacon on ch2 (where a searching controller looks): every loop when down, occasionally when up
-    if (millis()-g_lastDisc >= (connNow ? 200u : 0u)) { g_lastDisc=millis(); g_rfCh=2; for (int s=0;s<NSLOT;s++) rfHostFrameOnce(s); }
-  }
-  if (g_connOn && millis()-g_connCooldown > 2500) { rfConnStep(); }            // connected-mode: poll controller, read input
-  { static bool wasRfConn=false;                                               // remote wakeup on new RF controller connection
-    bool nowRfConn=(g_connSlot>=0 && millis()-g_connReplyMs<300);
-    if(nowRfConn && !wasRfConn && USBDevice.suspended()) USBDevice.remoteWakeup();
-    wasRfConn=nowRfConn;
-  }
-  { static bool wasHapticLinkUp=false;
-    bool up=hapticLinkUp();
-    if(up && !wasHapticLinkUp){
-      g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;
-      g_haptic82On = false;
-      hapticCancelPendingOn();
-      g_hapticStop = HAPTIC_STOP_BURST;
-    }
-    wasHapticLinkUp=up;
-  }
-  // Legacy XInput rumble -> relay the haptic (0x82 [01 01 gain]) to the controller while the host commands it,
-  // re-queued ~40/s like Steam's glide haptic so it sustains. Not active in the current HID Xbox presentation.
-  if (modeIsXbox(g_usbMode) && g_rumble && millis()-g_rumbleMs > RUMBLE_STUCK_MS) g_rumble=0;
-  if (modeIsXbox(g_usbMode) && g_rumble && !g_relayPend && g_connSlot>=0) {
-    static unsigned long lastRumble=0;
-    if (millis()-lastRumble>=25) { lastRumble=millis();
-      uint8_t gain = g_rumble<0x30?0x30:g_rumble;   // floor so low rumble is still feelable
-      g_relayBuf[0]=0x82; g_relayBuf[1]=3; g_relayBuf[2]=0x01; g_relayBuf[3]=0x01; g_relayBuf[4]=gain; g_relayPend=true;
-    }
-  }
-  // Steam-mode haptic guard: do not synthesize haptic packets. The reconnect guard above only drops stale
-  // host 0x82 traffic until the link is stable; active-session haptics are relayed verbatim.
-  if (!g_xbox && g_haptic82On && millis()-g_haptic82Ms > HAPTIC_QUIET_MS) g_haptic82On=false;  // host went quiet -> mark the 0x82 stream inactive. Do NOT synthesize a stop: trackpad haptics are one-shot pulses, so firing a 0x82-zero ~HAPTIC_QUIET_MS after a swipe ends is the extra end-of-movement click the real puck doesn't make. Steam forwards its own stop for any sustained haptic.
-  // QoS: if the current channel is degrading (crcfail+noRx), hop to the next clean candidate (conservative).
-  if (g_qos && g_connSlot>=0 && millis()-g_qosCheckMs>=600) {
-    uint16_t bad=g_qosBad; g_qosBad=0; g_qosCheckMs=millis();
-    if (bad>20 && millis()-g_qosLastHopMs>2000) {
-      for(int k=0;k<(int)sizeof g_hopCand;k++){ g_hopIdx=(g_hopIdx+1)%(sizeof g_hopCand); if(g_hopCand[g_hopIdx]!=g_sessCh) break; }
-      if(Serial.availableForWrite()>60) Serial.printf("# QoS: ch%u bad=%u -> hop ch%u\n",g_sessCh,bad,g_hopCand[g_hopIdx]);
-      rfHopTo(g_hopCand[g_hopIdx]); g_qosLastHopMs=millis();
-    }
-  }
-  // USB connection presentation (like the real dongle): report 0x79 = connection state (01=disc, 02=conn),
-  // edge-triggered, + periodic 0x7B status. Live-captured: this is what Steam reads to mark the controller
-  // connected. Without it Steam shows disconnected even though 0x45 input is streaming.
-  if(!g_xbox){ static bool usbConn=false; static unsigned long last79=0, last7B=0;   // puck-only USB presentation
-    bool conn = (g_connSlot>=0 && millis()-g_connReplyMs < 300);
-    if (g_connSlot>=0 && g_connSlot<NSLOT && hid[g_connSlot].ready()){
-      // 0x79 connection state: on edge AND repeated every 750ms while connected (Steam can miss the edge,
-      // and the 0x45 stream can starve the endpoint at the transition instant -> resend until it sticks).
-      if (conn!=usbConn || (conn && millis()-last79>=750)){
-        uint8_t st=conn?0x02:0x01; hid[g_connSlot].sendReport(0x79,&st,1); usbConn=conn; last79=millis();
-      } else if (conn && millis()-last7B>=2000){
-        static const uint8_t s7b[12]={0xF7,0x01,0x89,0x00,0x00,0x00,0x03,0x00,0xDD,0x00,0x3A,0x02};
-        hid[g_connSlot].sendReport(0x7B,s7b,12); last7B=millis();
-      }
-    } else if(!conn) usbConn=false;
-  }
-  if (g_connOn && millis()-g_stMs>=1000){ g_f1ps=g_stF1; g_newps=g_stNew; if(Serial.availableForWrite()>70) Serial.printf("# stat polls=%lu/s F1=%lu/s new=%lu/s F3=%lu/s(v%d) e7b=%u crcfail=%lu noRx=%lu slot=%d\n",(unsigned long)g_stPoll,(unsigned long)g_stF1,(unsigned long)g_stNew,(unsigned long)g_stF3,(int8_t)g_connF3v,g_e7b,(unsigned long)g_stCrc,(unsigned long)g_stNoRx,g_connSlot); g_stPoll=0; g_stF1=0; g_stNew=0; g_stF3=0; g_stCrc=0; g_stNoRx=0; g_chF1[0]=g_chF1[1]=g_chF1[2]=0; g_stMs=millis(); }
-  rfSniffPoll();
-  // phase-1 PARKS (no hop) on the primary/override channel — re-arm RX only if it dropped (handled in rfSniffPoll via SHORTS END_START)
-  if (g_rfRespond && millis() - g_lastHop >= 25) {          // scan the 3 BLE adv channels w/ matched whitening
-    g_lastHop = millis();
-    static const uint8_t advf[3]={2,26,80}, advw[3]={37,38,39}; static uint8_t ai=0; ai=(ai+1)%3;
-    g_rfCh=advf[ai]; g_whiteiv=advw[ai];
-    rfConfig(g_rfCh); rfSetAddr(g_rfBase,g_rfPrefix); NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
-    NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk; NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
-  }
+  if (g_active) g_active->task();
+  serialConsolePoll();
+  rfDiagTask();
+  rfLinkTask();
+  hapticTask();
+  ledTask();
+#endif
 }

+ 30 - 0
OpenPuck/bonds.cpp

@@ -0,0 +1,30 @@
+#include "bonds.h"
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
+using namespace Adafruit_LittleFS_Namespace;
+
+Slot g_slot[NSLOT];
+int           g_connSlot = -1;
+unsigned long g_connReplyMs = 0;
+volatile bool g_dirty = false;
+bool          g_pairing = false;
+
+#define BOND_FILE "/bonds.bin"
+
+bool recEmpty(const uint8_t *r) { for (int i = 0; i < 24; i++) if (r[i]) return false; return true; }
+
+void saveBonds() {
+  InternalFS.remove(BOND_FILE);
+  File f(InternalFS);
+  if (f.open(BOND_FILE, FILE_O_WRITE)) {
+    for (int i = 0; i < NSLOT; i++) { uint8_t u = g_slot[i].used ? 1 : 0; f.write(&u, 1); f.write(g_slot[i].rec, 24); }
+    f.close();
+  }
+}
+
+void loadBonds() {
+  File f(InternalFS);
+  if (f.open(BOND_FILE, FILE_O_READ))
+    for (int i = 0; i < NSLOT; i++) { uint8_t u = 0; if (f.read(&u, 1) == 1) { g_slot[i].used = u; f.read(g_slot[i].rec, 24); } }
+  f.close();
+}

+ 24 - 0
OpenPuck/bonds.h

@@ -0,0 +1,24 @@
+// bonds.h -- the puck's bond slots + live link state.
+//
+// The real puck holds four controller bonds and exposes one HID control interface per slot (interface N owns
+// slot N). We mirror that: g_slot[N] holds the bond record [8 uuid][16 serial] plus a staging buffer for that
+// interface's pending feature-report reply (filled by puck_hid.cpp's command channel). Bonds persist to
+// flash (bonds.bin). g_connSlot / g_connReplyMs are the live RF link state, shared here so the USB feature
+// handler can report per-slot connection status without depending on the RF layer.
+#pragma once
+#include <stdint.h>
+
+#define NSLOT 4
+
+// per-slot state (record = [8 uuid][16 serial]) + per-interface response staging
+struct Slot { uint8_t rec[24]; bool used; uint8_t resp[63]; uint16_t resp_len; };
+extern Slot g_slot[NSLOT];
+
+extern int           g_connSlot;     // bonded slot being polled (== the USB-connected slot); -1 = none
+extern unsigned long g_connReplyMs;  // millis of last RF reply (link-alive timestamp)
+extern volatile bool g_dirty;        // bonds changed -> flush to flash from loop()
+extern bool          g_pairing;
+
+bool recEmpty(const uint8_t *r);
+void loadBonds();
+void saveBonds();

+ 66 - 0
OpenPuck/config.cpp

@@ -0,0 +1,66 @@
+#include "config.h"
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
+using namespace Adafruit_LittleFS_Namespace;
+
+uint8_t g_usbMode = 0;
+bool    g_xbox = false;
+uint8_t g_chordBtn[3] = { MODE_LIZARD, MODE_XBOX, MODE_SW_HORI };   // back4+B/X/Y -> these modes (A always STEAM)
+bool    g_persistMode = false;
+uint8_t g_bootMode = 0xFF;
+
+bool          g_debugCdcThisBoot = false;
+static uint8_t g_debugCdc = 0;   // persisted one-shot arm, stored in Cfg.rsvd0 (1 = keep CDC for the next boot)
+
+int     g_mDiv = 64, g_mFric = 94;
+uint8_t g_abSwap = 0;
+uint8_t g_back[4] = {5,6,7,8};   // L4->LB R4->RB L5->L3 R5->R3 (see codeToXB)
+
+// poll rate is fixed. Faster than the controller can refresh wastes airtime; slower adds latency. Any rate
+// persisted by an older build is ignored and overwritten with the default on boot (see loadCfg).
+const uint32_t g_pollUs = POLL_US_DEFAULT;
+
+#define CFG_FILE "/cfg.bin"
+#define CFG_MAGIC 0xC7   // bumped (chordBtn[3]): old cfg ignored -> clean defaults on first boot
+struct Cfg { uint8_t magic, mode, mDiv, mFric, rsvd0, abSwap, back[4], pollU100, persistMode, bootMode, chordBtn[3]; };  // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
+
+void saveCfg(){
+  Cfg c={CFG_MAGIC,g_usbMode,(uint8_t)g_mDiv,(uint8_t)g_mFric,g_debugCdc,g_abSwap,
+         {g_back[0],g_back[1],g_back[2],g_back[3]},(uint8_t)(g_pollUs/100),(uint8_t)(g_persistMode?1:0),g_bootMode,
+         {g_chordBtn[0],g_chordBtn[1],g_chordBtn[2]}};
+  InternalFS.remove(CFG_FILE); File f(InternalFS);
+  if(f.open(CFG_FILE,FILE_O_WRITE)){ f.write((uint8_t*)&c,sizeof c); f.close(); }
+}
+
+void loadCfg(){
+  Cfg c; File f(InternalFS); bool consume=false;
+  if(f.open(CFG_FILE,FILE_O_READ)){
+    if(f.read((uint8_t*)&c,sizeof c)==(int)sizeof c && c.magic==CFG_MAGIC){
+      g_mDiv=c.mDiv?c.mDiv:64; g_mFric=c.mFric;
+      g_abSwap=c.abSwap; for(int i=0;i<4;i++) g_back[i]=c.back[i];
+      g_persistMode = c.persistMode?true:false;
+      // one-shot debug-CDC (Cfg.rsvd0): honor it for THIS boot, then consume so the next boot reverts to normal.
+      g_debugCdcThisBoot = c.rsvd0 ? true : false;
+      if(c.rsvd0){ g_debugCdc = 0; consume = true; }
+      // poll rate is fixed (g_pollUs const = POLL_US_DEFAULT). A stale rate from an older build is never
+      // applied; rewrite cfg so the persisted byte also matches the new default.
+      if(c.pollU100 != (uint8_t)(POLL_US_DEFAULT/100)) consume=true;
+      // boot-mode policy: a one-shot bootMode (set by an explicit switch when !persist) wins once and is then
+      // cleared; otherwise persist->last mode, else->Steam. (poll rate stays POLL_US_DEFAULT -- never restored from cfg.)
+      if(c.bootMode!=0xFF){ g_usbMode=modeValid(c.bootMode)?c.bootMode:0; consume=true; }
+      else                 g_usbMode = g_persistMode ? (modeValid(c.mode)?c.mode:0) : 0;
+      static const uint8_t CHORD_DEF[3]={MODE_LIZARD,MODE_XBOX,MODE_SW_HORI};
+      for(int i=0;i<3;i++) g_chordBtn[i]=modeValid(c.chordBtn[i])?c.chordBtn[i]:CHORD_DEF[i];
+    }
+    f.close();
+  }
+  if(consume){ g_bootMode=0xFF; saveCfg(); }   // clear the one-shot so the NEXT cold boot reverts to the default/persist policy
+}
+
+void saveMode(uint8_t m){
+  if(g_persistMode){ g_usbMode=m; g_bootMode=0xFF; }
+  else             { g_bootMode=m; }
+  saveCfg();
+}
+
+void armDebugCdcNextBoot(){ g_debugCdc = 1; saveCfg(); }   // next boot keeps CDC; loadCfg() consumes it after

+ 72 - 0
OpenPuck/config.h

@@ -0,0 +1,72 @@
+// config.h -- USB-presentation modes + persisted, runtime-tunable settings.
+//
+// One puck speaks the SAME RF protocol no matter which USB face it wears; only the USB enumeration and the
+// input-report mapping change. The active face is g_usbMode, persisted to flash (cfg.bin). Tunables here
+// (mouse sensitivity, back-paddle mapping, A/B swap, chord assignments, QoS) are set over the CDC console
+// (serial_console.cpp) and the WebUSB panel (webusb_config.cpp) and likewise persist.
+#pragma once
+#include <stdint.h>
+
+// ---- Compile-time logging / diagnostics ----
+// 0 (default) = PRODUCTION build: no host->controller capture ring (reclaims ~80KB RAM), no per-event
+// logging, no main-loop section timing, no WebUSB capture channel. The functional bits (haptic reconnect
+// block, the buzz-clear re-init, rate stats) stay. Build the diagnostic firmware with -DOPK_LOG=1.
+// The WebUSB panel reads this back in the status blob and hides its logging UI when it's 0.
+#ifndef OPK_LOG
+#define OPK_LOG 0
+#endif
+
+// ---- USB presentation modes (g_usbMode). RF poll/relay is identical across all; only USB enumeration +
+//      report mapping differ. ----
+#define MODE_STEAM   0   // Valve puck; auto-lizard when Steam closed
+#define MODE_XBOX    1   // Xbox 360 (XInput + right-pad mouse)
+#define MODE_SW_HORI 2   // HORIPAD (Switch console whitelist)
+#define MODE_LIZARD  3   // Puck HID; always keyboard+mouse (ignores Steam heartbeat)
+#define MODE_SW_PRO  4   // Nintendo Switch Pro Controller (057E:2009) + gyro
+#define MODE_PS5     5   // Sony DualSense (054C:0CE6) + gyro + split trackpad
+#define MODE_HIDGYRO 6   // DS4-layout generic HID gamepad + gyro (Fortnite-friendly)
+#define MODE_XBOX_ONE 7  // Xbox One identity (XInput + right-pad mouse)
+#define MODE_MAX      7
+
+static inline bool modeIsPuck(uint8_t m){ return m==MODE_STEAM || m==MODE_LIZARD; }
+static inline bool modeIsXbox(uint8_t m){ return m==MODE_XBOX || m==MODE_XBOX_ONE; }
+static inline bool modeValid(uint8_t m){ return m<=MODE_MAX; }
+
+extern uint8_t g_usbMode;       // loaded from flash at boot
+extern bool    g_xbox;          // true for all non-puck presentations (cached !modeIsPuck(g_usbMode))
+extern uint8_t g_chordBtn[3];   // back4+B/X/Y -> these modes (A always STEAM)
+
+// Mode persistence policy: by DEFAULT every fresh power-on/reconnect lands in STEAM mode (0). An explicit
+// mode switch still works for the session via a ONE-SHOT bootMode (honored once, then cleared, so the next
+// cold boot reverts to Steam). The WebUI "persist last mode" toggle (g_persistMode) instead remembers the
+// last selected mode across reboots.
+extern bool    g_persistMode;   // false (default) = always boot Steam; true = boot into last mode
+extern uint8_t g_bootMode;      // one-shot: boot into this mode once then clear (!persistMode + explicit switch)
+
+// One-shot debug CDC. Puck mode normally DROPS the CDC serial console to free the USB endpoint its wake-mouse
+// interface needs (so puck can wake a sleeping Windows host). Arming this keeps CDC for the NEXT boot only --
+// dropping the wake mouse that boot -- so someone can attach the serial debugger; the boot after reverts to
+// normal automatically. Mirrors the g_bootMode one-shot. Armed from the WebUSB panel or CDC 'D' command.
+extern bool    g_debugCdcThisBoot;   // decision for THIS boot: true => keep CDC, skip the wake interface
+void armDebugCdcNextBoot();          // persist the one-shot (caller reboots)
+
+// persisted, runtime-tunable config:
+extern int     g_mDiv, g_mFric; // xbox/lizard mouse sensitivity divisor / friction%
+extern uint8_t g_abSwap;        // 1 = swap A/B and X/Y (Nintendo face-button layout)
+extern uint8_t g_back[4];       // back paddles L4,R4,L5,R5 -> button codes (5=LB 6=RB 7=L3 8=R3)
+
+#define POLL_US_DEFAULT 4000u   // 250 Hz -- matches SC2 input report rate (1000000/250 = 4000 us)
+#define USB_STREAM_MS   4u      // host-side HID stream cadence for translated modes (~250 Hz)
+extern const uint32_t g_pollUs; // RF poll cadence (us). FIXED -- not configurable (see loadCfg).
+
+// loop-timing diagnostics (defined in OpenPuck.ino) -- surfaced in the WebUSB status blob to find what caps
+// the poll rate: avg loop period, slowest section index, and that section's avg us/iteration.
+extern uint16_t g_loopPeriodUs;
+extern uint8_t  g_loopWorst;
+extern uint16_t g_loopWorstUs;
+
+void loadCfg();
+void saveCfg();
+// Mode switch (chord / WebUI): persist mode if the toggle is on, else arm a one-shot so this reboot lands in
+// the new mode but the next cold boot returns to Steam. Either way saveCfg + caller reboots.
+void saveMode(uint8_t m);

+ 27 - 0
OpenPuck/controllers.cpp

@@ -0,0 +1,27 @@
+#include "controllers.h"
+#include "config.h"
+#include "puck_hid.h"
+#include "mode_xinput.h"
+#include "mode_switch_hori.h"
+#include "mode_switch_pro.h"
+#include "mode_ps5.h"
+#include "mode_hidgyro.h"
+
+IController* g_active = nullptr;
+
+// Map a USB-presentation mode to its singleton controller. Steam and Lizard share the puck controller (the
+// lizard-vs-forward decision is made per-report inside it). This is the one place that knows the mode->class
+// mapping; everything else dispatches through g_active.
+IController* controllerFor(uint8_t mode){
+  switch(mode){
+    case MODE_STEAM:   return &g_steamPuck;
+    case MODE_LIZARD:  return &g_steamPuck;
+    case MODE_XBOX:
+    case MODE_XBOX_ONE:return &g_xboxCtl;
+    case MODE_SW_HORI: return &g_switchHori;
+    case MODE_SW_PRO:  return &g_switchPro;
+    case MODE_PS5:     return &g_ps5Ctl;
+    case MODE_HIDGYRO: return &g_hidGyroCtl;
+    default:           return &g_steamPuck;
+  }
+}

+ 34 - 0
OpenPuck/controllers.h

@@ -0,0 +1,34 @@
+// controllers.h -- the emulated-USB-controller abstraction.
+//
+// Each USB personality (Steam puck, Xbox/XInput, Switch HORIPAD, Switch Pro, PS5 DualSense, DS4 gyro) is an
+// IController living in its own mode_*.cpp. Exactly ONE is active per boot, selected by g_usbMode. The split
+// of responsibilities:
+//   begin()       -- called once from setup(): set the USB device VID/PID/strings and register this mode's
+//                    interface(s). The shared USB lifecycle (detach, clear/keep CDC, serial suffix, WebUSB,
+//                    attach) stays in setup(); begin() only adds what's specific to this controller.
+//   onReport45()  -- called from rf_link.cpp each time a controller input report 0x45 is decoded. PUSH-style
+//                    modes (Xbox, puck/lizard) build + send their host report here. STREAM-style modes ignore
+//                    it and instead emit at a fixed cadence from task(); they read the decoded g_in directly.
+//   task()        -- called every loop(): streaming emit, handshake/subcommand draining, mode-specific upkeep.
+//   isPuck()      -- true for Steam/Lizard (keep the boot CDC composite; different USB lifecycle in setup()).
+//
+// To add a new controller: implement IController in a new mode_*.cpp, give it a singleton, and wire it into
+// controllerFor() in controllers.cpp. Nothing else needs to change.
+#pragma once
+#include <stdint.h>
+
+class IController {
+public:
+  virtual ~IController() {}
+  virtual void begin() = 0;
+  virtual void onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen) { (void)rep; (void)fresh; (void)bodyTlen; }
+  virtual void task() {}
+  virtual void wakeEvent() {}   // a wake gesture fired while suspended; queue any post-resume input the host needs to actually wake
+  virtual bool isPuck() const { return false; }
+};
+
+// The singleton for a given mode (nullptr if the mode is unknown). Defined in controllers.cpp.
+IController* controllerFor(uint8_t mode);
+
+// The active controller for this boot (set in setup()). rf_link/loop dispatch through it.
+extern IController* g_active;

+ 87 - 0
OpenPuck/gamepad_util.cpp

@@ -0,0 +1,87 @@
+#include "gamepad_util.h"
+#include "triton.h"
+#include "config.h"
+
+uint8_t swStick(int16_t v,bool invert){   // int16 (center 0) -> uint8 (center 0x80)
+  int32_t a = 0x80 + (invert ? -((int32_t)v>>8) : ((int32_t)v>>8));
+  if(a<0)a=0; if(a>255)a=255; return (uint8_t)a;
+}
+
+// Map Steam trackpad s16 coords into a 0..max axis (centered touch -> mid-range).
+uint16_t padNormU16(int16_t v, uint16_t maxv){
+  int32_t t=((int32_t)v+32768); if(t<0)t=0; if(t>65535)t=65535;
+  return (uint16_t)((t*(int32_t)maxv)/65535);
+}
+uint16_t touchHalfX(int16_t v, bool rightHalf){
+  uint16_t halfMax = TOUCH_PAD_W/2 - 1;
+  uint16_t x = padNormU16(v, halfMax);
+  return rightHalf ? (uint16_t)(TOUCH_PAD_W/2 + x) : x;
+}
+uint16_t touchYInv(int16_t v, uint16_t height){
+  uint16_t maxy = height - 1;
+  return (uint16_t)(maxy - padNormU16(v, maxy));
+}
+void touchPackPoint(uint8_t* base, int finger, bool touch, uint16_t x, uint16_t y){
+  uint8_t* f=base+finger*4;
+  if(!touch){ f[0]=0x80; f[1]=0; f[2]=0; f[3]=0; return; }
+  f[0]=(uint8_t)(finger&0x7F);
+  f[1]=(uint8_t)(x&0xFF);
+  f[2]=(uint8_t)(((x>>8)&0x0F)|((y&0x0F)<<4));
+  f[3]=(uint8_t)((y>>4)&0xFF);
+}
+void touchPackPads(uint8_t* pts, bool lTouch, bool rTouch,
+    uint16_t lx, uint16_t ly, uint16_t rx, uint16_t ry){
+  touchPackPoint(pts, 0, false, 0, 0);
+  touchPackPoint(pts, 1, false, 0, 0);
+  if(lTouch && rTouch){
+    touchPackPoint(pts, 0, true, lx, ly);
+    touchPackPoint(pts, 1, true, rx, ry);
+  } else if(lTouch){
+    touchPackPoint(pts, 0, true, lx, ly);
+  } else if(rTouch){
+    touchPackPoint(pts, 0, true, rx, ry);
+  }
+}
+void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy, int16_t rpx, int16_t rpy,
+    uint16_t* lx, uint16_t* ly, uint16_t* rx, uint16_t* ry){
+  bool lt=(b&TB_LPADT)||(b&TB_LPADC), rt=(b&TB_RPADT)||(b&TB_RPADC);
+  *lx=touchHalfX(lpx, false); *ly=touchYInv(lpy, touchH);
+  *rx=touchHalfX(rpx, true);  *ry=touchYInv(rpy, touchH);
+  if(lt && !(b&TB_LPADT)){ *lx=TOUCH_PAD_W/4; *ly=touchH/2; }
+  if(rt && !(b&TB_RPADT)){ *rx=TOUCH_PAD_W/4*3; *ry=touchH/2; }
+}
+
+// Map g_back[] paddle code -> Steam button flags (same codes as codeToXB / codeToSwitch).
+static void psOrBackCode(uint32_t* b, uint8_t c){
+  switch(c){ case 1:*b|=TB_A; break; case 2:*b|=TB_B; break; case 3:*b|=TB_X; break; case 4:*b|=TB_Y; break;
+    case 5:*b|=TB_LB; break; case 6:*b|=TB_RB; break; case 7:*b|=TB_L3; break; case 8:*b|=TB_R3; break;
+    case 9:*b|=TB_VIEW; break; case 10:*b|=TB_MENU; break; case 11:*b|=TB_STEAM; break; default: break; }
+}
+uint32_t psButtonsFromSteam(uint32_t raw){
+  uint32_t b=raw;
+  if((b&CHORD_BACK4)==CHORD_BACK4) b&=~(uint32_t)(TB_A|TB_B|TB_X|TB_Y);
+  if(b&TB_L4) psOrBackCode(&b, g_back[0]); if(b&TB_R4) psOrBackCode(&b, g_back[1]);
+  if(b&TB_L5) psOrBackCode(&b, g_back[2]); if(b&TB_R5) psOrBackCode(&b, g_back[3]);
+  return b;
+}
+// DualSense / DS4 buttons[1]: L1..R3, Create(Share), Options(Start)
+uint8_t psShouldersByte(uint32_t b){
+  return ((b&TB_LB)?0x01:0)|((b&TB_RB)?0x02:0)
+         |((g_in.lt>SW_TRIG_ON||(b&0x8000000u))?0x04:0)|((g_in.rt>SW_TRIG_ON||(b&0x800000u))?0x08:0)
+         |((b&TB_MENU)?0x10:0)|((b&TB_VIEW)?0x20:0)   // Menu=Options, View=Create/Share
+         |((b&TB_L3)?0x40:0)|((b&TB_R3)?0x80:0);
+}
+uint8_t psHatNibble(uint32_t b){
+  bool u=b&TB_DUP,d=b&TB_DDN,l=b&TB_DLF,r=b&TB_DRT;
+  if(u&&r)return 1; if(r&&d)return 3; if(d&&l)return 5; if(l&&u)return 7;
+  if(u)return 0; if(r)return 2; if(d)return 4; if(l)return 6; return 8;
+}
+uint8_t psFaceNibble(uint32_t b){
+  uint8_t f=0;
+  if(g_abSwap){
+    if(b&TB_A)f|=0x40; if(b&TB_B)f|=0x20; if(b&TB_X)f|=0x80; if(b&TB_Y)f|=0x10;
+  } else {
+    if(b&TB_A)f|=0x20; if(b&TB_B)f|=0x40; if(b&TB_X)f|=0x10; if(b&TB_Y)f|=0x80;
+  }
+  return f;
+}

+ 29 - 0
OpenPuck/gamepad_util.h

@@ -0,0 +1,29 @@
+// gamepad_util.h -- shared helpers for building host USB gamepad reports from g_in.
+//
+// These are reused across the DualSense / DS4 / Switch personalities: analog-stick rescaling, Steam-trackpad
+// -> absolute-touch mapping, and the PlayStation-layout button/hat/face/shoulder packers. Per-target button
+// code maps (codeToXB / codeToSwitch / codeToJc) live with their own controllers; only the cross-mode pieces
+// are here.
+#pragma once
+#include <stdint.h>
+
+// int16 stick (center 0) -> uint8 (center 0x80), optional axis invert (HID Y is down-positive).
+uint8_t swStick(int16_t v, bool invert);
+
+// Steam trackpad s16 coords -> absolute touch surface. TOUCH_PAD_W is split into left/right halves so both
+// pads can co-exist as two contacts on a single DualSense/DS4 touchpad.
+#define TOUCH_PAD_W 1920u
+uint16_t padNormU16(int16_t v, uint16_t maxv);
+uint16_t touchHalfX(int16_t v, bool rightHalf);
+uint16_t touchYInv(int16_t v, uint16_t height);
+void touchPackPoint(uint8_t* base, int finger, bool touch, uint16_t x, uint16_t y);
+// DualSense / DS4: many hosts only honor contact slot 0 -- put a lone pad in slot 0, both pads in 0+1.
+void touchPackPads(uint8_t* pts, bool lTouch, bool rTouch, uint16_t lx, uint16_t ly, uint16_t rx, uint16_t ry);
+void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy, int16_t rpx, int16_t rpy,
+    uint16_t* lx, uint16_t* ly, uint16_t* rx, uint16_t* ry);
+
+// PlayStation-layout button packing (shared by mode_ps5 + mode_hidgyro).
+uint32_t psButtonsFromSteam(uint32_t raw);   // apply back-paddle + chord-guard remapping
+uint8_t  psShouldersByte(uint32_t b);        // L1..R3 + Create/Options byte
+uint8_t  psHatNibble(uint32_t b);            // d-pad -> 8-way hat (8 = neutral)
+uint8_t  psFaceNibble(uint32_t b);           // face buttons (with A/B + X/Y swap)

+ 225 - 0
OpenPuck/haptics.cpp

@@ -0,0 +1,225 @@
+#include "haptics.h"
+#include "bonds.h"
+#include "config.h"
+#include "rf_link.h"
+#include <Arduino.h>
+#include <string.h>
+
+uint8_t          g_relayOp  = 0xE3;   // E3 poll
+// relay sub-TLV type byte = SET (confirmed on hardware: E3 + [len][05][rid][payload] makes the controller act
+// on it -- haptics buzz + lizard-off lands). GET is 01.
+uint8_t          g_relaySub = 0x05;
+volatile uint8_t g_testHaptic = 0;
+volatile uint8_t g_hapticStop = 0;
+unsigned long    g_hapticBlockUntil = 0;   // drop Steam haptics briefly during reconnect settle
+
+static unsigned long g_haptic82Ms = 0;     // millis of last 0x82 haptic OUTPUT relayed (Steam mode)
+static bool          g_haptic82On = false; // a non-zero 0x82 haptic is currently active (awaiting host stop)
+static unsigned long g_reinitAt = 0;       // when to fire the next post-reconnect haptic re-init (0 = none scheduled)
+static uint8_t       g_reinitLeft = 0;     // how many re-init shots remain in this connect window
+
+// ---- relay ring: multi-producer (USB ISR + loop-context console/xinput), single consumer (poll flush) ----
+// Producers serialize through a brief PRIMASK critical section (copy is <=62 bytes); the consumer only ever
+// touches the tail entry, which no producer writes while the ring isn't full -- so a flush can't be torn.
+struct RelayMsg { uint8_t rid, len; uint8_t data[RELAY_MAXP]; };
+#define RELAY_QLEN 32          // deep enough to hold a full Steam settings/LED transaction burst without loss
+static RelayMsg         g_rq[RELAY_QLEN];
+static volatile uint8_t g_rqHead = 0, g_rqTail = 0;   // head=next write, tail=next read; empty when equal
+static inline uint8_t rqNext(uint8_t i){ return (uint8_t)((i+1)%RELAY_QLEN); }
+
+bool relayPending(){ return g_rqHead != g_rqTail; }
+bool relayEnqueue(uint8_t rid, const uint8_t* payload, uint8_t plen){
+  if (plen > RELAY_MAXP) plen = RELAY_MAXP;            // 60B is the RF frame ceiling; longer can't be relayed
+  uint32_t pm = __get_PRIMASK(); __disable_irq();
+  uint8_t h = g_rqHead, nx = rqNext(h);
+  // Full -> evict the OLDEST entry, never the newest. Steam sends commands as bursts where the meaningful
+  // frame comes LAST (a settings transaction ends with its commit; a haptic stream ends with its stop). The
+  // old drop-newest policy threw away exactly those, so LED commits and haptic stops were the casualties --
+  // brightness never applied and a glide's final stop never reached the controller (stuck buzz). Dropping the
+  // oldest instead guarantees the most recent command always lands.
+  if (nx == g_rqTail) g_rqTail = rqNext(g_rqTail);
+  g_rq[h].rid = rid; g_rq[h].len = plen;
+  if (plen) memcpy(g_rq[h].data, payload, plen);
+  g_rqHead = nx;
+  __set_PRIMASK(pm); return true;
+}
+
+#if OPK_LOG
+// diagnostic capture: a ring of the last OUTPUT reports Steam sends (rid/slot/bytes/ms), dumped with 'H'.
+// Reproduce the whine, press Steam to stop it, then 'H' to see the ON stream + the exact OFF frame Steam sends.
+struct HapLog { uint32_t ms; uint8_t slot, rid, n, b[16]; };   // 16 payload bytes: capture full 0x87 settings frames
+// Big always-on ring: log EVERYTHING from boot (Steam writes, our TX-to-controller, link edges) so a rare
+// reconnect-buzz can be caught after the fact -- the trigger happens moments after the puck boots, while this
+// RAM is fresh, and we dump it once the panel reconnects. 4096 * 20B ~= 80KB (we have plenty of free RAM).
+#define HAPLOG_N 4096
+static HapLog   g_hapLog[HAPLOG_N];
+static uint16_t g_hapHead = 0;
+static uint16_t g_hapTail = 0;  // live/dump drain cursor (loop-context reader; chases g_hapHead)
+
+void hapLogAdd(uint8_t slot, uint8_t rid, const uint8_t* b, uint16_t n){
+  // Written from the USB SET ISR (handleSet) AND loop context (relay flush / link edges) -> guard g_hapHead.
+  // Special slot markers for the diagnostic capture: 0xFE = a frame WE transmitted to the controller (TX
+  // relay); 0xFD = a link state edge (b[0]=1 up, 0 down). Real Steam writes use the interface index (0..3).
+  uint32_t pm = __get_PRIMASK(); __disable_irq();
+  HapLog &e = g_hapLog[g_hapHead]; e.ms = millis(); e.slot = slot; e.rid = rid; e.n = (uint8_t)(n>255?255:n);
+  for(int i=0;i<16;i++) e.b[i] = (i<(int)n) ? b[i] : 0;
+  g_hapHead = (uint8_t)((g_hapHead+1) % (sizeof g_hapLog / sizeof g_hapLog[0]));
+  __set_PRIMASK(pm);
+}
+void hapticDumpLog(){   // dump the captured history (oldest->newest) over CDC for the haptic-whine hunt
+  const uint16_t N=HAPLOG_N; uint32_t now=millis();
+  Serial.printf("# --- capture history (now=%lu, connSlot=%d) ---\n",(unsigned long)now,g_connSlot);
+  for(uint16_t i=0;i<N;i++){ HapLog &e=g_hapLog[(uint16_t)((g_hapHead+i)%N)]; if(!e.ms && !e.rid) continue;
+    Serial.printf("# -%lums if%u rid=%02X n=%u:",(unsigned long)(now-e.ms),e.slot,e.rid,e.n);
+    for(uint8_t j=0;j<16 && j<e.n;j++) Serial.printf(" %02X",e.b[j]); Serial.println(); }
+  Serial.println("# --- end ---");
+}
+// ---- drain cursor: stream entries to the WebUSB panel. resetDrain(false)=from "now" (live only);
+//      resetDrain(true)=from the OLDEST entry (dump the whole ring from boot). pull skips empty slots. ----
+void hapLogResetDrain(bool fromBoot){
+  g_hapTail = fromBoot ? (uint16_t)((g_hapHead+1)%HAPLOG_N)   // oldest slot (the next-to-overwrite holds it)
+                       : g_hapHead;                            // "now"
+}
+bool hapLogPull(uint32_t* logMs, uint8_t* slot, uint8_t* rid, uint8_t* n, uint8_t bytes16[16]){
+  while(g_hapTail != g_hapHead){
+    HapLog &e=g_hapLog[g_hapTail];
+    g_hapTail=(uint16_t)((g_hapTail+1)%HAPLOG_N);
+    if(!e.ms && !e.rid) continue;                            // skip empty slot (ring not yet full)
+    // Return the ABSOLUTE log time (millis since boot), not "age now". The panel drains in batches 100ms
+    // apart, so an age computed here would jump between batches; the panel computes age vs the newest entry.
+    *logMs=e.ms; *slot=e.slot; *rid=e.rid; *n=(e.n>16)?16:e.n; memcpy(bytes16,e.b,16);
+    return true;
+  }
+  return false;
+}
+#endif // OPK_LOG
+
+bool hapticLinkUp(){
+  return g_connSlot>=0 && (millis()-g_connReplyMs) < 300;
+}
+bool haptic82Blocked(){
+  return !hapticLinkUp() || (g_hapticBlockUntil && (int32_t)(millis()-g_hapticBlockUntil) < 0);
+}
+bool hapticRelaySlotOk(int slot){
+  return g_connSlot>=0 && slot==g_connSlot;
+}
+static bool haptic82PayloadOn(const uint8_t* p, uint16_t n){
+  if(n<3) return false;
+  for(uint16_t i=2;i<n;i++) if(p[i]) return true;   // observed form is [01 01 gain], but treat any trailing non-zero as active
+  return false;
+}
+static void hapticCancelPendingOn(){   // void queued 0x82-ON entries (stale Steam haptics across a reconnect)
+  uint32_t pm = __get_PRIMASK(); __disable_irq();
+  for(uint8_t i=g_rqTail; i!=g_rqHead; i=rqNext(i)){
+    RelayMsg &m = g_rq[i];
+    if(m.rid==0x82){ bool on=false; for(uint8_t j=2;j<m.len;j++) if(m.data[j]){on=true;break;} if(on) m.rid=0; }
+  }
+  __set_PRIMASK(pm);
+}
+void haptic82HostReport(const uint8_t* p, uint16_t n){
+  if(n<3) return;
+  g_haptic82Ms = millis();
+  // Track on/off only. Do NOT synthesize a stop burst when Steam's own stop arrives: that stop is already
+  // forwarded verbatim, so adding 0x82-zero frames on top just makes the controller see the stop several times
+  // over. Each 0x82 is a discrete pad click, so the extra frames are exactly the spurious end-of-movement
+  // "click"/buzz that the real puck never produces. (Connect-time clearing still runs in hapticTask().)
+  g_haptic82On = haptic82PayloadOn(p,n);
+}
+// Queue a pending test-haptic / stop relay (runs inside the poll cadence -- never at raw loop rate).
+void rfConnQueueHapticRelay(){
+  if(relayPending()) return;            // host relays first; injectables wait for an idle cycle
+  static const uint8_t HAP_ON[3]  = {0x01,0x01,0xF7};
+  static const uint8_t HAP_OFF[3] = {0x01,0x01,0x00};
+  if(g_testHaptic){
+    if(relayEnqueue(0x82, HAP_ON, 3)) g_testHaptic--;
+  } else if(g_hapticStop && !g_xbox){
+    if(relayEnqueue(0x82, HAP_OFF, 3)) g_hapticStop--;
+  }
+}
+void rfConnFlushRelay(uint8_t ch, uint8_t s1){
+  while(g_rqTail != g_rqHead){
+    RelayMsg &m = g_rq[g_rqTail];
+    if(m.rid){                          // rid 0 = entry voided by hapticCancelPendingOn -> skip, take the next
+      uint8_t rl = m.len; if(rl > RELAY_MAXP) rl = RELAY_MAXP;
+      uint8_t p[4+RELAY_MAXP]; p[0]=g_relayOp; p[1]=(uint8_t)(1+rl); p[2]=g_relaySub; p[3]=m.rid;
+      memcpy(p+4, m.data, rl);
+      hapLogAdd(0xFE, m.rid, m.data, rl);   // log what we actually TX to the controller (slot 0xFE) for the buzz hunt
+      g_rqTail = rqNext(g_rqTail);      // copied out -> release the slot before the TX
+      // s1 carries a PID distinct from the GET poll that follows (caller cycles it), so the controller's ESB
+      // dedup never mistakes the GET for a retransmit of this relay. 80us RX window: the relay is NO-ACK, the
+      // controller sends nothing back, so don't burn the full ~1.2ms reply window (that was halving the poll
+      // rate during haptics).
+      rfConnTx(ch,s1,p,(uint8_t)(4+rl),80);
+      return;                           // ONE relay per poll cycle (matches the real puck's pacing)
+    }
+    g_rqTail = rqNext(g_rqTail);
+  }
+}
+
+// Haptic-subsystem RE-INIT: the exact sequence Steam sends (captured on hardware) when it (re)takes control,
+// and which clears a stuck haptic script on the controller -- a 0x81 reset action plus 0x87 writes to the
+// haptic registers (30/07/08/31/52, 18/2e/34/35). We replay it to recover from the latched-buzz the
+// controller falls into across a reconnect. Brightness (0x87 reg 2d) is deliberately OMITTED so we don't
+// stomp the LED. Enqueued onto the normal relay (drains in the poll cadence).
+void hapticReinit(){
+  // The FULL sequence Steam sends when it (re)takes control / on a Steam-button press -- captured on hardware
+  // and proven to clear a stuck haptic. Replayed verbatim and in order (no trimming this time: the brightness
+  // write 0x87[2d] and the two 7-byte 0x81 frames are part of what Steam sends, and dropping them may be why
+  // the trimmed version didn't always clear the connect buzz).
+  static const uint8_t H30[]={0x30,0x00,0x00,0x07,0x07,0x00,0x08,0x07,0x00,0x31,0x02,0x00,0x52,0x03,0x00};
+  static const uint8_t H18[]={0x18,0x00,0x00,0x2e,0x00,0x00,0x34,0xff,0xff,0x35,0xff,0xff,0x34,0xff,0xff};
+  static const uint8_t H35[]={0x35,0xff,0xff,0x2e,0x00,0x00};
+  static const uint8_t H2D[]={0x2d,0x64,0x00};                       // brightness (Steam sets it here too)
+  static const uint8_t T81A[]={0x00,0x00,0x00,0x00,0x00,0x00,0x00};
+  static const uint8_t T81B[]={0x01,0x00,0x00,0x00,0x00,0x00,0x00};
+  relayEnqueue(0x81, nullptr, 0);            // reset action (FUN_0001f554) -- Steam sends this first
+  relayEnqueue(0x87, H30, sizeof H30);
+  relayEnqueue(0x87, H18, sizeof H18);
+  relayEnqueue(0x87, H35, sizeof H35);
+  relayEnqueue(0x87, H2D, sizeof H2D);
+  relayEnqueue(0x81, T81A, sizeof T81A);
+  relayEnqueue(0x81, T81B, sizeof T81B);
+}
+void hapticInit(){
+  g_rqHead = g_rqTail = 0; g_haptic82On=false;
+  g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;   // boot: block stale Steam 0x82 until link stable
+  // NO fabricated stop burst. USB capture proves Steam only ever sends 0x82 [01 01 f7] pulses -- never a
+  // zero-gain [01 01 00] "stop". Injecting our invented stop frame (which the real puck never sends) at
+  // boot/connect is unfaithful and, given the controller's "haptic script already active -> ignore" behavior,
+  // a likelier cause of the connect-time buzz than a cure (the buzz happened WITH this burst in place). Be a
+  // pure pass-through of Steam's haptics, like the real puck.
+  g_hapticStop = 0;
+}
+// Arm the post-(re)connect haptic block + schedule the clearing re-init. Called from rf_link the moment a
+// controller reply arrives after a gap (the reliable reconnect signal), and as a backup on hapticTask's
+// link-up edge. Idempotent -- safe to call repeatedly.
+void hapticOnReconnect(){
+  g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;   // no haptics relayed for the next 3s
+  g_haptic82On = false;
+  hapticCancelPendingOn();                                     // drop any haptic ON queued before the link came up
+  // Be PROACTIVE: start re-initing the haptic engine right after the link is up and repeat across the whole
+  // settle window, instead of waiting for the block to end. The brief connect buzz engages early (during the
+  // block, controller-internal), so a single late shot misses it -- frequent early resets cut it short or
+  // preempt it. The re-init is settings only (no haptic play), and Steam haptics are blocked the whole time,
+  // so repeating it can't itself buzz.
+  g_reinitAt = millis() + 200u;                               // first reset ~200ms after (re)connect
+  g_reinitLeft = HAPTIC_REINIT_SHOTS;                          // then every HAPTIC_REINIT_GAP_MS across the window
+  uint8_t mk=2; hapLogAdd(0xFD, 0xEE, &mk, 1);                 // capture marker: RECONNECT detected (block+reinit armed)
+}
+void hapticTask(){
+  static bool wasHapticLinkUp=false;
+  bool up=hapticLinkUp();
+  // Link-edge markers are diagnostic only now -- the block/re-init is armed reliably from rf_link
+  // (hapticOnReconnect) on the first reply after a gap, which fires even when this 300ms edge doesn't.
+  if(up && !wasHapticLinkUp){ uint8_t mk=1; hapLogAdd(0xFD,0xEE,&mk,1); hapticOnReconnect(); }
+  if(!up && wasHapticLinkUp){ uint8_t mk=0; hapLogAdd(0xFD,0xEE,&mk,1); }
+  wasHapticLinkUp=up;
+  if(g_reinitAt && up && (int32_t)(millis()-g_reinitAt) >= 0){   // proactive haptic re-init across the connect window
+    hapticReinit();
+    g_reinitAt = (g_reinitLeft && --g_reinitLeft) ? (millis()+HAPTIC_REINIT_GAP_MS) : 0;
+  }
+  // Steam-mode: host went quiet -> mark the 0x82 stream inactive. Do NOT synthesize a stop: trackpad haptics
+  // are one-shot pulses, so firing a 0x82-zero ~HAPTIC_QUIET_MS after a swipe ends is the extra end-of-movement
+  // click the real puck doesn't make. Steam forwards its own stop for any sustained haptic.
+  if (!g_xbox && g_haptic82On && millis()-g_haptic82Ms > HAPTIC_QUIET_MS) g_haptic82On=false;
+}

+ 83 - 0
OpenPuck/haptics.h

@@ -0,0 +1,83 @@
+// haptics.h -- host -> controller relay queue (haptics + settings) + the watchdogs that stop a stuck buzz.
+//
+// Steam (and legacy XInput rumble) send OUTPUT/feature reports the real dongle forwards to the controller as
+// a SET sub-TLV inside the E3 poll. We do the same: handleSet()/the console enqueue with relayEnqueue(), and
+// rfConnFlushRelay() emits ONE entry per poll cycle (rf_link.cpp), never at raw loop rate.
+//
+// The queue is a small ring, NOT a single buffer: producers run in USB-ISR context while the consumer may be
+// mid-flush in loop context, and Steam writes settings/calibration as bursts of back-to-back feature reports.
+// The old single g_relayBuf both LOST every report that arrived before the previous one flushed and could be
+// TORN mid-copy by the ISR (a half-old-half-new command on the air) -- the settings-never-save and the
+// corrupted-haptic-latch bugs. Entries carry up to RELAY_MAXP payload bytes (the RF frame fits 60: [E3][len]
+// [05][rid] + payload <= MAXLEN 64); the old path truncated everything to 18, which chopped any multi-register
+// 0x87 settings block (LED brightness) and calibration write.
+//
+// The controller's haptic LATCHES until told to stop. If the host's stop is lost over RF (or the link drops
+// mid-buzz) the actuator whines forever -- so we send 0x82-zero stop bursts on reconnect, but ONLY when a
+// haptic was actually active when the link dropped: every extra 0x82 frame is an audible click on the
+// controller (captured), so the old unconditional burst on each link-up edge was itself a buzz source on a
+// flapping link. The g_hapLog ring captures recent OUTPUT reports for the 'H' dump.
+#pragma once
+#include <stdint.h>
+#include "config.h"   // OPK_LOG
+
+// after this much host silence, consider the current 0x82 haptic stream inactive
+#define HAPTIC_QUIET_MS    300u
+// Block ALL haptic relays for this long after a (re)connect: a just-powered-on controller's haptic engine
+// isn't ready, and feeding it haptics in that window leaves it in a degraded/latched state (slow/stuck/missing
+// haptics until a re-init). 3s gives it time to initialize. (Was 1500.)
+#define HAPTIC_RECONNECT_BLOCK_MS 3000u
+// 0x82-zero relays per stop event (sent at poll cadence -- not loop rate)
+#define HAPTIC_STOP_BURST  4u
+// max relayed payload bytes per entry: RF frame = [E3][len][05][rid][payload] and MAXLEN=64 -> 60
+#define RELAY_MAXP 60u
+// Proactive post-(re)connect haptic re-init: this many shots, this far apart, starting ~200ms after the link
+// comes up -- covers ~0.2s..3s so the brief controller-side connect buzz gets reset before it can sustain.
+#define HAPTIC_REINIT_SHOTS  8u
+#define HAPTIC_REINIT_GAP_MS 350u
+
+// ---- relay queue (written by puck_hid.cpp, mode_xinput.cpp, serial_console.cpp; drained by rf_link.cpp) ----
+// Enqueue one host->controller report: rid = report/command id, payload = the bytes AFTER [cmd][len] (what
+// goes on the air). ISR-safe (brief PRIMASK critical section). Returns false (dropped) when the ring is full.
+bool relayEnqueue(uint8_t rid, const uint8_t* payload, uint8_t plen);
+bool relayPending();                 // anything still queued (xinput uses it to pace rumble re-queues)
+extern uint8_t          g_relayOp;   // relay frame opcode (E3 poll)
+extern uint8_t          g_relaySub;  // relay sub-TLV type byte = SET
+extern volatile uint8_t g_testHaptic;// 't<n>' injects n test haptics for the buzz hunt
+extern volatile uint8_t g_hapticStop;// pending haptic-STOP frames to relay (kill a latched whine)
+extern unsigned long    g_hapticBlockUntil;
+
+// ---- diagnostic capture (compiled in only when OPK_LOG): a ring of recent host->controller commands +
+//      link/TX markers, dumped over WebUSB. No-ops in a production build so call sites vanish. ----
+#if OPK_LOG
+void hapLogAdd(uint8_t slot, uint8_t rid, const uint8_t* b, uint16_t n);
+void hapticDumpLog();   // 'H' console dump of the recent OUTPUT-report history
+// WebUSB capture drain: resetDrain(true) rewinds to the OLDEST entry (dump the whole ring from boot);
+// resetDrain(false) starts at "now" (live only). pull yields each entry once, oldest->newest, skipping empties.
+void    hapLogResetDrain(bool fromBoot);
+bool    hapLogPull(uint32_t* logMs, uint8_t* slot, uint8_t* rid, uint8_t* n, uint8_t bytes16[16]);
+#else
+static inline void hapLogAdd(uint8_t, uint8_t, const uint8_t*, uint16_t){}
+static inline void hapticDumpLog(){}
+static inline void hapLogResetDrain(bool){}
+static inline bool hapLogPull(uint32_t*, uint8_t*, uint8_t*, uint8_t*, uint8_t*){ return false; }
+#endif
+
+bool hapticLinkUp();
+bool haptic82Blocked();
+bool hapticRelaySlotOk(int slot);
+void haptic82HostReport(const uint8_t* p, uint16_t n);
+
+// queue + flush the pending host/test/stop relay inside the poll cadence (called from rf_link).
+// rfConnFlushRelay's s1 must carry a PID distinct from the GET poll that follows it (rf_link cycles the shared
+// PID counter), so the controller doesn't dedup the GET as a retransmit of the relay.
+void rfConnQueueHapticRelay();
+void rfConnFlushRelay(uint8_t ch, uint8_t s1);
+
+void hapticInit();   // boot reset: clear relay/active flags, arm the reconnect block
+void hapticTask();   // per-loop upkeep: link-edge markers + steam 0x82 quiet timeout + fires the scheduled re-init
+void hapticReinit(); // replay Steam's haptic-subsystem re-init to the controller -> clears a latched/stuck buzz
+// Called from rf_link the instant a controller (re)connect is detected (an F-reply after a gap): blocks haptic
+// relays for HAPTIC_RECONNECT_BLOCK_MS and schedules a re-init just after, to keep the freshly-booted
+// controller out of the degraded/latched haptic state. Reliable -- independent of hapticTask's link heuristic.
+void hapticOnReconnect();

+ 18 - 0
OpenPuck/identity.cpp

@@ -0,0 +1,18 @@
+#include "identity.h"
+#include <Arduino.h>
+#include <stdio.h>
+#include <string.h>
+
+char g_unit[16];
+char g_board[16];
+char g_usbSerial[18];
+
+const uint8_t ATTR83[25] = {     // 0x83 attributes (product 0x1304 = puck)
+  0x01,0x04,0x13,0x00,0x00,0x02,0x00,0x00,0x00,0x00,0x0A,0xF2,0xF9,0xD2,0x68,
+  0x04,0x53,0xD0,0x18,0x6A,0x09,0x47,0x00,0x00,0x00 };
+
+void genSerial() {
+  uint32_t id = NRF_FICR->DEVICEID[0] ^ NRF_FICR->DEVICEID[1];
+  snprintf(g_unit,  sizeof g_unit,  "FXB99602%05lX", (unsigned long)(id & 0xFFFFF));
+  snprintf(g_board, sizeof g_board, "MXB99602%05lX", (unsigned long)(id & 0xFFFFF));
+}

+ 15 - 0
OpenPuck/identity.h

@@ -0,0 +1,15 @@
+// identity.h -- device identity derived from the nRF52 FICR DEVICEID.
+//
+// A UNIQUE serial (never clashes with a real puck) is generated at boot. Steam mode reports g_unit as its
+// USB serial (its pairing identity); the other modes append a 1-char suffix (setup() in OpenPuck.ino).
+#pragma once
+#include <stdint.h>
+
+extern char g_unit[16];        // "FXB99602xxxxx"
+extern char g_board[16];       // "MXB99602xxxxx"
+extern char g_usbSerial[18];   // per-mode USB serial (Steam uses g_unit; others get a suffix)
+
+// 0x83 attributes (product 0x1304 = puck), returned by the puck feature command channel.
+extern const uint8_t ATTR83[25];
+
+void genSerial();

+ 71 - 0
OpenPuck/mode_hidgyro.cpp

@@ -0,0 +1,71 @@
+#include "mode_hidgyro.h"
+#include "triton.h"
+#include "gamepad_util.h"
+#include "config.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+HidGyroController g_hidGyroCtl;
+
+static const uint8_t GYRO_HID_DESC[]={
+  0x05,0x01,0x09,0x05,0xA1,0x01,0x85,0x01,0x09,0x30,0x09,0x31,0x09,0x32,0x09,0x35,
+  0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x04,0x81,0x02,0x09,0x39,0x15,0x00,0x25,
+  0x07,0x35,0x00,0x46,0x3B,0x01,0x65,0x14,0x75,0x04,0x95,0x01,0x81,0x42,0x65,0x00,
+  0x05,0x09,0x19,0x01,0x29,0x0E,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x0E,0x81,0x02,
+  0x06,0x00,0xFF,0x09,0x20,0x75,0x06,0x95,0x01,0x15,0x00,0x25,0x7F,0x81,0x02,0x05,
+  0x01,0x09,0x33,0x09,0x34,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x02,0x81,0x02,
+  0x06,0x00,0xFF,0x09,0x21,0x95,0x36,0x81,0x02,0x85,0x05,0x09,0x22,0x95,0x1F,0x91,
+  0x02,0x85,0x04,0x09,0x23,0x95,0x24,0xB1,0x02,0x85,0x02,0x09,0x24,0x95,0x24,0xB1,
+  0x02,0xC0
+};
+#define DS4_TOUCH_H 942
+#define DS4_STATUS_USB 0x1B   // cable + level 11 (full)
+static unsigned long g_gyroLastMs=0;
+static Adafruit_USBD_HID g_hidGyro;
+
+static void hidGyroBuild(uint8_t out[63]){
+  uint32_t b=psButtonsFromSteam(g_in.buttons);
+  bool lTouch=(b&TB_LPADT)||(b&TB_LPADC), rTouch=(b&TB_RPADT)||(b&TB_RPADC);
+  memset(out,0,63);
+  out[0]=swStick(g_in.lx,false); out[1]=swStick(g_in.ly,true);
+  out[2]=swStick(g_in.rx,false); out[3]=swStick(g_in.ry,true);
+  out[4]=psHatNibble(b)|psFaceNibble(b);
+  out[5]=psShouldersByte(b);
+  static uint8_t ctr=0; out[6]=((ctr++&0x0F)<<4)|((b&TB_LPADC||b&TB_RPADC)?0x02:0)|((b&TB_STEAM)?0x01:0);
+  out[7]=g_in.lt; out[8]=g_in.rt;
+  out[12]=g_in.gx&0xFF; out[13]=g_in.gx>>8;
+  out[14]=g_in.gy&0xFF; out[15]=g_in.gy>>8;
+  out[16]=g_in.gz&0xFF; out[17]=g_in.gz>>8;
+  out[18]=g_in.ax&0xFF; out[19]=g_in.ax>>8;
+  out[20]=g_in.ay&0xFF; out[21]=g_in.ay>>8;
+  out[22]=g_in.az&0xFF; out[23]=g_in.az>>8;
+  out[29]=DS4_STATUS_USB;
+  if(lTouch || rTouch){
+    uint16_t lx, ly, rx, ry;
+    steamPadsToTouch(b, DS4_TOUCH_H, g_in.lpx, g_in.lpy, g_in.rpx, g_in.rpy, &lx, &ly, &rx, &ry);
+    static uint8_t tstamp=0;
+    out[32]=1; out[33]=tstamp++;
+    touchPackPads(out+34, lTouch, rTouch, lx, ly, rx, ry);
+  } else {
+    out[32]=0;
+    touchPackPads(out+34, false, false, 0, 0, 0, 0);   // contact 0x80 -- memset(0) reads as touch @0,0
+  }
+}
+
+void HidGyroController::begin(){
+  USBDevice.setID(0x054C, 0x05C4);
+  USBDevice.setDeviceVersion(0x0101);   // bumped from 0x0100 for the added wake-mouse interface (Windows caches config by VID:PID:bcdDevice)
+  USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
+  USBDevice.setProductDescriptor("Wireless Controller");
+  g_hidGyro.enableOutEndpoint(true);
+  g_hidGyro.setReportDescriptor(GYRO_HID_DESC, sizeof GYRO_HID_DESC);
+  g_hidGyro.setPollInterval(4);
+  g_hidGyro.begin();
+}
+void HidGyroController::task(){
+  if(!g_hidGyro.ready()) return;
+  if(millis()-g_gyroLastMs<USB_STREAM_MS) return; g_gyroLastMs=millis();
+  uint8_t p[63]; hidGyroBuild(p);
+  g_hidGyro.sendReport(0x01, p, sizeof p);
+}

+ 14 - 0
OpenPuck/mode_hidgyro.h

@@ -0,0 +1,14 @@
+// mode_hidgyro.h -- generic HID gyro personality (MODE_HIDGYRO): DS4 (054C:05C4) USB report layout + motion.
+//
+// A standard DS4 descriptor (input report 0x01, 64 B) with gyro/accel at bytes 12-23 (GIMX layout). Aimed at
+// motion-aware PC games (e.g. Fortnite) that read the DS4 report directly. Streamed at ~250Hz from task();
+// reuses the shared PS-layout button packers + Steam-trackpad touch mapping.
+#pragma once
+#include "controllers.h"
+
+class HidGyroController : public IController {
+public:
+  void begin() override;
+  void task() override;
+};
+extern HidGyroController g_hidGyroCtl;

+ 56 - 0
OpenPuck/mode_lizard.cpp

@@ -0,0 +1,56 @@
+#include "mode_lizard.h"
+#include "triton.h"
+#include "config.h"
+
+void rfLizard(const uint8_t* r, Adafruit_USBD_HID* mdev, Adafruit_USBD_HID* kdev, uint8_t mrid, uint8_t krid){
+  uint32_t b=btnsOf(r);
+  // --- right pad -> mouse motion with glide (mirrors mode_xinput's rfXboxMouse) ---
+  static int prx=0,pry=0; static bool prt=false; static float vx=0,vy=0,rmx=0,rmy=0;
+  bool rtouch=b&TB_RPADT; int rx=s16off(r,22), ry=s16off(r,24);
+  if(rtouch){ if(prt){ vx+=(rx-prx); vy+=(ry-pry); } prx=rx; pry=ry; } prt=rtouch;
+  float mxf=vx/(float)(g_mDiv*10)+rmx, myf=-(vy/(float)(g_mDiv*10))+rmy;   // Y inverted; *10 = desktop-cursor sensitivity (g_mDiv 64 -> eff 640). Lower g_mDiv via WebUI slider = faster.
+  int dx=(int)mxf, dy=(int)myf; rmx=mxf-dx; rmy=myf-dy;          // sub-pixel carry
+  if(dx>127)dx=127; if(dx<-127)dx=-127; if(dy>127)dy=127; if(dy<-127)dy=-127;
+  float f=g_mFric/100.0f; vx*=f; vy*=f; if(vx>-1&&vx<1)vx=0; if(vy>-1&&vy<1)vy=0;   // friction = glide/decay
+  // --- left pad -> vertical scroll wheel (no momentum; only while touching, coarse) ---
+  static int ply=0; static bool plt=false; static float sacc=0;
+  bool ltouch=b&TB_LPADT; int ly=s16off(r,18);
+  if(ltouch){ if(plt){ sacc += (ly-ply)/(float)(g_mDiv*24); } ply=ly; } else sacc=0; plt=ltouch;
+  int dw=(int)sacc; sacc-=dw; if(dw>15)dw=15; if(dw<-15)dw=-15;   // finger up = wheel up (positive)
+  // --- mouse buttons: left=R-pad-click|R-trigger, right=L-trigger, middle=L-pad-click ---
+  // trigU8 (full-range scale + saturate) so a full pull reaches ~0xFF; a raw >>8 tops out ~0x80 and never
+  // crosses the threshold, leaving the trigger clicks dead.
+  uint8_t rtrig=trigU8(u16off(r,6)), ltrig=trigU8(u16off(r,4));
+  uint8_t mbtn=0;
+  if((b&TB_RPADC)||rtrig>180) mbtn|=1;   // right trigger -> left (primary) click
+  if(ltrig>180)               mbtn|=2;   // left trigger  -> right click
+  if(b&TB_LPADC)              mbtn|=4;
+  static uint8_t pmbtn=0;
+  if(dx||dy||dw||mbtn!=pmbtn){ pmbtn=mbtn;
+    hid_mouse_report_t m; m.buttons=mbtn; m.x=(int8_t)dx; m.y=(int8_t)dy; m.wheel=(int8_t)dw; m.pan=0;
+    if(mdev->ready()) mdev->sendReport(mrid,&m,sizeof m);
+  }
+  // --- keyboard: modifiers + up to 6 keycodes ---
+  uint8_t mod=0, kc[6]={0,0,0,0,0,0}, nk=0;
+  if(b&TB_LB) mod|=KEYBOARD_MODIFIER_LEFTCTRL;
+  if(b&TB_RB) mod|=KEYBOARD_MODIFIER_LEFTALT;
+  #define LZK(cond,code) do{ if((cond)&&nk<6) kc[nk++]=(code); }while(0)
+  LZK(b&TB_A,    HID_KEY_ENTER);
+  LZK(b&TB_B,    HID_KEY_ESCAPE);
+  LZK(b&TB_X,    HID_KEY_PAGE_UP);
+  LZK(b&TB_Y,    HID_KEY_PAGE_DOWN);
+  LZK(b&TB_VIEW, HID_KEY_TAB);
+  LZK(b&TB_MENU, HID_KEY_ESCAPE);
+  int sx=s16off(r,8), sy=s16off(r,10);   // left stick (XInput sign: +Y = up); deflect ~37% acts as a d-pad
+  LZK((b&TB_DUP)||sy> 12000, HID_KEY_ARROW_UP);
+  LZK((b&TB_DDN)||sy<-12000, HID_KEY_ARROW_DOWN);
+  LZK((b&TB_DLF)||sx<-12000, HID_KEY_ARROW_LEFT);
+  LZK((b&TB_DRT)||sx> 12000, HID_KEY_ARROW_RIGHT);
+  #undef LZK
+  static uint8_t pmod=0, pkc[6]={0,0,0,0,0,0};
+  bool chg=(mod!=pmod); for(int i=0;i<6;i++) if(kc[i]!=pkc[i]) chg=true;
+  if(chg){ pmod=mod; for(int i=0;i<6;i++) pkc[i]=kc[i];
+    uint8_t krep[8]={mod,0,kc[0],kc[1],kc[2],kc[3],kc[4],kc[5]};
+    if(kdev->ready()) kdev->sendReport(krid,krep,8);
+  }
+}

+ 13 - 0
OpenPuck/mode_lizard.h

@@ -0,0 +1,13 @@
+// mode_lizard.h -- the controller's native "lizard" desktop behavior (driverless keyboard + mouse).
+//
+// Not a standalone IController: lizard rides ON the puck interface (puck_hid.cpp calls rfLizard when Steam is
+// closed), driving mouse(0x40)+keyboard(0x41) on the same puck HID slot. Canonical Valve SC1 map: right pad ->
+// mouse (trackball glide), left pad -> scroll wheel + middle-click, R-trigger/R-pad-click -> left mouse,
+// L-trigger -> right mouse; A=Enter B=Esc X=PgUp Y=PgDn, d-pad/left-stick -> arrows, LB=LeftCtrl RB=LeftAlt,
+// View=Tab, Menu=Esc. Mouse reuses the same velocity+friction+sub-pixel GLIDE model as Xbox mode (g_mDiv /
+// g_mFric, both live-tunable).
+#pragma once
+#include <Adafruit_TinyUSB.h>
+#include <stdint.h>
+
+void rfLizard(const uint8_t* r, Adafruit_USBD_HID* mdev, Adafruit_USBD_HID* kdev, uint8_t mrid, uint8_t krid);

+ 73 - 0
OpenPuck/mode_ps5.cpp

@@ -0,0 +1,73 @@
+#include "mode_ps5.h"
+#include "triton.h"
+#include "gamepad_util.h"
+#include "config.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+Ps5Controller g_ps5Ctl;
+
+static const uint8_t PS5_HID_DESC[]={
+  0x05,0x01,0x09,0x05,0xA1,0x01,0x85,0x01,0x09,0x30,0x09,0x31,0x09,0x32,0x09,0x35,
+  0x09,0x33,0x09,0x34,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x06,0x81,0x02,0x06,
+  0x00,0xFF,0x09,0x20,0x95,0x01,0x81,0x02,0x05,0x01,0x09,0x39,0x15,0x00,0x25,0x07,
+  0x35,0x00,0x46,0x3B,0x01,0x65,0x14,0x75,0x04,0x95,0x01,0x81,0x42,0x65,0x00,0x05,
+  0x09,0x19,0x01,0x29,0x0F,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x0F,0x81,0x02,0x06,
+  0x00,0xFF,0x09,0x21,0x95,0x0D,0x81,0x02,0x06,0x00,0xFF,0x09,0x22,0x15,0x00,0x26,
+  0xFF,0x00,0x75,0x08,0x95,0x34,0x81,0x02,0x85,0x02,0x09,0x23,0x95,0x2F,0x91,0x02,
+  0x85,0x05,0x09,0x33,0x95,0x28,0xB1,0x02,0x85,0x08,0x09,0x34,0x95,0x2F,0xB1,0x02,
+  0x85,0x09,0x09,0x24,0x95,0x13,0xB1,0x02,0x85,0x0A,0x09,0x25,0x95,0x1A,0xB1,0x02,
+  0x85,0x20,0x09,0x26,0x95,0x3F,0xB1,0x02,0x85,0x21,0x09,0x27,0x95,0x04,0xB1,0x02,
+  0x85,0x22,0x09,0x40,0x95,0x3F,0xB1,0x02,0x85,0x80,0x09,0x28,0x95,0x3F,0xB1,0x02,
+  0x85,0x81,0x09,0x29,0x95,0x3F,0xB1,0x02,0x85,0x82,0x09,0x2A,0x95,0x09,0xB1,0x02,
+  0x85,0x83,0x09,0x2B,0x95,0x3F,0xB1,0x02,0x85,0x84,0x09,0x2C,0x95,0x3F,0xB1,0x02,
+  0x85,0x85,0x09,0x2D,0x95,0x02,0xB1,0x02,0x85,0xA0,0x09,0x2E,0x95,0x01,0xB1,0x02,
+  0x85,0xE0,0x09,0x2F,0x95,0x3F,0xB1,0x02,0x85,0xF0,0x09,0x30,0x95,0x3F,0xB1,0x02,
+  0x85,0xF1,0x09,0x31,0x95,0x3F,0xB1,0x02,0x85,0xF2,0x09,0x32,0x95,0x0F,0xB1,0x02,
+  0x85,0xF4,0x09,0x35,0x95,0x3F,0xB1,0x02,0x85,0xF5,0x09,0x36,0x95,0x03,0xB1,0x02,0xC0
+};
+#define PS5_TOUCH_H 1080
+#define PS5_STATUS_USB 0x1A   // charging + level 10 (~100%)
+static unsigned long g_ps5LastMs=0;
+static Adafruit_USBD_HID g_ps5;
+
+static void ps5Build(uint8_t out[63]){
+  uint32_t b=psButtonsFromSteam(g_in.buttons);
+  bool lTouch=(b&TB_LPADT)||(b&TB_LPADC), rTouch=(b&TB_RPADT)||(b&TB_RPADC);
+  memset(out,0,63);
+  out[0]=swStick(g_in.lx,false); out[1]=swStick(g_in.ly,true);
+  out[2]=swStick(g_in.rx,false); out[3]=swStick(g_in.ry,true);
+  out[4]=g_in.lt; out[5]=g_in.rt;
+  static uint8_t seq=0; out[6]=seq++;
+  out[7]=psHatNibble(b)|psFaceNibble(b);
+  out[8]=psShouldersByte(b);
+  out[9]=((b&TB_STEAM)?0x01:0)|((b&TB_LPADC||b&TB_RPADC)?0x02:0);
+  out[15]=g_in.gx&0xFF; out[16]=g_in.gx>>8;
+  out[17]=g_in.gz&0xFF; out[18]=g_in.gz>>8;
+  out[19]=g_in.gy&0xFF; out[20]=g_in.gy>>8;
+  out[21]=g_in.ax&0xFF; out[22]=g_in.ax>>8;
+  out[23]=g_in.ay&0xFF; out[24]=g_in.ay>>8;
+  out[25]=g_in.az&0xFF; out[26]=g_in.az>>8;
+  uint16_t lx, ly, rx, ry;
+  steamPadsToTouch(b, PS5_TOUCH_H, g_in.lpx, g_in.lpy, g_in.rpx, g_in.rpy, &lx, &ly, &rx, &ry);
+  touchPackPads(out+32, lTouch, rTouch, lx, ly, rx, ry);
+  out[52]=PS5_STATUS_USB;
+}
+
+void Ps5Controller::begin(){
+  USBDevice.setID(0x054C, 0x0CE6);
+  USBDevice.setDeviceVersion(0x0101);   // bumped from 0x0100 for the added wake-mouse interface (Windows caches config by VID:PID:bcdDevice)
+  USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
+  USBDevice.setProductDescriptor("DualSense Wireless Controller");
+  g_ps5.enableOutEndpoint(true);
+  g_ps5.setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
+  g_ps5.setPollInterval(4);
+  g_ps5.begin();
+}
+void Ps5Controller::task(){
+  if(!g_ps5.ready()) return;
+  if(millis()-g_ps5LastMs<USB_STREAM_MS) return; g_ps5LastMs=millis();
+  uint8_t p[63]; ps5Build(p);
+  g_ps5.sendReport(0x01, p, sizeof p);
+}

+ 14 - 0
OpenPuck/mode_ps5.h

@@ -0,0 +1,14 @@
+// mode_ps5.h -- Sony DualSense personality (MODE_PS5): 054C:0CE6 + gyro + split trackpad.
+//
+// Streams the DualSense USB input report (id 0x01) at ~250Hz from task(): sticks, triggers, PS-layout buttons,
+// gyro/accel, and BOTH Steam trackpads mapped onto the single DualSense touchpad (left pad -> left half, right
+// pad -> right half) via the shared touch packers in gamepad_util.
+#pragma once
+#include "controllers.h"
+
+class Ps5Controller : public IController {
+public:
+  void begin() override;
+  void task() override;
+};
+extern Ps5Controller g_ps5Ctl;

+ 73 - 0
OpenPuck/mode_switch_hori.cpp

@@ -0,0 +1,73 @@
+#include "mode_switch_hori.h"
+#include "triton.h"
+#include "gamepad_util.h"
+#include "config.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+
+SwitchHoriController g_switchHori;
+
+// 8-byte input report: [btn_lo][btn_hi][hat][LX][LY][RX][RY][vendor], sticks uint8 center 0x80.
+static const uint8_t SWITCH_HID_DESC[]={
+  0x05,0x01, 0x09,0x05, 0xA1,0x01,           // Usage Page (Generic Desktop), Usage (Game Pad), Collection (App)
+  0x15,0x00, 0x25,0x01, 0x35,0x00, 0x45,0x01, 0x75,0x01, 0x95,0x10,   // 16 x 1-bit buttons
+  0x05,0x09, 0x19,0x01, 0x29,0x10, 0x81,0x02,
+  0x05,0x01, 0x25,0x07, 0x46,0x3B,0x01, 0x75,0x04, 0x95,0x01, 0x65,0x14, 0x09,0x39, 0x81,0x42,  // hat switch (4 bits)
+  0x65,0x00, 0x95,0x01, 0x81,0x01,           // 4-bit padding to a byte
+  0x26,0xFF,0x00, 0x46,0xFF,0x00, 0x09,0x30, 0x09,0x31, 0x09,0x32, 0x09,0x35, 0x75,0x08, 0x95,0x04, 0x81,0x02, // X,Y,Z,Rz
+  0x06,0x00,0xFF, 0x09,0x20, 0x95,0x01, 0x81,0x02,           // vendor byte (input)
+  0x0A,0x21,0x26, 0x95,0x08, 0x91,0x02,                      // vendor (output, 8 bytes; unused)
+  0xC0
+};
+static Adafruit_USBD_HID g_switch;   // HORIPAD gamepad HID interface
+static unsigned long g_swLastMs=0;
+
+// back-paddle code (g_back[]) -> Switch button bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back(Minus) 10=Start(Plus) 11=Guide(Home)
+static uint16_t codeToSwitch(uint8_t c, uint16_t fA,uint16_t fB,uint16_t fX,uint16_t fY){
+  switch(c){ case 1:return fA; case 2:return fB; case 3:return fX; case 4:return fY;
+    case 5:return 0x10; case 6:return 0x20; case 7:return 0x400; case 8:return 0x800;
+    case 9:return 0x100; case 10:return 0x200; case 11:return 0x1000; default:return 0; }
+}
+// HORIPAD/Switch button bits: Y=1 B=2 A=4 X=8 L=10 R=20 ZL=40 ZR=80 Minus=100 Plus=200 LClick=400 RClick=800 Home=1000 Capture=2000
+static void switchBuildHoripad(uint8_t out[8]){
+  uint32_t b=g_in.buttons; uint16_t btn=0;
+  // Mode-switch chord (all 4 back + A/X/Y): don't pass the face press to the console while the back-4 are held.
+  if((b&CHORD_BACK4)==CHORD_BACK4) b &= ~(uint32_t)(TB_A|TB_X|TB_Y);
+  // face buttons with optional A/B + X/Y swap (Nintendo physical-vs-label layout)
+  uint16_t fY = g_abSwap?0x08:0x01, fB = g_abSwap?0x04:0x02, fA = g_abSwap?0x02:0x04, fX = g_abSwap?0x01:0x08;
+  if(b&TB_Y)btn|=fY; if(b&TB_B)btn|=fB; if(b&TB_A)btn|=fA; if(b&TB_X)btn|=fX;
+  if(b&TB_LB)btn|=0x10; if(b&TB_RB)btn|=0x20;                 // L, R
+  // ZL/ZR digital: trip on the analog threshold (activates early) OR the full-press click bit
+  if((g_in.lt>=SW_TRIG_ON)||(b&0x8000000u))btn|=0x40; if((g_in.rt>=SW_TRIG_ON)||(b&0x800000u))btn|=0x80;
+  if(b&TB_MENU)btn|=0x100; if(b&TB_VIEW)btn|=0x200;           // Minus, Plus
+  if(b&TB_L3)btn|=0x400; if(b&TB_R3)btn|=0x800;               // LClick, RClick
+  if(b&TB_STEAM)btn|=0x1000;                                  // Home
+  // back paddles -> configurable mapping (same g_back[] as Xbox: default L4->LB R4->RB L5->L3 R5->R3)
+  if(b&TB_L4)btn|=codeToSwitch(g_back[0],fA,fB,fX,fY); if(b&TB_R4)btn|=codeToSwitch(g_back[1],fA,fB,fX,fY);
+  if(b&TB_L5)btn|=codeToSwitch(g_back[2],fA,fB,fX,fY); if(b&TB_R5)btn|=codeToSwitch(g_back[3],fA,fB,fX,fY);
+  bool u=b&TB_DUP,d=b&TB_DDN,l=b&TB_DLF,r=b&TB_DRT;           // hat: 0=N..7=NW, 8=neutral
+  uint8_t hat=8;
+  if(u&&r)hat=1; else if(r&&d)hat=3; else if(d&&l)hat=5; else if(l&&u)hat=7;
+  else if(u)hat=0; else if(r)hat=2; else if(d)hat=4; else if(l)hat=6;
+  out[0]=btn&0xFF; out[1]=btn>>8; out[2]=hat;
+  out[3]=swStick(g_in.lx,false); out[4]=swStick(g_in.ly,true);  // HID Y is down-positive -> invert
+  out[5]=swStick(g_in.rx,false); out[6]=swStick(g_in.ry,true);
+  out[7]=0;
+}
+
+void SwitchHoriController::begin(){
+  USBDevice.setID(0x0F0D, 0x0092);
+  USBDevice.setDeviceVersion(0x0201);   // bumped from 0x0200 for the added wake-mouse interface (Windows caches config by VID:PID:bcdDevice)
+  USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
+  USBDevice.setProductDescriptor("POKKEN CONTROLLER");
+  g_switch.enableOutEndpoint(true);
+  g_switch.setReportDescriptor(SWITCH_HID_DESC, sizeof SWITCH_HID_DESC);
+  g_switch.setPollInterval(8);
+  g_switch.begin();
+}
+void SwitchHoriController::task(){   // stream the 8-byte HORIPAD report at ~250Hz (no handshake needed)
+  if(!g_switch.ready()) return;
+  if(millis()-g_swLastMs < USB_STREAM_MS) return; g_swLastMs=millis();
+  uint8_t p[8]; switchBuildHoripad(p);
+  g_switch.sendReport(0, p, sizeof p);   // report-id-less descriptor
+}

+ 15 - 0
OpenPuck/mode_switch_hori.h

@@ -0,0 +1,15 @@
+// mode_switch_hori.h -- Switch HORIPAD / Pokkén Tournament Pro Pad personality (MODE_SW_HORI).
+//
+// Targets a REAL Switch console: a plain HID gamepad (HORI 0F0D:0092) the console accepts on plug-in with NO
+// handshake / SPI calibration / encryption -- unlike the genuine Pro Controller, whose USB init the console
+// handles fussily. This is the descriptor every DIY Switch controller copies (the LUFA "Splatoon printer"
+// descriptor). Also recognized by SDL/Steam on PC. 8-byte input report streamed at ~250Hz from task().
+#pragma once
+#include "controllers.h"
+
+class SwitchHoriController : public IController {
+public:
+  void begin() override;
+  void task() override;
+};
+extern SwitchHoriController g_switchHori;

+ 227 - 0
OpenPuck/mode_switch_pro.cpp

@@ -0,0 +1,227 @@
+#include "mode_switch_pro.h"
+#include "triton.h"
+#include "config.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+SwitchProController g_switchPro;
+
+static const uint8_t SWPRO_HID_DESC[]={
+  0x05,0x01,0x15,0x00,0x09,0x04,0xA1,0x01,0x85,0x30,0x05,0x01,0x05,0x09,0x19,0x01,
+  0x29,0x0A,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x0A,0x55,0x00,0x65,0x00,0x81,0x02,
+  0x05,0x09,0x19,0x0B,0x29,0x0E,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x04,0x81,0x02,
+  0x75,0x01,0x95,0x02,0x81,0x03,0x0B,0x01,0x00,0x01,0x00,0xA1,0x00,0x0B,0x30,0x00,
+  0x01,0x00,0x0B,0x31,0x00,0x01,0x00,0x0B,0x32,0x00,0x01,0x00,0x0B,0x35,0x00,0x01,
+  0x00,0x15,0x00,0x27,0xFF,0xFF,0x00,0x00,0x75,0x10,0x95,0x04,0x81,0x02,0xC0,0x0B,
+  0x39,0x00,0x01,0x00,0x15,0x00,0x25,0x07,0x35,0x00,0x46,0x3B,0x01,0x65,0x14,0x75,
+  0x04,0x95,0x01,0x81,0x42,0x05,0x09,0x19,0x0F,0x29,0x12,0x15,0x00,0x25,0x01,0x75,
+  0x01,0x95,0x04,0x81,0x02,0x75,0x08,0x95,0x34,0x81,0x03,0x06,0x00,0xFF,0x85,0x21,
+  0x09,0x01,0x75,0x08,0x95,0x3F,0x81,0x03,0x85,0x81,0x09,0x02,0x75,0x08,0x95,0x3F,
+  0x81,0x03,0x85,0x01,0x09,0x03,0x75,0x08,0x95,0x3F,0x91,0x83,0x85,0x10,0x09,0x04,
+  0x75,0x08,0x95,0x3F,0x91,0x83,0x85,0x80,0x09,0x05,0x75,0x08,0x95,0x3F,0x91,0x83,
+  0x85,0x82,0x09,0x06,0x75,0x08,0x95,0x3F,0x91,0x83,0xC0
+};
+#define JC_BTN_Y      (1u<<0)
+#define JC_BTN_X      (1u<<1)
+#define JC_BTN_B      (1u<<2)
+#define JC_BTN_A      (1u<<3)
+#define JC_BTN_R      (1u<<6)
+#define JC_BTN_ZR     (1u<<7)
+#define JC_BTN_MINUS  (1u<<8)
+#define JC_BTN_PLUS   (1u<<9)
+#define JC_BTN_RSTICK (1u<<10)
+#define JC_BTN_LSTICK (1u<<11)
+#define JC_BTN_HOME   (1u<<12)
+#define JC_BTN_DOWN   (1u<<16)
+#define JC_BTN_UP     (1u<<17)
+#define JC_BTN_RIGHT  (1u<<18)
+#define JC_BTN_LEFT   (1u<<19)
+#define JC_BTN_L      (1u<<22)
+#define JC_BTN_ZL     (1u<<23)
+static uint32_t codeToJc(uint8_t c, uint32_t fA,uint32_t fB,uint32_t fX,uint32_t fY){
+  switch(c){ case 1:return fA; case 2:return fB; case 3:return fX; case 4:return fY;
+    case 5:return JC_BTN_L; case 6:return JC_BTN_R; case 7:return JC_BTN_LSTICK; case 8:return JC_BTN_RSTICK;
+    case 9:return JC_BTN_MINUS; case 10:return JC_BTN_PLUS; case 11:return JC_BTN_HOME; default:return 0; }
+}
+static unsigned long g_swProLastMs=0;
+static Adafruit_USBD_HID g_swPro;
+static uint8_t g_swProReportMode=0;   // 0 until the host's subcommand 0x03 selects 0x30 -> THEN we stream input
+static const uint8_t JC_MAC[6]={0x7C,0xBB,0x8A,0x00,0x00,0x01};   // synthetic but stable controller MAC
+static int jcStick12(int16_t v, bool inv){   // steam int16 (center 0) -> 12-bit (center 0x800), clamped
+  int a = 2048 + (inv ? -((int)v>>4) : ((int)v>>4));
+  return a<0?0:(a>4095?4095:a);
+}
+static void jcPackStick(uint8_t s[3], int16_t x, int16_t y){   // pack two 12-bit axes into 3 bytes (both normal; Switch Y polarity is opposite DS4)
+  int X=jcStick12(x,false), Y=jcStick12(y,false);
+  s[0]=(uint8_t)(X&0xFF);
+  s[1]=(uint8_t)(((Y&0x0F)<<4)|((X>>8)&0x0F));
+  s[2]=(uint8_t)((Y>>4)&0xFF);
+}
+// Standard input-report prefix [0..11] (timer, battery/conn, 3 button bytes, both packed sticks, vibrator),
+// shared by the streamed 0x30 report and the 0x21 subcommand-reply reports the host reads during init.
+static uint8_t g_jcTimer=0;
+static void jcInputPrefix(uint8_t* out){
+  uint32_t b=g_in.buttons;
+  if((b&CHORD_BACK4)==CHORD_BACK4) b&=~(uint32_t)(TB_A|TB_B|TB_X|TB_Y);
+  uint32_t fA=g_abSwap?JC_BTN_B:JC_BTN_A, fB=g_abSwap?JC_BTN_A:JC_BTN_B;
+  uint32_t fX=g_abSwap?JC_BTN_Y:JC_BTN_X, fY=g_abSwap?JC_BTN_X:JC_BTN_Y;
+  uint32_t jc=0;
+  if(b&TB_Y)jc|=fY; if(b&TB_B)jc|=fB; if(b&TB_A)jc|=fA; if(b&TB_X)jc|=fX;
+  if(b&TB_LB)jc|=JC_BTN_L; if(b&TB_RB)jc|=JC_BTN_R;
+  if((g_in.lt>=SW_TRIG_ON)||(b&0x8000000u))jc|=JC_BTN_ZL; if((g_in.rt>=SW_TRIG_ON)||(b&0x800000u))jc|=JC_BTN_ZR;
+  if(b&TB_VIEW)jc|=JC_BTN_PLUS; if(b&TB_MENU)jc|=JC_BTN_MINUS;
+  if(b&TB_L3)jc|=JC_BTN_LSTICK; if(b&TB_R3)jc|=JC_BTN_RSTICK;
+  if(b&TB_STEAM)jc|=JC_BTN_HOME;
+  if(b&TB_DDN)jc|=JC_BTN_DOWN; if(b&TB_DUP)jc|=JC_BTN_UP;
+  if(b&TB_DRT)jc|=JC_BTN_RIGHT; if(b&TB_DLF)jc|=JC_BTN_LEFT;
+  if(b&TB_L4)jc|=codeToJc(g_back[0],fA,fB,fX,fY); if(b&TB_R4)jc|=codeToJc(g_back[1],fA,fB,fX,fY);
+  if(b&TB_L5)jc|=codeToJc(g_back[2],fA,fB,fX,fY); if(b&TB_R5)jc|=codeToJc(g_back[3],fA,fB,fX,fY);
+  out[0]=g_jcTimer++;
+  out[1]=0x81;   // full battery + USB powered (hid-nintendo bat_con)
+  out[2]=(uint8_t)(jc); out[3]=(uint8_t)(jc>>8); out[4]=(uint8_t)(jc>>16);
+  jcPackStick(out+5, g_in.lx, g_in.ly);
+  jcPackStick(out+8, g_in.rx, g_in.ry);
+  out[11]=0;
+}
+static void switchProBuild(uint8_t out[63]){
+  memset(out,0,63);
+  jcInputPrefix(out);
+  for(int k=0;k<3;k++){
+    int o=12+k*12;
+    out[o+0]=g_in.ax&0xFF; out[o+1]=g_in.ax>>8;
+    out[o+2]=g_in.ay&0xFF; out[o+3]=g_in.ay>>8;
+    out[o+4]=g_in.az&0xFF; out[o+5]=g_in.az>>8;
+    out[o+6]=g_in.gx&0xFF; out[o+7]=g_in.gx>>8;
+    out[o+8]=g_in.gy&0xFF; out[o+9]=g_in.gy>>8;
+    out[o+10]=g_in.gz&0xFF; out[o+11]=g_in.gz>>8;
+  }
+}
+// --- Canonical factory SPI dumps the host reads for calibration. Neutral IMU + centered sticks so a fresh
+// "device" calibrates sane; user-cal regions (0x80xx) read 0xFF so the host falls back to these factory blocks.
+static const uint8_t SPI_IMU_CAL[24]={0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x40,0x00,0x40,0x00,0x40,
+                                      0x00,0x00,0x00,0x00,0x00,0x00, 0x3B,0x34,0x3B,0x34,0x3B,0x34};
+static const uint8_t SPI_PARAMS1[24]={0x50,0xFD,0x00,0x00,0xC6,0x0F, 0x0F,0x30,0x61,0x96,0x30,0xF3,
+                                      0xD4,0x14,0x54,0x41,0x15,0x54, 0xC7,0x79,0x9C,0x33,0x36,0x63};
+static const uint8_t SPI_PARAMS2[18]={0x0F,0x30,0x61,0x96,0x30,0xF3, 0xD4,0x14,0x54,0x41,0x15,0x54,
+                                      0xC7,0x79,0x9C,0x33,0x36,0x63};
+static const uint8_t SPI_COLOR[13]={0x32,0x32,0x32, 0xE6,0xE6,0xE6, 0x32,0x32,0x32, 0x32,0x32,0x32, 0xFF};
+static uint8_t g_spiStickCal[18];   // built at boot: left[9]+right[9] factory stick calibration (packed 12-bit)
+static void jcPack12(uint8_t* o9, const uint16_t v[6]){   // pack 6 12-bit values into 9 bytes (Switch stick-cal format)
+  o9[0]=v[0]&0xFF;                       o9[1]=((v[1]&0x0F)<<4)|((v[0]>>8)&0x0F); o9[2]=(v[1]>>4)&0xFF;
+  o9[3]=v[2]&0xFF;                       o9[4]=((v[3]&0x0F)<<4)|((v[2]>>8)&0x0F); o9[5]=(v[3]>>4)&0xFF;
+  o9[6]=v[4]&0xFF;                       o9[7]=((v[5]&0x0F)<<4)|((v[4]>>8)&0x0F); o9[8]=(v[5]>>4)&0xFF;
+}
+static void jcBuildStickCal(){
+  const uint16_t C=2048, R=1800;   // center, +/- range per axis
+  uint16_t L[6]={R,R, C,C, R,R};   // left  order: max(x,y), center(x,y), min(x,y)
+  uint16_t Rr[6]={C,C, R,R, R,R};  // right order: center(x,y), min(x,y), max(x,y)
+  jcPack12(&g_spiStickCal[0], L); jcPack12(&g_spiStickCal[9], Rr);
+}
+static void spiRead(uint32_t addr, uint8_t len, uint8_t* dst){
+  for(uint8_t i=0;i<len;i++){
+    uint32_t a=addr+i; uint8_t v=0xFF;
+    if     (a>=0x6020 && a<0x6020+24) v=SPI_IMU_CAL[a-0x6020];
+    else if(a>=0x603D && a<0x603D+18) v=g_spiStickCal[a-0x603D];
+    else if(a>=0x6050 && a<0x6050+13) v=SPI_COLOR[a-0x6050];
+    else if(a>=0x6080 && a<0x6080+24) v=SPI_PARAMS1[a-0x6080];
+    else if(a>=0x6098 && a<0x6098+18) v=SPI_PARAMS2[a-0x6098];
+    dst[i]=v;   // unmapped (incl. user-cal 0x80xx) -> 0xFF = "blank flash" -> host uses factory blocks above
+  }
+}
+// Reply FIFO: the host's handshake/subcommand reports arrive in the USB ISR (jcSet); we enqueue the canonical
+// 0x81/0x21 reply and drain it from task() where sendReport is safe. SPSC ring, volatile indices. Every reply
+// is sent as a full 63-byte report (64 with the id): macOS IOHIDManager silently DROPS any input report shorter
+// than the descriptor-declared length (0x3F), so short 0x81/0x21 replies never reached the host.
+#define JC_REPLEN 63
+struct JcRep { uint8_t rid; uint8_t data[JC_REPLEN]; };
+#define JCQ_N 8
+static volatile uint8_t g_jcQh=0, g_jcQt=0;
+static JcRep g_jcQ[JCQ_N];
+static void jcEnq(uint8_t rid, const uint8_t* d, uint8_t len){
+  uint8_t nt=(uint8_t)((g_jcQt+1)%JCQ_N);
+  if(nt==g_jcQh) return;                 // full -> drop; the host re-requests on timeout
+  if(len>JC_REPLEN) len=JC_REPLEN;
+  g_jcQ[g_jcQt].rid=rid;
+  memset(g_jcQ[g_jcQt].data,0,JC_REPLEN); memcpy(g_jcQ[g_jcQt].data,d,len);   // zero-pad to full report length
+  g_jcQt=nt;
+}
+// Build the 0x21 reply (standard input prefix + ACK + echoed subcommand id + reply data) for a report-0x01 subcommand.
+static void jcSubcmd(uint8_t sub, const uint8_t* args, uint16_t alen){
+  uint8_t p[JC_REPLEN]; memset(p,0,sizeof p);
+  jcInputPrefix(p);
+  p[13]=sub;
+  switch(sub){
+    case 0x02:                         // request device info
+      p[12]=0x82;
+      p[14]=0x04; p[15]=0x21;          // firmware version (4.33)
+      p[16]=0x03;                      // controller type: Pro Controller
+      p[17]=0x02;                      // fixed
+      memcpy(&p[18], JC_MAC, 6);
+      p[24]=0x01;                      // colors stored in SPI (0x6050)
+      p[25]=0x02;                      // fixed
+      break;
+    case 0x10: {                       // SPI flash read -> echo [addr][len] then the data
+      if(alen<5){ p[12]=0x80; break; }
+      uint32_t a=(uint32_t)args[0]|((uint32_t)args[1]<<8)|((uint32_t)args[2]<<16)|((uint32_t)args[3]<<24);
+      uint8_t rl=args[4]; if(rl>0x1D) rl=0x1D;
+      p[12]=0x90;
+      p[14]=args[0]; p[15]=args[1]; p[16]=args[2]; p[17]=args[3]; p[18]=rl;
+      spiRead(a, rl, &p[19]);
+      break; }
+    case 0x03:                         // set input report mode (0x30 = standard full) -> begin streaming
+      if(alen>=1 && args[0]==0x30) g_swProReportMode=0x30;
+      p[12]=0x80; break;
+    case 0x04: p[12]=0x83; break;      // trigger buttons elapsed time
+    case 0x21: p[12]=0xA0; break;      // set NFC/IR config
+    default:   p[12]=0x80; break;      // 0x06/0x08/0x30/0x38/0x40/0x41/0x48/... generic positive ACK
+  }
+  jcEnq(0x21, p, JC_REPLEN);
+}
+// USB-ISR callback for the Pro Controller's OUT endpoint. Data arrives via the interrupt OUT endpoint, where
+// this TinyUSB core passes report_id=0 and leaves the real report id in buffer[0] (it does NOT strip it). Also
+// accept the control SET_REPORT shape (id already in `rid`) for safety. report 0x80 = USB handshake; 0x01 = subcommand.
+static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const* b, uint16_t n){
+  if(type!=HID_REPORT_TYPE_OUTPUT || n<1) return;
+  uint8_t id; const uint8_t* p; uint16_t pn;
+  if(rid==0){ id=b[0]; p=b+1; pn=(uint16_t)(n-1); }   // EP-OUT: id is the first payload byte
+  else      { id=rid;  p=b;   pn=n;               }   // control SET_REPORT: id already split out
+  if(id==0x80){
+    if(pn<1) return;
+    if(p[0]==0x01){ uint8_t d[9]={0x01,0x00,0x03,JC_MAC[0],JC_MAC[1],JC_MAC[2],JC_MAC[3],JC_MAC[4],JC_MAC[5]}; jcEnq(0x81,d,9); } // device type + MAC
+    else if(p[0]==0x02){ uint8_t d[1]={0x02}; jcEnq(0x81,d,1); }   // handshake
+    else if(p[0]==0x03){ uint8_t d[1]={0x03}; jcEnq(0x81,d,1); }   // set baudrate
+    // 0x04 force-USB / 0x05 enable-timeout / 0x06 reset: no reply expected
+    return;
+  }
+  if(id==0x01){                        // [timer][rumble x8][subcmd][args...]
+    if(pn<10) return;
+    jcSubcmd(p[9], p+10, (pn>10)?(uint16_t)(pn-10):0);
+  }
+  // report 0x10 (rumble only) / 0x82: ignored
+}
+
+void SwitchProController::begin(){
+  USBDevice.setID(0x057E, 0x2009);
+  USBDevice.setDeviceVersion(0x0211);   // bumped from 0x0210 for the added wake-mouse interface (Windows caches config by VID:PID:bcdDevice)
+  USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
+  USBDevice.setProductDescriptor("Pro Controller");
+  jcBuildStickCal();
+  g_swPro.enableOutEndpoint(true);
+  g_swPro.setReportCallback(NULL, jcSet);   // answer the Nintendo USB handshake + subcommands (else Steam never binds it)
+  g_swPro.setReportDescriptor(SWPRO_HID_DESC, sizeof SWPRO_HID_DESC);
+  g_swPro.setPollInterval(4);
+  g_swPro.begin();
+}
+void SwitchProController::task(){
+  if(!g_swPro.ready()) return;
+  if(g_jcQh!=g_jcQt){                                   // drain handshake/subcommand replies first (ordered)
+    JcRep* r=&g_jcQ[g_jcQh];
+    if(g_swPro.sendReport(r->rid, r->data, JC_REPLEN)) g_jcQh=(uint8_t)((g_jcQh+1)%JCQ_N);
+    return;                                             // one report per call; the rest next loop
+  }
+  if(g_swProReportMode!=0x30) return;                   // not until the host has finished init + selected 0x30
+  if(millis()-g_swProLastMs<USB_STREAM_MS) return; g_swProLastMs=millis();
+  uint8_t p[63]; switchProBuild(p);
+  g_swPro.sendReport(0x30, p, sizeof p);
+}

+ 17 - 0
OpenPuck/mode_switch_pro.h

@@ -0,0 +1,17 @@
+// mode_switch_pro.h -- Nintendo Switch Pro Controller personality (MODE_SW_PRO): 057E:2009, report 0x30 + IMU.
+//
+// Unlike the HORIPAD, the genuine Pro Controller requires a USB handshake + subcommand exchange (device info,
+// SPI calibration reads, set-report-mode) before any host (or Steam) will bind it. jcSet() answers that
+// handshake from the USB ISR by enqueuing canonical 0x81/0x21 replies; task() drains them (and, once the host
+// selects report mode 0x30, streams the standard input report with gyro). Linux hid-nintendo hardcodes the
+// 0x30 layout, so we follow it exactly. macOS drops input reports shorter than the descriptor length, so every
+// reply is padded to the full 63 bytes.
+#pragma once
+#include "controllers.h"
+
+class SwitchProController : public IController {
+public:
+  void begin() override;
+  void task() override;
+};
+extern SwitchProController g_switchPro;

+ 177 - 0
OpenPuck/mode_xinput.cpp

@@ -0,0 +1,177 @@
+#include "mode_xinput.h"
+#include "triton.h"
+#include "config.h"
+#include "haptics.h"
+#include "bonds.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+// custom class-driver API (usbd_class_driver_t, usbd_edpt_*) for the XInput interface
+extern "C" {
+#include "device/usbd_pvt.h"
+}
+
+XboxController g_xboxCtl;
+
+// XInput button bits
+enum { XB_DUP=0x0001,XB_DDOWN=0x0002,XB_DLEFT=0x0004,XB_DRIGHT=0x0008, XB_START=0x0010,XB_BACK=0x0020,
+       XB_L3=0x0040,XB_R3=0x0080, XB_LB=0x0100,XB_RB=0x0200,XB_GUIDE=0x0400, XB_A=0x1000,XB_B=0x2000,XB_X=0x4000,XB_Y=0x8000 };
+
+// ===================== XInput custom TinyUSB class driver =====================
+// Custom class driver (registered via usbd_app_driver_get_cb) + an Adafruit_USBD_Interface subclass that emits
+// the interface + 0x21-blob + 2 endpoints. (Verified against Adafruit nRF52 1.7.0 / TinyUSB 0.18.)
+#define XINPUT_DESC_LEN (9 + 17 + 7 + 7)   // interface(9)+vendor0x21(17)+IN ep(7)+OUT ep(7) = 40
+static uint8_t g_xiItf=0xFF, g_xiEpIn=0, g_xiEpOut=0;
+static uint8_t g_xiInBuf[32], g_xiOutBuf[32];
+
+// legacy XInput rumble strength from the host's OUT packet (0=off); drives the haptic relay
+static volatile uint8_t g_rumble = 0;
+static volatile unsigned long g_rumbleMs = 0;   // millis of last rumble OUT packet (stuck-rumble watchdog)
+#define RUMBLE_STUCK_MS 2500u   // release a held rumble if no OUT packet refreshes it for this long (covers a lost stop without cutting normal short rumbles)
+
+static void xi_init(void){}
+static bool xi_deinit(void){return true;}
+static void xi_reset(uint8_t rhport){(void)rhport; g_xiEpIn=g_xiEpOut=0;}
+static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const* itf, uint16_t max_len){
+  if(!(itf->bInterfaceClass==0xFF && itf->bInterfaceSubClass==0x5D && itf->bInterfaceProtocol==0x01)) return 0;
+  g_xiItf=itf->bInterfaceNumber;
+  uint8_t const* p=(uint8_t const*)itf; uint8_t const* end=p+max_len; uint16_t used=itf->bLength; p+=itf->bLength;
+  uint8_t opened=0;
+  while(p<end && opened<itf->bNumEndpoints){
+    uint8_t blen=p[0], btype=p[1];
+    if(btype==TUSB_DESC_ENDPOINT){ tusb_desc_endpoint_t const* ep=(tusb_desc_endpoint_t const*)p;
+      usbd_edpt_open(rhport,ep);
+      if(tu_edpt_dir(ep->bEndpointAddress)==TUSB_DIR_IN) g_xiEpIn=ep->bEndpointAddress; else g_xiEpOut=ep->bEndpointAddress;
+      opened++; }
+    used+=blen; p+=blen;
+  }
+  if(g_xiEpOut) usbd_edpt_xfer(rhport,g_xiEpOut,g_xiOutBuf,sizeof g_xiOutBuf);  // arm OUT (rumble/LED)
+  return used;
+}
+static bool xi_ctrl(uint8_t rhport,uint8_t stage,tusb_control_request_t const* req){(void)rhport;(void)req; return stage!=CONTROL_STAGE_SETUP;}
+static bool xi_xfer(uint8_t rhport,uint8_t ep,xfer_result_t res,uint32_t n){(void)res;
+  if(ep==g_xiEpOut){
+    // XInput rumble packet: [00][08][00][bigMotor][smallMotor][00][00][00]; LED pkt is [01][03][led]
+    if(n>=5 && g_xiOutBuf[0]==0x00 && g_xiOutBuf[1]==0x08){
+      uint8_t big=g_xiOutBuf[3], sml=g_xiOutBuf[4]; g_rumble = big>sml?big:sml; g_rumbleMs = millis();   // stamp for the stuck-rumble watchdog
+    }
+    usbd_edpt_xfer(rhport,g_xiEpOut,g_xiOutBuf,sizeof g_xiOutBuf);    // re-arm OUT
+  }
+  return true; }
+static const usbd_class_driver_t g_xiDriver = {
+#if CFG_TUSB_DEBUG>=2
+  .name="XINPUT",
+#endif
+  .init=xi_init, .deinit=xi_deinit, .reset=xi_reset, .open=xi_open,
+  .control_xfer_cb=xi_ctrl, .xfer_cb=xi_xfer, .sof=NULL };
+extern "C" const usbd_class_driver_t* usbd_app_driver_get_cb(uint8_t* count){ *count=1; return &g_xiDriver; }
+
+class Adafruit_USBD_XInput : public Adafruit_USBD_Interface {
+public:
+  uint16_t getInterfaceDescriptor(uint8_t, uint8_t* buf, uint16_t bufsize) override {
+    if(!buf) return XINPUT_DESC_LEN; if(bufsize<XINPUT_DESC_LEN) return 0;
+    uint8_t itfnum=TinyUSBDevice.allocInterface(1);
+    uint8_t epin=TinyUSBDevice.allocEndpoint(TUSB_DIR_IN), epout=TinyUSBDevice.allocEndpoint(TUSB_DIR_OUT);
+    const uint8_t t[XINPUT_DESC_LEN]={
+      9,TUSB_DESC_INTERFACE,itfnum,0x00,0x02,0xFF,0x5D,0x01,_strid,
+      0x11,0x21,0x00,0x01,0x01,0x25,epin,0x14,0x00,0x00,0x00,0x00,0x13,epout,0x08,0x00,0x00,
+      7,TUSB_DESC_ENDPOINT,epin, TUSB_XFER_INTERRUPT,U16_TO_U8S_LE(0x20),1,   // bInterval 1ms (1000Hz) so the RF rate is the only limit
+      7,TUSB_DESC_ENDPOINT,epout,TUSB_XFER_INTERRUPT,U16_TO_U8S_LE(0x20),8 };
+    memcpy(buf,t,XINPUT_DESC_LEN); return XINPUT_DESC_LEN;
+  }
+  bool begin(){ return TinyUSBDevice.addInterface(*this); }
+};
+static Adafruit_USBD_XInput g_xinput;
+static void xinputSend(uint16_t buttons,uint8_t lt,uint8_t rt,int16_t lx,int16_t ly,int16_t rx,int16_t ry){
+  if(!tud_mounted() || g_xiEpIn==0 || usbd_edpt_busy(0,g_xiEpIn)) return;
+  uint8_t* r=g_xiInBuf;
+  r[0]=0x00; r[1]=0x14; r[2]=buttons&0xFF; r[3]=buttons>>8; r[4]=lt; r[5]=rt;
+  r[6]=lx&0xFF; r[7]=lx>>8; r[8]=ly&0xFF; r[9]=ly>>8; r[10]=rx&0xFF; r[11]=rx>>8; r[12]=ry&0xFF; r[13]=ry>>8;
+  memset(r+14,0,6);
+  if(usbd_edpt_claim(0,g_xiEpIn)){ if(!usbd_edpt_xfer(0,g_xiEpIn,r,20)) usbd_edpt_release(0,g_xiEpIn); }
+}
+
+// ===================== right-pad mouse interface =====================
+static const uint8_t MOUSE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
+static Adafruit_USBD_HID g_mouse;   // Xbox-mode mouse interface (right trackpad)
+
+// ===================== report 0x45 -> XInput + mouse =====================
+// button code (g_back[], g_abSwap targets) -> legacy XInput bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide
+static uint16_t codeToXB(uint8_t c){
+  switch(c){ case 1:return XB_A; case 2:return XB_B; case 3:return XB_X; case 4:return XB_Y;
+    case 5:return XB_LB; case 6:return XB_RB; case 7:return XB_L3; case 8:return XB_R3;
+    case 9:return XB_BACK; case 10:return XB_START; case 11:return XB_GUIDE; default:return 0; }
+}
+static void rfXboxGamepad(const uint8_t* r){
+  uint32_t b=btnsOf(r);
+  uint16_t btn=0;                                   // map Triton buttons -> XInput button word
+  if(b&TB_DUP)btn|=XB_DUP;   if(b&TB_DDN)btn|=XB_DDOWN; if(b&TB_DLF)btn|=XB_DLEFT; if(b&TB_DRT)btn|=XB_DRIGHT;
+  if(b&TB_VIEW)btn|=XB_START; if(b&TB_MENU)btn|=XB_BACK; if(b&TB_STEAM)btn|=XB_GUIDE;
+  if(b&TB_LB)btn|=XB_LB;  if(b&TB_RB)btn|=XB_RB;
+  if(b&TB_L3)btn|=XB_L3;  if(b&TB_R3)btn|=XB_R3;
+  // face buttons, with optional A/B + X/Y swap (Nintendo layout)
+  uint16_t fA=g_abSwap?XB_B:XB_A, fB=g_abSwap?XB_A:XB_B, fX=g_abSwap?XB_Y:XB_X, fY=g_abSwap?XB_X:XB_Y;
+  if(b&TB_A)btn|=fA; if(b&TB_B)btn|=fB; if(b&TB_X)btn|=fX; if(b&TB_Y)btn|=fY;
+  // back paddles -> configurable mapping (default L4->LB, R4->RB, L5->L3, R5->R3)
+  if(b&TB_L4)btn|=codeToXB(g_back[0]); if(b&TB_R4)btn|=codeToXB(g_back[1]);
+  if(b&TB_L5)btn|=codeToXB(g_back[2]); if(b&TB_R5)btn|=codeToXB(g_back[3]);
+  uint8_t lt=trigU8(u16off(r,4)), rt=trigU8(u16off(r,6));   // triggers u16 (half-scale) -> full-range u8
+  xinputSend(btn, lt, rt, (int16_t)s16off(r,8), (int16_t)s16off(r,10),   // L stick X/Y
+                          (int16_t)s16off(r,12), (int16_t)s16off(r,14)); // R stick X/Y
+}
+// Right pad -> mouse, riding ALONGSIDE the XInput gamepad on a second HID-mouse interface. Same glide model as
+// Lizard's right pad. Purely a USB-side translation -- touches NOTHING about the RF poll or relay. RPad click =
+// left button, LPad click = right.
+static void rfXboxMouse(const uint8_t* r){
+  uint32_t b=btnsOf(r);
+  static int prx=0,pry=0; static bool prt=false; static float vx=0,vy=0,rmx=0,rmy=0; static uint8_t pmb=0;
+  bool rtouch=b&TB_RPADT; int rx=s16off(r,22), ry=s16off(r,24);
+  if(rtouch){ if(prt){ vx+=(rx-prx); vy+=(ry-pry); } prx=rx; pry=ry; } prt=rtouch;
+  float mxf=vx/(float)(g_mDiv*10)+rmx, myf=-(vy/(float)(g_mDiv*10))+rmy;   // Y inverted for screen coords
+  int dx=(int)mxf, dy=(int)myf; rmx=mxf-dx; rmy=myf-dy;                    // sub-pixel carry
+  if(dx>127)dx=127; if(dx<-127)dx=-127; if(dy>127)dy=127; if(dy<-127)dy=-127;
+  float f=g_mFric/100.0f; vx*=f; vy*=f; if(vx>-1&&vx<1)vx=0; if(vy>-1&&vy<1)vy=0;   // friction = glide/decay
+  uint8_t mb=((b&TB_RPADC)?1:0)|((b&TB_LPADC)?2:0);  // RPad click = left, LPad click = right
+  if(dx||dy||mb!=pmb){ pmb=mb;
+    hid_mouse_report_t m; m.buttons=mb; m.x=(int8_t)dx; m.y=(int8_t)dy; m.wheel=0; m.pan=0;
+    if(g_mouse.ready()) g_mouse.sendReport(0,&m,sizeof m);
+  }
+}
+
+// ===================== IController =====================
+void XboxController::begin(){
+  g_rumble=0;
+  if (g_usbMode == MODE_XBOX_ONE) {
+    USBDevice.setID(0x045E, 0x02D1);   // Xbox One (original) identity
+    USBDevice.setDeviceVersion(0x0500);
+    USBDevice.setProductDescriptor("Xbox One Controller");
+  } else {
+    USBDevice.setID(0x045E, 0x028E);   // Xbox 360 wired identity
+    // bcdDevice 0x0115 (was 0x0114): Windows caches the config descriptor by VID:PID:bcdDevice, so any change to
+    // this mode's interfaces (here: the wake-mouse interface) MUST bump bcdDevice or Windows serves a stale
+    // descriptor and the change is invisible (no manual Device-Manager cache-clear should ever be needed).
+    USBDevice.setDeviceVersion(0x0115);
+    USBDevice.setProductDescriptor("Controller");
+  }
+  USBDevice.setManufacturerDescriptor("Microsoft");
+  g_xinput.setStringDescriptor("Controller"); g_xinput.begin();   // XInput vendor interface (FF/5D/01) -> MI_00
+  g_mouse.setStringDescriptor("OpenPuck Mouse");
+  g_mouse.setBootProtocol(HID_ITF_PROTOCOL_MOUSE);
+  g_mouse.setReportDescriptor(MOUSE_HID_DESC, sizeof MOUSE_HID_DESC); g_mouse.setPollInterval(1); g_mouse.begin();
+}
+void XboxController::onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen){
+  (void)fresh; (void)bodyTlen;
+  rfXboxGamepad(rep); rfXboxMouse(rep);   // standard gamepad + right-pad mouse (2nd interface)
+}
+// Legacy XInput rumble -> relay the haptic (0x82 [01 01 gain]) to the controller while the host commands it,
+// re-queued ~40/s like Steam's glide haptic so it sustains. Not active in the current HID Xbox presentation.
+void XboxController::task(){
+  if (g_rumble && millis()-g_rumbleMs > RUMBLE_STUCK_MS) g_rumble=0;
+  if (g_rumble && !relayPending() && g_connSlot>=0) {
+    static unsigned long lastRumble=0;
+    if (millis()-lastRumble>=25) { lastRumble=millis();
+      uint8_t gain = g_rumble<0x30?0x30:g_rumble;   // floor so low rumble is still feelable
+      uint8_t pl[3]={0x01,0x01,gain}; relayEnqueue(0x82,pl,3);
+    }
+  }
+}

+ 18 - 0
OpenPuck/mode_xinput.h

@@ -0,0 +1,18 @@
+// mode_xinput.h -- Xbox XInput personality (MODE_XBOX/MODE_XBOX_ONE): gamepad + right-pad mouse.
+//
+// Real Xbox 360 pads are NOT HID: they use a vendor interface (class 0xFF / sub 0x5D / proto 0x01) carrying a
+// 20-byte XInput report. We register a custom TinyUSB class driver for that interface (see mode_xinput.cpp)
+// plus a second boot-mouse interface for the right trackpad. Steam/SDL/Windows xusb bind by VID/PID
+// (045E:028E) + the FF/5D/01 interface. The host's OUT endpoint carries rumble, relayed to the controller as
+// a haptic by task().
+#pragma once
+#include "controllers.h"
+#include <stdint.h>
+
+class XboxController : public IController {
+public:
+  void begin() override;
+  void onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen) override;
+  void task() override;   // legacy XInput rumble -> haptic relay + stuck-rumble watchdog
+};
+extern XboxController g_xboxCtl;

+ 291 - 0
OpenPuck/puck_hid.cpp

@@ -0,0 +1,291 @@
+#include "puck_hid.h"
+#include "bonds.h"
+#include "config.h"
+#include "identity.h"
+#include "haptics.h"
+#include "rf_link.h"
+#include "triton.h"
+#include "mode_lizard.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+uint8_t g_fwdNewOnly = 1;
+SteamPuckController g_steamPuck;
+
+// ---- cloned puck HID report descriptor (verbatim): mouse(0x40)+keyboard(0x41)+vendor(FF00) with the 63-byte
+//      FEATURE command reports on report id 1/2. Each of the 4 interfaces uses this. ----
+static const uint8_t PUCK_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 hid[NSLOT];
+
+// ===================== seamless LIZARD decision =====================
+// Steam, while running, re-sends settings report 0x87 (lizard-off) every ~3s as a heartbeat (captured on HW),
+// and ANY OUTPUT report likewise stamps g_steamAliveMs. When the heartbeat stops we fall back to lizard, so the
+// controller drives desktop keyboard+mouse whenever Steam isn't running. MODE_LIZARD forces lizard always.
+static unsigned long g_steamAliveMs = 0;   // millis of last Steam OUTPUT/settings write; 0 at boot => lizard until Steam appears
+// Fall back to lizard this long after Steam's ~3s settings heartbeat stops. Keep >2x the cadence (7s): the
+// haptic relay is gated by !lizardActive(), so a shorter window lets a single jittered/delayed heartbeat flip
+// lizard mid-session while Steam is still running -> a haptic that arrives in that window gets gated out
+// (dropped/missing haptic). 7s tolerates one missed beat. (This matches main; do not shorten without a lizard
+// trigger that doesn't depend on the heartbeat timeout.)
+#define LIZARD_WD_MS 7000u
+static bool g_autoLizard = true;            // master switch; false => Steam mode always forwards 0x45
+// Single source of truth, shared by the USB input path AND the haptic relay gate: if we ever relay a 0x82 to
+// the controller while presenting lizard (Steam isn't reading 0x45 back), Steam loops the same haptic -> buzz.
+static inline bool steamDrivingGamepad(){ return g_steamAliveMs && (millis()-g_steamAliveMs < LIZARD_WD_MS); }
+static inline bool lizardActive(){ return modeIsPuck(g_usbMode) && (g_usbMode==MODE_LIZARD || (g_autoLizard && !steamDrivingGamepad())); }
+// Right after the host resumes from suspend, MUTE input forwarding briefly. Otherwise the controller's input
+// in that instant (a trackpad click/trigger, or residual button state from the wake gesture) gets forwarded
+// as a real click/keypress into the just-woken desktop -- which was activating the highlighted Start tile
+// (Edge) and closing the Start menu on every wake. Set when task() sees the suspended->active transition.
+static unsigned long g_resumeMs = 0;
+#define POST_RESUME_MUTE_MS 1500u
+
+// ===================== puck feature command channel =====================
+// `slot` is the interface index (interface N == bond slot N).
+static void handleSet(int slot, uint8_t rid, hid_report_type_t type, uint8_t const *b, uint16_t n) {
+  if (type == HID_REPORT_TYPE_OUTPUT) {   // Steam OUTPUT reports 0x80-0x89. The haptic/actuator reports (0x80-0x86)
+    // are relayed to the controller, and ONLY when they arrive on the CONNECTED slot's interface. We have one
+    // controller but expose 4 puck slots, and a report aimed at a DIFFERENT slot made the controller buzz at
+    // random -> that is what the slot gate below fixes. (This used to also clamp to 0x82-ONLY, which silently
+    // dropped the ping / grip / test haptics: those ride other report IDs such as 0x85/0x86, so they never
+    // reached the controller.) The 63-byte settings/config reports 0x87/0x88/0x89 are NOT haptics and are not
+    // pushed here -- 0x87 (lizard-off/settings) reaches the controller through the feature-0x01 passthrough path.
+    if (rid >= 0x80 && rid <= 0x89) {
+      hapLogAdd((uint8_t)slot, rid, b, n);   // capture ALL OUTPUT reports (even un-relayed) for the 'H' dump
+      g_steamAliveMs = millis();   // ANY Steam OUTPUT report (not just the 0x87 heartbeat) means Steam is present and
+                                   // driving -> leave lizard for gamepad NOW, so a haptic that arrives before the
+                                   // first 0x87 doesn't get relayed while we're still presenting lizard (-> buzz loop).
+    }
+    // Post-resume mute also gates haptics: while onReport45 is muted Steam reads NO 0x45 back, which is the
+    // exact condition under which Steam loops the same haptic command (-> connect/wake buzz loop). Same logic
+    // as the lizard gate; the mute is short (1.5s), so a dropped wake chime is the cheap end of the trade.
+    bool muted = g_resumeMs && millis()-g_resumeMs < POST_RESUME_MUTE_MS;
+    if (rid >= 0x80 && rid <= 0x86 && n >= 1 && hapticRelaySlotOk(slot) && !lizardActive() && !muted) {  // wrap as a SET sub-TLV like the report-01 path
+      if (!haptic82Blocked()) {
+        relayEnqueue(rid, b, (uint8_t)(n > RELAY_MAXP ? RELAY_MAXP : n));
+        // Track on/off from what was actually RELAYED (= the controller's believed state), not from every host
+        // report: a BLOCKED stop must leave "on" set (the controller may be latched -> reconnect stop-burst),
+        // and a blocked ON must not set it (nothing reached the controller -> no spurious burst clicks).
+        if (rid == 0x82) haptic82HostReport(b, n);
+      }
+    }
+    if (Serial.availableForWrite() > 80) {                      // log so we can see what Steam actually sends (e.g. glide haptics)
+      Serial.printf("# OUT if%d rid=%02X n=%u:", slot, rid, n);
+      for (uint16_t i = 0; i < n && i < 14; i++) Serial.printf(" %02X", b[i]);
+      Serial.println();
+    }
+    return;
+  }
+  if (type != HID_REPORT_TYPE_FEATURE || n < 1) return;
+  Slot &S = g_slot[slot];
+  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;
+  if (cmd >= 0x80 && cmd <= 0x89) g_steamAliveMs = millis();   // any Steam settings/haptic/LED report (incl. the 0x87 lizard-off heartbeat) -> Steam present, forward gamepad, suppress auto-lizard
+  if (rid == 1 && n >= 2) {   // report 0x01 = raw passthrough -> queue for RF relay to the controller
+    hapLogAdd((uint8_t)slot, cmd, b, n);   // capture EVERY relayed feature-1 command for the WebUSB capture view
+                                           // (haptics, LED SET_LED_COLOR, 0x87 settings, 0x9F power-off). The log
+                                           // shows cmd as "rid"; bytes start [cmd][len]...  Was gated to 0x80-0x89,
+                                           // which hid exactly the LED/power-off frames we now need to see.
+    bool haptic82 = (cmd == 0x82 && len <= pln);
+    bool muted = g_resumeMs && millis()-g_resumeMs < POST_RESUME_MUTE_MS;   // see the OUTPUT path: no haptics while Steam can't read 0x45 back
+    bool relayOk = hapticRelaySlotOk(slot) && !(haptic82 && (lizardActive() || muted));  // never push haptics to the controller while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop)
+    if (relayOk && (!haptic82 || !haptic82Blocked())) {
+      // Relay the DECLARED length (up to the 60B RF frame ceiling), not a truncation: Steam's multi-register
+      // 0x87 settings blocks (LED brightness) and calibration writes are longer than the old 18B cap, and the
+      // chopped frames were why those settings never landed on the controller.
+      uint8_t rl = (len <= pln) ? len : (uint8_t)pln;
+      if (len > RELAY_MAXP && Serial.availableForWrite() > 60)
+        Serial.printf("# RELAY TRUNC cmd=%02X len=%u>%u\n", cmd, len, (unsigned)RELAY_MAXP);
+      relayEnqueue(cmd, pl, rl);
+      if (haptic82) haptic82HostReport(pl, len);  // track from RELAYED frames only (see the OUTPUT path)
+    }
+  }
+  if (Serial.availableForWrite() > 80) {   // log host feature writes (non-blocking)
+    Serial.printf("# SET if%d rid=%02X cmd=%02X len=%u:", slot, rid, cmd, len);
+    for (uint16_t i = 0; i < n && i < 14; i++) Serial.printf(" %02X", b[i]);
+    Serial.println();
+  }
+  memset(S.resp, 0, sizeof S.resp); S.resp_len = 0;
+  switch (cmd) {
+    case 0x83:
+      S.resp[0] = 0x83; S.resp[1] = sizeof ATTR83; memcpy(S.resp + 2, ATTR83, sizeof ATTR83); S.resp_len = 63; break;
+    case 0xAE: {
+      uint8_t idx = pln > 0 ? pl[0] : 1; const char *s = (idx == 0) ? g_board : (idx == 1) ? g_unit : "NA";
+      S.resp[0] = 0xAE; S.resp[1] = 0x14; S.resp[2] = idx; memset(S.resp + 3, 0, 60); memcpy(S.resp + 3, s, strlen(s)); S.resp_len = 63; break; }
+    case 0xB4:    // connection/version state per slot: value 0x02 = controller connected, 0x01 = not
+      S.resp[0] = 0xB4; S.resp[1] = 0x01;
+      S.resp[2] = (slot == g_connSlot && !g_xbox && (millis() - g_connReplyMs < 500)) ? 0x02 : 0x01;
+      S.resp_len = 63; break;
+    case 0xAD:
+      g_pairing = (pln > 0 && pl[0] != 0); Serial.printf("# pairing %s\n", g_pairing ? "ON" : "off");
+      S.resp[0] = 0xAD; S.resp[1] = 0; S.resp_len = 63; break;
+    case 0xA2:                                   // write/clear THIS interface's slot
+      if (len >= 24 && pln >= 24) {
+        if (recEmpty(pl)) { S.used = false; memset(S.rec, 0, 24); }
+        else { memcpy(S.rec, pl, 24); S.used = true; }
+        g_dirty = true; Serial.printf("# slot %d %s\n", slot, recEmpty(pl) ? "cleared" : "bonded");
+      }
+      S.resp[0] = 0xA2; S.resp[1] = 0; S.resp_len = 63; break;
+    case 0xA3:                                   // read THIS interface's slot
+      S.resp[0] = 0xA3; S.resp[1] = 0x18; memset(S.resp + 2, 0, 24);
+      if (S.used) memcpy(S.resp + 2, S.rec, 24); S.resp_len = 63; break;
+    default:
+      S.resp[0] = cmd; S.resp[1] = len; if (pln) memcpy(S.resp + 2, pl, pln > 60 ? 60 : pln); S.resp_len = 63; break;
+  }
+}
+static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type, uint8_t *buf, uint16_t reqlen) {
+  (void)rid;
+  if (type != HID_REPORT_TYPE_FEATURE) return 0;
+  Slot &S = g_slot[slot];
+  uint16_t n = S.resp_len ? S.resp_len : 63; if (n > reqlen) n = reqlen;
+  memcpy(buf, S.resp, n); return n;
+}
+
+// one callback pair per interface (the Adafruit core routes by interface to the matching object)
+#define SLOTCB(N) \
+  static void setcb##N(uint8_t r, hid_report_type_t t, uint8_t const *b, uint16_t n) { handleSet(N, r, t, b, n); } \
+  static uint16_t getcb##N(uint8_t r, hid_report_type_t t, uint8_t *bf, uint16_t rl) { return handleGet(N, r, t, bf, rl); }
+SLOTCB(0) SLOTCB(1) SLOTCB(2) SLOTCB(3)
+typedef uint16_t (*getcb_t)(uint8_t, hid_report_type_t, uint8_t *, uint16_t);
+typedef void (*setcb_t)(uint8_t, hid_report_type_t, uint8_t const *, uint16_t);
+static getcb_t GETCB[NSLOT] = { getcb0, getcb1, getcb2, getcb3 };
+static setcb_t SETCB[NSLOT] = { setcb0, setcb1, setcb2, setcb3 };
+
+// ===================== IController =====================
+void SteamPuckController::begin(){
+  USBDevice.setID(0x28DE, 0x1304);
+  // Distinct bcdDevice so Windows keys a FRESH usbflags entry (cache is VID:PID:bcdDevice) and actually runs
+  // MS OS 2.0 / WinUSB binding for the WebUSB vendor interface -- instead of reusing a stale "no WinUSB" entry
+  // tied to the real Steam Controller (28DE:1304), which has no WebUSB interface. The normal (wake-mouse) and
+  // one-shot debug (CDC) boots present DIFFERENT interface sets, so they use DIFFERENT bcdDevice values --
+  // otherwise Windows would serve one's cached descriptor for the other when you reboot between them.
+  USBDevice.setDeviceVersion(g_debugCdcThisBoot ? 0x0212 : 0x0211);
+  USBDevice.setManufacturerDescriptor("Valve Software");
+  USBDevice.setProductDescriptor("Steam Controller Puck");
+  for (int i = 0; i < NSLOT; i++) {
+    hid[i].setReportDescriptor(PUCK_HID_DESC, sizeof PUCK_HID_DESC);
+    hid[i].setReportCallback(GETCB[i], SETCB[i]);
+    hid[i].setPollInterval(1);       // 1ms USB poll (was default 10ms = 100/s cap -> choppy)
+    hid[i].begin();
+  }
+}
+
+// Forward the controller's report 0x45 to Steam, or drive lizard kb/mouse when Steam is closed. This is a
+// PURELY USB-SIDE decision -- it changes nothing about the RF poll or the host->controller relay.
+void SteamPuckController::onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen){
+  // Host asleep -> forward NOTHING. While suspended, every sendReport attempt (a lizard-mouse report from a
+  // trackpad graze, a 0x45 forward) can translate into a host wake -- which made the PC wake on any controller
+  // movement and nearly impossible to keep asleep. Waking is an explicit gesture only (Steam-button short press
+  // / controller connect), handled in rf_link.cpp via the device-level USB resume signal.
+  if (USBDevice.suspended()) return;
+  // Just woke? Hold off forwarding for a beat so the wake gesture's residual controller input doesn't click /
+  // type into the freshly-woken desktop (it was launching Edge from the Start menu on every wake).
+  if (g_resumeMs && millis()-g_resumeMs < POST_RESUME_MUTE_MS) return;
+  if (g_connSlot < 0 || g_connSlot >= NSLOT) return;
+  if (lizardActive()){
+    rfLizard(rep, &hid[g_connSlot], &hid[g_connSlot], 0x40, 0x41);
+  } else {
+    uint8_t blen = bodyTlen - 1; if(blen>45)blen=45;   // body after the 0x45 id byte
+    // forward the puck's raw pad coords untouched -- Steam does its own interpolation/smoothing. Forward only
+    // FRESH reports (the real puck dedupes -> Steam gets a clean unique stream; sending stale repeats makes
+    // Steam's velocity/smoothing stair-step). g_fwdNewOnly toggles for A/B.
+    if((fresh || !g_fwdNewOnly) && g_slot[g_connSlot].used && hid[g_connSlot].ready())
+      hid[g_connSlot].sendReport(0x45, rep+1, blen);   // Steam/SDL: input report -> "connected"
+  }
+}
+
+// ---- wake nudge: a bare USB resume signal is NOT enough to wake some hosts (Windows in particular) -- they
+// only wake when actual mouse/keyboard input follows. So on a deliberate wake gesture we play a HARMLESS mouse
+// JIGGLE (move a few px right, then back -- NET ZERO cursor, NO button): real mouse activity wakes the host,
+// but it clicks nothing and doesn't close/activate anything (an open Start menu stays open). Earlier versions
+// also sent a click / space / Ctrl; those either activated the focused item or weren't needed. Queued by
+// wakeEvent() (rf_link, on a Steam short press / controller connect while suspended); reports can't cross a
+// suspended bus, so it's delivered once the bus has resumed.
+static uint8_t       g_nudgeStep = 0;      // 0=idle; 1=jiggle+, 2=jiggle-
+static unsigned long g_nudgeMs = 0;
+#define NUDGE_JIGGLE_PX 10
+void SteamPuckController::wakeEvent(){
+  g_nudgeStep = 1; g_nudgeMs = millis();
+}
+static void wakeNudgeTask(){
+  if(!g_nudgeStep) return;
+  if(millis()-g_nudgeMs > 5000){ g_nudgeStep=0; return; }            // bus never resumed -> drop the nudge
+  if(USBDevice.suspended()) return;                                   // wait for resume; reports can't cross a suspended bus
+  if(g_connSlot<0 || g_connSlot>=NSLOT || !hid[g_connSlot].ready()) return;
+  static unsigned long stepMs=0;
+  if(millis()-stepMs < 15) return;                                    // pace the edges
+  stepMs=millis();
+  hid_mouse_report_t m; m.buttons=0; m.x=(g_nudgeStep==1)?NUDGE_JIGGLE_PX:-NUDGE_JIGGLE_PX; m.y=0; m.wheel=0; m.pan=0;
+  hid[g_connSlot].sendReport(0x40,&m,sizeof m);                       // jiggle right, then back
+  g_nudgeStep = (g_nudgeStep>=2) ? 0 : (uint8_t)(g_nudgeStep+1);
+}
+
+// USB connection presentation (like the real dongle): report 0x79 = connection state (01=disc, 02=conn),
+// edge-triggered, + periodic 0x7B status. Live-captured: this is what Steam reads to mark the controller
+// connected. Without it Steam shows disconnected even though 0x45 input is streaming.
+void SteamPuckController::task(){
+  wakeNudgeTask();
+  { static bool wasSusp=false; bool susp=USBDevice.suspended();   // stamp the suspended->active edge for the post-resume mute
+    if(wasSusp && !susp) g_resumeMs=millis();
+    wasSusp=susp; }
+  if (USBDevice.suspended()) return;   // no periodic 0x79/0x7B while the host sleeps -- those sends can wake it too
+  static bool usbConn=false; static unsigned long last79=0, last7B=0, connEdgeMs=0;
+  bool conn = (g_connSlot>=0 && millis()-g_connReplyMs < 300);
+  if (g_connSlot>=0 && g_connSlot<NSLOT && hid[g_connSlot].ready()){
+    // 0x79 connection state: on edge, then repeated every 750ms ONLY until Steam reacts (its first OUTPUT/
+    // settings write after the edge -- g_steamAliveMs). The real puck sends 0x79 ONCE, edge-triggered; the old
+    // unconditional forever-resend could re-trigger Steam's connect handling (connect chime) every 750ms
+    // before Steam started consuming 0x45 -> the connect-time loop of haptic buzzes that only stopped on the
+    // first real input (the Steam button). Resending until acked still covers "Steam missed the edge".
+    bool steamAcked = g_steamAliveMs && (int32_t)(g_steamAliveMs - connEdgeMs) >= 0;
+    if (conn!=usbConn || (conn && !steamAcked && millis()-last79>=750)){
+      if (conn && !usbConn) connEdgeMs = millis();
+      uint8_t st=conn?0x02:0x01; hid[g_connSlot].sendReport(0x79,&st,1); usbConn=conn; last79=millis();
+    } else if (conn && millis()-last7B>=2000){
+      // 0x7B status, live-captured template. Byte 8 is the controller->puck signal strength as signed dBm
+      // (capture showed 0xDD = -35; replaying it verbatim is why Steam pinned -35dBm forever) -- patch in the
+      // smoothed RSSI the radio now samples on each controller reply (rf_link). 0 = no sample yet -> keep the
+      // capture value rather than report garbage.
+      //
+      // CALIBRATION: our raw RSSISAMPLE reads ~RSSI_DBM_OFFSET dB lower than the real Valve puck at the same
+      // distance (the Pro Micro's PCB-trace antenna vs Valve's tuned front-end). The 2Mbit ESB link has ~55dB
+      // of margin, so a -75dBm reading still works across a house -- but Steam's bar maps raw dBm to "weak"
+      // long before that. Adding the offset lines our close-range value up with the puck's captured -35 so the
+      // bar tracks usable range instead of antenna gain. Clamp keeps it in a sane window (no wrap/garbage).
+      uint8_t s7b[12]={0xF7,0x01,0x89,0x00,0x00,0x00,0x03,0x00,0xDD,0x00,0x3A,0x02};
+      if (g_linkRssi){
+        int mag = (int)g_linkRssi - RSSI_DBM_OFFSET;
+        if (mag < 25) mag = 25; else if (mag > 95) mag = 95;
+        s7b[8]=(uint8_t)(0u-(uint8_t)mag);
+      }
+      hid[g_connSlot].sendReport(0x7B,s7b,12); last7B=millis();
+    }
+  } else if(!conn) usbConn=false;
+}

+ 29 - 0
OpenPuck/puck_hid.h

@@ -0,0 +1,29 @@
+// puck_hid.h -- the Steam Controller puck personality (MODE_STEAM + MODE_LIZARD).
+//
+// This is the controller that wears Valve's identity (28DE:1304) and exposes the four puck HID slot interfaces
+// (interface N owns bond slot N). It implements:
+//   - the puck feature COMMAND CHANNEL (handleSet/handleGet): reports 0x83/0xAE/0xB4/0xAD/0xA2/0xA3 the host
+//     uses to read attributes/serials and read/write/clear bond slots, plus the host->controller haptic relay
+//     ride-along on OUTPUT reports 0x80-0x86 and feature passthrough (report 0x01).
+//   - the seamless LIZARD decision: when Steam is driving the gamepad (recent OUTPUT/heartbeat) we forward the
+//     gamepad report 0x45; when Steam is closed we present keyboard+mouse on the SAME interface. MODE_LIZARD
+//     forces lizard always. lizardActive() is the single source of truth, shared with the haptic gate so we
+//     never relay haptics while presenting lizard (Steam isn't reading 0x45 back -> would buzz-loop).
+//   - the USB connection-state presentation (reports 0x79 / 0x7B) Steam reads to mark the controller connected.
+#pragma once
+#include "controllers.h"
+#include <stdint.h>
+
+// Steam mode: forward report 0x45 only when the seq advanced (dedupe like the real puck). Sending stale
+// repeats makes Steam's trackpad smoothing stair-step. Toggle to A/B (WebUSB field 14).
+extern uint8_t g_fwdNewOnly;
+
+class SteamPuckController : public IController {
+public:
+  void begin() override;
+  void onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen) override;
+  void task() override;
+  void wakeEvent() override;   // queue the harmless post-resume wake nudge (mouse jiggle + Ctrl tap)
+  bool isPuck() const override { return true; }
+};
+extern SteamPuckController g_steamPuck;

+ 74 - 0
OpenPuck/radio.cpp

@@ -0,0 +1,74 @@
+#include "radio.h"
+
+// Pairing rendezvous bytes from IBEX rodata. The 0x91A2A793 key that precedes the "ibex" string is a
+// DIFFERENT key, NOT the discovery address (confirmed via PTR_s_ibexesb_common=0x56f99 -> "ibex...").
+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 measured clean (RSSI 95/90, PER 0) on the REAL PUCK's own channel scan and is in its active hop set
+// {18,2,80}; trackpad data bursts collide on bad channels -> reply-rate crash. Tunable 'C'.
+uint8_t  g_sessCh = 18;
+
+// pre-init to "ibex"/0x10 as a safe default; rfGenSessionAddr() overwrites with the per-device value at boot.
+uint8_t  g_sessBase[4] = {0x69, 0x62, 0x65, 0x78};
+uint8_t  g_sessPrefix = 0x10;
+
+void rfGenSessionAddr(){
+  // Mix the 64-bit FICR DEVICEID into a 4-byte base + a prefix. Deterministic per chip (stable across reboots,
+  // debuggable) and unique across chips (DEVICEID is unique) -> two OpenPucks never collide on the connected
+  // session. Avoid degenerate address bytes (0x00/0xFF) for clean preamble correlation, and never reproduce
+  // the shared discovery base "ibex" (else the isolation is lost).
+  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;   // collided with "ibex"
+}
+
+uint8_t  rfrx[80], rftx[80];
+uint32_t g_rfRxCount = 0;
+
+// HYBRID puck-link config (firmware-derived, definitive). DECODED from real-puck capture: puck TX = Ble_2Mbit,
+// ENDIAN=Big (MSB-first), NO whitening, addr "ibex", CRC16 0x11021/0xFFFF; frame = FUN_00027e9a (static length).
+uint32_t g_crcinit = 0xFFFF;
+uint8_t  g_whiteiv = 37;
+uint8_t  g_mode    = RADIO_MODE_MODE_Ble_2Mbit;   // <-- was the bug (1Mbit). Real puck = 2Mbit.
+uint32_t g_pcnf0   = 0x00030008UL;                // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) - CRC-VALIDATED
+uint8_t  g_statlen = 0x20;
+uint32_t g_pcnf1   = 0x01040040;                  // ENDIAN=Big, BALEN4, MAXLEN64 - CRC-VALIDATED
+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);   // Ble_2Mbit (tunable 'M')
+  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;       // static length (tunable '0'); g_pcnf1=0 -> build from g_statlen/g_balen
+  NRF_RADIO->PCNF1 = g_pcnf1 ? g_pcnf1 :
+        ((1u<<RADIO_PCNF1_ENDIAN_Pos)              // ENDIAN=Big (puck transmits MSB-first)
+        |((uint32_t)g_balen<<RADIO_PCNF1_BALEN_Pos)
+        |((uint32_t)g_statlen<<RADIO_PCNF1_STATLEN_Pos)
+        |((uint32_t)g_statlen<<RADIO_PCNF1_MAXLEN_Pos));   // WHITEEN=0
+  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;
+}

+ 56 - 0
OpenPuck/radio.h

@@ -0,0 +1,56 @@
+// radio.h -- bare-metal nRF52 RADIO (ESB-style) hardware layer.
+//
+// We drive the RADIO peripheral directly, with NO SoftDevice (BLE is never started), coexisting with TinyUSB.
+// This file owns the register-level config: PHY/CRC/whitening/address parameters (all live-tunable from the
+// CDC console for hardware iteration) plus rfConfig()/rfSetAddr() which program the radio, and the rfrx/rftx
+// DMA buffers. The puck *protocol* built on top lives in rf_link.cpp; the RE/calibration tooling in rf_diag.cpp.
+//
+// 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 peripheral would otherwise hang the whole
+// main loop (and USB stops being serviced -> device "dies" until replug). On timeout we bail and continue;
+// the next rfConfig re-inits the radio.
+#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 (rodata byte @0x56f98)
+extern uint8_t  g_rfCh;              // current TX/RX channel (hopped during beacon/poll)
+extern uint8_t  g_rfBase[4];         // "ibex"
+extern uint8_t  g_sessCh;            // connected-session channel: a CLEAN data channel off the congested adv ch2
+
+// Per-device SESSION address (base+prefix). Discovery/rendezvous stays on the SHARED "ibex" address (g_rfBase)
+// so any controller can find us; the host frame then advertises THIS unique address and the controller adopts
+// it for the connected session. Two OpenPucks therefore never share an on-air session address -> no crosstalk,
+// no spurious cross-wake. Derived from the FICR DEVICEID (stable per chip, unique across chips).
+extern uint8_t  g_sessBase[4];
+extern uint8_t  g_sessPrefix;
+void rfGenSessionAddr();
+
+extern uint8_t  rfrx[80], rftx[80];  // RADIO DMA buffers
+extern uint32_t g_rfRxCount;
+
+// ---- tunable radio parameters (CDC console M/0/2/i/w/I/P/N/T/A) ----
+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, MAXLEN64 -- 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
+extern bool     g_prefixRaw;// false = bitrev8 prefix (ESB addr_conv); true = raw prefix byte
+
+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);

+ 307 - 0
OpenPuck/rf_diag.cpp

@@ -0,0 +1,307 @@
+#include "rf_diag.h"
+#include "radio.h"
+#include "bonds.h"
+#include <Arduino.h>
+#include <string.h>
+
+bool    g_rfListen = false;
+bool    g_rfBeacon = false;
+uint8_t g_plen = 0x18, g_s1incl = 0;
+bool    g_rfRaw = false;
+bool    g_rfSweep = false;
+bool    g_rfCap = false, g_rfCapOne = false, g_rfReplay = false;
+uint8_t g_replayLen = 0;
+bool    g_rfAuto = false;
+uint8_t g_capV = 0, g_cfgIdx = 0;
+bool    g_rfRespond = false;
+bool    g_sniff = false;
+uint8_t g_sniffPh = 0, g_sniffPark = 0;
+
+static uint8_t  g_seq = 0;
+static unsigned long g_lastBeacon = 0, g_lastHop = 0, g_lastCfg = 0;
+static uint8_t  g_replay[48];
+static uint32_t g_capPass = 0;
+static uint32_t g_advCount = 0;
+static uint8_t  g_hostTx[48]; static int g_hostLen=0;
+static uint32_t g_sniffN=0;
+static uint8_t  g_sbase[4]={0,0,0,0}, g_sprefix=0; static uint8_t g_schan[3]={78,2,80}; static uint8_t g_schi=0;
+
+// ===================== promiscuous raw capture (calibration only) =====================
+// preamble-match on 0x55 bytes, fixed-length grab, no CRC. Catches any 2Mbit packet on a channel so we can
+// read the controller's reconnect addr/prefix/framing.
+#define RAWCAP 48
+void rfRawStart(uint8_t ch){
+  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+  NRF_RADIO->MODE=(RADIO_MODE_MODE_Nrf_2Mbit<<RADIO_MODE_MODE_Pos);
+#if defined(RADIO_MODECNF0_RU_Fast)
+  NRF_RADIO->MODECNF0=(RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
+#endif
+  NRF_RADIO->FREQUENCY=ch; NRF_RADIO->TXPOWER=(RADIO_TXPOWER_TXPOWER_0dBm<<RADIO_TXPOWER_TXPOWER_Pos);
+  NRF_RADIO->CRCCNF=0; NRF_RADIO->PCNF0=0;
+  NRF_RADIO->PCNF1=(RADIO_PCNF1_WHITEEN_Disabled<<RADIO_PCNF1_WHITEEN_Pos)
+                 |(RADIO_PCNF1_ENDIAN_Big<<RADIO_PCNF1_ENDIAN_Pos)
+                 |(2u<<RADIO_PCNF1_BALEN_Pos)|(RAWCAP<<RADIO_PCNF1_STATLEN_Pos)|(RAWCAP<<RADIO_PCNF1_MAXLEN_Pos);
+  NRF_RADIO->BASE0=0x55555555; NRF_RADIO->PREFIX0=0x00000055;
+  NRF_RADIO->TXADDRESS=0; NRF_RADIO->RXADDRESSES=1; NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
+  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
+  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
+}
+
+// ===================== CRC-validating framing sweep =====================
+// Data looked bit-reversed (not whitened). Firmware-correct: ESB DPL (S0L0, LFLEN6or8, S1LEN3), BALEN4
+// (5-byte addr), CRC8/16 addr-included. Sweep PCNF0{LFLEN6,8} x CRC{8,16} x BALEN{4,3} x ENDIAN{Big,Lit}.
+static const uint32_t CAPP0B[]={0x00030006,0x00030008};  // S1LEN3 + LFLEN6 / LFLEN8 (dynamic length)
+static const uint32_t CAPCRCB[][3]={{0x1,0x107,0xFF},{0x2,0x11021,0xFFFF}};
+void rfCapStart(uint8_t ch){
+  uint8_t ci=g_capV&1;
+  uint8_t balen=((g_capV>>1)&1)?3:4;            // BALEN 4 first
+  uint8_t endbig=((g_capV>>2)&1)^1;             // ENDIAN Big first
+  uint32_t p0=CAPP0B[(g_capV>>3)&1];
+  const uint32_t* cr=CAPCRCB[ci];
+  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+  NRF_RADIO->MODE=(RADIO_MODE_MODE_Ble_2Mbit<<RADIO_MODE_MODE_Pos);
+#if defined(RADIO_MODECNF0_RU_Fast)
+  NRF_RADIO->MODECNF0=(RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
+#endif
+  NRF_RADIO->FREQUENCY=ch;
+  NRF_RADIO->PCNF0=p0;
+  NRF_RADIO->PCNF1=((uint32_t)endbig<<RADIO_PCNF1_ENDIAN_Pos)|((uint32_t)balen<<RADIO_PCNF1_BALEN_Pos)
+                  |(60u<<RADIO_PCNF1_MAXLEN_Pos);
+  NRF_RADIO->CRCCNF=cr[0]; NRF_RADIO->CRCPOLY=cr[1]; NRF_RADIO->CRCINIT=cr[2];
+  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(g_rfBase[i]);
+  NRF_RADIO->BASE0=((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
+  NRF_RADIO->PREFIX0=rfBitrev8(g_rfPrefix); NRF_RADIO->TXADDRESS=0; NRF_RADIO->RXADDRESSES=1;
+  NRF_RADIO->PACKETPTR=(uint32_t)rfrx; memset(rfrx,0,8);
+  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
+  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
+}
+// REPLAY: capture one real-puck frame (raw, ENDIAN=Little) then re-transmit it VERBATIM (same bits, addr, ch)
+// to impersonate the puck bit-for-bit -- sidesteps unknown framing/CRC. Controller must be bonded to the REAL
+// puck (replayed frame carries real-puck uuids + its valid CRC).
+static void rfCapPoll(){
+  if(!g_rfCap) return;
+  if(NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
+    g_capPass++;
+    if(g_rfCapOne){ memcpy(g_replay,rfrx,sizeof g_replay); g_replayLen=32; g_rfCapOne=false; g_rfCap=false;
+      Serial.printf("# captured replay frame (%uB): ",g_replayLen);
+      for(int i=0;i<g_replayLen;i++)Serial.printf("%02X",g_replay[i]); Serial.println(); return; }
+    if(NRF_RADIO->CRCSTATUS&1){
+      uint8_t ci=g_capV&1; uint8_t balen=((g_capV>>1)&1)?3:4; uint8_t eb=((g_capV>>2)&1)^1;
+      uint32_t p0=CAPP0B[(g_capV>>3)&1];
+      Serial.printf("@@@ CRCOK PCNF0=%lX BALEN=%u ENDIAN=%s POLY=%lX: ",(unsigned long)p0,balen,
+        eb?"Big":"Lit",(unsigned long)CAPCRCB[ci][1]);
+      for(int i=0;i<28;i++) Serial.printf("%02X",rfrx[i]); Serial.println();
+    }
+  }
+}
+void rfReplayOnce(){
+  if(!g_replayLen) return;
+  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+  NRF_RADIO->MODE=(RADIO_MODE_MODE_Ble_2Mbit<<RADIO_MODE_MODE_Pos);
+#if defined(RADIO_MODECNF0_RU_Fast)
+  NRF_RADIO->MODECNF0=(RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
+#endif
+  NRF_RADIO->FREQUENCY=2;                                          // ch2
+  NRF_RADIO->PCNF0=0;                                              // static, ENDIAN=Little -> reproduce on-air bits
+  NRF_RADIO->PCNF1=(3u<<RADIO_PCNF1_BALEN_Pos)|((uint32_t)g_replayLen<<RADIO_PCNF1_STATLEN_Pos)|((uint32_t)g_replayLen<<RADIO_PCNF1_MAXLEN_Pos);
+  NRF_RADIO->CRCCNF=0;                                            // CRC already inside the captured bytes
+  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(g_rfBase[i]);
+  NRF_RADIO->BASE0=((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
+  NRF_RADIO->PREFIX0=rfBitrev8(g_rfPrefix); NRF_RADIO->TXADDRESS=0;
+  NRF_RADIO->PACKETPTR=(uint32_t)g_replay;
+  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->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+}
+
+// ===================== address/CRC listen =====================
+void rfListenStart(){
+  rfConfig(g_rfCh); rfSetAddr(g_rfBase, g_rfPrefix);
+  NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+  NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk | RADIO_SHORTS_END_START_Msk;
+  NRF_RADIO->EVENTS_END = 0; NRF_RADIO->TASKS_RXEN = 1;
+  g_rfListen = true;
+  Serial.printf("# RF listen ch%u base %02X%02X%02X%02X prefix %02X\n",
+                g_rfCh, g_rfBase[0],g_rfBase[1],g_rfBase[2],g_rfBase[3], g_rfPrefix);
+}
+static void rfPoll(){
+  if (g_rfRaw){
+    if (NRF_RADIO->EVENTS_END){
+      NRF_RADIO->EVENTS_END = 0;
+      // filter noise: require some non-0x55/0xAA/0x00 structure
+      int nz=0; for(int i=0;i<RAWCAP;i++){uint8_t b=rfrx[i]; if(b&&b!=0x55&&b!=0xAA&&b!=0xFF)nz++;}
+      if (nz>=6){
+        g_rfRxCount++;
+        Serial.printf("RAW#%lu ch%u: ", (unsigned long)g_rfRxCount, g_rfCh);
+        for (int i=0;i<RAWCAP;i++) Serial.printf("%02X", rfrx[i]);
+        Serial.println();
+      }
+    }
+    return;
+  }
+  if (!g_rfListen) return;
+  if (NRF_RADIO->EVENTS_END){
+    NRF_RADIO->EVENTS_END = 0;
+    bool crcok = NRF_RADIO->CRCSTATUS & 1;
+    uint8_t len = rfrx[0];
+    if (len && len < 64){
+      g_rfRxCount++;
+      Serial.printf("RF#%lu ch%u crc%d len%u: ", (unsigned long)g_rfRxCount, g_rfCh, crcok, len);
+      for (uint8_t i=0;i<=len && i<40;i++) Serial.printf("%02X ", rfrx[i]);
+      Serial.println();
+    }
+  }
+}
+
+// ===================== autosweep candidate radio configs =====================
+// Cycle candidate radio configs while beaconing, so ONE controller search window covers the space. S0LEN=1 /
+// CRC16 0x11021 / addr "ibex" held fixed; sweep the residual unknowns (PHY, whitening, BALEN, prefix).
+struct RfCfg { uint8_t mode; uint32_t pcnf0, pcnf1; uint8_t whiteiv; uint16_t crccnf; bool prefRaw; const char* tag; };
+static const RfCfg SWEEP[] = {
+  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02040040, 37, 0x2, false, "1M BALEN4 whiv37 pfxRev"},
+  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00040040, 0,  0x2, false, "1M BALEN4 whOFF pfxRev"},
+  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02040040, 37, 0x2, true,  "1M BALEN4 whiv37 pfxRAW"},
+  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00040040, 0,  0x2, true,  "1M BALEN4 whOFF pfxRAW"},
+  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02030040, 37, 0x2, false, "1M BALEN3 whiv37 pfxRev"},
+  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00030040, 0,  0x2, true,  "1M BALEN3 whOFF pfxRAW"},
+  {RADIO_MODE_MODE_Ble_2Mbit, 0x00100108, 0x02040040, 37, 0x2, false, "2M BALEN4 whiv37 pfxRev"},
+  {RADIO_MODE_MODE_Ble_2Mbit, 0x00100108, 0x00040040, 0,  0x2, true,  "2M BALEN4 whOFF pfxRAW"},
+};
+void applyCfg(uint8_t i){
+  const RfCfg &c = SWEEP[i];
+  g_mode=c.mode; g_pcnf0=c.pcnf0; g_pcnf1=c.pcnf1; g_whiteiv=c.whiteiv; g_crccnf=c.crccnf; g_prefixRaw=c.prefRaw;
+  Serial.printf("# cfg[%u] %s\n", i, c.tag);
+}
+#define SWEEP_N (sizeof SWEEP/sizeof SWEEP[0])
+
+// ===================== dongle beacon experiment =====================
+// Dongle is PTX: TX a beacon [01][seq][..][E2@5] then RX the controller's response (0xF0...).
+void rfBeaconOnce(){
+  uint8_t pl[48]; memset(pl,0,sizeof pl);
+  pl[0]=0x01; pl[1]=g_seq++; pl[5]=0xE2;                 // dongle beacon (FUN_00027ed8 format)
+  rfConfig(g_rfCh); rfSetAddr(g_rfBase,g_rfPrefix);
+  if (g_s1incl) NRF_RADIO->PCNF0 |= (1u<<RADIO_PCNF0_S1INCL_Pos);
+  // RAM: [LENGTH][ (S1 if S1INCL) ][payload]
+  rftx[0]=0; rftx[1]=g_plen; memcpy(rftx+2,pl,g_plen);
+  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;
+  // RX window for the controller's response
+  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 && (micros()-t0)<500){}
+  if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
+    bool crcok=NRF_RADIO->CRCSTATUS&1; uint8_t len=rfrx[0];
+    if (len && len<64){ g_rfRxCount++;
+      Serial.printf("RESP#%lu crc%d len%u: ",(unsigned long)g_rfRxCount,crcok,len);
+      for(uint8_t i=0;i<=len&&i<40;i++)Serial.printf("%02X ",rfrx[i]); Serial.println();
+    }
+  }
+  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+}
+
+// ===================== scan-then-respond (dongle role) =====================
+// Listen on ch2 for the controller's advertisement, and the instant one arrives, TX the host frame (its ack).
+static void buildHostTx(){
+  int slot=-1; for(int i=0;i<NSLOT;i++) if(g_slot[i].used){slot=i;break;}
+  memset(g_hostTx,0,sizeof g_hostTx); g_hostLen=0; if(slot<0) return;
+  // S0LEN=1 RAM layout: [0]=S0=0x12, [1]=LENGTH, raw RAM offsets validated by controller.
+  memset(g_hostTx,0,sizeof g_hostTx);
+  g_hostTx[0]=0x12; g_hostTx[1]=0x20; g_hostTx[3]=1; g_hostTx[5]=0xE1;
+  memcpy(g_hostTx+6,g_slot[slot].rec,4); memcpy(g_hostTx+10,g_slot[slot].rec+4,4);
+  g_hostTx[0xe]=2; memcpy(g_hostTx+0x12,g_rfBase,4); g_hostTx[0x16]=g_rfPrefix;
+  g_hostLen=0x22;
+}
+void rfRespondStart(){
+  buildHostTx();
+  rfConfig(2); rfSetAddr(g_rfBase,g_rfPrefix);            // RX ch2 / 91A2A793
+  NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
+  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
+  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1; g_rfRespond=true;
+  Serial.printf("# RESPOND mode: RX ch2/91A2A793, reply host frame (len%d)\n", g_hostLen);
+}
+static void rfRespondPoll(){
+  if(!g_rfRespond) return;
+  if(NRF_RADIO->EVENTS_END){
+    NRF_RADIO->EVENTS_END=0; uint8_t len=rfrx[0];
+    if(len && len<64){
+      g_advCount++;
+      // reply: TX the host frame immediately (ack)
+      NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+      NRF_RADIO->PACKETPTR=(uint32_t)g_hostTx;
+      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;
+      Serial.printf("ADV#%lu len%u: ",(unsigned long)g_advCount,len);
+      for(uint8_t i=0;i<=len&&i<32;i++)Serial.printf("%02X",rfrx[i]); Serial.println();
+      // re-arm RX
+      NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
+      NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
+      NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
+    }
+  }
+}
+
+// ===================== live-session sniffer =====================
+// phase 0 RX the real puck's host frame on "ibex"/ch2 to learn the random session base/prefix/channel; phase 1
+// RX that session address on the primary channel to capture the live connected exchange (E3 poll + F1 reply).
+void rfSniffStart(){
+  uint8_t ch = (g_sniffPh==0)?2:(g_sniffPark?g_sniffPark:g_schan[0]);   // PARK (no hop): primary carries the traffic
+  const uint8_t* base = (g_sniffPh==0)?g_rfBase:g_sbase;
+  uint8_t pfx = (g_sniffPh==0)?g_rfPrefix:g_sprefix;
+  rfConfig(ch);                         // Ble_2Mbit, PCNF0=0x30008, ENDIAN big, BALEN4, CRC16 0x11021
+  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(base[i]);
+  NRF_RADIO->BASE0=((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
+  NRF_RADIO->PREFIX0=rfBitrev8(pfx); NRF_RADIO->TXADDRESS=0; NRF_RADIO->RXADDRESSES=1;
+  NRF_RADIO->PACKETPTR=(uint32_t)rfrx; memset(rfrx,0,4);
+  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
+  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
+}
+static void rfSniffPoll(){
+  if(!g_sniff) return;
+  if(NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
+    bool crcok=NRF_RADIO->CRCSTATUS&1; uint8_t len=rfrx[0];
+    if(crcok && len && len<40){
+      g_sniffN++;
+      if(g_sniffPh==0 && rfrx[2]==0xE1){           // host frame -> learn session params + DUMP FULL (app-data may ride here)
+        memcpy(g_sbase,rfrx+15,4); g_sprefix=rfrx[19]; g_schan[0]=rfrx[11];
+        Serial.printf("# HOSTFRAME ch=%u sbase=%02X%02X%02X%02X prefix=%02X len%u FULL: ",
+          rfrx[11],g_sbase[0],g_sbase[1],g_sbase[2],g_sbase[3],g_sprefix,len);
+        { uint8_t dn=(len+2<64)?len+2:64; for(uint8_t i=0;i<dn;i++)Serial.printf("%02X",rfrx[i]); } Serial.println();
+        g_sniffPh=1; g_schi=0; rfSniffStart();
+      } else if(g_sniffPh==1){
+        uint8_t ty=rfrx[2];                          // 0xF1=input, 0x12/0xE1/0xE4=puck host-frame w/ app-data
+        const char* tag = (ty==0xF1)?"<<<INPUT":(ty==0x12||ty==0xE1||ty==0xE4)?">>>HOSTFR":"  pkt";
+        Serial.printf("%s SNIFF#%lu ch%u len%u: ",tag,(unsigned long)g_sniffN,g_schan[g_schi],len);
+        uint8_t dn=(len+2<64)?len+2:64;              // FULL frame (app-data TLVs sit near the end)
+        for(uint8_t i=0;i<dn;i++)Serial.printf("%02X",rfrx[i]); Serial.println();
+      }
+    }
+  }
+}
+
+void rfDiagTask(){
+  rfPoll();
+  rfRespondPoll();
+  rfCapPoll();
+  if (g_rfCap && millis() - g_lastHop >= 90) {    // sweep config combos on ch2, CRC-validate
+    g_lastHop = millis(); g_capV = (uint8_t)(g_capV+1); rfCapStart(g_rfCh);
+  }
+  if (g_rfBeacon && millis() - g_lastBeacon >= 5) { g_lastBeacon = millis(); rfBeaconOnce(); }
+  if (g_rfSweep && millis() - g_lastHop >= 60) { g_lastHop = millis(); g_rfCh += 2; if (g_rfCh > 80) g_rfCh = 2; rfRawStart(g_rfCh); }
+  if (g_rfAuto && millis() - g_lastCfg >= 500) {   // advance to next candidate config every 500ms
+    g_lastCfg = millis(); g_cfgIdx = (g_cfgIdx + 1) % SWEEP_N; applyCfg(g_cfgIdx);
+  }
+  if (g_rfReplay) { rfReplayOnce(); }   // bit-perfect replay of a captured real-puck frame on ch2
+  rfSniffPoll();
+  if (g_rfRespond && millis() - g_lastHop >= 25) {          // scan the 3 BLE adv channels w/ matched whitening
+    g_lastHop = millis();
+    static const uint8_t advf[3]={2,26,80}, advw[3]={37,38,39}; static uint8_t ai=0; ai=(ai+1)%3;
+    g_rfCh=advf[ai]; g_whiteiv=advw[ai];
+    rfConfig(g_rfCh); rfSetAddr(g_rfBase,g_rfPrefix); NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
+    NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk; NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
+  }
+}

+ 36 - 0
OpenPuck/rf_diag.h

@@ -0,0 +1,36 @@
+// rf_diag.h -- RF reverse-engineering & calibration scaffolding (NOT used in normal operation).
+//
+// The puck protocol is solved (see radio.cpp / rf_link.cpp), but the tooling that solved it is preserved here:
+// promiscuous raw capture, CRC-validating config sweeps, bit-exact frame replay, address/CRC listen, the
+// scan-then-respond dongle experiment, the dongle beacon, and the live-session sniffer. Each is driven by a
+// flag toggled from the CDC console (serial_console.cpp) and serviced by rfDiagTask() from loop(). Isolating
+// it keeps rf_link.cpp readable while keeping the hard-won RE history compilable and runnable.
+#pragma once
+#include <stdint.h>
+
+// ---- mode flags (toggled by the CDC console; mutually managed there) ----
+extern bool    g_rfListen;
+extern bool    g_rfBeacon;
+extern uint8_t g_plen, g_s1incl;   // dongle-beacon payload length / S1INCL toggle
+extern bool    g_rfRaw;
+extern bool    g_rfSweep;
+extern bool    g_rfCap, g_rfCapOne, g_rfReplay;
+extern uint8_t g_replayLen;
+extern bool    g_rfAuto;
+extern uint8_t g_capV, g_cfgIdx;
+extern bool    g_rfRespond;
+extern bool    g_sniff;
+extern uint8_t g_sniffPh, g_sniffPark;
+
+// ---- start hooks (called from the console on command) ----
+void rfListenStart();          // RX + log on the discovery addr/channel
+void rfRawStart(uint8_t ch);   // promiscuous preamble-match capture (no CRC)
+void rfCapStart(uint8_t ch);   // CRC-validating framing sweep (cycles config combos)
+void rfReplayOnce();           // bit-exact re-transmit of a captured real-puck frame
+void rfBeaconOnce();           // dongle beacon experiment ([01][seq][..][E2@5])
+void applyCfg(uint8_t i);      // apply autosweep candidate radio config i
+void rfRespondStart();         // scan-then-respond (RX ch2 adv, reply host frame)
+void rfSniffStart();           // session sniffer (learn session params, then capture the live exchange)
+
+// Per-loop: service whichever diag mode is active.
+void rfDiagTask();

+ 306 - 0
OpenPuck/rf_link.cpp

@@ -0,0 +1,306 @@
+#include "rf_link.h"
+#include "radio.h"
+#include "bonds.h"
+#include "config.h"
+#include "triton.h"
+#include "haptics.h"
+#include "controllers.h"
+#include "status_led.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+bool     g_rfHost = true;
+bool     g_connOn = true;
+uint8_t  g_connType = 0xE7;     // start with protocol-version handshake, then 0xE3
+uint8_t  g_e7b = 0;             // 0=current(slow/awake), 1=test protocol-version-1. 'V<n>' to toggle.
+uint8_t  g_connLen = 0x08;
+uint8_t  g_getParam = 0x00;     // GET report 0x45 param byte (try 0x00, fall back 0x2D) - 'q' cmd
+uint8_t  g_e3mode = 1;          // DEFAULT 1: cycling the ESB PID drains the controller's report queue (~400 new/s vs ~60 with a fixed PID) -- THE rate fix. 'e<n>' to A/B.
+bool     g_connVerbose = false;
+uint32_t g_rxWin = 1200;        // poll RX-window (us); shorter=more polls/s but may miss DELAYED replies. Tunable 'r'.
+unsigned long g_connCooldown = 0;
+
+uint8_t  g_connSt = 0;          // 0=announce awake, 1=poll loop
+uint8_t  g_connStep = 0;        // repeat counter within a state
+uint16_t g_connPoll = 0;        // poll counter (re-assert awake every 32nd)
+uint32_t g_connF1 = 0;
+uint8_t  g_connF3v = 0xFF;
+
+uint8_t  g_qos = 0;
+// clean, spread channels (from the puck's RSSI/PER scan)
+static const uint8_t g_hopCand[] = {18,46,76,22,68};
+uint8_t  g_hopIdx = 0;
+volatile uint16_t g_qosBad = 0;
+unsigned long g_qosCheckMs = 0, g_qosLastHopMs = 0;
+
+uint16_t g_f1ps = 0;
+uint16_t g_newps = 0;
+uint16_t g_pollsps = 0;   // polls/s (GET+relay TXs) last second -- distinguishes loop-starvation from reply-loss
+uint16_t g_pollPeriodUs = 0;   // MEASURED avg us between GET-poll fires (vs intended g_pollUs) -- ground truth
+static uint32_t g_pollDtSum = 0; static uint16_t g_pollDtCnt = 0;
+volatile uint8_t g_linkRssi = 0;   // smoothed |dBm| of the controller's replies (0 = none yet)
+
+// ---- internal counters / timers ----
+static uint8_t  g_e3pid = 0;
+static uint32_t g_stPoll=0, g_stF1=0, g_stF3=0; static unsigned long g_stMs=0;
+static uint8_t  g_lastSeq=0; static uint32_t g_stNew=0;
+static uint32_t g_stCrc=0, g_stNoRx=0;
+static uint32_t g_chF1[3]={0,0,0};
+static uint32_t g_lastPollUs=0;
+static uint32_t g_connRx = 0;
+static unsigned long g_lastSessBeacon=0, g_lastDisc=0;
+static unsigned long g_lastStream = 0;
+
+// HOST FRAME the bonded controller waits for (IBEX FUN_00019000 verify: b[0]=0x12, b[5]=0xE1, b[6..10]=
+// proteus_uuid, b[10..14]=ibex_uuid). Built like PROTEUS FUN_00027e9a. Sent on the shared rendezvous addr;
+// the controller filters by the uuids in the payload, then connects.
+// Transmit one host frame. `discovery`=true sends it on the SHARED rendezvous address ("ibex"/ch2) where a
+// searching controller looks; =false sends it on this puck's unique SESSION address (the keepalive once the
+// controller has adopted the session). EITHER way the payload advertises the session base/prefix/channel, so
+// the controller always learns the unique address to connect on.
+static void rfHostFrameOnce(int slot, bool discovery){
+  if (slot<0||slot>=NSLOT||!g_slot[slot].used) return;
+  uint8_t *rec = g_slot[slot].rec;                       // [proteus_uuid 4][ibex_uuid 4][serial 16]
+  // CRC-VALIDATED frame (decoded from real puck): ESB-DPL RAM = [LENGTH][S1=PID][payload(18)]. payload:
+  // [0]=0xE1, [1..5]=proteus_uuid LE, [5..9]=ibex_uuid LE, [9]=session channel, [10..13]=0, [13..17]=session
+  // base, [17]=session prefix. Radio auto-appends CRC16 0x11021.
+  memset(rftx,0,sizeof rftx);
+  rftx[0]=0x12;                        // LENGTH = 18 (controller's buf[0]==0x12 check validates this)
+  rftx[1]=(uint8_t)((g_pid++&3)<<1);   // S1 = PID<<1 | noack0  (matches real puck 00/02/04/06)
+  rftx[2]=0xE1;                        // payload[0] marker
+  memcpy(rftx+3, rec+0, 4);            // payload[1..5] proteus_uuid (LE, as bonded)
+  memcpy(rftx+7, rec+4, 4);            // payload[5..9] ibex_uuid
+  rftx[11]=g_sessCh;                   // payload[9] session channel: tell the controller to run the session on
+                                       // the clean channel (it adopts buf[0xe]); discovery beacon still TXes on ch2
+  memcpy(rftx+15, g_sessBase, 4);      // payload[13..17] session base  (the per-device UNIQUE address)
+  rftx[19]=g_sessPrefix;               // payload[17] session prefix
+  // TX address: discovery uses the shared "ibex" rendezvous; the session keepalive uses our unique address
+  // (where the controller now listens). The advertised session params (above) are identical either way.
+  const uint8_t* txBase = discovery ? g_rfBase   : g_sessBase;
+  uint8_t        txPfx  = discovery ? g_rfPrefix : g_sessPrefix;
+  rfConfig(g_rfCh); rfSetAddr(txBase, txPfx);
+  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; rfrx[0]=0;
+  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk;
+  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
+  // Discovery/pairing beacons listen for the controller's response (matters for RE/pairing); the connected
+  // session keepalive expects NO response (the controller answers E3 polls, not the beacon), so don't burn
+  // 800us of dead air per frame -- that was the bulk of the idle poll-rate deficit (40 beacons/s x slots).
+  uint16_t bwin = discovery ? 800u : 150u;
+  uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<bwin){}
+  if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;   // ANY reception = controller answered our frame
+    g_rfRxCount++; bool crcok=NRF_RADIO->CRCSTATUS&1; uint8_t len=rfrx[0];
+    if (Serial.availableForWrite()>90){                  // non-blocking: don't stall the loop on CDC backpressure
+      Serial.printf("*** RESP#%lu ch%u crc%d rxmatch%lu len%u: ",(unsigned long)g_rfRxCount,
+                    g_rfCh,crcok,(unsigned long)NRF_RADIO->RXMATCH,len);
+      for(uint8_t i=0;i<(len<40?len+2:40);i++)Serial.printf("%02X ",rfrx[i]); Serial.println(); } }
+  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+}
+
+void rfHopTo(uint8_t newCh){
+  if(g_connSlot<0 || newCh==g_sessCh) return;
+  uint8_t cur=g_sessCh, savedRfCh=g_rfCh;
+  g_sessCh=newCh; g_rfCh=cur;                // host frame now advertises newCh but is TXed on cur (session addr)
+  for(int k=0;k<6;k++){ rfHostFrameOnce(g_connSlot, false); delayMicroseconds(700); }
+  g_rfCh=savedRfCh;                          // poll + session beacon now run on g_sessCh=newCh
+}
+
+uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen, uint16_t rxWinUs){
+  uint16_t win = rxWinUs ? rxWinUs : g_rxWin;   // relays pass a tiny window (no reply expected); polls use g_rxWin
+  memset(rftx,0,sizeof rftx);
+  rftx[0]=plen;                          // LENGTH = payload byte count
+  rftx[1]=s1;                            // S1 (type-specific)
+  memcpy(rftx+2, payload, plen);         // payload[0]=type byte, then data/TLVs
+  rfConfig(ch); rfSetAddr(g_sessBase,g_sessPrefix);   // connected poll runs on this puck's UNIQUE session addr
+  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; rfrx[0]=0;
+  // RXEN->READY->START; catch the reply. ADDRESS->RSSISTART samples the reply's signal strength (read from
+  // RSSISAMPLE below, surfaced to Steam via report 0x7B); DISABLED->RSSISTOP closes the measurement.
+  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_ADDRESS_RSSISTART_Msk|RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
+  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
+  g_stPoll++;
+  uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<win){}  // RX window (tunable 'r'; or relay override)
+  uint8_t rxlen=0;
+  if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
+    bool crcok=NRF_RADIO->CRCSTATUS&1; rxlen=rfrx[0];
+    if(!crcok){ g_stCrc++; g_qosBad++; }                // reply arrived but CRC failed -> RF quality (channel/interference)
+    if (crcok && rxlen && rxlen<=64){                   // F1 report ~46B, so allow up to MAXLEN
+      uint8_t rtype=rfrx[2];                            // reply type byte (proven offset from captures)
+      // Only OUR controller's replies (F-type: 0xF1 input / 0xF2 disconnect / 0xF3 status) mark the link
+      // alive. Every OpenPuck shares the same RF address "ibex" + CRC config, and a puck transmits host-frame
+      // beacons (0xE1) + polls (0xE2/E3/E7) -- all E-type. Without this gate, puck A receives a SECOND puck's
+      // 0xE1 beacon (e.g. one just plugged into another computer), bumps g_connReplyMs, and the "new RF
+      // connection" wake in rfLinkTask() fires -> the second puck spuriously wakes this sleeping host.
+      if (rtype >= 0xF0){                                       // F-type reply (F1/F2/F3) -> our controller is alive
+        // A reply after a long gap (or the first ever) = a (re)connect. Arm the haptic block + re-init HERE,
+        // directly off the reply stream -- reliable even when hapticTask's 300ms link-up edge doesn't fire
+        // (e.g. a power-cycled controller that reconnects without us cleanly seeing the link drop).
+        if (g_connReplyMs==0 || (uint32_t)(millis()-g_connReplyMs) > 1500u) hapticOnReconnect();
+        g_connRx++; g_connReplyMs=millis();                     // link alive -> loop() suppresses the redundant E1 beacon
+        uint8_t rs=(uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);      // |dBm| of this reply (started by the ADDRESS short)
+        if(rs) g_linkRssi = g_linkRssi ? (uint8_t)((g_linkRssi*7u + rs + 4u)/8u) : rs;   // EWMA, ~8-sample horizon
+      }
+      if(rtype==0xF1) g_stF1++;
+      if(rtype==0xF2) g_connCooldown=millis();          // controller disconnecting/powering off -> back off 2.5s
+      if(rtype==0xF3){                                  // F3 = controller status/version reply (reply to E7 handshake, byte[6]=version)
+        g_stF3++; g_connF3v=rfrx[6];
+        if(g_connVerbose && Serial.availableForWrite()>40){ Serial.print("  F3 "); for(uint8_t i=0;i<(rxlen+2<32?rxlen+2:32);i++)Serial.printf("%02X",rfrx[i]); Serial.println(); }
+      }
+      bool isF1=(rtype==0xF1);
+      if(isF1){
+        g_connF1++;
+        int idx=3, end=rxlen+2;                         // walk ALL type6 TLVs (= HID report 0x45). idx is INT, not
+        const uint8_t* lastRep=nullptr; uint8_t lastTlen=0;  // uint8_t: a tlen of 0xFE would make idx+=tlen+2 wrap
+        while(idx+1<end){                               // mod-256 back to itself -> infinite loop -> USB hang/"crash".
+          uint8_t tlen=rfrx[idx], ttype=rfrx[idx+1];    // pace with the real puck -- taking only [0] halved our rate.
+          if(tlen==0) break;
+          // Only a FULL 0x45 report that fits entirely in rfrx: a short or late/garbled TLV must not let the
+          // decode read past the RF buffer (corrupts rftx/RAM -> eventual crash).
+          if(ttype==6 && tlen>=28 && (size_t)(idx+2)+tlen<=sizeof(rfrx) && rfrx[idx+2]==0x45){
+            const uint8_t* rep=&rfrx[idx+2];            // report 0x45: [0x45][seq][buttons u32]...
+            bool fresh=(rep[1]!=g_lastSeq); if(fresh){ g_stNew++; g_lastSeq=rep[1]; }  // genuine new report vs stale poll-repeat
+            uint32_t bb=btnsOf(rep);
+            // USB remote wakeup on Steam button short press (down + up within 1 s). A long press likely means
+            // the user is powering off the controller, so we ignore it.
+            { static bool steamWasDown=false; static unsigned long steamDownMs=0;
+              if(fresh){
+                bool steamNow=(bb & TB_STEAM)!=0;
+                if(steamNow && !steamWasDown) steamDownMs=millis();                                                            // rising edge: record press time
+                if(!steamNow && steamWasDown && millis()-steamDownMs<1000u && USBDevice.suspended()){                          // falling edge within 1 s -> short press -> wake
+                  USBDevice.remoteWakeup(); ledWakePulse();
+                  if(g_active) g_active->wakeEvent();   // post-resume nudge (host needs real input to actually wake)
+                }
+                steamWasDown=steamNow;
+              }
+            }
+            // Decode the report into the shared g_in (one source, read by every IController).
+            g_in.buttons=bb;
+            g_in.lx=(int16_t)s16off(rep,8);  g_in.ly=(int16_t)s16off(rep,10);
+            g_in.rx=(int16_t)s16off(rep,12); g_in.ry=(int16_t)s16off(rep,14);
+            g_in.lt=trigU8(u16off(rep,4));   g_in.rt=trigU8(u16off(rep,6));   // for the Switch digital-trigger threshold
+            g_in.lpx=(int16_t)s16off(rep,16); g_in.lpy=(int16_t)s16off(rep,18);
+            g_in.rpx=(int16_t)s16off(rep,22); g_in.rpy=(int16_t)s16off(rep,24);
+            imuFrom45(rep, &g_in.ax,&g_in.ay,&g_in.az,&g_in.gx,&g_in.gy,&g_in.gz);
+            // Mode-switch chord (all 4 back + face): don't leak the face press to the host. g_in.buttons stays
+            // intact so the chord detector still fires; per-mode builders mask the same bits while back-4 held.
+            if((bb&CHORD_BACK4)==CHORD_BACK4)
+              ((uint8_t*)rep)[2] &= ~(uint8_t)(TB_A|TB_B|TB_X|TB_Y);
+            // Hand the report to the active controller. STREAM modes ignore it (they emit from task() reading
+            // g_in); PUSH modes (Xbox, puck/lizard) build + send their host report here.
+            if(g_active) g_active->onReport45(rep, fresh, tlen);
+            lastRep=rep; lastTlen=tlen;
+          }
+          idx+=tlen+2;
+        }
+        // mode-switch chord (back4 + face): A=always Steam; B/X/Y=configurable (g_chordBtn[]). Debounced.
+        { static uint8_t chWant=0xFF, chCnt=0;
+          uint8_t want=0xFF;
+          if((g_in.buttons&CHORD_BACK4)==CHORD_BACK4){
+            if(g_in.buttons&TB_A) want=MODE_STEAM;
+            else if(g_in.buttons&TB_B) want=g_chordBtn[0];
+            else if(g_in.buttons&TB_X) want=g_chordBtn[1];
+            else if(g_in.buttons&TB_Y) want=g_chordBtn[2];
+          }
+          if(want!=0xFF && want==chWant){ if(++chCnt>=12 && want!=g_usbMode && modeValid(want) && !USBDevice.suspended()){ saveMode(want); delay(40); NVIC_SystemReset(); } }
+          else { chWant=want; chCnt=(want!=0xFF)?1:0; }
+        }
+        // compact stream for rf_controller_ui.py -- NON-BLOCKING: skip if CDC TX is backed up (a blocking
+        // Serial.print stalls the RF+USB loop -> jaggy input). One line/frame using the last record.
+        if(lastRep && !g_connVerbose && Serial.availableForWrite()>110 && millis()-g_lastStream>=4){
+          g_lastStream=millis(); Serial.print("I45 ");
+          for(uint8_t i=0;i<lastTlen;i++)Serial.printf("%02X",lastRep[i]); Serial.println();
+        }
+      }
+      if(g_connVerbose){
+        Serial.printf("%s CRX#%lu txtype%02X ch%u len%u: ",isF1?"<<<F1":(rtype==0xF3?"  F3":"  rx"),
+                      (unsigned long)g_connRx,payload[0],ch,rxlen);
+        for(uint8_t i=0;i<(rxlen+2<=66?rxlen+2:66);i++)Serial.printf("%02X",rfrx[i]); Serial.println();
+      }
+    } else rxlen=0;
+  } else { g_stNoRx++; g_qosBad++; }                   // RX window expired with no packet at all
+  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+  return rxlen;
+}
+
+// Drive the connected-mode sequence one step per call. Camps on g_sessCh (the host-frame channel the
+// controller connected on); the host-frame beacon runs in parallel as keepalive.
+static void rfConnStep(){
+  g_connSlot=-1; for(int s=0;s<NSLOT;s++) if(g_slot[s].used){g_connSlot=s;break;}
+  if(g_connSlot<0) return;               // need a bonded slot (session established by host frame)
+  uint8_t ch=g_sessCh;
+  if(g_connSt==0){                       // announce HOST AWAKE: E7 00 00, a few times
+    uint8_t p[3]={0xE7,0x00,g_e7b}; rfConnTx(ch,0x01,p,3);
+    if(++g_connStep>=4){ g_connSt=1; g_connStep=0; Serial.println("# CONN: awake announced -> polling GET report 0x45"); }
+  } else {                               // poll loop: E3 + GET report 0x45 every poll; re-assert awake periodically
+    if((uint32_t)(micros()-g_lastPollUs) < g_pollUs) return;   // CONTROLLED CADENCE: poll ~every g_pollUs
+    // FIXED-RATE schedule: advance the deadline by exactly one interval. The old `g_lastPollUs = micros()`
+    // reset the reference to AFTER the gate passed, so each cycle's ~1ms of poll work + loop jitter was added
+    // to the next interval and never reclaimed -- the period drifted to ~5000us (200/s) instead of 4000us
+    // (250/s). Advancing by g_pollUs keeps the long-run average at exactly the target; the resync guard
+    // prevents a catch-up burst if we ever fall more than a full interval behind (a real stall).
+    { uint32_t now=micros();
+      static uint32_t lastFire=0;
+      if(lastFire){ g_pollDtSum += (uint32_t)(now-lastFire); g_pollDtCnt++; }   // MEASURED actual period
+      lastFire=now;
+      g_lastPollUs += g_pollUs;
+      if((uint32_t)(now - g_lastPollUs) >= g_pollUs) g_lastPollUs = now;        // fell >1 interval behind -> resync
+    }
+    if((g_connPoll & 0x1F)==0){ uint8_t pa[3]={0xE7,0x00,g_e7b}; rfConnTx(ch,0x01,pa,3); }   // re-assert awake/version
+    rfConnQueueHapticRelay();
+    // Relay (if any) gets its OWN cycled PID, then the GET poll gets the NEXT one -- so they're always distinct
+    // and the controller never dedups the GET as a retransmit of the relay (that was dropping ~half the replies
+    // during haptics). Advancing g_e3pid even when no relay is pending just skips a PID value (harmless).
+    { uint8_t rs1=(uint8_t)((((g_e3pid++)&3)<<1)|1); rfConnFlushRelay(ch, rs1); }
+    {
+      uint8_t p[5]={0xE3,0x02,0x01,0x45,g_getParam}; // E3 + TLV [len=02][subtype=01 GET][id=0x45][param]
+      uint8_t s1 = (g_e3mode==1) ? (uint8_t)((((g_e3pid++)&3)<<1)|1)   // cycle PID (S1 1,3,5,7), NO_ACK=1
+                 : (g_e3mode==2) ? (uint8_t)(((g_e3pid++)&3)<<1)       // cycle PID (S1 0,2,4,6), NO_ACK=0
+                 : 0x07;                                               // fixed (matches captured puck poll)
+      uint8_t rx=rfConnTx(ch,s1,p,5);
+      if(rx) g_chF1[0]++;
+    }
+    g_connPoll++;
+  }
+}
+
+void rfLinkTask(){
+  // Host-frame beacon: sent continuously, INCLUDING while connected. The controller uses the periodic E1 (the
+  // real puck's per-hop-cycle announce) to stay synced and keep answering polls at full rate; suppressing it
+  // drops the reply rate from ~210/s to ~38/s. Paused only during the post-disconnect cooldown so a controller
+  // that's powering off isn't immediately re-woken/reconnected.
+  if (g_rfHost && millis()-g_connCooldown > 2500) {
+    bool connNow = (g_connSlot>=0 && millis()-g_connReplyMs < 300);
+    // session keepalive on the clean channel: every loop while connecting (fast), every 25ms once connected
+    // (every-loop beaconing also hammers the session ch and steals reply slots from the poll)
+    if (millis()-g_lastSessBeacon >= (connNow ? 25u : 0u)) { g_lastSessBeacon=millis(); g_rfCh=g_sessCh; for (int s=0;s<NSLOT;s++) rfHostFrameOnce(s, false); }
+    // discovery beacon on ch2 (where a searching controller looks): every loop when down, occasionally when up
+    if (millis()-g_lastDisc >= (connNow ? 200u : 0u)) { g_lastDisc=millis(); g_rfCh=2; for (int s=0;s<NSLOT;s++) rfHostFrameOnce(s, true); }
+  }
+  if (g_connOn && millis()-g_connCooldown > 2500) { rfConnStep(); }            // connected-mode: poll controller, read input
+  { static bool wasRfConn=false;                                               // remote wakeup on new RF controller connection
+    bool nowRfConn=(g_connSlot>=0 && millis()-g_connReplyMs<300);
+    if(nowRfConn && !wasRfConn && USBDevice.suspended()){
+      USBDevice.remoteWakeup(); ledWakePulse();
+      if(g_active) g_active->wakeEvent();   // post-resume nudge (host needs real input to actually wake)
+    }
+    wasRfConn=nowRfConn;
+  }
+  // QoS: if the current channel is degrading (crcfail+noRx), hop to the next clean candidate (conservative).
+  if (g_qos && g_connSlot>=0 && millis()-g_qosCheckMs>=600) {
+    uint16_t bad=g_qosBad; g_qosBad=0; g_qosCheckMs=millis();
+    if (bad>20 && millis()-g_qosLastHopMs>2000) {
+      for(int k=0;k<(int)sizeof g_hopCand;k++){ g_hopIdx=(g_hopIdx+1)%(sizeof g_hopCand); if(g_hopCand[g_hopIdx]!=g_sessCh) break; }
+      if(Serial.availableForWrite()>60) Serial.printf("# QoS: ch%u bad=%u -> hop ch%u\n",g_sessCh,bad,g_hopCand[g_hopIdx]);
+      rfHopTo(g_hopCand[g_hopIdx]); g_qosLastHopMs=millis();
+    }
+  }
+  if (g_connOn && millis()-g_stMs>=1000){ g_f1ps=g_stF1; g_newps=g_stNew; g_pollsps=(uint16_t)g_stPoll;
+    g_pollPeriodUs = g_pollDtCnt ? (uint16_t)(g_pollDtSum/g_pollDtCnt) : 0; g_pollDtSum=0; g_pollDtCnt=0; if(Serial.availableForWrite()>70) Serial.printf("# stat polls=%lu/s F1=%lu/s new=%lu/s F3=%lu/s(v%d) e7b=%u crcfail=%lu noRx=%lu slot=%d\n",(unsigned long)g_stPoll,(unsigned long)g_stF1,(unsigned long)g_stNew,(unsigned long)g_stF3,(int8_t)g_connF3v,g_e7b,(unsigned long)g_stCrc,(unsigned long)g_stNoRx,g_connSlot); g_stPoll=0; g_stF1=0; g_stNew=0; g_stF3=0; g_stCrc=0; g_stNoRx=0; g_chF1[0]=g_chF1[1]=g_chF1[2]=0; g_stMs=millis(); }
+}

+ 61 - 0
OpenPuck/rf_link.h

@@ -0,0 +1,61 @@
+// rf_link.h -- the operational puck<->controller RF protocol (the "dongle" role).
+//
+// Built on the radio HW layer (radio.cpp), this is what makes the device act as a puck once a controller is
+// bonded: it transmits the host frame the bonded controller reconnects on, runs the connected-mode poll loop
+// (E7 awake announce -> E3 + GET-report-0x45 each cycle), and decodes the controller's 0xF1 input reply. The
+// decode fills the shared g_in (triton.h) and dispatches to the active IController; it also handles the
+// mode-switch chord, USB remote-wakeup on a Steam short-press, adaptive channel hopping (QoS), and the
+// host-frame keepalive beacons. RE/calibration scaffolding lives separately in rf_diag.cpp.
+//
+// RECIPE (IBEX disasm @0x18d80): the controller streams 0xF1 ONLY when (a) connstate==3 [host frame E1
+// establishes it], (b) we mark host-awake via E7 [00][00], and (c) each E3 poll carries a GET-report-0x45
+// sub-TLV. F1 reply = type 0xF1 then TLV type4(analog/buttons)+type6(per-module gyro/accel/sticks).
+#pragma once
+#include <stdint.h>
+
+// ---- operational toggles + tunables (set from the CDC console / WebUSB; read here + by webusb status) ----
+extern bool     g_rfHost;       // auto-start host beacon on boot (resumes puck role after a USB replug)
+extern bool     g_connOn;       // auto-start connected-mode poll on boot
+extern uint8_t  g_connType;     // start packet type (E7 handshake, then E3)
+extern uint8_t  g_e7b;          // E7 payload B-byte: 0=current(slow/awake), 1=test protocol-version-1
+extern uint8_t  g_connLen;
+extern uint8_t  g_getParam;     // GET report 0x45 param byte
+extern uint8_t  g_e3mode;       // E3-poll PID/S1 mode (0=fixed07, 1=cyclePID+noack1, 2=cyclePID+noack0)
+extern bool     g_connVerbose;  // full multi-line debug dump vs compact "I45 <hex>" stream
+extern uint32_t g_rxWin;        // poll RX-window (us): caps poll rate (~1e6/rxWin)
+extern unsigned long g_connCooldown; // set on 0xF2 disconnect; pauses beacon+poll so a powering-off controller can sleep
+
+// connected-mode state (reset by the 'k' console toggle)
+extern uint8_t  g_connSt, g_connStep;
+extern uint16_t g_connPoll;
+extern uint32_t g_connF1;       // count of 0xF1 input reports seen
+extern uint8_t  g_connF3v;      // last protocol version the controller reported in an F3 reply (0xFF=none)
+
+// QoS adaptive channel hopping
+extern uint8_t  g_qos;          // 0=off (static g_sessCh), 1=auto-hop on degradation
+extern uint8_t  g_hopIdx;
+extern volatile uint16_t g_qosBad;
+extern unsigned long g_qosCheckMs, g_qosLastHopMs;
+
+// per-second rate readouts for the WebUSB status blob
+extern uint16_t g_f1ps;         // last completed second's F1 rate
+extern uint16_t g_newps;        // genuine new-report rate (report 0x45 seq byte changes)
+extern uint16_t g_pollsps;      // last second's poll TX count (GET+relay) -- vs F1 tells starvation from reply-loss
+extern uint16_t g_pollPeriodUs; // MEASURED avg us between GET-poll fires (compare to the intended g_pollUs=4000)
+
+// Smoothed controller->puck signal strength, sampled by the radio (RSSISAMPLE) on each CRC-good controller
+// reply during the poll. Stored as the dBm MAGNITUDE (35 = -35dBm); 0 = no sample yet. puck_hid reports it
+// to Steam in status report 0x7B byte 8 (signed dBm), after subtracting RSSI_DBM_OFFSET.
+extern volatile uint8_t g_linkRssi;
+// dB to subtract from our raw RSSI magnitude before reporting, to match the real puck's close-range -35dBm
+// (compensates the Pro Micro antenna vs Valve's front-end). Tune against one known-distance reading.
+#define RSSI_DBM_OFFSET 20
+
+// TX one connected packet [LEN][S1][payload] on channel ch, then RX the reply into rfrx; decodes 0xF1.
+// rxWinUs overrides the reply-wait window (0 = use g_rxWin). Pass a tiny value for NO-ACK relays that expect
+// no reply, so they don't burn a full ~1.2ms window of dead air per haptic.
+uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen, uint16_t rxWinUs=0);
+// Mid-session channel hop: advertise newCh on the current channel a few times, then move the poll to newCh.
+void rfHopTo(uint8_t newCh);
+// Per-loop: host-frame beacons + connected-mode poll + remote-wakeup + QoS hop + per-second stats.
+void rfLinkTask();

+ 84 - 0
OpenPuck/serial_console.cpp

@@ -0,0 +1,84 @@
+#include "serial_console.h"
+#include "radio.h"
+#include "rf_diag.h"
+#include "rf_link.h"
+#include "haptics.h"
+#include "bonds.h"
+#include "config.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <stdlib.h>
+
+// CDC commands: l=listen, s=stop, cN=channel, p<hex>=prefix, a<8hex>=base addr, b=bonds, etc.
+// NOTE: command order matters -- this is an else-if chain, so the FIRST matching letter wins. A couple of
+// letters ('C', 'H') appear twice; the second occurrence is unreachable (kept verbatim from the original).
+void serialConsolePoll(){
+  static char line[24]; static uint8_t li=0;
+  while (Serial.available()){
+    char c=Serial.read();
+    if (c=='\n'||c=='\r'){ line[li]=0;
+      if (line[0]=='l') rfListenStart();
+      else if (line[0]=='B'){ g_rfBeacon=!g_rfBeacon; g_rfListen=false; Serial.printf("# beacon %s ch%u\n",g_rfBeacon?"ON":"off",g_rfCh); }
+      else if (line[0]=='L'){ g_plen=strtol(line+1,0,16); Serial.printf("# plen=%02X\n",g_plen); }
+      else if (line[0]=='1'){ g_s1incl=!g_s1incl; Serial.printf("# s1incl=%u\n",g_s1incl); }
+      else if (line[0]=='R'){ g_rfCh=atoi(line+1); g_rfRaw=true; g_rfListen=false; g_rfBeacon=false; rfRawStart(g_rfCh); Serial.printf("# raw cap ch%u\n",g_rfCh); }
+      else if (line[0]=='Z'){ g_rfRaw=true; g_rfSweep=true; g_rfListen=false; g_rfBeacon=false; g_rfCh=2; rfRawStart(g_rfCh); Serial.println("# raw SWEEP"); }
+      else if (line[0]=='H'){ g_rfHost=!g_rfHost; g_rfBeacon=false; g_rfListen=false; g_rfRaw=false; g_rfSweep=false; g_rfCh=2;
+        Serial.printf("# HOSTFRAME beacon %s base %02X%02X%02X%02X (slot0 uuids %02X%02X%02X%02X/%02X%02X%02X%02X)\n",
+          g_rfHost?"ON":"off",g_rfBase[0],g_rfBase[1],g_rfBase[2],g_rfBase[3],
+          g_slot[0].rec[0],g_slot[0].rec[1],g_slot[0].rec[2],g_slot[0].rec[3],
+          g_slot[0].rec[4],g_slot[0].rec[5],g_slot[0].rec[6],g_slot[0].rec[7]); }
+      else if (line[0]=='C'){ g_rfBeacon=g_rfListen=g_rfRaw=g_rfSweep=g_rfHost=false; rfRespondStart(); }
+      else if (line[0]=='s'){ g_rfListen=false; g_rfBeacon=false; g_rfRaw=false; g_rfSweep=false; g_rfHost=false; g_rfRespond=false; NRF_RADIO->TASKS_DISABLE=1; Serial.println("# RF off"); }
+      else if (line[0]=='x'){ uint8_t m=strtoul(line+1,0,10); if(modeValid(m)){
+        if(USBDevice.suspended()){ Serial.println("# mode change blocked: host suspended"); }
+        else { Serial.printf("# switch mode %u (reboot)\n",m); delay(20); saveMode(m); delay(40); NVIC_SystemReset(); }
+      } }   // switch USB mode: 0=steam 1=xbox360 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro 7=xboxone
+      else if (line[0]=='c'){ g_rfCh=atoi(line+1); Serial.printf("# ch=%u\n",g_rfCh); if(g_rfListen) rfListenStart(); }
+      else if (line[0]=='p'){ g_rfPrefix=strtol(line+1,0,16); Serial.printf("# prefix=%02X\n",g_rfPrefix); if(g_rfListen) rfListenStart(); }
+      else if (line[0]=='i'){ g_crcinit=strtoul(line+1,0,16); Serial.printf("# crcinit=%06lX\n",(unsigned long)g_crcinit); }
+      else if (line[0]=='w'){ g_whiteiv=atoi(line+1); Serial.printf("# whiteiv=%u\n",g_whiteiv); }
+      else if (line[0]=='0'){ g_pcnf0=strtoul(line+1,0,16); Serial.printf("# pcnf0=%08lX\n",(unsigned long)g_pcnf0); }
+      else if (line[0]=='2'){ g_pcnf1=strtoul(line+1,0,16); Serial.printf("# pcnf1=%08lX\n",(unsigned long)g_pcnf1); }
+      else if (line[0]=='M'){ g_mode=strtoul(line+1,0,16); Serial.printf("# mode=%u (3=Ble1M,4=Ble2M,0/2=Nrf)\n",g_mode); }
+      else if (line[0]=='I'){ g_crcinit=strtoul(line+1,0,16); Serial.printf("# crcinit=%08lX\n",(unsigned long)g_crcinit); }
+      else if (line[0]=='P'){ g_crcpoly=strtoul(line+1,0,16); Serial.printf("# crcpoly=%lX\n",(unsigned long)g_crcpoly); }
+      else if (line[0]=='N'){ g_crccnf=strtoul(line+1,0,16); Serial.printf("# crccnf=%X\n",g_crccnf); }
+      else if (line[0]=='Y'){ g_rfCap=true; g_rfCapOne=true; g_rfReplay=g_rfHost=g_rfAuto=false; g_rfCh=2; rfCapStart(2); Serial.println("# capture ONE frame for replay..."); }
+      else if (line[0]=='y'){ g_rfReplay=!g_rfReplay; g_rfCap=g_rfHost=g_rfAuto=false; Serial.printf("# REPLAY %s (%uB)\n",g_rfReplay?"ON":"off",g_replayLen); }
+      else if (line[0]=='T'){ g_statlen=strtoul(line+1,0,16); g_pcnf1=0; Serial.printf("# statlen=%02X\n",g_statlen); }
+      else if (line[0]=='k'){ g_connOn=!g_connOn; if(g_connOn){ g_connSt=0; g_connStep=0; g_connPoll=0; g_connF1=0; } Serial.printf("# CONN mode %s: E7-awake[00 00] -> E3+GET-report-0x45 poll on ch%u (param=%02X). F1 seen=%lu\n",g_connOn?"ON":"off",g_rfCh,g_getParam,(unsigned long)g_connF1); }
+      else if (line[0]=='q'){ g_getParam = g_getParam ? 0x00 : 0x2D; Serial.printf("# GET-0x45 param=%02X\n",g_getParam); }
+      else if (line[0]=='v'){ g_connVerbose=!g_connVerbose; Serial.printf("# conn verbose %s (F1 seen=%lu)\n",g_connVerbose?"ON":"off",(unsigned long)g_connF1); }
+      else if (line[0]=='C'){ g_sessCh=strtoul(line+1,0,10); Serial.printf("# session channel=%u (re-pair/reconnect to apply)\n",g_sessCh); }
+      else if (line[0]=='E'){ g_mDiv=strtoul(line+1,0,10); if(g_mDiv<4)g_mDiv=4; saveCfg(); Serial.printf("# xbox-mouse sensitivity divisor=%d (lower=faster)\n",g_mDiv); }
+      else if (line[0]=='F'){ g_mFric=strtoul(line+1,0,10); if(g_mFric>99)g_mFric=99; saveCfg(); Serial.printf("# xbox-mouse friction=%d%% (higher=more glide/momentum)\n",g_mFric); }
+      else if (line[0]=='W'){ g_abSwap=!g_abSwap; saveCfg(); Serial.printf("# A/B + X/Y swap %s (Nintendo layout)\n",g_abSwap?"ON":"off"); }
+      else if (line[0]=='K'){ int i=line[1]-'0'; uint8_t code=strtoul(line+2,0,10); if(i>=0&&i<4){ g_back[i]=code; saveCfg(); Serial.printf("# back[%d] (%s) -> code %u  [0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide]\n",i,(const char*[]){"L4","R4","L5","R5"}[i],code); } else Serial.println("# usage: K<0-3> <code>  (0=L4 1=R4 2=L5 3=R5)"); }
+      else if (line[0]=='J'){ char* sp=0; uint8_t id=strtoul(line+1,&sp,0); uint16_t val=sp?strtoul(sp,0,0):0;  // inject SET-SETTINGS to controller: report 0x87 [id][val u16 LE]
+        uint8_t pl[3]={id,(uint8_t)(val&0xFF),(uint8_t)(val>>8)}; relayEnqueue(0x87,pl,3);
+        Serial.printf("# queued SET-SETTINGS id=0x%02X val=%u (relay 0x87) — watch new=/s\n",id,val); }
+      else if (line[0]=='G'){ g_sniff=!g_sniff; g_sniffPh=0; g_rfHost=g_connOn=g_rfCap=g_rfAuto=g_rfReplay=false; if(g_sniff) rfSniffStart(); Serial.printf("# SESSION SNIFF %s (phase0: catch host frame on ibex/ch2; phase1 PARK ch=%s)\n",g_sniff?"ON":"off",g_sniffPark?"override":"primary"); }
+      else if (line[0]=='g'){ g_sniffPark=strtoul(line+1,0,10); if(g_sniffPh==1) rfSniffStart(); Serial.printf("# sniff park channel=%u (0=learned primary)\n",g_sniffPark); }
+      else if (line[0]=='V'){ g_e7b=strtoul(line+1,0,10); Serial.printf("# E7 protocol-version B-byte=%u (0=awake/slow, 1=try v1-fast; watch new=/s; revert if input stops)\n",g_e7b); }
+      else if (line[0]=='O'){ g_relayOp=strtoul(line+1,0,16); Serial.printf("# relay opcode=%02X\n",g_relayOp); }
+      else if (line[0]=='o'){ g_relaySub=strtoul(line+1,0,16); Serial.printf("# relay sub-type=%02X\n",g_relaySub); }
+      else if (line[0]=='h'){ uint8_t nc=strtoul(line+1,0,10); Serial.printf("# HOP %u->%u (advertise on current ch, then poll new). Watch F1=/s\n",g_sessCh,nc); rfHopTo(nc); }
+      else if (line[0]=='z'){ g_qos=!g_qos; g_hopIdx=0; g_qosBad=0; g_qosCheckMs=millis(); Serial.printf("# QoS adaptive channel hopping %s (candidates 18,46,76,22,68)\n",g_qos?"ON":"off"); }
+      else if (line[0]=='r'){ g_rxWin=strtoul(line+1,0,10); if(g_rxWin<150)g_rxWin=150; Serial.printf("# poll RX-window=%lu us (poll rate caps ~%lu/s)\n",(unsigned long)g_rxWin,(unsigned long)(1000000/g_rxWin)); }
+      else if (line[0]=='e'){ g_e3mode=strtoul(line+1,0,10); Serial.printf("# E3 poll PID mode=%u (0=fixed07, 1=cyclePID+noack1, 2=cyclePID+noack0) - watch new=/s\n",g_e3mode); }
+      else if (line[0]=='t'){ uint8_t n=line[1]?strtoul(line+1,0,10):40;     // inject n test haptics (output 0x82 [01 01 F7]) over the relay
+        g_testHaptic=n; Serial.printf("# test-haptic burst x%u queued (relay 0x82 01 01 F7 via op=%02X sub=%02X)\n",n,g_relayOp,g_relaySub); }
+      else if (line[0]=='H'){ hapticDumpLog(); }   // dump the captured OUTPUT-report history (oldest->newest)
+      else if (line[0]=='j'){ g_connType=strtoul(line+1,0,16); Serial.printf("# connType=%02X\n",g_connType); }
+      else if (line[0]=='Q'){ g_connLen=strtoul(line+1,0,16); Serial.printf("# connLen=%02X\n",g_connLen); }
+      else if (line[0]=='A'){ g_balen=strtoul(line+1,0,16); g_pcnf1=0; Serial.printf("# balen=%u\n",g_balen); }
+      else if (line[0]=='a'){ uint32_t v=strtoul(line+1,0,16); g_rfBase[0]=v>>24;g_rfBase[1]=v>>16;g_rfBase[2]=v>>8;g_rfBase[3]=v; Serial.printf("# base=%08lX\n",(unsigned long)v); if(g_rfListen) rfListenStart(); }
+      else if (line[0]=='b'){ for(int i=0;i<NSLOT;i++) if(g_slot[i].used){Serial.printf("slot%d ",i); for(int j=0;j<24;j++)Serial.printf("%02X",g_slot[i].rec[j]); Serial.println();} }
+      else if (line[0]=='D'){ armDebugCdcNextBoot(); Serial.println("# debug-CDC armed: reset to KEEP the serial console one more boot (auto-reverts after; the wake mouse is dropped that boot)"); }
+      else if (line[0]=='X'){ g_rfCap=!g_rfCap; g_rfHost=g_rfAuto=g_rfBeacon=g_rfListen=g_rfRaw=g_rfSweep=g_rfRespond=false; g_rfCh=2; g_capV=0; if(g_rfCap) rfCapStart(g_rfCh); Serial.printf("# CAPTURE(listen) %s ch%u on \"ibex\", cycling 1M/2M x BALEN3/4 (use cN to set ch)\n", g_rfCap?"ON":"off",g_rfCh); }
+      else if (line[0]=='S'){ g_rfAuto=!g_rfAuto; g_rfHost=g_rfAuto; g_rfBeacon=g_rfListen=g_rfRaw=g_rfSweep=false; g_rfCh=2; g_cfgIdx=0; if(g_rfAuto) applyCfg(0); Serial.printf("# AUTOSWEEP %s\n", g_rfAuto?"ON":"off"); }
+      li=0;
+    } else if (li<sizeof line-1) line[li++]=c;
+  }
+}

+ 9 - 0
OpenPuck/serial_console.h

@@ -0,0 +1,9 @@
+// serial_console.h -- the CDC debug command line.
+//
+// Single-letter commands over USB CDC Serial, used during development and protocol RE: toggle RF diag modes,
+// poke radio registers live, switch USB mode, edit tunables, inject test haptics, dump capture history. The
+// operational firmware never needs it; it's the human/agent interface to everything under the hood. Present
+// only in puck modes (clean controller modes drop CDC). See the parser in serial_console.cpp for the full map.
+#pragma once
+
+void serialConsolePoll();   // drain + dispatch CDC commands; call every loop()

+ 28 - 0
OpenPuck/status_led.cpp

@@ -0,0 +1,28 @@
+#include "status_led.h"
+#include <Arduino.h>
+
+#define WAKE_LED_OFF ((WAKE_LED_ON)==HIGH ? LOW : HIGH)
+#define PULSE_MS 500u   // wake flash duration
+
+static unsigned long g_pulseMs = 0;
+static bool g_lit = false;
+
+static void ledWrite(int level){
+  digitalWrite(WAKE_LED_PIN_A, level);
+  digitalWrite(WAKE_LED_PIN_B, level);
+}
+
+void ledInit(){
+  pinMode(WAKE_LED_PIN_A, OUTPUT);
+  pinMode(WAKE_LED_PIN_B, OUTPUT);
+  ledWrite(WAKE_LED_OFF);
+}
+
+void ledWakePulse(){
+  g_pulseMs = millis(); g_lit = true;
+  ledWrite(WAKE_LED_ON);   // light immediately at the remoteWakeup() call site, not on the next loop
+}
+
+void ledTask(){
+  if (g_lit && millis()-g_pulseMs >= PULSE_MS){ g_lit = false; ledWrite(WAKE_LED_OFF); }
+}

+ 28 - 0
OpenPuck/status_led.h

@@ -0,0 +1,28 @@
+// status_led.h -- LED indication of wake activity.
+//
+// The LED is DARK in all steady states -- including while wake is armed (host suspended) -- and flashes for
+// half a second at the moment a wake is actually sent (USBDevice.remoteWakeup() fired by a Steam-button short
+// press, a controller connect, or the boot-time wake). This turns the LED into a wake debugger you can read
+// from the couch: flash + PC stays asleep = the resume signal was sent and the HOST ignored it (fix on the
+// host: powercfg /deviceenablewake); no flash = the firmware never fired (it didn't see the gesture, or it
+// didn't consider the bus suspended).
+//
+// Board note: the sketch is built with the Feather nRF52840 variant, but the usual hardware is a SuperMini
+// "Pro Micro" clone. The Feather's user LED is P1.15 (D3, active high); the SuperMini's blue user LED is
+// P0.15 (= D24 in the Feather pin map -- SPI MISO, unused by this project). We drive BOTH pins so the
+// indicator works on either board. Override the pins/polarity below if your board differs.
+#pragma once
+
+#ifndef WAKE_LED_PIN_A
+#define WAKE_LED_PIN_A LED_BUILTIN   // Feather: P1.15 user LED (harmless unconnected pad on SuperMini clones)
+#endif
+#ifndef WAKE_LED_PIN_B
+#define WAKE_LED_PIN_B 24            // SuperMini "Pro Micro" clone: P0.15 blue user LED (D24 in the Feather map)
+#endif
+#ifndef WAKE_LED_ON
+#define WAKE_LED_ON HIGH             // set LOW if your board's LED is wired active-low
+#endif
+
+void ledInit();        // call once from setup(): pins to output, LED off
+void ledWakePulse();   // call at each USBDevice.remoteWakeup() site: LED on now, off after 500ms
+void ledTask();        // call every loop(): times out the pulse

+ 8 - 0
OpenPuck/triton.cpp

@@ -0,0 +1,8 @@
+#include "triton.h"
+
+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);
+}

+ 61 - 0
OpenPuck/triton.h

@@ -0,0 +1,61 @@
+// triton.h -- the controller's native input as it comes off the RF link, and the decoders for it.
+//
+// "Triton" is the Steam Controller 2 controller. Its HID input report 0x45 is what the puck relays; rf_link.cpp
+// decodes one into the shared g_in struct each fresh frame, and every USB personality (the IController
+// implementations in mode_*.cpp) reads g_in to build its own host report. 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_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)
+#define SW_TRIG_ON 40   // analog-trigger fraction (of 0xFF) at which digital ZL/ZR (Switch) etc. trip
+
+// ---- 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 analog: the u16 in report 0x45 tops out near half-scale (~0x8000) at a full pull, so a
+// straight >>8 reads only ~0x80 (host sees a half-pressed trigger). Scale x2 (>>7) and saturate so a full
+// pull maps to the full 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 rf_link.cpp once per fresh report 0x45, read by every mode) ----
+// This single struct replaces the per-mode duplicates the firmware used to carry (the IMU/stick/button state
+// was copied verbatim into g_swPro*, g_ps5*, and g_gyro*). One source, many consumers.
+struct PuckInput {
+  uint32_t buttons;            // raw Triton buttons (TB_*); per-mode builders apply their own chord masking
+  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)
+  int16_t  ax, ay, az;         // accelerometer
+  int16_t  gx, gy, gz;         // gyroscope
+};
+extern PuckInput g_in;

+ 17 - 0
OpenPuck/wake_hid.cpp

@@ -0,0 +1,17 @@
+#include "wake_hid.h"
+#include <Adafruit_TinyUSB.h>
+
+// A bog-standard boot MOUSE descriptor -- the same shape Xbox mode's right-pad mouse uses, which is proven to
+// enumerate and wake Windows. We never send a report on it; it exists purely so the host enumerates a
+// "HID-compliant mouse" child and grants the device wake-from-sleep privileges. (A boot keyboard was tried
+// first and was worse: it didn't enumerate on Windows and suppressed the wake that the mouse class provides.)
+static const uint8_t WAKE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
+static Adafruit_USBD_HID g_wakeHid;
+
+void wakeHidBegin(){
+  g_wakeHid.setBootProtocol(HID_ITF_PROTOCOL_MOUSE);   // boot mouse = a wake device class honored by Windows + Linux
+  g_wakeHid.setStringDescriptor("OpenPuck Wake");
+  g_wakeHid.setReportDescriptor(WAKE_HID_DESC, sizeof WAKE_HID_DESC);
+  g_wakeHid.setPollInterval(10);
+  g_wakeHid.begin();
+}

+ 19 - 0
OpenPuck/wake_hid.h

@@ -0,0 +1,19 @@
+// wake_hid.h -- a minimal boot-protocol HID interface whose ONLY job is to make the device a host-recognized
+// USB wake source.
+//
+// Every mode already advertises remote-wakeup in its config descriptor (the Adafruit core sets
+// TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP), so the device is *armed* to wake the host. But hosts only *honor* a
+// remote-wakeup resume signal from an allow-listed input device class -- a HID keyboard/mouse. Windows
+// (especially under Modern Standby) ignores the wake from a bare gamepad / vendor / composite presentation
+// even though it's armed. Exposing a boot MOUSE interface (the same shape Xbox mode already uses to wake
+// Windows) puts the device in that allow-list. It never sends reports -- the actual wake is the device-level
+// USBDevice.remoteWakeup() resume signal driven from rf_link.cpp; this interface only changes how the host
+// classifies us. (A boot keyboard was tried and was worse -- it didn't enumerate on Windows.)
+//
+// Added for every clean mode AND for puck mode on a normal boot: puck mode drops its CDC serial console by
+// default (freeing the endpoint this interface needs) so it too can wake Windows. The exception is the one-shot
+// debug boot, where CDC is kept and this interface is skipped (no endpoint room for both) -- see config.h
+// (g_debugCdcThisBoot) and ARCHITECTURE.md.
+#pragma once
+
+void wakeHidBegin();   // register the boot-keyboard wake interface (call from setup() for clean modes)

+ 112 - 0
OpenPuck/webusb_config.cpp

@@ -0,0 +1,112 @@
+#include "webusb_config.h"
+#include "config.h"
+#include "bonds.h"
+#include "rf_link.h"
+#include "haptics.h"
+#include "puck_hid.h"
+#include <Arduino.h>
+#include <string.h>
+
+Adafruit_USBD_WebUSB usb_web;
+
+// blob payload = [ver=2][mode][mDiv][mFric][rsvd=0][abSwap][back0..3][connSlot(0xFF=none)][linkUp]
+//                [f1ps_lo][f1ps_hi][pollU100][newps_lo][newps_hi][e7b][relayOp][relaySub][fwdNewOnly]
+//                [qos][persistMode][chordBtn B][chordBtn X][chordBtn Y][pollsps_lo][pollsps_hi]
+//                [loopPeriod_lo][loopPeriod_hi][loopWorstIdx][loopWorstUs_lo][loopWorstUs_hi]
+//                [pollPeriod_lo][pollPeriod_hi][logEnabled]
+#define WB_PAYLEN 36
+static void webusbSendBlob(){
+  if(!usb_web.connected()) return;
+  bool up = (g_connSlot>=0 && (millis()-g_connReplyMs) < 300);
+  uint8_t p[2+WB_PAYLEN];
+  p[0]=0xA5; p[1]=WB_PAYLEN;
+  p[2]=2;                          // protocol version (2 = chordBtn[3] in blob)
+  p[3]=g_usbMode; p[4]=(uint8_t)g_mDiv; p[5]=(uint8_t)g_mFric; p[6]=0 /*rsvd: ex-padSmooth*/; p[7]=g_abSwap;
+  p[8]=g_back[0]; p[9]=g_back[1]; p[10]=g_back[2]; p[11]=g_back[3];
+  p[12]=(g_connSlot>=0)?(uint8_t)g_connSlot:0xFF;
+  p[13]=up?1:0;
+  p[14]=(uint8_t)g_f1ps; p[15]=(uint8_t)(g_f1ps>>8);
+  p[16]=(uint8_t)(g_pollUs/100);
+  p[17]=(uint8_t)g_newps; p[18]=(uint8_t)(g_newps>>8);
+  p[19]=g_e7b;
+  p[20]=g_relayOp; p[21]=g_relaySub; p[22]=g_fwdNewOnly; p[23]=g_qos; p[24]=g_persistMode?1:0;
+  p[25]=g_chordBtn[0]; p[26]=g_chordBtn[1]; p[27]=g_chordBtn[2];
+  p[28]=(uint8_t)g_pollsps; p[29]=(uint8_t)(g_pollsps>>8);   // poll TX rate (vs delivered/new -> starvation vs reply-loss)
+  p[30]=(uint8_t)g_loopPeriodUs; p[31]=(uint8_t)(g_loopPeriodUs>>8);   // loop diag: avg iteration time + slowest section
+  p[32]=g_loopWorst; p[33]=(uint8_t)g_loopWorstUs; p[34]=(uint8_t)(g_loopWorstUs>>8);
+  p[35]=(uint8_t)g_pollPeriodUs; p[36]=(uint8_t)(g_pollPeriodUs>>8);   // measured actual poll period (vs intended 4000)
+  p[37]=OPK_LOG;                                                       // logging build? panel shows/hides its log UI
+  usb_web.write(p,sizeof p); usb_web.flush();
+}
+#if OPK_LOG
+// Stream the capture ring (haptics / relayed host commands) to the panel as 0xA6 frames, so the browser can
+// pull it with no CDC. Frame formats:
+//   entry: [0xA6][L][T=1][age u32 LE][slot][rid][n][bytes n]   (L = 8 + n)
+//   end:   [0xA6][1][T=0]
+static void webusbCapFrame(uint32_t ms,uint8_t slot,uint8_t rid,uint8_t nb,const uint8_t* b){
+  if(nb>16) nb=16;
+  uint8_t f[2+9+16]; uint8_t L=(uint8_t)(8+nb);
+  f[0]=0xA6; f[1]=L; f[2]=1;
+  f[3]=(uint8_t)ms; f[4]=(uint8_t)(ms>>8); f[5]=(uint8_t)(ms>>16); f[6]=(uint8_t)(ms>>24);  // absolute log time
+  f[7]=slot; f[8]=rid; f[9]=nb; memcpy(f+10,b,nb);
+  usb_web.write(f,(uint16_t)(2+L)); usb_web.flush();
+}
+static void webusbCapEnd(){ uint8_t e[3]={0xA6,1,0}; usb_web.write(e,3); usb_web.flush(); }
+// 0x06: drain -- entries since the last drain (or since the 0x05 start/rewind). The panel polls this on a
+// timer and accumulates, so a dump-from-boot of the whole ring streams over many polls without blocking the
+// loop. A per-call budget bounds how long one poll spends here.
+static void webusbDrainCapture(){
+  if(!usb_web.connected()) return;
+  uint32_t ms=0; uint8_t slot=0,rid=0,nb=0,b[16]; uint16_t budget=128;
+  while(budget-- && hapLogPull(&ms,&slot,&rid,&nb,b)) webusbCapFrame(ms,slot,rid,nb,b);
+  webusbCapEnd();
+}
+#endif // OPK_LOG
+void webusbPoll(){
+  static uint8_t buf[16]; static uint8_t n=0;
+  while(usb_web.available()){
+    int c=usb_web.read(); if(c<0) break;
+    if(n<sizeof buf) buf[n++]=(uint8_t)c;
+    // process complete commands from the front of buf
+    for(;;){
+      if(n==0) break;
+      uint8_t op=buf[0]; uint8_t need = (op==0x02)?3 : (op==0x03||op==0x05)?2 : 1;   // 0x05 carries a value byte
+      if(op<0x01 || op>0x07){ // resync: drop one byte
+        memmove(buf,buf+1,--n); continue;
+      }
+      if(n<need) break;                      // wait for more bytes
+      if(op==0x01){ webusbSendBlob(); }
+#if OPK_LOG
+      else if(op==0x05){ hapLogResetDrain(buf[1]!=0); }   // rewind drain: buf[1]=1 from boot (whole ring), 0 from now
+      else if(op==0x06){ webusbDrainCapture(); }          // drain entries since the rewind (panel polls this)
+#endif
+      else if(op==0x07){ hapticReinit(); }                // clear a stuck/latched haptic buzz on the controller (functional)
+      else if(op==0x02){
+        uint8_t f=buf[1], v=buf[2];
+        bool persist=true;   // every settable field persists (poll rate is no longer settable)
+        switch(f){
+          case 1: g_mDiv = v<4?4:v; break;
+          case 2: g_mFric = v>99?99:v; break;
+          // case 3 (padSmooth) removed -- Steam-mode pad coords are forwarded raw; Steam does its own smoothing.
+          case 4: g_abSwap = v?1:0; break;
+          case 5: case 6: case 7: case 8: g_back[f-5]=v; break;
+          // case 9 (pollU100) removed -- poll rate is fixed at POLL_US_DEFAULT and no longer configurable.
+          case 10: g_e7b = v?1:0; break;                       // E7 protocol-version B-byte (experimental v1 fast)
+          case 11: g_relayOp = v; break;                       // haptic-relay opcode (buzz hunt)
+          case 12: g_relaySub = v; break;                      // haptic-relay sub-type (buzz hunt)
+          case 13: g_testHaptic = v?v:40; break;               // inject v test haptics (0->40)
+          case 14: g_fwdNewOnly = v?1:0; break;                // Steam: forward only fresh reports (dedupe)
+          case 15: g_qos = v?1:0; g_hopIdx=0; g_qosBad=0; g_qosCheckMs=millis(); break;  // QoS adaptive channel hopping
+          case 16: g_persistMode = v?true:false; break;   // persist last mode across reboots (else always boot Steam)
+          case 17: case 18: case 19: if(modeValid(v)) g_chordBtn[f-17]=v; break;   // back4+B/X/Y mode assignments
+          case 20: armDebugCdcNextBoot(); usb_web.flush(); delay(40); NVIC_SystemReset(); break;  // reboot once WITH the CDC serial console (puck mode), then auto-revert
+        }
+        if(persist) saveCfg();
+        webusbSendBlob();
+      } else if(op==0x03){
+        uint8_t m=buf[1]; if(modeValid(m) && !USBDevice.suspended()){ webusbSendBlob(); usb_web.flush(); saveMode(m); delay(40); NVIC_SystemReset(); }
+      }
+      memmove(buf,buf+need,n-need); n-=need;
+    }
+  }
+}

+ 15 - 0
OpenPuck/webusb_config.h

@@ -0,0 +1,15 @@
+// webusb_config.h -- WebUSB configuration channel.
+//
+// A dedicated vendor interface present in EVERY mode so the browser panel (the GitHub-Pages config UI) can
+// read/set the runtime tunables. Binary framed protocol:
+//   host->dev:  0x01                 GET  -> reply status blob
+//               0x02 <field> <value> SET one byte field (see the switch in webusb_config.cpp)
+//               0x03 <mode>          switch USB mode (0..7): persist + reboot
+//   dev->host:  0xA5 <len> <payload>  status blob (mode/tunables/link state/rates)
+// No setLandingPage() on purpose -- that would pop a Chrome "open <url>?" notification on every plug-in.
+#pragma once
+#include <Adafruit_TinyUSB.h>
+
+extern Adafruit_USBD_WebUSB usb_web;
+
+void webusbPoll();   // drain host commands + reply; call every loop()

+ 2 - 0
README.md

@@ -53,6 +53,8 @@ A webusb based configuration UI is available [here](https://safijari.github.io/o
 - Allow bluetooth controllers to pair with and connect to the puck
 
 # Contributions
+The firmware is split into small, single-responsibility modules under `OpenPuck/` (one file per emulated controller, plus the RF, config, and host-interface layers). Start with [ARCHITECTURE.md](./ARCHITECTURE.md) for the map of how it all fits together and how to add a new USB personality.
+
 I have tested this software fairly extensively but I have limited resources. I only have one controller so I actually don't know if multiple controllers even work correctly (in fact I'm almost sure they will not). Please submit issues with any issues you find. PRs also welcome of course.
 
 # Acknowledgements

+ 23 - 1
docs/PROTOCOL.md

@@ -88,6 +88,26 @@ When Steam writes feature report `0x01`, OpenPuck forwards it over RF to the con
 
 This is how settings writes, haptics, and lizard-disable requests reach the controller.
 
+The relay carries the command's **declared length** (the `[len]` byte of the feature payload), up to 60 bytes —
+the most one RF frame fits (`[E3][len][05][rid]` + payload ≤ MAXLEN 64). Multi-register `0x87` settings blocks
+(e.g. LED brightness) and calibration writes exceed 18 bytes, so any shorter cap silently corrupts them and the
+settings never land on the controller. Relays are staged in a small ring (not a single buffer): the USB SET
+callbacks run in ISR context and Steam sends settings/calibration as back-to-back bursts, so a single pending
+slot both drops reports and can be torn mid-flush. One queued relay is emitted per poll cycle.
+
+### 3.4 Connection presentation (input reports `0x79` / `0x7B`)
+
+- `0x79` (1 byte): connection state, `0x02` = connected, `0x01` = disconnected. **Edge-triggered**, like the
+  real puck. OpenPuck additionally re-sends it every 750 ms after a connect edge *until Steam reacts* (its
+  first OUTPUT/settings write after the edge), covering a missed edge. It must not be re-sent unconditionally:
+  each `0x79=02` can re-trigger Steam's connect handling (connect chime) and loop the haptic before Steam
+  starts consuming `0x45`.
+- `0x7B` (12 bytes): periodic status, sent every 2 s while connected. Byte 8 is the controller→puck signal
+  strength as **signed dBm** (live-captured example `0xDD` = −35 dBm). OpenPuck fills it with the smoothed
+  radio RSSI sampled on each CRC-good controller reply (`RSSISAMPLE`, started by an `ADDRESS→RSSISTART`
+  short during the poll RX window). The other bytes are replayed from the live capture
+  (`F7 01 89 00 00 00 03 00 __ 00 3A 02`); their semantics are unconfirmed.
+
 ## 4. Radio physical layer
 
 OpenPuck uses Nordic raw radio mode configured to match the puck firmware:
@@ -297,7 +317,9 @@ does a `detach -> rebuild -> attach` so the host re-reads the descriptor cleanly
   that arrives before the heartbeat.
 - **Haptics are gated on this decision**: haptic reports are **never** relayed to the controller while
   the puck is presenting lizard. Relaying haptics while Steam isn't reading `0x45` back made Steam loop
-  the same command, leaving the controller buzzing; suppressing them keeps the lizard state clean.
+  the same command, leaving the controller buzzing; suppressing them keeps the lizard state clean. The
+  same gate applies during the post-resume input mute (`POST_RESUME_MUTE_MS`): Steam can't read `0x45`
+  back in that window either, so a wake-time haptic would loop identically.
 - **Which OUTPUT reports are relayed**: the haptic/actuator reports `0x80`–`0x86` are forwarded to the
   **connected slot only** (the slot gate is what stops a haptic aimed at another of the four exposed
   slots from buzzing the single controller). The 63-byte settings/config reports `0x87`/`0x88`/`0x89`

+ 104 - 2
docs/index.html

@@ -83,6 +83,14 @@
         <div class="stat"><div class="k">Slot</div><div class="v" id="stSlot">—</div></div>
         <div class="stat"><div class="k">Delivered</div><div class="v" id="stRate">—</div></div>
         <div class="stat"><div class="k">New reports</div><div class="v" id="stNew">—</div></div>
+        <div class="stat logonly"><div class="k">Polls/s</div><div class="v" id="stPolls">—</div></div>
+        <div class="stat logonly"><div class="k">Loop period</div><div class="v" id="stLoop">—</div></div>
+        <div class="stat logonly"><div class="k">Slowest stage</div><div class="v" id="stWorst">—</div></div>
+        <div class="stat logonly"><div class="k">Poll period act/want</div><div class="v" id="stPollPer">—</div></div>
+      </div>
+      <p class="note logonly">Polls/s ≈ 250 expected; it's capped by Loop period (≈4000 µs needed). "Slowest stage" is the loop section eating the most time per iteration.</p>
+      <div class="row" style="margin:10px 0 0"><button id="hapClear">Clear stuck buzz</button>
+        <span class="note" style="flex:1">If the controller's haptics get stuck/buzzing, this re-inits them (same as pressing Steam).</span>
       </div>
     </div>
 
@@ -107,6 +115,18 @@
       <p class="note">Back-paddle → button, applied in Xbox &amp; Switch modes.</p>
     </div>
 
+    <div class="card logonly">
+      <h2>Capture — host → controller commands</h2>
+      <p class="note" style="margin-top:0">The firmware logs everything from boot into a big RAM ring: Steam's writes (<code>ifN</code>), the frames we transmit to the controller (<code>TX→ctlr</code>), and RF <code>LINK UP/DOWN</code> edges. For the reconnect buzz: trigger it (it appears moments after the puck boots / the controller reconnects), then connect here and click <b>Dump from boot</b> — the trigger is still in the ring. (Only present in a logging build, <code>-DOPK_LOG=1</code>.)</p>
+      <div class="row" style="margin:0;flex-wrap:wrap">
+        <button id="capStart" class="primary">Dump from boot</button>
+        <button id="capStop" disabled>Stop</button>
+        <button id="capDl">Download .txt</button>
+      </div>
+      <p class="note">Dump streams the whole ring (oldest→newest) and then keeps live-updating until you press Stop. <code>cmd</code> is the command/report byte, then the raw bytes.</p>
+      <pre id="capOut" style="font:12px ui-monospace,Menlo,monospace;color:var(--fg);background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px;margin-top:10px;max-height:260px;overflow:auto;white-space:pre-wrap"></pre>
+    </div>
+
     <div id="log"></div>
   </div>
 </div>
@@ -115,7 +135,7 @@
 const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","Xbox One"];
 const CHORD_FIELD = [17, 18, 19];
 const BTN_CODES = [[0,"— none —"],[1,"A"],[2,"B"],[3,"X"],[4,"Y"],[5,"LB"],[6,"RB"],[7,"L3"],[8,"R3"],[9,"Back"],[10,"Start"],[11,"Guide"]];
-let dev=null, epIn=0, epOut=0, ifNum=0, polling=false;
+let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false;
 const $ = s=>document.querySelector(s);
 function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+"  "+m+"\n"+l.textContent).slice(0,2000); }
 
@@ -186,6 +206,14 @@ function applyBlob(p){
   const back=[p[6],p[7],p[8],p[9]], slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
   const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
   const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,2];   // v2 blob: chordBtn after qos/persist
+  const polls=(p.length>27?(p[26]|(p[27]<<8)):0);          // poll TX rate
+  const loopUs=(p.length>29?(p[28]|(p[29]<<8)):0);         // loop diag
+  const worstIdx=(p.length>30?p[30]:0), worstUs=(p.length>32?(p[31]|(p[32]<<8)):0);
+  const WORST=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led"];
+  const pollUs=(p.length>34?(p[33]|(p[34]<<8)):0);         // MEASURED poll period
+  const pollIntended=(p.length>14?p[14]*100:0);            // intended poll interval (g_pollUs)
+  const logEnabled=(p.length>35?p[35]:0);                  // is this a logging build (-DOPK_LOG=1)?
+  for(const el of document.querySelectorAll(".logonly")) el.style.display = logEnabled ? "" : "none";
   // mode buttons
   for(const b of document.querySelectorAll(".modebtn")) b.classList.toggle("active", +b.dataset.mode===mode);
   $("#mouseCard").style.opacity = (mode===1||mode===3||mode===7)?1:0.5;
@@ -194,6 +222,10 @@ function applyBlob(p){
   $("#stSlot").textContent = slot===0xFF? "—" : slot;
   $("#stRate").textContent = up? f1+" /s" : "—";
   $("#stNew").textContent = up? newps+" /s" : "—";
+  $("#stPolls").textContent = up? polls+" /s" : "—";
+  $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
+  $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
+  $("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
   // sliders (only if not being dragged)
   setSlider("mDiv",mDiv); setSlider("mFric",mFric);
   $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
@@ -206,13 +238,79 @@ function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ e
 
 let inflight=false;
 async function refresh(){
-  if(!dev||inflight) return; inflight=true;
+  if(!dev||inflight||capturing) return; inflight=true;   // don't let the status poll steal capture frames
   await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
   inflight=false;
 }
 async function setField(field,value){
   await send([0x02, field, value&0xff]); const p=await readBlob(); if(p) applyBlob(p);
 }
+function fmtCapEntry(e,maxMs){
+  const hex=e.bytes.map(x=>x.toString(16).padStart(2,"0")).join(" ");
+  const t = `-${String(maxMs-e.ms).padStart(6," ")}ms  `;   // age vs the newest entry (consistent across drain batches)
+  if(e.slot===0xFD){ const k=e.bytes[0]; return `${t}════════ ${k===2?"RECONNECT (block+reinit armed)":k===1?"RF LINK UP":"RF LINK DOWN"} ════════`; }
+  const who = (e.slot===0xFE) ? "TX→ctlr " : `if${e.slot}     `;                        // 0xFE = frame WE sent the controller
+  return `${t}${who} cmd=${e.rid.toString(16).padStart(2,"0")}  n=${e.nb}:  ${hex}`;
+}
+function downloadCap(){
+  const t=window._lastCap||$("#capOut").textContent||"";
+  const a=document.createElement("a");
+  a.href=URL.createObjectURL(new Blob([t],{type:"text/plain"}));
+  a.download="puck-capture.txt"; a.click();
+}
+// ---- live Start/Stop capture: poll the firmware's drain (op 0x06) on a timer and accumulate in the browser ----
+let liveTimer=null, draining=false, capLines=[];   // capLines = array of {ms,slot,rid,nb,bytes}
+function renderCap(){
+  if(!capLines.length){ $("#capOut").textContent="(nothing captured yet)"; window._lastCap=""; return; }
+  const maxMs=capLines[capLines.length-1].ms;       // entries are chronological -> newest is last
+  const t=capLines.map(e=>fmtCapEntry(e,maxMs)).join("\n");
+  $("#capOut").textContent=t; window._lastCap=t;
+}
+async function drainTick(){
+  if(!dev||draining) return; draining=true;
+  let acc=new Uint8Array(0);
+  try{
+    await send([0x06]);
+    let done=false, guard=0;
+    while(!done && guard++<400){
+      const r=await dev.transferIn(epIn,64);
+      if(r.status!=="ok") break;
+      const d=new Uint8Array(r.data.buffer);
+      const m=new Uint8Array(acc.length+d.length); m.set(acc); m.set(d,acc.length); acc=m;
+      let i=0;
+      while(i<acc.length){
+        if(acc[i]!==0xA6){ i++; continue; }
+        if(i+2>acc.length) break;
+        const L=acc[i+1]; if(i+2+L>acc.length) break;
+        const f=acc.slice(i+2,i+2+L); i+=2+L;
+        if(f[0]===0){ done=true; break; }
+        if(f[0]===1 && L>=8){
+          const ms=((f[1])|(f[2]<<8)|(f[3]<<16)|(f[4]<<24))>>>0;
+          capLines.push({ms, slot:f[5], rid:f[6], nb:f[7], bytes:[...f.slice(8,8+f[7])]});
+        }
+      }
+      acc=acc.slice(i);
+    }
+  }catch(e){ log("drain err: "+e.message); }
+  draining=false;
+  if(liveTimer){ const el=$("#capOut"); const atBottom=el.scrollTop+el.clientHeight>=el.scrollHeight-4; renderCap(); if(atBottom) el.scrollTop=el.scrollHeight; }
+}
+async function startCapture(){
+  if(!dev||capturing) return;
+  capturing=true; capLines=[];                       // capturing=true pauses the status poll so it can't steal frames
+  $("#capOut").textContent="(dumping ring from boot, then live — press Stop when done)";
+  $("#capStart").disabled=true; $("#capStop").disabled=false;
+  await send([0x05,1]);                               // rewind drain to OLDEST -> dumps the whole ring from boot
+  liveTimer=setInterval(drainTick,100);
+}
+async function stopCapture(){
+  if(liveTimer){ clearInterval(liveTimer); liveTimer=null; }
+  await drainTick();                                  // final flush
+  try{ await send([0x05,0]); }catch(e){}
+  capturing=false;
+  $("#capStart").disabled=false; $("#capStop").disabled=true;
+  renderCap(); log(`capture stopped — ${capLines.length} entries`);
+}
 async function startPolling(){
   polling=true; await refresh();
   while(polling && dev){ await new Promise(r=>setTimeout(r,600)); await refresh(); }
@@ -220,6 +318,10 @@ async function startPolling(){
 
 // UI wiring
 $("#connectBtn").onclick=connect;
+$("#capDl").onclick=downloadCap;
+$("#capStart").onclick=startCapture;
+$("#capStop").onclick=stopCapture;
+$("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); log("haptic re-init sent (clear stuck buzz)"); } };
 $("#abSwap").onclick=()=>{ const on=$("#abSwap").classList.contains("active"); setField(4, on?0:1); };
 $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
 for(const id of ["mDiv","mFric"]){