Files
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

18 KiB
Raw Permalink Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

A Raspberry Pi Pico 2 W (RP2350) firmware that bridges a Sony DualSense (DS5) controller over Bluetooth Classic to a USB host. Presents itself to the host as a USB HID + UAC1 audio composite device. This repo is MarcelineVPQ/DS5Dongle-OLED-Edition, a personal fork of upstream awalol/DS5Dongle that adds an optional Waveshare Pico-OLED-1.3 status display (10 screens) and a 4-slot persistent multi-controller pairing layer.

README.md is authoritative for user-facing capability; CHANGELOG.md tracks what shipped per release. Read both before proposing changes — much of the design is non-obvious from the code alone (audit history, BT pairing-posture rationale, overclock requirement).

Build

This is firmware — there are no tests. Verification is hardware-in-the-loop (see "Hardware-in-the-loop workflow" below).

The build is non-trivial to set up because it requires a specific TinyUSB version:

# Pico SDK 2.2.0 must be installed; export PICO_SDK_PATH.
# CRITICAL: TinyUSB must be pinned to 0.20.0. The 0.18.0 that ships with
# Pico SDK 2.2.0 lacks the 4-arg form of TUD_AUDIO_EP_SIZE used by
# src/tusb_config.h — compile errors with no clear pointer to the cause.
cd "$PICO_SDK_PATH/lib/tinyusb"
git fetch --tags && git checkout 0.20.0

# Build
cd /path/to/DS5Dongle
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DPICO_SDK_PATH="$PICO_SDK_PATH"
cmake --build build --target ds5-bridge
# Produces build/ds5-bridge-oled.uf2  (output name set in CMakeLists.txt, not the default)

Common build flags (set via -D on the cmake invocation):

  • ENABLE_SERIAL=ON — route printf to USB CDC for debugging. Default OFF (production).
  • ENABLE_VERBOSE=ON — chatty BT/HID logging. Default OFF.
  • ENABLE_BATT_LED=OFF — disable the low-battery LED blink. Default ON.
  • PICO_W_BUILD=ON — build for the original Pico W (drops audio, lower clock). Default targets Pico 2 W.

CI runs four build variants in .github/workflows/build.yml (standard / debug / no-batt-led / Pico W). When changing build flags or CMake, update the workflow accordingly.

Hardware-in-the-loop workflow

There are no software tests. To verify a change:

  1. Build → produces build/ds5-bridge-oled.uf2.
  2. Put Pico into bootloader: hold BOOTSEL, plug USB. It mounts as /run/media/$USER/RP2350 (Linux) or RPI-RP2 (others).
  3. Copy the UF2 to the mount: cp build/ds5-bridge-oled.uf2 /run/media/$USER/RP2350/ — Pico auto-reboots into the new firmware.
  4. Pair a DualSense: hold Create + PS on the controller until the lightbar pulses; the dongle inquiry picks it up.
  5. Verify enumeration host-side: lsusb | grep 054c:0ce6 should show Sony Corp. DualSense wireless controller (PS5).

The development cadence is one feature per UF2 + checkpoint with the user before moving on — the user has hardware in front of them, regressions are caught the same minute, and large multi-feature commits make root-causing hard.

Architecture

Two cores, asymmetric workload:

  • Core 0 runs everything: main() infinite loop in src/main.cpp drives cyw43_arch_poll() → BTstack, tud_task() → TinyUSB, audio_loop() (USB → BT haptic path), interrupt_loop() (BT → USB HID path), and oled_loop().
  • Core 1 runs only core1_entry() in src/audio.cpp — the Opus speaker encoder. The two cores communicate via Pico SDK queue_t (audio_fifo) and a critical section (opus_cs).

Two parallel data paths through the firmware:

  1. HID (DS5 input → host, host output → DS5): src/bt.cpp registers on_bt_data()src/main.cpp:on_bt_data() → updates interrupt_in_data[63]tud_hid_report(). Output reports come the other way through tud_hid_set_report_cb.
  2. Audio (host USB audio → DS5 speaker + haptics): src/audio.cpp:audio_loop() reads 4-channel USB UAC1, splits ch1/2 (speaker → Opus encode on core1 → BT 0x32 report) and ch3/4 (haptic → resample 48k→3k → BT 0x32 report).

BT layer: Uses Pico SDK's bundled BTstack (no separate clone). src/bt.cpp is the only file that touches HCI/L2CAP. PSMs are PSM_HID_CONTROL and PSM_HID_INTERRUPT. Link keys live in BTstack's TLV NVM with NVM_NUM_LINK_KEYS=4 (configured in src/btstack_config.h).

OLED add-on is optional and self-contained: All 10 screens, the SH1107 SPI driver, the 5×7 font, the icon table, and the input handling live in src/oled.cpp (~30 KB). The only outward dependencies are read-only accessors (bt_is_connected, bt_get_addr, audio_peak_*, bt_get_signal_strength, the new slot accessors) and the global interrupt_in_data[63] (read-only for input visualization). When no OLED is wired, the SPI writes go nowhere — no init check needed.

Idle power ladder (oled_loop tail): Three-stage state machine (OLED_ACTIVEOLED_DIMOLED_OFF) driven by last_activity_us. kAutoDimUs = 2 min enters Dim — the regular per-screen render is replaced with render_dim_pulse(), which clears the framebuffer and walks a 2×2 dot through 8 positions every 30 s, blinking 1 s on / 1 s off. kAutoOffUs = 15 min enters Off — cmd(0xAE) puts the SH1107 to sleep and oled_loop returns early before rendering. Wakes on KEY0/KEY1 (via handle_buttons bumping last_activity_us), bt_is_connected() rising edge, or any change in the interrupt_in_data[0..9] hash. The dim tier uses flush_fb_raw() (the chrome-less variant) since there's no nav target while asleep. Why this shape: the SH1107 contrast register has a heavily non-linear perceptual curve on the Waveshare panel — even 0x02 looks ~90 % as bright as 0xFF. The only reliable "dim" available is rendering fewer pixels.

Multi-slot pairing (Phase G): Storage is two-tier:

  • Link keys stay in BTstack's TLV NVM (4 slots, unchanged from upstream).
  • bd_addrs + occupancy in a custom flash sector owned by src/slots.cpp — sector below the config sector (PICO_FLASH_SIZE_BYTES - 2*FLASH_SECTOR_SIZE).

Slot routing is enforced in src/bt.cpp's HCI_EVENT_INQUIRY_RESULT handler: skip devices owned by other slots, accept only the current slot's bd_addr if occupied, auto-assign on first L2CAP_EVENT_CHANNEL_OPENED for HID_CONTROL when the current slot is empty. Switching slots flows: bt_set_slot() → persist new index → bt_disconnect()HCI_EVENT_DISCONNECTION_COMPLETE → restart inquiry under new filter.

Flash layout (top of flash, growing downward):

Sector Owner Contents
PICO_FLASH_SIZE_BYTES - 1*FLASH_SECTOR_SIZE src/config.cpp Config{magic, version, crc32, size, body} — 9 user-tunable fields incl. current_slot
PICO_FLASH_SIZE_BYTES - 2*FLASH_SECTOR_SIZE src/slots.cpp SlotsData{magic, addrs[4][6], occupied[4]}

config_save() and save_slots_to_flash() both use save_and_disable_interrupts() + flash_range_erase + flash_range_program. PICO_FLASH_ASSUME_CORE1_SAFE=1 is set in CMakeLists.txt — flash writes happen while core1 may be running, but core1's hot path (Opus encode) does not access XIP during the brief erase/program window.

Critical gotchas

These are non-obvious from the code; they cost time when forgotten.

  • Overclock 320 MHz @ 1.20 V is load-bearing. Dropping voltage to 1.10 V or clock to stock breaks the CYW43 PIO SPI bus (BT chip becomes unreachable). Tested. Don't "optimize" src/main.cpp:main()'s vreg_set_voltage(VREG_VOLTAGE_1_20) + set_sys_clock_khz(SYS_CLOCK_KHZ, true) block.

  • Render functions are __attribute__((noinline)) on purpose. With aggressive inlining the compiler folds all 10 render_screen_* into oled_loop(), which then exceeds Thumb's 4 KB literal-pool reach and the linker emits dangerous relocation: unsupported relocation. If you remove the noinline attributes or merge two render functions into a single huge one, the build will fail at link time. The symbol size grows over time as screens get richer — the lambda inside render_screen_settings was already hoisted to a noinline format_settings_item() for the same reason.

  • config_default() is implemented in this fork, not upstream. Upstream declares it in config.h:30 but never defines it. The implementation in src/config.cpp fills the body with 0xFF and re-runs config_valid() (matches a freshly-erased flash sector). Don't assume any header declaration is implemented just because it compiles — check the .cpp.

  • Screen indices are symbolic. kScreenStatus, kScreenSlots, kScreenLightbar, … are defined in a single block near the top of src/oled.cpp. oled_loop()'s switch and handle_buttons()' KEY1 contextual checks (Trigger preset cycle, Lightbar mode cycle) refer to them by name. Never hard-code integers — reorder = one-block edit.

  • OLED kPin* pinout is hardcoded to the Waveshare Pico-OLED-1.3 standard (kPinMOSI=11, kPinCLK=10, kPinCS=9, kPinDC=8, kPinRST=12, kPinKey0=15, kPinKey1=17). SPI1 instance. Conflicts with anything else on those pins. Pico's BT chip uses internal SPI to CYW43 (different bus), and USB / UART pins (GP0/GP1) are also free — no conflicts in default config.

  • DS5 PS+Mute hold-2-seconds → watchdog_reboot(0,0,0) is the headless soft-reboot path (see src/main.cpp:interrupt_loop()). It checks interrupt_in_data[9] bits 0+2. If you change input report layout, this combo breaks silently.

  • 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 0xF60xF9 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:

  • Release tag (e.g. v0.6.2-oled-edition) → created with git tag then gh release create.
  • .github/workflows/release.yml picks the tag up as $FIRMWARE_VERSION and passes it to CMake via -DVERSION="$FIRMWARE_VERSION".
  • CMakeLists.txt exposes it to C++ as a compile-time macro:
    target_compile_definitions(ds5-bridge PRIVATE FIRMWARE_VERSION="${VERSION}")
    
    Local builds without -DVERSION=... get the default "dev" — that's a deliberate visual signal so an untagged build is obvious on the OLED Status header.
  • src/oled.cpp render_screen() renders "DS5 Bridge " FIRMWARE_VERSION for the Status screen header. No string literal for the version exists anywhere else in the firmware source.
  • Web preview (DS5Dongle-OLED-Config-Web) reads the same tag at runtime from public/firmware-latest.json, which CI bundles in .github/workflows/deploy.yml's "Bundle latest firmware UF2 from GitHub releases" step (it pulls from MarcelineVPQ/DS5Dongle-OLED-Edition/releases/latest via the GitHub API). OledEmulator.tsx fetches that JSON on mount and writes the short form (suffix -oled-edition stripped) into state.firmwareVersionLabel, which screens.ts:renderStatus consumes.

The release ritual is therefore:

  1. Update CHANGELOG.md — move [Unreleased] content into a new [X.Y.Z-oled-edition] section dated today.
  2. Commit the CHANGELOG bump.
  3. git tag -a vX.Y.Z-oled-edition -m "..."
  4. git push origin master && git push origin vX.Y.Z-oled-edition
  5. gh release create vX.Y.Z-oled-edition -R MarcelineVPQ/DS5Dongle-OLED-Edition --title "vX.Y.Z — OLED Edition" --notes "..."
  6. CI builds the UF2s (~57 min), uploads them with SHA256SUMS, edits the release notes to append checksums, and (when WEB_REPO_DISPATCH_PAT is configured on the firmware repo — currently unset, see below) fires a repository_dispatch event to the web repo to refresh firmware-latest.json. Without the secret, the next push to the web repo's master does the refresh instead.

There is no other place to edit the version. If you find a hardcoded version string in source ("v0.6.0", "v0.5.4", etc.), it's a bug — replace it with the macro / JSON lookup.

Known follow-up: WEB_REPO_DISPATCH_PAT secret is unset on the firmware repo, so the firmware-release → web-rebuild dispatch is currently silently no-op'd (peter-evans/repository-dispatch with continue-on-error). The web bundle still updates via push events to the web repo's master, but not automatically on every firmware release.

Git / branch model

  • master (origin) = MarcelineVPQ/DS5Dongle-OLED-Edition (this fork's primary branding, what users download).
  • upstream / upstream-real = awalol/DS5Dongle (read-only mirror for git fetch upstream && git rebase cycles).
  • upstream-fork = MarcelineVPQ/DS5Dongle-upstream-fork (a separate cleanroom fork retained from earlier PR contributions; not the active development branch).

Commits in this repo include a Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> trailer for AI-assisted work.

Where features live

When asked to modify behavior, the first file to read is usually one of:

  • New BT pairing / connection state behavior → src/bt.cpp (HCI + L2CAP event handlers).
  • New OLED screen or change to existing one → src/oled.cpp.
  • New diagnostic counter on the OLED Diagnostics screen → bump kNumDiagRows in src/oled.cpp and add a case to format_diag_row() (single switch, one row per case). The screen scrolls automatically; no D-pad wiring needed. Counters that need rate-per-second arithmetic should be sampled in sample_diag_rates() and read from g_diag_rates. Counter globals themselves typically live in src/main.cpp next to g_bt_31_packets etc. with extern declarations near the top of src/oled.cpp.
  • Host-side diagnostics → scripts/mic_diag.sh (Linux only, reads /dev/hidraw directly). Subcommands: status / capture [secs] / watch / bt-trace. bt-trace reads the firmware's 0xFD vendor feature report (defined in src/cmd.cpp's tud_hid_get_report_cb), which currently exposes BT-input counters + the host-output trigger-flow counters. To add a new counter visible to bt-trace: extend the 0xFD payload in src/cmd.cpp (bump want, write at the new offset), bump IOCTL_SIZE in bt_trace() of the script, and add the field to its decode() dict. The 0xFD report ID is not declared in the HID descriptor (Linux hidraw ioctls don't enforce that; WebHID would reject undeclared IDs, which is why config goes through 0xF6).
  • New persistent config field → src/config.h (struct), src/config.cpp:config_valid() (defaults + clamping), src/oled.cpp:format_settings_item() (UI), src/oled.cpp:settings_adjust() (D-pad ▶◀ behavior). Update CHANGELOG.md.
  • USB descriptor or interface change → src/usb_descriptors.cpp + src/tusb_config.h.
  • Audio / haptic path → src/audio.cpp. Don't add stack arrays sized smaller than the resampler / Opus expects (this is the C1 bug that caused the long-standing "audio stuttering" issue — fix landed in upstream 5b04cbd, but the lesson stands).

Don't do these

  • Don't disable the watchdog without explicit user authorization. watchdog_enable(1000, true) in src/main.cpp:main() is the safety net for hangs.
  • Don't add fallbacks for missing OLED beyond what oled.cpp already does (no init checks, SPI writes harmlessly to nothing) — the firmware must boot identically with or without the display.
  • Don't write a new flash sector backend without updating the static_assert(SECTOR_OFFSET % FLASH_SECTOR_SIZE == 0) pattern from slots.cpp and confirming no overlap with config.cpp's sector.
  • Don't push to upstream or upstream-fork by accident — only origin/master is the personal fork's deployment target.