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>
15 KiB
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](orbin/ludic-ui-previewdirectly) 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.-0is written0. 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 shows 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 sets 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,yis the TOP LEFT of the line box, not the baseline. The game's overlay puts the baseline aty + 0.8 * floor(size), and draws each glyph from font.json's cell withpad_x/base_y.sizeis the font size in screen pixels as ludic.ui asked for it; the game draws and measures atfloor(size)pixels an em.widthis 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'sadv(from font.json; a control character is the space, a code point the atlas lacks is?) timesfloor(size), summed, the sum truncated to a whole pixel. Line breaking,text-fit: shrinkand ellipses were decided with the same numbers, so the studio's own rendering of the run should be fitted towidth.- With no font loaded (
hellosays""), every code point measures half offloor(size). - The string is the text as drawn: already translated (
locale), already wrapped (one line atextline) and already cut with its ellipsis. A text-shadow is its owntextline, 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);
nullpast its end; - a JSON object: its value under the arguments' texts joined by
,("0","food","2,3"), else under"*", elsenull; - 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.)