7b928983
Add GPU render testing implementation plan
a73x 2026-04-17 11:05
Commit message
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(®ion)); | ||
| 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. | ||