feat: give Joy-Con pairs independent persistent profile banks

This commit is contained in:
Joey Yakimowich-Payne 2026-09-07 15:41:06 -06:00
commit 34f8a50c19
25 changed files with 1644 additions and 117 deletions

View file

@ -639,8 +639,29 @@ Chromium checks covered all assets, ten layout/orientation views, mobile
panning and non-overlapping hotspots, remapping, and simulated live topology
transitions without losing focus or draft data. The connected real Joy-Con pair
reported its paired layout on the flashed firmware. All 293 tests and all five
firmware builds passed. Pair profiles still share the left bank at this
checkpoint; independent pair banks are separate work.
firmware builds passed at checkpoint `8451b1e`.
Joy-Con 2 L + R now has a distinct persistent owner keyed by both typed
Bluetooth addresses. Its first bank atomically snapshots the left bank's eight
profiles, names and active slot; the new alias is empty. Both solo banks remain
unchanged. Later edits are copy-on-write, reconnect never reseeds, and a
different member combination gets a different owner. The first-ready USB slot
and existing input/rumble generation barriers remain intact. Failed seed
initialization leaves the first solo active.
Indexed catalog 3 migrates catalog 1/2 atomically at boot. Tagged pair identities
remain 14 bytes and cannot enter physical Bluetooth approval inventories.
Regression coverage includes pair/solo edits and activation, both connection
orders, member replacement, capacity, compaction, interrupted publication and
committed-but-unacknowledged seeds. All 321 tests passed; the physical catalog
migration preserved the seven existing owners and all 56 profiles, metadata,
active selections and adapter settings.
The real pair subsequently created its separate L + R owner: eight owners and
64 profiles, with all eight initial pair profiles/names matching the left bank.
An inactive pair-profile edit and rename survived reboot without touching either
solo bank; independent activation was checked and all test settings restored.
Studio's selected pair key matched the live firmware owner, and Identify
produced two physical dispatches.
| Priority | Candidate | Intended scope | Dependency or principal risk |
|---|---|---|---|
@ -733,7 +754,7 @@ Set B delivery evidence:
fork changes. Dynamic GATT discovery, matched acknowledgements, calibration,
normalized motion and retained finite/stateful rumble replace the unsafe
fixed-handle/timeout-advance behavior in that proposal.
- Joy-Con 2 pairs retain the first-ready USB slot and left profile identity;
- Joy-Con 2 pairs retain the first-ready USB slot and use an independent pair identity;
either connection order works. Right-half motion, both-half feedback,
sideways solo operation and neutral detach transitions are implemented.
A pair still consumes two of the four physical Bluetooth connections.
@ -814,7 +835,7 @@ Current constraints:
precedence, earliest deadlines, rotating ties, periodic credit reservations,
and generation-bound grant completion. Radio power policy is unchanged.
- Adapter configuration schema 3 persists up to 16 explicit physical approvals.
Profile schema 7/catalog 2 preserves prior settings; bonds and wake identity are unchanged.
Profile schema 7/catalog 3 preserves prior settings; bonds and wake identity are unchanged.
- Original Switch Joy-Cons remain separate, horizontally mapped controllers.
Joy-Con 2 logical pairing is a distinct BLE implementation described above.

View file

@ -247,7 +247,11 @@ Switch 2's **C, GL, GR, Left SL/SR and Right SL/SR** are additional source-only
Controller Studio uses the supplied lightweight SVGs for Switch 2 Pro, Joy-Con 2 left/right solo and paired layouts, original Switch Pro, DualSense, Xbox, and Wii Remote/Nunchuk views. Hotspots follow the artwork's actual coordinates; solo Joy-Con views rotate with their firmware input mappings. Rear buttons and rails are labeled below the front view rather than drawn in fictitious positions. On narrow screens, pan the diagram or use the **Source control** menu.
**Auto** uses matching-owner live metadata to distinguish a Joy-Con pair from a solo half. **Preview** changes only the editor's diagram and source labels; it does not pair controllers or change saved mappings, and physical highlighting is disabled. Source choices reflect the layout while stored unavailable mappings are retained. Wii orientation is not reported, so horizontal/vertical views require an explicit preview. The current pair still uses the left controller's profile bank; select that owner to edit paired input.
**Auto** uses matching-owner live metadata to distinguish a Joy-Con pair from a solo half. **Preview** changes only the editor's diagram and source labels; it does not pair controllers or change saved mappings, and physical highlighting is disabled. Source choices reflect the layout while stored unavailable mappings are retained. Wii orientation is not reported, so horizontal/vertical views require an explicit preview.
**Joy-Con 2 pair profiles:** the first successful connection of an L + R combination creates a separate **Nintendo Joy-Con 2 (L + R)** owner with eight profiles. It initially copies the left bank's profiles, names, and active selection; its alias starts empty. Both solo banks stay unchanged. Pair edits, names, and active selections are independent thereafter. Disconnecting a half restores the survivor's solo bank; reconnecting the same members restores their existing pair bank without copying again. Different member combinations have different banks. Select the L + R owner—not either solo owner—to edit paired settings.
Pair keys contain both complete Bluetooth addresses and address types, in canonical L/R order. They retain the 14-byte identity size: transport byte 1 is 3; byte 0 contains the stable bit plus the left/right static-random flags in bits 1/2; bytes 2–7 and 8–13 contain the left and right addresses. Pair keys are profile identities, not Bluetooth peers or native-output approvals. Update host tools with firmware when using this identity kind.
The read-only playtest endpoint (`0x39`) uses schema 4, 56 bytes: byte 55 identifies unspecified (0), Joy-Con 2 left solo (1), right solo (2), pair (3), Wii Remote (4), or Wii Remote + Nunchuk (5). Host tools still read schema 2/54-byte and schema 3/55-byte payloads; older firmware cannot confirm Joy-Con topology. This metadata does not change profile records, input mapping, or rumble.
@ -343,16 +347,18 @@ Motion-producing Bluepad32 parsers normalize to 1024 units per degree/second and
The AIO firmware implements the proprietary BLE protocol for Nintendo `057E:2069` (Pro), `057E:2067` (left Joy-Con 2), and `057E:2066` (right Joy-Con 2). This is controller **input** support, distinct from the existing Switch 2 console-wake feature and from emulating a native Switch 2 USB controller.
- **Pairing:** fresh SYNC pairing requires the existing bounded pairing window. A directed reconnect must target this adapter's Bluetooth address and match its persistent application-level authorization. These links are unencrypted and are **not authenticated SMP bonds**. No global Bluetooth security downgrade is made; automatic SMP requests for these devices fail closed while other controllers retain their existing policy. Public/static addresses can own profiles; transient private addresses are not promoted to persistent identities.
- **Joy-Con ownership:** an opposite ready half automatically joins a solo half. Either connection order works; the first-ready player slot is retained, with the left controller's identity/profile owning the pair. The right half supplies motion. A disconnected half's inputs and pending effects are removed immediately; the surviving half returns to sideways solo operation and its own profile identity. A lone half has rotated controls and SL/SR shoulders. Two pairs exhaust the four physical Bluetooth connections. Pairing does not reopen discovery outside the existing connection policy.
- **Joy-Con ownership:** an opposite ready half joins a solo half after its independent pair bank is ready. Either connection order works; the first-ready player slot is retained. The pair identity includes both typed member addresses, and the right half supplies motion. Losing a half clears its inputs and pending effects; the survivor returns to sideways solo operation and its own bank. Failed bank initialization leaves the existing solo intact rather than sharing its settings with the pair. A lone half has rotated controls and SL/SR shoulders. Two pairs exhaust the four physical Bluetooth connections. Pairing does not reopen discovery outside the existing connection policy.
- **Protocol:** service, characteristic and CCCD UUIDs are discovered rather than trusting fixed ATT handles. Setup requires matching acknowledgements, reads user/factory stick calibration and gyro bias, and rejects malformed/failed transactions. Motion is normalized to the existing SDL-oriented units; sensor clock/range classification and physical axis accuracy still need wider model qualification.
- **Rumble:** Switch HD commands now retain independent left/right frequency/amplitude fields and up to three ordered subframes through the native Switch 2 encoder and bounded queues described below. XInput and local feedback retain their conventional fixed-carrier behavior. This is separate from the original Switch-native opt-in backend.
- **Not implemented:** Joy-Con mouse output, native GameChat signaling, NFC/IR and Switch 2 NSO GameCube support. C and back/rail inputs can instead be remapped to controls the selected USB mode supports.
This is a scoped reimplementation informed by [Bluepad32 PR #219](https://github.com/ricardoquesada/bluepad32/pull/219), reviewed at `9c95e43a87d3bd8a68565da0836d8a758bd8d8af`, not a wholesale fork import. Protocol references: [ndeadly's research](https://github.com/ndeadly/switch2_controller_research), [Nadeflore](https://github.com/Nadeflore/switch2-controllers), [Switch2Connect](https://github.com/TommyWabg/Switch2Connect), and [SDL's Switch 2 sensor implementation](https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_switch2.c).
**Verification:** 289 tests passed; AIO, XInput/feasibility, HD-rumble, haptics and UART firmware variants built. Native protocol tests use the SDK's real BTstack types/accessors and cover discovery, acknowledgement ordering, calibration, persistence, output deadlines and teardown. Lifecycle tests cover both Joy-Con connection orders, multiple pairs, detach/replacement and pairing-policy isolation. The editor's extra/Shift mappings were exercised in Chromium.
**Verification:** 321 tests passed; AIO, XInput/feasibility, HD-rumble, haptics and UART firmware variants built. Native protocol tests use the SDK's real BTstack types/accessors and cover discovery, acknowledgement ordering, calibration, persistence, output deadlines and teardown. Lifecycle/storage tests cover both Joy-Con connection orders, multiple pairs, detach/replacement, independent pair banks, interrupted initialization and pairing-policy isolation. The editor's layouts, remapping and source preservation were exercised in Chromium.
On the flashed Pico, a real Switch 2 Pro (`3C:A9:AB:65:73:12`) completed setup, appeared in persistent pairing/profile inventories, and delivered live sticks, accelerometer, gyro and independent C/GL/GR presses. A 100-report USB rumble exercise retained its connection while 3,033 controller reports arrived. Schema-7 extra mappings were written/read and restored on hardware; all 32 pre-existing profiles, metadata and active selections were compared against a pre-flash backup and preserved, with adapter configuration generation 13 / CRC `3af5ee18` unchanged. Physical rumble feel, Joy-Con 2 pair behavior, long-duration reconnect and mixed-controller transport remain hardware qualification items. Use schema-7-capable firmware after saving expanded profiles.
On hardware, the new L + R owner copied all eight left-bank profiles and names, bringing the inventory to eight owners / 64 profiles without changing the original 56. A temporary edit and name on inactive pair profile 8 survived reboot while both solo banks stayed unchanged; pair activation was also independent. Test edits, names and active selections were restored. Studio selected the real composite owner and its Identify action produced two physical rumble dispatches.
At the initial Switch 2 input checkpoint, a real Pro (`3C:A9:AB:65:73:12`) completed setup, appeared in persistent pairing/profile inventories, and delivered live sticks, accelerometer, gyro and independent C/GL/GR presses. A 100-report USB rumble exercise retained its connection while 3,033 controller reports arrived. Schema-7 extra mappings were written/read and restored; all 32 then-existing profiles, metadata and active selections were preserved, with adapter configuration generation 13 / CRC `3af5ee18` unchanged. Subsequent native-rumble and pair measurements are documented here and below; wider sensor-axis, perceptual-equivalence and long-duration transport qualification remains open. Use schema-7-capable firmware after saving expanded profiles.
### Switch 2 native HD rumble
@ -909,35 +915,42 @@ linked binary, not from the larger debug-bearing ELF or UF2 transport file:
| Resource | Used or reserved | Device capacity |
|---|---:|---:|
| Executable flash image | 815,192 bytes | 4 MiB |
| Executable flash image | 825,232 bytes | 4 MiB |
| Indexed profile arenas | 256 KiB | 4 MiB flash |
| Adapter configuration | 8 KiB | 4 MiB flash |
| BTstack bonds and Switch 2 application authorizations | 8 KiB | 4 MiB flash |
| RP2350 terminal sector | 4 KiB | 4 MiB flash |
| Allocated/reserved SRAM, including heap and stacks | 150,824 bytes | 520 KiB |
| Allocated/reserved SRAM, including heap and stacks | 151,652 bytes | 520 KiB |
The executable plus persistent reservations consume 1,097,816 bytes of flash,
leaving 3,096,488 bytes. Allocated SRAM sections leave 381,656 bytes of link-time
The executable plus persistent reservations consume 1,107,856 bytes of flash,
leaving 3,086,448 bytes. Allocated SRAM sections leave 380,828 bytes of link-time
headroom; this is not a runtime heap high-water measurement. Core 0 has a
4 KiB stack, and Core 1 uses a dedicated 16 KiB stack in main SRAM for nested
catalog migration/compaction rather than overflowing its 4 KiB scratch bank.
Profiles use two 128 KiB append-only arenas and retain 248 physical record
slots. Catalog 2 uses a 128-byte header plus a 384-byte profile in the same
slots. Catalog 3 uses a 128-byte header plus a 384-byte profile in the same
512-byte stride. The second page is programmed before the header-containing
first page, and records are read back before publication. Profile names
remain 256-byte metadata payloads and aliases remain 32 bytes. The compact
index stores locations and generations; active/fallback profiles for observed
identities and the selected profile are decoded, not the entire database.
Pair initialization commits one seed record that snapshots the left bank's
immutable profile/name record references and active selection. Subsequent
writes are independent, and compaction materializes pair-owned records.
Interrupted initialization exposes either no pair bank or the complete bank,
never a partly copied bank. An ambiguous storage write/readback failure freezes
catalog access until reboot/replay; existing live profiles retain their last
committed cached settings.
The catalog supports eight profiles for the global fallback and each of 16
stable identities. Missing records resolve to defaults, so profiles 5–8 do
stable identities, including pair owners. Missing records resolve to defaults, so profiles 5–8 do
not consume flash until changed. When an arena fills, the latest indexed
records are compacted into its peer and the new superblock is published last.
Interrupted or corrupt appends leave the previous valid record available.
Catalog 1 and retired four-profile banks migrate through the alternate arena;
Catalogs 1/2 and retired four-profile banks migrate through the alternate arena;
the old published data is retained until all copies and the new superblock
verify. Schema 1–5 profiles retain their meaning when decoded as schema 6.
verify. Schema 1–6 profiles retain their meaning when decoded as schema 7.
Keep a profile export before downgrading: older firmware cannot read the new
catalog/profile format.

Binary file not shown.

Binary file not shown.

View file

@ -3,9 +3,27 @@
namespace {
constexpr uint16_t kNintendoVendorId = 0x057e;
constexpr uint16_t kJoyConLeftProductId = 0x2067;
constexpr uint16_t kJoyConRightProductId = 0x2066;
bool member_address_valid(uint8_t type, const uint8_t* address) {
return type == 0 || (type == 1 && (address[0] & 0xc0) == 0xc0);
}
bool partner_empty(const ControllerIdentity& identity) {
constexpr uint8_t empty[6]{};
return identity.partner_address_type == 0 &&
memcmp(identity.partner_address, empty, sizeof(empty)) == 0;
}
bool controller_identity_valid(const ControllerIdentity& identity) {
const uint8_t transport = static_cast<uint8_t>(identity.transport);
if (transport > static_cast<uint8_t>(ControllerTransport::kBle)) {
if (identity.transport == ControllerTransport::kJoyConPair) {
return controller_identity_is_joycon_pair(identity);
}
if (transport > static_cast<uint8_t>(ControllerTransport::kBle) ||
!partner_empty(identity)) {
return false;
}
if (identity.stable) {
@ -32,7 +50,71 @@ bool controller_identity_equal(const ControllerIdentity& first,
first.address_type == second.address_type &&
memcmp(first.address, second.address, sizeof(first.address)) == 0 &&
first.vendor_id == second.vendor_id &&
first.product_id == second.product_id;
first.product_id == second.product_id &&
first.partner_address_type == second.partner_address_type &&
memcmp(first.partner_address, second.partner_address,
sizeof(first.partner_address)) == 0;
}
bool controller_identity_is_joycon_pair(const ControllerIdentity& identity) {
return identity.stable &&
identity.transport == ControllerTransport::kJoyConPair &&
identity.vendor_id == kNintendoVendorId &&
identity.product_id == kJoyConLeftProductId &&
member_address_valid(identity.address_type, identity.address) &&
member_address_valid(identity.partner_address_type,
identity.partner_address) &&
(identity.address_type != identity.partner_address_type ||
memcmp(identity.address, identity.partner_address,
sizeof(identity.address)) != 0);
}
bool controller_identity_make_joycon_pair(const ControllerIdentity& left,
const ControllerIdentity& right,
ControllerIdentity* output) {
if (output == nullptr || !left.stable || !right.stable ||
left.transport != ControllerTransport::kBle ||
right.transport != ControllerTransport::kBle ||
left.vendor_id != kNintendoVendorId ||
right.vendor_id != kNintendoVendorId ||
left.product_id != kJoyConLeftProductId ||
right.product_id != kJoyConRightProductId ||
!partner_empty(left) || !partner_empty(right)) {
return false;
}
ControllerIdentity pair = left;
pair.transport = ControllerTransport::kJoyConPair;
pair.partner_address_type = right.address_type;
memcpy(pair.partner_address, right.address, sizeof(pair.partner_address));
if (!controller_identity_is_joycon_pair(pair)) {
return false;
}
*output = pair;
return true;
}
bool controller_identity_joycon_pair_members(const ControllerIdentity& pair,
ControllerIdentity* left,
ControllerIdentity* right) {
if (left == nullptr || right == nullptr || left == right ||
!controller_identity_is_joycon_pair(pair)) {
return false;
}
ControllerIdentity decoded_left{};
decoded_left.stable = true;
decoded_left.transport = ControllerTransport::kBle;
decoded_left.address_type = pair.address_type;
memcpy(decoded_left.address, pair.address, sizeof(decoded_left.address));
decoded_left.vendor_id = kNintendoVendorId;
decoded_left.product_id = kJoyConLeftProductId;
ControllerIdentity decoded_right = decoded_left;
decoded_right.address_type = pair.partner_address_type;
memcpy(decoded_right.address, pair.partner_address,
sizeof(decoded_right.address));
decoded_right.product_id = kJoyConRightProductId;
*left = decoded_left;
*right = decoded_right;
return true;
}
bool controller_identity_encode(const ControllerIdentity& identity,
@ -41,6 +123,15 @@ bool controller_identity_encode(const ControllerIdentity& identity,
!controller_identity_valid(identity)) {
return false;
}
if (identity.transport == ControllerTransport::kJoyConPair) {
output[0] = 1 | (identity.address_type << 1) |
(identity.partner_address_type << 2);
output[1] = static_cast<uint8_t>(identity.transport);
memcpy(&output[2], identity.address, sizeof(identity.address));
memcpy(&output[8], identity.partner_address,
sizeof(identity.partner_address));
return true;
}
output[0] = identity.stable ? 1 : 0;
output[1] = static_cast<uint8_t>(identity.transport);
@ -57,21 +148,36 @@ bool controller_identity_encode(const ControllerIdentity& identity,
bool controller_identity_decode(const uint8_t* input, size_t input_size,
ControllerIdentity* output) {
if (input == nullptr || output == nullptr ||
input_size != CONTROLLER_IDENTITY_ENCODED_SIZE || input[0] > 1 ||
input[1] > static_cast<uint8_t>(ControllerTransport::kBle) ||
input[3] != 0) {
input_size != CONTROLLER_IDENTITY_ENCODED_SIZE) {
return false;
}
ControllerIdentity decoded{};
decoded.stable = input[0] != 0;
decoded.transport = static_cast<ControllerTransport>(input[1]);
decoded.address_type = input[2];
memcpy(decoded.address, &input[4], sizeof(decoded.address));
decoded.vendor_id = static_cast<uint16_t>(input[10]) |
static_cast<uint16_t>(input[11] << 8);
decoded.product_id = static_cast<uint16_t>(input[12]) |
static_cast<uint16_t>(input[13] << 8);
if (decoded.transport == ControllerTransport::kJoyConPair) {
if ((input[0] & 0xf9) != 1) {
return false;
}
decoded.stable = true;
decoded.address_type = (input[0] >> 1) & 1;
decoded.partner_address_type = (input[0] >> 2) & 1;
memcpy(decoded.address, &input[2], sizeof(decoded.address));
memcpy(decoded.partner_address, &input[8],
sizeof(decoded.partner_address));
decoded.vendor_id = kNintendoVendorId;
decoded.product_id = kJoyConLeftProductId;
} else {
if (input[0] > 1 || input[3] != 0) {
return false;
}
decoded.stable = input[0] != 0;
decoded.address_type = input[2];
memcpy(decoded.address, &input[4], sizeof(decoded.address));
decoded.vendor_id = static_cast<uint16_t>(input[10]) |
static_cast<uint16_t>(input[11] << 8);
decoded.product_id = static_cast<uint16_t>(input[12]) |
static_cast<uint16_t>(input[13] << 8);
}
if (!controller_identity_valid(decoded)) {
return false;
}

View file

@ -9,6 +9,7 @@ enum class ControllerTransport : uint8_t {
kUnknown = 0,
kClassic = 1,
kBle = 2,
kJoyConPair = 3,
};
struct ControllerIdentity {
@ -18,12 +19,21 @@ struct ControllerIdentity {
uint8_t address[6]{};
uint16_t vendor_id = 0;
uint16_t product_id = 0;
uint8_t partner_address_type = 0;
uint8_t partner_address[6]{};
};
ControllerIdentity controller_identity_global();
bool controller_identity_is_global(const ControllerIdentity& identity);
bool controller_identity_equal(const ControllerIdentity& first,
const ControllerIdentity& second);
bool controller_identity_is_joycon_pair(const ControllerIdentity& identity);
bool controller_identity_make_joycon_pair(const ControllerIdentity& left,
const ControllerIdentity& right,
ControllerIdentity* output);
bool controller_identity_joycon_pair_members(const ControllerIdentity& pair,
ControllerIdentity* left,
ControllerIdentity* right);
bool controller_identity_encode(const ControllerIdentity& identity,
uint8_t* output, size_t output_size);
bool controller_identity_decode(const uint8_t* input, size_t input_size,

View file

@ -179,8 +179,8 @@ struct BackendSlot {
ControllerIdentity identity;
// Non-null with active=false is a connected device still becoming ready.
uni_hid_device_t* device;
// A pair occupies one output/profile, but still consumes two of the four
// Bluepad32 physical device indices. The left half always owns identity.
// A pair occupies one logical profile owner and output, but still consumes
// two Bluepad32 physical device indices. device is always the left half.
uni_hid_device_t* companion;
uni_gamepad_t gamepad;
uni_gamepad_t companion_gamepad;
@ -698,7 +698,7 @@ void publish_ble_identity(const BleIdentityMapping& mapping) {
bool observe_identity = false;
critical_section_enter_blocking(&g_state_lock);
for (BackendSlot& slot : g_slots) {
if (slot.device != nullptr &&
if (slot.device != nullptr && slot.companion == nullptr &&
gap_get_connection_type(slot.device->conn.handle) ==
GAP_CONNECTION_LE &&
slot.device->conn.handle == mapping.connection_handle &&
@ -748,7 +748,7 @@ void clear_ble_identity_for_handle(hci_con_handle_t connection_handle) {
critical_section_enter_blocking(&g_state_lock);
for (BackendSlot& slot : g_slots) {
if (slot.device != nullptr &&
if (slot.device != nullptr && slot.companion == nullptr &&
gap_get_connection_type(slot.device->conn.handle) ==
GAP_CONNECTION_LE &&
slot.device->conn.handle == connection_handle) {
@ -2326,6 +2326,35 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
}
if (partner_index >= 0) {
BackendSlot& partner = g_slots[partner_index];
const uint32_t partner_generation = partner.connection_generation;
const uint32_t pending_generation = pending.connection_generation;
uni_hid_device_t* const partner_device = partner.device;
const ControllerIdentity partner_identity =
identity_for_device(partner_device);
critical_section_exit(&g_state_lock);
ControllerIdentity pair_identity{};
if (!controller_identity_make_joycon_pair(
side < 0 ? connection_identity : partner_identity,
side < 0 ? partner_identity : connection_identity,
&pair_identity) ||
!profile_service_observe_joycon_pair_on_storage_core(
pair_identity)) {
return UNI_ERROR_INIT_FAILED;
}
// Storage can publish the complete bank without blocking Core 0.
// Only then replace the first-ready solo's topology and generation.
critical_section_enter_blocking(&g_state_lock);
if (!partner.active || partner.device != partner_device ||
partner.companion != nullptr ||
partner.connection_generation != partner_generation ||
pending.active ||
pending.connection_generation != pending_generation ||
reserve_device_slot(device) != slot_index) {
critical_section_exit(&g_state_lock);
return UNI_ERROR_INIT_FAILED;
}
invalidate_slot(partner);
if (side < 0) {
partner.companion = partner.device;
@ -2340,8 +2369,8 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
partner.companion_extra_buttons = 0;
}
// Preserve the existing player's output index regardless of
// physical connection order, with the left identity as profile owner.
partner.identity = identity_for_device(partner.device);
// physical connection order, with a separately seeded pair owner.
partner.identity = pair_identity;
refresh_topology_input(partner);
release_slot(pending);
slot_index = partner_index;
@ -2399,7 +2428,7 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
apply_slot_lighting(static_cast<uint8_t>(slot_index), companion);
}
}
if (connection_identity.stable) {
if (!paired && connection_identity.stable) {
profile_service_observe_identity_on_storage_core(
connection_identity);
}

View file

@ -63,6 +63,24 @@ bool valid_identity(const ControllerIdentity &identity) {
controller_identity_encode(identity, encoded, sizeof(encoded));
}
bool valid_owner_locked(const ControllerIdentity &identity) {
if (!valid_identity(identity)) {
return false;
}
if (!controller_identity_is_joycon_pair(identity)) {
return true;
}
if (g_metadata.state != ProfileServiceState::kReady) {
return false;
}
for (uint8_t index = 0; index < g_list.count; ++index) {
if (controller_identity_equal(g_list.rows[index].identity, identity)) {
return true;
}
}
return false;
}
void refresh_list_locked() {
g_list = ProfileServiceListSnapshot{};
g_list.metadata = g_metadata;
@ -119,6 +137,17 @@ void refresh_selected_locked() {
void refresh_metadata_locked(ProfileServiceState state) {
const ProfileStorageSnapshot &stored = g_storage.snapshot();
if (state == ProfileServiceState::kStorageError) {
// Keep the last committed publication usable by already-connected slots.
// In particular, a rejected pair setup must not replace either solo's
// profile with defaults or expose newly appended member rows.
g_metadata.state = state;
g_list.metadata = g_metadata;
g_selected.metadata = g_metadata;
g_selected.valid = false;
g_selected.status = ConfigurationTransactionStatus::kStorageError;
return;
}
g_metadata.state = state;
g_metadata.generation = stored.valid ? stored.generation : 0;
g_metadata.payload_crc = stored.valid ? stored.payload_crc : 0;
@ -216,7 +245,8 @@ void profile_service_initialize_on_storage_core() {
bool profile_service_observe_identity_on_storage_core(
const ControllerIdentity &identity) {
if (!g_prepared || !identity.stable ||
controller_identity_is_global(identity) || !valid_identity(identity)) {
controller_identity_is_global(identity) || !valid_identity(identity) ||
controller_identity_is_joycon_pair(identity)) {
return false;
}
critical_section_enter_blocking(&g_lock);
@ -228,6 +258,39 @@ bool profile_service_observe_identity_on_storage_core(
const ProfileStorageResult result = g_storage.ensure_identity(identity);
if (result != ProfileStorageResult::kOk &&
result != ProfileStorageResult::kUnchanged) {
if (result == ProfileStorageResult::kIoError) {
critical_section_enter_blocking(&g_lock);
refresh_metadata_locked(ProfileServiceState::kStorageError);
critical_section_exit(&g_lock);
}
return false;
}
critical_section_enter_blocking(&g_lock);
refresh_metadata_locked(ProfileServiceState::kReady);
critical_section_exit(&g_lock);
return true;
}
bool profile_service_observe_joycon_pair_on_storage_core(
const ControllerIdentity &pair) {
if (!g_prepared || !controller_identity_is_joycon_pair(pair)) {
return false;
}
critical_section_enter_blocking(&g_lock);
const bool ready = g_metadata.state == ProfileServiceState::kReady;
critical_section_exit(&g_lock);
if (!ready) {
return false;
}
// Flash writes and the seed snapshot must not hold either core's state lock.
const ProfileStorageResult result = g_storage.ensure_joycon_pair(pair);
if (result != ProfileStorageResult::kOk &&
result != ProfileStorageResult::kUnchanged) {
if (result == ProfileStorageResult::kIoError) {
critical_section_enter_blocking(&g_lock);
refresh_metadata_locked(ProfileServiceState::kStorageError);
critical_section_exit(&g_lock);
}
return false;
}
critical_section_enter_blocking(&g_lock);
@ -309,7 +372,7 @@ profile_service_select(const ControllerIdentity &identity,
critical_section_enter_blocking(&g_lock);
ConfigurationTransactionStatus status =
ConfigurationTransactionStatus::kCommitted;
if (!valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) {
if (!valid_owner_locked(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) {
status = ConfigurationTransactionStatus::kMalformed;
g_selected.metadata = g_metadata;
g_selected.identity = identity;
@ -359,7 +422,7 @@ profile_service_begin(uint32_t transaction_id,
g_transaction.profile_index = profile_index;
if (transaction_id == 0 ||
(transaction_id & kInternalTransactionIdMask) != 0 ||
!valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT ||
!valid_owner_locked(identity) || profile_index >= CONTROLLER_PROFILE_COUNT ||
payload_size == 0) {
g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed;
} else if (schema_version != CONTROLLER_PROFILE_SCHEMA_VERSION) {
@ -459,7 +522,7 @@ profile_service_reset(uint32_t transaction_id,
g_transaction.profile_index = profile_index;
if (transaction_id == 0 ||
(transaction_id & kInternalTransactionIdMask) != 0 ||
!valid_identity(identity) ||
!valid_owner_locked(identity) ||
(profile_index != CONTROLLER_PROFILE_ALL &&
profile_index >= CONTROLLER_PROFILE_COUNT)) {
g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed;
@ -498,7 +561,7 @@ profile_service_activate(uint32_t transaction_id,
g_transaction.profile_index = profile_index;
if (transaction_id == 0 ||
(transaction_id & kInternalTransactionIdMask) != 0 ||
!valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) {
!valid_owner_locked(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) {
g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed;
critical_section_exit(&g_lock);
return ConfigurationTransactionStatus::kMalformed;
@ -535,7 +598,7 @@ ConfigurationTransactionStatus profile_service_set_metadata(
bool malformed =
transaction_id == 0 ||
(transaction_id & kInternalTransactionIdMask) != 0 ||
!valid_identity(identity) ||
!valid_owner_locked(identity) ||
(profile_index != CONTROLLER_PROFILE_ALL &&
profile_index >= CONTROLLER_PROFILE_COUNT) ||
value_size > PROFILE_SERVICE_METADATA_MAX_BYTES ||
@ -582,7 +645,7 @@ profile_service_activate_internal(uint32_t transaction_id,
return ConfigurationTransactionStatus::kBusy;
}
if ((transaction_id & kInternalTransactionIdMask) == 0 ||
!valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) {
!valid_owner_locked(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) {
critical_section_exit(&g_lock);
return ConfigurationTransactionStatus::kMalformed;
}
@ -673,9 +736,12 @@ void profile_service_active_profile_snapshot(
critical_section_enter_blocking(&g_lock);
output->metadata = g_metadata;
if (g_metadata.state == ProfileServiceState::kReady &&
if ((g_metadata.state == ProfileServiceState::kReady ||
g_metadata.state == ProfileServiceState::kStorageError) &&
g_active_profile_count != 0) {
const PublishedActiveProfile *active = &g_active_profiles[0];
const PublishedActiveProfile *active =
controller_identity_is_joycon_pair(identity) ? nullptr
: &g_active_profiles[0];
for (uint8_t index = 1; index < g_active_profile_count; ++index) {
if (controller_identity_equal(g_active_profiles[index].identity,
identity)) {
@ -683,9 +749,11 @@ void profile_service_active_profile_snapshot(
break;
}
}
output->profile_index = active->profile_index;
output->profile = active->profile;
output->valid = true;
if (active != nullptr) {
output->profile_index = active->profile_index;
output->profile = active->profile;
output->valid = true;
}
}
critical_section_exit(&g_lock);
}

View file

@ -74,6 +74,8 @@ void profile_service_initialize_on_storage_core();
void profile_service_task_on_storage_core(uint32_t now_ms);
bool profile_service_observe_identity_on_storage_core(
const ControllerIdentity& identity);
bool profile_service_observe_joycon_pair_on_storage_core(
const ControllerIdentity& pair);
ConfigurationTransactionStatus profile_service_select(
const ControllerIdentity& identity, uint8_t profile_index);

View file

@ -9,7 +9,8 @@ constexpr uint8_t kRecordMagic[4] = {'S', 'P', 'C', 'R'};
constexpr uint8_t kLegacyStorageMagic[4] = {'S', 'P', 'P', 'F'};
constexpr uint8_t kLegacyDatabaseMagic[4] = {'S', 'P', 'D', 'B'};
constexpr uint16_t kLegacyCatalogVersion = 1;
constexpr uint16_t kCatalogVersion = 2;
constexpr uint16_t kExpandedCatalogVersion = 2;
constexpr uint16_t kCatalogVersion = 3;
constexpr size_t kRecordPayloadOffset = 128;
constexpr size_t kLegacyRecordPayloadOffset = 256;
static_assert(kRecordPayloadOffset + CONTROLLER_PROFILE_ENCODED_SIZE ==
@ -188,10 +189,17 @@ bool ProfileStorage::initialize(const ProfileStorageIo &io) {
return initialized_;
}
bool ProfileStorage::valid_owner(const ControllerIdentity &identity) const {
return valid_identity(identity) &&
(!controller_identity_is_joycon_pair(identity) ||
find(identity) != nullptr);
}
ProfileStorageResult
ProfileStorage::ensure_identity(const ControllerIdentity &identity) {
if (!initialized_ || !valid_identity(identity)) {
return ProfileStorageResult::kInvalidArgument;
if (!initialized_ || !valid_owner(identity)) {
return initialized_ ? ProfileStorageResult::kInvalidArgument
: ProfileStorageResult::kIoError;
}
if (find(identity) != nullptr) {
return ProfileStorageResult::kUnchanged;
@ -202,12 +210,46 @@ ProfileStorage::ensure_identity(const ControllerIdentity &identity) {
return append(RecordType::kActivate, identity, 0, nullptr, 0);
}
ProfileStorageResult
ProfileStorage::ensure_joycon_pair(const ControllerIdentity &pair) {
if (!initialized_) {
return ProfileStorageResult::kIoError;
}
ControllerIdentity left{};
ControllerIdentity right{};
if (!controller_identity_joycon_pair_members(pair, &left, &right)) {
return ProfileStorageResult::kInvalidArgument;
}
if (find(pair) != nullptr) {
return ProfileStorageResult::kUnchanged;
}
const uint8_t needed = 1 + (find(left) == nullptr) + (find(right) == nullptr);
if (identity_count_ + needed > CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) {
return ProfileStorageResult::kFull;
}
ProfileStorageResult result = ensure_identity(left);
if (result != ProfileStorageResult::kOk &&
result != ProfileStorageResult::kUnchanged) {
return result;
}
result = ensure_identity(right);
if (result != ProfileStorageResult::kOk &&
result != ProfileStorageResult::kUnchanged) {
return result;
}
// One committed record snapshots the immutable left record references.
// Defaults are identity-independent; later edits use normal copy-on-write.
return append(RecordType::kSeedJoyConPair, pair,
CONTROLLER_PROFILE_ALL, nullptr, 0);
}
ProfileStorageResult ProfileStorage::get(const ControllerIdentity &identity,
uint8_t profile_index,
ControllerProfile *output) const {
if (!initialized_ || output == nullptr || !valid_identity(identity) ||
if (!initialized_ || output == nullptr || !valid_owner(identity) ||
profile_index >= CONTROLLER_PROFILE_COUNT) {
return ProfileStorageResult::kInvalidArgument;
return initialized_ ? ProfileStorageResult::kInvalidArgument
: ProfileStorageResult::kIoError;
}
const ProfileStorageIdentityIndex *entry = find(identity);
if (entry == nullptr ||
@ -223,10 +265,11 @@ ProfileStorageResult ProfileStorage::get(const ControllerIdentity &identity,
ProfileStorageResult ProfileStorage::set(const ControllerIdentity &identity,
uint8_t profile_index,
const ControllerProfile &profile) {
if (!initialized_ || !valid_identity(identity) ||
if (!initialized_ || !valid_owner(identity) ||
profile_index >= CONTROLLER_PROFILE_COUNT ||
!controller_profile_validate(profile)) {
return ProfileStorageResult::kInvalidArgument;
return initialized_ ? ProfileStorageResult::kInvalidArgument
: ProfileStorageResult::kIoError;
}
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
if (!controller_profile_encode(profile, encoded, sizeof(encoded))) {
@ -250,10 +293,11 @@ ProfileStorageResult ProfileStorage::set(const ControllerIdentity &identity,
ProfileStorageResult ProfileStorage::reset(const ControllerIdentity &identity,
uint8_t profile_index) {
if (!initialized_ || !valid_identity(identity) ||
if (!initialized_ || !valid_owner(identity) ||
(profile_index != CONTROLLER_PROFILE_ALL &&
profile_index >= CONTROLLER_PROFILE_COUNT)) {
return ProfileStorageResult::kInvalidArgument;
return initialized_ ? ProfileStorageResult::kInvalidArgument
: ProfileStorageResult::kIoError;
}
const ProfileStorageIdentityIndex *entry = find(identity);
if (entry == nullptr) {
@ -278,9 +322,10 @@ ProfileStorageResult ProfileStorage::reset(const ControllerIdentity &identity,
ProfileStorageResult
ProfileStorage::activate(const ControllerIdentity &identity,
uint8_t profile_index) {
if (!initialized_ || !valid_identity(identity) ||
if (!initialized_ || !valid_owner(identity) ||
profile_index >= CONTROLLER_PROFILE_COUNT) {
return ProfileStorageResult::kInvalidArgument;
return initialized_ ? ProfileStorageResult::kInvalidArgument
: ProfileStorageResult::kIoError;
}
const ProfileStorageIdentityIndex *entry = find(identity);
if (entry != nullptr && entry->active_profile == profile_index) {
@ -297,8 +342,9 @@ 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;
!valid_owner(identity)) {
return initialized_ ? ProfileStorageResult::kInvalidArgument
: ProfileStorageResult::kIoError;
}
const ProfileStorageIdentityIndex *entry = find(identity);
if (entry == nullptr ||
@ -314,10 +360,11 @@ ProfileStorageResult ProfileStorage::get_alias(
ProfileStorageResult ProfileStorage::set_alias(
const ControllerIdentity &identity, const char *value,
size_t value_size) {
if (!initialized_ || !valid_identity(identity) ||
if (!initialized_ || !valid_owner(identity) ||
value_size > PROFILE_STORAGE_METADATA_MAX_BYTES ||
(value_size != 0 && value == nullptr)) {
return ProfileStorageResult::kInvalidArgument;
return initialized_ ? ProfileStorageResult::kInvalidArgument
: ProfileStorageResult::kIoError;
}
char current[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
if (find(identity) == nullptr &&
@ -343,9 +390,10 @@ 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) ||
!valid_owner(identity) ||
profile_index >= CONTROLLER_PROFILE_COUNT) {
return ProfileStorageResult::kInvalidArgument;
return initialized_ ? ProfileStorageResult::kInvalidArgument
: ProfileStorageResult::kIoError;
}
const ProfileStorageIdentityIndex *entry = find(identity);
if (entry == nullptr ||
@ -374,11 +422,12 @@ ProfileStorageResult ProfileStorage::get_profile_name(
ProfileStorageResult ProfileStorage::set_profile_name(
const ControllerIdentity &identity, uint8_t profile_index,
const char *value, size_t value_size) {
if (!initialized_ || !valid_identity(identity) ||
if (!initialized_ || !valid_owner(identity) ||
profile_index >= CONTROLLER_PROFILE_COUNT ||
value_size > PROFILE_STORAGE_METADATA_MAX_BYTES ||
(value_size != 0 && value == nullptr)) {
return ProfileStorageResult::kInvalidArgument;
return initialized_ ? ProfileStorageResult::kInvalidArgument
: ProfileStorageResult::kIoError;
}
const ProfileStorageIdentityIndex *entry = find(identity);
if (entry == nullptr &&
@ -443,7 +492,8 @@ ProfileStorageResult ProfileStorage::scan_arena(
}
*version = read_u16(&superblock[4]);
if (memcmp(superblock, kSuperblockMagic, sizeof(kSuperblockMagic)) != 0 ||
(*version != kCatalogVersion && *version != kLegacyCatalogVersion) ||
(*version != kCatalogVersion && *version != kExpandedCatalogVersion &&
*version != kLegacyCatalogVersion) ||
profile_storage_crc32(superblock, 12) != read_u32(&superblock[12]) ||
!bytes_are(0, &superblock[16], sizeof(superblock) - 16)) {
return ProfileStorageResult::kUnchanged;
@ -479,9 +529,11 @@ ProfileStorageResult ProfileStorage::scan_arena(
controller_identity_decode(&record[20], CONTROLLER_IDENTITY_ENCODED_SIZE,
&identity_value);
const uint32_t record_generation = read_u32(&record[8]);
apply_record(index, identity_count, static_cast<RecordType>(record[6]),
identity_value, record[7], record_generation,
pack_record(arena, offset));
if (!apply_record(index, identity_count, static_cast<RecordType>(record[6]),
identity_value, record[7], record_generation,
pack_record(arena, offset))) {
return ProfileStorageResult::kIoError;
}
if (!have_generation || generation_is_newer(record_generation, *generation)) {
have_generation = true;
*generation = record_generation;
@ -512,7 +564,8 @@ bool ProfileStorage::validate_record(const uint8_t *record,
const bool known_type =
type == RecordType::kProfile || type == RecordType::kReset ||
type == RecordType::kResetAll || type == RecordType::kActivate ||
type == RecordType::kAlias || type == RecordType::kProfileNames;
type == RecordType::kAlias || type == RecordType::kProfileNames ||
(version == kCatalogVersion && type == RecordType::kSeedJoyConPair);
const bool indexed_profile =
type == RecordType::kProfile || type == RecordType::kReset ||
type == RecordType::kActivate;
@ -529,6 +582,11 @@ bool ProfileStorage::validate_record(const uint8_t *record,
if (!controller_identity_decode(
&record[20], CONTROLLER_IDENTITY_ENCODED_SIZE, &identity_value) ||
!valid_identity(identity_value) || !known_type ||
(version != kCatalogVersion &&
controller_identity_is_joycon_pair(identity_value)) ||
(type == RecordType::kSeedJoyConPair &&
(!controller_identity_is_joycon_pair(identity_value) ||
profile_index != CONTROLLER_PROFILE_ALL)) ||
(indexed_profile && profile_index >= CONTROLLER_PROFILE_COUNT) ||
payload_size != expected_payload_size ||
profile_storage_crc32(payload, payload_size) != read_u32(&record[16])) {
@ -575,7 +633,8 @@ bool ProfileStorage::read_record_payload(
? kLegacyRecordPayloadOffset
: kRecordPayloadOffset;
const size_t payload_size = read_u16(&header[12]);
if ((version != kCatalogVersion && version != kLegacyCatalogVersion) ||
if ((version != kCatalogVersion && version != kExpandedCatalogVersion &&
version != kLegacyCatalogVersion) ||
payload_size > capacity ||
payload_size > PROFILE_STORAGE_RECORD_SIZE - payload_offset ||
!io_.read(io_.context, record_arena(record),
@ -620,6 +679,7 @@ ProfileStorageResult ProfileStorage::append(RecordType type,
if (next_offset_ + PROFILE_STORAGE_RECORD_SIZE > PROFILE_STORAGE_ARENA_SIZE) {
const ProfileStorageResult result = compact();
if (result != ProfileStorageResult::kOk) {
initialized_ = false;
return result;
}
}
@ -628,10 +688,17 @@ ProfileStorageResult ProfileStorage::append(RecordType type,
next_offset_ += PROFILE_STORAGE_RECORD_SIZE;
if (!write_record(snapshot_.active_bank, offset, type, identity,
profile_index, generation, payload, payload_size)) {
// The header may have committed even when program/readback reports an
// error. Freeze all mutations until initialize() replays durable state;
// otherwise a retry could seed from a different left snapshot.
initialized_ = false;
return ProfileStorageResult::kIoError;
}
if (!apply_record(index_, &identity_count_, type, identity, profile_index,
generation, pack_record(snapshot_.active_bank, offset))) {
initialized_ = false;
return ProfileStorageResult::kIoError;
}
apply_record(index_, &identity_count_, type, identity, profile_index,
generation, pack_record(snapshot_.active_bank, offset));
snapshot_.generation = generation;
snapshot_.payload_crc = profile_storage_crc32(payload, payload_size);
return ProfileStorageResult::kOk;
@ -851,7 +918,8 @@ ProfileStorageResult ProfileStorage::migrate_legacy() {
!controller_identity_decode(
entry_header, CONTROLLER_IDENTITY_ENCODED_SIZE, &identity_value) ||
!identity_value.stable ||
controller_identity_is_global(identity_value)) {
controller_identity_is_global(identity_value) ||
controller_identity_is_joycon_pair(identity_value)) {
return ProfileStorageResult::kIoError;
}
for (uint8_t profile = 0; profile < PROFILE_STORAGE_LEGACY_PROFILE_COUNT;
@ -1004,7 +1072,7 @@ bool ProfileStorage::write_record(uint8_t arena, size_t offset, RecordType type,
memcmp(record, verified, sizeof(record)) == 0;
}
void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index,
bool ProfileStorage::apply_record(ProfileStorageIdentityIndex *index,
uint8_t *identity_count, RecordType type,
const ControllerIdentity &identity,
uint8_t profile_index, uint32_t generation,
@ -1017,9 +1085,46 @@ void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index,
break;
}
}
if (type == RecordType::kSeedJoyConPair) {
if (entry != nullptr) {
return true; // A duplicate seed can never overwrite pair-owned edits.
}
ControllerIdentity left{};
ControllerIdentity right{};
if (!controller_identity_joycon_pair_members(identity, &left, &right) ||
*identity_count >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) {
return false;
}
const ProfileStorageIdentityIndex *source = nullptr;
bool have_right = false;
for (uint8_t current = 0; current < *identity_count; ++current) {
if (index[current].used &&
controller_identity_equal(index[current].identity, left)) {
source = &index[current];
}
if (index[current].used &&
controller_identity_equal(index[current].identity, right)) {
have_right = true;
}
}
if (source == nullptr || !have_right) {
return false;
}
entry = &index[(*identity_count)++];
*entry = *source;
entry->identity = identity;
entry->alias_record = PROFILE_STORAGE_NO_RECORD;
entry->alias_generation = 0;
entry->active_generation = generation;
entry->profile_names_generation = generation;
for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) {
entry->profile_generation[profile] = generation;
}
return true;
}
if (entry == nullptr) {
if (*identity_count >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) {
return;
return false;
}
entry = &index[(*identity_count)++];
*entry = {};
@ -1035,7 +1140,7 @@ void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index,
entry->active_profile = profile_index;
entry->active_generation = generation;
}
return;
return true;
}
if (type == RecordType::kAlias) {
if (entry->alias_generation == 0 ||
@ -1043,7 +1148,7 @@ void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index,
entry->alias_record = record;
entry->alias_generation = generation;
}
return;
return true;
}
if (type == RecordType::kProfileNames) {
if (entry->profile_names_generation == 0 ||
@ -1052,7 +1157,7 @@ void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index,
entry->profile_names_record = record;
entry->profile_names_generation = generation;
}
return;
return true;
}
if (type == RecordType::kResetAll) {
for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) {
@ -1067,7 +1172,7 @@ void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index,
entry->active_profile = 0;
entry->active_generation = generation;
}
return;
return true;
}
if (entry->profile_generation[profile_index] == 0 ||
generation_is_newer(generation,
@ -1076,4 +1181,5 @@ void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index,
type == RecordType::kProfile ? record : PROFILE_STORAGE_NO_RECORD;
entry->profile_generation[profile_index] = generation;
}
return true;
}

View file

@ -93,6 +93,7 @@ class ProfileStorage {
public:
bool initialize(const ProfileStorageIo &io);
ProfileStorageResult ensure_identity(const ControllerIdentity &identity);
ProfileStorageResult ensure_joycon_pair(const ControllerIdentity &pair);
ProfileStorageResult get(const ControllerIdentity &identity,
uint8_t profile_index,
ControllerProfile *output) const;
@ -130,8 +131,10 @@ private:
kActivate = 4,
kAlias = 5,
kProfileNames = 6,
kSeedJoyConPair = 7,
};
bool valid_owner(const ControllerIdentity &identity) const;
ProfileStorageResult scan_arena(
uint8_t arena, uint16_t *version, uint32_t *epoch,
uint32_t *generation, uint32_t *payload_crc, size_t *next_offset,
@ -154,7 +157,7 @@ private:
const ControllerIdentity &identity, uint8_t profile_index,
uint32_t generation, const uint8_t *payload,
size_t payload_size) const;
void apply_record(ProfileStorageIdentityIndex *index, uint8_t *identity_count,
bool apply_record(ProfileStorageIdentityIndex *index, uint8_t *identity_count,
RecordType type, const ControllerIdentity &identity,
uint8_t profile_index, uint32_t generation,
uint32_t record) const;

View file

@ -132,6 +132,7 @@ PAIRING_RECORD_CAPACITY = 16
TRANSPORT_UNKNOWN = 0
TRANSPORT_CLASSIC = 1
TRANSPORT_BLE = 2
TRANSPORT_JOYCON_PAIR = 3
PROFILE_LEGACY_SCHEMA_VERSION = 1
PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2
PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3
@ -622,6 +623,10 @@ class PairingRecord:
address_type: int
address: bytes
def __post_init__(self) -> None:
if self.transport not in (TRANSPORT_CLASSIC, TRANSPORT_BLE):
raise ConfigManagerError("pairing record must identify a physical Bluetooth peer")
@property
def address_text(self) -> str:
return ":".join(f"{octet:02X}" for octet in self.address)
@ -767,15 +772,39 @@ class ControllerIdentity:
address: bytes
vendor_id: int
product_id: int
partner_address_type: int = 0
partner_address: bytes = bytes(6)
def __post_init__(self) -> None:
_require_bool(self.stable, "identity stable")
_require_int(self.transport, "identity transport", 0, 2)
_require_int(self.transport, "identity transport", 0, TRANSPORT_JOYCON_PAIR)
_require_int(self.address_type, "identity address_type", 0, 0xFF)
if type(self.address) is not bytes or len(self.address) != 6:
raise ConfigManagerError("identity address must contain six bytes")
_require_int(self.vendor_id, "identity vendor_id", 0, 0xFFFF)
_require_int(self.product_id, "identity product_id", 0, 0xFFFF)
_require_int(self.partner_address_type, "identity partner_address_type", 0, 0xFF)
if type(self.partner_address) is not bytes or len(self.partner_address) != 6:
raise ConfigManagerError("identity partner_address must contain six bytes")
if self.is_joycon_pair:
if (
not self.stable
or self.vendor_id != 0x057E
or self.product_id != 0x2067
or self.address_type not in (0, 1)
or self.partner_address_type not in (0, 1)
or (self.address_type == 1 and self.address[0] & 0xC0 != 0xC0)
or (
self.partner_address_type == 1
and self.partner_address[0] & 0xC0 != 0xC0
)
or (self.address_type, self.address)
== (self.partner_address_type, self.partner_address)
):
raise ConfigManagerError("invalid Joy-Con 2 pair identity")
return
if self.partner_address_type != 0 or self.partner_address != bytes(6):
raise ConfigManagerError("physical identity cannot contain a partner address")
if self.stable:
if self.transport not in (TRANSPORT_CLASSIC, TRANSPORT_BLE):
raise ConfigManagerError(
@ -796,11 +825,53 @@ class ControllerIdentity:
def global_fallback(cls) -> ControllerIdentity:
return cls(False, TRANSPORT_UNKNOWN, 0, bytes(6), 0, 0)
@classmethod
def make_joycon_pair(
cls, left: ControllerIdentity, right: ControllerIdentity
) -> ControllerIdentity:
for member, product_id in ((left, 0x2067), (right, 0x2066)):
if (
not isinstance(member, ControllerIdentity)
or not member.stable
or member.transport != TRANSPORT_BLE
or member.vendor_id != 0x057E
or member.product_id != product_id
or member.address_type not in (0, 1)
):
raise ConfigManagerError(
"pair members must be stable BLE Joy-Con 2 (L) and (R) identities"
)
return cls(
True, TRANSPORT_JOYCON_PAIR, left.address_type, left.address,
0x057E, 0x2067, right.address_type, right.address,
)
def joycon_pair_members(self) -> tuple[ControllerIdentity, ControllerIdentity]:
if not self.is_joycon_pair:
raise ConfigManagerError("identity is not a Joy-Con 2 pair")
return (
ControllerIdentity(
True, TRANSPORT_BLE, self.address_type, self.address, 0x057E, 0x2067,
),
ControllerIdentity(
True, TRANSPORT_BLE, self.partner_address_type, self.partner_address,
0x057E, 0x2066,
),
)
@classmethod
def from_bytes(cls, payload: bytes) -> ControllerIdentity:
payload = bytes(payload)
if len(payload) != CONTROLLER_IDENTITY_SIZE:
raise ConfigManagerError("invalid controller identity size")
if payload[1] == TRANSPORT_JOYCON_PAIR:
flags = payload[0]
if flags & ~0x07 or not flags & 1:
raise ConfigManagerError("invalid Joy-Con 2 pair flags")
return cls(
True, TRANSPORT_JOYCON_PAIR, (flags >> 1) & 1, payload[2:8],
0x057E, 0x2067, (flags >> 2) & 1, payload[8:14],
)
stable, transport, address_type, reserved = payload[:4]
if stable not in (0, 1) or reserved != 0:
raise ConfigManagerError("invalid controller identity encoding")
@ -815,6 +886,15 @@ class ControllerIdentity:
)
def to_bytes(self) -> bytes:
if self.is_joycon_pair:
return (
bytes((
1 | (self.address_type << 1) | (self.partner_address_type << 2),
TRANSPORT_JOYCON_PAIR,
))
+ self.address
+ self.partner_address
)
return (
bytes(
[
@ -832,18 +912,47 @@ class ControllerIdentity:
def is_global_fallback(self) -> bool:
return not self.stable
@property
def is_joycon_pair(self) -> bool:
return self.transport == TRANSPORT_JOYCON_PAIR
@property
def address_text(self) -> str:
return ":".join(f"{octet:02X}" for octet in self.address)
@property
def partner_address_text(self) -> str:
return ":".join(f"{octet:02X}" for octet in self.partner_address)
@property
def transport_text(self) -> str:
if self.transport == TRANSPORT_CLASSIC:
return "Classic"
if self.transport == TRANSPORT_BLE:
return "BLE"
if self.is_joycon_pair:
return "BLE pair"
return "Unknown"
def to_json_object(self) -> dict[str, Any]:
result: dict[str, Any] = {
"stable": self.stable,
"address": self.address_text,
"address_type": self.address_type,
"transport": self.transport_text,
"vendor_id": self.vendor_id,
"product_id": self.product_id,
"is_joycon_pair": self.is_joycon_pair,
}
if self.is_joycon_pair:
left, right = self.joycon_pair_members()
result.update(
partner_address=self.partner_address_text,
partner_address_type=self.partner_address_type,
members={"left": left.to_json_object(), "right": right.to_json_object()},
)
return result
@dataclass(frozen=True)
class ProfileListEntry:
@ -919,12 +1028,7 @@ class ProfilePlaytest:
"connection_generation": self.connection_generation,
"state_generation": self.state_generation,
"identity": (
{
"address": self.identity.address_text,
"transport": self.identity.transport_text,
"vendor_id": self.identity.vendor_id,
"product_id": self.identity.product_id,
}
self.identity.to_json_object()
if self.identity is not None
else None
),
@ -4142,11 +4246,22 @@ def _print_pairings(snapshot: PairingSnapshot) -> None:
print("Warning: additional pairings did not fit in the response.")
def _print_profiles(entries: Sequence[ProfileListEntry]) -> None:
def _print_profiles(
entries: Sequence[ProfileListEntry], *, physical_only: bool = False
) -> None:
for index, entry in enumerate(entries):
identity = entry.identity
if physical_only and (identity.is_global_fallback or identity.is_joycon_pair):
continue
if identity.is_global_fallback:
description = "global fallback"
elif identity.is_joycon_pair:
description = (
f"Nintendo Joy-Con 2 (L+R) · {identity.transport_text} "
f"L {identity.address_text} address-type {identity.address_type}; "
f"R {identity.partner_address_text} "
f"address-type {identity.partner_address_type}"
)
else:
description = (
f"{identity.transport_text} {identity.address_text} "
@ -4550,7 +4665,7 @@ def main(argv: Sequence[str] | None = None) -> int:
print(NATIVE_SWITCH_RUMBLE_APPROVAL_NOTE)
if configuration.schema_version != CONFIGURATION_SCHEMA_VERSION:
print("Native rumble approval requires schema 3 firmware.")
_print_profiles(entries)
_print_profiles(entries, physical_only=True)
for index, entry in enumerate(entries):
if entry.identity in configuration.native_switch_controllers:
print(

View file

@ -144,6 +144,12 @@ def _controller_presentation(
identity: config_manager.ControllerIdentity,
layout: str | None = None,
) -> dict[str, str]:
if identity.is_joycon_pair:
return {
"model": "Nintendo Joy-Con 2 (L+R)",
"style": "switch",
"layout": "joycon2-pair",
}
live_layouts = {
"joycon2-left": ("Nintendo Joy-Con 2 (L)", "switch", {0x2067}),
"joycon2-right": ("Nintendo Joy-Con 2 (R)", "switch", {0x2066}),
@ -235,6 +241,9 @@ def _controller_label(
}
model = friendly_models.get(presentation["model"], presentation["model"])
address_suffix = ":".join(f"{octet:02X}" for octet in identity.address[-2:])
if identity.is_joycon_pair:
partner_suffix = ":".join(f"{octet:02X}" for octet in identity.partner_address[-2:])
return f"{model} · L {address_suffix} + R {partner_suffix}"
return f"{model} · {address_suffix}"
@ -530,6 +539,7 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
"index": index,
"label": _controller_label(entry.identity, entry.alias),
"key": entry.identity.to_bytes().hex(),
"identity": entry.identity.to_json_object(),
"active_profile": entry.active_profile_index + 1,
"controller": controller,
"alias": entry.alias,
@ -553,7 +563,7 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
def _read_playtest(self, identity_index: int, profile_index: int) -> dict[str, Any]:
device = self.profile_server.find_device()
_, identity = self._entries_and_identity(device, identity_index)
entries, identity = self._entries_and_identity(device, identity_index)
config_manager.select_profile(device, identity, profile_index)
playtest = config_manager.read_profile_playtest(device)
result = playtest.to_json_object()
@ -570,7 +580,10 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
)
result["source_controls"] = _source_controls(result["controller"])
result["label"] = (
_controller_label(playtest.identity)
_controller_label(
playtest.identity,
entries[identity_index].alias if playtest.identity == identity else "",
)
if playtest.identity is not None
else None
)

View file

@ -116,7 +116,7 @@ const ControllerLayouts = (() => {
left_sl: back("Left SL · rail", "L SL"), left_sr: back("Left SR · rail", "L SR"),
right_sl: back("Right SL · rail", "R SL"), right_sr: back("Right SR · rail", "R SR"),
},
note: "Paired inputs use the left controller's profile. The grip is illustrative, not detected. Rear triggers and rails are not visible in this front view. C and rail inputs are source-only; GL/GR belong to Switch 2 Pro, not Joy-Con 2.",
note: "Select the L+R profile owner for paired settings; its eight profiles are independent of both solo banks. If no L+R owner is listed, update firmware and connect both members. The grip is illustrative, not detected. Rear triggers and rails are not visible in this front view. C and rail inputs are source-only; GL/GR belong to Switch 2 Pro, not Joy-Con 2.",
},
"joycon2-left": {
name: "Joy-Con 2 left · sideways solo", style: "switch", asset: "switch-2-joycon-left.svg",
@ -145,7 +145,7 @@ const ControllerLayouts = (() => {
right_trigger: back("ZR · rear", "ZR"),
right_sl: back("SL · rail extra", "SL extra"), right_sr: back("SR · rail extra", "SR extra"),
},
note: "Solo firmware rotates ABXY sources; the right stick becomes LEFT axes and left-stick click. Output labels do not rotate. When paired, select the left controller's profile to edit the pair. " + railNote,
note: "Solo firmware rotates ABXY sources; the right stick becomes LEFT axes and left-stick click. Output labels do not rotate. Select the separate L+R profile owner for paired settings, not this solo bank. " + railNote,
},
"wii-remote": {
name: "Wii Remote · orientation unknown", style: "switch", asset: "wii-remote-simple.svg",

View file

@ -346,8 +346,11 @@ function renderPlaytest(sample) {
state.liveSample = sample;
if (!matchingLiveSample(sample)) {
clearPlaytest(
sample.connected ? "Live input belongs to another profile owner; this draft and its layout are unchanged." :
"Connect or move the selected controller to compare its raw input with this draft."
sample.connected
? sample.identity?.is_joycon_pair
? "Paired input belongs to the L+R profile owner. Select that owner for paired settings; this draft and its layout are unchanged."
: "Live input belongs to another profile owner; this draft and its layout are unchanged."
: "Connect or move the selected controller to compare its raw input with this draft."
);
return;
}
@ -870,9 +873,12 @@ function renderButtonMap() {
: live && !topologyKnown
? "Legacy firmware does not report Joy-Con pair/solo topology. This is an owner reference, not detected solo mode. Choose a preview or update firmware."
: "Auto uses only the selected owner's metadata. Preview changes this editor's source labels and diagram, never firmware topology or saved mappings.";
elements.controllerLayoutNote.textContent = layout.note ||
(layout.generic ? "Generic reference art is not a model identification. Additional reported sources are unlocated, not buttons on this drawing." :
"Rear triggers are listed off-art below the front view. Unavailable stored mappings are retained.");
elements.controllerLayoutNote.textContent =
!preview && live?.layout === "joycon2-pair" && !live.identity?.is_joycon_pair
? "This older firmware reports paired input through a physical controller's profile bank. There is no independent L+R bank; edits still affect the selected existing owner. Update firmware for separate pair profiles. The grip is illustrative, not detected."
: layout.note ||
(layout.generic ? "Generic reference art is not a model identification. Additional reported sources are unlocated, not buttons on this drawing." :
"Rear triggers are listed off-art below the front view. Unavailable stored mappings are retained.");
const diagramKey = `${layoutId}:${sources.join(",")}`;
if (diagramKey !== state.diagramKey) {
const focusedSource = elements.controllerCanvas.contains(document.activeElement)

View file

@ -14,6 +14,8 @@
#include "parser/uni_switch2_pairing.h"
#include "platform/pico/controller_color_config.h"
#include "input/switch2_wake.h"
#include "pico/critical_section.h"
#include "profile/profile_storage.h"
namespace {
@ -648,7 +650,26 @@ void switch2_wake_diagnostics(Switch2WakeDiagnostics*) {
}
#include "core/controller_identity.cpp"
namespace {
unsigned state_lock_depth = 0;
void tracked_state_lock_enter(critical_section_t* lock) {
critical_section_enter_blocking(lock);
++state_lock_depth;
}
void tracked_state_lock_exit(critical_section_t* lock) {
require(state_lock_depth != 0, "state lock exit must match an enter");
--state_lock_depth;
critical_section_exit(lock);
}
} // namespace
#define critical_section_enter_blocking tracked_state_lock_enter
#define critical_section_exit tracked_state_lock_exit
#include "input/bluepad32_input_backend.cpp"
#undef critical_section_enter_blocking
#undef critical_section_exit
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
namespace {
@ -701,6 +722,61 @@ void haptics_transport_probe_send(uint32_t, uint32_t, bool) {}
#endif
namespace {
uint8_t profile_flash[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE]{};
ProfileStorage runtime_profile_storage;
bool runtime_profile_storage_initialized = false;
bool fail_profile_program = false;
unsigned pair_observation_count = 0;
void (*before_pair_seed)(const ControllerIdentity&) = nullptr;
bool runtime_profile_read(void*, uint8_t arena, size_t offset,
uint8_t* output, size_t size) {
require(state_lock_depth == 0, "profile reads must not hold the input state lock");
if (arena >= PROFILE_STORAGE_ARENA_COUNT ||
offset > PROFILE_STORAGE_ARENA_SIZE ||
size > PROFILE_STORAGE_ARENA_SIZE - offset) {
return false;
}
memcpy(output, &profile_flash[arena][offset], size);
return true;
}
bool runtime_profile_erase(void*, uint8_t arena) {
require(state_lock_depth == 0, "profile erases must not hold the input state lock");
if (arena >= PROFILE_STORAGE_ARENA_COUNT) return false;
memset(profile_flash[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE);
return true;
}
bool runtime_profile_program(void*, uint8_t arena, size_t offset,
const uint8_t* page, size_t size) {
require(state_lock_depth == 0, "profile writes must not hold the input state lock");
if (fail_profile_program || arena >= PROFILE_STORAGE_ARENA_COUNT ||
size != PROFILE_STORAGE_PAGE_SIZE ||
offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
offset > PROFILE_STORAGE_ARENA_SIZE - size) {
return false;
}
for (size_t index = 0; index < size; ++index) {
profile_flash[arena][offset + index] &= page[index];
}
return memcmp(&profile_flash[arena][offset], page, size) == 0;
}
ProfileStorageIo runtime_profile_io() {
return {nullptr, PROFILE_STORAGE_ARENA_SIZE,
PROFILE_STORAGE_SECTOR_SIZE, PROFILE_STORAGE_PAGE_SIZE,
runtime_profile_read, runtime_profile_erase, runtime_profile_program};
}
void initialize_runtime_profile_storage() {
if (runtime_profile_storage_initialized) return;
memset(profile_flash, 0xff, sizeof(profile_flash));
require(runtime_profile_storage.initialize(runtime_profile_io()),
"runtime profile catalog must initialize");
runtime_profile_storage_initialized = true;
}
void require_clear_completion_pending() {
if (expected_pending_clear_token != 0) {
require(!bluepad32_input_backend_clear_pairings_completed(
@ -752,8 +828,23 @@ bool profile_service_observe_identity_on_storage_core(
"profile identity observation fixture overflow");
observed_profile_identities[observed_profile_identity_count++] =
identity;
if (runtime_profile_storage_initialized) {
const ProfileStorageResult result = runtime_profile_storage.ensure_identity(identity);
return result == ProfileStorageResult::kOk ||
result == ProfileStorageResult::kUnchanged;
}
return true;
}
bool profile_service_observe_joycon_pair_on_storage_core(
const ControllerIdentity& identity) {
require(state_lock_depth == 0, "pair admission must release the input state lock");
++pair_observation_count;
if (before_pair_seed != nullptr) before_pair_seed(identity);
initialize_runtime_profile_storage();
const ProfileStorageResult result = runtime_profile_storage.ensure_joycon_pair(identity);
return result == ProfileStorageResult::kOk ||
result == ProfileStorageResult::kUnchanged;
}
void configuration_service_snapshot(ConfigurationServiceSnapshot* output) {
*output = {};
output->state = ConfigurationServiceState::kReady;
@ -840,6 +931,10 @@ void dispatch_identity_event(uint8_t event_type,
packet[2] = static_cast<uint8_t>(controller.conn.handle);
packet[3] = static_cast<uint8_t>(controller.conn.handle >> 8);
switch (event_type) {
case SM_EVENT_IDENTITY_RESOLVING_STARTED:
case SM_EVENT_IDENTITY_RESOLVING_FAILED:
packet_size = 11;
break;
case SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED:
packet_size = sizeof(packet);
packet[4] = BD_ADDR_TYPE_LE_RANDOM;
@ -968,6 +1063,32 @@ Bluepad32SlotSnapshot slot_snapshot(uint8_t index) {
return result;
}
void require_pair_owner(const ControllerIdentity& identity,
const uni_hid_device_t& left,
const uni_hid_device_t& right) {
ControllerIdentity left_member{};
ControllerIdentity right_member{};
require(controller_identity_is_joycon_pair(identity) &&
controller_identity_joycon_pair_members(
identity, &left_member, &right_member) &&
controller_identity_equal(left_member, identity_for_device(&left)) &&
controller_identity_equal(right_member, identity_for_device(&right)) &&
!controller_identity_equal(identity, left_member) &&
!controller_identity_equal(identity, right_member),
"logical owner must distinguish both typed physical members from their solo owners");
}
void require_active_profile(const ControllerIdentity& identity,
uint8_t expected_index, uint8_t expected_scale) {
const ProfileStorageIdentityIndex* owner = runtime_profile_storage.find(identity);
ControllerProfile profile{};
require(owner != nullptr && owner->active_profile == expected_index &&
runtime_profile_storage.get(identity, owner->active_profile, &profile) ==
ProfileStorageResult::kOk &&
profile.weak_rumble_scale == expected_scale,
"live owner must resolve its independent persisted active profile");
}
ControllerRumbleOutput ordered_switch2_hd(uint8_t frequency = 40) {
ControllerRumbleOutput rumble{17, 29};
rumble.hd.actuators[0].sample_count = 3;
@ -1315,6 +1436,18 @@ void test_switch2_pair_lifecycle(bool right_first) {
right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID);
uni_hid_device_t right = switch2_device(
right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID);
left.conn.btaddr[0] = 0xc1;
left.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM;
initialize_runtime_profile_storage();
const ControllerIdentity left_identity = identity_for_device(&left);
const ControllerIdentity right_identity = identity_for_device(&right);
ControllerProfile left_profile = controller_profile_default(left_identity, 2);
left_profile.weak_rumble_scale = 37;
require(runtime_profile_storage.set(left_identity, 2, left_profile) ==
ProfileStorageResult::kOk &&
runtime_profile_storage.activate(left_identity, 2) == ProfileStorageResult::kOk &&
runtime_profile_storage.activate(right_identity, 5) == ProfileStorageResult::kOk,
"solo owners must have independently selected profiles before pairing");
uni_hid_device_t& first = right_first ? right : left;
uni_hid_device_t& second = right_first ? left : right;
ready_switch2(first);
@ -1366,13 +1499,23 @@ void test_switch2_pair_lifecycle(bool right_first) {
Bluepad32SlotSnapshot merged = slot_snapshot(0);
require(merged.active && !slot_snapshot(1).active &&
merged.connection_generation != solo.connection_generation &&
controller_identity_equal(merged.identity, identity_for_device(&left)),
"either connection order must merge into first output with left profile owner");
merged.connection_generation != solo.connection_generation,
"either connection order must merge into the first-ready output");
require_pair_owner(merged.identity, left, right);
require_active_profile(merged.identity, 2, 37);
ControllerProfile pair_profile = controller_profile_default(merged.identity, 7);
pair_profile.weak_rumble_scale = 95;
require(runtime_profile_storage.set(merged.identity, 7, pair_profile) ==
ProfileStorageResult::kOk &&
runtime_profile_storage.activate(merged.identity, 7) == ProfileStorageResult::kOk,
"the merged bank must be independently editable");
require_active_profile(left_identity, 2, 37);
require_active_profile(right_identity, 5, UINT8_MAX);
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(playtest.controller_layout ==
Bluepad32ControllerLayout::kJoyCon2MergedPair &&
playtest.state.motion_sample_count == 0,
playtest.state.motion_sample_count == 0 &&
controller_identity_equal(playtest.identity, merged.identity),
"playtest must detect a live companion before any merged motion report");
Bluepad32CaptureSnapshot capture{};
require(bluepad32_input_backend_capture_page(0, 0, &capture) &&
@ -1391,6 +1534,28 @@ void test_switch2_pair_lifecycle(bool right_first) {
require(left.rumble_calls + right.rumble_calls == calls_after_merge &&
left.player_leds == 1 && right.player_leds == 1,
"old-generation solo feedback must not reach the pair");
require(!bluepad32_input_backend_identify(left_identity) &&
!bluepad32_input_backend_identify(right_identity) &&
bluepad32_input_backend_identify(merged.identity),
"identify must match the pair owner, not either physical solo owner");
process_rumble_timer(&g_rumble_timer);
require(left.last_low == UINT8_MAX && right.last_low == UINT8_MAX &&
left.player_leds == 1 && right.player_leds == 1,
"identifying the pair owner must reach both physical halves");
now_ms += 150;
process_rumble_timer(&g_rumble_timer);
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, left,
left.switch2_identity_address_type, left.conn.btaddr);
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, right,
right.switch2_identity_address_type, right.conn.btaddr);
require(controller_identity_equal(slot_snapshot(0).identity, merged.identity),
"physical identity publication must not replace an enrolled composite owner");
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_FAILED, left,
left.switch2_identity_address_type, left.conn.btaddr);
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_STARTED, right,
right.switch2_identity_address_type, right.conn.btaddr);
require(controller_identity_equal(slot_snapshot(0).identity, merged.identity),
"physical identity resolution resets must not demote a live pair to global");
platform_on_controller_data(&left, &left_data);
platform_on_controller_data(&right, &right_data);
merged = slot_snapshot(0);
@ -1456,6 +1621,10 @@ void test_switch2_pair_lifecycle(bool right_first) {
!detached.state.dpad_up && !detached.state.button_east &&
(right_first ? detached.state.button_south : detached.state.button_west),
"either half detach must immediately publish only the rotated survivor and own profile");
require_active_profile(detached.identity, right_first ? 5 : 2,
right_first ? UINT8_MAX : 37);
require(!bluepad32_input_backend_identify(merged.identity),
"an offline pair bank must not identify its surviving solo member");
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(playtest.controller_layout ==
(right_first ? Bluepad32ControllerLayout::kJoyCon2RightSolo
@ -1477,12 +1646,21 @@ void test_switch2_pair_lifecycle(bool right_first) {
slot_snapshot(0).state.extra_buttons == survivor.switch2_extra_buttons,
"late detached input and generation-bound feedback must be ignored");
uni_hid_device_t replacement = switch2_device(lost.idx, lost.product_id);
memcpy(replacement.conn.btaddr, lost.conn.btaddr, sizeof(bd_addr_t));
replacement.switch2_identity_address_type = lost.switch2_identity_address_type;
left_profile.weak_rumble_scale = 61;
require(runtime_profile_storage.set(left_identity, 2, left_profile) ==
ProfileStorageResult::kOk,
"changing the source solo must remain independent while detached");
ready_switch2(replacement);
platform_on_device_disconnected(&lost);
require(slot_snapshot(0).active && !slot_snapshot(1).active &&
slot_snapshot(0).state.extra_buttons == survivor.switch2_extra_buttons &&
slot_snapshot(0).connection_generation != detached.connection_generation,
"replacement must re-pair without stale presses or a late old disconnect");
require(controller_identity_equal(slot_snapshot(0).identity, merged.identity),
"rejoining the same typed physical members must restore the same pair owner");
require_active_profile(slot_snapshot(0).identity, 7, 95);
const uint32_t clear_token = bluepad32_input_backend_clear_pairings();
process_rumble_timer(&g_rumble_timer);
Bluepad32PairingSnapshot cleared{};
@ -1506,11 +1684,11 @@ void test_switch2_multiple_pairs() {
ready_switch2(left1);
require(slot_snapshot(0).active && slot_snapshot(1).active &&
!slot_snapshot(2).active && !slot_snapshot(3).active &&
controller_identity_equal(slot_snapshot(0).identity, identity_for_device(&left0)) &&
controller_identity_equal(slot_snapshot(1).identity, identity_for_device(&left1)) &&
g_connection_policy_state == ConnectionPolicyState::Paused &&
!scanning_enabled && !incoming_connections,
"two deterministic pairs must consume four physical resources but only two outputs");
require_pair_owner(slot_snapshot(0).identity, left0, right0);
require_pair_owner(slot_snapshot(1).identity, left1, right1);
uni_controller_t data{};
data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
data.gamepad.buttons = BUTTON_A;
@ -1552,6 +1730,128 @@ void test_switch2_multiple_pairs() {
"remapped ordinary physical index must isolate input and feedback from both pairs");
}
void test_switch2_pair_admission_failure(bool right_first) {
start_pairing_backend();
initialize_runtime_profile_storage();
auto left = switch2_device(right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID);
auto& first = right_first ? right : left;
auto& second = right_first ? left : right;
ready_switch2(first);
const ControllerIdentity solo_identity = identity_for_device(&first);
require(runtime_profile_storage.activate(solo_identity, 3) == ProfileStorageResult::kOk,
"the first solo must have an existing independent active profile");
uni_controller_t data{};
data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
data.gamepad.buttons = BUTTON_TRIGGER_L | BUTTON_TRIGGER_R;
platform_on_controller_data(&first, &data);
const auto solo = slot_snapshot(0);
require(bluepad32_input_backend_capture_start(
0, solo.connection_generation, CaptureOptions{}),
"the first solo must remain recordable during pair admission");
bluepad32_input_backend_queue_profile_feedback(
0, solo.connection_generation, 2, ControllerProfileConfirmationPolicy::kRumble);
const int initial_rumble_calls = first.rumble_calls;
platform_on_device_connected(&second);
second.switch2_identity_valid = false;
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED &&
pair_observation_count == 0 &&
first.rumble_calls == initial_rumble_calls,
"an unstable member must reject pairing before storage or solo output changes");
second.switch2_identity_valid = true;
first.switch2_identity_valid = false;
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED &&
pair_observation_count == 0 &&
controller_identity_equal(slot_snapshot(0).identity, solo_identity),
"an unresolved first-ready member must not merge using its stale solo owner");
first.switch2_identity_valid = true;
require(runtime_profile_storage.ensure_identity(identity_for_device(&second)) ==
ProfileStorageResult::kOk,
"preexisting member rows must allow exercising failure of the actual pair seed");
before_pair_seed = [](const ControllerIdentity&) {
const auto first_solo = slot_snapshot(0);
Bluepad32CaptureSnapshot capture{};
require(first_solo.active &&
first_solo.identity.transport == ControllerTransport::kBle &&
!slot_snapshot(1).active &&
bluepad32_input_backend_capture_page(0, 0, &capture) &&
capture.state == CaptureState::kRecording,
"persistent pair setup must precede any visible solo topology change");
};
fail_profile_program = true;
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED &&
pair_observation_count == 1,
"pair seed I/O failure must explicitly reject the second ready callback");
const auto rejected = slot_snapshot(0);
require(rejected.active && !slot_snapshot(1).active &&
rejected.connection_generation == solo.connection_generation &&
controller_identity_equal(rejected.identity, solo_identity) &&
rejected.state.left_trigger == solo.state.left_trigger &&
first.rumble_calls == initial_rumble_calls &&
first.player_leds == 1 && second.player_leds == 0,
"seed failure must leave the first solo's owner, input, generation and outputs intact");
ControllerIdentity pair_identity{};
require(controller_identity_make_joycon_pair(
identity_for_device(&left), identity_for_device(&right), &pair_identity) &&
runtime_profile_storage.find(pair_identity) == nullptr,
"a failed pair seed must not expose a partial persistent owner");
process_rumble_timer(&g_rumble_timer);
require(first.last_low == UINT8_MAX && second.rumble_calls == 0,
"a rejected merge must retain the first solo's queued generation-bound feedback");
fail_profile_program = false;
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED,
"a retry before catalog replay must not expose a failed seed");
require(runtime_profile_storage.initialize(runtime_profile_io()),
"storage replay must recover intact member banks after admission failure");
require_active_profile(solo_identity, 3, UINT8_MAX);
require(platform_on_device_ready(&second) == UNI_ERROR_SUCCESS,
"a replayed catalog must allow the pending physical member to merge on retry");
before_pair_seed = nullptr;
require_pair_owner(slot_snapshot(0).identity, left, right);
require_active_profile(slot_snapshot(0).identity, right_first ? 0 : 3, UINT8_MAX);
}
void test_switch2_pair_member_replacement() {
start_pairing_backend();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
right.conn.btaddr[0] = 0xc2;
ready_switch2(left);
ready_switch2(right);
const ControllerIdentity original = slot_snapshot(0).identity;
require(runtime_profile_storage.activate(original, 6) == ProfileStorageResult::kOk,
"the original physical pair must have a distinct saved selection");
platform_on_device_disconnected(&right);
auto replacement = switch2_device(1, UNI_SW2_JOYCON_R_PID);
replacement.conn.btaddr[5] = 0x35;
ready_switch2(replacement);
const ControllerIdentity replaced = slot_snapshot(0).identity;
require_pair_owner(replaced, left, replacement);
require(!controller_identity_equal(original, replaced) &&
!bluepad32_input_backend_identify(original),
"swapping only the right member must select a different pair bank and owner");
require_active_profile(replaced, 0, UINT8_MAX);
require(runtime_profile_storage.activate(replaced, 4) == ProfileStorageResult::kOk,
"the replacement pair bank must be independently selectable");
platform_on_device_disconnected(&replacement);
ready_switch2(right);
require(controller_identity_equal(slot_snapshot(0).identity, original),
"restoring the original right member must restore the original pair key");
require_active_profile(slot_snapshot(0).identity, 6, UINT8_MAX);
require_active_profile(replaced, 4, UINT8_MAX);
platform_on_device_disconnected(&right);
auto typed_right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
memcpy(typed_right.conn.btaddr, right.conn.btaddr, sizeof(bd_addr_t));
typed_right.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM;
ready_switch2(typed_right);
const ControllerIdentity retyped = slot_snapshot(0).identity;
require_pair_owner(retyped, left, typed_right);
require(!controller_identity_equal(retyped, original),
"a member's address type must participate in the pair key even with the same MAC");
require_active_profile(retyped, 0, UINT8_MAX);
require_active_profile(original, 6, UINT8_MAX);
}
void test_switch2_admission() {
start_backend();
require(!switch_pico_switch2_pairing_allowed(),
@ -1763,9 +2063,9 @@ void test_switch2_mate_reconnect() {
"mate setup must pause background scanning while reserving physical capacity");
require(platform_on_device_ready(&left) == UNI_ERROR_SUCCESS &&
slot_snapshot(0).active && !slot_snapshot(1).active &&
controller_identity_equal(slot_snapshot(0).identity, identity_for_device(&left)) &&
!scanning_enabled && !classic_scanning_enabled,
"ready remembered mate must merge without a pairing window and stop background scanning");
require_pair_owner(slot_snapshot(0).identity, left, right);
auto ordinary = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
platform_on_device_connected(&ordinary);
require(platform_on_device_ready(&ordinary) == UNI_ERROR_SUCCESS &&
@ -4001,6 +4301,12 @@ int main(int argc, char** argv) {
test_switch2_pair_lifecycle(true);
} else if (scenario == "switch2-multiple-pairs") {
test_switch2_multiple_pairs();
} else if (scenario == "switch2-pair-failure-left") {
test_switch2_pair_admission_failure(false);
} else if (scenario == "switch2-pair-failure-right") {
test_switch2_pair_admission_failure(true);
} else if (scenario == "switch2-pair-replacement") {
test_switch2_pair_member_replacement();
} else if (scenario == "switch2-admission") {
test_switch2_admission();
} else if (scenario == "switch2-radio-policy") {

View file

@ -58,6 +58,7 @@ enum uni_error_t {
UNI_ERROR_IGNORE_DEVICE = 1,
UNI_ERROR_INVALID_CONTROLLER = 2,
UNI_ERROR_NO_SLOTS = 3,
UNI_ERROR_INIT_FAILED = 4,
};
enum {

View file

@ -39,6 +39,84 @@ bool read_encoded_database(void*, size_t offset, uint8_t* output,
return true;
}
void test_pair_identity_wire_and_member_validation() {
ControllerIdentity left = identity(1);
left.transport = ControllerTransport::kBle;
left.product_id = 0x2067;
const uint8_t left_address[6] = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15};
memcpy(left.address, left_address, sizeof(left_address));
ControllerIdentity right = left;
right.product_id = 0x2066;
right.address_type = 1;
right.address[0] = 0xc0;
ControllerIdentity pair{};
uint8_t encoded[CONTROLLER_IDENTITY_ENCODED_SIZE]{};
const uint8_t expected[14] = {
5, 3, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
0xc0, 0x11, 0x12, 0x13, 0x14, 0x15};
require(controller_identity_make_joycon_pair(left, right, &pair) &&
controller_identity_encode(pair, encoded, sizeof(encoded)) &&
memcmp(encoded, expected, sizeof(expected)) == 0,
"pair identity did not encode both typed members");
ControllerIdentity decoded{};
ControllerIdentity decoded_left{};
ControllerIdentity decoded_right{};
require(controller_identity_decode(encoded, sizeof(encoded), &decoded) &&
controller_identity_equal(pair, decoded) &&
controller_identity_joycon_pair_members(
decoded, &decoded_left, &decoded_right) &&
controller_identity_equal(left, decoded_left) &&
controller_identity_equal(right, decoded_right),
"pair identity round trip lost a member or its model");
decoded.partner_address[5] ^= 1;
require(!controller_identity_equal(pair, decoded),
"pair equality ignored the right address");
for (uint8_t flags = 0; flags < 16; ++flags) {
if (flags == 1 || flags == 5) {
continue;
}
memcpy(encoded, expected, sizeof(encoded));
encoded[0] = flags;
require(!controller_identity_decode(encoded, sizeof(encoded), &decoded),
"pair decoder accepted unstable, reserved, or nonstatic flags");
}
require(!controller_identity_make_joycon_pair(right, left, &decoded),
"pair helper accepted reversed models");
right.product_id = 0x2069;
require(!controller_identity_make_joycon_pair(left, right, &decoded),
"pair helper accepted a non-Joy-Con member");
right.product_id = 0x2066;
right.address_type = 2;
require(!controller_identity_make_joycon_pair(left, right, &decoded),
"pair helper accepted a noncanonical identity address type");
right.address_type = left.address_type;
memcpy(right.address, left.address, sizeof(right.address));
require(!controller_identity_make_joycon_pair(left, right, &decoded),
"pair helper accepted one physical typed address twice");
left.address[0] = right.address[0] = 0xc0;
right.address_type = 1;
require(controller_identity_make_joycon_pair(left, right, &decoded),
"pair helper conflated public and static-random address namespaces");
left.partner_address[0] = 1;
require(!controller_identity_encode(left, encoded, sizeof(encoded)) &&
!controller_identity_make_joycon_pair(left, right, &decoded),
"ordinary member accepted stray partner fields");
left.partner_address[0] = 0;
const uint8_t ordinary[14] = {
1, 2, 0, 0, 0xc0, 0x11, 0x12, 0x13, 0x14, 0x15,
0x7e, 0x05, 0x67, 0x20};
require(controller_identity_encode(left, encoded, sizeof(encoded)) &&
memcmp(encoded, ordinary, sizeof(encoded)) == 0 &&
controller_identity_decode(encoded, sizeof(encoded), &decoded) &&
controller_identity_equal(left, decoded),
"ordinary identity wire encoding changed");
const uint8_t global[14]{};
require(controller_identity_encode(controller_identity_global(), encoded,
sizeof(encoded)) &&
memcmp(encoded, global, sizeof(encoded)) == 0,
"global identity wire encoding changed");
}
void test_profile_wire_schema() {
const ControllerProfile profile =
controller_profile_default(controller_identity_global(), 0);
@ -605,6 +683,7 @@ void test_extra_control_schema_round_trip_and_output_limits() {
} // namespace
int main() {
test_pair_identity_wire_and_member_validation();
test_profile_wire_schema();
test_legacy_profile_migration();
test_database_round_trip_and_capacity();

View file

@ -12,9 +12,13 @@ namespace {
struct FakeFlash {
uint8_t bytes[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE];
bool fail_program_once = false;
};
FakeFlash flash{};
bool observe_pair_during_program = false;
ControllerIdentity pending_pair{};
ControllerIdentity pending_left{};
void require(bool condition, const char *message) {
if (!condition) {
@ -53,6 +57,20 @@ bool fake_program(void *context, uint8_t arena, size_t offset,
offset + size > PROFILE_STORAGE_ARENA_SIZE) {
return false;
}
if (observe_pair_during_program) {
ProfileServiceActiveProfileSnapshot pair_snapshot{};
ProfileServiceActiveProfileSnapshot left_snapshot{};
profile_service_active_profile_snapshot(pending_pair, &pair_snapshot);
profile_service_active_profile_snapshot(pending_left, &left_snapshot);
require(!pair_snapshot.valid && left_snapshot.valid &&
left_snapshot.profile_index == 7 &&
left_snapshot.profile.weak_rumble_scale == 27,
"pair seed exposed incomplete profiles while writing flash");
}
if (storage->fail_program_once) {
storage->fail_program_once = false;
return false;
}
for (size_t index = 0; index < size; ++index) {
storage->bytes[arena][offset + index] &= page[index];
}
@ -426,6 +444,123 @@ void test_catalog1_selected_and_active_snapshots_migrate() {
"old selection/activation snapshots lost migrated content or defaults");
}
void test_pair_publication_failure_recovery_and_independence() {
memset(flash.bytes, 0xff, sizeof(flash.bytes));
ControllerIdentity left = stable_identity();
left.transport = ControllerTransport::kBle;
left.vendor_id = 0x057e;
left.product_id = 0x2067;
ControllerIdentity right = left;
right.product_id = 0x2066;
right.address[5] = 8;
ControllerIdentity pair{};
require(controller_identity_make_joycon_pair(left, right, &pair),
"service pair identity is invalid");
ProfileStorage setup;
require(setup.initialize(fake_io()), "service pair fixture did not initialize");
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
ControllerProfile profile = controller_profile_default(left, slot);
profile.weak_rumble_scale = 20 + slot;
const char name[2] = {'L', static_cast<char>('0' + slot)};
require(setup.set(left, slot, profile) == ProfileStorageResult::kOk &&
setup.set_profile_name(left, slot, name, sizeof(name)) ==
ProfileStorageResult::kOk,
"service solo fixture did not persist");
}
require(setup.activate(left, 7) == ProfileStorageResult::kOk &&
setup.set_alias(left, "Solo", 4) == ProfileStorageResult::kOk,
"service solo activation did not persist");
profile_service_initialize_on_storage_core();
require(!active_snapshot(pair).valid &&
!profile_service_observe_identity_on_storage_core(pair) &&
profile_service_select(pair, 0) == ConfigurationTransactionStatus::kMalformed &&
profile_service_activate(100, pair, 0) ==
ConfigurationTransactionStatus::kMalformed &&
profile_service_activate_internal(0x80000001u, pair, 0) ==
ConfigurationTransactionStatus::kMalformed &&
profile_service_reset(101, pair, CONTROLLER_PROFILE_ALL) ==
ConfigurationTransactionStatus::kMalformed &&
profile_service_set_metadata(102, pair, 7, "Premature", 9) ==
ConfigurationTransactionStatus::kMalformed &&
profile_service_begin(103, pair, 0, CONTROLLER_PROFILE_SCHEMA_VERSION,
CONTROLLER_PROFILE_ENCODED_SIZE, 0) ==
ConfigurationTransactionStatus::kMalformed,
"service admitted an unseeded pair or supplied its fallback profile");
ProfileServiceListSnapshot before{};
profile_service_list_snapshot(&before);
pending_pair = pair;
pending_left = left;
observe_pair_during_program = true;
flash.fail_program_once = true;
require(!profile_service_observe_joycon_pair_on_storage_core(pair),
"failed seed published a ready pair");
observe_pair_during_program = false;
ProfileServiceListSnapshot failed{};
profile_service_list_snapshot(&failed);
const auto solo_after_failure = active_snapshot(left);
require(failed.count == before.count &&
failed.metadata.state == ProfileServiceState::kStorageError &&
failed.metadata.generation == before.metadata.generation &&
!active_snapshot(pair).valid && solo_after_failure.valid &&
solo_after_failure.profile_index == 7 &&
solo_after_failure.profile.weak_rumble_scale == 27 &&
!profile_service_observe_joycon_pair_on_storage_core(pair),
"rejected pair replaced the last committed solo publication");
profile_service_initialize_on_storage_core();
observe_pair_during_program = true;
require(profile_service_observe_joycon_pair_on_storage_core(pair),
"pair seed did not retry after recovery");
observe_pair_during_program = false;
ProfileServiceListSnapshot seeded{};
profile_service_list_snapshot(&seeded);
require(seeded.count == before.count + 2 &&
seeded.metadata.state == ProfileServiceState::kReady &&
active_snapshot(pair).valid &&
active_snapshot(pair).profile_index == 7 &&
active_snapshot(pair).profile.weak_rumble_scale == 27,
"seeded pair list and active profile were not published together");
require(profile_service_select(pair, 7) ==
ConfigurationTransactionStatus::kCommitted,
"seeded pair could not be selected");
ProfileServiceMetadataSnapshot metadata{};
profile_service_metadata_snapshot(&metadata);
require(metadata.valid && metadata.alias[0] == '\0' &&
strcmp(metadata.profile_names[7], "L7") == 0,
"service did not expose independent seeded metadata");
auto profile = controller_profile_default(pair, 7);
profile.weak_rumble_scale = 81;
write_profile(104, pair, 7, profile, 9000);
require(active_snapshot(pair).profile.weak_rumble_scale == 81 &&
active_snapshot(left).profile.weak_rumble_scale == 27 &&
profile_service_activate(105, pair, 3) ==
ConfigurationTransactionStatus::kPending,
"pair edit changed the solo active profile");
profile_service_task_on_storage_core(10000);
require(active_snapshot(pair).profile_index == 3 &&
active_snapshot(left).profile_index == 7,
"pair activation changed the solo selection");
require(profile_service_set_metadata(106, pair, 7, "Pair", 4) ==
ConfigurationTransactionStatus::kPending,
"pair metadata edit did not queue");
profile_service_task_on_storage_core(11000);
require(profile_service_observe_joycon_pair_on_storage_core(pair),
"existing pair reconnect failed");
profile_service_metadata_snapshot(&metadata);
require(metadata.valid && strcmp(metadata.profile_names[7], "Pair") == 0 &&
profile_service_select(left, 7) ==
ConfigurationTransactionStatus::kCommitted,
"pair reconnect overwrote pair-owned metadata");
profile_service_metadata_snapshot(&metadata);
require(metadata.valid && strcmp(metadata.profile_names[7], "L7") == 0 &&
strcmp(metadata.alias, "Solo") == 0,
"pair metadata edit changed solo metadata");
profile_service_initialize_on_storage_core();
require(profile_service_observe_joycon_pair_on_storage_core(pair) &&
active_snapshot(pair).profile_index == 3 &&
active_snapshot(left).profile_index == 7,
"service reload reseeded the pair active selection");
}
} // namespace
ProfileStorageIo pico_profile_storage_io() { return fake_io(); }
@ -435,6 +570,7 @@ int main() {
test_profile_bounds_and_transaction_namespace();
test_schema7_validation_and_atomic_selection();
test_catalog1_selected_and_active_snapshots_migrate();
test_pair_publication_failure_recovery_and_independence();
std::cout << "profile service tests passed\n";
return 0;
}

View file

@ -20,6 +20,7 @@ struct FakeFlash {
uint8_t corrupt_arena = 0;
size_t corrupt_offset = 0;
bool corrupt_next_program = false;
int fail_read_after_programs = -1;
};
FakeFlash flash{};
@ -45,6 +46,10 @@ bool fake_read(void *context, uint8_t arena, size_t offset, uint8_t *output,
size > PROFILE_STORAGE_ARENA_SIZE - offset) {
return false;
}
if (storage->fail_read_after_programs >= 0 &&
storage->programs >= storage->fail_read_after_programs) {
return false;
}
memcpy(output, &storage->bytes[arena][offset], size);
return true;
}
@ -116,6 +121,22 @@ ControllerIdentity identity(uint8_t suffix) {
return result;
}
ControllerIdentity joycon(uint8_t suffix, bool left) {
ControllerIdentity result = identity(suffix);
result.transport = ControllerTransport::kBle;
result.vendor_id = 0x057e;
result.product_id = left ? 0x2067 : 0x2066;
return result;
}
ControllerIdentity joycon_pair(const ControllerIdentity &left,
const ControllerIdentity &right) {
ControllerIdentity pair{};
require(controller_identity_make_joycon_pair(left, right, &pair),
"pair fixture identity is invalid");
return pair;
}
void write_u16(uint8_t *output, uint16_t value) {
output[0] = static_cast<uint8_t>(value);
output[1] = static_cast<uint8_t>(value >> 8);
@ -279,7 +300,7 @@ void install_populated_catalog(uint16_t version, bool fill_arena = false) {
index <= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; ++index) {
const ControllerIdentity id = catalog_identity(index);
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
const ControllerProfile profile = catalog_profile(index, slot, version == 2);
const ControllerProfile profile = catalog_profile(index, slot, version != 1);
uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{};
if (version == 1) {
encode_schema5(profile, payload);
@ -558,14 +579,14 @@ void test_legacy_migration_is_atomic_and_complete() {
void test_populated_catalog_publication_power_loss() {
static FakeFlash baseline;
for (uint16_t version = 1; version <= 2; ++version) {
for (uint16_t version = 1; version <= 3; ++version) {
erase_all();
install_populated_catalog(version, true);
baseline = flash;
ProfileStorage completed;
require(completed.initialize(fake_io()), "populated catalog did not load");
require_populated_catalog(completed, version == 2);
if (version == 2) {
require_populated_catalog(completed, version != 1);
if (version == 3) {
require(completed.set_alias(catalog_identity(0), "New", 3) ==
ProfileStorageResult::kOk,
"populated catalog could not compact all 187 live records");
@ -578,7 +599,7 @@ void test_populated_catalog_publication_power_loss() {
flash = baseline;
flash.torn_page_bytes = torn_bytes;
ProfileStorage interrupted;
if (version == 2) {
if (version == 3) {
require(interrupted.initialize(fake_io()),
"catalog2 power-loss baseline did not load");
}
@ -587,9 +608,9 @@ void test_populated_catalog_publication_power_loss() {
} else {
flash.fail_after_programs = cut;
}
if (version == 1) {
if (version != 3) {
require(!interrupted.initialize(fake_io()),
"incomplete catalog1 migration silently initialized empty");
"incomplete old catalog migration silently initialized empty");
} else {
require(interrupted.set_alias(catalog_identity(0), "New", 3) ==
ProfileStorageResult::kIoError,
@ -604,7 +625,7 @@ void test_populated_catalog_publication_power_loss() {
ProfileStorage recovered;
require(recovered.initialize(fake_io()),
"catalog did not recover after an interrupted publication");
require_populated_catalog(recovered, version == 2);
require_populated_catalog(recovered, version != 1);
require(recovered.snapshot().generation >= 1248,
"catalog publication regressed the stored generation");
}
@ -738,7 +759,7 @@ void test_legacy_high_generation_remains_mutable() {
void test_schema6_read_migration_is_lazy_and_edit_preserves_metadata() {
erase_all();
install_populated_catalog(2);
install_populated_catalog(3);
ProfileStorage storage;
require(storage.initialize(fake_io()), "schema6 catalog did not load");
require_populated_catalog(storage, true);
@ -779,6 +800,219 @@ void test_schema6_read_migration_is_lazy_and_edit_preserves_metadata() {
"editing a schema6 profile lost its active index, alias, or name");
}
void require_pair_bank(const ProfileStorage &storage,
const ControllerIdentity &owner, uint8_t base,
uint8_t active, char name_prefix) {
const auto *entry = storage.find(owner);
require(entry != nullptr && entry->active_profile == active,
"pair bank active selection changed");
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
ControllerProfile actual{};
char name[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
require(storage.get(owner, slot, &actual) == ProfileStorageResult::kOk &&
actual.weak_rumble_scale == base + slot &&
storage.get_profile_name(owner, slot, name, sizeof(name)) ==
ProfileStorageResult::kOk &&
name[0] == name_prefix && name[1] == '0' + slot &&
name[2] == '\0',
"pair bank profile or name bled into another owner");
}
}
void write_pair_bank(ProfileStorage &storage, const ControllerIdentity &owner,
uint8_t base, char name_prefix) {
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
ControllerProfile profile = controller_profile_default(owner, slot);
profile.weak_rumble_scale = base + slot;
const char name[2] = {name_prefix, static_cast<char>('0' + slot)};
require(storage.set(owner, slot, profile) == ProfileStorageResult::kOk &&
storage.set_profile_name(owner, slot, name, sizeof(name)) ==
ProfileStorageResult::kOk,
"pair bank edit failed");
}
}
void test_pair_seed_independence_reconnect_and_compaction() {
erase_all();
const auto left = joycon(1, true);
const auto right = joycon(2, false);
const auto pair = joycon_pair(left, right);
ProfileStorage storage;
require(storage.initialize(fake_io()), "pair catalog did not initialize");
write_pair_bank(storage, left, 20, 'L');
write_pair_bank(storage, right, 40, 'R');
require(storage.set_alias(left, "Solo", 4) == ProfileStorageResult::kOk &&
storage.activate(left, 7) == ProfileStorageResult::kOk &&
storage.activate(right, 3) == ProfileStorageResult::kOk,
"solo bank setup failed");
const int programs = flash.programs;
const int erases = flash.erases;
require(storage.ensure_joycon_pair(pair) == ProfileStorageResult::kOk &&
flash.programs == programs + 2 && flash.erases == erases,
"first pair did not seed with one atomic append");
require_pair_bank(storage, pair, 20, 7, 'L');
char alias[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
require(storage.get_alias(pair, alias, sizeof(alias)) ==
ProfileStorageResult::kOk && alias[0] == '\0' &&
storage.get_alias(left, alias, sizeof(alias)) ==
ProfileStorageResult::kOk && strcmp(alias, "Solo") == 0,
"pair inherited or changed the solo alias");
write_pair_bank(storage, left, 60, 'S');
require(storage.activate(left, 1) == ProfileStorageResult::kOk,
"left selection edit failed");
ProfileStorage replayed;
require(replayed.initialize(fake_io()), "pair seed did not replay");
require_pair_bank(replayed, pair, 20, 7, 'L');
require_pair_bank(replayed, left, 60, 1, 'S');
require_pair_bank(replayed, right, 40, 3, 'R');
write_pair_bank(replayed, pair, 80, 'P');
require(replayed.activate(pair, 6) == ProfileStorageResult::kOk &&
replayed.set_alias(pair, "Together", 8) == ProfileStorageResult::kOk,
"pair selection or alias edit failed");
const uint32_t generation = replayed.snapshot().generation;
require(replayed.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged &&
replayed.snapshot().generation == generation,
"pair reconnect reseeded or wrote the bank");
const auto other_pair = joycon_pair(left, joycon(3, false));
require(replayed.ensure_joycon_pair(other_pair) == ProfileStorageResult::kOk,
"different right member did not receive a separate bank");
require_pair_bank(replayed, other_pair, 60, 1, 'S');
require_pair_bank(replayed, pair, 80, 6, 'P');
require_pair_bank(replayed, left, 60, 1, 'S');
require_pair_bank(replayed, right, 40, 3, 'R');
const uint8_t old_arena = replayed.snapshot().active_bank;
for (size_t write = 0; write <= PROFILE_STORAGE_RECORD_CAPACITY; ++write) {
require(replayed.activate(left, (write + 2) % CONTROLLER_PROFILE_COUNT) ==
ProfileStorageResult::kOk,
"pair compaction trigger failed");
}
require(replayed.snapshot().active_bank != old_arena,
"pair catalog never compacted");
ProfileStorage compacted;
require(compacted.initialize(fake_io()) &&
compacted.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
"materialized pair did not reconnect after compaction");
require_pair_bank(compacted, pair, 80, 6, 'P');
require_pair_bank(compacted, other_pair, 60, 1, 'S');
require_pair_bank(compacted, right, 40, 3, 'R');
require(compacted.get_alias(pair, alias, sizeof(alias)) ==
ProfileStorageResult::kOk && strcmp(alias, "Together") == 0,
"compaction lost pair-owned alias");
require(compacted.reset(pair, CONTROLLER_PROFILE_ALL) ==
ProfileStorageResult::kOk,
"pair reset failed");
require_pair_bank(compacted, other_pair, 60, 1, 'S');
require_pair_bank(compacted, right, 40, 3, 'R');
}
void test_pair_capacity_and_unseeded_mutations() {
erase_all();
const auto left = joycon(1, true);
const auto right = joycon(2, false);
const auto pair = joycon_pair(left, right);
ProfileStorage storage;
require(storage.initialize(fake_io()), "pair capacity catalog did not initialize");
ControllerProfile profile = controller_profile_default(pair, 0);
profile.weak_rumble_scale = 42;
require(storage.ensure_identity(pair) == ProfileStorageResult::kInvalidArgument &&
storage.set(pair, 0, profile) == ProfileStorageResult::kInvalidArgument &&
storage.activate(pair, 7) == ProfileStorageResult::kInvalidArgument &&
storage.set_alias(pair, "Pair", 4) == ProfileStorageResult::kInvalidArgument &&
storage.set_profile_name(pair, 7, "Pair", 4) ==
ProfileStorageResult::kInvalidArgument &&
storage.reset(pair, CONTROLLER_PROFILE_ALL) ==
ProfileStorageResult::kInvalidArgument &&
storage.find(pair) == nullptr,
"mutation implicitly created an unseeded pair");
for (uint8_t index = 1; index <= 14; ++index) {
require(storage.ensure_identity(identity(index)) == ProfileStorageResult::kOk,
"capacity setup failed");
}
const int programs = flash.programs;
require(storage.ensure_joycon_pair(pair) == ProfileStorageResult::kFull &&
storage.find(left) == nullptr && storage.find(right) == nullptr &&
storage.find(pair) == nullptr && flash.programs == programs,
"capacity preflight changed owners before rejecting the pair");
erase_all();
require(storage.initialize(fake_io()), "exact-fit catalog did not initialize");
for (uint8_t index = 1; index <= 13; ++index) {
require(storage.ensure_identity(identity(index)) == ProfileStorageResult::kOk,
"exact-fit setup failed");
}
require(storage.ensure_joycon_pair(pair) == ProfileStorageResult::kOk &&
storage.identity_count() == CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1 &&
storage.find(left) != nullptr && storage.find(right) != nullptr &&
storage.get(pair, 7, &profile) == ProfileStorageResult::kOk &&
profile.weak_rumble_scale ==
controller_profile_default(left, 7).weak_rumble_scale &&
storage.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
"exact-capacity pair did not create both members and the default bank");
}
void test_pair_seed_interruption_and_ambiguous_readback() {
erase_all();
const auto left = joycon(1, true);
const auto right = joycon(2, false);
const auto pair = joycon_pair(left, right);
ProfileStorage initial;
require(initial.initialize(fake_io()), "seed fault catalog did not initialize");
write_pair_bank(initial, left, 20, 'L');
require(initial.activate(left, 7) == ProfileStorageResult::kOk,
"seed fault active setup failed");
static FakeFlash baseline;
baseline = flash;
// Right row and seed each use two pages. Exercise every cut, including a
// fully committed header whose program call nevertheless reports failure.
for (size_t torn_bytes : {size_t{0}, size_t{64}, size_t{256}}) {
for (int cut = 0; cut < 4; ++cut) {
flash = baseline;
ProfileStorage interrupted;
require(interrupted.initialize(fake_io()), "seed fault baseline did not load");
flash.fail_after_programs = flash.programs + cut;
flash.torn_page_bytes = torn_bytes;
require(interrupted.ensure_joycon_pair(pair) == ProfileStorageResult::kIoError &&
interrupted.find(pair) == nullptr,
"interrupted seed exposed a partially initialized bank");
flash.fail_after_programs = -1;
require(interrupted.ensure_joycon_pair(pair) == ProfileStorageResult::kIoError &&
interrupted.activate(left, 0) == ProfileStorageResult::kIoError,
"ambiguous seed allowed retry or source mutation before replay");
flash.torn_page_bytes = 0;
ProfileStorage recovered;
require(recovered.initialize(fake_io()), "interrupted seed did not recover");
const auto result = recovered.ensure_joycon_pair(pair);
require(result == ProfileStorageResult::kOk ||
result == ProfileStorageResult::kUnchanged,
"seed retry failed after durable replay");
require_pair_bank(recovered, pair, 20, 7, 'L');
require_pair_bank(recovered, left, 20, 7, 'L');
}
}
flash = baseline;
ProfileStorage ambiguous;
require(ambiguous.initialize(fake_io()) &&
ambiguous.ensure_identity(right) == ProfileStorageResult::kOk,
"readback fault setup failed");
flash.fail_read_after_programs = flash.programs + 2;
require(ambiguous.ensure_joycon_pair(pair) == ProfileStorageResult::kIoError &&
ambiguous.find(pair) == nullptr,
"unacknowledged seed was published in memory");
ProfileStorage unreadable;
require(!unreadable.initialize(fake_io()), "unreadable seed replay failed open");
flash.fail_read_after_programs = -1;
require(ambiguous.activate(left, 0) == ProfileStorageResult::kIoError,
"unacknowledged seed did not freeze source changes");
ProfileStorage recovered;
require(recovered.initialize(fake_io()) &&
recovered.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
"committed unacknowledged seed was reseeded");
require_pair_bank(recovered, pair, 20, 7, 'L');
write_pair_bank(recovered, left, 60, 'S');
require(recovered.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
"recovered pair was reseeded after source edits");
require_pair_bank(recovered, pair, 20, 7, 'L');
}
} // namespace
int main() {
@ -795,6 +1029,9 @@ int main() {
test_late_second_page_program_is_not_reused();
test_unreadable_legacy_data_is_not_erased();
test_schema6_read_migration_is_lazy_and_edit_preserves_metadata();
test_pair_seed_independence_reconnect_and_compaction();
test_pair_capacity_and_unseeded_mutations();
test_pair_seed_interruption_and_ambiguous_readback();
std::cout << "profile storage tests passed\n";
return 0;
}

View file

@ -83,6 +83,8 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
f"-I{root / 'src' / 'firmware'}",
f"-I{root / 'bluepad32_config'}",
str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"),
str(root / "src" / "firmware" / "profile" / "controller_profile.cpp"),
str(root / "src" / "firmware" / "profile" / "profile_storage.cpp"),
str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"),
str(
root / "src" / "firmware" / "input" / "controller_macro_capture.cpp"
@ -97,6 +99,9 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
"switch2-forward",
"switch2-reverse",
"switch2-multiple-pairs",
"switch2-pair-failure-left",
"switch2-pair-failure-right",
"switch2-pair-replacement",
"switch2-admission",
"switch2-radio-policy",
"switch2-radio-settling",

View file

@ -819,7 +819,7 @@ def test_native_rumble_configuration_rejects_invalid_lists(malformation: str) ->
@pytest.mark.parametrize(
"malformation", ("duplicate", "overflow", "global", "ble", "vendor", "product")
"malformation", ("duplicate", "overflow", "global", "ble", "pair", "vendor", "product")
)
def test_native_rumble_write_rejects_invalid_approvals_before_transaction(
malformation: str,
@ -833,6 +833,11 @@ def test_native_rumble_write_rejects_invalid_approvals_before_transaction(
),
"global": (config_manager.ControllerIdentity.global_fallback(),),
"ble": (replace(identity, transport=config_manager.TRANSPORT_BLE),),
"pair": (
config_manager.ControllerIdentity.from_bytes(
bytes.fromhex("0503102030405060C12233445566")
),
),
"vendor": (replace(identity, vendor_id=0x045E),),
"product": (replace(identity, product_id=0x2019),),
}[malformation]
@ -1478,6 +1483,129 @@ def test_requested_and_active_mode_response_validation() -> None:
config_manager.read_info(device)
@pytest.mark.parametrize(
("left_type", "right_type", "flags"), [(0, 0, 1), (1, 0, 3), (0, 1, 5), (1, 1, 7)]
)
def test_joycon_pair_wire_round_trip_preserves_both_typed_members(
left_type: int, right_type: int, flags: int,
) -> None:
left = config_manager.ControllerIdentity(
True, config_manager.TRANSPORT_BLE, left_type,
bytes.fromhex("C10203040506"), 0x057E, 0x2067,
)
right = config_manager.ControllerIdentity(
True, config_manager.TRANSPORT_BLE, right_type,
bytes.fromhex("D11213141516"), 0x057E, 0x2066,
)
pair = config_manager.ControllerIdentity.make_joycon_pair(left, right)
wire = bytes((flags, 3)) + left.address + right.address
assert pair.to_bytes() == wire
assert config_manager.ControllerIdentity.from_bytes(wire) == pair
assert pair.joycon_pair_members() == (left, right)
assert pair.to_bytes() not in (left.to_bytes(), right.to_bytes())
other_right = replace(right, address=bytes.fromhex("D11213141517"))
assert config_manager.ControllerIdentity.make_joycon_pair(left, other_right) != pair
def test_joycon_pair_distinguishes_address_types_without_normalizing_members() -> None:
left = config_manager.ControllerIdentity(
True, config_manager.TRANSPORT_BLE, 0,
bytes.fromhex("C10203040506"), 0x057E, 0x2067,
)
right = replace(left, address_type=1, product_id=0x2066)
pair = config_manager.ControllerIdentity.make_joycon_pair(left, right)
assert pair.to_bytes() == bytes.fromhex("0503C10203040506C10203040506")
assert pair.joycon_pair_members() == (left, right)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerIdentity.make_joycon_pair(left, replace(right, address_type=0))
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerIdentity.make_joycon_pair(right, left)
@pytest.mark.parametrize("wire", [
"0003102030405060C12233445566", # Stable flag missing.
"0D03102030405060C12233445566", # Reserved flag.
"0303102030405060C12233445566", # Left random address is not static.
"0503C12233445566102030405060", # Right random address is not static.
"0103102030405060102030405060", # Duplicate typed members.
])
def test_joycon_pair_rejects_malformed_wire(wire: str) -> None:
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerIdentity.from_bytes(bytes.fromhex(wire))
@pytest.mark.parametrize("malformation", [
"global", "classic", "vendor", "model", "address_type", "random_address",
])
def test_joycon_pair_requires_legitimate_ble_members(malformation: str) -> None:
pair = config_manager.ControllerIdentity.from_bytes(
bytes.fromhex("0503102030405060C12233445566")
)
left, right = pair.joycon_pair_members()
invalid = {
"global": config_manager.ControllerIdentity.global_fallback(),
"classic": replace(right, transport=config_manager.TRANSPORT_CLASSIC),
"vendor": replace(right, vendor_id=0x045E),
"model": replace(right, product_id=0x2069),
"address_type": replace(right, address_type=3),
"random_address": replace(right, address=bytes.fromhex("412233445566")),
}[malformation]
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerIdentity.make_joycon_pair(left, invalid)
@pytest.mark.parametrize("fields", [
{"stable": False},
{"vendor_id": 0x045E},
{"product_id": 0x2066},
{"address_type": 2},
{"partner_address_type": 3},
])
def test_joycon_pair_rejects_inconsistent_in_memory_identity(fields: dict[str, object]) -> None:
pair = config_manager.ControllerIdentity.from_bytes(
bytes.fromhex("0503102030405060C12233445566")
)
with pytest.raises(config_manager.ConfigManagerError):
replace(pair, **fields)
@pytest.mark.parametrize("fields", [
{"partner_address_type": 1},
{"partner_address": bytes.fromhex("C12233445566")},
])
def test_physical_identity_cannot_hide_pair_members(fields: dict[str, object]) -> None:
with pytest.raises(config_manager.ConfigManagerError):
replace(native_rumble_identity(), **fields)
def test_pairing_inventory_rejects_logical_profile_owners() -> None:
device = FakeDevice()
device.records = [(config_manager.TRANSPORT_JOYCON_PAIR, 0, bytes.fromhex("102030405060"))]
with pytest.raises(config_manager.ConfigManagerError):
config_manager.read_pairings(device)
def test_profile_cli_lists_both_pair_addresses_but_native_inventory_omits_pair(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> None:
device = FakeDevice()
pair = config_manager.ControllerIdentity.from_bytes(
bytes.fromhex("0503102030405060C12233445566")
)
device.profile_identities.append(pair)
device.active_profiles[pair.to_bytes()] = 4
monkeypatch.setattr(config_manager, "find_pico", lambda *_: device)
assert config_manager.main(["profiles", "list"]) == 0
listing = capsys.readouterr().out
assert pair.address_text in listing
assert pair.partner_address_text in listing
assert pair.transport_text in listing
assert config_manager.main(["config", "native-rumble", "list"]) == 0
physical = capsys.readouterr().out
assert pair.partner_address_text not in physical
assert device.stable_identity.address_text in physical
def test_identity_and_profile_binary_json_round_trip() -> None:
identity = config_manager.ControllerIdentity(
True,

View file

@ -235,6 +235,145 @@ def test_editor_identifies_connected_controller_artwork(
]
@pytest.fixture
def joycon_pair_device() -> FakeDevice:
device = FakeDevice()
pair = config_manager.ControllerIdentity.from_bytes(
bytes.fromhex("0503102030405060C12233445566")
)
left, right = pair.joycon_pair_members()
device.profile_identities = [device.global_identity, left, right, pair]
device.stable_identity = pair
device.active_profiles = {
identity.to_bytes(): index for index, identity in enumerate(device.profile_identities)
}
default_profile = config_manager.ControllerProfile.default().to_bytes()
device.profiles = {
(identity.to_bytes(), index): default_profile
for identity in device.profile_identities
for index in range(config_manager.PROFILE_CAPACITY)
}
device.playtest_layout = 3
device.playtest_motion = None
return device
def test_persistent_pair_owner_is_offline_capable_and_isolated_from_solo_banks(
monkeypatch: pytest.MonkeyPatch, joycon_pair_device: FakeDevice,
) -> None:
device = joycon_pair_device
_, left, right, pair = device.profile_identities
device.playtest_connected = False
with running_server(monkeypatch, device) as (base_url, token):
status, listing = request_json(f"{base_url}/api/profiles")
assert status == 200
owners = listing["identities"]
assert [owner["key"] for owner in owners] == [
identity.to_bytes().hex() for identity in device.profile_identities
]
assert [owner["controller"]["layout"] for owner in owners[1:]] == [
"joycon2-left", "joycon2-right", "joycon2-pair",
]
owner = owners[3]
assert owner["controller"]["model"] != owners[1]["controller"]["model"]
assert "50:60" in owner["label"] and "55:66" in owner["label"]
assert owner["identity"]["members"]["left"]["address"] == left.address_text
assert owner["identity"]["members"]["left"]["address_type"] == 0
assert owner["identity"]["members"]["right"]["address"] == right.address_text
assert owner["identity"]["members"]["right"]["address_type"] == 1
assert set(owner["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == {
"c", "left_sl", "left_sr", "right_sl", "right_sr",
}
status, offline = request_json(f"{base_url}/api/profiles/3/8/playtest")
assert status == 200
assert offline["connected"] is False
assert offline["controller"] == owner["controller"]
assert offline["source_controls"] == owner["source_controls"]
expected = {
index: config_manager.ControllerProfile.default().to_json_object()
for index in (1, 2, 3)
}
for bank, output in ((3, "north"), (1, "east"), (2, "west")):
expected[bank]["button_map"]["south"] = output
status, _ = request_json(
f"{base_url}/api/profiles/{bank}/8", method="PUT",
value=expected[bank], token=token,
)
assert status == 200
for other_bank, expected_profile in expected.items():
status, stored = request_json(f"{base_url}/api/profiles/{other_bank}/8")
assert status == 200
assert stored["profile"] == expected_profile
for path, value in (
("profiles/3/8/name", "Pair platformer"),
("identities/3/alias", "Couch pair"),
):
status, _ = request_json(
f"{base_url}/api/{path}", method="PUT", value={"value": value}, token=token,
)
assert status == 200
status, _ = request_json(
f"{base_url}/api/profiles/3/8/activate", method="POST", token=token,
)
assert status == 200
status, listing = request_json(f"{base_url}/api/profiles")
assert status == 200
assert [owner["active_profile"] for owner in listing["identities"]] == [1, 2, 3, 8]
assert listing["identities"][3]["label"] == "Couch pair"
assert listing["identities"][3]["key"] == pair.to_bytes().hex()
for bank in (1, 2, 3):
status, stored = request_json(f"{base_url}/api/profiles/{bank}/8")
assert status == 200
assert stored["name"] == ("Pair platformer" if bank == 3 else "")
assert stored["alias"] == ("Couch pair" if bank == 3 else "")
device.playtest_connected = True
for bank in (1, 2, 3):
status, live = request_json(f"{base_url}/api/profiles/{bank}/8/playtest")
assert status == 200
assert live["owner_key"] == device.profile_identities[bank].to_bytes().hex()
assert live["identity_key"] == pair.to_bytes().hex()
assert (live["identity_key"] == live["owner_key"]) == (bank == 3)
assert live["label"] == "Couch pair"
other_pair = config_manager.ControllerIdentity.make_joycon_pair(
left, replace(right, address=bytes.fromhex("C12233445567"))
)
device.stable_identity = other_pair
status, other_live = request_json(f"{base_url}/api/profiles/3/8/playtest")
assert status == 200
assert other_live["owner_key"] == pair.to_bytes().hex()
assert other_live["identity_key"] == other_pair.to_bytes().hex()
assert other_live["owner_key"] != other_live["identity_key"]
def test_capture_cannot_bind_pair_input_to_a_solo_owner(
monkeypatch: pytest.MonkeyPatch, joycon_pair_device: FakeDevice,
) -> None:
device = joycon_pair_device
left = device.profile_identities[1]
with running_server(monkeypatch, device) as (base_url, token):
status, _ = request_json(f"{base_url}/api/profiles/1/1/playtest")
assert status == 200
status, _ = request_json(
f"{base_url}/api/profiles/1/1/capture/start", method="POST", token=token,
value={
"owner_key": left.to_bytes().hex(),
"capture_id": "solo-bank-paired-input",
"slot": device.playtest_slot,
"connection_generation": device.playtest_connection_generation,
"macro_index": 0,
"profile": config_manager.ControllerProfile.default().to_json_object(),
"channels": 1,
"max_events": 8,
"axis_quantum": 512,
"trigger_quantum": 1024,
"max_duration_ms": 1000,
},
)
assert status == 400
@pytest.mark.parametrize("owner_index", [0, 1])
def test_live_layout_transitions_do_not_infer_topology_from_profile_owner(
monkeypatch: pytest.MonkeyPatch, owner_index: int,
@ -254,6 +393,7 @@ def test_live_layout_transitions_do_not_infer_topology_from_profile_owner(
assert sample["controller"]["layout"] == expected
assert sample["owner_key"] == device.profile_identities[owner_index].to_bytes().hex()
assert sample["identity_key"] == left.to_bytes().hex()
assert sample["identity"]["is_joycon_pair"] is False
expected_extras = (
{"c", "left_sl", "left_sr", "right_sl", "right_sr"}
if code == 3 else {"left_sl", "left_sr"}
@ -261,6 +401,9 @@ def test_live_layout_transitions_do_not_infer_topology_from_profile_owner(
assert set(sample["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == expected_extras
status, listing = request_json(f"{base_url}/api/profiles")
assert status == 200
assert [owner["key"] for owner in listing["identities"]] == [
device.global_identity.to_bytes().hex(), left.to_bytes().hex(),
]
assert listing["identities"][1]["controller"]["layout"] == "joycon2-left"
device.playtest_connected = False
status, offline = request_json(f"{base_url}/api/profiles/{owner_index}/1/playtest")