a73x

df6b166c

main: add scenario entry point + runtime callbacks

a73x   2026-04-19 13:33

Commit message
main: add scenario entry point + runtime callbacks

--scenario <path> spins up a full waystty runtime (wayland +
vulkan + real main loop with blink timer), plays the scenario
file through scenario.tick, captures offscreen frames on
capture directives, diffs against golden PNGs, and exits with
a documented code (0/2/3/4/5/6).

runTerminal now branches on `tick_ctx`: when non-null, the pty
is opened via Pty.openForScenario (no shell fork), an
OffscreenTarget is created, and a ScenarioState is built on
top of the terminal's real cell geometry. The RunContext the
caller passed in is populated with stable pointers to that
frame's term/ctx/atlas/face. The main loop tick hook lands
right before the render_pending bail: blink-flip bookkeeping
is forwarded as a one-shot flag, then scenario.tick runs and
maps its outcome to window.should_close.

src/scenario_runtime.zig owns the TickIO callback
implementations: write_bytes -> term.write, capture ->
snapshot + offscreen render + PNG + out/<scenario>/<label>.png
+ golden diff + 3-panel heatmap on mismatch, blink_just_flipped
-> per-iter flag toggled by the main loop. Golden update mode
(WAYSTTY_SCENARIO_UPDATE=1) rewrites goldens and skips diff.

Exit code mapping: ScenarioTimeout/SleepUntilFlipTimeout -> 3,
AssertFailed/AssertCellWithoutCapture/PredicateOnMissingLabel
-> 4, CallbackFailed/OOM -> 6. Capture mismatches (regressions)
exit 4 unless update mode is active.

First fixture lands in the next task.

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

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 }