a73x

a1685301

scenarios: first fixture blink-bar + Makefile targets

a73x   2026-04-19 13:46

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>

diff --git a/.gitignore b/.gitignore
index df57e1a..7ade714 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,8 @@ perf.data
flamegraph.svg
tests/golden/output/

tests/scenarios/out/

# Scratch test binaries (ad-hoc compilations)
/test_io
/test_io2
diff --git a/Makefile b/Makefile
index f00a753..188b64e 100644
--- a/Makefile
+++ b/Makefile
@@ -43,5 +43,21 @@ bench-baseline:
bench-check:
	$(ZIG) build bench-check -Doptimize=$(OPT)

.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

clean:
	rm -rf zig-out .zig-cache perf.data bench.log flamegraph.svg tests/golden/output
diff --git a/src/main.zig b/src/main.zig
index cc6cdfc..629453e 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -237,15 +237,9 @@ pub fn runScenarios(alloc: std.mem.Allocator, argv: []const [:0]const u8) !u8 {
    };
    defer rc.deinit();

    runTerminal(alloc, &rc) catch |err| switch (err) {
        error.ScenarioScaleChangeNotSupported => {
            // already printed; return exit 6 (other render error)
            return @intFromEnum(scenario_runtime.ExitCode.other_error);
        },
        else => {
            std.debug.print("scenario {s}: runtime error: {s}\n", .{ scenario_name, @errorName(err) });
            return @intFromEnum(scenario_runtime.ExitCode.other_error);
        },
    runTerminal(alloc, &rc) catch |err| {
        std.debug.print("scenario {s}: runtime error: {s}\n", .{ scenario_name, @errorName(err) });
        return @intFromEnum(scenario_runtime.ExitCode.other_error);
    };

    if (rc.tick_fatal) |err| {
@@ -592,6 +586,9 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext
        if (tick_ctx) |rc| {
            if (rc.state) |s| {
                if (tick.flipped) rc.blink_flipped_this_iter = true;
                // Keep the capture callback's cursor rendering in sync with
                // the current blink phase.
                rc.blink_on = blink_state.blink_on;

                const outcome = s.tick(std.time.nanoTimestamp(), scenario_runtime.tickIO(rc)) catch |err| {
                    rc.tick_fatal = err;
@@ -614,10 +611,16 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext
        if (!render_pending) continue;

        if (scale_pending) {
            if (tick_ctx) |_| {
                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", .{});
                return error.ScenarioScaleChangeNotSupported;
            }
            if (tick_ctx != null) {
                // In scenario mode the swapchain is never presented — all
                // captures go through the fixed-size offscreen target.
                // Silently absorb compositor scale changes so mixed-DPI
                // setups (e.g. 2x primary + 1x secondary) do not abort the
                // scenario. Update last_scale so we don't re-enter this path
                // every iteration.
                last_scale = current_scale;
                scale_pending = false;
            } else {
            vk_sync.waitIdleForShutdown(ctx.vkd, ctx.device);

            geom = try rebuildFaceForScale(
@@ -645,6 +648,16 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext

            last_scale = geom.buffer_scale;
            scale_pending = false;
            }
        }

        if (resize_pending and tick_ctx != null) {
            // Scenario mode: compositor window size changes are irrelevant —
            // the terminal grid and offscreen target dimensions are fixed at
            // parse time. Absorb without touching the grid or swapchain.
            last_window_w = window.width;
            last_window_h = window.height;
            resize_pending = false;
        }

        if (resize_pending) {
@@ -694,17 +707,21 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext
        // state we see right now, so no transition can be "missed".
        const current_cursor = term.render_state.cursor;
        const viewport_present = current_cursor.viewport != null;
        // In scenario mode there is no Wayland keyboard-focus event, so
        // treat has_focus as true unconditionally. This lets DECSCUSR 5/6
        // arm the blink timer so blink-phase fixtures work correctly.
        const has_focus_eff = keyboard.has_focus or (tick_ctx != null);
        const want_blink = current_cursor.visible
            and viewport_present
            and current_cursor.blinking
            and keyboard.has_focus;
            and has_focus_eff;

        const cursor_identity_changed =
            !viewportEql(current_cursor.viewport, previous_cursor.viewport)
            or current_cursor.visual_style != previous_cursor.visual_style
            or current_cursor.visible != previous_cursor.visible
            or current_cursor.blinking != previous_cursor.blinking;
        const focus_regained = keyboard.has_focus and !previous_has_focus;
        const focus_regained = has_focus_eff and !previous_has_focus;

        const reconf = reconfigureBlink(
            blink_state,
@@ -779,7 +796,7 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext
            const draw_cursor = shouldDrawCursor(
                current_cursor.visible,
                current_cursor.viewport != null,
                keyboard.has_focus,
                has_focus_eff,
                current_cursor.blinking,
                blink_state.blink_on,
            );
@@ -945,7 +962,7 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext
        frame_timing.present_us = submit_timing.present_us;

        frame_ring.push(frame_timing);
        previous_has_focus = keyboard.has_focus;
        previous_has_focus = has_focus_eff;

        clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan);
        if (!bench_unthrottled) frame_loop.commitRender() catch {};
diff --git a/src/scenario_runtime.zig b/src/scenario_runtime.zig
index a66c361..8d73f4e 100644
--- a/src/scenario_runtime.zig
+++ b/src/scenario_runtime.zig
@@ -30,6 +30,9 @@ pub const RunContext = struct {

    failures: std.ArrayListUnmanaged(Failure) = .{},
    blink_flipped_this_iter: bool = false,
    /// Current blink phase — true = cursor on, false = cursor off.
    /// Updated by the main loop each tick before scenario.tick() fires.
    blink_on: bool = true,

    // Populated by runTerminal once it has built the ScenarioState on top of
    // its own terminal/renderer handles. The main loop tick hook reads this.
@@ -138,6 +141,35 @@ pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image {
        rc.baseline,
    );

    // --- cursor overlay ---
    // Mirror shouldDrawCursor logic from main.zig: in scenario mode we
    // always treat has_focus as true. Draw if visible, in-viewport, and
    // (not blinking OR currently blink-on).
    const cur = term.render_state.cursor;
    const draw_cursor = cur.visible
        and cur.viewport != null
        and (!cur.blinking or rc.blink_on);
    if (draw_cursor) {
        const vp = cur.viewport.?;
        const shape: renderer.CursorShape = switch (cur.visual_style) {
            .block, .block_hollow => .block,
            .underline => .underline,
            .bar => .bar,
        };
        var inst = renderer.cursorInstance(
            shape,
            rc.cell_w,
            rc.cell_h,
            1, // scenario mode is always scale-1 in offscreen
            atlas.cursorUV(),
        );
        inst.cell_pos = .{
            @floatFromInt(vp.x),
            @floatFromInt(vp.y),
        };
        try instances.append(rc.alloc, inst);
    }

    // If scenario bytes pulled in new glyphs, the atlas pixels are newer
    // than the GPU copy. Re-upload before rendering.
    if (atlas.dirty) {
diff --git a/tests/scenarios/blink-bar.scenario b/tests/scenarios/blink-bar.scenario
new file mode 100644
index 0000000..74ee438
--- /dev/null
+++ b/tests/scenarios/blink-bar.scenario
@@ -0,0 +1,14 @@
# 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
diff --git a/tests/scenarios/golden/blink-bar/after-flip.png b/tests/scenarios/golden/blink-bar/after-flip.png
new file mode 100644
index 0000000..22ac3b2
Binary files /dev/null and b/tests/scenarios/golden/blink-bar/after-flip.png differ
diff --git a/tests/scenarios/golden/blink-bar/before-flip.png b/tests/scenarios/golden/blink-bar/before-flip.png
new file mode 100644
index 0000000..3ed3d6e
Binary files /dev/null and b/tests/scenarios/golden/blink-bar/before-flip.png differ