df6b166c
main: add scenario entry point + runtime callbacks
a73x 2026-04-19 13:33
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -391,13 +391,19 @@ pub fn build(b: *std.Build) void { | |||
| 391 | scenario_mod.addImport("png", png_mod); | 391 | scenario_mod.addImport("png", png_mod); |
| 392 | scenario_mod.addImport("imgdiff", imgdiff_lib_mod); | 392 | scenario_mod.addImport("imgdiff", imgdiff_lib_mod); |
| 393 | 393 | ||
| 394 | // scenario_runtime stub module (Task 3 populates) | 394 | // scenario_runtime — full TickIO callback impls, golden PNG IO, exit code mapping |
| 395 | const scenario_runtime_mod = b.createModule(.{ | 395 | const scenario_runtime_mod = b.createModule(.{ |
| 396 | .root_source_file = b.path("src/scenario_runtime.zig"), | 396 | .root_source_file = b.path("src/scenario_runtime.zig"), |
| 397 | .target = target, | 397 | .target = target, |
| 398 | .optimize = optimize, | 398 | .optimize = optimize, |
| 399 | }); | 399 | }); |
| 400 | scenario_runtime_mod.addImport("scenario", scenario_mod); | 400 | scenario_runtime_mod.addImport("scenario", scenario_mod); |
| 401 | scenario_runtime_mod.addImport("png", png_mod); | ||
| 402 | scenario_runtime_mod.addImport("imgdiff", imgdiff_lib_mod); | ||
| 403 | scenario_runtime_mod.addImport("renderer", renderer_mod); | ||
| 404 | scenario_runtime_mod.addImport("vt", vt_mod); | ||
| 405 | scenario_runtime_mod.addImport("font", font_mod); | ||
| 406 | scenario_runtime_mod.addImport("cell_instance", cell_instance_mod); | ||
| 401 | 407 | ||
| 402 | exe_mod.addImport("scenario", scenario_mod); | 408 | exe_mod.addImport("scenario", scenario_mod); |
| 403 | exe_mod.addImport("scenario_runtime", scenario_runtime_mod); | 409 | exe_mod.addImport("scenario_runtime", scenario_runtime_mod); |
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -179,9 +179,97 @@ pub fn main() !void { | |||
| 179 | return capture.run(alloc, args[1..]); | 179 | return capture.run(alloc, args[1..]); |
| 180 | } | 180 | } |
| 181 | 181 | ||
| 182 | if (args.len >= 2 and std.mem.eql(u8, args[1], "--scenario")) { | ||
| 183 | return runScenarios(alloc, args[1..]); | ||
| 184 | } | ||
| 185 | |||
| 182 | return runTerminal(alloc, null); | 186 | return runTerminal(alloc, null); |
| 183 | } | 187 | } |
| 184 | 188 | ||
| 189 | fn basenameNoExt(path: []const u8) []const u8 { | ||
| 190 | const base = std.fs.path.basename(path); | ||
| 191 | const dot = std.mem.lastIndexOfScalar(u8, base, '.') orelse return base; | ||
| 192 | return base[0..dot]; | ||
| 193 | } | ||
| 194 | |||
| 195 | /// `--scenario <path>` entry point. Parses the scenario file, builds a | ||
| 196 | /// RunContext, and calls runTerminal with it. runTerminal brings up the | ||
| 197 | /// full wayland + vulkan + blink-aware main loop; the scenario's TickIO | ||
| 198 | /// callbacks drive bytes through term.write, captures through the | ||
| 199 | /// offscreen target, and sleep-until-flip through the blink phase flag. | ||
| 200 | /// | ||
| 201 | /// Exit codes: | ||
| 202 | /// 0 — all captures match goldens, no tick errors | ||
| 203 | /// 2 — scenario file read or parse error | ||
| 204 | /// 3 — scenario wall-clock timeout OR sleep-until-flip timeout | ||
| 205 | /// 4 — one or more captures mismatched (unless WAYSTTY_SCENARIO_UPDATE=1) | ||
| 206 | /// 5 — Vulkan-side timeout (mapped from CallbackFailed conservatively) | ||
| 207 | /// 6 — other runtime error | ||
| 208 | pub fn runScenarios(alloc: std.mem.Allocator, argv: []const [:0]const u8) !void { | ||
| 209 | if (argv.len < 2) { | ||
| 210 | std.debug.print("usage: waystty --scenario <path>\n", .{}); | ||
| 211 | std.process.exit(@intFromEnum(scenario_runtime.ExitCode.parse_error)); | ||
| 212 | } | ||
| 213 | const scenario_path = argv[1]; | ||
| 214 | |||
| 215 | const source = std.fs.cwd().readFileAlloc(alloc, scenario_path, 1 * 1024 * 1024) catch |err| { | ||
| 216 | std.debug.print("scenario: cannot read {s}: {s}\n", .{ scenario_path, @errorName(err) }); | ||
| 217 | std.process.exit(@intFromEnum(scenario_runtime.ExitCode.parse_error)); | ||
| 218 | }; | ||
| 219 | defer alloc.free(source); | ||
| 220 | |||
| 221 | var diag: scenario.Diagnostic = .{}; | ||
| 222 | var parsed = scenario.parse(alloc, source, &diag) catch { | ||
| 223 | std.debug.print("scenario: {s}:{d}: {s}\n", .{ scenario_path, diag.line, diag.message }); | ||
| 224 | std.process.exit(@intFromEnum(scenario_runtime.ExitCode.parse_error)); | ||
| 225 | }; | ||
| 226 | defer parsed.deinit(); | ||
| 227 | |||
| 228 | const scenario_name = basenameNoExt(scenario_path); | ||
| 229 | const update = std.posix.getenv("WAYSTTY_SCENARIO_UPDATE") != null; | ||
| 230 | |||
| 231 | var rc: scenario_runtime.RunContext = .{ | ||
| 232 | .alloc = alloc, | ||
| 233 | .scenario_name = scenario_name, | ||
| 234 | .update_goldens = update, | ||
| 235 | .scenario = &parsed, | ||
| 236 | }; | ||
| 237 | defer rc.deinit(); | ||
| 238 | |||
| 239 | runTerminal(alloc, &rc) catch |err| { | ||
| 240 | std.debug.print("scenario {s}: runtime error: {s}\n", .{ scenario_name, @errorName(err) }); | ||
| 241 | std.process.exit(@intFromEnum(scenario_runtime.ExitCode.other_error)); | ||
| 242 | }; | ||
| 243 | |||
| 244 | if (rc.tick_fatal) |err| { | ||
| 245 | const code = scenario_runtime.mapTickError(err); | ||
| 246 | std.debug.print("scenario {s}: {s}\n", .{ scenario_name, @errorName(err) }); | ||
| 247 | std.process.exit(@intFromEnum(code)); | ||
| 248 | } | ||
| 249 | |||
| 250 | if (rc.failures.items.len > 0) { | ||
| 251 | std.debug.print("scenario {s}: {d} failure(s):\n", .{ scenario_name, rc.failures.items.len }); | ||
| 252 | for (rc.failures.items) |f| switch (f) { | ||
| 253 | .capture_mismatch => |m| std.debug.print( | ||
| 254 | " capture {s}: RMSE={d:.4}% max={d:.4}%\n", | ||
| 255 | .{ m.label, m.rmse * 100.0, m.max_pixel * 100.0 }, | ||
| 256 | ), | ||
| 257 | .missing_golden => |m| std.debug.print( | ||
| 258 | " capture {s}: golden missing (run with WAYSTTY_SCENARIO_UPDATE=1 to create)\n", | ||
| 259 | .{m.label}, | ||
| 260 | ), | ||
| 261 | .size_mismatch => |m| std.debug.print( | ||
| 262 | " capture {s}: size mismatch vs golden\n", | ||
| 263 | .{m.label}, | ||
| 264 | ), | ||
| 265 | }; | ||
| 266 | if (!update) std.process.exit(@intFromEnum(scenario_runtime.ExitCode.assertion_mismatch)); | ||
| 267 | } | ||
| 268 | |||
| 269 | std.debug.print("scenario {s}: OK\n", .{scenario_name}); | ||
| 270 | std.process.exit(0); | ||
| 271 | } | ||
| 272 | |||
| 185 | fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext) !void { | 273 | fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext) !void { |
| 186 | // === font first, to know cell size === | 274 | // === font first, to know cell size === |
| 187 | var font_lookup = try font.lookupConfiguredFont(alloc); | 275 | var font_lookup = try font.lookupConfiguredFont(alloc); |
| @@ -309,12 +397,15 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext | |||
| 309 | } | 397 | } |
| 310 | } | 398 | } |
| 311 | 399 | ||
| 312 | var p = try pty.Pty.spawn(.{ | 400 | var p = if (tick_ctx) |_| |
| 313 | .cols = cols, | 401 | try pty.Pty.openForScenario(cols, rows) |
| 314 | .rows = rows, | 402 | else |
| 315 | .shell = shell, | 403 | try pty.Pty.spawn(.{ |
| 316 | .shell_args = if (bench_script) |script| &.{ "-c", script } else null, | 404 | .cols = cols, |
| 317 | }); | 405 | .rows = rows, |
| 406 | .shell = shell, | ||
| 407 | .shell_args = if (bench_script) |script| &.{ "-c", script } else null, | ||
| 408 | }); | ||
| 318 | defer p.deinit(); | 409 | defer p.deinit(); |
| 319 | term.setWritePtyCallback(&p, &writePtyFromTerminal); | 410 | term.setWritePtyCallback(&p, &writePtyFromTerminal); |
| 320 | 411 | ||
| @@ -336,6 +427,50 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext | |||
| 336 | window.frame_loop = &frame_loop; | 427 | window.frame_loop = &frame_loop; |
| 337 | defer window.frame_loop = null; | 428 | defer window.frame_loop = null; |
| 338 | 429 | ||
| 430 | // === scenario runtime setup === | ||
| 431 | // In scenario mode, stand up a persistent offscreen render target and a | ||
| 432 | // ScenarioState built against the real cell geometry. The state and | ||
| 433 | // offscreen are stack-local here so their lifetime equals runTerminal's | ||
| 434 | // frame; RunContext only holds pointers into this frame. | ||
| 435 | var scenario_offscreen: ?renderer.OffscreenTarget = null; | ||
| 436 | var scenario_state: ?scenario.ScenarioState = null; | ||
| 437 | defer if (scenario_offscreen) |*t| renderer.destroyOffscreen(ctx.vkd, ctx.device, t.*); | ||
| 438 | defer if (scenario_state) |*s| s.deinit(); | ||
| 439 | |||
| 440 | if (tick_ctx) |rc| { | ||
| 441 | const sc = rc.scenario; | ||
| 442 | const px_w: u32 = @as(u32, sc.cols) * cell_w; | ||
| 443 | const px_h: u32 = @as(u32, sc.rows) * cell_h; | ||
| 444 | scenario_offscreen = try renderer.createOffscreen( | ||
| 445 | ctx.vki, | ||
| 446 | ctx.vkd, | ||
| 447 | ctx.physical_device, | ||
| 448 | ctx.device, | ||
| 449 | ctx.render_pass, | ||
| 450 | ctx.swapchain_format, | ||
| 451 | px_w, | ||
| 452 | px_h, | ||
| 453 | ); | ||
| 454 | rc.term = term; | ||
| 455 | rc.ctx = &ctx; | ||
| 456 | rc.offscreen = &scenario_offscreen.?; | ||
| 457 | rc.face = &face; | ||
| 458 | rc.atlas = &atlas; | ||
| 459 | rc.cell_w = cell_w; | ||
| 460 | rc.cell_h = cell_h; | ||
| 461 | rc.baseline = baseline; | ||
| 462 | rc.px_w = px_w; | ||
| 463 | rc.px_h = px_h; | ||
| 464 | |||
| 465 | scenario_state = scenario.ScenarioState.init( | ||
| 466 | alloc, | ||
| 467 | sc, | ||
| 468 | std.time.nanoTimestamp(), | ||
| 469 | .{ .cell_w_px = cell_w, .cell_h_px = cell_h }, | ||
| 470 | ); | ||
| 471 | rc.state = &scenario_state.?; | ||
| 472 | } | ||
| 473 | |||
| 339 | // === main loop === | 474 | // === main loop === |
| 340 | var pollfds_extra = [_]std.posix.pollfd{ | 475 | var pollfds_extra = [_]std.posix.pollfd{ |
| 341 | .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 }, | 476 | .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 }, |
| @@ -442,6 +577,33 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext | |||
| 442 | printFrameStats(computeFrameStats(&frame_ring)); | 577 | printFrameStats(computeFrameStats(&frame_ring)); |
| 443 | } | 578 | } |
| 444 | 579 | ||
| 580 | // --- scenario tick --- | ||
| 581 | // Placed after the blink phase flip (so a just-flipped blink | ||
| 582 | // informs io.blink_just_flipped on THIS tick) and before the | ||
| 583 | // render_pending bail (so a tick that emits bytes or a capture | ||
| 584 | // can still force a render this iteration). | ||
| 585 | if (tick_ctx) |rc| { | ||
| 586 | if (rc.state) |s| { | ||
| 587 | if (tick.flipped) rc.blink_flipped_this_iter = true; | ||
| 588 | |||
| 589 | const outcome = s.tick(std.time.nanoTimestamp(), scenario_runtime.tickIO(rc)) catch |err| { | ||
| 590 | rc.tick_fatal = err; | ||
| 591 | window.should_close = true; | ||
| 592 | break; | ||
| 593 | }; | ||
| 594 | switch (outcome) { | ||
| 595 | .working => { | ||
| 596 | // Keep the loop turning even if no other input fired. | ||
| 597 | render_pending = true; | ||
| 598 | }, | ||
| 599 | .done => { | ||
| 600 | window.should_close = true; | ||
| 601 | break; | ||
| 602 | }, | ||
| 603 | } | ||
| 604 | } | ||
| 605 | } | ||
| 606 | |||
| 445 | if (!render_pending) continue; | 607 | if (!render_pending) continue; |
| 446 | 608 | ||
| 447 | if (scale_pending) { | 609 | if (scale_pending) { |
src/scenario_runtime.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,322 @@ | |||
| 1 | // src/scenario_runtime.zig (STUB — Task 3 replaces) | 1 | //! Runtime glue for scenario mode. Owns the TickIO callback |
| 2 | //! implementations, golden-PNG IO, and result accumulation. | ||
| 3 | //! | ||
| 4 | //! Pure I/O — no main-loop code here. `src/main.zig` wires this | ||
| 5 | //! into `runTerminal` via a `RunContext` pointer the main loop | ||
| 6 | //! dereferences on each tick. | ||
| 7 | |||
| 8 | const std = @import("std"); | ||
| 9 | const png = @import("png"); | ||
| 2 | const scenario = @import("scenario"); | 10 | const scenario = @import("scenario"); |
| 11 | const imgdiff = @import("imgdiff"); | ||
| 12 | const renderer = @import("renderer"); | ||
| 13 | const vt = @import("vt"); | ||
| 14 | const font = @import("font"); | ||
| 15 | const cell_instance = @import("cell_instance"); | ||
| 16 | |||
| 17 | pub const Failure = union(enum) { | ||
| 18 | capture_mismatch: struct { label: []const u8, rmse: f64, max_pixel: f64 }, | ||
| 19 | missing_golden: struct { label: []const u8 }, | ||
| 20 | size_mismatch: struct { label: []const u8 }, | ||
| 21 | }; | ||
| 22 | |||
| 3 | pub const RunContext = struct { | 23 | pub const RunContext = struct { |
| 24 | alloc: std.mem.Allocator, | ||
| 25 | scenario_name: []const u8, // borrowed; lifetime >= RunContext | ||
| 26 | update_goldens: bool, // WAYSTTY_SCENARIO_UPDATE=1 → rewrite goldens, suppress mismatch | ||
| 27 | |||
| 28 | // Parsed scenario, supplied by runScenarios before runTerminal is called. | ||
| 29 | scenario: *const scenario.Scenario, | ||
| 30 | |||
| 31 | failures: std.ArrayListUnmanaged(Failure) = .{}, | ||
| 32 | blink_flipped_this_iter: bool = false, | ||
| 33 | |||
| 34 | // Populated by runTerminal once it has built the ScenarioState on top of | ||
| 35 | // its own terminal/renderer handles. The main loop tick hook reads this. | ||
| 4 | state: ?*scenario.ScenarioState = null, | 36 | state: ?*scenario.ScenarioState = null, |
| 37 | tick_fatal: ?scenario.TickError = null, | ||
| 38 | |||
| 39 | // Rendering handles needed by the capture callback. Populated by | ||
| 40 | // runTerminal once its font/atlas/terminal/renderer stack is up. | ||
| 41 | term: ?*vt.Terminal = null, | ||
| 42 | ctx: ?*renderer.Context = null, | ||
| 43 | offscreen: ?*renderer.OffscreenTarget = null, | ||
| 44 | face: ?*font.Face = null, | ||
| 45 | atlas: ?*font.Atlas = null, | ||
| 46 | cell_w: u32 = 0, | ||
| 47 | cell_h: u32 = 0, | ||
| 48 | baseline: u32 = 0, | ||
| 49 | px_w: u32 = 0, | ||
| 50 | px_h: u32 = 0, | ||
| 51 | |||
| 52 | pub fn deinit(self: *RunContext) void { | ||
| 53 | for (self.failures.items) |f| switch (f) { | ||
| 54 | .capture_mismatch => |m| self.alloc.free(m.label), | ||
| 55 | .missing_golden => |m| self.alloc.free(m.label), | ||
| 56 | .size_mismatch => |m| self.alloc.free(m.label), | ||
| 57 | }; | ||
| 58 | self.failures.deinit(self.alloc); | ||
| 59 | } | ||
| 60 | }; | ||
| 61 | |||
| 62 | pub fn writeBytesCb(ctx: *anyopaque, bytes: []const u8) anyerror!void { | ||
| 63 | const rc: *RunContext = @ptrCast(@alignCast(ctx)); | ||
| 64 | const term = rc.term orelse return error.RunContextNotPopulated; | ||
| 65 | term.write(bytes); | ||
| 66 | } | ||
| 67 | |||
| 68 | pub fn blinkFlippedCb(ctx: *anyopaque) bool { | ||
| 69 | const rc: *RunContext = @ptrCast(@alignCast(ctx)); | ||
| 70 | const v = rc.blink_flipped_this_iter; | ||
| 71 | rc.blink_flipped_this_iter = false; | ||
| 72 | return v; | ||
| 73 | } | ||
| 74 | |||
| 75 | pub fn tickIO(rc: *RunContext) scenario.TickIO { | ||
| 76 | return .{ | ||
| 77 | .ctx = rc, | ||
| 78 | .write_bytes = writeBytesCb, | ||
| 79 | .capture = captureCb, | ||
| 80 | .blink_just_flipped = blinkFlippedCb, | ||
| 81 | }; | ||
| 82 | } | ||
| 83 | |||
| 84 | pub const ExitCode = enum(u8) { | ||
| 85 | success = 0, | ||
| 86 | parse_error = 2, | ||
| 87 | timeout = 3, | ||
| 88 | assertion_mismatch = 4, | ||
| 89 | vulkan_timeout = 5, | ||
| 90 | other_error = 6, | ||
| 5 | }; | 91 | }; |
| 92 | |||
| 93 | pub fn mapTickError(err: scenario.TickError) ExitCode { | ||
| 94 | return switch (err) { | ||
| 95 | error.ScenarioTimeout => .timeout, | ||
| 96 | error.SleepUntilFlipTimeout => .timeout, | ||
| 97 | error.AssertFailed => .assertion_mismatch, | ||
| 98 | error.AssertCellWithoutCapture => .assertion_mismatch, | ||
| 99 | error.PredicateOnMissingLabel => .assertion_mismatch, | ||
| 100 | error.CallbackFailed => .other_error, | ||
| 101 | error.OutOfMemory => .other_error, | ||
| 102 | }; | ||
| 103 | } | ||
| 104 | |||
| 105 | /// Core of the runner. On each `capture` directive: | ||
| 106 | /// 1. Snapshot the terminal. | ||
| 107 | /// 2. Build a flat Instance list covering every cell. | ||
| 108 | /// 3. Render a single frame to the reusable offscreen target. | ||
| 109 | /// 4. Read back BGRA→RGBA pixels. | ||
| 110 | /// 5. Write the PNG to tests/scenarios/out/<scenario>/<label>.png. | ||
| 111 | /// 6. If update_goldens, also overwrite the golden and skip diff. | ||
| 112 | /// Otherwise: load golden, compare, append to rc.failures on mismatch | ||
| 113 | /// and dump a 3-panel heatmap alongside the output PNG. | ||
| 114 | /// 7. Return the captured image with ownership transferred to | ||
| 115 | /// ScenarioState (it keeps the most recent image per label for | ||
| 116 | /// assert-cell-at predicates). | ||
| 117 | pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image { | ||
| 118 | const rc: *RunContext = @ptrCast(@alignCast(ctx)); | ||
| 119 | const term = rc.term orelse return error.RunContextNotPopulated; | ||
| 120 | const rctx = rc.ctx orelse return error.RunContextNotPopulated; | ||
| 121 | const offscreen = rc.offscreen orelse return error.RunContextNotPopulated; | ||
| 122 | const face = rc.face orelse return error.RunContextNotPopulated; | ||
| 123 | const atlas = rc.atlas orelse return error.RunContextNotPopulated; | ||
| 124 | |||
| 125 | // --- render terminal state to offscreen --- | ||
| 126 | try term.snapshot(); | ||
| 127 | |||
| 128 | var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty; | ||
| 129 | defer instances.deinit(rc.alloc); | ||
| 130 | try buildInstancesForSnapshot( | ||
| 131 | rc.alloc, | ||
| 132 | &instances, | ||
| 133 | term, | ||
| 134 | face, | ||
| 135 | atlas, | ||
| 136 | rc.cell_w, | ||
| 137 | rc.cell_h, | ||
| 138 | rc.baseline, | ||
| 139 | ); | ||
| 140 | |||
| 141 | // If scenario bytes pulled in new glyphs, the atlas pixels are newer | ||
| 142 | // than the GPU copy. Re-upload before rendering. | ||
| 143 | if (atlas.dirty) { | ||
| 144 | try rctx.uploadAtlas(atlas.pixels); | ||
| 145 | atlas.dirty = false; | ||
| 146 | atlas.last_uploaded_y = atlas.cursor_y; | ||
| 147 | } | ||
| 148 | |||
| 149 | const push = renderer.PushConstants{ | ||
| 150 | .viewport_size = .{ @floatFromInt(rc.px_w), @floatFromInt(rc.px_h) }, | ||
| 151 | .cell_size = .{ @floatFromInt(rc.cell_w), @floatFromInt(rc.cell_h) }, | ||
| 152 | .coverage_params = renderer.coverageVariantParams(.baseline), | ||
| 153 | }; | ||
| 154 | try rctx.renderToOffscreen(offscreen, instances.items, push); | ||
| 155 | |||
| 156 | // --- readback --- | ||
| 157 | const pixels = try rc.alloc.alloc(u8, @as(usize, rc.px_w) * rc.px_h * 4); | ||
| 158 | errdefer rc.alloc.free(pixels); | ||
| 159 | try rctx.readbackOffscreen(offscreen, pixels); | ||
| 160 | |||
| 161 | const img: png.Image = .{ .width = rc.px_w, .height = rc.px_h, .pixels = pixels }; | ||
| 162 | |||
| 163 | // --- write out/<scenario>/<label>.png --- | ||
| 164 | const out_dir = try std.fmt.allocPrint( | ||
| 165 | rc.alloc, | ||
| 166 | "tests/scenarios/out/{s}", | ||
| 167 | .{rc.scenario_name}, | ||
| 168 | ); | ||
| 169 | defer rc.alloc.free(out_dir); | ||
| 170 | std.fs.cwd().makePath(out_dir) catch |err| switch (err) { | ||
| 171 | error.PathAlreadyExists => {}, | ||
| 172 | else => return err, | ||
| 173 | }; | ||
| 174 | |||
| 175 | const out_path = try std.fmt.allocPrint(rc.alloc, "{s}/{s}.png", .{ out_dir, label }); | ||
| 176 | defer rc.alloc.free(out_path); | ||
| 177 | |||
| 178 | var enc_buf: std.ArrayList(u8) = .empty; | ||
| 179 | defer enc_buf.deinit(rc.alloc); | ||
| 180 | try png.encode(rc.alloc, img, enc_buf.writer(rc.alloc)); | ||
| 181 | |||
| 182 | { | ||
| 183 | const out_file = try std.fs.cwd().createFile(out_path, .{ .truncate = true }); | ||
| 184 | defer out_file.close(); | ||
| 185 | try out_file.writeAll(enc_buf.items); | ||
| 186 | } | ||
| 187 | |||
| 188 | // --- golden handling --- | ||
| 189 | const golden_dir = try std.fmt.allocPrint( | ||
| 190 | rc.alloc, | ||
| 191 | "tests/scenarios/golden/{s}", | ||
| 192 | .{rc.scenario_name}, | ||
| 193 | ); | ||
| 194 | defer rc.alloc.free(golden_dir); | ||
| 195 | const golden_path = try std.fmt.allocPrint(rc.alloc, "{s}/{s}.png", .{ golden_dir, label }); | ||
| 196 | defer rc.alloc.free(golden_path); | ||
| 197 | |||
| 198 | if (rc.update_goldens) { | ||
| 199 | std.fs.cwd().makePath(golden_dir) catch |err| switch (err) { | ||
| 200 | error.PathAlreadyExists => {}, | ||
| 201 | else => return err, | ||
| 202 | }; | ||
| 203 | const g_file = try std.fs.cwd().createFile(golden_path, .{ .truncate = true }); | ||
| 204 | defer g_file.close(); | ||
| 205 | try g_file.writeAll(enc_buf.items); | ||
| 206 | // No diff in update mode — return the owned image. | ||
| 207 | return img; | ||
| 208 | } | ||
| 209 | |||
| 210 | // Read the golden; if missing, record a distinct failure. | ||
| 211 | const golden_bytes = std.fs.cwd().readFileAlloc(rc.alloc, golden_path, 64 * 1024 * 1024) catch |err| switch (err) { | ||
| 212 | error.FileNotFound => { | ||
| 213 | const lbl = try rc.alloc.dupe(u8, label); | ||
| 214 | errdefer rc.alloc.free(lbl); | ||
| 215 | try rc.failures.append(rc.alloc, .{ .missing_golden = .{ .label = lbl } }); | ||
| 216 | return img; | ||
| 217 | }, | ||
| 218 | else => return err, | ||
| 219 | }; | ||
| 220 | defer rc.alloc.free(golden_bytes); | ||
| 221 | |||
| 222 | var golden = try png.decode(rc.alloc, golden_bytes); | ||
| 223 | defer golden.deinit(rc.alloc); | ||
| 224 | |||
| 225 | if (golden.width != img.width or golden.height != img.height) { | ||
| 226 | const lbl = try rc.alloc.dupe(u8, label); | ||
| 227 | errdefer rc.alloc.free(lbl); | ||
| 228 | try rc.failures.append(rc.alloc, .{ .size_mismatch = .{ .label = lbl } }); | ||
| 229 | try writeDiffHeatmap(rc, out_dir, label, img, golden); | ||
| 230 | return img; | ||
| 231 | } | ||
| 232 | |||
| 233 | const diff = imgdiff.compare(img, golden) catch |err| return err; | ||
| 234 | if (diff.rmse > imgdiff.RMSE_DEFAULT or diff.max_pixel > imgdiff.PIXEL_MAX_DEFAULT) { | ||
| 235 | const lbl = try rc.alloc.dupe(u8, label); | ||
| 236 | errdefer rc.alloc.free(lbl); | ||
| 237 | try rc.failures.append(rc.alloc, .{ .capture_mismatch = .{ | ||
| 238 | .label = lbl, | ||
| 239 | .rmse = diff.rmse, | ||
| 240 | .max_pixel = diff.max_pixel, | ||
| 241 | } }); | ||
| 242 | try writeDiffHeatmap(rc, out_dir, label, img, golden); | ||
| 243 | } | ||
| 244 | |||
| 245 | return img; | ||
| 246 | } | ||
| 247 | |||
| 248 | /// Write a 3-panel heatmap (actual | golden | delta) next to the out PNG. | ||
| 249 | /// Non-fatal on error — the mismatch is already logged; failure to write the | ||
| 250 | /// heatmap should not lose that signal. | ||
| 251 | fn writeDiffHeatmap( | ||
| 252 | rc: *RunContext, | ||
| 253 | out_dir: []const u8, | ||
| 254 | label: []const u8, | ||
| 255 | actual: png.Image, | ||
| 256 | golden: png.Image, | ||
| 257 | ) !void { | ||
| 258 | // makeDiffImage only succeeds when dimensions match. Skip the heatmap | ||
| 259 | // on size_mismatch (the operator doesn't need a panel for this case). | ||
| 260 | if (actual.width != golden.width or actual.height != golden.height) return; | ||
| 261 | const heat = try imgdiff.makeDiffImage(rc.alloc, actual, golden); | ||
| 262 | defer rc.alloc.free(heat.pixels); | ||
| 263 | |||
| 264 | const heat_path = try std.fmt.allocPrint(rc.alloc, "{s}/{s}.diff.png", .{ out_dir, label }); | ||
| 265 | defer rc.alloc.free(heat_path); | ||
| 266 | |||
| 267 | var buf: std.ArrayList(u8) = .empty; | ||
| 268 | defer buf.deinit(rc.alloc); | ||
| 269 | try png.encode(rc.alloc, heat, buf.writer(rc.alloc)); | ||
| 270 | |||
| 271 | const file = try std.fs.cwd().createFile(heat_path, .{ .truncate = true }); | ||
| 272 | defer file.close(); | ||
| 273 | try file.writeAll(buf.items); | ||
| 274 | } | ||
| 275 | |||
| 276 | /// Mirrors capture.zig's buildInstancesForSnapshot (which is private to | ||
| 277 | /// that module). Walks every cell in the current snapshot and emits the | ||
| 278 | /// shared Instance list. No dirty-row tracking, no selection/cursor | ||
| 279 | /// overlay — this is a one-shot full rebuild, same as --capture. | ||
| 280 | fn buildInstancesForSnapshot( | ||
| 281 | alloc: std.mem.Allocator, | ||
| 282 | instances: *std.ArrayListUnmanaged(renderer.Instance), | ||
| 283 | term: *vt.Terminal, | ||
| 284 | face: *font.Face, | ||
| 285 | atlas: *font.Atlas, | ||
| 286 | cell_w: u32, | ||
| 287 | cell_h: u32, | ||
| 288 | baseline: u32, | ||
| 289 | ) !void { | ||
| 290 | const default_bg = term.backgroundColor(); | ||
| 291 | const bg_uv = atlas.cursorUV(); | ||
| 292 | |||
| 293 | const term_rows = term.render_state.row_data.items(.cells); | ||
| 294 | var row_idx: u32 = 0; | ||
| 295 | while (row_idx < term_rows.len) : (row_idx += 1) { | ||
| 296 | const row_cells = term_rows[row_idx]; | ||
| 297 | const raw_cells = row_cells.items(.raw); | ||
| 298 | var col_idx: u32 = 0; | ||
| 299 | while (col_idx < raw_cells.len) : (col_idx += 1) { | ||
| 300 | const cp = raw_cells[col_idx].codepoint(); | ||
| 301 | const colors = term.cellColors(row_cells.get(col_idx)); | ||
| 302 | const glyph_uv = if (cp == 0 or cp == ' ') | ||
| 303 | null | ||
| 304 | else | ||
| 305 | atlas.getOrInsert(face, @intCast(cp)) catch null; | ||
| 306 | |||
| 307 | try cell_instance.appendCellInstances( | ||
| 308 | alloc, | ||
| 309 | instances, | ||
| 310 | row_idx, | ||
| 311 | col_idx, | ||
| 312 | cell_w, | ||
| 313 | cell_h, | ||
| 314 | baseline, | ||
| 315 | glyph_uv, | ||
| 316 | bg_uv, | ||
| 317 | colors, | ||
| 318 | default_bg, | ||
| 319 | ); | ||
| 320 | } | ||
| 321 | } | ||
| 322 | } | ||