How ascii maps bas3line/ascii onto
Elixir, and every place JavaScript and Elixir disagree. Upstream is the source
of truth: where this port differs from upstream, the difference is listed
here. The pinned commit is in UPSTREAM.md.
The upstream contract
A piece is one TypeScript module, src/pieces/<slug>.ts, with no runtime
imports. It exports:
meta:name(lowercase),category(one of 14),note(one lowercase line, at most 72 characters),colsandrows(the frame size in cells),fps(0 to 60; 0 is a still), and optionallyoptions(defaults for every option),clock: true(the picture reads the real time),palette(2 to 64#rrggbbcolours),ground(the colour behind it) andcell(1 for square cells, else 2).- a default function: takes the options, precomputes, and returns
frame(t, env), which returns the picture attseconds as one string of exactlyrowslines of exactlycolscharacters, joined by\n.
env has two fields. paper is true when ink is dark on a light ground, and
shaded pieces flip their ramp for it. color, present only when a coloured
piece is drawn in colour, is a Uint8Array(cols * rows) that the frame
fills with palette indices, row by row. mount allocates one such buffer per
mounted piece and never clears it, so a cell a frame does not write keeps its
last index.
The frame function is a closure, and many pieces keep state in it: a
simulation stepped by how far t moved since the last call, or a cache keyed
on the minute. mount only ever calls it with t increasing from 0, by
min(dt, 100 ms) a frame while the piece is on screen.
Frames may use printable ASCII, ·, ° and U+2500–U+259F. Scenes may also
use • and ●. Text pieces are at most 80×32; scenes (category: "scenes",
which must have a palette and a ground) are at most 320×120. scripts/check.ts
enforces all of this, plus determinism (same t, same frame, unless clock),
"frame 0 is a good still", at least three distinct frames in six seconds for
an animated piece, and a time budget: 4 ms a frame on average and 30 ms at
worst, or 10 ms on average for a scene.
Logos (logos) and distros (distros) have a palette and no ground, and draw
from a second picture in colour (env.color present) than in one ink.
The Elixir mapping
| upstream | here |
|---|---|
src/pieces/<slug>.ts | Ascii.Pieces.<CamelName> in lib/ascii/pieces/<slug>.ex |
meta | Ascii.Piece.meta/0 returning %Ascii.Meta{}; options keyed by atoms |
| the default function | Ascii.Piece.init/1: takes every option, merged with the defaults, and precomputes |
frame(t, env) | Ascii.Piece.frame/3: (state, t, env) to {iodata, colours, state} |
| the closure's mutable state | the state term, threaded through by the caller |
env.paper | env.paper |
env.color (the buffer) | env.color? says whether colours are wanted; the frame returns them as a binary of cols * rows palette indices, row-major, or nil |
Date.now() | env.now, a DateTime (the golden tests pin it) or nil for the system clock; see Ascii.Kit.Clock |
load, names, isPiece | Ascii.Registry and Ascii.pieces/0,1, Ascii.meta/1 |
mount | Ascii.new/2 + Ascii.frame/2, Ascii.Player, the Phoenix components |
A piece that leaves some colour cells unwritten (upstream relies on the
buffer keeping old values) keeps its last colours in its state and starts
from zeros, which is what a fresh Uint8Array holds.
Categories are atoms written in code (:scenes, :logos, ...), never made
from input. Slugs are the upstream file names, as strings; looking one up
never creates an atom.
Options
Options passed to Ascii.new/2 (or a LiveView session, or the CLI) are
checked against the piece's defaults before init/1 sees them:
- keys must name a default option; string keys are matched against the defaults' names, so no atom is ever created from input;
- each value must have the shape of its default (a number for a number, a list of strings for a list of strings, a map with known keys for a map), and strings from params are cast to numbers or booleans where the default is one;
- numbers are clamped to the piece's
meta.boundsfor that option, or to ±1,000,000; a bound reaches inside an option of lists or maps, as a range for every number in it or ranges by key (cpu-meters' process pids and times, bar-chart's values and heatmap's counts go well past a million); strings to 256 characters and lists to 256 entries.
Anything else is {:error, {:invalid_option, key}}. Upstream accepts
anything and lets the piece cope.
Numbers: :math, and what JS needs besides
Pieces shade with Math.sin, Math.exp, Math.pow and so on, and a cell's
character often sits on a threshold of one. Two facts shape how this port
computes them.
:mathis not V8. Erlang's:mathcalls the platform libm. V8 calls its own port of fdlibm (src/base/ieee754.cc). Over random inputs they disagree in the last bit for 4% (sin) to 17% (acos) of calls, and macOS and glibc disagree with each other too.V8 itself differs between CPUs. Clang compiles
ieee754.ccwith-ffp-contract=on, and on arm64, where fused multiply-add is always available, it fusesa * b + cinside the polynomials. The darwin-arm64 Node disagrees with the darwin-x64 Node in the last bit ofsin,cos,tan,exp,log,log10,log1p,asinand (more rarely)powfor up to about 0.5% of inputs. The fixtures are generated with x64 Node, which runs fdlibm as written; the generators refuse to run on non-x64 Node. Regenerating every fixture with the arm64 Node gives identical frames and colours for all 191 pieces all the same.
The port first answered (1) by transcribing fdlibm into pure Elixir, so
that every function agreed with x64 V8 bit for bit. Measured afterwards,
that bought nothing a picture shows: with :math in its place, every golden
frame of all 191 pieces still matches, on macOS (arm64) and on Linux (glibc,
arm64 and, in CI, x86-64), because a character rarely sits within a last bit
of its threshold. And :math is a BIF, several times faster than fdlibm in
Elixir: frames are 1.3 times faster overall, scenes 1.25 times, setup 1.35
times, and a few pieces that are little but math far more (galaxy 8.8
times). So the pieces call :math for the transcendental functions, and the
1,200 lines of fdlibm are gone.
What that costs: a frame can now, in principle, differ from upstream's in a cell that sits within a last bit of a threshold, and from one platform to another. The golden tests run on macOS and Linux and would show it. And the chaotic simulations (double-pendulum, three-body, lorenz) drift apart from upstream's, and between platforms, over a long run; the goldens cover the times upstream's own checks play.
Ascii.JSMath keeps what JS does differently and Elixir has no answer
for, each agreeing with V8 bit for bit (test/fixtures/js_math.json holds
V8's answers and Ascii.JSMathTest requires them):
Math.round(halves up, toward +infinity),%(the dividend's sign),Math.fround, typed-array stores,toFixedandString(number);Math.hypot, which in V8 is not libm but Torque code that scales by the largest argument and sums with Kahan compensation. It differs fromsqrt(a*a + b*b)in about a third of cases;Math.cbrtandMath.log1p, which:mathdoes not have, as fdlibm computes them (log(1 + x)loses what matters near 0).
sqrt is exactly rounded everywhere and is :math.sqrt/1.
JS → Elixir hazards
Every piece is checked against this list.
- 32-bit integers.
Math.imul,>>>,>>,<<,| 0,^,&wrap at 32 bits; Elixir integers do not. Hashes and PRNGs go throughAscii.Int32, never rawBitwiseon unbounded values.(x * 9) | 0on a float truncates toward zero, then wraps. Math.roundrounds halves toward +∞:JSMath.round(-2.5) == -2. Elixir'sround/1rounds halves away from zero. V8 computes it asceil(x), less one if that is more than half abovex, which is also right for0.49999999999999994. All rounding helpers return integers.%is a truncated remainder with the dividend's sign, for floats too:JSMath.mod/2(Cfmod). Pieces that want a true modulo write((a % n) + n) % nupstream, and the port does the same.- Division by zero,
Infinity,NaN. Erlang floats cannot hold infinities or NaN:1 / 0.0raises, and so does:math.exp(1000). Decided once for the whole port: no sentinels.:mathandJSMathraiseArithmeticErrorwherever JS would produceInfinityorNaN. Each piece guards the sites where upstream relies on one (for exampleat = -Infinitymeaning "no glint": the port usesniland the branch it implies). The golden tests at fixed times and the property tests at random times both run every piece, so a missed guard fails loudly rather than drawing something wrong. - Typed arrays. A
Float32ArraystoresMath.fround(x): the port rounds throughJSMath.fround/1(<<x::float-32>>) at every store into one, which matters for depth buffers compared against fresh doubles (donut).Uint8Array,Int8Array,Int16ArrayandUint16Arraywrap (JSMath.u8/1and friends);Uint8ClampedArrayclamps and rounds halves to even (JSMath.clamp_u8/1). No piece uses the clamped one. - Reads past the end.
a[i]out of range isundefined, which becomesNaNin arithmetic and is falsy in a test. The port returns the value JS would have ended up with at each such site (usually a branch not taken). - Truthiness.
0,""andNaNare falsy in JS; in Elixir onlynilandfalseare. Everyif (x)on a number becomesx != 0. - Strings. JS indexes UTF-16 code units. Every character a frame may
contain is in the BMP, so a code unit is a code point; the port works on
code points (or single-character binaries) and never on bytes, since
·,°and the box drawing are two and three bytes in UTF-8. Option text with characters outside the BMP would count differently (JS counts surrogate pairs as two); that case is not reproduced. - Closure state and time going backwards. Each piece's state is explicit
and threaded through; where upstream clamps
t - last(doom-fire,falling-sand) the port clamps the same way. Whentdecreases, the port does what upstream does: usually it steps by zero and holds. - The clock. The five
clock: truepieces (analog-clock,calendar,digital-clock,landscape,sundial) readenv.nowinstead ofDate.now(). Local time comes from theDateTime's own offset. Withnow: nilit is the system's local time. - Sorting.
Array.prototype.sortis stable in V8, as isEnum.sort/2. Comparators of the form(a, b) => a.k - b.kbecomeEnum.sort_by/3on the key, which keeps ties in order the same way. - Iteration order.
MapandSetiterate in insertion order in JS; Elixir maps do not past 32 keys. The pieces that iterate one keep a list. - Number formatting.
toFixedrounds the exact binary value, halves away from zero, andString(x)prints the shortest round-trip digits with JS's own exponent rules. These areJSMath.to_fixed/2andJSMath.to_string/1, checked against V8. - Negative zero. JS keeps
-0in doubles. Elixir integer arithmetic has no-0. It only matters where a sign of zero is observed (atan2,1/x), and those sites stay in float arithmetic.
The bulk port found more, each now handled where it occurs:
- Assignments are expressions.
d = (a[k] = x)givesdthe unrounded double while later reads of the Float32Array get the rounded one;(f32[i] -= d) < 0tests the unrounded value. A+=into a Float32Array rounds at every add, and a Float32Array used as a visit marker (fire[k] += 2) changes the stored value.typedArray.mapreturns a typed array, so its results are rounded too. - Compound assignment groups the whole right side:
x += a + bisx + (a + b)andb *= A * Bisb * (A * B); and+ W + 40is two additions, not+ (W + 40).mix(c, r, 1)is not exactlyr. - Loops.
for (i = 0; i < x; i++)with a floatxrunsceil(x)times.for (; dt > eps; dt -= step)leaves sub-step remainders that must be replayed. A JS loop with<=can run zero times, but Elixir'slo..hicounts down whenhi < lo: such ranges are writtenlo..hi//1.continueskips the draws ofrand()later in the same iteration. - Order of visits. A JS
Mapiterates in insertion order, and updating a key keeps its place (snake, ferris-wheel). Neighbours walked through a linked list come out newest first (starlings). Velocities written during a step are seen by later birds in the same step. A ring buffer may be summed in raw index order, not oldest first (lorenz): float sums depend on order. - Indexing. Neighbour lookups by flat index wrap across rows (candle,
hourglass); writes past a typed array's end are dropped; reads past it are
undefined, which may be "not a space" (bonsai). - NaN that draws nothing. A negative base to a fractional power, a
sqrtof a rounding-negative, 0/0 from a zero-length segment: JS gets NaN, every comparison with it is false, and the piece draws nothing there (or draws its fallback character). The port takes that branch explicitly.Infinityas a starting distance becomes a finite sentinel ornil. - Elixir's own traps.
/always gives a float, so(cols - 1) / 2used as an index or map key misses the integer key;Bitwiseraises on a float like2 * 24.5.Math.max(0, x)in Elixir can return the integer 0. In aforcomprehension a binding whose value isfalseornilfilters the element out. JStrim()trims U+FEFF but not U+0085, the opposite ofString.trim/1, and PCRE's$matches before a final newline (anchor with\z). A call toJSMathin a module attribute makes a compile-time dependency; such constants are computed ininit. - Closures read final values. A closure that reads a variable the setup
code goes on changing sees its last value (form-controls), and a
module-level
letis shared by every instance (planet). - Seeds. Upstream sometimes passes an arbitrary number straight into
mulberry32, which adds, then truncates;
Kit.Rand.seed/1truncates first, which differs for negative fractions, so those pieces callInt32.mulberry32/1with the raw value. - Golden times are samples. They can miss whole phases of a cyclic piece (maze's closing walls, landscape's night). Every batch also compared long plays, random jumps backwards and forwards, and non-default options against upstream in Node.
Speed without changing a bit
BEAM is several times slower than V8 at float arithmetic, and upstream's
scenes do tens of thousands of pow and exp calls a frame. Every piece
keeps upstream's results exactly; the ones that needed it are fast by these
means, in order of preference:
- Precompute what does not move, in
init, as the very subexpressions upstream computes (multiplication is not associative), down to whole static cells. - Skip work whose result is known: a term multiplied by an exact zero, a term below half an ulp of the sum it joins, a cell provably outside a shape, a frame where a term is static (night-coast's glow without a flash), a memo of last frame's inputs per cell.
- Guard floats.
is_floatguards on hot functions and on values read from tuples or lists let the compiler keep arithmetic unboxed: 2-4x in the hottest loops. - Certified fast paths. Where a value only feeds a rounding, a
threshold or a colour step, a cheaper value is computed first (a table
and series for
pow, a quantised depth), with a proven error bound; wherever the decision lies within the bound of its boundary, the plain value is computed instead. fern places its points by angle sums, short series and fixed-point sums this way, 2.5 times faster than placing them plainly. (ocean-sunset and varanasi-ghats once guessed with:mathand checked against fdlibm; with:mathas the plain path that went.)
Each certified fast path is held to its exact path by tests: fern and the
scenes have an internal exact? switch, and glxgears, gyroscope, heart,
mobius-strip, torus-knot and twisted-ring have a plain reference port in
their test files; a property compares them at random times, and fixed times
cover the rarest Float32 cases. One gap is known: a property that plays random
times rarely catches a per-cell memo whose key leaves out one colour channel,
since the channels move together.
Frame times are in PORT_STATUS.md. Six pieces are over upstream's own budget (4 ms a frame, 10 ms for scenes) and all are within three times it: alpine-dawn, aurora-fjord, deep-reef, ocean-sunset and varanasi-ghats (10.6-14.1 ms against 10), and reaction-diffusion (~9.1 ms against 4). mandelbrot is within budget over check.ts's six seconds but takes 16-22 ms deep in its zoom; it closes a loop, so a LiveView player draws that once.
Golden fixtures
tools/gen_golden.mjs (run with mix ascii.golden) imports every
upstream piece and writes test/fixtures/golden/<slug>.json:
meta,upstream_commit,node,archandcall_order([0, 0.5, 1, 2.5, 5, 10, 30]);frames: for each run, a fresh instance played at each time incall_order, recordingt,paper,mono(no colour buffer),now(the stubbed clock, for clock pieces) andtext. A run in colour also records the colour buffer after each frame, base64-encoded (about a quarter of the size of a JSON list of indices, which matters for the 20,000-cell scenes). Text pieces get two runs (ink and paper); coloured pieces get four (ink and paper, each in colour and in one ink).play:scripts/check.ts's own playback. A fresh instance stepped every1/fpsseconds from 0 to 6 (t = round(i * step * 1e6) / 1e6), keeping the frames nearest 0, 1, 2, 3, 4 and 6 s. Stateful pieces step differently under small, even steps than under the jumps ofcall_order, so both are checked.
Clock pieces see Date stubbed to 2026-03-14T15:09:26.535Z at t = 0,
advancing with t, in TZ=UTC.
Ascii.GoldenCase replays both, threading state, and requires every frame
and every colour to match exactly. A per-piece tolerance
(test/fixtures/tolerances.exs) exists for a libm difference proven to sit on
a threshold. Since JSMath matches V8 bit for bit there should be none, and
the list below is the complete set.
Tolerances
None.
Deviations from upstream
At the default options, none: every piece draws upstream's frames. The differences:
- Options are validated (above). Upstream takes any value.
sparkline's per-seriesformatfunction is not accepted as an option: options are data, and functions cannot come from params. Itsdigitsis held to 0..100, where upstream'stoFixedwould throw.- Options of this port's own, for live data in dashboards and terminal
UIs. Their defaults leave upstream's picture as it is (the golden tests
hold it), and
meta.optionslists them with upstream's: progress-barvalues,style,cols; sparklinecols,rows(and upstream's own per-seriesvalues, which the options could not reach beforemeta.shapes); gaugevalue; cpu-meterscores,memory,swap,load,tasks,uptime,processes; uptime-barhistory,days; heatmapcounts,date,weeks,months,weekdays; candlestickscandles,times,digits; bar-chartcols; equalizerlevels; and, for components an app drives and a reader uses, gaugecols,rows; radarcontacts,land; boot-logsteps,title,cols,rows; terminalhistory,typing,cols,rows; file-treewalk,open,cursor,paths,cols,rows; form-controlsfields,buttons,focus,status,cols; calendardate,marks,months,weekdays,cols,rows; digital-clocktimer,from,label. Each piece's documentation says what they do. Pieces whose size follows their options say so withAscii.Piece.size/1; those that carry state over new options (the gauge's needle, the bars of bar-chart and equalizer, cpu-meters' simulation, a terminal typing out its history, a running timer) withAscii.Piece.update/2; those that can be used (file-tree, form-controls, calendar) take keys and clicks withAscii.Piece.handle_event/2; and the components tell what they show withAscii.Piece.describe/1. None of these is upstream's. - Parts (
Ascii.Piece.parts/2) are this port's own: a piece's frames labelled cell by cell, read off the picture after it is drawn, so colouring them never changes a character. Where a frame cannot tell (a gauge's needle over a tick), a cell may be in no part. - Numbers too wide for their column. Upstream writes a reading wider
than its column past it, and the line runs off the frame (this port used
to clip it). sparkline's value and range columns, and candlesticks' axis
prices, last price and quote, write such a number to fit instead
(
Ascii.Kit.Number): decimals first, then compact, in thousands, millions and on (17.5M,3.02G), an axis's prices all alike. A number that fits is written exactly as upstream writes it, so default frames are unchanged; so those options' values are bounded at ±10^15 rather than ±1,000,000. candlesticks visits only the chart's own rows when drawing a candle, which changes nothing on screen, but a far-offstartwould otherwise loop over millions of rows. - Other sizes. gauge at
cols/rowsother than 55×18 draws a dial of another radius, centred, its ticks, band and needle upstream's distances in from the rim, the pivot in a cell's lower pixel. boot-log at another width lays its self test out to it and clips longer lines at the frame's edge; terminal, file-tree and calendar lay themselves out to their size. - radar's
contactsshow over land and at the scope's edge (drawn just inside the bezel), where upstream hides its own tracks;land: falseleaves out its made-up coast. - spinners with
namesshrinks to fit them, as upstream means it to; a frame here must say its size, so this issize/1. Names it does not know are left out, and if none is left it shows all twelve, where upstream draws nothing. A negativespeedruns them backwards, where upstream would index past its frames. - Unusual options that make upstream misbehave. bar-chart and radar clip
at the frame's edge where upstream's lines would grow past
cols; cpu-meters writes a reading too wide for its column shorter (see its docs), so a table row never grows pastcols, including upstream's own TIME+ past a thousand hours, some ten days into play; bar-chart draws zeros for a dataset without values, and no bars for none, where upstream throws; dissolve wraps its word index where upstream would throw. Counts that size arrays (starfield'sstars, fireflies'count) are truncated to integers within their bounds. - The clock is read at the first frame. analog-clock notes the wall
clock when it is made upstream and at its first frame here: the same
instant when a piece is played as soon as it is made. With
env.now: nillocal time comes from the system's time zone (sundial's start of the year uses its offset at that instant, as JS'sDatedoes). - Fixtures come from x64 V8 (above), so a threshold cell may differ from what an Apple-silicon browser draws.
paperfollows the ground in colour. As inmount, a coloured piece drawn in colour over itsgroundgetspaperfrom the ground's lightness, whatever was asked for.