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(®ion), ); // 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 @@ [2J[H !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ [H \ 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 @@ [2J[H[0mnormal[0m [1mbold[0m [2mdim[0m [3mitalic[0m [4munderline[0m [7mreverse[0m [30mfg30 [31mfg31 [32mfg32 [33mfg33 [34mfg34 [35mfg35 [36mfg36 [37mfg37 [0m [40mbg40 [41mbg41 [42mbg42 [43mbg43 [44mbg44 [45mbg45 [46mbg46 [47mbg47 [0m [H \ 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 @@ [2J[H┌──────────┐ │ │ │ │ │ │ └──────────┘ ░▒▓█ block chars [H \ No newline at end of file