Add indexed eight-profile catalog

This commit is contained in:
Joey Yakimowich-Payne 2026-09-04 17:50:09 -06:00
commit 541ca49201
18 changed files with 2033 additions and 1970 deletions

View file

@ -323,7 +323,7 @@ Core completion evidence:
### Phase 3 — Mapping, tuning, profiles, and macros — Complete ### 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: Button mapping:
@ -380,7 +380,7 @@ Turbo behavior:
Profile switching: Profile switching:
- configurable controller chord - configurable controller chord
- one to four rumble pulses - one to eight rumble pulses
- matching onboard LED count - matching onboard LED count
- controller RGB/player LED feedback when supported - controller RGB/player LED feedback when supported
@ -394,12 +394,13 @@ Acceptance:
Completion evidence: 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 profiles for the global fallback and each of sixteen stable identities
- profile and adapter stores remain separate from each other and BTstack bonds; - profile and adapter stores remain separate from each other and BTstack bonds;
profile commits use one flash-safe batched inactive-bank replacement profile commits append one flash-safe record and compact atomically between
- schema-v1 profile databases migrate inherited trigger defaults to schema v2 two 128 KiB arenas
without losing custom thresholds, identities, active profiles, or other data - legacy fixed profile databases migrate without losing custom thresholds,
identities, active profiles, or other data
- direct mapping, stick/trigger fixed-point transforms, Switch thresholds, - direct mapping, stick/trigger fixed-point transforms, Switch thresholds,
XInput analog values, rumble scaling, macros, Turbo, Auto Burst, and all XInput analog values, rumble scaling, macros, Turbo, Auto Burst, and all
cancellation paths have deterministic native coverage 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. 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: Measure:

View file

@ -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. 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. `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 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. - Chord buttons are consumed locally and are not forwarded to the host.
- The new profile applies only after its atomic flash commit completes. - 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. - 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. - 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. - 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. 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. 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 ## References
- GP2040-CE (controller firmware ecosystem): https://github.com/OpenStickCommunity/GP2040-CE - GP2040-CE (controller firmware ecosystem): https://github.com/OpenStickCommunity/GP2040-CE
- nxbt (Switch controller research/tools): https://github.com/Brikwerk/nxbt - nxbt (Switch controller research/tools): https://github.com/Brikwerk/nxbt

View file

@ -52,6 +52,10 @@ constexpr SwitchRgbColor kProfileLightbarPalette[CONTROLLER_PROFILE_COUNT] = {
{0x00, 0xcc, 0x66}, {0x00, 0xcc, 0x66},
{0xff, 0xaa, 0x00}, {0xff, 0xaa, 0x00},
{0xcc, 0x33, 0xff}, {0xcc, 0x33, 0xff},
{0xff, 0x44, 0x44},
{0x00, 0xdd, 0xdd},
{0xff, 0x66, 0xbb},
{0xcc, 0xff, 0x33},
}; };
constexpr bool kDefaultMotionEnabled = constexpr bool kDefaultMotionEnabled =
SWITCH_MOTION_DEFAULT_ENABLED != 0; SWITCH_MOTION_DEFAULT_ENABLED != 0;
@ -69,7 +73,7 @@ constexpr uint8_t kMotionEnabledFeedbackStrongMagnitude =
SWITCH_MOTION_ENABLED_FEEDBACK_STRONG_MAGNITUDE; SWITCH_MOTION_ENABLED_FEEDBACK_STRONG_MAGNITUDE;
static_assert(kProfileFeedbackPhaseDurationMs == 75); static_assert(kProfileFeedbackPhaseDurationMs == 75);
static_assert(CONTROLLER_PROFILE_COUNT == 4); static_assert(CONTROLLER_PROFILE_COUNT == 8);
static_assert(kMotionDisabledFeedbackDurationMs > 0); static_assert(kMotionDisabledFeedbackDurationMs > 0);
static_assert(kMotionEnabledFeedbackDurationMs > 0); static_assert(kMotionEnabledFeedbackDurationMs > 0);

View file

@ -1,4 +1,5 @@
#include "platform/pico/pico_profile_storage.h" #include "platform/pico/pico_profile_storage.h"
#include <string.h> #include <string.h>
#include "configuration/configuration_storage.h" #include "configuration/configuration_storage.h"
@ -19,75 +20,35 @@ constexpr uint32_t kProfileStorageOffset =
kConfigurationStorageOffset - PROFILE_STORAGE_TOTAL_SIZE; kConfigurationStorageOffset - PROFILE_STORAGE_TOTAL_SIZE;
constexpr uint32_t kFlashSafeExecuteTimeoutMs = 5000; constexpr uint32_t kFlashSafeExecuteTimeoutMs = 5000;
static_assert(FLASH_SECTOR_SIZE == PROFILE_STORAGE_SECTOR_SIZE, 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);
static_assert(FLASH_PAGE_SIZE == PROFILE_STORAGE_PAGE_SIZE,
"profile storage page size does not match Pico flash");
static_assert(PICO_FLASH_BANK_STORAGE_OFFSET >= static_assert(PICO_FLASH_BANK_STORAGE_OFFSET >=
kConfigurationStorageSize + PROFILE_STORAGE_TOTAL_SIZE, kConfigurationStorageSize + PROFILE_STORAGE_TOTAL_SIZE);
"profile storage offset underflows flash");
static_assert(kProfileStorageOffset + PROFILE_STORAGE_TOTAL_SIZE <= static_assert(kProfileStorageOffset + PROFILE_STORAGE_TOTAL_SIZE <=
kConfigurationStorageOffset, kConfigurationStorageOffset);
"profile storage overlaps adapter configuration storage");
static_assert(kConfigurationStorageOffset + kConfigurationStorageSize <= static_assert(kConfigurationStorageOffset + kConfigurationStorageSize <=
PICO_FLASH_BANK_STORAGE_OFFSET, PICO_FLASH_BANK_STORAGE_OFFSET);
"adapter configuration storage overlaps BTstack bonds"); static_assert(PICO_FLASH_BANK_STORAGE_OFFSET + PICO_FLASH_BANK_TOTAL_SIZE <=
static_assert(PICO_FLASH_BANK_STORAGE_OFFSET + PICO_FLASH_SIZE_BYTES);
PICO_FLASH_BANK_TOTAL_SIZE <=
PICO_FLASH_SIZE_BYTES,
"BTstack storage exceeds flash");
struct FlashBankReplacement { struct EraseOperation {
uint8_t bank; uint32_t offset;
const uint8_t* payload;
size_t payload_size;
const uint8_t* header;
bool replaced;
}; };
void perform_flash_bank_replacement(void* context) { struct ProgramOperation {
auto* replacement = uint32_t offset;
static_cast<FlashBankReplacement*>(context); const uint8_t *page;
replacement->replaced = false; };
const uint32_t bank_offset =
kProfileStorageOffset +
replacement->bank * PROFILE_STORAGE_BANK_SIZE;
for (size_t offset = 0; offset < PROFILE_STORAGE_BANK_SIZE; void perform_erase(void *context) {
offset += FLASH_SECTOR_SIZE) { const auto *operation = static_cast<const EraseOperation *>(context);
flash_range_erase(bank_offset + offset, FLASH_SECTOR_SIZE); flash_range_erase(operation->offset, PROFILE_STORAGE_ARENA_SIZE);
} }
uint8_t final_page[FLASH_PAGE_SIZE]{}; void perform_program(void *context) {
for (size_t offset = 0; offset < replacement->payload_size; const auto *operation = static_cast<const ProgramOperation *>(context);
offset += FLASH_PAGE_SIZE) { flash_range_program(operation->offset, operation->page,
const size_t remaining = PROFILE_STORAGE_PAGE_SIZE);
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<const uint8_t*>(
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<const uint8_t*>(
XIP_BASE + bank_offset);
replacement->replaced =
memcmp(stored_header, replacement->header,
PROFILE_STORAGE_RECORD_HEADER_SIZE) == 0;
} }
bool storage_region_available() { bool storage_region_available() {
@ -96,55 +57,68 @@ bool storage_region_available() {
return binary_end <= kProfileStorageOffset; 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) { size_t size) {
if (bank >= PROFILE_STORAGE_BANK_COUNT || output == nullptr || if (arena >= PROFILE_STORAGE_ARENA_COUNT || output == nullptr ||
offset > PROFILE_STORAGE_BANK_SIZE || offset > PROFILE_STORAGE_ARENA_SIZE ||
size > PROFILE_STORAGE_BANK_SIZE - offset || size > PROFILE_STORAGE_ARENA_SIZE - offset ||
!storage_region_available()) { !storage_region_available()) {
return false; return false;
} }
const uintptr_t address = const uintptr_t address = XIP_BASE + kProfileStorageOffset +
XIP_BASE + kProfileStorageOffset + arena * PROFILE_STORAGE_ARENA_SIZE + offset;
bank * PROFILE_STORAGE_BANK_SIZE + offset; memcpy(output, reinterpret_cast<const void *>(address), size);
memcpy(output, reinterpret_cast<const void*>(address), size);
return true; return true;
} }
bool replace_storage_bank(void*, uint8_t bank, bool erase_arena(void *, uint8_t arena) {
const uint8_t* payload, if (arena >= PROFILE_STORAGE_ARENA_COUNT || !storage_region_available()) {
size_t payload_size, return false;
const uint8_t* header, }
size_t header_size) { EraseOperation operation{
if (bank >= PROFILE_STORAGE_BANK_COUNT || payload == nullptr || kProfileStorageOffset + arena * PROFILE_STORAGE_ARENA_SIZE,
header == nullptr || };
payload_size != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || if (flash_safe_execute(perform_erase, &operation,
header_size != PROFILE_STORAGE_RECORD_HEADER_SIZE || kFlashSafeExecuteTimeoutMs) != PICO_OK) {
return false;
}
const auto *stored =
reinterpret_cast<const uint8_t *>(XIP_BASE + operation.offset);
for (size_t offset = 0; offset < PROFILE_STORAGE_ARENA_SIZE; ++offset) {
if (stored[offset] != 0xff) {
return false;
}
}
return true;
}
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()) { !storage_region_available()) {
return false; return false;
} }
FlashBankReplacement replacement{ ProgramOperation operation{
bank, kProfileStorageOffset + arena * PROFILE_STORAGE_ARENA_SIZE + offset,
payload, page,
payload_size,
header,
false,
}; };
return flash_safe_execute( return flash_safe_execute(perform_program, &operation,
perform_flash_bank_replacement, &replacement,
kFlashSafeExecuteTimeoutMs) == PICO_OK && kFlashSafeExecuteTimeoutMs) == PICO_OK &&
replacement.replaced; memcmp(reinterpret_cast<const void *>(XIP_BASE + operation.offset),
page, size) == 0;
} }
} // namespace } // namespace
ProfileStorageIo pico_profile_storage_io() { ProfileStorageIo pico_profile_storage_io() {
return { return {
nullptr, nullptr, PROFILE_STORAGE_ARENA_SIZE,
PROFILE_STORAGE_BANK_SIZE, FLASH_SECTOR_SIZE, FLASH_PAGE_SIZE,
FLASH_SECTOR_SIZE, read_storage, erase_arena,
FLASH_PAGE_SIZE, program_page,
read_storage,
replace_storage_bank,
}; };
} }

View file

@ -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_ACTION_CONTROL_SCHEMA_VERSION = 4;
constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 5; constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 5;
constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 256; 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_BUTTON_COUNT = 16;
constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT = 18; constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT = 18;
constexpr uint8_t CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL = 16; 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_STABLE_IDENTITY_CAPACITY *
CONTROLLER_PROFILE_DATABASE_ENTRY_SIZE; 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"); "profile database wire size changed");
enum class ControllerProfileLogicalButton : uint8_t { enum class ControllerProfileLogicalButton : uint8_t {
kSouth = 0, kSouth = 0,

View file

@ -39,24 +39,19 @@ struct PublishedActiveProfile {
critical_section_t g_lock; critical_section_t g_lock;
bool g_prepared = false; bool g_prepared = false;
ProfileStorage g_storage; ProfileStorage g_storage;
ControllerProfileDatabase g_database;
ProfileServiceMetadata g_metadata; ProfileServiceMetadata g_metadata;
uint32_t g_published_generation = 0; uint32_t g_published_generation = 0;
ProfileServiceListSnapshot g_list; ProfileServiceListSnapshot g_list;
ProfileServiceSelectedSnapshot g_selected; ProfileServiceSelectedSnapshot g_selected;
PublishedActiveProfile PublishedActiveProfile g_active_profiles[PROFILE_SERVICE_LIST_CAPACITY]{};
g_active_profiles[PROFILE_SERVICE_LIST_CAPACITY]{};
uint8_t g_active_profile_count = 0; uint8_t g_active_profile_count = 0;
ProfileTransaction g_transaction; ProfileTransaction g_transaction;
PendingCommand g_command; PendingCommand g_command;
PendingCommand g_internal_activation; 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; bool g_has_committed = false;
uint32_t g_last_commit_ms = 0; uint32_t g_last_commit_ms = 0;
bool valid_identity(const ControllerIdentity& identity) { bool valid_identity(const ControllerIdentity &identity) {
uint8_t encoded[CONTROLLER_IDENTITY_ENCODED_SIZE]{}; uint8_t encoded[CONTROLLER_IDENTITY_ENCODED_SIZE]{};
return (controller_identity_is_global(identity) || identity.stable) && return (controller_identity_is_global(identity) || identity.stable) &&
controller_identity_encode(identity, encoded, sizeof(encoded)); controller_identity_encode(identity, encoded, sizeof(encoded));
@ -65,56 +60,54 @@ bool valid_identity(const ControllerIdentity& identity) {
void refresh_list_locked() { void refresh_list_locked() {
g_list = ProfileServiceListSnapshot{}; g_list = ProfileServiceListSnapshot{};
g_list.metadata = g_metadata; g_list.metadata = g_metadata;
g_list.count = 1; for (uint8_t index = 0; index < g_storage.identity_count() &&
g_list.rows[0].identity = controller_identity_global(); g_list.count < PROFILE_SERVICE_LIST_CAPACITY;
g_list.rows[0].active_profile = ++index) {
g_database.fallback_active_profile; const ProfileStorageIdentityIndex *entry = g_storage.identity(index);
for (const ControllerProfileDatabaseEntry& entry : g_database.entries) { if (entry == nullptr || !entry->used) {
if (!entry.used || g_list.count >= PROFILE_SERVICE_LIST_CAPACITY) {
continue; continue;
} }
ProfileServiceListRow& row = g_list.rows[g_list.count++]; ProfileServiceListRow &row = g_list.rows[g_list.count++];
row.identity = entry.identity; row.identity = entry->identity;
row.active_profile = entry.active_profile; row.active_profile = entry->active_profile;
} }
} }
void refresh_active_profiles_locked() { void refresh_active_profiles_locked() {
g_active_profile_count = 1; g_active_profile_count = 0;
g_active_profiles[0].identity = controller_identity_global(); for (uint8_t index = 0;
g_active_profiles[0].profile_index = index < g_storage.identity_count() &&
g_database.fallback_active_profile; g_active_profile_count < PROFILE_SERVICE_LIST_CAPACITY;
g_active_profiles[0].profile = ++index) {
g_database.fallback_profiles[g_database.fallback_active_profile]; const ProfileStorageIdentityIndex *entry = g_storage.identity(index);
for (const ControllerProfileDatabaseEntry& entry : g_database.entries) { if (entry == nullptr || !entry->used) {
if (!entry.used ||
g_active_profile_count >= PROFILE_SERVICE_LIST_CAPACITY) {
continue; continue;
} }
PublishedActiveProfile& active = PublishedActiveProfile &active = g_active_profiles[g_active_profile_count];
g_active_profiles[g_active_profile_count++]; active.identity = entry->identity;
active.identity = entry.identity; active.profile_index = entry->active_profile;
active.profile_index = entry.active_profile; if (g_storage.get(entry->identity, entry->active_profile,
active.profile = entry.profiles[entry.active_profile]; &active.profile) != ProfileStorageResult::kOk) {
continue;
}
++g_active_profile_count;
} }
} }
void refresh_selected_locked() { void refresh_selected_locked() {
g_selected.metadata = g_metadata; g_selected.metadata = g_metadata;
const ControllerProfile* profile = controller_profile_database_get( if (g_storage.get(g_selected.identity, g_selected.profile_index,
g_database, g_selected.identity, g_selected.profile_index); &g_selected.profile) != ProfileStorageResult::kOk) {
if (profile == nullptr) {
g_selected.valid = false; g_selected.valid = false;
g_selected.status = ConfigurationTransactionStatus::kMalformed; g_selected.status = ConfigurationTransactionStatus::kMalformed;
return; return;
} }
g_selected.profile = *profile;
g_selected.valid = true; g_selected.valid = true;
g_selected.status = ConfigurationTransactionStatus::kCommitted; g_selected.status = ConfigurationTransactionStatus::kCommitted;
} }
void refresh_metadata_locked(ProfileServiceState state) { void refresh_metadata_locked(ProfileServiceState state) {
const ProfileStorageSnapshot& stored = g_storage.snapshot(); const ProfileStorageSnapshot &stored = g_storage.snapshot();
g_metadata.state = state; g_metadata.state = state;
g_metadata.generation = stored.valid ? stored.generation : 0; g_metadata.generation = stored.valid ? stored.generation : 0;
g_metadata.payload_crc = stored.valid ? stored.payload_crc : 0; g_metadata.payload_crc = stored.valid ? stored.payload_crc : 0;
@ -125,50 +118,51 @@ void refresh_metadata_locked(ProfileServiceState state) {
__ATOMIC_RELEASE); __ATOMIC_RELEASE);
} }
ConfigurationTransactionStatus database_result_status( ConfigurationTransactionStatus
ControllerProfileDatabaseResult result) { storage_result_status(ProfileStorageResult result) {
switch (result) { switch (result) {
case ControllerProfileDatabaseResult::kOk: case ProfileStorageResult::kOk:
return ConfigurationTransactionStatus::kPending; return ConfigurationTransactionStatus::kCommitted;
case ControllerProfileDatabaseResult::kFull: case ProfileStorageResult::kUnchanged:
return ConfigurationTransactionStatus::kTooLarge; return ConfigurationTransactionStatus::kUnchanged;
case ControllerProfileDatabaseResult::kInvalidArgument: case ProfileStorageResult::kInvalidArgument:
return ConfigurationTransactionStatus::kMalformed; return ConfigurationTransactionStatus::kMalformed;
case ProfileStorageResult::kFull:
return ConfigurationTransactionStatus::kTooLarge;
case ProfileStorageResult::kIoError:
return ConfigurationTransactionStatus::kStorageError;
} }
return ConfigurationTransactionStatus::kStorageError; return ConfigurationTransactionStatus::kStorageError;
} }
bool mutation_ready(uint32_t now_ms) { bool mutation_ready(uint32_t now_ms) {
return !g_has_committed || return !g_has_committed || static_cast<uint32_t>(now_ms - g_last_commit_ms) >=
static_cast<uint32_t>(now_ms - g_last_commit_ms) >=
kMinimumCommitIntervalMs; kMinimumCommitIntervalMs;
} }
void finish_mutation(ConfigurationTransactionStatus status, void finish_mutation(ConfigurationTransactionStatus status,
bool clear_command) { bool clear_command) {
const ProfileStorageSnapshot& stored = g_storage.snapshot();
critical_section_enter_blocking(&g_lock); critical_section_enter_blocking(&g_lock);
g_transaction.snapshot.status = status; g_transaction.snapshot.status = status;
g_transaction.snapshot.stored_generation = g_transaction.snapshot.stored_generation =
stored.valid ? stored.generation : 0; g_storage.snapshot().valid ? g_storage.snapshot().generation : 0;
g_transaction.snapshot.stored_crc = g_transaction.snapshot.stored_crc =
stored.valid ? stored.payload_crc : 0; g_storage.snapshot().valid ? g_storage.snapshot().payload_crc : 0;
if (clear_command) { if (clear_command) {
g_command = {}; g_command = {};
} }
refresh_metadata_locked( refresh_metadata_locked(status ==
status == ConfigurationTransactionStatus::kStorageError ConfigurationTransactionStatus::kStorageError
? ProfileServiceState::kStorageError ? ProfileServiceState::kStorageError
: ProfileServiceState::kReady); : ProfileServiceState::kReady);
critical_section_exit(&g_lock); critical_section_exit(&g_lock);
} }
void finish_internal_activation( void finish_internal_activation(ConfigurationTransactionStatus status) {
ConfigurationTransactionStatus status) {
critical_section_enter_blocking(&g_lock); critical_section_enter_blocking(&g_lock);
g_internal_activation = {}; g_internal_activation = {};
refresh_metadata_locked( refresh_metadata_locked(status ==
status == ConfigurationTransactionStatus::kStorageError ConfigurationTransactionStatus::kStorageError
? ProfileServiceState::kStorageError ? ProfileServiceState::kStorageError
: ProfileServiceState::kReady); : ProfileServiceState::kReady);
critical_section_exit(&g_lock); critical_section_exit(&g_lock);
@ -192,7 +186,6 @@ void profile_service_prepare() {
g_transaction = {}; g_transaction = {};
g_command = {}; g_command = {};
g_internal_activation = {}; g_internal_activation = {};
g_identity_dirty = false;
g_has_committed = false; g_has_committed = false;
g_last_commit_ms = 0; g_last_commit_ms = 0;
g_prepared = true; g_prepared = true;
@ -202,8 +195,7 @@ void profile_service_initialize_on_storage_core() {
if (!g_prepared) { if (!g_prepared) {
profile_service_prepare(); profile_service_prepare();
} }
const bool initialized = const bool initialized = g_storage.initialize(pico_profile_storage_io());
g_storage.initialize(pico_profile_storage_io(), &g_database);
critical_section_enter_blocking(&g_lock); critical_section_enter_blocking(&g_lock);
refresh_metadata_locked(initialized ? ProfileServiceState::kReady refresh_metadata_locked(initialized ? ProfileServiceState::kReady
: ProfileServiceState::kStorageError); : ProfileServiceState::kStorageError);
@ -211,39 +203,32 @@ void profile_service_initialize_on_storage_core() {
} }
bool profile_service_observe_identity_on_storage_core( bool profile_service_observe_identity_on_storage_core(
const ControllerIdentity& identity) { const ControllerIdentity &identity) {
if (!g_prepared || !identity.stable || if (!g_prepared || !identity.stable ||
controller_identity_is_global(identity) || controller_identity_is_global(identity) || !valid_identity(identity)) {
!valid_identity(identity)) {
return false; return false;
} }
critical_section_enter_blocking(&g_lock); critical_section_enter_blocking(&g_lock);
if (g_metadata.state != ProfileServiceState::kReady) { const bool ready = g_metadata.state == ProfileServiceState::kReady;
critical_section_exit(&g_lock); critical_section_exit(&g_lock);
if (!ready) {
return false; return false;
} }
ControllerProfileDatabaseEntry* entry = const ProfileStorageResult result = g_storage.ensure_identity(identity);
controller_profile_database_find(&g_database, identity); if (result != ProfileStorageResult::kOk &&
if (entry != nullptr) { result != ProfileStorageResult::kUnchanged) {
return false;
}
critical_section_enter_blocking(&g_lock);
refresh_metadata_locked(ProfileServiceState::kReady);
critical_section_exit(&g_lock); critical_section_exit(&g_lock);
return true; 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;
} }
void profile_service_task_on_storage_core(uint32_t now_ms) { void profile_service_task_on_storage_core(uint32_t now_ms) {
PendingCommand command{}; PendingCommand command{};
bool process_write = false; bool process_write = false;
bool process_internal_activation = false; bool process_internal_activation = false;
bool process_identity = false;
ControllerIdentity write_identity{}; ControllerIdentity write_identity{};
uint8_t write_profile_index = 0; uint8_t write_profile_index = 0;
uint8_t write_payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; uint8_t write_payload[CONTROLLER_PROFILE_ENCODED_SIZE]{};
@ -257,92 +242,40 @@ void profile_service_task_on_storage_core(uint32_t now_ms) {
process_write = true; process_write = true;
write_identity = g_transaction.identity; write_identity = g_transaction.identity;
write_profile_index = g_transaction.profile_index; write_profile_index = g_transaction.profile_index;
memcpy(write_payload, g_transaction.payload, memcpy(write_payload, g_transaction.payload, sizeof(write_payload));
sizeof(write_payload)); } else if (g_internal_activation.type != PendingCommandType::kNone) {
} else if (g_internal_activation.type !=
PendingCommandType::kNone) {
command = g_internal_activation; command = g_internal_activation;
process_internal_activation = true; process_internal_activation = true;
} else if (g_identity_dirty) {
process_identity = true;
} }
} }
critical_section_exit(&g_lock); critical_section_exit(&g_lock);
if (!process_write && !process_identity && if (!process_write && !process_internal_activation &&
!process_internal_activation &&
command.type == PendingCommandType::kNone) { command.type == PendingCommandType::kNone) {
return; return;
} }
ControllerProfileDatabaseResult database_result = ProfileStorageResult storage_result = ProfileStorageResult::kInvalidArgument;
ControllerProfileDatabaseResult::kInvalidArgument; if (process_write) {
if (process_identity) {
database_result = ControllerProfileDatabaseResult::kOk;
} else if (process_write) {
ControllerProfile profile{}; ControllerProfile profile{};
if (controller_profile_decode(write_payload, sizeof(write_payload), if (controller_profile_decode(write_payload, sizeof(write_payload),
&profile)) { &profile)) {
critical_section_enter_blocking(&g_lock); storage_result =
database_result = controller_profile_database_set( g_storage.set(write_identity, write_profile_index, profile);
&g_database, write_identity, write_profile_index, profile);
critical_section_exit(&g_lock);
} }
} else if (command.type == PendingCommandType::kReset) { } else if (command.type == PendingCommandType::kReset) {
critical_section_enter_blocking(&g_lock); storage_result = g_storage.reset(command.identity, command.profile_index);
database_result = controller_profile_database_reset(
&g_database, command.identity, command.profile_index);
critical_section_exit(&g_lock);
} else if (command.type == PendingCommandType::kActivate) { } else if (command.type == PendingCommandType::kActivate) {
critical_section_enter_blocking(&g_lock); storage_result =
database_result = controller_profile_database_activate( g_storage.activate(command.identity, command.profile_index);
&g_database, command.identity, command.profile_index);
critical_section_exit(&g_lock);
} }
if (database_result != ControllerProfileDatabaseResult::kOk) { const ConfigurationTransactionStatus status =
const ConfigurationTransactionStatus error_status = storage_result_status(storage_result);
database_result_status(database_result); if (status == ConfigurationTransactionStatus::kCommitted) {
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_has_committed = true;
g_last_commit_ms = now_ms; 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) { if (process_internal_activation) {
finish_internal_activation(status); finish_internal_activation(status);
} else { } else {
@ -350,16 +283,16 @@ void profile_service_task_on_storage_core(uint32_t now_ms) {
} }
} }
ConfigurationTransactionStatus profile_service_select( ConfigurationTransactionStatus
const ControllerIdentity& identity, uint8_t profile_index) { profile_service_select(const ControllerIdentity &identity,
uint8_t profile_index) {
if (!g_prepared) { if (!g_prepared) {
profile_service_prepare(); profile_service_prepare();
} }
critical_section_enter_blocking(&g_lock); critical_section_enter_blocking(&g_lock);
ConfigurationTransactionStatus status = ConfigurationTransactionStatus status =
ConfigurationTransactionStatus::kCommitted; ConfigurationTransactionStatus::kCommitted;
if (!valid_identity(identity) || if (!valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) {
profile_index >= CONTROLLER_PROFILE_COUNT) {
status = ConfigurationTransactionStatus::kMalformed; status = ConfigurationTransactionStatus::kMalformed;
g_selected.metadata = g_metadata; g_selected.metadata = g_metadata;
g_selected.identity = identity; g_selected.identity = identity;
@ -385,9 +318,10 @@ ConfigurationTransactionStatus profile_service_select(
return status; return status;
} }
ConfigurationTransactionStatus profile_service_begin( ConfigurationTransactionStatus
uint32_t transaction_id, const ControllerIdentity& identity, profile_service_begin(uint32_t transaction_id,
uint8_t profile_index, uint16_t schema_version, size_t payload_size, const ControllerIdentity &identity, uint8_t profile_index,
uint16_t schema_version, size_t payload_size,
uint32_t payload_crc) { uint32_t payload_crc) {
if (!g_prepared) { if (!g_prepared) {
profile_service_prepare(); profile_service_prepare();
@ -408,34 +342,29 @@ ConfigurationTransactionStatus profile_service_begin(
g_transaction.profile_index = profile_index; g_transaction.profile_index = profile_index;
if (transaction_id == 0 || if (transaction_id == 0 ||
(transaction_id & kInternalTransactionIdMask) != 0 || (transaction_id & kInternalTransactionIdMask) != 0 ||
!valid_identity(identity) || !valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT ||
profile_index >= CONTROLLER_PROFILE_COUNT || payload_size == 0) { payload_size == 0) {
g_transaction.snapshot.status = g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed;
ConfigurationTransactionStatus::kMalformed;
} else if (schema_version != CONTROLLER_PROFILE_SCHEMA_VERSION) { } else if (schema_version != CONTROLLER_PROFILE_SCHEMA_VERSION) {
g_transaction.snapshot.status = g_transaction.snapshot.status =
ConfigurationTransactionStatus::kUnsupportedSchema; ConfigurationTransactionStatus::kUnsupportedSchema;
} else if (payload_size > CONTROLLER_PROFILE_ENCODED_SIZE) { } else if (payload_size > CONTROLLER_PROFILE_ENCODED_SIZE) {
g_transaction.snapshot.status = g_transaction.snapshot.status = ConfigurationTransactionStatus::kTooLarge;
ConfigurationTransactionStatus::kTooLarge;
} else if (payload_size != CONTROLLER_PROFILE_ENCODED_SIZE) { } else if (payload_size != CONTROLLER_PROFILE_ENCODED_SIZE) {
g_transaction.snapshot.status = g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed;
ConfigurationTransactionStatus::kMalformed;
} else { } else {
g_transaction.snapshot.expected_size = g_transaction.snapshot.expected_size = static_cast<uint16_t>(payload_size);
static_cast<uint16_t>(payload_size);
g_transaction.snapshot.expected_crc = payload_crc; g_transaction.snapshot.expected_crc = payload_crc;
g_transaction.snapshot.status = g_transaction.snapshot.status = ConfigurationTransactionStatus::kReceiving;
ConfigurationTransactionStatus::kReceiving;
} }
const ConfigurationTransactionStatus status = const ConfigurationTransactionStatus status = g_transaction.snapshot.status;
g_transaction.snapshot.status;
critical_section_exit(&g_lock); critical_section_exit(&g_lock);
return status; return status;
} }
ConfigurationTransactionStatus profile_service_append( ConfigurationTransactionStatus profile_service_append(uint32_t transaction_id,
uint32_t transaction_id, size_t offset, const uint8_t* data, size_t offset,
const uint8_t *data,
size_t size) { size_t size) {
if ((transaction_id & kInternalTransactionIdMask) != 0) { if ((transaction_id & kInternalTransactionIdMask) != 0) {
return ConfigurationTransactionStatus::kMalformed; return ConfigurationTransactionStatus::kMalformed;
@ -451,21 +380,17 @@ ConfigurationTransactionStatus profile_service_append(
offset != g_transaction.snapshot.received_size || offset != g_transaction.snapshot.received_size ||
offset > g_transaction.snapshot.expected_size || offset > g_transaction.snapshot.expected_size ||
size > g_transaction.snapshot.expected_size - offset) { size > g_transaction.snapshot.expected_size - offset) {
g_transaction.snapshot.status = g_transaction.snapshot.status = ConfigurationTransactionStatus::kOutOfOrder;
ConfigurationTransactionStatus::kOutOfOrder;
} else { } else {
memcpy(&g_transaction.payload[offset], data, size); memcpy(&g_transaction.payload[offset], data, size);
g_transaction.snapshot.received_size = g_transaction.snapshot.received_size = static_cast<uint16_t>(offset + size);
static_cast<uint16_t>(offset + size);
} }
const ConfigurationTransactionStatus status = const ConfigurationTransactionStatus status = g_transaction.snapshot.status;
g_transaction.snapshot.status;
critical_section_exit(&g_lock); critical_section_exit(&g_lock);
return status; return status;
} }
ConfigurationTransactionStatus profile_service_commit( ConfigurationTransactionStatus profile_service_commit(uint32_t transaction_id) {
uint32_t transaction_id) {
if ((transaction_id & kInternalTransactionIdMask) != 0) { if ((transaction_id & kInternalTransactionIdMask) != 0) {
return ConfigurationTransactionStatus::kMalformed; return ConfigurationTransactionStatus::kMalformed;
} }
@ -475,31 +400,28 @@ ConfigurationTransactionStatus profile_service_commit(
transaction_id != g_transaction.snapshot.transaction_id || transaction_id != g_transaction.snapshot.transaction_id ||
g_transaction.snapshot.received_size != g_transaction.snapshot.received_size !=
g_transaction.snapshot.expected_size) { g_transaction.snapshot.expected_size) {
g_transaction.snapshot.status = g_transaction.snapshot.status = ConfigurationTransactionStatus::kOutOfOrder;
ConfigurationTransactionStatus::kOutOfOrder; } else if (profile_storage_crc32(g_transaction.payload,
} else if (profile_storage_crc32(
g_transaction.payload,
g_transaction.snapshot.expected_size) != g_transaction.snapshot.expected_size) !=
g_transaction.snapshot.expected_crc) { g_transaction.snapshot.expected_crc) {
g_transaction.snapshot.status = g_transaction.snapshot.status = ConfigurationTransactionStatus::kBadCrc;
ConfigurationTransactionStatus::kBadCrc;
} else { } else {
ControllerProfile profile{}; ControllerProfile profile{};
g_transaction.snapshot.status = g_transaction.snapshot.status =
controller_profile_decode( controller_profile_decode(g_transaction.payload,
g_transaction.payload, g_transaction.snapshot.expected_size,
g_transaction.snapshot.expected_size, &profile) &profile)
? ConfigurationTransactionStatus::kPending ? ConfigurationTransactionStatus::kPending
: ConfigurationTransactionStatus::kMalformed; : ConfigurationTransactionStatus::kMalformed;
} }
const ConfigurationTransactionStatus status = const ConfigurationTransactionStatus status = g_transaction.snapshot.status;
g_transaction.snapshot.status;
critical_section_exit(&g_lock); critical_section_exit(&g_lock);
return status; return status;
} }
ConfigurationTransactionStatus profile_service_reset( ConfigurationTransactionStatus
uint32_t transaction_id, const ControllerIdentity& identity, profile_service_reset(uint32_t transaction_id,
const ControllerIdentity &identity,
uint8_t profile_index) { uint8_t profile_index) {
if (!g_prepared) { if (!g_prepared) {
profile_service_prepare(); profile_service_prepare();
@ -523,8 +445,7 @@ ConfigurationTransactionStatus profile_service_reset(
!valid_identity(identity) || !valid_identity(identity) ||
(profile_index != CONTROLLER_PROFILE_ALL && (profile_index != CONTROLLER_PROFILE_ALL &&
profile_index >= CONTROLLER_PROFILE_COUNT)) { profile_index >= CONTROLLER_PROFILE_COUNT)) {
g_transaction.snapshot.status = g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed;
ConfigurationTransactionStatus::kMalformed;
critical_section_exit(&g_lock); critical_section_exit(&g_lock);
return ConfigurationTransactionStatus::kMalformed; return ConfigurationTransactionStatus::kMalformed;
} }
@ -537,8 +458,9 @@ ConfigurationTransactionStatus profile_service_reset(
return ConfigurationTransactionStatus::kPending; return ConfigurationTransactionStatus::kPending;
} }
ConfigurationTransactionStatus profile_service_activate( ConfigurationTransactionStatus
uint32_t transaction_id, const ControllerIdentity& identity, profile_service_activate(uint32_t transaction_id,
const ControllerIdentity &identity,
uint8_t profile_index) { uint8_t profile_index) {
if (!g_prepared) { if (!g_prepared) {
profile_service_prepare(); profile_service_prepare();
@ -559,10 +481,8 @@ ConfigurationTransactionStatus profile_service_activate(
g_transaction.profile_index = profile_index; g_transaction.profile_index = profile_index;
if (transaction_id == 0 || if (transaction_id == 0 ||
(transaction_id & kInternalTransactionIdMask) != 0 || (transaction_id & kInternalTransactionIdMask) != 0 ||
!valid_identity(identity) || !valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) {
profile_index >= CONTROLLER_PROFILE_COUNT) { g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed;
g_transaction.snapshot.status =
ConfigurationTransactionStatus::kMalformed;
critical_section_exit(&g_lock); critical_section_exit(&g_lock);
return ConfigurationTransactionStatus::kMalformed; return ConfigurationTransactionStatus::kMalformed;
} }
@ -575,8 +495,9 @@ ConfigurationTransactionStatus profile_service_activate(
return ConfigurationTransactionStatus::kPending; return ConfigurationTransactionStatus::kPending;
} }
ConfigurationTransactionStatus profile_service_activate_internal( ConfigurationTransactionStatus
uint32_t transaction_id, const ControllerIdentity& identity, profile_service_activate_internal(uint32_t transaction_id,
const ControllerIdentity &identity,
uint8_t profile_index) { uint8_t profile_index) {
if (!g_prepared) { if (!g_prepared) {
profile_service_prepare(); profile_service_prepare();
@ -592,8 +513,7 @@ ConfigurationTransactionStatus profile_service_activate_internal(
return ConfigurationTransactionStatus::kBusy; return ConfigurationTransactionStatus::kBusy;
} }
if ((transaction_id & kInternalTransactionIdMask) == 0 || if ((transaction_id & kInternalTransactionIdMask) == 0 ||
!valid_identity(identity) || !valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) {
profile_index >= CONTROLLER_PROFILE_COUNT) {
critical_section_exit(&g_lock); critical_section_exit(&g_lock);
return ConfigurationTransactionStatus::kMalformed; return ConfigurationTransactionStatus::kMalformed;
} }
@ -605,7 +525,7 @@ ConfigurationTransactionStatus profile_service_activate_internal(
return ConfigurationTransactionStatus::kPending; return ConfigurationTransactionStatus::kPending;
} }
void profile_service_list_snapshot(ProfileServiceListSnapshot* output) { void profile_service_list_snapshot(ProfileServiceListSnapshot *output) {
if (output == nullptr) { if (output == nullptr) {
return; return;
} }
@ -614,8 +534,7 @@ void profile_service_list_snapshot(ProfileServiceListSnapshot* output) {
critical_section_exit(&g_lock); critical_section_exit(&g_lock);
} }
void profile_service_selected_snapshot( void profile_service_selected_snapshot(ProfileServiceSelectedSnapshot *output) {
ProfileServiceSelectedSnapshot* output) {
if (output == nullptr) { if (output == nullptr) {
return; return;
} }
@ -625,7 +544,7 @@ void profile_service_selected_snapshot(
} }
void profile_service_transaction_snapshot( void profile_service_transaction_snapshot(
ProfileServiceTransactionSnapshot* output) { ProfileServiceTransactionSnapshot *output) {
if (output == nullptr) { if (output == nullptr) {
return; return;
} }
@ -642,8 +561,8 @@ uint32_t profile_service_database_generation() {
} }
void profile_service_active_profile_snapshot( void profile_service_active_profile_snapshot(
const ControllerIdentity& identity, const ControllerIdentity &identity,
ProfileServiceActiveProfileSnapshot* output) { ProfileServiceActiveProfileSnapshot *output) {
if (output == nullptr) { if (output == nullptr) {
return; return;
} }
@ -656,10 +575,10 @@ void profile_service_active_profile_snapshot(
output->metadata = g_metadata; output->metadata = g_metadata;
if (g_metadata.state == ProfileServiceState::kReady && if (g_metadata.state == ProfileServiceState::kReady &&
g_active_profile_count != 0) { g_active_profile_count != 0) {
const PublishedActiveProfile* active = &g_active_profiles[0]; const PublishedActiveProfile *active = &g_active_profiles[0];
for (uint8_t index = 1; index < g_active_profile_count; ++index) { for (uint8_t index = 1; index < g_active_profile_count; ++index) {
if (controller_identity_equal( if (controller_identity_equal(g_active_profiles[index].identity,
g_active_profiles[index].identity, identity)) { identity)) {
active = &g_active_profiles[index]; active = &g_active_profiles[index];
break; break;
} }

View file

@ -1,45 +1,48 @@
#include "profile/profile_storage.h" #include "profile/profile_storage.h"
#include <string.h> #include <string.h>
namespace { namespace {
constexpr uint8_t kRecordMagic[4] = {'S', 'P', 'P', 'F'}; constexpr uint8_t kSuperblockMagic[4] = {'S', 'P', 'C', 'A'};
constexpr uint16_t kRecordFormatVersion = 1; constexpr uint8_t kRecordMagic[4] = {'S', 'P', 'C', 'R'};
constexpr size_t kHeaderFieldsSize = 24; constexpr uint8_t kLegacyStorageMagic[4] = {'S', 'P', 'P', 'F'};
constexpr size_t kHeaderCrcOffset = 20; 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) { uint16_t read_u16(const uint8_t *input) {
return static_cast<uint16_t>(input[0]) | return static_cast<uint16_t>(input[0]) | static_cast<uint16_t>(input[1] << 8);
(static_cast<uint16_t>(input[1]) << 8);
} }
uint32_t storage_read_u32(const uint8_t* input) { uint32_t read_u32(const uint8_t *input) {
return static_cast<uint32_t>(input[0]) | return static_cast<uint32_t>(input[0]) |
(static_cast<uint32_t>(input[1]) << 8) | (static_cast<uint32_t>(input[1]) << 8) |
(static_cast<uint32_t>(input[2]) << 16) | (static_cast<uint32_t>(input[2]) << 16) |
(static_cast<uint32_t>(input[3]) << 24); (static_cast<uint32_t>(input[3]) << 24);
} }
void storage_write_u16(uint8_t* output, uint16_t value) { void write_u16(uint8_t *output, uint16_t value) {
output[0] = static_cast<uint8_t>(value); output[0] = static_cast<uint8_t>(value);
output[1] = static_cast<uint8_t>(value >> 8); output[1] = static_cast<uint8_t>(value >> 8);
} }
void storage_write_u32(uint8_t* output, uint32_t value) { void write_u32(uint8_t *output, uint32_t value) {
output[0] = static_cast<uint8_t>(value); output[0] = static_cast<uint8_t>(value);
output[1] = static_cast<uint8_t>(value >> 8); output[1] = static_cast<uint8_t>(value >> 8);
output[2] = static_cast<uint8_t>(value >> 16); output[2] = static_cast<uint8_t>(value >> 16);
output[3] = static_cast<uint8_t>(value >> 24); output[3] = static_cast<uint8_t>(value >> 24);
} }
uint32_t crc32_update(uint32_t crc, const uint8_t* data, size_t size) { uint32_t crc32_update(uint32_t crc, const uint8_t *data, size_t size) {
for (size_t index = 0; index < size; ++index) { for (size_t index = 0; index < size; ++index) {
crc ^= data[index]; crc ^= data[index];
for (uint8_t bit = 0; bit < 8; ++bit) { for (uint8_t bit = 0; bit < 8; ++bit) {
crc = (crc >> 1) ^ const uint32_t mask = 0u - (crc & 1u);
(0xedb88320u & crc = (crc >> 1) ^ (0xedb88320u & mask);
static_cast<uint32_t>(
-static_cast<int32_t>(crc & 1u)));
} }
} }
return crc; return crc;
@ -49,203 +52,711 @@ bool generation_is_newer(uint32_t candidate, uint32_t current) {
return static_cast<int32_t>(candidate - current) > 0; return static_cast<int32_t>(candidate - current) > 0;
} }
struct DatabaseReadContext { bool valid_identity(const ControllerIdentity &identity) {
const ProfileStorageIo* io; uint8_t encoded[CONTROLLER_IDENTITY_ENCODED_SIZE]{};
uint8_t bank; return (controller_identity_is_global(identity) || identity.stable) &&
}; controller_identity_encode(identity, encoded, sizeof(encoded));
}
bool read_database(void* context, size_t offset, uint8_t* output, uint32_t pack_record(uint8_t arena, size_t offset) {
size_t size) { return static_cast<uint32_t>(arena * PROFILE_STORAGE_ARENA_SIZE + offset);
const auto* read_context = }
static_cast<const DatabaseReadContext*>(context);
return read_context->io->read( uint8_t record_arena(uint32_t record) {
read_context->io->context, read_context->bank, return static_cast<uint8_t>(record / PROFILE_STORAGE_ARENA_SIZE);
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset, output, size); }
size_t record_offset(uint32_t record) {
return record % PROFILE_STORAGE_ARENA_SIZE;
}
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 } // namespace
uint32_t profile_storage_crc32(const uint8_t* data, size_t size) { uint32_t profile_storage_crc32(const uint8_t *data, size_t size) {
if (data == nullptr && size != 0) { if (data == nullptr && size != 0) {
return 0; return 0;
} }
return ~crc32_update(0xffffffffu, data, size); return ~crc32_update(0xffffffffu, data, size);
} }
bool ProfileStorage::initialize(const ProfileStorageIo& io, bool ProfileStorage::initialize(const ProfileStorageIo &io) {
ControllerProfileDatabase* database) {
io_ = io; io_ = io;
snapshot_ = {}; snapshot_ = {};
initialized_ = identity_count_ = 0;
database != nullptr && io_.read != nullptr && epoch_ = 0;
io_.replace_bank != nullptr && next_offset_ = PROFILE_STORAGE_RECORDS_OFFSET;
io_.bank_size >= PROFILE_STORAGE_BANK_SIZE && 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_.sector_size == PROFILE_STORAGE_SECTOR_SIZE &&
io_.page_size == PROFILE_STORAGE_PAGE_SIZE && io_.page_size == PROFILE_STORAGE_PAGE_SIZE;
io_.bank_size % io_.sector_size == 0 &&
io_.sector_size % io_.page_size == 0;
if (!initialized_) { if (!initialized_) {
return false; return false;
} }
controller_profile_database_default(database); ProfileStorageIdentityIndex
BankHeader headers[PROFILE_STORAGE_BANK_COUNT]{}; candidate[PROFILE_STORAGE_ARENA_COUNT]
bool valid[PROFILE_STORAGE_BANK_COUNT]{}; [CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1]{};
for (uint8_t bank = 0; bank < PROFILE_STORAGE_BANK_COUNT; ++bank) { uint8_t counts[PROFILE_STORAGE_ARENA_COUNT]{};
valid[bank] = read_header(bank, &headers[bank]) && uint32_t epochs[PROFILE_STORAGE_ARENA_COUNT]{};
validate_payload(bank, headers[bank].payload_crc); 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 first = 0; uint8_t selected = 0;
uint8_t second = 1; if (valid[1] && (!valid[0] || generation_is_newer(epochs[1], epochs[0]))) {
if (valid[1] && selected = 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}; if (valid[selected]) {
for (uint8_t candidate : order) { for (size_t index = 0;
if (!valid[candidate] || !decode_bank(candidate, database)) { index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1; ++index) {
continue; index_[index] = candidate[selected][index];
} }
identity_count_ = counts[selected];
epoch_ = epochs[selected];
next_offset_ = offsets[selected];
snapshot_.valid = true; snapshot_.valid = true;
snapshot_.generation = headers[candidate].generation; snapshot_.active_bank = selected;
snapshot_.payload_crc = headers[candidate].payload_crc; snapshot_.generation = generations[selected];
snapshot_.active_bank = candidate; snapshot_.payload_crc = payload_crcs[selected];
return true; return true;
} }
controller_profile_database_default(database);
if (migrate_legacy()) {
return true; 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);
} }
ProfileStorageResult ProfileStorage::commit( ProfileStorageResult
const ControllerProfileDatabase& database, ProfileStorage::ensure_identity(const ControllerIdentity &identity) {
uint8_t* encoded_database, size_t encoded_database_size) { if (!initialized_ || !valid_identity(identity)) {
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; return ProfileStorageResult::kInvalidArgument;
} }
if (snapshot_.valid && if (find(identity) != nullptr) {
payload_matches_encoded(
snapshot_.active_bank, encoded_database,
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE)) {
return ProfileStorageResult::kUnchanged; return ProfileStorageResult::kUnchanged;
} }
if (identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) {
const uint32_t crc = profile_storage_crc32( return ProfileStorageResult::kFull;
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;
} }
return append(RecordType::kActivate, identity, 0, nullptr, 0);
snapshot_.valid = true;
snapshot_.generation = generation;
snapshot_.payload_crc = crc;
snapshot_.active_bank = target_bank;
return ProfileStorageResult::kOk;
} }
const ProfileStorageSnapshot& ProfileStorage::snapshot() const { 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, &current) == 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_; return snapshot_;
} }
bool ProfileStorage::read_header(uint8_t bank, BankHeader* output) const { bool ProfileStorage::scan_arena(uint8_t arena, uint32_t *epoch,
uint8_t header[PROFILE_STORAGE_RECORD_HEADER_SIZE]{}; uint32_t *generation, uint32_t *payload_crc,
if (bank >= PROFILE_STORAGE_BANK_COUNT || output == nullptr || size_t *next_offset,
!io_.read(io_.context, bank, 0, header, sizeof(header)) || ProfileStorageIdentityIndex *index,
memcmp(header, kRecordMagic, sizeof(kRecordMagic)) != 0 || uint8_t *identity_count) const {
storage_read_u16(&header[4]) != kRecordFormatVersion || uint8_t superblock[PROFILE_STORAGE_PAGE_SIZE]{};
(storage_read_u16(&header[6]) != if (!io_.read(io_.context, arena, 0, superblock, sizeof(superblock)) ||
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION && memcmp(superblock, kSuperblockMagic, sizeof(kSuperblockMagic)) != 0 ||
storage_read_u16(&header[6]) != read_u16(&superblock[4]) != kCatalogVersion ||
CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION) || profile_storage_crc32(superblock, 12) != read_u32(&superblock[12]) ||
storage_read_u32(&header[12]) != !bytes_are(0, &superblock[16], sizeof(superblock) - 16)) {
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
profile_storage_crc32(header, kHeaderCrcOffset) !=
storage_read_u32(&header[kHeaderCrcOffset])) {
return false; return false;
} }
for (size_t offset = kHeaderFieldsSize; *epoch = read_u32(&superblock[8]);
offset < sizeof(header); ++offset) { *generation = 0;
if (header[offset] != 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; 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<RecordType>(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]);
}
} }
output->generation = storage_read_u32(&header[8]);
output->payload_crc = storage_read_u32(&header[16]);
return true; return true;
} }
bool ProfileStorage::validate_payload(uint8_t bank, bool ProfileStorage::read_profile_record(uint32_t record,
uint32_t expected_crc) const { 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<uint8_t>(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]{}; uint8_t page[PROFILE_STORAGE_PAGE_SIZE]{};
uint32_t crc = 0xffffffffu; uint32_t crc = 0xffffffffu;
for (size_t offset = 0; size_t remaining = PROFILE_STORAGE_LEGACY_DATABASE_SIZE;
offset < CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE; size_t payload_offset = base + PROFILE_STORAGE_LEGACY_HEADER_SIZE;
offset += sizeof(page)) { while (remaining != 0) {
const size_t size = const size_t size = remaining < sizeof(page) ? remaining : sizeof(page);
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE - offset < sizeof(page) if (!io_.read(io_.context, arena, payload_offset, page, size)) {
? CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE - offset remaining = SIZE_MAX;
: sizeof(page); break;
if (!io_.read(io_.context, bank,
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset,
page, size)) {
return false;
} }
crc = crc32_update(crc, page, size); 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<size_t>(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( bool ProfileStorage::publish_empty_arena(uint8_t arena, uint32_t epoch) {
uint8_t bank, ControllerProfileDatabase* database) const { if (!io_.erase_arena(io_.context, arena)) {
DatabaseReadContext context{&io_, bank}; return false;
return controller_profile_database_decode(read_database, &context, }
database); 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( bool ProfileStorage::write_record(uint8_t arena, size_t offset, RecordType type,
uint8_t bank, const uint8_t* payload, const ControllerIdentity &identity,
uint8_t profile_index, uint32_t generation,
const uint8_t *payload,
size_t payload_size) const { size_t payload_size) const {
uint8_t stored[PROFILE_STORAGE_PAGE_SIZE]{}; if (arena >= PROFILE_STORAGE_ARENA_COUNT ||
for (size_t offset = 0; offset < payload_size; offset < PROFILE_STORAGE_RECORDS_OFFSET ||
offset += sizeof(stored)) { offset + PROFILE_STORAGE_RECORD_SIZE > PROFILE_STORAGE_ARENA_SIZE ||
const size_t size = offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
payload_size - offset < sizeof(stored) (type == RecordType::kProfile
? payload_size - offset ? payload == nullptr ||
: sizeof(stored); payload_size != CONTROLLER_PROFILE_ENCODED_SIZE
if (!io_.read( : payload_size != 0)) {
io_.context, bank, return false;
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset, }
stored, size) || if (payload_size != 0) {
memcmp(stored, &payload[offset], size) != 0) { if (!io_.program_page(io_.context, arena, offset + kRecordPayloadOffset,
payload, PROFILE_STORAGE_PAGE_SIZE)) {
return false; 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<uint8_t>(type);
header[7] = profile_index;
write_u32(&header[8], generation);
write_u16(&header[12], static_cast<uint16_t>(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;
}
} }

View file

@ -5,43 +5,41 @@
#include "profile/controller_profile.h" #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_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_PAGE_SIZE = 256;
constexpr size_t PROFILE_STORAGE_RECORD_HEADER_SIZE = constexpr size_t PROFILE_STORAGE_ARENA_SIZE = 128 * 1024;
PROFILE_STORAGE_PAGE_SIZE; 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); // Layout of the retired v1/v2 whole-database store. The indexed catalog reads
static_assert(PROFILE_STORAGE_TOTAL_SIZE == 40 * 1024); // this region once during migration; new firmware never writes it.
static_assert(PROFILE_STORAGE_RECORD_HEADER_SIZE + constexpr uint8_t PROFILE_STORAGE_LEGACY_BANK_COUNT = 2;
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE <= constexpr size_t PROFILE_STORAGE_LEGACY_BANK_SIZE = 20 * 1024;
PROFILE_STORAGE_BANK_SIZE, constexpr size_t PROFILE_STORAGE_LEGACY_TOTAL_SIZE =
"profile database does not fit a storage bank"); 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 { static_assert(PROFILE_STORAGE_ARENA_SIZE % PROFILE_STORAGE_SECTOR_SIZE == 0);
kOk = 0, static_assert(PROFILE_STORAGE_RECORDS_OFFSET % PROFILE_STORAGE_RECORD_SIZE ==
kUnchanged = 1, 0);
kInvalidArgument = 2,
kIoError = 3,
};
struct ProfileStorageIo { struct ProfileStorageIo {
void* context = nullptr; void *context = nullptr;
size_t bank_size = 0; size_t arena_size = 0;
size_t sector_size = 0; size_t sector_size = 0;
size_t page_size = 0; size_t page_size = 0;
bool (*read)(void* context, uint8_t bank, size_t offset, bool (*read)(void *context, uint8_t arena, size_t offset, uint8_t *output,
uint8_t* output, size_t size) = nullptr; size_t size) = nullptr;
// Replaces one bank and verifies the payload before publishing the bool (*erase_arena)(void *context, uint8_t arena) = nullptr;
// complete header page. bool (*program_page)(void *context, uint8_t arena, size_t offset,
bool (*replace_bank)(void* context, uint8_t bank, const uint8_t *page, size_t size) = nullptr;
const uint8_t* payload, size_t payload_size,
const uint8_t* header, size_t header_size) = nullptr;
}; };
struct ProfileStorageSnapshot { struct ProfileStorageSnapshot {
@ -51,33 +49,81 @@ struct ProfileStorageSnapshot {
uint8_t active_bank = 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 { class ProfileStorage {
public: public:
bool initialize(const ProfileStorageIo& io, bool initialize(const ProfileStorageIo &io);
ControllerProfileDatabase* database); ProfileStorageResult ensure_identity(const ControllerIdentity &identity);
ProfileStorageResult commit( ProfileStorageResult get(const ControllerIdentity &identity,
const ControllerProfileDatabase& database, uint8_t profile_index,
uint8_t* encoded_database, ControllerProfile *output) const;
size_t encoded_database_size); ProfileStorageResult set(const ControllerIdentity &identity,
const ProfileStorageSnapshot& snapshot() const; 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: private:
struct BankHeader { enum class RecordType : uint8_t {
uint32_t generation = 0; kProfile = 1,
uint32_t payload_crc = 0; kReset = 2,
kResetAll = 3,
kActivate = 4,
}; };
bool read_header(uint8_t bank, BankHeader* output) const; bool scan_arena(uint8_t arena, uint32_t *epoch, uint32_t *generation,
bool validate_payload(uint8_t bank, uint32_t expected_crc) const; uint32_t *payload_crc, size_t *next_offset,
bool decode_bank(uint8_t bank, ProfileStorageIdentityIndex *index,
ControllerProfileDatabase* database) const; uint8_t *identity_count) const;
bool payload_matches_encoded(uint8_t bank, bool read_profile_record(uint32_t record, ControllerProfile *output) const;
const uint8_t* payload, 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; 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_{}; ProfileStorageIo io_{};
ProfileStorageSnapshot snapshot_{}; 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; bool initialized_ = false;
}; };

View file

@ -105,7 +105,7 @@ PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3
PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4 PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4
PROFILE_SCHEMA_VERSION = 5 PROFILE_SCHEMA_VERSION = 5
PROFILE_SIZE = 256 PROFILE_SIZE = 256
PROFILE_CAPACITY = 4 PROFILE_CAPACITY = 8
PROFILE_IDENTITY_CAPACITY = 16 PROFILE_IDENTITY_CAPACITY = 16
PROFILE_LIST_CAPACITY = PROFILE_IDENTITY_CAPACITY + 1 PROFILE_LIST_CAPACITY = PROFILE_IDENTITY_CAPACITY + 1
CONTROLLER_IDENTITY_SIZE = 14 CONTROLLER_IDENTITY_SIZE = 14
@ -2572,10 +2572,12 @@ def _profile_number(value: str) -> int:
number = int(value) number = int(value)
except ValueError as exc: except ValueError as exc:
raise argparse.ArgumentTypeError( 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 ) from exc
if not 1 <= number <= PROFILE_CAPACITY: 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 return number - 1

View file

@ -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( def _controller_presentation(
identity: config_manager.ControllerIdentity, identity: config_manager.ControllerIdentity,
) -> dict[str, str]: ) -> dict[str, str]:
@ -116,6 +151,8 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
"turbo_modes": list(config_manager.TURBO_MODES), "turbo_modes": list(config_manager.TURBO_MODES),
"macro_overrides": list(config_manager.MACRO_OVERRIDE_NAMES), "macro_overrides": list(config_manager.MACRO_OVERRIDE_NAMES),
"macro_count": config_manager.PROFILE_MACRO_COUNT, "macro_count": config_manager.PROFILE_MACRO_COUNT,
"profile_capacity": config_manager.PROFILE_CAPACITY,
"control_labels": _CONTROL_LABELS,
"maximum_macro_state_steps": ( "maximum_macro_state_steps": (
config_manager.PROFILE_MACRO_STEPS_PER_MACRO config_manager.PROFILE_MACRO_STEPS_PER_MACRO
), ),

View file

@ -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; } .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); } 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-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-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; } .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 { 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-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-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-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 { 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; } .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; } .panel-heading > p { text-align: left; }
.analog-grid, .feedback-grid, .macro-controls { grid-template-columns: 1fr; } .analog-grid, .feedback-grid, .macro-controls { grid-template-columns: 1fr; }
.macro-picker { flex-direction: column; } .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; } .macro-budget { flex-basis: auto; }
.step-grid { grid-template-columns: 1fr 1fr; } .step-grid { grid-template-columns: 1fr 1fr; }
} }

View file

@ -174,15 +174,23 @@
</section> </section>
<section class="panel" id="macro"> <section class="panel" id="macro">
<div class="panel-heading macro-heading"> <div class="panel-heading">
<div> <div>
<p class="eyebrow">05 · Actions</p> <p class="eyebrow">05 · Actions</p>
<h3>Bindings &amp; custom macro</h3> <h3>Bindings &amp; custom macro</h3>
</div> </div>
<button class="button button-secondary" id="addMacroStepButton" type="button">Add state step</button>
</div> </div>
<div class="builtin-actions" id="builtinActions"></div> <div class="builtin-actions" id="builtinActions"></div>
<div class="macro-controls" id="macroControls"></div> <div class="macro-controls" id="macroControls"></div>
<div class="macro-steps-heading">
<div>
<p class="eyebrow">Sequence</p>
<h4 id="macroStepsTitle">Macro 1 steps</h4>
</div>
<span class="macro-step-action">
<button class="button button-secondary" id="addMacroStepButton" type="button">Add state step</button>
</span>
</div>
<div class="macro-steps" id="macroSteps"></div> <div class="macro-steps" id="macroSteps"></div>
</section> </section>
</form> </form>

View file

@ -40,6 +40,7 @@ const elements = {
turbo: document.querySelector("#turboFields"), turbo: document.querySelector("#turboFields"),
macroControls: document.querySelector("#macroControls"), macroControls: document.querySelector("#macroControls"),
macroSteps: document.querySelector("#macroSteps"), macroSteps: document.querySelector("#macroSteps"),
macroStepsTitle: document.querySelector("#macroStepsTitle"),
refresh: document.querySelector("#refreshButton"), refresh: document.querySelector("#refreshButton"),
resetDraft: document.querySelector("#resetDraftButton"), resetDraft: document.querySelector("#resetDraftButton"),
activate: document.querySelector("#activateButton"), activate: document.querySelector("#activateButton"),
@ -64,6 +65,24 @@ function label(value) {
.join(" "); .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) { function clone(value) {
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));
} }
@ -156,7 +175,7 @@ function renderIdentities() {
function renderProfileList() { function renderProfileList() {
const owner = state.identities[state.identityIndex]; 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 selected = index === state.profileIndex;
const active = owner && owner.active_profile === index + 1; const active = owner && owner.active_profile === index + 1;
return ` return `
@ -179,13 +198,14 @@ function renderProfileList() {
function buttonOptions( function buttonOptions(
selected, selected,
includeNone = true, includeNone = true,
choices = state.schema.controls choices = state.schema.controls,
style = currentControllerStyle()
) { ) {
const none = includeNone const none = includeNone
? `<option value=""${selected === null ? " selected" : ""}>None</option>` ? `<option value=""${selected === null ? " selected" : ""}>None</option>`
: ""; : "";
return none + choices.map((button) => ( return none + choices.map((button) => (
`<option value="${button}"${selected === button ? " selected" : ""}>${label(button)}</option>` `<option value="${button}"${selected === button ? " selected" : ""}>${escapeHtml(controlLabel(button, style))}</option>`
)).join(""); )).join("");
} }
@ -299,7 +319,7 @@ function renderButtonMap() {
hotspot.textContent = controlGlyph(button, style); hotspot.textContent = controlGlyph(button, style);
hotspot.classList.toggle("selected", button === selected); hotspot.classList.toggle("selected", button === selected);
hotspot.classList.toggle("disabled-map", output === null); 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.setAttribute("aria-label", hotspot.title);
hotspot.onclick = () => { hotspot.onclick = () => {
state.selectedButton = button; state.selectedButton = button;
@ -308,11 +328,11 @@ function renderButtonMap() {
}); });
elements.selectedControlGlyph.textContent = controlGlyph(selected, style); elements.selectedControlGlyph.textContent = controlGlyph(selected, style);
elements.selectedControlName.textContent = label(selected); elements.selectedControlName.textContent = controlLabel(selected, style);
elements.selectedControlDescription.textContent = ( 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 = () => { elements.selectedMapping.onchange = () => {
setControlMapping(selected, elements.selectedMapping.value || null); setControlMapping(selected, elements.selectedMapping.value || null);
renderButtonMap(); renderButtonMap();
@ -395,7 +415,7 @@ function renderFeedback() {
function renderTurbo() { function renderTurbo() {
elements.turbo.innerHTML = state.schema.buttons.map((button) => ` elements.turbo.innerHTML = state.schema.buttons.map((button) => `
<div class="control-card"> <div class="control-card">
<label for="turbo-${button}">${label(button)}</label> <label for="turbo-${button}">${controlLabel(button)}</label>
<select class="select" id="turbo-${button}" data-kind="turbo" data-name="${button}"> <select class="select" id="turbo-${button}" data-kind="turbo" data-name="${button}">
${state.schema.turbo_modes.map((mode) => `<option value="${mode}"${state.profile.turbo[button] === mode ? " selected" : ""}>${label(mode)}</option>`).join("")} ${state.schema.turbo_modes.map((mode) => `<option value="${mode}"${state.profile.turbo[button] === mode ? " selected" : ""}>${label(mode)}</option>`).join("")}
</select> </select>
@ -415,9 +435,9 @@ function actionChordCard(action, title, description, selectedButtons, defaults)
const effectiveButtons = inherited ? defaults : selectedButtons; const effectiveButtons = inherited ? defaults : selectedButtons;
const selected = new Set(effectiveButtons); const selected = new Set(effectiveButtons);
const defaultText = inherited const defaultText = inherited
? `Using default: ${defaults.map(label).join(" + ")}.` ? `Using default: ${defaults.map((button) => controlLabel(button)).join(" + ")}.`
: defaults?.length : 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."; : "Empty disables this action.";
return ` return `
<div class="action-card"> <div class="action-card">
@ -431,7 +451,7 @@ function actionChordCard(action, title, description, selectedButtons, defaults)
<input type="checkbox" data-kind="action-chord" data-action="${action}" data-name="${button}"${selected.has(button) ? " checked" : ""}> <input type="checkbox" data-kind="action-chord" data-action="${action}" data-name="${button}"${selected.has(button) ? " checked" : ""}>
<span data-button="${button}"> <span data-button="${button}">
<b>${controlGlyph(button, state.identities[state.identityIndex]?.controller?.style || "generic")}</b> <b>${controlGlyph(button, state.identities[state.identityIndex]?.controller?.style || "generic")}</b>
<small>${label(button)}</small> <small>${controlLabel(button)}</small>
</span> </span>
</label>`).join("")} </label>`).join("")}
</div> </div>
@ -467,7 +487,7 @@ function renderMacro() {
actionChordCard( actionChordCard(
"profile_switch", "profile_switch",
"Cycle active profile", "Cycle active profile",
"Advance through profile slots 1–4.", `Advance through profile slots 1–${state.schema.profile_capacity}.`,
state.profile.switching_chord, state.profile.switching_chord,
state.schema.default_switching_chord state.schema.default_switching_chord
), ),
@ -503,7 +523,7 @@ function renderMacro() {
<div class="control-card"> <div class="control-card">
<label for="macro-cancel">Macro ${state.selectedMacro + 1} cancel control</label> <label for="macro-cancel">Macro ${state.selectedMacro + 1} cancel control</label>
<select class="select" id="macro-cancel" data-kind="macro-selector" data-field="cancel"> <select class="select" id="macro-cancel" data-kind="macro-selector" data-field="cancel">
${buttonOptions(macro.cancel, true, state.schema.controls)} ${buttonOptions(macro.cancel, true, state.schema.controls, controllerStyle)}
</select> </select>
</div>`; </div>`;
@ -550,12 +570,13 @@ function renderMacro() {
${state.schema.buttons.map((button) => ` ${state.schema.buttons.map((button) => `
<label class="checkbox-pill"> <label class="checkbox-pill">
<input type="checkbox" data-kind="macro-output" data-index="${index}" data-name="${button}"${outputButtons.has(button) ? " checked" : ""}${overrides.has("buttons") ? "" : " disabled"}> <input type="checkbox" data-kind="macro-output" data-index="${index}" data-name="${button}"${outputButtons.has(button) ? " checked" : ""}${overrides.has("buttons") ? "" : " disabled"}>
<span>${label(button)}</span> <span>${controlLabel(button, controllerStyle)}</span>
</label>`).join("")} </label>`).join("")}
</div> </div>
</div> </div>
</div>`; </div>`;
}).join(""); }).join("");
elements.macroStepsTitle.textContent = `Macro ${state.selectedMacro + 1} steps`;
elements.addMacroStep.textContent = `Add step to macro ${state.selectedMacro + 1}`; elements.addMacroStep.textContent = `Add step to macro ${state.selectedMacro + 1}`;
elements.addMacroStep.disabled = ( elements.addMacroStep.disabled = (
state.busy || macro.steps.length >= 8 || totalSteps >= 16 || state.busy || macro.steps.length >= 8 || totalSteps >= 16 ||

View file

@ -627,19 +627,19 @@ void test_custom_switching_chord_and_wrap() {
"custom switching chord was not consumed or activated"); "custom switching chord was not consumed or activated");
prepare_profiles(); prepare_profiles();
rows[0].active_profile = 3; rows[0].active_profile = 7;
rows[0].profiles[3].switching_chord = kCustomChord; rows[0].profiles[7].switching_chord = kCustomChord;
snapshot = make_snapshot(0); snapshot = make_snapshot(0);
(void)runtime_transform(0, snapshot, 10); (void)runtime_transform(0, snapshot, 10);
bool event_available = true; bool event_available = true;
(void)take_profile_change(0, &event_available); (void)take_profile_change(0, &event_available);
require(!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); apply_button_mask(kCustomChord, &snapshot);
(void)runtime_transform(0, snapshot, 11); (void)runtime_transform(0, snapshot, 11);
require(activation_attempt_count == 1 && require(activation_attempt_count == 1 &&
activation_attempts[0].profile_index == 0, 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() { void test_switching_slot_isolation() {
prepare_profiles(); prepare_profiles();

View file

@ -1,8 +1,9 @@
#include "core/controller_identity.h" #include "core/controller_identity.h"
#include "profile/controller_profile.h"
#include "platform/pico/pico_profile_storage.h" #include "platform/pico/pico_profile_storage.h"
#include "profile/controller_profile.h"
#include "profile/profile_service.h" #include "profile/profile_service.h"
#include "profile/profile_storage.h" #include "profile/profile_storage.h"
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
#include <iostream> #include <iostream>
@ -10,428 +11,216 @@
namespace { namespace {
struct FakeFlash { struct FakeFlash {
uint8_t bytes[PROFILE_STORAGE_BANK_COUNT][PROFILE_STORAGE_BANK_SIZE]; uint8_t bytes[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE];
int bank_replacements = 0;
}; };
FakeFlash flash{}; FakeFlash flash{};
void require(bool condition, const char* message) { void require(bool condition, const char *message) {
if (!condition) { if (!condition) {
std::cerr << message << '\n'; std::cerr << message << '\n';
std::exit(1); std::exit(1);
} }
} }
bool fake_read(void* context, uint8_t bank, size_t offset, bool fake_read(void *context, uint8_t arena, size_t offset, uint8_t *output,
uint8_t* output, size_t size) { size_t size) {
auto* storage = static_cast<FakeFlash*>(context); auto *storage = static_cast<FakeFlash *>(context);
if (bank >= PROFILE_STORAGE_BANK_COUNT || output == nullptr || if (arena >= PROFILE_STORAGE_ARENA_COUNT || output == nullptr ||
offset > PROFILE_STORAGE_BANK_SIZE || offset > PROFILE_STORAGE_ARENA_SIZE ||
size > PROFILE_STORAGE_BANK_SIZE - offset) { size > PROFILE_STORAGE_ARENA_SIZE - offset) {
return false; return false;
} }
memcpy(output, &storage->bytes[bank][offset], size); memcpy(output, &storage->bytes[arena][offset], size);
return true; return true;
} }
bool fake_replace_bank(void* context, uint8_t bank, bool fake_erase(void *context, uint8_t arena) {
const uint8_t* payload, size_t payload_size, auto *storage = static_cast<FakeFlash *>(context);
const uint8_t* header, size_t header_size) { if (arena >= PROFILE_STORAGE_ARENA_COUNT) {
auto* storage = static_cast<FakeFlash*>(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; return false;
} }
++storage->bank_replacements; memset(storage->bytes[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE);
memset(storage->bytes[bank], 0xff, PROFILE_STORAGE_BANK_SIZE); return true;
memcpy( }
&storage->bytes[bank][PROFILE_STORAGE_RECORD_HEADER_SIZE],
payload, payload_size); bool fake_program(void *context, uint8_t arena, size_t offset,
memcpy(storage->bytes[bank], header, header_size); const uint8_t *page, size_t size) {
return memcmp( auto *storage = static_cast<FakeFlash *>(context);
&storage->bytes[bank][ if (arena >= PROFILE_STORAGE_ARENA_COUNT || page == nullptr ||
PROFILE_STORAGE_RECORD_HEADER_SIZE], size != PROFILE_STORAGE_PAGE_SIZE ||
payload, payload_size) == 0 && offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
memcmp(storage->bytes[bank], header, header_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() { ProfileStorageIo fake_io() {
return { return {
&flash, &flash,
PROFILE_STORAGE_BANK_SIZE, PROFILE_STORAGE_ARENA_SIZE,
PROFILE_STORAGE_SECTOR_SIZE, PROFILE_STORAGE_SECTOR_SIZE,
PROFILE_STORAGE_PAGE_SIZE, PROFILE_STORAGE_PAGE_SIZE,
fake_read, fake_read,
fake_replace_bank, 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 transaction_snapshot() {
ProfileServiceTransactionSnapshot snapshot{}; ProfileServiceTransactionSnapshot snapshot{};
profile_service_transaction_snapshot(&snapshot); profile_service_transaction_snapshot(&snapshot);
return snapshot; return snapshot;
} }
ProfileServiceActiveProfileSnapshot active_profile_snapshot( ProfileServiceActiveProfileSnapshot
const ControllerIdentity& identity) { active_snapshot(const ControllerIdentity &identity) {
ProfileServiceActiveProfileSnapshot snapshot{}; ProfileServiceActiveProfileSnapshot snapshot{};
profile_service_active_profile_snapshot(identity, &snapshot); profile_service_active_profile_snapshot(identity, &snapshot);
return snapshot; return snapshot;
} }
ControllerProfileDatabase reload_database( void write_profile(uint32_t transaction_id, const ControllerIdentity &identity,
const ProfileServiceTransactionSnapshot& transaction, uint8_t profile_index, const ControllerProfile &profile,
uint32_t expected_generation) { uint32_t now_ms) {
ControllerProfileDatabase recovered{}; uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
ProfileStorage storage; require(controller_profile_encode(profile, encoded, sizeof(encoded)),
require(storage.initialize(fake_io(), &recovered) && "profile did not encode");
storage.snapshot().valid && require(
storage.snapshot().generation == expected_generation && profile_service_begin(transaction_id, identity, profile_index,
storage.snapshot().generation == CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded),
transaction.transaction.stored_generation && profile_storage_crc32(encoded, sizeof(encoded))) ==
storage.snapshot().payload_crc == ConfigurationTransactionStatus::kReceiving,
transaction.transaction.stored_crc, "profile transaction did not begin");
"terminal transaction status did not identify persisted storage"); require(profile_service_append(transaction_id, 0, encoded, 117) ==
return recovered; 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() { void test_eight_profile_transactions_and_active_cache() {
memset(flash.bytes, 0xff, sizeof(flash.bytes)); memset(flash.bytes, 0xff, sizeof(flash.bytes));
profile_service_prepare(); profile_service_prepare();
profile_service_initialize_on_storage_core(); profile_service_initialize_on_storage_core();
ProfileServiceActiveProfileSnapshot active =
active_profile_snapshot(controller_identity_global());
require(active.valid &&
active.metadata.state == ProfileServiceState::kReady &&
active.metadata.generation == 0 &&
profile_service_database_generation() == 0 &&
active.profile_index == 0,
"initial active profile snapshot was not coherent");
const ControllerIdentity identity = controller_identity_global();
constexpr uint8_t kProfileIndex = 2;
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");
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");
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");
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");
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");
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");
}
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_completed_write_then_dirty_identity_activation() {
const int replacements_before = flash.bank_replacements;
const ControllerIdentity global = controller_identity_global(); const ControllerIdentity global = controller_identity_global();
constexpr uint8_t kWrittenProfileIndex = 1; ProfileServiceActiveProfileSnapshot active = active_snapshot(global);
ControllerProfile customized = require(active.valid && active.profile_index == 0 &&
controller_profile_default(global, kWrittenProfileIndex); active.metadata.state == ProfileServiceState::kReady,
customized.strong_rumble_scale = 31; "fallback active cache did not initialize");
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");
constexpr uint32_t kWriteTransactionId = 0x31415926; ControllerProfile eighth = controller_profile_default(global, 7);
require(profile_service_begin( eighth.strong_rumble_scale = 37;
kWriteTransactionId, global, kWrittenProfileIndex, write_profile(1, global, 7, eighth, 0);
CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), require(profile_service_select(global, 7) ==
profile_storage_crc32(encoded, sizeof(encoded))) == ConfigurationTransactionStatus::kCommitted,
ConfigurationTransactionStatus::kReceiving && "profile eight was not selectable");
profile_service_append(kWriteTransactionId, 0, encoded, ProfileServiceSelectedSnapshot selected{};
sizeof(encoded)) == profile_service_selected_snapshot(&selected);
ConfigurationTransactionStatus::kReceiving && require(selected.valid && selected.profile_index == 7 &&
profile_service_commit(kWriteTransactionId) == selected.profile.strong_rumble_scale == 37,
"selected profile eight was not decoded on demand");
require(profile_service_activate(2, global, 7) ==
ConfigurationTransactionStatus::kPending, ConfigurationTransactionStatus::kPending,
"sequential profile write did not reach pending"); "profile eight activation was not queued");
profile_service_task_on_storage_core(6000); profile_service_task_on_storage_core(1000);
const ProfileServiceTransactionSnapshot written = require(transaction_snapshot().transaction.status ==
transaction_snapshot(); ConfigurationTransactionStatus::kCommitted,
require(written.transaction.transaction_id == kWriteTransactionId && "profile eight activation did not persist");
written.transaction.status == active = active_snapshot(global);
ConfigurationTransactionStatus::kCommitted && require(active.valid && active.profile_index == 7 &&
flash.bank_replacements == replacements_before + 1, active.profile.strong_rumble_scale == 37,
"completed write lost correlation or used multiple bank replacements"); "active cache did not publish profile eight");
ControllerIdentity connected{}; const ControllerIdentity connected = stable_identity();
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), require(profile_service_observe_identity_on_storage_core(connected),
"connected identity did not enter the dirty database"); "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 kActivateTransactionId = 0x27182818; ControllerProfile seventh = controller_profile_default(connected, 6);
constexpr uint8_t kActivatedProfileIndex = 2; seventh.weak_rumble_scale = 61;
require(profile_service_activate( write_profile(3, connected, 6, seventh, 3000);
kActivateTransactionId, connected, require(profile_service_activate_internal(0x80000007u, connected, 6) ==
kActivatedProfileIndex) ==
ConfigurationTransactionStatus::kPending, ConfigurationTransactionStatus::kPending,
"activation after completed write did not reach pending"); "controller activation was not queued");
const ProfileServiceTransactionSnapshot pending = profile_service_task_on_storage_core(4000);
transaction_snapshot(); active = active_snapshot(connected);
require(pending.transaction.transaction_id == require(active.valid && active.profile_index == 6 &&
kActivateTransactionId && active.profile.weak_rumble_scale == 61,
pending.transaction.status == "controller activation did not refresh the active cache");
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); require(profile_service_reset(4, global, CONTROLLER_PROFILE_ALL) ==
const ProfileServiceTransactionSnapshot activated = ConfigurationTransactionStatus::kPending,
transaction_snapshot(); "reset-all was not queued");
require(activated.transaction.transaction_id == profile_service_task_on_storage_core(5000);
kActivateTransactionId && active = active_snapshot(global);
activated.transaction.status == require(active.valid && active.profile_index == 0 &&
ConfigurationTransactionStatus::kCommitted && active.profile.strong_rumble_scale == UINT8_MAX,
activated.transaction.stored_generation == "reset-all did not restore defaults and activation");
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( ProfileStorage reloaded;
activated, activated.transaction.stored_generation); ControllerProfile persisted{};
const ControllerProfileDatabaseEntry* connected_entry = require(
controller_profile_database_find(recovered, connected); reloaded.initialize(fake_io()) && reloaded.find(connected) != nullptr &&
require(connected_entry != nullptr && reloaded.find(connected)->active_profile == 6 &&
connected_entry->active_profile == reloaded.get(connected, 6, &persisted) == ProfileStorageResult::kOk &&
kActivatedProfileIndex && persisted.weak_rumble_scale == 61,
recovered.fallback_profiles[kWrittenProfileIndex] "service mutations did not survive catalog reload");
.strong_rumble_scale == }
customized.strong_rumble_scale &&
recovered.fallback_profiles[kWrittenProfileIndex] void test_profile_bounds_and_transaction_namespace() {
.weak_rumble_scale == const ControllerIdentity global = controller_identity_global();
customized.weak_rumble_scale, require(profile_service_select(global, 8) ==
"activation did not atomically persist the dirty identity and prior write"); ConfigurationTransactionStatus::kMalformed &&
const ProfileServiceActiveProfileSnapshot active = profile_service_activate(10, global, 8) ==
active_profile_snapshot(connected); ConfigurationTransactionStatus::kMalformed &&
require(active.valid && profile_service_reset(11, global, 8) ==
active.metadata.generation == ConfigurationTransactionStatus::kMalformed,
activated.transaction.stored_generation && "profile index beyond eight was admitted");
active.profile_index == kActivatedProfileIndex, require(profile_service_activate(0x80000001u, global, 0) ==
"completed activation did not publish the dirty identity"); ConfigurationTransactionStatus::kMalformed &&
profile_service_activate_internal(12, global, 0) ==
ConfigurationTransactionStatus::kMalformed,
"transaction namespaces were not enforced");
} }
} // namespace } // namespace
ProfileStorageIo pico_profile_storage_io() { ProfileStorageIo pico_profile_storage_io() { return fake_io(); }
return fake_io();
}
int main() { int main() {
test_pending_commands_are_not_decoded_as_profile_writes(); test_eight_profile_transactions_and_active_cache();
test_host_and_controller_mutations_are_serialized(); test_profile_bounds_and_transaction_namespace();
test_completed_write_then_dirty_identity_activation(); std::cout << "profile service tests passed\n";
return 0; return 0;
} }

View file

@ -1,7 +1,6 @@
#include "core/controller_identity.h" #include "core/controller_identity.h"
#include "profile/controller_profile.h" #include "profile/controller_profile.h"
#include "profile/profile_storage.h" #include "profile/profile_storage.h"
#include "controller_profile_legacy_fixtures.h"
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
@ -10,23 +9,16 @@
namespace { namespace {
struct FakeFlash { struct FakeFlash {
uint8_t bytes[PROFILE_STORAGE_BANK_COUNT][PROFILE_STORAGE_BANK_SIZE]; uint8_t bytes[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE];
int successful_programs = 0; int programs = 0;
int erases = 0;
int fail_after_programs = -1; int fail_after_programs = -1;
bool corrupt_next_program = false; 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;
}; };
FakeFlash flash{}; FakeFlash flash{};
ControllerProfileDatabase database{};
ControllerProfileDatabase recovered_database{};
uint8_t encoded_database[CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE]{};
void require(bool condition, const char* message) { void require(bool condition, const char *message) {
if (!condition) { if (!condition) {
std::cerr << message << '\n'; std::cerr << message << '\n';
std::exit(1); std::exit(1);
@ -34,625 +26,288 @@ void require(bool condition, const char* message) {
} }
void erase_all() { void erase_all() {
flash = FakeFlash{};
memset(flash.bytes, 0xff, sizeof(flash.bytes)); memset(flash.bytes, 0xff, sizeof(flash.bytes));
flash.successful_programs = 0;
flash.fail_after_programs = -1; 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;
} }
bool fake_read(void* context, uint8_t bank, size_t offset, bool fake_read(void *context, uint8_t arena, size_t offset, uint8_t *output,
uint8_t* output, size_t size) { size_t size) {
auto* storage = static_cast<FakeFlash*>(context); auto *storage = static_cast<FakeFlash *>(context);
if (storage->fail_reads_after_header_program && if (arena >= PROFILE_STORAGE_ARENA_COUNT || output == nullptr ||
storage->header_programmed) { offset > PROFILE_STORAGE_ARENA_SIZE ||
size > PROFILE_STORAGE_ARENA_SIZE - offset) {
return false; return false;
} }
if (bank >= PROFILE_STORAGE_BANK_COUNT || memcpy(output, &storage->bytes[arena][offset], size);
offset > PROFILE_STORAGE_BANK_SIZE ||
size > PROFILE_STORAGE_BANK_SIZE - offset) {
return false;
}
memcpy(output, &storage->bytes[bank][offset], size);
return true; return true;
} }
bool fake_replace_bank(void* context, uint8_t bank, bool fake_erase(void *context, uint8_t arena) {
const uint8_t* payload, size_t payload_size, auto *storage = static_cast<FakeFlash *>(context);
const uint8_t* header, size_t header_size) { if (arena >= PROFILE_STORAGE_ARENA_COUNT) {
auto* storage = static_cast<FakeFlash*>(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; return false;
} }
memset(storage->bytes[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE);
++storage->erases;
return true;
}
++storage->bank_replacements; bool fake_program(void *context, uint8_t arena, size_t offset,
memset(storage->bytes[bank], 0xff, PROFILE_STORAGE_BANK_SIZE); const uint8_t *page, size_t size) {
storage->erase_count += auto *storage = static_cast<FakeFlash *>(context);
static_cast<int>(PROFILE_STORAGE_SECTORS_PER_BANK); if (arena >= PROFILE_STORAGE_ARENA_COUNT || page == nullptr ||
size != PROFILE_STORAGE_PAGE_SIZE ||
uint8_t final_page[PROFILE_STORAGE_PAGE_SIZE]{}; offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
for (size_t offset = 0; offset < payload_size; offset + size > PROFILE_STORAGE_ARENA_SIZE ||
offset += PROFILE_STORAGE_PAGE_SIZE) { (storage->fail_after_programs >= 0 &&
if (storage->fail_after_programs >= 0 && storage->programs >= storage->fail_after_programs)) {
storage->successful_programs >=
storage->fail_after_programs) {
return false; return false;
} }
const size_t remaining = payload_size - offset; for (size_t index = 0; index < size; ++index) {
const uint8_t* page = &payload[offset]; storage->bytes[arena][offset + index] &= page[index];
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) { if (storage->corrupt_next_program) {
storage->bytes[bank][ storage->bytes[arena][offset] ^= 1;
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset] ^= 1;
storage->corrupt_next_program = false; storage->corrupt_next_program = false;
} }
++storage->successful_programs; ++storage->programs;
} return memcmp(&storage->bytes[arena][offset], page, size) == 0;
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<size_t>(
storage->corrupt_header_padding_offset) < header_size) {
storage->bytes[bank][
static_cast<size_t>(
storage->corrupt_header_padding_offset)] ^= 1;
}
return memcmp(storage->bytes[bank], header, header_size) == 0;
} }
ProfileStorageIo fake_io() { ProfileStorageIo fake_io() {
return { return {
&flash, &flash,
PROFILE_STORAGE_BANK_SIZE, PROFILE_STORAGE_ARENA_SIZE,
PROFILE_STORAGE_SECTOR_SIZE, PROFILE_STORAGE_SECTOR_SIZE,
PROFILE_STORAGE_PAGE_SIZE, PROFILE_STORAGE_PAGE_SIZE,
fake_read, fake_read,
fake_replace_bank, fake_erase,
fake_program,
}; };
} }
uint16_t fixture_read_u16(const uint8_t* input) { ControllerIdentity identity(uint8_t suffix) {
return static_cast<uint16_t>(input[0]) | ControllerIdentity result{};
static_cast<uint16_t>(input[1] << 8); 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) { void write_u16(uint8_t *output, uint16_t value) {
output[0] = static_cast<uint8_t>(value); output[0] = static_cast<uint8_t>(value);
output[1] = static_cast<uint8_t>(value >> 8); output[1] = static_cast<uint8_t>(value >> 8);
} }
void fixture_write_u32(uint8_t* output, uint32_t value) { void write_u32(uint8_t *output, uint32_t value) {
output[0] = static_cast<uint8_t>(value); output[0] = static_cast<uint8_t>(value);
output[1] = static_cast<uint8_t>(value >> 8); output[1] = static_cast<uint8_t>(value >> 8);
output[2] = static_cast<uint8_t>(value >> 16); output[2] = static_cast<uint8_t>(value >> 16);
output[3] = static_cast<uint8_t>(value >> 24); output[3] = static_cast<uint8_t>(value >> 24);
} }
void install_legacy_database_bank_fixture() { void install_legacy_database() {
erase_all(); 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 uint8_t kBank = 1;
constexpr uint32_t kGeneration = 41; constexpr size_t kBankBase =
constexpr size_t kFallbackOffset = kArenaBase + kBank * PROFILE_STORAGE_LEGACY_BANK_SIZE;
CONTROLLER_PROFILE_DATABASE_HEADER_SIZE; uint8_t *header = flash.bytes[kArena] + kBankBase;
constexpr size_t kEntryOffset = uint8_t *payload = header + PROFILE_STORAGE_LEGACY_HEADER_SIZE;
kFallbackOffset + memset(header, 0, PROFILE_STORAGE_LEGACY_HEADER_SIZE);
CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE; memset(payload, 0, PROFILE_STORAGE_LEGACY_DATABASE_SIZE);
constexpr uint8_t kEntryHeader[CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE] = {
1, 1, 7, 0, 1, 2, 3, 4, 5, 6, 0x7e, 0x05, 0x09, 0x20, 3, 1,
};
uint8_t* const record = flash.bytes[kBank];
uint8_t* const payload = record + PROFILE_STORAGE_RECORD_HEADER_SIZE;
memset(record, 0, PROFILE_STORAGE_RECORD_HEADER_SIZE);
memset(payload, 0, CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE);
memcpy(payload, "SPDB", 4); memcpy(payload, "SPDB", 4);
fixture_write_u16(&payload[4], write_u16(&payload[4], 2);
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION); write_u16(&payload[6], PROFILE_STORAGE_LEGACY_DATABASE_SIZE);
fixture_write_u16(
&payload[6],
static_cast<uint16_t>(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE));
payload[8] = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; payload[8] = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY;
payload[9] = CONTROLLER_PROFILE_COUNT; payload[9] = PROFILE_STORAGE_LEGACY_PROFILE_COUNT;
payload[10] = 2; payload[10] = 3;
payload[11] = 1; payload[11] = 1;
for (uint8_t profile_index = 0;
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
const uint8_t* fixture = const ControllerIdentity global = controller_identity_global();
profile_index == 0 for (uint8_t profile = 0; profile < PROFILE_STORAGE_LEGACY_PROFILE_COUNT;
? kLegacyNarrowRawRangeProfile ++profile) {
: profile_index == 1 ? kLegacyCustomThresholdProfile ControllerProfile value = controller_profile_default(global, profile);
: kLegacyDefaultProfile; value.weak_rumble_scale = static_cast<uint8_t>(20 + profile);
memcpy(&payload[kFallbackOffset + require(controller_profile_encode(value, encoded, sizeof(encoded)),
profile_index * CONTROLLER_PROFILE_ENCODED_SIZE], "legacy fallback profile did not encode");
fixture, CONTROLLER_PROFILE_ENCODED_SIZE); memcpy(payload + 32 + profile * sizeof(encoded), encoded, sizeof(encoded));
} }
memcpy(&payload[kEntryOffset], kEntryHeader, sizeof(kEntryHeader)); constexpr size_t kEntrySize = 16 + PROFILE_STORAGE_LEGACY_PROFILE_COUNT *
for (uint8_t profile_index = 0; CONTROLLER_PROFILE_ENCODED_SIZE;
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { uint8_t *entry =
const uint8_t* fixture = payload + 32 +
profile_index == 0 PROFILE_STORAGE_LEGACY_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE;
? kLegacyNarrowRawRangeProfile const ControllerIdentity stable = identity(7);
: profile_index == 3 ? kLegacyCustomThresholdProfile require(controller_identity_encode(stable, entry,
: kLegacyDefaultProfile; CONTROLLER_IDENTITY_ENCODED_SIZE),
memcpy(&payload[kEntryOffset + "legacy identity did not encode");
CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE + entry[14] = 2;
profile_index * CONTROLLER_PROFILE_ENCODED_SIZE], entry[15] = 1;
fixture, CONTROLLER_PROFILE_ENCODED_SIZE); 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<uint8_t>(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( memcpy(header, "SPPF", 4);
payload, CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE); write_u16(&header[4], 1);
memcpy(record, "SPPF", 4); write_u16(&header[6], 2);
fixture_write_u16(&record[4], 1); write_u32(&header[8], 41);
fixture_write_u16(&record[6], write_u32(&header[12], PROFILE_STORAGE_LEGACY_DATABASE_SIZE);
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION); write_u32(&header[16], profile_storage_crc32(
fixture_write_u32(&record[8], kGeneration); payload, PROFILE_STORAGE_LEGACY_DATABASE_SIZE));
fixture_write_u32( write_u32(&header[20], profile_storage_crc32(header, 20));
&record[12],
static_cast<uint32_t>(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE));
fixture_write_u32(&record[16], payload_crc);
fixture_write_u32(&record[20], profile_storage_crc32(record, 20));
} }
void test_initialize_requires_batch_replacement() { void test_empty_catalog_and_eight_profiles() {
erase_all(); erase_all();
ProfileStorageIo io = fake_io();
io.replace_bank = nullptr;
ProfileStorage storage; ProfileStorage storage;
require(!storage.initialize(io, &database), require(storage.initialize(fake_io()) && storage.snapshot().valid &&
"profile storage initialized without bank replacement"); 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");
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; ProfileStorage reloaded;
require(reloaded.initialize(fake_io(), &recovered_database) && ControllerProfile recovered{};
reloaded.snapshot().generation == 2 && require(reloaded.initialize(fake_io()) && reloaded.find(global) != nullptr &&
recovered_database.fallback_profiles[0].button_map[0] == 1, reloaded.find(global)->active_profile == 7 &&
"latest profile database did not survive reload"); reloaded.get(global, 7, &recovered) ==
ProfileStorageResult::kOk &&
const uint8_t newest_bank = reloaded.snapshot().active_bank; recovered.strong_rumble_scale == 77,
flash.bytes[newest_bank][PROFILE_STORAGE_RECORD_HEADER_SIZE + 4] ^= 1; "profile eight did not survive reload");
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() { void test_identity_capacity_and_defaults() {
erase_all(); erase_all();
controller_profile_database_default(&database);
ProfileStorage storage; ProfileStorage storage;
require(storage.initialize(fake_io(), &database) && require(storage.initialize(fake_io()), "catalog did not initialize");
storage.commit(database, encoded_database, for (uint8_t index = 1; index <= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY;
sizeof(encoded_database)) == ++index) {
require(storage.ensure_identity(identity(index)) ==
ProfileStorageResult::kOk, ProfileStorageResult::kOk,
"interruption baseline did not commit"); "stable identity was not indexed");
database.fallback_profiles[1].button_map[2] = 3; }
flash.fail_after_programs = flash.successful_programs + 1; require(storage.ensure_identity(identity(99)) == ProfileStorageResult::kFull,
require(storage.commit(database, encoded_database, "identity catalog accepted a seventeenth stable identity");
sizeof(encoded_database)) == ControllerProfile profile{};
ProfileStorageResult::kIoError, require(storage.get(identity(1), 7, &profile) == ProfileStorageResult::kOk &&
"interrupted profile write reported success"); controller_profile_validate(profile),
"unstored profile eight did not resolve to its default");
}
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");
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; flash.fail_after_programs = -1;
ProfileStorage recovered; ProfileStorage recovered;
require(recovered.initialize(fake_io(), &recovered_database) && ControllerProfile value{};
recovered.snapshot().generation == 1 && require(recovered.initialize(fake_io()) &&
recovered_database.fallback_profiles[1].button_map[2] == 2, recovered.get(global, 0, &value) == ProfileStorageResult::kOk &&
"interrupted profile write replaced previous bank"); value.weak_rumble_scale == 11,
} "interrupted append displaced the previous record");
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; flash.corrupt_next_program = true;
require(storage.commit(database, encoded_database, second.weak_rumble_scale = 33;
sizeof(encoded_database)) == require(recovered.set(global, 0, second) == ProfileStorageResult::kIoError,
ProfileStorageResult::kIoError && "corrupt payload program reported success");
flash.successful_programs == ProfileStorage after_corruption;
programs_before_corruption + kPayloadProgramCount, require(after_corruption.initialize(fake_io()) &&
"corrupt payload programming reached the header program"); after_corruption.get(global, 0, &value) ==
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 && ProfileStorageResult::kOk &&
flash.bank_replacements == 1 && value.weak_rumble_scale == 11,
flash.erase_count == static_cast<int>( "corrupt newest record displaced the previous record");
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() { void test_compaction_preserves_latest_records() {
erase_all(); erase_all();
controller_profile_database_default(&database); const ControllerIdentity global = controller_identity_global();
ProfileStorage storage; ProfileStorage storage;
require(storage.initialize(fake_io(), &database) && require(storage.initialize(fake_io()), "catalog did not initialize");
storage.commit(database, encoded_database, ControllerProfile profile = controller_profile_default(global, 0);
sizeof(encoded_database)) == for (uint16_t write = 1; write <= 260; ++write) {
ProfileStorageResult::kOk, profile.weak_rumble_scale = static_cast<uint8_t>(write);
"header padding baseline did not commit"); require(storage.set(global, 0, profile) == ProfileStorageResult::kOk,
"catalog update failed while forcing compaction");
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<int>(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");
} }
require(storage.snapshot().active_bank == 1 && flash.erases >= 2,
flash.corrupt_header_padding_offset = -1; "full arena did not compact into its peer");
require(storage.commit(database, encoded_database, ProfileStorage reloaded;
sizeof(encoded_database)) == ControllerProfile recovered{};
ProfileStorageResult::kOk, require(reloaded.initialize(fake_io()) &&
"valid full header page did not commit"); reloaded.get(global, 0, &recovered) ==
for (size_t offset = 24; ProfileStorageResult::kOk &&
offset < PROFILE_STORAGE_RECORD_HEADER_SIZE; ++offset) { recovered.weak_rumble_scale == static_cast<uint8_t>(260),
require( "compaction did not preserve the latest profile");
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");
} }
void test_legacy_database_bank_migration() { void test_legacy_migration_is_atomic_and_complete() {
install_legacy_database_bank_fixture(); erase_all();
install_legacy_database();
ProfileStorage storage; ProfileStorage storage;
require(storage.initialize(fake_io(), &recovered_database) && require(storage.initialize(fake_io()) && storage.snapshot().valid &&
storage.snapshot().valid && storage.identity_count() == 2,
storage.snapshot().active_bank == 1 && "legacy database did not migrate");
storage.snapshot().generation == 41, const ControllerIdentity global = controller_identity_global();
"legacy v1 database bank was not selected"); ControllerProfile profile{};
require(flash.erase_count == 0, require(storage.find(global)->active_profile == 3 &&
"legacy bank admission erased flash"); storage.get(global, 3, &profile) == ProfileStorageResult::kOk &&
require(recovered_database.fallback_active_profile == 2, profile.weak_rumble_scale == 23,
"legacy fallback active profile was not preserved"); "legacy fallback profiles were not preserved");
require(storage.get(global, 7, &profile) == ProfileStorageResult::kOk &&
for (uint8_t profile_index = 0; profile.weak_rumble_scale == UINT8_MAX,
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { "new profile slots were not defaulted during migration");
const uint16_t expected_left = const ControllerIdentity stable = identity(7);
profile_index == 1 require(storage.find(stable) != nullptr &&
? 0x1234 storage.find(stable)->active_profile == 2 &&
: CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; storage.get(stable, 2, &profile) == ProfileStorageResult::kOk &&
const uint16_t expected_right = profile.strong_rumble_scale == 42,
profile_index == 1 "legacy stable identity was not preserved");
? 0xabcd
: CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
require(recovered_database.fallback_profiles[profile_index]
.triggers[0]
.digital_threshold == expected_left &&
recovered_database.fallback_profiles[profile_index]
.triggers[1]
.digital_threshold == expected_right,
"legacy fallback thresholds were not selectively migrated");
}
require(recovered_database.fallback_profiles[0]
.triggers[0]
.lower_deadzone == 30000 &&
recovered_database.fallback_profiles[0]
.triggers[0]
.upper_saturation == 40000 &&
recovered_database.fallback_profiles[0]
.triggers[1]
.lower_deadzone == 30000 &&
recovered_database.fallback_profiles[0]
.triggers[1]
.upper_saturation == 40000,
"legacy fallback raw trigger ranges were not preserved");
const ControllerProfileDatabaseEntry& entry =
recovered_database.entries[0];
require(entry.used && entry.active_profile == 3 &&
entry.identity.stable &&
entry.identity.transport == ControllerTransport::kClassic &&
entry.identity.address_type == 7 &&
entry.identity.address[0] == 1 &&
entry.identity.address[5] == 6 &&
entry.identity.vendor_id == 0x057e &&
entry.identity.product_id == 0x2009,
"legacy entry identity or active profile was not preserved");
for (uint8_t profile_index = 0;
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) {
const uint16_t expected_left =
profile_index == 3
? 0x1234
: CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
const uint16_t expected_right =
profile_index == 3
? 0xabcd
: CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
require(entry.profiles[profile_index]
.triggers[0]
.digital_threshold == expected_left &&
entry.profiles[profile_index]
.triggers[1]
.digital_threshold == expected_right,
"legacy entry thresholds were not selectively migrated");
}
require(entry.profiles[0].triggers[0].lower_deadzone == 30000 &&
entry.profiles[0].triggers[0].upper_saturation == 40000 &&
entry.profiles[0].triggers[1].lower_deadzone == 30000 &&
entry.profiles[0].triggers[1].upper_saturation == 40000,
"legacy entry raw trigger ranges were not preserved");
recovered_database.fallback_profiles[2].weak_rumble_scale = 17;
require(storage.commit(recovered_database, 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(&current_record[6]) ==
CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION &&
fixture_read_u16(&current_payload[4]) ==
CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION,
"post-migration commit did not emit v2 storage schemas");
constexpr size_t kFallbackOffset =
CONTROLLER_PROFILE_DATABASE_HEADER_SIZE;
constexpr size_t kEntryOffset =
kFallbackOffset +
CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE;
for (uint8_t profile_index = 0;
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) {
require(fixture_read_u16(
&current_payload[kFallbackOffset +
profile_index *
CONTROLLER_PROFILE_ENCODED_SIZE]) ==
CONTROLLER_PROFILE_SCHEMA_VERSION &&
fixture_read_u16(
&current_payload[
kEntryOffset +
CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE +
profile_index *
CONTROLLER_PROFILE_ENCODED_SIZE]) ==
CONTROLLER_PROFILE_SCHEMA_VERSION,
"post-migration commit retained a v1 profile");
}
require(fixture_read_u16(&flash.bytes[1][6]) ==
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION &&
fixture_read_u16(
&flash.bytes[1][PROFILE_STORAGE_RECORD_HEADER_SIZE + 4]) ==
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION,
"post-migration commit erased or rewrote the admitted legacy bank");
ProfileStorage reloaded; ProfileStorage reloaded;
require(reloaded.initialize(fake_io(), &database) && require(reloaded.initialize(fake_io()) && reloaded.find(stable) != nullptr &&
reloaded.snapshot().generation == 42 && reloaded.find(stable)->active_profile == 2,
database.fallback_profiles[2].weak_rumble_scale == 17 && "migrated catalog did not survive reload");
database.fallback_profiles[0]
.triggers[0]
.lower_deadzone == 30000 &&
database.fallback_profiles[0]
.triggers[0]
.upper_saturation == 40000 &&
database.fallback_profiles[0]
.triggers[0]
.digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD &&
database.fallback_profiles[1]
.triggers[0]
.digital_threshold == 0x1234 &&
database.fallback_profiles[1]
.triggers[1]
.digital_threshold == 0xabcd &&
database.entries[0].used &&
database.entries[0].active_profile == 3 &&
database.entries[0]
.profiles[0]
.triggers[0]
.lower_deadzone == 30000 &&
database.entries[0]
.profiles[0]
.triggers[0]
.upper_saturation == 40000 &&
database.entries[0]
.profiles[0]
.triggers[0]
.digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD &&
database.entries[0]
.profiles[3]
.triggers[0]
.digital_threshold == 0x1234 &&
database.entries[0]
.profiles[3]
.triggers[1]
.digital_threshold == 0xabcd,
"v2 migration commit did not reload without data loss");
} }
} // namespace } // namespace
int main() { int main() {
test_two_bank_recovery(); test_empty_catalog_and_eight_profiles();
test_initialize_requires_batch_replacement(); test_identity_capacity_and_defaults();
test_interrupted_commit_retains_previous_bank(); test_interrupted_and_corrupt_append_recovery();
test_successful_header_program_is_commit_point(); test_compaction_preserves_latest_records();
test_payload_corruption_prevents_header_publication(); test_legacy_migration_is_atomic_and_complete();
test_batched_bank_replacement_is_one_atomic_operation(); std::cout << "profile storage tests passed\n";
test_header_padding_corruption_fails_commit_and_recovery();
test_legacy_database_bank_migration();
return 0; return 0;
} }

View file

@ -1598,11 +1598,11 @@ def test_profile_cli_json_round_trip_activate_and_reset(
assert ( assert (
config_manager.main( config_manager.main(
["profiles", "activate", "4", "--identity", "1"] ["profiles", "activate", "8", "--identity", "1"]
) )
== 0 == 0
) )
assert device.active_profiles[device.stable_identity.to_bytes()] == 3 assert device.active_profiles[device.stable_identity.to_bytes()] == 7
_ = capsys.readouterr() _ = capsys.readouterr()
before_reset_requests = len(device.requests) before_reset_requests = len(device.requests)

View file

@ -87,6 +87,13 @@ def test_editor_serves_assets_and_complete_schema(
assert schema["rumble_policies"] == list(config_manager.RUMBLE_POLICIES) assert schema["rumble_policies"] == list(config_manager.RUMBLE_POLICIES)
assert schema["turbo_modes"] == list(config_manager.TURBO_MODES) assert schema["turbo_modes"] == list(config_manager.TURBO_MODES)
assert schema["macro_overrides"] == list(config_manager.MACRO_OVERRIDE_NAMES) 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 ( assert (
config_manager.ControllerProfile.from_json( config_manager.ControllerProfile.from_json(
json.dumps(schema["default_profile"]) json.dumps(schema["default_profile"])
@ -152,13 +159,13 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
"style": "xbox", "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 status == 200
assert selected["active"] is False assert selected["active"] is False
profile = custom_profile().to_json_object() profile = custom_profile().to_json_object()
status, stored = request_json( status, stored = request_json(
f"{base_url}/api/profiles/1/3", f"{base_url}/api/profiles/1/8",
method="PUT", method="PUT",
value=profile, value=profile,
token=token, token=token,
@ -167,21 +174,21 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
assert stored["stored_generation"] == 8 assert stored["stored_generation"] == 8
assert ( assert (
config_manager.ControllerProfile.from_bytes( config_manager.ControllerProfile.from_bytes(
device.profiles[(device.stable_identity.to_bytes(), 2)] device.profiles[(device.stable_identity.to_bytes(), 7)]
) )
== custom_profile() == custom_profile()
) )
assert device.profile_chunk_sizes == [40, 40, 40, 40, 40, 40, 16] assert device.profile_chunk_sizes == [40, 40, 40, 40, 40, 40, 16]
status, activated = request_json( status, activated = request_json(
f"{base_url}/api/profiles/1/3/activate", f"{base_url}/api/profiles/1/8/activate",
method="POST", method="POST",
token=token, token=token,
) )
assert status == 200 assert status == 200
assert activated["stored_generation"] == 9 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( def test_editor_rejects_invalid_or_unauthorized_mutations(