OpenDMD

Psi language reference#

On this page

Psi is the language OpenDMD's Formulas are written in. A formula is a few lines of arithmetic, worked out for every pixel of the 128 × 32 panel every frame. Its last line is a colour.

This page lists every name the language knows and what it does. Then come how a formula runs, its limits, some recipes and the language in full. The error codes are on a separate page. To learn Psi, start with Make a Formula in the guide. To try it, open the demo's Formulas.

On the site, the search box filters the built-ins (press /), and Cheat sheet shows only the tables.

Every name#

kind names
values x, y, px, py, i, t, hour, minute, second, a, b, c, ur, ug, ub, ul, ink, seed, phase
values about the middle cx, cy, r, angle
built-in inputs (values from the panel) daylight, sun.alt, moon.phase, season, yearday, night, bright, replay.playing, replay.platform, replay.game
their events since(KEY), was(KEY)
constants w, h
functions (how many values) sin (1), cos (1), tri (1), saw (1), sqr (1), abs (1), floor (1), frac (1), fract (1), sqrt (1), min (2), max (2), step (2), hash (2), len (2), atan2 (2), clamp (3), mix (3), smoothstep (3), noise (3), select (3), bit (2), digit(…) (3 values and a font), glyph(…) (3 values and a font), pick(k, a, b, …)
colours (how many values, before the coverage) rgb (3), hsv (3), pal (2), gray (1), cycle (3)
palettes fire, ice, ocean, chrono, amber, rainbow, gray
fonts normal, small, bold
blends over, add, multiply, screen, max, min
keywords loop, layer, part, use, draw, for, table, fn
drawing line, dot, pset, rect, rectfill, circ, circfill, trifill, cls, spr, print
declarations psi, name, about, control, look
words that are keywords in one place only fps, smooth, forever, sprite, palette, key, rate
words of the draw stage and scenes (names elsewhere) if, else, when, scene, tween
the picture under under, under(x, y)
the built-in inputs' groups (not names) sun, moon, replay

The name rule. A formula's names win over the built-ins.

What each does#

These rows are the editor's cheat sheet. Each has a small example. On the site the example is drawn as the panel draws it, with a link to try it in the editor.

Values#

write what it does example
x, y where the pixel is: x 0 at the left to 1 at the right, y 0 at the top to 1 at the bottom rgb(x, y, 0.5)
px, py the pixel’s column 0–127 from the left and row 0–31 from the top gray(px % 8 == 0 or py % 8 == 0)
t seconds into the loop loop 4; hsv(x + t/4, 1, 1)
phase where the frame is in the loop, 0 to 1: t / the loop’s length (OpenDMD 1.1: update a panel that says it is unknown) loop 4; hsv(x + phase, 1, 1)
hour, minute, second the time of day band = floor(y*3); hsv(band/3, 0.8, x < pick(band, second/60, minute/60, hour/24))
a, b, c the controls, in the order the control lines declare them (undeclared: a slider 0 to 1 at 0); any built-in name may be set as your own control size 0..1 = 0.5 "Size"; gray(r < size*2)
w, h 128 and 32 gray(px == 0 or py == 0 or px == w - 1 or py == h - 1)
i the pixel’s number, 0–4095 hsv(i/4096, 1, 1)
cx, cy from the middle: −2 to 2 across, −0.5 to 0.5 down rgb(abs(cx)/2, abs(cy)*2, 0.3)
r, angle distance and angle (turns, clockwise from the right) from the middle loop 4; hsv(angle + t/4, 1, 1 - r/2)
ur, ug, ub the colour under the formula: the GIF it is drawn over, 0 to 1 rgb(ub, ug, ur)
ul how bright that is pal(fire, ul)
under(x, y) the picture under the formula at pixel x, y (the nearest, held to the panel), as a #… colour. under(px + 2, py) shifts it; under(floor(px/4)4, floor(py/4)4) pixelates it. under alone is this pixel's loop 2; under(floor(px/4)*4 + 2 + floor(sin(t/2)*2), floor(py/4)*4 + 2)
daylight how light it is outside where the panel is, 0 at night to 1 by day (twilight between); the panel's location in Settings, else 1 mix(#081030, #70b0ff, daylight) * (1 - y*0.5)
sun.alt the sun's height in turns: 0 on the horizon, 0.25 straight up, below 0 at night; 0.1 without a location s = 16 - sun.alt*120; mix(#102040, #ffd060, len(px - 64, py - s) < 4)
moon.phase, season, yearday the moon (0 new, 0.5 full), the time of year (0 midwinter, 0.5 midsummer) and the day of the year (0 on 1 January) hsv(season + 0.3, 0.6, (abs(px - 64) < 6) * (abs(py - 16) < 6) * step(x, moon.phase + 0.4))
night, bright 1 while night mode dims the panel; the panel's brightness, 0 to 1 gray(mix(1, 0.3, night) * y)
replay.playing, replay.platform, replay.game RePlayOS: 1 while a game plays; its platform as a number (0 none); a number 0 to 1 for the game, the same each time it plays hsv(replay.game + x*0.2, replay.playing, 0.3 + 0.7*y)

Functions and statements#

write what it does example
sin(v) cos(v) a wave: one turn per 1, between −1 and 1 loop 4; gray(abs(y - 0.5 - 0.4*sin(x*2 + t/4)) < 0.06)
tri(v) saw(v) sqr(v) triangle, sawtooth and square waves loop 4; k = floor(y*3); v = pick(k, tri(x*2 - t/4), saw(x*2 - t/4), sqr(x*2 - t/4)); hsv(k/3, 1, 0.5 + v/2)
abs floor frac sqrt size, whole part, fraction, square root loop 4; gray(frac(x*4 - t/4) * sqrt(abs(cy)*2))
min max clamp(v, lo, hi) limits gray(clamp(sin(x*2)*2, 0, 1))
mix(a, b, k) from a to b as k goes 0 to 1 mix(#f40, #04f, x)
step(edge, v) 1 past the edge, else 0 loop 4; gray(step(frac(t/4), x))
smoothstep(e0, e1, v) a soft step gray(smoothstep(0.3, 0.7, x))
len(dx, dy) distance gray(len(px - 63.5, py - 15.5) < 12)
atan2(dy, dx) angle, in turns hsv(atan2(py - 15.5, px - 63.5), 1, 1)
noise(x, y, z) smooth random hills, 0 to 1 loop 8; gray(noise(x*8, y*2, t/2))
hash(a, b) random 0 to 1 gray(hash(px, py) > 0.9)
select(c, a, b) a when c is true (not 0), else b; or c ? a : b gray(select(x < 0.5, y, 1 - y))
pick(k, a, b, …) a at k = 0, b at 1, …; else 0 pal(rainbow, pick(floor(x*4), 0, 0.3, 0.6, 0.9))
since(replay.game) seconds since a built-in input last changed, 32767 if it never has. max(0, 1 - since(replay.game)) flashes on a change k = max(0, 1 - since(replay.game)); hsv(replay.game, 1 - k, max(k, y))
was(replay.game) a built-in input's value before its last change. mix(was(k), k, min(1, since(k))) fades from the old to the new hsv(mix(was(replay.game), replay.game, min(1, since(replay.game))), 1, y)
digit(n, col, row) 1 where digit n has a pixel in the panel’s font; col 0–4, row 0–6 from the top. Add , small or , bold (chunky digits) for another font gray(digit(second % 10, floor(px/4) - 14, floor(py/4)))
glyph('A', col, row) the same for a character gray(glyph('A', floor(px/4) - 14, floor(py/4)))
bit(n, i) bit i of a whole number: 1 or 0 gray(bit(px, floor(py/4)))
< <= > >= == != compare: 1 or 0; and, or, not rgb(x < 0.5, y >= 0.5, 0.2)
% remainder: v % 1 is the fraction gray((px % 16 < 8) != (py % 16 < 8))
; a new line, on the same line u = x; v = y; rgb(u, v, 0.5)
loop 8 the loop’s length in seconds, and the least time it plays loop 2; gray(frac(x - t/2))
loop forever goes on without repeating: t grows from when it starts (use frac, %, sin, noise of it); seed is 0 to 1, new each time it plays loop forever; hsv(frac(x + t/10), 1, 1)
smooth drawn every refresh of the panel (100 to 125 a second), not every 2nd; for fast motion, a light formula only (OpenDMD 1.1; before, fps 100) smooth; loop 1; gray(abs(x - phase) < 0.02)
layer add { … } a layer with its own names and colour, on what is below: over, add, multiply, screen, max or min layer { gray(y*0.5) }; layer add { rgb(x, 0, 0) }
layer rate 4 { … } a slow bottom layer: a band of its rows is redrawn each frame, so every row every 4th frame, and all of it when the clock or a control it reads changes. The layers above draw over it every frame loop 4; layer rate 4 { gray(noise(x*6, y*2, t)) }; layer { rgb(1, 0.5, 0, abs(x - frac(t/4)) < 0.01) }
fn ring(cx, r) = abs(len(px - cx, py - 16) - r) < 1 a function: a value worked out from its values, used as ring(64, 8). With { … } instead of = …, it holds lines that draw, called on a line of their own in draw loop 4
fn ring(cx, r) = abs(len(px - cx, py - 15.5) - r) < 1
fn tree(x0, s) {
line(x0, 31, x0, 31 - 10*s, #a62)
circfill(x0, 31 - 10*s, 3*s, #2a4)
}
draw { for k < 4 { tree(10 + k*30, 1 + k/4) } }
pal(ice, (ring(64 + sin(t/4)*30, 8) + ring(64, 12))*0.8)
fn p(x, y) { … } a function whose values are x and y, so px, py, r, angle … inside follow them. With a colour as its last line, p(1 - x, y) draws it mirrored loop 8
fn p(x, y) {
wave = sin(x*2 + t/8) + sin(y*1.5 - t/4)
hsv(wave/4 + t/8, 0.9, r < 0.6)
}
p(abs(x - 0.5)*2, y)
use Plasma as p puts in one of your formulas or an example, as a function fn p(x, y) { … } fn p(x, y) = hsv(x + t/8, 1, 1)
p(1 - x, y)

Drawing#

write what it does example
draw { … } once a frame, before the pixels: values, loops, lines and dots into ink draw { line(0, 0, 127, 31) }; pal(fire, ink)
for k < 12 { … } inside draw: k = 0, 1, … 11; two deep at most, 2048 turns a frame draw { for k < 12 { line(k*10 + 8, 4, k*10 + 8, 27, 0.2, 1) } }; gray(ink)
line(x0, y0, x1, y1, v0, v1) a line, in pixels from the top left; v0, v1 its ink at each end, 0 to 1 (else 1) loop 4; draw { line(64, 16, 64 + 40*cos(t/4), 16 + 15*sin(t/4), 0.2, 1) }; pal(ice, ink)
dot(x, y, v) one pixel of ink v draw { for k < 64 { dot(hash(k, 1)*128, hash(k, 2)*32, hash(k, 3)) } }; gray(ink)
cls(#102) paint the whole panel one colour. Paint is drawn over the colour line, and the last painted wins draw { cls(#102); circfill(64, 16, 10, #f80) }
pset(x, y, #f80) paint one pixel; line(x0, y0, x1, y1, c) and dot(x, y, c) paint too when their last value is a colour loop 4; draw { for k < 64 { pset(hash(k, 1)*128, frac(hash(k, 2) + t/4)*32, #fff) } }
rect(x0, y0, x1, y1, c) rectfill(…) paint a box (its corners included), or a filled one draw { rectfill(10, 6, 50, 25, #048); rect(10, 6, 50, 25, #fff) }
circ(x, y, r, c) circfill(…) paint a circle, or a filled one loop 4; draw { circfill(64 + 40*sin(t/4), 16, 9, #f80); circ(64, 16, 14, #0ff) }
trifill(x0, y0, x1, y1, x2, y2, c) paint a filled triangle draw { trifill(40, 28, 64, 4, 88, 28, #0c4) }
print("TEXT", x, y, c) paint text in the panel's font (add small); or a number: print(minute, x, y, c, small, 2) with at least 2 digits draw { print("HELLO", 4, 4, #ff0); print(minute, 90, 20, #0ff, small, 2) }
spr(s, x, y, flip) paint sprite s with its top left at x, y, in its key's colours (key . 0, R #f00); flip 1 across, 2 down, 3 both sprite heart {
key . 0, R #e24
.R.R.
RRRRR
.RRR.
..R..
}
draw { spr(heart, 60, 12); spr(heart, 70, 12, 2) }
pal(fire, v), gray(v) colours to paint with besides #…: a palette's or a gray, v 0 to 1 draw { for k < 16 { rectfill(k*8, 0, k*8 + 7, 31, pal(fire, k/15)) } }
if c { … } else { … } inside draw: only when c is not 0 draw { for k < 16 { if k % 2 == 0 { rectfill(k*8, 4, k*8 + 6, 27, #f80) } else { circfill(k*8 + 3, 16, 3, #0ff) } } }
ink what draw put at the pixel, 0 to 1: pal(ice, ink), #40c0ff * ink draw { line(0, 16, 127, 16) }; #40c0ff * ink
ink(16) the same in 16 levels, as a whole number. Ink k/16 reads exactly k, for pick(ink(16), …); 1 to 255 levels draw { for k < 16 { line(k*8, 0, k*8 + 7, 31, k/16) } }; pal(rainbow, ink(16)/15)
table T = 1, -2, 0.5 numbers, 256 a table and 1024 in all (tables and sprites) table T = 0.2, 0.6, 1, 0.6; gray(T[floor(x*4)])
T[k] number k of table T, from 0 (0 outside it) table hue = 0, 0.1, 0.3, 0.6; hsv(hue[px % 4], 1, 1)
sprite s { … } a small picture in characters. First the key, key . 0, X 1, gives each character the number it reads as (up to 16); then rows up to 64 wide and 32 tall sprite s {
key . 0, X 1
.X.
XXX
.X.
}
gray(s[floor(px/4) - 14, floor(py/4) - 2])
s[u, v] sprite s at column u, row v from its top left: its key’s number (0 outside) sprite heart {
key . 0, X 1
.X.X.
XXXXX
.XXX.
..X..
}
rgb(1, 0.2, 0.3) * heart[px % 6, py % 5]

Colours#

write what it does example
hsv(hue, sat, val) colour: hue in turns, 0 to 1 each hsv(x, 1, y)
rgb(r, g, b) red, green, blue: 0 to 1 rgb(x, y, 1 - x)
pal(fire, v) a palette: fire, ice, ocean, chrono, amber, rainbow, gray pal(fire, y)
palette sea = #024, #08c, #fff your own palette of up to 32 colours (8 palettes; pal and cycle read 3). pal(sea, 0) is the first, pal(sea, 1) the last, pal(sea, 0.5) the middle one palette sea = #024, #08c, #fff; pal(sea, y)
scene s when c { v = … } values worked out only while c is not 0 (c once a frame or a row). After it, its names read 0 while it is off. The estimate counts two scenes at once loop 4; on = frac(t/4) > 0.5; scene s when on { w = sin(x*9 + t*3)*0.5 + 0.5 }; gray(w)
palette sky = tween(day, night, w) a palette mixed once a frame, day towards night by w (0 to 1, of t or sliders); tweens nest; tween(a, b, w), tween(c, d, v) fills the low and high 16 colours loop 8; palette day = #48c, #adf, #ffc; palette night = #002, #124, #346; palette sky = tween(day, night, 0.5 + 0.5*sin(t*0.785)); pal(sky, y)
palette sea = deep #024, foam #fff a colour may have a name: sea.foam is where pal finds it (sea.step from one colour to the next, sea.n how many) palette sea = deep #024, mid #08c, foam #fff; pal(sea, sea.mid + sea.step*(y > 0.5))
pal(fire, v) * k a palette colour, dimmed (or lit) by k pal(fire, y) * x
cycle(fire, v, s) palette cycling: the palette read at v, turned by s (one turn per 1, so t/4 turns it every 4 s). It wraps where pal stops; s may change by row; cheap when v is the same every frame loop 4; cycle(fire, x, t/4)
gray(v) white, 0 to 1 gray(x)
#ff8800 a colour: alone, or #f80 * v, mix(#000, #f80, v) #f80 * y
rgb(r, g, b, k) a last value: how much it covers, 0 to 1 (what is under shows through); rgb(#f80, k) layer { pal(ice, x) }; layer { rgb(1, 0.5, 0, r < 0.6) }

Declarations#

write what it does example
name "Sunrise" the formula’s name (60 characters at most) name "Sunrise"; hsv(0.08, 1, y)
about "…" one line about it (120 at most) about "A warm glow"; pal(fire, y)
control speed 0..1 = 0.5 "Speed" a slider: its name in the formula, range, default and label (three controls at most) control speed 0..2 = 1 "Speed"; loop 4; gray(frac(x - t*speed/4))
control mode = calm, wild buttons: the value is the choice’s number, 0 for the first (pick(mode, …)); two words make a switch control mode = calm, wild; hsv(x, 1, pick(mode, 0.5, 1))
look screen 40 it can be the panel’s look, drawn over the GIF with a blend and an amount (look alone is over, 100) look screen 40; pal(fire, y)

How a formula runs#

Limits#

limit at most
a formula's text, comments included 32,768 bytes
steps (per frame, draw, per row, per pixel, and one per layer) 768
values held a pixel at once 12
constants and per-frame and per-row values held at once 241
nesting of brackets, operators and function calls 48
table and sprite numbers, in all 1,024
numbers in one table 256
built-in inputs read (each since and was one more) 24
palettes of your own (of colours) 8
palettes pal and cycle read 3
loop turns a frame in draw { } 2,048
lines and dots a frame 2,048
line steps a frame 16,384
layer rate N N from 2 to 200
name / about / a control's label 60 / 120 / 24 characters

A formula's cost is an estimate in milliseconds a frame. Up to 5 ms is light, up to 8 ms medium, and past that heavy. The panel refuses a formula past 40 ms a frame by the plain VM estimate, or past 12 ms for a look drawn over every GIF. A smooth one past 32 ms, the most it can keep every refresh, is drawn every 2nd refresh instead (from OpenDMD 1.1; earlier panels refuse it). That is the VM's figure with no still cache even where the JIT or the cache would draw it faster, because the panel may have to draw it that way (the JIT switched off, short of memory, or failing its self-check). The light, medium and heavy ratings count the JIT's estimate where it draws, so a Formula can rate light and still be refused: the editor and psi check say so. The gallery takes a formula of 10 ms or less by the rating, and none the panel refuses.

Recipes#

Each recipe is a complete formula. Open one in the editor and change it. Three of them are Formulas from the gallery, word for word.

Scrolling text#

A message is a table of character codes (a character in single quotes is its code). Each pixel works out which character of the message it is in, and where in that character. glyph says whether the font has a pixel there. The message moves a whole number of characters a loop, so it wraps without a jump.

name "Scrolling text"
about "A message scrolling across in the panel's font, twice its size"
loop 6
-- the message: a character in quotes is its code ('A' is 65)
table msg = 'H', 'E', 'L', 'L', 'O', ' ', 'P', 'A', 'N', 'E', 'L', ' '
-- a character is 6 cells (5 and a gap), a cell 2 pixels; the 12 go by
-- once a loop: 72 cells in 6 s
u = floor(px/2 + t*12)
k = floor(u/6) % 12
row = floor((py - 9)/2)
hsv(x/2 + phase, 0.6, glyph(msg[k], u % 6, row))

A palette cycle#

The 1990s trick: the picture never moves, only its palette turns. cycle(P, v, s) reads palette P at v, turned by s. With s = phase (where the frame is in the loop, 0 to 1) it turns once a loop. wave reads only the pixel's position, so the panel works it out once and keeps it, and the formula is very light.

name "Cycled plasma"
about "Plasma the 1992 way: the picture holds still and only its palette turns"
loop 8
smooth
palette glow = #002, #208, #80f, #f0c, #f60, #ff0, #fff, #ff0, #f60, #f0c, #80f, #208
-- three waves that never move (worked out once, then kept)
wave = sin(x*2) + sin((1 - y) * 1.5) + sin(x*1.5 + (1 - y))
-- the palette turned once round in the loop
cycle(glow, wave/6, phase)

A clock face#

hour and minute are the panel's clock. The hands' angles change once a minute, so they are worked out once a frame, not for every pixel. Each hand is a thin box turned by its angle. The dial is a ring where the distance from the middle is close to 15.

name "Clock"
about "A clock face with hour and minute hands"
-- Hands on a round dial; dx, dy: pixels from the middle
dx = px - 63.5
dy = 15.5 - py
hours = (hour%12 + minute/60) / 12
mins = minute/60
hour_hand = step(max(abs(dx*cos(hours) - dy*sin(hours)), abs(dx*sin(hours) + dy*cos(hours) - 4) - 3), 1)
minute_hand = step(max(abs(dx*cos(mins) - dy*sin(mins)), abs(dx*sin(mins) + dy*cos(mins) - 6.5) - 5.5), 0.8)
dial = step(abs(dx*dx + dy*dy - 218), 22)
pal(ice, dial*0.6 + hour_hand + minute_hand)

Drawing over a GIF#

A formula with a look line can be the panel's look, drawn over whatever GIF plays. ur, ug and ub are the colour of the GIF under each pixel and ul its brightness, so the formula can recolour it. Here the GIF turns sepia, by as much as the control says.

name "Old photo"
about "The GIF in warm sepia tones"
control amount = 0.8 "How much" -- 0: the GIF as it is
look
mix(rgb(ur, ug, ub), rgb(ul*1.1 + 0.08, ul*0.92 + 0.04, ul*0.65), amount)

A slow background with rate#

Noise is one of the heavier steps. A bottom layer marked rate 4 is redrawn every 4th frame, a band of rows each frame. The layers above it are drawn every frame, so something small can move smoothly over a heavy background.

name "Slow clouds"
about "Clouds redrawn every 4th frame, with a spark racing over them every frame"
loop 8
-- the bottom layer: heavy noise, worked out every 4th frame
layer rate 4 {
  pal(ocean, noise(x*6 + t/2, y*2, t/4)*0.9)
}
-- the layer above is drawn every frame
layer {
  rgb(1, 0.8, 0.3, len((x - phase)*4, cy) < 0.12)
}

A starfield: a draw loop#

draw { } runs once a frame, before the pixels, and puts lines and dots into the frame's ink. The colour line reads it back as ink. A for loop draws one streak a star. hash(k, …) gives each star a direction and a start, the same every frame. The gallery's Starfield is the full version.

name "Warp stars"
about "64 stars streaking out from the middle: lines drawn once a frame"
loop 2
smooth
draw {
  for k < 64 {
    dir = hash(k, 1)         -- its direction, in turns
    near = frac(phase + hash(k, 2)) -- 0 far away, 1 here
    d = near*near*80         -- how far out it is, in pixels
    tail = d*0.8             -- where it was a moment ago
    line(64 + cos(dir)*tail, 16 + sin(dir)*tail*0.4, 64 + cos(dir)*d, 16 + sin(dir)*d*0.4, 0, near)
  }
}
pal(ice, ink)

Reading slider controls#

A control line makes a slider in the editor, the gallery and the panel's settings. With words instead of a range it makes a row of buttons. It names its value, which reads like any other value. A formula has three controls at most. With a whole-number speed the loop doesn't jump.

name "Sliders"
about "A bar to steer: its speed, its width and its colour are controls"
control speed 0..4 = 1 "Speed"
control width 0.05..0.5 = 0.2 "Width"
control tone = warm, cold
loop 8
-- a control is a value like any other, read once a frame
pos = frac(t*speed/8)
bar = abs(x - pos) < width/2
hsv(pick(tone, 0.06, 0.55), 0.9, bar*(0.3 + y*0.7))

The language in full#

This part is for implementers, tools and the gallery's checker. It covers the version promise, the text and grammar, the numbers, what stays the same between versions, how a formula travels, and the psi command.

Versions#

This page describes Psi 1. A formula may begin with psi 1. It doesn't need to, because a formula without the line is version 1. The editor does not write the line yet; it will once there is a version 2.

The promise starts at OpenDMD 1.0.0. Psi 1 is the language as OpenDMD 1.0.0 ships it. Through the betas it changed without a new version: y turned to count down, part blocks and frame { } went, and / became exact at every level. part stays a reserved word: a formula can't use it as a name, and one that still writes a part block is told to write a function instead. From 1.0.0:

Text#

Grammar#

formula   = { sep } { decl sep } { stmt sep } colourline { sep }   -- or layers instead of colourline
decl      = "psi" NUMBER                      -- the language version, first line only; never needed (unversioned is 1)
          | "name" TEXT                       -- what the formula is called (60 characters)
          | "about" TEXT                      -- one line about it (120 characters)
          | "control" NAME [ NUM ".." NUM ] [ "=" NUM ] [ TEXT ]   -- a slider: range (0..1), default (the low end), label
          | "control" NAME "=" WORD "," WORD { "," WORD } [ TEXT ] -- 2-6 words: buttons; the value is the word's number
          | "look" [ BLEND ] [ NUMBER ]       -- it may be the panel's look: blend (over), amount 0..100 (100)
stmt      = "loop" NUMBER                     -- 0.1..3600 s, once, default 8
          | "loop" "forever"                  -- no loop: t grows without end (section 11)
          | "smooth"                          -- drawn every refresh, not every 2nd: once (a formula's own `smooth` aside)
          | "fps" NUMBER                      -- the older spelling: 100 (smooth), 50 (the default) or 25 (every 4th)
          | NAME "=" expr                     -- may set a name again: a read sees the latest
          | "layer" [BLEND] ["rate" NUMBER] "{" { assign | fn } colourline "}"   -- rate 2..200; fn: a "fn" line as below
          | "fn" NAME "(" [ NAME { "," NAME } ] ")" "=" expr-or-colourline   -- a function (section 9)
          | "fn" NAME "(" [ NAME { "," NAME } ] ")" "{" { assign | dstmt } last "}"
          | "draw" "{" { dstmt sep } "}"      -- once a frame, before the pixels: one per formula
          | "table" NAME "=" ["-"] NUMBER { "," ["-"] NUMBER }   -- constants; a "," may end a line
          | "palette" NAME "=" [ NAME ] COLOUR { "," [ NAME ] COLOUR }   -- 1..32 colours (section 6)
          | "palette" NAME "=" tween [ "," tween ]   -- mixed once a frame; two: the halves (section 6)
          | "scene" NAME "when" e "{" { NAME "=" expr sep } "}"   -- e once a frame or a row; its names 0 while e is 0 (section 13)
tween     = "tween" "(" psrc "," psrc "," e ")"   -- e once a frame, held to 0..1
psrc      = PALETTE | tween                    -- yours, a built-in (not gray), or another tween
dstmt     = NAME "=" expr                     -- the same for every pixel (no x, y, px, py, i, ur…, ink)
          | "for" NAME "<" NUMBER "{" { dstmt sep } "}"   -- NAME = 0 … NUMBER−1; 1..1024, two deep
          | "line" "(" e "," e "," e "," e [ "," e [ "," e ] ] ")"   -- x0, y0, x1, y1 [, v0 [, v1]]
          | "dot" "(" e "," e [ "," e ] ")"                            -- x, y [, v]
          | "print" "(" ( TEXT | e ) "," e "," e "," e [ "," FONT [ "," e ] ] ")"   -- text (32 characters) or a number, x, y, colour [, font [, digits]]
colourline= "rgb" "(" e "," e "," e [ "," cover ] ")"
          | "rgb" "(" ce [ "," cover ] ")"       -- a colour, maybe covering less
          | "hsv" "(" e "," e "," e [ "," cover ] ")"
          | "pal" "(" PALETTE "," e [ "," cover ] ")"
          | "gray" "(" e [ "," cover ] ")"
          | FN "(" args ")"                    -- a function whose last line is a colour
          | ce                                 -- a colour expression (see 6)
          | e                                  -- a bare value: gray(e)
ce        = an expr with a COLOUR or an "rgb(" e "," e "," e ")" in it, read
            once a channel
expr      = or [ "?" expr ":" expr ]           -- both arms are always evaluated
or        = and { ("or" | "||") and }
and       = not { ("and" | "&&") not }
not       = ("not" | "!") not | cmp
cmp       = sum [ ("<" | "<=" | ">" | ">=" | "==" | "!=") sum ]   -- one: E218
sum       = term { ("+" | "-") term }
term      = unary { ("*" | "/" | "%") unary }
unary     = ("-" | "+") unary | atom
atom      = NUMBER | CHAR | COLOUR | NAME | FUNC "(" args ")" | FN "(" args ")"
          | TABLE "[" expr "]" | SPRITE "[" e "," e "]" | "(" expr ")"
args      = e { "," e }                        -- digit/glyph: e "," e "," e [ "," FONT ]
                                               -- pick: e "," e { "," e }
sep       = newline | ";"                      -- newlines inside ( ) and [ ] are spaces
NUMBER    = digits [ "." digits ] | "." digits -- no sign, no exponent
CHAR      = "'" one printable ASCII character "'"   -- its code: 'A' is 65
COLOUR    = "#" 6 hex digits | "#" 3 hex digits   -- #ff8800, #f80 (each digit twice); colour lines only
TEXT      = '"' characters '"'                -- on name/about/control lines and in print only; no " < > \ or tabs inside; label 24 characters
NUM       = [ "-" ] NUMBER
WORD      = NAME
FONT      = "normal" | "small" | "bold"      -- bold: digit only
NAME      = [A-Za-z_][A-Za-z0-9_]*
comment   = "--" or "//" to the end of the line

The header comes first: the declarations (decl), each once (control up to three times), and the loop and smooth (or fps) lines, in any order among themselves, before every other line (a declaration below another line is E951). loop and smooth may also come later among the formula's own lines, outside layers. The colour line is the last line of the formula, of a function and of a layer. With layers, every colour line is inside a layer.

Numbers#

Every value is a fixed-point number from −32,768 to 32,767.99998, in steps of 1/65,536 (Q16.16). Arithmetic saturates rather than wrapping.

since(KEY) is the seconds since a built-in input last changed. It is held at 32766 (about 9 hours), and reads 32767 when the input has never changed, so since(k) < 32767 means "it has changed". was(KEY) is the input's value before that change. A built-in input the panel has no value for reads its default. That is the case with no location set, the clock not set, or RePlayOS not followed.

Where a function is defined by a table (sin, sqrt, atan2, the palettes, the fonts), the table is the definition; noise and hash are defined by their integer arithmetic. A formula gives the same bytes on the panel, in the browser and on a computer.

What stays the same#

Each item below shows in a formula's pixels or in whether it compiles. From OpenDMD 1.0.0, changing any of it makes a Psi 2 (see "Versions").

Machinery that gives the same bytes is not on the list and may change at any release. It includes the .psb ABI, the steps and their narrowed and fused forms, the JIT, keeping values that never change from frame to frame, the column run, the cost model and its tiers, and the error sentences. A formula refused as too heavy meets a product limit, not the language. Error codes are only ever added, and a retired code is not used again.

How a formula travels#

Tools#

The psi command checks and formats formulas on a computer. Install it from the source with cargo install --path opendmd-psi-cli, or with make psi.

$ psi check storm-front.psi --out -
ok: 39 of 768 steps (per frame 11, draw 0, per row 5, per pixel 23), 2 layers, 3 of 12 registers, 38 of 241 values
name: Storm front
never repeats (loop forever), drawn every 2nd refresh; reads t
panel: ~5.5 ms a frame with the JIT, ~6.8 without: medium (light ≤ 5 ms, medium ≤ 8 ms; the panel takes ≤ 40 ms on the VM, a look ≤ 12)
JIT: yes, ~5.5 ms a frame compiled (what the tier and the gallery's limit count; ~6.8 ms without the JIT)
length: 563 B as written (32,768 at most), 355 B compiled (489 B as a path, @formula?psb=…: 6,144 to show it, 2,048 for an inline look); compiled 355 B of the gallery's 4,500
note: line 14: `d` is worked out for every pixel each frame (~666 ns a pixel): it would be kept in the still cache but it reads `fall`, which changes from frame to frame
heaviest lines (their share of the work a frame; make these lighter first):
  line 8 (74%): cl = noise(x*3 - t*0.15, (1 - y) * 2, 0)*0.7 + noise(x*7 …
  line 14 (12%): d = hash(px, floor(py/4 + hash(px, 1)*8 - fall))
  line 9 (9%): pal(ice, cl*0.8 + (1 - y)*0.3) * (0.35 + cl*0.5)

$ psi check broken.psi
error[E202]: line 3, column 6: unknown function `sine` — did you mean `sin`?
  gray(sine(v))
       ^^^^

psi check also writes a PNG contact sheet of frames across one loop (--out, --frames, --a/--b/--c, --clock, --set KEY=V, --since KEY=S and --was KEY=V for the built-in inputs, --hash, --listing). It says whether the panel's JIT takes the formula, and why not. It also prints the compiler's notes, each with its line number:

--levels lists every value with how often it is worked out and the chain that makes it so (beam per pixel: readsoff(line 3) ←px``).

--same-as other.psi checks a rewrite. It reports whether the two are the same program, and passes when they give the same frames. The frames are three seeds × 120 frames over a loop, or a loop forever formula's first 5 minutes, with the clock moving and the sliders at 0 and at 1. --seeds and --frames change the counts. It exits 1 at the first frame that differs.

psi fmt FILE prints the formula in the canonical layout. --write rewrites the file, and --check names the files that aren't in it. The layout is one statement a line, two spaces inside { }, and spaces round =, comparisons and + -. *, / and % close up where they bind tighter (x*3 + t/8). Comments stay where they are. It changes only the spacing between the formula's words (a ; becomes a line end, a { } block is opened out onto lines), and checks that the result is the same program before it writes.

psi builtins --json and psi errors --json give this page's tables as data. The name tables, the limits and the error codes are generated from the compiler, and the tests fail when they drift. The tests compile every example and recipe, and hold the panel, the browser and a computer to the same frames.