pot has three modes:
- play — mounts a synthetic
/procvia FUSE, launchestopinside an isolated mount namespace, and plays an animation through the process list. - preview — plays directly to the terminal using ANSI cursor control. No FUSE, no
top. Useful for quickly checking a.framesfile. - convert — converts a GIF or video file to
.framesusing area-average luminance → ASCII.
The FUSE approach requires no kernel patches, no root, and leaves the host system untouched.
Mount namespace isolation (unshare --mount --user) ensures the synthetic /proc is visible
only to the top process spawned by pot.
A .frames file is gzip-compressed plain text (the reader also accepts uncompressed files
transparently via magic-byte detection).
WIDTH=120
HEIGHT=45
FPS=10
---
row 1 of frame 1 (exactly WIDTH chars, space-padded)
row 2 of frame 1
...
row HEIGHT of frame 1
---
row 1 of frame 2
...
- The header block (
KEY=VALUElines) precedes the first---delimiter. - Each frame follows a
---delimiter and consists of exactlyHEIGHTrows. - Rows are padded or truncated to
WIDTHcharacters on read and write.
Implementation: internal/frames/format.go (Parse, Write).
Convert(img, width, height, gamma, bgThreshold, ramp) → []string
Pipeline:
-
Letterbox geometry — compute the largest rect with the source's aspect ratio that fits inside the canvas, accounting for the 2:1 height-to-width ratio of terminal cells. Margins are filled with spaces.
-
Area-average luminance — for each output character cell, average the luminance of all source pixels that map to that cell.
-
Min-max normalisation — stretch the histogram to [0, 1].
-
Gamma correction —
l = l ^ gamma. Values > 1 darken, < 1 brighten. -
Background threshold — cells with luminance ≥
bgThresholdare forced to space. Useful for white-background sources like the horse animation (threshold ≈ 0.55). -
Ramp mapping —
ramp[int(l * (len(ramp)-1))]. Default ramp:` `.;+*(darkest to brightest). Use a reversed ramp for light-on-dark sources.
pot play mounts a FUSE filesystem that impersonates /proc. top reads it as usual
and gets animation rows instead of real process data.
| Path | Content |
|---|---|
/proc/ |
directories 1…N (N = frame height), plus stat, meminfo, uptime, loadavg, self |
/proc/uptime |
real elapsed seconds (monotonically increasing — required for top's CPU delta) |
/proc/stat |
idle counter tracking real wall-clock jiffies (same reason) |
/proc/meminfo |
plausible totals, all "free" |
/proc/loadavg |
0.00 0.00 0.00 1/1 1 |
/proc/self |
symlink to the real top PID (so top can find itself) |
/proc/[1..N]/cmdline |
row H − pid of the current animation frame — PID 1 is the bottom row, so art renders top-down under top's PID-ascending sort |
/proc/[1..N]/stat |
52-field stat line with simulated utime, vsize, rss |
/proc/[1..N]/statm |
vsize and rss in pages |
/proc/[1..N]/status |
name, pid, state=S, uid from process profile |
/proc/[other]/stat |
wildcard entry with comm=(top) — lets top query itself |
/proc/uptime and /proc/stat must reflect real time. If they return constant values,
top's per-cycle CPU delta is zero and every process shows 99.9%.
There is no timer. Frame rate couples naturally to top's refresh cycle:
topreads/proc/[pid]/cmdlinefor each fake PID → setsstate.dirty = true.topcallsReaddir("/proc/")to enumerate PIDs → ifdirty, advance frame index and reset.
One top refresh cycle = one animation frame, regardless of refresh speed.
Implementation: internal/fuse/state.go.
Each fake process has two states:
- Idle — 0.1–2% CPU. Rounds to 0.0% in
top's display. - Spike — 20–70% CPU for 1.5–5 seconds, then returns to idle.
At most 3 processes spike concurrently, which looks realistic under top's sort.
The utime counter increments by round(dt × cpu%) jiffies per frame.
Integer rounding ensures the per-frame increment is constant for a given CPU level,
which eliminates %CPU flicker in top's display.
Each process has a constant vsize (50–2000 MB) and a rss that drifts sinusoidally
±3–15% around a base value, updated every 20 frames (~2 seconds at 10 FPS).
The separation between virt and res prevents an unnaturally proportional appearance.
-
Preflight — checks
/dev/fuse,unshare,topin PATH, and that unprivileged user namespaces are enabled. Each failure produces an actionable error message. -
Load animation — via
resolveAnimation(internal/cli/resolve.go): resolves:namebuilt-ins or file paths into a*frames.Frames. -
Terminal size check — warns if the terminal is narrower than
frame_width + 38columns (158 for the default 120-wide built-ins) or shorter thanframe_height + 7rows. -
FUSE mount — creates a temporary directory under
$XDG_RUNTIME_DIR, mounts the synthetic procfs there. -
toprc isolation — writes a minimal embedded
toprcto a temp config directory, setsXDG_CONFIG_HOMEto point at it. This forcestopto show only PID, RES, %CPU, %MEM, TIME+, COMMAND — maximising width for the art.Two rcfile variants are embedded:
toprc.v3(procps-ng 3.x, packed-bytefieldscurformat) andtoprc.v4(procps-ng 4.x, numeric field list). They are not interchangeable — feeding the 4.x file to 3.x'stopproducesincompatible rcfile. At runtimetoprcForTop()probestop --versionandtop -v(3.x only accepts-v; later versions standardised on--version), matchesprocps-ng <major>.<minor>in the combined output, and selects v3 when the major is< 4. On any uncertainty it defaults to v4. -
Launch
top— viaunshare --mount --user --map-root-user, binding the FUSE mount over/procinside the new namespace, thenexec top -d <delay>. -
Cleanup — on exit or Ctrl-C, unmounts FUSE and removes temp directories.
Hides the cursor, moves to the top-left, renders all frame rows, sleeps 1/FPS seconds
(or --delay if overridden), and repeats. Restores the cursor on exit or Ctrl-C.
No FUSE, no namespace, no top.
Reads a GIF (detected by GIF8 magic bytes) or a video file (piped through ffmpeg as
PNG frames). Decodes each source frame, calls ascii.Convert, and writes a .frames file.
Default output dimensions: 240×70 at 10 FPS, sized for 1440p terminals. Use
--width 120 --height 45 to match the built-in animations. The --gamma, --bg-threshold, and --ramp flags tune the ASCII
conversion for sources with unusual contrast or background colour.
Six .frames files embedded via //go:embed *.frames. Generated by make demos, which
fetches source assets and runs two generator tools:
cmd/mkdonut/main.go— renders the spinning torus using 3D rotation matrices and a z-buffer, then writes a.framesfile directly.cmd/mktux/main.go— animates the Tux PNG with a bobbing sinusoid, compositing over white to handle alpha, then writes a.framesfile.
Both tools use //go:build ignore and are invoked via go run from make demos.
For a minimal, self-contained example of building a *frames.Frames value programmatically
(no external assets, no image decoding), see GenerateDemo() in internal/frames/demo.go —
a 120×45 bouncing-ball animation rendered from a simple physics loop. It is a good starting
point if you want to add your own procedural generator.
make test # or: go test ./...| Package | What is tested |
|---|---|
internal/frames |
gzip round-trip, metadata validation, frame dimensions |
internal/fuse |
dirty-flag semantics, stat line has exactly 52 fields |
internal/ascii |
letterbox margins are spaces, output dimensions match |
internal/cli |
preflight error messages, --delay validation |
internal/frames/builtins |
each embedded .frames parses without error |