v0.3.0
The "Refine & prove" release. It settles the pre-1.0 API — a fresco-owned
ColorProfile enum, up-front Palette.Validate, and a buffer-reusing
AppendRender — then proves the hot path with an allocation pass and validates
the whole surface against its real downstream consumer (atrium).
Added
AppendRender(dst []byte, w, h, frame int, opts Options) []byte— a
buffer-friendly render path that appends the frame to a caller-owned slice, so
a per-frame loop can reuse one buffer (buf = AppendRender(buf[:0], …))
instead of allocating a fresh string every tick (#17).Renderbecomes a thin,
byte-identical wrapper over it. Purely additive: measured at 120×40 truecolor,
reusing the buffer removes the per-frame output allocation (≈172 KB → 82 KB).Palette.Validate() error— an advisory, opt-in check that reports any
anchor that is not a canonical hex colour ("#rgb"or"#rrggbb"), naming
every offending field (#18).Renderis unchanged: it still never errors or
panics on a malformed palette — each bad anchor degrades to a documented
fallback, so the exactly-h×w-cells contract always holds.Validateis
deliberately stricter than the renderer's parser, so it flags typos (a missing
#, a wrong length, trailing garbage) the renderer would otherwise paint.
Changed
Options.Profileis now a fresco-ownedColorProfileenum instead of
*termenv.Profile, so pinning colour depth no longer requires importing
termenv(#15). The zero value,Auto, auto-detects the terminal exactly as
an unset (nil) profile did before; pinTrueColor,ANSI256,ANSI16, or
NoColorfor a fixed depth. This also settles the options-ergonomics review
(#16):Optionsstays a plain struct — functional options were rejected as
per-frame allocation churn for a 60fps render call — andLumRangestays a
*float64because0is a meaningful value, so no sentinel can mean "unset".
Breaking:Profile: &pbecomesProfile: p(aColorProfilevalue);
callers pinning depth swaptermenv.Ascii→NoColor,termenv.ANSI→ANSI16,
and drop thetermenvimport.
Performance
- Hot-path allocation pass (#21) — the render loop no longer materialises the
field into two per-frame[]float64buffers (the "Pass 1"/"Pass 2" split is
fused: each cell is evaluated inline as it is emitted), and the per-palette LUT
cache is keyed by a comparable struct instead of a freshly joined string on every
call. Output is byte-identical (verified by a 16,896-frame SHA-256 sweep across
variants × profiles × frames × sizes × luminance ranges). On a reused
AppendRenderbuffer this takes the warm hot path from 4 allocations/frame
(≈82 KB at 120×40) to 1 (Rain/Ripple: to zero); viaRender, per-frame
B/oproughly halves. Wall-clock gains are modest and variant-dependent (≈8–12%
for the lighter variants, flat for tunnel and the shaded path) — the pass targets
garbage, not latency. Baselines and method are recorded in
docs/perf.md.