Compare commits

..
Author SHA1 Message Date
MarcelineVPQandClaude Opus 4.8 bc70bbd87c docs: credit SundayMoments/DS5_Bridge in Acknowledgements
The button-remap apply logic + digital-control set (src/remap.{h,cpp}) were
ported from it, and its audio architecture was the reference for the
speaker-cadence investigation. Crediting it was an oversight before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 20:49:54 -06:00
MarcelineVPQandClaude Opus 4.8 be3736c230 Merge native-trigger firmware as v0.6.12 — supersedes the withdrawn v0.6.11
v0.6.12 = the verified-working native-adaptive-trigger firmware (off v0.6.10)
plus the two separable good v0.6.11 features (CtrlWake, brightness persistence).
The v0.6.11 regressions are intentionally NOT carried over: the state_mgr.cpp
trigger-FFB allow-bit mirror (#11 constant haptics) and the audio.cpp resampler
retiming (#12). This merge takes the branch's tree wholesale; v0.6.11 remains in
history (and its tag) but its file changes are superseded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:28:31 -06:00
MarcelineVPQandClaude Opus 4.7 aef03e9633 docs(readme): document gyro-tilt rework + L3/R3 indicator (EN + CN)
Bring both READMEs up to v0.6.10's user-facing capability:
- Status screen: note the L3/R3 stick-click inverse-flash indicator and
  the ~Nm charge-time token next to the battery.
- Gyro Tilt screen: per-unit factory IMU calibration, dot centres when the
  controller lies flat, and tilt moves the dot in the matching direction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 18:28:31 -06:00
MarcelineVPQandClaude Opus 4.8 cb7fffd795 docs(linux): native adaptive triggers confirmed on Steam AND Heroic, flag-free
Verified 2026-06-07: Cyberpunk on GOG/Heroic fires adaptive triggers in
real gameplay, 1:1 with Steam. Corrects the recipe across README,
CHANGELOG, and the new DUALSENSE_GAME_LIST.md:
  - no launch option needed on Wine 11 (PROTON_ENABLE_HIDRAW now legacy)
  - works on BOTH Steam and Heroic; Heroic needs Steam fully quit + no
    global PROTON_PREFER_SDL
  - XInput-only games (Ghostrunner, Control) give rumble but no triggers
    by engine design, not a dongle limit
  - OpenRGB confirmed a non-issue

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:25:25 -06:00
MarcelineVPQandClaude Opus 4.8 9cabdca3f5 feat(oled): fold CtrlWake + brightness persistence onto native-trigger firmware (v0.6.12)
Cherry-picks ONLY the two separable good features from the withdrawn
v0.6.11 (commit 71cead4) — the `controller_wakes_display` (CtrlWake /
OLED-sleep-while-playing) toggle and `screen_brightness` persistence —
onto the verified-working native-trigger build.

The v0.6.11 regressions are intentionally NOT carried over:
  - 71cead4's state_mgr.cpp trigger-FFB allow-bit mirror (#11 suspect)
  - all of 84393c0's audio resampler retiming + config audio-defaults
    + chunked-SPI flush (#12 suspect)

Touches only src/config.{h,cpp} and src/oled.cpp, so the trigger and
audio code paths compile byte-identical to the tested firmware and
cannot regress. Two new Config_body fields append at the struct tail
(erased flash -> clamped to defaults by config_valid, clean upgrade).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 12:23:21 -06:00
MarcelineVPQandClaude Opus 4.8 00da3d6a18 docs(readme): flag the WebHID Config/Remap tabs as non-functional on the native-trigger firmware
Inline warnings on the Config and Remap tab descriptions (in addition to the
existing caveat block) so users on the native-trigger firmware don't try the
browser config tool and hit a silent failure. The reports those tabs use
(0xF6/0xF7) are undeclared on this firmware so WebHID can't reach them; OLED
on-device config and a Linux hidraw path still work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:53:22 -06:00
MarcelineVPQandClaude Opus 4.8 6482c79644 fix(audio): fold the crackle fix (RX buffer 3x->16x) into the native-trigger firmware
The speaker-crackle root cause -- USB-audio OUT endpoint software buffer overflow
at 3x the max packet size -- was fixed on the audio branch (de37e00) but never on
this branch. Bumping CFG_TUD_AUDIO_FUNC_1_EP_OUT_SW_BUF_SZ from 3x to 16x removes
the crackle; verified by A/B tone test (440 Hz) through the DS5 speaker. A faint
residual cadence "warble" remains (a separate root cause, not the buffer) and is
left for a later pass. Net: one firmware with BOTH native adaptive triggers and a
crackle-free speaker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:50:37 -06:00
MarcelineVPQandClaude Opus 4.8 00822d6556 docs: document native adaptive triggers in games (Linux/Proton) + the descriptor/feature-data fixes
CHANGELOG: headline + Added/Changed/Known-tradeoff for the native-trigger work.
README: a Features bullet, a "Linux: native adaptive triggers (Proton)" setup
section with every launch option in a reference table, and a caveat that the
WebHID Config/Remap tabs are disabled on this firmware. CLAUDE.md: a load-bearing
gotcha -- the HID descriptor must stay byte-identical to a real DS5 (289 bytes)
and feature reports 0x09/0x20/0x05 must serve the real cached controller data, or
native game triggers silently break.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 10:44:31 -06:00
MarcelineVPQandClaude Opus 4.8 8d811f09cc fix(hid): serve the controller's REAL 0x09/0x20/0x05 feature data to the host
The native-recognition shortcut answered 0x09 (pairing), 0x20 (firmware) and 0x05
(calibration) with zeros + a valid crc. hid_playstation accepted that (it checks
size+crc only), but games validate the actual CONTENT: Cyberpunk GET-read 0x20/0x09
~156x in a retry storm rejecting the zeros, never completed the DualSense handshake,
and showed no controller / no adaptive triggers.

init_feature() already caches the real reports from the controller over BT, so serve
those (via get_feature_data) for 0x09/0x20/0x05, keeping the crc-valid synthetic stub
only as a fallback for the USB-enumeration probe before the BT link (so the kernel
still binds then). The dongle now returns the genuine firmware string / pairing /
calibration, and native adaptive triggers work in games through the dongle, 1:1 with
a wired DualSense.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 10:36:10 -06:00
MarcelineVPQandClaude Opus 4.8 36687ce76d fix(usb): strip web-config reports 0xF6-0xF9 so the HID descriptor byte-matches a real DS5
The OLED Edition declared four extra feature reports (0xF6-0xF9, the WebHID config
IDs) in the DualSense HID descriptor, making it 321 bytes vs a genuine DS5's 289.
Games' native DualSense parser (Cyberpunk, etc.) rejects the mismatch and falls
back to generic/Xbox, so adaptive triggers never fire in a game -- the long-standing
"triggers only work in the OLED self-test" bug.

Remove the four declarations and the runtime wDescriptorLength patch that overrode
them (0x41->0x21) so the descriptor is byte-identical to a real DS5 (289 bytes). The
firmware still handles 0xF6-0xF9; they're just undeclared now and work over hidraw on
Linux exactly like 0xFD. Trade-off: the browser WebHID config tool can't reach them
(OLED + hidraw config still work).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 10:36:10 -06:00
MarcelineVPQandClaude Opus 4.8 5e5aa71cfe fix(usb): use the controller MAC as the USB serial, matching the 0x09 report
The serial was the Pico flash unique id (board_usb_get_serial), while the 0x09
pairing-info feature report returns the controller's BT MAC. The host saw two
identities for one controller (flash-id over USB, MAC over 0x09), and Wine/Steam
device-matching choked — "recognized but no controller" in-game.

Set STRID_SERIAL to the controller MAC (live bt_get_addr, falling back to the
current slot's stored addr, then the flash id if never paired) so the dongle
presents one consistent identity like a real DualSense. Steam now recognises
the dongle as a native DualSense.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:39:46 -06:00
MarcelineVPQandClaude Opus 4.8 d090df6820 feat(hid): answer DS5 0x09/0x20/0x05 feature reports so Linux binds hid_playstation
Linux's hid_playstation driver reads three feature reports at probe — 0x09
(pairing info / controller MAC), 0x20 (firmware info) and 0x05 (calibration),
the latter two CRC-checked. Without valid answers the kernel never creates a
gamepad, so games outside Steam Input (Heroic/Proton/native) see no controller.

Synthesize them in tud_hid_get_report_cb: 0x09 returns the BT MAC at [0..5];
0x20/0x05 fill zeros plus a valid trailing crc32 over [0xA3 seed, report_id,
data]. Confirmed: the dongle now binds hid_playstation — a real gamepad on Linux.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:39:46 -06:00
16 changed files with 249 additions and 588 deletions
+11 -17
View File
@@ -6,34 +6,28 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
--- ---
## [Unreleased] ## [0.6.12-oled-edition] — 2026-06-07
### Added (experimental — `audio/speaker-rate-trim` branch) > **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.
- **`SpkTrim` setting — fine speaker clock-drift trim to chase the periodic crackle (#7).** The DS5's DAC and the USB host's audio clock are independent ~48 kHz crystals; the residual ppm mismatch slowly drifts the controller's audio buffer, which underruns and crackles. The speaker path now delivers `48000 + trim` samples/s (via a zero-order-hold duplicate/drop accumulator) instead of exactly 48000, to null that drift. New `speaker_rate_trim` config field (stored 0200 = **100…+100 Hz**, default **0 Hz** = exact no-op), swept on the OLED **Settings** screen with the D-pad ▶◀ at 1 Hz/step. **Experimental / diagnostic:** if one value silences the crackle and *holds*, the drift was a static offset and this is the fix; if it nulls then creeps back, that proves the drift is dynamic and needs adaptive resampling. Tune with `scripts/sine_ch12.py` playing a steady tone and time the interval between crackles. 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.
--- **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.
## [0.6.11-oled-edition] — 2026-06-02
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 ### Added
- **`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. - **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)
### Changed ### Changed
- **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`). - **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.
- **`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. - **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.
- **`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.
### Fixed ### Known tradeoff
- **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. - **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.
- **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,6 +97,8 @@ 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. - **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 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 ## Versioning — single source of truth
The release tag is the **only** place the version is written. Everything else flows from it: The release tag is the **only** place the version is written. Everything else flows from it:
+58
View File
@@ -0,0 +1,58 @@
# 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.
-106
View File
@@ -1,106 +0,0 @@
# HANDOFF — audio work in progress
_Last updated: 2026-06-04 (written on the laptop; continuing on the main PC)_
This is a working handoff so the next session — on the main PC, or a fresh Claude Code
session — can pick up exactly where we left off. **All the real work is committed and pushed
to `origin`; nothing is stuck on the laptop.** This file lives on the `audio/speaker-rate-trim`
branch, so after `git checkout audio/speaker-rate-trim` it's right here.
---
## TL;DR
- The frustration that kicked this off: **audio (speaker/mic/HD haptics) is the priority**, not OLED features. Goal: make the dongle behave **as close to a wired-USB DualSense as possible**, with flaky extras opt-in.
- We confirmed the open GitHub issues are real, synced the tree up to **v0.6.11** (it was stale at 0.6.4), and split the work onto two feature branches.
- **Main thing to do next:** build `audio/speaker-rate-trim` on the main PC, flash it, and run the **`SpkTrim` sweep test** (below) to find out whether the speaker crackle is a fixed clock offset (curable with the new knob) or live drift (needs adaptive resampling).
---
## Branch map
| Branch | Commit | State | Needs |
|---|---|---|---|
| `master` | `878a742` | untouched, = `origin/master` = **v0.6.11** | — |
| `defaults/usb-faithful` | `333201e` | pushed | **HIL soak-test** before merging to master |
| `audio/speaker-rate-trim` | `3b157cb` | pushed | **build + flash + sweep test** (not yet built) |
Both feature branches are pushed to `origin` (the fork) and tracking. `upstream` / `upstream-fork` were **not** touched.
---
## What each branch contains
### `defaults/usb-faithful` — out-of-box "acts like USB" defaults
A full audit found the firmware is already ~USB-faithful for everything that matters (input is byte-for-byte passthrough; lightbar defaults to host control; etc.). Only two defaults were flipped:
- **`polling_rate_mode` 0 → 2** (250 Hz → **1000 Hz / realtime**) — matches a wired DS5's 1 ms latency. ⚠️ Realtime also drops the report-throttle, so **this needs a 3060 min hardware soak-test** (watch for BT drops / OLED Diag `BT31 in/s` collapse) before it merges to master. Fallback if unstable: `polling_rate_mode = 1` (500 Hz).
- **`bt_mic_enable` 1 → 0** (mic **off** by default) — the BT mic has a known 2× playback bug (#10), so it's opt-in until fixed.
Both only affect fresh flashes / Reset-to-defaults; existing saved configs keep their values.
### `audio/speaker-rate-trim` — experimental crackle fix (the active thread)
New **`SpkTrim`** setting that trims the speaker sample rate to null clock drift.
**Why:** the speaker path is 1:1 (no resampler) — it delivers samples at the **USB host's 48 kHz clock** while the DS5 consumes at **its own 48 kHz crystal**. Two independent clocks drift apart → the DS5's audio buffer slowly underruns → the periodic **crackle (#7)**. 0.6.11's "retiming" fixed the *average* rate but can't track *drift*.
**What it does:** delivers `48000 + trim` samples/s via a zero-order-hold duplicate/drop accumulator (`src/audio.cpp`, in the speaker accumulation loop). At ppm scale, a single duplicated/dropped sample is inaudible vs a 480-sample gap.
**Files changed:** `src/config.h` (field), `src/config.cpp` (clamp), `src/audio.cpp` (the trim), `src/oled.cpp` (Settings UI), `CHANGELOG.md`.
**Config field:** `speaker_rate_trim`, stored `0..200` = **100…+100 Hz**, default **100 (= 0 Hz, an exact no-op)**. So a fresh flash sounds **identical to 0.6.11** until you sweep it — safe to flash.
**UI:** OLED **Settings** screen, item **`SpkTrim ±NHz`** (just above "Reset to defaults"), swept with the D-pad **▶ / ◀** at **1 Hz/step**. (Reset/Wipe moved to settings indices 16/17 via named constants.)
---
## Next steps on the main PC
```bash
git fetch origin
git checkout audio/speaker-rate-trim
# ensure TinyUSB is still pinned (CLAUDE.md — 0.18 that ships with SDK 2.2.0 won't compile):
( cd "$PICO_SDK_PATH/lib/tinyusb" && 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 build/ds5-bridge-oled.uf2 onto the RP2350 mount
```
### The sweep test (the whole point)
1. OLED **Settings** → scroll to **`SpkTrim`** (reads `+0Hz`).
2. Play a steady tone from the host: `python3 scripts/sine_ch12.py 600` (10 min of 440 Hz on the speaker channels).
3. Nudge `SpkTrim` with the D-pad ▶/◀ and **time the interval between crackles**. A longer interval = closer to the DS5's true rate. Walk it toward the value where the crackle stops. (The crackle interval is itself a tachometer: a click every ~16 s ≈ ~600 ppm of clock error.)
4. **Decision read — leave the good value running 1015 min:**
- **Stays clean → static offset.** The knob is the fix; we lock in a default and ship it.
- **Creeps back → dynamic drift.** Proof we need **adaptive resampling** (continuously measure drift and vary the trim), which is the bigger but "correct" fix. The DS5 gives no buffer back-channel, so by-ear / crackle-interval is the right instrument.
---
## Other open threads (not blocking the sweep)
- **`defaults/usb-faithful` soak-test** — validate 1000 Hz polling stability, then merge to master (or fall back to 500 Hz).
- **#10 mic 2× playback** — needs OLED **Diagnostics** `Mic dec=` (samples per `opus_decode`; `240` would confirm a half-rate stream) and `Mic in/s` (arrival rate) read with the mic enabled. Mic is off-by-default now, so it's no longer an out-of-box problem.
- **#11 constant low-level haptics** — root-caused to the **#6 adaptive-trigger fix** (`71cead4`): 0.6.11 now honors host trigger-FFB, and the dongle re-asserts `state[]` continuously via the `0x36` audio frames, so a game's idle trigger effect buzzes constantly. **Not** auto-haptics (already off). Decisive read: watch the OLED Diag **`trig`** / **`host02`** counters during the buzz — climbing = host streaming FFB (add an opt-out toggle); static = stale latch (clear FFB params when allow-bit is 0, in `src/state_mgr.cpp`).
---
## Why audio is hard (context for any new session)
The DualSense's Bluetooth audio is **not A2DP / not normal Bluetooth audio** — Sony tunnels Opus-encoded audio inside the HID controller reports (`0x31`/`0x36`) over a proprietary, undocumented protocol with no clock negotiation. A normal PC/phone paired to a DS5 over BT gets **zero** controller audio; **only the PS5** implements it (Sony built both ends + a dedicated radio). This dongle reverse-engineers that on a CYW43 chip over emulated SPI (hence the load-bearing 320 MHz overclock) with general-purpose BTstack. So the gap from PS5-quality is **fidelity, not feasibility** — the crackle and mic-2× are timing/rate bugs, not walls.
---
## Gotchas / reminders
- **Push only to `origin`** — never `upstream` / `upstream-fork`.
- **TinyUSB must be 0.20.0** or the build fails on `TUD_AUDIO_EP_SIZE`.
- Build target is `ds5-bridge`; output is `build/ds5-bridge-oled.uf2` (custom name).
- `SpkTrim = 0` is a byte-exact no-op, so flashing this branch can't regress audio.
- The `0x36` packet layout and the `state[]` re-assertion are **load-bearing** (see CLAUDE.md) — don't "simplify" the audio frame.
---
## One-paste kickoff for a fresh Claude session
> I'm on branch `audio/speaker-rate-trim`. Read `HANDOFF.md`, the latest commit message, and the `[Unreleased]` section of `CHANGELOG.md`. We added an experimental `SpkTrim` setting to chase the speaker crackle (#7) by trimming the speaker sample rate to null clock drift between the host and the DualSense. Help me build the UF2, flash it, and run the sweep test in HANDOFF.md — find the `SpkTrim` value where the crackle stops, then check whether it holds (static offset = fix) or creeps back (dynamic drift = needs adaptive resampling).
+44 -2
View File
@@ -17,12 +17,14 @@ 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. - **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. > **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. - **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. - **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.
- **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. - **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. 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)). > 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)).
--- ---
@@ -43,6 +45,7 @@ This project enables the Raspberry Pi Pico2W to function as a Bluetooth bridge f
**OLED Edition additions:** **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) - 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. - **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 - **4-slot persistent multi-controller pairing** — bond up to four DualSenses, switch between them from the OLED, slot 0 reconnects automatically on boot
@@ -52,6 +55,44 @@ 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) - **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)) - **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 ## Hardware
### Required ### Required
@@ -388,6 +429,7 @@ 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). - **[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. - **[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). - **[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 ## Roadmap
-73
View File
@@ -1,73 +0,0 @@
#!/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
@@ -1,146 +0,0 @@
#!/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
@@ -1,45 +0,0 @@
#!/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)")
+34 -81
View File
@@ -20,7 +20,7 @@
#define INPUT_CHANNELS 4 #define INPUT_CHANNELS 4
#define OUTPUT_CHANNELS 2 #define OUTPUT_CHANNELS 2
#define SAMPLE_SIZE 60 // 60 B = 30 haptic frames @ 3 kHz = 10.00 ms/packet → true 100 Hz cadence #define SAMPLE_SIZE 64
#define REPORT_SIZE 398 #define REPORT_SIZE 398
#define REPORT_ID 0x36 #define REPORT_ID 0x36
// #define VOLUME_GAIN 2 // #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; } uint32_t audio_mic_plc_frames() { return g_mic_plc_frames; }
struct audio_raw_element { struct audio_raw_element {
float data[480 * 2]; // exactly one 10 ms Opus frame (480 stereo samples) float data[512 * 2];
}; };
void set_headset(bool state) { void set_headset(bool state) {
@@ -96,10 +96,6 @@ uint32_t opus_fifo_drops() { return 0; }
// emulator's USB / BT rate display. Updated below. // emulator's USB / BT rate display. Updated below.
static volatile uint32_t g_usb_frames = 0; static volatile uint32_t g_usb_frames = 0;
static volatile uint32_t g_bt_packets = 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_usb_frames() { return g_usb_frames; }
uint32_t audio_bt_packets() { return g_bt_packets; } uint32_t audio_bt_packets() { return g_bt_packets; }
@@ -262,22 +258,14 @@ void audio_loop() {
} }
g_usb_frames += (uint32_t)frames; g_usb_frames += (uint32_t)frames;
static float audio_buf[480 * 2]; static float audio_buf[512 * 2];
static uint audio_buf_pos = 0; static uint audio_buf_pos = 0;
static double spk_acc = 0.0; // speaker rate-trim accumulator (persists across calls)
// 2. 从4ch中提取ch3/ch4,转换为float输入重采样器 // 2. 从4ch中提取ch3/ch4,转换为float输入重采样器
WDL_ResampleSample *in_buf; WDL_ResampleSample *in_buf;
int nframes = resampler.ResamplePrepare(frames, OUTPUT_CHANNELS, &in_buf); int nframes = resampler.ResamplePrepare(frames, OUTPUT_CHANNELS, &in_buf);
const float audio_gain = mute[0] ? 0.0f : powf(10.0f, get_config().speaker_volume / 20.0f); const float audio_gain = mute[0] ? 0.0f : powf(10.0f, get_config().speaker_volume / 20.0f);
const float haptics_gain = get_config().haptics_gain; const float haptics_gain = get_config().haptics_gain;
// EXPERIMENTAL speaker clock-drift trim (audio/speaker-rate-trim). Deliver
// 48000 + trim samples/s to the DS5 instead of exactly 48000, to null the slow
// inter-crystal drift behind the crackle (#7). trim = 0 → ratio == 1.0 → exact
// no-op (byte-identical to pre-trim, since 48000/48000 is exact in double).
const int spk_trim_hz = (int)get_config().speaker_rate_trim - 100; // -100..+100 Hz
const double spk_ratio = (48000.0 + spk_trim_hz) / 48000.0;
uint16_t spk_max = g_peak_spk; uint16_t spk_max = g_peak_spk;
uint16_t hap_max = g_peak_hap; uint16_t hap_max = g_peak_hap;
@@ -316,32 +304,18 @@ void audio_loop() {
if (b > hap_max) hap_max = b; if (b > hap_max) hap_max = b;
} }
#if !DISABLE_SPEAKER_PROC #if !DISABLE_SPEAKER_PROC
{ audio_buf[audio_buf_pos++] = raw[i * INPUT_CHANNELS] / 32768.0f * audio_gain;
const float spk_l = raw[i * INPUT_CHANNELS] / 32768.0f * audio_gain; audio_buf[audio_buf_pos++] = raw[i * INPUT_CHANNELS + 1] / 32768.0f * audio_gain;
const float spk_r = raw[i * INPUT_CHANNELS + 1] / 32768.0f * audio_gain; if (audio_buf_pos == 512 * 2) {
// Rate-trim: emit `spk_ratio` output samples per input sample on average static audio_raw_element element{};
// (usually 1; occasionally 2 when trim>0, or 0 when trim<0). Zero-order memcpy(element.data, audio_buf, 512 * 2 * 4);
// hold — at ppm-scale trims a single duplicated/dropped sample is inaudible if (queue_is_full(&audio_fifo)) {
// vs a full 480-sample buffer underrun. queue_try_remove(&audio_fifo,NULL);
spk_acc += spk_ratio;
int emit = (int)spk_acc;
spk_acc -= emit;
while (emit-- > 0) {
audio_buf[audio_buf_pos++] = spk_l;
audio_buf[audio_buf_pos++] = spk_r;
if (audio_buf_pos == 480 * 2) {
static audio_raw_element element{};
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");
}
audio_buf_pos = 0;
}
} }
if (!queue_try_add(&audio_fifo, &element)) {
printf("[Audio] Warning: audio_fifo add failed\n");
}
audio_buf_pos = 0;
} }
#endif #endif
float h_l = raw[i * INPUT_CHANNELS + 2] / 32768.0f * haptics_gain; float h_l = raw[i * INPUT_CHANNELS + 2] / 32768.0f * haptics_gain;
@@ -428,45 +402,20 @@ void audio_loop() {
pkt[77] = SAMPLE_SIZE; pkt[77] = SAMPLE_SIZE;
memcpy(pkt + 78, haptic_buf, SAMPLE_SIZE); memcpy(pkt + 78, haptic_buf, SAMPLE_SIZE);
#if !DISABLE_SPEAKER_PROC #if !DISABLE_SPEAKER_PROC
// Speaker Audio Data — MUST immediately follow the haptic block. The DS5 // Speaker Audio Data
// parses sub-reports sequentially (header + len + data), so this offset is pkt[142] = (plug_headset ? 0x16 : 0x13) | 0 << 6 | 1 << 7; // Speaker: 0x13
// 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 Mono: 0x14
// L Headset R Speaker: 0x15 // L Headset R Speaker: 0x15
// Headset: 0x16 // Headset: 0x16
pkt[kSpkOff + 1] = 200; pkt[143] = 200;
critical_section_enter_blocking(&opus_cs); critical_section_enter_blocking(&opus_cs);
memcpy(pkt + kSpkOff + 2, opus_buf, 200); memcpy(pkt + 144, opus_buf, 200);
critical_section_exit(&opus_cs); critical_section_exit(&opus_cs);
#endif #endif
bt_write(pkt, sizeof(pkt)); bt_write(pkt, sizeof(pkt));
g_bt_packets++; g_bt_packets++;
haptic_buf_pos = 0; 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);
}
}
} }
} }
@@ -494,6 +443,7 @@ void audio_init() {
} }
static OpusEncoder *encoder; static OpusEncoder *encoder;
static WDL_Resampler resampler_audio;
void core1_entry() { void core1_entry() {
int error = 0; int error = 0;
@@ -505,25 +455,28 @@ void core1_entry() {
opus_encoder_ctl(encoder,OPUS_SET_EXPERT_FRAME_DURATION(OPUS_FRAMESIZE_10_MS)); 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_BITRATE(200 * 8 * 100));
opus_encoder_ctl(encoder,OPUS_SET_VBR(false)); opus_encoder_ctl(encoder,OPUS_SET_VBR(false));
opus_encoder_ctl(encoder,OPUS_SET_COMPLEXITY(0)); // 5 overloaded core1 (stale frames -> worse); 0 keeps up 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);
while (true) { while (true) {
static audio_raw_element audio_element{}; static audio_raw_element audio_element{};
queue_remove_blocking(&audio_fifo, &audio_element); queue_remove_blocking(&audio_fifo, &audio_element);
// audio_element is exactly 480 stereo frames (10 ms @ 48 kHz) = one native // 将 512 frames 重采样成 480 frames 以解决噪音问题。感谢 @Junhoo
// Opus frame, so encode it directly. The old 512→480 (51200→48000) resample WDL_ResampleSample *in_buf;
// only existed to coerce a 512-sample buffer into a legal Opus frame size; it int nframes = resampler_audio.ResamplePrepare(512, 2, &in_buf);
// shipped 480 samples every 10.667 ms (haptic-gated cadence) = 45 kHz into the for (int i = 0; i < nframes * 2; i++) {
// DS5's free-running 48 kHz DAC → ~6.25% underrun = the periodic gaps/crackle. in_buf[i] = audio_element.data[i];
// 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 WDL_ResampleSample out_buf[480 * 2];
resampler_audio.ResampleOut(out_buf, nframes, 480, 2);
static uint8_t out[200]; static uint8_t out[200];
int enc_ret = opus_encode_float(encoder, audio_element.data, 480, out, 200); (void) opus_encode_float(encoder, out_buf, 480, out, 200);
g_opus_last_ret = enc_ret;
g_opus_encodes++;
critical_section_enter_blocking(&opus_cs); critical_section_enter_blocking(&opus_cs);
memcpy(opus_buf, out, 200); memcpy(opus_buf, out, 200);
critical_section_exit(&opus_cs); critical_section_exit(&opus_cs);
g_opus_ready = true;
} }
} }
+3 -7
View File
@@ -72,7 +72,7 @@ void config_valid() {
printf("[Config] polling_rate_mode is invalid\n"); printf("[Config] polling_rate_mode is invalid\n");
} }
if (body->audio_buffer_length < 16 || body->audio_buffer_length > 128) { if (body->audio_buffer_length < 16 || body->audio_buffer_length > 128) {
body->audio_buffer_length = 16; // low buffer avoids the DS5's periodic re-buffer gap body->audio_buffer_length = 64;
printf("[Config] haptics_buffer_length is invalid\n"); printf("[Config] haptics_buffer_length is invalid\n");
} }
if (body->controller_mode > 2) { if (body->controller_mode > 2) {
@@ -84,8 +84,8 @@ void config_valid() {
printf("[Config] current_slot is invalid\n"); printf("[Config] current_slot is invalid\n");
} }
if (body->auto_haptics_enable > 3) { if (body->auto_haptics_enable > 3) {
body->auto_haptics_enable = 0; // default OFF (was 1/Fallback) — it derives erratic rumble from the speaker body->auto_haptics_enable = 1; // Fallback default
printf("[Config] auto_haptics_enable invalid, defaulting to 0 (Off)\n"); printf("[Config] auto_haptics_enable invalid, defaulting to 1 (Fallback)\n");
} }
if (body->auto_haptics_gain > 200) { if (body->auto_haptics_gain > 200) {
body->auto_haptics_gain = 100; body->auto_haptics_gain = 100;
@@ -121,10 +121,6 @@ void config_valid() {
body->controller_wakes_display = 1; body->controller_wakes_display = 1;
printf("[Config] controller_wakes_display invalid, defaulting to 1 (on)\n"); printf("[Config] controller_wakes_display invalid, defaulting to 1 (on)\n");
} }
if (body->speaker_rate_trim > 200) { // 0xFF erased / upgrade → 0 Hz (centered)
body->speaker_rate_trim = 100; // 100 encodes 0 Hz; actual trim = stored - 100
printf("[Config] speaker_rate_trim invalid, defaulting to 100 (0 Hz)\n");
}
if (body->config_version != CONFIG_VERSION) { if (body->config_version != CONFIG_VERSION) {
body->config_version = CONFIG_VERSION; body->config_version = CONFIG_VERSION;
printf("[Config] Warning: Config may breaking change\n"); printf("[Config] Warning: Config may breaking change\n");
-8
View File
@@ -54,14 +54,6 @@ struct __attribute__((packed)) Config_body {
// preserves the original "any controller activity wakes the screen" // preserves the original "any controller activity wakes the screen"
// behavior. Issues #8 (dim timeout never fired during play) and #9. // behavior. Issues #8 (dim timeout never fired during play) and #9.
uint8_t controller_wakes_display; uint8_t controller_wakes_display;
// EXPERIMENTAL (audio/speaker-rate-trim branch): fine speaker clock-drift trim.
// The DS5's DAC and the USB host's audio clock are independent ~48 kHz crystals;
// the residual ppm mismatch slowly drifts the DS5's buffer and produces the
// periodic crackle (#7). This nudges our delivered rate to 48000 + (trim-100) Hz
// to null that drift. Stored 0..200, offset by 100 so an erased-flash 0xFF clamps
// to 100 = 0 Hz (an exact no-op, byte-identical to pre-trim). Swept on the OLED
// Settings screen with the D-pad ▶◀, 1 Hz/step. Default 100 (0 Hz).
uint8_t speaker_rate_trim;
}; };
struct __attribute__((packed)) Config { struct __attribute__((packed)) Config {
+41
View File
@@ -251,6 +251,47 @@ uint16_t tud_hid_get_report_cb(uint8_t itf, uint8_t report_id, hid_report_type_t
(void) buffer; (void) buffer;
(void) reqlen; (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)) { if (is_pico_cmd(report_id)) {
return pico_cmd_get(report_id, buffer, reqlen); return pico_cmd_get(report_id, buffer, reqlen);
} }
+14 -70
View File
@@ -52,10 +52,6 @@ constexpr int kRowBytes = kW / 8;
constexpr int kFbBytes = kRowBytes * kH; constexpr int kFbBytes = kRowBytes * kH;
uint8_t fb[kFbBytes]; 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; uint32_t last_render_us = 0;
constexpr uint32_t kFrameUs = 100000; constexpr uint32_t kFrameUs = 100000;
@@ -130,7 +126,7 @@ constexpr int kLbModeHost = 8;
constexpr int kNumLbModes = 9; constexpr int kNumLbModes = 9;
// Settings screen state // Settings screen state
constexpr int kNumSettingsItems = 18; // 8 fields + 3 auto-haptic + 2 screen-timeout + BT mic + Ctrl-wake + Spk-trim + Reset + Wipe constexpr int kNumSettingsItems = 17; // 8 fields + 3 auto-haptic + 2 screen-timeout + BT mic + Ctrl-wake + Reset + Wipe
constexpr int kSettingsAutoHapEnaIdx = 8; constexpr int kSettingsAutoHapEnaIdx = 8;
constexpr int kSettingsAutoHapGainIdx = 9; constexpr int kSettingsAutoHapGainIdx = 9;
constexpr int kSettingsAutoHapLpIdx = 10; constexpr int kSettingsAutoHapLpIdx = 10;
@@ -138,9 +134,8 @@ constexpr int kSettingsScrDimIdx = 11;
constexpr int kSettingsScrOffIdx = 12; constexpr int kSettingsScrOffIdx = 12;
constexpr int kSettingsBtMicIdx = 13; constexpr int kSettingsBtMicIdx = 13;
constexpr int kSettingsCtrlWakeIdx = 14; constexpr int kSettingsCtrlWakeIdx = 14;
constexpr int kSettingsSpkTrimIdx = 15; constexpr int kSettingsResetIdx = 15;
constexpr int kSettingsResetIdx = 16; constexpr int kSettingsWipeSlotsIdx = 16;
constexpr int kSettingsWipeSlotsIdx = 17;
Config_body settings_local{}; Config_body settings_local{};
int settings_sel = 0; int settings_sel = 0;
bool settings_dirty = false; bool settings_dirty = false;
@@ -242,9 +237,7 @@ void sh1107_init() {
// lives near the other text-drawing helpers below. // lives near the other text-drawing helpers below.
void draw_button_chrome(); void draw_button_chrome();
// Blocking SPI flush — used only during boot splash before the main loop void flush_fb_raw() {
// starts (no audio/BT to service yet, so blocking is fine).
void flush_fb_raw_blocking() {
cmd(0xB0); cmd(0xB0);
for (int j = 0; j < kH; j++) { for (int j = 0; j < kH; j++) {
const uint8_t col = kH - 1 - j; const uint8_t col = kH - 1 - j;
@@ -256,45 +249,11 @@ void flush_fb_raw_blocking() {
} }
} }
// Prepare a chunked (non-blocking) SPI flush. Pre-reverses the framebuffer void flush_fb() {
// into fb_tx via the LUT and sets flush_progress = 0. The actual SPI draw_button_chrome();
// transfer is driven by flush_chunk(), called from oled_loop() on flush_fb_raw();
// 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 fb_clear() { memset(fb, 0, sizeof(fb)); }
void px(int x, int y, bool on) { void px(int x, int y, bool on) {
@@ -583,10 +542,8 @@ void handle_buttons() {
// --- Charge ETA tracker -------------------------------------------------- // --- Charge ETA tracker --------------------------------------------------
// The DS5 only reports battery in 10% steps (interrupt_in_data[52] low // The DS5 only reports battery in 10% steps (interrupt_in_data[52] low
// nibble, 0..10; high nibble is power-state, 1 == charging). We display the // nibble, 0..10; high nibble is power-state, 1 == charging). We can't read a
// midpoint of each band (+5), matching the kernel hid-playstation driver and // finer percentage over BT, so a smooth countdown is impossible. Instead we
// 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 // time how long each 10% step takes while charging and extrapolate the
// remaining steps. Sampled once per frame from oled_loop (continuously, so // 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 // the estimate stays current even while the panel is dimmed/off and even when
@@ -744,8 +701,8 @@ __attribute__((noinline)) void render_screen() {
draw_text(kContentX, 9, buf); draw_text(kContentX, 9, buf);
const uint8_t pwr = interrupt_in_data[52]; const uint8_t pwr = interrupt_in_data[52];
int raw = pwr & 0x0F; int pct = (pwr & 0x0F) * 10;
int pct = (raw >= 10) ? 100 : raw * 10 + 5; if (pct > 100) pct = 100;
const uint8_t pstate = pwr >> 4; const uint8_t pstate = pwr >> 4;
char marker = ' '; char marker = ' ';
if (pstate == 1) marker = '+'; // Charging if (pstate == 1) marker = '+'; // Charging
@@ -1600,12 +1557,6 @@ void settings_adjust(int delta) {
} }
case 13: c.bt_mic_enable ^= 1; break; // BT mic on/off case 13: c.bt_mic_enable ^= 1; break; // BT mic on/off
case 14: c.controller_wakes_display ^= 1; break; // controller activity wakes OLED on/off case 14: c.controller_wakes_display ^= 1; break; // controller activity wakes OLED on/off
case 15: { // speaker_rate_trim stored 0..200 = -100..+100 Hz, 1 Hz/step
int v = (int)c.speaker_rate_trim + delta;
if (v < 0) v = 0; if (v > 200) v = 200;
c.speaker_rate_trim = (uint8_t)v;
break;
}
} }
} }
@@ -1709,9 +1660,8 @@ __attribute__((noinline)) void format_settings_item(int idx, char* line, size_t
break; break;
case 13: snprintf(line, n, "%s BT Mic %s", cur, c.bt_mic_enable ? "on" : "off"); break; case 13: snprintf(line, n, "%s BT Mic %s", cur, c.bt_mic_enable ? "on" : "off"); break;
case 14: snprintf(line, n, "%s CtrlWake %s", cur, c.controller_wakes_display ? "on" : "off"); break; case 14: snprintf(line, n, "%s CtrlWake %s", cur, c.controller_wakes_display ? "on" : "off"); break;
case 15: snprintf(line, n, "%s SpkTrim %+dHz", cur, (int)c.speaker_rate_trim - 100); break; case 15: snprintf(line, n, "%s Reset to defaults", cur); break;
case 16: snprintf(line, n, "%s Reset to defaults", cur); break; case 16: snprintf(line, n, "%s Wipe all slots", cur); break;
case 17: snprintf(line, n, "%s Wipe all slots", cur); break;
} }
} }
@@ -1857,15 +1807,13 @@ void boot_splash() {
draw_text(cx_for(l1), 16, l1); draw_text(cx_for(l1), 16, l1);
draw_text(cx_for(l2), 30, l2); draw_text(cx_for(l2), 30, l2);
draw_text(cx_for(l3), 44, l3); draw_text(cx_for(l3), 44, l3);
draw_button_chrome(); flush_fb();
flush_fb_raw_blocking();
sleep_ms(1500); sleep_ms(1500);
} }
} // namespace } // namespace
void oled_init() { void oled_init() {
for (int i = 0; i < 256; i++) reverse_lut[i] = reverse_byte((uint8_t)i);
spi_init(spi1, 10 * 1000 * 1000); spi_init(spi1, 10 * 1000 * 1000);
gpio_set_function(kPinCLK, GPIO_FUNC_SPI); gpio_set_function(kPinCLK, GPIO_FUNC_SPI);
gpio_set_function(kPinMOSI, GPIO_FUNC_SPI); gpio_set_function(kPinMOSI, GPIO_FUNC_SPI);
@@ -1926,10 +1874,6 @@ void oled_loop() {
handle_buttons(); handle_buttons();
const uint32_t now = time_us_32(); const uint32_t now = time_us_32();
rumble_burst_tick(now); rumble_burst_tick(now);
if (flush_progress >= 0) {
flush_chunk();
return;
}
if ((now - last_render_us) < kFrameUs) return; if ((now - last_render_us) < kFrameUs) return;
last_render_us = now; last_render_us = now;
// Track charge progress every frame — before the power-ladder early-returns // Track charge progress every frame — before the power-ladder early-returns
-10
View File
@@ -82,16 +82,6 @@ void state_update(const uint8_t *data, const uint8_t size) {
set_bit(state[0], 0, update.EnableRumbleEmulation); set_bit(state[0], 0, update.EnableRumbleEmulation);
set_bit(state[0], 1, update.UseRumbleNotHaptics); 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); set_bit(state[38], 2, update.EnableImprovedRumbleEmulation);
copy_if_allowed( copy_if_allowed(
update.UseRumbleNotHaptics || update.EnableRumbleEmulation, 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_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_IN_SZ_MAX CFG_TUD_AUDIO_FUNC_1_FORMAT_1_EP_SZ_IN
#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_OUT_SW_BUF_SZ (16 * 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) #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) // Enable OUT EP (speaker) and IN EP (mic)
+41 -22
View File
@@ -26,6 +26,8 @@
#include "bsp/board_api.h" #include "bsp/board_api.h"
#include "tusb.h" #include "tusb.h"
#include "config.h" #include "config.h"
#include "bt.h"
#include "slots.h"
#ifndef ENABLE_SERIAL #ifndef ENABLE_SERIAL
#define ENABLE_SERIAL 0 #define ENABLE_SERIAL 0
@@ -370,7 +372,7 @@ uint8_t descriptor_configuration[] = {
0x00, // bCountryCode: Not localized 0x00, // bCountryCode: Not localized
0x01, // bNumDescriptors: 1 report descriptor 0x01, // bNumDescriptors: 1 report descriptor
0x22, // bDescriptorType: Report 0x22, // bDescriptorType: Report
0x41, 0x01, // wDescriptorLength: 321 (0x0141) DS 0x21, 0x01, // wDescriptorLength: 289 (0x0121) DS (F6-F9 removed to byte-match a real DS5)
// 0xB5, 0x01, // wDescriptorLength: 437 (0x01B5) DSE // 0xB5, 0x01, // wDescriptorLength: 437 (0x01B5) DSE
// Endpoint Descriptor (HID IN: EP4) // Endpoint Descriptor (HID IN: EP4)
@@ -416,7 +418,7 @@ uint8_t const *tud_descriptor_configuration_cb(uint8_t index) {
descriptor_configuration[offset - 1] = bInterval; descriptor_configuration[offset - 1] = bInterval;
descriptor_configuration[offset - 8] = bInterval; descriptor_configuration[offset - 8] = bInterval;
if (ds_mode()) { if (ds_mode()) {
descriptor_configuration[offset - 16] = 0x41; descriptor_configuration[offset - 16] = 0x21; // wDescriptorLength lo = 289 (0x0121); F6-F9 removed to byte-match a real DS5
}else { }else {
descriptor_configuration[offset - 16] = 0xB5; descriptor_configuration[offset - 16] = 0xB5;
} }
@@ -569,26 +571,17 @@ uint8_t const desc_hid_report_ds[] = {
0x09, 0x36, // Usage (0x36) 0x09, 0x36, // Usage (0x36)
0x95, 0x03, // Report Count (3) 0x95, 0x03, // Report Count (3)
0xB1, 0x02, // Feature (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile) 0xB1, 0x02, // Feature (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
0x85, 0xF6, // Report ID (-10) // NOTE: web-config feature reports 0xF6-0xF9 are intentionally NOT declared
0x09, 0x37, // Usage (Vendor 0x37) // here. Declaring them made this descriptor 321 bytes vs a genuine DS5's 289,
0x95, 0x3F, // Report Count (63) // and games' native DualSense parser rejected the mismatch -> no adaptive
0xB1, 0x02, // Feature (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile) // triggers in-game (the long-standing "triggers only work in the OLED test"
0x85, 0xF7, // Report ID (-9) // bug). The firmware still HANDLES 0xF6-0xF9 (src/cmd.cpp); on Linux they
0x09, 0x38, // Usage (Vendor 0x38) // work fine undeclared over hidraw, exactly like 0xFD. Trade-off: the browser
0x95, 0x3F, // Report Count (63) // WebHID config tool can't reach them (OLED + hidraw config still work).
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 0xC0, // End Collection
// 321 bytes // 289 bytes — byte-identical to a real DualSense
}; };
static_assert(sizeof(desc_hid_report_ds) == 0x0141); static_assert(sizeof(desc_hid_report_ds) == 0x0121);
uint8_t const desc_hid_report_dse[] = { uint8_t const desc_hid_report_dse[] = {
0x05, 0x01, // Usage Page (Generic Desktop Ctrls) 0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
@@ -858,9 +851,35 @@ uint16_t const *tud_descriptor_string_cb(uint8_t index, uint16_t langid) {
chr_count = 1; chr_count = 1;
break; break;
case STRID_SERIAL: case STRID_SERIAL: {
chr_count = board_usb_get_serial(_desc_str + 1, 32); // 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);
}
break; break;
}
default: default:
// Note: the 0xEE index string is a Microsoft OS 1.0 Descriptors. // Note: the 0xEE index string is a Microsoft OS 1.0 Descriptors.