From 4c1c4d7585a04c747343d6a54d92557aca8ace77 Mon Sep 17 00:00:00 2001 From: qiin <414382190@qq.com> Date: Wed, 19 Aug 2026 01:11:22 +0800 Subject: [PATCH 1/4] feat(ds5): add configurable legacy ERM renderer --- docs/ds5_legacy_haptics_tuning.md | 32 +++++++++ src/ds5_config.cpp | 115 +++++++++++++++++++++++++++++- src/ds5_config.h | 29 ++++++++ src/ds5_config_api.cpp | 35 ++++++++- src/haptics/authored_ir.cpp | 86 +++++++++++++++++++--- src/haptics/authored_ir.h | 3 + tests/unit/test_authored_ir.cpp | 34 +++++++++ tests/unit/test_ds5_config.cpp | 48 +++++++++++++ 8 files changed, 366 insertions(+), 16 deletions(-) create mode 100644 docs/ds5_legacy_haptics_tuning.md diff --git a/docs/ds5_legacy_haptics_tuning.md b/docs/ds5_legacy_haptics_tuning.md new file mode 100644 index 00000000..2c21b64e --- /dev/null +++ b/docs/ds5_legacy_haptics_tuning.md @@ -0,0 +1,32 @@ +# DualSense Legacy Haptics Tuning + +The authored haptics SDK remains device-independent. It analyzes PCM and +returns `AhAuthoredHapticFrame`; Sunshine owns the ERM renderer and the legacy +`RUMBLE_DATA` policy. + +The renderer supports four profiles: + +| Profile | Strength | Curve | Gate | Max output | High scale | Response | Body mix | +| --- | ---: | ---: | ---: | ---: | ---: | --- | ---: | +| quiet | 0.75 | 0.75 | 0.008 | 0.55 | 0.65 | smooth | 0.10 | +| balanced | 1.00 | 0.50 | 0.006 | 0.70 | 0.75 | balanced | 0.15 | +| strong | 1.10 | 0.40 | 0.004 | 0.82 | 0.85 | fast | 0.18 | +| custom | user supplied | user supplied | user supplied | user supplied | user supplied | user supplied | user supplied | + +`ds5_config.json` v1 files containing the original five fields remain readable. +The next save writes schema 2 and adds `profile`, `max_output`, `high_scale`, +`response`, and `body_mix`. Invalid ranges, profile names, and response names +are rejected before a snapshot is published. + +The mapping stages are: + +```text +AhAuthoredHapticFrame -> two ERM energy lanes -> gate/curve/strength + -> high-motor scale/body mix -> output ceilings -> response/slew limiting + -> 16-bit RUMBLE_DATA +``` + +The 80 ms minimum active hold protects short pulses from clients that keep only +the newest queued rumble packet. Stream-end and watchdog paths bypass smoothing +and emit zero immediately. Presets are starting calibration values; manual +validation should compare quiet-band perceptibility against combat motor noise. diff --git a/src/ds5_config.cpp b/src/ds5_config.cpp index f0470daf..ff3296ab 100644 --- a/src/ds5_config.cpp +++ b/src/ds5_config.cpp @@ -112,13 +112,98 @@ namespace ds5_config { } bool validate(const settings_t &settings) noexcept { + const auto valid_profile = settings.legacy_profile == legacy_profile_t::custom || + settings.legacy_profile == legacy_profile_t::quiet || + settings.legacy_profile == legacy_profile_t::balanced || + settings.legacy_profile == legacy_profile_t::strong; + const auto valid_response = settings.legacy_response == legacy_response_t::fast || + settings.legacy_response == legacy_response_t::balanced || + settings.legacy_response == legacy_response_t::smooth; return settings.revision > 0 && + valid_profile && valid_response && std::isfinite(settings.legacy_strength) && settings.legacy_strength >= MIN_STRENGTH && settings.legacy_strength <= MAX_STRENGTH && std::isfinite(settings.legacy_curve) && settings.legacy_curve >= MIN_CURVE && settings.legacy_curve <= MAX_CURVE && std::isfinite(settings.legacy_noise_gate) && - settings.legacy_noise_gate >= MIN_NOISE_GATE && settings.legacy_noise_gate <= MAX_NOISE_GATE; + settings.legacy_noise_gate >= MIN_NOISE_GATE && settings.legacy_noise_gate <= MAX_NOISE_GATE && + std::isfinite(settings.legacy_max_output) && + settings.legacy_max_output >= MIN_MAX_OUTPUT && settings.legacy_max_output <= MAX_MAX_OUTPUT && + std::isfinite(settings.legacy_high_scale) && + settings.legacy_high_scale >= MIN_HIGH_SCALE && settings.legacy_high_scale <= MAX_HIGH_SCALE && + std::isfinite(settings.legacy_body_mix) && + settings.legacy_body_mix >= 0.0 && settings.legacy_body_mix <= MAX_BODY_MIX; + } + + std::string_view legacy_profile_name(legacy_profile_t profile) noexcept { + switch (profile) { + case legacy_profile_t::quiet: return "quiet"; + case legacy_profile_t::balanced: return "balanced"; + case legacy_profile_t::strong: return "strong"; + case legacy_profile_t::custom: return "custom"; + } + return "custom"; + } + + bool parse_legacy_profile(std::string_view value, legacy_profile_t &profile) noexcept { + if (value == "quiet") profile = legacy_profile_t::quiet; + else if (value == "balanced") profile = legacy_profile_t::balanced; + else if (value == "strong") profile = legacy_profile_t::strong; + else if (value == "custom") profile = legacy_profile_t::custom; + else return false; + return true; + } + + std::string_view legacy_response_name(legacy_response_t response) noexcept { + switch (response) { + case legacy_response_t::fast: return "fast"; + case legacy_response_t::balanced: return "balanced"; + case legacy_response_t::smooth: return "smooth"; + } + return "balanced"; + } + + bool parse_legacy_response(std::string_view value, legacy_response_t &response) noexcept { + if (value == "fast") response = legacy_response_t::fast; + else if (value == "balanced") response = legacy_response_t::balanced; + else if (value == "smooth") response = legacy_response_t::smooth; + else return false; + return true; + } + + settings_t resolve_legacy_profile(settings_t settings) noexcept { + switch (settings.legacy_profile) { + case legacy_profile_t::quiet: + settings.legacy_strength = 0.75; + settings.legacy_curve = 0.75; + settings.legacy_noise_gate = 0.008; + settings.legacy_max_output = 0.55; + settings.legacy_high_scale = 0.65; + settings.legacy_response = legacy_response_t::smooth; + settings.legacy_body_mix = 0.10; + break; + case legacy_profile_t::balanced: + settings.legacy_strength = 1.00; + settings.legacy_curve = 0.50; + settings.legacy_noise_gate = 0.006; + settings.legacy_max_output = 0.70; + settings.legacy_high_scale = 0.75; + settings.legacy_response = legacy_response_t::balanced; + settings.legacy_body_mix = 0.15; + break; + case legacy_profile_t::strong: + settings.legacy_strength = 1.10; + settings.legacy_curve = 0.40; + settings.legacy_noise_gate = 0.004; + settings.legacy_max_output = 0.82; + settings.legacy_high_scale = 0.85; + settings.legacy_response = legacy_response_t::fast; + settings.legacy_body_mix = 0.18; + break; + case legacy_profile_t::custom: + break; + } + return settings; } prepared_settings_t prepare(settings_t settings) noexcept { @@ -167,12 +252,21 @@ namespace ds5_config { } const auto input = nlohmann::json::parse(contents); - if (!input.is_object() || input.size() != 5 || + const bool has_extended = input.is_object() && input.size() == 11 && + input.contains("ds5_legacy_haptics_schema") && + input["ds5_legacy_haptics_schema"].is_number_integer() && + input["ds5_legacy_haptics_schema"].get() == 2; + if (!input.is_object() || (input.size() != 5 && !has_extended) || !input.contains("ds5_enabled") || !input["ds5_enabled"].is_boolean() || !input.contains("ds5_audio_haptics") || !input["ds5_audio_haptics"].is_boolean() || !input.contains("ds5_legacy_haptics_strength") || !input["ds5_legacy_haptics_strength"].is_number() || !input.contains("ds5_legacy_haptics_curve") || !input["ds5_legacy_haptics_curve"].is_number() || - !input.contains("ds5_legacy_haptics_noise_gate") || !input["ds5_legacy_haptics_noise_gate"].is_number()) { + !input.contains("ds5_legacy_haptics_noise_gate") || !input["ds5_legacy_haptics_noise_gate"].is_number() || + (has_extended && (!input.contains("ds5_legacy_haptics_profile") || !input["ds5_legacy_haptics_profile"].is_string() || + !input.contains("ds5_legacy_haptics_max_output") || !input["ds5_legacy_haptics_max_output"].is_number() || + !input.contains("ds5_legacy_haptics_high_scale") || !input["ds5_legacy_haptics_high_scale"].is_number() || + !input.contains("ds5_legacy_haptics_response") || !input["ds5_legacy_haptics_response"].is_string() || + !input.contains("ds5_legacy_haptics_body_mix") || !input["ds5_legacy_haptics_body_mix"].is_number()))) { return {load_status_t::INVALID, {}}; } @@ -183,6 +277,15 @@ namespace ds5_config { input["ds5_legacy_haptics_curve"].get(), input["ds5_legacy_haptics_noise_gate"].get(), }; + if (has_extended) { + if (!parse_legacy_profile(input["ds5_legacy_haptics_profile"].get(), settings.legacy_profile) || + !parse_legacy_response(input["ds5_legacy_haptics_response"].get(), settings.legacy_response)) { + return {load_status_t::INVALID, {}}; + } + settings.legacy_max_output = input["ds5_legacy_haptics_max_output"].get(); + settings.legacy_high_scale = input["ds5_legacy_haptics_high_scale"].get(); + settings.legacy_body_mix = input["ds5_legacy_haptics_body_mix"].get(); + } return validate(settings) ? load_result_t {load_status_t::LOADED, settings} : load_result_t {load_status_t::INVALID, {}}; } @@ -206,6 +309,12 @@ namespace ds5_config { {"ds5_legacy_haptics_strength", settings.legacy_strength}, {"ds5_legacy_haptics_curve", settings.legacy_curve}, {"ds5_legacy_haptics_noise_gate", settings.legacy_noise_gate}, + {"ds5_legacy_haptics_schema", 2}, + {"ds5_legacy_haptics_profile", legacy_profile_name(settings.legacy_profile)}, + {"ds5_legacy_haptics_max_output", settings.legacy_max_output}, + {"ds5_legacy_haptics_high_scale", settings.legacy_high_scale}, + {"ds5_legacy_haptics_response", legacy_response_name(settings.legacy_response)}, + {"ds5_legacy_haptics_body_mix", settings.legacy_body_mix}, }; std::ofstream file(temporary_path, std::ios::binary | std::ios::trunc); if (!file.is_open()) return false; diff --git a/src/ds5_config.h b/src/ds5_config.h index 3d30d0a2..d2214fba 100644 --- a/src/ds5_config.h +++ b/src/ds5_config.h @@ -7,6 +7,7 @@ #include #include #include +#include #include namespace ds5_config { @@ -16,6 +17,24 @@ namespace ds5_config { inline constexpr double MAX_CURVE = 2.0; inline constexpr double MIN_NOISE_GATE = 0.002; inline constexpr double MAX_NOISE_GATE = 0.060; + inline constexpr double MIN_MAX_OUTPUT = 0.25; + inline constexpr double MAX_MAX_OUTPUT = 1.0; + inline constexpr double MIN_HIGH_SCALE = 0.25; + inline constexpr double MAX_HIGH_SCALE = 1.0; + inline constexpr double MAX_BODY_MIX = 0.35; + + enum class legacy_profile_t { + custom, + quiet, + balanced, + strong, + }; + + enum class legacy_response_t { + fast, + balanced, + smooth, + }; struct settings_t { bool enabled = false; @@ -23,6 +42,11 @@ namespace ds5_config { double legacy_strength = 1.0; double legacy_curve = 1.0; double legacy_noise_gate = 0.020; + legacy_profile_t legacy_profile = legacy_profile_t::custom; + double legacy_max_output = 1.0; + double legacy_high_scale = 1.0; + legacy_response_t legacy_response = legacy_response_t::balanced; + double legacy_body_mix = 0.0; std::uint64_t revision = 1; }; @@ -69,6 +93,11 @@ namespace ds5_config { std::filesystem::path backup_path_for(const std::filesystem::path &settings_file); bool validate(const settings_t &settings) noexcept; + settings_t resolve_legacy_profile(settings_t settings) noexcept; + std::string_view legacy_profile_name(legacy_profile_t profile) noexcept; + bool parse_legacy_profile(std::string_view value, legacy_profile_t &profile) noexcept; + std::string_view legacy_response_name(legacy_response_t response) noexcept; + bool parse_legacy_response(std::string_view value, legacy_response_t &response) noexcept; prepared_settings_t prepare(settings_t settings) noexcept; bool commit(prepared_settings_t &&settings) noexcept; bool configure(settings_t settings) noexcept; diff --git a/src/ds5_config_api.cpp b/src/ds5_config_api.cpp index 79d5c8bf..8ef99786 100644 --- a/src/ds5_config_api.cpp +++ b/src/ds5_config_api.cpp @@ -63,6 +63,12 @@ namespace ds5_config::api { {"ds5_legacy_haptics_strength", settings.legacy_strength}, {"ds5_legacy_haptics_curve", settings.legacy_curve}, {"ds5_legacy_haptics_noise_gate", settings.legacy_noise_gate}, + {"ds5_legacy_haptics_schema", 2}, + {"ds5_legacy_haptics_profile", legacy_profile_name(settings.legacy_profile)}, + {"ds5_legacy_haptics_max_output", settings.legacy_max_output}, + {"ds5_legacy_haptics_high_scale", settings.legacy_high_scale}, + {"ds5_legacy_haptics_response", legacy_response_name(settings.legacy_response)}, + {"ds5_legacy_haptics_body_mix", settings.legacy_body_mix}, }; if (changed) result["changed"] = *changed; return result; @@ -73,16 +79,30 @@ namespace ds5_config::api { left.audio_haptics == right.audio_haptics && left.legacy_strength == right.legacy_strength && left.legacy_curve == right.legacy_curve && - left.legacy_noise_gate == right.legacy_noise_gate; + left.legacy_noise_gate == right.legacy_noise_gate && + left.legacy_profile == right.legacy_profile && + left.legacy_max_output == right.legacy_max_output && + left.legacy_high_scale == right.legacy_high_scale && + left.legacy_response == right.legacy_response && + left.legacy_body_mix == right.legacy_body_mix; } bool parse_settings(const json &input, settings_t &settings) { - if (!input.is_object() || input.size() != 5 || + const bool has_extended = input.is_object() && input.size() == 11 && + input.contains("ds5_legacy_haptics_schema") && + input["ds5_legacy_haptics_schema"].is_number_integer() && + input["ds5_legacy_haptics_schema"].get() == 2; + if (!input.is_object() || (input.size() != 5 && !has_extended) || !input.contains("ds5_enabled") || !input["ds5_enabled"].is_boolean() || !input.contains("ds5_audio_haptics") || !input["ds5_audio_haptics"].is_boolean() || !input.contains("ds5_legacy_haptics_strength") || !input["ds5_legacy_haptics_strength"].is_number() || !input.contains("ds5_legacy_haptics_curve") || !input["ds5_legacy_haptics_curve"].is_number() || - !input.contains("ds5_legacy_haptics_noise_gate") || !input["ds5_legacy_haptics_noise_gate"].is_number()) { + !input.contains("ds5_legacy_haptics_noise_gate") || !input["ds5_legacy_haptics_noise_gate"].is_number() || + (has_extended && (!input.contains("ds5_legacy_haptics_profile") || !input["ds5_legacy_haptics_profile"].is_string() || + !input.contains("ds5_legacy_haptics_max_output") || !input["ds5_legacy_haptics_max_output"].is_number() || + !input.contains("ds5_legacy_haptics_high_scale") || !input["ds5_legacy_haptics_high_scale"].is_number() || + !input.contains("ds5_legacy_haptics_response") || !input["ds5_legacy_haptics_response"].is_string() || + !input.contains("ds5_legacy_haptics_body_mix") || !input["ds5_legacy_haptics_body_mix"].is_number()))) { return false; } settings = { @@ -92,6 +112,15 @@ namespace ds5_config::api { input["ds5_legacy_haptics_curve"].get(), input["ds5_legacy_haptics_noise_gate"].get(), }; + if (has_extended) { + if (!parse_legacy_profile(input["ds5_legacy_haptics_profile"].get(), settings.legacy_profile) || + !parse_legacy_response(input["ds5_legacy_haptics_response"].get(), settings.legacy_response)) { + return false; + } + settings.legacy_max_output = input["ds5_legacy_haptics_max_output"].get(); + settings.legacy_high_scale = input["ds5_legacy_haptics_high_scale"].get(); + settings.legacy_body_mix = input["ds5_legacy_haptics_body_mix"].get(); + } return validate(settings); } diff --git a/src/haptics/authored_ir.cpp b/src/haptics/authored_ir.cpp index 1802f17d..4274720e 100644 --- a/src/haptics/authored_ir.cpp +++ b/src/haptics/authored_ir.cpp @@ -21,11 +21,12 @@ namespace haptics { constexpr std::uint8_t source_stream_end = 0x02; constexpr std::uint8_t source_discontinuity = 0x04; constexpr auto legacy_emit_period = std::chrono::milliseconds(20); + constexpr auto legacy_min_active_hold = std::chrono::milliseconds(80); constexpr auto legacy_watchdog_timeout = std::chrono::milliseconds(100); constexpr float legacy_gate_open = 0.020f; constexpr float legacy_gate_close = 0.010f; constexpr float legacy_gate_hold_seconds = 0.060f; - constexpr float legacy_output_floor = 0.004f; + constexpr float legacy_output_floor = 0.030f; constexpr float legacy_low_band_trim = 1.15f; constexpr float legacy_high_band_trim = 1.20f; constexpr float legacy_transient_trim = 1.15f; @@ -36,6 +37,28 @@ namespace haptics { constexpr float legacy_high_attack_seconds = 0.006f; constexpr float legacy_high_release_seconds = 0.025f; + struct response_params_t { + float low_attack_seconds; + float low_release_seconds; + float high_attack_seconds; + float high_release_seconds; + float max_slew_per_second; + }; + + response_params_t response_params(ds5_config::legacy_response_t response) noexcept { + switch (response) { + case ds5_config::legacy_response_t::fast: + return {0.006f, 0.020f, 0.004f, 0.015f, 18.0f}; + case ds5_config::legacy_response_t::smooth: + return {0.025f, 0.080f, 0.012f, 0.060f, 5.0f}; + case ds5_config::legacy_response_t::balanced: + return {legacy_low_attack_seconds, legacy_low_release_seconds, + legacy_high_attack_seconds, legacy_high_release_seconds, 10.0f}; + } + return {legacy_low_attack_seconds, legacy_low_release_seconds, + legacy_high_attack_seconds, legacy_high_release_seconds, 10.0f}; + } + void write_u16(std::uint8_t *p, std::uint16_t value) { p[0] = static_cast(value); @@ -243,6 +266,9 @@ namespace haptics { _low_gate = {}; _high_gate = {}; _last_emit = {}; + _active_since = {}; + _last_nonzero_low = 0; + _last_nonzero_high = 0; } const bool must_stop = (frame->flags & AH_AUTHORED_FRAME_STREAM_END) != 0; @@ -268,7 +294,7 @@ namespace haptics { // The immutable snapshot was fully validated before publication. Load it // once so both motor lanes use one coherent revision without per-packet // file access, parsing, locking, or duplicated range handling. - const auto settings = ds5_config::current(); + const auto settings = ds5_config::resolve_legacy_profile(ds5_config::current()); if (_tuning_revision != settings.revision) { _low_gate = {}; _high_gate = {}; @@ -280,32 +306,69 @@ namespace haptics { const auto gate_close = gate_open * 0.5f; const auto curve = static_cast(settings.legacy_curve); const auto strength = static_cast(settings.legacy_strength); + const auto max_output = static_cast(settings.legacy_max_output); + const auto high_scale = static_cast(settings.legacy_high_scale); + const auto body_mix = static_cast(settings.legacy_body_mix); if (must_stop) { _low_gate = {}; _high_gate = {}; } - const auto low_target = must_stop ? 0.0f : shaped( + auto low_target = must_stop ? 0.0f : shaped( low_energy, legacy_low_makeup_gain, _low_gate, duration_seconds, gate_open, gate_close, curve, strength); - const auto high_target = must_stop ? 0.0f : shaped( + auto high_target = must_stop ? 0.0f : shaped( high_energy, legacy_high_makeup_gain, _high_gate, duration_seconds, gate_open, gate_close, curve, strength); + if (!must_stop) { + high_target *= high_scale; + if (_active_since != std::chrono::steady_clock::time_point {} && + now - _active_since >= std::chrono::milliseconds(60)) { + low_target += high_target * body_mix; + } + low_target = std::min(low_target, max_output); + high_target = std::min(high_target, max_output * high_scale); + } + + const auto response = response_params(settings.legacy_response); const auto smooth = [duration_seconds](float previous, float target, float attack, float release) { const auto tau = target > previous ? attack : release; const auto alpha = 1.0f - std::exp(-duration_seconds / tau); return previous + (target - previous) * alpha; }; - _smoothed_low = must_stop ? 0.0f : smooth( - _smoothed_low, low_target, legacy_low_attack_seconds, legacy_low_release_seconds); - _smoothed_high = must_stop ? 0.0f : smooth( - _smoothed_high, high_target, legacy_high_attack_seconds, legacy_high_release_seconds); + const auto slew_limit = [duration_seconds, &response](float previous, float target) { + const auto max_delta = response.max_slew_per_second * duration_seconds; + return previous + std::clamp(target - previous, -max_delta, max_delta); + }; + _smoothed_low = must_stop ? 0.0f : slew_limit( + _smoothed_low, smooth(_smoothed_low, low_target, + response.low_attack_seconds, response.low_release_seconds)); + _smoothed_high = must_stop ? 0.0f : slew_limit( + _smoothed_high, smooth(_smoothed_high, high_target, + response.high_attack_seconds, response.high_release_seconds)); + _smoothed_low = std::min(_smoothed_low, max_output); + _smoothed_high = std::min(_smoothed_high, max_output * high_scale); if (low_target <= 0.0f && _smoothed_low < legacy_output_floor) _smoothed_low = 0.0f; if (high_target <= 0.0f && _smoothed_high < legacy_output_floor) _smoothed_high = 0.0f; - const auto low = rumble_u16(_smoothed_low); - const auto high = rumble_u16(_smoothed_high); + auto low = rumble_u16(_smoothed_low); + auto high = rumble_u16(_smoothed_high); + if (low != 0 || high != 0) { + if (_active_since == std::chrono::steady_clock::time_point {}) _active_since = now; + _last_nonzero_low = low; + _last_nonzero_high = high; + } + else if (!must_stop && _active_since != std::chrono::steady_clock::time_point {} && + now - _active_since < legacy_min_active_hold) { + low = _last_nonzero_low; + high = _last_nonzero_high; + } + else if (must_stop || _active_since != std::chrono::steady_clock::time_point {}) { + _active_since = {}; + _last_nonzero_low = 0; + _last_nonzero_high = 0; + } // The 20 ms rate limit is the only emission gate. Held silence additionally // stays quiet instead of re-sending zero rumble at 50 Hz. const bool silent_hold = low == 0 && high == 0 && _last_low == 0 && _last_high == 0; @@ -334,6 +397,9 @@ namespace haptics { _high_gate = {}; _last_low = 0; _last_high = 0; + _active_since = {}; + _last_nonzero_low = 0; + _last_nonzero_high = 0; if (!had_output) return std::nullopt; _last_emit = now; return legacy_rumble_t {_controller_id, 0, 0}; diff --git a/src/haptics/authored_ir.h b/src/haptics/authored_ir.h index 5ca5ced0..248f2afb 100644 --- a/src/haptics/authored_ir.h +++ b/src/haptics/authored_ir.h @@ -101,9 +101,12 @@ namespace haptics { authored_ir_session_t _analyzer; std::chrono::steady_clock::time_point _last_input {}; std::chrono::steady_clock::time_point _last_emit {}; + std::chrono::steady_clock::time_point _active_since {}; std::uint16_t _controller_id = 0; std::uint16_t _last_low = 0; std::uint16_t _last_high = 0; + std::uint16_t _last_nonzero_low = 0; + std::uint16_t _last_nonzero_high = 0; float _smoothed_low = 0.0f; float _smoothed_high = 0.0f; std::uint64_t _tuning_revision = 0; diff --git a/tests/unit/test_authored_ir.cpp b/tests/unit/test_authored_ir.cpp index 8150ea0b..3f5a9aa8 100644 --- a/tests/unit/test_authored_ir.cpp +++ b/tests/unit/test_authored_ir.cpp @@ -229,6 +229,40 @@ TEST(AuthoredDualSenseIr, LegacyFallbackWatchdogReleasesMotors) { EXPECT_FALSE(session.poll(start + 200ms).has_value()); } +TEST(AuthoredDualSenseIr, LegacyFallbackAppliesCeilingAndHighMotorScale) { + using namespace std::chrono_literals; + const auto old_settings = ds5_config::current(); + auto settings = old_settings; + settings.legacy_profile = ds5_config::legacy_profile_t::custom; + settings.legacy_max_output = 0.40; + settings.legacy_high_scale = 0.50; + settings.legacy_response = ds5_config::legacy_response_t::fast; + settings.legacy_body_mix = 0.0; + settings.revision = old_settings.revision + 1; + ASSERT_TRUE(ds5_config::configure(settings)); + + haptics::legacy_rumble_session_t session; + ASSERT_TRUE(session.ready()); + const auto start = std::chrono::steady_clock::time_point {1s}; + const auto loud = sine_pcm(120.0, 32000.0); + std::uint16_t max_low = 0; + std::uint16_t max_high = 0; + for (std::uint32_t chunk = 0; chunk < 80; ++chunk) { + const auto output = session.process( + 0, chunk == 0 ? 0x01 : 0, 240, chunk, chunk * 5000, + loud, start + std::chrono::milliseconds(chunk * 5)); + if (output) { + max_low = std::max(max_low, output->low_frequency); + max_high = std::max(max_high, output->high_frequency); + } + } + ASSERT_TRUE(ds5_config::configure(old_settings)); + EXPECT_LE(max_low, static_cast(0.40 * 65535.0)); + EXPECT_LE(max_high, static_cast(0.20 * 65535.0)); + EXPECT_GT(max_low, 0u); + EXPECT_GT(max_high, 0u); +} + TEST(AuthoredDualSenseIr, LegacyFallbackTuningKnobsOpenQuietBand) { using namespace std::chrono_literals; // A -36 dBFS sine sits in the band where voice-coil-authored content is diff --git a/tests/unit/test_ds5_config.cpp b/tests/unit/test_ds5_config.cpp index 2bcfbeda..1f30a964 100644 --- a/tests/unit/test_ds5_config.cpp +++ b/tests/unit/test_ds5_config.cpp @@ -64,6 +64,17 @@ namespace { {"ds5_legacy_haptics_noise_gate", 0.006}, }; } + + nlohmann::json extended_json() { + auto value = valid_json(); + value["ds5_legacy_haptics_schema"] = 2; + value["ds5_legacy_haptics_profile"] = "balanced"; + value["ds5_legacy_haptics_max_output"] = 0.70; + value["ds5_legacy_haptics_high_scale"] = 0.75; + value["ds5_legacy_haptics_response"] = "balanced"; + value["ds5_legacy_haptics_body_mix"] = 0.15; + return value; + } } // namespace TEST_F(Ds5ConfigTest, ResolvesBesideSelectedSunshineConfig) { @@ -100,6 +111,38 @@ TEST_F(Ds5ConfigTest, RejectsMalformedSchemaAndInvalidNumbers) { invalid = {}; invalid.legacy_noise_gate = 0.061; EXPECT_FALSE(ds5_config::validate(invalid)); + invalid = {}; + invalid.legacy_max_output = 0.24; + EXPECT_FALSE(ds5_config::validate(invalid)); + invalid = {}; + invalid.legacy_body_mix = 0.36; + EXPECT_FALSE(ds5_config::validate(invalid)); + + write_json(extended_json()); + const auto extended = ds5_config::load(path_); + ASSERT_EQ(extended.status, ds5_config::load_status_t::LOADED); + EXPECT_EQ(extended.settings.legacy_profile, ds5_config::legacy_profile_t::balanced); + EXPECT_DOUBLE_EQ(extended.settings.legacy_max_output, 0.70); + EXPECT_DOUBLE_EQ(extended.settings.legacy_high_scale, 0.75); + EXPECT_EQ(extended.settings.legacy_response, ds5_config::legacy_response_t::balanced); + EXPECT_DOUBLE_EQ(extended.settings.legacy_body_mix, 0.15); +} + +TEST_F(Ds5ConfigTest, ResolvesLegacyPresetsToCompleteRendererSettings) { + auto settings = ds5_config::settings_t {}; + settings.legacy_profile = ds5_config::legacy_profile_t::balanced; + const auto resolved = ds5_config::resolve_legacy_profile(settings); + EXPECT_DOUBLE_EQ(resolved.legacy_strength, 1.0); + EXPECT_DOUBLE_EQ(resolved.legacy_curve, 0.5); + EXPECT_DOUBLE_EQ(resolved.legacy_noise_gate, 0.006); + EXPECT_DOUBLE_EQ(resolved.legacy_max_output, 0.70); + EXPECT_DOUBLE_EQ(resolved.legacy_high_scale, 0.75); + EXPECT_EQ(resolved.legacy_response, ds5_config::legacy_response_t::balanced); + EXPECT_DOUBLE_EQ(resolved.legacy_body_mix, 0.15); + + settings.legacy_profile = ds5_config::legacy_profile_t::custom; + settings.legacy_strength = 1.7; + EXPECT_DOUBLE_EQ(ds5_config::resolve_legacy_profile(settings).legacy_strength, 1.7); } TEST_F(Ds5ConfigTest, SavesBacksUpAndReloadsCompleteSettings) { @@ -119,6 +162,11 @@ TEST_F(Ds5ConfigTest, SavesBacksUpAndReloadsCompleteSettings) { EXPECT_DOUBLE_EQ(loaded.settings.legacy_strength, replacement.legacy_strength); EXPECT_DOUBLE_EQ(loaded.settings.legacy_curve, replacement.legacy_curve); EXPECT_DOUBLE_EQ(loaded.settings.legacy_noise_gate, replacement.legacy_noise_gate); + EXPECT_EQ(loaded.settings.legacy_profile, replacement.legacy_profile); + EXPECT_DOUBLE_EQ(loaded.settings.legacy_max_output, replacement.legacy_max_output); + EXPECT_DOUBLE_EQ(loaded.settings.legacy_high_scale, replacement.legacy_high_scale); + EXPECT_EQ(loaded.settings.legacy_response, replacement.legacy_response); + EXPECT_DOUBLE_EQ(loaded.settings.legacy_body_mix, replacement.legacy_body_mix); // Revision describes only the current process and is not persisted. EXPECT_EQ(loaded.settings.revision, 1); } From 1dc3278494dec30c248d099cf91b6535cf8e22de Mon Sep 17 00:00:00 2001 From: qiin <414382190@qq.com> Date: Wed, 19 Aug 2026 01:14:18 +0800 Subject: [PATCH 2/4] fix(ds5): preserve default legacy mapping --- src/haptics/authored_ir.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/haptics/authored_ir.cpp b/src/haptics/authored_ir.cpp index 4274720e..5639738d 100644 --- a/src/haptics/authored_ir.cpp +++ b/src/haptics/authored_ir.cpp @@ -105,6 +105,11 @@ namespace haptics { const auto gated = std::clamp( (value - gate_close) / (1.0f - gate_close), 0.0f, 1.0f); + if (curve == 1.0f && strength == 1.0f) { + // Keep the PR974 default point-for-point with the established legacy + // tanh mapping; configurable curves take the path below. + return std::tanh(makeup_gain * gated) / std::tanh(makeup_gain); + } // A curve below 1 lifts the quiet band where voice-coil-authored content // is clearly felt while rotor motors do not start; tanh still caps the // top end so the strength multiplier cannot overdrive strong effects. From 45dde8190ebedcb75a68dd841e0f032aff695d43 Mon Sep 17 00:00:00 2001 From: Yundi339 Date: Thu, 20 Aug 2026 13:52:16 +0800 Subject: [PATCH 3/4] fix(ds5): preserve renderer settings for legacy clients --- src/ds5_config_api.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ds5_config_api.cpp b/src/ds5_config_api.cpp index 696ed8a7..b2641e09 100644 --- a/src/ds5_config_api.cpp +++ b/src/ds5_config_api.cpp @@ -197,13 +197,13 @@ namespace ds5_config::api { !input.contains("ds5_legacy_haptics_body_mix") || !input["ds5_legacy_haptics_body_mix"].is_number()))) { return false; } - settings = { - input["ds5_enabled"].get(), - input["ds5_audio_haptics"].get(), - input["ds5_legacy_haptics_strength"].get(), - input["ds5_legacy_haptics_curve"].get(), - input["ds5_legacy_haptics_noise_gate"].get(), - }; + // 五字段客户端早于 schema 2;处理旧文档时保留当前扩展渲染参数, + // 避免重置调用方无法读取和修改的设置。 + settings.enabled = input["ds5_enabled"].get(); + settings.audio_haptics = input["ds5_audio_haptics"].get(); + settings.legacy_strength = input["ds5_legacy_haptics_strength"].get(); + settings.legacy_curve = input["ds5_legacy_haptics_curve"].get(); + settings.legacy_noise_gate = input["ds5_legacy_haptics_noise_gate"].get(); if (has_extended) { if (!parse_legacy_profile(input["ds5_legacy_haptics_profile"].get(), settings.legacy_profile) || !parse_legacy_response(input["ds5_legacy_haptics_response"].get(), settings.legacy_response)) { @@ -273,7 +273,7 @@ namespace ds5_config::api { } const auto input = json::parse(request->content.string(), nullptr, false); - settings_t requested; + auto requested = current(); if (input.is_discarded() || !parse_settings(input, requested)) { write_error( std::move(response), From 6af8acffb31fb375695973a0d51b1211db0310cc Mon Sep 17 00:00:00 2001 From: Yundi339 Date: Thu, 20 Aug 2026 13:52:29 +0800 Subject: [PATCH 4/4] Revert "fix(ds5): preserve renderer settings for legacy clients" This reverts commit 45dde8190ebedcb75a68dd841e0f032aff695d43. --- src/ds5_config_api.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ds5_config_api.cpp b/src/ds5_config_api.cpp index b2641e09..696ed8a7 100644 --- a/src/ds5_config_api.cpp +++ b/src/ds5_config_api.cpp @@ -197,13 +197,13 @@ namespace ds5_config::api { !input.contains("ds5_legacy_haptics_body_mix") || !input["ds5_legacy_haptics_body_mix"].is_number()))) { return false; } - // 五字段客户端早于 schema 2;处理旧文档时保留当前扩展渲染参数, - // 避免重置调用方无法读取和修改的设置。 - settings.enabled = input["ds5_enabled"].get(); - settings.audio_haptics = input["ds5_audio_haptics"].get(); - settings.legacy_strength = input["ds5_legacy_haptics_strength"].get(); - settings.legacy_curve = input["ds5_legacy_haptics_curve"].get(); - settings.legacy_noise_gate = input["ds5_legacy_haptics_noise_gate"].get(); + settings = { + input["ds5_enabled"].get(), + input["ds5_audio_haptics"].get(), + input["ds5_legacy_haptics_strength"].get(), + input["ds5_legacy_haptics_curve"].get(), + input["ds5_legacy_haptics_noise_gate"].get(), + }; if (has_extended) { if (!parse_legacy_profile(input["ds5_legacy_haptics_profile"].get(), settings.legacy_profile) || !parse_legacy_response(input["ds5_legacy_haptics_response"].get(), settings.legacy_response)) { @@ -273,7 +273,7 @@ namespace ds5_config::api { } const auto input = json::parse(request->content.string(), nullptr, false); - auto requested = current(); + settings_t requested; if (input.is_discarded() || !parse_settings(input, requested)) { write_error( std::move(response),