Loading files
b51161896This 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