ludic/tools/ui-preview/protocol-v1.md
Orkuncakilkaya 49b5e80af5 ludic ui-preview: ludic.ui components previewed for a studio over stdio (R5)
A prebuilt tool, bin/ludic-ui-preview (built by ludic-dev build, shipped beside ludic-lsp, run as
`ludic ui-preview [--font DIR]`): ludic.ui with a backend that records every draw call as a line,
and mock component classes the studio describes (load / model / calls). frame t runs ui_show at
interface time t; text is measured on the CPU with the game's font.json metrics; locale goes
through ludic.i18n; tree / box / rules inspect the frame. No game code, no GPU, no network. The wire
protocol is frozen as tools/ui-preview/protocol-v1.md; smoke.txt is a transcript to run.

ludic.ui gains, all additive: UiClass.make_of, UiBackend.said / emitted, UiRule.text / at (with
comment line breaks kept so rule lines count true, and a class's styles read under its .lss path),
and ui_root, ui_find, ui_rules_of, ui_rule_value, ui_building, ui_class, ui_class_load,
ui_file_forget, ui_errors_clear; ui_nine_cuts_into exported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:53:26 +03:00

244 lines
15 KiB
Markdown

# ui-preview wire protocol, version 1 (frozen)
This is the whole contract between `ludic ui-preview` (the host) and a studio that spawns it. The
studio builds its process management and its canvas replay against this document and nothing else.
Version 1 is frozen: a change to any line format below is version 2, announced by `hello`. A later
v1 host may add a request or an event kind; a studio ignores reply lines it does not know.
## 1. Transport
- The studio starts `ludic ui-preview [--font DIR]` (or `bin/ludic-ui-preview` directly) and talks
to it over stdin (requests) and stdout (replies). Nothing else is read or written except the files
a request names. stderr carries only a start-up complaint about `--font`; ignore it.
- Everything is UTF-8. A line ends with LF (`\n`); a CR before the LF of a request is dropped.
- One request is one line. The host answers every request, in order, with zero or more reply lines
followed by exactly one **terminal line** (§3). It reads the next request only after writing and
flushing that terminal line, so a studio may pipeline requests.
- End of stdin, or `quit`, ends the process with exit status 0.
## 2. Words
A line is a sequence of words separated by one or more spaces or tabs. A word is either:
- a **JSON value** - a string (`"..."`), an object (`{...}`) or a list (`[...]`) - read whole,
whatever spaces, brackets or escaped quotes it holds; or
- a **bare word**: a run of characters other than space and tab.
In the request tables below, `<text>` is a JSON string or a bare word (a bare word is its own text);
`<num>` is a JSON number written bare (`12`, `-3.5`); `<json>` is a JSON value.
Template and stylesheet text contains line breaks, so it is always sent as a JSON string.
### 2.1 Quoting in replies
Every string the host writes - in a draw line, an event, an answer - is a JSON string quoted the same
way: `"` and `\` are escaped as `\"` and `\\`; LF, CR and TAB as `\n`, `\r`, `\t`; any other byte
below 0x20 as `\u00XX` (lower-case hex); every other byte, UTF-8 included, is written as it is. A
reply line never contains a raw LF.
### 2.2 Numbers in replies
- An integer (`<int>`) is written in decimal: `-12`, `0`, `640`.
- A real number (`<real>`) is rounded to the nearest thousandth, written with no exponent and with no
trailing zeros or trailing point: `12`, `12.5`, `-0.125`, `0.333`. `-0` is written `0`. A value
beyond 2,000,000 in magnitude is written as a whole number, truncated.
- A colour (`<rgb>`) is `#rrggbb`, six lower-case hex digits, 8 bits a channel, sRGB as the game
draws it (no premultiplication). An alpha (`<a>`) is a `<real>` from 0 (clear) to 1 (opaque).
Every primitive and style line that has a colour writes it as the pair `<rgb> <a>`.
## 3. Terminal lines
| Line | Meaning |
| --- | --- |
| `ok` | the request was done |
| `ok ui-preview 1 "<font dir>" "<locale>"` | the answer to `hello` only (§4) |
| `fail "<why>"` | the request was not understood or could not be done; nothing changed |
A template error is NOT a `fail`: the request succeeded and the error is reported as an `err` event
(§6) before the `ok`.
## 4. Requests
| Request | Reply before the terminal line |
| --- | --- |
| `hello` | none; the terminal line is `ok ui-preview <protocol> "<font dir>" "<locale>"`, protocol `1`, `""` for no font or English |
| `font <text dir>` | none. Reads `<dir>/font.json` (§5.3); `fail` if it has no `adv` list |
| `viewport <num w> <num h> [<num scale>]` | none. The frame's size in screen pixels, and screen pixels a design pixel; with no scale, `h / 1080` (the game's own rule). Default 1920 1080 |
| `locale <text po path>` | none. Every text a template shows is looked up in that gettext file as the game looks it up (ludic.i18n: exact, then patterns with holes, then sentence by sentence). `""` or `en` is English. The path is read through `file` overrides first |
| `load <text Name> <text xml> <text lss> [<text xml path> [<text lss path>]]` | events only (`err`). Registers component class `Name`, or replaces its template and styles in place if it exists. Paths default to `Name.xml` and `Name.lss`; they name the files in errors and rules, and an `@import` in the styles is read from beside the xml path. Every capitalised tag in the template naming a class not yet loaded gets an empty class (`<row/>`) of that name, filled in place when the studio loads it; the studio sends every class a story names before it `show`s the root |
| `model <text Name> <json object>` | none. The class's model: every key is a name its template reads. Props from the parent's tag and values the template `set`s are laid over it |
| `calls <text Name> <json object>` | none. How the class's functions answer (§7) |
| `show <text Name>` | none. The class a `frame` shows, as a whole screen; `fail` if it is not loaded |
| `native <text tag> [<num w> <num h>]` | none. `<tag>` becomes an element the game draws itself: laid out with that content size in design pixels (default 0 0), drawn as a `native` line. Declare natives before the templates that use them are loaded |
| `atlas <text prefix> <text name> <int cols> <int rows> [<json list of names>]` | none. `<img src="prefix:cell">` is a cell of atlas `name`: a `cols` x `rows` grid, cells numbered row by row from 0, named by the list (a cell is also reachable by its number) |
| `image <text path> <num w> <num h>` | none. A picture's natural size in pixels, for `object-fit`; an undeclared picture has none (it fills its box) |
| `file <text path> <text contents>` | none. Reading `path` gives these contents (an imported stylesheet, a `.po`) instead of the disk's, until replaced; every class re-reads its styles at its next show |
| `input pointer <num x> <num y> <int buttons>` | none. Held until changed. Screen pixels; buttons is a mask: 1 left, 2 right, 4 middle |
| `input wheel <num d>` | none. Added to the next frame's wheel only |
| `input key <text name>` | none. Pressed in the next frame only. Names: `tab` `backtab` (shift-tab) `up` `down` `left` `right` `enter` `space` `escape` `backspace`, or a runtime key code as digits (a key-capture field) |
| `input text <text utf8>` | none. Typed in the next frame only (appended if sent twice) |
| `input shift <int 0/1>` | none. Held until changed |
| `input pad <text button> <int 0/1>` | none. Held until changed. Buttons: `a` (Enter), `b` (Escape), `up` `down` `left` `right` (a held direction repeats as the game's does), `enter` (Enter held down) |
| `frame [<num t>]` | the frame (§5), then events. `t` is the interface clock in seconds (animations, transitions, key repeat); it is the studio's to drive and may go backwards. Without `t` the clock stays where it was |
| `tree` | one `node` line an element of the last frame (§8) |
| `box <text id>` | a `box` line and its `style` lines (§8); `id` is an element's id or its key |
| `rules <text id>` | `rule` / `inline` lines, each followed by its `decl` lines (§8) |
| `reset` | none. The root's template state and component instances are forgotten, as a screen shown for the first time |
| `quit` | none; the process ends after `ok` |
Input takes effect in the next `frame`; a press's events are reported by that frame.
## 5. The frame
```
frame <real t>
<draw line>
...
end
<event line>
...
ok
```
The draw lines are the UiBackend's calls, in the order ludic.ui made them, which is painting order:
draw each over the ones before. Coordinates are screen pixels from the viewport's top left, x right
and y down. Every draw line starts with its kind:
| Line | Draw |
| --- | --- |
| `rect <x> <y> <w> <h> <rgb> <a>` | a filled rectangle |
| `round <x> <y> <w> <h> <radius> <rgb> <a>` | a filled rectangle with every corner rounded by `radius` (the game clamps it to half the shorter side) |
| `ring <x> <y> <w> <h> <radius> <width> <rgb> <a>` | a rounded frame `width` thick, drawn inside that rectangle (a focus ring, an outline) |
| `text <x> <y> <size> <width> <rgb> <a> "<utf8>"` | one line of text; see §5.3 |
| `image path "<path>" <x> <y> <w> <h> <u0> <v0> <u1> <v1> <rgb> <a>` | a picture file stretched over the rectangle; the source rectangle is always `0 0 1 1` (the whole picture) in v1 |
| `image atlas "<atlas name>" <int cell> <x> <y> <w> <h> <u0> <v0> <u1> <v1> <rgb> <a>` | a cell of an atlas (`atlas` request); the rectangle is already the square the game draws it in (centred, the shorter side, whole pixels); the source rectangle is the cell's share of the sheet, in 0-1 of its width and height |
| `nine "<path>" <slice> <x> <y> <w> <h> <dst> <x0> <x1> <x2> <x3> <y0> <y1> <y2> <y3> <rgb> <a>` | `border-image`: the picture cut `slice` source pixels in from each of its four edges, into nine pieces; the corners are drawn `dst` wide into the columns `x0..x1`, `x1..x2`, `x2..x3` and rows `y0..y1`, `y1..y2`, `y2..y3` (already on whole pixels, and never more than half the box); the edges and the centre stretch |
| `clip <x> <y> <w> <h>` | from here on, draw only inside this rectangle |
| `unclip` | from here on, draw everywhere |
| `native "<tag>" <x> <y> <w> <h>` | a native element's box: the studio draws its placeholder (or a story's still) there |
**Colours.** `<rgb> <a>` is the colour to draw with and its opacity, every opacity around the element
already multiplied in. For `image` and `nine` it is a TINT multiplied into the picture's own colours
(`#ffffff 1` draws the picture as it is).
**Clips do not nest in the list.** ludic.ui intersects nested clips itself: every `clip` line is the
whole clip from then on, replacing the one before, and `unclip` removes it. A frame starts
unclipped, and the next frame starts unclipped again.
### 5.3 Text
- `x`, `y` is the TOP LEFT of the line box, not the baseline. The game's overlay puts the baseline at
`y + 0.8 * floor(size)`, and draws each glyph from font.json's cell with `pad_x` / `base_y`.
- `size` is the font size in screen pixels as ludic.ui asked for it; the game draws and measures at
`floor(size)` pixels an em.
- `width` is the run's MEASURED width in screen pixels, from the host's own copy of the game's
metrics: for each code point, its glyph's `adv` (from font.json; a control character is the space,
a code point the atlas lacks is `?`) times `floor(size)`, summed, the sum truncated to a whole
pixel. Line breaking, `text-fit: shrink` and ellipses were decided with the same numbers, so the
studio's own rendering of the run should be fitted to `width`.
- With no font loaded (`hello` says `""`), every code point measures half of `floor(size)`.
- The string is the text as drawn: already translated (`locale`), already wrapped (one line a `text`
line) and already cut with its ellipsis. A text-shadow is its own `text` line, drawn before.
## 6. Events
Events are what happened while a request ran. They are written after the request's own reply lines
(after `end` for a frame) and before the terminal line, in the order they happened. Each is one line:
| Line | Meaning |
| --- | --- |
| `err "<file>" <int line> <int col> "<message>"` | a template or stylesheet error. `file` and `line` are where the element or stylesheet was written when ludic.ui knows it (`""` and `0` when not); `col` is always `0` in v1. The message is ludic.ui's, with the `file:line: ` it began with taken off. Each distinct error is said once; a `load` forgets them, so the next frame says again whatever is still wrong |
| `call "<Class>" "<name>" <json list of arguments>` | an action (a press, a key, a fired event) called a function of that class. Calls made while a frame's tree is built are not events: they are the story answering |
| `set "<Class>" "<name>" <json value>` | an action set the class's state `name` (`set name = value`); the value is kept and laid over the model from then on |
| `emit "<name>"` | an action ran `emit name` (a click is `press`), whether or not a parent handled it |
| `sound "<name>"` | a control asked for a sound (`click`, or its `sound="..."`) |
| `miss "<Class>" "<name>"` | a frame's build called a function `calls` does not answer; it answered `null`. Said once a class and name |
JSON values in events are written on one line with the quoting of §2.1 and the numbers of §2.2.
## 7. Function answers (`calls`)
`calls <Name> {"<function>": <answer>, ...}`. A template's call `f(args...)` on an instance of the
class is answered from `<answer>`:
- a JSON list: its item at the first argument (as an int); `null` past its end;
- a JSON object: its value under the arguments' texts joined by `,` (`"0"`, `"food"`, `"2,3"`), else
under `"*"`, else `null`;
- anything else: that value, whatever the arguments.
A constant object is written as `{"*": {...}}`. A name the table does not have answers `null` (and a
`miss` event during a build). `set name = value` never reaches the table.
## 8. Inspection replies
`tree` - one line an element of the last frame shown, depth first:
```
node <int depth> <int kind> "<key>" "<tag>" "<id>" "<classes>" <x> <y> <w> <h> "<text>"
```
`kind` is ludic.ui's: 0 box, 1 text, 2 spacer, 3 button, 4 scroll, 5 image, 6 rule, 7 native.
`key` is the element's key (unique in the frame, usable in `box` / `rules`); `tag` is `""` for a
node the template did not name (a text run); `classes` is space-separated.
`box <id>`:
```
box "<key>" <x> <y> <w> <h>
style font-size <real>
style color <rgb> <a>
style background <rgb> <a> (or: style background none)
style opacity <real>
style padding <top> <right> <bottom> <left>
style margin <top> <right> <bottom> <left>
style border-width <top> <right> <bottom> <left>
style border-color <rgb> <a> (or: style border-color none)
style border-radius <real>
style gap <real>
style flex-grow <real>
style flex-direction row|column
style align-items start|center|end|stretch
style justify-content start|center|end|space-between|space-around|space-evenly
style position static|relative|absolute|fixed <int z-index>
style content <w> <h>
style enabled true|false
```
Lengths are screen pixels, after the cascade and layout; `content` is the measured content size.
A later v1 host may add `style` lines; a studio ignores names it does not know.
`rules <id>` - the stylesheet rules that match the element now (its `:hover` and the viewport's
`@media` included), in the order the cascade applies them (the later wins), then its `style="..."`:
```
rule "<file>" <int line> <int specificity> "<selector>"
decl "<property>" "<value>"
...
inline "" 0 0 ""
decl "<property>" "<value>"
```
`file` and `line` are where the selector was written (the default sheet is `"the default sheet"`);
`value` is the declaration as written when it is plain text, and `""` when it is an expression
(`{...}`), which is evaluated per element.
## 9. Example
```
> hello
< ok ui-preview 1 "" ""
> load "Pill" "<row class=\"pill\"><p>{label}</p></row>" ".pill { background: #203040; }"
< ok
> model "Pill" {"label": "Food"}
< ok
> show Pill
< ok
> frame 0
< frame 0
< rect 0 0 1920 1080 ...
< text 0 0 16 32 #eeeeee 1 "Food"
< end
< ok
```
(The draw lines of a real frame depend on the templates and the default sheet; `smoke.txt` has a
transcript with what each line must contain.)