Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
3f40b6632This file is pinned to the selected release. Download the original to open it in your design tool.
/**
* @file surface/settings.cpp
* @brief alchemy::Settings implementation.
*/
#include "alchemy/surface/settings.h"
#include <cassert>
#include <cstring>
#include "alchemy/hw/alchemy_lab.h"
#include "alchemy/control/selector.h"
#include "alchemy/led/ring_frame.h"
#include "alchemy/led/anims/catch_pip.h"
#include "alchemy/surface/lock_source.h"
#include "alchemy/surface/manual.h"
#include "alchemy/surface/pager.h"
#include "alchemy/surface/presets.h"
namespace alchemy {
/* Schema tag — bump if Settings's serialised layout grammar changes. */
static constexpr uint32_t kSettingsSchemaTag = 0x53455430u; /* 'SET0' */
/* ── Constructors ─────────────────────────────────────────────────── */
Settings::Settings(IButton& b2, IButton& b3, uint32_t hold_ms)
: b2_(&b2), b3_(&b3), hold_ms_(hold_ms)
{
}
Settings::Settings(AlchemyLab& hw, Pager* perf_pager, uint32_t hold_ms)
: b2_(&hw.buttons[1]),
b3_(&hw.buttons[2]),
hold_ms_(hold_ms),
hw_(&hw),
perf_pager_(perf_pager),
b1_(&hw.buttons[0])
{
}
/* ── Standard options ─────────────────────────────────────────────── */
BrightnessHandle Settings::UseBrightness()
{
EnsurePage(0u);
SettingsSlot& s = slots_[0][0];
s.kind = SettingsKind::Brightness;
s.range_lo = 0.05f;
s.range_hi = 1.00f;
s.value = 0.30f;
s.pot.stored = (s.value - s.range_lo) / (s.range_hi - s.range_lo);
s.pot.caught = true;
s.color = {0xFF, 0xC0, 0x40};
if (hw_) ApplyBrightnessSlot(s);
return BrightnessHandle(this, &s, 0u, 0u);
}
/* ── Slot relocation (handles' At()) ─────────────────────────────────── */
SettingsSlot* Settings::RelocateSlot(uint8_t from_page, uint8_t from_pot,
uint8_t to_page, uint8_t to_pot)
{
if (from_page >= kSettingsMaxPages || from_pot >= kNumPots
|| to_page >= kSettingsMaxPages || to_pot >= kNumPots)
return nullptr;
if (from_page == to_page && from_pot == to_pot)
return &slots_[from_page][from_pot];
/* Relocating onto a configured slot would silently destroy it. */
assert(slots_[to_page][to_pot].kind == SettingsKind::None);
EnsurePage(to_page);
slots_[to_page][to_pot] = slots_[from_page][from_pot];
slots_[from_page][from_pot] = SettingsSlot{};
return &slots_[to_page][to_pot];
}
BrightnessHandle& BrightnessHandle::At(uint8_t page, uint8_t pot)
{
if (!owner_ || !s_) return *this;
SettingsSlot* moved = owner_->RelocateSlot(page_, pot_, page, pot);
if (!moved) return *this;
s_ = moved;
page_ = page;
pot_ = pot;
return *this;
}
/* UseLocks defaults: P5 sync / P6 exit, grey/amber zones, hostlink
* identity. */
namespace {
constexpr uint8_t kLockModeDefaultPot = 4u;
constexpr uint8_t kLockExitDefaultPot = 5u;
const LedPanel::Rgb kLockZoneColors[2] = {
{0x60, 0x60, 0x60}, /* zone 0 (default) — neutral grey */
{0xFF, 0x50, 0x00}, /* zone 1 — settings amber */
};
const char* const kLockModeLabels[2] = {"Free", "Clocked"};
const char* const kLockExitLabels[2] = {"Return", "Latch"};
/* Exit zones render Return-first; LockExit is Latch = 0. */
uint8_t ExitModeFromZone(uint8_t zone)
{
return static_cast<uint8_t>(zone == 0u ? LockExit::Return
: LockExit::Latch);
}
uint8_t ZoneFromExitMode(LockExit m)
{
return (m == LockExit::Return) ? 0u : 1u;
}
} // namespace
Settings::LocksHandle Settings::UseLocks(LockSource& locks)
{
/* Options the module cannot honor would be lies: call
* locks.UseClock(...) first, and Return needs the paged form. */
assert(locks.HasClock());
assert(locks.CanReturn());
EnsurePage(0u);
SettingsSlot& s = slots_[0][kLockModeDefaultPot];
assert(s.kind == SettingsKind::None); /* P5 already taken */
s = SettingsSlot{};
s.kind = SettingsKind::Selector;
s.num_zones = 2u;
s.value_idx = locks.SyncMode() ? 1u : 0u;
s.pot.stored = (static_cast<float>(s.value_idx) + 0.5f) / 2.0f;
s.pot.caught = true;
s.zone_colors = kLockZoneColors;
s.labels = kLockModeLabels;
s.num_labels = 2u;
s.ident = "lock_mode";
s.display_name = "Lock Mode";
s.help = stock_help::kLockMode;
s.lock_src = &locks;
locks.SetSyncMode(s.value_idx);
SettingsSlot& e = slots_[0][kLockExitDefaultPot];
assert(e.kind == SettingsKind::None); /* P6 already taken */
e = SettingsSlot{};
e.kind = SettingsKind::Selector;
e.num_zones = 2u;
e.value_idx = ZoneFromExitMode(LockExit::Return);
e.pot.stored = (static_cast<float>(e.value_idx) + 0.5f) / 2.0f;
e.pot.caught = true;
e.zone_colors = kLockZoneColors;
e.labels = kLockExitLabels;
e.num_labels = 2u;
e.ident = "lock_exit";
e.display_name = "Lock Exit";
e.help = stock_help::kLockExit;
e.lock_exit_src = &locks;
locks.SetExitMode(ExitModeFromZone(e.value_idx));
return LocksHandle(this, 0u, kLockModeDefaultPot,
0u, kLockExitDefaultPot);
}
/* ── LocksHandle ──────────────────────────────────────────────────── */
SettingsSlot* Settings::LocksHandle::Slot() const
{
if (!owner_ || page_ >= kSettingsMaxPages || pot_ >= kNumPots)
return nullptr;
SettingsSlot& s = owner_->slots_[page_][pot_];
return (s.lock_src != nullptr) ? &s : nullptr;
}
Settings::LocksHandle& Settings::LocksHandle::At(uint8_t page, uint8_t pot)
{
if (!Slot()) return *this;
if (owner_->RelocateSlot(page_, pot_, page, pot))
{
page_ = page;
pot_ = pot;
}
return *this;
}
Settings::LocksHandle& Settings::LocksHandle::Default(LockSync m)
{
SettingsSlot* s = Slot();
if (!s) return *this;
s->value_idx = (m == LockSync::Clocked) ? 1u : 0u;
s->pot.stored = (static_cast<float>(s->value_idx) + 0.5f) / 2.0f;
s->lock_src->SetSyncMode(s->value_idx);
return *this;
}
Settings::LocksHandle& Settings::LocksHandle::Colors(const LedPanel::Rgb* palette)
{
if (SettingsSlot* s = Slot()) s->zone_colors = palette;
return *this;
}
Settings::LocksHandle& Settings::LocksHandle::Ident(const char* id)
{
if (SettingsSlot* s = Slot()) s->ident = id;
return *this;
}
Settings::LocksHandle& Settings::LocksHandle::Name(const char* n)
{
if (SettingsSlot* s = Slot()) s->display_name = n;
return *this;
}
Settings::LocksHandle& Settings::LocksHandle::Help(const char* md)
{
if (SettingsSlot* s = Slot()) s->help = md;
return *this;
}
LockSync Settings::LocksHandle::Value() const
{
const SettingsSlot* s = Slot();
return (s && s->value_idx) ? LockSync::Clocked : LockSync::Free;
}
SettingsSlot* Settings::LocksHandle::ExitSlot() const
{
if (!owner_ || exit_page_ >= kSettingsMaxPages || exit_pot_ >= kNumPots)
return nullptr;
SettingsSlot& s = owner_->slots_[exit_page_][exit_pot_];
return (s.lock_exit_src != nullptr) ? &s : nullptr;
}
Settings::LocksHandle& Settings::LocksHandle::ExitAt(uint8_t page, uint8_t pot)
{
if (!ExitSlot()) return *this;
if (owner_->RelocateSlot(exit_page_, exit_pot_, page, pot))
{
exit_page_ = page;
exit_pot_ = pot;
}
return *this;
}
Settings::LocksHandle& Settings::LocksHandle::DefaultExit(LockExit m)
{
SettingsSlot* s = ExitSlot();
if (!s) return *this;
s->value_idx = ZoneFromExitMode(m);
s->pot.stored = (static_cast<float>(s->value_idx) + 0.5f) / 2.0f;
s->lock_exit_src->SetExitMode(static_cast<uint8_t>(m));
return *this;
}
LockExit Settings::LocksHandle::ExitValue() const
{
const SettingsSlot* s = ExitSlot();
return s ? static_cast<LockExit>(ExitModeFromZone(s->value_idx))
: LockExit::Latch;
}
PresetGestureUi& Settings::UsePresets(Presets& store)
{
EnsurePage(0u);
preset_gesture_.Bind(store);
uses_presets_ = true;
SettingsSlot& slot_pot = slots_[0][preset_slot_pot_];
slot_pot.kind = SettingsKind::PresetSlotPot;
slot_pot.preset_gesture = &preset_gesture_;
slot_pot.pot.stored = 0.0f;
slot_pot.pot.caught = true;
SettingsSlot& act_pot = slots_[0][preset_action_pot_];
act_pot.kind = SettingsKind::PresetActionPot;
act_pot.preset_gesture = &preset_gesture_;
act_pot.pot.stored = 0.5f;
act_pot.pot.caught = true;
return preset_gesture_;
}
/* ── Builders ─────────────────────────────────────────────────────── */
Settings::PageBuilder Settings::Page(uint8_t idx)
{
if (idx >= kSettingsMaxPages) idx = kSettingsMaxPages - 1;
EnsurePage(idx);
return PageBuilder(*this, idx);
}
BrightnessHandle Settings::PotBuilder::Brightness()
{
SettingsSlot& s = s_.slots_[page_][pot_];
s.kind = SettingsKind::Brightness;
s.range_lo = 0.05f;
s.range_hi = 1.00f;
s.value = 0.30f;
s.pot.stored = (s.value - s.range_lo) / (s.range_hi - s.range_lo);
s.pot.caught = true;
if (s_.hw_) s_.ApplyBrightnessSlot(s);
return BrightnessHandle(&s_, &s, page_, pot_);
}
KnobHandle Settings::PotBuilder::Knob()
{
SettingsSlot& s = s_.slots_[page_][pot_];
s.kind = SettingsKind::Knob;
s.value = 0.5f;
s.pot.stored = 0.5f;
s.pot.caught = true;
return KnobHandle(&s);
}
BipolarHandle Settings::PotBuilder::Bipolar()
{
SettingsSlot& s = s_.slots_[page_][pot_];
s.kind = SettingsKind::Bipolar;
s.value = 0.0f;
s.pot.stored = 0.5f;
s.pot.caught = true;
return BipolarHandle(&s);
}
SelectorHandle Settings::PotBuilder::Selector(uint8_t num_zones)
{
SettingsSlot& s = s_.slots_[page_][pot_];
s.kind = SettingsKind::Selector;
s.num_zones = (num_zones == 0u) ? 1u : num_zones;
s.value_idx = 0;
s.pot.stored = 0.5f / static_cast<float>(s.num_zones);
s.pot.caught = true;
return SelectorHandle(&s);
}
CustomHandle Settings::PotBuilder::Custom()
{
SettingsSlot& s = s_.slots_[page_][pot_];
s.kind = SettingsKind::Custom;
s.pot.stored = 0.5f;
s.pot.caught = true;
return CustomHandle(&s);
}
/* ── Internal helpers ─────────────────────────────────────────────── */
void Settings::EnsurePage(uint8_t page)
{
if (page >= kSettingsMaxPages) return;
if (page >= num_pages_) num_pages_ = static_cast<uint8_t>(page + 1u);
}
void Settings::ApplyBrightnessSlot(SettingsSlot& s)
{
if (!hw_) return;
/* Map [range_lo..range_hi] then push to the panel. */
const float span = s.range_hi - s.range_lo;
const float t = s.pot.stored;
const float v = s.range_lo + ((t < 0.0f) ? 0.0f : (t > 1.0f ? span : t * span));
s.value = v;
hw_->leds.SetBrightness(v);
}
void Settings::ArmPage(uint8_t page, const float* phys)
{
if (page >= num_pages_) return;
bool has_preset_action = false;
for (uint8_t p = 0; p < kNumPots; p++)
{
SettingsSlot& s = slots_[page][p];
if (s.kind == SettingsKind::None) continue;
if (s.kind == SettingsKind::PresetSlotPot
|| s.kind == SettingsKind::PresetActionPot)
{
/* Preset pots are gesture-driven; we always treat them as caught
* so the gesture sees the raw physical pot value immediately. */
s.pot.stored = phys[p];
s.pot.caught = true;
if (s.kind == SettingsKind::PresetActionPot) has_preset_action = true;
continue;
}
InitCatch(s.pot, phys[p]);
}
/* Disarm the preset gesture on entry: if the action pot is already
* parked at a save/load edge, require the user to bring it through
* neutral (noon) before save/load can re-arm. */
if (has_preset_action && uses_presets_) preset_gesture_.Disarm();
}
/* ── Mode-flag gesture (unchanged from legacy) ────────────────────── */
void Settings::TickModeGesture(uint32_t t_ms)
{
const bool b2_pressed = b2_->Pressed();
const bool b3_pressed = b3_->Pressed();
const bool b2_rising = b2_pressed && !prev_b2_pressed_;
const bool b3_rising = b3_pressed && !prev_b3_pressed_;
prev_b2_pressed_ = b2_pressed;
prev_b3_pressed_ = b3_pressed;
if (active_)
{
if (b2_rising || b3_rising)
{
active_ = false;
both_held_active_ = false;
}
return;
}
if (b2_pressed && b3_pressed)
{
if (!both_held_active_)
{
both_held_active_ = true;
both_held_since_ = t_ms;
}
else if ((t_ms - both_held_since_) >= hold_ms_)
{
active_ = true;
both_held_active_ = false;
}
}
else
{
both_held_active_ = false;
}
}
void Settings::PollButtons(uint32_t t_ms)
{
/* B2+B3 hold-to-enter / B2 or B3 release-to-exit. */
TickModeGesture(t_ms);
gesture_polled_ = true;
/* B1 short-press cycles settings pages while active. Track prev_b1
* unconditionally so we don't fire a spurious edge on (re)entry. */
if (!b1_) return;
const bool b1_pressed = b1_->Pressed();
const bool b1_falling = !b1_pressed && prev_b1_pressed_;
prev_b1_pressed_ = b1_pressed;
if (active_ && b1_falling && num_pages_ > 1u)
settings_advance_pending_ = true;
}
/* ── Update ───────────────────────────────────────────────────────── */
void Settings::Update(uint32_t t_ms) { Update(nullptr, t_ms); }
void Settings::Update(const float* phys, uint32_t t_ms)
{
/* Mode gesture and B1 page-nav edge detection are normally done by
* PollButtons at 1 ms cadence. If PollButtons hasn't fired since the
* last Update (legacy ctor users or hand-rolled loops not using
* ControlLoop), fall back to per-frame gesture detection here. */
if (!gesture_polled_) TickModeGesture(t_ms);
gesture_polled_ = false;
/* Active-state transitions are detected against prev_update_active_. */
const bool was_active = prev_update_active_;
prev_update_active_ = active_;
/* Compute dt for preset gesture timing. First tick after enter
* gets 0 to avoid a large impulse. */
const float dt_ms = (last_t_ms_ == 0u || t_ms < last_t_ms_)
? 0.0f
: static_cast<float>(t_ms - last_t_ms_);
last_t_ms_ = t_ms;
/* Flag-only mode (no hw_ → no controls to drive). */
if (!hw_ || !phys) return;
/* Lazy re-arm post-Deserialize — happens regardless of mode so a
* preset load that happens while we're in perf mode still re-arms
* the next time we enter settings (or now, if we're active). */
if (pending_rearm_)
{
pending_rearm_ = false;
for (uint8_t pg = 0; pg < num_pages_; pg++) ArmPage(pg, phys);
/* Brightness in particular needs to be pushed to the LED panel. */
for (uint8_t pg = 0; pg < num_pages_; pg++)
for (uint8_t p = 0; p < kNumPots; p++)
if (slots_[pg][p].kind == SettingsKind::Brightness)
ApplyBrightnessSlot(slots_[pg][p]);
}
/* Edge transitions. */
if (active_ && !was_active) ArmPage(page_, phys);
if (!active_ && was_active && perf_pager_)
perf_pager_->LockPage(perf_pager_->Page(), phys);
if (!active_) return;
/* Consume the B1 page-cycle flag set by PollButtons. */
if (settings_advance_pending_)
{
settings_advance_pending_ = false;
page_ = static_cast<uint8_t>((page_ + 1u) % num_pages_);
ArmPage(page_, phys);
}
/* Tick every slot on the active page. */
for (uint8_t p = 0; p < kNumPots; p++)
{
SettingsSlot& s = slots_[page_][p];
switch (s.kind)
{
case SettingsKind::None:
break;
case SettingsKind::Brightness:
UpdateCatch(s.pot, phys[p], t_ms);
ApplyBrightnessSlot(s);
break;
case SettingsKind::Knob:
UpdateCatch(s.pot, phys[p], t_ms);
s.value = s.pot.stored;
break;
case SettingsKind::Bipolar:
UpdateCatch(s.pot, phys[p], t_ms);
s.value = (s.pot.stored - 0.5f) * 2.0f;
break;
case SettingsKind::Selector:
{
UpdateCatch(s.pot, phys[p], t_ms);
s.value_idx = SelectorIndex(s.pot.stored, s.num_zones);
if (s.lock_src) s.lock_src->SetSyncMode(s.value_idx);
if (s.lock_exit_src)
s.lock_exit_src->SetExitMode(ExitModeFromZone(s.value_idx));
break;
}
case SettingsKind::PresetSlotPot:
case SettingsKind::PresetActionPot:
/* No catch — we feed the gesture the live phys value. */
s.pot.stored = phys[p];
s.pot.caught = true;
break;
case SettingsKind::Custom:
if (s.custom_tick) s.custom_tick(s, phys[p], t_ms);
break;
}
}
/* Drive the preset gesture if registered (always on page 0). */
if (uses_presets_ && page_ == 0u)
{
const float slot_v = phys[preset_slot_pot_];
const float action_v = phys[preset_action_pot_];
const bool committed = preset_gesture_.Update(slot_v, action_v, dt_ms);
if (committed)
{
/* Save: nothing else to do.
* Load: every managed Serializable just changed — set pending_rearm_
* so the next Update locks every page against fresh phys. */
pending_rearm_ = true;
}
}
}
/* ── Render ───────────────────────────────────────────────────────── */
void Settings::Render(uint32_t t_ms) const
{
if (!hw_) return;
const ArcGeometry& geo = hw_->Arc();
for (uint8_t p = 0; p < kNumPots; p++)
{
const SettingsSlot& s = slots_[page_][p];
switch (s.kind)
{
case SettingsKind::None:
break;
case SettingsKind::Brightness:
{
const float k = s.pot.caught ? 1.0f : 0.30f;
FillDesc desc;
desc.mode = FillMode::Edge;
desc.color = LedPanel::Scale(s.color, k);
DrawFill(hw_->leds, p, geo, s.pot.stored, desc, t_ms);
DrawCatchPip(hw_->leds, p, s.pot, geo, {0xFF, 0xFF, 0xFF});
break;
}
case SettingsKind::Knob:
{
const float k = s.pot.caught ? 1.0f : 0.30f;
FillDesc desc;
desc.mode = FillMode::Edge;
desc.color = LedPanel::Scale(s.color, k);
desc.anim = s.anim;
DrawFill(hw_->leds, p, geo, s.pot.stored, desc, t_ms);
DrawCatchPip(hw_->leds, p, s.pot, geo, {0xFF, 0xFF, 0xFF});
break;
}
case SettingsKind::Bipolar:
{
const float k = s.pot.caught ? 1.0f : 0.30f;
FillDesc desc;
desc.mode = FillMode::Center;
desc.color = LedPanel::Scale(s.color, k);
desc.passive_color = LedPanel::Scale(s.alt_color, k);
desc.center_color = LedPanel::Scale(s.center_color, k);
desc.pivot01 = 0.5f;
DrawFill(hw_->leds, p, geo, s.value, desc, t_ms);
DrawCatchPip(hw_->leds, p, s.pot, geo, {0xFF, 0xFF, 0xFF});
break;
}
case SettingsKind::Selector:
{
const float k = s.pot.caught ? 1.0f : 0.30f;
SelectorDesc desc;
desc.num_zones = s.num_zones;
desc.zone_geo = s.zone_geo;
desc.inactive_color = s.inactive_color;
desc.inactive_dim = s.inactive_dim;
if (s.zone_colors)
{
desc.active_color =
LedPanel::Scale(s.zone_colors[s.value_idx], k);
}
else
{
desc.active_color = LedPanel::Scale(s.color, k);
}
RingFrame frame;
frame.Begin(geo);
frame.BaseZone(desc, s.value_idx);
frame.Emit(hw_->leds, p);
DrawCatchPip(hw_->leds, p, s.pot, geo, {0xFF, 0xFF, 0xFF});
break;
}
case SettingsKind::PresetSlotPot:
case SettingsKind::PresetActionPot:
/* The gesture renders both pots together — handled once below. */
break;
case SettingsKind::Custom:
if (s.custom_render) s.custom_render(s, hw_->leds, p, geo, t_ms);
break;
}
}
/* Preset gesture is a two-pot draw; do it once per frame. */
if (uses_presets_ && page_ == 0u)
{
preset_gesture_.Render(hw_->leds, geo,
preset_slot_pot_, preset_action_pot_, t_ms);
}
/* Mode indicator on B1. */
hw_->leds.SetButtonPair(0, indicator_color_);
}
/* ── Serializable ─────────────────────────────────────────────────── */
namespace {
/* Persistent payload kind tag — used both for SchemaHash and to keep
* the serialised stream agnostic to physical pot positions. */
enum class PersistKind : uint8_t
{
None = 0,
Float, /* 4 bytes — Brightness, Knob, Bipolar via pot.stored */
Byte, /* 1 byte — Selector value_idx */
};
PersistKind PersistKindOf(SettingsKind k)
{
switch (k)
{
case SettingsKind::Brightness:
case SettingsKind::Knob:
case SettingsKind::Bipolar: return PersistKind::Float;
case SettingsKind::Selector: return PersistKind::Byte;
default: return PersistKind::None;
}
}
size_t PersistBytes(PersistKind pk)
{
switch (pk)
{
case PersistKind::Float: return sizeof(float);
case PersistKind::Byte: return 1u;
default: return 0u;
}
}
} // namespace
size_t Settings::SerializedSize() const
{
size_t total = 0;
for (uint8_t pg = 0; pg < num_pages_; pg++)
for (uint8_t p = 0; p < kNumPots; p++)
total += PersistBytes(PersistKindOf(slots_[pg][p].kind));
return total;
}
void Settings::Serialize(uint8_t* out) const
{
for (uint8_t pg = 0; pg < num_pages_; pg++)
{
for (uint8_t p = 0; p < kNumPots; p++)
{
const SettingsSlot& s = slots_[pg][p];
switch (PersistKindOf(s.kind))
{
case PersistKind::Float:
{
const float v = s.pot.stored;
std::memcpy(out, &v, sizeof(float));
out += sizeof(float);
break;
}
case PersistKind::Byte:
out[0] = s.value_idx;
out += 1u;
break;
default:
break;
}
}
}
}
bool Settings::Deserialize(const uint8_t* in)
{
for (uint8_t pg = 0; pg < num_pages_; pg++)
{
for (uint8_t p = 0; p < kNumPots; p++)
{
SettingsSlot& s = slots_[pg][p];
switch (PersistKindOf(s.kind))
{
case PersistKind::Float:
{
float v;
std::memcpy(&v, in, sizeof(float));
in += sizeof(float);
/* Blob passed CRC + schema gates, but a live host
* push can still carry any bytes: NaN-safe clamp to
* the pot-normalized range everything downstream
* assumes. */
if (!(v >= 0.0f)) v = 0.0f;
if (v > 1.0f) v = 1.0f;
s.pot.stored = v;
/* Canonicalize the resolved value NOW. Update only
* re-derives it while the slot's page is active in
* the menu; consumers polling Value() must see the
* loaded state immediately. */
switch (s.kind)
{
case SettingsKind::Bipolar:
s.value = (v - 0.5f) * 2.0f;
break;
case SettingsKind::Brightness:
s.value = s.range_lo + v * (s.range_hi - s.range_lo);
break;
default: /* Knob */
s.value = v;
break;
}
break;
}
case PersistKind::Byte:
{
uint8_t idx = in[0];
in += 1u;
if (s.num_zones > 0u && idx >= s.num_zones)
idx = static_cast<uint8_t>(s.num_zones - 1u);
s.value_idx = idx;
/* Canonicalize pot.stored to the zone centre. The
* active-page tick derives value_idx FROM pot.stored,
* so leaving it stale reverts the loaded selection
* the moment the page is viewed in the menu. */
if (s.num_zones > 0u)
s.pot.stored = (static_cast<float>(idx) + 0.5f)
/ static_cast<float>(s.num_zones);
if (s.lock_src) s.lock_src->SetSyncMode(s.value_idx);
if (s.lock_exit_src)
s.lock_exit_src->SetExitMode(
ExitModeFromZone(s.value_idx));
break;
}
default:
break;
}
}
}
pending_rearm_ = true;
return true;
}
uint32_t Settings::SchemaHash() const
{
/* Mix in shape: page count, per-(page, pot) kind tag. Stable across
* builds when the declarative layout is unchanged. */
uint32_t h = kSettingsSchemaTag
^ (static_cast<uint32_t>(num_pages_) << 8);
for (uint8_t pg = 0; pg < num_pages_; pg++)
{
for (uint8_t p = 0; p < kNumPots; p++)
{
const uint32_t mix = (static_cast<uint32_t>(slots_[pg][p].kind) << 16)
^ (static_cast<uint32_t>(pg) << 8)
^ static_cast<uint32_t>(p);
h = (h * 0x01000193u) ^ mix; /* FNV-ish mix */
}
}
return h;
}
/* ── Introspection (HostLink descriptor generation) ────────────────── */
SettingsKind Settings::KindAt(uint8_t page, uint8_t pot) const
{
if (page >= num_pages_ || pot >= kNumPots) return SettingsKind::None;
return slots_[page][pot].kind;
}
uint8_t Settings::ZonesAt(uint8_t page, uint8_t pot) const
{
if (page >= num_pages_ || pot >= kNumPots) return 0u;
return slots_[page][pot].num_zones;
}
float Settings::StoredNormAt(uint8_t page, uint8_t pot) const
{
if (page >= num_pages_ || pot >= kNumPots) return 0.0f;
return slots_[page][pot].pot.stored;
}
uint8_t Settings::SelectorIdxAt(uint8_t page, uint8_t pot) const
{
if (page >= num_pages_ || pot >= kNumPots) return 0u;
return slots_[page][pot].value_idx;
}
float Settings::RangeLoAt(uint8_t page, uint8_t pot) const
{
if (page >= num_pages_ || pot >= kNumPots) return 0.0f;
return slots_[page][pot].range_lo;
}
float Settings::RangeHiAt(uint8_t page, uint8_t pot) const
{
if (page >= num_pages_ || pot >= kNumPots) return 1.0f;
return slots_[page][pot].range_hi;
}
size_t Settings::PersistedBytesFor(SettingsKind k)
{
/* Delegates to the same tables Serialize() uses (file-local above). */
return PersistBytes(PersistKindOf(k));
}
} // namespace alchemy