Add indexed eight-profile catalog
This commit is contained in:
parent
ce93f34d94
commit
541ca49201
18 changed files with 2033 additions and 1970 deletions
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
41
README.md
41
README.md
|
|
@ -141,7 +141,7 @@ Development USB identities are `CAFE:4010` (XInput), `CAFE:4020` (DInput), and `
|
||||||
|
|
||||||
The editor selects Switch Pro, DualSense, or Xbox artwork from the connected controller's USB VID/PID and places each remappable control directly over the matching physical button. Controller artwork is from [AL2009man/Gamepad-Asset-Pack](https://github.com/AL2009man/Gamepad-Asset-Pack) under its MIT license; the bundled license and source revision are recorded beside the assets.
|
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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,132 +20,105 @@ 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() {
|
||||||
const uintptr_t binary_end =
|
const uintptr_t binary_end =
|
||||||
reinterpret_cast<uintptr_t>(&__flash_binary_end) - XIP_BASE;
|
reinterpret_cast<uintptr_t>(&__flash_binary_end) - XIP_BASE;
|
||||||
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) {
|
||||||
!storage_region_available()) {
|
return false;
|
||||||
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;
|
||||||
}
|
}
|
||||||
FlashBankReplacement replacement{
|
}
|
||||||
bank,
|
return true;
|
||||||
payload,
|
|
||||||
payload_size,
|
|
||||||
header,
|
|
||||||
false,
|
|
||||||
};
|
|
||||||
return flash_safe_execute(
|
|
||||||
perform_flash_bank_replacement, &replacement,
|
|
||||||
kFlashSafeExecuteTimeoutMs) == PICO_OK &&
|
|
||||||
replacement.replaced;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
bool program_page(void *, uint8_t arena, size_t offset, const uint8_t *page,
|
||||||
|
size_t size) {
|
||||||
|
if (arena >= PROFILE_STORAGE_ARENA_COUNT || page == nullptr ||
|
||||||
|
size != PROFILE_STORAGE_PAGE_SIZE ||
|
||||||
|
offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
|
||||||
|
offset > PROFILE_STORAGE_ARENA_SIZE ||
|
||||||
|
size > PROFILE_STORAGE_ARENA_SIZE - offset ||
|
||||||
|
!storage_region_available()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ProgramOperation operation{
|
||||||
|
kProfileStorageOffset + arena * PROFILE_STORAGE_ARENA_SIZE + offset,
|
||||||
|
page,
|
||||||
|
};
|
||||||
|
return flash_safe_execute(perform_program, &operation,
|
||||||
|
kFlashSafeExecuteTimeoutMs) == PICO_OK &&
|
||||||
|
memcmp(reinterpret_cast<const void *>(XIP_BASE + operation.offset),
|
||||||
|
page, size) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // 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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,251 +1,762 @@
|
||||||
#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;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool generation_is_newer(uint32_t candidate, uint32_t current) {
|
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,
|
|
||||||
size_t size) {
|
|
||||||
const auto* read_context =
|
|
||||||
static_cast<const DatabaseReadContext*>(context);
|
|
||||||
return read_context->io->read(
|
|
||||||
read_context->io->context, read_context->bank,
|
|
||||||
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset, output, size);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
uint32_t pack_record(uint8_t arena, size_t offset) {
|
||||||
|
return static_cast<uint32_t>(arena * PROFILE_STORAGE_ARENA_SIZE + offset);
|
||||||
uint32_t profile_storage_crc32(const uint8_t* data, size_t size) {
|
|
||||||
if (data == nullptr && size != 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return ~crc32_update(0xffffffffu, data, size);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ProfileStorage::initialize(const ProfileStorageIo& io,
|
uint8_t record_arena(uint32_t record) {
|
||||||
ControllerProfileDatabase* database) {
|
return static_cast<uint8_t>(record / PROFILE_STORAGE_ARENA_SIZE);
|
||||||
io_ = io;
|
|
||||||
snapshot_ = {};
|
|
||||||
initialized_ =
|
|
||||||
database != nullptr && io_.read != nullptr &&
|
|
||||||
io_.replace_bank != nullptr &&
|
|
||||||
io_.bank_size >= PROFILE_STORAGE_BANK_SIZE &&
|
|
||||||
io_.sector_size == PROFILE_STORAGE_SECTOR_SIZE &&
|
|
||||||
io_.page_size == PROFILE_STORAGE_PAGE_SIZE &&
|
|
||||||
io_.bank_size % io_.sector_size == 0 &&
|
|
||||||
io_.sector_size % io_.page_size == 0;
|
|
||||||
if (!initialized_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
controller_profile_database_default(database);
|
|
||||||
BankHeader headers[PROFILE_STORAGE_BANK_COUNT]{};
|
|
||||||
bool valid[PROFILE_STORAGE_BANK_COUNT]{};
|
|
||||||
for (uint8_t bank = 0; bank < PROFILE_STORAGE_BANK_COUNT; ++bank) {
|
|
||||||
valid[bank] = read_header(bank, &headers[bank]) &&
|
|
||||||
validate_payload(bank, headers[bank].payload_crc);
|
|
||||||
}
|
|
||||||
|
|
||||||
uint8_t first = 0;
|
|
||||||
uint8_t second = 1;
|
|
||||||
if (valid[1] &&
|
|
||||||
(!valid[0] || generation_is_newer(headers[1].generation,
|
|
||||||
headers[0].generation))) {
|
|
||||||
first = 1;
|
|
||||||
second = 0;
|
|
||||||
}
|
|
||||||
const uint8_t order[PROFILE_STORAGE_BANK_COUNT] = {first, second};
|
|
||||||
for (uint8_t candidate : order) {
|
|
||||||
if (!valid[candidate] || !decode_bank(candidate, database)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
snapshot_.valid = true;
|
|
||||||
snapshot_.generation = headers[candidate].generation;
|
|
||||||
snapshot_.payload_crc = headers[candidate].payload_crc;
|
|
||||||
snapshot_.active_bank = candidate;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
controller_profile_database_default(database);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ProfileStorageResult ProfileStorage::commit(
|
size_t record_offset(uint32_t record) {
|
||||||
const ControllerProfileDatabase& database,
|
return record % PROFILE_STORAGE_ARENA_SIZE;
|
||||||
uint8_t* encoded_database, size_t encoded_database_size) {
|
}
|
||||||
if (!initialized_ || !controller_profile_database_validate(database) ||
|
|
||||||
encoded_database == nullptr ||
|
|
||||||
encoded_database_size < CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
|
|
||||||
!controller_profile_database_encode_range(
|
|
||||||
database, 0, encoded_database,
|
|
||||||
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE)) {
|
|
||||||
return ProfileStorageResult::kInvalidArgument;
|
|
||||||
}
|
|
||||||
if (snapshot_.valid &&
|
|
||||||
payload_matches_encoded(
|
|
||||||
snapshot_.active_bank, encoded_database,
|
|
||||||
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE)) {
|
|
||||||
return ProfileStorageResult::kUnchanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uint32_t crc = profile_storage_crc32(
|
bool bytes_are(uint8_t value, const uint8_t *data, size_t size) {
|
||||||
encoded_database, CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE);
|
for (size_t index = 0; index < size; ++index) {
|
||||||
const uint8_t target_bank =
|
if (data[index] != value) {
|
||||||
snapshot_.valid ? snapshot_.active_bank ^ 1u : 0;
|
return false;
|
||||||
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 true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
uint32_t profile_storage_crc32(const uint8_t *data, size_t size) {
|
||||||
|
if (data == nullptr && size != 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return ~crc32_update(0xffffffffu, data, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ProfileStorage::initialize(const ProfileStorageIo &io) {
|
||||||
|
io_ = io;
|
||||||
|
snapshot_ = {};
|
||||||
|
identity_count_ = 0;
|
||||||
|
epoch_ = 0;
|
||||||
|
next_offset_ = PROFILE_STORAGE_RECORDS_OFFSET;
|
||||||
|
initialized_ = io_.read != nullptr && io_.erase_arena != nullptr &&
|
||||||
|
io_.program_page != nullptr &&
|
||||||
|
io_.arena_size == PROFILE_STORAGE_ARENA_SIZE &&
|
||||||
|
io_.sector_size == PROFILE_STORAGE_SECTOR_SIZE &&
|
||||||
|
io_.page_size == PROFILE_STORAGE_PAGE_SIZE;
|
||||||
|
if (!initialized_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileStorageIdentityIndex
|
||||||
|
candidate[PROFILE_STORAGE_ARENA_COUNT]
|
||||||
|
[CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1]{};
|
||||||
|
uint8_t counts[PROFILE_STORAGE_ARENA_COUNT]{};
|
||||||
|
uint32_t epochs[PROFILE_STORAGE_ARENA_COUNT]{};
|
||||||
|
uint32_t generations[PROFILE_STORAGE_ARENA_COUNT]{};
|
||||||
|
uint32_t payload_crcs[PROFILE_STORAGE_ARENA_COUNT]{};
|
||||||
|
size_t offsets[PROFILE_STORAGE_ARENA_COUNT]{};
|
||||||
|
bool valid[PROFILE_STORAGE_ARENA_COUNT]{};
|
||||||
|
for (uint8_t arena = 0; arena < PROFILE_STORAGE_ARENA_COUNT; ++arena) {
|
||||||
|
valid[arena] = scan_arena(arena, &epochs[arena], &generations[arena],
|
||||||
|
&payload_crcs[arena], &offsets[arena],
|
||||||
|
candidate[arena], &counts[arena]);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t selected = 0;
|
||||||
|
if (valid[1] && (!valid[0] || generation_is_newer(epochs[1], epochs[0]))) {
|
||||||
|
selected = 1;
|
||||||
|
}
|
||||||
|
if (valid[selected]) {
|
||||||
|
for (size_t index = 0;
|
||||||
|
index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1; ++index) {
|
||||||
|
index_[index] = candidate[selected][index];
|
||||||
|
}
|
||||||
|
identity_count_ = counts[selected];
|
||||||
|
epoch_ = epochs[selected];
|
||||||
|
next_offset_ = offsets[selected];
|
||||||
snapshot_.valid = true;
|
snapshot_.valid = true;
|
||||||
snapshot_.generation = generation;
|
snapshot_.active_bank = selected;
|
||||||
snapshot_.payload_crc = crc;
|
snapshot_.generation = generations[selected];
|
||||||
snapshot_.active_bank = target_bank;
|
snapshot_.payload_crc = payload_crcs[selected];
|
||||||
return ProfileStorageResult::kOk;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProfileStorageSnapshot& ProfileStorage::snapshot() const {
|
|
||||||
return snapshot_;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ProfileStorage::read_header(uint8_t bank, BankHeader* output) const {
|
|
||||||
uint8_t header[PROFILE_STORAGE_RECORD_HEADER_SIZE]{};
|
|
||||||
if (bank >= PROFILE_STORAGE_BANK_COUNT || output == nullptr ||
|
|
||||||
!io_.read(io_.context, bank, 0, header, sizeof(header)) ||
|
|
||||||
memcmp(header, kRecordMagic, sizeof(kRecordMagic)) != 0 ||
|
|
||||||
storage_read_u16(&header[4]) != kRecordFormatVersion ||
|
|
||||||
(storage_read_u16(&header[6]) !=
|
|
||||||
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION &&
|
|
||||||
storage_read_u16(&header[6]) !=
|
|
||||||
CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION) ||
|
|
||||||
storage_read_u32(&header[12]) !=
|
|
||||||
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
|
|
||||||
profile_storage_crc32(header, kHeaderCrcOffset) !=
|
|
||||||
storage_read_u32(&header[kHeaderCrcOffset])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (size_t offset = kHeaderFieldsSize;
|
|
||||||
offset < sizeof(header); ++offset) {
|
|
||||||
if (header[offset] != 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
output->generation = storage_read_u32(&header[8]);
|
|
||||||
output->payload_crc = storage_read_u32(&header[16]);
|
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrate_legacy()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (ProfileStorageIdentityIndex &entry : index_) {
|
||||||
|
entry = {};
|
||||||
|
}
|
||||||
|
identity_count_ = 1;
|
||||||
|
index_[0].used = true;
|
||||||
|
index_[0].identity = controller_identity_global();
|
||||||
|
for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) {
|
||||||
|
index_[0].profile_record[profile] = PROFILE_STORAGE_NO_RECORD;
|
||||||
|
}
|
||||||
|
return publish_empty_arena(0, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ProfileStorage::validate_payload(uint8_t bank,
|
ProfileStorageResult
|
||||||
uint32_t expected_crc) const {
|
ProfileStorage::ensure_identity(const ControllerIdentity &identity) {
|
||||||
|
if (!initialized_ || !valid_identity(identity)) {
|
||||||
|
return ProfileStorageResult::kInvalidArgument;
|
||||||
|
}
|
||||||
|
if (find(identity) != nullptr) {
|
||||||
|
return ProfileStorageResult::kUnchanged;
|
||||||
|
}
|
||||||
|
if (identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) {
|
||||||
|
return ProfileStorageResult::kFull;
|
||||||
|
}
|
||||||
|
return append(RecordType::kActivate, identity, 0, nullptr, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileStorageResult ProfileStorage::get(const ControllerIdentity &identity,
|
||||||
|
uint8_t profile_index,
|
||||||
|
ControllerProfile *output) const {
|
||||||
|
if (!initialized_ || output == nullptr || !valid_identity(identity) ||
|
||||||
|
profile_index >= CONTROLLER_PROFILE_COUNT) {
|
||||||
|
return ProfileStorageResult::kInvalidArgument;
|
||||||
|
}
|
||||||
|
const ProfileStorageIdentityIndex *entry = find(identity);
|
||||||
|
if (entry == nullptr ||
|
||||||
|
entry->profile_record[profile_index] == PROFILE_STORAGE_NO_RECORD) {
|
||||||
|
*output = controller_profile_default(identity, profile_index);
|
||||||
|
return ProfileStorageResult::kOk;
|
||||||
|
}
|
||||||
|
return read_profile_record(entry->profile_record[profile_index], output)
|
||||||
|
? ProfileStorageResult::kOk
|
||||||
|
: ProfileStorageResult::kIoError;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileStorageResult ProfileStorage::set(const ControllerIdentity &identity,
|
||||||
|
uint8_t profile_index,
|
||||||
|
const ControllerProfile &profile) {
|
||||||
|
if (!initialized_ || !valid_identity(identity) ||
|
||||||
|
profile_index >= CONTROLLER_PROFILE_COUNT ||
|
||||||
|
!controller_profile_validate(profile)) {
|
||||||
|
return ProfileStorageResult::kInvalidArgument;
|
||||||
|
}
|
||||||
|
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||||
|
if (!controller_profile_encode(profile, encoded, sizeof(encoded))) {
|
||||||
|
return ProfileStorageResult::kInvalidArgument;
|
||||||
|
}
|
||||||
|
ControllerProfile current{};
|
||||||
|
uint8_t current_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||||
|
if (get(identity, profile_index, ¤t) == ProfileStorageResult::kOk &&
|
||||||
|
controller_profile_encode(current, current_encoded,
|
||||||
|
sizeof(current_encoded)) &&
|
||||||
|
memcmp(encoded, current_encoded, sizeof(encoded)) == 0) {
|
||||||
|
return ProfileStorageResult::kUnchanged;
|
||||||
|
}
|
||||||
|
if (find(identity) == nullptr &&
|
||||||
|
identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) {
|
||||||
|
return ProfileStorageResult::kFull;
|
||||||
|
}
|
||||||
|
return append(RecordType::kProfile, identity, profile_index, encoded,
|
||||||
|
sizeof(encoded));
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileStorageResult ProfileStorage::reset(const ControllerIdentity &identity,
|
||||||
|
uint8_t profile_index) {
|
||||||
|
if (!initialized_ || !valid_identity(identity) ||
|
||||||
|
(profile_index != CONTROLLER_PROFILE_ALL &&
|
||||||
|
profile_index >= CONTROLLER_PROFILE_COUNT)) {
|
||||||
|
return ProfileStorageResult::kInvalidArgument;
|
||||||
|
}
|
||||||
|
const ProfileStorageIdentityIndex *entry = find(identity);
|
||||||
|
if (entry == nullptr) {
|
||||||
|
return ProfileStorageResult::kUnchanged;
|
||||||
|
}
|
||||||
|
if (profile_index == CONTROLLER_PROFILE_ALL) {
|
||||||
|
bool changed = entry->active_profile != 0;
|
||||||
|
for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) {
|
||||||
|
changed = changed ||
|
||||||
|
entry->profile_record[profile] != PROFILE_STORAGE_NO_RECORD;
|
||||||
|
}
|
||||||
|
return changed ? append(RecordType::kResetAll, identity,
|
||||||
|
CONTROLLER_PROFILE_ALL, nullptr, 0)
|
||||||
|
: ProfileStorageResult::kUnchanged;
|
||||||
|
}
|
||||||
|
if (entry->profile_record[profile_index] == PROFILE_STORAGE_NO_RECORD) {
|
||||||
|
return ProfileStorageResult::kUnchanged;
|
||||||
|
}
|
||||||
|
return append(RecordType::kReset, identity, profile_index, nullptr, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileStorageResult
|
||||||
|
ProfileStorage::activate(const ControllerIdentity &identity,
|
||||||
|
uint8_t profile_index) {
|
||||||
|
if (!initialized_ || !valid_identity(identity) ||
|
||||||
|
profile_index >= CONTROLLER_PROFILE_COUNT) {
|
||||||
|
return ProfileStorageResult::kInvalidArgument;
|
||||||
|
}
|
||||||
|
const ProfileStorageIdentityIndex *entry = find(identity);
|
||||||
|
if (entry != nullptr && entry->active_profile == profile_index) {
|
||||||
|
return ProfileStorageResult::kUnchanged;
|
||||||
|
}
|
||||||
|
if (entry == nullptr &&
|
||||||
|
identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) {
|
||||||
|
return ProfileStorageResult::kFull;
|
||||||
|
}
|
||||||
|
return append(RecordType::kActivate, identity, profile_index, nullptr, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t ProfileStorage::identity_count() const { return identity_count_; }
|
||||||
|
|
||||||
|
const ProfileStorageIdentityIndex *
|
||||||
|
ProfileStorage::identity(uint8_t index) const {
|
||||||
|
return index < identity_count_ ? &index_[index] : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileStorageIdentityIndex *
|
||||||
|
ProfileStorage::find(const ControllerIdentity &identity) const {
|
||||||
|
for (uint8_t index = 0; index < identity_count_; ++index) {
|
||||||
|
if (index_[index].used &&
|
||||||
|
controller_identity_equal(index_[index].identity, identity)) {
|
||||||
|
return &index_[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileStorageSnapshot &ProfileStorage::snapshot() const {
|
||||||
|
return snapshot_;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ProfileStorage::scan_arena(uint8_t arena, uint32_t *epoch,
|
||||||
|
uint32_t *generation, uint32_t *payload_crc,
|
||||||
|
size_t *next_offset,
|
||||||
|
ProfileStorageIdentityIndex *index,
|
||||||
|
uint8_t *identity_count) const {
|
||||||
|
uint8_t superblock[PROFILE_STORAGE_PAGE_SIZE]{};
|
||||||
|
if (!io_.read(io_.context, arena, 0, superblock, sizeof(superblock)) ||
|
||||||
|
memcmp(superblock, kSuperblockMagic, sizeof(kSuperblockMagic)) != 0 ||
|
||||||
|
read_u16(&superblock[4]) != kCatalogVersion ||
|
||||||
|
profile_storage_crc32(superblock, 12) != read_u32(&superblock[12]) ||
|
||||||
|
!bytes_are(0, &superblock[16], sizeof(superblock) - 16)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*epoch = read_u32(&superblock[8]);
|
||||||
|
*generation = 0;
|
||||||
|
*payload_crc = 0;
|
||||||
|
*next_offset = PROFILE_STORAGE_RECORDS_OFFSET;
|
||||||
|
*identity_count = 1;
|
||||||
|
index[0].used = true;
|
||||||
|
index[0].identity = controller_identity_global();
|
||||||
|
for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) {
|
||||||
|
index[0].profile_record[profile] = PROFILE_STORAGE_NO_RECORD;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t offset = PROFILE_STORAGE_RECORDS_OFFSET;
|
||||||
|
offset + PROFILE_STORAGE_RECORD_SIZE <= PROFILE_STORAGE_ARENA_SIZE;
|
||||||
|
offset += PROFILE_STORAGE_RECORD_SIZE) {
|
||||||
|
uint8_t header[PROFILE_STORAGE_PAGE_SIZE]{};
|
||||||
|
uint8_t payload_prefix[4]{};
|
||||||
|
if (!io_.read(io_.context, arena, offset, header, sizeof(header)) ||
|
||||||
|
!io_.read(io_.context, arena, offset + kRecordPayloadOffset,
|
||||||
|
payload_prefix, sizeof(payload_prefix))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!bytes_are(0xff, header, 4) ||
|
||||||
|
!bytes_are(0xff, payload_prefix, sizeof(payload_prefix))) {
|
||||||
|
*next_offset = offset + PROFILE_STORAGE_RECORD_SIZE;
|
||||||
|
}
|
||||||
|
if (memcmp(header, kRecordMagic, sizeof(kRecordMagic)) != 0 ||
|
||||||
|
read_u16(&header[4]) != kCatalogVersion ||
|
||||||
|
profile_storage_crc32(header, kRecordHeaderCrcOffset) !=
|
||||||
|
read_u32(&header[kRecordHeaderCrcOffset]) ||
|
||||||
|
!bytes_are(0, &header[kRecordHeaderCrcOffset + 4],
|
||||||
|
sizeof(header) - kRecordHeaderCrcOffset - 4)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const auto type = static_cast<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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ProfileStorage::read_profile_record(uint32_t record,
|
||||||
|
ControllerProfile *output) const {
|
||||||
|
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||||
|
return record != PROFILE_STORAGE_NO_RECORD &&
|
||||||
|
io_.read(io_.context, record_arena(record),
|
||||||
|
record_offset(record) + kRecordPayloadOffset, encoded,
|
||||||
|
sizeof(encoded)) &&
|
||||||
|
controller_profile_decode(encoded, sizeof(encoded), output);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileStorageResult ProfileStorage::append(RecordType type,
|
||||||
|
const ControllerIdentity &identity,
|
||||||
|
uint8_t profile_index,
|
||||||
|
const uint8_t *payload,
|
||||||
|
size_t payload_size) {
|
||||||
|
if (next_offset_ + PROFILE_STORAGE_RECORD_SIZE > PROFILE_STORAGE_ARENA_SIZE) {
|
||||||
|
const ProfileStorageResult result = compact();
|
||||||
|
if (result != ProfileStorageResult::kOk) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const uint32_t generation = snapshot_.generation + 1u;
|
||||||
|
const size_t offset = next_offset_;
|
||||||
|
next_offset_ += PROFILE_STORAGE_RECORD_SIZE;
|
||||||
|
if (!write_record(snapshot_.active_bank, offset, type, identity,
|
||||||
|
profile_index, generation, payload, payload_size)) {
|
||||||
|
return ProfileStorageResult::kIoError;
|
||||||
|
}
|
||||||
|
apply_record(index_, &identity_count_, type, identity, profile_index,
|
||||||
|
generation, pack_record(snapshot_.active_bank, offset));
|
||||||
|
snapshot_.generation = generation;
|
||||||
|
snapshot_.payload_crc = profile_storage_crc32(payload, payload_size);
|
||||||
|
return ProfileStorageResult::kOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileStorageResult ProfileStorage::compact() {
|
||||||
|
const uint8_t target = snapshot_.active_bank ^ 1u;
|
||||||
|
if (!io_.erase_arena(io_.context, target)) {
|
||||||
|
return ProfileStorageResult::kIoError;
|
||||||
|
}
|
||||||
|
size_t offset = PROFILE_STORAGE_RECORDS_OFFSET;
|
||||||
|
uint32_t generation = snapshot_.generation;
|
||||||
|
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||||
|
for (uint8_t identity_index = 0; identity_index < identity_count_;
|
||||||
|
++identity_index) {
|
||||||
|
const ProfileStorageIdentityIndex &entry = index_[identity_index];
|
||||||
|
for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) {
|
||||||
|
if (entry.profile_record[profile] == PROFILE_STORAGE_NO_RECORD) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ControllerProfile decoded{};
|
||||||
|
if (!read_profile_record(entry.profile_record[profile], &decoded) ||
|
||||||
|
!controller_profile_encode(decoded, encoded, sizeof(encoded)) ||
|
||||||
|
!write_record(target, offset, RecordType::kProfile, entry.identity,
|
||||||
|
profile, ++generation, encoded, sizeof(encoded))) {
|
||||||
|
return ProfileStorageResult::kIoError;
|
||||||
|
}
|
||||||
|
offset += PROFILE_STORAGE_RECORD_SIZE;
|
||||||
|
}
|
||||||
|
if (!write_record(target, offset, RecordType::kActivate, entry.identity,
|
||||||
|
entry.active_profile, ++generation, nullptr, 0)) {
|
||||||
|
return ProfileStorageResult::kIoError;
|
||||||
|
}
|
||||||
|
offset += PROFILE_STORAGE_RECORD_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t superblock[PROFILE_STORAGE_PAGE_SIZE]{};
|
||||||
|
memcpy(superblock, kSuperblockMagic, sizeof(kSuperblockMagic));
|
||||||
|
write_u16(&superblock[4], kCatalogVersion);
|
||||||
|
write_u32(&superblock[8], epoch_ + 1u);
|
||||||
|
write_u32(&superblock[12], profile_storage_crc32(superblock, 12));
|
||||||
|
if (!io_.program_page(io_.context, target, 0, superblock,
|
||||||
|
sizeof(superblock))) {
|
||||||
|
return ProfileStorageResult::kIoError;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileStorageIdentityIndex
|
||||||
|
rebuilt[CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1]{};
|
||||||
|
uint8_t rebuilt_count = 0;
|
||||||
|
uint32_t rebuilt_epoch = 0;
|
||||||
|
uint32_t rebuilt_generation = 0;
|
||||||
|
uint32_t rebuilt_payload_crc = 0;
|
||||||
|
size_t rebuilt_offset = 0;
|
||||||
|
if (!scan_arena(target, &rebuilt_epoch, &rebuilt_generation,
|
||||||
|
&rebuilt_payload_crc, &rebuilt_offset, rebuilt,
|
||||||
|
&rebuilt_count)) {
|
||||||
|
return ProfileStorageResult::kIoError;
|
||||||
|
}
|
||||||
|
for (size_t index = 0;
|
||||||
|
index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1; ++index) {
|
||||||
|
index_[index] = rebuilt[index];
|
||||||
|
}
|
||||||
|
identity_count_ = rebuilt_count;
|
||||||
|
epoch_ = rebuilt_epoch;
|
||||||
|
next_offset_ = rebuilt_offset;
|
||||||
|
snapshot_.active_bank = target;
|
||||||
|
snapshot_.generation = rebuilt_generation;
|
||||||
|
snapshot_.payload_crc = rebuilt_payload_crc;
|
||||||
|
snapshot_.valid = true;
|
||||||
|
return ProfileStorageResult::kOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ProfileStorage::migrate_legacy() {
|
||||||
|
struct LegacyHeader {
|
||||||
|
bool valid = false;
|
||||||
|
uint32_t generation = 0;
|
||||||
|
uint32_t crc = 0;
|
||||||
|
uint8_t bank = 0;
|
||||||
|
} headers[PROFILE_STORAGE_LEGACY_BANK_COUNT]{};
|
||||||
|
const uint8_t arena =
|
||||||
|
static_cast<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,
|
crc = crc32_update(crc, page, size);
|
||||||
page, size)) {
|
payload_offset += size;
|
||||||
return false;
|
remaining -= size;
|
||||||
}
|
|
||||||
crc = crc32_update(crc, page, 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,
|
||||||
size_t payload_size) const {
|
uint8_t profile_index, uint32_t generation,
|
||||||
uint8_t stored[PROFILE_STORAGE_PAGE_SIZE]{};
|
const uint8_t *payload,
|
||||||
for (size_t offset = 0; offset < payload_size;
|
size_t payload_size) const {
|
||||||
offset += sizeof(stored)) {
|
if (arena >= PROFILE_STORAGE_ARENA_COUNT ||
|
||||||
const size_t size =
|
offset < PROFILE_STORAGE_RECORDS_OFFSET ||
|
||||||
payload_size - offset < sizeof(stored)
|
offset + PROFILE_STORAGE_RECORD_SIZE > PROFILE_STORAGE_ARENA_SIZE ||
|
||||||
? payload_size - offset
|
offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
|
||||||
: sizeof(stored);
|
(type == RecordType::kProfile
|
||||||
if (!io_.read(
|
? payload == nullptr ||
|
||||||
io_.context, bank,
|
payload_size != CONTROLLER_PROFILE_ENCODED_SIZE
|
||||||
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset,
|
: payload_size != 0)) {
|
||||||
stored, size) ||
|
return false;
|
||||||
memcmp(stored, &payload[offset], size) != 0) {
|
}
|
||||||
return false;
|
if (payload_size != 0) {
|
||||||
}
|
if (!io_.program_page(io_.context, arena, offset + kRecordPayloadOffset,
|
||||||
|
payload, PROFILE_STORAGE_PAGE_SIZE)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
}
|
||||||
|
uint8_t header[PROFILE_STORAGE_PAGE_SIZE]{};
|
||||||
|
memcpy(header, kRecordMagic, sizeof(kRecordMagic));
|
||||||
|
write_u16(&header[4], kCatalogVersion);
|
||||||
|
header[6] = static_cast<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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,79 +5,125 @@
|
||||||
|
|
||||||
#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 {
|
||||||
bool valid = false;
|
bool valid = false;
|
||||||
uint32_t generation = 0;
|
uint32_t generation = 0;
|
||||||
uint32_t payload_crc = 0;
|
uint32_t payload_crc = 0;
|
||||||
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,
|
||||||
size_t payload_size) const;
|
const ControllerIdentity &identity,
|
||||||
|
uint8_t profile_index, const uint8_t *payload,
|
||||||
|
size_t payload_size);
|
||||||
|
ProfileStorageResult compact();
|
||||||
|
bool migrate_legacy();
|
||||||
|
bool publish_empty_arena(uint8_t arena, uint32_t epoch);
|
||||||
|
bool write_record(uint8_t arena, size_t offset, RecordType type,
|
||||||
|
const ControllerIdentity &identity, uint8_t profile_index,
|
||||||
|
uint32_t generation, const uint8_t *payload,
|
||||||
|
size_t payload_size) const;
|
||||||
|
void apply_record(ProfileStorageIdentityIndex *index, uint8_t *identity_count,
|
||||||
|
RecordType type, const ControllerIdentity &identity,
|
||||||
|
uint8_t profile_index, uint32_t generation,
|
||||||
|
uint32_t record) const;
|
||||||
|
|
||||||
ProfileStorageIo io_{};
|
ProfileStorageIo io_{};
|
||||||
ProfileStorageSnapshot snapshot_{};
|
ProfileStorageSnapshot snapshot_{};
|
||||||
bool initialized_ = false;
|
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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 & custom macro</h3>
|
<h3>Bindings & 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>
|
||||||
|
|
|
||||||
|
|
@ -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 ||
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
return false;
|
||||||
if (bank >= PROFILE_STORAGE_BANK_COUNT || payload == nullptr ||
|
}
|
||||||
header == nullptr ||
|
memset(storage->bytes[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE);
|
||||||
payload_size != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
|
return true;
|
||||||
header_size != PROFILE_STORAGE_RECORD_HEADER_SIZE) {
|
}
|
||||||
return false;
|
|
||||||
}
|
bool fake_program(void *context, uint8_t arena, size_t offset,
|
||||||
++storage->bank_replacements;
|
const uint8_t *page, size_t size) {
|
||||||
memset(storage->bytes[bank], 0xff, PROFILE_STORAGE_BANK_SIZE);
|
auto *storage = static_cast<FakeFlash *>(context);
|
||||||
memcpy(
|
if (arena >= PROFILE_STORAGE_ARENA_COUNT || page == nullptr ||
|
||||||
&storage->bytes[bank][PROFILE_STORAGE_RECORD_HEADER_SIZE],
|
size != PROFILE_STORAGE_PAGE_SIZE ||
|
||||||
payload, payload_size);
|
offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
|
||||||
memcpy(storage->bytes[bank], header, header_size);
|
offset + size > PROFILE_STORAGE_ARENA_SIZE) {
|
||||||
return memcmp(
|
return false;
|
||||||
&storage->bytes[bank][
|
}
|
||||||
PROFILE_STORAGE_RECORD_HEADER_SIZE],
|
for (size_t index = 0; index < size; ++index) {
|
||||||
payload, payload_size) == 0 &&
|
storage->bytes[arena][offset + index] &= page[index];
|
||||||
memcmp(storage->bytes[bank], header, header_size) == 0;
|
}
|
||||||
|
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 =
|
const ControllerIdentity global = controller_identity_global();
|
||||||
active_profile_snapshot(controller_identity_global());
|
ProfileServiceActiveProfileSnapshot active = active_snapshot(global);
|
||||||
require(active.valid &&
|
require(active.valid && active.profile_index == 0 &&
|
||||||
active.metadata.state == ProfileServiceState::kReady &&
|
active.metadata.state == ProfileServiceState::kReady,
|
||||||
active.metadata.generation == 0 &&
|
"fallback active cache did not initialize");
|
||||||
profile_service_database_generation() == 0 &&
|
|
||||||
active.profile_index == 0,
|
|
||||||
"initial active profile snapshot was not coherent");
|
|
||||||
|
|
||||||
const ControllerIdentity identity = controller_identity_global();
|
ControllerProfile eighth = controller_profile_default(global, 7);
|
||||||
constexpr uint8_t kProfileIndex = 2;
|
eighth.strong_rumble_scale = 37;
|
||||||
ControllerProfile customized =
|
write_profile(1, global, 7, eighth, 0);
|
||||||
controller_profile_default(identity, kProfileIndex);
|
require(profile_service_select(global, 7) ==
|
||||||
customized.strong_rumble_scale = 17;
|
ConfigurationTransactionStatus::kCommitted,
|
||||||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
"profile eight was not selectable");
|
||||||
require(controller_profile_encode(customized, encoded, sizeof(encoded)),
|
ProfileServiceSelectedSnapshot selected{};
|
||||||
"customized profile did not encode");
|
profile_service_selected_snapshot(&selected);
|
||||||
|
require(selected.valid && selected.profile_index == 7 &&
|
||||||
|
selected.profile.strong_rumble_scale == 37,
|
||||||
|
"selected profile eight was not decoded on demand");
|
||||||
|
|
||||||
constexpr uint32_t kWriteTransactionId = 0x10203040;
|
require(profile_service_activate(2, global, 7) ==
|
||||||
require(profile_service_begin(
|
ConfigurationTransactionStatus::kPending,
|
||||||
kWriteTransactionId, identity, kProfileIndex,
|
"profile eight activation was not queued");
|
||||||
CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded),
|
profile_service_task_on_storage_core(1000);
|
||||||
profile_storage_crc32(encoded, sizeof(encoded))) ==
|
require(transaction_snapshot().transaction.status ==
|
||||||
ConfigurationTransactionStatus::kReceiving &&
|
ConfigurationTransactionStatus::kCommitted,
|
||||||
profile_service_append(kWriteTransactionId, 0, encoded,
|
"profile eight activation did not persist");
|
||||||
sizeof(encoded)) ==
|
active = active_snapshot(global);
|
||||||
ConfigurationTransactionStatus::kReceiving &&
|
require(active.valid && active.profile_index == 7 &&
|
||||||
profile_service_commit(kWriteTransactionId) ==
|
active.profile.strong_rumble_scale == 37,
|
||||||
ConfigurationTransactionStatus::kPending,
|
"active cache did not publish profile eight");
|
||||||
"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;
|
const ControllerIdentity connected = stable_identity();
|
||||||
require(
|
require(profile_service_observe_identity_on_storage_core(connected),
|
||||||
profile_service_begin(
|
"stable identity was not added to the catalog");
|
||||||
kReservedInternalTransactionId, identity, kProfileIndex,
|
ProfileServiceListSnapshot list{};
|
||||||
CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded),
|
profile_service_list_snapshot(&list);
|
||||||
profile_storage_crc32(encoded, sizeof(encoded))) ==
|
require(list.count == 2 &&
|
||||||
ConfigurationTransactionStatus::kMalformed &&
|
controller_identity_equal(list.rows[1].identity, connected),
|
||||||
profile_service_reset(kReservedInternalTransactionId, identity,
|
"profile list did not publish the stable identity");
|
||||||
kProfileIndex) ==
|
active = active_snapshot(connected);
|
||||||
ConfigurationTransactionStatus::kMalformed &&
|
require(active.valid && active.profile_index == 0,
|
||||||
profile_service_activate(kReservedInternalTransactionId, identity,
|
"new identity did not publish its default active profile");
|
||||||
kProfileIndex) ==
|
|
||||||
ConfigurationTransactionStatus::kMalformed,
|
|
||||||
"host profile mutations admitted the internal transaction namespace");
|
|
||||||
|
|
||||||
constexpr uint32_t kResetTransactionId = 0x25a55a5a;
|
ControllerProfile seventh = controller_profile_default(connected, 6);
|
||||||
require(profile_service_reset(kResetTransactionId, identity,
|
seventh.weak_rumble_scale = 61;
|
||||||
kProfileIndex) ==
|
write_profile(3, connected, 6, seventh, 3000);
|
||||||
ConfigurationTransactionStatus::kPending,
|
require(profile_service_activate_internal(0x80000007u, connected, 6) ==
|
||||||
"profile reset did not reach pending");
|
ConfigurationTransactionStatus::kPending,
|
||||||
ProfileServiceTransactionSnapshot reset = transaction_snapshot();
|
"controller activation was not queued");
|
||||||
require(reset.transaction.transaction_id == kResetTransactionId &&
|
profile_service_task_on_storage_core(4000);
|
||||||
reset.transaction.status ==
|
active = active_snapshot(connected);
|
||||||
ConfigurationTransactionStatus::kPending,
|
require(active.valid && active.profile_index == 6 &&
|
||||||
"pending reset lost its transaction identity");
|
active.profile.weak_rumble_scale == 61,
|
||||||
|
"controller activation did not refresh the active cache");
|
||||||
|
|
||||||
profile_service_task_on_storage_core(1000);
|
require(profile_service_reset(4, global, CONTROLLER_PROFILE_ALL) ==
|
||||||
reset = transaction_snapshot();
|
ConfigurationTransactionStatus::kPending,
|
||||||
require(reset.transaction.transaction_id == kResetTransactionId &&
|
"reset-all was not queued");
|
||||||
reset.transaction.status ==
|
profile_service_task_on_storage_core(5000);
|
||||||
ConfigurationTransactionStatus::kCommitted,
|
active = active_snapshot(global);
|
||||||
"one reset tick decoded profile payload or published a malformed result");
|
require(active.valid && active.profile_index == 0 &&
|
||||||
ControllerProfileDatabase recovered = reload_database(reset, 2);
|
active.profile.strong_rumble_scale == UINT8_MAX,
|
||||||
require(recovered.fallback_profiles[kProfileIndex].strong_rumble_scale ==
|
"reset-all did not restore defaults and activation");
|
||||||
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;
|
ProfileStorage reloaded;
|
||||||
constexpr uint8_t kActivatedProfile = 3;
|
ControllerProfile persisted{};
|
||||||
require(profile_service_activate(kActivateTransactionId, identity,
|
require(
|
||||||
kActivatedProfile) ==
|
reloaded.initialize(fake_io()) && reloaded.find(connected) != nullptr &&
|
||||||
ConfigurationTransactionStatus::kPending,
|
reloaded.find(connected)->active_profile == 6 &&
|
||||||
"profile activation did not reach pending");
|
reloaded.get(connected, 6, &persisted) == ProfileStorageResult::kOk &&
|
||||||
ProfileServiceTransactionSnapshot activate = transaction_snapshot();
|
persisted.weak_rumble_scale == 61,
|
||||||
require(activate.transaction.transaction_id == kActivateTransactionId &&
|
"service mutations did not survive catalog reload");
|
||||||
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() {
|
void test_profile_bounds_and_transaction_namespace() {
|
||||||
const ControllerIdentity identity = controller_identity_global();
|
const ControllerIdentity global = controller_identity_global();
|
||||||
ControllerProfile profile =
|
require(profile_service_select(global, 8) ==
|
||||||
controller_profile_default(identity, 1);
|
ConfigurationTransactionStatus::kMalformed &&
|
||||||
profile.weak_rumble_scale = 23;
|
profile_service_activate(10, global, 8) ==
|
||||||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
ConfigurationTransactionStatus::kMalformed &&
|
||||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
|
profile_service_reset(11, global, 8) ==
|
||||||
"serialization fixture profile did not encode");
|
ConfigurationTransactionStatus::kMalformed,
|
||||||
|
"profile index beyond eight was admitted");
|
||||||
constexpr uint32_t kHostTransactionId = 0x11223344;
|
require(profile_service_activate(0x80000001u, global, 0) ==
|
||||||
constexpr uint32_t kInternalTransactionId = 0x80000019;
|
ConfigurationTransactionStatus::kMalformed &&
|
||||||
require(profile_service_begin(
|
profile_service_activate_internal(12, global, 0) ==
|
||||||
kHostTransactionId, identity, 1,
|
ConfigurationTransactionStatus::kMalformed,
|
||||||
CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded),
|
"transaction namespaces were not enforced");
|
||||||
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() {
|
} // namespace
|
||||||
const int replacements_before = flash.bank_replacements;
|
|
||||||
const ControllerIdentity global = controller_identity_global();
|
|
||||||
constexpr uint8_t kWrittenProfileIndex = 1;
|
|
||||||
ControllerProfile customized =
|
|
||||||
controller_profile_default(global, kWrittenProfileIndex);
|
|
||||||
customized.strong_rumble_scale = 31;
|
|
||||||
customized.weak_rumble_scale = 47;
|
|
||||||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
|
||||||
require(controller_profile_encode(customized, encoded, sizeof(encoded)),
|
|
||||||
"sequential mutation fixture profile did not encode");
|
|
||||||
|
|
||||||
constexpr uint32_t kWriteTransactionId = 0x31415926;
|
ProfileStorageIo pico_profile_storage_io() { return fake_io(); }
|
||||||
require(profile_service_begin(
|
|
||||||
kWriteTransactionId, global, kWrittenProfileIndex,
|
|
||||||
CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded),
|
|
||||||
profile_storage_crc32(encoded, sizeof(encoded))) ==
|
|
||||||
ConfigurationTransactionStatus::kReceiving &&
|
|
||||||
profile_service_append(kWriteTransactionId, 0, encoded,
|
|
||||||
sizeof(encoded)) ==
|
|
||||||
ConfigurationTransactionStatus::kReceiving &&
|
|
||||||
profile_service_commit(kWriteTransactionId) ==
|
|
||||||
ConfigurationTransactionStatus::kPending,
|
|
||||||
"sequential profile write did not reach pending");
|
|
||||||
profile_service_task_on_storage_core(6000);
|
|
||||||
const ProfileServiceTransactionSnapshot written =
|
|
||||||
transaction_snapshot();
|
|
||||||
require(written.transaction.transaction_id == kWriteTransactionId &&
|
|
||||||
written.transaction.status ==
|
|
||||||
ConfigurationTransactionStatus::kCommitted &&
|
|
||||||
flash.bank_replacements == replacements_before + 1,
|
|
||||||
"completed write lost correlation or used multiple bank replacements");
|
|
||||||
|
|
||||||
ControllerIdentity connected{};
|
|
||||||
connected.stable = true;
|
|
||||||
connected.transport = ControllerTransport::kClassic;
|
|
||||||
connected.address[0] = 0x10;
|
|
||||||
connected.address[1] = 0x20;
|
|
||||||
connected.address[2] = 0x30;
|
|
||||||
connected.address[3] = 0x40;
|
|
||||||
connected.address[4] = 0x50;
|
|
||||||
connected.address[5] = 0x60;
|
|
||||||
connected.vendor_id = 0x1234;
|
|
||||||
connected.product_id = 0xabcd;
|
|
||||||
require(profile_service_observe_identity_on_storage_core(connected),
|
|
||||||
"connected identity did not enter the dirty database");
|
|
||||||
|
|
||||||
constexpr uint32_t kActivateTransactionId = 0x27182818;
|
|
||||||
constexpr uint8_t kActivatedProfileIndex = 2;
|
|
||||||
require(profile_service_activate(
|
|
||||||
kActivateTransactionId, connected,
|
|
||||||
kActivatedProfileIndex) ==
|
|
||||||
ConfigurationTransactionStatus::kPending,
|
|
||||||
"activation after completed write did not reach pending");
|
|
||||||
const ProfileServiceTransactionSnapshot pending =
|
|
||||||
transaction_snapshot();
|
|
||||||
require(pending.transaction.transaction_id ==
|
|
||||||
kActivateTransactionId &&
|
|
||||||
pending.transaction.status ==
|
|
||||||
ConfigurationTransactionStatus::kPending &&
|
|
||||||
pending.transaction.stored_generation == 0 &&
|
|
||||||
pending.transaction.stored_crc == 0,
|
|
||||||
"pending activation was not correlated to its own transaction");
|
|
||||||
|
|
||||||
profile_service_task_on_storage_core(7000);
|
|
||||||
const ProfileServiceTransactionSnapshot activated =
|
|
||||||
transaction_snapshot();
|
|
||||||
require(activated.transaction.transaction_id ==
|
|
||||||
kActivateTransactionId &&
|
|
||||||
activated.transaction.status ==
|
|
||||||
ConfigurationTransactionStatus::kCommitted &&
|
|
||||||
activated.transaction.stored_generation ==
|
|
||||||
written.transaction.stored_generation + 1 &&
|
|
||||||
flash.bank_replacements == replacements_before + 2,
|
|
||||||
"activation did not complete as one next correlated bank replacement");
|
|
||||||
|
|
||||||
const ControllerProfileDatabase recovered = reload_database(
|
|
||||||
activated, activated.transaction.stored_generation);
|
|
||||||
const ControllerProfileDatabaseEntry* connected_entry =
|
|
||||||
controller_profile_database_find(recovered, connected);
|
|
||||||
require(connected_entry != nullptr &&
|
|
||||||
connected_entry->active_profile ==
|
|
||||||
kActivatedProfileIndex &&
|
|
||||||
recovered.fallback_profiles[kWrittenProfileIndex]
|
|
||||||
.strong_rumble_scale ==
|
|
||||||
customized.strong_rumble_scale &&
|
|
||||||
recovered.fallback_profiles[kWrittenProfileIndex]
|
|
||||||
.weak_rumble_scale ==
|
|
||||||
customized.weak_rumble_scale,
|
|
||||||
"activation did not atomically persist the dirty identity and prior write");
|
|
||||||
const ProfileServiceActiveProfileSnapshot active =
|
|
||||||
active_profile_snapshot(connected);
|
|
||||||
require(active.valid &&
|
|
||||||
active.metadata.generation ==
|
|
||||||
activated.transaction.stored_generation &&
|
|
||||||
active.profile_index == kActivatedProfileIndex,
|
|
||||||
"completed activation did not publish the dirty identity");
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
ProfileStorageIo pico_profile_storage_io() {
|
|
||||||
return fake_io();
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,649 +9,305 @@
|
||||||
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 fail_after_programs = -1;
|
int erases = 0;
|
||||||
bool corrupt_next_program = false;
|
int fail_after_programs = -1;
|
||||||
int corrupt_header_padding_offset = -1;
|
bool corrupt_next_program = false;
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void erase_all() {
|
void erase_all() {
|
||||||
memset(flash.bytes, 0xff, sizeof(flash.bytes));
|
flash = FakeFlash{};
|
||||||
flash.successful_programs = 0;
|
memset(flash.bytes, 0xff, sizeof(flash.bytes));
|
||||||
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 ||
|
||||||
return false;
|
size > PROFILE_STORAGE_ARENA_SIZE - offset) {
|
||||||
}
|
return false;
|
||||||
if (bank >= PROFILE_STORAGE_BANK_COUNT ||
|
}
|
||||||
offset > PROFILE_STORAGE_BANK_SIZE ||
|
memcpy(output, &storage->bytes[arena][offset], size);
|
||||||
size > PROFILE_STORAGE_BANK_SIZE - offset) {
|
return true;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
memcpy(output, &storage->bytes[bank][offset], size);
|
|
||||||
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);
|
return false;
|
||||||
if (bank >= PROFILE_STORAGE_BANK_COUNT || payload == nullptr ||
|
}
|
||||||
header == nullptr ||
|
memset(storage->bytes[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE);
|
||||||
payload_size != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
|
++storage->erases;
|
||||||
header_size != PROFILE_STORAGE_RECORD_HEADER_SIZE) {
|
return true;
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
++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 >=
|
return false;
|
||||||
storage->fail_after_programs) {
|
}
|
||||||
return false;
|
for (size_t index = 0; index < size; ++index) {
|
||||||
}
|
storage->bytes[arena][offset + index] &= page[index];
|
||||||
const size_t remaining = payload_size - offset;
|
}
|
||||||
const uint8_t* page = &payload[offset];
|
if (storage->corrupt_next_program) {
|
||||||
if (remaining < PROFILE_STORAGE_PAGE_SIZE) {
|
storage->bytes[arena][offset] ^= 1;
|
||||||
memcpy(final_page, page, remaining);
|
storage->corrupt_next_program = false;
|
||||||
page = final_page;
|
}
|
||||||
}
|
++storage->programs;
|
||||||
memcpy(
|
return memcmp(&storage->bytes[arena][offset], page, size) == 0;
|
||||||
&storage->bytes[bank][
|
|
||||||
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset],
|
|
||||||
page, PROFILE_STORAGE_PAGE_SIZE);
|
|
||||||
if (storage->corrupt_next_program) {
|
|
||||||
storage->bytes[bank][
|
|
||||||
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset] ^= 1;
|
|
||||||
storage->corrupt_next_program = false;
|
|
||||||
}
|
|
||||||
++storage->successful_programs;
|
|
||||||
}
|
|
||||||
if (memcmp(
|
|
||||||
&storage->bytes[bank][PROFILE_STORAGE_RECORD_HEADER_SIZE],
|
|
||||||
payload, payload_size) != 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (storage->fail_after_programs >= 0 &&
|
|
||||||
storage->successful_programs >= storage->fail_after_programs) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
memcpy(storage->bytes[bank], header, header_size);
|
|
||||||
storage->header_programmed = true;
|
|
||||||
++storage->successful_programs;
|
|
||||||
if (storage->corrupt_header_padding_offset >= 24 &&
|
|
||||||
static_cast<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 =
|
||||||
constexpr uint8_t kBank = 1;
|
PROFILE_STORAGE_TOTAL_SIZE - PROFILE_STORAGE_LEGACY_TOTAL_SIZE;
|
||||||
constexpr uint32_t kGeneration = 41;
|
constexpr uint8_t kArena = 1;
|
||||||
constexpr size_t kFallbackOffset =
|
constexpr size_t kArenaBase = kLegacyStart - PROFILE_STORAGE_ARENA_SIZE;
|
||||||
CONTROLLER_PROFILE_DATABASE_HEADER_SIZE;
|
constexpr uint8_t kBank = 1;
|
||||||
constexpr size_t kEntryOffset =
|
constexpr size_t kBankBase =
|
||||||
kFallbackOffset +
|
kArenaBase + kBank * PROFILE_STORAGE_LEGACY_BANK_SIZE;
|
||||||
CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE;
|
uint8_t *header = flash.bytes[kArena] + kBankBase;
|
||||||
constexpr uint8_t kEntryHeader[CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE] = {
|
uint8_t *payload = header + PROFILE_STORAGE_LEGACY_HEADER_SIZE;
|
||||||
1, 1, 7, 0, 1, 2, 3, 4, 5, 6, 0x7e, 0x05, 0x09, 0x20, 3, 1,
|
memset(header, 0, PROFILE_STORAGE_LEGACY_HEADER_SIZE);
|
||||||
};
|
memset(payload, 0, PROFILE_STORAGE_LEGACY_DATABASE_SIZE);
|
||||||
|
|
||||||
uint8_t* const record = flash.bytes[kBank];
|
memcpy(payload, "SPDB", 4);
|
||||||
uint8_t* const payload = record + PROFILE_STORAGE_RECORD_HEADER_SIZE;
|
write_u16(&payload[4], 2);
|
||||||
memset(record, 0, PROFILE_STORAGE_RECORD_HEADER_SIZE);
|
write_u16(&payload[6], PROFILE_STORAGE_LEGACY_DATABASE_SIZE);
|
||||||
memset(payload, 0, CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE);
|
payload[8] = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY;
|
||||||
|
payload[9] = PROFILE_STORAGE_LEGACY_PROFILE_COUNT;
|
||||||
|
payload[10] = 3;
|
||||||
|
payload[11] = 1;
|
||||||
|
|
||||||
memcpy(payload, "SPDB", 4);
|
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||||
fixture_write_u16(&payload[4],
|
const ControllerIdentity global = controller_identity_global();
|
||||||
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION);
|
for (uint8_t profile = 0; profile < PROFILE_STORAGE_LEGACY_PROFILE_COUNT;
|
||||||
fixture_write_u16(
|
++profile) {
|
||||||
&payload[6],
|
ControllerProfile value = controller_profile_default(global, profile);
|
||||||
static_cast<uint16_t>(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE));
|
value.weak_rumble_scale = static_cast<uint8_t>(20 + profile);
|
||||||
payload[8] = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY;
|
require(controller_profile_encode(value, encoded, sizeof(encoded)),
|
||||||
payload[9] = CONTROLLER_PROFILE_COUNT;
|
"legacy fallback profile did not encode");
|
||||||
payload[10] = 2;
|
memcpy(payload + 32 + profile * sizeof(encoded), encoded, sizeof(encoded));
|
||||||
payload[11] = 1;
|
}
|
||||||
for (uint8_t profile_index = 0;
|
|
||||||
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) {
|
|
||||||
const uint8_t* fixture =
|
|
||||||
profile_index == 0
|
|
||||||
? kLegacyNarrowRawRangeProfile
|
|
||||||
: profile_index == 1 ? kLegacyCustomThresholdProfile
|
|
||||||
: kLegacyDefaultProfile;
|
|
||||||
memcpy(&payload[kFallbackOffset +
|
|
||||||
profile_index * CONTROLLER_PROFILE_ENCODED_SIZE],
|
|
||||||
fixture, CONTROLLER_PROFILE_ENCODED_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
memcpy(&payload[kEntryOffset], kEntryHeader, sizeof(kEntryHeader));
|
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();
|
ProfileStorage storage;
|
||||||
io.replace_bank = nullptr;
|
require(storage.initialize(fake_io()) && storage.snapshot().valid &&
|
||||||
ProfileStorage storage;
|
storage.identity_count() == 1,
|
||||||
require(!storage.initialize(io, &database),
|
"erased flash did not initialize an empty catalog");
|
||||||
"profile storage initialized without bank replacement");
|
const ControllerIdentity global = controller_identity_global();
|
||||||
|
ControllerProfile profile = controller_profile_default(global, 7);
|
||||||
|
profile.strong_rumble_scale = 77;
|
||||||
|
require(storage.set(global, 7, profile) == ProfileStorageResult::kOk,
|
||||||
|
"profile eight did not append");
|
||||||
|
require(storage.activate(global, 7) == ProfileStorageResult::kOk,
|
||||||
|
"profile eight did not activate");
|
||||||
|
|
||||||
|
ProfileStorage reloaded;
|
||||||
|
ControllerProfile recovered{};
|
||||||
|
require(reloaded.initialize(fake_io()) && reloaded.find(global) != nullptr &&
|
||||||
|
reloaded.find(global)->active_profile == 7 &&
|
||||||
|
reloaded.get(global, 7, &recovered) ==
|
||||||
|
ProfileStorageResult::kOk &&
|
||||||
|
recovered.strong_rumble_scale == 77,
|
||||||
|
"profile eight did not survive reload");
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_two_bank_recovery() {
|
void test_identity_capacity_and_defaults() {
|
||||||
erase_all();
|
erase_all();
|
||||||
controller_profile_database_default(&database);
|
ProfileStorage storage;
|
||||||
ProfileStorage storage;
|
require(storage.initialize(fake_io()), "catalog did not initialize");
|
||||||
require(storage.initialize(fake_io(), &database) &&
|
for (uint8_t index = 1; index <= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY;
|
||||||
!storage.snapshot().valid,
|
++index) {
|
||||||
"erased profile storage did not initialize empty");
|
require(storage.ensure_identity(identity(index)) ==
|
||||||
require(storage.commit(database, encoded_database,
|
|
||||||
sizeof(encoded_database)) ==
|
|
||||||
ProfileStorageResult::kOk &&
|
|
||||||
storage.snapshot().generation == 1,
|
|
||||||
"first profile database did not commit");
|
|
||||||
const int programs_after_first = flash.successful_programs;
|
|
||||||
require(storage.commit(database, encoded_database,
|
|
||||||
sizeof(encoded_database)) ==
|
|
||||||
ProfileStorageResult::kUnchanged &&
|
|
||||||
flash.successful_programs == programs_after_first,
|
|
||||||
"unchanged profile database consumed flash writes");
|
|
||||||
|
|
||||||
database.fallback_profiles[0].button_map[0] = 1;
|
|
||||||
require(storage.commit(database, encoded_database,
|
|
||||||
sizeof(encoded_database)) ==
|
|
||||||
ProfileStorageResult::kOk &&
|
|
||||||
storage.snapshot().generation == 2,
|
|
||||||
"second profile database generation did not commit");
|
|
||||||
ProfileStorage reloaded;
|
|
||||||
require(reloaded.initialize(fake_io(), &recovered_database) &&
|
|
||||||
reloaded.snapshot().generation == 2 &&
|
|
||||||
recovered_database.fallback_profiles[0].button_map[0] == 1,
|
|
||||||
"latest profile database did not survive reload");
|
|
||||||
|
|
||||||
const uint8_t newest_bank = reloaded.snapshot().active_bank;
|
|
||||||
flash.bytes[newest_bank][PROFILE_STORAGE_RECORD_HEADER_SIZE + 4] ^= 1;
|
|
||||||
ProfileStorage after_corruption;
|
|
||||||
require(after_corruption.initialize(fake_io(), &recovered_database) &&
|
|
||||||
after_corruption.snapshot().generation == 1 &&
|
|
||||||
recovered_database.fallback_profiles[0].button_map[0] == 0,
|
|
||||||
"corrupt newest profile bank did not roll back");
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_interrupted_commit_retains_previous_bank() {
|
|
||||||
erase_all();
|
|
||||||
controller_profile_database_default(&database);
|
|
||||||
ProfileStorage storage;
|
|
||||||
require(storage.initialize(fake_io(), &database) &&
|
|
||||||
storage.commit(database, encoded_database,
|
|
||||||
sizeof(encoded_database)) ==
|
|
||||||
ProfileStorageResult::kOk,
|
|
||||||
"interruption baseline did not commit");
|
|
||||||
database.fallback_profiles[1].button_map[2] = 3;
|
|
||||||
flash.fail_after_programs = flash.successful_programs + 1;
|
|
||||||
require(storage.commit(database, encoded_database,
|
|
||||||
sizeof(encoded_database)) ==
|
|
||||||
ProfileStorageResult::kIoError,
|
|
||||||
"interrupted profile write reported success");
|
|
||||||
|
|
||||||
flash.fail_after_programs = -1;
|
|
||||||
ProfileStorage recovered;
|
|
||||||
require(recovered.initialize(fake_io(), &recovered_database) &&
|
|
||||||
recovered.snapshot().generation == 1 &&
|
|
||||||
recovered_database.fallback_profiles[1].button_map[2] == 2,
|
|
||||||
"interrupted profile write replaced previous bank");
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_successful_header_program_is_commit_point() {
|
|
||||||
erase_all();
|
|
||||||
controller_profile_database_default(&database);
|
|
||||||
ProfileStorage storage;
|
|
||||||
require(storage.initialize(fake_io(), &database) &&
|
|
||||||
storage.commit(database, encoded_database,
|
|
||||||
sizeof(encoded_database)) ==
|
|
||||||
ProfileStorageResult::kOk,
|
|
||||||
"commit-point baseline did not commit");
|
|
||||||
|
|
||||||
database.fallback_profiles[1].strong_rumble_scale = 17;
|
|
||||||
flash.header_programmed = false;
|
|
||||||
flash.fail_reads_after_header_program = true;
|
|
||||||
require(storage.commit(database, encoded_database,
|
|
||||||
sizeof(encoded_database)) ==
|
|
||||||
ProfileStorageResult::kOk &&
|
|
||||||
storage.snapshot().generation == 2,
|
|
||||||
"successful header program was rolled back by a later read");
|
|
||||||
|
|
||||||
flash.fail_reads_after_header_program = false;
|
|
||||||
ProfileStorage recovered;
|
|
||||||
require(recovered.initialize(fake_io(), &recovered_database) &&
|
|
||||||
recovered.snapshot().generation == 2 &&
|
|
||||||
recovered_database.fallback_profiles[1]
|
|
||||||
.strong_rumble_scale == 17,
|
|
||||||
"committed header did not recover after transient read failure");
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_payload_corruption_prevents_header_publication() {
|
|
||||||
erase_all();
|
|
||||||
controller_profile_database_default(&database);
|
|
||||||
ProfileStorage storage;
|
|
||||||
require(storage.initialize(fake_io(), &database) &&
|
|
||||||
storage.commit(database, encoded_database,
|
|
||||||
sizeof(encoded_database)) ==
|
|
||||||
ProfileStorageResult::kOk,
|
|
||||||
"corruption baseline did not commit");
|
|
||||||
const ProfileStorageSnapshot previous = storage.snapshot();
|
|
||||||
const uint8_t target_bank = previous.active_bank ^ 1u;
|
|
||||||
const int programs_before_corruption = flash.successful_programs;
|
|
||||||
constexpr int kPayloadProgramCount =
|
|
||||||
(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE +
|
|
||||||
PROFILE_STORAGE_PAGE_SIZE - 1) /
|
|
||||||
PROFILE_STORAGE_PAGE_SIZE;
|
|
||||||
|
|
||||||
database.fallback_profiles[1].button_map[2] = 3;
|
|
||||||
flash.corrupt_next_program = true;
|
|
||||||
require(storage.commit(database, encoded_database,
|
|
||||||
sizeof(encoded_database)) ==
|
|
||||||
ProfileStorageResult::kIoError &&
|
|
||||||
flash.successful_programs ==
|
|
||||||
programs_before_corruption + kPayloadProgramCount,
|
|
||||||
"corrupt payload programming reached the header program");
|
|
||||||
for (size_t index = 0; index < PROFILE_STORAGE_RECORD_HEADER_SIZE;
|
|
||||||
++index) {
|
|
||||||
require(flash.bytes[target_bank][index] == 0xff,
|
|
||||||
"rejected corrupt payload published a discoverable header");
|
|
||||||
}
|
|
||||||
require(storage.snapshot().valid == previous.valid &&
|
|
||||||
storage.snapshot().generation == previous.generation &&
|
|
||||||
storage.snapshot().payload_crc == previous.payload_crc &&
|
|
||||||
storage.snapshot().active_bank == previous.active_bank,
|
|
||||||
"rejected corrupt programming changed the storage snapshot");
|
|
||||||
|
|
||||||
ProfileStorage recovered;
|
|
||||||
require(recovered.initialize(fake_io(), &recovered_database) &&
|
|
||||||
recovered.snapshot().generation == previous.generation &&
|
|
||||||
recovered.snapshot().active_bank == previous.active_bank &&
|
|
||||||
recovered_database.fallback_profiles[1].button_map[2] == 2,
|
|
||||||
"headerless corrupt payload was recovered");
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_batched_bank_replacement_is_one_atomic_operation() {
|
|
||||||
erase_all();
|
|
||||||
controller_profile_database_default(&database);
|
|
||||||
ProfileStorage storage;
|
|
||||||
constexpr int kPayloadProgramCount =
|
|
||||||
(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE +
|
|
||||||
PROFILE_STORAGE_PAGE_SIZE - 1) /
|
|
||||||
PROFILE_STORAGE_PAGE_SIZE;
|
|
||||||
require(storage.initialize(fake_io(), &database) &&
|
|
||||||
storage.commit(database, encoded_database,
|
|
||||||
sizeof(encoded_database)) ==
|
|
||||||
ProfileStorageResult::kOk &&
|
|
||||||
flash.bank_replacements == 1 &&
|
|
||||||
flash.erase_count == static_cast<int>(
|
|
||||||
PROFILE_STORAGE_SECTORS_PER_BANK) &&
|
|
||||||
flash.successful_programs ==
|
|
||||||
kPayloadProgramCount + 1,
|
|
||||||
"batched commit did not replace one bank in one operation");
|
|
||||||
|
|
||||||
const ProfileStorageSnapshot previous = storage.snapshot();
|
|
||||||
const uint8_t target_bank = previous.active_bank ^ 1u;
|
|
||||||
const int programs_before_corruption = flash.successful_programs;
|
|
||||||
database.fallback_profiles[1].button_map[2] = 3;
|
|
||||||
flash.corrupt_next_program = true;
|
|
||||||
require(storage.commit(database, encoded_database,
|
|
||||||
sizeof(encoded_database)) ==
|
|
||||||
ProfileStorageResult::kIoError &&
|
|
||||||
flash.bank_replacements == 2 &&
|
|
||||||
flash.successful_programs ==
|
|
||||||
programs_before_corruption +
|
|
||||||
kPayloadProgramCount,
|
|
||||||
"corrupt batched payload reached header publication");
|
|
||||||
for (size_t index = 0; index < PROFILE_STORAGE_RECORD_HEADER_SIZE;
|
|
||||||
++index) {
|
|
||||||
require(flash.bytes[target_bank][index] == 0xff,
|
|
||||||
"failed batched replacement published a header");
|
|
||||||
}
|
|
||||||
require(storage.snapshot().generation == previous.generation &&
|
|
||||||
storage.snapshot().payload_crc ==
|
|
||||||
previous.payload_crc &&
|
|
||||||
storage.snapshot().active_bank ==
|
|
||||||
previous.active_bank,
|
|
||||||
"failed batched replacement changed the committed snapshot");
|
|
||||||
|
|
||||||
ProfileStorage recovered;
|
|
||||||
require(recovered.initialize(fake_io(), &recovered_database) &&
|
|
||||||
recovered.snapshot().generation ==
|
|
||||||
previous.generation &&
|
|
||||||
recovered.snapshot().active_bank ==
|
|
||||||
previous.active_bank &&
|
|
||||||
recovered_database.fallback_profiles[1]
|
|
||||||
.button_map[2] == 2,
|
|
||||||
"headerless batched payload replaced the prior bank");
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_header_padding_corruption_fails_commit_and_recovery() {
|
|
||||||
erase_all();
|
|
||||||
controller_profile_database_default(&database);
|
|
||||||
ProfileStorage storage;
|
|
||||||
require(storage.initialize(fake_io(), &database) &&
|
|
||||||
storage.commit(database, encoded_database,
|
|
||||||
sizeof(encoded_database)) ==
|
|
||||||
ProfileStorageResult::kOk,
|
|
||||||
"header padding baseline did not commit");
|
|
||||||
|
|
||||||
const ProfileStorageSnapshot previous = storage.snapshot();
|
|
||||||
const uint8_t previous_scale =
|
|
||||||
database.fallback_profiles[1].strong_rumble_scale;
|
|
||||||
database.fallback_profiles[1].strong_rumble_scale = 17;
|
|
||||||
for (size_t offset = 24;
|
|
||||||
offset < PROFILE_STORAGE_RECORD_HEADER_SIZE; ++offset) {
|
|
||||||
flash.corrupt_header_padding_offset =
|
|
||||||
static_cast<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");
|
|
||||||
}
|
|
||||||
|
|
||||||
flash.corrupt_header_padding_offset = -1;
|
|
||||||
require(storage.commit(database, encoded_database,
|
|
||||||
sizeof(encoded_database)) ==
|
|
||||||
ProfileStorageResult::kOk,
|
ProfileStorageResult::kOk,
|
||||||
"valid full header page did not commit");
|
"stable identity was not indexed");
|
||||||
for (size_t offset = 24;
|
}
|
||||||
offset < PROFILE_STORAGE_RECORD_HEADER_SIZE; ++offset) {
|
require(storage.ensure_identity(identity(99)) == ProfileStorageResult::kFull,
|
||||||
require(
|
"identity catalog accepted a seventeenth stable identity");
|
||||||
flash.bytes[storage.snapshot().active_bank][offset] == 0,
|
ControllerProfile profile{};
|
||||||
"valid committed header contained nonzero padding");
|
require(storage.get(identity(1), 7, &profile) == ProfileStorageResult::kOk &&
|
||||||
}
|
controller_profile_validate(profile),
|
||||||
ProfileStorage recovered;
|
"unstored profile eight did not resolve to its default");
|
||||||
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_interrupted_and_corrupt_append_recovery() {
|
||||||
install_legacy_database_bank_fixture();
|
erase_all();
|
||||||
ProfileStorage storage;
|
const ControllerIdentity global = controller_identity_global();
|
||||||
require(storage.initialize(fake_io(), &recovered_database) &&
|
ProfileStorage storage;
|
||||||
storage.snapshot().valid &&
|
require(storage.initialize(fake_io()), "catalog did not initialize");
|
||||||
storage.snapshot().active_bank == 1 &&
|
ControllerProfile first = controller_profile_default(global, 0);
|
||||||
storage.snapshot().generation == 41,
|
first.weak_rumble_scale = 11;
|
||||||
"legacy v1 database bank was not selected");
|
require(storage.set(global, 0, first) == ProfileStorageResult::kOk,
|
||||||
require(flash.erase_count == 0,
|
"baseline profile did not append");
|
||||||
"legacy bank admission erased flash");
|
|
||||||
require(recovered_database.fallback_active_profile == 2,
|
|
||||||
"legacy fallback active profile was not preserved");
|
|
||||||
|
|
||||||
for (uint8_t profile_index = 0;
|
ControllerProfile second = first;
|
||||||
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) {
|
second.weak_rumble_scale = 22;
|
||||||
const uint16_t expected_left =
|
flash.fail_after_programs = flash.programs + 1;
|
||||||
profile_index == 1
|
require(storage.set(global, 0, second) == ProfileStorageResult::kIoError,
|
||||||
? 0x1234
|
"interrupted header publish reported success");
|
||||||
: CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
|
flash.fail_after_programs = -1;
|
||||||
const uint16_t expected_right =
|
ProfileStorage recovered;
|
||||||
profile_index == 1
|
ControllerProfile value{};
|
||||||
? 0xabcd
|
require(recovered.initialize(fake_io()) &&
|
||||||
: CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
|
recovered.get(global, 0, &value) == ProfileStorageResult::kOk &&
|
||||||
require(recovered_database.fallback_profiles[profile_index]
|
value.weak_rumble_scale == 11,
|
||||||
.triggers[0]
|
"interrupted append displaced the previous record");
|
||||||
.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 =
|
flash.corrupt_next_program = true;
|
||||||
recovered_database.entries[0];
|
second.weak_rumble_scale = 33;
|
||||||
require(entry.used && entry.active_profile == 3 &&
|
require(recovered.set(global, 0, second) == ProfileStorageResult::kIoError,
|
||||||
entry.identity.stable &&
|
"corrupt payload program reported success");
|
||||||
entry.identity.transport == ControllerTransport::kClassic &&
|
ProfileStorage after_corruption;
|
||||||
entry.identity.address_type == 7 &&
|
require(after_corruption.initialize(fake_io()) &&
|
||||||
entry.identity.address[0] == 1 &&
|
after_corruption.get(global, 0, &value) ==
|
||||||
entry.identity.address[5] == 6 &&
|
ProfileStorageResult::kOk &&
|
||||||
entry.identity.vendor_id == 0x057e &&
|
value.weak_rumble_scale == 11,
|
||||||
entry.identity.product_id == 0x2009,
|
"corrupt newest record displaced the previous record");
|
||||||
"legacy entry identity or active profile was not preserved");
|
|
||||||
for (uint8_t profile_index = 0;
|
|
||||||
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) {
|
|
||||||
const uint16_t expected_left =
|
|
||||||
profile_index == 3
|
|
||||||
? 0x1234
|
|
||||||
: CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
|
|
||||||
const uint16_t expected_right =
|
|
||||||
profile_index == 3
|
|
||||||
? 0xabcd
|
|
||||||
: CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
|
|
||||||
require(entry.profiles[profile_index]
|
|
||||||
.triggers[0]
|
|
||||||
.digital_threshold == expected_left &&
|
|
||||||
entry.profiles[profile_index]
|
|
||||||
.triggers[1]
|
|
||||||
.digital_threshold == expected_right,
|
|
||||||
"legacy entry thresholds were not selectively migrated");
|
|
||||||
}
|
|
||||||
require(entry.profiles[0].triggers[0].lower_deadzone == 30000 &&
|
|
||||||
entry.profiles[0].triggers[0].upper_saturation == 40000 &&
|
|
||||||
entry.profiles[0].triggers[1].lower_deadzone == 30000 &&
|
|
||||||
entry.profiles[0].triggers[1].upper_saturation == 40000,
|
|
||||||
"legacy entry raw trigger ranges were not preserved");
|
|
||||||
|
|
||||||
recovered_database.fallback_profiles[2].weak_rumble_scale = 17;
|
|
||||||
require(storage.commit(recovered_database, encoded_database,
|
|
||||||
sizeof(encoded_database)) ==
|
|
||||||
ProfileStorageResult::kOk &&
|
|
||||||
storage.snapshot().active_bank == 0 &&
|
|
||||||
storage.snapshot().generation == 42,
|
|
||||||
"mutation after legacy admission did not commit");
|
|
||||||
const uint8_t* const current_record = flash.bytes[0];
|
|
||||||
const uint8_t* const current_payload =
|
|
||||||
current_record + PROFILE_STORAGE_RECORD_HEADER_SIZE;
|
|
||||||
require(fixture_read_u16(¤t_record[6]) ==
|
|
||||||
CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION &&
|
|
||||||
fixture_read_u16(¤t_payload[4]) ==
|
|
||||||
CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION,
|
|
||||||
"post-migration commit did not emit v2 storage schemas");
|
|
||||||
constexpr size_t kFallbackOffset =
|
|
||||||
CONTROLLER_PROFILE_DATABASE_HEADER_SIZE;
|
|
||||||
constexpr size_t kEntryOffset =
|
|
||||||
kFallbackOffset +
|
|
||||||
CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE;
|
|
||||||
for (uint8_t profile_index = 0;
|
|
||||||
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) {
|
|
||||||
require(fixture_read_u16(
|
|
||||||
¤t_payload[kFallbackOffset +
|
|
||||||
profile_index *
|
|
||||||
CONTROLLER_PROFILE_ENCODED_SIZE]) ==
|
|
||||||
CONTROLLER_PROFILE_SCHEMA_VERSION &&
|
|
||||||
fixture_read_u16(
|
|
||||||
¤t_payload[
|
|
||||||
kEntryOffset +
|
|
||||||
CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE +
|
|
||||||
profile_index *
|
|
||||||
CONTROLLER_PROFILE_ENCODED_SIZE]) ==
|
|
||||||
CONTROLLER_PROFILE_SCHEMA_VERSION,
|
|
||||||
"post-migration commit retained a v1 profile");
|
|
||||||
}
|
|
||||||
require(fixture_read_u16(&flash.bytes[1][6]) ==
|
|
||||||
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION &&
|
|
||||||
fixture_read_u16(
|
|
||||||
&flash.bytes[1][PROFILE_STORAGE_RECORD_HEADER_SIZE + 4]) ==
|
|
||||||
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION,
|
|
||||||
"post-migration commit erased or rewrote the admitted legacy bank");
|
|
||||||
|
|
||||||
ProfileStorage reloaded;
|
|
||||||
require(reloaded.initialize(fake_io(), &database) &&
|
|
||||||
reloaded.snapshot().generation == 42 &&
|
|
||||||
database.fallback_profiles[2].weak_rumble_scale == 17 &&
|
|
||||||
database.fallback_profiles[0]
|
|
||||||
.triggers[0]
|
|
||||||
.lower_deadzone == 30000 &&
|
|
||||||
database.fallback_profiles[0]
|
|
||||||
.triggers[0]
|
|
||||||
.upper_saturation == 40000 &&
|
|
||||||
database.fallback_profiles[0]
|
|
||||||
.triggers[0]
|
|
||||||
.digital_threshold ==
|
|
||||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD &&
|
|
||||||
database.fallback_profiles[1]
|
|
||||||
.triggers[0]
|
|
||||||
.digital_threshold == 0x1234 &&
|
|
||||||
database.fallback_profiles[1]
|
|
||||||
.triggers[1]
|
|
||||||
.digital_threshold == 0xabcd &&
|
|
||||||
database.entries[0].used &&
|
|
||||||
database.entries[0].active_profile == 3 &&
|
|
||||||
database.entries[0]
|
|
||||||
.profiles[0]
|
|
||||||
.triggers[0]
|
|
||||||
.lower_deadzone == 30000 &&
|
|
||||||
database.entries[0]
|
|
||||||
.profiles[0]
|
|
||||||
.triggers[0]
|
|
||||||
.upper_saturation == 40000 &&
|
|
||||||
database.entries[0]
|
|
||||||
.profiles[0]
|
|
||||||
.triggers[0]
|
|
||||||
.digital_threshold ==
|
|
||||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD &&
|
|
||||||
database.entries[0]
|
|
||||||
.profiles[3]
|
|
||||||
.triggers[0]
|
|
||||||
.digital_threshold == 0x1234 &&
|
|
||||||
database.entries[0]
|
|
||||||
.profiles[3]
|
|
||||||
.triggers[1]
|
|
||||||
.digital_threshold == 0xabcd,
|
|
||||||
"v2 migration commit did not reload without data loss");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
void test_compaction_preserves_latest_records() {
|
||||||
|
erase_all();
|
||||||
|
const ControllerIdentity global = controller_identity_global();
|
||||||
|
ProfileStorage storage;
|
||||||
|
require(storage.initialize(fake_io()), "catalog did not initialize");
|
||||||
|
ControllerProfile profile = controller_profile_default(global, 0);
|
||||||
|
for (uint16_t write = 1; write <= 260; ++write) {
|
||||||
|
profile.weak_rumble_scale = static_cast<uint8_t>(write);
|
||||||
|
require(storage.set(global, 0, profile) == ProfileStorageResult::kOk,
|
||||||
|
"catalog update failed while forcing compaction");
|
||||||
|
}
|
||||||
|
require(storage.snapshot().active_bank == 1 && flash.erases >= 2,
|
||||||
|
"full arena did not compact into its peer");
|
||||||
|
ProfileStorage reloaded;
|
||||||
|
ControllerProfile recovered{};
|
||||||
|
require(reloaded.initialize(fake_io()) &&
|
||||||
|
reloaded.get(global, 0, &recovered) ==
|
||||||
|
ProfileStorageResult::kOk &&
|
||||||
|
recovered.weak_rumble_scale == static_cast<uint8_t>(260),
|
||||||
|
"compaction did not preserve the latest profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_legacy_migration_is_atomic_and_complete() {
|
||||||
|
erase_all();
|
||||||
|
install_legacy_database();
|
||||||
|
ProfileStorage storage;
|
||||||
|
require(storage.initialize(fake_io()) && storage.snapshot().valid &&
|
||||||
|
storage.identity_count() == 2,
|
||||||
|
"legacy database did not migrate");
|
||||||
|
const ControllerIdentity global = controller_identity_global();
|
||||||
|
ControllerProfile profile{};
|
||||||
|
require(storage.find(global)->active_profile == 3 &&
|
||||||
|
storage.get(global, 3, &profile) == ProfileStorageResult::kOk &&
|
||||||
|
profile.weak_rumble_scale == 23,
|
||||||
|
"legacy fallback profiles were not preserved");
|
||||||
|
require(storage.get(global, 7, &profile) == ProfileStorageResult::kOk &&
|
||||||
|
profile.weak_rumble_scale == UINT8_MAX,
|
||||||
|
"new profile slots were not defaulted during migration");
|
||||||
|
const ControllerIdentity stable = identity(7);
|
||||||
|
require(storage.find(stable) != nullptr &&
|
||||||
|
storage.find(stable)->active_profile == 2 &&
|
||||||
|
storage.get(stable, 2, &profile) == ProfileStorageResult::kOk &&
|
||||||
|
profile.strong_rumble_scale == 42,
|
||||||
|
"legacy stable identity was not preserved");
|
||||||
|
|
||||||
|
ProfileStorage reloaded;
|
||||||
|
require(reloaded.initialize(fake_io()) && reloaded.find(stable) != nullptr &&
|
||||||
|
reloaded.find(stable)->active_profile == 2,
|
||||||
|
"migrated catalog did not survive reload");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
int main() {
|
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();
|
return 0;
|
||||||
test_legacy_database_bank_migration();
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue