a73x

docs/superpowers/plans/2026-04-19-scenario-parser-state.md

Ref:   Size: 41.7 KiB

# Scenario Parser + State Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build `src/scenario.zig` — the parser, state machine, and cell-region predicate evaluator for the scenario runner. Everything in this plan is **pure** (no Vulkan, no Wayland, no main loop). All side-effectful work (pty writes, offscreen renders) is represented as callbacks that tests mock and Plan 3 wires for real.

**Architecture:** Three independently-testable units in one file — `Scenario` (the parsed directive list), `ScenarioState` (the tick-driven execution state), and `evalPredicate` (cell-region assertions). Parser uses a hand-rolled line-based tokenizer. State machine is callback-driven so it has zero runtime dependencies beyond `std` + `imgdiff` + `png`.

**Tech Stack:** Zig 0.15, stdlib, `src/imgdiff.zig` (from Plan 1), `png` module.

**Reference spec:** `docs/superpowers/specs/2026-04-19-scenario-runner-design.md`

---

## Reference facts

- Base commit: `9fae352`. `src/imgdiff.zig` exists and is importable as `@import("imgdiff")` (Plan 1 already wired this).
- `png.Image` struct is `{ width: u32, height: u32, pixels: []u8 }` where pixels are BGRA or RGBA (4 bytes per pixel).
- Existing harness `src/capture.zig` forces `CAPTURE_COLS=80, CAPTURE_ROWS=24`. Scenario runner v1 pins to the same dimensions (spec says `size` is informational in v1).
- No existing scenario or similar scaffolding in the repo.
- Tests live inline in the same file (see `src/vk_sync.zig`, `src/imgdiff.zig` for the pattern).
- Scenario file format is defined in the spec's **Scenario file format** section. Review before implementing.

---

## File structure after this plan

- **`src/scenario.zig`** (NEW) — `Directive` enum, `Scenario` struct, `parse`, `ScenarioState`, `tick`, `isDone`, `evalPredicate`, inline tests.
- **`build.zig`** (MODIFIED) — new `scenario_mod` module import for inline tests; wired into `test_step`. No consumers yet (main.zig touches happen in Plan 3).

---

## Task 1: Directive types + parser

**Files:**
- Create: `src/scenario.zig`
- Modify: `build.zig` — wire `scenario_mod` as a module + its test target

**Goal:** `parse(alloc, source, *Diagnostic) !Scenario` produces a typed directive list from a scenario text buffer. Every directive from the spec is recognized. Parse errors are returned with line number and static message.

- [ ] **Step 1: Create `src/scenario.zig` with the types, parser signature, and failing tests.**

Full initial contents:

```zig
//! Scenario runner — parser, state machine, and cell predicate evaluator.
//!
//! All APIs in this file are pure. Side-effectful work (pty writes,
//! offscreen renders) is represented as callbacks that the caller
//! supplies. See docs/superpowers/specs/2026-04-19-scenario-runner-design.md.

const std = @import("std");
const png = @import("png");
const imgdiff = @import("imgdiff");

// ---------------------------------------------------------------
// Directive types
// ---------------------------------------------------------------

pub const Predicate = enum {
    cell_matches_golden,
    cursor_block_at,
    cursor_bar_at,
    cursor_underline_at,
    cell_empty,
};

pub const Directive = union(enum) {
    sleep: u64, // ms
    sleep_until_flip,
    bytes: []const u8, // owned by Scenario.arena
    capture: []const u8, // label, owned by Scenario.arena
    assert_cell: AssertCell,
    assert_cell_at: AssertCellAt,

    pub const AssertCell = struct {
        row: u16,
        col: u16,
        pred: Predicate,
    };
    pub const AssertCellAt = struct {
        label: []const u8, // owned by Scenario.arena
        row: u16,
        col: u16,
        pred: Predicate,
    };
};

pub const Scenario = struct {
    cols: u16,
    rows: u16,
    timeout_ms: u64,
    directives: []const Directive,

    // Owns directive payload slices.
    arena: std.heap.ArenaAllocator,

    pub fn deinit(self: *Scenario) void {
        self.arena.deinit();
    }
};

pub const Diagnostic = struct {
    line: usize = 0,
    message: []const u8 = "", // static string
};

pub const ParseError = error{ParseFailed} || std.mem.Allocator.Error;

pub fn parse(
    gpa: std.mem.Allocator,
    source: []const u8,
    diag: *Diagnostic,
) ParseError!Scenario {
    _ = gpa;
    _ = source;
    _ = diag;
    @compileError("parse: not yet implemented");
}

// ---------------------------------------------------------------
// Parser tests
// ---------------------------------------------------------------

fn parseOk(source: []const u8) !Scenario {
    var diag: Diagnostic = .{};
    return parse(std.testing.allocator, source, &diag) catch |err| {
        std.debug.print("unexpected parse error at line {}: {s}\n", .{ diag.line, diag.message });
        return err;
    };
}

fn parseErr(source: []const u8) !Diagnostic {
    var diag: Diagnostic = .{};
    const result = parse(std.testing.allocator, source, &diag);
    try std.testing.expectError(error.ParseFailed, result);
    return diag;
}

test "parse: minimum valid scenario" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 1000ms
    );
    defer s.deinit();
    try std.testing.expectEqual(@as(u16, 80), s.cols);
    try std.testing.expectEqual(@as(u16, 24), s.rows);
    try std.testing.expectEqual(@as(u64, 1000), s.timeout_ms);
    try std.testing.expectEqual(@as(usize, 0), s.directives.len);
}

test "parse: comments and blank lines ignored" {
    var s = try parseOk(
        \\# leading comment
        \\
        \\size 80 24
        \\   # indented comment
        \\timeout 500ms
        \\
    );
    defer s.deinit();
    try std.testing.expectEqual(@as(u16, 80), s.cols);
}

test "parse: missing size directive fails at line 1" {
    const diag = try parseErr(
        \\timeout 500ms
    );
    try std.testing.expectEqual(@as(usize, 1), diag.line);
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "size") != null);
}

test "parse: missing timeout directive fails" {
    const diag = try parseErr(
        \\size 80 24
    );
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "timeout") != null);
}

test "parse: unknown directive fails at its line" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 500ms
        \\what-is-this
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "unknown directive") != null);
}

test "parse: sleep directive" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\sleep 250ms
        \\sleep 1s
    );
    defer s.deinit();
    try std.testing.expectEqual(@as(usize, 2), s.directives.len);
    try std.testing.expectEqual(@as(u64, 250), s.directives[0].sleep);
    try std.testing.expectEqual(@as(u64, 1000), s.directives[1].sleep);
}

test "parse: sleep-until-flip directive" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\sleep-until-flip
    );
    defer s.deinit();
    try std.testing.expectEqual(@as(usize, 1), s.directives.len);
    try std.testing.expect(s.directives[0] == .sleep_until_flip);
}

test "parse: bytes directive — plain ASCII" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\bytes "hello"
    );
    defer s.deinit();
    try std.testing.expectEqualSlices(u8, "hello", s.directives[0].bytes);
}

test "parse: bytes directive — escape set" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\bytes "a\eb\nc\td\\e\"f\r"
    );
    defer s.deinit();
    // Expected bytes: 'a', 0x1B, 'b', 0x0A, 'c', 0x09, 'd', '\\', 'e', '"', 'f', 0x0D
    try std.testing.expectEqualSlices(
        u8,
        &[_]u8{ 'a', 0x1B, 'b', 0x0A, 'c', 0x09, 'd', '\\', 'e', '"', 'f', 0x0D },
        s.directives[0].bytes,
    );
}

test "parse: bytes directive — unclosed string fails" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 5000ms
        \\bytes "no-close
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
}

test "parse: bytes directive — unknown escape fails" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 5000ms
        \\bytes "bad \q escape"
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "escape") != null);
}

test "parse: bytes-hex directive" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\bytes-hex 1B 5B 35 20 71
    );
    defer s.deinit();
    try std.testing.expectEqualSlices(u8, &[_]u8{ 0x1B, 0x5B, 0x35, 0x20, 0x71 }, s.directives[0].bytes);
}

test "parse: bytes-hex accepts mixed case" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\bytes-hex aB cD eF
    );
    defer s.deinit();
    try std.testing.expectEqualSlices(u8, &[_]u8{ 0xAB, 0xCD, 0xEF }, s.directives[0].bytes);
}

test "parse: bytes-hex rejects odd-length token" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 5000ms
        \\bytes-hex 1B 5
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
}

test "parse: bytes-hex rejects non-hex characters" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 5000ms
        \\bytes-hex 1B ZZ
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
}

test "parse: capture directive — label stored" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\capture on-phase
    );
    defer s.deinit();
    try std.testing.expectEqualSlices(u8, "on-phase", s.directives[0].capture);
}

test "parse: capture directive — empty label fails" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 5000ms
        \\capture
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
}

test "parse: assert-cell directive" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\assert-cell 5 3 cursor-bar-at
    );
    defer s.deinit();
    const a = s.directives[0].assert_cell;
    try std.testing.expectEqual(@as(u16, 5), a.row);
    try std.testing.expectEqual(@as(u16, 3), a.col);
    try std.testing.expectEqual(Predicate.cursor_bar_at, a.pred);
}

test "parse: assert-cell-at directive" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\assert-cell-at on-phase 5 3 cursor-block-at
    );
    defer s.deinit();
    const a = s.directives[0].assert_cell_at;
    try std.testing.expectEqualSlices(u8, "on-phase", a.label);
    try std.testing.expectEqual(@as(u16, 5), a.row);
    try std.testing.expectEqual(@as(u16, 3), a.col);
    try std.testing.expectEqual(Predicate.cursor_block_at, a.pred);
}

test "parse: all five predicates round-trip through assert-cell" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\assert-cell 0 0 cell-matches-golden
        \\assert-cell 0 0 cursor-block-at
        \\assert-cell 0 0 cursor-bar-at
        \\assert-cell 0 0 cursor-underline-at
        \\assert-cell 0 0 cell-empty
    );
    defer s.deinit();
    try std.testing.expectEqual(Predicate.cell_matches_golden, s.directives[0].assert_cell.pred);
    try std.testing.expectEqual(Predicate.cursor_block_at, s.directives[1].assert_cell.pred);
    try std.testing.expectEqual(Predicate.cursor_bar_at, s.directives[2].assert_cell.pred);
    try std.testing.expectEqual(Predicate.cursor_underline_at, s.directives[3].assert_cell.pred);
    try std.testing.expectEqual(Predicate.cell_empty, s.directives[4].assert_cell.pred);
}

test "parse: unknown predicate fails at line" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 5000ms
        \\assert-cell 0 0 totally-bogus
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
}
```

- [ ] **Step 2: Wire `src/scenario.zig` as a module in build.zig.**

Insert after the imgdiff library wiring (after `build.zig:388` or equivalent, adjust if line numbers shifted):

```zig
    // scenario — DSL parser, state machine, predicate evaluator (pure)
    const scenario_mod = b.createModule(.{
        .root_source_file = b.path("src/scenario.zig"),
        .target = target,
        .optimize = optimize,
    });
    scenario_mod.addImport("png", png_mod);
    scenario_mod.addImport("imgdiff", imgdiff_lib_mod);

    const scenario_test_mod = b.createModule(.{
        .root_source_file = b.path("src/scenario.zig"),
        .target = target,
        .optimize = optimize,
    });
    scenario_test_mod.addImport("png", png_mod);
    scenario_test_mod.addImport("imgdiff", imgdiff_lib_mod);
    const scenario_tests = b.addTest(.{ .root_module = scenario_test_mod });
    test_step.dependOn(&b.addRunArtifact(scenario_tests).step);
```

- [ ] **Step 3: Run tests to confirm compile failure on the `parse` stub.**

Run:
```bash
cd /home/xanderle/code/rad/waystty
zig build test 2>&1 | tail -10
```

Expected: `@compileError("parse: not yet implemented")` fires. That's the intentional failure proving the test module links against the new symbols.

- [ ] **Step 4: Implement `parse`.**

Replace the stubbed `parse` body with a working implementation. Guidance:

- Line-based: split source on `\n`, track line number starting at 1.
- Skip lines whose trimmed content is empty or begins with `#`.
- Tokenize the first word of each line; dispatch on it.
- `size` must be the first non-comment/non-blank directive. If another directive appears first, diag.line = that line, message = "expected size directive first".
- `timeout` must appear before any body directive. Track `has_size` and `has_timeout` booleans.
- `sleep 250ms` / `sleep 1s` / `sleep 2500ms` — accept `NNms` or `NNs`. Units mandatory.
- `bytes "STR"` — strict double-quoted string; escape set `\n`, `\t`, `\r`, `\e` (→ 0x1B), `\\`, `\"`. Any other backslash is an error.
- `bytes-hex HH HH HH ...` — each token must be exactly 2 hex chars. Mixed case allowed.
- `capture LABEL` — label is a single token, `[a-zA-Z0-9_-]+` (tight — rejects `/` and whitespace). Non-empty.
- `assert-cell R C PRED` — three args, PRED parsed from a small table.
- `assert-cell-at LABEL R C PRED` — four args.
- `sleep-until-flip` — no args.
- Directive payload slices (bytes, capture labels, assert-cell-at labels) are allocated from `Scenario.arena` via `arena.allocator().dupe`.
- On any error, set `diag.line` and `diag.message` (static string) and return `error.ParseFailed`. Deinit the arena cleanly before returning.
- On success, the directive list slice is allocated from the arena and stored on `Scenario`.

Keep the implementation straightforward; don't build a full lexer framework. ~200-300 lines is the target.

- [ ] **Step 5: Run tests.**

```bash
zig build test 2>&1 | tail -30
```

Expected: all scenario-parser tests pass. Existing tests unaffected.

- [ ] **Step 6: Commit.**

```bash
git add src/scenario.zig build.zig
git commit -m "$(cat <<'EOF'
scenario: add parser for the scenario runner DSL

Parses size/timeout header + sleep/sleep-until-flip/bytes/
bytes-hex/capture/assert-cell/assert-cell-at directives. Errors
return a Diagnostic with line number and static message.

Pure — no I/O, no state machine yet. Plan 2 Task 2 builds on this.

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

---

## Task 2: ScenarioState + tick + isDone

**Files:**
- Modify: `src/scenario.zig` — add `ScenarioState`, `tick`, `isDone`, and tests with a fake-clock + mock IO.

**Goal:** A deterministic tick-driven state machine that walks the parsed directive list on a monotonic timeline, invoking caller-supplied callbacks for side effects.

- [ ] **Step 1: Append types, stubs, and failing tests to `src/scenario.zig`.**

Add the following after the existing code:

```zig
// ---------------------------------------------------------------
// Tick state machine
// ---------------------------------------------------------------

/// Caller-supplied side-effects. The state machine itself is pure.
pub const TickIO = struct {
    ctx: *anyopaque,
    /// Write bytes into the terminal's VT parser (caller decides whether
    /// that goes through pty master or direct term.write).
    write_bytes: *const fn (ctx: *anyopaque, bytes: []const u8) anyerror!void,
    /// Perform an offscreen render and return an owned png.Image.
    /// The Scenario will take ownership; caller must not free.
    capture: *const fn (ctx: *anyopaque, label: []const u8) anyerror!png.Image,
    /// Return true if the blink timer flipped since the previous tick.
    /// Used by sleep-until-flip. If blink isn't armed at all, calls
    /// to this return false forever and sleep-until-flip will time out.
    blink_just_flipped: *const fn (ctx: *anyopaque) bool,
};

pub const TickError = error{
    ScenarioTimeout,
    SleepUntilFlipTimeout,
    AssertFailed,
    PredicateOnMissingLabel,
} || std.mem.Allocator.Error || anyerror; // callbacks can surface anyerror

pub const TickOutcome = enum {
    working, // directives remain; call tick again later
    done,    // no more directives
};

pub const ScenarioState = struct {
    scenario: *const Scenario,
    cursor: usize, // index into scenario.directives
    origin_ns: i128, // monotonic wall-clock at start
    deadline_ns: i128, // origin + timeout + slack; exceeding this → TickError.ScenarioTimeout
    scheduled_offset_ns: i128, // "next directive may execute once (now - origin) >= this"
    sleep_until_flip_started_ns: ?i128, // set when entering a sleep-until-flip directive
    captures: std.StringHashMapUnmanaged(png.Image), // label → captured PNG
    alloc: std.mem.Allocator,

    pub fn init(
        alloc: std.mem.Allocator,
        scenario: *const Scenario,
        origin_ns: i128,
    ) ScenarioState {
        return .{
            .scenario = scenario,
            .cursor = 0,
            .origin_ns = origin_ns,
            .deadline_ns = origin_ns + @as(i128, scenario.timeout_ms) * std.time.ns_per_ms,
            .scheduled_offset_ns = 0,
            .sleep_until_flip_started_ns = null,
            .captures = .{},
            .alloc = alloc,
        };
    }

    pub fn deinit(self: *ScenarioState) void {
        var it = self.captures.iterator();
        while (it.next()) |entry| {
            self.alloc.free(entry.value_ptr.pixels);
        }
        self.captures.deinit(self.alloc);
    }

    pub fn isDone(self: *const ScenarioState) bool {
        return self.cursor >= self.scenario.directives.len;
    }

    pub fn tick(self: *ScenarioState, now_ns: i128, io: TickIO) TickError!TickOutcome {
        _ = self;
        _ = now_ns;
        _ = io;
        @compileError("tick: not yet implemented");
    }
};

// ---------------------------------------------------------------
// Tick tests
// ---------------------------------------------------------------

const TestIO = struct {
    writes: std.ArrayListUnmanaged(u8) = .{},
    captures_called: std.ArrayListUnmanaged([]const u8) = .{},
    flip_stub: bool = false,
    alloc: std.mem.Allocator,

    pub fn writeBytes(ctx: *anyopaque, bytes: []const u8) anyerror!void {
        const self: *TestIO = @ptrCast(@alignCast(ctx));
        try self.writes.appendSlice(self.alloc, bytes);
    }

    pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image {
        const self: *TestIO = @ptrCast(@alignCast(ctx));
        const label_copy = try self.alloc.dupe(u8, label);
        try self.captures_called.append(self.alloc, label_copy);
        // Return a 1x1 white pixel that the Scenario takes ownership of.
        const pixels = try self.alloc.alloc(u8, 4);
        pixels[0] = 255;
        pixels[1] = 255;
        pixels[2] = 255;
        pixels[3] = 255;
        return .{ .width = 1, .height = 1, .pixels = pixels };
    }

    pub fn flipCb(ctx: *anyopaque) bool {
        const self: *TestIO = @ptrCast(@alignCast(ctx));
        const v = self.flip_stub;
        self.flip_stub = false;
        return v;
    }

    pub fn io(self: *TestIO) TickIO {
        return .{
            .ctx = self,
            .write_bytes = writeBytes,
            .capture = captureCb,
            .blink_just_flipped = flipCb,
        };
    }

    pub fn deinit(self: *TestIO) void {
        for (self.captures_called.items) |lbl| self.alloc.free(lbl);
        self.captures_called.deinit(self.alloc);
        self.writes.deinit(self.alloc);
    }
};

test "tick: empty scenario is immediately done" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 1000ms
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    try std.testing.expect(state.isDone());
}

test "tick: bytes directive fires write_bytes immediately" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 1000ms
        \\bytes "abc"
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    const r = try state.tick(0, tio.io());
    try std.testing.expectEqual(TickOutcome.done, r);
    try std.testing.expectEqualSlices(u8, "abc", tio.writes.items);
    try std.testing.expect(state.isDone());
}

test "tick: sleep holds advancement until time passes" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\sleep 500ms
        \\bytes "x"
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    // At t=0, sleep is consumed (moves scheduled offset forward); bytes is held.
    const r1 = try state.tick(0, tio.io());
    try std.testing.expectEqual(TickOutcome.working, r1);
    try std.testing.expectEqual(@as(usize, 0), tio.writes.items.len);

    // At t=250ms, still under scheduled offset — no advance.
    const r2 = try state.tick(250 * std.time.ns_per_ms, tio.io());
    try std.testing.expectEqual(TickOutcome.working, r2);
    try std.testing.expectEqual(@as(usize, 0), tio.writes.items.len);

    // At t=500ms, bytes fires.
    const r3 = try state.tick(500 * std.time.ns_per_ms, tio.io());
    try std.testing.expectEqual(TickOutcome.done, r3);
    try std.testing.expectEqualSlices(u8, "x", tio.writes.items);
}

test "tick: capture calls io.capture and stores the result under the label" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\capture snap1
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    _ = try state.tick(0, tio.io());
    try std.testing.expect(state.isDone());
    try std.testing.expectEqual(@as(usize, 1), tio.captures_called.items.len);
    try std.testing.expectEqualSlices(u8, "snap1", tio.captures_called.items[0]);
    try std.testing.expect(state.captures.contains("snap1"));
}

test "tick: scenario timeout fires" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 100ms
        \\sleep 500ms
        \\bytes "x"
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    // 200ms is past the 100ms scenario timeout.
    const r = state.tick(200 * std.time.ns_per_ms, tio.io());
    try std.testing.expectError(error.ScenarioTimeout, r);
}

test "tick: sleep-until-flip holds until flip callback returns true" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\sleep-until-flip
        \\bytes "x"
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    // No flip yet — tick holds at the sleep-until-flip directive.
    tio.flip_stub = false;
    const r1 = try state.tick(100 * std.time.ns_per_ms, tio.io());
    try std.testing.expectEqual(TickOutcome.working, r1);
    try std.testing.expectEqual(@as(usize, 0), tio.writes.items.len);

    // Flip observed — sleep-until-flip advances; bytes fires.
    tio.flip_stub = true;
    const r2 = try state.tick(200 * std.time.ns_per_ms, tio.io());
    try std.testing.expectEqual(TickOutcome.done, r2);
    try std.testing.expectEqualSlices(u8, "x", tio.writes.items);
}

test "tick: sleep-until-flip times out after 2x blink_period_ns of real wait" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 10000ms
        \\sleep-until-flip
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    // After 2 * 500ms = 1s with no flip observed, fail.
    // First tick enters the directive, stamps the start time.
    _ = try state.tick(0, tio.io());
    // Second tick at t=1.1s — over the 2x blink_period budget.
    const r = state.tick(1_100 * std.time.ns_per_ms, tio.io());
    try std.testing.expectError(error.SleepUntilFlipTimeout, r);
}
```

- [ ] **Step 2: Run tests to confirm compile failure on the `tick` stub.**

Run:
```bash
zig build test 2>&1 | tail -10
```

Expected: `@compileError("tick: not yet implemented")` fires.

- [ ] **Step 3: Implement `tick`.**

Replace the stubbed body with a working implementation. Guidance:

- Every entry to `tick` first checks `now_ns > deadline_ns` → return `error.ScenarioTimeout`.
- Then loop: while there's a current directive AND its scheduled time has arrived, execute it and advance cursor.
- Directive execution:
  - `.sleep N` — advance `scheduled_offset_ns` by `N * ns_per_ms`. Advance cursor.
  - `.sleep_until_flip` — on first encounter, set `sleep_until_flip_started_ns = now_ns` and return `.working`. On subsequent encounters: if `io.blink_just_flipped()` returns true, clear the stamp, advance cursor. Else if `now_ns - started > 2 * blink_period_ns` (use a local constant, `500 * ns_per_ms * 2`), return `error.SleepUntilFlipTimeout`. Else return `.working`.
  - `.bytes slice` — call `io.write_bytes(io.ctx, slice)`. Advance cursor.
  - `.capture label` — call `io.capture(io.ctx, label)`. Store the returned image in `self.captures` (keyed by a duped label — the scenario's arena-owned label may have different lifetime). Advance cursor.
  - `.assert_cell` and `.assert_cell_at` — Task 3 handles predicate evaluation; for now stub these as `unreachable`. Tests don't exercise them at this step.
- Return `.working` if cursor < len after the loop; else `.done`.

- [ ] **Step 4: Run tests.**

```bash
zig build test 2>&1 | tail -30
```

Expected: all scenario-tick tests pass. Existing tests unaffected.

- [ ] **Step 5: Commit.**

```bash
git add src/scenario.zig
git commit -m "$(cat <<'EOF'
scenario: add ScenarioState + tick state machine

Tick advances the directive cursor on a monotonic timeline,
invoking caller-supplied callbacks for bytes/capture/flip.
sleep-until-flip rendezvouses with the blink timer via a
caller-observed flag; times out at 2x blink period.

Assert-cell predicates still stubbed — Task 3 wires the
predicate evaluator.

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

---

## Task 3: Cell-region predicate evaluator

**Files:**
- Modify: `src/scenario.zig` — add `evalPredicate` and predicate-specific tests; wire predicates into the `tick` assert branches.

**Goal:** A pure function `evalPredicate` that takes a captured PNG, a cell coordinate, cell pixel dimensions, and a predicate, and returns a pass/fail outcome. Plug it into `tick`'s assert-cell branches.

- [ ] **Step 1: Add predicate evaluator signature + failing tests.**

Append to `src/scenario.zig`:

```zig
// ---------------------------------------------------------------
// Cell-region predicate evaluator
// ---------------------------------------------------------------

pub const EvalResult = struct {
    pass: bool,
    /// Short diagnostic for failure case (static string where possible).
    reason: []const u8 = "",
};

pub const CellGeom = struct {
    cell_w_px: u32,
    cell_h_px: u32,
};

/// `golden_for_cell_matches` is only consulted by `cell_matches_golden`;
/// callers may pass null otherwise. When null and pred needs it, result
/// is pass=false with reason="missing golden".
pub fn evalPredicate(
    image: png.Image,
    row: u16,
    col: u16,
    geom: CellGeom,
    pred: Predicate,
    golden_for_cell_matches: ?png.Image,
) EvalResult {
    _ = image;
    _ = row;
    _ = col;
    _ = geom;
    _ = pred;
    _ = golden_for_cell_matches;
    @compileError("evalPredicate: not yet implemented");
}

// ---------------------------------------------------------------
// Predicate evaluator tests
// ---------------------------------------------------------------

/// Build a width×height PNG where every pixel is `color` (RGBA).
fn makeSolid(alloc: std.mem.Allocator, w: u32, h: u32, color: [4]u8) !png.Image {
    const pixels = try alloc.alloc(u8, @as(usize, w) * h * 4);
    var i: usize = 0;
    while (i < pixels.len) : (i += 4) {
        pixels[i + 0] = color[0];
        pixels[i + 1] = color[1];
        pixels[i + 2] = color[2];
        pixels[i + 3] = color[3];
    }
    return .{ .width = w, .height = h, .pixels = pixels };
}

test "evalPredicate: cell-empty passes on all-black image" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null);
    try std.testing.expect(r.pass);
}

test "evalPredicate: cell-empty fails on image with bright cell" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Make cell (0,0)'s first pixel bright.
    img.pixels[0] = 255;
    img.pixels[1] = 255;
    img.pixels[2] = 255;
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null);
    try std.testing.expect(!r.pass);
}

test "evalPredicate: cursor-block-at passes when cell is mostly bright" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Fill cell (0,0)'s 8x16 rect with white.
    fillCell(&img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .{ 255, 255, 255, 255 });
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_block_at, null);
    try std.testing.expect(r.pass);
}

test "evalPredicate: cursor-block-at fails when cell is dark" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_block_at, null);
    try std.testing.expect(!r.pass);
}

test "evalPredicate: cursor-bar-at passes when bright pixels are at cell left" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Paint only the leftmost 2 pixels of cell (0,0), full height.
    var y: u32 = 0;
    while (y < 16) : (y += 1) {
        var x: u32 = 0;
        while (x < 2) : (x += 1) {
            const off = (@as(usize, y) * 80 + x) * 4;
            img.pixels[off + 0] = 255;
            img.pixels[off + 1] = 255;
            img.pixels[off + 2] = 255;
        }
    }
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_bar_at, null);
    try std.testing.expect(r.pass);
}

test "evalPredicate: cursor-bar-at fails when bright pixels are at cell right" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Paint only the rightmost 2 pixels of cell (0,0), full height.
    var y: u32 = 0;
    while (y < 16) : (y += 1) {
        var x: u32 = 6;
        while (x < 8) : (x += 1) {
            const off = (@as(usize, y) * 80 + x) * 4;
            img.pixels[off + 0] = 255;
            img.pixels[off + 1] = 255;
            img.pixels[off + 2] = 255;
        }
    }
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_bar_at, null);
    try std.testing.expect(!r.pass);
}

test "evalPredicate: cursor-underline-at passes when bright pixels are at cell bottom" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Paint only the bottom 2 rows of cell (0,0), full width.
    var y: u32 = 14;
    while (y < 16) : (y += 1) {
        var x: u32 = 0;
        while (x < 8) : (x += 1) {
            const off = (@as(usize, y) * 80 + x) * 4;
            img.pixels[off + 0] = 255;
            img.pixels[off + 1] = 255;
            img.pixels[off + 2] = 255;
        }
    }
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_underline_at, null);
    try std.testing.expect(r.pass);
}

test "evalPredicate: cell-matches-golden passes when images match in the cell rect" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
    defer alloc.free(img.pixels);
    var golden = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
    defer alloc.free(golden.pixels);
    const r = evalPredicate(img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden);
    try std.testing.expect(r.pass);
}

test "evalPredicate: cell-matches-golden fails on bright delta at the target cell" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
    defer alloc.free(img.pixels);
    var golden = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
    defer alloc.free(golden.pixels);
    // Corrupt cell (5,3) in img.
    fillCell(&img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .{ 255, 0, 0, 255 });
    const r = evalPredicate(img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden);
    try std.testing.expect(!r.pass);
}

test "evalPredicate: cell-matches-golden with null golden fails with 'missing golden'" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
    defer alloc.free(img.pixels);
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, null);
    try std.testing.expect(!r.pass);
    try std.testing.expect(std.mem.indexOf(u8, r.reason, "missing golden") != null);
}

fn fillCell(img: *png.Image, row: u16, col: u16, geom: CellGeom, color: [4]u8) void {
    const start_x: u32 = @as(u32, col) * geom.cell_w_px;
    const start_y: u32 = @as(u32, row) * geom.cell_h_px;
    var y: u32 = start_y;
    while (y < start_y + geom.cell_h_px) : (y += 1) {
        var x: u32 = start_x;
        while (x < start_x + geom.cell_w_px) : (x += 1) {
            const off = (@as(usize, y) * img.width + x) * 4;
            img.pixels[off + 0] = color[0];
            img.pixels[off + 1] = color[1];
            img.pixels[off + 2] = color[2];
            img.pixels[off + 3] = color[3];
        }
    }
}
```

- [ ] **Step 2: Run tests to confirm compile failure on the `evalPredicate` stub.**

```bash
zig build test 2>&1 | tail -10
```

Expected: `@compileError("evalPredicate: not yet implemented")` fires.

- [ ] **Step 3: Implement `evalPredicate`.**

Guidance:

- Cell pixel rect: `x0 = col * cell_w_px`, `y0 = row * cell_h_px`, span = `cell_w_px × cell_h_px`.
- Brightness of a pixel: `(R + G + B) / 3` normalized [0, 1].
- `cell_empty`: every pixel in the cell rect has brightness ≤ 0.1. Pass if so; fail with reason "bright pixel in supposedly empty cell".
- `cursor_block_at`: mean brightness over the cell rect is ≥ 0.5. Pass if so; fail with reason "cell too dark for block cursor".
- `cursor_bar_at`: centroid x of bright pixels (those with brightness ≥ 0.5) falls in the left third of the cell AND at least a threshold count of pixels are bright (`cell_h_px * 2`, matching the 2px bar width). Fail otherwise with reason "bar cursor centroid not at cell left".
- `cursor_underline_at`: centroid y of bright pixels falls in the bottom third of the cell AND bright pixel count ≥ `cell_w_px * 2`. Fail otherwise.
- `cell_matches_golden`: if `golden_for_cell_matches` is null, return pass=false with reason "missing golden". Else compute RMSE over the cell rect using `imgdiff.compare`-style math (copy the pixel-wise math into a helper or reuse `imgdiff.compare` on a cropped rect — simplest is an inline loop). Pass if RMSE ≤ `imgdiff.RMSE_DEFAULT`.

Keep the implementation short. Rough target ~80-100 lines for this entire function + any helpers.

- [ ] **Step 4: Wire evalPredicate into the `tick` assert-cell branches.**

Replace the `unreachable` stubs in `tick`'s `.assert_cell` and `.assert_cell_at` cases with real code:

For `.assert_cell`: evaluate against `self.captures.get(<last_captured_label>)` — need to track the last captured label. Add a field `last_capture_label: ?[]const u8 = null` to `ScenarioState` and update it on every successful `capture`. If null at assert time, return `error.AssertFailed` with a diagnostic (stderr print) noting "assert-cell with no prior capture".

For `.assert_cell_at`: look up `self.captures.get(label)`; if missing, return `error.PredicateOnMissingLabel`.

For `cell_matches_golden`: v1 does NOT load a golden file. Pass `null` for `golden_for_cell_matches`. The predicate will return pass=false with reason "missing golden". This is expected — cell-matches-golden is not useful until Plan 3 wires golden-PNG loading from disk; for Plan 2 scope we just confirm the plumbing exists.

Assertions that fail print a diagnostic via `std.debug.print` and return `error.AssertFailed`. Caller (Plan 3) decides whether to stop or accumulate.

Add a final test that combines parse + state + predicate end-to-end:

```zig
test "tick+eval: assert-cell evaluates against last capture" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\capture snap
        \\assert-cell 0 0 cursor-block-at
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    // TestIO.captureCb returns a 1x1 all-white PNG; cursor_block_at should pass
    // because a 1x1 white image has mean brightness ~1.0 >= 0.5.
    const r = try state.tick(0, tio.io());
    try std.testing.expectEqual(TickOutcome.done, r);
}

test "tick+eval: assert-cell-at on missing label errors" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\assert-cell-at nope 0 0 cell-empty
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    const r = state.tick(0, tio.io());
    try std.testing.expectError(error.PredicateOnMissingLabel, r);
}
```

Note: the first test's `cursor-block-at` assertion on a 1×1 white PNG with `cell_w_px=8, cell_h_px=16` is actually going to fail because cell (0,0) reaches pixel coordinates (0..8, 0..16) but the image is only 1×1. Handle image-too-small as a predicate failure gracefully rather than panicking — add a bounds check at the top of `evalPredicate`: if `(col+1)*cell_w_px > image.width or (row+1)*cell_h_px > image.height`, return pass=false with reason "cell out of bounds". Remove or adjust the end-to-end test accordingly — simpler: feed a larger PNG in `TestIO.captureCb` (e.g. 80×24 = 640×384 at 8×16 cells) so real assertions can run against it.

Recommended adjustment: change `TestIO.captureCb` to return a 640×384 all-white PNG. Then `cursor-block-at` on cell (0,0) will pass.

- [ ] **Step 5: Run all tests.**

```bash
zig build test 2>&1 | tail -30
```

Expected: all scenario tests pass. Existing tests unaffected.

- [ ] **Step 6: Commit.**

```bash
git add src/scenario.zig
git commit -m "$(cat <<'EOF'
scenario: add cell-region predicate evaluator + wire into tick

Pure predicate evaluation over a captured PNG. Predicates
cover: cell-empty, cursor-block/bar/underline-at, and
cell-matches-golden (stubbed pass=false until Plan 3
loads goldens from disk).

Tick's assert-cell and assert-cell-at branches now invoke
the evaluator and return AssertFailed / PredicateOnMissingLabel
on failure.

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

---

## Post-plan verification

- [ ] `zig build` succeeds.
- [ ] `zig build test` — all existing tests plus all new scenario tests pass (estimated 20-25 scenario tests total).
- [ ] `src/scenario.zig` is importable as `@import("scenario")` by any module declaring it as a dep. Plan 3 will consume this.
- [ ] No main-loop / Vulkan / wayland touchpoints were modified.

---

## Self-review coverage check

Spec "Implementation phasing" step 2: "src/scenario.zig parser + state + tick (pure, no integration). All helpers unit-tested with a fake clock. isDone(), directive scheduling, predicate evaluation — all pure. No main.zig touch yet." — Task 1 (parser), Task 2 (state + tick with fake clock), Task 3 (predicates + wire). Matches.

Spec "Scenario file format" directives all covered: size, timeout, sleep, sleep-until-flip, bytes, bytes-hex, capture, assert-cell, assert-cell-at. Task 1 tests explicitly cover each.

Spec "Timing tolerance" sleep-until-flip semantics: tick-level conditional advance based on flip observation, timeout at 2x blink period. Task 2 tests cover both advance and timeout paths.

Spec "Predicates": cursor-block-at, cursor-bar-at, cursor-underline-at, cell-empty, cell-matches-golden. All in Task 3 with per-predicate unit tests.

Spec "Error handling" contracts (parse errors with line number, timeout, capture errors, predicate failures) — mapped to `error.ParseFailed` + Diagnostic, `error.ScenarioTimeout`, `error.SleepUntilFlipTimeout`, `error.AssertFailed`, `error.PredicateOnMissingLabel`.