src/scenario_runtime.zig
Ref: Size: 12.9 KiB
//! 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,
/// Current blink phase — true = cursor on, false = cursor off.
/// Updated by the main loop each tick before scenario.tick() fires.
blink_on: bool = true,
// 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,
/// Snapshotted at scenario-start from runTerminal's live geometry.
/// Never updated even if the compositor changes scale mid-scenario —
/// captures use the fixed offscreen target, so stale on-screen geom
/// is inert. If you later add a path that re-reads live geom inside
/// a capture, you MUST wire scale-change propagation here too.
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,
);
// --- cursor overlay ---
// Mirror shouldDrawCursor logic from main.zig: in scenario mode we
// always treat has_focus as true. Draw if visible, in-viewport, and
// (not blinking OR currently blink-on).
const cur = term.render_state.cursor;
const draw_cursor = cur.visible
and cur.viewport != null
and (!cur.blinking or rc.blink_on);
if (draw_cursor) {
const vp = cur.viewport.?;
const shape: renderer.CursorShape = switch (cur.visual_style) {
.block, .block_hollow => .block,
.underline => .underline,
.bar => .bar,
};
var inst = renderer.cursorInstance(
shape,
rc.cell_w,
rc.cell_h,
1, // scenario mode is always scale-1 in offscreen
atlas.cursorUV(),
);
inst.cell_pos = .{
@floatFromInt(vp.x),
@floatFromInt(vp.y),
};
try instances.append(rc.alloc, inst);
}
// 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);
try rctx.readbackOffscreen(offscreen, pixels);
const img: png.Image = .{ .width = rc.px_w, .height = rc.px_h, .pixels = pixels };
errdefer rc.alloc.free(img.pixels); // covers all subsequent error paths
// --- 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 = try imgdiff.compare(img, golden);
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,
);
}
}
}