Bläddra i källkod

Merge main and port dpad back-mapping

copilot-swe-agent[bot] 4 månader sedan
förälder
incheckning
d67d3b2738
47 ändrade filer med 3534 tillägg och 1985 borttagningar
  1. 280 0
      ARCHITECTURE.md
  2. 86 1980
      OpenPuck/OpenPuck.ino
  3. 30 0
      OpenPuck/bonds.cpp
  4. 24 0
      OpenPuck/bonds.h
  5. 66 0
      OpenPuck/config.cpp
  6. 70 0
      OpenPuck/config.h
  7. 26 0
      OpenPuck/controllers.cpp
  8. 34 0
      OpenPuck/controllers.h
  9. 88 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. 83 0
      OpenPuck/mode_switch_hori.cpp
  22. 15 0
      OpenPuck/mode_switch_hori.h
  23. 228 0
      OpenPuck/mode_switch_pro.cpp
  24. 17 0
      OpenPuck/mode_switch_pro.h
  25. 172 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. 18 2
      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 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** 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 - 1980
OpenPuck/OpenPuck.ino

@@ -1,2034 +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   // 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_MAX     6
-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 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 (0..11 buttons, 12..15 D-pad U/D/L/R)
-#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 12=Dup 13=Ddown 14=Dleft 15=Dright
-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;
-    case 12:return XB_DUP; case 13:return XB_DDOWN; case 14:return XB_DLEFT; case 15:return XB_DRIGHT; 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 inline void backCodeToHatDirs(uint8_t c, bool& u, bool& d, bool& l, bool& r){
-  if(c==12) u=true;
-  else if(c==13) d=true;
-  else if(c==14) l=true;
-  else if(c==15) r=true;
-}
-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
-  if(b&TB_L4) backCodeToHatDirs(g_back[0],u,d,l,r);
-  if(b&TB_R4) backCodeToHatDirs(g_back[1],u,d,l,r);
-  if(b&TB_L5) backCodeToHatDirs(g_back[2],u,d,l,r);
-  if(b&TB_R5) backCodeToHatDirs(g_back[3],u,d,l,r);
-  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;
-    case 12:*b|=TB_DUP; break; case 13:*b|=TB_DDN; break; case 14:*b|=TB_DLF; break; case 15:*b|=TB_DRT; 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;
-    case 12:return JC_BTN_UP; case 13:return JC_BTN_DOWN; case 14:return JC_BTN_LEFT; case 15:return JC_BTN_RIGHT; 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(g_usbMode==MODE_XBOX){            // 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..6): 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=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro
-      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 12=Dup 13=Ddown 14=Dleft 15=Dright]\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'};   // modes 1..6
-  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 (g_usbMode == MODE_XBOX) {        // XBOX presentation: XInput vendor interface + HID boot mouse
-    USBDevice.setID(0x045E, 0x028E);   // device-level 045E:028E match -> Windows xusb / SDL / Linux xpad all bind it
-    USBDevice.setDeviceVersion(0x0114);
-    USBDevice.setManufacturerDescriptor("Microsoft");
-    USBDevice.setProductDescriptor("Controller");
-    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)","XBOX(xinput+mouse)","SWITCH(horipad)","LIZARD(puck kb/mouse)","SWITCH(pro+gyro)","PS5(dualsense)","HIDGYRO(ds4+motion)"};
   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 (g_usbMode==MODE_XBOX && g_rumble && millis()-g_rumbleMs > RUMBLE_STUCK_MS) g_rumble=0;
-  if (g_usbMode==MODE_XBOX && 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 (0..11 buttons, 12..15 D-pad U/D/L/R)
+
+// 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

+ 70 - 0
OpenPuck/config.h

@@ -0,0 +1,70 @@
+// 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   // 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_MAX     6
+
+static inline bool modeIsPuck(uint8_t m){ return m==MODE_STEAM || m==MODE_LIZARD; }
+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 (0..11 buttons, 12..15 D-pad U/D/L/R)
+
+#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);

+ 26 - 0
OpenPuck/controllers.cpp

@@ -0,0 +1,26 @@
+#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:    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;

+ 88 - 0
OpenPuck/gamepad_util.cpp

@@ -0,0 +1,88 @@
+#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;
+    case 12:*b|=TB_DUP; break; case 13:*b|=TB_DDN; break; case 14:*b|=TB_DLF; break; case 15:*b|=TB_DRT; 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;

+ 83 - 0
OpenPuck/mode_switch_hori.cpp

@@ -0,0 +1,83 @@
+#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; }
+}
+static inline void backCodeToHatDirs(uint8_t c, bool& u, bool& d, bool& l, bool& r){
+  if(c==12) u=true;
+  else if(c==13) d=true;
+  else if(c==14) l=true;
+  else if(c==15) r=true;
+}
+// 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
+  if(b&TB_L4) backCodeToHatDirs(g_back[0],u,d,l,r);
+  if(b&TB_R4) backCodeToHatDirs(g_back[1],u,d,l,r);
+  if(b&TB_L5) backCodeToHatDirs(g_back[2],u,d,l,r);
+  if(b&TB_R5) backCodeToHatDirs(g_back[3],u,d,l,r);
+  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;

+ 228 - 0
OpenPuck/mode_switch_pro.cpp

@@ -0,0 +1,228 @@
+#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;
+    case 12:return JC_BTN_UP; case 13:return JC_BTN_DOWN; case 14:return JC_BTN_LEFT; case 15:return JC_BTN_RIGHT; 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;

+ 172 - 0
OpenPuck/mode_xinput.cpp

@@ -0,0 +1,172 @@
+#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 12=Dup 13=Ddown 14=Dleft 15=Dright
+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;
+    case 12:return XB_DUP; case 13:return XB_DDOWN; case 14:return XB_DLEFT; case 15:return XB_DRIGHT; 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;
+  USBDevice.setID(0x045E, 0x028E);   // device-level 045E:028E match -> Windows xusb / SDL / Linux xpad all bind it
+  // 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.setManufacturerDescriptor("Microsoft");
+  USBDevice.setProductDescriptor("Controller");
+  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 360 wired (XInput) personality (MODE_XBOX): XInput gamepad + right-pad mouse.
+//
+// Real Xbox 360 pads are NOT HID: they use a vendor interface (class 0xFF / sub 0x5D / proto 0x01) carrying a
+// 20-byte XInput report. 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=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro
+      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 12=Dup 13=Ddown 14=Dleft 15=Dright]\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..6): 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()

+ 18 - 2
README.md

@@ -17,17 +17,31 @@ There are two fundamental problems with the controller:
 2. Steam Input isn't just a nicety, it's a requirement. This means that the controller is basically useless unless you have Steam running (outside of certain contexts that I personally consider niche). If, for example, you have gamepass and want to play FH6 through Gamepass you're gonna be in for a bad time and you'll probalby need specialized software running on your computer in order to make the controller work with it.
 
 # What this project does
+**Video Intro**
+
+[![OpenPuck Intro](https://img.youtube.com/vi/gSaqO9oqq9s/0.jpg)](https://www.youtube.com/watch?v=gSaqO9oqq9s)
+
 OpenPuck uses a [Pro Micro NRF52840](https://www.amazon.com/dp/B0GSZ7FD6T) ($8 on Amazon, possibly cheaper elsewhere) which uses a radio similar to the one being used by the controller and the puck. Once the arduino sketch is uploaded it emulates the puck over USB to Steam by default and allows pairing the controller normally (almost, the lizard mode for when Steam is off might not be 1:1).
 
 At any point you can hold all 4 back buttons and press X to switch over to ***Xbox mode** which maps all canonical inputs to their expected counterparts (plus L4 -> LB, L5 -> L3, etc which are configurable). In this mode the right trackpad acts as a mouse but at present this only works in Android and SteamOS.
 
-Similarly you can hold all 4 back buttons and press Y to switch (teehee) over to a **Switch mode**. This emulates a Pokken controller and is very limited (no gyro, no haptics, etc) but it's functional enough for my kids to play Animal Crossing :D.
+Similarly you can hold all 4 back buttons and press Y to switch (teehee) over to a **Switch mode**. This emulates a Pokken controller and is very limited (no gyro, no haptics, etc) but it's functional enough for my kids to play Animal Crossing :D. There's other modes as well:
+
+| Button combo (configurable) | Mode | Comment |
+|---|---|---|
+| back-4 + A | Steam | Steam Controller Mode |
+| back-4 + B | Lizard | Lizard mode, even if Steam is open |
+| back-4 + X | Xbox | Xbox 360 Controller |
+| back-4 + Y | HORI | Switch Controller (works on the consoles) |
+| WebUSB panel → mode 4 | Switch Pro + Gyro | PC only |
+| WebUSB panel → mode 5 | DualSense + Gyro + Trackpad | PC only |
+| WebUSB panel → mode 6 | DS4/HIDGYRO + Gyro + Trackpad | PC only |
 
 # How to use it
 You will need to flash OpenPuck.ino onto a compatible microcontroller. See [build instructions document](./docs/BUILD_AND_DEPLOY.md) for more details.
 
 # Configuration
-A webusb based configuration UI is available [here](https://safijari.github.io/openpuck/). It allows Switching the mode manually and changing the back button mapping for Xbox mode among other things. This will likely only work in Chrome and Edge and needs the pro micro to be connected via USB to the same computer for it to function.
+A webusb based configuration UI is available [here](https://safijari.github.io/openpuck/). It allows Switching the mode manually and changing the back button mapping for other modes among other things. This will likely only work in Chrome and Edge and needs the pro micro to be connected via USB to the same computer for it to function.
 
 # Future work
 - Find a way to make Xinput mode and mouse work together on all platforms
@@ -38,6 +52,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

@@ -82,6 +82,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>
 
@@ -106,6 +114,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>
@@ -114,7 +134,7 @@
 const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)"];
 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"],[12,"D-pad Up"],[13,"D-pad Down"],[14,"D-pad Left"],[15,"D-pad Right"]];
-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); }
 
@@ -185,6 +205,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)?1:0.5;
@@ -193,6 +221,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);
@@ -205,13 +237,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(); }
@@ -219,6 +317,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"]){