1176 lines
53 KiB
C++
1176 lines
53 KiB
C++
#include "core/controller_identity.h"
|
|
#include "profile/controller_profile.h"
|
|
#include "profile/profile_storage.h"
|
|
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <iostream>
|
|
#include <initializer_list>
|
|
|
|
namespace {
|
|
|
|
struct FakeFlash {
|
|
uint8_t bytes[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE];
|
|
int programs = 0;
|
|
int erases = 0;
|
|
int fail_after_programs = -1;
|
|
int fail_after_erases = -1;
|
|
size_t torn_page_bytes = 0;
|
|
int corrupt_previous_after_programs = -1;
|
|
uint8_t corrupt_arena = 0;
|
|
size_t corrupt_offset = 0;
|
|
bool corrupt_next_program = false;
|
|
int fail_read_after_programs = -1;
|
|
};
|
|
|
|
FakeFlash flash{};
|
|
|
|
void require(bool condition, const char *message) {
|
|
if (!condition) {
|
|
std::cerr << message << '\n';
|
|
std::exit(1);
|
|
}
|
|
}
|
|
|
|
void erase_all() {
|
|
flash = FakeFlash{};
|
|
memset(flash.bytes, 0xff, sizeof(flash.bytes));
|
|
flash.fail_after_programs = -1;
|
|
}
|
|
|
|
bool fake_read(void *context, uint8_t arena, size_t offset, uint8_t *output,
|
|
size_t size) {
|
|
auto *storage = static_cast<FakeFlash *>(context);
|
|
if (arena >= PROFILE_STORAGE_ARENA_COUNT || output == nullptr ||
|
|
offset > PROFILE_STORAGE_ARENA_SIZE ||
|
|
size > PROFILE_STORAGE_ARENA_SIZE - offset) {
|
|
return false;
|
|
}
|
|
if (storage->fail_read_after_programs >= 0 &&
|
|
storage->programs >= storage->fail_read_after_programs) {
|
|
return false;
|
|
}
|
|
memcpy(output, &storage->bytes[arena][offset], size);
|
|
return true;
|
|
}
|
|
|
|
bool fake_erase(void *context, uint8_t arena) {
|
|
auto *storage = static_cast<FakeFlash *>(context);
|
|
if (arena >= PROFILE_STORAGE_ARENA_COUNT) {
|
|
return false;
|
|
}
|
|
if (storage->fail_after_erases >= 0 &&
|
|
storage->erases >= storage->fail_after_erases) {
|
|
memset(storage->bytes[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE / 2);
|
|
return false;
|
|
}
|
|
memset(storage->bytes[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE);
|
|
++storage->erases;
|
|
return true;
|
|
}
|
|
|
|
bool fake_program(void *context, uint8_t arena, size_t offset,
|
|
const uint8_t *page, size_t size) {
|
|
auto *storage = static_cast<FakeFlash *>(context);
|
|
if (arena >= PROFILE_STORAGE_ARENA_COUNT || page == nullptr ||
|
|
size != PROFILE_STORAGE_PAGE_SIZE ||
|
|
offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
|
|
offset + size > PROFILE_STORAGE_ARENA_SIZE) {
|
|
return false;
|
|
}
|
|
if (storage->fail_after_programs >= 0 &&
|
|
storage->programs >= storage->fail_after_programs) {
|
|
for (size_t index = 0; index < storage->torn_page_bytes; ++index) {
|
|
storage->bytes[arena][offset + index] &= page[index];
|
|
}
|
|
return false;
|
|
}
|
|
for (size_t index = 0; index < size; ++index) {
|
|
storage->bytes[arena][offset + index] &= page[index];
|
|
}
|
|
if (storage->corrupt_next_program) {
|
|
storage->bytes[arena][offset] ^= 1;
|
|
storage->corrupt_next_program = false;
|
|
}
|
|
++storage->programs;
|
|
if (storage->programs == storage->corrupt_previous_after_programs) {
|
|
storage->bytes[storage->corrupt_arena][storage->corrupt_offset] ^= 1;
|
|
}
|
|
return memcmp(&storage->bytes[arena][offset], page, size) == 0;
|
|
}
|
|
|
|
ProfileStorageIo fake_io() {
|
|
return {
|
|
&flash,
|
|
PROFILE_STORAGE_ARENA_SIZE,
|
|
PROFILE_STORAGE_SECTOR_SIZE,
|
|
PROFILE_STORAGE_PAGE_SIZE,
|
|
fake_read,
|
|
fake_erase,
|
|
fake_program,
|
|
};
|
|
}
|
|
|
|
ControllerIdentity identity(uint8_t suffix) {
|
|
ControllerIdentity result{};
|
|
result.stable = true;
|
|
result.transport = ControllerTransport::kClassic;
|
|
result.address[5] = suffix;
|
|
result.vendor_id = 0x1234;
|
|
result.product_id = 0x5678;
|
|
return result;
|
|
}
|
|
|
|
ControllerIdentity joycon(uint8_t suffix, bool left) {
|
|
ControllerIdentity result = identity(suffix);
|
|
result.transport = ControllerTransport::kBle;
|
|
result.vendor_id = 0x057e;
|
|
result.product_id = left ? 0x2067 : 0x2066;
|
|
return result;
|
|
}
|
|
|
|
ControllerIdentity joycon_pair(const ControllerIdentity &left,
|
|
const ControllerIdentity &right) {
|
|
ControllerIdentity pair{};
|
|
require(controller_identity_make_joycon_pair(left, right, &pair),
|
|
"pair fixture identity is invalid");
|
|
return pair;
|
|
}
|
|
|
|
void write_u16(uint8_t *output, uint16_t value) {
|
|
output[0] = static_cast<uint8_t>(value);
|
|
output[1] = static_cast<uint8_t>(value >> 8);
|
|
}
|
|
|
|
void write_u32(uint8_t *output, uint32_t value) {
|
|
output[0] = static_cast<uint8_t>(value);
|
|
output[1] = static_cast<uint8_t>(value >> 8);
|
|
output[2] = static_cast<uint8_t>(value >> 16);
|
|
output[3] = static_cast<uint8_t>(value >> 24);
|
|
}
|
|
|
|
void encode_schema5(const ControllerProfile &profile, uint8_t *output) {
|
|
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
|
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
|
|
"profile fixture did not encode");
|
|
memcpy(output, encoded, CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE);
|
|
write_u16(output, CONTROLLER_PROFILE_SPARSE_MACRO_SCHEMA_VERSION);
|
|
write_u16(output + 2, CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE);
|
|
}
|
|
|
|
void install_legacy_database(uint32_t generation = 41) {
|
|
constexpr size_t kLegacyStart =
|
|
PROFILE_STORAGE_TOTAL_SIZE - PROFILE_STORAGE_LEGACY_TOTAL_SIZE;
|
|
constexpr uint8_t kArena = 1;
|
|
constexpr size_t kArenaBase = kLegacyStart - PROFILE_STORAGE_ARENA_SIZE;
|
|
constexpr uint8_t kBank = 1;
|
|
constexpr size_t kBankBase =
|
|
kArenaBase + kBank * PROFILE_STORAGE_LEGACY_BANK_SIZE;
|
|
uint8_t *header = flash.bytes[kArena] + kBankBase;
|
|
uint8_t *payload = header + PROFILE_STORAGE_LEGACY_HEADER_SIZE;
|
|
memset(header, 0, PROFILE_STORAGE_LEGACY_HEADER_SIZE);
|
|
memset(payload, 0, PROFILE_STORAGE_LEGACY_DATABASE_SIZE);
|
|
|
|
memcpy(payload, "SPDB", 4);
|
|
write_u16(&payload[4], 2);
|
|
write_u16(&payload[6], PROFILE_STORAGE_LEGACY_DATABASE_SIZE);
|
|
payload[8] = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY;
|
|
payload[9] = PROFILE_STORAGE_LEGACY_PROFILE_COUNT;
|
|
payload[10] = 3;
|
|
payload[11] = 1;
|
|
|
|
uint8_t encoded[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE]{};
|
|
const ControllerIdentity global = controller_identity_global();
|
|
for (uint8_t profile = 0; profile < PROFILE_STORAGE_LEGACY_PROFILE_COUNT;
|
|
++profile) {
|
|
ControllerProfile value = controller_profile_default(global, profile);
|
|
value.weak_rumble_scale = static_cast<uint8_t>(20 + profile);
|
|
encode_schema5(value, encoded);
|
|
memcpy(payload + 32 + profile * sizeof(encoded), encoded, sizeof(encoded));
|
|
}
|
|
|
|
uint8_t *entry =
|
|
payload + 32 +
|
|
PROFILE_STORAGE_LEGACY_PROFILE_COUNT *
|
|
CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE;
|
|
const ControllerIdentity stable = identity(7);
|
|
require(controller_identity_encode(stable, entry,
|
|
CONTROLLER_IDENTITY_ENCODED_SIZE),
|
|
"legacy identity did not encode");
|
|
entry[14] = 2;
|
|
entry[15] = 1;
|
|
for (uint8_t profile = 0; profile < PROFILE_STORAGE_LEGACY_PROFILE_COUNT;
|
|
++profile) {
|
|
ControllerProfile value = controller_profile_default(stable, profile);
|
|
value.strong_rumble_scale = static_cast<uint8_t>(40 + profile);
|
|
encode_schema5(value, encoded);
|
|
memcpy(entry + 16 + profile * sizeof(encoded), encoded, sizeof(encoded));
|
|
}
|
|
|
|
memcpy(header, "SPPF", 4);
|
|
write_u16(&header[4], 1);
|
|
write_u16(&header[6], 2);
|
|
write_u32(&header[8], generation);
|
|
write_u32(&header[12], PROFILE_STORAGE_LEGACY_DATABASE_SIZE);
|
|
write_u32(&header[16], profile_storage_crc32(
|
|
payload, PROFILE_STORAGE_LEGACY_DATABASE_SIZE));
|
|
write_u32(&header[20], profile_storage_crc32(header, 20));
|
|
}
|
|
|
|
ControllerIdentity catalog_identity(uint8_t index) {
|
|
return index == 0 ? controller_identity_global() : identity(index);
|
|
}
|
|
|
|
ControllerProfile catalog_profile(uint8_t index, uint8_t slot, bool extended) {
|
|
ControllerProfile profile =
|
|
controller_profile_default(catalog_identity(index), slot);
|
|
profile.weak_rumble_scale = static_cast<uint8_t>(10 + index);
|
|
profile.strong_rumble_scale = static_cast<uint8_t>(30 + slot);
|
|
profile.macros[0].trigger_mask = 1u << 10;
|
|
profile.macros[0].step_count = 8;
|
|
profile.macros[1].trigger_mask = 1u << 11;
|
|
profile.macros[1].first_step = 8;
|
|
profile.macros[1].step_count = 8;
|
|
profile.macros[2].first_step = 16;
|
|
profile.macros[3].first_step = 16;
|
|
profile.macro_step_count = 16;
|
|
// Fill 132 of the old 136 stream bytes, including the old page tail.
|
|
for (uint8_t step = 0; step < profile.macro_step_count; ++step) {
|
|
auto &value = profile.macro_steps[step];
|
|
value.duration_ms = static_cast<uint16_t>(1 + step);
|
|
if (step < 14) {
|
|
value.override_flags = kControllerProfileOverrideButtons |
|
|
kControllerProfileOverrideLeftStick;
|
|
value.output_button_mask = static_cast<uint16_t>(1u << (step % 16));
|
|
value.left_stick_x = static_cast<int16_t>(100 * step - 500);
|
|
value.left_stick_y = static_cast<int16_t>(100 * slot + index);
|
|
}
|
|
}
|
|
if (extended) {
|
|
profile.shortcuts.modifier = 4;
|
|
const uint8_t selectors[8] = {0, 1, 2, 3, 12, 13, 14, 15};
|
|
memcpy(profile.shortcuts.selectors, selectors, sizeof(selectors));
|
|
profile.shift.mode = ControllerProfileShiftMode::kToggle;
|
|
profile.shift.modifier = 17;
|
|
profile.shift.button_map[0] = 15;
|
|
profile.turbo_modes[0] = ControllerProfileTurboMode::kBurst;
|
|
profile.turbo_defaults = {7, 33, 5};
|
|
profile.turbo_override_mask = UINT16_MAX;
|
|
for (uint8_t button = 0; button < 16; ++button) {
|
|
profile.turbo_overrides[button] = {
|
|
static_cast<uint8_t>(button + 1),
|
|
static_cast<uint8_t>(button + 2),
|
|
static_cast<uint8_t>(button + 3)};
|
|
}
|
|
profile.macros[0].mode = ControllerProfileMacroMode::kRepeat;
|
|
profile.macros[0].repeat_count = 255;
|
|
}
|
|
return profile;
|
|
}
|
|
|
|
void install_catalog_record(uint16_t version, size_t offset, uint8_t type,
|
|
const ControllerIdentity &identity_value,
|
|
uint8_t slot, uint32_t generation,
|
|
const uint8_t *payload, size_t size) {
|
|
uint8_t *record = &flash.bytes[0][offset];
|
|
memset(record, 0, PROFILE_STORAGE_RECORD_SIZE);
|
|
memcpy(record, "SPCR", 4);
|
|
write_u16(record + 4, version);
|
|
record[6] = type;
|
|
record[7] = slot;
|
|
write_u32(record + 8, generation);
|
|
write_u16(record + 12, static_cast<uint16_t>(size));
|
|
write_u16(record + 14, type == 1
|
|
? static_cast<uint16_t>(payload[0] | (payload[1] << 8))
|
|
: 0);
|
|
write_u32(record + 16, profile_storage_crc32(payload, size));
|
|
require(controller_identity_encode(identity_value, record + 20,
|
|
CONTROLLER_IDENTITY_ENCODED_SIZE),
|
|
"catalog fixture identity did not encode");
|
|
write_u32(record + 34, profile_storage_crc32(record, 34));
|
|
if (size != 0) {
|
|
memcpy(record + (version == 1 ? 256 : 128), payload, size);
|
|
}
|
|
}
|
|
|
|
void install_populated_catalog(uint16_t version, bool fill_arena = false) {
|
|
size_t offset = PROFILE_STORAGE_RECORDS_OFFSET;
|
|
uint32_t generation = 1000;
|
|
for (uint8_t index = 0;
|
|
index <= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; ++index) {
|
|
const ControllerIdentity id = catalog_identity(index);
|
|
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
|
|
const ControllerProfile profile = catalog_profile(index, slot, version != 1);
|
|
uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
|
if (version == 1) {
|
|
encode_schema5(profile, payload);
|
|
} else {
|
|
require(controller_profile_encode(profile, payload, sizeof(payload)),
|
|
"catalog2 fixture profile did not encode");
|
|
// Keep catalog2 fixtures on the deployed schema6 wire format.
|
|
write_u16(payload, CONTROLLER_PROFILE_EXPANDED_SCHEMA_VERSION);
|
|
memset(payload + 344, 0, sizeof(payload) - 344);
|
|
}
|
|
install_catalog_record(
|
|
version, offset, 1, id, slot, ++generation, payload,
|
|
version == 1 ? CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE
|
|
: CONTROLLER_PROFILE_ENCODED_SIZE);
|
|
offset += PROFILE_STORAGE_RECORD_SIZE;
|
|
}
|
|
uint8_t alias[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
|
alias[0] = 1;
|
|
alias[1] = static_cast<uint8_t>('A' + index);
|
|
install_catalog_record(version, offset, 5, id, CONTROLLER_PROFILE_ALL,
|
|
++generation, alias, sizeof(alias));
|
|
offset += PROFILE_STORAGE_RECORD_SIZE;
|
|
uint8_t names[PROFILE_STORAGE_PROFILE_NAMES_PAYLOAD_SIZE]{};
|
|
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
|
|
const size_t base = slot * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE;
|
|
names[base] = 2;
|
|
names[base + 1] = static_cast<uint8_t>('A' + index);
|
|
names[base + 2] = static_cast<uint8_t>('0' + slot);
|
|
}
|
|
install_catalog_record(version, offset, 6, id, CONTROLLER_PROFILE_ALL,
|
|
++generation, names, sizeof(names));
|
|
offset += PROFILE_STORAGE_RECORD_SIZE;
|
|
install_catalog_record(version, offset, 4, id, index % 8,
|
|
++generation, nullptr, 0);
|
|
offset += PROFILE_STORAGE_RECORD_SIZE;
|
|
}
|
|
while (fill_arena && offset < PROFILE_STORAGE_ARENA_SIZE) {
|
|
install_catalog_record(version, offset, 4, catalog_identity(0), 0,
|
|
++generation, nullptr, 0);
|
|
offset += PROFILE_STORAGE_RECORD_SIZE;
|
|
}
|
|
uint8_t *superblock = flash.bytes[0];
|
|
memset(superblock, 0, PROFILE_STORAGE_SUPERBLOCK_SIZE);
|
|
memcpy(superblock, "SPCA", 4);
|
|
write_u16(superblock + 4, version);
|
|
write_u32(superblock + 8, 20);
|
|
write_u32(superblock + 12, profile_storage_crc32(superblock, 12));
|
|
}
|
|
|
|
void require_populated_catalog(const ProfileStorage &storage, bool extended) {
|
|
require(storage.identity_count() ==
|
|
CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1,
|
|
"catalog lost a populated identity");
|
|
for (uint8_t index = 0;
|
|
index <= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; ++index) {
|
|
const ControllerIdentity id = catalog_identity(index);
|
|
const auto *entry = storage.find(id);
|
|
require(entry != nullptr && entry->active_profile == index % 8,
|
|
"catalog lost an active profile index");
|
|
char value[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
|
require(storage.get_alias(id, value, sizeof(value)) ==
|
|
ProfileStorageResult::kOk &&
|
|
value[0] == 'A' + index && value[1] == '\0',
|
|
"catalog lost a controller alias");
|
|
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
|
|
ControllerProfile actual{};
|
|
const ControllerProfile expected = catalog_profile(index, slot, extended);
|
|
uint8_t actual_bytes[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
|
uint8_t expected_bytes[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
|
require(storage.get(id, slot, &actual) == ProfileStorageResult::kOk &&
|
|
controller_profile_encode(actual, actual_bytes,
|
|
sizeof(actual_bytes)) &&
|
|
controller_profile_encode(expected, expected_bytes,
|
|
sizeof(expected_bytes)) &&
|
|
memcmp(actual_bytes, expected_bytes, sizeof(actual_bytes)) == 0,
|
|
"catalog lost profile content or a macro at the old page tail");
|
|
require(storage.get_profile_name(id, slot, value, sizeof(value)) ==
|
|
ProfileStorageResult::kOk &&
|
|
value[0] == 'A' + index && value[1] == '0' + slot &&
|
|
value[2] == '\0',
|
|
"catalog lost a profile name");
|
|
}
|
|
}
|
|
}
|
|
|
|
void test_empty_catalog_and_eight_profiles() {
|
|
erase_all();
|
|
ProfileStorage storage;
|
|
require(storage.initialize(fake_io()) && storage.snapshot().valid &&
|
|
storage.identity_count() == 1,
|
|
"erased flash did not initialize an empty catalog");
|
|
const ControllerIdentity global = controller_identity_global();
|
|
ControllerProfile profile = controller_profile_default(global, 7);
|
|
profile.strong_rumble_scale = 77;
|
|
require(storage.set(global, 7, profile) == ProfileStorageResult::kOk,
|
|
"profile eight did not append");
|
|
require(storage.activate(global, 7) == ProfileStorageResult::kOk,
|
|
"profile eight did not activate");
|
|
|
|
ProfileStorage reloaded;
|
|
ControllerProfile recovered{};
|
|
require(reloaded.initialize(fake_io()) && reloaded.find(global) != nullptr &&
|
|
reloaded.find(global)->active_profile == 7 &&
|
|
reloaded.get(global, 7, &recovered) ==
|
|
ProfileStorageResult::kOk &&
|
|
recovered.strong_rumble_scale == 77,
|
|
"profile eight did not survive reload");
|
|
}
|
|
|
|
void test_identity_capacity_and_defaults() {
|
|
erase_all();
|
|
ProfileStorage storage;
|
|
require(storage.initialize(fake_io()), "catalog did not initialize");
|
|
for (uint8_t index = 1; index <= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY;
|
|
++index) {
|
|
require(storage.ensure_identity(identity(index)) ==
|
|
ProfileStorageResult::kOk,
|
|
"stable identity was not indexed");
|
|
}
|
|
require(storage.ensure_identity(identity(99)) == ProfileStorageResult::kFull,
|
|
"identity catalog accepted a seventeenth stable identity");
|
|
ControllerProfile profile{};
|
|
require(storage.get(identity(1), 7, &profile) == ProfileStorageResult::kOk &&
|
|
controller_profile_validate(profile),
|
|
"unstored profile eight did not resolve to its default");
|
|
}
|
|
|
|
void test_interrupted_and_corrupt_append_recovery() {
|
|
erase_all();
|
|
const ControllerIdentity global = controller_identity_global();
|
|
ProfileStorage storage;
|
|
require(storage.initialize(fake_io()), "catalog did not initialize");
|
|
ControllerProfile first = controller_profile_default(global, 0);
|
|
first.weak_rumble_scale = 11;
|
|
require(storage.set(global, 0, first) == ProfileStorageResult::kOk,
|
|
"baseline profile did not append");
|
|
|
|
ControllerProfile second = first;
|
|
second.weak_rumble_scale = 22;
|
|
flash.fail_after_programs = flash.programs + 1;
|
|
require(storage.set(global, 0, second) == ProfileStorageResult::kIoError,
|
|
"interrupted header publish reported success");
|
|
flash.fail_after_programs = -1;
|
|
ProfileStorage recovered;
|
|
ControllerProfile value{};
|
|
require(recovered.initialize(fake_io()) &&
|
|
recovered.get(global, 0, &value) == ProfileStorageResult::kOk &&
|
|
value.weak_rumble_scale == 11,
|
|
"interrupted append displaced the previous record");
|
|
|
|
flash.corrupt_next_program = true;
|
|
second.weak_rumble_scale = 33;
|
|
require(recovered.set(global, 0, second) == ProfileStorageResult::kIoError,
|
|
"corrupt payload program reported success");
|
|
ProfileStorage after_corruption;
|
|
require(after_corruption.initialize(fake_io()) &&
|
|
after_corruption.get(global, 0, &value) ==
|
|
ProfileStorageResult::kOk &&
|
|
value.weak_rumble_scale == 11,
|
|
"corrupt newest record displaced the previous record");
|
|
second.weak_rumble_scale = 44;
|
|
require(after_corruption.set(global, 0, second) == ProfileStorageResult::kOk,
|
|
"interrupted second-page slot was reused instead of skipped");
|
|
ProfileStorage after_retry;
|
|
require(after_retry.initialize(fake_io()) &&
|
|
after_retry.get(global, 0, &value) == ProfileStorageResult::kOk &&
|
|
value.weak_rumble_scale == 44,
|
|
"append after interrupted and corrupt slots did not survive reload");
|
|
}
|
|
|
|
void test_profile_names_and_aliases_recover() {
|
|
erase_all();
|
|
const ControllerIdentity stable = identity(7);
|
|
ProfileStorage storage;
|
|
require(storage.initialize(fake_io()), "metadata catalog did not initialize");
|
|
require(storage.set_alias(stable, "Player one", 10) ==
|
|
ProfileStorageResult::kOk &&
|
|
storage.set_profile_name(stable, 0, "Zelda", 5) ==
|
|
ProfileStorageResult::kOk &&
|
|
storage.set_profile_name(stable, 7, "Desktop", 7) ==
|
|
ProfileStorageResult::kOk,
|
|
"profile metadata did not append");
|
|
char value[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
|
require(storage.get_alias(stable, value, sizeof(value)) ==
|
|
ProfileStorageResult::kOk &&
|
|
strcmp(value, "Player one") == 0,
|
|
"controller alias did not read back");
|
|
|
|
flash.fail_after_programs = flash.programs + 1;
|
|
require(storage.set_alias(stable, "Interrupted", 11) ==
|
|
ProfileStorageResult::kIoError,
|
|
"interrupted alias append reported success");
|
|
flash.fail_after_programs = -1;
|
|
ProfileStorage reloaded;
|
|
require(reloaded.initialize(fake_io()) &&
|
|
reloaded.get_alias(stable, value, sizeof(value)) ==
|
|
ProfileStorageResult::kOk &&
|
|
strcmp(value, "Player one") == 0,
|
|
"interrupted alias displaced the previous value");
|
|
require(reloaded.get_profile_name(stable, 0, value, sizeof(value)) ==
|
|
ProfileStorageResult::kOk &&
|
|
strcmp(value, "Zelda") == 0 &&
|
|
reloaded.get_profile_name(stable, 7, value, sizeof(value)) ==
|
|
ProfileStorageResult::kOk &&
|
|
strcmp(value, "Desktop") == 0,
|
|
"profile names did not survive reload");
|
|
}
|
|
|
|
void test_compaction_preserves_latest_records() {
|
|
erase_all();
|
|
const ControllerIdentity global = controller_identity_global();
|
|
ProfileStorage storage;
|
|
require(storage.initialize(fake_io()), "catalog did not initialize");
|
|
ControllerProfile profile = controller_profile_default(global, 0);
|
|
require(storage.set_alias(global, "Fallback", 8) ==
|
|
ProfileStorageResult::kOk &&
|
|
storage.set_profile_name(global, 0, "Compacted", 9) ==
|
|
ProfileStorageResult::kOk,
|
|
"compaction metadata did not append");
|
|
profile.extra_button_map[0] = 16;
|
|
profile.shift.extra_button_map[6] = 15;
|
|
profile.swing = {2, 2, 24};
|
|
profile.nunchuk_swing = {255, 0, 18, 3};
|
|
profile.combined_swing = {4, 255, 16};
|
|
profile.combination_window_ms = 30;
|
|
profile.macros[3].step_count = 1;
|
|
profile.macros[3].mode = ControllerProfileMacroMode::kToggle;
|
|
profile.macro_step_count = 1;
|
|
profile.macro_steps[0].duration_ms = 50;
|
|
ControllerProfile other = controller_profile_default(global, 7);
|
|
other.swing = {15, 0, CONTROLLER_PROFILE_NO_BUTTON};
|
|
other.nunchuk_swing = {1, 2, 24};
|
|
other.combined_swing = {255, 0, 255};
|
|
other.combination_window_ms = 200;
|
|
other.macros[0].step_count = 1;
|
|
other.macro_step_count = 1;
|
|
other.macro_steps[0].duration_ms = 75;
|
|
for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) {
|
|
other.macros[index].first_step = 1;
|
|
}
|
|
const ControllerIdentity stable = identity(1);
|
|
require(storage.set(global, 7, other) == ProfileStorageResult::kOk,
|
|
"second swing profile did not append");
|
|
other.swing = {255, 1, 16, 0};
|
|
other.nunchuk_swing = {3, 1, 17};
|
|
other.combined_swing = {14, 255, 24};
|
|
other.combination_window_ms = 120;
|
|
require(storage.set(stable, 0, other) == ProfileStorageResult::kOk,
|
|
"stable identity swing profile did not append");
|
|
for (uint16_t write = 1; write <= 260; ++write) {
|
|
profile.weak_rumble_scale = static_cast<uint8_t>(write);
|
|
require(storage.set(global, 0, profile) == ProfileStorageResult::kOk,
|
|
"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{};
|
|
char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
|
require(reloaded.initialize(fake_io()) &&
|
|
reloaded.get(global, 0, &recovered) ==
|
|
ProfileStorageResult::kOk &&
|
|
recovered.weak_rumble_scale == static_cast<uint8_t>(260) &&
|
|
recovered.extra_button_map[0] == 16 &&
|
|
recovered.shift.extra_button_map[6] == 15 &&
|
|
recovered.swing.button == 2 &&
|
|
recovered.swing.sensitivity == 2 &&
|
|
recovered.swing.modifier == 24 &&
|
|
recovered.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.nunchuk_swing.sensitivity == 0 &&
|
|
recovered.nunchuk_swing.modifier == 18 &&
|
|
recovered.nunchuk_swing.macro == 3 &&
|
|
recovered.combined_swing.button == 4 &&
|
|
recovered.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.combined_swing.modifier == 16 &&
|
|
recovered.combination_window_ms == 30 &&
|
|
recovered.macros[3].mode == ControllerProfileMacroMode::kToggle &&
|
|
recovered.macro_steps[0].duration_ms == 50 &&
|
|
reloaded.get_alias(global, metadata, sizeof(metadata)) ==
|
|
ProfileStorageResult::kOk &&
|
|
strcmp(metadata, "Fallback") == 0 &&
|
|
reloaded.get_profile_name(global, 0, metadata,
|
|
sizeof(metadata)) ==
|
|
ProfileStorageResult::kOk &&
|
|
strcmp(metadata, "Compacted") == 0,
|
|
"compaction did not preserve profiles and metadata");
|
|
const uint32_t generation = reloaded.snapshot().generation;
|
|
const int programs = flash.programs;
|
|
recovered.nunchuk_swing.button = 0;
|
|
require(reloaded.set(global, 0, recovered) ==
|
|
ProfileStorageResult::kInvalidArgument &&
|
|
reloaded.snapshot().generation == generation &&
|
|
flash.programs == programs &&
|
|
reloaded.get(global, 0, &recovered) == ProfileStorageResult::kOk &&
|
|
recovered.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.nunchuk_swing.macro == 3,
|
|
"rejected gesture action modified the stored profile");
|
|
require(reloaded.get(global, 7, &recovered) == ProfileStorageResult::kOk &&
|
|
recovered.swing.button == 15 &&
|
|
recovered.swing.sensitivity == 0 &&
|
|
recovered.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.nunchuk_swing.button == 1 &&
|
|
recovered.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.combined_swing.macro == 0 &&
|
|
recovered.combination_window_ms == 200 &&
|
|
recovered.macro_steps[0].duration_ms == 75 &&
|
|
reloaded.get(stable, 0, &recovered) == ProfileStorageResult::kOk &&
|
|
recovered.swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.swing.macro == 0 &&
|
|
recovered.swing.sensitivity == 1 &&
|
|
recovered.swing.modifier == 16 &&
|
|
recovered.nunchuk_swing.button == 3 &&
|
|
recovered.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.combined_swing.button == 14 &&
|
|
recovered.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.combination_window_ms == 120 &&
|
|
reloaded.get(global, 1, &recovered) == ProfileStorageResult::kOk &&
|
|
recovered.swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
recovered.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON,
|
|
"compacted swing settings bled across profiles or identities");
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
void test_populated_catalog_publication_power_loss() {
|
|
static FakeFlash baseline;
|
|
for (uint16_t version = 1; version <= 3; ++version) {
|
|
erase_all();
|
|
install_populated_catalog(version, true);
|
|
baseline = flash;
|
|
ProfileStorage completed;
|
|
require(completed.initialize(fake_io()), "populated catalog did not load");
|
|
require_populated_catalog(completed, version != 1);
|
|
if (version == 3) {
|
|
require(completed.set_alias(catalog_identity(0), "New", 3) ==
|
|
ProfileStorageResult::kOk,
|
|
"populated catalog could not compact all 187 live records");
|
|
}
|
|
const int publication_programs = flash.programs;
|
|
for (size_t torn_bytes : {size_t{0}, size_t{129}}) {
|
|
// Include interruption during arena erase and before every program page,
|
|
// including both pages of every record and the final superblock.
|
|
for (int cut = -1; cut < publication_programs; ++cut) {
|
|
flash = baseline;
|
|
flash.torn_page_bytes = torn_bytes;
|
|
ProfileStorage interrupted;
|
|
if (version == 3) {
|
|
require(interrupted.initialize(fake_io()),
|
|
"catalog2 power-loss baseline did not load");
|
|
}
|
|
if (cut < 0) {
|
|
flash.fail_after_erases = 0;
|
|
} else {
|
|
flash.fail_after_programs = cut;
|
|
}
|
|
if (version != 3) {
|
|
require(!interrupted.initialize(fake_io()),
|
|
"incomplete old catalog migration silently initialized empty");
|
|
} else {
|
|
require(interrupted.set_alias(catalog_identity(0), "New", 3) ==
|
|
ProfileStorageResult::kIoError,
|
|
"incomplete compaction or append reported success");
|
|
}
|
|
require(memcmp(flash.bytes[0], baseline.bytes[0],
|
|
PROFILE_STORAGE_ARENA_SIZE) == 0,
|
|
"interrupted publication modified the old published arena");
|
|
flash.fail_after_erases = -1;
|
|
flash.fail_after_programs = -1;
|
|
flash.torn_page_bytes = 0;
|
|
ProfileStorage recovered;
|
|
require(recovered.initialize(fake_io()),
|
|
"catalog did not recover after an interrupted publication");
|
|
require_populated_catalog(recovered, version != 1);
|
|
require(recovered.snapshot().generation >= 1248,
|
|
"catalog publication regressed the stored generation");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void test_migration_verifies_all_records_before_publication() {
|
|
erase_all();
|
|
install_populated_catalog(1);
|
|
static FakeFlash baseline;
|
|
baseline = flash;
|
|
// Corrupt an already-verified first record while the last record is being
|
|
// copied. A per-write readback alone would publish an incomplete catalog.
|
|
flash.corrupt_previous_after_programs =
|
|
2 * PROFILE_STORAGE_MAX_LIVE_RECORDS;
|
|
flash.corrupt_arena = 1;
|
|
flash.corrupt_offset = PROFILE_STORAGE_RECORDS_OFFSET + 128 + 283;
|
|
ProfileStorage interrupted;
|
|
require(!interrupted.initialize(fake_io()) &&
|
|
flash.bytes[1][0] == 0xff &&
|
|
memcmp(flash.bytes[0], baseline.bytes[0],
|
|
PROFILE_STORAGE_ARENA_SIZE) == 0,
|
|
"migration published before fully verifying the copied arena");
|
|
flash.corrupt_previous_after_programs = -1;
|
|
ProfileStorage recovered;
|
|
require(recovered.initialize(fake_io()),
|
|
"migration did not retry after copy verification failed");
|
|
require_populated_catalog(recovered, false);
|
|
}
|
|
|
|
void test_retired_bank_migration_power_loss() {
|
|
erase_all();
|
|
install_legacy_database();
|
|
static FakeFlash baseline;
|
|
baseline = flash;
|
|
ProfileStorage complete;
|
|
require(complete.initialize(fake_io()), "retired bank did not migrate");
|
|
const int programs = flash.programs;
|
|
for (int cut = -1; cut < programs; ++cut) {
|
|
flash = baseline;
|
|
flash.torn_page_bytes = 129;
|
|
if (cut < 0) {
|
|
flash.fail_after_erases = 0;
|
|
} else {
|
|
flash.fail_after_programs = cut;
|
|
}
|
|
ProfileStorage interrupted;
|
|
require(!interrupted.initialize(fake_io()) &&
|
|
memcmp(flash.bytes[1], baseline.bytes[1],
|
|
PROFILE_STORAGE_ARENA_SIZE) == 0,
|
|
"interrupted retired-bank migration lost the old database");
|
|
flash.fail_after_erases = -1;
|
|
flash.fail_after_programs = -1;
|
|
flash.torn_page_bytes = 0;
|
|
ProfileStorage recovered;
|
|
ControllerProfile profile{};
|
|
require(recovered.initialize(fake_io()) &&
|
|
recovered.find(identity(7)) != nullptr &&
|
|
recovered.find(identity(7))->active_profile == 2 &&
|
|
recovered.get(identity(7), 2, &profile) ==
|
|
ProfileStorageResult::kOk &&
|
|
profile.strong_rumble_scale == 42 &&
|
|
recovered.get(catalog_identity(0), 3, &profile) ==
|
|
ProfileStorageResult::kOk &&
|
|
profile.weak_rumble_scale == 23 &&
|
|
recovered.snapshot().generation > 41,
|
|
"retired-bank migration did not recover its profiles/generation");
|
|
}
|
|
}
|
|
|
|
void test_late_second_page_program_is_not_reused() {
|
|
erase_all();
|
|
ProfileStorage storage;
|
|
require(storage.initialize(fake_io()), "catalog did not initialize");
|
|
// The first page and first bytes of page two are erased; only a late
|
|
// default-rate byte was programmed when power was lost.
|
|
flash.bytes[0][PROFILE_STORAGE_RECORDS_OFFSET + 128 + 283] = 0;
|
|
ProfileStorage recovered;
|
|
require(recovered.initialize(fake_io()), "partial record did not recover");
|
|
const auto id = catalog_identity(0);
|
|
ControllerProfile profile = catalog_profile(0, 7, true);
|
|
require(recovered.set(id, 7, profile) == ProfileStorageResult::kOk,
|
|
"scanner reused a partially programmed second page");
|
|
profile.turbo_defaults.rate_hz = 23;
|
|
require(recovered.set(id, 7, profile) == ProfileStorageResult::kOk,
|
|
"newest profile did not append");
|
|
const uint32_t newest = recovered.find(id)->profile_record[7];
|
|
flash.bytes[0][newest + 128 + 283] ^= 1;
|
|
ProfileStorage fallback;
|
|
ControllerProfile value{};
|
|
require(fallback.initialize(fake_io()) &&
|
|
fallback.get(id, 7, &value) == ProfileStorageResult::kOk &&
|
|
value.turbo_defaults.rate_hz == 7 &&
|
|
value.macros[0].repeat_count == 255,
|
|
"corrupted schema6 newest record did not fall back to its predecessor");
|
|
}
|
|
|
|
void test_unreadable_legacy_data_is_not_erased() {
|
|
erase_all();
|
|
install_legacy_database();
|
|
constexpr size_t base = PROFILE_STORAGE_ARENA_SIZE -
|
|
PROFILE_STORAGE_LEGACY_BANK_SIZE;
|
|
flash.bytes[1][base + PROFILE_STORAGE_LEGACY_HEADER_SIZE + 32] ^= 1;
|
|
static FakeFlash baseline;
|
|
baseline = flash;
|
|
ProfileStorage storage;
|
|
require(!storage.initialize(fake_io()) &&
|
|
memcmp(flash.bytes, baseline.bytes, sizeof(flash.bytes)) == 0,
|
|
"unreadable old data was silently replaced with an empty catalog");
|
|
}
|
|
|
|
void test_legacy_high_generation_remains_mutable() {
|
|
erase_all();
|
|
install_legacy_database(0x80000000u);
|
|
ProfileStorage storage;
|
|
require(storage.initialize(fake_io()),
|
|
"high-generation legacy bank could not migrate");
|
|
const auto global = controller_identity_global();
|
|
auto changed = controller_profile_default(global, 0);
|
|
changed.weak_rumble_scale = 77;
|
|
require(storage.set(global, 0, changed) == ProfileStorageResult::kOk,
|
|
"high-generation migrated profile could not be updated");
|
|
ProfileStorage reloaded;
|
|
ControllerProfile restored{};
|
|
require(reloaded.initialize(fake_io()) &&
|
|
reloaded.get(global, 0, &restored) == ProfileStorageResult::kOk &&
|
|
restored.weak_rumble_scale == 77,
|
|
"high-generation update was lost after reload");
|
|
}
|
|
|
|
void test_schema6_read_migration_is_lazy_and_edit_preserves_metadata() {
|
|
erase_all();
|
|
install_populated_catalog(3);
|
|
ProfileStorage storage;
|
|
require(storage.initialize(fake_io()), "schema6 catalog did not load");
|
|
require_populated_catalog(storage, true);
|
|
require(flash.programs == 0 && flash.erases == 0,
|
|
"reading schema6 profiles rewrote the published flash arena");
|
|
const auto id = catalog_identity(7);
|
|
ControllerProfile profile{};
|
|
uint8_t before[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
|
require(storage.get(id, 7, &profile) == ProfileStorageResult::kOk &&
|
|
controller_profile_encode(profile, before, sizeof(before)) &&
|
|
storage.set(id, 7, profile) == ProfileStorageResult::kUnchanged &&
|
|
flash.programs == 0 && flash.erases == 0,
|
|
"unchanged migrated profile caused an unnecessary flash write");
|
|
profile.extra_button_map[0] = 16;
|
|
profile.extra_button_map[6] = 15;
|
|
profile.shift.extra_button_map[1] = 2;
|
|
require(storage.set(id, 7, profile) == ProfileStorageResult::kOk,
|
|
"schema6 profile could not add extra-control mappings");
|
|
ProfileStorage reloaded;
|
|
ControllerProfile recovered{};
|
|
uint8_t after[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
|
require(reloaded.initialize(fake_io()) &&
|
|
reloaded.get(id, 7, &recovered) == ProfileStorageResult::kOk &&
|
|
controller_profile_encode(recovered, after, sizeof(after)) &&
|
|
memcmp(before + 2, after + 2, 342) == 0 &&
|
|
recovered.extra_button_map[0] == 16 &&
|
|
recovered.extra_button_map[6] == 15 &&
|
|
recovered.shift.extra_button_map[1] == 2,
|
|
"editing migrated extras lost existing settings or the new mappings");
|
|
char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
|
require(reloaded.find(id)->active_profile == 7 &&
|
|
reloaded.get_alias(id, metadata, sizeof(metadata)) ==
|
|
ProfileStorageResult::kOk &&
|
|
strcmp(metadata, "H") == 0 &&
|
|
reloaded.get_profile_name(id, 7, metadata, sizeof(metadata)) ==
|
|
ProfileStorageResult::kOk &&
|
|
strcmp(metadata, "H7") == 0,
|
|
"editing a schema6 profile lost its active index, alias, or name");
|
|
}
|
|
|
|
void test_schema8_swing_migration_is_lazy_and_preserves_existing_data() {
|
|
erase_all();
|
|
install_populated_catalog(3);
|
|
const auto id = controller_identity_global();
|
|
ControllerProfile original = catalog_profile(0, 0, true);
|
|
original.extra_button_map[6] = 16;
|
|
original.shift.extra_button_map[0] = 15;
|
|
original.swing = {2, 0, 24};
|
|
uint8_t legacy[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
|
require(controller_profile_encode(original, legacy, sizeof(legacy)),
|
|
"schema8 storage fixture did not encode");
|
|
write_u16(legacy, CONTROLLER_PROFILE_SWING_SCHEMA_VERSION);
|
|
memset(legacy + 367, 0, sizeof(legacy) - 367);
|
|
install_catalog_record(3, PROFILE_STORAGE_RECORDS_OFFSET, 1, id, 0, 1001,
|
|
legacy, sizeof(legacy));
|
|
ProfileStorage storage;
|
|
ControllerProfile migrated{};
|
|
require(storage.initialize(fake_io()) &&
|
|
storage.get(id, 0, &migrated) == ProfileStorageResult::kOk &&
|
|
migrated.swing.button == 2 && migrated.swing.sensitivity == 0 &&
|
|
migrated.swing.modifier == 24 &&
|
|
migrated.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
migrated.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
migrated.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
migrated.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
migrated.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
|
migrated.combination_window_ms == 100 &&
|
|
storage.set(id, 0, migrated) == ProfileStorageResult::kUnchanged &&
|
|
flash.programs == 0 && flash.erases == 0,
|
|
"schema8 read enabled new gestures or unnecessarily rewrote flash");
|
|
migrated.nunchuk_swing = {255, 2, 18, 1};
|
|
migrated.combined_swing = {255, 0, 16};
|
|
migrated.combination_window_ms = 200;
|
|
require(storage.set(id, 0, migrated) == ProfileStorageResult::kOk,
|
|
"schema8 profile could not add gesture macro actions");
|
|
ProfileStorage reloaded;
|
|
ControllerProfile recovered{};
|
|
uint8_t upgraded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
|
char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
|
require(reloaded.initialize(fake_io()) &&
|
|
reloaded.get(id, 0, &recovered) == ProfileStorageResult::kOk &&
|
|
controller_profile_encode(recovered, upgraded, sizeof(upgraded)) &&
|
|
memcmp(legacy + 2, upgraded + 2, 365) == 0 &&
|
|
recovered.nunchuk_swing.macro == 1 &&
|
|
recovered.combined_swing.macro == 0 &&
|
|
recovered.combination_window_ms == 200 &&
|
|
reloaded.find(id)->active_profile == 0 &&
|
|
reloaded.get_alias(id, metadata, sizeof(metadata)) ==
|
|
ProfileStorageResult::kOk &&
|
|
strcmp(metadata, "A") == 0 &&
|
|
reloaded.get_profile_name(id, 0, metadata, sizeof(metadata)) ==
|
|
ProfileStorageResult::kOk &&
|
|
strcmp(metadata, "A0") == 0,
|
|
"editing migrated gestures lost Remote, macros, extras, or metadata");
|
|
}
|
|
|
|
void require_pair_bank(const ProfileStorage &storage,
|
|
const ControllerIdentity &owner, uint8_t base,
|
|
uint8_t active, char name_prefix) {
|
|
const auto *entry = storage.find(owner);
|
|
require(entry != nullptr && entry->active_profile == active,
|
|
"pair bank active selection changed");
|
|
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
|
|
ControllerProfile actual{};
|
|
char name[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
|
require(storage.get(owner, slot, &actual) == ProfileStorageResult::kOk &&
|
|
actual.weak_rumble_scale == base + slot &&
|
|
storage.get_profile_name(owner, slot, name, sizeof(name)) ==
|
|
ProfileStorageResult::kOk &&
|
|
name[0] == name_prefix && name[1] == '0' + slot &&
|
|
name[2] == '\0',
|
|
"pair bank profile or name bled into another owner");
|
|
}
|
|
}
|
|
|
|
void write_pair_bank(ProfileStorage &storage, const ControllerIdentity &owner,
|
|
uint8_t base, char name_prefix) {
|
|
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
|
|
ControllerProfile profile = controller_profile_default(owner, slot);
|
|
profile.weak_rumble_scale = base + slot;
|
|
const char name[2] = {name_prefix, static_cast<char>('0' + slot)};
|
|
require(storage.set(owner, slot, profile) == ProfileStorageResult::kOk &&
|
|
storage.set_profile_name(owner, slot, name, sizeof(name)) ==
|
|
ProfileStorageResult::kOk,
|
|
"pair bank edit failed");
|
|
}
|
|
}
|
|
|
|
void test_pair_seed_independence_reconnect_and_compaction() {
|
|
erase_all();
|
|
const auto left = joycon(1, true);
|
|
const auto right = joycon(2, false);
|
|
const auto pair = joycon_pair(left, right);
|
|
ProfileStorage storage;
|
|
require(storage.initialize(fake_io()), "pair catalog did not initialize");
|
|
write_pair_bank(storage, left, 20, 'L');
|
|
write_pair_bank(storage, right, 40, 'R');
|
|
require(storage.set_alias(left, "Solo", 4) == ProfileStorageResult::kOk &&
|
|
storage.activate(left, 7) == ProfileStorageResult::kOk &&
|
|
storage.activate(right, 3) == ProfileStorageResult::kOk,
|
|
"solo bank setup failed");
|
|
const int programs = flash.programs;
|
|
const int erases = flash.erases;
|
|
require(storage.ensure_joycon_pair(pair) == ProfileStorageResult::kOk &&
|
|
flash.programs == programs + 2 && flash.erases == erases,
|
|
"first pair did not seed with one atomic append");
|
|
require_pair_bank(storage, pair, 20, 7, 'L');
|
|
char alias[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
|
require(storage.get_alias(pair, alias, sizeof(alias)) ==
|
|
ProfileStorageResult::kOk && alias[0] == '\0' &&
|
|
storage.get_alias(left, alias, sizeof(alias)) ==
|
|
ProfileStorageResult::kOk && strcmp(alias, "Solo") == 0,
|
|
"pair inherited or changed the solo alias");
|
|
write_pair_bank(storage, left, 60, 'S');
|
|
require(storage.activate(left, 1) == ProfileStorageResult::kOk,
|
|
"left selection edit failed");
|
|
ProfileStorage replayed;
|
|
require(replayed.initialize(fake_io()), "pair seed did not replay");
|
|
require_pair_bank(replayed, pair, 20, 7, 'L');
|
|
require_pair_bank(replayed, left, 60, 1, 'S');
|
|
require_pair_bank(replayed, right, 40, 3, 'R');
|
|
write_pair_bank(replayed, pair, 80, 'P');
|
|
require(replayed.activate(pair, 6) == ProfileStorageResult::kOk &&
|
|
replayed.set_alias(pair, "Together", 8) == ProfileStorageResult::kOk,
|
|
"pair selection or alias edit failed");
|
|
const uint32_t generation = replayed.snapshot().generation;
|
|
require(replayed.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged &&
|
|
replayed.snapshot().generation == generation,
|
|
"pair reconnect reseeded or wrote the bank");
|
|
const auto other_pair = joycon_pair(left, joycon(3, false));
|
|
require(replayed.ensure_joycon_pair(other_pair) == ProfileStorageResult::kOk,
|
|
"different right member did not receive a separate bank");
|
|
require_pair_bank(replayed, other_pair, 60, 1, 'S');
|
|
require_pair_bank(replayed, pair, 80, 6, 'P');
|
|
require_pair_bank(replayed, left, 60, 1, 'S');
|
|
require_pair_bank(replayed, right, 40, 3, 'R');
|
|
const uint8_t old_arena = replayed.snapshot().active_bank;
|
|
for (size_t write = 0; write <= PROFILE_STORAGE_RECORD_CAPACITY; ++write) {
|
|
require(replayed.activate(left, (write + 2) % CONTROLLER_PROFILE_COUNT) ==
|
|
ProfileStorageResult::kOk,
|
|
"pair compaction trigger failed");
|
|
}
|
|
require(replayed.snapshot().active_bank != old_arena,
|
|
"pair catalog never compacted");
|
|
ProfileStorage compacted;
|
|
require(compacted.initialize(fake_io()) &&
|
|
compacted.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
|
|
"materialized pair did not reconnect after compaction");
|
|
require_pair_bank(compacted, pair, 80, 6, 'P');
|
|
require_pair_bank(compacted, other_pair, 60, 1, 'S');
|
|
require_pair_bank(compacted, right, 40, 3, 'R');
|
|
require(compacted.get_alias(pair, alias, sizeof(alias)) ==
|
|
ProfileStorageResult::kOk && strcmp(alias, "Together") == 0,
|
|
"compaction lost pair-owned alias");
|
|
require(compacted.reset(pair, CONTROLLER_PROFILE_ALL) ==
|
|
ProfileStorageResult::kOk,
|
|
"pair reset failed");
|
|
require_pair_bank(compacted, other_pair, 60, 1, 'S');
|
|
require_pair_bank(compacted, right, 40, 3, 'R');
|
|
}
|
|
|
|
void test_pair_capacity_and_unseeded_mutations() {
|
|
erase_all();
|
|
const auto left = joycon(1, true);
|
|
const auto right = joycon(2, false);
|
|
const auto pair = joycon_pair(left, right);
|
|
ProfileStorage storage;
|
|
require(storage.initialize(fake_io()), "pair capacity catalog did not initialize");
|
|
ControllerProfile profile = controller_profile_default(pair, 0);
|
|
profile.weak_rumble_scale = 42;
|
|
require(storage.ensure_identity(pair) == ProfileStorageResult::kInvalidArgument &&
|
|
storage.set(pair, 0, profile) == ProfileStorageResult::kInvalidArgument &&
|
|
storage.activate(pair, 7) == ProfileStorageResult::kInvalidArgument &&
|
|
storage.set_alias(pair, "Pair", 4) == ProfileStorageResult::kInvalidArgument &&
|
|
storage.set_profile_name(pair, 7, "Pair", 4) ==
|
|
ProfileStorageResult::kInvalidArgument &&
|
|
storage.reset(pair, CONTROLLER_PROFILE_ALL) ==
|
|
ProfileStorageResult::kInvalidArgument &&
|
|
storage.find(pair) == nullptr,
|
|
"mutation implicitly created an unseeded pair");
|
|
for (uint8_t index = 1; index <= 14; ++index) {
|
|
require(storage.ensure_identity(identity(index)) == ProfileStorageResult::kOk,
|
|
"capacity setup failed");
|
|
}
|
|
const int programs = flash.programs;
|
|
require(storage.ensure_joycon_pair(pair) == ProfileStorageResult::kFull &&
|
|
storage.find(left) == nullptr && storage.find(right) == nullptr &&
|
|
storage.find(pair) == nullptr && flash.programs == programs,
|
|
"capacity preflight changed owners before rejecting the pair");
|
|
erase_all();
|
|
require(storage.initialize(fake_io()), "exact-fit catalog did not initialize");
|
|
for (uint8_t index = 1; index <= 13; ++index) {
|
|
require(storage.ensure_identity(identity(index)) == ProfileStorageResult::kOk,
|
|
"exact-fit setup failed");
|
|
}
|
|
require(storage.ensure_joycon_pair(pair) == ProfileStorageResult::kOk &&
|
|
storage.identity_count() == CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1 &&
|
|
storage.find(left) != nullptr && storage.find(right) != nullptr &&
|
|
storage.get(pair, 7, &profile) == ProfileStorageResult::kOk &&
|
|
profile.weak_rumble_scale ==
|
|
controller_profile_default(left, 7).weak_rumble_scale &&
|
|
storage.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
|
|
"exact-capacity pair did not create both members and the default bank");
|
|
}
|
|
|
|
void test_pair_seed_interruption_and_ambiguous_readback() {
|
|
erase_all();
|
|
const auto left = joycon(1, true);
|
|
const auto right = joycon(2, false);
|
|
const auto pair = joycon_pair(left, right);
|
|
ProfileStorage initial;
|
|
require(initial.initialize(fake_io()), "seed fault catalog did not initialize");
|
|
write_pair_bank(initial, left, 20, 'L');
|
|
require(initial.activate(left, 7) == ProfileStorageResult::kOk,
|
|
"seed fault active setup failed");
|
|
static FakeFlash baseline;
|
|
baseline = flash;
|
|
// Right row and seed each use two pages. Exercise every cut, including a
|
|
// fully committed header whose program call nevertheless reports failure.
|
|
for (size_t torn_bytes : {size_t{0}, size_t{64}, size_t{256}}) {
|
|
for (int cut = 0; cut < 4; ++cut) {
|
|
flash = baseline;
|
|
ProfileStorage interrupted;
|
|
require(interrupted.initialize(fake_io()), "seed fault baseline did not load");
|
|
flash.fail_after_programs = flash.programs + cut;
|
|
flash.torn_page_bytes = torn_bytes;
|
|
require(interrupted.ensure_joycon_pair(pair) == ProfileStorageResult::kIoError &&
|
|
interrupted.find(pair) == nullptr,
|
|
"interrupted seed exposed a partially initialized bank");
|
|
flash.fail_after_programs = -1;
|
|
require(interrupted.ensure_joycon_pair(pair) == ProfileStorageResult::kIoError &&
|
|
interrupted.activate(left, 0) == ProfileStorageResult::kIoError,
|
|
"ambiguous seed allowed retry or source mutation before replay");
|
|
flash.torn_page_bytes = 0;
|
|
ProfileStorage recovered;
|
|
require(recovered.initialize(fake_io()), "interrupted seed did not recover");
|
|
const auto result = recovered.ensure_joycon_pair(pair);
|
|
require(result == ProfileStorageResult::kOk ||
|
|
result == ProfileStorageResult::kUnchanged,
|
|
"seed retry failed after durable replay");
|
|
require_pair_bank(recovered, pair, 20, 7, 'L');
|
|
require_pair_bank(recovered, left, 20, 7, 'L');
|
|
}
|
|
}
|
|
flash = baseline;
|
|
ProfileStorage ambiguous;
|
|
require(ambiguous.initialize(fake_io()) &&
|
|
ambiguous.ensure_identity(right) == ProfileStorageResult::kOk,
|
|
"readback fault setup failed");
|
|
flash.fail_read_after_programs = flash.programs + 2;
|
|
require(ambiguous.ensure_joycon_pair(pair) == ProfileStorageResult::kIoError &&
|
|
ambiguous.find(pair) == nullptr,
|
|
"unacknowledged seed was published in memory");
|
|
ProfileStorage unreadable;
|
|
require(!unreadable.initialize(fake_io()), "unreadable seed replay failed open");
|
|
flash.fail_read_after_programs = -1;
|
|
require(ambiguous.activate(left, 0) == ProfileStorageResult::kIoError,
|
|
"unacknowledged seed did not freeze source changes");
|
|
ProfileStorage recovered;
|
|
require(recovered.initialize(fake_io()) &&
|
|
recovered.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
|
|
"committed unacknowledged seed was reseeded");
|
|
require_pair_bank(recovered, pair, 20, 7, 'L');
|
|
write_pair_bank(recovered, left, 60, 'S');
|
|
require(recovered.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
|
|
"recovered pair was reseeded after source edits");
|
|
require_pair_bank(recovered, pair, 20, 7, 'L');
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
test_empty_catalog_and_eight_profiles();
|
|
test_identity_capacity_and_defaults();
|
|
test_interrupted_and_corrupt_append_recovery();
|
|
test_profile_names_and_aliases_recover();
|
|
test_compaction_preserves_latest_records();
|
|
test_legacy_migration_is_atomic_and_complete();
|
|
test_legacy_high_generation_remains_mutable();
|
|
test_populated_catalog_publication_power_loss();
|
|
test_migration_verifies_all_records_before_publication();
|
|
test_retired_bank_migration_power_loss();
|
|
test_late_second_page_program_is_not_reused();
|
|
test_unreadable_legacy_data_is_not_erased();
|
|
test_schema6_read_migration_is_lazy_and_edit_preserves_metadata();
|
|
test_schema8_swing_migration_is_lazy_and_preserves_existing_data();
|
|
test_pair_seed_independence_reconnect_and_compaction();
|
|
test_pair_capacity_and_unseeded_mutations();
|
|
test_pair_seed_interruption_and_ambiguous_readback();
|
|
std::cout << "profile storage tests passed\n";
|
|
return 0;
|
|
}
|