a73x

c05013f9

Add waystty implementation plan

a73x   2026-04-08 05:30

Monolithic plan covering all seven phases: scaffolding, PTY, VT wrapper,
headless proof-of-life, font rendering, Wayland protocol, Vulkan
renderer, and full integration. TDD with bite-sized tasks.

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

diff --git a/docs/superpowers/plans/2026-04-07-waystty-implementation.md b/docs/superpowers/plans/2026-04-07-waystty-implementation.md
new file mode 100644
index 0000000..725453f
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-07-waystty-implementation.md
@@ -0,0 +1,4288 @@
# waystty Implementation Plan

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

**Goal:** Build waystty — a minimal, hackable Wayland terminal emulator in Zig — using libghostty-vt for terminal emulation, Vulkan for rendering, and freetype for glyph rasterization.

**Architecture:** Six modules (`main`, `wayland`, `vt`, `pty`, `font`, `renderer`). Single-threaded `poll()` event loop multiplexing Wayland and PTY file descriptors. Vulkan renders the terminal grid via a single instanced draw call per frame, sampling a glyph atlas texture.

**Tech Stack:** Zig 0.15, libghostty-vt (Zig module), zig-wayland, vulkan-zig, freetype2, fontconfig, xkbcommon, wayland-client, glslc.

## Important Deviation From Spec

The spec describes wrapping libghostty-vt via `@cImport <ghostty/vt.h>`. During planning research we discovered that **ghostty-vt is published as a native Zig module** with an idiomatic Zig API. We use the Zig module directly — no `@cImport` needed. This simplifies `vt.zig` significantly.

The reference for the Zig module API is `example/zig-vt/` in the ghostty repo. Engineers executing this plan should consult that example when the API shape isn't clear from this plan.

## Reference Files

When in doubt, consult these external references:

- **ghostty-vt Zig API**: `ghostty-org/ghostty:example/zig-vt/src/main.zig` and `build.zig`
- **ghostty-vt C headers** (for semantics reference): `ghostty-org/ghostty:include/ghostty/vt.h`
- **Ghostling C reference**: `ghostty-org/ghostling:main.c` — full end-to-end terminal
- **zig-wayland example**: `ifreund/zig-wayland:example/hello/hello.zig`
- **vulkan-zig examples**: `Snektron/vulkan-zig:examples/`

---

## Phase 0: Project Scaffolding

### Task 0.1: Initialize project and git

**Files:**
- Create: `.gitignore`
- Already exists: `docs/superpowers/specs/2026-04-07-waystty-design.md`
- Already exists: `.git/`

- [ ] **Step 1: Create .gitignore**

```
zig-out/
.zig-cache/
*.o
*.swp
```

- [ ] **Step 2: Commit**

```bash
git add .gitignore
git commit -m "chore: add gitignore"
```

---

### Task 0.2: Create build.zig.zon with dependencies

**Files:**
- Create: `build.zig.zon`

- [ ] **Step 1: Write build.zig.zon**

```zig
.{
    .name = .waystty,
    .version = "0.0.1",
    .fingerprint = 0x1234567890abcdef,
    .minimum_zig_version = "0.15.0",
    .paths = .{
        "build.zig",
        "build.zig.zon",
        "src",
        "shaders",
    },
    .dependencies = .{
        .wayland = .{
            .url = "git+https://github.com/ifreund/zig-wayland",
            .hash = "",
        },
        .vulkan = .{
            .url = "git+https://github.com/Snektron/vulkan-zig",
            .hash = "",
        },
        .vulkan_headers = .{
            .url = "git+https://github.com/KhronosGroup/Vulkan-Headers",
            .hash = "",
        },
        .ghostty = .{
            .url = "git+https://github.com/ghostty-org/ghostty",
            .hash = "",
            .lazy = true,
        },
    },
}
```

- [ ] **Step 2: Fetch dependencies to populate hashes**

```bash
zig fetch --save git+https://github.com/ifreund/zig-wayland
zig fetch --save git+https://github.com/Snektron/vulkan-zig
zig fetch --save git+https://github.com/KhronosGroup/Vulkan-Headers
zig fetch --save git+https://github.com/ghostty-org/ghostty
```

Expected: each command updates `build.zig.zon` with the correct hash.

- [ ] **Step 3: Generate fingerprint**

Edit `build.zig.zon` and change the `.fingerprint` value to a unique `u64`. Zig will print an error with the expected value when you first try to build — use that.

- [ ] **Step 4: Commit**

```bash
git add build.zig.zon
git commit -m "chore: add build.zig.zon with dependencies"
```

---

### Task 0.3: Create minimal build.zig

**Files:**
- Create: `build.zig`
- Create: `src/main.zig`

- [ ] **Step 1: Write minimal src/main.zig**

```zig
const std = @import("std");

pub fn main() !void {
    std.debug.print("waystty\n", .{});
}
```

- [ ] **Step 2: Write initial build.zig (executable only, deps added later)**

```zig
const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const exe = b.addExecutable(.{
        .name = "waystty",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| run_cmd.addArgs(args);

    const run_step = b.step("run", "Run waystty");
    run_step.dependOn(&run_cmd.step);

    const test_step = b.step("test", "Run unit tests");
    const tests = b.addTest(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });
    test_step.dependOn(&b.addRunArtifact(tests).step);
}
```

- [ ] **Step 3: Build and run**

```bash
zig build run
```

Expected: prints `waystty`.

- [ ] **Step 4: Commit**

```bash
git add build.zig src/main.zig
git commit -m "chore: minimal build.zig and main.zig"
```

---

### Task 0.4: Add ghostty-vt Zig module to build.zig

**Files:**
- Modify: `build.zig`

- [ ] **Step 1: Create src/vt.zig stub that imports ghostty-vt**

```zig
const std = @import("std");
const ghostty_vt = @import("ghostty-vt");

test "ghostty-vt module imports" {
    // Smoke test — just reference the module
    _ = ghostty_vt;
}
```

- [ ] **Step 2: Modify build.zig to expose the ghostty-vt module**

Add after `const exe = b.addExecutable(...)` and before `b.installArtifact(exe);`:

```zig
if (b.lazyDependency("ghostty", .{
    .target = target,
    .optimize = optimize,
})) |ghostty_dep| {
    exe.root_module.addImport("ghostty-vt", ghostty_dep.module("ghostty-vt"));
}
```

Also add the same for the test step, and add `src/vt.zig` as a module that `main.zig` can import:

```zig
const vt_module = b.addModule("vt", .{
    .root_source_file = b.path("src/vt.zig"),
    .target = target,
    .optimize = optimize,
});
if (b.lazyDependency("ghostty", .{
    .target = target,
    .optimize = optimize,
})) |ghostty_dep| {
    vt_module.addImport("ghostty-vt", ghostty_dep.module("ghostty-vt"));
}
exe.root_module.addImport("vt", vt_module);
```

- [ ] **Step 3: Update main.zig to import vt**

```zig
const std = @import("std");
const vt = @import("vt");

pub fn main() !void {
    std.debug.print("waystty\n", .{});
    _ = vt;
}
```

- [ ] **Step 4: Build**

```bash
zig build
```

Expected: builds successfully. First build will be slow as it compiles ghostty-vt.

- [ ] **Step 5: Commit**

```bash
git add build.zig src/main.zig src/vt.zig
git commit -m "build: wire ghostty-vt module"
```

---

## Phase 1: PTY Module

### Task 1.1: Create pty.zig with forkpty wrapper — failing test first

**Files:**
- Create: `src/pty.zig`
- Modify: `build.zig` (add pty module and test step)

- [ ] **Step 1: Write failing test for Pty.spawn**

Create `src/pty.zig`:

```zig
const std = @import("std");
const c = @cImport({
    @cInclude("pty.h");
    @cInclude("termios.h");
    @cInclude("unistd.h");
    @cInclude("sys/ioctl.h");
    @cInclude("fcntl.h");
    @cInclude("sys/wait.h");
});

pub const Pty = struct {
    master_fd: std.posix.fd_t,
    child_pid: std.posix.pid_t,

    pub const SpawnOptions = struct {
        cols: u16,
        rows: u16,
        shell: []const u8,
    };

    pub fn spawn(opts: SpawnOptions) !Pty {
        _ = opts;
        return error.NotImplemented;
    }

    pub fn deinit(self: *Pty) void {
        _ = self;
    }
};

test "Pty.spawn launches /bin/sh and returns valid fd" {
    var pty = try Pty.spawn(.{
        .cols = 80,
        .rows = 24,
        .shell = "/bin/sh",
    });
    defer pty.deinit();
    try std.testing.expect(pty.master_fd >= 0);
    try std.testing.expect(pty.child_pid > 0);
}
```

- [ ] **Step 2: Register pty module and test step in build.zig**

Add to `build.zig` (alongside the `vt_module` section):

```zig
const pty_module = b.addModule("pty", .{
    .root_source_file = b.path("src/pty.zig"),
    .target = target,
    .optimize = optimize,
});
pty_module.link_libc = true;
exe.root_module.addImport("pty", pty_module);
```

And extend the test step to also run pty tests:

```zig
const pty_tests = b.addTest(.{
    .root_source_file = b.path("src/pty.zig"),
    .target = target,
    .optimize = optimize,
});
pty_tests.linkLibC();
pty_tests.linkSystemLibrary("util"); // for forkpty
test_step.dependOn(&b.addRunArtifact(pty_tests).step);
```

- [ ] **Step 3: Run the failing test**

```bash
zig build test
```

Expected: FAIL with `error.NotImplemented`.

- [ ] **Step 4: Implement Pty.spawn using forkpty**

Replace the body of `spawn`:

```zig
pub fn spawn(opts: SpawnOptions) !Pty {
    var master: c_int = undefined;
    var winsize = c.struct_winsize{
        .ws_row = opts.rows,
        .ws_col = opts.cols,
        .ws_xpixel = 0,
        .ws_ypixel = 0,
    };

    const pid = c.forkpty(&master, null, null, &winsize);
    if (pid < 0) return error.ForkptyFailed;

    if (pid == 0) {
        // Child process
        _ = c.setenv("TERM", "xterm-256color", 1);

        const shell_z = std.heap.page_allocator.dupeZ(u8, opts.shell) catch std.process.exit(1);
        const argv = [_:null]?[*:0]const u8{ shell_z.ptr, null };
        const envp: [*:null]?[*:0]const u8 = @ptrCast(std.c.environ);
        _ = std.c.execve(shell_z.ptr, &argv, envp);
        std.process.exit(1);
    }

    // Parent: set master fd non-blocking
    const flags = try std.posix.fcntl(master, std.posix.F.GETFL, 0);
    _ = try std.posix.fcntl(master, std.posix.F.SETFL, flags | @as(u32, @bitCast(std.posix.O{ .NONBLOCK = true })));

    return .{
        .master_fd = master,
        .child_pid = pid,
    };
}

pub fn deinit(self: *Pty) void {
    std.posix.close(self.master_fd);
    _ = std.c.kill(self.child_pid, std.c.SIG.TERM);
    _ = std.c.waitpid(self.child_pid, null, 0);
}
```

- [ ] **Step 5: Run test**

```bash
zig build test
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add src/pty.zig build.zig
git commit -m "feat(pty): spawn child shell via forkpty"
```

---

### Task 1.2: Add Pty.read/write helpers

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

- [ ] **Step 1: Write failing test**

Add to `src/pty.zig`:

```zig
test "Pty.write and read echoes through shell" {
    var pty = try Pty.spawn(.{
        .cols = 80,
        .rows = 24,
        .shell = "/bin/sh",
    });
    defer pty.deinit();

    // Give shell a moment to start
    std.Thread.sleep(100 * std.time.ns_per_ms);

    // Write a command
    _ = try pty.write("echo hello\n");

    // Drain output for up to 1 second
    var buf: [4096]u8 = undefined;
    var seen_hello = false;
    const deadline = std.time.nanoTimestamp() + 1 * std.time.ns_per_s;
    while (std.time.nanoTimestamp() < deadline) {
        const n = pty.read(&buf) catch |err| switch (err) {
            error.WouldBlock => {
                std.Thread.sleep(10 * std.time.ns_per_ms);
                continue;
            },
            else => return err,
        };
        if (std.mem.indexOf(u8, buf[0..n], "hello") != null) {
            seen_hello = true;
            break;
        }
    }
    try std.testing.expect(seen_hello);
}
```

- [ ] **Step 2: Run failing test**

```bash
zig build test
```

Expected: FAIL (read/write don't exist yet).

- [ ] **Step 3: Implement read and write**

Add methods to `Pty`:

```zig
pub fn read(self: *Pty, buf: []u8) !usize {
    return std.posix.read(self.master_fd, buf) catch |err| switch (err) {
        error.WouldBlock => error.WouldBlock,
        else => err,
    };
}

pub fn write(self: *Pty, data: []const u8) !usize {
    return std.posix.write(self.master_fd, data);
}
```

- [ ] **Step 4: Run test**

```bash
zig build test
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/pty.zig
git commit -m "feat(pty): add read/write helpers"
```

---

### Task 1.3: Add Pty.resize

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

- [ ] **Step 1: Write failing test**

Add to `src/pty.zig`:

```zig
test "Pty.resize sets winsize via ioctl" {
    var pty = try Pty.spawn(.{
        .cols = 80,
        .rows = 24,
        .shell = "/bin/sh",
    });
    defer pty.deinit();

    try pty.resize(120, 40);

    var ws: c.struct_winsize = undefined;
    const rc = c.ioctl(pty.master_fd, c.TIOCGWINSZ, &ws);
    try std.testing.expectEqual(@as(c_int, 0), rc);
    try std.testing.expectEqual(@as(c_ushort, 120), ws.ws_col);
    try std.testing.expectEqual(@as(c_ushort, 40), ws.ws_row);
}
```

- [ ] **Step 2: Run failing test**

```bash
zig build test
```

Expected: FAIL (resize doesn't exist).

- [ ] **Step 3: Implement resize**

```zig
pub fn resize(self: *Pty, cols: u16, rows: u16) !void {
    var ws = c.struct_winsize{
        .ws_row = rows,
        .ws_col = cols,
        .ws_xpixel = 0,
        .ws_ypixel = 0,
    };
    if (c.ioctl(self.master_fd, c.TIOCSWINSZ, &ws) < 0) {
        return error.IoctlFailed;
    }
}
```

- [ ] **Step 4: Run test**

```bash
zig build test
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/pty.zig
git commit -m "feat(pty): add resize"
```

---

## Phase 2: VT Module (libghostty-vt wrapper)

**⚠️ Implementer note:** The exact ghostty-vt Zig API must be discovered from the upstream example. Before writing Task 2.x steps, read `ghostty-org/ghostty:example/zig-vt/src/main.zig` to confirm the exact type and function names. The code below uses plausible names that match the C API semantics — adjust to match the real Zig API.

### Task 2.1: Discover the ghostty-vt Zig API

**Files:**
- Modify: `src/vt.zig` (documentation comments)

- [ ] **Step 1: Fetch the upstream example**

```bash
cd .zig-cache/p && find . -name "main.zig" -path "*example/zig-vt*" | head -1
```

Expected: prints a path inside the fetched ghostty dependency.

- [ ] **Step 2: Read the example and take notes in src/vt.zig**

Read the `main.zig` and `build.zig` of `example/zig-vt`. At the top of `src/vt.zig`, write a doc comment listing the actual types and methods you found:

```zig
//! vt.zig — wrapper around the ghostty-vt Zig module.
//!
//! Upstream API observed from example/zig-vt/src/main.zig:
//!   const ghostty_vt = @import("ghostty-vt");
//!   ghostty_vt.Terminal — init/deinit, write(bytes), resize(cols, rows)
//!   ghostty_vt.KeyEncoder — init, syncFromTerminal, encode
//!   ghostty_vt.MouseEncoder — init, syncFromTerminal, encode
//!   ghostty_vt.RenderState — init, update, rowIterator
//!
//! The implementer should update these notes with the exact type and
//! method names found in the pinned version of ghostty-vt before
//! continuing to Task 2.2. If upstream names differ, tasks 2.2-2.7
//! must be adjusted to match.
```

- [ ] **Step 3: Commit notes**

```bash
git add src/vt.zig
git commit -m "docs(vt): note upstream ghostty-vt API shape"
```

---

### Task 2.2: Wrap Terminal lifecycle

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

- [ ] **Step 1: Write failing test**

Add to `src/vt.zig`:

```zig
test "Terminal init/deinit" {
    var term = try Terminal.init(std.testing.allocator, .{
        .cols = 80,
        .rows = 24,
        .max_scrollback = 1000,
    });
    defer term.deinit();
    try std.testing.expectEqual(@as(u16, 80), term.cols);
    try std.testing.expectEqual(@as(u16, 24), term.rows);
}
```

- [ ] **Step 2: Register vt tests in build.zig**

Add a `vt_tests` block alongside `pty_tests` in `build.zig`:

```zig
const vt_tests = b.addTest(.{
    .root_source_file = b.path("src/vt.zig"),
    .target = target,
    .optimize = optimize,
});
if (b.lazyDependency("ghostty", .{
    .target = target,
    .optimize = optimize,
})) |ghostty_dep| {
    vt_tests.root_module.addImport("ghostty-vt", ghostty_dep.module("ghostty-vt"));
}
test_step.dependOn(&b.addRunArtifact(vt_tests).step);
```

- [ ] **Step 3: Run failing test**

```bash
zig build test
```

Expected: FAIL (Terminal not defined).

- [ ] **Step 4: Implement Terminal.init/deinit**

Replace the body of `src/vt.zig`:

```zig
const std = @import("std");
const ghostty_vt = @import("ghostty-vt");

pub const InitOptions = struct {
    cols: u16,
    rows: u16,
    max_scrollback: u32 = 1000,
};

pub const Terminal = struct {
    inner: ghostty_vt.Terminal,
    cols: u16,
    rows: u16,

    pub fn init(allocator: std.mem.Allocator, opts: InitOptions) !Terminal {
        const inner = try ghostty_vt.Terminal.init(allocator, .{
            .cols = opts.cols,
            .rows = opts.rows,
            .max_scrollback = opts.max_scrollback,
        });
        return .{
            .inner = inner,
            .cols = opts.cols,
            .rows = opts.rows,
        };
    }

    pub fn deinit(self: *Terminal) void {
        self.inner.deinit();
    }
};
```

**If the upstream Terminal.init signature differs from this** (e.g. it returns a pointer, or the options struct has different fields), adjust to match the real signature from `example/zig-vt`.

- [ ] **Step 5: Run test**

```bash
zig build test
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add src/vt.zig build.zig
git commit -m "feat(vt): Terminal init/deinit wrapper"
```

---

### Task 2.3: Terminal.write (feed bytes to VT parser)

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

- [ ] **Step 1: Write failing test**

Add to `src/vt.zig`:

```zig
test "Terminal.write feeds bytes to VT parser" {
    var term = try Terminal.init(std.testing.allocator, .{
        .cols = 80,
        .rows = 24,
    });
    defer term.deinit();

    try term.write("Hello");
    // We can't easily assert on internal state here yet — that's Task 2.4.
    // Just verify the call succeeds.
}
```

- [ ] **Step 2: Run failing test**

```bash
zig build test
```

Expected: FAIL (`write` not defined on Terminal).

- [ ] **Step 3: Implement Terminal.write**

Add to `Terminal`:

```zig
pub fn write(self: *Terminal, bytes: []const u8) !void {
    try self.inner.write(bytes);
}
```

Adjust to match the actual upstream method name if different (e.g. `vtWrite`, `feed`, etc.).

- [ ] **Step 4: Run test**

```bash
zig build test
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/vt.zig
git commit -m "feat(vt): Terminal.write"
```

---

### Task 2.4: Render state snapshot and row iteration

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

- [ ] **Step 1: Write failing test**

Add to `src/vt.zig`:

```zig
test "RenderState iterates cells after write" {
    var term = try Terminal.init(std.testing.allocator, .{
        .cols = 80,
        .rows = 24,
    });
    defer term.deinit();

    try term.write("Hi");

    var render_state = try RenderState.init(std.testing.allocator);
    defer render_state.deinit();

    try render_state.update(&term);

    // First row should contain 'H' at col 0, 'i' at col 1.
    var row_iter = try render_state.rowIterator();
    defer row_iter.deinit();

    const first_row = (try row_iter.next()) orelse return error.NoRow;
    defer first_row.deinit();

    var cells = first_row.cells();
    defer cells.deinit();

    const cell0 = (try cells.next()) orelse return error.NoCell;
    try std.testing.expectEqual(@as(u21, 'H'), cell0.codepoint);

    const cell1 = (try cells.next()) orelse return error.NoCell;
    try std.testing.expectEqual(@as(u21, 'i'), cell1.codepoint);
}
```

- [ ] **Step 2: Run failing test**

```bash
zig build test
```

Expected: FAIL (RenderState etc. not defined).

- [ ] **Step 3: Implement RenderState wrapper**

Add to `src/vt.zig`:

```zig
pub const Cell = struct {
    codepoint: u21,
    fg: Rgb,
    bg: Rgb,
    bold: bool,
    italic: bool,
    inverse: bool,
    underline: bool,
};

pub const Rgb = struct { r: u8, g: u8, b: u8 };

pub const RenderState = struct {
    inner: ghostty_vt.RenderState,

    pub fn init(allocator: std.mem.Allocator) !RenderState {
        return .{ .inner = try ghostty_vt.RenderState.init(allocator) };
    }

    pub fn deinit(self: *RenderState) void {
        self.inner.deinit();
    }

    pub fn update(self: *RenderState, term: *Terminal) !void {
        try self.inner.update(&term.inner);
    }

    pub fn rowIterator(self: *RenderState) !RowIterator {
        return .{ .inner = try self.inner.rowIterator() };
    }
};

pub const RowIterator = struct {
    inner: ghostty_vt.RenderState.RowIterator,

    pub fn deinit(self: *RowIterator) void {
        self.inner.deinit();
    }

    pub fn next(self: *RowIterator) !?Row {
        const row = (try self.inner.next()) orelse return null;
        return .{ .inner = row };
    }
};

pub const Row = struct {
    inner: ghostty_vt.RenderState.Row,

    pub fn deinit(self: *Row) void {
        self.inner.deinit();
    }

    pub fn cells(self: *Row) CellIterator {
        return .{ .inner = self.inner.cells() };
    }
};

pub const CellIterator = struct {
    inner: ghostty_vt.RenderState.CellIterator,

    pub fn deinit(self: *CellIterator) void {
        self.inner.deinit();
    }

    pub fn next(self: *CellIterator) !?Cell {
        const c = (try self.inner.next()) orelse return null;
        return .{
            .codepoint = c.codepoint,
            .fg = .{ .r = c.fg.r, .g = c.fg.g, .b = c.fg.b },
            .bg = .{ .r = c.bg.r, .g = c.bg.g, .b = c.bg.b },
            .bold = c.bold,
            .italic = c.italic,
            .inverse = c.inverse,
            .underline = c.underline,
        };
    }
};
```

**⚠️ This code assumes an API shape. Adjust every field name and method to match what's in `example/zig-vt/src/main.zig`.** The structure (init/deinit/iterator-of-iterator) is correct based on the C API; the exact Zig names may differ.

- [ ] **Step 4: Run test**

```bash
zig build test
```

Expected: PASS (after adjusting to real API).

- [ ] **Step 5: Commit**

```bash
git add src/vt.zig
git commit -m "feat(vt): RenderState with row/cell iteration"
```

---

### Task 2.5: Key encoder

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

- [ ] **Step 1: Write failing test**

Add to `src/vt.zig`:

```zig
test "KeyEncoder encodes a keystroke" {
    var term = try Terminal.init(std.testing.allocator, .{
        .cols = 80,
        .rows = 24,
    });
    defer term.deinit();

    var encoder = try KeyEncoder.init(std.testing.allocator);
    defer encoder.deinit();

    try encoder.syncFromTerminal(&term);

    var buf: [64]u8 = undefined;
    const n = try encoder.encode(&buf, .{
        .keysym = 'a',
        .modifiers = .{},
        .action = .press,
    });
    try std.testing.expect(n >= 1);
    try std.testing.expectEqual(@as(u8, 'a'), buf[0]);
}
```

- [ ] **Step 2: Run failing test**

```bash
zig build test
```

Expected: FAIL.

- [ ] **Step 3: Implement KeyEncoder**

Add to `src/vt.zig`:

```zig
pub const Modifiers = struct {
    ctrl: bool = false,
    shift: bool = false,
    alt: bool = false,
    super: bool = false,
};

pub const KeyAction = enum { press, release, repeat };

pub const KeyEvent = struct {
    keysym: u32,
    modifiers: Modifiers,
    action: KeyAction,
};

pub const KeyEncoder = struct {
    inner: ghostty_vt.KeyEncoder,

    pub fn init(allocator: std.mem.Allocator) !KeyEncoder {
        return .{ .inner = try ghostty_vt.KeyEncoder.init(allocator) };
    }

    pub fn deinit(self: *KeyEncoder) void {
        self.inner.deinit();
    }

    pub fn syncFromTerminal(self: *KeyEncoder, term: *Terminal) !void {
        try self.inner.syncFromTerminal(&term.inner);
    }

    pub fn encode(self: *KeyEncoder, buf: []u8, ev: KeyEvent) !usize {
        const inner_ev = ghostty_vt.KeyEvent{
            .keysym = ev.keysym,
            .modifiers = .{
                .ctrl = ev.modifiers.ctrl,
                .shift = ev.modifiers.shift,
                .alt = ev.modifiers.alt,
                .super = ev.modifiers.super,
            },
            .action = switch (ev.action) {
                .press => .press,
                .release => .release,
                .repeat => .repeat,
            },
        };
        return try self.inner.encode(buf, inner_ev);
    }
};
```

- [ ] **Step 4: Run test**

```bash
zig build test
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/vt.zig
git commit -m "feat(vt): KeyEncoder wrapper"
```

---

### Task 2.6: Mouse encoder

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

- [ ] **Step 1: Write failing test**

```zig
test "MouseEncoder encodes a click" {
    var term = try Terminal.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
    defer term.deinit();

    var encoder = try MouseEncoder.init(std.testing.allocator);
    defer encoder.deinit();

    try encoder.syncFromTerminal(&term);

    var buf: [64]u8 = undefined;
    // With no mouse tracking mode enabled, encode should return 0
    const n = try encoder.encode(&buf, .{
        .x = 10,
        .y = 5,
        .button = .left,
        .action = .press,
        .modifiers = .{},
    });
    try std.testing.expectEqual(@as(usize, 0), n);
}
```

- [ ] **Step 2: Run failing test**

```bash
zig build test
```

Expected: FAIL.

- [ ] **Step 3: Implement MouseEncoder**

```zig
pub const MouseButton = enum { left, middle, right, none };

pub const MouseAction = enum { press, release, motion, scroll_up, scroll_down };

pub const MouseEvent = struct {
    x: u16,
    y: u16,
    button: MouseButton,
    action: MouseAction,
    modifiers: Modifiers,
};

pub const MouseEncoder = struct {
    inner: ghostty_vt.MouseEncoder,

    pub fn init(allocator: std.mem.Allocator) !MouseEncoder {
        return .{ .inner = try ghostty_vt.MouseEncoder.init(allocator) };
    }

    pub fn deinit(self: *MouseEncoder) void {
        self.inner.deinit();
    }

    pub fn syncFromTerminal(self: *MouseEncoder, term: *Terminal) !void {
        try self.inner.syncFromTerminal(&term.inner);
    }

    pub fn encode(self: *MouseEncoder, buf: []u8, ev: MouseEvent) !usize {
        const inner_ev = ghostty_vt.MouseEvent{
            .x = ev.x,
            .y = ev.y,
            .button = switch (ev.button) {
                .left => .left,
                .middle => .middle,
                .right => .right,
                .none => .none,
            },
            .action = switch (ev.action) {
                .press => .press,
                .release => .release,
                .motion => .motion,
                .scroll_up => .scroll_up,
                .scroll_down => .scroll_down,
            },
            .modifiers = .{
                .ctrl = ev.modifiers.ctrl,
                .shift = ev.modifiers.shift,
                .alt = ev.modifiers.alt,
                .super = ev.modifiers.super,
            },
        };
        return try self.inner.encode(buf, inner_ev);
    }
};
```

- [ ] **Step 4: Run test**

```bash
zig build test
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/vt.zig
git commit -m "feat(vt): MouseEncoder wrapper"
```

---

### Task 2.7: Terminal.resize

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

- [ ] **Step 1: Write failing test**

```zig
test "Terminal.resize updates dimensions" {
    var term = try Terminal.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
    defer term.deinit();

    try term.resize(120, 40);
    try std.testing.expectEqual(@as(u16, 120), term.cols);
    try std.testing.expectEqual(@as(u16, 40), term.rows);
}
```

- [ ] **Step 2: Run failing test**

```bash
zig build test
```

Expected: FAIL.

- [ ] **Step 3: Implement Terminal.resize**

Add to `Terminal`:

```zig
pub fn resize(self: *Terminal, cols: u16, rows: u16) !void {
    try self.inner.resize(cols, rows);
    self.cols = cols;
    self.rows = rows;
}
```

- [ ] **Step 4: Run test**

```bash
zig build test
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/vt.zig
git commit -m "feat(vt): Terminal.resize"
```

---

## Phase 3: Headless Integration — Proof Of Life

Before touching Wayland or Vulkan, we wire PTY + VT together into a headless tool that proves libghostty is working.

### Task 3.1: Headless mode — dump grid to stdout

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

- [ ] **Step 1: Write main loop that spawns shell, feeds output to terminal, dumps grid**

Replace `src/main.zig`:

```zig
const std = @import("std");
const vt = @import("vt");
const pty = @import("pty");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    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], "--headless")) {
        return runHeadless(alloc);
    }

    std.debug.print("waystty (run with --headless for CLI dump mode)\n", .{});
}

fn runHeadless(alloc: std.mem.Allocator) !void {
    const shell = std.posix.getenv("SHELL") orelse "/bin/sh";

    var p = try pty.Pty.spawn(.{
        .cols = 80,
        .rows = 24,
        .shell = shell,
    });
    defer p.deinit();

    var term = try vt.Terminal.init(alloc, .{
        .cols = 80,
        .rows = 24,
    });
    defer term.deinit();

    // Run command: echo hello; exit
    _ = try p.write("echo hello; exit\n");

    // Drain output
    var buf: [4096]u8 = undefined;
    const deadline = std.time.nanoTimestamp() + 2 * std.time.ns_per_s;
    while (std.time.nanoTimestamp() < deadline) {
        const n = p.read(&buf) catch |err| switch (err) {
            error.WouldBlock => {
                std.Thread.sleep(10 * std.time.ns_per_ms);
                continue;
            },
            else => return err,
        };
        if (n == 0) break;
        try term.write(buf[0..n]);
    }

    // Dump the grid
    var render_state = try vt.RenderState.init(alloc);
    defer render_state.deinit();

    try render_state.update(&term);

    var row_iter = try render_state.rowIterator();
    defer row_iter.deinit();

    const stdout = std.io.getStdOut().writer();
    while (try row_iter.next()) |row_| {
        var row = row_;
        defer row.deinit();
        var cells = row.cells();
        defer cells.deinit();
        while (try cells.next()) |cell| {
            var utf8: [4]u8 = undefined;
            const len = try std.unicode.utf8Encode(cell.codepoint, &utf8);
            try stdout.writeAll(utf8[0..len]);
        }
        try stdout.writeAll("\n");
    }
}
```

- [ ] **Step 2: Build and run**

```bash
zig build run -- --headless
```

Expected: dumps a grid with `hello` visible in the output (and trailing spaces/empty rows). This proves PTY + VT are working together.

- [ ] **Step 3: Commit**

```bash
git add src/main.zig
git commit -m "feat: headless mode — pty + vt proof of life"
```

---

## Phase 4: Font Rendering

### Task 4.1: fontconfig lookup

**Files:**
- Create: `src/font.zig`
- Modify: `build.zig` (add font module, link fontconfig, freetype)

- [ ] **Step 1: Write failing test**

Create `src/font.zig`:

```zig
const std = @import("std");
const c = @cImport({
    @cInclude("fontconfig/fontconfig.h");
    @cInclude("ft2build.h");
    @cInclude("freetype/freetype.h");
});

pub const FontLookup = struct {
    path: [:0]u8,
    index: c_int,

    pub fn deinit(self: *FontLookup, alloc: std.mem.Allocator) void {
        alloc.free(self.path);
    }
};

pub fn lookupMonospace(alloc: std.mem.Allocator) !FontLookup {
    _ = alloc;
    return error.NotImplemented;
}

test "lookupMonospace returns a valid font path" {
    var lookup = try lookupMonospace(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    // Just check the file exists
    const file = try std.fs.openFileAbsolute(lookup.path, .{});
    file.close();
}
```

- [ ] **Step 2: Register font module and tests in build.zig**

```zig
const font_module = b.addModule("font", .{
    .root_source_file = b.path("src/font.zig"),
    .target = target,
    .optimize = optimize,
});
font_module.link_libc = true;
font_module.linkSystemLibrary("fontconfig", .{});
font_module.linkSystemLibrary("freetype2", .{});
exe.root_module.addImport("font", font_module);

const font_tests = b.addTest(.{
    .root_source_file = b.path("src/font.zig"),
    .target = target,
    .optimize = optimize,
});
font_tests.linkLibC();
font_tests.linkSystemLibrary("fontconfig");
font_tests.linkSystemLibrary("freetype2");
test_step.dependOn(&b.addRunArtifact(font_tests).step);
```

- [ ] **Step 3: Run failing test**

```bash
zig build test
```

Expected: FAIL.

- [ ] **Step 4: Implement lookupMonospace**

```zig
pub fn lookupMonospace(alloc: std.mem.Allocator) !FontLookup {
    if (c.FcInit() == c.FcFalse) return error.FcInitFailed;

    const pattern = c.FcPatternCreate() orelse return error.FcPatternCreate;
    defer c.FcPatternDestroy(pattern);

    _ = c.FcPatternAddString(pattern, c.FC_FAMILY, "monospace");
    _ = c.FcPatternAddInteger(pattern, c.FC_WEIGHT, c.FC_WEIGHT_REGULAR);
    _ = c.FcPatternAddInteger(pattern, c.FC_SLANT, c.FC_SLANT_ROMAN);

    _ = c.FcConfigSubstitute(null, pattern, c.FcMatchPattern);
    c.FcDefaultSubstitute(pattern);

    var result: c.FcResult = undefined;
    const matched = c.FcFontMatch(null, pattern, &result) orelse return error.FcFontMatchFailed;
    defer c.FcPatternDestroy(matched);

    var file_cstr: [*c]c.FcChar8 = null;
    if (c.FcPatternGetString(matched, c.FC_FILE, 0, &file_cstr) != c.FcResultMatch) {
        return error.FcGetFileFailed;
    }

    var index: c_int = 0;
    _ = c.FcPatternGetInteger(matched, c.FC_INDEX, 0, &index);

    const slice = std.mem.span(@as([*:0]const u8, @ptrCast(file_cstr)));
    const dup = try alloc.dupeZ(u8, slice);
    return .{ .path = dup, .index = index };
}
```

- [ ] **Step 5: Run test**

```bash
zig build test
```

Expected: PASS (requires fontconfig installed + a monospace font on the system).

- [ ] **Step 6: Commit**

```bash
git add src/font.zig build.zig
git commit -m "feat(font): fontconfig monospace lookup"
```

---

### Task 4.2: Freetype face loading and glyph rasterization

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

- [ ] **Step 1: Write failing test**

Add to `src/font.zig`:

```zig
test "Face rasterizes glyph 'M'" {
    var lookup = try lookupMonospace(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
    defer face.deinit();

    const glyph = try face.rasterize('M');
    try std.testing.expect(glyph.width > 0);
    try std.testing.expect(glyph.height > 0);
    try std.testing.expect(glyph.bitmap.len == @as(usize, glyph.width) * @as(usize, glyph.height));
}
```

- [ ] **Step 2: Run failing test**

```bash
zig build test
```

Expected: FAIL.

- [ ] **Step 3: Implement Face**

Add to `src/font.zig`:

```zig
pub const Glyph = struct {
    codepoint: u21,
    width: u32,
    height: u32,
    bearing_x: i32,
    bearing_y: i32,
    advance_x: i32,
    bitmap: []u8, // R8, owned
};

pub const Face = struct {
    alloc: std.mem.Allocator,
    library: c.FT_Library,
    face: c.FT_Face,
    px_size: u32,

    pub fn init(alloc: std.mem.Allocator, path: [:0]const u8, index: c_int, px_size: u32) !Face {
        var library: c.FT_Library = null;
        if (c.FT_Init_FreeType(&library) != 0) return error.FtInitFailed;
        errdefer _ = c.FT_Done_FreeType(library);

        var face: c.FT_Face = null;
        if (c.FT_New_Face(library, path.ptr, index, &face) != 0) return error.FtNewFaceFailed;
        errdefer _ = c.FT_Done_Face(face);

        if (c.FT_Set_Pixel_Sizes(face, 0, px_size) != 0) return error.FtSetPixelSizesFailed;

        return .{
            .alloc = alloc,
            .library = library,
            .face = face,
            .px_size = px_size,
        };
    }

    pub fn deinit(self: *Face) void {
        _ = c.FT_Done_Face(self.face);
        _ = c.FT_Done_FreeType(self.library);
    }

    pub fn rasterize(self: *Face, codepoint: u21) !Glyph {
        const glyph_index = c.FT_Get_Char_Index(self.face, codepoint);
        if (c.FT_Load_Glyph(self.face, glyph_index, c.FT_LOAD_RENDER) != 0) {
            return error.FtLoadGlyphFailed;
        }

        const slot = self.face.*.glyph;
        const bitmap = slot.*.bitmap;
        const w: u32 = bitmap.width;
        const h: u32 = bitmap.rows;

        const pixels = try self.alloc.alloc(u8, @as(usize, w) * @as(usize, h));
        // bitmap.buffer may be null if width/height are 0
        if (w > 0 and h > 0) {
            const pitch: i32 = bitmap.pitch;
            const abs_pitch: u32 = @intCast(@abs(pitch));
            var y: u32 = 0;
            while (y < h) : (y += 1) {
                const src_row = bitmap.buffer + @as(usize, y) * abs_pitch;
                const dst_row = pixels.ptr + @as(usize, y) * w;
                @memcpy(dst_row[0..w], src_row[0..w]);
            }
        }

        return .{
            .codepoint = codepoint,
            .width = w,
            .height = h,
            .bearing_x = slot.*.bitmap_left,
            .bearing_y = slot.*.bitmap_top,
            .advance_x = @intCast(slot.*.advance.x >> 6),
            .bitmap = pixels,
        };
    }

    pub fn freeGlyph(self: *Face, glyph: Glyph) void {
        self.alloc.free(glyph.bitmap);
    }

    pub fn cellWidth(self: *Face) u32 {
        // Measure advance of 'M' for monospace cell width
        const m_index = c.FT_Get_Char_Index(self.face, 'M');
        _ = c.FT_Load_Glyph(self.face, m_index, c.FT_LOAD_DEFAULT);
        return @intCast(self.face.*.glyph.*.advance.x >> 6);
    }

    pub fn cellHeight(self: *Face) u32 {
        const metrics = self.face.*.size.*.metrics;
        return @intCast((metrics.ascender - metrics.descender) >> 6);
    }
};
```

- [ ] **Step 4: Run test**

```bash
zig build test
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/font.zig
git commit -m "feat(font): freetype face + rasterize"
```

---

### Task 4.3: Glyph atlas

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

- [ ] **Step 1: Write failing test**

```zig
test "Atlas packs multiple glyphs and returns UVs" {
    var lookup = try lookupMonospace(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
    defer face.deinit();

    var atlas = try Atlas.init(std.testing.allocator, 512, 512);
    defer atlas.deinit();

    const uv_m = try atlas.getOrInsert(&face, 'M');
    const uv_a = try atlas.getOrInsert(&face, 'a');

    // Both should have valid UVs within [0, 1]
    try std.testing.expect(uv_m.u0 >= 0.0 and uv_m.u1 <= 1.0);
    try std.testing.expect(uv_a.u0 >= 0.0 and uv_a.u1 <= 1.0);

    // Second call for 'M' should return cached UV (same values)
    const uv_m2 = try atlas.getOrInsert(&face, 'M');
    try std.testing.expectEqual(uv_m.u0, uv_m2.u0);
    try std.testing.expectEqual(uv_m.v0, uv_m2.v0);
}
```

- [ ] **Step 2: Run failing test**

```bash
zig build test
```

Expected: FAIL.

- [ ] **Step 3: Implement Atlas**

Add to `src/font.zig`:

```zig
pub const GlyphUV = struct {
    u0: f32,
    v0: f32,
    u1: f32,
    v1: f32,
    width: u32,
    height: u32,
    bearing_x: i32,
    bearing_y: i32,
    advance_x: i32,
};

pub const Atlas = struct {
    alloc: std.mem.Allocator,
    width: u32,
    height: u32,
    pixels: []u8, // R8
    // Row-based packer state
    cursor_x: u32,
    cursor_y: u32,
    row_height: u32,
    cache: std.AutoHashMap(u21, GlyphUV),
    dirty: bool,

    pub fn init(alloc: std.mem.Allocator, width: u32, height: u32) !Atlas {
        const pixels = try alloc.alloc(u8, @as(usize, width) * @as(usize, height));
        @memset(pixels, 0);
        return .{
            .alloc = alloc,
            .width = width,
            .height = height,
            .pixels = pixels,
            .cursor_x = 0,
            .cursor_y = 0,
            .row_height = 0,
            .cache = std.AutoHashMap(u21, GlyphUV).init(alloc),
            .dirty = true,
        };
    }

    pub fn deinit(self: *Atlas) void {
        self.alloc.free(self.pixels);
        self.cache.deinit();
    }

    pub fn getOrInsert(self: *Atlas, face: *Face, codepoint: u21) !GlyphUV {
        if (self.cache.get(codepoint)) |uv| return uv;

        const glyph = try face.rasterize(codepoint);
        defer face.freeGlyph(glyph);

        // Advance to next row if this glyph doesn't fit
        if (self.cursor_x + glyph.width > self.width) {
            self.cursor_x = 0;
            self.cursor_y += self.row_height;
            self.row_height = 0;
        }
        if (self.cursor_y + glyph.height > self.height) {
            return error.AtlasFull;
        }

        // Blit glyph into atlas
        var y: u32 = 0;
        while (y < glyph.height) : (y += 1) {
            const src = glyph.bitmap.ptr + @as(usize, y) * glyph.width;
            const dst = self.pixels.ptr + (@as(usize, self.cursor_y + y) * self.width) + self.cursor_x;
            @memcpy(dst[0..glyph.width], src[0..glyph.width]);
        }

        const uv = GlyphUV{
            .u0 = @as(f32, @floatFromInt(self.cursor_x)) / @as(f32, @floatFromInt(self.width)),
            .v0 = @as(f32, @floatFromInt(self.cursor_y)) / @as(f32, @floatFromInt(self.height)),
            .u1 = @as(f32, @floatFromInt(self.cursor_x + glyph.width)) / @as(f32, @floatFromInt(self.width)),
            .v1 = @as(f32, @floatFromInt(self.cursor_y + glyph.height)) / @as(f32, @floatFromInt(self.height)),
            .width = glyph.width,
            .height = glyph.height,
            .bearing_x = glyph.bearing_x,
            .bearing_y = glyph.bearing_y,
            .advance_x = glyph.advance_x,
        };

        try self.cache.put(codepoint, uv);
        self.cursor_x += glyph.width;
        if (glyph.height > self.row_height) self.row_height = glyph.height;
        self.dirty = true;

        return uv;
    }
};
```

- [ ] **Step 4: Run test**

```bash
zig build test
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/font.zig
git commit -m "feat(font): glyph atlas with row-based packing"
```

---

## Phase 5: Wayland Module

### Task 5.1: Wire zig-wayland into build.zig

**Files:**
- Create: `src/wayland.zig`
- Modify: `build.zig`

- [ ] **Step 1: Create src/wayland.zig stub**

```zig
const std = @import("std");
const wayland = @import("wayland");
const wl = wayland.client.wl;
const xdg = wayland.client.xdg;

test "wayland module imports" {
    _ = wl;
    _ = xdg;
}
```

- [ ] **Step 2: Set up the zig-wayland scanner in build.zig**

Add near the top of `build` function:

```zig
const Scanner = @import("wayland").Scanner;

pub fn build(b: *std.Build) void {
    // ... existing code ...

    const scanner = Scanner.create(b, .{});
    scanner.addSystemProtocol("stable/xdg-shell/xdg-shell.xml");
    scanner.addSystemProtocol("staging/cursor-shape/cursor-shape-v1.xml");
    scanner.addSystemProtocol("staging/fractional-scale/fractional-scale-v1.xml");
    // Viewporter is required by fractional-scale
    scanner.addSystemProtocol("stable/viewporter/viewporter.xml");

    scanner.generate("wl_compositor", 6);
    scanner.generate("wl_shm", 1);
    scanner.generate("wl_seat", 9);
    scanner.generate("wl_output", 4);
    scanner.generate("xdg_wm_base", 6);
    scanner.generate("wp_cursor_shape_manager_v1", 1);
    scanner.generate("wp_fractional_scale_manager_v1", 1);
    scanner.generate("wp_viewporter", 1);

    // The wayland module from the dependency
    const wayland_dep = b.dependency("wayland", .{});
    const wayland_module = b.createModule(.{
        .root_source_file = scanner.result,
    });
    _ = wayland_dep;

    // ... create exe ...

    const wayland_src_module = b.addModule("wayland", .{
        .root_source_file = b.path("src/wayland.zig"),
        .target = target,
        .optimize = optimize,
    });
    wayland_src_module.addImport("wayland", wayland_module);
    wayland_src_module.link_libc = true;
    wayland_src_module.linkSystemLibrary("wayland-client", .{});
    exe.root_module.addImport("wayland-client", wayland_src_module);
}
```

**Note:** this scanner block is approximate — consult the current `ifreund/zig-wayland` README, as the exact build.zig integration has changed over Zig releases. Adjust to match the version you pinned.

- [ ] **Step 3: Build**

```bash
zig build
```

Expected: builds successfully. Zig-wayland scans the protocol XMLs and generates bindings.

- [ ] **Step 4: Commit**

```bash
git add src/wayland.zig build.zig
git commit -m "build(wayland): wire zig-wayland scanner"
```

---

### Task 5.2: Connect to wl_display and bind globals

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

- [ ] **Step 1: Write a minimal Wayland client that binds compositor, xdg_wm_base, seat**

```zig
const std = @import("std");
const wayland = @import("wayland");
const wl = wayland.client.wl;
const xdg = wayland.client.xdg;

pub const Globals = struct {
    compositor: ?*wl.Compositor = null,
    wm_base: ?*xdg.WmBase = null,
    seat: ?*wl.Seat = null,
};

pub const Connection = struct {
    display: *wl.Display,
    registry: *wl.Registry,
    globals: Globals,

    pub fn init() !Connection {
        const display = try wl.Display.connect(null);
        const registry = try display.getRegistry();

        var globals = Globals{};
        registry.setListener(*Globals, registryListener, &globals);

        _ = display.roundtrip();

        if (globals.compositor == null) return error.NoCompositor;
        if (globals.wm_base == null) return error.NoXdgWmBase;
        if (globals.seat == null) return error.NoSeat;

        return .{
            .display = display,
            .registry = registry,
            .globals = globals,
        };
    }

    pub fn deinit(self: *Connection) void {
        self.display.disconnect();
    }
};

fn registryListener(
    registry: *wl.Registry,
    event: wl.Registry.Event,
    globals: *Globals,
) void {
    switch (event) {
        .global => |g| {
            const iface = std.mem.span(g.interface);
            if (std.mem.eql(u8, iface, wl.Compositor.interface.name)) {
                globals.compositor = registry.bind(g.name, wl.Compositor, 6) catch return;
            } else if (std.mem.eql(u8, iface, xdg.WmBase.interface.name)) {
                globals.wm_base = registry.bind(g.name, xdg.WmBase, 6) catch return;
            } else if (std.mem.eql(u8, iface, wl.Seat.interface.name)) {
                globals.seat = registry.bind(g.name, wl.Seat, 9) catch return;
            }
        },
        .global_remove => {},
    }
}
```

**Adjust the exact interface names, versions, and event field names to match the generated `zig-wayland` bindings.** If `g.interface` is `[*:0]const u8`, `std.mem.span` works; if it's a different type, use the matching accessor.

- [ ] **Step 2: Build**

```bash
zig build
```

Expected: builds. Won't crash without a live Wayland session because we aren't running it yet.

- [ ] **Step 3: Smoke-test under a live Wayland session**

In your Wayland session, run:

```bash
zig build
./zig-out/bin/waystty --wayland-smoke-test
```

For now, add a `--wayland-smoke-test` branch to `main.zig` that calls `wayland.Connection.init()` and prints "connected" on success. Commit both the branch and the connection wiring together.

Expected: prints `connected`.

- [ ] **Step 4: Commit**

```bash
git add src/wayland.zig src/main.zig
git commit -m "feat(wayland): connect to display and bind globals"
```

---

### Task 5.3: Create surface and xdg_toplevel

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

- [ ] **Step 1: Extend Connection with createWindow**

```zig
pub const Window = struct {
    surface: *wl.Surface,
    xdg_surface: *xdg.Surface,
    xdg_toplevel: *xdg.Toplevel,
    configured: bool = false,
    should_close: bool = false,
    width: u32 = 800,
    height: u32 = 600,

    pub fn deinit(self: *Window) void {
        self.xdg_toplevel.destroy();
        self.xdg_surface.destroy();
        self.surface.destroy();
    }
};

pub fn createWindow(self: *Connection, title: [*:0]const u8) !*Window {
    const compositor = self.globals.compositor orelse return error.NoCompositor;
    const wm_base = self.globals.wm_base orelse return error.NoXdgWmBase;

    const window = try std.heap.c_allocator.create(Window);
    window.* = .{
        .surface = try compositor.createSurface(),
        .xdg_surface = undefined,
        .xdg_toplevel = undefined,
    };

    window.xdg_surface = try wm_base.getXdgSurface(window.surface);
    window.xdg_toplevel = try window.xdg_surface.getToplevel();

    window.xdg_toplevel.setTitle(title);
    window.xdg_toplevel.setAppId("waystty");

    window.xdg_surface.setListener(*Window, xdgSurfaceListener, window);
    window.xdg_toplevel.setListener(*Window, xdgToplevelListener, window);
    wm_base.setListener(*xdg.WmBase, wmBaseListener, wm_base);

    window.surface.commit();
    _ = self.display.roundtrip();

    return window;
}

fn wmBaseListener(wm_base: *xdg.WmBase, event: xdg.WmBase.Event, _: *xdg.WmBase) void {
    switch (event) {
        .ping => |p| wm_base.pong(p.serial),
    }
}

fn xdgSurfaceListener(surface: *xdg.Surface, event: xdg.Surface.Event, window: *Window) void {
    switch (event) {
        .configure => |c| {
            surface.ackConfigure(c.serial);
            window.configured = true;
        },
    }
}

fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Window) void {
    switch (event) {
        .configure => |c| {
            if (c.width > 0) window.width = @intCast(c.width);
            if (c.height > 0) window.height = @intCast(c.height);
        },
        .close => window.should_close = true,
        else => {},
    }
}
```

- [ ] **Step 2: Extend smoke test to create window**

In `main.zig --wayland-smoke-test`, after connecting, create a window and call `display.roundtrip()` twice, then print `window created`.

- [ ] **Step 3: Run smoke test**

```bash
zig build run -- --wayland-smoke-test
```

Expected: prints `connected`, `window created`, exits cleanly.

- [ ] **Step 4: Commit**

```bash
git add src/wayland.zig src/main.zig
git commit -m "feat(wayland): create surface + xdg_toplevel"
```

---

### Task 5.4: Keyboard input with xkbcommon

**Files:**
- Modify: `src/wayland.zig`
- Modify: `build.zig` (link xkbcommon)

- [ ] **Step 1: Link xkbcommon in build.zig**

In the `wayland_src_module` block:

```zig
wayland_src_module.linkSystemLibrary("xkbcommon", .{});
```

- [ ] **Step 2: Add xkbcommon cImport and keyboard struct**

At top of `src/wayland.zig`:

```zig
const c = @cImport({
    @cInclude("xkbcommon/xkbcommon.h");
    @cInclude("sys/mman.h");
    @cInclude("unistd.h");
});

pub const KeyboardEvent = struct {
    keysym: u32,
    modifiers: Modifiers,
    action: Action,
    utf8: [8]u8,
    utf8_len: u8,

    pub const Modifiers = struct {
        ctrl: bool = false,
        shift: bool = false,
        alt: bool = false,
        super: bool = false,
    };

    pub const Action = enum { press, release, repeat };
};

pub const Keyboard = struct {
    wl_keyboard: *wl.Keyboard,
    xkb_ctx: ?*c.xkb_context,
    xkb_keymap: ?*c.xkb_keymap = null,
    xkb_state: ?*c.xkb_state = null,
    event_queue: std.ArrayList(KeyboardEvent),
    // repeat info
    repeat_rate: u32 = 25,  // keys per second
    repeat_delay: u32 = 500, // ms
    last_key: ?u32 = null,
    last_key_time_ns: i128 = 0,
    has_focus: bool = false,

    pub fn init(alloc: std.mem.Allocator, seat: *wl.Seat) !*Keyboard {
        const kb = try alloc.create(Keyboard);
        kb.* = .{
            .wl_keyboard = try seat.getKeyboard(),
            .xkb_ctx = c.xkb_context_new(c.XKB_CONTEXT_NO_FLAGS),
            .event_queue = std.ArrayList(KeyboardEvent).init(alloc),
        };
        kb.wl_keyboard.setListener(*Keyboard, keyboardListener, kb);
        return kb;
    }

    pub fn deinit(self: *Keyboard, alloc: std.mem.Allocator) void {
        if (self.xkb_state) |s| c.xkb_state_unref(s);
        if (self.xkb_keymap) |m| c.xkb_keymap_unref(m);
        if (self.xkb_ctx) |ctx| c.xkb_context_unref(ctx);
        self.wl_keyboard.release();
        self.event_queue.deinit();
        alloc.destroy(self);
    }
};

fn keyboardListener(_: *wl.Keyboard, event: wl.Keyboard.Event, kb: *Keyboard) void {
    switch (event) {
        .keymap => |k| {
            if (k.format != .xkb_v1) return;
            const map_mem = c.mmap(
                null,
                k.size,
                c.PROT_READ,
                c.MAP_PRIVATE,
                k.fd,
                0,
            );
            defer _ = c.munmap(map_mem, k.size);
            defer _ = c.close(k.fd);

            if (map_mem == c.MAP_FAILED) return;

            const new_keymap = c.xkb_keymap_new_from_string(
                kb.xkb_ctx,
                @ptrCast(map_mem),
                c.XKB_KEYMAP_FORMAT_TEXT_V1,
                c.XKB_KEYMAP_COMPILE_NO_FLAGS,
            ) orelse return;

            const new_state = c.xkb_state_new(new_keymap) orelse {
                c.xkb_keymap_unref(new_keymap);
                return;
            };

            if (kb.xkb_state) |s| c.xkb_state_unref(s);
            if (kb.xkb_keymap) |m| c.xkb_keymap_unref(m);
            kb.xkb_keymap = new_keymap;
            kb.xkb_state = new_state;
        },
        .enter => {
            kb.has_focus = true;
        },
        .leave => {
            kb.has_focus = false;
            kb.last_key = null;
        },
        .key => |k| {
            const state = kb.xkb_state orelse return;
            const keycode: u32 = k.key + 8; // evdev -> xkb offset
            const keysym = c.xkb_state_key_get_one_sym(state, keycode);

            var utf8: [8]u8 = undefined;
            const len = c.xkb_state_key_get_utf8(state, keycode, &utf8, utf8.len);

            const action: KeyboardEvent.Action = if (k.state == .pressed) .press else .release;

            const mods = KeyboardEvent.Modifiers{
                .ctrl = c.xkb_state_mod_name_is_active(state, "Control", c.XKB_STATE_MODS_EFFECTIVE) > 0,
                .shift = c.xkb_state_mod_name_is_active(state, "Shift", c.XKB_STATE_MODS_EFFECTIVE) > 0,
                .alt = c.xkb_state_mod_name_is_active(state, "Mod1", c.XKB_STATE_MODS_EFFECTIVE) > 0,
                .super = c.xkb_state_mod_name_is_active(state, "Mod4", c.XKB_STATE_MODS_EFFECTIVE) > 0,
            };

            var ev = KeyboardEvent{
                .keysym = keysym,
                .modifiers = mods,
                .action = action,
                .utf8 = utf8,
                .utf8_len = @intCast(@min(len, 8)),
            };

            kb.event_queue.append(ev) catch return;

            if (action == .press) {
                kb.last_key = keycode;
                kb.last_key_time_ns = std.time.nanoTimestamp();
            } else if (kb.last_key == keycode) {
                kb.last_key = null;
            }
        },
        .modifiers => |m| {
            const state = kb.xkb_state orelse return;
            _ = c.xkb_state_update_mask(
                state,
                m.mods_depressed,
                m.mods_latched,
                m.mods_locked,
                0,
                0,
                m.group,
            );
        },
        .repeat_info => |r| {
            kb.repeat_rate = @intCast(r.rate);
            kb.repeat_delay = @intCast(r.delay);
        },
    }
}
```

- [ ] **Step 3: Build**

```bash
zig build
```

Expected: builds.

- [ ] **Step 4: Commit**

```bash
git add src/wayland.zig build.zig
git commit -m "feat(wayland): keyboard input with xkbcommon"
```

---

### Task 5.5: Key repeat tick

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

- [ ] **Step 1: Add tickRepeat method**

Add to `Keyboard`:

```zig
/// Called from the main loop. If a key is currently held and enough time has
/// passed, push a synthetic repeat event to the queue.
pub fn tickRepeat(self: *Keyboard) void {
    const last_key = self.last_key orelse return;
    const state = self.xkb_state orelse return;

    const now = std.time.nanoTimestamp();
    const elapsed_ms = @divTrunc(now - self.last_key_time_ns, std.time.ns_per_ms);
    if (elapsed_ms < @as(i128, self.repeat_delay)) return;

    const interval_ms: i128 = @divTrunc(1000, @as(i128, self.repeat_rate));
    const repeats_due = @divTrunc(elapsed_ms - self.repeat_delay, interval_ms) + 1;
    _ = repeats_due;

    // Simple approach: fire one repeat per tick, advance last_key_time_ns
    const keysym = c.xkb_state_key_get_one_sym(state, last_key);
    var utf8: [8]u8 = undefined;
    const len = c.xkb_state_key_get_utf8(state, last_key, &utf8, utf8.len);

    const ev = KeyboardEvent{
        .keysym = keysym,
        .modifiers = .{},
        .action = .repeat,
        .utf8 = utf8,
        .utf8_len = @intCast(@min(len, 8)),
    };
    self.event_queue.append(ev) catch return;
    self.last_key_time_ns = now;
}
```

- [ ] **Step 2: Build**

```bash
zig build
```

Expected: builds.

- [ ] **Step 3: Commit**

```bash
git add src/wayland.zig
git commit -m "feat(wayland): client-side key repeat"
```

---

## Phase 6: Vulkan Renderer

### Task 6.1: Wire vulkan-zig into build.zig

**Files:**
- Create: `src/renderer.zig`
- Modify: `build.zig`

- [ ] **Step 1: Create renderer.zig stub**

```zig
const std = @import("std");
const vk = @import("vulkan");

test "vulkan module imports" {
    _ = vk;
}
```

- [ ] **Step 2: Wire vulkan-zig in build.zig**

```zig
const vulkan_headers_dep = b.dependency("vulkan_headers", .{});
const vulkan_zig_dep = b.dependency("vulkan", .{
    .registry = vulkan_headers_dep.path("registry/vk.xml"),
});
const vulkan_module = vulkan_zig_dep.module("vulkan-zig");

const renderer_module = b.addModule("renderer", .{
    .root_source_file = b.path("src/renderer.zig"),
    .target = target,
    .optimize = optimize,
});
renderer_module.addImport("vulkan", vulkan_module);
exe.root_module.addImport("renderer", renderer_module);
```

- [ ] **Step 3: Build**

```bash
zig build
```

Expected: builds. vulkan-zig generates bindings from vk.xml.

- [ ] **Step 4: Commit**

```bash
git add src/renderer.zig build.zig
git commit -m "build(renderer): wire vulkan-zig"
```

---

### Task 6.2: Shaders — cell.vert and cell.frag

**Files:**
- Create: `shaders/cell.vert`
- Create: `shaders/cell.frag`
- Modify: `build.zig` (add glslc build step)

- [ ] **Step 1: Write cell.vert (GLSL)**

```glsl
#version 450

layout(push_constant) uniform PushConstants {
    vec2 viewport_size;
    vec2 cell_size;
} pc;

layout(location = 0) in vec2 in_unit_pos; // [0,1] quad corner

layout(location = 1) in vec2 in_cell_pos;    // cell grid coords
layout(location = 2) in vec4 in_uv_rect;     // u0,v0,u1,v1
layout(location = 3) in vec4 in_fg_color;
layout(location = 4) in vec4 in_bg_color;

layout(location = 0) out vec2 out_uv;
layout(location = 1) out vec4 out_fg;
layout(location = 2) out vec4 out_bg;

void main() {
    vec2 pixel_pos = (in_cell_pos + in_unit_pos) * pc.cell_size;
    vec2 ndc = (pixel_pos / pc.viewport_size) * 2.0 - 1.0;
    gl_Position = vec4(ndc, 0.0, 1.0);

    out_uv = mix(in_uv_rect.xy, in_uv_rect.zw, in_unit_pos);
    out_fg = in_fg_color;
    out_bg = in_bg_color;
}
```

- [ ] **Step 2: Write cell.frag (GLSL)**

```glsl
#version 450

layout(binding = 0) uniform sampler2D glyph_atlas;

layout(location = 0) in vec2 in_uv;
layout(location = 1) in vec4 in_fg;
layout(location = 2) in vec4 in_bg;

layout(location = 0) out vec4 out_color;

void main() {
    float alpha = texture(glyph_atlas, in_uv).r;
    out_color = mix(in_bg, in_fg, alpha);
}
```

- [ ] **Step 3: Add glslc build step in build.zig**

```zig
fn compileShader(b: *std.Build, comptime name: []const u8) std.Build.LazyPath {
    const glslc = b.addSystemCommand(&.{ "glslc", "--target-env=vulkan1.2" });
    glslc.addFileArg(b.path("shaders/" ++ name));
    glslc.addArg("-o");
    return glslc.addOutputFileArg(name ++ ".spv");
}

// In build():
const vert_spv = compileShader(b, "cell.vert");
const frag_spv = compileShader(b, "cell.frag");

renderer_module.addAnonymousImport("cell_vert_spv", .{
    .root_source_file = vert_spv,
});
renderer_module.addAnonymousImport("cell_frag_spv", .{
    .root_source_file = frag_spv,
});
```

Then in `renderer.zig` access via `@embedFile` equivalent — vulkan-zig expects a byte slice. The above uses `addAnonymousImport` which isn't right for raw bytes. Actually use this simpler approach:

```zig
const vert_install = b.addInstallFile(vert_spv, "shaders/cell.vert.spv");
const frag_install = b.addInstallFile(frag_spv, "shaders/cell.frag.spv");
exe.step.dependOn(&vert_install.step);
exe.step.dependOn(&frag_install.step);
```

And in `renderer.zig` load at runtime, OR use Zig's `@embedFile` by copying SPV into `src/` via the build step. The cleanest approach for embed-at-compile-time:

```zig
const install_shaders = b.addWriteFiles();
_ = install_shaders.addCopyFile(vert_spv, "cell.vert.spv");
_ = install_shaders.addCopyFile(frag_spv, "cell.frag.spv");
renderer_module.addIncludePath(install_shaders.getDirectory());
```

Then in Zig use `@embedFile` with a path resolved via the include path. If this turns out not to work cleanly, fall back to `@import` of a generated `.zig` file that contains `pub const cell_vert_spv = @embedFile("cell.vert.spv");`.

**This task is intentionally flexible** — the exact mechanism depends on Zig version. The key deliverable: at the end of this task, renderer.zig can reference `const vert_spv = @embedFile("cell.vert.spv");` and it works.

- [ ] **Step 4: Verify with a test**

In `renderer.zig`:

```zig
test "shaders are embedded" {
    const vert = @embedFile("cell.vert.spv");
    const frag = @embedFile("cell.frag.spv");
    try std.testing.expect(vert.len > 0);
    try std.testing.expect(frag.len > 0);
    // SPIR-V magic number
    try std.testing.expectEqual(@as(u32, 0x07230203), std.mem.bytesAsSlice(u32, vert[0..4])[0]);
}
```

- [ ] **Step 5: Build and test**

```bash
zig build test
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add shaders/ src/renderer.zig build.zig
git commit -m "build(shaders): glslc compile + embed"
```

---

### Task 6.3: Vulkan instance + wayland surface

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

- [ ] **Step 1: Create Vulkan instance with wayland extension**

```zig
const std = @import("std");
const vk = @import("vulkan");

const apis: []const vk.ApiInfo = &.{
    vk.features.version_1_0,
    vk.features.version_1_1,
    vk.features.version_1_2,
    vk.extensions.khr_surface,
    vk.extensions.khr_wayland_surface,
    vk.extensions.khr_swapchain,
};

const BaseDispatch = vk.BaseWrapper(apis);
const InstanceDispatch = vk.InstanceWrapper(apis);
const DeviceDispatch = vk.DeviceWrapper(apis);

pub const Renderer = struct {
    alloc: std.mem.Allocator,
    vkb: BaseDispatch,
    vki: InstanceDispatch,
    instance: vk.Instance,

    pub fn init(alloc: std.mem.Allocator, loader: anytype) !Renderer {
        const vkb = try BaseDispatch.load(loader);

        const app_info = vk.ApplicationInfo{
            .p_application_name = "waystty",
            .application_version = vk.makeApiVersion(0, 0, 0, 1),
            .p_engine_name = "waystty",
            .engine_version = vk.makeApiVersion(0, 0, 0, 1),
            .api_version = vk.API_VERSION_1_2,
        };

        const extensions = [_][*:0]const u8{
            vk.extensions.khr_surface.name,
            vk.extensions.khr_wayland_surface.name,
        };

        const instance = try vkb.createInstance(&.{
            .p_application_info = &app_info,
            .enabled_extension_count = extensions.len,
            .pp_enabled_extension_names = &extensions,
        }, null);

        const vki = try InstanceDispatch.load(instance, vkb.dispatch.vkGetInstanceProcAddr);

        return .{
            .alloc = alloc,
            .vkb = vkb,
            .vki = vki,
            .instance = instance,
        };
    }

    pub fn deinit(self: *Renderer) void {
        self.vki.destroyInstance(self.instance, null);
    }

    pub fn createWaylandSurface(
        self: *Renderer,
        display: *anyopaque,
        surface: *anyopaque,
    ) !vk.SurfaceKHR {
        return try self.vki.createWaylandSurfaceKHR(self.instance, &.{
            .display = @ptrCast(display),
            .surface = @ptrCast(surface),
        }, null);
    }
};
```

**The exact vulkan-zig API (apis tuple, function names, field names) must be verified against the current version of vulkan-zig. Adjust as needed.**

- [ ] **Step 2: Build**

```bash
zig build
```

Expected: builds. Will fail on many small API mismatches that need to be fixed.

- [ ] **Step 3: Commit**

```bash
git add src/renderer.zig
git commit -m "feat(renderer): Vulkan instance + wayland surface creation"
```

---

### Task 6.4: Physical device + logical device + queues

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

- [ ] **Step 1: Add device selection**

Extend `Renderer`:

```zig
pub const DeviceInfo = struct {
    physical: vk.PhysicalDevice,
    graphics_queue_family: u32,
    present_queue_family: u32,
};

pub fn pickPhysicalDevice(self: *Renderer, surface: vk.SurfaceKHR) !DeviceInfo {
    var count: u32 = 0;
    _ = try self.vki.enumeratePhysicalDevices(self.instance, &count, null);

    const devices = try self.alloc.alloc(vk.PhysicalDevice, count);
    defer self.alloc.free(devices);
    _ = try self.vki.enumeratePhysicalDevices(self.instance, &count, devices.ptr);

    for (devices[0..count]) |pd| {
        var qf_count: u32 = 0;
        self.vki.getPhysicalDeviceQueueFamilyProperties(pd, &qf_count, null);
        const qfs = try self.alloc.alloc(vk.QueueFamilyProperties, qf_count);
        defer self.alloc.free(qfs);
        self.vki.getPhysicalDeviceQueueFamilyProperties(pd, &qf_count, qfs.ptr);

        var graphics_idx: ?u32 = null;
        var present_idx: ?u32 = null;
        for (qfs[0..qf_count], 0..) |qf, i| {
            if (qf.queue_flags.graphics_bit) graphics_idx = @intCast(i);
            var supported: vk.Bool32 = vk.FALSE;
            _ = try self.vki.getPhysicalDeviceSurfaceSupportKHR(pd, @intCast(i), surface, &supported);
            if (supported == vk.TRUE) present_idx = @intCast(i);
            if (graphics_idx != null and present_idx != null) break;
        }

        if (graphics_idx != null and present_idx != null) {
            return .{
                .physical = pd,
                .graphics_queue_family = graphics_idx.?,
                .present_queue_family = present_idx.?,
            };
        }
    }
    return error.NoSuitableDevice;
}
```

- [ ] **Step 2: Add logical device creation**

```zig
pub const Device = struct {
    vkd: DeviceDispatch,
    handle: vk.Device,
    graphics_queue: vk.Queue,
    present_queue: vk.Queue,
};

pub fn createDevice(self: *Renderer, info: DeviceInfo) !Device {
    const priority: f32 = 1.0;
    var unique_families = [_]u32{ info.graphics_queue_family, info.present_queue_family };
    // deduplicate
    var unique_count: u32 = 1;
    if (info.graphics_queue_family != info.present_queue_family) unique_count = 2;

    var queue_infos: [2]vk.DeviceQueueCreateInfo = undefined;
    for (0..unique_count) |i| {
        queue_infos[i] = .{
            .queue_family_index = unique_families[i],
            .queue_count = 1,
            .p_queue_priorities = @ptrCast(&priority),
        };
    }

    const exts = [_][*:0]const u8{vk.extensions.khr_swapchain.name};

    const device = try self.vki.createDevice(info.physical, &.{
        .queue_create_info_count = unique_count,
        .p_queue_create_infos = &queue_infos,
        .enabled_extension_count = exts.len,
        .pp_enabled_extension_names = &exts,
    }, null);

    const vkd = try DeviceDispatch.load(device, self.vki.dispatch.vkGetDeviceProcAddr);

    return .{
        .vkd = vkd,
        .handle = device,
        .graphics_queue = vkd.getDeviceQueue(device, info.graphics_queue_family, 0),
        .present_queue = vkd.getDeviceQueue(device, info.present_queue_family, 0),
    };
}
```

- [ ] **Step 3: Build**

```bash
zig build
```

Expected: builds.

- [ ] **Step 4: Commit**

```bash
git add src/renderer.zig
git commit -m "feat(renderer): physical + logical device selection"
```

---

### Task 6.5: Swapchain

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

- [ ] **Step 1: Implement swapchain creation**

```zig
pub const Swapchain = struct {
    handle: vk.SwapchainKHR,
    format: vk.Format,
    extent: vk.Extent2D,
    images: []vk.Image,
    image_views: []vk.ImageView,
};

pub fn createSwapchain(
    self: *Renderer,
    device: *Device,
    info: DeviceInfo,
    surface: vk.SurfaceKHR,
    width: u32,
    height: u32,
) !Swapchain {
    const caps = try self.vki.getPhysicalDeviceSurfaceCapabilitiesKHR(info.physical, surface);

    var fmt_count: u32 = 0;
    _ = try self.vki.getPhysicalDeviceSurfaceFormatsKHR(info.physical, surface, &fmt_count, null);
    const formats = try self.alloc.alloc(vk.SurfaceFormatKHR, fmt_count);
    defer self.alloc.free(formats);
    _ = try self.vki.getPhysicalDeviceSurfaceFormatsKHR(info.physical, surface, &fmt_count, formats.ptr);

    var chosen_format = formats[0];
    for (formats[0..fmt_count]) |f| {
        if (f.format == .b8g8r8a8_srgb and f.color_space == .srgb_nonlinear_khr) {
            chosen_format = f;
            break;
        }
    }

    var extent = caps.current_extent;
    if (extent.width == 0xFFFFFFFF) {
        extent = .{ .width = width, .height = height };
    }

    var image_count: u32 = caps.min_image_count + 1;
    if (caps.max_image_count > 0 and image_count > caps.max_image_count) {
        image_count = caps.max_image_count;
    }

    const same_family = info.graphics_queue_family == info.present_queue_family;
    const families = [_]u32{ info.graphics_queue_family, info.present_queue_family };

    const handle = try device.vkd.createSwapchainKHR(device.handle, &.{
        .surface = surface,
        .min_image_count = image_count,
        .image_format = chosen_format.format,
        .image_color_space = chosen_format.color_space,
        .image_extent = extent,
        .image_array_layers = 1,
        .image_usage = .{ .color_attachment_bit = true },
        .image_sharing_mode = if (same_family) .exclusive else .concurrent,
        .queue_family_index_count = if (same_family) 0 else 2,
        .p_queue_family_indices = if (same_family) null else &families,
        .pre_transform = caps.current_transform,
        .composite_alpha = .{ .opaque_bit_khr = true },
        .present_mode = .fifo_khr,
        .clipped = vk.TRUE,
    }, null);

    var sc_image_count: u32 = 0;
    _ = try device.vkd.getSwapchainImagesKHR(device.handle, handle, &sc_image_count, null);
    const images = try self.alloc.alloc(vk.Image, sc_image_count);
    _ = try device.vkd.getSwapchainImagesKHR(device.handle, handle, &sc_image_count, images.ptr);

    const image_views = try self.alloc.alloc(vk.ImageView, sc_image_count);
    for (images, image_views) |img, *view| {
        view.* = try device.vkd.createImageView(device.handle, &.{
            .image = img,
            .view_type = .@"2d",
            .format = chosen_format.format,
            .components = .{ .r = .identity, .g = .identity, .b = .identity, .a = .identity },
            .subresource_range = .{
                .aspect_mask = .{ .color_bit = true },
                .base_mip_level = 0,
                .level_count = 1,
                .base_array_layer = 0,
                .layer_count = 1,
            },
        }, null);
    }

    return .{
        .handle = handle,
        .format = chosen_format.format,
        .extent = extent,
        .images = images,
        .image_views = image_views,
    };
}
```

- [ ] **Step 2: Build**

```bash
zig build
```

Expected: builds.

- [ ] **Step 3: Commit**

```bash
git add src/renderer.zig
git commit -m "feat(renderer): swapchain with FIFO present mode"
```

---

### Task 6.6: Render pass + framebuffers

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

- [ ] **Step 1: Implement render pass**

```zig
pub fn createRenderPass(device: *Device, format: vk.Format) !vk.RenderPass {
    const color_attachment = vk.AttachmentDescription{
        .format = format,
        .samples = .{ .@"1_bit" = true },
        .load_op = .clear,
        .store_op = .store,
        .stencil_load_op = .dont_care,
        .stencil_store_op = .dont_care,
        .initial_layout = .undefined,
        .final_layout = .present_src_khr,
    };

    const color_ref = vk.AttachmentReference{
        .attachment = 0,
        .layout = .color_attachment_optimal,
    };

    const subpass = vk.SubpassDescription{
        .pipeline_bind_point = .graphics,
        .color_attachment_count = 1,
        .p_color_attachments = @ptrCast(&color_ref),
    };

    const dep = vk.SubpassDependency{
        .src_subpass = vk.SUBPASS_EXTERNAL,
        .dst_subpass = 0,
        .src_stage_mask = .{ .color_attachment_output_bit = true },
        .dst_stage_mask = .{ .color_attachment_output_bit = true },
        .src_access_mask = .{},
        .dst_access_mask = .{ .color_attachment_write_bit = true },
    };

    return try device.vkd.createRenderPass(device.handle, &.{
        .attachment_count = 1,
        .p_attachments = @ptrCast(&color_attachment),
        .subpass_count = 1,
        .p_subpasses = @ptrCast(&subpass),
        .dependency_count = 1,
        .p_dependencies = @ptrCast(&dep),
    }, null);
}

pub fn createFramebuffers(
    alloc: std.mem.Allocator,
    device: *Device,
    render_pass: vk.RenderPass,
    swapchain: *const Swapchain,
) ![]vk.Framebuffer {
    const fbs = try alloc.alloc(vk.Framebuffer, swapchain.image_views.len);
    for (swapchain.image_views, fbs) |view, *fb| {
        fb.* = try device.vkd.createFramebuffer(device.handle, &.{
            .render_pass = render_pass,
            .attachment_count = 1,
            .p_attachments = @ptrCast(&view),
            .width = swapchain.extent.width,
            .height = swapchain.extent.height,
            .layers = 1,
        }, null);
    }
    return fbs;
}
```

- [ ] **Step 2: Build**

```bash
zig build
```

Expected: builds.

- [ ] **Step 3: Commit**

```bash
git add src/renderer.zig
git commit -m "feat(renderer): render pass + framebuffers"
```

---

### Task 6.7: Graphics pipeline

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

- [ ] **Step 1: Define vertex/instance formats and pipeline**

Add to `src/renderer.zig`:

```zig
pub const Instance = extern struct {
    cell_pos: [2]f32,
    uv_rect: [4]f32,
    fg: [4]f32,
    bg: [4]f32,
};

pub const Vertex = extern struct {
    unit_pos: [2]f32,
};

pub fn createPipeline(
    device: *Device,
    render_pass: vk.RenderPass,
    pipeline_layout: vk.PipelineLayout,
) !vk.Pipeline {
    const vert_spv align(4) = @embedFile("cell.vert.spv").*;
    const frag_spv align(4) = @embedFile("cell.frag.spv").*;

    const vert_module = try device.vkd.createShaderModule(device.handle, &.{
        .code_size = vert_spv.len,
        .p_code = @ptrCast(&vert_spv),
    }, null);
    defer device.vkd.destroyShaderModule(device.handle, vert_module, null);

    const frag_module = try device.vkd.createShaderModule(device.handle, &.{
        .code_size = frag_spv.len,
        .p_code = @ptrCast(&frag_spv),
    }, null);
    defer device.vkd.destroyShaderModule(device.handle, frag_module, null);

    const stages = [_]vk.PipelineShaderStageCreateInfo{
        .{
            .stage = .{ .vertex_bit = true },
            .module = vert_module,
            .p_name = "main",
        },
        .{
            .stage = .{ .fragment_bit = true },
            .module = frag_module,
            .p_name = "main",
        },
    };

    const binding_descs = [_]vk.VertexInputBindingDescription{
        .{ .binding = 0, .stride = @sizeOf(Vertex), .input_rate = .vertex },
        .{ .binding = 1, .stride = @sizeOf(Instance), .input_rate = .instance },
    };

    const attr_descs = [_]vk.VertexInputAttributeDescription{
        .{ .location = 0, .binding = 0, .format = .r32g32_sfloat, .offset = 0 },
        .{ .location = 1, .binding = 1, .format = .r32g32_sfloat, .offset = @offsetOf(Instance, "cell_pos") },
        .{ .location = 2, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "uv_rect") },
        .{ .location = 3, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "fg") },
        .{ .location = 4, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "bg") },
    };

    const vertex_input = vk.PipelineVertexInputStateCreateInfo{
        .vertex_binding_description_count = binding_descs.len,
        .p_vertex_binding_descriptions = &binding_descs,
        .vertex_attribute_description_count = attr_descs.len,
        .p_vertex_attribute_descriptions = &attr_descs,
    };

    const input_assembly = vk.PipelineInputAssemblyStateCreateInfo{
        .topology = .triangle_list,
        .primitive_restart_enable = vk.FALSE,
    };

    const viewport_state = vk.PipelineViewportStateCreateInfo{
        .viewport_count = 1,
        .scissor_count = 1,
    };

    const rasterizer = vk.PipelineRasterizationStateCreateInfo{
        .depth_clamp_enable = vk.FALSE,
        .rasterizer_discard_enable = vk.FALSE,
        .polygon_mode = .fill,
        .cull_mode = .{},
        .front_face = .counter_clockwise,
        .depth_bias_enable = vk.FALSE,
        .depth_bias_constant_factor = 0,
        .depth_bias_clamp = 0,
        .depth_bias_slope_factor = 0,
        .line_width = 1.0,
    };

    const multisampling = vk.PipelineMultisampleStateCreateInfo{
        .rasterization_samples = .{ .@"1_bit" = true },
        .sample_shading_enable = vk.FALSE,
        .min_sample_shading = 1.0,
        .alpha_to_coverage_enable = vk.FALSE,
        .alpha_to_one_enable = vk.FALSE,
    };

    const color_blend_attachment = vk.PipelineColorBlendAttachmentState{
        .blend_enable = vk.FALSE,
        .src_color_blend_factor = .one,
        .dst_color_blend_factor = .zero,
        .color_blend_op = .add,
        .src_alpha_blend_factor = .one,
        .dst_alpha_blend_factor = .zero,
        .alpha_blend_op = .add,
        .color_write_mask = .{ .r_bit = true, .g_bit = true, .b_bit = true, .a_bit = true },
    };

    const color_blend = vk.PipelineColorBlendStateCreateInfo{
        .logic_op_enable = vk.FALSE,
        .logic_op = .copy,
        .attachment_count = 1,
        .p_attachments = @ptrCast(&color_blend_attachment),
        .blend_constants = [_]f32{ 0, 0, 0, 0 },
    };

    const dynamic_states = [_]vk.DynamicState{ .viewport, .scissor };
    const dynamic_state = vk.PipelineDynamicStateCreateInfo{
        .dynamic_state_count = dynamic_states.len,
        .p_dynamic_states = &dynamic_states,
    };

    var pipeline: vk.Pipeline = undefined;
    _ = try device.vkd.createGraphicsPipelines(
        device.handle,
        .null_handle,
        1,
        @ptrCast(&vk.GraphicsPipelineCreateInfo{
            .stage_count = stages.len,
            .p_stages = &stages,
            .p_vertex_input_state = &vertex_input,
            .p_input_assembly_state = &input_assembly,
            .p_viewport_state = &viewport_state,
            .p_rasterization_state = &rasterizer,
            .p_multisample_state = &multisampling,
            .p_color_blend_state = &color_blend,
            .p_dynamic_state = &dynamic_state,
            .layout = pipeline_layout,
            .render_pass = render_pass,
            .subpass = 0,
            .base_pipeline_index = -1,
        }),
        null,
        @ptrCast(&pipeline),
    );

    return pipeline;
}
```

- [ ] **Step 2: Build**

```bash
zig build
```

Expected: builds.

- [ ] **Step 3: Commit**

```bash
git add src/renderer.zig
git commit -m "feat(renderer): graphics pipeline with instanced input"
```

---

### Task 6.8: Glyph atlas texture upload

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

- [ ] **Step 1: Implement uploadAtlas**

Add helpers for buffer allocation and staging upload:

```zig
pub const GpuAtlas = struct {
    image: vk.Image,
    memory: vk.DeviceMemory,
    view: vk.ImageView,
    sampler: vk.Sampler,
    width: u32,
    height: u32,
};

pub fn findMemoryType(
    vki: InstanceDispatch,
    physical: vk.PhysicalDevice,
    type_filter: u32,
    properties: vk.MemoryPropertyFlags,
) !u32 {
    const mem_props = vki.getPhysicalDeviceMemoryProperties(physical);
    var i: u32 = 0;
    while (i < mem_props.memory_type_count) : (i += 1) {
        if ((type_filter & (@as(u32, 1) << @intCast(i))) != 0 and
            mem_props.memory_types[i].property_flags.contains(properties))
        {
            return i;
        }
    }
    return error.NoSuitableMemoryType;
}

pub fn createAtlasTexture(
    vki: InstanceDispatch,
    physical: vk.PhysicalDevice,
    device: *Device,
    width: u32,
    height: u32,
) !GpuAtlas {
    const image = try device.vkd.createImage(device.handle, &.{
        .image_type = .@"2d",
        .format = .r8_unorm,
        .extent = .{ .width = width, .height = height, .depth = 1 },
        .mip_levels = 1,
        .array_layers = 1,
        .samples = .{ .@"1_bit" = true },
        .tiling = .optimal,
        .usage = .{ .transfer_dst_bit = true, .sampled_bit = true },
        .sharing_mode = .exclusive,
        .initial_layout = .undefined,
    }, null);

    const mem_reqs = device.vkd.getImageMemoryRequirements(device.handle, image);
    const mem_type = try findMemoryType(
        vki,
        physical,
        mem_reqs.memory_type_bits,
        .{ .device_local_bit = true },
    );

    const memory = try device.vkd.allocateMemory(device.handle, &.{
        .allocation_size = mem_reqs.size,
        .memory_type_index = mem_type,
    }, null);

    try device.vkd.bindImageMemory(device.handle, image, memory, 0);

    const view = try device.vkd.createImageView(device.handle, &.{
        .image = image,
        .view_type = .@"2d",
        .format = .r8_unorm,
        .components = .{ .r = .identity, .g = .identity, .b = .identity, .a = .identity },
        .subresource_range = .{
            .aspect_mask = .{ .color_bit = true },
            .base_mip_level = 0,
            .level_count = 1,
            .base_array_layer = 0,
            .layer_count = 1,
        },
    }, null);

    const sampler = try device.vkd.createSampler(device.handle, &.{
        .mag_filter = .nearest,
        .min_filter = .nearest,
        .mipmap_mode = .nearest,
        .address_mode_u = .clamp_to_edge,
        .address_mode_v = .clamp_to_edge,
        .address_mode_w = .clamp_to_edge,
        .mip_lod_bias = 0,
        .anisotropy_enable = vk.FALSE,
        .max_anisotropy = 1,
        .compare_enable = vk.FALSE,
        .compare_op = .always,
        .min_lod = 0,
        .max_lod = 0,
        .border_color = .int_opaque_black,
        .unnormalized_coordinates = vk.FALSE,
    }, null);

    return .{
        .image = image,
        .memory = memory,
        .view = view,
        .sampler = sampler,
        .width = width,
        .height = height,
    };
}

/// Upload CPU pixel data to GPU atlas texture via staging buffer.
/// Caller provides command pool + queue for one-shot submit.
pub fn uploadAtlasPixels(
    vki: InstanceDispatch,
    physical: vk.PhysicalDevice,
    device: *Device,
    atlas: *GpuAtlas,
    pixels: []const u8,
    command_pool: vk.CommandPool,
) !void {
    // Create staging buffer
    const staging = try device.vkd.createBuffer(device.handle, &.{
        .size = pixels.len,
        .usage = .{ .transfer_src_bit = true },
        .sharing_mode = .exclusive,
    }, null);
    defer device.vkd.destroyBuffer(device.handle, staging, null);

    const staging_reqs = device.vkd.getBufferMemoryRequirements(device.handle, staging);
    const staging_mem_type = try findMemoryType(
        vki,
        physical,
        staging_reqs.memory_type_bits,
        .{ .host_visible_bit = true, .host_coherent_bit = true },
    );
    const staging_mem = try device.vkd.allocateMemory(device.handle, &.{
        .allocation_size = staging_reqs.size,
        .memory_type_index = staging_mem_type,
    }, null);
    defer device.vkd.freeMemory(device.handle, staging_mem, null);

    try device.vkd.bindBufferMemory(device.handle, staging, staging_mem, 0);

    const mapped = try device.vkd.mapMemory(device.handle, staging_mem, 0, pixels.len, .{});
    @memcpy(@as([*]u8, @ptrCast(mapped))[0..pixels.len], pixels);
    device.vkd.unmapMemory(device.handle, staging_mem);

    // One-shot command buffer
    var cmd: vk.CommandBuffer = undefined;
    _ = try device.vkd.allocateCommandBuffers(device.handle, &.{
        .command_pool = command_pool,
        .level = .primary,
        .command_buffer_count = 1,
    }, @ptrCast(&cmd));
    defer device.vkd.freeCommandBuffers(device.handle, command_pool, 1, @ptrCast(&cmd));

    try device.vkd.beginCommandBuffer(cmd, &.{ .flags = .{ .one_time_submit_bit = true } });

    // Transition: undefined -> transfer_dst_optimal
    const barrier_to_dst = vk.ImageMemoryBarrier{
        .src_access_mask = .{},
        .dst_access_mask = .{ .transfer_write_bit = true },
        .old_layout = .undefined,
        .new_layout = .transfer_dst_optimal,
        .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
        .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
        .image = atlas.image,
        .subresource_range = .{
            .aspect_mask = .{ .color_bit = true },
            .base_mip_level = 0,
            .level_count = 1,
            .base_array_layer = 0,
            .layer_count = 1,
        },
    };
    device.vkd.cmdPipelineBarrier(
        cmd,
        .{ .top_of_pipe_bit = true },
        .{ .transfer_bit = true },
        .{},
        0, null,
        0, null,
        1, @ptrCast(&barrier_to_dst),
    );

    const region = vk.BufferImageCopy{
        .buffer_offset = 0,
        .buffer_row_length = 0,
        .buffer_image_height = 0,
        .image_subresource = .{
            .aspect_mask = .{ .color_bit = true },
            .mip_level = 0,
            .base_array_layer = 0,
            .layer_count = 1,
        },
        .image_offset = .{ .x = 0, .y = 0, .z = 0 },
        .image_extent = .{ .width = atlas.width, .height = atlas.height, .depth = 1 },
    };
    device.vkd.cmdCopyBufferToImage(cmd, staging, atlas.image, .transfer_dst_optimal, 1, @ptrCast(&region));

    // Transition: transfer_dst_optimal -> shader_read_only_optimal
    const barrier_to_shader = vk.ImageMemoryBarrier{
        .src_access_mask = .{ .transfer_write_bit = true },
        .dst_access_mask = .{ .shader_read_bit = true },
        .old_layout = .transfer_dst_optimal,
        .new_layout = .shader_read_only_optimal,
        .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
        .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
        .image = atlas.image,
        .subresource_range = .{
            .aspect_mask = .{ .color_bit = true },
            .base_mip_level = 0,
            .level_count = 1,
            .base_array_layer = 0,
            .layer_count = 1,
        },
    };
    device.vkd.cmdPipelineBarrier(
        cmd,
        .{ .transfer_bit = true },
        .{ .fragment_shader_bit = true },
        .{},
        0, null,
        0, null,
        1, @ptrCast(&barrier_to_shader),
    );

    try device.vkd.endCommandBuffer(cmd);

    const submit = vk.SubmitInfo{
        .command_buffer_count = 1,
        .p_command_buffers = @ptrCast(&cmd),
    };
    _ = try device.vkd.queueSubmit(device.graphics_queue, 1, @ptrCast(&submit), .null_handle);
    _ = try device.vkd.queueWaitIdle(device.graphics_queue);
}
```

- [ ] **Step 2: Build**

```bash
zig build
```

Expected: builds.

- [ ] **Step 3: Commit**

```bash
git add src/renderer.zig
git commit -m "feat(renderer): glyph atlas texture upload via staging buffer"
```

---

### Task 6.9: Per-frame draw — instance buffer + draw call

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

- [ ] **Step 1: Implement drawFrame**

Add a high-level draw function that takes:
- An array of `Instance` (one per visible cell)
- The descriptor set bound to the glyph atlas
- The current swapchain image index
- The framebuffer, pipeline, pipeline_layout, command buffer

```zig
pub const FrameContext = struct {
    command_buffer: vk.CommandBuffer,
    framebuffer: vk.Framebuffer,
    render_pass: vk.RenderPass,
    pipeline: vk.Pipeline,
    pipeline_layout: vk.PipelineLayout,
    descriptor_set: vk.DescriptorSet,
    viewport_size: [2]f32,
    cell_size: [2]f32,
    extent: vk.Extent2D,
    instance_buffer: vk.Buffer,
    quad_vertex_buffer: vk.Buffer,
    instance_count: u32,
};

pub const PushConstants = extern struct {
    viewport_size: [2]f32,
    cell_size: [2]f32,
};

pub fn recordFrame(device: *Device, ctx: FrameContext) !void {
    try device.vkd.beginCommandBuffer(ctx.command_buffer, &.{});

    const clear_value = vk.ClearValue{
        .color = .{ .float_32 = .{ 0.05, 0.05, 0.05, 1.0 } },
    };

    device.vkd.cmdBeginRenderPass(ctx.command_buffer, &.{
        .render_pass = ctx.render_pass,
        .framebuffer = ctx.framebuffer,
        .render_area = .{ .offset = .{ .x = 0, .y = 0 }, .extent = ctx.extent },
        .clear_value_count = 1,
        .p_clear_values = @ptrCast(&clear_value),
    }, .@"inline");

    device.vkd.cmdBindPipeline(ctx.command_buffer, .graphics, ctx.pipeline);

    const viewport = vk.Viewport{
        .x = 0, .y = 0,
        .width = @floatFromInt(ctx.extent.width),
        .height = @floatFromInt(ctx.extent.height),
        .min_depth = 0, .max_depth = 1,
    };
    const scissor = vk.Rect2D{ .offset = .{ .x = 0, .y = 0 }, .extent = ctx.extent };
    device.vkd.cmdSetViewport(ctx.command_buffer, 0, 1, @ptrCast(&viewport));
    device.vkd.cmdSetScissor(ctx.command_buffer, 0, 1, @ptrCast(&scissor));

    const pc = PushConstants{
        .viewport_size = ctx.viewport_size,
        .cell_size = ctx.cell_size,
    };
    device.vkd.cmdPushConstants(
        ctx.command_buffer,
        ctx.pipeline_layout,
        .{ .vertex_bit = true },
        0,
        @sizeOf(PushConstants),
        @ptrCast(&pc),
    );

    device.vkd.cmdBindDescriptorSets(
        ctx.command_buffer,
        .graphics,
        ctx.pipeline_layout,
        0,
        1,
        @ptrCast(&ctx.descriptor_set),
        0,
        null,
    );

    const buffers = [_]vk.Buffer{ ctx.quad_vertex_buffer, ctx.instance_buffer };
    const offsets = [_]vk.DeviceSize{ 0, 0 };
    device.vkd.cmdBindVertexBuffers(ctx.command_buffer, 0, 2, &buffers, &offsets);

    device.vkd.cmdDraw(ctx.command_buffer, 6, ctx.instance_count, 0, 0);

    device.vkd.cmdEndRenderPass(ctx.command_buffer);
    try device.vkd.endCommandBuffer(ctx.command_buffer);
}
```

- [ ] **Step 2: Build**

```bash
zig build
```

Expected: builds.

- [ ] **Step 3: Commit**

```bash
git add src/renderer.zig
git commit -m "feat(renderer): recordFrame with instanced draw"
```

---

## Phase 7: Full Integration

### Task 7.1: main.zig — init all subsystems

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

- [ ] **Step 1: Write full initialization sequence**

```zig
const std = @import("std");
const vt = @import("vt");
const pty = @import("pty");
const font = @import("font");
const wayland_mod = @import("wayland-client");
const renderer_mod = @import("renderer");

const FontSize = 14;
const Cols = 80;
const Rows = 24;

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

    // 1. Wayland connection
    var conn = try wayland_mod.Connection.init();
    defer conn.deinit();

    // 2. Font
    var lookup = try font.lookupMonospace(alloc);
    defer lookup.deinit(alloc);

    var face = try font.Face.init(alloc, lookup.path, lookup.index, FontSize);
    defer face.deinit();

    const cell_w = face.cellWidth();
    const cell_h = face.cellHeight();

    // 3. Window (sized to match grid)
    const win_w: u32 = @as(u32, Cols) * cell_w;
    const win_h: u32 = @as(u32, Rows) * cell_h;

    var window = try conn.createWindow("waystty");
    defer window.deinit();
    window.width = win_w;
    window.height = win_h;

    // 4. Vulkan renderer (init instance, pick device, create swapchain, etc.)
    //    This is the big block — see Task 7.2 for the details wired together.

    // 5. Atlas
    var atlas = try font.Atlas.init(alloc, 1024, 1024);
    defer atlas.deinit();

    // 6. Terminal
    var term = try vt.Terminal.init(alloc, .{
        .cols = Cols,
        .rows = Rows,
        .max_scrollback = 1000,
    });
    defer term.deinit();

    // 7. PTY
    const shell = std.posix.getenv("SHELL") orelse "/bin/sh";
    var p = try pty.Pty.spawn(.{
        .cols = Cols,
        .rows = Rows,
        .shell = shell,
    });
    defer p.deinit();

    // 8. Encoders
    var key_encoder = try vt.KeyEncoder.init(alloc);
    defer key_encoder.deinit();

    // 9. Main loop — see Task 7.3
    std.debug.print("waystty init complete\n", .{});
}
```

- [ ] **Step 2: Build**

```bash
zig build
```

Expected: builds (won't have a working terminal yet — just init).

- [ ] **Step 3: Commit**

```bash
git add src/main.zig
git commit -m "feat(main): initialize all subsystems"
```

---

### Task 7.2: Vulkan renderer wire-up

**Files:**
- Modify: `src/main.zig`
- Modify: `src/renderer.zig` (add a high-level `Context` bundling all Vulkan state)

- [ ] **Step 1: Add a high-level Renderer.Context that owns everything**

Add to `src/renderer.zig`:

```zig
pub const Context = struct {
    alloc: std.mem.Allocator,
    renderer: Renderer,
    device_info: DeviceInfo,
    device: Device,
    surface: vk.SurfaceKHR,
    swapchain: Swapchain,
    render_pass: vk.RenderPass,
    framebuffers: []vk.Framebuffer,
    pipeline_layout: vk.PipelineLayout,
    pipeline: vk.Pipeline,
    descriptor_pool: vk.DescriptorPool,
    descriptor_set_layout: vk.DescriptorSetLayout,
    descriptor_set: vk.DescriptorSet,
    command_pool: vk.CommandPool,
    command_buffer: vk.CommandBuffer,
    image_available: vk.Semaphore,
    render_finished: vk.Semaphore,
    in_flight_fence: vk.Fence,
    quad_vertex_buffer: vk.Buffer,
    quad_vertex_memory: vk.DeviceMemory,
    instance_buffer: vk.Buffer,
    instance_memory: vk.DeviceMemory,
    instance_capacity: u32,
    gpu_atlas: GpuAtlas,

    pub fn init(
        alloc: std.mem.Allocator,
        display: *anyopaque,
        surface: *anyopaque,
        width: u32,
        height: u32,
        loader: anytype,
    ) !Context {
        var r = try Renderer.init(alloc, loader);
        errdefer r.deinit();

        const vk_surface = try r.createWaylandSurface(display, surface);
        const info = try r.pickPhysicalDevice(vk_surface);
        var device = try r.createDevice(info);
        var sc = try r.createSwapchain(&device, info, vk_surface, width, height);

        const rp = try createRenderPass(&device, sc.format);
        const fbs = try createFramebuffers(alloc, &device, rp, &sc);

        // descriptor set layout (single combined image sampler)
        const binding = vk.DescriptorSetLayoutBinding{
            .binding = 0,
            .descriptor_type = .combined_image_sampler,
            .descriptor_count = 1,
            .stage_flags = .{ .fragment_bit = true },
        };
        const dsl = try device.vkd.createDescriptorSetLayout(device.handle, &.{
            .binding_count = 1,
            .p_bindings = @ptrCast(&binding),
        }, null);

        // push constants
        const push_range = vk.PushConstantRange{
            .stage_flags = .{ .vertex_bit = true },
            .offset = 0,
            .size = @sizeOf(PushConstants),
        };

        const pl = try device.vkd.createPipelineLayout(device.handle, &.{
            .set_layout_count = 1,
            .p_set_layouts = @ptrCast(&dsl),
            .push_constant_range_count = 1,
            .p_push_constant_ranges = @ptrCast(&push_range),
        }, null);

        const pipeline = try createPipeline(&device, rp, pl);

        // descriptor pool + set
        const pool_size = vk.DescriptorPoolSize{
            .type = .combined_image_sampler,
            .descriptor_count = 1,
        };
        const dp = try device.vkd.createDescriptorPool(device.handle, &.{
            .max_sets = 1,
            .pool_size_count = 1,
            .p_pool_sizes = @ptrCast(&pool_size),
        }, null);

        var ds: vk.DescriptorSet = undefined;
        _ = try device.vkd.allocateDescriptorSets(device.handle, &.{
            .descriptor_pool = dp,
            .descriptor_set_count = 1,
            .p_set_layouts = @ptrCast(&dsl),
        }, @ptrCast(&ds));

        // command pool + buffer
        const cp = try device.vkd.createCommandPool(device.handle, &.{
            .flags = .{ .reset_command_buffer_bit = true },
            .queue_family_index = info.graphics_queue_family,
        }, null);

        var cb: vk.CommandBuffer = undefined;
        _ = try device.vkd.allocateCommandBuffers(device.handle, &.{
            .command_pool = cp,
            .level = .primary,
            .command_buffer_count = 1,
        }, @ptrCast(&cb));

        // sync
        const sem_info = vk.SemaphoreCreateInfo{};
        const fence_info = vk.FenceCreateInfo{ .flags = .{ .signaled_bit = true } };
        const ia = try device.vkd.createSemaphore(device.handle, &sem_info, null);
        const rf = try device.vkd.createSemaphore(device.handle, &sem_info, null);
        const iff = try device.vkd.createFence(device.handle, &fence_info, null);

        // GPU atlas
        const gpu_atlas = try createAtlasTexture(r.vki, info.physical, &device, 1024, 1024);

        // Update descriptor set to point at atlas
        const img_info = vk.DescriptorImageInfo{
            .sampler = gpu_atlas.sampler,
            .image_view = gpu_atlas.view,
            .image_layout = .shader_read_only_optimal,
        };
        const write = vk.WriteDescriptorSet{
            .dst_set = ds,
            .dst_binding = 0,
            .dst_array_element = 0,
            .descriptor_count = 1,
            .descriptor_type = .combined_image_sampler,
            .p_image_info = @ptrCast(&img_info),
            .p_buffer_info = undefined,
            .p_texel_buffer_view = undefined,
        };
        device.vkd.updateDescriptorSets(device.handle, 1, @ptrCast(&write), 0, null);

        // Static quad vertex buffer (6 vertices: two triangles forming unit quad)
        const quad_verts = [_]Vertex{
            .{ .unit_pos = .{ 0, 0 } },
            .{ .unit_pos = .{ 1, 0 } },
            .{ .unit_pos = .{ 1, 1 } },
            .{ .unit_pos = .{ 0, 0 } },
            .{ .unit_pos = .{ 1, 1 } },
            .{ .unit_pos = .{ 0, 1 } },
        };
        const quad_vb_result = try createHostVisibleBuffer(
            r.vki, info.physical, &device,
            @sizeOf(@TypeOf(quad_verts)),
            .{ .vertex_buffer_bit = true },
        );
        {
            const mapped = try device.vkd.mapMemory(device.handle, quad_vb_result.memory, 0, @sizeOf(@TypeOf(quad_verts)), .{});
            @memcpy(
                @as([*]Vertex, @ptrCast(@alignCast(mapped)))[0..quad_verts.len],
                &quad_verts,
            );
            device.vkd.unmapMemory(device.handle, quad_vb_result.memory);
        }

        // Pre-allocate instance buffer (large enough for 200x80 grid)
        const max_instances: u32 = 200 * 80;
        const instance_buffer_size: vk.DeviceSize = @sizeOf(Instance) * max_instances;
        const inst_result = try createHostVisibleBuffer(
            r.vki, info.physical, &device,
            instance_buffer_size,
            .{ .vertex_buffer_bit = true },
        );

        return .{
            .alloc = alloc,
            .renderer = r,
            .device_info = info,
            .device = device,
            .surface = vk_surface,
            .swapchain = sc,
            .render_pass = rp,
            .framebuffers = fbs,
            .pipeline_layout = pl,
            .pipeline = pipeline,
            .descriptor_pool = dp,
            .descriptor_set_layout = dsl,
            .descriptor_set = ds,
            .command_pool = cp,
            .command_buffer = cb,
            .image_available = ia,
            .render_finished = rf,
            .in_flight_fence = iff,
            .quad_vertex_buffer = quad_vb_result.buffer,
            .quad_vertex_memory = quad_vb_result.memory,
            .instance_buffer = inst_result.buffer,
            .instance_memory = inst_result.memory,
            .instance_capacity = max_instances,
            .gpu_atlas = gpu_atlas,
        };
    }

    pub fn deinit(self: *Context) void {
        _ = self.device.vkd.deviceWaitIdle(self.device.handle) catch {};
        // free all vulkan objects in reverse order
        self.device.vkd.destroyFence(self.device.handle, self.in_flight_fence, null);
        self.device.vkd.destroySemaphore(self.device.handle, self.render_finished, null);
        self.device.vkd.destroySemaphore(self.device.handle, self.image_available, null);
        self.device.vkd.destroyCommandPool(self.device.handle, self.command_pool, null);
        self.device.vkd.destroyDescriptorPool(self.device.handle, self.descriptor_pool, null);
        self.device.vkd.destroyDescriptorSetLayout(self.device.handle, self.descriptor_set_layout, null);
        self.device.vkd.destroySampler(self.device.handle, self.gpu_atlas.sampler, null);
        self.device.vkd.destroyImageView(self.device.handle, self.gpu_atlas.view, null);
        self.device.vkd.destroyImage(self.device.handle, self.gpu_atlas.image, null);
        self.device.vkd.freeMemory(self.device.handle, self.gpu_atlas.memory, null);
        self.device.vkd.destroyBuffer(self.device.handle, self.quad_vertex_buffer, null);
        self.device.vkd.freeMemory(self.device.handle, self.quad_vertex_memory, null);
        self.device.vkd.destroyBuffer(self.device.handle, self.instance_buffer, null);
        self.device.vkd.freeMemory(self.device.handle, self.instance_memory, null);
        self.device.vkd.destroyPipeline(self.device.handle, self.pipeline, null);
        self.device.vkd.destroyPipelineLayout(self.device.handle, self.pipeline_layout, null);
        for (self.framebuffers) |fb| self.device.vkd.destroyFramebuffer(self.device.handle, fb, null);
        self.alloc.free(self.framebuffers);
        self.device.vkd.destroyRenderPass(self.device.handle, self.render_pass, null);
        for (self.swapchain.image_views) |iv| self.device.vkd.destroyImageView(self.device.handle, iv, null);
        self.alloc.free(self.swapchain.image_views);
        self.alloc.free(self.swapchain.images);
        self.device.vkd.destroySwapchainKHR(self.device.handle, self.swapchain.handle, null);
        self.device.vkd.destroyDevice(self.device.handle, null);
        self.renderer.vki.destroySurfaceKHR(self.renderer.instance, self.surface, null);
        self.renderer.deinit();
    }

    pub fn uploadInstances(self: *Context, instances: []const Instance) !void {
        if (instances.len > self.instance_capacity) return error.InstanceBufferTooSmall;
        const size = @sizeOf(Instance) * instances.len;
        const mapped = try self.device.vkd.mapMemory(self.device.handle, self.instance_memory, 0, size, .{});
        @memcpy(
            @as([*]Instance, @ptrCast(@alignCast(mapped)))[0..instances.len],
            instances,
        );
        self.device.vkd.unmapMemory(self.device.handle, self.instance_memory);
    }
};

pub const BufferResult = struct { buffer: vk.Buffer, memory: vk.DeviceMemory };

pub fn createHostVisibleBuffer(
    vki: InstanceDispatch,
    physical: vk.PhysicalDevice,
    device: *Device,
    size: vk.DeviceSize,
    usage: vk.BufferUsageFlags,
) !BufferResult {
    const buf = try device.vkd.createBuffer(device.handle, &.{
        .size = size,
        .usage = usage,
        .sharing_mode = .exclusive,
    }, null);

    const reqs = device.vkd.getBufferMemoryRequirements(device.handle, buf);
    const idx = try findMemoryType(
        vki, physical,
        reqs.memory_type_bits,
        .{ .host_visible_bit = true, .host_coherent_bit = true },
    );
    const mem = try device.vkd.allocateMemory(device.handle, &.{
        .allocation_size = reqs.size,
        .memory_type_index = idx,
    }, null);
    try device.vkd.bindBufferMemory(device.handle, buf, mem, 0);
    return .{ .buffer = buf, .memory = mem };
}
```

- [ ] **Step 2: Build**

```bash
zig build
```

Expected: builds.

- [ ] **Step 3: Commit**

```bash
git add src/renderer.zig
git commit -m "feat(renderer): Context bundle with full Vulkan lifecycle"
```

---

### Task 7.3: Main event loop

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

- [ ] **Step 1: Wire up the full poll() loop**

Replace `src/main.zig`:

```zig
const std = @import("std");
const vt = @import("vt");
const pty = @import("pty");
const font = @import("font");
const wayland_mod = @import("wayland-client");
const renderer_mod = @import("renderer");

const FontSize = 14;
const Cols = 80;
const Rows = 24;

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

    // === Wayland ===
    var conn = try wayland_mod.Connection.init();
    defer conn.deinit();

    // === Font ===
    var lookup = try font.lookupMonospace(alloc);
    defer lookup.deinit(alloc);
    var face = try font.Face.init(alloc, lookup.path, lookup.index, FontSize);
    defer face.deinit();
    const cell_w = face.cellWidth();
    const cell_h = face.cellHeight();

    // === Window ===
    const win_w: u32 = @as(u32, Cols) * cell_w;
    const win_h: u32 = @as(u32, Rows) * cell_h;
    var window = try conn.createWindow("waystty");
    defer window.deinit();
    window.width = win_w;
    window.height = win_h;
    _ = conn.display.roundtrip();

    // === Vulkan ===
    var ctx = try renderer_mod.Context.init(
        alloc,
        @ptrCast(conn.display),
        @ptrCast(window.surface),
        win_w,
        win_h,
        vkGetInstanceProcAddrStub,
    );
    defer ctx.deinit();

    // === Atlas ===
    var atlas = try font.Atlas.init(alloc, 1024, 1024);
    defer atlas.deinit();

    // === Terminal ===
    var term = try vt.Terminal.init(alloc, .{
        .cols = Cols,
        .rows = Rows,
        .max_scrollback = 1000,
    });
    defer term.deinit();

    // === Encoders ===
    var key_encoder = try vt.KeyEncoder.init(alloc);
    defer key_encoder.deinit();

    // === PTY ===
    const shell = std.posix.getenv("SHELL") orelse "/bin/sh";
    var p = try pty.Pty.spawn(.{
        .cols = Cols,
        .rows = Rows,
        .shell = shell,
    });
    defer p.deinit();

    // === Keyboard ===
    var keyboard = try wayland_mod.Keyboard.init(alloc, conn.globals.seat.?);
    defer keyboard.deinit(alloc);

    // === Main loop ===
    const wl_fd = conn.display.getFd();
    var pollfds = [_]std.posix.pollfd{
        .{ .fd = wl_fd, .events = std.posix.POLL.IN, .revents = 0 },
        .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 },
    };
    var render_state = try vt.RenderState.init(alloc);
    defer render_state.deinit();

    var read_buf: [8192]u8 = undefined;
    var encode_buf: [64]u8 = undefined;

    while (!window.should_close and p.child_pid > 0) {
        // Flush pending wayland requests before polling
        _ = conn.display.flush();

        _ = std.posix.poll(&pollfds, 10) catch continue;

        if (pollfds[0].revents & std.posix.POLL.IN != 0) {
            _ = conn.display.dispatch();
        }

        if (pollfds[1].revents & std.posix.POLL.IN != 0) {
            const n = p.read(&read_buf) catch |err| switch (err) {
                error.WouldBlock => 0,
                else => return err,
            };
            if (n > 0) try term.write(read_buf[0..n]);
        }

        // Drain keyboard events
        keyboard.tickRepeat();
        for (keyboard.event_queue.items) |kev| {
            if (kev.action == .release) continue;
            try key_encoder.syncFromTerminal(&term);
            const len = try key_encoder.encode(&encode_buf, .{
                .keysym = kev.keysym,
                .modifiers = .{
                    .ctrl = kev.modifiers.ctrl,
                    .shift = kev.modifiers.shift,
                    .alt = kev.modifiers.alt,
                    .super = kev.modifiers.super,
                },
                .action = switch (kev.action) {
                    .press => .press,
                    .repeat => .repeat,
                    .release => .release,
                },
            });
            if (len > 0) _ = try p.write(encode_buf[0..len]);
        }
        keyboard.event_queue.clearRetainingCapacity();

        // Render
        try render_state.update(&term);
        var instances = std.ArrayList(renderer_mod.Instance).init(alloc);
        defer instances.deinit();

        var row_iter = try render_state.rowIterator();
        defer row_iter.deinit();
        var row_y: u32 = 0;
        while (try row_iter.next()) |row_| {
            var row = row_;
            defer row.deinit();
            var cells = row.cells();
            defer cells.deinit();
            var col_x: u32 = 0;
            while (try cells.next()) |cell| {
                if (cell.codepoint == ' ' and cell.bg.r == 0 and cell.bg.g == 0 and cell.bg.b == 0) {
                    col_x += 1;
                    continue;
                }
                const uv = try atlas.getOrInsert(&face, cell.codepoint);
                try instances.append(.{
                    .cell_pos = .{ @floatFromInt(col_x), @floatFromInt(row_y) },
                    .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 },
                    .fg = .{
                        @as(f32, @floatFromInt(cell.fg.r)) / 255.0,
                        @as(f32, @floatFromInt(cell.fg.g)) / 255.0,
                        @as(f32, @floatFromInt(cell.fg.b)) / 255.0,
                        1.0,
                    },
                    .bg = .{
                        @as(f32, @floatFromInt(cell.bg.r)) / 255.0,
                        @as(f32, @floatFromInt(cell.bg.g)) / 255.0,
                        @as(f32, @floatFromInt(cell.bg.b)) / 255.0,
                        1.0,
                    },
                });
                col_x += 1;
            }
            row_y += 1;
        }

        // If atlas got new glyphs, reupload
        if (atlas.dirty) {
            try renderer_mod.uploadAtlasPixels(
                ctx.renderer.vki,
                ctx.device_info.physical,
                &ctx.device,
                &ctx.gpu_atlas,
                atlas.pixels,
                ctx.command_pool,
            );
            atlas.dirty = false;
        }

        try ctx.uploadInstances(instances.items);
        try renderFrame(&ctx, @intCast(instances.items.len), cell_w, cell_h);
    }
}

fn renderFrame(ctx: *renderer_mod.Context, instance_count: u32, cell_w: u32, cell_h: u32) !void {
    const vk = @import("vulkan");
    _ = vk;

    _ = try ctx.device.vkd.waitForFences(ctx.device.handle, 1, @ptrCast(&ctx.in_flight_fence), 1, std.math.maxInt(u64));
    try ctx.device.vkd.resetFences(ctx.device.handle, 1, @ptrCast(&ctx.in_flight_fence));

    var image_index: u32 = 0;
    _ = try ctx.device.vkd.acquireNextImageKHR(
        ctx.device.handle,
        ctx.swapchain.handle,
        std.math.maxInt(u64),
        ctx.image_available,
        .null_handle,
        &image_index,
    );

    try ctx.device.vkd.resetCommandBuffer(ctx.command_buffer, .{});

    try renderer_mod.recordFrame(&ctx.device, .{
        .command_buffer = ctx.command_buffer,
        .framebuffer = ctx.framebuffers[image_index],
        .render_pass = ctx.render_pass,
        .pipeline = ctx.pipeline,
        .pipeline_layout = ctx.pipeline_layout,
        .descriptor_set = ctx.descriptor_set,
        .viewport_size = .{
            @floatFromInt(ctx.swapchain.extent.width),
            @floatFromInt(ctx.swapchain.extent.height),
        },
        .cell_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
        .extent = ctx.swapchain.extent,
        .instance_buffer = ctx.instance_buffer,
        .quad_vertex_buffer = ctx.quad_vertex_buffer,
        .instance_count = instance_count,
    });

    const wait_stage = @import("vulkan").PipelineStageFlags{ .color_attachment_output_bit = true };
    const submit = @import("vulkan").SubmitInfo{
        .wait_semaphore_count = 1,
        .p_wait_semaphores = @ptrCast(&ctx.image_available),
        .p_wait_dst_stage_mask = @ptrCast(&wait_stage),
        .command_buffer_count = 1,
        .p_command_buffers = @ptrCast(&ctx.command_buffer),
        .signal_semaphore_count = 1,
        .p_signal_semaphores = @ptrCast(&ctx.render_finished),
    };
    _ = try ctx.device.vkd.queueSubmit(ctx.device.graphics_queue, 1, @ptrCast(&submit), ctx.in_flight_fence);

    const present = @import("vulkan").PresentInfoKHR{
        .wait_semaphore_count = 1,
        .p_wait_semaphores = @ptrCast(&ctx.render_finished),
        .swapchain_count = 1,
        .p_swapchains = @ptrCast(&ctx.swapchain.handle),
        .p_image_indices = @ptrCast(&image_index),
    };
    _ = try ctx.device.vkd.queuePresentKHR(ctx.device.present_queue, &present);
}

// vulkan-zig needs a loader. On Linux we use dlsym on libvulkan.so.1
fn vkGetInstanceProcAddrStub(instance: anytype, name: [*:0]const u8) ?*const fn () callconv(.C) void {
    // Use dlopen + dlsym to get vkGetInstanceProcAddr from libvulkan.so.1
    const dl = @cImport({
        @cInclude("dlfcn.h");
    });
    const handle = dl.dlopen("libvulkan.so.1", dl.RTLD_NOW) orelse return null;
    const get_proc: *const fn (instance: anytype, name: [*:0]const u8) ?*const fn () callconv(.C) void =
        @ptrCast(@alignCast(dl.dlsym(handle, "vkGetInstanceProcAddr") orelse return null));
    return get_proc(instance, name);
}
```

**Note:** The `vkGetInstanceProcAddrStub` is approximate — the exact signature expected by vulkan-zig's `BaseDispatch.load()` varies. Consult vulkan-zig examples for the correct loader. On Linux, dlopen libvulkan.so.1 and dlsym `vkGetInstanceProcAddr` is the standard approach.

- [ ] **Step 2: Link libdl (for dlopen)**

Add to exe in build.zig:

```zig
exe.linkSystemLibrary("dl");
exe.linkSystemLibrary("wayland-client");
```

- [ ] **Step 3: Build and run**

```bash
zig build run
```

Expected: opens a window showing a shell prompt. Typing and seeing text appear proves the full loop works.

- [ ] **Step 4: Commit**

```bash
git add src/main.zig build.zig
git commit -m "feat(main): full event loop integration"
```

---

### Task 7.4: Resize handling

**Files:**
- Modify: `src/main.zig`
- Modify: `src/renderer.zig` (add `recreateSwapchain`)

- [ ] **Step 1: Add recreateSwapchain to Context**

In `src/renderer.zig`:

```zig
pub fn recreateSwapchain(self: *Context, width: u32, height: u32) !void {
    _ = try self.device.vkd.deviceWaitIdle(self.device.handle);

    // Destroy old
    for (self.framebuffers) |fb| self.device.vkd.destroyFramebuffer(self.device.handle, fb, null);
    self.alloc.free(self.framebuffers);
    for (self.swapchain.image_views) |iv| self.device.vkd.destroyImageView(self.device.handle, iv, null);
    self.alloc.free(self.swapchain.image_views);
    self.alloc.free(self.swapchain.images);
    self.device.vkd.destroySwapchainKHR(self.device.handle, self.swapchain.handle, null);

    // Recreate
    self.swapchain = try self.renderer.createSwapchain(&self.device, self.device_info, self.surface, width, height);
    self.framebuffers = try createFramebuffers(self.alloc, &self.device, self.render_pass, &self.swapchain);
}
```

- [ ] **Step 2: Handle configure events in main loop**

In the main loop (before render), check if window dimensions changed:

```zig
var last_w: u32 = win_w;
var last_h: u32 = win_h;
// ... inside loop, after dispatch:
if (window.width != last_w or window.height != last_h) {
    try ctx.recreateSwapchain(window.width, window.height);
    const new_cols: u16 = @intCast(window.width / cell_w);
    const new_rows: u16 = @intCast(window.height / cell_h);
    try term.resize(new_cols, new_rows);
    try p.resize(new_cols, new_rows);
    last_w = window.width;
    last_h = window.height;
}
```

- [ ] **Step 3: Build and test resize**

```bash
zig build run
```

Expected: resize the window; it should not crash and the grid should adjust.

- [ ] **Step 4: Commit**

```bash
git add src/main.zig src/renderer.zig
git commit -m "feat: handle window resize"
```

---

### Task 7.5: Frame timing instrumentation

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

- [ ] **Step 1: Add frame timer with debug warning**

In the main loop, wrap the render portion:

```zig
var frame_timer = try std.time.Timer.start();
// ... render ...
const frame_ns = frame_timer.read();
if (std.debug.runtime_safety and frame_ns > 16 * std.time.ns_per_ms) {
    std.debug.print("slow frame: {d}ms\n", .{@divTrunc(frame_ns, std.time.ns_per_ms)});
}
```

- [ ] **Step 2: Build**

```bash
zig build
```

Expected: builds. In debug mode, slow frames print a warning.

- [ ] **Step 3: Commit**

```bash
git add src/main.zig
git commit -m "feat: frame timing warning in debug builds"
```

---

## Self-Review Checklist

After completing the plan above, the reviewer should verify:

- [ ] `zig build` succeeds
- [ ] `zig build test` passes (pty, vt, font tests)
- [ ] `zig build run -- --headless` dumps a grid including "hello"
- [ ] `zig build run` opens a window with a working shell
- [ ] Typing in the window produces expected characters
- [ ] Resizing the window doesn't crash and adjusts the grid
- [ ] Running `vim` inside waystty works (tests key encoding, cursor movement, redraws)
- [ ] Running `htop` inside waystty works (tests refresh rate, color, block characters)
- [ ] Exit via `exit` command closes the window cleanly

## Spec Coverage Map

| Spec Section | Implementing Tasks |
|--------------|-------------------|
| Architecture: 6 modules | 0.3, 1.x, 2.x, 4.x, 5.x, 6.x, 7.x |
| wayland.zig protocols | 5.1, 5.2, 5.3 |
| Key repeat | 5.5 |
| Focus events | 5.4 (enter/leave) |
| Cursor shape | *Deferred — add as follow-up task* |
| DPI scaling | *Deferred — add as follow-up task* |
| Clipboard | *Phase 2 per spec* |
| pty.zig | 1.x |
| vt.zig all handles | 2.1–2.7 |
| Effect callbacks | *Implementer must wire these in 2.2+ per observed upstream API* |
| font.zig (fontconfig, freetype) | 4.1, 4.2 |
| Glyph atlas R8 | 4.3 |
| Harfbuzz | *Phase 2 per spec* |
| Cell metrics | 4.2 (cellWidth/cellHeight) |
| renderer.zig Vulkan | 6.1–6.9, 7.2 |
| Shaders glslc | 6.2 |
| Instanced draw | 6.9, 7.3 |
| Swapchain recreation | 7.4 |
| Event loop with poll | 7.3 |
| Resize handling | 7.4 |
| Hardcoded defaults | 7.3 (constants at top of main.zig) |
| Build system (build.zig only) | 0.2, 0.3, 0.4, 1.1, 2.2, 4.1, 5.1, 6.1, 6.2 |
| Testing (zig test) | throughout via `zig build test` |
| Frame timing | 7.5 |

### Known Gaps (acknowledge before starting)

1. **Cursor shape + DPI scaling** — spec mentions these but no tasks. Add as follow-up tasks after the terminal is functional.
2. **Focus encoding via ghostty_focus_encode** — spec mentions this; implementer must add to the keyboard enter/leave handler in Task 5.4 or as a follow-up.
3. **`GHOSTTY_TERMINAL_OPT_*` effect callbacks** — the exact registration happens via the upstream Zig API, which we don't fully know until Task 2.1. The implementer must set WRITE_PTY at minimum, along with DA, SIZE, XTVERSION, TITLE_CHANGED, COLOR_SCHEME per spec.
4. **vulkan-zig loader** — the `vkGetInstanceProcAddrStub` in Task 7.3 is approximate. Must be adjusted to match vulkan-zig's expected signature.
5. **zig-wayland scanner API** — has churned across Zig versions. The build.zig snippets in Task 5.1 are approximate and must be adapted to the pinned dependency version.

These gaps are called out because this plan covers a very large surface (Wayland + Vulkan + libghostty-vt + freetype + xkb), and small API drift in any of the five dependencies will need small adjustments during implementation.