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
+2 -2
View File
@@ -1321,8 +1321,8 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's).
that is genuinely DRIVING re-sends and so keeps outranking the restore, while a lane that sent
one point and had it folded does not. That is the correct reading of the rule "a lane in
read/write mode outranks a plug-in-side set" (stated in `docs/product/parameter-automation.md`
§7 as reasoning from the host's replay behaviour, not from a header — the SDK does not spell it
out). The second pass's unbounded latch made the claim true by making every later writer
§6.1 as reasoning from the host's replay behaviour, not from a header — the SDK does not spell
it out). The second pass's unbounded latch made the claim true by making every later writer
permanently deaf; `shell/instrument/CLAUDE.md`'s Authority section is the model now.
(c) **STILL OPEN, and it is the half the bundled `[verify, FIRST]` originally asked**: that
REAPER calls `setState` (not `setComponentState`) on a single-component plug-in — a state
+6
View File
@@ -335,6 +335,12 @@ invariant Θ-W1-T1 was run to establish.
> outranks anything the plugin sets, because the host replays it. That is inherent to
> automation and is not a defect to design away — but it has one sharp consequence for the
> resample bake, and that is §9.
>
> **Superseded by the paragraph immediately below.** "Outranks anything the plugin sets" reads
> as unbounded; the bounded formulation there — outranks only until the model has caught up,
> never a later restore/reset/knob move — is the correct one and the one `shell/instrument/
> CLAUDE.md`'s Authority section and `core/instrument/param/param_merge` implement. An unbounded
> hold was tried and is the specific defect this history keeps.
**SETTLED at the track, from the vendored SDK.** The delivery question the `[verify]` here
bundled is answered by the headers rather than by the DAW: `setParamNormalized` is documented as
+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
+16 -11
View File
@@ -126,14 +126,18 @@ the single authority.** Everything else that holds these values is a cache or a
| Host controller write (`setParamNormalized`) | the call | the call returns (it writes the model) |
| State restore (`setState`) | the call | the call returns |
| Bake reset (`adoptBakedCapture`) | the call | the call returns |
| Reload seed (`reloadInstrument`) | under `reloadMutex_` | the publish (it re-folds the model) |
| Limiter toggle (`setLimiterEnabled`) | the call | the call returns (it writes the model too, but through neither `commitLive` nor `commitAndReload`) |
| Reload seed (`reloadInstrument`) | never — it does not write `params_` | it only republishes a live block folded from whatever the model already holds |
| **Host automation point** (`IParameterChanges`) | the block it lands in | **the UI thread has folded it into the model and republished** |
Every writer except the last writes the model directly, so for those "authority ends" is just
"the write happened". The automation lane is the only one that cannot: the SDK delivers it on the
audio thread, where the model path allocates (`resolvePlay` copies velocity curves and spline
contours). So it patches the engine-facing block in place and is couriered to the UI thread,
which folds it into the model on the next tick.
Every writer above except the last two writes the model directly, so for those "authority ends"
is just "the write happened". Reload seed is not itself a model write — `reloadInstrument` never
touches `params_`; the only write in the tree is `setInstrumentParams`'s, `processor_state.cpp:192`
— which is why its row states no authority window of its own. The automation lane is the only one
that cannot write directly: the SDK delivers it on the audio thread, where the model path
allocates (`resolvePlay` copies velocity curves and spline contours). So it patches the
engine-facing block in place and is couriered to the UI thread, which folds it into the model on
the next tick.
**The hold is the bridge across that gap, and nothing more.** Between the point landing and the
fold — at most one UI tick — the model does not yet carry the value, so a model republish in that
@@ -171,14 +175,15 @@ under it: the automation fold, the host's generic panel, a state restore.
different windows: `AutomationChannel::land` drops a repeat of a standing hold whole (the flat
read-mode segment, where a host sends one point per block), and the merge publishes only when the
merged block differs from the last (a model republish that changed nothing). Neither is measured
against a performance budget — they are there because `VoiceEngine::refreshLive` runs
against a performance budget — they are there because `VoiceEngine::applyLiveToActive` runs
`voice.applyLive` over every active voice, and neither case needs it.
- **The automation values fold back into the model on the UI thread** (`drainAutomationToModel`,
called from `getState`, the editor's sync tick, and the bake's reload tail). The blob is
authoritative, so a value that never came back would be lost on save. The fold is suppressed
from notifying the host — the values came FROM it, and echoing them would let a lane in write
mode re-record its own playback.
called from `getState`, the editor's sync tick, and `instrument_bake.cpp:125` — at the HEAD of
the bake chain, before the render, not its reload tail). The blob is authoritative, so a value
that never came back would be lost on save. The fold is suppressed from notifying the host —
the values came FROM it, and echoing them would let a lane in write mode re-record its own
playback.
- **`IMidiMapping` is deliberately NOT implemented** — no conventional CC names most of what
is exposed, an invented map would hijack CCs the user's controller already sends, and
`[verify — DAW]` REAPER's own per-parameter MIDI learn is expected to cover the case without
+10 -4
View File
@@ -2,6 +2,9 @@
// AUTHORITY LIFETIME is mechanised: a point outranks the model from the block it lands in until
// the UI thread has folded it back into the model AND republished. This directory's CLAUDE.md
// states the model; `core/instrument/param/param_merge` is the pure decision this feeds.
// The release itself is observed the NEXT BLOCK, not the instant it happens — refreshReleases()
// only runs inside process()'s merge branch (reasampler_processor.cpp), and publishLiveParams
// always bumps the generation that branch checks, so the next block is guaranteed to take it.
#pragma once
@@ -44,10 +47,13 @@ public:
return ridesTheBlock;
}
// Refreshes each held slot's release answer. Must run BEFORE the model block is read: the
// acquire here synchronizes with the UI thread's release store, which it makes only AFTER
// republishing the model — so a slot seen released is one whose value any block read after
// this point is guaranteed to already carry.
// Refreshes each held slot's release answer. Runs only inside process()'s merge branch, so a
// release lands the NEXT BLOCK after the UI thread makes it, never the same instant — benign,
// because publishLiveParams always bumps the generation that branch checks, so the next block
// is guaranteed to run this. Must run BEFORE the model block is read: the acquire here
// synchronizes with the UI thread's release store, which it makes only AFTER republishing the
// model — so a slot seen released is one whose value any block read after this point is
// guaranteed to already carry.
void refreshReleases() {
for (std::size_t i = 0; i < kDeckParamSlots; ++i) {
if (!slots_[i].held) continue;
+6 -6
View File
@@ -211,10 +211,11 @@ void ReaSamplerEditor::commitAndReload() {
// self-contained for that sample.
if (!processor_) return;
processor_->setSelectedSampleId(selectedId_);
processor_->setInstrumentParams(params_);
// This copy IS the model now, so adopt the generation it produced rather than re-seeding off
// it on the next tick. Same reason at commitLive.
seenParamsGeneration_ = processor_->instrumentParamsGeneration();
// This copy IS the model now, so adopt the generation IT produced (the return, not a second
// separate query — a write landing between the two would make this adopt a generation newer
// than the copy just sent) rather than re-seeding off it on the next tick. Same reason at
// commitLive.
seenParamsGeneration_ = processor_->setInstrumentParams(params_);
processor_->reloadInstrument();
// The reload may have auto-defaulted the channel mode (implicit only) — re-read so the
// toggle draws what the engine actually decoded with.
@@ -227,8 +228,7 @@ void ReaSamplerEditor::commitAndReload() {
void ReaSamplerEditor::commitLive() {
// UI thread only. See the declaration for why this still writes the parameter set.
if (!processor_) return;
processor_->setInstrumentParams(params_);
seenParamsGeneration_ = processor_->instrumentParamsGeneration();
seenParamsGeneration_ = processor_->setInstrumentParams(params_);
processor_->publishLiveParams();
}
+20 -4
View File
@@ -6,6 +6,8 @@
#include "shell/instrument/reasampler_processor.h"
#include <cassert>
#include "base/source/fstring.h"
#include "pluginterfaces/base/ustring.h"
#include "pluginterfaces/vst/ivstparameterchanges.h" // IParameterChanges / IParamValueQueue
@@ -291,16 +293,23 @@ void ReaSamplerProcessor::drainAutomationToModel() {
++foldedCount;
// Master gain's model IS the atomic the audio thread already wrote; there is nothing to
// fold, only the controller cache to refresh below.
if (row.deck != DeckParam::kMasterGain) {
writeDeckParamToModel(params, row.deck, value);
moved = true;
}
if (row.deck == DeckParam::kMasterGain) continue;
// A present-but-static lane resends the SAME point every tick; writing it back would
// republish liveParams_ and move paramsGeneration_ — a full model copy, a controller
// cache write and an editor repaint, every tick, forever, for a value that never moved.
// The release below still fires: the model already carries the point either way.
if (value == modelParamNormalized(params, row.deck)) continue;
writeDeckParamToModel(params, row.deck, value);
moved = true;
}
// Suppressed for the whole fold: these values CAME from the host, and echoing them back
// through performEdit would let a lane in write mode re-record its own playback. The
// controller cache is still refreshed, so the host's display and the editor follow.
const bool wasSuppressed = paramNotifySuppressed_;
paramNotifySuppressed_ = true;
// Captured before the publish below, so the assert at the release loop can tell "the model's
// publish already landed" from "it merely happened to be in flight for some other reason".
const std::uint32_t generationBeforeFold = liveParams_.generation();
if (moved) {
setInstrumentParams(params);
publishLiveParams();
@@ -311,6 +320,13 @@ void ReaSamplerProcessor::drainAutomationToModel() {
// model carries the point. Released any earlier and the audio thread could drop the hold
// ahead of the block that carries its value; never released at all — the defect this
// replaces — and one point would defeat every later restore, reset and knob move.
// Enforced, not just commented: two prior passes inverted this order and every test in the
// tree still passed, because nothing exercises `process()`. `publishLiveParams` is a no-op
// before the engine has a sample rate (`builtSampleRate_`), which is the one case this assert
// must not fire for.
assert((!moved || builtSampleRate_.load(std::memory_order_relaxed) <= 0 ||
liveParams_.generation() != generationBeforeFold) &&
"release ran ahead of the publish that gives it authority");
for (std::size_t i = 0; i < foldedCount; ++i) {
automation_.release(foldedSlots[i], foldedSeqs[i]);
}
+7 -3
View File
@@ -179,11 +179,12 @@ InstrumentParams ReaSamplerProcessor::instrumentParams(std::uint32_t& generation
return params_;
}
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
std::uint32_t ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
// The exposed values alone, not the whole set: this funnel fires per mouse move on every live
// knob and node drag, and InstrumentParams owns seven vectors — copying all of them to diff
// 44 doubles is the cost, and the diff is what the notification actually needs.
double before[kDeckParamSlots];
std::uint32_t generation;
{
std::lock_guard<std::mutex> lock(paramsMutex_);
for (const instrument::param::ParamRow& row : instrument::param::exposedParams()) {
@@ -191,8 +192,10 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
}
params_ = params;
// Bumped inside the lock with the write it names, so a reader taking the pair together
// can never see a generation that does not describe the set beside it.
++paramsGeneration_;
// can never see a generation that does not describe the set beside it. Read back before
// the unlock for the same reason — a caller wanting ITS OWN write's generation must not
// race a second writer's bump between this function's unlock and its return.
generation = ++paramsGeneration_;
}
// Every writer of the parameter set — setState, the editor's commits, the bake's adopt —
// funnels through here, so mirroring the limiter flag at this one point is what keeps the
@@ -214,6 +217,7 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
// limiter mirror sits here: an internal write that skipped it would leave the host
// displaying — and, on the next touch, re-imposing — the superseded value.
notifyParamsFromModel(before, params);
return generation;
}
void ReaSamplerProcessor::flushLatencyRestart() {
+4 -4
View File
@@ -190,7 +190,7 @@ public:
// never read on the audio thread — reloadInstrument bakes it into the SampleData
// off-thread.
InstrumentParams instrumentParams();
void setInstrumentParams(const InstrumentParams& params);
std::uint32_t setInstrumentParams(const InstrumentParams& params); // adopt THIS generation, never a second instrumentParamsGeneration() call
// The model's edit counter, bumped by every setInstrumentParams. A holder of a COPY (the
// editor's snapshot) re-seeds when this moves under it; the overload answers both under one
@@ -392,9 +392,9 @@ private:
// and republishes ONLY when the RESULT moved — so neither an unchanged model nor a lane
// resending the value it already sent reaches the per-voice fan-out. A block carrying no
// automation and no model change costs one relaxed load plus, when the host passed a non-null
// IParameterChanges (REAPER's normal case), one cross-module getParameterCount(). Two blocks
// because the seqlock's single-writer contract is load-bearing and the two writers differ in
// thread; this directory's CLAUDE.md owns the argument.
// IParameterChanges (`[verify — DAW]` REAPER's normal case), one cross-module
// getParameterCount(). Two blocks because the seqlock's single-writer contract is load-bearing
// and the two writers differ in thread; this directory's CLAUDE.md owns the argument.
instrument::engine::LiveParams automationLive_;
// Audio thread only. The last liveParams_ generation merged, the last block published (the
// republish gate compares against it), and the automation slots themselves.
+63
View File
@@ -91,6 +91,68 @@ static void testADrawnEnvelopePinsTheFoldedTriggerLength() {
CHECK(v.lengthFraction == 1.0);
}
// Poisons the block ONE LEAF FIELD AT A TIME and checks operator== catches every one — the half
// that actually catches a forgotten field, since the static_assert above only fires when a
// member changes sizeof(LiveValues), which padding can absorb. Covers every leaf of every
// nested struct, not just the 14 top-level members, so a member dropped from sameAdsr/sameAhd/
// sameFilterSettings is caught here too, not just a member dropped from operator== itself.
static void testEveryFieldOfLiveValuesIsCompared() {
using instrument::engine::filter::MorphLaw;
const LiveValues base{};
auto poisoned = [&](auto mutate) {
LiveValues v = base;
mutate(v);
return v;
};
CHECK(base == base);
CHECK(poisoned([](LiveValues& v) { v.filterSettings.cutoffNorm += 0.1f; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterSettings.resonanceNorm += 0.1f; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterSettings.morphNorm += 0.1f; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterSettings.driveNorm += 0.1f; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterSettings.morphLaw = MorphLaw::HighNotchLow; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterModAmount += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterVelAmount += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterKeyTrack += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterEnv.attackFrames += 1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterEnv.holdFrames += 1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterEnv.decayFrames += 1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterEnv.sustainLevel += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterEnv.releaseFrames += 1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterEnv.attackCurve += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterEnv.decayCurve += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterEnv.releaseCurve += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterAhd.attackFrames += 1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterAhd.decayFrames += 1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterAhd.holdFraction += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterAhd.attackCurve += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.filterAhd.decayCurve += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.adsr.attackFrames += 1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.adsr.holdFrames += 1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.adsr.decayFrames += 1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.adsr.sustainLevel += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.adsr.releaseFrames += 1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.adsr.attackCurve += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.adsr.decayCurve += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.adsr.releaseCurve += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.ampAhd.attackFrames += 1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.ampAhd.decayFrames += 1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.ampAhd.holdFraction += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.ampAhd.attackCurve += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.ampAhd.decayCurve += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.pitchEnv.enabled = !v.pitchEnv.enabled; }) != base);
CHECK(poisoned([](LiveValues& v) { v.pitchEnv.peakSemitones += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.attackFrames += 1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.decayFrames += 1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.holdFraction += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.attackCurve += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.decayCurve += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.playRate += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.pitchOffsetSemitones += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.keyTrack += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.lengthFraction += 0.1; }) != base);
CHECK(poisoned([](LiveValues& v) { v.splineActive = !v.splineActive; }) != base);
}
static void testUnpublishedBlockReadsAsNothing() {
LiveParams block;
LiveValues out;
@@ -188,6 +250,7 @@ static void testRampStepIsRateDerived() {
int main() {
testFoldCarriesEveryContinuousControl();
testADrawnEnvelopePinsTheFoldedTriggerLength();
testEveryFieldOfLiveValuesIsCompared();
testUnpublishedBlockReadsAsNothing();
testConcurrentReaderNeverSeesAHalfAppliedEdit();
testRampTerminatesExactlyOnTheTarget();
+6 -7
View File
@@ -126,11 +126,10 @@ static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() {
}
}
// The one place the two surfaces GENUINELY diverge, asserted so it stays a known property rather
// than a surprise: an exponent inside curve_law's centre detent but not exactly neutral is
// reachable only through an overlay knot drag, and the host — which holds the norm and nothing
// else — reads it back as the neutral the knob law snaps to.
static void testAnOffDetentExponentReadsNeutralToTheHostAndTrueToTheEditor() {
// A continuous range stays continuous at the host boundary (Daniel, 2026-08-02): the knob
// detent is a drag affordance only, so an exponent inside it but not exactly neutral — reachable
// via an overlay knot drag — must read back true to the host, the same digits the editor shows.
static void testAnOffDetentExponentReadsTrueToBothTheHostAndTheEditor() {
PlaySeconds play;
// The detent is +/-0.01 in NORM, which is a ~+/-0.047 band in the exponent — so 1.04 is
// inside it and still prints as a distinct number.
@@ -144,7 +143,7 @@ static void testAnOffDetentExponentReadsNeutralToTheHostAndTrueToTheEditor() {
char hostBuf[24];
formatPlainFor(DeckParam::kAttackCurve, toPlain(DeckParam::kAttackCurve, hostNorm), hostBuf,
sizeof(hostBuf));
CHECK(std::string(hostBuf) == "1.00");
CHECK(std::string(hostBuf) == "1.04");
}
static void testKeyTrackPrintsTheSameDigitsFromEitherSurface() {
@@ -227,7 +226,7 @@ static void testEveryExposedParameterHasAFormatterThatWritesSomething() {
int main() {
testEachCategoryPrintsItsSpecifiedShape();
testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue();
testAnOffDetentExponentReadsNeutralToTheHostAndTrueToTheEditor();
testAnOffDetentExponentReadsTrueToBothTheHostAndTheEditor();
testMasterGainPrintsTheSameDigitsFromEitherSurface();
testKeyTrackPrintsTheSameDigitsFromEitherSurface();
testTypingBackADisplayedValueLandsOnIt();