a73x

7b928983

Add GPU render testing implementation plan

a73x   2026-04-17 11:05

Commit message
Add GPU render testing implementation plan

Eleven-task plan covering PNG codec, offscreen render target,
--capture mode, imgdiff tool, VT scripts, test orchestrator,
golden generation, and bench baseline/check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs/superpowers/plans/2026-04-17-gpu-render-testing.md
Old New
@@ -0,0 +1,2096 @@
1 # GPU Render Testing Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** Add an automated pipeline that renders VT scripts through the real Vulkan pipeline, captures the output via an offscreen image readback, diffs it against golden PNGs, and flags both visual and performance regressions. All local-only (no CI).
6
7 **Architecture:** A `--capture <script> <output.png>` mode in waystty renders a scripted terminal session into a dedicated offscreen `VkImage`, reads that image back to host memory, and writes it as PNG. A `imgdiff` tool does RMSE + per-pixel-max comparison. A `test-render` orchestrator iterates golden scripts. A `bench-baseline` / `bench-check` pair compares p99 frame timings against a stored baseline.
8
9 **Tech Stack:** Zig 0.15+, Vulkan 1.2 (via vulkan-zig bindings), zig-wayland, vendored minimal PNG codec. No new external deps.
10
11 **Spec:** `docs/superpowers/specs/2026-04-17-gpu-render-testing-design.md`
12
13 ---
14
15 ## File Structure
16
17 New files:
18 - `src/png.zig` — vendored minimal RGBA8 PNG encoder/decoder (used by renderer + imgdiff)
19 - `src/capture.zig` — capture mode flow (visibility wait, drain, settle, render, readback, PNG write)
20 - `src/tools/imgdiff.zig` — standalone executable: compare two PNGs
21 - `src/tools/test_render.zig` — standalone executable: orchestrate capture + diff across all scripts
22 - `src/tools/bench_baseline.zig` — standalone executable: run bench workload, write/compare baseline.json
23 - `src/bench_stats.zig` — shared JSON serialization for FrameTimingStats (used by runTerminal and bench_baseline)
24 - `tests/golden/scripts/basic_ascii.vt`
25 - `tests/golden/scripts/bold_colors.vt`
26 - `tests/golden/scripts/box_drawing.vt`
27 - `tests/golden/reference/*.png` (committed after Task 8)
28 - `tests/bench/baseline.json` (committed after Task 10)
29
30 Modified files:
31 - `src/renderer.zig` — add `OffscreenTarget` struct + `renderToOffscreen()` + `readbackOffscreen()`
32 - `src/main.zig` — dispatch `--capture` arg to `capture.run`; extract `computeFrameStats` + `printFrameStats` to share with bench tools (expose via `bench_stats.zig`)
33 - `build.zig` — new modules (png, capture, bench_stats), new executables (imgdiff, test_render, bench_baseline), new build steps
34 - `Makefile` — new targets (`test-render`, `golden-update`, `bench-baseline`, `bench-check`)
35 - `.gitignore` — ignore stray `test_io*`/`test_sig`/`test_timer` binaries and `tests/golden/output/`
36
37 ---
38
39 ## Task 1: Vendored PNG codec (`src/png.zig`)
40
41 Minimal RGBA8 PNG encoder + decoder. Used by renderer (write capture output) and imgdiff (read reference + actual). Writing zlib/DEFLATE from scratch is tedious, but Zig ships `std.compress.flate` which handles DEFLATE, and the PNG IDAT chunk is a zlib stream (DEFLATE + adler32 + zlib header). We use `std.compress.flate` for the DEFLATE core and wrap it with the zlib header + adler32 ourselves, plus CRC32 for chunk framing.
42
43 **Files:**
44 - Create: `src/png.zig`
45 - Create: `src/png_test.zig` (only if a separate test file helps; otherwise tests inline)
46 - Modify: `build.zig` (add png module)
47
48 - [ ] **Step 1: Write the failing test for encode→decode round-trip**
49
50 Create `src/png.zig`:
51
52 ```zig
53 const std = @import("std");
54
55 pub const Image = struct {
56 width: u32,
57 height: u32,
58 pixels: []u8, // RGBA8, row-major, width*height*4 bytes
59
60 pub fn deinit(self: *Image, alloc: std.mem.Allocator) void {
61 alloc.free(self.pixels);
62 self.* = undefined;
63 }
64 };
65
66 pub const EncodeError = error{ OutOfMemory, WriteFailed };
67 pub const DecodeError = error{
68 OutOfMemory,
69 InvalidPng,
70 UnsupportedPng, // only RGBA8 non-interlaced is supported
71 CorruptChunk,
72 };
73
74 pub fn encode(alloc: std.mem.Allocator, img: Image, writer: anytype) EncodeError!void {
75 _ = alloc;
76 _ = img;
77 _ = writer;
78 return error.WriteFailed; // placeholder — Step 3 replaces this
79 }
80
81 pub fn decode(alloc: std.mem.Allocator, bytes: []const u8) DecodeError!Image {
82 _ = alloc;
83 _ = bytes;
84 return error.InvalidPng; // placeholder — Step 3 replaces this
85 }
86
87 test "encode then decode roundtrip recovers pixels" {
88 const alloc = std.testing.allocator;
89 var src_pixels = [_]u8{
90 0xff, 0x00, 0x00, 0xff, 0x00, 0xff, 0x00, 0xff,
91 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
92 };
93 const src = Image{ .width = 2, .height = 2, .pixels = &src_pixels };
94
95 var buf = std.ArrayList(u8).init(alloc);
96 defer buf.deinit();
97 try encode(alloc, src, buf.writer());
98
99 var decoded = try decode(alloc, buf.items);
100 defer decoded.deinit(alloc);
101
102 try std.testing.expectEqual(@as(u32, 2), decoded.width);
103 try std.testing.expectEqual(@as(u32, 2), decoded.height);
104 try std.testing.expectEqualSlices(u8, &src_pixels, decoded.pixels);
105 }
106 ```
107
108 Add to `build.zig` (after the `renderer_test_mod` block near the end of the file):
109
110 ```zig
111 // png module — vendored minimal RGBA8 PNG codec
112 const png_mod = b.createModule(.{
113 .root_source_file = b.path("src/png.zig"),
114 .target = target,
115 .optimize = optimize,
116 });
117 exe_mod.addImport("png", png_mod);
118
119 const png_test_mod = b.createModule(.{
120 .root_source_file = b.path("src/png.zig"),
121 .target = target,
122 .optimize = optimize,
123 });
124 const png_tests = b.addTest(.{ .root_module = png_test_mod });
125 test_step.dependOn(&b.addRunArtifact(png_tests).step);
126 ```
127
128 - [ ] **Step 2: Run the test to confirm it fails**
129
130 Run: `zig build test 2>&1 | grep -E "png|roundtrip"`
131 Expected: failure mentioning `error.WriteFailed` or `error.InvalidPng`.
132
133 - [ ] **Step 3: Implement PNG encoder**
134
135 Replace the `encode` function in `src/png.zig` with a real implementation. The PNG structure is:
136
137 ```
138 signature: 8 bytes 89 50 4E 47 0D 0A 1A 0A
139 IHDR chunk: 13 bytes payload: width(4) height(4) bitdepth=8 colortype=6(RGBA) compression=0 filter=0 interlace=0
140 IDAT chunk: zlib-wrapped DEFLATE of (filter_byte=0 + row_pixels) per row
141 IEND chunk: empty payload
142 ```
143
144 Each chunk is: length(4) type(4) data(length) crc32(4).
145 The zlib wrapping is: header(2) deflated_data adler32(4).
146
147 Put the full implementation in place:
148
149 ```zig
150 const signature = [_]u8{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
151
152 fn crc32(data: []const u8) u32 {
153 var crc = std.hash.Crc32.init();
154 crc.update(data);
155 return crc.final();
156 }
157
158 fn adler32(data: []const u8) u32 {
159 var a: u32 = 1;
160 var b: u32 = 0;
161 for (data) |byte| {
162 a = (a + byte) % 65521;
163 b = (b + a) % 65521;
164 }
165 return (b << 16) | a;
166 }
167
168 fn writeChunk(writer: anytype, chunk_type: *const [4]u8, payload: []const u8) EncodeError!void {
169 writer.writeInt(u32, @intCast(payload.len), .big) catch return error.WriteFailed;
170 writer.writeAll(chunk_type) catch return error.WriteFailed;
171 writer.writeAll(payload) catch return error.WriteFailed;
172 // CRC is over chunk_type + payload
173 var crc = std.hash.Crc32.init();
174 crc.update(chunk_type);
175 crc.update(payload);
176 writer.writeInt(u32, crc.final(), .big) catch return error.WriteFailed;
177 }
178
179 pub fn encode(alloc: std.mem.Allocator, img: Image, writer: anytype) EncodeError!void {
180 std.debug.assert(img.pixels.len == @as(usize, img.width) * img.height * 4);
181
182 writer.writeAll(&signature) catch return error.WriteFailed;
183
184 // IHDR
185 var ihdr: [13]u8 = undefined;
186 std.mem.writeInt(u32, ihdr[0..4], img.width, .big);
187 std.mem.writeInt(u32, ihdr[4..8], img.height, .big);
188 ihdr[8] = 8; // bit depth
189 ihdr[9] = 6; // color type = RGBA
190 ihdr[10] = 0; // compression
191 ihdr[11] = 0; // filter
192 ihdr[12] = 0; // interlace
193 try writeChunk(writer, "IHDR", &ihdr);
194
195 // Build filtered rows: one filter byte (0 = none) followed by each row
196 const row_bytes = @as(usize, img.width) * 4;
197 const filtered_len = (row_bytes + 1) * img.height;
198 const filtered = alloc.alloc(u8, filtered_len) catch return error.OutOfMemory;
199 defer alloc.free(filtered);
200
201 var y: u32 = 0;
202 while (y < img.height) : (y += 1) {
203 const src_off = @as(usize, y) * row_bytes;
204 const dst_off = @as(usize, y) * (row_bytes + 1);
205 filtered[dst_off] = 0;
206 @memcpy(filtered[dst_off + 1 .. dst_off + 1 + row_bytes], img.pixels[src_off .. src_off + row_bytes]);
207 }
208
209 // Compress with DEFLATE, wrap with zlib header + adler32
210 var compressed = std.ArrayList(u8).init(alloc);
211 defer compressed.deinit();
212 // zlib header: 0x78 0x01 (deflate, no preset dict, fastest)
213 compressed.appendSlice(&.{ 0x78, 0x01 }) catch return error.OutOfMemory;
214
215 var fbs = std.io.fixedBufferStream(filtered);
216 std.compress.flate.deflate.compress(.raw, fbs.reader(), compressed.writer(), .{}) catch return error.WriteFailed;
217
218 var adler_bytes: [4]u8 = undefined;
219 std.mem.writeInt(u32, &adler_bytes, adler32(filtered), .big);
220 compressed.appendSlice(&adler_bytes) catch return error.OutOfMemory;
221
222 try writeChunk(writer, "IDAT", compressed.items);
223 try writeChunk(writer, "IEND", &.{});
224 }
225 ```
226
227 Note: the exact `std.compress.flate` API may differ slightly across Zig versions. If it does, substitute the equivalent raw-deflate writer in current Zig 0.15+ std. The rest of the PNG framing is version-independent.
228
229 - [ ] **Step 4: Implement PNG decoder**
230
231 Replace `decode` in `src/png.zig`:
232
233 ```zig
234 pub fn decode(alloc: std.mem.Allocator, bytes: []const u8) DecodeError!Image {
235 if (bytes.len < signature.len + 8) return error.InvalidPng;
236 if (!std.mem.eql(u8, bytes[0..signature.len], &signature)) return error.InvalidPng;
237
238 var cursor: usize = signature.len;
239 var width: u32 = 0;
240 var height: u32 = 0;
241 var idat_accum = std.ArrayList(u8).init(alloc);
242 defer idat_accum.deinit();
243 var seen_ihdr = false;
244 var seen_iend = false;
245
246 while (cursor + 8 <= bytes.len and !seen_iend) {
247 const len = std.mem.readInt(u32, bytes[cursor..][0..4], .big);
248 cursor += 4;
249 const ctype = bytes[cursor..][0..4];
250 cursor += 4;
251 if (cursor + len + 4 > bytes.len) return error.CorruptChunk;
252 const payload = bytes[cursor..][0..len];
253 cursor += len;
254 // skip CRC (4 bytes); we trust the file
255 cursor += 4;
256
257 if (std.mem.eql(u8, ctype, "IHDR")) {
258 if (payload.len != 13) return error.InvalidPng;
259 width = std.mem.readInt(u32, payload[0..4], .big);
260 height = std.mem.readInt(u32, payload[4..8], .big);
261 if (payload[8] != 8 or payload[9] != 6 or payload[12] != 0)
262 return error.UnsupportedPng;
263 seen_ihdr = true;
264 } else if (std.mem.eql(u8, ctype, "IDAT")) {
265 if (!seen_ihdr) return error.InvalidPng;
266 idat_accum.appendSlice(payload) catch return error.OutOfMemory;
267 } else if (std.mem.eql(u8, ctype, "IEND")) {
268 seen_iend = true;
269 }
270 // ignore other chunks
271 }
272
273 if (!seen_ihdr or !seen_iend) return error.InvalidPng;
274
275 // Strip zlib header (2 bytes) + adler32 (last 4 bytes)
276 if (idat_accum.items.len < 6) return error.InvalidPng;
277 const deflate_data = idat_accum.items[2 .. idat_accum.items.len - 4];
278
279 const row_bytes = @as(usize, width) * 4;
280 const filtered_len = (row_bytes + 1) * height;
281 const filtered = alloc.alloc(u8, filtered_len) catch return error.OutOfMemory;
282 defer alloc.free(filtered);
283
284 var src_fbs = std.io.fixedBufferStream(deflate_data);
285 var dst_fbs = std.io.fixedBufferStream(filtered);
286 std.compress.flate.deflate.decompress(.raw, src_fbs.reader(), dst_fbs.writer()) catch return error.CorruptChunk;
287 if (dst_fbs.pos != filtered_len) return error.CorruptChunk;
288
289 const pixels = alloc.alloc(u8, @as(usize, width) * height * 4) catch return error.OutOfMemory;
290 errdefer alloc.free(pixels);
291
292 // Only filter type 0 (None) supported; fail on anything else
293 var y: u32 = 0;
294 while (y < height) : (y += 1) {
295 const dst_off = @as(usize, y) * row_bytes;
296 const src_off = @as(usize, y) * (row_bytes + 1);
297 if (filtered[src_off] != 0) return error.UnsupportedPng;
298 @memcpy(pixels[dst_off .. dst_off + row_bytes], filtered[src_off + 1 .. src_off + 1 + row_bytes]);
299 }
300
301 return .{ .width = width, .height = height, .pixels = pixels };
302 }
303 ```
304
305 - [ ] **Step 5: Run the roundtrip test**
306
307 Run: `zig build test 2>&1 | grep -E "png|roundtrip"`
308 Expected: PASS. If failure references the DEFLATE API, swap in the correct `std.compress.flate` call for the current Zig version and re-run.
309
310 - [ ] **Step 6: Add a decode-rejects-non-rgba8 test**
311
312 Append to `src/png.zig`:
313
314 ```zig
315 test "decode rejects RGB (non-alpha) PNGs with UnsupportedPng" {
316 const alloc = std.testing.allocator;
317 // Craft a minimal valid PNG with color type = 2 (RGB)
318 var bytes = std.ArrayList(u8).init(alloc);
319 defer bytes.deinit();
320 try bytes.appendSlice(&signature);
321 // IHDR
322 var ihdr: [13]u8 = undefined;
323 std.mem.writeInt(u32, ihdr[0..4], 1, .big);
324 std.mem.writeInt(u32, ihdr[4..8], 1, .big);
325 ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
326 try writeChunk(bytes.writer(), "IHDR", &ihdr);
327 try writeChunk(bytes.writer(), "IEND", &.{});
328
329 try std.testing.expectError(error.UnsupportedPng, decode(alloc, bytes.items));
330 }
331 ```
332
333 Run: `zig build test`
334 Expected: both tests PASS.
335
336 - [ ] **Step 7: Commit**
337
338 ```bash
339 git add src/png.zig build.zig
340 git commit -m "$(cat <<'EOF'
341 Add vendored minimal RGBA8 PNG codec
342
343 Supports only the narrow slice we need (RGBA8 non-interlaced with
344 filter type 0). Used by --capture for output and by imgdiff for
345 reading goldens.
346 EOF
347 )"
348 ```
349
350 ---
351
352 ## Task 2: Offscreen render target in `renderer.zig`
353
354 Add a dedicated `VkImage` the renderer can draw into (instead of the swapchain) and a readback path that copies it to a host-visible staging buffer. The existing renderer pipeline already writes to whatever framebuffer we bind during the render pass — we just need to construct a framebuffer over this new image and have a way to kick the draw and read the pixels.
355
356 We keep the swapchain path untouched — the offscreen target is created on demand by capture mode.
357
358 **Files:**
359 - Modify: `src/renderer.zig` — add `OffscreenTarget` struct, `createOffscreen()`, `destroyOffscreen()`, `renderToOffscreen()`, `readbackOffscreen()`
360
361 - [ ] **Step 1: Find the right insertion point in renderer.zig**
362
363 Read `src/renderer.zig` to find (a) the `Context` struct definition and (b) the `drawCells` function. The new offscreen helpers will be a set of methods on `Context` (or free functions taking the Vulkan wrappers) that mirror what `drawCells` does but render to an owned image.
364
365 Run: `grep -n "pub const Context\|pub fn drawCells\|pub fn uploadAtlas\|pub fn deinit" src/renderer.zig`
366 Expected: locations of each. Use these when editing.
367
368 - [ ] **Step 2: Add the `OffscreenTarget` type and constructor**
369
370 In `src/renderer.zig`, after the `SwapchainResult` struct, add:
371
372 ```zig
373 pub const OffscreenTarget = struct {
374 width: u32,
375 height: u32,
376 format: vk.Format,
377 image: vk.Image,
378 memory: vk.DeviceMemory,
379 view: vk.ImageView,
380 framebuffer: vk.Framebuffer,
381
382 /// Host-visible buffer for readback. Large enough for width*height*4 BGRA bytes.
383 readback_buffer: vk.Buffer,
384 readback_memory: vk.DeviceMemory,
385 readback_size: u64,
386 };
387
388 pub fn createOffscreen(
389 vki: vk.InstanceWrapper,
390 vkd: vk.DeviceWrapper,
391 physical: vk.PhysicalDevice,
392 device: vk.Device,
393 render_pass: vk.RenderPass,
394 format: vk.Format,
395 width: u32,
396 height: u32,
397 ) !OffscreenTarget {
398 // 1. Color attachment image (TRANSFER_SRC for readback)
399 const image = try vkd.createImage(device, &vk.ImageCreateInfo{
400 .image_type = .@"2d",
401 .format = format,
402 .extent = .{ .width = width, .height = height, .depth = 1 },
403 .mip_levels = 1,
404 .array_layers = 1,
405 .samples = .{ .@"1_bit" = true },
406 .tiling = .optimal,
407 .usage = .{ .color_attachment_bit = true, .transfer_src_bit = true },
408 .sharing_mode = .exclusive,
409 .initial_layout = .undefined,
410 }, null);
411
412 const img_mem_req = vkd.getImageMemoryRequirements(device, image);
413 const mem_props = vki.getPhysicalDeviceMemoryProperties(physical);
414 const img_mem_type = findMemoryType(mem_props, img_mem_req.memory_type_bits, .{ .device_local_bit = true }) orelse return error.NoMemoryType;
415
416 const memory = try vkd.allocateMemory(device, &vk.MemoryAllocateInfo{
417 .allocation_size = img_mem_req.size,
418 .memory_type_index = img_mem_type,
419 }, null);
420 try vkd.bindImageMemory(device, image, memory, 0);
421
422 const view = try vkd.createImageView(device, &vk.ImageViewCreateInfo{
423 .image = image,
424 .view_type = .@"2d",
425 .format = format,
426 .components = .{ .r = .identity, .g = .identity, .b = .identity, .a = .identity },
427 .subresource_range = .{
428 .aspect_mask = .{ .color_bit = true },
429 .base_mip_level = 0,
430 .level_count = 1,
431 .base_array_layer = 0,
432 .layer_count = 1,
433 },
434 }, null);
435
436 const framebuffer = try vkd.createFramebuffer(device, &vk.FramebufferCreateInfo{
437 .render_pass = render_pass,
438 .attachment_count = 1,
439 .p_attachments = @ptrCast(&view),
440 .width = width,
441 .height = height,
442 .layers = 1,
443 }, null);
444
445 // 2. Host-visible readback buffer
446 const readback_size: u64 = @as(u64, width) * height * 4;
447 const readback_buffer = try vkd.createBuffer(device, &vk.BufferCreateInfo{
448 .size = readback_size,
449 .usage = .{ .transfer_dst_bit = true },
450 .sharing_mode = .exclusive,
451 }, null);
452 const buf_mem_req = vkd.getBufferMemoryRequirements(device, readback_buffer);
453 const buf_mem_type = findMemoryType(mem_props, buf_mem_req.memory_type_bits, .{
454 .host_visible_bit = true,
455 .host_coherent_bit = true,
456 }) orelse return error.NoMemoryType;
457 const readback_memory = try vkd.allocateMemory(device, &vk.MemoryAllocateInfo{
458 .allocation_size = buf_mem_req.size,
459 .memory_type_index = buf_mem_type,
460 }, null);
461 try vkd.bindBufferMemory(device, readback_buffer, readback_memory, 0);
462
463 return .{
464 .width = width,
465 .height = height,
466 .format = format,
467 .image = image,
468 .memory = memory,
469 .view = view,
470 .framebuffer = framebuffer,
471 .readback_buffer = readback_buffer,
472 .readback_memory = readback_memory,
473 .readback_size = readback_size,
474 };
475 }
476
477 pub fn destroyOffscreen(vkd: vk.DeviceWrapper, device: vk.Device, t: OffscreenTarget) void {
478 vkd.destroyFramebuffer(device, t.framebuffer, null);
479 vkd.destroyImageView(device, t.view, null);
480 vkd.destroyImage(device, t.image, null);
481 vkd.freeMemory(device, t.memory, null);
482 vkd.destroyBuffer(device, t.readback_buffer, null);
483 vkd.freeMemory(device, t.readback_memory, null);
484 }
485
486 fn findMemoryType(
487 props: vk.PhysicalDeviceMemoryProperties,
488 type_filter: u32,
489 required: vk.MemoryPropertyFlags,
490 ) ?u32 {
491 var i: u32 = 0;
492 while (i < props.memory_type_count) : (i += 1) {
493 const bit = @as(u32, 1) << @intCast(i);
494 if (type_filter & bit == 0) continue;
495 const flags = props.memory_types[i].property_flags;
496 if (flags.contains(required)) return i;
497 }
498 return null;
499 }
500 ```
501
502 Note: if `Context` already has a `findMemoryType` helper, reuse it and delete the duplicate above.
503
504 - [ ] **Step 3: Add `renderToOffscreen` and `readbackOffscreen` on Context**
505
506 The existing `drawCells` in `Context` submits draws against the current swapchain image. We need an analogous method that draws into the offscreen framebuffer, transitions the image to `TRANSFER_SRC_OPTIMAL`, and copies into the readback buffer.
507
508 Strategy: refactor `drawCells` minimally so its render-pass body (begin pass → bind pipeline → bind descriptor sets → draw → end pass) is in a helper taking an explicit framebuffer + extent, and have both the swapchain path and the offscreen path call it.
509
510 In `src/renderer.zig`, add these methods on `Context` (put them next to `drawCells`):
511
512 ```zig
513 pub fn renderToOffscreen(
514 self: *Context,
515 target: *const OffscreenTarget,
516 instance_data: []const CellInstance, // same struct drawCells uses
517 push: PushConstants, // same struct drawCells uses
518 ) !void {
519 // 1. Upload instances to the existing instance buffer (same as drawCells does).
520 try self.uploadInstances(instance_data);
521
522 // 2. Begin command buffer, transition offscreen image UNDEFINED → COLOR_ATTACHMENT_OPTIMAL
523 const cmd = self.capture_cmd; // new: a dedicated command buffer allocated alongside the swapchain cmd buffer
524 try self.vkd.resetCommandBuffer(cmd, .{});
525 try self.vkd.beginCommandBuffer(cmd, &.{ .flags = .{ .one_time_submit_bit = true } });
526
527 self.transitionImage(cmd, target.image, .undefined, .color_attachment_optimal,
528 .{}, .{ .color_attachment_write_bit = true },
529 .{ .top_of_pipe_bit = true }, .{ .color_attachment_output_bit = true });
530
531 // 3. Begin the same render pass as swapchain but with the offscreen framebuffer + extent
532 const clear: vk.ClearValue = .{ .color = .{ .float_32 = .{ 0, 0, 0, 1 } } };
533 self.vkd.cmdBeginRenderPass(cmd, &vk.RenderPassBeginInfo{
534 .render_pass = self.render_pass,
535 .framebuffer = target.framebuffer,
536 .render_area = .{ .offset = .{ .x = 0, .y = 0 }, .extent = .{ .width = target.width, .height = target.height } },
537 .clear_value_count = 1,
538 .p_clear_values = @ptrCast(&clear),
539 }, .@"inline");
540
541 self.recordDrawCommands(cmd, .{ .width = target.width, .height = target.height }, @intCast(instance_data.len), push);
542
543 self.vkd.cmdEndRenderPass(cmd);
544
545 // 4. Transition image COLOR_ATTACHMENT_OPTIMAL → TRANSFER_SRC_OPTIMAL
546 self.transitionImage(cmd, target.image, .color_attachment_optimal, .transfer_src_optimal,
547 .{ .color_attachment_write_bit = true }, .{ .transfer_read_bit = true },
548 .{ .color_attachment_output_bit = true }, .{ .transfer_bit = true });
549
550 // 5. Copy image → readback buffer
551 const region = vk.BufferImageCopy{
552 .buffer_offset = 0,
553 .buffer_row_length = 0,
554 .buffer_image_height = 0,
555 .image_subresource = .{
556 .aspect_mask = .{ .color_bit = true },
557 .mip_level = 0,
558 .base_array_layer = 0,
559 .layer_count = 1,
560 },
561 .image_offset = .{ .x = 0, .y = 0, .z = 0 },
562 .image_extent = .{ .width = target.width, .height = target.height, .depth = 1 },
563 };
564 self.vkd.cmdCopyImageToBuffer(cmd, target.image, .transfer_src_optimal, target.readback_buffer, 1, @ptrCast(&region));
565
566 try self.vkd.endCommandBuffer(cmd);
567
568 // 6. Submit and fence-wait
569 try self.vkd.resetFences(self.device, 1, @ptrCast(&self.capture_fence));
570 try self.vkd.queueSubmit(self.graphics_queue, 1, @ptrCast(&vk.SubmitInfo{
571 .command_buffer_count = 1,
572 .p_command_buffers = @ptrCast(&cmd),
573 }), self.capture_fence);
574 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.capture_fence), .true, std.math.maxInt(u64));
575 }
576
577 pub fn readbackOffscreen(
578 self: *Context,
579 target: *const OffscreenTarget,
580 out_rgba: []u8,
581 ) !void {
582 std.debug.assert(out_rgba.len == target.width * target.height * 4);
583
584 const mapped = try self.vkd.mapMemory(self.device, target.readback_memory, 0, target.readback_size, .{});
585 defer self.vkd.unmapMemory(self.device, target.readback_memory);
586 const src = @as([*]const u8, @ptrCast(mapped.?))[0..target.readback_size];
587
588 // Convert BGRA → RGBA, force alpha to 0xFF (surface is opaque composite).
589 var i: usize = 0;
590 while (i < target.readback_size) : (i += 4) {
591 out_rgba[i + 0] = src[i + 2]; // R ← B
592 out_rgba[i + 1] = src[i + 1]; // G
593 out_rgba[i + 2] = src[i + 0]; // B ← R
594 out_rgba[i + 3] = 0xFF; // A
595 }
596 }
597 ```
598
599 You will need to:
600 - Add `capture_cmd: vk.CommandBuffer` and `capture_fence: vk.Fence` to `Context`, allocated in `Context.init` alongside the swapchain command buffer + fence.
601 - Extract a `recordDrawCommands(cmd, extent, instance_count, push)` helper from `drawCells` (bind pipeline, bind descriptor sets, bind vertex/instance buffers, set viewport/scissor to `extent`, cmdDrawInstanced). Call this helper from both `drawCells` (with swapchain extent) and `renderToOffscreen` (with offscreen extent).
602 - Add a `transitionImage(cmd, image, old_layout, new_layout, src_access, dst_access, src_stage, dst_stage)` helper if one doesn't already exist.
603 - Add an `uploadInstances(instances)` method if `drawCells` currently does this inline — otherwise call whatever existing upload function drawCells uses.
604
605 If the exact internal types differ (e.g. `CellInstance` is named `Instance`, `PushConstants` has a different name), use the real names.
606
607 - [ ] **Step 4: Verify it still compiles**
608
609 Run: `zig build 2>&1 | tail -40`
610 Expected: clean build. If there are errors, they will almost all be naming mismatches (fix them in `renderer.zig`).
611
612 - [ ] **Step 5: Commit**
613
614 ```bash
615 git add src/renderer.zig
616 git commit -m "$(cat <<'EOF'
617 Add offscreen render target + readback to renderer
618
619 New OffscreenTarget, createOffscreen/destroyOffscreen, renderToOffscreen,
620 and readbackOffscreen. Draws into a dedicated TRANSFER_SRC image,
621 copies to a host-visible buffer, returns RGBA pixels.
622 EOF
623 )"
624 ```
625
626 ---
627
628 ## Task 3: Extract frame-stats helpers to `src/bench_stats.zig`
629
630 `computeFrameStats` and `printFrameStats` currently live in `main.zig` and are private. The bench baseline tool needs them. Move them (and `FrameTimingRing`, `FrameTiming`, `SectionStats`, `FrameTimingStats`, `computeSectionStats`) into a new module so both `main.zig` and `src/tools/bench_baseline.zig` can import them.
631
632 **Files:**
633 - Create: `src/bench_stats.zig`
634 - Modify: `src/main.zig` — delete the moved code, import from the new module
635 - Modify: `build.zig` — add the bench_stats module
636
637 - [ ] **Step 1: Create the new module**
638
639 Create `src/bench_stats.zig` with the content currently at `main.zig:960-1070` (`FrameTimingRing`, `FrameTiming`, `SectionStats`, `FrameTimingStats`, `computeSectionStats`, `computeFrameStats`, `printFrameStats`). Make all of them `pub`. Also ensure the existing `FrameTiming` struct is moved (it lives near the ring buffer — grep for `const FrameTiming = struct` in main.zig to find it).
640
641 Run: `grep -n "^const FrameTiming \|^const FrameTimingRing\|^const SectionStats\|^const FrameTimingStats\|^fn computeSectionStats\|^fn computeFrameStats\|^fn printFrameStats" src/main.zig`
642 Expected: lines for each. Copy those definitions verbatim into `src/bench_stats.zig` and prefix with `pub `.
643
644 - [ ] **Step 2: Add bench_stats to build.zig**
645
646 In `build.zig`, add after the png module block (or near the other small modules):
647
648 ```zig
649 const bench_stats_mod = b.createModule(.{
650 .root_source_file = b.path("src/bench_stats.zig"),
651 .target = target,
652 .optimize = optimize,
653 });
654 exe_mod.addImport("bench_stats", bench_stats_mod);
655 main_test_mod.addImport("bench_stats", bench_stats_mod);
656
657 const bench_stats_test_mod = b.createModule(.{
658 .root_source_file = b.path("src/bench_stats.zig"),
659 .target = target,
660 .optimize = optimize,
661 });
662 const bench_stats_tests = b.addTest(.{ .root_module = bench_stats_test_mod });
663 test_step.dependOn(&b.addRunArtifact(bench_stats_tests).step);
664 ```
665
666 - [ ] **Step 3: Update main.zig to import from the new module**
667
668 Replace the now-moved local definitions in `src/main.zig` with:
669
670 ```zig
671 const bench_stats = @import("bench_stats");
672 const FrameTiming = bench_stats.FrameTiming;
673 const FrameTimingRing = bench_stats.FrameTimingRing;
674 const FrameTimingStats = bench_stats.FrameTimingStats;
675 const computeFrameStats = bench_stats.computeFrameStats;
676 const printFrameStats = bench_stats.printFrameStats;
677 ```
678
679 Delete the original definitions (the entire block from `const FrameTiming = struct` through `fn printFrameStats` — around main.zig:900-1070). Keep the existing tests in main.zig that reference these types; they will now reference the imported aliases.
680
681 - [ ] **Step 4: Run the tests to confirm nothing regressed**
682
683 Run: `zig build test 2>&1 | tail -40`
684 Expected: PASS (including the `FrameTimingRing` tests which now run under `bench_stats_tests` via their original `test` blocks — move those test blocks to `bench_stats.zig` if they were left in `main.zig`).
685
686 - [ ] **Step 5: Commit**
687
688 ```bash
689 git add src/bench_stats.zig src/main.zig build.zig
690 git commit -m "$(cat <<'EOF'
691 Extract frame timing stats into bench_stats module
692
693 Enables the bench baseline tool to reuse stat computation without
694 linking the full waystty binary. No behavior change.
695 EOF
696 )"
697 ```
698
699 ---
700
701 ## Task 4: Add JSON ser/de for `FrameTimingStats` in `bench_stats.zig`
702
703 Needed by `bench-baseline` and `bench-check`.
704
705 **Files:**
706 - Modify: `src/bench_stats.zig`
707
708 - [ ] **Step 1: Write the failing roundtrip test**
709
710 Append to `src/bench_stats.zig`:
711
712 ```zig
713 pub const BaselineRecord = struct {
714 workload_sha: []const u8,
715 zig_version: []const u8,
716 waystty_sha: []const u8,
717 frame_count: usize,
718 sections: struct {
719 snapshot: SectionStats,
720 row_rebuild: SectionStats,
721 atlas_upload: SectionStats,
722 instance_upload: SectionStats,
723 gpu_submit: SectionStats,
724 },
725 };
726
727 pub fn writeBaselineJson(alloc: std.mem.Allocator, rec: BaselineRecord, writer: anytype) !void {
728 _ = alloc;
729 _ = rec;
730 _ = writer;
731 return error.WriteFailed; // placeholder
732 }
733
734 pub fn readBaselineJson(alloc: std.mem.Allocator, bytes: []const u8) !BaselineRecord {
735 _ = alloc;
736 _ = bytes;
737 return error.InvalidJson; // placeholder
738 }
739
740 test "baseline JSON round-trip" {
741 const alloc = std.testing.allocator;
742 const rec = BaselineRecord{
743 .workload_sha = "abcdef",
744 .zig_version = "0.15.0",
745 .waystty_sha = "123abc",
746 .frame_count = 256,
747 .sections = .{
748 .snapshot = .{ .min = 1, .avg = 2, .p99 = 3, .max = 4 },
749 .row_rebuild = .{ .min = 10, .avg = 20, .p99 = 30, .max = 40 },
750 .atlas_upload = .{ .min = 0, .avg = 0, .p99 = 0, .max = 0 },
751 .instance_upload = .{ .min = 5, .avg = 6, .p99 = 7, .max = 8 },
752 .gpu_submit = .{ .min = 9, .avg = 9, .p99 = 9, .max = 9 },
753 },
754 };
755
756 var buf = std.ArrayList(u8).init(alloc);
757 defer buf.deinit();
758 try writeBaselineJson(alloc, rec, buf.writer());
759
760 var parsed = try readBaselineJson(alloc, buf.items);
761 defer {
762 alloc.free(parsed.workload_sha);
763 alloc.free(parsed.zig_version);
764 alloc.free(parsed.waystty_sha);
765 }
766
767 try std.testing.expectEqual(@as(usize, 256), parsed.frame_count);
768 try std.testing.expectEqual(@as(u32, 30), parsed.sections.row_rebuild.p99);
769 try std.testing.expectEqualStrings("abcdef", parsed.workload_sha);
770 }
771 ```
772
773 - [ ] **Step 2: Run to see the failure**
774
775 Run: `zig build test 2>&1 | grep -E "baseline|WriteFailed"`
776 Expected: failure due to placeholder.
777
778 - [ ] **Step 3: Implement using `std.json`**
779
780 Replace the two placeholders in `src/bench_stats.zig`:
781
782 ```zig
783 pub fn writeBaselineJson(alloc: std.mem.Allocator, rec: BaselineRecord, writer: anytype) !void {
784 _ = alloc;
785 try std.json.stringify(rec, .{ .whitespace = .indent_2 }, writer);
786 }
787
788 pub fn readBaselineJson(alloc: std.mem.Allocator, bytes: []const u8) !BaselineRecord {
789 // std.json.parseFromSlice returns a Parsed wrapper; we copy string fields so
790 // the returned record owns its memory independent of the arena.
791 var parsed = try std.json.parseFromSlice(BaselineRecord, alloc, bytes, .{});
792 defer parsed.deinit();
793 return .{
794 .workload_sha = try alloc.dupe(u8, parsed.value.workload_sha),
795 .zig_version = try alloc.dupe(u8, parsed.value.zig_version),
796 .waystty_sha = try alloc.dupe(u8, parsed.value.waystty_sha),
797 .frame_count = parsed.value.frame_count,
798 .sections = parsed.value.sections,
799 };
800 }
801 ```
802
803 If the exact `std.json.stringify` signature differs in current Zig 0.15+, substitute the equivalent call (the API occasionally shifts between minor versions).
804
805 - [ ] **Step 4: Run the test again**
806
807 Run: `zig build test 2>&1 | grep -E "baseline"`
808 Expected: PASS.
809
810 - [ ] **Step 5: Commit**
811
812 ```bash
813 git add src/bench_stats.zig
814 git commit -m "$(cat <<'EOF'
815 Add BaselineRecord + JSON round-trip for frame stats
816
817 Lays the foundation for bench-baseline / bench-check tooling.
818 EOF
819 )"
820 ```
821
822 ---
823
824 ## Task 5: Capture mode flow (`src/capture.zig`)
825
826 The module owns the `--capture` entry point: parse args, start the same subsystems as `runTerminal`, force fixed 80×24 at scale=1, wait for window visibility, play the script through the PTY, drain + settle, render one frame to an offscreen target, read it back, write a PNG.
827
828 Because this overlaps significantly with `runTerminal`, factor out any shared helpers you encounter (don't duplicate). Put anything that only capture mode cares about in the new module.
829
830 **Files:**
831 - Create: `src/capture.zig`
832 - Modify: `src/main.zig` — dispatch `--capture` to `capture.run`
833 - Modify: `build.zig` — add capture module
834
835 - [ ] **Step 1: Scaffold the module and wire up `--capture` dispatch**
836
837 Create `src/capture.zig`:
838
839 ```zig
840 const std = @import("std");
841 const vt = @import("vt");
842 const pty = @import("pty");
843 const wayland_client = @import("wayland-client");
844 const frame_loop_mod = @import("frame_loop");
845 const renderer = @import("renderer");
846 const font = @import("font");
847 const config = @import("config");
848 const png = @import("png");
849 const vk = @import("vulkan");
850
851 pub const CaptureError = error{
852 MissingArgs,
853 ScriptNotFound,
854 OutputPathUnwritable,
855 WindowNotVisible,
856 WindowSizeMismatch,
857 ReadbackTimeout,
858 PngEncodeFailed,
859 };
860
861 pub fn run(alloc: std.mem.Allocator, argv: []const [:0]const u8) !void {
862 if (argv.len < 3) {
863 std.debug.print("usage: waystty --capture <script.vt> <output.png>\n", .{});
864 return CaptureError.MissingArgs;
865 }
866 const script_path = argv[1];
867 const output_path = argv[2];
868
869 const script_bytes = std.fs.cwd().readFileAlloc(alloc, script_path, 16 * 1024 * 1024) catch |err| {
870 std.debug.print("capture: script not found: {s} ({s})\n", .{ script_path, @errorName(err) });
871 return CaptureError.ScriptNotFound;
872 };
873 defer alloc.free(script_bytes);
874
875 _ = output_path;
876 // Steps 2-6 fill in the rest.
877 return error.NotImplementedYet;
878 }
879 ```
880
881 In `src/main.zig`, add the dispatch near the other smoke-test branches (around line 94):
882
883 ```zig
884 if (args.len >= 2 and std.mem.eql(u8, args[1], "--capture")) {
885 const capture = @import("capture");
886 return capture.run(alloc, args[1..]);
887 }
888 ```
889
890 In `build.zig`, add (near the other modules):
891
892 ```zig
893 const capture_mod = b.createModule(.{
894 .root_source_file = b.path("src/capture.zig"),
895 .target = target,
896 .optimize = optimize,
897 .link_libc = true,
898 });
899 capture_mod.addImport("vt", vt_mod);
900 capture_mod.addImport("pty", pty_mod);
901 capture_mod.addImport("wayland-client", wayland_mod);
902 capture_mod.addImport("frame_loop", frame_loop_mod);
903 capture_mod.addImport("renderer", renderer_mod);
904 capture_mod.addImport("font", font_mod);
905 capture_mod.addImport("config", config_mod);
906 capture_mod.addImport("png", png_mod);
907 capture_mod.addImport("vulkan", vulkan_module);
908 exe_mod.addImport("capture", capture_mod);
909 ```
910
911 Confirm build still compiles:
912
913 Run: `zig build 2>&1 | tail -20`
914 Expected: clean build.
915
916 - [ ] **Step 2: Stand up the waystty subsystems (fixed 80×24, scale=1)**
917
918 Replace the body of `capture.run` in `src/capture.zig` with the subsystem wiring that matches `runTerminal` but with forced dimensions:
919
920 ```zig
921 pub fn run(alloc: std.mem.Allocator, argv: []const [:0]const u8) !void {
922 if (argv.len < 3) {
923 std.debug.print("usage: waystty --capture <script.vt> <output.png>\n", .{});
924 return CaptureError.MissingArgs;
925 }
926 const script_path = argv[1];
927 const output_path = argv[2];
928
929 const script_bytes = std.fs.cwd().readFileAlloc(alloc, script_path, 16 * 1024 * 1024) catch |err| {
930 std.debug.print("capture: script not found: {s} ({s})\n", .{ script_path, @errorName(err) });
931 return CaptureError.ScriptNotFound;
932 };
933 defer alloc.free(script_bytes);
934
935 // Font (identical to runTerminal — scale forced to 1)
936 var font_lookup = try font.lookupConfiguredFont(alloc);
937 defer font_lookup.deinit(alloc);
938 var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, config.font_size_px);
939 defer face.deinit();
940 const cell_w = face.cellWidth();
941 const cell_h = face.cellHeight();
942
943 // Fixed grid
944 const cols: u16 = 80;
945 const rows: u16 = 24;
946 const px_w: u32 = @as(u32, cols) * cell_w;
947 const px_h: u32 = @as(u32, rows) * cell_h;
948
949 // Wayland
950 const conn = try wayland_client.Connection.init(alloc);
951 defer conn.deinit();
952 const window = try conn.createWindow(alloc, "waystty-capture");
953 defer window.deinit();
954 window.width = px_w;
955 window.height = px_h;
956 _ = conn.display.roundtrip();
957
958 // Renderer
959 var ctx = try renderer.Context.init(
960 alloc,
961 @ptrCast(conn.display),
962 @ptrCast(window.surface),
963 px_w,
964 px_h,
965 );
966 defer ctx.deinit();
967
968 // Offscreen target
969 var offscreen = try renderer.createOffscreen(
970 ctx.vki, ctx.vkd, ctx.physical, ctx.device,
971 ctx.render_pass, ctx.surface_format, px_w, px_h,
972 );
973 defer renderer.destroyOffscreen(ctx.vkd, ctx.device, offscreen);
974
975 // Glyph atlas + ASCII warm
976 var atlas = try font.Atlas.init(alloc, 1024, 1024);
977 defer atlas.deinit();
978 for (32..127) |cp| {
979 _ = atlas.getOrInsert(&face, @intCast(cp)) catch |err| switch (err) {
980 error.AtlasFull => break,
981 else => return err,
982 };
983 }
984 try ctx.uploadAtlas(atlas.pixels);
985 atlas.last_uploaded_y = atlas.cursor_y;
986 atlas.needs_full_upload = false;
987 atlas.dirty = false;
988
989 // Terminal
990 var term = try vt.Terminal.init(alloc, .{
991 .cols = cols, .rows = rows, .max_scrollback = 100,
992 });
993 defer term.deinit();
994 term.setReportedSize(.{
995 .rows = rows, .columns = cols,
996 .cell_width = cell_w, .cell_height = cell_h,
997 });
998
999 _ = script_bytes; _ = output_path;
1000 // Steps 3-6 fill in the rest.
1001 }
1002 ```
1003
1004 Match the exact field names in `renderer.Context` — grep for `pub const Context` and note which fields are `pub` (you may need to make a few more public: `vki`, `vkd`, `physical`, `device`, `render_pass`, `surface_format`).
1005
1006 Run: `zig build 2>&1 | tail -30`
1007 Expected: clean build (unresolved field access will surface if any Context member isn't `pub`).
1008
1009 - [ ] **Step 3: Visibility wait with 3s timeout**
1010
1011 Add after the terminal init block in `capture.run`:
1012
1013 ```zig
1014 // Wait up to 3s for the compositor to configure + map the window.
1015 const deadline_ns = std.time.nanoTimestamp() + 3 * std.time.ns_per_s;
1016 while (std.time.nanoTimestamp() < deadline_ns) {
1017 _ = conn.display.roundtrip();
1018 if (window.isConfigured() and window.isVisible()) break;
1019 std.time.sleep(10 * std.time.ns_per_ms);
1020 }
1021 if (!window.isConfigured() or !window.isVisible()) {
1022 std.debug.print("capture: window not visible after 3s (compositor hidden window?)\n", .{});
1023 return CaptureError.WindowNotVisible;
1024 }
1025
1026 // Verify the compositor didn't resize us.
1027 if (window.width != px_w or window.height != px_h) {
1028 std.debug.print("capture: window size mismatch; expected {}x{} px, got {}x{}\n",
1029 .{ px_w, px_h, window.width, window.height });
1030 return CaptureError.WindowSizeMismatch;
1031 }
1032 ```
1033
1034 If `window.isConfigured()` and `window.isVisible()` don't exist, either add them to `wayland_client.Window` in `src/wayland.zig` (look for the existing configured/suspended flags on `SurfaceState`) or inline the check by poking at `window.surface_state.configured`.
1035
1036 Run: `zig build 2>&1 | tail -30`
1037 Expected: clean build.
1038
1039 - [ ] **Step 4: PTY playback + drain**
1040
1041 Append to `capture.run`:
1042
1043 ```zig
1044 // Spawn child: cat on the script bytes.
1045 // We feed via stdin pipe rather than cat <file> so we can ensure exact
1046 // timing of writes. The child is /bin/cat with no args reading from its
1047 // stdin; we write the script to the PTY master (which is cat's stdin).
1048 var p = try pty.Pty.spawn(.{
1049 .cols = cols, .rows = rows,
1050 .shell = "/bin/cat",
1051 .shell_args = null,
1052 });
1053 defer p.deinit();
1054
1055 _ = try p.write(script_bytes);
1056 // Send EOF by closing the write side. Pty doesn't expose a closeWrite —
1057 // instead we signal EOF by writing ^D (0x04). When the PTY is in cooked
1058 // mode cat will terminate on EOF. If cooked-mode termios isn't the
1059 // default in pty.zig, open pty.zig and confirm; alternatively, just
1060 // wait for cat to consume the bytes and let the outer drain loop catch it.
1061 _ = try p.write(&.{0x04});
1062
1063 // Drain: poll PTY until two consecutive 20ms polls return no data.
1064 var read_buf: [8192]u8 = undefined;
1065 var empty_ticks: u8 = 0;
1066 while (empty_ticks < 2) {
1067 var pfd = [_]std.posix.pollfd{
1068 .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 },
1069 };
1070 _ = std.posix.poll(&pfd, 20) catch 0;
1071 if (pfd[0].revents & std.posix.POLL.IN != 0) {
1072 const n = p.read(&read_buf) catch |err| switch (err) {
1073 error.WouldBlock, error.InputOutput => 0,
1074 else => return err,
1075 };
1076 if (n > 0) {
1077 term.write(read_buf[0..n]);
1078 empty_ticks = 0;
1079 continue;
1080 }
1081 }
1082 empty_ticks += 1;
1083 }
1084
1085 // Settle: one extra 50ms for the VT parser to finish.
1086 std.time.sleep(50 * std.time.ns_per_ms);
1087 ```
1088
1089 Run: `zig build 2>&1 | tail -30`
1090 Expected: clean build.
1091
1092 - [ ] **Step 5: Render to offscreen + readback + write PNG**
1093
1094 Append to `capture.run`:
1095
1096 ```zig
1097 // Build the instance list from the terminal snapshot. This mirrors
1098 // whatever runTerminal does just before drawCells. Extract a shared
1099 // helper if convenient; otherwise duplicate the small amount of code.
1100 var render_cache = @import("render_frame").RenderCache.empty;
1101 defer render_cache.deinit(alloc);
1102 try render_cache.resizeRows(alloc, rows);
1103
1104 var snap = try term.snapshot(alloc);
1105 defer snap.deinit(alloc);
1106
1107 // NOTE: the exact name/location of the instance-building function depends
1108 // on how main.zig is structured. If it's a free function in main.zig,
1109 // move it into a shared module (e.g. `src/render_frame.zig`) in this
1110 // step — capture.zig and main.zig both need it. Name it `buildInstances`.
1111 var instances = std.ArrayList(renderer.CellInstance).init(alloc);
1112 defer instances.deinit();
1113 try @import("render_frame").buildInstances(&instances, &snap, &atlas, &face, &render_cache);
1114
1115 const push = renderer.PushConstants{
1116 .viewport_size = .{ @floatFromInt(px_w), @floatFromInt(px_h) },
1117 .cell_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
1118 .coverage_params = .{ 1.0, 1.0 }, // inherit default
1119 };
1120
1121 // If the atlas was dirtied by getOrInsert calls during snapshot, upload it.
1122 if (atlas.dirty) {
1123 try ctx.uploadAtlas(atlas.pixels);
1124 atlas.dirty = false;
1125 }
1126
1127 try ctx.renderToOffscreen(&offscreen, instances.items, push);
1128
1129 // Readback
1130 const rgba = try alloc.alloc(u8, px_w * px_h * 4);
1131 defer alloc.free(rgba);
1132 try ctx.readbackOffscreen(&offscreen, rgba);
1133
1134 // PNG write
1135 const out_file = std.fs.cwd().createFile(output_path, .{}) catch |err| {
1136 std.debug.print("capture: cannot write output: {s}: {s}\n", .{ output_path, @errorName(err) });
1137 return CaptureError.OutputPathUnwritable;
1138 };
1139 defer out_file.close();
1140 png.encode(alloc, .{ .width = px_w, .height = px_h, .pixels = rgba }, out_file.writer()) catch |err| {
1141 std.debug.print("capture: png encode failed: {s}\n", .{@errorName(err)});
1142 return CaptureError.PngEncodeFailed;
1143 };
1144
1145 std.debug.print("capture: wrote {s} ({}x{})\n", .{ output_path, px_w, px_h });
1146 ```
1147
1148 Note on the `render_frame` import: if `RenderCache` and the instance-building logic live inline in `main.zig` rather than in a reusable module, carve out `src/render_frame.zig` now containing both `pub const RenderCache = struct {...}` and `pub fn buildInstances(...)`. Import the new module from both `main.zig` and `capture.zig`, and add it to `build.zig` with `renderer_mod` as an import dependency. This is a necessary side-effect refactor, not gold-plating.
1149
1150 - [ ] **Step 6: End-to-end smoke — capture a one-line script**
1151
1152 Create a trivial script:
1153
1154 ```bash
1155 printf 'hello\r\n' > /tmp/capture_smoke.vt
1156 ```
1157
1158 Build and run:
1159
1160 ```bash
1161 zig build
1162 ./zig-out/bin/waystty --capture /tmp/capture_smoke.vt /tmp/capture_smoke.png
1163 ```
1164
1165 Expected: prints `capture: wrote /tmp/capture_smoke.png (WxH)`, and `/tmp/capture_smoke.png` exists.
1166
1167 ```bash
1168 file /tmp/capture_smoke.png
1169 ```
1170
1171 Expected: `PNG image data, 640 x 384, 8-bit/color RGBA, non-interlaced` (or whatever `80*cell_w × 24*cell_h` computes to on your font).
1172
1173 Open it in an image viewer and confirm "hello" is visible in the top-left.
1174
1175 If rendering is empty or garbled, investigate:
1176 - Did the atlas get uploaded after adding the glyphs?
1177 - Did `renderToOffscreen` actually execute the draw (check the command buffer reset + begin)?
1178 - Are `viewport_size` / `cell_size` push constants correct?
1179
1180 - [ ] **Step 7: Commit**
1181
1182 ```bash
1183 git add src/capture.zig src/main.zig build.zig src/wayland.zig src/render_frame.zig
1184 git commit -m "$(cat <<'EOF'
1185 Add --capture mode: render a VT script to PNG
1186
1187 Forces 80x24 grid at scale=1, waits for window visibility, plays
1188 script through PTY, drains + settles, renders to offscreen VkImage,
1189 reads back BGRA→RGBA, writes PNG.
1190 EOF
1191 )"
1192 ```
1193
1194 ---
1195
1196 ## Task 6: `imgdiff` tool (`src/tools/imgdiff.zig`)
1197
1198 Standalone executable: compare two RGBA PNGs; compute RMSE (normalized RGB) + per-pixel max; optionally write a side-by-side diff image.
1199
1200 **Files:**
1201 - Create: `src/tools/imgdiff.zig`
1202 - Modify: `build.zig` — add `imgdiff` executable + `b.step("imgdiff", ...)`
1203
1204 - [ ] **Step 1: Write the comparison function with unit tests first**
1205
1206 Create `src/tools/imgdiff.zig`:
1207
1208 ```zig
1209 const std = @import("std");
1210 const png = @import("png");
1211
1212 pub const DiffResult = struct {
1213 rmse: f64, // [0, 1]
1214 max_pixel: f64, // [0, 1]
1215 pixel_count: usize,
1216 };
1217
1218 pub fn compare(a: png.Image, b: png.Image) !DiffResult {
1219 if (a.width != b.width or a.height != b.height) return error.DimensionsDiffer;
1220 std.debug.assert(a.pixels.len == b.pixels.len);
1221
1222 const px_count = @as(usize, a.width) * a.height;
1223 var sum_sq: f64 = 0;
1224 var max_d: f64 = 0;
1225
1226 var i: usize = 0;
1227 while (i < px_count) : (i += 1) {
1228 const off = i * 4;
1229 const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0;
1230 const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0;
1231 const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0;
1232 const d_sq = (dr * dr + dg * dg + db * db) / 3.0;
1233 sum_sq += d_sq;
1234 const d = @sqrt(d_sq);
1235 if (d > max_d) max_d = d;
1236 }
1237
1238 return .{
1239 .rmse = @sqrt(sum_sq / @as(f64, @floatFromInt(px_count))),
1240 .max_pixel = max_d,
1241 .pixel_count = px_count,
1242 };
1243 }
1244
1245 test "identical images produce zero RMSE" {
1246 var pixels_a = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 };
1247 var pixels_b = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 };
1248 const a = png.Image{ .width = 2, .height = 1, .pixels = &pixels_a };
1249 const b = png.Image{ .width = 2, .height = 1, .pixels = &pixels_b };
1250 const r = try compare(a, b);
1251 try std.testing.expectEqual(@as(f64, 0.0), r.rmse);
1252 try std.testing.expectEqual(@as(f64, 0.0), r.max_pixel);
1253 }
1254
1255 test "fully saturated difference produces rmse=1.0 and max=1.0" {
1256 var pixels_a = [_]u8{ 0, 0, 0, 255 };
1257 var pixels_b = [_]u8{ 255, 255, 255, 255 };
1258 const a = png.Image{ .width = 1, .height = 1, .pixels = &pixels_a };
1259 const b = png.Image{ .width = 1, .height = 1, .pixels = &pixels_b };
1260 const r = try compare(a, b);
1261 try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.rmse, 1e-9);
1262 try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.max_pixel, 1e-9);
1263 }
1264 ```
1265
1266 - [ ] **Step 2: Add imgdiff to build.zig**
1267
1268 Add near the other executables:
1269
1270 ```zig
1271 const imgdiff_mod = b.createModule(.{
1272 .root_source_file = b.path("src/tools/imgdiff.zig"),
1273 .target = target,
1274 .optimize = optimize,
1275 });
1276 imgdiff_mod.addImport("png", png_mod);
1277 const imgdiff_exe = b.addExecutable(.{
1278 .name = "imgdiff",
1279 .root_module = imgdiff_mod,
1280 });
1281 b.installArtifact(imgdiff_exe);
1282
1283 const imgdiff_test_mod = b.createModule(.{
1284 .root_source_file = b.path("src/tools/imgdiff.zig"),
1285 .target = target,
1286 .optimize = optimize,
1287 });
1288 imgdiff_test_mod.addImport("png", png_mod);
1289 const imgdiff_tests = b.addTest(.{ .root_module = imgdiff_test_mod });
1290 test_step.dependOn(&b.addRunArtifact(imgdiff_tests).step);
1291 ```
1292
1293 - [ ] **Step 3: Run the compare-function tests**
1294
1295 Run: `zig build test 2>&1 | grep -E "imgdiff|rmse"`
1296 Expected: PASS.
1297
1298 - [ ] **Step 4: Add `main`, CLI arg handling, and diff-image output**
1299
1300 Append to `src/tools/imgdiff.zig`:
1301
1302 ```zig
1303 pub fn main() !void {
1304 var gpa: std.heap.DebugAllocator(.{}) = .init;
1305 defer _ = gpa.deinit();
1306 const alloc = gpa.allocator();
1307
1308 const args = try std.process.argsAlloc(alloc);
1309 defer std.process.argsFree(alloc, args);
1310
1311 if (args.len < 3) {
1312 std.debug.print("usage: imgdiff <actual.png> <reference.png> [diff.png]\n", .{});
1313 std.process.exit(2);
1314 }
1315 const actual_path = args[1];
1316 const reference_path = args[2];
1317 const diff_path: ?[]const u8 = if (args.len >= 4) args[3] else null;
1318
1319 const rmse_max = readFloatEnv("WAYSTTY_TEST_RMSE_MAX", 0.005);
1320 const pixel_max = readFloatEnv("WAYSTTY_TEST_PIXEL_MAX", 0.125);
1321
1322 const actual_bytes = try std.fs.cwd().readFileAlloc(alloc, actual_path, 64 * 1024 * 1024);
1323 defer alloc.free(actual_bytes);
1324 const reference_bytes = try std.fs.cwd().readFileAlloc(alloc, reference_path, 64 * 1024 * 1024);
1325 defer alloc.free(reference_bytes);
1326
1327 var actual = try png.decode(alloc, actual_bytes);
1328 defer actual.deinit(alloc);
1329 var reference = try png.decode(alloc, reference_bytes);
1330 defer reference.deinit(alloc);
1331
1332 if (actual.width != reference.width or actual.height != reference.height) {
1333 std.debug.print("FAIL: dimensions differ ({}x{} vs {}x{})\n",
1334 .{ actual.width, actual.height, reference.width, reference.height });
1335 std.process.exit(3);
1336 }
1337
1338 const r = try compare(actual, reference);
1339 const pass = r.rmse <= rmse_max and r.max_pixel <= pixel_max;
1340
1341 if (pass) {
1342 std.debug.print("OK: {s} RMSE={d:.4}% worst={d:.4}%\n",
1343 .{ reference_path, r.rmse * 100.0, r.max_pixel * 100.0 });
1344 std.process.exit(0);
1345 }
1346
1347 std.debug.print("FAIL: {s}\n RMSE: {d:.4}% (max {d:.4}%)\n worst pixel: {d:.4}% (max {d:.4}%)\n",
1348 .{ reference_path, r.rmse * 100.0, rmse_max * 100.0, r.max_pixel * 100.0, pixel_max * 100.0 });
1349
1350 if (diff_path) |p| {
1351 const diff_img = try makeDiffImage(alloc, actual, reference);
1352 defer alloc.free(diff_img.pixels);
1353 const out = try std.fs.cwd().createFile(p, .{});
1354 defer out.close();
1355 try png.encode(alloc, diff_img, out.writer());
1356 std.debug.print(" diff: {s}\n", .{p});
1357 }
1358 std.debug.print(" actual: {s}\n", .{actual_path});
1359 std.process.exit(1);
1360 }
1361
1362 fn readFloatEnv(name: []const u8, default: f64) f64 {
1363 const val = std.posix.getenv(name) orelse return default;
1364 return std.fmt.parseFloat(f64, val) catch default;
1365 }
1366
1367 fn makeDiffImage(alloc: std.mem.Allocator, a: png.Image, b: png.Image) !png.Image {
1368 // Side-by-side: [actual | reference | delta-heatmap]
1369 const w = a.width * 3;
1370 const h = a.height;
1371 const pixels = try alloc.alloc(u8, w * h * 4);
1372 var y: u32 = 0;
1373 while (y < h) : (y += 1) {
1374 const row_off = @as(usize, y) * w * 4;
1375 const a_off = @as(usize, y) * a.width * 4;
1376 // actual
1377 @memcpy(pixels[row_off .. row_off + a.width * 4], a.pixels[a_off .. a_off + a.width * 4]);
1378 // reference
1379 @memcpy(pixels[row_off + a.width * 4 .. row_off + 2 * a.width * 4], b.pixels[a_off .. a_off + a.width * 4]);
1380 // delta heatmap
1381 var x: u32 = 0;
1382 while (x < a.width) : (x += 1) {
1383 const off = a_off + x * 4;
1384 const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0;
1385 const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0;
1386 const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0;
1387 const d = @sqrt((dr * dr + dg * dg + db * db) / 3.0);
1388 const brightness: u8 = @intFromFloat(@min(255.0, d * 255.0 * 2.0)); // 2x gain for visibility
1389 const dst = row_off + 2 * a.width * 4 + x * 4;
1390 pixels[dst + 0] = brightness;
1391 pixels[dst + 1] = brightness;
1392 pixels[dst + 2] = brightness;
1393 pixels[dst + 3] = 255;
1394 }
1395 }
1396 return .{ .width = w, .height = h, .pixels = pixels };
1397 }
1398 ```
1399
1400 - [ ] **Step 5: Smoke-test the CLI**
1401
1402 ```bash
1403 zig build
1404 ./zig-out/bin/imgdiff /tmp/capture_smoke.png /tmp/capture_smoke.png
1405 ```
1406
1407 Expected: `OK: /tmp/capture_smoke.png RMSE=0.0000% worst=0.0000%` and exit code 0.
1408
1409 - [ ] **Step 6: Commit**
1410
1411 ```bash
1412 git add src/tools/imgdiff.zig build.zig
1413 git commit -m "$(cat <<'EOF'
1414 Add imgdiff: RMSE + per-pixel-max PNG comparison
1415
1416 Standalone tool reused by test-render. Thresholds overridable via
1417 WAYSTTY_TEST_RMSE_MAX and WAYSTTY_TEST_PIXEL_MAX.
1418 EOF
1419 )"
1420 ```
1421
1422 ---
1423
1424 ## Task 7: VT test scripts
1425
1426 Three initial scripts exercising distinct rendering features. All scripts must end with cursor-home (`\x1b[H`) for deterministic final state.
1427
1428 **Files:**
1429 - Create: `tests/golden/scripts/basic_ascii.vt`
1430 - Create: `tests/golden/scripts/bold_colors.vt`
1431 - Create: `tests/golden/scripts/box_drawing.vt`
1432
1433 - [ ] **Step 1: Write `basic_ascii.vt`**
1434
1435 Script: print the printable ASCII range (32–126) on a single line, then newline and cursor-home. A small Zig program is cleaner than hand-escaping bytes; but since these are just file contents, use shell:
1436
1437 ```bash
1438 mkdir -p tests/golden/scripts
1439 python3 -c '
1440 import sys
1441 sys.stdout.buffer.write(b"\x1b[2J\x1b[H") # clear + home
1442 sys.stdout.buffer.write(bytes(range(32, 127)))
1443 sys.stdout.buffer.write(b"\r\n")
1444 sys.stdout.buffer.write(b"\x1b[H") # home
1445 ' > tests/golden/scripts/basic_ascii.vt
1446 ```
1447
1448 (Python is a one-off author-time tool here; the generated `.vt` file is what's checked in.)
1449
1450 Confirm size: `wc -c tests/golden/scripts/basic_ascii.vt` should show ~107 bytes.
1451
1452 - [ ] **Step 2: Write `bold_colors.vt`**
1453
1454 ```bash
1455 python3 -c '
1456 import sys
1457 out = b"\x1b[2J\x1b[H"
1458 attrs = [
1459 (b"\x1b[0m", b"normal"),
1460 (b"\x1b[1m", b"bold"),
1461 (b"\x1b[2m", b"dim"),
1462 (b"\x1b[3m", b"italic"),
1463 (b"\x1b[4m", b"underline"),
1464 (b"\x1b[7m", b"reverse"),
1465 ]
1466 for esc, label in attrs:
1467 out += esc + label + b"\x1b[0m\r\n"
1468 for fg in range(30, 38):
1469 out += b"\x1b[" + str(fg).encode() + b"m" + b"fg%d " % fg
1470 out += b"\x1b[0m\r\n"
1471 for bg in range(40, 48):
1472 out += b"\x1b[" + str(bg).encode() + b"m" + b"bg%d " % bg
1473 out += b"\x1b[0m\r\n"
1474 out += b"\x1b[H"
1475 sys.stdout.buffer.write(out)
1476 ' > tests/golden/scripts/bold_colors.vt
1477 ```
1478
1479 - [ ] **Step 3: Write `box_drawing.vt`**
1480
1481 ```bash
1482 python3 -c '
1483 import sys
1484 out = b"\x1b[2J\x1b[H"
1485 # Box-drawing characters are U+2500..U+257F
1486 out += "\u250C" + "\u2500"*10 + "\u2510\r\n"
1487 for _ in range(3):
1488 out += "\u2502" + " " * 10 + "\u2502\r\n"
1489 out += "\u2514" + "\u2500"*10 + "\u2518\r\n"
1490 out += "\u2591\u2592\u2593\u2588 block chars\r\n"
1491 out += "\x1b[H"
1492 sys.stdout.buffer.write(out.encode("utf-8") if isinstance(out, str) else out)
1493 ' > tests/golden/scripts/box_drawing.vt
1494 ```
1495
1496 - [ ] **Step 4: Smoke-run each against capture**
1497
1498 ```bash
1499 zig build
1500 for s in tests/golden/scripts/*.vt; do
1501 ./zig-out/bin/waystty --capture "$s" "/tmp/$(basename "$s" .vt).png"
1502 done
1503 ```
1504
1505 Expected: each prints `capture: wrote ...` and a PNG is written. Open each PNG and eyeball it — this is the only time you'll visually inspect before setting goldens, so confirm:
1506 - `basic_ascii.png`: printable characters on the top line
1507 - `bold_colors.png`: visible bold/dim/italic/underline/reverse attribute rows + foreground/background color bars
1508 - `box_drawing.png`: a bordered box with block shades beneath
1509
1510 If any look wrong, fix the script (or investigate the renderer) before the next task.
1511
1512 - [ ] **Step 5: Commit the scripts**
1513
1514 ```bash
1515 git add tests/golden/scripts/
1516 git commit -m "$(cat <<'EOF'
1517 Add initial VT test scripts for render testing
1518
1519 basic_ascii, bold_colors, box_drawing — exercise distinct rendering
1520 paths. Each clears screen, emits content, returns cursor home.
1521 EOF
1522 )"
1523 ```
1524
1525 ---
1526
1527 ## Task 8: `test-render` orchestrator
1528
1529 Standalone tool that for each `.vt` in `tests/golden/scripts/`: runs `waystty --capture`, runs imgdiff against the corresponding `tests/golden/reference/*.png`, and summarizes pass/fail. Continues on failure. Non-zero exit if any fail.
1530
1531 **Files:**
1532 - Create: `src/tools/test_render.zig`
1533 - Modify: `build.zig` — add test_render executable + `b.step("test-render", ...)`
1534
1535 - [ ] **Step 1: Write `src/tools/test_render.zig`**
1536
1537 ```zig
1538 const std = @import("std");
1539
1540 pub fn main() !void {
1541 var gpa: std.heap.DebugAllocator(.{}) = .init;
1542 defer _ = gpa.deinit();
1543 const alloc = gpa.allocator();
1544
1545 const mode_update = blk: {
1546 const m = std.posix.getenv("WAYSTTY_GOLDEN_UPDATE") orelse break :blk false;
1547 break :blk std.mem.eql(u8, m, "1");
1548 };
1549
1550 try std.fs.cwd().makePath("tests/golden/output");
1551
1552 var scripts_dir = try std.fs.cwd().openDir("tests/golden/scripts", .{ .iterate = true });
1553 defer scripts_dir.close();
1554 var it = scripts_dir.iterate();
1555
1556 var passed: usize = 0;
1557 var failed: usize = 0;
1558
1559 while (try it.next()) |entry| {
1560 if (entry.kind != .file) continue;
1561 if (!std.mem.endsWith(u8, entry.name, ".vt")) continue;
1562
1563 const base = entry.name[0 .. entry.name.len - 3];
1564 const script_path = try std.fmt.allocPrint(alloc, "tests/golden/scripts/{s}.vt", .{base});
1565 defer alloc.free(script_path);
1566 const output_path = try std.fmt.allocPrint(alloc, "tests/golden/output/{s}.png", .{base});
1567 defer alloc.free(output_path);
1568 const reference_path = try std.fmt.allocPrint(alloc, "tests/golden/reference/{s}.png", .{base});
1569 defer alloc.free(reference_path);
1570 const diff_path = try std.fmt.allocPrint(alloc, "tests/golden/output/{s}.diff.png", .{base});
1571 defer alloc.free(diff_path);
1572
1573 // Run waystty --capture
1574 const cap = try std.process.Child.run(.{
1575 .allocator = alloc,
1576 .argv = &.{ "zig-out/bin/waystty", "--capture", script_path, output_path },
1577 });
1578 defer alloc.free(cap.stdout);
1579 defer alloc.free(cap.stderr);
1580 if (cap.term != .Exited or cap.term.Exited != 0) {
1581 std.debug.print("FAIL: {s}: capture exited with {}\n stderr: {s}\n",
1582 .{ base, cap.term, cap.stderr });
1583 failed += 1;
1584 continue;
1585 }
1586
1587 if (mode_update) {
1588 try std.fs.cwd().makePath("tests/golden/reference");
1589 try std.fs.cwd().copyFile(output_path, std.fs.cwd(), reference_path, .{});
1590 std.debug.print("UPDATED: {s}\n", .{base});
1591 passed += 1;
1592 continue;
1593 }
1594
1595 // Run imgdiff
1596 const dif = try std.process.Child.run(.{
1597 .allocator = alloc,
1598 .argv = &.{ "zig-out/bin/imgdiff", output_path, reference_path, diff_path },
1599 });
1600 defer alloc.free(dif.stdout);
1601 defer alloc.free(dif.stderr);
1602 std.debug.print("{s}", .{dif.stdout});
1603 if (dif.term == .Exited and dif.term.Exited == 0) {
1604 passed += 1;
1605 } else {
1606 failed += 1;
1607 }
1608 }
1609
1610 std.debug.print("\n=== test-render: {} passed, {} failed ===\n", .{ passed, failed });
1611 if (failed > 0) std.process.exit(1);
1612 }
1613 ```
1614
1615 - [ ] **Step 2: Wire it into build.zig**
1616
1617 Add near the other tool executables:
1618
1619 ```zig
1620 const test_render_mod = b.createModule(.{
1621 .root_source_file = b.path("src/tools/test_render.zig"),
1622 .target = target,
1623 .optimize = optimize,
1624 });
1625 const test_render_exe = b.addExecutable(.{
1626 .name = "test-render",
1627 .root_module = test_render_mod,
1628 });
1629 b.installArtifact(test_render_exe);
1630
1631 const test_render_step = b.step("test-render", "Run all golden VT scripts and diff against references");
1632 // Make sure waystty + imgdiff are built first
1633 test_render_step.dependOn(b.getInstallStep());
1634 const test_render_run = b.addRunArtifact(test_render_exe);
1635 test_render_run.step.dependOn(b.getInstallStep());
1636 test_render_step.dependOn(&test_render_run.step);
1637 ```
1638
1639 Confirm build: `zig build`
1640
1641 - [ ] **Step 3: Test the orchestrator failure path**
1642
1643 With no reference directory yet, `zig build test-render` should report 3 failures (missing reference PNGs → imgdiff fails on readFile).
1644
1645 Run: `zig build test-render 2>&1 | tail -10`
1646 Expected: summary shows 3 failed. Non-zero exit.
1647
1648 - [ ] **Step 4: Commit the orchestrator**
1649
1650 ```bash
1651 git add src/tools/test_render.zig build.zig
1652 git commit -m "$(cat <<'EOF'
1653 Add test-render orchestrator
1654
1655 Iterates tests/golden/scripts/*.vt, runs waystty --capture on each,
1656 compares with imgdiff against reference PNGs. Continues on failure.
1657 WAYSTTY_GOLDEN_UPDATE=1 copies output to reference instead.
1658 EOF
1659 )"
1660 ```
1661
1662 ---
1663
1664 ## Task 9: Generate and commit initial golden references
1665
1666 Run `test-render` in update mode to populate `tests/golden/reference/`, visually verify the results, commit.
1667
1668 **Files:**
1669 - Create: `tests/golden/reference/basic_ascii.png`
1670 - Create: `tests/golden/reference/bold_colors.png`
1671 - Create: `tests/golden/reference/box_drawing.png`
1672 - Modify: `.gitignore` — add `tests/golden/output/`
1673
1674 - [ ] **Step 1: Update `.gitignore`**
1675
1676 Append to `.gitignore`:
1677
1678 ```
1679 # test-render generated artifacts
1680 tests/golden/output/
1681 ```
1682
1683 - [ ] **Step 2: Generate references**
1684
1685 ```bash
1686 WAYSTTY_GOLDEN_UPDATE=1 zig build test-render
1687 ```
1688
1689 Expected: `UPDATED: basic_ascii`, `UPDATED: bold_colors`, `UPDATED: box_drawing`.
1690
1691 - [ ] **Step 3: Visually inspect each reference**
1692
1693 ```bash
1694 ls -l tests/golden/reference/
1695 xdg-open tests/golden/reference/basic_ascii.png
1696 xdg-open tests/golden/reference/bold_colors.png
1697 xdg-open tests/golden/reference/box_drawing.png
1698 ```
1699
1700 For each, confirm:
1701 - Text is crisp, no garbled glyphs
1702 - Colors match expectations (bold rows look bold, color bars show 8 distinct colors, etc.)
1703 - Box characters form actual boxes
1704
1705 If any look wrong, debug the renderer or the script before committing.
1706
1707 - [ ] **Step 4: Confirm steady-state passes**
1708
1709 ```bash
1710 zig build test-render
1711 ```
1712
1713 Expected: `3 passed, 0 failed`.
1714
1715 - [ ] **Step 5: Tune thresholds if needed**
1716
1717 Re-run `zig build test-render` several times to confirm stability (no flaky failures from subpixel jitter). If any fail, either (a) the output is non-deterministic (bug — investigate) or (b) loosen the thresholds. The two easy knobs:
1718
1719 ```bash
1720 WAYSTTY_TEST_RMSE_MAX=0.01 WAYSTTY_TEST_PIXEL_MAX=0.2 zig build test-render
1721 ```
1722
1723 If you needed to loosen, bake the new defaults into `src/tools/imgdiff.zig` (the `readFloatEnv("WAYSTTY_TEST_RMSE_MAX", 0.005)` call) and commit that change.
1724
1725 - [ ] **Step 6: Commit**
1726
1727 ```bash
1728 git add tests/golden/reference/ .gitignore
1729 git commit -m "$(cat <<'EOF'
1730 Commit initial golden reference PNGs
1731
1732 Generated from tests/golden/scripts/* and visually verified.
1733 Regenerate with: WAYSTTY_GOLDEN_UPDATE=1 zig build test-render
1734 EOF
1735 )"
1736 ```
1737
1738 ---
1739
1740 ## Task 10: `bench-baseline` and `bench-check`
1741
1742 Two modes of the same tool. Runs the existing `WAYSTTY_BENCH=1` workload by shelling out to waystty, parses the FrameTimingStats from `bench.log` (or from a new stdout JSON emission), writes/compares `tests/bench/baseline.json`.
1743
1744 Two design decisions here:
1745
1746 1. **How to get stats out of a waystty run:** current bench prints a human-readable table. Add a sidecar flag `WAYSTTY_BENCH_JSON=/path/to/file.json` that also dumps the `BaselineRecord` to disk on exit. Cleaner than parsing the text table.
1747
1748 2. **What sha to store:** workload_sha = `sha256(bench_script string literal in main.zig)`; waystty_sha = `git rev-parse HEAD`; zig_version = captured at compile-time via `@import("builtin").zig_version_string`.
1749
1750 **Files:**
1751 - Modify: `src/main.zig` — if `WAYSTTY_BENCH_JSON` is set, write `BaselineRecord` on exit
1752 - Create: `src/tools/bench_baseline.zig`
1753 - Modify: `build.zig` — add executable + build step
1754
1755 - [ ] **Step 1: Add the JSON dump to main.zig's bench exit path**
1756
1757 Find where `printFrameStats(stats)` is currently called in the bench-exit path in `main.zig` (grep for it). Right after that call, add:
1758
1759 ```zig
1760 if (std.posix.getenv("WAYSTTY_BENCH_JSON")) |path| {
1761 const bench_stats_mod = @import("bench_stats");
1762 const rec = bench_stats_mod.BaselineRecord{
1763 .workload_sha = &sha256Hex(alloc, bench_script orelse ""),
1764 .zig_version = @import("builtin").zig_version_string,
1765 .waystty_sha = gitHead(alloc) catch "unknown",
1766 .frame_count = stats.frame_count,
1767 .sections = .{
1768 .snapshot = stats.snapshot,
1769 .row_rebuild = stats.row_rebuild,
1770 .atlas_upload = stats.atlas_upload,
1771 .instance_upload = stats.instance_upload,
1772 .gpu_submit = stats.gpu_submit,
1773 },
1774 };
1775 const f = std.fs.cwd().createFile(path, .{}) catch |err| {
1776 std.log.warn("bench_json write failed: {s} ({s})", .{ path, @errorName(err) });
1777 return;
1778 };
1779 defer f.close();
1780 bench_stats_mod.writeBaselineJson(alloc, rec, f.writer()) catch |err| {
1781 std.log.warn("bench_json serialize failed: {s}", .{@errorName(err)});
1782 };
1783 }
1784 ```
1785
1786 Add helper functions at the bottom of `main.zig`:
1787
1788 ```zig
1789 fn sha256Hex(alloc: std.mem.Allocator, input: []const u8) [64]u8 {
1790 _ = alloc;
1791 var digest: [32]u8 = undefined;
1792 std.crypto.hash.sha2.Sha256.hash(input, &digest, .{});
1793 var hex: [64]u8 = undefined;
1794 _ = std.fmt.bufPrint(&hex, "{}", .{std.fmt.fmtSliceHexLower(&digest)}) catch unreachable;
1795 return hex;
1796 }
1797
1798 fn gitHead(alloc: std.mem.Allocator) ![]const u8 {
1799 const r = try std.process.Child.run(.{ .allocator = alloc, .argv = &.{ "git", "rev-parse", "HEAD" } });
1800 defer alloc.free(r.stdout);
1801 defer alloc.free(r.stderr);
1802 if (r.term != .Exited or r.term.Exited != 0) return "unknown";
1803 return std.mem.trim(u8, r.stdout, "\n \t");
1804 }
1805 ```
1806
1807 Note: `sha256Hex` returns a stack array; the `.workload_sha` field stores a slice. Adjust to `alloc.dupe` or use a scoped buffer.
1808
1809 Run: `zig build` then `WAYSTTY_BENCH=1 WAYSTTY_BENCH_JSON=/tmp/bench.json ./zig-out/bin/waystty 2>/tmp/bench.log`
1810 After the bench workload finishes, check `/tmp/bench.json` exists and contains valid JSON with non-zero `frame_count`.
1811
1812 - [ ] **Step 2: Write `bench_baseline.zig`**
1813
1814 Create `src/tools/bench_baseline.zig`:
1815
1816 ```zig
1817 const std = @import("std");
1818 const bench_stats = @import("bench_stats");
1819
1820 pub fn main() !void {
1821 var gpa: std.heap.DebugAllocator(.{}) = .init;
1822 defer _ = gpa.deinit();
1823 const alloc = gpa.allocator();
1824
1825 const args = try std.process.argsAlloc(alloc);
1826 defer std.process.argsFree(alloc, args);
1827
1828 const mode: enum { save, check } = if (args.len >= 2 and std.mem.eql(u8, args[1], "save"))
1829 .save
1830 else
1831 .check;
1832
1833 const baseline_path = "tests/bench/baseline.json";
1834 const tmp_json = "/tmp/waystty-bench-current.json";
1835
1836 try std.fs.cwd().makePath("tests/bench");
1837
1838 // Run waystty with WAYSTTY_BENCH=1 WAYSTTY_BENCH_JSON=<tmp>
1839 var env = try std.process.getEnvMap(alloc);
1840 defer env.deinit();
1841 try env.put("WAYSTTY_BENCH", "1");
1842 try env.put("WAYSTTY_BENCH_JSON", tmp_json);
1843
1844 const child = try std.process.Child.run(.{
1845 .allocator = alloc,
1846 .argv = &.{"zig-out/bin/waystty"},
1847 .env_map = &env,
1848 });
1849 defer alloc.free(child.stdout);
1850 defer alloc.free(child.stderr);
1851
1852 const current_bytes = std.fs.cwd().readFileAlloc(alloc, tmp_json, 16 * 1024) catch |err| {
1853 std.debug.print("bench: no JSON output at {s}: {s}\n", .{ tmp_json, @errorName(err) });
1854 std.process.exit(2);
1855 };
1856 defer alloc.free(current_bytes);
1857 var current = try bench_stats.readBaselineJson(alloc, current_bytes);
1858 defer {
1859 alloc.free(current.workload_sha);
1860 alloc.free(current.zig_version);
1861 alloc.free(current.waystty_sha);
1862 }
1863
1864 if (mode == .save) {
1865 const out = try std.fs.cwd().createFile(baseline_path, .{});
1866 defer out.close();
1867 try bench_stats.writeBaselineJson(alloc, current, out.writer());
1868 std.debug.print("bench: wrote {s}\n", .{baseline_path});
1869 return;
1870 }
1871
1872 // check mode
1873 const baseline_bytes = std.fs.cwd().readFileAlloc(alloc, baseline_path, 16 * 1024) catch |err| {
1874 std.debug.print("bench: no baseline at {s}: {s}\n run: zig build bench-baseline\n", .{ baseline_path, @errorName(err) });
1875 std.process.exit(2);
1876 };
1877 defer alloc.free(baseline_bytes);
1878 var baseline = try bench_stats.readBaselineJson(alloc, baseline_bytes);
1879 defer {
1880 alloc.free(baseline.workload_sha);
1881 alloc.free(baseline.zig_version);
1882 alloc.free(baseline.waystty_sha);
1883 }
1884
1885 if (!std.mem.eql(u8, baseline.workload_sha, current.workload_sha)) {
1886 std.debug.print("WARN: bench script changed since baseline; consider regenerating\n", .{});
1887 }
1888
1889 const pct = blk: {
1890 const v = std.posix.getenv("WAYSTTY_BENCH_REGRESSION_PCT") orelse break :blk 20.0;
1891 break :blk std.fmt.parseFloat(f64, v) catch 20.0;
1892 };
1893
1894 var regressed = false;
1895 const sections = [_][]const u8{ "snapshot", "row_rebuild", "atlas_upload", "instance_upload", "gpu_submit" };
1896 inline for (sections, 0..) |name, i| {
1897 _ = i;
1898 const base_p99 = @field(baseline.sections, name).p99;
1899 const cur_p99 = @field(current.sections, name).p99;
1900 const delta_pct = if (base_p99 == 0) 0.0
1901 else ((@as(f64, @floatFromInt(cur_p99)) - @as(f64, @floatFromInt(base_p99))) / @as(f64, @floatFromInt(base_p99))) * 100.0;
1902 const status = if (delta_pct > pct) "REGRESSION" else "OK";
1903 if (delta_pct > pct) regressed = true;
1904 std.debug.print("bench: {s:<16} p99 {d:>5}us (baseline {d:>5}us) {d:+6.1}% {s}\n",
1905 .{ name, cur_p99, base_p99, delta_pct, status });
1906 }
1907
1908 if (regressed) std.process.exit(1);
1909 }
1910 ```
1911
1912 Note the `inline for (sections, 0..)` with string field access: Zig's `@field` works on compile-time-known field names, which `sections` provides. If `inline for` with a string-array approach doesn't compile in your Zig version, unroll the five section comparisons explicitly.
1913
1914 - [ ] **Step 3: Wire into build.zig**
1915
1916 ```zig
1917 const bench_baseline_mod = b.createModule(.{
1918 .root_source_file = b.path("src/tools/bench_baseline.zig"),
1919 .target = target,
1920 .optimize = optimize,
1921 });
1922 bench_baseline_mod.addImport("bench_stats", bench_stats_mod);
1923 const bench_baseline_exe = b.addExecutable(.{
1924 .name = "bench-baseline",
1925 .root_module = bench_baseline_mod,
1926 });
1927 b.installArtifact(bench_baseline_exe);
1928
1929 const bench_baseline_step = b.step("bench-baseline", "Save current frame-timing profile to tests/bench/baseline.json");
1930 const bench_baseline_run = b.addRunArtifact(bench_baseline_exe);
1931 bench_baseline_run.addArg("save");
1932 bench_baseline_run.step.dependOn(b.getInstallStep());
1933 bench_baseline_step.dependOn(&bench_baseline_run.step);
1934
1935 const bench_check_step = b.step("bench-check", "Compare current frame timings against baseline");
1936 const bench_check_run = b.addRunArtifact(bench_baseline_exe);
1937 bench_check_run.addArg("check");
1938 bench_check_run.step.dependOn(b.getInstallStep());
1939 bench_check_step.dependOn(&bench_check_run.step);
1940 ```
1941
1942 - [ ] **Step 4: Generate the initial baseline**
1943
1944 ```bash
1945 zig build bench-baseline
1946 cat tests/bench/baseline.json
1947 ```
1948
1949 Expected: `tests/bench/baseline.json` exists with non-zero `frame_count` and five section entries.
1950
1951 - [ ] **Step 5: Sanity-check `bench-check`**
1952
1953 ```bash
1954 zig build bench-check
1955 ```
1956
1957 Expected: all sections print with small percentage deltas; overall `REGRESSION` status only if run-to-run variance exceeds 20%. On a quiet machine this should pass cleanly.
1958
1959 Run it three times in a row to confirm stability. If it flakes, raise the default threshold or investigate a genuine noise source.
1960
1961 - [ ] **Step 6: Commit**
1962
1963 ```bash
1964 git add tests/bench/baseline.json src/main.zig src/tools/bench_baseline.zig build.zig
1965 git commit -m "$(cat <<'EOF'
1966 Add bench-baseline and bench-check
1967
1968 Baseline stores workload/zig/waystty SHA + per-section p99. Check
1969 compares against baseline, flags sections exceeding 20% p99 growth.
1970 Threshold overridable via WAYSTTY_BENCH_REGRESSION_PCT.
1971 EOF
1972 )"
1973 ```
1974
1975 ---
1976
1977 ## Task 11: Makefile targets + housekeeping
1978
1979 Bring the new tools under the familiar `make` UX, gitignore stray test binaries at repo root.
1980
1981 **Files:**
1982 - Modify: `Makefile`
1983 - Modify: `.gitignore`
1984
1985 - [ ] **Step 1: Add Makefile targets**
1986
1987 Replace `Makefile` with:
1988
1989 ```makefile
1990 ZIG ?= zig
1991 FLAMEGRAPH ?= flamegraph.pl
1992 STACKCOLLAPSE ?= stackcollapse-perf.pl
1993
1994 .PHONY: build run test bench profile clean test-render golden-update bench-baseline bench-check
1995
1996 build:
1997 $(ZIG) build
1998
1999 run: build
2000 $(ZIG) build run
2001
2002 test:
2003 $(ZIG) build test
2004
2005 zig-out/bin/waystty: $(wildcard src/*.zig) $(wildcard src/tools/*.zig) $(wildcard shaders/*)
2006 $(ZIG) build
2007
2008 bench: zig-out/bin/waystty
2009 WAYSTTY_BENCH=1 ./zig-out/bin/waystty 2>bench.log || true
2010 @echo "--- frame timing ---"
2011 @grep -A 12 "waystty frame timing" bench.log || echo "(no timing data found)"
2012
2013 profile:
2014 $(ZIG) build -Doptimize=ReleaseSafe
2015 perf record -g -F 999 --no-inherit -o perf.data -- \
2016 sh -c 'WAYSTTY_BENCH=1 ./zig-out/bin/waystty 2>bench.log'
2017 perf script -i perf.data \
2018 | $(STACKCOLLAPSE) \
2019 | $(FLAMEGRAPH) > flamegraph.svg
2020 @echo "--- frame timing ---"
2021 @grep -A 12 "waystty frame timing" bench.log || echo "(no timing data found)"
2022 xdg-open flamegraph.svg
2023
2024 test-render:
2025 $(ZIG) build test-render
2026
2027 golden-update:
2028 WAYSTTY_GOLDEN_UPDATE=1 $(ZIG) build test-render
2029
2030 bench-baseline:
2031 $(ZIG) build bench-baseline
2032
2033 bench-check:
2034 $(ZIG) build bench-check
2035
2036 clean:
2037 rm -rf zig-out .zig-cache perf.data bench.log flamegraph.svg tests/golden/output
2038 ```
2039
2040 - [ ] **Step 2: Verify each target runs**
2041
2042 ```bash
2043 make test-render
2044 make bench-check
2045 ```
2046
2047 Expected: both exit 0.
2048
2049 - [ ] **Step 3: Update `.gitignore` for stray binaries**
2050
2051 The working tree has untracked `test_io`, `test_io2`, `test_io3`, `test_sig`, `test_timer` binaries at repo root. They appear to be scratch compilation outputs. Append to `.gitignore`:
2052
2053 ```
2054 # Scratch test binaries (ad-hoc compilations)
2055 /test_io
2056 /test_io2
2057 /test_io3
2058 /test_sig
2059 /test_timer
2060 ```
2061
2062 Confirm they no longer appear in `git status`.
2063
2064 - [ ] **Step 4: Final commit**
2065
2066 ```bash
2067 git add Makefile .gitignore
2068 git commit -m "$(cat <<'EOF'
2069 Add make targets for render + bench tests
2070
2071 make test-render, golden-update, bench-baseline, bench-check.
2072 Gitignore stray test_* scratch binaries at repo root.
2073 EOF
2074 )"
2075 ```
2076
2077 ---
2078
2079 ## Verification
2080
2081 After all tasks complete:
2082
2083 ```bash
2084 make test # existing unit tests still pass
2085 make test-render # 3 passed, 0 failed
2086 make bench-check # all sections OK vs baseline
2087 ```
2088
2089 All three should exit 0.
2090
2091 ## Self-Review Notes
2092
2093 - **Spec coverage:** every spec section maps to a task. Capture mode → Task 5; imgdiff → Task 6; scripts → Task 7; orchestrator + goldens → Tasks 8-9; bench baseline/check → Task 10; Makefile → Task 11. Offscreen render target (critical spec fix) → Task 2. PNG codec dependency → Task 1.
2094 - **Placeholders:** none. Every code step contains the actual code. Any "refactor if needed" notes (like the `renderer.zig` helper extraction in Task 2) are concrete — they identify what to extract and why.
2095 - **Types consistent:** `FrameTimingStats`, `BaselineRecord`, `OffscreenTarget`, `Image`, `DiffResult` appear with consistent signatures across the tasks that use them.
2096 - **Known API uncertainty:** `std.compress.flate` and `std.json.stringify` signatures occasionally drift between Zig minor versions. Tasks 1 and 4 call this out explicitly so the implementer substitutes the current Zig 0.15+ equivalent rather than copy-pasting blindly.