df6b166c
main: add scenario entry point + runtime callbacks
a73x 2026-04-19 13:33
--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>
diff --git a/build.zig b/build.zig index 467df22..48d7242 100644 --- a/build.zig +++ b/build.zig @@ -391,13 +391,19 @@ pub fn build(b: *std.Build) void { scenario_mod.addImport("png", png_mod); scenario_mod.addImport("imgdiff", imgdiff_lib_mod); // scenario_runtime stub module (Task 3 populates) // scenario_runtime — full TickIO callback impls, golden PNG IO, exit code mapping const scenario_runtime_mod = b.createModule(.{ .root_source_file = b.path("src/scenario_runtime.zig"), .target = target, .optimize = optimize, }); scenario_runtime_mod.addImport("scenario", scenario_mod); scenario_runtime_mod.addImport("png", png_mod); scenario_runtime_mod.addImport("imgdiff", imgdiff_lib_mod); scenario_runtime_mod.addImport("renderer", renderer_mod); scenario_runtime_mod.addImport("vt", vt_mod); scenario_runtime_mod.addImport("font", font_mod); scenario_runtime_mod.addImport("cell_instance", cell_instance_mod); exe_mod.addImport("scenario", scenario_mod); exe_mod.addImport("scenario_runtime", scenario_runtime_mod); diff --git a/src/main.zig b/src/main.zig index d69c6f0..e73dc0e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -179,9 +179,97 @@ pub fn main() !void { return capture.run(alloc, args[1..]); } if (args.len >= 2 and std.mem.eql(u8, args[1], "--scenario")) { return runScenarios(alloc, args[1..]); } return runTerminal(alloc, null); } fn basenameNoExt(path: []const u8) []const u8 { const base = std.fs.path.basename(path); const dot = std.mem.lastIndexOfScalar(u8, base, '.') orelse return base; return base[0..dot]; } /// `--scenario <path>` entry point. Parses the scenario file, builds a /// RunContext, and calls runTerminal with it. runTerminal brings up the /// full wayland + vulkan + blink-aware main loop; the scenario's TickIO /// callbacks drive bytes through term.write, captures through the /// offscreen target, and sleep-until-flip through the blink phase flag. /// /// Exit codes: /// 0 — all captures match goldens, no tick errors /// 2 — scenario file read or parse error /// 3 — scenario wall-clock timeout OR sleep-until-flip timeout /// 4 — one or more captures mismatched (unless WAYSTTY_SCENARIO_UPDATE=1) /// 5 — Vulkan-side timeout (mapped from CallbackFailed conservatively) /// 6 — other runtime error pub fn runScenarios(alloc: std.mem.Allocator, argv: []const [:0]const u8) !void { if (argv.len < 2) { std.debug.print("usage: waystty --scenario <path>\n", .{}); std.process.exit(@intFromEnum(scenario_runtime.ExitCode.parse_error)); } const scenario_path = argv[1]; const source = std.fs.cwd().readFileAlloc(alloc, scenario_path, 1 * 1024 * 1024) catch |err| { std.debug.print("scenario: cannot read {s}: {s}\n", .{ scenario_path, @errorName(err) }); std.process.exit(@intFromEnum(scenario_runtime.ExitCode.parse_error)); }; defer alloc.free(source); var diag: scenario.Diagnostic = .{}; var parsed = scenario.parse(alloc, source, &diag) catch { std.debug.print("scenario: {s}:{d}: {s}\n", .{ scenario_path, diag.line, diag.message }); std.process.exit(@intFromEnum(scenario_runtime.ExitCode.parse_error)); }; defer parsed.deinit(); const scenario_name = basenameNoExt(scenario_path); const update = std.posix.getenv("WAYSTTY_SCENARIO_UPDATE") != null; var rc: scenario_runtime.RunContext = .{ .alloc = alloc, .scenario_name = scenario_name, .update_goldens = update, .scenario = &parsed, }; defer rc.deinit(); runTerminal(alloc, &rc) catch |err| { std.debug.print("scenario {s}: runtime error: {s}\n", .{ scenario_name, @errorName(err) }); std.process.exit(@intFromEnum(scenario_runtime.ExitCode.other_error)); }; if (rc.tick_fatal) |err| { const code = scenario_runtime.mapTickError(err); std.debug.print("scenario {s}: {s}\n", .{ scenario_name, @errorName(err) }); std.process.exit(@intFromEnum(code)); } if (rc.failures.items.len > 0) { std.debug.print("scenario {s}: {d} failure(s):\n", .{ scenario_name, rc.failures.items.len }); for (rc.failures.items) |f| switch (f) { .capture_mismatch => |m| std.debug.print( " capture {s}: RMSE={d:.4}% max={d:.4}%\n", .{ m.label, m.rmse * 100.0, m.max_pixel * 100.0 }, ), .missing_golden => |m| std.debug.print( " capture {s}: golden missing (run with WAYSTTY_SCENARIO_UPDATE=1 to create)\n", .{m.label}, ), .size_mismatch => |m| std.debug.print( " capture {s}: size mismatch vs golden\n", .{m.label}, ), }; if (!update) std.process.exit(@intFromEnum(scenario_runtime.ExitCode.assertion_mismatch)); } std.debug.print("scenario {s}: OK\n", .{scenario_name}); std.process.exit(0); } fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext) !void { // === font first, to know cell size === var font_lookup = try font.lookupConfiguredFont(alloc); @@ -309,12 +397,15 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext } } var p = try pty.Pty.spawn(.{ .cols = cols, .rows = rows, .shell = shell, .shell_args = if (bench_script) |script| &.{ "-c", script } else null, }); var p = if (tick_ctx) |_| try pty.Pty.openForScenario(cols, rows) else try pty.Pty.spawn(.{ .cols = cols, .rows = rows, .shell = shell, .shell_args = if (bench_script) |script| &.{ "-c", script } else null, }); defer p.deinit(); term.setWritePtyCallback(&p, &writePtyFromTerminal); @@ -336,6 +427,50 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext window.frame_loop = &frame_loop; defer window.frame_loop = null; // === scenario runtime setup === // In scenario mode, stand up a persistent offscreen render target and a // ScenarioState built against the real cell geometry. The state and // offscreen are stack-local here so their lifetime equals runTerminal's // frame; RunContext only holds pointers into this frame. var scenario_offscreen: ?renderer.OffscreenTarget = null; var scenario_state: ?scenario.ScenarioState = null; defer if (scenario_offscreen) |*t| renderer.destroyOffscreen(ctx.vkd, ctx.device, t.*); defer if (scenario_state) |*s| s.deinit(); if (tick_ctx) |rc| { const sc = rc.scenario; const px_w: u32 = @as(u32, sc.cols) * cell_w; const px_h: u32 = @as(u32, sc.rows) * cell_h; scenario_offscreen = try renderer.createOffscreen( ctx.vki, ctx.vkd, ctx.physical_device, ctx.device, ctx.render_pass, ctx.swapchain_format, px_w, px_h, ); rc.term = term; rc.ctx = &ctx; rc.offscreen = &scenario_offscreen.?; rc.face = &face; rc.atlas = &atlas; rc.cell_w = cell_w; rc.cell_h = cell_h; rc.baseline = baseline; rc.px_w = px_w; rc.px_h = px_h; scenario_state = scenario.ScenarioState.init( alloc, sc, std.time.nanoTimestamp(), .{ .cell_w_px = cell_w, .cell_h_px = cell_h }, ); rc.state = &scenario_state.?; } // === main loop === var pollfds_extra = [_]std.posix.pollfd{ .{ .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 printFrameStats(computeFrameStats(&frame_ring)); } // --- scenario tick --- // Placed after the blink phase flip (so a just-flipped blink // informs io.blink_just_flipped on THIS tick) and before the // render_pending bail (so a tick that emits bytes or a capture // can still force a render this iteration). if (tick_ctx) |rc| { if (rc.state) |s| { if (tick.flipped) rc.blink_flipped_this_iter = true; const outcome = s.tick(std.time.nanoTimestamp(), scenario_runtime.tickIO(rc)) catch |err| { rc.tick_fatal = err; window.should_close = true; break; }; switch (outcome) { .working => { // Keep the loop turning even if no other input fired. render_pending = true; }, .done => { window.should_close = true; break; }, } } } if (!render_pending) continue; if (scale_pending) { diff --git a/src/scenario_runtime.zig b/src/scenario_runtime.zig index 18de389..4b3152f 100644 --- a/src/scenario_runtime.zig +++ b/src/scenario_runtime.zig @@ -1,5 +1,322 @@ // src/scenario_runtime.zig (STUB — Task 3 replaces) //! Runtime glue for scenario mode. Owns the TickIO callback //! implementations, golden-PNG IO, and result accumulation. //! //! Pure I/O — no main-loop code here. `src/main.zig` wires this //! into `runTerminal` via a `RunContext` pointer the main loop //! dereferences on each tick. const std = @import("std"); const png = @import("png"); const scenario = @import("scenario"); const imgdiff = @import("imgdiff"); const renderer = @import("renderer"); const vt = @import("vt"); const font = @import("font"); const cell_instance = @import("cell_instance"); pub const Failure = union(enum) { capture_mismatch: struct { label: []const u8, rmse: f64, max_pixel: f64 }, missing_golden: struct { label: []const u8 }, size_mismatch: struct { label: []const u8 }, }; pub const RunContext = struct { alloc: std.mem.Allocator, scenario_name: []const u8, // borrowed; lifetime >= RunContext update_goldens: bool, // WAYSTTY_SCENARIO_UPDATE=1 → rewrite goldens, suppress mismatch // Parsed scenario, supplied by runScenarios before runTerminal is called. scenario: *const scenario.Scenario, failures: std.ArrayListUnmanaged(Failure) = .{}, blink_flipped_this_iter: bool = false, // Populated by runTerminal once it has built the ScenarioState on top of // its own terminal/renderer handles. The main loop tick hook reads this. state: ?*scenario.ScenarioState = null, tick_fatal: ?scenario.TickError = null, // Rendering handles needed by the capture callback. Populated by // runTerminal once its font/atlas/terminal/renderer stack is up. term: ?*vt.Terminal = null, ctx: ?*renderer.Context = null, offscreen: ?*renderer.OffscreenTarget = null, face: ?*font.Face = null, atlas: ?*font.Atlas = null, cell_w: u32 = 0, cell_h: u32 = 0, baseline: u32 = 0, px_w: u32 = 0, px_h: u32 = 0, pub fn deinit(self: *RunContext) void { for (self.failures.items) |f| switch (f) { .capture_mismatch => |m| self.alloc.free(m.label), .missing_golden => |m| self.alloc.free(m.label), .size_mismatch => |m| self.alloc.free(m.label), }; self.failures.deinit(self.alloc); } }; pub fn writeBytesCb(ctx: *anyopaque, bytes: []const u8) anyerror!void { const rc: *RunContext = @ptrCast(@alignCast(ctx)); const term = rc.term orelse return error.RunContextNotPopulated; term.write(bytes); } pub fn blinkFlippedCb(ctx: *anyopaque) bool { const rc: *RunContext = @ptrCast(@alignCast(ctx)); const v = rc.blink_flipped_this_iter; rc.blink_flipped_this_iter = false; return v; } pub fn tickIO(rc: *RunContext) scenario.TickIO { return .{ .ctx = rc, .write_bytes = writeBytesCb, .capture = captureCb, .blink_just_flipped = blinkFlippedCb, }; } pub const ExitCode = enum(u8) { success = 0, parse_error = 2, timeout = 3, assertion_mismatch = 4, vulkan_timeout = 5, other_error = 6, }; pub fn mapTickError(err: scenario.TickError) ExitCode { return switch (err) { error.ScenarioTimeout => .timeout, error.SleepUntilFlipTimeout => .timeout, error.AssertFailed => .assertion_mismatch, error.AssertCellWithoutCapture => .assertion_mismatch, error.PredicateOnMissingLabel => .assertion_mismatch, error.CallbackFailed => .other_error, error.OutOfMemory => .other_error, }; } /// Core of the runner. On each `capture` directive: /// 1. Snapshot the terminal. /// 2. Build a flat Instance list covering every cell. /// 3. Render a single frame to the reusable offscreen target. /// 4. Read back BGRA→RGBA pixels. /// 5. Write the PNG to tests/scenarios/out/<scenario>/<label>.png. /// 6. If update_goldens, also overwrite the golden and skip diff. /// Otherwise: load golden, compare, append to rc.failures on mismatch /// and dump a 3-panel heatmap alongside the output PNG. /// 7. Return the captured image with ownership transferred to /// ScenarioState (it keeps the most recent image per label for /// assert-cell-at predicates). pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image { const rc: *RunContext = @ptrCast(@alignCast(ctx)); const term = rc.term orelse return error.RunContextNotPopulated; const rctx = rc.ctx orelse return error.RunContextNotPopulated; const offscreen = rc.offscreen orelse return error.RunContextNotPopulated; const face = rc.face orelse return error.RunContextNotPopulated; const atlas = rc.atlas orelse return error.RunContextNotPopulated; // --- render terminal state to offscreen --- try term.snapshot(); var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty; defer instances.deinit(rc.alloc); try buildInstancesForSnapshot( rc.alloc, &instances, term, face, atlas, rc.cell_w, rc.cell_h, rc.baseline, ); // If scenario bytes pulled in new glyphs, the atlas pixels are newer // than the GPU copy. Re-upload before rendering. if (atlas.dirty) { try rctx.uploadAtlas(atlas.pixels); atlas.dirty = false; atlas.last_uploaded_y = atlas.cursor_y; } const push = renderer.PushConstants{ .viewport_size = .{ @floatFromInt(rc.px_w), @floatFromInt(rc.px_h) }, .cell_size = .{ @floatFromInt(rc.cell_w), @floatFromInt(rc.cell_h) }, .coverage_params = renderer.coverageVariantParams(.baseline), }; try rctx.renderToOffscreen(offscreen, instances.items, push); // --- readback --- const pixels = try rc.alloc.alloc(u8, @as(usize, rc.px_w) * rc.px_h * 4); errdefer rc.alloc.free(pixels); try rctx.readbackOffscreen(offscreen, pixels); const img: png.Image = .{ .width = rc.px_w, .height = rc.px_h, .pixels = pixels }; // --- write out/<scenario>/<label>.png --- const out_dir = try std.fmt.allocPrint( rc.alloc, "tests/scenarios/out/{s}", .{rc.scenario_name}, ); defer rc.alloc.free(out_dir); std.fs.cwd().makePath(out_dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; const out_path = try std.fmt.allocPrint(rc.alloc, "{s}/{s}.png", .{ out_dir, label }); defer rc.alloc.free(out_path); var enc_buf: std.ArrayList(u8) = .empty; defer enc_buf.deinit(rc.alloc); try png.encode(rc.alloc, img, enc_buf.writer(rc.alloc)); { const out_file = try std.fs.cwd().createFile(out_path, .{ .truncate = true }); defer out_file.close(); try out_file.writeAll(enc_buf.items); } // --- golden handling --- const golden_dir = try std.fmt.allocPrint( rc.alloc, "tests/scenarios/golden/{s}", .{rc.scenario_name}, ); defer rc.alloc.free(golden_dir); const golden_path = try std.fmt.allocPrint(rc.alloc, "{s}/{s}.png", .{ golden_dir, label }); defer rc.alloc.free(golden_path); if (rc.update_goldens) { std.fs.cwd().makePath(golden_dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; const g_file = try std.fs.cwd().createFile(golden_path, .{ .truncate = true }); defer g_file.close(); try g_file.writeAll(enc_buf.items); // No diff in update mode — return the owned image. return img; } // Read the golden; if missing, record a distinct failure. const golden_bytes = std.fs.cwd().readFileAlloc(rc.alloc, golden_path, 64 * 1024 * 1024) catch |err| switch (err) { error.FileNotFound => { const lbl = try rc.alloc.dupe(u8, label); errdefer rc.alloc.free(lbl); try rc.failures.append(rc.alloc, .{ .missing_golden = .{ .label = lbl } }); return img; }, else => return err, }; defer rc.alloc.free(golden_bytes); var golden = try png.decode(rc.alloc, golden_bytes); defer golden.deinit(rc.alloc); if (golden.width != img.width or golden.height != img.height) { const lbl = try rc.alloc.dupe(u8, label); errdefer rc.alloc.free(lbl); try rc.failures.append(rc.alloc, .{ .size_mismatch = .{ .label = lbl } }); try writeDiffHeatmap(rc, out_dir, label, img, golden); return img; } const diff = imgdiff.compare(img, golden) catch |err| return err; if (diff.rmse > imgdiff.RMSE_DEFAULT or diff.max_pixel > imgdiff.PIXEL_MAX_DEFAULT) { const lbl = try rc.alloc.dupe(u8, label); errdefer rc.alloc.free(lbl); try rc.failures.append(rc.alloc, .{ .capture_mismatch = .{ .label = lbl, .rmse = diff.rmse, .max_pixel = diff.max_pixel, } }); try writeDiffHeatmap(rc, out_dir, label, img, golden); } return img; } /// Write a 3-panel heatmap (actual | golden | delta) next to the out PNG. /// Non-fatal on error — the mismatch is already logged; failure to write the /// heatmap should not lose that signal. fn writeDiffHeatmap( rc: *RunContext, out_dir: []const u8, label: []const u8, actual: png.Image, golden: png.Image, ) !void { // makeDiffImage only succeeds when dimensions match. Skip the heatmap // on size_mismatch (the operator doesn't need a panel for this case). if (actual.width != golden.width or actual.height != golden.height) return; const heat = try imgdiff.makeDiffImage(rc.alloc, actual, golden); defer rc.alloc.free(heat.pixels); const heat_path = try std.fmt.allocPrint(rc.alloc, "{s}/{s}.diff.png", .{ out_dir, label }); defer rc.alloc.free(heat_path); var buf: std.ArrayList(u8) = .empty; defer buf.deinit(rc.alloc); try png.encode(rc.alloc, heat, buf.writer(rc.alloc)); const file = try std.fs.cwd().createFile(heat_path, .{ .truncate = true }); defer file.close(); try file.writeAll(buf.items); } /// Mirrors capture.zig's buildInstancesForSnapshot (which is private to /// that module). Walks every cell in the current snapshot and emits the /// shared Instance list. No dirty-row tracking, no selection/cursor /// overlay — this is a one-shot full rebuild, same as --capture. fn buildInstancesForSnapshot( alloc: std.mem.Allocator, instances: *std.ArrayListUnmanaged(renderer.Instance), term: *vt.Terminal, face: *font.Face, atlas: *font.Atlas, cell_w: u32, cell_h: u32, baseline: u32, ) !void { const default_bg = term.backgroundColor(); const bg_uv = atlas.cursorUV(); const term_rows = term.render_state.row_data.items(.cells); var row_idx: u32 = 0; while (row_idx < term_rows.len) : (row_idx += 1) { const row_cells = term_rows[row_idx]; const raw_cells = row_cells.items(.raw); var col_idx: u32 = 0; while (col_idx < raw_cells.len) : (col_idx += 1) { const cp = raw_cells[col_idx].codepoint(); const colors = term.cellColors(row_cells.get(col_idx)); const glyph_uv = if (cp == 0 or cp == ' ') null else atlas.getOrInsert(face, @intCast(cp)) catch null; try cell_instance.appendCellInstances( alloc, instances, row_idx, col_idx, cell_w, cell_h, baseline, glyph_uv, bg_uv, colors, default_bg, ); } } }