Skip to content

Content vocabulary

Lute is a general authoring toolchain; your game is a consumer of it. So a concrete list of emotions does not live in the compiler — the compiler declares the vocabulary slots, and your project declares the members. Since language 0.9.0 the built-in lute.core plugin ships an empty enums:; there is no baseline list to override, repudiate, or work around.

The consequence is the other half of the trade: using a slot nothing declares is an error.

error [E-DOMAIN-UNKNOWN] `emotion` is not a declared domain — declare its members in an `enums:`
block in this document's own frontmatter, in a project schema reached through `uses:`, or in a
plugin's `enums` export before using `emotion`

Before 0.9.0 six of the original slots carried closed members no route could extend, and action was skipped by the checker entirely whenever nothing declared it, so a misspelling like action="step-foward" shipped silently. Both special cases are gone.

A slot is a named domain the language binds to an authoring position. The name is the language’s; the members are yours.

SlotBound at
emotioncontent line emotion=, ::actor{emotion}
actioncontent line action=, ::actor{action}
anchor::actor{anchor}
costume::actor{costume}
framing::camera{framing}
cameraMove::camera{move}
transition::camera{transition}
cgLayout::cg{layout}
sequence::sequence{name}
mood::music{mood}
volume::music{volume}
musicPlayback::music{playback}; a long form may add optional stops: members that end current music
vfxType::vfx{type}
textStylean inline style span :name[…] in line text

Where the slot name and the attribute name differ (::camera{move} fills cameraMove, ::music{playback} fills musicPlayback), the diagnostic names the slot. The camera, CG, costume and sequence slots are how a project expresses staging the core does not hard-code: a project whose camera shakes declares cameraMove: [shake] and writes ::camera{move="shake"}; see Core directives.

Not vocabulary. ::cg{display} and ::video{display} stay { enum: [show, hide] } declared inline on the directive — a two-member pairing the engine dispatches on, not a shared vocabulary. The delivery flags {mono} / {os} / {vo}, the reserved narrator speaker, the core text modifiers :pause / :speed, and the ::end tag are grammar.

A declaration is an enums: block. A bare sequence is shorthand for { members: [...] }, so every pre-0.9.0 block still parses byte-for-byte.

enums:
emotion: [neutral, surprised, delighted, shy, content, angry, sad]
anchor:
members: [left, center, right]
default: center
action:
members: [fadeInUp, sway, lean, idle, fadeOut, hide]
exits: [fadeOut, hide]
mood: [peaceful, tense, romantic, sad, upbeat]
volume: [silent, quiet, normal, loud, full]
musicPlayback: [start, change, stop, resume, fadeOut]
vfxType: [whiteOut, blackOut, rain, snow, leaves, petals, raindrop]
framing: [wide, medium, close]
cameraMove: [pushIn, pullOut, shake]
transition: [cut, dissolve, fade]
textStyle: [emphasis, whisper]

volume members are absolute levels, an ordered scale of degree like silent, quiet, normal, loud, full — never moves relative to the current level: writing quiet twice plays at the quiet level, not quieter (0.42). A player turns each member into a gain through the volumes: table of player.bindings.yaml. Before 0.42 the documented scale was silent, down, normal, up, full; lute fix renames down → quiet and up → loud in a project whose volume domain is exactly that old scale, and leaves a project’s own levels alone (lute fix).

There are three routes to that block, and all three go through the same parser and the same validator.

  1. Inline — in the using document’s own frontmatter. enums: is a universal frontmatter key, so any document (scene, quest, schema) may declare the vocabulary it uses right where it uses it. This is the only route open to a single-file author and to the playground, which checks one in-memory document and can resolve no import.
  2. Imported — in a project schema reached through uses: or extends: (see State schemas and Imports). The route a multi-document project should prefer: declare once at the root and let every document reach the same members. docs/examples/base.schema.yaml is one.
  3. Plugin — in a capability plugin’s enums export (see Manifests). The route an engine or genre pack uses to ship a vocabulary to every project that activates it. showcase.pack is one. Unlike the other two it surfaces on the capability snapshot — it lands under lute context --json’s enums and so moves capabilitySnapshot, where the two project routes land under the separate projectEnums key and move neither.

Declaration is per project root — a directory with a lute.project.yaml. A sibling root’s declaration never reaches into yours.

Precedence when two routes declare one slot

Section titled “Precedence when two routes declare one slot”

The two clashes behave differently, and the difference is worth reading twice.

A project route against a plugin is E-DOMAIN-DUP, and the plugin wins. This holds for both project routes — an inline enums: block and an imported schema’s are treated the same way. The project entry is dropped and reported, so subsequent values are judged against the plugin’s members:

error [E-DOMAIN-DUP] domain `emotion` is declared by this project — in a document's own `enums:`
frontmatter or in a project schema reached through `uses:`/`extends:` — but already exists in the
plugin/core vocabulary; a domain name must be declared by exactly one source, so drop the project
declaration or the plugin's `enums` export (the plugin's wins)
error [E-BAD-ENUM] `furious` is not a valid value for `emotion` of `::narrator`
(expected one of: neutral, delighted, pensive)

Pick one route per slot. Since the core ships no members, this only ever fires against a plugin you activated.

Inline against imported is not a clash — the inline declaration wins, and it must be a superset. A document may re-declare a slot its schema already declares in order to add members, and the inline list then governs. Dropping a member the base declared is E-EXTENDS-RELATION-SIG:

error [E-EXTENDS-RELATION-SIG] enum `emotion` is missing base member(s) ["delighted"]; an inline
re-declaration must re-declare a superset of the imported base's members

This is deliberately not E-DOMAIN-DUP. That code is reserved for clashes involving a plugin.

Two slots have members the compiler branches on, and it will not guess which.

  • A declaration of action MUST supply exits: — the members that end a character’s presence on stage. A member listed there lowers to an actor record carrying exit: true; a member not listed does not.
  • A declaration of anchor MUST supply default: — the member used when a character is shown without an explicit anchor. It is injected as an actor record with provenance.by: "auto-anchor-on-show".

Both used to be hardcoded: exits were detected by name (fadeOut* / exit* / hide) in two hand-synced copies, and the default anchor was a DEFAULT_ANCHOR = "center" constant in the checker. Omission is now an error rather than a fallback, because a silent fallback to a name prefix is exactly the hidden coupling 0.9.0 removes. The language owns the knowledge that these two slots need member semantics; it does not know which members satisfy them.

For every other slot exits: and default: are meaningless and rejected, so a typo cannot hide in an ignored key. default: and every exits: entry must itself be a declared member.

CodeFires when
E-ENUM-MISSING-SEMANTICSaction declared without exits:, or anchor without default:
E-ENUM-UNEXPECTED-SEMANTICSexits: or default: on a slot that has no such semantics
E-ENUM-DEFAULT-NOT-MEMBERdefault: is not one of members:
E-ENUM-EXITS-NOT-MEMBERan exits: entry is not one of members:

Neither key is serialized into the artifact. The compiler has already resolved them into actor.exit and the emitted anchor, so an engine needs no member semantics at runtime.

lute init scaffolds a starter vocabulary covering emotion, anchor, action, mood, volume, musicPlayback and vfxType into a vocabulary.schema.yaml the generated scene imports — an opinionated template, not a rule, in a file you own and edit. Add the camera, CG, costume, sequence and text-style slots when the project first stages one.

lute doctor --project <DIR> reports which slots a root has declared, with the member semantics inline:

$ lute init demo && lute doctor --project demo
lute doctor — demo
• toolchain version: 0.10.0
• language version: 0.10.0
• IR schema version: 0.10.0
✓ lute.project.yaml: found at demo/lute.project.yaml
✓ content documents: 1 `.lute` file(s) under demo
• provider snapshots: no providers/ directory (core-only project)
• vocabulary slots declared: emotion, action (exits: fadeOut/hide), anchor (default: center), mood, volume, musicPlayback, vfxType
• VS Code extension: not detectable from the CLI

A root that declares nothing reports vocabulary slots declared: none.

For a project that declares inline or through uses:/extends:, the compiled artifact’s enums array becomes populated — the artifact is self-describing about the vocabulary it was compiled against. A plugin-supplied vocabulary does not appear there; it is capability surface, and shows up in capabilitySnapshot and lute context --json’s enums instead. Either way this is an artifact-content change only: the vocabulary work added, renamed, and moved no IR field. (irVersion reads "0.10.0"; the shape change that number carries is a single provenance-field rename, unrelated to vocabulary.)

Known limitation: a component body resolves against the importing document

Section titled “Known limitation: a component body resolves against the importing document”

State it plainly, because it will bite someone. A component’s own uses: and its own inline enums: are both discarded at parse. The resolved component carries only params, body, and src, so once its body is folded into an importing document the vocabulary it checks against is the importing document’s.

So a component naming a slot only it declares passes standalone and fails through a ::use:

$ lute check c.component.lute # its own uses: declares emotion, its own inline enums: declares vfxType
ok: c.component.lute (0 warning(s))
$ lute check s.lute # components: [c.component.lute], scene declares no vocabulary
s.lute:1:1: error [E-DOMAIN-UNKNOWN] component `reaction` (…/c.component.lute): `emotion` is not a declared domain — …
s.lute:1:1: error [E-DOMAIN-UNKNOWN] component `reaction` (…/c.component.lute): `vfxType` is not a declared domain — …
failed: s.lute (2 error(s), 0 warning(s))

The diagnostic anchors at the ::use site — the importing document’s frontmatter — with a prefix naming the component and its file.

Consumers own the vocabulary imports. The practical rule: keep the declaration at the project root and let both the scene and the component reach the same one, which is what docs/examples does. A component schema surface — a component declaring its own imports and ::use carrying them into the expansion — is a named future direction filed separately, not part of this release.

This is a scoping limit, not a checking divergence. Every content-line rule now runs on both routes: 0.9.0 closed five stages of check() that used to be root-only, so a component body no longer checks clean through a ::use while the same lines report at scene level. See Components & extends.

Vocabulary declarations are reflected in compiled artifacts and capability reports, not in a human-readable transcript. For machine consumers, use the report envelope (command, schemaVersion, ok) and treat lute context --json as the capability surface. The CLI command tree and output schema are available from LUTE_TELEMETRY=0 target/debug/lute schema context.