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

  1. 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 the display.relay-host experiment. 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.