Files
reasampler/docs/product/parameter-automation.md
T

1004 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VST3 automation parameters — the Phase Γ specification
> Daniel, 2026-08-01: *"we need to scope the whole parameter system for automation, which
> will require redesigning the existing wiring."* And, on Rate being latched at note-on:
> *"we will need to analyze ALL the controls for LIVE use/automation use."*
**Superseding ruling, Daniel 2026-08-01 (later the same day):** *"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."*
**This IS Phase Γ work.** It ships as **Γ-W4-T1 `vst3-parameter-set`**, the phase's last
track. This doc was written as scoping and has been promoted in place: §§15 are the
original analysis (still correct, still the reasoning), and **§§610 are the specification**
— the ID scheme and its freeze invariant, the per-control classification, the one-way-door
sweep, the resample interaction, and the build shape.
**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."
---
## 1. Current state, verified
**The instrument has zero VST3 automatable parameters.**
- `ReaSamplerProcessor::initialize` never populates the parameter list, so
`getParameterCount()` returns the SDK default **0**. A host sees no parameters at all.
- All state lives in the **`ComponentState` blob** — envelope v1…v11, params payload
v1…v13 (`core/instrument/map/component_state_io.h`), a cross-artifact contract the
extension's `instrument_drop` and the instrument's processor both read and write.
- `DeckParam` ids are **runtime-only and explicitly free to reorder** — the editor's
`ParamControl` is an alias of the same enum, and nothing persists an id.
Consequently the instrument today has: no host automation, no MIDI learn, no parameter
linking, no host-side modulation, and no generic FX panel. Everything is reached through the
plugin's own editor.
---
## 2. What is actually on the table (name the prize properly)
Framing this as "automation" undersells it. **REAPER gives every exposed VST3 parameter a
parameter-modulation block for free** — an LFO, an envelope, an audio-follower/sidechain,
MIDI link, and parameter linking, per parameter, with no plugin-side work beyond declaring
the parameter.
So exposing parameters is not primarily about drawing automation lanes. It is the
instrument **gaining a modulation system it would otherwise have to design, build, persist
and draw itself.** A sampler with a host-provided LFO on filter cutoff, an envelope-follower
on drive, and MIDI-CC on pitch offset is a materially different instrument, and none of that
is our code.
That is the reason this is worth a phase. It is also the reason the parameter list's design
matters more than the plumbing: the list *is* the modulation matrix's rows.
---
## 3. The hard problems, in the order they bite
### 3.1 Parameter IDs become FOREVER-STABLE
A VST3 `ParamID` is recorded in the project file. Once shipped, the id ↔ meaning mapping is
**frozen forever** — the same discipline the extension's command-id strings and the VST3
class UIDs already carry, and the same discipline the params-payload version ladder carries.
**`DeckParam`'s "free to reorder" property dies the day parameters ship.** Anything that
wants to renumber, regroup or reorder that enum has to happen first. This is not a
theoretical concern: Phase Γ adds two entries to it.
**DECIDED — §6.2: an independent id space with an explicit frozen table.** Direct
`DeckParam` values were simpler and were rejected: they would freeze an enum whose own
header declares it *"runtime-only… free to change"*, and they cannot express the cases
where the two spaces genuinely differ (a `DeckParam` that is not a parameter, a parameter
with no deck cell). The reasoning is the command-id family's: display order and wire
identity should not be the same number.
### 3.2 The taper becomes the host-facing contract
VST3 parameters are normalized `[0,1]` with the plugin owning the taper
(`toPlain`/`toNormalized`). An automation envelope a user draws in REAPER is drawn against
the **normalized** value.
**Therefore: re-tapering a parameter after it ships silently re-interprets every recorded
automation point in every saved project.** A 40 %-of-travel node on an attack knob means
16 ms under a log taper and 800 ms under a linear one. There is no version ladder that can
fix this, because the data is in the host's project file, not ours.
Today, re-tapering is **free** — normalization exists only in `ui/deck_values.cpp` as a
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
in both can drift — a project reloaded with automation could restore the blob's value and
then have it immediately overwritten by the host's parameter value, or the reverse,
depending on call order.
Two coherent answers, both viable, neither obviously right:
- **(a) Parameters are the source of truth** for everything automatable; the blob carries
only non-automatable structure — sample refs, velocity curves, spline contours, mode
selections, the loaded capture. Cleanest host semantics; largest rewrite; and it splits
the parameter set across two storage mechanisms, which the "one parameter set" invariant
went out of its way to unify.
- **(b) The blob stays authoritative; parameters are a projection** with one defined sync
direction and a defined precedence at load. Smallest change; preserves the cross-artifact
blob contract intact; risks exactly the drift class above if the precedence is not
airtight.
**DECIDED — (b), the blob stays authoritative. See §6.1 for the reasoning, the two
findings that settled it, and the precedence rule that closes the drift risk.** This was
going to be handed to the track as its opening architecture decision, the way Ξ-W2-T1's
crossing decision was. It is not, because two verified facts collapse the fork rather
than balancing it — a fork with a dominated option in it is not a fork.
### 3.4 Which controls can be parameters at all — three classes
The good news: **this analysis is already done once, in one place.** `deckParamCommit` /
`liveCommitFor` (`ui/deck_groups`) is exactly "which controls can change without a rebuild,"
which is the same question automation asks. Phase Γ widens it from two states to three
(§3.5). The parameter work should widen the *same* decision point again rather than start a
second table — that is the standing rule (`core/instrument/CLAUDE.md`: *"which controls are
live is ONE decision, recorded in ONE place"*).
| Class | Examples | Automatable? |
|---|---|---|
| **Continuous, live-safe** | filter cutoff/Q/morph/drive/mod amt/key-track, every stage time and level, pitch env depth, master gain, pitch offset | **Yes** — the live tier already delivers them at block boundaries |
| **Discrete / rebuild-tier** | voice count, Poly/Mono, Retrig/Legato, Gate/Trigger, Staged/Spline, pitch engine, filter law | **Only if** each gains a live path, or is exposed as a stepped parameter that is explicitly *not* safe to automate at rate. Today they route through `rebuildVoiceEngine` or a full reload — neither is RT-safe at automation rates |
| **Structural** | the loaded capture, `SampleRefs`, the three velocity curves, the three spline contours, loop points, root note | **No.** These are not scalars; they stay in the blob |
| **Latency-changing** | the **limiter enable** | **No** — and for a reason unrelated to the live tier. Flipping it changes reported latency, which the SDK defines as a host deactivate/reactivate. §3.8 |
The awkward middle class is the second row, and it is where the design work is.
### 3.5 Live vs. latched is a per-parameter decision, and Γ opens the seam
Phase Γ settles that **Rate is latched at note-on**, and — importantly — settles it as a
*third commit class* (`Live` / `NoteOnLatched` / `Reload`) recorded in the same
`deck_groups` predicate, rather than as a special case at a call site. See
`docs/product/instrument-control-surface.md` §2.3.
That is exactly the vocabulary the parameter system needs. A VST3 parameter has to declare
what it means to move it mid-note, and the answer is per parameter:
- **Live** — the sounding voice follows (the φ-holding `applyLive` rule).
- **Note-on latched** — published, but read only by `snapLive`. Automation still works; it
just takes effect on the next note. Rate is here, and the *reason* it is here is recorded:
loop resolution and contour mapping are note-on folds, so live rate means re-folding a
resolved loop mid-note without a discontinuity.
- **Not automatable** — rebuild or structural.
**Lifting Rate from latched to live is a real feature with a named cost, not a flag flip.**
When someone proposes it, that is the paragraph to read first.
### 3.6 Parameter count, grouping, and the generic panel
~60 `DeckParam`s plus the non-deck controls (voice count, master gain, key-track, preview
velocity, limiter — though the limiter enable is **excluded** from the automatable set, see
§3.8). REAPER's generic FX panel and every automation-lane picker will list all of them flat
unless they are grouped.
VST3's answer is `IUnitInfo` — a unit tree that maps naturally onto the deck's own group
structure (PITCH/RATE, FILTER, VELOCITY, VOICE, the three envelopes, MASTER). Also needed
per parameter: a display name that survives truncation, `ParameterInfo::units`,
`stepCount` for discretes, and the right flags (`kCanAutomate`, `kIsBypass` — the limiter
toggle is emphatically **not** the plugin's bypass parameter).
Open: does the unit tree mirror the deck's *visual* grouping (which Phase Γ has just
reflowed into two rows) or the engine's signal flow? They currently agree. Keeping them
agreeing is a constraint worth stating before they diverge.
### 3.7 Sample-accurate automation vs. block-boundary observation
The live-parameter seqlock is observed **once per `render()` and once per note-on** — block
boundaries, by design, and that design is what keeps the per-sample path free of
indirection. VST3's `IParameterChanges` can carry multiple points inside one block.
Block-boundary application is standard, acceptable, and what most instruments do. Reading
the points sample-accurately would put a per-sample "has a parameter changed" question on
the hot path, which the phase-wide guardrail forbids in its current form. **Lean: block
boundary, explicitly, and say so — then revisit only if a user reports audible stepping on a
fast automation curve.**
### 3.8 Latency reporting — SETTLED, and it removes one control from the parameter list
**Fork Γ-F2 is ruled (Daniel, 2026-08-01): the limiter has lookahead and the plugin reports
latency DYNAMICALLY** — zero when the limiter is off, the lookahead when it is on. Do not
plan against a zero-latency instrument.
The consequence for this doc is concrete and it is a **subtraction from the parameter list**:
> **The limiter enable is NOT automatable.** It goes in §3.4's third class, and the reason
> is not that it lacks a live path — it is that changing it changes the plugin's reported
> latency, and the vendored SDK defines `restartComponent(kLatencyChanged)` as *"the host
> has to deactivate and reactivate the plug-in"*
> (`pluginterfaces/vst/ivsteditcontroller.h:105-108`). In this plugin a deactivate frees
> every sounding voice. **An automation lane toggling that parameter would deactivate the
> plugin on every flip.**
Two corollaries the parameter work must carry rather than rediscover:
- **It is also not the plugin's `kIsBypass` parameter.** A safety limiter is not a bypass;
binding it to `kIsBypass` would hand the host a control that restarts the component.
- **Latency reporting must be derived from persisted state, not from a transient.** The SDK
states the new latency is what `getLatencySamples` returns *after* `setActive(true)` — and
this plugin's `setActive(false)` destroys the whole voice state. Whatever holds the limiter
flag must survive that cycle.
Full reasoning, the SDK quotes, and the required verification steps are in
`docs/product/instrument-control-surface.md` §3.1.1. **Fork Γ-F6 is now RULED (Daniel,
2026-08-01): dynamic latency ships as specced and the deactivate/reactivate is accepted.**
There is no constant-reported-latency fallback — that option is closed, not shelved — so
**this section does not shrink to a footnote and the limiter enable does not become
automatable.** Plan against the not-automatable classification; it is settled.
**The decoupling that was filed against this section has LANDED (Γ-W3), and it changes the
cost but not the classification.** `setActive(true)` no longer re-decodes the WAV: the decoded
sample now survives a deactivate and only the voice state is rebuilt
(`instrument-control-surface.md` §7.11). So a flip costs a voice rebuild rather than a disk
read plus a decode — but **the deactivate still frees every sounding voice**, which is the
ground the not-automatable classification actually rests on. Plan against not-automatable; if
the parameter work wants that lane, the question to answer is the voice cut, not the decode.
---
## 4. The sequencing assertion — now satisfied inside one phase
**The knob-taper work (Phase Γ item D) must land before the parameter system. Not
alongside; before.**
- Re-tapering is **free today** — normalization is a UI layer, the payload stores raw engine
doubles, and a re-taper moves the needle angle and nothing else (saved projects reload
bit-identical).
- Re-tapering is **permanently expensive after parameters ship** — the taper becomes the
host-facing normalization, and changing it re-interprets recorded automation in project
files we do not own and cannot migrate (§3.2).
The same argument, weaker but real, applies to `DeckParam` additions and reordering (§3.1):
Phase Γ adds Rate and Pitch to that enum, and doing it now costs one exhaustive-switch
update; doing it after parameters ship costs a frozen-id decision.
**So the tension the brief flags is real, and it resolves in Γ's favour.** Both item D and
the parameter system touch `deck_values.cpp` and `editor_controls.cpp`, and the ordering is
not a coin flip: item D first, by a wide margin.
**What changed when the parameter system moved INTO Phase Γ.** The assertion above was
written to justify Γ running before a later phase. It now governs **wave order inside one
phase**: Γ-W1-T1 lands the taper, Γ-W4-T1 lands the parameters, and two whole waves of new
controls sit between them so that every parameter exists before any parameter is declared.
The argument is unchanged; only its blast radius shrank, which is strictly better — the two
halves are now reviewed against each other rather than across a phase boundary.
**And it grew a second clause.** Daniel's ruling *"extend the stage lengths to 10 s"*
reverses Γ-F3 and moves `kEnvTimeMaxSeconds` 2.0 → 10.0 into **Γ-W1-T1, beside the taper**,
for exactly the reason stated here: a range endpoint is part of the host-facing
normalization every bit as much as the curve between the endpoints is. §8 sweeps for every
other constant with that property.
---
## 5. Open questions — status after the promotion
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 |
|---|---|---|
| 1 | Blob vs. parameters as the source of truth (§3.3) | **CLOSED — (b), blob authoritative.** §6.1 |
| 2 | `ParamID` space (§3.1) | **CLOSED — independent frozen table.** §6.2 |
| 3 | The discrete/rebuild-tier controls (§3.4) | **CLOSED — omitted from the list entirely**, not exposed-and-flagged. §7.2 |
| 4 | Per-parameter live/latched classification (§3.5) | **CLOSED — the list is DERIVED from the three-state predicate**, never hand-maintained. §7 |
| 5 | Unit tree shape (§3.6) | **CLOSED — the deck's groups, in signal-flow order**, layout-independent. §6.4 |
| 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?** | **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 |
---
## 6. The specification — architecture, identity, order
### 6.1 Storage: the blob stays authoritative; parameters are a projection (option (b))
**Decided, not deferred.** Two verified facts collapse §3.3's fork:
1. **This plugin is a `SingleComponentEffect`, so there is only ONE state.** The SDK's own
header `#define`s `setState`/`getState` to `setEditorState`/`getEditorState` around its
include of `vsteditcontroller.h`, *"work around for the name clash of
IComponent::setState and IEditController::setState"*
(`public.sdk/source/vst/vstsinglecomponenteffect.h:41-47`). `setComponentState` exists to
feed a **separate** controller in a split-component plug-in; we have no separate
controller. §3.3's "a value that lives in both can drift, depending on call order"
describes a hazard of a design we do not use. **The drift class it warns about is
substantially smaller than the scoping pass assumed.**
2. **The blob is a cross-artifact contract.** The extension's `instrument_drop` writes the
identical bytes to build a transient `.vstpreset` (`map/component_state_io.h`). Under
option (a) a blob the extension writes would no longer fully determine the sound — a
shipped seam would silently become partial. That is not a trade, it is a regression.
Add the repo's own invariant — *"the instrument holds ONE loaded capture and ONE set of
playback parameters… every playback parameter edits in exactly one place"* — and option (a),
which splits the parameter set across two storage mechanisms, is arguing against the
invariant Θ-W1-T1 was run to establish.
**The rule, stated so it cannot drift:**
> **`InstrumentParams` / `PlaySeconds` is THE model. The `ComponentState` blob is its only
> persistence. A VST3 parameter is a THIRD SURFACE onto that model — a peer of the deck
> knob and the overlay node, not a second copy of the value.**
>
> - **Load:** the blob restores the model; every exposed parameter's normalized value is
> then *derived* from the model and pushed to the controller. The SDK is explicit that
> this push must not be reflected back to the host: *"The controller must never pass this
> value-change back to the host via the IComponentHandler. It should update the according
> GUI element(s) only"* (`ivsteditcontroller.h:430-433`).
> - **Host → plugin:** a parameter change writes the model through the **existing commit
> tiers** (`shell/instrument/CLAUDE.md`) — live publish or note-on latch. It never takes a
> fourth route.
> - **Plugin → host:** an editor gesture writes the model AND notifies the host, bracketed
> `beginEdit` / `performEdit` / `endEdit` (`ivsteditcontroller.h:175-185`), on the UI
> thread as the SDK requires.
> - **Save:** `getState` serializes the model. Nothing new is persisted.
>
> **The residual risk is not drift, it is authority:** a host automation lane, once written,
> 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.
**[verify] at the track, before wiring:** 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. Verify against the vendored SDK
and in the DAW — do not build on the paragraph above without it.
### 6.2 The ID space: hand-assigned constants in one frozen table
**`ParamID` is an independent space from `DeckParam`, defined by a hand-written table of
named constants in one pure module.** Not derived, not computed, not an enum cast.
**Why hand-assigned rather than derived.** A derived id is a function of something else —
enum position, group index, a hash of a name — and every one of those inputs is a thing
that must then never change. Deriving does not remove the freeze; it hides it behind a
mechanism, and the day someone reorders the source enum the ids move silently. A literal
table makes the freeze **visible at the point of change**: you cannot renumber it by
accident, because renumbering it means editing the numbers.
**The layout — blocks of 100 per deck group, steps of 10 within a block:**
| Block | Group | Reserved |
|---|---|---|
| `1000` | PITCH/RATE | 10001099 |
| `1100` | PITCH ENV | 11001199 |
| `1200` | FILTER | 12001299 |
| `1300` | FILTER ENV | 13001399 |
| `1400` | AMP ENVELOPE | 14001499 |
| `1500` | VELOCITY | 15001599 (reserved; empty today — §7.3) |
| `1600` | VOICE | 16001699 (reserved; empty today — §7.2) |
| `1700` | MASTER | 17001799 |
- **Within a block, ids step by 10** in the group's own left-to-right cell order. Nine free
slots sit between every pair of neighbours, so **a control added later lands numerically
adjacent to its siblings instead of appended at the end of the whole table.** That is the
concrete content of "make the parameter order logical": the order is a property of the
numbering scheme, maintained by construction, not by remembering to re-sort.
- **A curve-exponent inner dial takes its outer knob's id + 1.** The dial is a property of
the knob it sits inside — the codebase already says so (`curveParamFor`, "each is the
INNER DIAL of the stage knob it shapes") — so its id says so too. This is why the step is
10 and not 2: it leaves room for the dial *and* for a future sibling knob.
- **Blocks start at 1000, not 0.** `0` is a plausible accident (a default-initialised
`ParamID`), and a scheme whose first legitimate id is also its most likely bug value is a
scheme that cannot tell them apart.
- **An empty block is still reserved.** VELOCITY and VOICE expose nothing today. Their
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` |
| *15001599* | *VELOCITY — reserved, empty* | | 1441 | └ release curve | — |
| *16001699* | *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.
> **THE PARAMETER-ID TABLE IS FOREVER-FROZEN.** A VST3 `ParamID` is recorded by the host
> into automation lanes inside project files this repo does not own and cannot migrate.
> From the first shipped build that exposes parameters:
>
> - **No id is ever reassigned, reused, or re-pointed.** If a control's meaning genuinely
> changes, it takes a NEW id and the old one is **retired** — marked dead in the table,
> never re-issued.
> - **No exposed parameter's normalization ever changes** — not its taper, not either range
> endpoint, not its `stepCount`. The normalization *is* the meaning of every recorded
> automation point. §8 enumerates every constant this now freezes.
> - **A parameter's meaning never depends on a mode.** The Gate-face and Trigger-face stage
> times are separate stored fields and take separate ids; an id whose plain value means
> one thing in Gate and another in Trigger is unautomatable by construction.
> - **A new control takes the next free slot inside its own group's block**, never the next
> number at the end of the table.
>
> This sits on the same footing as the extension's `"STABLE_FOREVER_STRING"` command ids
> (root `CLAUDE.md`, "REAPER extension contract"), the two VST3 class UIDs
> (`core/wire/reasampler_uid.h`, FOREVER-FROZEN), and the params-payload field order
> (`map/component_state_io.h`, "EVERY wire format below is FROZEN"). It is the fourth
> member of that family, not a new kind of rule.
**One property the table BUYS, and it is the reason to prefer it over deriving:**
`DeckParam`'s header says the enum is *"runtime-only… free to change."* With an independent
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, CLOSED: signal flow
Two orders exist and they are not the same thing:
- **Identity order** — the id numbers, frozen by §6.3.
- **Presentation order** — the sequence `getParameterInfo(index)` reports, which is what a
host's FX-parameter list and every automation-lane picker display.
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.
> **RULED (Daniel, 2026-08-01): signal-flow order.** *"signal flow order."*
**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.
**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 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:
> **The editor's visual layout has already moved twice** — Θ-W6-T1 grew the window floor
> 840 → 980, and Γ-W3-T1 takes it to 1198 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
Not architecture — the engineer's call is *how*. These are the observable contracts:
- **`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` 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 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.
**State it in the header rather than leaving it to be discovered**; revisit only if
audible stepping is reported on a fast curve.
- **A host parameter change takes the control's existing commit tier and no other.** Live
parameters publish; note-on-latched parameters publish and are read by `snapLive`. Nothing
on the automation path may reach `reloadInstrument` or `rebuildVoiceEngine` — which is
precisely what §7.2's omissions guarantee.
- **`IMidiMapping` is in scope and nearly free** (`ivsteditcontroller.h:558-568`): declaring
a CC → `ParamID` map costs one function and gives every exposed parameter MIDI control.
**[propose at review]** whether to ship a default map in this track or leave it to REAPER's
host-side MIDI learn; do not silently skip it without saying so.
- **`IParameterFunctionName` is assessed and NOT implemented**
(`ivstparameterfunctionname.h`). Its vocabulary is compressor/panner/dry-wet semantics
(`Comp:GainReduction`, `DryWetMix`, `Randomize`); none of it names anything this
instrument exposes. Recorded so it is not re-surveyed.
- **`IAutomationState` is assessed and NOT implemented**
(`ivstautomationstate.h`). It reports the host's automation mode for the **whole plug-in**
(`kNoAutomation` / `kReadState` / `kWriteState`), not per parameter — so it cannot answer
"is THIS parameter automated," which is the only question §9 would have wanted it for.
### 6.6 Migration — the verification, not a design
Every saved project predates parameters, and under §6.1 that is a non-event: the blob
restores the model and the parameters are derived from it. Three things are nonetheless
**[verify], not assume**:
1. A pre-parameter project opens with every parameter reading the blob's value, and sounds
identical.
2. A project saved by the new build and opened by an **older** binary still restores fully —
the blob carries everything, so it should; prove it.
3. A project with automation drawn on a parameter, saved and reopened, replays that
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, 0200** | 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.14.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, 132),
`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
**The list is DERIVED, never hand-maintained.** Its rule is one line:
> **A control is an exposed VST3 parameter if and only if its commit class is `Live` or
> `NoteOnLatched`.** Everything else is omitted from the parameter list entirely.
That makes `deckParamCommit` / `liveCommitFor` — already *"THE home for why each excluded
control is excluded"* — the single source for the parameter list too, which is the standing
rule (`core/instrument/CLAUDE.md`: *"which controls are live is ONE decision, recorded in ONE
place"*) applied once more rather than a second table opened beside it.
### 7.1 Exposed — 44 parameters
Counts are against the control inventory as it stands at the end of Γ-W3; the track asserts
the count against the predicate rather than against this table.
| Group | Parameters | Class |
|---|---|---|
| **PITCH/RATE** (3) | Rate; Pitch offset; Key-track | Rate **NoteOnLatched** (Γ, settled); Pitch **Live**; Key-track **NoteOnLatched** (see §7.4) |
| **PITCH ENV** (6) | attack, hold-fraction, decay, depth + attack/decay curve exponents | **Live** |
| **FILTER** (7) | morph, cutoff, Q, drive, mod amount, velocity amount, key-track | **Live** |
| **FILTER ENV** (13) | AHDSR A/H/D/S/R; Trigger AHD A/H/D; five curve exponents | **Live** |
| **AMP ENVELOPE** (14) | AHDSR A/H/D/S/R; Trigger AHD A/H/D; Trigger length; five curve exponents | **Live**, except Trigger length **NoteOnLatched** (§7.4) |
| **VELOCITY** (0) | — | its three cells are curve popups, not scalars (§7.3) |
| **VOICE** (0) | — | rebuild tier (§7.2) |
| **MASTER** (1) | Master gain | **Live** |
**Both play-mode faces are exposed simultaneously**, because both are stored simultaneously
and always have been. A Trigger stage time is a real parameter whose effect is silent while
Gate is selected — which is the same relationship a disabled filter's cutoff already has, and
far better than an id whose meaning depends on a mode (§6.3).
### 7.2 Omitted — the reload and rebuild tiers, and why "omit" beats "expose read-only"
**Discrete / reload tier:** play mode (Gate|Trigger), pitch engine (Varispeed|Preserve),
filter enable, filter morph law, pitch-envelope enable, the three Staged|Spline mode
toggles. **Rebuild tier:** voice count, Poly|Mono, Retrigger|Legato. **Latency-changing:**
the limiter enable (§3.8 — settled, and its one reopening condition is named there).
The scoping pass left three options (gain a live path / expose non-automatable / omit).
**Omit.** An exposed-but-not-automatable parameter is still listed in the host's generic
panel and is still *settable from it*, which would let a generic-panel drag reach
`reloadInstrument` from a host thread — strictly worse than the control being absent. VST3's
`kIsReadOnly` would prevent that, at the cost of a visible row the user cannot move, in a
list whose whole purpose is things you can move. Neither beats absence.
**Named limitation, stated rather than discovered:** the user cannot automate filter on/off,
play mode, the pitch engine, Staged↔Spline, or polyphony. The unlock is written down and is
not this track's: **give the control a live path first, then it qualifies by the same
rule that excluded it.** Nothing about the parameter system needs to change to admit it.
**One door this closes by omission** — if voice count were exposed, its 132 ceiling would
become a frozen `stepCount` and could never be raised. It is not exposed, so it is not
frozen; a future "expose voice count" proposal must accept that freeze as part of the price.
### 7.3 Omitted — structural state
The loaded capture and `SampleRefs`; root note; loop start/end, the loop crossfade, and
**the new loop enable** (Γ-W2-T2, user-owned from that track on); start frame; channel mode;
the three velocity transfer curves; the three spline contours; the three overlay-select
radios (view state, never persisted); preview velocity (a utility, not a musical parameter).
None of these are scalars with a taper, and several are not scalars at all. They stay in the
blob, which is exactly what §6.1's split is for.
### 7.4 Two promotions the three-state predicate makes available
Γ-W2-T1 introduces `NoteOnLatched` for Rate. Two controls that are excluded from the live
set **for the note-on-latch reason** currently route through the reload tier, and the new
state fits them exactly:
- **Key-track** — `deckParamCommit`'s header already says it *"feed[s] values a voice
latches at note-on by design (the pitch ratio…), so live delivery would retune… a note
already struck."* That sentence describes `NoteOnLatched`, not `Reload`.
- **Trigger length** — *"resolves `playEnd_`, a fact about the note, not a setting of it."*
Same shape.
**[propose at review, Γ-W4-T1]** promote both. The promotion aligns the routing with the
predicate's own stated semantics — and it is what makes them automatable, since today they
would re-decode a WAV per automation point. **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.
**Not promoted, and not proposed for promotion: Rate to Live.** §3.5 records the cost;
that paragraph is the first thing to read if it is ever proposed.
---
## 8. One-way doors — the sweep
**Deliverable, run 2026-08-01 against `dev`.** Once parameters ship, anything that
participates in a parameter's normalization is frozen as hard as the id itself. This is the
complete list of such constants, with a disposition for each. **Two were already known; five
are new; three are already frozen for an unrelated reason and are recorded so they are not
"tidied" later.**
**Scheduled ahead of the parameter track — action required:**
| # | Constant / property | Disposition |
|---|---|---|
| 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.** **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:**
| # | Constant | Note |
|---|---|---|
| 5 | `kKeyTrackMax` = 2.0 (0200 %) | NEW. Becomes the frozen normalization of both key-track parameters. If 0200 % is ever going to be thought wrong, now is when it is free. Recommendation: **no change.** |
| 6 | The curve-exponent domain 0.1 … 10 and its log travel (`core/util/curve_law.h`) | NEW. Twelve exposed inner dials. Recommendation: **no change.** |
| 7 | Master gain's dB taper, −∞ … +24 dB, unity ≈ 0.714 norm (`engine/master_gain`) | NEW. Already a shared pure module, which is why the needle and the audio cannot drift; the host joins as a third consumer. Unity is a default → it is a **#3 case**, and the sharpest one, because a host reset that lands a hair off unity is audible as a gain error. |
| 8 | `kPitchDepthMaxSemis` / `kVelocityPitchRangeSemitones` = 24.0 | Already frozen — the v12 wire format stores the pitch curve's y as a fraction of it. Now doubly frozen. The plan already forbids minting a second ±24 constant. |
| 9 | Rate's 50200 % (±12 st linear) and Pitch's ±24 st centre-expanded laws | Set at birth in Γ-W2-T1, two waves before parameters. **Closed by ordering** — recorded so nobody "tunes Rate's range" in a later phase. |
| 10 | The parameter INDEX order and the `IUnitInfo` unit ids | NEW, softer than the id freeze but user-facing and cached by some hosts. Set once, at Γ-W4-T1. §6.4. |
**Swept and clear — not doors:**
- **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 — 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
constants, loop and crossfade frame domains.** None participate in a parameter
normalization.
**Nothing further was found.** The sweep covered: every constant `ui/deck_values` reads or
writes; every `*Norm` field in the payload ladder; `core/util/curve_law.h`;
`engine/master_gain`; `engine/play_params.h`'s range constants; and the two range constants
Phase Γ itself introduces.
---
## 9. What automation does to the resample bake — and it is not small
Ξ-W2's bake ends by **resetting the baked-in parameters to default**, "destructive to the
dialled settings by design." Exposing those same values as VST3 parameters changes that step
in two ways. Both are **Γ-W4-T1's** to carry, because Γ-W4-T1 is what creates them.
1. **The reset must notify the host.** After §6.1, writing the model without a
`beginEdit`/`performEdit`/`endEdit` bracket leaves the host displaying — and, on the next
touch, re-imposing — the pre-bake value. **Every internal writer of a model value that is
an exposed parameter must go through the one notification path**, and the bake's reset is
the first non-gesture writer in the codebase. Enumerating those sites is part of the
track, not a follow-up.
2. **A host automation lane outranks the reset, and we cannot clear it.** An automation lane
lives in the host's project data. If a reset-class parameter carries one, the lane replays
its curve onto audio that already has that processing baked in — **double processing**,
and Ξ-W2-T1's own acceptance criterion ("after the click… sounds as the dialled instrument
sounded just before the click") does not hold in that case.
**There is no detection available.** `IAutomationState` reports the host's automation mode
for the whole plug-in, not per parameter (`ivstautomationstate.h:43-53`), and nothing else
in the SDK's edit-controller surface answers "is this parameter automated." So "refuse the
bake when an automated parameter would be reset" and "reset only the un-automated ones"
are both unbuildable, not merely unattractive.
**Disposition: a named, documented limitation.** The bake resets the plugin's values; it
cannot clear the host's lanes, and the user's remedy is to remove the lane. Stated in the
product doc and in the track's acceptance criteria as a *known* boundary of the bake's
fidelity claim — **not discovered later as a bug report against Ξ.**
---
## 10. Build shape
**Γ-W4-T1 `vst3-parameter-set`** — one track, the last in Phase Γ, specified in
`docs/PLAN.md`. It depends on all three earlier waves: on Γ-W1-T1 for the taper module that
becomes the host normalization, on Γ-W2-T1 for the three-state predicate and the two new
controls, and on Γ-W3 for the final control inventory and a bake whose reset list is already
complete (`docs/PLAN.md`, Γ-W3-T2).
**It takes no rung of the params-payload ladder under §6.1** — nothing new is persisted. A
rung is nonetheless **reserved** for it in case the architecture verification (§6.1's
`[verify]`) forces a persisted field; if unspent, the rung falls through to the next phase.