Dialogue & cast
Content is the spoken and narrated text of a scene. Every content line has the same shape:
@speaker{attributes}: the text they sayThe speaker selects the line’s kind:
- the reserved
narrator→ narration (speakerless); - any other speaker id → dialogue, carrying that speaker.
Frontmatter pov names the scene’s point-of-view character, falling back to the project’s
defaults.pov. It does not change how that speaker’s ordinary lines compile: the protagonist’s
lines are dialogue, and the artifact carries neither pov nor a player role. It does decide who
may speak {mono}. An engine that renders its protagonist differently (no
portrait, the player’s chosen name as the label) keys that on the speaker id; in line text, write
{{userName}} for the player’s name.
There is no separate thought or prose node — role is derived from the speaker plus its delivery (below).
@narrator: Venny's again. The chain restaurant that has never offended anyone.@marina{code="0010" emotion="delighted" variant="1"}: Mr. Fixer! You came back!@fixer{code="0010"}: I did.(From docs/examples/showcase/episode01.lute.)
Sections
Section titled “Sections”Content lives in sections. A section starts at a ## heading and runs to the next one; the
heading text is for the author and tools. A document has no body title — its title is frontmatter
title:, and a # heading in the body is E-INERT-TITLE.
A heading may end in a stable id, {#id}: a letter followed by up to 63 letters, digits, -
or _, unique among the document’s sections (E-SECTION-DUP otherwise). The id is not part of the
heading text:
---kind: sceneid: diner.nighttitle: Night Shift---
## The Counter {#counter}
@narrator: The diner hums.
## Closing Time
@narrator: The lights go out one by one.The artifact lists the sections in document order, numbered from 1:
"sections": [ {"section": 1, "heading": "The Counter", "id": "counter"}, {"section": 2, "heading": "Closing Time"}]A section’s number is also the first segment of every record position in it (001-0100 is the
first record of section 1). Positions and numbers follow source order, so they change when sections
move; tools that need a section to keep its identity across edits read its {#id}, and report a
section without one as having no stable identity. A section id is identity only: it is not a jump
target — ::jump goes to a ::label.
Line attributes
Section titled “Line attributes”Attributes in {…} are content metadata: code (a stable per-line id), emotion, variant,
action, dialogMotion, and as (a one-off speaker-label override). Their domains are project
vocabulary, not grammar — run lute context <file> to list the legal emotion/variant values
for your project. None is required; a missing code is back-filled deterministically at compile
time and can be persisted with lute tag.
Every line compiles with a lineId and a voiceKey — narration and {mono} lines included:
{"kind": "line", "family": "content", "position": "001-0100", "role": "narration", "speaker": "narrator", "text": "The diner hums.", "lineId": "diner.night.narrator_0010", "voiceKey": "diner.night.narrator-0010"}A voice key is a join key for recordings, not a claim that a recording exists; a project that does
not voice its narration simply has no asset under those keys. Two lines in a project that share a
voiceKey must say the same text (compared after inline modifiers are
stripped), or whole-project lute check reports E-DUP-VOICEKEY.
action= is the one line attribute with a stage consequence, and it is a
small one: it sets the speaker’s pose for that line and marks them dirty, so
the next plain line from the same speaker gets a posReset injected ahead of
it (provenance.by: "auto-pose-reset"). This is a delivery detail, not staging.
A character’s entrance and exit are any staging record lowered to actor (including
::actor); an exit: true field ends that character’s presence. ::clear (0.24.0) ends
everyone’s (see Core directives; lute context lists
mayExitCharacter among actor’s semantics for the core directive). Writing a member of the
action domain’s exits: list on a content line is W-EXIT-INERT: the pose is honoured, the
character stays on stage, and the artifact gets no exit record. Once a character has left — by
a declared actor exit, a bg staging record (including a plugin directive lowered to bg), or a
::clear — a line from them before an actor record stages them again is W-STAGE-ABSENT, judged
along every path through choices and <match> arms (see Stage state).
@marina{as="???"}: ...who's there?as overrides only the shown label for that one line. When absent, a line renders its speaker id
as the label, and the engine maps that id to a display name: the name the project’s
cast gives it. as= is the only way to set a label from the source. A richer
display-name capability — the
character/cast proposal,
with costumes and name-reveal — is a draft, and no such plugin ships yet.
The cast
Section titled “The cast”A project can declare its cast (dsl 0.23.0): the speaker ids its lines may use, each with an
optional display name. A schema document declares it under cast:, and the documents that import
the schema are checked against it:
cast: mira: { name: Mira } oskar: { name: "Oskar Lind" } vesna: {}A plugin can ship one as well, with a cast export of cast/*.yaml files in the same shape (see
Manifests), so an engine pack declares the characters its portraits and
voices exist for.
Once a cast is declared, every speaker must be in it. Scene lines, quest bodies, lore entries, and
bundle beats are all checked. A speaker outside the cast is
E-CAST-UNKNOWN, with a did-you-mean for a near miss such as @oskr. narrator is always a
speaker. The pov speaker is not special: a scene whose pov is fixer still needs fixer in the
cast.
./scenes/arrival.lute:12:2: error [E-CAST-UNKNOWN] speaker `fixer` is not in the declared castWithout a declared cast, speakers are checked for shape only, as before 0.23.0, so a project can
write dialogue before it settles its characters. The cast belongs to the project, not to one
scene: a cast: key in a scene’s frontmatter is E-META-UNKNOWN-KEY.
lute context <file> lists the cast with its display names (pass --project <DIR> to include a
plugin’s cast), and the language server offers the cast when it completes a speaker after @.
Display names shared by two speakers
Section titled “Display names shared by two speakers”Two speakers shown under the same name look like one person in the dialogue box. When several
authors add characters to one project, that happens by accident: two areas each write a
Hiker Gus. Whole-project lute check reports it as the advisory W-DISPLAY-NAME-DUP (dsl 0.26.0
§2.8), and lute lint reports it too. It compares two cast entries with exactly the same name:,
a cast name equal to a component’s ::use{… name="…"} display string, and two such strings for
different speakers (who=):
./scenes/a.lute:11:1: warning [W-DISPLAY-NAME-DUP] display name `Hiker Gus` is shown for 2 different speakers: `gus` (./scenes/a.lute:10), `gus2` (./scenes/a.lute:11) — the dialogue box cannot tell them apart; rename oneSome names are shared on purpose: a role several speakers play, such as a villain team’s rank and
file. Mark such an entry sharedName: true, and it is not counted:
cast: gus: { name: Hiker Gus } gus2: { name: Hiker Gus Jr. } grunt1: { name: Eclipse Grunt, sharedName: true } grunt2: { name: Eclipse Grunt, sharedName: true }A plugin’s cast/*.yaml entry takes the same key. A merge gate that runs
lute check --project . --deny-warnings passes again once the intended role names are marked, and
lute lint --deny W-DISPLAY-NAME-DUP promotes the code on its own. See
Multi-author projects.
Staging is checked against the cast too
Section titled “Staging is checked against the cast too”With a cast declared, the character a staging directive names must be in it, like a speaker (dsl 0.24.0 §4). ::actor{character} and ::camera{focus} outside the cast are E-CAST-UNKNOWN, with the same did-you-mean. Timeline clips and the bodies of <match> arms and choices are checked as well:
./scenes/ridge.lute:11:20: error [E-CAST-UNKNOWN] `::actor{character}` `corvn` is not in the declared cast — did you mean `corvin`?./scenes/ridge.lute:12:17: error [E-CAST-UNKNOWN] `::camera{focus}` `isold` is not in the declared cast — did you mean `isolde`?Per-speaker emotions
Section titled “Per-speaker emotions”A cast entry may list the emotions a character shows, emotions: [calm, fierce] (dsl 0.24.0 §4). Each member must also be a member of the project’s emotion enum. On a line by that speaker, or on a directive whose literal character= names them, an emotion= outside the list is E-BAD-ENUM, even when the emotion enum has the value:
./scenes/ridge.lute:14:18: error [E-BAD-ENUM] `sad` is not one of `isolde`'s emotions (expected one of: calm, fierce) — the cast declares `emotions:` for `isolde`A value the emotion enum itself rejects keeps its one existing error. A speaker without emotions: may use any member of the enum.
Presence: present:
Section titled “Presence: present:”In a party game, a companion is not always there to speak. A cast entry may say when they are, as a condition, present: "<condition>" (dsl 0.24.0 §4). The condition is ordinary state or facts, so party membership needs no new mechanism:
entities: person: { members: [isolde, corvin] }relations: inParty: { args: [person], tier: run }cast: isolde: { name: Isolde, present: "holds('inParty', ['isolde'])", emotions: [calm, fierce] } corvin: { name: "Corvin Hale" }A plugin’s cast/*.yaml entries take the same two keys (see Manifests).
The checker then asks, for every line by isolde, whether the guards around the line imply her present: condition. When they do not, the line is W-CAST-ABSENT:
@corvin: Where is Isolde?@isolde: Right here.@isolde{when="holds('inParty', ['isolde'])"}: Right behind you.<branch id="ask"> <choice id="call" text="Call her over" when="holds('inParty', ['isolde'])"> @isolde{emotion="calm"}: I came. ::retract{ inParty(isolde) } @isolde: And now I'm leaving. </choice> <choice id="wait" text="Wait"> @corvin: We wait, then. </choice></branch>::assert{ inParty(isolde) }@isolde: Back again.@isolde: Right here. has no guard, so it warns. The next line and I came. are guarded by the condition itself. And now I'm leaving. warns again, because the ::retract between the choice guard and the line falsifies what the guard said. The message names the condition and the guard that would satisfy it:
./scenes/camp.lute:12:2: warning [W-CAST-ABSENT] `isolde` may not be here: the cast declares `present: "holds('inParty', ['isolde'])"` for `isolde`, and the guards around this line do not imply it. Guard the line — `@isolde{when="holds('inParty', ['isolde'])"}` — or move it under a guard that implies itThe guards that count are the line’s own when=, every enclosing <choice when> (in a branch or a hub), <match> arms (the arm’s is= or test, and the fact that no earlier arm matched), <on when>, <objective done>, and the scene beat’s when:, an entry’s when= or a bundle beat’s when=. @defs in them are expanded. Some guards are implied by where the line sits:
- a lore entry’s body assumes its own
entry.<id>.read/everRead; - a quest
<on>handler assumes the quest’s state at that event, and the conjuncts of itsstartthat stay true once true (entry.<id>.everRead,visited(…)); - a quest’s
questCompletehandler also assumes the quest’s completion (dsl 0.27.0): the quest completed when one of its required objectives did, so one required objective’sdoneholds. A handler of a quest whose required objectives all need@withTobymay give Tobias a line without repeating the guard. An::assert,::retractor::setin an objective body or an earlierquestCompletehandler of the quest can cancel this assumption, like any other guard; - under whole-project
lute check, a beat assumes that every always-eligibleoncebeat ranked above it for the same occasion has already been spent.
A holds(A) guard over a derived relation also implies the bodies of A’s rules: with inParty(P) :- recruited(P), not departed(P), a line guarded by holds('inParty', ['isolde']) satisfies present: "!holds('departed', ['isolde'])". A relation whose rules are ground once bound, such as a cel()-only schedule, reads as the disjunction of those rule bodies.
A write cancels a guard only when it can falsify it on that path. An ::assert or ::retract voids a guard atom of the same relation with unifiable arguments, or one derived through a rule that has the written relation as a premise, and only when it moves the atom the wrong way. Asserting inParty(corvin) keeps a holds('inParty', ['isolde']) guard, and so does asserting a positive premise of it. A ::set voids a guard that reads the path, including through such a rule. Each && conjunct of a guard is judged on its own, and a write in one <choice> or <match> arm does not reach its siblings.
A {vo} line is exempt, since a voice-over is not the speaker being there: they may speak from outside the scene’s time, as a memory or a narration over it. An {os} line is still checked (dsl 0.25.0 D-E): {os} means the speaker is in the scene, heard but out of frame, so they must be present. There is no flag yet for a remote speaker, such as a voice over the radio or the phone.
Facts are the one place where lute check <FILE> and whole-project lute check differ. Whole-project lute check also counts the facts that hold on every route to the line: asserted earlier on every path, or seeded and never retracted. So the last line above, @isolde: Back again. after ::assert{ inParty(isolde) }, is clean there. A single-file lute check <FILE> cannot see those facts, so it warns on that line too and adds a note saying that whole-project lute check does see them. Like any warning, --deny W-CAST-ABSENT makes it an error.
Presence often has a second half that only the engine writes, such as “and she has not fallen”:
present: "holds('inParty', ['isolde']) && !holds('fell', ['isolde'])", where fell is a reserved: true
relation. The checker does not know when the engine writes fell, so without help only a guard at
each line or beat satisfies that half, and every line by isolde warns. A cast entry that adds
assume: true (dsl 0.24.0 §4) reads every negated holds of a reserved: relation in
present: as true, whether it is negated directly or through a rule such as
inParty(P) :- recruited(P), not fell(P). With it, the lines above warn exactly as they did with
the plain holds('inParty', ['isolde']) condition. The price is that a line spoken after the engine event
needs its own guard, since the checker no longer asks for one, unless the relation names the
events it changes on (changedOn, dsl 0.25.0):
cast: isolde: { name: Isolde, present: "holds('inParty', ['isolde']) && !holds('fell', ['isolde'])", assume: true }present:, emotions: and assume: are checker inputs. None of them reaches the compiled artifact.
Presence after engine events: changedOn
Section titled “Presence after engine events: changedOn”assume: true is right until the engine event that the reserved relation stands for: after a battle, fell(isolde) may well hold, and a line there is exactly where the check matters. The reserved relation can say on which occasions the engine changes it (dsl 0.25.0 §6):
entities: companion: { members: [isolde] }relations: inParty: { args: [companion], tier: run } fell: { args: [companion], tier: run, reserved: true, changedOn: [battleEnd] }cast: isolde: { name: Isolde, present: "holds('inParty', ['isolde']) && !holds('fell', ['isolde'])", assume: true }With changedOn, assume: true no longer reads !holds('fell', ['…']) as true in a unit presented on one of those occasions: a scene, entry or bundle beat answering on: battleEnd, or a quest <on event> handler or on= objective body judged there. Under whole-project lute check the same holds for every after-descendant of such a unit in the scenario graph, over its after: / after= / [start] edges. Occasions have no static order of their own, so the graph is what says “after the battle”. Take a camp scene that asserts inParty(isolde), an aftermath scene on battleEnd with after: 'visited("camp")', and a road scene with after: 'visited("aftermath")':
@isolde{when="holds('inParty', ['isolde'])"}: We should keep moving.@isolde{os}: Wait for me!@isolde{vo}: I remember that road.On the road, the first two lines warn again, each with a note saying why assume: true did not cover them. The {vo} line stays exempt:
./scenes/road.lute:11:2: warning [W-CAST-ABSENT] `isolde` may not be here: the cast declares `present: "holds('inParty', ['isolde']) && !holds('fell', ['isolde'])"` for `isolde`, and the guards around this line do not imply it. Guard the line — `@isolde{when="holds('inParty', ['isolde']) && !holds('fell', ['isolde'])"}` — or move it under a guard that implies it; `assume: true` does not cover `fell`: this line follows an occasion its `changedOn:` namesGuard those lines with the whole condition, @isolde{when="holds('inParty', ['isolde']) && !holds('fell', ['isolde'])"}, and the warnings go. Lines in the camp scene, which comes before the battle in the graph, stay covered by assume: true. Without changedOn, the 0.24 behaviour is unchanged: assume: true covers every line. changedOn on a relation that is not reserved: true is E-RELATION-DECL, and so is an occasion no plugin declares, with a did-you-mean (`changedOn: batleEnd` is not a declared occasion — did you mean `battleEnd`?). In a project whose plugins declare no occasions, the names are not checked.
Leaving the stage: ::clear
Section titled “Leaving the stage: ::clear”Presence is about who is with the player. Who is on stage is the stage state (see Stage state). An ::actor brings a character on, and a declared exit or a ::bg takes them off. Since 0.24.0 the leaf ::clear takes everyone off at once, leaving the background and music in place (see Core directives). A line from a cleared character before an ::actor shows them again is W-STAGE-ABSENT, and the warning names the ::clear.
Delivery flags
Section titled “Delivery flags”A delivery flag is a bare word in the braces (no =value) that changes how a line is
delivered:
{mono}— interior monologue / thought (not spoken aloud in-scene).{os}— off-screen: the speaker is heard but not currently staged or visible.{vo}— voice-over: narration-style delivery layered over the scene.
@fixer{mono}: An android, then. Which would, on reflection, explain the ramen.The three are mutually exclusive — at most one per line (E-DELIVERY-CONFLICT on two) — and
none is allowed on @narrator (E-DELIVERY-NARRATOR).
Roles derive from speaker + delivery, and the artifact’s role uses the same words: narrator →
narration; {mono} → mono; {os} → os; {vo} → vo; any character otherwise →
dialogue.
Who may speak {mono}
Section titled “Who may speak {mono}”A {mono} line is the point-of-view character’s inner voice. The effective POV is the
document’s pov:, else the project’s defaults.pov. A {mono} line is legal when its speaker is
the effective POV or is listed in monoSpeakers: — a list of speaker ids in the document’s
frontmatter, or in the project’s defaults: for every document (the document’s own list wins).
Use it for a scene that shares the inner voice, such as a second protagonist:
---kind: sceneid: diner.nightpov: fixermonoSpeakers: [mira]---
## The Counter
@fixer{mono}: Another night, another coffee.@mira{mono}: He looks tired.@mira{os}: Order up!@fixer{vo}: I remember that night.Without monoSpeakers: [mira], @mira{mono} is E-MONO-POV; when no POV resolves at all — no
pov: and no defaults.pov — a {mono} line by a speaker outside monoSpeakers is
E-MONO-NO-POV:
diner.lute:10:7: error [E-MONO-POV] `@mira{mono}` is an interior monologue, but `mira` is not this document's point of view (`fixer`) and not in `monoSpeakers:` (which lists none) — only the POV character and the speakers `monoSpeakers:` lists may speak `mono`; add `mira` to `monoSpeakers:`, or write the line as dialogue, `{os}` or `{vo}`A component’s {mono} lines are checked at each ::use, against
the calling document’s POV and monoSpeakers; the error is reported at the ::use and names the
component’s line.
Interpolation
Section titled “Interpolation”Content Text (and the label-grammar values below) may embed {{…}} interpolations that read game state at
render time:
@narrator: Good to see you, {{userName}}.@marina{code="0010" emotion="delighted" variant="1"}: You came back! Warmth so far: {{run.affection}}.(From docs/examples/showcase/hub-demo.lute.)
{{userName}} is the always-available reserved token. Any other bare name is a host token a plugin declares; otherwise an interpolation must name a
declared state path; an interpolation is a read for definite-assignment analysis, so a
maybe-unset path interpolated without a guard is E-MAYBE-UNSET. Apart from interpolations and
inline modifiers, the text after the second colon is opaque to end of
line — parentheses, <, //, #, single braces and anything else are literal, never parsed. The
checker warns on the shapes that are markup in Ink or Yarn, since the player would see them: a
single-brace {run.oil} or {run.oil > 0: …} (W-TEXT-SINGLE-BRACE; interpolation is
{{run.oil}}, conditional text is a guarded line), and a // note or trailing # tag
(W-TEXT-COMMENT-LIKE; a comment is // on a line of its own). See
Coming from Ink or Yarn.
A path typed against an enum whose members carry display labels: renders the label, not the
member id: Today is {{run.weekday}}. reads Today is Sunday. (see
Paths typed by a named enum).
Labels and prompts
Section titled “Labels and prompts”Three kinds of quoted attribute value take the label grammar: a <choice>’s text, a
<branch prompt> or <hub prompt>, and a plugin directive attribute declared
type: text. Each holds {{…}} interpolations with their
format hints and no inline modifiers. Since 0.52 a
prompt is checked as an option label is: an undeclared path is E-CEL-PROFILE, a maybe-unset one
E-MAYBE-UNSET, and the runtime renders the prompt’s markers onto the menu with the labels’
(see Choices & hubs). \{{ writes a literal {{ in
all three (0.52, #211), as in line text: text="Say \{{open}}" shows Say {{open}} and has no
placeholder. In a quoted value that does not take the label grammar — a ::bg location, a when,
a ::use argument — \{{ is E-STRING-ESCAPE:
br.lute:17:16: error [E-STRING-ESCAPE] `\{{` (a literal `{{`) is defined only in line text and in the values that take the label grammar — `<choice text>`, `prompt` and a plugin `text` attribute; `location` of `::bg` is not oneA ::use argument passes a literal {{ as \\{{ (Components). An
<objective title> is plain text, not label grammar: a quest UI shows it as written, so a {{ in it
is E-OBJECTIVE-TITLE-INTERP (Quests & scenes).
Host tokens
Section titled “Host tokens”A line may name a value only the engine knows, such as the player’s name, with a bare identifier: {{playerName}}. Since 0.50 a plugin declares such names in its hostTokens export, and lute.core declares userName: { type: string }. With hostTokens: { playerName: { type: string } }:
@narrator: Welcome, {{playerName}}.compiles to a reserved placeholder, { "kind": "reserved", "token": "playerName", "format"? }. A token is a bare identifier (an ASCII letter or _, then letters, digits or _). {{player-name}} and {{2nd}} are not tokens: they stay state-path-shaped and keep their E-CEL-PROFILE. A state path always has a tier root and a ., so no spelling that was a state path changes meaning; the names that are state roots or CEL literals cannot be declared as tokens (see Reserved names).
- Declared. The token’s type decides which format hints it takes. A
stringorenumtoken takes the text hints ({{platform:capitalize}}is clean), and a number hint on it isE-REF-TYPE. Aninttoken takes the number hints ({{rank:ordinal}}is clean), and a text hint on it ({{rank:capitalize}}) isE-REF-TYPE.{{userName}}behaves exactly as before. - Undeclared. A bare name no plugin declares is
E-CEL-PROFILE. The message names “a declared host token” and lists the declared tokens:interpolation `playerName` is not a valid `{{…}}` form — only a state path, …, a def `@ref` / `@ref(args)`, or a declared host token (`userName`) are permitted; declare `playerName` in a plugin's `hostTokens:` export if the engine substitutes it …. The rest of the message is unchanged. - Escape.
\{{name}}is unchanged: it is never a placeholder. - Never a state read. A host token takes no part in definite-assignment, dependency or quest-reference analysis, and counts as one word for lint.
- At runtime. The runtime leaves every host-token marker verbatim in the rendered text, and the host or presenter substitutes it. This holds for every token, not only
userName(see the runtime contract). The editor’s hover readshost token `playerName` — `string`, declared by `acme.stage`; the engine substitutes it at render time.
Ordinals: {{x:ordinal}}
Section titled “Ordinals: {{x:ordinal}}”A numeric value can be rendered as an English ordinal with a format hint after a colon (dsl 0.24.0 §4):
---kind: sceneid: tower.gatestate: user.climbs: { type: int, default: 1 }---
## The Gate
@narrator: Welcome back, {{userName}}. This is your {{user.climbs:ordinal}} climb.lute run, lute play, lute trace and lute test render 1st 2nd 3rd 4th … 11th
12th 13th … 21st 22nd … 101st 111th. A value that has no ordinal, a fraction or a
negative number, renders unchanged (2.5, -3). In the artifact the line’s placeholder carries
"format": "ordinal", and the engine does the rendering. A placeholder without a hint has no
format key, so artifacts that use none are unchanged.
Ordinal words: {{x:ordinalWord}}
Section titled “Ordinal words: {{x:ordinalWord}}”:ordinalWord (dsl 0.25.0 §8) renders the ordinal as a word, for text that reads better spelled out:
---kind: sceneid: road.campstate: run.day: { type: int, default: 1 }---
## Camp
@narrator: The {{run.day:ordinalWord}} night on the road.The IR placeholder carries "format": "ordinalWord", and the engine localizes the word. lute run, lute play, lute trace and lute test render the English words first second … twentieth for 1–20, and fall back to the :ordinal digits outside that range (0th, 21st). The checker admits :ordinalWord wherever it admits :ordinal.
Number words and plurals: {{x:cardinalWord}}, {{x:plural(one|other)}}
Section titled “Number words and plurals: {{x:cardinalWord}}, {{x:plural(one|other)}}”:cardinalWord spells a number one two … twenty and falls back to its digits outside that range (0, 21); the checker admits it wherever it admits :ordinal. :plural(singular|plural) picks the first form when the number is 1 and the second otherwise. In a form, # is the number, #word the number as a cardinal word and #Word that word capitalized, for a sentence that starts with it:
---kind: sceneid: road.wagonsstate: run.wagons: { type: int, default: 11 }---
## Camp
@narrator: {{run.wagons:plural(One wagon waits|#Word wagons wait)}} at the ford, {{run.wagons:cardinalWord}} in all.That line reads Eleven wagons wait at the ford, eleven in all. Plural forms are bare text separated by |: a quoted form (plural("One wagon"|"# wagons")) would print its quotes and a , separator (plural(wagon,wagons)) is one form, so both are E-PLURAL-FORM, and the message shows the rewrite.
Text hints and label forms: {{x:capitalize}}, {{x:start}}, {{x:indefinite}}
Section titled “Text hints and label forms: {{x:capitalize}}, {{x:start}}, {{x:indefinite}}”Three hints format text — a string, an enum or kind label, a cast name, {{occasion.target}}, a string or enum host token such as {{userName}}. :capitalize upper-cases the first letter. :start and :indefinite read the forms a kind’s labels: entry may declare beside its text:
entities: spot: members: [cut, den] labels: cut: { text: "the smugglers' cut", start: "The smugglers' cut", indefinite: "a smugglers' cut" } den: ashwraith den{{occasion.target:start}} is quiet. reads The smugglers' cut is quiet. for cut. When a form is absent the hint falls back: :start capitalizes the text (Ashwraith den), and :indefinite puts an before a text whose first letter is a vowel and a before any other (an ashwraith den). Declare indefinite: for the words that rule gets wrong (an hour, a unicorn). A label entry is a string or { text, start, indefinite } with text: required; any other key is E-ENTITY-KIND-SHAPE. The IR carries the hint as the placeholder’s format and the forms as labelForms on the kind’s entities[] entry and on a state path typed by the kind, so an engine can localize them. A component param spliced as a literal takes the fallbacks.
The hints are ordinal, ordinalWord, cardinalWord and plural for an int or double (or an int host token), and capitalize, start and indefinite for text. Any other — {{run.floor:roman}}, a misspelt {{run.day:ordinalword}} — is E-CEL-PROFILE. A numeric hint on a string, enum or bool path or def, or on a string or enum host token such as {{userName}}, is E-REF-TYPE, and so is a text hint on an int, double, or bool, or on an int host token.
Localizable directive text (since 0.51)
Section titled “Localizable directive text (since 0.51)”A plugin directive can carry text that a translator must see, such as a banner title. Its manifest declares the attribute with type: text. A text attribute is admitted only on a directive that compiles to a plugin record (no lower:, no bridge:), and it takes no default:. Each text attribute is one translatable unit, the way a choice label is:
::banner{code="0010" title="Welcome to the hall, {{userName}}" style="card"}- Value grammar. A
textvalue is a quoted string in the same grammar as a<choice>label:{{…}}interpolations of state paths,@defs and host tokens with their modifiers.\{{writes a literal{{(since 0.52, #211; see Labels and prompts). Inline text modifiers are not parsed in it. Each interpolation is checked as a label’s is. An@refvalue (title=@greeting) isE-ATTR-TYPE: a@defvalue has no translation of its own. code=. A directive with at least onetextattribute takescode="…"as a content line does: a string, trimmed. On any other directivecodestaysE-UNKNOWN-ATTR. A directive that declares its own attribute namedcodebeside atextattribute is refused when the plugin loads.lineId. The id of atextattribute is{prefix}~{tag}~{code}~{attr}, for exampleintro~banner~0010~title. It does not go through thelineIdtemplate, and position is never identity.~is the separator, so acodecontaining~isE-LINE-CODE-RESERVEDand an identity template whose literal text contains~isE-IDENTITY-TEMPLATE. As a backstop, whole-projectlute checkreports two units with the same finallineId— a line, option, prompt or directive text — asE-LINE-ID-DUP, since alineIdkeys one translation. Two::bannercalls in one scope sharing acodeareE-DUP-LINE-CODE.- Back-fill. An untagged directive gets the next code in its scope, counted per directive tag, as a line does; in a component the codes are counted over the whole component source.
lute tagwritescode="…"into every untagged directive that has atextattribute. A component is tagged under the snapshots of the documents that use it, and when those users resolve its text directives differently it isE-COMPONENT-TEXT-DIRECTIVE. Withidentity.requireStable, such a directive withoutcodeisW-LINE-CODE-UNTAGGED. - Artifact. The record’s
fields.<attr>keeps the source-language string with its{{…}}markers as authored, so a host that ignores localization reads what it read before. ThelineIdand the placeholders ride inlocalized.<attr>, andcodeitself is written to neitherfieldsnorstamps. A directive withouttextattributes compiles exactly as in 0.50.
The strings are exported, translated and merged with lute loc export, lute loc import and lute compile --locale-bundle: run lute tag first, because a directive’s lineId can be reproduced from the source only when its code is authored.
Inline text modifiers
Section titled “Inline text modifiers”A line’s text may mark up part of itself for presentation — a styled word, a pause, a slowed phrase. A modifier is a colon, a lowerCamel name, then a bracketed span, a braced attribute list, or both:
- span —
:name[text]wraps text; - leaf —
:name{attrs}marks a point in the text; - span with attributes —
:name[text]{attrs}.
Modifiers are legal only in the text after a content line’s second colon — never in headings,
attributes, tag text or a <choice>’s text. The core has two:
| Modifier | Form | Meaning |
|---|---|---|
:pause{s=0.5} | leaf | a pause of s seconds (required, non-negative) |
:speed[text]{rate=1.25} | span | the wrapped text is delivered at rate (required, positive) |
Every other name is a text style: a member of the project’s textStyle domain, declared in
enums: like any other staging vocabulary.
A style is a span with no attributes; what emphasis or whisper looks like is the engine’s
business.
---kind: sceneid: diner.nightpov: fixerenums: textStyle: [emphasis, whisper]state: run.tips: { type: int, default: 3 }---
## The Counter
@mira: You look :emphasis[tired].:pause{s=0.5} Coffee?@fixer: :speed[Just... one more cup.]{rate=0.75}@mira: :whisper[I counted :emphasis[{{run.tips}}] tips tonight.]@fixer: Write \:emphasis[this] to show the markup itself.@fixer: The sign says 10:30 :) and nothing else.In the artifact, text is always the plain text: modifier markup removed, escapes decoded
(except a literal {{, see below), {{…}} kept. A line that uses a modifier also gets segments — text runs carrying the active
styles (outermost first) and rate, and {pause} leaves; a line without one has no segments:
{"kind": "line", "family": "content", "position": "001-0100", "role": "dialogue", "speaker": "mira", "text": "You look tired. Coffee?", "lineId": "diner.night.mira_0010", "voiceKey": "diner.night.mira-0010", "segments": [{"text": "You look "}, {"text": "tired", "styles": ["emphasis"]}, {"text": "."}, {"pause": 0.5}, {"text": " Coffee?"}]}{"kind": "line", "family": "content", "position": "001-0200", "role": "dialogue", "speaker": "fixer", "text": "Just... one more cup.", "lineId": "diner.night.fixer_0010", "voiceKey": "diner.night.fixer-0010", "segments": [{"text": "Just... one more cup.", "rate": 0.75}]}{"kind": "line", "family": "content", "position": "001-0300", "role": "dialogue", "speaker": "mira", "text": "I counted {{run.tips}} tips tonight.", "lineId": "diner.night.mira_0020", "voiceKey": "diner.night.mira-0020", "placeholders": [{"kind": "path", "path": "run.tips"}], "segments": [{"text": "I counted ", "styles": ["whisper"]}, {"text": "{{run.tips}}", "styles": ["whisper", "emphasis"]}, {"text": " tips tonight.", "styles": ["whisper"]}]}{"kind": "line", "family": "content", "position": "001-0400", "role": "dialogue", "speaker": "fixer", "text": "Write :emphasis[this] to show the markup itself.", "lineId": "diner.night.fixer_0020", "voiceKey": "diner.night.fixer-0020"}The rules, line by line above:
- Nesting. Spans nest; an interpolation may sit inside a span and stays in
textandplaceholders. Nested styles stack (["whisper", "emphasis"]); a nested:speeduses the innermostrate. Adjacent runs with the same styles and rate are merged. - Escapes.
\:is a literal colon that never starts a modifier. Inside a modifier,\[,\],\{,\}and\\write the bracket, brace or backslash; any other escape there isE-TEXT-ESCAPE. - Literal
{{.\{{writes a literal{{: an odd run of backslashes directly before{{escapes it, an even run leaves a marker (\\{{run.a}}is a backslash, then the marker). In the artifact (lute 0.47.1) a literal{{stays written\{{intext, segment runs and translations, and a literal backslash directly before a{{is doubled, soLit \{{run.a}} and {{ run.a }}.keeps thattext, has one placeholder, and rendersLit {{run.a}} and 3.whenrun.ais 3. A translation may write\{{too. - A marker that opens on a literal brace (0.52, #139; 0.53, #229). The artifact has no spelling
for a literal
{directly before a{{…}}marker: compiled text would open the marker one brace early. So in line text a marker whose preceding literal text ends in an odd run of{isE-INTERP-BRACE, whichever escapes or modifiers spell the run ({\{{{{run.a}},x{:pause{s=0.5}{{run.a}}). An even run is a run of literal{{pairs and stays legal:\{{{{run.a}}renders{{3. Since 0.53 a marker whose expression itself begins with{({{{run.a}},{{{{run.a}}) reads as the marker after n literal braces and isE-INTERP-BRACEtoo, in line text and in every label-grammar value (a choice or hub label, a prompt, a plugintextattribute), where 0.52 reportedE-CEL-PROFILE. The message names the run length n and spells the two ways out for exactly that many braces. A space after the{{({{ {run.a}}) is a marker you opened, so it staysE-CEL-PROFILE. A translation with such a marker is not merged (see Translations keep the source’s markers), and a component line whose spliced argument ends the run reports at the::use, as does an argument ending in a plain{{(#221, see Components). - Plain colons stay text. A colon starts a modifier only when a name and then
[or{follow it, so10:30and:)need no escape.
sign.lute:14:20: error [E-INTERP-BRACE] a literal `{` directly before `{{run.a}}` cannot be told apart from the marker in compiled text (lute 0.47.1 §3): put a character between them (`{ {{run.a}}`), or show the brace from a string state path (`{{run.lbrace}}{{run.a}}`, with `run.lbrace` declared `{ type: string, default: "{" }`)That is the message for Your score: {{{run.a}} (n = 1). For {\{{{{run.a}}, three braces, the
message says “literal {{{” and spells \{{{ {{run.a}} and three {{run.lbrace}}:
sign.lute:14:24: error [E-INTERP-BRACE] a literal `{{{` directly before `{{run.a}}` cannot be told apart from the marker in compiled text (lute 0.47.1 §3): put a character between them (`\{{{ {{run.a}}`), or show each brace from a string state path (`{{run.lbrace}}{{run.lbrace}}{{run.lbrace}}{{run.a}}`, with `run.lbrace` declared `{ type: string, default: "{" }`)A malformed modifier is E-TEXT-MODIFIER: an unterminated span, a name that is neither core nor a
textStyle member, attributes on a style, or a positional value (:pause{0.5} — write s=0.5):
diner.lute:11:33: error [E-TEXT-MODIFIER] inline modifier span is missing a closing `]`diner.lute:12:10: error [E-TEXT-MODIFIER] `:shout` is neither a core modifier (`pause`, `speed`) nor a member of `textStyle` (it has `emphasis`)diner.lute:13:26: error [E-TEXT-MODIFIER] text style `:emphasis` takes no attributes, got `size` — only the core `pause`/`speed` carry attributesVoice keys compare the plain text, so restyling a line never collides with its recording.
lute loc export exports the source markup; a translation must carry
the same modifiers — same names, forms and attributes, in any order — or it is not merged
(E-L10N-MODIFIERS). A translated locale always gets its plain text in texts, and a modified
line also gets localeSegments for that locale.
Each export row also carries a sourceHash — "sha256:<hex>" of the line’s plain text (markup
and escapes removed, as text compiles), of an option’s text, or of a prompt. Keep it in the
translated file: lute loc import stores it per lineId and locale in
the bundle (schemaVersion: 2, sourceHashes), and lute compile --locale-bundle reports
W-L10N-STALE when a line was edited after the export its translation came from — re-export and
update that translation. The stale translation is still merged until you do.
Translations keep the source’s markers
Section titled “Translations keep the source’s markers”Since 0.53 a translation also shows its source’s {{…}} markers, as it shows its modifiers: each
source marker as often as the source does, in any order. A marker is compared by its interior
without the spaces at its edges, so {{ run.a }} is {{run.a}}, but a space inside it or another
format hint makes another marker ({{run.a: ordinal}} is not {{run.a:ordinal}}). The rule covers
a line, an option label, a prompt and a directive text attribute. A marker that opens on a
literal brace, or one that is not the source’s, is E-L10N-MARKERS: the translation is not merged,
and lute compile --locale-bundle fails. A line translation whose {{ has no }} fails the
compile as any inline markup that does not parse, E-L10N-MODIFIERS (wrapping
E-INTERP-UNTERMINATED). lute loc import has only the translated rows, so it refuses a marker
that does not parse — that unterminated {{ in a line included, there as E-L10N-MARKERS — and
writes no bundle. Without this the runtime would show a marker it cannot match to the source
verbatim. Given Score {{run.a}} now. as the source and 점수 {{run.zzz}} as the ko text:
s.lute:1:1: error [E-L10N-MARKERS] `ko` text for `s.narrator_0010` is not merged: its markers differ from the source's (missing `{{run.a}}`; extra `{{run.zzz}}`): a translation shows each `{{…}}` marker of its source as often as the source does, in any order