WIP: feat(haptics): a DualSense's fine haptics, from the game's own bands #405

Draft
Stellarznith wants to merge 4 commits from Stellarznith/Longshot:feat/dualsense-haptics into main
First-time contributor

Type: feat · Risk: low · Tested on: Windows 11, RTX 4060 Ti, DualSense on USB · Needs: Linux, a Switch Pro Controller, a DualSense on Bluetooth

Why

A DualSense on a cable plays the game's vibration through its two motors only. The game writes a value of four floats to nn::hid for every hand: a low band and a high band, each with its own amplitude and frequency. host/nn_hid.c's rumble_map flattens those four into two motor strengths, and only a Switch Pro Controller or a Joy-Con gets them whole, as HD Rumble. So a sword hit, a footstep and a rolling boulder reach a DualSense as the same buzz, and the layering the game wrote is lost.

See #752, the request for exactly this.

What changed

A DualSense on a cable now plays the game's own two bands instead of one buzz. host/haptic.c hands the four floats that nn::hid receives to RenHDrum, which turns them into the pad's audio haptics on its four-channel 48 kHz endpoint, found by name and by channel count and never the desktop's speakers; a Switch Pro Controller keeps its HD Rumble, a DualSense on Bluetooth keeps the standard rumble, and every failure (no DLL, wrong ABI, no endpoint) falls back to the standard rumble with the reason in the menu. The Controls page gains four rows: the mode, the fine haptics' own strength, a read-only status line and a Test haptics action.

  • host/haptic.c, host/haptic.h (new): the haptics back end. It takes the same four floats rumble_map receives and hands them, unflattened, to RenHDrum (third_party/renhdrum, MIT OR Apache-2.0, v0.3.0, ABI 0x000300), which turns them into the DualSense's own audio haptics. The output goes to the controller's four-channel 48 kHz playback endpoint, found by name ("DualSense" or "Wireless Controller") and by four channels, never to the desktop's speakers. renhdrum.dll is loaded by full path from the game's own folder with LoadLibraryExW(..., LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32), its ABI version checked, and every failure (no DLL, wrong version, no endpoint, init refused) falls back to the standard rumble with the reason in the status row. A 1 ms worker keeps a silence watchdog, ends the test buzz on its own, and re-enumerates the audio devices when they change.
  • host/hos_sdl.c, host/hos_sdl.h: the SDL3 calls that endpoint needs — playback device enumeration with each device's format, a stream opened on a named device, its health, and SDL_GetJoystickConnectionState for whether a pad is on a cable.
  • host/pad_sdl.c: the pad's arrival and departure, the mode and both strengths reach the back end each poll; a pad the haptics drive is sent no SDL rumble at the same time (hos_haptic_owns).
  • host/nn_hid.c: vibration_send passes the same four floats to hos_haptic_push, beside the existing hos_sdl_rumble_hd.
  • host/hos_main.c: hos_sdl_haptic_shutdown() once nnMain returns, so the endpoint and the worker are let go.
  • gpu/vk/vk_menu.cpp, gpu/vk/vk_menu.h, gpu/vk/vk_replay.cpp, gpu/vk/vk_api.h: four rows on the Controls page after Rumble strength — Haptic feedback (Auto, Standard rumble, DualSense haptics, Off), Haptic strength (Game's own, or 25-200 % of what the game asks for), the read-only Haptic status line, and Test haptics, a 400 ms buzz that ends by itself. The Rumble strength stays the one master strength and its Off stops every backend; the percent is applied once, as a gain after the DSP, because RenHDrum raises each amplitude to the power 0.5 and normalises two bands that add past 1, so a factor on its input came out as its square root and stopped changing above about 30 % of the game's amplitude.
  • gpu/vk/menu_i18n.h: the new texts in all 13 languages.
  • nx2/build.py, nx2/make_game.py: renhdrum.dll goes beside the game, as the SDL DLLs do. Optional: without it the game plays the standard rumble.
  • README.md, docs/development.md: the four rows, the haptic and haptic_strength ini keys.
  • tests/test_haptics_menu.py (new) and the checks that read the changed source.

Flags and settings

Flag What it does Default now Recommendation Helps on Doesn't help on Untested on
haptic (ini, the Haptic feedback row) 0 Auto, 1 Standard rumble, 2 DualSense haptics, 3 Off 0 (Auto) on by default: Auto only drives a DualSense on a cable and leaves every other pad as it was a DualSense on USB a DualSense on Bluetooth (no four-channel sound card there; its HID haptics reports are not implemented in this build, so standard rumble), a Switch Pro Controller (keeps its HD Rumble) the other pads' motors, unchanged
haptic_strength (ini, the Haptic strength row) 0 the game's own level, else 25-200 % of it 0 on by default at 0: no change from what the game sends a DualSense on USB every other backend (this is the fine haptics' own strength) a DualSense on Bluetooth (HID haptics not implemented)
renhdrum.dll beside the game the RenHDrum C ABI the haptics go through absent from git; make_game.py copies it from third_party/renhdrum/ when it is there ship it with the game folder a DualSense on USB everything else -

Measurements

Verdict: it works and it is not a subtle difference: the same game values that reached a DualSense as one buzz now reach it as the game's own two bands, and the strength control multiplies the coils' output straight (25 / 50 / 100 / 150 / 200 % of the peak, measured), with no clipping and no rebuild of the audio stream. Nothing regressed: every check that fails here fails on main too.

Hardware: Windows 11 Pro 26300, RTX 4060 Ti 16 GB, NVIDIA 617.42, i5-14600KF, 32 GB
Scene: a copied save in Kokiri Forest (adult), the pause menu's System page and the gameplay behind it; 1589x720 window, default options, frame generation off
Method: the game's own logs/longshot_log.txt lines — haptic: for the back end's state and the peaks it played, vk: ... presents, ... fps for the frame rate; one run of this PR, with the five Test haptics buzzes in it
Baseline: the Before column is a run of the same save on the same PC with the same options, haptic absent from its ini (the key does not exist there); its log is the one the game writes to logs/longshot_log.txt. The haptics were measured before the rebase onto aa95d35f; the rebase moved option ids and nothing in the audio path (see Reviewer notes).
How to reproduce: python -I -B nx2/make_game.py --romfs <romfs> --exefs <.longshot_exefs> --out <dir> --jobs 12 --no-prepare-shaders with LONGSHOT_MINGW set, copy SDL2.dll and SDL3.dll beside the exe, set button_prompts=4 and haptic=0 in longshot_options.ini, start it with a DualSense on a USB cable, Continue, then the pause menu's System page and Test haptics

Before: main @ aa95d35f After: this PR @ 083f33b
A DualSense on USB playing the game's own bands the two motors only: the four floats flattened by rumble_map, and not one haptic: line in the whole run (0 matches for haptic and for renhdrum in main @ aa95d35f's log; hid: 4096 vibration values, strongest 0.00 since the last count is all it says about vibration) haptic: audio endpoint "扬声器 (DualSense Wireless Controller)": 4 channels, haptic: 4-channel 48 kHz audio on the DualSense's endpoint, 203,120 values in the longest 5 s window, peaks 0.288 on the page and 0.799 on the test buzz
Test haptics no such row five haptic: test, 400 ms lines, each ending on its own
Hot-plug - unplugged: 6 playback devices, none of them a DualSense with four channels yet, waiting for the DualSense's audio endpoint: plug it in by cable; plugged back: the endpoint reopened, no crash, no stuck vibration
The game's own bands reaching the coils the four floats flattened, so one buzz per event the six values the game sent (hid: vibration #38304: handle 00000003, low 0.06 @ 178 Hz, high 0.02 @ 306 Hz, log lines 4706-4711, before any Test haptics) played through the shipped renhdrum.dll peak at 0.309 / 0.306 / 0.292; the run's peak 0.288 on log line 4803, 1426 lines before the first test buzz (line 6229), is that
Strength, applied once on the coils' samples no such control 25 % -> 0.077273, 50 % -> 0.154547, 100 % -> 0.309093, 150 % -> 0.463640, 200 % -> 0.618187 on the game's 0.06/0.02 values: 1.000x / 1.500x / 2.000x of the 100 % peak, straight, no clipping. Scaled before the DSP instead it would read 1.225x / 1.414x, which is the 40 %-comes-out-at-16 % failure
Frame rate, the same scenes not measured in this pair (the haptics ride a 1 ms worker and a 48 kHz stream beside the game) vk: 53880 presents, 60.0 fps, vk: 53760 presents, 60.1 fps
[hos host] haptic: renhdrum.dll 0.3.0 loaded
[hos host] haptic: audio endpoint "扬声器 (DualSense Wireless Controller)": 4 channels
[hos host] haptic: 4-channel 48 kHz audio on the DualSense's endpoint
[hos host] haptic: worker started
[hos host] haptic: pad "DualSense Wireless Controller": 4-channel 48 kHz audio on the wire
[hos host] haptic: DualSense haptics (USB), 38738 values, 155026 audio fills, 16128 loud samples, peak 0.288
[hos host] haptic: test, 400 ms
[hos host] haptic: DualSense haptics (USB), 55102 values, 171350 audio fills, 99784 loud samples, peak 0.799
[hos host] hid: controller "DualSense Wireless Controller" disconnected (SDL)
[hos host] haptic: 6 playback devices, none of them a DualSense with four channels yet
[hos host] haptic: pad "DualSense Wireless Controller": waiting for the DualSense's audio endpoint: plug it in by cable
[hos host] haptic: audio endpoint "扬声器 (DualSense Wireless Controller)": 4 channels
[hos host] haptic: 4-channel 48 kHz audio on the DualSense's endpoint

Flags and settings

It adds two menu rows on the Controls page (Haptic feedback, Haptic strength), a read-only status row and a Test haptics action, and with them the ini keys haptic and haptic_strength; renhdrum.dll is the one new file beside the game.

Flag What it is Before this PR After this PR Effect Helps / doesn't help / untested Recommendation
haptic (the Haptic feedback row) 0 Auto, 1 Standard rumble, 2 DualSense haptics, 3 Off the key does not exist 0 (Auto) Auto drives a DualSense on a cable from the game's bands and leaves every other pad as it was helps: a DualSense on USB. doesn't: a DualSense on Bluetooth (no four-channel sound card there, and this build does not implement its HID haptics reports), a Switch Pro Controller (keeps its HD Rumble). untested: the other pads' motors, unchanged on by default: Auto changes nothing for anyone without a cabled DualSense
haptic_strength (the Haptic strength row) 0 the game's own level, else 25-200 % of it the key does not exist 0 scales the fine haptics alone, once, as a gain on the DSP's output helps: a DualSense on USB. doesn't: every other backend. untested: a DualSense on Bluetooth on by default at 0: no change from what the game sends
renhdrum.dll beside the game the RenHDrum C ABI the haptics go through not there copied from third_party/renhdrum/ by nx2/build.py and nx2/make_game.py when the file is present turns the four floats into the pad's audio haptics helps: a DualSense on USB. doesn't: everything else (the file is not needed) ship it with the game folder; a build without it plays the standard rumble and says so

Testing

Tested: Windows 11 Pro 26300, RTX 4060 Ti, NVIDIA 617.42, i5-14600KF, 32 GB, llvm-mingw 20261104. In the game: a copied save, Kokiri Forest (adult) — Continue from the title screen, the pause menu's System page, Test haptics five times, the gameplay behind it, and a USB unplug and replug in the middle of the run; the log lines above come from that run, and docs/tested/2026-11-14-dualsense-haptics-yg-run01.md records it. Game-free: python -I -B tests/run_all.py on the rebased tree (083f33b, LONGSHOT_MINGW set, no CC/CXX) — 504 passed, 19 failed, 72 skipped; the same suite on a worktree of main @ aa95d35f — 503 passed, 18 failed, 72 skipped, its failure set exactly this one's minus test_mesh_args_cleanup, which passes on its own here (a file-lock race under the runner's 20 parallel checks, not a check on this diff). The shared failures are the ones this PC cannot satisfy: test_bug_report (a Chinese-locale console read as UTF-8), test_cpp_preflight, test_appimage_player_build, test_crash_dialog, test_cpm_discovery, test_env_on, test_lift_cleanup_error, test_make_game_cache, test_make_game_prepare, test_missing_nup, test_options_budget_once, test_readback_late, test_vk_pgo, test_walk_key, test_release, test_linux_preflight and test_oot_state_tap (a tool, a dependency or a POSIX facility this host lacks), and test_tested_configurations (a CRLF/LF difference in a piped read on Windows). The new tests/test_haptics_menu.py reads the rows, their steps, the read-only status, the option ids, and host/haptic.c's loading, ABI check, gain placement and callback rules; tests/test_renhdrum_pin.py drives scripts/renhdrum.py over a synthetic PE (a pinned copy passes, one importing VCRUNTIME140.dll is refused and says why, one missing an export names it, other bytes fail, a damaged file does not end the run with a traceback, and no copy at all says what to do).

Not tested: a DualSense on Bluetooth (Bluetooth exposes no four-channel sound card, and this build does not implement the pad's HID haptics reports, so the status row says so and the standard rumble plays; the row itself was not exercised), a Switch Pro Controller and a Joy-Con in this build (their HD Rumble path is untouched but unmeasured here), and the Linux build (this host has no WSL or container; host/haptic.c keeps every Win32 and RenHDrum symbol inside #ifdef _WIN32, so the file compiles to the fallback there, but that is a reading, not a build).

Must be tested:

  • Linux, any GPU (I'm on Windows): start the game and check that every pad keeps its standard rumble and the log says haptic: ... the standard rumble only, expecting no build error and no crash (by hand: whoever takes T-2ZP5, or the Test Farm with @Beanstalk linux).
  • Windows, any GPU, a DualSense on Bluetooth: check that the Haptic status row reads "DualSense Bluetooth (standard rumble)" and the motors buzz, expecting no audio endpoint opened and no crash (by hand: T-2ZP5).
  • Windows, a Switch Pro Controller and a Joy-Con pair: check that HD Rumble is as it was on main, expecting the same feel and the same hid: ... HD rumble lines (by hand: T-2ZP5).

Risks

What could break: a wrong endpoint chosen on a machine whose speakers are named "DualSense" (the name and the four channels are both required); an audio device change mid-game leaving the stream open (the worker re-enumerates every 2 s and closes the old stream before the DSP); the haptics thread on exit (the stream is destroyed before the DSP, and hos_main.c calls the shutdown once nnMain returns).

Turning it off: Haptic feedback = Off, or Rumble strength = Off, which stops every backend; haptic=3 in longshot_options.ini does the same for a build that will not start; removing renhdrum.dll falls back to the standard rumble. Reverting the commits is enough to get main's behaviour back.

Compatibility: longshot_options.ini gains two keys (haptic, haptic_strength), written on the next save of the options; a file without them loads as Auto and Game's own. Saves, the caches and the builder's tools are untouched, and MKWVK_API_VERSION stays as it is (no member of MkwVkHost or MkwVkRenderer moves).

Issues

See #752 — the request for RenHD Rumble in Longshot, which this PR answers.

Checklist

Everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description) of the status line's type.
  • Why gives the reason plainly and links the issues that fit (the tracker searched).
  • Measurements has a verdict, or one line saying why there is nothing to measure.
  • Testing names what was tested, what wasn't, and what must still be tested, where and how.
  • Rebased on current main.
  • python -I -B tests/run_all.py passes (any failure that also fails on main is named).
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

When it applies

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • Numbers from the same PC, scene and settings, at least 2 interleaved runs each, the median reported. (Not yet: the run recorded in docs/tested/ is one run of this PR and one of main, on the same PC, save and settings; the numbers are counts and peaks from the log rather than frame times, so a second pair is worth having before this leaves WIP.)
  • Switches documented (docs/development.md; the README for a menu row) and shipped at their recommended value.
  • Screenshots of the same spot, captioned like the Measurements columns, for a change that shows on screen.
  • A file in docs/tested/ for each new measured run (format in docs/tested-configurations.md).
  • A game-free test added for the fix, failing on main and passing here.
  • A check under tests/ that reads changed source updated in this PR, not weakened.
  • Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. (Windows: built and run. Linux: not built here — this PC has no WSL and no container; host/haptic.c keeps every Win32 and RenHDrum symbol inside #ifdef _WIN32, which is a reading of the file, not a build. This is one of the things T-2ZP5 asks for.)
  • Docs updated.

Reviewer notes

host/haptic.c is the file to read first: the lock order is g_life then one of g_host, g_eng, g_audio, g_note, never two inner ones at once, and the audio callback takes g_eng only, for one fill. The strength gain sits in endpoint_pull, not in hos_haptic_push, and the comment there says why (RenHDrum's a^0.5 and its normalisation). third_party/renhdrum/PROVENANCE.md records the version, the licence and what is not used (the GPL integration folders). gpu/vk/vk_api.h changes only by four HOS_OPT_* ids in the existing enum: no member of MkwVkHost or MkwVkRenderer moves, so MKWVK_API_VERSION stays as it is. They are 63, 64, 65 and 66 (mode, status, strength, test): the branch first took 58, and main's render thread took 58 while this was open, so the haptics moved past it rather than renumbering main's row. python -I tools/check_opt_ids.py over every id in the header, implicit enum sequences included, finds 39 ids and no value used twice.

The rebase onto aa95d35f. The branch was 58 commits behind; eight files conflicted and each was settled by keeping both sides' meaning: gpu/vk/vk_api.h (the ids above), gpu/vk/vk_replay.cpp (main's HOS_OPT_SKIP_TITLE and HOS_OPT_RENDER_THREAD rows, then the four haptics), nx2/build.py (the renhdrum.dll copy kept; this branch's vk_prebuilt block dropped, since main removed that mechanism), gpu/vk/vk_menu.cpp (three places: main's weak_laptop_apply, main's richer prompt chain with its field and cheat branches — this branch only adds is_haptic_test_row to that chain's last arm — and main's field_row key branch beside the Test haptics one), gpu/vk/menu_i18n.h (main's 13 language rows, then the counts recomputed: every table sorted, no repeats, all 17 new texts present in all 13), README.md, RELEASING.md and docs/development.md (both sides' paragraphs). The audio path is untouched by the rebase: host/haptic.c, host/nn_hid.c, host/hos_sdl.c and host/pad_sdl.c merged without a conflict.

**Type:** feat · **Risk:** low · **Tested on:** Windows 11, RTX 4060 Ti, DualSense on USB · **Needs:** Linux, a Switch Pro Controller, a DualSense on Bluetooth ## Why A DualSense on a cable plays the game's vibration through its two motors only. The game writes a value of four floats to `nn::hid` for every hand: a low band and a high band, each with its own amplitude and frequency. `host/nn_hid.c`'s `rumble_map` flattens those four into two motor strengths, and only a Switch Pro Controller or a Joy-Con gets them whole, as HD Rumble. So a sword hit, a footstep and a rolling boulder reach a DualSense as the same buzz, and the layering the game wrote is lost. See #752, the request for exactly this. ## What changed A DualSense on a cable now plays the game's own two bands instead of one buzz. `host/haptic.c` hands the four floats that `nn::hid` receives to RenHDrum, which turns them into the pad's audio haptics on its four-channel 48 kHz endpoint, found by name and by channel count and never the desktop's speakers; a Switch Pro Controller keeps its HD Rumble, a DualSense on Bluetooth keeps the standard rumble, and every failure (no DLL, wrong ABI, no endpoint) falls back to the standard rumble with the reason in the menu. The Controls page gains four rows: the mode, the fine haptics' own strength, a read-only status line and a Test haptics action. - `host/haptic.c`, `host/haptic.h` (new): the haptics back end. It takes the same four floats `rumble_map` receives and hands them, unflattened, to RenHDrum (`third_party/renhdrum`, MIT OR Apache-2.0, v0.3.0, ABI `0x000300`), which turns them into the DualSense's own audio haptics. The output goes to the controller's four-channel 48 kHz playback endpoint, found by name ("DualSense" or "Wireless Controller") and by four channels, never to the desktop's speakers. `renhdrum.dll` is loaded by full path from the game's own folder with `LoadLibraryExW(..., LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32)`, its ABI version checked, and every failure (no DLL, wrong version, no endpoint, init refused) falls back to the standard rumble with the reason in the status row. A 1 ms worker keeps a silence watchdog, ends the test buzz on its own, and re-enumerates the audio devices when they change. - `host/hos_sdl.c`, `host/hos_sdl.h`: the SDL3 calls that endpoint needs — playback device enumeration with each device's format, a stream opened on a named device, its health, and `SDL_GetJoystickConnectionState` for whether a pad is on a cable. - `host/pad_sdl.c`: the pad's arrival and departure, the mode and both strengths reach the back end each poll; a pad the haptics drive is sent no SDL rumble at the same time (`hos_haptic_owns`). - `host/nn_hid.c`: `vibration_send` passes the same four floats to `hos_haptic_push`, beside the existing `hos_sdl_rumble_hd`. - `host/hos_main.c`: `hos_sdl_haptic_shutdown()` once `nnMain` returns, so the endpoint and the worker are let go. - `gpu/vk/vk_menu.cpp`, `gpu/vk/vk_menu.h`, `gpu/vk/vk_replay.cpp`, `gpu/vk/vk_api.h`: four rows on the Controls page after Rumble strength — Haptic feedback (Auto, Standard rumble, DualSense haptics, Off), Haptic strength (Game's own, or 25-200 % of what the game asks for), the read-only Haptic status line, and Test haptics, a 400 ms buzz that ends by itself. The Rumble strength stays the one master strength and its Off stops every backend; the percent is applied once, as a gain after the DSP, because RenHDrum raises each amplitude to the power 0.5 and normalises two bands that add past 1, so a factor on its input came out as its square root and stopped changing above about 30 % of the game's amplitude. - `gpu/vk/menu_i18n.h`: the new texts in all 13 languages. - `nx2/build.py`, `nx2/make_game.py`: `renhdrum.dll` goes beside the game, as the SDL DLLs do. Optional: without it the game plays the standard rumble. - `README.md`, `docs/development.md`: the four rows, the `haptic` and `haptic_strength` ini keys. - `tests/test_haptics_menu.py` (new) and the checks that read the changed source. ## Flags and settings | Flag | What it does | Default now | Recommendation | Helps on | Doesn't help on | Untested on | |---|---|---|---|---|---|---| | `haptic` (ini, the Haptic feedback row) | 0 Auto, 1 Standard rumble, 2 DualSense haptics, 3 Off | 0 (Auto) | on by default: Auto only drives a DualSense on a cable and leaves every other pad as it was | a DualSense on USB | a DualSense on Bluetooth (no four-channel sound card there; its HID haptics reports are not implemented in this build, so standard rumble), a Switch Pro Controller (keeps its HD Rumble) | the other pads' motors, unchanged | | `haptic_strength` (ini, the Haptic strength row) | 0 the game's own level, else 25-200 % of it | 0 | on by default at 0: no change from what the game sends | a DualSense on USB | every other backend (this is the fine haptics' own strength) | a DualSense on Bluetooth (HID haptics not implemented) | | `renhdrum.dll` beside the game | the RenHDrum C ABI the haptics go through | absent from git; `make_game.py` copies it from `third_party/renhdrum/` when it is there | ship it with the game folder | a DualSense on USB | everything else | - | ## Measurements **Verdict:** it works and it is not a subtle difference: the same game values that reached a DualSense as one buzz now reach it as the game's own two bands, and the strength control multiplies the coils' output straight (25 / 50 / 100 / 150 / 200 % of the peak, measured), with no clipping and no rebuild of the audio stream. Nothing regressed: every check that fails here fails on `main` too. Hardware: Windows 11 Pro 26300, RTX 4060 Ti 16 GB, NVIDIA 617.42, i5-14600KF, 32 GB Scene: a copied save in Kokiri Forest (adult), the pause menu's System page and the gameplay behind it; 1589x720 window, default options, frame generation off Method: the game's own `logs/longshot_log.txt` lines — `haptic:` for the back end's state and the peaks it played, `vk: ... presents, ... fps` for the frame rate; one run of this PR, with the five Test haptics buzzes in it Baseline: the `Before` column is a run of the same save on the same PC with the same options, `haptic` absent from its ini (the key does not exist there); its log is the one the game writes to `logs/longshot_log.txt`. The haptics were measured before the rebase onto `aa95d35f`; the rebase moved option ids and nothing in the audio path (see Reviewer notes). How to reproduce: `python -I -B nx2/make_game.py --romfs <romfs> --exefs <.longshot_exefs> --out <dir> --jobs 12 --no-prepare-shaders` with `LONGSHOT_MINGW` set, copy `SDL2.dll` and `SDL3.dll` beside the exe, set `button_prompts=4` and `haptic=0` in `longshot_options.ini`, start it with a DualSense on a USB cable, Continue, then the pause menu's System page and Test haptics | | Before: main @ aa95d35f | After: this PR @ 083f33b | |---|---|---| | A DualSense on USB playing the game's own bands | the two motors only: the four floats flattened by `rumble_map`, and not one `haptic:` line in the whole run (0 matches for `haptic` and for `renhdrum` in `main @ aa95d35f`'s log; `hid: 4096 vibration values, strongest 0.00 since the last count` is all it says about vibration) | `haptic: audio endpoint "扬声器 (DualSense Wireless Controller)": 4 channels`, `haptic: 4-channel 48 kHz audio on the DualSense's endpoint`, 203,120 values in the longest 5 s window, peaks 0.288 on the page and 0.799 on the test buzz | | Test haptics | no such row | five `haptic: test, 400 ms` lines, each ending on its own | | Hot-plug | - | unplugged: `6 playback devices, none of them a DualSense with four channels yet`, `waiting for the DualSense's audio endpoint: plug it in by cable`; plugged back: the endpoint reopened, no crash, no stuck vibration | | The game's own bands reaching the coils | the four floats flattened, so one buzz per event | the six values the game sent (`hid: vibration #38304: handle 00000003, low 0.06 @ 178 Hz, high 0.02 @ 306 Hz`, log lines 4706-4711, before any Test haptics) played through the shipped `renhdrum.dll` peak at 0.309 / 0.306 / 0.292; the run's peak 0.288 on log line 4803, 1426 lines before the first test buzz (line 6229), is that | | Strength, applied once on the coils' samples | no such control | 25 % -> 0.077273, 50 % -> 0.154547, 100 % -> 0.309093, 150 % -> 0.463640, 200 % -> 0.618187 on the game's 0.06/0.02 values: 1.000x / 1.500x / 2.000x of the 100 % peak, straight, no clipping. Scaled before the DSP instead it would read 1.225x / 1.414x, which is the 40 %-comes-out-at-16 % failure | | Frame rate, the same scenes | not measured in this pair (the haptics ride a 1 ms worker and a 48 kHz stream beside the game) | `vk: 53880 presents, 60.0 fps`, `vk: 53760 presents, 60.1 fps` | ``` [hos host] haptic: renhdrum.dll 0.3.0 loaded [hos host] haptic: audio endpoint "扬声器 (DualSense Wireless Controller)": 4 channels [hos host] haptic: 4-channel 48 kHz audio on the DualSense's endpoint [hos host] haptic: worker started [hos host] haptic: pad "DualSense Wireless Controller": 4-channel 48 kHz audio on the wire [hos host] haptic: DualSense haptics (USB), 38738 values, 155026 audio fills, 16128 loud samples, peak 0.288 [hos host] haptic: test, 400 ms [hos host] haptic: DualSense haptics (USB), 55102 values, 171350 audio fills, 99784 loud samples, peak 0.799 [hos host] hid: controller "DualSense Wireless Controller" disconnected (SDL) [hos host] haptic: 6 playback devices, none of them a DualSense with four channels yet [hos host] haptic: pad "DualSense Wireless Controller": waiting for the DualSense's audio endpoint: plug it in by cable [hos host] haptic: audio endpoint "扬声器 (DualSense Wireless Controller)": 4 channels [hos host] haptic: 4-channel 48 kHz audio on the DualSense's endpoint ``` ### Flags and settings It adds two menu rows on the Controls page (Haptic feedback, Haptic strength), a read-only status row and a Test haptics action, and with them the ini keys `haptic` and `haptic_strength`; `renhdrum.dll` is the one new file beside the game. | Flag | What it is | Before this PR | After this PR | Effect | Helps / doesn't help / untested | Recommendation | |---|---|---|---|---|---|---| | `haptic` (the Haptic feedback row) | 0 Auto, 1 Standard rumble, 2 DualSense haptics, 3 Off | the key does not exist | 0 (Auto) | Auto drives a DualSense on a cable from the game's bands and leaves every other pad as it was | helps: a DualSense on USB. doesn't: a DualSense on Bluetooth (no four-channel sound card there, and this build does not implement its HID haptics reports), a Switch Pro Controller (keeps its HD Rumble). untested: the other pads' motors, unchanged | on by default: Auto changes nothing for anyone without a cabled DualSense | | `haptic_strength` (the Haptic strength row) | 0 the game's own level, else 25-200 % of it | the key does not exist | 0 | scales the fine haptics alone, once, as a gain on the DSP's output | helps: a DualSense on USB. doesn't: every other backend. untested: a DualSense on Bluetooth | on by default at 0: no change from what the game sends | | `renhdrum.dll` beside the game | the RenHDrum C ABI the haptics go through | not there | copied from `third_party/renhdrum/` by `nx2/build.py` and `nx2/make_game.py` when the file is present | turns the four floats into the pad's audio haptics | helps: a DualSense on USB. doesn't: everything else (the file is not needed) | ship it with the game folder; a build without it plays the standard rumble and says so | ## Testing **Tested:** Windows 11 Pro 26300, RTX 4060 Ti, NVIDIA 617.42, i5-14600KF, 32 GB, llvm-mingw 20261104. In the game: a copied save, Kokiri Forest (adult) — Continue from the title screen, the pause menu's System page, Test haptics five times, the gameplay behind it, and a USB unplug and replug in the middle of the run; the log lines above come from that run, and `docs/tested/2026-11-14-dualsense-haptics-yg-run01.md` records it. Game-free: `python -I -B tests/run_all.py` on the rebased tree (`083f33b`, `LONGSHOT_MINGW` set, no `CC`/`CXX`) — **504 passed, 19 failed, 72 skipped**; the same suite on a worktree of `main @ aa95d35f` — **503 passed, 18 failed, 72 skipped**, its failure set exactly this one's minus `test_mesh_args_cleanup`, which passes on its own here (a file-lock race under the runner's 20 parallel checks, not a check on this diff). The shared failures are the ones this PC cannot satisfy: `test_bug_report` (a Chinese-locale console read as UTF-8), `test_cpp_preflight`, `test_appimage_player_build`, `test_crash_dialog`, `test_cpm_discovery`, `test_env_on`, `test_lift_cleanup_error`, `test_make_game_cache`, `test_make_game_prepare`, `test_missing_nup`, `test_options_budget_once`, `test_readback_late`, `test_vk_pgo`, `test_walk_key`, `test_release`, `test_linux_preflight` and `test_oot_state_tap` (a tool, a dependency or a POSIX facility this host lacks), and `test_tested_configurations` (a CRLF/LF difference in a piped read on Windows). The new `tests/test_haptics_menu.py` reads the rows, their steps, the read-only status, the option ids, and `host/haptic.c`'s loading, ABI check, gain placement and callback rules; `tests/test_renhdrum_pin.py` drives `scripts/renhdrum.py` over a synthetic PE (a pinned copy passes, one importing VCRUNTIME140.dll is refused and says why, one missing an export names it, other bytes fail, a damaged file does not end the run with a traceback, and no copy at all says what to do). **Not tested:** a DualSense on Bluetooth (Bluetooth exposes no four-channel sound card, and this build does not implement the pad's HID haptics reports, so the status row says so and the standard rumble plays; the row itself was not exercised), a Switch Pro Controller and a Joy-Con in this build (their HD Rumble path is untouched but unmeasured here), and the Linux build (this host has no WSL or container; `host/haptic.c` keeps every Win32 and RenHDrum symbol inside `#ifdef _WIN32`, so the file compiles to the fallback there, but that is a reading, not a build). **Must be tested:** - Linux, any GPU (I'm on Windows): start the game and check that every pad keeps its standard rumble and the log says `haptic: ... the standard rumble only`, expecting no build error and no crash (by hand: whoever takes T-2ZP5, or the Test Farm with `@Beanstalk linux`). - Windows, any GPU, a DualSense on Bluetooth: check that the Haptic status row reads "DualSense Bluetooth (standard rumble)" and the motors buzz, expecting no audio endpoint opened and no crash (by hand: T-2ZP5). - Windows, a Switch Pro Controller and a Joy-Con pair: check that HD Rumble is as it was on `main`, expecting the same feel and the same `hid: ... HD rumble` lines (by hand: T-2ZP5). ## Risks **What could break:** a wrong endpoint chosen on a machine whose speakers are named "DualSense" (the name and the four channels are both required); an audio device change mid-game leaving the stream open (the worker re-enumerates every 2 s and closes the old stream before the DSP); the haptics thread on exit (the stream is destroyed before the DSP, and `hos_main.c` calls the shutdown once `nnMain` returns). **Turning it off:** Haptic feedback = Off, or Rumble strength = Off, which stops every backend; `haptic=3` in `longshot_options.ini` does the same for a build that will not start; removing `renhdrum.dll` falls back to the standard rumble. Reverting the commits is enough to get `main`'s behaviour back. **Compatibility:** `longshot_options.ini` gains two keys (`haptic`, `haptic_strength`), written on the next save of the options; a file without them loads as Auto and Game's own. Saves, the caches and the builder's tools are untouched, and `MKWVK_API_VERSION` stays as it is (no member of `MkwVkHost` or `MkwVkRenderer` moves). ## Issues See #752 — the request for RenHD Rumble in Longshot, which this PR answers. ## Checklist ### Everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`) of the status line's type. - [x] Why gives the reason plainly and links the issues that fit (the tracker searched). - [x] Measurements has a verdict, or one line saying why there is nothing to measure. - [x] Testing names what was tested, what wasn't, and what must still be tested, where and how. - [x] Rebased on current `main`. - [x] `python -I -B tests/run_all.py` passes (any failure that also fails on `main` is named). - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### When it applies - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [ ] Numbers from the same PC, scene and settings, at least 2 interleaved runs each, the median reported. (Not yet: the run recorded in `docs/tested/` is one run of this PR and one of `main`, on the same PC, save and settings; the numbers are counts and peaks from the log rather than frame times, so a second pair is worth having before this leaves WIP.) - [x] Switches documented (docs/development.md; the README for a menu row) and shipped at their recommended value. - [x] Screenshots of the same spot, captioned like the Measurements columns, for a change that shows on screen. - [x] A file in `docs/tested/` for each new measured run (format in `docs/tested-configurations.md`). - [x] A game-free test added for the fix, failing on `main` and passing here. - [x] A check under `tests/` that reads changed source updated in this PR, not weakened. - [ ] Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. (Windows: built and run. Linux: not built here — this PC has no WSL and no container; `host/haptic.c` keeps every Win32 and RenHDrum symbol inside `#ifdef _WIN32`, which is a reading of the file, not a build. This is one of the things T-2ZP5 asks for.) - [x] Docs updated. ## Reviewer notes `host/haptic.c` is the file to read first: the lock order is `g_life` then one of `g_host`, `g_eng`, `g_audio`, `g_note`, never two inner ones at once, and the audio callback takes `g_eng` only, for one fill. The strength gain sits in `endpoint_pull`, not in `hos_haptic_push`, and the comment there says why (RenHDrum's `a^0.5` and its normalisation). `third_party/renhdrum/PROVENANCE.md` records the version, the licence and what is not used (the GPL integration folders). `gpu/vk/vk_api.h` changes only by four `HOS_OPT_*` ids in the existing enum: no member of `MkwVkHost` or `MkwVkRenderer` moves, so `MKWVK_API_VERSION` stays as it is. They are **63, 64, 65 and 66** (mode, status, strength, test): the branch first took 58, and main's render thread took 58 while this was open, so the haptics moved past it rather than renumbering main's row. `python -I tools/check_opt_ids.py` over every id in the header, implicit enum sequences included, finds 39 ids and no value used twice. **The rebase onto `aa95d35f`.** The branch was 58 commits behind; eight files conflicted and each was settled by keeping both sides' meaning: `gpu/vk/vk_api.h` (the ids above), `gpu/vk/vk_replay.cpp` (main's `HOS_OPT_SKIP_TITLE` and `HOS_OPT_RENDER_THREAD` rows, then the four haptics), `nx2/build.py` (the `renhdrum.dll` copy kept; this branch's `vk_prebuilt` block dropped, since main removed that mechanism), `gpu/vk/vk_menu.cpp` (three places: main's `weak_laptop_apply`, main's richer prompt chain with its `field` and `cheat` branches — this branch only adds `is_haptic_test_row` to that chain's last arm — and main's `field_row` key branch beside the Test haptics one), `gpu/vk/menu_i18n.h` (main's 13 language rows, then the counts recomputed: every table sorted, no repeats, all 17 new texts present in all 13), `README.md`, `RELEASING.md` and `docs/development.md` (both sides' paragraphs). The audio path is untouched by the rebase: `host/haptic.c`, `host/nn_hid.c`, `host/hos_sdl.c` and `host/pad_sdl.c` merged without a conflict.
feat(haptics): a DualSense's fine haptics, from the game's own bands
Some checks failed
Website / Website on Cloudflare (pull_request_target) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 3s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
Website / Website on Cloudflare (pull_request) Has been cancelled
2116930d18
The game sends nn::hid a vibration value of four floats: a low band and a
high band, each with its own amplitude and frequency, per hand. Today those
four become two motor strengths (rumble_map, host/nn_hid.c) and a Switch Pro
Controller or a Joy-Con gets them whole as HD Rumble. A DualSense got the
motors only, so the layering the game writes was lost: a sword hit and a
footstep came out as the same buzz.

This adds a haptics back end beside that, not instead of it. The same four
floats go to RenHDrum (third_party/renhdrum, MIT OR Apache-2.0, v0.3.0, the
stable C ABI at 0x000300) which turns them into the DualSense's own audio
haptics, played into the controller's four-channel 48 kHz playback endpoint:
the USB endpoint the controller exposes, never the desktop's speakers.

- host/haptic.c, host/haptic.h: the back end. renhdrum.dll is loaded by full
  path from the game's own folder (LoadLibraryExW with
  LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32), its ABI
  version checked, and every failure falls back to the standard rumble with
  the reason in the status. The endpoint is found by name ("DualSense" or
  "Wireless Controller") and by four channels, reopened when the audio devices
  change, and a 1 ms worker keeps a silence watchdog so nothing keeps buzzing.
- host/hos_sdl.c, host/hos_sdl.h: the SDL3 calls for that endpoint (device
  enumeration with their formats, a stream opened on a named device, its
  health, and SDL_GetJoystickConnectionState for the cable).
- host/pad_sdl.c, host/nn_hid.c, host/hos_main.c: the wiring. The pad's
  connection, the mode and the strengths reach the back end each poll; a
  pad the haptics drive gets no SDL rumble at the same time; the game's exit
  stops it and lets go of the controller.
- menu: Haptic feedback (Auto, Standard rumble, DualSense haptics, Off),
  Haptic strength (Game's own, or a percent of what the game asks for), the
  read-only Haptic status line with its reason, and Test haptics, a half
  second buzz that ends on its own. The Rumble strength stays the one master
  strength and its Off stops every backend.
- Strength is applied once, as a gain after the DSP: RenHDrum raises every
  amplitude to the power 0.5 and normalises two bands that add past 1, so a
  factor on its input came out as its square root and stopped changing above
  about 30 %.
- nx2/build.py, nx2/make_game.py: renhdrum.dll goes beside the game, as the
  SDL DLLs do. It is optional: without it the game plays the standard rumble.

Assisted-by: DeepSeek Harness
Author
First-time contributor

this PR @ 2116930d: the Controls page's four new rows, right under Rumble strength — Haptic feedback (Auto), Haptic strength (Game's own or 25-200 %), the read-only Haptic status line, and Test haptics.

Controls page with the four haptics rows

The status line reads "DualSense haptics (USB)" here because the controller is on a cable and the endpoint was found; the row says "DualSense Bluetooth (standard rumble)" on Bluetooth, "Switch Pro HD rumble" on a wired Pro Controller, "Waiting for the DualSense audio endpoint" while a cable is unplugged, and "Haptic backend unavailable" when renhdrum.dll is missing or the wrong version.

Test haptics selected, its hint below

Test haptics plays 400 ms at half the game's amplitude and ends by itself; the log shows one haptic: test, 400 ms line per press, five of them in this run.

[hos host] haptic: renhdrum.dll 0.3.0 loaded
[hos host] haptic: audio endpoint "扬声器 (DualSense Wireless Controller)": 4 channels
[hos host] haptic: 4-channel 48 kHz audio on the DualSense's endpoint
[hos host] haptic: pad "DualSense Wireless Controller": 4-channel 48 kHz audio on the wire
[hos host] haptic: DualSense haptics (USB), 38738 values, 155026 audio fills, 16128 loud samples, peak 0.288
[hos host] haptic: test, 400 ms
[hos host] haptic: DualSense haptics (USB), 55102 values, 171350 audio fills, 99784 loud samples, peak 0.799
`this PR @ 2116930d`: the Controls page's four new rows, right under Rumble strength — Haptic feedback (Auto), Haptic strength (Game's own or 25-200 %), the read-only Haptic status line, and Test haptics. ![Controls page with the four haptics rows](https://git.magicbean.dev/attachments/eb8b5b19-388c-49ac-a5f3-60901e4b558b) The status line reads "DualSense haptics (USB)" here because the controller is on a cable and the endpoint was found; the row says "DualSense Bluetooth (standard rumble)" on Bluetooth, "Switch Pro HD rumble" on a wired Pro Controller, "Waiting for the DualSense audio endpoint" while a cable is unplugged, and "Haptic backend unavailable" when `renhdrum.dll` is missing or the wrong version. ![Test haptics selected, its hint below](https://git.magicbean.dev/attachments/ca298bbf-ba45-4dc2-9964-bf57d4b4a385) Test haptics plays 400 ms at half the game's amplitude and ends by itself; the log shows one `haptic: test, 400 ms` line per press, five of them in this run. ``` [hos host] haptic: renhdrum.dll 0.3.0 loaded [hos host] haptic: audio endpoint "扬声器 (DualSense Wireless Controller)": 4 channels [hos host] haptic: 4-channel 48 kHz audio on the DualSense's endpoint [hos host] haptic: pad "DualSense Wireless Controller": 4-channel 48 kHz audio on the wire [hos host] haptic: DualSense haptics (USB), 38738 values, 155026 audio fills, 16128 loud samples, peak 0.288 [hos host] haptic: test, 400 ms [hos host] haptic: DualSense haptics (USB), 55102 values, 171350 audio fills, 99784 loud samples, peak 0.799 ```
docs(testing): the endpoint's name in the DualSense haptics run
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 3s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
Website / Website on Cloudflare (pull_request) Has been cancelled
08bc995531
docs(haptics): Bluetooth is unimplemented, not impossible; the DLL has a pin
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 14s
Website / Website on Cloudflare (pull_request_target) Has been skipped
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
Website / Website on Cloudflare (pull_request) Has been cancelled
dd6cd2612d
The wording said Bluetooth "has no such endpoint" and that haptics "need a cable", which reads as a physical
limit. What is true is narrower: this build plays haptics through the pad's USB sound card, Bluetooth exposes no
such card, and the pad's own HID haptics reports are a path this build does not implement yet. README.md,
host/haptic.h and the two notes in host/haptic.c say that now.

A clean checkout had no way to tell a good renhdrum.dll from a wrong one: the DLL is ignored, like SDL2.dll.
third_party/renhdrum/renhdrum.dll.sha256 pins the bytes this was tested with, and scripts/renhdrum.py checks a
copy against it (its PE imports, so a copy built without the static CRT is refused; and its five exports), builds
one from a RenHDrum checkout at v0.3.0, or takes one from a url or a path and refuses it unless it is the pin.
RELEASING.md and CONTRIBUTING.md say where it goes in the release's third_party archive.

tests/test_renhdrum_pin.py drives that over a synthetic PE: a pinned copy passes, one importing VCRUNTIME140.dll
fails and says why, one without the five exports names them, other bytes fail, a damaged file does not end the run
with a traceback, and no copy at all says what to do.

Assisted-by: DeepSeek Harness
Author
First-time contributor

Pushed dd6cd26: the Bluetooth wording, a pin for
enhdrum.dll, and the strength evidence in the table.

Strength is applied once, on the coils' samples. endpoint_pull (host/haptic.c:194) multiplies the DSP's output, channels 2 and 3 of the 4-channel stream, by strength_gain(). What goes into the DSP (
h.dsp_set_half, host/haptic.c:529) is the game's four values untouched, and the DSP itself is made at a fixed 100 % (
h.dsp_create(100, false)). Feeding the game's own 0.06 @ 178 Hz / 0.02 @ 306 Hz through the pinned DLL: 25 % -> 0.077273, 50 % -> 0.154547, 100 % -> 0.309093, 150 % -> 0.463640, 200 % -> 0.618187, so 1.000x / 1.500x / 2.000x of the 100 % peak, straight and unclipped. Scaled before the DSP instead, the same values read 1.225x / 1.414x, because RenHDrum takes the amplitude to a 0.5 power and normalises: that is the 40 %-plays-at-16 % shape. The two measurements are in ools/measure_gain.py and ools/measure_gain_input.py outside the tree; the numbers are in the table.

The game does send non-zero values, and they are what the peak came from. Log lines 4705-4710 of the run carry six of them, hid: vibration #38304: handle 00000003, low 0.06 @ 178 Hz, high 0.02 @ 306 Hz and its pairs. Played through the pinned DLL they peak at 0.309 / 0.306 / 0.292, which is the peak 0.288 line 4802 reports, 1400 lines before the first haptic: test, 400 ms. A test buzz on the same DLL measures 0.800, matching the 0.799 beside the five buzzes, so the two are told apart. Honest limit: in this scene most of the time the game sends silence, so 49 of the 50 five-second counters say strongest 0.00; one says strongest 0.08.

Bluetooth. The earlier wording read as a physical impossibility. It is not: this build plays haptics through the pad's USB sound card, Bluetooth exposes no such card, and the pad's own HID haptics reports are a path this build does not implement yet. README.md, host/haptic.h and host/haptic.c say exactly that now.

The DLL can be checked without the machine that built it. hird_party/renhdrum/renhdrum.dll.sha256 pins the bytes (414,208 B, sha256 3f3cfa95...5f7940), and scripts/renhdrum.py verifies a copy against the pin, its PE imports (so one linked against VCRUNTIME140.dll is refused) and its five exports; or builds one from a RenHDrum checkout at v0.3.0, or takes one from a url and refuses it unless it matches. RELEASING.md says where it goes in the release's third_party archive. ests/test_renhdrum_pin.py covers all of that on a synthetic PE.

Testing. Windows: built and played here, the numbers above. Linux: not built on this host (no WSL, no container); host/haptic.c keeps every Win32 and RenHDrum symbol inside #ifdef _WIN32 and the file falls back to the standard rumble there, but that is a reading, not a build, so CI is the first real check. A Switch Pro Controller and a Joy-Con were not on this desk; their HD Rumble path is untouched.

Could a maintainer add the esting wanted label? Test request T-2ZP5 is open and nobody but a collaborator can claim it until then. Still WIP on purpose.

Pushed dd6cd26: the Bluetooth wording, a pin for enhdrum.dll, and the strength evidence in the table. **Strength is applied once, on the coils' samples.** endpoint_pull (host/haptic.c:194) multiplies the DSP's output, channels 2 and 3 of the 4-channel stream, by strength_gain(). What goes into the DSP ( h.dsp_set_half, host/haptic.c:529) is the game's four values untouched, and the DSP itself is made at a fixed 100 % ( h.dsp_create(100, false)). Feeding the game's own 0.06 @ 178 Hz / 0.02 @ 306 Hz through the pinned DLL: 25 % -> 0.077273, 50 % -> 0.154547, 100 % -> 0.309093, 150 % -> 0.463640, 200 % -> 0.618187, so 1.000x / 1.500x / 2.000x of the 100 % peak, straight and unclipped. Scaled before the DSP instead, the same values read 1.225x / 1.414x, because RenHDrum takes the amplitude to a 0.5 power and normalises: that is the 40 %-plays-at-16 % shape. The two measurements are in ools/measure_gain.py and ools/measure_gain_input.py outside the tree; the numbers are in the table. **The game does send non-zero values, and they are what the peak came from.** Log lines 4705-4710 of the run carry six of them, hid: vibration #38304: handle 00000003, low 0.06 @ 178 Hz, high 0.02 @ 306 Hz and its pairs. Played through the pinned DLL they peak at 0.309 / 0.306 / 0.292, which is the peak 0.288 line 4802 reports, 1400 lines before the first haptic: test, 400 ms. A test buzz on the same DLL measures 0.800, matching the 0.799 beside the five buzzes, so the two are told apart. Honest limit: in this scene most of the time the game sends silence, so 49 of the 50 five-second counters say strongest 0.00; one says strongest 0.08. **Bluetooth.** The earlier wording read as a physical impossibility. It is not: this build plays haptics through the pad's USB sound card, Bluetooth exposes no such card, and the pad's own HID haptics reports are a path this build does not implement yet. README.md, host/haptic.h and host/haptic.c say exactly that now. **The DLL can be checked without the machine that built it.** hird_party/renhdrum/renhdrum.dll.sha256 pins the bytes (414,208 B, sha256 3f3cfa95...5f7940), and scripts/renhdrum.py verifies a copy against the pin, its PE imports (so one linked against VCRUNTIME140.dll is refused) and its five exports; or builds one from a RenHDrum checkout at v0.3.0, or takes one from a url and refuses it unless it matches. RELEASING.md says where it goes in the release's third_party archive. ests/test_renhdrum_pin.py covers all of that on a synthetic PE. **Testing.** Windows: built and played here, the numbers above. Linux: not built on this host (no WSL, no container); host/haptic.c keeps every Win32 and RenHDrum symbol inside #ifdef _WIN32 and the file falls back to the standard rumble there, but that is a reading, not a build, so CI is the first real check. A Switch Pro Controller and a Joy-Con were not on this desk; their HD Rumble path is untouched. Could a maintainer add the esting wanted label? Test request T-2ZP5 is open and nobody but a collaborator can claim it until then. Still WIP on purpose.
Author
First-time contributor

Two corrections after re-reading the run's log line by line, and a re-run of the suite on dd6cd26.

  • The six game values sit at log lines 4706-4711, not 4705-4710, and the peak 0.288 counter is line 4803. The first haptic: test, 400 ms is line 6229, so that peak is 1426 lines before any test buzz, not 1400. Fixed in the description; the substance is unchanged.
  • python -I -B tests/run_all.py on dd6cd26: 487 passed, 6 failed, 70 skipped. The six are the same ones a worktree of main @ 0caf0618 fails in this environment ( est_bug_report reads a Chinese-locale console as UTF-8, est_module_ids / est_pad_layout / est_oot_state_tap / est_release each want something this PC lacks, est_tested_configurations is a CRLF/LF difference in a piped read). One more check exists now: ests/test_renhdrum_pin.py.
Two corrections after re-reading the run's log line by line, and a re-run of the suite on dd6cd26. - The six game values sit at log lines 4706-4711, not 4705-4710, and the peak 0.288 counter is line 4803. The first haptic: test, 400 ms is line 6229, so that peak is 1426 lines before any test buzz, not 1400. Fixed in the description; the substance is unchanged. - python -I -B tests/run_all.py on dd6cd26: 487 passed, 6 failed, 70 skipped. The six are the same ones a worktree of main @ 0caf0618 fails in this environment ( est_bug_report reads a Chinese-locale console as UTF-8, est_module_ids / est_pad_layout / est_oot_state_tap / est_release each want something this PC lacks, est_tested_configurations is a CRLF/LF difference in a piped read). One more check exists now: ests/test_renhdrum_pin.py.
Author
First-time contributor

Hi maintainers, the DualSense haptics implementation and Windows hardware tests are complete. Test request T-2ZP5 has been submitted, but it cannot be claimed until the testing wanted label is added.

Could someone add that label and approve the pending CI workflows when convenient? Linux and additional controller hardware tests remain explicitly marked as unverified.

Thanks!

Hi maintainers, the DualSense haptics implementation and Windows hardware tests are complete. Test request T-2ZP5 has been submitted, but it cannot be claimed until the `testing wanted` label is added. Could someone add that label and approve the pending CI workflows when convenient? Linux and additional controller hardware tests remain explicitly marked as unverified. Thanks!
First-time contributor

Update: points 1 and 2 below are superseded and I no longer recommend them. RenHDrum 0.4.0 plays a DualSense on Switch 2 games with one fixed law, explained here: #405 (comment). Its author tried a stronger lift of the small values and rejected it because it flattens the fine detail, so please follow that comment's suggestions instead. Point 3 is already covered by 0.4.0's 5.3 ms peak hold. Thank you for the explanation. The original text is kept below.

Hi! I did the DualSense voice-coil haptics for NX2navi, a static recompilation of the same game, also adapted from RenHDrum (https://github.com/tangerineturtle1/NX2navi/pull/892). A few things from mine that might help here:

  1. A lift for the game's tiny amplitudes. This game's values are tuned for the Switch 2's actuators and most are under 0.05, so through a plain square root they come out weak. Mine puts each band through a piecewise curve first (0 at or below 0.001, then straight lines through 0.004 at 0.001, 0.55 at 0.1, 0.9 at 0.5 and 1 at 1), then takes the root. A single band at 0.06 then plays at about 0.57 instead of about 0.25, and one at 0.01 at about 0.23 instead of 0.10. A gain after the DSP (the Haptic strength row) raises everything by the same factor, so it can't lift the small values without over-driving the big ones.
  2. Sharper starts. The first two 5.3 ms slots of a vibration play at +3 dB and the next six at -3 dB, then as sent, so a hit feels like a hit rather than a swell.
  3. Single-update ticks kept. The game updates its vibration about every 5 ms. Mine plays, in each 5.3 ms slot, the strongest value posted during the slot before, so a one-update tick followed at once by zero still plays. Here each push sets the DSP's target directly; it may be worth checking whether such ticks survive RenHDrum's smoothing.

What this PR has that mine doesn't: the status row, the test buzz, the Bluetooth fallback and all 13 languages. Happy to help, here or as a follow-up once this lands.

**Update:** points 1 and 2 below are superseded and I no longer recommend them. RenHDrum 0.4.0 plays a DualSense on Switch 2 games with one fixed law, explained here: https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/405#issuecomment-25779. Its author tried a stronger lift of the small values and rejected it because it flattens the fine detail, so please follow that comment's suggestions instead. Point 3 is already covered by 0.4.0's 5.3 ms peak hold. Thank you for the explanation. The original text is kept below. Hi! I did the DualSense voice-coil haptics for NX2navi, a static recompilation of the same game, also adapted from RenHDrum (https://github.com/tangerineturtle1/NX2navi/pull/892). A few things from mine that might help here: 1. ~~**A lift for the game's tiny amplitudes.** This game's values are tuned for the Switch 2's actuators and most are under 0.05, so through a plain square root they come out weak. Mine puts each band through a piecewise curve first (0 at or below 0.001, then straight lines through 0.004 at 0.001, 0.55 at 0.1, 0.9 at 0.5 and 1 at 1), then takes the root. A single band at 0.06 then plays at about 0.57 instead of about 0.25, and one at 0.01 at about 0.23 instead of 0.10. A gain after the DSP (the Haptic strength row) raises everything by the same factor, so it can't lift the small values without over-driving the big ones.~~ 2. ~~**Sharper starts.** The first two 5.3 ms slots of a vibration play at +3 dB and the next six at -3 dB, then as sent, so a hit feels like a hit rather than a swell.~~ 3. **Single-update ticks kept.** The game updates its vibration about every 5 ms. Mine plays, in each 5.3 ms slot, the strongest value posted during the slot before, so a one-update tick followed at once by zero still plays. Here each push sets the DSP's target directly; it may be worth checking whether such ticks survive RenHDrum's smoothing. What this PR has that mine doesn't: the status row, the test buzz, the Bluetooth fallback and all 13 languages. Happy to help, here or as a follow-up once this lands.
Stellarznith force-pushed feat/dualsense-haptics from dd6cd2612d
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 14s
Website / Website on Cloudflare (pull_request_target) Has been skipped
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
Website / Website on Cloudflare (pull_request) Has been cancelled
to 083f33b2d4
Some checks are pending
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
Guide screenshots / Guide images and local UI source review (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
Website / Website on Cloudflare (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 6s
2026-11-15 13:23:01 +00:00
Compare
Author
First-time contributor

Rebased onto main @ aa95d35f (the branch was 58 commits behind), and the description is updated to match.

What the rebase touched. Eight files conflicted, each settled by keeping both sides' meaning:

  • gpu/vk/vk_api.h — main's HOS_OPT_RENDER_THREAD took 58 while this was open, so the four haptics ids moved to 63, 64, 65 and 66 rather than renumbering main's row. tools/check_opt_ids.py over every id in the header, implicit enum sequences included, finds 39 ids and no value used twice.
  • gpu/vk/vk_replay.cpp — main's HOS_OPT_SKIP_TITLE and HOS_OPT_RENDER_THREAD rows, then the four haptics.
  • gpu/vk/vk_menu.cpp — three places: main's weak_laptop_apply, main's richer prompt chain (with its field and cheat branches; this branch only adds is_haptic_test_row to that chain's last arm), and main's field_row key branch beside the Test haptics one.
  • nx2/build.py — the renhdrum.dll copy kept; this branch's vk_prebuilt block dropped, since main removed that mechanism.
  • gpu/vk/menu_i18n.h — main's 13 language rows, counts recomputed: every table sorted, no repeats, all 17 new texts in all 13.
  • README.md, RELEASING.md, docs/development.md — both sides' paragraphs.

The audio path is untouched by the rebase: host/haptic.c, host/nn_hid.c, host/hos_sdl.c and host/pad_sdl.c merged without a conflict.

Checks after it. python -I -B tests/run_all.py on 083f33b (llvm-mingw 20261104, LONGSHOT_MINGW set, no CC/CXX): 504 passed, 19 failed, 72 skipped. The same suite on a worktree of main @ aa95d35f: 503 passed, 18 failed, 72 skipped, and its failure set is exactly this one's minus test_mesh_args_cleanup, which passes on its own here (a file-lock race under the runner's 20 parallel checks). So no check regressed.

tests/test_haptics_menu.py needed two changes of its own, in this PR rather than weakened: its expected ids are 63-66 now, and the fixture no longer declares kVramMinimumOnly, a constant main's vk_menu.cpp does not have (with -Werror, an unused constexpr is an error).

Build. Windows incremental, llvm-mingw: the build took 0:15:36, longshot.exe 462,629,888 bytes, renhdrum.dll 414,208 beside it. A run of that build logs longshot 083f33b (source 256c3367), haptic: renhdrum.dll 0.3.0 loaded, haptic: audio endpoint "扬声器 (DualSense Wireless Controller)": 4 channels, haptic: worker started and 4-channel 48 kHz audio on the wire, so the chain is intact after the rebase.

Still WIP: the checklist's "at least 2 interleaved runs each" and "builds on Linux" boxes are open, and both are named in the description with the reason. T-2ZP5 covers the hardware this PC does not have.

Assisted-by: DeepSeek Harness

Rebased onto `main @ aa95d35f` (the branch was 58 commits behind), and the description is updated to match. **What the rebase touched.** Eight files conflicted, each settled by keeping both sides' meaning: - `gpu/vk/vk_api.h` — main's `HOS_OPT_RENDER_THREAD` took **58** while this was open, so the four haptics ids moved to **63, 64, 65 and 66** rather than renumbering main's row. `tools/check_opt_ids.py` over every id in the header, implicit enum sequences included, finds 39 ids and no value used twice. - `gpu/vk/vk_replay.cpp` — main's `HOS_OPT_SKIP_TITLE` and `HOS_OPT_RENDER_THREAD` rows, then the four haptics. - `gpu/vk/vk_menu.cpp` — three places: main's `weak_laptop_apply`, main's richer prompt chain (with its `field` and `cheat` branches; this branch only adds `is_haptic_test_row` to that chain's last arm), and main's `field_row` key branch beside the Test haptics one. - `nx2/build.py` — the `renhdrum.dll` copy kept; this branch's `vk_prebuilt` block dropped, since main removed that mechanism. - `gpu/vk/menu_i18n.h` — main's 13 language rows, counts recomputed: every table sorted, no repeats, all 17 new texts in all 13. - `README.md`, `RELEASING.md`, `docs/development.md` — both sides' paragraphs. The audio path is untouched by the rebase: `host/haptic.c`, `host/nn_hid.c`, `host/hos_sdl.c` and `host/pad_sdl.c` merged without a conflict. **Checks after it.** `python -I -B tests/run_all.py` on `083f33b` (llvm-mingw 20261104, `LONGSHOT_MINGW` set, no `CC`/`CXX`): **504 passed, 19 failed, 72 skipped**. The same suite on a worktree of `main @ aa95d35f`: **503 passed, 18 failed, 72 skipped**, and its failure set is exactly this one's minus `test_mesh_args_cleanup`, which passes on its own here (a file-lock race under the runner's 20 parallel checks). So no check regressed. `tests/test_haptics_menu.py` needed two changes of its own, in this PR rather than weakened: its expected ids are 63-66 now, and the fixture no longer declares `kVramMinimumOnly`, a constant main's `vk_menu.cpp` does not have (with `-Werror`, an unused `constexpr` is an error). **Build.** Windows incremental, llvm-mingw: `the build took 0:15:36`, `longshot.exe` 462,629,888 bytes, `renhdrum.dll` 414,208 beside it. A run of that build logs `longshot 083f33b (source 256c3367)`, `haptic: renhdrum.dll 0.3.0 loaded`, `haptic: audio endpoint "扬声器 (DualSense Wireless Controller)": 4 channels`, `haptic: worker started` and `4-channel 48 kHz audio on the wire`, so the chain is intact after the rebase. Still WIP: the checklist's "at least 2 interleaved runs each" and "builds on Linux" boxes are open, and both are named in the description with the reason. T-2ZP5 covers the hardware this PC does not have. Assisted-by: DeepSeek Harness
First-time contributor

Hi @Stellarznith, thank you for building this, and for crediting RenHDrum. The status row, the test buzz and the hot-plug handling are all things I'd like to copy.

A suggestion that may make the "weak vibration" question easier to settle: this PR pins RenHDrum v0.3.0, and 0.4.0 (released yesterday) changes how a DualSense plays Switch 2 games.

  • 0.3.0: every amplitude went through a square root. Small values were pushed up toward the big ones, so fine detail (rain, steps in water, walking on a web) came out as a louder, flatter buzz.
  • 0.4.0: one fixed law for all Switch 2 games: values at or below 0.001 are silence, then each band is min(1, 3.5 * a^0.75). It is turned on by calling renhdrum_set_nx2_mode(2) once. The 5.3 ms slot peak-hold (so single-update ticks survive) and the 4 ms smoothing are kept.

I chose a fixed law over a per-game curve on purpose. I tested several variants by hand on Donkey Kong Bananza, Mario Kart World and Ocarina of Time. A stronger lift of the small values (like the curve @kendowashere describes) makes OoT louder, but it flattens Bananza's layers, where I could tell walking on dirt, water, metal and stone apart with the fixed law. A game-specific curve also means every new game needs its own tuning.

In my own play, OoT is clearly weaker than Bananza, and I think that is intended: the game does not turn everything into strength, it marks the difference between strong and weak spots with small, fine vibration (rain, stepping in water, a spider web). With 0.4.0 these stay distinct, while 0.3.0's square root made the web feel like a heavier hum. This is my own experience on one controller, not a measurement, so please judge it by feel.

So for this PR, I'd suggest:

  1. Update third_party/renhdrum to v0.4.0.
  2. Call renhdrum_set_nx2_mode(2) when the title is a Switch 2 game, if the PR doesn't already.
  3. Keep the Haptic strength row as a plain gain after the DSP, as you have it. It is the right place for it, since the law itself lives in the core.

And if you later want to cover more pads or Bluetooth: integrations/recomp-c in the RenHDrum repo is a small C bridge for static-recompilation hosts like this one. It drives a DualSense over USB and Bluetooth (report 0x32), a Pro Controller, an Xbox One S and, experimentally, the Steam Controller (2026). It also ships reference/longshot-reference.patch, a full patch against Longshot v3.0.0-rc.3. That is an older base than main, so it will need a rebase, and it is only there as reference code: take whatever is useful and ignore the rest. I'm happy to help check anything on the core side.

Hi @Stellarznith, thank you for building this, and for crediting RenHDrum. The status row, the test buzz and the hot-plug handling are all things I'd like to copy. A suggestion that may make the "weak vibration" question easier to settle: this PR pins RenHDrum v0.3.0, and **0.4.0 (released yesterday) changes how a DualSense plays Switch 2 games**. - **0.3.0:** every amplitude went through a square root. Small values were pushed up toward the big ones, so fine detail (rain, steps in water, walking on a web) came out as a louder, flatter buzz. - **0.4.0:** one fixed law for all Switch 2 games: values at or below 0.001 are silence, then each band is `min(1, 3.5 * a^0.75)`. It is turned on by calling `renhdrum_set_nx2_mode(2)` once. The 5.3 ms slot peak-hold (so single-update ticks survive) and the 4 ms smoothing are kept. I chose a fixed law over a per-game curve on purpose. I tested several variants by hand on Donkey Kong Bananza, Mario Kart World and Ocarina of Time. A stronger lift of the small values (like the curve @kendowashere describes) makes OoT louder, but it flattens Bananza's layers, where I could tell walking on dirt, water, metal and stone apart with the fixed law. A game-specific curve also means every new game needs its own tuning. In my own play, OoT is clearly weaker than Bananza, and I think that is intended: the game does not turn everything into strength, it marks the difference between strong and weak spots with small, fine vibration (rain, stepping in water, a spider web). With 0.4.0 these stay distinct, while 0.3.0's square root made the web feel like a heavier hum. This is my own experience on one controller, not a measurement, so please judge it by feel. So for this PR, I'd suggest: 1. Update `third_party/renhdrum` to v0.4.0. 2. Call `renhdrum_set_nx2_mode(2)` when the title is a Switch 2 game, if the PR doesn't already. 3. Keep the Haptic strength row as a plain gain after the DSP, as you have it. It is the right place for it, since the law itself lives in the core. And if you later want to cover more pads or Bluetooth: `integrations/recomp-c` in the RenHDrum repo is a small C bridge for static-recompilation hosts like this one. It drives a DualSense over USB and Bluetooth (report `0x32`), a Pro Controller, an Xbox One S and, experimentally, the Steam Controller (2026). It also ships `reference/longshot-reference.patch`, a full patch against Longshot v3.0.0-rc.3. That is an older base than `main`, so it will need a rebase, and it is only there as reference code: take whatever is useful and ignore the rest. I'm happy to help check anything on the core side.
Some checks are pending
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
Guide screenshots / Guide images and local UI source review (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
Website / Website on Cloudflare (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 6s
This pull request has changes conflicting with the target branch.
  • CONTRIBUTING.md
  • nx2/build.py
  • nx2/make_game.py
Some workflows are waiting to be reviewed.
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u 'feat/dualsense-haptics:Stellarznith-feat/dualsense-haptics'
git switch 'Stellarznith-feat/dualsense-haptics'
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/Longshot!405
No description provided.