b7dbcdaf
build: add opt-in native client and OpenGL probe
a73x 2026-09-04 19:17
Commit message
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 | } | ||