a73x

321b2280

Merge branch 'gpu-render-testing'

a73x   2026-04-17 16:45

Commit message
Merge branch 'gpu-render-testing'

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

.gitignore
Old New
@@ -6,3 +6,11 @@ zig-out/
6 bench.log 6 bench.log
7 perf.data 7 perf.data
8 flamegraph.svg 8 flamegraph.svg
9 tests/golden/output/
10
11 # Scratch test binaries (ad-hoc compilations)
12 /test_io
13 /test_io2
14 /test_io3
15 /test_sig
16 /test_timer
Makefile
Old New
@@ -2,7 +2,7 @@ ZIG ?= zig
2 FLAMEGRAPH ?= flamegraph.pl 2 FLAMEGRAPH ?= flamegraph.pl
3 STACKCOLLAPSE ?= stackcollapse-perf.pl 3 STACKCOLLAPSE ?= stackcollapse-perf.pl
4 4
5 .PHONY: build run test bench profile clean 5 .PHONY: build run test bench profile clean test-render golden-update bench-baseline bench-check
6 6
7 build: 7 build:
8 $(ZIG) build 8 $(ZIG) build
@@ -13,7 +13,7 @@ run: build
13 test: 13 test:
14 $(ZIG) build test 14 $(ZIG) build test
15 15
16 zig-out/bin/waystty: $(wildcard src/*.zig) $(wildcard shaders/*) 16 zig-out/bin/waystty: $(wildcard src/*.zig) $(wildcard src/tools/*.zig) $(wildcard shaders/*)
17 $(ZIG) build 17 $(ZIG) build
18 18
19 bench: zig-out/bin/waystty 19 bench: zig-out/bin/waystty
@@ -32,5 +32,17 @@ profile:
32 @grep -A 12 "waystty frame timing" bench.log || echo "(no timing data found)" 32 @grep -A 12 "waystty frame timing" bench.log || echo "(no timing data found)"
33 xdg-open flamegraph.svg 33 xdg-open flamegraph.svg
34 34
35 test-render:
36 $(ZIG) build test-render
37
38 golden-update:
39 WAYSTTY_GOLDEN_UPDATE=1 $(ZIG) build test-render
40
41 bench-baseline:
42 $(ZIG) build bench-baseline
43
44 bench-check:
45 $(ZIG) build bench-check
46
35 clean: 47 clean:
36 rm -rf zig-out .zig-cache perf.data bench.log flamegraph.svg 48 rm -rf zig-out .zig-cache perf.data bench.log flamegraph.svg tests/golden/output
build.zig
Old New
@@ -15,6 +15,12 @@ pub fn build(b: *std.Build) void {
15 .optimize = optimize, 15 .optimize = optimize,
16 }); 16 });
17 17
18 const bench_stats_mod = b.createModule(.{
19 .root_source_file = b.path("src/bench_stats.zig"),
20 .target = target,
21 .optimize = optimize,
22 });
23
18 // Lazy-fetch the ghostty dependency. On the first invocation this 24 // Lazy-fetch the ghostty dependency. On the first invocation this
19 // materializes the package; subsequent builds use the local cache. 25 // materializes the package; subsequent builds use the local cache.
20 const ghostty_dep = b.lazyDependency("ghostty", .{}); 26 const ghostty_dep = b.lazyDependency("ghostty", .{});
@@ -79,6 +85,7 @@ pub fn build(b: *std.Build) void {
79 exe_mod.addImport("wayland-client", wayland_mod); 85 exe_mod.addImport("wayland-client", wayland_mod);
80 exe_mod.addImport("config", config_mod); 86 exe_mod.addImport("config", config_mod);
81 exe_mod.addImport("frame_loop", frame_loop_mod); 87 exe_mod.addImport("frame_loop", frame_loop_mod);
88 exe_mod.addImport("bench_stats", bench_stats_mod);
82 89
83 const exe = b.addExecutable(.{ 90 const exe = b.addExecutable(.{
84 .name = "waystty", 91 .name = "waystty",
@@ -130,6 +137,15 @@ pub fn build(b: *std.Build) void {
130 }); 137 });
131 test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step); 138 test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step);
132 139
140 // Test bench_stats.zig
141 const bench_stats_test_mod = b.createModule(.{
142 .root_source_file = b.path("src/bench_stats.zig"),
143 .target = target,
144 .optimize = optimize,
145 });
146 const bench_stats_tests = b.addTest(.{ .root_module = bench_stats_test_mod });
147 test_step.dependOn(&b.addRunArtifact(bench_stats_tests).step);
148
133 // Test frame_loop.zig 149 // Test frame_loop.zig
134 const frame_loop_test_mod = b.createModule(.{ 150 const frame_loop_test_mod = b.createModule(.{
135 .root_source_file = b.path("src/frame_loop.zig"), 151 .root_source_file = b.path("src/frame_loop.zig"),
@@ -169,6 +185,7 @@ pub fn build(b: *std.Build) void {
169 main_test_mod.addImport("vt", vt_mod); 185 main_test_mod.addImport("vt", vt_mod);
170 main_test_mod.addImport("wayland-client", wayland_mod); 186 main_test_mod.addImport("wayland-client", wayland_mod);
171 main_test_mod.addImport("config", config_mod); 187 main_test_mod.addImport("config", config_mod);
188 main_test_mod.addImport("bench_stats", bench_stats_mod);
172 const main_tests = b.addTest(.{ 189 const main_tests = b.addTest(.{
173 .root_module = main_test_mod, 190 .root_module = main_test_mod,
174 }); 191 });
@@ -268,4 +285,114 @@ pub fn build(b: *std.Build) void {
268 .root_module = renderer_test_mod, 285 .root_module = renderer_test_mod,
269 }); 286 });
270 test_step.dependOn(&b.addRunArtifact(renderer_tests).step); 287 test_step.dependOn(&b.addRunArtifact(renderer_tests).step);
288
289 // png module — vendored minimal RGBA8 PNG codec
290 const png_mod = b.createModule(.{
291 .root_source_file = b.path("src/png.zig"),
292 .target = target,
293 .optimize = optimize,
294 });
295 exe_mod.addImport("png", png_mod);
296
297 const png_test_mod = b.createModule(.{
298 .root_source_file = b.path("src/png.zig"),
299 .target = target,
300 .optimize = optimize,
301 });
302 const png_tests = b.addTest(.{ .root_module = png_test_mod });
303 test_step.dependOn(&b.addRunArtifact(png_tests).step);
304
305 // cell_instance module — shared appendCellInstances / glyphTopOffset helpers
306 const cell_instance_mod = b.createModule(.{
307 .root_source_file = b.path("src/cell_instance.zig"),
308 .target = target,
309 .optimize = optimize,
310 });
311 cell_instance_mod.addImport("renderer", renderer_mod);
312 cell_instance_mod.addImport("font", font_mod);
313 cell_instance_mod.addImport("vt", vt_mod);
314 exe_mod.addImport("cell_instance", cell_instance_mod);
315 main_test_mod.addImport("cell_instance", cell_instance_mod);
316
317 // capture module — --capture mode (render a VT script to PNG)
318 const capture_mod = b.createModule(.{
319 .root_source_file = b.path("src/capture.zig"),
320 .target = target,
321 .optimize = optimize,
322 .link_libc = true,
323 });
324 capture_mod.addImport("vt", vt_mod);
325 capture_mod.addImport("pty", pty_mod);
326 capture_mod.addImport("wayland-client", wayland_mod);
327 capture_mod.addImport("renderer", renderer_mod);
328 capture_mod.addImport("font", font_mod);
329 capture_mod.addImport("config", config_mod);
330 capture_mod.addImport("png", png_mod);
331 capture_mod.addImport("vulkan", vulkan_module);
332 capture_mod.addImport("cell_instance", cell_instance_mod);
333 exe_mod.addImport("capture", capture_mod);
334
335 // imgdiff — standalone PNG comparison tool
336 const imgdiff_mod = b.createModule(.{
337 .root_source_file = b.path("src/tools/imgdiff.zig"),
338 .target = target,
339 .optimize = optimize,
340 });
341 imgdiff_mod.addImport("png", png_mod);
342 const imgdiff_exe = b.addExecutable(.{
343 .name = "imgdiff",
344 .root_module = imgdiff_mod,
345 });
346 b.installArtifact(imgdiff_exe);
347
348 const imgdiff_test_mod = b.createModule(.{
349 .root_source_file = b.path("src/tools/imgdiff.zig"),
350 .target = target,
351 .optimize = optimize,
352 });
353 imgdiff_test_mod.addImport("png", png_mod);
354 const imgdiff_tests = b.addTest(.{ .root_module = imgdiff_test_mod });
355 test_step.dependOn(&b.addRunArtifact(imgdiff_tests).step);
356
357 const test_render_mod = b.createModule(.{
358 .root_source_file = b.path("src/tools/test_render.zig"),
359 .target = target,
360 .optimize = optimize,
361 });
362 const test_render_exe = b.addExecutable(.{
363 .name = "test-render",
364 .root_module = test_render_mod,
365 });
366 b.installArtifact(test_render_exe);
367
368 const test_render_step = b.step("test-render", "Run all golden VT scripts and diff against references");
369 test_render_step.dependOn(b.getInstallStep());
370 const test_render_run = b.addRunArtifact(test_render_exe);
371 test_render_run.step.dependOn(b.getInstallStep());
372 test_render_step.dependOn(&test_render_run.step);
373
374 // bench-baseline / bench-check — frame-timing regression guard
375 const bench_baseline_mod = b.createModule(.{
376 .root_source_file = b.path("src/tools/bench_baseline.zig"),
377 .target = target,
378 .optimize = optimize,
379 });
380 bench_baseline_mod.addImport("bench_stats", bench_stats_mod);
381 const bench_baseline_exe = b.addExecutable(.{
382 .name = "bench-baseline",
383 .root_module = bench_baseline_mod,
384 });
385 b.installArtifact(bench_baseline_exe);
386
387 const bench_baseline_step = b.step("bench-baseline", "Save current frame-timing profile to tests/bench/baseline.json");
388 const bench_baseline_run = b.addRunArtifact(bench_baseline_exe);
389 bench_baseline_run.addArg("save");
390 bench_baseline_run.step.dependOn(b.getInstallStep());
391 bench_baseline_step.dependOn(&bench_baseline_run.step);
392
393 const bench_check_step = b.step("bench-check", "Compare current frame timings against baseline");
394 const bench_check_run = b.addRunArtifact(bench_baseline_exe);
395 bench_check_run.addArg("check");
396 bench_check_run.step.dependOn(b.getInstallStep());
397 bench_check_step.dependOn(&bench_check_run.step);
271 } 398 }
src/bench_stats.zig
Old New
@@ -0,0 +1,267 @@
1 const std = @import("std");
2
3 pub const FrameTiming = struct {
4 snapshot_us: u32 = 0,
5 row_rebuild_us: u32 = 0,
6 atlas_upload_us: u32 = 0,
7 instance_upload_us: u32 = 0,
8 gpu_submit_us: u32 = 0,
9
10 pub fn total(self: FrameTiming) u32 {
11 return self.snapshot_us +
12 self.row_rebuild_us +
13 self.atlas_upload_us +
14 self.instance_upload_us +
15 self.gpu_submit_us;
16 }
17 };
18
19 pub const FrameTimingRing = struct {
20 pub const capacity = 256;
21
22 entries: [capacity]FrameTiming = [_]FrameTiming{.{}} ** capacity,
23 head: usize = 0,
24 count: usize = 0,
25
26 pub fn push(self: *FrameTimingRing, timing: FrameTiming) void {
27 const idx = if (self.count < capacity) self.count else self.head;
28 self.entries[idx] = timing;
29 if (self.count < capacity) {
30 self.count += 1;
31 } else {
32 self.head = (self.head + 1) % capacity;
33 }
34 }
35
36 /// Return a slice of valid entries in insertion order.
37 /// Caller must provide a scratch buffer of `capacity` entries.
38 pub fn orderedSlice(self: *const FrameTimingRing, buf: *[capacity]FrameTiming) []const FrameTiming {
39 if (self.count < capacity) {
40 return self.entries[0..self.count];
41 }
42 // Ring has wrapped — copy from head..end then 0..head
43 const tail_len = capacity - self.head;
44 @memcpy(buf[0..tail_len], self.entries[self.head..capacity]);
45 @memcpy(buf[tail_len..capacity], self.entries[0..self.head]);
46 return buf[0..capacity];
47 }
48 };
49
50 pub const SectionStats = struct {
51 min: u32 = 0,
52 avg: u32 = 0,
53 p99: u32 = 0,
54 max: u32 = 0,
55 };
56
57 pub const FrameTimingStats = struct {
58 snapshot: SectionStats = .{},
59 row_rebuild: SectionStats = .{},
60 atlas_upload: SectionStats = .{},
61 instance_upload: SectionStats = .{},
62 gpu_submit: SectionStats = .{},
63 total: SectionStats = .{},
64 frame_count: usize = 0,
65 };
66
67 pub fn computeSectionStats(values: []u32) SectionStats {
68 if (values.len == 0) return .{};
69 std.mem.sort(u32, values, {}, std.sort.asc(u32));
70 var sum: u64 = 0;
71 for (values) |v| sum += v;
72 const p99_idx = if (values.len <= 1) 0 else ((values.len - 1) * 99) / 100;
73 return .{
74 .min = values[0],
75 .avg = @intCast(sum / values.len),
76 .p99 = values[p99_idx],
77 .max = values[values.len - 1],
78 };
79 }
80
81 pub fn computeFrameStats(ring: *const FrameTimingRing) FrameTimingStats {
82 if (ring.count == 0) return .{};
83
84 var ordered_buf: [FrameTimingRing.capacity]FrameTiming = undefined;
85 const entries = ring.orderedSlice(&ordered_buf);
86 const n = entries.len;
87
88 var snapshot_vals: [FrameTimingRing.capacity]u32 = undefined;
89 var row_rebuild_vals: [FrameTimingRing.capacity]u32 = undefined;
90 var atlas_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
91 var instance_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
92 var gpu_submit_vals: [FrameTimingRing.capacity]u32 = undefined;
93 var total_vals: [FrameTimingRing.capacity]u32 = undefined;
94
95 for (entries, 0..) |e, i| {
96 snapshot_vals[i] = e.snapshot_us;
97 row_rebuild_vals[i] = e.row_rebuild_us;
98 atlas_upload_vals[i] = e.atlas_upload_us;
99 instance_upload_vals[i] = e.instance_upload_us;
100 gpu_submit_vals[i] = e.gpu_submit_us;
101 total_vals[i] = e.total();
102 }
103
104 return .{
105 .snapshot = computeSectionStats(snapshot_vals[0..n]),
106 .row_rebuild = computeSectionStats(row_rebuild_vals[0..n]),
107 .atlas_upload = computeSectionStats(atlas_upload_vals[0..n]),
108 .instance_upload = computeSectionStats(instance_upload_vals[0..n]),
109 .gpu_submit = computeSectionStats(gpu_submit_vals[0..n]),
110 .total = computeSectionStats(total_vals[0..n]),
111 .frame_count = n,
112 };
113 }
114
115 pub fn printFrameStats(stats: FrameTimingStats) void {
116 const row_fmt = "{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}\n";
117 std.debug.print("\n=== waystty frame timing ({d} frames) ===\n", .{stats.frame_count});
118 std.debug.print("{s:<20}{s:>6}{s:>6}{s:>6}{s:>6} (us)\n", .{ "section", "min", "avg", "p99", "max" });
119 std.debug.print(row_fmt, .{ "snapshot", stats.snapshot.min, stats.snapshot.avg, stats.snapshot.p99, stats.snapshot.max });
120 std.debug.print(row_fmt, .{ "row_rebuild", stats.row_rebuild.min, stats.row_rebuild.avg, stats.row_rebuild.p99, stats.row_rebuild.max });
121 std.debug.print(row_fmt, .{ "atlas_upload", stats.atlas_upload.min, stats.atlas_upload.avg, stats.atlas_upload.p99, stats.atlas_upload.max });
122 std.debug.print(row_fmt, .{ "instance_upload", stats.instance_upload.min, stats.instance_upload.avg, stats.instance_upload.p99, stats.instance_upload.max });
123 std.debug.print(row_fmt, .{ "gpu_submit", stats.gpu_submit.min, stats.gpu_submit.avg, stats.gpu_submit.p99, stats.gpu_submit.max });
124 std.debug.print("----------------------------------------------------\n", .{});
125 std.debug.print(row_fmt, .{ "total", stats.total.min, stats.total.avg, stats.total.p99, stats.total.max });
126 }
127
128 test "FrameTiming.total sums all sections" {
129 const ft: FrameTiming = .{
130 .snapshot_us = 10,
131 .row_rebuild_us = 20,
132 .atlas_upload_us = 30,
133 .instance_upload_us = 40,
134 .gpu_submit_us = 50,
135 };
136 try std.testing.expectEqual(@as(u32, 150), ft.total());
137 }
138
139 test "FrameTimingRing records and wraps correctly" {
140 var ring = FrameTimingRing{};
141 try std.testing.expectEqual(@as(usize, 0), ring.count);
142
143 ring.push(.{ .snapshot_us = 1, .row_rebuild_us = 2, .atlas_upload_us = 3, .instance_upload_us = 4, .gpu_submit_us = 5 });
144 try std.testing.expectEqual(@as(usize, 1), ring.count);
145 try std.testing.expectEqual(@as(u32, 1), ring.entries[0].snapshot_us);
146
147 // Fill to capacity
148 for (1..FrameTimingRing.capacity) |i| {
149 ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
150 }
151 try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
152
153 // One more wraps around — overwrites entries[0], head advances to 1
154 ring.push(.{ .snapshot_us = 999, .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
155 try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
156 // Newest entry is at (head + capacity - 1) % capacity = 0
157 try std.testing.expectEqual(@as(u32, 999), ring.entries[0].snapshot_us);
158 // head has advanced past the overwritten slot
159 try std.testing.expectEqual(@as(usize, 1), ring.head);
160 }
161
162 test "FrameTimingRing.orderedSlice returns entries in insertion order after wrap" {
163 var ring = FrameTimingRing{};
164 // Push capacity + 3 entries so the ring wraps
165 for (0..FrameTimingRing.capacity + 3) |i| {
166 ring.push(.{ .snapshot_us = @intCast(i), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
167 }
168 var buf: [FrameTimingRing.capacity]FrameTiming = undefined;
169 const ordered = ring.orderedSlice(&buf);
170 try std.testing.expectEqual(FrameTimingRing.capacity, ordered.len);
171 // First entry should be the 4th pushed (index 3), last should be capacity+2
172 try std.testing.expectEqual(@as(u32, 3), ordered[0].snapshot_us);
173 try std.testing.expectEqual(@as(u32, FrameTimingRing.capacity + 2), ordered[ordered.len - 1].snapshot_us);
174 }
175
176 test "FrameTimingStats computes min/avg/p99/max correctly" {
177 var ring = FrameTimingRing{};
178 // Push 100 frames with snapshot_us = 1..100
179 for (0..100) |i| {
180 ring.push(.{
181 .snapshot_us = @intCast(i + 1),
182 .row_rebuild_us = 0,
183 .atlas_upload_us = 0,
184 .instance_upload_us = 0,
185 .gpu_submit_us = 0,
186 });
187 }
188 const stats = computeFrameStats(&ring);
189 try std.testing.expectEqual(@as(u32, 1), stats.snapshot.min);
190 try std.testing.expectEqual(@as(u32, 100), stats.snapshot.max);
191 try std.testing.expectEqual(@as(u32, 50), stats.snapshot.avg);
192 // p99 of 1..100 = value at index 98 (0-based) = 99
193 try std.testing.expectEqual(@as(u32, 99), stats.snapshot.p99);
194 try std.testing.expectEqual(@as(usize, 100), stats.frame_count);
195 }
196
197 test "FrameTimingStats handles empty ring" {
198 var ring = FrameTimingRing{};
199 const stats = computeFrameStats(&ring);
200 try std.testing.expectEqual(@as(usize, 0), stats.frame_count);
201 try std.testing.expectEqual(@as(u32, 0), stats.snapshot.min);
202 }
203
204 pub const BaselineRecord = struct {
205 workload_sha: []const u8,
206 zig_version: []const u8,
207 waystty_sha: []const u8,
208 frame_count: usize,
209 sections: struct {
210 snapshot: SectionStats,
211 row_rebuild: SectionStats,
212 atlas_upload: SectionStats,
213 instance_upload: SectionStats,
214 gpu_submit: SectionStats,
215 },
216 };
217
218 /// Serialize `rec` to JSON and return an owned slice. Caller must free.
219 pub fn writeBaselineJson(alloc: std.mem.Allocator, rec: BaselineRecord) ![]u8 {
220 var out: std.Io.Writer.Allocating = .init(alloc);
221 errdefer out.deinit();
222 try std.json.Stringify.value(rec, .{ .whitespace = .indent_2 }, &out.writer);
223 return out.toOwnedSlice();
224 }
225
226 pub fn readBaselineJson(alloc: std.mem.Allocator, bytes: []const u8) !BaselineRecord {
227 var parsed = try std.json.parseFromSlice(BaselineRecord, alloc, bytes, .{});
228 defer parsed.deinit();
229 return .{
230 .workload_sha = try alloc.dupe(u8, parsed.value.workload_sha),
231 .zig_version = try alloc.dupe(u8, parsed.value.zig_version),
232 .waystty_sha = try alloc.dupe(u8, parsed.value.waystty_sha),
233 .frame_count = parsed.value.frame_count,
234 .sections = parsed.value.sections,
235 };
236 }
237
238 test "baseline JSON round-trip" {
239 const alloc = std.testing.allocator;
240 const rec = BaselineRecord{
241 .workload_sha = "abcdef",
242 .zig_version = "0.15.0",
243 .waystty_sha = "123abc",
244 .frame_count = 256,
245 .sections = .{
246 .snapshot = .{ .min = 1, .avg = 2, .p99 = 3, .max = 4 },
247 .row_rebuild = .{ .min = 10, .avg = 20, .p99 = 30, .max = 40 },
248 .atlas_upload = .{ .min = 0, .avg = 0, .p99 = 0, .max = 0 },
249 .instance_upload = .{ .min = 5, .avg = 6, .p99 = 7, .max = 8 },
250 .gpu_submit = .{ .min = 9, .avg = 9, .p99 = 9, .max = 9 },
251 },
252 };
253
254 const json_bytes = try writeBaselineJson(alloc, rec);
255 defer alloc.free(json_bytes);
256
257 const parsed = try readBaselineJson(alloc, json_bytes);
258 defer {
259 alloc.free(parsed.workload_sha);
260 alloc.free(parsed.zig_version);
261 alloc.free(parsed.waystty_sha);
262 }
263
264 try std.testing.expectEqual(@as(usize, 256), parsed.frame_count);
265 try std.testing.expectEqual(@as(u32, 30), parsed.sections.row_rebuild.p99);
266 try std.testing.expectEqualStrings("abcdef", parsed.workload_sha);
267 }
src/capture.zig
Old New
@@ -0,0 +1,360 @@
1 //! `--capture <script> <output.png>` mode.
2 //!
3 //! Renders a VT script to a single PNG frame for golden-image testing.
4 //!
5 //! 1. Stand up a Wayland window + Vulkan context at a forced 80x24 grid,
6 //! buffer scale = 1 (so renders are deterministic across multi-monitor
7 //! setups).
8 //! 2. Wait up to 3s for the window to become visible.
9 //! 3. Pipe the script through a PTY via `/bin/cat`, then drain remaining
10 //! output after cat exits.
11 //! 4. Snapshot the terminal, build a flat Instance list for every cell,
12 //! render a single frame to an offscreen VkImage, read the BGRA bytes
13 //! back, convert to RGBA and write a PNG.
14 //!
15 //! The window itself is never committed/presented — the offscreen target
16 //! is its own framebuffer. We still need the Wayland surface so Vulkan
17 //! can allocate a swapchain (required by the current Context.init path)
18 //! and so the compositor hands us a real configure event.
19
20 const std = @import("std");
21 const vt = @import("vt");
22 const pty = @import("pty");
23 const wayland_client = @import("wayland-client");
24 const renderer = @import("renderer");
25 const font = @import("font");
26 const config = @import("config");
27 const png = @import("png");
28 const vk = @import("vulkan");
29
30 pub const CaptureError = error{
31 MissingArgs,
32 ScriptNotFound,
33 OutputPathUnwritable,
34 WindowNotVisible,
35 WindowSizeMismatch,
36 PngEncodeFailed,
37 };
38
39 const cell_instance = @import("cell_instance");
40 const appendCellInstances = cell_instance.appendCellInstances;
41 const glyphTopOffset = cell_instance.glyphTopOffset;
42
43 const CAPTURE_COLS: u16 = 80;
44 const CAPTURE_ROWS: u16 = 24;
45 const VISIBILITY_TIMEOUT_NS: i128 = 3 * std.time.ns_per_s;
46
47 /// Entry point. `argv[0]` is `--capture`; argv[1] = script path, argv[2] = out path.
48 pub fn run(alloc: std.mem.Allocator, argv: []const [:0]const u8) !void {
49 if (argv.len < 3) {
50 std.debug.print("usage: waystty --capture <script.vt> <output.png>\n", .{});
51 return CaptureError.MissingArgs;
52 }
53 const script_path = argv[1];
54 const out_path = argv[2];
55
56 // Probe script path up-front so we fail fast with a clean error rather
57 // than having cat silently print a "No such file" diagnostic onto the
58 // captured image.
59 std.fs.cwd().access(script_path, .{}) catch |err| {
60 std.debug.print("capture: cannot read script {s}: {t}\n", .{ script_path, err });
61 return CaptureError.ScriptNotFound;
62 };
63
64 // === font + cell metrics (scale=1, same lookup as runTerminal) ===
65 var font_lookup = try font.lookupConfiguredFont(alloc);
66 defer font_lookup.deinit(alloc);
67
68 const font_size: u32 = config.font_size_px;
69 var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, font_size);
70 defer face.deinit();
71
72 const cell_w: u32 = face.cellWidth();
73 const cell_h: u32 = face.cellHeight();
74 const baseline: u32 = face.baseline();
75
76 const px_w: u32 = @as(u32, CAPTURE_COLS) * cell_w;
77 const px_h: u32 = @as(u32, CAPTURE_ROWS) * cell_h;
78
79 // === wayland ===
80 const conn = try wayland_client.Connection.init(alloc);
81 defer conn.deinit();
82
83 const window = try conn.createWindow(alloc, "waystty-capture");
84 defer window.deinit();
85
86 window.width = px_w;
87 window.height = px_h;
88 _ = conn.display.roundtrip();
89
90 // === vulkan context (swapchain matches requested px size) ===
91 var ctx = try renderer.Context.init(
92 alloc,
93 @ptrCast(conn.display),
94 @ptrCast(window.surface),
95 px_w,
96 px_h,
97 );
98 defer ctx.deinit();
99
100 // === offscreen render target (separate framebuffer; renders don't present) ===
101 var offscreen = try renderer.createOffscreen(
102 ctx.vki,
103 ctx.vkd,
104 ctx.physical_device,
105 ctx.device,
106 ctx.render_pass,
107 ctx.swapchain_format,
108 px_w,
109 px_h,
110 );
111 defer renderer.destroyOffscreen(ctx.vkd, ctx.device, offscreen);
112
113 // === glyph atlas + printable ASCII warm-up (matches runTerminal) ===
114 var atlas = try font.Atlas.init(alloc, 1024, 1024);
115 defer atlas.deinit();
116
117 for (32..127) |cp| {
118 _ = atlas.getOrInsert(&face, @intCast(cp)) catch |err| switch (err) {
119 error.AtlasFull => break,
120 else => return err,
121 };
122 }
123 try ctx.uploadAtlas(atlas.pixels);
124 atlas.last_uploaded_y = atlas.cursor_y;
125 atlas.needs_full_upload = false;
126 atlas.dirty = false;
127
128 // === terminal ===
129 var term = try vt.Terminal.init(alloc, .{
130 .cols = CAPTURE_COLS,
131 .rows = CAPTURE_ROWS,
132 .max_scrollback = 1000,
133 });
134 defer term.deinit();
135 term.setReportedSize(.{
136 .rows = CAPTURE_ROWS,
137 .columns = CAPTURE_COLS,
138 .cell_width = cell_w,
139 .cell_height = cell_h,
140 });
141
142 // === visibility wait + size check ===
143 try waitUntilVisible(conn, window);
144
145 if (window.width != px_w or window.height != px_h) {
146 std.debug.print(
147 "capture: window size mismatch (got {d}x{d}, expected {d}x{d})\n",
148 .{ window.width, window.height, px_w, px_h },
149 );
150 return CaptureError.WindowSizeMismatch;
151 }
152
153 // === play script through /bin/cat ===
154 try playScript(term, script_path);
155
156 // === snapshot + build instances ===
157 try term.snapshot();
158
159 var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
160 defer instances.deinit(alloc);
161
162 try buildInstancesForSnapshot(
163 alloc,
164 &instances,
165 term,
166 &face,
167 &atlas,
168 cell_w,
169 cell_h,
170 baseline,
171 );
172
173 // If the script needed glyphs that weren't in the ASCII warm-up set,
174 // the atlas pixels are newer than the GPU copy. Re-upload the full
175 // atlas so the render samples valid texels.
176 if (atlas.dirty) {
177 try ctx.uploadAtlas(atlas.pixels);
178 atlas.dirty = false;
179 atlas.last_uploaded_y = atlas.cursor_y;
180 }
181
182 // === render one frame to offscreen ===
183 const push = renderer.PushConstants{
184 .viewport_size = .{ @floatFromInt(px_w), @floatFromInt(px_h) },
185 .cell_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
186 .coverage_params = renderer.coverageVariantParams(.baseline),
187 };
188
189 try ctx.renderToOffscreen(&offscreen, instances.items, push);
190
191 // === readback BGRA->RGBA ===
192 const rgba = try alloc.alloc(u8, @as(usize, px_w) * px_h * 4);
193 defer alloc.free(rgba);
194 try ctx.readbackOffscreen(&offscreen, rgba);
195
196 // === encode PNG ===
197 try writePng(alloc, out_path, px_w, px_h, rgba);
198
199 std.debug.print("capture: wrote {s} ({d}x{d})\n", .{ out_path, px_w, px_h });
200 }
201
202 /// Wait up to VISIBILITY_TIMEOUT_NS for the Wayland compositor to `configure`
203 /// the surface. For `--capture` we don't need the surface to actually be
204 /// mapped onto an output (which would require committing a presentable
205 /// buffer via the swapchain — we deliberately skip that since rendering is
206 /// offscreen). A configured surface is enough to know our fixed 80x24
207 /// geometry was accepted.
208 fn waitUntilVisible(conn: *wayland_client.Connection, window: *wayland_client.Window) !void {
209 const deadline = @as(i128, std.time.nanoTimestamp()) + VISIBILITY_TIMEOUT_NS;
210 while (std.time.nanoTimestamp() < deadline) {
211 _ = conn.display.roundtrip();
212 if (window.state.configured) return;
213 std.Thread.sleep(10 * std.time.ns_per_ms);
214 }
215 std.debug.print(
216 "capture: window never configured within 3s\n",
217 .{},
218 );
219 return CaptureError.WindowNotVisible;
220 }
221
222 /// Spawn `/bin/cat <script>` on a PTY; feed all its output into `term`.
223 /// Returns once the child has exited AND two consecutive 20 ms polls
224 /// produce no new bytes (drain).
225 ///
226 /// Note: spawns cat with script as argv rather than piping stdin+^D —
227 /// avoids EOF-signalling races with VT escape sequences.
228 fn playScript(
229 term: *vt.Terminal,
230 script_path: [:0]const u8,
231 ) !void {
232 var p = try pty.Pty.spawn(.{
233 .cols = CAPTURE_COLS,
234 .rows = CAPTURE_ROWS,
235 .shell = "/bin/cat",
236 .shell_args = &.{script_path},
237 });
238 defer p.deinit();
239
240 var buf: [4096]u8 = undefined;
241 var consecutive_empty: u32 = 0;
242
243 // Loop until child exited AND we saw two empty polls in a row (to make
244 // sure any straggler bytes in the master buffer have been drained).
245 while (true) {
246 var pfd = [_]std.posix.pollfd{
247 .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 },
248 };
249 _ = std.posix.poll(&pfd, 20) catch 0;
250
251 var saw_bytes = false;
252 while (true) {
253 const n = p.read(&buf) catch |err| switch (err) {
254 error.WouldBlock => break,
255 // EIO on Linux after slave fd closes is the normal signal
256 // that cat exited. Break out — the child reaper below will
257 // notice.
258 error.InputOutput => break,
259 else => return err,
260 };
261 if (n == 0) break;
262 term.write(buf[0..n]);
263 saw_bytes = true;
264 }
265
266 const alive = p.isChildAlive();
267 if (saw_bytes) {
268 consecutive_empty = 0;
269 } else if (!alive) {
270 consecutive_empty += 1;
271 if (consecutive_empty >= 2) break;
272 }
273 }
274
275 // VT parser settle — give any delayed effects (timers, etc) a beat.
276 std.Thread.sleep(50 * std.time.ns_per_ms);
277 }
278
279 /// Build a flat Instance list covering every cell in the current snapshot.
280 /// Does not do dirty tracking — this is a one-shot full rebuild. Mirrors
281 /// the per-cell logic in `main.zig:rebuildRowInstances`, minus the
282 /// selection/cursor overlay.
283 fn buildInstancesForSnapshot(
284 alloc: std.mem.Allocator,
285 instances: *std.ArrayListUnmanaged(renderer.Instance),
286 term: *vt.Terminal,
287 face: *font.Face,
288 atlas: *font.Atlas,
289 cell_w: u32,
290 cell_h: u32,
291 baseline: u32,
292 ) !void {
293 const default_bg = term.backgroundColor();
294 const bg_uv = atlas.cursorUV();
295
296 const term_rows = term.render_state.row_data.items(.cells);
297 var row_idx: u32 = 0;
298 while (row_idx < term_rows.len) : (row_idx += 1) {
299 const row_cells = term_rows[row_idx];
300 const raw_cells = row_cells.items(.raw);
301 var col_idx: u32 = 0;
302 while (col_idx < raw_cells.len) : (col_idx += 1) {
303 const cp = raw_cells[col_idx].codepoint();
304 const colors = term.cellColors(row_cells.get(col_idx));
305 const glyph_uv = if (cp == 0 or cp == ' ')
306 null
307 else
308 atlas.getOrInsert(face, @intCast(cp)) catch null;
309
310 try appendCellInstances(
311 alloc,
312 instances,
313 row_idx,
314 col_idx,
315 cell_w,
316 cell_h,
317 baseline,
318 glyph_uv,
319 bg_uv,
320 colors,
321 default_bg,
322 );
323 }
324 }
325 }
326
327 /// Encode `rgba` as a PNG to a brand-new file at `path`. Buffers the full
328 /// encoded byte stream in memory (fine for 80x24@16px: under 200 KB) and
329 /// writes it in one shot.
330 fn writePng(
331 alloc: std.mem.Allocator,
332 path: [:0]const u8,
333 width: u32,
334 height: u32,
335 rgba: []u8,
336 ) !void {
337 var buf: std.ArrayList(u8) = .empty;
338 defer buf.deinit(alloc);
339
340 const img: png.Image = .{
341 .width = width,
342 .height = height,
343 .pixels = rgba,
344 };
345 png.encode(alloc, img, buf.writer(alloc)) catch |err| {
346 std.debug.print("capture: PNG encode failed: {t}\n", .{err});
347 return CaptureError.PngEncodeFailed;
348 };
349
350 const file = std.fs.cwd().createFile(path, .{ .truncate = true }) catch |err| {
351 std.debug.print("capture: cannot open output {s}: {t}\n", .{ path, err });
352 return CaptureError.OutputPathUnwritable;
353 };
354 defer file.close();
355
356 file.writeAll(buf.items) catch |err| {
357 std.debug.print("capture: write failed for {s}: {t}\n", .{ path, err });
358 return CaptureError.OutputPathUnwritable;
359 };
360 }
src/cell_instance.zig
Old New
@@ -0,0 +1,58 @@
1 //! Shared cell-instance helpers used by both the live renderer (main.zig)
2 //! and the capture renderer (capture.zig).
3
4 const std = @import("std");
5 const renderer = @import("renderer");
6 const font = @import("font");
7 const vt = @import("vt");
8
9 /// Appends 0-2 `renderer.Instance` entries for a single terminal cell:
10 /// - a filled-background quad when the cell's bg differs from the terminal
11 /// default bg (so transparent cells don't draw a quad at all);
12 /// - a glyph quad when `glyph_uv` is non-null (i.e. the cell has a
13 /// printable codepoint that was found in the atlas).
14 pub fn appendCellInstances(
15 alloc: std.mem.Allocator,
16 instances: *std.ArrayListUnmanaged(renderer.Instance),
17 row_idx: u32,
18 col_idx: u32,
19 cell_w: u32,
20 cell_h: u32,
21 baseline: u32,
22 glyph_uv: ?font.GlyphUV,
23 bg_uv: font.GlyphUV,
24 colors: vt.CellColors,
25 default_bg: [4]f32,
26 ) !void {
27 if (!std.meta.eql(colors.bg, default_bg)) {
28 try instances.append(alloc, .{
29 .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
30 .glyph_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
31 .glyph_bearing = .{ 0, 0 },
32 .uv_rect = .{ bg_uv.u0, bg_uv.v0, bg_uv.u1, bg_uv.v1 },
33 .fg = colors.bg,
34 .bg = colors.bg,
35 });
36 }
37
38 const uv = glyph_uv orelse return;
39 try instances.append(alloc, .{
40 .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
41 .glyph_size = .{ @floatFromInt(uv.width), @floatFromInt(uv.height) },
42 .glyph_bearing = .{
43 @floatFromInt(uv.bearing_x),
44 glyphTopOffset(baseline, uv.bearing_y),
45 },
46 .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 },
47 .fg = colors.fg,
48 .bg = colors.bg,
49 });
50 }
51
52 /// Returns the number of pixels from the top of the cell to the top of the
53 /// glyph bitmap, given the cell `baseline` (pixels from cell top to the
54 /// typographic baseline) and the glyph's `bearing_y` (pixels from baseline
55 /// to the top of the glyph bitmap, positive = up).
56 pub fn glyphTopOffset(baseline: u32, bearing_y: i32) f32 {
57 return @as(f32, @floatFromInt(baseline)) - @as(f32, @floatFromInt(bearing_y));
58 }
src/main.zig
Old New
@@ -7,6 +7,17 @@ const renderer = @import("renderer");
7 const font = @import("font"); 7 const font = @import("font");
8 const config = @import("config"); 8 const config = @import("config");
9 const vk = @import("vulkan"); 9 const vk = @import("vulkan");
10 const bench_stats = @import("bench_stats");
11 const cell_instance = @import("cell_instance");
12 const appendCellInstances = cell_instance.appendCellInstances;
13 const glyphTopOffset = cell_instance.glyphTopOffset;
14 const FrameTiming = bench_stats.FrameTiming;
15 const FrameTimingRing = bench_stats.FrameTimingRing;
16 const SectionStats = bench_stats.SectionStats;
17 const FrameTimingStats = bench_stats.FrameTimingStats;
18 const computeSectionStats = bench_stats.computeSectionStats;
19 const computeFrameStats = bench_stats.computeFrameStats;
20 const printFrameStats = bench_stats.printFrameStats;
10 21
11 const c = @cImport({ 22 const c = @cImport({
12 @cInclude("xkbcommon/xkbcommon-keysyms.h"); 23 @cInclude("xkbcommon/xkbcommon-keysyms.h");
@@ -59,6 +70,61 @@ fn updateWindowTitle(_: *vt.Terminal, ctx: ?*anyopaque, title: ?[:0]const u8) vo
59 window.setTitle(title); 70 window.setTitle(title);
60 } 71 }
61 72
73 /// If `WAYSTTY_BENCH_JSON` is set, write a BaselineRecord JSON to that path.
74 /// Machine-readable companion to `printFrameStats`.
75 fn writeBenchJson(alloc: std.mem.Allocator, stats: FrameTimingStats, workload: ?[:0]const u8) !void {
76 const path = std.posix.getenv("WAYSTTY_BENCH_JSON") orelse return;
77
78 // sha256 of the bench workload string (empty string if no workload)
79 var digest: [32]u8 = undefined;
80 std.crypto.hash.sha2.Sha256.hash(workload orelse "", &digest, .{});
81 var sha_hex: [64]u8 = undefined;
82 const hex_lut = "0123456789abcdef";
83 for (digest, 0..) |b, i| {
84 sha_hex[i * 2] = hex_lut[b >> 4];
85 sha_hex[i * 2 + 1] = hex_lut[b & 0x0f];
86 }
87
88 // git HEAD (falls back to "unknown")
89 const git_head = blk: {
90 const r = std.process.Child.run(.{
91 .allocator = alloc,
92 .argv = &.{ "git", "rev-parse", "HEAD" },
93 }) catch {
94 break :blk try alloc.dupe(u8, "unknown");
95 };
96 defer alloc.free(r.stdout);
97 defer alloc.free(r.stderr);
98 if (r.term != .Exited or r.term.Exited != 0) {
99 break :blk try alloc.dupe(u8, "unknown");
100 }
101 const trimmed = std.mem.trim(u8, r.stdout, "\n \t");
102 break :blk try alloc.dupe(u8, trimmed);
103 };
104 defer alloc.free(git_head);
105
106 const rec = bench_stats.BaselineRecord{
107 .workload_sha = &sha_hex,
108 .zig_version = @import("builtin").zig_version_string,
109 .waystty_sha = git_head,
110 .frame_count = stats.frame_count,
111 .sections = .{
112 .snapshot = stats.snapshot,
113 .row_rebuild = stats.row_rebuild,
114 .atlas_upload = stats.atlas_upload,
115 .instance_upload = stats.instance_upload,
116 .gpu_submit = stats.gpu_submit,
117 },
118 };
119
120 const json_bytes = try bench_stats.writeBaselineJson(alloc, rec);
121 defer alloc.free(json_bytes);
122
123 const out = try std.fs.cwd().createFile(path, .{});
124 defer out.close();
125 try out.writeAll(json_bytes);
126 }
127
62 pub fn main() !void { 128 pub fn main() !void {
63 var gpa: std.heap.DebugAllocator(.{}) = .init; 129 var gpa: std.heap.DebugAllocator(.{}) = .init;
64 defer _ = gpa.deinit(); 130 defer _ = gpa.deinit();
@@ -95,6 +161,11 @@ pub fn main() !void {
95 return runHiddenFreezeRegression(alloc); 161 return runHiddenFreezeRegression(alloc);
96 } 162 }
97 163
164 if (args.len >= 2 and std.mem.eql(u8, args[1], "--capture")) {
165 const capture = @import("capture");
166 return capture.run(alloc, args[1..]);
167 }
168
98 return runTerminal(alloc); 169 return runTerminal(alloc);
99 } 170 }
100 171
@@ -625,7 +696,11 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
625 } 696 }
626 697
627 // Dump timing stats on exit 698 // Dump timing stats on exit
628 printFrameStats(computeFrameStats(&frame_ring)); 699 const final_stats = computeFrameStats(&frame_ring);
700 printFrameStats(final_stats);
701 writeBenchJson(alloc, final_stats, bench_script) catch |err| {
702 std.log.warn("bench_json write failed: {s}", .{@errorName(err)});
703 };
629 704
630 _ = try ctx.vkd.deviceWaitIdle(ctx.device); 705 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
631 } 706 }
@@ -943,131 +1018,6 @@ fn clampSelectionSpan(span: SelectionSpan, cols: u16, rows: u16) ?SelectionSpan
943 } else null; 1018 } else null;
944 } 1019 }
945 1020
946 const FrameTiming = struct {
947 snapshot_us: u32 = 0,
948 row_rebuild_us: u32 = 0,
949 atlas_upload_us: u32 = 0,
950 instance_upload_us: u32 = 0,
951 gpu_submit_us: u32 = 0,
952
953 fn total(self: FrameTiming) u32 {
954 return self.snapshot_us +
955 self.row_rebuild_us +
956 self.atlas_upload_us +
957 self.instance_upload_us +
958 self.gpu_submit_us;
959 }
960 };
961
962 const FrameTimingRing = struct {
963 const capacity = 256;
964
965 entries: [capacity]FrameTiming = [_]FrameTiming{.{}} ** capacity,
966 head: usize = 0,
967 count: usize = 0,
968
969 fn push(self: *FrameTimingRing, timing: FrameTiming) void {
970 const idx = if (self.count < capacity) self.count else self.head;
971 self.entries[idx] = timing;
972 if (self.count < capacity) {
973 self.count += 1;
974 } else {
975 self.head = (self.head + 1) % capacity;
976 }
977 }
978
979 /// Return a slice of valid entries in insertion order.
980 /// Caller must provide a scratch buffer of `capacity` entries.
981 fn orderedSlice(self: *const FrameTimingRing, buf: *[capacity]FrameTiming) []const FrameTiming {
982 if (self.count < capacity) {
983 return self.entries[0..self.count];
984 }
985 // Ring has wrapped — copy from head..end then 0..head
986 const tail_len = capacity - self.head;
987 @memcpy(buf[0..tail_len], self.entries[self.head..capacity]);
988 @memcpy(buf[tail_len..capacity], self.entries[0..self.head]);
989 return buf[0..capacity];
990 }
991 };
992
993 const SectionStats = struct {
994 min: u32 = 0,
995 avg: u32 = 0,
996 p99: u32 = 0,
997 max: u32 = 0,
998 };
999
1000 const FrameTimingStats = struct {
1001 snapshot: SectionStats = .{},
1002 row_rebuild: SectionStats = .{},
1003 atlas_upload: SectionStats = .{},
1004 instance_upload: SectionStats = .{},
1005 gpu_submit: SectionStats = .{},
1006 total: SectionStats = .{},
1007 frame_count: usize = 0,
1008 };
1009
1010 fn computeSectionStats(values: []u32) SectionStats {
1011 if (values.len == 0) return .{};
1012 std.mem.sort(u32, values, {}, std.sort.asc(u32));
1013 var sum: u64 = 0;
1014 for (values) |v| sum += v;
1015 const p99_idx = if (values.len <= 1) 0 else ((values.len - 1) * 99) / 100;
1016 return .{
1017 .min = values[0],
1018 .avg = @intCast(sum / values.len),
1019 .p99 = values[p99_idx],
1020 .max = values[values.len - 1],
1021 };
1022 }
1023
1024 fn computeFrameStats(ring: *const FrameTimingRing) FrameTimingStats {
1025 if (ring.count == 0) return .{};
1026
1027 var ordered_buf: [FrameTimingRing.capacity]FrameTiming = undefined;
1028 const entries = ring.orderedSlice(&ordered_buf);
1029 const n = entries.len;
1030
1031 var snapshot_vals: [FrameTimingRing.capacity]u32 = undefined;
1032 var row_rebuild_vals: [FrameTimingRing.capacity]u32 = undefined;
1033 var atlas_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
1034 var instance_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
1035 var gpu_submit_vals: [FrameTimingRing.capacity]u32 = undefined;
1036 var total_vals: [FrameTimingRing.capacity]u32 = undefined;
1037
1038 for (entries, 0..) |e, i| {
1039 snapshot_vals[i] = e.snapshot_us;
1040 row_rebuild_vals[i] = e.row_rebuild_us;
1041 atlas_upload_vals[i] = e.atlas_upload_us;
1042 instance_upload_vals[i] = e.instance_upload_us;
1043 gpu_submit_vals[i] = e.gpu_submit_us;
1044 total_vals[i] = e.total();
1045 }
1046
1047 return .{
1048 .snapshot = computeSectionStats(snapshot_vals[0..n]),
1049 .row_rebuild = computeSectionStats(row_rebuild_vals[0..n]),
1050 .atlas_upload = computeSectionStats(atlas_upload_vals[0..n]),
1051 .instance_upload = computeSectionStats(instance_upload_vals[0..n]),
1052 .gpu_submit = computeSectionStats(gpu_submit_vals[0..n]),
1053 .total = computeSectionStats(total_vals[0..n]),
1054 .frame_count = n,
1055 };
1056 }
1057
1058 fn printFrameStats(stats: FrameTimingStats) void {
1059 const row_fmt = "{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}\n";
1060 std.debug.print("\n=== waystty frame timing ({d} frames) ===\n", .{stats.frame_count});
1061 std.debug.print("{s:<20}{s:>6}{s:>6}{s:>6}{s:>6} (us)\n", .{ "section", "min", "avg", "p99", "max" });
1062 std.debug.print(row_fmt, .{ "snapshot", stats.snapshot.min, stats.snapshot.avg, stats.snapshot.p99, stats.snapshot.max });
1063 std.debug.print(row_fmt, .{ "row_rebuild", stats.row_rebuild.min, stats.row_rebuild.avg, stats.row_rebuild.p99, stats.row_rebuild.max });
1064 std.debug.print(row_fmt, .{ "atlas_upload", stats.atlas_upload.min, stats.atlas_upload.avg, stats.atlas_upload.p99, stats.atlas_upload.max });
1065 std.debug.print(row_fmt, .{ "instance_upload", stats.instance_upload.min, stats.instance_upload.avg, stats.instance_upload.p99, stats.instance_upload.max });
1066 std.debug.print(row_fmt, .{ "gpu_submit", stats.gpu_submit.min, stats.gpu_submit.avg, stats.gpu_submit.p99, stats.gpu_submit.max });
1067 std.debug.print("----------------------------------------------------\n", .{});
1068 std.debug.print(row_fmt, .{ "total", stats.total.min, stats.total.avg, stats.total.p99, stats.total.max });
1069 }
1070
1071 var sigusr1_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); 1021 var sigusr1_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
1072 1022
1073 fn sigusr1Handler(_: c_int) callconv(.c) void { 1023 fn sigusr1Handler(_: c_int) callconv(.c) void {
@@ -1598,48 +1548,6 @@ fn cursorTouchesDirtyRow(dirty_rows: []const bool, cursor: CursorRefreshContext)
1598 return false; 1548 return false;
1599 } 1549 }
1600 1550
1601 fn appendCellInstances(
1602 alloc: std.mem.Allocator,
1603 instances: *std.ArrayListUnmanaged(renderer.Instance),
1604 row_idx: u32,
1605 col_idx: u32,
1606 cell_w: u32,
1607 cell_h: u32,
1608 baseline: u32,
1609 glyph_uv: ?font.GlyphUV,
1610 bg_uv: font.GlyphUV,
1611 colors: vt.CellColors,
1612 default_bg: [4]f32,
1613 ) !void {
1614 if (!std.meta.eql(colors.bg, default_bg)) {
1615 try instances.append(alloc, .{
1616 .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
1617 .glyph_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
1618 .glyph_bearing = .{ 0, 0 },
1619 .uv_rect = .{ bg_uv.u0, bg_uv.v0, bg_uv.u1, bg_uv.v1 },
1620 .fg = colors.bg,
1621 .bg = colors.bg,
1622 });
1623 }
1624
1625 const uv = glyph_uv orelse return;
1626 try instances.append(alloc, .{
1627 .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
1628 .glyph_size = .{ @floatFromInt(uv.width), @floatFromInt(uv.height) },
1629 .glyph_bearing = .{
1630 @floatFromInt(uv.bearing_x),
1631 glyphTopOffset(baseline, uv.bearing_y),
1632 },
1633 .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 },
1634 .fg = colors.fg,
1635 .bg = colors.bg,
1636 });
1637 }
1638
1639 fn glyphTopOffset(baseline: u32, bearing_y: i32) f32 {
1640 return @as(f32, @floatFromInt(baseline)) - @as(f32, @floatFromInt(bearing_y));
1641 }
1642
1643 fn encodeKeyboardEvent( 1551 fn encodeKeyboardEvent(
1644 term: *const vt.Terminal, 1552 term: *const vt.Terminal,
1645 ev: wayland_client.KeyboardEvent, 1553 ev: wayland_client.KeyboardEvent,
@@ -3132,82 +3040,6 @@ test "buildTextCoverageCompareScene repeats the same specimen in four panels" {
3132 ); 3040 );
3133 } 3041 }
3134 3042
3135 test "FrameTiming.total sums all sections" {
3136 const ft: FrameTiming = .{
3137 .snapshot_us = 10,
3138 .row_rebuild_us = 20,
3139 .atlas_upload_us = 30,
3140 .instance_upload_us = 40,
3141 .gpu_submit_us = 50,
3142 };
3143 try std.testing.expectEqual(@as(u32, 150), ft.total());
3144 }
3145
3146 test "FrameTimingRing records and wraps correctly" {
3147 var ring = FrameTimingRing{};
3148 try std.testing.expectEqual(@as(usize, 0), ring.count);
3149
3150 ring.push(.{ .snapshot_us = 1, .row_rebuild_us = 2, .atlas_upload_us = 3, .instance_upload_us = 4, .gpu_submit_us = 5 });
3151 try std.testing.expectEqual(@as(usize, 1), ring.count);
3152 try std.testing.expectEqual(@as(u32, 1), ring.entries[0].snapshot_us);
3153
3154 // Fill to capacity
3155 for (1..FrameTimingRing.capacity) |i| {
3156 ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
3157 }
3158 try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
3159
3160 // One more wraps around — overwrites entries[0], head advances to 1
3161 ring.push(.{ .snapshot_us = 999, .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
3162 try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
3163 // Newest entry is at (head + capacity - 1) % capacity = 0
3164 try std.testing.expectEqual(@as(u32, 999), ring.entries[0].snapshot_us);
3165 // head has advanced past the overwritten slot
3166 try std.testing.expectEqual(@as(usize, 1), ring.head);
3167 }
3168
3169 test "FrameTimingRing.orderedSlice returns entries in insertion order after wrap" {
3170 var ring = FrameTimingRing{};
3171 // Push capacity + 3 entries so the ring wraps
3172 for (0..FrameTimingRing.capacity + 3) |i| {
3173 ring.push(.{ .snapshot_us = @intCast(i), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
3174 }
3175 var buf: [FrameTimingRing.capacity]FrameTiming = undefined;
3176 const ordered = ring.orderedSlice(&buf);
3177 try std.testing.expectEqual(FrameTimingRing.capacity, ordered.len);
3178 // First entry should be the 4th pushed (index 3), last should be capacity+2
3179 try std.testing.expectEqual(@as(u32, 3), ordered[0].snapshot_us);
3180 try std.testing.expectEqual(@as(u32, FrameTimingRing.capacity + 2), ordered[ordered.len - 1].snapshot_us);
3181 }
3182
3183 test "FrameTimingStats computes min/avg/p99/max correctly" {
3184 var ring = FrameTimingRing{};
3185 // Push 100 frames with snapshot_us = 1..100
3186 for (0..100) |i| {
3187 ring.push(.{
3188 .snapshot_us = @intCast(i + 1),
3189 .row_rebuild_us = 0,
3190 .atlas_upload_us = 0,
3191 .instance_upload_us = 0,
3192 .gpu_submit_us = 0,
3193 });
3194 }
3195 const stats = computeFrameStats(&ring);
3196 try std.testing.expectEqual(@as(u32, 1), stats.snapshot.min);
3197 try std.testing.expectEqual(@as(u32, 100), stats.snapshot.max);
3198 try std.testing.expectEqual(@as(u32, 50), stats.snapshot.avg);
3199 // p99 of 1..100 = value at index 98 (0-based) = 99
3200 try std.testing.expectEqual(@as(u32, 99), stats.snapshot.p99);
3201 try std.testing.expectEqual(@as(usize, 100), stats.frame_count);
3202 }
3203
3204 test "FrameTimingStats handles empty ring" {
3205 var ring = FrameTimingRing{};
3206 const stats = computeFrameStats(&ring);
3207 try std.testing.expectEqual(@as(usize, 0), stats.frame_count);
3208 try std.testing.expectEqual(@as(u32, 0), stats.snapshot.min);
3209 }
3210
3211 fn runRenderSmokeTest(alloc: std.mem.Allocator) !void { 3043 fn runRenderSmokeTest(alloc: std.mem.Allocator) !void {
3212 const conn = try wayland_client.Connection.init(alloc); 3044 const conn = try wayland_client.Connection.init(alloc);
3213 defer conn.deinit(); 3045 defer conn.deinit();
src/png.zig
Old New
@@ -0,0 +1,261 @@
1 const std = @import("std");
2
3 pub const Image = struct {
4 width: u32,
5 height: u32,
6 pixels: []u8, // RGBA8, row-major, width*height*4 bytes
7
8 pub fn deinit(self: *Image, alloc: std.mem.Allocator) void {
9 alloc.free(self.pixels);
10 self.* = undefined;
11 }
12 };
13
14 pub const EncodeError = error{ OutOfMemory, WriteFailed };
15 pub const DecodeError = error{
16 OutOfMemory,
17 InvalidPng,
18 UnsupportedPng, // only RGBA8 non-interlaced is supported
19 CorruptChunk,
20 };
21
22 const signature = [_]u8{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
23
24 fn adler32(data: []const u8) u32 {
25 var a: u32 = 1;
26 var b: u32 = 0;
27 for (data) |byte| {
28 a = (a + byte) % 65521;
29 b = (b + a) % 65521;
30 }
31 return (b << 16) | a;
32 }
33
34 fn writeChunk(writer: anytype, chunk_type: *const [4]u8, payload: []const u8) EncodeError!void {
35 writer.writeInt(u32, @intCast(payload.len), .big) catch return error.WriteFailed;
36 writer.writeAll(chunk_type) catch return error.WriteFailed;
37 writer.writeAll(payload) catch return error.WriteFailed;
38 var crc = std.hash.Crc32.init();
39 crc.update(chunk_type);
40 crc.update(payload);
41 writer.writeInt(u32, crc.final(), .big) catch return error.WriteFailed;
42 }
43
44 /// Build a zlib stream wrapping the `filtered` data using DEFLATE stored
45 /// blocks (type 0, no compression). This is always valid PNG and avoids
46 /// dependency on the std.compress.flate encoder, which is incomplete in
47 /// Zig 0.15.
48 fn buildZlibStored(alloc: std.mem.Allocator, filtered: []const u8) EncodeError![]u8 {
49 // zlib header: CMF=0x78 (deflate, window=32K), FLG=0x01 (no dict, level=0,
50 // fcheck makes CMF*256+FLG divisible by 31: 0x7801 % 31 == 0).
51 const zlib_header = [_]u8{ 0x78, 0x01 };
52
53 // DEFLATE stored block layout:
54 // 1 byte: BFINAL | (BTYPE << 1) — BTYPE=00 for stored
55 // 2 bytes: LEN (little-endian u16)
56 // 2 bytes: NLEN (one's complement of LEN, little-endian)
57 // LEN bytes: data
58 //
59 // Maximum single stored block payload is 65535 bytes.
60 const max_block: usize = 65535;
61 const actual_blocks: usize = if (filtered.len == 0) 1 else (filtered.len + max_block - 1) / max_block;
62 // Header per block: 5 bytes. Total deflate stream bytes:
63 const deflate_len = actual_blocks * 5 + filtered.len;
64
65 // Full buffer: zlib_header(2) + deflate + adler32(4)
66 const total = 2 + deflate_len + 4;
67 const buf = alloc.alloc(u8, total) catch return error.OutOfMemory;
68 errdefer alloc.free(buf);
69
70 var pos: usize = 0;
71 buf[pos] = zlib_header[0];
72 pos += 1;
73 buf[pos] = zlib_header[1];
74 pos += 1;
75
76 var src_pos: usize = 0;
77 var block_idx: usize = 0;
78 while (block_idx < actual_blocks) : (block_idx += 1) {
79 const remaining = filtered.len - src_pos;
80 const block_len: u16 = @intCast(@min(remaining, max_block));
81 const is_final = block_idx == actual_blocks - 1;
82 const bfinal: u8 = if (is_final) 0x01 else 0x00;
83 buf[pos] = bfinal; // BFINAL=is_final, BTYPE=00
84 pos += 1;
85 std.mem.writeInt(u16, buf[pos..][0..2], block_len, .little);
86 pos += 2;
87 const nlen: u16 = ~block_len;
88 std.mem.writeInt(u16, buf[pos..][0..2], nlen, .little);
89 pos += 2;
90 @memcpy(buf[pos..][0..block_len], filtered[src_pos..][0..block_len]);
91 pos += block_len;
92 src_pos += block_len;
93 }
94
95 // Adler-32 of the uncompressed (filtered) data, big-endian
96 std.mem.writeInt(u32, buf[pos..][0..4], adler32(filtered), .big);
97 pos += 4;
98 std.debug.assert(pos == total);
99
100 return buf;
101 }
102
103 pub fn encode(alloc: std.mem.Allocator, img: Image, writer: anytype) EncodeError!void {
104 std.debug.assert(img.pixels.len == @as(usize, img.width) * img.height * 4);
105
106 writer.writeAll(&signature) catch return error.WriteFailed;
107
108 var ihdr: [13]u8 = undefined;
109 std.mem.writeInt(u32, ihdr[0..4], img.width, .big);
110 std.mem.writeInt(u32, ihdr[4..8], img.height, .big);
111 ihdr[8] = 8; // bit depth
112 ihdr[9] = 6; // colour type = RGBA
113 ihdr[10] = 0; // compression method
114 ihdr[11] = 0; // filter method
115 ihdr[12] = 0; // interlace method = none
116 try writeChunk(writer, "IHDR", &ihdr);
117
118 const row_bytes = @as(usize, img.width) * 4;
119 const filtered_len = (row_bytes + 1) * img.height;
120 const filtered = alloc.alloc(u8, filtered_len) catch return error.OutOfMemory;
121 defer alloc.free(filtered);
122
123 // Filter type 0 (None) per row
124 var y: u32 = 0;
125 while (y < img.height) : (y += 1) {
126 const src_off = @as(usize, y) * row_bytes;
127 const dst_off = @as(usize, y) * (row_bytes + 1);
128 filtered[dst_off] = 0; // filter byte
129 @memcpy(filtered[dst_off + 1 ..][0..row_bytes], img.pixels[src_off..][0..row_bytes]);
130 }
131
132 const compressed = try buildZlibStored(alloc, filtered);
133 defer alloc.free(compressed);
134
135 try writeChunk(writer, "IDAT", compressed);
136 try writeChunk(writer, "IEND", &.{});
137 }
138
139 pub fn decode(alloc: std.mem.Allocator, bytes: []const u8) DecodeError!Image {
140 if (bytes.len < signature.len + 8) return error.InvalidPng;
141 if (!std.mem.eql(u8, bytes[0..signature.len], &signature)) return error.InvalidPng;
142
143 var cursor: usize = signature.len;
144 var width: u32 = 0;
145 var height: u32 = 0;
146 var idat_accum: std.ArrayList(u8) = .empty;
147 defer idat_accum.deinit(alloc);
148 var seen_ihdr = false;
149 var seen_iend = false;
150
151 while (cursor + 8 <= bytes.len and !seen_iend) {
152 const len = std.mem.readInt(u32, bytes[cursor..][0..4], .big);
153 cursor += 4;
154 const ctype = bytes[cursor..][0..4];
155 cursor += 4;
156 if (cursor + len + 4 > bytes.len) return error.CorruptChunk;
157 const payload = bytes[cursor..][0..len];
158 cursor += len;
159 cursor += 4; // skip CRC
160
161 if (std.mem.eql(u8, ctype, "IHDR")) {
162 if (payload.len != 13) return error.InvalidPng;
163 width = std.mem.readInt(u32, payload[0..4], .big);
164 height = std.mem.readInt(u32, payload[4..8], .big);
165 // bit depth=8, colour type=6 (RGBA), interlace=0
166 if (payload[8] != 8 or payload[9] != 6 or payload[12] != 0)
167 return error.UnsupportedPng;
168 seen_ihdr = true;
169 } else if (std.mem.eql(u8, ctype, "IDAT")) {
170 if (!seen_ihdr) return error.InvalidPng;
171 idat_accum.appendSlice(alloc, payload) catch return error.OutOfMemory;
172 } else if (std.mem.eql(u8, ctype, "IEND")) {
173 seen_iend = true;
174 }
175 }
176
177 if (!seen_ihdr or !seen_iend) return error.InvalidPng;
178 // zlib stream: 2-byte header + deflate body + 4-byte adler32
179 if (idat_accum.items.len < 6) return error.InvalidPng;
180 // Strip the 2-byte zlib header and 4-byte adler32 footer to get raw deflate
181 const deflate_data = idat_accum.items[2 .. idat_accum.items.len - 4];
182
183 const row_bytes = @as(usize, width) * 4;
184 const filtered_len = (row_bytes + 1) * @as(usize, height);
185 const filtered = alloc.alloc(u8, filtered_len) catch return error.OutOfMemory;
186 defer alloc.free(filtered);
187
188 // Decompress using std.compress.flate.Decompress with the new Zig 0.15 API.
189 // The indirect vtable (used when a window buffer is provided) fills its
190 // internal buffer on each vtable call and returns 0; the caller must loop,
191 // draining the buffer on alternate calls.
192 {
193 var in_reader: std.Io.Reader = .fixed(deflate_data);
194 var decomp_buf: [std.compress.flate.max_window_len]u8 = undefined;
195 var decomp: std.compress.flate.Decompress = .init(&in_reader, .raw, &decomp_buf);
196
197 var dst_writer: std.Io.Writer = .fixed(filtered);
198 var written: usize = 0;
199 while (written < filtered_len) {
200 const n = decomp.reader.stream(&dst_writer, .unlimited) catch |err| switch (err) {
201 error.EndOfStream => break,
202 else => return error.CorruptChunk,
203 };
204 written += n;
205 if (n == 0 and decomp.reader.seek == decomp.reader.end) break;
206 }
207 if (written != filtered_len) return error.CorruptChunk;
208 }
209
210 const pixels = alloc.alloc(u8, @as(usize, width) * height * 4) catch return error.OutOfMemory;
211 errdefer alloc.free(pixels);
212
213 var row: u32 = 0;
214 while (row < height) : (row += 1) {
215 const dst_off = @as(usize, row) * row_bytes;
216 const src_off = @as(usize, row) * (row_bytes + 1);
217 if (filtered[src_off] != 0) return error.UnsupportedPng; // only filter type 0
218 @memcpy(pixels[dst_off..][0..row_bytes], filtered[src_off + 1 ..][0..row_bytes]);
219 }
220
221 return .{ .width = width, .height = height, .pixels = pixels };
222 }
223
224 test "encode then decode roundtrip recovers pixels" {
225 const alloc = std.testing.allocator;
226 var src_pixels = [_]u8{
227 0xff, 0x00, 0x00, 0xff, 0x00, 0xff, 0x00, 0xff,
228 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
229 };
230 const src = Image{ .width = 2, .height = 2, .pixels = &src_pixels };
231
232 var buf: std.ArrayList(u8) = .empty;
233 defer buf.deinit(alloc);
234 try encode(alloc, src, buf.writer(alloc));
235
236 var decoded = try decode(alloc, buf.items[0..]);
237 defer decoded.deinit(alloc);
238
239 try std.testing.expectEqual(@as(u32, 2), decoded.width);
240 try std.testing.expectEqual(@as(u32, 2), decoded.height);
241 try std.testing.expectEqualSlices(u8, &src_pixels, decoded.pixels);
242 }
243
244 test "decode rejects RGB (non-alpha) PNGs with UnsupportedPng" {
245 const alloc = std.testing.allocator;
246 var bytes: std.ArrayList(u8) = .empty;
247 defer bytes.deinit(alloc);
248 try bytes.appendSlice(alloc, &signature);
249 var ihdr: [13]u8 = undefined;
250 std.mem.writeInt(u32, ihdr[0..4], 1, .big);
251 std.mem.writeInt(u32, ihdr[4..8], 1, .big);
252 ihdr[8] = 8;
253 ihdr[9] = 2; // colour type 2 = RGB (not RGBA)
254 ihdr[10] = 0;
255 ihdr[11] = 0;
256 ihdr[12] = 0;
257 try writeChunk(bytes.writer(alloc), "IHDR", &ihdr);
258 try writeChunk(bytes.writer(alloc), "IEND", &.{});
259
260 try std.testing.expectError(error.UnsupportedPng, decode(alloc, bytes.items[0..]));
261 }
src/renderer.zig
Old New
@@ -418,6 +418,125 @@ fn swapchainNeedsRebuild(result: vk.Result) bool {
418 return result == .suboptimal_khr; 418 return result == .suboptimal_khr;
419 } 419 }
420 420
421 /// Offscreen color attachment + readback staging buffer.
422 /// The image is allocated with COLOR_ATTACHMENT_BIT | TRANSFER_SRC_BIT so the
423 /// renderer can draw into it and then copy it into the host-visible readback
424 /// buffer. Created via `createOffscreen` and freed with `destroyOffscreen`.
425 pub const OffscreenTarget = struct {
426 width: u32,
427 height: u32,
428 format: vk.Format,
429 image: vk.Image,
430 memory: vk.DeviceMemory,
431 view: vk.ImageView,
432 framebuffer: vk.Framebuffer,
433 readback_buffer: vk.Buffer,
434 readback_memory: vk.DeviceMemory,
435 readback_size: u64,
436 };
437
438 /// Allocate an offscreen color-attachment image plus matching readback buffer.
439 /// The framebuffer is compatible with `render_pass` — pass the same render pass
440 /// the swapchain uses so the existing pipeline is reusable.
441 pub fn createOffscreen(
442 vki: vk.InstanceWrapper,
443 vkd: vk.DeviceWrapper,
444 physical: vk.PhysicalDevice,
445 device: vk.Device,
446 render_pass: vk.RenderPass,
447 format: vk.Format,
448 width: u32,
449 height: u32,
450 ) !OffscreenTarget {
451 // 1. Color attachment image with TRANSFER_SRC_BIT | COLOR_ATTACHMENT_BIT
452 const image = try vkd.createImage(device, &vk.ImageCreateInfo{
453 .image_type = .@"2d",
454 .format = format,
455 .extent = .{ .width = width, .height = height, .depth = 1 },
456 .mip_levels = 1,
457 .array_layers = 1,
458 .samples = .{ .@"1_bit" = true },
459 .tiling = .optimal,
460 .usage = .{ .color_attachment_bit = true, .transfer_src_bit = true },
461 .sharing_mode = .exclusive,
462 .initial_layout = .undefined,
463 }, null);
464 errdefer vkd.destroyImage(device, image, null);
465
466 // 2. Device-local memory bound
467 const img_reqs = vkd.getImageMemoryRequirements(device, image);
468 const img_mem_idx = try findMemoryType(vki, physical, img_reqs.memory_type_bits, .{ .device_local_bit = true });
469 const memory = try vkd.allocateMemory(device, &vk.MemoryAllocateInfo{
470 .allocation_size = img_reqs.size,
471 .memory_type_index = img_mem_idx,
472 }, null);
473 errdefer vkd.freeMemory(device, memory, null);
474 try vkd.bindImageMemory(device, image, memory, 0);
475
476 // 3. ImageView + Framebuffer (using the provided render_pass)
477 const view = try vkd.createImageView(device, &vk.ImageViewCreateInfo{
478 .image = image,
479 .view_type = .@"2d",
480 .format = format,
481 .components = .{ .r = .identity, .g = .identity, .b = .identity, .a = .identity },
482 .subresource_range = .{
483 .aspect_mask = .{ .color_bit = true },
484 .base_mip_level = 0,
485 .level_count = 1,
486 .base_array_layer = 0,
487 .layer_count = 1,
488 },
489 }, null);
490 errdefer vkd.destroyImageView(device, view, null);
491
492 const framebuffer = try vkd.createFramebuffer(device, &vk.FramebufferCreateInfo{
493 .render_pass = render_pass,
494 .attachment_count = 1,
495 .p_attachments = @ptrCast(&view),
496 .width = width,
497 .height = height,
498 .layers = 1,
499 }, null);
500 errdefer vkd.destroyFramebuffer(device, framebuffer, null);
501
502 // 4. Host-visible readback buffer (TRANSFER_DST, width*height*4 bytes)
503 const readback_size: u64 = @as(u64, width) * @as(u64, height) * 4;
504 const readback = try createHostVisibleBuffer(
505 vki,
506 physical,
507 vkd,
508 device,
509 @intCast(readback_size),
510 .{ .transfer_dst_bit = true },
511 );
512 errdefer {
513 vkd.destroyBuffer(device, readback.buffer, null);
514 vkd.freeMemory(device, readback.memory, null);
515 }
516
517 return .{
518 .width = width,
519 .height = height,
520 .format = format,
521 .image = image,
522 .memory = memory,
523 .view = view,
524 .framebuffer = framebuffer,
525 .readback_buffer = readback.buffer,
526 .readback_memory = readback.memory,
527 .readback_size = readback_size,
528 };
529 }
530
531 pub fn destroyOffscreen(vkd: vk.DeviceWrapper, device: vk.Device, t: OffscreenTarget) void {
532 vkd.destroyFramebuffer(device, t.framebuffer, null);
533 vkd.destroyImageView(device, t.view, null);
534 vkd.destroyImage(device, t.image, null);
535 vkd.freeMemory(device, t.memory, null);
536 vkd.destroyBuffer(device, t.readback_buffer, null);
537 vkd.freeMemory(device, t.readback_memory, null);
538 }
539
421 pub const Context = struct { 540 pub const Context = struct {
422 alloc: std.mem.Allocator, 541 alloc: std.mem.Allocator,
423 vkb: vk.BaseWrapper, 542 vkb: vk.BaseWrapper,
@@ -473,6 +592,9 @@ pub const Context = struct {
473 // Dedicated transfer command buffer + fence 592 // Dedicated transfer command buffer + fence
474 atlas_transfer_cb: vk.CommandBuffer, 593 atlas_transfer_cb: vk.CommandBuffer,
475 atlas_transfer_fence: vk.Fence, 594 atlas_transfer_fence: vk.Fence,
595 // Dedicated capture command buffer + fence (used by renderToOffscreen)
596 capture_cmd: vk.CommandBuffer,
597 capture_fence: vk.Fence,
476 598
477 pub fn init( 599 pub fn init(
478 alloc: std.mem.Allocator, 600 alloc: std.mem.Allocator,
@@ -929,6 +1051,19 @@ pub const Context = struct {
929 }, null); 1051 }, null);
930 errdefer vkd.destroyFence(device, atlas_transfer_fence, null); 1052 errdefer vkd.destroyFence(device, atlas_transfer_fence, null);
931 1053
1054 // --- Dedicated capture (offscreen render + readback) command buffer + fence ---
1055 var capture_cmd: vk.CommandBuffer = undefined;
1056 try vkd.allocateCommandBuffers(device, &vk.CommandBufferAllocateInfo{
1057 .command_pool = command_pool,
1058 .level = .primary,
1059 .command_buffer_count = 1,
1060 }, @ptrCast(&capture_cmd));
1061
1062 const capture_fence = try vkd.createFence(device, &vk.FenceCreateInfo{
1063 .flags = .{ .signaled_bit = true },
1064 }, null);
1065 errdefer vkd.destroyFence(device, capture_fence, null);
1066
932 // Bind atlas to descriptor set 1067 // Bind atlas to descriptor set
933 const img_info = vk.DescriptorImageInfo{ 1068 const img_info = vk.DescriptorImageInfo{
934 .sampler = atlas_sampler, 1069 .sampler = atlas_sampler,
@@ -991,6 +1126,8 @@ pub const Context = struct {
991 .atlas_staging_memory = atlas_staging.memory, 1126 .atlas_staging_memory = atlas_staging.memory,
992 .atlas_transfer_cb = atlas_transfer_cb, 1127 .atlas_transfer_cb = atlas_transfer_cb,
993 .atlas_transfer_fence = atlas_transfer_fence, 1128 .atlas_transfer_fence = atlas_transfer_fence,
1129 .capture_cmd = capture_cmd,
1130 .capture_fence = capture_fence,
994 }; 1131 };
995 } 1132 }
996 1133
@@ -1006,6 +1143,7 @@ pub const Context = struct {
1006 self.vkd.destroyBuffer(self.device, self.atlas_staging_buffer, null); 1143 self.vkd.destroyBuffer(self.device, self.atlas_staging_buffer, null);
1007 self.vkd.freeMemory(self.device, self.atlas_staging_memory, null); 1144 self.vkd.freeMemory(self.device, self.atlas_staging_memory, null);
1008 self.vkd.destroyFence(self.device, self.atlas_transfer_fence, null); 1145 self.vkd.destroyFence(self.device, self.atlas_transfer_fence, null);
1146 self.vkd.destroyFence(self.device, self.capture_fence, null);
1009 self.vkd.destroyBuffer(self.device, self.instance_buffer, null); 1147 self.vkd.destroyBuffer(self.device, self.instance_buffer, null);
1010 self.vkd.freeMemory(self.device, self.instance_memory, null); 1148 self.vkd.freeMemory(self.device, self.instance_memory, null);
1011 self.vkd.destroyBuffer(self.device, self.quad_vertex_buffer, null); 1149 self.vkd.destroyBuffer(self.device, self.quad_vertex_buffer, null);
@@ -1480,6 +1618,67 @@ pub const Context = struct {
1480 return false; 1618 return false;
1481 } 1619 }
1482 1620
1621 /// Shared "bind pipeline + push constants + vertex/instance buffers +
1622 /// dynamic viewport/scissor + drawInstanced" block used by both the
1623 /// swapchain draw path (`drawCells`) and the offscreen draw path
1624 /// (`renderToOffscreen`).
1625 ///
1626 /// The caller is responsible for recording the surrounding
1627 /// `cmdBeginRenderPass`/`cmdEndRenderPass` pair with the appropriate
1628 /// framebuffer and render area.
1629 fn recordDrawCommands(
1630 self: *Context,
1631 cmd: vk.CommandBuffer,
1632 extent: vk.Extent2D,
1633 instance_count: u32,
1634 push: PushConstants,
1635 ) void {
1636 self.vkd.cmdBindPipeline(cmd, .graphics, self.pipeline);
1637
1638 // Dynamic viewport + scissor
1639 const viewport = vk.Viewport{
1640 .x = 0.0,
1641 .y = 0.0,
1642 .width = @floatFromInt(extent.width),
1643 .height = @floatFromInt(extent.height),
1644 .min_depth = 0.0,
1645 .max_depth = 1.0,
1646 };
1647 self.vkd.cmdSetViewport(cmd, 0, 1, @ptrCast(&viewport));
1648
1649 const scissor = vk.Rect2D{
1650 .offset = .{ .x = 0, .y = 0 },
1651 .extent = extent,
1652 };
1653 self.vkd.cmdSetScissor(cmd, 0, 1, @ptrCast(&scissor));
1654
1655 // Push constants
1656 self.vkd.cmdPushConstants(
1657 cmd,
1658 self.pipeline_layout,
1659 .{ .vertex_bit = true, .fragment_bit = true },
1660 0,
1661 @sizeOf(PushConstants),
1662 @ptrCast(&push),
1663 );
1664
1665 // Bind descriptor set (atlas sampler)
1666 self.vkd.cmdBindDescriptorSets(
1667 cmd,
1668 .graphics,
1669 self.pipeline_layout,
1670 0, 1, @ptrCast(&self.descriptor_set),
1671 0, null,
1672 );
1673
1674 // Bind vertex buffers: binding 0 = quad, binding 1 = instances
1675 const buffers = [_]vk.Buffer{ self.quad_vertex_buffer, self.instance_buffer };
1676 const offsets = [_]vk.DeviceSize{ 0, 0 };
1677 self.vkd.cmdBindVertexBuffers(cmd, 0, 2, &buffers, &offsets);
1678
1679 self.vkd.cmdDraw(cmd, 6, instance_count, 0, 0);
1680 }
1681
1483 /// Full draw pass: bind pipeline, push constants, vertex + instance buffers, draw, present. 1682 /// Full draw pass: bind pipeline, push constants, vertex + instance buffers, draw, present.
1484 pub fn drawCells( 1683 pub fn drawCells(
1485 self: *Context, 1684 self: *Context,
@@ -1525,26 +1724,6 @@ pub const Context = struct {
1525 .p_clear_values = @ptrCast(&clear_value), 1724 .p_clear_values = @ptrCast(&clear_value),
1526 }, .@"inline"); 1725 }, .@"inline");
1527 1726
1528 self.vkd.cmdBindPipeline(self.command_buffer, .graphics, self.pipeline);
1529
1530 // Dynamic viewport + scissor
1531 const viewport = vk.Viewport{
1532 .x = 0.0,
1533 .y = 0.0,
1534 .width = @floatFromInt(self.swapchain_extent.width),
1535 .height = @floatFromInt(self.swapchain_extent.height),
1536 .min_depth = 0.0,
1537 .max_depth = 1.0,
1538 };
1539 self.vkd.cmdSetViewport(self.command_buffer, 0, 1, @ptrCast(&viewport));
1540
1541 const scissor = vk.Rect2D{
1542 .offset = .{ .x = 0, .y = 0 },
1543 .extent = self.swapchain_extent,
1544 };
1545 self.vkd.cmdSetScissor(self.command_buffer, 0, 1, @ptrCast(&scissor));
1546
1547 // Push constants
1548 const pc = PushConstants{ 1727 const pc = PushConstants{
1549 .viewport_size = .{ 1728 .viewport_size = .{
1550 @floatFromInt(self.swapchain_extent.width), 1729 @floatFromInt(self.swapchain_extent.width),
@@ -1553,30 +1732,7 @@ pub const Context = struct {
1553 .cell_size = cell_size, 1732 .cell_size = cell_size,
1554 .coverage_params = coverage_params, 1733 .coverage_params = coverage_params,
1555 }; 1734 };
1556 self.vkd.cmdPushConstants( 1735 self.recordDrawCommands(self.command_buffer, self.swapchain_extent, instance_count, pc);
1557 self.command_buffer,
1558 self.pipeline_layout,
1559 .{ .vertex_bit = true, .fragment_bit = true },
1560 0,
1561 @sizeOf(PushConstants),
1562 @ptrCast(&pc),
1563 );
1564
1565 // Bind descriptor set (atlas sampler)
1566 self.vkd.cmdBindDescriptorSets(
1567 self.command_buffer,
1568 .graphics,
1569 self.pipeline_layout,
1570 0, 1, @ptrCast(&self.descriptor_set),
1571 0, null,
1572 );
1573
1574 // Bind vertex buffers: binding 0 = quad, binding 1 = instances
1575 const buffers = [_]vk.Buffer{ self.quad_vertex_buffer, self.instance_buffer };
1576 const offsets = [_]vk.DeviceSize{ 0, 0 };
1577 self.vkd.cmdBindVertexBuffers(self.command_buffer, 0, 2, &buffers, &offsets);
1578
1579 self.vkd.cmdDraw(self.command_buffer, 6, instance_count, 0, 0);
1580 1736
1581 self.vkd.cmdEndRenderPass(self.command_buffer); 1737 self.vkd.cmdEndRenderPass(self.command_buffer);
1582 try self.vkd.endCommandBuffer(self.command_buffer); 1738 try self.vkd.endCommandBuffer(self.command_buffer);
@@ -1606,6 +1762,184 @@ pub const Context = struct {
1606 }; 1762 };
1607 if (swapchainNeedsRebuild(present_result)) return error.OutOfDateKHR; 1763 if (swapchainNeedsRebuild(present_result)) return error.OutOfDateKHR;
1608 } 1764 }
1765
1766 /// Render `instance_data` into the offscreen target and copy the rendered
1767 /// image into the target's host-visible readback buffer.
1768 ///
1769 /// After this call returns, `target.readback_buffer` contains the raw
1770 /// bytes in the target's native format (typically BGRA8). Use
1771 /// `readbackOffscreen` to pull them out as RGBA8.
1772 pub fn renderToOffscreen(
1773 self: *Context,
1774 target: *const OffscreenTarget,
1775 instance_data: []const Instance,
1776 push: PushConstants,
1777 ) !void {
1778 // Wait for any in-flight swapchain frame to complete before touching the
1779 // shared instance buffer. (drawCells and renderToOffscreen share
1780 // self.instance_memory; without this wait the host would overwrite bytes
1781 // the GPU is still reading.)
1782 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.in_flight_fence), .true, std.math.maxInt(u64));
1783
1784 // 1. Upload instances (same path drawCells uses)
1785 try self.uploadInstances(instance_data);
1786
1787 // 2. Reset + begin capture command buffer
1788 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.capture_fence), .true, std.math.maxInt(u64));
1789 try self.vkd.resetFences(self.device, 1, @ptrCast(&self.capture_fence));
1790
1791 try self.vkd.resetCommandBuffer(self.capture_cmd, .{});
1792 try self.vkd.beginCommandBuffer(self.capture_cmd, &vk.CommandBufferBeginInfo{
1793 .flags = .{ .one_time_submit_bit = true },
1794 });
1795
1796 // 3. Transition target.image UNDEFINED -> COLOR_ATTACHMENT_OPTIMAL.
1797 // The render pass's `initial_layout = .undefined` technically
1798 // accepts the image in any layout, but an explicit barrier makes
1799 // the access masks/stage masks unambiguous.
1800 const to_color = vk.ImageMemoryBarrier{
1801 .src_access_mask = .{},
1802 .dst_access_mask = .{ .color_attachment_write_bit = true },
1803 .old_layout = .undefined,
1804 .new_layout = .color_attachment_optimal,
1805 .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
1806 .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
1807 .image = target.image,
1808 .subresource_range = .{
1809 .aspect_mask = .{ .color_bit = true },
1810 .base_mip_level = 0,
1811 .level_count = 1,
1812 .base_array_layer = 0,
1813 .layer_count = 1,
1814 },
1815 };
1816 self.vkd.cmdPipelineBarrier(
1817 self.capture_cmd,
1818 .{ .top_of_pipe_bit = true },
1819 .{ .color_attachment_output_bit = true },
1820 .{},
1821 0, null,
1822 0, null,
1823 1, @ptrCast(&to_color),
1824 );
1825
1826 // 4. Begin the render pass on target.framebuffer / extent
1827 const clear_value = vk.ClearValue{ .color = .{ .float_32 = .{ 0.0, 0.0, 0.0, 1.0 } } };
1828 const extent = vk.Extent2D{ .width = target.width, .height = target.height };
1829 self.vkd.cmdBeginRenderPass(self.capture_cmd, &vk.RenderPassBeginInfo{
1830 .render_pass = self.render_pass,
1831 .framebuffer = target.framebuffer,
1832 .render_area = .{
1833 .offset = .{ .x = 0, .y = 0 },
1834 .extent = extent,
1835 },
1836 .clear_value_count = 1,
1837 .p_clear_values = @ptrCast(&clear_value),
1838 }, .@"inline");
1839
1840 // 5. Shared draw commands (same as drawCells)
1841 self.recordDrawCommands(self.capture_cmd, extent, @intCast(instance_data.len), push);
1842
1843 // 6. End render pass (the pass's final_layout is .present_src_khr)
1844 self.vkd.cmdEndRenderPass(self.capture_cmd);
1845
1846 // 7. Transition target.image PRESENT_SRC_KHR -> TRANSFER_SRC_OPTIMAL
1847 // The render pass forced the final layout to .present_src_khr
1848 // (it's the same render pass the swapchain uses). Barrier from
1849 // there to TRANSFER_SRC before cmdCopyImageToBuffer.
1850 const to_transfer = vk.ImageMemoryBarrier{
1851 .src_access_mask = .{ .color_attachment_write_bit = true },
1852 .dst_access_mask = .{ .transfer_read_bit = true },
1853 .old_layout = .present_src_khr,
1854 .new_layout = .transfer_src_optimal,
1855 .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
1856 .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
1857 .image = target.image,
1858 .subresource_range = .{
1859 .aspect_mask = .{ .color_bit = true },
1860 .base_mip_level = 0,
1861 .level_count = 1,
1862 .base_array_layer = 0,
1863 .layer_count = 1,
1864 },
1865 };
1866 self.vkd.cmdPipelineBarrier(
1867 self.capture_cmd,
1868 .{ .color_attachment_output_bit = true },
1869 .{ .transfer_bit = true },
1870 .{},
1871 0, null,
1872 0, null,
1873 1, @ptrCast(&to_transfer),
1874 );
1875
1876 // 8. Copy image -> readback buffer
1877 const region = vk.BufferImageCopy{
1878 .buffer_offset = 0,
1879 .buffer_row_length = 0,
1880 .buffer_image_height = 0,
1881 .image_subresource = .{
1882 .aspect_mask = .{ .color_bit = true },
1883 .mip_level = 0,
1884 .base_array_layer = 0,
1885 .layer_count = 1,
1886 },
1887 .image_offset = .{ .x = 0, .y = 0, .z = 0 },
1888 .image_extent = .{ .width = target.width, .height = target.height, .depth = 1 },
1889 };
1890 self.vkd.cmdCopyImageToBuffer(
1891 self.capture_cmd,
1892 target.image,
1893 .transfer_src_optimal,
1894 target.readback_buffer,
1895 1,
1896 @ptrCast(&region),
1897 );
1898
1899 // 9. End + submit + wait
1900 try self.vkd.endCommandBuffer(self.capture_cmd);
1901 try self.vkd.queueSubmit(self.graphics_queue, 1, @ptrCast(&vk.SubmitInfo{
1902 .command_buffer_count = 1,
1903 .p_command_buffers = @ptrCast(&self.capture_cmd),
1904 }), self.capture_fence);
1905 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.capture_fence), .true, std.math.maxInt(u64));
1906 }
1907
1908 /// Read the offscreen target's readback buffer into `out_rgba`.
1909 /// Converts BGRA (the swapchain/native format) to RGBA and forces
1910 /// alpha to 0xFF. Caller must ensure a prior `renderToOffscreen` has
1911 /// finished (it waits on the capture fence, so simple back-to-back
1912 /// call is safe).
1913 pub fn readbackOffscreen(
1914 self: *Context,
1915 target: *const OffscreenTarget,
1916 out_rgba: []u8,
1917 ) !void {
1918 std.debug.assert(target.format == .b8g8r8a8_unorm); // swizzle below assumes BGRA8
1919 std.debug.assert(out_rgba.len == @as(usize, target.width) * @as(usize, target.height) * 4);
1920 if (out_rgba.len < target.readback_size) return error.BufferTooSmall;
1921
1922 const mapped = try self.vkd.mapMemory(
1923 self.device,
1924 target.readback_memory,
1925 0,
1926 @intCast(target.readback_size),
1927 .{},
1928 );
1929 defer self.vkd.unmapMemory(self.device, target.readback_memory);
1930
1931 const src = @as([*]const u8, @ptrCast(mapped))[0..target.readback_size];
1932 const pixel_count: usize = @intCast(@as(u64, target.width) * @as(u64, target.height));
1933 var i: usize = 0;
1934 while (i < pixel_count) : (i += 1) {
1935 const o = i * 4;
1936 // Source is BGRA8 (swapchain-native); produce RGBA8, force alpha=0xFF.
1937 out_rgba[o + 0] = src[o + 2]; // R <- B
1938 out_rgba[o + 1] = src[o + 1]; // G <- G
1939 out_rgba[o + 2] = src[o + 0]; // B <- R
1940 out_rgba[o + 3] = 0xFF;
1941 }
1942 }
1609 }; 1943 };
1610 1944
1611 test "vulkan module imports" { 1945 test "vulkan module imports" {
src/tools/bench_baseline.zig
Old New
@@ -0,0 +1,121 @@
1 const std = @import("std");
2 const bench_stats = @import("bench_stats");
3
4 pub fn main() !void {
5 var gpa: std.heap.DebugAllocator(.{}) = .init;
6 defer _ = gpa.deinit();
7 const alloc = gpa.allocator();
8
9 const args = try std.process.argsAlloc(alloc);
10 defer std.process.argsFree(alloc, args);
11
12 const Mode = enum { save, check };
13 const mode: Mode = if (args.len >= 2 and std.mem.eql(u8, args[1], "save"))
14 .save
15 else
16 .check;
17
18 const baseline_path = "tests/bench/baseline.json";
19 const tmp_json = "/tmp/waystty-bench-current.json";
20
21 try std.fs.cwd().makePath("tests/bench");
22
23 // Run waystty with WAYSTTY_BENCH=1 WAYSTTY_BENCH_JSON=<tmp>
24 var env = try std.process.getEnvMap(alloc);
25 defer env.deinit();
26 try env.put("WAYSTTY_BENCH", "1");
27 try env.put("WAYSTTY_BENCH_JSON", tmp_json);
28
29 const child = try std.process.Child.run(.{
30 .allocator = alloc,
31 .argv = &.{"zig-out/bin/waystty"},
32 .env_map = &env,
33 });
34 defer alloc.free(child.stdout);
35 defer alloc.free(child.stderr);
36
37 if (child.term != .Exited or child.term.Exited != 0) {
38 std.debug.print("bench: waystty exited abnormally: {any}\n stderr: {s}\n", .{ child.term, child.stderr });
39 std.process.exit(2);
40 }
41
42 const current_bytes = std.fs.cwd().readFileAlloc(alloc, tmp_json, 16 * 1024) catch |err| {
43 std.debug.print("bench: no JSON output at {s}: {s}\n", .{ tmp_json, @errorName(err) });
44 std.process.exit(2);
45 };
46 defer alloc.free(current_bytes);
47
48 const current = try bench_stats.readBaselineJson(alloc, current_bytes);
49 defer {
50 alloc.free(current.workload_sha);
51 alloc.free(current.zig_version);
52 alloc.free(current.waystty_sha);
53 }
54
55 if (mode == .save) {
56 const json_out = try bench_stats.writeBaselineJson(alloc, current);
57 defer alloc.free(json_out);
58 const out = try std.fs.cwd().createFile(baseline_path, .{});
59 defer out.close();
60 try out.writeAll(json_out);
61 std.debug.print("bench: wrote {s} (frame_count={d})\n", .{ baseline_path, current.frame_count });
62 return;
63 }
64
65 // check mode — compare current to baseline.
66 const baseline_bytes = std.fs.cwd().readFileAlloc(alloc, baseline_path, 16 * 1024) catch |err| {
67 std.debug.print("bench: no baseline at {s}: {s}\n run: zig build bench-baseline\n", .{ baseline_path, @errorName(err) });
68 std.process.exit(2);
69 };
70 defer alloc.free(baseline_bytes);
71
72 const baseline = try bench_stats.readBaselineJson(alloc, baseline_bytes);
73 defer {
74 alloc.free(baseline.workload_sha);
75 alloc.free(baseline.zig_version);
76 alloc.free(baseline.waystty_sha);
77 }
78
79 if (!std.mem.eql(u8, baseline.workload_sha, current.workload_sha)) {
80 std.debug.print("WARN: bench script changed since baseline; consider regenerating via `zig build bench-baseline`\n", .{});
81 }
82
83 const pct_threshold: f64 = blk: {
84 const v = std.posix.getenv("WAYSTTY_BENCH_REGRESSION_PCT") orelse break :blk 20.0;
85 break :blk std.fmt.parseFloat(f64, v) catch 20.0;
86 };
87
88 var regressed = false;
89
90 const SectionName = struct {
91 name: []const u8,
92 base_p99: u32,
93 cur_p99: u32,
94 };
95
96 const sections = [_]SectionName{
97 .{ .name = "snapshot", .base_p99 = baseline.sections.snapshot.p99, .cur_p99 = current.sections.snapshot.p99 },
98 .{ .name = "row_rebuild", .base_p99 = baseline.sections.row_rebuild.p99, .cur_p99 = current.sections.row_rebuild.p99 },
99 .{ .name = "atlas_upload", .base_p99 = baseline.sections.atlas_upload.p99, .cur_p99 = current.sections.atlas_upload.p99 },
100 .{ .name = "instance_upload", .base_p99 = baseline.sections.instance_upload.p99, .cur_p99 = current.sections.instance_upload.p99 },
101 .{ .name = "gpu_submit", .base_p99 = baseline.sections.gpu_submit.p99, .cur_p99 = current.sections.gpu_submit.p99 },
102 };
103
104 std.debug.print("bench: threshold {d:.1}% p99 growth\n", .{pct_threshold});
105 for (sections) |s| {
106 const delta_pct: f64 = if (s.base_p99 == 0)
107 0.0
108 else
109 ((@as(f64, @floatFromInt(s.cur_p99)) - @as(f64, @floatFromInt(s.base_p99))) / @as(f64, @floatFromInt(s.base_p99))) * 100.0;
110 const status = if (delta_pct > pct_threshold) "REGRESSION" else "OK";
111 if (delta_pct > pct_threshold) regressed = true;
112 const sign: []const u8 = if (delta_pct >= 0) "+" else "-";
113 const abs_delta: f64 = if (delta_pct >= 0) delta_pct else -delta_pct;
114 std.debug.print(
115 "bench: {s:<16} p99 {d:>5}us (baseline {d:>5}us) {s}{d:>5.1}% {s}\n",
116 .{ s.name, s.cur_p99, s.base_p99, sign, abs_delta, status },
117 );
118 }
119
120 if (regressed) std.process.exit(1);
121 }
src/tools/imgdiff.zig
Old New
@@ -0,0 +1,151 @@
1 const std = @import("std");
2 const png = @import("png");
3
4 pub const DiffResult = struct {
5 rmse: f64, // [0, 1]
6 max_pixel: f64, // [0, 1]
7 pixel_count: usize,
8 };
9
10 pub fn compare(a: png.Image, b: png.Image) !DiffResult {
11 if (a.width != b.width or a.height != b.height) return error.DimensionsDiffer;
12 std.debug.assert(a.pixels.len == b.pixels.len);
13
14 const px_count = @as(usize, a.width) * a.height;
15 var sum_sq: f64 = 0;
16 var max_d: f64 = 0;
17
18 var i: usize = 0;
19 while (i < px_count) : (i += 1) {
20 const off = i * 4;
21 const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0;
22 const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0;
23 const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0;
24 const d_sq = (dr * dr + dg * dg + db * db) / 3.0;
25 sum_sq += d_sq;
26 const d = @sqrt(d_sq);
27 if (d > max_d) max_d = d;
28 }
29
30 return .{
31 .rmse = @sqrt(sum_sq / @as(f64, @floatFromInt(px_count))),
32 .max_pixel = max_d,
33 .pixel_count = px_count,
34 };
35 }
36
37 test "identical images produce zero RMSE" {
38 var pixels_a = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 };
39 var pixels_b = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 };
40 const a = png.Image{ .width = 2, .height = 1, .pixels = &pixels_a };
41 const b = png.Image{ .width = 2, .height = 1, .pixels = &pixels_b };
42 const r = try compare(a, b);
43 try std.testing.expectEqual(@as(f64, 0.0), r.rmse);
44 try std.testing.expectEqual(@as(f64, 0.0), r.max_pixel);
45 }
46
47 test "fully saturated difference produces rmse=1.0 and max=1.0" {
48 var pixels_a = [_]u8{ 0, 0, 0, 255 };
49 var pixels_b = [_]u8{ 255, 255, 255, 255 };
50 const a = png.Image{ .width = 1, .height = 1, .pixels = &pixels_a };
51 const b = png.Image{ .width = 1, .height = 1, .pixels = &pixels_b };
52 const r = try compare(a, b);
53 try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.rmse, 1e-9);
54 try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.max_pixel, 1e-9);
55 }
56
57 pub fn main() !void {
58 var gpa: std.heap.DebugAllocator(.{}) = .init;
59 defer _ = gpa.deinit();
60 const alloc = gpa.allocator();
61
62 const args = try std.process.argsAlloc(alloc);
63 defer std.process.argsFree(alloc, args);
64
65 if (args.len < 3) {
66 std.debug.print("usage: imgdiff <actual.png> <reference.png> [diff.png]\n", .{});
67 std.process.exit(2);
68 }
69 const actual_path = args[1];
70 const reference_path = args[2];
71 const diff_path: ?[]const u8 = if (args.len >= 4) args[3] else null;
72
73 const rmse_max = readFloatEnv("WAYSTTY_TEST_RMSE_MAX", 0.005);
74 const pixel_max = readFloatEnv("WAYSTTY_TEST_PIXEL_MAX", 0.125);
75
76 const actual_bytes = try std.fs.cwd().readFileAlloc(alloc, actual_path, 64 * 1024 * 1024);
77 defer alloc.free(actual_bytes);
78 const reference_bytes = try std.fs.cwd().readFileAlloc(alloc, reference_path, 64 * 1024 * 1024);
79 defer alloc.free(reference_bytes);
80
81 var actual = try png.decode(alloc, actual_bytes);
82 defer actual.deinit(alloc);
83 var reference = try png.decode(alloc, reference_bytes);
84 defer reference.deinit(alloc);
85
86 if (actual.width != reference.width or actual.height != reference.height) {
87 std.debug.print("FAIL: dimensions differ ({}x{} vs {}x{})\n", .{ actual.width, actual.height, reference.width, reference.height });
88 std.process.exit(3);
89 }
90
91 const r = try compare(actual, reference);
92 const pass = r.rmse <= rmse_max and r.max_pixel <= pixel_max;
93
94 if (pass) {
95 std.debug.print("OK: {s} RMSE={d:.4}% worst={d:.4}%\n", .{ reference_path, r.rmse * 100.0, r.max_pixel * 100.0 });
96 std.process.exit(0);
97 }
98
99 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 });
100
101 if (diff_path) |p| {
102 const diff_img = try makeDiffImage(alloc, actual, reference);
103 defer alloc.free(diff_img.pixels);
104
105 var buf: std.ArrayList(u8) = .empty;
106 defer buf.deinit(alloc);
107 try png.encode(alloc, diff_img, buf.writer(alloc));
108
109 const out = try std.fs.cwd().createFile(p, .{ .truncate = true });
110 defer out.close();
111 try out.writeAll(buf.items);
112
113 std.debug.print(" diff: {s}\n", .{p});
114 }
115 std.debug.print(" actual: {s}\n", .{actual_path});
116 std.process.exit(1);
117 }
118
119 fn readFloatEnv(name: []const u8, default: f64) f64 {
120 const val = std.posix.getenv(name) orelse return default;
121 return std.fmt.parseFloat(f64, val) catch default;
122 }
123
124 fn makeDiffImage(alloc: std.mem.Allocator, a: png.Image, b: png.Image) !png.Image {
125 // Side-by-side: [actual | reference | delta-heatmap]
126 const w = a.width * 3;
127 const h = a.height;
128 const pixels = try alloc.alloc(u8, w * h * 4);
129 var y: u32 = 0;
130 while (y < h) : (y += 1) {
131 const row_off = @as(usize, y) * w * 4;
132 const a_off = @as(usize, y) * a.width * 4;
133 @memcpy(pixels[row_off .. row_off + a.width * 4], a.pixels[a_off .. a_off + a.width * 4]);
134 @memcpy(pixels[row_off + a.width * 4 .. row_off + 2 * a.width * 4], b.pixels[a_off .. a_off + a.width * 4]);
135 var x: u32 = 0;
136 while (x < a.width) : (x += 1) {
137 const off = a_off + x * 4;
138 const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0;
139 const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0;
140 const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0;
141 const d = @sqrt((dr * dr + dg * dg + db * db) / 3.0);
142 const brightness: u8 = @intFromFloat(@min(255.0, d * 255.0 * 2.0));
143 const dst = row_off + 2 * a.width * 4 + x * 4;
144 pixels[dst + 0] = brightness;
145 pixels[dst + 1] = brightness;
146 pixels[dst + 2] = brightness;
147 pixels[dst + 3] = 255;
148 }
149 }
150 return .{ .width = w, .height = h, .pixels = pixels };
151 }
src/tools/test_render.zig
Old New
@@ -0,0 +1,75 @@
1 const std = @import("std");
2
3 pub fn main() !void {
4 var gpa: std.heap.DebugAllocator(.{}) = .init;
5 defer _ = gpa.deinit();
6 const alloc = gpa.allocator();
7
8 const mode_update = blk: {
9 const m = std.posix.getenv("WAYSTTY_GOLDEN_UPDATE") orelse break :blk false;
10 break :blk std.mem.eql(u8, m, "1");
11 };
12
13 try std.fs.cwd().makePath("tests/golden/output");
14
15 var scripts_dir = try std.fs.cwd().openDir("tests/golden/scripts", .{ .iterate = true });
16 defer scripts_dir.close();
17 var it = scripts_dir.iterate();
18
19 var passed: usize = 0;
20 var failed: usize = 0;
21
22 while (try it.next()) |entry| {
23 if (entry.kind != .file) continue;
24 if (!std.mem.endsWith(u8, entry.name, ".vt")) continue;
25
26 const base = entry.name[0 .. entry.name.len - 3];
27 const script_path = try std.fmt.allocPrint(alloc, "tests/golden/scripts/{s}.vt", .{base});
28 defer alloc.free(script_path);
29 const output_path = try std.fmt.allocPrint(alloc, "tests/golden/output/{s}.png", .{base});
30 defer alloc.free(output_path);
31 const reference_path = try std.fmt.allocPrint(alloc, "tests/golden/reference/{s}.png", .{base});
32 defer alloc.free(reference_path);
33 const diff_path = try std.fmt.allocPrint(alloc, "tests/golden/output/{s}.diff.png", .{base});
34 defer alloc.free(diff_path);
35
36 // Run waystty --capture
37 const cap = try std.process.Child.run(.{
38 .allocator = alloc,
39 .argv = &.{ "zig-out/bin/waystty", "--capture", script_path, output_path },
40 });
41 defer alloc.free(cap.stdout);
42 defer alloc.free(cap.stderr);
43 if (cap.term != .Exited or cap.term.Exited != 0) {
44 std.debug.print("FAIL: {s}: capture exited with {}\n stderr: {s}\n",
45 .{ base, cap.term, cap.stderr });
46 failed += 1;
47 continue;
48 }
49
50 if (mode_update) {
51 try std.fs.cwd().makePath("tests/golden/reference");
52 try std.fs.cwd().copyFile(output_path, std.fs.cwd(), reference_path, .{});
53 std.debug.print("UPDATED: {s}\n", .{base});
54 passed += 1;
55 continue;
56 }
57
58 // Run imgdiff
59 const dif = try std.process.Child.run(.{
60 .allocator = alloc,
61 .argv = &.{ "zig-out/bin/imgdiff", output_path, reference_path, diff_path },
62 });
63 defer alloc.free(dif.stdout);
64 defer alloc.free(dif.stderr);
65 std.debug.print("{s}", .{dif.stdout});
66 if (dif.term == .Exited and dif.term.Exited == 0) {
67 passed += 1;
68 } else {
69 failed += 1;
70 }
71 }
72
73 std.debug.print("\n=== test-render: {} passed, {} failed ===\n", .{ passed, failed });
74 if (failed > 0) std.process.exit(1);
75 }
tests/bench/baseline.json
Old New
@@ -0,0 +1,38 @@
1 {
2 "workload_sha": "066a95eee2d2f6195c0eb997e7e18a63f75b48010b538dd287056f948cc65005",
3 "zig_version": "0.15.2",
4 "waystty_sha": "828c61f589b2bac3785e37531a094d6abb1f40ad",
5 "frame_count": 5,
6 "sections": {
7 "snapshot": {
8 "min": 10,
9 "avg": 201,
10 "p99": 214,
11 "max": 725
12 },
13 "row_rebuild": {
14 "min": 115,
15 "avg": 2075,
16 "p99": 2166,
17 "max": 5447
18 },
19 "atlas_upload": {
20 "min": 0,
21 "avg": 10,
22 "p99": 0,
23 "max": 50
24 },
25 "instance_upload": {
26 "min": 4,
27 "avg": 37,
28 "p99": 21,
29 "max": 144
30 },
31 "gpu_submit": {
32 "min": 37,
33 "avg": 58,
34 "p99": 66,
35 "max": 79
36 }
37 }
38 }
\ No newline at end of file 38 \ No newline at end of file
tests/golden/reference/basic_ascii.png
Old New
Binary file
Binary files differ
tests/golden/reference/bold_colors.png
Old New
Binary file
Binary files differ
tests/golden/reference/box_drawing.png
Old New
Binary file
Binary files differ
tests/golden/scripts/basic_ascii.vt
Old New
@@ -0,0 +1,2 @@
1  !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~
2 
\ No newline at end of file 2 \ No newline at end of file
tests/golden/scripts/bold_colors.vt
Old New
@@ -0,0 +1,9 @@
1 normal
2 bold
3 dim
4 italic
5 underline
6 reverse
7 fg30 fg31 fg32 fg33 fg34 fg35 fg36 fg37 
8 bg40 bg41 bg42 bg43 bg44 bg45 bg46 bg47 
9 
\ No newline at end of file 9 \ No newline at end of file
tests/golden/scripts/box_drawing.vt
Old New
@@ -0,0 +1,7 @@
1 ┌──────────┐
2 │ │
3 │ │
4 │ │
5 └──────────┘
6 ░▒▓█ block chars
7 
\ No newline at end of file 7 \ No newline at end of file