Add Profile Studio Set A enhancements

This commit is contained in:
Joey Yakimowich-Payne 2026-09-04 21:49:50 -06:00
commit 8818f1f6e9
22 changed files with 1745 additions and 118 deletions

View file

@ -585,10 +585,10 @@ Storage design:
- decode only the fallback and active profile for each observed identity
- keep report-path profile access allocation-free with bounded snapshots
The completed AIO image uses 683,128 bytes of 4 MiB flash and reserves 256
The completed AIO image uses 695,592 bytes of 4 MiB flash and reserves 256
KiB for the two profile arenas. Total flash use plus configuration, bonds,
and the RP2350 terminal sector is 965,752 bytes (23.03%). Linked SRAM is
97,600 of 532,480 bytes; the profile catalog index is 1,556 bytes.
and the RP2350 terminal sector is 978,216 bytes (23.32%). Linked SRAM is
99,040 of 532,480 bytes; the profile catalog index is 1,828 bytes.
Acceptance:
@ -613,6 +613,78 @@ Completion evidence:
- the browser editor renders and selects all eight slots
- Pico 2 W hardware read and activated profile 8, then restored profile 1
### Candidate profile and Profile Studio enhancements — Set A complete
The live playtest, eight-slot catalog, controller-native labels, automatic
active-profile synchronization, and all Set A usability work are complete.
Set B and Set C remain candidates; their order reflects user value and
dependency, not a commitment to implement every item.
| Priority | Candidate | Intended scope | Dependency or principal risk |
|---|---|---|---|
| A1 — Complete | Named and copyable profiles | Store a short profile name; rename, duplicate to another slot or controller, expose JSON import/export in the browser, and reset one section without replacing the entire profile. Names use catalog metadata records, so the 256-byte input profile remains stable. | Implemented with atomic metadata records and existing profile transactions; no report-path cost. |
| A2 — Complete | Visual response-curve editor | Replace raw `curve_q8_8` as the primary control with a graph, named presets, fine adjustment, live raw/output markers, and “apply to other side” for sticks or triggers. Retain the exact fixed-point value as the wire representation. | Implemented entirely in Profile Studio; existing profiles round-trip unchanged. |
| A3 — Complete | Controller aliases and Identify action | Allow names such as “Living-room DualSense”; show battery, transport, and capabilities as secondary details; provide an Identify button that briefly rumbles or lights only the selected live controller. | Implemented with catalog alias records, capability-gated live telemetry, and a bounded non-persistent Identify command. |
| B1 | Direct profile shortcuts | Assign a modifier plus face button or D-pad direction to select profiles 1–8 directly while retaining the existing cycle chord. Publish feedback only after the activation record commits. | Profile schema/runtime change; chord precedence and consumption must remain deterministic. |
| B2 | One Shift layer per profile | While a configured modifier is held or toggled, apply one alternate button map over the base profile. Start with buttons only; do not layer analog transforms, Turbo, or macros initially. | Profile schema change and explicit precedence between base mapping, Shift, Turbo, and macros. |
| B3 | Macro authoring tools | Record live controller input, insert, duplicate, remove, and drag-reorder steps; preview playback; show time and sparse-byte cost on every edit. Later add once, while-held, toggle, and bounded-repeat playback modes. | Editing tools fit the current format; new playback modes require schema/runtime work and must remain within the shared 16-step/136-byte budget. |
| B4 | Configurable Turbo and finite Burst | Add a bounded repeat rate, duty cycle, and finite Burst count while retaining hold Turbo and Auto Burst. Support shared defaults with optional per-button overrides rather than duplicating full settings sixteen times. | Profile schema/runtime change; scheduling must preserve the existing deterministic phase accumulator. |
| C1 | Motion calibration and tuning | Expose live gyro/accelerometer values, bias calibration, axis orientation/inversion, sensitivity, drift threshold, smoothing, and hold/toggle activation. Preserve native Switch motion units rather than introducing gyro-to-stick emulation first. | Controller-specific validation and physical motion testing; filters must not add report latency. |
| C2 | Feedback preview and profile lighting | Add non-persistent weak/strong rumble tests, profile-switch preview, and player LED/lightbar preview. Optionally persist an RGB profile color where the controller supports it. | New bounded management command; unsupported output capabilities must be visibly disabled. |
Set A completion evidence:
- profile names and controller aliases survive catalog reload, interrupted
writes, and arena compaction without changing the 256-byte profile schema
- profiles copy across identities and slots through existing validated atomic
transactions; browser JSON import/export and per-section resets operate on
unsaved drafts
- four response-curve graphs expose named presets, exact Q8.8 fine adjustment,
live curve markers, and one-click linked-side application
- live controller details expose transport, normalized battery percentage, and
rumble/lightbar/player-LED/motion capabilities
- Identify queues one bounded feedback pulse only for the matching live stable
identity
Recommended delivery order:
1. A1 profile metadata, duplication, and browser backup controls.
2. A2 curve visualization and linked-side editing.
3. A3 controller aliases, battery/capability details, and Identify.
4. B1 direct profile shortcuts.
5. B3 macro recording and editing before adding new playback semantics.
6. B2 one bounded Shift layer.
7. B4 configurable Turbo and finite Burst.
8. C1 motion calibration.
9. C2 feedback preview and custom lighting.
Design constraints:
- Do not add automatic per-game switching on console paths; the Pico cannot
reliably observe the active game.
- Do not add arbitrary scripts or an unbounded stack of action layers.
- Keep every report-path operation allocation-free and bounded.
- Keep live telemetry read-only, non-overlapping, and paused while the editor
is hidden.
- Preserve unsaved drafts across metadata refreshes and transient USB
disconnects.
- Gate every controller-specific input or output by reported capabilities.
- Add a new profile schema only when persistent runtime behavior changes;
UI-only presets and catalog metadata must not churn the profile wire format.
Research basis:
- 8BitDo Ultimate Software: mapping, stick/trigger tuning, vibration, macros,
profiles, and Turbo/Burst modes — https://support.8bitdo.com/ultimate/pro2.html
- DualSense Edge profiles: names, copies, direct shortcuts, curve presets,
linked trigger settings, and live playtest —
https://www.playstation.com/en-us/support/hardware/set-up-edge-controller/
- Steam Input action-set layers: temporary mapping overlays and precedence
risks —
https://partner.steamgames.com/doc/features/steam_controller/action_set_layers
- Xbox Elite Shift: modifier-driven alternate mappings —
https://support.xbox.com/en-US/help/hardware-network/controller/shift-elite-series-2
### Phase 8 — Performance and release qualification
Measure:

View file

@ -141,6 +141,16 @@ Development USB identities are `CAFE:4010` (XInput), `CAFE:4020` (DInput), and `
The editor selects Switch Pro, DualSense, or Xbox artwork from the connected controller's USB VID/PID and places each remappable control directly over the matching physical button. Controller artwork is from [AL2009man/Gamepad-Asset-Pack](https://github.com/AL2009man/Gamepad-Asset-Pack) under its MIT license; the bundled license and source revision are recorded beside the assets.
Profile names and controller aliases are stored as independently checksummed
catalog metadata, so naming does not change the 256-byte profile format. The
editor can rename and copy profiles across controllers and slots, import or
export JSON backups, and reset one section without discarding the rest of the
draft. Its response-curve cards provide named presets, exact Q8.8 fine
adjustment, live curve markers, and one-click application to the opposite
stick or trigger. Connected-controller details include transport, battery,
and supported feedback/motion capabilities; **Identify** sends one bounded
rumble/light pulse only to the selected live controller.
`profiles list` prints identity index `0` for the global fallback plus each stable Bluetooth identity observed by the firmware. Each identity owns eight persistent profiles and one active index. The JSON export/import commands remain available for version-controlled or scripted profiles. Profile numbers shown to users are `1` through `8`; `--identity` uses the zero-based index from `profiles list`.
`pairings list` refreshes and prints stored Bluetooth Classic and BLE addresses. `pairings clear --yes` deletes all bonds, disconnects active controllers, closes new authentication, and resumes discovery because no controllers remain. Destructive commands require `--yes`. If multiple compatible Picos are attached, select one with `--bus N --address N`; the error lists their locations. USB access errors require permission to the matching `/dev/bus/usb` device.
@ -601,16 +611,16 @@ linked binary, not from the larger debug-bearing ELF or UF2 transport file:
| Resource | Used or reserved | Device capacity |
|---|---:|---:|
| Executable flash image | 683,128 bytes | 4 MiB |
| Executable flash image | 695,592 bytes | 4 MiB |
| Indexed profile arenas | 256 KiB | 4 MiB flash |
| Adapter configuration | 8 KiB | 4 MiB flash |
| BTstack bonds | 8 KiB | 4 MiB flash |
| RP2350 terminal sector | 4 KiB | 4 MiB flash |
| Linked SRAM | 97,600 bytes | 520 KiB |
| Linked SRAM | 99,040 bytes | 520 KiB |
The executable plus persistent reservations consume 965,752 bytes (23.03%)
of flash, leaving 3,228,552 bytes (3.08 MiB). Linked SRAM consumes 18.33%,
leaving 434,880 bytes of link-time headroom.
The executable plus persistent reservations consume 978,216 bytes (23.32%)
of flash, leaving 3,216,088 bytes (3.07 MiB). Linked SRAM consumes 18.60%,
leaving 433,440 bytes of link-time headroom.
Profiles use two 128 KiB append-only arenas. Each independently published
record contains one identity/profile key, generation, schema, length, and CRC.

View file

@ -1906,6 +1906,14 @@ void bluepad32_input_backend_playtest_snapshot(
out->identity = slot.identity;
out->physical_button_mask = slot.pre_hotkey_button_mask;
out->state = slot.state;
if (slot.device != nullptr) {
out->battery = slot.device->controller.battery;
out->capabilities =
(slot.device->report_parser.play_dual_rumble != nullptr ? 1u : 0u) |
(slot.device->report_parser.set_lightbar_color != nullptr ? 2u : 0u) |
(slot.device->report_parser.set_player_leds != nullptr ? 4u : 0u) |
(slot.state.motion_sample_count != 0 ? 8u : 0u);
}
critical_section_exit(&g_state_lock);
}
@ -1972,6 +1980,37 @@ void bluepad32_input_backend_queue_rumble(
critical_section_exit(&g_state_lock);
}
bool bluepad32_input_backend_identify(
const ControllerIdentity& identity) {
if (!g_initialized || !identity.stable ||
controller_identity_is_global(identity)) {
return false;
}
bool queued = false;
critical_section_enter_blocking(&g_state_lock);
for (BackendSlot& slot : g_slots) {
if (!slot.active ||
!controller_identity_equal(slot.identity, identity)) {
continue;
}
const ProfileFeedbackEnvelope feedback{
slot.connection_generation, 1,
ControllerProfileConfirmationPolicy::kRumbleAndLed};
if (slot.pending_profile_feedback_count <
kProfileFeedbackQueueCapacity) {
slot.pending_profile_feedback[
slot.pending_profile_feedback_count++] = feedback;
} else {
slot.pending_profile_feedback[
kProfileFeedbackQueueCapacity - 1u] = feedback;
}
queued = true;
break;
}
critical_section_exit(&g_state_lock);
return queued;
}
void bluepad32_input_backend_queue_profile_feedback(
uint8_t slot_index, uint32_t connection_generation,
uint8_t active_profile_number,

View file

@ -70,6 +70,8 @@ struct Bluepad32PlaytestSnapshot {
uint32_t state_generation = 0;
ControllerIdentity identity{};
uint16_t physical_button_mask = 0;
uint8_t battery = 0;
uint8_t capabilities = 0;
ControllerState state{};
};
@ -111,6 +113,8 @@ bool bluepad32_input_backend_toggle_motion(
uint8_t slot, uint32_t connection_generation);
void bluepad32_input_backend_queue_rumble(
uint8_t slot, const ControllerRumbleOutput& rumble);
bool bluepad32_input_backend_identify(
const ControllerIdentity& identity);
// Enqueue bounded local profile confirmation for the matching live connection
// generation. The two-entry per-slot FIFO preserves initial-then-switch
// ordering. Profile lighting is transient and restored to the steady slot

View file

@ -9,11 +9,15 @@ namespace {
constexpr uint32_t kMinimumCommitIntervalMs = 1000;
constexpr uint32_t kInternalTransactionIdMask = 0x80000000u;
static_assert(PROFILE_SERVICE_METADATA_MAX_BYTES ==
PROFILE_STORAGE_METADATA_MAX_BYTES);
enum class PendingCommandType : uint8_t {
kNone = 0,
kReset = 1,
kActivate = 2,
kSetAlias = 3,
kSetProfileName = 4,
};
struct PendingCommand {
@ -21,6 +25,8 @@ struct PendingCommand {
uint32_t transaction_id = 0;
ControllerIdentity identity{};
uint8_t profile_index = 0;
uint8_t value_size = 0;
char value[PROFILE_SERVICE_METADATA_MAX_BYTES]{};
};
struct ProfileTransaction {
@ -68,6 +74,11 @@ void refresh_list_locked() {
continue;
}
ProfileServiceListRow &row = g_list.rows[g_list.count++];
if (g_storage.get_alias(
entry->identity, row.alias,
sizeof(row.alias)) != ProfileStorageResult::kOk) {
row.alias[0] = '\0';
}
row.identity = entry->identity;
row.active_profile = entry->active_profile;
}
@ -268,8 +279,14 @@ void profile_service_task_on_storage_core(uint32_t now_ms) {
} else if (command.type == PendingCommandType::kActivate) {
storage_result =
g_storage.activate(command.identity, command.profile_index);
} else if (command.type == PendingCommandType::kSetAlias) {
storage_result = g_storage.set_alias(
command.identity, command.value, command.value_size);
} else if (command.type == PendingCommandType::kSetProfileName) {
storage_result = g_storage.set_profile_name(
command.identity, command.profile_index,
command.value, command.value_size);
}
const ConfigurationTransactionStatus status =
storage_result_status(storage_result);
if (status == ConfigurationTransactionStatus::kCommitted) {
@ -495,6 +512,58 @@ profile_service_activate(uint32_t transaction_id,
return ConfigurationTransactionStatus::kPending;
}
ConfigurationTransactionStatus profile_service_set_metadata(
uint32_t transaction_id, const ControllerIdentity &identity,
uint8_t profile_index, const char *value, size_t value_size) {
if (!g_prepared) {
profile_service_prepare();
}
critical_section_enter_blocking(&g_lock);
if (g_command.type != PendingCommandType::kNone ||
g_internal_activation.type != PendingCommandType::kNone ||
g_transaction.snapshot.status ==
ConfigurationTransactionStatus::kReceiving ||
g_transaction.snapshot.status ==
ConfigurationTransactionStatus::kPending) {
critical_section_exit(&g_lock);
return ConfigurationTransactionStatus::kBusy;
}
g_transaction = {};
g_transaction.snapshot.transaction_id = transaction_id;
g_transaction.identity = identity;
g_transaction.profile_index = profile_index;
bool malformed =
transaction_id == 0 ||
(transaction_id & kInternalTransactionIdMask) != 0 ||
!valid_identity(identity) ||
(profile_index != CONTROLLER_PROFILE_ALL &&
profile_index >= CONTROLLER_PROFILE_COUNT) ||
value_size > PROFILE_SERVICE_METADATA_MAX_BYTES ||
(value_size != 0 && value == nullptr);
for (size_t index = 0; index < value_size && !malformed; ++index) {
malformed = value[index] == '\0';
}
if (malformed) {
g_transaction.snapshot.status =
ConfigurationTransactionStatus::kMalformed;
critical_section_exit(&g_lock);
return ConfigurationTransactionStatus::kMalformed;
}
g_transaction.snapshot.status = ConfigurationTransactionStatus::kPending;
g_command.transaction_id = transaction_id;
g_command.type = profile_index == CONTROLLER_PROFILE_ALL
? PendingCommandType::kSetAlias
: PendingCommandType::kSetProfileName;
g_command.identity = identity;
g_command.profile_index = profile_index;
g_command.value_size = static_cast<uint8_t>(value_size);
if (value_size != 0) {
memcpy(g_command.value, value, value_size);
}
critical_section_exit(&g_lock);
return ConfigurationTransactionStatus::kPending;
}
ConfigurationTransactionStatus
profile_service_activate_internal(uint32_t transaction_id,
const ControllerIdentity &identity,
@ -543,6 +612,37 @@ void profile_service_selected_snapshot(ProfileServiceSelectedSnapshot *output) {
critical_section_exit(&g_lock);
}
void profile_service_metadata_snapshot(
ProfileServiceMetadataSnapshot *output) {
if (output == nullptr) {
return;
}
*output = {};
critical_section_enter_blocking(&g_lock);
output->metadata = g_metadata;
output->identity = g_selected.identity;
output->status = g_selected.status;
bool valid =
g_metadata.state == ProfileServiceState::kReady &&
g_storage.get_alias(
g_selected.identity, output->alias,
sizeof(output->alias)) == ProfileStorageResult::kOk;
for (uint8_t profile = 0;
profile < CONTROLLER_PROFILE_COUNT && valid; ++profile) {
valid = g_storage.get_profile_name(
g_selected.identity, profile,
output->profile_names[profile],
sizeof(output->profile_names[profile])) ==
ProfileStorageResult::kOk;
}
output->valid = valid;
if (!valid && output->status ==
ConfigurationTransactionStatus::kCommitted) {
output->status = ConfigurationTransactionStatus::kStorageError;
}
critical_section_exit(&g_lock);
}
void profile_service_transaction_snapshot(
ProfileServiceTransactionSnapshot *output) {
if (output == nullptr) {

View file

@ -8,6 +8,7 @@
constexpr uint8_t PROFILE_SERVICE_LIST_CAPACITY =
CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1;
constexpr size_t PROFILE_SERVICE_METADATA_MAX_BYTES = 31;
enum class ProfileServiceState : uint8_t {
kLoading = 0,
@ -23,6 +24,7 @@ struct ProfileServiceMetadata {
struct ProfileServiceListRow {
ControllerIdentity identity{};
char alias[PROFILE_SERVICE_METADATA_MAX_BYTES + 1]{};
uint8_t active_profile = 0;
};
@ -42,6 +44,17 @@ struct ProfileServiceSelectedSnapshot {
ControllerProfile profile{};
};
struct ProfileServiceMetadataSnapshot {
ProfileServiceMetadata metadata{};
bool valid = false;
ConfigurationTransactionStatus status =
ConfigurationTransactionStatus::kIdle;
ControllerIdentity identity{};
char alias[PROFILE_SERVICE_METADATA_MAX_BYTES + 1]{};
char profile_names[CONTROLLER_PROFILE_COUNT]
[PROFILE_SERVICE_METADATA_MAX_BYTES + 1]{};
};
struct ProfileServiceActiveProfileSnapshot {
ProfileServiceMetadata metadata{};
bool valid = false;
@ -79,6 +92,9 @@ ConfigurationTransactionStatus profile_service_reset(
ConfigurationTransactionStatus profile_service_activate(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t profile_index);
ConfigurationTransactionStatus profile_service_set_metadata(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t profile_index, const char* value, size_t value_size);
// Queue a controller-originated activation without replacing the host-visible
// transaction snapshot. transaction_id must be nonzero with its high bit set.
ConfigurationTransactionStatus profile_service_activate_internal(
@ -88,6 +104,8 @@ ConfigurationTransactionStatus profile_service_activate_internal(
void profile_service_list_snapshot(ProfileServiceListSnapshot* output);
void profile_service_selected_snapshot(
ProfileServiceSelectedSnapshot* output);
void profile_service_metadata_snapshot(
ProfileServiceMetadataSnapshot* output);
void profile_service_transaction_snapshot(
ProfileServiceTransactionSnapshot* output);
uint32_t profile_service_database_generation();

View file

@ -79,6 +79,13 @@ bool bytes_are(uint8_t value, const uint8_t *data, size_t size) {
return true;
}
bool metadata_value_valid(const uint8_t *payload, size_t size) {
return payload != nullptr && size == PROFILE_STORAGE_METADATA_PAYLOAD_SIZE &&
payload[0] <= PROFILE_STORAGE_METADATA_MAX_BYTES &&
bytes_are(0, &payload[payload[0] + 1],
size - payload[0] - 1);
}
} // namespace
uint32_t profile_storage_crc32(const uint8_t *data, size_t size) {
@ -257,6 +264,128 @@ ProfileStorage::activate(const ControllerIdentity &identity,
return append(RecordType::kActivate, identity, profile_index, nullptr, 0);
}
ProfileStorageResult ProfileStorage::get_alias(
const ControllerIdentity &identity, char *output,
size_t output_size) const {
if (!initialized_ || output == nullptr || output_size == 0 ||
!valid_identity(identity)) {
return ProfileStorageResult::kInvalidArgument;
}
const ProfileStorageIdentityIndex *entry = find(identity);
if (entry == nullptr ||
entry->alias_record == PROFILE_STORAGE_NO_RECORD) {
output[0] = '\0';
return ProfileStorageResult::kOk;
}
return read_metadata_record(entry->alias_record, output, output_size)
? ProfileStorageResult::kOk
: ProfileStorageResult::kIoError;
}
ProfileStorageResult ProfileStorage::set_alias(
const ControllerIdentity &identity, const char *value,
size_t value_size) {
if (!initialized_ || !valid_identity(identity) ||
value_size > PROFILE_STORAGE_METADATA_MAX_BYTES ||
(value_size != 0 && value == nullptr)) {
return ProfileStorageResult::kInvalidArgument;
}
char current[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
if (find(identity) == nullptr &&
identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) {
return ProfileStorageResult::kFull;
}
if (get_alias(identity, current, sizeof(current)) ==
ProfileStorageResult::kOk &&
strlen(current) == value_size &&
(value_size == 0 || memcmp(current, value, value_size) == 0)) {
return ProfileStorageResult::kUnchanged;
}
uint8_t payload[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
payload[0] = static_cast<uint8_t>(value_size);
if (value_size != 0) {
memcpy(&payload[1], value, value_size);
}
return append(RecordType::kAlias, identity, CONTROLLER_PROFILE_ALL,
payload, sizeof(payload));
}
ProfileStorageResult ProfileStorage::get_profile_name(
const ControllerIdentity &identity, uint8_t profile_index,
char *output, size_t output_size) const {
if (!initialized_ || output == nullptr || output_size == 0 ||
!valid_identity(identity) ||
profile_index >= CONTROLLER_PROFILE_COUNT) {
return ProfileStorageResult::kInvalidArgument;
}
const ProfileStorageIdentityIndex *entry = find(identity);
if (entry == nullptr ||
entry->profile_names_record == PROFILE_STORAGE_NO_RECORD) {
output[0] = '\0';
return ProfileStorageResult::kOk;
}
uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{};
if (!io_.read(
io_.context, record_arena(entry->profile_names_record),
record_offset(entry->profile_names_record) +
kRecordPayloadOffset,
payload, sizeof(payload))) {
return ProfileStorageResult::kIoError;
}
const size_t offset =
profile_index * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE;
const size_t size = payload[offset];
if (size > PROFILE_STORAGE_METADATA_MAX_BYTES ||
output_size <= size) {
return ProfileStorageResult::kInvalidArgument;
}
memcpy(output, &payload[offset + 1], size);
output[size] = '\0';
return ProfileStorageResult::kOk;
}
ProfileStorageResult ProfileStorage::set_profile_name(
const ControllerIdentity &identity, uint8_t profile_index,
const char *value, size_t value_size) {
if (!initialized_ || !valid_identity(identity) ||
profile_index >= CONTROLLER_PROFILE_COUNT ||
value_size > PROFILE_STORAGE_METADATA_MAX_BYTES ||
(value_size != 0 && value == nullptr)) {
return ProfileStorageResult::kInvalidArgument;
}
const ProfileStorageIdentityIndex *entry = find(identity);
if (entry == nullptr &&
identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) {
return ProfileStorageResult::kFull;
}
char current[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
if (get_profile_name(identity, profile_index, current,
sizeof(current)) == ProfileStorageResult::kOk &&
strlen(current) == value_size &&
(value_size == 0 || memcmp(current, value, value_size) == 0)) {
return ProfileStorageResult::kUnchanged;
}
uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{};
if (entry != nullptr &&
entry->profile_names_record != PROFILE_STORAGE_NO_RECORD &&
!io_.read(
io_.context, record_arena(entry->profile_names_record),
record_offset(entry->profile_names_record) +
kRecordPayloadOffset,
payload, sizeof(payload))) {
return ProfileStorageResult::kIoError;
}
const size_t offset =
profile_index * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE;
memset(&payload[offset], 0, PROFILE_STORAGE_METADATA_PAYLOAD_SIZE);
payload[offset] = static_cast<uint8_t>(value_size);
if (value_size != 0) {
memcpy(&payload[offset + 1], value, value_size);
}
return append(RecordType::kProfileNames, identity,
CONTROLLER_PROFILE_ALL, payload, sizeof(payload));
}
uint8_t ProfileStorage::identity_count() const { return identity_count_; }
const ProfileStorageIdentityIndex *
@ -330,30 +459,59 @@ bool ProfileStorage::scan_arena(uint8_t arena, uint32_t *epoch,
const uint32_t record_generation = read_u32(&header[8]);
const size_t payload_size = read_u16(&header[12]);
ControllerIdentity identity_value{};
const bool known_type =
type == RecordType::kProfile || type == RecordType::kReset ||
type == RecordType::kResetAll || type == RecordType::kActivate ||
type == RecordType::kAlias ||
type == RecordType::kProfileNames;
const bool indexed_profile =
type == RecordType::kProfile || type == RecordType::kReset ||
type == RecordType::kActivate;
const size_t expected_payload_size =
type == RecordType::kProfile ||
type == RecordType::kProfileNames
? CONTROLLER_PROFILE_ENCODED_SIZE
: type == RecordType::kAlias
? PROFILE_STORAGE_METADATA_PAYLOAD_SIZE
: 0;
if (!controller_identity_decode(
&header[20], CONTROLLER_IDENTITY_ENCODED_SIZE, &identity_value) ||
!valid_identity(identity_value) ||
(type != RecordType::kProfile && type != RecordType::kReset &&
type != RecordType::kResetAll && type != RecordType::kActivate) ||
((type == RecordType::kProfile || type == RecordType::kReset ||
type == RecordType::kActivate) &&
profile_index >= CONTROLLER_PROFILE_COUNT) ||
(type == RecordType::kProfile &&
payload_size != CONTROLLER_PROFILE_ENCODED_SIZE) ||
(type != RecordType::kProfile && payload_size != 0)) {
!valid_identity(identity_value) || !known_type ||
(indexed_profile && profile_index >= CONTROLLER_PROFILE_COUNT) ||
payload_size != expected_payload_size) {
continue;
}
if (type == RecordType::kProfile) {
if (payload_size != 0) {
uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{};
ControllerProfile decoded{};
if (!io_.read(io_.context, arena, offset + kRecordPayloadOffset, payload,
sizeof(payload)) ||
read_u16(&header[14]) != read_u16(payload) ||
profile_storage_crc32(payload, sizeof(payload)) !=
read_u32(&header[16]) ||
!controller_profile_decode(payload, sizeof(payload), &decoded)) {
if (!io_.read(io_.context, arena, offset + kRecordPayloadOffset,
payload, payload_size) ||
profile_storage_crc32(payload, payload_size) !=
read_u32(&header[16])) {
continue;
}
if (type == RecordType::kProfile) {
ControllerProfile decoded{};
if (read_u16(&header[14]) != read_u16(payload) ||
!controller_profile_decode(payload, payload_size, &decoded)) {
continue;
}
} else if (type == RecordType::kAlias) {
if (read_u16(&header[14]) != 0 ||
!metadata_value_valid(payload, payload_size)) {
continue;
}
} else {
bool valid_names = read_u16(&header[14]) == 0;
for (size_t name = 0;
name < CONTROLLER_PROFILE_COUNT && valid_names; ++name) {
valid_names = metadata_value_valid(
&payload[name * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE],
PROFILE_STORAGE_METADATA_PAYLOAD_SIZE);
}
if (!valid_names) {
continue;
}
}
} else if (read_u16(&header[14]) != 0) {
continue;
}
@ -377,6 +535,23 @@ bool ProfileStorage::read_profile_record(uint32_t record,
controller_profile_decode(encoded, sizeof(encoded), output);
}
bool ProfileStorage::read_metadata_record(
uint32_t record, char *output, size_t output_size) const {
uint8_t payload[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
if (record == PROFILE_STORAGE_NO_RECORD ||
!io_.read(
io_.context, record_arena(record),
record_offset(record) + kRecordPayloadOffset,
payload, sizeof(payload)) ||
payload[0] > PROFILE_STORAGE_METADATA_MAX_BYTES ||
output_size <= payload[0]) {
return false;
}
memcpy(output, &payload[1], payload[0]);
output[payload[0]] = '\0';
return true;
}
ProfileStorageResult ProfileStorage::append(RecordType type,
const ControllerIdentity &identity,
uint8_t profile_index,
@ -426,6 +601,34 @@ ProfileStorageResult ProfileStorage::compact() {
}
offset += PROFILE_STORAGE_RECORD_SIZE;
}
uint8_t metadata[CONTROLLER_PROFILE_ENCODED_SIZE]{};
if (entry.alias_record != PROFILE_STORAGE_NO_RECORD) {
if (!io_.read(
io_.context, record_arena(entry.alias_record),
record_offset(entry.alias_record) + kRecordPayloadOffset,
metadata, PROFILE_STORAGE_METADATA_PAYLOAD_SIZE) ||
!write_record(
target, offset, RecordType::kAlias, entry.identity,
CONTROLLER_PROFILE_ALL, ++generation, metadata,
PROFILE_STORAGE_METADATA_PAYLOAD_SIZE)) {
return ProfileStorageResult::kIoError;
}
offset += PROFILE_STORAGE_RECORD_SIZE;
}
if (entry.profile_names_record != PROFILE_STORAGE_NO_RECORD) {
if (!io_.read(
io_.context, record_arena(entry.profile_names_record),
record_offset(entry.profile_names_record) +
kRecordPayloadOffset,
metadata, CONTROLLER_PROFILE_ENCODED_SIZE) ||
!write_record(
target, offset, RecordType::kProfileNames,
entry.identity, CONTROLLER_PROFILE_ALL, ++generation,
metadata, CONTROLLER_PROFILE_ENCODED_SIZE)) {
return ProfileStorageResult::kIoError;
}
offset += PROFILE_STORAGE_RECORD_SIZE;
}
if (!write_record(target, offset, RecordType::kActivate, entry.identity,
entry.active_profile, ++generation, nullptr, 0)) {
return ProfileStorageResult::kIoError;
@ -670,19 +873,27 @@ bool ProfileStorage::write_record(uint8_t arena, size_t offset, RecordType type,
uint8_t profile_index, uint32_t generation,
const uint8_t *payload,
size_t payload_size) const {
const size_t expected_payload_size =
type == RecordType::kProfile ||
type == RecordType::kProfileNames
? CONTROLLER_PROFILE_ENCODED_SIZE
: type == RecordType::kAlias
? PROFILE_STORAGE_METADATA_PAYLOAD_SIZE
: 0;
if (arena >= PROFILE_STORAGE_ARENA_COUNT ||
offset < PROFILE_STORAGE_RECORDS_OFFSET ||
offset + PROFILE_STORAGE_RECORD_SIZE > PROFILE_STORAGE_ARENA_SIZE ||
offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
(type == RecordType::kProfile
? payload == nullptr ||
payload_size != CONTROLLER_PROFILE_ENCODED_SIZE
: payload_size != 0)) {
payload_size != expected_payload_size ||
(payload_size != 0 && payload == nullptr)) {
return false;
}
if (payload_size != 0) {
if (!io_.program_page(io_.context, arena, offset + kRecordPayloadOffset,
payload, PROFILE_STORAGE_PAGE_SIZE)) {
uint8_t page[PROFILE_STORAGE_PAGE_SIZE]{};
memcpy(page, payload, payload_size);
if (!io_.program_page(io_.context, arena,
offset + kRecordPayloadOffset,
page, sizeof(page))) {
return false;
}
}
@ -737,6 +948,23 @@ void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index,
}
return;
}
if (type == RecordType::kAlias) {
if (entry->alias_generation == 0 ||
generation_is_newer(generation, entry->alias_generation)) {
entry->alias_record = record;
entry->alias_generation = generation;
}
return;
}
if (type == RecordType::kProfileNames) {
if (entry->profile_names_generation == 0 ||
generation_is_newer(
generation, entry->profile_names_generation)) {
entry->profile_names_record = record;
entry->profile_names_generation = generation;
}
return;
}
if (type == RecordType::kResetAll) {
for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) {
if (entry->profile_generation[profile] == 0 ||

View file

@ -15,6 +15,18 @@ constexpr size_t PROFILE_STORAGE_SUPERBLOCK_SIZE = PROFILE_STORAGE_PAGE_SIZE;
constexpr size_t PROFILE_STORAGE_RECORD_SIZE = 2 * PROFILE_STORAGE_PAGE_SIZE;
constexpr size_t PROFILE_STORAGE_RECORDS_OFFSET = PROFILE_STORAGE_SECTOR_SIZE;
constexpr uint32_t PROFILE_STORAGE_NO_RECORD = UINT32_MAX;
constexpr uint8_t PROFILE_STORAGE_METADATA_MAX_BYTES = 31;
constexpr size_t PROFILE_STORAGE_METADATA_PAYLOAD_SIZE =
PROFILE_STORAGE_METADATA_MAX_BYTES + 1;
constexpr size_t PROFILE_STORAGE_MAX_LIVE_RECORDS =
(CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) *
(CONTROLLER_PROFILE_COUNT + 3);
constexpr size_t PROFILE_STORAGE_RECORD_CAPACITY =
(PROFILE_STORAGE_ARENA_SIZE - PROFILE_STORAGE_RECORDS_OFFSET) /
PROFILE_STORAGE_RECORD_SIZE;
static_assert(PROFILE_STORAGE_MAX_LIVE_RECORDS <=
PROFILE_STORAGE_RECORD_CAPACITY,
"all live profile records must fit during compaction");
// Layout of the retired v1/v2 whole-database store. The indexed catalog reads
// this region once during migration; new firmware never writes it.
@ -64,6 +76,10 @@ struct ProfileStorageIdentityIndex {
uint32_t active_generation = 0;
uint32_t profile_generation[CONTROLLER_PROFILE_COUNT]{};
uint32_t profile_record[CONTROLLER_PROFILE_COUNT]{};
uint32_t alias_generation = 0;
uint32_t alias_record = PROFILE_STORAGE_NO_RECORD;
uint32_t profile_names_generation = 0;
uint32_t profile_names_record = PROFILE_STORAGE_NO_RECORD;
};
uint32_t profile_storage_crc32(const uint8_t *data, size_t size);
@ -82,6 +98,18 @@ public:
uint8_t profile_index);
ProfileStorageResult activate(const ControllerIdentity &identity,
uint8_t profile_index);
ProfileStorageResult get_alias(
const ControllerIdentity &identity, char *output,
size_t output_size) const;
ProfileStorageResult set_alias(
const ControllerIdentity &identity, const char *value,
size_t value_size);
ProfileStorageResult get_profile_name(
const ControllerIdentity &identity, uint8_t profile_index,
char *output, size_t output_size) const;
ProfileStorageResult set_profile_name(
const ControllerIdentity &identity, uint8_t profile_index,
const char *value, size_t value_size);
uint8_t identity_count() const;
const ProfileStorageIdentityIndex *identity(uint8_t index) const;
@ -95,6 +123,8 @@ private:
kReset = 2,
kResetAll = 3,
kActivate = 4,
kAlias = 5,
kProfileNames = 6,
};
bool scan_arena(uint8_t arena, uint32_t *epoch, uint32_t *generation,
@ -102,6 +132,8 @@ private:
ProfileStorageIdentityIndex *index,
uint8_t *identity_count) const;
bool read_profile_record(uint32_t record, ControllerProfile *output) const;
bool read_metadata_record(uint32_t record, char *output,
size_t output_size) const;
ProfileStorageResult append(RecordType type,
const ControllerIdentity &identity,
uint8_t profile_index, const uint8_t *payload,

View file

@ -100,6 +100,13 @@ bool valid_out_size(Operation operation, size_t size) {
case Operation::kProfileReset:
case Operation::kProfileActivate:
return size == kRequestHeaderSize + 19;
case Operation::kProfileMetadataSet:
return size >= kRequestHeaderSize + 20 &&
size <= kRequestHeaderSize + 20 +
PROFILE_SERVICE_METADATA_MAX_BYTES;
case Operation::kProfileIdentify:
return size == kRequestHeaderSize +
CONTROLLER_IDENTITY_ENCODED_SIZE;
case Operation::kPairingRefresh:
case Operation::kPairingClear:
return size == kRequestHeaderSize;
@ -271,15 +278,20 @@ size_t encode_profile_list(const ProfileServiceListSnapshot& snapshot,
payload[0] = snapshot.count;
size_t offset = 1;
for (uint8_t index = 0; index < snapshot.count; ++index) {
const size_t alias_size = strlen(snapshot.rows[index].alias);
if (!controller_identity_encode(snapshot.rows[index].identity,
&payload[offset],
CONTROLLER_IDENTITY_ENCODED_SIZE) ||
snapshot.rows[index].active_profile >=
CONTROLLER_PROFILE_COUNT) {
CONTROLLER_PROFILE_COUNT ||
alias_size > PROFILE_SERVICE_METADATA_MAX_BYTES) {
return 0;
}
payload[offset + 14] = snapshot.rows[index].active_profile;
offset += 16;
payload[offset + 16] = static_cast<uint8_t>(alias_size);
memcpy(&payload[offset + 17], snapshot.rows[index].alias,
alias_size);
offset += kProfileListRowSize;
}
return encode_response(
Operation::kProfileList, profile_service_status(snapshot.metadata),
@ -306,38 +318,30 @@ size_t encode_profile_playtest(
write_u16(&payload[2], snapshot.physical_button_mask);
write_u32(&payload[4], snapshot.connection_generation);
write_u32(&payload[8], snapshot.state_generation);
write_u16(
&payload[26],
static_cast<uint16_t>(snapshot.state.left_stick_x));
write_u16(
&payload[28],
static_cast<uint16_t>(snapshot.state.left_stick_y));
write_u16(
&payload[30],
static_cast<uint16_t>(snapshot.state.right_stick_x));
write_u16(
&payload[32],
static_cast<uint16_t>(snapshot.state.right_stick_y));
write_u16(&payload[26],
static_cast<uint16_t>(snapshot.state.left_stick_x));
write_u16(&payload[28],
static_cast<uint16_t>(snapshot.state.left_stick_y));
write_u16(&payload[30],
static_cast<uint16_t>(snapshot.state.right_stick_x));
write_u16(&payload[32],
static_cast<uint16_t>(snapshot.state.right_stick_y));
write_u16(&payload[34], snapshot.state.left_trigger);
write_u16(&payload[36], snapshot.state.right_trigger);
payload[38] = snapshot.state.motion_sample_count;
payload[39] = snapshot.battery;
payload[40] = snapshot.capabilities;
if (snapshot.state.motion_sample_count != 0) {
payload[0] |= 2;
const ControllerMotionSample& motion =
snapshot.state.motion_samples[
snapshot.state.motion_sample_count - 1u];
write_u16(&payload[40],
static_cast<uint16_t>(motion.accel_x));
write_u16(&payload[42],
static_cast<uint16_t>(motion.accel_y));
write_u16(&payload[44],
static_cast<uint16_t>(motion.accel_z));
write_u16(&payload[46],
static_cast<uint16_t>(motion.gyro_x));
write_u16(&payload[48],
static_cast<uint16_t>(motion.gyro_y));
write_u16(&payload[50],
static_cast<uint16_t>(motion.gyro_z));
write_u16(&payload[42], static_cast<uint16_t>(motion.accel_x));
write_u16(&payload[44], static_cast<uint16_t>(motion.accel_y));
write_u16(&payload[46], static_cast<uint16_t>(motion.accel_z));
write_u16(&payload[48], static_cast<uint16_t>(motion.gyro_x));
write_u16(&payload[50], static_cast<uint16_t>(motion.gyro_y));
write_u16(&payload[52], static_cast<uint16_t>(motion.gyro_z));
}
}
return encode_response(
@ -368,6 +372,41 @@ size_t encode_profile_read(const ProfileServiceSelectedSnapshot& snapshot,
payload, payload_size, output, output_size);
}
size_t encode_profile_metadata(
const ProfileServiceMetadataSnapshot& snapshot,
uint8_t* output, size_t output_size) {
Status status = profile_service_status(snapshot.metadata);
if (status == Status::kOk) {
status = transaction_status(snapshot.status);
}
uint8_t payload[kProfileMetadataPayloadSize]{};
if (snapshot.valid) {
const char* values[CONTROLLER_PROFILE_COUNT + 1] = {
snapshot.alias,
snapshot.profile_names[0], snapshot.profile_names[1],
snapshot.profile_names[2], snapshot.profile_names[3],
snapshot.profile_names[4], snapshot.profile_names[5],
snapshot.profile_names[6], snapshot.profile_names[7],
};
for (size_t index = 0;
index < CONTROLLER_PROFILE_COUNT + 1; ++index) {
const size_t size = strlen(values[index]);
if (size > PROFILE_SERVICE_METADATA_MAX_BYTES) {
return 0;
}
const size_t offset =
index * (PROFILE_SERVICE_METADATA_MAX_BYTES + 1);
payload[offset] = static_cast<uint8_t>(size);
memcpy(&payload[offset + 1], values[index], size);
}
}
return encode_response(
Operation::kProfileMetadataRead, status, 0,
kProfileMetadataSchemaVersion, snapshot.metadata.generation,
payload, snapshot.valid ? sizeof(payload) : 0,
output, output_size);
}
size_t encode_profile_transaction(
const ProfileServiceTransactionSnapshot& snapshot,
uint8_t* output, size_t output_size) {
@ -585,6 +624,41 @@ bool process_out_request() {
status == ConfigurationTransactionStatus::kUnchanged ||
status == ConfigurationTransactionStatus::kCommitted;
}
case Operation::kProfileMetadataSet: {
const uint32_t transaction_id =
static_cast<uint32_t>(payload[0]) |
(static_cast<uint32_t>(payload[1]) << 8) |
(static_cast<uint32_t>(payload[2]) << 16) |
(static_cast<uint32_t>(payload[3]) << 24);
ControllerIdentity identity{};
const uint8_t profile_index = payload[18];
const size_t value_size = payload[19];
if (transaction_id == 0 ||
(profile_index != CONTROLLER_PROFILE_ALL &&
profile_index >= CONTROLLER_PROFILE_COUNT) ||
value_size > PROFILE_SERVICE_METADATA_MAX_BYTES ||
request.payload_size != 20 + value_size ||
!controller_identity_decode(
&payload[4], CONTROLLER_IDENTITY_ENCODED_SIZE,
&identity)) {
return false;
}
const ConfigurationTransactionStatus status =
profile_service_set_metadata(
transaction_id, identity, profile_index,
reinterpret_cast<const char*>(&payload[20]),
value_size);
return status == ConfigurationTransactionStatus::kPending ||
status == ConfigurationTransactionStatus::kUnchanged ||
status == ConfigurationTransactionStatus::kCommitted;
}
case Operation::kProfileIdentify: {
ControllerIdentity identity{};
return controller_identity_decode(
payload, CONTROLLER_IDENTITY_ENCODED_SIZE,
&identity) &&
bluepad32_input_backend_identify(identity);
}
case Operation::kPairingRefresh:
bluepad32_input_backend_request_pairing_snapshot();
return true;
@ -706,6 +780,13 @@ bool usb_configuration_management_vendor_control(
selected_slot, playtest, response, sizeof(response));
break;
}
case Operation::kProfileMetadataRead: {
ProfileServiceMetadataSnapshot snapshot{};
profile_service_metadata_snapshot(&snapshot);
response_size = encode_profile_metadata(
snapshot, response, sizeof(response));
break;
}
case Operation::kProfileTransactionStatus: {
ProfileServiceTransactionSnapshot snapshot{};
profile_service_transaction_snapshot(&snapshot);

View file

@ -18,16 +18,22 @@ constexpr size_t kRequestHeaderSize = 16;
constexpr size_t kResponseHeaderSize = 20;
constexpr size_t kPairingRecordSize = 8;
constexpr size_t kPairingPayloadHeaderSize = 4;
constexpr size_t kMaximumRequestSize = 64;
constexpr size_t kMaximumRequestSize = 80;
constexpr size_t kProfileListRowSize =
16 + PROFILE_SERVICE_METADATA_MAX_BYTES + 1;
constexpr size_t kProfileListPayloadSize =
1 + PROFILE_SERVICE_LIST_CAPACITY * 16;
constexpr uint16_t kProfilePlaytestSchemaVersion = 1;
constexpr size_t kProfilePlaytestPayloadSize = 52;
1 + PROFILE_SERVICE_LIST_CAPACITY * kProfileListRowSize;
constexpr uint16_t kProfilePlaytestSchemaVersion = 2;
constexpr size_t kProfilePlaytestPayloadSize = 54;
constexpr size_t kProfileMetadataPayloadSize =
(CONTROLLER_PROFILE_COUNT + 1) *
(PROFILE_SERVICE_METADATA_MAX_BYTES + 1);
constexpr uint16_t kProfileMetadataSchemaVersion = 1;
constexpr size_t kMaximumResponseSize =
kResponseHeaderSize + kProfileListPayloadSize;
constexpr size_t kMaximumChunkSize =
kMaximumRequestSize - kRequestHeaderSize - 8;
static_assert(kMaximumResponseSize == 293,
static_assert(kMaximumResponseSize == 837,
"profile list no longer fits the EP0 response buffer");
enum class Operation : uint8_t {
@ -55,6 +61,9 @@ enum class Operation : uint8_t {
kProfileActivate = 0x37,
kProfileTransactionStatus = 0x38,
kProfilePlaytest = 0x39,
kProfileMetadataRead = 0x3a,
kProfileMetadataSet = 0x3b,
kProfileIdentify = 0x3c,
};
enum class Status : uint8_t {
@ -93,6 +102,9 @@ size_t encode_profile_playtest(
size_t encode_profile_transaction(
const ProfileServiceTransactionSnapshot& snapshot,
uint8_t* output, size_t output_size);
size_t encode_profile_metadata(
const ProfileServiceMetadataSnapshot& snapshot,
uint8_t* output, size_t output_size);
} // namespace UsbConfigurationManagement

View file

@ -29,8 +29,8 @@ REQUEST_INDEX = 0x0001
PROTOCOL_VERSION = 1
REQUEST_HEADER_SIZE = 16
RESPONSE_HEADER_SIZE = 20
MAXIMUM_REQUEST_SIZE = 64
MAXIMUM_RESPONSE_SIZE = 293
MAXIMUM_REQUEST_SIZE = 80
MAXIMUM_RESPONSE_SIZE = 837
MAXIMUM_CHUNK_SIZE = 40
USB_TIMEOUT_MS = 1000
DEFAULT_OPERATION_TIMEOUT_SECONDS = 15.0
@ -60,6 +60,9 @@ OP_PROFILE_RESET = 0x36
OP_PROFILE_ACTIVATE = 0x37
OP_PROFILE_TRANSACTION_STATUS = 0x38
OP_PROFILE_PLAYTEST = 0x39
OP_PROFILE_METADATA_READ = 0x3A
OP_PROFILE_METADATA_SET = 0x3B
OP_PROFILE_IDENTIFY = 0x3C
STATUS_OK = 0
STATUS_PENDING = 1
@ -110,7 +113,7 @@ PROFILE_CAPACITY = 8
PROFILE_IDENTITY_CAPACITY = 16
PROFILE_LIST_CAPACITY = PROFILE_IDENTITY_CAPACITY + 1
CONTROLLER_IDENTITY_SIZE = 14
PROFILE_LIST_ROW_SIZE = 16
PROFILE_LIST_ROW_SIZE = 48
PROFILE_NONE_BUTTON = 0xFF
PROFILE_MACRO_COUNT = 4
PROFILE_MACRO_STEP_CAPACITY = 16
@ -121,9 +124,13 @@ PROFILE_MACRO_STEP_SIZE = 19
PROFILE_MAXIMUM_WAIT_MS = 10000
PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD = 0x8000
PROFILE_DEFAULT_DIGITAL_THRESHOLD = 22934
PROFILE_PLAYTEST_SCHEMA_VERSION = 1
PROFILE_PLAYTEST_SIZE = 52
PROFILE_PLAYTEST_SCHEMA_VERSION = 2
PROFILE_PLAYTEST_SIZE = 54
PROFILE_PLAYTEST_SLOT_COUNT = 4
PROFILE_METADATA_SCHEMA_VERSION = 1
PROFILE_METADATA_MAX_BYTES = 31
PROFILE_METADATA_VALUE_SIZE = 32
PROFILE_METADATA_SIZE = 288
LOGICAL_BUTTONS = (
"south",
@ -497,6 +504,7 @@ class ControllerIdentity:
class ProfileListEntry:
identity: ControllerIdentity
active_profile_index: int
alias: str = ""
def __post_init__(self) -> None:
if not isinstance(self.identity, ControllerIdentity):
@ -509,6 +517,35 @@ class ProfileListEntry:
0,
PROFILE_CAPACITY - 1,
)
if type(self.alias) is not str or "\x00" in self.alias or len(
self.alias.encode("utf-8")
) > PROFILE_METADATA_MAX_BYTES:
raise ConfigManagerError(
"controller alias must contain at most 31 UTF-8 bytes"
)
@dataclass(frozen=True)
class ProfileMetadata:
alias: str
profile_names: tuple[str, ...]
def __post_init__(self) -> None:
if len(self.profile_names) != PROFILE_CAPACITY:
raise ConfigManagerError("profile metadata must contain eight names")
for label, value in (
("controller alias", self.alias),
*(
(f"profile {index + 1} name", name)
for index, name in enumerate(self.profile_names)
),
):
if type(value) is not str or "\x00" in value or len(
value.encode("utf-8")
) > PROFILE_METADATA_MAX_BYTES:
raise ConfigManagerError(
f"{label} must contain at most 31 UTF-8 bytes"
)
@dataclass(frozen=True)
@ -522,6 +559,8 @@ class ProfilePlaytest:
left_stick: tuple[int, int]
right_stick: tuple[int, int]
triggers: tuple[int, int]
battery: int
capabilities: int
motion: tuple[int, int, int, int, int, int] | None
def to_json_object(self) -> dict[str, Any]:
@ -533,6 +572,7 @@ class ProfilePlaytest:
"identity": (
{
"address": self.identity.address_text,
"transport": self.identity.transport_text,
"vendor_id": self.identity.vendor_id,
"product_id": self.identity.product_id,
}
@ -552,6 +592,16 @@ class ProfilePlaytest:
"left": self.triggers[0],
"right": self.triggers[1],
},
"battery": (
round((self.battery - 1) / 250 * 100)
if self.battery != 0
else None
),
"capabilities": [
name for bit, name in enumerate(
("rumble", "lightbar", "player_leds", "motion")
) if self.capabilities & (1 << bit)
],
"motion": (
{
"accel": list(self.motion[:3]),
@ -2060,8 +2110,18 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]:
envelope.payload[offset : offset + CONTROLLER_IDENTITY_SIZE]
)
active_profile_index = envelope.payload[offset + 14]
if envelope.payload[offset + 15] != 0:
raise ConfigManagerError("profile-list reserved field is nonzero")
alias_size = envelope.payload[offset + 16]
alias_payload = envelope.payload[offset + 17 : offset + 48]
if (
envelope.payload[offset + 15] != 0
or alias_size > PROFILE_METADATA_MAX_BYTES
or any(alias_payload[alias_size:])
):
raise ConfigManagerError("invalid profile-list metadata")
try:
alias = alias_payload[:alias_size].decode("utf-8")
except UnicodeDecodeError as exc:
raise ConfigManagerError("invalid profile-list alias") from exc
if index == 0 and not identity.is_global_fallback:
raise ConfigManagerError(
"profile list does not begin with global fallback"
@ -2071,7 +2131,7 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]:
if identity in identities:
raise ConfigManagerError("duplicate identity in profile list")
identities.add(identity)
entries.append(ProfileListEntry(identity, active_profile_index))
entries.append(ProfileListEntry(identity, active_profile_index, alias))
return tuple(entries)
@ -2124,6 +2184,93 @@ def read_profile(
return read_selected_profile(device)
def _decode_profile_metadata_value(
payload: bytes, offset: int, label: str
) -> str:
size = payload[offset]
encoded = payload[offset + 1 : offset + PROFILE_METADATA_VALUE_SIZE]
if size > PROFILE_METADATA_MAX_BYTES or any(encoded[size:]):
raise ConfigManagerError(f"invalid {label} metadata")
try:
return encoded[:size].decode("utf-8")
except UnicodeDecodeError as exc:
raise ConfigManagerError(f"invalid {label} UTF-8") from exc
def parse_profile_metadata(envelope: Envelope) -> ProfileMetadata:
_raise_status(envelope)
if (
envelope.schema_version != PROFILE_METADATA_SCHEMA_VERSION
or len(envelope.payload) != PROFILE_METADATA_SIZE
):
raise ConfigManagerError("invalid profile metadata payload")
alias = _decode_profile_metadata_value(
envelope.payload, 0, "controller alias"
)
names = tuple(
_decode_profile_metadata_value(
envelope.payload,
(index + 1) * PROFILE_METADATA_VALUE_SIZE,
f"profile {index + 1} name",
)
for index in range(PROFILE_CAPACITY)
)
return ProfileMetadata(alias, names)
def read_selected_profile_metadata(device: UsbDevice) -> ProfileMetadata:
return parse_profile_metadata(
_control_in(device, OP_PROFILE_METADATA_READ)
)
def read_profile_metadata(
device: UsbDevice,
identity: ControllerIdentity,
profile_index: int = 0,
) -> ProfileMetadata:
select_profile(device, identity, profile_index)
return read_selected_profile_metadata(device)
def set_profile_metadata(
device: UsbDevice,
identity: ControllerIdentity,
profile_index: int,
value: str,
timeout: float,
) -> TransactionStatus:
if profile_index != PROFILE_NONE_BUTTON:
_validate_profile_index(profile_index)
if type(value) is not str or "\x00" in value:
raise ConfigManagerError("profile metadata must be text")
encoded = value.encode("utf-8")
if len(encoded) > PROFILE_METADATA_MAX_BYTES:
raise ConfigManagerError(
"profile metadata must contain at most 31 UTF-8 bytes"
)
transaction_id = _host_transaction_id()
_control_out(
device,
OP_PROFILE_METADATA_SET,
struct.pack("<I", transaction_id)
+ identity.to_bytes()
+ bytes((profile_index, len(encoded)))
+ encoded,
)
return _wait_for_profile_transaction(
device, transaction_id, timeout
)
def identify_controller(
device: UsbDevice, identity: ControllerIdentity
) -> None:
if identity.is_global_fallback:
raise ConfigManagerError("default profile has no controller to identify")
_control_out(device, OP_PROFILE_IDENTIFY, identity.to_bytes())
def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest:
_raise_status(envelope)
if (
@ -2133,7 +2280,7 @@ def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest:
raise ConfigManagerError("invalid profile playtest payload")
payload = envelope.payload
flags = payload[0]
if flags & ~0x03 or flags != envelope.flags or payload[39] != 0:
if flags & ~0x03 or flags != envelope.flags or payload[41] != 0:
raise ConfigManagerError("invalid profile playtest flags")
connected = bool(flags & 0x01)
has_motion = bool(flags & 0x02)
@ -2143,7 +2290,7 @@ def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest:
raise ConfigManagerError("invalid disconnected playtest payload")
return ProfilePlaytest(
False, None, 0, 0, None, 0,
(0, 0), (0, 0), (0, 0), None,
(0, 0), (0, 0), (0, 0), 0, 0, None,
)
if (
payload[1] >= PROFILE_PLAYTEST_SLOT_COUNT
@ -2155,7 +2302,7 @@ def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest:
left_x, left_y, right_x, right_y, left_trigger, right_trigger = (
struct.unpack_from("<hhhhHH", payload, 26)
)
motion_values = struct.unpack_from("<hhhhhh", payload, 40)
motion_values = struct.unpack_from("<hhhhhh", payload, 42)
if not has_motion and any(motion_values):
raise ConfigManagerError("playtest motion sample was not declared")
return ProfilePlaytest(
@ -2168,6 +2315,8 @@ def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest:
left_stick=(left_x, left_y),
right_stick=(right_x, right_y),
triggers=(left_trigger, right_trigger),
battery=payload[39],
capabilities=payload[40],
motion=motion_values if has_motion else None,
)

View file

@ -95,7 +95,11 @@ def _controller_presentation(
return {"model": "8BitDo controller", "style": "switch"}
return {"model": "Connected controller", "style": "generic"}
def _controller_label(identity: config_manager.ControllerIdentity) -> str:
def _controller_label(
identity: config_manager.ControllerIdentity, alias: str = ""
) -> str:
if alias:
return alias
if identity.is_global_fallback:
return "Default profile"
presentation = _controller_presentation(identity)
@ -218,26 +222,60 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
def do_PUT(self) -> None:
if not self._allow_mutation():
return
selection = self._parse_profile_path(urlsplit(self.path).path)
if selection is None or selection[2] is not None:
path = urlsplit(self.path).path
identity_selection = self._parse_identity_path(path)
if identity_selection is not None:
identity_index, action = identity_selection
if action == "alias":
self._api_call(lambda: self._set_alias(identity_index))
return
selection = self._parse_profile_path(path)
if selection is None:
self.send_error(HTTPStatus.NOT_FOUND)
return
identity_index, profile_index, _ = selection
self._api_call(
lambda: self._write_profile(identity_index, profile_index)
)
identity_index, profile_index, action = selection
if action is None:
self._api_call(
lambda: self._write_profile(identity_index, profile_index)
)
return
if action == "name":
self._api_call(
lambda: self._set_profile_name(
identity_index, profile_index
)
)
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_POST(self) -> None:
if not self._allow_mutation():
return
selection = self._parse_profile_path(urlsplit(self.path).path)
if selection is None or selection[2] != "activate":
self.send_error(HTTPStatus.NOT_FOUND)
return
identity_index, profile_index, _ = selection
self._api_call(
lambda: self._activate_profile(identity_index, profile_index)
)
path = urlsplit(self.path).path
identity_selection = self._parse_identity_path(path)
if identity_selection is not None:
identity_index, action = identity_selection
if action == "identify":
self._api_call(lambda: self._identify(identity_index))
return
selection = self._parse_profile_path(path)
if selection is not None:
identity_index, profile_index, action = selection
if action == "activate":
self._api_call(
lambda: self._activate_profile(
identity_index, profile_index
)
)
return
if action == "copy":
self._api_call(
lambda: self._copy_profile(
identity_index, profile_index
)
)
return
self.send_error(HTTPStatus.NOT_FOUND)
def _local_host_header(self) -> bool:
host = self.headers.get("Host", "")
@ -279,6 +317,21 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
action = parts[4] if len(parts) == 5 else None
return identity_index, profile_number - 1, action
@staticmethod
def _parse_identity_path(path: str) -> tuple[int, str] | None:
parts = path.strip("/").split("/")
if len(parts) != 4 or parts[:2] != ["api", "identities"]:
return None
try:
identity_index = int(parts[2])
except ValueError:
return None
return (
(identity_index, parts[3])
if identity_index >= 0
else None
)
def _entries_and_identity(
self, device: config_manager.UsbDevice, identity_index: int
) -> tuple[
@ -300,10 +353,11 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
"identities": [
{
"index": index,
"label": _controller_label(entry.identity),
"label": _controller_label(entry.identity, entry.alias),
"key": entry.identity.to_bytes().hex(),
"active_profile": entry.active_profile_index + 1,
"controller": _controller_presentation(entry.identity),
"alias": entry.alias,
}
for index, entry in enumerate(entries)
]
@ -315,8 +369,12 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
device = self.profile_server.find_device()
entries, identity = self._entries_and_identity(device, identity_index)
profile = config_manager.read_profile(device, identity, profile_index)
metadata = config_manager.read_selected_profile_metadata(device)
return {
"profile": profile.to_json_object(),
"name": metadata.profile_names[profile_index],
"profile_names": list(metadata.profile_names),
"alias": metadata.alias,
"active": entries[identity_index].active_profile_index == profile_index,
}
@ -349,6 +407,14 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
)
return self.rfile.read(length).decode("utf-8")
def _read_json_object(self) -> dict[str, Any]:
value = json.loads(self._read_json_body())
if not isinstance(value, dict):
raise config_manager.ConfigManagerError(
"request body must be a JSON object"
)
return value
def _write_profile(
self, identity_index: int, profile_index: int
) -> dict[str, Any]:
@ -367,6 +433,86 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
"stored_crc": f"{status.stored_crc:08x}",
}
def _set_profile_name(
self, identity_index: int, profile_index: int
) -> dict[str, Any]:
value = self._read_json_object().get("value")
if not isinstance(value, str):
raise config_manager.ConfigManagerError(
"profile name must be text"
)
device = self.profile_server.find_device()
_, identity = self._entries_and_identity(device, identity_index)
status = config_manager.set_profile_metadata(
device, identity, profile_index, value,
self.profile_server.operation_timeout,
)
return {"stored_generation": status.stored_generation}
def _set_alias(self, identity_index: int) -> dict[str, Any]:
value = self._read_json_object().get("value")
if not isinstance(value, str):
raise config_manager.ConfigManagerError(
"controller alias must be text"
)
device = self.profile_server.find_device()
_, identity = self._entries_and_identity(device, identity_index)
status = config_manager.set_profile_metadata(
device, identity, config_manager.PROFILE_NONE_BUTTON,
value, self.profile_server.operation_timeout,
)
return {
"stored_generation": status.stored_generation,
"label": _controller_label(identity, value),
}
def _identify(self, identity_index: int) -> dict[str, Any]:
device = self.profile_server.find_device()
_, identity = self._entries_and_identity(device, identity_index)
config_manager.identify_controller(device, identity)
return {"identified": True}
def _copy_profile(
self, identity_index: int, profile_index: int
) -> dict[str, Any]:
destination = self._read_json_object()
destination_identity_index = destination.get("identity_index")
destination_profile_number = destination.get("profile_number")
if (
type(destination_identity_index) is not int
or type(destination_profile_number) is not int
or not 1 <= destination_profile_number <=
config_manager.PROFILE_CAPACITY
):
raise config_manager.ConfigManagerError(
"copy destination is invalid"
)
device = self.profile_server.find_device()
_, source_identity = self._entries_and_identity(
device, identity_index
)
profile = config_manager.read_profile(
device, source_identity, profile_index
)
metadata = config_manager.read_selected_profile_metadata(device)
_, destination_identity = self._entries_and_identity(
device, destination_identity_index
)
target_profile = destination_profile_number - 1
profile_status = config_manager.write_profile(
device, destination_identity, target_profile, profile,
self.profile_server.operation_timeout,
)
name_status = config_manager.set_profile_metadata(
device, destination_identity, target_profile,
metadata.profile_names[profile_index],
self.profile_server.operation_timeout,
)
return {
"stored_generation": name_status.stored_generation,
"profile_generation": profile_status.stored_generation,
}
def _activate_profile(
self, identity_index: int, profile_index: int
) -> dict[str, Any]:

View file

@ -536,6 +536,33 @@ input[type="range"] { width: 100%; accent-color: var(--blue); }
.toast.show { opacity: 1; transform: translateY(0); }
.toast.error { border-color: var(--rose); }
.identity-tools .field-label { margin-top: 18px; }
.identity-actions { display: flex; gap: 8px; margin-top: 9px; }
.identity-actions .button { flex: 1; padding-inline: 8px; }
.controller-details { min-height: 18px; margin-top: 8px; color: var(--subtle); font-size: 0.67rem; }
.profile-name-editor { display: grid; grid-template-columns: minmax(140px, 240px) auto; align-items: end; gap: 8px; margin-top: 12px; }
.profile-name-editor label { grid-column: 1 / -1; color: var(--muted); font-size: 0.66rem; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; }
.profile-name-editor input { min-height: 40px; padding: 8px 11px; color: var(--ink); border: 1px solid var(--control-line); border-radius: 10px; background: var(--input); }
.panel-heading-actions { display: flex; align-items: flex-end; flex-direction: column; gap: 8px; }
.panel-heading-actions p { max-width: 440px; color: var(--muted); font-size: 0.78rem; line-height: 1.55; text-align: right; }
.section-reset { padding: 0; color: var(--sky); border: 0; background: transparent; font-size: 0.68rem; font-weight: 760; }
.section-reset:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
.curve-editor { display: grid; grid-template-columns: minmax(120px, 0.8fr) minmax(150px, 1.2fr); gap: 14px; margin-bottom: 16px; padding: 12px; border: 1px solid var(--line-soft); border-radius: 13px; background: #0a1120; }
.curve-editor svg { width: 100%; min-height: 110px; overflow: visible; border-left: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.curve-editor path { fill: none; vector-effect: non-scaling-stroke; }
.curve-guide { stroke: var(--subtle); stroke-dasharray: 3 3; opacity: 0.45; }
.curve-line { stroke: var(--blue); stroke-width: 2.5; }
.curve-marker { fill: var(--amber); stroke: #0a1120; stroke-width: 2; opacity: 0; transition: opacity 120ms ease; }
.curve-marker.visible { opacity: 1; }
.curve-controls { display: flex; flex-direction: column; gap: 10px; }
.curve-controls label { color: var(--muted); font-size: 0.67rem; font-weight: 720; }
.curve-controls output { float: right; color: var(--ink); }
.curve-controls input[type="range"] { display: block; margin-top: 8px; }
.copy-side { min-height: 34px; margin-top: auto; padding-block: 6px; }
.copy-dialog { width: min(440px, calc(100% - 28px)); padding: 24px; color: var(--ink); border: 1px solid var(--line); border-radius: 18px; background: var(--surface-solid); box-shadow: var(--shadow); }
.copy-dialog::backdrop { background: rgba(2, 5, 13, 0.76); }
.copy-dialog h3 { margin-bottom: 18px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 22px; }
@media (max-width: 1120px) {
.workspace { grid-template-columns: 250px minmax(0, 1fr); }
.profile-list { grid-template-columns: 1fr; }
@ -558,6 +585,7 @@ input[type="range"] { width: 100%; accent-color: var(--blue); }
.mini-active { margin-left: 0; }
.editor-header, .panel-heading { align-items: flex-start; flex-direction: column; }
.header-actions { justify-content: flex-start; }
.curve-editor { grid-template-columns: 1fr; }
.section-nav { top: 8px; }
.builtin-actions, .analog-grid, .feedback-grid, .macro-controls { grid-template-columns: 1fr; }
.playtest-grid { grid-template-columns: 1fr; }
@ -575,6 +603,9 @@ input[type="range"] { width: 100%; accent-color: var(--blue); }
.connection { max-width: 48%; padding: 7px 9px; font-size: 0.68rem; }
.workspace { width: min(100% - 16px, 480px); }
.sidebar, .panel, .editor-header { border-radius: 17px; }
.profile-name-editor { grid-template-columns: 1fr; }
.panel-heading-actions { align-items: flex-start; }
.panel-heading-actions p { text-align: left; }
.sidebar, .panel { padding: 18px; }
.trigger-meter { grid-template-columns: 25px minmax(100px, 1fr); }
.trigger-meter output { grid-column: 2; text-align: left; }

View file

@ -39,6 +39,15 @@
<label class="field-label" for="identitySelect">Profile owner</label>
<select id="identitySelect" class="select"></select>
<p class="field-help">The default applies when a controller has no dedicated layout.</p>
<div class="identity-tools">
<label class="field-label" for="controllerAlias">Controller alias</label>
<input class="number-input" id="controllerAlias" maxlength="31" placeholder="Optional nickname">
<div class="identity-actions">
<button class="button button-secondary" id="saveAliasButton" type="button">Save alias</button>
<button class="button button-ghost" id="identifyButton" type="button">Identify</button>
</div>
<p class="controller-details" id="controllerDetails"></p>
</div>
<div class="profile-list" id="profileList" aria-label="Profiles"></div>
@ -57,8 +66,17 @@
<span class="active-badge" id="activeBadge" hidden>Active</span>
<span class="dirty-badge" id="dirtyBadge" hidden>Unsaved</span>
</div>
<div class="profile-name-editor">
<label for="profileName">Profile name</label>
<input id="profileName" maxlength="31" placeholder="Optional name">
<button class="button button-secondary" id="saveProfileNameButton" type="button">Rename</button>
</div>
</div>
<div class="header-actions">
<button class="button button-ghost" id="copyProfileButton" type="button">Copy</button>
<button class="button button-ghost" id="exportProfileButton" type="button">Export</button>
<button class="button button-ghost" id="importProfileButton" type="button">Import</button>
<input id="importProfileFile" type="file" accept="application/json,.json" hidden>
<button class="button button-ghost" id="resetDraftButton" type="button">Start from defaults</button>
<button class="button button-secondary" id="activateButton" type="button">Make active</button>
<button class="button button-primary" id="saveButton" type="button">Save to Pico</button>
@ -85,7 +103,7 @@
<p class="eyebrow">01 · Controls</p>
<h3>Button mapping</h3>
</div>
<p>Choose what each physical button should produce.</p>
<div class="panel-heading-actions"><p>Choose what each physical button should produce.</p><button class="section-reset" type="button" data-reset-section="mapping">Reset section</button></div>
</div>
<div class="controller-mapper">
<div class="controller-canvas" id="controllerCanvas" data-style="generic">
@ -149,7 +167,7 @@
<p class="eyebrow">02 · Response</p>
<h3>Sticks &amp; triggers</h3>
</div>
<p>Values use the controller's normalized integer range.</p>
<div class="panel-heading-actions"><p>Values use the controller's normalized integer range.</p><button class="section-reset" type="button" data-reset-section="analog">Reset section</button></div>
</div>
<div class="playtest-panel" id="playtestPanel" data-state="waiting">
<div class="playtest-heading">
@ -231,7 +249,7 @@
<p class="eyebrow">03 · Identity</p>
<h3>Feedback</h3>
</div>
<p>Scale rumble and choose profile-change confirmation.</p>
<div class="panel-heading-actions"><p>Scale rumble and choose profile-change confirmation.</p><button class="section-reset" type="button" data-reset-section="feedback">Reset section</button></div>
</div>
<div class="subpanel" id="rumbleFields"></div>
</section>
@ -242,7 +260,7 @@
<p class="eyebrow">04 · Repeat</p>
<h3>Turbo behavior</h3>
</div>
<p>Off, hold-to-repeat, or press-once Auto Burst per button.</p>
<div class="panel-heading-actions"><p>Off, hold-to-repeat, or press-once Auto Burst per button.</p><button class="section-reset" type="button" data-reset-section="turbo">Reset section</button></div>
</div>
<div class="mapping-grid compact" id="turboFields"></div>
</section>
@ -253,6 +271,7 @@
<p class="eyebrow">05 · Actions</p>
<h3>Bindings &amp; custom macro</h3>
</div>
<div class="panel-heading-actions"><button class="section-reset" type="button" data-reset-section="macro">Reset section</button></div>
</div>
<div class="builtin-actions" id="builtinActions"></div>
<div class="macro-controls" id="macroControls"></div>
@ -271,6 +290,20 @@
</section>
</main>
<dialog class="copy-dialog" id="copyDialog">
<form method="dialog">
<p class="eyebrow">Duplicate profile</p>
<h3>Choose a destination</h3>
<label class="field-label" for="copyIdentity">Controller</label>
<select class="select" id="copyIdentity"></select>
<label class="field-label" for="copySlot">Profile slot</label>
<select class="select" id="copySlot"></select>
<div class="dialog-actions">
<button class="button button-ghost" value="cancel">Cancel</button>
<button class="button button-primary" id="confirmCopyButton" value="default">Copy profile</button>
</div>
</form>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="/playtest.js" defer></script>
<script src="/app.js" defer></script>

View file

@ -8,12 +8,15 @@ const state = {
identityIndex: 0,
profileIndex: 0,
profile: null,
profileNames: Array(8).fill(""),
original: "",
pendingName: false,
active: false,
selectedButton: "south",
selectedMacro: 0,
token: "",
busy: false,
identifyAvailable: false,
adapterConnected: false,
playtestRequestActive: false,
playtestTimer: 0,
@ -27,9 +30,15 @@ const elements = {
identity: document.querySelector("#identitySelect"),
profileList: document.querySelector("#profileList"),
profileOwner: document.querySelector("#profileOwner"),
controllerAlias: document.querySelector("#controllerAlias"),
controllerDetails: document.querySelector("#controllerDetails"),
saveAlias: document.querySelector("#saveAliasButton"),
identify: document.querySelector("#identifyButton"),
profileTitle: document.querySelector("#profileTitle"),
activeBadge: document.querySelector("#activeBadge"),
dirtyBadge: document.querySelector("#dirtyBadge"),
profileName: document.querySelector("#profileName"),
saveProfileName: document.querySelector("#saveProfileNameButton"),
loading: document.querySelector("#loadingCard"),
form: document.querySelector("#profileForm"),
controllerCanvas: document.querySelector("#controllerCanvas"),
@ -61,6 +70,14 @@ const elements = {
activate: document.querySelector("#activateButton"),
save: document.querySelector("#saveButton"),
addMacroStep: document.querySelector("#addMacroStepButton"),
copyProfile: document.querySelector("#copyProfileButton"),
exportProfile: document.querySelector("#exportProfileButton"),
importProfile: document.querySelector("#importProfileButton"),
importProfileFile: document.querySelector("#importProfileFile"),
copyDialog: document.querySelector("#copyDialog"),
copyIdentity: document.querySelector("#copyIdentity"),
copySlot: document.querySelector("#copySlot"),
confirmCopy: document.querySelector("#confirmCopyButton"),
toast: document.querySelector("#toast"),
};
@ -146,7 +163,30 @@ function updateTriggerPlaytest(side, raw, output, config) {
meter.querySelector("output").textContent = `${raw} → ${output}`;
}
function updateCurveMarker(group, side, input) {
const panel = document.querySelector(
`[data-analog-group="${group}"][data-analog-side="${side}"]`
);
if (!panel) return;
const normalized = Math.max(0, Math.min(1, input));
const config = state.profile[group][side];
const output = ProfilePlaytestMath.transformTrigger(
Math.round(normalized * 65535),
{
lower_deadzone: 0,
upper_saturation: 65535,
curve_q8_8: config.curve_q8_8,
}
) / 65535;
const marker = panel.querySelector(".curve-marker");
marker.setAttribute("cx", String(normalized * 100));
marker.setAttribute("cy", String(60 - output * 60));
marker.classList.add("visible");
}
function clearPlaytest(message, stateName = "waiting") {
state.identifyAvailable = false;
elements.identify.disabled = true;
elements.playtestPanel.dataset.state = stateName;
elements.playtestStatus.textContent =
stateName === "error" ? "Unavailable" : "Waiting";
@ -155,6 +195,8 @@ function clearPlaytest(message, stateName = "waiting") {
elements.playtestHelp.textContent = message;
elements.controllerHotspots.querySelectorAll(".pressed")
.forEach((button) => button.classList.remove("pressed"));
document.querySelectorAll(".curve-marker.visible")
.forEach((marker) => marker.classList.remove("visible"));
}
function renderPlaytest(sample) {
@ -188,6 +230,16 @@ function renderPlaytest(sample) {
"right", sample.triggers.right, outputRightTrigger,
state.profile.triggers.right
);
updateCurveMarker(
"sticks", "left",
Math.max(Math.abs(rawLeft.x), Math.abs(rawLeft.y)) / 32767
);
updateCurveMarker(
"sticks", "right",
Math.max(Math.abs(rawRight.x), Math.abs(rawRight.y)) / 32767
);
updateCurveMarker("triggers", "left", sample.triggers.left / 65535);
updateCurveMarker("triggers", "right", sample.triggers.right / 65535);
const style = sample.controller?.style || currentControllerStyle();
elements.playtestLeftTriggerLabel.textContent =
controlLabel("left_trigger", style);
@ -204,7 +256,25 @@ function renderPlaytest(sample) {
});
elements.playtestPanel.dataset.state = "live";
elements.playtestStatus.textContent = "Live";
const owner = currentOwner();
const sourceAddress = sample.identity?.address
?.replaceAll(":", "").toLowerCase();
state.identifyAvailable = Boolean(
owner && owner.index !== 0 && sourceAddress &&
owner.key.includes(sourceAddress) &&
sample.capabilities?.some((capability) =>
["rumble", "lightbar", "player_leds"].includes(capability)
)
);
elements.identify.disabled = state.busy || !state.identifyAvailable;
elements.playtestTitle.textContent = sample.label || "Connected controller";
const details = [
sample.controller?.model,
sample.identity?.transport,
sample.battery === null ? null : `${sample.battery}% battery`,
...(sample.capabilities || []).map(label),
].filter(Boolean);
elements.controllerDetails.textContent = details.join(" · ");
elements.playtestHelp.textContent =
"Yellow is raw input; blue is the output produced by this unsaved draft.";
}
@ -248,7 +318,8 @@ async function pollPlaytest() {
}
function isDirty() {
return state.profile !== null && canonical(state.profile) !== state.original;
return state.profile !== null &&
(canonical(state.profile) !== state.original || state.pendingName);
}
function setConnection(mode, text) {
@ -299,6 +370,16 @@ function setBusy(busy) {
elements.resetDraft.disabled = busy;
elements.refresh.disabled = busy;
elements.identity.disabled = busy;
elements.identify.disabled = busy || !state.identifyAvailable;
elements.profileName.disabled = busy;
elements.saveProfileName.disabled = busy || !state.adapterConnected;
elements.controllerAlias.disabled =
busy || currentOwner()?.index === 0;
elements.saveAlias.disabled =
busy || !state.adapterConnected || currentOwner()?.index === 0;
elements.copyProfile.disabled = busy || !state.adapterConnected;
elements.importProfile.disabled = busy;
elements.exportProfile.disabled = busy;
if (state.schema && state.profile) {
const macro = state.profile.macros[state.selectedMacro];
const totalSteps = state.profile.macros.reduce(
@ -435,7 +516,7 @@ function renderProfileList() {
const active = owner && owner.active_profile === index + 1;
return `
<button class="profile-button${selected ? " selected" : ""}" type="button" data-profile-index="${index}">
<span class="profile-number"><span>${index + 1}</span>Profile ${index + 1}</span>
<span class="profile-number"><span>${index + 1}</span>${escapeHtml(state.profileNames[index] || `Profile ${index + 1}`)}</span>
${active ? '<span class="mini-active">Active</span>' : ""}
</button>`;
}).join("");
@ -601,29 +682,49 @@ const analogDefinitions = [
["center_y", "Center Y", -32768, 32767],
["inner_deadzone", "Inner deadzone", 0, 32767],
["outer_saturation", "Outer saturation", 1, 32767],
["curve_q8_8", "Curve (Q8.8)", 1, 65535],
]],
["sticks", "right", "Right stick", [
["center_x", "Center X", -32768, 32767],
["center_y", "Center Y", -32768, 32767],
["inner_deadzone", "Inner deadzone", 0, 32767],
["outer_saturation", "Outer saturation", 1, 32767],
["curve_q8_8", "Curve (Q8.8)", 1, 65535],
]],
["triggers", "left", "Left trigger", [
["lower_deadzone", "Lower deadzone", 0, 65535],
["upper_saturation", "Upper saturation", 1, 65535],
["curve_q8_8", "Curve (Q8.8)", 1, 65535],
["digital_threshold", "Digital threshold", 0, 65535],
]],
["triggers", "right", "Right trigger", [
["lower_deadzone", "Lower deadzone", 0, 65535],
["upper_saturation", "Upper saturation", 1, 65535],
["curve_q8_8", "Curve (Q8.8)", 1, 65535],
["digital_threshold", "Digital threshold", 0, 65535],
]],
];
const curvePresets = {
quick: 128,
linear: 256,
precise: 512,
deliberate: 768,
};
function curvePreset(value) {
return Object.entries(curvePresets)
.find(([, preset]) => preset === value)?.[0] || "custom";
}
function curvePath(curve) {
return Array.from({ length: 21 }, (_, index) => {
const input = Math.round(index / 20 * 65535);
const output = ProfilePlaytestMath.transformTrigger(input, {
lower_deadzone: 0,
upper_saturation: 65535,
curve_q8_8: curve,
});
return `${index === 0 ? "M" : "L"} ${index * 5} ${60 - output / 65535 * 60}`;
}).join(" ");
}
function renderAnalog() {
elements.analog.innerHTML = analogDefinitions.map(([group, side, title, fields]) => {
const config = state.profile[group][side];
@ -636,8 +737,27 @@ function renderAnalog() {
</label>`).join("")}
</div>` : "";
return `
<div class="subpanel">
<div class="subpanel" data-analog-group="${group}" data-analog-side="${side}">
<div class="subpanel-heading"><h4>${title}</h4><span>${group === "sticks" ? "Signed axes · 32767 full scale" : "Unsigned · 65535 full scale"}</span></div>
<div class="curve-editor">
<svg viewBox="0 0 100 60" role="img" aria-label="${title} response curve">
<path class="curve-guide" d="M 0 60 L 100 0"></path>
<path class="curve-line" d="${curvePath(config.curve_q8_8)}"></path>
<circle class="curve-marker" cx="0" cy="60" r="3"></circle>
</svg>
<div class="curve-controls">
<label>Response preset
<select class="select" data-kind="curve-preset" data-group="${group}" data-side="${side}">
${Object.keys(curvePresets).map((preset) => `<option value="${preset}"${curvePreset(config.curve_q8_8) === preset ? " selected" : ""}>${label(preset)}</option>`).join("")}
<option value="custom"${curvePreset(config.curve_q8_8) === "custom" ? " selected" : ""}>Custom</option>
</select>
</label>
<label>Fine adjustment <output>${config.curve_q8_8}</output>
<input type="range" min="1" max="2048" step="1" value="${config.curve_q8_8}" data-kind="curve-range" data-group="${group}" data-side="${side}">
</label>
<button class="button button-ghost copy-side" type="button" data-copy-analog="${group}" data-source-side="${side}">Apply to ${side === "left" ? "right" : "left"}</button>
</div>
</div>
<div class="number-grid">
${fields.map(([field, fieldLabel, min, max]) => `
<div class="number-field${field === "digital_threshold" ? " wide" : ""}">
@ -840,10 +960,22 @@ function renderMacro() {
}
function renderEditor() {
elements.profileTitle.textContent = `Profile ${state.profileIndex + 1}`;
elements.profileTitle.textContent =
state.profileNames[state.profileIndex] || `Profile ${state.profileIndex + 1}`;
elements.activeBadge.hidden = !state.active;
elements.activate.disabled =
!state.adapterConnected || state.busy || state.active;
elements.profileName.value = state.profileNames[state.profileIndex] || "";
const owner = currentOwner();
elements.controllerAlias.value = owner?.alias || "";
elements.identify.disabled =
state.busy || !state.identifyAvailable;
elements.controllerAlias.disabled = !owner || owner.index === 0;
elements.saveAlias.disabled =
!state.adapterConnected || !owner || owner.index === 0;
elements.controllerDetails.textContent = owner && owner.index !== 0
? owner.controller.model
: "Used when no dedicated controller profile exists.";
renderIdentities();
renderProfileList();
renderButtonMap();
@ -867,6 +999,10 @@ async function loadProfile() {
const payload = await api(`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}`);
state.profile = payload.profile;
state.original = canonical(payload.profile);
state.pendingName = false;
state.profileNames = payload.profile_names;
const owner = currentOwner();
if (owner) owner.alias = payload.alias;
state.active = payload.active;
setConnection("ready", "Adapter connected");
renderEditor();
@ -910,6 +1046,22 @@ function handleFormChange(event) {
state.profile[target.dataset.group][target.dataset.side][target.dataset.field] = Number(target.value);
} else if (kind === "analog-bool") {
state.profile[target.dataset.group][target.dataset.side][target.dataset.field] = target.checked;
} else if (kind === "curve-preset") {
if (target.value !== "custom") {
state.profile[target.dataset.group][target.dataset.side].curve_q8_8 =
curvePresets[target.value];
renderAnalog();
}
} else if (kind === "curve-range") {
const config = state.profile[target.dataset.group][target.dataset.side];
config.curve_q8_8 = Number(target.value);
const editor = target.closest(".curve-editor");
editor.querySelector("output").textContent = target.value;
editor.querySelector(".curve-line").setAttribute(
"d", curvePath(config.curve_q8_8)
);
editor.querySelector("[data-kind='curve-preset']").value =
curvePreset(config.curve_q8_8);
} else if (kind === "rumble-range") {
state.profile.rumble[target.dataset.field] = Number(target.value);
document.querySelector(`#rumble-${target.dataset.field}-value`).value = target.value;
@ -926,9 +1078,7 @@ function handleFormChange(event) {
profile_switch: state.schema.default_switching_chord,
motion_toggle: state.schema.default_motion_toggle_chord,
}[target.dataset.action];
const current = owner[field].length === 0 && defaults
? defaults
: owner[field];
const current = owner[field].length === 0 && defaults ? defaults : owner[field];
const selected = new Set(current);
target.checked ? selected.add(target.dataset.name) : selected.delete(target.dataset.name);
owner[field] = state.schema.controls.filter((name) => selected.has(name));
@ -960,6 +1110,18 @@ function handleFormChange(event) {
updateDirtyState();
}
elements.analog.addEventListener("click", (event) => {
const button = event.target.closest("[data-copy-analog]");
if (!button) return;
const group = button.dataset.copyAnalog;
const source = button.dataset.sourceSide;
const destination = source === "left" ? "right" : "left";
state.profile[group][destination] = clone(state.profile[group][source]);
renderAnalog();
updateDirtyState();
toast(`${label(source)} ${label(group)} settings applied to ${destination}.`);
});
elements.form.addEventListener("input", handleFormChange);
elements.form.addEventListener("change", handleFormChange);
@ -973,6 +1135,7 @@ elements.identity.addEventListener("change", async () => {
const owner = currentOwner();
if (owner) persistOwnerKey(owner.key);
state.profileIndex = 0;
state.profileNames = Array(state.schema.profile_capacity).fill("");
await loadProfile();
});
@ -1019,6 +1182,180 @@ elements.macroSteps.addEventListener("click", (event) => {
updateDirtyState();
});
elements.saveProfileName.addEventListener("click", async () => {
setBusy(true);
try {
const value = elements.profileName.value.trim();
const result = await api(
`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}/name`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
}
);
state.profileNames[state.profileIndex] = value;
state.pendingName = false;
renderEditor();
toast(`Profile name saved · generation ${result.stored_generation}`);
} catch (error) {
toast(error.message, true);
} finally {
setBusy(false);
}
});
elements.saveAlias.addEventListener("click", async () => {
const owner = currentOwner();
if (!owner) return;
setBusy(true);
try {
const value = elements.controllerAlias.value.trim();
const result = await api(`/api/identities/${owner.index}/alias`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
});
owner.alias = value;
owner.label = result.label;
renderIdentities();
toast(`Controller alias saved · generation ${result.stored_generation}`);
} catch (error) {
toast(error.message, true);
} finally {
setBusy(false);
}
});
elements.identify.addEventListener("click", async () => {
const owner = currentOwner();
if (!owner || owner.index === 0) return;
setBusy(true);
try {
await api(`/api/identities/${owner.index}/identify`, { method: "POST" });
toast("Identification pulse sent.");
} catch (error) {
toast(error.message, true);
} finally {
setBusy(false);
}
});
elements.exportProfile.addEventListener("click", () => {
const name = state.profileNames[state.profileIndex] ||
`profile-${state.profileIndex + 1}`;
const blob = new Blob([
JSON.stringify({ name, profile: state.profile }, null, 2),
], { type: "application/json" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `${name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.json`;
link.click();
URL.revokeObjectURL(link.href);
});
elements.importProfile.addEventListener("click", () => {
elements.importProfileFile.click();
});
elements.importProfileFile.addEventListener("change", async () => {
const [file] = elements.importProfileFile.files;
if (!file) return;
try {
const imported = JSON.parse(await file.text());
if (
typeof imported.name === "string" &&
new TextEncoder().encode(imported.name).length > 31
) {
throw new Error("Profile name exceeds 31 UTF-8 bytes");
}
state.profile = clone(imported.profile || imported);
if (typeof imported.name === "string") {
state.profileNames[state.profileIndex] = imported.name;
state.pendingName = true;
}
renderEditor();
toast("Profile imported into the unsaved draft.");
} catch (error) {
toast(`Import failed: ${error.message}`, true);
} finally {
elements.importProfileFile.value = "";
}
});
elements.copyProfile.addEventListener("click", () => {
elements.copyIdentity.innerHTML = state.identities.map((identity) => (
`<option value="${identity.index}">${escapeHtml(identity.label)}</option>`
)).join("");
elements.copyIdentity.value = String(state.identityIndex);
elements.copySlot.innerHTML = Array.from(
{ length: state.schema.profile_capacity },
(_, index) => `<option value="${index + 1}">Profile ${index + 1}</option>`
).join("");
elements.copySlot.value = String(
Math.min(state.schema.profile_capacity, state.profileIndex + 2)
);
elements.copyDialog.showModal();
});
elements.confirmCopy.addEventListener("click", async (event) => {
event.preventDefault();
setBusy(true);
try {
const result = await api(
`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}/copy`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identity_index: Number(elements.copyIdentity.value),
profile_number: Number(elements.copySlot.value),
}),
}
);
if (Number(elements.copyIdentity.value) === state.identityIndex) {
state.profileNames[Number(elements.copySlot.value) - 1] =
state.profileNames[state.profileIndex];
renderProfileList();
}
elements.copyDialog.close();
toast(`Profile copied · generation ${result.stored_generation}`);
} catch (error) {
toast(error.message, true);
} finally {
setBusy(false);
}
});
document.querySelectorAll("[data-reset-section]").forEach((button) => {
button.addEventListener("click", () => {
const defaults = state.schema.default_profile;
const section = button.dataset.resetSection;
if (section === "mapping") {
state.profile.button_map = clone(defaults.button_map);
state.profile.triggers.left.output = defaults.triggers.left.output;
state.profile.triggers.right.output = defaults.triggers.right.output;
} else if (section === "analog") {
state.profile.sticks = clone(defaults.sticks);
const leftOutput = state.profile.triggers.left.output;
const rightOutput = state.profile.triggers.right.output;
state.profile.triggers = clone(defaults.triggers);
state.profile.triggers.left.output = leftOutput;
state.profile.triggers.right.output = rightOutput;
} else if (section === "feedback") {
state.profile.rumble = clone(defaults.rumble);
} else if (section === "turbo") {
state.profile.turbo = clone(defaults.turbo);
} else if (section === "macro") {
state.profile.switching_chord = clone(defaults.switching_chord);
state.profile.motion_toggle_chord = clone(defaults.motion_toggle_chord);
state.profile.macros = clone(defaults.macros);
}
renderEditor();
toast(`${label(section)} reset in the unsaved draft.`);
});
});
elements.save.addEventListener("click", async () => {
if (!elements.form.reportValidity()) return;
setBusy(true);
@ -1028,6 +1365,19 @@ elements.save.addEventListener("click", async () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(state.profile),
});
if (state.pendingName) {
await api(
`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}/name`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
value: state.profileNames[state.profileIndex],
}),
}
);
state.pendingName = false;
}
state.original = canonical(state.profile);
updateDirtyState();
toast(`Saved atomically · generation ${result.stored_generation} · CRC ${result.stored_crc}`);

View file

@ -767,6 +767,19 @@ void test_rejections() {
"valid device must occupy its indexed slot");
require(platform_on_device_ready(&collision) == UNI_ERROR_NO_SLOTS,
"different device cannot replace an occupied slot");
g_slots[0].identity.stable = true;
g_slots[0].identity.transport = ControllerTransport::kClassic;
g_slots[0].identity.address[5] = 1;
Bluepad32SlotSnapshot identity_snapshot{};
bluepad32_input_backend_snapshot(0, &identity_snapshot);
require(bluepad32_input_backend_identify(
identity_snapshot.identity) &&
g_slots[0].pending_profile_feedback_count == 1 &&
!bluepad32_input_backend_identify(
controller_identity_global()),
"Identify did not target only the selected live controller");
g_slots[0].pending_profile_feedback_count = 0;
g_slots[0].pending_profile_feedback[0] = {};
uni_controller_t collision_data{};
collision_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
@ -785,12 +798,15 @@ void test_rejections() {
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count == 3,
"valid slot input must remain observable");
slot_zero.controller.battery = 201;
g_last_snapshot_generation[0] = 0;
Bluepad32PlaytestSnapshot playtest{};
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(playtest.active && playtest.state_generation != 0 &&
playtest.state.motion_sample_count == 3,
"playtest snapshot did not expose current raw input");
playtest.state.motion_sample_count == 3 &&
playtest.battery == 201 &&
(playtest.capabilities & 0x08u) != 0,
"playtest snapshot did not expose input capabilities");
bluepad32_input_backend_report_sent(0);
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count == 3,

View file

@ -98,6 +98,7 @@ struct uni_gamepad_t {
struct uni_controller_t {
int klass;
uni_gamepad_t gamepad;
uint8_t battery;
};
struct uni_hid_device_t;
@ -138,6 +139,7 @@ struct uni_hid_device_t {
uni_bt_conn_t conn;
int idx;
bool gamepad;
uni_controller_t controller;
uni_report_parser_t report_parser;
int rumble_calls;
uint8_t last_high;

View file

@ -188,14 +188,46 @@ void test_eight_profile_transactions_and_active_cache() {
active.profile.strong_rumble_scale == UINT8_MAX,
"reset-all did not restore defaults and activation");
require(profile_service_set_metadata(
5, connected, CONTROLLER_PROFILE_ALL,
"Desk pad", 8) ==
ConfigurationTransactionStatus::kPending,
"controller alias was not queued");
profile_service_task_on_storage_core(6000);
require(transaction_snapshot().transaction.status ==
ConfigurationTransactionStatus::kCommitted,
"controller alias did not persist");
require(profile_service_set_metadata(
6, connected, 6, "Desktop", 7) ==
ConfigurationTransactionStatus::kPending,
"profile name was not queued");
profile_service_task_on_storage_core(7000);
require(profile_service_select(connected, 6) ==
ConfigurationTransactionStatus::kCommitted,
"named profile was not selected");
ProfileServiceMetadataSnapshot metadata{};
profile_service_metadata_snapshot(&metadata);
require(metadata.valid && strcmp(metadata.alias, "Desk pad") == 0 &&
strcmp(metadata.profile_names[6], "Desktop") == 0,
"profile metadata snapshot lost persisted values");
ProfileStorage reloaded;
ControllerProfile persisted{};
char stored_metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
require(
reloaded.initialize(fake_io()) && reloaded.find(connected) != nullptr &&
reloaded.find(connected)->active_profile == 6 &&
reloaded.get(connected, 6, &persisted) == ProfileStorageResult::kOk &&
persisted.weak_rumble_scale == 61,
"service mutations did not survive catalog reload");
persisted.weak_rumble_scale == 61 &&
reloaded.get_alias(connected, stored_metadata,
sizeof(stored_metadata)) ==
ProfileStorageResult::kOk &&
strcmp(stored_metadata, "Desk pad") == 0 &&
reloaded.get_profile_name(connected, 6, stored_metadata,
sizeof(stored_metadata)) ==
ProfileStorageResult::kOk &&
strcmp(stored_metadata, "Desktop") == 0,
"service mutations and metadata did not survive catalog reload");
}
void test_profile_bounds_and_transaction_namespace() {

View file

@ -249,12 +249,55 @@ void test_interrupted_and_corrupt_append_recovery() {
"corrupt newest record displaced the previous record");
}
void test_profile_names_and_aliases_recover() {
erase_all();
const ControllerIdentity stable = identity(7);
ProfileStorage storage;
require(storage.initialize(fake_io()), "metadata catalog did not initialize");
require(storage.set_alias(stable, "Player one", 10) ==
ProfileStorageResult::kOk &&
storage.set_profile_name(stable, 0, "Zelda", 5) ==
ProfileStorageResult::kOk &&
storage.set_profile_name(stable, 7, "Desktop", 7) ==
ProfileStorageResult::kOk,
"profile metadata did not append");
char value[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
require(storage.get_alias(stable, value, sizeof(value)) ==
ProfileStorageResult::kOk &&
strcmp(value, "Player one") == 0,
"controller alias did not read back");
flash.fail_after_programs = flash.programs + 1;
require(storage.set_alias(stable, "Interrupted", 11) ==
ProfileStorageResult::kIoError,
"interrupted alias append reported success");
flash.fail_after_programs = -1;
ProfileStorage reloaded;
require(reloaded.initialize(fake_io()) &&
reloaded.get_alias(stable, value, sizeof(value)) ==
ProfileStorageResult::kOk &&
strcmp(value, "Player one") == 0,
"interrupted alias displaced the previous value");
require(reloaded.get_profile_name(stable, 0, value, sizeof(value)) ==
ProfileStorageResult::kOk &&
strcmp(value, "Zelda") == 0 &&
reloaded.get_profile_name(stable, 7, value, sizeof(value)) ==
ProfileStorageResult::kOk &&
strcmp(value, "Desktop") == 0,
"profile names did not survive reload");
}
void test_compaction_preserves_latest_records() {
erase_all();
const ControllerIdentity global = controller_identity_global();
ProfileStorage storage;
require(storage.initialize(fake_io()), "catalog did not initialize");
ControllerProfile profile = controller_profile_default(global, 0);
require(storage.set_alias(global, "Fallback", 8) ==
ProfileStorageResult::kOk &&
storage.set_profile_name(global, 0, "Compacted", 9) ==
ProfileStorageResult::kOk,
"compaction metadata did not append");
for (uint16_t write = 1; write <= 260; ++write) {
profile.weak_rumble_scale = static_cast<uint8_t>(write);
require(storage.set(global, 0, profile) == ProfileStorageResult::kOk,
@ -264,11 +307,19 @@ void test_compaction_preserves_latest_records() {
"full arena did not compact into its peer");
ProfileStorage reloaded;
ControllerProfile recovered{};
char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
require(reloaded.initialize(fake_io()) &&
reloaded.get(global, 0, &recovered) ==
ProfileStorageResult::kOk &&
recovered.weak_rumble_scale == static_cast<uint8_t>(260),
"compaction did not preserve the latest profile");
recovered.weak_rumble_scale == static_cast<uint8_t>(260) &&
reloaded.get_alias(global, metadata, sizeof(metadata)) ==
ProfileStorageResult::kOk &&
strcmp(metadata, "Fallback") == 0 &&
reloaded.get_profile_name(global, 0, metadata,
sizeof(metadata)) ==
ProfileStorageResult::kOk &&
strcmp(metadata, "Compacted") == 0,
"compaction did not preserve profiles and metadata");
}
void test_legacy_migration_is_atomic_and_complete() {
@ -306,6 +357,7 @@ int main() {
test_empty_catalog_and_eight_profiles();
test_identity_capacity_and_defaults();
test_interrupted_and_corrupt_append_recovery();
test_profile_names_and_aliases_recover();
test_compaction_preserves_latest_records();
test_legacy_migration_is_atomic_and_complete();
std::cout << "profile storage tests passed\n";

View file

@ -95,6 +95,15 @@ class FakeDevice:
for identity in self.profile_identities
for index in range(config_manager.PROFILE_CAPACITY)
}
self.profile_aliases = {
identity.to_bytes(): "" for identity in self.profile_identities
}
self.profile_names = {
(identity.to_bytes(), index): ""
for identity in self.profile_identities
for index in range(config_manager.PROFILE_CAPACITY)
}
self.identified_identities: list[bytes] = []
self.selected_profile = (self.global_identity.to_bytes(), 0)
self.profile_generation = 7
self.profile_transaction_id = 0
@ -111,6 +120,8 @@ class FakeDevice:
self.profile_chunk_sizes: list[int] = []
self.pending_profile_mutation: tuple[int, bytes, int] | None = None
self.profile_transaction_pending_reads = 0
self.playtest_battery = 251
self.playtest_capabilities = 0x0F
self.profile_status_responses: list[tuple[int, int]] = []
self.playtest_connected = True
self.playtest_slot = 1
@ -143,8 +154,29 @@ class FakeDevice:
def _profile_list_payload(self) -> bytes:
payload = bytearray([len(self.profile_identities)])
for identity in self.profile_identities:
payload.extend(identity.to_bytes())
payload.extend((self.active_profiles[identity.to_bytes()], 0))
identity_bytes = identity.to_bytes()
alias = self.profile_aliases.get(identity_bytes, "").encode("utf-8")
payload.extend(identity_bytes)
payload.extend((self.active_profiles[identity_bytes], 0, len(alias)))
payload.extend(alias)
payload.extend(bytes(config_manager.PROFILE_METADATA_MAX_BYTES - len(alias)))
return bytes(payload)
def _profile_metadata_payload(self) -> bytes:
identity, _ = self.selected_profile
values = [
self.profile_aliases.get(identity, ""),
*(
self.profile_names.get((identity, index), "")
for index in range(config_manager.PROFILE_CAPACITY)
),
]
payload = bytearray()
for value in values:
encoded = value.encode("utf-8")
payload.extend((len(encoded),))
payload.extend(encoded)
payload.extend(bytes(config_manager.PROFILE_METADATA_MAX_BYTES - len(encoded)))
return bytes(payload)
def _profile_transaction_payload(self) -> bytes:
@ -182,8 +214,10 @@ class FakeDevice:
*self.playtest_triggers
)
payload[38] = 1 if self.playtest_motion is not None else 0
payload[39] = self.playtest_battery
payload[40] = self.playtest_capabilities
if self.playtest_motion is not None:
struct.pack_into("<hhhhhh", payload, 40, *self.playtest_motion)
struct.pack_into("<hhhhhh", payload, 42, *self.playtest_motion)
return bytes(payload), flags
def _queue_profile_mutation(self, operation: int, payload: bytes) -> None:
@ -334,6 +368,13 @@ class FakeDevice:
schema=config_manager.PROFILE_PLAYTEST_SCHEMA_VERSION,
generation=self.playtest_state_generation,
)
if request == config_manager.OP_PROFILE_METADATA_READ:
return make_response(
request,
self._profile_metadata_payload(),
schema=config_manager.PROFILE_METADATA_SCHEMA_VERSION,
generation=self.profile_generation,
)
if request == config_manager.OP_PROFILE_TRANSACTION_STATUS:
if self.profile_transaction_status == config_manager.STATUS_PENDING:
if self.profile_transaction_pending_reads:
@ -513,6 +554,26 @@ class FakeDevice:
<= self.profile_transaction_index
< config_manager.PROFILE_CAPACITY
)
elif request == config_manager.OP_PROFILE_METADATA_SET:
self.profile_transaction_id = struct.unpack_from("<I", payload)[0]
identity = payload[4:18]
profile_index = payload[18]
value_size = payload[19]
value = payload[20 : 20 + value_size].decode("utf-8")
assert len(payload) == 20 + value_size
if profile_index == config_manager.PROFILE_NONE_BUTTON:
self.profile_aliases[identity] = value
else:
self.profile_names[(identity, profile_index)] = value
self.profile_transaction_identity = identity
self.profile_transaction_index = profile_index
self.profile_transaction_payload = bytearray()
self.profile_transaction_expected_size = 0
self.profile_transaction_expected_crc = 0
self.profile_generation += 1
self.profile_transaction_status = config_manager.STATUS_OK
elif request == config_manager.OP_PROFILE_IDENTIFY:
self.identified_identities.append(payload)
else:
raise AssertionError(f"unexpected OUT request {request}")
return len(encoded)
@ -1456,6 +1517,32 @@ def test_profile_list_select_read_and_chunked_commit() -> None:
)
def test_profile_metadata_and_identify_round_trip() -> None:
device = FakeDevice()
identity = device.stable_identity
alias_status = config_manager.set_profile_metadata(
device, identity, config_manager.PROFILE_NONE_BUTTON,
"Desk pad", 1.0,
)
name_status = config_manager.set_profile_metadata(
device, identity, 7, "Desktop", 1.0,
)
metadata = config_manager.read_profile_metadata(device, identity, 7)
assert alias_status.status == config_manager.STATUS_OK
assert name_status.stored_generation == alias_status.stored_generation + 1
assert metadata.alias == "Desk pad"
assert metadata.profile_names[7] == "Desktop"
assert config_manager.list_profiles(device)[1].alias == "Desk pad"
config_manager.identify_controller(device, identity)
assert device.identified_identities == [identity.to_bytes()]
with pytest.raises(
config_manager.ConfigManagerError,
match="no controller to identify",
):
config_manager.identify_controller(device, device.global_identity)
def test_profile_playtest_decodes_raw_controller_state() -> None:
device = FakeDevice()
playtest = config_manager.read_profile_playtest(device)
@ -1469,6 +1556,8 @@ def test_profile_playtest_decodes_raw_controller_state() -> None:
left_stick=(-1234, 2345),
right_stick=(-30000, 30000),
triggers=(123, 65000),
battery=251,
capabilities=0x0F,
motion=(1, -2, 3, -4, 5, -6),
)
assert playtest.to_json_object()["buttons"] == [
@ -1489,6 +1578,8 @@ def test_profile_playtest_decodes_raw_controller_state() -> None:
left_stick=(0, 0),
right_stick=(0, 0),
triggers=(0, 0),
battery=0,
capabilities=0,
motion=None,
)
device.playtest_connected = True

View file

@ -188,6 +188,10 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
]
assert playtest["left_stick"] == {"x": -1234, "y": 2345}
assert playtest["triggers"] == {"left": 123, "right": 65000}
assert playtest["battery"] == 100
assert playtest["capabilities"] == [
"rumble", "lightbar", "player_leds", "motion"
]
status, selected = request_json(f"{base_url}/api/profiles/1/8")
assert status == 200
@ -209,6 +213,53 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
== custom_profile()
)
assert device.profile_chunk_sizes == [40, 40, 40, 40, 40, 40, 16]
status, renamed = request_json(
f"{base_url}/api/profiles/1/8/name",
method="PUT",
value={"value": "Desktop"},
token=token,
)
assert status == 200
assert renamed["stored_generation"] == 9
assert device.profile_names[
(device.stable_identity.to_bytes(), 7)
] == "Desktop"
status, aliased = request_json(
f"{base_url}/api/identities/1/alias",
method="PUT",
value={"value": "Desk pad"},
token=token,
)
assert status == 200
assert aliased["label"] == "Desk pad"
status, identified = request_json(
f"{base_url}/api/identities/1/identify",
method="POST",
token=token,
)
assert status == 200
assert identified == {"identified": True}
assert device.identified_identities == [
device.stable_identity.to_bytes()
]
status, copied = request_json(
f"{base_url}/api/profiles/1/8/copy",
method="POST",
value={"identity_index": 1, "profile_number": 4},
token=token,
)
assert status == 200
assert copied["stored_generation"] == 12
assert device.profiles[
(device.stable_identity.to_bytes(), 3)
] == custom_profile().to_bytes()
assert device.profile_names[
(device.stable_identity.to_bytes(), 3)
] == "Desktop"
status, activated = request_json(
f"{base_url}/api/profiles/1/8/activate",
@ -217,7 +268,7 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
)
assert status == 200
assert activated["stored_generation"] == 9
assert activated["stored_generation"] == 13
assert device.active_profiles[device.stable_identity.to_bytes()] == 7

View file

@ -14,6 +14,7 @@ ConfigurationServiceSnapshot current_configuration{};
ProfileServiceListSnapshot current_profile_list{};
ProfileServiceSelectedSnapshot current_profile_selected{};
ProfileServiceTransactionSnapshot current_profile_transaction{};
ProfileServiceMetadataSnapshot current_profile_metadata{};
Bluepad32PlaytestSnapshot current_playtest[
BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
AdapterUsbMode current_active_mode = AdapterUsbMode::kSwitchProbe;
@ -52,6 +53,11 @@ uint32_t profile_reset_transaction_id = 0;
uint32_t profile_commit_transaction_id = 0;
bool profile_activate_requested = false;
uint32_t profile_activate_transaction_id = 0;
bool profile_metadata_requested = false;
uint32_t profile_metadata_transaction_id = 0;
uint8_t profile_metadata_index = 0;
std::string profile_metadata_value;
bool identify_requested = false;
void require(bool condition, const char* message) {
if (!condition) {
@ -454,12 +460,12 @@ void test_profile_vendor_requests() {
Operation::kProfileList, TUSB_DIR_IN, kMaximumResponseSize);
require(usb_configuration_management_vendor_control(
0, CONTROL_STAGE_SETUP, &request) &&
control_payload.size() == kResponseHeaderSize + 33 &&
control_payload.size() == kResponseHeaderSize + 97 &&
control_payload[5] ==
static_cast<uint8_t>(Operation::kProfileList) &&
control_payload[10] == CONTROLLER_PROFILE_SCHEMA_VERSION &&
control_payload[kResponseHeaderSize] == 2 &&
control_payload[kResponseHeaderSize + 31] == 2,
control_payload[kResponseHeaderSize + 63] == 2,
"profile list response was not encoded");
current_profile_selected = {};
@ -502,6 +508,8 @@ void test_profile_vendor_requests() {
current_playtest[2].state.right_stick_y = INT16_MAX;
current_playtest[2].state.left_trigger = 123;
current_playtest[2].state.right_trigger = 65000;
current_playtest[2].battery = 201;
current_playtest[2].capabilities = 0x0f;
current_playtest[2].state.motion_sample_count = 1;
current_playtest[2].state.motion_samples[0] =
{1, -2, 3, -4, 5, -6};
@ -531,7 +539,9 @@ void test_profile_vendor_requests() {
read_u16(control_payload, kResponseHeaderSize + 36) ==
65000 &&
static_cast<int16_t>(read_u16(
control_payload, kResponseHeaderSize + 50)) == -6,
control_payload, kResponseHeaderSize + 52)) == -6 &&
control_payload[kResponseHeaderSize + 39] == 201 &&
control_payload[kResponseHeaderSize + 40] == 0x0f,
"profile playtest response lost live controller state");
current_playtest[2].active = false;
require(usb_configuration_management_vendor_control(
@ -539,6 +549,52 @@ void test_profile_vendor_requests() {
control_payload[kResponseHeaderSize] == 0 &&
control_payload[kResponseHeaderSize + 1] == 0xff,
"disconnected profile playtest was not encoded");
current_profile_metadata = {};
current_profile_metadata.metadata.state = ProfileServiceState::kReady;
current_profile_metadata.metadata.generation = 10;
current_profile_metadata.status =
ConfigurationTransactionStatus::kCommitted;
current_profile_metadata.valid = true;
memcpy(current_profile_metadata.alias, "Desk pad", 9);
memcpy(current_profile_metadata.profile_names[7], "Desktop", 8);
request = setup_request(
Operation::kProfileMetadataRead, TUSB_DIR_IN,
kMaximumResponseSize);
require(usb_configuration_management_vendor_control(
0, CONTROL_STAGE_SETUP, &request) &&
control_payload.size() ==
kResponseHeaderSize + kProfileMetadataPayloadSize &&
control_payload[kResponseHeaderSize] == 8 &&
memcmp(&control_payload[kResponseHeaderSize + 1],
"Desk pad", 8) == 0 &&
control_payload[
kResponseHeaderSize +
8 * (PROFILE_SERVICE_METADATA_MAX_BYTES + 1)] == 7,
"profile metadata response was not encoded");
std::vector<uint8_t> metadata(27);
write_u32(&metadata, 0, 0x12345678);
require(controller_identity_encode(
expected_identity, &metadata[4],
CONTROLLER_IDENTITY_ENCODED_SIZE),
"metadata identity did not encode");
metadata[18] = 7;
metadata[19] = 7;
memcpy(&metadata[20], "Desktop", 7);
perform_out(Operation::kProfileMetadataSet, metadata);
require(profile_metadata_requested &&
profile_metadata_transaction_id == 0x12345678 &&
profile_metadata_index == 7 &&
profile_metadata_value == "Desktop",
"profile metadata mutation was not dispatched");
std::vector<uint8_t> identify(CONTROLLER_IDENTITY_ENCODED_SIZE);
require(controller_identity_encode(
expected_identity, identify.data(), identify.size()),
"identify identity did not encode");
perform_out(Operation::kProfileIdentify, identify);
require(identify_requested,
"controller Identify request was not dispatched");
current_profile_transaction = {};
current_profile_transaction.metadata.state =
@ -735,6 +791,17 @@ ConfigurationTransactionStatus profile_service_select(
return ConfigurationTransactionStatus::kPending;
}
ConfigurationTransactionStatus profile_service_set_metadata(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t selected_profile, const char* value, size_t value_size) {
profile_metadata_requested = true;
profile_metadata_transaction_id = transaction_id;
profile_identity = identity;
profile_metadata_index = selected_profile;
profile_metadata_value.assign(value, value + value_size);
return ConfigurationTransactionStatus::kPending;
}
ConfigurationTransactionStatus profile_service_begin(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t selected_profile, uint16_t schema_version,
@ -763,6 +830,17 @@ ConfigurationTransactionStatus profile_service_commit(
return ConfigurationTransactionStatus::kPending;
}
void profile_service_metadata_snapshot(
ProfileServiceMetadataSnapshot* output) {
*output = current_profile_metadata;
}
bool bluepad32_input_backend_identify(
const ControllerIdentity&) {
identify_requested = true;
return true;
}
ConfigurationTransactionStatus profile_service_reset(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t selected_profile) {