Psi language reference#
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.
- Any built-in value's, function's or drawing call's name may be set as a value of the formula's (
t = 0.5,sin = 0.25,line = 3). From the line that sets it, the name reads as the formula's value wherever a value is read (settingxorymovespx py i cx cy r anglewith it, as in a function). A callname(…)is still the built-in function, andsince,was,underandink(n)work the same way assin. - The keywords (E204), the palettes (E208) and
sun,moonandreplay(E216) are not names. - A
fn, table, sprite or palette of the formula's needs a name of its own. None may take the name of a value the formula has set (E203), and afnmay not take a built-in function's name either (E215). since(k)andwas(k)read the built-in inputs only. On a name the formula has set they are refused (E217), so a name is not read two ways on one line.- A built-in added later is read only where a formula has not set its name, so a formula saved before it means what it meant.
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 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 |
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) |
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 { |
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 { |
s[u, v] |
sprite s at column u, row v from its top left: its key’s number (0 outside) | sprite heart { |
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#
- Levels. Each value is worked out only as often as what it reads
changes. Constants are worked out once. Values of the time, the clock and
the controls are worked out once a frame, values of
y,pyandcyonce a row, and the rest once a pixel. This changes the cost, not the result. - Layers (
layer [blend] [rate N] { … }) are drawn bottom to top. Each has its own names and colour, and goes onto what is below with its blend (over,add,multiply,screen,max,min). A colour's last value says how much of the pixel it covers. - Functions (
fn p(a, b) = …orfn p(a, b) { … }) are written out where they are called. A function gives a value, a colour, or lines that draw. Inside a function whose values arex, y, the namespx py i cx cy r anglefollow them, so it draws at another place. draw { }runs once a frame, before the pixels. Its loops (for k < n), lines and dots go into a canvas, which the pixels read back asink.- Time.
tis the frame's time, in seconds into the loop (loop N, 8 when left out). Which instants get drawn is the display's: the panel draws a frame on its scan's refresh (about 8 ms; 100 to 125 a second, by panel), every 2nd refresh unless the formula asks for every refresh (smooth), and fewer when frames take too long; the browser's preview draws at about the same times, and a saved GIF at 20 or 40 ms. So motion is a function oft, never a count of frames. On the panel, racing its scan,twraps at exactly the loop's length (from OpenDMD 1.1), as in the preview; a saved GIF, and the panel when it can't follow its scan (aratelayer, the glow), play whole 20 ms (or 40 ms) frames, so a loop that isn't a whole number of them comes out up to a frame short there (such a frame, the first of a play among them, that falls in the loop's last part frame drawstat 0). The time past the loop's end carries into the next, sotkeeps the clock's pace across the wrap.loop Nis also the least time the formula plays for in a playlist, and the length of a GIF saved from it.phaseis where the frame is in the loop, 0 to 1:tdivided by the loop's length as its line writes it, the very division, sosin(phase)andsin(t/8)in an 8-second loop draw the same frames, and a change of the loop's length needs no other edit. Withloop forever,tis seconds since the formula started and grows without end (and there is nophase: E908).phaseandsmoothare OpenDMD 1.1's: a panel before it says the name is unknown, and needs an update. - Built-in inputs (
daylight,sun.alt,moon.phase,season,yearday,night,bright,replay.playing,replay.platform,replay.game) are read by name. Each is the same for every pixel of a frame and is held to its range. When the panel has no value for one, the input reads its default; thumbnails, the gallery andpsi checkdraw at the defaults, and the editor's preview does until its inputs panel moves them.since(KEY)says when one last changed andwas(KEY)what it was before ("Numbers" gives their ends). A formula has no memory between frames.
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:
- Within version 1, new built-in names may be added under the name rule ("Every name"). A name a formula sets is that formula's, even where a later release made it a built-in. A formula saved before a name existed means what it meant.
- A change that would alter the pixels of a formula that compiled under version 1, or refuse one, is a new version. "What stays the same" lists what that covers. The tests hold every example and gallery formula to the same frames.
- The compiled form has its own number. A compiled Formula (
.psb) carries a program format number, the ABI (3 at 1.0.0). The ABI defines the steps the panel runs and what each means. It is not the language and may change at any release. A panel that meets another ABI's bytes says so, and the web app prepares the Formula again from its text. A new ABI is never a new Psi version, and a new Psi version need not be a new ABI. - A formula written for a later version than the panel has (
psi 2on a version 1 panel) is refused (E601).
Text#
- A formula is text, at most 32,768 bytes (of UTF-8), comments included.
- Lines. Each statement is a line;
;ends a statement too (a = 1; b = 2). Inside( )and[ ]a line end is a space. After an operator or a,the statement continues on the next line. - Comments run from
--or//to the end of the line. - Numbers are digits with at most one point, no sign and no exponent:
12,0.5,.5. A minus sign is an operator. - Characters in single quotes are numbers:
'A'is 65, one printable ASCII character at a time. There are no strings as values; onlyprint("…", …)indraw { }takes text. - Colours are
#and 3 or 6 hex digits:#f80is#ff8800. - Names are a letter or
_, then letters, digits and_. - Text in quotes (
"…") goes only onname,aboutandcontrollines and inprint("…", …)insidedraw { }, on one line, without",<,>,\, tabs, control characters or characters that hide or reorder text. - A sprite starts with
sprite NAME {on a line of its own, then a key line and rows of characters, and ends with}alone. Its rows are read as written, a line at a time.
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.
a / bis the exact quotient, truncated towards zero to a step and held to the ends. Half of the step below zero is 0.a / 0is the largest number ofa's sign, and0 / 0is 0.a % 0is 0.%is floored, so-1 % 3is 2.- Comparisons and
and,or,notgive 1 or 0. Any number but 0 is true. - Comparisons don't chain.
0 < x < 0.5is refused (E218); write0 < x and x < 0.5. - Both sides of
? :are worked out. - A division per pixel by a constant, or by a frame's or a row's value, gives the same bits as the same division done once a frame. The panel divides a row of pixels by one divisor exactly, with multiplies.
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.
- Syntax and acceptance. The keywords, the palette names and the
dotted inputs' groups (
sun,moon,replay) are not names; the name rule ("Every name"); the header first (its lines in any order);psi Non the first line only;{on the line that opens it;;as a separator;--and//comments; a line going on after an operator or,; literal counts forfor,ink(n)andlook; comparisons don't chain; the colour grammar (what is a channel expression, what is a whole colour,pal(P, k) * ethe one scaled whole colour, coverage on the outermost colour only). Grammar that is refused today may be accepted later (else if). - Limits only grow. A formula that fits keeps fitting (see "Limits").
- Numbers. Q16.16 with
+ - *, negation andabssaturating;*rounding down;/truncating towards zero, exact at every level;/ 0,0 / 0,% 0; floored%;floorandfracof negatives;sqrtof 0 and below is 0;absof the lowest number is the highest. - Tables and fixed arithmetic are definitions:
sin/cos(256 entries, interpolated),sqrt,atan2,noise,hash, the three fonts, the hsv conversion,ul's weights, the blends and coverage in 8-bit channels,cycle's turn, tweens' rounding, and the palettes' colours. In particular: pal(P, k)reads the one of the palette's 32 entries whose band holdsk(entryfloor(k × 32), 31 atk = 1), with no blending between them. A three-colour palette bands rather than ramps. An interpolated read would be a new name.rainbowis black at 0 and dark at 1, its hues between;oceanis the same table asice(one palette, two names).- Inputs.
xandyacross and down,cx cy r angleas defined,angleclockwise with a quarter down,tthe frame's time in seconds (which instants are drawn is the display's, not the language's),loop8 when left out,phaseastover it,smoothasfps 100andfps25, 50 or 100 only, the controls' ranges and defaults, each built-in input's range and default,since's 32766 for a change and 32767 for never,hour minute secondwhole. - Reads outside their range give 0:
pickpast its choices,T[k]past a table,S[u, v]outside a sprite,digitandglyphoutside the cell. - The draw stage: ink keeps the brighter value, paint is painter's
order over the colour line,
lineanddotwith no ink draw 1, the caps skip the rest the same way on every panel. - Scenes, rates and layers: a scene's names read 0 while it is off, the
ratebands' schedule, layers blended bottom to top, a look ignored when the formula plays alone;loop forever's growingt, itsseedper play, and the growing-value forms it accepts (more may be accepted later, never fewer).
How a formula travels#
- Links carry names, not formula text. A gallery Formula travels by
name:
https://opendmd.dev/f/#gallery=<name>, or#gallery=<name>to a panel's web app. A formula changed on the site goes to the panel's web app bypostMessagefrom the page that opened it. To share a formula, submit it to the gallery. - On the panel a playlist line or the look names a bundled Formula
(
@formula:<name>) or one saved on the panel. A saved one is a.psifile, played from the compiled.psbthe web app writes beside it. A formula sent to show at once goes compiled, on one line (@formula?psb=…, base64url). That line is 6,144 bytes at most, or 2,048 for a look set that way. The panel compiles only the.psifiles on its card whose.psbis missing, older or of another ABI (a card filled on a computer, or after an update), in the background once the card is mounted; everything else it plays already compiled. - In the gallery a formula is a file of two comment lines (
-- author: …and-- licence: CC0-1.0orCC-BY-4.0) followed by the formula. The formula is at most 32,768 bytes and 4,500 bytes once compiled; the file is at most 36 KB.
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:
- a
pickworked out per pixel because one of its arms is; - a sum or product that brackets better:
px - 64 - Xworks- Xout for every pixel,px - (64 + X)once a frame; - which values are worked out once and kept, and which once a column;
- the heaviest value that could not be kept, and the value that kept it out;
- a draw loop run as lanes (32 turns or more, costed for the next multiple of 128 turns).
--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.
