a73x

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