Compare commits

..
Author SHA1 Message Date
MarcelineVPQandClaude Opus 4.8 03647048f0 docs: handoff for the trigger-buzz fix (#11) — keep #6, kill the side-effect
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>
2026-06-05 12:18:49 -06:00
15 changed files with 594 additions and 235 deletions
+13 -11
View File
@@ -6,28 +6,30 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
---
## [0.6.12-oled-edition] — 2026-06-07
## [Unreleased]
> **Supersedes the withdrawn v0.6.11.** v0.6.11 bundled the two OLED quality-of-life features below with two regressions (constant haptics, issue #11; an audio retiming change, issue #12) and is not recommended. v0.6.12 is built on the verified-working native-trigger firmware and folds in **only** the two separable good features — `CtrlWake` and brightness persistence — leaving the trigger and audio code paths byte-identical to the tested build. The v0.6.11 regression changes (the `state_mgr.cpp` trigger-FFB allow-bit mirror and the `audio.cpp` resampler retiming) are intentionally **not** carried over.
---
Headline: **native DualSense adaptive triggers now fire in real PC games on Linux/Proton — through the dongle, 1:1 with a wired controller.** For weeks the triggers only ever worked in the on-dongle OLED self-test, never in a game: the dongle was *recognised*, but games fell back to a generic/Xbox pad and never sent trigger effects. The root cause was three ways the dongle's USB presentation differed from genuine Sony hardware — each one accepted by Linux's `hid_playstation` (which only checks size + CRC) but **rejected by a game's native DualSense detection, which validates the actual content**. Closing all three makes the dongle byte-for-byte indistinguishable from a real DS5 to the game.
## [0.6.11-oled-edition] — 2026-06-02
**Host-side recipe (required alongside this firmware):** run the game on a Proton that carries the Wine `winebus.sys` **#9034** fix (Wine 11 / current Proton-GE — the bug suppresses the SDL gamepad device when a hidraw device exists for the same VID/PID, so the pad shows up everywhere *except* in-game) and **disable Steam Input** (native path). **No launch option is needed** — Wine 11 enables the hidraw native path by default. Works on **both Steam and Heroic** (on Heroic, fully quit Steam so it can't grab the pad, and keep any global `PROTON_PREFER_SDL` off). The game must natively support DualSense — XInput-only titles give rumble but no adaptive triggers. With a real DS5 this "just works"; the firmware changes below make the dongle match.
Headline: **adaptive triggers now actually work through the dongle** (#6) — host trigger force-feedback was being forwarded with its enable bits cleared, so the DualSense silently discarded it; this is the fix. Also adds an opt-in **`CtrlWake`** setting so the OLED can sleep while you play (#8/#9), persists the **OLED brightness** choice across power cycles (#9), and ships an in-progress **speaker-audio retiming** pass aimed at the periodic crackle (#7). UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.11-oled-edition) (built by `.github/workflows/release.yml`).
### Added
- **Native adaptive triggers & haptics in games on Linux/Proton, through the dongle.** Games with native DualSense support (Cyberpunk 2077, etc.) now drive the controller's adaptive triggers via the dongle, identical to a directly-wired DualSense — verified 1:1 by A/B against the same controller plugged in over USB-C. No Steam Input, no per-game hacks beyond the host recipe above. The firmware already proxies the host's trigger output reports to the controller (unchanged); the missing piece was getting games to *recognise* the dongle as a genuine DS5 in the first place (below).
- **`CtrlWake` setting — let the OLED sleep while you play.** New Settings toggle (default **on**, preserving the old behaviour). With it **off**, controller input no longer keeps the panel awake — only the OLED's own KEY0/KEY1 do — so the auto-dim / auto-off timers actually count down during gameplay and the screen can sleep while the controller is in active use. (folded from v0.6.11; issues #8/#9)
- **OLED brightness now persists across a power cycle.** The KEY1-long-press brightness choice is saved to config and restored on boot, instead of resetting to full every power-on. (folded from v0.6.11; issue #9)
- **`CtrlWake` setting — let the OLED sleep while the controller is in use (issues #8, #9).** The idle power-ladder (dim → off) previously never fired during gameplay, because every controller input bumped the activity timer and kept the panel awake — so a configured dim/off timeout effectively did nothing while playing. New Settings item `CtrlWake` (default **on**, preserving the old behavior); set it **off** and controller input no longer wakes the screen — only the OLED's KEY0/KEY1 do — so the dim/off timers count down during play and the panel can sleep, requiring a button press to wake.
### Changed
- **The DualSense HID report descriptor is now byte-identical to a real DS5 (289 bytes, was 321).** The OLED Edition declared four extra feature reports — `0xF6``0xF9`, the WebHID config/remap IDs — inside the gamepad collection, making the descriptor 32 bytes longer than genuine hardware. Games' native DualSense parser rejects that mismatch and falls back to a generic pad (no triggers); `hid_playstation` didn't care, which is why the OLED self-test always worked but games never did. The four declarations are removed (plus the runtime `wDescriptorLength` patch that overrode the static array, `0x41``0x21`). The firmware **still handles** `0xF6``0xF9`; they're simply undeclared now and work over Linux `hidraw` exactly like the existing `0xFD` diagnostic report.
- **Feature reports `0x09` (pairing), `0x20` (firmware) and `0x05` (calibration) now return the controller's real cached data, not zeros.** The earlier native-recognition fix answered these with zeros + a valid CRC — enough for `hid_playstation` to bind, but games validate the *content*: Cyberpunk GET-read `0x20`/`0x09` ~156× in a retry storm rejecting the zeros, never completed the DualSense handshake, and showed no controller. The dongle already fetches and caches the genuine reports from the connected controller over BT (`init_feature()`); `tud_hid_get_report_cb` now serves that cache (real firmware string / pairing / calibration), keeping the CRC-valid synthetic stub only as a fallback for the USB-enumeration probe before the BT link is up (so `hid_playstation` still binds then). Builds on the `hid_playstation` binding fix and the serial-=-MAC identity fix.
- **Speaker audio path retimed onto a true 10 ms / 100 Hz grid (issue #7, experimental).** The old path resampled 512→480 samples and shipped a 480-sample Opus frame on the haptic-gated cadence (~every 10.667 ms ≈ 45 kHz into the DS5's free-running 48 kHz DAC), a ~6.25 % underrun that produced a periodic gap/crackle. The resampler is removed and the `0x36` frame now carries exactly one native 10 ms / 48 kHz Opus frame (`SAMPLE_SIZE` 64→60, 480-sample buffer), with the speaker sub-report offset computed from the haptic block length rather than hard-coded. This is an in-progress change aimed at the long-standing crackle; the audio path also carries debug counters (inert in the production UF2, visible only with `ENABLE_SERIAL`).
- **`audio_buffer_length` now defaults to 16** (was 64) — a lower buffer avoids the DS5's periodic re-buffer gap. Existing saved configs keep their value; this only affects fresh flashes / Reset-to-defaults.
- **`auto_haptics_enable` now defaults to Off** (was Fallback) — the speaker-derived rumble fallback could produce erratic haptics, so it is opt-in.
- **OLED SPI flush is now chunked / non-blocking.** The framebuffer is sent in row-chunks across main-loop iterations instead of one ~1.1 ms blocking transfer, so the OLED no longer stalls `tud_task` / `audio_loop` / BTstack polling while it refreshes (the OLED-on side of the #7 audio distortion).
- **Battery percentage shows the mid-point of each 10 % band** (5, 15, …, 95, 100 %), matching the kernel `hid-playstation` driver and Steam, instead of the band floor.
### Known tradeoff
### Fixed
- **The browser (WebHID) Config and Remap tabs no longer reach the dongle.** They drove config over reports `0xF6`/`0xF7`, which are no longer *declared* in the HID descriptor — and WebHID refuses undeclared report IDs (declaring them is exactly what broke game triggers). Mitigations, no firmware change needed: **on-dongle OLED config still works**, and because undeclared IDs are fine over Linux `hidraw`, a small `hidraw` CLI/script can read & write all of `0xF6``0xF9` (the same mechanism `scripts/mic_diag.sh` uses for `0xFD`). Restoring the *browser* tool would require moving the custom reports into a separate vendor HID collection — which risks the descriptor diverging from a real DS5 again, re-breaking triggers, so it's deliberately not done here.
- **Adaptive trigger effects now work through the dongle (issue #6).** Host-sent trigger force-feedback was silently dropped: `state_update()` copied the 11-byte `RightTriggerFFB`/`LeftTriggerFFB` parameter blocks into the outgoing controller-state but never set the `AllowRightTriggerFFB`/`AllowLeftTriggerFFB` "apply" bits in byte 0, so the DualSense received the data with the enable flags cleared and discarded it. Affected **both** output paths (standalone `0x31` and the `0x36` audio-frame fold), which is why triggers felt absent through the dongle but worked on direct USB. The on-dongle Trigger Test screen always set those bits directly (`0x0C`), which is why it worked. Now the host's two allow-flags are mirrored into the state alongside the FFB data, like the rumble flags already were.
- **OLED brightness now persists across a power cycle (issue #9).** The KEY1-long-press brightness level was a runtime-only value that reset to full on every boot; it is now stored in config and restored at init.
---
-2
View File
@@ -97,8 +97,6 @@ These are non-obvious from the code; they cost time when forgotten.
- **Inactivity-disconnect uses `packet[3..12]`** in the L2CAP interrupt data path (`src/bt.cpp:l2cap_packet_handler`). It's looking at sticks and DPad/buttons to decide "idle." Don't touch those bytes' layout without updating the heuristic.
- **The 0x36 BT audio packet layout is load-bearing — speaker + HD haptic actuators silently die without the SetStateData sub-report.** Upstream commit `3a31bd7` (May 2026, "refactor: add SetStateData and audio send priority") moved the `0x10` SetStateData block out of every audio frame and into a one-time L2CAP-open setup. The DualSense hardware requires that sub-report (specifically the `0x7f 0x7f` Headphones + Speaker volume bytes) at `pkt[11..75]` of every `0x36` frame, or the actuators stop producing output even though USB and BT byte counts look fine. Our fork keeps `state_data[63]` in `src/audio.cpp` and re-asserts it on every frame; the pre-3a31bd7 packet layout is: state_data at `pkt[11..75]`, haptic at `pkt[76..141]`, speaker format at `pkt[142]`, opus payload at `pkt[144..343]`. If you rebase onto an upstream that has the refactor and don't preserve this restoration, speaker + HD haptics silently break. The `scripts/test_speaker.sh` helper + the `USB aud / BT 0x32` counters on the OLED Diagnostics screen are the regression tripwire — if bytes are flowing but you hear nothing, look at packet contents not flow. Upstream PR #93 is tracking a proper unified fix; ours is the pre-refactor revert applied just to `audio.cpp`. Same fix was shipped independently by `loteran/DS5Dongle` (commit `c7a8d3c`).
- **The DualSense HID descriptor must stay byte-identical to a real DS5 (289 bytes), and `0x09`/`0x20`/`0x05` feature reports must return the controller's *real* cached data — or native game triggers silently break.** A game's native DualSense detection (Cyberpunk, etc.) validates *both* the descriptor shape and the feature-report *content*, then drives adaptive triggers over the native hidraw path. If either differs from genuine hardware the game falls back to a generic/Xbox pad and triggers never fire. Linux's `hid_playstation` is far more lenient (size + CRC only), so the dongle still *binds* and the on-dongle OLED trigger-test still works — which masked this break for weeks. Two load-bearing things: (1) **don't re-declare config reports `0xF6``0xF9`** in `desc_hid_report_ds` (or bump its `wDescriptorLength` runtime patch in `tud_descriptor_configuration_cb` back to `0x41`) — that's what made the descriptor 321 bytes and killed triggers; (2) keep `tud_hid_get_report_cb` serving the **real `init_feature()` cache** for `0x09`/`0x20`/`0x05`, with the CRC-valid synthetic stub only as the pre-BT-link probe fallback. `desc_hid_report_dse` (DSE mode) still declares F6-F9 and needs the same cut. Host side requires a Proton with the Wine `winebus.sys` #9034 fix + `PROTON_ENABLE_HIDRAW=0x054c/0x0ce6` + Steam Input off; diff a dongle vs real-DS5 `PROTON_LOG=+hid` capture to debug (a GET retry storm on `0x20`/`0x09` = the dongle's feature content is being rejected).
## Versioning — single source of truth
The release tag is the **only** place the version is written. Everything else flows from it:
-58
View File
@@ -1,58 +0,0 @@
# DualSense adaptive-trigger games — owned + tested through the dongle
Cross-referenced across Steam + Epic + GOG (~1,271 owned games). Native adaptive triggers work
on **both Steam and Heroic**, flag-free, with **runner = GE-Proton-DualSense (Wine 11)**. Verified
2026-06-07 by running Cyberpunk on *both* launchers — identical trigger behaviour.
- **Steam:** GE-Proton-DualSense + **Steam Input off**. No launch option needed (Wine 11 enables
the hidraw native path by default).
- **Heroic (Epic / GOG):** GE-Proton-DualSense + **Steam fully quit** + **no `PROTON_PREFER_SDL`**.
No launch option needed. (Earlier belief that Heroic needs `PROTON_ENABLE_HIDRAW` was wrong —
it was masked by the two gotchas below.)
### ⚠️ Linux gotchas that masquerade as "the dongle is broken" (all host-side)
- **Steam running in the background** grabs the pad from non-Steam (Heroic) games. Fully quit Steam,
or disable Settings → Controller → PlayStation controller support.
- **A global `PROTON_PREFER_SDL=1`** forces the SDL/Xbox path and *suppresses* native triggers. Keep
it off (or per-game only). It's how you get a generic Xbox pad when a game lacks native DualSense.
- **XInput-only games can't do adaptive triggers — period.** XInput has no trigger-resistance API, so
titles that read the pad via XInput (e.g. Ghostrunner, Control on PC) give rumble only, on any OS,
through any tool. Not a dongle limit.
Legend: ✅ = confirmed working **flag-free** through the dongle (this session, 2026-06-07).
## 🎯 Full adaptive triggers
| Game | Where you own it | Status |
|---|---|---|
| Cyberpunk 2077 | Steam · GOG | ✅ tested flag-free — **confirmed on BOTH Steam and Heroic/GOG** |
| The Last of Us Part I | Steam | ✅ tested flag-free |
| Marvel's Spider-Man Remastered | Steam | ✅ tested flag-free |
| Uncharted: Legacy of Thieves Collection | Steam | ✅ tested flag-free |
| Ghost of Tsushima Director's Cut | Steam | recognized — bow draw 🏹 (earlier) |
| Hogwarts Legacy | Steam · Epic | ✅ tested flag-free (native recog + rumble; triggers moderate, in spellcasting) |
| Ghostrunner | Epic · GOG | ⚠️ XInput-only on PC — rumble, **no adaptive triggers** (not the dongle) |
| Avatar: Frontiers of Pandora | Steam | ✅ tested flag-free |
| Assassin's Creed Shadows | Steam | |
| Indiana Jones and the Great Circle | Steam | ✅ tested flag-free |
| Star Wars Jedi: Fallen Order | Steam | |
| LEGO Star Wars: The Skywalker Saga | Steam | |
| Metro Exodus (Enhanced) | Steam | |
| Marvel's Guardians of the Galaxy | Epic | |
| F.I.S.T.: Forged In Shadow Torch | Epic | |
| Dakar Desert Rally | Epic | racing-trigger feel |
## 〰️ Lighter / haptics-leaning (subtle trigger use)
- Baldur's Gate 3 — Steam — ✅ great experience; haptics-forward, light trigger use (dice rolls / ranged)
- Sifu — Epic — haptics only; combat is O/Triangle face-buttons, **no adaptive triggers** (not a trigger test)
- The Witcher 3: Wild Hunt (next-gen) — Steam · GOG
- Control Ultimate Edition — GOG — XInput-only on PC (rumble, no adaptive triggers)
- Forza Horizon 5 — Steam
- Hellblade: Senua's Sacrifice — Steam
- Star Wars: Squadrons — Epic
- Maneater — Epic
**Caveats:** matched the *well-known* trigger titles across a 600+ game library — there may be a
few more not flagged. ~18 games with real triggers, all owned, all working through the dongle.
+171
View File
@@ -0,0 +1,171 @@
# 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`): in `src/state_mgr.cpp` we added
```c
set_bit(state[0], 2, update.AllowRightTriggerFFB);
set_bit(state[0], 3, update.AllowLeftTriggerFFB);
```
Upstream copies the trigger FFB *params* (its `copy_if_allowed` lines 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 **every `0x36` audio frame** — it's load-bearing for keeping the
speaker/HD-haptic actuators alive (the `0x7f 0x7f` volume 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`
1. Add a file-static one-shot (single-core: `state_update` and the frame senders all run on core 0, so
no atomics needed):
```c
static bool trigger_oneshot = false; // armed by a host trigger write, consumed by the next outbound frame
```
2. In `state_update()`, right after the two existing `set_bit(state[0], 2/3, ...)` lines, arm it:
```c
// 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;
```
3. Add a new frame-copy next to `state_set()`:
```c
// 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:
```c
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` → mask `0x0C`.
- `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)
```bash
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)
1. **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).
2. **Buzz is gone (#11):** set a trigger effect in a game, then idle — the constant low-level
buzz/vibration should not persist.
3. **Decisive instrument:** OLED **Diagnostics** screen — watch the `trig` / `host02` counters during
the buzz scenario. (These are `g_host_out02_trig_allow` / `_to_bt` / `_folded` from `src/main.cpp`.)
Confirms whether the host is streaming trigger FFB vs a stale latch.
4. **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-trim`** branch — experimental `SpkTrim` crackle sweep (#7). See `HANDOFF.md` on
that branch. Separate work.
- **`defaults/usb-faithful`** branch — 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 (a `state_set_frame` one-shot in
> `state_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.
+2 -44
View File
@@ -17,14 +17,12 @@ The web tool is a one-stop shop — **no installs, no command line, no `picotool
- **Flash Firmware tab** — put the Pico in **BOOTSEL mode**, then click *Connect to Pico* in the browser and *Flash now*. The site bundles the latest release UF2, or you can load a local `.uf2` you've built yourself. Powered by WebUSB.
> **What is BOOTSEL mode?** It's the Pico's built-in flashing mode. To enter it: press and hold the small white button labeled **BOOTSEL** on the Pico, *then* plug the USB cable in (or, if it's already plugged in, briefly disconnect and reconnect while holding BOOTSEL). The Pico will appear to your computer as a removable drive — that's how you know it's in BOOTSEL mode. After the web tool flashes the firmware, the Pico auto-reboots into normal mode and is ready to use.
- **Config tab** — once the dongle is flashed and reconnected, edit haptics gain, speaker volume, polling rate, audio auto-haptics mode, and the rest of the persistent settings; save to the dongle's flash with one click. Powered by WebHID. **⚠️ Does not work on the native-trigger firmware** — see the note below.
- **Remap tab** — visual button remapper: click a button on a live DualSense diagram to reassign it to any other (the shoulders/triggers float to the corners as labeled glyphs). The map is stored on the dongle and applied before the host sees the report, so it works in every game and on every OS. Powered by WebHID. **⚠️ Does not work on the native-trigger firmware** — see the note below.
- **Config tab** — once the dongle is flashed and reconnected, edit haptics gain, speaker volume, polling rate, audio auto-haptics mode, and the rest of the persistent settings; save to the dongle's flash with one click. Powered by WebHID.
- **Remap tab** — visual button remapper: click a button on a live DualSense diagram to reassign it to any other (the shoulders/triggers float to the corners as labeled glyphs). The map is stored on the dongle and applied before the host sees the report, so it works in every game and on every OS. Powered by WebHID.
- **OLED Preview tab** — pixel-perfect emulation of all 11 OLED screens. Use the in-page KEY0/KEY1 buttons (or the controller's △ / R1 / D-pad when a DualSense is paired) to navigate. Adaptive triggers actually fire on the controller when you cycle the Trigger Test preset.
Works in any Chromium-based browser (Chrome, Edge, Brave, Opera). Firefox + Safari don't expose WebHID or WebUSB, so flashing and live config aren't available there — the OLED Preview still renders with mock data.
> **⚠️ Native-trigger firmware (Unreleased) and the WebHID tabs.** To make games on Linux/Proton recognise the dongle as a genuine DualSense and fire **native adaptive triggers** (see [CHANGELOG.md](./CHANGELOG.md)), the firmware's HID descriptor is now byte-identical to a real DS5 — which meant *un-declaring* the `0xF6`/`0xF7` config reports the browser **Config** and **Remap** tabs depend on. On that firmware those two tabs can't reach the dongle (WebHID refuses undeclared report IDs). **Flash Firmware and OLED Preview still work**, on-dongle OLED config still works, and the same settings can be driven over Linux `hidraw`. Earlier releases are unaffected.
> Source for the web tool: **[MarcelineVPQ/DS5Dongle-OLED-Config-Web](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Config-Web)** (fork of [awalol/ds5dongle-config-web](https://github.com/awalol/ds5dongle-config-web)).
---
@@ -45,7 +43,6 @@ This project enables the Raspberry Pi Pico2W to function as a Bluetooth bridge f
**OLED Edition additions:**
- **Native DualSense adaptive triggers in PC games on Linux/Proton — through the dongle.** Games with native DualSense support drive the controller's adaptive triggers wirelessly via the dongle, **1:1 with a directly-wired DualSense**. Requires a Proton carrying the Wine `winebus.sys` #9034 fix (Wine 11 / current Proton-GE) with Steam Input disabled — **no launch option needed** (Wine 11 enables the hidraw native path by default). Works on **both Steam and Heroic** (Epic/GOG). The firmware makes the dongle byte-for-byte indistinguishable from a real DS5 so the game accepts it (full write-up in [CHANGELOG.md](./CHANGELOG.md)). Trade-off: the browser Config/Remap tabs are disabled on this firmware (see the note above).
- Optional Pico-OLED-1.3 status display with **11 screens** (status, slots, lightbar, trigger test, gyro tilt, touchpad, diagnostics, CPU/clock, RSSI, VU meters, settings)
- **Button remapping** — reassign any of the 16 digital controls (face buttons, D-pad, shoulders/triggers, stick clicks, Create/Options) to any other. Stored on the dongle and applied before the host sees the report, so it works in **every game and OS with no host-side software**; identity (no remap) is the default. Edit it visually in the [web config tool](#-web-config-tool)'s **Remap tab**, or headlessly via `scripts/remap_test.py`. Persisted in its own flash sector, survives reboot.
- **4-slot persistent multi-controller pairing** — bond up to four DualSenses, switch between them from the OLED, slot 0 reconnects automatically on boot
@@ -55,44 +52,6 @@ This project enables the Raspberry Pi Pico2W to function as a Bluetooth bridge f
- **Soft-reboot** without unplugging USB via DS5 `PS + Mute` hold (works headless) or **KEY0 + KEY1 held together for 1 s** on the OLED add-on (replaces the older KEY0 double-click gesture, which was easy to fire by accident while paging quickly)
- **Audit pass on the core bridge** — critical stack-overflow fix in the audio path (resolves long-standing "audio stuttering"), security hardening, watchdog, length validation across HID/L2CAP boundaries (see [CHANGELOG.md](./CHANGELOG.md))
## 🐧 Linux: native adaptive triggers (Proton)
Getting a game to drive the controller's **adaptive triggers through the dongle** on Linux needs a Proton that carries the Wine `winebus.sys` **#9034** fix. **As of this writing no official Proton-GE release includes it yet** — it's merged upstream but unreleased, and `GE-Proton10-34` (the current release) and earlier do **not** have it. So a build that does is provided: **`GE-Proton-DualSense`** (see [Releases](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases)). Once an official GE-Proton ships the fix, that will work too.
> Why: the #9034 bug suppresses the SDL gamepad device when a hidraw device exists for the same VID/PID, so the pad is seen *everywhere except in-game*. The fix lets the native (trigger-capable) path and a working gamepad coexist.
**One-time setup**
1. **Install the Proton build.** Download `GE-Proton-DualSense.tar.gz` and extract it to:
- **Steam:** `~/.steam/root/compatibilitytools.d/`
- **Heroic:** `~/.config/heroic/tools/proton/`
Then **fully restart Steam** so it detects the new compatibility tool.
2. **Disable Steam Input** so the game talks to the controller natively — Steam → game **Properties → Controller → Disable Steam Input** (or globally: Settings → Controller → turn off PlayStation Controller support).
**Per game**
3. **Force the Proton:** Steam → right-click the game → **Properties → Compatibility → Force the use of a specific Steam Play compatibility tool → `GE-Proton-DualSense`**. (Heroic: set the game's *Wine version* to it.)
**No launch option is needed** — Wine 11 enables the hidraw native path by default, so the dongle is handed to the game automatically. Just launch — native adaptive triggers fire through the dongle, 1:1 with a wired DualSense (verified flag-free on Cyberpunk 2077, Uncharted, Spider-Man Remastered, The Last of Us Part I, Avatar, Indiana Jones — and on **both Steam and Heroic**). The game must have **native DualSense support** (XInput-only games give rumble but no adaptive triggers — that's an engine limit, not the dongle). First launch under a new Proton does locale/prefix setup and can sit "Not Responding" for a minute — that's setup, not a crash; let it finish.
### Heroic (Epic / GOG) — same recipe, two extra requirements
Native triggers work identically on Heroic, but non-Steam launchers are sensitive to two host-side things that silently steal the controller:
1. **Fully quit Steam** — a background Steam grabs the DualSense from non-Steam games (or disable Settings → Controller → PlayStation controller support).
2. **No global `PROTON_PREFER_SDL`** — that env var forces the SDL/Xbox path and *suppresses* native triggers. Use it per-game only, as a deliberate generic-pad fallback.
**Launch-option reference**
| Variable | Value | Purpose |
|---|---|---|
| `PROTON_ENABLE_HIDRAW` | `0x054c/0x0ce6` | *Legacy / optional — **not needed** on Wine 11*, which enables the hidraw native path by default. Harmless if set. Only relevant on older Proton where hidraw was opt-in (but those also lack the #9034 fix, so triggers won't work there anyway). |
| `PROTON_PREFER_SDL` | `1` | *Alternative, input-only.* Forces the SDL gamepad path — gives working input + rumble but as a generic/Xbox pad (**no triggers**). Use only when a game lacks native DualSense support and `ENABLE_HIDRAW` doesn't help. Do **not** combine with `ENABLE_HIDRAW` for trigger games. |
| `PROTON_LOG` | `+hid` | *Diagnostic only* (not for normal play). Writes a HID enumeration trace — Steam lands it at `~/steam-<appid>.log`; Heroic sets `PROTON_LOG_DIR` to your home, so it lands at `~/steam-0.log`. Useful for confirming the game opened the native HID path. |
> **Heroic vs Steam:** Native triggers work on **both** — and neither needs a launch option on Wine 11. Steam: set the runner + disable Steam Input. Heroic: set the runner, **fully quit Steam** (a background Steam steals the pad from non-Steam games), and keep `PROTON_PREFER_SDL` off. Either way the game must natively support DualSense — XInput-only games (e.g. Ghostrunner, Control) give rumble but no adaptive triggers, on any OS.
## Hardware
### Required
@@ -429,7 +388,6 @@ Some features and design ideas in this fork are borrowed from other forks of ups
- **[zurce/DS5Dongle-OLED](https://github.com/zurce/DS5Dongle-OLED)** — pixel-art icons in the OLED status header (visual approach), the "hold for factory reset" UX pattern used by the Settings screen's "Reset to defaults" item (hold △ for 2 s to confirm), and the multi-slot persistent BT pairing system on the new Slots screen (4 bonded controllers, D-pad to navigate, △ to switch slots, □ hold to wipe a slot, plus "Wipe all slots" in the Settings menu).
- **[loteran/DS5Dongle](https://github.com/loteran/DS5Dongle)** — independent rediscovery of the upstream `3a31bd7` regression that broke speaker / HD haptic output (commit `c7a8d3c`); the fix in our `src/audio.cpp` restores the same SetStateData sub-report. Also the source of the Audio Auto Haptics DSP (1-pole LP + envelope follower, see Settings → Auto Haptics) and the "don't sync USB-side UAC1 volume to the persistent config" fix.
- **[SundayMoments/DS5_Bridge](https://github.com/SundayMoments/DS5_Bridge)** — a sibling Pico DualSense bridge. The button-remapping apply logic and digital-control set in `src/remap.{h,cpp}` are ported from it. Its audio architecture (on-device Opus encoding vs an optional host-encoding companion app) was also the reference for diagnosing our speaker-cadence ("warble") work. Thanks for the groundwork.
- **[awalol/ds5dongle-config-web](https://github.com/awalol/ds5dongle-config-web)** — base for our forked web config app at [MarcelineVPQ/DS5Dongle-OLED-Config-Web](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Config-Web). The fork adapts upstream's 13-byte Config_body layout to our 19-byte one and adds UI for our additions (multi-slot pairing, Auto Haptics).
## Roadmap
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Decode raw Opus frames captured from firmware serial output.
Reads [OPUS_FRAME_N] hex lines from stdin or a file, decodes each frame
with libopus, and writes the result as a WAV file + prints a summary
(peak amplitude, zero-crossing rate) to diagnose encoder output quality.
"""
import ctypes
import struct
import sys
import wave
import re
lib = ctypes.cdll.LoadLibrary("libopus.so.0")
SAMPLE_RATE = 48000
CHANNELS = 2
FRAME_SIZE = 480 # 10ms at 48kHz
# Create decoder
err = ctypes.c_int(0)
decoder = lib.opus_decoder_create(SAMPLE_RATE, CHANNELS, ctypes.byref(err))
if err.value != 0:
print(f"opus_decoder_create failed: {err.value}", file=sys.stderr)
sys.exit(1)
infile = sys.argv[1] if len(sys.argv) > 1 else "/tmp/ds5_opus.log"
with open(infile) as f:
lines = f.readlines()
frames = []
for line in lines:
m = re.match(r'\[OPUS_FRAME_\d+\]\s+([0-9a-fA-F]+)', line.strip())
if m:
frames.append(bytes.fromhex(m.group(1)))
if not frames:
print("No [OPUS_FRAME_N] lines found in input.", file=sys.stderr)
sys.exit(1)
print(f"Found {len(frames)} Opus frames, decoding...")
all_pcm = b""
for i, frame_data in enumerate(frames):
pcm = (ctypes.c_int16 * (FRAME_SIZE * CHANNELS))()
ret = lib.opus_decode(
decoder,
frame_data, len(frame_data),
pcm, FRAME_SIZE,
0 # no FEC
)
if ret < 0:
errstr = lib.opus_strerror(ret)
print(f" Frame {i+1}: DECODE ERROR {ret} ({ctypes.string_at(errstr).decode()})")
continue
samples = list(pcm)
peak = max(abs(s) for s in samples)
nonzero = sum(1 for s in samples if s != 0)
print(f" Frame {i+1}: {ret} samples decoded, peak={peak}, nonzero={nonzero}/{len(samples)}")
print(f" TOC=0x{frame_data[0]:02x} first 8 bytes: {frame_data[:8].hex()}")
all_pcm += struct.pack(f"<{ret * CHANNELS}h", *samples[:ret * CHANNELS])
outpath = "/tmp/ds5_opus_decoded.wav"
with wave.open(outpath, "w") as wf:
wf.setnchannels(CHANNELS)
wf.setsampwidth(2)
wf.setframerate(SAMPLE_RATE)
wf.writeframes(all_pcm)
print(f"\nDecoded audio written to {outpath}")
print(f"Play with: aplay {outpath}")
print(f"View spectrogram: sox {outpath} -n spectrogram -o /tmp/ds5_opus_spectrogram.png")
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Send a 440 Hz sine to the DualSense speaker over Bluetooth from a Linux host
(Raspberry Pi), via raw /dev/hidraw writes — replicating the DS5Dongle firmware's
0x36 audio report BYTE-FOR-BYTE (same Opus settings, same SetStateData, same CRC).
Purpose: test whether a NON-tunneled BT path (Pi's BlueZ over a real UART) delivers
clean audio to the DS5 speaker, vs the Pico 2 W's BT-over-gSPI tunnel. If clean here,
the Pico's tunnel is the crackle culprit.
Pair the DS5 first (bluetoothctl), then run as root (hidraw needs RW):
sudo python3 pi_ds5_audio_test.py [seconds] [--headset] [--audbuf N]
"""
import ctypes, struct, math, sys, os, glob, time
# ---- libopus via ctypes (same loader style as decode_opus_dump.py) ----
opus = ctypes.cdll.LoadLibrary("libopus.so.0")
opus.opus_encoder_create.restype = ctypes.c_void_p
opus.opus_encoder_create.argtypes = [ctypes.c_int32, ctypes.c_int, ctypes.c_int,
ctypes.POINTER(ctypes.c_int)]
opus.opus_encode_float.restype = ctypes.c_int32
opus.opus_encode_float.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_float),
ctypes.c_int, ctypes.POINTER(ctypes.c_ubyte),
ctypes.c_int32]
# opus_encoder_ctl is variadic; leave argtypes unset and pass c_int values.
OPUS_APPLICATION_AUDIO = 2049
OPUS_SET_BITRATE_REQUEST = 4002
OPUS_SET_VBR_REQUEST = 4006
OPUS_SET_COMPLEXITY_REQUEST = 4010
RATE, CH, FRAME, OPUS_BYTES = 48000, 2, 480, 200 # 480 = 10 ms; 200 B = 160 kbps CBR
def make_encoder():
err = ctypes.c_int(0)
enc = opus.opus_encoder_create(RATE, CH, OPUS_APPLICATION_AUDIO, ctypes.byref(err))
if err.value != 0 or not enc:
sys.exit(f"opus_encoder_create failed: {err.value}")
enc_p = ctypes.c_void_p(enc)
# match firmware core1_entry(): 160 kbps, CBR, complexity 0
opus.opus_encoder_ctl(enc_p, OPUS_SET_BITRATE_REQUEST, ctypes.c_int(OPUS_BYTES*8*100))
opus.opus_encoder_ctl(enc_p, OPUS_SET_VBR_REQUEST, ctypes.c_int(0))
opus.opus_encoder_ctl(enc_p, OPUS_SET_COMPLEXITY_REQUEST, ctypes.c_int(0))
return enc_p
# ---- 63-byte SetStateData, verbatim from firmware src/state_mgr.cpp ----
STATE_DATA = bytes([
0xfd, 0xf7, 0x00, 0x00,
0x7f, 0x64, # VolHeadphonesMax, VolSpeaker
0x40, 0x09, 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, 0x00, 0x00, 0x0a,
0x07, 0x00, 0x00, 0x02, 0x01,
0x00,
0xff, 0xd7, 0x00, # RGB
]) + bytes(16) # trailing zeros -> 63 total
assert len(STATE_DATA) == 63
# ---- CRC-32, 0xA2-seeded, verbatim from firmware src/utils.h ----
def ds_crc32(data):
crc = (~0xEADA2D49) & 0xFFFFFFFF
for b in data:
crc ^= b
for _ in range(8):
if crc & 1: crc = (crc >> 1) ^ 0xEDB88320
else: crc >>= 1
return (~crc) & 0xFFFFFFFF
REPORT_SIZE, SAMPLE_SIZE = 398, 64 # original firmware layout
def build_packet(payload, seq, counter, headset, audbuf):
pkt = bytearray(REPORT_SIZE)
pkt[0] = 0x36
pkt[1] = (seq & 0x0F) << 4
pkt[2] = 0x11 | 0x80 # 0x91
pkt[3] = 7
pkt[4] = 0xFE # audio enable, mic OFF
for i in range(5, 10): pkt[i] = audbuf & 0xFF
pkt[10] = counter & 0xFF
pkt[11] = 0x10 | 0x80 # 0x90 SetStateData
pkt[12] = 63
pkt[13:76] = STATE_DATA
pkt[76] = 0x12 | 0x80 # 0x92 haptic
pkt[77] = SAMPLE_SIZE # haptic data pkt[78..141] = 0 (silent)
pkt[142] = (0x16 if headset else 0x13) | 0x80 # 0x96 headset / 0x93 speaker
pkt[143] = OPUS_BYTES
pkt[144:144+OPUS_BYTES] = payload
struct.pack_into("<I", pkt, REPORT_SIZE-4, ds_crc32(pkt[:REPORT_SIZE-4]))
return bytes(pkt)
def find_ds5_hidraw():
for ue in glob.glob("/sys/class/hidraw/hidraw*/device/uevent"):
txt = open(ue).read().upper()
if "054C" in txt and ("0CE6" in txt or "0DF2" in txt):
return "/dev/" + ue.split("/")[4]
return None
def main():
headset = "--headset" in sys.argv
audbuf = int(sys.argv[sys.argv.index("--audbuf")+1]) if "--audbuf" in sys.argv else 64
pos = [a for a in sys.argv[1:] if not a.startswith("--") and a.isdigit()]
secs = int(pos[0]) if pos else 15
node = find_ds5_hidraw()
if not node:
sys.exit("DualSense hidraw node not found — paired & connected? (bluetoothctl)")
print(f"DS5 hidraw: {node} | output: {'HEADSET' if headset else 'SPEAKER'} | "
f"AudBuf={audbuf} | {secs}s")
enc = make_encoder()
pcm = (ctypes.c_float * (FRAME*CH))()
out = (ctypes.c_ubyte * OPUS_BYTES)()
# Pre-build every packet so the timed send loop is pure I/O (no compute jitter).
n_frames = secs * 100
packets, seq, counter, samp = [], 0, 0, 0
first_nb = None
for _ in range(n_frames):
for i in range(FRAME):
v = 0.5 * math.sin(2*math.pi*440.0*samp/RATE)
pcm[i*2] = v; pcm[i*2+1] = v; samp += 1
nb = opus.opus_encode_float(enc, pcm, FRAME, out, OPUS_BYTES)
if nb < 0: sys.exit(f"opus_encode_float error {nb}")
if first_nb is None: first_nb = nb
payload = bytes(out[:nb]) + bytes(OPUS_BYTES - nb) if nb < OPUS_BYTES else bytes(out[:OPUS_BYTES])
packets.append(build_packet(payload, seq, counter, headset, audbuf))
seq = (seq+1) & 0x0F; counter = (counter+1) & 0xFF
print(f"Encoded {len(packets)} frames (first frame {first_nb} B — expect ~200 for CBR). Streaming...")
fd = os.open(node, os.O_RDWR)
short = 0
t0 = time.monotonic()
for n, pkt in enumerate(packets):
try:
w = os.write(fd, pkt)
except OSError as e:
print(f"write failed at frame {n}: {e}"); break
if w != REPORT_SIZE: short += 1
target = t0 + (n+1)*0.01 # 10 ms grid -> 100 pkt/s
dt = target - time.monotonic()
if dt > 0: time.sleep(dt)
os.close(fd)
print(f"Done — {n+1} frames sent (~{(n+1)/100:.1f}s).", end="")
print(f" WARNING: {short} short writes (hidraw truncating the 398 B report!)" if short else " All writes full-length.")
if __name__ == "__main__":
main()
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Send a 440 Hz sine wave on channels 1+2 only (speaker), silence on 3+4 (haptic).
Usage: python3 scripts/sine_ch12.py [seconds]
"""
import subprocess, struct, math, sys, re
RATE = 48000
FREQ = 440
DURATION = int(sys.argv[1]) if len(sys.argv) > 1 else 5
CHANNELS = 4
SAMPLES = RATE * DURATION
# Generate 4-channel S16_LE: sine on ch1+ch2, silence on ch3+ch4
data = bytearray()
for i in range(SAMPLES):
val = int(6000 * math.sin(2 * math.pi * FREQ * i / RATE)) # ~-15 dBFS (was 32767 / 0 dBFS) — clipping test
s = struct.pack('<h', val)
data += s + s + b'\x00\x00' + b'\x00\x00' # L, R, hapL=0, hapR=0
# Auto-detect the dongle's ALSA card — it enumerates as "DualSense Wireless
# Controller", and the card number shifts across reboots, so never hardcode it.
def find_dualsense_card():
out = subprocess.check_output(['aplay', '-l'], text=True)
for line in out.splitlines():
m = re.match(r'card (\d+):', line)
if m and 'DualSense' in line:
return int(m.group(1))
return None
card = find_dualsense_card()
if card is None:
print("DualSense dongle not found in `aplay -l` — paired and enumerated?", file=sys.stderr)
sys.exit(1)
device = f'hw:{card},0'
print(f"Playing to {device} (DualSense dongle)")
proc = subprocess.Popen(
['aplay', '-D', device, '-f', 'S16_LE', '-c', '4', '-r', '48000', '-'],
stdin=subprocess.PIPE
)
proc.stdin.write(data)
proc.stdin.close()
proc.wait()
print(f"Played {DURATION}s of {FREQ} Hz sine on ch1+ch2 (speaker only)")
+51 -25
View File
@@ -20,7 +20,7 @@
#define INPUT_CHANNELS 4
#define OUTPUT_CHANNELS 2
#define SAMPLE_SIZE 64
#define SAMPLE_SIZE 60 // 60 B = 30 haptic frames @ 3 kHz = 10.00 ms/packet → true 100 Hz cadence
#define REPORT_SIZE 398
#define REPORT_ID 0x36
// #define VOLUME_GAIN 2
@@ -80,7 +80,7 @@ static volatile uint32_t g_mic_plc_frames = 0; // concealed frames genera
uint32_t audio_mic_plc_frames() { return g_mic_plc_frames; }
struct audio_raw_element {
float data[512 * 2];
float data[480 * 2]; // exactly one 10 ms Opus frame (480 stereo samples)
};
void set_headset(bool state) {
@@ -96,6 +96,10 @@ uint32_t opus_fifo_drops() { return 0; }
// emulator's USB / BT rate display. Updated below.
static volatile uint32_t g_usb_frames = 0;
static volatile uint32_t g_bt_packets = 0;
static volatile int32_t g_opus_last_ret = 0;
static volatile uint32_t g_fifo_drops = 0;
static volatile uint32_t g_opus_encodes = 0;
static volatile bool g_opus_ready = false;
uint32_t audio_usb_frames() { return g_usb_frames; }
uint32_t audio_bt_packets() { return g_bt_packets; }
@@ -258,7 +262,7 @@ void audio_loop() {
}
g_usb_frames += (uint32_t)frames;
static float audio_buf[512 * 2];
static float audio_buf[480 * 2];
static uint audio_buf_pos = 0;
// 2. 从4ch中提取ch3/ch4,转换为float输入重采样器
WDL_ResampleSample *in_buf;
@@ -306,11 +310,12 @@ void audio_loop() {
#if !DISABLE_SPEAKER_PROC
audio_buf[audio_buf_pos++] = raw[i * INPUT_CHANNELS] / 32768.0f * audio_gain;
audio_buf[audio_buf_pos++] = raw[i * INPUT_CHANNELS + 1] / 32768.0f * audio_gain;
if (audio_buf_pos == 512 * 2) {
if (audio_buf_pos == 480 * 2) {
static audio_raw_element element{};
memcpy(element.data, audio_buf, 512 * 2 * 4);
memcpy(element.data, audio_buf, 480 * 2 * 4);
if (queue_is_full(&audio_fifo)) {
queue_try_remove(&audio_fifo,NULL);
g_fifo_drops++;
}
if (!queue_try_add(&audio_fifo, &element)) {
printf("[Audio] Warning: audio_fifo add failed\n");
@@ -402,20 +407,45 @@ void audio_loop() {
pkt[77] = SAMPLE_SIZE;
memcpy(pkt + 78, haptic_buf, SAMPLE_SIZE);
#if !DISABLE_SPEAKER_PROC
// Speaker Audio Data
pkt[142] = (plug_headset ? 0x16 : 0x13) | 0 << 6 | 1 << 7; // Speaker: 0x13
// Speaker Audio Data — MUST immediately follow the haptic block. The DS5
// parses sub-reports sequentially (header + len + data), so this offset is
// 78 + SAMPLE_SIZE, NOT a fixed 142. At SAMPLE_SIZE 64 that worked out to
// 142; shrinking the haptic block to 60 without moving this is what
// silenced the speaker (controller couldn't locate the speaker sub-report).
constexpr int kSpkOff = 78 + SAMPLE_SIZE; // = 138 at SAMPLE_SIZE 60
pkt[kSpkOff] = (plug_headset ? 0x16 : 0x13) | 0 << 6 | 1 << 7; // Speaker: 0x13
// L Headset Mono: 0x14
// L Headset R Speaker: 0x15
// Headset: 0x16
pkt[143] = 200;
pkt[kSpkOff + 1] = 200;
critical_section_enter_blocking(&opus_cs);
memcpy(pkt + 144, opus_buf, 200);
memcpy(pkt + kSpkOff + 2, opus_buf, 200);
critical_section_exit(&opus_cs);
#endif
bt_write(pkt, sizeof(pkt));
g_bt_packets++;
haptic_buf_pos = 0;
// Debug: dump 5 consecutive Opus frames (skip first 10 to let encoder settle)
{
static int dump_count = 0;
if (g_bt_packets > 10 && dump_count < 5) {
dump_count++;
printf("[OPUS_FRAME_%d] ", dump_count);
critical_section_enter_blocking(&opus_cs);
for (int di = 0; di < 200; di++) printf("%02x", opus_buf[di]);
critical_section_exit(&opus_cs);
printf("\n");
}
if ((g_bt_packets % 94) == 0) {
printf("[AUD] usb=%lu enc=%lu bt=%lu opus_ret=%ld fifo_drop=%lu hs=%d\n",
(unsigned long)g_usb_frames, (unsigned long)g_opus_encodes,
(unsigned long)g_bt_packets, (long)g_opus_last_ret,
(unsigned long)g_fifo_drops,
plug_headset ? 1 : 0);
}
}
}
}
@@ -443,7 +473,6 @@ void audio_init() {
}
static OpusEncoder *encoder;
static WDL_Resampler resampler_audio;
void core1_entry() {
int error = 0;
@@ -455,28 +484,25 @@ void core1_entry() {
opus_encoder_ctl(encoder,OPUS_SET_EXPERT_FRAME_DURATION(OPUS_FRAMESIZE_10_MS));
opus_encoder_ctl(encoder,OPUS_SET_BITRATE(200 * 8 * 100));
opus_encoder_ctl(encoder,OPUS_SET_VBR(false));
opus_encoder_ctl(encoder,OPUS_SET_COMPLEXITY(0)); // max 4
resampler_audio.SetMode(true, 0, false);
resampler_audio.SetRates(51200, 48000);
resampler_audio.SetFeedMode(true);
resampler_audio.Prealloc(2, 512, 480);
opus_encoder_ctl(encoder,OPUS_SET_COMPLEXITY(0)); // 5 overloaded core1 (stale frames -> worse); 0 keeps up
while (true) {
static audio_raw_element audio_element{};
queue_remove_blocking(&audio_fifo, &audio_element);
// 将 512 frames 重采样成 480 frames 以解决噪音问题。感谢 @Junhoo
WDL_ResampleSample *in_buf;
int nframes = resampler_audio.ResamplePrepare(512, 2, &in_buf);
for (int i = 0; i < nframes * 2; i++) {
in_buf[i] = audio_element.data[i];
}
static WDL_ResampleSample out_buf[480 * 2];
resampler_audio.ResampleOut(out_buf, nframes, 480, 2);
// audio_element is exactly 480 stereo frames (10 ms @ 48 kHz) = one native
// Opus frame, so encode it directly. The old 512→480 (51200→48000) resample
// only existed to coerce a 512-sample buffer into a legal Opus frame size; it
// shipped 480 samples every 10.667 ms (haptic-gated cadence) = 45 kHz into the
// DS5's free-running 48 kHz DAC → ~6.25% underrun = the periodic gaps/crackle.
// SAMPLE_SIZE 60 + a 480-sample buffer put the whole 0x36 frame on a true
// 10 ms / 100 Hz grid: 100 × 480 = 48000 samples/s, matched, no gaps.
static uint8_t out[200];
(void) opus_encode_float(encoder, out_buf, 480, out, 200);
int enc_ret = opus_encode_float(encoder, audio_element.data, 480, out, 200);
g_opus_last_ret = enc_ret;
g_opus_encodes++;
critical_section_enter_blocking(&opus_cs);
memcpy(opus_buf, out, 200);
critical_section_exit(&opus_cs);
g_opus_ready = true;
}
}
+3 -3
View File
@@ -72,7 +72,7 @@ void config_valid() {
printf("[Config] polling_rate_mode is invalid\n");
}
if (body->audio_buffer_length < 16 || body->audio_buffer_length > 128) {
body->audio_buffer_length = 64;
body->audio_buffer_length = 16; // low buffer avoids the DS5's periodic re-buffer gap
printf("[Config] haptics_buffer_length is invalid\n");
}
if (body->controller_mode > 2) {
@@ -84,8 +84,8 @@ void config_valid() {
printf("[Config] current_slot is invalid\n");
}
if (body->auto_haptics_enable > 3) {
body->auto_haptics_enable = 1; // Fallback default
printf("[Config] auto_haptics_enable invalid, defaulting to 1 (Fallback)\n");
body->auto_haptics_enable = 0; // default OFF (was 1/Fallback) — it derives erratic rumble from the speaker
printf("[Config] auto_haptics_enable invalid, defaulting to 0 (Off)\n");
}
if (body->auto_haptics_gain > 200) {
body->auto_haptics_gain = 100;
-41
View File
@@ -251,47 +251,6 @@ uint16_t tud_hid_get_report_cb(uint8_t itf, uint8_t report_id, hid_report_type_t
(void) buffer;
(void) reqlen;
// --- DualSense feature reports that Linux's hid_playstation reads at probe ---
// Without valid answers the kernel never creates a gamepad device, so games
// outside Steam Input (Heroic/Proton/native) see no controller. The host asks
// only for the report DATA (reqlen = report_size - 1); usbhid prepends the
// report-id byte itself. The two CRC'd reports validate crc32 over
// [0xA3 feature-seed, report_id, data...] in the last 4 bytes.
// hid_playstation (kernel) AND the game's native DualSense detection both read
// 0x09 (pairing), 0x20 (firmware) and 0x05 (calibration). The KERNEL only checks
// size + crc, but the GAME validates the actual CONTENT — so synthesized zeros
// pass the kernel yet get rejected by the game (a ~156x GET retry storm, and no
// native adaptive triggers). Serve the REAL controller data, which init_feature()
// caches from the controller over BT (get_feature_data returns it incl. the
// report-id at [0]). Fall back to a crc-valid synthetic answer ONLY when the
// controller isn't linked yet (USB-enumeration probe before the BT link), so the
// kernel still binds at that moment.
if (report_id == 0x09 || report_id == 0x20 || report_id == 0x05) {
if (reqlen == 0) return 0;
std::vector<uint8_t> real = get_feature_data(report_id, reqlen);
if (real.size() > 1) { // real cached response present
uint16_t n = (uint16_t)(real.size() - 1);
if (n > reqlen) n = reqlen;
memcpy(buffer, real.data() + 1, n);
return n;
}
memset(buffer, 0, reqlen);
if (report_id == 0x09) { // not linked yet: MAC-only stub
if (reqlen >= 6) bt_get_addr(buffer);
return reqlen;
}
if (reqlen < 5) return 0; // 0x20 / 0x05 stub: zeros + valid crc32
uint8_t tmp[2 + 64];
tmp[0] = 0xA3; tmp[1] = report_id;
memcpy(tmp + 2, buffer, reqlen - 4);
uint32_t crc = crc32_seeded(tmp, (size_t)(2 + (reqlen - 4)), 0);
buffer[reqlen - 4] = (uint8_t)(crc);
buffer[reqlen - 3] = (uint8_t)(crc >> 8);
buffer[reqlen - 2] = (uint8_t)(crc >> 16);
buffer[reqlen - 1] = (uint8_t)(crc >> 24);
return reqlen;
}
if (is_pico_cmd(report_id)) {
return pico_cmd_get(report_id, buffer, reqlen);
}
+57 -9
View File
@@ -52,6 +52,10 @@ constexpr int kRowBytes = kW / 8;
constexpr int kFbBytes = kRowBytes * kH;
uint8_t fb[kFbBytes];
uint8_t fb_tx[kFbBytes];
uint8_t reverse_lut[256];
int flush_progress = -1;
constexpr int kFlushChunkRows = 8;
uint32_t last_render_us = 0;
constexpr uint32_t kFrameUs = 100000;
@@ -237,7 +241,9 @@ void sh1107_init() {
// lives near the other text-drawing helpers below.
void draw_button_chrome();
void flush_fb_raw() {
// Blocking SPI flush — used only during boot splash before the main loop
// starts (no audio/BT to service yet, so blocking is fine).
void flush_fb_raw_blocking() {
cmd(0xB0);
for (int j = 0; j < kH; j++) {
const uint8_t col = kH - 1 - j;
@@ -249,11 +255,45 @@ void flush_fb_raw() {
}
}
void flush_fb() {
draw_button_chrome();
flush_fb_raw();
// Prepare a chunked (non-blocking) SPI flush. Pre-reverses the framebuffer
// into fb_tx via the LUT and sets flush_progress = 0. The actual SPI
// transfer is driven by flush_chunk(), called from oled_loop() on
// subsequent iterations — each chunk flushes kFlushChunkRows rows (~130 µs)
// then yields back to the main loop so audio_loop / tud_task /
// cyw43_arch_poll stay serviced. This eliminates the ~1.1 ms blocking
// window that caused audio distortion when the OLED was active (issue #7).
void flush_prepare(bool chrome) {
if (chrome) draw_button_chrome();
for (int i = 0; i < kFbBytes; i++) fb_tx[i] = reverse_lut[fb[i]];
flush_progress = 0;
}
bool flush_chunk() {
if (flush_progress < 0) return true;
if (flush_progress == 0) cmd(0xB0);
const int end = (flush_progress + kFlushChunkRows < kH)
? flush_progress + kFlushChunkRows : kH;
for (int j = flush_progress; j < end; j++) {
const uint8_t col = kH - 1 - j;
cmd(0x00 + (col & 0x0F));
cmd(0x10 + (col >> 4));
gpio_put(kPinDC, 1);
gpio_put(kPinCS, 0);
spi_write_blocking(spi1, &fb_tx[j * kRowBytes], kRowBytes);
gpio_put(kPinCS, 1);
}
flush_progress = end;
if (flush_progress >= kH) {
flush_progress = -1;
return true;
}
return false;
}
void flush_fb_raw() { flush_prepare(false); }
void flush_fb() { flush_prepare(true); }
void fb_clear() { memset(fb, 0, sizeof(fb)); }
void px(int x, int y, bool on) {
@@ -542,8 +582,10 @@ void handle_buttons() {
// --- Charge ETA tracker --------------------------------------------------
// The DS5 only reports battery in 10% steps (interrupt_in_data[52] low
// nibble, 0..10; high nibble is power-state, 1 == charging). We can't read a
// finer percentage over BT, so a smooth countdown is impossible. Instead we
// nibble, 0..10; high nibble is power-state, 1 == charging). We display the
// midpoint of each band (+5), matching the kernel hid-playstation driver and
// Steam: 5, 15, 25, … 95, 100%. We can't read a finer percentage over BT,
// so a smooth countdown is impossible. Instead we
// time how long each 10% step takes while charging and extrapolate the
// remaining steps. Sampled once per frame from oled_loop (continuously, so
// the estimate stays current even while the panel is dimmed/off and even when
@@ -701,8 +743,8 @@ __attribute__((noinline)) void render_screen() {
draw_text(kContentX, 9, buf);
const uint8_t pwr = interrupt_in_data[52];
int pct = (pwr & 0x0F) * 10;
if (pct > 100) pct = 100;
int raw = pwr & 0x0F;
int pct = (raw >= 10) ? 100 : raw * 10 + 5;
const uint8_t pstate = pwr >> 4;
char marker = ' ';
if (pstate == 1) marker = '+'; // Charging
@@ -1807,13 +1849,15 @@ void boot_splash() {
draw_text(cx_for(l1), 16, l1);
draw_text(cx_for(l2), 30, l2);
draw_text(cx_for(l3), 44, l3);
flush_fb();
draw_button_chrome();
flush_fb_raw_blocking();
sleep_ms(1500);
}
} // namespace
void oled_init() {
for (int i = 0; i < 256; i++) reverse_lut[i] = reverse_byte((uint8_t)i);
spi_init(spi1, 10 * 1000 * 1000);
gpio_set_function(kPinCLK, GPIO_FUNC_SPI);
gpio_set_function(kPinMOSI, GPIO_FUNC_SPI);
@@ -1874,6 +1918,10 @@ void oled_loop() {
handle_buttons();
const uint32_t now = time_us_32();
rumble_burst_tick(now);
if (flush_progress >= 0) {
flush_chunk();
return;
}
if ((now - last_render_us) < kFrameUs) return;
last_render_us = now;
// Track charge progress every frame — before the power-ladder early-returns
+10
View File
@@ -82,6 +82,16 @@ void state_update(const uint8_t *data, const uint8_t size) {
set_bit(state[0], 0, update.EnableRumbleEmulation);
set_bit(state[0], 1, update.UseRumbleNotHaptics);
// Mirror the host's adaptive-trigger "apply" flags into the outgoing state.
// Without these, copy_if_allowed below writes the 11-byte FFB params into
// state[] but the DS5 receives them with AllowRight/LeftTriggerFFB cleared
// and discards them — so adaptive triggers were dead through the dongle on
// BOTH the standalone 0x31 path and the 0x36 audio fold, while direct USB
// (which carries the host's flags verbatim) worked. The on-device Trigger
// Test screen sets these same bits (0x0C) directly, which is why it worked.
// (issue #6)
set_bit(state[0], 2, update.AllowRightTriggerFFB);
set_bit(state[0], 3, update.AllowLeftTriggerFFB);
set_bit(state[38], 2, update.EnableImprovedRumbleEmulation);
copy_if_allowed(
update.UseRumbleNotHaptics || update.EnableRumbleEmulation,
+1 -1
View File
@@ -127,7 +127,7 @@
#define CFG_TUD_AUDIO_FUNC_1_EP_OUT_SZ_MAX CFG_TUD_AUDIO_FUNC_1_FORMAT_1_EP_SZ_OUT
#define CFG_TUD_AUDIO_FUNC_1_EP_IN_SZ_MAX CFG_TUD_AUDIO_FUNC_1_FORMAT_1_EP_SZ_IN
#define CFG_TUD_AUDIO_FUNC_1_EP_OUT_SW_BUF_SZ (16 * CFG_TUD_AUDIO_FUNC_1_EP_OUT_SZ_MAX)
#define CFG_TUD_AUDIO_FUNC_1_EP_OUT_SW_BUF_SZ (3 * CFG_TUD_AUDIO_FUNC_1_EP_OUT_SZ_MAX)
#define CFG_TUD_AUDIO_FUNC_1_EP_IN_SW_BUF_SZ (4 * CFG_TUD_AUDIO_FUNC_1_EP_IN_SZ_MAX)
// Enable OUT EP (speaker) and IN EP (mic)
+22 -41
View File
@@ -26,8 +26,6 @@
#include "bsp/board_api.h"
#include "tusb.h"
#include "config.h"
#include "bt.h"
#include "slots.h"
#ifndef ENABLE_SERIAL
#define ENABLE_SERIAL 0
@@ -372,7 +370,7 @@ uint8_t descriptor_configuration[] = {
0x00, // bCountryCode: Not localized
0x01, // bNumDescriptors: 1 report descriptor
0x22, // bDescriptorType: Report
0x21, 0x01, // wDescriptorLength: 289 (0x0121) DS (F6-F9 removed to byte-match a real DS5)
0x41, 0x01, // wDescriptorLength: 321 (0x0141) DS
// 0xB5, 0x01, // wDescriptorLength: 437 (0x01B5) DSE
// Endpoint Descriptor (HID IN: EP4)
@@ -418,7 +416,7 @@ uint8_t const *tud_descriptor_configuration_cb(uint8_t index) {
descriptor_configuration[offset - 1] = bInterval;
descriptor_configuration[offset - 8] = bInterval;
if (ds_mode()) {
descriptor_configuration[offset - 16] = 0x21; // wDescriptorLength lo = 289 (0x0121); F6-F9 removed to byte-match a real DS5
descriptor_configuration[offset - 16] = 0x41;
}else {
descriptor_configuration[offset - 16] = 0xB5;
}
@@ -571,17 +569,26 @@ uint8_t const desc_hid_report_ds[] = {
0x09, 0x36, // Usage (0x36)
0x95, 0x03, // Report Count (3)
0xB1, 0x02, // Feature (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
// NOTE: web-config feature reports 0xF6-0xF9 are intentionally NOT declared
// here. Declaring them made this descriptor 321 bytes vs a genuine DS5's 289,
// and games' native DualSense parser rejected the mismatch -> no adaptive
// triggers in-game (the long-standing "triggers only work in the OLED test"
// bug). The firmware still HANDLES 0xF6-0xF9 (src/cmd.cpp); on Linux they
// work fine undeclared over hidraw, exactly like 0xFD. Trade-off: the browser
// WebHID config tool can't reach them (OLED + hidraw config still work).
0x85, 0xF6, // Report ID (-10)
0x09, 0x37, // Usage (Vendor 0x37)
0x95, 0x3F, // Report Count (63)
0xB1, 0x02, // Feature (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
0x85, 0xF7, // Report ID (-9)
0x09, 0x38, // Usage (Vendor 0x38)
0x95, 0x3F, // Report Count (63)
0xB1, 0x02, // Feature (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
0x85, 0xF8, // Report ID (-8)
0x09, 0x39, // Usage (Vendor 0x39)
0x95, 0x3F, // Report Count (63)
0xB1, 0x02, // Feature (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
0x85, 0xF9, // Report ID (-7)
0x09, 0x3A, // Usage (Vendor 0x3A)
0x95, 0x3F, // Report Count (63)
0xB1, 0x02, // Feature (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
0xC0, // End Collection
// 289 bytes — byte-identical to a real DualSense
// 321 bytes
};
static_assert(sizeof(desc_hid_report_ds) == 0x0121);
static_assert(sizeof(desc_hid_report_ds) == 0x0141);
uint8_t const desc_hid_report_dse[] = {
0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
@@ -851,35 +858,9 @@ uint16_t const *tud_descriptor_string_cb(uint8_t index, uint16_t langid) {
chr_count = 1;
break;
case STRID_SERIAL: {
// Present like a REAL DualSense: USB serial == controller MAC, the
// same value the 0x09 pairing-info feature report returns. Before
// this the serial was the Pico flash unique id, so the host saw two
// identities for one controller (flash-id over USB, MAC over 0x09)
// and Wine/Steam device-matching choked. Prefer the live connected
// MAC; fall back to the current slot's stored MAC (known at boot,
// before BT connects); finally fall back to the flash id if never
// paired so the descriptor is always valid.
uint8_t mac[6] = {0};
bool have = false;
bt_get_addr(mac);
for (int i = 0; i < 6; ++i) if (mac[i]) { have = true; break; }
if (!have) {
slot_get_addr(get_config().current_slot, mac);
for (int i = 0; i < 6; ++i) if (mac[i]) { have = true; break; }
}
if (have) {
static const char hexd[] = "0123456789ABCDEF";
for (int i = 0; i < 6; ++i) {
_desc_str[1 + i * 2] = hexd[(mac[i] >> 4) & 0x0F];
_desc_str[1 + i * 2 + 1] = hexd[mac[i] & 0x0F];
}
chr_count = 12;
} else {
chr_count = board_usb_get_serial(_desc_str + 1, 32);
}
case STRID_SERIAL:
chr_count = board_usb_get_serial(_desc_str + 1, 32);
break;
}
default:
// Note: the 0xEE index string is a Microsoft OS 1.0 Descriptors.