diff --git a/ADAPTER_PARITY_PLAN.md b/ADAPTER_PARITY_PLAN.md index 5bcc361..3f7c6f3 100644 --- a/ADAPTER_PARITY_PLAN.md +++ b/ADAPTER_PARITY_PLAN.md @@ -323,7 +323,7 @@ Core completion evidence: ### Phase 3 — Mapping, tuning, profiles, and macros — Complete -Use four profile slots per stable controller identity. Resolve identity from Bluetooth transport, identity address, VID, and PID; use a global default when stable identity is unavailable. +Use eight profile slots per stable controller identity. Resolve identity from Bluetooth transport, identity address, VID, and PID; use a global default when stable identity is unavailable. Button mapping: @@ -380,7 +380,7 @@ Turbo behavior: Profile switching: - configurable controller chord -- one to four rumble pulses +- one to eight rumble pulses - matching onboard LED count - controller RGB/player LED feedback when supported @@ -394,12 +394,13 @@ Acceptance: Completion evidence: -- strict 256-byte profile schema and 17,696-byte fixed database support four +- strict 256-byte profile records and a compact indexed catalog support eight profiles for the global fallback and each of sixteen stable identities - profile and adapter stores remain separate from each other and BTstack bonds; - profile commits use one flash-safe batched inactive-bank replacement -- schema-v1 profile databases migrate inherited trigger defaults to schema v2 - without losing custom thresholds, identities, active profiles, or other data + profile commits append one flash-safe record and compact atomically between + two 128 KiB arenas +- legacy fixed profile databases migrate without losing custom thresholds, + identities, active profiles, or other data - direct mapping, stick/trigger fixed-point transforms, Switch thresholds, XInput analog values, rumble scaling, macros, Turbo, Auto Burst, and all cancellation paths have deterministic native coverage @@ -562,7 +563,57 @@ Flow: Do not add a second in-application flash writer unless ROM UF2 cannot meet a concrete requirement. -### Phase 7 — Performance and release qualification +### Phase 7 — Indexed profile catalog — Complete + +Replace the fully decoded fixed database before increasing profile count. +Initial capacity is eight profiles for the global fallback and each of sixteen +stable controller identities; the format must support a later increase without +another storage rewrite. + +Storage design: + +- reserve two 128 KiB flash arenas for append-only profile records and atomic + compaction +- store identity, profile index, generation, schema, payload length, and CRC + in every record header +- keep two independently checksummed superblocks; publish a compacted arena + only after every live record verifies +- retain the current four-profile bank reader for one-time migration +- do not erase an admitted legacy bank until the new catalog and superblock + have been read back successfully +- maintain a compact RAM index, not a decoded copy of every profile +- decode only the fallback and active profile for each observed identity +- keep report-path profile access allocation-free with bounded snapshots + +The completed AIO image uses 683,128 bytes of 4 MiB flash and reserves 256 +KiB for the two profile arenas. Total flash use plus configuration, bonds, +and the RP2350 terminal sector is 965,752 bytes (23.03%). Linked SRAM is +97,600 of 532,480 bytes; the profile catalog index is 1,556 bytes. + +Acceptance: + +- all existing profiles 1–4 survive migration byte-for-byte at the semantic + level +- profiles 5–8 default independently and persist across reboot +- interrupted append and compaction recover the last published generation +- corrupt newest records fall back to the previous valid record +- identity capacity remains aligned with the sixteen-entry bond store +- only active/fallback profiles are decoded in SRAM +- profile switching, management USB, and the graphical editor expose all + eight slots + +Completion evidence: + +- the native catalog suite covers interrupted header publication, corrupt + payload fallback, compaction, sixteen-identity capacity, and semantic + migration of global and stable profiles 1–4 +- service tests cover on-demand selection, cached active profiles, reset-all, + controller-originated activation, and persistence of profiles 7 and 8 +- all 109 tests pass; UART, AIO, and feasibility firmware build +- the browser editor renders and selects all eight slots +- Pico 2 W hardware read and activated profile 8, then restored profile 1 + +### Phase 8 — Performance and release qualification Measure: diff --git a/README.md b/README.md index 4ae014d..2dd1db8 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Development USB identities are `CAFE:4010` (XInput), `CAFE:4020` (DInput), and ` The editor selects Switch Pro, DualSense, or Xbox artwork from the connected controller's USB VID/PID and places each remappable control directly over the matching physical button. Controller artwork is from [AL2009man/Gamepad-Asset-Pack](https://github.com/AL2009man/Gamepad-Asset-Pack) under its MIT license; the bundled license and source revision are recorded beside the assets. -`profiles list` prints identity index `0` for the global fallback plus each stable Bluetooth identity observed by the firmware. Each identity owns four persistent profiles and one active index. The JSON export/import commands remain available for version-controlled or scripted profiles. Profile numbers shown to users are `1` through `4`; `--identity` uses the zero-based index from `profiles list`. +`profiles list` prints identity index `0` for the global fallback plus each stable Bluetooth identity observed by the firmware. Each identity owns eight persistent profiles and one active index. The JSON export/import commands remain available for version-controlled or scripted profiles. Profile numbers shown to users are `1` through `8`; `--identity` uses the zero-based index from `profiles list`. `pairings list` refreshes and prints stored Bluetooth Classic and BLE addresses. `pairings clear --yes` deletes all bonds, disconnects active controllers, closes new authentication, and resumes discovery because no controllers remain. Destructive commands require `--yes`. If multiple compatible Picos are attached, select one with `--bus N --address N`; the error lists their locations. USB access errors require permission to the matching `/dev/bus/usb` device. @@ -156,10 +156,10 @@ configuration, pairing, and profile work. The profile editor lists **Cycle active profile**, **Toggle motion**, and **Run custom macro** as separate editable actions. Every action chord can contain any combination of the 16 buttons and the L2/R2 analog triggers. The default profile-switching chord is **L + R + Select + Start**; on DualSense, use **L1 + R1 + Create + Options**. A stored empty chord selects that default. -- The chord cycles persistent profiles `1 → 2 → 3 → 4 → 1`. +- The chord cycles persistent profiles `1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 1`. - Chord buttons are consumed locally and are not forwarded to the host. - The new profile applies only after its atomic flash commit completes. -- Confirmation uses one to four 75 ms pulses matching the active profile number. +- Confirmation uses one to eight 75 ms pulses matching the active profile number. - The profile policy independently enables rumble and LED feedback. - On connection and profile changes, RGB/player LEDs briefly show the active profile color/count, then return to the persistent USB slot color/player number. - Each controller identity and each of the four active USB slots remain isolated. @@ -594,6 +594,41 @@ After changing any IMU conversion, calibration, timing, or report packing: 5. Inject a known single-axis gyro rate and decode the packed quaternion. The corresponding component must change smoothly with the expected sign. 6. Perform the decisive end-to-end check: genuine Pro Controller → SDL3 bridge → UART → emulated Pico → Zelda. This path was confirmed correct after the mode-2 fix. +## Firmware resource usage + +The Pico 2 W AIO build is measured from `build-aio/switch-pico.elf` and its +linked binary, not from the larger debug-bearing ELF or UF2 transport file: + +| Resource | Used or reserved | Device capacity | +|---|---:|---:| +| Executable flash image | 683,128 bytes | 4 MiB | +| Indexed profile arenas | 256 KiB | 4 MiB flash | +| Adapter configuration | 8 KiB | 4 MiB flash | +| BTstack bonds | 8 KiB | 4 MiB flash | +| RP2350 terminal sector | 4 KiB | 4 MiB flash | +| Linked SRAM | 97,600 bytes | 520 KiB | + +The executable plus persistent reservations consume 965,752 bytes (23.03%) +of flash, leaving 3,228,552 bytes (3.08 MiB). Linked SRAM consumes 18.33%, +leaving 434,880 bytes of link-time headroom. + +Profiles use two 128 KiB append-only arenas. Each independently published +record contains one identity/profile key, generation, schema, length, and CRC. +The compact in-memory index is 1,556 bytes; only the fallback and active +profile for each observed identity are decoded and published. Including the +active cache, selected-profile buffer, transaction state, profile runtime +contexts, and catalog index, the profile subsystem uses approximately 13 KiB +of SRAM instead of retaining every profile in decoded form. + +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 +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 therefore leave the previous valid record +available. On first boot after upgrading, the legacy four-profile banks are +read from their old flash addresses and copied into the new catalog before +the legacy region can be erased. + ## References - GP2040-CE (controller firmware ecosystem): https://github.com/OpenStickCommunity/GP2040-CE - nxbt (Switch controller research/tools): https://github.com/Brikwerk/nxbt diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 597fadb..0ccab0f 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -52,6 +52,10 @@ constexpr SwitchRgbColor kProfileLightbarPalette[CONTROLLER_PROFILE_COUNT] = { {0x00, 0xcc, 0x66}, {0xff, 0xaa, 0x00}, {0xcc, 0x33, 0xff}, + {0xff, 0x44, 0x44}, + {0x00, 0xdd, 0xdd}, + {0xff, 0x66, 0xbb}, + {0xcc, 0xff, 0x33}, }; constexpr bool kDefaultMotionEnabled = SWITCH_MOTION_DEFAULT_ENABLED != 0; @@ -69,7 +73,7 @@ constexpr uint8_t kMotionEnabledFeedbackStrongMagnitude = SWITCH_MOTION_ENABLED_FEEDBACK_STRONG_MAGNITUDE; static_assert(kProfileFeedbackPhaseDurationMs == 75); -static_assert(CONTROLLER_PROFILE_COUNT == 4); +static_assert(CONTROLLER_PROFILE_COUNT == 8); static_assert(kMotionDisabledFeedbackDurationMs > 0); static_assert(kMotionEnabledFeedbackDurationMs > 0); diff --git a/src/firmware/platform/pico/pico_profile_storage.cpp b/src/firmware/platform/pico/pico_profile_storage.cpp index 196052e..4654b55 100644 --- a/src/firmware/platform/pico/pico_profile_storage.cpp +++ b/src/firmware/platform/pico/pico_profile_storage.cpp @@ -1,4 +1,5 @@ #include "platform/pico/pico_profile_storage.h" + #include #include "configuration/configuration_storage.h" @@ -19,132 +20,105 @@ constexpr uint32_t kProfileStorageOffset = kConfigurationStorageOffset - PROFILE_STORAGE_TOTAL_SIZE; constexpr uint32_t kFlashSafeExecuteTimeoutMs = 5000; -static_assert(FLASH_SECTOR_SIZE == PROFILE_STORAGE_SECTOR_SIZE, - "profile storage sector size does not match Pico flash"); -static_assert(FLASH_PAGE_SIZE == PROFILE_STORAGE_PAGE_SIZE, - "profile storage page size does not match Pico flash"); +static_assert(FLASH_SECTOR_SIZE == PROFILE_STORAGE_SECTOR_SIZE); +static_assert(FLASH_PAGE_SIZE == PROFILE_STORAGE_PAGE_SIZE); static_assert(PICO_FLASH_BANK_STORAGE_OFFSET >= - kConfigurationStorageSize + PROFILE_STORAGE_TOTAL_SIZE, - "profile storage offset underflows flash"); + kConfigurationStorageSize + PROFILE_STORAGE_TOTAL_SIZE); static_assert(kProfileStorageOffset + PROFILE_STORAGE_TOTAL_SIZE <= - kConfigurationStorageOffset, - "profile storage overlaps adapter configuration storage"); + kConfigurationStorageOffset); static_assert(kConfigurationStorageOffset + kConfigurationStorageSize <= - PICO_FLASH_BANK_STORAGE_OFFSET, - "adapter configuration storage overlaps BTstack bonds"); -static_assert(PICO_FLASH_BANK_STORAGE_OFFSET + - PICO_FLASH_BANK_TOTAL_SIZE <= - PICO_FLASH_SIZE_BYTES, - "BTstack storage exceeds flash"); + PICO_FLASH_BANK_STORAGE_OFFSET); +static_assert(PICO_FLASH_BANK_STORAGE_OFFSET + PICO_FLASH_BANK_TOTAL_SIZE <= + PICO_FLASH_SIZE_BYTES); -struct FlashBankReplacement { - uint8_t bank; - const uint8_t* payload; - size_t payload_size; - const uint8_t* header; - bool replaced; +struct EraseOperation { + uint32_t offset; }; -void perform_flash_bank_replacement(void* context) { - auto* replacement = - static_cast(context); - replacement->replaced = false; - const uint32_t bank_offset = - kProfileStorageOffset + - replacement->bank * PROFILE_STORAGE_BANK_SIZE; +struct ProgramOperation { + uint32_t offset; + const uint8_t *page; +}; - for (size_t offset = 0; offset < PROFILE_STORAGE_BANK_SIZE; - offset += FLASH_SECTOR_SIZE) { - flash_range_erase(bank_offset + offset, FLASH_SECTOR_SIZE); - } +void perform_erase(void *context) { + const auto *operation = static_cast(context); + flash_range_erase(operation->offset, PROFILE_STORAGE_ARENA_SIZE); +} - uint8_t final_page[FLASH_PAGE_SIZE]{}; - for (size_t offset = 0; offset < replacement->payload_size; - offset += FLASH_PAGE_SIZE) { - const size_t remaining = - replacement->payload_size - offset; - const uint8_t* page = &replacement->payload[offset]; - if (remaining < FLASH_PAGE_SIZE) { - memcpy(final_page, page, remaining); - page = final_page; - } - flash_range_program( - bank_offset + PROFILE_STORAGE_RECORD_HEADER_SIZE + offset, - page, FLASH_PAGE_SIZE); - } - - const auto* stored_payload = reinterpret_cast( - XIP_BASE + bank_offset + - PROFILE_STORAGE_RECORD_HEADER_SIZE); - if (memcmp(stored_payload, replacement->payload, - replacement->payload_size) != 0) { - return; - } - - flash_range_program(bank_offset, replacement->header, - PROFILE_STORAGE_RECORD_HEADER_SIZE); - const auto* stored_header = reinterpret_cast( - XIP_BASE + bank_offset); - replacement->replaced = - memcmp(stored_header, replacement->header, - PROFILE_STORAGE_RECORD_HEADER_SIZE) == 0; +void perform_program(void *context) { + const auto *operation = static_cast(context); + flash_range_program(operation->offset, operation->page, + PROFILE_STORAGE_PAGE_SIZE); } bool storage_region_available() { - const uintptr_t binary_end = - reinterpret_cast(&__flash_binary_end) - XIP_BASE; - return binary_end <= kProfileStorageOffset; + const uintptr_t binary_end = + reinterpret_cast(&__flash_binary_end) - XIP_BASE; + return binary_end <= kProfileStorageOffset; } -bool read_storage(void*, uint8_t bank, size_t offset, uint8_t* output, +bool read_storage(void *, uint8_t arena, size_t offset, uint8_t *output, size_t size) { - if (bank >= PROFILE_STORAGE_BANK_COUNT || output == nullptr || - offset > PROFILE_STORAGE_BANK_SIZE || - size > PROFILE_STORAGE_BANK_SIZE - offset || - !storage_region_available()) { - return false; - } - const uintptr_t address = - XIP_BASE + kProfileStorageOffset + - bank * PROFILE_STORAGE_BANK_SIZE + offset; - memcpy(output, reinterpret_cast(address), size); - return true; + if (arena >= PROFILE_STORAGE_ARENA_COUNT || output == nullptr || + offset > PROFILE_STORAGE_ARENA_SIZE || + size > PROFILE_STORAGE_ARENA_SIZE - offset || + !storage_region_available()) { + return false; + } + const uintptr_t address = XIP_BASE + kProfileStorageOffset + + arena * PROFILE_STORAGE_ARENA_SIZE + offset; + memcpy(output, reinterpret_cast(address), size); + return true; } -bool replace_storage_bank(void*, uint8_t bank, - const uint8_t* payload, - size_t payload_size, - const uint8_t* header, - size_t header_size) { - if (bank >= PROFILE_STORAGE_BANK_COUNT || payload == nullptr || - header == nullptr || - payload_size != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || - header_size != PROFILE_STORAGE_RECORD_HEADER_SIZE || - !storage_region_available()) { - return false; +bool erase_arena(void *, uint8_t arena) { + if (arena >= PROFILE_STORAGE_ARENA_COUNT || !storage_region_available()) { + return false; + } + EraseOperation operation{ + kProfileStorageOffset + arena * PROFILE_STORAGE_ARENA_SIZE, + }; + if (flash_safe_execute(perform_erase, &operation, + kFlashSafeExecuteTimeoutMs) != PICO_OK) { + return false; + } + const auto *stored = + reinterpret_cast(XIP_BASE + operation.offset); + for (size_t offset = 0; offset < PROFILE_STORAGE_ARENA_SIZE; ++offset) { + if (stored[offset] != 0xff) { + return false; } - FlashBankReplacement replacement{ - bank, - payload, - payload_size, - header, - false, - }; - return flash_safe_execute( - perform_flash_bank_replacement, &replacement, - kFlashSafeExecuteTimeoutMs) == PICO_OK && - replacement.replaced; + } + return true; } -} // namespace +bool program_page(void *, uint8_t arena, size_t offset, const uint8_t *page, + size_t size) { + if (arena >= PROFILE_STORAGE_ARENA_COUNT || page == nullptr || + size != PROFILE_STORAGE_PAGE_SIZE || + offset % PROFILE_STORAGE_PAGE_SIZE != 0 || + offset > PROFILE_STORAGE_ARENA_SIZE || + size > PROFILE_STORAGE_ARENA_SIZE - offset || + !storage_region_available()) { + return false; + } + ProgramOperation operation{ + kProfileStorageOffset + arena * PROFILE_STORAGE_ARENA_SIZE + offset, + page, + }; + return flash_safe_execute(perform_program, &operation, + kFlashSafeExecuteTimeoutMs) == PICO_OK && + memcmp(reinterpret_cast(XIP_BASE + operation.offset), + page, size) == 0; +} + +} // namespace ProfileStorageIo pico_profile_storage_io() { - return { - nullptr, - PROFILE_STORAGE_BANK_SIZE, - FLASH_SECTOR_SIZE, - FLASH_PAGE_SIZE, - read_storage, - replace_storage_bank, - }; + return { + nullptr, PROFILE_STORAGE_ARENA_SIZE, + FLASH_SECTOR_SIZE, FLASH_PAGE_SIZE, + read_storage, erase_arena, + program_page, + }; } diff --git a/src/firmware/profile/controller_profile.h b/src/firmware/profile/controller_profile.h index 7e8b5d3..d767a88 100644 --- a/src/firmware/profile/controller_profile.h +++ b/src/firmware/profile/controller_profile.h @@ -11,7 +11,7 @@ constexpr uint16_t CONTROLLER_PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3; constexpr uint16_t CONTROLLER_PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4; constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 5; constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 256; -constexpr uint8_t CONTROLLER_PROFILE_COUNT = 4; +constexpr uint8_t CONTROLLER_PROFILE_COUNT = 8; constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT = 16; constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT = 18; constexpr uint8_t CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL = 16; @@ -42,7 +42,7 @@ constexpr size_t CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY * CONTROLLER_PROFILE_DATABASE_ENTRY_SIZE; -static_assert(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE == 17696, +static_assert(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE == 35104, "profile database wire size changed"); enum class ControllerProfileLogicalButton : uint8_t { kSouth = 0, diff --git a/src/firmware/profile/profile_service.cpp b/src/firmware/profile/profile_service.cpp index 4a29b2d..821b7be 100644 --- a/src/firmware/profile/profile_service.cpp +++ b/src/firmware/profile/profile_service.cpp @@ -11,662 +11,581 @@ constexpr uint32_t kMinimumCommitIntervalMs = 1000; constexpr uint32_t kInternalTransactionIdMask = 0x80000000u; enum class PendingCommandType : uint8_t { - kNone = 0, - kReset = 1, - kActivate = 2, + kNone = 0, + kReset = 1, + kActivate = 2, }; struct PendingCommand { - PendingCommandType type = PendingCommandType::kNone; - uint32_t transaction_id = 0; - ControllerIdentity identity{}; - uint8_t profile_index = 0; + PendingCommandType type = PendingCommandType::kNone; + uint32_t transaction_id = 0; + ControllerIdentity identity{}; + uint8_t profile_index = 0; }; struct ProfileTransaction { - ControllerIdentity identity{}; - uint8_t profile_index = 0; - ConfigurationTransactionSnapshot snapshot{}; - uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + ControllerIdentity identity{}; + uint8_t profile_index = 0; + ConfigurationTransactionSnapshot snapshot{}; + uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; }; struct PublishedActiveProfile { - ControllerIdentity identity{}; - uint8_t profile_index = 0; - ControllerProfile profile{}; + ControllerIdentity identity{}; + uint8_t profile_index = 0; + ControllerProfile profile{}; }; critical_section_t g_lock; bool g_prepared = false; ProfileStorage g_storage; -ControllerProfileDatabase g_database; ProfileServiceMetadata g_metadata; uint32_t g_published_generation = 0; ProfileServiceListSnapshot g_list; ProfileServiceSelectedSnapshot g_selected; -PublishedActiveProfile - g_active_profiles[PROFILE_SERVICE_LIST_CAPACITY]{}; +PublishedActiveProfile g_active_profiles[PROFILE_SERVICE_LIST_CAPACITY]{}; uint8_t g_active_profile_count = 0; ProfileTransaction g_transaction; PendingCommand g_command; PendingCommand g_internal_activation; -alignas(4) uint8_t - g_encoded_database[CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE]{}; -bool g_identity_dirty = false; bool g_has_committed = false; uint32_t g_last_commit_ms = 0; -bool valid_identity(const ControllerIdentity& identity) { - uint8_t encoded[CONTROLLER_IDENTITY_ENCODED_SIZE]{}; - return (controller_identity_is_global(identity) || identity.stable) && - controller_identity_encode(identity, encoded, sizeof(encoded)); +bool valid_identity(const ControllerIdentity &identity) { + uint8_t encoded[CONTROLLER_IDENTITY_ENCODED_SIZE]{}; + return (controller_identity_is_global(identity) || identity.stable) && + controller_identity_encode(identity, encoded, sizeof(encoded)); } void refresh_list_locked() { - g_list = ProfileServiceListSnapshot{}; - g_list.metadata = g_metadata; - g_list.count = 1; - g_list.rows[0].identity = controller_identity_global(); - g_list.rows[0].active_profile = - g_database.fallback_active_profile; - for (const ControllerProfileDatabaseEntry& entry : g_database.entries) { - if (!entry.used || g_list.count >= PROFILE_SERVICE_LIST_CAPACITY) { - continue; - } - ProfileServiceListRow& row = g_list.rows[g_list.count++]; - row.identity = entry.identity; - row.active_profile = entry.active_profile; + g_list = ProfileServiceListSnapshot{}; + g_list.metadata = g_metadata; + for (uint8_t index = 0; index < g_storage.identity_count() && + g_list.count < PROFILE_SERVICE_LIST_CAPACITY; + ++index) { + const ProfileStorageIdentityIndex *entry = g_storage.identity(index); + if (entry == nullptr || !entry->used) { + continue; } + ProfileServiceListRow &row = g_list.rows[g_list.count++]; + row.identity = entry->identity; + row.active_profile = entry->active_profile; + } } void refresh_active_profiles_locked() { - g_active_profile_count = 1; - g_active_profiles[0].identity = controller_identity_global(); - g_active_profiles[0].profile_index = - g_database.fallback_active_profile; - g_active_profiles[0].profile = - g_database.fallback_profiles[g_database.fallback_active_profile]; - for (const ControllerProfileDatabaseEntry& entry : g_database.entries) { - if (!entry.used || - g_active_profile_count >= PROFILE_SERVICE_LIST_CAPACITY) { - continue; - } - PublishedActiveProfile& active = - g_active_profiles[g_active_profile_count++]; - active.identity = entry.identity; - active.profile_index = entry.active_profile; - active.profile = entry.profiles[entry.active_profile]; + g_active_profile_count = 0; + for (uint8_t index = 0; + index < g_storage.identity_count() && + g_active_profile_count < PROFILE_SERVICE_LIST_CAPACITY; + ++index) { + const ProfileStorageIdentityIndex *entry = g_storage.identity(index); + if (entry == nullptr || !entry->used) { + continue; } + PublishedActiveProfile &active = g_active_profiles[g_active_profile_count]; + active.identity = entry->identity; + active.profile_index = entry->active_profile; + if (g_storage.get(entry->identity, entry->active_profile, + &active.profile) != ProfileStorageResult::kOk) { + continue; + } + ++g_active_profile_count; + } } void refresh_selected_locked() { - g_selected.metadata = g_metadata; - const ControllerProfile* profile = controller_profile_database_get( - g_database, g_selected.identity, g_selected.profile_index); - if (profile == nullptr) { - g_selected.valid = false; - g_selected.status = ConfigurationTransactionStatus::kMalformed; - return; - } - g_selected.profile = *profile; - g_selected.valid = true; - g_selected.status = ConfigurationTransactionStatus::kCommitted; + g_selected.metadata = g_metadata; + if (g_storage.get(g_selected.identity, g_selected.profile_index, + &g_selected.profile) != ProfileStorageResult::kOk) { + g_selected.valid = false; + g_selected.status = ConfigurationTransactionStatus::kMalformed; + return; + } + g_selected.valid = true; + g_selected.status = ConfigurationTransactionStatus::kCommitted; } void refresh_metadata_locked(ProfileServiceState state) { - const ProfileStorageSnapshot& stored = g_storage.snapshot(); - g_metadata.state = state; - g_metadata.generation = stored.valid ? stored.generation : 0; - g_metadata.payload_crc = stored.valid ? stored.payload_crc : 0; - refresh_active_profiles_locked(); - refresh_list_locked(); - refresh_selected_locked(); - __atomic_store_n(&g_published_generation, g_metadata.generation, - __ATOMIC_RELEASE); + const ProfileStorageSnapshot &stored = g_storage.snapshot(); + g_metadata.state = state; + g_metadata.generation = stored.valid ? stored.generation : 0; + g_metadata.payload_crc = stored.valid ? stored.payload_crc : 0; + refresh_active_profiles_locked(); + refresh_list_locked(); + refresh_selected_locked(); + __atomic_store_n(&g_published_generation, g_metadata.generation, + __ATOMIC_RELEASE); } -ConfigurationTransactionStatus database_result_status( - ControllerProfileDatabaseResult result) { - switch (result) { - case ControllerProfileDatabaseResult::kOk: - return ConfigurationTransactionStatus::kPending; - case ControllerProfileDatabaseResult::kFull: - return ConfigurationTransactionStatus::kTooLarge; - case ControllerProfileDatabaseResult::kInvalidArgument: - return ConfigurationTransactionStatus::kMalformed; - } +ConfigurationTransactionStatus +storage_result_status(ProfileStorageResult result) { + switch (result) { + case ProfileStorageResult::kOk: + return ConfigurationTransactionStatus::kCommitted; + case ProfileStorageResult::kUnchanged: + return ConfigurationTransactionStatus::kUnchanged; + case ProfileStorageResult::kInvalidArgument: + return ConfigurationTransactionStatus::kMalformed; + case ProfileStorageResult::kFull: + return ConfigurationTransactionStatus::kTooLarge; + case ProfileStorageResult::kIoError: return ConfigurationTransactionStatus::kStorageError; + } + return ConfigurationTransactionStatus::kStorageError; } bool mutation_ready(uint32_t now_ms) { - return !g_has_committed || - static_cast(now_ms - g_last_commit_ms) >= - kMinimumCommitIntervalMs; + return !g_has_committed || static_cast(now_ms - g_last_commit_ms) >= + kMinimumCommitIntervalMs; } void finish_mutation(ConfigurationTransactionStatus status, bool clear_command) { - const ProfileStorageSnapshot& stored = g_storage.snapshot(); - critical_section_enter_blocking(&g_lock); - g_transaction.snapshot.status = status; - g_transaction.snapshot.stored_generation = - stored.valid ? stored.generation : 0; - g_transaction.snapshot.stored_crc = - stored.valid ? stored.payload_crc : 0; - if (clear_command) { - g_command = {}; - } - refresh_metadata_locked( - status == ConfigurationTransactionStatus::kStorageError - ? ProfileServiceState::kStorageError - : ProfileServiceState::kReady); - critical_section_exit(&g_lock); + critical_section_enter_blocking(&g_lock); + g_transaction.snapshot.status = status; + g_transaction.snapshot.stored_generation = + g_storage.snapshot().valid ? g_storage.snapshot().generation : 0; + g_transaction.snapshot.stored_crc = + g_storage.snapshot().valid ? g_storage.snapshot().payload_crc : 0; + if (clear_command) { + g_command = {}; + } + refresh_metadata_locked(status == + ConfigurationTransactionStatus::kStorageError + ? ProfileServiceState::kStorageError + : ProfileServiceState::kReady); + critical_section_exit(&g_lock); } -void finish_internal_activation( - ConfigurationTransactionStatus status) { - critical_section_enter_blocking(&g_lock); - g_internal_activation = {}; - refresh_metadata_locked( - status == ConfigurationTransactionStatus::kStorageError - ? ProfileServiceState::kStorageError - : ProfileServiceState::kReady); - critical_section_exit(&g_lock); +void finish_internal_activation(ConfigurationTransactionStatus status) { + critical_section_enter_blocking(&g_lock); + g_internal_activation = {}; + refresh_metadata_locked(status == + ConfigurationTransactionStatus::kStorageError + ? ProfileServiceState::kStorageError + : ProfileServiceState::kReady); + critical_section_exit(&g_lock); } -} // namespace +} // namespace void profile_service_prepare() { - if (g_prepared) { - return; - } - critical_section_init(&g_lock); - g_metadata = {}; - __atomic_store_n(&g_published_generation, 0, __ATOMIC_RELAXED); - g_list = ProfileServiceListSnapshot{}; - g_selected = {}; - g_active_profiles[0] = {}; - g_active_profile_count = 0; - g_selected.identity = controller_identity_global(); - g_selected.profile_index = 0; - g_transaction = {}; - g_command = {}; - g_internal_activation = {}; - g_identity_dirty = false; - g_has_committed = false; - g_last_commit_ms = 0; - g_prepared = true; + if (g_prepared) { + return; + } + critical_section_init(&g_lock); + g_metadata = {}; + __atomic_store_n(&g_published_generation, 0, __ATOMIC_RELAXED); + g_list = ProfileServiceListSnapshot{}; + g_selected = {}; + g_active_profiles[0] = {}; + g_active_profile_count = 0; + g_selected.identity = controller_identity_global(); + g_selected.profile_index = 0; + g_transaction = {}; + g_command = {}; + g_internal_activation = {}; + g_has_committed = false; + g_last_commit_ms = 0; + g_prepared = true; } void profile_service_initialize_on_storage_core() { - if (!g_prepared) { - profile_service_prepare(); - } - const bool initialized = - g_storage.initialize(pico_profile_storage_io(), &g_database); - critical_section_enter_blocking(&g_lock); - refresh_metadata_locked(initialized ? ProfileServiceState::kReady - : ProfileServiceState::kStorageError); - critical_section_exit(&g_lock); + if (!g_prepared) { + profile_service_prepare(); + } + const bool initialized = g_storage.initialize(pico_profile_storage_io()); + critical_section_enter_blocking(&g_lock); + refresh_metadata_locked(initialized ? ProfileServiceState::kReady + : ProfileServiceState::kStorageError); + critical_section_exit(&g_lock); } bool profile_service_observe_identity_on_storage_core( - const ControllerIdentity& identity) { - if (!g_prepared || !identity.stable || - controller_identity_is_global(identity) || - !valid_identity(identity)) { - return false; - } - critical_section_enter_blocking(&g_lock); - if (g_metadata.state != ProfileServiceState::kReady) { - critical_section_exit(&g_lock); - return false; - } - ControllerProfileDatabaseEntry* entry = - controller_profile_database_find(&g_database, identity); - if (entry != nullptr) { - critical_section_exit(&g_lock); - return true; - } - const ControllerProfileDatabaseResult result = - controller_profile_database_ensure(&g_database, identity, &entry); - if (result == ControllerProfileDatabaseResult::kOk) { - g_identity_dirty = true; - refresh_list_locked(); - } - critical_section_exit(&g_lock); - return result == ControllerProfileDatabaseResult::kOk; + const ControllerIdentity &identity) { + if (!g_prepared || !identity.stable || + controller_identity_is_global(identity) || !valid_identity(identity)) { + 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; + } + const ProfileStorageResult result = g_storage.ensure_identity(identity); + if (result != ProfileStorageResult::kOk && + result != ProfileStorageResult::kUnchanged) { + return false; + } + critical_section_enter_blocking(&g_lock); + refresh_metadata_locked(ProfileServiceState::kReady); + critical_section_exit(&g_lock); + return true; } - void profile_service_task_on_storage_core(uint32_t now_ms) { - PendingCommand command{}; - bool process_write = false; - bool process_internal_activation = false; - bool process_identity = false; - ControllerIdentity write_identity{}; - uint8_t write_profile_index = 0; - uint8_t write_payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + PendingCommand command{}; + bool process_write = false; + bool process_internal_activation = false; + ControllerIdentity write_identity{}; + uint8_t write_profile_index = 0; + uint8_t write_payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; - critical_section_enter_blocking(&g_lock); - if (mutation_ready(now_ms)) { - if (g_command.type != PendingCommandType::kNone) { - command = g_command; - } else if (g_transaction.snapshot.status == - ConfigurationTransactionStatus::kPending) { - process_write = true; - write_identity = g_transaction.identity; - write_profile_index = g_transaction.profile_index; - memcpy(write_payload, g_transaction.payload, - sizeof(write_payload)); - } else if (g_internal_activation.type != - PendingCommandType::kNone) { - command = g_internal_activation; - process_internal_activation = true; - } else if (g_identity_dirty) { - process_identity = true; - } + critical_section_enter_blocking(&g_lock); + if (mutation_ready(now_ms)) { + if (g_command.type != PendingCommandType::kNone) { + command = g_command; + } else if (g_transaction.snapshot.status == + ConfigurationTransactionStatus::kPending) { + process_write = true; + write_identity = g_transaction.identity; + write_profile_index = g_transaction.profile_index; + memcpy(write_payload, g_transaction.payload, sizeof(write_payload)); + } else if (g_internal_activation.type != PendingCommandType::kNone) { + command = g_internal_activation; + process_internal_activation = true; } - critical_section_exit(&g_lock); + } + critical_section_exit(&g_lock); - if (!process_write && !process_identity && - !process_internal_activation && - command.type == PendingCommandType::kNone) { - return; - } + if (!process_write && !process_internal_activation && + command.type == PendingCommandType::kNone) { + return; + } - ControllerProfileDatabaseResult database_result = - ControllerProfileDatabaseResult::kInvalidArgument; - if (process_identity) { - database_result = ControllerProfileDatabaseResult::kOk; - } else if (process_write) { - ControllerProfile profile{}; - if (controller_profile_decode(write_payload, sizeof(write_payload), - &profile)) { - critical_section_enter_blocking(&g_lock); - database_result = controller_profile_database_set( - &g_database, write_identity, write_profile_index, profile); - critical_section_exit(&g_lock); - } - } else if (command.type == PendingCommandType::kReset) { - critical_section_enter_blocking(&g_lock); - database_result = controller_profile_database_reset( - &g_database, command.identity, command.profile_index); - critical_section_exit(&g_lock); - } else if (command.type == PendingCommandType::kActivate) { - critical_section_enter_blocking(&g_lock); - database_result = controller_profile_database_activate( - &g_database, command.identity, command.profile_index); - critical_section_exit(&g_lock); + ProfileStorageResult storage_result = ProfileStorageResult::kInvalidArgument; + if (process_write) { + ControllerProfile profile{}; + if (controller_profile_decode(write_payload, sizeof(write_payload), + &profile)) { + storage_result = + g_storage.set(write_identity, write_profile_index, profile); } + } else if (command.type == PendingCommandType::kReset) { + storage_result = g_storage.reset(command.identity, command.profile_index); + } else if (command.type == PendingCommandType::kActivate) { + storage_result = + g_storage.activate(command.identity, command.profile_index); + } - if (database_result != ControllerProfileDatabaseResult::kOk) { - const ConfigurationTransactionStatus error_status = - database_result_status(database_result); - if (process_internal_activation) { - finish_internal_activation(error_status); - } else { - finish_mutation(error_status, !process_write); - } - return; - } - - const ProfileStorageResult storage_result = g_storage.commit( - g_database, g_encoded_database, sizeof(g_encoded_database)); - ConfigurationTransactionStatus status = - ConfigurationTransactionStatus::kStorageError; - if (storage_result == ProfileStorageResult::kOk) { - status = ConfigurationTransactionStatus::kCommitted; - g_has_committed = true; - g_last_commit_ms = now_ms; - } else if (storage_result == ProfileStorageResult::kUnchanged) { - status = ConfigurationTransactionStatus::kUnchanged; - } else { - critical_section_enter_blocking(&g_lock); - const bool restored = - g_storage.initialize(pico_profile_storage_io(), &g_database); - critical_section_exit(&g_lock); - if (!restored) { - status = ConfigurationTransactionStatus::kStorageError; - } - } - if (process_identity) { - critical_section_enter_blocking(&g_lock); - g_identity_dirty = false; - refresh_metadata_locked( - status == ConfigurationTransactionStatus::kStorageError - ? ProfileServiceState::kStorageError - : ProfileServiceState::kReady); - critical_section_exit(&g_lock); - return; - } - critical_section_enter_blocking(&g_lock); - g_identity_dirty = false; - critical_section_exit(&g_lock); - if (process_internal_activation) { - finish_internal_activation(status); - } else { - finish_mutation(status, !process_write); - } + const ConfigurationTransactionStatus status = + storage_result_status(storage_result); + if (status == ConfigurationTransactionStatus::kCommitted) { + g_has_committed = true; + g_last_commit_ms = now_ms; + } + if (process_internal_activation) { + finish_internal_activation(status); + } else { + finish_mutation(status, !process_write); + } } -ConfigurationTransactionStatus profile_service_select( - const ControllerIdentity& identity, uint8_t profile_index) { - if (!g_prepared) { - profile_service_prepare(); - } - critical_section_enter_blocking(&g_lock); - ConfigurationTransactionStatus status = - ConfigurationTransactionStatus::kCommitted; - if (!valid_identity(identity) || - profile_index >= CONTROLLER_PROFILE_COUNT) { - status = ConfigurationTransactionStatus::kMalformed; - g_selected.metadata = g_metadata; - g_selected.identity = identity; - g_selected.profile_index = profile_index; - g_selected.valid = false; - g_selected.status = status; - } else if (g_metadata.state != ProfileServiceState::kReady) { - status = g_metadata.state == ProfileServiceState::kLoading - ? ConfigurationTransactionStatus::kPending - : ConfigurationTransactionStatus::kStorageError; - g_selected.metadata = g_metadata; - g_selected.identity = identity; - g_selected.profile_index = profile_index; - g_selected.valid = false; - g_selected.status = status; - } else { - g_selected.identity = identity; - g_selected.profile_index = profile_index; - refresh_selected_locked(); - status = g_selected.status; - } - critical_section_exit(&g_lock); - return status; +ConfigurationTransactionStatus +profile_service_select(const ControllerIdentity &identity, + uint8_t profile_index) { + if (!g_prepared) { + profile_service_prepare(); + } + critical_section_enter_blocking(&g_lock); + ConfigurationTransactionStatus status = + ConfigurationTransactionStatus::kCommitted; + if (!valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) { + status = ConfigurationTransactionStatus::kMalformed; + g_selected.metadata = g_metadata; + g_selected.identity = identity; + g_selected.profile_index = profile_index; + g_selected.valid = false; + g_selected.status = status; + } else if (g_metadata.state != ProfileServiceState::kReady) { + status = g_metadata.state == ProfileServiceState::kLoading + ? ConfigurationTransactionStatus::kPending + : ConfigurationTransactionStatus::kStorageError; + g_selected.metadata = g_metadata; + g_selected.identity = identity; + g_selected.profile_index = profile_index; + g_selected.valid = false; + g_selected.status = status; + } else { + g_selected.identity = identity; + g_selected.profile_index = profile_index; + refresh_selected_locked(); + status = g_selected.status; + } + critical_section_exit(&g_lock); + return status; } -ConfigurationTransactionStatus profile_service_begin( - uint32_t transaction_id, const ControllerIdentity& identity, - uint8_t profile_index, uint16_t schema_version, size_t payload_size, - uint32_t payload_crc) { - if (!g_prepared) { - profile_service_prepare(); - } - critical_section_enter_blocking(&g_lock); - if (g_command.type != PendingCommandType::kNone || - g_internal_activation.type != PendingCommandType::kNone || - g_transaction.snapshot.status == - ConfigurationTransactionStatus::kReceiving || - g_transaction.snapshot.status == - ConfigurationTransactionStatus::kPending) { - critical_section_exit(&g_lock); - return ConfigurationTransactionStatus::kBusy; - } - g_transaction = {}; - g_transaction.snapshot.transaction_id = transaction_id; - g_transaction.identity = identity; - g_transaction.profile_index = profile_index; - if (transaction_id == 0 || - (transaction_id & kInternalTransactionIdMask) != 0 || - !valid_identity(identity) || - profile_index >= CONTROLLER_PROFILE_COUNT || payload_size == 0) { - g_transaction.snapshot.status = - ConfigurationTransactionStatus::kMalformed; - } else if (schema_version != CONTROLLER_PROFILE_SCHEMA_VERSION) { - g_transaction.snapshot.status = - ConfigurationTransactionStatus::kUnsupportedSchema; - } else if (payload_size > CONTROLLER_PROFILE_ENCODED_SIZE) { - g_transaction.snapshot.status = - ConfigurationTransactionStatus::kTooLarge; - } else if (payload_size != CONTROLLER_PROFILE_ENCODED_SIZE) { - g_transaction.snapshot.status = - ConfigurationTransactionStatus::kMalformed; - } else { - g_transaction.snapshot.expected_size = - static_cast(payload_size); - g_transaction.snapshot.expected_crc = payload_crc; - g_transaction.snapshot.status = - ConfigurationTransactionStatus::kReceiving; - } - const ConfigurationTransactionStatus status = - g_transaction.snapshot.status; +ConfigurationTransactionStatus +profile_service_begin(uint32_t transaction_id, + const ControllerIdentity &identity, uint8_t profile_index, + uint16_t schema_version, size_t payload_size, + uint32_t payload_crc) { + if (!g_prepared) { + profile_service_prepare(); + } + critical_section_enter_blocking(&g_lock); + if (g_command.type != PendingCommandType::kNone || + g_internal_activation.type != PendingCommandType::kNone || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kReceiving || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kPending) { critical_section_exit(&g_lock); - return status; + return ConfigurationTransactionStatus::kBusy; + } + g_transaction = {}; + g_transaction.snapshot.transaction_id = transaction_id; + g_transaction.identity = identity; + g_transaction.profile_index = profile_index; + if (transaction_id == 0 || + (transaction_id & kInternalTransactionIdMask) != 0 || + !valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT || + payload_size == 0) { + g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed; + } else if (schema_version != CONTROLLER_PROFILE_SCHEMA_VERSION) { + g_transaction.snapshot.status = + ConfigurationTransactionStatus::kUnsupportedSchema; + } else if (payload_size > CONTROLLER_PROFILE_ENCODED_SIZE) { + g_transaction.snapshot.status = ConfigurationTransactionStatus::kTooLarge; + } else if (payload_size != CONTROLLER_PROFILE_ENCODED_SIZE) { + g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed; + } else { + g_transaction.snapshot.expected_size = static_cast(payload_size); + g_transaction.snapshot.expected_crc = payload_crc; + g_transaction.snapshot.status = ConfigurationTransactionStatus::kReceiving; + } + const ConfigurationTransactionStatus status = g_transaction.snapshot.status; + critical_section_exit(&g_lock); + return status; } -ConfigurationTransactionStatus profile_service_append( - uint32_t transaction_id, size_t offset, const uint8_t* data, - size_t size) { - if ((transaction_id & kInternalTransactionIdMask) != 0) { - return ConfigurationTransactionStatus::kMalformed; - } - critical_section_enter_blocking(&g_lock); - if (g_transaction.snapshot.status != - ConfigurationTransactionStatus::kReceiving) { - critical_section_exit(&g_lock); - return ConfigurationTransactionStatus::kBusy; - } - if (transaction_id != g_transaction.snapshot.transaction_id || - data == nullptr || size == 0 || - offset != g_transaction.snapshot.received_size || - offset > g_transaction.snapshot.expected_size || - size > g_transaction.snapshot.expected_size - offset) { - g_transaction.snapshot.status = - ConfigurationTransactionStatus::kOutOfOrder; - } else { - memcpy(&g_transaction.payload[offset], data, size); - g_transaction.snapshot.received_size = - static_cast(offset + size); - } - const ConfigurationTransactionStatus status = - g_transaction.snapshot.status; +ConfigurationTransactionStatus profile_service_append(uint32_t transaction_id, + size_t offset, + const uint8_t *data, + size_t size) { + if ((transaction_id & kInternalTransactionIdMask) != 0) { + return ConfigurationTransactionStatus::kMalformed; + } + critical_section_enter_blocking(&g_lock); + if (g_transaction.snapshot.status != + ConfigurationTransactionStatus::kReceiving) { critical_section_exit(&g_lock); - return status; + return ConfigurationTransactionStatus::kBusy; + } + if (transaction_id != g_transaction.snapshot.transaction_id || + data == nullptr || size == 0 || + offset != g_transaction.snapshot.received_size || + offset > g_transaction.snapshot.expected_size || + size > g_transaction.snapshot.expected_size - offset) { + g_transaction.snapshot.status = ConfigurationTransactionStatus::kOutOfOrder; + } else { + memcpy(&g_transaction.payload[offset], data, size); + g_transaction.snapshot.received_size = static_cast(offset + size); + } + const ConfigurationTransactionStatus status = g_transaction.snapshot.status; + critical_section_exit(&g_lock); + return status; } -ConfigurationTransactionStatus profile_service_commit( - uint32_t transaction_id) { - if ((transaction_id & kInternalTransactionIdMask) != 0) { - return ConfigurationTransactionStatus::kMalformed; - } - critical_section_enter_blocking(&g_lock); - if (g_transaction.snapshot.status != - ConfigurationTransactionStatus::kReceiving || - transaction_id != g_transaction.snapshot.transaction_id || - g_transaction.snapshot.received_size != - g_transaction.snapshot.expected_size) { - g_transaction.snapshot.status = - ConfigurationTransactionStatus::kOutOfOrder; - } else if (profile_storage_crc32( - g_transaction.payload, - g_transaction.snapshot.expected_size) != - g_transaction.snapshot.expected_crc) { - g_transaction.snapshot.status = - ConfigurationTransactionStatus::kBadCrc; - } else { - ControllerProfile profile{}; - g_transaction.snapshot.status = - controller_profile_decode( - g_transaction.payload, - g_transaction.snapshot.expected_size, &profile) - ? ConfigurationTransactionStatus::kPending - : ConfigurationTransactionStatus::kMalformed; - } - const ConfigurationTransactionStatus status = - g_transaction.snapshot.status; - critical_section_exit(&g_lock); - return status; +ConfigurationTransactionStatus profile_service_commit(uint32_t transaction_id) { + if ((transaction_id & kInternalTransactionIdMask) != 0) { + return ConfigurationTransactionStatus::kMalformed; + } + critical_section_enter_blocking(&g_lock); + if (g_transaction.snapshot.status != + ConfigurationTransactionStatus::kReceiving || + transaction_id != g_transaction.snapshot.transaction_id || + g_transaction.snapshot.received_size != + g_transaction.snapshot.expected_size) { + g_transaction.snapshot.status = ConfigurationTransactionStatus::kOutOfOrder; + } else if (profile_storage_crc32(g_transaction.payload, + g_transaction.snapshot.expected_size) != + g_transaction.snapshot.expected_crc) { + g_transaction.snapshot.status = ConfigurationTransactionStatus::kBadCrc; + } else { + ControllerProfile profile{}; + g_transaction.snapshot.status = + controller_profile_decode(g_transaction.payload, + g_transaction.snapshot.expected_size, + &profile) + ? ConfigurationTransactionStatus::kPending + : ConfigurationTransactionStatus::kMalformed; + } + const ConfigurationTransactionStatus status = g_transaction.snapshot.status; + critical_section_exit(&g_lock); + return status; } -ConfigurationTransactionStatus profile_service_reset( - uint32_t transaction_id, const ControllerIdentity& identity, - uint8_t profile_index) { - if (!g_prepared) { - profile_service_prepare(); - } - critical_section_enter_blocking(&g_lock); - if (g_command.type != PendingCommandType::kNone || - g_internal_activation.type != PendingCommandType::kNone || - g_transaction.snapshot.status == - ConfigurationTransactionStatus::kReceiving || - g_transaction.snapshot.status == - ConfigurationTransactionStatus::kPending) { - critical_section_exit(&g_lock); - return ConfigurationTransactionStatus::kBusy; - } - g_transaction = {}; - g_transaction.snapshot.transaction_id = transaction_id; - g_transaction.identity = identity; - g_transaction.profile_index = profile_index; - if (transaction_id == 0 || - (transaction_id & kInternalTransactionIdMask) != 0 || - !valid_identity(identity) || - (profile_index != CONTROLLER_PROFILE_ALL && - profile_index >= CONTROLLER_PROFILE_COUNT)) { - g_transaction.snapshot.status = - ConfigurationTransactionStatus::kMalformed; - critical_section_exit(&g_lock); - return ConfigurationTransactionStatus::kMalformed; - } - g_transaction.snapshot.status = ConfigurationTransactionStatus::kPending; - g_command.transaction_id = transaction_id; - g_command.type = PendingCommandType::kReset; - g_command.identity = identity; - g_command.profile_index = profile_index; +ConfigurationTransactionStatus +profile_service_reset(uint32_t transaction_id, + const ControllerIdentity &identity, + uint8_t profile_index) { + if (!g_prepared) { + profile_service_prepare(); + } + critical_section_enter_blocking(&g_lock); + if (g_command.type != PendingCommandType::kNone || + g_internal_activation.type != PendingCommandType::kNone || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kReceiving || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kPending) { critical_section_exit(&g_lock); - return ConfigurationTransactionStatus::kPending; + return ConfigurationTransactionStatus::kBusy; + } + g_transaction = {}; + g_transaction.snapshot.transaction_id = transaction_id; + g_transaction.identity = identity; + g_transaction.profile_index = profile_index; + if (transaction_id == 0 || + (transaction_id & kInternalTransactionIdMask) != 0 || + !valid_identity(identity) || + (profile_index != CONTROLLER_PROFILE_ALL && + profile_index >= CONTROLLER_PROFILE_COUNT)) { + g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed; + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kMalformed; + } + g_transaction.snapshot.status = ConfigurationTransactionStatus::kPending; + g_command.transaction_id = transaction_id; + g_command.type = PendingCommandType::kReset; + g_command.identity = identity; + g_command.profile_index = profile_index; + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kPending; } -ConfigurationTransactionStatus profile_service_activate( - uint32_t transaction_id, const ControllerIdentity& identity, - uint8_t profile_index) { - if (!g_prepared) { - profile_service_prepare(); - } - critical_section_enter_blocking(&g_lock); - if (g_command.type != PendingCommandType::kNone || - g_internal_activation.type != PendingCommandType::kNone || - g_transaction.snapshot.status == - ConfigurationTransactionStatus::kReceiving || - g_transaction.snapshot.status == - ConfigurationTransactionStatus::kPending) { - critical_section_exit(&g_lock); - return ConfigurationTransactionStatus::kBusy; - } - g_transaction = {}; - g_transaction.snapshot.transaction_id = transaction_id; - g_transaction.identity = identity; - g_transaction.profile_index = profile_index; - if (transaction_id == 0 || - (transaction_id & kInternalTransactionIdMask) != 0 || - !valid_identity(identity) || - profile_index >= CONTROLLER_PROFILE_COUNT) { - g_transaction.snapshot.status = - ConfigurationTransactionStatus::kMalformed; - critical_section_exit(&g_lock); - return ConfigurationTransactionStatus::kMalformed; - } - g_transaction.snapshot.status = ConfigurationTransactionStatus::kPending; - g_command.transaction_id = transaction_id; - g_command.type = PendingCommandType::kActivate; - g_command.identity = identity; - g_command.profile_index = profile_index; +ConfigurationTransactionStatus +profile_service_activate(uint32_t transaction_id, + const ControllerIdentity &identity, + uint8_t profile_index) { + if (!g_prepared) { + profile_service_prepare(); + } + critical_section_enter_blocking(&g_lock); + if (g_command.type != PendingCommandType::kNone || + g_internal_activation.type != PendingCommandType::kNone || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kReceiving || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kPending) { critical_section_exit(&g_lock); - return ConfigurationTransactionStatus::kPending; + return ConfigurationTransactionStatus::kBusy; + } + g_transaction = {}; + g_transaction.snapshot.transaction_id = transaction_id; + g_transaction.identity = identity; + g_transaction.profile_index = profile_index; + if (transaction_id == 0 || + (transaction_id & kInternalTransactionIdMask) != 0 || + !valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) { + g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed; + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kMalformed; + } + g_transaction.snapshot.status = ConfigurationTransactionStatus::kPending; + g_command.transaction_id = transaction_id; + g_command.type = PendingCommandType::kActivate; + g_command.identity = identity; + g_command.profile_index = profile_index; + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kPending; } -ConfigurationTransactionStatus profile_service_activate_internal( - uint32_t transaction_id, const ControllerIdentity& identity, - uint8_t profile_index) { - if (!g_prepared) { - profile_service_prepare(); - } - critical_section_enter_blocking(&g_lock); - if (g_command.type != PendingCommandType::kNone || - g_internal_activation.type != PendingCommandType::kNone || - g_transaction.snapshot.status == - ConfigurationTransactionStatus::kReceiving || - g_transaction.snapshot.status == - ConfigurationTransactionStatus::kPending) { - critical_section_exit(&g_lock); - return ConfigurationTransactionStatus::kBusy; - } - if ((transaction_id & kInternalTransactionIdMask) == 0 || - !valid_identity(identity) || - profile_index >= CONTROLLER_PROFILE_COUNT) { - critical_section_exit(&g_lock); - return ConfigurationTransactionStatus::kMalformed; - } - g_internal_activation.type = PendingCommandType::kActivate; - g_internal_activation.transaction_id = transaction_id; - g_internal_activation.identity = identity; - g_internal_activation.profile_index = profile_index; +ConfigurationTransactionStatus +profile_service_activate_internal(uint32_t transaction_id, + const ControllerIdentity &identity, + uint8_t profile_index) { + if (!g_prepared) { + profile_service_prepare(); + } + critical_section_enter_blocking(&g_lock); + if (g_command.type != PendingCommandType::kNone || + g_internal_activation.type != PendingCommandType::kNone || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kReceiving || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kPending) { critical_section_exit(&g_lock); - return ConfigurationTransactionStatus::kPending; + return ConfigurationTransactionStatus::kBusy; + } + if ((transaction_id & kInternalTransactionIdMask) == 0 || + !valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kMalformed; + } + g_internal_activation.type = PendingCommandType::kActivate; + g_internal_activation.transaction_id = transaction_id; + g_internal_activation.identity = identity; + g_internal_activation.profile_index = profile_index; + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kPending; } -void profile_service_list_snapshot(ProfileServiceListSnapshot* output) { - if (output == nullptr) { - return; - } - critical_section_enter_blocking(&g_lock); - *output = g_list; - critical_section_exit(&g_lock); +void profile_service_list_snapshot(ProfileServiceListSnapshot *output) { + if (output == nullptr) { + return; + } + critical_section_enter_blocking(&g_lock); + *output = g_list; + critical_section_exit(&g_lock); } -void profile_service_selected_snapshot( - ProfileServiceSelectedSnapshot* output) { - if (output == nullptr) { - return; - } - critical_section_enter_blocking(&g_lock); - *output = g_selected; - critical_section_exit(&g_lock); +void profile_service_selected_snapshot(ProfileServiceSelectedSnapshot *output) { + if (output == nullptr) { + return; + } + critical_section_enter_blocking(&g_lock); + *output = g_selected; + critical_section_exit(&g_lock); } void profile_service_transaction_snapshot( - ProfileServiceTransactionSnapshot* output) { - if (output == nullptr) { - return; - } - critical_section_enter_blocking(&g_lock); - output->metadata = g_metadata; - output->identity = g_transaction.identity; - output->profile_index = g_transaction.profile_index; - output->transaction = g_transaction.snapshot; - critical_section_exit(&g_lock); + ProfileServiceTransactionSnapshot *output) { + if (output == nullptr) { + return; + } + critical_section_enter_blocking(&g_lock); + output->metadata = g_metadata; + output->identity = g_transaction.identity; + output->profile_index = g_transaction.profile_index; + output->transaction = g_transaction.snapshot; + critical_section_exit(&g_lock); } uint32_t profile_service_database_generation() { - return __atomic_load_n(&g_published_generation, __ATOMIC_ACQUIRE); + return __atomic_load_n(&g_published_generation, __ATOMIC_ACQUIRE); } void profile_service_active_profile_snapshot( - const ControllerIdentity& identity, - ProfileServiceActiveProfileSnapshot* output) { - if (output == nullptr) { - return; - } - *output = {}; - if (!valid_identity(identity)) { - return; - } + const ControllerIdentity &identity, + ProfileServiceActiveProfileSnapshot *output) { + if (output == nullptr) { + return; + } + *output = {}; + if (!valid_identity(identity)) { + return; + } - critical_section_enter_blocking(&g_lock); - output->metadata = g_metadata; - if (g_metadata.state == ProfileServiceState::kReady && - g_active_profile_count != 0) { - const PublishedActiveProfile* active = &g_active_profiles[0]; - for (uint8_t index = 1; index < g_active_profile_count; ++index) { - if (controller_identity_equal( - g_active_profiles[index].identity, identity)) { - active = &g_active_profiles[index]; - break; - } - } - output->profile_index = active->profile_index; - output->profile = active->profile; - output->valid = true; + critical_section_enter_blocking(&g_lock); + output->metadata = g_metadata; + if (g_metadata.state == ProfileServiceState::kReady && + g_active_profile_count != 0) { + const PublishedActiveProfile *active = &g_active_profiles[0]; + for (uint8_t index = 1; index < g_active_profile_count; ++index) { + if (controller_identity_equal(g_active_profiles[index].identity, + identity)) { + active = &g_active_profiles[index]; + break; + } } - critical_section_exit(&g_lock); + output->profile_index = active->profile_index; + output->profile = active->profile; + output->valid = true; + } + critical_section_exit(&g_lock); } diff --git a/src/firmware/profile/profile_storage.cpp b/src/firmware/profile/profile_storage.cpp index 068fd77..39a57af 100644 --- a/src/firmware/profile/profile_storage.cpp +++ b/src/firmware/profile/profile_storage.cpp @@ -1,251 +1,762 @@ #include "profile/profile_storage.h" + #include namespace { -constexpr uint8_t kRecordMagic[4] = {'S', 'P', 'P', 'F'}; -constexpr uint16_t kRecordFormatVersion = 1; -constexpr size_t kHeaderFieldsSize = 24; -constexpr size_t kHeaderCrcOffset = 20; +constexpr uint8_t kSuperblockMagic[4] = {'S', 'P', 'C', 'A'}; +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 kCatalogVersion = 1; +constexpr size_t kRecordPayloadOffset = PROFILE_STORAGE_PAGE_SIZE; +constexpr size_t kRecordHeaderCrcOffset = 34; +constexpr size_t kLegacyStart = + PROFILE_STORAGE_TOTAL_SIZE - PROFILE_STORAGE_LEGACY_TOTAL_SIZE; -uint16_t storage_read_u16(const uint8_t* input) { - return static_cast(input[0]) | - (static_cast(input[1]) << 8); +uint16_t read_u16(const uint8_t *input) { + return static_cast(input[0]) | static_cast(input[1] << 8); } -uint32_t storage_read_u32(const uint8_t* input) { - return static_cast(input[0]) | - (static_cast(input[1]) << 8) | - (static_cast(input[2]) << 16) | - (static_cast(input[3]) << 24); +uint32_t read_u32(const uint8_t *input) { + return static_cast(input[0]) | + (static_cast(input[1]) << 8) | + (static_cast(input[2]) << 16) | + (static_cast(input[3]) << 24); } -void storage_write_u16(uint8_t* output, uint16_t value) { - output[0] = static_cast(value); - output[1] = static_cast(value >> 8); +void write_u16(uint8_t *output, uint16_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); } -void storage_write_u32(uint8_t* output, uint32_t value) { - output[0] = static_cast(value); - output[1] = static_cast(value >> 8); - output[2] = static_cast(value >> 16); - output[3] = static_cast(value >> 24); +void write_u32(uint8_t *output, uint32_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); + output[2] = static_cast(value >> 16); + output[3] = static_cast(value >> 24); } -uint32_t crc32_update(uint32_t crc, const uint8_t* data, size_t size) { - for (size_t index = 0; index < size; ++index) { - crc ^= data[index]; - for (uint8_t bit = 0; bit < 8; ++bit) { - crc = (crc >> 1) ^ - (0xedb88320u & - static_cast( - -static_cast(crc & 1u))); - } +uint32_t crc32_update(uint32_t crc, const uint8_t *data, size_t size) { + for (size_t index = 0; index < size; ++index) { + crc ^= data[index]; + for (uint8_t bit = 0; bit < 8; ++bit) { + const uint32_t mask = 0u - (crc & 1u); + crc = (crc >> 1) ^ (0xedb88320u & mask); } - return crc; + } + return crc; } bool generation_is_newer(uint32_t candidate, uint32_t current) { - return static_cast(candidate - current) > 0; + return static_cast(candidate - current) > 0; } -struct DatabaseReadContext { - const ProfileStorageIo* io; - uint8_t bank; -}; - -bool read_database(void* context, size_t offset, uint8_t* output, - size_t size) { - const auto* read_context = - static_cast(context); - return read_context->io->read( - read_context->io->context, read_context->bank, - PROFILE_STORAGE_RECORD_HEADER_SIZE + offset, output, size); +bool valid_identity(const ControllerIdentity &identity) { + uint8_t encoded[CONTROLLER_IDENTITY_ENCODED_SIZE]{}; + return (controller_identity_is_global(identity) || identity.stable) && + controller_identity_encode(identity, encoded, sizeof(encoded)); } -} // namespace - -uint32_t profile_storage_crc32(const uint8_t* data, size_t size) { - if (data == nullptr && size != 0) { - return 0; - } - return ~crc32_update(0xffffffffu, data, size); +uint32_t pack_record(uint8_t arena, size_t offset) { + return static_cast(arena * PROFILE_STORAGE_ARENA_SIZE + offset); } -bool ProfileStorage::initialize(const ProfileStorageIo& io, - ControllerProfileDatabase* database) { - io_ = io; - snapshot_ = {}; - initialized_ = - database != nullptr && io_.read != nullptr && - io_.replace_bank != nullptr && - io_.bank_size >= PROFILE_STORAGE_BANK_SIZE && - io_.sector_size == PROFILE_STORAGE_SECTOR_SIZE && - io_.page_size == PROFILE_STORAGE_PAGE_SIZE && - io_.bank_size % io_.sector_size == 0 && - io_.sector_size % io_.page_size == 0; - if (!initialized_) { - return false; - } - - controller_profile_database_default(database); - BankHeader headers[PROFILE_STORAGE_BANK_COUNT]{}; - bool valid[PROFILE_STORAGE_BANK_COUNT]{}; - for (uint8_t bank = 0; bank < PROFILE_STORAGE_BANK_COUNT; ++bank) { - valid[bank] = read_header(bank, &headers[bank]) && - validate_payload(bank, headers[bank].payload_crc); - } - - uint8_t first = 0; - uint8_t second = 1; - if (valid[1] && - (!valid[0] || generation_is_newer(headers[1].generation, - headers[0].generation))) { - first = 1; - second = 0; - } - const uint8_t order[PROFILE_STORAGE_BANK_COUNT] = {first, second}; - for (uint8_t candidate : order) { - if (!valid[candidate] || !decode_bank(candidate, database)) { - continue; - } - snapshot_.valid = true; - snapshot_.generation = headers[candidate].generation; - snapshot_.payload_crc = headers[candidate].payload_crc; - snapshot_.active_bank = candidate; - return true; - } - controller_profile_database_default(database); - return true; +uint8_t record_arena(uint32_t record) { + return static_cast(record / PROFILE_STORAGE_ARENA_SIZE); } -ProfileStorageResult ProfileStorage::commit( - const ControllerProfileDatabase& database, - uint8_t* encoded_database, size_t encoded_database_size) { - if (!initialized_ || !controller_profile_database_validate(database) || - encoded_database == nullptr || - encoded_database_size < CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || - !controller_profile_database_encode_range( - database, 0, encoded_database, - CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE)) { - return ProfileStorageResult::kInvalidArgument; - } - if (snapshot_.valid && - payload_matches_encoded( - snapshot_.active_bank, encoded_database, - CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE)) { - return ProfileStorageResult::kUnchanged; - } +size_t record_offset(uint32_t record) { + return record % PROFILE_STORAGE_ARENA_SIZE; +} - const uint32_t crc = profile_storage_crc32( - encoded_database, CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE); - const uint8_t target_bank = - snapshot_.valid ? snapshot_.active_bank ^ 1u : 0; - const uint32_t generation = - snapshot_.valid ? snapshot_.generation + 1u : 1u; - uint8_t header[PROFILE_STORAGE_RECORD_HEADER_SIZE]{}; - memcpy(header, kRecordMagic, sizeof(kRecordMagic)); - storage_write_u16(&header[4], kRecordFormatVersion); - storage_write_u16( - &header[6], CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION); - storage_write_u32(&header[8], generation); - storage_write_u32( - &header[12], CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE); - storage_write_u32(&header[16], crc); - storage_write_u32( - &header[kHeaderCrcOffset], - profile_storage_crc32(header, kHeaderCrcOffset)); - - if (!io_.replace_bank( - io_.context, target_bank, encoded_database, - CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE, header, - sizeof(header))) { - return ProfileStorageResult::kIoError; +bool bytes_are(uint8_t value, const uint8_t *data, size_t size) { + for (size_t index = 0; index < size; ++index) { + if (data[index] != value) { + return false; } + } + return true; +} +} // namespace + +uint32_t profile_storage_crc32(const uint8_t *data, size_t size) { + if (data == nullptr && size != 0) { + return 0; + } + return ~crc32_update(0xffffffffu, data, size); +} + +bool ProfileStorage::initialize(const ProfileStorageIo &io) { + io_ = io; + snapshot_ = {}; + identity_count_ = 0; + epoch_ = 0; + next_offset_ = PROFILE_STORAGE_RECORDS_OFFSET; + initialized_ = io_.read != nullptr && io_.erase_arena != nullptr && + io_.program_page != nullptr && + io_.arena_size == PROFILE_STORAGE_ARENA_SIZE && + io_.sector_size == PROFILE_STORAGE_SECTOR_SIZE && + io_.page_size == PROFILE_STORAGE_PAGE_SIZE; + if (!initialized_) { + return false; + } + + ProfileStorageIdentityIndex + candidate[PROFILE_STORAGE_ARENA_COUNT] + [CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1]{}; + uint8_t counts[PROFILE_STORAGE_ARENA_COUNT]{}; + uint32_t epochs[PROFILE_STORAGE_ARENA_COUNT]{}; + uint32_t generations[PROFILE_STORAGE_ARENA_COUNT]{}; + uint32_t payload_crcs[PROFILE_STORAGE_ARENA_COUNT]{}; + size_t offsets[PROFILE_STORAGE_ARENA_COUNT]{}; + bool valid[PROFILE_STORAGE_ARENA_COUNT]{}; + for (uint8_t arena = 0; arena < PROFILE_STORAGE_ARENA_COUNT; ++arena) { + valid[arena] = scan_arena(arena, &epochs[arena], &generations[arena], + &payload_crcs[arena], &offsets[arena], + candidate[arena], &counts[arena]); + } + + uint8_t selected = 0; + if (valid[1] && (!valid[0] || generation_is_newer(epochs[1], epochs[0]))) { + selected = 1; + } + if (valid[selected]) { + for (size_t index = 0; + index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1; ++index) { + index_[index] = candidate[selected][index]; + } + identity_count_ = counts[selected]; + epoch_ = epochs[selected]; + next_offset_ = offsets[selected]; snapshot_.valid = true; - snapshot_.generation = generation; - snapshot_.payload_crc = crc; - snapshot_.active_bank = target_bank; - return ProfileStorageResult::kOk; -} - -const ProfileStorageSnapshot& ProfileStorage::snapshot() const { - return snapshot_; -} - -bool ProfileStorage::read_header(uint8_t bank, BankHeader* output) const { - uint8_t header[PROFILE_STORAGE_RECORD_HEADER_SIZE]{}; - if (bank >= PROFILE_STORAGE_BANK_COUNT || output == nullptr || - !io_.read(io_.context, bank, 0, header, sizeof(header)) || - memcmp(header, kRecordMagic, sizeof(kRecordMagic)) != 0 || - storage_read_u16(&header[4]) != kRecordFormatVersion || - (storage_read_u16(&header[6]) != - CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION && - storage_read_u16(&header[6]) != - CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION) || - storage_read_u32(&header[12]) != - CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || - profile_storage_crc32(header, kHeaderCrcOffset) != - storage_read_u32(&header[kHeaderCrcOffset])) { - return false; - } - for (size_t offset = kHeaderFieldsSize; - offset < sizeof(header); ++offset) { - if (header[offset] != 0) { - return false; - } - } - output->generation = storage_read_u32(&header[8]); - output->payload_crc = storage_read_u32(&header[16]); + snapshot_.active_bank = selected; + snapshot_.generation = generations[selected]; + snapshot_.payload_crc = payload_crcs[selected]; return true; + } + + if (migrate_legacy()) { + return true; + } + for (ProfileStorageIdentityIndex &entry : index_) { + entry = {}; + } + identity_count_ = 1; + index_[0].used = true; + index_[0].identity = controller_identity_global(); + for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) { + index_[0].profile_record[profile] = PROFILE_STORAGE_NO_RECORD; + } + return publish_empty_arena(0, 1); } -bool ProfileStorage::validate_payload(uint8_t bank, - uint32_t expected_crc) const { +ProfileStorageResult +ProfileStorage::ensure_identity(const ControllerIdentity &identity) { + if (!initialized_ || !valid_identity(identity)) { + return ProfileStorageResult::kInvalidArgument; + } + if (find(identity) != nullptr) { + return ProfileStorageResult::kUnchanged; + } + if (identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) { + return ProfileStorageResult::kFull; + } + return append(RecordType::kActivate, identity, 0, nullptr, 0); +} + +ProfileStorageResult ProfileStorage::get(const ControllerIdentity &identity, + uint8_t profile_index, + ControllerProfile *output) const { + if (!initialized_ || output == nullptr || !valid_identity(identity) || + profile_index >= CONTROLLER_PROFILE_COUNT) { + return ProfileStorageResult::kInvalidArgument; + } + const ProfileStorageIdentityIndex *entry = find(identity); + if (entry == nullptr || + entry->profile_record[profile_index] == PROFILE_STORAGE_NO_RECORD) { + *output = controller_profile_default(identity, profile_index); + return ProfileStorageResult::kOk; + } + return read_profile_record(entry->profile_record[profile_index], output) + ? ProfileStorageResult::kOk + : ProfileStorageResult::kIoError; +} + +ProfileStorageResult ProfileStorage::set(const ControllerIdentity &identity, + uint8_t profile_index, + const ControllerProfile &profile) { + if (!initialized_ || !valid_identity(identity) || + profile_index >= CONTROLLER_PROFILE_COUNT || + !controller_profile_validate(profile)) { + return ProfileStorageResult::kInvalidArgument; + } + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + if (!controller_profile_encode(profile, encoded, sizeof(encoded))) { + return ProfileStorageResult::kInvalidArgument; + } + ControllerProfile current{}; + uint8_t current_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + if (get(identity, profile_index, ¤t) == ProfileStorageResult::kOk && + controller_profile_encode(current, current_encoded, + sizeof(current_encoded)) && + memcmp(encoded, current_encoded, sizeof(encoded)) == 0) { + return ProfileStorageResult::kUnchanged; + } + if (find(identity) == nullptr && + identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) { + return ProfileStorageResult::kFull; + } + return append(RecordType::kProfile, identity, profile_index, encoded, + sizeof(encoded)); +} + +ProfileStorageResult ProfileStorage::reset(const ControllerIdentity &identity, + uint8_t profile_index) { + if (!initialized_ || !valid_identity(identity) || + (profile_index != CONTROLLER_PROFILE_ALL && + profile_index >= CONTROLLER_PROFILE_COUNT)) { + return ProfileStorageResult::kInvalidArgument; + } + const ProfileStorageIdentityIndex *entry = find(identity); + if (entry == nullptr) { + return ProfileStorageResult::kUnchanged; + } + if (profile_index == CONTROLLER_PROFILE_ALL) { + bool changed = entry->active_profile != 0; + for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) { + changed = changed || + entry->profile_record[profile] != PROFILE_STORAGE_NO_RECORD; + } + return changed ? append(RecordType::kResetAll, identity, + CONTROLLER_PROFILE_ALL, nullptr, 0) + : ProfileStorageResult::kUnchanged; + } + if (entry->profile_record[profile_index] == PROFILE_STORAGE_NO_RECORD) { + return ProfileStorageResult::kUnchanged; + } + return append(RecordType::kReset, identity, profile_index, nullptr, 0); +} + +ProfileStorageResult +ProfileStorage::activate(const ControllerIdentity &identity, + uint8_t profile_index) { + if (!initialized_ || !valid_identity(identity) || + profile_index >= CONTROLLER_PROFILE_COUNT) { + return ProfileStorageResult::kInvalidArgument; + } + const ProfileStorageIdentityIndex *entry = find(identity); + if (entry != nullptr && entry->active_profile == profile_index) { + return ProfileStorageResult::kUnchanged; + } + if (entry == nullptr && + identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) { + return ProfileStorageResult::kFull; + } + return append(RecordType::kActivate, identity, profile_index, nullptr, 0); +} + +uint8_t ProfileStorage::identity_count() const { return identity_count_; } + +const ProfileStorageIdentityIndex * +ProfileStorage::identity(uint8_t index) const { + return index < identity_count_ ? &index_[index] : nullptr; +} + +const ProfileStorageIdentityIndex * +ProfileStorage::find(const ControllerIdentity &identity) const { + for (uint8_t index = 0; index < identity_count_; ++index) { + if (index_[index].used && + controller_identity_equal(index_[index].identity, identity)) { + return &index_[index]; + } + } + return nullptr; +} + +const ProfileStorageSnapshot &ProfileStorage::snapshot() const { + return snapshot_; +} + +bool ProfileStorage::scan_arena(uint8_t arena, uint32_t *epoch, + uint32_t *generation, uint32_t *payload_crc, + size_t *next_offset, + ProfileStorageIdentityIndex *index, + uint8_t *identity_count) const { + uint8_t superblock[PROFILE_STORAGE_PAGE_SIZE]{}; + if (!io_.read(io_.context, arena, 0, superblock, sizeof(superblock)) || + memcmp(superblock, kSuperblockMagic, sizeof(kSuperblockMagic)) != 0 || + read_u16(&superblock[4]) != kCatalogVersion || + profile_storage_crc32(superblock, 12) != read_u32(&superblock[12]) || + !bytes_are(0, &superblock[16], sizeof(superblock) - 16)) { + return false; + } + *epoch = read_u32(&superblock[8]); + *generation = 0; + *payload_crc = 0; + *next_offset = PROFILE_STORAGE_RECORDS_OFFSET; + *identity_count = 1; + index[0].used = true; + index[0].identity = controller_identity_global(); + for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) { + index[0].profile_record[profile] = PROFILE_STORAGE_NO_RECORD; + } + + for (size_t offset = PROFILE_STORAGE_RECORDS_OFFSET; + offset + PROFILE_STORAGE_RECORD_SIZE <= PROFILE_STORAGE_ARENA_SIZE; + offset += PROFILE_STORAGE_RECORD_SIZE) { + uint8_t header[PROFILE_STORAGE_PAGE_SIZE]{}; + uint8_t payload_prefix[4]{}; + if (!io_.read(io_.context, arena, offset, header, sizeof(header)) || + !io_.read(io_.context, arena, offset + kRecordPayloadOffset, + payload_prefix, sizeof(payload_prefix))) { + return false; + } + if (!bytes_are(0xff, header, 4) || + !bytes_are(0xff, payload_prefix, sizeof(payload_prefix))) { + *next_offset = offset + PROFILE_STORAGE_RECORD_SIZE; + } + if (memcmp(header, kRecordMagic, sizeof(kRecordMagic)) != 0 || + read_u16(&header[4]) != kCatalogVersion || + profile_storage_crc32(header, kRecordHeaderCrcOffset) != + read_u32(&header[kRecordHeaderCrcOffset]) || + !bytes_are(0, &header[kRecordHeaderCrcOffset + 4], + sizeof(header) - kRecordHeaderCrcOffset - 4)) { + continue; + } + const auto type = static_cast(header[6]); + const uint8_t profile_index = header[7]; + const uint32_t record_generation = read_u32(&header[8]); + const size_t payload_size = read_u16(&header[12]); + ControllerIdentity identity_value{}; + if (!controller_identity_decode( + &header[20], CONTROLLER_IDENTITY_ENCODED_SIZE, &identity_value) || + !valid_identity(identity_value) || + (type != RecordType::kProfile && type != RecordType::kReset && + type != RecordType::kResetAll && type != RecordType::kActivate) || + ((type == RecordType::kProfile || type == RecordType::kReset || + type == RecordType::kActivate) && + profile_index >= CONTROLLER_PROFILE_COUNT) || + (type == RecordType::kProfile && + payload_size != CONTROLLER_PROFILE_ENCODED_SIZE) || + (type != RecordType::kProfile && payload_size != 0)) { + continue; + } + if (type == RecordType::kProfile) { + uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + ControllerProfile decoded{}; + if (!io_.read(io_.context, arena, offset + kRecordPayloadOffset, payload, + sizeof(payload)) || + read_u16(&header[14]) != read_u16(payload) || + profile_storage_crc32(payload, sizeof(payload)) != + read_u32(&header[16]) || + !controller_profile_decode(payload, sizeof(payload), &decoded)) { + continue; + } + } else if (read_u16(&header[14]) != 0) { + continue; + } + apply_record(index, identity_count, type, identity_value, profile_index, + record_generation, pack_record(arena, offset)); + if (generation_is_newer(record_generation, *generation)) { + *generation = record_generation; + *payload_crc = read_u32(&header[16]); + } + } + return true; +} + +bool ProfileStorage::read_profile_record(uint32_t record, + ControllerProfile *output) const { + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + return record != PROFILE_STORAGE_NO_RECORD && + io_.read(io_.context, record_arena(record), + record_offset(record) + kRecordPayloadOffset, encoded, + sizeof(encoded)) && + controller_profile_decode(encoded, sizeof(encoded), output); +} + +ProfileStorageResult ProfileStorage::append(RecordType type, + const ControllerIdentity &identity, + uint8_t profile_index, + const uint8_t *payload, + size_t payload_size) { + if (next_offset_ + PROFILE_STORAGE_RECORD_SIZE > PROFILE_STORAGE_ARENA_SIZE) { + const ProfileStorageResult result = compact(); + if (result != ProfileStorageResult::kOk) { + return result; + } + } + const uint32_t generation = snapshot_.generation + 1u; + const size_t offset = next_offset_; + next_offset_ += PROFILE_STORAGE_RECORD_SIZE; + if (!write_record(snapshot_.active_bank, offset, type, identity, + profile_index, generation, payload, payload_size)) { + 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; +} + +ProfileStorageResult ProfileStorage::compact() { + const uint8_t target = snapshot_.active_bank ^ 1u; + if (!io_.erase_arena(io_.context, target)) { + return ProfileStorageResult::kIoError; + } + size_t offset = PROFILE_STORAGE_RECORDS_OFFSET; + uint32_t generation = snapshot_.generation; + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + for (uint8_t identity_index = 0; identity_index < identity_count_; + ++identity_index) { + const ProfileStorageIdentityIndex &entry = index_[identity_index]; + for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) { + if (entry.profile_record[profile] == PROFILE_STORAGE_NO_RECORD) { + continue; + } + ControllerProfile decoded{}; + if (!read_profile_record(entry.profile_record[profile], &decoded) || + !controller_profile_encode(decoded, encoded, sizeof(encoded)) || + !write_record(target, offset, RecordType::kProfile, entry.identity, + profile, ++generation, encoded, sizeof(encoded))) { + return ProfileStorageResult::kIoError; + } + offset += PROFILE_STORAGE_RECORD_SIZE; + } + if (!write_record(target, offset, RecordType::kActivate, entry.identity, + entry.active_profile, ++generation, nullptr, 0)) { + return ProfileStorageResult::kIoError; + } + offset += PROFILE_STORAGE_RECORD_SIZE; + } + + uint8_t superblock[PROFILE_STORAGE_PAGE_SIZE]{}; + memcpy(superblock, kSuperblockMagic, sizeof(kSuperblockMagic)); + write_u16(&superblock[4], kCatalogVersion); + write_u32(&superblock[8], epoch_ + 1u); + write_u32(&superblock[12], profile_storage_crc32(superblock, 12)); + if (!io_.program_page(io_.context, target, 0, superblock, + sizeof(superblock))) { + return ProfileStorageResult::kIoError; + } + + ProfileStorageIdentityIndex + rebuilt[CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1]{}; + uint8_t rebuilt_count = 0; + uint32_t rebuilt_epoch = 0; + uint32_t rebuilt_generation = 0; + uint32_t rebuilt_payload_crc = 0; + size_t rebuilt_offset = 0; + if (!scan_arena(target, &rebuilt_epoch, &rebuilt_generation, + &rebuilt_payload_crc, &rebuilt_offset, rebuilt, + &rebuilt_count)) { + return ProfileStorageResult::kIoError; + } + for (size_t index = 0; + index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1; ++index) { + index_[index] = rebuilt[index]; + } + identity_count_ = rebuilt_count; + epoch_ = rebuilt_epoch; + next_offset_ = rebuilt_offset; + snapshot_.active_bank = target; + snapshot_.generation = rebuilt_generation; + snapshot_.payload_crc = rebuilt_payload_crc; + snapshot_.valid = true; + return ProfileStorageResult::kOk; +} + +bool ProfileStorage::migrate_legacy() { + struct LegacyHeader { + bool valid = false; + uint32_t generation = 0; + uint32_t crc = 0; + uint8_t bank = 0; + } headers[PROFILE_STORAGE_LEGACY_BANK_COUNT]{}; + const uint8_t arena = + static_cast(kLegacyStart / PROFILE_STORAGE_ARENA_SIZE); + const size_t arena_base = kLegacyStart % PROFILE_STORAGE_ARENA_SIZE; + for (uint8_t bank = 0; bank < PROFILE_STORAGE_LEGACY_BANK_COUNT; ++bank) { + uint8_t header[PROFILE_STORAGE_LEGACY_HEADER_SIZE]{}; + const size_t base = arena_base + bank * PROFILE_STORAGE_LEGACY_BANK_SIZE; + if (!io_.read(io_.context, arena, base, header, sizeof(header)) || + memcmp(header, kLegacyStorageMagic, 4) != 0 || + read_u16(&header[4]) != 1 || + (read_u16(&header[6]) != 1 && read_u16(&header[6]) != 2) || + read_u32(&header[12]) != PROFILE_STORAGE_LEGACY_DATABASE_SIZE || + profile_storage_crc32(header, 20) != read_u32(&header[20]) || + !bytes_are(0, &header[24], sizeof(header) - 24)) { + continue; + } uint8_t page[PROFILE_STORAGE_PAGE_SIZE]{}; uint32_t crc = 0xffffffffu; - for (size_t offset = 0; - offset < CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE; - offset += sizeof(page)) { - const size_t size = - CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE - offset < sizeof(page) - ? CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE - offset - : sizeof(page); - if (!io_.read(io_.context, bank, - PROFILE_STORAGE_RECORD_HEADER_SIZE + offset, - page, size)) { - return false; - } - crc = crc32_update(crc, page, size); + size_t remaining = PROFILE_STORAGE_LEGACY_DATABASE_SIZE; + size_t payload_offset = base + PROFILE_STORAGE_LEGACY_HEADER_SIZE; + while (remaining != 0) { + const size_t size = remaining < sizeof(page) ? remaining : sizeof(page); + if (!io_.read(io_.context, arena, payload_offset, page, size)) { + remaining = SIZE_MAX; + break; + } + crc = crc32_update(crc, page, size); + payload_offset += size; + remaining -= size; } - return ~crc == expected_crc; + if (remaining == 0 && ~crc == read_u32(&header[16])) { + headers[bank] = {true, read_u32(&header[8]), read_u32(&header[16]), bank}; + } + } + int selected = -1; + for (uint8_t bank = 0; bank < PROFILE_STORAGE_LEGACY_BANK_COUNT; ++bank) { + if (headers[bank].valid && + (selected < 0 || generation_is_newer(headers[bank].generation, + headers[selected].generation))) { + selected = bank; + } + } + if (selected < 0) { + return false; + } + + const size_t legacy_base = + arena_base + + static_cast(selected) * PROFILE_STORAGE_LEGACY_BANK_SIZE + + PROFILE_STORAGE_LEGACY_HEADER_SIZE; + uint8_t database_header[32]{}; + if (!io_.read(io_.context, arena, legacy_base, database_header, + sizeof(database_header)) || + memcmp(database_header, kLegacyDatabaseMagic, 4) != 0 || + (read_u16(&database_header[4]) != 1 && + read_u16(&database_header[4]) != 2) || + read_u16(&database_header[6]) != PROFILE_STORAGE_LEGACY_DATABASE_SIZE || + database_header[8] != CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY || + database_header[9] != PROFILE_STORAGE_LEGACY_PROFILE_COUNT || + database_header[10] >= PROFILE_STORAGE_LEGACY_PROFILE_COUNT) { + return false; + } + if (!io_.erase_arena(io_.context, 0)) { + return false; + } + size_t target_offset = PROFILE_STORAGE_RECORDS_OFFSET; + uint32_t generation = 0; + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + ControllerProfile decoded{}; + const ControllerIdentity global = controller_identity_global(); + for (uint8_t profile = 0; profile < PROFILE_STORAGE_LEGACY_PROFILE_COUNT; + ++profile) { + const size_t source = + legacy_base + 32 + profile * CONTROLLER_PROFILE_ENCODED_SIZE; + if (!io_.read(io_.context, arena, source, encoded, sizeof(encoded)) || + !controller_profile_decode(encoded, sizeof(encoded), &decoded) || + !write_record(0, target_offset, RecordType::kProfile, global, profile, + ++generation, encoded, sizeof(encoded))) { + return false; + } + target_offset += PROFILE_STORAGE_RECORD_SIZE; + } + if (!write_record(0, target_offset, RecordType::kActivate, global, + database_header[10], ++generation, nullptr, 0)) { + return false; + } + target_offset += PROFILE_STORAGE_RECORD_SIZE; + + constexpr size_t kLegacyEntrySize = + 16 + + PROFILE_STORAGE_LEGACY_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE; + const size_t entries_base = + legacy_base + 32 + + PROFILE_STORAGE_LEGACY_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE; + for (uint8_t entry_index = 0; + entry_index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; + ++entry_index) { + const size_t entry_base = entries_base + entry_index * kLegacyEntrySize; + uint8_t entry_header[16]{}; + if (!io_.read(io_.context, arena, entry_base, entry_header, + sizeof(entry_header))) { + return false; + } + if (entry_header[15] == 0) { + continue; + } + ControllerIdentity identity_value{}; + if (entry_header[15] != 1 || + entry_header[14] >= PROFILE_STORAGE_LEGACY_PROFILE_COUNT || + !controller_identity_decode( + entry_header, CONTROLLER_IDENTITY_ENCODED_SIZE, &identity_value) || + !identity_value.stable || + controller_identity_is_global(identity_value)) { + return false; + } + for (uint8_t profile = 0; profile < PROFILE_STORAGE_LEGACY_PROFILE_COUNT; + ++profile) { + const size_t source = + entry_base + 16 + profile * CONTROLLER_PROFILE_ENCODED_SIZE; + if (!io_.read(io_.context, arena, source, encoded, sizeof(encoded)) || + !controller_profile_decode(encoded, sizeof(encoded), &decoded) || + !write_record(0, target_offset, RecordType::kProfile, identity_value, + profile, ++generation, encoded, sizeof(encoded))) { + return false; + } + target_offset += PROFILE_STORAGE_RECORD_SIZE; + } + if (!write_record(0, target_offset, RecordType::kActivate, identity_value, + entry_header[14], ++generation, nullptr, 0)) { + return false; + } + target_offset += PROFILE_STORAGE_RECORD_SIZE; + } + + uint8_t superblock[PROFILE_STORAGE_PAGE_SIZE]{}; + memcpy(superblock, kSuperblockMagic, sizeof(kSuperblockMagic)); + write_u16(&superblock[4], kCatalogVersion); + write_u32(&superblock[8], 1); + write_u32(&superblock[12], profile_storage_crc32(superblock, 12)); + if (!io_.program_page(io_.context, 0, 0, superblock, sizeof(superblock))) { + return false; + } + + ProfileStorageIdentityIndex + rebuilt[CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1]{}; + uint8_t rebuilt_count = 0; + uint32_t rebuilt_epoch = 0; + uint32_t rebuilt_generation = 0; + uint32_t rebuilt_payload_crc = 0; + size_t rebuilt_offset = 0; + if (!scan_arena(0, &rebuilt_epoch, &rebuilt_generation, &rebuilt_payload_crc, + &rebuilt_offset, rebuilt, &rebuilt_count)) { + return false; + } + for (size_t index = 0; + index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1; ++index) { + index_[index] = rebuilt[index]; + } + identity_count_ = rebuilt_count; + epoch_ = rebuilt_epoch; + next_offset_ = rebuilt_offset; + snapshot_.valid = true; + snapshot_.active_bank = 0; + snapshot_.generation = rebuilt_generation; + snapshot_.payload_crc = rebuilt_payload_crc; + return true; } -bool ProfileStorage::decode_bank( - uint8_t bank, ControllerProfileDatabase* database) const { - DatabaseReadContext context{&io_, bank}; - return controller_profile_database_decode(read_database, &context, - database); +bool ProfileStorage::publish_empty_arena(uint8_t arena, uint32_t epoch) { + if (!io_.erase_arena(io_.context, arena)) { + return false; + } + uint8_t superblock[PROFILE_STORAGE_PAGE_SIZE]{}; + memcpy(superblock, kSuperblockMagic, sizeof(kSuperblockMagic)); + write_u16(&superblock[4], kCatalogVersion); + write_u32(&superblock[8], epoch); + write_u32(&superblock[12], profile_storage_crc32(superblock, 12)); + if (!io_.program_page(io_.context, arena, 0, superblock, + sizeof(superblock))) { + return false; + } + epoch_ = epoch; + next_offset_ = PROFILE_STORAGE_RECORDS_OFFSET; + snapshot_.valid = true; + snapshot_.active_bank = arena; + snapshot_.generation = 0; + snapshot_.payload_crc = 0; + return true; } -bool ProfileStorage::payload_matches_encoded( - uint8_t bank, const uint8_t* payload, - size_t payload_size) const { - uint8_t stored[PROFILE_STORAGE_PAGE_SIZE]{}; - for (size_t offset = 0; offset < payload_size; - offset += sizeof(stored)) { - const size_t size = - payload_size - offset < sizeof(stored) - ? payload_size - offset - : sizeof(stored); - if (!io_.read( - io_.context, bank, - PROFILE_STORAGE_RECORD_HEADER_SIZE + offset, - stored, size) || - memcmp(stored, &payload[offset], size) != 0) { - return false; - } +bool ProfileStorage::write_record(uint8_t arena, size_t offset, RecordType type, + const ControllerIdentity &identity, + uint8_t profile_index, uint32_t generation, + const uint8_t *payload, + size_t payload_size) const { + if (arena >= PROFILE_STORAGE_ARENA_COUNT || + offset < PROFILE_STORAGE_RECORDS_OFFSET || + offset + PROFILE_STORAGE_RECORD_SIZE > PROFILE_STORAGE_ARENA_SIZE || + offset % PROFILE_STORAGE_PAGE_SIZE != 0 || + (type == RecordType::kProfile + ? payload == nullptr || + payload_size != CONTROLLER_PROFILE_ENCODED_SIZE + : payload_size != 0)) { + return false; + } + if (payload_size != 0) { + if (!io_.program_page(io_.context, arena, offset + kRecordPayloadOffset, + payload, PROFILE_STORAGE_PAGE_SIZE)) { + return false; } - return true; + } + uint8_t header[PROFILE_STORAGE_PAGE_SIZE]{}; + memcpy(header, kRecordMagic, sizeof(kRecordMagic)); + write_u16(&header[4], kCatalogVersion); + header[6] = static_cast(type); + header[7] = profile_index; + write_u32(&header[8], generation); + write_u16(&header[12], static_cast(payload_size)); + write_u16(&header[14], type == RecordType::kProfile ? read_u16(payload) : 0); + write_u32(&header[16], profile_storage_crc32(payload, payload_size)); + if (!controller_identity_encode(identity, &header[20], + CONTROLLER_IDENTITY_ENCODED_SIZE)) { + return false; + } + write_u32(&header[kRecordHeaderCrcOffset], + profile_storage_crc32(header, kRecordHeaderCrcOffset)); + return io_.program_page(io_.context, arena, offset, header, sizeof(header)); +} + +void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index, + uint8_t *identity_count, RecordType type, + const ControllerIdentity &identity, + uint8_t profile_index, uint32_t generation, + uint32_t record) const { + ProfileStorageIdentityIndex *entry = nullptr; + for (uint8_t current = 0; current < *identity_count; ++current) { + if (index[current].used && + controller_identity_equal(index[current].identity, identity)) { + entry = &index[current]; + break; + } + } + if (entry == nullptr) { + if (*identity_count >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) { + return; + } + entry = &index[(*identity_count)++]; + *entry = {}; + entry->used = true; + entry->identity = identity; + for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) { + entry->profile_record[profile] = PROFILE_STORAGE_NO_RECORD; + } + } + if (type == RecordType::kActivate) { + if (entry->active_generation == 0 || + generation_is_newer(generation, entry->active_generation)) { + entry->active_profile = profile_index; + entry->active_generation = generation; + } + return; + } + if (type == RecordType::kResetAll) { + for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) { + if (entry->profile_generation[profile] == 0 || + generation_is_newer(generation, entry->profile_generation[profile])) { + entry->profile_record[profile] = PROFILE_STORAGE_NO_RECORD; + entry->profile_generation[profile] = generation; + } + } + if (entry->active_generation == 0 || + generation_is_newer(generation, entry->active_generation)) { + entry->active_profile = 0; + entry->active_generation = generation; + } + return; + } + if (entry->profile_generation[profile_index] == 0 || + generation_is_newer(generation, + entry->profile_generation[profile_index])) { + entry->profile_record[profile_index] = + type == RecordType::kProfile ? record : PROFILE_STORAGE_NO_RECORD; + entry->profile_generation[profile_index] = generation; + } } diff --git a/src/firmware/profile/profile_storage.h b/src/firmware/profile/profile_storage.h index b793e6b..1f204f3 100644 --- a/src/firmware/profile/profile_storage.h +++ b/src/firmware/profile/profile_storage.h @@ -5,79 +5,125 @@ #include "profile/controller_profile.h" -constexpr uint8_t PROFILE_STORAGE_BANK_COUNT = 2; +constexpr uint8_t PROFILE_STORAGE_ARENA_COUNT = 2; constexpr size_t PROFILE_STORAGE_SECTOR_SIZE = 4096; -constexpr size_t PROFILE_STORAGE_SECTORS_PER_BANK = 5; -constexpr size_t PROFILE_STORAGE_BANK_SIZE = - PROFILE_STORAGE_SECTOR_SIZE * PROFILE_STORAGE_SECTORS_PER_BANK; -constexpr size_t PROFILE_STORAGE_TOTAL_SIZE = - PROFILE_STORAGE_BANK_COUNT * PROFILE_STORAGE_BANK_SIZE; constexpr size_t PROFILE_STORAGE_PAGE_SIZE = 256; -constexpr size_t PROFILE_STORAGE_RECORD_HEADER_SIZE = - PROFILE_STORAGE_PAGE_SIZE; +constexpr size_t PROFILE_STORAGE_ARENA_SIZE = 128 * 1024; +constexpr size_t PROFILE_STORAGE_TOTAL_SIZE = + PROFILE_STORAGE_ARENA_COUNT * PROFILE_STORAGE_ARENA_SIZE; +constexpr size_t PROFILE_STORAGE_SUPERBLOCK_SIZE = PROFILE_STORAGE_PAGE_SIZE; +constexpr size_t PROFILE_STORAGE_RECORD_SIZE = 2 * PROFILE_STORAGE_PAGE_SIZE; +constexpr size_t PROFILE_STORAGE_RECORDS_OFFSET = PROFILE_STORAGE_SECTOR_SIZE; +constexpr uint32_t PROFILE_STORAGE_NO_RECORD = UINT32_MAX; -static_assert(PROFILE_STORAGE_BANK_SIZE == 20 * 1024); -static_assert(PROFILE_STORAGE_TOTAL_SIZE == 40 * 1024); -static_assert(PROFILE_STORAGE_RECORD_HEADER_SIZE + - CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE <= - PROFILE_STORAGE_BANK_SIZE, - "profile database does not fit a storage bank"); +// Layout of the retired v1/v2 whole-database store. The indexed catalog reads +// this region once during migration; new firmware never writes it. +constexpr uint8_t PROFILE_STORAGE_LEGACY_BANK_COUNT = 2; +constexpr size_t PROFILE_STORAGE_LEGACY_BANK_SIZE = 20 * 1024; +constexpr size_t PROFILE_STORAGE_LEGACY_TOTAL_SIZE = + PROFILE_STORAGE_LEGACY_BANK_COUNT * PROFILE_STORAGE_LEGACY_BANK_SIZE; +constexpr size_t PROFILE_STORAGE_LEGACY_HEADER_SIZE = PROFILE_STORAGE_PAGE_SIZE; +constexpr uint8_t PROFILE_STORAGE_LEGACY_PROFILE_COUNT = 4; +constexpr size_t PROFILE_STORAGE_LEGACY_DATABASE_SIZE = 17696; -enum class ProfileStorageResult : uint8_t { - kOk = 0, - kUnchanged = 1, - kInvalidArgument = 2, - kIoError = 3, -}; +static_assert(PROFILE_STORAGE_ARENA_SIZE % PROFILE_STORAGE_SECTOR_SIZE == 0); +static_assert(PROFILE_STORAGE_RECORDS_OFFSET % PROFILE_STORAGE_RECORD_SIZE == + 0); struct ProfileStorageIo { - void* context = nullptr; - size_t bank_size = 0; - size_t sector_size = 0; - size_t page_size = 0; - bool (*read)(void* context, uint8_t bank, size_t offset, - uint8_t* output, size_t size) = nullptr; - // Replaces one bank and verifies the payload before publishing the - // complete header page. - bool (*replace_bank)(void* context, uint8_t bank, - const uint8_t* payload, size_t payload_size, - const uint8_t* header, size_t header_size) = nullptr; + void *context = nullptr; + size_t arena_size = 0; + size_t sector_size = 0; + size_t page_size = 0; + bool (*read)(void *context, uint8_t arena, size_t offset, uint8_t *output, + size_t size) = nullptr; + bool (*erase_arena)(void *context, uint8_t arena) = nullptr; + bool (*program_page)(void *context, uint8_t arena, size_t offset, + const uint8_t *page, size_t size) = nullptr; }; struct ProfileStorageSnapshot { - bool valid = false; - uint32_t generation = 0; - uint32_t payload_crc = 0; - uint8_t active_bank = 0; + bool valid = false; + uint32_t generation = 0; + uint32_t payload_crc = 0; + uint8_t active_bank = 0; }; -uint32_t profile_storage_crc32(const uint8_t* data, size_t size); +enum class ProfileStorageResult : uint8_t { + kOk = 0, + kUnchanged = 1, + kInvalidArgument = 2, + kIoError = 3, + kFull = 4, +}; + +struct ProfileStorageIdentityIndex { + bool used = false; + ControllerIdentity identity{}; + uint8_t active_profile = 0; + uint32_t active_generation = 0; + uint32_t profile_generation[CONTROLLER_PROFILE_COUNT]{}; + uint32_t profile_record[CONTROLLER_PROFILE_COUNT]{}; +}; + +uint32_t profile_storage_crc32(const uint8_t *data, size_t size); class ProfileStorage { public: - bool initialize(const ProfileStorageIo& io, - ControllerProfileDatabase* database); - ProfileStorageResult commit( - const ControllerProfileDatabase& database, - uint8_t* encoded_database, - size_t encoded_database_size); - const ProfileStorageSnapshot& snapshot() const; + bool initialize(const ProfileStorageIo &io); + ProfileStorageResult ensure_identity(const ControllerIdentity &identity); + ProfileStorageResult get(const ControllerIdentity &identity, + uint8_t profile_index, + ControllerProfile *output) const; + ProfileStorageResult set(const ControllerIdentity &identity, + uint8_t profile_index, + const ControllerProfile &profile); + ProfileStorageResult reset(const ControllerIdentity &identity, + uint8_t profile_index); + ProfileStorageResult activate(const ControllerIdentity &identity, + uint8_t profile_index); + + uint8_t identity_count() const; + const ProfileStorageIdentityIndex *identity(uint8_t index) const; + const ProfileStorageIdentityIndex * + find(const ControllerIdentity &identity) const; + const ProfileStorageSnapshot &snapshot() const; private: - struct BankHeader { - uint32_t generation = 0; - uint32_t payload_crc = 0; - }; + enum class RecordType : uint8_t { + kProfile = 1, + kReset = 2, + kResetAll = 3, + kActivate = 4, + }; - bool read_header(uint8_t bank, BankHeader* output) const; - bool validate_payload(uint8_t bank, uint32_t expected_crc) const; - bool decode_bank(uint8_t bank, - ControllerProfileDatabase* database) const; - bool payload_matches_encoded(uint8_t bank, - const uint8_t* payload, - size_t payload_size) const; + bool scan_arena(uint8_t arena, uint32_t *epoch, uint32_t *generation, + uint32_t *payload_crc, size_t *next_offset, + ProfileStorageIdentityIndex *index, + uint8_t *identity_count) const; + bool read_profile_record(uint32_t record, ControllerProfile *output) const; + ProfileStorageResult append(RecordType type, + const ControllerIdentity &identity, + uint8_t profile_index, const uint8_t *payload, + size_t payload_size); + ProfileStorageResult compact(); + bool migrate_legacy(); + bool publish_empty_arena(uint8_t arena, uint32_t epoch); + bool write_record(uint8_t arena, size_t offset, RecordType type, + 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, + RecordType type, const ControllerIdentity &identity, + uint8_t profile_index, uint32_t generation, + uint32_t record) const; - ProfileStorageIo io_{}; - ProfileStorageSnapshot snapshot_{}; - bool initialized_ = false; + ProfileStorageIo io_{}; + ProfileStorageSnapshot snapshot_{}; + ProfileStorageIdentityIndex + index_[CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1]{}; + uint8_t identity_count_ = 0; + uint32_t epoch_ = 0; + size_t next_offset_ = PROFILE_STORAGE_RECORDS_OFFSET; + bool initialized_ = false; }; diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 5b167fa..2231823 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -105,7 +105,7 @@ PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3 PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4 PROFILE_SCHEMA_VERSION = 5 PROFILE_SIZE = 256 -PROFILE_CAPACITY = 4 +PROFILE_CAPACITY = 8 PROFILE_IDENTITY_CAPACITY = 16 PROFILE_LIST_CAPACITY = PROFILE_IDENTITY_CAPACITY + 1 CONTROLLER_IDENTITY_SIZE = 14 @@ -2572,10 +2572,12 @@ def _profile_number(value: str) -> int: number = int(value) except ValueError as exc: raise argparse.ArgumentTypeError( - "profile must be a number from 1 to 4" + f"profile must be a number from 1 to {PROFILE_CAPACITY}" ) from exc if not 1 <= number <= PROFILE_CAPACITY: - raise argparse.ArgumentTypeError("profile must be a number from 1 to 4") + raise argparse.ArgumentTypeError( + f"profile must be a number from 1 to {PROFILE_CAPACITY}" + ) return number - 1 diff --git a/src/switch_pico_bridge/profile_web.py b/src/switch_pico_bridge/profile_web.py index 97ff739..f532193 100644 --- a/src/switch_pico_bridge/profile_web.py +++ b/src/switch_pico_bridge/profile_web.py @@ -33,6 +33,41 @@ _ASSET_TYPES = { ), } +_CONTROL_LABELS = { + "generic": { + "north": "Y", "east": "B", "south": "A", "west": "X", + "left_shoulder": "LB", "right_shoulder": "RB", + "left_trigger": "LT", "right_trigger": "RT", + "select": "View", "start": "Menu", "capture": "Share", + "system": "Xbox", "left_stick": "Left Stick", + "right_stick": "Right Stick", + }, + "xbox": { + "north": "Y", "east": "B", "south": "A", "west": "X", + "left_shoulder": "LB", "right_shoulder": "RB", + "left_trigger": "LT", "right_trigger": "RT", + "select": "View", "start": "Menu", "capture": "Share", + "system": "Xbox", "left_stick": "Left Stick", + "right_stick": "Right Stick", + }, + "switch": { + "north": "X", "east": "A", "south": "B", "west": "Y", + "left_shoulder": "L", "right_shoulder": "R", + "left_trigger": "ZL", "right_trigger": "ZR", + "select": "Minus", "start": "Plus", "capture": "Capture", + "system": "Home", "left_stick": "Left Stick", + "right_stick": "Right Stick", + }, + "playstation": { + "north": "Triangle", "east": "Circle", "south": "Cross", + "west": "Square", "left_shoulder": "L1", + "right_shoulder": "R1", "left_trigger": "L2", + "right_trigger": "R2", "select": "Create", "start": "Options", + "capture": "Touchpad", "system": "PS", "left_stick": "L3", + "right_stick": "R3", + }, +} + def _controller_presentation( identity: config_manager.ControllerIdentity, ) -> dict[str, str]: @@ -116,6 +151,8 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): "turbo_modes": list(config_manager.TURBO_MODES), "macro_overrides": list(config_manager.MACRO_OVERRIDE_NAMES), "macro_count": config_manager.PROFILE_MACRO_COUNT, + "profile_capacity": config_manager.PROFILE_CAPACITY, + "control_labels": _CONTROL_LABELS, "maximum_macro_state_steps": ( config_manager.PROFILE_MACRO_STEPS_PER_MACRO ), diff --git a/src/switch_pico_bridge/web/profile_editor.css b/src/switch_pico_bridge/web/profile_editor.css index a28e0c8..c832f31 100644 --- a/src/switch_pico_bridge/web/profile_editor.css +++ b/src/switch_pico_bridge/web/profile_editor.css @@ -375,7 +375,6 @@ h4 { font-size: 0.98rem; } .action-kind { color: var(--cyan); font-size: 0.61rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; } input[type="range"] { width: 100%; accent-color: var(--cyan); } -.macro-heading { align-items: center; } .macro-controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; } .macro-picker { display: flex; grid-column: 1 / -1; align-items: stretch; justify-content: space-between; gap: 14px; padding: 12px; border: 1px solid var(--line); border-radius: 15px; background: #0d1219; } .macro-tabs { display: grid; grid-template-columns: repeat(4, minmax(90px, 1fr)); gap: 7px; flex: 1; } @@ -387,6 +386,9 @@ input[type="range"] { width: 100%; accent-color: var(--cyan); } .macro-budget progress { width: 100%; height: 5px; margin-top: 8px; overflow: hidden; border: 0; border-radius: 999px; background: var(--surface-3); accent-color: var(--cyan); } .macro-budget progress::-webkit-progress-bar { border-radius: inherit; background: var(--surface-3); } .macro-budget progress::-webkit-progress-value { border-radius: inherit; background: linear-gradient(90deg, var(--cyan), var(--coral)); } +.macro-steps-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin: 4px 0 12px; padding: 0 4px; } +.macro-steps-heading h4 { margin-top: 3px; font-size: 1rem; } +.macro-step-action { display: flex; } .macro-steps { display: grid; gap: 12px; } .macro-step { padding: 17px; border: 1px solid var(--line); border-left: 3px solid var(--coral); border-radius: 14px; background: var(--surface-2); } .macro-step.end { border-left-color: var(--muted); opacity: 0.72; } @@ -421,6 +423,8 @@ input[type="range"] { width: 100%; accent-color: var(--cyan); } .panel-heading > p { text-align: left; } .analog-grid, .feedback-grid, .macro-controls { grid-template-columns: 1fr; } .macro-picker { flex-direction: column; } + .macro-steps-heading { align-items: stretch; flex-direction: column; } + .macro-step-action .button { width: 100%; } .macro-budget { flex-basis: auto; } .step-grid { grid-template-columns: 1fr 1fr; } } diff --git a/src/switch_pico_bridge/web/profile_editor.html b/src/switch_pico_bridge/web/profile_editor.html index 572a5df..d6b9043 100644 --- a/src/switch_pico_bridge/web/profile_editor.html +++ b/src/switch_pico_bridge/web/profile_editor.html @@ -174,15 +174,23 @@
-
+

05 · Actions

Bindings & custom macro

-
+
+
+

Sequence

+

Macro 1 steps

+
+ + + +
diff --git a/src/switch_pico_bridge/web/profile_editor.js b/src/switch_pico_bridge/web/profile_editor.js index 9328a15..dab8099 100644 --- a/src/switch_pico_bridge/web/profile_editor.js +++ b/src/switch_pico_bridge/web/profile_editor.js @@ -40,6 +40,7 @@ const elements = { turbo: document.querySelector("#turboFields"), macroControls: document.querySelector("#macroControls"), macroSteps: document.querySelector("#macroSteps"), + macroStepsTitle: document.querySelector("#macroStepsTitle"), refresh: document.querySelector("#refreshButton"), resetDraft: document.querySelector("#resetDraftButton"), activate: document.querySelector("#activateButton"), @@ -64,6 +65,24 @@ function label(value) { .join(" "); } + +const directionalLabels = { + dpad_up: "D-pad Up", + dpad_right: "D-pad Right", + dpad_down: "D-pad Down", + dpad_left: "D-pad Left", +}; + +function currentControllerStyle() { + return state.identities[state.identityIndex]?.controller?.style || "generic"; +} + +function controlLabel(control, style = currentControllerStyle()) { + return state.schema.control_labels?.[style]?.[control] || + directionalLabels[control] || + label(control); +} + function clone(value) { return JSON.parse(JSON.stringify(value)); } @@ -156,7 +175,7 @@ function renderIdentities() { function renderProfileList() { const owner = state.identities[state.identityIndex]; - elements.profileList.innerHTML = Array.from({ length: 4 }, (_, index) => { + elements.profileList.innerHTML = Array.from({ length: state.schema.profile_capacity }, (_, index) => { const selected = index === state.profileIndex; const active = owner && owner.active_profile === index + 1; return ` @@ -179,13 +198,14 @@ function renderProfileList() { function buttonOptions( selected, includeNone = true, - choices = state.schema.controls + choices = state.schema.controls, + style = currentControllerStyle() ) { const none = includeNone ? `` : ""; return none + choices.map((button) => ( - `` + `` )).join(""); } @@ -299,7 +319,7 @@ function renderButtonMap() { hotspot.textContent = controlGlyph(button, style); hotspot.classList.toggle("selected", button === selected); hotspot.classList.toggle("disabled-map", output === null); - hotspot.title = `${label(button)} → ${output === null ? "Disabled" : label(output)}`; + hotspot.title = `${controlLabel(button, style)} → ${output === null ? "Disabled" : controlLabel(output, style)}`; hotspot.setAttribute("aria-label", hotspot.title); hotspot.onclick = () => { state.selectedButton = button; @@ -308,11 +328,11 @@ function renderButtonMap() { }); elements.selectedControlGlyph.textContent = controlGlyph(selected, style); - elements.selectedControlName.textContent = label(selected); + elements.selectedControlName.textContent = controlLabel(selected, style); elements.selectedControlDescription.textContent = ( - `Physical ${label(selected)} currently produces ${mappedOutput === null ? "no output" : label(mappedOutput)}.` + `Physical ${controlLabel(selected, style)} currently produces ${mappedOutput === null ? "no output" : controlLabel(mappedOutput, style)}.` ); - elements.selectedMapping.innerHTML = buttonOptions(mappedOutput); + elements.selectedMapping.innerHTML = buttonOptions(mappedOutput, true, state.schema.controls, style); elements.selectedMapping.onchange = () => { setControlMapping(selected, elements.selectedMapping.value || null); renderButtonMap(); @@ -395,7 +415,7 @@ function renderFeedback() { function renderTurbo() { elements.turbo.innerHTML = state.schema.buttons.map((button) => `
- + @@ -415,9 +435,9 @@ function actionChordCard(action, title, description, selectedButtons, defaults) const effectiveButtons = inherited ? defaults : selectedButtons; const selected = new Set(effectiveButtons); const defaultText = inherited - ? `Using default: ${defaults.map(label).join(" + ")}.` + ? `Using default: ${defaults.map((button) => controlLabel(button)).join(" + ")}.` : defaults?.length - ? `Clear every selection to restore ${defaults.map(label).join(" + ")}.` + ? `Clear every selection to restore ${defaults.map((button) => controlLabel(button)).join(" + ")}.` : "Empty disables this action."; return `
@@ -431,7 +451,7 @@ function actionChordCard(action, title, description, selectedButtons, defaults) ${controlGlyph(button, state.identities[state.identityIndex]?.controller?.style || "generic")} - ${label(button)} + ${controlLabel(button)} `).join("")}
@@ -467,7 +487,7 @@ function renderMacro() { actionChordCard( "profile_switch", "Cycle active profile", - "Advance through profile slots 1–4.", + `Advance through profile slots 1–${state.schema.profile_capacity}.`, state.profile.switching_chord, state.schema.default_switching_chord ), @@ -503,7 +523,7 @@ function renderMacro() {
`; @@ -550,12 +570,13 @@ function renderMacro() { ${state.schema.buttons.map((button) => ` `).join("")}
`; }).join(""); + elements.macroStepsTitle.textContent = `Macro ${state.selectedMacro + 1} steps`; elements.addMacroStep.textContent = `Add step to macro ${state.selectedMacro + 1}`; elements.addMacroStep.disabled = ( state.busy || macro.steps.length >= 8 || totalSteps >= 16 || diff --git a/tests/controller_profile_runtime_test.cpp b/tests/controller_profile_runtime_test.cpp index b2f474a..b2629a2 100644 --- a/tests/controller_profile_runtime_test.cpp +++ b/tests/controller_profile_runtime_test.cpp @@ -627,19 +627,19 @@ void test_custom_switching_chord_and_wrap() { "custom switching chord was not consumed or activated"); prepare_profiles(); - rows[0].active_profile = 3; - rows[0].profiles[3].switching_chord = kCustomChord; + rows[0].active_profile = 7; + rows[0].profiles[7].switching_chord = kCustomChord; snapshot = make_snapshot(0); (void)runtime_transform(0, snapshot, 10); bool event_available = true; (void)take_profile_change(0, &event_available); require(!event_available, - "initial profile 4 load published a change event"); + "initial profile 8 load published a change event"); apply_button_mask(kCustomChord, &snapshot); (void)runtime_transform(0, snapshot, 11); require(activation_attempt_count == 1 && activation_attempts[0].profile_index == 0, - "profile switching did not wrap profile 4 to profile 1"); + "profile switching did not wrap profile 8 to profile 1"); } void test_switching_slot_isolation() { prepare_profiles(); diff --git a/tests/profile_service_test.cpp b/tests/profile_service_test.cpp index 13b5f9d..68bab29 100644 --- a/tests/profile_service_test.cpp +++ b/tests/profile_service_test.cpp @@ -1,8 +1,9 @@ #include "core/controller_identity.h" -#include "profile/controller_profile.h" #include "platform/pico/pico_profile_storage.h" +#include "profile/controller_profile.h" #include "profile/profile_service.h" #include "profile/profile_storage.h" + #include #include #include @@ -10,428 +11,216 @@ namespace { struct FakeFlash { - uint8_t bytes[PROFILE_STORAGE_BANK_COUNT][PROFILE_STORAGE_BANK_SIZE]; - int bank_replacements = 0; + uint8_t bytes[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE]; }; FakeFlash flash{}; -void require(bool condition, const char* message) { - if (!condition) { - std::cerr << message << '\n'; - std::exit(1); - } +void require(bool condition, const char *message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } } -bool fake_read(void* context, uint8_t bank, size_t offset, - uint8_t* output, size_t size) { - auto* storage = static_cast(context); - if (bank >= PROFILE_STORAGE_BANK_COUNT || output == nullptr || - offset > PROFILE_STORAGE_BANK_SIZE || - size > PROFILE_STORAGE_BANK_SIZE - offset) { - return false; - } - memcpy(output, &storage->bytes[bank][offset], size); - return true; +bool fake_read(void *context, uint8_t arena, size_t offset, uint8_t *output, + size_t size) { + auto *storage = static_cast(context); + if (arena >= PROFILE_STORAGE_ARENA_COUNT || output == nullptr || + offset > PROFILE_STORAGE_ARENA_SIZE || + size > PROFILE_STORAGE_ARENA_SIZE - offset) { + return false; + } + memcpy(output, &storage->bytes[arena][offset], size); + return true; } -bool fake_replace_bank(void* context, uint8_t bank, - const uint8_t* payload, size_t payload_size, - const uint8_t* header, size_t header_size) { - auto* storage = static_cast(context); - if (bank >= PROFILE_STORAGE_BANK_COUNT || payload == nullptr || - header == nullptr || - payload_size != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || - header_size != PROFILE_STORAGE_RECORD_HEADER_SIZE) { - return false; - } - ++storage->bank_replacements; - memset(storage->bytes[bank], 0xff, PROFILE_STORAGE_BANK_SIZE); - memcpy( - &storage->bytes[bank][PROFILE_STORAGE_RECORD_HEADER_SIZE], - payload, payload_size); - memcpy(storage->bytes[bank], header, header_size); - return memcmp( - &storage->bytes[bank][ - PROFILE_STORAGE_RECORD_HEADER_SIZE], - payload, payload_size) == 0 && - memcmp(storage->bytes[bank], header, header_size) == 0; +bool fake_erase(void *context, uint8_t arena) { + auto *storage = static_cast(context); + if (arena >= PROFILE_STORAGE_ARENA_COUNT) { + return false; + } + memset(storage->bytes[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE); + return true; +} + +bool fake_program(void *context, uint8_t arena, size_t offset, + const uint8_t *page, size_t size) { + auto *storage = static_cast(context); + if (arena >= PROFILE_STORAGE_ARENA_COUNT || page == nullptr || + size != PROFILE_STORAGE_PAGE_SIZE || + offset % PROFILE_STORAGE_PAGE_SIZE != 0 || + offset + size > PROFILE_STORAGE_ARENA_SIZE) { + return false; + } + for (size_t index = 0; index < size; ++index) { + storage->bytes[arena][offset + index] &= page[index]; + } + return memcmp(&storage->bytes[arena][offset], page, size) == 0; } ProfileStorageIo fake_io() { - return { - &flash, - PROFILE_STORAGE_BANK_SIZE, - PROFILE_STORAGE_SECTOR_SIZE, - PROFILE_STORAGE_PAGE_SIZE, - fake_read, - fake_replace_bank, - }; + return { + &flash, + PROFILE_STORAGE_ARENA_SIZE, + PROFILE_STORAGE_SECTOR_SIZE, + PROFILE_STORAGE_PAGE_SIZE, + fake_read, + fake_erase, + fake_program, + }; +} + +ControllerIdentity stable_identity() { + ControllerIdentity identity{}; + identity.stable = true; + identity.transport = ControllerTransport::kClassic; + identity.address[5] = 7; + identity.vendor_id = 0x054c; + identity.product_id = 0x0ce6; + return identity; } ProfileServiceTransactionSnapshot transaction_snapshot() { - ProfileServiceTransactionSnapshot snapshot{}; - profile_service_transaction_snapshot(&snapshot); - return snapshot; + ProfileServiceTransactionSnapshot snapshot{}; + profile_service_transaction_snapshot(&snapshot); + return snapshot; } -ProfileServiceActiveProfileSnapshot active_profile_snapshot( - const ControllerIdentity& identity) { - ProfileServiceActiveProfileSnapshot snapshot{}; - profile_service_active_profile_snapshot(identity, &snapshot); - return snapshot; +ProfileServiceActiveProfileSnapshot +active_snapshot(const ControllerIdentity &identity) { + ProfileServiceActiveProfileSnapshot snapshot{}; + profile_service_active_profile_snapshot(identity, &snapshot); + return snapshot; } -ControllerProfileDatabase reload_database( - const ProfileServiceTransactionSnapshot& transaction, - uint32_t expected_generation) { - ControllerProfileDatabase recovered{}; - ProfileStorage storage; - require(storage.initialize(fake_io(), &recovered) && - storage.snapshot().valid && - storage.snapshot().generation == expected_generation && - storage.snapshot().generation == - transaction.transaction.stored_generation && - storage.snapshot().payload_crc == - transaction.transaction.stored_crc, - "terminal transaction status did not identify persisted storage"); - return recovered; +void write_profile(uint32_t transaction_id, const ControllerIdentity &identity, + uint8_t profile_index, const ControllerProfile &profile, + uint32_t now_ms) { + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode(profile, encoded, sizeof(encoded)), + "profile did not encode"); + require( + profile_service_begin(transaction_id, identity, profile_index, + CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), + profile_storage_crc32(encoded, sizeof(encoded))) == + ConfigurationTransactionStatus::kReceiving, + "profile transaction did not begin"); + require(profile_service_append(transaction_id, 0, encoded, 117) == + ConfigurationTransactionStatus::kReceiving && + profile_service_append(transaction_id, 117, encoded + 117, + sizeof(encoded) - 117) == + ConfigurationTransactionStatus::kReceiving, + "profile chunks were not accepted"); + require(profile_service_commit(transaction_id) == + ConfigurationTransactionStatus::kPending, + "profile transaction did not become pending"); + profile_service_task_on_storage_core(now_ms); + require(transaction_snapshot().transaction.status == + ConfigurationTransactionStatus::kCommitted, + "profile transaction did not persist"); } -void test_pending_commands_are_not_decoded_as_profile_writes() { - memset(flash.bytes, 0xff, sizeof(flash.bytes)); - profile_service_prepare(); - profile_service_initialize_on_storage_core(); - ProfileServiceActiveProfileSnapshot active = - active_profile_snapshot(controller_identity_global()); - require(active.valid && - active.metadata.state == ProfileServiceState::kReady && - active.metadata.generation == 0 && - profile_service_database_generation() == 0 && - active.profile_index == 0, - "initial active profile snapshot was not coherent"); +void test_eight_profile_transactions_and_active_cache() { + memset(flash.bytes, 0xff, sizeof(flash.bytes)); + profile_service_prepare(); + profile_service_initialize_on_storage_core(); + const ControllerIdentity global = controller_identity_global(); + ProfileServiceActiveProfileSnapshot active = active_snapshot(global); + require(active.valid && active.profile_index == 0 && + active.metadata.state == ProfileServiceState::kReady, + "fallback active cache did not initialize"); - const ControllerIdentity identity = controller_identity_global(); - constexpr uint8_t kProfileIndex = 2; - ControllerProfile customized = - controller_profile_default(identity, kProfileIndex); - customized.strong_rumble_scale = 17; - uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; - require(controller_profile_encode(customized, encoded, sizeof(encoded)), - "customized profile did not encode"); + ControllerProfile eighth = controller_profile_default(global, 7); + eighth.strong_rumble_scale = 37; + write_profile(1, global, 7, eighth, 0); + require(profile_service_select(global, 7) == + ConfigurationTransactionStatus::kCommitted, + "profile eight was not selectable"); + ProfileServiceSelectedSnapshot selected{}; + profile_service_selected_snapshot(&selected); + require(selected.valid && selected.profile_index == 7 && + selected.profile.strong_rumble_scale == 37, + "selected profile eight was not decoded on demand"); - constexpr uint32_t kWriteTransactionId = 0x10203040; - require(profile_service_begin( - kWriteTransactionId, identity, kProfileIndex, - CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), - profile_storage_crc32(encoded, sizeof(encoded))) == - ConfigurationTransactionStatus::kReceiving && - profile_service_append(kWriteTransactionId, 0, encoded, - sizeof(encoded)) == - ConfigurationTransactionStatus::kReceiving && - profile_service_commit(kWriteTransactionId) == - ConfigurationTransactionStatus::kPending, - "profile write did not reach pending"); - profile_service_task_on_storage_core(0); - require(transaction_snapshot().transaction.status == - ConfigurationTransactionStatus::kCommitted, - "profile write baseline did not commit"); - active = active_profile_snapshot(identity); - require(active.valid && active.metadata.generation == 1 && - profile_service_database_generation() == 1 && - active.profile_index == 0, - "profile write did not publish one coherent generation"); + require(profile_service_activate(2, global, 7) == + ConfigurationTransactionStatus::kPending, + "profile eight activation was not queued"); + profile_service_task_on_storage_core(1000); + require(transaction_snapshot().transaction.status == + ConfigurationTransactionStatus::kCommitted, + "profile eight activation did not persist"); + active = active_snapshot(global); + require(active.valid && active.profile_index == 7 && + active.profile.strong_rumble_scale == 37, + "active cache did not publish profile eight"); - constexpr uint32_t kReservedInternalTransactionId = 0x80000019u; - require( - profile_service_begin( - kReservedInternalTransactionId, identity, kProfileIndex, - CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), - profile_storage_crc32(encoded, sizeof(encoded))) == - ConfigurationTransactionStatus::kMalformed && - profile_service_reset(kReservedInternalTransactionId, identity, - kProfileIndex) == - ConfigurationTransactionStatus::kMalformed && - profile_service_activate(kReservedInternalTransactionId, identity, - kProfileIndex) == - ConfigurationTransactionStatus::kMalformed, - "host profile mutations admitted the internal transaction namespace"); + const ControllerIdentity connected = stable_identity(); + require(profile_service_observe_identity_on_storage_core(connected), + "stable identity was not added to the catalog"); + ProfileServiceListSnapshot list{}; + profile_service_list_snapshot(&list); + require(list.count == 2 && + controller_identity_equal(list.rows[1].identity, connected), + "profile list did not publish the stable identity"); + active = active_snapshot(connected); + require(active.valid && active.profile_index == 0, + "new identity did not publish its default active profile"); - constexpr uint32_t kResetTransactionId = 0x25a55a5a; - require(profile_service_reset(kResetTransactionId, identity, - kProfileIndex) == - ConfigurationTransactionStatus::kPending, - "profile reset did not reach pending"); - ProfileServiceTransactionSnapshot reset = transaction_snapshot(); - require(reset.transaction.transaction_id == kResetTransactionId && - reset.transaction.status == - ConfigurationTransactionStatus::kPending, - "pending reset lost its transaction identity"); + ControllerProfile seventh = controller_profile_default(connected, 6); + seventh.weak_rumble_scale = 61; + write_profile(3, connected, 6, seventh, 3000); + require(profile_service_activate_internal(0x80000007u, connected, 6) == + ConfigurationTransactionStatus::kPending, + "controller activation was not queued"); + profile_service_task_on_storage_core(4000); + active = active_snapshot(connected); + require(active.valid && active.profile_index == 6 && + active.profile.weak_rumble_scale == 61, + "controller activation did not refresh the active cache"); - profile_service_task_on_storage_core(1000); - reset = transaction_snapshot(); - require(reset.transaction.transaction_id == kResetTransactionId && - reset.transaction.status == - ConfigurationTransactionStatus::kCommitted, - "one reset tick decoded profile payload or published a malformed result"); - ControllerProfileDatabase recovered = reload_database(reset, 2); - require(recovered.fallback_profiles[kProfileIndex].strong_rumble_scale == - UINT8_MAX, - "terminal reset status was published before reset persisted"); - active = active_profile_snapshot(identity); - require(active.valid && active.metadata.generation == 2 && - profile_service_database_generation() == 2 && - active.profile_index == 0, - "profile reset did not refresh the active snapshot generation"); + require(profile_service_reset(4, global, CONTROLLER_PROFILE_ALL) == + ConfigurationTransactionStatus::kPending, + "reset-all was not queued"); + profile_service_task_on_storage_core(5000); + active = active_snapshot(global); + require(active.valid && active.profile_index == 0 && + active.profile.strong_rumble_scale == UINT8_MAX, + "reset-all did not restore defaults and activation"); - constexpr uint32_t kActivateTransactionId = 0x50607080; - constexpr uint8_t kActivatedProfile = 3; - require(profile_service_activate(kActivateTransactionId, identity, - kActivatedProfile) == - ConfigurationTransactionStatus::kPending, - "profile activation did not reach pending"); - ProfileServiceTransactionSnapshot activate = transaction_snapshot(); - require(activate.transaction.transaction_id == kActivateTransactionId && - activate.transaction.status == - ConfigurationTransactionStatus::kPending, - "pending activation lost its transaction identity"); - active = active_profile_snapshot(identity); - require(active.valid && active.metadata.generation == 2 && - active.profile_index == 0, - "pending activation leaked an uncommitted active profile"); - - profile_service_task_on_storage_core(2000); - activate = transaction_snapshot(); - require(activate.transaction.transaction_id == kActivateTransactionId && - activate.transaction.status == - ConfigurationTransactionStatus::kCommitted, - "one activation tick decoded profile payload or published a malformed result"); - recovered = reload_database(activate, 3); - require(recovered.fallback_active_profile == kActivatedProfile, - "terminal activation status was published before activation persisted"); - active = active_profile_snapshot(identity); - require(active.valid && active.metadata.generation == 3 && - profile_service_database_generation() == 3 && - active.profile_index == kActivatedProfile && - active.profile.strong_rumble_scale == - recovered.fallback_profiles[kActivatedProfile] - .strong_rumble_scale, - "activation did not publish profile, index, and generation together"); + ProfileStorage reloaded; + ControllerProfile persisted{}; + require( + reloaded.initialize(fake_io()) && reloaded.find(connected) != nullptr && + reloaded.find(connected)->active_profile == 6 && + reloaded.get(connected, 6, &persisted) == ProfileStorageResult::kOk && + persisted.weak_rumble_scale == 61, + "service mutations did not survive catalog reload"); } -void test_host_and_controller_mutations_are_serialized() { - const ControllerIdentity identity = controller_identity_global(); - ControllerProfile profile = - controller_profile_default(identity, 1); - profile.weak_rumble_scale = 23; - uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; - require(controller_profile_encode(profile, encoded, sizeof(encoded)), - "serialization fixture profile did not encode"); - - constexpr uint32_t kHostTransactionId = 0x11223344; - constexpr uint32_t kInternalTransactionId = 0x80000019; - require(profile_service_begin( - kHostTransactionId, identity, 1, - CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), - profile_storage_crc32(encoded, sizeof(encoded))) == - ConfigurationTransactionStatus::kReceiving, - "host write did not acquire the profile mutation boundary"); - require(profile_service_activate_internal( - kInternalTransactionId, identity, 2) == - ConfigurationTransactionStatus::kBusy, - "controller activation raced a receiving host write"); - ProfileServiceTransactionSnapshot snapshot = - transaction_snapshot(); - require(snapshot.transaction.transaction_id == - kHostTransactionId && - snapshot.transaction.status == - ConfigurationTransactionStatus::kReceiving, - "busy controller activation replaced the host transaction"); - require( - profile_service_append(kInternalTransactionId, 0, encoded, - sizeof(encoded)) == - ConfigurationTransactionStatus::kMalformed && - profile_service_commit(kInternalTransactionId) == - ConfigurationTransactionStatus::kMalformed && - transaction_snapshot().transaction.transaction_id == - kHostTransactionId && - transaction_snapshot().transaction.status == - ConfigurationTransactionStatus::kReceiving, - "reserved internal IDs corrupted a receiving host transaction"); - - require(profile_service_append( - kHostTransactionId, 0, encoded, sizeof(encoded)) == - ConfigurationTransactionStatus::kReceiving && - profile_service_commit(kHostTransactionId) == - ConfigurationTransactionStatus::kPending, - "host write did not reach pending after controller contention"); - profile_service_task_on_storage_core(3000); - require(transaction_snapshot().transaction.status == - ConfigurationTransactionStatus::kCommitted, - "serialized host write did not commit"); - - constexpr uint32_t kHostActivationTransactionId = 0x22334455; - require(profile_service_activate( - kHostActivationTransactionId, identity, 0) == - ConfigurationTransactionStatus::kPending, - "host activation did not acquire the released boundary"); - require(profile_service_activate_internal( - kInternalTransactionId, identity, 2) == - ConfigurationTransactionStatus::kBusy, - "controller activation raced a pending host activation"); - snapshot = transaction_snapshot(); - require(snapshot.transaction.transaction_id == - kHostActivationTransactionId && - snapshot.transaction.status == - ConfigurationTransactionStatus::kPending && - active_profile_snapshot(identity).profile_index == 3, - "pending host activation was replaced or leaked before commit"); - profile_service_task_on_storage_core(4000); - require(active_profile_snapshot(identity).profile_index == 0, - "serialized host activation did not commit"); - require(profile_service_activate_internal( - 0x19, identity, 2) == - ConfigurationTransactionStatus::kMalformed, - "internal activation admitted a transaction without the high bit"); - - require(profile_service_activate_internal( - kInternalTransactionId, identity, 2) == - ConfigurationTransactionStatus::kPending, - "controller activation did not acquire the released boundary"); - require(profile_service_begin( - 0x55667788, identity, 0, - CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), - profile_storage_crc32(encoded, sizeof(encoded))) == - ConfigurationTransactionStatus::kBusy, - "host write raced a pending controller activation"); - snapshot = transaction_snapshot(); - require(snapshot.transaction.transaction_id == - kHostActivationTransactionId && - snapshot.transaction.status == - ConfigurationTransactionStatus::kCommitted && - active_profile_snapshot(identity).profile_index == 0, - "pending controller activation replaced host-visible status or leaked before commit"); - - profile_service_task_on_storage_core(5000); - const ProfileServiceActiveProfileSnapshot active = - active_profile_snapshot(identity); - snapshot = transaction_snapshot(); - require(active.valid && active.profile_index == 2 && - snapshot.transaction.transaction_id == - kHostActivationTransactionId && - snapshot.transaction.status == - ConfigurationTransactionStatus::kCommitted, - "controller activation did not publish while preserving host-visible status"); +void test_profile_bounds_and_transaction_namespace() { + const ControllerIdentity global = controller_identity_global(); + require(profile_service_select(global, 8) == + ConfigurationTransactionStatus::kMalformed && + profile_service_activate(10, global, 8) == + ConfigurationTransactionStatus::kMalformed && + profile_service_reset(11, global, 8) == + ConfigurationTransactionStatus::kMalformed, + "profile index beyond eight was admitted"); + require(profile_service_activate(0x80000001u, global, 0) == + ConfigurationTransactionStatus::kMalformed && + profile_service_activate_internal(12, global, 0) == + ConfigurationTransactionStatus::kMalformed, + "transaction namespaces were not enforced"); } -void test_completed_write_then_dirty_identity_activation() { - const int replacements_before = flash.bank_replacements; - const ControllerIdentity global = controller_identity_global(); - constexpr uint8_t kWrittenProfileIndex = 1; - ControllerProfile customized = - controller_profile_default(global, kWrittenProfileIndex); - customized.strong_rumble_scale = 31; - customized.weak_rumble_scale = 47; - uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; - require(controller_profile_encode(customized, encoded, sizeof(encoded)), - "sequential mutation fixture profile did not encode"); +} // namespace - constexpr uint32_t kWriteTransactionId = 0x31415926; - require(profile_service_begin( - kWriteTransactionId, global, kWrittenProfileIndex, - CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), - profile_storage_crc32(encoded, sizeof(encoded))) == - ConfigurationTransactionStatus::kReceiving && - profile_service_append(kWriteTransactionId, 0, encoded, - sizeof(encoded)) == - ConfigurationTransactionStatus::kReceiving && - profile_service_commit(kWriteTransactionId) == - ConfigurationTransactionStatus::kPending, - "sequential profile write did not reach pending"); - profile_service_task_on_storage_core(6000); - const ProfileServiceTransactionSnapshot written = - transaction_snapshot(); - require(written.transaction.transaction_id == kWriteTransactionId && - written.transaction.status == - ConfigurationTransactionStatus::kCommitted && - flash.bank_replacements == replacements_before + 1, - "completed write lost correlation or used multiple bank replacements"); - - ControllerIdentity connected{}; - connected.stable = true; - connected.transport = ControllerTransport::kClassic; - connected.address[0] = 0x10; - connected.address[1] = 0x20; - connected.address[2] = 0x30; - connected.address[3] = 0x40; - connected.address[4] = 0x50; - connected.address[5] = 0x60; - connected.vendor_id = 0x1234; - connected.product_id = 0xabcd; - require(profile_service_observe_identity_on_storage_core(connected), - "connected identity did not enter the dirty database"); - - constexpr uint32_t kActivateTransactionId = 0x27182818; - constexpr uint8_t kActivatedProfileIndex = 2; - require(profile_service_activate( - kActivateTransactionId, connected, - kActivatedProfileIndex) == - ConfigurationTransactionStatus::kPending, - "activation after completed write did not reach pending"); - const ProfileServiceTransactionSnapshot pending = - transaction_snapshot(); - require(pending.transaction.transaction_id == - kActivateTransactionId && - pending.transaction.status == - ConfigurationTransactionStatus::kPending && - pending.transaction.stored_generation == 0 && - pending.transaction.stored_crc == 0, - "pending activation was not correlated to its own transaction"); - - profile_service_task_on_storage_core(7000); - const ProfileServiceTransactionSnapshot activated = - transaction_snapshot(); - require(activated.transaction.transaction_id == - kActivateTransactionId && - activated.transaction.status == - ConfigurationTransactionStatus::kCommitted && - activated.transaction.stored_generation == - written.transaction.stored_generation + 1 && - flash.bank_replacements == replacements_before + 2, - "activation did not complete as one next correlated bank replacement"); - - const ControllerProfileDatabase recovered = reload_database( - activated, activated.transaction.stored_generation); - const ControllerProfileDatabaseEntry* connected_entry = - controller_profile_database_find(recovered, connected); - require(connected_entry != nullptr && - connected_entry->active_profile == - kActivatedProfileIndex && - recovered.fallback_profiles[kWrittenProfileIndex] - .strong_rumble_scale == - customized.strong_rumble_scale && - recovered.fallback_profiles[kWrittenProfileIndex] - .weak_rumble_scale == - customized.weak_rumble_scale, - "activation did not atomically persist the dirty identity and prior write"); - const ProfileServiceActiveProfileSnapshot active = - active_profile_snapshot(connected); - require(active.valid && - active.metadata.generation == - activated.transaction.stored_generation && - active.profile_index == kActivatedProfileIndex, - "completed activation did not publish the dirty identity"); -} - -} // namespace - -ProfileStorageIo pico_profile_storage_io() { - return fake_io(); -} +ProfileStorageIo pico_profile_storage_io() { return fake_io(); } int main() { - test_pending_commands_are_not_decoded_as_profile_writes(); - test_host_and_controller_mutations_are_serialized(); - test_completed_write_then_dirty_identity_activation(); - return 0; + test_eight_profile_transactions_and_active_cache(); + test_profile_bounds_and_transaction_namespace(); + std::cout << "profile service tests passed\n"; + return 0; } diff --git a/tests/profile_storage_test.cpp b/tests/profile_storage_test.cpp index e0fdc9d..2943289 100644 --- a/tests/profile_storage_test.cpp +++ b/tests/profile_storage_test.cpp @@ -1,7 +1,6 @@ #include "core/controller_identity.h" #include "profile/controller_profile.h" #include "profile/profile_storage.h" -#include "controller_profile_legacy_fixtures.h" #include #include @@ -10,649 +9,305 @@ namespace { struct FakeFlash { - uint8_t bytes[PROFILE_STORAGE_BANK_COUNT][PROFILE_STORAGE_BANK_SIZE]; - int successful_programs = 0; - int fail_after_programs = -1; - bool corrupt_next_program = false; - int corrupt_header_padding_offset = -1; - bool fail_reads_after_header_program = false; - bool header_programmed = false; - int erase_count = 0; - int bank_replacements = 0; + uint8_t bytes[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE]; + int programs = 0; + int erases = 0; + int fail_after_programs = -1; + bool corrupt_next_program = false; }; FakeFlash flash{}; -ControllerProfileDatabase database{}; -ControllerProfileDatabase recovered_database{}; -uint8_t encoded_database[CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE]{}; -void require(bool condition, const char* message) { - if (!condition) { - std::cerr << message << '\n'; - std::exit(1); - } +void require(bool condition, const char *message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } } void erase_all() { - memset(flash.bytes, 0xff, sizeof(flash.bytes)); - flash.successful_programs = 0; - flash.fail_after_programs = -1; - flash.corrupt_next_program = false; - flash.corrupt_header_padding_offset = -1; - flash.fail_reads_after_header_program = false; - flash.header_programmed = false; - flash.erase_count = 0; - flash.bank_replacements = 0; + flash = FakeFlash{}; + memset(flash.bytes, 0xff, sizeof(flash.bytes)); + flash.fail_after_programs = -1; } -bool fake_read(void* context, uint8_t bank, size_t offset, - uint8_t* output, size_t size) { - auto* storage = static_cast(context); - if (storage->fail_reads_after_header_program && - storage->header_programmed) { - return false; - } - if (bank >= PROFILE_STORAGE_BANK_COUNT || - offset > PROFILE_STORAGE_BANK_SIZE || - size > PROFILE_STORAGE_BANK_SIZE - offset) { - return false; - } - memcpy(output, &storage->bytes[bank][offset], size); - return true; +bool fake_read(void *context, uint8_t arena, size_t offset, uint8_t *output, + size_t size) { + auto *storage = static_cast(context); + if (arena >= PROFILE_STORAGE_ARENA_COUNT || output == nullptr || + offset > PROFILE_STORAGE_ARENA_SIZE || + size > PROFILE_STORAGE_ARENA_SIZE - offset) { + return false; + } + memcpy(output, &storage->bytes[arena][offset], size); + return true; } -bool fake_replace_bank(void* context, uint8_t bank, - const uint8_t* payload, size_t payload_size, - const uint8_t* header, size_t header_size) { - auto* storage = static_cast(context); - if (bank >= PROFILE_STORAGE_BANK_COUNT || payload == nullptr || - header == nullptr || - payload_size != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || - header_size != PROFILE_STORAGE_RECORD_HEADER_SIZE) { - return false; - } +bool fake_erase(void *context, uint8_t arena) { + auto *storage = static_cast(context); + if (arena >= PROFILE_STORAGE_ARENA_COUNT) { + return false; + } + memset(storage->bytes[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE); + ++storage->erases; + return true; +} - ++storage->bank_replacements; - memset(storage->bytes[bank], 0xff, PROFILE_STORAGE_BANK_SIZE); - storage->erase_count += - static_cast(PROFILE_STORAGE_SECTORS_PER_BANK); - - uint8_t final_page[PROFILE_STORAGE_PAGE_SIZE]{}; - for (size_t offset = 0; offset < payload_size; - offset += PROFILE_STORAGE_PAGE_SIZE) { - if (storage->fail_after_programs >= 0 && - storage->successful_programs >= - storage->fail_after_programs) { - return false; - } - const size_t remaining = payload_size - offset; - const uint8_t* page = &payload[offset]; - if (remaining < PROFILE_STORAGE_PAGE_SIZE) { - memcpy(final_page, page, remaining); - page = final_page; - } - memcpy( - &storage->bytes[bank][ - PROFILE_STORAGE_RECORD_HEADER_SIZE + offset], - page, PROFILE_STORAGE_PAGE_SIZE); - if (storage->corrupt_next_program) { - storage->bytes[bank][ - PROFILE_STORAGE_RECORD_HEADER_SIZE + offset] ^= 1; - storage->corrupt_next_program = false; - } - ++storage->successful_programs; - } - if (memcmp( - &storage->bytes[bank][PROFILE_STORAGE_RECORD_HEADER_SIZE], - payload, payload_size) != 0) { - return false; - } - if (storage->fail_after_programs >= 0 && - storage->successful_programs >= storage->fail_after_programs) { - return false; - } - - memcpy(storage->bytes[bank], header, header_size); - storage->header_programmed = true; - ++storage->successful_programs; - if (storage->corrupt_header_padding_offset >= 24 && - static_cast( - storage->corrupt_header_padding_offset) < header_size) { - storage->bytes[bank][ - static_cast( - storage->corrupt_header_padding_offset)] ^= 1; - } - return memcmp(storage->bytes[bank], header, header_size) == 0; +bool fake_program(void *context, uint8_t arena, size_t offset, + const uint8_t *page, size_t size) { + auto *storage = static_cast(context); + if (arena >= PROFILE_STORAGE_ARENA_COUNT || page == nullptr || + size != PROFILE_STORAGE_PAGE_SIZE || + offset % PROFILE_STORAGE_PAGE_SIZE != 0 || + offset + size > PROFILE_STORAGE_ARENA_SIZE || + (storage->fail_after_programs >= 0 && + storage->programs >= storage->fail_after_programs)) { + return false; + } + for (size_t index = 0; index < size; ++index) { + storage->bytes[arena][offset + index] &= page[index]; + } + if (storage->corrupt_next_program) { + storage->bytes[arena][offset] ^= 1; + storage->corrupt_next_program = false; + } + ++storage->programs; + return memcmp(&storage->bytes[arena][offset], page, size) == 0; } ProfileStorageIo fake_io() { - return { - &flash, - PROFILE_STORAGE_BANK_SIZE, - PROFILE_STORAGE_SECTOR_SIZE, - PROFILE_STORAGE_PAGE_SIZE, - fake_read, - fake_replace_bank, - }; + return { + &flash, + PROFILE_STORAGE_ARENA_SIZE, + PROFILE_STORAGE_SECTOR_SIZE, + PROFILE_STORAGE_PAGE_SIZE, + fake_read, + fake_erase, + fake_program, + }; } -uint16_t fixture_read_u16(const uint8_t* input) { - return static_cast(input[0]) | - static_cast(input[1] << 8); +ControllerIdentity identity(uint8_t suffix) { + ControllerIdentity result{}; + result.stable = true; + result.transport = ControllerTransport::kClassic; + result.address[5] = suffix; + result.vendor_id = 0x1234; + result.product_id = 0x5678; + return result; } -void fixture_write_u16(uint8_t* output, uint16_t value) { - output[0] = static_cast(value); - output[1] = static_cast(value >> 8); +void write_u16(uint8_t *output, uint16_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); } -void fixture_write_u32(uint8_t* output, uint32_t value) { - output[0] = static_cast(value); - output[1] = static_cast(value >> 8); - output[2] = static_cast(value >> 16); - output[3] = static_cast(value >> 24); +void write_u32(uint8_t *output, uint32_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); + output[2] = static_cast(value >> 16); + output[3] = static_cast(value >> 24); } -void install_legacy_database_bank_fixture() { - erase_all(); - constexpr uint8_t kBank = 1; - constexpr uint32_t kGeneration = 41; - constexpr size_t kFallbackOffset = - CONTROLLER_PROFILE_DATABASE_HEADER_SIZE; - constexpr size_t kEntryOffset = - kFallbackOffset + - CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE; - constexpr uint8_t kEntryHeader[CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE] = { - 1, 1, 7, 0, 1, 2, 3, 4, 5, 6, 0x7e, 0x05, 0x09, 0x20, 3, 1, - }; +void install_legacy_database() { + constexpr size_t kLegacyStart = + PROFILE_STORAGE_TOTAL_SIZE - PROFILE_STORAGE_LEGACY_TOTAL_SIZE; + constexpr uint8_t kArena = 1; + constexpr size_t kArenaBase = kLegacyStart - PROFILE_STORAGE_ARENA_SIZE; + constexpr uint8_t kBank = 1; + constexpr size_t kBankBase = + kArenaBase + kBank * PROFILE_STORAGE_LEGACY_BANK_SIZE; + uint8_t *header = flash.bytes[kArena] + kBankBase; + uint8_t *payload = header + PROFILE_STORAGE_LEGACY_HEADER_SIZE; + memset(header, 0, PROFILE_STORAGE_LEGACY_HEADER_SIZE); + memset(payload, 0, PROFILE_STORAGE_LEGACY_DATABASE_SIZE); - uint8_t* const record = flash.bytes[kBank]; - uint8_t* const payload = record + PROFILE_STORAGE_RECORD_HEADER_SIZE; - memset(record, 0, PROFILE_STORAGE_RECORD_HEADER_SIZE); - memset(payload, 0, CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE); + memcpy(payload, "SPDB", 4); + write_u16(&payload[4], 2); + write_u16(&payload[6], PROFILE_STORAGE_LEGACY_DATABASE_SIZE); + payload[8] = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; + payload[9] = PROFILE_STORAGE_LEGACY_PROFILE_COUNT; + payload[10] = 3; + payload[11] = 1; - memcpy(payload, "SPDB", 4); - fixture_write_u16(&payload[4], - CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION); - fixture_write_u16( - &payload[6], - static_cast(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE)); - payload[8] = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; - payload[9] = CONTROLLER_PROFILE_COUNT; - payload[10] = 2; - payload[11] = 1; - for (uint8_t profile_index = 0; - profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { - const uint8_t* fixture = - profile_index == 0 - ? kLegacyNarrowRawRangeProfile - : profile_index == 1 ? kLegacyCustomThresholdProfile - : kLegacyDefaultProfile; - memcpy(&payload[kFallbackOffset + - profile_index * CONTROLLER_PROFILE_ENCODED_SIZE], - fixture, CONTROLLER_PROFILE_ENCODED_SIZE); - } + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + const ControllerIdentity global = controller_identity_global(); + for (uint8_t profile = 0; profile < PROFILE_STORAGE_LEGACY_PROFILE_COUNT; + ++profile) { + ControllerProfile value = controller_profile_default(global, profile); + value.weak_rumble_scale = static_cast(20 + profile); + require(controller_profile_encode(value, encoded, sizeof(encoded)), + "legacy fallback profile did not encode"); + memcpy(payload + 32 + profile * sizeof(encoded), encoded, sizeof(encoded)); + } - memcpy(&payload[kEntryOffset], kEntryHeader, sizeof(kEntryHeader)); - for (uint8_t profile_index = 0; - profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { - const uint8_t* fixture = - profile_index == 0 - ? kLegacyNarrowRawRangeProfile - : profile_index == 3 ? kLegacyCustomThresholdProfile - : kLegacyDefaultProfile; - memcpy(&payload[kEntryOffset + - CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE + - profile_index * CONTROLLER_PROFILE_ENCODED_SIZE], - fixture, CONTROLLER_PROFILE_ENCODED_SIZE); - } + constexpr size_t kEntrySize = 16 + PROFILE_STORAGE_LEGACY_PROFILE_COUNT * + CONTROLLER_PROFILE_ENCODED_SIZE; + uint8_t *entry = + payload + 32 + + PROFILE_STORAGE_LEGACY_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE; + const ControllerIdentity stable = identity(7); + require(controller_identity_encode(stable, entry, + CONTROLLER_IDENTITY_ENCODED_SIZE), + "legacy identity did not encode"); + entry[14] = 2; + entry[15] = 1; + for (uint8_t profile = 0; profile < PROFILE_STORAGE_LEGACY_PROFILE_COUNT; + ++profile) { + ControllerProfile value = controller_profile_default(stable, profile); + value.strong_rumble_scale = static_cast(40 + profile); + require(controller_profile_encode(value, encoded, sizeof(encoded)), + "legacy stable profile did not encode"); + memcpy(entry + 16 + profile * sizeof(encoded), encoded, sizeof(encoded)); + } + (void)kEntrySize; - const uint32_t payload_crc = profile_storage_crc32( - payload, CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE); - memcpy(record, "SPPF", 4); - fixture_write_u16(&record[4], 1); - fixture_write_u16(&record[6], - CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION); - fixture_write_u32(&record[8], kGeneration); - fixture_write_u32( - &record[12], - static_cast(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE)); - fixture_write_u32(&record[16], payload_crc); - fixture_write_u32(&record[20], profile_storage_crc32(record, 20)); + memcpy(header, "SPPF", 4); + write_u16(&header[4], 1); + write_u16(&header[6], 2); + write_u32(&header[8], 41); + write_u32(&header[12], PROFILE_STORAGE_LEGACY_DATABASE_SIZE); + write_u32(&header[16], profile_storage_crc32( + payload, PROFILE_STORAGE_LEGACY_DATABASE_SIZE)); + write_u32(&header[20], profile_storage_crc32(header, 20)); } -void test_initialize_requires_batch_replacement() { - erase_all(); - ProfileStorageIo io = fake_io(); - io.replace_bank = nullptr; - ProfileStorage storage; - require(!storage.initialize(io, &database), - "profile storage initialized without bank replacement"); +void test_empty_catalog_and_eight_profiles() { + erase_all(); + ProfileStorage storage; + require(storage.initialize(fake_io()) && storage.snapshot().valid && + storage.identity_count() == 1, + "erased flash did not initialize an empty catalog"); + const ControllerIdentity global = controller_identity_global(); + ControllerProfile profile = controller_profile_default(global, 7); + profile.strong_rumble_scale = 77; + require(storage.set(global, 7, profile) == ProfileStorageResult::kOk, + "profile eight did not append"); + require(storage.activate(global, 7) == ProfileStorageResult::kOk, + "profile eight did not activate"); + + ProfileStorage reloaded; + ControllerProfile recovered{}; + require(reloaded.initialize(fake_io()) && reloaded.find(global) != nullptr && + reloaded.find(global)->active_profile == 7 && + reloaded.get(global, 7, &recovered) == + ProfileStorageResult::kOk && + recovered.strong_rumble_scale == 77, + "profile eight did not survive reload"); } -void test_two_bank_recovery() { - erase_all(); - controller_profile_database_default(&database); - ProfileStorage storage; - require(storage.initialize(fake_io(), &database) && - !storage.snapshot().valid, - "erased profile storage did not initialize empty"); - require(storage.commit(database, encoded_database, - sizeof(encoded_database)) == - ProfileStorageResult::kOk && - storage.snapshot().generation == 1, - "first profile database did not commit"); - const int programs_after_first = flash.successful_programs; - require(storage.commit(database, encoded_database, - sizeof(encoded_database)) == - ProfileStorageResult::kUnchanged && - flash.successful_programs == programs_after_first, - "unchanged profile database consumed flash writes"); - - database.fallback_profiles[0].button_map[0] = 1; - require(storage.commit(database, encoded_database, - sizeof(encoded_database)) == - ProfileStorageResult::kOk && - storage.snapshot().generation == 2, - "second profile database generation did not commit"); - ProfileStorage reloaded; - require(reloaded.initialize(fake_io(), &recovered_database) && - reloaded.snapshot().generation == 2 && - recovered_database.fallback_profiles[0].button_map[0] == 1, - "latest profile database did not survive reload"); - - const uint8_t newest_bank = reloaded.snapshot().active_bank; - flash.bytes[newest_bank][PROFILE_STORAGE_RECORD_HEADER_SIZE + 4] ^= 1; - ProfileStorage after_corruption; - require(after_corruption.initialize(fake_io(), &recovered_database) && - after_corruption.snapshot().generation == 1 && - recovered_database.fallback_profiles[0].button_map[0] == 0, - "corrupt newest profile bank did not roll back"); -} - -void test_interrupted_commit_retains_previous_bank() { - erase_all(); - controller_profile_database_default(&database); - ProfileStorage storage; - require(storage.initialize(fake_io(), &database) && - storage.commit(database, encoded_database, - sizeof(encoded_database)) == - ProfileStorageResult::kOk, - "interruption baseline did not commit"); - database.fallback_profiles[1].button_map[2] = 3; - flash.fail_after_programs = flash.successful_programs + 1; - require(storage.commit(database, encoded_database, - sizeof(encoded_database)) == - ProfileStorageResult::kIoError, - "interrupted profile write reported success"); - - flash.fail_after_programs = -1; - ProfileStorage recovered; - require(recovered.initialize(fake_io(), &recovered_database) && - recovered.snapshot().generation == 1 && - recovered_database.fallback_profiles[1].button_map[2] == 2, - "interrupted profile write replaced previous bank"); -} - -void test_successful_header_program_is_commit_point() { - erase_all(); - controller_profile_database_default(&database); - ProfileStorage storage; - require(storage.initialize(fake_io(), &database) && - storage.commit(database, encoded_database, - sizeof(encoded_database)) == - ProfileStorageResult::kOk, - "commit-point baseline did not commit"); - - database.fallback_profiles[1].strong_rumble_scale = 17; - flash.header_programmed = false; - flash.fail_reads_after_header_program = true; - require(storage.commit(database, encoded_database, - sizeof(encoded_database)) == - ProfileStorageResult::kOk && - storage.snapshot().generation == 2, - "successful header program was rolled back by a later read"); - - flash.fail_reads_after_header_program = false; - ProfileStorage recovered; - require(recovered.initialize(fake_io(), &recovered_database) && - recovered.snapshot().generation == 2 && - recovered_database.fallback_profiles[1] - .strong_rumble_scale == 17, - "committed header did not recover after transient read failure"); -} - -void test_payload_corruption_prevents_header_publication() { - erase_all(); - controller_profile_database_default(&database); - ProfileStorage storage; - require(storage.initialize(fake_io(), &database) && - storage.commit(database, encoded_database, - sizeof(encoded_database)) == - ProfileStorageResult::kOk, - "corruption baseline did not commit"); - const ProfileStorageSnapshot previous = storage.snapshot(); - const uint8_t target_bank = previous.active_bank ^ 1u; - const int programs_before_corruption = flash.successful_programs; - constexpr int kPayloadProgramCount = - (CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE + - PROFILE_STORAGE_PAGE_SIZE - 1) / - PROFILE_STORAGE_PAGE_SIZE; - - database.fallback_profiles[1].button_map[2] = 3; - flash.corrupt_next_program = true; - require(storage.commit(database, encoded_database, - sizeof(encoded_database)) == - ProfileStorageResult::kIoError && - flash.successful_programs == - programs_before_corruption + kPayloadProgramCount, - "corrupt payload programming reached the header program"); - for (size_t index = 0; index < PROFILE_STORAGE_RECORD_HEADER_SIZE; - ++index) { - require(flash.bytes[target_bank][index] == 0xff, - "rejected corrupt payload published a discoverable header"); - } - require(storage.snapshot().valid == previous.valid && - storage.snapshot().generation == previous.generation && - storage.snapshot().payload_crc == previous.payload_crc && - storage.snapshot().active_bank == previous.active_bank, - "rejected corrupt programming changed the storage snapshot"); - - ProfileStorage recovered; - require(recovered.initialize(fake_io(), &recovered_database) && - recovered.snapshot().generation == previous.generation && - recovered.snapshot().active_bank == previous.active_bank && - recovered_database.fallback_profiles[1].button_map[2] == 2, - "headerless corrupt payload was recovered"); -} - -void test_batched_bank_replacement_is_one_atomic_operation() { - erase_all(); - controller_profile_database_default(&database); - ProfileStorage storage; - constexpr int kPayloadProgramCount = - (CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE + - PROFILE_STORAGE_PAGE_SIZE - 1) / - PROFILE_STORAGE_PAGE_SIZE; - require(storage.initialize(fake_io(), &database) && - storage.commit(database, encoded_database, - sizeof(encoded_database)) == - ProfileStorageResult::kOk && - flash.bank_replacements == 1 && - flash.erase_count == static_cast( - PROFILE_STORAGE_SECTORS_PER_BANK) && - flash.successful_programs == - kPayloadProgramCount + 1, - "batched commit did not replace one bank in one operation"); - - const ProfileStorageSnapshot previous = storage.snapshot(); - const uint8_t target_bank = previous.active_bank ^ 1u; - const int programs_before_corruption = flash.successful_programs; - database.fallback_profiles[1].button_map[2] = 3; - flash.corrupt_next_program = true; - require(storage.commit(database, encoded_database, - sizeof(encoded_database)) == - ProfileStorageResult::kIoError && - flash.bank_replacements == 2 && - flash.successful_programs == - programs_before_corruption + - kPayloadProgramCount, - "corrupt batched payload reached header publication"); - for (size_t index = 0; index < PROFILE_STORAGE_RECORD_HEADER_SIZE; - ++index) { - require(flash.bytes[target_bank][index] == 0xff, - "failed batched replacement published a header"); - } - require(storage.snapshot().generation == previous.generation && - storage.snapshot().payload_crc == - previous.payload_crc && - storage.snapshot().active_bank == - previous.active_bank, - "failed batched replacement changed the committed snapshot"); - - ProfileStorage recovered; - require(recovered.initialize(fake_io(), &recovered_database) && - recovered.snapshot().generation == - previous.generation && - recovered.snapshot().active_bank == - previous.active_bank && - recovered_database.fallback_profiles[1] - .button_map[2] == 2, - "headerless batched payload replaced the prior bank"); -} - -void test_header_padding_corruption_fails_commit_and_recovery() { - erase_all(); - controller_profile_database_default(&database); - ProfileStorage storage; - require(storage.initialize(fake_io(), &database) && - storage.commit(database, encoded_database, - sizeof(encoded_database)) == - ProfileStorageResult::kOk, - "header padding baseline did not commit"); - - const ProfileStorageSnapshot previous = storage.snapshot(); - const uint8_t previous_scale = - database.fallback_profiles[1].strong_rumble_scale; - database.fallback_profiles[1].strong_rumble_scale = 17; - for (size_t offset = 24; - offset < PROFILE_STORAGE_RECORD_HEADER_SIZE; ++offset) { - flash.corrupt_header_padding_offset = - static_cast(offset); - require(storage.commit(database, encoded_database, - sizeof(encoded_database)) == - ProfileStorageResult::kIoError, - "corrupt header padding did not fail the commit"); - - ProfileStorage recovered; - require(recovered.initialize(fake_io(), &recovered_database) && - recovered.snapshot().generation == - previous.generation && - recovered.snapshot().active_bank == - previous.active_bank && - recovered_database.fallback_profiles[1] - .strong_rumble_scale == previous_scale, - "corrupt header padding was accepted on recovery"); - } - - flash.corrupt_header_padding_offset = -1; - require(storage.commit(database, encoded_database, - sizeof(encoded_database)) == +void test_identity_capacity_and_defaults() { + erase_all(); + ProfileStorage storage; + require(storage.initialize(fake_io()), "catalog did not initialize"); + for (uint8_t index = 1; index <= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; + ++index) { + require(storage.ensure_identity(identity(index)) == ProfileStorageResult::kOk, - "valid full header page did not commit"); - for (size_t offset = 24; - offset < PROFILE_STORAGE_RECORD_HEADER_SIZE; ++offset) { - require( - flash.bytes[storage.snapshot().active_bank][offset] == 0, - "valid committed header contained nonzero padding"); - } - ProfileStorage recovered; - require(recovered.initialize(fake_io(), &recovered_database) && - recovered.snapshot().generation == - previous.generation + 1u && - recovered_database.fallback_profiles[1] - .strong_rumble_scale == 17, - "valid full header page was rejected on recovery"); + "stable identity was not indexed"); + } + require(storage.ensure_identity(identity(99)) == ProfileStorageResult::kFull, + "identity catalog accepted a seventeenth stable identity"); + ControllerProfile profile{}; + require(storage.get(identity(1), 7, &profile) == ProfileStorageResult::kOk && + controller_profile_validate(profile), + "unstored profile eight did not resolve to its default"); } -void test_legacy_database_bank_migration() { - install_legacy_database_bank_fixture(); - ProfileStorage storage; - require(storage.initialize(fake_io(), &recovered_database) && - storage.snapshot().valid && - storage.snapshot().active_bank == 1 && - storage.snapshot().generation == 41, - "legacy v1 database bank was not selected"); - require(flash.erase_count == 0, - "legacy bank admission erased flash"); - require(recovered_database.fallback_active_profile == 2, - "legacy fallback active profile was not preserved"); +void test_interrupted_and_corrupt_append_recovery() { + erase_all(); + const ControllerIdentity global = controller_identity_global(); + ProfileStorage storage; + require(storage.initialize(fake_io()), "catalog did not initialize"); + ControllerProfile first = controller_profile_default(global, 0); + first.weak_rumble_scale = 11; + require(storage.set(global, 0, first) == ProfileStorageResult::kOk, + "baseline profile did not append"); - for (uint8_t profile_index = 0; - profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { - const uint16_t expected_left = - profile_index == 1 - ? 0x1234 - : CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; - const uint16_t expected_right = - profile_index == 1 - ? 0xabcd - : CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; - require(recovered_database.fallback_profiles[profile_index] - .triggers[0] - .digital_threshold == expected_left && - recovered_database.fallback_profiles[profile_index] - .triggers[1] - .digital_threshold == expected_right, - "legacy fallback thresholds were not selectively migrated"); - } - require(recovered_database.fallback_profiles[0] - .triggers[0] - .lower_deadzone == 30000 && - recovered_database.fallback_profiles[0] - .triggers[0] - .upper_saturation == 40000 && - recovered_database.fallback_profiles[0] - .triggers[1] - .lower_deadzone == 30000 && - recovered_database.fallback_profiles[0] - .triggers[1] - .upper_saturation == 40000, - "legacy fallback raw trigger ranges were not preserved"); + ControllerProfile second = first; + second.weak_rumble_scale = 22; + flash.fail_after_programs = flash.programs + 1; + require(storage.set(global, 0, second) == ProfileStorageResult::kIoError, + "interrupted header publish reported success"); + flash.fail_after_programs = -1; + ProfileStorage recovered; + ControllerProfile value{}; + require(recovered.initialize(fake_io()) && + recovered.get(global, 0, &value) == ProfileStorageResult::kOk && + value.weak_rumble_scale == 11, + "interrupted append displaced the previous record"); - const ControllerProfileDatabaseEntry& entry = - recovered_database.entries[0]; - require(entry.used && entry.active_profile == 3 && - entry.identity.stable && - entry.identity.transport == ControllerTransport::kClassic && - entry.identity.address_type == 7 && - entry.identity.address[0] == 1 && - entry.identity.address[5] == 6 && - entry.identity.vendor_id == 0x057e && - entry.identity.product_id == 0x2009, - "legacy entry identity or active profile was not preserved"); - for (uint8_t profile_index = 0; - profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { - const uint16_t expected_left = - profile_index == 3 - ? 0x1234 - : CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; - const uint16_t expected_right = - profile_index == 3 - ? 0xabcd - : CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; - require(entry.profiles[profile_index] - .triggers[0] - .digital_threshold == expected_left && - entry.profiles[profile_index] - .triggers[1] - .digital_threshold == expected_right, - "legacy entry thresholds were not selectively migrated"); - } - require(entry.profiles[0].triggers[0].lower_deadzone == 30000 && - entry.profiles[0].triggers[0].upper_saturation == 40000 && - entry.profiles[0].triggers[1].lower_deadzone == 30000 && - entry.profiles[0].triggers[1].upper_saturation == 40000, - "legacy entry raw trigger ranges were not preserved"); - - recovered_database.fallback_profiles[2].weak_rumble_scale = 17; - require(storage.commit(recovered_database, encoded_database, - sizeof(encoded_database)) == - ProfileStorageResult::kOk && - storage.snapshot().active_bank == 0 && - storage.snapshot().generation == 42, - "mutation after legacy admission did not commit"); - const uint8_t* const current_record = flash.bytes[0]; - const uint8_t* const current_payload = - current_record + PROFILE_STORAGE_RECORD_HEADER_SIZE; - require(fixture_read_u16(¤t_record[6]) == - CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION && - fixture_read_u16(¤t_payload[4]) == - CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION, - "post-migration commit did not emit v2 storage schemas"); - constexpr size_t kFallbackOffset = - CONTROLLER_PROFILE_DATABASE_HEADER_SIZE; - constexpr size_t kEntryOffset = - kFallbackOffset + - CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE; - for (uint8_t profile_index = 0; - profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { - require(fixture_read_u16( - ¤t_payload[kFallbackOffset + - profile_index * - CONTROLLER_PROFILE_ENCODED_SIZE]) == - CONTROLLER_PROFILE_SCHEMA_VERSION && - fixture_read_u16( - ¤t_payload[ - kEntryOffset + - CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE + - profile_index * - CONTROLLER_PROFILE_ENCODED_SIZE]) == - CONTROLLER_PROFILE_SCHEMA_VERSION, - "post-migration commit retained a v1 profile"); - } - require(fixture_read_u16(&flash.bytes[1][6]) == - CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION && - fixture_read_u16( - &flash.bytes[1][PROFILE_STORAGE_RECORD_HEADER_SIZE + 4]) == - CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION, - "post-migration commit erased or rewrote the admitted legacy bank"); - - ProfileStorage reloaded; - require(reloaded.initialize(fake_io(), &database) && - reloaded.snapshot().generation == 42 && - database.fallback_profiles[2].weak_rumble_scale == 17 && - database.fallback_profiles[0] - .triggers[0] - .lower_deadzone == 30000 && - database.fallback_profiles[0] - .triggers[0] - .upper_saturation == 40000 && - database.fallback_profiles[0] - .triggers[0] - .digital_threshold == - CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && - database.fallback_profiles[1] - .triggers[0] - .digital_threshold == 0x1234 && - database.fallback_profiles[1] - .triggers[1] - .digital_threshold == 0xabcd && - database.entries[0].used && - database.entries[0].active_profile == 3 && - database.entries[0] - .profiles[0] - .triggers[0] - .lower_deadzone == 30000 && - database.entries[0] - .profiles[0] - .triggers[0] - .upper_saturation == 40000 && - database.entries[0] - .profiles[0] - .triggers[0] - .digital_threshold == - CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && - database.entries[0] - .profiles[3] - .triggers[0] - .digital_threshold == 0x1234 && - database.entries[0] - .profiles[3] - .triggers[1] - .digital_threshold == 0xabcd, - "v2 migration commit did not reload without data loss"); + flash.corrupt_next_program = true; + second.weak_rumble_scale = 33; + require(recovered.set(global, 0, second) == ProfileStorageResult::kIoError, + "corrupt payload program reported success"); + ProfileStorage after_corruption; + require(after_corruption.initialize(fake_io()) && + after_corruption.get(global, 0, &value) == + ProfileStorageResult::kOk && + value.weak_rumble_scale == 11, + "corrupt newest record displaced the previous record"); } -} // namespace +void test_compaction_preserves_latest_records() { + erase_all(); + const ControllerIdentity global = controller_identity_global(); + ProfileStorage storage; + require(storage.initialize(fake_io()), "catalog did not initialize"); + ControllerProfile profile = controller_profile_default(global, 0); + for (uint16_t write = 1; write <= 260; ++write) { + profile.weak_rumble_scale = static_cast(write); + require(storage.set(global, 0, profile) == ProfileStorageResult::kOk, + "catalog update failed while forcing compaction"); + } + require(storage.snapshot().active_bank == 1 && flash.erases >= 2, + "full arena did not compact into its peer"); + ProfileStorage reloaded; + ControllerProfile recovered{}; + require(reloaded.initialize(fake_io()) && + reloaded.get(global, 0, &recovered) == + ProfileStorageResult::kOk && + recovered.weak_rumble_scale == static_cast(260), + "compaction did not preserve the latest profile"); +} + +void test_legacy_migration_is_atomic_and_complete() { + erase_all(); + install_legacy_database(); + ProfileStorage storage; + require(storage.initialize(fake_io()) && storage.snapshot().valid && + storage.identity_count() == 2, + "legacy database did not migrate"); + const ControllerIdentity global = controller_identity_global(); + ControllerProfile profile{}; + require(storage.find(global)->active_profile == 3 && + storage.get(global, 3, &profile) == ProfileStorageResult::kOk && + profile.weak_rumble_scale == 23, + "legacy fallback profiles were not preserved"); + require(storage.get(global, 7, &profile) == ProfileStorageResult::kOk && + profile.weak_rumble_scale == UINT8_MAX, + "new profile slots were not defaulted during migration"); + const ControllerIdentity stable = identity(7); + require(storage.find(stable) != nullptr && + storage.find(stable)->active_profile == 2 && + storage.get(stable, 2, &profile) == ProfileStorageResult::kOk && + profile.strong_rumble_scale == 42, + "legacy stable identity was not preserved"); + + ProfileStorage reloaded; + require(reloaded.initialize(fake_io()) && reloaded.find(stable) != nullptr && + reloaded.find(stable)->active_profile == 2, + "migrated catalog did not survive reload"); +} + +} // namespace + int main() { - test_two_bank_recovery(); - test_initialize_requires_batch_replacement(); - test_interrupted_commit_retains_previous_bank(); - test_successful_header_program_is_commit_point(); - test_payload_corruption_prevents_header_publication(); - test_batched_bank_replacement_is_one_atomic_operation(); - test_header_padding_corruption_fails_commit_and_recovery(); - test_legacy_database_bank_migration(); - return 0; + test_empty_catalog_and_eight_profiles(); + test_identity_capacity_and_defaults(); + test_interrupted_and_corrupt_append_recovery(); + test_compaction_preserves_latest_records(); + test_legacy_migration_is_atomic_and_complete(); + std::cout << "profile storage tests passed\n"; + return 0; } diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index e646d18..018602f 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -1598,11 +1598,11 @@ def test_profile_cli_json_round_trip_activate_and_reset( assert ( config_manager.main( - ["profiles", "activate", "4", "--identity", "1"] + ["profiles", "activate", "8", "--identity", "1"] ) == 0 ) - assert device.active_profiles[device.stable_identity.to_bytes()] == 3 + assert device.active_profiles[device.stable_identity.to_bytes()] == 7 _ = capsys.readouterr() before_reset_requests = len(device.requests) diff --git a/tests/test_profile_web.py b/tests/test_profile_web.py index 04dfca7..41719d3 100644 --- a/tests/test_profile_web.py +++ b/tests/test_profile_web.py @@ -87,6 +87,13 @@ def test_editor_serves_assets_and_complete_schema( assert schema["rumble_policies"] == list(config_manager.RUMBLE_POLICIES) assert schema["turbo_modes"] == list(config_manager.TURBO_MODES) assert schema["macro_overrides"] == list(config_manager.MACRO_OVERRIDE_NAMES) + assert schema["profile_capacity"] == 8 + assert schema["control_labels"]["generic"]["south"] == "A" + assert schema["control_labels"]["xbox"]["left_shoulder"] == "LB" + assert schema["control_labels"]["switch"]["east"] == "A" + assert schema["control_labels"]["switch"]["left_trigger"] == "ZL" + assert schema["control_labels"]["playstation"]["south"] == "Cross" + assert schema["control_labels"]["playstation"]["select"] == "Create" assert ( config_manager.ControllerProfile.from_json( json.dumps(schema["default_profile"]) @@ -152,13 +159,13 @@ def test_editor_reads_writes_and_activates_profiles_atomically( "style": "xbox", } - status, selected = request_json(f"{base_url}/api/profiles/1/3") + status, selected = request_json(f"{base_url}/api/profiles/1/8") assert status == 200 assert selected["active"] is False profile = custom_profile().to_json_object() status, stored = request_json( - f"{base_url}/api/profiles/1/3", + f"{base_url}/api/profiles/1/8", method="PUT", value=profile, token=token, @@ -167,21 +174,21 @@ def test_editor_reads_writes_and_activates_profiles_atomically( assert stored["stored_generation"] == 8 assert ( config_manager.ControllerProfile.from_bytes( - device.profiles[(device.stable_identity.to_bytes(), 2)] + device.profiles[(device.stable_identity.to_bytes(), 7)] ) == custom_profile() ) assert device.profile_chunk_sizes == [40, 40, 40, 40, 40, 40, 16] status, activated = request_json( - f"{base_url}/api/profiles/1/3/activate", + f"{base_url}/api/profiles/1/8/activate", method="POST", token=token, ) assert status == 200 assert activated["stored_generation"] == 9 - assert device.active_profiles[device.stable_identity.to_bytes()] == 2 + assert device.active_profiles[device.stable_identity.to_bytes()] == 7 def test_editor_rejects_invalid_or_unauthorized_mutations(