Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ protected override layout() {
}

render() {
this.hdr.set(this.background)
this.restore()
// …moving things…
this.finish()
}
Expand All @@ -103,6 +103,13 @@ city's river) can be made in the first pass, box-filtered with `this.shrink(buff
the second pass to use. Scenes whose `layout()` paints nothing set `supersample = 1`. Smooth fills that cost a lot per
pixel (space's nebula) can be worked out once per real pixel.

The engine also refits only the terminal cells that can have changed since the last frame. `restore()` starts the
frame's `touched` list of boxes, and every drawing method below adds the box it drew in. A scene that writes `hdr` in
its own loop during `render()` must call `touch(x0, y0, x1, y1)` (pixels, inclusive) for that area; the shared helpers
(sky, weather, shadows, light, water, wind) record their own. Anything left untouched keeps last frame's cells, so a missed loop
shows as a smear; `bun dev/check.ts --verify-dirty <id>` catches it. A scene that never calls `restore()` is refitted
in full every frame.

### Drawing

`Canvas` methods, all in pixels, mixing over what's already there:
Expand All @@ -116,7 +123,8 @@ pixel (space's nebula) can be worked out once per real pixel.
| `polygon(points, color, alpha?)` | An anti-aliased polygon in a flat color, for straight-edged things like buildings and boats |
| `shape(parts, lighting, light?)` | Rounded parts joined into one lit form, for creatures, people and plants |

For per-pixel effects such as water, sand or fog, loop over `hdr` directly. `reflect()` in `src/water.ts` mirrors
For per-pixel effects such as water, sand or fog, loop over `hdr` directly, and `touch()` the area during `render()`
(see above). `reflect()` in `src/water.ts` mirrors
other rows of the scene into water rows with drifting ripples, a tint and a per-pixel weight; call it each frame over
only the water's rows (as the beach and the prehistoric lake do), or once in `layout()` for still water such as ice.

Expand Down Expand Up @@ -291,7 +299,8 @@ All optional, and all on `Scene`:

## Performance

A typical wallpaper costs 8 to 12 ms per frame at 15 fps through the whole pipeline (`bun dev/check.ts`). Timings vary
A typical wallpaper costs 3 to 10 ms per frame at 15 fps through the whole pipeline (`bun dev/check.ts`), most of it
fitting the cells that changed: scenes that move everywhere (the ocean, the beach's sea) cost the most. Timings vary
a lot with machine load, so compare against an existing wallpaper measured back to back.

- Paint static things once in `layout()`.
Expand All @@ -314,6 +323,7 @@ a lot with machine load, so compare against an existing wallpaper measured back
```sh
bun run typecheck
bun dev/check.ts [id...] [season] [weather] # every activity and time: errors, ms/frame, flicker
bun dev/check.ts --verify-dirty [id...] # also checks that refitting only changed cells matches a full refit
bun dev/snap.ts <id> [activity] [time] [season] [weather] [seconds...] # scene PNGs in dev/out/, and ms per render
bun dev/preview.ts <id> [activity] [time] [season] [weather] [home|session] [WxH] [seconds] # terminal cells behind text, as a PNG
bun dev/pixel-check.ts [id] [activity] [time] # real-pixel mode against a mock kitty terminal
Expand All @@ -329,6 +339,8 @@ bun dev/gif.ts all # re-renders the README's GIFs
`--quiet` prints only failures, and it fails on flicker above `--max-flicker` (150) and, if given, on frames slower
than `--max-ms`; CI runs `--quiet --max-ms=60`. Dev renders seed `Math.random`, so they are reproducible.
Errors thrown in `step` or `render` show up here; inside OpenCode they are only logged to `WALLPAPER_DEBUG`.
`--verify-dirty` plays each combination twice, refitting every cell (`WALLPAPER_DIRTY=0`) and then only what
changed, with text edits, redraws, agent events and a crossfade mixed in, and fails on any cell that differs.
- `snap.ts` renders the scene directly, before terminal conversion, for judging the art.
- `preview.ts` shows what the terminal will show, scrim included. `home` and `session` lay the text out like OpenCode's
home screen or a busy session, and a size such as `240x60` or `100x30` replaces 160x45.
Expand Down
103 changes: 82 additions & 21 deletions dev/check.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// Runs every wallpaper (or the named ones) at every activity level and time of day through the engine on a mock screen and reports errors, frame cost and
// flicker (cells whose glyph changes per frame; lower is calmer). Exits non-zero on errors, on flicker above --max-flicker (default 150 of 7200 cells)
// and, only when given, on frame cost above --max-ms: bun dev/check.ts [--quiet] [--max-ms=N] [--max-flicker=N] [wallpaper...] [season] [weather]
// and, only when given, on frame cost above --max-ms: bun dev/check.ts [--quiet] [--max-ms=N] [--max-flicker=N] [--verify-dirty] [wallpaper...] [season] [weather]
// --verify-dirty plays each combination twice, refitting every cell every frame (WALLPAPER_DIRTY=0) and then only the
// cells that changed, with text edits, redraws between frames and agent events mixed in, and fails on any cell that
// differs between the two.
import { mkdirSync, rmSync } from "node:fs"
import { parseArgs } from "node:util"
import { ACTIVITIES, SEASONS, TIMES, WEATHERS } from "../src/engine"
import { ACTIVITIES, SEASONS, TIMES, WEATHERS, type Activity, type Engine, type Time, type Wallpaper } from "../src/engine"
import { H, W, mockScreen, text } from "./mock"
import { seed } from "./seed"
import { wallpapers } from "./wallpapers"
Expand All @@ -14,49 +17,107 @@ rmSync(debug, { force: true })
process.env.WALLPAPER_DEBUG = debug
process.env.WALLPAPER_OCTANTS ??= "1"

const args = parseArgs({ allowPositionals: true, options: { quiet: { type: "boolean" }, "max-ms": { type: "string" }, "max-flicker": { type: "string", default: "150" } } })
const args = parseArgs({ allowPositionals: true, options: { quiet: { type: "boolean" }, "max-ms": { type: "string" }, "max-flicker": { type: "string", default: "150" }, "verify-dirty": { type: "boolean" } } })
const maxMs = Number(args.values["max-ms"] ?? Infinity)
const maxFlicker = Number(args.values["max-flicker"])
const verify = args.values["verify-dirty"]
const season = SEASONS.find((s) => args.positionals.includes(s))
const weather = WEATHERS.find((w) => args.positionals.includes(w))
const ids = args.positionals.filter((arg) => arg !== season && arg !== weather)
const unknown = ids.filter((id) => !wallpapers.some((w) => w.id === id))
if (unknown.length) throw new Error(`unknown wallpaper ${unknown.join(", ")}; available: ${wallpapers.map((w) => w.id).join(", ")}`)
const screen = mockScreen()
// Holding the clock makes the next frame a redraw that doesn't advance the scene, as when OpenCode repaints for typing.
let hold = false
const cursor = { x: 10, y: 40, visible: true }
let failed = 0
let slowest = { ms: -1, name: "" }
let busiest = { flicker: -1, name: "" }
const frames = 240
const runs = (ids.length ? wallpapers.filter((w) => ids.includes(w.id)) : wallpapers).flatMap((w) => ACTIVITIES.flatMap((activity) => TIMES.map((time) => ({ wallpaper: w, activity, time }))))
for (const { wallpaper, activity, time } of runs) {
// Same random stream per combination, so a run's numbers don't depend on which others ran before it.
const reference = verify ? play(wallpaper, activity, time, "0").recording : undefined
const result = play(wallpaper, activity, time, "1", reference)
const errors = (await Bun.file(debug).exists()) ? (await Bun.file(debug).text()).split("\n").filter((l) => l.startsWith("error:")) : []
rmSync(debug, { force: true })
const name = `${wallpaper.id} ${activity} ${time}`
if (result.ms > slowest.ms) slowest = { ms: result.ms, name }
if (result.flicker > busiest.flicker) busiest = { flicker: result.flicker, name }
const problems = [
errors.length && `${errors.length} errors`,
result.flicker > maxFlicker && `flicker over ${maxFlicker}`,
result.ms > maxMs && `slower than ${maxMs} ms`,
result.mismatches && `${result.mismatches} cells differ from a full refit, first ${result.first}`,
].filter(Boolean)
if (problems.length) failed++
if (args.values.quiet && !problems.length) continue
console.log(`${name.padEnd(26)} ${problems.length ? "FAIL" : "ok "} ${result.ms.toFixed(1)} ms/frame ${result.flicker.toFixed(0)} of ${W * H} cells change per frame${problems.length ? ` (${problems.join(", ")})` : ""}`)
for (const e of errors.slice(0, 3)) console.log(` ${e}`)
}
console.log(`${runs.length} runs, ${failed} failed; slowest ${slowest.ms.toFixed(1)} ms/frame (${slowest.name}), most flicker ${busiest.flicker.toFixed(0)} cells (${busiest.name})`)
if (failed) process.exit(1)

// Plays one combination, with WALLPAPER_DIRTY set to dirty. Under --verify-dirty it records every frame, and compares
// each against the reference recording when given.
function play(wallpaper: Wallpaper, activity: Activity, time: Time, dirty: string, reference?: Uint16Array[]) {
process.env.WALLPAPER_DIRTY = dirty
// A fresh engine and the same random stream per combination, so a run doesn't depend on which others ran before it.
const screen = mockScreen()
const tick = performance.now
let held = 0
performance.now = () => (hold ? held : (held = tick()))
screen.renderer.getCursorState = () => cursor
seed(1)
screen.engine.start(wallpaper, { activity, time, season, weather })
let previous = new Uint32Array(W * H)
let changes = 0
let total = 0
const frames = 240
let mismatches = 0
let first = ""
const recording: Uint16Array[] = []
for (let f = 0; f < frames; f++) {
if (verify) script(f, screen.engine, wallpaper, activity, time)
const start = Bun.nanoseconds()
screen.frame(text)
screen.frame(verify && f >= 100 && f < 160 ? (x, y) => text(x + 3, y) : text)
hold = false
if (f >= 60) {
total += (Bun.nanoseconds() - start) / 1e6
for (let i = 0; i < W * H; i++) if (screen.buffers.char[i] !== previous[i]) changes++
}
previous = screen.buffers.char.slice()
if (!verify) continue
const cells = new Uint16Array(W * H * 8)
for (let i = 0; i < W * H; i++) {
cells[i * 8] = screen.buffers.char[i] & 0xffff
cells[i * 8 + 1] = screen.buffers.char[i] >>> 16
for (let c = 0; c < 3; c++) {
cells[i * 8 + 2 + c] = screen.buffers.fg[i * 4 + c]
cells[i * 8 + 5 + c] = screen.buffers.bg[i * 4 + c]
}
}
recording.push(cells)
const expected = reference?.[f]
if (!expected) continue
for (let i = 0; i < W * H; i++) {
let same = true
for (let k = 0; k < 8; k++) if (cells[i * 8 + k] !== expected[i * 8 + k]) same = false
if (same) continue
mismatches++
first ||= `frame ${f} cell ${i % W},${(i / W) | 0}`
}
}
screen.engine.stop()
const errors = (await Bun.file(debug).exists()) ? (await Bun.file(debug).text()).split("\n").filter((l) => l.startsWith("error:")) : []
rmSync(debug, { force: true })
const name = `${wallpaper.id} ${activity} ${time}`
const ms = total / (frames - 60)
const flicker = changes / (frames - 60)
if (ms > slowest.ms) slowest = { ms, name }
if (flicker > busiest.flicker) busiest = { flicker, name }
const problems = [errors.length && `${errors.length} errors`, flicker > maxFlicker && `flicker over ${maxFlicker}`, ms > maxMs && `slower than ${maxMs} ms`].filter(Boolean)
if (problems.length) failed++
if (args.values.quiet && !problems.length) continue
console.log(`${name.padEnd(26)} ${problems.length ? "FAIL" : "ok "} ${ms.toFixed(1)} ms/frame ${flicker.toFixed(0)} of ${W * H} cells change per frame${problems.length ? ` (${problems.join(", ")})` : ""}`)
for (const e of errors.slice(0, 3)) console.log(` ${e}`)
return { ms: total / (frames - 60), flicker: changes / (frames - 60), mismatches, first, recording }
}

// Text edits, redraws between frames, a moving cursor, agent events, a brightness change and a crossfade, to exercise every reason
// a cell's fit can go stale.
function script(f: number, engine: Engine, wallpaper: Wallpaper, activity: Activity, time: Time) {
hold = f % 9 === 4
cursor.x = 10 + (f >> 4)
if (f === 70) engine.react("busy")
if (f === 130) engine.react("done")
if (f === 150) engine.react("error")
if (f === 200) engine.react("idle")
if (f === 180) engine.start(wallpaper, { activity, time, season, weather, brightness: "vivid" })
if (f === 205) engine.start(wallpaper, { activity, time: time === "night" ? "day" : "night", season, weather, brightness: "vivid" })
}
console.log(`${runs.length} runs, ${failed} failed; slowest ${slowest.ms.toFixed(1)} ms/frame (${slowest.name}), most flicker ${busiest.flicker.toFixed(0)} cells (${busiest.name})`)
if (failed) process.exit(1)
Loading
Loading