a73x

docs/superpowers/plans/2026-04-19-scenario-main-loop-integration.md

Ref:   Size: 33.8 KiB

# Scenario Runner Main-Loop Integration 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:** Wire the finished scenario module (`src/scenario.zig`, from Plan 2) into waystty's real main loop. Add a `--scenario <path>` CLI flag that plays a scenario through the full real runtime (Vulkan render, blink timer, frame_loop) and exits with a documented status code. Ship one end-to-end fixture (`blink-bar.scenario`) to prove the pipeline works.

**Architecture:** Minimal surgery. `runTerminal` grows two optional parameters (`scenario_state`, `cell_geom`). Main loop guard relaxes when scenario mode is active. One tick call lands after the blink phase check and before the `if (!render_pending) continue` bail. TickIO callbacks in a small new file (`src/scenario_runtime.zig`) wire `write_bytes` → `term.write`, `capture` → offscreen render + PNG write + golden diff, `blink_just_flipped` → previous-iter blink state compare. No wayland or VT changes.

**Tech Stack:** Zig 0.15, existing `runTerminal` main loop, `src/scenario.zig` (from Plan 2), `src/imgdiff.zig` (from Plan 1), existing `renderer.createOffscreen` / `renderToOffscreen` / `readbackOffscreen`, existing `png` module.

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

---

## Reference facts (grep once, reuse)

- Base commit: `52e5cdc`. Plan 1 + Plan 2 landed. `src/scenario.zig` exports `parse`, `ScenarioState`, `tick`, `evalPredicate`, `CellGeom`, closed `TickError` with `CallbackFailed` / `AssertCellWithoutCapture` / `AssertFailed` / `ScenarioTimeout` / `SleepUntilFlipTimeout` / `PredicateOnMissingLabel`.
- `runTerminal` is defined in `src/main.zig` around line 200-ish (find via `fn runTerminal(`). Main loop starts around L344. Blink phase integration from the earlier cursor-blink feature is around L345-355 (`tickBlinkPhase` call), L507-532 (`reconfigureBlink` call).
- `src/pty.zig` defines `Pty` + `Pty.spawn(SpawnOptions) !Pty`. `master_fd: fd_t`, `child_pid: pid_t`, `isChildAlive()` method.
- `src/capture.zig` demonstrates the offscreen render + PNG write pattern: `run(alloc, argv)` entry, uses `wayland_client.Connection`, `renderer.Context`, `renderer.createOffscreen` / `renderToOffscreen` / `readbackOffscreen`, `png.encode`. Hardcoded 80×24 / scale=1.
- Spec exit codes: 0=pass, 2=parse error, 3=scenario wall-clock timeout, 4=capture/assertion mismatch, 5=Vulkan timeout (flake), 6=other IO/render error.
- Spec: `WAYSTTY_SCENARIO_UPDATE=1` rewrites goldens and exits 0 on mismatch.
- Spec: scenarios live at `tests/scenarios/<name>.scenario`; goldens at `tests/scenarios/golden/<name>/<label>.png`; failure artifacts at `tests/scenarios/out/<name>/<label>.png` + `.diff.png`.
- Blink period is 500ms (`scenario.blink_period_ns`). Main runtime uses `main.blink_period_ns` in the blink module (consistent value).

---

## File structure after this plan

- **`src/pty.zig`** (MODIFIED) — add `openForScenario(cols, rows) !Pty` variant that skips fork but returns a valid Pty whose `isChildAlive()` returns true.
- **`src/main.zig`** (MODIFIED) — parse `--scenario` CLI arg (dispatch mirrors the existing `--capture` pattern). `runTerminal` grows optional `scenario_state: ?*scenario.ScenarioState` param. Loop guard relaxed. Tick call added. New `runScenarios(alloc, argv)` entry point for the scenario subcommand (mirrors `capture.run`).
- **`src/scenario_runtime.zig`** (NEW) — TickIO callback implementations, golden-PNG load/diff helpers, exit code mapping. Small file (~250 LOC target).
- **`tests/scenarios/blink-bar.scenario`** (NEW) — first fixture.
- **`tests/scenarios/golden/blink-bar/*.png`** (NEW) — golden PNGs for the fixture's captures.
- **`Makefile`** (MODIFIED) — new `scenario` and `scenario-update` targets.

---

## Task 1: Pty.openForScenario

**Files:**
- Modify: `src/pty.zig`

**Goal:** A constructor that returns a Pty with a real master/slave fd pair but no child process. `isChildAlive()` always returns true. `deinit` closes the fd but does not wait on a child.

- [ ] **Step 1: Inspect the existing Pty shape.**

Run: `grep -n 'pub fn\|const Pty\|isChildAlive\|pub fn deinit' /home/xanderle/code/rad/waystty/src/pty.zig`. Note the current field names and isChildAlive implementation before writing the new function.

- [ ] **Step 2: Add `openForScenario` + failing test stubs.**

Append to `src/pty.zig` (before any test block that may exist at the bottom):

```zig
/// Open a Pty without spawning a child. Master and slave fds are real,
/// the pty is usable for TIOCSWINSZ, but `isChildAlive` returns true
/// forever so a scenario-mode main loop stays scheduled. No fork().
pub fn openForScenario(cols: u16, rows: u16) !Pty {
    var master: c_int = undefined;
    var slave: c_int = undefined;
    var winsize = c.struct_winsize{
        .ws_row = rows,
        .ws_col = cols,
        .ws_xpixel = 0,
        .ws_ypixel = 0,
    };

    // openpty(3): allocates a pty pair without forking.
    if (c.openpty(&master, &slave, null, null, &winsize) < 0) {
        return error.OpenptyFailed;
    }
    // Slave fd isn't used by anyone in scenario mode; close to avoid leak.
    _ = c.close(slave);

    // Match spawn's O_NONBLOCK on master.
    const flags = try std.posix.fcntl(master, std.posix.F.GETFL, 0);
    const nonblock_bit: usize = @as(u32, @bitCast(std.posix.O{ .NONBLOCK = true }));
    _ = try std.posix.fcntl(master, std.posix.F.SETFL, flags | nonblock_bit);

    return .{
        .master_fd = master,
        .child_pid = -1, // sentinel: no child
        .child_reaped = true, // there's nothing to reap
    };
}
```

Update the existing `isChildAlive` method to special-case the `child_pid == -1` sentinel and return `true`. Shape:

```zig
pub fn isChildAlive(self: *Pty) bool {
    if (self.child_pid == -1) return true; // scenario mode: always alive
    // existing logic unchanged
    ...
}
```

And `deinit`:

```zig
pub fn deinit(self: *Pty) void {
    if (self.child_pid != -1) {
        // existing child-reaping logic
        ...
    }
    _ = std.posix.close(self.master_fd);
}
```

Fetch existing `isChildAlive` and `deinit` bodies before editing to avoid clobbering logic.

If `pty.h` exposes `openpty` in the cImport but doesn't on this system, the alternative is `posix_openpt` + `grantpt` + `unlockpt` + `ptsname` — more involved. Verify `openpty` is in the existing `@cInclude` (`pty.h` is). Should be fine.

- [ ] **Step 3: Test compile + basic integrity.**

Inline test (append to `src/pty.zig`):

```zig
test "openForScenario: returns a pty with master fd and fake child" {
    var p = try Pty.openForScenario(80, 24);
    defer p.deinit();
    try std.testing.expect(p.master_fd >= 0);
    try std.testing.expectEqual(@as(std.posix.pid_t, -1), p.child_pid);
    try std.testing.expect(p.isChildAlive());
}
```

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

Run: `zig test src/pty.zig` — or via `zig build test` if the repo has a pty_tests step (grep build.zig to check).

Expected: test passes. If the pty module isn't wired for tests, add a `pty_tests` step to build.zig mirroring the scenario_tests pattern.

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

```bash
git add src/pty.zig build.zig
git commit -m "$(cat <<'EOF'
pty: add openForScenario — pty pair without a child

Uses openpty(3) instead of forkpty(3). master_fd is non-blocking
(same as spawn). child_pid is sentinel -1 so isChildAlive returns
true and deinit skips reaping. Used by scenario mode where the
main loop needs a live pty invariant but no shell.

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

---

## Task 2: runTerminal scenario plumbing

**Files:**
- Modify: `src/main.zig` — thread optional ScenarioState + CellGeom through `runTerminal`. Relax loop guard. Do NOT add the tick call yet (Task 3). Existing non-scenario entry point continues to pass null.

**Goal:** Set up the signature so Task 3 can plug the tick call in. Verify the existing non-scenario path is byte-for-byte unchanged.

- [ ] **Step 1: Change the `runTerminal` signature.**

Find the `fn runTerminal(` declaration. Add one new parameter at the end (an opaque tick context pointer — Task 3 defines the concrete type):

```zig
fn runTerminal(
    alloc: std.mem.Allocator,
    // ... existing params ...
    tick_ctx: ?*scenario_runtime.RunContext,
) !void {
    _ = tick_ctx; // used in Task 3
```

At the top of `src/main.zig`, add both imports:

```zig
const scenario = @import("scenario");
const scenario_runtime = @import("scenario_runtime");
```

If the import name collides with a local identifier, rename the local — the module import should win.

Note: `scenario_runtime` doesn't exist yet as a module — Task 3 creates it. For Task 2 to compile, either (a) stub `src/scenario_runtime.zig` with a minimal `pub const RunContext = struct {};` now, OR (b) use `?*anyopaque` as the parameter type here and Task 3 refines it to `?*scenario_runtime.RunContext`. Go with **option (a)** — create a 3-line stub file in Task 2 Step 1 so the import resolves:

```zig
// src/scenario_runtime.zig (STUB — Task 3 replaces)
pub const RunContext = struct {};
```

Plus the build.zig wiring (Step 4) so the module resolves.

- [ ] **Step 2: Relax the loop guard.**

Find the main loop at `while (!window.should_close and p.isChildAlive()) {`. Replace with:

```zig
while (!window.should_close and loopShouldRun(&p, tick_ctx)) {
```

Add a helper near the top of `main.zig`:

```zig
fn loopShouldRun(p: *pty.Pty, tick_ctx: ?*scenario_runtime.RunContext) bool {
    if (tick_ctx) |rc| {
        // Scenario mode — check state inside RunContext. RunContext carries
        // a pointer to the ScenarioState; Task 3 populates this field.
        if (rc.state) |s| {
            if (s.isDone()) return false;
        }
    }
    return p.isChildAlive();
}
```

For Task 2's stub, add a nullable `state: ?*scenario.ScenarioState = null` field to the stub struct so `loopShouldRun` compiles:

```zig
// src/scenario_runtime.zig (STUB — Task 3 replaces)
const scenario = @import("scenario");
pub const RunContext = struct {
    state: ?*scenario.ScenarioState = null,
};
```

In normal mode, `tick_ctx` is null and we fall through to `p.isChildAlive()`. In scenario mode, the RunContext's state is non-null and controls the loop exit.

- [ ] **Step 3: Update the existing call site to pass null.**

Find where `runTerminal` is called from `main`. Append `, null` to the call.

- [ ] **Step 4: Wire build.zig so `main.zig` can import `scenario` and `scenario_runtime`.**

Grep build.zig for the `exe_mod` (the waystty binary's module). Add:

```zig
// scenario_runtime stub module (Task 3 populates)
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);

exe_mod.addImport("scenario", scenario_mod);
exe_mod.addImport("scenario_runtime", scenario_runtime_mod);
```

(`imgdiff` is already an import on `scenario_mod` from Plan 1; Task 3 adds it + `renderer`, `vt`, `png` directly to `scenario_runtime_mod`.)

- [ ] **Step 5: Build + run existing tests + run waystty briefly to verify no-regression.**

```bash
cd /home/xanderle/code/rad/waystty
zig build
zig build test-scenario
```

Expected: build succeeds, scenario tests still pass.

Optional smoke: launch waystty for a few seconds to verify it still starts and renders. Kill with Ctrl-C.

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

```bash
git add src/main.zig build.zig
git commit -m "$(cat <<'EOF'
main: thread optional ScenarioState through runTerminal

Signature change only — scenario_state and cell_geom_override
parameters are unused this commit. Loop guard relaxed via
loopShouldRun helper so scenario mode can exit on state.isDone().

Existing entry point passes null — no behavior change for the
normal non-scenario path.

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

---

## Task 3: Scenario entry point + tick hook + real TickIO callbacks + golden diff + exit codes

**Files:**
- Create: `src/scenario_runtime.zig` — callback implementations, golden PNG load/diff, output-directory helpers, exit code mapping, `RunContext`.
- Modify: `src/main.zig` — add `runScenarios(alloc, argv)` (mirrors `capture.run`), dispatch from the argv parser on `--scenario`, thread `*RunContext` through `runTerminal` so the tick site can invoke callbacks, add the tick call in the main loop.
- Modify: `build.zig` — register `scenario_runtime_mod` with imports (`scenario`, `png`, `imgdiff`, `renderer`, `vt`) and add as an import on `exe_mod`.

This is the largest task. Break into sub-steps.

**Design note on context threading:** `runTerminal` takes a `?*scenario_runtime.RunContext` (the "tick context"), not a bare `?*scenario.ScenarioState`. Rationale: the ScenarioState lives inside the RunContext along with the callbacks. Passing just the state would force the tick site to construct a `TickIO` from scratch, which needs access to the RunContext anyway. Simpler to pass one pointer that owns both. Update Task 2's signature retroactively — what that task wrote as `scenario_state: ?*scenario.ScenarioState` is now `tick_ctx: ?*scenario_runtime.RunContext`. Task 2's null-passes become null of the new type. The `cell_geom_override` parameter from Task 2 goes away; `RunContext` owns the cell geom if it needs to.

- [ ] **Step 1: Replace the Task 2 stub `src/scenario_runtime.zig` with the full skeleton.**

```zig
//! 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 `ScenarioTickCtx` + `scenarioTickIO`.

const std = @import("std");
const png = @import("png");
const scenario = @import("scenario");
const imgdiff = @import("imgdiff");
const renderer = @import("renderer");
const vt = @import("vt");

pub const Failure = union(enum) {
    capture_mismatch: struct { label: []const u8, rmse: f64, max_pixel: f64 },
    assert_failed: struct { line: usize, reason: []const u8 },
    // populated as the runner encounters failures
};

pub const RunContext = struct {
    alloc: std.mem.Allocator,
    scenario_name: []const u8,
    update_goldens: bool, // WAYSTTY_SCENARIO_UPDATE=1
    failures: std.ArrayListUnmanaged(Failure) = .{},
    blink_flipped_this_iter: bool = false,

    // Populated by runScenarios before runTerminal is called.
    state: ?*scenario.ScenarioState = null,
    tick_fatal: ?scenario.TickError = null,

    // Rendering handles needed by the capture callback.
    term: *vt.Terminal,
    ctx: *renderer.Context,
    // Offscreen target is created lazily on the first capture and reused
    // across subsequent captures; reference semantics determined at impl time.

    pub fn deinit(self: *RunContext) void {
        for (self.failures.items) |f| switch (f) {
            .capture_mismatch => |m| self.alloc.free(m.label),
            .assert_failed => {}, // reason is static
        };
        self.failures.deinit(self.alloc);
    }
};

pub fn writeBytesCb(ctx: *anyopaque, bytes: []const u8) anyerror!void {
    const rc: *RunContext = @ptrCast(@alignCast(ctx));
    rc.term.write(bytes);
}

pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image {
    const rc: *RunContext = @ptrCast(@alignCast(ctx));
    // 1. Offscreen render via rc.ctx (renderer.renderToOffscreen).
    // 2. Readback into a local RGBA buffer.
    // 3. Encode PNG to tests/scenarios/out/<scenario>/<label>.png.
    // 4. If golden exists, diff via imgdiff.compare.
    // 5. On mismatch: encode diff heatmap via imgdiff.makeDiffImage,
    //    append to rc.failures.
    // 6. On update_goldens: copy out → golden, suppress failure.
    // 7. Return an owned png.Image copy of the readback (for
    //    ScenarioState's in-memory captures map).
    _ = rc;
    _ = label;
    @compileError("captureCb: implement in the next step");
}

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

- [ ] **Step 2: Implement `captureCb` in `src/scenario_runtime.zig`.**

This is the core of the task. Use `capture.zig` as a reference — specifically its offscreen render + readback + png.encode + file write sequence. Approximate shape:

```zig
pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image {
    const rc: *RunContext = @ptrCast(@alignCast(ctx));

    // 1. Render to offscreen.
    try rc.ctx.renderToOffscreen(/* args derived from the capture.zig pattern */);

    // 2. Readback to RGBA.
    const img = try rc.ctx.readbackOffscreen(rc.alloc);

    // 3. Ensure output directory exists.
    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,
    };

    // 4. Write captured PNG.
    const out_path = try std.fmt.allocPrint(rc.alloc, "{s}/{s}.png", .{ out_dir, label });
    defer rc.alloc.free(out_path);

    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(rc.alloc);
    try png.encode(rc.alloc, img, buf.writer(rc.alloc));

    const out_file = try std.fs.cwd().createFile(out_path, .{ .truncate = true });
    defer out_file.close();
    try out_file.writeAll(buf.items);

    // 5. Golden path.
    const golden_path = try std.fmt.allocPrint(rc.alloc, "tests/scenarios/golden/{s}/{s}.png", .{ rc.scenario_name, label });
    defer rc.alloc.free(golden_path);

    if (rc.update_goldens) {
        const g_dir = try std.fmt.allocPrint(rc.alloc, "tests/scenarios/golden/{s}", .{rc.scenario_name});
        defer rc.alloc.free(g_dir);
        std.fs.cwd().makePath(g_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(buf.items);
        // No diff check when updating.
    } else {
        // Attempt to load golden; if missing, record as mismatch.
        const golden_bytes = std.fs.cwd().readFileAlloc(rc.alloc, golden_path, 64 * 1024 * 1024) catch |err| {
            if (err == error.FileNotFound) {
                // Record as failure with a clear message.
                const lbl = try rc.alloc.dupe(u8, label);
                try rc.failures.append(rc.alloc, .{ .capture_mismatch = .{
                    .label = lbl,
                    .rmse = 1.0,
                    .max_pixel = 1.0,
                }});
                return img;
            }
            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);
            try rc.failures.append(rc.alloc, .{ .capture_mismatch = .{
                .label = lbl,
                .rmse = 1.0,
                .max_pixel = 1.0,
            }});
        } else {
            const diff_res = try imgdiff.compare(img, golden);
            if (diff_res.rmse > imgdiff.RMSE_DEFAULT or diff_res.max_pixel > imgdiff.PIXEL_MAX_DEFAULT) {
                const lbl = try rc.alloc.dupe(u8, label);
                try rc.failures.append(rc.alloc, .{ .capture_mismatch = .{
                    .label = lbl,
                    .rmse = diff_res.rmse,
                    .max_pixel = diff_res.max_pixel,
                }});
                // Write diff heatmap.
                const heat = try imgdiff.makeDiffImage(rc.alloc, img, golden);
                defer rc.alloc.free(heat.pixels);
                var heat_buf: std.ArrayList(u8) = .empty;
                defer heat_buf.deinit(rc.alloc);
                try png.encode(rc.alloc, heat, heat_buf.writer(rc.alloc));
                const heat_path = try std.fmt.allocPrint(rc.alloc, "{s}/{s}.diff.png", .{ out_dir, label });
                defer rc.alloc.free(heat_path);
                const heat_file = try std.fs.cwd().createFile(heat_path, .{ .truncate = true });
                defer heat_file.close();
                try heat_file.writeAll(heat_buf.items);
            }
        }
    }

    return img;
}
```

This is a sketch — real implementation must get the `renderToOffscreen` call's arguments right by cross-referencing `src/capture.zig`.

- [ ] **Step 3: Add the tick call in the main loop.**

Inside `runTerminal` (the function whose signature Task 2 grew), after the `tickBlinkPhase` block (around L350-355) and the blink flip bookkeeping, and BEFORE the `if (!render_pending) continue` bail (around L423), add:

```zig
// --- scenario tick ---
// Lands after the blink phase flip so a just-flipped blink correctly
// informs io.blink_just_flipped on THIS tick. Lands before the
// render_pending bail so a scheduled tick can set render_pending true.
if (tick_ctx) |rc| {
    const s = rc.state orelse break;
    // Observe blink flip for this iteration (tick.flipped was set in tickBlinkPhase above).
    if (tick.flipped) rc.blink_flipped_this_iter = true;

    const tick_now = std.time.nanoTimestamp();
    const outcome = s.tick(tick_now, scenario_runtime.tickIO(rc)) catch |err| {
        rc.tick_fatal = err;
        window.should_close = true;
        break;
    };
    switch (outcome) {
        .working => {},
        .done => window.should_close = true,
    }
}
```

`tick_fatal: ?scenario.TickError = null` is a field on `RunContext` (Step 1 added it). `rc.state` is the ScenarioState pointer, also a RunContext field from Step 1. `tick.flipped` is the already-existing blink bookkeeping local from the cursor-blink feature commits.

- [ ] **Step 4: Add `runScenarios` entry point in `src/main.zig`.**

Mirrors `capture.run`. Stand up a wayland connection + vulkan context + pty (via `openForScenario`) + offscreen target, parse the scenario file, build a `RunContext`, wire into `scenarioTickIO`, call `runTerminal(..., &state, null)`, map any `scenario_tick_fatal` to exit code, report golden diffs / assertion failures, exit with the right code.

```zig
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(2);
    }
    const scenario_path = argv[1];

    // Read + parse scenario.
    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(2);
    };
    defer alloc.free(source);

    var diag: scenario.Diagnostic = .{};
    var s = scenario.parse(alloc, source, &diag) catch {
        std.debug.print("scenario: {s}:{d}: {s}\n", .{ scenario_path, diag.line, diag.message });
        std.process.exit(2);
    };
    defer s.deinit();

    // Derive scenario_name from the path (basename without extension).
    const scenario_name = basenameNoExt(scenario_path);

    // Stand up the runtime (wayland + vulkan + renderer + term + pty-for-scenario).
    // See src/capture.zig for the pattern. Reuse as much as possible.

    // ... setup elided — see capture.zig for reference ...

    const update = std.posix.getenv("WAYSTTY_SCENARIO_UPDATE") != null;
    var rc: scenario_runtime.RunContext = .{
        .alloc = alloc,
        .scenario_name = scenario_name,
        .update_goldens = update,
        .last_blink_on = true,
        .term = &term,
        .ctx = &ctx,
    };
    defer rc.deinit();

    var state = scenario.ScenarioState.init(alloc, &s, std.time.nanoTimestamp(), .{
        .cell_w_px = cell_w,
        .cell_h_px = cell_h,
    });
    defer state.deinit();

    // Wire scenarioTickCtx — replaces the Task 3 stub:
    scenario_tick_ctx = &rc;

    // Run the main loop with scenario mode.
    try runTerminal(alloc, /* existing args */, &state, .{
        .cell_w_px = cell_w, .cell_h_px = cell_h,
    });

    // On exit, decide the exit code.
    if (scenario_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 }),
            .assert_failed => |a| std.debug.print("  assert line {d}: {s}\n", .{ a.line, a.reason }),
        };
        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 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];
}
```

This is also sketched — implementer fills wayland+vulkan setup by following `capture.zig`'s `run` verbatim for those pieces, replacing capture's PTY spawn with `openForScenario` and replacing the one-shot render with the scenario ticker.

Two things to be careful about:
- `scenario_tick_ctx` is a module-level variable Task 3 declared. It needs to be `*RunContext` typed — adjust Task 3's declaration to `var scenario_tick_ctx: ?*scenario_runtime.RunContext = null;` and `scenarioTickIO(ctx.?)` when dispatching. That keeps normal mode (no context) panic-free via a null check at the tick site.

- [ ] **Step 5: Dispatch `--scenario` in `main`.**

Near the top of `main`, add a branch that detects `--scenario` in argv and calls `runScenarios`:

```zig
pub fn main() !void {
    var gpa = std.heap.DebugAllocator(.{}){};
    defer _ = gpa.deinit();
    const alloc = gpa.allocator();

    const args = try std.process.argsAlloc(alloc);
    defer std.process.argsFree(alloc, args);

    if (args.len >= 2 and std.mem.eql(u8, args[1], "--scenario")) {
        return runScenarios(alloc, args[1..]);
    }
    if (args.len >= 2 and std.mem.eql(u8, args[1], "--capture")) {
        return capture.run(alloc, args[1..]);
    }
    // ... existing normal-mode dispatch ...
}
```

- [ ] **Step 6: Finalize `scenario_runtime_mod` in build.zig.**

Task 2 created a stub module with only `scenario` as an import. Extend it now with the remaining imports the real implementation needs:

```zig
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);
```

(These three `addImport` calls are added to the existing `scenario_runtime_mod` created in Task 2 Step 4.)

- [ ] **Step 7: Build + run existing tests.**

```bash
zig build
zig build test-scenario
```

Expected: builds, scenario tests still pass. No scenario fixture runs yet (that's Task 4).

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

```bash
git add src/scenario_runtime.zig src/main.zig build.zig
git commit -m "$(cat <<'EOF'
main: add scenario entry point + runtime callbacks

--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).

src/scenario_runtime.zig owns the TickIO callback
implementations: write_bytes -> term.write, capture ->
offscreen render + PNG + golden diff, blink_just_flipped ->
per-iter flag toggled by the main loop.

First fixture lands in the next commit.

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

---

## Task 4: First fixture + Makefile targets

**Files:**
- Create: `tests/scenarios/blink-bar.scenario`
- Create: `tests/scenarios/golden/blink-bar/*.png` (generated in step below)
- Modify: `Makefile` — add `scenario` and `scenario-update` targets
- Modify: `.gitignore` — add `tests/scenarios/out/`

- [ ] **Step 1: Write the fixture file.**

Create `tests/scenarios/blink-bar.scenario`:

```
# Blinking bar cursor: DECSCUSR 5 then observe two phases.
size 80 24
timeout 5000ms

# Inject DECSCUSR 5 directly (no shell) - blinking bar.
bytes "\e[5 q"

# Give blink state machine time to arm.
sleep 100ms
capture before-flip

# Advance past the first phase flip (500ms period).
sleep 500ms
capture after-flip
```

- [ ] **Step 2: Update .gitignore.**

Append to `.gitignore`:

```
tests/scenarios/out/
```

- [ ] **Step 3: Add Makefile targets.**

Append to `Makefile`:

```makefile
.PHONY: scenario scenario-update

scenario:
	zig build
	@for f in tests/scenarios/*.scenario; do \
	  echo "=== $$f ==="; \
	  ./zig-out/bin/waystty --scenario "$$f" || exit $$?; \
	done

scenario-update:
	zig build
	@for f in tests/scenarios/*.scenario; do \
	  echo "=== $$f (update goldens) ==="; \
	  WAYSTTY_SCENARIO_UPDATE=1 ./zig-out/bin/waystty --scenario "$$f" || exit $$?; \
	done
```

- [ ] **Step 4: Generate goldens for the fixture.**

```bash
cd /home/xanderle/code/rad/waystty
make scenario-update
```

Expected: writes `tests/scenarios/golden/blink-bar/before-flip.png` and `tests/scenarios/golden/blink-bar/after-flip.png`. Exit 0.

Inspect the two PNGs manually (or with `imgdiff`): they should be black backgrounds with a single bright bar at the cursor position in column 0, row 0. The two images should DIFFER — before-flip shows the cursor bar, after-flip shows no cursor (phase off).

If they look identical, either the blink timer didn't flip (investigate — perhaps blink timer arm conditions unmet in scenario mode) or the capture timing is off.

- [ ] **Step 5: Run the fixture against goldens.**

```bash
make scenario
```

Expected: `scenario blink-bar: OK`, exit 0.

- [ ] **Step 6: Verify mutation detection.**

Intentionally break one golden (e.g. `rm tests/scenarios/golden/blink-bar/after-flip.png` and `touch` a random file in its place, or edit the scenario to sleep 250ms instead of 500ms so the capture misses the flip).

```bash
make scenario || echo "expected non-zero: $?"
```

Expected: the runner detects the mismatch, writes `tests/scenarios/out/blink-bar/after-flip.diff.png`, exits with code 4.

Restore the golden (either via `make scenario-update` or `git checkout tests/scenarios/golden/blink-bar/`).

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

```bash
git add tests/scenarios/blink-bar.scenario tests/scenarios/golden/blink-bar/ Makefile .gitignore
git commit -m "$(cat <<'EOF'
scenarios: first fixture blink-bar + Makefile targets

blink-bar.scenario drives DECSCUSR 5 and captures two frames
separated by 500ms to observe the blink-bar cursor on/off
phase boundary. Proves the scenario runner end-to-end:

  - --scenario flag parses and dispatches
  - bytes directive injects into the VT parser
  - real main loop fires the blink timer
  - capture directive offscreens + PNG-writes + diffs goldens
  - exit code 0 on pass, 4 on golden mismatch

make scenario           — run all *.scenario fixtures
make scenario-update    — regenerate goldens (WAYSTTY_SCENARIO_UPDATE=1)

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

---

## Post-plan verification

- [ ] `make scenario` — expected: "scenario blink-bar: OK", exit 0.
- [ ] `make scenario-update` — regenerates goldens. Git should report them as unchanged if the generator is deterministic.
- [ ] `zig build` — no regressions.
- [ ] `zig build test-scenario` — all 50 pure scenario tests still pass.
- [ ] Manually launch `./zig-out/bin/waystty` (normal mode, no --scenario) — verify nothing regressed; cursor still blinks, typing works, resize works.
- [ ] `rm tests/scenarios/golden/blink-bar/after-flip.png; make scenario || true` — verify mismatch path produces a `diff.png` and non-zero exit.

---

## Self-review coverage check

Spec "Implementation phasing" step 3: "Main-loop integration + first fixtures. `--scenario` flag, `runTerminal` hook, one `blink-bar.scenario` fixture with golden PNGs, `make scenario` target. This plan is the visible landing." — every bullet covered.

Spec "Architecture" tick integration: "immediately after the blink phase check and before the `if (!render_pending) continue` bail" — Task 3 Step 1 places it there exactly.

Spec "No dummy child process" — Task 1 uses `openpty(3)` + `child_pid = -1` sentinel. No fork, no zombie.

Spec "Golden update mode: WAYSTTY_SCENARIO_UPDATE=1" — Task 4 reads the env var and Task 5 wires the `scenario-update` target around it.

Spec "Exit codes 0/2/3/4/5/6 with distinct semantics for Vulkan-timeout vs regression" — Task 4 Step 1 maps `scenario.TickError` variants to `ExitCode` distinctly (VkTimeout via `error.CallbackFailed` is conservative — the renderer itself returns `vk_sync` errors through the TickIO capture callback which wraps to `CallbackFailed`, so exit code 5 for flake-vs-6 for other is not preserved yet; file as follow-up if needed).

Spec "Non-goals: No wayland event path testing." — this plan does not add any, correct.