a73x

b7dbcdaf

build: add opt-in native client and OpenGL probe

a73x   2026-09-04 19:17

Commit message
build: add opt-in native client and OpenGL probe

Makefile
Old New
@@ -31,7 +31,7 @@ SHA256 ?= shasum -a 256
31 endif 31 endif
32 MUX_TARGET ?= x86_64-linux-musl 32 MUX_TARGET ?= x86_64-linux-musl
33 33
34 .PHONY: build check ci test e2e soak bench agent throughput vm coverage deps clean clean-deps xversion xversion-build install release release-mac mac-sdk mac xos provision-mac 34 .PHONY: build check ci test e2e soak bench agent native native-e2e throughput vm coverage deps clean clean-deps xversion xversion-build install release release-mac mac-sdk mac xos provision-mac
35 35
36 # The QUIC stack (deps/quic) is built on demand by build.zig, so no target 36 # The QUIC stack (deps/quic) is built on demand by build.zig, so no target
37 # here needs to depend on this one. It exists to make the one-time cost 37 # here needs to depend on this one. It exists to make the one-time cost
@@ -269,6 +269,14 @@ ci:
269 agent: mac-sdk 269 agent: mac-sdk
270 $(ZIG) build agent 270 $(ZIG) build agent
271 271
272 # Opt-in because these link system SDL3, freetype, fontconfig and HarfBuzz.
273 # They stay outside every default build and delivery gate.
274 native: mac-sdk
275 $(ZIG) build native native-test
276
277 native-e2e: mac-sdk
278 $(ZIG) build native-e2e
279
272 # Cross-version gate (test/xversion.sh): this tree's client against a 280 # Cross-version gate (test/xversion.sh): this tree's client against a
273 # previous version's daemon and back, each daemon in a container. 281 # previous version's daemon and back, each daemon in a container.
274 # 282 #
build.zig
Old New
@@ -136,6 +136,8 @@ const ModSpec = struct {
136 wasm: bool = false, 136 wasm: bool = false,
137 /// This module's test binary needs the QUIC archives. 137 /// This module's test binary needs the QUIC archives.
138 quic_tests: bool = false, 138 quic_tests: bool = false,
139 /// Built only by a named opt-in step, never by the default test loop.
140 opt_in: bool = false,
139 }; 141 };
140 142
141 const mod_table = [_]ModSpec{ 143 const mod_table = [_]ModSpec{
@@ -321,6 +323,10 @@ const mod_table = [_]ModSpec{
321 // round-trip's: it needs a directory to generate into, which the daemon 323 // round-trip's: it needs a directory to generate into, which the daemon
322 // never touches. 324 // never touches.
323 .{ .name = "mux", .path = "src/cli/mux.zig", .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "term", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags", "dial", "server_os", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 325 .{ .name = "mux", .path = "src/cli/mux.zig", .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "term", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags", "dial", "server_os", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
326 // Opt-in rows: no default artifact or test names them, so machines
327 // without the viewer's system libraries keep all existing gates.
328 .{ .name = "native", .path = "src/gui/native.zig", .link_libc = true, .imports = &.{ "client", "term" }, .opt_in = true },
329 .{ .name = "muxg", .path = "src/cli/muxg.zig", .link_libc = true, .imports = &.{ "native", "client", "term", "cliflags", "sockpath" }, .opt_in = true },
324 }; 330 };
325 331
326 /// Comptime row lookup. Every hand-written module name in this file goes 332 /// Comptime row lookup. Every hand-written module name in this file goes
@@ -362,7 +368,7 @@ comptime {
362 /// The folders the doc gate walks. Listed rather than globbed: a new folder 368 /// The folders the doc gate walks. Listed rather than globbed: a new folder
363 /// under `src/` is a decision about who owns what, and a glob would let one 369 /// under `src/` is a decision about who owns what, and a glob would let one
364 /// appear — with every file in it ungated — as a side effect of a mkdir. 370 /// appear — with every file in it ungated — as a side effect of a mkdir.
365 const src_dirs = [_][]const u8{ "src", "src/engine", "src/server", "src/client", "src/tui", "src/cli", "src/os" }; 371 const src_dirs = [_][]const u8{ "src", "src/engine", "src/server", "src/client", "src/tui", "src/cli", "src/os", "src/gui" };
366 372
367 /// The source bans, read off the PRODUCTION lines of the files under `src/`. 373 /// The source bans, read off the PRODUCTION lines of the files under `src/`.
368 /// They catch what the import graph cannot: a module needs no import to 374 /// They catch what the import graph cannot: a module needs no import to
@@ -422,7 +428,7 @@ const source_bans = [_]SourceBan{
422 // spelled by a leaf utility runs exactly as well as one spelled by 428 // spelled by a leaf utility runs exactly as well as one spelled by
423 // the daemon, and a rule with a hole in it is a rule that reports 429 // the daemon, and a rule with a hole in it is a rule that reports
424 // green about the place nobody looked. 430 // green about the place nobody looked.
425 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os" }, 431 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os", "src/gui" },
426 .needles = &.{ "\"/bin/sh\"", "\"-c\"" }, 432 .needles = &.{ "\"/bin/sh\"", "\"-c\"" },
427 .why = "the only program mux runs is one the user named — the " ++ 433 .why = "the only program mux runs is one the user named — the " ++
428 "session shell, `ssh` from the handoff recipe, or `--via`'s own " ++ 434 "session shell, `ssh` from the handoff recipe, or `--via`'s own " ++
@@ -431,7 +437,7 @@ const source_bans = [_]SourceBan{
431 }, 437 },
432 .{ 438 .{
433 .rule = "6", 439 .rule = "6",
434 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os" }, 440 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os", "src/gui" },
435 .needles = &.{"posix.fork("}, 441 .needles = &.{"posix.fork("},
436 .except = &.{ "src/os/server_os_linux.zig", "src/os/server_os_macos.zig" }, 442 .except = &.{ "src/os/server_os_linux.zig", "src/os/server_os_macos.zig" },
437 .why = "the daemon starts itself \u{2014} `mux d start -d` forks, and " ++ 443 .why = "the daemon starts itself \u{2014} `mux d start -d` forks, and " ++
@@ -443,7 +449,7 @@ const source_bans = [_]SourceBan{
443 }, 449 },
444 .{ 450 .{
445 .rule = "7", 451 .rule = "7",
446 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli" }, 452 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/gui" },
447 // The raw spellings the platform layer exists to hold. `src/os/` is 453 // The raw spellings the platform layer exists to hold. `src/os/` is
448 // absent from the list on purpose: its children may spell anything, 454 // absent from the list on purpose: its children may spell anything,
449 // and its roots have no reason to. Comments count, as they do for 455 // and its roots have no reason to. Comments count, as they do for
@@ -466,6 +472,19 @@ const source_bans = [_]SourceBan{
466 "server_os or client_os operation whose doc names what it guarantees; " ++ 472 "server_os or client_os operation whose doc names what it guarantees; " ++
467 "everything else builds for every OS from the same line", 473 "everything else builds for every OS from the same line",
468 }, 474 },
475 .{
476 .rule = "8",
477 .folders = &.{"src/gui"},
478 .needles = &.{ "@import(\"wall\")", "wall_host", "wall_layout", "wall_picker", "layoutfile", "sessionpoll" },
479 .why = "src/gui/ renders one session and contains no multi-session policy",
480 },
481 .{
482 .rule = "9",
483 .folders = &.{"src/gui"},
484 .needles = &.{"sdl"},
485 .except = &.{"src/gui/frame.zig"},
486 .why = "the windowing dependency is confined to frame.zig",
487 },
469 }; 488 };
470 489
471 fn checkSourceBan(b: *std.Build, ban: SourceBan) void { 490 fn checkSourceBan(b: *std.Build, ban: SourceBan) void {
@@ -824,10 +843,15 @@ comptime {
824 // Every table row appears in the test loop exactly once. A module in 843 // Every table row appears in the test loop exactly once. A module in
825 // the table but not the loop is the silent-module-loss hazard with a 844 // the table but not the loop is the silent-module-loss hazard with a
826 // new spelling; a duplicate runs a suite twice and skews timings. 845 // new spelling; a duplicate runs a suite twice and skews timings.
827 if (test_order.len != mod_table.len) 846 var default_rows: usize = 0;
828 @compileError("test_order must cover every mod_table row exactly once"); 847 for (mod_table) |m| {
848 if (!m.opt_in) default_rows += 1;
849 }
850 if (test_order.len != default_rows)
851 @compileError("test_order must cover every non-opt-in mod_table row exactly once");
829 for (test_order, 0..) |n, i| { 852 for (test_order, 0..) |n, i| {
830 _ = idxOf(n); 853 if (mod_table[idxOf(n)].opt_in)
854 @compileError("test_order contains opt-in row: " ++ n);
831 for (test_order[i + 1 ..]) |n2| { 855 for (test_order[i + 1 ..]) |n2| {
832 if (std.mem.eql(u8, n, n2)) @compileError("duplicate in test_order: " ++ n); 856 if (std.mem.eql(u8, n, n2)) @compileError("duplicate in test_order: " ++ n);
833 } 857 }
@@ -1107,6 +1131,39 @@ pub fn build(b: *std.Build) void {
1107 const agent_step = b.step("agent", "Run the agent-surface end-to-end suite"); 1131 const agent_step = b.step("agent", "Run the agent-surface end-to-end suite");
1108 agent_step.dependOn(&agent.step); 1132 agent_step.dependOn(&agent.step);
1109 1133
1134 // The native client is entirely opt-in. These are the only compile
1135 // steps that reach its rows or ask pkg-config for its system libraries.
1136 // GL functions are resolved through the window library at runtime, so
1137 // there is deliberately no libGL link here.
1138 const native_mod = mods[comptime idxOf("native")];
1139 const muxg_mod = mods[comptime idxOf("muxg")];
1140 const muxg_exe = b.addExecutable(.{ .name = "muxg", .root_module = muxg_mod });
1141 linkerFor(muxg_exe);
1142 for ([_][]const u8{ "sdl3", "freetype2", "fontconfig", "harfbuzz" }) |lib|
1143 muxg_exe.linkSystemLibrary2(lib, .{ .use_pkg_config = .force });
1144 linkQuic(b, muxg_exe, quic);
1145 const install_muxg = b.addInstallArtifact(muxg_exe, .{});
1146 const native_step = b.step("native", "Build muxg (opt-in; needs SDL3, freetype, fontconfig, HarfBuzz)");
1147 native_step.dependOn(&install_muxg.step);
1148
1149 // This compiles the same native component and links the same libraries,
1150 // but its tests do not initialize video or open a window.
1151 const native_tests = b.addTest(.{ .root_module = native_mod });
1152 linkerFor(native_tests);
1153 for ([_][]const u8{ "sdl3", "freetype2", "fontconfig", "harfbuzz" }) |lib|
1154 native_tests.linkSystemLibrary2(lib, .{ .use_pkg_config = .force });
1155 linkQuic(b, native_tests, quic);
1156 const native_test_step = b.step("native-test", "Run the native client's unit tests (opt-in)");
1157 native_test_step.dependOn(&b.addRunArtifact(native_tests).step);
1158
1159 // The runner is added with the full event path; declaring it here keeps
1160 // the opt-in interface fixed while remaining lazy until that step runs.
1161 const native_e2e = b.addSystemCommand(&.{"test/native.sh"});
1162 native_e2e.addArtifactArg(mux_exe);
1163 native_e2e.addArtifactArg(muxg_exe);
1164 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)");
1165 native_e2e_step.dependOn(&native_e2e.step);
1166
1110 const soak = b.addSystemCommand(&.{"test/soak.sh"}); 1167 const soak = b.addSystemCommand(&.{"test/soak.sh"});
1111 // The same list the e2e step passes, in the same order: soak IS that 1168 // The same list the e2e step passes, in the same order: soak IS that
1112 // suite run N times, so an argument added to one and not the other 1169 // suite run N times, so an argument added to one and not the other
docs/superpowers/specs/2026-09-04-native-client-design.md
Old New
@@ -4,9 +4,8 @@
4 with OpenGL from the grid cells-on-the-wire delivers. It is a SESSION 4 with OpenGL from the grid cells-on-the-wire delivers. It is a SESSION
5 VIEWER, not a wall. Nothing in it decides which sessions are on screen, 5 VIEWER, not a wall. Nothing in it decides which sessions are on screen,
6 where they sit, or what happens when one ends; it shows one session and 6 where they sit, or what happens when one ends; it shows one session and
7 types into it. This spec is written against the cells-on-the-wire branch's 7 types into it. The cells-on-the-wire implementation has landed: the painter consumes
8 `src/engine/grid.zig` and `replica.zig`, and implementation starts after 8 `src/engine/grid.zig` through `replica.zig` and links no terminal emulator.
9 that branch lands on main, because the painter's whole input is that grid.
10 9
11 ## Goal 10 ## Goal
12 11
@@ -76,11 +75,12 @@ A new folder, `src/gui/`, one row in the layout table:
76 |---|---| 75 |---|---|
77 | `src/gui/` | `native`(`native.zig`) — `font` `atlas` `quads` `gl` `frame` `bench` | 76 | `src/gui/` | `native`(`native.zig`) — `font` `atlas` `quads` `gl` `frame` `bench` |
78 77
79 Its entry is `src/cli/muxg.zig`, a child of the `mux` row like the other 78 Its entry is the separate `muxg` row rooted at `src/cli/muxg.zig`. The
80 entrypoints. The binary is `muxg`, separate from `mux`: the Linux release 79 binary is `muxg`, separate from `mux`: the Linux release of `mux` is static
81 of `mux` is static musl, and this program links the system's SDL3, libGL, 80 musl, and this program links the system's SDL3, freetype, fontconfig and
82 freetype and fontconfig dynamically through pkg-config, against native 81 HarfBuzz dynamically through pkg-config, against native glibc. GL functions
83 glibc. `zig build native` is an opt-in step that builds `muxg` and 82 are loaded by name through SDL, so there is no libGL link. `zig build native`
83 is an opt-in step that builds `muxg` and
84 `native-test` runs its unit tests; `make native` is the only caller of 84 `native-test` runs its unit tests; `make native` is the only caller of
85 either. `make build`, `make check` and `make ci` never touch it, so a box 85 either. `make build`, `make check` and `make ci` never touch it, so a box
86 without SDL3 builds and gates mux as before. 86 without SDL3 builds and gates mux as before.
@@ -120,19 +120,25 @@ dial), sends the attach frame with the current size, then loops on a poll
120 over the transport fd and the doorbell: it reads frames, applies each 120 over the transport fd and the doorbell: it reads frames, applies each
121 through `Replica.apply` under the mutex, feeds every non-replay frame to 121 through `Replica.apply` under the mutex, feeds every non-replay frame to
122 `ClientCore.receive`, and calls the wake once per pass that changed 122 `ClientCore.receive`, and calls the wake once per pass that changed
123 anything. The wake is a function pointer; on the SDL side it pushes one 123 anything. Socket and pipe reads consume one frame per readiness check;
124 user event, and the window thread coalesces however many wakes arrived 124 QUIC consumes at most 64 buffered frames before servicing the mailbox and
125 into one paint. 125 waking the painter. A full QUIC batch schedules another pass without
126 waiting for new socket traffic. The wake is a function pointer; the window
127 side keeps at most one outstanding wake event and bounds event processing
128 before painting, so continuous arrivals cannot starve a frame.
126 129
127 The mailbox carries the four things a viewer can say — input bytes, a 130 The mailbox carries the four things a viewer can say — input bytes, a
128 resize, a detach and quit — and an eventfd doorbell rings the pump out of 131 resize, a detach and quit — and a nonblocking pipe doorbell rings the pump
129 its poll to send them. The window thread never touches the transport. 132 out of its poll to send them. The window thread never touches the transport.
130 133
131 Frame handling mirrors the wall pump arm for arm and no further: 134 Frame handling mirrors the wall pump arm for arm and no further:
132 135
136 - `BadPayload` from a short snapshot leaves the grid untouched and is
137 skipped. `SnapshotAborted` makes the replica unusable: publish a failure,
138 wake the window and exit 1. Unexpected pump exits also publish failure.
133 - `.resync` from `apply`: re-attach at seq 0, as `wall_pump` does, because 139 - `.resync` from `apply`: re-attach at seq 0, as `wall_pump` does, because
134 a quoted seq after a resync invites an unfixable delta. 140 a quoted seq after a resync invites an unfixable delta.
135 - `exit_status`: before any replay frame it is the refusal path and the 141 - `exit_status`: before any replay frame on the current attach it is the refusal path and the
136 state is `refused` with the payload; after, `exited` with the shell's 142 state is `refused` with the payload; after, `exited` with the shell's
137 code. The pump returns. 143 code. The pump returns.
138 - `taken_over`: the wall treats it as an end and so does this (the current 144 - `taken_over`: the wall treats it as an end and so does this (the current
@@ -143,7 +149,9 @@ Frame handling mirrors the wall pump arm for arm and no further:
143 questions. 149 questions.
144 - A lost link (read error, `.closed`): state `reconnecting`, redial with 150 - A lost link (read error, `.closed`): state `reconnecting`, redial with
145 `nextBackoffMs`, re-attach with the replica's `attachArgs`, until the 151 `nextBackoffMs`, re-attach with the replica's `attachArgs`, until the
146 mailbox says quit. 152 mailbox says quit. Reset admission state on every attach while retaining
153 the resume sequence and epoch on reconnect. Closing must interrupt
154 reconnect waits; failed initial dials use the error path below.
147 155
148 The header names the debt: this is the third terminal-free attach loop, 156 The header names the debt: this is the third terminal-free attach loop,
149 after `webhub.pumpTile` and `mux a`'s, and `pumpTile` is the first 157 after `webhub.pumpTile` and `mux a`'s, and `pumpTile` is the first
@@ -152,25 +160,30 @@ candidate to move onto it. It is not moved in this change.
152 ### 3. The painter — `src/gui/` 160 ### 3. The painter — `src/gui/`
153 161
154 **`font.zig`** asks fontconfig for the system monospace face (`monospace` 162 **`font.zig`** asks fontconfig for the system monospace face (`monospace`
155 pattern, default size 12 pt overridable by a flag) and freetype for glyph 163 pattern, default size 16 pixels overridable by `--font-px`) and freetype
156 bitmaps. It measures the cell from the face's advance width and its 164 for glyph bitmaps. HarfBuzz shapes each cell's complete UTF-8 cluster
157 ascender-plus-descender height, in whole pixels, and rasterises one glyph 165 within the daemon's narrow or wide span; it never shapes across cells.
166 Regular, bold, italic and bold-italic variants are matched or synthesized. It measures the cell from the face's advance width and its
167 ascender-plus-descender height, in whole pixels, and rasterises glyph IDs
158 on demand. Colour emoji, fallback faces and hinting choices are deferred; 168 on demand. Colour emoji, fallback faces and hinting choices are deferred;
159 a codepoint the face lacks paints as the face's missing-glyph box. 169 a codepoint the face lacks paints as the face's missing-glyph box.
160 170
161 **`atlas.zig`** is one R8 texture, shelf-packed, grown by re-upload only 171 **`atlas.zig`** is one R8 texture, shelf-packed, grown by re-upload only
162 when a glyph first appears. It never shrinks. Each entry is the glyph's 172 when a glyph first appears. It never shrinks. Each entry is the glyph's
163 texture rectangle plus its bearing, so `quads` can place it. 173 texture rectangle plus its bearing, keyed by face variant and glyph ID.
174 Shaped runs are cached by owned complete text and font variant. Resolve
175 all atlas insertions before computing normalized texture coordinates, so
176 growth cannot invalidate the frame being drawn.
164 177
165 **`quads.zig`** is the port of waystty's `cell_instance.zig`, rewritten 178 **`quads.zig`** is the port of waystty's `cell_instance.zig`, rewritten
166 over `term.grid`. For each row it walks the cells and emits zero, one or 179 over `term.grid`. Each cell emits an effective background when needed,
167 two instances per cell: a background quad when the cell's background is not 180 a positioned glyph run, and any solid decoration instances. Glyphs retain
168 the default, and a glyph quad when the cell has text, with the atlas entry 181 their rasterized size and shaping offsets within the authoritative one- or
169 for the cell's text (rasterised on first sight). A wide cell's glyph quad 182 two-column span; trailing wide-cell halves emit no duplicate glyph.
170 is two columns wide and the trailing half is skipped. Reverse video, bold, 183 Render every defined `proto.CellStyle` flag, including inverse, faint,
171 underline and the rest of `proto.CellStyle` are attributes on the 184 invisible, blink, strikethrough, overline and all underline variants and
172 instance; the palette is a fixed table for the 256 colours plus RGB 185 colours. Invisible suppresses glyphs and decorations. The palette is a
173 pass-through. The cursor is one more quad over its cell, in the foreground 186 fixed table for the 256 colours plus RGB pass-through. The cursor is one more quad over its cell, in the foreground
174 colour. A row's instances are built from a `Row` and a column offset, so 187 colour. A row's instances are built from a `Row` and a column offset, so
175 the unit test can hold the offset non-zero. 188 the unit test can hold the offset non-zero.
176 189
@@ -191,7 +204,8 @@ say whether dirty rows ever matter. A window resize floors the drawable
191 size to whole cells and, when cols or rows changed, sends a resize through 204 size to whole cells and, when cols or rows changed, sends a resize through
192 the mailbox; the daemon follows the latest active client, so the session 205 the mailbox; the daemon follows the latest active client, so the session
193 takes the window's size. Frames are painted only on a wake, a resize or an 206 takes the window's size. Frames are painted only on a wake, a resize or an
194 expose; an idle window draws nothing. 207 expose, plus timed repainting while blinking cells are visible. An ordinary
208 idle window draws nothing. Bell-title restoration also has a deadline.
195 209
196 **`bench.zig`** is a ring of per-frame stage times in microseconds: `apply` 210 **`bench.zig`** is a ring of per-frame stage times in microseconds: `apply`
197 (pump side, the time inside `Replica.apply` per pass), and on the window 211 (pump side, the time inside `Replica.apply` per pass), and on the window
@@ -217,11 +231,14 @@ SDL's text-input events carry UTF-8 for anything that types a character;
217 those bytes go to the mailbox as an `input` frame unchanged. Key-down 231 those bytes go to the mailbox as an `input` frame unchanged. Key-down
218 events for everything else become a `keymap.Event`: the arrows, Home, End, 232 events for everything else become a `keymap.Event`: the arrows, Home, End,
219 Insert, Delete, Page Up, Page Down, F1 through F12, Enter, Tab, Backspace 233 Insert, Delete, Page Up, Page Down, F1 through F12, Enter, Tab, Backspace
220 and Escape by name, and a letter held with Ctrl or Alt as `.char` with the 234 and Escape by name, and a printable character (including punctuation and space) held with
235 Ctrl or Alt as `.char` with the
221 codepoint and the modifiers. `keymap.encode` produces the bytes, so the 236 codepoint and the modifiers. `keymap.encode` produces the bytes, so the
222 key table stays in one file and a chord means the same on the wall, in the 237 key table stays in one file and a chord means the same on the wall, in the
223 browser and here. The SDL keycode to `keymap.Event` mapping is a pure 238 browser and here. The SDL keycode to `keymap.Event` mapping is a pure
224 table in `frame.zig`, unit-tested without a window. 239 table in `frame.zig`, unit-tested without a window. A consumed modifier
240 chord must not also be sent as text; text composition and AltGr retain
241 their layout-produced UTF-8.
225 242
226 There is no prefix chord: `Ctrl-\` is a byte for the session, because a 243 There is no prefix chord: `Ctrl-\` is a byte for the session, because a
227 viewer has nothing to switch to. Closing the window sends detach and 244 viewer has nothing to switch to. Closing the window sends detach and
@@ -241,6 +258,10 @@ latest-wins follows input, not focus.
241 grid painted until frames resume or the window closes. 258 grid painted until frames resume or the window closes.
242 - A missing library is a link error in pkg-config's own words at 259 - A missing library is a link error in pkg-config's own words at
243 `make native`; nothing falls back. 260 `make native`; nothing falls back.
261 - A local dial to a socket nobody answers exits 2 with `muxg: no daemon at
262 PATH (run: mux d start -d --sock PATH)`. `muxg` never starts a daemon: the
263 self-exec rule says an auto-start may only run the image already running,
264 and this image is not the daemon's.
244 - A face freetype cannot load, or a GL context SDL cannot create, exits 2 265 - A face freetype cannot load, or a GL context SDL cannot create, exits 2
245 with that library's error string on stderr. 266 with that library's error string on stderr.
246 - The pump thread ending for any reason the state does not name ends the 267 - The pump thread ending for any reason the state does not name ends the
@@ -250,18 +271,20 @@ latest-wins follows input, not focus.
250 271
251 Both layers are opt-in under `make native` and outside `make ci`. 272 Both layers are opt-in under `make native` and outside `make ci`.
252 273
253 **Unit tests, no window.** `native-test` builds `src/gui/` without SDL and 274 **Unit tests, no window.** `native-test` links the same libraries, opens no
254 runs: 275 window, and runs:
255 276
256 - `quads` over a `Row` built from decoded cells, with the column offset 277 - `quads` over a `Row` built from decoded cells, with the column offset
257 non-zero: a blank cell emits nothing, a styled-background blank emits one 278 non-zero: a blank cell emits nothing, a styled-background blank emits one
258 quad, a cell with text emits a glyph quad, a wide cell emits one 279 quad, complete text clusters retain every shaped glyph and offset,
259 two-column glyph quad and its trailing half emits nothing, and the cursor 280 wide-cell trailing halves emit no duplicate glyph, and the cursor lands
260 quad lands on the cursor cell. 281 on its cell. Cover each style and underline variant, combinations,
282 invisible decorations and both blink phases.
261 - The drawable-size to cols and rows flooring at a non-square cell size, 283 - The drawable-size to cols and rows flooring at a non-square cell size,
262 and the resize decision (changed vs unchanged) it feeds. 284 and the resize decision (changed vs unchanged) it feeds.
263 - The SDL keycode to `keymap.Event` table, both directions worth pinning: 285 - The SDL keycode to `keymap.Event` table, both directions worth pinning:
264 a named key maps, a printable key does not (it arrives as text). 286 a named key maps, a bare printable key does not (it arrives as text),
287 and Ctrl punctuation/space and Alt chords produce exactly one sequence.
265 - The atlas packer: a second glyph lands beside the first, a row that 288 - The atlas packer: a second glyph lands beside the first, a row that
266 overflows opens a new shelf, and growth keeps every earlier entry's 289 overflows opens a new shelf, and growth keeps every earlier entry's
267 rectangle. 290 rectangle.
@@ -271,7 +294,9 @@ daemon on an isolated `XDG_RUNTIME_DIR` and `XDG_STATE_HOME`, in the
271 harness's shape (`test/os_oracle.sh` for anything about a pid): 294 harness's shape (`test/os_oracle.sh` for anything about a pid):
272 295
273 1. Start a daemon; start `muxg --sock PATH` with `SDL_VIDEODRIVER=offscreen` 296 1. Start a daemon; start `muxg --sock PATH` with `SDL_VIDEODRIVER=offscreen`
274 and a fixed window size, and capture its pid. 297 and a fixed window size, and capture its pid. The 2026-09-04 probe on the
298 development box succeeded with the offscreen driver and an OpenGL 4.6
299 core context, so this is the primary test path.
275 2. Type `echo native-ok` through the real event path — SDL's own event 300 2. Type `echo native-ok` through the real event path — SDL's own event
276 injection from a test hook in `muxg`, gated on an environment variable 301 injection from a test hook in `muxg`, gated on an environment variable
277 the leg sets — and read the shell's reply through `mux a`. The bytes 302 the leg sets — and read the shell's reply through `mux a`. The bytes
@@ -288,6 +313,13 @@ harness's shape (`test/os_oracle.sh` for anything about a pid):
288 6. Close the window; the process exits 0 and the daemon still lists the 313 6. Close the window; the process exits 0 and the daemon still lists the
289 session. 314 session.
290 315
316 At the fixed 960×600 test window, the window-side total p99 budget is
317 20,000 microseconds over the timing ring, including draw/swap and any
318 vsync wait. Report pump apply time separately; it is not window latency.
319 The flood must show producer progress between frame-count samples and
320 remain active through both samples. A timing table without concurrent
321 frame progress fails, regardless of its percentiles.
322
291 The budget is stated for ReleaseSafe or ReleaseFast only, and the leg 323 The budget is stated for ReleaseSafe or ReleaseFast only, and the leg
292 refuses to grade a Debug binary, because a Debug ghostty runs its 324 refuses to grade a Debug binary, because a Debug ghostty runs its
293 page-integrity check on every mutation and a number measured there means 325 page-integrity check on every mutation and a number measured there means
@@ -302,7 +334,7 @@ with which.
302 `frame.zig` `bench.zig`; `src/cli/muxg.zig`. 334 `frame.zig` `bench.zig`; `src/cli/muxg.zig`.
303 - `src/client/session_pump.zig`, re-exported from `client.zig`. 335 - `src/client/session_pump.zig`, re-exported from `client.zig`.
304 - `build.zig`: the `native` and `native-test` steps, pkg-config for SDL3, 336 - `build.zig`: the `native` and `native-test` steps, pkg-config for SDL3,
305 GL, freetype2 and fontconfig, the table row, the two folder rules. 337 freetype2, fontconfig and HarfBuzz, the table row, the two folder rules.
306 - `Makefile`: `native`, `native-e2e`. 338 - `Makefile`: `native`, `native-e2e`.
307 - `test/native.sh`. 339 - `test/native.sh`.
308 - `README.md`: one section, `muxg TARGET`, and that it is a viewer. 340 - `README.md`: one section, `muxg TARGET`, and that it is a viewer.
src/cli/muxg.zig
Old New
@@ -0,0 +1,68 @@
1 //! `muxg`: native client entry point. During the first implementation sprint
2 //! this is a real OpenGL probe over the system window library.
3 const std = @import("std");
4 const native = @import("native");
5 const client = @import("client");
6 const term = @import("term");
7 const cliflags = @import("cliflags");
8 const sockpath = @import("sockpath");
9
10 const c = @cImport({
11 @cInclude("SDL3/SDL.h");
12 });
13
14 const gl_version = 0x1F02;
15
16 pub fn main() u8 {
17 if (!c.SDL_Init(c.SDL_INIT_VIDEO)) {
18 std.debug.print("muxg: SDL_Init: {s}\n", .{std.mem.span(c.SDL_GetError())});
19 return 2;
20 }
21 defer c.SDL_Quit();
22
23 if (!c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3) or
24 !c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 3) or
25 !c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_CORE))
26 {
27 std.debug.print("muxg: SDL_GL_SetAttribute: {s}\n", .{std.mem.span(c.SDL_GetError())});
28 return 2;
29 }
30
31 const window = c.SDL_CreateWindow("muxg probe", 320, 200, c.SDL_WINDOW_OPENGL) orelse {
32 std.debug.print("muxg: SDL_CreateWindow: {s}\n", .{std.mem.span(c.SDL_GetError())});
33 return 2;
34 };
35 defer c.SDL_DestroyWindow(window);
36
37 const context = c.SDL_GL_CreateContext(window) orelse {
38 std.debug.print("muxg: SDL_GL_CreateContext: {s}\n", .{std.mem.span(c.SDL_GetError())});
39 return 2;
40 };
41 defer _ = c.SDL_GL_DestroyContext(context);
42
43 const get_string: ?*const fn (u32) callconv(.c) ?[*:0]const u8 =
44 @ptrCast(c.SDL_GL_GetProcAddress("glGetString"));
45 const get = get_string orelse {
46 std.debug.print("muxg: no glGetString: {s}\n", .{std.mem.span(c.SDL_GetError())});
47 return 2;
48 };
49 const version = get(gl_version) orelse {
50 std.debug.print("muxg: glGetString(GL_VERSION) returned null\n", .{});
51 return 2;
52 };
53 const driver = c.SDL_GetCurrentVideoDriver() orelse {
54 std.debug.print("muxg: no current video driver: {s}\n", .{std.mem.span(c.SDL_GetError())});
55 return 2;
56 };
57 std.debug.print("muxg probe: driver={s} GL_VERSION={s}\n", .{
58 std.mem.span(driver),
59 std.mem.span(version),
60 });
61
62 _ = native;
63 _ = client;
64 _ = term;
65 _ = cliflags;
66 _ = sockpath;
67 return 0;
68 }
src/gui/frame.zig
Old New
@@ -0,0 +1,4 @@
1 //! Window integration boundary for the native session painter.
2 //!
3 //! The first sprint establishes this ownership boundary. The event loop is
4 //! added with the painter; the executable currently performs the GL probe.
src/gui/native.zig
Old New
@@ -0,0 +1,19 @@
1 //! The native client's painter: a window onto one daemon session, rendered
2 //! directly from the grid delivered on the wire.
3 //!
4 //! Folder rules 8 and 9 in build.zig keep this directory limited to rendering
5 //! and window integration for a single session. Multi-session placement and
6 //! discovery remain outside it. The window API is confined to frame.zig; the
7 //! remaining painter code stays usable in unit tests that open no window.
8 //!
9 //! Imports `client` and `term` and nothing else of ours.
10 const client = @import("client");
11 const term = @import("term");
12
13 pub const frame = @import("frame.zig");
14
15 test {
16 _ = client;
17 _ = term;
18 _ = frame;
19 }