docs: close Γ-F7 to signal-flow order and spec real units at the host boundary

The 44-id table stated in full. Adds the plain-value layer, a per-category
unit/precision table, the one-formatter invariant, a stepCount sweep, and the
filter read-side resolution.
This commit is contained in:
2026-08-01 18:18:09 -04:00
parent 9f17df1420
commit 0a7778b396
3 changed files with 556 additions and 108 deletions
+46 -18
View File
@@ -60,8 +60,12 @@ own width formula, not carried over from a prior measurement. The stale geometry
- **VST3 automation parameters SHIP IN THIS PHASE, as its last track** (Ruling 1, Daniel
2026-08-01). The specification is `docs/product/parameter-automation.md` §§610: 44
exposed parameters derived from the three-state commit predicate, a hand-assigned
FOREVER-FROZEN id table in blocks of 100 with steps of 10, and the blob left
authoritative with parameters as a third surface onto the one model.
FOREVER-FROZEN id table in blocks of 100 with steps of 10 **in signal-flow order**
(Γ-F7, ruled), and the blob left authoritative with parameters as a third surface onto the
one model. **They report REAL UNITS to the host** (Ruling 3) through VST3's plain-value
layer — a plain range, a unit string and a stated display precision per parameter, formatted
by the same pure function the knob label uses. §6.7 there; §8.4 here for what it binds in
this doc.
- **The stage-time ceiling moves 2 s → 10 s, in wave 1** (Daniel, reversing Γ-F3),
*because* parameters now ship in-phase — a range endpoint is host-facing normalization,
free to move now and permanently expensive afterwards. Its real cost is not the constant
@@ -783,6 +787,13 @@ model"* — a snap available on one and not the others would be a divergence.
| **decibels** | master gain | whole **dB** |
| **already integer** | voice count, preview velocity | no change |
> **This table is a DRAG rule and must never become `ParameterInfo::stepCount`** (Ruling 3,
> §8.4). `stepCount` quantizes the parameter itself — permanently, and for the host's
> automation too — which would put continuous cents out of reach from a lane and freeze the
> grid into the forever contract. The two axes are already visibly independent here: filter
> cutoff *displays* in Hz, *snaps* in whole percent of its normalized position, and is
> *automated* continuously. All 44 exposed parameters ship `stepCount = 0`.
### 4.3 The tapers
**Both taper changes are SAFE for persistence and require no format bump.** The
@@ -1352,7 +1363,7 @@ shape is deliberate and its reasoning is documented at the call site.
---
## 8. Forks — six ruled (one later reversed), one open
## 8. Forks — SEVEN ruled (one later reversed), NONE open
### 8.1 Ruled by Daniel, 2026-08-01
@@ -1369,6 +1380,7 @@ ceiling.
| **Γ-F4** | Explicit loop enable? | **Yes — on the CHROME ROW.** Not a deck cell; loop is a waveform-overlay concept and has no deck. | **§6.4** (new), §6.5, §7.9 |
| **Γ-F5** | MASTER's reserved slot: one cell or two? | **One cell.** Two would spend 60 of the 90 px headroom on an unnamed control and freeze row 1 forever. | **§1.6** (new), §1.4 |
| **Γ-F6** | Is the `kLatencyChanged` deactivate/reactivate acceptable as the cost of the toggle? | **Yes — ship dynamic latency as ruled.** No constant-latency fallback, no measurement gate. *Corrected this doc's analysis: the cost is self-inflicted, not SDK-imposed.* | **§3.1.1** (rewritten), §7.10, §7.11, `docs/TODO.md` |
| **Γ-F7** | VST3 parameter ORDER: signal flow, or the editor's visual rows? | **Signal flow***"signal flow order."* The frozen id numbering and the presentation index both follow the deck's own rule; the visual layout is too mobile to freeze against. | **§8.3**; `parameter-automation.md` §6.4 (argument) and §6.2 (the 44-id table) |
Three of these corrected this doc rather than confirming it, and all three corrections are
worth remembering as pattern:
@@ -1425,27 +1437,43 @@ than just counting:
`restartComponent`), not about this flag.
5. **The reduction is filed**, with a trigger condition, in `docs/TODO.md`.
### 8.3 Γ-F7 — OPEN. The parameter order
### 8.3 Γ-F7 — RULED: signal flow. The parameter order
**Opened 2026-08-01 by Ruling 1** (*"Make the parameter order logical"*), because "logical"
resolves two ways and the choice is frozen forever the day parameters ship.
resolved two ways and the choice freezes forever the day parameters ship. **Closed the same
day by Daniel: *"signal flow order."***
> **Signal-flow order** — PITCH/RATE → PITCH ENV → FILTER → FILTER ENV → AMP → VELOCITY →
> VOICE → MASTER, the deck's own documented ordering rule, layout-independent.
> **OR the editor's visual row order** after the Γ-W3 reflow — row 1 then row 2 then MASTER,
> matching what the user's eye scans.
> **PITCH/RATE → PITCH ENV → FILTER → FILTER ENV → AMP → VELOCITY → VOICE → MASTER**, the
> deck's own documented ordering rule, layout-independent. Both the frozen id numbering and
> the `getParameterInfo` presentation index follow it; each group's cells take the semantic
> order the id table freezes.
Same membership, different sequence; the recommendation, both arguments, and why the
grouping (`IUnitInfo`, one unit per deck group) is settled either way are in
`docs/product/parameter-automation.md` §6.4. **Recommendation: signal flow**, because the
visual layout has moved twice already and this phase moves it again, and freezing a forever
identity to a thing that moves is the wrong coupling.
**The rejected alternative** was the editor's visual row order after the Γ-W3 reflow — row 1,
then row 2, then MASTER, matching what the user's eye scans. **The reason it lost, recorded
because a reader will ask why the numbering does not match the screen:** the editor's layout
has already moved twice and this phase moves it again, and within-row order is settled by
width fitting rather than by meaning — so binding a permanently-frozen id order to a
demonstrably mobile layout guarantees the two drift apart, after which the order is neither
logical nor matching. Signal flow is the axis that does not move.
**Urgency: low, but not zero.** Three waves sit in front of Γ-W4-T1 and nothing before it
depends on the answer. It must close **before Γ-W4 dispatches**, and it cannot be closed by
proposal at review — a forever commitment is a Daniel call.
The full argument, the accepted residual cost (a user scanning REAPER's flat list), and the
resulting 44-id table are in `docs/product/parameter-automation.md` §6.4 and §6.2.
*Every other fork in this phase is ruled. Nothing in §§17 awaits a Daniel answer.*
*Every fork in this phase is now ruled, and nothing in this doc awaits a Daniel answer.*
### 8.4 Ruling 3 — real units at the host boundary
**Daniel, 2026-08-01:** *"The parameter values exposed to the VST host should be in real
units, such that the host automation lanes report usable values."* Not a fork — it arrived as
a requirement and is specified rather than decided. It lands entirely in Γ-W4-T1 and moves no
wave boundary.
It reaches back into this doc in exactly one place: **§4's interaction law and §4.2's
snap-unit table are DRAG rules and must never become `ParameterInfo::stepCount`.** Snapped
drag and parameter continuity are independent axes — this doc's own table already proves it,
since filter cutoff displays in Hz, snaps in whole percent of normalized position, and is
automated continuously. Full spec, including the unit/precision table, the one-formatter
invariant and the `stepCount` sweep: `docs/product/parameter-automation.md` §6.7.
---
+362 -42
View File
@@ -14,11 +14,12 @@ original analysis (still correct, still the reasoning), and **§§610 are the
— the ID scheme and its freeze invariant, the per-control classification, the one-way-door
sweep, the resample interaction, and the build shape.
Two things that were open in the scoping pass are now **decided** rather than deferred:
the storage architecture (§3.3, decided in §6.1) and the ID space (§3.1, decided in §6.2).
One thing is **open and wants Daniel** before Γ-W4 dispatches: **Γ-F7**, the parameter
*order* (§6.4). It is not urgent — three waves sit in front of it — but it is a forever
commitment, so it is not mine to close silently.
**Nothing in this doc is open to Daniel any longer.** The storage architecture (§3.3, decided
in §6.1), the ID space (§3.1, decided in §6.2) and **Γ-F7, the parameter order — RULED
signal flow, 2026-08-01** (§6.4) are all settled. **Ruling 3 (real units at the host
boundary) is specified at §6.7**, which also carries the one-formatter invariant, the
`stepCount` sweep, and the resolution of the apparent conflict between "report real units"
and "do not re-taper the filter."
---
@@ -92,6 +93,10 @@ Today, re-tapering is **free** — normalization exists only in `ui/deck_values.
display/interaction layer, and the payload stores raw engine values as doubles. That is a
property worth spending while we still have it. See §4.
**Ruling 3 makes this literal rather than analogous:** `toPlain`/`toNormalized` are not merely
*like* our taper, they **are** it — one extracted pure module read by the knob, the overlay
and the host alike. §6.7.3.
### 3.3 Two sources of truth
VST3 saves parameter values *and* calls `setState`/`setComponentState`. A value that lives
@@ -262,7 +267,9 @@ other constant with that property.
## 5. Open questions — status after the promotion
The scoping pass collected eight. Seven are closed below; one is open and is a Daniel call.
The scoping pass collected eight, and Γ-F7 was opened beside them. **All nine are closed**;
one (migration) survives as a verification rather than a question. Ruling 3 added a tenth
line, closed on arrival.
| # | Question | Status |
|---|---|---|
@@ -274,7 +281,8 @@ The scoping pass collected eight. Seven are closed below; one is open and is a D
| 6 | Sample-accurate vs. block-boundary (§3.7) | **CLOSED — block boundary, stated explicitly.** §6.5 |
| 7 | Does the extension's `instrument_drop` need to know? | **CLOSED — no.** It was the sharpest cost of option (a), and (a) is not taken. The blob a drop writes still fully determines the sound. §6.1 |
| 8 | Migration | **CLOSED as a design question, OPEN as a verification.** §6.6 |
| **Γ-F7** | **Parameter ORDER — signal flow, or the editor's visual rows?** | **OPEN — Daniel.** Recommendation and reasoning at §6.4. Must close before Γ-W4 dispatches; blocks nothing before then. |
| **Γ-F7** | **Parameter ORDER — signal flow, or the editor's visual rows?** | **CLOSED — SIGNAL FLOW** (Daniel, 2026-08-01: *"signal flow order."*). The block table needed no renumbering; visual order would have. §6.4 |
| **Ruling 3** | **Real units at the host boundary** | **CLOSED on arrival** (Daniel, 2026-08-01). Satisfied through the plain-value layer, not the wire format. Unit/precision table, the one-formatter invariant, the `stepCount` sweep, and the filter resolution: §6.7 |
---
@@ -373,6 +381,62 @@ accident, because renumbering it means editing the numbers.
blocks exist so that if either ever gains an automatable control it lands in its own
group's range rather than in whatever range happened to be free.
**The block sequence above IS signal-flow order, and that is now the ruled order (§6.4).**
The table was authored in the deck's own `sampleDeckGroups` sequence, which today happens to
be both the signal flow and the pre-reflow visual order; Γ-W3-T1's reflow is what separates
them. Because Daniel ruled signal flow, **no block moves** — had the ruling gone to visual
rows, every block from 1100 up would have been renumbered before it was ever frozen.
**Within a block, the order is the group's own semantic order, seeded ONCE at the freeze and
never re-seeded.** Γ-F7's reasoning applies recursively: a group's `cellIds` order is exactly
as mobile as the row order is, and a future width fit can reorder cells inside a group as
easily as the reflow reorders groups. So the within-block sequence is a property of *this
table*, not a tracked function of `cellIds`. It is seeded from the cell order as it stands at
Γ-W3, which is semantic in every group today — envelope stages in temporal order, filter
cells in solve order — and that is what makes the seeding safe rather than arbitrary.
#### The table, stated
44 parameters. Curve dials take their outer knob's id + 1 (§6.2 above); a `+1` row is always
the inner dial of the row above it.
| ID | Parameter | `units` | ID | Parameter | `units` |
|---|---|---|---|---|---|
| **1000** | Key-track (pitch) | `%` | **1300** | Filter env attack | `ms` |
| **1010** | Rate | `%` | 1301 | └ attack curve | — |
| **1020** | Pitch offset | `st` | **1310** | Filter env hold | `ms` |
| | | | **1320** | Filter env decay | `ms` |
| **1100** | Pitch env attack | `ms` | 1321 | └ decay curve | — |
| 1101 | └ attack curve | — | **1330** | Filter env sustain | `%` |
| **1110** | Pitch env hold | `%` | **1340** | Filter env release | `ms` |
| **1120** | Pitch env decay | `ms` | 1341 | └ release curve | — |
| 1121 | └ decay curve | — | **1350** | Filter trig attack | `ms` |
| **1130** | Pitch env depth | `st` | 1351 | └ attack curve | — |
| | | | **1360** | Filter trig hold | `%` |
| **1200** | Filter morph | `%` | **1370** | Filter trig decay | `ms` |
| **1210** | Filter cutoff | `Hz` | 1371 | └ decay curve | — |
| **1220** | Filter Q | — | | | |
| **1230** | Filter drive | — | **1400** | Amp attack | `ms` |
| **1240** | Filter mod amount | `%` | 1401 | └ attack curve | — |
| **1250** | Filter velocity amount | `%` | **1410** | Amp hold | `ms` |
| **1260** | Key-track (filter) | `%` | **1420** | Amp decay | `ms` |
| | | | 1421 | └ decay curve | — |
| **1700** | Master gain | `dB` | **1430** | Amp sustain | `%` |
| | | | **1440** | Amp release | `ms` |
| *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.
@@ -404,7 +468,7 @@ State this in the ID table's header, with the same force as its three siblings.
table that stays true — the reflow, a future regrouping, a new deck can all reorder the enum
freely. Deriving ids from it would have quietly repealed that sentence.
### 6.4 Order and grouping — Γ-F7, OPEN
### 6.4 Order and grouping — Γ-F7, CLOSED: signal flow
Two orders exist and they are not the same thing:
@@ -415,32 +479,46 @@ Two orders exist and they are not the same thing:
They should agree (a list whose numbering runs backwards against its display is a trap), so
this is one decision, and it is a forever one on both counts.
**Recommendation: signal-flow order — PITCH/RATE → PITCH ENV → FILTER → FILTER ENV → AMP →
VELOCITY → VOICE → MASTER**, which is the deck's own documented ordering rule
(`sampleDeckGroups`, *"left to right, in SIGNAL-FLOW order: pitch → filter → amp, then the
two instance-wide groups"*), with each group's cells in their own left-to-right order.
> **RULED (Daniel, 2026-08-01): signal-flow order.** *"signal flow order."*
**The alternative is the editor's visual rows** after Γ-W3-T1 — row 1 PITCH/RATE, FILTER,
VELOCITY, VOICE; row 2 PITCH ENV, FILTER ENV, AMP; MASTER spanning. Same membership,
different sequence.
**The order is PITCH/RATE → PITCH ENV → FILTER → FILTER ENV → AMP → VELOCITY → VOICE →
MASTER**, which is the deck's own documented ordering rule (`sampleDeckGroups`, *"left to
right, in SIGNAL-FLOW order: pitch → filter → amp, then the two instance-wide groups"*), with
each group's cells in the semantic order §6.2's table freezes. Identity order and
presentation order are the same sequence; the block table in §6.2 stands unchanged, because
it was already authored in this order.
**Why the recommendation goes to signal flow:** the visual layout is a canvas-constrained
artifact that has already moved twice (Θ-W6-T1 grew the floor 840 → 980, Γ-W3-T1 takes it to
1190 and re-rows every group), and within-row order is decided by *width fitting*, not by
meaning. Freezing the host-facing order to a layout that demonstrably moves is the wrong
coupling; signal flow has never moved and is the deck's own stated rule. §3.6 asked whether
the unit tree mirrors the visual grouping or the signal flow and noted "they currently
agree" — the reflow is exactly what makes them stop agreeing, so the question has to be
answered rather than deferred again.
**The rejected alternative was the editor's visual rows** after Γ-W3-T1 — row 1 PITCH/RATE,
FILTER, VELOCITY, VOICE; row 2 PITCH ENV, FILTER ENV, AMP; MASTER spanning. Same membership,
different sequence. Do not re-propose it: the id table is frozen from Γ-W4-T1, so re-ordering
is no longer a preference question at all.
**Why it is nonetheless Daniel's:** the counter-argument is real — a user hunting a
parameter in REAPER's flat list finds it fastest if the list reads in the order their eyes
scan the plugin window. If Daniel weights find-it-on-screen over layout-independence, visual
order is the right answer and it is not a mistake; it just also means the parameter order
stops being free the next time the deck is re-laid.
**Why signal flow — the argument a future reader will come looking for.** The obvious
objection to this table is that its numbering does not match what is on screen, and someone
will eventually propose "fixing" that. The answer is that the two *cannot* both be satisfied
forever, and only one of the two axes holds still:
**Grouping is settled either way: `IUnitInfo`, one unit per deck group**, mirroring the
group inventory (not the rows). Only the sequence is at issue.
> **The editor's visual layout has already moved twice** — Θ-W6-T1 grew the window floor
> 840 → 980, and Γ-W3-T1 takes it to 1190 and re-rows every group into two categorical rows
> with a double-height MASTER. Within-row order is decided by *width fitting*, not by meaning.
> **Binding a permanently-frozen id order to a demonstrably mobile layout guarantees the two
> drift apart** — and after the first drift the order is neither logical *nor* matching, which
> is strictly worse than either. **Signal flow is the axis that does not move.** It is also
> the deck's own stated rule, so the order is not a new invention that has to be remembered;
> it is the one the codebase already documents.
The counter-argument, recorded because it was real: a user hunting a parameter in REAPER's
flat list finds it fastest if the list reads in the order their eyes scan the plugin window.
That is answered — partly — by `IUnitInfo` grouping, which lets a host present the parameters
under their group names rather than as one flat run. It is not fully answered, and that
residual cost is accepted knowingly.
§3.6 asked whether the unit tree mirrors the visual grouping or the signal flow and noted
"they currently agree"; the reflow is what makes them stop agreeing, and this ruling is the
answer.
**Grouping: `IUnitInfo`, one unit per deck group**, mirroring the group inventory (not the
rows) — unchanged by the ruling, which decided only the sequence.
### 6.5 What the plugin must implement, as behaviour
@@ -448,21 +526,21 @@ Not architecture — the engineer's call is *how*. These are the observable cont
- **`getParameterCount` / `getParameterInfo`** report exactly the §7 list, in §6.4's order,
each with: the frozen `id`; a `title` that survives truncation and a distinct
`shortTitle`; `units` (`"ms"`, `"st"`, `"%"`, `"dB"`, `""` for exponents) matching what the
editor prints, so the host's readout and the knob's label never disagree; `stepCount = 0`
(every exposed parameter is continuous — §7.2 is why); `defaultNormalizedValue` per §8's
exactness rule; the group's `unitId`; and `kCanAutomate`.
`shortTitle`; `units` and a plain range per **§6.7**, which is where Ruling 3's real-unit
contract lives; `stepCount = 0` on all 44 (§6.7's sweep); `defaultNormalizedValue`
**computed** per §6.7's tightening of §8's exactness rule; the group's `unitId`; and
`kCanAutomate`.
- **`kIsBypass` is not set on anything.** The limiter is a safety device, not a bypass, and
binding it to `kIsBypass` would hand the host a control that restarts the component
(§3.8). The plugin exposes **no** bypass parameter — it is an instrument.
- **`setParamNormalized` / `getParamNormalized`** are the model projection of §6.1, not a
cached shadow value.
- **`normalizedParamToPlain` / `plainParamToNormalized` / `getParamStringByValue` /
`getParamValueByString` all route through the ONE taper module Γ-W1-T1 extracts.** This is
the structural point of that extraction: the host's normalization, the knob's needle
angle, and the overlay node's position must be the *same function*, not three functions
that agree today. `getParamStringByValue` reuses the editor's own formatters
(`formatEnvTimeMs` and its peers) for the same reason.
`getParamValueByString` all route through the ONE taper module Γ-W1-T1 extracts and the ONE
formatter per unit category.** This is the structural point of that extraction: the host's
normalization, the knob's needle angle, and the overlay node's position must be the *same
function*, not three functions that agree today. **§6.7 states both invariants in full** —
it is the specification for this bullet, not a gloss on it.
- **`IParameterChanges` is observed at BLOCK boundaries** — the last point in the block wins
for that block (§3.7). Sample-accurate application would put a per-sample "did anything
change" question on the per-voice-per-sample path, which the phase-wide guardrail forbids.
@@ -499,6 +577,245 @@ restores the model and the parameters are derived from it. Three things are none
automation against the same plain values — the round trip through the frozen taper is
exact at both ends.
### 6.7 Real units at the host boundary — Ruling 3
**Ruling numbering.** `docs/PLAN.md` numbers Daniel's Phase-Γ rulings in the order they
arrived: **Ruling 1** = VST3 parameters ship in Γ; **Ruling 2** = the 10 s ceiling and the
knob-interaction law it grew; **Ruling 3** = this one. The same numbers are used here.
> **Ruling 3 (Daniel, 2026-08-01):** *"The parameter values exposed to the VST host should be
> in real units, such that the host automation lanes report usable values."*
**This is satisfiable, and the mechanism matters, because the naive reading of it is
impossible.** VST3's automation *wire format* is normalized `0..1` and cannot be anything
else: `setParamNormalized` / `getParamNormalized` (`ivsteditcontroller.h:429-433`) are the
interface, and what the host writes into a lane and replays from it is a normalized double.
There is no VST3 in which a lane stores milliseconds.
What delivers the requirement — **usable values in the host's UI and lane readouts** — is the
**plain-value layer**, which the vendored SDK provides and which we are simply not using yet:
| Surface | Where | What it does |
|---|---|---|
| `Parameter::toPlain` / `toNormalized` | `public.sdk/source/vst/vstparameters.h:88-90` | **virtual** — overriding them is how a non-linear law is expressed to the host |
| `RangeParameter` | `vstparameters.h:110-138` | carries plain min/max and does the *linear* mapping for free |
| `getParamStringByValue` | `ivsteditcontroller.h:418` | the display string the host shows in its readout |
| `ParameterInfo::units` | `ivsteditcontroller.h:52` | the unit string (`"ms"`, `"st"`, `"dB"`, `"%"`, `"Hz"`) |
| `normalizedParamToPlain` / `plainParamToNormalized` | `ivsteditcontroller.h:424-426` | the controller-level pair a host calls to read a lane in plain units |
**The direct precedent is in the vendored tree.** `public.sdk/samples/vst/common/logscale.h`
overrides exactly this pair for a logarithmic law — `toPlain` returns `logScale.scale(norm)`,
`toNormalized` returns `logScale.invscale(plain)` (`logscale.h:221-229`), with `toString`
printing the *plain* value. That is precisely the shape our log-tapered ms knobs and the
log2 semitone knobs need. We are not inventing a mechanism; we are using the one the SDK
ships and Steinberg's own sample demonstrates.
#### 6.7.1 Every exposed parameter declares a plain range, a unit, and a display precision
Eight unit categories, covering all 44. **Precision and formatting are a product decision
about what a user reads in a lane, and they are decided here.**
| Category | Parameters (count) | `units` | Plain range | Display | Taper (norm → plain) |
|---|---|---|---|---|---|
| **Time** | the 14 stage times | `ms` | 0 … 10000 | one decimal below 10 ms, integer at and above (`0.5`, `3`, `104`, `10000`) | log (Γ-W1-T1) |
| **Semitones** | Pitch offset, Pitch env depth (2) | `st` | 24 … +24 | one decimal, **always signed** (`+3.5`, `-12.0`, `+0.0`) | log2, centre-expanded (Γ-W1-T1) |
| **Percent, unipolar** | amp/filter sustain, three hold fractions, Trigger length, morph (7) | `%` | 0 … 100 | integer | identity ×100 |
| **Percent, 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
@@ -604,8 +921,8 @@ are new; three are already frozen for an unrelated reason and are recorded so th
|---|---|---|
| 1 | **The ms and semitone tapers** (`ui/deck_values`) | Γ-W1-T1. Known. The taper *is* the host normalization. |
| 2 | **`kEnvTimeMaxSeconds` / `kGateStageMaxSeconds` 2.0 → 10.0** | Γ-W1-T1, per Daniel's ruling. A range endpoint is normalization. Γ-F3 reversed. |
| 3 | **Every default must have an EXACT normalized preimage** — NEW | Γ-W1-T1 acceptance criterion. `ParameterInfo::defaultNormalizedValue` is normalized, so a host's reset-to-default arrives as `toPlain(defaultNorm)`. Γ-W1-T1 makes `resetDeckParam` **bypass** the taper, which fixes the editor's reset and **cannot fix the host's** — the host has no bypass. The taper must therefore be designed so every default round-trips exactly, or the host's reset lands a hair off and the "double-click lands exactly on its default" criterion holds on one surface and not the other. **This is the sharpest new finding in the sweep.** |
| 4 | **Do NOT re-taper the filter's four normalized controls** — NEW | Γ-W1-T1 exclusion. Cutoff/Q/morph/drive persist as `cutoffNorm`/`resonanceNorm`/`morphNorm`/`driveNorm` in the **v9 payload** — their laws are *already* wire-frozen today, and re-mapping them would re-tune every saved project independently of automation. §4.2's snap-unit table names them, which is a display concern; nothing in the taper pass may touch their norm↔plain law. |
| 3 | **Every default must have an EXACT normalized preimage** — NEW | Γ-W1-T1 acceptance criterion. `ParameterInfo::defaultNormalizedValue` is normalized, so a host's reset-to-default arrives as `toPlain(defaultNorm)`. Γ-W1-T1 makes `resetDeckParam` **bypass** the taper, which fixes the editor's reset and **cannot fix the host's** — the host has no bypass. The taper must therefore be designed so every default round-trips exactly, or the host's reset lands a hair off and the "double-click lands exactly on its default" criterion holds on one surface and not the other. **This is the sharpest new finding in the sweep.** **Tightened by Ruling 3 — see §6.7.7**: `defaultNormalizedValue` is computed rather than written, the assertion moves onto `toPlain`, and arbitrary-point round-trip exactness is explicitly NOT required. |
| 4 | **Do NOT re-taper the filter's four normalized controls** — NEW | Γ-W1-T1 exclusion. Cutoff/Q/morph/drive persist as `cutoffNorm`/`resonanceNorm`/`morphNorm`/`driveNorm` in the **v9 payload** — their laws are *already* wire-frozen today, and re-mapping them would re-tune every saved project independently of automation. §4.2's snap-unit table names them, which is a display concern; nothing in the taper pass may touch their norm↔plain law. **Ruling 3 does NOT conflict with this** — reporting Hz/Q/drive-depth to the host means *calling* the frozen laws, not replacing them; §6.7.5 works it out and names the one additive gap (drive has no published inverse). |
**Frozen by this work — record only, no change wanted:**
@@ -623,7 +940,10 @@ are new; three are already frozen for an unrelated reason and are recorded so th
- **The params-payload ladder.** Under §6.1 the blob stays authoritative, so payload growth
continues on its own independent axis exactly as before. Automation adds no constraint to
it.
- **`stepCount`.** No discrete parameter is exposed (§7.2), so no step count is frozen.
- **`stepCount`.** No discrete parameter is exposed (§7.2), so no step count is frozen — swept
explicitly over all 44 under Ruling 3 and confirmed: **every one ships `stepCount = 0`**, and
the coincidence is structural rather than lucky. §6.7.6, which also forbids the specific
mistake of exposing the editor's shift-snap grid as `stepCount`.
- **Hold fractions, sustain level, Trigger length.** Natural `[0,1]`, identity taper, nothing
to freeze beyond the identity.
- **The limiter's 0.3 dBTP ceiling, `kMaxCurvePoints` = 128, the editor floor/budget