docs: schedule VST3 parameters into Phase Γ

Automation ships as Γ-W4; the stage ceiling goes to 10 s in W1-T1 ahead of the
one-way door; a new Γ-W3-T2 corrects Ξ's bake reset list. Four waves, ten tracks.
Opens Γ-F7 on parameter order.
This commit is contained in:
2026-08-01 17:26:18 -04:00
parent 256216d670
commit 2fa55658c1
4 changed files with 1278 additions and 202 deletions
+458 -37
View File
@@ -1,15 +1,24 @@
# VST3 automation parameters — scoping, not scheduling
# 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."*
**This is NOT Phase Γ work.** It is framing and open questions, written now because Phase Γ
makes two decisions whose cost changes permanently the day parameters ship, and because
knowing that is what makes Γ's ordering correct rather than arbitrary. Nothing here is
scheduled; nothing here is settled.
**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."*
The one thing this doc *does* assert is a sequencing constraint — §4.
**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.
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.
---
@@ -61,11 +70,12 @@ class UIDs already carry, and the same discipline the params-payload version lad
wants to renumber, regroup or reorder that enum has to happen first. This is not a
theoretical concern: Phase Γ adds two entries to it.
Open: is `ParamID` the `DeckParam` value directly, or an independent id space with an
explicit mapping table? Direct is simpler and tempting; an independent space is what lets
the enum keep being a UI-ordering convenience. **Lean: independent id space with an explicit
frozen table**, on the same reasoning the command-id family already uses — the display order
and the wire identity should not be the same number.
**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
@@ -101,8 +111,11 @@ Two coherent answers, both viable, neither obviously right:
blob contract intact; risks exactly the drift class above if the precedence is not
airtight.
Open. This is the load-bearing architectural decision of that phase and it should be made
first, the way Ξ-W2-T1's crossing decision was.
**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
@@ -212,7 +225,7 @@ decoupling first, not to re-litigate the classification.
---
## 4. The sequencing assertion — the one thing this doc claims
## 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.**
@@ -232,30 +245,438 @@ update; doing it after parameters ship costs a frozen-id decision.
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, collected
## 5. Open questions — status after the promotion
Not one of these is a Phase Γ blocker. They are what a future phase's first wave answers.
The scoping pass collected eight. Seven are closed below; one is open and is a Daniel call.
1. **Blob vs. parameters as the source of truth** (§3.3) — the architectural decision, made
first, before any wiring.
2. **`ParamID` space** — direct `DeckParam` values, or an independent frozen table (§3.1).
*Lean: independent.*
3. **The discrete/rebuild-tier controls** (§3.4) — for each of voice count, Poly/Mono,
Retrig/Legato, Gate/Trigger, Staged/Spline, pitch engine, filter law: gain a live path,
expose as non-automatable, or omit from the parameter list entirely.
4. **Per-parameter live/latched classification** (§3.5) — the three-state predicate Phase Γ
opens must be filled in for every exposed parameter, including whether Rate is lifted to
live and at what cost.
5. **Unit tree shape** (§3.6) — mirror the deck's visual grouping, or the signal flow.
6. **Sample-accurate vs. block-boundary** (§3.7). *Lean: block boundary, stated explicitly.*
7. **Does the extension's `instrument_drop` need to know?** It writes the same
`ComponentState` bytes. If parameters become the source of truth for automatable values
(option (a)), a blob the extension writes would no longer fully determine the instrument's
sound. That is a cross-artifact contract change and it is the sharpest hidden cost of
option (a).
8. **Migration.** Every saved project predates parameters. On first load a parameter's value
comes from the blob; after that the host owns it. Confirm that path is lossless, and
confirm what happens to a project saved *by* the new build and opened by an older one
(the blob still carries everything, so it should be fine — verify, do not assume).
| # | 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?** | **OPEN — Daniel.** Recommendation and reasoning at §6.4. Must close before Γ-W4 dispatches; blocks nothing before then. |
---
## 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.
### 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, OPEN
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.
**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.
**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.
**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.
**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.
**Grouping is settled either way: `IUnitInfo`, one unit per deck group**, mirroring the
group inventory (not the rows). Only the sequence is at issue.
### 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` (`"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`.
- **`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.
- **`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.
---
## 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 `isLiveDeckParam` / `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** — `isLiveDeckParam`'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.** |
| 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. |
**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.
- **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.