a73x

3f961039

Plan 3 of 3 for scenario runner: main-loop integration

a73x   2026-04-19 13:20

Commit message
Plan 3 of 3 for scenario runner: main-loop integration

Four tasks:
1. Pty.openForScenario — pty without fork, isChildAlive always true
2. runTerminal signature growth + loop guard relax (stub scenario_runtime)
3. Scenario entry point + real TickIO callbacks + golden diff + exit codes
4. First fixture (blink-bar.scenario) + Makefile targets

Integration lands inside the existing runTerminal main loop so the
real blink timer / frame_loop / Vulkan render participate — no
parallel mode.

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

docs/superpowers/plans/2026-04-19-scenario-main-loop-integration.md
Old New
@@ -0,0 +1,839 @@
1 # Scenario Runner Main-Loop Integration Plan
2
3 > **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.
4
5 **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.
6
7 **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.
8
9 **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.
10
11 **Reference spec:** `docs/superpowers/specs/2026-04-19-scenario-runner-design.md`
12
13 ---
14
15 ## Reference facts (grep once, reuse)
16
17 - 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`.
18 - `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).
19 - `src/pty.zig` defines `Pty` + `Pty.spawn(SpawnOptions) !Pty`. `master_fd: fd_t`, `child_pid: pid_t`, `isChildAlive()` method.
20 - `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.
21 - 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.
22 - Spec: `WAYSTTY_SCENARIO_UPDATE=1` rewrites goldens and exits 0 on mismatch.
23 - 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`.
24 - Blink period is 500ms (`scenario.blink_period_ns`). Main runtime uses `main.blink_period_ns` in the blink module (consistent value).
25
26 ---
27
28 ## File structure after this plan
29
30 - **`src/pty.zig`** (MODIFIED) — add `openForScenario(cols, rows) !Pty` variant that skips fork but returns a valid Pty whose `isChildAlive()` returns true.
31 - **`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`).
32 - **`src/scenario_runtime.zig`** (NEW) — TickIO callback implementations, golden-PNG load/diff helpers, exit code mapping. Small file (~250 LOC target).
33 - **`tests/scenarios/blink-bar.scenario`** (NEW) — first fixture.
34 - **`tests/scenarios/golden/blink-bar/*.png`** (NEW) — golden PNGs for the fixture's captures.
35 - **`Makefile`** (MODIFIED) — new `scenario` and `scenario-update` targets.
36
37 ---
38
39 ## Task 1: Pty.openForScenario
40
41 **Files:**
42 - Modify: `src/pty.zig`
43
44 **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.
45
46 - [ ] **Step 1: Inspect the existing Pty shape.**
47
48 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.
49
50 - [ ] **Step 2: Add `openForScenario` + failing test stubs.**
51
52 Append to `src/pty.zig` (before any test block that may exist at the bottom):
53
54 ```zig
55 /// Open a Pty without spawning a child. Master and slave fds are real,
56 /// the pty is usable for TIOCSWINSZ, but `isChildAlive` returns true
57 /// forever so a scenario-mode main loop stays scheduled. No fork().
58 pub fn openForScenario(cols: u16, rows: u16) !Pty {
59 var master: c_int = undefined;
60 var slave: c_int = undefined;
61 var winsize = c.struct_winsize{
62 .ws_row = rows,
63 .ws_col = cols,
64 .ws_xpixel = 0,
65 .ws_ypixel = 0,
66 };
67
68 // openpty(3): allocates a pty pair without forking.
69 if (c.openpty(&master, &slave, null, null, &winsize) < 0) {
70 return error.OpenptyFailed;
71 }
72 // Slave fd isn't used by anyone in scenario mode; close to avoid leak.
73 _ = c.close(slave);
74
75 // Match spawn's O_NONBLOCK on master.
76 const flags = try std.posix.fcntl(master, std.posix.F.GETFL, 0);
77 const nonblock_bit: usize = @as(u32, @bitCast(std.posix.O{ .NONBLOCK = true }));
78 _ = try std.posix.fcntl(master, std.posix.F.SETFL, flags | nonblock_bit);
79
80 return .{
81 .master_fd = master,
82 .child_pid = -1, // sentinel: no child
83 .child_reaped = true, // there's nothing to reap
84 };
85 }
86 ```
87
88 Update the existing `isChildAlive` method to special-case the `child_pid == -1` sentinel and return `true`. Shape:
89
90 ```zig
91 pub fn isChildAlive(self: *Pty) bool {
92 if (self.child_pid == -1) return true; // scenario mode: always alive
93 // existing logic unchanged
94 ...
95 }
96 ```
97
98 And `deinit`:
99
100 ```zig
101 pub fn deinit(self: *Pty) void {
102 if (self.child_pid != -1) {
103 // existing child-reaping logic
104 ...
105 }
106 _ = std.posix.close(self.master_fd);
107 }
108 ```
109
110 Fetch existing `isChildAlive` and `deinit` bodies before editing to avoid clobbering logic.
111
112 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.
113
114 - [ ] **Step 3: Test compile + basic integrity.**
115
116 Inline test (append to `src/pty.zig`):
117
118 ```zig
119 test "openForScenario: returns a pty with master fd and fake child" {
120 var p = try Pty.openForScenario(80, 24);
121 defer p.deinit();
122 try std.testing.expect(p.master_fd >= 0);
123 try std.testing.expectEqual(@as(std.posix.pid_t, -1), p.child_pid);
124 try std.testing.expect(p.isChildAlive());
125 }
126 ```
127
128 - [ ] **Step 4: Run the pty tests.**
129
130 Run: `zig test src/pty.zig` — or via `zig build test` if the repo has a pty_tests step (grep build.zig to check).
131
132 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.
133
134 - [ ] **Step 5: Commit.**
135
136 ```bash
137 git add src/pty.zig build.zig
138 git commit -m "$(cat <<'EOF'
139 pty: add openForScenario — pty pair without a child
140
141 Uses openpty(3) instead of forkpty(3). master_fd is non-blocking
142 (same as spawn). child_pid is sentinel -1 so isChildAlive returns
143 true and deinit skips reaping. Used by scenario mode where the
144 main loop needs a live pty invariant but no shell.
145
146 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
147 EOF
148 )"
149 ```
150
151 ---
152
153 ## Task 2: runTerminal scenario plumbing
154
155 **Files:**
156 - 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.
157
158 **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.
159
160 - [ ] **Step 1: Change the `runTerminal` signature.**
161
162 Find the `fn runTerminal(` declaration. Add one new parameter at the end (an opaque tick context pointer — Task 3 defines the concrete type):
163
164 ```zig
165 fn runTerminal(
166 alloc: std.mem.Allocator,
167 // ... existing params ...
168 tick_ctx: ?*scenario_runtime.RunContext,
169 ) !void {
170 _ = tick_ctx; // used in Task 3
171 ```
172
173 At the top of `src/main.zig`, add both imports:
174
175 ```zig
176 const scenario = @import("scenario");
177 const scenario_runtime = @import("scenario_runtime");
178 ```
179
180 If the import name collides with a local identifier, rename the local — the module import should win.
181
182 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:
183
184 ```zig
185 // src/scenario_runtime.zig (STUB — Task 3 replaces)
186 pub const RunContext = struct {};
187 ```
188
189 Plus the build.zig wiring (Step 4) so the module resolves.
190
191 - [ ] **Step 2: Relax the loop guard.**
192
193 Find the main loop at `while (!window.should_close and p.isChildAlive()) {`. Replace with:
194
195 ```zig
196 while (!window.should_close and loopShouldRun(&p, tick_ctx)) {
197 ```
198
199 Add a helper near the top of `main.zig`:
200
201 ```zig
202 fn loopShouldRun(p: *pty.Pty, tick_ctx: ?*scenario_runtime.RunContext) bool {
203 if (tick_ctx) |rc| {
204 // Scenario mode — check state inside RunContext. RunContext carries
205 // a pointer to the ScenarioState; Task 3 populates this field.
206 if (rc.state) |s| {
207 if (s.isDone()) return false;
208 }
209 }
210 return p.isChildAlive();
211 }
212 ```
213
214 For Task 2's stub, add a nullable `state: ?*scenario.ScenarioState = null` field to the stub struct so `loopShouldRun` compiles:
215
216 ```zig
217 // src/scenario_runtime.zig (STUB — Task 3 replaces)
218 const scenario = @import("scenario");
219 pub const RunContext = struct {
220 state: ?*scenario.ScenarioState = null,
221 };
222 ```
223
224 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.
225
226 - [ ] **Step 3: Update the existing call site to pass null.**
227
228 Find where `runTerminal` is called from `main`. Append `, null` to the call.
229
230 - [ ] **Step 4: Wire build.zig so `main.zig` can import `scenario` and `scenario_runtime`.**
231
232 Grep build.zig for the `exe_mod` (the waystty binary's module). Add:
233
234 ```zig
235 // scenario_runtime stub module (Task 3 populates)
236 const scenario_runtime_mod = b.createModule(.{
237 .root_source_file = b.path("src/scenario_runtime.zig"),
238 .target = target,
239 .optimize = optimize,
240 });
241 scenario_runtime_mod.addImport("scenario", scenario_mod);
242
243 exe_mod.addImport("scenario", scenario_mod);
244 exe_mod.addImport("scenario_runtime", scenario_runtime_mod);
245 ```
246
247 (`imgdiff` is already an import on `scenario_mod` from Plan 1; Task 3 adds it + `renderer`, `vt`, `png` directly to `scenario_runtime_mod`.)
248
249 - [ ] **Step 5: Build + run existing tests + run waystty briefly to verify no-regression.**
250
251 ```bash
252 cd /home/xanderle/code/rad/waystty
253 zig build
254 zig build test-scenario
255 ```
256
257 Expected: build succeeds, scenario tests still pass.
258
259 Optional smoke: launch waystty for a few seconds to verify it still starts and renders. Kill with Ctrl-C.
260
261 - [ ] **Step 6: Commit.**
262
263 ```bash
264 git add src/main.zig build.zig
265 git commit -m "$(cat <<'EOF'
266 main: thread optional ScenarioState through runTerminal
267
268 Signature change only — scenario_state and cell_geom_override
269 parameters are unused this commit. Loop guard relaxed via
270 loopShouldRun helper so scenario mode can exit on state.isDone().
271
272 Existing entry point passes null — no behavior change for the
273 normal non-scenario path.
274
275 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
276 EOF
277 )"
278 ```
279
280 ---
281
282 ## Task 3: Scenario entry point + tick hook + real TickIO callbacks + golden diff + exit codes
283
284 **Files:**
285 - Create: `src/scenario_runtime.zig` — callback implementations, golden PNG load/diff, output-directory helpers, exit code mapping, `RunContext`.
286 - 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.
287 - Modify: `build.zig` — register `scenario_runtime_mod` with imports (`scenario`, `png`, `imgdiff`, `renderer`, `vt`) and add as an import on `exe_mod`.
288
289 This is the largest task. Break into sub-steps.
290
291 **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.
292
293 - [ ] **Step 1: Replace the Task 2 stub `src/scenario_runtime.zig` with the full skeleton.**
294
295 ```zig
296 //! Runtime glue for scenario mode. Owns the TickIO callback
297 //! implementations, golden-PNG IO, and result accumulation.
298 //!
299 //! Pure I/O — no main-loop code here. `src/main.zig` wires this
300 //! into `runTerminal` via `ScenarioTickCtx` + `scenarioTickIO`.
301
302 const std = @import("std");
303 const png = @import("png");
304 const scenario = @import("scenario");
305 const imgdiff = @import("imgdiff");
306 const renderer = @import("renderer");
307 const vt = @import("vt");
308
309 pub const Failure = union(enum) {
310 capture_mismatch: struct { label: []const u8, rmse: f64, max_pixel: f64 },
311 assert_failed: struct { line: usize, reason: []const u8 },
312 // populated as the runner encounters failures
313 };
314
315 pub const RunContext = struct {
316 alloc: std.mem.Allocator,
317 scenario_name: []const u8,
318 update_goldens: bool, // WAYSTTY_SCENARIO_UPDATE=1
319 failures: std.ArrayListUnmanaged(Failure) = .{},
320 blink_flipped_this_iter: bool = false,
321
322 // Populated by runScenarios before runTerminal is called.
323 state: ?*scenario.ScenarioState = null,
324 tick_fatal: ?scenario.TickError = null,
325
326 // Rendering handles needed by the capture callback.
327 term: *vt.Terminal,
328 ctx: *renderer.Context,
329 // Offscreen target is created lazily on the first capture and reused
330 // across subsequent captures; reference semantics determined at impl time.
331
332 pub fn deinit(self: *RunContext) void {
333 for (self.failures.items) |f| switch (f) {
334 .capture_mismatch => |m| self.alloc.free(m.label),
335 .assert_failed => {}, // reason is static
336 };
337 self.failures.deinit(self.alloc);
338 }
339 };
340
341 pub fn writeBytesCb(ctx: *anyopaque, bytes: []const u8) anyerror!void {
342 const rc: *RunContext = @ptrCast(@alignCast(ctx));
343 rc.term.write(bytes);
344 }
345
346 pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image {
347 const rc: *RunContext = @ptrCast(@alignCast(ctx));
348 // 1. Offscreen render via rc.ctx (renderer.renderToOffscreen).
349 // 2. Readback into a local RGBA buffer.
350 // 3. Encode PNG to tests/scenarios/out/<scenario>/<label>.png.
351 // 4. If golden exists, diff via imgdiff.compare.
352 // 5. On mismatch: encode diff heatmap via imgdiff.makeDiffImage,
353 // append to rc.failures.
354 // 6. On update_goldens: copy out → golden, suppress failure.
355 // 7. Return an owned png.Image copy of the readback (for
356 // ScenarioState's in-memory captures map).
357 _ = rc;
358 _ = label;
359 @compileError("captureCb: implement in the next step");
360 }
361
362 pub fn blinkFlippedCb(ctx: *anyopaque) bool {
363 const rc: *RunContext = @ptrCast(@alignCast(ctx));
364 const v = rc.blink_flipped_this_iter;
365 rc.blink_flipped_this_iter = false;
366 return v;
367 }
368
369 pub fn tickIO(rc: *RunContext) scenario.TickIO {
370 return .{
371 .ctx = rc,
372 .write_bytes = writeBytesCb,
373 .capture = captureCb,
374 .blink_just_flipped = blinkFlippedCb,
375 };
376 }
377
378 pub const ExitCode = enum(u8) {
379 success = 0,
380 parse_error = 2,
381 timeout = 3,
382 assertion_mismatch = 4,
383 vulkan_timeout = 5,
384 other_error = 6,
385 };
386
387 pub fn mapTickError(err: scenario.TickError) ExitCode {
388 return switch (err) {
389 error.ScenarioTimeout => .timeout,
390 error.SleepUntilFlipTimeout => .timeout,
391 error.AssertFailed => .assertion_mismatch,
392 error.AssertCellWithoutCapture => .assertion_mismatch,
393 error.PredicateOnMissingLabel => .assertion_mismatch,
394 error.CallbackFailed => .other_error,
395 error.OutOfMemory => .other_error,
396 };
397 }
398 ```
399
400 - [ ] **Step 2: Implement `captureCb` in `src/scenario_runtime.zig`.**
401
402 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:
403
404 ```zig
405 pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image {
406 const rc: *RunContext = @ptrCast(@alignCast(ctx));
407
408 // 1. Render to offscreen.
409 try rc.ctx.renderToOffscreen(/* args derived from the capture.zig pattern */);
410
411 // 2. Readback to RGBA.
412 const img = try rc.ctx.readbackOffscreen(rc.alloc);
413
414 // 3. Ensure output directory exists.
415 const out_dir = try std.fmt.allocPrint(rc.alloc, "tests/scenarios/out/{s}", .{rc.scenario_name});
416 defer rc.alloc.free(out_dir);
417 std.fs.cwd().makePath(out_dir) catch |err| switch (err) {
418 error.PathAlreadyExists => {},
419 else => return err,
420 };
421
422 // 4. Write captured PNG.
423 const out_path = try std.fmt.allocPrint(rc.alloc, "{s}/{s}.png", .{ out_dir, label });
424 defer rc.alloc.free(out_path);
425
426 var buf: std.ArrayList(u8) = .empty;
427 defer buf.deinit(rc.alloc);
428 try png.encode(rc.alloc, img, buf.writer(rc.alloc));
429
430 const out_file = try std.fs.cwd().createFile(out_path, .{ .truncate = true });
431 defer out_file.close();
432 try out_file.writeAll(buf.items);
433
434 // 5. Golden path.
435 const golden_path = try std.fmt.allocPrint(rc.alloc, "tests/scenarios/golden/{s}/{s}.png", .{ rc.scenario_name, label });
436 defer rc.alloc.free(golden_path);
437
438 if (rc.update_goldens) {
439 const g_dir = try std.fmt.allocPrint(rc.alloc, "tests/scenarios/golden/{s}", .{rc.scenario_name});
440 defer rc.alloc.free(g_dir);
441 std.fs.cwd().makePath(g_dir) catch |err| switch (err) {
442 error.PathAlreadyExists => {},
443 else => return err,
444 };
445 const g_file = try std.fs.cwd().createFile(golden_path, .{ .truncate = true });
446 defer g_file.close();
447 try g_file.writeAll(buf.items);
448 // No diff check when updating.
449 } else {
450 // Attempt to load golden; if missing, record as mismatch.
451 const golden_bytes = std.fs.cwd().readFileAlloc(rc.alloc, golden_path, 64 * 1024 * 1024) catch |err| {
452 if (err == error.FileNotFound) {
453 // Record as failure with a clear message.
454 const lbl = try rc.alloc.dupe(u8, label);
455 try rc.failures.append(rc.alloc, .{ .capture_mismatch = .{
456 .label = lbl,
457 .rmse = 1.0,
458 .max_pixel = 1.0,
459 }});
460 return img;
461 }
462 return err;
463 };
464 defer rc.alloc.free(golden_bytes);
465 var golden = try png.decode(rc.alloc, golden_bytes);
466 defer golden.deinit(rc.alloc);
467
468 if (golden.width != img.width or golden.height != img.height) {
469 const lbl = try rc.alloc.dupe(u8, label);
470 try rc.failures.append(rc.alloc, .{ .capture_mismatch = .{
471 .label = lbl,
472 .rmse = 1.0,
473 .max_pixel = 1.0,
474 }});
475 } else {
476 const diff_res = try imgdiff.compare(img, golden);
477 if (diff_res.rmse > imgdiff.RMSE_DEFAULT or diff_res.max_pixel > imgdiff.PIXEL_MAX_DEFAULT) {
478 const lbl = try rc.alloc.dupe(u8, label);
479 try rc.failures.append(rc.alloc, .{ .capture_mismatch = .{
480 .label = lbl,
481 .rmse = diff_res.rmse,
482 .max_pixel = diff_res.max_pixel,
483 }});
484 // Write diff heatmap.
485 const heat = try imgdiff.makeDiffImage(rc.alloc, img, golden);
486 defer rc.alloc.free(heat.pixels);
487 var heat_buf: std.ArrayList(u8) = .empty;
488 defer heat_buf.deinit(rc.alloc);
489 try png.encode(rc.alloc, heat, heat_buf.writer(rc.alloc));
490 const heat_path = try std.fmt.allocPrint(rc.alloc, "{s}/{s}.diff.png", .{ out_dir, label });
491 defer rc.alloc.free(heat_path);
492 const heat_file = try std.fs.cwd().createFile(heat_path, .{ .truncate = true });
493 defer heat_file.close();
494 try heat_file.writeAll(heat_buf.items);
495 }
496 }
497 }
498
499 return img;
500 }
501 ```
502
503 This is a sketch — real implementation must get the `renderToOffscreen` call's arguments right by cross-referencing `src/capture.zig`.
504
505 - [ ] **Step 3: Add the tick call in the main loop.**
506
507 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:
508
509 ```zig
510 // --- scenario tick ---
511 // Lands after the blink phase flip so a just-flipped blink correctly
512 // informs io.blink_just_flipped on THIS tick. Lands before the
513 // render_pending bail so a scheduled tick can set render_pending true.
514 if (tick_ctx) |rc| {
515 const s = rc.state orelse break;
516 // Observe blink flip for this iteration (tick.flipped was set in tickBlinkPhase above).
517 if (tick.flipped) rc.blink_flipped_this_iter = true;
518
519 const tick_now = std.time.nanoTimestamp();
520 const outcome = s.tick(tick_now, scenario_runtime.tickIO(rc)) catch |err| {
521 rc.tick_fatal = err;
522 window.should_close = true;
523 break;
524 };
525 switch (outcome) {
526 .working => {},
527 .done => window.should_close = true,
528 }
529 }
530 ```
531
532 `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.
533
534 - [ ] **Step 4: Add `runScenarios` entry point in `src/main.zig`.**
535
536 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.
537
538 ```zig
539 pub fn runScenarios(alloc: std.mem.Allocator, argv: []const [:0]const u8) !void {
540 if (argv.len < 2) {
541 std.debug.print("usage: waystty --scenario <path>\n", .{});
542 std.process.exit(2);
543 }
544 const scenario_path = argv[1];
545
546 // Read + parse scenario.
547 const source = std.fs.cwd().readFileAlloc(alloc, scenario_path, 1 * 1024 * 1024) catch |err| {
548 std.debug.print("scenario: cannot read {s}: {s}\n", .{ scenario_path, @errorName(err) });
549 std.process.exit(2);
550 };
551 defer alloc.free(source);
552
553 var diag: scenario.Diagnostic = .{};
554 var s = scenario.parse(alloc, source, &diag) catch {
555 std.debug.print("scenario: {s}:{d}: {s}\n", .{ scenario_path, diag.line, diag.message });
556 std.process.exit(2);
557 };
558 defer s.deinit();
559
560 // Derive scenario_name from the path (basename without extension).
561 const scenario_name = basenameNoExt(scenario_path);
562
563 // Stand up the runtime (wayland + vulkan + renderer + term + pty-for-scenario).
564 // See src/capture.zig for the pattern. Reuse as much as possible.
565
566 // ... setup elided — see capture.zig for reference ...
567
568 const update = std.posix.getenv("WAYSTTY_SCENARIO_UPDATE") != null;
569 var rc: scenario_runtime.RunContext = .{
570 .alloc = alloc,
571 .scenario_name = scenario_name,
572 .update_goldens = update,
573 .last_blink_on = true,
574 .term = &term,
575 .ctx = &ctx,
576 };
577 defer rc.deinit();
578
579 var state = scenario.ScenarioState.init(alloc, &s, std.time.nanoTimestamp(), .{
580 .cell_w_px = cell_w,
581 .cell_h_px = cell_h,
582 });
583 defer state.deinit();
584
585 // Wire scenarioTickCtx — replaces the Task 3 stub:
586 scenario_tick_ctx = &rc;
587
588 // Run the main loop with scenario mode.
589 try runTerminal(alloc, /* existing args */, &state, .{
590 .cell_w_px = cell_w, .cell_h_px = cell_h,
591 });
592
593 // On exit, decide the exit code.
594 if (scenario_tick_fatal) |err| {
595 const code = scenario_runtime.mapTickError(err);
596 std.debug.print("scenario {s}: {s}\n", .{ scenario_name, @errorName(err) });
597 std.process.exit(@intFromEnum(code));
598 }
599
600 if (rc.failures.items.len > 0) {
601 std.debug.print("scenario {s}: {d} failure(s):\n", .{ scenario_name, rc.failures.items.len });
602 for (rc.failures.items) |f| switch (f) {
603 .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 }),
604 .assert_failed => |a| std.debug.print(" assert line {d}: {s}\n", .{ a.line, a.reason }),
605 };
606 if (!update) std.process.exit(@intFromEnum(scenario_runtime.ExitCode.assertion_mismatch));
607 }
608
609 std.debug.print("scenario {s}: OK\n", .{scenario_name});
610 std.process.exit(0);
611 }
612
613 fn basenameNoExt(path: []const u8) []const u8 {
614 const base = std.fs.path.basename(path);
615 const dot = std.mem.lastIndexOfScalar(u8, base, '.') orelse return base;
616 return base[0..dot];
617 }
618 ```
619
620 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.
621
622 Two things to be careful about:
623 - `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.
624
625 - [ ] **Step 5: Dispatch `--scenario` in `main`.**
626
627 Near the top of `main`, add a branch that detects `--scenario` in argv and calls `runScenarios`:
628
629 ```zig
630 pub fn main() !void {
631 var gpa = std.heap.DebugAllocator(.{}){};
632 defer _ = gpa.deinit();
633 const alloc = gpa.allocator();
634
635 const args = try std.process.argsAlloc(alloc);
636 defer std.process.argsFree(alloc, args);
637
638 if (args.len >= 2 and std.mem.eql(u8, args[1], "--scenario")) {
639 return runScenarios(alloc, args[1..]);
640 }
641 if (args.len >= 2 and std.mem.eql(u8, args[1], "--capture")) {
642 return capture.run(alloc, args[1..]);
643 }
644 // ... existing normal-mode dispatch ...
645 }
646 ```
647
648 - [ ] **Step 6: Finalize `scenario_runtime_mod` in build.zig.**
649
650 Task 2 created a stub module with only `scenario` as an import. Extend it now with the remaining imports the real implementation needs:
651
652 ```zig
653 scenario_runtime_mod.addImport("png", png_mod);
654 scenario_runtime_mod.addImport("imgdiff", imgdiff_lib_mod);
655 scenario_runtime_mod.addImport("renderer", renderer_mod);
656 scenario_runtime_mod.addImport("vt", vt_mod);
657 ```
658
659 (These three `addImport` calls are added to the existing `scenario_runtime_mod` created in Task 2 Step 4.)
660
661 - [ ] **Step 7: Build + run existing tests.**
662
663 ```bash
664 zig build
665 zig build test-scenario
666 ```
667
668 Expected: builds, scenario tests still pass. No scenario fixture runs yet (that's Task 4).
669
670 - [ ] **Step 8: Commit.**
671
672 ```bash
673 git add src/scenario_runtime.zig src/main.zig build.zig
674 git commit -m "$(cat <<'EOF'
675 main: add scenario entry point + runtime callbacks
676
677 --scenario <path> spins up a full waystty runtime (wayland +
678 vulkan + real main loop with blink timer), plays the scenario
679 file through scenario.tick, captures offscreen frames on
680 capture directives, diffs against golden PNGs, and exits with
681 a documented code (0/2/3/4/5/6).
682
683 src/scenario_runtime.zig owns the TickIO callback
684 implementations: write_bytes -> term.write, capture ->
685 offscreen render + PNG + golden diff, blink_just_flipped ->
686 per-iter flag toggled by the main loop.
687
688 First fixture lands in the next commit.
689
690 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
691 EOF
692 )"
693 ```
694
695 ---
696
697 ## Task 4: First fixture + Makefile targets
698
699 **Files:**
700 - Create: `tests/scenarios/blink-bar.scenario`
701 - Create: `tests/scenarios/golden/blink-bar/*.png` (generated in step below)
702 - Modify: `Makefile` — add `scenario` and `scenario-update` targets
703 - Modify: `.gitignore` — add `tests/scenarios/out/`
704
705 - [ ] **Step 1: Write the fixture file.**
706
707 Create `tests/scenarios/blink-bar.scenario`:
708
709 ```
710 # Blinking bar cursor: DECSCUSR 5 then observe two phases.
711 size 80 24
712 timeout 5000ms
713
714 # Inject DECSCUSR 5 directly (no shell) - blinking bar.
715 bytes "\e[5 q"
716
717 # Give blink state machine time to arm.
718 sleep 100ms
719 capture before-flip
720
721 # Advance past the first phase flip (500ms period).
722 sleep 500ms
723 capture after-flip
724 ```
725
726 - [ ] **Step 2: Update .gitignore.**
727
728 Append to `.gitignore`:
729
730 ```
731 tests/scenarios/out/
732 ```
733
734 - [ ] **Step 3: Add Makefile targets.**
735
736 Append to `Makefile`:
737
738 ```makefile
739 .PHONY: scenario scenario-update
740
741 scenario:
742 zig build
743 @for f in tests/scenarios/*.scenario; do \
744 echo "=== $$f ==="; \
745 ./zig-out/bin/waystty --scenario "$$f" || exit $$?; \
746 done
747
748 scenario-update:
749 zig build
750 @for f in tests/scenarios/*.scenario; do \
751 echo "=== $$f (update goldens) ==="; \
752 WAYSTTY_SCENARIO_UPDATE=1 ./zig-out/bin/waystty --scenario "$$f" || exit $$?; \
753 done
754 ```
755
756 - [ ] **Step 4: Generate goldens for the fixture.**
757
758 ```bash
759 cd /home/xanderle/code/rad/waystty
760 make scenario-update
761 ```
762
763 Expected: writes `tests/scenarios/golden/blink-bar/before-flip.png` and `tests/scenarios/golden/blink-bar/after-flip.png`. Exit 0.
764
765 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).
766
767 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.
768
769 - [ ] **Step 5: Run the fixture against goldens.**
770
771 ```bash
772 make scenario
773 ```
774
775 Expected: `scenario blink-bar: OK`, exit 0.
776
777 - [ ] **Step 6: Verify mutation detection.**
778
779 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).
780
781 ```bash
782 make scenario || echo "expected non-zero: $?"
783 ```
784
785 Expected: the runner detects the mismatch, writes `tests/scenarios/out/blink-bar/after-flip.diff.png`, exits with code 4.
786
787 Restore the golden (either via `make scenario-update` or `git checkout tests/scenarios/golden/blink-bar/`).
788
789 - [ ] **Step 7: Commit.**
790
791 ```bash
792 git add tests/scenarios/blink-bar.scenario tests/scenarios/golden/blink-bar/ Makefile .gitignore
793 git commit -m "$(cat <<'EOF'
794 scenarios: first fixture blink-bar + Makefile targets
795
796 blink-bar.scenario drives DECSCUSR 5 and captures two frames
797 separated by 500ms to observe the blink-bar cursor on/off
798 phase boundary. Proves the scenario runner end-to-end:
799
800 - --scenario flag parses and dispatches
801 - bytes directive injects into the VT parser
802 - real main loop fires the blink timer
803 - capture directive offscreens + PNG-writes + diffs goldens
804 - exit code 0 on pass, 4 on golden mismatch
805
806 make scenario — run all *.scenario fixtures
807 make scenario-update — regenerate goldens (WAYSTTY_SCENARIO_UPDATE=1)
808
809 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
810 EOF
811 )"
812 ```
813
814 ---
815
816 ## Post-plan verification
817
818 - [ ] `make scenario` — expected: "scenario blink-bar: OK", exit 0.
819 - [ ] `make scenario-update` — regenerates goldens. Git should report them as unchanged if the generator is deterministic.
820 - [ ] `zig build` — no regressions.
821 - [ ] `zig build test-scenario` — all 50 pure scenario tests still pass.
822 - [ ] Manually launch `./zig-out/bin/waystty` (normal mode, no --scenario) — verify nothing regressed; cursor still blinks, typing works, resize works.
823 - [ ] `rm tests/scenarios/golden/blink-bar/after-flip.png; make scenario || true` — verify mismatch path produces a `diff.png` and non-zero exit.
824
825 ---
826
827 ## Self-review coverage check
828
829 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.
830
831 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.
832
833 Spec "No dummy child process" — Task 1 uses `openpty(3)` + `child_pid = -1` sentinel. No fork, no zombie.
834
835 Spec "Golden update mode: WAYSTTY_SCENARIO_UPDATE=1" — Task 4 reads the env var and Task 5 wires the `scenario-update` target around it.
836
837 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).
838
839 Spec "Non-goals: No wayland event path testing." — this plan does not add any, correct.