Porting a content set
This guide is for a team that already has a content set in another shape (spreadsheet rows, an
engine’s export, an older script format) and writes an importer that emits .lute documents. The
importer is the author. Lute is not asked to accept what the sheet looked like; the plugin declares
what the sheet’s columns, tokens and rewards mean, and lute check holds every imported row to it.
Everything here is available since 0.50. The recipes answer the seven problems a large real port
ran into (issues #162, #163, #164, #166, #169, #172 and #177), and each section ends with what the
toolchain catches. A runnable project that uses all of them is
docs/examples/plugin-surfaces/:
lute check --project docs/examples/plugin-surfaceslute test --project docs/examples/plugin-surfaceslute lint --project docs/examples/plugin-surfacesThe plugin manifest vocabulary is on Plugin manifests. This page collects the port recipes around it.
Since 0.51 the guide also covers the problems that issues #159, #165, #170, #172 and #176 reported:
retry loops, scenes that a directive or a reward raises,
directive text that players read, balance rules and
structured attribute values. The runnable project for these is
docs/examples/flow-and-text/.
Since 0.53 it also covers shots as sections (a port may keep one section per source shot, and a retry loop may span them),
quests the host grants once a condition holds, and lute fix on an old project.
Columns become stamps on the surface they belong to
Section titled “Columns become stamps on the surface they belong to”A sheet has different columns for different rows: a voice asset for a dialogue row, an asset name for
a background row, a menu type for a choice row, an option key for each option. A stamp is a plugin
attribute that is not part of a directive’s own declaration. Since 0.49 a stamp is admissible on every
directive and every content line, which is too wide for a column that belongs to one kind of row.
Since 0.50 a stampAttrs entry names its surfaces with on::
stampAttrs: - { name: voiceAssetId, type: { providerRef: voice }, required: true, on: [line] } - { name: assetName, type: string, on: [bg, actor] } - { name: menuType, type: { enum: [plain, timed, minigame] }, on: [branch, hub] } - { name: optionKey, type: string, required: true, on: [choice] } - { name: cue, type: string } # cross-cutting, as in 0.49<branch id="route" prompt="Which way?" menuType="plain"> <choice id="goLeft" text="Left" optionKey="L1"> @mira{voiceAssetId="VO.mira.0010"}: Left it is. </choice></branch>on: takes a non-empty list of surface names. The vocabulary is closed:
| Surface name | Where the stamp may be written |
|---|---|
line | every content line (@speaker{…}: text), including lines in component, quest, objective, <on>, entry and beat bodies |
branch, hub | the element of that name |
choice | every <choice>, in a <branch> or a <hub> |
| a directive tag | that directive, if it is a directive of the active profile with a stamp carrier: bg, actor, a plugin directive. Not jump, label or clear, which emit no stamped record |
The four names line, branch, hub and choice always mean the core surface, and they are already
reserved as plugin directive names, so a plugin directive tagged line cannot be named in on:. The
order inside on: does not matter.
What a column can and cannot do
Section titled “What a column can and cannot do”- Without
on:a stamp keeps its 0.49 meaning exactly: every directive that has a stamp carrier and every content line. It is not admissible on<branch>,<hub>or<choice>. The logic tags stay closed unless a stamp names them, sobranch,hubandchoiceare never admissible for a cross-cutting stamp. - A scoped stamp is refused elsewhere. Writing
voiceAssetIdon::bgisE-UNKNOWN-ATTR, and the message says where the stamp does apply instead of listing the surface’s own attributes. required: trueneedson:. A cross-cutting stamp cannot be required: a stamp required on every record is not a column. Before 0.50 the flag was silently ignored; it is nowE-PLUGIN-PARSEat load. On a scoped stamp it is enforced: a surface that does not write the stamp isE-MISSING-ATTRon the directive, on the line, or on the whole<branch>/<hub>/<choice>element. The rule applies wherever the surface is written, component bodies included, and a line that::usesplices in is checked once, in its component. A declareddefault:does not satisfy it, because a stamp is never injected.- One name may have two types, as long as the entries’ surface sets are disjoint:
assetNamecan be astringonbgand anintonactor. Each surface serializes the value with its own type. Any overlap is the existing duplicate code:E-PLUGIN-DUP-IDwithin one plugin package, andE-PLUGIN-DUP-ACROSSbetween two plugins, each naming the shared surfaces. An entry withouton:overlaps every other entry of its name. - A scoped stamp cannot shadow its surface’s own attribute.
promptonbranch,textonchoiceorcodeonlinecould never be read, so the declaration isE-PLUGIN-RESERVED-STAMP-ATTR. The surface’s own attributes are the directive’s declared attributes; the line’s known attributes andwhen;id,prompt,timeoutanddefaulton<branch>;idandprompton<hub>; andid,text,when,into,value,onceandexiton<choice>, plus its retiredlabel,asandpersist. - An
on:name that is no surface isE-PLUGIN-STAMP-SURFACEat assembly, with a did-you-mean (bggforbg). A directive of another plugin is a surface only when that plugin is in yourdepends:, so that it is in every profile that activates yours.
Load-time faults are all E-PLUGIN-PARSE at the entry: an on: that is not a list of strings or is
empty, a surface repeated in on:, and required: true without on:.
The value is typed by the declaration of the surface it is written on, so a mistyped one is the
ordinary E-ATTR-TYPE or E-BAD-ENUM. That holds for an @ref value on <branch>, <hub> and
<choice> too: a def of the wrong type is E-REF-TYPE, and a def that folds outside an enum is
E-BAD-ENUM.
In the example project,
a scene that writes voiceAssetId on ::bg (line 8) and a <choice id="goLeft"> without optionKey
(line 10) gives:
./scenes/a.lute:8:33: error [E-UNKNOWN-ATTR] `::bg` has no attribute `voiceAssetId`: it is a stamp of `acme.stage` for content lines only./scenes/a.lute:10:3: error [E-MISSING-ATTR] `<choice id="goLeft">` requires attribute `optionKey` (a stamp of `acme.stage`)What reaches the artifact
Section titled “What reaches the artifact”The runtime never reads stamps. A stamp on a record is in that record’s stamps object, as in 0.49. A
stamp on <branch> or <hub> is in the stamps of the choice or hub record, and each option
gains its own stamps object from its <choice> element (omitted when it has none). An unauthored
stamp is never injected. The menu the runtime awaits carries only the address and the verdicts, so a
host that does not use the reference player reads stamps from the IR record at the menu’s address, as
it already reads option text. The reference player’s ChoiceView carries the element’s and each
option’s stamps. See Runtime contract.
{{playerName}}: tokens the host substitutes
Section titled “{{playerName}}: tokens the host substitutes”An importer meets names the engine fills in at render time: the player’s name, a rank, a platform.
Declare each one with a hostTokens export (exports: { hostTokens: hosttokens/ }):
hostTokens: playerName: { type: string } rank: { type: int } platform: { type: { enum: [ios, android, web] } }@mira: Welcome, {{playerName}}. You are our {{rank:ordinal}} guest, and {{platform:capitalize}} is fine.- A token name is an identifier (an ASCII letter or
_, then letters, digits or_).player-nameand2ndNameareE-PLUGIN-PARSEat load, because a token is written bare inside{{…}}. The value is{ type }withstring,intor{ enum: [...] }. - A name that reads as a state root or a CEL literal (
scene,run,user,app,quest,entry,prev,clock,occasion,season,true,false,null) isE-PLUGIN-RESERVED-NAME:{{run}}would read as a state root. lute.coredeclaresuserName: { type: string }. Redeclaring a token that another plugin, orlute.core, declares isE-PLUGIN-DUP-ACROSS.- A format hint is checked against the token’s type. A
stringorenumtoken takes the text hints ({{platform:capitalize}}), and a number hint on it isE-REF-TYPE. Aninttoken takes the number hints ({{rank:ordinal}}), and a text hint on it isE-REF-TYPE. - An undeclared bare name is
E-CEL-PROFILE, and the message now says to declare the name in a plugin’shostTokens:export.{{player-name}}and{{2nd}}are not identifiers, so they keep theE-CEL-PROFILEthey always had. - The escape
\{{name}}is unchanged: it is never a placeholder.
A host token is never a state read, and the runtime does not substitute it: every token marker stays
verbatim in the rendered text, and the host or presenter replaces it. That holds for every token, not
only userName. The IR placeholder is { "kind": "reserved", "token": "playerName" }, and an
interpolation counts as one word in the lints, as before. lute context lists the declared tokens.
Rewards that assert, retract and accept
Section titled “Rewards that assert, retract and accept”A port often carries rows like “this quest hands out a badge” or “finishing it offers the next one”.
Before 0.50 a reward kind could only credit a state path, so the fact the badge stood for was invisible to
the checker and the quest it unlocked drew a false E-QUEST-UNREACHABLE. A reward kind now declares
effects:, in the directive vocabulary:
rewardKinds: BADGE: { target: { entity: person, required: true }, effects: { asserts: ["met(@target)"] } } REVOKE: { target: { entity: person, required: true }, effects: { retracts: ["met(@target)"] } } OFFER_QUEST: { target: { required: true }, effects: { accepts: ["@target"] } }The example project’s quests use all three (quests/visits.lute):
---kind: questid: visits.queststitle: Visits---/*Reward effects (0.50): - firstVisit's BADGE asserts met(mira) at the grant; secondVisit starts on that fact, so it is reachable only because the reward produces it. Its `follows` places it in the scenario graph, where `lute scenario --facts` draws the fact edge firstVisit -> secondVisit [met(mira)]. - firstVisit's OFFER_QUEST accepts giftErrand, an accept-driven quest no `::accept` names. - secondVisit's REVOKE retracts met(mira) when it completes.*/
<quest id="firstVisit" tier="run" title="Say hello to Mira" start="true"> <reward kind="BADGE" amount="1" target="mira"/> <reward kind="OFFER_QUEST" target="giftErrand"/> <objective id="talk" title="Talk to Mira" done="run.talked"/></quest>
<quest id="secondVisit" tier="run" title="Visit Mira again" follows="completed('firstVisit')" start="holds('met', ['mira'])"> <reward kind="REVOKE" target="mira"/> <objective id="chat" title="Have a second chat" done="run.chatted"/></quest>
<quest id="giftErrand" tier="run" title="Carry Mira's gift to Tomas"> <objective id="deliver" title="Deliver the gift" done="run.delivered"/></quest>OFFER_QUEST names giftErrand, an accept-driven quest (no start). Naming secondVisit, which has a
start, would be E-ACCEPT-TARGET, as an ::accept of it is.
What an effect may say
Section titled “What an effect may say”effectshasasserts,retractsandaccepts, each optional. An emptyeffects: {}isE-PLUGIN-PARSE(“declares no effect”).writesis not admitted. A reward’s state write iscredits:, andeffects.writesisE-PLUGIN-KEYsaying so.- Pattern arguments of
assertsandretractsare members,true/false,@targetand, inretractsonly,_. Any other@nameisE-PLUGIN-PARSE: a<reward>takes no attribute but its own, so a kind’s effects read only@target. acceptsentries are@targetor a literal quest id. A kind withtarget: { forbidden: true }whose effects read@targetisE-PLUGIN-PARSE, because the effect could never bind.
How each <reward> is judged
Section titled “How each <reward> is judged”@target is bound to the reward’s quoted target=. Each resolved fact pattern is judged as the
::assert or ::retract of it would be (relation declared, arity, argument kinds and members,
closed-kind membership), with the same codes at the reward’s kind=, and the message is prefixed with
`<reward kind="BADGE">` asserts `met(mira)` (its kind's `effects`):. Each resolved quest id in accepts is
judged as ::accept judges it, project-wide: an unknown id (with a did-you-mean), a quest that has a
start, and a child that auto-activates are E-ACCEPT-TARGET, at the reward’s target= when the id came
from @target, otherwise at kind=.
A reward with no target= applies no effect that reads @target, with no extra diagnostic (the
directive rule). A kind with target: { required: true } already reports E-REWARD-TARGET for it.
When it happens
Section titled “When it happens”At the grant instant, for a reward that is granted (its outcome filter matches and its when=
holds), in declaration order and per reward:
- the
grantrecord and itscreditswrite, as before; - the reward’s
retracts, then itsasserts, through the one runtime assert/retract path (theE-FACT-EXCLUSIVEcheck included); - its
accepts, through::accept’s path. An accept-driven quest activates on the next lifecycle round, and a quest already pastunsetis left alone, with the record saying so.
Then the next reward. Objective grants precede quest grants, and all grants of a transition precede
its lifecycle handler body, so at run time a questComplete handler reads the fact the reward
asserted. An effect fires exactly once per grant.
Each effect record names the grant it follows with a grant key, and lute play, lute run and
lute trace print it. The example’s play script (lute play --project . --script plays/visits.play.yaml):
── step 1 (the player talks to Mira) · engine ────────────── set run.talked = true firstVisit.talk done quest firstVisit -> complete grant[#1 i0] firstVisit BADGE 1 -> mira assert met(mira) (effect of reward BADGE) grant[#1 i1] firstVisit OFFER_QUEST 1 -> giftErrand quest giftErrand accepted (effect of reward OFFER_QUEST) quest giftErrand -> active quest secondVisit -> activeWhat every analysis sees
Section titled “What every analysis sees”Every analysis reads a reward effect at the owner’s grant point: the quest’s completion (before its
questComplete handlers), its failure for outcome="failed" (before questFailed), or the
objective’s completion. A reward’s assert is a producer only: it adds to what may hold and
never to what must hold, guarded or not, in the handler bodies, the objective body, or anything
gated on the quest. Lifecycle handlers can be deferred past other quests’ grants and the occasion’s
beats, a user-tier quest stays complete across a reset that clears its facts, and a host may seed
a quest complete without its grants, so no grant-to-fact implication is sound. A reward’s
retract removes what it matches from what must hold, as a guarded ::retract does. So:
secondVisitabove is reachable: the reward is a producer of itsstartfact, so there is noE-QUEST-UNREACHABLE.E-OBJECTIVE-UNSATISFIABLE,W-RELATION-UNREADandE-FACT-EXCLUSIVEsee the effect too. A guard that testsmet(mira)after the grant draws neitherW-FACT-GUARANTEEDnorE-ARM-DEAD: the fact is possible there, not guaranteed.lute scenariodraws the fact edge and the accept edge, andlute lore/lute scenario knowledgename the producer:met(mira) — produced by reward BADGE of quest `firstVisit`.W-QUEST-NEVER-ACCEPTEDnow says “no::acceptor rewardacceptsin the project names it”, and a reward accept silences it.- Start-cycle detection is unchanged. A reward accept anchors an edge for reachability and
connectivity only; it does not gate a quest’s start, so it never draws
E-QUEST-START-CYCLEorE-QUEST-START-SELF.
An artifact that has a reward effect carries the semantic id lute.quest.rewardEffects/1. An engine
that ignores the fields would lose facts and quest activations, so one that does not implement the id
refuses the artifact. See Runtime contract.
Levels for an imported subtree
Section titled “Levels for an imported subtree”A first import is noisy. A staging warning on 212 rows is true and not worth fixing on day one, and
--deny-warnings in CI means you cannot have a warning you have not decided about. The project
manifest sets a level per diagnostic code, optionally for a path:
diagnostics: W-DISPLAY-NAME-DUP: error W-STAGE-ABSENT: { level: off, paths: ["scenes/imported/**"] }diagnostics:maps a code to a level (off,info,warn,error) or to{ level, paths }. One entry per code.pathsare project-root-relative globs with thelute.lint.yamlignore:syntax (*within a segment,**across segments). Withpaths, the level applies only to diagnostics of documents that match; elsewhere the code keeps its native severity. A diagnostic with no document path (a manifest diagnostic) matches only an entry withoutpaths.- Only
W-*codes are configurable. AnE-*code is a contract violation and is never re-levelled, and anL-*lint rule is set inlute.lint.yamlrules:. A level that is not one of the four, a code the toolchain does not define (with a did-you-mean), orpathsthat are empty, absolute or contain..isE-DIAGNOSTICS-CONFIG, reported once per manifest, and that entry is dropped.
A manifest with W-STAGE-ABSNT: off on its line 4 gives:
./lute.project.yaml:4:3: error [E-DIAGNOSTICS-CONFIG] `W-STAGE-ABSNT` is not a diagnostic code — did you mean `W-STAGE-ABSENT`?How a level is applied, in this order, at the point that prints a diagnostic:
- the project level for the code and the document’s path;
--deny CODEnames the code: it is promoted to an error even when the project set itoff, because an explicit command line beats the config;--deny-warningspromotes what is still a warning after step 1. Anoffcode stays off, and aninfocode stays info.
Errors are never demoted. A code relevelled to error fails the verdict and the exit status, as
--deny does; in JSON it carries "severity": "error" and "configured": "lute.project.yaml", and the
human line carries a [project] marker beside [denied]. A code relevelled to info or warn carries
the new severity and the same marker.
Levels change only what is reported and the exit status. They never change an analysis, an artifact,
a constraint verdict, the scenario graph, or what a .test.yaml or .play.yaml expectation observes.
They gate lute check, lute compile and lute lint. The simulating commands (lute test,
lute play, lute calendar) do not apply them at all: they print native diagnostics, and a project
error level leaves lute test’s results and exit status as they are without it. The LSP applies the
same levels: an off diagnostic is not published.
Nothing is hidden without saying so. Every summary line that counts diagnostics appends
, N suppressed by lute.project.yaml only when N is above zero, and every JSON report carries a
"suppressed": { "<code>": n } map only when it is not empty:
In the example project, lute check --project . prints:
ok: ./quests/visits.lute (0 warning(s))ok: ./scenes/cafe.lute (0 warning(s))ok: ./scenes/imported/street.lute (0 warning(s), 1 suppressed by lute.project.yaml)ok: . (3 file(s), 0 project-wide warning(s))A rules: entry in lute.lint.yaml that names a checker code is E-LINT-CONFIG, and the message points
here: `W-STAGE-ABSENT` is a checker code, not a lint rule — set its level in `lute.project.yaml` `diagnostics:`.
A line that stages its speaker
Section titled “A line that stages its speaker”W-STAGE-ABSENT warns when a character speaks after they left the stage (a declared exit, a ::bg
scene change, a ::clear) and nothing showed them again. An importer that knows a row shows an
off-stage speaker emits the staging row first. No new construct is needed, because a record that stages
a character is the actor record, from ::actor or from a plugin directive lowered to it:
directives: - name: showSpeaker attrs: - { name: character, required: true, type: string } - { name: spriteId, type: string } lower: record: actor fields: character: { fromAttr: character } costume: { fromAttr: spriteId }::bg{location="street" time="night"}::showSpeaker{character="tomas" spriteId="SPR_tomas_02"}@tomas: Still here?A directive lowered to actor stages its character like ::actor (since 0.45), and character must
bind to a required attribute or a literal. The actor record has no field named for a sprite, so the
example carries the importer’s sprite id in costume; a plugin can also keep it in a stamp scoped
on: [showSpeaker]. The W-STAGE-ABSENT hint now reads “Show them again with an ::actor (or a plugin
directive declared lower: { record: actor, fields: { character: { fromAttr: character } } })”. It
names the full form because lower: { record: actor } alone is refused at load (fields: is
required) and an empty fields: {} is E-LOWER-RECORD-FIELD.
While the port is under review, set the code off for the imported subtree, as in the
levels above, and delete the entry when the importer emits the
staging rows:
diagnostics: W-STAGE-ABSENT: { level: off, paths: ["scenes/imported/**"] }Lints for structure and dependent values
Section titled “Lints for structure and dependent values”Some rules are about where a node sits, not what it contains: every section opens with a ::bg; a
::signal of a kind must be followed by a <branch>; a label in a beat starts with b_. Others
are about one attribute depending on another: the legal speed depends on the mode; the legal
option key depends on the menu type of the enclosing <branch>. Both are target: node lints, which
run over the AST in lute.lint.yaml custom: rules, in a plugin’s lints: export, and in the LSP.
A node rule gets one row per node, in document order, over every body the checker walks: sections;
<quest> declarations with their objectives and <on> handlers; <reward> children; top-level
<entry> and <beat>; <branch> and <hub> choices, choice bodies and a hub’s <return>;
<match> arms and their bodies; and <timeline> (one row; clips are not rows). The bindings are
node, scene (the document row) and options:
| Field | Meaning |
|---|---|
node.kind | line, directive (every ::tag{…}, core or plugin, ::set and ::end included), or the element name (branch, hub, choice, return, match, when, otherwise, timeline, quest, objective, on, reward, entry, beat) |
node.tag | the directive tag or element name; "" for a line |
node.speaker | a line’s speaker; "" otherwise |
node.attrs | map string → string of every attribute as authored: a key is present only if the author wrote it, and its value is the authored text, unquoted. Lifted attributes (<branch id>, <reward kind target amount>, ::jump{to}) and stamps are in it. A bare flag is "true", an @ref is its source text, and ::set, ::assert and ::retract have {} |
node.index, node.first, node.last | 0-based position in its body; first and last in that body |
node.depth | 0 for a top-level node of a section or document, plus 1 per enclosing element |
node.prev, node.next | { kind, tag, attrs } of the sibling in the same body; every field empty at a body edge |
node.parent | { kind, tag, attrs } of the enclosing element; kind is "section" at a section’s top level and "document" at a quest or lore document’s top level |
node.preceding | map tag → { attrs }: for each tag, the nearest node with that tag among the earlier siblings of this node and of each ancestor, and the ancestors themselves. It does not enter the bodies of earlier siblings (another arm is not on this path), is reset at each section and declaration, and does not key lines |
node.section | { index, title, id } of the enclosing section; { 0, "", "" } outside sections |
node.attrs is read from the source, so <reward kind="COINS" amount="+005"/> gives "+005", not
"5", and a reward written without amount has no amount key. The findings span the node, and
{node.attrs.value} interpolates into the message.
Two rules keep a lint honest:
- Guard every index. A missing key keeps CEL semantics:
node.attrs["x"]on an absent key is an evaluation error (E-LINT-EXPR).&&short-circuits, so write"x" in node.attrs && node.attrs["x"] …,"t" in node.preceding && "k" in node.preceding["t"].attrs && …, andkey in options.cases && …before indexing an options map. - An unknown discriminator is someone else’s finding. A dependent-value rule excludes a
discriminator it has no case for, and a separate
*-knownrule reports the discriminator itself. Each violation is then reported once, by the rule that names it.
The lint expression language also has the CEL string members s.startsWith(t), s.endsWith(t) and
s.contains(t), in member-call form, with both operands strings (a non-string operand is an
evaluation error). There are no regexes and no macros.
The recipes below are plugin lints: entries. In lute.lint.yaml the same entries go under custom:
with bare ids; a plugin rule’s id is <plugin-id>/<rule-id>.
lints: - id: signal-needs-menu target: node when: 'node.tag == "signal" && "value" in node.attrs && node.attrs["value"] == "puzzle" && node.next.kind != "branch"' level: warn message: "a puzzle ::signal must be followed by a <branch>" - id: section-opens-with-bg target: node when: 'node.first && node.parent.kind == "section" && node.tag != "bg"' level: warn message: "section {node.section.title} opens with {node.tag}" - id: beat-label-prefix target: node when: 'node.tag == "label" && scene.kind == "beat" && "name" in node.attrs && !node.attrs["name"].startsWith("b_")' level: error message: "a beat's labels start with b_"One attribute decides another’s values (the mode of a move selects its legal speeds). The
first rule reports an unknown mode, and the second skips it:
lints: - id: move-mode-known target: node when: 'node.tag == "move" && "mode" in node.attrs && !(node.attrs["mode"] in options.speeds)' level: error message: "unknown move mode {node.attrs.mode}" options: { speeds: { walk: [slow, normal], run: [normal, fast] } } - id: speed-fits-mode target: node when: >- node.tag == "move" && "speed" in node.attrs && "mode" in node.attrs && node.attrs["mode"] in options.speeds && !(node.attrs["speed"] in options.speeds[node.attrs["mode"]]) level: error message: "speed {node.attrs.speed} is not legal in mode {node.attrs.mode}" options: { speeds: { walk: [slow, normal], run: [normal, fast] } }A marker directive before the branch decides the legal option values. node.preceding finds the
nearest ::interaction on the path to each ::option:
lints: - id: interaction-kind-known target: node when: 'node.tag == "interaction" && (!("kind" in node.attrs) || !(node.attrs["kind"] in options.cases))' level: error message: "::interaction needs a known kind" options: { cases: { greet: [wave, bow], gift: [accept, decline] } } - id: option-fits-interaction target: node when: >- node.tag == "option" && "value" in node.attrs && "interaction" in node.preceding && "kind" in node.preceding["interaction"].attrs && node.preceding["interaction"].attrs["kind"] in options.cases && !(node.attrs["value"] in options.cases[node.preceding["interaction"].attrs["kind"]]) level: error message: "option {node.attrs.value} does not belong to this interaction" options: { cases: { greet: [wave, bow], gift: [accept, decline] } }The parent <branch> carries the discriminator, as a scoped stamp from the first section of this
guide:
lints: - id: menu-type-known target: node when: 'node.kind == "branch" && "menuType" in node.attrs && !(node.attrs["menuType"] in options.keys)' level: error message: "unknown menu type {node.attrs.menuType}" options: { keys: { plain: [L1, R1], timed: [T1] } } - id: choice-fits-menu target: node when: >- node.kind == "choice" && "optionKey" in node.attrs && "menuType" in node.parent.attrs && node.parent.attrs["menuType"] in options.keys && !(node.attrs["optionKey"] in options.keys[node.parent.attrs["menuType"]]) level: error message: "option key {node.attrs.optionKey} is not a {node.parent.attrs.menuType} key" options: { keys: { plain: [L1, R1], timed: [T1] } }Balance rules with pair
Section titled “Balance rules with pair”A balance rule is a rule about an opener that must be closed: ::group{mode="begin"} and
::group{mode="end"}, a window that opens and closes. It is a target: pair lint (since 0.51), in the same
places as a node lint. A pair rule declares open and close predicates over the same node row, an
optional inside predicate, and a scope (body, section, or — since 0.53 — document); it has no when. A pair rule with a when, or any other rule
with open, close, inside or scope, is E-PLUGIN-PARSE in a plugin’s lints: and E-LINT-CONFIG in
lute.lint.yaml.
lints: - id: group-balanced target: pair open: 'node.tag == "group" && "mode" in node.attrs && node.attrs["mode"] == "begin"' close: 'node.tag == "group" && "mode" in node.attrs && node.attrs["mode"] == "end"' inside: 'node.kind == "line" || node.tag in ["actor", "sfx"]' # optional scope: body # body (default) | section | document level: error message: "::group begin/end"::group{mode="begin"}@mira: Look at this.@tomas: And this.::group{mode="end"}That pair is balanced. Each scope runs one pass with one open slot:
scope: body(the default) pairs the direct children of each body, in order: the rows that are siblings.scope: sectionpairs every row of one section or declaration in document order, at every depth, so anopenin one<choice>and aclosein its sibling pair up.sectionis document order, not paths.- A row for which
closeholds while a pair is open closes it. A row that satisfies bothopenandcloseis a close while a pair is open, and an open otherwise. - A row for which
openholds while a pair is open is a nested open: the outer pair stays open. A row for whichcloseholds with no pair open is an unopened close. At the end of the scope, an open pair is an unclosed open. - With
inside, every row strictly between an open and its close for whichinsideis false is an inside fault. Inbodyscope only siblings count, not their descendants.
Each finding carries the rule’s code and level, sits on the offending row, and appends a fixed phrase to
the rule’s message: — opened here, with no close in this body (in this section for section scope),
— a close with no open pair in this body, — opened again while the pair from line N is still open, and
— not allowed inside the pair opened at line N. A nested open and an inside fault name the open row as a
related location.
An inside violation: the ::bg between the markers is not a line, ::actor or ::sfx, so the rule above
reports it as not allowed inside the pair opened at line 1.
::group{mode="begin"}@mira: Look at this.::bg{location="street" time="night"}::group{mode="end"}A pair can span a branch. With scope: section, a begin before a <branch> and an end after it pair
up, and so would a begin inside one <choice> and an end inside the next, which a body rule would report
as an unclosed open and an unopened close:
lints: - id: window-balanced target: pair open: 'node.tag == "window" && "mode" in node.attrs && node.attrs["mode"] == "open"' close: 'node.tag == "window" && "mode" in node.attrs && node.attrs["mode"] == "close"' scope: section level: error message: "::window open/close"::window{mode="open"}<branch id="buy" prompt="Buy something?"> <choice id="yes" text="Yes"> @shopkeeper: Here you go. </choice> <choice id="no" text="No"> @shopkeeper: Next time. </choice></branch>::window{mode="close"}The guard rule of the node lints holds here too: write "mode" in node.attrs && …, because a missing key in an
unguarded predicate is E-LINT-EXPR and stops that rule for the document. Each row costs at most three
predicate calls per pair rule. See Linting for the rest of lute.lint.yaml.
Retry loops with a bounded ::jump
Section titled “Retry loops with a bounded ::jump”A sheet row like “play the minigame, and offer it again if the player fails, at most twice” is a loop. Before
0.51 ::jump could only go forward, so an importer unrolled the loop or hid it in a passthrough directive that no
analysis could see. Since 0.51 a ::jump that carries max may target an earlier ::label:
::label{name="attempt"}::tryLock{door="vault"}<match subject="scene.lock.vault.won"> <when is="false"> @mira: Again! ::jump{to="attempt" max="2"} </when> <when is="true"> @mira: It clicks open. </when></match>@theo: That's enough for today.::tryLock is a blocking bridge call that writes scene.lock.vault.won from the host’s answer. max is the
number of times the jump may be taken: after max takes it is not taken and control falls through to the next
node, so this loop asks at most three times (once, then two retries).
maxis a literal integer from 1 to 99. An@ref,0, a negative number or a number above 99 isE-JUMP-MAX, since a loop bound is static.max="two"is onlyE-ATTR-TYPE.maxon a jump that goes forward isE-JUMP-MAXtoo, because it means nothing there. A backward jump withoutmaxstaysE-JUMP-BACKWARD, and its message now namesmaxand the<hub>for offering choices again.when=andmaxcombine: the jump is taken whilewhenholds and takes remain.- A loop stays in one body, and the sections of a document are one body. The label must be in the jump’s own
body, or in a body that encloses it through
<choice>bodies of a<branch>or<match>arms only. The top-level##sections of a document run as one range, so the label may be in an earlier section (0.53; see Shots as sections). A label outside the<hub>option the jump is in isE-JUMP-LOOP-SCOPE. - A loop is entered only at its head. A forward jump from outside to a label inside the loop after its head is
E-JUMP-INTO-LOOP. A forward jump to the head, and a jump out of the loop, are fine. - Loops nest, they never cross. Two loops either nest or are disjoint, or it is
E-JUMP-LOOP-CROSSat the later jump. Two bounded jumps may share one head, each with its own counter.
A nested loop counts for itself. The inner counter returns to 0 every time control reaches the inner head from
outside the inner loop, so an inner max="2" inside an outer max="1" runs the inner body three times in each of
the two rounds:
::label{name="round"}@theo: A new round.::label{name="knock"}@theo: Knock.::jump{to="knock" max="2"}@theo: Round done.::jump{to="round" max="1"}Between two takes, control moves only forward, so a region of nesting depth d runs at most the product of
(max + 1) over its enclosing jumps: every walk ends without the player’s help, and the analyses stay finite.
Every flow analysis joins the states of all jumps that reach a label, forward and backward, and iterates a loop head
until it is stable. A read after a label that a jump skipped a ::set to is E-MAYBE-UNSET, a jump past a
re-entrance is W-STAGE-ABSENT, and a bounded jump does not end its body (W-CODE-AFTER-JUMP stays quiet; its
exhaustion falls through). That also fixes two gaps for forward jumps, so a document that jumps over a write may gain
an E-MAYBE-UNSET it did not have in 0.50.
For play and tests: counters are local to one walk and rebuilt by replay, so the runtime snapshot needs nothing
new. A <branch> reached again in a later iteration is a new menu, and a scripted choose: list for it is
consumed one decision per presentation (choose: { retry: [yes, yes, no] } drives three iterations). A <hub>
inside a loop starts each visit at its first presentation, and a once choice taken on an earlier visit stays
spent. lute trace prints <jump -> attempt (again 1/2)> and <jump -> attempt not taken (2/2 used)>. The
reference runtime re-checks the loops at load and refuses an artifact whose backward transfers are not
well-nested bounded jumps. A ::label inside a component is compiled once per ::use instance, so each
instance loops on its own label (#207).
Use a loop for “the same stretch again, a bounded number of times”. Use a <hub> to offer a menu again until the
player takes an exit choice.
Shots as sections
Section titled “Shots as sections”A source set that is cut into shots maps to one ## section per shot, and a port may keep that cut: the importer
emits ## Arrival {#shot-01}, ## The lock {#shot-02}, … and no longer has to merge shots to keep a loop inside one
section. A section is presentation and identity — its heading, its {#id} in the artifact’s sections table — and not a
runtime boundary: the sections of a scene run as one range, and a ## heading is not a jump target, a resume point or a
host entry. Since 0.53 a bounded ::jump may therefore loop back to a label in an earlier section; the loop’s region is every node
from the head through the jump, across the sections between. Every other loop rule is unchanged (E-JUMP-INTO-LOOP,
E-JUMP-LOOP-CROSS, nesting, E-JUMP-LOOP-SCOPE for a loop that leaves a hub option, <return>, <on>, <objective>, an
entry or a beat body). Three shots, the third of which sends the player back to the first twice at most:
---kind: sceneid: demo.vault---## Arrival {#shot-01}
::label{name="attempt"}@mira: The vault door is locked.
## The lock {#shot-02}
<branch id="lock"> <choice id="pick" text="Pick it"> @mira: It jams. </choice> <choice id="leave" text="Leave it"> ::end </choice></branch>
## Again {#shot-03}
::jump{to="attempt" max="2"}@mira: That's enough for today.A forward jump that enters a loop anywhere but its head is still E-JUMP-INTO-LOOP. When that jump is the last node of a
<choice> body and nothing else targets its label, the message adds the move that fixes it (“blockA is reached only
from option a of <branch id="pick">: move the block into that option’s body”). Content that a host routes to is not
reached by a section jump: it is a bridge result read by a <match>, as for every host-driven flow. See
a loop may span sections.
Quests the host grants
Section titled “Quests the host grants”A sheet row “the board offers quest B once quest A is done” is a quest the host grants: the board, a menu or a
server owns the grant. Write it accept="external" with the condition in follows:
---kind: questtitle: The boardstate: run.dived: { type: bool, default: false }---
<quest id="survey" title="Survey the reef" tier="run" start="true"> <objective id="look" title="Look at the reef" done="run.dived"/></quest>
<quest id="salvage" title="Salvage the wreck" tier="run" accept="external" follows="completed('survey')"> <objective id="dive" title="Dive the wreck" done="run.dived"/></quest>- The host applies
follows. Lute carries the condition to the host (prereqEdges) and analysis reads it as host policy (lute scenario reachreports the verdict withheuristicevidence); Lute does not enforce it, and 0.53 adds no runtime gate. A reward kind’seffects.acceptsor a::acceptactivates the quest whatever itsfollowssays; there is no spelling that gates an in-script accept. accept="external"withstartis refused (E-ATTR-TYPE): astartcondition activates a quest by itself, so the host could never decide. The message namesfollows="…"as the spelling for a condition the host applies.- A quest that is not external is reached only through its in-script accepts, never through its
follows. An importer that maps a “unlocked by” column tofollowson a quest nothing accepts getsW-QUEST-NEVER-ACCEPTED; mark itaccept="external"if the host grants it. - A cycle of in-script accepts that nothing outside it enters is
E-QUEST-START-CYCLE; let the host grant one quest of the cycle withaccept="external".
See routes into an accept-driven quest.
Scenes that a directive or a reward raises
Section titled “Scenes that a directive or a reward raises”A sheet row “play cutscene X” does not run the cutscene. The host does, by raising an occasion that the scene answers. Lute does not raise occasions (that is a host duty), so before 0.51 the scene that answered it had no edge from the row that caused it, and the scenario graph could not order them. Since 0.51 a directive or a reward kind declares the occasions the host raises in answer to it, and an occasion can say that only declared raises ever raise it:
occasions: cutscene: { select: first, target: { prefix: scene, entity: cutsceneId }, raisedBy: declared }# plugins/acme.flow/directives/flow.yamldirectives: - name: playScene attrs: - { name: scene, required: true, type: { entity: cutsceneId } } effects: raises: [{ occasion: cutscene, target: "@scene" }]# plugins/acme.flow/rewardkinds/rewards.yamlrewardKinds: CUTSCENE: { target: { entity: cutsceneId, required: true }, effects: { raises: [{ occasion: cutscene, target: "@target" }] } }---kind: sceneid: chapter2on: cutscenetarget: scene.chapter2---::playScene{scene="chapter2"} in a scene, or a <reward kind="CUTSCENE" target="chapter2"/> in a quest, now
raises cutscene for scene.chapter2.
effects.raisesis a non-empty list of{ occasion, target? }, besidewrites/asserts/retractson a directive andasserts/retracts/acceptson a reward kind. A reward kind may declare onlyraises. Thetargetis@<attr>on a directive (a declared attribute),@targeton a reward kind (the reward’starget=), or a literal target written whole (scene.chapter2); it is absent only for an occasion declared without a target.raisedBy: declaredon an occasion says the host raises it only where a declaredraisessays so. Without it a declared raise is recorded and checked but draws no graph edge.- A shape fault is
E-PLUGIN-PARSEat load. A directive that has alower:compiles to a record that carries no effects, so itsraisescould never reach the host:E-PLUGIN-INVALID-DIRECTIVE. At assembly,E-PLUGIN-RAISESchecks the occasion (with a did-you-mean) and the target’s shape and type against it. The occasion may come from another plugin, which then belongs independs:. - In
check --project,E-RAISES-TARGETchecks each resolved target: a member of the entity kind, and answered by some scene or beat. The value is the call’s attribute, else the attribute’sdefault:; a target with no value raises nothing, and one that readsoccasion.targetis dynamic and not judged. W-OCCASION-NEVER-RAISEDwarns at a scene or beat that answers araisedBy: declaredoccasion that noeffects.raisesin the project reaches. Alute playstep that raises it is not a source, and an occasion the project clock raises is never warned about. Re-level it inlute.project.yamldiagnostics:like anyW-*code.
Every analysis reads a declared raise as possible, never guaranteed: the host may refuse it at the occasion’s
gate, so a raise adds no fact, write or must-claim in the answering scene. lute scenario and
lute scenario reach draw a [raises] edge from the raiser to every answering scene and beat, and a scene that
declares after: keeps after: as its route. (Since 0.52 lute impact follows
[raises] and [accept] edges too; #210.) The flow-and-text example:
$ lute scenario --project docs/examples/flow-and-textproject root: docs/examples/flow-and-text topological layers: layer 0: scene(hall), scene(lock), quest(finale) layer 1: scene(chapter2) edges (prerequisite -> dependent) [atom kind(s)]: scene(lock) -> scene(chapter2) [raises] quest(finale) -> scene(chapter2) [raises]The host performs the raise. After the presentation that ran the call ends, it sends each raise as a
raiseOccasion input once the runtime is idle, in record order. For a reward it does so after settling the grant.
The compiled plugin and grant records carry the resolved raises, and lute play prints
→ raises cutscene scene.chapter2 on those lines. The reference player performs them for you, keeps the pending
ones in saves and rollback points, and a custom host does the same (see Custom hosts).
lute play does not raise by itself: a script raises the occasion as its next step.
Text in a directive that players read
Section titled “Text in a directive that players read”A staged banner, a toast or a chapter title is text a player reads, so it must be translatable. Declare such an
attribute type: text:
directives: - name: banner attrs: - { name: title, required: true, type: text } - { name: subtitle, type: text } - { name: style, type: { enum: [toast, card] } }::banner{code="0010" title="Welcome to the hall, {{userName}}" style="card"}- A
textvalue is a quoted string in the choice-label grammar:{{…}}interpolations of state paths,@defs and host tokens, each checked as in a label.\{{writes a literal{{(since 0.52, #211; on 0.51 it wasE-STRING-ESCAPE). An@refvalue isE-ATTR-TYPE. textis admitted only on an attribute of a directive that stays apluginrecord: nolower:and nobridge:. It cannot have adefault:, and it is not a type for stamps, plugin options, payloads or reward-kind attributes. A directive with atextattribute cannot declare an attribute namedcode. Each fault isE-PLUGIN-PARSEat the attribute, and a scoped stamp namedcodeon such a directive isE-PLUGIN-RESERVED-STAMP-ATTR.code=is the identity of such a directive, as on a line. ThelineIdof each text attribute is{prefix}~{tag}~{code}~{attr}:hall~banner~0010~title.~is reserved for these ids, so acodethat contains it isE-LINE-CODE-RESERVEDand alineIdtemplate that contains it isE-IDENTITY-TEMPLATE. Two::bannercalls in one scope with the samecodeareE-DUP-LINE-CODE, and two units anywhere in the project with one finallineIdareE-LINE-ID-DUP.code=on a directive without atextattribute isE-UNKNOWN-ATTR.lute tagwritescode="…"into every untagged directive that has atextattribute, in the order the compiler back-fills; a component is tagged under the snapshots of its users, and users that disagree areE-COMPONENT-TEXT-DIRECTIVE. Withidentity.requireStable,W-LINE-CODE-UNTAGGEDreports one without a code.
The translation round trip is the one lines already use, and a text attribute is one more row kind:
lute tag sceneslute loc export --project . -o export/en.json# copy it to export/ko.json (the file stem is the locale) and translate the "text" values,# keeping every {{…}} markerlute loc import export/ko.json -o loc/ko.json # the locale bundlelute compile scenes/hall.lute --project . --locale-bundle loc/ko.jsonAn exported row is { "kind": "directive", "lineId": "hall~banner~0010~title", "key": "banner.title", … }. Its
lineId is null until the directive has a code, so run lute tag first. lute loc report counts directive
texts (directiveTexts) with the prompts. In the compiled plugin record, fields.title keeps the source text,
markers as authored, and localized.title carries { lineId, placeholders, texts } with the translations merged in. A missing
locale is W-L10N-MISSING and a changed source is W-L10N-STALE, as for a line. The runtime renders the markers
(a host token stays verbatim), and the reference player’s PluginCall.localized gives a host handler each text in the
player’s locale.
A translation shows its source’s markers: each {{…}} marker of the source appears in the translation as often as in the
source, in any order (the spaces at a marker’s edges do not count; a space inside it or a format hint does). A translation
that drops one, adds one, or writes one that opens on a literal brace is E-L10N-MARKERS: compile --locale-bundle refuses
it. A line translation with a {{ that has no }} fails the compile as a markup error, E-L10N-MODIFIERS (wrapping
E-INTERP-UNTERMINATED). lute loc import sees only the translated rows and refuses a marker that does not parse, that
unterminated {{ included, as E-L10N-MARKERS. See translate Quayside.
Structured values are not supported yet
Section titled “Structured values are not supported yet”A column that holds a list or a record (a tag list, a voice-asset map keyed by language) does not fit a directive
attribute. list, map and record attribute and stamp types stay refused with E-PLUGIN-INVALID-DIRECTIVE,
because no .lute value can satisfy them. Structured authoring (#176) is deferred, not covered: there is no
spelling in 0.51 for generic enum-list metadata such as ::tagged{tags=…}, and none for a voice-asset map keyed by
language. One voice key per line, resolved without a locale, is not a per-language asset map.
Where a scalar decomposition fits, use it:
| The row has | Write |
|---|---|
| a few named fields | one attribute per field; scope a stamp to the surface with on: |
| text a player reads, in several languages | one text attribute |
| a list that really is a set of options | <choice> elements |
The refusal message says the same: give each field its own attribute, use a text attribute for player-visible
text, or write a list of options as <choice> elements. For a stamp the text clause is omitted, since a stamp cannot be text.
Running lute fix on an old project
Section titled “Running lute fix on an old project”A port often starts from a project written against an older release: an importer’s output that predates 0.37, a
luteVersion that was never raised. Run lute fix --check first, review the diff, then lute fix.
lute fix applies every migration of every older release, not only the version raise, so the diff can be larger than
the stamp: ## Shot N. prefixes, renamed directive tags, id= on content lines. A plugin stamp is kept: a key your
plugin declares as a stamp attribute is not a 0.37 spelling, and each site lute fix leaves alone is printed. See
What lute fix runs.
$ lute fix --check .lute: ./lute.project.yaml: would apply 1 fix(es): lute-version 1lute fix: ./scenes/intro.lute:9:22: kept `action` on `::cg` — the active profile declares it as a stamp attribute; the 0.37 rename to `display` does not applylute fix: ./scenes/intro.lute:10:25: kept `action` on `::music` — the active profile declares it as a stamp attribute; the 0.37 rename to `playback` does not applylute fix: ./scenes/intro.lute:11:25: kept `action` on `::video` — the active profile declares it as a stamp attribute; the 0.37 rename to `display` does not applylute: ./scenes/old.lute: would apply 4 fix(es): shot-heading 1, renamed-tag 1, line-id-label 2lute: would apply 5 fix(es) in 2 of 6 file(s)$ lute fix .lute: ./lute.project.yaml: applied 1 fix(es): lute-version 1…lute: ./scenes/old.lute: applied 4 fix(es): shot-heading 1, renamed-tag 1, line-id-label 2lute: applied 5 fix(es) in 2 of 6 file(s)$ lute check --project .ok: ./scenes/intro.lute (0 warning(s))ok: ./scenes/old.lute (0 warning(s))ok: . (2 file(s), 0 project-wide warning(s))Migrating from 0.52.0: run lute fix --check, review, then lute fix. lute fix applies every migration of every older
release, not only the version raise; see What lute fix runs. A port that kept one section
per source shot needs no change for 0.53: loops across sections are allowed, and an accept="external" quest may carry a
follows the host applies.
What the tool catches, and what it does not
Section titled “What the tool catches, and what it does not”| Problem in the port | Caught by |
|---|---|
| A column written on a row it does not belong to | E-UNKNOWN-ATTR, naming the surfaces of the stamp |
| A row missing a required column | E-MISSING-ATTR for a scoped required: true stamp |
| A stamp declared required on every record | E-PLUGIN-PARSE at load |
| A stamp named after an attribute its surface already has | E-PLUGIN-RESERVED-STAMP-ATTR |
A misspelled surface in on: | E-PLUGIN-STAMP-SURFACE, with a did-you-mean |
| Two declarations of one column on overlapping surfaces | E-PLUGIN-DUP-ID, E-PLUGIN-DUP-ACROSS |
{{playerName}} the plugin does not declare | E-CEL-PROFILE, naming the declared tokens |
| A number hint on a text token, or the reverse | E-REF-TYPE |
| A badge fact no quest can see | gone: E-QUEST-UNREACHABLE reads the reward effect |
| A reward that accepts a quest that does not exist | E-ACCEPT-TARGET, with a did-you-mean |
| A warning flood in an imported subtree | diagnostics: with paths:, counted in the summary |
| A speaker shown off stage | W-STAGE-ABSENT, unless the importer emits the staging row |
| A structural or dependent-value rule | a node lint |
| A loop the checker could not see | ::jump{max} with E-JUMP-MAX, E-JUMP-LOOP-SCOPE, E-JUMP-INTO-LOOP, E-JUMP-LOOP-CROSS |
| A scene that nothing raises | W-OCCASION-NEVER-RAISED; a mistyped raise target is E-RAISES-TARGET |
| A raise of an occasion no plugin declares | E-PLUGIN-RAISES, with a did-you-mean |
| Player-visible directive text that cannot be translated | type: text, lute tag and lute loc |
| Two translation units with one id | E-LINE-ID-DUP; ~ in a code is E-LINE-CODE-RESERVED |
| An opener that is never closed | a pair lint |
| A retry loop that spans shots | ::jump{max} across sections (0.53) |
| A quest the host grants once a condition holds | accept="external" follows="…"; start with it is E-ATTR-TYPE |
| A translation whose markers differ from its source’s | E-L10N-MARKERS |
| An old project’s stale spellings | lute fix --check, then lute fix |
| A list or record column | refused; use scalar decompositions |
What Lute does not know:
- Whether a stamp’s value means anything to your engine.
voiceAssetId="VO.mira.0010"is a string (or aproviderRef, when you pin a catalog). The engine’s asset table is yours. - What your engine does with a token. A host token is a promise that the host substitutes it. If the host forgets, the marker stays in the text.
- Whether a quiet subtree is correct.
offhides a warning; it does not fix the row. Keep thepaths:globs narrow, and delete the entry when the review is done. - Whether the host performs a declared raise. A
raisesentry is a promise about the host, never a guarantee that the scene plays: the occasion’s gate may refuse it. - Whether a translation is right.
W-L10N-STALEsays the source changed after the export; it does not read the translation.