a73x

321b2280

Merge branch 'gpu-render-testing'

a73x   2026-04-17 16:45

End-to-end automated GPU render testing:
- --capture mode renders VT scripts to PNG via offscreen VkImage
- imgdiff compares PNGs with RMSE + per-pixel-max (side-by-side diffs)
- test-render iterates tests/golden/scripts, diffs against references
- bench-baseline/bench-check track per-section p99 frame timings
- Vendored minimal PNG codec in src/png.zig
- Makefile: test-render, golden-update, bench-baseline, bench-check

diff --git a/.gitignore b/.gitignore
index 2979db1..df57e1a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,11 @@ zig-out/
bench.log
perf.data
flamegraph.svg
tests/golden/output/

# Scratch test binaries (ad-hoc compilations)
/test_io
/test_io2
/test_io3
/test_sig
/test_timer
diff --git a/Makefile b/Makefile
index 3e7754a..087369b 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,7 @@ ZIG ?= zig
FLAMEGRAPH ?= flamegraph.pl
STACKCOLLAPSE ?= stackcollapse-perf.pl

.PHONY: build run test bench profile clean
.PHONY: build run test bench profile clean test-render golden-update bench-baseline bench-check

build:
	$(ZIG) build
@@ -13,7 +13,7 @@ run: build
test:
	$(ZIG) build test

zig-out/bin/waystty: $(wildcard src/*.zig) $(wildcard shaders/*)
zig-out/bin/waystty: $(wildcard src/*.zig) $(wildcard src/tools/*.zig) $(wildcard shaders/*)
	$(ZIG) build

bench: zig-out/bin/waystty
@@ -32,5 +32,17 @@ profile:
	@grep -A 12 "waystty frame timing" bench.log || echo "(no timing data found)"
	xdg-open flamegraph.svg

test-render:
	$(ZIG) build test-render

golden-update:
	WAYSTTY_GOLDEN_UPDATE=1 $(ZIG) build test-render

bench-baseline:
	$(ZIG) build bench-baseline

bench-check:
	$(ZIG) build bench-check

clean:
	rm -rf zig-out .zig-cache perf.data bench.log flamegraph.svg
	rm -rf zig-out .zig-cache perf.data bench.log flamegraph.svg tests/golden/output
diff --git a/build.zig b/build.zig
index 7c8c808..8afb3bf 100644
--- a/build.zig
+++ b/build.zig
@@ -15,6 +15,12 @@ pub fn build(b: *std.Build) void {
        .optimize = optimize,
    });

    const bench_stats_mod = b.createModule(.{
        .root_source_file = b.path("src/bench_stats.zig"),
        .target = target,
        .optimize = optimize,
    });

    // Lazy-fetch the ghostty dependency. On the first invocation this
    // materializes the package; subsequent builds use the local cache.
    const ghostty_dep = b.lazyDependency("ghostty", .{});
@@ -79,6 +85,7 @@ pub fn build(b: *std.Build) void {
    exe_mod.addImport("wayland-client", wayland_mod);
    exe_mod.addImport("config", config_mod);
    exe_mod.addImport("frame_loop", frame_loop_mod);
    exe_mod.addImport("bench_stats", bench_stats_mod);

    const exe = b.addExecutable(.{
        .name = "waystty",
@@ -130,6 +137,15 @@ pub fn build(b: *std.Build) void {
    });
    test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step);

    // Test bench_stats.zig
    const bench_stats_test_mod = b.createModule(.{
        .root_source_file = b.path("src/bench_stats.zig"),
        .target = target,
        .optimize = optimize,
    });
    const bench_stats_tests = b.addTest(.{ .root_module = bench_stats_test_mod });
    test_step.dependOn(&b.addRunArtifact(bench_stats_tests).step);

    // Test frame_loop.zig
    const frame_loop_test_mod = b.createModule(.{
        .root_source_file = b.path("src/frame_loop.zig"),
@@ -169,6 +185,7 @@ pub fn build(b: *std.Build) void {
    main_test_mod.addImport("vt", vt_mod);
    main_test_mod.addImport("wayland-client", wayland_mod);
    main_test_mod.addImport("config", config_mod);
    main_test_mod.addImport("bench_stats", bench_stats_mod);
    const main_tests = b.addTest(.{
        .root_module = main_test_mod,
    });
@@ -268,4 +285,114 @@ pub fn build(b: *std.Build) void {
        .root_module = renderer_test_mod,
    });
    test_step.dependOn(&b.addRunArtifact(renderer_tests).step);

    // png module — vendored minimal RGBA8 PNG codec
    const png_mod = b.createModule(.{
        .root_source_file = b.path("src/png.zig"),
        .target = target,
        .optimize = optimize,
    });
    exe_mod.addImport("png", png_mod);

    const png_test_mod = b.createModule(.{
        .root_source_file = b.path("src/png.zig"),
        .target = target,
        .optimize = optimize,
    });
    const png_tests = b.addTest(.{ .root_module = png_test_mod });
    test_step.dependOn(&b.addRunArtifact(png_tests).step);

    // cell_instance module — shared appendCellInstances / glyphTopOffset helpers
    const cell_instance_mod = b.createModule(.{
        .root_source_file = b.path("src/cell_instance.zig"),
        .target = target,
        .optimize = optimize,
    });
    cell_instance_mod.addImport("renderer", renderer_mod);
    cell_instance_mod.addImport("font", font_mod);
    cell_instance_mod.addImport("vt", vt_mod);
    exe_mod.addImport("cell_instance", cell_instance_mod);
    main_test_mod.addImport("cell_instance", cell_instance_mod);

    // capture module — --capture mode (render a VT script to PNG)
    const capture_mod = b.createModule(.{
        .root_source_file = b.path("src/capture.zig"),
        .target = target,
        .optimize = optimize,
        .link_libc = true,
    });
    capture_mod.addImport("vt", vt_mod);
    capture_mod.addImport("pty", pty_mod);
    capture_mod.addImport("wayland-client", wayland_mod);
    capture_mod.addImport("renderer", renderer_mod);
    capture_mod.addImport("font", font_mod);
    capture_mod.addImport("config", config_mod);
    capture_mod.addImport("png", png_mod);
    capture_mod.addImport("vulkan", vulkan_module);
    capture_mod.addImport("cell_instance", cell_instance_mod);
    exe_mod.addImport("capture", capture_mod);

    // imgdiff — standalone PNG comparison tool
    const imgdiff_mod = b.createModule(.{
        .root_source_file = b.path("src/tools/imgdiff.zig"),
        .target = target,
        .optimize = optimize,
    });
    imgdiff_mod.addImport("png", png_mod);
    const imgdiff_exe = b.addExecutable(.{
        .name = "imgdiff",
        .root_module = imgdiff_mod,
    });
    b.installArtifact(imgdiff_exe);

    const imgdiff_test_mod = b.createModule(.{
        .root_source_file = b.path("src/tools/imgdiff.zig"),
        .target = target,
        .optimize = optimize,
    });
    imgdiff_test_mod.addImport("png", png_mod);
    const imgdiff_tests = b.addTest(.{ .root_module = imgdiff_test_mod });
    test_step.dependOn(&b.addRunArtifact(imgdiff_tests).step);

    const test_render_mod = b.createModule(.{
        .root_source_file = b.path("src/tools/test_render.zig"),
        .target = target,
        .optimize = optimize,
    });
    const test_render_exe = b.addExecutable(.{
        .name = "test-render",
        .root_module = test_render_mod,
    });
    b.installArtifact(test_render_exe);

    const test_render_step = b.step("test-render", "Run all golden VT scripts and diff against references");
    test_render_step.dependOn(b.getInstallStep());
    const test_render_run = b.addRunArtifact(test_render_exe);
    test_render_run.step.dependOn(b.getInstallStep());
    test_render_step.dependOn(&test_render_run.step);

    // bench-baseline / bench-check — frame-timing regression guard
    const bench_baseline_mod = b.createModule(.{
        .root_source_file = b.path("src/tools/bench_baseline.zig"),
        .target = target,
        .optimize = optimize,
    });
    bench_baseline_mod.addImport("bench_stats", bench_stats_mod);
    const bench_baseline_exe = b.addExecutable(.{
        .name = "bench-baseline",
        .root_module = bench_baseline_mod,
    });
    b.installArtifact(bench_baseline_exe);

    const bench_baseline_step = b.step("bench-baseline", "Save current frame-timing profile to tests/bench/baseline.json");
    const bench_baseline_run = b.addRunArtifact(bench_baseline_exe);
    bench_baseline_run.addArg("save");
    bench_baseline_run.step.dependOn(b.getInstallStep());
    bench_baseline_step.dependOn(&bench_baseline_run.step);

    const bench_check_step = b.step("bench-check", "Compare current frame timings against baseline");
    const bench_check_run = b.addRunArtifact(bench_baseline_exe);
    bench_check_run.addArg("check");
    bench_check_run.step.dependOn(b.getInstallStep());
    bench_check_step.dependOn(&bench_check_run.step);
}
diff --git a/src/bench_stats.zig b/src/bench_stats.zig
new file mode 100644
index 0000000..457ee79
--- /dev/null
+++ b/src/bench_stats.zig
@@ -0,0 +1,267 @@
const std = @import("std");

pub const FrameTiming = struct {
    snapshot_us: u32 = 0,
    row_rebuild_us: u32 = 0,
    atlas_upload_us: u32 = 0,
    instance_upload_us: u32 = 0,
    gpu_submit_us: u32 = 0,

    pub fn total(self: FrameTiming) u32 {
        return self.snapshot_us +
            self.row_rebuild_us +
            self.atlas_upload_us +
            self.instance_upload_us +
            self.gpu_submit_us;
    }
};

pub const FrameTimingRing = struct {
    pub const capacity = 256;

    entries: [capacity]FrameTiming = [_]FrameTiming{.{}} ** capacity,
    head: usize = 0,
    count: usize = 0,

    pub fn push(self: *FrameTimingRing, timing: FrameTiming) void {
        const idx = if (self.count < capacity) self.count else self.head;
        self.entries[idx] = timing;
        if (self.count < capacity) {
            self.count += 1;
        } else {
            self.head = (self.head + 1) % capacity;
        }
    }

    /// Return a slice of valid entries in insertion order.
    /// Caller must provide a scratch buffer of `capacity` entries.
    pub fn orderedSlice(self: *const FrameTimingRing, buf: *[capacity]FrameTiming) []const FrameTiming {
        if (self.count < capacity) {
            return self.entries[0..self.count];
        }
        // Ring has wrapped — copy from head..end then 0..head
        const tail_len = capacity - self.head;
        @memcpy(buf[0..tail_len], self.entries[self.head..capacity]);
        @memcpy(buf[tail_len..capacity], self.entries[0..self.head]);
        return buf[0..capacity];
    }
};

pub const SectionStats = struct {
    min: u32 = 0,
    avg: u32 = 0,
    p99: u32 = 0,
    max: u32 = 0,
};

pub const FrameTimingStats = struct {
    snapshot: SectionStats = .{},
    row_rebuild: SectionStats = .{},
    atlas_upload: SectionStats = .{},
    instance_upload: SectionStats = .{},
    gpu_submit: SectionStats = .{},
    total: SectionStats = .{},
    frame_count: usize = 0,
};

pub fn computeSectionStats(values: []u32) SectionStats {
    if (values.len == 0) return .{};
    std.mem.sort(u32, values, {}, std.sort.asc(u32));
    var sum: u64 = 0;
    for (values) |v| sum += v;
    const p99_idx = if (values.len <= 1) 0 else ((values.len - 1) * 99) / 100;
    return .{
        .min = values[0],
        .avg = @intCast(sum / values.len),
        .p99 = values[p99_idx],
        .max = values[values.len - 1],
    };
}

pub fn computeFrameStats(ring: *const FrameTimingRing) FrameTimingStats {
    if (ring.count == 0) return .{};

    var ordered_buf: [FrameTimingRing.capacity]FrameTiming = undefined;
    const entries = ring.orderedSlice(&ordered_buf);
    const n = entries.len;

    var snapshot_vals: [FrameTimingRing.capacity]u32 = undefined;
    var row_rebuild_vals: [FrameTimingRing.capacity]u32 = undefined;
    var atlas_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
    var instance_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
    var gpu_submit_vals: [FrameTimingRing.capacity]u32 = undefined;
    var total_vals: [FrameTimingRing.capacity]u32 = undefined;

    for (entries, 0..) |e, i| {
        snapshot_vals[i] = e.snapshot_us;
        row_rebuild_vals[i] = e.row_rebuild_us;
        atlas_upload_vals[i] = e.atlas_upload_us;
        instance_upload_vals[i] = e.instance_upload_us;
        gpu_submit_vals[i] = e.gpu_submit_us;
        total_vals[i] = e.total();
    }

    return .{
        .snapshot = computeSectionStats(snapshot_vals[0..n]),
        .row_rebuild = computeSectionStats(row_rebuild_vals[0..n]),
        .atlas_upload = computeSectionStats(atlas_upload_vals[0..n]),
        .instance_upload = computeSectionStats(instance_upload_vals[0..n]),
        .gpu_submit = computeSectionStats(gpu_submit_vals[0..n]),
        .total = computeSectionStats(total_vals[0..n]),
        .frame_count = n,
    };
}

pub fn printFrameStats(stats: FrameTimingStats) void {
    const row_fmt = "{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}\n";
    std.debug.print("\n=== waystty frame timing ({d} frames) ===\n", .{stats.frame_count});
    std.debug.print("{s:<20}{s:>6}{s:>6}{s:>6}{s:>6}  (us)\n", .{ "section", "min", "avg", "p99", "max" });
    std.debug.print(row_fmt, .{ "snapshot",        stats.snapshot.min,        stats.snapshot.avg,        stats.snapshot.p99,        stats.snapshot.max });
    std.debug.print(row_fmt, .{ "row_rebuild",     stats.row_rebuild.min,     stats.row_rebuild.avg,     stats.row_rebuild.p99,     stats.row_rebuild.max });
    std.debug.print(row_fmt, .{ "atlas_upload",    stats.atlas_upload.min,    stats.atlas_upload.avg,    stats.atlas_upload.p99,    stats.atlas_upload.max });
    std.debug.print(row_fmt, .{ "instance_upload", stats.instance_upload.min, stats.instance_upload.avg, stats.instance_upload.p99, stats.instance_upload.max });
    std.debug.print(row_fmt, .{ "gpu_submit",      stats.gpu_submit.min,      stats.gpu_submit.avg,      stats.gpu_submit.p99,      stats.gpu_submit.max });
    std.debug.print("----------------------------------------------------\n", .{});
    std.debug.print(row_fmt, .{ "total",           stats.total.min,           stats.total.avg,           stats.total.p99,           stats.total.max });
}

test "FrameTiming.total sums all sections" {
    const ft: FrameTiming = .{
        .snapshot_us = 10,
        .row_rebuild_us = 20,
        .atlas_upload_us = 30,
        .instance_upload_us = 40,
        .gpu_submit_us = 50,
    };
    try std.testing.expectEqual(@as(u32, 150), ft.total());
}

test "FrameTimingRing records and wraps correctly" {
    var ring = FrameTimingRing{};
    try std.testing.expectEqual(@as(usize, 0), ring.count);

    ring.push(.{ .snapshot_us = 1, .row_rebuild_us = 2, .atlas_upload_us = 3, .instance_upload_us = 4, .gpu_submit_us = 5 });
    try std.testing.expectEqual(@as(usize, 1), ring.count);
    try std.testing.expectEqual(@as(u32, 1), ring.entries[0].snapshot_us);

    // Fill to capacity
    for (1..FrameTimingRing.capacity) |i| {
        ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
    }
    try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);

    // One more wraps around — overwrites entries[0], head advances to 1
    ring.push(.{ .snapshot_us = 999, .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
    try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
    // Newest entry is at (head + capacity - 1) % capacity = 0
    try std.testing.expectEqual(@as(u32, 999), ring.entries[0].snapshot_us);
    // head has advanced past the overwritten slot
    try std.testing.expectEqual(@as(usize, 1), ring.head);
}

test "FrameTimingRing.orderedSlice returns entries in insertion order after wrap" {
    var ring = FrameTimingRing{};
    // Push capacity + 3 entries so the ring wraps
    for (0..FrameTimingRing.capacity + 3) |i| {
        ring.push(.{ .snapshot_us = @intCast(i), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
    }
    var buf: [FrameTimingRing.capacity]FrameTiming = undefined;
    const ordered = ring.orderedSlice(&buf);
    try std.testing.expectEqual(FrameTimingRing.capacity, ordered.len);
    // First entry should be the 4th pushed (index 3), last should be capacity+2
    try std.testing.expectEqual(@as(u32, 3), ordered[0].snapshot_us);
    try std.testing.expectEqual(@as(u32, FrameTimingRing.capacity + 2), ordered[ordered.len - 1].snapshot_us);
}

test "FrameTimingStats computes min/avg/p99/max correctly" {
    var ring = FrameTimingRing{};
    // Push 100 frames with snapshot_us = 1..100
    for (0..100) |i| {
        ring.push(.{
            .snapshot_us = @intCast(i + 1),
            .row_rebuild_us = 0,
            .atlas_upload_us = 0,
            .instance_upload_us = 0,
            .gpu_submit_us = 0,
        });
    }
    const stats = computeFrameStats(&ring);
    try std.testing.expectEqual(@as(u32, 1), stats.snapshot.min);
    try std.testing.expectEqual(@as(u32, 100), stats.snapshot.max);
    try std.testing.expectEqual(@as(u32, 50), stats.snapshot.avg);
    // p99 of 1..100 = value at index 98 (0-based) = 99
    try std.testing.expectEqual(@as(u32, 99), stats.snapshot.p99);
    try std.testing.expectEqual(@as(usize, 100), stats.frame_count);
}

test "FrameTimingStats handles empty ring" {
    var ring = FrameTimingRing{};
    const stats = computeFrameStats(&ring);
    try std.testing.expectEqual(@as(usize, 0), stats.frame_count);
    try std.testing.expectEqual(@as(u32, 0), stats.snapshot.min);
}

pub const BaselineRecord = struct {
    workload_sha: []const u8,
    zig_version: []const u8,
    waystty_sha: []const u8,
    frame_count: usize,
    sections: struct {
        snapshot: SectionStats,
        row_rebuild: SectionStats,
        atlas_upload: SectionStats,
        instance_upload: SectionStats,
        gpu_submit: SectionStats,
    },
};

/// Serialize `rec` to JSON and return an owned slice. Caller must free.
pub fn writeBaselineJson(alloc: std.mem.Allocator, rec: BaselineRecord) ![]u8 {
    var out: std.Io.Writer.Allocating = .init(alloc);
    errdefer out.deinit();
    try std.json.Stringify.value(rec, .{ .whitespace = .indent_2 }, &out.writer);
    return out.toOwnedSlice();
}

pub fn readBaselineJson(alloc: std.mem.Allocator, bytes: []const u8) !BaselineRecord {
    var parsed = try std.json.parseFromSlice(BaselineRecord, alloc, bytes, .{});
    defer parsed.deinit();
    return .{
        .workload_sha = try alloc.dupe(u8, parsed.value.workload_sha),
        .zig_version = try alloc.dupe(u8, parsed.value.zig_version),
        .waystty_sha = try alloc.dupe(u8, parsed.value.waystty_sha),
        .frame_count = parsed.value.frame_count,
        .sections = parsed.value.sections,
    };
}

test "baseline JSON round-trip" {
    const alloc = std.testing.allocator;
    const rec = BaselineRecord{
        .workload_sha = "abcdef",
        .zig_version = "0.15.0",
        .waystty_sha = "123abc",
        .frame_count = 256,
        .sections = .{
            .snapshot = .{ .min = 1, .avg = 2, .p99 = 3, .max = 4 },
            .row_rebuild = .{ .min = 10, .avg = 20, .p99 = 30, .max = 40 },
            .atlas_upload = .{ .min = 0, .avg = 0, .p99 = 0, .max = 0 },
            .instance_upload = .{ .min = 5, .avg = 6, .p99 = 7, .max = 8 },
            .gpu_submit = .{ .min = 9, .avg = 9, .p99 = 9, .max = 9 },
        },
    };

    const json_bytes = try writeBaselineJson(alloc, rec);
    defer alloc.free(json_bytes);

    const parsed = try readBaselineJson(alloc, json_bytes);
    defer {
        alloc.free(parsed.workload_sha);
        alloc.free(parsed.zig_version);
        alloc.free(parsed.waystty_sha);
    }

    try std.testing.expectEqual(@as(usize, 256), parsed.frame_count);
    try std.testing.expectEqual(@as(u32, 30), parsed.sections.row_rebuild.p99);
    try std.testing.expectEqualStrings("abcdef", parsed.workload_sha);
}
diff --git a/src/capture.zig b/src/capture.zig
new file mode 100644
index 0000000..1c82e21
--- /dev/null
+++ b/src/capture.zig
@@ -0,0 +1,360 @@
//! `--capture <script> <output.png>` mode.
//!
//! Renders a VT script to a single PNG frame for golden-image testing.
//!
//! 1. Stand up a Wayland window + Vulkan context at a forced 80x24 grid,
//!    buffer scale = 1 (so renders are deterministic across multi-monitor
//!    setups).
//! 2. Wait up to 3s for the window to become visible.
//! 3. Pipe the script through a PTY via `/bin/cat`, then drain remaining
//!    output after cat exits.
//! 4. Snapshot the terminal, build a flat Instance list for every cell,
//!    render a single frame to an offscreen VkImage, read the BGRA bytes
//!    back, convert to RGBA and write a PNG.
//!
//! The window itself is never committed/presented — the offscreen target
//! is its own framebuffer. We still need the Wayland surface so Vulkan
//! can allocate a swapchain (required by the current Context.init path)
//! and so the compositor hands us a real configure event.

const std = @import("std");
const vt = @import("vt");
const pty = @import("pty");
const wayland_client = @import("wayland-client");
const renderer = @import("renderer");
const font = @import("font");
const config = @import("config");
const png = @import("png");
const vk = @import("vulkan");

pub const CaptureError = error{
    MissingArgs,
    ScriptNotFound,
    OutputPathUnwritable,
    WindowNotVisible,
    WindowSizeMismatch,
    PngEncodeFailed,
};

const cell_instance = @import("cell_instance");
const appendCellInstances = cell_instance.appendCellInstances;
const glyphTopOffset = cell_instance.glyphTopOffset;

const CAPTURE_COLS: u16 = 80;
const CAPTURE_ROWS: u16 = 24;
const VISIBILITY_TIMEOUT_NS: i128 = 3 * std.time.ns_per_s;

/// Entry point. `argv[0]` is `--capture`; argv[1] = script path, argv[2] = out path.
pub fn run(alloc: std.mem.Allocator, argv: []const [:0]const u8) !void {
    if (argv.len < 3) {
        std.debug.print("usage: waystty --capture <script.vt> <output.png>\n", .{});
        return CaptureError.MissingArgs;
    }
    const script_path = argv[1];
    const out_path = argv[2];

    // Probe script path up-front so we fail fast with a clean error rather
    // than having cat silently print a "No such file" diagnostic onto the
    // captured image.
    std.fs.cwd().access(script_path, .{}) catch |err| {
        std.debug.print("capture: cannot read script {s}: {t}\n", .{ script_path, err });
        return CaptureError.ScriptNotFound;
    };

    // === font + cell metrics (scale=1, same lookup as runTerminal) ===
    var font_lookup = try font.lookupConfiguredFont(alloc);
    defer font_lookup.deinit(alloc);

    const font_size: u32 = config.font_size_px;
    var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, font_size);
    defer face.deinit();

    const cell_w: u32 = face.cellWidth();
    const cell_h: u32 = face.cellHeight();
    const baseline: u32 = face.baseline();

    const px_w: u32 = @as(u32, CAPTURE_COLS) * cell_w;
    const px_h: u32 = @as(u32, CAPTURE_ROWS) * cell_h;

    // === wayland ===
    const conn = try wayland_client.Connection.init(alloc);
    defer conn.deinit();

    const window = try conn.createWindow(alloc, "waystty-capture");
    defer window.deinit();

    window.width = px_w;
    window.height = px_h;
    _ = conn.display.roundtrip();

    // === vulkan context (swapchain matches requested px size) ===
    var ctx = try renderer.Context.init(
        alloc,
        @ptrCast(conn.display),
        @ptrCast(window.surface),
        px_w,
        px_h,
    );
    defer ctx.deinit();

    // === offscreen render target (separate framebuffer; renders don't present) ===
    var offscreen = try renderer.createOffscreen(
        ctx.vki,
        ctx.vkd,
        ctx.physical_device,
        ctx.device,
        ctx.render_pass,
        ctx.swapchain_format,
        px_w,
        px_h,
    );
    defer renderer.destroyOffscreen(ctx.vkd, ctx.device, offscreen);

    // === glyph atlas + printable ASCII warm-up (matches runTerminal) ===
    var atlas = try font.Atlas.init(alloc, 1024, 1024);
    defer atlas.deinit();

    for (32..127) |cp| {
        _ = atlas.getOrInsert(&face, @intCast(cp)) catch |err| switch (err) {
            error.AtlasFull => break,
            else => return err,
        };
    }
    try ctx.uploadAtlas(atlas.pixels);
    atlas.last_uploaded_y = atlas.cursor_y;
    atlas.needs_full_upload = false;
    atlas.dirty = false;

    // === terminal ===
    var term = try vt.Terminal.init(alloc, .{
        .cols = CAPTURE_COLS,
        .rows = CAPTURE_ROWS,
        .max_scrollback = 1000,
    });
    defer term.deinit();
    term.setReportedSize(.{
        .rows = CAPTURE_ROWS,
        .columns = CAPTURE_COLS,
        .cell_width = cell_w,
        .cell_height = cell_h,
    });

    // === visibility wait + size check ===
    try waitUntilVisible(conn, window);

    if (window.width != px_w or window.height != px_h) {
        std.debug.print(
            "capture: window size mismatch (got {d}x{d}, expected {d}x{d})\n",
            .{ window.width, window.height, px_w, px_h },
        );
        return CaptureError.WindowSizeMismatch;
    }

    // === play script through /bin/cat ===
    try playScript(term, script_path);

    // === snapshot + build instances ===
    try term.snapshot();

    var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
    defer instances.deinit(alloc);

    try buildInstancesForSnapshot(
        alloc,
        &instances,
        term,
        &face,
        &atlas,
        cell_w,
        cell_h,
        baseline,
    );

    // If the script needed glyphs that weren't in the ASCII warm-up set,
    // the atlas pixels are newer than the GPU copy. Re-upload the full
    // atlas so the render samples valid texels.
    if (atlas.dirty) {
        try ctx.uploadAtlas(atlas.pixels);
        atlas.dirty = false;
        atlas.last_uploaded_y = atlas.cursor_y;
    }

    // === render one frame to offscreen ===
    const push = renderer.PushConstants{
        .viewport_size = .{ @floatFromInt(px_w), @floatFromInt(px_h) },
        .cell_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
        .coverage_params = renderer.coverageVariantParams(.baseline),
    };

    try ctx.renderToOffscreen(&offscreen, instances.items, push);

    // === readback BGRA->RGBA ===
    const rgba = try alloc.alloc(u8, @as(usize, px_w) * px_h * 4);
    defer alloc.free(rgba);
    try ctx.readbackOffscreen(&offscreen, rgba);

    // === encode PNG ===
    try writePng(alloc, out_path, px_w, px_h, rgba);

    std.debug.print("capture: wrote {s} ({d}x{d})\n", .{ out_path, px_w, px_h });
}

/// Wait up to VISIBILITY_TIMEOUT_NS for the Wayland compositor to `configure`
/// the surface. For `--capture` we don't need the surface to actually be
/// mapped onto an output (which would require committing a presentable
/// buffer via the swapchain — we deliberately skip that since rendering is
/// offscreen). A configured surface is enough to know our fixed 80x24
/// geometry was accepted.
fn waitUntilVisible(conn: *wayland_client.Connection, window: *wayland_client.Window) !void {
    const deadline = @as(i128, std.time.nanoTimestamp()) + VISIBILITY_TIMEOUT_NS;
    while (std.time.nanoTimestamp() < deadline) {
        _ = conn.display.roundtrip();
        if (window.state.configured) return;
        std.Thread.sleep(10 * std.time.ns_per_ms);
    }
    std.debug.print(
        "capture: window never configured within 3s\n",
        .{},
    );
    return CaptureError.WindowNotVisible;
}

/// Spawn `/bin/cat <script>` on a PTY; feed all its output into `term`.
/// Returns once the child has exited AND two consecutive 20 ms polls
/// produce no new bytes (drain).
///
/// Note: spawns cat with script as argv rather than piping stdin+^D —
/// avoids EOF-signalling races with VT escape sequences.
fn playScript(
    term: *vt.Terminal,
    script_path: [:0]const u8,
) !void {
    var p = try pty.Pty.spawn(.{
        .cols = CAPTURE_COLS,
        .rows = CAPTURE_ROWS,
        .shell = "/bin/cat",
        .shell_args = &.{script_path},
    });
    defer p.deinit();

    var buf: [4096]u8 = undefined;
    var consecutive_empty: u32 = 0;

    // Loop until child exited AND we saw two empty polls in a row (to make
    // sure any straggler bytes in the master buffer have been drained).
    while (true) {
        var pfd = [_]std.posix.pollfd{
            .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 },
        };
        _ = std.posix.poll(&pfd, 20) catch 0;

        var saw_bytes = false;
        while (true) {
            const n = p.read(&buf) catch |err| switch (err) {
                error.WouldBlock => break,
                // EIO on Linux after slave fd closes is the normal signal
                // that cat exited. Break out — the child reaper below will
                // notice.
                error.InputOutput => break,
                else => return err,
            };
            if (n == 0) break;
            term.write(buf[0..n]);
            saw_bytes = true;
        }

        const alive = p.isChildAlive();
        if (saw_bytes) {
            consecutive_empty = 0;
        } else if (!alive) {
            consecutive_empty += 1;
            if (consecutive_empty >= 2) break;
        }
    }

    // VT parser settle — give any delayed effects (timers, etc) a beat.
    std.Thread.sleep(50 * std.time.ns_per_ms);
}

/// Build a flat Instance list covering every cell in the current snapshot.
/// Does not do dirty tracking — this is a one-shot full rebuild. Mirrors
/// the per-cell logic in `main.zig:rebuildRowInstances`, minus the
/// selection/cursor overlay.
fn buildInstancesForSnapshot(
    alloc: std.mem.Allocator,
    instances: *std.ArrayListUnmanaged(renderer.Instance),
    term: *vt.Terminal,
    face: *font.Face,
    atlas: *font.Atlas,
    cell_w: u32,
    cell_h: u32,
    baseline: u32,
) !void {
    const default_bg = term.backgroundColor();
    const bg_uv = atlas.cursorUV();

    const term_rows = term.render_state.row_data.items(.cells);
    var row_idx: u32 = 0;
    while (row_idx < term_rows.len) : (row_idx += 1) {
        const row_cells = term_rows[row_idx];
        const raw_cells = row_cells.items(.raw);
        var col_idx: u32 = 0;
        while (col_idx < raw_cells.len) : (col_idx += 1) {
            const cp = raw_cells[col_idx].codepoint();
            const colors = term.cellColors(row_cells.get(col_idx));
            const glyph_uv = if (cp == 0 or cp == ' ')
                null
            else
                atlas.getOrInsert(face, @intCast(cp)) catch null;

            try appendCellInstances(
                alloc,
                instances,
                row_idx,
                col_idx,
                cell_w,
                cell_h,
                baseline,
                glyph_uv,
                bg_uv,
                colors,
                default_bg,
            );
        }
    }
}

/// Encode `rgba` as a PNG to a brand-new file at `path`. Buffers the full
/// encoded byte stream in memory (fine for 80x24@16px: under 200 KB) and
/// writes it in one shot.
fn writePng(
    alloc: std.mem.Allocator,
    path: [:0]const u8,
    width: u32,
    height: u32,
    rgba: []u8,
) !void {
    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(alloc);

    const img: png.Image = .{
        .width = width,
        .height = height,
        .pixels = rgba,
    };
    png.encode(alloc, img, buf.writer(alloc)) catch |err| {
        std.debug.print("capture: PNG encode failed: {t}\n", .{err});
        return CaptureError.PngEncodeFailed;
    };

    const file = std.fs.cwd().createFile(path, .{ .truncate = true }) catch |err| {
        std.debug.print("capture: cannot open output {s}: {t}\n", .{ path, err });
        return CaptureError.OutputPathUnwritable;
    };
    defer file.close();

    file.writeAll(buf.items) catch |err| {
        std.debug.print("capture: write failed for {s}: {t}\n", .{ path, err });
        return CaptureError.OutputPathUnwritable;
    };
}
diff --git a/src/cell_instance.zig b/src/cell_instance.zig
new file mode 100644
index 0000000..53a78e9
--- /dev/null
+++ b/src/cell_instance.zig
@@ -0,0 +1,58 @@
//! Shared cell-instance helpers used by both the live renderer (main.zig)
//! and the capture renderer (capture.zig).

const std = @import("std");
const renderer = @import("renderer");
const font = @import("font");
const vt = @import("vt");

/// Appends 0-2 `renderer.Instance` entries for a single terminal cell:
///   - a filled-background quad when the cell's bg differs from the terminal
///     default bg (so transparent cells don't draw a quad at all);
///   - a glyph quad when `glyph_uv` is non-null (i.e. the cell has a
///     printable codepoint that was found in the atlas).
pub fn appendCellInstances(
    alloc: std.mem.Allocator,
    instances: *std.ArrayListUnmanaged(renderer.Instance),
    row_idx: u32,
    col_idx: u32,
    cell_w: u32,
    cell_h: u32,
    baseline: u32,
    glyph_uv: ?font.GlyphUV,
    bg_uv: font.GlyphUV,
    colors: vt.CellColors,
    default_bg: [4]f32,
) !void {
    if (!std.meta.eql(colors.bg, default_bg)) {
        try instances.append(alloc, .{
            .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
            .glyph_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
            .glyph_bearing = .{ 0, 0 },
            .uv_rect = .{ bg_uv.u0, bg_uv.v0, bg_uv.u1, bg_uv.v1 },
            .fg = colors.bg,
            .bg = colors.bg,
        });
    }

    const uv = glyph_uv orelse return;
    try instances.append(alloc, .{
        .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
        .glyph_size = .{ @floatFromInt(uv.width), @floatFromInt(uv.height) },
        .glyph_bearing = .{
            @floatFromInt(uv.bearing_x),
            glyphTopOffset(baseline, uv.bearing_y),
        },
        .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 },
        .fg = colors.fg,
        .bg = colors.bg,
    });
}

/// Returns the number of pixels from the top of the cell to the top of the
/// glyph bitmap, given the cell `baseline` (pixels from cell top to the
/// typographic baseline) and the glyph's `bearing_y` (pixels from baseline
/// to the top of the glyph bitmap, positive = up).
pub fn glyphTopOffset(baseline: u32, bearing_y: i32) f32 {
    return @as(f32, @floatFromInt(baseline)) - @as(f32, @floatFromInt(bearing_y));
}
diff --git a/src/main.zig b/src/main.zig
index b25e71e..702cfdd 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -7,6 +7,17 @@ const renderer = @import("renderer");
const font = @import("font");
const config = @import("config");
const vk = @import("vulkan");
const bench_stats = @import("bench_stats");
const cell_instance = @import("cell_instance");
const appendCellInstances = cell_instance.appendCellInstances;
const glyphTopOffset = cell_instance.glyphTopOffset;
const FrameTiming = bench_stats.FrameTiming;
const FrameTimingRing = bench_stats.FrameTimingRing;
const SectionStats = bench_stats.SectionStats;
const FrameTimingStats = bench_stats.FrameTimingStats;
const computeSectionStats = bench_stats.computeSectionStats;
const computeFrameStats = bench_stats.computeFrameStats;
const printFrameStats = bench_stats.printFrameStats;

const c = @cImport({
    @cInclude("xkbcommon/xkbcommon-keysyms.h");
@@ -59,6 +70,61 @@ fn updateWindowTitle(_: *vt.Terminal, ctx: ?*anyopaque, title: ?[:0]const u8) vo
    window.setTitle(title);
}

/// If `WAYSTTY_BENCH_JSON` is set, write a BaselineRecord JSON to that path.
/// Machine-readable companion to `printFrameStats`.
fn writeBenchJson(alloc: std.mem.Allocator, stats: FrameTimingStats, workload: ?[:0]const u8) !void {
    const path = std.posix.getenv("WAYSTTY_BENCH_JSON") orelse return;

    // sha256 of the bench workload string (empty string if no workload)
    var digest: [32]u8 = undefined;
    std.crypto.hash.sha2.Sha256.hash(workload orelse "", &digest, .{});
    var sha_hex: [64]u8 = undefined;
    const hex_lut = "0123456789abcdef";
    for (digest, 0..) |b, i| {
        sha_hex[i * 2] = hex_lut[b >> 4];
        sha_hex[i * 2 + 1] = hex_lut[b & 0x0f];
    }

    // git HEAD (falls back to "unknown")
    const git_head = blk: {
        const r = std.process.Child.run(.{
            .allocator = alloc,
            .argv = &.{ "git", "rev-parse", "HEAD" },
        }) catch {
            break :blk try alloc.dupe(u8, "unknown");
        };
        defer alloc.free(r.stdout);
        defer alloc.free(r.stderr);
        if (r.term != .Exited or r.term.Exited != 0) {
            break :blk try alloc.dupe(u8, "unknown");
        }
        const trimmed = std.mem.trim(u8, r.stdout, "\n \t");
        break :blk try alloc.dupe(u8, trimmed);
    };
    defer alloc.free(git_head);

    const rec = bench_stats.BaselineRecord{
        .workload_sha = &sha_hex,
        .zig_version = @import("builtin").zig_version_string,
        .waystty_sha = git_head,
        .frame_count = stats.frame_count,
        .sections = .{
            .snapshot = stats.snapshot,
            .row_rebuild = stats.row_rebuild,
            .atlas_upload = stats.atlas_upload,
            .instance_upload = stats.instance_upload,
            .gpu_submit = stats.gpu_submit,
        },
    };

    const json_bytes = try bench_stats.writeBaselineJson(alloc, rec);
    defer alloc.free(json_bytes);

    const out = try std.fs.cwd().createFile(path, .{});
    defer out.close();
    try out.writeAll(json_bytes);
}

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();
@@ -95,6 +161,11 @@ pub fn main() !void {
        return runHiddenFreezeRegression(alloc);
    }

    if (args.len >= 2 and std.mem.eql(u8, args[1], "--capture")) {
        const capture = @import("capture");
        return capture.run(alloc, args[1..]);
    }

    return runTerminal(alloc);
}

@@ -625,7 +696,11 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
    }

    // Dump timing stats on exit
    printFrameStats(computeFrameStats(&frame_ring));
    const final_stats = computeFrameStats(&frame_ring);
    printFrameStats(final_stats);
    writeBenchJson(alloc, final_stats, bench_script) catch |err| {
        std.log.warn("bench_json write failed: {s}", .{@errorName(err)});
    };

    _ = try ctx.vkd.deviceWaitIdle(ctx.device);
}
@@ -943,131 +1018,6 @@ fn clampSelectionSpan(span: SelectionSpan, cols: u16, rows: u16) ?SelectionSpan 
    } else null;
}

const FrameTiming = struct {
    snapshot_us: u32 = 0,
    row_rebuild_us: u32 = 0,
    atlas_upload_us: u32 = 0,
    instance_upload_us: u32 = 0,
    gpu_submit_us: u32 = 0,

    fn total(self: FrameTiming) u32 {
        return self.snapshot_us +
            self.row_rebuild_us +
            self.atlas_upload_us +
            self.instance_upload_us +
            self.gpu_submit_us;
    }
};

const FrameTimingRing = struct {
    const capacity = 256;

    entries: [capacity]FrameTiming = [_]FrameTiming{.{}} ** capacity,
    head: usize = 0,
    count: usize = 0,

    fn push(self: *FrameTimingRing, timing: FrameTiming) void {
        const idx = if (self.count < capacity) self.count else self.head;
        self.entries[idx] = timing;
        if (self.count < capacity) {
            self.count += 1;
        } else {
            self.head = (self.head + 1) % capacity;
        }
    }

    /// Return a slice of valid entries in insertion order.
    /// Caller must provide a scratch buffer of `capacity` entries.
    fn orderedSlice(self: *const FrameTimingRing, buf: *[capacity]FrameTiming) []const FrameTiming {
        if (self.count < capacity) {
            return self.entries[0..self.count];
        }
        // Ring has wrapped — copy from head..end then 0..head
        const tail_len = capacity - self.head;
        @memcpy(buf[0..tail_len], self.entries[self.head..capacity]);
        @memcpy(buf[tail_len..capacity], self.entries[0..self.head]);
        return buf[0..capacity];
    }
};

const SectionStats = struct {
    min: u32 = 0,
    avg: u32 = 0,
    p99: u32 = 0,
    max: u32 = 0,
};

const FrameTimingStats = struct {
    snapshot: SectionStats = .{},
    row_rebuild: SectionStats = .{},
    atlas_upload: SectionStats = .{},
    instance_upload: SectionStats = .{},
    gpu_submit: SectionStats = .{},
    total: SectionStats = .{},
    frame_count: usize = 0,
};

fn computeSectionStats(values: []u32) SectionStats {
    if (values.len == 0) return .{};
    std.mem.sort(u32, values, {}, std.sort.asc(u32));
    var sum: u64 = 0;
    for (values) |v| sum += v;
    const p99_idx = if (values.len <= 1) 0 else ((values.len - 1) * 99) / 100;
    return .{
        .min = values[0],
        .avg = @intCast(sum / values.len),
        .p99 = values[p99_idx],
        .max = values[values.len - 1],
    };
}

fn computeFrameStats(ring: *const FrameTimingRing) FrameTimingStats {
    if (ring.count == 0) return .{};

    var ordered_buf: [FrameTimingRing.capacity]FrameTiming = undefined;
    const entries = ring.orderedSlice(&ordered_buf);
    const n = entries.len;

    var snapshot_vals: [FrameTimingRing.capacity]u32 = undefined;
    var row_rebuild_vals: [FrameTimingRing.capacity]u32 = undefined;
    var atlas_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
    var instance_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
    var gpu_submit_vals: [FrameTimingRing.capacity]u32 = undefined;
    var total_vals: [FrameTimingRing.capacity]u32 = undefined;

    for (entries, 0..) |e, i| {
        snapshot_vals[i] = e.snapshot_us;
        row_rebuild_vals[i] = e.row_rebuild_us;
        atlas_upload_vals[i] = e.atlas_upload_us;
        instance_upload_vals[i] = e.instance_upload_us;
        gpu_submit_vals[i] = e.gpu_submit_us;
        total_vals[i] = e.total();
    }

    return .{
        .snapshot = computeSectionStats(snapshot_vals[0..n]),
        .row_rebuild = computeSectionStats(row_rebuild_vals[0..n]),
        .atlas_upload = computeSectionStats(atlas_upload_vals[0..n]),
        .instance_upload = computeSectionStats(instance_upload_vals[0..n]),
        .gpu_submit = computeSectionStats(gpu_submit_vals[0..n]),
        .total = computeSectionStats(total_vals[0..n]),
        .frame_count = n,
    };
}

fn printFrameStats(stats: FrameTimingStats) void {
    const row_fmt = "{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}\n";
    std.debug.print("\n=== waystty frame timing ({d} frames) ===\n", .{stats.frame_count});
    std.debug.print("{s:<20}{s:>6}{s:>6}{s:>6}{s:>6}  (us)\n", .{ "section", "min", "avg", "p99", "max" });
    std.debug.print(row_fmt, .{ "snapshot",        stats.snapshot.min,        stats.snapshot.avg,        stats.snapshot.p99,        stats.snapshot.max });
    std.debug.print(row_fmt, .{ "row_rebuild",     stats.row_rebuild.min,     stats.row_rebuild.avg,     stats.row_rebuild.p99,     stats.row_rebuild.max });
    std.debug.print(row_fmt, .{ "atlas_upload",    stats.atlas_upload.min,    stats.atlas_upload.avg,    stats.atlas_upload.p99,    stats.atlas_upload.max });
    std.debug.print(row_fmt, .{ "instance_upload", stats.instance_upload.min, stats.instance_upload.avg, stats.instance_upload.p99, stats.instance_upload.max });
    std.debug.print(row_fmt, .{ "gpu_submit",      stats.gpu_submit.min,      stats.gpu_submit.avg,      stats.gpu_submit.p99,      stats.gpu_submit.max });
    std.debug.print("----------------------------------------------------\n", .{});
    std.debug.print(row_fmt, .{ "total",           stats.total.min,           stats.total.avg,           stats.total.p99,           stats.total.max });
}

var sigusr1_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);

fn sigusr1Handler(_: c_int) callconv(.c) void {
@@ -1598,48 +1548,6 @@ fn cursorTouchesDirtyRow(dirty_rows: []const bool, cursor: CursorRefreshContext)
    return false;
}

fn appendCellInstances(
    alloc: std.mem.Allocator,
    instances: *std.ArrayListUnmanaged(renderer.Instance),
    row_idx: u32,
    col_idx: u32,
    cell_w: u32,
    cell_h: u32,
    baseline: u32,
    glyph_uv: ?font.GlyphUV,
    bg_uv: font.GlyphUV,
    colors: vt.CellColors,
    default_bg: [4]f32,
) !void {
    if (!std.meta.eql(colors.bg, default_bg)) {
        try instances.append(alloc, .{
            .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
            .glyph_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
            .glyph_bearing = .{ 0, 0 },
            .uv_rect = .{ bg_uv.u0, bg_uv.v0, bg_uv.u1, bg_uv.v1 },
            .fg = colors.bg,
            .bg = colors.bg,
        });
    }

    const uv = glyph_uv orelse return;
    try instances.append(alloc, .{
        .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
        .glyph_size = .{ @floatFromInt(uv.width), @floatFromInt(uv.height) },
        .glyph_bearing = .{
            @floatFromInt(uv.bearing_x),
            glyphTopOffset(baseline, uv.bearing_y),
        },
        .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 },
        .fg = colors.fg,
        .bg = colors.bg,
    });
}

fn glyphTopOffset(baseline: u32, bearing_y: i32) f32 {
    return @as(f32, @floatFromInt(baseline)) - @as(f32, @floatFromInt(bearing_y));
}

fn encodeKeyboardEvent(
    term: *const vt.Terminal,
    ev: wayland_client.KeyboardEvent,
@@ -3132,82 +3040,6 @@ test "buildTextCoverageCompareScene repeats the same specimen in four panels" {
    );
}

test "FrameTiming.total sums all sections" {
    const ft: FrameTiming = .{
        .snapshot_us = 10,
        .row_rebuild_us = 20,
        .atlas_upload_us = 30,
        .instance_upload_us = 40,
        .gpu_submit_us = 50,
    };
    try std.testing.expectEqual(@as(u32, 150), ft.total());
}

test "FrameTimingRing records and wraps correctly" {
    var ring = FrameTimingRing{};
    try std.testing.expectEqual(@as(usize, 0), ring.count);

    ring.push(.{ .snapshot_us = 1, .row_rebuild_us = 2, .atlas_upload_us = 3, .instance_upload_us = 4, .gpu_submit_us = 5 });
    try std.testing.expectEqual(@as(usize, 1), ring.count);
    try std.testing.expectEqual(@as(u32, 1), ring.entries[0].snapshot_us);

    // Fill to capacity
    for (1..FrameTimingRing.capacity) |i| {
        ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
    }
    try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);

    // One more wraps around — overwrites entries[0], head advances to 1
    ring.push(.{ .snapshot_us = 999, .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
    try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
    // Newest entry is at (head + capacity - 1) % capacity = 0
    try std.testing.expectEqual(@as(u32, 999), ring.entries[0].snapshot_us);
    // head has advanced past the overwritten slot
    try std.testing.expectEqual(@as(usize, 1), ring.head);
}

test "FrameTimingRing.orderedSlice returns entries in insertion order after wrap" {
    var ring = FrameTimingRing{};
    // Push capacity + 3 entries so the ring wraps
    for (0..FrameTimingRing.capacity + 3) |i| {
        ring.push(.{ .snapshot_us = @intCast(i), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
    }
    var buf: [FrameTimingRing.capacity]FrameTiming = undefined;
    const ordered = ring.orderedSlice(&buf);
    try std.testing.expectEqual(FrameTimingRing.capacity, ordered.len);
    // First entry should be the 4th pushed (index 3), last should be capacity+2
    try std.testing.expectEqual(@as(u32, 3), ordered[0].snapshot_us);
    try std.testing.expectEqual(@as(u32, FrameTimingRing.capacity + 2), ordered[ordered.len - 1].snapshot_us);
}

test "FrameTimingStats computes min/avg/p99/max correctly" {
    var ring = FrameTimingRing{};
    // Push 100 frames with snapshot_us = 1..100
    for (0..100) |i| {
        ring.push(.{
            .snapshot_us = @intCast(i + 1),
            .row_rebuild_us = 0,
            .atlas_upload_us = 0,
            .instance_upload_us = 0,
            .gpu_submit_us = 0,
        });
    }
    const stats = computeFrameStats(&ring);
    try std.testing.expectEqual(@as(u32, 1), stats.snapshot.min);
    try std.testing.expectEqual(@as(u32, 100), stats.snapshot.max);
    try std.testing.expectEqual(@as(u32, 50), stats.snapshot.avg);
    // p99 of 1..100 = value at index 98 (0-based) = 99
    try std.testing.expectEqual(@as(u32, 99), stats.snapshot.p99);
    try std.testing.expectEqual(@as(usize, 100), stats.frame_count);
}

test "FrameTimingStats handles empty ring" {
    var ring = FrameTimingRing{};
    const stats = computeFrameStats(&ring);
    try std.testing.expectEqual(@as(usize, 0), stats.frame_count);
    try std.testing.expectEqual(@as(u32, 0), stats.snapshot.min);
}

fn runRenderSmokeTest(alloc: std.mem.Allocator) !void {
    const conn = try wayland_client.Connection.init(alloc);
    defer conn.deinit();
diff --git a/src/png.zig b/src/png.zig
new file mode 100644
index 0000000..a9774f2
--- /dev/null
+++ b/src/png.zig
@@ -0,0 +1,261 @@
const std = @import("std");

pub const Image = struct {
    width: u32,
    height: u32,
    pixels: []u8, // RGBA8, row-major, width*height*4 bytes

    pub fn deinit(self: *Image, alloc: std.mem.Allocator) void {
        alloc.free(self.pixels);
        self.* = undefined;
    }
};

pub const EncodeError = error{ OutOfMemory, WriteFailed };
pub const DecodeError = error{
    OutOfMemory,
    InvalidPng,
    UnsupportedPng, // only RGBA8 non-interlaced is supported
    CorruptChunk,
};

const signature = [_]u8{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };

fn adler32(data: []const u8) u32 {
    var a: u32 = 1;
    var b: u32 = 0;
    for (data) |byte| {
        a = (a + byte) % 65521;
        b = (b + a) % 65521;
    }
    return (b << 16) | a;
}

fn writeChunk(writer: anytype, chunk_type: *const [4]u8, payload: []const u8) EncodeError!void {
    writer.writeInt(u32, @intCast(payload.len), .big) catch return error.WriteFailed;
    writer.writeAll(chunk_type) catch return error.WriteFailed;
    writer.writeAll(payload) catch return error.WriteFailed;
    var crc = std.hash.Crc32.init();
    crc.update(chunk_type);
    crc.update(payload);
    writer.writeInt(u32, crc.final(), .big) catch return error.WriteFailed;
}

/// Build a zlib stream wrapping the `filtered` data using DEFLATE stored
/// blocks (type 0, no compression).  This is always valid PNG and avoids
/// dependency on the std.compress.flate encoder, which is incomplete in
/// Zig 0.15.
fn buildZlibStored(alloc: std.mem.Allocator, filtered: []const u8) EncodeError![]u8 {
    // zlib header: CMF=0x78 (deflate, window=32K), FLG=0x01 (no dict, level=0,
    // fcheck makes CMF*256+FLG divisible by 31: 0x7801 % 31 == 0).
    const zlib_header = [_]u8{ 0x78, 0x01 };

    // DEFLATE stored block layout:
    //   1 byte: BFINAL | (BTYPE << 1)  — BTYPE=00 for stored
    //   2 bytes: LEN  (little-endian u16)
    //   2 bytes: NLEN (one's complement of LEN, little-endian)
    //   LEN bytes: data
    //
    // Maximum single stored block payload is 65535 bytes.
    const max_block: usize = 65535;
    const actual_blocks: usize = if (filtered.len == 0) 1 else (filtered.len + max_block - 1) / max_block;
    // Header per block: 5 bytes. Total deflate stream bytes:
    const deflate_len = actual_blocks * 5 + filtered.len;

    // Full buffer: zlib_header(2) + deflate + adler32(4)
    const total = 2 + deflate_len + 4;
    const buf = alloc.alloc(u8, total) catch return error.OutOfMemory;
    errdefer alloc.free(buf);

    var pos: usize = 0;
    buf[pos] = zlib_header[0];
    pos += 1;
    buf[pos] = zlib_header[1];
    pos += 1;

    var src_pos: usize = 0;
    var block_idx: usize = 0;
    while (block_idx < actual_blocks) : (block_idx += 1) {
        const remaining = filtered.len - src_pos;
        const block_len: u16 = @intCast(@min(remaining, max_block));
        const is_final = block_idx == actual_blocks - 1;
        const bfinal: u8 = if (is_final) 0x01 else 0x00;
        buf[pos] = bfinal; // BFINAL=is_final, BTYPE=00
        pos += 1;
        std.mem.writeInt(u16, buf[pos..][0..2], block_len, .little);
        pos += 2;
        const nlen: u16 = ~block_len;
        std.mem.writeInt(u16, buf[pos..][0..2], nlen, .little);
        pos += 2;
        @memcpy(buf[pos..][0..block_len], filtered[src_pos..][0..block_len]);
        pos += block_len;
        src_pos += block_len;
    }

    // Adler-32 of the uncompressed (filtered) data, big-endian
    std.mem.writeInt(u32, buf[pos..][0..4], adler32(filtered), .big);
    pos += 4;
    std.debug.assert(pos == total);

    return buf;
}

pub fn encode(alloc: std.mem.Allocator, img: Image, writer: anytype) EncodeError!void {
    std.debug.assert(img.pixels.len == @as(usize, img.width) * img.height * 4);

    writer.writeAll(&signature) catch return error.WriteFailed;

    var ihdr: [13]u8 = undefined;
    std.mem.writeInt(u32, ihdr[0..4], img.width, .big);
    std.mem.writeInt(u32, ihdr[4..8], img.height, .big);
    ihdr[8] = 8; // bit depth
    ihdr[9] = 6; // colour type = RGBA
    ihdr[10] = 0; // compression method
    ihdr[11] = 0; // filter method
    ihdr[12] = 0; // interlace method = none
    try writeChunk(writer, "IHDR", &ihdr);

    const row_bytes = @as(usize, img.width) * 4;
    const filtered_len = (row_bytes + 1) * img.height;
    const filtered = alloc.alloc(u8, filtered_len) catch return error.OutOfMemory;
    defer alloc.free(filtered);

    // Filter type 0 (None) per row
    var y: u32 = 0;
    while (y < img.height) : (y += 1) {
        const src_off = @as(usize, y) * row_bytes;
        const dst_off = @as(usize, y) * (row_bytes + 1);
        filtered[dst_off] = 0; // filter byte
        @memcpy(filtered[dst_off + 1 ..][0..row_bytes], img.pixels[src_off..][0..row_bytes]);
    }

    const compressed = try buildZlibStored(alloc, filtered);
    defer alloc.free(compressed);

    try writeChunk(writer, "IDAT", compressed);
    try writeChunk(writer, "IEND", &.{});
}

pub fn decode(alloc: std.mem.Allocator, bytes: []const u8) DecodeError!Image {
    if (bytes.len < signature.len + 8) return error.InvalidPng;
    if (!std.mem.eql(u8, bytes[0..signature.len], &signature)) return error.InvalidPng;

    var cursor: usize = signature.len;
    var width: u32 = 0;
    var height: u32 = 0;
    var idat_accum: std.ArrayList(u8) = .empty;
    defer idat_accum.deinit(alloc);
    var seen_ihdr = false;
    var seen_iend = false;

    while (cursor + 8 <= bytes.len and !seen_iend) {
        const len = std.mem.readInt(u32, bytes[cursor..][0..4], .big);
        cursor += 4;
        const ctype = bytes[cursor..][0..4];
        cursor += 4;
        if (cursor + len + 4 > bytes.len) return error.CorruptChunk;
        const payload = bytes[cursor..][0..len];
        cursor += len;
        cursor += 4; // skip CRC

        if (std.mem.eql(u8, ctype, "IHDR")) {
            if (payload.len != 13) return error.InvalidPng;
            width = std.mem.readInt(u32, payload[0..4], .big);
            height = std.mem.readInt(u32, payload[4..8], .big);
            // bit depth=8, colour type=6 (RGBA), interlace=0
            if (payload[8] != 8 or payload[9] != 6 or payload[12] != 0)
                return error.UnsupportedPng;
            seen_ihdr = true;
        } else if (std.mem.eql(u8, ctype, "IDAT")) {
            if (!seen_ihdr) return error.InvalidPng;
            idat_accum.appendSlice(alloc, payload) catch return error.OutOfMemory;
        } else if (std.mem.eql(u8, ctype, "IEND")) {
            seen_iend = true;
        }
    }

    if (!seen_ihdr or !seen_iend) return error.InvalidPng;
    // zlib stream: 2-byte header + deflate body + 4-byte adler32
    if (idat_accum.items.len < 6) return error.InvalidPng;
    // Strip the 2-byte zlib header and 4-byte adler32 footer to get raw deflate
    const deflate_data = idat_accum.items[2 .. idat_accum.items.len - 4];

    const row_bytes = @as(usize, width) * 4;
    const filtered_len = (row_bytes + 1) * @as(usize, height);
    const filtered = alloc.alloc(u8, filtered_len) catch return error.OutOfMemory;
    defer alloc.free(filtered);

    // Decompress using std.compress.flate.Decompress with the new Zig 0.15 API.
    // The indirect vtable (used when a window buffer is provided) fills its
    // internal buffer on each vtable call and returns 0; the caller must loop,
    // draining the buffer on alternate calls.
    {
        var in_reader: std.Io.Reader = .fixed(deflate_data);
        var decomp_buf: [std.compress.flate.max_window_len]u8 = undefined;
        var decomp: std.compress.flate.Decompress = .init(&in_reader, .raw, &decomp_buf);

        var dst_writer: std.Io.Writer = .fixed(filtered);
        var written: usize = 0;
        while (written < filtered_len) {
            const n = decomp.reader.stream(&dst_writer, .unlimited) catch |err| switch (err) {
                error.EndOfStream => break,
                else => return error.CorruptChunk,
            };
            written += n;
            if (n == 0 and decomp.reader.seek == decomp.reader.end) break;
        }
        if (written != filtered_len) return error.CorruptChunk;
    }

    const pixels = alloc.alloc(u8, @as(usize, width) * height * 4) catch return error.OutOfMemory;
    errdefer alloc.free(pixels);

    var row: u32 = 0;
    while (row < height) : (row += 1) {
        const dst_off = @as(usize, row) * row_bytes;
        const src_off = @as(usize, row) * (row_bytes + 1);
        if (filtered[src_off] != 0) return error.UnsupportedPng; // only filter type 0
        @memcpy(pixels[dst_off..][0..row_bytes], filtered[src_off + 1 ..][0..row_bytes]);
    }

    return .{ .width = width, .height = height, .pixels = pixels };
}

test "encode then decode roundtrip recovers pixels" {
    const alloc = std.testing.allocator;
    var src_pixels = [_]u8{
        0xff, 0x00, 0x00, 0xff, 0x00, 0xff, 0x00, 0xff,
        0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
    };
    const src = Image{ .width = 2, .height = 2, .pixels = &src_pixels };

    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(alloc);
    try encode(alloc, src, buf.writer(alloc));

    var decoded = try decode(alloc, buf.items[0..]);
    defer decoded.deinit(alloc);

    try std.testing.expectEqual(@as(u32, 2), decoded.width);
    try std.testing.expectEqual(@as(u32, 2), decoded.height);
    try std.testing.expectEqualSlices(u8, &src_pixels, decoded.pixels);
}

test "decode rejects RGB (non-alpha) PNGs with UnsupportedPng" {
    const alloc = std.testing.allocator;
    var bytes: std.ArrayList(u8) = .empty;
    defer bytes.deinit(alloc);
    try bytes.appendSlice(alloc, &signature);
    var ihdr: [13]u8 = undefined;
    std.mem.writeInt(u32, ihdr[0..4], 1, .big);
    std.mem.writeInt(u32, ihdr[4..8], 1, .big);
    ihdr[8] = 8;
    ihdr[9] = 2; // colour type 2 = RGB (not RGBA)
    ihdr[10] = 0;
    ihdr[11] = 0;
    ihdr[12] = 0;
    try writeChunk(bytes.writer(alloc), "IHDR", &ihdr);
    try writeChunk(bytes.writer(alloc), "IEND", &.{});

    try std.testing.expectError(error.UnsupportedPng, decode(alloc, bytes.items[0..]));
}
diff --git a/src/renderer.zig b/src/renderer.zig
index 9f5f458..57eb3ca 100644
--- a/src/renderer.zig
+++ b/src/renderer.zig
@@ -418,6 +418,125 @@ fn swapchainNeedsRebuild(result: vk.Result) bool {
    return result == .suboptimal_khr;
}

/// Offscreen color attachment + readback staging buffer.
/// The image is allocated with COLOR_ATTACHMENT_BIT | TRANSFER_SRC_BIT so the
/// renderer can draw into it and then copy it into the host-visible readback
/// buffer. Created via `createOffscreen` and freed with `destroyOffscreen`.
pub const OffscreenTarget = struct {
    width: u32,
    height: u32,
    format: vk.Format,
    image: vk.Image,
    memory: vk.DeviceMemory,
    view: vk.ImageView,
    framebuffer: vk.Framebuffer,
    readback_buffer: vk.Buffer,
    readback_memory: vk.DeviceMemory,
    readback_size: u64,
};

/// Allocate an offscreen color-attachment image plus matching readback buffer.
/// The framebuffer is compatible with `render_pass` — pass the same render pass
/// the swapchain uses so the existing pipeline is reusable.
pub fn createOffscreen(
    vki: vk.InstanceWrapper,
    vkd: vk.DeviceWrapper,
    physical: vk.PhysicalDevice,
    device: vk.Device,
    render_pass: vk.RenderPass,
    format: vk.Format,
    width: u32,
    height: u32,
) !OffscreenTarget {
    // 1. Color attachment image with TRANSFER_SRC_BIT | COLOR_ATTACHMENT_BIT
    const image = try vkd.createImage(device, &vk.ImageCreateInfo{
        .image_type = .@"2d",
        .format = format,
        .extent = .{ .width = width, .height = height, .depth = 1 },
        .mip_levels = 1,
        .array_layers = 1,
        .samples = .{ .@"1_bit" = true },
        .tiling = .optimal,
        .usage = .{ .color_attachment_bit = true, .transfer_src_bit = true },
        .sharing_mode = .exclusive,
        .initial_layout = .undefined,
    }, null);
    errdefer vkd.destroyImage(device, image, null);

    // 2. Device-local memory bound
    const img_reqs = vkd.getImageMemoryRequirements(device, image);
    const img_mem_idx = try findMemoryType(vki, physical, img_reqs.memory_type_bits, .{ .device_local_bit = true });
    const memory = try vkd.allocateMemory(device, &vk.MemoryAllocateInfo{
        .allocation_size = img_reqs.size,
        .memory_type_index = img_mem_idx,
    }, null);
    errdefer vkd.freeMemory(device, memory, null);
    try vkd.bindImageMemory(device, image, memory, 0);

    // 3. ImageView + Framebuffer (using the provided render_pass)
    const view = try vkd.createImageView(device, &vk.ImageViewCreateInfo{
        .image = image,
        .view_type = .@"2d",
        .format = format,
        .components = .{ .r = .identity, .g = .identity, .b = .identity, .a = .identity },
        .subresource_range = .{
            .aspect_mask = .{ .color_bit = true },
            .base_mip_level = 0,
            .level_count = 1,
            .base_array_layer = 0,
            .layer_count = 1,
        },
    }, null);
    errdefer vkd.destroyImageView(device, view, null);

    const framebuffer = try vkd.createFramebuffer(device, &vk.FramebufferCreateInfo{
        .render_pass = render_pass,
        .attachment_count = 1,
        .p_attachments = @ptrCast(&view),
        .width = width,
        .height = height,
        .layers = 1,
    }, null);
    errdefer vkd.destroyFramebuffer(device, framebuffer, null);

    // 4. Host-visible readback buffer (TRANSFER_DST, width*height*4 bytes)
    const readback_size: u64 = @as(u64, width) * @as(u64, height) * 4;
    const readback = try createHostVisibleBuffer(
        vki,
        physical,
        vkd,
        device,
        @intCast(readback_size),
        .{ .transfer_dst_bit = true },
    );
    errdefer {
        vkd.destroyBuffer(device, readback.buffer, null);
        vkd.freeMemory(device, readback.memory, null);
    }

    return .{
        .width = width,
        .height = height,
        .format = format,
        .image = image,
        .memory = memory,
        .view = view,
        .framebuffer = framebuffer,
        .readback_buffer = readback.buffer,
        .readback_memory = readback.memory,
        .readback_size = readback_size,
    };
}

pub fn destroyOffscreen(vkd: vk.DeviceWrapper, device: vk.Device, t: OffscreenTarget) void {
    vkd.destroyFramebuffer(device, t.framebuffer, null);
    vkd.destroyImageView(device, t.view, null);
    vkd.destroyImage(device, t.image, null);
    vkd.freeMemory(device, t.memory, null);
    vkd.destroyBuffer(device, t.readback_buffer, null);
    vkd.freeMemory(device, t.readback_memory, null);
}

pub const Context = struct {
    alloc: std.mem.Allocator,
    vkb: vk.BaseWrapper,
@@ -473,6 +592,9 @@ pub const Context = struct {
    // Dedicated transfer command buffer + fence
    atlas_transfer_cb: vk.CommandBuffer,
    atlas_transfer_fence: vk.Fence,
    // Dedicated capture command buffer + fence (used by renderToOffscreen)
    capture_cmd: vk.CommandBuffer,
    capture_fence: vk.Fence,

    pub fn init(
        alloc: std.mem.Allocator,
@@ -929,6 +1051,19 @@ pub const Context = struct {
        }, null);
        errdefer vkd.destroyFence(device, atlas_transfer_fence, null);

        // --- Dedicated capture (offscreen render + readback) command buffer + fence ---
        var capture_cmd: vk.CommandBuffer = undefined;
        try vkd.allocateCommandBuffers(device, &vk.CommandBufferAllocateInfo{
            .command_pool = command_pool,
            .level = .primary,
            .command_buffer_count = 1,
        }, @ptrCast(&capture_cmd));

        const capture_fence = try vkd.createFence(device, &vk.FenceCreateInfo{
            .flags = .{ .signaled_bit = true },
        }, null);
        errdefer vkd.destroyFence(device, capture_fence, null);

        // Bind atlas to descriptor set
        const img_info = vk.DescriptorImageInfo{
            .sampler = atlas_sampler,
@@ -991,6 +1126,8 @@ pub const Context = struct {
            .atlas_staging_memory = atlas_staging.memory,
            .atlas_transfer_cb = atlas_transfer_cb,
            .atlas_transfer_fence = atlas_transfer_fence,
            .capture_cmd = capture_cmd,
            .capture_fence = capture_fence,
        };
    }

@@ -1006,6 +1143,7 @@ pub const Context = struct {
        self.vkd.destroyBuffer(self.device, self.atlas_staging_buffer, null);
        self.vkd.freeMemory(self.device, self.atlas_staging_memory, null);
        self.vkd.destroyFence(self.device, self.atlas_transfer_fence, null);
        self.vkd.destroyFence(self.device, self.capture_fence, null);
        self.vkd.destroyBuffer(self.device, self.instance_buffer, null);
        self.vkd.freeMemory(self.device, self.instance_memory, null);
        self.vkd.destroyBuffer(self.device, self.quad_vertex_buffer, null);
@@ -1480,6 +1618,67 @@ pub const Context = struct {
        return false;
    }

    /// Shared "bind pipeline + push constants + vertex/instance buffers +
    /// dynamic viewport/scissor + drawInstanced" block used by both the
    /// swapchain draw path (`drawCells`) and the offscreen draw path
    /// (`renderToOffscreen`).
    ///
    /// The caller is responsible for recording the surrounding
    /// `cmdBeginRenderPass`/`cmdEndRenderPass` pair with the appropriate
    /// framebuffer and render area.
    fn recordDrawCommands(
        self: *Context,
        cmd: vk.CommandBuffer,
        extent: vk.Extent2D,
        instance_count: u32,
        push: PushConstants,
    ) void {
        self.vkd.cmdBindPipeline(cmd, .graphics, self.pipeline);

        // Dynamic viewport + scissor
        const viewport = vk.Viewport{
            .x = 0.0,
            .y = 0.0,
            .width = @floatFromInt(extent.width),
            .height = @floatFromInt(extent.height),
            .min_depth = 0.0,
            .max_depth = 1.0,
        };
        self.vkd.cmdSetViewport(cmd, 0, 1, @ptrCast(&viewport));

        const scissor = vk.Rect2D{
            .offset = .{ .x = 0, .y = 0 },
            .extent = extent,
        };
        self.vkd.cmdSetScissor(cmd, 0, 1, @ptrCast(&scissor));

        // Push constants
        self.vkd.cmdPushConstants(
            cmd,
            self.pipeline_layout,
            .{ .vertex_bit = true, .fragment_bit = true },
            0,
            @sizeOf(PushConstants),
            @ptrCast(&push),
        );

        // Bind descriptor set (atlas sampler)
        self.vkd.cmdBindDescriptorSets(
            cmd,
            .graphics,
            self.pipeline_layout,
            0, 1, @ptrCast(&self.descriptor_set),
            0, null,
        );

        // Bind vertex buffers: binding 0 = quad, binding 1 = instances
        const buffers = [_]vk.Buffer{ self.quad_vertex_buffer, self.instance_buffer };
        const offsets = [_]vk.DeviceSize{ 0, 0 };
        self.vkd.cmdBindVertexBuffers(cmd, 0, 2, &buffers, &offsets);

        self.vkd.cmdDraw(cmd, 6, instance_count, 0, 0);
    }

    /// Full draw pass: bind pipeline, push constants, vertex + instance buffers, draw, present.
    pub fn drawCells(
        self: *Context,
@@ -1525,26 +1724,6 @@ pub const Context = struct {
            .p_clear_values = @ptrCast(&clear_value),
        }, .@"inline");

        self.vkd.cmdBindPipeline(self.command_buffer, .graphics, self.pipeline);

        // Dynamic viewport + scissor
        const viewport = vk.Viewport{
            .x = 0.0,
            .y = 0.0,
            .width = @floatFromInt(self.swapchain_extent.width),
            .height = @floatFromInt(self.swapchain_extent.height),
            .min_depth = 0.0,
            .max_depth = 1.0,
        };
        self.vkd.cmdSetViewport(self.command_buffer, 0, 1, @ptrCast(&viewport));

        const scissor = vk.Rect2D{
            .offset = .{ .x = 0, .y = 0 },
            .extent = self.swapchain_extent,
        };
        self.vkd.cmdSetScissor(self.command_buffer, 0, 1, @ptrCast(&scissor));

        // Push constants
        const pc = PushConstants{
            .viewport_size = .{
                @floatFromInt(self.swapchain_extent.width),
@@ -1553,30 +1732,7 @@ pub const Context = struct {
            .cell_size = cell_size,
            .coverage_params = coverage_params,
        };
        self.vkd.cmdPushConstants(
            self.command_buffer,
            self.pipeline_layout,
            .{ .vertex_bit = true, .fragment_bit = true },
            0,
            @sizeOf(PushConstants),
            @ptrCast(&pc),
        );

        // Bind descriptor set (atlas sampler)
        self.vkd.cmdBindDescriptorSets(
            self.command_buffer,
            .graphics,
            self.pipeline_layout,
            0, 1, @ptrCast(&self.descriptor_set),
            0, null,
        );

        // Bind vertex buffers: binding 0 = quad, binding 1 = instances
        const buffers = [_]vk.Buffer{ self.quad_vertex_buffer, self.instance_buffer };
        const offsets = [_]vk.DeviceSize{ 0, 0 };
        self.vkd.cmdBindVertexBuffers(self.command_buffer, 0, 2, &buffers, &offsets);

        self.vkd.cmdDraw(self.command_buffer, 6, instance_count, 0, 0);
        self.recordDrawCommands(self.command_buffer, self.swapchain_extent, instance_count, pc);

        self.vkd.cmdEndRenderPass(self.command_buffer);
        try self.vkd.endCommandBuffer(self.command_buffer);
@@ -1606,6 +1762,184 @@ pub const Context = struct {
        };
        if (swapchainNeedsRebuild(present_result)) return error.OutOfDateKHR;
    }

    /// Render `instance_data` into the offscreen target and copy the rendered
    /// image into the target's host-visible readback buffer.
    ///
    /// After this call returns, `target.readback_buffer` contains the raw
    /// bytes in the target's native format (typically BGRA8). Use
    /// `readbackOffscreen` to pull them out as RGBA8.
    pub fn renderToOffscreen(
        self: *Context,
        target: *const OffscreenTarget,
        instance_data: []const Instance,
        push: PushConstants,
    ) !void {
        // Wait for any in-flight swapchain frame to complete before touching the
        // shared instance buffer. (drawCells and renderToOffscreen share
        // self.instance_memory; without this wait the host would overwrite bytes
        // the GPU is still reading.)
        _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.in_flight_fence), .true, std.math.maxInt(u64));

        // 1. Upload instances (same path drawCells uses)
        try self.uploadInstances(instance_data);

        // 2. Reset + begin capture command buffer
        _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.capture_fence), .true, std.math.maxInt(u64));
        try self.vkd.resetFences(self.device, 1, @ptrCast(&self.capture_fence));

        try self.vkd.resetCommandBuffer(self.capture_cmd, .{});
        try self.vkd.beginCommandBuffer(self.capture_cmd, &vk.CommandBufferBeginInfo{
            .flags = .{ .one_time_submit_bit = true },
        });

        // 3. Transition target.image UNDEFINED -> COLOR_ATTACHMENT_OPTIMAL.
        //    The render pass's `initial_layout = .undefined` technically
        //    accepts the image in any layout, but an explicit barrier makes
        //    the access masks/stage masks unambiguous.
        const to_color = vk.ImageMemoryBarrier{
            .src_access_mask = .{},
            .dst_access_mask = .{ .color_attachment_write_bit = true },
            .old_layout = .undefined,
            .new_layout = .color_attachment_optimal,
            .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
            .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
            .image = target.image,
            .subresource_range = .{
                .aspect_mask = .{ .color_bit = true },
                .base_mip_level = 0,
                .level_count = 1,
                .base_array_layer = 0,
                .layer_count = 1,
            },
        };
        self.vkd.cmdPipelineBarrier(
            self.capture_cmd,
            .{ .top_of_pipe_bit = true },
            .{ .color_attachment_output_bit = true },
            .{},
            0, null,
            0, null,
            1, @ptrCast(&to_color),
        );

        // 4. Begin the render pass on target.framebuffer / extent
        const clear_value = vk.ClearValue{ .color = .{ .float_32 = .{ 0.0, 0.0, 0.0, 1.0 } } };
        const extent = vk.Extent2D{ .width = target.width, .height = target.height };
        self.vkd.cmdBeginRenderPass(self.capture_cmd, &vk.RenderPassBeginInfo{
            .render_pass = self.render_pass,
            .framebuffer = target.framebuffer,
            .render_area = .{
                .offset = .{ .x = 0, .y = 0 },
                .extent = extent,
            },
            .clear_value_count = 1,
            .p_clear_values = @ptrCast(&clear_value),
        }, .@"inline");

        // 5. Shared draw commands (same as drawCells)
        self.recordDrawCommands(self.capture_cmd, extent, @intCast(instance_data.len), push);

        // 6. End render pass (the pass's final_layout is .present_src_khr)
        self.vkd.cmdEndRenderPass(self.capture_cmd);

        // 7. Transition target.image PRESENT_SRC_KHR -> TRANSFER_SRC_OPTIMAL
        //    The render pass forced the final layout to .present_src_khr
        //    (it's the same render pass the swapchain uses). Barrier from
        //    there to TRANSFER_SRC before cmdCopyImageToBuffer.
        const to_transfer = vk.ImageMemoryBarrier{
            .src_access_mask = .{ .color_attachment_write_bit = true },
            .dst_access_mask = .{ .transfer_read_bit = true },
            .old_layout = .present_src_khr,
            .new_layout = .transfer_src_optimal,
            .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
            .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
            .image = target.image,
            .subresource_range = .{
                .aspect_mask = .{ .color_bit = true },
                .base_mip_level = 0,
                .level_count = 1,
                .base_array_layer = 0,
                .layer_count = 1,
            },
        };
        self.vkd.cmdPipelineBarrier(
            self.capture_cmd,
            .{ .color_attachment_output_bit = true },
            .{ .transfer_bit = true },
            .{},
            0, null,
            0, null,
            1, @ptrCast(&to_transfer),
        );

        // 8. Copy image -> readback buffer
        const region = vk.BufferImageCopy{
            .buffer_offset = 0,
            .buffer_row_length = 0,
            .buffer_image_height = 0,
            .image_subresource = .{
                .aspect_mask = .{ .color_bit = true },
                .mip_level = 0,
                .base_array_layer = 0,
                .layer_count = 1,
            },
            .image_offset = .{ .x = 0, .y = 0, .z = 0 },
            .image_extent = .{ .width = target.width, .height = target.height, .depth = 1 },
        };
        self.vkd.cmdCopyImageToBuffer(
            self.capture_cmd,
            target.image,
            .transfer_src_optimal,
            target.readback_buffer,
            1,
            @ptrCast(&region),
        );

        // 9. End + submit + wait
        try self.vkd.endCommandBuffer(self.capture_cmd);
        try self.vkd.queueSubmit(self.graphics_queue, 1, @ptrCast(&vk.SubmitInfo{
            .command_buffer_count = 1,
            .p_command_buffers = @ptrCast(&self.capture_cmd),
        }), self.capture_fence);
        _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.capture_fence), .true, std.math.maxInt(u64));
    }

    /// Read the offscreen target's readback buffer into `out_rgba`.
    /// Converts BGRA (the swapchain/native format) to RGBA and forces
    /// alpha to 0xFF. Caller must ensure a prior `renderToOffscreen` has
    /// finished (it waits on the capture fence, so simple back-to-back
    /// call is safe).
    pub fn readbackOffscreen(
        self: *Context,
        target: *const OffscreenTarget,
        out_rgba: []u8,
    ) !void {
        std.debug.assert(target.format == .b8g8r8a8_unorm); // swizzle below assumes BGRA8
        std.debug.assert(out_rgba.len == @as(usize, target.width) * @as(usize, target.height) * 4);
        if (out_rgba.len < target.readback_size) return error.BufferTooSmall;

        const mapped = try self.vkd.mapMemory(
            self.device,
            target.readback_memory,
            0,
            @intCast(target.readback_size),
            .{},
        );
        defer self.vkd.unmapMemory(self.device, target.readback_memory);

        const src = @as([*]const u8, @ptrCast(mapped))[0..target.readback_size];
        const pixel_count: usize = @intCast(@as(u64, target.width) * @as(u64, target.height));
        var i: usize = 0;
        while (i < pixel_count) : (i += 1) {
            const o = i * 4;
            // Source is BGRA8 (swapchain-native); produce RGBA8, force alpha=0xFF.
            out_rgba[o + 0] = src[o + 2]; // R <- B
            out_rgba[o + 1] = src[o + 1]; // G <- G
            out_rgba[o + 2] = src[o + 0]; // B <- R
            out_rgba[o + 3] = 0xFF;
        }
    }
};

test "vulkan module imports" {
diff --git a/src/tools/bench_baseline.zig b/src/tools/bench_baseline.zig
new file mode 100644
index 0000000..3477c10
--- /dev/null
+++ b/src/tools/bench_baseline.zig
@@ -0,0 +1,121 @@
const std = @import("std");
const bench_stats = @import("bench_stats");

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();
    const alloc = gpa.allocator();

    const args = try std.process.argsAlloc(alloc);
    defer std.process.argsFree(alloc, args);

    const Mode = enum { save, check };
    const mode: Mode = if (args.len >= 2 and std.mem.eql(u8, args[1], "save"))
        .save
    else
        .check;

    const baseline_path = "tests/bench/baseline.json";
    const tmp_json = "/tmp/waystty-bench-current.json";

    try std.fs.cwd().makePath("tests/bench");

    // Run waystty with WAYSTTY_BENCH=1 WAYSTTY_BENCH_JSON=<tmp>
    var env = try std.process.getEnvMap(alloc);
    defer env.deinit();
    try env.put("WAYSTTY_BENCH", "1");
    try env.put("WAYSTTY_BENCH_JSON", tmp_json);

    const child = try std.process.Child.run(.{
        .allocator = alloc,
        .argv = &.{"zig-out/bin/waystty"},
        .env_map = &env,
    });
    defer alloc.free(child.stdout);
    defer alloc.free(child.stderr);

    if (child.term != .Exited or child.term.Exited != 0) {
        std.debug.print("bench: waystty exited abnormally: {any}\n  stderr: {s}\n", .{ child.term, child.stderr });
        std.process.exit(2);
    }

    const current_bytes = std.fs.cwd().readFileAlloc(alloc, tmp_json, 16 * 1024) catch |err| {
        std.debug.print("bench: no JSON output at {s}: {s}\n", .{ tmp_json, @errorName(err) });
        std.process.exit(2);
    };
    defer alloc.free(current_bytes);

    const current = try bench_stats.readBaselineJson(alloc, current_bytes);
    defer {
        alloc.free(current.workload_sha);
        alloc.free(current.zig_version);
        alloc.free(current.waystty_sha);
    }

    if (mode == .save) {
        const json_out = try bench_stats.writeBaselineJson(alloc, current);
        defer alloc.free(json_out);
        const out = try std.fs.cwd().createFile(baseline_path, .{});
        defer out.close();
        try out.writeAll(json_out);
        std.debug.print("bench: wrote {s} (frame_count={d})\n", .{ baseline_path, current.frame_count });
        return;
    }

    // check mode — compare current to baseline.
    const baseline_bytes = std.fs.cwd().readFileAlloc(alloc, baseline_path, 16 * 1024) catch |err| {
        std.debug.print("bench: no baseline at {s}: {s}\n  run: zig build bench-baseline\n", .{ baseline_path, @errorName(err) });
        std.process.exit(2);
    };
    defer alloc.free(baseline_bytes);

    const baseline = try bench_stats.readBaselineJson(alloc, baseline_bytes);
    defer {
        alloc.free(baseline.workload_sha);
        alloc.free(baseline.zig_version);
        alloc.free(baseline.waystty_sha);
    }

    if (!std.mem.eql(u8, baseline.workload_sha, current.workload_sha)) {
        std.debug.print("WARN: bench script changed since baseline; consider regenerating via `zig build bench-baseline`\n", .{});
    }

    const pct_threshold: f64 = blk: {
        const v = std.posix.getenv("WAYSTTY_BENCH_REGRESSION_PCT") orelse break :blk 20.0;
        break :blk std.fmt.parseFloat(f64, v) catch 20.0;
    };

    var regressed = false;

    const SectionName = struct {
        name: []const u8,
        base_p99: u32,
        cur_p99: u32,
    };

    const sections = [_]SectionName{
        .{ .name = "snapshot", .base_p99 = baseline.sections.snapshot.p99, .cur_p99 = current.sections.snapshot.p99 },
        .{ .name = "row_rebuild", .base_p99 = baseline.sections.row_rebuild.p99, .cur_p99 = current.sections.row_rebuild.p99 },
        .{ .name = "atlas_upload", .base_p99 = baseline.sections.atlas_upload.p99, .cur_p99 = current.sections.atlas_upload.p99 },
        .{ .name = "instance_upload", .base_p99 = baseline.sections.instance_upload.p99, .cur_p99 = current.sections.instance_upload.p99 },
        .{ .name = "gpu_submit", .base_p99 = baseline.sections.gpu_submit.p99, .cur_p99 = current.sections.gpu_submit.p99 },
    };

    std.debug.print("bench: threshold {d:.1}% p99 growth\n", .{pct_threshold});
    for (sections) |s| {
        const delta_pct: f64 = if (s.base_p99 == 0)
            0.0
        else
            ((@as(f64, @floatFromInt(s.cur_p99)) - @as(f64, @floatFromInt(s.base_p99))) / @as(f64, @floatFromInt(s.base_p99))) * 100.0;
        const status = if (delta_pct > pct_threshold) "REGRESSION" else "OK";
        if (delta_pct > pct_threshold) regressed = true;
        const sign: []const u8 = if (delta_pct >= 0) "+" else "-";
        const abs_delta: f64 = if (delta_pct >= 0) delta_pct else -delta_pct;
        std.debug.print(
            "bench: {s:<16} p99 {d:>5}us (baseline {d:>5}us)  {s}{d:>5.1}%  {s}\n",
            .{ s.name, s.cur_p99, s.base_p99, sign, abs_delta, status },
        );
    }

    if (regressed) std.process.exit(1);
}
diff --git a/src/tools/imgdiff.zig b/src/tools/imgdiff.zig
new file mode 100644
index 0000000..bf33b00
--- /dev/null
+++ b/src/tools/imgdiff.zig
@@ -0,0 +1,151 @@
const std = @import("std");
const png = @import("png");

pub const DiffResult = struct {
    rmse: f64, // [0, 1]
    max_pixel: f64, // [0, 1]
    pixel_count: usize,
};

pub fn compare(a: png.Image, b: png.Image) !DiffResult {
    if (a.width != b.width or a.height != b.height) return error.DimensionsDiffer;
    std.debug.assert(a.pixels.len == b.pixels.len);

    const px_count = @as(usize, a.width) * a.height;
    var sum_sq: f64 = 0;
    var max_d: f64 = 0;

    var i: usize = 0;
    while (i < px_count) : (i += 1) {
        const off = i * 4;
        const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0;
        const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0;
        const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0;
        const d_sq = (dr * dr + dg * dg + db * db) / 3.0;
        sum_sq += d_sq;
        const d = @sqrt(d_sq);
        if (d > max_d) max_d = d;
    }

    return .{
        .rmse = @sqrt(sum_sq / @as(f64, @floatFromInt(px_count))),
        .max_pixel = max_d,
        .pixel_count = px_count,
    };
}

test "identical images produce zero RMSE" {
    var pixels_a = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 };
    var pixels_b = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 };
    const a = png.Image{ .width = 2, .height = 1, .pixels = &pixels_a };
    const b = png.Image{ .width = 2, .height = 1, .pixels = &pixels_b };
    const r = try compare(a, b);
    try std.testing.expectEqual(@as(f64, 0.0), r.rmse);
    try std.testing.expectEqual(@as(f64, 0.0), r.max_pixel);
}

test "fully saturated difference produces rmse=1.0 and max=1.0" {
    var pixels_a = [_]u8{ 0, 0, 0, 255 };
    var pixels_b = [_]u8{ 255, 255, 255, 255 };
    const a = png.Image{ .width = 1, .height = 1, .pixels = &pixels_a };
    const b = png.Image{ .width = 1, .height = 1, .pixels = &pixels_b };
    const r = try compare(a, b);
    try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.rmse, 1e-9);
    try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.max_pixel, 1e-9);
}

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();
    const alloc = gpa.allocator();

    const args = try std.process.argsAlloc(alloc);
    defer std.process.argsFree(alloc, args);

    if (args.len < 3) {
        std.debug.print("usage: imgdiff <actual.png> <reference.png> [diff.png]\n", .{});
        std.process.exit(2);
    }
    const actual_path = args[1];
    const reference_path = args[2];
    const diff_path: ?[]const u8 = if (args.len >= 4) args[3] else null;

    const rmse_max = readFloatEnv("WAYSTTY_TEST_RMSE_MAX", 0.005);
    const pixel_max = readFloatEnv("WAYSTTY_TEST_PIXEL_MAX", 0.125);

    const actual_bytes = try std.fs.cwd().readFileAlloc(alloc, actual_path, 64 * 1024 * 1024);
    defer alloc.free(actual_bytes);
    const reference_bytes = try std.fs.cwd().readFileAlloc(alloc, reference_path, 64 * 1024 * 1024);
    defer alloc.free(reference_bytes);

    var actual = try png.decode(alloc, actual_bytes);
    defer actual.deinit(alloc);
    var reference = try png.decode(alloc, reference_bytes);
    defer reference.deinit(alloc);

    if (actual.width != reference.width or actual.height != reference.height) {
        std.debug.print("FAIL: dimensions differ ({}x{} vs {}x{})\n", .{ actual.width, actual.height, reference.width, reference.height });
        std.process.exit(3);
    }

    const r = try compare(actual, reference);
    const pass = r.rmse <= rmse_max and r.max_pixel <= pixel_max;

    if (pass) {
        std.debug.print("OK:   {s}  RMSE={d:.4}%  worst={d:.4}%\n", .{ reference_path, r.rmse * 100.0, r.max_pixel * 100.0 });
        std.process.exit(0);
    }

    std.debug.print("FAIL: {s}\n  RMSE: {d:.4}% (max {d:.4}%)\n  worst pixel: {d:.4}% (max {d:.4}%)\n", .{ reference_path, r.rmse * 100.0, rmse_max * 100.0, r.max_pixel * 100.0, pixel_max * 100.0 });

    if (diff_path) |p| {
        const diff_img = try makeDiffImage(alloc, actual, reference);
        defer alloc.free(diff_img.pixels);

        var buf: std.ArrayList(u8) = .empty;
        defer buf.deinit(alloc);
        try png.encode(alloc, diff_img, buf.writer(alloc));

        const out = try std.fs.cwd().createFile(p, .{ .truncate = true });
        defer out.close();
        try out.writeAll(buf.items);

        std.debug.print("  diff: {s}\n", .{p});
    }
    std.debug.print("  actual: {s}\n", .{actual_path});
    std.process.exit(1);
}

fn readFloatEnv(name: []const u8, default: f64) f64 {
    const val = std.posix.getenv(name) orelse return default;
    return std.fmt.parseFloat(f64, val) catch default;
}

fn makeDiffImage(alloc: std.mem.Allocator, a: png.Image, b: png.Image) !png.Image {
    // Side-by-side: [actual | reference | delta-heatmap]
    const w = a.width * 3;
    const h = a.height;
    const pixels = try alloc.alloc(u8, w * h * 4);
    var y: u32 = 0;
    while (y < h) : (y += 1) {
        const row_off = @as(usize, y) * w * 4;
        const a_off = @as(usize, y) * a.width * 4;
        @memcpy(pixels[row_off .. row_off + a.width * 4], a.pixels[a_off .. a_off + a.width * 4]);
        @memcpy(pixels[row_off + a.width * 4 .. row_off + 2 * a.width * 4], b.pixels[a_off .. a_off + a.width * 4]);
        var x: u32 = 0;
        while (x < a.width) : (x += 1) {
            const off = a_off + x * 4;
            const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0;
            const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0;
            const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0;
            const d = @sqrt((dr * dr + dg * dg + db * db) / 3.0);
            const brightness: u8 = @intFromFloat(@min(255.0, d * 255.0 * 2.0));
            const dst = row_off + 2 * a.width * 4 + x * 4;
            pixels[dst + 0] = brightness;
            pixels[dst + 1] = brightness;
            pixels[dst + 2] = brightness;
            pixels[dst + 3] = 255;
        }
    }
    return .{ .width = w, .height = h, .pixels = pixels };
}
diff --git a/src/tools/test_render.zig b/src/tools/test_render.zig
new file mode 100644
index 0000000..144e7aa
--- /dev/null
+++ b/src/tools/test_render.zig
@@ -0,0 +1,75 @@
const std = @import("std");

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();
    const alloc = gpa.allocator();

    const mode_update = blk: {
        const m = std.posix.getenv("WAYSTTY_GOLDEN_UPDATE") orelse break :blk false;
        break :blk std.mem.eql(u8, m, "1");
    };

    try std.fs.cwd().makePath("tests/golden/output");

    var scripts_dir = try std.fs.cwd().openDir("tests/golden/scripts", .{ .iterate = true });
    defer scripts_dir.close();
    var it = scripts_dir.iterate();

    var passed: usize = 0;
    var failed: usize = 0;

    while (try it.next()) |entry| {
        if (entry.kind != .file) continue;
        if (!std.mem.endsWith(u8, entry.name, ".vt")) continue;

        const base = entry.name[0 .. entry.name.len - 3];
        const script_path = try std.fmt.allocPrint(alloc, "tests/golden/scripts/{s}.vt", .{base});
        defer alloc.free(script_path);
        const output_path = try std.fmt.allocPrint(alloc, "tests/golden/output/{s}.png", .{base});
        defer alloc.free(output_path);
        const reference_path = try std.fmt.allocPrint(alloc, "tests/golden/reference/{s}.png", .{base});
        defer alloc.free(reference_path);
        const diff_path = try std.fmt.allocPrint(alloc, "tests/golden/output/{s}.diff.png", .{base});
        defer alloc.free(diff_path);

        // Run waystty --capture
        const cap = try std.process.Child.run(.{
            .allocator = alloc,
            .argv = &.{ "zig-out/bin/waystty", "--capture", script_path, output_path },
        });
        defer alloc.free(cap.stdout);
        defer alloc.free(cap.stderr);
        if (cap.term != .Exited or cap.term.Exited != 0) {
            std.debug.print("FAIL: {s}: capture exited with {}\n  stderr: {s}\n",
                .{ base, cap.term, cap.stderr });
            failed += 1;
            continue;
        }

        if (mode_update) {
            try std.fs.cwd().makePath("tests/golden/reference");
            try std.fs.cwd().copyFile(output_path, std.fs.cwd(), reference_path, .{});
            std.debug.print("UPDATED: {s}\n", .{base});
            passed += 1;
            continue;
        }

        // Run imgdiff
        const dif = try std.process.Child.run(.{
            .allocator = alloc,
            .argv = &.{ "zig-out/bin/imgdiff", output_path, reference_path, diff_path },
        });
        defer alloc.free(dif.stdout);
        defer alloc.free(dif.stderr);
        std.debug.print("{s}", .{dif.stdout});
        if (dif.term == .Exited and dif.term.Exited == 0) {
            passed += 1;
        } else {
            failed += 1;
        }
    }

    std.debug.print("\n=== test-render: {} passed, {} failed ===\n", .{ passed, failed });
    if (failed > 0) std.process.exit(1);
}
diff --git a/tests/bench/baseline.json b/tests/bench/baseline.json
new file mode 100644
index 0000000..e8b01a6
--- /dev/null
+++ b/tests/bench/baseline.json
@@ -0,0 +1,38 @@
{
  "workload_sha": "066a95eee2d2f6195c0eb997e7e18a63f75b48010b538dd287056f948cc65005",
  "zig_version": "0.15.2",
  "waystty_sha": "828c61f589b2bac3785e37531a094d6abb1f40ad",
  "frame_count": 5,
  "sections": {
    "snapshot": {
      "min": 10,
      "avg": 201,
      "p99": 214,
      "max": 725
    },
    "row_rebuild": {
      "min": 115,
      "avg": 2075,
      "p99": 2166,
      "max": 5447
    },
    "atlas_upload": {
      "min": 0,
      "avg": 10,
      "p99": 0,
      "max": 50
    },
    "instance_upload": {
      "min": 4,
      "avg": 37,
      "p99": 21,
      "max": 144
    },
    "gpu_submit": {
      "min": 37,
      "avg": 58,
      "p99": 66,
      "max": 79
    }
  }
}
\ No newline at end of file
diff --git a/tests/golden/reference/basic_ascii.png b/tests/golden/reference/basic_ascii.png
new file mode 100644
index 0000000..1bdbb50
Binary files /dev/null and b/tests/golden/reference/basic_ascii.png differ
diff --git a/tests/golden/reference/bold_colors.png b/tests/golden/reference/bold_colors.png
new file mode 100644
index 0000000..a0874b9
Binary files /dev/null and b/tests/golden/reference/bold_colors.png differ
diff --git a/tests/golden/reference/box_drawing.png b/tests/golden/reference/box_drawing.png
new file mode 100644
index 0000000..4bc6da6
Binary files /dev/null and b/tests/golden/reference/box_drawing.png differ
diff --git a/tests/golden/scripts/basic_ascii.vt b/tests/golden/scripts/basic_ascii.vt
new file mode 100644
index 0000000..61020c9
--- /dev/null
+++ b/tests/golden/scripts/basic_ascii.vt
@@ -0,0 +1,2 @@
 !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~

\ No newline at end of file
diff --git a/tests/golden/scripts/bold_colors.vt b/tests/golden/scripts/bold_colors.vt
new file mode 100644
index 0000000..da45151
--- /dev/null
+++ b/tests/golden/scripts/bold_colors.vt
@@ -0,0 +1,9 @@
normal
bold
dim
italic
underline
reverse
fg30 fg31 fg32 fg33 fg34 fg35 fg36 fg37 
bg40 bg41 bg42 bg43 bg44 bg45 bg46 bg47 

\ No newline at end of file
diff --git a/tests/golden/scripts/box_drawing.vt b/tests/golden/scripts/box_drawing.vt
new file mode 100644
index 0000000..7d11bfd
--- /dev/null
+++ b/tests/golden/scripts/box_drawing.vt
@@ -0,0 +1,7 @@
┌──────────┐
│          │
│          │
│          │
└──────────┘
░▒▓█ block chars

\ No newline at end of file