Parameter Design
Ownership and Lifecycle
Code owns the schema; the user owns the value. A parameter's name, style, page, range, default and help text are yours to declare and re-assert. Its value is the user's, and nothing you write may quietly discard it.
Extensions reinitialize on every source-file save, so every creation path must be get-or-create, never create-blindly:
def ensureCustomPage(comp, name):
for page in comp.customPages:
if page.name == name:
return page
return comp.appendCustomPage(name)
def ensureCustomPar(comp, page, name, style, **attrs):
"""Get-or-create one custom par: the Par, or the ParGroup for a tuplet."""
# Probe the WHOLE COMP by tupletName. Names are a flat per-COMP namespace,
# and a multi-value style (RGB, XYZ) is stored as <name>r/<name>g/<name>b,
# so a probe by .name never finds it and append*() (replace=True) would
# re-create it on every reinit.
found = [p for p in comp.customPars if p.tupletName == name]
if not found:
getattr(page, 'append' + style)(name)
found = [p for p in comp.customPars if p.tupletName == name]
elif found[0].style not in STYLE_FAMILY.get(style, (style,)):
raise ValueError('par %s is %s, declared %s -- refusing to replace it'
% (name, found[0].style, style))
for p in found: # symmetric: schema attrs re-applied
for key, value in attrs.items(): # on every reinit; the user's state
if key not in ('val', 'expr', 'bindExpr', 'mode', 'export'):
setattr(p, key, value) # is never touched -- and a typo RAISES
return found[0] if len(found) == 1 else found[0].parGroup
# TD reports a tuplet's style by family: RGB reads 'RGBA', XY/XYZ read 'XYZW'.
STYLE_FAMILY = {'RGB': ('RGB', 'RGBA'), 'XY': ('XY', 'XYZW'), 'XYZ': ('XYZ', 'XYZW')}
Four rules that make it safe:
- Probe the whole COMP, not the page. Custom parameter names are a flat per-COMP namespace, and
append*()defaults toreplace=True-- a page-scoped probe will happily destroy a same-named par living on another page. - Re-read after appending, by
tupletName.append*()returns a ParGroup, and a multi-value style is stored as component pars (Tintr/Tintg/Tintb) whosetupletNameis the name you declared --comp.par['Tint']isNone. Embody ships this exact helper asop.Embody.op('embody_pardef').module.ensureCustomPar. - Never
destroy()on style drift.Par.destroy()takes the user's value, expressions and exports with it. Report the mismatch and refuse; removal is a separate, deliberate act. - Apply declared attributes symmetrically. If you set
minwhen creating, set it when the par already exists too, or a schema change never reaches existing installs.
Parameter Callbacks
One dispatcher, not one promoted method per parameter. A parexec DAT that grows an elif par.name == ... chain drags a public-tier method onto the COMP for every branch (see the three tiers in td-python.md). Name the handler for its parameter instead and let the DAT find it:
# the WHOLE onValueChange in the parexec DAT
def onValueChange(par, prev):
ext = parent.MyComp.ext.MyExt
handler = getattr(ext, '_on%sValueChange' % par.name, None)
if handler is None:
return
try:
handler(par, prev)
except Exception as e:
parent.MyComp.Error('%s handler failed: %s' % (par.name, e))
# on the extension -- tier 3, invisible on the COMP
def _onSpeedValueChange(self, par, prev):
self.ownerComp.op('./timer1').par.speed = par.eval()
def _onResetPulse(self, par):
self.rebuild()
- Audit once at init that every
_on*handler matches a real parameter. A renamed par turns its handler into dead code that fails no test; the audit turns that into a warning. - Never swallow the exception silently. A handler that raises into nothing is indistinguishable from a parameter that does nothing.
- Pulse handlers take
(par); value handlers take(par, prev).
Parameter, Storage, or Dependency?
Custom parameters are one of three ways to hold state on a COMP, and the choice is about lifetime, not taste:
| The state is | Use | Lives on |
|---|---|---|
| User-facing, persisted, part of the COMP's interface | custom parameter | the COMP |
| Durable bookkeeping the user should not see | storage |
the COMP |
| A derived value that must recook whatever reads it | tdu.Dependency |
usually the extension instance |
What survives what. Probed on TD 2025.33070; undocumented, so re-verify on another build.
| Event | Custom par | storage |
Dependency on the extension |
|---|---|---|---|
Extension reinit -- source .py edit, initializeExtensions() |
survives | survives | destroyed |
External-tox reload -- enableexternaltoxpulse, COMP restore |
survives | wiped | destroyed |
.toe / .tox save and load |
survives | survives | survives only if stored |
storage lives on the COMP, not on the extension instance, so a hot-sync reinit does not clear it -- the common belief that it does is wrong. What clears it is the COMP's contents being replaced: a tox reload, a TDXN reconstruction, a restore. Embody hits that case, which is why Envoy's state machine trusts the Envoystatus parameter rather than its envoy_running store.
Do not reach for a Dependency where a parameter works -- a par is inspectable, persisted, exportable and free. Reach for one when a computed value has no business on the user's parameter dialog but still has to invalidate whatever reads it. A Dependency assigned to self in __init__ dies with the instance on every source save; rebuild it there, never treat it as persistent.
Publish the value; do not push it
The common failure (Function Store, issue #94): code recomputes something and then pushes it -- other.par.Value = x on each consumer, or a method call on each -- where it should publish ONCE as a dependable and let consumers pull. A push must enumerate its consumers, so it goes stale the moment someone adds a reader, fires whether or not the value changed, and inverts the cook model TD already gives you.
# push -- every new reader is a new line here, and nobody else knows
for w in self.widgets: w.par.Scale = value
# publish -- readers subscribe by reading; adding one costs nothing
self.Scale.val = value # tdu.Dependency built in __init__
# reader: parent.Host.Scale.val (or an expression, which recooks on change)
Publish through tdu.Dependency (derived runtime state), store() (durable bookkeeping), or a custom parameter (user-facing). Push is still right for exactly one case: a consumer that is NOT dependency-aware -- an external process, a file, a network peer.
What actually notifies dependents
The whole point of choosing between these is whether a change reaches the expressions that read it. Measured on 2025.33070 with a CHOP parameter expression as the dependent:
| Change | Dependent expression | Cooked |
|---|---|---|
comp.store('k', 99) -- immutable |
11 -> 99 | yes, same frame |
comp.storage['k'] = 55 -- direct dict write |
still reads 1 (value really is 55) | no |
comp.fetch('lst').append(4) -- in-place mutation |
still reads 3 (list really is 4 long) | no |
self.Scale = 42 -- plain extension attribute |
still reads 5 (attribute really is 42) | no |
self.DepScale.val = 77 -- tdu.Dependency |
-> 77 | yes |
Three of those five are silent: the value is genuinely updated and every print looks right, but nothing downstream hears about it. That is the entire failure mode.
- Always write through
store(), nevercomp.storage[k] = v. Re-storing an unchanged value still notifies, sostore(k, fetch(k))is the documented way to publish an in-place mutation. - A plain extension attribute is not dependable. This is exactly what
tdu.Dependencyexists to fix -- not storage, which is already dependable for immutable values.
Mechanics worth knowing
fetch(key)with no default RAISES, it does not returnNone--tdError: The fetched item was not found and no default was specified. Pass a default, or usefetchOwner(key)(returnsNone) to test presence. Derivative's reason: a stored value could legitimately BENone.fetch()searches UP the parent chain by default. A missing local key silently resolves to an ancestor's value of the same name.search=Falsefor local-only;fetchOwner()names the operator that answered.unstore()is glob-matched, not a literal key delete. Verified:unstore('sales*')removedsales_aandsales_band leftkeep. Never pass a computed or user-derived key -- one containing*,?or[will take its siblings with it.- "You cannot store operator references" is a
StorageManagerrule, not a storage rule. Rawstore()takes any Python object and TD's docs demonstrate storing an OP; verified round-tripping one through a.tox.StorageManageris stricter because it pickles -- store a.pathstring there. - Storage is pickled into the
.toe/.toxat save. An unpicklable value logs a warning and vanishes on reload while the save itself succeeds, so it is easy to miss. Locks,threading.Event, sockets and file handles are out -- and so is an instance of a class defined in a DAT, which stops pickling the moment that DAT recompiles. A file-synced.pyedit does that routinely, which makes it an Embody-shaped hazard specifically. storeStartupValue()writes a separate startup dictionary that overrides the saved value on load. Use it to force a known state on open and to keep session state out of the file.
Dependency: the two idioms, and the trap
import TDFunctions as TDF
class MyExt:
def __init__(self, ownerComp):
self.ownerComp = ownerComp
TDF.createProperty(self, 'Scale', value=1.0, dependable=True) # expression: op('c').Scale
self.Raw = tdu.Dependency(5) # expression: op('c').Raw.val
The property's name is its access tier. createProperty(self, 'MyProperty', ...) makes a capitalized instance attribute, and TD promotes those onto the COMP exactly like methods -- a dependable property named Upper IS tier-1 public API (the promoted-surface census counts them). Capitalize it only when users/agents should read it off the COMP; internal state stays _lower or lower.
The two idioms need different expressions, and swapping them fails quietly: the raw form read as op('c').Raw evaluates the Dependency object, which is always truthy and never changes.
self.Raw = 5destroys the Dependency -- it rebinds the attribute to a plain int and removes the cook dependency. Nothing raises, and a Dependency reads as its underlying value, so everyprintand comparison still looks correct. Writeself.Raw.val = 5..peekValreads without creating a dependency; reading.valinside an evaluating expression is what forms it.dep.opslists the operators currently dependent on it -- the right tool for "why is this cooking".- Mutating a container inside a Dependency notifies nothing. Call
.modified(), or hold it inTDStoreTools.DependDict/DependList/DependSet(those names live inTDStoreTools;tdu.DependableDictdoes not exist on any build). NoteDependDictsubclassesMutableMapping, notdict, soisinstance(x, dict)isFalseandjson.dumpsraises -- usegetRaw()for plain data. - A bind expression to a Dependency is BI-DIRECTIONAL (Function Store, issue #94; verified 2026-08-30 on 2025.33070). Dependency objects are legal bind MASTERS: a par with
bindExpr = "me.fetch('probedep')"in bind mode read the Dependency's value, followeddep.val = 7.5immediately -- and setting the BOUND PAR wrote 3.25 back intodep.val, with the par staying in bind mode. The value lives at the master, so a bound par is a two-way control surface for extension state, not a read-only view. Choose bind mode deliberately: an expression-mode reference is one-way by construction. dep.callbacksleaks across a hot reload. Appending a bound method holds a strong reference to the extension instance, so every source save adds another live subscriber and one change fires N callbacks against dead state. It reads like a race, not a leak. Remove it inonDestroyTDby copying the list, mutating, and reassigning -- an in-place.append/.removeis documented as insufficient.- Both are main-thread only. Setting
.valdirties dependents and runs callbacks synchronously;store()participates in the cook model. Resolve values on the main thread and hand plain data to a worker.
Help Text
Every custom parameter MUST have help text set via par.help = "...". Help text appears as a tooltip when users hover the parameter name in the dialog. Describe what the parameter controls and what its values mean.
- Good:
"Maximum number of rows displayed in the manager list. Set to 0 for unlimited." - Bad:
"Max rows"(just restates the label) - Unacceptable: no help text at all
In TDXN files, include "help": "..." in the parameter definition. Embody exports and imports help text automatically.
Section Breaks
Use par.startSection = True on the first parameter of each logical group. This draws a horizontal separator line above the parameter, visually grouping related controls.
In TDXN: "startSection": true in the parameter definition.
Parameter Ordering
Parameters appear in the order they are appended. Keep related parameters together and maintain a logical flow within each page:
- Primary controls first (what users interact with most)
- Secondary/advanced settings after
- Read-only status/info parameters last
If reordering after creation, use par.order (accepts float values like 11.5 to insert between existing positions).
Page Organization
Group parameters into pages by function. Use comp.appendCustomPage('PageName') -- pages appear in creation order.
Common patterns:
| Page | Purpose |
|---|---|
| Main / Settings | Primary configuration |
| Tags | Externalization tags, strategies |
| UI | Visual and display options |
| About | Version, build, author (read-only) |
Naming
- First letter MUST be uppercase, rest lowercase letters and numbers only
- No underscores, spaces, or special characters
- Examples:
Speed,Maxrows,Autosave,Envoyenable
Style Selection
| Use case | Style | Notes |
|---|---|---|
| On/off toggle | Toggle |
Values are 0/1 |
| Fire-once action | Pulse |
No persistent value |
| Enumerated choices | Menu |
Set menuNames and menuLabels separately |
| Editable dropdown | StrMenu |
Free-text input with suggestions |
| Numeric value | Float or Int |
Set range properties (see below) |
| Text input | Str |
Free-form string |
| File/folder path | File / Folder |
Opens system dialog |
| Operator reference | OP, COMP, TOP, CHOP, SOP, DAT, MAT |
Filtered by family |
| Section header | Header |
Visual label only, no value |
Numeric Ranges
For Float and Int parameters, configure the range:
| Property | Purpose |
|---|---|
min / max |
Minimum and maximum values |
clampMin / clampMax |
Whether to enforce min/max as hard limits (True) or allow values outside (False) |
normMin / normMax |
Slider range in the UI (what the slider covers visually) |
Example:
p = page.appendFloat('Speed', label='Speed')[0]
p.default = 1.0
p.min = 0.0
p.max = 10.0
p.clampMin = True
p.clampMax = False # Allow values above 10 via manual entry
p.normMin = 0.0
p.normMax = 5.0 # Slider covers 0-5, but values up to 10+ accepted
p.help = "Playback speed multiplier. 1.0 = normal speed."
Read-Only Parameters
Use par.readOnly = True for status and informational parameters that users should see but not edit (version, build number, connection status).
Defaults
Always set par.default = value. This enables "Revert to Default" in the TD parameter dialog and ensures TDXN round-trips produce consistent results.
.default never sets the value. After appendFloat + p.default = 5.0 the par still evaluates 0.0, and a later min/clampMin clamps it to the min, not the default (verified 2025.33230, issue #94). Set p.val = p.default -- only on the create branch of get-or-create, so a user's value is never overwritten.
Creating Custom Parameters
All page.append*() methods return a ParGroup (tuple-like), not a single Par. Index with [0] to get the Par object:
page = comp.appendCustomPage('Settings')
pg = page.appendFloat('Speed', label='Speed') # Returns ParGroup
p = pg[0] # Get the Par
p.default = 1.0
p.help = "Playback speed multiplier."
p.startSection = True
Sequence Parameters
Resizable parameter blocks (glslTOP uniforms, constantCHOP channels, mathmixPOP combines, and custom sequences). Function Store's note on issue #94 -- "building sequence pars takes a bit more thinking than it should" -- is fair, and it is because three separate things are easy to get wrong.
Reach the sequence, not the parameter. comp.seq.<name> from the operator,
or par.sequence from any block parameter. The individual parameters are named
<seq><index><par> (uniname0, uniname1), so never build those names by hand
when you can go through the sequence object.
numBlocks is the get-or-create. Assigning it grows or shrinks in one step
and is idempotent, which is what you want in an extension that reinitializes on
every source save. Use insertBlock(i) / destroyBlock(i) only when position
matters.
seq = comp.seq.uni
seq.numBlocks = max(seq.numBlocks, len(uniforms)) # grow, never shrink blindly
for i, u in enumerate(uniforms):
seq[i].par.uniname = u.name # block i, by index
Shrinking destroys values. destroyBlock / lowering numBlocks takes the
block's values, expressions and exports with it, exactly like Par.destroy()
(see Ownership and Lifecycle). Code owns the block COUNT only where the count is
genuinely derived; if the user can add blocks, grow to fit and leave the excess
alone.
In Embody's TDXN, sequences are stored by BASE name. sequences: is keyed by
sequence name, each value a list of block objects holding only non-default values
under base names (uniname, not uniname0). Two consequences worth knowing: the
default block count is probed from a throwaway instance rather than assumed to
be 1, so a sequence sitting below its type default still exports its full block
list and the shrink survives reimport; and blocks are created in import Phase 2.5,
before Phase 3 sets parameters, so the block parameters exist before anything
writes to them. Full detail: docs/tdxn/specification.md.