Building the display sink: contract, deviations, boundary
Table of Contents
The banner above is not a picture of the display. It is the display, computed from a snapshot of its DOM by the same rules the page uses, and it stays where it is while the page scrolls past it. Both facts come from the same decision: the sink's observable surface is data, so anything that reads the data can reproduce the render.
1. Overview
/tools/display/ is a sink. It draws w by h cells, each one of 16
palette entries, from frames a single source sends, and it renders
nothing on its own. The contract lives at
the spec (v0.2.3 at the time of
writing) and the page at the
tool. This note is the build log: what was specified, what moved
between drafts, where the boundary sits, what broke, and how each
claim is checked. The code is src/wal_sh/tools/display/ (a pure
.cljc core, a browser adapter, a same-page source, a renderer) with
tests under test/wal_sh/tools/display/, all run under bb.
2. The contract in four tables
Four things are fixed by the spec and read by everything else.
Query parameters. d picks a preset; w h px gap aspect src palette
fps override one field each; a value outside its domain is rejected,
never clamped, and the page shows an error element naming the
parameter. w or h given explicitly fixes the grid. The resolution
is a fold over the parameters in table order, so the first bad one
wins:
(core/parse-params {"d" "blinkenlights"})
;;=> {:ok {:d :blinkenlights :w 18 :h 8 :px 0 :aspect 1.6 :gap 0.3
;; :palette :mono :fps 5 :rate 3 :src nil :fixed? false}}
(core/parse-params {"d" "green-building" "w" "9" "h" "17" "fps" "60"})
;;=> {:ok {... :fps 8 :rate 4 :fixed? true}} ; fps lowers only; w and h fix
(core/parse-params {"px" "201"})
;;=> {:error :bad-px}
Presets. Twelve named geometries, each with a palette, a frame-rate
ceiling, a preferred rate, and a kind. The default is cga40, 40 by 25
at cell aspect 1.2, because the IBM PC's 40-column mode is the display
the author remembers.
Frames. pal16, one byte per cell, is the wire form; hex, one text
line per row, is the same information for anything that speaks
printf. The sink decodes both and the two encodings of the same
cells decode equal:
(core/encode-frame :hex 9 2 [0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 15 0])
;;=> "012345678\n9abcdeff0\n"
(= (core/decode-frame 9 2 hex) (core/decode-frame 9 2 pal16))
;;=> true
The fold. reduce-event is total over open caps lease frame error
close tick and anything else: malformed input increments :dropped
and changes nothing. Its invariants are test.check properties: cell
count is w*h, every cell is 0 to 15, :seq is monotone, an
undecodable frame never touches the cells.
3. Deviations
The spec went through three drafts in a day and one merge from outside. The table is what moved and what held.
| against | proposed | landed | why |
|---|---|---|---|
| issue #93 first draft | cell aspect 0.8 (height over width) | 1.2 (width over height) | one convention throughout; CGA's 6:5 pixel |
| issue #93 first draft | idx4 nibbles and rgb24 on the wire |
pal16 native, hex text |
a byte a cell is 153 bytes for the Green Building; RGB carries 20 bits the display discards |
| v0.1.0 build | preset id cga |
cga40 |
the palette is also called cga; the id names the mode |
| v0.1.0 build | sink-side quantization of RGB | removed (NR-QUANT) | two sources sending the same picture in different RGB must render identically; quantize before the wire |
| v0.1.0 build | no px |
px 0 to 200, a rendering hint |
a fixed cell height for a kiosk; never geometry authority |
| 17x9-Tetris display contract | cga40 aspect 1 |
kept 1.2 | the tube, not the cell count, sets the look |
| 17x9-Tetris display contract | max_fps 30 |
kept hub75 at 60 |
the panel does 60; the ceiling is the hardware's |
| 17x9-Tetris display contract | rgb888 accepted by the relay |
recorded as relay-side only | the sink never sees it |
| 17x9-Tetris display contract | kind, levels, mono, two facades, BLP and MCUF interop |
adopted | the facades are real displays with real depths |
| own v0.2.2 | facades at 30 fps | ceilings 5 and 8, rates 3 and 4 | see 6 |
The largest change under "adopted" is the palette rule. Short palettes first aliased every high index to their last entry; the contract's rule reduces an index to the display's levels instead, so a four-entry Game Boy palette is a ramp and a lamp is lit or unlit:
(mapv #(core/level 2 %) (range 16)) ;;=> [0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1]
(mapv #(core/level 4 %) (range 16)) ;;=> [0 1 1 1 1 1 1 1 2 2 2 2 2 3 3 3]
(mapv #(core/level 8 %) (range 16)) ;;=> [0 1 1 1 2 2 3 3 4 4 5 5 6 6 7 7]
Index 0 is always unlit and any lit index stays lit; the rest is
round(idx * (n-1) / 15).
4. Boundary
The question that shaped the second half of the day was whether a demo could drive the display without the display knowing about demos. The answer is a boundary listed crossing by crossing in the spec.
Sink to source, read-only: the advertisement at capabilities.json
(generated from the core's tables at build time, served with GET-only
CORS so a driver opened from disk can read it); one announcement,
{display:"ready", d, w, h, palette, levels, mono, fps, rate, kind},
posted to the window that opened or embeds the page once the grid is
mounted, and raised as a display:ready event for a source on the
same page; and the window.display getters.
Source to sink, the only accepted input: {frame} by postMessage,
or window.display.send(frame). Its effects are exhaustive: cells
replaced and data-seq incremented, or nothing changed and
data-dropped incremented. A message without a frame key is
ignored.
What a source cannot do, because no message exists for it: change any
parameter, take or release the lease, alter the announcement or the
advertisement. And the check from outside, which the reference driver
and the harness both run: send one frame of length w*h+1 and watch
data-dropped move while data-seq and the cells hold.
A relay's caps is the one input that can resize the grid, and only
when the URL did not fix it:
(-> (core/initial-state (:ok (core/parse-params {"d" "green-building" "w" "9" "h" "17"})))
(core/reduce-event {:op :caps :w 64 :h 32 :fps 60})
(select-keys [:w :h :error :status :refused :dropped]))
;;=> {:w 9 :h 17 :error :bad-src :status :error :refused {:w 64 :h 32} :dropped 0}
The refusal is an error, not a drop: the page shows #display-error
naming src with the relay's geometry, and the grid the URL asked for
stays.
5. Built-in sources
?demo=<bars|bounce|matrix|life|plasma> runs a source that ships in
the bundle, for a display with no driver at hand. It is a page
parameter, not a sink parameter: the sink ignores it, and the
namespace that reads it hears display:ready, reads window.display,
and sends frames through window.display.send, the same three
crossings an external driver uses. The sink never requires it. The
demos are pure and seeded:
(second (demos/step (demos/init :bars {:w 4 :h 1}))) ;;=> [0 4 8 12]
so a run from a seed is reproducible and every demo is tested on every
preset under bb. The picker under the grid switches demos with
history.replaceState; the page does not reload, the sink is not
re-mounted, and the only thing that reaches it is frames.
6. Flash safety
The first version of the built-in source ran at the preset's fps,
which was a hardware ceiling. On the Haus des Lehrers preset that put
Conway's Life on 144 relay-switched lamps at 30 Hz. The author's
reaction was that it would cause a seizure, and it would: WCAG 2.3.1
puts the threshold at three flashes a second, a flash being a pair of
opposite changes, so a whole-field animation is on the limit at 6 fps
and far over it at 30.
Every preset now carries two rates. fps is the ceiling the sink
accepts; rate is what a source should run at. Facades accept 5 or 8
and prefer 3 or 4, which is also all a halogen lamp behind a relay
will do; screens and panels keep their ceilings with rates of 10 to
- The sink enforces the ceiling on the same-page path as the relay
does on the wire, with the page clock on each frame:
;; blinkenlights: fps 5, one frame per 200 ms
(mapv (juxt :seq :dropped)
(reductions core/reduce-event s0
[{:op :frame :bytes f1 :at 1000}
{:op :frame :bytes f2 :at 1100} ; 100 ms later
{:op :frame :bytes f3 :at 1200}]))
;;=> [[1 0] [1 1] [2 1]]
No source on the page can strobe the display past its ceiling, whichever demo or driver sends.
7. The gate that was never served
The tool ships behind a feature gate, tool.display, evaluated in the
browser from a static config. The first live load logged
GET /data/experiments.edn 404. The file had never been deployed and
the host does not serve .edn, so every gated tool on the site, six
of them, had been rendering "gated off" since the gate SDK landed. The
SDK had also been parsing the HTML 404 page as EDN, which reads as a
symbol and evaluates every gate to false without an error.
The fix is a JSON carrier: gmake experiments-json exports the EDN
with keywords as ":name" strings, refuses to write unless the JSON
decodes back to the EDN it came from, and the browser fetches
/static/experiments.json; a non-2xx response now rejects. Dropping
the EDN reader took about 40 KB off each bundle. A page's
:EXPERIMENT: drawer, which nothing had rendered, now appears as a
badge under the keyword chips linking to the experiments page.
8. The expected render
The last piece answers a question asked with a DOM snapshot: given the
#display element as the browser serialized it, can the expected
picture be generated? It can, because the semantic DOM is the whole
contract. The renderer reads data-w data-h data-aspect data-gap
data-px data-palette from the root and one <i data-x data-y
data-c> per cell, and draws SVG with the same palette rule and the
same geometry the CSS derives: cell width is cell height times aspect,
the gap is the gap fraction of the cell width, a backdrop behind the
cells.
(let [v (render/dom->view snapshot)]
((juxt :d :w :h :palette :seq :dropped :cell-h) v))
;;=> ["arcade" 20 26 :grey8 772 2 34.61538461538461]
(count (re-seq #"<rect " (render/dom->svg snapshot)))
;;=> 521 ; the backdrop and one per cell
The snapshot is a fixture in the tests, kept as the verbatim root tag
plus the 520 cells as 26 rows of hex, the spec's own text format. Its
data-dropped of 2 against data-seq 772 is the ceiling at work on
the arcade facade. The banner is that fixture rendered at 40 px a
cell and rasterized; a screenshot of the page and the banner should
agree cell for cell, and where they do not, one of the two is wrong.
9. Verification
| check | result |
|---|---|
| bb tests, core + demos + render | 52 deftests, 758 assertions, 9 defspecs, pass |
decode format symmetry |
property, 200 runs |
| fixed grid never resized | property, 200 runs |
live capabilities.json |
identical to the generated file, CORS GET |
| live bundle | local size equals Content-Length |
| live badge on all six gated tool pages | data-experiment present |
The live checks are curl against the deployed files, compared by
size and by field, since a 200 alone says nothing.
10. Open questions
- The relay (
/tools/display/ws) is specified and not hosted; it is thedisplay.relay-hostexperiment. Until it lands, the only live paths are same-page and cross-window. - The two Blinkenlights facades carry a placeholder gap of 0.3. The window-to-masonry ratio should be measured from the catalogue photographs.
- Toronto City Hall, the Cira Centre, and the Schonherz dormitory are
advertised as
unconfigured: their grids are not in the sources consulted, and guessing a grid for a real building is the wrong kind of deviation.