Apply persistent controller profiles

This commit is contained in:
Joey Yakimowich-Payne 2026-09-02 17:35:13 -06:00
commit cfec816dac
25 changed files with 2179 additions and 96 deletions

View file

@ -101,6 +101,8 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
bluepad32_input_backend.cpp
controller_identity.cpp
controller_profile.cpp
controller_profile_transform.cpp
controller_profile_runtime.cpp
profile_storage.cpp
profile_service.cpp
pico_profile_storage.cpp

View file

@ -17,6 +17,7 @@ constexpr uint8_t kMacroOverrideMask =
kControllerProfileOverrideRightStick |
kControllerProfileOverrideLeftTrigger |
kControllerProfileOverrideRightTrigger;
constexpr uint16_t kLegacyDefaultDigitalThreshold = 0x8000;
uint16_t profile_read_u16(const uint8_t* input) {
return static_cast<uint16_t>(input[0]) |
@ -144,7 +145,8 @@ ControllerProfile controller_profile_default(const ControllerIdentity& identity,
trigger.lower_deadzone = 0;
trigger.upper_saturation = UINT16_MAX;
trigger.curve_q8_8 = 256;
trigger.digital_threshold = 0x8000;
trigger.digital_threshold =
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
}
profile.weak_rumble_scale = UINT8_MAX;
profile.strong_rumble_scale = UINT8_MAX;
@ -176,9 +178,7 @@ bool controller_profile_validate(const ControllerProfile& profile) {
for (const ControllerProfileTriggerConfiguration& trigger :
profile.triggers) {
if (trigger.lower_deadzone >= trigger.upper_saturation ||
trigger.curve_q8_8 == 0 ||
trigger.digital_threshold < trigger.lower_deadzone ||
trigger.digital_threshold > trigger.upper_saturation) {
trigger.curve_q8_8 == 0) {
return false;
}
}
@ -272,13 +272,19 @@ bool controller_profile_encode(const ControllerProfile& profile,
bool controller_profile_decode(const uint8_t* input, size_t input_size,
ControllerProfile* output) {
if (input == nullptr || output == nullptr ||
input_size != CONTROLLER_PROFILE_ENCODED_SIZE ||
profile_read_u16(&input[0]) != CONTROLLER_PROFILE_SCHEMA_VERSION ||
input_size != CONTROLLER_PROFILE_ENCODED_SIZE) {
return false;
}
const uint16_t schema_version = profile_read_u16(&input[0]);
if ((schema_version != CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION &&
schema_version != CONTROLLER_PROFILE_SCHEMA_VERSION) ||
profile_read_u16(&input[2]) != CONTROLLER_PROFILE_ENCODED_SIZE ||
!profile_bytes_are_zero(&input[31], 5) || !profile_bytes_are_zero(&input[47], 5) ||
!profile_bytes_are_zero(&input[60], 2) || !profile_bytes_are_zero(&input[70], 2) ||
input[75] != 0 || input[81] != 0 ||
!profile_bytes_are_zero(&input[98], 2) || !profile_bytes_are_zero(&input[252], 4)) {
!profile_bytes_are_zero(&input[31], 5) ||
!profile_bytes_are_zero(&input[47], 5) ||
!profile_bytes_are_zero(&input[60], 2) ||
!profile_bytes_are_zero(&input[70], 2) || input[75] != 0 ||
input[81] != 0 || !profile_bytes_are_zero(&input[98], 2) ||
!profile_bytes_are_zero(&input[252], 4)) {
return false;
}
@ -307,6 +313,11 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
trigger.upper_saturation = profile_read_u16(&encoded[2]);
trigger.curve_q8_8 = profile_read_u16(&encoded[4]);
trigger.digital_threshold = profile_read_u16(&encoded[6]);
if (schema_version == CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION &&
trigger.digital_threshold == kLegacyDefaultDigitalThreshold) {
trigger.digital_threshold =
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
}
}
profile.weak_rumble_scale = input[72];
profile.strong_rumble_scale = input[73];
@ -489,11 +500,16 @@ bool controller_profile_database_decode(
return false;
}
uint8_t header[CONTROLLER_PROFILE_DATABASE_HEADER_SIZE]{};
if (!read(context, 0, header, sizeof(header)) ||
memcmp(header, kDatabaseMagic, sizeof(kDatabaseMagic)) != 0 ||
profile_read_u16(&header[4]) !=
CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION ||
profile_read_u16(&header[6]) != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
if (!read(context, 0, header, sizeof(header))) {
return false;
}
const uint16_t schema_version = profile_read_u16(&header[4]);
if (memcmp(header, kDatabaseMagic, sizeof(kDatabaseMagic)) != 0 ||
(schema_version !=
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION &&
schema_version != CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION) ||
profile_read_u16(&header[6]) !=
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
header[8] != CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY ||
header[9] != CONTROLLER_PROFILE_COUNT ||
header[10] >= CONTROLLER_PROFILE_COUNT ||

View file

@ -5,7 +5,8 @@
#include "controller_identity.h"
constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 1;
constexpr uint16_t CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION = 1;
constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 2;
constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 256;
constexpr uint8_t CONTROLLER_PROFILE_COUNT = 4;
constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT = 16;
@ -13,9 +14,12 @@ constexpr uint8_t CONTROLLER_PROFILE_MACRO_STEP_CAPACITY = 8;
constexpr uint16_t CONTROLLER_PROFILE_MAX_WAIT_MS = 10000;
constexpr uint8_t CONTROLLER_PROFILE_NO_BUTTON = 0xff;
constexpr uint8_t CONTROLLER_PROFILE_ALL = 0xff;
// Exact 16-bit counterpart of the existing 358-of-1023 Switch boundary.
constexpr uint16_t CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD = 22934;
constexpr uint8_t CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY = 16;
constexpr uint16_t CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION = 1;
constexpr uint16_t CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION = 1;
constexpr uint16_t CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION = 2;
constexpr size_t CONTROLLER_PROFILE_DATABASE_HEADER_SIZE = 32;
constexpr size_t CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE = 16;
constexpr size_t CONTROLLER_PROFILE_DATABASE_ENTRY_SIZE =
@ -92,7 +96,9 @@ struct ControllerProfileTriggerConfiguration {
uint16_t lower_deadzone = 0;
uint16_t upper_saturation = UINT16_MAX;
uint16_t curve_q8_8 = 256;
uint16_t digital_threshold = 0x8000;
// Compared against the transformed uint16 trigger output.
uint16_t digital_threshold =
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
};
struct ControllerProfileMacroStep {

View file

@ -0,0 +1,131 @@
#include "controller_profile_runtime.h"
#include "controller_identity.h"
#include "profile_service.h"
namespace {
struct ControllerProfileRuntimeContext {
bool active = false;
uint32_t connection_generation = 0;
ControllerIdentity identity{};
uint32_t database_generation = 0;
uint8_t active_profile_index = 0;
ControllerProfile profile{};
};
ControllerProfileRuntimeContext
g_contexts[CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT]{};
ControllerProfile g_default_profile{};
ControllerProfileTransformResult g_neutral_output{};
bool g_initialized = false;
void initialize_defaults() {
if (g_initialized) {
return;
}
g_default_profile =
controller_profile_default(controller_identity_global(), 0);
g_neutral_output = controller_profile_transform(
controller_neutral_state(), g_default_profile);
g_initialized = true;
}
void clear_context(ControllerProfileRuntimeContext* context) {
if (context == nullptr) {
return;
}
*context = {};
}
void refresh_profile(ControllerProfileRuntimeContext* context,
const ControllerIdentity& identity,
uint32_t connection_generation,
uint32_t observed_database_generation) {
ProfileServiceActiveProfileSnapshot snapshot{};
profile_service_active_profile_snapshot(identity, &snapshot);
context->active = true;
context->connection_generation = connection_generation;
context->identity = identity;
context->database_generation = snapshot.valid
? snapshot.metadata.generation
: observed_database_generation;
context->active_profile_index = snapshot.valid ? snapshot.profile_index : 0;
context->profile = snapshot.valid ? snapshot.profile : g_default_profile;
}
ControllerProfileRuntimeContext* update_context(
uint8_t slot, const Bluepad32SlotSnapshot& snapshot) {
if (slot >= CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT) {
return nullptr;
}
ControllerProfileRuntimeContext& context = g_contexts[slot];
if (!snapshot.active) {
if (context.active) {
clear_context(&context);
}
return nullptr;
}
const uint32_t database_generation =
profile_service_database_generation();
if (!context.active ||
context.connection_generation != snapshot.connection_generation ||
!controller_identity_equal(context.identity, snapshot.identity) ||
context.database_generation != database_generation) {
refresh_profile(&context, snapshot.identity,
snapshot.connection_generation,
database_generation);
}
return &context;
}
} // namespace
void controller_profile_runtime_reset() {
g_initialized = false;
initialize_defaults();
for (ControllerProfileRuntimeContext& context : g_contexts) {
clear_context(&context);
}
}
ControllerProfileTransformResult controller_profile_runtime_transform(
uint8_t slot, const Bluepad32SlotSnapshot& snapshot) {
initialize_defaults();
ControllerProfileRuntimeContext* context =
update_context(slot, snapshot);
if (context == nullptr) {
return g_neutral_output;
}
return controller_profile_transform(snapshot.state, context->profile);
}
ControllerRumbleOutput controller_profile_runtime_scale_host_rumble(
uint8_t slot, const Bluepad32SlotSnapshot& snapshot,
const ControllerRumbleOutput& rumble) {
initialize_defaults();
ControllerProfileRuntimeContext* context =
update_context(slot, snapshot);
const ControllerProfile& profile =
context == nullptr ? g_default_profile : context->profile;
return controller_profile_scale_host_rumble(rumble, profile);
}
ControllerProfileRuntimeLocalConfirmation
controller_profile_runtime_local_confirmation(
uint8_t slot, const Bluepad32SlotSnapshot& snapshot,
const ControllerRumbleOutput& rumble) {
initialize_defaults();
ControllerProfileRuntimeContext* context =
update_context(slot, snapshot);
const ControllerProfile& profile =
context == nullptr ? g_default_profile : context->profile;
return {
rumble,
controller_profile_confirmation_policy(profile),
};
}

View file

@ -0,0 +1,34 @@
#pragma once
#include <stdint.h>
#include "bluepad32_input_backend.h"
#include "controller_profile_transform.h"
constexpr uint8_t CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT = 4;
struct ControllerProfileRuntimeLocalConfirmation {
ControllerRumbleOutput rumble{};
ControllerProfileConfirmationPolicy policy =
ControllerProfileConfirmationPolicy::kRumbleAndLed;
};
// Reset all four fixed slot caches to the default profile.
void controller_profile_runtime_reset();
// Refresh a slot only when its connection key or database generation changes,
// then transform the raw snapshot. Inactive snapshots return neutral output and
// invalidate the slot immediately.
ControllerProfileTransformResult controller_profile_runtime_transform(
uint8_t slot, const Bluepad32SlotSnapshot& snapshot);
// Refresh from the current slot snapshot and scale host-originated rumble.
ControllerRumbleOutput controller_profile_runtime_scale_host_rumble(
uint8_t slot, const Bluepad32SlotSnapshot& snapshot,
const ControllerRumbleOutput& rumble);
// Preserve local confirmation rumble exactly while exposing profile policy.
ControllerProfileRuntimeLocalConfirmation
controller_profile_runtime_local_confirmation(
uint8_t slot, const Bluepad32SlotSnapshot& snapshot,
const ControllerRumbleOutput& rumble);

View file

@ -0,0 +1,344 @@
#include "controller_profile_transform.h"
#include <limits.h>
namespace {
constexpr uint32_t kQ16One = 1u << 16;
constexpr uint16_t button_bit(ControllerProfileLogicalButton button) {
return static_cast<uint16_t>(
1u << static_cast<uint8_t>(button));
}
int32_t clamp_centered_axis(int16_t value, int16_t center) {
const int32_t adjusted =
static_cast<int32_t>(value) - static_cast<int32_t>(center);
if (adjusted < INT16_MIN) {
return INT16_MIN;
}
if (adjusted > INT16_MAX) {
return INT16_MAX;
}
return adjusted;
}
uint32_t absolute_axis(int32_t value) {
return value < 0 ? static_cast<uint32_t>(-value)
: static_cast<uint32_t>(value);
}
// This endpoint-preserving rational curve is x / (c + (1 - c) * x),
// evaluated with x in Q16 and c in Q8.8. Values above 1.0 reduce the
// response below the linear curve; values below 1.0 increase it.
uint32_t apply_curve_q16(uint32_t input_q16, uint16_t curve_q8_8) {
if (input_q16 == 0 || input_q16 == kQ16One || curve_q8_8 == 256) {
return input_q16;
}
const uint64_t denominator =
static_cast<uint64_t>(curve_q8_8) *
(kQ16One - input_q16) +
static_cast<uint64_t>(256) * input_q16;
const uint64_t numerator =
static_cast<uint64_t>(input_q16) * 256u * kQ16One;
const uint64_t curved = (numerator + denominator / 2u) / denominator;
return curved > kQ16One ? kQ16One
: static_cast<uint32_t>(curved);
}
bool is_default_stick_configuration(
const ControllerProfileStickConfiguration& configuration) {
return configuration.center_x == 0 && configuration.center_y == 0 &&
configuration.inner_deadzone == 0 &&
configuration.outer_saturation == 32767 &&
configuration.curve_q8_8 == 256 && !configuration.invert_x &&
!configuration.invert_y;
}
bool is_default_trigger_configuration(
const ControllerProfileTriggerConfiguration& configuration) {
return configuration.lower_deadzone == 0 &&
configuration.upper_saturation == UINT16_MAX &&
configuration.curve_q8_8 == 256;
}
int16_t scale_stick_axis(int32_t adjusted_axis, uint32_t magnitude,
uint32_t response_q16, bool invert) {
if (adjusted_axis == 0 || magnitude == 0 || response_q16 == 0) {
return 0;
}
bool negative = adjusted_axis < 0;
if (invert) {
negative = !negative;
}
const uint32_t axis_magnitude = absolute_axis(adjusted_axis);
const uint32_t output_limit =
negative ? static_cast<uint32_t>(-static_cast<int32_t>(INT16_MIN))
: static_cast<uint32_t>(INT16_MAX);
const uint64_t numerator =
static_cast<uint64_t>(axis_magnitude) * output_limit *
response_q16;
const uint64_t denominator =
static_cast<uint64_t>(magnitude) * kQ16One;
uint32_t output_magnitude = static_cast<uint32_t>(
(numerator + denominator / 2u) / denominator);
if (output_magnitude > output_limit) {
output_magnitude = output_limit;
}
if (!negative) {
return static_cast<int16_t>(output_magnitude);
}
if (output_magnitude ==
static_cast<uint32_t>(-static_cast<int32_t>(INT16_MIN))) {
return INT16_MIN;
}
return static_cast<int16_t>(-static_cast<int32_t>(output_magnitude));
}
void transform_stick(const ControllerProfileStickConfiguration& configuration,
int16_t input_x, int16_t input_y, int16_t* output_x,
int16_t* output_y) {
if (is_default_stick_configuration(configuration)) {
*output_x = input_x;
*output_y = input_y;
return;
}
const int32_t adjusted_x =
clamp_centered_axis(input_x, configuration.center_x);
const int32_t adjusted_y =
clamp_centered_axis(input_y, configuration.center_y);
const uint32_t magnitude_x = absolute_axis(adjusted_x);
const uint32_t magnitude_y = absolute_axis(adjusted_y);
const uint32_t magnitude =
magnitude_x > magnitude_y ? magnitude_x : magnitude_y;
uint32_t response_q16 = 0;
if (magnitude <= configuration.inner_deadzone) {
response_q16 = 0;
} else if (magnitude >= configuration.outer_saturation) {
response_q16 = kQ16One;
} else {
const uint32_t input_range =
static_cast<uint32_t>(configuration.outer_saturation) -
configuration.inner_deadzone;
const uint32_t input_offset =
magnitude - configuration.inner_deadzone;
const uint32_t normalized_q16 = static_cast<uint32_t>(
(static_cast<uint64_t>(input_offset) * kQ16One +
input_range / 2u) /
input_range);
response_q16 =
apply_curve_q16(normalized_q16, configuration.curve_q8_8);
}
*output_x = scale_stick_axis(adjusted_x, magnitude, response_q16,
configuration.invert_x);
*output_y = scale_stick_axis(adjusted_y, magnitude, response_q16,
configuration.invert_y);
}
uint16_t transform_trigger(
uint16_t input,
const ControllerProfileTriggerConfiguration& configuration) {
if (is_default_trigger_configuration(configuration)) {
return input;
}
if (input <= configuration.lower_deadzone) {
return 0;
}
if (input >= configuration.upper_saturation) {
return UINT16_MAX;
}
const uint32_t input_range =
static_cast<uint32_t>(configuration.upper_saturation) -
configuration.lower_deadzone;
const uint32_t input_offset =
static_cast<uint32_t>(input) - configuration.lower_deadzone;
if (configuration.curve_q8_8 == 256) {
return static_cast<uint16_t>(
(static_cast<uint64_t>(input_offset) * UINT16_MAX +
input_range / 2u) /
input_range);
}
const uint32_t normalized_q16 = static_cast<uint32_t>(
(static_cast<uint64_t>(input_offset) * kQ16One +
input_range / 2u) /
input_range);
const uint32_t curved_q16 =
apply_curve_q16(normalized_q16, configuration.curve_q8_8);
return static_cast<uint16_t>(
(static_cast<uint64_t>(curved_q16) * UINT16_MAX +
kQ16One / 2u) /
kQ16One);
}
} // namespace
uint16_t controller_profile_extract_button_mask(const ControllerState& state) {
uint16_t mask = 0;
mask |= state.button_south
? button_bit(ControllerProfileLogicalButton::kSouth)
: 0;
mask |= state.button_east
? button_bit(ControllerProfileLogicalButton::kEast)
: 0;
mask |= state.button_west
? button_bit(ControllerProfileLogicalButton::kWest)
: 0;
mask |= state.button_north
? button_bit(ControllerProfileLogicalButton::kNorth)
: 0;
mask |= state.button_left_shoulder
? button_bit(ControllerProfileLogicalButton::kLeftShoulder)
: 0;
mask |= state.button_right_shoulder
? button_bit(ControllerProfileLogicalButton::kRightShoulder)
: 0;
mask |= state.button_select
? button_bit(ControllerProfileLogicalButton::kSelect)
: 0;
mask |= state.button_start
? button_bit(ControllerProfileLogicalButton::kStart)
: 0;
mask |= state.button_system
? button_bit(ControllerProfileLogicalButton::kSystem)
: 0;
mask |= state.button_capture
? button_bit(ControllerProfileLogicalButton::kCapture)
: 0;
mask |= state.button_left_stick
? button_bit(ControllerProfileLogicalButton::kLeftStick)
: 0;
mask |= state.button_right_stick
? button_bit(ControllerProfileLogicalButton::kRightStick)
: 0;
mask |= state.dpad_up
? button_bit(ControllerProfileLogicalButton::kDpadUp)
: 0;
mask |= state.dpad_down
? button_bit(ControllerProfileLogicalButton::kDpadDown)
: 0;
mask |= state.dpad_left
? button_bit(ControllerProfileLogicalButton::kDpadLeft)
: 0;
mask |= state.dpad_right
? button_bit(ControllerProfileLogicalButton::kDpadRight)
: 0;
return mask;
}
void controller_profile_apply_button_mask(uint16_t button_mask,
ControllerState* state) {
if (state == nullptr) {
return;
}
state->button_south =
(button_mask & button_bit(ControllerProfileLogicalButton::kSouth)) != 0;
state->button_east =
(button_mask & button_bit(ControllerProfileLogicalButton::kEast)) != 0;
state->button_west =
(button_mask & button_bit(ControllerProfileLogicalButton::kWest)) != 0;
state->button_north =
(button_mask & button_bit(ControllerProfileLogicalButton::kNorth)) != 0;
state->button_left_shoulder =
(button_mask &
button_bit(ControllerProfileLogicalButton::kLeftShoulder)) != 0;
state->button_right_shoulder =
(button_mask &
button_bit(ControllerProfileLogicalButton::kRightShoulder)) != 0;
state->button_select =
(button_mask & button_bit(ControllerProfileLogicalButton::kSelect)) != 0;
state->button_start =
(button_mask & button_bit(ControllerProfileLogicalButton::kStart)) != 0;
state->button_system =
(button_mask & button_bit(ControllerProfileLogicalButton::kSystem)) != 0;
state->button_capture =
(button_mask & button_bit(ControllerProfileLogicalButton::kCapture)) != 0;
state->button_left_stick =
(button_mask &
button_bit(ControllerProfileLogicalButton::kLeftStick)) != 0;
state->button_right_stick =
(button_mask &
button_bit(ControllerProfileLogicalButton::kRightStick)) != 0;
state->dpad_up =
(button_mask & button_bit(ControllerProfileLogicalButton::kDpadUp)) != 0;
state->dpad_down =
(button_mask & button_bit(ControllerProfileLogicalButton::kDpadDown)) != 0;
state->dpad_left =
(button_mask & button_bit(ControllerProfileLogicalButton::kDpadLeft)) != 0;
state->dpad_right =
(button_mask & button_bit(ControllerProfileLogicalButton::kDpadRight)) != 0;
}
uint16_t controller_profile_map_button_mask(
uint16_t input_button_mask, const ControllerProfile& profile) {
uint16_t output_button_mask = 0;
for (uint8_t input = 0;
input < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++input) {
if ((input_button_mask & static_cast<uint16_t>(1u << input)) == 0) {
continue;
}
const uint8_t output = profile.button_map[input];
if (output < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) {
output_button_mask |= static_cast<uint16_t>(1u << output);
}
}
return output_button_mask;
}
ControllerProfileTransformResult controller_profile_transform(
const ControllerState& input, const ControllerProfile& profile) {
ControllerProfileTransformResult result{};
result.state = input;
const uint16_t input_button_mask =
controller_profile_extract_button_mask(input);
controller_profile_apply_button_mask(
controller_profile_map_button_mask(input_button_mask, profile),
&result.state);
transform_stick(profile.sticks[0], input.left_stick_x,
input.left_stick_y, &result.state.left_stick_x,
&result.state.left_stick_y);
transform_stick(profile.sticks[1], input.right_stick_x,
input.right_stick_y, &result.state.right_stick_x,
&result.state.right_stick_y);
result.state.left_trigger = transform_trigger(input.left_trigger,
profile.triggers[0]);
result.state.right_trigger = transform_trigger(input.right_trigger,
profile.triggers[1]);
result.left_trigger_digital_threshold =
profile.triggers[0].digital_threshold;
result.right_trigger_digital_threshold =
profile.triggers[1].digital_threshold;
return result;
}
uint8_t controller_profile_scale_rumble_magnitude(uint8_t magnitude,
uint8_t scale) {
const uint32_t scaled =
(static_cast<uint32_t>(magnitude) * scale + UINT8_MAX / 2u) /
UINT8_MAX;
return scaled > UINT8_MAX ? UINT8_MAX
: static_cast<uint8_t>(scaled);
}
ControllerRumbleOutput controller_profile_scale_host_rumble(
const ControllerRumbleOutput& input, const ControllerProfile& profile) {
return {
controller_profile_scale_rumble_magnitude(
input.low_frequency_magnitude, profile.strong_rumble_scale),
controller_profile_scale_rumble_magnitude(
input.high_frequency_magnitude, profile.weak_rumble_scale),
};
}
ControllerProfileConfirmationPolicy controller_profile_confirmation_policy(
const ControllerProfile& profile) {
return profile.confirmation_policy;
}

View file

@ -0,0 +1,31 @@
#pragma once
#include <stdint.h>
#include "controller_profile.h"
#include "controller_state.h"
#include "switch_haptics.h"
struct ControllerProfileTransformResult {
ControllerState state{};
uint16_t left_trigger_digital_threshold =
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
uint16_t right_trigger_digital_threshold =
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
};
uint16_t controller_profile_extract_button_mask(const ControllerState& state);
void controller_profile_apply_button_mask(uint16_t button_mask,
ControllerState* state);
uint16_t controller_profile_map_button_mask(
uint16_t input_button_mask, const ControllerProfile& profile);
ControllerProfileTransformResult controller_profile_transform(
const ControllerState& input, const ControllerProfile& profile);
uint8_t controller_profile_scale_rumble_magnitude(uint8_t magnitude,
uint8_t scale);
ControllerRumbleOutput controller_profile_scale_host_rumble(
const ControllerRumbleOutput& input, const ControllerProfile& profile);
ControllerProfileConfirmationPolicy controller_profile_confirmation_policy(
const ControllerProfile& profile);

View file

@ -29,13 +29,23 @@ struct ProfileTransaction {
uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{};
};
struct PublishedActiveProfile {
ControllerIdentity identity{};
uint8_t profile_index = 0;
ControllerProfile profile{};
};
critical_section_t g_lock;
bool g_prepared = false;
ProfileStorage g_storage;
ControllerProfileDatabase g_database;
ProfileServiceMetadata g_metadata;
uint32_t g_published_generation = 0;
ProfileServiceListSnapshot g_list;
ProfileServiceSelectedSnapshot g_selected;
PublishedActiveProfile
g_active_profiles[PROFILE_SERVICE_LIST_CAPACITY]{};
uint8_t g_active_profile_count = 0;
ProfileTransaction g_transaction;
PendingCommand g_command;
bool g_identity_dirty = false;
@ -65,6 +75,26 @@ void refresh_list_locked() {
}
}
void refresh_active_profiles_locked() {
g_active_profile_count = 1;
g_active_profiles[0].identity = controller_identity_global();
g_active_profiles[0].profile_index =
g_database.fallback_active_profile;
g_active_profiles[0].profile =
g_database.fallback_profiles[g_database.fallback_active_profile];
for (const ControllerProfileDatabaseEntry& entry : g_database.entries) {
if (!entry.used ||
g_active_profile_count >= PROFILE_SERVICE_LIST_CAPACITY) {
continue;
}
PublishedActiveProfile& active =
g_active_profiles[g_active_profile_count++];
active.identity = entry.identity;
active.profile_index = entry.active_profile;
active.profile = entry.profiles[entry.active_profile];
}
}
void refresh_selected_locked() {
g_selected.metadata = g_metadata;
const ControllerProfile* profile = controller_profile_database_get(
@ -84,8 +114,11 @@ void refresh_metadata_locked(ProfileServiceState state) {
g_metadata.state = state;
g_metadata.generation = stored.valid ? stored.generation : 0;
g_metadata.payload_crc = stored.valid ? stored.payload_crc : 0;
refresh_active_profiles_locked();
refresh_list_locked();
refresh_selected_locked();
__atomic_store_n(&g_published_generation, g_metadata.generation,
__ATOMIC_RELEASE);
}
ConfigurationTransactionStatus database_result_status(
@ -134,8 +167,11 @@ void profile_service_prepare() {
}
critical_section_init(&g_lock);
g_metadata = {};
__atomic_store_n(&g_published_generation, 0, __ATOMIC_RELAXED);
g_list = {};
g_selected = {};
g_active_profiles[0] = {};
g_active_profile_count = 0;
g_selected.identity = controller_identity_global();
g_selected.profile_index = 0;
g_transaction = {};
@ -522,27 +558,36 @@ void profile_service_transaction_snapshot(
critical_section_exit(&g_lock);
}
bool profile_service_active_profile(const ControllerIdentity& identity,
ControllerProfile* output,
uint8_t* profile_index) {
if (output == nullptr || profile_index == nullptr ||
!valid_identity(identity)) {
return false;
uint32_t profile_service_database_generation() {
return __atomic_load_n(&g_published_generation, __ATOMIC_ACQUIRE);
}
void profile_service_active_profile_snapshot(
const ControllerIdentity& identity,
ProfileServiceActiveProfileSnapshot* output) {
if (output == nullptr) {
return;
}
*output = {};
if (!valid_identity(identity)) {
return;
}
critical_section_enter_blocking(&g_lock);
if (g_metadata.state != ProfileServiceState::kReady) {
critical_section_exit(&g_lock);
return false;
}
const ControllerProfileDatabaseEntry* entry =
controller_profile_database_find(g_database, identity);
if (entry != nullptr) {
*profile_index = entry->active_profile;
*output = entry->profiles[entry->active_profile];
} else {
*profile_index = g_database.fallback_active_profile;
*output = g_database.fallback_profiles[*profile_index];
output->metadata = g_metadata;
if (g_metadata.state == ProfileServiceState::kReady &&
g_active_profile_count != 0) {
const PublishedActiveProfile* active = &g_active_profiles[0];
for (uint8_t index = 1; index < g_active_profile_count; ++index) {
if (controller_identity_equal(
g_active_profiles[index].identity, identity)) {
active = &g_active_profiles[index];
break;
}
}
output->profile_index = active->profile_index;
output->profile = active->profile;
output->valid = true;
}
critical_section_exit(&g_lock);
return true;
}

View file

@ -42,6 +42,13 @@ struct ProfileServiceSelectedSnapshot {
ControllerProfile profile{};
};
struct ProfileServiceActiveProfileSnapshot {
ProfileServiceMetadata metadata{};
bool valid = false;
uint8_t profile_index = 0;
ControllerProfile profile{};
};
struct ProfileServiceTransactionSnapshot {
ProfileServiceMetadata metadata{};
ControllerIdentity identity{};
@ -78,6 +85,7 @@ void profile_service_selected_snapshot(
ProfileServiceSelectedSnapshot* output);
void profile_service_transaction_snapshot(
ProfileServiceTransactionSnapshot* output);
bool profile_service_active_profile(const ControllerIdentity& identity,
ControllerProfile* output,
uint8_t* profile_index);
uint32_t profile_service_database_generation();
void profile_service_active_profile_snapshot(
const ControllerIdentity& identity,
ProfileServiceActiveProfileSnapshot* output);

View file

@ -236,8 +236,10 @@ bool ProfileStorage::read_header(uint8_t bank, BankHeader* output) const {
!io_.read(io_.context, bank, 0, header, sizeof(header)) ||
memcmp(header, kRecordMagic, sizeof(kRecordMagic)) != 0 ||
storage_read_u16(&header[4]) != kRecordFormatVersion ||
storage_read_u16(&header[6]) !=
CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION ||
(storage_read_u16(&header[6]) !=
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION &&
storage_read_u16(&header[6]) !=
CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION) ||
storage_read_u32(&header[12]) !=
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
profile_storage_crc32(header, kHeaderCrcOffset) !=

View file

@ -69,7 +69,8 @@ PAIRING_RECORD_CAPACITY = 16
TRANSPORT_UNKNOWN = 0
TRANSPORT_CLASSIC = 1
TRANSPORT_BLE = 2
PROFILE_SCHEMA_VERSION = 1
PROFILE_LEGACY_SCHEMA_VERSION = 1
PROFILE_SCHEMA_VERSION = 2
PROFILE_SIZE = 256
PROFILE_CAPACITY = 4
PROFILE_IDENTITY_CAPACITY = 16
@ -80,6 +81,8 @@ PROFILE_NONE_BUTTON = 0xFF
PROFILE_MACRO_STEP_CAPACITY = 8
PROFILE_MACRO_STEP_SIZE = 19
PROFILE_MAXIMUM_WAIT_MS = 10000
PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD = 0x8000
PROFILE_DEFAULT_DIGITAL_THRESHOLD = 22934
LOGICAL_BUTTONS = (
"south",
@ -513,10 +516,7 @@ class TriggerConfig:
)
_require_int(self.curve_q8_8, "trigger curve_q8_8", 1, 0xFFFF)
_require_int(
self.digital_threshold,
"trigger digital_threshold",
self.lower_deadzone,
self.upper_saturation,
self.digital_threshold, "trigger digital_threshold", 0, 0xFFFF
)
@classmethod
@ -575,6 +575,19 @@ class TriggerConfig:
),
)
def _migrate_legacy_trigger_threshold(trigger: TriggerConfig) -> TriggerConfig:
if (
trigger.digital_threshold
!= PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD
):
return trigger
return TriggerConfig(
trigger.lower_deadzone,
trigger.upper_saturation,
trigger.curve_q8_8,
PROFILE_DEFAULT_DIGITAL_THRESHOLD,
)
@dataclass(frozen=True)
class MacroStep:
@ -866,7 +879,9 @@ class ControllerProfile:
@classmethod
def default(cls) -> ControllerProfile:
stick = StickConfig(0, 0, 0, 0x7FFF, 256, False, False)
trigger = TriggerConfig(0, 0xFFFF, 256, 0x8000)
trigger = TriggerConfig(
0, 0xFFFF, 256, PROFILE_DEFAULT_DIGITAL_THRESHOLD
)
return cls(
button_map=tuple(range(len(LOGICAL_BUTTONS))),
left_stick=stick,
@ -889,7 +904,11 @@ class ControllerProfile:
if len(payload) != PROFILE_SIZE:
raise ConfigManagerError("invalid profile size")
version, size = struct.unpack_from("<HH", payload)
if version != PROFILE_SCHEMA_VERSION or size != PROFILE_SIZE:
if (
version
not in (PROFILE_LEGACY_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION)
or size != PROFILE_SIZE
):
raise ConfigManagerError("unsupported profile schema")
if payload[75] != 0 or payload[81] != 0:
raise ConfigManagerError("profile reserved fields must be zero")
@ -912,12 +931,17 @@ class ControllerProfile:
step != MacroStep.end() for step in all_steps[macro_count:]
):
raise ConfigManagerError("unused macro steps must be canonical end")
left_trigger = TriggerConfig.from_bytes(payload[52:62])
right_trigger = TriggerConfig.from_bytes(payload[62:72])
if version == PROFILE_LEGACY_SCHEMA_VERSION:
left_trigger = _migrate_legacy_trigger_threshold(left_trigger)
right_trigger = _migrate_legacy_trigger_threshold(right_trigger)
return cls(
button_map=tuple(payload[4:20]),
left_stick=StickConfig.from_bytes(payload[20:36]),
right_stick=StickConfig.from_bytes(payload[36:52]),
left_trigger=TriggerConfig.from_bytes(payload[52:62]),
right_trigger=TriggerConfig.from_bytes(payload[62:72]),
left_trigger=left_trigger,
right_trigger=right_trigger,
weak_rumble_scale=payload[72],
strong_rumble_scale=payload[73],
confirmation_policy=payload[74],
@ -1020,11 +1044,12 @@ class ControllerProfile:
"turbo",
)
obj = _require_object(value, fields, "profile")
schema_version = _require_int(
obj["schema_version"], "profile.schema_version", 0, 0xFFFF
)
if (
_require_int(
obj["schema_version"], "profile.schema_version", 0, 0xFFFF
)
!= PROFILE_SCHEMA_VERSION
schema_version
not in (PROFILE_LEGACY_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION)
or _require_int(obj["size"], "profile.size", 0, 0xFFFF)
!= PROFILE_SIZE
):
@ -1056,6 +1081,15 @@ class ControllerProfile:
raise ConfigManagerError(
"profile.macro.steps must contain one to eight steps"
)
left_trigger = TriggerConfig.from_json_object(
triggers["left"], "profile.triggers.left"
)
right_trigger = TriggerConfig.from_json_object(
triggers["right"], "profile.triggers.right"
)
if schema_version == PROFILE_LEGACY_SCHEMA_VERSION:
left_trigger = _migrate_legacy_trigger_threshold(left_trigger)
right_trigger = _migrate_legacy_trigger_threshold(right_trigger)
return cls(
button_map=tuple(
_button_index(
@ -1069,12 +1103,8 @@ class ControllerProfile:
right_stick=StickConfig.from_json_object(
sticks["right"], "profile.sticks.right"
),
left_trigger=TriggerConfig.from_json_object(
triggers["left"], "profile.triggers.left"
),
right_trigger=TriggerConfig.from_json_object(
triggers["right"], "profile.triggers.right"
),
left_trigger=left_trigger,
right_trigger=right_trigger,
weak_rumble_scale=_require_int(
rumble["weak_scale"],
"profile.rumble.weak_scale",
@ -1345,7 +1375,10 @@ def reset_configuration(device: UsbDevice, timeout: float) -> TransactionStatus:
def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]:
_raise_status(envelope)
if envelope.schema_version != PROFILE_SCHEMA_VERSION:
if envelope.schema_version not in (
PROFILE_LEGACY_SCHEMA_VERSION,
PROFILE_SCHEMA_VERSION,
):
raise ConfigManagerError("unsupported profile-list schema")
if not envelope.payload:
raise ConfigManagerError("short profile-list payload")
@ -1407,7 +1440,10 @@ def select_profile(
def read_selected_profile(device: UsbDevice) -> ControllerProfile:
envelope = _control_in(device, OP_PROFILE_READ)
_raise_status(envelope)
if envelope.schema_version != PROFILE_SCHEMA_VERSION:
if envelope.schema_version not in (
PROFILE_LEGACY_SCHEMA_VERSION,
PROFILE_SCHEMA_VERSION,
):
raise ConfigManagerError("unsupported profile schema")
return ControllerProfile.from_bytes(envelope.payload)

View file

@ -7,6 +7,7 @@
#include "hardware/uart.h"
#else
#include "bluepad32_input_backend.h"
#include "controller_profile_runtime.h"
#include "bootsel_pairing_button.h"
#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY
#include "adapter_host_probe.h"
@ -33,6 +34,10 @@
#ifdef SWITCH_PICO_BLUEPAD32
static_assert(SWITCH_PICO_HID_INSTANCE_COUNT ==
BLUEPAD32_INPUT_BACKEND_SLOT_COUNT);
static_assert(CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT ==
BLUEPAD32_INPUT_BACKEND_SLOT_COUNT);
static_assert(SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD);
static bool g_last_ready[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
static ControllerState
g_user_states[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
@ -80,7 +85,11 @@ static void on_rumble_from_switch(uint8_t instance,
if (instance >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT) {
return;
}
bluepad32_input_backend_queue_rumble(instance, rumble);
Bluepad32SlotSnapshot snapshot{};
bluepad32_input_backend_snapshot(instance, &snapshot);
bluepad32_input_backend_queue_rumble(
instance, controller_profile_runtime_scale_host_rumble(
instance, snapshot, rumble));
#else
if (instance != SWITCH_HID_INSTANCE) {
return;
@ -217,6 +226,7 @@ int main() {
#ifdef SWITCH_PICO_BLUEPAD32
bluepad32_input_backend_init();
controller_profile_runtime_reset();
#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY
adapter_host_probe_init();
#endif
@ -241,13 +251,19 @@ int main() {
switch_pro_set_rumble_callback(instance,
on_rumble_from_switch);
g_user_states[instance] = neutral_input();
switch_pro_set_input(instance, g_user_states[instance]);
switch_pro_set_input(
instance, g_user_states[instance],
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD,
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD);
}
#else
switch_pro_init(instance);
switch_pro_set_rumble_callback(instance, on_rumble_from_switch);
g_user_states[instance] = neutral_input();
switch_pro_set_input(instance, g_user_states[instance]);
switch_pro_set_input(
instance, g_user_states[instance],
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD,
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD);
#endif
}
#else
@ -255,7 +271,9 @@ int main() {
switch_pro_set_rumble_callback(SWITCH_HID_INSTANCE,
on_rumble_from_switch);
g_user_state = neutral_input();
switch_pro_set_input(SWITCH_HID_INSTANCE, g_user_state);
switch_pro_set_input(SWITCH_HID_INSTANCE, g_user_state,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
#endif
#ifdef SWITCH_PICO_BLUEPAD32
@ -292,8 +310,9 @@ int main() {
instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) {
Bluepad32SlotSnapshot snapshot{};
bluepad32_input_backend_snapshot(instance, &snapshot);
g_user_states[instance] =
snapshot.active ? snapshot.state : controller_neutral_state();
const ControllerProfileTransformResult transformed =
controller_profile_runtime_transform(instance, snapshot);
g_user_states[instance] = transformed.state;
#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY
bool sent = false;
if (adapter_host_probe_mode() == AdapterUsbMode::kXInput) {
@ -301,14 +320,20 @@ int main() {
g_user_states[instance]);
sent = xinput_feasibility_task(instance);
} else {
switch_pro_set_input(instance, g_user_states[instance]);
switch_pro_set_input(
instance, g_user_states[instance],
transformed.left_trigger_digital_threshold,
transformed.right_trigger_digital_threshold);
sent = switch_pro_task(instance);
}
if (sent) {
bluepad32_input_backend_report_sent(instance);
}
#else
switch_pro_set_input(instance, g_user_states[instance]);
switch_pro_set_input(
instance, g_user_states[instance],
transformed.left_trigger_digital_threshold,
transformed.right_trigger_digital_threshold);
if (switch_pro_task(instance)) {
bluepad32_input_backend_report_sent(instance);
}
@ -318,7 +343,9 @@ int main() {
bool new_data = poll_uart_frames(); // Pull controller state from UART1
(void)new_data;
ControllerState state = g_user_state;
switch_pro_set_input(SWITCH_HID_INSTANCE, state);
switch_pro_set_input(SWITCH_HID_INSTANCE, state,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
(void)switch_pro_task(SWITCH_HID_INSTANCE);
#endif
log_usb_state();

View file

@ -43,6 +43,10 @@ struct MotionQuaternion {
struct SwitchProContext {
ControllerState input_state{};
uint16_t left_trigger_threshold =
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD;
uint16_t right_trigger_threshold =
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD;
uint8_t report_buffer[SWITCH_PRO_ENDPOINT_SIZE]{};
SwitchProReport switch_report{};
uint8_t last_report_counter = 0;
@ -420,6 +424,8 @@ static ControllerState make_neutral_state() {
static void reset_context_runtime(SwitchProContext& context, uint32_t now,
bool ready_before_mount) {
context.input_state = make_neutral_state();
context.left_trigger_threshold = SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD;
context.right_trigger_threshold = SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD;
memset(context.report_buffer, 0x00, sizeof(context.report_buffer));
context.switch_report = {};
context.switch_report.reportID = 0x30;
@ -747,7 +753,7 @@ static void update_switch_report_from_state(SwitchProContext& context) {
inputs.buttonRightSL = 0;
inputs.buttonR = state.button_right_shoulder;
inputs.buttonZR =
state.right_trigger >= SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD;
state.right_trigger >= context.right_trigger_threshold;
inputs.buttonMinus = state.button_select;
inputs.buttonPlus = state.button_start;
inputs.buttonThumbR = state.button_right_stick;
@ -758,7 +764,7 @@ static void update_switch_report_from_state(SwitchProContext& context) {
inputs.buttonLeftSL = 0;
inputs.buttonL = state.button_left_shoulder;
inputs.buttonZL =
state.left_trigger >= SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD;
state.left_trigger >= context.left_trigger_threshold;
uint16_t left_x =
scale16To12(controller_axis_to_unsigned(state.left_stick_x));
@ -813,10 +819,14 @@ void switch_pro_init(uint8_t instance) {
to_ms_since_boot(get_absolute_time()), true);
}
void switch_pro_set_input(uint8_t instance, const ControllerState& state) {
void switch_pro_set_input(uint8_t instance, const ControllerState& state,
uint16_t left_trigger_threshold,
uint16_t right_trigger_threshold) {
SwitchProContext* context = context_for(instance);
if (context != nullptr) {
context->input_state = state;
context->left_trigger_threshold = left_trigger_threshold;
context->right_trigger_threshold = right_trigger_threshold;
}
}

View file

@ -27,8 +27,11 @@ constexpr uint16_t SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD =
// Initialize one HID instance before entering the main loop.
void switch_pro_init(uint8_t instance);
// Update the desired controller state for one HID instance.
void switch_pro_set_input(uint8_t instance, const ControllerState& state);
// Update the desired controller state and digital trigger thresholds for one
// HID instance.
void switch_pro_set_input(uint8_t instance, const ControllerState& state,
uint16_t left_trigger_threshold,
uint16_t right_trigger_threshold);
// Drive one Switch Pro USB state machine; returns true only when a regular
// 0x30 input report was successfully queued.

View file

@ -0,0 +1,63 @@
#pragma once
#include <stdint.h>
#include "controller_profile.h"
constexpr uint8_t kLegacyDefaultProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = {
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
0x0c, 0x0d, 0x0e, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00,
0xff, 0xff, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0xff, 0xff,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
};
constexpr uint8_t
kLegacyNarrowRawRangeProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = {
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
0x0c, 0x0d, 0x0e, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x30, 0x75, 0x40, 0x9c, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x30, 0x75,
0x40, 0x9c, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0xff, 0xff,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
};
constexpr uint8_t kLegacyCustomThresholdProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = {
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
0x0c, 0x0d, 0x0e, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x01, 0x34, 0x12, 0x00, 0x00, 0x00, 0x00,
0xff, 0xff, 0x00, 0x01, 0xcd, 0xab, 0x00, 0x00, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0xff, 0xff,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
};

View file

@ -0,0 +1,273 @@
#include "controller_profile_runtime.h"
#include "controller_identity.h"
#include "controller_profile.h"
#include "profile_service.h"
#include <array>
#include <cstdlib>
#include <cstring>
#include <iostream>
namespace {
struct FakeProfileRow {
ControllerIdentity identity{};
uint8_t active_profile = 0;
ControllerProfile profiles[CONTROLLER_PROFILE_COUNT]{};
};
std::array<FakeProfileRow, CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT> rows{};
uint32_t database_generation = 7;
unsigned active_snapshot_count = 0;
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
std::exit(1);
}
}
ControllerIdentity make_identity(uint8_t value) {
ControllerIdentity identity{};
identity.stable = true;
identity.transport = ControllerTransport::kClassic;
identity.address[0] = value;
identity.address[5] = static_cast<uint8_t>(value + 0x40u);
identity.vendor_id = static_cast<uint16_t>(0x1000u + value);
identity.product_id = static_cast<uint16_t>(0x2000u + value);
return identity;
}
void prepare_profiles() {
database_generation = 7;
active_snapshot_count = 0;
for (uint8_t slot = 0;
slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) {
FakeProfileRow& row = rows[slot];
row = {};
row.identity = make_identity(static_cast<uint8_t>(slot + 1u));
for (uint8_t profile_index = 0;
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) {
row.profiles[profile_index] =
controller_profile_default(row.identity, profile_index);
}
row.profiles[0].triggers[0].digital_threshold =
static_cast<uint16_t>(1000u + slot);
row.profiles[0].triggers[1].digital_threshold =
static_cast<uint16_t>(5000u + slot);
row.profiles[0].confirmation_policy =
slot == 0 ? ControllerProfileConfirmationPolicy::kLed
: ControllerProfileConfirmationPolicy::kRumble;
}
rows[0].profiles[0].strong_rumble_scale = 0;
rows[0].profiles[0].weak_rumble_scale = UINT8_MAX;
rows[1].profiles[0].strong_rumble_scale = UINT8_MAX;
rows[1].profiles[0].weak_rumble_scale = 0;
controller_profile_runtime_reset();
}
Bluepad32SlotSnapshot make_snapshot(uint8_t slot,
uint32_t connection_generation = 1) {
Bluepad32SlotSnapshot snapshot{};
snapshot.active = true;
snapshot.connection_generation = connection_generation;
snapshot.identity = rows[slot].identity;
snapshot.state = controller_neutral_state();
return snapshot;
}
bool motion_equal(const ControllerState& first,
const ControllerState& second) {
return first.motion_sample_count == second.motion_sample_count &&
std::memcmp(first.motion_samples, second.motion_samples,
sizeof(first.motion_samples)) == 0;
}
void test_four_slot_cache_and_unchanged_generation() {
prepare_profiles();
std::array<Bluepad32SlotSnapshot,
CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT>
snapshots{};
std::array<ControllerProfileTransformResult,
CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT>
transformed{};
for (uint8_t slot = 0;
slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) {
snapshots[slot] = make_snapshot(slot);
transformed[slot] =
controller_profile_runtime_transform(slot, snapshots[slot]);
require(transformed[slot].left_trigger_digital_threshold ==
static_cast<uint16_t>(1000u + slot) &&
transformed[slot].right_trigger_digital_threshold ==
static_cast<uint16_t>(5000u + slot),
"a slot did not receive its own cached profile");
}
require(active_snapshot_count == CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT,
"initial slot loads did not fetch exactly one profile each");
rows[0].profiles[0].triggers[0].digital_threshold = 65000;
for (uint8_t slot = 0;
slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) {
transformed[slot] =
controller_profile_runtime_transform(slot, snapshots[slot]);
}
require(active_snapshot_count == CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT,
"unchanged generations copied profiles on the report path");
require(transformed[0].left_trigger_digital_threshold == 1000,
"an unchanged generation bypassed the slot cache");
snapshots[2].connection_generation = 2;
transformed[2] =
controller_profile_runtime_transform(2, snapshots[2]);
require(active_snapshot_count ==
CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT + 1 &&
transformed[2].left_trigger_digital_threshold == 1002,
"one slot connection generation did not refresh in isolation");
snapshots[3].identity = rows[1].identity;
transformed[3] =
controller_profile_runtime_transform(3, snapshots[3]);
require(active_snapshot_count ==
CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT + 2 &&
transformed[3].left_trigger_digital_threshold == 1001,
"an exact identity change did not refresh only its slot");
}
void test_activation_disconnect_and_default_preservation() {
prepare_profiles();
Bluepad32SlotSnapshot snapshot = make_snapshot(0);
snapshot.state.button_south = true;
ControllerProfileTransformResult transformed =
controller_profile_runtime_transform(0, snapshot);
require(transformed.state.button_south &&
transformed.left_trigger_digital_threshold == 1000,
"initial active profile was not applied");
rows[0].active_profile = 1;
rows[0].profiles[1].button_map[
static_cast<uint8_t>(ControllerProfileLogicalButton::kSouth)] =
static_cast<uint8_t>(ControllerProfileLogicalButton::kNorth);
rows[0].profiles[1].triggers[0].digital_threshold = 12345;
++database_generation;
transformed = controller_profile_runtime_transform(0, snapshot);
require(!transformed.state.button_south && transformed.state.button_north &&
transformed.left_trigger_digital_threshold == 12345,
"profile activation generation did not refresh the slot cache");
const unsigned reads_before_disconnect = active_snapshot_count;
snapshot.active = false;
transformed = controller_profile_runtime_transform(0, snapshot);
require(!transformed.state.button_south && !transformed.state.button_north &&
transformed.left_trigger_digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD &&
active_snapshot_count == reads_before_disconnect,
"disconnect did not neutralize output without fetching a profile");
transformed = controller_profile_runtime_transform(0, snapshot);
require(active_snapshot_count == reads_before_disconnect,
"an unchanged inactive slot repeatedly cleared or fetched state");
snapshot.active = true;
transformed = controller_profile_runtime_transform(0, snapshot);
require(active_snapshot_count == reads_before_disconnect + 1 &&
transformed.state.button_north,
"reconnection did not reload a cleared slot cache");
Bluepad32SlotSnapshot default_snapshot = make_snapshot(2);
rows[2].profiles[0] =
controller_profile_default(rows[2].identity, 0);
++database_generation;
default_snapshot.state.button_east = true;
default_snapshot.state.dpad_left = true;
default_snapshot.state.left_trigger = 32123;
default_snapshot.state.right_trigger = 54321;
default_snapshot.state.left_stick_x = -12345;
default_snapshot.state.right_stick_y = 23456;
default_snapshot.state.motion_sample_count = 2;
default_snapshot.state.motion_samples[0] = {1, 2, 3, 4, 5, 6};
default_snapshot.state.motion_samples[1] = {-1, -2, -3, -4, -5, -6};
transformed = controller_profile_runtime_transform(2, default_snapshot);
require(transformed.state.button_east && transformed.state.dpad_left &&
transformed.state.left_trigger == 32123 &&
transformed.state.right_trigger == 54321 &&
transformed.state.left_stick_x == -12345 &&
transformed.state.right_stick_y == 23456 &&
motion_equal(transformed.state, default_snapshot.state) &&
transformed.left_trigger_digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD &&
transformed.right_trigger_digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD,
"default runtime profile changed serializer input or motion");
}
void test_analog_thresholds_rumble_and_local_confirmation() {
prepare_profiles();
Bluepad32SlotSnapshot first = make_snapshot(0);
Bluepad32SlotSnapshot second = make_snapshot(1);
first.state.left_trigger = second.state.left_trigger = 23456;
first.state.right_trigger = second.state.right_trigger = 45678;
const ControllerProfileTransformResult first_output =
controller_profile_runtime_transform(0, first);
const ControllerProfileTransformResult second_output =
controller_profile_runtime_transform(1, second);
require(first_output.state.left_trigger ==
second_output.state.left_trigger &&
first_output.state.right_trigger ==
second_output.state.right_trigger &&
first_output.left_trigger_digital_threshold !=
second_output.left_trigger_digital_threshold,
"digital thresholds changed XInput-visible analog trigger state");
const ControllerRumbleOutput host{91, 73};
const ControllerRumbleOutput first_host =
controller_profile_runtime_scale_host_rumble(0, first, host);
const ControllerRumbleOutput second_host =
controller_profile_runtime_scale_host_rumble(1, second, host);
require(first_host.low_frequency_magnitude == 0 &&
first_host.high_frequency_magnitude == 73 &&
second_host.low_frequency_magnitude == 91 &&
second_host.high_frequency_magnitude == 0,
"host rumble was not scaled through each slot profile");
const ControllerProfileRuntimeLocalConfirmation confirmation =
controller_profile_runtime_local_confirmation(0, first, host);
require(confirmation.rumble.low_frequency_magnitude == 91 &&
confirmation.rumble.high_frequency_magnitude == 73 &&
confirmation.policy ==
ControllerProfileConfirmationPolicy::kLed,
"local confirmation was scaled or lost its profile policy");
}
} // namespace
uint32_t profile_service_database_generation() {
return database_generation;
}
void profile_service_active_profile_snapshot(
const ControllerIdentity& identity,
ProfileServiceActiveProfileSnapshot* output) {
if (output == nullptr) {
return;
}
++active_snapshot_count;
*output = {};
output->metadata.state = ProfileServiceState::kReady;
output->metadata.generation = database_generation;
for (const FakeProfileRow& row : rows) {
if (!controller_identity_equal(row.identity, identity)) {
continue;
}
output->valid = true;
output->profile_index = row.active_profile;
output->profile = row.profiles[row.active_profile];
return;
}
}
int main() {
test_four_slot_cache_and_unchanged_generation();
test_activation_disconnect_and_default_preservation();
test_analog_thresholds_rumble_and_local_confirmation();
return 0;
}

View file

@ -1,5 +1,6 @@
#include "controller_identity.h"
#include "controller_profile.h"
#include "tests/controller_profile_legacy_fixtures.h"
#include <cstdlib>
#include <cstring>
@ -44,9 +45,9 @@ void test_profile_wire_schema() {
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
"default profile did not encode");
require(encoded[0] == 1 && encoded[1] == 0 &&
require(encoded[0] == 2 && encoded[1] == 0 &&
encoded[2] == 0 && encoded[3] == 1,
"profile header is not little-endian v1/256");
"profile header is not little-endian v2/256");
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
require(encoded[4 + index] == index,
@ -56,7 +57,12 @@ void test_profile_wire_schema() {
encoded[30] == 0,
"default stick encoding changed");
require(encoded[54] == 0xff && encoded[55] == 0xff &&
encoded[58] == 0x00 && encoded[59] == 0x80,
encoded[58] ==
static_cast<uint8_t>(
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) &&
encoded[59] ==
static_cast<uint8_t>(
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8),
"default trigger encoding changed");
require(encoded[72] == 0xff && encoded[73] == 0xff &&
encoded[74] == 3 && encoded[78] == 0xff &&
@ -84,11 +90,35 @@ void test_profile_wire_schema() {
invalid.sticks[0].outer_saturation;
require(!controller_profile_validate(invalid),
"empty stick range was accepted");
ControllerProfile boundary = profile;
boundary.triggers[0].lower_deadzone = 30000;
boundary.triggers[0].upper_saturation = 40000;
boundary.triggers[0].digital_threshold = 0;
uint8_t boundary_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(boundary, boundary_encoded,
sizeof(boundary_encoded)) &&
controller_profile_decode(boundary_encoded,
sizeof(boundary_encoded),
&decoded) &&
decoded.triggers[0].digital_threshold == 0,
"current profile rejected zero transformed trigger threshold");
boundary.triggers[0].digital_threshold = UINT16_MAX;
require(controller_profile_encode(boundary, boundary_encoded,
sizeof(boundary_encoded)) &&
controller_profile_decode(boundary_encoded,
sizeof(boundary_encoded),
&decoded) &&
decoded.triggers[0].digital_threshold == UINT16_MAX,
"current profile rejected maximum transformed trigger threshold");
invalid = profile;
invalid.triggers[0].digital_threshold = 0;
invalid.triggers[0].lower_deadzone = 1;
invalid.triggers[0].lower_deadzone =
invalid.triggers[0].upper_saturation;
require(!controller_profile_validate(invalid),
"trigger threshold outside its range was accepted");
"empty raw trigger range was accepted");
invalid.triggers[0].lower_deadzone = UINT16_MAX;
invalid.triggers[0].upper_saturation = UINT16_MAX - 1;
require(!controller_profile_validate(invalid),
"reversed raw trigger range was accepted");
invalid = profile;
invalid.turbo_modes[0] =
static_cast<ControllerProfileTurboMode>(3);
@ -110,6 +140,97 @@ void test_profile_wire_schema() {
"macro without a final end was accepted");
}
void test_legacy_profile_migration() {
ControllerProfile migrated{};
require(controller_profile_decode(
kLegacyDefaultProfile, sizeof(kLegacyDefaultProfile),
&migrated),
"legacy default profile did not decode");
require(migrated.triggers[0].digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD &&
migrated.triggers[1].digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD,
"legacy inherited thresholds were not migrated");
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
"migrated default profile did not encode");
for (size_t index = 0; index < sizeof(encoded); ++index) {
const bool schema_byte = index == 0;
const bool threshold_byte =
(index >= 58 && index < 60) ||
(index >= 68 && index < 70);
if (!schema_byte && !threshold_byte) {
require(encoded[index] == kLegacyDefaultProfile[index],
"legacy default profile changed an unrelated byte");
}
}
require(encoded[0] == CONTROLLER_PROFILE_SCHEMA_VERSION &&
encoded[58] ==
static_cast<uint8_t>(
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) &&
encoded[59] ==
static_cast<uint8_t>(
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8) &&
encoded[68] ==
static_cast<uint8_t>(
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) &&
encoded[69] ==
static_cast<uint8_t>(
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8),
"migrated default profile did not encode as v2");
require(controller_profile_decode(
kLegacyNarrowRawRangeProfile,
sizeof(kLegacyNarrowRawRangeProfile), &migrated),
"legacy narrow-raw-range profile did not decode");
require(migrated.triggers[0].lower_deadzone == 30000 &&
migrated.triggers[0].upper_saturation == 40000 &&
migrated.triggers[0].digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD &&
migrated.triggers[1].lower_deadzone == 30000 &&
migrated.triggers[1].upper_saturation == 40000 &&
migrated.triggers[1].digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD,
"legacy narrow raw range or inherited threshold was not migrated");
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
"migrated narrow-raw-range profile did not encode");
for (size_t index = 0; index < sizeof(encoded); ++index) {
const bool schema_byte = index == 0;
const bool threshold_byte =
(index >= 58 && index < 60) ||
(index >= 68 && index < 70);
if (!schema_byte && !threshold_byte) {
require(
encoded[index] == kLegacyNarrowRawRangeProfile[index],
"narrow-raw-range migration changed unrelated profile data");
}
}
require(controller_profile_decode(
kLegacyCustomThresholdProfile,
sizeof(kLegacyCustomThresholdProfile), &migrated),
"legacy custom-threshold profile did not decode");
require(migrated.triggers[0].digital_threshold == 0x1234 &&
migrated.triggers[1].digital_threshold == 0xabcd,
"legacy custom thresholds were not preserved");
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
"legacy custom-threshold profile did not re-encode");
for (size_t index = 1; index < sizeof(encoded); ++index) {
require(encoded[index] == kLegacyCustomThresholdProfile[index],
"legacy custom-threshold profile changed data");
}
ControllerProfile current =
controller_profile_default(controller_identity_global(), 0);
current.triggers[0].digital_threshold = 0x8000;
require(controller_profile_encode(current, encoded, sizeof(encoded)) &&
controller_profile_decode(encoded, sizeof(encoded),
&migrated) &&
migrated.triggers[0].digital_threshold == 0x8000,
"v2 custom threshold matching the legacy default was migrated");
}
void test_database_round_trip_and_capacity() {
controller_profile_database_default(&database);
for (uint8_t index = 0;
@ -141,6 +262,10 @@ void test_database_round_trip_and_capacity() {
database, offset, &encoded_database[offset], size),
"database range did not encode");
}
require(encoded_database[4] ==
CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION &&
encoded_database[5] == 0,
"database encoder did not emit v2");
require(controller_profile_database_decode(
read_encoded_database, nullptr, &decoded_database),
"database did not decode");
@ -157,6 +282,7 @@ void test_database_round_trip_and_capacity() {
} // namespace
int main() {
test_profile_wire_schema();
test_legacy_profile_migration();
test_database_round_trip_and_capacity();
return 0;
}

View file

@ -0,0 +1,385 @@
#include "controller_identity.h"
#include "controller_profile.h"
#include "controller_profile_transform.h"
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <limits.h>
namespace {
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
std::exit(1);
}
}
ControllerProfile default_profile() {
return controller_profile_default(controller_identity_global(), 0);
}
bool states_equal(const ControllerState& left, const ControllerState& right) {
return left.dpad_up == right.dpad_up &&
left.dpad_down == right.dpad_down &&
left.dpad_left == right.dpad_left &&
left.dpad_right == right.dpad_right &&
left.button_south == right.button_south &&
left.button_east == right.button_east &&
left.button_west == right.button_west &&
left.button_north == right.button_north &&
left.button_left_shoulder == right.button_left_shoulder &&
left.button_right_shoulder == right.button_right_shoulder &&
left.button_select == right.button_select &&
left.button_start == right.button_start &&
left.button_system == right.button_system &&
left.button_capture == right.button_capture &&
left.button_left_stick == right.button_left_stick &&
left.button_right_stick == right.button_right_stick &&
left.left_trigger == right.left_trigger &&
left.right_trigger == right.right_trigger &&
left.left_stick_x == right.left_stick_x &&
left.left_stick_y == right.left_stick_y &&
left.right_stick_x == right.right_stick_x &&
left.right_stick_y == right.right_stick_y &&
left.motion_sample_count == right.motion_sample_count &&
std::memcmp(left.motion_samples, right.motion_samples,
sizeof(left.motion_samples)) == 0;
}
ControllerProfileTransformResult transform_left_stick(
const ControllerProfileStickConfiguration& configuration, int16_t x,
int16_t y) {
ControllerProfile profile = default_profile();
profile.sticks[0] = configuration;
ControllerState state{};
state.left_stick_x = x;
state.left_stick_y = y;
return controller_profile_transform(state, profile);
}
uint16_t transform_left_trigger(
const ControllerProfileTriggerConfiguration& configuration,
uint16_t value) {
ControllerProfile profile = default_profile();
profile.triggers[0] = configuration;
ControllerState state{};
state.left_trigger = value;
return controller_profile_transform(state, profile).state.left_trigger;
}
void test_button_masks_and_direct_mapping() {
ControllerState state{};
controller_profile_apply_button_mask(UINT16_MAX, &state);
require(controller_profile_extract_button_mask(state) == UINT16_MAX,
"button mask application omitted a logical button");
controller_profile_apply_button_mask(0, &state);
require(controller_profile_extract_button_mask(state) == 0,
"zero button mask did not clear every logical button");
controller_profile_apply_button_mask(UINT16_MAX, nullptr);
for (uint8_t input = 0;
input < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++input) {
ControllerProfile profile = default_profile();
const uint8_t output = static_cast<uint8_t>(
CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT - 1u - input);
profile.button_map[input] = output;
state = {};
controller_profile_apply_button_mask(
static_cast<uint16_t>(1u << input), &state);
const ControllerProfileTransformResult transformed =
controller_profile_transform(state, profile);
require(controller_profile_extract_button_mask(transformed.state) ==
static_cast<uint16_t>(1u << output),
"a logical button did not map directly to its output");
}
ControllerProfile profile = default_profile();
profile.button_map[0] = 1;
profile.button_map[1] = 2;
require(controller_profile_map_button_mask(1u, profile) == 2u,
"button mapping recursively remapped an output");
profile.button_map[1] = 1;
require(controller_profile_map_button_mask(3u, profile) == 2u,
"duplicate mapped outputs were not combined");
profile.button_map[0] = CONTROLLER_PROFILE_NO_BUTTON;
require(controller_profile_map_button_mask(1u, profile) == 0,
"disabled button mapping still produced output");
ControllerProfile invalid = default_profile();
invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT;
require(!controller_profile_validate(invalid),
"logical output 16 was accepted");
invalid.button_map[0] = 0xfe;
require(!controller_profile_validate(invalid),
"logical output 0xfe was accepted");
invalid.button_map[0] = CONTROLLER_PROFILE_NO_BUTTON;
require(controller_profile_validate(invalid),
"disabled logical output 0xff was rejected");
}
void test_stick_center_boundaries_and_inversion() {
ControllerProfileStickConfiguration configuration{};
configuration.center_x = 1234;
configuration.center_y = -2345;
configuration.inner_deadzone = 1000;
configuration.outer_saturation = 20000;
configuration.curve_q8_8 = 256;
ControllerProfileTransformResult transformed = transform_left_stick(
configuration, configuration.center_x, configuration.center_y);
require(transformed.state.left_stick_x == 0 &&
transformed.state.left_stick_y == 0,
"center calibration did not precede stick shaping");
transformed = transform_left_stick(
configuration,
static_cast<int16_t>(configuration.center_x +
configuration.inner_deadzone),
configuration.center_y);
require(transformed.state.left_stick_x == 0 &&
transformed.state.left_stick_y == 0,
"inner deadzone boundary was not neutral");
transformed = transform_left_stick(
configuration,
static_cast<int16_t>(configuration.center_x +
configuration.inner_deadzone + 1),
configuration.center_y);
require(transformed.state.left_stick_x > 0,
"first value outside inner deadzone stayed neutral");
configuration.center_x = 0;
configuration.center_y = 0;
configuration.inner_deadzone = 0;
configuration.outer_saturation = 20000;
transformed = transform_left_stick(configuration, 19999, 0);
require(transformed.state.left_stick_x > 0 &&
transformed.state.left_stick_x < INT16_MAX,
"value below outer saturation reached an endpoint");
transformed = transform_left_stick(configuration, 20000, 0);
require(transformed.state.left_stick_x == INT16_MAX,
"positive outer saturation boundary missed endpoint");
transformed = transform_left_stick(configuration, -20000, 0);
require(transformed.state.left_stick_x == INT16_MIN,
"negative outer saturation boundary missed endpoint");
configuration.outer_saturation = 32767;
configuration.invert_x = true;
configuration.invert_y = true;
transformed = transform_left_stick(configuration, INT16_MIN, 0);
require(transformed.state.left_stick_x == INT16_MAX,
"negative stick endpoint did not invert to positive endpoint");
transformed = transform_left_stick(configuration, 0, INT16_MAX);
require(transformed.state.left_stick_y == INT16_MIN,
"positive stick endpoint did not invert to negative endpoint");
configuration.invert_x = false;
configuration.invert_y = false;
configuration.outer_saturation = 20000;
transformed = transform_left_stick(configuration, 15000, 15000);
require(transformed.state.left_stick_x < INT16_MAX &&
transformed.state.left_stick_y < INT16_MAX,
"stick magnitude was not Chebyshev magnitude");
}
void test_stick_curves_and_monotonicity() {
ControllerProfileStickConfiguration linear{};
linear.inner_deadzone = 0;
linear.outer_saturation = 32767;
linear.curve_q8_8 = 256;
ControllerProfileStickConfiguration slow = linear;
slow.curve_q8_8 = 512;
ControllerProfileStickConfiguration fast = linear;
fast.curve_q8_8 = 128;
const int16_t linear_mid =
transform_left_stick(linear, 16384, 0).state.left_stick_x;
const int16_t slow_mid =
transform_left_stick(slow, 16384, 0).state.left_stick_x;
const int16_t fast_mid =
transform_left_stick(fast, 16384, 0).state.left_stick_x;
require(slow_mid < linear_mid && linear_mid < fast_mid,
"stick curve directions are reversed or ineffective");
require(transform_left_stick(slow, 0, 0).state.left_stick_x == 0 &&
transform_left_stick(fast, 32767, 0)
.state.left_stick_x == INT16_MAX,
"stick response curve did not preserve endpoints");
const uint16_t curves[] = {1, 128, 256, 512, UINT16_MAX};
for (uint16_t curve : curves) {
ControllerProfileStickConfiguration configuration = linear;
configuration.curve_q8_8 = curve;
int16_t previous = 0;
for (int32_t input = 0; input <= INT16_MAX; ++input) {
const int16_t output = transform_left_stick(
configuration,
static_cast<int16_t>(input), 0)
.state.left_stick_x;
require(output >= previous,
"stick response was not monotonic");
previous = output;
}
require(previous == INT16_MAX,
"monotonic stick response missed positive endpoint");
}
}
void test_trigger_boundaries_curves_and_thresholds() {
ControllerProfileTriggerConfiguration configuration{};
configuration.lower_deadzone = 1000;
configuration.upper_saturation = 60000;
configuration.curve_q8_8 = 256;
configuration.digital_threshold = 32000;
require(transform_left_trigger(configuration, 999) == 0 &&
transform_left_trigger(configuration, 1000) == 0,
"trigger lower deadzone boundary was not zero");
require(transform_left_trigger(configuration, 1001) > 0,
"first trigger value above lower deadzone stayed zero");
require(transform_left_trigger(configuration, 59999) < UINT16_MAX &&
transform_left_trigger(configuration, 60000) == UINT16_MAX &&
transform_left_trigger(configuration, UINT16_MAX) ==
UINT16_MAX,
"trigger upper saturation boundary missed full scale");
ControllerProfileTriggerConfiguration slow = configuration;
slow.curve_q8_8 = 512;
ControllerProfileTriggerConfiguration fast = configuration;
fast.curve_q8_8 = 128;
const uint16_t midpoint = static_cast<uint16_t>(
(static_cast<uint32_t>(configuration.lower_deadzone) +
configuration.upper_saturation) /
2u);
const uint16_t linear_mid =
transform_left_trigger(configuration, midpoint);
const uint16_t slow_mid = transform_left_trigger(slow, midpoint);
const uint16_t fast_mid = transform_left_trigger(fast, midpoint);
require(slow_mid < linear_mid && linear_mid < fast_mid,
"trigger curve directions are reversed or ineffective");
require(transform_left_trigger(slow, configuration.lower_deadzone) == 0 &&
transform_left_trigger(fast,
configuration.upper_saturation) ==
UINT16_MAX,
"trigger response curve did not preserve endpoints");
uint16_t previous = 0;
for (uint32_t input = 0; input <= UINT16_MAX; ++input) {
const uint16_t output = transform_left_trigger(
slow, static_cast<uint16_t>(input));
require(output >= previous,
"trigger response was not monotonic");
previous = output;
}
ControllerProfile profile = default_profile();
profile.triggers[0] = slow;
profile.triggers[0].digital_threshold = 12345;
profile.triggers[1].digital_threshold = 54321;
ControllerState state{};
state.left_trigger = midpoint;
const ControllerProfileTransformResult transformed =
controller_profile_transform(state, profile);
require(transformed.left_trigger_digital_threshold == 12345 &&
transformed.right_trigger_digital_threshold == 54321,
"profile-owned digital thresholds were not returned");
}
void test_default_whole_state_equivalence() {
ControllerState input{};
input.dpad_up = true;
input.dpad_right = true;
input.button_south = true;
input.button_west = true;
input.button_left_shoulder = true;
input.button_select = true;
input.button_system = true;
input.button_capture = true;
input.button_right_stick = true;
input.left_trigger = 0;
input.right_trigger = UINT16_MAX;
input.left_stick_x = INT16_MIN;
input.left_stick_y = INT16_MAX;
input.right_stick_x = INT16_MAX;
input.right_stick_y = INT16_MIN;
input.motion_sample_count = CONTROLLER_MOTION_SAMPLE_CAPACITY;
input.motion_samples[0] =
{INT16_MIN, -30000, -1, 0, 1, INT16_MAX};
input.motion_samples[1] = {1, 2, 3, 4, 5, 6};
input.motion_samples[2] =
{INT16_MAX, 30000, 1, 0, -1, INT16_MIN};
const ControllerProfile profile = default_profile();
const ControllerProfileTransformResult transformed =
controller_profile_transform(input, profile);
require(states_equal(input, transformed.state),
"default profile changed whole controller state or motion");
require(transformed.left_trigger_digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD &&
transformed.right_trigger_digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD &&
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD == 22934,
"default digital threshold changed");
for (uint32_t trigger = 0; trigger <= UINT16_MAX; ++trigger) {
ControllerState trigger_state{};
trigger_state.left_trigger = static_cast<uint16_t>(trigger);
trigger_state.right_trigger = static_cast<uint16_t>(trigger);
const ControllerState output =
controller_profile_transform(trigger_state, profile).state;
require(output.left_trigger == trigger_state.left_trigger &&
output.right_trigger == trigger_state.right_trigger,
"default profile lost full trigger analog precision");
}
}
void test_rumble_scaling_and_confirmation_policy() {
ControllerProfile profile = default_profile();
for (uint16_t magnitude = 0; magnitude <= UINT8_MAX; ++magnitude) {
const ControllerRumbleOutput input{
static_cast<uint8_t>(magnitude),
static_cast<uint8_t>(UINT8_MAX - magnitude),
};
const ControllerRumbleOutput output =
controller_profile_scale_host_rumble(input, profile);
require(output.low_frequency_magnitude ==
input.low_frequency_magnitude &&
output.high_frequency_magnitude ==
input.high_frequency_magnitude,
"default rumble scaling was not bit-exact identity");
}
profile.strong_rumble_scale = 0;
profile.weak_rumble_scale = 0;
ControllerRumbleOutput output = controller_profile_scale_host_rumble(
{UINT8_MAX, UINT8_MAX}, profile);
require(output.low_frequency_magnitude == 0 &&
output.high_frequency_magnitude == 0,
"zero rumble scales did not mute both bands");
profile.strong_rumble_scale = 128;
profile.weak_rumble_scale = 64;
output = controller_profile_scale_host_rumble({200, 201}, profile);
require(output.low_frequency_magnitude == 100 &&
output.high_frequency_magnitude == 50,
"strong/weak mid-scale rumble mapping was incorrect");
require(controller_profile_scale_rumble_magnitude(UINT8_MAX,
UINT8_MAX) ==
UINT8_MAX,
"full rumble scaling did not saturate at uint8 maximum");
profile.confirmation_policy = ControllerProfileConfirmationPolicy::kLed;
require(controller_profile_confirmation_policy(profile) ==
ControllerProfileConfirmationPolicy::kLed,
"confirmation policy was not exposed unchanged");
}
} // namespace
int main() {
test_button_masks_and_direct_mapping();
test_stick_center_boundaries_and_inversion();
test_stick_curves_and_monotonicity();
test_trigger_boundaries_curves_and_thresholds();
test_default_whole_state_equivalence();
test_rumble_scaling_and_confirmation_policy();
return 0;
}

View file

@ -82,6 +82,13 @@ ProfileServiceTransactionSnapshot transaction_snapshot() {
return snapshot;
}
ProfileServiceActiveProfileSnapshot active_profile_snapshot(
const ControllerIdentity& identity) {
ProfileServiceActiveProfileSnapshot snapshot{};
profile_service_active_profile_snapshot(identity, &snapshot);
return snapshot;
}
ControllerProfileDatabase reload_database(
const ProfileServiceTransactionSnapshot& transaction,
uint32_t expected_generation) {
@ -102,6 +109,14 @@ void test_pending_commands_are_not_decoded_as_profile_writes() {
memset(flash.bytes, 0xff, sizeof(flash.bytes));
profile_service_prepare();
profile_service_initialize_on_storage_core();
ProfileServiceActiveProfileSnapshot active =
active_profile_snapshot(controller_identity_global());
require(active.valid &&
active.metadata.state == ProfileServiceState::kReady &&
active.metadata.generation == 0 &&
profile_service_database_generation() == 0 &&
active.profile_index == 0,
"initial active profile snapshot was not coherent");
const ControllerIdentity identity = controller_identity_global();
constexpr uint8_t kProfileIndex = 2;
@ -128,6 +143,11 @@ void test_pending_commands_are_not_decoded_as_profile_writes() {
require(transaction_snapshot().transaction.status ==
ConfigurationTransactionStatus::kCommitted,
"profile write baseline did not commit");
active = active_profile_snapshot(identity);
require(active.valid && active.metadata.generation == 1 &&
profile_service_database_generation() == 1 &&
active.profile_index == 0,
"profile write did not publish one coherent generation");
constexpr uint32_t kResetTransactionId = 0xa5a55a5a;
require(profile_service_reset(kResetTransactionId, identity,
@ -150,6 +170,11 @@ void test_pending_commands_are_not_decoded_as_profile_writes() {
require(recovered.fallback_profiles[kProfileIndex].strong_rumble_scale ==
UINT8_MAX,
"terminal reset status was published before reset persisted");
active = active_profile_snapshot(identity);
require(active.valid && active.metadata.generation == 2 &&
profile_service_database_generation() == 2 &&
active.profile_index == 0,
"profile reset did not refresh the active snapshot generation");
constexpr uint32_t kActivateTransactionId = 0x50607080;
constexpr uint8_t kActivatedProfile = 3;
@ -162,6 +187,10 @@ void test_pending_commands_are_not_decoded_as_profile_writes() {
activate.transaction.status ==
ConfigurationTransactionStatus::kPending,
"pending activation lost its transaction identity");
active = active_profile_snapshot(identity);
require(active.valid && active.metadata.generation == 2 &&
active.profile_index == 0,
"pending activation leaked an uncommitted active profile");
profile_service_task_on_storage_core(2000);
activate = transaction_snapshot();
@ -172,6 +201,14 @@ void test_pending_commands_are_not_decoded_as_profile_writes() {
recovered = reload_database(activate, 3);
require(recovered.fallback_active_profile == kActivatedProfile,
"terminal activation status was published before activation persisted");
active = active_profile_snapshot(identity);
require(active.valid && active.metadata.generation == 3 &&
profile_service_database_generation() == 3 &&
active.profile_index == kActivatedProfile &&
active.profile.strong_rumble_scale ==
recovered.fallback_profiles[kActivatedProfile]
.strong_rumble_scale,
"activation did not publish profile, index, and generation together");
}
} // namespace

View file

@ -1,6 +1,7 @@
#include "controller_identity.h"
#include "controller_profile.h"
#include "profile_storage.h"
#include "tests/controller_profile_legacy_fixtures.h"
#include <cstdlib>
#include <cstring>
@ -15,6 +16,7 @@ struct FakeFlash {
bool corrupt_next_program = false;
bool fail_reads_after_header_program = false;
bool header_programmed = false;
int erase_count = 0;
};
FakeFlash flash{};
@ -35,6 +37,7 @@ void erase_all() {
flash.corrupt_next_program = false;
flash.fail_reads_after_header_program = false;
flash.header_programmed = false;
flash.erase_count = 0;
}
bool fake_read(void* context, uint8_t bank, size_t offset,
@ -62,6 +65,7 @@ bool fake_erase_sector(void* context, uint8_t bank, size_t offset) {
PROFILE_STORAGE_BANK_SIZE - offset) {
return false;
}
++storage->erase_count;
memset(&storage->bytes[bank][offset], 0xff,
PROFILE_STORAGE_SECTOR_SIZE);
return true;
@ -107,6 +111,91 @@ ProfileStorageIo fake_io() {
};
}
uint16_t fixture_read_u16(const uint8_t* input) {
return static_cast<uint16_t>(input[0]) |
static_cast<uint16_t>(input[1] << 8);
}
void fixture_write_u16(uint8_t* output, uint16_t value) {
output[0] = static_cast<uint8_t>(value);
output[1] = static_cast<uint8_t>(value >> 8);
}
void fixture_write_u32(uint8_t* output, uint32_t value) {
output[0] = static_cast<uint8_t>(value);
output[1] = static_cast<uint8_t>(value >> 8);
output[2] = static_cast<uint8_t>(value >> 16);
output[3] = static_cast<uint8_t>(value >> 24);
}
void install_legacy_database_bank_fixture() {
erase_all();
constexpr uint8_t kBank = 1;
constexpr uint32_t kGeneration = 41;
constexpr size_t kFallbackOffset =
CONTROLLER_PROFILE_DATABASE_HEADER_SIZE;
constexpr size_t kEntryOffset =
kFallbackOffset +
CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE;
constexpr uint8_t kEntryHeader[CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE] = {
1, 1, 7, 0, 1, 2, 3, 4, 5, 6, 0x7e, 0x05, 0x09, 0x20, 3, 1,
};
uint8_t* const record = flash.bytes[kBank];
uint8_t* const payload = record + PROFILE_STORAGE_RECORD_HEADER_SIZE;
memset(record, 0, PROFILE_STORAGE_RECORD_HEADER_SIZE);
memset(payload, 0, CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE);
memcpy(payload, "SPDB", 4);
fixture_write_u16(&payload[4],
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION);
fixture_write_u16(
&payload[6],
static_cast<uint16_t>(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE));
payload[8] = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY;
payload[9] = CONTROLLER_PROFILE_COUNT;
payload[10] = 2;
payload[11] = 1;
for (uint8_t profile_index = 0;
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) {
const uint8_t* fixture =
profile_index == 0
? kLegacyNarrowRawRangeProfile
: profile_index == 1 ? kLegacyCustomThresholdProfile
: kLegacyDefaultProfile;
memcpy(&payload[kFallbackOffset +
profile_index * CONTROLLER_PROFILE_ENCODED_SIZE],
fixture, CONTROLLER_PROFILE_ENCODED_SIZE);
}
memcpy(&payload[kEntryOffset], kEntryHeader, sizeof(kEntryHeader));
for (uint8_t profile_index = 0;
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) {
const uint8_t* fixture =
profile_index == 0
? kLegacyNarrowRawRangeProfile
: profile_index == 3 ? kLegacyCustomThresholdProfile
: kLegacyDefaultProfile;
memcpy(&payload[kEntryOffset +
CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE +
profile_index * CONTROLLER_PROFILE_ENCODED_SIZE],
fixture, CONTROLLER_PROFILE_ENCODED_SIZE);
}
const uint32_t payload_crc = profile_storage_crc32(
payload, CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE);
memcpy(record, "SPPF", 4);
fixture_write_u16(&record[4], 1);
fixture_write_u16(&record[6],
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION);
fixture_write_u32(&record[8], kGeneration);
fixture_write_u32(
&record[12],
static_cast<uint32_t>(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE));
fixture_write_u32(&record[16], payload_crc);
fixture_write_u32(&record[20], profile_storage_crc32(record, 20));
}
void test_two_bank_recovery() {
erase_all();
controller_profile_database_default(&database);
@ -225,11 +314,180 @@ void test_payload_corruption_prevents_header_publication() {
"headerless corrupt payload was recovered");
}
void test_legacy_database_bank_migration() {
install_legacy_database_bank_fixture();
ProfileStorage storage;
require(storage.initialize(fake_io(), &recovered_database) &&
storage.snapshot().valid &&
storage.snapshot().active_bank == 1 &&
storage.snapshot().generation == 41,
"legacy v1 database bank was not selected");
require(flash.erase_count == 0,
"legacy bank admission erased flash");
require(recovered_database.fallback_active_profile == 2,
"legacy fallback active profile was not preserved");
for (uint8_t profile_index = 0;
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) {
const uint16_t expected_left =
profile_index == 1
? 0x1234
: CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
const uint16_t expected_right =
profile_index == 1
? 0xabcd
: CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
require(recovered_database.fallback_profiles[profile_index]
.triggers[0]
.digital_threshold == expected_left &&
recovered_database.fallback_profiles[profile_index]
.triggers[1]
.digital_threshold == expected_right,
"legacy fallback thresholds were not selectively migrated");
}
require(recovered_database.fallback_profiles[0]
.triggers[0]
.lower_deadzone == 30000 &&
recovered_database.fallback_profiles[0]
.triggers[0]
.upper_saturation == 40000 &&
recovered_database.fallback_profiles[0]
.triggers[1]
.lower_deadzone == 30000 &&
recovered_database.fallback_profiles[0]
.triggers[1]
.upper_saturation == 40000,
"legacy fallback raw trigger ranges were not preserved");
const ControllerProfileDatabaseEntry& entry =
recovered_database.entries[0];
require(entry.used && entry.active_profile == 3 &&
entry.identity.stable &&
entry.identity.transport == ControllerTransport::kClassic &&
entry.identity.address_type == 7 &&
entry.identity.address[0] == 1 &&
entry.identity.address[5] == 6 &&
entry.identity.vendor_id == 0x057e &&
entry.identity.product_id == 0x2009,
"legacy entry identity or active profile was not preserved");
for (uint8_t profile_index = 0;
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) {
const uint16_t expected_left =
profile_index == 3
? 0x1234
: CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
const uint16_t expected_right =
profile_index == 3
? 0xabcd
: CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
require(entry.profiles[profile_index]
.triggers[0]
.digital_threshold == expected_left &&
entry.profiles[profile_index]
.triggers[1]
.digital_threshold == expected_right,
"legacy entry thresholds were not selectively migrated");
}
require(entry.profiles[0].triggers[0].lower_deadzone == 30000 &&
entry.profiles[0].triggers[0].upper_saturation == 40000 &&
entry.profiles[0].triggers[1].lower_deadzone == 30000 &&
entry.profiles[0].triggers[1].upper_saturation == 40000,
"legacy entry raw trigger ranges were not preserved");
recovered_database.fallback_profiles[2].weak_rumble_scale = 17;
require(storage.commit(recovered_database) ==
ProfileStorageResult::kOk &&
storage.snapshot().active_bank == 0 &&
storage.snapshot().generation == 42,
"mutation after legacy admission did not commit");
const uint8_t* const current_record = flash.bytes[0];
const uint8_t* const current_payload =
current_record + PROFILE_STORAGE_RECORD_HEADER_SIZE;
require(fixture_read_u16(&current_record[6]) ==
CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION &&
fixture_read_u16(&current_payload[4]) ==
CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION,
"post-migration commit did not emit v2 storage schemas");
constexpr size_t kFallbackOffset =
CONTROLLER_PROFILE_DATABASE_HEADER_SIZE;
constexpr size_t kEntryOffset =
kFallbackOffset +
CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE;
for (uint8_t profile_index = 0;
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) {
require(fixture_read_u16(
&current_payload[kFallbackOffset +
profile_index *
CONTROLLER_PROFILE_ENCODED_SIZE]) ==
CONTROLLER_PROFILE_SCHEMA_VERSION &&
fixture_read_u16(
&current_payload[
kEntryOffset +
CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE +
profile_index *
CONTROLLER_PROFILE_ENCODED_SIZE]) ==
CONTROLLER_PROFILE_SCHEMA_VERSION,
"post-migration commit retained a v1 profile");
}
require(fixture_read_u16(&flash.bytes[1][6]) ==
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION &&
fixture_read_u16(
&flash.bytes[1][PROFILE_STORAGE_RECORD_HEADER_SIZE + 4]) ==
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION,
"post-migration commit erased or rewrote the admitted legacy bank");
ProfileStorage reloaded;
require(reloaded.initialize(fake_io(), &database) &&
reloaded.snapshot().generation == 42 &&
database.fallback_profiles[2].weak_rumble_scale == 17 &&
database.fallback_profiles[0]
.triggers[0]
.lower_deadzone == 30000 &&
database.fallback_profiles[0]
.triggers[0]
.upper_saturation == 40000 &&
database.fallback_profiles[0]
.triggers[0]
.digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD &&
database.fallback_profiles[1]
.triggers[0]
.digital_threshold == 0x1234 &&
database.fallback_profiles[1]
.triggers[1]
.digital_threshold == 0xabcd &&
database.entries[0].used &&
database.entries[0].active_profile == 3 &&
database.entries[0]
.profiles[0]
.triggers[0]
.lower_deadzone == 30000 &&
database.entries[0]
.profiles[0]
.triggers[0]
.upper_saturation == 40000 &&
database.entries[0]
.profiles[0]
.triggers[0]
.digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD &&
database.entries[0]
.profiles[3]
.triggers[0]
.digital_threshold == 0x1234 &&
database.entries[0]
.profiles[3]
.triggers[1]
.digital_threshold == 0xabcd,
"v2 migration commit did not reload without data loss");
}
} // namespace
int main() {
test_two_bank_recovery();
test_interrupted_commit_retains_previous_bank();
test_successful_header_program_is_commit_point();
test_payload_corruption_prevents_header_publication();
test_legacy_database_bank_migration();
return 0;
}

View file

@ -289,7 +289,9 @@ void test_input_reports_and_timers_are_isolated() {
states[3].button_west = true;
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
switch_pro_set_input(instance, states[instance]);
switch_pro_set_input(instance, states[instance],
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
}
now_ms = 15;
@ -328,7 +330,9 @@ void test_input_reports_and_timers_are_isolated() {
ControllerState changed_zero = states[0];
changed_zero.button_east = false;
changed_zero.button_system = true;
switch_pro_set_input(0, changed_zero);
switch_pro_set_input(0, changed_zero,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
now_ms = 30;
expect(switch_pro_task(0),
"instance 0 did not apply its changed input state");
@ -341,7 +345,9 @@ void test_input_reports_and_timers_are_isolated() {
ControllerState changed_three = states[3];
changed_three.button_west = false;
changed_three.button_capture = true;
switch_pro_set_input(3, changed_three);
switch_pro_set_input(3, changed_three,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
now_ms = 45;
expect(switch_pro_task(3),
"instance 3 did not apply its changed input state");
@ -370,8 +376,10 @@ void test_callback_send_and_imu_modes_are_isolated() {
ControllerState one = zero;
one.button_north = true;
one.motion_samples[0] = {1001, 2002, 3003, 4004, 5005, 6006};
switch_pro_set_input(0, zero);
switch_pro_set_input(1, one);
switch_pro_set_input(0, zero, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
switch_pro_set_input(1, one, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
now_ms = 21;
expect(switch_pro_task(0), "raw-IMU instance did not send input");
expect(switch_pro_task(1), "off-IMU instance timer did not send input");
@ -400,8 +408,11 @@ void test_callback_send_and_imu_modes_are_isolated() {
stationary.right_stick_x = stationary.right_stick_y = 0;
stationary.motion_sample_count = 1;
stationary.motion_samples[0] = {1000, 2000, 3000, 0, 0, 0};
switch_pro_set_input(0, moving);
switch_pro_set_input(1, stationary);
switch_pro_set_input(0, moving, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
switch_pro_set_input(1, stationary,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
now_ms = 21;
expect(switch_pro_task(0), "moving quaternion instance did not report");
expect(switch_pro_task(1), "stationary quaternion timer crossed instances");
@ -578,7 +589,9 @@ void test_lifecycle_and_invalid_instances() {
ControllerState ignored{};
ignored.button_system = true;
switch_pro_init(kInvalidInstance);
switch_pro_set_input(kInvalidInstance, ignored);
switch_pro_set_input(kInvalidInstance, ignored,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
switch_pro_set_rumble_callback(kInvalidInstance, rumble_callback);
expect(!switch_pro_task(kInvalidInstance),
"invalid instance ran a driver task");
@ -598,7 +611,8 @@ void test_protocol_neutral_trigger_threshold() {
state.left_trigger =
static_cast<uint16_t>(SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD - 1u);
state.right_trigger = SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD;
switch_pro_set_input(0, state);
switch_pro_set_input(0, state, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
now_ms = 15;
expect(switch_pro_task(0), "trigger threshold report was not sent");
SwitchProReport report = copy_switch_report(latest_regular_report(0));
@ -607,7 +621,8 @@ void test_protocol_neutral_trigger_threshold() {
state.left_trigger = SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD;
state.right_trigger = CONTROLLER_TRIGGER_MIN;
switch_pro_set_input(0, state);
switch_pro_set_input(0, state, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
now_ms = 30;
expect(switch_pro_task(0), "second trigger threshold report was not sent");
report = copy_switch_report(latest_regular_report(0));
@ -615,13 +630,36 @@ void test_protocol_neutral_trigger_threshold() {
"Switch trigger threshold changed at the upper boundary");
}
void test_custom_trigger_thresholds_are_isolated() {
initialize_contexts();
ControllerState state{};
state.left_trigger = 300;
state.right_trigger = 300;
switch_pro_set_input(0, state, 300, 301);
switch_pro_set_input(1, state, 301, 300);
now_ms = 15;
expect(switch_pro_task(0) && switch_pro_task(1),
"custom trigger threshold reports were not sent");
const SwitchProReport first =
copy_switch_report(latest_regular_report(0));
const SwitchProReport second =
copy_switch_report(latest_regular_report(1));
expect(first.inputs.buttonZL && !first.inputs.buttonZR,
"instance 0 did not use its exact left/right trigger thresholds");
expect(!second.inputs.buttonZL && second.inputs.buttonZR,
"instance 1 trigger thresholds crossed HID contexts");
}
void test_uart_parser_is_pure() {
initialize_contexts();
ControllerState driver_state{};
driver_state.left_stick_x = driver_state.left_stick_y =
driver_state.right_stick_x = driver_state.right_stick_y = 0;
driver_state.button_north = true;
switch_pro_set_input(0, driver_state);
switch_pro_set_input(0, driver_state,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
now_ms = 15;
switch_pro_task(0);
@ -724,6 +762,7 @@ int main() {
test_grip_colors_are_isolated();
test_lifecycle_and_invalid_instances();
test_protocol_neutral_trigger_threshold();
test_custom_trigger_thresholds_are_isolated();
test_uart_parser_is_pure();
if (failures != 0) {
std::cerr << failures << " driver context test(s) failed\n";

View file

@ -500,6 +500,13 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerIdentity.from_bytes(malformed_identity)
default_profile = config_manager.ControllerProfile.default()
assert default_profile.left_trigger.digital_threshold == 22934
assert default_profile.right_trigger.digital_threshold == 22934
default_wire = default_profile.to_bytes()
assert struct.unpack_from("<H", default_wire, 58)[0] == 22934
assert struct.unpack_from("<H", default_wire, 68)[0] == 22934
profile = custom_profile()
encoded = profile.to_bytes()
assert len(encoded) == config_manager.PROFILE_SIZE
@ -513,11 +520,143 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
serialized = profile.to_json()
assert serialized.startswith('{\n "schema_version": 1,\n "size": 256,')
assert serialized.startswith('{\n "schema_version": 2,\n "size": 256,')
decoded = config_manager.ControllerProfile.from_json(serialized)
assert decoded == profile
assert decoded.to_json() == serialized
legacy_default_wire = bytearray(default_wire)
struct.pack_into(
"<H",
legacy_default_wire,
0,
config_manager.PROFILE_LEGACY_SCHEMA_VERSION,
)
struct.pack_into(
"<HHHH",
legacy_default_wire,
52,
30000,
40000,
256,
config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD,
)
struct.pack_into(
"<HHHH",
legacy_default_wire,
62,
30000,
40000,
256,
config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD,
)
migrated_default = config_manager.ControllerProfile.from_bytes(
legacy_default_wire
)
assert (
migrated_default.left_trigger.digital_threshold
== config_manager.PROFILE_DEFAULT_DIGITAL_THRESHOLD
)
assert (
migrated_default.right_trigger.digital_threshold
== config_manager.PROFILE_DEFAULT_DIGITAL_THRESHOLD
)
assert migrated_default.left_trigger.lower_deadzone == 30000
assert migrated_default.left_trigger.upper_saturation == 40000
assert migrated_default.right_trigger.lower_deadzone == 30000
assert migrated_default.right_trigger.upper_saturation == 40000
assert migrated_default.to_bytes()[0] == config_manager.PROFILE_SCHEMA_VERSION
current_old_value_wire = bytearray(default_wire)
struct.pack_into(
"<H",
current_old_value_wire,
58,
config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD,
)
assert (
config_manager.ControllerProfile.from_bytes(
current_old_value_wire
).left_trigger.digital_threshold
== config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD
)
legacy_custom_wire = bytearray(encoded)
struct.pack_into(
"<H",
legacy_custom_wire,
0,
config_manager.PROFILE_LEGACY_SCHEMA_VERSION,
)
assert (
config_manager.ControllerProfile.from_bytes(legacy_custom_wire)
== profile
)
legacy_json_object = default_profile.to_json_object()
legacy_json_object["schema_version"] = (
config_manager.PROFILE_LEGACY_SCHEMA_VERSION
)
legacy_json_object["triggers"]["left"]["digital_threshold"] = (
config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD
)
legacy_json_object["triggers"]["right"]["digital_threshold"] = 33000
legacy_json_object["triggers"]["left"]["lower_deadzone"] = 30000
legacy_json_object["triggers"]["left"]["upper_saturation"] = 40000
migrated_json = config_manager.ControllerProfile.from_json_object(
legacy_json_object
)
assert (
migrated_json.left_trigger.digital_threshold
== config_manager.PROFILE_DEFAULT_DIGITAL_THRESHOLD
)
assert migrated_json.right_trigger.digital_threshold == 33000
assert migrated_json.left_trigger.lower_deadzone == 30000
assert migrated_json.left_trigger.upper_saturation == 40000
def test_trigger_threshold_uses_transformed_output_domain() -> None:
for threshold in (0, 0xFFFF):
trigger = config_manager.TriggerConfig(30000, 40000, 256, threshold)
assert config_manager.TriggerConfig.from_bytes(trigger.to_bytes()) == trigger
assert (
config_manager.TriggerConfig.from_json_object(
trigger.to_json_object(), "trigger"
)
== trigger
)
current_wire = bytearray(
config_manager.ControllerProfile.default().to_bytes()
)
struct.pack_into(
"<HHHH", current_wire, 52, 30000, 40000, 256, threshold
)
current_profile = config_manager.ControllerProfile.from_bytes(
current_wire
)
assert current_profile.left_trigger.digital_threshold == threshold
assert current_profile.to_bytes() == current_wire
for threshold in (-1, 0x10000):
with pytest.raises(
config_manager.ConfigManagerError,
match="trigger digital_threshold",
):
config_manager.TriggerConfig(30000, 40000, 256, threshold)
for lower_deadzone, upper_saturation in (
(40000, 40000),
(40001, 40000),
):
with pytest.raises(
config_manager.ConfigManagerError,
match="lower_deadzone must be below upper_saturation",
):
config_manager.TriggerConfig(
lower_deadzone, upper_saturation, 256, 0
)
def test_profile_list_select_read_and_chunked_commit() -> None:
device = FakeDevice()

View file

@ -0,0 +1,32 @@
import shutil
import subprocess
from pathlib import Path
def test_controller_profile_runtime_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
executable = tmp_path / "controller_profile_runtime_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{root}",
str(root / "tests" / "controller_profile_runtime_test.cpp"),
str(root / "controller_identity.cpp"),
str(root / "controller_profile.cpp"),
str(root / "controller_profile_transform.cpp"),
str(root / "controller_profile_runtime.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -0,0 +1,31 @@
import shutil
import subprocess
from pathlib import Path
def test_controller_profile_transform_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
executable = tmp_path / "controller_profile_transform_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{root}",
str(root / "tests" / "controller_profile_transform_test.cpp"),
str(root / "controller_identity.cpp"),
str(root / "controller_profile.cpp"),
str(root / "controller_profile_transform.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -279,7 +279,12 @@ void test_profile_vendor_requests() {
control_payload.size() ==
kResponseHeaderSize +
CONTROLLER_PROFILE_ENCODED_SIZE &&
control_payload[kResponseHeaderSize] == 1 &&
control_payload[10] ==
CONTROLLER_PROFILE_SCHEMA_VERSION &&
control_payload[kResponseHeaderSize] ==
static_cast<uint8_t>(
CONTROLLER_PROFILE_SCHEMA_VERSION) &&
control_payload[kResponseHeaderSize + 1] == 0 &&
control_payload[kResponseHeaderSize + 2] == 0 &&
control_payload[kResponseHeaderSize + 3] == 1,
"selected profile response was not encoded");