diff --git a/docs/PLAN.md b/docs/PLAN.md index b2d9ade..0eab6b8 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -50,13 +50,20 @@ restart expensive *here* is self-inflicted (`setActive(true)` calls `reloadInstr the cost is ours to reduce and the reduction is filed in `docs/TODO.md` rather than designed around. -**Γ-F3 was subsequently REVERSED and a seventh fork opened, both by Daniel's later rulings -of 2026-08-01.** Γ-F3 (*"the stage-time ceiling stays 2.0 s"*) is replaced by *"extend the -stage lengths to 10s"* — the ceiling moves in Γ-W1-T1. And **Γ-F7 is OPEN**: the VST3 -parameter *order* (signal flow, or the editor's visual rows), a forever commitment that -cannot be closed by proposal at review. It blocks nothing until **Γ-W4 dispatches**, which is -the last track of the last wave of the phase. **Exactly one track in this plan carries an -unanswered [Daniel]-class question: Γ-W4-T1, and it is Γ-F7.** +**Γ-F3 was subsequently REVERSED and a seventh fork opened AND CLOSED, all by Daniel's later +rulings of 2026-08-01.** Γ-F3 (*"the stage-time ceiling stays 2.0 s"*) is replaced by *"extend +the stage lengths to 10s"* — the ceiling moves in Γ-W1-T1. **Γ-F7** (the VST3 parameter +*order*) is **RULED: signal flow** — *"signal flow order."* **There is now NO unanswered +[Daniel]-class question anywhere in this plan.** + +**Ruling 3 (Daniel, 2026-08-01) — real units at the host boundary.** *"The parameter values +exposed to the VST host should be in real units, such that the host automation lanes report +usable values."* Satisfied through VST3's **plain-value layer**, not its wire format (which is +normalized and cannot be otherwise): `toPlain`/`toNormalized`, `getParamStringByValue` and +`ParameterInfo::units`. Specified at **`docs/product/parameter-automation.md` §6.7** — the +per-category unit/precision table, the one-formatter invariant, the `stepCount` sweep, and the +resolution of the apparent conflict with the filter's re-taper prohibition. It lands entirely +in Γ-W4-T1 and changes no wave boundary. ### Flagged for awareness — not blocking, but decision-grade @@ -107,7 +114,7 @@ unanswered [Daniel]-class question: Γ-W4-T1, and it is Γ-F7.** ## Phase-wide acceptance criteria These bind every track in all three phases and are stated once here rather than repeated -per track. **Phase Γ adds five of its own**, stated in its phase header. +per track. **Phase Γ adds a set of its own**, stated in its phase header. ### Structural (root `CLAUDE.md`, Daniel 2026-07-28) @@ -534,7 +541,7 @@ system's is in **`docs/product/parameter-automation.md` §§6–10**. Read §1.2 table) and §7 (collisions) before dispatching any track here — every number in this phase is derived there, and `docs/TODO.md`'s old deck-rework geometry is superseded. -**Fork state — six ruled, ONE OF THEM LATER REVERSED, and one open.** Indexed at spec §8, +**Fork state — SEVEN ruled, ONE OF THEM LATER REVERSED, NONE OPEN.** Indexed at spec §8, folded into the tracks below: - **Γ-F1** — `kEditorMinHeight` stays **680**. - **Γ-F2** — the limiter has **lookahead with DYNAMIC reported latency** (zero when off, @@ -554,24 +561,31 @@ folded into the tracks below: deactivate/reactivate the SDK mandates is accepted: *"the limiter will either be on or off on its instance, toggling during playback is not a use case."* No constant-latency fallback, no measurement gate. *This ruling also corrected the analysis* — spec §3.1.1. -- **Γ-F7 — OPEN, and it is the only unanswered [Daniel] question in this plan.** The VST3 - parameter **order**: signal-flow order (the deck's own documented rule, layout-independent) - or the editor's visual row order after the reflow. Same membership, different sequence, - frozen forever the day parameters ship. **Recommendation: signal flow**, because the visual - layout has already moved twice and this phase moves it again. Reasoning and both arguments: - `docs/product/parameter-automation.md` §6.4. **Blocks nothing until Γ-W4 dispatches**; it - cannot be closed by proposal at review. +- **Γ-F7 — RULED: SIGNAL FLOW.** Daniel, 2026-08-01: *"signal flow order."* The VST3 parameter + order — both the frozen id numbering and the `getParameterInfo` presentation index — is + **PITCH/RATE → PITCH ENV → FILTER → FILTER ENV → AMP → VELOCITY → VOICE → MASTER**, the + deck's own `sampleDeckGroups` rule, with each group's cells in the semantic order the id + table freezes. The editor's visual rows after the reflow were the rejected alternative. + **The reason, because a future reader will ask why the id order does not match the screen:** + the editor's layout has already moved twice (Θ-W6-T1 grew the floor 840 → 980; Γ-W3-T1 takes + it to 1190 and re-rows every group) and within-row order is settled by width fitting, not by + meaning — so **binding a permanently-frozen id order to a demonstrably mobile layout + guarantees the two drift apart**, after which the order is neither logical nor matching. + Signal flow is the axis that does not move. Full argument and the accepted residual cost: + `docs/product/parameter-automation.md` §6.4; **the resulting 44-id table is stated at §6.2.** **Ruling 1 (Daniel, 2026-08-01) — VST3 parameter reporting ships in this phase.** Verbatim intent: *"correct the phase gamma plan to account for complying with the VST3 standard for parameter reporting… by the end of gamma we have the automatable params reported. Make the parameter order logical."* `docs/product/parameter-automation.md` was written as scoping and has been **promoted in place**: §§1–5 are the original analysis, **§§6–10 are the -specification** Γ-W4-T1 is built from. Three things it decides that the scoping pass left +specification** Γ-W4-T1 is built from. Four things it decides that the scoping pass left open: the blob stays authoritative and parameters are a third surface onto the one model -(§6.1); the id space is an independent, hand-assigned, FOREVER-FROZEN table (§6.2, §6.3); and -the exposed list is **derived from the three-state commit predicate**, never hand-maintained -(§7). Today the plugin has **zero** parameters — `ReaSamplerProcessor::initialize` +(§6.1); the id space is an independent, hand-assigned, FOREVER-FROZEN table, **now stated in +full as 44 numbered rows in signal-flow order** (§6.2, §6.3); the exposed list is **derived +from the three-state commit predicate**, never hand-maintained (§7); and, under Ruling 3, +every parameter's **plain unit, range and display precision** (§6.7). Today the plugin has +**zero** parameters — `ReaSamplerProcessor::initialize` (`reasampler_processor.cpp:56-73`) never populates `SingleComponentEffect::parameters`, so `getParameterCount()` returns the SDK default 0. @@ -700,21 +714,51 @@ exact interim layout; do not "fix" it in a track that does not own it. load-bearing in the v12 wire format. The new Pitch knob **reads** it; it does not mint a second ±24 constant. **From Γ-W4-T1 it is also a frozen host normalization** — one more reason, not a new rule. -- **The taper has exactly ONE home and three consumers.** Γ-W1-T1 extracts it into a pure - module; the knob's needle (`deck_values`), the AHDSR overlay's schematic axis - (`envelope_overlay` + `envelope_edit`), and the host's `normalizedParamToPlain` (Γ-W4-T1) - all call the same function. **Three functions that agree today is a defect, not an - implementation choice** — the failure it prevents is a host automation lane that means one - value and a needle that draws another. +- **The taper has exactly ONE home and three consumers, and from Γ-W4-T1 the taper IS the + host's `toPlain`/`toNormalized`.** Γ-W1-T1 extracts it into a pure module; the knob's needle + (`deck_values`), the AHDSR overlay's schematic axis (`envelope_overlay` + `envelope_edit`), + and the host's `normalizedParamToPlain` / `plainParamToNormalized` (Γ-W4-T1) all call the + same function. **Three functions that agree today is a defect, not an implementation + choice** — the failure it prevents is a host automation lane that means one value and a + needle that draws another. Under Ruling 3 this stops being an analogy: `toNormalized` is + not *like* the taper, it *is* the taper (`docs/product/parameter-automation.md` §6.7.3). +- **ONE formatter per unit category, and the editor and the host are both its callers.** The + formatter is pure and returns the **digits** of a plain value in that category's single + `units` string — no embedded unit, no magnitude-switched unit, no width-conditional + abbreviation, no caller-side branch. The editor's knob label and + `getParamStringByValue` read the same function. **The editor and the host printing + different text for the same stored value is a defect class, forbidden structurally rather + than caught at review** — same discipline, same reason, as the taper criterion above. + Consequences (existing formatters stop embedding their unit; cutoff's `k` abbreviation is + retired; the curve dial's `^` is static cell chrome, not value): spec §6.7.2. - **Every default value has an EXACT normalized preimage under its own taper.** Binds Γ-W1-T1 (which designs the taper) and Γ-W4-T1 (which declares `ParameterInfo::defaultNormalizedValue`). `resetDeckParam` bypasses the taper; **a host's reset-to-default cannot**, so exactness in the map itself is the only thing that makes the - editor's reset and the host's reset land on the same value. + editor's reset and the host's reset land on the same value. **Ruling 3 tightens this twice + and adds one non-requirement** (§6.7.7): `defaultNormalizedValue` is **computed** as + `toNormalized(default)`, never written as a normalized literal; the assertion is made on + `toPlain(defaultNormalizedValue)`, the pair the host actually calls; and + `toNormalized(toPlain(n)) == n` at **arbitrary** n is explicitly NOT required — no log map + satisfies it in double, and demanding it would over-constrain the taper for nothing. - **Nothing in this phase may re-map the filter's four normalized controls.** Cutoff, Q, morph and drive persist as `*Norm` doubles in payload v9 — their laws are already wire-frozen, and re-tapering them would re-tune every saved project independently of - automation. The snap-unit table names them; that is display, not law. + automation. The snap-unit table names them; that is display, not law. **This does NOT + conflict with Ruling 3's real-unit requirement**, and the two must not be read as a + collision: `toPlain` is a pure read-side mapping that never touches the stored value, so + reporting Hz / Q / drive depth means **calling** `filterCutoffHzFromNorm` and its peers, not + replacing them — which the editor's own labels already do today. The prohibition forbids + *editing* those laws; the requirement is satisfied by *calling* them. One additive gap: + drive has no published inverse and `filterNormFromDriveDepth` must be added beside the two + that exist — the analytic inverse of a frozen law is not a change to it. Spec §6.7.5. +- **Shift-snap is a drag rule; `stepCount` is a parameter property; they are independent.** + The editor's snap grid must never be exposed as `ParameterInfo::stepCount` — that would + quantize the parameter itself, permanently and for the host's automation too, freezing the + grid into the forever contract and putting continuous cents out of reach from a lane. + **All 44 exposed parameters ship `stepCount = 0`**, swept and confirmed, and the coincidence + is structural: every discrete control is reload or rebuild tier and therefore omitted by the + predicate. Spec §6.7.6. - **From Γ-W4-T1, the parameter-id table is FOREVER-FROZEN**, on the same footing as the extension's `"STABLE_FOREVER_STRING"` command ids, the two VST3 class UIDs, and the params-payload field order. No id is reassigned, reused or re-pointed; no exposed @@ -1835,10 +1879,12 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's). **cross-artifact contract** the extension's `instrument_drop` writes, which parameters-as-truth would silently make partial. - **`ParamID` is an independent, hand-assigned, FOREVER-FROZEN table** — blocks of 100 per - deck group, steps of 10 within a block, a curve dial at its outer knob's id + 1, blocks - starting at 1000. §6.2 for the layout and why hand-assignment beats derivation; **§6.3 for - the freeze invariant, which is to be stated in the table's header with the same force as - the command-id strings, the class UIDs and the payload field order.** + deck group **in signal-flow order** (Γ-F7), steps of 10 within a block, a curve dial at its + outer knob's id + 1, blocks starting at 1000. **The full 44-id assignment is stated at + §6.2** and is to be transcribed, not re-derived. §6.2 also for why hand-assignment beats + derivation and why the within-block order is seeded ONCE rather than tracked against + `cellIds`; **§6.3 for the freeze invariant, which is to be stated in the table's header with + the same force as the command-id strings, the class UIDs and the payload field order.** - **The exposed set is DERIVED from `isLiveDeckParam` / `liveCommitFor`, never hand-maintained** — a control is a parameter iff its class is `Live` or `NoteOnLatched`. **44 parameters** at the end of Γ-W3, enumerated by group in §7.1. @@ -1847,18 +1893,41 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's). states why omission beats `kIsReadOnly`, and names the limitation plainly — the user cannot automate filter on/off, play mode, the pitch engine, Staged↔Spline or polyphony, and the unlock is to give the control a live path first. +- **Every exposed parameter reports REAL UNITS to the host** (Ruling 3). Each declares a + **plain range**, a **`units` string**, and a **display precision**; the complete + eight-category table covering all 44 — ranges, units, precision, and which taper each + category carries — is `docs/product/parameter-automation.md` §6.7.1, and it is a + specification, not a suggestion. VST3's wire format stays normalized (it cannot be + otherwise); the requirement is met through the **plain-value layer** the SDK provides, whose + direct precedent in the vendored tree is `public.sdk/samples/vst/common/logscale.h:221-229` + overriding `toPlain`/`toNormalized` for a log law — the exact shape our log ms and log2 + semitone knobs need. - **`normalizedParamToPlain` / `plainParamToNormalized` / `getParamStringByValue` / - `getParamValueByString` route through W1-T1's taper module and the editor's own - formatters.** Three functions that agree today is a defect; the host's normalization, the - needle angle and the overlay node must be the same function. + `getParamValueByString` route through W1-T1's taper module and the ONE formatter per unit + category.** Three functions that agree today is a defect; the host's normalization, the + needle angle and the overlay node must be the same function. **`toNormalized` IS the + taper** — §6.7.3 — which is what makes the phase's one-way-door ordering structurally true + rather than a warning someone has to remember. +- **`stepCount = 0` on all 44, and the editor's shift-snap is never exposed as `stepCount`.** + Snapped drag and parameter continuity are independent axes; `stepCount` quantizes the + parameter permanently, including for the host's automation, and freezes into the forever + contract. The sweep is done and clean (§6.7.6) — every discrete control is reload or rebuild + tier and therefore already omitted, so continuity is structural rather than lucky. - **`IParameterChanges` is observed at BLOCK boundaries, stated in the header** — the last point in a block wins. Sample-accurate application would put a per-sample "did anything change" question on the per-voice-per-sample path, which the phase-wide guardrail forbids. - **A host parameter change takes the control's existing commit tier and no other.** Nothing on the automation path may reach `reloadInstrument` or `rebuildVoiceEngine` — which §7.2's omissions guarantee structurally rather than by care. -- **`IUnitInfo`: one unit per deck group.** Order per **Γ-F7** (§6.4) — **recommendation - signal flow; Daniel's call, and it must be closed before this track dispatches.** +- **`IUnitInfo`: one unit per deck group**, mirroring the group inventory rather than the + editor's rows. **Order is signal flow — Γ-F7, RULED** (§6.4). Presentation index order is + ascending id, so identity and presentation agree by construction. +- **The filter's four report plain units WITHOUT being re-tapered.** `toPlain` is read-side + only; reporting Hz / Q / drive depth means calling `filterCutoffHzFromNorm`, + `filterQFromNorm` and `filterDriveDepthFromNorm` — the filter module's own frozen laws, + which `deckValueLabel` already calls today — not restating them. **The one additive piece: + `filterNormFromDriveDepth` does not exist and must be added in `filter_params`**, beside the + two inverses that do; the analytic inverse of a frozen law is not a change to it. §6.7.5. - **No `kIsBypass` on anything.** The plugin is an instrument and exposes no bypass parameter; the limiter is a safety device, not a bypass, and binding it there would hand the host a control that restarts the component. @@ -1879,12 +1948,24 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's). not against a literal count. - **Every id in the table is asserted unique, in its group's block, and on its step** — and a test fails if any id changes value, which is what makes the freeze mechanical rather than - cultural. -- **Every parameter round-trips `plain → normalized → plain` exactly at its default**, so a - host's reset-to-default and the editor's double-click land on the same value. If this fails, - it is a W1-T1 defect surfacing here, not a defect of this track. -- **`getParamStringByValue` prints what the editor prints**, unit for unit, at the same - values. + cultural. **The asserted values are §6.2's table verbatim**, including the signal-flow block + sequence; a test that recomputes the ids from `cellIds` would defeat the freeze it exists + to hold. +- **`toPlain(info.defaultNormalizedValue)` compares EXACTLY equal to the default**, per + parameter, against a default-constructed `PlaySeconds` (and against `master_gain`'s unity), + so a host's reset-to-default and the editor's double-click land on the same value. + **`defaultNormalizedValue` is computed as `toNormalized(default)`, not written as a + literal** — a grep finds no normalized default constant. If the exactness fails, it is a + W1-T1 defect surfacing here, not a defect of this track. **Round-trip exactness at + arbitrary values is NOT asserted** — it is not required (§6.7.7) and asserting it would + over-constrain the taper. +- **`getParamStringByValue` prints what the editor's knob label prints**, digit for digit, at + the same stored value, across every unit category — asserted by calling **the same + formatter** from both sides in one test, not by comparing two independently produced + strings. **A grep finds exactly one formatter per unit category** and no `snprintf` of a + parameter value outside it. +- **Every exposed parameter's `units` and plain range match §6.7.1**, asserted per parameter; + `stepCount` is asserted **zero on all 44**. - **A host automation lane moving a Live parameter moves a sounding note; a lane moving a NoteOnLatched parameter takes effect on the next note and does NOT trigger a reload or an engine rebuild** — assert the tier, not just the sound. @@ -1900,23 +1981,42 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's). - **The double-processing limitation is documented, not discovered** — a bake whose reset-class parameter carries a host lane is a named boundary of the bake's fidelity claim (§9), stated in the product doc and in this track's review. +- **The lane-linearity consequence is stated in the header, not left to be found** — under a + tapered parameter a straight line drawn in a host automation lane is **not** linear in the + plain unit (exponential in ms, linear in octaves on cutoff, linear in dB on master gain). + This is standard and desirable, it follows directly from Ruling 3 plus the taper, and §6.7.4 + gives it per category. Writing it down is the acceptance criterion; changing the taper to + avoid it is not an option. +- **The filter's four are proven untouched**: a regression baseline shows their audio + unchanged, and their persisted `*Norm` values are byte-identical across a save/reload that + passes through the parameter surface. Reporting Hz/Q/depth changed display only. **Open questions.** -- **[Daniel] — Γ-F7, the parameter order.** Signal flow (recommended) or the editor's visual - rows. **This is the only unanswered [Daniel]-class question in this plan.** It blocks - nothing until this track dispatches, and it cannot be closed by proposal at review: a - forever commitment is Daniel's. §6.4. +- **No [Daniel] questions. Γ-F7 is RULED — signal flow** (2026-08-01, *"signal flow order."*), + and Ruling 3 (real units) arrived specified rather than forked. **There is no unanswered + [Daniel]-class question in this track or anywhere in this plan.** - **[verify, FIRST]** that REAPER calls `setState` (not `setComponentState`) on a single-component plug-in, and the ordering of `setState` against the first `IParameterChanges` block after a project load. §6.1 is built on the SDK's own name-collapse; **verify it in the DAW before wiring, and do not build on the paragraph alone.** +- **[verify]** whether REAPER renders `ParameterInfo::units` beside the string + `getParamStringByValue` returns, or shows the string alone. **We ship the SDK's own + convention** — digits in the string, unit carried separately, which is what + `RangeParameter::toString` and the `Parameter` constructor's signature both express. If + REAPER shows no unit at all, the fallback is to append the unit **inside the one formatter**: + a one-line change in one place, touching neither the frozen id table nor the editor, because + display strings are explicitly not frozen (§6.7.1). Do not discover this after shipping. - **[propose at review]** promoting **key-track** and **Trigger length** from `Reload` to `NoteOnLatched` (§7.4). Both are excluded from the live set *for the note-on-latch reason* in the predicate's own words, so the promotion aligns routing with documented semantics — and it is what makes them automatable at all. **If either promotion is refused, that control simply drops out of the parameter list.** The list follows the predicate; the - predicate is never bent to fill the list. + predicate is never bent to fill the list. **Consequence for the frozen table:** a refusal + drops ids 1000 and 1260 (key-track) or 1450 (Trigger length) and the count falls below 44. + Those slots are then simply **never issued** — not retired, since nothing shipped under + them — and remain available to the same control if it is promoted later. No other id moves; + that is what the block-and-step scheme buys. - **[propose at review]** whether to ship a default `IMidiMapping` CC table here or leave MIDI control to REAPER's host-side learn. Either is defensible; **skipping it silently is not.** - **[propose at review]** whether this track spends the reserved payload rung. §6.1 says diff --git a/docs/product/instrument-control-surface.md b/docs/product/instrument-control-surface.md index 3b206a4..b406e8d 100644 --- a/docs/product/instrument-control-surface.md +++ b/docs/product/instrument-control-surface.md @@ -60,8 +60,12 @@ own width formula, not carried over from a prior measurement. The stale geometry - **VST3 automation parameters SHIP IN THIS PHASE, as its last track** (Ruling 1, Daniel 2026-08-01). The specification is `docs/product/parameter-automation.md` §§6–10: 44 exposed parameters derived from the three-state commit predicate, a hand-assigned - FOREVER-FROZEN id table in blocks of 100 with steps of 10, and the blob left - authoritative with parameters as a third surface onto the one model. + FOREVER-FROZEN id table in blocks of 100 with steps of 10 **in signal-flow order** + (Γ-F7, ruled), and the blob left authoritative with parameters as a third surface onto the + one model. **They report REAL UNITS to the host** (Ruling 3) through VST3's plain-value + layer — a plain range, a unit string and a stated display precision per parameter, formatted + by the same pure function the knob label uses. §6.7 there; §8.4 here for what it binds in + this doc. - **The stage-time ceiling moves 2 s → 10 s, in wave 1** (Daniel, reversing Γ-F3), *because* parameters now ship in-phase — a range endpoint is host-facing normalization, free to move now and permanently expensive afterwards. Its real cost is not the constant @@ -783,6 +787,13 @@ model"* — a snap available on one and not the others would be a divergence. | **decibels** | master gain | whole **dB** | | **already integer** | voice count, preview velocity | no change | +> **This table is a DRAG rule and must never become `ParameterInfo::stepCount`** (Ruling 3, +> §8.4). `stepCount` quantizes the parameter itself — permanently, and for the host's +> automation too — which would put continuous cents out of reach from a lane and freeze the +> grid into the forever contract. The two axes are already visibly independent here: filter +> cutoff *displays* in Hz, *snaps* in whole percent of its normalized position, and is +> *automated* continuously. All 44 exposed parameters ship `stepCount = 0`. + ### 4.3 The tapers **Both taper changes are SAFE for persistence and require no format bump.** The @@ -1352,7 +1363,7 @@ shape is deliberate and its reasoning is documented at the call site. --- -## 8. Forks — six ruled (one later reversed), one open +## 8. Forks — SEVEN ruled (one later reversed), NONE open ### 8.1 Ruled by Daniel, 2026-08-01 @@ -1369,6 +1380,7 @@ ceiling. | **Γ-F4** | Explicit loop enable? | **Yes — on the CHROME ROW.** Not a deck cell; loop is a waveform-overlay concept and has no deck. | **§6.4** (new), §6.5, §7.9 | | **Γ-F5** | MASTER's reserved slot: one cell or two? | **One cell.** Two would spend 60 of the 90 px headroom on an unnamed control and freeze row 1 forever. | **§1.6** (new), §1.4 | | **Γ-F6** | Is the `kLatencyChanged` deactivate/reactivate acceptable as the cost of the toggle? | **Yes — ship dynamic latency as ruled.** No constant-latency fallback, no measurement gate. *Corrected this doc's analysis: the cost is self-inflicted, not SDK-imposed.* | **§3.1.1** (rewritten), §7.10, §7.11, `docs/TODO.md` | +| **Γ-F7** | VST3 parameter ORDER: signal flow, or the editor's visual rows? | **Signal flow** — *"signal flow order."* The frozen id numbering and the presentation index both follow the deck's own rule; the visual layout is too mobile to freeze against. | **§8.3**; `parameter-automation.md` §6.4 (argument) and §6.2 (the 44-id table) | Three of these corrected this doc rather than confirming it, and all three corrections are worth remembering as pattern: @@ -1425,27 +1437,43 @@ than just counting: `restartComponent`), not about this flag. 5. **The reduction is filed**, with a trigger condition, in `docs/TODO.md`. -### 8.3 Γ-F7 — OPEN. The parameter order +### 8.3 Γ-F7 — RULED: signal flow. The parameter order **Opened 2026-08-01 by Ruling 1** (*"Make the parameter order logical"*), because "logical" -resolves two ways and the choice is frozen forever the day parameters ship. +resolved two ways and the choice freezes forever the day parameters ship. **Closed the same +day by Daniel: *"signal flow order."*** -> **Signal-flow order** — PITCH/RATE → PITCH ENV → FILTER → FILTER ENV → AMP → VELOCITY → -> VOICE → MASTER, the deck's own documented ordering rule, layout-independent. -> **OR the editor's visual row order** after the Γ-W3 reflow — row 1 then row 2 then MASTER, -> matching what the user's eye scans. +> **PITCH/RATE → PITCH ENV → FILTER → FILTER ENV → AMP → VELOCITY → VOICE → MASTER**, the +> deck's own documented ordering rule, layout-independent. Both the frozen id numbering and +> the `getParameterInfo` presentation index follow it; each group's cells take the semantic +> order the id table freezes. -Same membership, different sequence; the recommendation, both arguments, and why the -grouping (`IUnitInfo`, one unit per deck group) is settled either way are in -`docs/product/parameter-automation.md` §6.4. **Recommendation: signal flow**, because the -visual layout has moved twice already and this phase moves it again, and freezing a forever -identity to a thing that moves is the wrong coupling. +**The rejected alternative** was the editor's visual row order after the Γ-W3 reflow — row 1, +then row 2, then MASTER, matching what the user's eye scans. **The reason it lost, recorded +because a reader will ask why the numbering does not match the screen:** the editor's layout +has already moved twice and this phase moves it again, and within-row order is settled by +width fitting rather than by meaning — so binding a permanently-frozen id order to a +demonstrably mobile layout guarantees the two drift apart, after which the order is neither +logical nor matching. Signal flow is the axis that does not move. -**Urgency: low, but not zero.** Three waves sit in front of Γ-W4-T1 and nothing before it -depends on the answer. It must close **before Γ-W4 dispatches**, and it cannot be closed by -proposal at review — a forever commitment is a Daniel call. +The full argument, the accepted residual cost (a user scanning REAPER's flat list), and the +resulting 44-id table are in `docs/product/parameter-automation.md` §6.4 and §6.2. -*Every other fork in this phase is ruled. Nothing in §§1–7 awaits a Daniel answer.* +*Every fork in this phase is now ruled, and nothing in this doc awaits a Daniel answer.* + +### 8.4 Ruling 3 — real units at the host boundary + +**Daniel, 2026-08-01:** *"The parameter values exposed to the VST host should be in real +units, such that the host automation lanes report usable values."* Not a fork — it arrived as +a requirement and is specified rather than decided. It lands entirely in Γ-W4-T1 and moves no +wave boundary. + +It reaches back into this doc in exactly one place: **§4's interaction law and §4.2's +snap-unit table are DRAG rules and must never become `ParameterInfo::stepCount`.** Snapped +drag and parameter continuity are independent axes — this doc's own table already proves it, +since filter cutoff displays in Hz, snaps in whole percent of normalized position, and is +automated continuously. Full spec, including the unit/precision table, the one-formatter +invariant and the `stepCount` sweep: `docs/product/parameter-automation.md` §6.7. --- diff --git a/docs/product/parameter-automation.md b/docs/product/parameter-automation.md index 1b8a1f8..6bbaedb 100644 --- a/docs/product/parameter-automation.md +++ b/docs/product/parameter-automation.md @@ -14,11 +14,12 @@ original analysis (still correct, still the reasoning), and **§§6–10 are the — the ID scheme and its freeze invariant, the per-control classification, the one-way-door sweep, the resample interaction, and the build shape. -Two things that were open in the scoping pass are now **decided** rather than deferred: -the storage architecture (§3.3, decided in §6.1) and the ID space (§3.1, decided in §6.2). -One thing is **open and wants Daniel** before Γ-W4 dispatches: **Γ-F7**, the parameter -*order* (§6.4). It is not urgent — three waves sit in front of it — but it is a forever -commitment, so it is not mine to close silently. +**Nothing in this doc is open to Daniel any longer.** The storage architecture (§3.3, decided +in §6.1), the ID space (§3.1, decided in §6.2) and **Γ-F7, the parameter order — RULED +signal flow, 2026-08-01** (§6.4) are all settled. **Ruling 3 (real units at the host +boundary) is specified at §6.7**, which also carries the one-formatter invariant, the +`stepCount` sweep, and the resolution of the apparent conflict between "report real units" +and "do not re-taper the filter." --- @@ -92,6 +93,10 @@ Today, re-tapering is **free** — normalization exists only in `ui/deck_values. display/interaction layer, and the payload stores raw engine values as doubles. That is a property worth spending while we still have it. See §4. +**Ruling 3 makes this literal rather than analogous:** `toPlain`/`toNormalized` are not merely +*like* our taper, they **are** it — one extracted pure module read by the knob, the overlay +and the host alike. §6.7.3. + ### 3.3 Two sources of truth VST3 saves parameter values *and* calls `setState`/`setComponentState`. A value that lives @@ -262,7 +267,9 @@ other constant with that property. ## 5. Open questions — status after the promotion -The scoping pass collected eight. Seven are closed below; one is open and is a Daniel call. +The scoping pass collected eight, and Γ-F7 was opened beside them. **All nine are closed**; +one (migration) survives as a verification rather than a question. Ruling 3 added a tenth +line, closed on arrival. | # | Question | Status | |---|---|---| @@ -274,7 +281,8 @@ The scoping pass collected eight. Seven are closed below; one is open and is a D | 6 | Sample-accurate vs. block-boundary (§3.7) | **CLOSED — block boundary, stated explicitly.** §6.5 | | 7 | Does the extension's `instrument_drop` need to know? | **CLOSED — no.** It was the sharpest cost of option (a), and (a) is not taken. The blob a drop writes still fully determines the sound. §6.1 | | 8 | Migration | **CLOSED as a design question, OPEN as a verification.** §6.6 | -| **Γ-F7** | **Parameter ORDER — signal flow, or the editor's visual rows?** | **OPEN — Daniel.** Recommendation and reasoning at §6.4. Must close before Γ-W4 dispatches; blocks nothing before then. | +| **Γ-F7** | **Parameter ORDER — signal flow, or the editor's visual rows?** | **CLOSED — SIGNAL FLOW** (Daniel, 2026-08-01: *"signal flow order."*). The block table needed no renumbering; visual order would have. §6.4 | +| **Ruling 3** | **Real units at the host boundary** | **CLOSED on arrival** (Daniel, 2026-08-01). Satisfied through the plain-value layer, not the wire format. Unit/precision table, the one-formatter invariant, the `stepCount` sweep, and the filter resolution: §6.7 | --- @@ -373,6 +381,62 @@ accident, because renumbering it means editing the numbers. blocks exist so that if either ever gains an automatable control it lands in its own group's range rather than in whatever range happened to be free. +**The block sequence above IS signal-flow order, and that is now the ruled order (§6.4).** +The table was authored in the deck's own `sampleDeckGroups` sequence, which today happens to +be both the signal flow and the pre-reflow visual order; Γ-W3-T1's reflow is what separates +them. Because Daniel ruled signal flow, **no block moves** — had the ruling gone to visual +rows, every block from 1100 up would have been renumbered before it was ever frozen. + +**Within a block, the order is the group's own semantic order, seeded ONCE at the freeze and +never re-seeded.** Γ-F7's reasoning applies recursively: a group's `cellIds` order is exactly +as mobile as the row order is, and a future width fit can reorder cells inside a group as +easily as the reflow reorders groups. So the within-block sequence is a property of *this +table*, not a tracked function of `cellIds`. It is seeded from the cell order as it stands at +Γ-W3, which is semantic in every group today — envelope stages in temporal order, filter +cells in solve order — and that is what makes the seeding safe rather than arbitrary. + +#### The table, stated + +44 parameters. Curve dials take their outer knob's id + 1 (§6.2 above); a `+1` row is always +the inner dial of the row above it. + +| ID | Parameter | `units` | ID | Parameter | `units` | +|---|---|---|---|---|---| +| **1000** | Key-track (pitch) | `%` | **1300** | Filter env attack | `ms` | +| **1010** | Rate | `%` | 1301 | └ attack curve | — | +| **1020** | Pitch offset | `st` | **1310** | Filter env hold | `ms` | +| | | | **1320** | Filter env decay | `ms` | +| **1100** | Pitch env attack | `ms` | 1321 | └ decay curve | — | +| 1101 | └ attack curve | — | **1330** | Filter env sustain | `%` | +| **1110** | Pitch env hold | `%` | **1340** | Filter env release | `ms` | +| **1120** | Pitch env decay | `ms` | 1341 | └ release curve | — | +| 1121 | └ decay curve | — | **1350** | Filter trig attack | `ms` | +| **1130** | Pitch env depth | `st` | 1351 | └ attack curve | — | +| | | | **1360** | Filter trig hold | `%` | +| **1200** | Filter morph | `%` | **1370** | Filter trig decay | `ms` | +| **1210** | Filter cutoff | `Hz` | 1371 | └ decay curve | — | +| **1220** | Filter Q | — | | | | +| **1230** | Filter drive | — | **1400** | Amp attack | `ms` | +| **1240** | Filter mod amount | `%` | 1401 | └ attack curve | — | +| **1250** | Filter velocity amount | `%` | **1410** | Amp hold | `ms` | +| **1260** | Key-track (filter) | `%` | **1420** | Amp decay | `ms` | +| | | | 1421 | └ decay curve | — | +| **1700** | Master gain | `dB` | **1430** | Amp sustain | `%` | +| | | | **1440** | Amp release | `ms` | +| *1500–1599* | *VELOCITY — reserved, empty* | | 1441 | └ release curve | — | +| *1600–1699* | *VOICE — reserved, empty* | | **1450** | Trigger length | `%` | +| | | | **1460** | Amp trig attack | `ms` | +| | | | 1461 | └ attack curve | — | +| | | | **1470** | Amp trig hold | `%` | +| | | | **1480** | Amp trig decay | `ms` | +| | | | 1481 | └ decay curve | — | + +**Presentation index order is ascending id** — 1000, 1010, 1020, 1100, 1101, 1110, … 1700. +Identity order and presentation order therefore agree by construction, which is §6.4's +requirement rather than a coincidence to be maintained. **The count is asserted against the +predicate, never against this table** (§7); the table is what the predicate's members are +*numbered* as, not what decides membership. + ### 6.3 The freeze invariant State this in the ID table's header, with the same force as its three siblings. @@ -404,7 +468,7 @@ State this in the ID table's header, with the same force as its three siblings. table that stays true — the reflow, a future regrouping, a new deck can all reorder the enum freely. Deriving ids from it would have quietly repealed that sentence. -### 6.4 Order and grouping — Γ-F7, OPEN +### 6.4 Order and grouping — Γ-F7, CLOSED: signal flow Two orders exist and they are not the same thing: @@ -415,32 +479,46 @@ Two orders exist and they are not the same thing: They should agree (a list whose numbering runs backwards against its display is a trap), so this is one decision, and it is a forever one on both counts. -**Recommendation: signal-flow order — PITCH/RATE → PITCH ENV → FILTER → FILTER ENV → AMP → -VELOCITY → VOICE → MASTER**, which is the deck's own documented ordering rule -(`sampleDeckGroups`, *"left to right, in SIGNAL-FLOW order: pitch → filter → amp, then the -two instance-wide groups"*), with each group's cells in their own left-to-right order. +> **RULED (Daniel, 2026-08-01): signal-flow order.** *"signal flow order."* -**The alternative is the editor's visual rows** after Γ-W3-T1 — row 1 PITCH/RATE, FILTER, -VELOCITY, VOICE; row 2 PITCH ENV, FILTER ENV, AMP; MASTER spanning. Same membership, -different sequence. +**The order is PITCH/RATE → PITCH ENV → FILTER → FILTER ENV → AMP → VELOCITY → VOICE → +MASTER**, which is the deck's own documented ordering rule (`sampleDeckGroups`, *"left to +right, in SIGNAL-FLOW order: pitch → filter → amp, then the two instance-wide groups"*), with +each group's cells in the semantic order §6.2's table freezes. Identity order and +presentation order are the same sequence; the block table in §6.2 stands unchanged, because +it was already authored in this order. -**Why the recommendation goes to signal flow:** the visual layout is a canvas-constrained -artifact that has already moved twice (Θ-W6-T1 grew the floor 840 → 980, Γ-W3-T1 takes it to -1190 and re-rows every group), and within-row order is decided by *width fitting*, not by -meaning. Freezing the host-facing order to a layout that demonstrably moves is the wrong -coupling; signal flow has never moved and is the deck's own stated rule. §3.6 asked whether -the unit tree mirrors the visual grouping or the signal flow and noted "they currently -agree" — the reflow is exactly what makes them stop agreeing, so the question has to be -answered rather than deferred again. +**The rejected alternative was the editor's visual rows** after Γ-W3-T1 — row 1 PITCH/RATE, +FILTER, VELOCITY, VOICE; row 2 PITCH ENV, FILTER ENV, AMP; MASTER spanning. Same membership, +different sequence. Do not re-propose it: the id table is frozen from Γ-W4-T1, so re-ordering +is no longer a preference question at all. -**Why it is nonetheless Daniel's:** the counter-argument is real — a user hunting a -parameter in REAPER's flat list finds it fastest if the list reads in the order their eyes -scan the plugin window. If Daniel weights find-it-on-screen over layout-independence, visual -order is the right answer and it is not a mistake; it just also means the parameter order -stops being free the next time the deck is re-laid. +**Why signal flow — the argument a future reader will come looking for.** The obvious +objection to this table is that its numbering does not match what is on screen, and someone +will eventually propose "fixing" that. The answer is that the two *cannot* both be satisfied +forever, and only one of the two axes holds still: -**Grouping is settled either way: `IUnitInfo`, one unit per deck group**, mirroring the -group inventory (not the rows). Only the sequence is at issue. +> **The editor's visual layout has already moved twice** — Θ-W6-T1 grew the window floor +> 840 → 980, and Γ-W3-T1 takes it to 1190 and re-rows every group into two categorical rows +> with a double-height MASTER. Within-row order is decided by *width fitting*, not by meaning. +> **Binding a permanently-frozen id order to a demonstrably mobile layout guarantees the two +> drift apart** — and after the first drift the order is neither logical *nor* matching, which +> is strictly worse than either. **Signal flow is the axis that does not move.** It is also +> the deck's own stated rule, so the order is not a new invention that has to be remembered; +> it is the one the codebase already documents. + +The counter-argument, recorded because it was real: a user hunting a parameter in REAPER's +flat list finds it fastest if the list reads in the order their eyes scan the plugin window. +That is answered — partly — by `IUnitInfo` grouping, which lets a host present the parameters +under their group names rather than as one flat run. It is not fully answered, and that +residual cost is accepted knowingly. + +§3.6 asked whether the unit tree mirrors the visual grouping or the signal flow and noted +"they currently agree"; the reflow is what makes them stop agreeing, and this ruling is the +answer. + +**Grouping: `IUnitInfo`, one unit per deck group**, mirroring the group inventory (not the +rows) — unchanged by the ruling, which decided only the sequence. ### 6.5 What the plugin must implement, as behaviour @@ -448,21 +526,21 @@ Not architecture — the engineer's call is *how*. These are the observable cont - **`getParameterCount` / `getParameterInfo`** report exactly the §7 list, in §6.4's order, each with: the frozen `id`; a `title` that survives truncation and a distinct - `shortTitle`; `units` (`"ms"`, `"st"`, `"%"`, `"dB"`, `""` for exponents) matching what the - editor prints, so the host's readout and the knob's label never disagree; `stepCount = 0` - (every exposed parameter is continuous — §7.2 is why); `defaultNormalizedValue` per §8's - exactness rule; the group's `unitId`; and `kCanAutomate`. + `shortTitle`; `units` and a plain range per **§6.7**, which is where Ruling 3's real-unit + contract lives; `stepCount = 0` on all 44 (§6.7's sweep); `defaultNormalizedValue` + **computed** per §6.7's tightening of §8's exactness rule; the group's `unitId`; and + `kCanAutomate`. - **`kIsBypass` is not set on anything.** The limiter is a safety device, not a bypass, and binding it to `kIsBypass` would hand the host a control that restarts the component (§3.8). The plugin exposes **no** bypass parameter — it is an instrument. - **`setParamNormalized` / `getParamNormalized`** are the model projection of §6.1, not a cached shadow value. - **`normalizedParamToPlain` / `plainParamToNormalized` / `getParamStringByValue` / - `getParamValueByString` all route through the ONE taper module Γ-W1-T1 extracts.** This is - the structural point of that extraction: the host's normalization, the knob's needle - angle, and the overlay node's position must be the *same function*, not three functions - that agree today. `getParamStringByValue` reuses the editor's own formatters - (`formatEnvTimeMs` and its peers) for the same reason. + `getParamValueByString` all route through the ONE taper module Γ-W1-T1 extracts and the ONE + formatter per unit category.** This is the structural point of that extraction: the host's + normalization, the knob's needle angle, and the overlay node's position must be the *same + function*, not three functions that agree today. **§6.7 states both invariants in full** — + it is the specification for this bullet, not a gloss on it. - **`IParameterChanges` is observed at BLOCK boundaries** — the last point in the block wins for that block (§3.7). Sample-accurate application would put a per-sample "did anything change" question on the per-voice-per-sample path, which the phase-wide guardrail forbids. @@ -499,6 +577,245 @@ restores the model and the parameters are derived from it. Three things are none automation against the same plain values — the round trip through the frozen taper is exact at both ends. +### 6.7 Real units at the host boundary — Ruling 3 + +**Ruling numbering.** `docs/PLAN.md` numbers Daniel's Phase-Γ rulings in the order they +arrived: **Ruling 1** = VST3 parameters ship in Γ; **Ruling 2** = the 10 s ceiling and the +knob-interaction law it grew; **Ruling 3** = this one. The same numbers are used here. + +> **Ruling 3 (Daniel, 2026-08-01):** *"The parameter values exposed to the VST host should be +> in real units, such that the host automation lanes report usable values."* + +**This is satisfiable, and the mechanism matters, because the naive reading of it is +impossible.** VST3's automation *wire format* is normalized `0..1` and cannot be anything +else: `setParamNormalized` / `getParamNormalized` (`ivsteditcontroller.h:429-433`) are the +interface, and what the host writes into a lane and replays from it is a normalized double. +There is no VST3 in which a lane stores milliseconds. + +What delivers the requirement — **usable values in the host's UI and lane readouts** — is the +**plain-value layer**, which the vendored SDK provides and which we are simply not using yet: + +| Surface | Where | What it does | +|---|---|---| +| `Parameter::toPlain` / `toNormalized` | `public.sdk/source/vst/vstparameters.h:88-90` | **virtual** — overriding them is how a non-linear law is expressed to the host | +| `RangeParameter` | `vstparameters.h:110-138` | carries plain min/max and does the *linear* mapping for free | +| `getParamStringByValue` | `ivsteditcontroller.h:418` | the display string the host shows in its readout | +| `ParameterInfo::units` | `ivsteditcontroller.h:52` | the unit string (`"ms"`, `"st"`, `"dB"`, `"%"`, `"Hz"`) | +| `normalizedParamToPlain` / `plainParamToNormalized` | `ivsteditcontroller.h:424-426` | the controller-level pair a host calls to read a lane in plain units | + +**The direct precedent is in the vendored tree.** `public.sdk/samples/vst/common/logscale.h` +overrides exactly this pair for a logarithmic law — `toPlain` returns `logScale.scale(norm)`, +`toNormalized` returns `logScale.invscale(plain)` (`logscale.h:221-229`), with `toString` +printing the *plain* value. That is precisely the shape our log-tapered ms knobs and the +log2 semitone knobs need. We are not inventing a mechanism; we are using the one the SDK +ships and Steinberg's own sample demonstrates. + +#### 6.7.1 Every exposed parameter declares a plain range, a unit, and a display precision + +Eight unit categories, covering all 44. **Precision and formatting are a product decision +about what a user reads in a lane, and they are decided here.** + +| Category | Parameters (count) | `units` | Plain range | Display | Taper (norm → plain) | +|---|---|---|---|---|---| +| **Time** | the 14 stage times | `ms` | 0 … 10000 | one decimal below 10 ms, integer at and above (`0.5`, `3`, `104`, `10000`) | log (Γ-W1-T1) | +| **Semitones** | Pitch offset, Pitch env depth (2) | `st` | −24 … +24 | one decimal, **always signed** (`+3.5`, `-12.0`, `+0.0`) | log2, centre-expanded (Γ-W1-T1) | +| **Percent, unipolar** | amp/filter sustain, three hold fractions, Trigger length, morph (7) | `%` | 0 … 100 | integer | identity ×100 | +| **Percent, 0–200** | key-track ×2 (1000, 1260) (2) | `%` | 0 … 200 | integer | identity ×200 | +| **Percent, bipolar** | filter mod amount, filter velocity amount (2) | `%` | −100 … +100 | integer, **always signed** | identity ×100 | +| **Percent, Rate** | Rate (1) | `%` | 50 … 200 | **one decimal** | exponential in ratio (linear in semitones, Γ-W2-T1) | +| **Decibels** | Master gain (1) | `dB` | −60 … +24 | one decimal, always signed; **norm 0 reads `-inf`** | linear in dB (`engine/master_gain`) | +| **Hertz** | Filter cutoff (1) | `Hz` | 20 … 20000 | integer | log Hz — **frozen v9 law, read-side only** (§6.7.5) | +| **Dimensionless** | 12 curve exponents, filter Q, filter drive (14) | *(empty)* | exponents 0.1 … 10; Q 0.1 … 10; drive 0 … 4 | two decimals | exponents: `curve_law` log travel. Q/drive: **frozen v9 laws, read-side only** | + +14 + 2 + 7 + 2 + 2 + 1 + 1 + 1 + 14 = **44.** + +**Four formatting decisions inside that table, each with its reason:** + +1. **Time never switches to seconds**, so the ceiling reads `10000 ms` and not `10 s`. This + is the existing rule (`deck_values.h`: *"the editor reads in one unit so two stage times + are comparable at a glance"*) surviving the 10 s ceiling intact, and it is *forced* on the + host side anyway — `ParameterInfo::units` is a static string per parameter and cannot + change with magnitude. +2. **Rate reads to one decimal, not integer percent.** Its snap grid is whole semitones + (§4.2 of the control-surface spec), and semitone steps do not land on integer percent + (+1 st = 105.946 %). An integer display would print a snapped position as a value the + snap cannot actually produce. +3. **Cutoff's `k` abbreviation is RETIRED** — cutoff reads integer Hz on both surfaces + (`240`, `12800`), not `12.8k`. `units` is one static string per parameter, so a + magnitude-switching unit is not expressible; and the label band demonstrably fits eight + characters, since the 10 s ceiling already forces `10000 ms`. **The rejected alternative + was to keep `12.8k` in the editor and accept a host/editor divergence on cutoff alone** — + which is exactly the defect class §6.7.2 exists to forbid, for the sake of one idiom. +4. **The `^` on a curve exponent is static cell chrome, not part of the value.** The editor's + label keeps its caret; the value string is `1.00`. A constant prefix cannot diverge, and + `^` is not a unit. + +**What is frozen here and what is not.** The **plain range** and the **norm↔plain law** are +frozen with the id (§6.3) — they are the meaning of every recorded automation point. The +`units` string, the `title`/`shortTitle`, and the display precision are **not frozen**: they +are what a user reads, not what a lane stores, and may be improved at any time. + +#### 6.7.2 ONE formatter, two consumers — an invariant, not a review check + +> **A unit category has exactly ONE pure formatter, and the editor and the host are both its +> callers.** The formatter returns the **digits** of a plain value in that category's one +> `units` string — no embedded unit, no magnitude-switched unit, no width-conditional +> abbreviation, and no caller-side branch that changes what the digits are. The editor's knob +> label renders `digits` (plus its own static chrome — the unit suffix, the `^` prefix); the +> host receives `digits` from `getParamStringByValue` and `units` from +> `ParameterInfo::units`. **There is no second implementation on either side.** +> +> **The editor and the host disagreeing about what a value reads as is a defect class, and it +> is forbidden structurally rather than caught at review** — the same discipline, and the same +> reason, as *"three functions that agree today is a defect"* for the taper. + +This has a consequence for the two formatters that exist today: **they stop embedding their +unit.** `formatEnvTimeMs` (`core/instrument/ui/deck_values.h`) returns `"104 ms"` today and +must return `"104"`; `formatMasterGainLabel` (`core/instrument/engine/master_gain.h`) returns +`"-12.0dB"` and must return `"-12.0"` (and `"-inf"` at norm 0, which stays inside the +formatter so both surfaces show it). `deckValueLabel` +(`shell/instrument/editor_controls.cpp`) stops carrying `snprintf` format strings of its own +and becomes a dispatch to the per-category formatters — it is today the *second* +implementation for every category except time and dB, which is precisely the drift this +invariant closes. + +**[verify] at the track:** whether REAPER renders `ParameterInfo::units` beside the string +from `getParamStringByValue`, or shows the string alone. The SDK's own convention is +digits-only in the string with `units` carried separately (`RangeParameter::toString` prints +the number; the `Parameter` constructor takes `units` as its own argument), so that is what +we ship. **If REAPER shows no unit at all, the fallback is to append the unit inside the one +formatter** — a one-line change in one place, touching neither the frozen table nor the +editor, because display strings are not frozen (§6.7.1). + +#### 6.7.3 `toNormalized` IS the taper + +**Γ-W1-T1's extracted taper module is not merely *consulted* by the parameter layer — on the +norm↔plain axis it *is* the parameter layer.** `toPlain` is the taper's forward map; +`toNormalized` is its inverse. **Three consumers, one law:** + +1. the editor's knob drag (`ui/deck_values`), +2. the AHDSR overlay's schematic axis and its drag inverse (`ui/envelope_overlay` + + `ui/envelope_edit`), +3. the host's `normalizedParamToPlain` / `plainParamToNormalized` (this track). + +**This is what makes the one-way-door reasoning structurally true rather than a warning.** +The plan says re-tapering after Γ-W4-T1 silently re-interprets every recorded automation +point; the reason that cannot be forgotten is that there is no second place a taper could be +changed. The host reads the same function the needle does. + +#### 6.7.4 The consequence to know: a straight line in a lane is not linear in real units + +Under a tapered parameter, **a linear ramp drawn in a host automation lane sweeps +non-linearly in the plain unit.** This is standard, generally desirable, and follows directly +from Ruling 3 combined with the taper ruling — so it is written down rather than discovered: + +- **Time (log):** a straight lane ramp sweeps **exponentially in ms** — most of its travel is + spent in the first few hundred milliseconds, which is where a stage time is musically + decided. +- **Cutoff (log Hz):** a straight ramp is **linear in octaves** — the musically right filter + sweep, and the reason every synth taper looks like this. +- **Master gain (linear in dB):** a straight ramp is a **linear dB fade**, i.e. exponential in + amplitude — the musically right fade. +- **Rate (linear in semitones):** a straight ramp is a **linear pitch glide** and an + exponential sweep in ratio/percent. +- **Pitch offset, pitch env depth (log2, centre-expanded):** a straight ramp moves **slowly + near zero and fast at the extremes**, in semitones. This is the one category where the + taper is chosen for knob resolution rather than for musical linearity, and it is the one a + user is most likely to notice as unexpected. +- **Percent, dimensionless, curve exponents:** identity or near-identity — a straight ramp is + straight. + +**The remedy, if a user wants a literal-units ramp, is the host's own curve tools**, not a +change to the taper. There is no version of this in which lanes are stored in plain units. + +#### 6.7.5 Plain units for the filter's four is NOT re-tapering + +The plan forbids re-mapping cutoff / Q / morph / drive: they persist as `*Norm` doubles in +payload v9, so their laws are already wire-frozen and re-tapering them would re-tune every +saved project independently of automation (§8, row 4). Ruling 3 requires them to report real +units. **An engineer will read those two as contradictory. They are not, and here is why:** + +> **`toPlain` is a pure READ-side mapping.** It does not touch the stored value. +> `getParamNormalized` returns the stored `cutoffNorm` unchanged; `setParamNormalized` writes +> it unchanged; the payload writes the same double it always has. The *only* thing `toPlain` +> changes is what the host **displays**. The frozen law stays frozen precisely because +> `toPlain` is the law — reporting Hz means *calling* it, not replacing it. + +The codebase already does exactly this and has since the filter shipped: `deckValueLabel` +prints cutoff via `filterCutoffHzFromNorm`, Q via `filterQFromNorm`, and drive via +`filterDriveDepthFromNorm` — the filter module's own laws +(`core/instrument/engine/filter/filter_params.h`). **The parameter layer calls those; it does +not restate them.** For morph, plain % is `norm × 100`. + +**One gap, and it is additive.** `plainParamToNormalized` and `getParamValueByString` need +the inverse of each law. `filterNormFromCutoffHz` and `filterNormFromQ` already exist and are +documented as exact inverses; **`filterDriveDepthFromNorm` has no published inverse and one +must be added.** Adding the analytic inverse of a frozen law is not a change to the law — it +has exactly the standing the two existing inverses already have, and it belongs in +`filter_params`, beside them, not in the parameter module. + +**Stated once so the two rules never read as a collision again:** the prohibition forbids +*editing* `filterCutoffHzFromNorm` and its peers; the requirement is satisfied by *calling* +them. + +#### 6.7.6 Shift-snap must NOT become `stepCount` — and the sweep + +**These are two different things and conflating them would freeze the wrong one forever:** + +- **Shift-snap is a DRAG interaction** — a modifier held during a mouse gesture in the editor + (control-surface spec §4.1–4.2). It affects which values a *drag* produces and nothing else. +- **`ParameterInfo::stepCount` quantizes the PARAMETER** — permanently, for every writer + including the host's automation, and it is part of the frozen normalization (§6.3 names + `stepCount` explicitly). + +Exposing the snap grid as `stepCount` would make continuous cents unreachable from a host +lane, quantize every automation curve to the snap grid, and freeze that quantization into a +contract that cannot be revised. **The two axes are independent, and the codebase already +proves it:** filter cutoff *displays* in Hz, *snaps* in whole percent of its normalized +position, and would be *automated* continuously — three different grids on one control, none +of which is the others. + +> **Every continuous-valued parameter ships `stepCount = 0`.** + +**The sweep, run over all 44: none carries a non-zero `stepCount`.** Every one of the 44 is +continuous — the 14 times, the 2 semitone throws, the 12 percent controls, the 12 curve +exponents, cutoff, Q, drive, master gain. **And that is structural rather than lucky:** +`stepCount > 0` is only meaningful for a discrete control, and §7's predicate excludes every +discrete control by construction — discrete controls are reload or rebuild tier (§7.2), which +is why the classification and the continuity coincide. + +**The one case that would change this**, named so it is not a surprise: if a discrete control +ever gains a live path and therefore qualifies (voice count is the obvious candidate, 1–32), +`stepCount` becomes a live decision *at that moment* and freezes with the new id. §7.2 +already names that door from the other side. + +#### 6.7.7 The exact-preimage requirement, re-checked against this ruling + +**Unchanged in force. Tightened in two ways, with one explicit NON-requirement added.** + +1. **Tightening — `defaultNormalizedValue` is COMPUTED, never a literal.** It is + `toNormalized(default)`, evaluated against the one taper at table construction, where + `default` is read off a default-constructed `PlaySeconds` (the standing rule that there is + no second table of defaults). A hand-written normalized literal is a second source of truth + for the default and drifts from the taper silently — precisely the failure the whole + one-formatter/one-taper discipline exists to prevent. +2. **Tightening — the assertion moves onto the pair the host actually calls.** Γ-W1-T1 still + owns the taper *shape* that makes exactness achievable, and still fails there if it is not. + Γ-W4-T1 additionally asserts `toPlain(info.defaultNormalizedValue)` compares **exactly** + equal to the default, per parameter, on the host-facing functions. The prior wording made + this an inference from the taper's own tests; it is now a direct test of the contract. +3. **NON-requirement, stated so nobody over-constrains the taper: `toNormalized(toPlain(n)) + == n` is NOT required for arbitrary `n`.** No log map satisfies that exactly in double, + and demanding it would rule out the taper the phase has already chosen. Exactness is + required **at the defaults**; monotonicity is required **everywhere**; round-trip exactness + at arbitrary points is required **nowhere**. +4. **The filter's four are exact by identity and need no work.** Their stored value *is* the + normalized one, so `defaultNormalizedValue` is the stored default verbatim and `toPlain` + never participates in the reset path. +5. **Master gain's unity remains the sharpest case** (≈ 0.714 norm), for the reason §8 row 7 + gives: a host reset that lands a hair off unity is an audible gain error, not a cosmetic + one. + --- ## 7. The classification — which controls become parameters @@ -604,8 +921,8 @@ are new; three are already frozen for an unrelated reason and are recorded so th |---|---|---| | 1 | **The ms and semitone tapers** (`ui/deck_values`) | Γ-W1-T1. Known. The taper *is* the host normalization. | | 2 | **`kEnvTimeMaxSeconds` / `kGateStageMaxSeconds` 2.0 → 10.0** | Γ-W1-T1, per Daniel's ruling. A range endpoint is normalization. Γ-F3 reversed. | -| 3 | **Every default must have an EXACT normalized preimage** — NEW | Γ-W1-T1 acceptance criterion. `ParameterInfo::defaultNormalizedValue` is normalized, so a host's reset-to-default arrives as `toPlain(defaultNorm)`. Γ-W1-T1 makes `resetDeckParam` **bypass** the taper, which fixes the editor's reset and **cannot fix the host's** — the host has no bypass. The taper must therefore be designed so every default round-trips exactly, or the host's reset lands a hair off and the "double-click lands exactly on its default" criterion holds on one surface and not the other. **This is the sharpest new finding in the sweep.** | -| 4 | **Do NOT re-taper the filter's four normalized controls** — NEW | Γ-W1-T1 exclusion. Cutoff/Q/morph/drive persist as `cutoffNorm`/`resonanceNorm`/`morphNorm`/`driveNorm` in the **v9 payload** — their laws are *already* wire-frozen today, and re-mapping them would re-tune every saved project independently of automation. §4.2's snap-unit table names them, which is a display concern; nothing in the taper pass may touch their norm↔plain law. | +| 3 | **Every default must have an EXACT normalized preimage** — NEW | Γ-W1-T1 acceptance criterion. `ParameterInfo::defaultNormalizedValue` is normalized, so a host's reset-to-default arrives as `toPlain(defaultNorm)`. Γ-W1-T1 makes `resetDeckParam` **bypass** the taper, which fixes the editor's reset and **cannot fix the host's** — the host has no bypass. The taper must therefore be designed so every default round-trips exactly, or the host's reset lands a hair off and the "double-click lands exactly on its default" criterion holds on one surface and not the other. **This is the sharpest new finding in the sweep.** **Tightened by Ruling 3 — see §6.7.7**: `defaultNormalizedValue` is computed rather than written, the assertion moves onto `toPlain`, and arbitrary-point round-trip exactness is explicitly NOT required. | +| 4 | **Do NOT re-taper the filter's four normalized controls** — NEW | Γ-W1-T1 exclusion. Cutoff/Q/morph/drive persist as `cutoffNorm`/`resonanceNorm`/`morphNorm`/`driveNorm` in the **v9 payload** — their laws are *already* wire-frozen today, and re-mapping them would re-tune every saved project independently of automation. §4.2's snap-unit table names them, which is a display concern; nothing in the taper pass may touch their norm↔plain law. **Ruling 3 does NOT conflict with this** — reporting Hz/Q/drive-depth to the host means *calling* the frozen laws, not replacing them; §6.7.5 works it out and names the one additive gap (drive has no published inverse). | **Frozen by this work — record only, no change wanted:** @@ -623,7 +940,10 @@ are new; three are already frozen for an unrelated reason and are recorded so th - **The params-payload ladder.** Under §6.1 the blob stays authoritative, so payload growth continues on its own independent axis exactly as before. Automation adds no constraint to it. -- **`stepCount`.** No discrete parameter is exposed (§7.2), so no step count is frozen. +- **`stepCount`.** No discrete parameter is exposed (§7.2), so no step count is frozen — swept + explicitly over all 44 under Ruling 3 and confirmed: **every one ships `stepCount = 0`**, and + the coincidence is structural rather than lucky. §6.7.6, which also forbids the specific + mistake of exposing the editor's shift-snap grid as `stepCount`. - **Hold fractions, sustain level, Trigger length.** Natural `[0,1]`, identity taper, nothing to freeze beyond the identity. - **The limiter's −0.3 dBTP ceiling, `kMaxCurvePoints` = 128, the editor floor/budget