a73x

b8b679eb

docs: the native client implementation plan

a73x   2026-09-04 18:48

Commit message
docs: the native client implementation plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW

docs/superpowers/plans/2026-09-04-native-client.md
Old New
@@ -0,0 +1,2794 @@
1 # Native Client (`muxg`) Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** A window, `muxg TARGET`, that shows one daemon session painted with OpenGL from the cells the daemon sends, types into it, follows the window's size, and prints a per-stage frame-timing table proving it painted under a flood.
6
7 **Architecture:** A pump thread under `src/client/` owns the transport (dial, attach, read frames, apply to the `Replica` under a mutex, redial) and wakes the window thread through a callback; the window thread (`src/gui/`) rebuilds instanced quads from the whole grid on each wake and draws them in one call. SDL3 is confined to `frame.zig` and the entry file; every other file under `src/gui/` is plain Zig over `term.grid`, freetype and GL function pointers. No wall policy exists in `src/gui/`: no tiles, no layout file, no picker, no host grade.
8
9 **Tech Stack:** Zig 0.15.2 (`deps/zig/zig`), SDL3 3.4, OpenGL 3.3 core loaded through `SDL_GL_GetProcAddress`, freetype2, fontconfig — all system libraries through pkg-config, dynamically linked, opt-in under `make native`.
10
11 **Spec:** `docs/superpowers/specs/2026-09-04-native-client-design.md`
12
13 ## Global Constraints
14
15 - **Prerequisite:** the cells-on-the-wire branch must be on main first. Every type below (`term.grid.Grid`, `Row`, `Cell`, `proto.CellStyle`, `proto.Wide`, `Replica.apply` returning `.painted | .resync`, `Replica.attachArgs`) comes from it. Do not start Task 4 or later on a tree without `src/engine/grid.zig`.
16 - Toolchain is `deps/zig/zig` (0.15.2). System zig will not build this tree. Every command below spells `deps/zig/zig` or `make`. A fresh worktree has no `deps/`: link the main checkout's before anything else (`ln -s ../../../../deps/zig deps/zig; ln -s ../../../../deps/quic deps/quic` from a worktree under `.claude/worktrees/`), and never share `.zig-cache` between worktrees.
17 - `make build`, `make check`, `make test` and `make ci` never build or link `src/gui/`. `zig build native`, `zig build native-test` and `zig build native-e2e` are the only steps that touch it; `make native` and `make native-e2e` are their only callers.
18 - `src/gui/` imports `client` and `term` and nothing else of ours. `src/cli/muxg.zig` (the entry) may also import `cliflags` and `sockpath`.
19 - No wall policy under `src/gui/`: none of `@import("wall")`, `wall_host`, `wall_layout`, `wall_picker`, `layoutfile`, `SessionPoll` may appear there. Enforced by build.zig folder rule 8 (Task 1).
20 - SDL is confined: the needle `sdl` may appear only in `src/gui/frame.zig` and `src/cli/muxg.zig`, comments included (the ban lower-cases each line). Enforced by folder rule 9 (Task 1).
21 - The existing folder rules 5, 6 and 7 list their folders explicitly; `src/gui` is added to each (Task 1). Rule 4 (escape bytes, termios) covers `src/client/`, so `session_pump.zig` spells no escape byte.
22 - Every module must `@import` every table import it declares (`checkGrantsUsed` fails the build otherwise).
23 - Every hand-run rig exports an isolated `XDG_STATE_HOME`; the e2e leg sources `test/e2e_lib.sh`, which does that.
24 - Never quote a speed number measured in a Debug build. The e2e leg refuses to grade one.
25 - Commit subjects are `type: what changed` with no scope. Every commit ends with:
26 ```
27 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
28 Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW
29 ```
30 - `make check` before every commit. It does not build `src/gui/`, so each task also runs `make native` (and from Task 2 on, `zig build native-test`) before committing.
31 - Never `pkill`/`pgrep`/`killall` anything named `mux` or `muxg`. Kill only a pid you captured with `$!`.
32
33 ---
34
35 ## File map
36
37 | File | Responsibility |
38 |---|---|
39 | `src/gui/native.zig` | Row root: the two folder rules in its header, re-exports, `run` |
40 | `src/gui/bench.zig` | Per-frame stage timing ring and the stats table |
41 | `src/gui/atlas.zig` | R8 glyph atlas: shelf packer, codepoint → entry map, growth |
42 | `src/gui/font.zig` | fontconfig match + freetype rasterise; cell metrics |
43 | `src/gui/quads.zig` | `Row` + column offset → instanced quads; colours |
44 | `src/gui/gl.zig` | GL 3.3 core objects loaded by name; one instanced draw |
45 | `src/gui/frame.zig` | SDL window, GL context, event loop, paint, test hook, SIGUSR1 |
46 | `src/cli/muxg.zig` | argv → `client.Target`, then `native.run` |
47 | `src/client/session_pump.zig` | The transport-owning thread, mailbox, doorbell, state |
48 | `build.zig` | rows `native` and `muxg`, steps `native` `native-test` `native-e2e`, rules 8 and 9, `src/gui` in rules 5–7 |
49 | `Makefile` | `native`, `native-e2e` |
50 | `test/native.sh` | The end-to-end leg |
51 | `README.md`, `CLAUDE.md` | The viewer's one section; the table row and rules |
52
53 ---
54
55 ### Task 1: Build wiring and the GL probe (the offscreen spike)
56
57 **Files:**
58 - Create: `src/gui/native.zig`, `src/cli/muxg.zig`
59 - Modify: `build.zig` (module table after the `mux` row; `checkSourceBans` rule table; a `native` block after the `agent` step), `Makefile` (after `agent:`), `docs/superpowers/specs/2026-09-04-native-client-design.md` (three lines, step 8)
60
61 **Interfaces:**
62 - Produces: module rows `native` (root `src/gui/native.zig`, imports `term` `client`) and `muxg` (root `src/cli/muxg.zig`, imports `native` `client` `term` `cliflags` `sockpath`); build steps `native`, `native-test`; `zig-out/bin/muxg`.
63
64 - [ ] **Step 1: Write the row root with the two rules in its header**
65
66 `src/gui/native.zig`:
67
68 ```zig
69 //! The native client's painter: a window on ONE daemon session, painted
70 //! with OpenGL from the grid cells-on-the-wire delivers. A SESSION VIEWER,
71 //! not a wall.
72 //!
73 //! Two folder rules, enforced by build.zig (rules 8 and 9):
74 //!
75 //! - No wall policy enters src/gui/. No tiles, no layout file, no picker,
76 //! no host grade, no session list. The wall's policy lives under src/tui/
77 //! against a cell painter, and the hub's copy of its grade is already an
78 //! open issue; a third copy here would be the same mistake with pixels.
79 //! Native panes wait on lifting that policy into a painter-free module.
80 //! - The windowing library is confined to frame.zig and the entry file.
81 //! Every other file here is plain Zig over term.grid, freetype and GL
82 //! function pointers, so the painter can be tested with no window and
83 //! moved to another windowing layer without touching it.
84 //!
85 //! Imports `client` and `term` and nothing else of ours.
86 const std = @import("std");
87 const client = @import("client");
88 const term = @import("term");
89
90 pub const frame = @import("frame.zig");
91
92 /// The one entry: dial, attach, paint until the window closes or the
93 /// session ends. Returns the process exit code, which is the shell's when
94 /// the session exited, because a script can read it.
95 pub fn run(alloc: std.mem.Allocator, opts: frame.Options) !u8 {
96 return frame.run(alloc, opts);
97 }
98
99 test {
100 _ = client;
101 _ = term;
102 _ = frame;
103 }
104 ```
105
106 - [ ] **Step 2: Write the probe entry**
107
108 `src/cli/muxg.zig` (this version is the spike; Task 8 replaces it):
109
110 ```zig
111 //! `muxg`: the native client's entry. Parses the target, hands it to
112 //! src/gui/native.zig. Until Task 8 this file is the GL probe: it opens a
113 //! window on whatever video driver SDL picks and prints the GL version.
114 const std = @import("std");
115 const native = @import("native");
116 const client = @import("client");
117 const term = @import("term");
118 const cliflags = @import("cliflags");
119 const sockpath = @import("sockpath");
120
121 const c = @cImport({
122 @cInclude("SDL3/SDL.h");
123 });
124
125 pub fn main() !u8 {
126 if (!c.SDL_Init(c.SDL_INIT_VIDEO)) {
127 std.debug.print("muxg: SDL_Init: {s}\n", .{std.mem.span(c.SDL_GetError())});
128 return 2;
129 }
130 defer c.SDL_Quit();
131 _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3);
132 _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 3);
133 _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_CORE);
134 const win = c.SDL_CreateWindow("muxg probe", 320, 200, c.SDL_WINDOW_OPENGL) orelse {
135 std.debug.print("muxg: SDL_CreateWindow: {s}\n", .{std.mem.span(c.SDL_GetError())});
136 return 2;
137 };
138 defer c.SDL_DestroyWindow(win);
139 const ctx = c.SDL_GL_CreateContext(win) orelse {
140 std.debug.print("muxg: SDL_GL_CreateContext: {s}\n", .{std.mem.span(c.SDL_GetError())});
141 return 2;
142 };
143 defer _ = c.SDL_GL_DestroyContext(ctx);
144 const get_string: ?*const fn (u32) callconv(.c) ?[*:0]const u8 =
145 @ptrCast(c.SDL_GL_GetProcAddress("glGetString"));
146 const f = get_string orelse {
147 std.debug.print("muxg: no glGetString\n", .{});
148 return 2;
149 };
150 const ver = f(0x1F02) orelse "?"; // GL_VERSION
151 std.debug.print("muxg probe: driver={s} GL_VERSION={s}\n", .{
152 std.mem.span(c.SDL_GetCurrentVideoDriver()),
153 std.mem.span(ver),
154 });
155 _ = native;
156 _ = client;
157 _ = term;
158 _ = cliflags;
159 _ = sockpath;
160 return 0;
161 }
162 ```
163
164 - [ ] **Step 3: Add the two rows to the module table**
165
166 In `build.zig`, after the `mux` row of `mod_table`:
167
168 ```zig
169 // Opt-in rows: nothing in test_order and nothing the `mux` binary links
170 // names them, so `zig build`, `test` and `check` never compile a line of
171 // src/gui/. Only the `native*` steps below reference them.
172 .{ .name = "native", .path = "src/gui/native.zig", .link_libc = true, .imports = &.{ "term", "client" } },
173 .{ .name = "muxg", .path = "src/cli/muxg.zig", .link_libc = true, .imports = &.{ "native", "client", "term", "cliflags", "sockpath" } },
174 ```
175
176 - [ ] **Step 4: Add `src/gui` to rules 5, 6 and 7 and add rules 8 and 9**
177
178 In `checkSourceBans`'s table: append `"src/gui"` to the `.folders` list of the rules whose `.rule` is `"5"`, `"6"` and `"7"`. Then append two entries:
179
180 ```zig
181 .{
182 .rule = "8",
183 .folders = &.{"src/gui"},
184 .needles = &.{ "@import(\"wall\")", "wall_host", "wall_layout", "wall_picker", "layoutfile", "sessionpoll" },
185 .why = "src/gui/ is a session viewer and carries no wall policy — " ++
186 "tiles, the layout file, the picker and the host grade live under " ++
187 "src/tui/, and a second copy of them is the hub's open issue again",
188 },
189 .{
190 .rule = "9",
191 .folders = &.{"src/gui"},
192 .needles = &.{"sdl"},
193 .except = &.{"src/gui/frame.zig"},
194 .why = "the windowing library is confined to frame.zig, so every other " ++
195 "painter file tests with no window and survives a change of layer",
196 },
197 ```
198
199 (Rule 9's folder is `src/gui` only; `src/cli/muxg.zig` is outside it and may spell SDL.)
200
201 - [ ] **Step 5: Add the `native` steps**
202
203 In `build()`, after the `agent_step` block:
204
205 ```zig
206 // The native client: opt-in, dynamically linked against the system's
207 // SDL3, freetype and fontconfig through pkg-config. GL is loaded by
208 // name through SDL, so no -lGL. Nothing on the default path depends on
209 // these, so a box without SDL3 builds and gates mux as before.
210 const native_mod = mods[comptime idxOf("native")];
211 const muxg_mod = mods[comptime idxOf("muxg")];
212 const muxg_exe = b.addExecutable(.{ .name = "muxg", .root_module = muxg_mod });
213 linkerFor(muxg_exe);
214 muxg_exe.linkSystemLibrary2("sdl3", .{});
215 muxg_exe.linkSystemLibrary2("freetype2", .{});
216 muxg_exe.linkSystemLibrary2("fontconfig", .{});
217 linkQuic(b, muxg_exe, quic);
218 const native_step = b.step("native", "Build the native client, muxg (opt-in; needs SDL3, freetype, fontconfig)");
219 native_step.dependOn(&b.addInstallArtifact(muxg_exe, .{}).step);
220
221 // Unit tests open no window but link the libraries: frame.zig's key
222 // table is tested through the same compilation unit that calls SDL.
223 const native_tests = b.addTest(.{ .root_module = native_mod });
224 linkerFor(native_tests);
225 native_tests.linkSystemLibrary2("sdl3", .{});
226 native_tests.linkSystemLibrary2("freetype2", .{});
227 native_tests.linkSystemLibrary2("fontconfig", .{});
228 linkQuic(b, native_tests, quic);
229 const native_test_step = b.step("native-test", "Run the native client's unit tests (opt-in)");
230 native_test_step.dependOn(&b.addRunArtifact(native_tests).step);
231
232 const native_e2e = b.addSystemCommand(&.{"test/native.sh"});
233 native_e2e.addArtifactArg(mux_exe);
234 native_e2e.addArtifactArg(muxg_exe);
235 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)");
236 native_e2e_step.dependOn(&native_e2e.step);
237 ```
238
239 `linkQuic` is needed because `client` links the QUIC transport; the `mux` binary already does the same.
240
241 - [ ] **Step 6: Add the Makefile targets**
242
243 After the `agent:` recipe in `Makefile`:
244
245 ```makefile
246 # The native client (docs/superpowers/specs/2026-09-04-native-client-design.md):
247 # opt-in, because it links the system's SDL3, freetype and fontconfig and
248 # a box without them must still build and gate mux. Not part of ci.
249 native: mac-sdk
250 $(ZIG) build native native-test
251
252 native-e2e: mac-sdk
253 $(ZIG) build native-e2e
254 ```
255
256 - [ ] **Step 7: Build and run the probe (this is the spike)**
257
258 Run:
259 ```sh
260 make check; echo "check rc=$?"
261 make native; echo "native rc=$?"
262 SDL_VIDEODRIVER=offscreen ./zig-out/bin/muxg; echo "offscreen rc=$?"
263 ./zig-out/bin/muxg; echo "display rc=$?"
264 ```
265 Expected: `check rc=0`; `native rc=0`; the offscreen run prints `muxg probe: driver=offscreen GL_VERSION=3.3 ...` (or higher) and `offscreen rc=0`; the display run prints `driver=wayland` or `x11` and rc 0.
266
267 If the offscreen run prints `SDL_GL_CreateContext: ...` and rc 2, the offscreen driver on this SDL has no EGL: record that line, and in Task 9 the leg runs under `xvfb-run -a` with `SDL_VIDEODRIVER=x11` instead (`pacman -S xorg-server-xvfb` on Arch). Amend the spec's Testing section with which was true (step 8).
268
269 - [ ] **Step 8: Amend the spec's three stale lines**
270
271 In `docs/superpowers/specs/2026-09-04-native-client-design.md`:
272 - Section 1, the pkg-config sentence: replace `SDL3, libGL, freetype and fontconfig` with `SDL3, freetype and fontconfig (GL functions are loaded by name through SDL, so no libGL link)`.
273 - Section 6, `native-test builds src/gui/ without SDL and runs:` → `native-test links the same libraries, opens no window, and runs:`.
274 - Section 5, add a bullet: `A local dial to a socket nobody answers exits 2 with \`muxg: no daemon at PATH (run: mux d start -d --sock PATH)\`. muxg never starts a daemon: the self-exec rule says an auto-start may only run the image already running, and this image is not the daemon's.`
275 - Section 6, step 1 of the leg: record the offscreen or Xvfb outcome from step 7.
276
277 - [ ] **Step 9: Commit**
278
279 ```sh
280 make check && git add build.zig Makefile src/gui/native.zig src/cli/muxg.zig docs/superpowers/specs/2026-09-04-native-client-design.md
281 git commit -m "build: opt-in native rows, steps and folder rules for the muxg viewer"
282 ```
283
284 ---
285
286 ### Task 2: `bench.zig` — the frame-timing ring
287
288 **Files:**
289 - Create: `src/gui/bench.zig`
290 - Modify: `src/gui/native.zig` (add `pub const bench = @import("bench.zig");` and `_ = bench;` in the test block)
291
292 **Interfaces:**
293 - Produces:
294 - `pub const Frame = struct { apply_us: u32 = 0, rebuild_us: u32 = 0, atlas_us: u32 = 0, upload_us: u32 = 0, draw_us: u32 = 0 }`
295 - `pub const Ring = struct { pub fn record(self: *Ring, f: Frame) void; pub fn count(self: *const Ring) u64; pub fn report(self: *const Ring, buf: []u8) []const u8; }` with `pub const capacity = 256`
296 - `pub fn usSince(t: *std.time.Timer) u32` — microseconds since the timer's last lap, capped at `maxInt(u32)`.
297
298 - [ ] **Step 1: Write the failing tests**
299
300 Append to `src/gui/bench.zig` (the file does not exist yet; create it with just these tests and `const std = @import("std");` at the top):
301
302 ```zig
303 test "an empty ring reports zero frames and no stats rows" {
304 var r: Ring = .{};
305 var buf: [2048]u8 = undefined;
306 const out = r.report(&buf);
307 try std.testing.expect(std.mem.indexOf(u8, out, "(0 frames)") != null);
308 }
309
310 /// The four numbers on the table row that starts with `name`, or null.
311 /// Tokenised rather than matched byte for byte, so the test pins the
312 /// stats and not the column widths.
313 fn statsRow(table: []const u8, name: []const u8) ?[4]u32 {
314 var lines = std.mem.splitScalar(u8, table, '\n');
315 while (lines.next()) |line| {
316 var toks = std.mem.tokenizeScalar(u8, line, ' ');
317 const first = toks.next() orelse continue;
318 if (!std.mem.eql(u8, first, name)) continue;
319 var out: [4]u32 = undefined;
320 for (&out) |*slot| slot.* = std.fmt.parseInt(u32, toks.next() orelse return null, 10) catch return null;
321 return out;
322 }
323 return null;
324 }
325
326 test "min avg p99 max per stage and a total row" {
327 var r: Ring = .{};
328 // 100 frames: rebuild 1..100 us, draw a flat 10 us.
329 var i: u32 = 1;
330 while (i <= 100) : (i += 1) r.record(.{ .rebuild_us = i, .draw_us = 10 });
331 try std.testing.expectEqual(@as(u64, 100), r.count());
332 var buf: [2048]u8 = undefined;
333 const out = r.report(&buf);
334 try std.testing.expect(std.mem.indexOf(u8, out, "(100 frames)") != null);
335 try std.testing.expectEqual([4]u32{ 1, 50, 99, 100 }, statsRow(out, "rebuild").?);
336 try std.testing.expectEqual([4]u32{ 10, 10, 10, 10 }, statsRow(out, "draw_swap").?);
337 // total per frame = rebuild + draw: min 11, avg 60, p99 109, max 110
338 try std.testing.expectEqual([4]u32{ 11, 60, 109, 110 }, statsRow(out, "total").?);
339 }
340
341 test "the ring keeps the newest capacity frames" {
342 var r: Ring = .{};
343 var i: u32 = 0;
344 while (i < Ring.capacity + 10) : (i += 1) r.record(.{ .apply_us = i });
345 try std.testing.expectEqual(@as(u64, Ring.capacity + 10), r.count());
346 var buf: [2048]u8 = undefined;
347 const out = r.report(&buf);
348 // The oldest ten (0..9) fell out: min is 10, max is 265.
349 const s = statsRow(out, "apply").?;
350 try std.testing.expectEqual(@as(u32, 10), s[0]);
351 try std.testing.expectEqual(@as(u32, Ring.capacity + 9), s[3]);
352 }
353 ```
354
355 - [ ] **Step 2: Run to verify they fail**
356
357 Run: `deps/zig/zig build native-test 2>&1 | tail -5`
358 Expected: compile error, `Ring` not found.
359
360 - [ ] **Step 3: Implement**
361
362 Prepend to `src/gui/bench.zig` (above the tests):
363
364 ```zig
365 //! Per-frame stage timings in a ring, and the table that reports them.
366 //! Always compiled in: a timer read per stage is noise next to a draw, and
367 //! the table is how the waystty fault was found — a painter that is fast
368 //! per frame and never asked to paint. Idle passes are not recorded.
369 //! Pure: no clock of its own, no output; the caller times and prints.
370 const std = @import("std");
371
372 pub const Frame = struct {
373 apply_us: u32 = 0,
374 rebuild_us: u32 = 0,
375 atlas_us: u32 = 0,
376 upload_us: u32 = 0,
377 draw_us: u32 = 0,
378
379 fn total(self: Frame) u32 {
380 return self.apply_us +| self.rebuild_us +| self.atlas_us +| self.upload_us +| self.draw_us;
381 }
382 };
383
384 pub const Ring = struct {
385 pub const capacity = 256;
386
387 frames: [capacity]Frame = undefined,
388 next: usize = 0,
389 recorded: u64 = 0,
390
391 pub fn record(self: *Ring, f: Frame) void {
392 self.frames[self.next] = f;
393 self.next = (self.next + 1) % capacity;
394 self.recorded += 1;
395 }
396
397 pub fn count(self: *const Ring) u64 {
398 return self.recorded;
399 }
400
401 fn held(self: *const Ring) usize {
402 return @intCast(@min(self.recorded, capacity));
403 }
404
405 const Stats = struct { min: u32, avg: u32, p99: u32, max: u32 };
406
407 fn stats(self: *const Ring, comptime pick: fn (Frame) u32) Stats {
408 const n = self.held();
409 var vals: [capacity]u32 = undefined;
410 var sum: u64 = 0;
411 for (self.frames[0..n], 0..) |f, i| {
412 vals[i] = pick(f);
413 sum += vals[i];
414 }
415 std.mem.sort(u32, vals[0..n], {}, std.sort.asc(u32));
416 return .{
417 .min = vals[0],
418 .avg = @intCast(sum / n),
419 .p99 = vals[(n * 99) / 100 - @intFromBool((n * 99) % 100 == 0 and n * 99 / 100 > 0)],
420 .max = vals[n - 1],
421 };
422 }
423
424 const rows = [_]struct { name: []const u8, pick: fn (Frame) u32 }{
425 .{ .name = "apply", .pick = struct {
426 fn f(x: Frame) u32 {
427 return x.apply_us;
428 }
429 }.f },
430 .{ .name = "rebuild", .pick = struct {
431 fn f(x: Frame) u32 {
432 return x.rebuild_us;
433 }
434 }.f },
435 .{ .name = "atlas_upload", .pick = struct {
436 fn f(x: Frame) u32 {
437 return x.atlas_us;
438 }
439 }.f },
440 .{ .name = "instance_upload", .pick = struct {
441 fn f(x: Frame) u32 {
442 return x.upload_us;
443 }
444 }.f },
445 .{ .name = "draw_swap", .pick = struct {
446 fn f(x: Frame) u32 {
447 return x.draw_us;
448 }
449 }.f },
450 .{ .name = "total", .pick = Frame.total },
451 };
452
453 /// The table, into `buf`. A buffer too small truncates the table
454 /// rather than failing: a report is diagnostics, never a gate.
455 pub fn report(self: *const Ring, buf: []u8) []const u8 {
456 var w = std.io.Writer.fixed(buf);
457 w.print("=== muxg frame timing ({d} frames) ===\n", .{self.recorded}) catch return w.buffered();
458 w.print("{s:<16}{s:>6}{s:>7}{s:>7}{s:>7} (us)\n", .{ "stage", "min", "avg", "p99", "max" }) catch return w.buffered();
459 if (self.held() == 0) return w.buffered();
460 inline for (rows) |row| {
461 const s = self.stats(row.pick);
462 w.print("{s:<16}{d:>6}{d:>7}{d:>7}{d:>7}\n", .{ row.name, s.min, s.avg, s.p99, s.max }) catch return w.buffered();
463 }
464 return w.buffered();
465 }
466 };
467
468 /// Microseconds since the timer's last lap, capped so a stall cannot wrap.
469 pub fn usSince(t: *std.time.Timer) u32 {
470 const ns = t.lap();
471 return @intCast(@min(ns / std.time.ns_per_us, std.math.maxInt(u32)));
472 }
473 ```
474
475 The p99 index: for n = 100 the test expects 99 (the 99th sorted value, index 98); `(n*99)/100 = 99`, minus 1 when `n*99` divides evenly, giving index 98 → value 99. For n = 256: `25344/100 = 253`, not even, index 253. For n = 1: `99/100 = 0`, value at index 0.
476
477 - [ ] **Step 4: Wire the re-export and run the tests**
478
479 In `src/gui/native.zig` add `pub const bench = @import("bench.zig");` under `pub const frame` and `_ = bench;` in the test block. (`frame.zig` does not exist until Task 8; until then keep `pub const frame` and `run` OUT of `native.zig` — add them in Task 8. For Tasks 2–7, `native.zig` re-exports only the files that exist.)
480
481 Run: `deps/zig/zig build native-test 2>&1 | tail -5; echo rc=$?`
482 Expected: rc 0, no output from the tests. The column widths are the spec's sample table (`stage` 16 wide, then 6, 7, 7, 7); the tests tokenise rows, so a width change never fails them.
483
484 - [ ] **Step 5: Commit**
485
486 ```sh
487 make check && git add src/gui/bench.zig src/gui/native.zig
488 git commit -m "feat: the native client's per-frame stage timing ring and table"
489 ```
490
491 ---
492
493 ### Task 3: `atlas.zig` — the glyph atlas
494
495 **Files:**
496 - Create: `src/gui/atlas.zig`
497 - Modify: `src/gui/native.zig` (re-export + test reach)
498
499 **Interfaces:**
500 - Produces:
501 - `pub const Entry = struct { x: u16, y: u16, w: u16, h: u16, left: i16, top: i16 }`
502 - `pub const Atlas = struct { width: u16, height: u16, pixels: []u8, dirty: bool, ... pub fn init(alloc, width: u16, height: u16) !Atlas; pub fn deinit(self: *Atlas, alloc) void; pub fn get(self: *const Atlas, cp: u21) ?Entry; pub fn put(self: *Atlas, alloc, cp: u21, w: u16, h: u16, left: i16, top: i16, bitmap: []const u8) !Entry; }`
503 - `put` copies `bitmap` (row-major, `w*h` bytes, one byte per pixel) into the atlas, growing its height by doubling when the current shelves are full, and sets `dirty = true`. The caller clears `dirty` after uploading.
504
505 - [ ] **Step 1: Write the failing tests**
506
507 Create `src/gui/atlas.zig` with `const std = @import("std");` and:
508
509 ```zig
510 test "a second glyph lands beside the first on the same shelf" {
511 const alloc = std.testing.allocator;
512 var a = try Atlas.init(alloc, 64, 16);
513 defer a.deinit(alloc);
514 const px = [_]u8{0xff} ** (8 * 12);
515 const e1 = try a.put(alloc, 'A', 8, 12, 0, 12, &px);
516 const e2 = try a.put(alloc, 'B', 8, 12, 1, 11, &px);
517 try std.testing.expectEqual(@as(u16, 0), e1.x);
518 try std.testing.expectEqual(@as(u16, 8), e2.x);
519 try std.testing.expectEqual(e1.y, e2.y);
520 try std.testing.expectEqual(@as(i16, 1), e2.left);
521 try std.testing.expect(a.dirty);
522 try std.testing.expectEqual(e1, a.get('A').?);
523 try std.testing.expect(a.get('C') == null);
524 }
525
526 test "a row that overflows opens a new shelf below" {
527 const alloc = std.testing.allocator;
528 var a = try Atlas.init(alloc, 20, 64);
529 defer a.deinit(alloc);
530 const px = [_]u8{1} ** (8 * 10);
531 _ = try a.put(alloc, 'a', 8, 10, 0, 0, &px);
532 _ = try a.put(alloc, 'b', 8, 10, 0, 0, &px);
533 const e3 = try a.put(alloc, 'c', 8, 10, 0, 0, &px);
534 try std.testing.expectEqual(@as(u16, 0), e3.x);
535 try std.testing.expectEqual(@as(u16, 10), e3.y);
536 }
537
538 test "growth keeps every earlier entry's rectangle and pixels" {
539 const alloc = std.testing.allocator;
540 var a = try Atlas.init(alloc, 16, 6);
541 defer a.deinit(alloc);
542 const px1 = [_]u8{7} ** (4 * 4);
543 const e1 = try a.put(alloc, 'x', 4, 4, 0, 0, &px1);
544 const px2 = [_]u8{9} ** (4 * 8);
545 // 8 tall on a 6-high atlas: the shelf grows the atlas to 12 (doubling).
546 const e2 = try a.put(alloc, 'y', 4, 8, 0, 0, &px2);
547 try std.testing.expectEqual(@as(u16, 12), a.height);
548 try std.testing.expectEqual(@as(u16, 4), e2.x); // beside x, same shelf
549 try std.testing.expectEqual(e1, a.get('x').?);
550 try std.testing.expectEqual(@as(u8, 7), a.pixels[@as(usize, e1.y) * a.width + e1.x]);
551 try std.testing.expectEqual(@as(u8, 9), a.pixels[@as(usize, e2.y) * a.width + e2.x]);
552 }
553
554 test "a glyph wider than the atlas is refused, not wrapped" {
555 const alloc = std.testing.allocator;
556 var a = try Atlas.init(alloc, 8, 8);
557 defer a.deinit(alloc);
558 const px = [_]u8{1} ** (9 * 1);
559 try std.testing.expectError(error.GlyphTooWide, a.put(alloc, 'w', 9, 1, 0, 0, &px));
560 }
561 ```
562
563 - [ ] **Step 2: Run to verify they fail**
564
565 Run: `deps/zig/zig build native-test 2>&1 | tail -5` — after adding `pub const atlas = @import("atlas.zig");` and `_ = atlas;` to `native.zig`.
566 Expected: compile error, `Atlas` not found.
567
568 - [ ] **Step 3: Implement**
569
570 Prepend to `src/gui/atlas.zig`:
571
572 ```zig
573 //! One R8 texture's worth of glyph bitmaps, shelf-packed: glyphs go left
574 //! to right along a shelf as tall as its tallest glyph, and a glyph that
575 //! does not fit opens the next shelf below. The atlas never shrinks and
576 //! never moves an entry — growth doubles the height and copies, so every
577 //! rectangle a quad already holds stays true. `dirty` says the texture on
578 //! the GPU is behind; the uploader clears it. No GL here.
579 const std = @import("std");
580
581 pub const Entry = struct { x: u16, y: u16, w: u16, h: u16, left: i16, top: i16 };
582
583 pub const Atlas = struct {
584 width: u16,
585 height: u16,
586 pixels: []u8,
587 dirty: bool = false,
588 entries: std.AutoHashMapUnmanaged(u21, Entry) = .empty,
589 /// The open shelf: its top row, its height, and the next free x.
590 shelf_y: u16 = 0,
591 shelf_h: u16 = 0,
592 shelf_x: u16 = 0,
593
594 pub fn init(alloc: std.mem.Allocator, width: u16, height: u16) !Atlas {
595 const px = try alloc.alloc(u8, @as(usize, width) * height);
596 @memset(px, 0);
597 return .{ .width = width, .height = height, .pixels = px };
598 }
599
600 pub fn deinit(self: *Atlas, alloc: std.mem.Allocator) void {
601 alloc.free(self.pixels);
602 self.entries.deinit(alloc);
603 }
604
605 pub fn get(self: *const Atlas, cp: u21) ?Entry {
606 return self.entries.get(cp);
607 }
608
609 pub fn put(
610 self: *Atlas,
611 alloc: std.mem.Allocator,
612 cp: u21,
613 w: u16,
614 h: u16,
615 left: i16,
616 top: i16,
617 bitmap: []const u8,
618 ) !Entry {
619 if (w > self.width) return error.GlyphTooWide;
620 std.debug.assert(bitmap.len >= @as(usize, w) * h);
621 // Wrap to a new shelf when this glyph does not fit beside the last.
622 if (self.shelf_x + w > self.width) {
623 self.shelf_y += self.shelf_h;
624 self.shelf_h = 0;
625 self.shelf_x = 0;
626 }
627 // Grow until the shelf's bottom (with this glyph on it) fits.
628 while (self.shelf_y + @max(self.shelf_h, h) > self.height) try self.grow(alloc);
629 const e: Entry = .{ .x = self.shelf_x, .y = self.shelf_y, .w = w, .h = h, .left = left, .top = top };
630 var row: usize = 0;
631 while (row < h) : (row += 1) {
632 const dst = (@as(usize, e.y) + row) * self.width + e.x;
633 @memcpy(self.pixels[dst .. dst + w], bitmap[row * w .. row * w + w]);
634 }
635 self.shelf_x += w;
636 self.shelf_h = @max(self.shelf_h, h);
637 self.dirty = true;
638 try self.entries.put(alloc, cp, e);
639 return e;
640 }
641
642 fn grow(self: *Atlas, alloc: std.mem.Allocator) !void {
643 const new_h: u16 = std.math.mul(u16, self.height, 2) catch return error.AtlasFull;
644 const px = try alloc.alloc(u8, @as(usize, self.width) * new_h);
645 @memset(px, 0);
646 @memcpy(px[0..self.pixels.len], self.pixels);
647 alloc.free(self.pixels);
648 self.pixels = px;
649 self.height = new_h;
650 self.dirty = true;
651 }
652 };
653 ```
654
655 - [ ] **Step 4: Run the tests**
656
657 Run: `deps/zig/zig build native-test 2>&1 | tail -5; echo rc=$?`
658 Expected: rc 0.
659
660 - [ ] **Step 5: Commit**
661
662 ```sh
663 make check && git add src/gui/atlas.zig src/gui/native.zig
664 git commit -m "feat: the native client's shelf-packed glyph atlas"
665 ```
666
667 ---
668
669 ### Task 4: `quads.zig` — cells to instanced quads
670
671 **Files:**
672 - Create: `src/gui/quads.zig`
673 - Modify: `src/gui/native.zig` (re-export + test reach)
674
675 **Interfaces:**
676 - Consumes: `term.grid.Row`, `term.grid.Cell`, `term.protocol.CellStyle`, `term.protocol.Wide`, `term.protocol.color_none/colorPalette/colorRgb`; `atlas.Entry`.
677 - Produces:
678 - `pub const Instance = extern struct { x: f32, y: f32, w: f32, h: f32, u0: f32, v0: f32, u1: f32, v1: f32, rgba: u32, kind: u32 }` — `kind` 0 solid, 1 glyph.
679 - `pub const Glyphs = struct { ctx: *anyopaque, get: *const fn (ctx: *anyopaque, cp: u21) ?atlas.Entry }`
680 - `pub const Ctx = struct { cell_w: u16, cell_h: u16, ascent: u16, x0: f32 = 0, y0: f32 = 0, atlas_w: f32, atlas_h: f32, glyphs: Glyphs, default_fg: u32 = 0xd0d0d0ff, default_bg: u32 = 0x101010ff }`
681 - `pub fn rowInstances(out: *std.ArrayListUnmanaged(Instance), alloc, row: *const Row, cols: u16, col_off: u16, y: u16, ctx: Ctx) !void`
682 - `pub fn cursorInstance(x: u16, y: u16, ctx: Ctx) Instance`
683 - `pub fn rgbaOf(color: u32, default: u32) u32` — the wire colour (none / palette / rgb) to packed RGBA.
684
685 - [ ] **Step 1: Write the failing tests**
686
687 Create `src/gui/quads.zig` with the imports and tests:
688
689 ```zig
690 const std = @import("std");
691 const term = @import("term");
692 const grid = term.grid;
693 const proto = term.protocol;
694 const atlas = @import("atlas.zig");
695
696 // --- tests ---
697
698 const StubGlyphs = struct {
699 fn get(_: *anyopaque, cp: u21) ?atlas.Entry {
700 // Every codepoint has a 6x10 glyph at (cp % 4) * 8 on shelf 0.
701 return .{ .x = @intCast((cp % 4) * 8), .y = 0, .w = 6, .h = 10, .left = 1, .top = 9 };
702 }
703 };
704
705 var stub_ctx_byte: u8 = 0;
706
707 fn testCtx() Ctx {
708 return .{
709 .cell_w = 8,
710 .cell_h = 16,
711 .ascent = 12,
712 .x0 = 0,
713 .y0 = 0,
714 .atlas_w = 64,
715 .atlas_h = 64,
716 .glyphs = .{ .ctx = @ptrCast(&stub_ctx_byte), .get = StubGlyphs.get },
717 };
718 }
719
720 /// A row built by hand: `spec` is one entry per cell, text "" for a blank.
721 const CellSpec = struct { text: []const u8 = "", style: proto.CellStyle = .{}, wide: proto.Wide = .narrow };
722
723 fn rowOf(alloc: std.mem.Allocator, spec: []const CellSpec) !grid.Row {
724 var r: grid.Row = .{ .cells = try alloc.alloc(grid.Cell, spec.len), .text = .empty };
725 for (spec, 0..) |s, i| {
726 const off: u32 = @intCast(r.text.items.len);
727 try r.text.appendSlice(alloc, s.text);
728 r.cells[i] = .{ .style = s.style, .wide = s.wide, .text_off = off, .text_len = @intCast(s.text.len) };
729 }
730 return r;
731 }
732
733 fn freeRow(alloc: std.mem.Allocator, r: *grid.Row) void {
734 alloc.free(r.cells);
735 r.text.deinit(alloc);
736 }
737
738 test "a blank cell emits nothing, text one glyph quad, a styled blank one background quad" {
739 const alloc = std.testing.allocator;
740 var row = try rowOf(alloc, &.{
741 .{},
742 .{ .text = "A" },
743 .{ .style = .{ .bg = proto.colorRgb(10, 20, 30) } },
744 });
745 defer freeRow(alloc, &row);
746 var out: std.ArrayListUnmanaged(Instance) = .empty;
747 defer out.deinit(alloc);
748 // Column offset 5, row 2: the fixture is off-origin on purpose.
749 try rowInstances(&out, alloc, &row, 3, 5, 2, testCtx());
750 try std.testing.expectEqual(@as(usize, 2), out.items.len);
751 const g = out.items[0];
752 try std.testing.expectEqual(@as(u32, 1), g.kind);
753 // cell (5+1, 2) at 8x16: x = 48 + left 1 = 49; y = 32 + (ascent 12 - top 9) = 35
754 try std.testing.expectEqual(@as(f32, 49), g.x);
755 try std.testing.expectEqual(@as(f32, 35), g.y);
756 try std.testing.expectEqual(@as(f32, 6), g.w);
757 try std.testing.expectEqual(@as(f32, 10), g.h);
758 // 'A' = 65, 65 % 4 = 1 → atlas x 8: u0 = 8/64
759 try std.testing.expectApproxEqAbs(@as(f32, 0.125), g.u0, 1e-6);
760 try std.testing.expectEqual(@as(u32, 0xd0d0d0ff), g.rgba);
761 const b = out.items[1];
762 try std.testing.expectEqual(@as(u32, 0), b.kind);
763 try std.testing.expectEqual(@as(f32, 56), b.x);
764 try std.testing.expectEqual(@as(f32, 32), b.y);
765 try std.testing.expectEqual(@as(f32, 8), b.w);
766 try std.testing.expectEqual(@as(f32, 16), b.h);
767 try std.testing.expectEqual(@as(u32, 0x0a141eff), b.rgba);
768 }
769
770 test "a wide cell emits one two-column glyph quad and its tail emits nothing" {
771 const alloc = std.testing.allocator;
772 var row = try rowOf(alloc, &.{
773 .{ .text = "漢", .wide = .wide },
774 .{ .wide = .spacer_tail },
775 .{ .text = "x" },
776 });
777 defer freeRow(alloc, &row);
778 var out: std.ArrayListUnmanaged(Instance) = .empty;
779 defer out.deinit(alloc);
780 try rowInstances(&out, alloc, &row, 3, 0, 0, testCtx());
781 try std.testing.expectEqual(@as(usize, 2), out.items.len);
782 try std.testing.expectEqual(@as(f32, 1), out.items[0].x);
783 try std.testing.expectEqual(@as(f32, 16 + 1), out.items[1].x);
784 }
785
786 test "background quads come before glyph quads so a glyph is never covered" {
787 const alloc = std.testing.allocator;
788 var row = try rowOf(alloc, &.{
789 .{ .text = "q", .style = .{ .bg = proto.colorPalette(1) } },
790 });
791 defer freeRow(alloc, &row);
792 var out: std.ArrayListUnmanaged(Instance) = .empty;
793 defer out.deinit(alloc);
794 try rowInstances(&out, alloc, &row, 1, 0, 0, testCtx());
795 try std.testing.expectEqual(@as(usize, 2), out.items.len);
796 try std.testing.expectEqual(@as(u32, 0), out.items[0].kind);
797 try std.testing.expectEqual(@as(u32, 1), out.items[1].kind);
798 try std.testing.expectEqual(@as(u32, 0xcc0000ff), out.items[0].rgba); // palette 1 = red
799 }
800
801 test "inverse swaps fg and bg" {
802 const alloc = std.testing.allocator;
803 var row = try rowOf(alloc, &.{
804 .{ .text = "i", .style = .{ .flags = 1 << 4 } },
805 });
806 defer freeRow(alloc, &row);
807 var out: std.ArrayListUnmanaged(Instance) = .empty;
808 defer out.deinit(alloc);
809 try rowInstances(&out, alloc, &row, 1, 0, 0, testCtx());
810 try std.testing.expectEqual(@as(usize, 2), out.items.len);
811 try std.testing.expectEqual(@as(u32, 0xd0d0d0ff), out.items[0].rgba); // bg painted in default fg
812 try std.testing.expectEqual(@as(u32, 0x101010ff), out.items[1].rgba); // glyph in default bg
813 }
814
815 test "the cursor quad covers its cell" {
816 const cur = cursorInstance(3, 1, testCtx());
817 try std.testing.expectEqual(@as(f32, 24), cur.x);
818 try std.testing.expectEqual(@as(f32, 16), cur.y);
819 try std.testing.expectEqual(@as(f32, 8), cur.w);
820 try std.testing.expectEqual(@as(f32, 16), cur.h);
821 try std.testing.expectEqual(@as(u32, 0), cur.kind);
822 }
823
824 test "wire colours: none is the default, palette indexes the table, rgb passes through" {
825 try std.testing.expectEqual(@as(u32, 0xabcdefff), rgbaOf(proto.color_none, 0xabcdefff));
826 try std.testing.expectEqual(@as(u32, 0x000000ff), rgbaOf(proto.colorPalette(0), 0xabcdefff));
827 try std.testing.expectEqual(@as(u32, 0x0000eeff), rgbaOf(proto.colorPalette(4), 0));
828 try std.testing.expectEqual(@as(u32, 0x5f0000ff), rgbaOf(proto.colorPalette(52), 0)); // cube (1,0,0)
829 try std.testing.expectEqual(@as(u32, 0x080808ff), rgbaOf(proto.colorPalette(232), 0)); // first grey
830 try std.testing.expectEqual(@as(u32, 0x112233ff), rgbaOf(proto.colorRgb(0x11, 0x22, 0x33), 0));
831 }
832 ```
833
834 - [ ] **Step 2: Run to verify they fail**
835
836 Add `pub const quads = @import("quads.zig");` and `_ = quads;` to `native.zig`. Run: `deps/zig/zig build native-test 2>&1 | tail -5`
837 Expected: compile error, `Instance`/`rowInstances` not found.
838
839 - [ ] **Step 3: Implement**
840
841 Insert between the imports and `// --- tests ---` in `src/gui/quads.zig`:
842
843 ```zig
844 //! A grid row becomes instanced quads: zero, one or two per cell — a
845 //! background quad when the cell's background is not the default, a glyph
846 //! quad when the cell has text. Backgrounds come first in the output so a
847 //! glyph is never covered by its own cell's background. A wide cell's
848 //! glyph spans two columns and its trailing half emits nothing. The port
849 //! of waystty's cell_instance.zig, rewritten over term.grid; the atlas
850 //! lookup is a callback so this file tests with no font and no GL.
851
852 pub const Instance = extern struct {
853 x: f32,
854 y: f32,
855 w: f32,
856 h: f32,
857 u0: f32,
858 v0: f32,
859 u1: f32,
860 v1: f32,
861 rgba: u32,
862 kind: u32,
863
864 pub const solid: u32 = 0;
865 pub const glyph: u32 = 1;
866 };
867
868 pub const Glyphs = struct {
869 ctx: *anyopaque,
870 get: *const fn (ctx: *anyopaque, cp: u21) ?atlas.Entry,
871 };
872
873 pub const Ctx = struct {
874 cell_w: u16,
875 cell_h: u16,
876 /// Baseline from the cell's top, in pixels: a glyph's `top` bearing is
877 /// measured from it.
878 ascent: u16,
879 x0: f32 = 0,
880 y0: f32 = 0,
881 atlas_w: f32,
882 atlas_h: f32,
883 glyphs: Glyphs,
884 default_fg: u32 = 0xd0d0d0ff,
885 default_bg: u32 = 0x101010ff,
886 };
887
888 /// ghostty Style.Flags bit 4 (see proto.CellStyle).
889 const flag_inverse: u16 = 1 << 4;
890 /// ghostty Style.Flags bit 5.
891 const flag_invisible: u16 = 1 << 5;
892
893 const ansi16 = [16]u32{
894 0x000000ff, 0xcc0000ff, 0x4e9a06ff, 0xc4a000ff, 0x0000eeff, 0x75507bff, 0x06989aff, 0xd3d7cfff,
895 0x555753ff, 0xef2929ff, 0x8ae234ff, 0xfce94fff, 0x729fcfff, 0xad7fa8ff, 0x34e2e2ff, 0xeeeeecff,
896 };
897
898 fn packRgb(r: u8, g: u8, b: u8) u32 {
899 return (@as(u32, r) << 24) | (@as(u32, g) << 16) | (@as(u32, b) << 8) | 0xff;
900 }
901
902 fn paletteRgba(i: u8) u32 {
903 if (i < 16) return ansi16[i];
904 if (i < 232) {
905 const n = i - 16;
906 const steps = [6]u8{ 0, 0x5f, 0x87, 0xaf, 0xd7, 0xff };
907 return packRgb(steps[n / 36], steps[(n / 6) % 6], steps[n % 6]);
908 }
909 const g: u8 = 8 + 10 * (i - 232);
910 return packRgb(g, g, g);
911 }
912
913 /// A wire colour (proto.color_none, colorPalette, colorRgb) to packed RGBA.
914 pub fn rgbaOf(color: u32, default: u32) u32 {
915 return switch (color >> 24) {
916 1 => paletteRgba(@intCast(color & 0xff)),
917 2 => (color << 8) | 0xff,
918 else => default,
919 };
920 }
921
922 fn firstCodepoint(text: []const u8) ?u21 {
923 if (text.len == 0) return null;
924 const n = std.unicode.utf8ByteSequenceLength(text[0]) catch return null;
925 if (text.len < n) return null;
926 return std.unicode.utf8Decode(text[0..n]) catch null;
927 }
928
929 pub fn rowInstances(
930 out: *std.ArrayListUnmanaged(Instance),
931 alloc: std.mem.Allocator,
932 row: *const grid.Row,
933 cols: u16,
934 col_off: u16,
935 y: u16,
936 ctx: Ctx,
937 ) !void {
938 const n: usize = @min(cols, row.cells.len);
939 const cell_h: f32 = @floatFromInt(ctx.cell_h);
940 const cell_w: f32 = @floatFromInt(ctx.cell_w);
941 const top = ctx.y0 + @as(f32, @floatFromInt(y)) * cell_h;
942
943 // Backgrounds first, then glyphs: two passes over the row, so the
944 // ordering rule holds without a sort.
945 var x: usize = 0;
946 while (x < n) : (x += 1) {
947 const c = row.cells[x];
948 if (c.wide == .spacer_tail) continue;
949 const inverse = c.style.flags & flag_inverse != 0;
950 const bg = if (inverse) rgbaOf(c.style.fg, ctx.default_fg) else rgbaOf(c.style.bg, ctx.default_bg);
951 if (!inverse and c.style.bg == proto.color_none) continue;
952 const span: f32 = if (c.wide == .wide) 2 else 1;
953 try out.append(alloc, .{
954 .x = ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cell_w,
955 .y = top,
956 .w = cell_w * span,
957 .h = cell_h,
958 .u0 = 0,
959 .v0 = 0,
960 .u1 = 0,
961 .v1 = 0,
962 .rgba = bg,
963 .kind = Instance.solid,
964 });
965 }
966 x = 0;
967 while (x < n) : (x += 1) {
968 const c = row.cells[x];
969 if (c.wide == .spacer_tail or c.text_len == 0) continue;
970 if (c.style.flags & flag_invisible != 0) continue;
971 const cp = firstCodepoint(row.textOf(c)) orelse continue;
972 const e = ctx.glyphs.get(ctx.glyphs.ctx, cp) orelse continue;
973 const inverse = c.style.flags & flag_inverse != 0;
974 const fg = if (inverse) rgbaOf(c.style.bg, ctx.default_bg) else rgbaOf(c.style.fg, ctx.default_fg);
975 const cell_x = ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cell_w;
976 try out.append(alloc, .{
977 .x = cell_x + @as(f32, @floatFromInt(e.left)),
978 .y = top + @as(f32, @floatFromInt(@as(i32, ctx.ascent) - e.top)),
979 .w = @floatFromInt(e.w),
980 .h = @floatFromInt(e.h),
981 .u0 = @as(f32, @floatFromInt(e.x)) / ctx.atlas_w,
982 .v0 = @as(f32, @floatFromInt(e.y)) / ctx.atlas_h,
983 .u1 = @as(f32, @floatFromInt(e.x + e.w)) / ctx.atlas_w,
984 .v1 = @as(f32, @floatFromInt(e.y + e.h)) / ctx.atlas_h,
985 .rgba = fg,
986 .kind = Instance.glyph,
987 });
988 }
989 }
990
991 pub fn cursorInstance(x: u16, y: u16, ctx: Ctx) Instance {
992 return .{
993 .x = ctx.x0 + @as(f32, @floatFromInt(x)) * @as(f32, @floatFromInt(ctx.cell_w)),
994 .y = ctx.y0 + @as(f32, @floatFromInt(y)) * @as(f32, @floatFromInt(ctx.cell_h)),
995 .w = @floatFromInt(ctx.cell_w),
996 .h = @floatFromInt(ctx.cell_h),
997 .u0 = 0,
998 .v0 = 0,
999 .u1 = 0,
1000 .v1 = 0,
1001 .rgba = ctx.default_fg,
1002 .kind = Instance.solid,
1003 };
1004 }
1005 ```
1006
1007 - [ ] **Step 4: Run the tests**
1008
1009 Run: `deps/zig/zig build native-test 2>&1 | tail -8; echo rc=$?`
1010 Expected: rc 0. If `grid.Row.text` is not an `ArrayListUnmanaged(u8)` on the landed branch, adapt `rowOf` to the landed type — the production code reads it only through `row.textOf`.
1011
1012 - [ ] **Step 5: Commit**
1013
1014 ```sh
1015 make check && git add src/gui/quads.zig src/gui/native.zig
1016 git commit -m "feat: grid rows become instanced quads for the native client"
1017 ```
1018
1019 ---
1020
1021 ### Task 5: `font.zig` — fontconfig and freetype
1022
1023 **Files:**
1024 - Create: `src/gui/font.zig`
1025 - Modify: `src/gui/native.zig` (re-export + test reach)
1026
1027 **Interfaces:**
1028 - Produces:
1029 - `pub const Glyph = struct { w: u16, h: u16, left: i16, top: i16, pixels: []u8, pub fn deinit(self: *Glyph, alloc) void }`
1030 - `pub const Face = struct { cell_w: u16, cell_h: u16, ascent: u16, ... pub fn open(px: u16) !Face; pub fn deinit(self: *Face) void; pub fn render(self: *Face, alloc, cp: u21) !Glyph; }`
1031 - `open` picks the system `monospace` face through fontconfig and sets the pixel size; `render` rasterises one codepoint to an 8-bit coverage bitmap (a missing codepoint renders the face's glyph 0, the missing-glyph box).
1032
1033 - [ ] **Step 1: Write the failing tests**
1034
1035 Create `src/gui/font.zig` with:
1036
1037 ```zig
1038 const std = @import("std");
1039
1040 const c = @cImport({
1041 @cInclude("fontconfig/fontconfig.h");
1042 @cInclude("ft2build.h");
1043 @cInclude("freetype/freetype.h");
1044 });
1045
1046 // --- tests ---
1047
1048 test "the system monospace face measures a cell and renders a glyph" {
1049 var face = try Face.open(16);
1050 defer face.deinit();
1051 try std.testing.expect(face.cell_w > 0 and face.cell_w < 64);
1052 try std.testing.expect(face.cell_h >= 16 and face.cell_h < 64);
1053 try std.testing.expect(face.ascent > 0 and face.ascent <= face.cell_h);
1054 var g = try face.render(std.testing.allocator, 'A');
1055 defer g.deinit(std.testing.allocator);
1056 try std.testing.expect(g.w > 0 and g.h > 0);
1057 try std.testing.expectEqual(@as(usize, g.w) * g.h, g.pixels.len);
1058 var ink: usize = 0;
1059 for (g.pixels) |p| ink += @intFromBool(p > 0);
1060 try std.testing.expect(ink > 0);
1061 }
1062
1063 test "a space renders empty, a missing codepoint renders the box, not an error" {
1064 var face = try Face.open(16);
1065 defer face.deinit();
1066 var sp = try face.render(std.testing.allocator, ' ');
1067 defer sp.deinit(std.testing.allocator);
1068 var ink: usize = 0;
1069 for (sp.pixels) |p| ink += @intFromBool(p > 0);
1070 try std.testing.expectEqual(@as(usize, 0), ink);
1071 var box = try face.render(std.testing.allocator, 0x10FFFD);
1072 defer box.deinit(std.testing.allocator);
1073 _ = box.w;
1074 }
1075 ```
1076
1077 - [ ] **Step 2: Run to verify they fail**
1078
1079 Add `pub const font = @import("font.zig");` and `_ = font;` to `native.zig`. Run: `deps/zig/zig build native-test 2>&1 | tail -5`
1080 Expected: compile error, `Face` not found.
1081
1082 - [ ] **Step 3: Implement**
1083
1084 Insert between the imports and `// --- tests ---`:
1085
1086 ```zig
1087 //! The face and its glyphs: fontconfig chooses the system monospace face,
1088 //! freetype rasterises one codepoint at a time to 8-bit coverage. The cell
1089 //! is the face's advance by its line height, in whole pixels. One face,
1090 //! no fallback chain, no colour emoji (deferred): a codepoint the face
1091 //! lacks renders as its missing-glyph box, which is honest and visible.
1092
1093 pub const Glyph = struct {
1094 w: u16,
1095 h: u16,
1096 left: i16,
1097 top: i16,
1098 pixels: []u8,
1099
1100 pub fn deinit(self: *Glyph, alloc: std.mem.Allocator) void {
1101 alloc.free(self.pixels);
1102 }
1103 };
1104
1105 pub const Face = struct {
1106 lib: c.FT_Library,
1107 face: c.FT_Face,
1108 cell_w: u16,
1109 cell_h: u16,
1110 ascent: u16,
1111
1112 pub const Error = error{ NoFontconfig, NoMonospaceFace, FreetypeInit, FaceLoad, SizeSet, GlyphLoad, OutOfMemory };
1113
1114 /// The path and index fontconfig answers for `monospace`, into `buf`.
1115 fn matchMonospace(buf: *[std.fs.max_path_bytes]u8, index_out: *c_int) Error![]const u8 {
1116 if (c.FcInit() == c.FcFalse) return error.NoFontconfig;
1117 const pat = c.FcNameParse("monospace") orelse return error.NoMonospaceFace;
1118 defer c.FcPatternDestroy(pat);
1119 _ = c.FcConfigSubstitute(null, pat, c.FcMatchPattern);
1120 c.FcDefaultSubstitute(pat);
1121 var result: c.FcResult = undefined;
1122 const found = c.FcFontMatch(null, pat, &result) orelse return error.NoMonospaceFace;
1123 defer c.FcPatternDestroy(found);
1124 var file: [*c]c.FcChar8 = null;
1125 if (c.FcPatternGetString(found, c.FC_FILE, 0, &file) != c.FcResultMatch) return error.NoMonospaceFace;
1126 var index: c_int = 0;
1127 if (c.FcPatternGetInteger(found, c.FC_INDEX, 0, &index) != c.FcResultMatch) index = 0;
1128 index_out.* = index;
1129 const path = std.mem.span(@as([*:0]const u8, @ptrCast(file)));
1130 if (path.len >= buf.len) return error.NoMonospaceFace;
1131 @memcpy(buf[0..path.len], path);
1132 buf[path.len] = 0;
1133 return buf[0..path.len];
1134 }
1135
1136 pub fn open(px: u16) Error!Face {
1137 var lib: c.FT_Library = null;
1138 if (c.FT_Init_FreeType(&lib) != 0) return error.FreetypeInit;
1139 errdefer _ = c.FT_Done_FreeType(lib);
1140 var path_buf: [std.fs.max_path_bytes]u8 = undefined;
1141 var index: c_int = 0;
1142 const path = try matchMonospace(&path_buf, &index);
1143 var face: c.FT_Face = null;
1144 if (c.FT_New_Face(lib, @ptrCast(path.ptr), index, &face) != 0) return error.FaceLoad;
1145 errdefer _ = c.FT_Done_Face(face);
1146 if (c.FT_Set_Pixel_Sizes(face, 0, px) != 0) return error.SizeSet;
1147 const m = face.*.size.*.metrics;
1148 // 26.6 fixed point; round up so no glyph is clipped at the cell edge.
1149 const adv: i64 = @intCast((m.max_advance + 63) >> 6);
1150 const height: i64 = @intCast((m.height + 63) >> 6);
1151 const asc: i64 = @intCast((m.ascender + 63) >> 6);
1152 return .{
1153 .lib = lib,
1154 .face = face,
1155 .cell_w = @intCast(@max(adv, 1)),
1156 .cell_h = @intCast(@max(height, 1)),
1157 .ascent = @intCast(std.math.clamp(asc, 1, @max(height, 1))),
1158 };
1159 }
1160
1161 pub fn deinit(self: *Face) void {
1162 _ = c.FT_Done_Face(self.face);
1163 _ = c.FT_Done_FreeType(self.lib);
1164 }
1165
1166 pub fn render(self: *Face, alloc: std.mem.Allocator, cp: u21) Error!Glyph {
1167 var idx = c.FT_Get_Char_Index(self.face, cp);
1168 if (idx == 0) idx = 0; // glyph 0 IS the missing-glyph box
1169 if (c.FT_Load_Glyph(self.face, idx, c.FT_LOAD_RENDER) != 0) return error.GlyphLoad;
1170 const slot = self.face.*.glyph;
1171 const bm = slot.*.bitmap;
1172 const w: u16 = @intCast(bm.width);
1173 const h: u16 = @intCast(bm.rows);
1174 const pixels = try alloc.alloc(u8, @as(usize, w) * h);
1175 var row: usize = 0;
1176 while (row < h) : (row += 1) {
1177 const src: [*]const u8 = @ptrCast(bm.buffer + @as(usize, @intCast(bm.pitch)) * row);
1178 @memcpy(pixels[row * w .. row * w + w], src[0..w]);
1179 }
1180 return .{
1181 .w = w,
1182 .h = h,
1183 .left = @intCast(slot.*.bitmap_left),
1184 .top = @intCast(slot.*.bitmap_top),
1185 .pixels = pixels,
1186 };
1187 }
1188 };
1189 ```
1190
1191 `bm.pitch` is positive for the top-down bitmaps `FT_LOAD_RENDER` produces; the `@intCast` traps in Debug if a face ever hands back a bottom-up one, which is the right place to learn that.
1192
1193 - [ ] **Step 4: Run the tests**
1194
1195 Run: `deps/zig/zig build native-test 2>&1 | tail -8; echo rc=$?`
1196 Expected: rc 0. If the compile fails on a freetype field name (`face.*.size.*.metrics`), check `deps/zig/zig translate-c` output for `FT_FaceRec` and use the translated field spelling; the metrics fields are `max_advance`, `height`, `ascender` in `FT_Size_Metrics`.
1197
1198 - [ ] **Step 5: Commit**
1199
1200 ```sh
1201 make check && git add src/gui/font.zig src/gui/native.zig
1202 git commit -m "feat: the native client's face and glyph rasteriser"
1203 ```
1204
1205 ---
1206
1207 ### Task 6: `gl.zig` — the GL 3.3 renderer
1208
1209 **Files:**
1210 - Create: `src/gui/gl.zig`
1211 - Modify: `src/gui/native.zig` (re-export + test reach)
1212
1213 **Interfaces:**
1214 - Consumes: `quads.Instance`, `atlas.Atlas`.
1215 - Produces:
1216 - `pub const GetProc = *const fn (name: [*:0]const u8) callconv(.c) ?*anyopaque`
1217 - `pub const Renderer = struct { pub fn init(get: GetProc) !Renderer; pub fn deinit(self: *Renderer) void; pub fn uploadAtlas(self: *Renderer, a: *atlas.Atlas) void; pub fn uploadInstances(self: *Renderer, items: []const quads.Instance) void; pub fn draw(self: *Renderer, count: usize, fb_w: i32, fb_h: i32, clear_rgba: u32) void; }`
1218 - `uploadAtlas` re-uploads the whole texture when `a.dirty` and clears it. `draw` clears to `clear_rgba`, sets the viewport, draws `count` instances.
1219
1220 - [ ] **Step 1: Write the one test that runs without a context**
1221
1222 Create `src/gui/gl.zig` with the imports and, at the bottom:
1223
1224 ```zig
1225 test "the loader names every function it needs and refuses a missing one" {
1226 const Stub = struct {
1227 var missing: []const u8 = "";
1228 fn get(name: [*:0]const u8) callconv(.c) ?*anyopaque {
1229 if (std.mem.eql(u8, std.mem.span(name), missing)) return null;
1230 return @ptrCast(@constCast(&get));
1231 }
1232 };
1233 Stub.missing = "glDrawArraysInstanced";
1234 try std.testing.expectError(error.MissingGlFunction, Fns.load(Stub.get));
1235 Stub.missing = "";
1236 _ = try Fns.load(Stub.get);
1237 }
1238 ```
1239
1240 - [ ] **Step 2: Run to verify it fails**
1241
1242 Add `pub const gl = @import("gl.zig");` and `_ = gl;` to `native.zig`. Run: `deps/zig/zig build native-test 2>&1 | tail -5`
1243 Expected: compile error, `Fns` not found.
1244
1245 - [ ] **Step 3: Implement**
1246
1247 `src/gui/gl.zig` above the test:
1248
1249 ```zig
1250 //! OpenGL 3.3 core, loaded by name: one program, one vertex array, one
1251 //! instance buffer, one R8 atlas texture, one instanced draw. Every GL
1252 //! function is a pointer fetched through the loader the window layer hands
1253 //! in, so this file names no windowing library and links no libGL. The
1254 //! quad is built in the vertex shader from gl_VertexID; the instance
1255 //! carries its rectangle, its atlas rectangle, its colour and its kind.
1256 const std = @import("std");
1257 const quads = @import("quads.zig");
1258 const atlas = @import("atlas.zig");
1259
1260 const c = @cImport({
1261 @cInclude("GL/glcorearb.h");
1262 });
1263
1264 pub const GetProc = *const fn (name: [*:0]const u8) callconv(.c) ?*anyopaque;
1265
1266 /// The function table. Field names are the GL names, so `load` can fetch
1267 /// each by reflection and a missing one is an error by name.
1268 pub const Fns = struct {
1269 glGetString: c.PFNGLGETSTRINGPROC,
1270 glCreateShader: c.PFNGLCREATESHADERPROC,
1271 glShaderSource: c.PFNGLSHADERSOURCEPROC,
1272 glCompileShader: c.PFNGLCOMPILESHADERPROC,
1273 glGetShaderiv: c.PFNGLGETSHADERIVPROC,
1274 glGetShaderInfoLog: c.PFNGLGETSHADERINFOLOGPROC,
1275 glCreateProgram: c.PFNGLCREATEPROGRAMPROC,
1276 glAttachShader: c.PFNGLATTACHSHADERPROC,
1277 glLinkProgram: c.PFNGLLINKPROGRAMPROC,
1278 glGetProgramiv: c.PFNGLGETPROGRAMIVPROC,
1279 glGetProgramInfoLog: c.PFNGLGETPROGRAMINFOLOGPROC,
1280 glUseProgram: c.PFNGLUSEPROGRAMPROC,
1281 glDeleteShader: c.PFNGLDELETESHADERPROC,
1282 glDeleteProgram: c.PFNGLDELETEPROGRAMPROC,
1283 glGetUniformLocation: c.PFNGLGETUNIFORMLOCATIONPROC,
1284 glUniform2f: c.PFNGLUNIFORM2FPROC,
1285 glUniform1i: c.PFNGLUNIFORM1IPROC,
1286 glGenVertexArrays: c.PFNGLGENVERTEXARRAYSPROC,
1287 glBindVertexArray: c.PFNGLBINDVERTEXARRAYPROC,
1288 glDeleteVertexArrays: c.PFNGLDELETEVERTEXARRAYSPROC,
1289 glGenBuffers: c.PFNGLGENBUFFERSPROC,
1290 glBindBuffer: c.PFNGLBINDBUFFERPROC,
1291 glBufferData: c.PFNGLBUFFERDATAPROC,
1292 glDeleteBuffers: c.PFNGLDELETEBUFFERSPROC,
1293 glEnableVertexAttribArray: c.PFNGLENABLEVERTEXATTRIBARRAYPROC,
1294 glVertexAttribPointer: c.PFNGLVERTEXATTRIBPOINTERPROC,
1295 glVertexAttribIPointer: c.PFNGLVERTEXATTRIBIPOINTERPROC,
1296 glVertexAttribDivisor: c.PFNGLVERTEXATTRIBDIVISORPROC,
1297 glGenTextures: c.PFNGLGENTEXTURESPROC,
1298 glBindTexture: c.PFNGLBINDTEXTUREPROC,
1299 glDeleteTextures: c.PFNGLDELETETEXTURESPROC,
1300 glTexImage2D: c.PFNGLTEXIMAGE2DPROC,
1301 glTexParameteri: c.PFNGLTEXPARAMETERIPROC,
1302 glPixelStorei: c.PFNGLPIXELSTOREIPROC,
1303 glActiveTexture: c.PFNGLACTIVETEXTUREPROC,
1304 glViewport: c.PFNGLVIEWPORTPROC,
1305 glClearColor: c.PFNGLCLEARCOLORPROC,
1306 glClear: c.PFNGLCLEARPROC,
1307 glEnable: c.PFNGLENABLEPROC,
1308 glBlendFunc: c.PFNGLBLENDFUNCPROC,
1309 glDrawArraysInstanced: c.PFNGLDRAWARRAYSINSTANCEDPROC,
1310
1311 pub fn load(get: GetProc) error{MissingGlFunction}!Fns {
1312 var f: Fns = undefined;
1313 inline for (std.meta.fields(Fns)) |field| {
1314 const p = get(field.name ++ "") orelse return error.MissingGlFunction;
1315 @field(f, field.name) = @ptrCast(p);
1316 }
1317 return f;
1318 }
1319 };
1320
1321 const vertex_src: [*:0]const u8 =
1322 \\#version 330 core
1323 \\layout(location = 0) in vec4 rect; // x y w h in pixels
1324 \\layout(location = 1) in vec4 uv; // u0 v0 u1 v1
1325 \\layout(location = 2) in uint rgba;
1326 \\layout(location = 3) in uint kind;
1327 \\uniform vec2 viewport;
1328 \\out vec2 v_uv;
1329 \\flat out vec4 v_color;
1330 \\flat out uint v_kind;
1331 \\void main() {
1332 \\ vec2 corner = vec2(gl_VertexID & 1, (gl_VertexID >> 1) & 1);
1333 \\ vec2 px = rect.xy + corner * rect.zw;
1334 \\ vec2 ndc = vec2(px.x / viewport.x * 2.0 - 1.0, 1.0 - px.y / viewport.y * 2.0);
1335 \\ gl_Position = vec4(ndc, 0.0, 1.0);
1336 \\ v_uv = mix(uv.xy, uv.zw, corner);
1337 \\ v_color = vec4(float((rgba >> 24u) & 255u), float((rgba >> 16u) & 255u),
1338 \\ float((rgba >> 8u) & 255u), float(rgba & 255u)) / 255.0;
1339 \\ v_kind = kind;
1340 \\}
1341 ;
1342
1343 const fragment_src: [*:0]const u8 =
1344 \\#version 330 core
1345 \\in vec2 v_uv;
1346 \\flat in vec4 v_color;
1347 \\flat in uint v_kind;
1348 \\uniform sampler2D atlas;
1349 \\out vec4 frag;
1350 \\void main() {
1351 \\ float a = (v_kind == 1u) ? texture(atlas, v_uv).r : 1.0;
1352 \\ frag = vec4(v_color.rgb, v_color.a * a);
1353 \\}
1354 ;
1355
1356 pub const Renderer = struct {
1357 f: Fns,
1358 program: c.GLuint,
1359 vao: c.GLuint,
1360 vbo: c.GLuint,
1361 tex: c.GLuint,
1362 u_viewport: c.GLint,
1363 tex_w: u16 = 0,
1364 tex_h: u16 = 0,
1365
1366 pub const Error = error{ MissingGlFunction, ShaderCompile, ProgramLink };
1367
1368 fn compile(f: *const Fns, kind: c.GLenum, src: [*:0]const u8) Error!c.GLuint {
1369 const sh = f.glCreateShader.?(kind);
1370 f.glShaderSource.?(sh, 1, @ptrCast(&src), null);
1371 f.glCompileShader.?(sh);
1372 var ok: c.GLint = 0;
1373 f.glGetShaderiv.?(sh, c.GL_COMPILE_STATUS, &ok);
1374 if (ok == 0) {
1375 var log: [1024]u8 = undefined;
1376 var n: c.GLsizei = 0;
1377 f.glGetShaderInfoLog.?(sh, log.len, &n, &log);
1378 std.debug.print("muxg: shader: {s}\n", .{log[0..@intCast(n)]});
1379 return error.ShaderCompile;
1380 }
1381 return sh;
1382 }
1383
1384 pub fn init(get: GetProc) Error!Renderer {
1385 const f = try Fns.load(get);
1386 const vs = try compile(&f, c.GL_VERTEX_SHADER, vertex_src);
1387 defer f.glDeleteShader.?(vs);
1388 const fs = try compile(&f, c.GL_FRAGMENT_SHADER, fragment_src);
1389 defer f.glDeleteShader.?(fs);
1390 const prog = f.glCreateProgram.?();
1391 f.glAttachShader.?(prog, vs);
1392 f.glAttachShader.?(prog, fs);
1393 f.glLinkProgram.?(prog);
1394 var ok: c.GLint = 0;
1395 f.glGetProgramiv.?(prog, c.GL_LINK_STATUS, &ok);
1396 if (ok == 0) {
1397 var log: [1024]u8 = undefined;
1398 var n: c.GLsizei = 0;
1399 f.glGetProgramInfoLog.?(prog, log.len, &n, &log);
1400 std.debug.print("muxg: program: {s}\n", .{log[0..@intCast(n)]});
1401 return error.ProgramLink;
1402 }
1403
1404 var vao: c.GLuint = 0;
1405 f.glGenVertexArrays.?(1, &vao);
1406 f.glBindVertexArray.?(vao);
1407 var vbo: c.GLuint = 0;
1408 f.glGenBuffers.?(1, &vbo);
1409 f.glBindBuffer.?(c.GL_ARRAY_BUFFER, vbo);
1410 const stride: c.GLsizei = @sizeOf(quads.Instance);
1411 f.glEnableVertexAttribArray.?(0);
1412 f.glVertexAttribPointer.?(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(quads.Instance, "x")));
1413 f.glVertexAttribDivisor.?(0, 1);
1414 f.glEnableVertexAttribArray.?(1);
1415 f.glVertexAttribPointer.?(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(quads.Instance, "u0")));
1416 f.glVertexAttribDivisor.?(1, 1);
1417 f.glEnableVertexAttribArray.?(2);
1418 f.glVertexAttribIPointer.?(2, 1, c.GL_UNSIGNED_INT, stride, @ptrFromInt(@offsetOf(quads.Instance, "rgba")));
1419 f.glVertexAttribDivisor.?(2, 1);
1420 f.glEnableVertexAttribArray.?(3);
1421 f.glVertexAttribIPointer.?(3, 1, c.GL_UNSIGNED_INT, stride, @ptrFromInt(@offsetOf(quads.Instance, "kind")));
1422 f.glVertexAttribDivisor.?(3, 1);
1423
1424 var tex: c.GLuint = 0;
1425 f.glGenTextures.?(1, &tex);
1426 f.glBindTexture.?(c.GL_TEXTURE_2D, tex);
1427 f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, c.GL_NEAREST);
1428 f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, c.GL_NEAREST);
1429 f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, c.GL_CLAMP_TO_EDGE);
1430 f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, c.GL_CLAMP_TO_EDGE);
1431 f.glPixelStorei.?(c.GL_UNPACK_ALIGNMENT, 1);
1432
1433 f.glUseProgram.?(prog);
1434 f.glUniform1i.?(f.glGetUniformLocation.?(prog, "atlas"), 0);
1435 f.glEnable.?(c.GL_BLEND);
1436 f.glBlendFunc.?(c.GL_SRC_ALPHA, c.GL_ONE_MINUS_SRC_ALPHA);
1437
1438 return .{
1439 .f = f,
1440 .program = prog,
1441 .vao = vao,
1442 .vbo = vbo,
1443 .tex = tex,
1444 .u_viewport = f.glGetUniformLocation.?(prog, "viewport"),
1445 };
1446 }
1447
1448 pub fn deinit(self: *Renderer) void {
1449 self.f.glDeleteTextures.?(1, &self.tex);
1450 self.f.glDeleteBuffers.?(1, &self.vbo);
1451 self.f.glDeleteVertexArrays.?(1, &self.vao);
1452 self.f.glDeleteProgram.?(self.program);
1453 }
1454
1455 /// The whole texture, only when the atlas says it changed. A whole
1456 /// re-upload on a new glyph is deliberate for v1: the timing table's
1457 /// atlas_upload row says whether an incremental path is ever needed.
1458 pub fn uploadAtlas(self: *Renderer, a: *atlas.Atlas) void {
1459 if (!a.dirty) return;
1460 self.f.glActiveTexture.?(c.GL_TEXTURE0);
1461 self.f.glBindTexture.?(c.GL_TEXTURE_2D, self.tex);
1462 self.f.glTexImage2D.?(c.GL_TEXTURE_2D, 0, c.GL_R8, a.width, a.height, 0, c.GL_RED, c.GL_UNSIGNED_BYTE, a.pixels.ptr);
1463 self.tex_w = a.width;
1464 self.tex_h = a.height;
1465 a.dirty = false;
1466 }
1467
1468 pub fn uploadInstances(self: *Renderer, items: []const quads.Instance) void {
1469 self.f.glBindBuffer.?(c.GL_ARRAY_BUFFER, self.vbo);
1470 self.f.glBufferData.?(c.GL_ARRAY_BUFFER, @intCast(items.len * @sizeOf(quads.Instance)), items.ptr, c.GL_STREAM_DRAW);
1471 }
1472
1473 pub fn draw(self: *Renderer, count: usize, fb_w: i32, fb_h: i32, clear_rgba: u32) void {
1474 const r: f32 = @as(f32, @floatFromInt((clear_rgba >> 24) & 0xff)) / 255.0;
1475 const g: f32 = @as(f32, @floatFromInt((clear_rgba >> 16) & 0xff)) / 255.0;
1476 const b: f32 = @as(f32, @floatFromInt((clear_rgba >> 8) & 0xff)) / 255.0;
1477 self.f.glViewport.?(0, 0, fb_w, fb_h);
1478 self.f.glClearColor.?(r, g, b, 1.0);
1479 self.f.glClear.?(c.GL_COLOR_BUFFER_BIT);
1480 self.f.glUseProgram.?(self.program);
1481 self.f.glUniform2f.?(self.u_viewport, @floatFromInt(fb_w), @floatFromInt(fb_h));
1482 self.f.glBindVertexArray.?(self.vao);
1483 self.f.glActiveTexture.?(c.GL_TEXTURE0);
1484 self.f.glBindTexture.?(c.GL_TEXTURE_2D, self.tex);
1485 self.f.glDrawArraysInstanced.?(c.GL_TRIANGLE_STRIP, 0, 4, @intCast(count));
1486 }
1487 };
1488 ```
1489
1490 - [ ] **Step 4: Run the tests**
1491
1492 Run: `deps/zig/zig build native-test 2>&1 | tail -8; echo rc=$?`
1493 Expected: rc 0. If a `PFNGL...PROC` name is not in the installed `glcorearb.h` (check with `grep -c PFNGLVERTEXATTRIBDIVISORPROC /usr/include/GL/glcorearb.h`), that function is `GL_ARB_instanced_arrays`'s spelling — it is in glcorearb.h under GL 3.3 on every Mesa install since 2012; report the exact missing name rather than substituting.
1494
1495 - [ ] **Step 5: Commit**
1496
1497 ```sh
1498 make check && git add src/gui/gl.zig src/gui/native.zig
1499 git commit -m "feat: the native client's GL 3.3 instanced renderer"
1500 ```
1501
1502 ---
1503
1504 ### Task 7: `session_pump.zig` — the transport-owning thread
1505
1506 **Files:**
1507 - Create: `src/client/session_pump.zig`
1508 - Modify: `src/client/client.zig` (add `pub const session_pump = @import("session_pump.zig");` beside `pub const hosts = @import("hosts.zig");` near line 26, and `_ = @import("session_pump.zig");` in the `test { std.testing.refAllDeclsRecursive(@This()); ... }` block near line 2904, beside `_ = @import("hosts.zig");`)
1509
1510 **Interfaces:**
1511 - Consumes: `client.Transport.open(alloc, target, null, -1, null)`, `.adopt`, `.pollFd`, `.timeoutMs`, `.service`, `.readFrame`, `.writeFrame`, `.flushQuic`, `.close`; `client.nextBackoffMs`; `client.core.ClientCore.receive`; `term.replica.Replica`, `term.grid.Grid`; `proto.encodeAttachNamed`, `proto.encodeSize`, `proto.wireName`, `proto.attach_max_len`.
1512 - Produces:
1513 - `pub const Say = union(enum) { input: []const u8, resize: struct { cols: u16, rows: u16 }, detach, quit }` — `input` bytes are COPIED by `say`.
1514 - `pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed }`
1515 - `pub const State = struct { phase: Phase = .dialing, exit_code: u8 = 0, bell: bool = false, reason: [128]u8 = undefined, reason_len: u8 = 0 }` with `pub fn reasonText(self: *const State) []const u8`
1516 - `pub const Options = struct { target: client.Target, session: []const u8, cols: u16, rows: u16, wake: *const fn (ctx: *anyopaque) void, wake_ctx: *anyopaque }`
1517 - `pub const Pump = struct { mu: std.Thread.Mutex, replica: Replica, grid: *Grid, last_apply_us: u32, ... pub fn start(alloc, opts: Options) !*Pump; pub fn say(self: *Pump, s: Say) !void; pub fn state(self: *Pump) State; pub fn stop(self: *Pump) void; }`
1518 - Reading the grid: lock `pump.mu`, read `pump.grid` and `pump.last_apply_us`, unlock. `stop` posts `.quit`, joins the thread and frees the pump; it is the ONLY way to end it.
1519
1520 - [ ] **Step 1: Write the failing tests (mailbox and doorbell only; the thread is graded end to end)**
1521
1522 Create `src/client/session_pump.zig` with the imports and, at the bottom:
1523
1524 ```zig
1525 test "the mailbox keeps order, copies input and rings once per post" {
1526 const alloc = std.testing.allocator;
1527 var mb = try Mailbox.init(alloc);
1528 defer mb.deinit();
1529 var typed = [_]u8{ 'h', 'i' };
1530 try mb.post(.{ .input = &typed });
1531 typed[0] = 'X'; // the mailbox copied, so this cannot reach the frame
1532 try mb.post(.{ .resize = .{ .cols = 100, .rows = 40 } });
1533 try mb.post(.detach);
1534 var ring: [8]u8 = undefined;
1535 const n = std.posix.read(mb.wake_r, &ring) catch 0;
1536 try std.testing.expect(n >= 1);
1537 var items: std.ArrayListUnmanaged(Say) = .empty;
1538 defer {
1539 for (items.items) |it| mb.free(it);
1540 items.deinit(alloc);
1541 }
1542 try mb.drain(&items);
1543 try std.testing.expectEqual(@as(usize, 3), items.items.len);
1544 try std.testing.expectEqualStrings("hi", items.items[0].input);
1545 try std.testing.expectEqual(@as(u16, 100), items.items[1].resize.cols);
1546 try std.testing.expect(items.items[2] == .detach);
1547 }
1548
1549 test "state is read as a copy under the lock" {
1550 var p: Pump = undefined;
1551 p.state_mu = .{};
1552 p.state_val = .{};
1553 p.setPhase(.refused, "no such session");
1554 const s = p.state();
1555 try std.testing.expectEqual(Phase.refused, s.phase);
1556 try std.testing.expectEqualStrings("no such session", s.reasonText());
1557 }
1558 ```
1559
1560 - [ ] **Step 2: Run to verify they fail**
1561
1562 Run: `deps/zig/zig build test 2>&1 | tail -5` — `client` is in `test_order`, so `make test` runs this file's tests.
1563 Expected: compile error, `Mailbox` not found.
1564
1565 - [ ] **Step 3: Implement**
1566
1567 `src/client/session_pump.zig` above the tests:
1568
1569 ```zig
1570 //! One thread that owns a transport from dial to close: it attaches to ONE
1571 //! session, reads frames, applies them to a Replica it owns under a mutex,
1572 //! feeds every non-replay frame to the client core, and rings a wake
1573 //! callback once per pass that changed anything. The window thread never
1574 //! touches the transport: it posts to the mailbox and rings the doorbell.
1575 //!
1576 //! What it does NOT handle is the point: no agent channels, no births, no
1577 //! end arms, no session lists. A viewer asks none of those questions.
1578 //!
1579 //! This is the third terminal-free attach loop, after webhub.pumpTile and
1580 //! mux a's. pumpTile is the first candidate to move onto it; it is not
1581 //! moved here.
1582 const std = @import("std");
1583 const proto = @import("term").protocol;
1584 const term = @import("term");
1585 const client = @import("client.zig");
1586 const core_mod = @import("client_core.zig");
1587
1588 const Replica = term.replica.Replica;
1589 const Grid = term.grid.Grid;
1590
1591 pub const Say = union(enum) {
1592 input: []const u8,
1593 resize: struct { cols: u16, rows: u16 },
1594 detach,
1595 quit,
1596 };
1597
1598 pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed };
1599
1600 pub const State = struct {
1601 phase: Phase = .dialing,
1602 exit_code: u8 = 0,
1603 /// Set by the pump, cleared by whoever shows it.
1604 bell: bool = false,
1605 reason: [128]u8 = undefined,
1606 reason_len: u8 = 0,
1607
1608 pub fn reasonText(self: *const State) []const u8 {
1609 return self.reason[0..self.reason_len];
1610 }
1611 };
1612
1613 pub const Mailbox = struct {
1614 alloc: std.mem.Allocator,
1615 mu: std.Thread.Mutex = .{},
1616 items: std.ArrayListUnmanaged(Say) = .empty,
1617 wake_r: std.posix.fd_t,
1618 wake_w: std.posix.fd_t,
1619
1620 pub fn init(alloc: std.mem.Allocator) !Mailbox {
1621 const p = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true });
1622 return .{ .alloc = alloc, .wake_r = p[0], .wake_w = p[1] };
1623 }
1624
1625 pub fn deinit(self: *Mailbox) void {
1626 for (self.items.items) |it| self.free(it);
1627 self.items.deinit(self.alloc);
1628 std.posix.close(self.wake_r);
1629 std.posix.close(self.wake_w);
1630 }
1631
1632 pub fn free(self: *Mailbox, s: Say) void {
1633 switch (s) {
1634 .input => |b| self.alloc.free(b),
1635 else => {},
1636 }
1637 }
1638
1639 /// Copies `input` bytes; rings the doorbell. A full pipe is fine: one
1640 /// byte in it is one wake, and the pump drains everything per wake.
1641 pub fn post(self: *Mailbox, s: Say) !void {
1642 const owned: Say = switch (s) {
1643 .input => |b| .{ .input = try self.alloc.dupe(u8, b) },
1644 else => s,
1645 };
1646 {
1647 self.mu.lock();
1648 defer self.mu.unlock();
1649 try self.items.append(self.alloc, owned);
1650 }
1651 _ = std.posix.write(self.wake_w, "w") catch {};
1652 }
1653
1654 /// Moves every pending item into `into`; the caller frees them.
1655 pub fn drain(self: *Mailbox, into: *std.ArrayListUnmanaged(Say)) !void {
1656 self.mu.lock();
1657 defer self.mu.unlock();
1658 try into.appendSlice(self.alloc, self.items.items);
1659 self.items.clearRetainingCapacity();
1660 }
1661
1662 fn drainBell(self: *Mailbox) void {
1663 var buf: [64]u8 = undefined;
1664 while (true) {
1665 const n = std.posix.read(self.wake_r, &buf) catch return;
1666 if (n < buf.len) return;
1667 }
1668 }
1669 };
1670
1671 pub const Options = struct {
1672 target: client.Target,
1673 session: []const u8,
1674 cols: u16,
1675 rows: u16,
1676 wake: *const fn (ctx: *anyopaque) void,
1677 wake_ctx: *anyopaque,
1678 };
1679
1680 pub const Pump = struct {
1681 alloc: std.mem.Allocator,
1682 opts: Options,
1683 mailbox: Mailbox,
1684 /// Guards `grid`, `replica` and `last_apply_us`.
1685 mu: std.Thread.Mutex = .{},
1686 grid: *Grid,
1687 replica: Replica,
1688 last_apply_us: u32 = 0,
1689 core: core_mod.ClientCore = .{},
1690 state_mu: std.Thread.Mutex = .{},
1691 state_val: State = .{},
1692 /// The size the daemon should hear on the next attach.
1693 cols: u16,
1694 rows: u16,
1695 thread: ?std.Thread = null,
1696 /// Pump-thread only: whether `run`'s transport is open (see `reconnect`).
1697 link_open: bool = false,
1698
1699 pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump {
1700 const p = try alloc.create(Pump);
1701 errdefer alloc.destroy(p);
1702 const g = try Grid.init(alloc, @max(opts.cols, 1), @max(opts.rows, 1));
1703 errdefer g.deinit();
1704 p.* = .{
1705 .alloc = alloc,
1706 .opts = opts,
1707 .mailbox = try Mailbox.init(alloc),
1708 .grid = g,
1709 .replica = Replica.init(alloc, g),
1710 .cols = opts.cols,
1711 .rows = opts.rows,
1712 };
1713 p.thread = try std.Thread.spawn(.{}, run, .{p});
1714 return p;
1715 }
1716
1717 pub fn say(self: *Pump, s: Say) !void {
1718 try self.mailbox.post(s);
1719 }
1720
1721 pub fn state(self: *Pump) State {
1722 self.state_mu.lock();
1723 defer self.state_mu.unlock();
1724 return self.state_val;
1725 }
1726
1727 pub fn stop(self: *Pump) void {
1728 self.mailbox.post(.quit) catch {};
1729 if (self.thread) |t| t.join();
1730 self.mailbox.deinit();
1731 self.grid.deinit();
1732 self.alloc.destroy(self);
1733 }
1734
1735 fn setPhase(self: *Pump, ph: Phase, reason: []const u8) void {
1736 self.state_mu.lock();
1737 defer self.state_mu.unlock();
1738 self.state_val.phase = ph;
1739 const n = @min(reason.len, self.state_val.reason.len);
1740 @memcpy(self.state_val.reason[0..n], reason[0..n]);
1741 self.state_val.reason_len = @intCast(n);
1742 }
1743
1744 fn setExit(self: *Pump, code: u8) void {
1745 self.state_mu.lock();
1746 defer self.state_mu.unlock();
1747 self.state_val.phase = .exited;
1748 self.state_val.exit_code = code;
1749 }
1750
1751 fn ringBell(self: *Pump) void {
1752 self.state_mu.lock();
1753 defer self.state_mu.unlock();
1754 self.state_val.bell = true;
1755 }
1756
1757 fn wake(self: *Pump) void {
1758 self.opts.wake(self.opts.wake_ctx);
1759 }
1760
1761 fn dial(self: *Pump) ?client.Transport {
1762 var backoff: u64 = 0;
1763 while (true) {
1764 if (client.Transport.open(self.alloc, self.opts.target, null, -1, null)) |tr| {
1765 return tr;
1766 } else |err| {
1767 var buf: [client.open_err_len]u8 = undefined;
1768 const f = client.openFailure(&buf, self.opts.target, err, "");
1769 self.setPhase(.reconnecting, f.message);
1770 self.wake();
1771 }
1772 if (self.quitRequested()) return null;
1773 backoff = client.nextBackoffMs(backoff);
1774 std.Thread.sleep(backoff * std.time.ns_per_ms);
1775 if (self.quitRequested()) return null;
1776 }
1777 }
1778
1779 /// True when a quit is waiting; leaves everything else in the mailbox.
1780 fn quitRequested(self: *Pump) bool {
1781 self.mailbox.mu.lock();
1782 defer self.mailbox.mu.unlock();
1783 for (self.mailbox.items.items) |it| if (it == .quit) return true;
1784 return false;
1785 }
1786
1787 fn sendAttach(self: *Pump, tr: *client.Transport, fresh: bool) !void {
1788 const args = if (fresh) Replica.AttachArgs{ .have_seq = 0, .have_epoch = 0 } else self.replica.attachArgs();
1789 var buf: [proto.attach_max_len]u8 = undefined;
1790 try tr.writeFrame(.attach, proto.encodeAttachNamed(
1791 &buf,
1792 self.cols,
1793 self.rows,
1794 args.have_seq,
1795 args.have_epoch,
1796 proto.wireName(self.opts.session),
1797 ));
1798 tr.flushQuic();
1799 }
1800
1801 /// Every pending mailbox item onto the wire. Returns false on quit or
1802 /// detach: the loop ends either way.
1803 fn deliver(self: *Pump, tr: *client.Transport) bool {
1804 self.mailbox.drainBell();
1805 var items: std.ArrayListUnmanaged(Say) = .empty;
1806 defer {
1807 for (items.items) |it| self.mailbox.free(it);
1808 items.deinit(self.alloc);
1809 }
1810 self.mailbox.drain(&items) catch return false;
1811 for (items.items) |it| {
1812 switch (it) {
1813 .input => |b| tr.writeFrame(.input, b) catch return true, // the read side reports the loss
1814 .resize => |r| {
1815 self.cols = r.cols;
1816 self.rows = r.rows;
1817 tr.writeFrame(.resize, &proto.encodeSize(r.cols, r.rows)) catch return true;
1818 },
1819 .detach => {
1820 tr.writeFrame(.detach, "") catch {};
1821 tr.flushQuic();
1822 return false;
1823 },
1824 .quit => return false,
1825 }
1826 }
1827 tr.flushQuic();
1828 return true;
1829 }
1830
1831 /// One frame. Returns true when something the window paints changed.
1832 fn onFrame(self: *Pump, tr: *client.Transport, frame: proto.Frame) !bool {
1833 switch (frame.type) {
1834 .snapshot, .delta => {
1835 var t = try std.time.Timer.start();
1836 self.mu.lock();
1837 defer self.mu.unlock();
1838 const applied = self.replica.apply(frame.type, frame.payload) catch .resync;
1839 self.last_apply_us = @intCast(@min(t.read() / std.time.ns_per_us, std.math.maxInt(u32)));
1840 switch (applied) {
1841 .painted => {
1842 if (self.state().phase != .attached) self.setPhase(.attached, "");
1843 return true;
1844 },
1845 .resync => {
1846 // The replica is suspect, not the transport: re-attach
1847 // at (0,0), since a quoted seq invites an unfixable
1848 // delta. The same rule as wall_pump's.
1849 self.replica.state_since_attach = false;
1850 try self.sendAttach(tr, true);
1851 return false;
1852 },
1853 }
1854 },
1855 .exit_status => {
1856 // Before any replay frame this is the refusal path; after,
1857 // the session really ended.
1858 if (self.replica.state_since_attach) {
1859 self.setExit(if (frame.payload.len >= 1) frame.payload[0] else 1);
1860 } else {
1861 self.setPhase(.refused, "the daemon refused the attach");
1862 }
1863 return error.SessionOver;
1864 },
1865 .taken_over => {
1866 // Unsent by this daemon (wire-compat): treated as an end, as
1867 // the wall does.
1868 self.setPhase(.taken, "");
1869 return error.SessionOver;
1870 },
1871 else => {
1872 switch (self.core.receive(frame.type, frame.payload)) {
1873 .effect => |e| switch (e) {
1874 .bell => {
1875 self.ringBell();
1876 return true;
1877 },
1878 .clipboard_set => return false, // v1 has no clipboard
1879 },
1880 else => return false,
1881 }
1882 },
1883 }
1884 }
1885
1886 fn run(self: *Pump) void {
1887 var tr = self.dial() orelse return;
1888 self.link_open = true;
1889 // `reconnect` closes and reopens; a redial that never came back
1890 // leaves `link_open` false so this does not close twice.
1891 defer if (self.link_open) tr.close();
1892 tr.adopt(self.alloc);
1893 self.sendAttach(&tr, true) catch {
1894 self.setPhase(.failed, "attach could not be sent");
1895 self.wake();
1896 return;
1897 };
1898
1899 while (true) {
1900 var fds = [2]std.posix.pollfd{
1901 .{ .fd = tr.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
1902 .{ .fd = self.mailbox.wake_r, .events = std.posix.POLL.IN, .revents = 0 },
1903 };
1904 _ = std.posix.poll(&fds, tr.timeoutMs(100)) catch return;
1905 tr.service();
1906
1907 if (fds[1].revents != 0) {
1908 if (!self.deliver(&tr)) return;
1909 }
1910
1911 var changed = false;
1912 if (fds[0].revents != 0 or tr.link == .quic) frames: {
1913 while (true) {
1914 const incoming = tr.readFrame(self.alloc) catch {
1915 if (!self.reconnect(&tr)) return;
1916 break :frames;
1917 };
1918 switch (incoming) {
1919 .incomplete => break :frames,
1920 .closed => {
1921 if (!self.reconnect(&tr)) return;
1922 break :frames;
1923 },
1924 .frame => |frame| {
1925 defer frame.deinit(self.alloc);
1926 const c = self.onFrame(&tr, frame) catch |err| switch (err) {
1927 error.SessionOver => {
1928 self.wake();
1929 return;
1930 },
1931 else => {
1932 if (!self.reconnect(&tr)) return;
1933 break :frames;
1934 },
1935 };
1936 changed = changed or c;
1937 },
1938 }
1939 }
1940 }
1941 if (changed) self.wake();
1942 }
1943 }
1944
1945 /// The link went away: say so, redial with backoff, re-attach with what
1946 /// the replica already holds. False when a quit arrived meanwhile.
1947 fn reconnect(self: *Pump, tr: *client.Transport) bool {
1948 tr.close();
1949 self.link_open = false;
1950 self.setPhase(.reconnecting, "");
1951 self.wake();
1952 tr.* = self.dial() orelse return false;
1953 self.link_open = true;
1954 tr.adopt(self.alloc);
1955 self.sendAttach(tr, false) catch return false;
1956 return true;
1957 }
1958 };
1959 ```
1960
1961 If `client.openFailure` returns a struct whose text field is not named `message`, read `OpenFailure` at `src/client/client.zig` (around line 914) and use its field. If `Transport.close` followed by re-assignment is not how `wall_pump.redial` does it, mirror `redial` (around `src/tui/wall_pump.zig:340-370`).
1962
1963 - [ ] **Step 4: Wire the re-export and run the tests**
1964
1965 In `src/client/client.zig`, next to `pub const hosts = @import("hosts.zig");`, add `pub const session_pump = @import("session_pump.zig");`, and in the `test { std.testing.refAllDeclsRecursive(@This()); ... }` block add `_ = @import("session_pump.zig");` beside `_ = @import("hosts.zig");`.
1966
1967 Run: `make test 2>&1 | tail -5; echo rc=$?` (note: `make test | tail` reports tail's code — use `make test > /tmp/claude-1000/-home-xanderle-code-rad-mux/d23565e2-afbd-42d3-8c39-9d1b211180b5/scratchpad/t.log 2>&1; echo rc=$?; tail -5 the log`).
1968 Expected: rc 0.
1969
1970 - [ ] **Step 5: Commit**
1971
1972 ```sh
1973 make check && git add src/client/session_pump.zig src/client/client.zig
1974 git commit -m "feat: a terminal-free session pump under src/client for the native viewer"
1975 ```
1976
1977 ---
1978
1979 ### Task 8: `frame.zig` and the real `muxg.zig`
1980
1981 **Files:**
1982 - Create: `src/gui/frame.zig`
1983 - Modify: `src/gui/native.zig` (add `pub const frame`, `run`, `_ = frame;`), `src/cli/muxg.zig` (replace the probe)
1984
1985 **Interfaces:**
1986 - Consumes: everything above; `client.session_pump.Pump`, `client.keymap`, `client.hosts.parse`, `client.Target.fromSpec`, `sockpath.defaultOrExplain`, `sockpath.answers`, `cliflags.parseStrict/exitFor/isHelp/isVersion/help`.
1987 - Produces:
1988 - `frame.Options = struct { target: client.Target, session: []const u8, font_px: u16 = 16, width: u32 = 960, height: u32 = 600, test_fifo: ?[]const u8 = null }`
1989 - `frame.run(alloc, opts) !u8`
1990 - `frame.keyEvent(sdl_key: u32, sdl_mod: u16) ?keymap.Event` — the pure key table.
1991 - Test hook: when `test_fifo` is set (`MUXG_TEST_FIFO` in the entry), a thread reads lines from that FIFO and pushes SDL events: `text:<utf8>`, `key:<name>` (enter, tab, escape, backspace, up, down, left, right), `resize:<W>x<H>`, `quit`.
1992 - `SIGUSR1` prints the frame table to stderr; so does exit.
1993
1994 - [ ] **Step 1: Write the failing key-table tests**
1995
1996 Create `src/gui/frame.zig` with the header, imports and, at the bottom:
1997
1998 ```zig
1999 test "named keys map, printable keys do not (they arrive as text)" {
2000 try std.testing.expectEqual(keymap.Key.up, keyEvent(c.SDLK_UP, 0).?.key);
2001 try std.testing.expectEqual(keymap.Key.enter, keyEvent(c.SDLK_RETURN, 0).?.key);
2002 try std.testing.expectEqual(keymap.Key.f5, keyEvent(c.SDLK_F5, 0).?.key);
2003 try std.testing.expect(keyEvent(c.SDLK_A, 0) == null);
2004 try std.testing.expect(keyEvent(c.SDLK_SPACE, 0) == null);
2005 }
2006
2007 test "ctrl and alt with a letter become a char event with mods" {
2008 const ev = keyEvent(c.SDLK_C, c.SDL_KMOD_LCTRL).?;
2009 try std.testing.expectEqual(keymap.Key.char, ev.key);
2010 try std.testing.expectEqual(@as(u21, 'c'), ev.cp);
2011 try std.testing.expect(ev.mods.ctrl and !ev.mods.alt);
2012 const alt = keyEvent(c.SDLK_X, c.SDL_KMOD_RALT).?;
2013 try std.testing.expect(alt.mods.alt);
2014 // Shift alone on a letter is text, not a key event.
2015 try std.testing.expect(keyEvent(c.SDLK_X, c.SDL_KMOD_LSHIFT) == null);
2016 }
2017
2018 test "the window's drawable size floors to whole cells" {
2019 try std.testing.expectEqual(CellsOf{ .cols = 120, .rows = 37 }, cellsOf(963, 601, 8, 16));
2020 try std.testing.expectEqual(CellsOf{ .cols = 1, .rows = 1 }, cellsOf(3, 5, 8, 16));
2021 }
2022
2023 test "a test-hook line parses" {
2024 try std.testing.expectEqualStrings("hi there", parseHook("text:hi there").?.text);
2025 try std.testing.expectEqual(keymap.Key.enter, parseHook("key:enter").?.key);
2026 const r = parseHook("resize:640x480").?;
2027 try std.testing.expectEqual(@as(u32, 640), r.resize.w);
2028 try std.testing.expect(parseHook("quit").? == .quit);
2029 try std.testing.expect(parseHook("bogus") == null);
2030 }
2031 ```
2032
2033 - [ ] **Step 2: Run to verify they fail**
2034
2035 Add to `native.zig`: `pub const frame = @import("frame.zig");`, the `run` function from Task 1's listing, and `_ = frame;`. Run: `deps/zig/zig build native-test 2>&1 | tail -5`
2036 Expected: compile error, `keyEvent` not found.
2037
2038 - [ ] **Step 3: Implement `frame.zig`**
2039
2040 Above the tests:
2041
2042 ```zig
2043 //! The window thread: SDL owns the window and the GL context; this file
2044 //! owns the loop. It waits on SDL's event queue; a wake from the pump or a
2045 //! resize locks the replica, rebuilds every instance from the whole grid,
2046 //! unlocks, uploads, draws and swaps. Keys become keymap events, text
2047 //! becomes input bytes, and both go to the pump's mailbox. The ONE file
2048 //! under src/gui/ that names SDL (folder rule 9).
2049 //!
2050 //! Whole-grid rebuild every frame is deliberate for v1: a large window is
2051 //! on the order of ten thousand cells, and the timing table is what will
2052 //! say whether dirty rows ever matter.
2053 const std = @import("std");
2054 const client = @import("client");
2055 const term = @import("term");
2056 const keymap = client.keymap;
2057 const session_pump = client.session_pump;
2058 const font = @import("font.zig");
2059 const atlas = @import("atlas.zig");
2060 const quads = @import("quads.zig");
2061 const gl = @import("gl.zig");
2062 const bench = @import("bench.zig");
2063
2064 const c = @cImport({
2065 @cInclude("SDL3/SDL.h");
2066 });
2067
2068 pub const Options = struct {
2069 target: client.Target,
2070 session: []const u8,
2071 font_px: u16 = 16,
2072 width: u32 = 960,
2073 height: u32 = 600,
2074 /// The e2e leg's hook: a FIFO of `text:`/`key:`/`resize:`/`quit` lines.
2075 test_fifo: ?[]const u8 = null,
2076 };
2077
2078 pub const CellsOf = struct { cols: u16, rows: u16 };
2079
2080 pub fn cellsOf(px_w: u32, px_h: u32, cell_w: u16, cell_h: u16) CellsOf {
2081 return .{
2082 .cols = @intCast(@max(px_w / cell_w, 1)),
2083 .rows = @intCast(@max(px_h / cell_h, 1)),
2084 };
2085 }
2086
2087 /// SDL keycode + mods → the keymap's event, or null for a key that types
2088 /// (text input carries it) or means nothing to a session.
2089 pub fn keyEvent(key: u32, mod: u16) ?keymap.Event {
2090 const ctrl = mod & c.SDL_KMOD_CTRL != 0;
2091 const alt = mod & c.SDL_KMOD_ALT != 0;
2092 const shift = mod & c.SDL_KMOD_SHIFT != 0;
2093 const mods: keymap.Mods = .{ .ctrl = ctrl, .alt = alt, .shift = shift };
2094 const named: ?keymap.Key = switch (key) {
2095 c.SDLK_RETURN, c.SDLK_KP_ENTER => .enter,
2096 c.SDLK_TAB => .tab,
2097 c.SDLK_BACKSPACE => .backspace,
2098 c.SDLK_ESCAPE => .escape,
2099 c.SDLK_UP => .up,
2100 c.SDLK_DOWN => .down,
2101 c.SDLK_LEFT => .left,
2102 c.SDLK_RIGHT => .right,
2103 c.SDLK_HOME => .home,
2104 c.SDLK_END => .end,
2105 c.SDLK_INSERT => .insert,
2106 c.SDLK_DELETE => .delete,
2107 c.SDLK_PAGEUP => .page_up,
2108 c.SDLK_PAGEDOWN => .page_down,
2109 c.SDLK_F1 => .f1,
2110 c.SDLK_F2 => .f2,
2111 c.SDLK_F3 => .f3,
2112 c.SDLK_F4 => .f4,
2113 c.SDLK_F5 => .f5,
2114 c.SDLK_F6 => .f6,
2115 c.SDLK_F7 => .f7,
2116 c.SDLK_F8 => .f8,
2117 c.SDLK_F9 => .f9,
2118 c.SDLK_F10 => .f10,
2119 c.SDLK_F11 => .f11,
2120 c.SDLK_F12 => .f12,
2121 else => null,
2122 };
2123 if (named) |k| return .{ .key = k, .mods = mods };
2124 // A printable key with Ctrl or Alt held: SDL sends no text for it.
2125 if ((ctrl or alt) and key >= 0x20 and key < 0x7f) {
2126 return .{ .key = .char, .cp = @intCast(key), .mods = mods };
2127 }
2128 return null;
2129 }
2130
2131 pub const Hook = union(enum) {
2132 text: []const u8,
2133 key: keymap.Key,
2134 resize: struct { w: u32, h: u32 },
2135 quit,
2136 };
2137
2138 pub fn parseHook(line: []const u8) ?Hook {
2139 if (std.mem.eql(u8, line, "quit")) return .quit;
2140 if (std.mem.startsWith(u8, line, "text:")) return .{ .text = line["text:".len..] };
2141 if (std.mem.startsWith(u8, line, "key:")) {
2142 const name = line["key:".len..];
2143 inline for (.{ "enter", "tab", "escape", "backspace", "up", "down", "left", "right" }) |n| {
2144 if (std.mem.eql(u8, name, n)) return .{ .key = @field(keymap.Key, n) };
2145 }
2146 return null;
2147 }
2148 if (std.mem.startsWith(u8, line, "resize:")) {
2149 const rest = line["resize:".len..];
2150 const x = std.mem.indexOfScalar(u8, rest, 'x') orelse return null;
2151 const w = std.fmt.parseInt(u32, rest[0..x], 10) catch return null;
2152 const h = std.fmt.parseInt(u32, rest[x + 1 ..], 10) catch return null;
2153 return .{ .resize = .{ .w = w, .h = h } };
2154 }
2155 return null;
2156 }
2157
2158 // --- the loop ---
2159
2160 var usr1_seen = std.atomic.Value(bool).init(false);
2161
2162 fn onUsr1(_: c_int) callconv(.c) void {
2163 usr1_seen.store(true, .release);
2164 }
2165
2166 const Wake = struct {
2167 var event_type: u32 = 0;
2168 fn ring(_: *anyopaque) void {
2169 var ev: c.SDL_Event = undefined;
2170 ev.type = event_type;
2171 _ = c.SDL_PushEvent(&ev);
2172 }
2173 };
2174
2175 /// A hook line becomes the same SDL event a person would have caused, so
2176 /// the leg exercises the real key and text paths. A resize is pushed as a
2177 /// window event of the given size; on a driver with no real window that is
2178 /// the only way one arrives.
2179 const HookThread = struct {
2180 path: []const u8,
2181 window_id: u32,
2182
2183 fn run(self: HookThread) void {
2184 const f = std.fs.cwd().openFile(self.path, .{}) catch return;
2185 defer f.close();
2186 var buf: [4096]u8 = undefined;
2187 var reader = f.reader(&buf);
2188 while (reader.interface.takeDelimiterExclusive('\n')) |line| {
2189 const hook = parseHook(line) orelse continue;
2190 var ev: c.SDL_Event = undefined;
2191 @memset(std.mem.asBytes(&ev), 0);
2192 switch (hook) {
2193 .text => |t| {
2194 // SDL_EVENT_TEXT_INPUT carries a pointer; SDL owns it
2195 // only for events it made, so hand it a stable copy.
2196 const z = std.heap.page_allocator.dupeZ(u8, t) catch continue;
2197 ev.text.type = c.SDL_EVENT_TEXT_INPUT;
2198 ev.text.windowID = self.window_id;
2199 ev.text.text = z.ptr;
2200 },
2201 .key => |k| {
2202 ev.key.type = c.SDL_EVENT_KEY_DOWN;
2203 ev.key.windowID = self.window_id;
2204 ev.key.key = switch (k) {
2205 .enter => c.SDLK_RETURN,
2206 .tab => c.SDLK_TAB,
2207 .escape => c.SDLK_ESCAPE,
2208 .backspace => c.SDLK_BACKSPACE,
2209 .up => c.SDLK_UP,
2210 .down => c.SDLK_DOWN,
2211 .left => c.SDLK_LEFT,
2212 .right => c.SDLK_RIGHT,
2213 else => continue,
2214 };
2215 },
2216 .resize => |r| {
2217 ev.window.type = c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED;
2218 ev.window.windowID = self.window_id;
2219 ev.window.data1 = @intCast(r.w);
2220 ev.window.data2 = @intCast(r.h);
2221 },
2222 .quit => {
2223 ev.type = c.SDL_EVENT_QUIT;
2224 },
2225 }
2226 _ = c.SDL_PushEvent(&ev);
2227 } else |_| {}
2228 }
2229 };
2230
2231 fn sdlFail(what: []const u8) u8 {
2232 std.debug.print("muxg: {s}: {s}\n", .{ what, std.mem.span(c.SDL_GetError()) });
2233 return 2;
2234 }
2235
2236 const GlyphCache = struct {
2237 alloc: std.mem.Allocator,
2238 face: *font.Face,
2239 atlas: *atlas.Atlas,
2240
2241 fn get(ctx: *anyopaque, cp: u21) ?atlas.Entry {
2242 const self: *GlyphCache = @ptrCast(@alignCast(ctx));
2243 if (self.atlas.get(cp)) |e| return e;
2244 var g = self.face.render(self.alloc, cp) catch return null;
2245 defer g.deinit(self.alloc);
2246 return self.atlas.put(self.alloc, cp, g.w, g.h, g.left, g.top, g.pixels) catch null;
2247 }
2248 };
2249
2250 fn setTitle(win: *c.SDL_Window, session: []const u8, suffix: []const u8) void {
2251 var buf: [128]u8 = undefined;
2252 const t = std.fmt.bufPrintZ(&buf, "muxg {s}{s}", .{ session, suffix }) catch "muxg";
2253 _ = c.SDL_SetWindowTitle(win, t.ptr);
2254 }
2255
2256 pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
2257 var ring: bench.Ring = .{};
2258 defer {
2259 var buf: [2048]u8 = undefined;
2260 std.debug.print("{s}", .{ring.report(&buf)});
2261 }
2262
2263 var sa: std.posix.Sigaction = .{
2264 .handler = .{ .handler = onUsr1 },
2265 .mask = std.posix.sigemptyset(),
2266 .flags = 0,
2267 };
2268 std.posix.sigaction(std.posix.SIG.USR1, &sa, null);
2269
2270 var face = font.Face.open(opts.font_px) catch |err| {
2271 std.debug.print("muxg: font: {s}\n", .{@errorName(err)});
2272 return 2;
2273 };
2274 defer face.deinit();
2275
2276 if (!c.SDL_Init(c.SDL_INIT_VIDEO)) return sdlFail("SDL_Init");
2277 defer c.SDL_Quit();
2278 _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3);
2279 _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 3);
2280 _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_CORE);
2281 const win = c.SDL_CreateWindow(
2282 "muxg",
2283 @intCast(opts.width),
2284 @intCast(opts.height),
2285 c.SDL_WINDOW_OPENGL | c.SDL_WINDOW_RESIZABLE,
2286 ) orelse return sdlFail("SDL_CreateWindow");
2287 defer c.SDL_DestroyWindow(win);
2288 const ctx = c.SDL_GL_CreateContext(win) orelse return sdlFail("SDL_GL_CreateContext");
2289 defer _ = c.SDL_GL_DestroyContext(ctx);
2290 _ = c.SDL_GL_SetSwapInterval(1);
2291 var renderer = gl.Renderer.init(@ptrCast(&c.SDL_GL_GetProcAddress)) catch |err| {
2292 std.debug.print("muxg: gl: {s}\n", .{@errorName(err)});
2293 return 2;
2294 };
2295 defer renderer.deinit();
2296 _ = c.SDL_StartTextInput(win);
2297
2298 var glyph_atlas = try atlas.Atlas.init(alloc, 1024, 256);
2299 defer glyph_atlas.deinit(alloc);
2300 var cache: GlyphCache = .{ .alloc = alloc, .face = &face, .atlas = &glyph_atlas };
2301
2302 var fb_w: c_int = 0;
2303 var fb_h: c_int = 0;
2304 _ = c.SDL_GetWindowSizeInPixels(win, &fb_w, &fb_h);
2305 var cells = cellsOf(@intCast(fb_w), @intCast(fb_h), face.cell_w, face.cell_h);
2306
2307 Wake.event_type = c.SDL_RegisterEvents(1);
2308 const pump = try session_pump.Pump.start(alloc, .{
2309 .target = opts.target,
2310 .session = opts.session,
2311 .cols = cells.cols,
2312 .rows = cells.rows,
2313 .wake = Wake.ring,
2314 .wake_ctx = @ptrCast(@constCast(&Wake.event_type)),
2315 });
2316 defer pump.stop();
2317 setTitle(win, opts.session, "");
2318
2319 if (opts.test_fifo) |path| {
2320 const th = try std.Thread.spawn(.{}, HookThread.run, .{HookThread{ .path = path, .window_id = c.SDL_GetWindowID(win) }});
2321 th.detach();
2322 }
2323
2324 var instances: std.ArrayListUnmanaged(quads.Instance) = .empty;
2325 defer instances.deinit(alloc);
2326 var seq_buf: [keymap.max_seq_len]u8 = undefined;
2327 var dirty = true;
2328 var last_phase: session_pump.Phase = .dialing;
2329
2330 while (true) {
2331 var ev: c.SDL_Event = undefined;
2332 // 250 ms so a SIGUSR1 with no event traffic still prints.
2333 if (c.SDL_WaitEventTimeout(&ev, 250)) {
2334 switch (ev.type) {
2335 c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => {
2336 pump.say(.detach) catch {};
2337 return 0;
2338 },
2339 c.SDL_EVENT_TEXT_INPUT => {
2340 const text = std.mem.span(ev.text.text);
2341 pump.say(.{ .input = text }) catch {};
2342 },
2343 c.SDL_EVENT_KEY_DOWN => {
2344 if (keyEvent(ev.key.key, ev.key.mod)) |kev| {
2345 const bytes = keymap.encode(kev, &seq_buf);
2346 if (bytes.len > 0) pump.say(.{ .input = bytes }) catch {};
2347 }
2348 },
2349 c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED => {
2350 fb_w = ev.window.data1;
2351 fb_h = ev.window.data2;
2352 const now = cellsOf(@intCast(@max(fb_w, 1)), @intCast(@max(fb_h, 1)), face.cell_w, face.cell_h);
2353 if (now.cols != cells.cols or now.rows != cells.rows) {
2354 cells = now;
2355 pump.say(.{ .resize = .{ .cols = cells.cols, .rows = cells.rows } }) catch {};
2356 }
2357 dirty = true;
2358 },
2359 c.SDL_EVENT_WINDOW_EXPOSED => dirty = true,
2360 else => if (ev.type == Wake.event_type) {
2361 dirty = true;
2362 },
2363 }
2364 // Coalesce: everything else already queued is handled before a paint.
2365 while (c.SDL_PollEvent(&ev)) {
2366 switch (ev.type) {
2367 c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => {
2368 pump.say(.detach) catch {};
2369 return 0;
2370 },
2371 c.SDL_EVENT_TEXT_INPUT => pump.say(.{ .input = std.mem.span(ev.text.text) }) catch {},
2372 c.SDL_EVENT_KEY_DOWN => if (keyEvent(ev.key.key, ev.key.mod)) |kev| {
2373 const bytes = keymap.encode(kev, &seq_buf);
2374 if (bytes.len > 0) pump.say(.{ .input = bytes }) catch {};
2375 },
2376 c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED => {
2377 fb_w = ev.window.data1;
2378 fb_h = ev.window.data2;
2379 const now = cellsOf(@intCast(@max(fb_w, 1)), @intCast(@max(fb_h, 1)), face.cell_w, face.cell_h);
2380 if (now.cols != cells.cols or now.rows != cells.rows) {
2381 cells = now;
2382 pump.say(.{ .resize = .{ .cols = cells.cols, .rows = cells.rows } }) catch {};
2383 }
2384 dirty = true;
2385 },
2386 else => dirty = true,
2387 }
2388 }
2389 }
2390
2391 if (usr1_seen.swap(false, .acq_rel)) {
2392 var buf: [2048]u8 = undefined;
2393 std.debug.print("{s}", .{ring.report(&buf)});
2394 }
2395
2396 const st = pump.state();
2397 if (st.phase != last_phase) {
2398 last_phase = st.phase;
2399 switch (st.phase) {
2400 .attached => setTitle(win, opts.session, ""),
2401 .reconnecting => setTitle(win, opts.session, " [reconnecting]"),
2402 .exited => return st.exit_code,
2403 .refused => {
2404 std.debug.print("muxg: {s}\n", .{st.reasonText()});
2405 return 1;
2406 },
2407 .taken => {
2408 std.debug.print("muxg: the session was taken by another client\n", .{});
2409 return 0;
2410 },
2411 .failed => {
2412 std.debug.print("muxg: {s}\n", .{st.reasonText()});
2413 return 1;
2414 },
2415 .dialing => {},
2416 }
2417 }
2418 if (st.bell) setTitle(win, opts.session, " [bell]");
2419
2420 if (!dirty) continue;
2421 dirty = false;
2422
2423 var timer = try std.time.Timer.start();
2424 var frame_t: bench.Frame = .{};
2425 instances.clearRetainingCapacity();
2426 {
2427 pump.mu.lock();
2428 defer pump.mu.unlock();
2429 frame_t.apply_us = pump.last_apply_us;
2430 const g = pump.grid;
2431 const qctx: quads.Ctx = .{
2432 .cell_w = face.cell_w,
2433 .cell_h = face.cell_h,
2434 .ascent = face.ascent,
2435 .atlas_w = @floatFromInt(glyph_atlas.width),
2436 .atlas_h = @floatFromInt(glyph_atlas.height),
2437 .glyphs = .{ .ctx = @ptrCast(&cache), .get = GlyphCache.get },
2438 };
2439 var y: u16 = 0;
2440 while (y < g.rows) : (y += 1) {
2441 try quads.rowInstances(&instances, alloc, g.row(y), g.cols, 0, y, qctx);
2442 }
2443 try instances.append(alloc, quads.cursorInstance(g.cursor.x, g.cursor.y, qctx));
2444 }
2445 frame_t.rebuild_us = bench.usSince(&timer);
2446 // The atlas may have grown during the rebuild: the uv values above
2447 // were computed against the size at rebuild time, so re-run the
2448 // rebuild once when it did. Rare (first sight of a glyph) and cheap.
2449 if (glyph_atlas.dirty) {
2450 renderer.uploadAtlas(&glyph_atlas);
2451 frame_t.atlas_us = bench.usSince(&timer);
2452 }
2453 renderer.uploadInstances(instances.items);
2454 frame_t.upload_us = bench.usSince(&timer);
2455 renderer.draw(instances.items.len, fb_w, fb_h, 0x101010ff);
2456 _ = c.SDL_GL_SwapWindow(win);
2457 frame_t.draw_us = bench.usSince(&timer);
2458 ring.record(frame_t);
2459 }
2460 }
2461 ```
2462
2463 The atlas-growth note: because `Ctx.atlas_w/h` are read once per rebuild and a `put` inside `GlyphCache.get` can grow the atlas mid-rebuild, a frame where growth happened paints a few glyphs with stale uv. To keep v1 simple and correct: after `uploadAtlas`, if the atlas size changed from what `qctx` used, set `dirty = true` so the NEXT loop pass repaints with the right uv. Implement that by comparing `renderer.tex_h` before and after the upload and setting `dirty = true` when it changed.
2464
2465 - [ ] **Step 4: Replace the probe with the real entry**
2466
2467 `src/cli/muxg.zig`:
2468
2469 ```zig
2470 //! `muxg`: the native client's entry. Parses ONE target the way `mux`
2471 //! does (HOST, --sock PATH, --via CMD, quic://HOST[:PORT]) plus --session
2472 //! and --font-px, resolves it to a client.Target, and hands it to the
2473 //! painter. A session viewer: no wall, no hosts file, no layout.
2474 //!
2475 //! No daemon is started here. The self-exec rule (CLAUDE.md) says an
2476 //! auto-start may only run the image already running, and this image is
2477 //! not the daemon's. A silent socket is a refusal with the command to run.
2478 const std = @import("std");
2479 const native = @import("native");
2480 const client = @import("client");
2481 const term = @import("term");
2482 const cliflags = @import("cliflags");
2483 const sockpath = @import("sockpath");
2484
2485 const proto = term.protocol;
2486 const hosts = client.hosts;
2487
2488 const usage =
2489 \\usage: muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N]
2490 \\
2491 \\ TARGET HOST (ssh handoff) or quic://HOST[:PORT]; none means the local daemon
2492 \\ --session the session name (default: the daemon's default session)
2493 \\ --sock a local daemon's socket path
2494 \\ --via a command whose stdio is the daemon
2495 \\ --key the QUIC key file (or MUX_KEY_FILE)
2496 \\ --font-px the face's pixel size (default 16)
2497 \\ --help --version
2498 \\
2499 ;
2500
2501 const Arguments = struct {
2502 sock: ?[]const u8 = null,
2503 via: ?[]const u8 = null,
2504 key: ?[]const u8 = null,
2505 session: ?proto.SessionName = null,
2506 font_px: u16 = 16,
2507 _target: ?[]const u8 = null,
2508 _targets: u8 = 0,
2509
2510 pub fn positional(self: *Arguments, word: []const u8) bool {
2511 self._target = word;
2512 self._targets += 1;
2513 return true;
2514 }
2515 };
2516
2517 comptime {
2518 cliflags.assertDocumented(Arguments, usage, &.{});
2519 }
2520
2521 pub fn main() !u8 {
2522 var gpa: std.heap.DebugAllocator(.{}) = .init;
2523 defer if (gpa.deinit() == .leak) std.debug.print("muxg: LEAK: allocations outlived deinit\n", .{});
2524 const alloc = gpa.allocator();
2525 const args = try std.process.argsAlloc(alloc);
2526 defer std.process.argsFree(alloc, args);
2527
2528 var o: Arguments = .{};
2529 cliflags.parseStrict(Arguments, &o, args[1..]) catch |e| return cliflags.exitFor(e, usage, "muxg", "0.0.1");
2530 const named: u8 = @as(u8, @intFromBool(o.sock != null)) + @intFromBool(o.via != null) + o._targets;
2531 if (named > 1) {
2532 std.debug.print("muxg: name one transport: HOST, --sock, --via or quic://\n{s}", .{usage});
2533 return 2;
2534 }
2535 const session = if (o.session) |n| n.name else "";
2536 const key = std.posix.getenv("MUX_KEY_FILE");
2537
2538 var target: client.Target = undefined;
2539 if (o.via) |cmd| {
2540 target = .{ .via = cmd };
2541 } else if (o._target) |word| {
2542 const spec = hosts.parse(word) catch |err| {
2543 std.debug.print("muxg: bad target {s}: {s}\n", .{ word, @errorName(err) });
2544 return 2;
2545 };
2546 target = client.Target.fromSpec(alloc, spec, o.key orelse key, client.quic_idle_ms_default, true) catch |err| switch (err) {
2547 error.MissingKey => {
2548 std.debug.print("muxg: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen`\n", .{});
2549 return 2;
2550 },
2551 else => |e| return e,
2552 };
2553 if (target == .hand) target.hand.narrate = true;
2554 } else {
2555 const path = if (o.sock) |s| try alloc.dupe(u8, s) else (try sockpath.defaultOrExplain(alloc, "muxg") orelse return 1);
2556 defer alloc.free(path);
2557 if (!sockpath.answers(path)) {
2558 std.debug.print("muxg: no daemon at {s} (run: mux d start -d --sock {s})\n", .{ path, path });
2559 return 2;
2560 }
2561 return native.run(alloc, .{
2562 .target = .{ .sock = path },
2563 .session = session,
2564 .font_px = o.font_px,
2565 .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"),
2566 });
2567 }
2568 return native.run(alloc, .{
2569 .target = target,
2570 .session = session,
2571 .font_px = o.font_px,
2572 .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"),
2573 });
2574 }
2575 ```
2576
2577 If `cliflags.parseStrict` cannot parse a `u16` field, check how `ClientArguments.quic_idle_ms` uses `client.IdleMs` (a struct with a `ms` field and its own parse) and give `font_px` the same shape. If `Target.fromSpec` frees nothing on the `.sock` arm and leaks the dupe, mirror `mux_main`'s `defer` for that arm.
2578
2579 - [ ] **Step 5: Build, run the unit tests, and try it by hand**
2580
2581 Run:
2582 ```sh
2583 deps/zig/zig build native-test 2>&1 | tail -5; echo rc=$?
2584 make native; echo rc=$?
2585 S=/tmp/claude-1000/-home-xanderle-code-rad-mux/d23565e2-afbd-42d3-8c39-9d1b211180b5/scratchpad
2586 export XDG_STATE_HOME=$S/state XDG_RUNTIME_DIR=$S/run; mkdir -p $S/state $S/run
2587 ./zig-out/bin/mux d start -d --sock $S/run/g.sock; echo daemon rc=$?
2588 ./zig-out/bin/muxg --sock $S/run/g.sock; echo muxg rc=$?
2589 ./zig-out/bin/mux d stop --sock $S/run/g.sock
2590 ```
2591 Expected: tests rc 0; a window with a shell prompt appears; typing `echo hi` Enter shows `hi`; resizing the window reflows the shell; closing the window prints the frame table on stderr and rc 0. Then `exit` in the shell on a second run ends the window with rc 0 and the shell's code.
2592
2593 - [ ] **Step 6: Commit**
2594
2595 ```sh
2596 make check && git add src/gui/frame.zig src/gui/native.zig src/cli/muxg.zig
2597 git commit -m "feat: muxg, a window on one daemon session painted from the grid"
2598 ```
2599
2600 ---
2601
2602 ### Task 9: `test/native.sh` — the end-to-end leg
2603
2604 **Files:**
2605 - Create: `test/native.sh` (mode 755)
2606
2607 **Interfaces:**
2608 - Consumes: `test/e2e_lib.sh` (`start_daemon SOCK LOG LABEL`, `defer_kill`, `defer_rm`, `wait_grid SOCK NEEDLE LABEL`, `wait_pid_gone`, `ok`), `$MUX a status --sock`, `$MUX a run --sock --timeout`, the `MUXG_TEST_FIFO` hook, `SIGUSR1`.
2609
2610 - [ ] **Step 1: Write the leg**
2611
2612 `test/native.sh`:
2613
2614 ```sh
2615 #!/bin/sh
2616 # The native client's end-to-end leg: a real daemon, a real muxg on SDL's
2617 # offscreen driver, keys through the real event path, a bounded flood,
2618 # and the frame table read while the flood ran. Opt-in (`make native-e2e`);
2619 # not part of ci, because it needs SDL3 and a GL-capable offscreen driver.
2620 #
2621 # The one pin that matters is the waystty failure: frames were painted
2622 # WHILE output flooded the session. A painter that is fast per frame and
2623 # never asked to paint reports a fine p99 and zero frames; this leg reads
2624 # the frame count twice during the flood and asserts it grew.
2625 set -u
2626 MUX="$1"
2627 MUXG="$2"
2628 [ -x "$MUX" ] && [ -x "$MUXG" ] || { echo "native FAIL: need mux and muxg (run: make native)"; exit 1; }
2629 E2E_DIR=$(dirname "$0")
2630 . "$E2E_DIR/e2e_lib.sh"
2631
2632 # A Debug muxg measures a Debug replica; the numbers would mean nothing.
2633 # The budget below is stated for ReleaseSafe/ReleaseFast and the leg
2634 # refuses to grade anything else. `zig build native -Doptimize=ReleaseSafe`.
2635 case "${MUXG_OPT:-}" in
2636 ReleaseSafe|ReleaseFast) ;;
2637 *) echo "native FAIL: set MUXG_OPT=ReleaseSafe|ReleaseFast to match the build; a Debug number is not a number"; exit 1 ;;
2638 esac
2639
2640 SOCK="${TMPDIR:-/tmp}/muxd-native-$$.sock"
2641 LOG="${TMPDIR:-/tmp}/muxd-native-$$.log"
2642 defer_rm "$LOG"
2643 start_daemon "$SOCK" "$LOG" "native daemon"
2644
2645 FIFO="${TMPDIR:-/tmp}/muxg-hook-$$"
2646 defer_rm "$FIFO"
2647 mkfifo "$FIFO"
2648 GLOG="${TMPDIR:-/tmp}/muxg-native-$$.log"
2649 defer_rm "$GLOG"
2650
2651 SDL_VIDEODRIVER="${MUXG_VIDEODRIVER:-offscreen}" MUXG_TEST_FIFO="$FIFO" \
2652 "$MUXG" --sock "$SOCK" 2>"$GLOG" &
2653 GPID=$!
2654 defer_kill "$GPID"
2655 # Hold the FIFO's write end open for the whole leg; each hook line is one echo.
2656 exec 8>"$FIFO"
2657
2658 # 1. The window attached: the daemon counts a client.
2659 # wait_until SECS LABEL PREDICATE — the predicate is eval'd per tick.
2660 wait_until 10 "muxg attached" '[ "$(attaches_now "$SOCK")" -ge 1 ]'
2661 ok "muxg attached to a real daemon on the offscreen driver"
2662
2663 # 2. Keys through the real event path reach the shell.
2664 printf 'text:echo native-ok-%s\n' "$$" >&8
2665 printf 'key:enter\n' >&8
2666 wait_grid "$SOCK" "native-ok-$$" "typed text landed on the daemon's grid"
2667 ok "text and Enter cross the keymap, the mailbox, the pump and the daemon"
2668
2669 # 3. A bounded flood, and the frame count read twice while it runs.
2670 frames_now() { kill -USR1 "$GPID"; sleep 0.3; grep -o 'timing ([0-9]* frames)' "$GLOG" | tail -1 | tr -dc '0-9'; }
2671 before=$(frames_now)
2672 "$MUX" a run --sock "$SOCK" --timeout 60000 'head -c 30000000 /dev/urandom | base64 | head -c 20000000; echo flood-done' >/dev/null 2>&1 &
2673 RUNPID=$!
2674 defer_kill "$RUNPID"
2675 sleep 1.5
2676 mid=$(frames_now)
2677 wait "$RUNPID"
2678 wait_grid "$SOCK" "flood-done" "the flood ended"
2679 after=$(frames_now)
2680 [ "${mid:-0}" -gt "${before:-0}" ] || { echo "native FAIL: no frame painted during the flood (before=$before mid=$mid)"; exit 1; }
2681 [ "${after:-0}" -gt "${mid:-0}" ] || { echo "native FAIL: no frame painted late in the flood (mid=$mid after=$after)"; exit 1; }
2682 ok "frames were painted while the session flooded: before=$before mid=$mid after=$after"
2683
2684 # 4. The window-side p99 sits under the budget (ReleaseSafe/Fast only).
2685 # Budget: 20 ms per stage on the total row, generous for a 960x600
2686 # window of ~4,500 cells; the row to read is `total`.
2687 p99=$(grep -E '^total ' "$GLOG" | tail -1 | awk '{print $4}')
2688 [ -n "$p99" ] && [ "$p99" -lt 20000 ] || { echo "native FAIL: total p99 ${p99:-?} us over the 20000 us budget"; tail -12 "$GLOG"; exit 1; }
2689 ok "window-side total p99 ${p99} us under 20000 us"
2690
2691 # 5. A resize through the hook is followed by the daemon: cols shrink from
2692 # whatever the 960 px window gave (the face decides the number) to
2693 # fewer at 640 px. Read before, then wait for after to differ.
2694 cols_of() { "$MUX" a status --sock "$SOCK" --timeout 2000 | sed 's/.*"cols":\([0-9]*\).*/\1/'; }
2695 cols_before=$(cols_of)
2696 printf 'resize:640x400\n' >&8
2697 wait_until 10 "the daemon's cols changed after the resize" '[ "$(cols_of)" != "$cols_before" ]'
2698 cols_after=$(cols_of)
2699 [ "$cols_after" -lt "$cols_before" ] || { echo "native FAIL: cols $cols_before -> $cols_after did not shrink with the 640 px window"; exit 1; }
2700 ok "the daemon followed the window's resize (cols $cols_before -> $cols_after)"
2701
2702 # 6. Close the window: muxg exits and the session survives on the daemon.
2703 # wait_pid_gone PID LABEL — it allows 2 s.
2704 printf 'quit\n' >&8
2705 wait_pid_gone "$GPID" "muxg exits on quit"
2706 "$MUX" a status --sock "$SOCK" --timeout 2000 >/dev/null 2>&1 || { echo "native FAIL: the session did not survive the window closing"; exit 1; }
2707 ok "closing the window detaches and leaves the session on its daemon"
2708
2709 echo "native OK ($OK_COUNT checkpoints)"
2710 ```
2711
2712 The helpers are spelled as `test/e2e_lib.sh` defines them (verified 2026-09-04): `wait_until SECS LABEL PREDICATE` at line 820, `attaches_now SOCK` at 864 (reads `mux d stats`), `wait_pid_gone PID LABEL` at 946 (2 s), `wait_grid SOCK NEEDLE LABEL [SESSION]` at 776, `start_daemon SOCK LOG LABEL` at 421. Re-grep the line numbers if the lib has moved; the argument shapes are the contract.
2713
2714 - [ ] **Step 2: Run it**
2715
2716 Run:
2717 ```sh
2718 chmod +x test/native.sh
2719 deps/zig/zig build native -Doptimize=ReleaseSafe 2>&1 | tail -3
2720 MUXG_OPT=ReleaseSafe test/native.sh zig-out/bin/mux zig-out/bin/muxg; echo rc=$?
2721 ```
2722 Expected: six `e2e OK:` lines, `native OK (6 checkpoints)`, rc 0. On the first run, expect to adjust: the `frames_now` grep pattern must match `bench.Ring.report`'s first line exactly (`=== muxg frame timing (N frames) ===`); the status JSON field order is `"cols":N,"rows":N` per `src/cli/muxa.zig:1095`.
2723
2724 If step 1's `wait_until` fails and `$GLOG` says `SDL_GL_CreateContext`, the offscreen driver has no GL here: run with `MUXG_VIDEODRIVER=x11` under `xvfb-run -a` and record that in the spec's Testing section.
2725
2726 - [ ] **Step 3: Make `make native-e2e` pass `MUXG_OPT`**
2727
2728 In `Makefile`, change the recipe to:
2729
2730 ```makefile
2731 native-e2e: mac-sdk
2732 $(ZIG) build native -Doptimize=ReleaseSafe
2733 MUXG_OPT=ReleaseSafe $(ZIG) build native-e2e -Doptimize=ReleaseSafe
2734 ```
2735
2736 Run: `make native-e2e; echo rc=$?` — expected rc 0.
2737
2738 - [ ] **Step 4: Commit**
2739
2740 ```sh
2741 make check && git add test/native.sh Makefile
2742 git commit -m "test: the native client's end-to-end leg pins painting under a flood"
2743 ```
2744
2745 ---
2746
2747 ### Task 10: Docs
2748
2749 **Files:**
2750 - Modify: `README.md` (a new section after the browser hub's), `CLAUDE.md` (the layout table, the toolchain block, the folder-rule paragraph)
2751
2752 - [ ] **Step 1: README**
2753
2754 After the `mux web` section, add:
2755
2756 ```markdown
2757 ## muxg: a native window on one session
2758
2759 `muxg [TARGET] [--session NAME]` opens a window on one daemon session and
2760 paints it with OpenGL from the cells the daemon sends. It is a session
2761 viewer: no panes, no picker, no hosts file. Type into it; resize it and
2762 the session follows; close it and the session stays on its daemon. A
2763 wall inside a window is `mux` run inside a `muxg` session.
2764
2765 It is opt-in: `make native` builds it against the system's SDL3, freetype
2766 and fontconfig, and `make native-e2e` runs its leg. `muxg` never starts a
2767 daemon; a silent local socket is refused with the `mux d start` line to
2768 run. `kill -USR1` on a running `muxg` prints its per-stage frame-timing
2769 table to stderr, as does exit.
2770 ```
2771
2772 - [ ] **Step 2: CLAUDE.md**
2773
2774 - Toolchain block: add `make native native-e2e # opt-in: the muxg viewer against system SDL3/freetype/fontconfig; never in ci`.
2775 - Layout table: add the row `| \`src/gui/\` | \`native\`(\`native.zig\`) — \`font\` \`atlas\` \`quads\` \`gl\` \`frame\` \`bench\` · the session VIEWER's painter; imports \`client\` and \`term\` only; no wall policy (rule 8), SDL confined to \`frame.zig\` (rule 9) |` and, under the `src/client/` row's child list, `session_pump`.
2776 - The folder-rule paragraph: after rule 7, add: `Rule 8 keeps wall policy out of \`src/gui/\` (no \`wall_host\`, \`wall_layout\`, \`wall_picker\`, \`layoutfile\`, \`SessionPoll\`); rule 9 confines the needle \`sdl\` to \`src/gui/frame.zig\`. Rules 5, 6 and 7 list \`src/gui\` too.`
2777 - The `ONE binary` paragraph: add one sentence: `\`muxg\` is a second, opt-in binary — the native viewer — because it links system libraries the static \`mux\` cannot.`
2778
2779 - [ ] **Step 3: Commit**
2780
2781 ```sh
2782 make check && git add README.md CLAUDE.md
2783 git commit -m "docs: muxg, the native session viewer, in README and CLAUDE.md"
2784 ```
2785
2786 ---
2787
2788 ## Self-review
2789
2790 **Spec coverage.** Section 1 (build root, target, two rules): Task 1. Section 2 (pump, mailbox, doorbell, state, frame arms, redial, the named debt): Task 7. Section 3 (font, atlas, quads, gl, frame loop, bench table): Tasks 2–6 and 8. Section 4 (input, no chord, close = detach, bell in title, clipboard ignored, mouse dropped): Task 8 — mouse events fall to the `else => dirty = true` arm and send nothing. Section 5 (dial failure words, exit code, refused, taken, reconnecting title, missing lib, face/GL failure, pump death): Task 8's phase switch and `muxg.zig`; the no-auto-start bullet is Task 1 step 8's spec amendment. Section 6 (unit tests: quads off-origin, flooring, key table both ways, atlas packer; the leg's six steps; Debug refusal; offscreen spike): Tasks 3, 4, 8, 9 and Task 1 step 7. Deferred items: none implemented, none needed.
2791
2792 **Type consistency.** `atlas.Entry {x,y,w,h,left,top}` is produced by Task 3 and consumed by Task 4's stub and Task 8's cache. `quads.Instance` field order (`x y w h u0 v0 u1 v1 rgba kind`) matches Task 6's attribute offsets. `bench.Frame` fields match the `frame_t` assignments in Task 8. `session_pump.Say` is posted by Task 8 as `.detach`, `.{ .input = ... }`, `.{ .resize = ... }`. `Pump.state()` returns a `State` with `phase`, `exit_code`, `bell`, `reasonText()`. `frame.Options.test_fifo` is read from `MUXG_TEST_FIFO` in `muxg.zig`.
2793
2794 **Known verification points for the implementer** (named at the step, not placeholders): the `OpenFailure` text field's name (Task 7), `Transport` reassignment on redial (Task 7), freetype translated field names (Task 5), `cliflags` parsing a `u16` (Task 8), the lib helper signatures (Task 9), and the `{s:<16}` column widths (Task 2). Each says what to read and what to do.