Design-complete, not yet implemented. Captures the root cause (verified vs upstream: #6 enabling the trigger apply-bits is the only haptic deviation; the 0x36 audio frame re-broadcasts state[] at ~100 Hz, re-firing the latched trigger effect on the audio clock = the buzz), and the exact USB-faithful fix: a state_set_frame() one-shot that emits trigger FFB once per host update then goes trigger-neutral, plus the three call-site swaps. Includes build + HIL verification steps. Resume on the main PC with hardware. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9.4 KiB
HANDOFF — fix the constant trigger buzz (#11) without losing #6
Written 2026-06-05 on the laptop (no Pico on hand). Resume on the main PC with hardware.
Status: design complete + agreed. NOT implemented, NOT built, NOT flashed. This doc has the full root-cause and the exact code so you can drop it in, build, and HIL-verify in one sitting.
The decision (don't re-litigate this)
- Ground truth for the whole project: the dongle should make the DualSense behave as close to a wired-USB controller as possible. Every "should we add a setting?" gets answered with "does USB do it? then match that" — not a new toggle.
- Keep the #6 adaptive-trigger fix. Users have confirmed games work with it. It was correct.
- #11 (constant trigger buzz) is an unintended side-effect of #6, not a reason to revert it.
- We rejected a toggle. A toggle treats the symptom and makes you babysit it. We fix the cause.
Root cause (verified against upstream awalol/DS5Dongle)
I fetched upstream and diffed the haptic path. Fork point: 0ed05d3 ("fix: rumble").
- The only haptic-relevant deviation from upstream is #6 (
71cead4): insrc/state_mgr.cppwe addedUpstream copies the trigger FFB params (itsset_bit(state[0], 2, update.AllowRightTriggerFFB); set_bit(state[0], 3, update.AllowLeftTriggerFFB);copy_if_allowedlines are identical to ours) but never sets these two apply-bits, so upstream's adaptive triggers are silently dead. We turned them on. That's the entire difference. - The re-broadcast is NOT our deviation. Both upstream and our fork stamp the full controller state
(
state_set(pkt+13, 63)) into every0x36audio frame — it's load-bearing for keeping the speaker/HD-haptic actuators alive (the0x7f 0x7fvolume bytes must ride every frame; see CLAUDE.md). - So the buzz = (our enabled trigger bits) × (the audio-rate re-broadcast). A host-latched trigger effect gets re-commanded ~100×/second on the audio clock. Wired USB never does that — the host drives trigger updates on its clock and the controller latches the last effect.
- Upstream doesn't buzz only because it never enables triggers (and pays for it with dead triggers). Neither upstream (no triggers) nor our current master (buzzing triggers) matches USB. The fix below does.
auto_haptics (speaker-derived rumble) is a fork-only feature but is off by default — not the culprit.
How state reaches the controller — the four egress points
| Site | Report | Cadence | Carries triggers today? |
|---|---|---|---|
src/main.cpp:322 |
0x31 |
only on a host output report, and only when audio is OFF | yes (already USB-like) |
src/bt.cpp:636 |
0x32 |
once, at L2CAP connect (init handshake) | init only; params are zero |
src/audio.cpp:172 |
0x36 |
~4 Hz keepalive (audio armed but idle) | yes — re-fires |
src/audio.cpp:404 |
0x36 |
~100 Hz while audio streams | yes — re-fires (the loud buzz) |
Critical detail at src/main.cpp:307-311: when audio is active the firmware deliberately does NOT send
the standalone 0x31 — it lets the trigger FFB written into state[] "ride the 0x36 audio frames
instead." So during gameplay-with-audio, the 100 Hz 0x36 re-broadcast is the only trigger delivery
path. That is exactly the path that buzzes.
The fix — one-shot trigger emit (mirrors USB cadence)
Deliver the trigger apply-bits once per host update, then go trigger-neutral so the controller
latches the effect instead of being re-hit by it. The 0x0C apply-bits are literally named
"Enable setting RightTriggerFFB" — cleared means "leave triggers as-is," so the DS5 holds the last
applied effect (same as USB).
src/state_mgr.cpp
- Add a file-static one-shot (single-core:
state_updateand the frame senders all run on core 0, so no atomics needed):static bool trigger_oneshot = false; // armed by a host trigger write, consumed by the next outbound frame - In
state_update(), right after the two existingset_bit(state[0], 2/3, ...)lines, arm it:// Trigger FFB is a host-latched effect, not a continuous level: arm a one-shot so exactly ONE // outbound frame carries the apply-bits after each host update, then state_set_frame() masks them // off. The controller holds the latched effect instead of it being re-fired on every 0x36 audio // frame (the #11 buzz). Mirrors wired USB. Keeps #6 fully intact. if (update.AllowRightTriggerFFB || update.AllowLeftTriggerFFB) trigger_oneshot = true; - Add a new frame-copy next to
state_set():// Like state_set(), but for REPEATING outbound frames (the 0x36 audio frames, the 0x31 host echo). // Emits the adaptive-trigger apply-bits only on the first frame after a host trigger update, then // clears them so the effect isn't re-fired at audio rate (#11). Rumble (bits 0/1) is a continuous // level and is intentionally left re-asserted every frame. void state_set_frame(uint8_t *data, const uint8_t size) { state_set(data, size); if (trigger_oneshot) trigger_oneshot = false; // this frame carries the triggers else if (size > 0) data[0] &= ~0x0C; // clear AllowRight/LeftTriggerFFB → hold latched effect }
src/state_mgr.h
Declare it:
void state_set_frame(uint8_t *data, const uint8_t size);
Swap the three repeating senders (leave bt.cpp:636 init as plain state_set)
src/audio.cpp:172→state_set_frame(pkt + 13, 63);src/audio.cpp:404→state_set_frame(pkt + 13, 63);src/main.cpp:322→state_set_frame(outputData + 3, sizeof(SetStateData));
Why this is safe
- Additive + 3 one-line swaps; fully reversible.
- Can't break #6: every host trigger update still produces exactly one trigger-bearing frame to the DS5.
- Can't regress rumble: only byte-0 bits 2,3 are masked; rumble bits 0,1 still re-assert every frame.
- Worst case if the buzz had another cause: it's a no-op move toward USB cadence, not a regression.
Offsets (already confirmed in src/utils.h:289 SetStateData)
- byte 0 bit 2 =
AllowRightTriggerFFB, bit 3 =AllowLeftTriggerFFB→ mask0x0C. RightTriggerFFB[11]at state byte 10;LeftTriggerFFB[11]at byte 21. (Params don't need zeroing — the DS5 ignores them when the apply-bit is clear. Masking the apply-bit is sufficient.)
Build + flash (main PC)
git fetch origin && git checkout master # this fix branches off master (clean 0.6.12)
git checkout -b fix/trigger-ffb-latch
# ...apply the edits above...
# Toolchain (Ubuntu 26.04 — the apt install was started on the laptop, finish/verify it):
# sudo apt-get install -y cmake ninja-build build-essential \
# gcc-arm-none-eabi libnewlib-arm-none-eabi libstdc++-arm-none-eabi-newlib python3 git
# Pico SDK 2.2.0 + export PICO_SDK_PATH, then PIN TinyUSB (or the audio config won't compile):
( cd "$PICO_SDK_PATH/lib/tinyusb" && git fetch --tags && git checkout 0.20.0 )
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DPICO_SDK_PATH="$PICO_SDK_PATH"
cmake --build build --target ds5-bridge # → build/ds5-bridge-oled.uf2
# BOOTSEL the board, copy the UF2 onto the RP2350 mount
HIL verification (the part that actually needs the hardware)
- Triggers still work (#6 intact): in a game with adaptive triggers (or the on-dongle Trigger Test screen), confirm resistance/effects still fire. Test with audio active specifically (that's the path we changed).
- Buzz is gone (#11): set a trigger effect in a game, then idle — the constant low-level buzz/vibration should not persist.
- Decisive instrument: OLED Diagnostics screen — watch the
trig/host02counters during the buzz scenario. (These areg_host_out02_trig_allow/_to_bt/_foldedfromsrc/main.cpp.) Confirms whether the host is streaming trigger FFB vs a stale latch. - Latch assumption check: the fix relies on the DS5 holding the last effect when apply-bit=0. If an effect unexpectedly clears between updates, the assumption is wrong — fall back to "emit on change" (shadow-compare the trigger bytes) instead of the one-shot. (Not expected; apply-bit semantics say hold.)
Commit only after HIL passes (one-UF2-per-feature cadence). Trailer: Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>. Branch off master, push to origin only. Then it can ship as 0.6.12 (update CHANGELOG.md [Unreleased] → Fixed).
Other open threads (unchanged, not blocking this)
audio/speaker-rate-trimbranch — experimentalSpkTrimcrackle sweep (#7). SeeHANDOFF.mdon that branch. Separate work.defaults/usb-faithfulbranch — 1000 Hz polling + mic-off defaults; needs a soak-test before merge.- Toolchain install was started on the laptop (Ubuntu 26.04, apt) but not finished/verified; Pico SDK not yet cloned there. The main PC presumably already has a working build env.
One-paste kickoff for a fresh session on the main PC
Read
HANDOFF-trigger-buzz.md. We're keeping the #6 adaptive-trigger fix and removing its side-effect, the constant trigger buzz (#11), by delivering trigger FFB once per host update instead of re-firing it on every 0x36 audio frame — wired-USB cadence. The exact edits (astate_set_frameone-shot instate_mgr.cpp+ three call-site swaps) are in the doc. Help me apply them on a branch off master, build the UF2, and run the HIL verification in the doc.