Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/ds5_legacy_haptics_tuning.md
Original file line number Diff line number Diff line change
@@ -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.
126 changes: 123 additions & 3 deletions src/ds5_config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,109 @@ 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;
}

bool uses_stock_legacy_renderer(const settings_t &settings) noexcept {
return settings.legacy_profile == legacy_profile_t::custom &&
settings.legacy_strength == 1.0 &&
settings.legacy_curve == 0.5 &&
settings.legacy_noise_gate == 0.020 &&
settings.legacy_max_output == 1.0 &&
settings.legacy_high_scale == 1.0 &&
settings.legacy_response == legacy_response_t::balanced &&
settings.legacy_body_mix == 0.0;
}

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 {
Expand Down Expand Up @@ -169,12 +265,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<int>() == 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, {}};
}

Expand All @@ -185,6 +290,15 @@ namespace ds5_config {
input["ds5_legacy_haptics_curve"].get<double>(),
input["ds5_legacy_haptics_noise_gate"].get<double>(),
};
if (has_extended) {
if (!parse_legacy_profile(input["ds5_legacy_haptics_profile"].get<std::string>(), settings.legacy_profile) ||
!parse_legacy_response(input["ds5_legacy_haptics_response"].get<std::string>(), settings.legacy_response)) {
return {load_status_t::INVALID, {}};
}
settings.legacy_max_output = input["ds5_legacy_haptics_max_output"].get<double>();
settings.legacy_high_scale = input["ds5_legacy_haptics_high_scale"].get<double>();
settings.legacy_body_mix = input["ds5_legacy_haptics_body_mix"].get<double>();
}
return validate(settings) ? load_result_t {load_status_t::LOADED, settings} :
load_result_t {load_status_t::INVALID, {}};
}
Expand All @@ -208,6 +322,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;
Expand Down
30 changes: 30 additions & 0 deletions src/ds5_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <filesystem>
#include <cstdint>
#include <memory>
#include <string_view>
#include <utility>

namespace ds5_config {
Expand All @@ -16,13 +17,36 @@ 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;
bool audio_haptics = true;
double legacy_strength = 1.0;
double legacy_curve = 0.5;
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;
};

Expand Down Expand Up @@ -69,6 +93,12 @@ namespace ds5_config {
std::filesystem::path backup_path_for(const std::filesystem::path &settings_file);

bool validate(const settings_t &settings) noexcept;
bool uses_stock_legacy_renderer(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;
Expand Down
42 changes: 38 additions & 4 deletions src/ds5_config_api.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,29 @@ 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;
}

std::string make_entity_tag(const settings_t &settings, bool persisted) {
std::ostringstream stream;
stream.imbue(std::locale::classic());
stream << "\"ds5-v1-" << std::dec << settings.revision << '-'
stream << "\"ds5-v2-" << std::dec << settings.revision << '-'
<< (persisted ? '1' : '0') << '-'
<< (settings.enabled ? '1' : '0') << (settings.audio_haptics ? '1' : '0')
<< '-' << std::hex << std::setfill('0')
<< std::setw(16) << std::bit_cast<std::uint64_t>(settings.legacy_strength)
<< '-' << std::setw(16) << std::bit_cast<std::uint64_t>(settings.legacy_curve)
<< '-' << std::setw(16) << std::bit_cast<std::uint64_t>(settings.legacy_noise_gate)
<< '-' << std::setw(2) << static_cast<unsigned>(settings.legacy_profile)
<< '-' << std::setw(16) << std::bit_cast<std::uint64_t>(settings.legacy_max_output)
<< '-' << std::setw(16) << std::bit_cast<std::uint64_t>(settings.legacy_high_scale)
<< '-' << std::setw(2) << static_cast<unsigned>(settings.legacy_response)
<< '-' << std::setw(16) << std::bit_cast<std::uint64_t>(settings.legacy_body_mix)
<< '\"';
return stream.str();
}
Expand Down Expand Up @@ -158,18 +168,33 @@ 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;
}

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<int>() == 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 = {
Expand All @@ -179,6 +204,15 @@ namespace ds5_config::api {
input["ds5_legacy_haptics_curve"].get<double>(),
input["ds5_legacy_haptics_noise_gate"].get<double>(),
};
if (has_extended) {
if (!parse_legacy_profile(input["ds5_legacy_haptics_profile"].get<std::string>(), settings.legacy_profile) ||
!parse_legacy_response(input["ds5_legacy_haptics_response"].get<std::string>(), settings.legacy_response)) {
return false;
}
settings.legacy_max_output = input["ds5_legacy_haptics_max_output"].get<double>();
settings.legacy_high_scale = input["ds5_legacy_haptics_high_scale"].get<double>();
settings.legacy_body_mix = input["ds5_legacy_haptics_body_mix"].get<double>();
}
return validate(settings);
}

Expand Down
Loading