Skip to content

Latest commit

 

History

History
209 lines (145 loc) · 8.42 KB

File metadata and controls

209 lines (145 loc) · 8.42 KB

Architecture

Overview

pot has three modes:

  • play — mounts a synthetic /proc via FUSE, launches top inside 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 .frames file.
  • convert — converts a GIF or video file to .frames using 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.


.frames format

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=VALUE lines) precedes the first --- delimiter.
  • Each frame follows a --- delimiter and consists of exactly HEIGHT rows.
  • Rows are padded or truncated to WIDTH characters on read and write.

Implementation: internal/frames/format.go (Parse, Write).


ASCII conversion (internal/ascii/)

Convert(img, width, height, gamma, bgThreshold, ramp) → []string

Pipeline:

  1. 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.

  2. Area-average luminance — for each output character cell, average the luminance of all source pixels that map to that cell.

  3. Min-max normalisation — stretch the histogram to [0, 1].

  4. Gamma correctionl = l ^ gamma. Values > 1 darken, < 1 brighten.

  5. Background threshold — cells with luminance ≥ bgThreshold are forced to space. Useful for white-background sources like the horse animation (threshold ≈ 0.55).

  6. Ramp mappingramp[int(l * (len(ramp)-1))]. Default ramp: ` `.;+* (darkest to brightest). Use a reversed ramp for light-on-dark sources.


Synthetic procfs (internal/fuse/)

pot play mounts a FUSE filesystem that impersonates /proc. top reads it as usual and gets animation rows instead of real process data.

Files served

Path Content
/proc/ directories 1N (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%.

Frame advance: dirty-flag coupling

There is no timer. Frame rate couples naturally to top's refresh cycle:

  1. top reads /proc/[pid]/cmdline for each fake PID → sets state.dirty = true.
  2. top calls Readdir("/proc/") to enumerate PIDs → if dirty, advance frame index and reset.

One top refresh cycle = one animation frame, regardless of refresh speed. Implementation: internal/fuse/state.go.

CPU simulation

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.

Memory simulation

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.


play subcommand (internal/cli/play.go)

  1. Preflight — checks /dev/fuse, unshare, top in PATH, and that unprivileged user namespaces are enabled. Each failure produces an actionable error message.

  2. Load animation — via resolveAnimation (internal/cli/resolve.go): resolves :name built-ins or file paths into a *frames.Frames.

  3. Terminal size check — warns if the terminal is narrower than frame_width + 38 columns (158 for the default 120-wide built-ins) or shorter than frame_height + 7 rows.

  4. FUSE mount — creates a temporary directory under $XDG_RUNTIME_DIR, mounts the synthetic procfs there.

  5. toprc isolation — writes a minimal embedded toprc to a temp config directory, sets XDG_CONFIG_HOME to point at it. This forces top to 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-byte fieldscur format) and toprc.v4 (procps-ng 4.x, numeric field list). They are not interchangeable — feeding the 4.x file to 3.x's top produces incompatible rcfile. At runtime toprcForTop() probes top --version and top -v (3.x only accepts -v; later versions standardised on --version), matches procps-ng <major>.<minor> in the combined output, and selects v3 when the major is < 4. On any uncertainty it defaults to v4.

  6. Launch top — via unshare --mount --user --map-root-user, binding the FUSE mount over /proc inside the new namespace, then exec top -d <delay>.

  7. Cleanup — on exit or Ctrl-C, unmounts FUSE and removes temp directories.


preview subcommand (internal/cli/preview.go)

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.


convert subcommand (internal/cli/convert.go)

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.


Built-in animations (internal/frames/builtins/)

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 .frames file directly.
  • cmd/mktux/main.go — animates the Tux PNG with a bobbing sinusoid, compositing over white to handle alpha, then writes a .frames file.

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.


Testing

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