Close pass four: undetented host curve read, LiveValues guard, ordering assert, static-lane fix, docs

Points toPlain's exponent arm at the undetented curve map so host reads
match the editor; adds a sizeof guard plus field-poison test for
LiveValues::operator==; skips the model write when an automation value
hasn't moved; corrects five stale doc citations.
This commit is contained in:
2026-08-02 18:22:21 -04:00
parent 1fd38bbd57
commit 9b5393098b
14 changed files with 163 additions and 59 deletions
+7
View File
@@ -70,6 +70,13 @@ struct LiveValues {
// The seqlock copies the block as raw bytes, which is only defensible for a plain value type.
static_assert(std::is_trivially_copyable_v<LiveValues>,
"the live block is copied under a seqlock — it must stay a plain value");
// Guards operator== against silent staleness: a member added to the struct above changes this
// size, so the assert fails at the new member's own commit instead of leaving a live control
// that never reaches a sounding voice with no compiler or test signal. Confirmed 352 bytes,
// MSVC 19.44 x64, Release (`SizeProbe<sizeof(LiveValues)>`, an incomplete-template size probe
// whose error message reports the value). Bump the literal AND operator== together.
static_assert(sizeof(LiveValues) == 352,
"a member was added or removed — extend operator== in live_params.cpp to match");
// FIELD-wise equality, and it must never be "simplified" into a memcmp. LiveValues carries
// padding, and nothing gives that padding a determinate value across a copy: NRVO is optional
+9 -14
View File
@@ -88,21 +88,16 @@ no longer exist.
- **Round-trip exactness at arbitrary values is NOT a property here and must not be asserted.**
No log map satisfies `toNormalized(toPlain(n)) == n` in double, and demanding it would rule
out the taper the range needs. Exactness is required at the defaults; monotonicity everywhere.
- **A curve exponent inside the knob detent but not exactly neutral READS BACK as `1.00` on the
host, while the stored value keeps its true exponent.** The detent lives in `curve_law`'s
norm↔exponent map, and `toPlain` is that map — so an off-detent near-neutral exponent (an
overlay knot drag can set one) displays as `1.00` in the host. The editor's own label reads the
stored field and shows the true value.
**The WRITE path does NOT have this loss, and that is deliberate.** A host write goes through
`hostStoredFromNorm`, which skips the detent: the detent is a DRAG affordance — a drag grid
- **Both the host's read (`toPlain`) and write (`hostStoredFromNorm`) paths for a curve exponent
skip `curve_law`'s knob detent, and that is deliberate** (Daniel, 2026-08-02: continuous ranges
stay continuous at the host boundary). The detent is a DRAG affordance only — a drag grid
delivers `start - dy/128` and lands on the identity only by luck, so a band wider than one drag
step snaps to it — and a lane has no grid. `curveFromKnobNorm` already answers exactly `1.0` at
norm `0.5`, so skipping the detent costs nothing in reachability from the host, and applying it
would flatten a knot-drawn exponent to `1.0` on any lane pass. `test_param_live`'s
`testTheHostSkipsTheCurveDetentAndNothingElse` pins both halves: the host map is the editor's
everywhere else, and differs exactly inside the band.
**What remains is the DISPLAY divergence above**, which this module already documents as
structural and which no change to the frozen `toPlain`/`toNormalized` pair was made to chase.
step snaps to it — and a host lane has no grid. `curveFromKnobNorm` already answers exactly
`1.0` at norm `0.5`, so skipping the detent costs nothing in reachability from the host. The
dial-drag path (`ui::storedFromNorm`/its snap) is the one place the detented map still applies,
because that is where the snap earns its place. `test_param_live`'s
`testTheHostSkipsTheCurveDetentAndNothingElse` pins the write half; `test_param_format`'s
`testAnOffDetentExponentReadsTrueToBothTheHostAndTheEditor` pins the read half.
- **Master gain's plain value at norm 0 is `-inf`**, which is outside the declared −60…+24 range
on purpose — norm 0 is true silence, not the floor. The formatter prints `-inf` there. The
editor additionally SUPPRESSES its unit suffix at that one value (`editor_controls`, the
+4 -3
View File
@@ -40,9 +40,10 @@ void mergeAutomation(engine::LiveValues& block, AutomationSlot* slots, std::size
// Whether a point of `normalized` for a slot in this state actually moves the block. False for a
// point equal to a hold that is still standing — the ordinary read-mode steady state, where a
// host delivers one point per block over a flat lane segment. Republishing there would drive
// `VoiceEngine::refreshLive` over every sounding voice — a `std::pow`, two envelope φ re-fits and
// the filter ramp aims, per voice — for a value that did not move. Once the hold has been
// RELEASED the answer is true again, because some other writer may have moved the model since.
// `VoiceEngine::applyLiveToActive` over every sounding voice — a `std::pow`, two envelope φ
// re-fits and the filter ramp aims, per voice — for a value that did not move. Once the hold has
// been RELEASED the answer is true again, because some other writer may have moved the model
// since.
inline bool automationPointMoves(const AutomationSlot& slot, double normalized) {
return !slot.held || slot.norm != normalized;
}
+3 -1
View File
@@ -191,7 +191,9 @@ double toPlain(DeckParam deck, double normalized) {
return ui::rateRatioFromNorm(normalized, ui::kRateMinRatio, ui::kRateMaxRatio) *
kPercentFullScale;
case UnitKind::Dimensionless:
return util::curveFromKnobNorm(normalized);
// Undetented: a host-facing continuous range stays continuous (Daniel, 2026-08-02) —
// the detent is a drag affordance, not part of the value law. See hostStoredFromNorm.
return util::curveFromKnobNormUndetented(normalized);
case UnitKind::Decibels:
case UnitKind::Hertz:
break; // handled above