a73x

a1685301

scenarios: first fixture blink-bar + Makefile targets

a73x   2026-04-19 13:46

Commit message
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

Three fixes required to make this work:

1. has_focus_eff: scenario mode has no keyboard focus event.
   Treat has_focus as true unconditionally in scenario mode so
   DECSCUSR 5/6 arms the blink timer and shouldDrawCursor draws
   it correctly during captures.

2. Scale/resize suppression: compositor promotes the waystty
   window to the display's native scale on mixed-DPI setups
   (DP-4 @2x). Scenario mode absorbs scale and resize events
   without touching the offscreen target or grid — only the
   fixed-size offscreen matters for captures.

3. Cursor rendering in captureCb: buildInstancesForSnapshot
   only builds cell instances. Added a cursor overlay in
   captureCb using renderer.cursorInstance, driven by rc.blink_on
   (kept in sync each tick). Without this, before-flip and
   after-flip were always identical (no cursor visible in either).

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

.gitignore
Old New
@@ -8,6 +8,8 @@ perf.data
8 flamegraph.svg 8 flamegraph.svg
9 tests/golden/output/ 9 tests/golden/output/
10 10
11 tests/scenarios/out/
12
11 # Scratch test binaries (ad-hoc compilations) 13 # Scratch test binaries (ad-hoc compilations)
12 /test_io 14 /test_io
13 /test_io2 15 /test_io2
Makefile
Old New
@@ -43,5 +43,21 @@ bench-baseline:
43 bench-check: 43 bench-check:
44 $(ZIG) build bench-check -Doptimize=$(OPT) 44 $(ZIG) build bench-check -Doptimize=$(OPT)
45 45
46 .PHONY: scenario scenario-update
47
48 scenario:
49 $(ZIG) build
50 @for f in tests/scenarios/*.scenario; do \
51 echo "=== $$f ==="; \
52 ./zig-out/bin/waystty --scenario "$$f" || exit $$?; \
53 done
54
55 scenario-update:
56 $(ZIG) build
57 @for f in tests/scenarios/*.scenario; do \
58 echo "=== $$f (update goldens) ==="; \
59 WAYSTTY_SCENARIO_UPDATE=1 ./zig-out/bin/waystty --scenario "$$f" || exit $$?; \
60 done
61
46 clean: 62 clean:
47 rm -rf zig-out .zig-cache perf.data bench.log flamegraph.svg tests/golden/output 63 rm -rf zig-out .zig-cache perf.data bench.log flamegraph.svg tests/golden/output
src/main.zig
Old New
@@ -237,15 +237,9 @@ pub fn runScenarios(alloc: std.mem.Allocator, argv: []const [:0]const u8) !u8 {
237 }; 237 };
238 defer rc.deinit(); 238 defer rc.deinit();
239 239
240 runTerminal(alloc, &rc) catch |err| switch (err) { 240 runTerminal(alloc, &rc) catch |err| {
241 error.ScenarioScaleChangeNotSupported => { 241 std.debug.print("scenario {s}: runtime error: {s}\n", .{ scenario_name, @errorName(err) });
242 // already printed; return exit 6 (other render error) 242 return @intFromEnum(scenario_runtime.ExitCode.other_error);
243 return @intFromEnum(scenario_runtime.ExitCode.other_error);
244 },
245 else => {
246 std.debug.print("scenario {s}: runtime error: {s}\n", .{ scenario_name, @errorName(err) });
247 return @intFromEnum(scenario_runtime.ExitCode.other_error);
248 },
249 }; 243 };
250 244
251 if (rc.tick_fatal) |err| { 245 if (rc.tick_fatal) |err| {
@@ -592,6 +586,9 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext
592 if (tick_ctx) |rc| { 586 if (tick_ctx) |rc| {
593 if (rc.state) |s| { 587 if (rc.state) |s| {
594 if (tick.flipped) rc.blink_flipped_this_iter = true; 588 if (tick.flipped) rc.blink_flipped_this_iter = true;
589 // Keep the capture callback's cursor rendering in sync with
590 // the current blink phase.
591 rc.blink_on = blink_state.blink_on;
595 592
596 const outcome = s.tick(std.time.nanoTimestamp(), scenario_runtime.tickIO(rc)) catch |err| { 593 const outcome = s.tick(std.time.nanoTimestamp(), scenario_runtime.tickIO(rc)) catch |err| {
597 rc.tick_fatal = err; 594 rc.tick_fatal = err;
@@ -614,10 +611,16 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext
614 if (!render_pending) continue; 611 if (!render_pending) continue;
615 612
616 if (scale_pending) { 613 if (scale_pending) {
617 if (tick_ctx) |_| { 614 if (tick_ctx != null) {
618 std.debug.print("scenario: runtime scale change not supported; please pin the compositor output to scale 1 (or re-run on a scale-1 monitor).\n", .{}); 615 // In scenario mode the swapchain is never presented — all
619 return error.ScenarioScaleChangeNotSupported; 616 // captures go through the fixed-size offscreen target.
620 } 617 // Silently absorb compositor scale changes so mixed-DPI
618 // setups (e.g. 2x primary + 1x secondary) do not abort the
619 // scenario. Update last_scale so we don't re-enter this path
620 // every iteration.
621 last_scale = current_scale;
622 scale_pending = false;
623 } else {
621 vk_sync.waitIdleForShutdown(ctx.vkd, ctx.device); 624 vk_sync.waitIdleForShutdown(ctx.vkd, ctx.device);
622 625
623 geom = try rebuildFaceForScale( 626 geom = try rebuildFaceForScale(
@@ -645,6 +648,16 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext
645 648
646 last_scale = geom.buffer_scale; 649 last_scale = geom.buffer_scale;
647 scale_pending = false; 650 scale_pending = false;
651 }
652 }
653
654 if (resize_pending and tick_ctx != null) {
655 // Scenario mode: compositor window size changes are irrelevant —
656 // the terminal grid and offscreen target dimensions are fixed at
657 // parse time. Absorb without touching the grid or swapchain.
658 last_window_w = window.width;
659 last_window_h = window.height;
660 resize_pending = false;
648 } 661 }
649 662
650 if (resize_pending) { 663 if (resize_pending) {
@@ -694,17 +707,21 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext
694 // state we see right now, so no transition can be "missed". 707 // state we see right now, so no transition can be "missed".
695 const current_cursor = term.render_state.cursor; 708 const current_cursor = term.render_state.cursor;
696 const viewport_present = current_cursor.viewport != null; 709 const viewport_present = current_cursor.viewport != null;
710 // In scenario mode there is no Wayland keyboard-focus event, so
711 // treat has_focus as true unconditionally. This lets DECSCUSR 5/6
712 // arm the blink timer so blink-phase fixtures work correctly.
713 const has_focus_eff = keyboard.has_focus or (tick_ctx != null);
697 const want_blink = current_cursor.visible 714 const want_blink = current_cursor.visible
698 and viewport_present 715 and viewport_present
699 and current_cursor.blinking 716 and current_cursor.blinking
700 and keyboard.has_focus; 717 and has_focus_eff;
701 718
702 const cursor_identity_changed = 719 const cursor_identity_changed =
703 !viewportEql(current_cursor.viewport, previous_cursor.viewport) 720 !viewportEql(current_cursor.viewport, previous_cursor.viewport)
704 or current_cursor.visual_style != previous_cursor.visual_style 721 or current_cursor.visual_style != previous_cursor.visual_style
705 or current_cursor.visible != previous_cursor.visible 722 or current_cursor.visible != previous_cursor.visible
706 or current_cursor.blinking != previous_cursor.blinking; 723 or current_cursor.blinking != previous_cursor.blinking;
707 const focus_regained = keyboard.has_focus and !previous_has_focus; 724 const focus_regained = has_focus_eff and !previous_has_focus;
708 725
709 const reconf = reconfigureBlink( 726 const reconf = reconfigureBlink(
710 blink_state, 727 blink_state,
@@ -779,7 +796,7 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext
779 const draw_cursor = shouldDrawCursor( 796 const draw_cursor = shouldDrawCursor(
780 current_cursor.visible, 797 current_cursor.visible,
781 current_cursor.viewport != null, 798 current_cursor.viewport != null,
782 keyboard.has_focus, 799 has_focus_eff,
783 current_cursor.blinking, 800 current_cursor.blinking,
784 blink_state.blink_on, 801 blink_state.blink_on,
785 ); 802 );
@@ -945,7 +962,7 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext
945 frame_timing.present_us = submit_timing.present_us; 962 frame_timing.present_us = submit_timing.present_us;
946 963
947 frame_ring.push(frame_timing); 964 frame_ring.push(frame_timing);
948 previous_has_focus = keyboard.has_focus; 965 previous_has_focus = has_focus_eff;
949 966
950 clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan); 967 clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan);
951 if (!bench_unthrottled) frame_loop.commitRender() catch {}; 968 if (!bench_unthrottled) frame_loop.commitRender() catch {};
src/scenario_runtime.zig
Old New
@@ -30,6 +30,9 @@ pub const RunContext = struct {
30 30
31 failures: std.ArrayListUnmanaged(Failure) = .{}, 31 failures: std.ArrayListUnmanaged(Failure) = .{},
32 blink_flipped_this_iter: bool = false, 32 blink_flipped_this_iter: bool = false,
33 /// Current blink phase — true = cursor on, false = cursor off.
34 /// Updated by the main loop each tick before scenario.tick() fires.
35 blink_on: bool = true,
33 36
34 // Populated by runTerminal once it has built the ScenarioState on top of 37 // Populated by runTerminal once it has built the ScenarioState on top of
35 // its own terminal/renderer handles. The main loop tick hook reads this. 38 // its own terminal/renderer handles. The main loop tick hook reads this.
@@ -138,6 +141,35 @@ pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image {
138 rc.baseline, 141 rc.baseline,
139 ); 142 );
140 143
144 // --- cursor overlay ---
145 // Mirror shouldDrawCursor logic from main.zig: in scenario mode we
146 // always treat has_focus as true. Draw if visible, in-viewport, and
147 // (not blinking OR currently blink-on).
148 const cur = term.render_state.cursor;
149 const draw_cursor = cur.visible
150 and cur.viewport != null
151 and (!cur.blinking or rc.blink_on);
152 if (draw_cursor) {
153 const vp = cur.viewport.?;
154 const shape: renderer.CursorShape = switch (cur.visual_style) {
155 .block, .block_hollow => .block,
156 .underline => .underline,
157 .bar => .bar,
158 };
159 var inst = renderer.cursorInstance(
160 shape,
161 rc.cell_w,
162 rc.cell_h,
163 1, // scenario mode is always scale-1 in offscreen
164 atlas.cursorUV(),
165 );
166 inst.cell_pos = .{
167 @floatFromInt(vp.x),
168 @floatFromInt(vp.y),
169 };
170 try instances.append(rc.alloc, inst);
171 }
172
141 // If scenario bytes pulled in new glyphs, the atlas pixels are newer 173 // If scenario bytes pulled in new glyphs, the atlas pixels are newer
142 // than the GPU copy. Re-upload before rendering. 174 // than the GPU copy. Re-upload before rendering.
143 if (atlas.dirty) { 175 if (atlas.dirty) {
tests/scenarios/blink-bar.scenario
Old New
@@ -0,0 +1,14 @@
1 # Blinking bar cursor: DECSCUSR 5 then observe two phases.
2 size 80 24
3 timeout 5000ms
4
5 # Inject DECSCUSR 5 directly (no shell) - blinking bar.
6 bytes "\e[5 q"
7
8 # Give blink state machine time to arm.
9 sleep 100ms
10 capture before-flip
11
12 # Advance past the first phase flip (500ms period).
13 sleep 500ms
14 capture after-flip
tests/scenarios/golden/blink-bar/after-flip.png
Old New
Binary file
Binary files differ
tests/scenarios/golden/blink-bar/before-flip.png
Old New
Binary file
Binary files differ