Prechádzať zdrojové kódy

Merge main into lizard-map branch: reconcile lizard rewrite with multi-controller/stability rework

Both sides rewrote mode_lizard + puck_hid; main also added the whole multi-
controller/stability/haptics/beta-0x42 rework (12 commits) this branch predated.

Reconciliation:
- mode_lizard: kept this branch's g_lizardMap binding-table rfLizard (merge-all).
  It reads g_in[] (buttons/sticks/pads), which main's per-slot decode populates for
  every slot and for BOTH report 0x45 and beta 0x42 -- so it already handles multi-
  controller and 0x42 without main's per-slot rfLizard signature.
- puck_hid: fire the merge-all rfLizard once per cycle, gated on the lowest
  CONNECTED slot (not lowest bonded) so an offline low bond can't starve lizard --
  preserves main's #98 "any bond slot works" fix.
- webusb_config: kept ALL of main's opcodes; renumbered this branch's 5 lizard-map
  commands 0x0D-0x11 -> 0x11-0x15 (main claimed 0x0D-0x10 for bond-import/stability/
  flight) and the lizard reply frame 0xA7 -> 0xA8 (main uses 0xA7 for bond export).
- docs/index.html + PROTOCOL.md: kept both feature sets; panel JS + docs renumbered
  to match (0x11-0x15, 0xA8); dropped the branch's duplicate per-connect disconnect
  listener (main registers one globally).

Builds clean (make build) and passes clang-format (make check).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Jari 3 mesiacov pred
rodič
commit
8849c4a1d6

+ 44 - 1
.github/workflows/build.yml

@@ -4,14 +4,28 @@ on:
   push:
     branches:
       - main
+    tags:
+      - '[0-9]*.[0-9]*.[0-9]*'
+  # PRs get the same gate -- without this the checks only ran after merge.
+  pull_request:
+  pull_request_review:
+    types:
+      - submitted
+
+permissions:
+  contents: read
 
 jobs:
   compile:
+    if: github.event_name != 'pull_request_review' || github.event.review.state == 'approved'
     runs-on: ubuntu-latest
 
     steps:
       - name: Check out repository
         uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+          ref: ${{ github.event.pull_request.head.sha || github.event.review.pull_request.head.sha || github.sha }}
 
       - name: Set up Arduino CLI
         uses: arduino/setup-arduino-cli@v2
@@ -33,7 +47,36 @@ jobs:
           arduino-cli core update-index
           arduino-cli core install adafruit:nrf52
 
+      - name: Resolve build version
+        id: build_version
+        env:
+          PR_NUMBER: ${{ github.event.pull_request.number || github.event.review.pull_request.number || '' }}
+          HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.review.pull_request.head.sha || github.sha }}
+        run: |
+          set -eu
+          SEMVER_RE='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
+          BASE_VERSION="$(
+            git tag --list \
+              | grep -E "$SEMVER_RE" \
+              | sort -V \
+              | tail -n1 || true
+          )"
+          if [ -z "$BASE_VERSION" ]; then
+            BASE_VERSION="0.9.1"
+          fi
+
+          if [ "${{ github.event_name }}" = "pull_request" ] || [ "${{ github.event_name }}" = "pull_request_review" ]; then
+            SHORT_SHA="$(git rev-parse --short=8 "${HEAD_SHA}")"
+            VERSION="${BASE_VERSION}-pr${PR_NUMBER}+${SHORT_SHA}"
+          else
+            VERSION="${BASE_VERSION}"
+          fi
+
+          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+
       - name: Generate version header
+        env:
+          OPK_BUILD_VERSION: ${{ steps.build_version.outputs.version }}
         run: ./gen_version.sh
 
       - name: Compile OpenPuck
@@ -44,7 +87,7 @@ jobs:
             --clean \
             --build-path build/cache/openpuck \
             --output-dir build/openpuck \
-            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4" \
+            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DCFG_TUD_TASK_QUEUE_SZ=64 -DCFG_TUD_VENDOR_TX_BUFSIZE=256" \
             OpenPuck
 
       - name: Generate UF2 (OpenPuck)

+ 2 - 0
.github/workflows/format.yml

@@ -4,6 +4,8 @@ on:
   push:
     branches:
       - main
+  # PRs get the same gate -- without this the checks only ran after merge.
+  pull_request:
 
 jobs:
   clang-format:

+ 79 - 12
.github/workflows/release.yml

@@ -4,17 +4,85 @@ on:
   push:
     branches:
       - main
+    tags:
+      - '[0-9]*.[0-9]*.[0-9]*'
+  release:
+    types:
+      - published
 
 permissions:
   contents: write
 
 jobs:
+  bump-version-tag:
+    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Tag next patch version
+        run: |
+          set -eu
+          git fetch --force --tags origin
+          SEMVER_RE='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
+
+          if git tag --points-at "$GITHUB_SHA" | grep -Eq "$SEMVER_RE"; then
+            echo "semver tag already exists on this commit; skipping bump"
+            echo "Tag already present on ${GITHUB_SHA}; release build runs from the tag event." >> "$GITHUB_STEP_SUMMARY"
+            exit 0
+          fi
+
+          LAST="$(
+            git tag --list \
+              | grep -E "$SEMVER_RE" \
+              | sort -V \
+              | tail -n1 || true
+          )"
+
+          if [ -z "$LAST" ]; then
+            # Seed semver history at the requested public baseline.
+            NEXT="0.9.1"
+          else
+            MAJOR="${LAST%%.*}"
+            REST="${LAST#*.}"
+            MINOR="${REST%%.*}"
+            PATCH="${LAST##*.}"
+            NEXT="${MAJOR}.${MINOR}.$((PATCH + 1))"
+          fi
+
+          git tag "$NEXT" "$GITHUB_SHA"
+          git push origin "$NEXT"
+
   build-release:
+    if: github.event_name == 'release' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
     runs-on: ubuntu-latest
+    env:
+      SEMVER_RE: '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
 
     steps:
       - name: Check out repository
         uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Validate pushed tag is semver
+        if: github.event_name == 'push'
+        run: |
+          echo "${GITHUB_REF_NAME}" | grep -Eq "${SEMVER_RE}"
+
+      - name: Resolve release version
+        id: release_version
+        run: |
+          if [ "${{ github.event_name }}" = "release" ]; then
+            VERSION="${{ github.event.release.tag_name }}"
+          else
+            VERSION="${GITHUB_REF_NAME}"
+          fi
+          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
 
       - name: Set up Arduino CLI
         uses: arduino/setup-arduino-cli@v2
@@ -35,6 +103,8 @@ jobs:
           arduino-cli core install adafruit:nrf52
 
       - name: Generate version header
+        env:
+          OPK_BUILD_VERSION: ${{ steps.release_version.outputs.version }}
         run: ./gen_version.sh
 
       - name: Compile OpenPuck
@@ -45,7 +115,7 @@ jobs:
             --clean \
             --build-path build/cache/openpuck \
             --output-dir build/openpuck \
-            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4" \
+            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DCFG_TUD_TASK_QUEUE_SZ=64 -DCFG_TUD_VENDOR_TX_BUFSIZE=256" \
             OpenPuck
 
       - name: Generate UF2 (standard)
@@ -73,19 +143,17 @@ jobs:
             --clean \
             --build-path build/cache/openpuck-recovery \
             --output-dir build/openpuck-recovery \
-            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DOPK_FACTORY_RESET=1" \
+            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DCFG_TUD_TASK_QUEUE_SZ=64 -DCFG_TUD_VENDOR_TX_BUFSIZE=256 -DOPK_FACTORY_RESET=1" \
             OpenPuck
 
       - name: Generate UF2 (recovery)
         run: ./gen_uf2.sh build/openpuck-recovery/OpenPuck.ino.hex build/openpuck-recovery/OpenPuck.ino.uf2
 
       - name: Collect artifacts
+        env:
+          VERSION: ${{ steps.release_version.outputs.version }}
         run: |
           mkdir -p release_assets
-          REF="${GITHUB_REF_NAME}"
-          SAFE_REF="$(printf '%s' "$REF" | tr -c 'A-Za-z0-9._-' '-')"
-          SHA_SHORT="$(git rev-parse --short=8 HEAD)"
-          VERSION="${SAFE_REF}-${SHA_SHORT}"
 
           find build/openpuck build/openpuck-recovery \
             -maxdepth 1 -type f \
@@ -116,16 +184,15 @@ jobs:
       - name: Create GitHub Release
         uses: softprops/action-gh-release@v2
         with:
-          tag_name: ${{ format('build-{0}', github.sha) }}
-          target_commitish: ${{ github.sha }}
-          name: ${{ format('Build {0}', github.sha) }}
-          prerelease: true
+          tag_name: ${{ steps.release_version.outputs.version }}
+          name: ${{ format('OpenPuck {0}', steps.release_version.outputs.version) }}
+          prerelease: false
           files: release_assets/*
           body: |
             OpenPuck Firmware Release
 
-            Build: ${{ github.sha }}
-            Ref: ${{ github.ref_name }}
+            Version: ${{ steps.release_version.outputs.version }}
+            Commit: ${{ github.sha }}
 
             ### Files included:
 

+ 487 - 0
CODE_MAP.md

@@ -0,0 +1,487 @@
+# OpenPuck firmware — architectural map
+
+nRF52840 / Adafruit Arduino core / TinyUSB. The device impersonates a Valve Steam
+Controller 2 "puck" over USB and talks to SC2 controllers over a bare-metal nRF52
+RADIO (ESB-style, **no SoftDevice**) protocol. Built with `-DCFG_TUD_HID=4`.
+
+> This map describes what the **code actually does**. Source comments in this repo
+> are known to be misleading and were ignored.
+
+---
+
+## 0. Execution contexts (read this first)
+
+Three contexts matter for every piece of state below:
+
+| Context | Priority / stack | What runs here |
+|---|---|---|
+| **loop task** | Arduino FreeRTOS "loop" task, low priority, ~4 KB stack | Everything called from `loop()`: `webusbPoll`, `g_active->task()`, `serialConsolePoll`, `rfDiagTask`, `rfLinkTask`, `hapticTask`, `ledTask`, `usbMountTask`. Also all of the RF radio code (it is driven synchronously from `rfLinkTask`/`rfDiagTask`). Also `setup()`. |
+| **usbd task** | TinyUSB "usbd" task, **high priority, ~800-byte stack** | All TinyUSB HID `set_report`/`get_report` callbacks, the custom XInput class-driver xfer callbacks, and (in the Adafruit core) HID `sendReport` queuing context. This is where host→device control transfers are decoded. **Stack is tiny — a `Serial.printf` here can overflow it (the suspected LOCKUP cause), which is why all per-report logging is `#if OPK_LOG` gated.** |
+| **ISR** | Hardware exception | `HardFault_Handler` (fault_diag.cpp). The RADIO is **polled, not interrupt-driven** — there is no radio ISR. |
+
+Key cross-task rule the code relies on: producers that touch the relay rings and the
+capture ring run under `__disable_irq()`/PRIMASK; the per-slot HID reply rings are
+single-producer (usbd) / single-consumer (loop) with `volatile` head/tail and no lock.
+
+---
+
+## 1. Top level — `OpenPuck.ino`
+
+**Entry point + main loop.** No input/output logic of its own.
+
+### `setup()` (loop task, boot)
+- `genSerial()`, `ledInit()`, seed per-slot RF session addresses, `InternalFS.begin()`,
+  optional one-time `factoryResetOnce()`, `loadCfg()`, `loadBonds()`, regenerate session
+  addresses for used slots.
+- Decides USB presentation: `g_xbox = !modeIsPuck`, `g_active = controllerFor(g_usbMode)`.
+- Builds the USB descriptor. Two paths:
+  - **Dynamic-mount modes** (emulated controllers): `wakeHidBegin()` (HID instance 0,
+    unless clean-PS), `g_active->beginPool()`, optional `usb_web.begin()`,
+    `usbMountEnable()`, `usbMountRebuildMap()`, `usbReenumerate(g_usbMountCount)`.
+  - **Static modes** (puck/lizard, or any non-dynamic): conditional CDC keep
+    (`keepCdc`), per-mode serial suffix, ordered interface registration
+    (`usb_web` IF0 → wake mouse IF1 → 4 puck slots IF2..5 for puck), then `attach()`.
+- Busy-waits up to **3 s** for USB mount: `for (i<300 && !mounted()) delay(10)`.
+- `hapticInit()`, `faultDiagBoot()`, prints diagnostics.
+- **Arms the hardware watchdog**: `NRF_WDT` CRV = `8*32768-1` (~8 s), RUN-in-sleep.
+  If `loop()` ever stops feeding it, the chip resets.
+
+### `usbReenumerate(uint8_t k)` (loop task)
+Tears down and rebuilds the USB config descriptor for `k` connected slots **with no MCU
+reboot**: `detach()`, `delay(20)`, `clearConfiguration()`, restore identity/serial,
+re-add wake mouse + `g_active->mountSlots(k)` + WebUSB in locked instance order,
+`attach()`. Called at boot and by `usbMountTask`.
+
+### `loop()` (loop task)
+1. Feeds the watchdog (`NRF_WDT->RR[0]`).
+2. If `g_dirty`, `saveBonds()` (flash write in loop context).
+3. Calls, in order: `webusbPoll`, `g_active->task`, `serialConsolePoll`, `rfDiagTask`,
+   `rfLinkTask`, `hapticTask`, `ledTask`, `usbMountTask`.
+   `#if OPK_LOG` wraps each in `micros()` timing for the WebUSB panel.
+
+### State owned
+- `g_usbCfgDesc[512]` — config-descriptor build buffer (puck composite + WebUSB exceeds 256 B).
+- `MODE_SUFFIX[8]`, `s_dynWantWebusb`, `s_dynWantWakeMouse`.
+- `g_loopPeriodUs`, `g_loopWorst`, `g_loopWorstUs` — loop-timing diagnostics (read by webusb).
+
+---
+
+## 2. Configuration & identity
+
+### `config.cpp` / `config.h` — persisted settings (loop task)
+- **Owns**: `g_usbMode`, `g_xbox`, `g_chordBtn[3]`, `g_persistMode`, `g_bootMode`,
+  `g_debugCdcThisBoot`, `g_mDiv`, `g_mFric`, `g_type[ET_COUNT]` (per-emulated-type
+  button config), `g_etype`, live mirrors `g_abSwap`/`g_back[4]`/`g_qamMap`/
+  `g_padHaptics`/`g_ledBright`, `g_rumbleScale`, `g_swProRate`, `g_swGyroScale10`,
+  and the constant `g_pollUs = 4000` (250 Hz, **fixed**).
+- `struct Cfg` is serialized to `/cfg.bin` (LittleFS), magic `0xCD`. `rxWin10`,
+  `hapticBlockOn`, `hapticBlockS` and the per-type table travel with it; `rsvd0` is the
+  one-shot debug-CDC arm.
+- `loadCfg()` resolves the boot mode policy: one-shot `bootMode` wins once then clears;
+  else `persistMode ? last mode : STEAM(0)`. Calls `applyActiveType()`.
+- `saveCfg`, `saveMode`, `armDebugCdcNextBoot`, `factoryErase` (LittleFS `format()`),
+  `factoryResetOnce` (git-hash-tagged one-time wipe). All flash I/O — **loop context only**.
+- `applyActiveType()` copies `g_type[g_etype]` into the hot-path live mirrors.
+
+### `identity.cpp` / `identity.h` (loop task, boot)
+- `genSerial()` derives `g_unit[16]` ("FXB...") and `g_board[16]` ("MXB...") from
+  `NRF_FICR->DEVICEID`. `g_usbSerial[18]` holds the per-mode suffixed serial.
+- `ATTR83[25]` — the constant 0x83-attribute blob (advertises product 0x1304).
+
+### `build_info.h` / `git_version.h` — compile-time tags only.
+
+---
+
+## 3. Bonds & shared link state — `bonds.cpp` / `bonds.h`
+
+- `NSLOT = 4`. `struct Slot { uint8_t rec[24]; bool used; uint8_t resp[63]; uint16_t resp_len; }`.
+  `rec` = `[8 uuid][16 serial]`. `resp`/`resp_len` is the staging buffer for that
+  interface's pending feature-report reply.
+- **`g_slot[NSLOT]`** — bond records + per-interface reply staging. Written by the puck
+  feature command channel (usbd task, `handleSet` 0xA2) and by `loadBonds` (loop). Read
+  by RF code, webusb, serial console.
+- **`g_connReplyMs[NSLOT]`** — `millis()` of the last F-type reply per slot (**the live
+  "controller is alive" timestamp**). Written by the RF decode (loop task,
+  `rfConnTx`). Read everywhere: puck_hid connection presentation, haptic link-up gate,
+  usb_mount connectivity watcher, webusb, serial console.
+- **`g_dirty`** (`volatile`) — set by `handleSet` (usbd) when a bond is written; consumed
+  by `loop()` → `saveBonds()` (defers flash off the usbd task).
+- `g_pairing` — set by `handleSet` 0xAD.
+- `loadBonds`/`saveBonds` — `/bonds.bin` LittleFS, loop context.
+
+---
+
+## 4. RF stack (bare-metal RADIO)
+
+### `radio.cpp` / `radio.h` — hardware layer (loop task)
+- Programs `NRF_RADIO` directly (PHY=Ble_2Mbit, ENDIAN=Big, no whitening, CRC16
+  poly 0x11021/init 0xFFFF, ESB-DPL framing). No SoftDevice.
+- **DMA buffers `rfrx[100]`, `rftx[100]`** (declared >= MAXLEN+2 = 66+). Shared by
+  `rf_link.cpp` AND `rf_diag.cpp` with **no guarding** (see §13).
+- `g_rfBase[4]="ibex"`, `g_rfPrefix=0x10`, `g_rfCh=2` — shared discovery rendezvous.
+- `g_sessCh=18` — connected-session channel (global across slots).
+- **`g_sessBase[NSLOT][4]`, `g_sessPrefix[NSLOT]`** — per-bond unique session address,
+  derived in `rfGenSessionAddr(slot)` from bond UUID + FICR DEVICEID (deterministic,
+  stable across reboots; degenerate bytes scrubbed; never equals "ibex").
+- Tunables (`g_crcinit`, `g_whiteiv`, `g_mode`, `g_pcnf0/1`, `g_crcpoly`, `g_crccnf`,
+  `g_pid`, `g_balen`, `g_prefixRaw`, `g_statlen`) — mutated by the CDC console and by
+  rf_diag's `applyCfg`/sweeps. `rfConfig()` reads them on every (re)program.
+- `RWAIT_DISABLED()` macro: **bounded** spin-wait for `EVENTS_DISABLED`, **3000 µs**
+  timeout, then bails (never hangs the loop forever).
+- `rfSetAddr` bit-reverses the base/prefix into BASE0/PREFIX0.
+
+### `rf_link.cpp` / `rf_link.h` — operational puck protocol (loop task)
+The "dongle" role. All driven synchronously from `rfLinkTask()` in `loop()`.
+
+- **`rfLinkTask()`**: host-frame beacons (session keepalive E1 on `g_sessCh`, discovery
+  E1 on ch2), `rfConnStep()` poll loop, USB remote-wakeup on a fresh RF connection, QoS
+  channel hop, per-second stat aggregation. Gated off during a 2.5 s post-disconnect
+  cooldown (`g_connCooldown`).
+- **`rfConnStep()`**: cycle gate fires once per `g_pollUs` (250 Hz); on each fire it
+  polls **every warm slot back-to-back** (so N controllers all run at full rate). Cold
+  slots (silent > 5 s) retry only every 2 s. Per slot it flushes one relay entry and
+  sends a bare E3 poll (real-puck behavior; legacy E7/GET path is toggle-able).
+- **`rfConnTx(ch, s1, payload, plen, rxWinUs)`**: the core TX-then-RX primitive. TXes
+  one frame on the current slot's session address, then busy-waits for a reply
+  (`while(!EVENTS_END && micros()-t0 < win)`, `win` = `g_rxWin` default **2000 µs**, or
+  a tiny override like **80 µs** for no-ACK relays). On a CRC-good F-type reply it:
+  samples RSSI, updates `g_connReplyMs`/`g_linkRssi`, calls `hapticOnReconnect` on a
+  gap, and **decodes the 0x45 input report into `g_in[g_curSlot]`** (buttons, sticks,
+  triggers, trackpads, IMU). Also handles: Steam-button short-press remote wakeup,
+  Steam+Y 2 s power-off chord, back-4 mode-switch chord (`saveMode` + `NVIC_SystemReset`),
+  and status reports 0x43 (battery → `g_battery`/`g_batteryState`) / 0x44 → dispatched
+  via `g_active->onAuxReport`.
+- **`g_curSlot`** — the slot the poll loop is currently driving. Set by `rfConnStep`
+  before each TX; read by the decode, the relay flush, haptics, stats, webusb.
+- **Shared output state** (all `volatile`, written here, read by puck_hid/webusb):
+  `g_linkRssi[NSLOT]`, `g_battery[NSLOT]`, `g_batteryState[NSLOT]`.
+- Per-second readouts: `g_f1ps`, `g_newps`, `g_pollsps`, `g_pollPeriodUs`.
+- Many runtime toggles (`g_pollGet`, `g_e7announce`, `g_e1keepalive`, `g_rxWin`,
+  `g_e3mode`, `g_qos`, …) set from the console/webusb.
+- **Buffers**: uses the shared `rftx`/`rfrx` (radio.h). The 0x45 TLV walk bounds every
+  read against the received length and `sizeof(rfrx)` (explicit guards against runaway
+  `idx` and reads past the buffer — the documented stack-smash/LOCKUP class).
+
+### `triton.cpp` / `triton.h` — decoded controller input
+- **`g_in[NSLOT]`** (`struct PuckInput`): the decoded SC2 input per slot — buttons,
+  `lx/ly/rx/ry`, `lt/rt`, `lpx/lpy/rpx/rpy`, accel `ax/ay/az`, gyro `gx/gy/gz`.
+  **Written by `rf_link` (loop task) once per fresh 0x45; read by every USB
+  personality's `task()` (loop) and by webusb.** No lock (single writer, loop-context
+  readers; the usbd-task readers in mode_switch_pro read it too — benign).
+- TB_* button masks, inline decoders `s16off`/`u16off`/`trigU8`/`btnsOf`, `imuFrom45`.
+
+### `rf_diag.cpp` / `rf_diag.h` — RE / calibration tooling (loop task)
+Channel sniffers, raw capture/replay, beacon/respond, config sweeps — all driven from
+`rfDiagTask()` and CDC-console start hooks. **Reprograms the same `NRF_RADIO` and shares
+`rfrx`/`rftx` with rf_link with zero mutual exclusion** (relies on the design assumption
+that diag modes are off during normal operation). Buffers: `g_replay[48]`,
+`g_hostTx[48]` (len 0x22=34), `pl[48]` in `rfBeaconOnce` (copy clamped to 48 into
+`rftx`). Spin-waits: `RWAIT_DISABLED()` (3000 µs) ×1–3 per call; a 500 µs RX window in
+`rfBeaconOnce`. `rfReplayOnce` runs every loop with no rate gate while armed.
+Notable: `rfCapPoll` copies 48 bytes into `g_replay` but sets `g_replayLen=32`.
+
+---
+
+## 5. Controller abstraction — `controllers.cpp` / `controllers.h`
+
+- `IController` interface: `begin`, `onReport45`, `onAuxReport`, `task`, `wakeEvent`,
+  `isPuck`, and the dynamic-mount hooks `dynamicMount`/`maxSlots`/`usbIdentity`/
+  `beginPool`/`mountSlots`.
+- **`g_active`** — the one controller for this boot.
+- `controllerFor(mode)` maps `g_usbMode` → singleton (Steam & Lizard share `g_steamPuck`;
+  PS5/PS5_GAME share `g_ps5Ctl`; HIDGYRO/DS4_GAME share `g_hidGyroCtl`).
+- **Dispatch model**: PUSH modes (puck/lizard, Xbox) build+send their host report inside
+  `onReport45` (loop task, called from rf_link). STREAM modes (Switch Hori/Pro, PS5,
+  HIDGYRO) ignore `onReport45` and emit from `task()` reading `g_in[]`.
+
+---
+
+## 6. Puck personality — `puck_hid.cpp` / `puck_hid.h`  (MODE_STEAM + MODE_LIZARD)
+
+The 28DE:1304 puck identity with four HID slot interfaces (interface N = bond slot N).
+
+- **`PUCK_HID_DESC[]`** / **`PUCK_LIZARD_HID_DESC[]`** — the cloned puck report
+  descriptors (mouse 0x40 + keyboard 0x41 + vendor FF00 with 63-byte feature reports).
+- **`hid[NSLOT]`** — the four `Adafruit_USBD_HID` slot objects.
+
+### usbd-task code (HID callbacks, 800-byte stack)
+- **`handleSet(slot, rid, type, b, n)`** (via `setcb0..3`): host→device reports.
+  - OUTPUT reports 0x80–0x89: stamp `g_steamAliveMs` (Steam-alive heartbeat),
+    `hapLogAdd`, and for 0x80–0x86 relay to the controller via **`relayEnqueue`**
+    (gated by `hapticRelaySlotOk`, `lizardActive`, post-resume mute, `haptic82Blocked`).
+  - FEATURE report 0x01 = raw passthrough → `relayEnqueue(cmd,…)`; 0x9F → `hapticSendShutdown`.
+  - FEATURE command channel: 0x83 (attrs), 0xAE (strings), 0xB4 (connection state),
+    0xAD (pairing), 0xA2 (write/clear `g_slot[slot]` → sets `g_dirty`), 0xA3 (read slot).
+    Each writes the reply into `S.resp[63]` / `S.resp_len`.
+- **`handleGet(slot, rid, type, buf, reqlen)`** (via `getcb0..3`): returns the staged
+  `S.resp` (≤63 bytes), stamps `g_steamAliveMs` in Steam mode, `hapLogAdd(0xFC,…)`.
+
+### loop-task code
+- **`onReport45(slot, rep, fresh, bodyTlen)`** (from rf_link): suspended/post-resume
+  gating, then either `rfLizard` (lizard, slot 0 only) or `hid[slot].sendReport(0x45,
+  rep+1, blen)` (`blen` clamped ≤ 45).
+- **`onAuxReport(slot, rid, data, n)`**: forwards controller 0x43/0x44 verbatim to
+  `hid[slot]` (how Steam reads battery).
+- **`task()`**: post-resume edge stamp; per-slot connection presentation —
+  edge-triggered `0x79` (1-byte conn state), periodic `0x7B` (12-byte status with RSSI
+  patched from `g_linkRssi`), and a synthesized 14-byte `0x43` battery report from
+  `g_battery`/`g_batteryState` every 2 s (for SDL's full-length requirement). Also
+  `wakeNudgeTask()`.
+- **`wakeEvent()`** / `wakeNudgeTask()`: queue + deliver a net-zero mouse jiggle to wake
+  the host (prefers the boot wake-mouse; falls back to per-slot gamepad jiggle).
+  `g_nudgeStep[NSLOT]`, `g_nudgeMs[NSLOT]`.
+
+### Shared state owned here
+- `g_steamAliveMs` (last Steam OUTPUT/heartbeat; written in usbd & loop) and the derived
+  `lizardActive()` — **the single source of truth gating both the USB input path and the
+  haptic relay**. `g_resumeMs` (post-resume mute). `g_fwdNewOnly`. `g_autoLizard`.
+- **Cross-task**: `S.resp`/`S.resp_len` written in `handleSet` (usbd) and read in
+  `handleGet` (usbd) — same task. `g_slot[slot].rec`/`used` + `g_dirty` written in usbd,
+  flushed in loop. `g_steamAliveMs`/`g_resumeMs` written in both contexts, no lock.
+
+---
+
+## 7. Lizard desktop driver — `mode_lizard.cpp` / `mode_lizard.h`  (loop task)
+
+- **`rfLizard(r, mdev, kdev, mrid, krid)`** — not an IController; called from
+  `puck_hid::onReport45` (loop task). Turns a raw 0x45 into keyboard+mouse:
+  - Right pad → mouse motion (velocity/friction/sub-pixel glide; dx/dy clamped ±127).
+    `mdev->sendReport(mrid, &m, 5)` (`hid_mouse_report_t` = 5 bytes).
+  - Left pad → wheel (dw clamped ±15). Buttons from pad-click / trigger thresholds.
+  - Keyboard: modifiers + up to 6 keycodes; `krep[8]` (boot-keyboard layout),
+    `kdev->sendReport(krid, krep, 8)`.
+  - MODE_LIZARD-only consumer-control: `mdev->sendReport(0x03, &cc, 1)`.
+- All state is loop-local statics; no USB callbacks; no shared cross-task state.
+
+---
+
+## 8. XInput personality — `mode_xinput.cpp` / `mode_xinput.h`  (`g_xboxCtl`)
+
+Dynamic-mount, PUSH-style. Custom TinyUSB **vendor class driver** (0xFF/0x5D/0x01) plus a
+boot mouse for the right pad.
+
+- **usbd-task code (custom class driver `g_xiDriver`, returned via
+  `usbd_app_driver_get_cb`)**: `xi_init`, `xi_reset`, `xi_open` (claims interface →
+  `g_usbToBond[u]`, arms OUT endpoint with a 32-byte xfer), `xi_ctrl`, **`xi_xfer`**.
+  `xi_xfer` decodes the host rumble OUT report (`outBuf[0]==0x00 && [1]==0x08`) and
+  **calls `hapticSteamRumble(lo, hi, slot)` directly from the usbd task**, then re-arms
+  the 32-byte OUT xfer. No HID feature/get/set callbacks in this mode.
+- **loop-task code**: `onReport45` → `rfXboxGamepad(slot, rep)` (+ `rfXboxMouse` for slot
+  0); `xinputSend` fills `S.inBuf` (**20 bytes** filled of the 32-byte buffer) and
+  `usbd_edpt_xfer(0, epIn, r, 20)`; `task()` is the stuck-rumble watchdog (2.5 s →
+  `hapticSteamRumble(0,0,s)`).
+- **Buffers**: `XiSlot.inBuf[32]`, `XiSlot.outBuf[32]`; XInput report = 20 bytes;
+  descriptor = `XINPUT_DESC_LEN` 40 bytes; mouse report = 5 bytes.
+- **Cross-task**: `g_xiSlot[NSLOT]` — `inUse`/`rumbleLow`/`rumbleHigh`/`rumbleMs`
+  `volatile`, but `itf`/`epIn`/`epOut` **not** volatile. usbd writes endpoint/rumble
+  fields, loop reads them and touches the IN endpoint. No lock. `hapticSteamRumble`
+  reached from **both** usbd (`xi_xfer`) and loop (`task`) — safety relies on the relay
+  ring's PRIMASK guard.
+
+---
+
+## 9. Switch personalities
+
+### `mode_switch_hori.cpp` / `mode_switch_hori.h`  (`g_switchHori`)
+HORIPAD (0F0D:0092). Dynamic-mount, STREAM-style, plain 8-byte report, **no report
+callbacks registered → no usbd-task user code at all**.
+- `task()` (loop): rate-gated, `switchBuildHoripad(slot, p[8])` from `g_in[]`,
+  `sendReport(0, p, 8)` (8 bytes, no report id). All build state loop-local.
+
+### `mode_switch_pro.cpp` / `mode_switch_pro.h`  (`g_switchPro`)
+Switch Pro (057E:2009). Dynamic-mount, STREAM-style + a full handshake/subcommand state
+machine. **This mode registers an OUT/set-report callback** (`setReportCallback(NULL,
+JC_SETCB[s])`).
+
+- **usbd-task code (800-byte stack, the deepest USB-callback path in the firmware)**:
+  `jcSet0..3` → `jcSetCommon` → `jcRumble` (decodes amplitudes, dedupes, calls
+  **`hapticSteamRumble(lo,hi,bond)` from usbd**) and `jcSubcmd` (builds a 63-byte 0x21
+  reply: BT pairing, device info, SPI read via `spiRead`, SPI write via `jcSpiWrite`,
+  report-mode set). `jcSubcmd`'s local `p[63]` plus `jcInputPrefix`/`spiRead` is the
+  heaviest stack user on the 800-byte stack. `jcEnq` (usbd) is the **ring producer**.
+  `jcSpiWrite` is **RAM-only** (writes `g_userCal[slot][]`, sets a dirty flag) —
+  deliberately no flash I/O in the callback.
+- **loop-task code**: `beginPool` (LittleFS loads, descriptor setup), `task()` —
+  deferred flash save (`saveUserCal` when dirty + 250 ms), drains **one** queued reply
+  per slot via `sendReport(rid, data, 63)`, else rate-gated `switchProBuild(slot, p[63])`
+  + `sendReport(0x30, p, 63)`.
+- **Buffers**: `JC_REPLEN = 63` (all input/reply reports are 63 payload bytes);
+  reply ring `g_jcQ[NSLOT][8]` of `{rid, data[63]}`; `g_userCal[NSLOT][0x100]`;
+  `g_spiStickCal[18]`; `g_jcMac[NSLOT][6]`; config files `b[3]`.
+- **Cross-task shared data**:
+  - Reply ring `g_jcQ` / `g_jcQh` / `g_jcQt` — SPSC (usbd produces, loop consumes);
+    head/tail `volatile`, ring body **not** volatile.
+  - `g_swProReportMode[]` — written usbd (`jcSubcmd`) + loop (`mountSlots`), read loop;
+    **not volatile**.
+  - `g_userCal[][]` + `g_userCalDirty`/`g_userCalDirtyMs` — `g_userCal` written/read in
+    usbd, read in loop (`saveUserCal`); only the dirty flag/ts are `volatile` → a
+    loop-side save can race a usbd-side write (no lock).
+  - `g_jcTimer[]` — incremented in both contexts, not atomic (benign).
+  - `g_in[bond]` — read from both `jcInputPrefix` (usbd) and `switchProBuild` (loop).
+
+---
+
+## 10. PlayStation personalities
+
+### `mode_ps5.cpp` / `mode_ps5.h`  (`g_ps5Ctl`, MODE_PS5 + MODE_PS5_GAME)
+DualSense (054C:0CE6). Dynamic-mount, STREAM-style. **Registers get+set report
+callbacks** (`setReportCallback(PS5_GETCB[s], PS5_SETCB[s])`).
+- **usbd task**: `ps5GetCommon` (GET_FEATURE: 0x03→47 B, 0x05 motion-calib via
+  `psNeutralCalib` writing through buf[33]→returns 40, 0x09 MAC 6 B→19, 0x20 fw→63 B;
+  each `memset(buf,0,reqlen)` first); `ps5SetCommon` (OUTPUT 0x02 rumble →
+  `hapticSteamRumble(p[3]*257, p[2]*257, bond)` from usbd, reads `g_usbToBond[slot]`).
+- **loop task**: `task()` rate-gated → `ps5Build(u, bond, p[63])` from `g_in[bond]` (per-slot
+  `seq[]`), `sendReport(0x01, p, 63)`. Touch packed at `out+32` (8 bytes).
+- **Buffers**: input report `p[63]`; `g_ps5Mac[NSLOT][6]`.
+
+### `mode_hidgyro.cpp` / `mode_hidgyro.h`  (`g_hidGyroCtl`, MODE_HIDGYRO + MODE_DS4_GAME)
+DS4-layout (054C:05C4) + gyro. Same structure as PS5.
+- **usbd task**: `hidGyroGetCommon` (GET_FEATURE: 0x02 calib via `psNeutralCalib`→36,
+  0x12 MAC 6→15, 0x81 MAC 6→6, 0xA3 fw→48); `hidGyroSetCommon` (OUTPUT 0x05 rumble →
+  `hapticSteamRumble(p[4]*257, p[3]*257, bond)` from usbd).
+- **loop task**: `task()` → `hidGyroBuild(u, bond, p[63])` from `g_in[bond]`,
+  `sendReport(0x01, p, 63)`; touch via `touchPackPads(out+34,…)` (8 bytes).
+- **Buffers**: input report `p[63]`; `g_ds4Mac[NSLOT][6]`.
+
+**Both PS modes**: `g_usbToBond[]` read in usbd without a lock (bounds-checked); the
+calib helper `psNeutralCalib` writes 34 bytes (buf[6..33]) while callers return 36/40
+(remainder zeroed by the prior memset).
+
+### `gamepad_util.cpp` / `gamepad_util.h` — shared report-builders (called from loop task)
+`swStick`, `psNeutralCalib` (writes through `buf[33]`), trackpad→touch mappers
+(`touchPackPads` writes 8 bytes = two 4-byte points), `psButtonsFromSteam` (back-paddle/
+QAM/chord remap), `psShouldersByte`, `psHatNibble`, `psFaceNibble`, `tritonFromCode`.
+Reads `g_qamMap`/`g_abSwap`/`g_back[]`. Pure transforms, no buffers beyond callers'.
+
+---
+
+## 11. Haptics & host→controller relay — `haptics.cpp` / `haptics.h`
+
+**The central cross-task data structure: the relay rings.**
+
+- **`g_rq[NSLOT][RELAY_QLEN]`** (`RELAY_QLEN = 32`), `struct RelayMsg { uint8_t rid, len;
+  uint8_t data[RELAY_MAXP=60]; }`, with **`volatile g_rqHead[NSLOT]` / `g_rqTail[NSLOT]`**.
+  One ring per bond slot. **Multi-producer** (usbd-task HID callbacks: puck_hid
+  `handleSet`, the mode rumble callbacks; plus loop-context console/test injectors),
+  **single-consumer** per slot (`rfConnFlushRelay` on that slot's poll turn, loop task).
+- **`relayEnqueue(rid, payload, plen, slot)`**: producers serialize under
+  `__get_PRIMASK()` / `__disable_irq()` / `__set_PRIMASK`. `slot==0xFF` broadcasts to all
+  rings. Full ring → evict the **oldest** (keeps the most recent meaningful frame).
+- **`rfConnFlushRelay(ch, s1)`** (loop, from `rfConnStep`): drains **one** entry from
+  `g_curSlot`'s ring, frames it as an E3 sub-TLV (type-01 form for 0x9F/LED-0x2D,
+  legacy type-05 otherwise), `hapLogAdd(0xFE,…)`, TXes via `rfConnTx(…, 80)` (no-ACK).
+- **`rfConnQueueHapticRelay()`** (loop): injects test/stop haptics only on an idle cycle.
+
+### Other haptic state (mostly loop task, some `volatile` flags)
+- `g_testHaptic`, `g_hapticStop` (`volatile`), `g_hapticBlockOn`, `g_hapticBlockMs`,
+  `g_hapticBlockUntil[NSLOT]`, `g_relayOp`, `g_relaySub`.
+- `hapticSendShutdown()` — bursts 0x9F "off!" (`{6f 66 66 21}`) ×3 broadcast.
+- `hapticSteamRumble(low, high, slot)` — builds a 9-byte 0x80 report (×`g_rumbleScale`),
+  `relayEnqueue(0x80, p, 9, slot)`; **called from usbd (mode rumble callbacks) and loop**.
+  Per-slot stuck-rumble tracking `g_rumble80On/Ms[NSLOT]`.
+- `haptic82Blocked`/`hapticLinkUp`/`hapticRelaySlotOk` — link-up + block gates (read
+  `g_connReplyMs`, `g_slot`).
+- `hapticOnReconnect(slot)` — arms the per-slot block + schedules `g_reinitLeft` re-init
+  shots; `hapticCancelPendingOn` voids queued ON entries under PRIMASK.
+- `hapticReinit(slot)` — enqueues the captured 0x81/0x87 settings re-init sequence.
+- **`hapticTask()`** (loop): per-slot link-edge detect → `hapticOnReconnect`; fires
+  scheduled re-init; **autonomous controller power-off on a persisted USB suspend**
+  (`SUSPEND_OFF_MS = 4000` ms, VBUS-gated) → `hapticSendShutdown`; stuck-rumble watchdog
+  (2.5 s); idle-clear re-init.
+- **`hapInit()`** resets all rings/flags at boot.
+
+### Diagnostic capture ring (`#if OPK_LOG` only)
+- `g_hapLog[HAPLOG_N=4096]` of `{ms, slot, rid, n, b[16]}` (~80 KB). `hapLogAdd` written
+  from **both usbd and loop**, guarded by PRIMASK; `g_hapHead` is `uint16_t`. Drained to
+  WebUSB via `hapLogResetDrain`/`hapLogPull` (loop) and `'H'` console dump. Compiled out
+  entirely in production (`OPK_LOG=0`), reclaiming the RAM.
+
+---
+
+## 12. USB infrastructure
+
+### `usb_mount.cpp` / `usb_mount.h` — dynamic re-enumeration watcher (loop task)
+- **`g_usbMountCount`**, **`g_usbToBond[NSLOT]`** (usbSlot→bond, -1 unused),
+  **`g_bondToUsb[NSLOT]`** (inverse). `g_usbToBond` is the routing map the usbd-task
+  rumble callbacks read to map a USB output back to a bond slot.
+- **No TinyUSB callbacks here** despite the name — it's a poll-driven state machine.
+- `usbMountTask()` (loop): `connectedMask()` (slots with a reply < `CONN_UP_MS=1200` ms),
+  debounce `MOUNT_DEBOUNCE_MS=2000` ms, and on a steady change `commitOrder()` (rewrites
+  both maps + count) then **`usbReenumerate(g_usbMountCount)`** (detach/rebuild/attach,
+  no reboot). Order-preserving so existing instances keep their index.
+- No `delay()` here (the detach delay lives in `usbReenumerate`). Maps rewritten
+  non-atomically just before re-attach; consumers tolerate the brief window.
+
+### `wake_hid.cpp` / `wake_hid.h` — boot-mouse wake source
+- `WAKE_HID_DESC` = boot mouse, `g_wakeHid`, `g_wakeHidPresent`.
+- `wakeHidBegin` (setup), `wakeHidAddInterface` (re-enumerate build), `wakeHidPresent`,
+  `wakeHidReady`, `wakeHidMove(dx,dy)` → `g_wakeHid.mouseReport(0,0,dx,dy,0,0)`
+  (buttons always 0). Poll interval 10 ms. No buffers/delays/loops; only shared state is
+  the one flag set once at begin.
+
+### `webusb_config.cpp` / `webusb_config.h` — browser config channel (loop task)
+- `Adafruit_USBD_WebUSB usb_web`. **No vendor callbacks** — fully **polled from
+  `webusbPoll()` in `loop()`** (the library buffers the vendor RX). So nothing here runs
+  on the usbd 800-byte stack.
+- `webusbSendBlob()` builds a **115-byte** status blob (`p[2+WB_PAYLEN(113)]`, header
+  `A5 113 11`) reading a large set of diagnostic globals (`g_curSlot`, `g_connReplyMs`,
+  `g_battery`, `g_linkRssi`, `g_in[0]` IMU, `g_slot`, `g_type`, loop/poll stats, …) —
+  unsynchronized reads of RF-owned state, but cosmetic (torn reads at most).
+- `webusbPoll()` command parser (`static buf[16]`): `0x01` send blob; `0x02 field value`
+  mutates config tunables (writes `g_type`, `g_mDiv/Fric`, `g_rxWin` (clamped 600..3000),
+  `g_hapticBlockMs` (≤60 s), etc., then `applyActiveType`/`saveCfg`/`swProSaveCfg`);
+  `0x03 mode`→`saveMode`+reset; `0x07`→`hapticReinit`; `0x08`→`hapticSendShutdown`;
+  `0x0A` ("ERS")→`factoryErase`+reset; `0x0B/0x0C`→DFU; capture drain (`OPK_LOG`).
+  Each reset/DFU path does `delay(40)` then `NVIC_SystemReset()` (harmless — reboots).
+
+### `serial_console.cpp` / `serial_console.h` — CDC debug CLI (loop task)
+- `serialConsolePoll()` drains available CDC bytes into `static char line[24]` (bounded),
+  dispatches single-letter commands on newline (first match wins). Commands mutate the
+  radio tunables, rf_link toggles, rf_diag flags, config (`E/F/W/K/Q` → `saveCfg`), and
+  inject relays (`J`/`t`). No blocking reads; `delay()` only before resets.
+- Note: `C` and `H` each appear twice in the chain — the **second occurrence of each is
+  dead code** (unreachable).
+
+---
+
+## 13. Status & fault
+
+### `status_led.cpp` / `status_led.h` (loop task)
+Drives two GPIO pins (LED_BUILTIN + pin 24) together. `ledWakePulse()` lights them and
+stamps `g_pulseMs`; `ledTask()` clears after `PULSE_MS=500`. No buffers/delays/radio.
+
+### `fault_diag.cpp` / `fault_diag.h`
+- **`HardFault_Handler()` (ISR / exception context)** — a **strong override** of the
+  weak default: sets `GPREGRET2 = 0xFA` then `NVIC_SystemReset()`. The only ISR/handler
+  in the codebase.
+- `faultDiagArmIntentionalReset()` sets `GPREGRET2 = 0xB1` before a deliberate reset.
+- `faultDiagBoot()` (loop) reads `RESETREAS` + `GPREGRET2`, classifies the last boot
+  (PIN / WATCHDOG / LOCKUP / HARDFAULT / REBOOT / SOFT / WAKE / POWERON / UNKNOWN),
+  clears `GPREGRET2`. Accessors `faultDiagReason`/`…ResetReas`/`…ReasonStr` feed WebUSB.
+
+---
+
+## 14. Cross-task shared state — consolidated
+
+State touched by **both** the loop task and the usbd task (the synchronization-critical set):
+
+| State | Writer(s) | Reader(s) | Synchronization |
+|---|---|---|---|
+| **`g_rq[NSLOT][32]` relay rings** + `g_rqHead/Tail` (haptics) | usbd HID callbacks (`handleSet`, mode rumble cbs) + loop (console/test/`hapticTask`) | loop `rfConnFlushRelay` | **Producers under PRIMASK**; `volatile` head/tail; ring body not volatile; SPSC-drain per slot |
+| **`g_slot[NSLOT].rec/used/resp`** (bonds) | usbd `handleSet` (0xA2 / reply staging) + loop (`loadBonds`) | loop RF/webusb/console, usbd `handleGet` | `resp` is same-task (usbd); `g_dirty` (`volatile`) defers the flash write to loop |
+| **`g_dirty`** | usbd `handleSet` | loop `loop()` → `saveBonds` | `volatile` flag |
+| **`g_in[NSLOT]`** (decoded input) | loop `rf_link` decode | loop mode `task()`/`onReport45`; **usbd `jcInputPrefix` (switch pro)**; webusb | none (single writer; readers tolerate staleness) |
+| **`g_connReplyMs[NSLOT]`** | loop `rf_link` | loop puck_hid/usb_mount/haptics/webusb; usbd haptic gates | none (plain `unsigned long`) |
+| **`g_battery/g_batteryState/g_linkRssi[NSLOT]`** | loop `rf_link` | loop puck_hid `task`/webusb | declared `volatile` |
+| **`g_usbToBond[NSLOT]`** (usb_mount) | loop `usbMountTask`/`commitOrder` | **usbd rumble callbacks** (ps5/hidgyro/xinput) + loop modes | none; bounds-checked; non-atomic rewrite just before re-attach |
+| **`g_jcQ`/`g_jcQh`/`g_jcQt`** (switch pro reply ring) | usbd `jcEnq` | loop `task()` | `volatile` head/tail, SPSC; body not volatile |
+| **`g_userCal[NSLOT][0x100]`** + dirty flags (switch pro) | usbd `jcSpiWrite` | usbd `spiRead`, loop `saveUserCal` | only dirty flag/ts `volatile`; `g_userCal` itself unguarded (loop save can race usbd write) |
+| **`g_xiSlot[NSLOT]`** (xinput) | usbd class driver (eps, rumble) | loop `xinputSend`/`task` | `inUse`/`rumble*` `volatile`; `itf`/`epIn`/`epOut` not volatile; no lock |
+| **`g_swProReportMode[NSLOT]`** | usbd `jcSubcmd` + loop `mountSlots` | loop `task` | **not volatile** |
+| **`g_steamAliveMs` / `g_resumeMs`** (puck_hid) | usbd `handleSet`/`handleGet` + loop `task` | both | none |
+| **`g_hapLog[4096]`** (`OPK_LOG`) | usbd + loop `hapLogAdd` | loop drain | PRIMASK in `hapLogAdd` |
+| **`rfrx[100]`/`rftx[100]` + `NRF_RADIO`** | loop `rf_link` AND loop `rf_diag` | same | **no guard** — relies on diag-modes-off assumption (both are loop task, so no preemption between them, but no arbitration of radio state) |
+
+**`hapticSteamRumble()` is the one app function legitimately entered from the usbd task**
+(via the ps5/hidgyro/xinput/switch-pro rumble callbacks) as well as the loop task; its
+safety rests entirely on `relayEnqueue`'s PRIMASK guard.

+ 58 - 1
Makefile

@@ -16,7 +16,64 @@ FORMAT_FILES := $(shell find OpenPuck ReversePuckFirmware puck_sniffer pairtui \
 	\( -name '*.c' -o -name '*.cpp' -o -name '*.h' -o -name '*.hpp' -o -name '*.ino' \) \
 	-not -name 'git_version.h')
 
-.PHONY: format format-check check
+# --- firmware build ---------------------------------------------------------
+# The OpenPuck firmware needs two TinyUSB config values that differ from the Adafruit nRF52 core defaults:
+#   CFG_TUD_HID=4            -- four HID interfaces (Steam mode exposes four puck slots); core default is 2.
+#   CFG_TUD_TASK_QUEUE_SZ=64 -- deeper usbd event queue; the default 16 deadlocks the loop task under comms
+#                               load -> watchdog reset (see OpenPuck.ino + docs/BUILD_AND_DEPLOY.md).
+#   CFG_TUD_VENDOR_TX_BUFSIZE=256 -- the WebUSB status blob is ~118 B; the core default 64 can't hold it whole,
+#                               so the panel send (which drops rather than blocks when the FIFO is full) could
+#                               never fit a frame -> blank panel. Sized to hold a full blob with headroom.
+# They're baked in here so a normal build is just `make build` -- no need to remember the flags. Override any
+# on the command line, e.g.   make build CFG_TUD_HID=6 CFG_TUD_TASK_QUEUE_SZ=128
+# or add your own defines:     make build EXTRA_FLAGS="-DOPK_LOG=1"
+FQBN ?= adafruit:nrf52:feather52840
+CFG_TUD_HID ?= 4
+# Deep so a loop()-context sendReport never blocks on a full usbd event queue (the watchdog path). Sends run
+# from loop() now (usbTxPump) for stable RF timing, so this depth is what keeps that block from happening.
+CFG_TUD_TASK_QUEUE_SZ ?= 512
+# Must hold a whole WebUSB status blob (~118 B) so the drop-on-full panel send can fit one; core default 64 is
+# too small and would silently drop every frame (blank panel / stale mappings).
+CFG_TUD_VENDOR_TX_BUFSIZE ?= 256
+EXTRA_FLAGS ?=
+# {build.flags.usb} is expanded by arduino-cli (VID/PID/strings); pass it through verbatim.
+USB_EXTRA_FLAGS = -DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=$(CFG_TUD_HID) -DCFG_TUD_TASK_QUEUE_SZ=$(CFG_TUD_TASK_QUEUE_SZ) -DCFG_TUD_VENDOR_TX_BUFSIZE=$(CFG_TUD_VENDOR_TX_BUFSIZE) $(EXTRA_FLAGS)
+
+# `flash`/`deploy` take the serial port as a REQUIRED POSITIONAL arg: `make flash /dev/cu.usbmodem1101`.
+# (No auto-detect -- uploading to a guessed serial port risks writing to the wrong device. List with
+# `arduino-cli board list`.) FLASH_PORT = whatever goal isn't one of our real targets; the catch-all rule at
+# the bottom swallows it so make doesn't try to build the port path as a target.
+FLASH_PORT := $(filter-out format format-check check build build-recovery flash deploy,$(MAKECMDGOALS))
+UPLOAD = arduino-cli upload -b $(FQBN) -p "$(FLASH_PORT)" OpenPuck
+
+.PHONY: format format-check check build build-recovery flash deploy
+
+## Compile the firmware with the required USB flags baked in. Override CFG_TUD_HID / CFG_TUD_TASK_QUEUE_SZ /
+## EXTRA_FLAGS / FQBN as make variables if needed.
+build:
+	arduino-cli compile -b $(FQBN) --build-property "build.extra_flags=$(USB_EXTRA_FLAGS)" OpenPuck
+
+## One-time factory-reset recovery image (wipes persistent storage once on first boot). See §6 of the build doc.
+build-recovery:
+	$(MAKE) build EXTRA_FLAGS="$(EXTRA_FLAGS) -DOPK_FACTORY_RESET=1"
+
+## Upload the most recent build. Usage: make flash <port>   e.g.  make flash /dev/cu.usbmodem1101
+## The board may need DFU/bootloader mode first (double-tap RST): puck (Steam/Lizard) mode drops the CDC port
+## so arduino-cli can't auto-reset it -- see docs/BUILD_AND_DEPLOY.md.
+flash:
+	@[ -n "$(FLASH_PORT)" ] || { echo "usage: make flash <port>   e.g. make flash /dev/cu.usbmodem1101   (list ports: arduino-cli board list)"; exit 1; }
+	$(UPLOAD)
+
+## Build then upload in one step. Usage: make deploy <port>   (same build overrides as `build`).
+deploy:
+	@[ -n "$(FLASH_PORT)" ] || { echo "usage: make deploy <port>   e.g. make deploy /dev/cu.usbmodem1101"; exit 1; }
+	$(MAKE) build
+	$(UPLOAD)
+
+# Swallow the positional <port> arg so make doesn't error trying to build it as a target. Scoped to things
+# that look like a serial port (so a real typo like `make buld` still errors instead of silently no-op'ing).
+/dev/% COM% tty% cu.%:
+	@:
 
 ## Reformat all C/C++ sources in place using the Linux kernel style.
 format:

+ 77 - 7
OpenPuck/OpenPuck.ino

@@ -2,7 +2,7 @@
 //
 // 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).
+// personalities. Build with `make build` (bakes in the required CFG_TUD_HID=4 + CFG_TUD_TASK_QUEUE_SZ=64).
 //
 // 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
@@ -29,6 +29,7 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "controllers.h"
 #include "haptics.h"
 #include "rf_link.h"
+#include "puck_hid.h" // puckCmdLogDrain()
 #include "rf_diag.h"
 #include "webusb_config.h"
 #include "lizard_map.h"
@@ -37,17 +38,35 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "status_led.h"
 #include "usb_mount.h"
 #include "identity.h"
+#include "fault_diag.h"
+#include "usb_tx.h"
 #include <stdio.h>
 
 #if CFG_TUD_HID < 4
-#error "build with -DCFG_TUD_HID=4 (extra_flags): up to 4 HID interfaces per mode"
+#error "CFG_TUD_HID must be >= 4 (up to 4 HID interfaces per mode). Build with `make build` (bakes it in), or pass -DCFG_TUD_HID=4 in build.extra_flags."
+#endif
+
+// The TinyUSB device-stack event queue (_usbd_q) defaults to 16 entries. tud_task() runs in the dedicated
+// high-priority "usbd" FreeRTOS task, but this firmware emits HID reports from the LOW-priority loop() task
+// (every mode: 0x45 forwards, per-slot 0x79/0x7B/0x43 status, streamed gamepad reports). A cross-task
+// sendReport whose EasyDMA is momentarily busy takes TinyUSB's usbd_defer_func() path, which does a BLOCKING
+// osal_queue_send (xQueueSendToBack WAIT_FOREVER) onto _usbd_q. Under a comms burst (haptics/trackpad floods,
+// the connect-time re-init storm, 4 slots at once) the 16-deep queue saturates; the loop task then blocks
+// posting to it and stops feeding the ~8 s watchdog -> the MCU resets and the controller "disconnects". A
+// deeper queue absorbs realistic bursts so the loop never blocks. Must be set on the build command (it is a
+// TinyUSB-internal #ifndef default, so a sketch header cannot reach usbd.c) -- enforce it here so a build that
+// omits the flag fails loudly instead of shipping the deadlock.
+#if !defined(CFG_TUD_TASK_QUEUE_SZ) || CFG_TUD_TASK_QUEUE_SZ < 32
+#error "CFG_TUD_TASK_QUEUE_SZ must be >= 32: the default 16-deep usbd event queue deadlocks the loop task under comms load -> watchdog reset. Build with `make build` (bakes in 64), or pass -DCFG_TUD_TASK_QUEUE_SZ=64 in build.extra_flags."
 #endif
 
 // puck composite (4 HID + WebUSB) exceeds the default 256 B config buffer
 static uint8_t g_usbCfgDesc[512];
 
-// Per-mode USB serial suffix (modes 1..8: X=xbox N=hori L=lizard P=swpro S=ps5 G=hidgyro Q=ps5game D=ds4game).
-static const char MODE_SUFFIX[] = { 'X', 'N', 'L', 'P', 'S', 'G', 'Q', 'D' };
+// Per-mode USB serial suffix (modes 1..9: X=xbox N=hori L=lizard P=swpro S=ps5 G=hidgyro Q=ps5game D=ds4game 3=ps3).
+static const char MODE_SUFFIX[] = {
+	'X', 'N', 'L', 'P', 'S', 'G', 'Q', 'D', '3'
+};
 // Fixed-interface flags captured at boot so usbReenumerate (dynamic mount, no reboot) replays them.
 static bool s_dynWantWebusb = false, s_dynWantWakeMouse = false;
 
@@ -65,7 +84,7 @@ void usbReenumerate(uint8_t k)
 	// serial carries the mounted count so the host invalidates its cached config descriptor on a change
 	snprintf(
 		g_usbSerial, sizeof g_usbSerial, "%s%c%u", g_unit,
-		MODE_SUFFIX[(g_usbMode >= 1 && g_usbMode <= 8) ? g_usbMode - 1 :
+		MODE_SUFFIX[(g_usbMode >= 1 && g_usbMode <= 9) ? g_usbMode - 1 :
 								 0],
 		(unsigned)k);
 	USBDevice.setSerialDescriptor(g_usbSerial);
@@ -196,7 +215,10 @@ void setup()
 		if (!puckMode && !psClean)
 			usb_web.begin();
 		// bmAttributes: required(0x80) | remote_wakeup(0x20). Remote Wakeup lets us signal wake-from-sleep.
-		USBDevice.setConfigurationAttribute(0x80 | 0x20);
+		// The PS3/Sixaxis (the only clean-PS mode on this static path) advertises 0x80 only -- match it so a
+		// strict console doesn't see a capability the genuine pad never claims.
+		USBDevice.setConfigurationAttribute(psClean ? 0x80 :
+							      (0x80 | 0x20));
 
 		// re-attach with the final descriptor (host re-reads it fresh -> deterministic enumeration)
 		USBDevice.attach();
@@ -208,17 +230,27 @@ void setup()
 		USBDevice.remoteWakeup();
 		ledWakePulse();
 	} // wake host if bus was sleeping when we (re-)attached
+	// Route all device->host HID sends through the usbd task (SOF drain) so loop() never calls tud_* directly
+	// -> the cross-task blocking-defer that deadlocked the loop under comms load can no longer happen.
+	usbTxBegin();
+	webusbInit(); // also drain the WebUSB status blob from the usbd task (its flush() can block loop() too)
 	hapticInit();
 	static const char *MODE_NAME[] = {
 		"STEAM(puck)",	       "XBOX(xinput+mouse)",
 		"SWITCH(horipad)",     "LIZARD(puck kb/mouse)",
 		"SWITCH(pro+gyro)",    "PS5(dualsense)",
 		"HIDGYRO(ds4+motion)", "PS5(dualsense,game/clean)",
-		"DS4(ds4,game/clean)"
+		"DS4(ds4,game/clean)", "PS3(dualshock3/sixaxis)"
 	};
 	Serial.printf("# copycat up: unit=%s board=%s, mode=%s\n", g_unit,
 		      g_board,
 		      MODE_NAME[g_usbMode <= MODE_MAX ? g_usbMode : 0]);
+	// Classify why we (re)booted: distinguishes a watchdog hang from a HardFault from an intentional reboot
+	// (issue #72 -- those are conflated in the field). Surfaced on the WebUSB panel too.
+	faultDiagBoot();
+	// Clock fingerprint: which crystals/oscillators this (possibly clone) board actually came up on. Surfaced
+	// on the panel so flaky clones can be told apart from the known-good board.
+	clockDiagBoot();
 	if (puckMode)
 		Serial.printf(
 			"# puck USB: %s\n",
@@ -249,6 +281,11 @@ void setup()
 	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;
+	// Capture the stuck PC on the WDT's pre-reset interrupt (software stand-in for SWD on the clone hangs).
+	faultDiagArmHangCapture();
+	// Flash black box: at ~6s of loop stall (2s before the WDT reset) dump both tasks' stacked PCs + vitals
+	// to a reserved flash page -- the post-mortem channel that survives even the boards that wipe .noinit.
+	faultDiagBlackBoxArm();
 }
 
 // loop-timing diagnostics: poll rate is capped by loop ITERATION time (pacing wants 4000us but the poll only
@@ -265,8 +302,17 @@ void loop()
 {
 	// feed the watchdog; if we ever stop, the ~8s WDT auto-resets us
 	NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+	faultDiagBeat(); // loop heartbeat -- the SOF blob reports ms-since-beat so a wedge is visible live
+	faultDiagStackTick(); // per-task stack headroom (self-gated ~1Hz) -- usbd-overflow hypothesis check
+	faultDiagFlightTick(); // flight recorder: refresh vitals + heartbeat crumb + live CDC vitals line
+	hapticStabTask(); // stability-test keepalive buzz (no-op unless armed via WebUSB)
+	// cross-check HFCLK(micros) vs LFCLK(millis) once a second -- cheap, both builds (clone clock diagnostic)
+	clockDiagTick();
 	if (g_dirty) {
 		g_dirty = false;
+		// A bond flash write erases+programs a page with interrupts effectively stalled for ms -- a known
+		// watchdog-hang class on slow-flash clones, so leave a crumb bracketing it in the recorder.
+		faultDiagTrace(FR_SAVE, 0);
 		saveBonds();
 	}
 #if OPK_LOG
@@ -276,28 +322,39 @@ void loop()
 	static unsigned long secMs = 0;
 	uint32_t t;
 	t = micros();
+	faultDiagSetStage(0);
 	webusbPoll();
 	acc[0] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(1);
 	if (g_active)
 		g_active->task();
 	acc[1] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(2);
 	serialConsolePoll();
 	acc[2] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(3);
 	rfDiagTask();
 	acc[3] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(4);
 	rfLinkTask();
 	acc[4] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(5);
 	hapticTask();
 	acc[5] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(6);
 	ledTask();
 	acc[6] += (uint32_t)(micros() - t);
+	faultDiagSetStage(7);
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
+	faultDiagSetStage(8);
+	usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
+	puckCmdLogDrain(); // print captured USB feature commands (diagnostic; no-op unless g_cmdCapture)
 	loops++;
 	if (millis() - secMs >= 1000) {
 		g_loopPeriodUs = loops ? (uint16_t)(1000000UL / loops) : 0;
@@ -316,14 +373,27 @@ void loop()
 		secMs = millis();
 	}
 #else
+	// faultDiagSetStage breadcrumb (GPREGRET2) before each stage: if loop() wedges, the watchdog reset's next
+	// boot reports which stage was stuck. Cheap (one register write each).
+	faultDiagSetStage(0);
 	webusbPoll();
+	faultDiagSetStage(1);
 	if (g_active)
 		g_active->task();
+	faultDiagSetStage(2);
 	serialConsolePoll();
+	faultDiagSetStage(3);
 	rfDiagTask();
+	faultDiagSetStage(4);
 	rfLinkTask();
+	faultDiagSetStage(5);
 	hapticTask();
+	faultDiagSetStage(6);
 	ledTask();
+	faultDiagSetStage(7);
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
+	faultDiagSetStage(8);
+	usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
+	puckCmdLogDrain(); // print captured USB feature commands (diagnostic; no-op unless g_cmdCapture)
 #endif
 }

+ 8 - 4
OpenPuck/build_info.h

@@ -1,11 +1,12 @@
 // build_info.h -- firmware build provenance, surfaced in the WebUSB panel so you can confirm EXACTLY which
-// build is flashed on a board (the git commit it was built from, and whether that working tree was dirty).
+// build is flashed on a board (semantic version when available, otherwise git hash, plus dirty flag).
 //
 // The values come from one of two sources, in priority order:
-//   1) -D defines on the compile line, e.g. build.extra_flags += -DOPK_GIT_HASH=\"abc12345\" -DOPK_GIT_DIRTY=1
+//   1) -D defines on the compile line, e.g. build.extra_flags += -DOPK_BUILD_VERSION=\"0.9.1\"
+//      -DOPK_GIT_HASH=\"abc12345\" -DOPK_GIT_DIRTY=1
 //   2) a generated git_version.h (written by gen_version.sh before compiling), picked up via __has_include.
-// If neither is present (a bare checkout compiled by hand) the values fall back to "unknown" / not-dirty, so
-// the build always succeeds -- the panel just shows "unknown" until you generate the header.
+// If neither is present (a bare checkout compiled by hand) the values fall back to empty-version/"unknown"/
+// not-dirty, so the build always succeeds.
 #pragma once
 
 #if !defined(OPK_GIT_HASH) && defined(__has_include)
@@ -14,6 +15,9 @@
 #endif
 #endif
 
+#ifndef OPK_BUILD_VERSION
+#define OPK_BUILD_VERSION ""
+#endif
 #ifndef OPK_GIT_HASH
 #define OPK_GIT_HASH "unknown"
 #endif

+ 27 - 7
OpenPuck/config.cpp

@@ -1,4 +1,6 @@
 #include "config.h"
+#include "rf_link.h" // g_rxWin (poll RX window persisted here)
+#include "haptics.h" // g_hapticBlockOn, g_hapticBlockMs
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
 #include <string.h>
@@ -21,12 +23,12 @@ int g_mDiv = 64, g_mFric = 94;
 
 // Per-type button config. back default {5,6,7,8} = L4->LB R4->RB L5->L3 R5->R3 (0..11 buttons, 12..15 D-pad,
 // 16/17 PS touch/mute, 18 Switch Capture). Switch differs: QAM defaults to Capture(18), A/B swap on, and
-// trackpad haptics off. qamMap 0 = unmapped (hardcoded per-mode behavior).
+// trackpad haptics off. qamMap 0 = unmapped (hardcoded per-mode behavior). ledBright 0 = no override.
 TypeCfg g_type[ET_COUNT] = {
-	/* ET_XBOX   */ { { 5, 6, 7, 8 }, 0, 0, 1 },
-	/* ET_SWITCH */ { { 5, 6, 7, 8 }, 18, 1, 0 },
-	/* ET_DS4    */ { { 5, 6, 7, 8 }, 0, 0, 1 },
-	/* ET_DS5    */ { { 5, 6, 7, 8 }, 0, 0, 1 },
+	/* ET_XBOX   */ { { 5, 6, 7, 8 }, 0, 0, 1, 0 },
+	/* ET_SWITCH */ { { 5, 6, 7, 8 }, 18, 1, 0, 0 },
+	/* ET_DS4    */ { { 5, 6, 7, 8 }, 0, 0, 1, 0 },
+	/* ET_DS5    */ { { 5, 6, 7, 8 }, 0, 0, 1, 0 },
 };
 uint8_t g_etype = ET_NONE;
 
@@ -35,6 +37,7 @@ uint8_t g_abSwap = 0;
 uint8_t g_back[4] = { 5, 6, 7, 8 };
 uint8_t g_qamMap = 0;
 uint8_t g_padHaptics = 1;
+uint8_t g_ledBright = 0;
 
 void applyActiveType()
 {
@@ -48,6 +51,7 @@ void applyActiveType()
 		g_qamMap = 0;
 		g_abSwap = 0;
 		g_padHaptics = 1;
+		g_ledBright = 0;
 		return;
 	}
 	const TypeCfg &t = g_type[g_etype];
@@ -56,6 +60,7 @@ void applyActiveType()
 	g_qamMap = t.qamMap;
 	g_abSwap = t.abSwap;
 	g_padHaptics = t.padHaptics;
+	g_ledBright = t.ledBright;
 }
 // rumble strength % (200 = double); adjustable from the WebUSB panel
 uint8_t g_rumbleScale = 200;
@@ -65,11 +70,15 @@ uint8_t g_rumbleScale = 200;
 const uint32_t g_pollUs = POLL_US_DEFAULT;
 
 #define CFG_FILE "/cfg.bin"
-// bumped (per-type TypeCfg blob replaces scalar abSwap/back/qamMap): old cfg ignored -> clean defaults on first boot
-#define CFG_MAGIC 0xCA
+// Struct layout/semantics changed (haptic-block bytes repurposed); bump so old flash format is discarded ->
+// clean defaults once.
+#define CFG_MAGIC 0xCE
 struct Cfg {
 	uint8_t magic, mode, mDiv, mFric, rsvd0, pollU100, persistMode,
 		bootMode, chordBtn[3], rumbleScale;
+	// rxWin10: legacy RF tunable slot (window now fixed; ignored). lizKeep: the id9=0 hold enable (see
+	// haptics.h LIZKEEP_MS). landAll87: the verbatim-0x87-relay experiment toggle (haptics.h g_landAll87).
+	uint8_t rxWin10, lizKeep, landAll87;
 	TypeCfg type[ET_COUNT]; // per-emulated-type back/qam/abSwap/padHaptics
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
@@ -85,6 +94,9 @@ void saveCfg()
 		  g_bootMode,
 		  { g_chordBtn[0], g_chordBtn[1], g_chordBtn[2] },
 		  g_rumbleScale,
+		  (uint8_t)(g_rxWin / 10),
+		  g_lizKeep,
+		  g_landAll87,
 		  {} };
 	for (int i = 0; i < ET_COUNT; i++)
 		c.type[i] = g_type[i];
@@ -139,6 +151,14 @@ void loadCfg()
 
 			// 0 is a valid setting (rumble off)
 			g_rumbleScale = c.rumbleScale;
+			// lizard-suppression keepalive enable (0/1; anything else = a pre-0xCE cfg leaked
+			// through -> keep the on default)
+			if (c.lizKeep <= 1)
+				g_lizKeep = c.lizKeep;
+			// verbatim-0x87-relay experiment toggle (0/1; default off)
+			if (c.landAll87 <= 1)
+				g_landAll87 = c.landAll87;
+			// The poll RX window is now FIXED (g_rxWin is const) -- any persisted rxWin10 is ignored.
 		}
 		f.close();
 	}

+ 13 - 2
OpenPuck/config.h

@@ -37,14 +37,18 @@
 #define MODE_PS5_GAME 7
 // DS4, CLEAN single-HID (no wake/WebUSB) for game PlayStation classification
 #define MODE_DS4_GAME 8
-#define MODE_MAX 8
+// Sony DualShock 3 / Sixaxis (054C:0268) -- CLEAN single-HID so it enumerates on a REAL PS3 console (the
+// console wants a bare Sixaxis HID, not a composite). Answers the PS3's GET_REPORT(0xF2/0xF5/0xEF/0x01)
+// enable handshake. + gyro/accel + rumble.
+#define MODE_PS3 9
+#define MODE_MAX 9
 
 // The two "game" personalities drop the wake-mouse + WebUSB interfaces so the device is a genuine single-HID PS
 // controller (some PC games -- e.g. Fortnite/UE GameInput -- refuse PS classification when extra interfaces are
 // present). Cost: no config panel / host-wake while in these modes; chord back to Steam (back4 + A) for the panel.
 static inline bool modeIsCleanPS(uint8_t m)
 {
-	return m == MODE_PS5_GAME || m == MODE_DS4_GAME;
+	return m == MODE_PS5_GAME || m == MODE_DS4_GAME || m == MODE_PS3;
 }
 
 static inline bool modeIsPuck(uint8_t m)
@@ -76,6 +80,7 @@ static inline uint8_t etypeForMode(uint8_t m)
 		return ET_SWITCH;
 	case MODE_HIDGYRO:
 	case MODE_DS4_GAME:
+	case MODE_PS3: // DS3 shares the PlayStation face layout / button config
 		return ET_DS4;
 	case MODE_PS5:
 	case MODE_PS5_GAME:
@@ -113,11 +118,15 @@ extern int g_mDiv, g_mFric; // xbox/lizard mouse sensitivity divisor / friction%
 // (0..15 standard, 16=PS Touch Click, 17=PS5 Mute, 18=Switch Capture/Screenshot). qamMap = QAM (3 dots)
 // physical button -> same code space (0 = default/unmapped). abSwap = swap A/B and X/Y (Nintendo layout).
 // padHaptics = 1 keeps the controller's autonomous trackpad haptics, 0 disables them for this type.
+// ledBright = LED brightness sent to the controller on connect: 0 = no override (controller default),
+// 1-100 = brightness %. Steam sets brightness each session; emulated modes never do, so the controller
+// comes up at full brightness. Setting a value here preserves the preferred brightness across mode switches.
 struct TypeCfg {
 	uint8_t back[4];
 	uint8_t qamMap;
 	uint8_t abSwap;
 	uint8_t padHaptics;
+	uint8_t ledBright;
 };
 extern TypeCfg g_type[ET_COUNT];
 extern uint8_t
@@ -130,6 +139,8 @@ extern uint8_t g_back[4];
 extern uint8_t g_qamMap;
 extern uint8_t
 	g_padHaptics; // 1 = trackpad haptics on (default), 0 = disabled for the active type
+// LED brightness for the active emulated type (0 = no override, 1-100 = brightness %)
+extern uint8_t g_ledBright;
 
 // Copy g_type[g_etype] into the live mirrors above (safe defaults when g_etype == ET_NONE).
 void applyActiveType();

+ 3 - 0
OpenPuck/controllers.cpp

@@ -6,6 +6,7 @@
 #include "mode_switch_pro.h"
 #include "mode_ps5.h"
 #include "mode_hidgyro.h"
+#include "mode_ps3.h"
 
 IController *g_active = nullptr;
 
@@ -36,6 +37,8 @@ IController *controllerFor(uint8_t mode)
 
 		// same DS4 controller; setup() drops wake/WebUSB for clean enum
 		return &g_hidGyroCtl;
+	case MODE_PS3:
+		return &g_ps3Ctl;
 	default:
 		return &g_steamPuck;
 	}

+ 840 - 0
OpenPuck/fault_diag.cpp

@@ -0,0 +1,840 @@
+#include "fault_diag.h"
+#include <Arduino.h> // readResetReason(), NRF_POWER, NVIC_SystemReset, POWER_RESETREAS_*_Msk
+#include <FreeRTOS.h>
+#include <task.h> // uxTaskGetSystemState -> per-task stack high-water (overflow hypothesis check)
+#include <string.h>
+#include "rf_link.h" // g_pollsps/g_relayps/g_crcps/g_norxps/g_rfStallRecover -- flight-recorder vitals
+#include "haptics.h" // g_ringFault, relayPending() -- relay-load vitals
+
+// GPREGRET2 markers. GPREGRET (id 0) is reserved by the Adafruit bootloader for the DFU magic; GPREGRET2 is
+// free for the application and is retained across soft/watchdog/pin reset, cleared only on power-on/brownout.
+// Arbitrary non-zero tags -- any value distinct from each other and from the power-on default (0) works.
+#define G2_INTENT 0xB1u
+#define G2_FAULT 0xFAu
+
+static uint8_t g_reason = RR_UNKNOWN;
+static uint32_t g_resetReas = 0;
+static uint8_t g_hangStage = 0xFF;
+
+// ---- watchdog pre-reset PC capture ("software SWD") --------------------------------------------------------
+// The nRF52 WDT raises a TIMEOUT interrupt ~2 LFCLK cycles BEFORE it resets the chip. We take that interrupt at
+// the highest NVIC priority (0) -- above FreeRTOS/TinyUSB BASEPRI critical sections -- read the stacked PC of
+// whatever code was wedged, and stash it in a .noinit RAM struct that survives the reset. Next boot reports it
+// on the panel; map it with addr2line to name the stuck function. Caveat: this fires only if interrupts aren't
+// hard-masked (PRIMASK/CPSID). If a watchdog reset reports NO pc, the hang was a PRIMASK-off / flash-stall type
+// (a different, narrower class) -- itself a useful signal. .noinit isn't zeroed by the C runtime, so it rides
+// through the watchdog reset (unlike GPREGRET2, which this board's bootloader wipes).
+struct HangFrame {
+	uint32_t pc, lr, magic;
+};
+#define HANGPC_MAGIC 0x48414E47u // "HANG"
+__attribute__((section(".noinit"))) volatile struct HangFrame g_hangFrame;
+static uint32_t g_reportHangPC = 0, g_reportHangLR = 0;
+
+__attribute__((naked)) void WDT_IRQHandler(void)
+{
+	__asm volatile(
+		"tst lr, #4            \n" // EXC_RETURN bit2: 0=frame on MSP, 1=on PSP
+		"ite eq                \n"
+		"mrseq r0, msp         \n"
+		"mrsne r0, psp         \n"
+		"ldr r1, [r0, #24]     \n" // stacked PC  (frame[6])
+		"ldr r3, [r0, #20]     \n" // stacked LR  (frame[5])
+		"ldr r2, =g_hangFrame  \n"
+		"str r1, [r2, #0]      \n"
+		"str r3, [r2, #4]      \n"
+		"ldr r1, =0x48414E47   \n"
+		"str r1, [r2, #8]      \n"
+		"b .                   \n" // hold until the WDT reset lands (microseconds away)
+		".ltorg                \n");
+}
+
+// Enable the WDT TIMEOUT interrupt at top priority. Call once, right after NRF_WDT->TASKS_START in setup().
+void faultDiagArmHangCapture()
+{
+	NRF_WDT->INTENSET = WDT_INTENSET_TIMEOUT_Msk;
+	NVIC_SetPriority(
+		WDT_IRQn,
+		0); // above FreeRTOS configMAX_SYSCALL priority -> fires through BASEPRI guards
+	NVIC_ClearPendingIRQ(WDT_IRQn);
+	NVIC_EnableIRQ(WDT_IRQn);
+}
+uint32_t faultDiagHangPC()
+{
+	return g_reportHangPC;
+}
+uint32_t faultDiagHangLR()
+{
+	return g_reportHangLR;
+}
+
+// Hang breadcrumb: loop() stage written to GPREGRET2 as 0x80|stage. 0x80..0x9F can never collide with the
+// intentional-reboot (0xB1) / HardFault (0xFA) markers or the power-on default (0), so the same register
+// disambiguates all of them at boot.
+#define G2_STAGE_FLAG 0x80u
+static const char *const STAGE_STR[] = { "webusb", "ctrl.task", "serial",
+					 "rfdiag", "rflink",	"haptic",
+					 "led",	   "usbmount",	"usbtx" };
+#define STAGE_COUNT ((uint8_t)(sizeof STAGE_STR / sizeof STAGE_STR[0]))
+
+static const char *const REASON_STR[RR_COUNT] = {
+	"unknown",   "power-on", "pin/replug", "WATCHDOG (hang)", "CPU lockup",
+	"HARDFAULT", "reboot",	 "soft reset", "wake-from-off",
+};
+
+// HardFault override. The Adafruit core's default handler (cores/.../debug.cpp) already does NVIC_SystemReset();
+// we replace it to first stamp the fault marker so the NEXT boot classifies this SREQ as RR_HARDFAULT rather
+// than an intentional reboot. The core's strong symbol is only linked to satisfy the vector table, so our own
+// strong definition takes its place. Keep this MINIMAL: we are in fault context on a possibly-corrupt stack --
+// no Serial, no allocations, just stamp and reset.
+extern "C" void HardFault_Handler(void)
+{
+	NRF_POWER->GPREGRET2 = G2_FAULT;
+	NVIC_SystemReset();
+	while (1) {
+	}
+}
+
+void faultDiagArmIntentionalReset()
+{
+	NRF_POWER->GPREGRET2 = G2_INTENT;
+}
+
+// Live stage + loop heartbeat. The post-reset GPREGRET2 breadcrumb only works if the bootloader preserves
+// GPREGRET2 across a watchdog reset -- some clone boards clear it, so it reads blank. These LIVE values don't
+// depend on surviving a reset: the WebUSB blob send runs on the USB SOF interrupt (independent of loop()), so
+// when loop() wedges, the SOF drain can still report the current stage + how long loop() has been stuck,
+// straight to the panel during the ~8s before the watchdog fires.
+static volatile uint8_t g_curStage = 0xFF;
+static volatile uint32_t g_loopBeatMs = 0;
+
+void faultDiagSetStage(uint8_t stage)
+{
+	g_curStage = stage;
+	NRF_POWER->GPREGRET2 = (uint8_t)(G2_STAGE_FLAG | (stage & 0x1Fu));
+}
+
+void faultDiagBeat()
+{
+	g_loopBeatMs = millis();
+}
+uint8_t faultDiagCurStage()
+{
+	return g_curStage;
+}
+uint32_t faultDiagStallMs()
+{
+	// ms since loop() last beat. ~0 when healthy (loop beats ~250x/s); grows while loop() is stuck.
+	return (uint32_t)(millis() - g_loopBeatMs);
+}
+const char *faultDiagStageStr(uint8_t s)
+{
+	return s < STAGE_COUNT ? STAGE_STR[s] : "?";
+}
+
+uint8_t faultDiagHangStage()
+{
+	return g_hangStage;
+}
+const char *faultDiagHangStageStr()
+{
+	return g_hangStage < STAGE_COUNT ? STAGE_STR[g_hangStage] : "n/a";
+}
+
+// ---- flight recorder (data) --------------------------------------------------------------------------------
+// The live ring lives in .noinit so it survives a watchdog reset (like g_hangFrame). At boot we copy the
+// pre-reset trail into g_flightSaved (ordinary RAM -- no need to survive twice) so the live session can start a
+// fresh ring while the console can still re-dump the old trail on demand. Declared here (above faultDiagBoot)
+// because the boot classifier snapshots + dumps it; the push/tick/dump functions are implemented further down.
+struct FRRec {
+	uint32_t ms; // millis() at the push
+	uint16_t arg; // event-specific
+	uint8_t evt; // FR_*
+	uint8_t stage; // loop stage current at the push (g_curStage)
+};
+#define FR_RING 96u
+#define FR_MAGIC 0x464C4954u // "FLIT"
+struct FlightRec {
+	uint32_t magic;
+	uint16_t head; // next write slot
+	uint16_t count; // total pushed this session (saturating) -> "showing last N of M"
+	FRRec ring[FR_RING];
+	// live vitals, refreshed ~4x/s -- the last values captured before a wedge
+	uint32_t vMs; // millis() at last refresh
+	uint32_t loopPerSec; // loop() iterations in the last full second
+	uint32_t heapUsed; // mallinfo().uordblks (bytes) -- trend up = leak/fragmentation
+	uint16_t usbdStackFree, loopStackFree; // words (0 = overflowed)
+	uint16_t pollsps, relayps, crcps, norxps;
+	uint16_t rfHeal, ringFault;
+	uint8_t curStage;
+	uint8_t stallMs; // ms since last loop beat, capped 255
+};
+__attribute__((section(".noinit"))) static volatile struct FlightRec g_flight;
+static struct FlightRec
+	g_flightSaved; // boot-time copy of the pre-reset trail (BSS)
+static bool g_haveSaved = false;
+bool g_vitals = true; // live per-second CDC vitals line (console "VIT" toggles)
+
+static const char *const FR_STR[] = { "none",  "beat",	  "SET",    "GET",
+				      "relay", "rf-up",	  "rf-DN",  "HEAL!",
+				      "mount", "SUSPEND", "resume", "OFF",
+				      "RINGF", "save" };
+#define FR_STR_COUNT ((uint8_t)(sizeof FR_STR / sizeof FR_STR[0]))
+static const char *frEvtStr(uint8_t e)
+{
+	return e < FR_STR_COUNT ? FR_STR[e] : "?";
+}
+
+// ---- flash black box (pre-watchdog dump) -------------------------------------------------------------------
+// On boards whose bootloader wipes .noinit + GPREGRET2 across a watchdog reset (Teyleten-class), EVERY
+// post-mortem channel above is destroyed -- hangs on those boards were unforensicable. Flash survives any
+// reset, so: a 4 Hz TIMER4 interrupt (priority 1, above FreeRTOS BASEPRI masking, below the WDT capture's 0)
+// watches the loop heartbeat; when loop() has been frozen ~6 s (the WDT resets at 8 s), it walks the loop and
+// usbd tasks' TCBs for their stacked PCs (both tasks BLOCKED = the suspected mutual-deadlock class) plus the
+// PC it interrupted (covers the one-task-spinning class), and writes one record to a reserved raw flash page.
+// Next boot reports it on CDC and feeds the existing hangPC/hangStage panel channels. Caveats: fires only if
+// priority-1 interrupts still run (PRIMASK-off hangs leave no record -- same caveat as the WDT capture, and
+// itself a signal); a task that is RUNNING (not blocked) has a stale TCB pxTopOfStack, so cross-check its PC
+// against irqPC.
+#define BB_ADDR \
+	0xE8000UL // raw page: app image (~170 KB, ends < 0x60000) < here < InternalFS (0xED000)
+#define BB_MAGIC 0x62627831u // "bbx1"
+#define BB_WORDS 12
+static TaskHandle_t g_hLoop,
+	g_hUsbd; // captured by faultDiagStackTick's 1 Hz sweep
+// non-static (like g_hangFrame): the naked ISR's `ldr =symbol` needs external linkage to resolve
+extern "C" {
+volatile uint32_t g_bbIrqPC,
+	g_bbIrqLR; // stacked frame of whatever TIMER4 preempted
+}
+static void bbBootReport(
+	bool isHang); // defined below faultDiagBlackBoxArm; called from faultDiagBoot
+static void bbErase(void); // dump-time lazy erase (see bbTimerBody)
+
+static void bbFlashWord(uint32_t addr, uint32_t v)
+{
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen << NVMC_CONFIG_WEN_Pos;
+	__DMB();
+	*(volatile uint32_t *)addr = v;
+	while (!NRF_NVMC->READY) {
+	}
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren << NVMC_CONFIG_WEN_Pos;
+	__DMB();
+}
+// Stacked PC/LR of a BLOCKED FreeRTOS task. pxTopOfStack is the first TCB member (portable-layer invariant);
+// the CM4F PendSV push order is [r4-r11, EXC_RETURN] then (s16-s31 iff the FPU frame was live) then the
+// hardware frame [r0-r3, r12, lr, pc, xpsr]. EXC_RETURN bit4 = 0 means the FPU frame is present.
+static void bbTaskPc(TaskHandle_t h, uint32_t *pc, uint32_t *lr)
+{
+	*pc = *lr = 0;
+	if (!h)
+		return;
+	uint32_t *tos = *(uint32_t **)h;
+	uint32_t a = (uint32_t)tos;
+	if (a < 0x20000000u || a >= 0x20040000u - (26 + 8) * 4)
+		return; // corrupt TCB/stack -> don't fault inside the black box
+	uint32_t excret = tos[8];
+	uint32_t hw = 9 + ((excret & 0x10) ? 0 : 16);
+	*pc = tos[hw + 6];
+	*lr = tos[hw + 5];
+}
+extern "C" void bbTimerBody(void)
+{
+	NRF_TIMER4->EVENTS_COMPARE[0] = 0;
+	(void)NRF_TIMER4->EVENTS_COMPARE[0]; // readback: event write is posted
+	static uint32_t lastBeat;
+	static uint16_t stuck;
+	static bool dumped;
+	uint32_t b = g_loopBeatMs;
+	if (b != lastBeat) {
+		lastBeat = b;
+		stuck = 0;
+		return;
+	}
+	if (dumped)
+		return;
+	if (++stuck <
+	    24) { // 24 ticks @ 4 Hz = 6 s frozen; WDT reset lands at 8 s
+		// USBD self-heal kick before giving up. The black-boxed wedge (2026-07-03, loopPC=start_dma,
+		// usbdPC=idle xQueueReceive) is a USB EasyDMA whose END event/interrupt got swallowed (clone
+		// USBD silicon under concurrent RADIO EasyDMA): TinyUSB's dma_running never clears, every next
+		// transfer re-defers on the TASK_PRIO_HIGH usbd task (livelock), and TASK_PRIO_LOW loop starves.
+		// If the END EVENT is latched but its IRQ was lost, re-pending the USBD IRQ makes the ISR run
+		// edpt_dma_end -> the defer chain unwinds -> FULL recovery, no reset (the panel trail shows a
+		// recovered stall episode). Harmless when nothing is pending: a spurious USBD ISR just returns.
+		// >=500ms of frozen loop heartbeat is never legitimate (bond flash saves are ms-class).
+		if (stuck >= 2) {
+			NVIC_SetPendingIRQ(USBD_IRQn);
+			faultDiagTrace(
+				FR_HEAL,
+				0x05Bu); // arg 0x5B = USBD kick (radio heal passes its own count)
+		}
+		return;
+	}
+	dumped = true;
+	// Lazy erase: the page still holds the PREVIOUS hang's (already-reported) record -- boot no longer
+	// erases it, so its CDC banner stays re-printable on any later debug boot. An in-ISR page erase is
+	// ~85ms of CPU stall, irrelevant here: the system is already dead and the WDT reset is ~2s away.
+	bbErase();
+	// Record v2, written in TWO PHASES: phase 1 (PCs + vitals) commits BEFORE any USBD register is
+	// touched -- if the USBD peripheral has wedged the AHB matrix, reading its registers could bus-stall
+	// this ISR forever (the WDT still resets: its reset path is hardware, not CPU). A record with phase 1
+	// present and phase 2 erased (0xFFFFFFFF) is therefore itself the proof of an AHB/USBD bus wedge.
+	uint32_t lpc, llr, upc, ulr;
+	bbTaskPc(g_hLoop, &lpc, &llr);
+	bbTaskPc(g_hUsbd, &upc, &ulr);
+	uint32_t w1[8] = {
+		BB_MAGIC,
+		(2u << 24) | ((uint32_t)g_curStage << 16), // ver 2
+		lpc, // w2: loop task stacked PC (where it starved/blocked)
+		g_bbIrqPC, // w3: what TIMER4 preempted = the code RUNNING at dump time
+		upc, // w4: usbd task stacked PC
+		((uint32_t)g_flight.usbdStackFree << 16) |
+			g_flight.loopStackFree, // w5
+		((uint32_t)g_flight.relayps << 16) | g_flight.pollsps, // w6
+		g_flight.vMs, // w7: millis at last healthy vitals ~= wedge time
+	};
+	(void)llr;
+	(void)ulr;
+	for (int i = 0; i < 8; i++)
+		bbFlashWord(BB_ADDR + 4u * i, w1[i]);
+	// phase 2: USBD peripheral state -- did the DMA END event ever latch? are its interrupts enabled?
+	uint32_t endmask = 0;
+	for (int i = 0; i < 8; i++) {
+		if (NRF_USBD->EVENTS_ENDEPIN[i])
+			endmask |= (1u << i);
+		if (NRF_USBD->EVENTS_ENDEPOUT[i])
+			endmask |= (1u << (8 + i));
+	}
+	if (NRF_USBD->EVENTS_EPDATA)
+		endmask |= (1u << 16);
+	if (NRF_USBD->EVENTS_USBEVENT)
+		endmask |= (1u << 17);
+	if (NRF_USBD->EVENTS_EP0DATADONE)
+		endmask |= (1u << 18);
+	if (NRF_USBD->EVENTS_SOF)
+		endmask |= (1u << 19);
+	if (NVIC_GetPendingIRQ(USBD_IRQn))
+		endmask |= (1u << 20); // IRQ pending but not being serviced
+	if (NVIC_GetEnableIRQ(USBD_IRQn))
+		endmask |= (1u << 21);
+	uint32_t w2[4] = {
+		endmask, // w8: latched-event bitmap (ENDEPIN 0-7, ENDEPOUT 8-15, EPDATA/USBEVENT/EP0DD/SOF, NVIC)
+		NRF_USBD->INTEN, // w9: which USBD interrupts are enabled
+		NRF_USBD->EPDATASTATUS, // w10: per-endpoint data-ready flags
+		((uint32_t)NRF_USBD->EPINEN << 16) |
+			(NRF_USBD->EPOUTEN & 0xFFFF), // w11
+	};
+	for (int i = 0; i < 4; i++)
+		bbFlashWord(BB_ADDR + 4u * (8 + i), w2[i]);
+}
+// Naked shim: grab the interrupted context's stacked PC/LR (like the WDT capture), then tail-branch to the C
+// body -- LR still holds EXC_RETURN, so the body's return IS the exception return.
+extern "C" __attribute__((naked)) void TIMER4_IRQHandler(void)
+{
+	__asm volatile("tst lr, #4            \n"
+		       "ite eq                \n"
+		       "mrseq r0, msp         \n"
+		       "mrsne r0, psp         \n"
+		       "ldr r1, [r0, #24]     \n"
+		       "ldr r2, =g_bbIrqPC    \n"
+		       "str r1, [r2]          \n"
+		       "ldr r1, [r0, #20]     \n"
+		       "ldr r2, =g_bbIrqLR    \n"
+		       "str r1, [r2]          \n"
+		       "b bbTimerBody         \n"
+		       ".ltorg                \n");
+}
+void faultDiagBlackBoxArm()
+{
+	NRF_TIMER4->TASKS_STOP = 1;
+	NRF_TIMER4->MODE = TIMER_MODE_MODE_Timer << TIMER_MODE_MODE_Pos;
+	NRF_TIMER4->BITMODE = TIMER_BITMODE_BITMODE_32Bit
+			      << TIMER_BITMODE_BITMODE_Pos;
+	NRF_TIMER4->PRESCALER = 9; // 16 MHz / 512 = 31250 Hz
+	NRF_TIMER4->CC[0] = 31250u / 4u; // 4 Hz
+	NRF_TIMER4->SHORTS = TIMER_SHORTS_COMPARE0_CLEAR_Msk;
+	NRF_TIMER4->EVENTS_COMPARE[0] = 0;
+	NRF_TIMER4->INTENSET = TIMER_INTENSET_COMPARE0_Msk;
+	NVIC_SetPriority(TIMER4_IRQn,
+			 1); // above BASEPRI masking, below the WDT capture
+	NVIC_ClearPendingIRQ(TIMER4_IRQn);
+	NVIC_EnableIRQ(TIMER4_IRQn);
+	NRF_TIMER4->TASKS_START = 1;
+}
+static void bbErase()
+{
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Een << NVMC_CONFIG_WEN_Pos;
+	__DMB();
+	NRF_NVMC->ERASEPAGE = BB_ADDR;
+	while (!NRF_NVMC->READY) {
+	}
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren << NVMC_CONFIG_WEN_Pos;
+	__DMB();
+}
+// Boot-side: report the record. Runs from faultDiagBoot AFTER the .noinit hangPC/hangStage recovery, so on
+// boards where those survive the black box only fills the gaps; on RAM-wiping boards it IS the evidence.
+// Lifecycle: the record is NOT erased here -- a normal boot often has no CDC attached, so erasing on first
+// boot would destroy the register words unread. Instead it persists (the banner re-prints on ANY later boot,
+// e.g. a debug-CDC reboot) and the NEXT dump erases the page itself. A "seen" marker at word 12 keeps the
+// panel adoption one-shot so a stale record can't masquerade as a later, unrelated hang's evidence.
+static void bbBootReport(bool isHang)
+{
+	const volatile uint32_t *w = (const volatile uint32_t *)BB_ADDR;
+	bool fresh = (w[12] == 0xFFFFFFFFu);
+	if (w[0] != BB_MAGIC) {
+		// garbage/partial page (interrupted dump or pre-feature content): clean it so the next dump
+		// writes onto erased flash. All-blank needs no erase.
+		bool blank = true;
+		for (int i = 0; i < BB_WORDS; i++)
+			if (w[i] != 0xFFFFFFFFu)
+				blank = false;
+		// keep a half-written phase-1 record (magic present handled below); anything else non-blank
+		// without magic is garbage
+		if (!blank)
+			bbErase();
+		return;
+	}
+	{
+		uint8_t stage = (uint8_t)(w[1] >> 16);
+		Serial.printf(
+			"# BLACK BOX v%lu (flash dump written ~6s into the last hang):\n",
+			(unsigned long)(w[1] >> 24));
+		Serial.printf(
+			"#   stage=%s  loopPC=%08lX  irqPC=%08lX (running at dump)  usbdPC=%08lX\n",
+			faultDiagStageStr(stage), (unsigned long)w[2],
+			(unsigned long)w[3], (unsigned long)w[4]);
+		Serial.printf(
+			"#   stkFree usbd=%luw loop=%luw  polls/s=%lu relay/s=%lu  tWedge~%lums\n",
+			(unsigned long)(w[5] >> 16),
+			(unsigned long)(w[5] & 0xFFFF),
+			(unsigned long)(w[6] & 0xFFFF),
+			(unsigned long)(w[6] >> 16), (unsigned long)w[7]);
+		// phase 2 = USBD peripheral state; all-FF here with phase 1 present = reading USBD registers
+		// bus-stalled the dump ISR = the peripheral wedged the AHB matrix (decisive by itself).
+		if (w[8] == 0xFFFFFFFFu && w[9] == 0xFFFFFFFFu)
+			Serial.printf(
+				"#   USBD regs: UNREADABLE (dump died reading them -> AHB/USBD bus wedge)\n");
+		else
+			Serial.printf(
+				"#   USBD: events=%08lX (ENDEPIN0-7,ENDEPOUT8-15,EPDATA16,USBEVT17,EP0DD18,SOF19,NVICpend20,NVICen21)"
+				" INTEN=%08lX EPDATASTATUS=%08lX EPINEN=%04lX EPOUTEN=%04lX\n",
+				(unsigned long)w[8], (unsigned long)w[9],
+				(unsigned long)w[10],
+				(unsigned long)(w[11] >> 16),
+				(unsigned long)(w[11] & 0xFFFF));
+		// feed the existing panel channels (hangLog / Last-reset tile) when the .noinit path came back
+		// blank -- i.e. exactly the RAM-wiping boards this exists for. loopPC is the primary suspect
+		// location; irqPC (the code actually RUNNING while loop starved) rides the LR slot. One-shot:
+		// only a FRESH record (seen-marker still erased) may claim this boot's hang.
+		if (isHang && fresh) {
+			if (!g_reportHangPC && w[2]) {
+				g_reportHangPC = w[2];
+				g_reportHangLR = w[3];
+			}
+			if (g_hangStage == 0xFF && stage < STAGE_COUNT)
+				g_hangStage = stage;
+		}
+		if (fresh)
+			bbFlashWord(BB_ADDR + 4u * 12, 1u); // stamp "seen"
+	}
+}
+
+void faultDiagBoot()
+{
+	uint32_t rr =
+		readResetReason(); // latched + cleared by the core's init()
+	uint8_t g2 = (uint8_t)NRF_POWER->GPREGRET2;
+	NRF_POWER->GPREGRET2 = 0; // consume the marker for this boot cycle
+	g_resetReas = rr;
+
+	// If this boot follows a watchdog/lockup reset and GPREGRET2 holds a stage breadcrumb (0x80|stage), recover
+	// which loop stage was stuck. Only meaningful for hangs -- an intentional reboot/HardFault stamps its own
+	// marker over the breadcrumb, and a clean power-on zeroes it.
+	bool isHang = (rr & POWER_RESETREAS_DOG_Msk) ||
+		      (rr & POWER_RESETREAS_LOCKUP_Msk);
+	if (isHang && (g2 & G2_STAGE_FLAG) && g2 < G2_INTENT)
+		g_hangStage = (uint8_t)(g2 & 0x1Fu);
+	else
+		g_hangStage = 0xFF;
+
+	// Recover the PC the WDT pre-reset ISR captured (if it fired -- i.e. the hang wasn't PRIMASK-off). Consume
+	// the magic so a later watchdog reset that DIDN'T capture (PRIMASK-off) can't report a stale address.
+	if (isHang && g_hangFrame.magic == HANGPC_MAGIC) {
+		g_reportHangPC = g_hangFrame.pc;
+		g_reportHangLR = g_hangFrame.lr;
+	} else {
+		g_reportHangPC = 0;
+		g_reportHangLR = 0;
+	}
+	g_hangFrame.magic = 0;
+
+	uint8_t reason;
+	// Precedence: a physical pin reset / watchdog / lockup is unambiguous from RESETREAS; only a SREQ needs the
+	// GPREGRET2 marker to split intentional reboot vs HardFault.
+	if (rr & POWER_RESETREAS_RESETPIN_Msk)
+		reason = RR_PIN;
+	else if (rr & POWER_RESETREAS_DOG_Msk)
+		reason = RR_WATCHDOG;
+	else if (rr & POWER_RESETREAS_LOCKUP_Msk)
+		reason = RR_LOCKUP;
+	else if (rr & POWER_RESETREAS_SREQ_Msk)
+		reason = (g2 == G2_FAULT)  ? RR_HARDFAULT :
+			 (g2 == G2_INTENT) ? RR_REBOOT :
+					     RR_SOFT;
+	else if (rr & POWER_RESETREAS_OFF_Msk)
+		reason = RR_WAKE;
+	else if (rr == 0)
+		reason = RR_POWERON; // all bits clear == power-on / brownout
+	else
+		reason = RR_UNKNOWN;
+	g_reason = reason;
+
+	Serial.printf(
+		"# reset cause: %s (RESETREAS=0x%08lX gpregret2=0x%02X)\n",
+		REASON_STR[reason], (unsigned long)rr, g2);
+	if (g_hangStage != 0xFF)
+		Serial.printf("# hang stage: %s (%u)\n",
+			      faultDiagHangStageStr(), g_hangStage);
+	if (g_reportHangPC)
+		Serial.printf("# hang PC=0x%08lX LR=0x%08lX\n",
+			      (unsigned long)g_reportHangPC,
+			      (unsigned long)g_reportHangLR);
+
+	// Flash black box: report + consume last hang's pre-watchdog dump (fills hangPC/stage on boards whose
+	// bootloader wiped the .noinit/GPREGRET2 evidence above).
+	bbBootReport(isHang);
+
+	// Flight recorder: if the pre-reset trail survived (.noinit), snapshot it into ordinary RAM so the live
+	// session can start a fresh ring while the console can still re-dump the old trail ("FR"). On a hang, dump
+	// it now -- this is the post-mortem of what the firmware was doing in the seconds before it wedged.
+	if (g_flight.magic == FR_MAGIC) {
+		memcpy(&g_flightSaved, (const void *)&g_flight,
+		       sizeof g_flightSaved);
+		g_haveSaved = true;
+	}
+	g_flight.magic = FR_MAGIC; // start this session's trail fresh
+	g_flight.head = 0;
+	g_flight.count = 0;
+	if (isHang)
+		faultDiagDumpFlight();
+}
+
+// ---- flight recorder (implementation; data declared above faultDiagBoot) ----------------------------------
+void faultDiagTrace(uint8_t evt, uint16_t arg)
+{
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	if (g_flight.magic !=
+	    FR_MAGIC) { // lazy (re)init: power-on garbage or first push this session
+		g_flight.magic = FR_MAGIC;
+		g_flight.head = 0;
+		g_flight.count = 0;
+	}
+	uint16_t h = g_flight.head;
+	g_flight.ring[h].ms = millis();
+	g_flight.ring[h].arg = arg;
+	g_flight.ring[h].evt = evt;
+	g_flight.ring[h].stage = g_curStage;
+	g_flight.head = (uint16_t)((h + 1u) % FR_RING);
+	if (g_flight.count < 0xFFFFu)
+		g_flight.count++;
+	if (!pm)
+		__enable_irq();
+}
+
+void faultDiagFlightTick(void)
+{
+	static unsigned long beatMs = 0, secMs = 0;
+	static uint32_t loops = 0;
+	loops++;
+	unsigned long now = millis();
+
+	// ~4x/s: refresh the persistent vitals snapshot + drop a heartbeat crumb (its cadence in the trail shows
+	// exactly when loop() stopped advancing).
+	if ((uint32_t)(now - beatMs) >= 250u) {
+		beatMs = now;
+		uint32_t stall = faultDiagStallMs();
+		g_flight.vMs = now;
+		g_flight.usbdStackFree = faultDiagUsbdStackFree();
+		g_flight.loopStackFree = faultDiagLoopStackFree();
+		// NOTE: mallinfo() removed here -- it suspended the FreeRTOS scheduler + walked the heap free list 4x/s,
+		// which is new loop latency and a new wedge surface on a slow clone, for zero payoff (heap was flat). If
+		// a heap-usage trend is ever needed, sample it lazily from loop, not on this hot path.
+		g_flight.heapUsed = 0;
+		g_flight.pollsps = g_pollsps;
+		g_flight.relayps = g_relayps;
+		g_flight.crcps = g_crcps;
+		g_flight.norxps = g_norxps;
+		g_flight.rfHeal = g_rfStallRecover;
+		g_flight.ringFault = g_ringFault;
+		g_flight.curStage = g_curStage;
+		g_flight.stallMs = (uint8_t)(stall > 255 ? 255 : stall);
+		faultDiagTrace(FR_BEAT, (uint16_t)g_flight.stallMs);
+	}
+
+	// once per second: latch loop rate + emit the live CDC vitals line (loop context -> Serial is safe here,
+	// unlike the usbd task). Reads as a trend on a flaky board: usbd stack falling toward 0 = the overflow;
+	// heapUsed climbing = a leak; poll==0 = the RF loop stalled; relay high = Steam flooding the link.
+	if ((uint32_t)(now - secMs) >= 1000u) {
+		g_flight.loopPerSec = loops;
+		loops = 0;
+		secMs = now;
+		// Guard on FIFO space: a CDC write to a host that has the port open but isn't draining blocks the loop --
+		// itself a watchdog-hang source (same failure the WebUSB blob send hit). If there's no room this second,
+		// skip the line rather than stall; the persistent trail still captured the vitals via the beat above.
+		if (g_vitals && Serial && Serial.availableForWrite() >= 200) {
+			// Clock fingerprint on every line: the radio waits time out on micros() (HFCLK-derived). If a clone's
+			// HFCLK drops to the internal RC (or micros() freezes), usPerMs drifts off 1000 and a "bounded" RX/
+			// disable wait can spin forever -- the prime suspect for a healthy-then-sudden hard lock. lf/hf: R=RC,
+			// X=xtal, S=synth, -=stopped.
+			const char clkc[] = { '-', 'R', 'X', 'S' };
+			uint8_t lf = clockLfSrc(), hf = clockHfSrc();
+			Serial.printf(
+				"# vit up=%lus loop=%lu/s stall=%ums stage=%s usbdStk=%u loopStk=%u heapUsed=%lu "
+				"poll=%u relay=%u crc=%u norx=%u heal=%u ringF=%u clk=%c/%c usPerMs=%u\n",
+				(unsigned long)(now / 1000),
+				(unsigned long)g_flight.loopPerSec,
+				(unsigned)g_flight.stallMs,
+				faultDiagStageStr(g_flight.curStage),
+				(unsigned)g_flight.usbdStackFree,
+				(unsigned)g_flight.loopStackFree,
+				(unsigned long)g_flight.heapUsed,
+				(unsigned)g_flight.pollsps,
+				(unsigned)g_flight.relayps,
+				(unsigned)g_flight.crcps,
+				(unsigned)g_flight.norxps,
+				(unsigned)g_flight.rfHeal,
+				(unsigned)g_flight.ringFault,
+				lf < 4 ? clkc[lf] : '?',
+				hf < 4 ? clkc[hf] : '?',
+				(unsigned)clockUsPerMs());
+		}
+	}
+}
+
+// Wait (bounded) for room in the CDC TX FIFO so a multi-line dump to a slow host neither drops lines nor
+// blocks forever. Caps each line's wait at ~20ms -> the whole ~100-line dump can't approach the ~8s watchdog.
+static void frWaitTx(uint16_t need)
+{
+	for (uint8_t w = 0;
+	     w < 20 && Serial && Serial.availableForWrite() < need; w++)
+		delay(1);
+}
+
+void faultDiagDumpFlight(void)
+{
+	if (!Serial) // no host attached (e.g. the automatic boot dump before the panel connects) -- use "FR" later
+		return;
+	if (!g_haveSaved || g_flightSaved.magic != FR_MAGIC) {
+		Serial.println(
+			"# flight: no pre-reset trail (recorder lost across this reset, or last boot wasn't a hang)");
+		return;
+	}
+	const struct FlightRec *f = &g_flightSaved;
+	Serial.printf(
+		"# ---- FLIGHT RECORDER (trail up to the last hang) ----\n"
+		"# vitals@wedge: loop=%lu/s stall=%ums stage=%s usbdStkFree=%u loopStkFree=%u heapUsed=%lu\n"
+		"#               poll=%u relay=%u crc=%u norx=%u heal=%u ringFault=%u  (age %lums before reset)\n",
+		(unsigned long)f->loopPerSec, (unsigned)f->stallMs,
+		faultDiagStageStr(f->curStage), (unsigned)f->usbdStackFree,
+		(unsigned)f->loopStackFree, (unsigned long)f->heapUsed,
+		(unsigned)f->pollsps, (unsigned)f->relayps, (unsigned)f->crcps,
+		(unsigned)f->norxps, (unsigned)f->rfHeal,
+		(unsigned)f->ringFault, (unsigned long)0);
+
+	uint16_t total = f->count;
+	uint16_t cnt = total < FR_RING ? total : (uint16_t)FR_RING;
+	if (!cnt) {
+		Serial.println("# flight: trail empty");
+		return;
+	}
+	uint16_t start = (uint16_t)((f->head + FR_RING - cnt) % FR_RING);
+	uint16_t lastIdx = (uint16_t)((f->head + FR_RING - 1u) % FR_RING);
+	uint32_t last = f->ring[lastIdx].ms;
+	Serial.printf(
+		"# %u events (of %u total); t = ms BEFORE the last recorded crumb:\n",
+		cnt, total);
+	for (uint16_t i = 0; i < cnt; i++) {
+		uint16_t idx = (uint16_t)((start + i) % FR_RING);
+		const FRRec *r = &f->ring[idx];
+		frWaitTx(64);
+		Serial.printf("#  -%6lu  %-8s stage=%-9s arg=0x%04X\n",
+			      (unsigned long)(last - r->ms), frEvtStr(r->evt),
+			      faultDiagStageStr(r->stage), r->arg);
+	}
+	Serial.println("# ---- end flight recorder ----");
+}
+
+// ---- WebUSB panel access to the saved trail ----------------------------------------------------------------
+static uint16_t g_frDrain =
+	0; // pull cursor into g_flightSaved, 0 = oldest saved event
+
+bool faultDiagHaveFlight(void)
+{
+	return g_haveSaved && g_flightSaved.magic == FR_MAGIC;
+}
+uint16_t faultDiagFlightCount(void)
+{
+	if (!faultDiagHaveFlight())
+		return 0;
+	uint16_t t = g_flightSaved.count;
+	return t < FR_RING ? t : (uint16_t)FR_RING;
+}
+uint16_t faultDiagFlightTotal(void)
+{
+	return faultDiagHaveFlight() ? g_flightSaved.count : 0;
+}
+void faultDiagFlightDrainReset(void)
+{
+	g_frDrain = 0;
+}
+bool faultDiagFlightPull(uint32_t *dtMs, uint8_t *evt, uint8_t *stage,
+			 uint16_t *arg)
+{
+	uint16_t cnt = faultDiagFlightCount();
+	if (g_frDrain >= cnt)
+		return false;
+	const struct FlightRec *f = &g_flightSaved;
+	uint16_t start = (uint16_t)((f->head + FR_RING - cnt) % FR_RING);
+	uint16_t idx = (uint16_t)((start + g_frDrain) % FR_RING);
+	uint16_t lastIdx = (uint16_t)((f->head + FR_RING - 1u) % FR_RING);
+	*dtMs = (uint32_t)(f->ring[lastIdx].ms - f->ring[idx].ms);
+	*evt = f->ring[idx].evt;
+	*stage = f->ring[idx].stage;
+	*arg = f->ring[idx].arg;
+	g_frDrain++;
+	return true;
+}
+bool faultDiagFlightVitals(struct FaultFlightVitals *out)
+{
+	if (!faultDiagHaveFlight())
+		return false;
+	const struct FlightRec *f = &g_flightSaved;
+	out->loopPerSec =
+		(uint16_t)(f->loopPerSec > 0xFFFFu ? 0xFFFFu : f->loopPerSec);
+	out->usbdStkFree = f->usbdStackFree;
+	out->loopStkFree = f->loopStackFree;
+	out->heapUsed = f->heapUsed;
+	out->pollsps = f->pollsps;
+	out->relayps = f->relayps;
+	out->crcps = f->crcps;
+	out->norxps = f->norxps;
+	out->rfHeal = f->rfHeal;
+	out->ringFault = f->ringFault;
+	out->curStage = f->curStage;
+	out->stallMs = f->stallMs;
+	return true;
+}
+
+// ---- clock fingerprint -------------------------------------------------------------------------------------
+static uint8_t g_clkLf = 0, g_clkHf = 0;
+static uint16_t g_clkUsPerMs = 0;
+
+void clockDiagBoot()
+{
+	// LFCLKSTAT: bit16 STATE(running), bits1:0 SRC (0=RC,1=Xtal,2=Synth). HFCLKSTAT: bit16 STATE, bit0 SRC
+	// (0=RC,1=Xtal). The bare-metal radio needs HFXO; if HF shows RC here, RF runs on the 64 MHz internal RC.
+	uint32_t lf = NRF_CLOCK->LFCLKSTAT;
+	uint32_t hf = NRF_CLOCK->HFCLKSTAT;
+	bool lfrun = lf & CLOCK_LFCLKSTAT_STATE_Msk;
+	uint8_t lfsrc = (uint8_t)(lf & CLOCK_LFCLKSTAT_SRC_Msk);
+	g_clkLf = lfrun ? (uint8_t)(lfsrc + 1) :
+			  0; // 1=RC,2=Xtal,3=Synth, 0=stopped
+	bool hfxtal = hf & CLOCK_HFCLKSTAT_SRC_Msk;
+	g_clkHf = hfxtal ? 2 : 0; // 2=crystal, 0=RC
+	Serial.printf("# clock: LFCLK=%s HFCLK=%s\n",
+		      g_clkLf == 2 ? "xtal" :
+		      g_clkLf == 1 ? "RC" :
+		      g_clkLf == 3 ? "synth" :
+				     "stopped",
+		      g_clkHf == 2 ? "xtal" : "RC");
+}
+
+void clockDiagTick()
+{
+	// Cross-check the two time bases: count micros() (HFCLK-derived) elapsed over a ~1s millis() (LFCLK/RTC)
+	// window. usPerMs = micros-delta / millis-delta; 1000 = the clocks agree. A clone whose HFCLK runs fast vs
+	// its LFCLK reads >1000 here -- the exact signature behind "poll rate too high, delivered too low".
+	static uint32_t u0 = 0;
+	static unsigned long m0 = 0;
+	static bool init = false;
+	unsigned long m = millis();
+	if (!init) {
+		u0 = micros();
+		m0 = m;
+		init = true;
+		return;
+	}
+	unsigned long dm = m - m0;
+	if (dm >= 1000) {
+		uint32_t du = (uint32_t)(micros() - u0);
+		g_clkUsPerMs = (uint16_t)(du / dm);
+		u0 = micros();
+		m0 = m;
+	}
+}
+
+uint8_t clockLfSrc()
+{
+	return g_clkLf;
+}
+uint8_t clockHfSrc()
+{
+	return g_clkHf;
+}
+uint16_t clockUsPerMs()
+{
+	return g_clkUsPerMs;
+}
+
+// ---- per-task stack headroom (usbd-overflow hypothesis check) ---------------------------------------------
+// uxTaskGetSystemState (configUSE_TRACE_FACILITY=1) reports each task's stack high-water mark = the LEAST free
+// stack it ever had, in words. The "usbd" task (200 words / 800 B, core-fixed) runs handleSet->relayEnqueue;
+// if this trends toward 0 under haptic load, the stack overflow is confirmed. Repo-scoped, read-only, no core
+// changes -- just observing FreeRTOS.
+static uint16_t g_usbdStackMin = 0xFFFF;
+static uint16_t g_loopStackMin = 0xFFFF;
+void faultDiagStackTick()
+{
+	static unsigned long ms = 0;
+	if ((uint32_t)(millis() - ms) < 1000)
+		return;
+	ms = millis();
+	static TaskStatus_t st[12];
+	UBaseType_t n = uxTaskGetSystemState(st, 12, NULL);
+	for (UBaseType_t i = 0; i < n; i++) {
+		uint16_t hw = (uint16_t)st[i].usStackHighWaterMark;
+		if (!strcmp(st[i].pcTaskName, "usbd")) {
+			g_hUsbd =
+				st[i].xHandle; // black box: TCB to walk at dump time
+			if (hw < g_usbdStackMin)
+				g_usbdStackMin = hw;
+		} else if (!strcmp(st[i].pcTaskName, "loop")) {
+			g_hLoop = st[i].xHandle;
+			if (hw < g_loopStackMin)
+				g_loopStackMin = hw;
+		}
+	}
+}
+uint16_t faultDiagUsbdStackFree()
+{
+	return g_usbdStackMin == 0xFFFF ? 0 : g_usbdStackMin;
+}
+uint16_t faultDiagLoopStackFree()
+{
+	return g_loopStackMin == 0xFFFF ? 0 : g_loopStackMin;
+}
+
+uint8_t faultDiagReason()
+{
+	return g_reason;
+}
+uint32_t faultDiagResetReas()
+{
+	return g_resetReas;
+}
+const char *faultDiagReasonStr()
+{
+	return REASON_STR[g_reason < RR_COUNT ? g_reason : 0];
+}

+ 155 - 0
OpenPuck/fault_diag.h

@@ -0,0 +1,155 @@
+// fault_diag.h -- "why did we reboot?" diagnostic for issue #72 (intermittent disconnects).
+//
+// Field reports conflate THREE different events that all look identical from the host (USB re-enumerates, the
+// WebUSB panel shows "RF Link down"):
+//   1. a genuine RF link drop -- NO reboot, the link self-recovers in seconds (handled in rf_link, not here);
+//   2. a loop() hang -- the ~8s hardware watchdog fires and resets the MCU (red LED);
+//   3. an MCU fault -- a HardFault triggers an immediate reset (the Adafruit core's default handler resets).
+// Without recording which one happened, (2) and (3) are indistinguishable in the field, so the thread keeps
+// guessing. This module classifies the cause of each boot and surfaces it (boot banner + WebUSB panel).
+//
+// How: the nRF52 RESETREAS register already distinguishes watchdog (DOG) / software (SREQ) / pin (RESETPIN) /
+// lockup resets; the Adafruit core latches it at boot (readResetReason()). A SREQ alone can't tell "we rebooted
+// on purpose" (mode-switch chord, config reboot, DFU) from "a HardFault reset us" -- so we stamp the GPREGRET2
+// retention register (retained across soft/watchdog/pin reset, cleared only on power-on; GPREGRET itself is
+// reserved by the bootloader for the DFU magic): faultDiagArmIntentionalReset() before every deliberate
+// NVIC_SystemReset, and the HardFault handler stamps a distinct fault marker.
+#pragma once
+#include <stdint.h>
+
+// Boot-cause classification (kept in sync with REASON_STR[] in fault_diag.cpp).
+enum {
+	RR_UNKNOWN = 0,
+	RR_POWERON, // cold power-on / brownout
+	RR_PIN, // reset pin / cable replug
+	RR_WATCHDOG, // WDT fired -> loop() stopped feeding it (a hang)
+	RR_LOCKUP, // CPU lockup
+	RR_HARDFAULT, // HardFault -> our handler reset us
+	RR_REBOOT, // intentional NVIC_SystemReset (mode switch / config / DFU)
+	RR_SOFT, // software reset, unattributed
+	RR_WAKE, // wake from System OFF
+	RR_COUNT,
+};
+
+// Classify + log the cause of THIS boot. Call once, early in setup() (after Serial.begin so the line lands).
+void faultDiagBoot();
+// Stamp "the reset about to happen is intentional" -- call immediately before a deliberate NVIC_SystemReset so
+// the next boot classifies it as RR_REBOOT, not RR_HARDFAULT.
+void faultDiagArmIntentionalReset();
+
+// ---- hang-stage breadcrumb -------------------------------------------------------------------------------
+// loop() writes the stage it is ENTERING to GPREGRET2 (encoded 0x80|stage, distinct from the intentional/fault
+// markers and from 0 during normal SREQ). If loop() wedges, the ~8s watchdog fires; GPREGRET2 survives that
+// reset, so the next boot can report WHICH loop stage was stuck -- the missing piece for the clone hangs.
+// Stage indices match the OPK_LOG timing order: 0 webusb,1 ctrl.task,2 serial,3 rfdiag,4 rflink,5 haptic,
+// 6 led,7 usbmount,8 usbtx.
+void faultDiagSetStage(uint8_t stage);
+// The stage loop() was in when the last WATCHDOG/LOCKUP reset fired (0xFF = last boot wasn't a hang, or the
+// breadcrumb didn't survive -- some clone bootloaders clear GPREGRET2). String form for the panel/console.
+uint8_t faultDiagHangStage();
+const char *faultDiagHangStageStr();
+
+// Live hang localization (does NOT need a reset to survive): faultDiagBeat() is called once per loop()
+// iteration; faultDiagStallMs() is ms since that last beat (~0 healthy, grows while loop() is wedged), and
+// faultDiagCurStage() is the stage loop() is currently in. The SOF-driven blob reports these so the panel can
+// show "STALLED in <stage>" live, before the watchdog even fires.
+void faultDiagBeat();
+uint8_t faultDiagCurStage();
+uint32_t faultDiagStallMs();
+const char *faultDiagStageStr(uint8_t s);
+
+// Watchdog pre-reset PC capture ("software SWD"): arm in setup() right after NRF_WDT->TASKS_START. After a
+// watchdog hang, faultDiagHangPC()/LR() return the PC/LR of the stuck code (0 if the hang hard-masked
+// interrupts so the capture ISR couldn't run). Map the PC with addr2line on the build .elf.
+void faultDiagArmHangCapture();
+// Flash black box: 4 Hz TIMER4 watchdog-of-the-watchdog; at ~6s of loop stall it dumps both tasks' stacked
+// PCs + vitals to a reserved flash page (survives ANY reset, incl. boards that wipe .noinit/GPREGRET2).
+// Reported + consumed by faultDiagBoot on the next boot. Arm once from setup(), after the WDT starts.
+void faultDiagBlackBoxArm();
+uint32_t faultDiagHangPC();
+uint32_t faultDiagHangLR();
+
+// Per-task stack headroom (words of stack never used = free). Call faultDiagStackTick() from loop() (self-gated
+// to ~1 Hz). faultDiagUsbdStackFree() trending toward 0 under haptic load confirms the usbd-task overflow.
+void faultDiagStackTick();
+uint16_t faultDiagUsbdStackFree();
+uint16_t faultDiagLoopStackFree();
+// Last-boot classification (RR_*) + the raw RESETREAS, for the WebUSB panel / console.
+uint8_t faultDiagReason();
+uint32_t faultDiagResetReas();
+const char *faultDiagReasonStr();
+
+// ---- flight recorder (catch a watchdog hang post-mortem) -------------------------------------------------
+// A .noinit ring of recent high-level events + a live vitals snapshot, BOTH surviving a watchdog reset (same
+// mechanism as the hang PC frame: .noinit RAM isn't zeroed by the C runtime, so it rides through the reset even
+// on clone boards whose bootloader wipes GPREGRET2). A hang drops USB the instant it resets, so live CDC
+// logging can never catch the moment itself -- instead we record the trail continuously and DUMP IT ON THE NEXT
+// BOOT when the last reset was a hang. Every push is a few stores under PRIMASK: safe to call from the fragile
+// 800B usbd task and from ISRs (NO printf, NO allocation). This is the "what was going on when it wedged" the
+// cheap boards (Teyleten hangs ~every 4 min) need. `arg` is event-specific, usually packed (high<<8)|low.
+enum {
+	FR_NONE = 0,
+	FR_BEAT, // periodic loop heartbeat (arg = stall ms) -- the gap between beats localizes the wedge in time
+	FR_SET, // usbd handleSet feature write     (arg = (rid<<8)|cmd)
+	FR_GET, // usbd handleGet feature read      (arg = (rid<<8)|cmd)
+	FR_RELAY, // host->controller relay enqueued (arg = (slot<<8)|rid)
+	FR_RFUP, // RF (re)connect                   (arg = slot)
+	FR_RFDN, // RF link down                     (arg = slot)
+	FR_HEAL, // RF-stall radio self-heal (power-cycle)
+	FR_MOUNT, // USB HID mount/unmount changed   (arg = mounted-slot count)
+	FR_SUSP, // USB bus suspend edge
+	FR_RESUME, // USB bus resume edge
+	FR_OFF, // controller power-off (0x9F) relayed (arg = slot)
+	FR_RINGF, // relay-ring fault caught          (arg = running count)
+	FR_SAVE, // bond flash write (saveBonds)
+};
+
+// Push one event into the flight recorder. Cheap + ISR/usbd-safe (a few stores under PRIMASK, no printf).
+void faultDiagTrace(uint8_t evt, uint16_t arg);
+// Per-loop upkeep: refresh the persistent vitals snapshot + emit a heartbeat (~4x/s), and stream the live
+// per-second vitals line over CDC when g_vitals is on. Call once per loop() iteration.
+void faultDiagFlightTick(void);
+// Print the pre-reset flight trail + vitals-at-wedge over CDC (from the boot-time copy of the surviving ring).
+// Called automatically from faultDiagBoot() after a hang; also re-dumpable on demand from the console ("FR").
+void faultDiagDumpFlight(void);
+
+// ---- WebUSB panel access to the saved (pre-reset) trail ---------------------------------------------------
+// The same boot-time copy the console "FR" dumps, exposed for the panel to stream as 0xA8 frames. Mirrors the
+// hapLog capture drain: reset the cursor to the oldest saved event, then pull entries until exhausted, so a
+// full 96-event trail streams across several panel polls without a single blocking write.
+bool faultDiagHaveFlight(
+	void); // true if a pre-reset trail was captured at this boot
+uint16_t faultDiagFlightCount(void); // saved events available to stream (<= 96)
+uint16_t faultDiagFlightTotal(
+	void); // total events pushed before the reset (for "showing N of M")
+void faultDiagFlightDrainReset(
+	void); // rewind the pull cursor to the oldest saved event
+// Pull the next saved event; false when exhausted. *dtMs = ms the event fired BEFORE the last recorded crumb.
+bool faultDiagFlightPull(uint32_t *dtMs, uint8_t *evt, uint8_t *stage,
+			 uint16_t *arg);
+// Vitals as of the last ~4Hz refresh before the reset (the "@wedge" snapshot). false if no trail was captured.
+struct FaultFlightVitals {
+	uint16_t loopPerSec;
+	uint16_t usbdStkFree, loopStkFree;
+	uint32_t heapUsed;
+	uint16_t pollsps, relayps, crcps, norxps, rfHeal, ringFault;
+	uint8_t curStage, stallMs;
+};
+bool faultDiagFlightVitals(struct FaultFlightVitals *out);
+// 1 = stream the live per-second vitals line over CDC (console "VIT" toggles). Watch usbd stack / heap / relay
+// rate trend on a flaky board in the seconds before it wedges.
+extern bool g_vitals;
+
+// ---- clock fingerprint (clone-board stability diagnostic) -------------------------------------------------
+// nice!nano clones vary in their crystals. The bare-metal radio needs HFXO (32 MHz xtal); millis()/RTC and the
+// watchdog run on LFCLK (32.768 kHz xtal or RC). If a clone runs HFCLK on the internal RC, or its LFCLK is
+// off-rate, the two time bases diverge -- which inflates the micros()-gated poll rate, shortens the RX window
+// (delivered drops), and skews the LFCLK-based watchdog. These let the panel show, per board: which source
+// each clock is actually on, and the measured micros()/millis() ratio (ideal 1000 us per ms).
+//   LF code: 0=stopped, 1=RC, 2=crystal, 3=synth.  HF code: 0=RC(running or not), 2=crystal.
+void clockDiagBoot(); // read+log the clock sources once at boot
+void clockDiagTick(); // call from loop(); recomputes usPerMs about once a second
+uint8_t clockLfSrc();
+uint8_t clockHfSrc();
+uint16_t
+clockUsPerMs(); // measured micros() advanced per millis() tick (ideal 1000; 0 until first sample)

+ 246 - 63
OpenPuck/haptics.cpp

@@ -2,7 +2,10 @@
 #include "bonds.h"
 #include "config.h"
 #include "rf_link.h"
+#include "usb_tx.h" // usbTxBoost/Unboost -- flood-rate CDC prints share the dcd DMA claim window
+#include "puck_hid.h" // puckLizardActive() -- gate the lizard-suppression keepalive
 
+#include "fault_diag.h" // faultDiagTrace() -- flight recorder
 // USBDevice.suspended() -> autonomous controller power-off on host sleep
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
@@ -14,6 +17,28 @@ uint8_t g_relayOp = 0xE3; // E3 poll
 uint8_t g_relaySub = 0x05;
 volatile uint8_t g_testHaptic = 0;
 volatile uint8_t g_hapticStop = 0;
+// Post-connect haptic block (see haptics.h): enabled + duration, both persisted and panel-adjustable.
+// Off by default -- opt in from the WebUSB panel if a controller's haptics come up degraded after connect.
+uint8_t g_hapticBlockOn = 0;
+uint16_t g_hapticBlockMs = HAPTIC_BLOCK_MS_DEFAULT;
+// id9 steering (see hapticTask): ON by default. Holds id9=0 while Steam is DRIVING (autonomous engine off,
+// Steam owns haptics, no reconnect buzz-latch) and lands id9=1 when lizard is PRESENTING (pure MODE_LIZARD
+// or Steam-closed fallback) so the autonomous pad layer -- the trackpad-tick source -- is on.
+// Persisted; console 'u' toggles for A/B.
+uint8_t g_lizKeep = 1;
+// EXPERIMENT (console "L87" toggles, persisted): land ALL relayed 0x87 SET_SETTINGS via type-01 framing so
+// Steam's haptic/amp/IMU config actually reaches the controller (verbatim relay = real-puck behavior),
+// instead of the whitelist discarding everything but LED(0x2D)/id9(0x09). Hypothesis: the buzz is the
+// controller's haptic engine left MISCONFIGURED because Steam's 0x87 config is discarded; landing it should
+// configure the engine correctly and stop the buzz. Default OFF (keeps the safe whitelist) because landing
+// reg 0x30 was once blamed for freezing the gyro and 0x34/0x35 for the buzz -- but those were OpenPuck's own
+// bad hapticReinit values, so Steam's real values may be fine. Flip it on hardware to test; flip back if the
+// gyro freezes.
+uint8_t g_landAll87 = 0;
+// Land Steam's amp/haptic-config 0x87 blocks (regs 0x18/0x2E/0x34/0x35, NOT gyro 0x30) so the controller's
+// amplifier is configured and haptics play as clean ticks instead of a default-amp buzz. On by default;
+// console "AMP" toggles for A/B. See the land01 whitelist in rfConnFlushRelay.
+uint8_t g_landAmp = 1;
 // Per-slot reconnect block. 0 = idle; non-zero = drop haptics aimed at this slot until millis() catches up.
 unsigned long g_hapticBlockUntil[NSLOT] = { 0 };
 
@@ -22,14 +47,16 @@ unsigned long g_hapticBlockUntil[NSLOT] = { 0 };
 // forwards host feature reports verbatim, so the controller acts on report 0x9F directly -- we relay it the
 // same way (E3 SET sub-TLV). The wire relay is NO-ACK, so send a small burst: a single lost frame must not
 // leave the controller on.
-void hapticSendShutdown()
+void hapticSendShutdown(uint8_t slot)
 {
 	static const uint8_t OFF[4] = { 0x6f, 0x66, 0x66, 0x21 }; // "off!"
-	// broadcast: every connected controller should power off, not just the slot the shutdown was triggered
-	// from (the trigger can come from a Steam interface, the test button, or host-suspend -- any of which
-	// "logically" means "all controllers off").
+	// slot-targeted: Steam's 0x9F arrives on ONE controller's interface and must power off only that
+	// controller (broadcasting it killed every connected controller when the user turned off one). The
+	// broadcast default (0xFF) remains for the triggers that logically mean "all off": host suspend and
+	// the panel/test power-off button.
+	faultDiagTrace(FR_OFF, slot);
 	for (uint8_t i = 0; i < HAPTIC_SHUTDOWN_SHOTS; i++)
-		relayEnqueue(0x9F, OFF, sizeof OFF, 0xFF);
+		relayEnqueue(0x9F, OFF, sizeof OFF, slot);
 }
 
 // millis of last 0x82 haptic OUTPUT relayed (Steam mode)
@@ -45,16 +72,6 @@ static unsigned long g_rumble80Ms[NSLOT] = { 0 };
 // stuck-rumble watchdog is independent
 static bool g_rumble80On[NSLOT] = { false, false, false, false };
 
-// when to fire the next post-reconnect haptic re-init (0 = none scheduled)
-static unsigned long g_reinitAt = 0;
-
-// how many re-init shots remain in this connect window
-static uint8_t g_reinitLeft = 0;
-
-// haptic activity happened -> arm a clear once it goes idle (catches a
-// latch that engaged during/after use, even seconds after connect)
-static bool g_hapClearArmed = false;
-
 // ---- relay rings: one per bond slot. Multi-producer (USB ISR + loop-context console/xinput), one consumer
 // per slot (rfConnFlushRelay on that slot's poll turn). Producers serialize under PRIMASK.
 struct RelayMsg {
@@ -71,6 +88,12 @@ static inline uint8_t rqNext(uint8_t i)
 {
 	return (uint8_t)((i + 1) % RELAY_QLEN);
 }
+// Counts times a ring-drain loop hit its iteration cap = head/tail were desynced or corrupted (e.g. an
+// out-of-range g_rqHead from a stray write/stack overflow). These loops run with IRQs DISABLED, so an
+// unterminated one spins forever with interrupts off -> millis()/USB/SOF all freeze and only the hardware
+// watchdog recovers (an invisible "watchdog (hang)" -- the live stall monitor can't see it because the SOF
+// IRQ is dead). The cap turns that into a logged, recovered event instead of a hang. Surfaced on the panel.
+volatile uint16_t g_ringFault = 0;
 
 bool relayPending()
 {
@@ -102,12 +125,11 @@ bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 			memcpy(g_rq[s][h].data, payload, plen);
 		g_rqHead[s] = nx;
 	}
-	// Any haptic relay arms the idle-clear (so the during-use latch gets cleared in every mode).
-	if (rid == 0x82 || rid == 0x80) {
+	// Track last-haptic time for the Steam-mode quiet timeout (marks the 0x82 stream inactive after silence).
+	if (rid == 0x82 || rid == 0x80)
 		g_haptic82Ms = millis();
-		g_hapClearArmed = true;
-	}
 	__set_PRIMASK(pm);
+	faultDiagTrace(FR_RELAY, (uint16_t)((slot << 8) | rid));
 	return true;
 }
 
@@ -140,8 +162,10 @@ void hapLogAdd(uint8_t slot, uint8_t rid, const uint8_t *b, uint16_t n)
 	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]));
+	// uint16_t, NOT uint8_t: HAPLOG_N is 4096, so a uint8_t cast truncated the head to 0..255 and only the
+	// first 256 ring slots were ever used (capture lost ~94% of its depth).
+	g_hapHead = (uint16_t)((g_hapHead + 1) %
+			       (sizeof g_hapLog / sizeof g_hapLog[0]));
 	__set_PRIMASK(pm);
 }
 void hapticDumpLog()
@@ -225,13 +249,24 @@ static bool haptic82PayloadOn(const uint8_t *p, uint16_t n)
 			return true;
 	return false;
 }
-static void hapticCancelPendingOn()
+static void hapticCancelPendingOn(int slot)
 {
-	// void queued ON entries across all slot queues (stale haptics / rumble across a reconnect)
+	// void queued ON entries (stale haptics / rumble across a reconnect). Per-slot: only the reconnected
+	// slot's queue is scrubbed -- another controller's pending haptics are its own business. slot=-1 keeps
+	// the old scrub-everything behavior (boot).
+	int c0 = (slot >= 0 && slot < NSLOT) ? slot : 0;
+	int c1 = (slot >= 0 && slot < NSLOT) ? slot + 1 : NSLOT;
 	uint32_t pm = __get_PRIMASK();
 	__disable_irq();
-	for (int s = 0; s < NSLOT; s++) {
+	for (int s = c0; s < c1; s++) {
+		uint8_t guard = RELAY_QLEN + 1;
 		for (uint8_t i = g_rqTail[s]; i != g_rqHead[s]; i = rqNext(i)) {
+			if (!guard--) { // desynced/corrupt ring -> recover, don't spin IRQs-off
+				g_rqHead[s] = g_rqTail[s] = 0;
+				g_ringFault++;
+				faultDiagTrace(FR_RINGF, g_ringFault);
+				break;
+			}
 			RelayMsg &m = g_rq[s][i];
 			if (m.rid == 0x82) {
 				bool on = false;
@@ -262,9 +297,6 @@ void haptic82HostReport(const uint8_t *p, uint16_t n)
 	if (n < 3)
 		return;
 	g_haptic82Ms = millis();
-
-	// any haptic activity arms a clear when it next goes idle (kills a latch from this use)
-	g_hapClearArmed = true;
 	// Track on/off only. Do NOT synthesize a stop burst: Steam's own stop is forwarded verbatim, and each extra
 	// 0x82 is a discrete pad click -- the spurious end-of-movement "click"/buzz the real puck never produces.
 	g_haptic82On = haptic82PayloadOn(p, n);
@@ -315,6 +347,38 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot)
 // haptics broadcast to all connected slots (slot 0xFF); the stop frame is broadcast too (a stuck latch can
 // affect any controller, and the haptic-engine clear-re-init is settings-only so it's harmless on healthy
 // ones).
+// Stability-test keepalive: while g_stabTest, buzz every connected controller for ~150ms once per 10s so an
+// unattended uptime measurement isn't ended by a controller idle-sleeping. Toggled by WebUSB cmd 0x0F; the
+// panel times uptime-until-reset. Reboots clear g_stabTest (the panel re-arms it on reconnect).
+bool g_stabTest = false;
+void hapticStabTask()
+{
+	if (!g_stabTest)
+		return;
+	static const uint8_t on[3] = { 0x01, 0x01, 0xF7 };
+	static const uint8_t off[3] = { 0x01, 0x01, 0x00 };
+	static unsigned long lastOn = 0, offAt = 0;
+	unsigned long now = millis();
+	// Buzz only slots whose controller is actually answering polls (same 300ms liveness the panel/0x79 use).
+	// The old 0xFF broadcast kept stuffing a powered-off controller's relay ring -- pure eviction churn plus
+	// wasted TX at a radio that can't hear it, right inside the power-off hang window (issue #72 repro).
+	auto enqLive = [&](const uint8_t *p) {
+		for (uint8_t s = 0; s < NSLOT; s++)
+			if (g_slot[s].used && g_connReplyMs[s] &&
+			    (unsigned long)(now - g_connReplyMs[s]) < 300u)
+				relayEnqueue(0x82, p, 3, s);
+	};
+	if (lastOn == 0 || (uint32_t)(now - lastOn) >= 10000u) {
+		lastOn = now;
+		enqLive(on);
+		offAt = now + 150;
+	}
+	if (offAt && (int32_t)(now - offAt) >= 0) {
+		enqLive(off);
+		offAt = 0;
+	}
+}
+
 void rfConnQueueHapticRelay()
 {
 	if (relayPending())
@@ -332,14 +396,39 @@ void rfConnQueueHapticRelay()
 // rfConnFlushRelay(ch, s1): drain one entry from the current slot's relay queue and TX it. Each slot's queue
 // is independent, so each controller only sees its own commands. With N connected slots the per-slot relay
 // rate is 1/N of the per-cycle rate; sustained buzz streams are still 1 packet/cycle/slot.
-void rfConnFlushRelay(uint8_t ch, uint8_t s1)
+bool rfConnFlushRelay(uint8_t ch, uint8_t s1)
 {
 	int cur = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
+	// Snapshot one entry under a short critical section. relayEnqueue() (the producer) runs on the
+	// high-priority usbd task and, when the ring is full, evicts the oldest by advancing g_rqTail itself --
+	// the same variable this consumer reads/advances. Reading the entry while a producer could overwrite it
+	// is a torn read (and dual-writing g_rqTail desyncs head/tail), so copy the entry out and consume the
+	// slot atomically here, then do the (slow) RF TX on the copy with interrupts enabled.
+	RelayMsg msg;
+	bool have = false;
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	uint8_t guard = RELAY_QLEN + 1;
 	while (g_rqTail[cur] != g_rqHead[cur]) {
+		if (!guard--) { // desynced/corrupt ring -> recover, don't spin IRQs-off (watchdog-hang class)
+			g_rqHead[cur] = g_rqTail[cur] = 0;
+			g_ringFault++;
+			faultDiagTrace(FR_RINGF, g_ringFault);
+			break;
+		}
 		RelayMsg &m = g_rq[cur][g_rqTail[cur]];
-
+		g_rqTail[cur] = rqNext(g_rqTail[cur]); // consume the slot
 		// rid 0 = entry voided by hapticCancelPendingOn -> skip
 		if (m.rid) {
+			msg = m; // copy out before the producer can reuse the slot
+			have = true;
+			break;
+		}
+	}
+	__set_PRIMASK(pm);
+	if (have) {
+		RelayMsg &m = msg;
+		{
 			uint8_t rl = m.len;
 			if (rl > RELAY_MAXP)
 				rl = RELAY_MAXP;
@@ -347,12 +436,34 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 			// the controller only with the type-01 + inner-len form E3 [2+rl][01][rid][innerlen][data];
 			// the legacy form E3 [1+rl][05][rid][data] makes the controller DISCARD any 0x87+ command.
 			//
-			// Whitelist type-01 to only two commands: LED brightness (0x87 reg 0x2D) and power-off
-			// (0x9F). Other 0x87 writes (e.g. reg 0x30 IMU enable, 0x34/0x35 haptic amplitude) must
-			// stay on legacy form -- landing 0x30 freezes the gyro; landing 0x34/0x35 causes the buzz.
+			// Whitelist type-01 to exactly three commands: LED brightness (0x87 reg 0x2D), power-off
+			// (0x9F), and the STANDALONE lizard-suppression keepalive (0x87 [09 00 00] -- rl==3 so a
+			// multi-register block that merely STARTS with 0x09 can't smuggle other registers into
+			// landing). Setting index 9 = digital-mappings/lizard-active (ibex FUN_0001f554 decomp);
+			// the real puck lands id9=0 every 3s -- without it the controller's revert timer fires,
+			// re-enables autonomous mode and resets ALL settings to defaults (audible amp pop +
+			// autonomous touchpad ticks = the spurious mid-session buzz). Other 0x87 writes (e.g. reg
+			// 0x30 IMU enable, 0x34/0x35 haptic amplitude) must stay on legacy form -- landing 0x30
+			// freezes the gyro; landing 0x34/0x35 causes the connect buzz.
+			// Amp/haptic-config registers (0x18/0x2E/0x34/0x35): LAND these so the controller's amplifier is
+			// configured the way Steam intends -> haptics (0x82 ticks, connect cue) play as clean ticks
+			// instead of the default-amp BUZZ we get when the config is discarded. Steam sends these grouped
+			// in 0x87 blocks that START with one of those regs and do NOT contain 0x30 (the gyro reg, which
+			// must NOT land -- it freezes the gyro), so gating on data[0] lands the amp block without 0x30.
+			// (g_landAmp, console "AMP"; on by default. The old "landing 0x34/0x35 buzzes" note was really the
+			// 0x81 storm, now removed.)
+			bool ampReg = (m.data[0] == 0x18 || m.data[0] == 0x2E ||
+				       m.data[0] == 0x34 || m.data[0] == 0x35);
 			bool land01 =
 				(m.rid == 0x9F) ||
-				(m.rid == 0x87 && rl >= 1 && m.data[0] == 0x2D);
+				(m.rid == 0x87 && rl >= 1 &&
+				 m.data[0] == 0x2D) ||
+				(m.rid == 0x87 && rl == 3 &&
+				 m.data[0] == 0x09) ||
+				(g_landAmp && m.rid == 0x87 && rl >= 1 &&
+				 ampReg) ||
+				// experiment: land EVERY 0x87 verbatim (real-puck relay) when enabled
+				(g_landAll87 && m.rid == 0x87);
 			uint8_t p[5 + RELAY_MAXP], plen;
 			if (land01) {
 				p[0] = g_relayOp;
@@ -371,15 +482,30 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 				plen = (uint8_t)(4 + rl);
 			}
 			hapLogAdd(0xFE, m.rid, m.data, rl);
-			// release slot before TX
-			g_rqTail[cur] = rqNext(g_rqTail[cur]);
+			// Lifecycle log (CDC debug boot): EXACTLY what we TX to the controller, so a capture shows
+			// whether OpenPuck (or Steam in the background) pokes the controller right before a buzz
+			// latches -- the piece the I45 stream (controller->us) can't show. Low rate outside a haptic
+			// burst; guarded so it never blocks the loop. cur = target slot.
+			if (Serial.availableForWrite() > 60) {
+				// boosted: this print runs at relay-flood rate and CDC flush enters the same
+				// TinyUSB DMA claim window as HID sends (the issue-72 livelock; see usb_tx.cpp)
+				usbTxBoost();
+				Serial.printf("# TX t=%lu slot%d %s rid=%02X:",
+					      (unsigned long)millis(), cur,
+					      land01 ? "L01" : "l05", m.rid);
+				for (uint8_t i = 0; i < rl && i < 8; i++)
+					Serial.printf(" %02X", m.data[i]);
+				Serial.println();
+				usbTxUnboost();
+			}
+			// slot was already consumed under the critical section above.
 			// s1 carries a PID distinct from the GET poll (caller cycles it) so the controller's ESB
 			// dedup never treats the GET as a retransmit of this relay. 80us RX: relay is NO-ACK.
-			rfConnTx(ch, s1, p, plen, 80);
-			return; // one relay per poll cycle
+			rfConnTx(ch, s1, p, plen,
+				 80); // one relay per poll cycle
 		}
-		g_rqTail[cur] = rqNext(g_rqTail[cur]);
 	}
+	return have; // true = a relay frame went out this cycle (steals a reply window from the E3 poll)
 }
 
 // Haptic-subsystem re-init: the captured sequence Steam sends when it (re)takes control (0x81 reset + 0x87
@@ -432,6 +558,13 @@ void hapticReinit(uint8_t slot)
 	// above, which would otherwise re-enable it). Default-on types send "enable"; Switch (padHaptics=0)
 	// disables. Inert until the setting id is captured.
 	hapticSetPadEnabled(slot, g_padHaptics != 0);
+	// Apply the configured LED brightness for the active emulated type. Steam sets the
+	// brightness each session; emulated modes never do, so the controller comes up at
+	// full brightness. 0 = no override (preserve controller default).
+	if (g_etype < ET_COUNT && g_ledBright > 0) {
+		uint8_t pl[3] = { 0x2D, g_ledBright, 0x00 };
+		relayEnqueue(0x87, pl, sizeof pl, slot);
+	}
 }
 void hapticInit()
 {
@@ -441,31 +574,80 @@ void hapticInit()
 		g_rqHead[s] = g_rqTail[s] = 0;
 		g_rumble80On[s] = false;
 		g_rumble80Ms[s] = 0;
-		// block stale Steam 0x82 until the link is stable after boot
-		g_hapticBlockUntil[s] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
+		// post-connect haptic block is permanently disabled (not armed, not configurable)
+		g_hapticBlockUntil[s] = 0;
 	}
 }
-// Arm the post-(re)connect haptic block + schedule the clearing re-init. Called on the reliable first-reply
-// signal from rf_link and again from hapticTask's link-up edge detector. Per-slot: only the reconnected
-// slot is blocked; the broadcast re-init covers all slots.
+// Schedule the clearing re-init for ONE slot. Called on the reliable first-reply signal from rf_link and
+// again from hapticTask's link-up edge detector. Strictly per-slot: only the reconnected controller gets
+// re-inits / its pending haptics scrubbed -- the other controllers hear nothing about it.
 void hapticOnReconnect(int slot)
 {
 	if (slot < 0 || slot >= NSLOT)
 		return;
-	g_hapticBlockUntil[slot] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
+	// post-connect haptic block is permanently disabled -- relay haptics immediately on (re)connect
+	g_hapticBlockUntil[slot] = 0;
 	g_haptic82On = false;
 	g_rumble80On[slot] = false;
 	g_rumble80Ms[slot] = 0;
-	hapticCancelPendingOn();
-	// Re-init repeatedly across the settle window: the connect buzz engages early (during the block,
-	// controller-internal), so a single late shot misses it.
-	g_reinitAt = millis() + 200u;
-	g_reinitLeft = HAPTIC_REINIT_SHOTS;
+	// Scrub haptics queued before the link came up (stale across the reconnect) -- this slot only.
+	hapticCancelPendingOn(slot);
+	// NO automatic haptic re-init. hapticReinit lands 0x81 (CLEAR DIGITAL MAPPINGS -- rid < 0x87 so it
+	// EXECUTES even on legacy framing). 0x81 is NON-IDEMPOTENT (ibex FUN_0001f554): EVERY call re-runs the
+	// lizard-disable event + func_0x0001bbf0 (a hardware peripheral re-arm unique to the 0x81 path) -- so
+	// firing 8 across the connect window = 8 audible clicks = the "repeated non-periodic clicks at connect"
+	// the real puck (which clears ONCE then holds) never produces. The clearing re-init stays available on
+	// demand (WebUSB "Clear stuck buzz" -> hapticReinit), just not sprayed at connect.
 	uint8_t mk = 2;
 	hapLogAdd(0xFD, 0xEE, &mk, 1);
 }
 void hapticTask()
 {
+	// id9 steering (SET_SETTINGS index 9 = digital-mappings / the controller's AUTONOMOUS mapping+haptic
+	// engine). Two regimes, switched by who is actually driving:
+	//  - Steam DRIVING (MODE_STEAM + heartbeat alive): hold id9=0 every LIZKEEP_MS, the way the real puck
+	//    does (sniff1.json) -- keeps the autonomous engine off so Steam owns haptics and the engine can't
+	//    latch the "deep-inside" reconnect buzz. 0x87 is change-guarded in the controller, so the repeat
+	//    is silent (no 0x81-style click).
+	//  - lizard PRESENTING (pure MODE_LIZARD, or MODE_STEAM with Steam closed): land id9=1 ONCE per
+	//    connect/lizard episode. id9 gates the whole autonomous pad layer INCLUDING the trackpad ticks --
+	//    the old always-in-MODE_STEAM id9=0 hold is why Steam-closed lizard lost its ticks, and a stale
+	//    id9=0 from a previous Steam session (mode switch without a controller power cycle) is why pure
+	//    lizard could come up tickless too. Landing 1 explicitly restores the pad layer immediately
+	//    instead of waiting for the controller's revert timer (which also resets settings = audible pop).
+	// Emulated modes (Xbox/Switch/DS) are untouched: lizardActive() is false there and the id9=0 hold
+	// stays scoped to MODE_STEAM, exactly as before.
+	if (g_lizKeep && modeIsPuck(g_usbMode)) {
+		static unsigned long lastKeep[NSLOT] = { 0 };
+		static bool landedAuto[NSLOT] = { false };
+		static const uint8_t K0[3] = { 0x09, 0x00, 0x00 };
+		static const uint8_t K1[3] = { 0x09, 0x01, 0x00 };
+		bool liz = puckLizardActive();
+		for (int s = 0; s < NSLOT; s++) {
+			if (!g_slot[s].used || !hapticLinkUp(s)) {
+				// re-land id9=1 on the next (re)connect: a fresh controller defaults to
+				// autonomous, but one carrying our previous session's id9=0 does not
+				landedAuto[s] = false;
+				lastKeep[s] = 0;
+				continue;
+			}
+			if (liz) {
+				if (!landedAuto[s]) {
+					landedAuto[s] = true;
+					relayEnqueue(0x87, K1, sizeof K1,
+						     (uint8_t)s);
+				}
+			} else {
+				landedAuto[s] = false;
+				if (lastKeep[s] &&
+				    (uint32_t)(millis() - lastKeep[s]) <
+					    LIZKEEP_MS)
+					continue;
+				lastKeep[s] = millis();
+				relayEnqueue(0x87, K0, sizeof K0, (uint8_t)s);
+			}
+		}
+	}
 	// Per-slot link-edge detect (backup for hapticOnReconnect in rf_link).
 	static bool wasHapticLinkUp[NSLOT] = { 0 };
 	for (int s = 0; s < NSLOT; s++) {
@@ -480,16 +662,17 @@ void hapticTask()
 		if (!up && wasHapticLinkUp[s]) {
 			uint8_t mk = 0;
 			hapLogAdd(0xFD, 0xEE, &mk, 1);
+			// Lifecycle log (CDC debug boot): the 300ms link-up watchdog saw this slot go silent. Pairs
+			// with the CONNECT/RECONNECT lines from rf_link so a session of cycles shows the full
+			// down->up cadence -- how often the link actually drops (churn) vs stays up.
+			if (Serial.availableForWrite() > 50)
+				Serial.printf("# LC t=%lu slot%d link DOWN\n",
+					      (unsigned long)millis(), s);
 		}
 		wasHapticLinkUp[s] = up;
 	}
-	if (g_reinitAt && anySlotLinkUp() &&
-	    (int32_t)(millis() - g_reinitAt) >= 0) {
-		hapticReinit();
-		g_reinitAt = (g_reinitLeft && --g_reinitLeft) ?
-				     (millis() + HAPTIC_REINIT_GAP_MS) :
-				     0;
-	}
+	// (No automatic re-init firing -- see hapticOnReconnect: the connect-time 0x81 storm was the click train
+	// the real puck never makes. hapticReinit is on-demand only, via the WebUSB "Clear stuck buzz" button.)
 	// Power-off on host sleep: only when VBUS is present (genuine sleep, not a cable unplug which also
 	// trips the suspend edge briefly) AND the suspend has PERSISTED >= SUSPEND_OFF_MS. A brief USB
 	// selective-suspend (host idle power-management) resumes in <1s; firing the power-off on its edge
@@ -504,9 +687,13 @@ void hapticTask()
 	if (susp && !wasSusp) {
 		suspSinceMs = millis();
 		suspArmed = true;
+		faultDiagTrace(FR_SUSP, 0);
 	}
-	if (!susp)
+	if (!susp) {
+		if (wasSusp)
+			faultDiagTrace(FR_RESUME, 0);
 		suspArmed = false;
+	}
 	if (suspArmed && vbus && (millis() - suspSinceMs) >= SUSPEND_OFF_MS) {
 		hapticSendShutdown();
 		suspArmed = false; // fire once per suspend
@@ -521,10 +708,6 @@ void hapticTask()
 		if (g_rumble80On[s] && millis() - g_rumble80Ms[s] > 2500u)
 			hapticSteamRumble(0, 0, (uint8_t)s);
 	}
-	// Idle-clear: after haptic activity goes quiet, re-init once to clear any latch left behind.
-	if (g_hapClearArmed &&
-	    (millis() - g_haptic82Ms) > HAPTIC_CLEAR_IDLE_MS) {
-		g_hapClearArmed = false;
-		hapticReinit();
-	}
+	// (No automatic idle-clear re-init either: same 0x81-click reason. A genuinely stuck buzz is cleared
+	// on demand from the panel. Verbatim relay -- like the real puck -- is the steady-state behavior.)
 }

+ 44 - 9
OpenPuck/haptics.h

@@ -20,10 +20,11 @@
 
 // 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.
-#define HAPTIC_RECONNECT_BLOCK_MS 3000u
+// Post-(re)connect haptic block default. While armed, ALL Steam haptic relays to that slot are dropped: 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). Runtime-adjustable (g_hapticBlockMs) and
+// toggleable (g_hapticBlockOn) from the WebUSB panel; this is the boot default.
+#define HAPTIC_BLOCK_MS_DEFAULT 10000u
 // 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
@@ -46,6 +47,29 @@
 bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 		  uint8_t slot = 0xFF);
 
+// id9=0 hold (MODE_STEAM only): land the controller's SET_SETTINGS index 9 (digital-mappings/lizard-active)
+// at 0, once per LIZKEEP_MS per connected slot, like the real puck. This holds the controller's autonomous
+// mapping/haptic engine OFF so it can't latch into the deep-inside buzz seen after repeated reconnects
+// (capture-for-haptics.txt: the buzz is controller-internal; OpenPuck relays no haptics in that state). It
+// also disables the controller's autonomous touchpad ticks (id9 gates the whole pad layer) -- fine in Steam
+// mode (Steam owns haptics), so it is scoped to MODE_STEAM; pure MODE_LIZARD is left alone to keep its ticks.
+#define LIZKEEP_MS 2000u
+extern uint8_t
+	g_lizKeep; // 1 = hold on (default, persisted); console 'u' toggles for A/B
+// Experiment: land ALL relayed 0x87 SET_SETTINGS verbatim (real-puck relay) instead of the discard-whitelist.
+// Default 0 (whitelist). Console "L87" toggles; persisted. See haptics.cpp for the buzz hypothesis it tests.
+extern uint8_t g_landAll87;
+// Land Steam's amp/haptic-config 0x87 (regs 0x18/0x2E/0x34/0x35, not gyro 0x30) so haptics play as clean
+// ticks not a default-amp buzz. On by default; console "AMP" toggles.
+extern uint8_t g_landAmp;
+
+// Post-connect haptic block (persisted, panel-controlled): when g_hapticBlockOn, Steam haptics are dropped for
+// g_hapticBlockMs after a (re)connect so the controller's haptic engine settles before the first real haptic.
+extern uint8_t
+	g_hapticBlockOn; // 1 = block enabled, 0 = relay haptics immediately on connect (default)
+extern uint16_t
+	g_hapticBlockMs; // block duration in ms (default HAPTIC_BLOCK_MS_DEFAULT)
+
 // anything still queued (xinput uses it to pace rumble re-queues)
 bool relayPending();
 extern uint8_t g_relayOp; // relay frame opcode (E3 poll)
@@ -53,11 +77,12 @@ extern uint8_t g_relaySub; // relay sub-TLV type byte = SET
 extern volatile uint8_t g_testHaptic; // 't<n>' injects n test haptics
 // pending haptic-STOP frames to relay (kill a latched whine)
 extern volatile uint8_t g_hapticStop;
-// Per-slot block: arm after a (re)connect, drop haptics aimed at the slot for HAPTIC_RECONNECT_BLOCK_MS.
+// Per-slot block: arm after a (re)connect, drop haptics aimed at the slot for g_hapticBlockMs (when g_hapticBlockOn).
 extern unsigned long g_hapticBlockUntil[NSLOT];
 
-// relay the controller power-off (0x9F "off!"), burst x3 (Steam 0x9F / host-suspend / test button)
-void hapticSendShutdown();
+// relay the controller power-off (0x9F "off!"), burst x3. Steam's per-interface 0x9F passes that slot so
+// only that controller powers off; host-suspend / the panel test button keep the broadcast default (all off).
+void hapticSendShutdown(uint8_t slot = 0xFF);
 
 // ---- 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. ----
@@ -98,8 +123,18 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot = 0);
 // 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.
+// Stability test: when g_stabTest (WebUSB cmd 0x0F), buzz all controllers every 10s to keep them awake for an
+// unattended uptime-until-hang measurement. hapticStabTask() is called from loop().
+extern bool g_stabTest;
+void hapticStabTask();
+
 void rfConnQueueHapticRelay();
-void rfConnFlushRelay(uint8_t ch, uint8_t s1);
+// returns true if a relay frame was actually transmitted this call (queue had an entry), so the poll loop can
+// count relay TXs separately from poll cycles.
+bool rfConnFlushRelay(uint8_t ch, uint8_t s1);
+// times a relay-ring drain hit its iteration cap (head/tail desync or corruption) -- non-zero means we caught
+// and recovered from what would otherwise be an IRQ-off watchdog hang. Surfaced on the WebUSB panel.
+extern volatile uint16_t g_ringFault;
 
 // boot reset: clear relay/active flags, arm the reconnect block
 void hapticInit();
@@ -112,7 +147,7 @@ void hapticTask();
 // harmless on healthy controllers, so it's worth re-initializing every slot the firmware knows about.
 void hapticReinit(uint8_t slot = 0xFF);
 // 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
+// relays for g_hapticBlockMs 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.
 // Per-slot: only the slot that just reconnected is blocked, the others keep relaying.
 void hapticOnReconnect(int slot);

+ 6 - 7
OpenPuck/lizard_map.cpp

@@ -7,15 +7,15 @@ using namespace Adafruit_LittleFS_Namespace;
 
 LizardMap g_lizardMap;
 
-#define LZ_FILE    "/lizard_map.bin"
-#define LZ_MAGIC   0xB1u
+#define LZ_FILE "/lizard_map.bin"
+#define LZ_MAGIC 0xB1u
 #define LZ_VERSION 1u
 
 // KB modifier bits (from TinyUSB hid.h)
-#define KM_LCTRL  0x01u
+#define KM_LCTRL 0x01u
 #define KM_LSHIFT 0x02u
-#define KM_LALT   0x04u
-#define KM_LGUI   0x08u
+#define KM_LALT 0x04u
+#define KM_LGUI 0x08u
 
 // Helper: append a binding to g_lizardMap
 static void addBind(uint8_t type, const uint8_t *od7, uint32_t trig,
@@ -46,8 +46,7 @@ static inline void addMouseBtn(uint8_t btn, uint32_t trig, uint32_t hold)
 	uint8_t d[7] = { btn, 0, 0, 0, 0, 0, 0 };
 	addBind(LZ_OUT_MOUSE_BTN, d, trig, hold);
 }
-static inline void addKey(uint8_t mod, uint8_t k0, uint32_t trig,
-			  uint32_t hold)
+static inline void addKey(uint8_t mod, uint8_t k0, uint32_t trig, uint32_t hold)
 {
 	uint8_t d[7] = { mod, k0, 0, 0, 0, 0, 0 };
 	addBind(LZ_OUT_KBD_CHORD, d, trig, hold);

+ 21 - 20
OpenPuck/lizard_map.h

@@ -12,48 +12,49 @@
 
 // ---- output type (LizardBinding::outType) ----
 // 0 = disabled; skip this entry.
-#define LZ_OUT_NONE       0u
+#define LZ_OUT_NONE 0u
 // Keyboard chord: outData[0]=modifier bits, outData[1..6]=HID keycodes (up to 6, zero=unused).
-#define LZ_OUT_KBD_CHORD  1u
+#define LZ_OUT_KBD_CHORD 1u
 // Mouse button: outData[0]=button bitmask (bit0=left, bit1=right, bit2=middle).
-#define LZ_OUT_MOUSE_BTN  2u
+#define LZ_OUT_MOUSE_BTN 2u
 // Mouse axis: outData[0]=source (LZ_MSRC_*), outData[1]=gyro activation (LZ_GYRO_*).
 #define LZ_OUT_MOUSE_AXIS 3u
 // Scroll wheel from an analog source: outData[0]=source (LZ_MSRC_*).
-#define LZ_OUT_SCROLL     4u
+#define LZ_OUT_SCROLL 4u
 // Consumer control (edge-triggered, MODE_LIZARD only): outData[0]=report bitmask (bit0=vol+, bit1=vol-).
-#define LZ_OUT_CONSUMER   5u
+#define LZ_OUT_CONSUMER 5u
 
 // ---- analog source (outData[0] for MOUSE_AXIS and SCROLL) ----
-#define LZ_MSRC_RPAD      0u // right trackpad (MOUSE_AXIS; touch-gated by TB_RPADT)
-#define LZ_MSRC_LSTICK    1u // left analog stick (MOUSE_AXIS)
-#define LZ_MSRC_GYRO      2u // gyroscope (MOUSE_AXIS; activation-gated, see LZ_GYRO_*)
-#define LZ_MSRC_LPAD      0u // left trackpad (SCROLL; touch-gated by TB_LPADT)
+#define LZ_MSRC_RPAD 0u // right trackpad (MOUSE_AXIS; touch-gated by TB_RPADT)
+#define LZ_MSRC_LSTICK 1u // left analog stick (MOUSE_AXIS)
+#define LZ_MSRC_GYRO \
+	2u // gyroscope (MOUSE_AXIS; activation-gated, see LZ_GYRO_*)
+#define LZ_MSRC_LPAD 0u // left trackpad (SCROLL; touch-gated by TB_LPADT)
 
 // ---- gyro activation condition (outData[1] when src=LZ_MSRC_GYRO) ----
-#define LZ_GYRO_ALWAYS    0u // gyro always drives mouse
-#define LZ_GYRO_RPAD      1u // only when right trackpad is touched
-#define LZ_GYRO_LSTICK    2u // only when left stick is deflected past threshold
+#define LZ_GYRO_ALWAYS 0u // gyro always drives mouse
+#define LZ_GYRO_RPAD 1u // only when right trackpad is touched
+#define LZ_GYRO_LSTICK 2u // only when left stick is deflected past threshold
 // only when all bits in LizardBinding::holdMask are held
-#define LZ_GYRO_BTN       3u
+#define LZ_GYRO_BTN 3u
 
 // ---- virtual stick-deflection trigger bits (usable in trigMask / holdMask) ----
 // The SC2 controller never sets bits 28..31 in its 0x45 report; rfLizard ORs these in after
 // reading the physical button word so bindings can fire on stick deflection.
-#define LZ_BTN_LSTICK_RT  0x10000000u // lx >  12000
-#define LZ_BTN_LSTICK_LF  0x20000000u // lx < -12000
-#define LZ_BTN_LSTICK_DN  0x40000000u // ly < -12000
-#define LZ_BTN_LSTICK_UP  0x80000000u // ly >  12000
+#define LZ_BTN_LSTICK_RT 0x10000000u // lx >  12000
+#define LZ_BTN_LSTICK_LF 0x20000000u // lx < -12000
+#define LZ_BTN_LSTICK_DN 0x40000000u // ly < -12000
+#define LZ_BTN_LSTICK_UP 0x80000000u // ly >  12000
 
 // ---- binding (16 bytes, naturally 4-byte aligned) ----
 // trigMask: (buttons & trigMask) != 0 activates the binding.
 // holdMask: ALL bits must also be set at the same time (AND-guard).
 // MOUSE_AXIS and SCROLL bindings ignore trigMask/holdMask; the analog source drives them.
 struct LizardBinding {
-	uint8_t outType;    // LZ_OUT_*
+	uint8_t outType; // LZ_OUT_*
 	uint8_t outData[7]; // type-specific payload (see constants above)
-	uint32_t trigMask;  // physical/virtual button bits: any-of (OR)
-	uint32_t holdMask;  // physical/virtual button bits: all-of (AND guard)
+	uint32_t trigMask; // physical/virtual button bits: any-of (OR)
+	uint32_t holdMask; // physical/virtual button bits: all-of (AND guard)
 };
 
 #define LZ_MAX_BINDINGS 32u

+ 2 - 1
OpenPuck/mode_hidgyro.cpp

@@ -5,6 +5,7 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "usb_mount.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -236,6 +237,6 @@ void HidGyroController::task()
 		g_gyroLastMs[u] = millis();
 		uint8_t p[63];
 		hidGyroBuild(u, (uint8_t)bond, p);
-		g_hidGyro[u].sendReport(0x01, p, sizeof p);
+		usbTxHid(&g_hidGyro[u], 0x01, p, sizeof p);
 	}
 }

+ 4 - 3
OpenPuck/mode_lizard.cpp

@@ -7,8 +7,8 @@
 // Evaluate the binding table for ONE controller's button word, merging its keyboard/mouse-button/
 // consumer outputs into the shared accumulators. Pulled out of rfLizard so every bonded controller can
 // feed the same outputs. doRpadMouse/doLpadScroll are map-level (set if such a binding exists at all).
-static void lizardEvalSlot(uint32_t buttons, uint8_t &outMod, uint8_t outKeys[6],
-			   uint8_t &nKeys, uint8_t &outMBtn,
+static void lizardEvalSlot(uint32_t buttons, uint8_t &outMod,
+			   uint8_t outKeys[6], uint8_t &nKeys, uint8_t &outMBtn,
 			   uint8_t &consumerBits, bool &doRpadMouse,
 			   bool &doLpadScroll)
 {
@@ -100,7 +100,8 @@ void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
 		if (!g_slot[s].used)
 			continue;
 		lizardEvalSlot(lizardButtons(g_in[s]), outMod, outKeys, nKeys,
-			       outMBtn, consumerBits, doRpadMouse, doLpadScroll);
+			       outMBtn, consumerBits, doRpadMouse,
+			       doLpadScroll);
 	}
 
 	// ---- right pad -> mouse motion with glide ----

+ 5 - 0
OpenPuck/mode_lizard.h

@@ -5,6 +5,11 @@
 // the same puck HID slot. The mapping table g_lizardMap (lizard_map.h) drives all outputs; the
 // default map mirrors the original hardcoded Valve SC1 behavior. Mouse reuses the Xbox-mode
 // velocity+friction+sub-pixel glide model (g_mDiv / g_mFric).
+//
+// EVERY connected controller drives lizard: rfLizard merges g_in[s] across all used bond slots
+// (populated by the per-slot RF decode) onto the ONE shared desktop mouse/keyboard, so any controller
+// on any bond slot contributes and multiple controllers drive the same cursor/keys together. Per-slot
+// glide/edge state keeps one controller's motion from clobbering another's.
 #pragma once
 #include <Adafruit_TinyUSB.h>
 #include <stdint.h>

+ 368 - 0
OpenPuck/mode_ps3.cpp

@@ -0,0 +1,368 @@
+#include "mode_ps3.h"
+#include "triton.h"
+#include "gamepad_util.h"
+#include "config.h"
+#include "haptics.h"
+#include "bonds.h"
+#include "usb_tx.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+Ps3Controller g_ps3Ctl;
+
+// ---------------------------------------------------------------------------------------------------------
+// Genuine Sony Sixaxis / DualShock 3 (054C:0268) HID report descriptor -- 148 bytes, captured verbatim from
+// real hardware (Nefarius "SIXAXIS native HID Report Descriptor"). The PS3 console does NOT parse this to
+// drive input -- it recognises the pad by VID/PID and the control-transfer handshake below -- but a faithful
+// descriptor (declaring input 0x01, output 0x01, and feature reports 0x01/0x02/0xEE/0xEF) keeps both the
+// console and PC HID stacks happy. Do not "tidy" it: this is the real firmware's (famously odd) descriptor.
+// ---------------------------------------------------------------------------------------------------------
+static const uint8_t DS3_HID_DESC[148] = {
+	0x05, 0x01, 0x09, 0x04, 0xA1, 0x01, 0xA1, 0x02, 0x85, 0x01, 0x75, 0x08,
+	0x95, 0x01, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x81, 0x03, 0x75, 0x01, 0x95,
+	0x13, 0x15, 0x00, 0x25, 0x01, 0x35, 0x00, 0x45, 0x01, 0x05, 0x09, 0x19,
+	0x01, 0x29, 0x13, 0x81, 0x02, 0x75, 0x01, 0x95, 0x0D, 0x06, 0x00, 0xFF,
+	0x81, 0x03, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x05, 0x01, 0x09, 0x01, 0xA1,
+	0x00, 0x75, 0x08, 0x95, 0x04, 0x35, 0x00, 0x46, 0xFF, 0x00, 0x09, 0x30,
+	0x09, 0x31, 0x09, 0x32, 0x09, 0x35, 0x81, 0x02, 0xC0, 0x05, 0x01, 0x75,
+	0x08, 0x95, 0x27, 0x09, 0x01, 0x81, 0x02, 0x75, 0x08, 0x95, 0x30, 0x09,
+	0x01, 0x91, 0x02, 0x75, 0x08, 0x95, 0x30, 0x09, 0x01, 0xB1, 0x02, 0xC0,
+	0xA1, 0x02, 0x85, 0x02, 0x75, 0x08, 0x95, 0x30, 0x09, 0x01, 0xB1, 0x02,
+	0xC0, 0xA1, 0x02, 0x85, 0xEE, 0x75, 0x08, 0x95, 0x30, 0x09, 0x01, 0xB1,
+	0x02, 0xC0, 0xA1, 0x02, 0x85, 0xEF, 0x75, 0x08, 0x95, 0x30, 0x09, 0x01,
+	0xB1, 0x02, 0xC0, 0xC0
+};
+
+// ---------------------------------------------------------------------------------------------------------
+// Magic GET_REPORT(Feature) responses. Lifted verbatim from GIMX-firmwares EMUPS3 (matlo) -- a LUFA-based DS3
+// emulator proven to enumerate on a real PS3. GIMX sends each 64-byte array RAW (Endpoint_Write_Control_-
+// Stream_LE), so the genuine wire byte 0 is the array's first byte -- which for a Sixaxis is NOT the report
+// id (e.g. GET 0xF5 returns 01 00.., GET 0xEF returns 00 EF..). See emitFeature() for how we reproduce that
+// despite TinyUSB force-prepending the report id. The PS3 enable handshake is:
+//   GET 0x01 -> GET 0xF2 -> {SET 0xEF / GET 0xEF}x2 -> GET 0xF8 -> SET output 0x01 -> SET 0xF4(enable)
+// then input report 0x01 streams. (GIMX does not even handle 0xF4 and streams unconditionally; so do we.)
+// ---------------------------------------------------------------------------------------------------------
+static const uint8_t report_01[64] = {
+	0x00, 0x01, 0x04, 0x00, 0x07, 0x0c, 0x01, 0x02, 0x18, 0x18, 0x18,
+	0x18, 0x09, 0x0a, 0x10, 0x11, 0x12, 0x13, 0x00, 0x00, 0x00, 0x00,
+	0x04, 0x00, 0x02, 0x02, 0x02, 0x02, 0x00, 0x00, 0x00, 0x04, 0x04,
+	0x04, 0x04, 0x00, 0x00, 0x04, 0x00, 0x01, 0x02, 0x07, 0x00, 0x17,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+};
+// 0xF2 device info; bytes [4..9] = the controller's Bluetooth MAC (overlaid from g_ds3Mac at runtime).
+static const uint8_t report_f2[64] = {
+	0xf2, 0xff, 0xff, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00,
+	0x03, 0x50, 0x81, 0xd8, 0x01, 0x8a, 0x13, 0x00, 0x00, 0x00, 0x00,
+	0x04, 0x00, 0x02, 0x02, 0x02, 0x02, 0x00, 0x00, 0x00, 0x04, 0x04,
+	0x04, 0x04, 0x00, 0x00, 0x04, 0x00, 0x01, 0x02, 0x07, 0x00, 0x17,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+};
+// 0xF5 host-pairing info; bytes [2..7] = master (host) BT MAC, overlaid once learned from a SET 0xF5.
+static const uint8_t report_f5[64] = {
+	0x01, 0x00, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xff, 0xf7, 0x00,
+	0x03, 0x50, 0x81, 0xd8, 0x01, 0x8a, 0x13, 0x00, 0x00, 0x00, 0x00,
+	0x04, 0x00, 0x02, 0x02, 0x02, 0x02, 0x00, 0x00, 0x00, 0x04, 0x04,
+	0x04, 0x04, 0x00, 0x00, 0x04, 0x00, 0x01, 0x02, 0x07, 0x00, 0x17,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+};
+// 0xEF / 0xF8 calibration; byte [7] echoes whatever the PS3 last wrote in SET 0xEF byte[6] (the "do you
+// remember" check the console performs during the 0xEF dance).
+static const uint8_t report_ef[64] = {
+	0x00, 0xef, 0x04, 0x00, 0x07, 0x03, 0x01, 0xb0, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x6b, 0x02, 0x68, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+};
+static const uint8_t report_f8[64] = {
+	0x00, 0x01, 0x00, 0x00, 0x07, 0x03, 0x01, 0xb0, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x6b, 0x02, 0x68, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+};
+static const uint8_t report_f7[64] = {
+	0x01, 0x04, 0xc4, 0x02, 0xd6, 0x01, 0xee, 0xff, 0x14, 0x13, 0x01,
+	0x02, 0xc4, 0x01, 0xd6, 0x00, 0x00, 0x02, 0x02, 0x02, 0x00, 0x03,
+	0x00, 0x00, 0x02, 0x00, 0x00, 0x02, 0x62, 0x01, 0x02, 0x01, 0x5e,
+	0x00, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+};
+
+// One HID slot -- a PS3 expects exactly one Sixaxis per USB port.
+static Adafruit_USBD_HID g_ds3;
+static unsigned long g_ds3LastMs = 0;
+// A slot counts as the live controller while it has replied within this window (matches usb_mount's CONN_UP_MS).
+#define DS3_CONN_MS 1200u
+// First bonded slot with a recent RF reply (the controller we present), or -1 if none is connected. Used for
+// both input streaming and rumble routing (this mode is static, so the dynamic g_usbToBond map isn't built).
+static int ds3ActiveSlot()
+{
+	unsigned long now = millis();
+	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used && g_connReplyMs[s] &&
+		    (now - g_connReplyMs[s]) < DS3_CONN_MS)
+			return s;
+	return -1;
+}
+static uint8_t g_byte6ef =
+	0xb0; // last SET-0xEF byte[6]; echoed back in GET 0xEF/0xF8 byte[7]
+static uint8_t g_masterBd[6] = {
+	0, 0, 0, 0, 0, 0
+}; // host MAC learned via SET 0xF5
+static bool g_haveMaster = false;
+
+// Controller BT MAC. OUI 00:1B:DC matches the other PS modes here; last byte 0x80 keeps it distinct. Used
+// only for BT pairing identity -- irrelevant over USB, but the PS3 reads it via GET 0xF2.
+static uint8_t g_ds3Mac[6] = { 0x00, 0x1B, 0xDC, 0x4F, 0x55, 0x80 };
+
+// Reproduce a genuine 64-byte Sixaxis feature report on the wire. TinyUSB's HID GET_REPORT path force-writes
+// the requested report id as wire byte 0 and hands us the buffer PAST it (`buf` = ctrl[1]); a real Sixaxis
+// (and GIMX) instead puts the array's own first byte there. So we overwrite that prepended id via buf[-1]
+// (== ctrl[0], a valid writable byte for this control transfer) and copy bytes [1..] into buf. Returns the
+// payload length; the stack adds the 1 byte we placed -> total = min(64, wLength), exactly like GIMX's
+// Endpoint_Write_Control_Stream_LE(arr, wLength). reqlen is post-prepend (host wLength - 1); the PS3 always
+// reads these with wLength >= 8, so the prepend always happened and buf[-1] is in bounds.
+static uint16_t emitFeature(uint8_t *buf, uint16_t reqlen,
+			    const uint8_t arr[64])
+{
+	// buf[-1] is only the prepended id (and writable) when the stack actually prepended -- which it does iff
+	// the host's wLength was > 1. reqlen here is already post-decrement, so reqlen < 2 means either no prepend
+	// (wLength <= 1) or a 2-byte request; bail in both cases so we never write out of bounds. The PS3 reads
+	// these reports with wLength >= 8, so real requests are unaffected.
+	if (reqlen < 2)
+		return 0;
+	buf[-1] = arr[0];
+	uint16_t n = 63; // 64-byte report minus byte 0 (placed above)
+	if (n > reqlen)
+		n = reqlen;
+	memcpy(buf, arr + 1, n);
+	return n;
+}
+
+static uint16_t ds3GetReport(uint8_t rid, hid_report_type_t type, uint8_t *buf,
+			     uint16_t reqlen)
+{
+	if (type != HID_REPORT_TYPE_FEATURE || !buf || reqlen == 0)
+		return 0;
+	uint8_t r[64];
+	switch (rid) {
+	case 0x01:
+		return emitFeature(buf, reqlen, report_01);
+	case 0xf2: // overlay our controller MAC at [4..9]
+		memcpy(r, report_f2, 64);
+		memcpy(r + 4, g_ds3Mac, 6);
+		return emitFeature(buf, reqlen, r);
+	case 0xf5: // overlay learned host MAC at [2..7] (else the dummy aa.. from the template)
+		memcpy(r, report_f5, 64);
+		if (g_haveMaster)
+			memcpy(r + 2, g_masterBd, 6);
+		return emitFeature(buf, reqlen, r);
+	case 0xef: // echo the PS3's remembered byte at [7]
+		memcpy(r, report_ef, 64);
+		r[7] = g_byte6ef;
+		return emitFeature(buf, reqlen, r);
+	case 0xf8:
+		memcpy(r, report_f8, 64);
+		r[7] = g_byte6ef;
+		return emitFeature(buf, reqlen, r);
+	case 0xf7:
+		return emitFeature(buf, reqlen, report_f7);
+	default:
+		return 0;
+	}
+}
+
+static void ds3SetReport(uint8_t rid, hid_report_type_t type, uint8_t const *b,
+			 uint16_t n)
+{
+	if (type == HID_REPORT_TYPE_FEATURE) {
+		// 0xEF: remember byte[6] so the next GET 0xEF/0xF8 echoes it. 0xF5: learn the host's BT MAC.
+		// 0xF4: the PS3's "set operational"/power command -- ACK silently (no gating needed; we stream
+		// unconditionally like GIMX). b[1]==0x08 would be a power-off request; ignored here.
+		if (rid == 0xef && n >= 7)
+			g_byte6ef = b[6];
+		else if (rid == 0xf5 && n >= 8) {
+			memcpy(g_masterBd, b + 2, 6);
+			g_haveMaster = true;
+		}
+		return;
+	}
+	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
+		return;
+	// DS3 output report 0x01 (rumble + LED). It reaches us two ways: on the OUT interrupt endpoint (rid=0,
+	// report id 0x01 included as b[0]) or via control SET_REPORT(Output,0x01) (rid=0x01, id not in the data).
+	// Normalise to `p` = the bytes AFTER the report id, where the full report is
+	//   [0]=id [1]=0x00 [2]=small(right/HFR) dur [3]=small power(on/off) [4]=large(left/LFR) dur [5]=large power.
+	// So in p: p[2]=small power, p[4]=large power. Map large->lowFreq, small->highFreq;
+	// 257 = 65535/255 scales 8-bit power to the 16-bit haptic range.
+	const uint8_t *p;
+	uint16_t pn;
+	if (rid == 0) { // OUT endpoint: report id leads the transfer
+		if (b[0] != 0x01)
+			return;
+		p = b + 1;
+		pn = (uint16_t)(n - 1);
+	} else if (rid ==
+		   0x01) { // control SET_REPORT: id may or may not be echoed in the data
+		if (b[0] == 0x01) {
+			p = b + 1;
+			pn = (uint16_t)(n - 1);
+		} else {
+			p = b;
+			pn = n;
+		}
+	} else {
+		return;
+	}
+	if (pn < 5)
+		return;
+	int slot = ds3ActiveSlot();
+	if (slot < 0)
+		return;
+	hapticSteamRumble((uint16_t)p[4] * 257u, p[2] ? 0xFFFFu : 0u,
+			  (uint8_t)slot);
+}
+
+// SC2 IMU int16 (center 0) -> DS3 10-bit unsigned (center 511), little-endian on the wire (low byte first).
+// >>6 maps the full int16 swing onto roughly +/-512 around center; the PS3 applies its own calibration so
+// exact scale is not critical. Writes 2 bytes at out[0..1].
+static void ds3Imu(uint8_t *out, int16_t v)
+{
+	int32_t e = 511 + ((int32_t)v >> 6);
+	if (e < 0)
+		e = 0;
+	if (e > 1023)
+		e = 1023;
+	out[0] = (uint8_t)(e & 0xFF);
+	out[1] = (uint8_t)((e >> 8) & 0xFF);
+}
+
+// Build the 48-byte input-report PAYLOAD (the stack prepends report id 0x01 -> 49-byte Sixaxis report).
+// Offsets are the genuine report's rd[] minus one (rd[0] is the prepended id).
+static void ds3Build(uint8_t slot, uint8_t out[48])
+{
+	uint32_t b = psButtonsFromSteam(g_in[slot].buttons);
+	bool l2 = (g_in[slot].lt > SW_TRIG_ON) || (b & TB_L2);
+	bool r2 = (g_in[slot].rt > SW_TRIG_ON) || (b & TB_R2);
+	memset(out, 0, 48);
+
+	// out[0] reserved (rd[1]); if it were 0xFF the kernel/console would drop the report -- keep 0.
+
+	// out[1] (rd[2]): Select(bit0) L3 R3 Start(bit3) Up Right Down Left.
+	// SC2 View -> Start, Menu -> Select: matches the physical button positions on the puck/controller (the
+	// opposite of the PS5/DS4 Create/Options convention, which read reversed on the DS3 in testing).
+	out[1] = ((b & TB_MENU) ? 0x01 : 0) | ((b & TB_L3) ? 0x02 : 0) |
+		 ((b & TB_R3) ? 0x04 : 0) | ((b & TB_VIEW) ? 0x08 : 0) |
+		 ((b & TB_DUP) ? 0x10 : 0) | ((b & TB_DRT) ? 0x20 : 0) |
+		 ((b & TB_DDN) ? 0x40 : 0) | ((b & TB_DLF) ? 0x80 : 0);
+
+	// out[2] (rd[3]): L2 R2 L1 R1 Triangle Circle Cross Square. g_abSwap swaps the A/B + X/Y face pair.
+	uint8_t tri, cir, crs, sqr;
+	if (g_abSwap) {
+		tri = (b & TB_A) ? 0x10 : 0;
+		cir = (b & TB_B) ? 0x20 : 0;
+		crs = (b & TB_X) ? 0x40 : 0;
+		sqr = (b & TB_Y) ? 0x80 : 0;
+	} else {
+		tri = (b & TB_Y) ? 0x10 : 0;
+		cir = (b & TB_B) ? 0x20 : 0;
+		crs = (b & TB_A) ? 0x40 : 0;
+		sqr = (b & TB_X) ? 0x80 : 0;
+	}
+	out[2] = (l2 ? 0x01 : 0) | (r2 ? 0x02 : 0) | ((b & TB_LB) ? 0x04 : 0) |
+		 ((b & TB_RB) ? 0x08 : 0) | tri | cir | crs | sqr;
+
+	// out[3] (rd[4]): PS button
+	out[3] = (b & TB_STEAM) ? 0x01 : 0;
+
+	// out[5..8] (rd[6..9]): LX LY RX RY, center 0x80
+	out[5] = swStick(g_in[slot].lx, false);
+	out[6] = swStick(g_in[slot].ly, true);
+	out[7] = swStick(g_in[slot].rx, false);
+	out[8] = swStick(g_in[slot].ry, true);
+
+	// out[13..24] (rd[14..25]): analog pressures, order Up Right Down Left L2 R2 L1 R1 Tri Cir Cross Square
+	out[13] = (b & TB_DUP) ? 0xFF : 0;
+	out[14] = (b & TB_DRT) ? 0xFF : 0;
+	out[15] = (b & TB_DDN) ? 0xFF : 0;
+	out[16] = (b & TB_DLF) ? 0xFF : 0;
+	out[17] = g_in[slot].lt;
+	out[18] = g_in[slot].rt;
+	out[19] = (b & TB_LB) ? 0xFF : 0;
+	out[20] = (b & TB_RB) ? 0xFF : 0;
+	out[21] = tri ? 0xFF : 0;
+	out[22] = cir ? 0xFF : 0;
+	out[23] = crs ? 0xFF : 0;
+	out[24] = sqr ? 0xFF : 0;
+
+	// out[28..29] (rd[29..30]): connection/charge status. Battery "full"; cosmetic on the PS3 over USB
+	// (the console knows it's wired from enumeration, not this byte).
+	out[28] = 0x00;
+	out[29] = 0x05; // battery level: full
+
+	// out[40..47] (rd[41..48]): accel X, accel Z, accel Y, gyro Z -- each 10-bit LE, center 511.
+	ds3Imu(out + 40, g_in[slot].ax);
+	ds3Imu(out + 42, g_in[slot].az);
+	ds3Imu(out + 44, g_in[slot].ay);
+	ds3Imu(out + 46, g_in[slot].gz);
+}
+
+// Neutral input report: centered sticks + IMU, no buttons. Streamed when no controller is linked so the PS3
+// keeps the Sixaxis "alive" (and a games/XMB controller slot) instead of seeing a silent endpoint.
+static void ds3Neutral(uint8_t out[48])
+{
+	memset(out, 0, 48);
+	out[5] = out[6] = out[7] = out[8] = 0x80; // sticks centered
+	out[29] = 0x05; // battery full
+	ds3Imu(out + 40, 0);
+	ds3Imu(out + 42, 0);
+	ds3Imu(out + 44, 0);
+	ds3Imu(out + 46, 0);
+}
+
+void Ps3Controller::usbIdentity()
+{
+	// Genuine Sixaxis / DualShock 3 identity. The PS3 recognises the pad by exactly this VID/PID.
+	USBDevice.setID(0x054C, 0x0268);
+	USBDevice.setDeviceVersion(0x0100);
+	USBDevice.setManufacturerDescriptor("Sony");
+	USBDevice.setProductDescriptor("PLAYSTATION(R)3 Controller");
+}
+
+// Static mount: present the single DS3 interface permanently (the setup() static path calls begin() then
+// attaches; it does NOT call usbIdentity() or build a slot pool, so we set the identity and register the HID
+// here). g_ds3.begin() both adds the interface to the descriptor and locks its TinyUSB instance.
+void Ps3Controller::begin()
+{
+	usbIdentity();
+	g_ds3.enableOutEndpoint(
+		true); // OUT endpoint for rumble/LED output reports
+	g_ds3.setReportCallback(ds3GetReport, ds3SetReport);
+	g_ds3.setReportDescriptor(DS3_HID_DESC, sizeof DS3_HID_DESC);
+	g_ds3.setPollInterval(1); // 1 ms interrupt IN, like the genuine pad
+	g_ds3.begin();
+}
+
+void Ps3Controller::task()
+{
+	if (!g_ds3.ready())
+		return;
+	if (millis() - g_ds3LastMs < USB_STREAM_MS)
+		return;
+	g_ds3LastMs = millis();
+	uint8_t p[48];
+	int slot = ds3ActiveSlot();
+	if (slot >= 0)
+		ds3Build((uint8_t)slot, p);
+	else
+		ds3Neutral(p);
+	usbTxHid(&g_ds3, 0x01, p, sizeof p);
+}

+ 25 - 0
OpenPuck/mode_ps3.h

@@ -0,0 +1,25 @@
+// mode_ps3.h -- Sony DualShock 3 / Sixaxis personality (MODE_PS3): 054C:0268.
+//
+// Unlike the PC-facing PS modes, this one is built to enumerate on a REAL PlayStation 3 console. The PS3 does
+// NOT parse the HID report descriptor to drive input the way a PC does -- it recognises the Sixaxis by VID/PID
+// and a fixed control-transfer "enable" handshake. So this mode replicates the genuine Sixaxis byte-for-byte:
+// the exact 148-byte HID report descriptor, the GET_REPORT(Feature) magic reports the console reads during
+// enable (0x01 calibration, 0xEF/0xF2 device+MAC info, 0xF5/0xF7/0xF8 status), and the SET_REPORT(0xF4)
+// operational command. It then streams the 49-byte Sixaxis input report (id 0x01): buttons, sticks, analog
+// pressures, accelerometer X/Y/Z + gyro Z, and a USB-plugged/charged status block. Rumble + LED arrive on the
+// 0x01 output report. Clean single-HID device (no wake mouse / WebUSB) -- the console wants a bare Sixaxis.
+#pragma once
+#include "controllers.h"
+
+class Ps3Controller : public IController {
+    public:
+	// STATIC mount (NOT dynamicMount): a real PS3 rejects the empty config a dynamic mode shows at plug-in
+	// before a controller has linked, and dislikes the later detach/re-attach. A genuine Sixaxis is always
+	// exactly one interface, so we present the single DS3 HID permanently from begin() and just stream neutral
+	// reports until a controller connects. begin() also sets the USB identity (the static setup path, unlike
+	// usbReenumerate, never calls usbIdentity()).
+	void begin() override;
+	void task() override;
+	void usbIdentity() override;
+};
+extern Ps3Controller g_ps3Ctl;

+ 2 - 1
OpenPuck/mode_ps5.cpp

@@ -5,6 +5,7 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "usb_mount.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -247,6 +248,6 @@ void Ps5Controller::task()
 		g_ps5LastMs[u] = millis();
 		uint8_t p[63];
 		ps5Build(u, (uint8_t)bond, p);
-		g_ps5[u].sendReport(0x01, p, sizeof p);
+		usbTxHid(&g_ps5[u], 0x01, p, sizeof p);
 	}
 }

+ 3 - 2
OpenPuck/mode_switch_hori.cpp

@@ -4,6 +4,7 @@
 #include "config.h"
 #include "bonds.h"
 #include "usb_mount.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 
@@ -223,7 +224,7 @@ void SwitchHoriController::task()
 		g_swLastMs[u] = millis();
 		uint8_t p[8];
 		switchBuildHoripad((uint8_t)bond, p);
-		g_switch[u].sendReport(0, p,
-				       sizeof p); // report-id-less descriptor
+		usbTxHid(&g_switch[u], 0, p,
+			 sizeof p); // report-id-less descriptor
 	}
 }

+ 4 - 3
OpenPuck/mode_switch_pro.cpp

@@ -5,6 +5,7 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "usb_mount.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
@@ -760,8 +761,8 @@ void SwitchProController::task()
 		// bursty host-init doesn't starve the streamed 0x30
 		if (g_jcQh[s] != g_jcQt[s]) {
 			JcRep *r = &g_jcQ[s][g_jcQh[s]];
-			if (g_swPro[s].sendReport(r->rid, r->data, JC_REPLEN))
-				g_jcQh[s] = (uint8_t)((g_jcQh[s] + 1) % JCQ_N);
+			usbTxHid(&g_swPro[s], r->rid, r->data, JC_REPLEN);
+			g_jcQh[s] = (uint8_t)((g_jcQh[s] + 1) % JCQ_N);
 			// one report per slot per call; the rest next loop
 			continue;
 		}
@@ -781,6 +782,6 @@ void SwitchProController::task()
 		g_swProLastMs[s] = millis();
 		uint8_t p[63];
 		switchProBuild((uint8_t)s, p);
-		g_swPro[s].sendReport(0x30, p, sizeof p);
+		usbTxHid(&g_swPro[s], 0x30, p, sizeof p);
 	}
 }

+ 39 - 4
OpenPuck/mode_xinput.cpp

@@ -16,6 +16,7 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "usb_mount.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -62,6 +63,9 @@ struct XiSlot {
 	volatile unsigned long
 		rumbleMs; // millis of last OUT packet (stuck-rumble watchdog)
 	volatile bool inUse;
+	// inBuf holds a built-but-not-yet-sent 20B XInput report; the actual usbd_edpt_xfer is issued from the
+	// usbd task (usbTxDrainHook), never from loop(), so the cross-task blocking-defer can't stall the loop.
+	volatile bool txPending;
 };
 static XiSlot g_xiSlot[NSLOT];
 // Dynamic mount: xi_open is called once per XInput interface in descriptor order; this counts them so the
@@ -232,6 +236,8 @@ static void xinputSend(uint8_t slot, uint16_t buttons, uint8_t lt, uint8_t rt,
 	if (slot >= NSLOT || !g_xiSlot[slot].inUse)
 		return;
 	XiSlot &S = g_xiSlot[slot];
+	// Only refill inBuf when the IN endpoint is idle: while a transfer is in flight (busy) the DMA is still
+	// reading inBuf, so leave it untouched (this also coalesces -- newest state wins, like a real pad).
 	if (!tud_mounted() || S.epIn == 0 || usbd_edpt_busy(0, S.epIn))
 		return;
 	uint8_t *r = S.inBuf;
@@ -250,9 +256,30 @@ static void xinputSend(uint8_t slot, uint16_t buttons, uint8_t lt, uint8_t rt,
 	r[12] = ry & 0xFF;
 	r[13] = ry >> 8;
 	memset(r + 14, 0, 6);
-	if (usbd_edpt_claim(0, S.epIn)) {
-		if (!usbd_edpt_xfer(0, S.epIn, r, 20))
-			usbd_edpt_release(0, S.epIn);
+	// Hand off to the usbd task: the xfer is issued from xiSofDrain() (SOF), not here, so loop() never
+	// calls into the dcd transfer path (which can block on the device event queue under load).
+	S.txPending = true;
+}
+
+// usbd-task drain (registered with usbTxRegisterDrain, called from tud_sof_cb). Issues the deferred XInput IN
+// transfers. Runs at higher priority than loop(), so the claim->xfer is atomic w.r.t. xinputSend() refilling
+// inBuf -- no torn DMA.
+static void xiSofDrain(void)
+{
+	if (!tud_mounted())
+		return;
+	for (int s = 0; s < NSLOT; s++) {
+		XiSlot &S = g_xiSlot[s];
+		if (!S.inUse || !S.txPending || S.epIn == 0)
+			continue;
+		if (usbd_edpt_busy(0, S.epIn))
+			continue;
+		if (usbd_edpt_claim(0, S.epIn)) {
+			if (usbd_edpt_xfer(0, S.epIn, S.inBuf, 20))
+				S.txPending = false;
+			else
+				usbd_edpt_release(0, S.epIn);
+		}
 	}
 }
 
@@ -430,7 +457,7 @@ static void rfXboxMouse(const uint8_t *r)
 		m.wheel = 0;
 		m.pan = 0;
 		if (g_mouse.ready())
-			g_mouse.sendReport(0, &m, sizeof m);
+			usbTxHid(&g_mouse, 0, &m, sizeof m);
 	}
 }
 
@@ -463,6 +490,8 @@ void XboxController::beginPool()
 	g_mouse.begin();
 	for (int s = 0; s < NSLOT; s++)
 		g_xinput[s].setStringDescriptor("Controller");
+	// Drain the deferred XInput IN transfers from the usbd task every SOF (only registered in this mode).
+	usbTxRegisterDrain(xiSofDrain);
 }
 void XboxController::mountSlots(uint8_t k)
 {
@@ -479,6 +508,12 @@ void XboxController::onReport45(int slot, const uint8_t *rep, bool fresh,
 	(void)bodyTlen;
 	if (slot < 0 || slot >= NSLOT)
 		return;
+	// rfXboxGamepad/rfXboxMouse read report 0x45 field offsets. The new-firmware report 0x42 is VERIFIED
+	// byte-identical over [0..45] (buttons/triggers/sticks/pads/IMU at the same offsets; it only appends 8
+	// trailing bytes and sets always-on status bits 28/29 that no TB_ mask reads -- see the rf_link decode
+	// note), so both ids decode here. Anything else must not be decoded as gamepad input.
+	if (rep[0] != 0x45 && rep[0] != 0x42)
+		return;
 	rfXboxGamepad((uint8_t)slot, rep);
 	// Shared desktop mouse: slot 0's right-pad only. Other slots' right-pad is still part of the
 	// per-slot XInput (TB_RPADT in the gamepad report is unused today but kept for forward-compat).

+ 295 - 29
OpenPuck/puck_hid.cpp

@@ -3,10 +3,12 @@
 #include "config.h"
 #include "identity.h"
 #include "haptics.h"
+#include "fault_diag.h"
 #include "rf_link.h"
 #include "triton.h"
 #include "mode_lizard.h"
 #include "wake_hid.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -91,6 +93,73 @@ static const uint8_t PUCK_LIZARD_HID_DESC[] = {
 
 static Adafruit_USBD_HID hid[NSLOT];
 
+// Drop Steam's relayed 0x81 CLEAR_DIGITAL_MAPPINGS in Steam mode (console "S81" toggles; on by default). It's
+// the confirmed amp-clicker in Steam's per-connect config and OpenPuck doesn't need it (own input translation
+// + id9=0 keepalive). Reversible from the console if it regresses.
+bool g_drop81 = true;
+
+// Per-slot shadow of the controller's SET_SETTINGS_VALUES array (id-indexed u16, ids 0..0x52). Steam writes
+// it with 0x87 and READS IT BACK with 0x89 to verify its config "took"; on the real puck the controller
+// answers 0x89 from this array. OpenPuck can't relay-and-return over the NO-ACK RF link, so it maintains the
+// shadow here and answers 0x89 from it -- WITHOUT this, Steam's verify never matches and it re-clears (0x81)
+// + re-writes (0x87) forever = the connect config storm that buzzes the amp and floods the usbd task.
+static uint16_t g_setShadow[NSLOT][0x53];
+
+// ---- feature-command capture (diagnostic) ---------------------------------------------------------------
+// Log the USB feature command channel (Steam's SET/GET) to serial to see the connect handshake -- WITHOUT a
+// Serial.printf on the fragile 800B usbd task (that path can blow the stack -> LOCKUP; it's why production
+// avoids printf here). handleSet/handleGet (usbd task) push a compact record under PRIMASK; puckCmdLogDrain()
+// prints them from loop() context. When g_cmdCapture is on, the high-rate I45 input stream is also suppressed
+// (rf_link) so the command sequence is readable. Toggle from the console with "FC".
+bool g_cmdCapture = true;
+struct FCmdRec {
+	uint8_t dir; // 0 = SET (host->puck write), 1 = GET (host read)
+	uint8_t iface; // HID interface index = bond slot the command hit
+	uint8_t rid, cmd, len, b[10];
+};
+static FCmdRec g_fc[32];
+static volatile uint8_t g_fcHead, g_fcTail;
+static void fcPush(uint8_t dir, int iface, uint8_t rid, uint8_t cmd,
+		   uint8_t len, const uint8_t *b, uint8_t n)
+{
+	if (!g_cmdCapture)
+		return;
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	uint8_t h = g_fcHead, nx = (uint8_t)((h + 1) & 31);
+	if (nx !=
+	    g_fcTail) { // drop-newest when full (loop drains fast); never block the usbd task
+		g_fc[h].dir = dir;
+		g_fc[h].iface = (uint8_t)iface;
+		g_fc[h].rid = rid;
+		g_fc[h].cmd = cmd;
+		g_fc[h].len = len;
+		for (uint8_t i = 0; i < 10; i++)
+			g_fc[h].b[i] = (i < n) ? b[i] : 0;
+		g_fcHead = nx;
+	}
+	__set_PRIMASK(pm);
+}
+void puckCmdLogDrain(void)
+{
+	// boosted: runs at Steam's feature-storm rate and CDC flush enters the same TinyUSB DMA claim window
+	// as HID sends (the issue-72 livelock; see usb_tx.cpp)
+	usbTxBoost();
+	while (g_fcHead != g_fcTail) {
+		if (Serial.availableForWrite() < 70)
+			break; // don't stall loop on CDC backpressure; resume next iteration
+		FCmdRec r = g_fc[g_fcTail];
+		g_fcTail = (uint8_t)((g_fcTail + 1) & 31);
+		Serial.printf("# FC %s if=%u rid=%u cmd=%02X len=%u:",
+			      r.dir ? "GET" : "SET", r.iface, r.rid, r.cmd,
+			      r.len);
+		for (uint8_t i = 0; i < 10; i++)
+			Serial.printf(" %02X", r.b[i]);
+		Serial.println();
+	}
+	usbTxUnboost();
+}
+
 // ===================== 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
@@ -117,6 +186,11 @@ static inline bool lizardActive()
 	       (g_usbMode == MODE_LIZARD ||
 		(g_autoLizard && !steamDrivingGamepad()));
 }
+// Public accessor for the haptic layer (haptics.cpp gates the lizard-suppression keepalive on this).
+bool puckLizardActive()
+{
+	return lizardActive();
+}
 static inline void hostStampAlive()
 {
 	g_steamAliveMs = millis();
@@ -152,7 +226,12 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		// exact condition under which Steam loops the same haptic command (-> connect/wake buzz loop).
 		bool muted = g_resumeMs &&
 			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
-		if (rid >= 0x80 && rid <= 0x86 && n >= 1 &&
+		// Drop the OUTPUT-report form of 0x81 too (Steam's haptic-reset action, a 7-byte report id 0x81 with
+		// payloads 00../01..). Same amp-clicker as the feature-0x01 0x81; g_drop81 only covered the feature
+		// path, so this OUTPUT form was still leaking through and clicking. OpenPuck doesn't need it.
+		bool dropOut81 =
+			(g_drop81 && g_usbMode == MODE_STEAM && rid == 0x81);
+		if (rid >= 0x80 && rid <= 0x86 && !dropOut81 && n >= 1 &&
 		    hapticRelaySlotOk(slot) && !lizardActive() && !muted) {
 			if (!haptic82Blocked(slot)) {
 				relayEnqueue(rid, b,
@@ -168,6 +247,12 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 			}
 		}
 
+#if OPK_LOG
+		// Per-report Serial echo: gated to the diagnostic build only. handleSet runs on the TinyUSB device
+		// task, whose FreeRTOS stack is just 800 bytes; a Serial.printf there costs 200-500B and, stacked
+		// under TinyUSB's control-transfer frames + a preempting USB ISR, can blow the stack -> the exact
+		// SP-corruption-during-exception-entry that shows up as RR_LOCKUP. Production keeps this path
+		// printf-free; the same data is already in the OPK_LOG capture ring (hapLogAdd) for the WebUSB panel.
 		if (Serial.availableForWrite() > 80) {
 			Serial.printf("# OUT if%d rid=%02X n=%u:", slot, rid,
 				      n);
@@ -175,6 +260,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 				Serial.printf(" %02X", b[i]);
 			Serial.println();
 		}
+#endif
 		return;
 	}
 	if (type != HID_REPORT_TYPE_FEATURE || n < 1)
@@ -187,15 +273,21 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 	// Capture EVERY feature SET (the whole cmd channel: 0x83 attr, 0xAE strings, 0xB4 conn, 0xA2/A3 bond, the
 	// feature-1 relay, AND any host battery query) to the WebUSB ring so the panel's capture view shows it.
 	hapLogAdd((uint8_t)slot, cmd, b, n);
+	// ...and to the serial feature-command capture (diagnostic; drained in loop, see puckCmdLogDrain).
+	fcPush(0, slot, rid, cmd, len, pl, pln > 10 ? 10 : (uint8_t)pln);
+	// ...and to the flight recorder (survives a watchdog reset). This runs ON the fragile usbd task, so the
+	// command flood that (theory) overflows it lands in the post-mortem trail rather than being lost with USB.
+	faultDiagTrace(FR_SET, (uint16_t)((rid << 8) | cmd));
 
 	// settings/haptic/LED report (incl. 0x87 lizard-off heartbeat, SDL Triton lizard-disable)
 	if (cmd >= 0x80 && cmd <= 0x89)
 		hostStampAlive();
 	// Controller power-off: Steam's "turn off controller" is feature-0x01 frame 9F 04 6F 66 66 21 ("off!"),
-	// confirmed from a real puck capture. The feature-0x01 relay below forwards it once; hapticSendShutdown()
-	// bursts it for NO-ACK reliability (the single hook the test button + host-suspend also drive).
+	// confirmed from a real puck capture. The feature-0x01 relay below forwards it once; hapticSendShutdown
+	// bursts it for NO-ACK reliability. Slot-targeted: the command arrived on THIS controller's interface,
+	// so only this controller powers off (broadcasting killed all connected controllers at once).
 	if (rid == 1 && cmd == 0x9F)
-		hapticSendShutdown();
+		hapticSendShutdown((uint8_t)slot);
 
 	// report 0x01 = raw passthrough -> queue for RF relay to the controller
 	if (rid == 1 && n >= 2) {
@@ -206,18 +298,38 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		bool muted = g_resumeMs &&
 			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
 
+		// Steam-mode: DROP the relayed 0x81 CLEAR_DIGITAL_MAPPINGS (g_drop81, console "S81"). It EXECUTES on
+		// the controller (rid<0x87, legacy framing) and each one re-arms the haptic amp (non-idempotent, ibex
+		// func_0x0001bbf0) = the connect click/buzz; Steam sends ~13 in its per-connect config. OpenPuck does
+		// its OWN input translation and holds mappings cleared via the id9=0 keepalive, so it does NOT need the
+		// controller's mapping engine -> Steam's 0x81 is pure downside here. The manual "Clear stuck buzz"
+		// (hapticReinit) sends its own 0x81 via relayEnqueue directly, so that cure path is unaffected.
+		bool drop =
+			(g_drop81 && g_usbMode == MODE_STEAM && cmd == 0x81);
+		// Do NOT relay commands OpenPuck answers LOCALLY (identity/bond/settings READS). Relaying them to the
+		// controller is pointless (their reply can't come back over the NO-ACK RF link -- we answer from the
+		// handleSet/handleGet switch), and 0x83 GET_ATTRIBUTES in particular is < 0x87 so it EXECUTES on the
+		// controller -- captured as the remaining periodic CLICK, fired every time Steam re-polls identity
+		// (0x83/0xAE on all 4 interfaces). Only actuator/config commands (0x80-0x82/0x84-0x88 haptics+config,
+		// 0x9F power) should reach the controller.
+		bool localAnswer = (cmd == 0x83 || cmd == 0x89 || cmd == 0xAE ||
+				    cmd == 0xA2 || cmd == 0xA3 || cmd == 0xAD ||
+				    cmd == 0xB4 || cmd == 0xED || cmd == 0xA4);
 		// never push haptics while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop)
-		bool relayOk = hapticRelaySlotOk(slot) &&
+		bool relayOk = hapticRelaySlotOk(slot) && !drop &&
+			       !localAnswer &&
 			       !(haptic82 && (lizardActive() || muted));
 		if (relayOk && (!haptic82 || !haptic82Blocked(slot))) {
 			// 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 exceed 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 OPK_LOG
 			if (len > RELAY_MAXP && Serial.availableForWrite() > 60)
 				Serial.printf(
 					"# RELAY TRUNC cmd=%02X len=%u>%u\n",
 					cmd, len, (unsigned)RELAY_MAXP);
+#endif
 			relayEnqueue(cmd, pl, rl, (uint8_t)slot);
 
 			// track from RELAYED frames only (see the OUTPUT path)
@@ -225,6 +337,9 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 				haptic82HostReport(pl, len);
 		}
 	}
+#if OPK_LOG
+	// Diagnostic-build only -- see the OUTPUT-path note: no Serial.printf on the 800B usbd-task stack in
+	// production (LOCKUP mitigation). The feature SET is already captured to the OPK_LOG ring above.
 	if (Serial.availableForWrite() > 80) {
 		Serial.printf("# SET if%d rid=%02X cmd=%02X len=%u:", slot, rid,
 			      cmd, len);
@@ -232,6 +347,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 			Serial.printf(" %02X", b[i]);
 		Serial.println();
 	}
+#endif
 	memset(S.resp, 0, sizeof S.resp);
 	S.resp_len = 0;
 	switch (cmd) {
@@ -239,18 +355,48 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		S.resp[0] = 0x83;
 		S.resp[1] = sizeof ATTR83;
 		memcpy(S.resp + 2, ATTR83, sizeof ATTR83);
+		// Report-id 1 = an attributes query RELAYED to the bonded CONTROLLER; it must report the CONTROLLER's
+		// product id (0x1302), NOT the puck/dongle's (0x1304) -- else Steam sees "a controller with a dongle's
+		// id" (HANDOFF.md) and drops to the LEGACY 0x81 CLEAR_DIGITAL_MAPPINGS init path, whose rapid re-arm
+		// storm is the connect buzz; with the correct id Steam uses the modern quiet 0x87 id9=0 path. Report-id
+		// 2 stays the puck (0x1304). ATTR83 stores product as u32 LE at offset 1, so only the low byte flips
+		// (0x04 -> 0x02); everything else in the blob is identical.
+		if (rid == 1)
+			S.resp[2 + 1] =
+				0x02; // product 0x1304 -> 0x1302 (controller)
 		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";
+		// Report-id 1 = string attributes of the bonded CONTROLLER, not the puck. Steam matches the connected
+		// controller to its bond by SERIAL: it reads the controller's serial here (rid 1) and compares to the
+		// 16-byte serial in the bond record it reads via 0xA3. OpenPuck was returning the PUCK's serial (g_unit,
+		// "FXB..."), which never matches any bond's controller serial ("FXA...") -> Steam can't associate the
+		// controller with a puck -> "paired to" list is EMPTY -> Steam treats it as unconfigured and re-runs the
+		// 0x81/0x87 config storm every connect (the buzz). So on rid 1, answer with the CONTROLLER's serial from
+		// this interface's bond record (rec[8..24], the same 16-byte serial 0xA3 returns). Captured: Steam
+		// hammered this read (AE x39 on rid1) exactly because the identity never matched. rid 2 = puck (unchanged).
+		// idx 0/1/4 = board/unit/alt serial (real controller returns the SAME serial for 0 and 4; the clone
+		// was returning "NA" for idx 4, which failed Steam's read -> retry).
 		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));
+		if (rid == 1 && slot >= 0 && slot < NSLOT &&
+		    g_slot[slot].used && (idx == 0 || idx == 1 || idx == 4)) {
+			// THIS slot's paired-controller serial, straight from its bond record (rec[8..24], the same
+			// 16-byte serial 0xA3 returns) -- per device, nothing hardcoded. Copied without a stack temp
+			// (this runs on the fragile 800B usbd task; every byte off the stack helps under a Steam
+			// re-enumeration burst).
+			memcpy(S.resp + 3, g_slot[slot].rec + 8, 16);
+		} else {
+			// rid 2 = the puck's own board/unit serials (device-derived). Any other idx -> "NA".
+			const char *s = (rid == 1)	       ? "NA" :
+					(idx == 0 || idx == 4) ? g_board :
+					(idx == 1)	       ? g_unit :
+								 "NA";
+			memcpy(S.resp + 3, s, strlen(s));
+		}
 		S.resp_len = 63;
 		break;
 	}
@@ -270,7 +416,9 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		break;
 	case 0xAD:
 		g_pairing = (pln > 0 && pl[0] != 0);
+#if OPK_LOG
 		Serial.printf("# pairing %s\n", g_pairing ? "ON" : "off");
+#endif
 		S.resp[0] = 0xAD;
 		S.resp[1] = 0;
 		S.resp_len = 63;
@@ -285,8 +433,10 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 				S.used = true;
 			}
 			g_dirty = true;
+#if OPK_LOG
 			Serial.printf("# slot %d %s\n", slot,
 				      recEmpty(pl) ? "cleared" : "bonded");
+#endif
 		}
 		S.resp[0] = 0xA2;
 		S.resp[1] = 0;
@@ -300,6 +450,86 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 			memcpy(S.resp + 2, S.rec, 24);
 		S.resp_len = 63;
 		break;
+	case 0x87:
+		// SET_SETTINGS_VALUES: payload = N x [id][val16 LE]. Shadow each into g_setShadow[slot] so the 0x89
+		// read-back below matches what Steam wrote -- this is THE fix that stops Steam's endless config-verify
+		// retry (the 0x81/0x87 storm that buzzes the amp). (Still relayed to the controller by the feature-1
+		// path above; this just maintains the dongle-side shadow the real controller would return on 0x89.)
+		// ACK with [0x87][0], not the default payload echo (a clean success reply, like the real dongle).
+		if (slot >= 0 && slot < NSLOT)
+			for (uint16_t i = 0; i + 2 < pln; i += 3) {
+				uint8_t id = pl[i];
+				if (id < 0x53)
+					g_setShadow[slot][id] =
+						(uint16_t)(pl[i + 1] |
+							   (pl[i + 2] << 8));
+			}
+		S.resp[0] = 0x87;
+		S.resp[1] = 0;
+		S.resp_len = 63;
+		break;
+	case 0x89: {
+		// GET_SETTINGS_VALUES: Steam reads back setting `id` (payload[0]) from the id-indexed array. Answer
+		// from the shadow 0x87 populated: [0x89][3][id][val16 LE]. (Steam didn't use 0x89 in the captured
+		// session -- it provisions via 0xED below -- but this is the correct real-dongle behavior; harmless.)
+		uint8_t id = (pln > 0) ? pl[0] : 0;
+		uint16_t v = (slot >= 0 && slot < NSLOT && id < 0x53) ?
+				     g_setShadow[slot][id] :
+				     0;
+		S.resp[0] = 0x89;
+		S.resp[1] = 3;
+		S.resp[2] = id;
+		S.resp[3] = (uint8_t)v;
+		S.resp[4] = (uint8_t)(v >> 8);
+		S.resp_len = 63;
+		break;
+	}
+	case 0xED: {
+		// GET-SETTING-BY-PATH (keyed). Steam SETs the path string, then GETs the value; the REAL controller
+		// returns the setting's value, but OpenPuck was ECHOING the path -> Steam sees the controller as
+		// un-provisioned/un-bonded and re-runs its full LEGACY config every connect (0x81 CLEAR_DIGITAL_MAPPINGS
+		// = the amp clicks/buzz; captured: 0xED reads of "esb/bond"/"user/wireless_transport" that got echoed).
+		// Answer the paths Steam checks at connect so it treats the controller as provisioned + bonded:
+		//   esb/bond          -> this slot's 24-byte bond record (the controller's record of ITS puck)
+		//   esb/bond_2        -> a 2nd-puck bond; OpenPuck bonds one puck per slot -> absent (empty)
+		//   user/wireless_transport -> 1 byte = the ACTIVE (connected) slot's transport code (see below)
+		// Anything else -> empty ([0xED][0]) rather than a garbage path echo.
+		const char *p = (const char *)pl;
+		uint16_t pl_n = pln;
+		auto pathIs = [&](const char *k) -> bool {
+			uint16_t kl = (uint16_t)strlen(k);
+			return pl_n >= kl && memcmp(p, k, kl) == 0 &&
+			       (pl_n == kl || p[kl] == 0);
+		};
+		S.resp[0] = 0xED;
+		if (slot >= 0 && slot < NSLOT && g_slot[slot].used &&
+		    pathIs("esb/bond")) {
+			S.resp[1] = 0x18;
+			memcpy(S.resp + 2, g_slot[slot].rec, 24);
+		} else if (pathIs("user/wireless_transport")) {
+			// Steam reads this (a CONTROLLER setting, relayed rid1) to mark which bond slot is ACTIVE in the
+			// pairing list, mapping active_slot = value XOR 2 (HANDOFF: slot0->0x02, slot1->0x03, slot2->0x00,
+			// slot3->0x01). It's a single DONGLE-WIDE value = the currently-connected slot, NOT this
+			// interface's own index (returning the raw index made Steam mark slot^2 active -> the live puck
+			// showed "Inactive"). Report the connected slot's code so Steam marks the live puck active.
+			int act = 0;
+			unsigned long best = 0, now = millis();
+			for (int s2 = 0; s2 < NSLOT; s2++)
+				if (g_slot[s2].used && g_connReplyMs[s2] &&
+				    (now - g_connReplyMs[s2]) < 1200u &&
+				    (best == 0 || g_connReplyMs[s2] > best)) {
+					best = g_connReplyMs[s2];
+					act = s2;
+				}
+			S.resp[1] = 1;
+			S.resp[2] = (uint8_t)(act ^ 2);
+		} else {
+			S.resp[1] =
+				0; // absent/empty -- valid "no value" reply, not a path echo
+		}
+		S.resp_len = 63;
+		break;
+	}
 	default:
 		S.resp[0] = cmd;
 		S.resp[1] = len;
@@ -316,27 +546,46 @@ static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type,
 	if (type != HID_REPORT_TYPE_FEATURE)
 		return 0;
 
-	// host reading feature data => gamepad consumer active (Steam/SDL)
-	if (g_usbMode == MODE_STEAM)
-		hostStampAlive();
+	// Do NOT treat a feature GET as "Steam is driving." A bare read is weak evidence: on Linux the kernel
+	// hid-steam driver (and any hidapi enumerator) issues GET_FEATURE probes even with Steam's window closed,
+	// which pinned steamDrivingGamepad() true forever and blocked the Steam-closed lizard fallback (keyboard/
+	// mouse dead, and the lizard-off writes those readers pair with the probe also killed the autonomous
+	// touchpad haptics). Steam actually taking over is detected by its OUTPUT/SET WRITES (the 0x87 lizard-off
+	// heartbeat ~every 3s, and 0x82 haptics), which stamp g_steamAliveMs on the handleSet paths. So drop the
+	// GET-based stamp entirely; a read alone no longer suppresses lizard.
 	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);
+	// Flight recorder (every GET, un-deduped): a read STORM -- e.g. the "AE x39" identity hammering seen before
+	// the identity fix -- is itself a wedge signal, so we want it filling the post-mortem trail if it happens.
+	faultDiagTrace(FR_GET, (uint16_t)((rid << 8) | S.resp[0]));
 	// Battery diagnostic: in gamepad (Steam) mode battery is read host-side via the feature channel, NOT the
 	// forwarded 0x43 (that path is verbatim-identical to lizard mode, where battery works). Capture what Steam
 	// GETs so a WebUSB-panel (or CDC) capture in Steam mode shows the report id it polls for battery (then we
 	// answer it with g_battery in handleSet). De-duped by report id so the high-rate polling doesn't flood the
 	// ring/console -- a freshly-requested id is logged once, then again only after 1s.
 	{
-		static uint8_t lastRid = 0xFF;
+		// Dedup by (rid, interface, response-cmd) so each interface's GET is captured (not collapsed to one
+		// line across all 4 slots) -- the per-interface bond/identity responses are exactly what we need to
+		// see -- while still not flooding on high-rate same-report polling.
+		static uint8_t lastRid = 0xFF, lastSlot = 0xFF, lastCmd = 0xFF;
 		static unsigned long lastMs = 0;
-		if (rid != lastRid || millis() - lastMs > 1000) {
+		if (rid != lastRid || (uint8_t)slot != lastSlot ||
+		    S.resp[0] != lastCmd || millis() - lastMs > 1000) {
 			lastRid = rid;
+			lastSlot = (uint8_t)slot;
+			lastCmd = S.resp[0];
 			lastMs = millis();
 			// ring marker 0xFC = "host feature GET" (panel renders it as "GET rid=.."); payload = what we returned
 			hapLogAdd(0xFC, rid, S.resp, n);
+			// serial feature-command capture: what Steam READ on this interface + our answer
+			fcPush(1, slot, rid, S.resp[0], (uint8_t)n, S.resp + 2,
+			       10);
+#if OPK_LOG
+			// Diagnostic build only -- no Serial.printf on the 800B usbd-task stack in production (LOCKUP
+			// mitigation). The GET is captured to the OPK_LOG ring above for the WebUSB panel.
 			if (Serial.availableForWrite() > 80)
 				Serial.printf(
 					"# GET if%d rid=%02X reqlen=%u -> %02X %02X %02X (batt=%u%%)\n",
@@ -345,6 +594,7 @@ static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type,
 					(slot >= 0 && slot < NSLOT) ?
 						g_battery[slot] :
 						0);
+#endif
 		}
 	}
 	return n;
@@ -408,7 +658,8 @@ void SteamPuckController::begin()
 // USB-side decision -- changes nothing about the RF poll or the host->controller relay. Per-slot: each
 // controller's 0x45 goes to its OWN hid[slot], so Steam sees four independent inputs. Lizard, by contrast,
 // presents ONE mouse + keyboard (a desktop can only consume one): rfLizard merges every bonded controller's
-// input onto hid[0], so all connected controllers drive the same cursor/keys together.
+// input (g_in[] across all used slots, populated by the per-slot decode) onto hid[0], so all connected
+// controllers drive the same cursor/keys together -- and it works regardless of which bond slot is live.
 void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 				     uint8_t bodyTlen)
 {
@@ -427,30 +678,45 @@ void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 		return;
 	if (!g_slot[slot].used)
 		return;
+	// Report id: 0x45 = legacy main input; 0x42 = new-firmware (SC2 beta ~2026-07) main input (53B). Forward
+	// whichever the controller actually sent, verbatim, under its own id -- both are declared in PUCK_HID_DESC
+	// and this is exactly what the real puck does. rep[0] is the id byte (rep points at it in the F1 TLV).
+	const uint8_t rid = rep[0];
 	if (lizardActive()) {
 		// All bonded controllers share ONE desktop mouse/keyboard (mounted on hid[0] regardless of
 		// which RF slots are in use). rfLizard merges g_in[] across every used slot internally, so we
-		// fire it ONCE per cycle -- gated on the lowest bonded slot's report so it runs at a steady
-		// cadence (not once per slot, which would multiply the output rate).
+		// fire it ONCE per cycle -- gated on the lowest CONNECTED slot's report so it runs at a steady
+		// cadence (not once per slot, which would multiply the output rate). g_in[] is populated by the
+		// per-slot decode for BOTH report 0x45 and the beta 0x42 (byte-identical front section), so
+		// lizard works on new-firmware controllers too without reading rep[] here.
+		// Gate on the lowest RECENTLY-REPLYING slot, not merely the lowest bonded one: a bonded-but-
+		// offline low slot (e.g. a stale/phantom bond from a cloned backup) must not starve lizard when
+		// a higher slot is the live controller (main's #98 fix -- do not regress it). onReport45 only
+		// runs for a slot that just decoded a report, so the reporting slot is itself connected.
+		unsigned long now = millis();
 		int lizSlot = -1;
 		for (int s = 0; s < NSLOT; s++)
-			if (g_slot[s].used) {
+			if (g_slot[s].used && g_connReplyMs[s] &&
+			    (now - g_connReplyMs[s]) < 1200u) {
 				lizSlot = s;
 				break;
 			}
 		if (slot == lizSlot)
 			rfLizard(&hid[0], &hid[0], 0x40, 0x41);
 	} else {
+		// body length after the id byte, clamped to the descriptor's declared size for this report id
+		// (0x42 = 53B vendor input, 0x45 = 45B input).
+		uint8_t maxb = (rid == 0x42) ? 53 : 45;
 		uint8_t blen = bodyTlen - 1;
-		if (blen > 45)
-			blen = 45; // body after the 0x45 id byte
+		if (blen > maxb)
+			blen = maxb;
 		// forward the puck's raw pad coords untouched (Steam does its own interpolation/smoothing). Forward
 		// only FRESH reports: the real puck dedupes, so stale repeats make Steam's velocity/smoothing
 		// stair-step. g_fwdNewOnly toggles for A/B.
 		if ((fresh || !g_fwdNewOnly) && hid[slot].ready())
-			hid[slot].sendReport(
-				0x45, rep + 1,
-				blen); // Steam/SDL Triton: input report 0x45
+			usbTxHid(
+				&hid[slot], rid, rep + 1,
+				blen); // Steam/SDL Triton: input report 0x45 (old) / 0x42 (new fw)
 	}
 }
 
@@ -474,7 +740,7 @@ void SteamPuckController::onAuxReport(int slot, uint8_t rid,
 		// capture the pushed status report (0x43 battery / 0x44) device->host for the WebUSB panel: this is
 		// the channel Steam actually reads battery from; marker 0xFB = "->host push".
 		hapLogAdd(0xFB, rid, data, n);
-		hid[slot].sendReport(rid, data, n);
+		usbTxHid(&hid[slot], rid, data, n);
 	}
 }
 
@@ -552,8 +818,8 @@ static void wakeNudgeTask()
 		m.y = 0;
 		m.wheel = 0;
 		m.pan = 0;
-		hid[s].sendReport(0x40, &m,
-				  sizeof m); // jiggle right, then back
+		usbTxHid(&hid[s], 0x40, &m,
+			 sizeof m); // jiggle right, then back
 		g_nudgeStep[s] = (g_nudgeStep[s] >= 2) ?
 					 0 :
 					 (uint8_t)(g_nudgeStep[s] + 1);
@@ -603,7 +869,7 @@ void SteamPuckController::task()
 				connEdgeMs[s] = millis();
 			uint8_t st = conn ? 0x02 : 0x01;
 			hapLogAdd(0xFB, 0x79, &st, 1); // ->host push (capture)
-			hid[s].sendReport(0x79, &st, 1);
+			usbTxHid(&hid[s], 0x79, &st, 1);
 			usbConn[s] = conn;
 			last79[s] = millis();
 		} else if (conn && millis() - last7B[s] >= 2000) {
@@ -627,7 +893,7 @@ void SteamPuckController::task()
 				s7b[8] = (uint8_t)(0u - (uint8_t)mag);
 			}
 			hapLogAdd(0xFB, 0x7B, s7b, 12); // ->host push (capture)
-			hid[s].sendReport(0x7B, s7b, 12);
+			usbTxHid(&hid[s], 0x7B, s7b, 12);
 			last7B[s] = millis();
 		}
 		// Synthesized 0x43 = ID_TRITON_BATTERY_STATUS for SDL/Steam's gamepad driver. The verbatim forward of
@@ -645,7 +911,7 @@ void SteamPuckController::task()
 			b43[0] = st; // ucChargeState
 			b43[1] = g_battery[s]; // ucBatteryLevel (percent)
 			hapLogAdd(0xFB, 0x43, b43, 14); // ->host push (capture)
-			hid[s].sendReport(0x43, b43, sizeof b43);
+			usbTxHid(&hid[s], 0x43, b43, sizeof b43);
 			last43[s] = millis();
 		}
 	}

+ 16 - 0
OpenPuck/puck_hid.h

@@ -18,6 +18,22 @@
 // repeats makes Steam's trackpad smoothing stair-step. (WebUSB field 14)
 extern uint8_t g_fwdNewOnly;
 
+// True while we are presenting the desktop keyboard/mouse instead of forwarding the gamepad to Steam
+// (MODE_LIZARD always, or MODE_STEAM once Steam's heartbeat has stopped). Exposed so the haptic layer can
+// tell "lizard is active" without duplicating the heartbeat logic -- the lizard-suppression keepalive must
+// NOT run while lizard is active, or it disables the controller's autonomous touchpad haptic ticks.
+bool puckLizardActive();
+
+// Feature-command capture (diagnostic): when g_cmdCapture is on, Steam's USB feature SET/GET commands are
+// logged to serial (# FC lines) from loop context and the high-rate I45 input stream is suppressed, so the
+// connect handshake is readable. puckCmdLogDrain() must be called once per loop(). Console "FC" toggles it.
+extern bool g_cmdCapture;
+void puckCmdLogDrain(void);
+
+// Drop Steam's relayed 0x81 CLEAR_DIGITAL_MAPPINGS in Steam mode (the amp-clicker in Steam's per-connect
+// config; OpenPuck doesn't need it). On by default; console "S81" toggles for A/B.
+extern bool g_drop81;
+
 class SteamPuckController : public IController {
     public:
 	void begin() override;

+ 4 - 1
OpenPuck/rf_diag.cpp

@@ -297,7 +297,10 @@ void rfBeaconOnce()
 	// RAM: [LENGTH][ (S1 if S1INCL) ][payload]
 	rftx[0] = 0;
 	rftx[1] = g_plen;
-	memcpy(rftx + 2, pl, g_plen);
+	// g_plen comes unclamped from the console 'L' command (0..255); the source is pl[48] and the dest is
+	// rftx[100] written at +2, so copy at most 48 bytes -- otherwise we over-read the stack and overrun rftx.
+	uint8_t cpLen = g_plen > sizeof pl ? (uint8_t)sizeof pl : g_plen;
+	memcpy(rftx + 2, pl, cpLen);
 	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
 	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
 			    RADIO_SHORTS_END_DISABLE_Msk;

+ 266 - 40
OpenPuck/rf_link.cpp

@@ -4,8 +4,10 @@
 #include "config.h"
 #include "triton.h"
 #include "haptics.h"
+#include "puck_hid.h" // g_cmdCapture (suppress I45 during feature-command capture)
 #include "controllers.h"
 #include "status_led.h"
+#include "fault_diag.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -40,8 +42,13 @@ bool g_e7announce =
 bool g_e1keepalive = true;
 
 bool g_connVerbose = false;
-// poll RX-window (us); shorter=more polls/s but may miss DELAYED replies. Tunable 'r'.
-uint32_t g_rxWin = 1200;
+// poll RX-window (us): the poll BUSY-WAITS up to this long for the controller's reply, so it is the dominant
+// per-poll loop cost and directly sets the poll rate -- one slot needs (g_rxWin + overhead) < g_pollUs(4000)
+// to sustain 250 Hz. 1200 is the proven 250 Hz value; it was briefly raised to 2000 (issue-72, delayed-reply
+// tolerance) which dropped the rate to ~220. FIXED + not configurable (like g_pollUs): there is no good
+// reason to raise it in the field, and doing so silently halves the poll rate. Any persisted/old value is
+// ignored.
+const uint32_t g_rxWin = 1200;
 unsigned long g_connCooldown = 0;
 
 uint8_t g_connSt = 0; // 0=announce awake, 1=poll loop
@@ -61,6 +68,23 @@ uint16_t g_f1ps = 0;
 uint16_t g_newps = 0;
 // polls/s (GET+relay TXs) last second -- distinguishes loop-starvation from reply-loss
 uint16_t g_pollsps = 0;
+// last second's CRC-fail and no-reply-in-window counts (wedge diagnosis)
+uint16_t g_crcps = 0, g_norxps = 0;
+uint16_t g_rfStallRecover = 0;
+
+// RF-stall self-heal thresholds. A genuine worst-case reply gap during normal play is ~1.5s (the
+// hapticOnReconnect re-init window); past RF_STALL_MS with us still actively polling, the whole link is wedged,
+// not merely blipping. RF_RECOVER_MS rate-limits the recovery so it can't thrash while a stalled link re-syncs.
+#define RF_STALL_MS 2500u
+#define RF_RECOVER_MS 2000u
+// A genuinely WEDGED radio recovers within a power-cycle or two; a controller that is simply OFF / out of
+// range never comes back no matter how many times we power-cycle. So after RF_STALL_GIVEUP consecutive
+// recoveries that restored NO link, treat it as "controller absent" and stop hammering: back the power-cycle
+// off to RF_STALL_BACKOFF_MS (a slow safety-net kick). Discovery beacons keep running throughout, so a
+// returning controller still reconnects normally without the power-cycle -- and the aggressive 2s cadence was
+// both wasteful and able to disrupt a controller mid-reconnect. The counter resets the moment any slot replies.
+#define RF_STALL_GIVEUP 3u
+#define RF_STALL_BACKOFF_MS 30000u
 // measured avg us between GET-poll fires (vs intended g_pollUs)
 uint16_t g_pollPeriodUs = 0;
 static uint32_t g_pollDtSum = 0;
@@ -85,13 +109,29 @@ int g_curSlot = -1;
 // instead of ~400. Each slot's counter increments once per poll-of-that-slot so it cycles 0,1,2,3 cleanly.
 static uint8_t g_pollPid[NSLOT] = {};
 static uint8_t g_relayPid[NSLOT] = {};
-static uint32_t g_stPoll = 0, g_stF1 = 0, g_stF3 = 0;
+// All link statistics are PER SLOT: each controller's polls/replies/errors are counted (and reported --
+// serial stat line, WebUSB blob v13) against that controller only. The old scalar counters merged every
+// slot into one number, so the panel couldn't tell "controller B is drowning" from "everything is slow".
+// The legacy aggregate snapshots (g_pollsps & co) are now sums over slots, kept for the serial line and the
+// blob's pre-v13 fields.
+static uint32_t g_stPoll[NSLOT] = {}, g_stF1[NSLOT] = {};
+static uint32_t g_stF3 = 0;
+// g_stPoll counts true poll CYCLES (one E3 GET per warm slot per cycle). g_stRelay counts relay frames TX'd
+// (host/haptic output reports). They were conflated before (every rfConnTx bumped one counter), which made
+// "Polls/s" read ~540 (250 polls + ~290 relays) and hid that each relay steals a reply window from its poll.
+static uint32_t g_stRelay[NSLOT] = {};
+uint16_t g_relayps = 0;
+// per-slot per-second snapshots (WebUSB blob v13 / per-controller panel stats)
+uint16_t g_slotPollsps[NSLOT] = {}, g_slotF1ps[NSLOT] = {},
+	 g_slotNewps[NSLOT] = {};
+uint8_t g_slotCrcps[NSLOT] = {}, g_slotNoRxps[NSLOT] = {},
+	g_slotRelayps[NSLOT] = {};
 static unsigned long g_stMs = 0;
 // Per-slot dedupe seq + per-slot new-report counter (the real puck sends 0x45 per controller; merging all
 // slots into a single sequence makes one controller "swallow" the other's frame).
 static uint8_t g_lastSeq[NSLOT] = { 0 };
-static uint32_t g_stNew = 0;
-static uint32_t g_stCrc = 0, g_stNoRx = 0;
+static uint32_t g_stNew[NSLOT] = {};
+static uint32_t g_stCrc[NSLOT] = {}, g_stNoRx[NSLOT] = {};
 static uint32_t g_chF1[3] = { 0, 0, 0 };
 // Cycle gate: fires once per g_pollUs; each fire polls every warm slot so all run at ~250 Hz.
 static uint32_t g_lastPollUs = 0;
@@ -174,8 +214,8 @@ static void rfHostFrameOnce(int slot, bool discovery)
 		g_rfRxCount++;
 		bool crcok = NRF_RADIO->CRCSTATUS & 1;
 		uint8_t len = rfrx[0];
-		// non-blocking: don't stall the loop on CDC backpressure
-		if (Serial.availableForWrite() > 90) {
+		// non-blocking: don't stall the loop on CDC backpressure (whole line ~165B; CDC write() has no timeout)
+		if (Serial.availableForWrite() > 180) {
 			Serial.printf(
 				"*** RESP#%lu ch%u crc%d rxmatch%lu len%u: ",
 				(unsigned long)g_rfRxCount, g_rfCh, crcok,
@@ -243,7 +283,6 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 			    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)
@@ -254,7 +293,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 		rxlen = rfrx[0];
 		// reply arrived but CRC failed -> RF quality (channel/interference)
 		if (!crcok) {
-			g_stCrc++;
+			g_stCrc[slot]++;
 			g_qosBad++;
 		}
 		// F1 input ~46B; 0x43-augmented ~66B -> allow up to MAXLEN(96)
@@ -275,8 +314,32 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 				// (e.g. a power-cycled controller that reconnects without us cleanly seeing the link drop).
 				if (g_connReplyMs[s] == 0 ||
 				    (uint32_t)(millis() - g_connReplyMs[s]) >
-					    1500u)
+					    1500u) {
+					// Lifecycle log (CDC debug boot): distinguish a first-ever connect from a reconnect and
+					// print the silent gap -- lets a long session of connect/disconnect cycles be diffed to
+					// see whether each cycle re-establishes (churn / boot-haptic click) vs stays linked.
+					if (Serial.availableForWrite() > 130) {
+						uint32_t gap =
+							g_connReplyMs[s] ?
+								(uint32_t)(millis() -
+									   g_connReplyMs
+										   [s]) :
+								0;
+						Serial.printf(
+							"# LC t=%lu slot%d %s gap=%lums rtype=%02X cd=%lums\n",
+							(unsigned long)millis(),
+							s,
+							g_connReplyMs[s] ?
+								"RECONNECT" :
+								"CONNECT",
+							(unsigned long)gap,
+							rtype,
+							(unsigned long)(millis() -
+									g_connCooldown));
+					}
 					hapticOnReconnect(s);
+					faultDiagTrace(FR_RFUP, s);
+				}
 				g_connRx++;
 				// link alive -> loop() suppresses the redundant E1 beacon
 				g_connReplyMs[s] = millis();
@@ -294,7 +357,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 							rs;
 			}
 			if (rtype == 0xF1)
-				g_stF1++;
+				g_stF1[slot]++;
 			// controller disconnecting/powering off -> back off 2.5s so we don't immediately re-wake it.
 			// BUT only when no OTHER slot is still live: g_connCooldown is global and gates ALL polling +
 			// beacons, so backing off because ONE controller powered off would drop every other connected
@@ -306,8 +369,22 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 					if (i != g_curSlot && g_slot[i].used &&
 					    millis() - g_connReplyMs[i] < 300)
 						others++;
+				faultDiagTrace(FR_RFDN,
+					       (uint16_t)((g_curSlot << 8) |
+							  (others & 0xFF)));
 				if (others == 0)
 					g_connCooldown = millis();
+				// Lifecycle log: the controller sent F2 (disconnect/power-off). Note whether it armed the
+				// 2.5s cooldown (which pauses ALL beacon+poll -> the controller can lose the session and
+				// reboot on the next reconnect = a boot-haptic click). Prime suspect for the connect buzz.
+				if (Serial.availableForWrite() > 130)
+					Serial.printf(
+						"# LC t=%lu slot%d F2 DISCONNECT others=%d%s\n",
+						(unsigned long)millis(),
+						g_curSlot, others,
+						others == 0 ?
+							" -> cooldown 2.5s (beacon+poll paused)" :
+							"");
 			}
 			// F3 = controller status/version reply (reply to E7 handshake, byte[6]=version)
 			if (rtype == 0xF3) {
@@ -325,6 +402,13 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 				}
 			}
 			bool isF1 = (rtype == 0xF1);
+			// Every rfConnTx caller (rfConnStep) sets g_curSlot to a valid 0..NSLOT-1 before a poll, so it
+			// IS in range here today. But this block does ~50 unguarded g_curSlot array writes (g_in[],
+			// g_lastSeq[], and several static per-slot arrays) -- if any future/edge caller ever left
+			// g_curSlot at -1, those become out-of-bounds writes that corrupt RAM (the stack-smash / LOCKUP
+			// class we're chasing). Guard once at the top so the decode is robust by construction.
+			if (isF1 && (g_curSlot < 0 || g_curSlot >= NSLOT))
+				isF1 = false;
 			if (isF1) {
 				g_connF1++;
 				// walk ALL type6 TLVs (= HID report 0x45); taking only [0] halves the rate. idx is INT,
@@ -337,13 +421,20 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						ttype = rfrx[idx + 1];
 					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).
+					// Only a FULL input 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).
+					// Main input report id: 0x45 (legacy, 45B body) OR 0x42 (SC2 beta update ~2026-07, 53B body).
+					// VERIFIED from live captures of both: the 0x42 body [0..45] is byte-for-byte the SAME layout as
+					// 0x45 (buttons/triggers/sticks/pads/IMU at identical offsets) -- it just adds 8 trailing bytes
+					// ([46..47]=0x7FFF const, [48..53]=0) and sets two extra always-on status bits (28/29) that no
+					// mode reads. So both decode through this ONE path unchanged; rep[0] carries the id downstream
+					// (Steam forwards it verbatim under the right id/length in onReport45).
 					if (ttype == 6 && tlen >= 28 &&
 					    (size_t)(idx + 2) + tlen <=
 						    sizeof(rfrx) &&
-					    rfrx[idx + 2] == 0x45) {
-						// report 0x45: [0x45][seq][buttons u32]...
+					    (rfrx[idx + 2] == 0x45 ||
+					     rfrx[idx + 2] == 0x42)) {
+						// report 0x45/0x42: [id][seq][buttons u32]...
 						const uint8_t *rep =
 							&rfrx[idx + 2];
 						bool fresh =
@@ -351,7 +442,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 							 g_lastSeq[g_curSlot]);
 						// genuine new report vs stale poll-repeat
 						if (fresh) {
-							g_stNew++;
+							g_stNew[g_curSlot]++;
 							g_lastSeq[g_curSlot] =
 								rep[1];
 						}
@@ -556,6 +647,33 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 								rep[0], rep + 1,
 								(uint8_t)(tlen -
 									  1));
+					} else if (ttype == 6 &&
+						   (size_t)(idx + 2) + tlen <=
+							   sizeof(rfrx) &&
+						   tlen >= 1) {
+						// DIAGNOSTIC (beta-update RE): a type-6 HID-report TLV whose report id we
+						// DON'T decode (not 0x45 input, not 0x43/0x44 status). If the new controller
+						// firmware moved input off 0x45, its record shows up here. Log rid + full body,
+						// rate-limited + non-blocking so it can't stall the loop. Remove once pinned.
+						static unsigned long lastUnk =
+							0;
+						if (Serial.availableForWrite() >
+							    150 &&
+						    millis() - lastUnk >= 200) {
+							lastUnk = millis();
+							Serial.printf(
+								"UNK rid=%02X tlen=%u: ",
+								rfrx[idx + 2],
+								tlen);
+							for (uint8_t i = 0;
+							     i < tlen; i++)
+								Serial.printf(
+									"%02X",
+									rfrx[idx +
+									     2 +
+									     i]);
+							Serial.println();
+						}
 					}
 					idx += tlen + 2;
 				}
@@ -595,6 +713,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						    !USBDevice.suspended()) {
 							saveMode(want);
 							delay(40);
+							faultDiagArmIntentionalReset();
 							NVIC_SystemReset();
 						}
 					} else {
@@ -606,7 +725,8 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 				// 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 &&
+				    !g_cmdCapture &&
+				    Serial.availableForWrite() > 150 &&
 				    millis() - g_lastStream >= 4) {
 					g_lastStream = millis();
 					Serial.print("I45 ");
@@ -616,7 +736,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 					Serial.println();
 				}
 			}
-			if (g_connVerbose) {
+			if (g_connVerbose && Serial.availableForWrite() > 180) {
 				Serial.printf(
 					"%s CRX#%lu txtype%02X ch%u len%u: ",
 					isF1 ? "<<<F1" :
@@ -634,7 +754,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 			rxlen = 0;
 		// RX window expired with no packet at all
 	} else {
-		g_stNoRx++;
+		g_stNoRx[slot]++;
 		g_qosBad++;
 	}
 	NRF_RADIO->TASKS_DISABLE = 1;
@@ -647,6 +767,14 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 // instead of every cycle. This keeps the online controllers at full 250 Hz while barely touching offline ones.
 #define SLOT_COLD_MS 5000u
 #define SLOT_COLD_RETRY_MS 2000u
+// Quiet tier: a slot that WAS replying but has been silent past SLOT_QUIET_MS (controller powering off, out
+// of range) backs off to SLOT_QUIET_RETRY_MS retries instead of full-rate polling until SLOT_COLD_MS demotes
+// it. Without this, a power-off left the slot at 250 Hz for 5 s with EVERY poll burning the whole g_rxWin
+// (~2 ms) waiting for a reply that never comes -- with two controllers powered off together that is ~100%
+// radio duty, the churn window the power-off watchdog hang (issue #72 repro) lives in. Recovery stays snappy:
+// the first reply re-warms the slot to full rate, so a controller returning from a fade waits <= one retry.
+#define SLOT_QUIET_MS 300u
+#define SLOT_QUIET_RETRY_MS 50u
 static unsigned long g_slotLastAttemptMs[NSLOT] = {};
 
 // Drive the connected-mode sequence. The cycle gate fires once per g_pollUs (250 Hz); each fire
@@ -725,7 +853,8 @@ static void rfConnStep()
 		{
 			uint8_t rs1 =
 				(uint8_t)((((g_relayPid[k]++) & 3) << 1) | 1);
-			rfConnFlushRelay(ch, rs1);
+			if (rfConnFlushRelay(ch, rs1))
+				g_stRelay[k]++;
 		}
 		// per-slot PID cycle so each bonded controller's polls stay distinct
 		uint8_t pidv = g_pollPid[k]++;
@@ -745,18 +874,32 @@ static void rfConnStep()
 		}
 		if (rx)
 			g_chF1[0]++;
+		g_stPoll[k]++; // one true poll cycle for this slot
 		g_connPoll++;
 	};
 
-	// Poll every warm slot this cycle. "Cold" = was connected, now silent > SLOT_COLD_MS;
-	// those are retried only every SLOT_COLD_RETRY_MS. Never-replied slots count as warm so
-	// a controller that connects at any time gets the full poll rate to establish its link.
+	// Poll a slot every cycle (full rate) only when its controller is actually here. Throttle the rest to
+	// every SLOT_COLD_RETRY_MS:
+	//   - "cold": WAS connected but silent > SLOT_COLD_MS (controller powered off / out of range);
+	//   - "phantom": NEVER replied AND another controller is already connected. This is the cloned-puck case
+	//     (issue: bonds copied from a backup include controllers that aren't present). Polling a phantom slot
+	//     every cycle was doubling/tripling Polls/s, flooding noRx, and stealing reply windows from the live
+	//     controller. A never-replied slot with nothing else connected still polls full-rate, so the FIRST
+	//     controller connects instantly; a phantom only backs off once a real link exists to protect.
+	bool linkUp = anySlotLinkUp();
 	for (int k = 0; k < NSLOT; k++) {
 		if (!g_slot[k].used)
 			continue;
-		bool cold = g_connReplyMs[k] != 0 &&
-			    nowMs - g_connReplyMs[k] > SLOT_COLD_MS;
-		if (cold && nowMs - g_slotLastAttemptMs[k] < SLOT_COLD_RETRY_MS)
+		bool everReplied = g_connReplyMs[k] != 0;
+		unsigned long silentMs =
+			everReplied ? (nowMs - g_connReplyMs[k]) : 0;
+		bool cold = everReplied && silentMs > SLOT_COLD_MS;
+		bool quiet = everReplied && !cold && silentMs > SLOT_QUIET_MS;
+		bool phantom = !everReplied && linkUp;
+		unsigned long retry = (cold || phantom) ? SLOT_COLD_RETRY_MS :
+				      quiet		? SLOT_QUIET_RETRY_MS :
+							  0;
+		if (retry && nowMs - g_slotLastAttemptMs[k] < retry)
 			continue;
 		doPoll(k);
 	}
@@ -797,6 +940,63 @@ void rfLinkTask()
 	if (g_connOn && millis() - g_connCooldown > 2500) {
 		rfConnStep();
 	} // connected-mode: poll controller, read input
+
+	// ---- RF stall self-heal -------------------------------------------------------------------------------
+	// Field wedge: the controllers stay powered and show a SOLID (connected) light, the puck keeps issuing E3
+	// polls + E1 beacons (radio TX is provably alive -- a power-cycled controller still re-adopts the session
+	// and goes solid against this puck), yet NOT ONE slot decodes a reply for seconds. The link reads down, no
+	// input reaches the host, and it never recovers on its own -- only a puck REPLUG fixes it. rfConfig() already
+	// re-disables (TASKS_DISABLE) the radio before every poll, so whatever this is survives a TASKS_DISABLE; the
+	// only thing that clears it is a full peripheral reset -- exactly what the replug does. So when the WHOLE
+	// link has been silent past RF_STALL_MS while we're still actively polling a slot that WAS connected, do the
+	// replug's job for the radio: power-cycle NRF_RADIO (POWER=0/1 clears every RADIO register; the next
+	// rfConnTx/rfHostFrameOnce re-applies all config via rfConfig()) and drop the connected-mode latches so
+	// polling/beaconing resume and the handshake re-runs from a clean slate. Gated on EVERY used+previously-
+	// connected slot being stalled, so one controller walking off (others still live) never trips it; the
+	// cooldown gate keeps it from firing while a controller is intentionally powering off; rate-limited so it
+	// cannot thrash. g_rfStallRecover is surfaced to the panel so the wedge -- and its recovery -- is observable.
+	if (g_connOn && g_curSlot >= 0 && millis() - g_connCooldown > 2500) {
+		static unsigned long lastRecoverMs = 0;
+		static uint8_t consecStall = 0;
+		unsigned long nowMs2 = millis();
+		bool anyEverConnected = false, allStalled = true;
+		for (int s = 0; s < NSLOT; s++) {
+			if (!(g_slot[s].used && g_connReplyMs[s] != 0))
+				continue;
+			anyEverConnected = true;
+			if ((uint32_t)(nowMs2 - g_connReplyMs[s]) < RF_STALL_MS)
+				allStalled = false;
+		}
+		// Any slot alive -> a recovery worked (or we were never stalled): reset the give-up counter.
+		if (!allStalled)
+			consecStall = 0;
+		// Back off once we've power-cycled RF_STALL_GIVEUP times with nothing coming back (controller absent,
+		// not a wedged radio): slow the retry to RF_STALL_BACKOFF_MS instead of hammering every RF_RECOVER_MS.
+		uint32_t interval = (consecStall < RF_STALL_GIVEUP) ?
+					    RF_RECOVER_MS :
+					    RF_STALL_BACKOFF_MS;
+		if (anyEverConnected && allStalled &&
+		    (uint32_t)(nowMs2 - lastRecoverMs) > interval) {
+			lastRecoverMs = nowMs2;
+			g_rfStallRecover++;
+			faultDiagTrace(FR_HEAL, g_rfStallRecover);
+			if (consecStall < 255)
+				consecStall++;
+			NRF_RADIO->POWER = 0;
+			NRF_RADIO->POWER = 1;
+			g_connCooldown = 0;
+			g_connSt = 0;
+			g_connStep = 0;
+			if (Serial.availableForWrite() > 110)
+				Serial.printf(
+					"# RF STALL (#%u consec=%u%s) -> radio power-cycle + reconnect\n",
+					g_rfStallRecover, consecStall,
+					consecStall >= RF_STALL_GIVEUP ?
+						" BACKOFF" :
+						"");
+		}
+	}
+
 	{
 		// remote wakeup on new RF controller connection (any slot)
 		static bool wasRfConn = false;
@@ -830,27 +1030,53 @@ void rfLinkTask()
 		}
 	}
 	if (g_connOn && millis() - g_stMs >= 1000) {
-		g_f1ps = g_stF1;
-		g_newps = g_stNew;
-		g_pollsps = (uint16_t)g_stPoll;
+		// Per-slot snapshots first (blob v13 / per-controller panel stats), then the legacy aggregates as
+		// their sums (serial stat line + pre-v13 blob fields).
+		uint32_t tPoll = 0, tF1 = 0, tNew = 0, tCrc = 0, tNoRx = 0,
+			 tRelay = 0;
+		for (int s = 0; s < NSLOT; s++) {
+			g_slotPollsps[s] = (uint16_t)g_stPoll[s];
+			g_slotF1ps[s] = (uint16_t)g_stF1[s];
+			g_slotNewps[s] = (uint16_t)g_stNew[s];
+			g_slotCrcps[s] =
+				(uint8_t)(g_stCrc[s] > 255 ? 255 : g_stCrc[s]);
+			g_slotNoRxps[s] = (uint8_t)(g_stNoRx[s] > 255 ?
+							    255 :
+							    g_stNoRx[s]);
+			g_slotRelayps[s] = (uint8_t)(g_stRelay[s] > 255 ?
+							     255 :
+							     g_stRelay[s]);
+			tPoll += g_stPoll[s];
+			tF1 += g_stF1[s];
+			tNew += g_stNew[s];
+			tCrc += g_stCrc[s];
+			tNoRx += g_stNoRx[s];
+			tRelay += g_stRelay[s];
+			g_stPoll[s] = g_stF1[s] = g_stNew[s] = 0;
+			g_stCrc[s] = g_stNoRx[s] = g_stRelay[s] = 0;
+		}
+		g_f1ps = (uint16_t)tF1;
+		g_newps = (uint16_t)tNew;
+		g_pollsps = (uint16_t)tPoll;
+		g_relayps = (uint16_t)tRelay;
+		g_crcps = (uint16_t)tCrc;
+		g_norxps = (uint16_t)tNoRx;
 		g_pollPeriodUs =
 			g_pollDtCnt ? (uint16_t)(g_pollDtSum / g_pollDtCnt) : 0;
 		g_pollDtSum = 0;
 		g_pollDtCnt = 0;
-		if (Serial.availableForWrite() > 70)
+		// Require room for the WHOLE line (~85B): CDC write() has NO timeout -- it spins yield() until the host
+		// drains, so a guard smaller than the line lets write() start, fill the FIFO mid-line, then spin loop()
+		// forever if the serial host stalls -> watchdog hang. (This exact line, guarded at >70 for an ~85B line,
+		// was the confirmed diagnostic-induced hang: the capture ended truncated mid-"# stat".)
+		if (Serial.availableForWrite() > 130)
 			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_curSlot);
-		g_stPoll = 0;
-		g_stF1 = 0;
-		g_stNew = 0;
+				(unsigned long)tPoll, (unsigned long)tF1,
+				(unsigned long)tNew, (unsigned long)g_stF3,
+				(int8_t)g_connF3v, g_e7b, (unsigned long)tCrc,
+				(unsigned long)tNoRx, g_curSlot);
 		g_stF3 = 0;
-		g_stCrc = 0;
-		g_stNoRx = 0;
 		g_chF1[0] = g_chF1[1] = g_chF1[2] = 0;
 		g_stMs = millis();
 	}

+ 18 - 2
OpenPuck/rf_link.h

@@ -43,7 +43,9 @@ extern bool
 
 // full multi-line debug dump vs compact "I45 <hex>" stream
 extern bool g_connVerbose;
-extern uint32_t g_rxWin; // poll RX-window (us): caps poll rate (~1e6/rxWin)
+// poll RX-window (us): caps poll rate (~1e6/rxWin). FIXED, not configurable (like g_pollUs) -- raising it
+// drops the poll rate, so it's pinned at the 250 Hz value rather than exposed as a footgun knob.
+extern const uint32_t g_rxWin;
 
 // set on 0xF2 disconnect; pauses beacon+poll so a powering-off controller can sleep
 extern unsigned long g_connCooldown;
@@ -76,8 +78,22 @@ extern uint16_t g_f1ps; // last completed second's F1 rate
 // genuine new-report rate (report 0x45 seq byte changes)
 extern uint16_t g_newps;
 
-// last second's poll TX count (GET+relay) -- vs F1 tells starvation from reply-loss
+// last second's TRUE poll-cycle count (one E3 GET per warm slot per cycle, ~250 for one controller) -- vs F1
+// tells starvation from reply-loss.
 extern uint16_t g_pollsps;
+// last second's relay-frame TX count (host/haptic output reports forwarded to the controller). Each relay TX
+// precedes a poll and consumes a reply window, so a high relay rate directly depresses the F1/delivered rate.
+extern uint16_t g_relayps;
+// last second's CRC-fail and no-reply counts: diagnose a wedge -- polls>0 + noRx high = RX dead / wrong
+// channel / controller silent; crc high = corrupt replies (interference); polls==0 = poll loop stopped.
+extern uint16_t g_crcps, g_norxps;
+// PER-SLOT per-second snapshots (WebUSB blob v13): the aggregates above are sums of these. Each controller's
+// panel tab shows its OWN link health, so one slot drowning no longer hides behind another's healthy rate.
+extern uint16_t g_slotPollsps[NSLOT], g_slotF1ps[NSLOT], g_slotNewps[NSLOT];
+extern uint8_t g_slotCrcps[NSLOT], g_slotNoRxps[NSLOT], g_slotRelayps[NSLOT];
+// Count of RF-stall self-heals (radio power-cycle + reconnect) performed since boot. Non-zero means the
+// firmware caught and recovered from a full-link wedge that previously needed a manual puck replug.
+extern uint16_t g_rfStallRecover;
 
 // MEASURED avg us between GET-poll fires (compare to the intended g_pollUs=4000)
 extern uint16_t g_pollPeriodUs;

+ 51 - 5
OpenPuck/serial_console.cpp

@@ -5,6 +5,8 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "config.h"
+#include "puck_hid.h" // g_cmdCapture (feature-command capture toggle)
+#include "fault_diag.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <stdlib.h>
@@ -30,7 +32,44 @@ void serialConsolePoll()
 				Serial.println(
 					"# done -- rebooting into clean defaults; re-pair the controller");
 				delay(40);
+				faultDiagArmIntentionalReset();
 				NVIC_SystemReset();
+			} else if (!strcmp(line, "AMP")) {
+				// A/B: land Steam's amp/haptic 0x87 config (0x18/0x2E/0x34/0x35) so haptics play as ticks
+				g_landAmp = !g_landAmp;
+				Serial.printf(
+					"# land amp/haptic 0x87 config %s\n",
+					g_landAmp ? "ON" : "off");
+			} else if (!strcmp(line, "S81")) {
+				// A/B: drop Steam's relayed 0x81 CLEAR_DIGITAL_MAPPINGS (the connect amp-clicker)
+				g_drop81 = !g_drop81;
+				Serial.printf(
+					"# drop relayed 0x81 (Steam mode) %s\n",
+					g_drop81 ? "ON" : "off");
+			} else if (!strcmp(line, "FC")) {
+				// feature-command capture: log Steam's USB SET/GET commands to serial + suppress I45
+				g_cmdCapture = !g_cmdCapture;
+				Serial.printf(
+					"# feature-cmd capture %s (I45 %s)\n",
+					g_cmdCapture ? "ON" : "off",
+					g_cmdCapture ? "suppressed" : "on");
+			} else if (!strcmp(line, "L87")) {
+				// EXPERIMENT: land all relayed 0x87 config verbatim (real-puck relay) vs the discard-whitelist.
+				// Exact-match string (single letters are all taken; 'l' alone = rfListenStart).
+				g_landAll87 = !g_landAll87;
+				saveCfg();
+				Serial.printf(
+					"# land-all-0x87 (verbatim 0x87 relay) %s\n",
+					g_landAll87 ? "ON" : "off");
+			} else if (!strcmp(line, "FR")) {
+				// re-dump the flight recorder trail captured before the last watchdog hang (also printed
+				// automatically at boot, but CDC may not be attached yet then -- this reprints on demand).
+				faultDiagDumpFlight();
+			} else if (!strcmp(line, "VIT")) {
+				// toggle the live per-second CDC vitals line (loop rate / usbd stack / heap / relay trend)
+				g_vitals = !g_vitals;
+				Serial.printf("# live vitals line %s\n",
+					      g_vitals ? "ON" : "off");
 			} else if (line[0] == 'l')
 				rfListenStart();
 			else if (line[0] == 'B') {
@@ -88,9 +127,17 @@ void serialConsolePoll()
 				g_rfRespond = false;
 				NRF_RADIO->TASKS_DISABLE = 1;
 				Serial.println("# RF off");
+			} else if (line[0] == 'u') {
+				// id9=0 hold (MODE_STEAM only) A/B toggle (persisted; see haptics.h LIZKEEP_MS)
+				g_lizKeep = !g_lizKeep;
+				saveCfg();
+				Serial.printf(
+					"# id9=0 hold (Steam mode, /%ums) %s\n",
+					(unsigned)LIZKEEP_MS,
+					g_lizKeep ? "ON" : "off");
 			}
 
-			// switch USB mode: 0=steam 1=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro 7=ps5-game/clean 8=ds4-game/clean
+			// switch USB mode: 0=steam 1=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro 7=ps5-game/clean 8=ds4-game/clean 9=ps3(dualshock3)
 			else if (line[0] == 'x') {
 				uint8_t m = strtoul(line + 1, 0, 10);
 				if (modeValid(m)) {
@@ -104,6 +151,7 @@ void serialConsolePoll()
 						delay(20);
 						saveMode(m);
 						delay(40);
+						faultDiagArmIntentionalReset();
 						NVIC_SystemReset();
 					}
 				}
@@ -368,11 +416,9 @@ void serialConsolePoll()
 						g_slot[s].rec[7]);
 				}
 			} else if (line[0] == 'r') {
-				g_rxWin = strtoul(line + 1, 0, 10);
-				if (g_rxWin < 150)
-					g_rxWin = 150;
+				// poll RX-window is FIXED (not configurable) -- report only.
 				Serial.printf(
-					"# poll RX-window=%lu us (poll rate caps ~%lu/s)\n",
+					"# poll RX-window=%lu us (FIXED; poll rate caps ~%lu/s)\n",
 					(unsigned long)g_rxWin,
 					(unsigned long)(1000000 / g_rxWin));
 			} else if (line[0] == 'e') {

+ 28 - 2
OpenPuck/usb_mount.cpp

@@ -1,5 +1,6 @@
 #include "usb_mount.h"
 #include "bonds.h"
+#include "fault_diag.h" // faultDiagTrace() -- flight recorder
 #include <Arduino.h>
 
 uint8_t g_usbMountCount = 0;
@@ -15,6 +16,12 @@ static uint8_t g_maxSlots = NSLOT;
 // The connected set must hold steady this long before we re-enumerate -- absorbs RF blips and the staggered
 // way multiple controllers connect at boot (one re-enumeration once the set settles, not one per controller).
 #define MOUNT_DEBOUNCE_MS 2000u
+// Lazy removal: a disconnected slot's interface is only dropped (and the device re-enumerated down) once NO
+// controller has sent input for this long -- i.e. during genuine downtime. Re-enumeration tears down/rebuilds
+// USB from the loop task and is risky to do mid-use, so we never shrink while anything is being played; we
+// just let dead slots linger as idle interfaces and clean them up when the rig goes quiet. Additions are NOT
+// gated by this -- a newly-connected controller grows the set right away.
+#define IDLE_CLEANUP_MS 60000u
 
 static uint8_t connectedMask()
 {
@@ -108,14 +115,32 @@ void usbMountTask()
 	static uint8_t lastMask = 0xFF; // force first compare
 	static unsigned long stableSince = 0;
 	unsigned long now = millis();
-	uint8_t want = connectedMask();
+	// ADD IMMEDIATELY, REMOVE LAZILY. Re-enumeration (usbReenumerate) tears down + rebuilds the whole USB
+	// config from the loop task; doing that while the high-priority usbd task is mid-tud_task races its
+	// internal state -> HardFault/reboot. So we only ever SHRINK the interface set during genuine downtime:
+	//   - active (some controller sent input within IDLE_CLEANUP_MS): target = connected UNION already-mounted
+	//     -> the set only grows. A controller that drops (e.g. an errant ReversePuck disconnect) keeps its
+	//     now-idle interface and reconnecting reuses it with NO re-enumeration. task() gates input per slot,
+	//     so an idle interface just streams nothing.
+	//   - idle (no controller input for a full minute): target = exactly the connected set -> any lingering
+	//     dead slot is dropped and we re-enumerate down, safely, because nobody is playing.
+	// New controllers grow the set right away (after the usual debounce) regardless of idle state.
+	unsigned long lastAny = 0;
+	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used && g_connReplyMs[s] > lastAny)
+			lastAny = g_connReplyMs[s];
+	bool idle = (lastAny == 0) || (now - lastAny) >= IDLE_CLEANUP_MS;
+	uint8_t want = idle ? connectedMask() :
+			      (uint8_t)(connectedMask() | mountedMask());
 	if (want != lastMask) { // set is moving -> restart the stability timer
 		lastMask = want;
 		stableSince = now;
 		return;
 	}
 	// `want` has been steady since stableSince. Re-enumerate once it has held long enough AND the resulting
-	// (order-preserving, capped) mount SET differs from what is currently mounted.
+	// (order-preserving, capped) mount SET differs from what is currently mounted. With the union above,
+	// while active that can only happen when a NEW controller needs an interface (grow); a shrink only ever
+	// comes from the idle branch dropping a long-dead slot.
 	if ((now - stableSince) < MOUNT_DEBOUNCE_MS)
 		return;
 	int8_t tmp[NSLOT];
@@ -123,5 +148,6 @@ void usbMountTask()
 	if (maskOfList(tmp, n) == mountedMask())
 		return; // same set already mounted (order is preserved across joins/leaves)
 	commitOrder(tmp, n);
+	faultDiagTrace(FR_MOUNT, g_usbMountCount);
 	usbReenumerate(g_usbMountCount);
 }

+ 163 - 0
OpenPuck/usb_tx.cpp

@@ -0,0 +1,163 @@
+#include "usb_tx.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+// One outbound ring per active HID destination. Only one USB mode is live at a time, and a mode presents at
+// most CFG_TUD_HID HID interfaces plus the wake mouse, so a handful of channels covers every case. Channels are
+// allocated lazily on first send to a given Adafruit_USBD_HID* and never freed (the object set is fixed for the
+// session). The producer side runs under PRIMASK (loop enqueue); the consumer (usbTxDrain) runs from the
+// usbtx task AND tud_hid_report_complete_cb, serialized by a guard flag inside usbTxDrain.
+#define TX_CHAN_MAX 8
+#define TX_RING_N \
+	4 // depth per destination; 250 Hz produced, ~1 kHz SOF drain -> stays shallow
+#define TX_DATA_MAX \
+	64 // CFG_TUD_HID_EP_BUFSIZE -- the largest report any mode sends (63 B + id)
+
+struct TxItem {
+	uint8_t rid;
+	uint8_t len;
+	uint8_t data[TX_DATA_MAX];
+};
+struct TxChan {
+	Adafruit_USBD_HID *hid; // nullptr = free slot
+	TxItem ring[TX_RING_N];
+	volatile uint8_t head,
+		tail; // head=next write, tail=next read; empty when equal
+};
+static TxChan g_chan[TX_CHAN_MAX];
+
+static inline uint8_t txNext(uint8_t i)
+{
+	return (uint8_t)((i + 1) % TX_RING_N);
+}
+
+void usbTxHid(Adafruit_USBD_HID *hid, uint8_t rid, const void *data,
+	      uint16_t len)
+{
+	if (!hid)
+		return;
+	if (len > TX_DATA_MAX)
+		len = TX_DATA_MAX;
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	// find this destination's channel, or claim a free one
+	TxChan *c = nullptr;
+	for (int i = 0; i < TX_CHAN_MAX; i++) {
+		if (g_chan[i].hid == hid) {
+			c = &g_chan[i];
+			break;
+		}
+	}
+	if (!c) {
+		for (int i = 0; i < TX_CHAN_MAX; i++) {
+			if (!g_chan[i].hid) {
+				c = &g_chan[i];
+				c->hid = hid;
+				c->head = c->tail = 0;
+				break;
+			}
+		}
+	}
+	if (c) {
+		uint8_t h = c->head, nx = txNext(h);
+		// full -> drop the oldest (advance tail), never the newest
+		if (nx == c->tail)
+			c->tail = txNext(c->tail);
+		c->ring[h].rid = rid;
+		c->ring[h].len = (uint8_t)len;
+		if (len)
+			memcpy(c->ring[h].data, data, len);
+		c->head = nx;
+	}
+	__set_PRIMASK(pm);
+}
+
+// Send at most one ready report per destination per call. After a send the endpoint stays busy until the host
+// polls it, so the next report for that destination goes out from tud_hid_report_complete_cb (the instant the
+// host reads it) -- that chaining is what paces each stream to the host's poll rate. The usbtx task calls this
+// too, to kick a stream that's idle (no completion pending) and to keep non-busy destinations moving. A busy
+// destination is simply skipped; it does not hold up the others.
+void usbTxDrain(void)
+{
+	// Re-entrancy guard (defensive): usbTxPump only calls this from loop(), but sendReport below can yield,
+	// so guard against a nested loop()-context call ever racing a channel's tail. The loser just skips.
+	static volatile unsigned char draining;
+	if (__atomic_test_and_set(&draining, __ATOMIC_ACQUIRE))
+		return;
+	for (int i = 0; i < TX_CHAN_MAX; i++) {
+		TxChan *c = &g_chan[i];
+		if (!c->hid)
+			continue;
+		uint8_t t = c->tail;
+		if (t == c->head)
+			continue; // empty
+		if (!c->hid->ready())
+			continue; // endpoint busy / not mounted -> retry on completion/next tick
+		TxItem &it = c->ring[t];
+		// sendReport copies into the endpoint buffer, so releasing the slot right after is safe.
+		if (c->hid->sendReport(it.rid, it.data, it.len))
+			c->tail = txNext(t);
+	}
+	__atomic_clear(&draining, __ATOMIC_RELEASE);
+}
+
+// Per-slot drain callbacks for non-HID senders (XInput endpoint, WebUSB blob). Registered at setup(), invoked
+// from usbTxPump() (loop context). Fixed, tiny capacity; registration is setup-only (single-threaded).
+#define TX_DRAIN_MAX 4
+static usbTxDrainFn g_drainFns[TX_DRAIN_MAX];
+static uint8_t g_nDrain;
+
+void usbTxRegisterDrain(usbTxDrainFn fn)
+{
+	if (fn && g_nDrain < TX_DRAIN_MAX)
+		g_drainFns[g_nDrain++] = fn;
+}
+
+// ---- priority-inversion guard (BLACK BOX capture 3, 2026-07-03) --------------------------------------------
+// TinyUSB's nrf5x dcd claims the chip's ONE EasyDMA slot (dma_running test_and_set) and only a few
+// instructions LATER writes TASKS_STARTEPIN -- and loop-context sends reach that window at TASK_PRIO_LOW.
+// Preempted right there during a Steam OUT flood (0x82 haptic storm), the flag is held by a task that never
+// runs again: the TASK_PRIO_HIGH usbd task livelocks re-deferring xact_out_dma around the never-started DMA
+// (caught live: loopPC=start_dma:139, irqPC=xact_out_dma re-defer) -> USB dead -> watchdog. The fix needs no
+// library patch: run loop's USB-TX windows at the usbd task's own priority. Equal priority = no preemption on
+// interrupt exit = the claim+start pair completes atomically w.r.t. the usbd task. Depth-counted so nested
+// boosted sections (pump -> drain callbacks) stay balanced; only ever used from the loop task.
+static UBaseType_t g_boostSaved;
+static uint8_t g_boostDepth;
+void usbTxBoost(void)
+{
+	if (!g_boostDepth++) {
+		g_boostSaved = uxTaskPriorityGet(NULL);
+		vTaskPrioritySet(NULL, TASK_PRIO_HIGH);
+	}
+}
+void usbTxUnboost(void)
+{
+	if (g_boostDepth && !--g_boostDepth)
+		vTaskPrioritySet(NULL, g_boostSaved);
+}
+
+// Drain everything from loop() -- NOT from a SOF callback, a dedicated task, or report_complete. All three of
+// those were tried and all fight the RF poll, which is a microsecond-precise busy-wait running in loop(): SOF
+// got silently disabled by bus resets; the high-priority usbd task (report_complete) preempted the RF RX
+// windows -> missed replies + usbd self-blocking on a full event queue -> controllers dropped; a separate
+// low-priority task got starved under USB load -> latency. Draining in loop() is what stability-fix did and is
+// the only context that does NOT jitter the RF timing: the send and the poll run sequentially in one task, so
+// no send ever interrupts an RX window. The cost -- loop() can block if sendReport hits a full usbd event
+// queue -- is handled by a deep CFG_TUD_TASK_QUEUE_SZ (so it never fills) rather than by moving sends off loop.
+// Call once per loop() iteration.
+void usbTxPump(void)
+{
+	// Whole pump (HID sends + vendor/WebUSB drains) runs boosted -- every dcd claim window in here is
+	// then atomic against the usbd task (see usbTxBoost above).
+	usbTxBoost();
+	usbTxDrain();
+	for (uint8_t i = 0; i < g_nDrain; i++)
+		g_drainFns[i]();
+	usbTxUnboost();
+}
+void usbTxBegin(void)
+{
+	// Nothing to arm: usbTxPump() is driven directly from loop(). Kept for call-site stability.
+}

+ 52 - 0
OpenPuck/usb_tx.h

@@ -0,0 +1,52 @@
+// usb_tx.h -- marshal device->host HID reports onto the TinyUSB "usbd" task.
+//
+// THE PROBLEM this fixes (see ARCHITECTURE / the usbd-queue-deadlock notes): TinyUSB's tud_task() runs in a
+// dedicated high-priority "usbd" FreeRTOS task, but this firmware historically called hid.sendReport() (i.e.
+// tud_hid_*_report) straight from the LOW-priority loop() task. When the nRF52 USBD's single shared EasyDMA is
+// momentarily busy, that cross-task send takes TinyUSB's usbd_defer_func() path, which does a BLOCKING
+// osal_queue_send on the device event queue. Under a comms burst the queue saturates, the loop task blocks
+// there, stops feeding the ~8 s watchdog, and the MCU resets (the controller "disconnects").
+//
+// THE FIX: loop-context code never calls tud_* directly. It enqueues the report here (non-blocking, ISR/PRIMASK
+// safe), and the actual sendReport() happens from tud_sof_cb() -- which TinyUSB dispatches ON THE usbd TASK
+// once per USB frame (~1 ms). Every tud_* call is therefore back in the single context TinyUSB expects, so the
+// blocking-defer path can never stall the loop. Reports are keyed by their Adafruit_USBD_HID* (NOT the HID
+// instance index, which is mode-dependent and fragile -- see the "HID instance order vs dynamic mount" note).
+#pragma once
+#include <stdint.h>
+
+class Adafruit_USBD_HID;
+
+// Queue one HID report for `hid` to be sent from the usbd task. Safe from any context (loop or usbd). The
+// payload is copied, so the caller's buffer can be reused immediately (tud_hid_*_report copies into the
+// endpoint buffer too, so this preserves the original fire-and-forget semantics). Never blocks: if that
+// destination's ring is full the oldest queued report is dropped (same policy as the haptic relay ring) --
+// for a 250 Hz gamepad stream the newest state is what matters. `len` is clamped to the 64 B report ceiling.
+void usbTxHid(Adafruit_USBD_HID *hid, uint8_t rid, const void *data,
+	      uint16_t len);
+
+// Kept for call-site stability; no longer does anything (the drain runs from loop() via usbTxPump). Call once.
+void usbTxBegin(void);
+
+// Drain all pending sends (HID rings + registered non-HID drains). MUST be called from loop() once per
+// iteration -- sends run in loop() context, sequentially with the RF poll, so they never interrupt its
+// timing-critical RX windows (which is what off-loop drain contexts did, dropping controllers).
+void usbTxPump(void);
+
+// Drain pending HID reports (one ready report per destination per call). Internal -- called by usbTxPump().
+void usbTxDrain(void);
+
+// Priority-inversion guard: run a loop-context USB TX window (HID/vendor/CDC writes) at the usbd task's
+// priority so TinyUSB's dcd DMA claim+start can't be preempted mid-claim (the issue-72 livelock; see
+// usb_tx.cpp). Depth-counted; loop task only, always in a balanced pair. usbTxPump() boosts itself; wrap
+// direct Serial/usb_web writes that can run at flood rate (e.g. per-relay logging).
+void usbTxBoost(void);
+void usbTxUnboost(void);
+
+// Register a callback to run every USB frame ON THE usbd TASK (from tud_sof_cb, right after usbTxDrain()).
+// This is how senders that DON'T go through Adafruit_USBD_HID get their transmits off the loop task too:
+//   - mode_xinput flushes its raw custom-class IN endpoints;
+//   - webusb_config sends the status blob (its usb_web.flush() can otherwise block the loop on a full queue).
+// Call once at setup() (before usbTxBegin()). Safe no-op if usb isn't up yet. Capacity is small + fixed.
+typedef void (*usbTxDrainFn)(void);
+void usbTxRegisterDrain(usbTxDrainFn fn);

+ 11 - 2
OpenPuck/wake_hid.cpp

@@ -1,4 +1,5 @@
 #include "wake_hid.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 
 // Boot MOUSE descriptor -- proven to enumerate and wake Windows. The host enumerates a "HID-compliant mouse"
@@ -34,8 +35,16 @@ bool wakeHidMove(int8_t dx, int8_t dy)
 {
 	if (!wakeHidReady())
 		return false;
-	// boot mouse descriptor has no report ID -> report_id 0; buttons=0 so we move but never click
-	return g_wakeHid.mouseReport(0, 0, dx, dy, 0, 0);
+	// boot mouse descriptor has no report ID -> report_id 0; buttons=0 so we move but never click. Queued for
+	// the usbd task (usbTxHid) rather than sent inline, like every other report -- loop() issues no tud_* call.
+	hid_mouse_report_t m;
+	m.buttons = 0;
+	m.x = dx;
+	m.y = dy;
+	m.wheel = 0;
+	m.pan = 0;
+	usbTxHid(&g_wakeHid, 0, &m, sizeof m);
+	return true;
 }
 
 void wakeHidAddInterface()

+ 460 - 54
OpenPuck/webusb_config.cpp

@@ -7,22 +7,47 @@
 #include "triton.h" // g_in raw IMU (diagnostic readout)
 #include "lizard_map.h"
 #include "build_info.h"
+#include "fault_diag.h"
+#include "usb_tx.h"
 #include <Arduino.h>
 #include <string.h>
 
 Adafruit_USBD_WebUSB usb_web;
 
-// blob payload = [ver=9][mode][mDiv][mFric][qamMap(active)][abSwap(active)][back0..3(active)][connSlot(0xFF=none)][linkUp]
+// Panel-blob send is deferred to the usbd task: webusbPoll() (loop) just sets this, and webusbSofDrain()
+// (registered with usb_tx, runs from tud_sof_cb on the usbd task) does the actual write+flush. usb_web.flush()
+// goes through the same blocking osd_queue_send path as HID sends, so doing it from loop() could stall the
+// loop and trip the watchdog under load -- with the panel open during heavy use that was a live risk.
+static volatile bool g_blobRequest = false;
+// Bond export ("clone" backup): a 0x09 panel command asks for a one-shot dump of all four bond slots so the
+// browser can save them to a file and restore them onto a second puck. Like the status blob, the actual send
+// is deferred to the usbd task (webusbSofDrain) so usb_web.write()/flush() never block loop().
+static volatile bool g_bondExportRequest = false;
+
+// blob payload = [ver=10][mode][mDiv][mFric][qamMap(active)][abSwap(active)][back0..3(active)][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][battery%][rssi|dBm|]
-//                [gitDirty][gitHash 12B ASCII, NUL-padded][rumbleScale][swPro120][swGyroScale10][raw accel ax ay az 3x s16 LE]
+//                [gitDirty][buildId 12B ASCII, NUL-padded][rumbleScale][swPro120][swGyroScale10][raw accel ax ay az 3x s16 LE]
 //                [bondedCount][slot0_up][slot0_batt][slot0_rssi]...[slot3_up][slot3_batt][slot3_rssi]
-//                [v9: per-type cfg, 4x7B: ET_XBOX/SWITCH/DS4/DS5 each {back0..3, qam, abSwap, padHaptics}]
-// p[6]/p[7]/p[8..11] mirror the ACTIVE type (legacy display). v9 extends to 101 bytes (103 total incl header);
+//                [v10: per-type cfg, 4x8B: ET_XBOX/SWITCH/DS4/DS5 each {back0..3, qam, abSwap, padHaptics, ledBright}]
+//                [v11: resetReason(RR_* code)][resetReas raw u32 LE][rxWin/10 us][hapticBlockOn][hapticBlock s]
+// p[6]/p[7]/p[8..11] mirror the ACTIVE type (legacy display). v11 extends to 113 bytes (115 total incl header);
 // browser reads with transferIn(128) to span the two USB-FS packets.
-#define WB_PAYLEN 101
+//                [diag: crc/s][noRx/s][rfStallRecover count]
+//                [v12: relayps(lo,hi)][clkLfSrc][clkHfSrc][usPerMs(lo,hi)][hangStage][curStage][stallMs/40][ringFault(lo,hi)][hangPC u32][hangLR u32][usbdStackFree(lo,hi)][loopStackFree(lo,hi)]
+//                [v13: per-slot link stats, 4x9B from p[141]: {pollsps u16, f1ps u16, newps u16, crc/s u8,
+//                 noRx/s u8, relay/s u8} -- each controller's own rates (the v4 aggregates are their sums)]
+//                [v14: p[177] landAll87 (verbatim-0x87-relay experiment toggle)]
+#define WB_PAYLEN 176
+// The blob send is drop-on-full (never blocks loop), so the vendor TX FIFO MUST be able to hold a whole blob
+// -- otherwise tud_vendor_write_available() never reaches the frame size and EVERY frame is dropped (blank
+// panel / stale mappings). The Makefile sets -DCFG_TUD_VENDOR_TX_BUFSIZE=256; guard it here so a build without
+// that flag (or a future blob that outgrows the FIFO) fails loudly instead of shipping a dead panel.
+static_assert(CFG_TUD_VENDOR_TX_BUFSIZE >= 2 + WB_PAYLEN,
+	      "CFG_TUD_VENDOR_TX_BUFSIZE too small to hold a WebUSB blob in one "
+	      "write -- build via `make build` (sets 256), or raise the flag.");
 static void webusbSendBlob()
 {
 	if (!usb_web.connected())
@@ -32,12 +57,16 @@ static void webusbSendBlob()
 	// every ~250ms in normal use.
 	int cs = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
 	bool up = (g_curSlot >= 0 && (millis() - g_connReplyMs[cs]) < 300);
-	uint8_t p[2 + WB_PAYLEN];
+	// STATIC, not stack: this runs on the usbd task, whose 800B stack (hardcoded USBD_STACK_SZ in the
+	// Adafruit core, not growable from the sketch) is the prime overflow suspect behind the issue-72
+	// watchdog hangs (hang boots report usbd stack free = 0). A 178B frame on that stack was the single
+	// biggest consumer. Single-writer (SOF drain only), so a static buffer is race-free.
+	static uint8_t p[2 + WB_PAYLEN];
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (9 = +per-type cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
-	p[2] = 9;
+	// protocol version (14 = +landAll87 toggle; 13 = +per-slot link stats; 12 = +relay rate + clock fingerprint; 11 = +reset cause; 10 = +ledBright per type; 9 = +per-type cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
+	p[2] = 14;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -83,14 +112,16 @@ static void webusbSendBlob()
 	p[39] = g_linkRssi[g_curSlot >= 0 && g_curSlot < NSLOT ?
 				   g_curSlot :
 				   0]; // RAW signal strength |dBm| (0=no sample)
-	// git commit this firmware was built from + dirty flag; injected at build time
-	// (build_info.h / gen_version.sh); "unknown" if the version header wasn't generated.
+	// Release builds are tagged and easier to trace by semver; local/dev builds
+	// still expose git hash when no version tag is available.
 	p[40] = OPK_GIT_DIRTY ? 1 : 0;
-	memset(&p[41], 0, 12); // 12B ASCII git hash, NUL-padded
+	memset(&p[41], 0, 12); // 12B ASCII build ID, NUL-padded
 	{
-		const char *h = OPK_GIT_HASH;
-		for (uint8_t i = 0; i < 12 && h[i]; i++)
-			p[41 + i] = (uint8_t)h[i];
+		const char *buildId = OPK_BUILD_VERSION;
+		if (buildId[0] == '\0')
+			buildId = OPK_GIT_HASH;
+		for (uint8_t i = 0; i < 12 && buildId[i]; i++)
+			p[41 + i] = (uint8_t)buildId[i];
 	}
 	p[53] = g_rumbleScale; // rumble strength % (protocol v5)
 
@@ -118,9 +149,9 @@ static void webusbSendBlob()
 			p[65 + s * 3] = g_linkRssi[s];
 		}
 	}
-	// per-emulated-type button config (protocol v9): 4 types x 7 bytes from p[75]
+	// per-emulated-type button config (protocol v10): 4 types x 8 bytes from p[75]
 	for (int et = 0; et < ET_COUNT; et++) {
-		uint8_t *q = &p[75 + et * 7];
+		uint8_t *q = &p[75 + et * 8];
 		q[0] = g_type[et].back[0];
 		q[1] = g_type[et].back[1];
 		q[2] = g_type[et].back[2];
@@ -128,9 +159,294 @@ static void webusbSendBlob()
 		q[4] = g_type[et].qamMap;
 		q[5] = g_type[et].abSwap;
 		q[6] = g_type[et].padHaptics;
+		q[7] = g_type[et].ledBright;
 	}
-	usb_web.write(p, sizeof p);
-	usb_web.flush();
+	// last-boot reset cause (protocol v11): why we (re)booted -- watchdog hang vs HardFault vs intentional
+	// reboot vs power-on (issue #72). p[107] = RR_* code; p[108..111] = raw RESETREAS for the curious.
+	p[107] = faultDiagReason();
+	uint32_t rr = faultDiagResetReas();
+	p[108] = (uint8_t)rr;
+	p[109] = (uint8_t)(rr >> 8);
+	p[110] = (uint8_t)(rr >> 16);
+	p[111] = (uint8_t)(rr >> 24);
+	// Connection tunables (protocol v11): poll RX window in 10us units, post-connect haptic block enable,
+	// and block duration in seconds.
+	p[112] = (uint8_t)(g_rxWin / 10);
+	p[113] = g_hapticBlockOn;
+	p[114] = (uint8_t)(g_hapticBlockMs / 1000);
+	// RF wedge diagnostics (always populated, even when the link reads down): CRC-fail/s and no-reply/s,
+	// capped at 255. With Polls/s this distinguishes poll-loop-stopped vs RX-dead vs corrupt-replies.
+	p[115] = (uint8_t)(g_crcps > 255 ? 255 : g_crcps);
+	p[116] = (uint8_t)(g_norxps > 255 ? 255 : g_norxps);
+	p[117] = (uint8_t)(g_rfStallRecover > 255 ? 255 : g_rfStallRecover);
+	// v12: relay-frame TX rate (separated from true poll cycles -- Polls/s now reads ~250, Relay/s exposes the
+	// host output-report flood that steals reply windows), and the clock fingerprint for clone-board triage.
+	p[118] = (uint8_t)g_relayps;
+	p[119] = (uint8_t)(g_relayps >> 8);
+	p[120] = clockLfSrc();
+	p[121] = clockHfSrc();
+	uint16_t upm = clockUsPerMs();
+	p[122] = (uint8_t)upm;
+	p[123] = (uint8_t)(upm >> 8);
+	// last hang's loop stage (0xFF = last boot wasn't a watchdog/lockup hang, or GPREGRET2 didn't survive)
+	p[124] = faultDiagHangStage();
+	// LIVE hang localization: current loop stage + how long loop() has been stuck (40ms units, capped). When
+	// loop() wedges these keep updating because the blob is sent from the SOF interrupt, not loop().
+	p[125] = faultDiagCurStage();
+	uint32_t sms = faultDiagStallMs();
+	p[126] = (uint8_t)(sms / 40 > 255 ? 255 : sms / 40);
+	// relay-ring fault count: non-zero = we caught+recovered a desynced/corrupt ring that would otherwise be an
+	// invisible IRQ-off watchdog hang. A climbing count points at the haptic relay path / memory corruption.
+	p[127] = (uint8_t)g_ringFault;
+	p[128] = (uint8_t)(g_ringFault >> 8);
+	// PC/LR of the stuck code captured by the WDT pre-reset ISR on the last watchdog hang (0 = none captured =
+	// the hang hard-masked interrupts). Map with addr2line on the .elf to name the function.
+	uint32_t hpc = faultDiagHangPC(), hlr = faultDiagHangLR();
+	p[129] = (uint8_t)hpc;
+	p[130] = (uint8_t)(hpc >> 8);
+	p[131] = (uint8_t)(hpc >> 16);
+	p[132] = (uint8_t)(hpc >> 24);
+	p[133] = (uint8_t)hlr;
+	p[134] = (uint8_t)(hlr >> 8);
+	p[135] = (uint8_t)(hlr >> 16);
+	p[136] = (uint8_t)(hlr >> 24);
+	// least-ever free stack (words) on the usbd task (800B total) and loop task -- usbd trending to 0 confirms
+	// the overflow. words, not bytes.
+	uint16_t usbdFree = faultDiagUsbdStackFree(),
+		 loopFree = faultDiagLoopStackFree();
+	p[137] = (uint8_t)usbdFree;
+	p[138] = (uint8_t)(usbdFree >> 8);
+	p[139] = (uint8_t)loopFree;
+	p[140] = (uint8_t)(loopFree >> 8);
+	// v13: per-slot link stats -- each controller's own poll/reply/error rates, so the panel's per-slot tabs
+	// no longer conflate every controller into the aggregate numbers above.
+	for (int s = 0; s < NSLOT; s++) {
+		uint8_t *q = &p[141 + s * 9];
+		q[0] = (uint8_t)g_slotPollsps[s];
+		q[1] = (uint8_t)(g_slotPollsps[s] >> 8);
+		q[2] = (uint8_t)g_slotF1ps[s];
+		q[3] = (uint8_t)(g_slotF1ps[s] >> 8);
+		q[4] = (uint8_t)g_slotNewps[s];
+		q[5] = (uint8_t)(g_slotNewps[s] >> 8);
+		q[6] = g_slotCrcps[s];
+		q[7] = g_slotNoRxps[s];
+		q[8] = g_slotRelayps[s];
+	}
+	// v14: verbatim-0x87-relay experiment toggle (panel reflects + toggles it)
+	p[177] = g_landAll87;
+	// CRITICAL: usb_web.write() SPINS (`while (remain && _connected) yield();`) until the IN FIFO drains or the
+	// panel disconnects. If the panel holds the WebUSB interface open but stops reading its IN endpoint -- a
+	// backgrounded tab, or the host briefly not servicing transferIn under load -- the FIFO never empties and
+	// that spin hangs loop() until the ~8s watchdog resets us (RESETREAS=watchdog/hang). This is the ONE
+	// cross-task tud_ call the marshalling refactor left able to block loop() (HID goes through the drop-oldest
+	// ring; this didn't). So DROP the blob whenever the FIFO can't take it whole -- a status panel missing an
+	// occasional frame is invisible, and loop() can never stall here again. (Matches the closing-the-panel
+	// "fix": that just flips _connected=false to break the same spin.)
+	if (tud_vendor_write_available() >= sizeof p) {
+		usb_web.write(p, sizeof p);
+		usb_web.flush();
+	}
+}
+
+// Bond export frame (panel "Export config to file"). One self-describing frame carrying all four bond slots so
+// the browser can clone a puck onto another without re-pairing:
+//   [0xA7][len][ver=1][usedMask][slot0 rec 24]...[slot3 rec 24]   (len = 2 + NSLOT*24 = 98)
+// usedMask bit s = slot s bonded. The session RF address is NOT exported: each puck re-derives it from the bond
+// UUID + its own FICR DEVICEID at boot, and the bonded controller relearns it from the E1 host beacon on every
+// reconnect (rf_link.cpp) -- so the 24-byte record is the whole portable identity. 98+2 < transferIn(128) and
+// < CFG_TUD_VENDOR_TX_BUFSIZE (256), so it sends whole in one drop-on-full write like the status blob.
+#define WB_BOND_PAYLEN (2 + NSLOT * 24)
+static void webusbSendBondExport()
+{
+	if (!usb_web.connected())
+		return;
+	// static for the same reason as the status blob: keep the 100B frame off the 800B usbd-task stack.
+	static uint8_t p[2 + WB_BOND_PAYLEN];
+	p[0] = 0xA7;
+	p[1] = WB_BOND_PAYLEN;
+	p[2] = 1; // format version
+	uint8_t mask = 0;
+	for (int s = 0; s < NSLOT; s++) {
+		if (g_slot[s].used)
+			mask |= (uint8_t)(1u << s);
+		memcpy(&p[4 + s * 24], g_slot[s].rec, 24);
+	}
+	p[3] = mask;
+	// Same anti-hang rule as the status blob: drop the frame if the FIFO can't take it whole, never spin.
+	if (tud_vendor_write_available() >= sizeof p) {
+		usb_web.write(p, sizeof p);
+		usb_web.flush();
+	}
+}
+
+// Stream the saved (pre-reset) flight-recorder trail to the panel as 0xA8 frames. Always compiled -- the
+// recorder is not OPK_LOG-gated (field hangs happen on release builds too). Frame layout mirrors the 0xA6
+// capture drain so the same drop-on-full discipline applies (a write only issues when the FIFO can take the
+// whole frame -> usb_web.write() never spins -> loop() never stalls here):
+//   header: [0xA8][29][T=2][count u16][total u16][loopPerSec u16][stallMs][curStage][usbdStk u16][loopStk u16]
+//                        [heapUsed u32][pollsps u16][relayps u16][crc u8][norx u8][heal u16][ringF u16]
+//   entry:  [0xA8][9][T=1][dtMs u32][evt][stage][arg u16]     (dtMs = ms before the last recorded crumb)
+//   end:    [0xA8][1][T=0]
+// The panel sends 0x10 with buf[1]=1 to (re)start (emits the header + rewinds the cursor), then 0x10 buf[1]=0
+// repeatedly; each call streams a budgeted chunk and the final call appends the end frame.
+#define WB_FLIGHT_HDR 29
+// RAII boost: these drains run from loop() and push many usb_web.write()s per call -- each enters TinyUSB's
+// dcd DMA claim window (the issue-72 livelock; see usb_tx.cpp), so the whole burst runs boosted.
+struct TxBoostScope {
+	TxBoostScope()
+	{
+		usbTxBoost();
+	}
+	~TxBoostScope()
+	{
+		usbTxUnboost();
+	}
+};
+static void webusbDrainFlight(bool restart)
+{
+	if (!usb_web.connected())
+		return;
+	TxBoostScope boost;
+	if (restart) {
+		faultDiagFlightDrainReset();
+		if (tud_vendor_write_available() >= 2 + WB_FLIGHT_HDR) {
+			struct FaultFlightVitals v;
+			bool have = faultDiagFlightVitals(&v);
+			if (!have)
+				memset(&v, 0, sizeof v);
+			uint16_t cnt = faultDiagFlightCount();
+			uint16_t tot = faultDiagFlightTotal();
+			uint8_t f[2 + WB_FLIGHT_HDR];
+			f[0] = 0xA8;
+			f[1] = WB_FLIGHT_HDR;
+			f[2] = 2; // header type
+			f[3] = (uint8_t)cnt;
+			f[4] = (uint8_t)(cnt >> 8);
+			f[5] = (uint8_t)tot;
+			f[6] = (uint8_t)(tot >> 8);
+			f[7] = (uint8_t)v.loopPerSec;
+			f[8] = (uint8_t)(v.loopPerSec >> 8);
+			f[9] = v.stallMs;
+			f[10] = v.curStage;
+			f[11] = (uint8_t)v.usbdStkFree;
+			f[12] = (uint8_t)(v.usbdStkFree >> 8);
+			f[13] = (uint8_t)v.loopStkFree;
+			f[14] = (uint8_t)(v.loopStkFree >> 8);
+			f[15] = (uint8_t)v.heapUsed;
+			f[16] = (uint8_t)(v.heapUsed >> 8);
+			f[17] = (uint8_t)(v.heapUsed >> 16);
+			f[18] = (uint8_t)(v.heapUsed >> 24);
+			f[19] = (uint8_t)v.pollsps;
+			f[20] = (uint8_t)(v.pollsps >> 8);
+			f[21] = (uint8_t)v.relayps;
+			f[22] = (uint8_t)(v.relayps >> 8);
+			f[23] = (uint8_t)(v.crcps > 255 ? 255 : v.crcps);
+			f[24] = (uint8_t)(v.norxps > 255 ? 255 : v.norxps);
+			f[25] = (uint8_t)v.rfHeal;
+			f[26] = (uint8_t)(v.rfHeal >> 8);
+			f[27] = (uint8_t)v.ringFault;
+			f[28] = (uint8_t)(v.ringFault >> 8);
+			f[29] = (uint8_t)0; // reserved / pad to header len
+			f[30] = (uint8_t)0;
+			usb_web.write(f, sizeof f);
+			usb_web.flush();
+		}
+	}
+	uint32_t dt = 0;
+	uint8_t evt = 0, stage = 0;
+	uint16_t arg = 0;
+	uint16_t budget = 64;
+	bool done = false;
+	while (budget--) {
+		if (tud_vendor_write_available() < 2 + 9)
+			break; // FIFO full -> resume on the panel's next 0x10 poll
+		if (!faultDiagFlightPull(&dt, &evt, &stage, &arg)) {
+			done = true;
+			break;
+		}
+		uint8_t f[2 + 9];
+		f[0] = 0xA8;
+		f[1] = 9;
+		f[2] = 1; // entry type
+		f[3] = (uint8_t)dt;
+		f[4] = (uint8_t)(dt >> 8);
+		f[5] = (uint8_t)(dt >> 16);
+		f[6] = (uint8_t)(dt >> 24);
+		f[7] = evt;
+		f[8] = stage;
+		f[9] = (uint8_t)arg;
+		f[10] = (uint8_t)(arg >> 8);
+		usb_web.write(f, sizeof f);
+		usb_web.flush();
+	}
+	if (done && tud_vendor_write_available() >= 3) {
+		uint8_t e[3] = { 0xA8, 1, 0 };
+		usb_web.write(e, 3);
+		usb_web.flush();
+	}
+}
+
+// Runs on the usbd task (registered via usbTxRegisterDrain -> tud_sof_cb). Sends the blob if loop() asked for
+// one. Keeps every usb_web write/flush off the loop task so it can't block on the device event queue.
+static void webusbSofDrain(void)
+{
+	// If loop() has stopped beating, it's wedged -- keep pushing the blob (which carries the live stuck stage)
+	// so the panel shows WHERE it hung during the ~8s before the watchdog fires. This runs on the SOF IRQ, so
+	// it works even though loop() is dead. drop-on-full in webusbSendBlob keeps it from ever blocking.
+	if (faultDiagStallMs() > 300)
+		g_blobRequest = true;
+	if (g_blobRequest) {
+		g_blobRequest = false;
+		webusbSendBlob();
+	}
+	if (g_bondExportRequest) {
+		g_bondExportRequest = false;
+		webusbSendBondExport();
+	}
+}
+// ---- live wedge reporter (0xA9) --------------------------------------------------------------------------
+// THE one channel that survives a loop() wedge on boards that wipe .noinit/GPREGRET across the watchdog reset
+// (Teyleten): tud_sof_cb runs on the usbd task, which is HIGHER priority than the loop() task, so the USB
+// stack keeps dispatching it even while loop() is wedged (spinning or blocked) -- as long as interrupts aren't
+// hard-masked (PRIMASK). When loop() has been stuck a while, emit a tiny frame carrying the stuck loop stage so
+// the panel can log WHERE it hung, live, during the ~8s before the watchdog fires. Zero traffic when healthy
+// (returns immediately unless stalled); rate-limited; drop-on-full so it never spins the usbd task.
+//   frame: [0xA9][3][stuck stage][stallMs lo][stallMs hi]
+extern "C" void tud_sof_cb(uint32_t frame_count)
+{
+	(void)frame_count;
+	uint32_t stall = faultDiagStallMs();
+	if (stall <
+	    1000) // loop() healthy -> nothing to report (the common case: near-zero overhead)
+		return;
+	static uint32_t lastMs = 0;
+	uint32_t now = millis();
+	if ((uint32_t)(now - lastMs) < 200) // ~5 Hz cap
+		return;
+	lastMs = now;
+	if (!usb_web.connected())
+		return;
+	uint8_t f[2 + 3];
+	f[0] = 0xA9;
+	f[1] = 3;
+	f[2] = faultDiagCurStage();
+	uint16_t s = (stall > 0xFFFFu) ? 0xFFFFu : (uint16_t)stall;
+	f[3] = (uint8_t)s;
+	f[4] = (uint8_t)(s >> 8);
+	if (tud_vendor_write_available() >= sizeof f) {
+		usb_web.write(f, sizeof f);
+		usb_web.flush();
+	}
+}
+
+// Register the SOF drain. Call once from setup() (harmless even if WebUSB isn't enumerated -- webusbSendBlob
+// no-ops while disconnected, and g_blobRequest is only ever set by panel traffic, which needs a connection).
+void webusbInit(void)
+{
+	usbTxRegisterDrain(webusbSofDrain);
+	// Enable SOF-callback dispatch so tud_sof_cb() above fires -- our only live view into a loop() wedge on
+	// boards whose bootloader wipes retained RAM. Cheap: the callback no-ops unless loop() is already stalled.
+	tud_sof_cb_enable(true);
 }
 
 // Send the whole lizard binding table as one 0xA7 frame so the panel can edit it:
@@ -146,7 +462,9 @@ static void webusbSendLizard()
 		count = LZ_MAX_BINDINGS;
 	// static (not stack): 514 B is large for the USB task stack, and webusbPoll is single-threaded.
 	static uint8_t f[2 + LZ_MAX_BINDINGS * 16];
-	f[0] = 0xA7;
+	// 0xA8, NOT 0xA7: the merge with main added bond-export on 0xA7, so the lizard map moved to the next
+	// free device->host marker (0xA5 status, 0xA6 capture, 0xA7 bond export, 0xA8 lizard map).
+	f[0] = 0xA8;
 	f[1] = count;
 	for (uint8_t i = 0; i < count; i++) {
 		const LizardBinding &b = g_lizardMap.bindings[i];
@@ -204,18 +522,27 @@ static void webusbDrainCapture()
 {
 	if (!usb_web.connected())
 		return;
+	TxBoostScope
+		boost; // capture streams at flood rate through the same dcd DMA window
 	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))
+	// Same anti-hang rule as the blob: webusbCapFrame's usb_web.write() spins until the FIFO drains, so stop
+	// pulling the moment the FIFO can't hold a max-size frame (2+9+16=27 B) -- the panel resumes the drain on
+	// its next 0x06 poll. Never let a backed-up panel hang loop() here.
+	while (budget-- && tud_vendor_write_available() >= 27 &&
+	       hapLogPull(&ms, &slot, &rid, &nb, b))
 		webusbCapFrame(ms, slot, rid, nb, b);
-	webusbCapEnd();
+	if (tud_vendor_write_available() >= 3)
+		webusbCapEnd();
 }
 #endif // OPK_LOG
 void webusbPoll()
 {
-	// 32B: the lizard "set binding" command (0x0E) is 18 bytes; every other command is <=4.
-	static uint8_t buf[32];
+	// 40 B holds the largest command: 0x0D writes one bond slot = [0x0D][slot][used][24 rec] = 27 B (still
+	// inside one 64-B USB-FS OUT packet, so no command ever spans packets). The lizard "set binding" command
+	// (0x12) is 18 B; every other command is <= 4 B.
+	static uint8_t buf[40];
 	static uint8_t n = 0;
 	while (usb_web.available()) {
 		int c = usb_web.read();
@@ -228,23 +555,47 @@ void webusbPoll()
 			if (n == 0)
 				break;
 			uint8_t op = buf[0];
-
-			// 0x05/0x0F carry one value byte; 0x02 carries field+value; 0x0A carries a 3-byte
-			// magic; 0x0E carries idx + a 16-byte serialized lizard binding (18 total).
-			uint8_t need = (op == 0x0E)		  ? 18 :
-				       (op == 0x02)		  ? 3 :
-				       (op == 0x03 || op == 0x05 ||
-					op == 0x0F)		  ? 2 :
-				       (op == 0x0A)		  ? 4 :
-								    1;
-			if (op < 0x01 || op > 0x11) { // resync: drop one byte
+			if (op < 0x01 || op > 0x15) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				continue;
 			}
+			// Command length (fixed per opcode). 0x0D = write-one-bond-slot (27 B); 0x12 = set-one-lizard-
+			// binding (18 B); 0x05/0x0E/0x0F/0x10/0x13 carry one value byte; 0x02 a field+value; 0x0A a
+			// 3-byte magic. (0x11/0x14/0x15 are bare opcodes -> default 1.)
+			uint8_t need = (op == 0x0D) ? 27 :
+				       (op == 0x12) ? 18 :
+				       (op == 0x02) ? 3 :
+				       (op == 0x03 || op == 0x05 ||
+					op == 0x0E || op == 0x0F ||
+					op == 0x10 || op == 0x13) ?
+						      2 :
+				       (op == 0x0A) ? 4 :
+						      1;
 			if (n < need)
 				break; // wait for more bytes
 			if (op == 0x01) {
-				webusbSendBlob();
+				g_blobRequest =
+					true; // sent from the usbd task (webusbSofDrain)
+			}
+			// 0x09: export all bond slots (clone backup). One 0xA7 frame, sent from the usbd task.
+			else if (op == 0x09) {
+				g_bondExportRequest = true;
+			}
+			// 0x0F: stability test on/off. Puck->controller haptics do NOT reset the controller's own
+			// user-input idle auto-off (we already poll it every 4ms without keeping it awake), so instead
+			// signal host-awake: enable the E7 announce (0xE7 00 00 = host-awake vs 00 01 = suspended, per
+			// the RE) so the controller is told the host is active and (hopefully) doesn't power-save. The
+			// 10s buzz stays -- it also exercises the haptic-relay path that correlates with the hang.
+			else if (op == 0x0F) {
+				g_stabTest = (buf[1] != 0);
+				g_e7b = 0; // byte2=0 => host-awake
+				g_e7announce = g_stabTest;
+			}
+			// 0x10: stream the saved flight-recorder trail (0xA8 frames). buf[1]=1 restarts (header + rewind),
+			// buf[1]=0 continues the drain. Drained straight from loop context like the 0x06 capture drain --
+			// webusbDrainFlight is drop-on-full so it never blocks. Always available (recorder isn't OPK_LOG).
+			else if (op == 0x10) {
+				webusbDrainFlight(buf[1] != 0);
 			}
 #if OPK_LOG
 
@@ -275,6 +626,7 @@ void webusbPoll()
 					usb_web.flush();
 					factoryErase();
 					delay(40);
+					faultDiagArmIntentionalReset();
 					NVIC_SystemReset();
 				}
 
@@ -282,28 +634,66 @@ void webusbPoll()
 			} else if (op == 0x0B) {
 				usb_web.flush();
 				delay(40);
+				faultDiagArmIntentionalReset();
 				enterSerialDfu();
 
 				// reboot into UF2 bootloader (USB mass storage)
 			} else if (op == 0x0C) {
 				usb_web.flush();
 				delay(40);
+				faultDiagArmIntentionalReset();
 				enterUf2Dfu();
 
-				// ---- lizard (desktop) binding map editor ----
-				// 0x0D: dump the current map as a 0xA7 frame.
+				// 0x0D: write ONE bond slot into RAM -- the "clone onto this puck" side of Export/Import.
+				// [0x0D][slot][used][24 rec]. used=0 (or an empty record) clears the slot. The panel sends
+				// one of these per slot, then a 0x0E to persist + reboot. Kept to a single small command
+				// (<=27 B) so it never spans a USB packet, and acked with a status blob so the panel's
+				// read-after-write keeps the OUT pipe flowing (same shape as the proven 0x02 setters).
 			} else if (op == 0x0D) {
+				uint8_t slot = buf[1], used = buf[2];
+				if (slot < NSLOT) {
+					if (used && !recEmpty(buf + 3)) {
+						memcpy(g_slot[slot].rec,
+						       buf + 3, 24);
+						g_slot[slot].used = true;
+					} else {
+						memset(g_slot[slot].rec, 0, 24);
+						g_slot[slot].used = false;
+					}
+				}
+				g_blobRequest = true; // ack
+
+				// 0x0E: commit an import -- persist the bond slots written by 0x0D, optionally apply a USB
+				// mode ([0x0E][mode]; 0xFF = leave unchanged), then reboot so the RF session addresses
+				// regenerate from the new bond UUIDs.
+			} else if (op == 0x0E) {
+				uint8_t mode = buf[1];
+				saveBonds();
+				if (modeValid(mode))
+					saveMode(mode);
+				usb_web.flush();
+				delay(40);
+				faultDiagArmIntentionalReset();
+				NVIC_SystemReset();
+
+				// ---- lizard (desktop) binding map editor ----
+				// Renumbered to 0x11..0x15 on the merge with main: main already owns 0x0D (bond-slot
+				// write), 0x0E (import commit), 0x0F (stability test) and 0x10 (flight-recorder drain),
+				// which the pre-merge branch had collided with. The panel JS uses the same 0x11..0x15.
+				// 0x11: dump the current map as a 0xA7 frame.
+			} else if (op == 0x11) {
 				webusbSendLizard();
 
-				// 0x0F [count]: BEGIN a map edit -- set the binding count (clamped). The
-				// panel then sends one 0x0E per binding (0..count-1) and a 0x10 to persist.
-			} else if (op == 0x0F) {
-				g_lizardMap.count = (buf[1] <= LZ_MAX_BINDINGS) ?
-							    buf[1] :
-							    LZ_MAX_BINDINGS;
+				// 0x13 [count]: BEGIN a map edit -- set the binding count (clamped). The
+				// panel then sends one 0x12 per binding (0..count-1) and a 0x14 to persist.
+			} else if (op == 0x13) {
+				g_lizardMap.count =
+					(buf[1] <= LZ_MAX_BINDINGS) ?
+						buf[1] :
+						LZ_MAX_BINDINGS;
 
-				// 0x0E [idx][outType][od0..6][trig LE4][hold LE4]: set one binding.
-			} else if (op == 0x0E) {
+				// 0x12 [idx][outType][od0..6][trig LE4][hold LE4]: set one binding.
+			} else if (op == 0x12) {
 				uint8_t idx = buf[1];
 				if (idx < LZ_MAX_BINDINGS) {
 					LizardBinding &b =
@@ -321,13 +711,13 @@ void webusbPoll()
 						     ((uint32_t)buf[17] << 24);
 				}
 
-				// 0x10: COMMIT the edited map to flash and echo it back.
-			} else if (op == 0x10) {
+				// 0x14: COMMIT the edited map to flash and echo it back.
+			} else if (op == 0x14) {
 				saveLizardMap();
 				webusbSendLizard();
 
-				// 0x11: reset the map to the built-in defaults, persist, echo back.
-			} else if (op == 0x11) {
+				// 0x15: reset the map to the built-in defaults, persist, echo back.
+			} else if (op == 0x15) {
 				defaultLizardMap();
 				saveLizardMap();
 				webusbSendLizard();
@@ -337,8 +727,8 @@ void webusbPoll()
 
 				// every settable field persists (poll rate is no longer settable)
 				bool persist = true;
-				// per-type cfg writes (protocol v9): field = 40 + et*8 + k, k: 0..3 back, 4 qam, 5 abSwap,
-				// 6 padHaptics. Edits g_type[et]; refresh the live mirrors if it's the active type.
+				// per-type cfg writes (protocol v10): field = 40 + et*8 + k, k: 0..3 back, 4 qam, 5 abSwap,
+				// 6 padHaptics, 7 ledBright. Edits g_type[et]; refresh the live mirrors if it's the active type.
 				if (f >= 40 && f < 40 + ET_COUNT * 8) {
 					uint8_t et = (uint8_t)((f - 40) / 8),
 						k = (uint8_t)((f - 40) % 8);
@@ -353,11 +743,15 @@ void webusbPoll()
 						else if (k == 6)
 							g_type[et].padHaptics =
 								v ? 1 : 0;
+						else if (k == 7)
+							g_type[et].ledBright =
+								v > 100 ? 100 :
+									  v;
 						if (et == g_etype)
 							applyActiveType();
 					}
 					saveCfg();
-					webusbSendBlob();
+					g_blobRequest = true;
 					memmove(buf, buf + need, n - need);
 					n -= need;
 					continue;
@@ -436,6 +830,7 @@ void webusbPoll()
 					armDebugCdcNextBoot();
 					usb_web.flush();
 					delay(40);
+					faultDiagArmIntentionalReset();
 					NVIC_SystemReset();
 					break;
 
@@ -464,17 +859,28 @@ void webusbPoll()
 					swProSaveCfg();
 					persist = false;
 					break; // Switch Pro gyro scale x10
+
+					// (field 25, poll RX window, removed -- g_rxWin is now FIXED/not configurable)
+					// (fields 27/28, post-connect haptic block, removed -- permanently disabled)
+
+				// EXPERIMENT: land ALL relayed 0x87 config verbatim (real-puck relay)
+				// instead of the discard-whitelist. Persisted; blob p[177] reflects state.
+				case 29:
+					g_landAll87 = v ? 1 : 0;
+					break;
 				}
 				if (persist)
 					saveCfg();
-				webusbSendBlob();
+				g_blobRequest = true;
 			} else if (op == 0x03) {
 				uint8_t m = buf[1];
 				if (modeValid(m) && !USBDevice.suspended()) {
-					webusbSendBlob();
-					usb_web.flush();
+					// best-effort status to the panel before the reboot; the SOF drain
+					// sends it during the delay(40) below (no blocking flush on loop()).
+					g_blobRequest = true;
 					saveMode(m);
 					delay(40);
+					faultDiagArmIntentionalReset();
 					NVIC_SystemReset();
 				}
 			}

+ 1 - 0
OpenPuck/webusb_config.h

@@ -12,4 +12,5 @@
 
 extern Adafruit_USBD_WebUSB usb_web;
 
+void webusbInit(); // register the SOF-driven blob sender; call once from setup() after usbTxBegin()
 void webusbPoll(); // drain host commands + reply; call every loop()

+ 21 - 11
README.md

@@ -1,8 +1,8 @@
 # OpenPuck
-Opensource firmware for NRF52840 Pro Micro that copycats the Steam Controller 2 Puck and adds an Xbox and Switch mode.
+Opensource firmware for NRF52840 Pro Micro that copycats the Steam Controller 2 Puck and allows emulation of Xbox, Switch, and PS3/4/5 controllers and also includes an independant lizard mode (which can work on UAC prompts/task manager/etc). The Switch and PS3 modes have been verified to work on real consoles and Switch, PS4/5 modes have gyro (and touchpad where available) hooked in. Back 4 buttons are mappable for all emulated modes.
 
 > [!WARNING]
-> Every part of this project _HEAVILY_ used LLMs. Everything from discovery of the protocol to writing the arduino sketch and running various automated benchmarks invovled Claude and Codex. This readme is the only organic, single origin, ethically sourced and humanely slaughtered assemblage of words in this project. I have done my best to review the code and I invite anyone concerned about the stability or security of this project to do the same.*
+> Every part of this project _HEAVILY_ used LLMs*
 
 # The Steam Controller 2
 Released in 2026, the Steam Controller 2 represents the peak (IMO) of controller design. Trackpads, gyro, 4 back buttons, all with the flexibility of Steam Input brings the amazing flexiblity of the Steam Deck's controls to gaming PCs in general.
@@ -12,7 +12,7 @@ The "puck" is what the controller uses for wireless communication with the host
 # The Problem
 There are two fundamental problems with the controller:
 
-1. The puck is not (yet) available for purchase separately from the controller so if you want a replacement or a second one (a single controller can pair to two) you're out of luck.
+1. The puck is not (yet) available for purchase separately from the controller so if you want a replacement or a second one (a single controller can pair to two) you're out of luck. Given Valve's track record I predict it'll never be available to buy as a separate accessory.
 
 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.
 
@@ -32,12 +32,13 @@ Similarly you can hold all 4 back buttons and press Y to switch (teehee) over to
 | back-4 + A | Steam | Steam Controller Mode |
 | back-4 + B | Lizard | Lizard mode, even if Steam is open |
 | back-4 + X | Xbox | Xbox 360 Controller |
-| back-4 + Y | Swith Pro | Switch Controller + Gyro + Haptics |
+| back-4 + Y | Switch Pro | Switch Controller + Gyro + Haptics |
 | WebUSB panel → mode 4 | Hori Pad | Switch mode with no gyro or haptics |
 | WebUSB panel → mode 5 | DualSense + Gyro + Trackpad | PC only |
 | WebUSB panel → mode 6 | DS4/HIDGYRO + Gyro + Trackpad | PC only |
+| WebUSB panel → mode 9 | PS3 DualShock 3 / Sixaxis | Enumerates on a real PS3 (+ gyro/haptics) |
 
-I'm also adding various QOL items as I go as well. For example having to hold the Steam button for like 6 seconds feels like an eternity. If Steam is open you can do Steam +Y for a shutdown. I'm adding Steam + Y for 2 seconds as a shutdown chort in ALL modes now.
+I'm also adding various QOL items as I go as well. For example having to hold the Steam button for like 6 seconds feels like an eternity. If Steam is open you can do Steam + Y for a shutdown. I'm adding Steam + Y for 2 seconds as a shutdown chort in ALL modes now.
 
 Note: to use the Switch mode on a real Switch you'll need to [enable the pro controller wired communication option](https://www.nintendo.com/en-gb/Support/Troubleshooting/How-to-Enable-Disable-Pro-Controller-Wired-Communication-1516284.html).
 
@@ -49,7 +50,9 @@ Additionally it has some shortcuts that might be useful: Steam + L5/R5 will do v
 and Steam + L4 ls Ctrl + Alt + Delete.
 
 # How to install/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.
+You will need to flash OpenPuck.ino onto a compatible microcontroller. The easiest way to do this is to grab a uf2 file from the GitHub releases and drag and drop it onto the folder that the microcontroller mounts when in DFU mode. Fresh microcontrollers should already be in DFU mode and present like a flash drive. If they are not in DFU mode you'll need to short the RST and GND pints twice in quick succession. If you've already flashed openpuck you can go to the [webusb configurator](https://safijari.github.io/openpuck/), connect to the OpenPuck, and then press the `UF2 DFU` button on the top bar.
+
+See [build instructions document](./docs/BUILD_AND_DEPLOY.md) for  details on how to flash the MCU during development.
 
 # Pairing
 Both OpenPuck and the controller need to be hooked up to the same machine with a data capable USB C cable at the same time and Steam must be running. Steam should in most cases automatically pop up a menu to pair the controller. If not, you can go to Settings -> Controllers and press "Add Controller".
@@ -61,24 +64,30 @@ Switching slots requires turning the controller off (Steam + Y if steam is runni
 # Configuration
 A webusb based configuration UI is available [here](https://safijari.github.io/openpuck/). It allows Switching the mode manually and changing the back button mapping for other modes among other things. This will likely only work in Chrome and Edge and needs the pro micro to be connected via USB to the same computer for it to function. Note that it might not work in all modes on all machines but should always work in the Steam Controller mode (which you can revert to with back-4 + A).
 
+If you're running Linux and your browser still shows "disconnected" after selecting the OpenPuck in the device selector, it's probably a permissions issue. Check [this document](./docs/WEBUSB_LINUX.md) for more details. 
+
+You can copy configurations between OpenPucks using the export/import card in this webusb UI as well. This allows for some interesting [hotswapping capability](https://www.youtube.com/watch?v=6RnsXVlHAoM) where controllers can switch between pucks without needing to swap slots.
+
 # 3D Printed Cases
 - [jaki-gh](https://github.com/jaki-gh) has contributed a 3D printable housing with OpenPuck written on it alongside a Steam logo. You can find that [here](https://www.thingiverse.com/thing:7371668).
 - [StonnedModder](https://www.printables.com/model/1760684-openpuck-promicro-nrf52840-case) built a case meant to accomodate a USB C to USB A adapter which you can find [here](https://www.printables.com/model/1760684-openpuck-promicro-nrf52840-case).
 - Another plain case for these pro micros can be found [here](https://www.printables.com/model/1285346-pro-micro-nicenano-nrf52840-dongle-case/collections).
 
 # ReversePuck
-This is a tool to emulate a Steam Controller 2 with almost all of its inputs (except grip) using a Steam Deck and allow it to connect over a low latency 2.4ghz connection to OpenPuck (this does not work with the official puck). Flash the firmware onto an NRF52840 Pro Micro and copy over the ReversePuck folder onto the Steam Deck (you'll need to install UV). Then add the ReversePuck script as a non steam game. Attach both the dongle and OpenPuck to the same machine and do pairing through Steam (which will say pairing has failed but it's actually fine). Then connect the OpenPuck to whatever machine you want to use your controllers on and the dongle to the Steam Deck. Launch the ReversePuck app in gamemode and you'll see the serial number of the OpenPuck show up in green. Press it and it'll turn blue at which point the Deck will show up as a controller on the host device. See video below for a demonstration of this in action (yes, other Steam Controllers can be connected to the same OpenPuck at the same time).
+This is a tool to emulate a Steam Controller 2 with almost all of its inputs (except grip) using a Steam Deck and allow it to connect over a low latency 2.4ghz connection to OpenPuck (this does not work with the official puck yet). Flash the firmware onto an NRF52840 Pro Micro and copy over the ReversePuck folder onto the Steam Deck (you might need to install UV). Then add the ReversePuck script as a non steam game. Attach both the dongle and OpenPuck to the same machine and do pairing through Steam (which will say pairing has failed but it's actually fine). Then connect the OpenPuck to whatever machine you want to use your controllers on and the dongle to the Steam Deck. Launch the ReversePuck app in gamemode and you'll see the serial number of the OpenPuck show up in green. Press it and it'll turn blue at which point the Deck will show up as a controller on the host device. See video below for a demonstration of this in action (yes, other Steam Controllers can be connected to the same OpenPuck at the same time).
 
 [![ReversePuck Demo](https://img.youtube.com/vi/q_AvvpFn4A8/0.jpg)](https://www.youtube.com/watch?v=q_AvvpFn4A8)
 
 # Future work
 - Find a way to make Xinput mode and mouse work together on all platforms
 - Design the charging portion (and make it short proof)
+- Make ReversePuck into a system that you can plug into most controllers and allow them to talk to OpenPuck
+- A BLE version of OpenPuck that can allow Steam Controllers and other BLE controllers to coexist
 
 # 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've been unable to do full end-to-end multi-controller hardware testing — the per-slot session address, per-slot USB fanout, and per-slot haptic demux are in place and the design is sound, but the `U` CDC command exists specifically so anyone with a second controller can verify the per-slot state at a glance. Please submit issues with any issues you find. PRs also welcome of course.
+I have tested this software fairly extensively but I have limited resources. Please submit issues with any issues you find. PRs also welcome of course.
 
 # Acknowledgements
 - Valve for putting out the amazing controller
@@ -87,8 +96,9 @@ I have tested this software fairly extensively but I have limited resources. I o
 - https://github.com/knflrpn/2wiCC for the Switch Pro controller mode help
 - Massive thanks to [u/Careful_Tune4744](https://www.reddit.com/user/Careful_Tune4744/) for latency testing as well as testing and giving feedback on the Switch Pro mode
 - Thanks to Lawstorant from a mutual discord server for constructive criticism of the repo's state
+- Everyone that participated in [issue #17](https://github.com/safijari/openpuck/issues/72) or reported/tested stability issues on various boards
 
-# * Another note on LLM use
-I want nothing more than for there to be a fully human coded alternative to OpenPuck that's on par with or better in every way. I would love to stop working on OpenPuck.
+# * On LLM Use 
+Everything from discovery of the protocol to writing the arduino sketch and running various automated benchmarks invovled Claude and Codex. This readme is the only organic, single origin, ethically sourced and humanely slaughtered assemblage of words in this project. I have done my level best to review the code and I invite anyone concerned about the stability or security of this project to do the same. I test thoroughly and obsessively as the primary purpose of this project is to bring some much needed QOL to my own use of the Steam Controller.
 
-If someone builds an alternative that matches this criteria I would happily change the name of this project and archive this repo.
+I want nothing more than for there to be a fully human coded alternative to OpenPuck that's on par with or better in every way. I would love to stop working on OpenPuck. If someone builds an alternative that matches this criteria I would also happily change the name of this project and archive this repo.

+ 120 - 27
ReversePuck/ui.py

@@ -1,12 +1,22 @@
 """ui.py -- fullscreen touchscreen UI for the Steam Deck forwarder.
 
 Big tappable tiles, one per bonded puck. A tile is tappable when its puck is LIVE (the RF link is up).
-Tapping a live tile detaches the Deck controls and forwards to that puck; tapping anywhere while
-forwarding stops and returns control to the Deck. Sized for the Deck's 1280x800 touchscreen but scales.
+Tapping a live tile detaches the Deck controls and forwards to that puck (a single tap -- connecting is
+cheap and reversible). The two DESTRUCTIVE actions -- stopping/disconnecting a forward, and removing a
+bond (unpair) -- instead require a HOLD (HOLD_SECS): an errant screen tap mid-game used to drop the forward
+(which on the puck side tears down a USB device and could reboot the MCU), so a brief touch must no longer
+trigger it. A progress fill shows the hold; lifting before it completes cancels. Sized for the Deck's
+1280x800 touchscreen but scales.
+
+Status icons (dots, the remove ✕) are drawn with primitives, not font glyphs -- the Deck's available fonts
+don't carry ● ○ ✕ etc., so rendering them as text just showed blank/boxes.
 """
 import time
 import pygame
 
+# Destructive actions (stop forwarding, remove bond) require holding this long; a tap no longer triggers them.
+HOLD_SECS = 2.0
+
 BG = (16, 18, 24)
 FG = (230, 232, 238)
 DIM = (120, 126, 140)
@@ -24,6 +34,23 @@ def _font(sz, bold=False):
     return f
 
 
+def _draw_dot(screen, center, r, color, filled=True):
+    """Status dot drawn with primitives -- font glyphs (● ○) don't render on the Deck's fallback font."""
+    if filled:
+        pygame.draw.circle(screen, color, center, r)
+    else:
+        pygame.draw.circle(screen, color, center, r, max(2, r // 3))
+
+
+def _draw_cross(screen, rect, color, width):
+    """Remove-bond ✕ drawn as two diagonals (the font glyph doesn't render)."""
+    pad = max(6, rect.width // 4)
+    pygame.draw.line(screen, color, (rect.x + pad, rect.y + pad),
+                     (rect.right - pad, rect.bottom - pad), width)
+    pygame.draw.line(screen, color, (rect.right - pad, rect.y + pad),
+                     (rect.x + pad, rect.bottom - pad), width)
+
+
 def run(app):
     pygame.init()
     pygame.display.set_caption("ReversePuck · Steam Deck")
@@ -37,21 +64,37 @@ def run(app):
 
     tile_rects = []  # (rect, slot, tappable)
     del_rects = []   # (rect, slot) -- the per-tile "remove bond" ✕ target
+    # Active hold-to-confirm gesture, or None. dict(kind="stop"|"unpair", slot, start). Read by draw() for
+    # the progress fill; set in handle_down()/cleared in handle_up() and on completion.
+    hold = None
+
+    def hold_progress():
+        """0.0..1.0 of the current hold, or None if no hold is active."""
+        if hold is None:
+            return None
+        return max(0.0, min(1.0, (time.monotonic() - hold["start"]) / HOLD_SECS))
 
     def draw():
         nonlocal tile_rects, del_rects
         screen.fill(BG)
         s = app.status
         connected = app.dongle_connected
-        # compact status line (no big title banner): dongle presence first, then RF link
+        # compact status line (no big title banner): dongle presence first, then RF link. The leading status
+        # dot is drawn (font glyphs don't render); filled = linked, ring = no-dongle / searching.
         if not connected:
-            badge, col = "⨯ no dongle", WARN
+            badge, col = "no dongle", WARN
         elif s["link_up"]:
-            badge, col = "● linked  ch%d" % s["sess_ch"], LIVE
+            badge, col = "linked  ch%d" % s["sess_ch"], LIVE
         else:
-            badge, col = "○ searching…", DIM
+            badge, col = "searching...", DIM
         t = f_med.render(badge, True, col)
-        screen.blit(t, (W - t.get_width() - 40, 24))
+        bx = W - t.get_width() - 40
+        screen.blit(t, (bx, 24))
+        _draw_dot(screen, (bx - 18, 24 + t.get_height() // 2), 8, col,
+                  filled=connected and s["link_up"])
+        # legend (top-left): how the gestures work
+        screen.blit(f_small.render("Tap to connect, hold to disconnect or remove", True, DIM),
+                    (40, 30))
 
         # tiles -- live pucks float to the top, then by slot index
         tile_rects = []
@@ -64,7 +107,7 @@ def run(app):
         if not connected:
             msg = f_big.render("Plug in the ReversePuck dongle", True, WARN)
             screen.blit(msg, (W // 2 - msg.get_width() // 2, H // 2 - 40))
-            sub = f_small.render("Waiting for the nRF (Valve 28DE:1302) on USB… it'll appear here automatically.",
+            sub = f_small.render("Waiting for the nRF (Valve 28DE:1302) on USB... it'll appear here automatically.",
                                  True, DIM)
             screen.blit(sub, (W // 2 - sub.get_width() // 2, H // 2 + 20))
         elif not bonds:
@@ -82,17 +125,16 @@ def run(app):
             color = TILE_SEL if sel else (TILE_LIVE if b["alive"] else TILE)
             pygame.draw.rect(screen, color, rect, border_radius=18)
             pygame.draw.rect(screen, (ACCENT if (tappable or sel) else DIM), rect, width=2, border_radius=18)
-            # glyph + serial
-            glyph = "●" if b["alive"] else "○"
+            # status dot (drawn -- font ● ○ don't render) + serial
             gcol = LIVE if b["alive"] else DIM
-            screen.blit(f_big.render(glyph, True, gcol), (rect.x + 30, rect.y + th // 2 - 22))
+            _draw_dot(screen, (rect.x + 46, rect.y + th // 2), 13, gcol, filled=b["alive"])
             screen.blit(f_big.render(b["serial"] or "puck %d" % slot, True, FG),
                         (rect.x + 90, rect.y + 20))
-            state = ("FORWARDING — tap to stop" if sel
-                     else "available — tap to forward" if tappable
+            state = ("FORWARDING - hold anywhere to stop" if sel
+                     else "available - tap to forward" if tappable
                      else "live (forwarding elsewhere)" if b["alive"]
-                     else "paired · offline")
-            screen.blit(f_small.render("slot %d · %s" % (slot, state), True, DIM),
+                     else "paired - offline")
+            screen.blit(f_small.render("slot %d - %s" % (slot, state), True, DIM),
                         (rect.x + 90, rect.y + th - 38))
             tile_rects.append((rect, slot, tappable or sel))
             # remove-bond ✕ on the far right (only when idle -- can't un-bond while forwarding)
@@ -100,10 +142,16 @@ def run(app):
                 dsz = min(th - 24, 64)
                 drect = pygame.Rect(rect.right - dsz - 18, rect.y + (th - dsz) // 2, dsz, dsz)
                 pygame.draw.rect(screen, TILE, drect, border_radius=12)
+                # while this ✕ is being held, fill it from the bottom up as a hold-to-unpair progress meter
+                held = (hold is not None and hold["kind"] == "unpair"
+                        and hold["slot"] == slot)
+                hp = hold_progress()
+                if held and hp:
+                    fh = int(dsz * hp)
+                    fill = pygame.Rect(drect.x, drect.bottom - fh, dsz, fh)
+                    pygame.draw.rect(screen, STOP, fill, border_radius=12)
                 pygame.draw.rect(screen, STOP, drect, width=2, border_radius=12)
-                x = f_big.render("✕", True, STOP)
-                screen.blit(x, (drect.centerx - x.get_width() // 2,
-                                drect.centery - x.get_height() // 2))
+                _draw_cross(screen, drect, FG if held else STOP, 4)
                 del_rects.append((drect, slot))
 
         # firmware log panel (the in-app serial monitor) — fills the area below the tiles
@@ -128,26 +176,67 @@ def run(app):
         if app.forwarding:
             bar = pygame.Rect(0, H - int(H * 0.1), W, int(H * 0.1))
             pygame.draw.rect(screen, STOP, bar)
-            msg = f_big.render("FORWARDING — Deck controls detached · tap anywhere to stop", True, (255, 255, 255))
+            hp = hold_progress()
+            if hold is not None and hold["kind"] == "stop" and hp:
+                # fill the banner left->right as the stop hold progresses; lighten so the fill reads as a meter
+                pygame.draw.rect(screen, (255, 150, 150),
+                                 pygame.Rect(bar.x, bar.y, int(bar.width * hp), bar.height))
+                left = max(0.0, HOLD_SECS * (1.0 - hp))
+                msg = f_big.render("KEEP HOLDING TO STOP - %.0fs" % (left + 0.99),
+                                   True, (255, 255, 255))
+            else:
+                msg = f_big.render("FORWARDING - Deck controls detached - hold to stop",
+                                   True, (255, 255, 255))
             screen.blit(msg, (W // 2 - msg.get_width() // 2, bar.y + bar.height // 2 - 22))
         pygame.display.flip()
 
-    def handle_tap(pos):
-        # Taps only ENQUEUE intents; the IO thread runs the actual grab()/send() so they never race the
-        # forwarding thread's serial/libusb access.
+    def handle_down(pos):
+        # Press handler. CONNECT (toggle a live tile) fires immediately -- it's cheap and reversible. The
+        # destructive actions (STOP a forward, REMOVE a bond) instead ARM a hold; they only fire from
+        # check_hold() once HOLD_SECS elapses, so an errant tap can't trigger them. Actions still only
+        # ENQUEUE intents so they never race the IO thread's serial/libusb access.
+        nonlocal hold
+        if hold is not None:
+            return  # a hold is already in progress; ignore re-entrant/duplicate down events
         if app.forwarding:
-            app.request_stop()
+            # hold anywhere on the screen to stop forwarding
+            hold = {"kind": "stop", "slot": None, "start": time.monotonic()}
             return
-        # the remove-bond ✕ takes priority over the tile body it sits on
+        # the remove-bond ✕ takes priority over the tile body it sits on -- hold it to unpair
         for drect, slot in del_rects:
             if drect.collidepoint(pos):
-                app.request_remove(slot)
+                hold = {"kind": "unpair", "slot": slot, "start": time.monotonic()}
                 return
+        # live tile: connecting/forwarding stays a single tap
         for rect, slot, tappable in tile_rects:
             if rect.collidepoint(pos) and tappable:
                 app.request_toggle(slot)
                 return
 
+    def handle_up():
+        # Lifting (or a stray up) before HOLD_SECS cancels the pending destructive action.
+        nonlocal hold
+        hold = None
+
+    def check_hold():
+        # Fire the held action once it has been sustained long enough, then disarm. Also cancel a hold whose
+        # target went away (the forward auto-released, or forwarding started so a bond can't be removed).
+        nonlocal hold
+        if hold is None:
+            return
+        if hold["kind"] == "stop" and not app.forwarding:
+            hold = None
+            return
+        if hold["kind"] == "unpair" and app.forwarding:
+            hold = None
+            return
+        if time.monotonic() - hold["start"] >= HOLD_SECS:
+            if hold["kind"] == "stop":
+                app.request_stop()
+            elif hold["kind"] == "unpair":
+                app.request_remove(hold["slot"])
+            hold = None
+
     running = True
     while running:
         for ev in pygame.event.get():
@@ -156,9 +245,13 @@ def run(app):
             elif ev.type == pygame.KEYDOWN and ev.key in (pygame.K_ESCAPE, pygame.K_q):
                 running = False
             elif ev.type == pygame.MOUSEBUTTONDOWN:
-                handle_tap(ev.pos)
+                handle_down(ev.pos)
             elif ev.type == pygame.FINGERDOWN:
-                handle_tap((int(ev.x * W), int(ev.y * H)))
+                handle_down((int(ev.x * W), int(ev.y * H)))
+            elif ev.type in (pygame.MOUSEBUTTONUP, pygame.FINGERUP):
+                handle_up()
+
+        check_hold()  # promote a sustained hold into the actual stop/unpair intent
 
         # Render only -- serial I/O + input forwarding run on app._io_loop (a separate thread) so the
         # vsync'd flip() below can't throttle forwarding to 60Hz. A bad draw must never kill the app.

+ 36 - 10
docs/BUILD_AND_DEPLOY.md

@@ -49,24 +49,48 @@ arduino-cli core install adafruit:nrf52 --additional-urls https://adafruit.githu
 From the repository root:
 
 ```bash
-arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4" OpenPuck
+make build
 ```
 
-This sketch requires `CFG_TUD_HID=4` because Steam mode exposes four HID interfaces.
+That's the whole command — the USB flags the firmware needs are baked in, so you don't pass them yourself:
 
-**Build provenance.** `gen_version.sh` writes `OpenPuck/git_version.h` with the current commit's short hash and a dirty flag (1 if the working tree has any tracked change or untracked file). The firmware bakes these in and reports them over WebUSB, so the panel's **Build (git)** field shows exactly which commit is flashed and whether it was a clean checkout — handy for confirming what's on a board. The header is git-ignored; if you skip the script the build still succeeds and the panel shows `unknown`. You can also inject the values directly instead of using the script, e.g. append ``-DOPK_GIT_HASH=\"$(git rev-parse --short=8 HEAD)\" -DOPK_GIT_DIRTY=0`` to `build.extra_flags`.
+- `CFG_TUD_HID=4` — Steam mode exposes four HID interfaces (the Adafruit nRF core defaults to 2).
+- `CFG_TUD_TASK_QUEUE_SZ=64` — a deeper TinyUSB device event queue; the default of 16 can deadlock the firmware's loop under heavy USB traffic and trip the watchdog.
+- `CFG_TUD_VENDOR_TX_BUFSIZE=256` — the WebUSB status blob (~118 B) must fit the vendor TX FIFO in one write; the default 64 is too small and the panel (which drops frames rather than block the loop) would send nothing — a blank dashboard.
 
-## 5. Upload the firmware
+**Overriding the defaults** (only if you need to) — pass them as `make` variables:
 
-### macOS / Linux
+```bash
+make build CFG_TUD_HID=6 CFG_TUD_TASK_QUEUE_SZ=128   # different interface count / queue depth
+make build EXTRA_FLAGS="-DOPK_LOG=1"                  # add your own defines
+make build FQBN=adafruit:nrf52:somethingelse          # a different nRF52840 board
+```
 
-Find the board port:
+**Calling `arduino-cli` directly** instead of `make`? Then you must supply the flags yourself — the build
+`#error`s without them (so a forgotten flag fails loudly instead of shipping a broken/deadlock-prone image):
 
 ```bash
-arduino-cli board list
+arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DCFG_TUD_TASK_QUEUE_SZ=64 -DCFG_TUD_VENDOR_TX_BUFSIZE=256" OpenPuck
 ```
 
-Upload:
+## 5. Upload the firmware
+
+The quickest path is `make`. The serial port is a **required argument** (find it with `arduino-cli board list`):
+
+```bash
+make flash /dev/cu.usbmodem1101    # upload the most recent build to that port
+make deploy /dev/cu.usbmodem1101   # build + flash in one step (same build overrides as `make build`)
+```
+
+Use the port for your OS: macOS `/dev/cu.usbmodem*`, Linux `/dev/ttyACM0`, Windows `COM5`.
+
+> **DFU note:** in puck (Steam/Lizard) mode the firmware drops the CDC serial port to free a USB endpoint, so `arduino-cli` can't auto-reset the board into its bootloader. If the upload can't connect, put the board in DFU mode first by **double-tapping RST**, then `make flash <bootloader-port>` (re-check `arduino-cli board list` — the port can change in DFU mode). The drag-and-drop UF2 path in §5b also works.
+
+### Manual upload (without `make`)
+
+Find the board port with `arduino-cli board list`, then:
+
+### macOS / Linux
 
 ```bash
 arduino-cli upload \
@@ -132,10 +156,12 @@ Re-flashing firmware does **not** erase the board's internal LittleFS. The paire
 - **Recovery build (`-DOPK_FACTORY_RESET=1`):** a firmware that wipes all persistent storage **once, on the first boot after flashing**, then behaves like a normal build that persists settings. Use it to recover a board from a bad config/bond without a console or panel:
 
   ```bash
-  ./gen_version.sh   # recommended: gives the build a distinct git hash (see below)
-  arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DOPK_FACTORY_RESET=1" OpenPuck
+  ./gen_version.sh   # recommended: embeds version (when tagged) + git hash provenance
+  make build-recovery
   ```
 
+  (`make build-recovery` is just `make build` plus `-DOPK_FACTORY_RESET=1`; the usual USB flags are still baked in.)
+
   It is **not** a wipe-every-boot image: after the one-time reset it stamps a tag file with the build's git hash, so subsequent boots skip the wipe and persist normally. Flashing this same image again won't re-wipe (the tag matches); flashing a **different** build (different git hash) re-triggers the one-time reset. For an on-demand wipe at any time, use the WebUSB button or serial `ERASE-ALL` below. Re-pair the controller after a reset.
 - **WebUSB panel (any mode):** open the panel (§8), and in the maintenance card click **⚠ Factory erase**. Confirm the two warning dialogs and type `ERASE` when prompted. The board reformats its filesystem and reboots to factory defaults. This works in every USB mode.
 - **Serial console (CDC):** connect to the board's serial port at 115200 baud and send the line `ERASE-ALL` (exact, all caps). Same effect: reformat + reboot.

+ 50 - 6
docs/PROTOCOL.md

@@ -370,14 +370,22 @@ Messages:
   - `0x01`: get status blob
   - `0x02 <field> <value>`: set one field
   - `0x03 <mode>`: switch mode and reboot
-  - `0x0D`: get the lizard binding map (device replies `0xA7`)
-  - `0x0F <count>`: begin a lizard-map edit — set the binding count
-  - `0x0E <idx> <16-byte binding>`: set one lizard binding (see below)
-  - `0x10`: commit the edited lizard map to flash (device echoes `0xA7`)
-  - `0x11`: reset the lizard map to built-in defaults (device echoes `0xA7`)
+  - `0x07`: re-init haptics (clear a stuck buzz)
+  - `0x08`: send controller power-off
+  - `0x09`: export all bond slots (reply: `0xA7` frame) — see §10.1
+  - `0x0A 0x45 0x52 0x53`: factory erase (`"ERS"` magic), then reboot
+  - `0x0B` / `0x0C`: reboot into serial DFU / UF2 bootloader
+  - `0x0D <slot> <used> <24-byte rec>`: write one bond slot into RAM — see §10.1
+  - `0x0E <mode>`: commit imported bonds (+ apply mode, `0xFF` = unchanged), then reboot
+  - `0x11`: get the lizard binding map (device replies `0xA8`)
+  - `0x13 <count>`: begin a lizard-map edit — set the binding count
+  - `0x12 <idx> <16-byte binding>`: set one lizard binding (see below)
+  - `0x14`: commit the edited lizard map to flash (device echoes `0xA8`)
+  - `0x15`: reset the lizard map to built-in defaults (device echoes `0xA8`)
 - Device to host:
   - `0xA5 <len> <payload>`: status blob
-  - `0xA7 <count> <count×16-byte bindings>`: lizard binding map
+  - `0xA7 <len> <payload>`: bond export (§10.1)
+  - `0xA8 <count> <count×16-byte bindings>`: lizard binding map
 
 Lizard binding wire format (16 bytes), matching `LizardBinding` in `lizard_map.h`:
 
@@ -388,6 +396,9 @@ Lizard binding wire format (16 bytes), matching `LizardBinding` in `lizard_map.h
 [12..15] holdMask u32 LE  (button bits: all-of guard)
 ```
 
+> Lizard-map commands and the `0xA8` reply were renumbered from `0x0D–0x11`/`0xA7` on the merge with
+> `main`, which had independently claimed `0x0D–0x10` and `0xA7`.
+
 Status blob payload:
 
 ```text
@@ -416,6 +427,39 @@ Status blob payload:
 [22] persist-mode flag
 ```
 
+### 10.1 Backup / clone (bond export & import)
+
+A puck's whole portable identity is its four 24-byte bond records (`[proteus_uuid 4][ibex_uuid 4][serial 16]`,
+§2). The per-bond on-air *session address* is **not** stored or transferred: each puck re-derives it from the
+bond UUID mixed with its own FICR `DEVICEID`, and the bonded controller relearns it from the `E1` host beacon on
+every reconnect. So copying the bond records onto a second puck is sufficient for any controller paired to the
+first to connect to the second **with no re-pairing** — the two pucks just derive different (non-colliding)
+session addresses for the same controller.
+
+This makes a puck cloneable. The WebUSB panel exposes it as **Export to file** / **Import from file**.
+
+**Export — `0x09` → `0xA7` frame:**
+
+```text
+[0]    format version (1)
+[1]    used mask (bit s = slot s bonded)
+[2..]  slot 0..3 records, 24 bytes each (96 bytes)        (len = 2 + 4*24 = 98)
+```
+
+**Import — `0x0D` (per slot) then `0x0E` (commit):**
+
+```text
+0x0D  <slot 0..3>  <used 0|1>  <24-byte record>      writes one slot into RAM (used=0 clears it)
+0x0E  <mode>                                         persist all slots, apply mode (0xFF=unchanged), reboot
+```
+
+Bonds are written one slot at a time so no command exceeds a single USB-FS packet, and each `0x0D`/`0x02` is
+acked with a status blob so the host's read-after-write keeps the OUT pipe flowing. The panel sends all four
+`0x0D` slot writes plus every config field via `0x02`, then a single `0x0E` to persist and reboot — so the
+reboot regenerates the session addresses from the new UUIDs and the restored puck reproduces both the pairings
+and all settings. Other settings (mouse, rumble, chords, per-type button maps, Switch Pro motion) ride in the
+backup file as plain `0x02` field values, not in the bond commands.
+
 ## 11. Timing notes
 
 - Default poll interval: `4000` microseconds (250 Hz, matches SC2 report rate)

+ 25 - 0
docs/WEBUSB_LINUX.md

@@ -0,0 +1,25 @@
+# WebUSB on Linux
+
+By default, not every user is allowed to access raw USB devices like the OpenPuck. This can result in the configuration webpage showing "disconnected" after selecting the correct device. 
+
+You can check the error log by going to `chrome://device-log/`. (or `chromium://device-log`, `edge://device-log`, ... depending on your browser.)
+
+If it shows "Failed to open /dev/bus/usb/xxx/xxx: Operation not permitted", you need to follow these instructions to give the current user raw access to Valve hardware (vendor ID 28de).
+
+
+
+## Instructions
+
+As root, create the file `/etc/udev/rules.d/50-openpuck.rules` with the following content: 
+
+```
+SUBSYSTEM=="usb", ATTR{idVendor}=="28de", MODE="0664", GROUP="plugdev"
+```
+
+This ensures that all members of the `plugdev` group can access all Valve hardware directly. 
+
+Then, run `sudo usermod -a -G plugdev $USER` to make sure your user is a member of that group, and then run `sudo udevadm control --reload-rules` (or reboot your machine) to enable the new rule. 
+
+Only if you have chromium installed through Snap, you may also need to run `snap connect chromium:raw-usb` to give it direct USB access.
+
+Then just unplug and re-plug the OpenPuck and your browser should be able to connect to the OpenPuck while it's in Steam Controller mode.

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 602 - 115
docs/index.html


+ 16 - 4
gen_version.sh

@@ -1,11 +1,22 @@
 #!/bin/sh
-# gen_version.sh -- write OpenPuck/git_version.h with the current git short hash + dirty flag, for the build
-# provenance shown in the WebUSB panel. Run this BEFORE `arduino-cli compile` (it's a no-op to re-run). The
-# generated header is git-ignored, so generating it never itself marks the tree dirty.
+# gen_version.sh -- write OpenPuck/git_version.h with the release/build identity shown in WebUSB.
+# Keep both semver + hash so local untagged builds still expose provenance.
+# Run this BEFORE `arduino-cli compile`. The generated header is git-ignored, so generating it never itself
+# marks the tree dirty.
 set -e
 cd "$(dirname "$0")"
 
 HASH=$(git rev-parse --short=8 HEAD 2>/dev/null || echo unknown)
+VERSION="${OPK_BUILD_VERSION:-}"
+if [ -z "$VERSION" ]; then
+	SEMVER_RE='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
+	VERSION="$(
+		git tag --points-at HEAD 2>/dev/null \
+			| grep -E "$SEMVER_RE" \
+			| sort -V \
+			| tail -n1 || true
+	)"
+fi
 # Dirty = any tracked change OR untracked file (porcelain is empty only on a clean tree). git_version.h is
 # git-ignored so it doesn't count.
 if [ -z "$(git status --porcelain 2>/dev/null)" ]; then DIRTY=0; else DIRTY=1; fi
@@ -13,8 +24,9 @@ if [ -z "$(git status --porcelain 2>/dev/null)" ]; then DIRTY=0; else DIRTY=1; f
 cat > OpenPuck/git_version.h <<EOF
 // AUTO-GENERATED by gen_version.sh -- do not edit, do not commit (git-ignored).
 #pragma once
+#define OPK_BUILD_VERSION "$VERSION"
 #define OPK_GIT_HASH "$HASH"
 #define OPK_GIT_DIRTY $DIRTY
 EOF
 
-echo "OpenPuck/git_version.h: hash=$HASH dirty=$DIRTY"
+echo "OpenPuck/git_version.h: version=${VERSION:-none} hash=$HASH dirty=$DIRTY"

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov