a73x

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,
            );
        }
    }
}