# src/core/instrument/param — the VST3 parameter surface's pure half

## Scope

What the instrument tells a VST3 host about its automatable parameters, with no VST3 type
anywhere: the frozen id table, the exposed set derived from the deck's commit predicate, the
plain-value layer (unit category, plain range, `toPlain` / `toNormalized`), the one formatter per
unit category, the host's own norm→stored write map, and the audio thread's block-boundary merge
decision. The VST3 shell (`shell/instrument/instrument_params`) adapts these onto
`Steinberg::Vst::Parameter`; it decides nothing.

A sixth peer of `engine/` / `map/` / `note/` / `bake/` / `ui/`, and it sits ABOVE `ui/`: the
parameter list is a function of `deckParamCommit` and the value binding, never the reverse.

**Where an exposed control's value lives is `valueHomeFor`'s answer, and the exposed set is
asserted against it.** Two controls sit beside the parameter set rather than in it — master gain
(the processor's atomic) and pitch key-track (`InstrumentParams::keyTrack`) — and a promotion
whose control has no home would no-op silently in both directions on the host path with nothing
to catch it at compile time. That is exactly what happened to id 1000 before the guard existed.

## Invariants

### The id table is FOREVER-FROZEN

`param_id.h`'s header states the rule in full and is its one home. It sits on the same footing
as the extension's `"STABLE_FOREVER_STRING"` command ids, the two VST3 class UIDs
(`core/wire/reasampler_uid.h`) and the params-payload field order
(`map/component_state_io.h`) — the fourth member of that family, not a new kind of rule.

**The table carries every assigned number, including numbers not issued today.** Membership of
the parameter list is `isExposed`'s answer, not the table's. A row whose control is currently
`Reload`-tier keeps its number reserved: the day that control gains a live path it is exposed
under the number already written beside it, and no other id moves. That is what the
block-and-step scheme buys, and it is why a refusal to promote a control is cheap.

### The list follows the predicate; the predicate is never bent to fill the list

A control is an exposed parameter **iff** `deckParamCommit` classifies it `Live` or
`NoteOnLatched`. There is no second membership table and no per-control exception. Adding a
parameter means giving a control a live path in `deck_groups`, at which point it qualifies by
the same rule that excluded it.

### `toPlain` is a READ-side mapping and changes no stored value

Reporting Hz / Q / drive depth for the filter's four means **calling** `filter_params`' frozen
laws, never replacing them: those four persist as normalized doubles in payload v9, so their
laws are already wire-frozen. The same holds for `master_gain`'s dB sweep and `curve_law`'s
exponent travel. Every law here is called; none is restated.

### ONE formatter per unit category, two callers

`param_format` returns the DIGITS of a plain value. The editor's knob label renders those digits
plus its own static chrome (the unit suffix, a curve dial's `^`); the host receives the same
digits from `getParamStringByValue` and the unit string from `ParameterInfo::units`. There is no
second implementation on either side — that is why `formatEnvTimeMs` and `formatMasterGainLabel`
no longer exist.

## Modules

- `param_id` — the frozen `ParamId` constants, the `ParamRow` table (id, `DeckParam`, `IUnitInfo`
  unit, title, shortTitle) in ascending id, `isExposed`, and the derived `exposedParams()`.
  Ascending id IS the presentation order, so identity order and presentation order agree by
  construction rather than by maintenance.
- `param_units` — `UnitKind`, `unitStringFor`, `plainRangeFor`, the `toPlain` / `toNormalized`
  pair, and the defaults read off a default-constructed `PlaySeconds`.
- `param_format` — the eight formatters and the digits parser behind `getParamValueByString`.
- `param_live` — a host parameter write, BOTH sides of the model/audio split: `applyLiveParam`
  patches the live block in place (allocation-free, lock-free, for the `IParameterChanges` queue
  the SDK delivers on the audio thread, where the model layer cannot run — `resolvePlay`
  allocates), and `writeHostParam` lands the same write in the stored parameter set. One value
  map (`param_units`' `hostStoredFromNorm`) serves both, so they cannot disagree; the ROUTING is
  pinned by an exhaustive equivalence test between them over every exposed control. The routing
  switch carries **no `default:`** — a control promoted into the list without a route fails to
  compile, which the call site's discarded return value would otherwise hide.
- `param_merge` — the audio thread's block-boundary merge DECISION, with no atomic and no host
  type in it: which held automation points still outrank the model, which the model has caught up
  on and are released, and whether an arriving point moves anything at all. It is the testable
  half of the AUTHORITY MODEL stated in `shell/instrument/CLAUDE.md`, and the reason both of that
  model's failure modes now have a test rather than a reviewer.

## Gotchas

- **`defaultNormalized` is COMPUTED, never a literal.** It is `toNormalized(defaultPlain)` for
  every tapered control, so a host's reset-to-default and the editor's double-click land on the
  same value. The filter's four are the one exception and for the opposite reason: their stored
  value already IS the normalized one, so their default normalized value is that double verbatim
  and no taper participates in the reset path at all.
- **Round-trip exactness at arbitrary values is NOT a property here and must not be asserted.**
  No log map satisfies `toNormalized(toPlain(n)) == n` in double, and demanding it would rule
  out the taper the range needs. Exactness is required at the defaults; monotonicity everywhere.
- **Both the host's read (`toPlain`) and write (`hostStoredFromNorm`) paths for a curve exponent
  skip `curve_law`'s knob detent, and that is deliberate** (Daniel, 2026-08-02: continuous ranges
  stay continuous at the host boundary). The detent is a DRAG affordance only — a drag grid
  delivers `start - dy/128` and lands on the identity only by luck, so a band wider than one drag
  step snaps to it — and a host lane has no grid. `curveFromKnobNorm` already answers exactly
  `1.0` at norm `0.5`, so skipping the detent costs nothing in reachability from the host. The
  dial-drag path (`ui::storedFromNorm`/its snap) is the one place the detented map still applies,
  because that is where the snap earns its place. `test_param_live`'s
  `testTheHostSkipsTheCurveDetentAndNothingElse` pins the write half; `test_param_format`'s
  `testAnOffDetentExponentReadsTrueToBothTheHostAndTheEditor` pins the read half.
- **Master gain's plain value at norm 0 is `-inf`**, which is outside the declared −60…+24 range
  on purpose — norm 0 is true silence, not the floor. The formatter prints `-inf` there. The
  editor additionally SUPPRESSES its unit suffix at that one value (`editor_controls`, the
  `Decibels` + non-finite test) — "-inf" rather than "-infdB", because there is no decibel value
  there. The host has no such hook and will render `ParameterInfo::units` beside it, so this is a
  deliberate ONE-VALUE break in the "editor digits + chrome == host digits + units" invariant
  stated above.
- **MORPH ALONE can display one digit differently from a not-yet-stored norm.** The filter's four
  store their position as a `float`, but cutoff, Q and drive cast the incoming norm to `float`
  *inside* `toPlain`, so `toPlain(n)` and `toPlain(double(float(n)))` are bit-identical and those
  three are held to digit-for-digit string equality like everything else. Morph's path is
  full-double (`clamp01(n) * 100`), so the float the model stores and the double the host holds
  are genuinely different inputs — worth one integer percent at a value landing on a display
  rounding boundary. Both surfaces read the MODEL in every settled state, so it is a transient of
  the write itself, not a standing divergence; `test_param_format` holds morph alone to the plain
  value rather than to the string.
- **A host write the MODEL clamps is not a settled state either.** Trigger length's stored
  domain is `(0,1]`, so a host norm of 0 comes back as 0.01. `setParamNormalized` caches what the
  model took, so the host never holds the rejected value — the sweep skips the clamped steps for
  that reason rather than loosening its comparison.
