diff --git a/apps/browser-demos/pages/kandelo/assets/catppuccin-totoro.jpg b/apps/browser-demos/pages/kandelo/assets/catppuccin-totoro.jpg new file mode 100644 index 0000000000..29f96dee74 Binary files /dev/null and b/apps/browser-demos/pages/kandelo/assets/catppuccin-totoro.jpg differ diff --git a/apps/browser-demos/pages/kandelo/assets/everforest-tree-tops.jpg b/apps/browser-demos/pages/kandelo/assets/everforest-tree-tops.jpg new file mode 100644 index 0000000000..c5f3a25129 Binary files /dev/null and b/apps/browser-demos/pages/kandelo/assets/everforest-tree-tops.jpg differ diff --git a/apps/browser-demos/pages/kandelo/assets/gruvbox-the-backwater.jpg b/apps/browser-demos/pages/kandelo/assets/gruvbox-the-backwater.jpg new file mode 100644 index 0000000000..352732ae5a Binary files /dev/null and b/apps/browser-demos/pages/kandelo/assets/gruvbox-the-backwater.jpg differ diff --git a/apps/browser-demos/pages/kandelo/assets/nord-black-moon.jpg b/apps/browser-demos/pages/kandelo/assets/nord-black-moon.jpg new file mode 100644 index 0000000000..7879f77ab2 Binary files /dev/null and b/apps/browser-demos/pages/kandelo/assets/nord-black-moon.jpg differ diff --git a/apps/browser-demos/pages/kandelo/assets/rose-pine-funky-shapes.jpg b/apps/browser-demos/pages/kandelo/assets/rose-pine-funky-shapes.jpg new file mode 100644 index 0000000000..9b3618e435 Binary files /dev/null and b/apps/browser-demos/pages/kandelo/assets/rose-pine-funky-shapes.jpg differ diff --git a/apps/browser-demos/pages/kandelo/assets/tokyo-night-sunset-lake.jpg b/apps/browser-demos/pages/kandelo/assets/tokyo-night-sunset-lake.jpg new file mode 100644 index 0000000000..5253f2871c Binary files /dev/null and b/apps/browser-demos/pages/kandelo/assets/tokyo-night-sunset-lake.jpg differ diff --git a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts index 95a3d6b7ab..f44044b415 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts +++ b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts @@ -86,6 +86,27 @@ import { builtinDemoPresentation, } from "../../../../../web-libs/kandelo-session/src/demo-guides"; import { PRESET_LIBRARY } from "../presets"; +import { + OMARCHY_APPS, + OMARCHY_APPS_DIR, + OMARCHY_BUS_SOCKET, + OMARCHY_CONF_PATH, + OMARCHY_DBUS_SESSION_CONF, + OMARCHY_FONTS_CONF, + OMARCHY_MAKO_CONFIG, + OMARCHY_MAKO_CONFIG_PATH, + OMARCHY_THEME_DIR, + OMARCHY_THEME_HOOK, + OMARCHY_THEMES, + OMARCHY_WAYBAR_CONFIG, + OMARCHY_WAYBAR_CONFIG_PATH, + OMARCHY_WAYBAR_STYLE, + OMARCHY_WAYBAR_STYLE_PATH, + OMARCHY_WAYBAR_STYLE_SEED_PATH, + OMARCHY_WLCOMPOSITOR_CONF, + renderImageWallpaperKwlp, + renderWallpaperKwlp, +} from "./omarchy-desktop"; import { descriptorWithVfsImageUrl, demoIdFromVfsImageUrl, @@ -128,6 +149,7 @@ import { import kernelWasmUrl from "@kernel-wasm?url"; import shellVfsUrl from "@binaries/programs/wasm32/shell.vfs.zst?url"; import dinitWasmUrl from "@binaries/programs/wasm32/dinit/dinit.wasm?url"; +import inconsolataFontUrl from "../../../../../examples/libs/wpkdraw/third_party/Inconsolata-Regular.ttf"; // @ts-expect-error Vite owns this virtual module in both canonical and normal mode. import canonicalPagesVfsProducts from "virtual:kandelo-pages-vfs-products"; @@ -187,28 +209,70 @@ const OPTIONAL_BINARY_URLS = { ...import.meta.glob("../../../../../binaries/programs/wasm32/sdl2.wasm", { query: "?url", import: "default", }), - ...import.meta.glob("../../../../../local-binaries/programs/wasm32/wlcompositor.wasm", { + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/modeset.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../binaries/programs/wasm32/modeset.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/wldesktop/wlcompositor.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../binaries/programs/wasm32/wldesktop/wlcompositor.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/wldesktop/wlterm.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../binaries/programs/wasm32/wldesktop/wlterm.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/wldesktop/wlclock.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../binaries/programs/wasm32/wldesktop/wlclock.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/wldesktop/wlpaint.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../binaries/programs/wasm32/wldesktop/wlpaint.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/waybar.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../binaries/programs/wasm32/waybar.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/wldesktop/klauncher.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../binaries/programs/wasm32/wldesktop/klauncher.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/dbus/dbus-daemon.wasm", { query: "?url", import: "default", }), - ...import.meta.glob("../../../../../binaries/programs/wasm32/wlcompositor.wasm", { + ...import.meta.glob("../../../../../binaries/programs/wasm32/dbus/dbus-daemon.wasm", { query: "?url", import: "default", }), - ...import.meta.glob("../../../../../local-binaries/programs/wasm32/wlterm.wasm", { + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/mako/mako.wasm", { query: "?url", import: "default", }), - ...import.meta.glob("../../../../../binaries/programs/wasm32/wlterm.wasm", { + ...import.meta.glob("../../../../../binaries/programs/wasm32/mako/mako.wasm", { query: "?url", import: "default", }), - ...import.meta.glob("../../../../../local-binaries/programs/wasm32/wlclock.wasm", { + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/wldesktop/notify-send.wasm", { query: "?url", import: "default", }), - ...import.meta.glob("../../../../../binaries/programs/wasm32/wlclock.wasm", { + ...import.meta.glob("../../../../../binaries/programs/wasm32/wldesktop/notify-send.wasm", { query: "?url", import: "default", }), - ...import.meta.glob("../../../../../local-binaries/programs/wasm32/wlpaint.wasm", { + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/foot.wasm", { query: "?url", import: "default", }), - ...import.meta.glob("../../../../../binaries/programs/wasm32/wlpaint.wasm", { + ...import.meta.glob("../../../../../binaries/programs/wasm32/foot.wasm", { query: "?url", import: "default", }), } as Record Promise>; @@ -351,6 +415,7 @@ const LIVE_DEMO_IDS = [ "sdl2", "wayland", "hyprland", + "omarchy", ] as const; type LiveDemoId = (typeof LIVE_DEMO_IDS)[number]; @@ -448,6 +513,10 @@ const LIVE_DEMO_SPECS: Record = { modeset: { image: "shell", features: ["kms"], + // The demo exists to show the fluid sim on card0, so it starts the + // client itself. The shared shell image cannot declare this through + // demo.json — sdl2, wayland, hyprland and omarchy ride the same image. + autoCommand: "/usr/local/bin/modeset", }, sdl2: { image: "shell", @@ -461,6 +530,10 @@ const LIVE_DEMO_SPECS: Record = { image: "shell", features: ["kms"], }, + omarchy: { + image: "shell", + features: ["kms"], + }, }; const DEFAULT_DEMO_FOR_VFS_IMAGE: Record = { @@ -542,6 +615,14 @@ interface LiveProfile { * to fill them. Browser-only page wiring; runs until the shell exits. */ hyprlandDemo: boolean; + /** + * Like hyprlandDemo, plus the desktop shell Omarchy is made of: Waybar on a + * wlr-layer-shell surface reserving the top strip, `klauncher` on + * SUPER/CTRL+Space, and the theme directory the compositor and both clients + * read. This is the O1 milestone of + * docs/plans/2026-07-14-build-hyprland-class-compositor-plan.md. + */ + omarchyDemo: boolean; } interface WebReadinessState { @@ -588,6 +669,7 @@ request_slowlog_trace_depth = 0 const SHELL_ENV: string[] = [ `HOME=${DEMO_HOME}`, "TMPDIR=/tmp", + "XDG_RUNTIME_DIR=/tmp", "TERM=xterm-256color", "LANG=en_US.UTF-8", "PATH=/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin", @@ -858,7 +940,7 @@ export async function createLiveHost( // sdl2) keep the webgl2 default — the GL bridge claims their canvas on // eglCreateContext, and the pump never touches it. h.setKmsDisplayMode( - profile.waylandDemo || profile.hyprlandDemo + profile.waylandDemo || profile.hyprlandDemo || profile.omarchyDemo ? "webgl2-scanout" : null, ); @@ -1038,6 +1120,7 @@ function customVfsProfile( sdl2Demo: false, waylandDemo: false, hyprlandDemo: false, + omarchyDemo: false, }; } @@ -1089,6 +1172,7 @@ function profileFor(id: string, fb?: FbDemo): LiveProfile { sdl2Demo: normalized === "sdl2", waylandDemo: normalized === "wayland", hyprlandDemo: normalized === "hyprland", + omarchyDemo: normalized === "omarchy", }; } @@ -1372,7 +1456,23 @@ async function bootProfile( await stageSdl2Runtime(buildFs); assertCurrent(); } + if (profile.id === "modeset") { + tick("staging modeset..."); + await stageModesetRuntime(buildFs); + assertCurrent(); + } ensureDemoHomes(buildFs); + if (profile.waylandDemo || profile.hyprlandDemo || profile.omarchyDemo) { + tick( + profile.omarchyDemo + ? "staging omarchy binaries..." + : profile.hyprlandDemo + ? "staging hyprland binaries..." + : "staging wayland binaries...", + ); + await stageWaylandDesktopRuntime(buildFs, profile); + assertCurrent(); + } } assertImageTerminalProgram(buildFs, terminalSession.initial); if (terminalSession.afterExit !== undefined) { @@ -1648,67 +1748,19 @@ async function bootProfile( } })(); } else if (profile.waylandDemo) { - // The Wayland desktop needs: all four binaries staged into the VFS, - // the input source attached so keystrokes reach the compositor's - // libinput (event0) and route through to the focused window, the - // compositor spawned as the KMS master (it opens /dev/dri/card0 and - // drives PAGE_FLIP — the Modeset pane picks it up), the two desktop - // clients (wlclock, wlpaint) spawned as floating windows, and - // finally the wlterm client run from bash. dash is already staged by + // The Wayland desktop's binaries are image bytes (staged by + // stageWaylandDesktopRuntime at compose time — the kernel worker owns + // the VFS after boot). This block wires the input source so + // keystrokes reach the compositor's libinput (event0), sizes the + // display, spawns the compositor as the KMS master (it opens + // /dev/dri/card0 and drives PAGE_FLIP — the Modeset pane picks it + // up), the two desktop clients (wlclock, wlpaint) as floating + // windows, and finally the wlterm client. dash is staged by // stageShellUtilities, so wlterm's forkpty'd execvp("dash") resolves // via PATH. The demo runs until the terminal's shell exits. const kernelForWayland = kernel; void (async () => { try { - const compositorUrl = await optionalBinaryUrl([ - "../../../../../local-binaries/programs/wasm32/wlcompositor.wasm", - "../../../../../binaries/programs/wasm32/wlcompositor.wasm", - ], "wlcompositor.wasm"); - const wltermUrl = await optionalBinaryUrl([ - "../../../../../local-binaries/programs/wasm32/wlterm.wasm", - "../../../../../binaries/programs/wasm32/wlterm.wasm", - ], "wlterm.wasm"); - const wlclockUrl = await optionalBinaryUrl([ - "../../../../../local-binaries/programs/wasm32/wlclock.wasm", - "../../../../../binaries/programs/wasm32/wlclock.wasm", - ], "wlclock.wasm"); - const wlpaintUrl = await optionalBinaryUrl([ - "../../../../../local-binaries/programs/wasm32/wlpaint.wasm", - "../../../../../binaries/programs/wasm32/wlpaint.wasm", - ], "wlpaint.wasm"); - tick("staging wayland binaries..."); - const [compBytes, termBytes, clockBytes, paintBytes] = await Promise.all([ - fetch(compositorUrl).then(failOn("wlcompositor.wasm")).then((r) => r.arrayBuffer()), - fetch(wltermUrl).then(failOn("wlterm.wasm")).then((r) => r.arrayBuffer()), - fetch(wlclockUrl).then(failOn("wlclock.wasm")).then((r) => r.arrayBuffer()), - fetch(wlpaintUrl).then(failOn("wlpaint.wasm")).then((r) => r.arrayBuffer()), - ]); - ensureDirRecursive(kernelForWayland.fs, "/usr/local/bin"); - writeVfsBinary( - kernelForWayland.fs, - "/usr/local/bin/wlcompositor", - new Uint8Array(compBytes), - 0o755, - ); - writeVfsBinary( - kernelForWayland.fs, - "/usr/local/bin/wlterm", - new Uint8Array(termBytes), - 0o755, - ); - writeVfsBinary( - kernelForWayland.fs, - "/usr/local/bin/wlclock", - new Uint8Array(clockBytes), - 0o755, - ); - writeVfsBinary( - kernelForWayland.fs, - "/usr/local/bin/wlpaint", - new Uint8Array(paintBytes), - 0o755, - ); - // Keyboard on event0 → compositor libinput → wl_keyboard → // wlterm → dash. The POINTER is owned by the Modeset pane // (sendPointerAbs → event1), so disable this source's pointer @@ -1735,25 +1787,10 @@ async function bootProfile( // embedder) the mode stays the 1920×1080 default and the // presenter letterboxes. tick("sizing display mode..."); - const sizeDeadline = performance.now() + 1500; - let displaySize = host.getKmsDisplaySize(1); - while (!displaySize && performance.now() < sizeDeadline) { - const paneCanvas = document.querySelector( - ".kmachine-primary-slot:not(.is-hidden) canvas", - ); - const rect = paneCanvas?.getBoundingClientRect(); - if (rect && rect.width >= 1 && rect.height >= 1) { - const dpr = window.devicePixelRatio || 1; - displaySize = { width: rect.width * dpr, height: rect.height * dpr }; - kernelForWayland.kmsSetDisplaySize( - 1, - displaySize.width, - displaySize.height, - ); - break; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - displaySize = host.getKmsDisplaySize(1); + const outputScale = outputScaleFor(window.devicePixelRatio || 1); + if (!host.getKmsDisplaySize(1)) { + const pane = await settledPaneDeviceSize(); + if (pane) kernelForWayland.kmsSetDisplaySize(1, pane.width, pane.height); } // Spawn the compositor in the background. All clients retry @@ -1761,37 +1798,37 @@ async function bootProfile( // compositor not yet having bound the socket — no explicit // barrier needed. tick("running wlcompositor..."); - const spawnBg = (bytes: ArrayBuffer, name: string) => - void kernelForWayland.spawn(bytes, [name], { - env: SHELL_ENV, + const spawnBg = (name: string, extraEnv: string[] = []) => + void kernelForWayland.spawnFromVfs(`/usr/local/bin/${name}`, [name], { + env: extraEnv.length ? [...SHELL_ENV, ...extraEnv] : SHELL_ENV, cwd: DEMO_HOME, uid: DEMO_UID, gid: DEMO_GID, - }).then( + }).then(({ exit }) => exit).then( () => tick(`${name} exited`), (err: unknown) => tick(`${name} failed: ${err instanceof Error ? err.message : String(err)}`), ); - spawnBg(compBytes, "wlcompositor"); + spawnBg("wlcompositor", [`WLC_SCALE=${outputScale}`]); tick("running wlclock + wlpaint..."); - spawnBg(clockBytes, "wlclock"); - spawnBg(paintBytes, "wlpaint"); + spawnBg("wlclock"); + spawnBg("wlpaint"); tick("running wlterm..."); // Keep-alive foreground client. Launch it through the non-forking - // `spawn` path (like the clock + paint clients) instead of + // spawn path (like the clock + paint clients) instead of // runShellCommand, which makes the pts/0 shell fork()+exec the client. // That shell-fork intermittently fails to start the client under CI's // Linux headless-chromium worker scheduling, so it never connects - // (CLIENT_CONNECTED count=3 never fires). `spawn` resolves on process - // EXIT (it is used as an exitPromise in kernel-host.ts), so awaiting - // it keeps the demo alive exactly as the foreground shell command did. - await kernelForWayland.spawn(termBytes, ["wlterm"], { + // (CLIENT_CONNECTED count=3 never fires). The exit promise resolves on + // process EXIT, so awaiting it keeps the demo alive exactly as the + // foreground shell command did. + await kernelForWayland.spawnFromVfs("/usr/local/bin/wlterm", ["wlterm"], { env: SHELL_ENV, cwd: DEMO_HOME, uid: DEMO_UID, gid: DEMO_GID, - }).then( + }).then(({ exit }) => exit).then( () => tick("wlterm exited"), (err: unknown) => tick(`wlterm failed: ${err instanceof Error ? err.message : String(err)}`), @@ -1801,74 +1838,24 @@ async function bootProfile( tick(`wayland failed: ${msg}`); } })(); - } else if (profile.hyprlandDemo) { + } else if (profile.hyprlandDemo || profile.omarchyDemo) { // Like waylandDemo but with WLC_LAYOUT=dwindle: the compositor tiles its // clients and dictates each one's size via xdg configure, which the // libkwl/vt100 clients honor by rebuilding at the tile size (KWL_RESIZE). // That client-side resize is the crux — floating clients never resize. + // + // The omarchy demo is the same desktop with its shell on top: Waybar on + // a layer-shell surface, klauncher on SUPER+Space, and the theme set. + // Only the extra staging and the extra binaries differ, so it shares this + // path rather than duplicating the KMS sizing and spawn ordering. + const omarchy = profile.omarchyDemo; const kernelForHyprland = kernel; void (async () => { try { - const compositorUrl = await optionalBinaryUrl([ - "../../../../../local-binaries/programs/wasm32/wlcompositor.wasm", - "../../../../../binaries/programs/wasm32/wlcompositor.wasm", - ], "wlcompositor.wasm"); - const wltermUrl = await optionalBinaryUrl([ - "../../../../../local-binaries/programs/wasm32/wlterm.wasm", - "../../../../../binaries/programs/wasm32/wlterm.wasm", - ], "wlterm.wasm"); - const wlclockUrl = await optionalBinaryUrl([ - "../../../../../local-binaries/programs/wasm32/wlclock.wasm", - "../../../../../binaries/programs/wasm32/wlclock.wasm", - ], "wlclock.wasm"); - // wlpaint is staged so the CTRL+P launch bind can exec it on demand; - // unlike the wayland demo it is not auto-spawned into the initial - // layout (the user opens it via the keybind, the "new pane" flow). - const wlpaintUrl = await optionalBinaryUrl([ - "../../../../../local-binaries/programs/wasm32/wlpaint.wasm", - "../../../../../binaries/programs/wasm32/wlpaint.wasm", - ], "wlpaint.wasm"); - tick("staging hyprland binaries..."); - const [compBytes, termBytes, clockBytes, paintBytes] = await Promise.all([ - fetch(compositorUrl).then(failOn("wlcompositor.wasm")).then((r) => r.arrayBuffer()), - fetch(wltermUrl).then(failOn("wlterm.wasm")).then((r) => r.arrayBuffer()), - fetch(wlclockUrl).then(failOn("wlclock.wasm")).then((r) => r.arrayBuffer()), - fetch(wlpaintUrl).then(failOn("wlpaint.wasm")).then((r) => r.arrayBuffer()), - ]); - ensureDirRecursive(kernelForHyprland.fs, "/usr/local/bin"); - writeVfsBinary( - kernelForHyprland.fs, - "/usr/local/bin/wlcompositor", - new Uint8Array(compBytes), - 0o755, - ); - writeVfsBinary( - kernelForHyprland.fs, - "/usr/local/bin/wlterm", - new Uint8Array(termBytes), - 0o755, - ); - writeVfsBinary( - kernelForHyprland.fs, - "/usr/local/bin/wlclock", - new Uint8Array(clockBytes), - 0o755, - ); - writeVfsBinary( - kernelForHyprland.fs, - "/usr/local/bin/wlpaint", - new Uint8Array(paintBytes), - 0o755, - ); - - ensureDirRecursive(kernelForHyprland.fs, "/etc/kandelo"); - writeVfsFile( - kernelForHyprland.fs, - "/etc/kandelo/wlcompositor.conf", - HYPRLAND_WLCOMPOSITOR_CONF, - 0o644, - ); - + // The desktop's binaries, configs, themes, and fonts are image + // bytes (staged by stageWaylandDesktopRuntime at compose time — + // the kernel worker owns the VFS after boot). This block wires + // input, sizes the display, and spawns the desktop. // Pointer is owned by the Modeset pane (event1); feed keyboard only. tick("attaching input source..."); const WL_FB_W = 1920; @@ -1881,75 +1868,142 @@ async function bootProfile( // Size the desktop from the Modeset pane's canvas exactly like // the wayland demo (see that block for the rationale). tick("sizing display mode..."); - const sizeDeadline = performance.now() + 1500; - let displaySize = host.getKmsDisplaySize(1); - while (!displaySize && performance.now() < sizeDeadline) { - const paneCanvas = document.querySelector( - ".kmachine-primary-slot:not(.is-hidden) canvas", - ); - const rect = paneCanvas?.getBoundingClientRect(); - if (rect && rect.width >= 1 && rect.height >= 1) { - const dpr = window.devicePixelRatio || 1; - displaySize = { width: rect.width * dpr, height: rect.height * dpr }; - kernelForHyprland.kmsSetDisplaySize( - 1, - displaySize.width, - displaySize.height, - ); - break; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - displaySize = host.getKmsDisplaySize(1); + const outputScale = outputScaleFor(window.devicePixelRatio || 1); + if (!host.getKmsDisplaySize(1)) { + const pane = await settledPaneDeviceSize(); + if (pane) kernelForHyprland.kmsSetDisplaySize(1, pane.width, pane.height); } // Clients retry their connect to /tmp/wayland-0, so the compositor // and clients can be spawned without an ordering barrier. tick("running wlcompositor..."); - const spawnBg = (bytes: ArrayBuffer, name: string, extraEnv: string[] = []) => - void kernelForHyprland.spawn(bytes, [name], { + const spawnBg = ( + path: string, + name: string, + extraEnv: string[] = [], + args: string[] = [], + ) => + kernelForHyprland.spawnFromVfs(path, [name, ...args], { env: extraEnv.length ? [...SHELL_ENV, ...extraEnv] : SHELL_ENV, cwd: DEMO_HOME, uid: DEMO_UID, gid: DEMO_GID, - }).then( + }).then(({ exit }) => exit).then( () => tick(`${name} exited`), (err: unknown) => tick(`${name} failed: ${err instanceof Error ? err.message : String(err)}`), ); - spawnBg(compBytes, "wlcompositor", [ + // A gated client connects to its sockets exactly once (no retry), + // so it starts behind a guest-side wait for them. The wait runs in + // the guest because the kernel worker owns the VFS — the main + // thread has no handle to poll a socket path. The gate gives up + // after 30 seconds and execs anyway, so a desktop that failed to + // bind surfaces as the client's own connect error in the syslog. + const spawnGated = ( + name: string, + sockets: string[], + extraEnv: string[] = [], + args: string[] = [], + seed = "", + ) => { + const gate = sockets.map((path) => `[ -S ${path} ]`).join(" && "); + const command = [`/usr/local/bin/${name}`, ...args].join(" "); + return spawnBg("/bin/bash", name, extraEnv, [ + "-c", + `until ${gate} || (( n++ >= 300 )); do sleep 0.1; done; ${seed}exec ${command}`, + ]); + }; + // Every omarchy desktop process gets the session bus address: the + // compositor passes it on to everything it execs (notify-send on a + // theme switch, launched terminals), and the terminals' shells can + // run notify-send themselves. + const busEnv = omarchy + ? [`DBUS_SESSION_BUS_ADDRESS=unix:path=${OMARCHY_BUS_SOCKET}`] + : []; + const compositorExit = spawnBg("/usr/local/bin/wlcompositor", "wlcompositor", [ "WLC_LAYOUT=dwindle", "WLC_CONFIG=/etc/kandelo/wlcompositor.conf", + `WLC_SCALE=${outputScale}`, + ...busEnv, ]); - // The clock + first terminal run in the background; the foreground - // terminal's shell keeps the demo alive (as waylandDemo does). - tick("running wlclock + wlterm..."); - spawnBg(clockBytes, "wlclock"); - spawnBg(termBytes, "wlterm"); + if (omarchy) { + // The notification stack: the session bus, then mako on it. mako + // connects to the compositor and the bus exactly once (stock + // wl_display_connect + sd-bus, no retry), so it waits for both + // sockets in the guest. + tick("running dbus-daemon + mako..."); + void spawnBg("/usr/local/bin/dbus-daemon", "dbus-daemon", [], [ + "--config-file=/etc/dbus-1/session.conf", + "--nofork", + ]); + void spawnGated("mako", ["/tmp/wayland-0", OMARCHY_BUS_SOCKET], busEnv, [ + "--config", OMARCHY_MAKO_CONFIG_PATH, + ]); + + // Waybar connects to the display and the Hyprland IPC pair + // exactly once (GTK does not retry). It is also a + // Gtk::Application, so g_application_register needs the session + // bus: without it GIO tries to autolaunch one and exits on the + // missing machine-id. Gate on all three sockets. + tick("running waybar..."); + // `-l debug` is what makes the bar legible in the Internals + // syslog: Waybar logs every Hyprland IPC event it receives at + // debug level, so a workspace switch or a window focus shows + // up as the bar's own line next to the compositor's marker. + void spawnGated("waybar", [ + "/tmp/wayland-0", + "/tmp/hypr/wlcompositor/.socket.sock", + OMARCHY_BUS_SOCKET, + ], [ + "HYPRLAND_INSTANCE_SIGNATURE=wlcompositor", + ...busEnv, + ], [ + "-l", "debug", + "-c", OMARCHY_WAYBAR_CONFIG_PATH, + "-s", OMARCHY_WAYBAR_STYLE_PATH, + ], `printf '%s' "$(<${OMARCHY_WAYBAR_STYLE_SEED_PATH})" > ${OMARCHY_WAYBAR_STYLE_PATH}; `); + } - tick("running wlterm..."); - // Keep-alive 3rd tiling client. Launch it through the non-forking - // `spawn` path (like the clock + first terminal) instead of - // runShellCommand, which makes the pts/0 shell fork()+exec the client. - // That shell-fork races the first terminal's forkpty under CI's Linux - // headless-chromium worker scheduling and intermittently fails to - // start the client, so it never connects (CLIENT_CONNECTED count=3 - // never fires). `spawn` resolves on process EXIT (it is used as an - // exitPromise in kernel-host.ts), so awaiting it keeps the demo alive - // exactly as the foreground shell command did. - await kernelForHyprland.spawn(termBytes, ["wlterm"], { - env: SHELL_ENV, - cwd: DEMO_HOME, - uid: DEMO_UID, - gid: DEMO_GID, - }).then( - () => tick("wlterm exited"), - (err: unknown) => - tick(`wlterm failed: ${err instanceof Error ? err.message : String(err)}`), - ); + if (omarchy) { + // The Omarchy desktop comes up bare — wallpaper and bar, no + // windows. Its clients are the ones the user opens, through the + // binds (CTRL+Return, CTRL+K) or the launcher. The compositor is + // the process whose lifetime is the desktop's, so awaiting it is + // what keeps the demo alive. + tick("omarchy desktop ready"); + await compositorExit; + } else { + // The clock + first terminal run in the background; the foreground + // terminal's shell keeps the demo alive (as waylandDemo does). + tick("running wlclock + wlterm..."); + void spawnBg("/usr/local/bin/wlclock", "wlclock"); + void spawnBg("/usr/local/bin/wlterm", "wlterm", busEnv); + + tick("running wlterm..."); + // Keep-alive 3rd tiling client. Launch it through the non-forking + // spawn path (like the clock + first terminal) instead of + // runShellCommand, which makes the pts/0 shell fork()+exec the client. + // That shell-fork races the first terminal's forkpty under CI's Linux + // headless-chromium worker scheduling and intermittently fails to + // start the client, so it never connects (CLIENT_CONNECTED count=3 + // never fires). The exit promise resolves on process EXIT, so + // awaiting it keeps the demo alive exactly as the foreground shell + // command did. + await kernelForHyprland.spawnFromVfs("/usr/local/bin/wlterm", ["wlterm"], { + env: busEnv.length ? [...SHELL_ENV, ...busEnv] : SHELL_ENV, + cwd: DEMO_HOME, + uid: DEMO_UID, + gid: DEMO_GID, + }).then(({ exit }) => exit).then( + () => tick("wlterm exited"), + (err: unknown) => + tick(`wlterm failed: ${err instanceof Error ? err.message : String(err)}`), + ); + } } catch (err) { const msg = err instanceof Error ? err.message : String(err); - tick(`hyprland failed: ${msg}`); + tick(`${omarchy ? "omarchy" : "hyprland"} failed: ${msg}`); } })(); } else if (presentation?.autoCommand) { @@ -2027,6 +2081,24 @@ function stageShellUtilities( } } +/** + * Bake the modeset fluid sim at `/usr/local/bin/modeset`. The published + * shell image delivers it as a lazy Homebrew formula; a locally built + * image has no bottle catalog, so the demo stages the resolved package + * binary the same way the other KMS demos stage theirs. + */ +async function stageModesetRuntime(fs: MemoryFileSystem): Promise { + const url = await optionalBinaryUrl([ + "../../../../../local-binaries/programs/wasm32/modeset.wasm", + "../../../../../binaries/programs/wasm32/modeset.wasm", + ], "modeset.wasm"); + const bytes = await fetch(url) + .then(failOn("modeset.wasm")) + .then((r) => r.arrayBuffer()); + ensureDirRecursive(fs, "/usr/local/bin"); + writeVfsBinary(fs, "/usr/local/bin/modeset", new Uint8Array(bytes), 0o755); +} + /** * Bake the SDL2 GLSL playground and its shader presets into the image. * @@ -2065,6 +2137,140 @@ async function stageSdl2Runtime(fs: MemoryFileSystem): Promise { writeVfsFile(fs, "/usr/share/shaders/sound/chord.frag", sdl2SoundChordFragSrc); } +/** The integer `wl_output` scale the desktop runs at. The mode is sized in + * device pixels, so without this the compositor would read a dpr-2 pane as a + * dpr-1 one twice the size and lay out windows at half their intended size. + * wl_output.scale has no fractional form, so a fractional dpr rounds. */ +function outputScaleFor(dpr: number): number { + return Math.max(1, Math.min(3, Math.round(dpr))); +} + +/** The pane's device-pixel size, once its layout has settled. + * + * Measures the pane SLOT, not the canvas inside it. `useFittedCanvasStyle` + * sizes the canvas to the slot at the CANVAS's own aspect, and that aspect + * comes from the backing store the mode sizes — so measuring the canvas feeds + * the mode back into itself and pins it to whatever the first sample caught. + * The slot is laid out by flex alone, so it is a fixed point. + * + * Requires two equal consecutive readings, so a mid-layout frame is not what + * the connector mode is built from. Returns null when the pane never settles + * (hidden slot, headless embedder), which leaves the connector on its + * 1920x1080 default. */ +async function settledPaneDeviceSize( + timeoutMs = 4_000, +): Promise<{ width: number; height: number } | null> { + const deadline = performance.now() + timeoutMs; + let previous = ""; + while (performance.now() < deadline) { + const slot = document.querySelector( + ".kmachine-primary-slot:not(.is-hidden)", + ); + const rect = slot?.getBoundingClientRect(); + if (rect && rect.width >= 1 && rect.height >= 1) { + const key = `${Math.round(rect.width)}x${Math.round(rect.height)}`; + if (key === previous) { + const dpr = window.devicePixelRatio || 1; + return { + width: Math.round(rect.width * dpr), + height: Math.round(rect.height * dpr), + }; + } + previous = key; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return null; +} + +/** + * Bake the Wayland desktop stack into the image: the compositor and its + * clients for every desktop demo, the compositor config for the tiling + * demos, and the Omarchy shell (bar, launcher, notification stack, themes, + * fonts, app registry) on top. The kernel worker owns the VFS after boot, + * so everything a desktop process reads from disk must be image bytes. + */ +async function stageWaylandDesktopRuntime( + fs: MemoryFileSystem, + profile: LiveProfile, +): Promise { + const fetchBinary = async (relPath: string, label: string) => { + const url = await optionalBinaryUrl([ + `../../../../../local-binaries/programs/wasm32/${relPath}`, + `../../../../../binaries/programs/wasm32/${relPath}`, + ], label); + return fetch(url).then(failOn(label)).then((r) => r.arrayBuffer()); + }; + const [compBytes, termBytes, clockBytes, paintBytes] = await Promise.all([ + fetchBinary("wldesktop/wlcompositor.wasm", "wlcompositor.wasm"), + fetchBinary("wldesktop/wlterm.wasm", "wlterm.wasm"), + fetchBinary("wldesktop/wlclock.wasm", "wlclock.wasm"), + fetchBinary("wldesktop/wlpaint.wasm", "wlpaint.wasm"), + ]); + ensureDirRecursive(fs, "/usr/local/bin"); + writeVfsBinary(fs, "/usr/local/bin/wlcompositor", new Uint8Array(compBytes), 0o755); + writeVfsBinary(fs, "/usr/local/bin/wlterm", new Uint8Array(termBytes), 0o755); + writeVfsBinary(fs, "/usr/local/bin/wlclock", new Uint8Array(clockBytes), 0o755); + writeVfsBinary(fs, "/usr/local/bin/wlpaint", new Uint8Array(paintBytes), 0o755); + if (profile.waylandDemo) return; + + ensureDirRecursive(fs, "/etc/kandelo"); + writeVfsFile( + fs, + OMARCHY_CONF_PATH, + profile.omarchyDemo ? OMARCHY_WLCOMPOSITOR_CONF : HYPRLAND_WLCOMPOSITOR_CONF, + 0o644, + ); + if (!profile.omarchyDemo) return; + + const [barBytes, launcherBytes, daemonBytes, makoBytes, + notifySendBytes, footBytes, fontBytes] = await Promise.all([ + fetchBinary("waybar.wasm", "waybar.wasm"), + fetchBinary("wldesktop/klauncher.wasm", "klauncher.wasm"), + fetchBinary("dbus/dbus-daemon.wasm", "dbus-daemon.wasm"), + fetchBinary("mako/mako.wasm", "mako.wasm"), + fetchBinary("wldesktop/notify-send.wasm", "notify-send.wasm"), + fetchBinary("foot.wasm", "foot.wasm"), + fetch(inconsolataFontUrl).then(failOn("Inconsolata-Regular.ttf")) + .then((r) => r.arrayBuffer()), + ]); + writeVfsBinary(fs, "/usr/local/bin/waybar", new Uint8Array(barBytes), 0o755); + // /home/maker is a scratch mount, so the desktop's configs cannot be image + // bytes under $HOME — they live under /usr/share/kandelo and the clients + // are pointed at them (waybar -c/-s, mako --config). The stylesheet the + // theme hook rewrites is seeded onto /tmp by the bar's start gate. + ensureDirRecursive(fs, dirname(OMARCHY_WAYBAR_CONFIG_PATH)); + writeVfsFile(fs, OMARCHY_WAYBAR_CONFIG_PATH, OMARCHY_WAYBAR_CONFIG, 0o644); + writeVfsFile(fs, OMARCHY_WAYBAR_STYLE_SEED_PATH, OMARCHY_WAYBAR_STYLE, 0o644); + writeVfsBinary(fs, "/usr/local/bin/klauncher", new Uint8Array(launcherBytes), 0o755); + writeVfsBinary(fs, "/usr/local/bin/dbus-daemon", new Uint8Array(daemonBytes), 0o755); + writeVfsBinary(fs, "/usr/local/bin/mako", new Uint8Array(makoBytes), 0o755); + writeVfsBinary(fs, "/usr/local/bin/notify-send", new Uint8Array(notifySendBytes), 0o755); + writeVfsFile(fs, "/usr/local/bin/omarchy-theme-changed", OMARCHY_THEME_HOOK, 0o755); + writeVfsBinary(fs, "/usr/local/bin/foot", new Uint8Array(footBytes), 0o755); + ensureDirRecursive(fs, "/etc/dbus-1"); + writeVfsFile(fs, "/etc/dbus-1/session.conf", OMARCHY_DBUS_SESSION_CONF, 0o644); + ensureDirRecursive(fs, dirname(OMARCHY_MAKO_CONFIG_PATH)); + writeVfsFile(fs, OMARCHY_MAKO_CONFIG_PATH, OMARCHY_MAKO_CONFIG, 0o644); + ensureDirRecursive(fs, "/usr/share/fonts"); + writeVfsBinary(fs, "/usr/share/fonts/Inconsolata-Regular.ttf", + new Uint8Array(fontBytes), 0o644); + ensureDirRecursive(fs, "/etc/fonts"); + writeVfsFile(fs, "/etc/fonts/fonts.conf", OMARCHY_FONTS_CONF, 0o644); + ensureDirRecursive(fs, OMARCHY_APPS_DIR); + for (const [name, body] of Object.entries(OMARCHY_APPS)) + writeVfsFile(fs, `${OMARCHY_APPS_DIR}/${name}`, body, 0o644); + for (const [name, theme] of Object.entries(OMARCHY_THEMES)) { + ensureDirRecursive(fs, `${OMARCHY_THEME_DIR}/${name}`); + writeVfsFile(fs, `${OMARCHY_THEME_DIR}/${name}/theme.conf`, theme.conf, 0o644); + const image = theme.wallpaper.image + ? await renderImageWallpaperKwlp(theme.wallpaper.image) + : null; + writeVfsBinary(fs, `${OMARCHY_THEME_DIR}/${name}/background.kwlp`, + image ?? renderWallpaperKwlp(theme), 0o644); + } +} + function ensureDemoHomes(fs: MemoryFileSystem): void { ensureDirRecursive(fs, "/home"); ensureOwnedDir(fs, DEMO_HOME, 0o755, DEMO_UID, DEMO_GID); diff --git a/apps/browser-demos/pages/kandelo/kernel-host/omarchy-desktop.ts b/apps/browser-demos/pages/kandelo/kernel-host/omarchy-desktop.ts new file mode 100644 index 0000000000..114bc6fe54 --- /dev/null +++ b/apps/browser-demos/pages/kandelo/kernel-host/omarchy-desktop.ts @@ -0,0 +1,569 @@ +/** + * The data half of the /?demo=omarchy desktop: the compositor config, the + * launcher's app registry, and the themes. + * + * Omarchy is not a program — it is an opinionated set of files layered over + * Hyprland: keybindings, a bar, a launcher, and a theme directory switched by + * re-linking one entry. This module is that layer for Kandelo: every file here + * lands in the VFS at boot, and the compositor + Waybar + klauncher read them + * at runtime exactly as they would read an installed desktop. + * + * Palettes are the well-known upstream colour schemes Omarchy ships + * (Tokyo Night, Catppuccin Mocha, Gruvbox, Nord, Everforest, Rosé Pine); the + * keys are ours. Each theme also carries its wallpaper — the real Omarchy + * background image plus an aurora fallback spec. The page renders it to raw + * pixels at staging time because nothing in the compositor decodes PNG/JPEG — + * it reads the KWLP raw format and crops and scales it to the output. + */ + +import tokyoNightWallpaperUrl from "../assets/tokyo-night-sunset-lake.jpg"; +import catppuccinWallpaperUrl from "../assets/catppuccin-totoro.jpg"; +import gruvboxWallpaperUrl from "../assets/gruvbox-the-backwater.jpg"; +import nordWallpaperUrl from "../assets/nord-black-moon.jpg"; +import everforestWallpaperUrl from "../assets/everforest-tree-tops.jpg"; +import rosePineWallpaperUrl from "../assets/rose-pine-funky-shapes.jpg"; + +/** Where the desktop's files live in the VFS. */ +export const OMARCHY_CONF_PATH = "/etc/kandelo/wlcompositor.conf"; +export const OMARCHY_THEME_DIR = "/usr/share/kandelo/themes"; +export const OMARCHY_APPS_DIR = "/usr/share/kandelo/apps"; +// /home/maker is a scratch mount (host/src/vfs/default-mounts.ts), so image +// bytes under it are shadowed at boot. The desktop's read-only configs are +// image bytes under /usr/share/kandelo, and the one file the theme hook +// rewrites — Waybar's stylesheet — lives on the /tmp scratch mount, seeded +// from its image copy by the bash gate that starts the bar. +export const OMARCHY_WAYBAR_CONFIG_PATH = "/usr/share/kandelo/waybar/config.jsonc"; +export const OMARCHY_WAYBAR_STYLE_SEED_PATH = "/usr/share/kandelo/waybar/style.css"; +export const OMARCHY_WAYBAR_STYLE_PATH = "/tmp/waybar-style.css"; +export const OMARCHY_MAKO_CONFIG_PATH = "/usr/share/kandelo/mako/config"; + +/** + * The compositor config. SUPER is what real Hyprland (and Omarchy) binds, but + * a browser reserves it for the OS (Cmd/Win), so every action is mirrored on + * CTRL — the modifier that actually reaches the page. The compositor grabs a + * bound combo before the focused client, so CTRL+W here shadows a terminal's + * werase (see docs/browser-support.md). + */ +export const OMARCHY_WLCOMPOSITOR_CONF = `# Kandelo wlcompositor — the Omarchy-shaped desktop. +theme = tokyo-night +notify = /usr/bin/bash /usr/local/bin/omarchy-theme-changed + +# Applications. +bind = SUPER, Return, exec, /usr/local/bin/wlterm +bind = CTRL, Return, exec, /usr/local/bin/wlterm +bind = SUPER, K, exec, /usr/local/bin/wlclock +bind = CTRL, K, exec, /usr/local/bin/wlclock +bind = SUPER, P, exec, /usr/local/bin/wlpaint +bind = CTRL, P, exec, /usr/local/bin/wlpaint + +# The launcher, on Omarchy's SUPER+Space. +bind = SUPER, space, exec, /usr/local/bin/klauncher +bind = CTRL, space, exec, /usr/local/bin/klauncher + +# The Omarchy menu, on Omarchy's SUPER+ALT+Space. +bind = SUPER ALT, space, exec, /usr/local/bin/klauncher --menu +bind = CTRL ALT, space, exec, /usr/local/bin/klauncher --menu + +# Window management. +bind = SUPER, W, killactive +bind = CTRL, W, killactive +bind = SUPER, J, cyclenext +bind = CTRL, J, cyclenext +bind = SUPER SHIFT, J, cycleprev +bind = CTRL SHIFT, J, cycleprev + +# Theme cycling, on Omarchy's SUPER+CTRL+SHIFT+Space. +bind = SUPER CTRL SHIFT, space, theme, next +bind = CTRL SHIFT, space, theme, next + +# Workspaces. +bind = SUPER, 1, workspace, 1 +bind = SUPER, 2, workspace, 2 +bind = SUPER, 3, workspace, 3 +bind = SUPER, 4, workspace, 4 +bind = SUPER, 5, workspace, 5 +bind = SUPER, 6, workspace, 6 +bind = SUPER, 7, workspace, 7 +bind = SUPER, 8, workspace, 8 +bind = SUPER, 9, workspace, 9 +bind = CTRL, 1, workspace, 1 +bind = CTRL, 2, workspace, 2 +bind = CTRL, 3, workspace, 3 +bind = CTRL, 4, workspace, 4 +bind = CTRL, 5, workspace, 5 +bind = CTRL, 6, workspace, 6 +bind = CTRL, 7, workspace, 7 +bind = CTRL, 8, workspace, 8 +bind = CTRL, 9, workspace, 9 +bind = SUPER SHIFT, 1, movetoworkspace, 1 +bind = SUPER SHIFT, 2, movetoworkspace, 2 +bind = SUPER SHIFT, 3, movetoworkspace, 3 +bind = CTRL SHIFT, 1, movetoworkspace, 1 +bind = CTRL SHIFT, 2, movetoworkspace, 2 +bind = CTRL SHIFT, 3, movetoworkspace, 3 +`; + +/** + * The launcher registry: one file per application. A package installs itself + * into the launcher by dropping a file here, which is why this is a directory + * and not a list inside the launcher. + */ +export const OMARCHY_APPS: Record = { + "terminal.conf": "name = Terminal\nexec = /usr/local/bin/wlterm\n", + "clock.conf": "name = Clock\nexec = /usr/local/bin/wlclock\n", + "paint.conf": "name = Paint\nexec = /usr/local/bin/wlpaint\n", + "vim.conf": "name = Vim\nexec = /usr/local/bin/wlterm /usr/bin/vim\n", + "nethack.conf": "name = NetHack\nexec = /usr/local/bin/wlterm /usr/bin/nethack\n", + "nano.conf": "name = Nano\nexec = /usr/local/bin/wlterm /usr/bin/nano\n", + "bash.conf": "name = Bash\nexec = /usr/local/bin/wlterm /usr/bin/bash -i\n", + "foot.conf": + "name = Foot\nexec = /usr/local/bin/foot --term=vt100 --override=main.workers=0 /usr/bin/bash -i\n", +}; + +/** + * The fontconfig configuration foot reads at startup. The demo stages one + * font (Inconsolata) under /usr/share/fonts and aliases the generic + * "monospace" family to it, so foot's default font pattern resolves without + * a per-user configuration. + */ +export const OMARCHY_FONTS_CONF = ` + + + /usr/share/fonts + /tmp/fontconfig + + monospace + Inconsolata + + +`; + +/** The session bus socket every desktop process shares: dbus-daemon listens + * on it, mako owns org.freedesktop.Notifications on it, and notify-send + * (spawned by the compositor's `notify =` hook, or from any terminal) calls + * Notify there. */ +export const OMARCHY_BUS_SOCKET = "/tmp/dbus-session.socket"; + +/** + * The dbus-daemon session config: EXTERNAL auth (SO_PEERCRED) with an + * allow-all policy — the single-user demo bus, not a hardened system bus. + */ +export const OMARCHY_DBUS_SESSION_CONF = ` + session + unix:path=${OMARCHY_BUS_SOCKET} + EXTERNAL + + + + + + +`; + +/** + * mako's config. Upstream mako keeps a notification until dismissed + * (default-timeout=0); the demo's toasts dismiss themselves like Omarchy's. + * The colours are the tokyo-night palette the desktop boots with — mako + * reads its config once at startup, so they persist across theme switches. + */ +export const OMARCHY_MAKO_CONFIG = `default-timeout=5000 +background-color=#1a1b26 +text-color=#c0caf5 +border-color=#7aa2f7 +`; + +/** + * Waybar config, translated from Omarchy's (config.jsonc): same top bar + * with workspaces left and the clock centered. The module lineup keeps + * only what this kernel backs — no /proc/stat, /sys battery, libnl + * network, or pulseaudio — and the Nerd Font glyphs become plain text + * (the demo stages Inconsolata only). The hyprland modules speak + * Hyprland IPC against wlcompositor's socket pair. + */ +export const OMARCHY_WAYBAR_CONFIG = `{ + "layer": "top", + "position": "top", + "spacing": 0, + "height": 26, + "modules-left": ["hyprland/workspaces"], + "modules-center": ["clock"], + "modules-right": ["hyprland/window"], + "hyprland/workspaces": { + "on-click": "activate", + "format": "{name}", + "persistent-workspaces": { "1": [], "2": [], "3": [], "4": [], "5": [] } + }, + "hyprland/window": { "format": "{title}", "max-length": 60 }, + "clock": { "format": "{:%H:%M:%S}", "interval": 1, "tooltip": false } +} +`; + +/** + * Waybar stylesheet: Omarchy's style.css shape over one palette. The theme + * hook renders the same template from the switched-to theme.conf, so the two + * paths — the file staged at boot and the file rewritten on a switch — cannot + * drift apart. + */ +const waybarStyle = (c: Record) => `* { + font-family: monospace; + font-size: 13px; + min-height: 0; +} +window#waybar { + background: ${c.bar}; + color: ${c.foreground}; +} +#workspaces button { + padding: 0 6px; + border: none; + border-radius: 0; + background: transparent; + color: ${c.muted}; +} +#workspaces button.active { + color: ${c.accent}; +} +#workspaces button.empty { + color: ${c.occupied}; +} +#window, +#clock { + padding: 0 8px; + color: ${c.foreground}; +} +`; + +/** The stylesheet staged at boot, on the theme the compositor config names. */ +export const OMARCHY_WAYBAR_STYLE = waybarStyle({ + bar: "#16161e", + foreground: "#c0caf5", + muted: "#565f89", + accent: "#7aa2f7", + occupied: "#292e42", +}); + +/** + * The compositor's `notify =` hook, which is where Omarchy puts the rest of a + * theme switch: its omarchy-theme-set writes the bar's stylesheet from the new + * palette and sends Waybar SIGUSR2, then notifies. The compositor repaints its + * own half (borders, gaps, wallpaper) and appends `Theme ` to this + * command, so `$2` is the theme that just became current. + * + * Bash builtins only, plus the final exec: a fork from the compositor's hook + * would cost a whole process image for a file write. `/proc//cmdline` + * stands in for pkill, which this image does not carry. + * + * The signal reaches Waybar while its threads sit in `poll()` and `read()`: + * the host ends the park with EINTR and the glue re-issues the syscall, so the + * handler runs and the bar reloads the new stylesheet in place. The whole + * switch is live. + */ +export const OMARCHY_THEME_HOOK = `#!/usr/bin/bash +theme=$2 +conf=\${WLC_THEME_DIR:-${OMARCHY_THEME_DIR}}/$theme/theme.conf +css=${OMARCHY_WAYBAR_STYLE_PATH} + +if [ -r "$conf" ]; then + while read -r key sep val; do + case $key in + bar|foreground|muted|accent|occupied) printf -v "c_$key" '#%s' "\${val#0x}" ;; + esac + done < "$conf" + + printf '%s' "${waybarStyle({ + bar: "$c_bar", + foreground: "$c_foreground", + muted: "$c_muted", + accent: "$c_accent", + occupied: "$c_occupied", + })}" > "$css" + + # /proc//cmdline separates the arguments with NUL and ends with one, so + # -d '' stops read at argv[0] alone. The bar reaches its own exec through the + # socket gate's \`exec /usr/local/bin/waybar\`, which leaves argv[0] absolute. + for d in /proc/[0-9]*; do + cmd= + read -r -d '' cmd < "$d/cmdline" + case \${cmd##*/} in + waybar*) + kill -USR2 "\${d#/proc/}" + printf 'THEME_HOOK theme=%s bar=%s bar_pid=%s\\n' \\ + "$theme" "$c_bar" "\${d#/proc/}" + ;; + esac + done +fi + +exec /usr/local/bin/notify-send "$@" +`; + +/** + * One radial glow of a wallpaper: center and radius as fractions of the + * output, colour as #rrggbb, alpha at the center fading to zero at the edge. + */ +export type WallpaperGlow = [x: number, y: number, r: number, color: string, alpha: number]; + +export interface OmarchyTheme { + /** theme.conf body: one palette file read by the compositor (border, gaps, + * wallpaper) and by the shell clients (bar, foreground, accent) — one file, + * both sides, which is what makes a switch atomic across the desktop. */ + conf: string; + /** The theme's wallpaper: `image` is the bundled URL of its real Omarchy + * background, rendered by renderImageWallpaperKwlp; the aurora spec is the + * fallback rendered by renderWallpaperKwlp when the fetch or decode fails. */ + wallpaper: { base: string; glows: WallpaperGlow[]; image?: string }; +} + +export const OMARCHY_THEMES: Record = { + "tokyo-night": { + conf: `# Tokyo Night +border_active = 0x7aa2f7 +wallpaper_top = 0x1a1b26 +wallpaper_bottom = 0x24283b +wallpaper = background.kwlp +bar = 0x16161e +foreground = 0xc0caf5 +muted = 0x565f89 +accent = 0x7aa2f7 +occupied = 0x292e42 +background = 0x1a1b26 +gaps_in = 8 +gaps_out = 12 +`, + wallpaper: { + base: "#16161e", + image: tokyoNightWallpaperUrl, + glows: [ + [0.22, 0.85, 0.75, "#7aa2f7", 0.32], + [0.85, 0.15, 0.65, "#bb9af7", 0.26], + [0.62, 0.72, 0.5, "#7dcfff", 0.18], + [0.1, 0.1, 0.45, "#3d59a1", 0.3], + ], + }, + }, + "catppuccin": { + conf: `# Catppuccin Mocha +border_active = 0xcba6f7 +wallpaper_top = 0x1e1e2e +wallpaper_bottom = 0x313244 +wallpaper = background.kwlp +bar = 0x181825 +foreground = 0xcdd6f4 +muted = 0x6c7086 +accent = 0xcba6f7 +occupied = 0x313244 +background = 0x1e1e2e +gaps_in = 10 +gaps_out = 16 +`, + wallpaper: { + base: "#181825", + image: catppuccinWallpaperUrl, + glows: [ + [0.8, 0.8, 0.7, "#cba6f7", 0.3], + [0.15, 0.2, 0.6, "#f5c2e7", 0.22], + [0.5, 0.45, 0.5, "#89b4fa", 0.18], + [0.9, 0.1, 0.4, "#f38ba8", 0.16], + ], + }, + }, + "gruvbox": { + conf: `# Gruvbox Dark +border_active = 0xd79921 +wallpaper_top = 0x282828 +wallpaper_bottom = 0x3c3836 +wallpaper = background.kwlp +bar = 0x1d2021 +foreground = 0xebdbb2 +muted = 0x928374 +accent = 0xd79921 +occupied = 0x3c3836 +background = 0x282828 +gaps_in = 6 +gaps_out = 8 +`, + wallpaper: { + base: "#1d2021", + image: gruvboxWallpaperUrl, + glows: [ + [0.5, 0.95, 0.8, "#d79921", 0.28], + [0.12, 0.25, 0.55, "#cc241d", 0.14], + [0.88, 0.3, 0.5, "#98971a", 0.16], + [0.7, 0.6, 0.45, "#d65d0e", 0.18], + ], + }, + }, + "nord": { + conf: `# Nord +border_active = 0x88c0d0 +wallpaper_top = 0x2e3440 +wallpaper_bottom = 0x3b4252 +wallpaper = background.kwlp +bar = 0x272c36 +foreground = 0xd8dee9 +muted = 0x4c566a +accent = 0x88c0d0 +occupied = 0x3b4252 +background = 0x2e3440 +gaps_in = 8 +gaps_out = 12 +`, + wallpaper: { + base: "#272c36", + image: nordWallpaperUrl, + glows: [ + [0.3, 0.1, 0.7, "#88c0d0", 0.24], + [0.85, 0.75, 0.65, "#5e81ac", 0.3], + [0.1, 0.8, 0.5, "#b48ead", 0.16], + [0.6, 0.4, 0.45, "#81a1c1", 0.18], + ], + }, + }, + "everforest": { + conf: `# Everforest Dark +border_active = 0xa7c080 +wallpaper_top = 0x2d353b +wallpaper_bottom = 0x3d484d +wallpaper = background.kwlp +bar = 0x232a2e +foreground = 0xd3c6aa +muted = 0x859289 +accent = 0xa7c080 +occupied = 0x3d484d +background = 0x2d353b +gaps_in = 8 +gaps_out = 12 +`, + wallpaper: { + base: "#232a2e", + image: everforestWallpaperUrl, + glows: [ + [0.2, 0.9, 0.75, "#a7c080", 0.24], + [0.8, 0.2, 0.6, "#7fbbb3", 0.22], + [0.55, 0.6, 0.5, "#dbbc7f", 0.14], + [0.05, 0.15, 0.45, "#425047", 0.4], + ], + }, + }, + "rose-pine": { + conf: `# Rosé Pine +border_active = 0xebbcba +wallpaper_top = 0x191724 +wallpaper_bottom = 0x26233a +wallpaper = background.kwlp +bar = 0x12101a +foreground = 0xe0def4 +muted = 0x6e6a86 +accent = 0xebbcba +occupied = 0x26233a +background = 0x191724 +gaps_in = 10 +gaps_out = 14 +`, + wallpaper: { + base: "#12101a", + image: rosePineWallpaperUrl, + glows: [ + [0.75, 0.85, 0.7, "#ebbcba", 0.24], + [0.2, 0.15, 0.6, "#c4a7e7", 0.22], + [0.5, 0.55, 0.5, "#31748f", 0.2], + [0.95, 0.25, 0.4, "#eb6f92", 0.12], + ], + }, + }, +}; + +function hexToRgba(hex: string, alpha: number): string { + const v = parseInt(hex.slice(1), 16); + return `rgba(${(v >> 16) & 0xff}, ${(v >> 8) & 0xff}, ${v & 0xff}, ${alpha})`; +} + +/** + * Render a theme's aurora wallpaper to KWLP raw pixels: "KWLP", u32le width, + * u32le height, then width*height u32le XRGB pixels. Rendered smaller than the + * output — the compositor bilinear-upscales, and the content is soft + * gradients, so the stretch is invisible while the staged file stays ~2 MB. + */ +export function renderWallpaperKwlp( + theme: OmarchyTheme, + w = 960, + h = 540, +): Uint8Array { + const canvas = new OffscreenCanvas(w, h); + const ctx = canvas.getContext("2d")!; + ctx.fillStyle = theme.wallpaper.base; + ctx.fillRect(0, 0, w, h); + for (const [fx, fy, fr, color, alpha] of theme.wallpaper.glows) { + const grad = ctx.createRadialGradient( + fx * w, fy * h, 0, fx * w, fy * h, fr * w); + grad.addColorStop(0, hexToRgba(color, alpha)); + grad.addColorStop(1, hexToRgba(color, 0)); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, w, h); + } + + return encodeKwlp(ctx, w, h); +} + +/** Per-axis ceiling on a staged wallpaper. Six themes are staged eagerly and + * each costs width x height x 4 bytes of VFS, so an oversized asset would be + * paid for six times over. It matches the connector mode's own width clamp, + * above which no pane can ask for the pixels anyway. Every bundled asset is + * under it and passes through at its native size. */ +const MAX_WALLPAPER_PX = 3840; + +/** + * Render a theme's real background image to KWLP raw pixels at the source's + * own resolution, capped by MAX_WALLPAPER_PX per axis. + * + * The staged size cannot follow the mode. The image is baked into the VFS at + * compose time and the kernel owns the VFS from boot, while the mode is only + * decided once the pane's layout settles — and the pane's own box moves while + * it does. So the page stages every pixel the source has and the compositor + * cover-crops to whatever mode it ends up with: sharp on a HiDPI pane, and + * undistorted at any aspect. Staging past the source would cost four times + * the bytes for pixels the source cannot supply. + * + * Returns null when the fetch or decode fails so the caller can fall back to + * renderWallpaperKwlp. + */ +export async function renderImageWallpaperKwlp( + url: string, +): Promise { + try { + const response = await fetch(url); + if (!response.ok) return null; + const bitmap = await createImageBitmap(await response.blob()); + const fit = Math.min(1, + MAX_WALLPAPER_PX / bitmap.width, MAX_WALLPAPER_PX / bitmap.height); + const w = Math.max(1, Math.round(bitmap.width * fit)); + const h = Math.max(1, Math.round(bitmap.height * fit)); + const canvas = new OffscreenCanvas(w, h); + const ctx = canvas.getContext("2d")!; + ctx.drawImage(bitmap, 0, 0, w, h); + bitmap.close(); + return encodeKwlp(ctx, w, h); + } catch { + return null; + } +} + +function encodeKwlp( + ctx: OffscreenCanvasRenderingContext2D, + w: number, + h: number, +): Uint8Array { + const img = ctx.getImageData(0, 0, w, h).data; + const out = new Uint8Array(12 + w * h * 4); + out.set([0x4b, 0x57, 0x4c, 0x50]); // "KWLP" + const view = new DataView(out.buffer); + view.setUint32(4, w, true); + view.setUint32(8, h, true); + for (let i = 0; i < w * h; i++) { + out[12 + i * 4] = img[i * 4 + 2]; + out[12 + i * 4 + 1] = img[i * 4 + 1]; + out[12 + i * 4 + 2] = img[i * 4]; + out[12 + i * 4 + 3] = 0xff; + } + return out; +} diff --git a/apps/browser-demos/pages/kandelo/presets.ts b/apps/browser-demos/pages/kandelo/presets.ts index 30c1a985e8..dcd0a9b612 100644 --- a/apps/browser-demos/pages/kandelo/presets.ts +++ b/apps/browser-demos/pages/kandelo/presets.ts @@ -161,4 +161,15 @@ export const PRESET_LIBRARY: Preset[] = [ bootCommand: ["bash", "-l", "-i"], estimatedUrlBytes: 612, }, + { + id: "omarchy", + title: "Omarchy desktop", + summary: "The Omarchy desktop on Kandelo: the tiling compositor with the shell Omarchy is made of. Unmodified Waybar — the real GTK3 status bar, on wlr-layer-shell, its hyprland modules reading the compositor's Hyprland IPC sockets — reserves the top strip (workspaces, focused window, clock) and the windows tile beneath it. CTRL+Space opens klauncher, a keyboard-exclusive overlay launcher reading /usr/share/kandelo/apps; type to filter and Enter to launch. CTRL+SHIFT+Space cycles six themes (Tokyo Night, Catppuccin, Gruvbox, Nord, Everforest, Rosé Pine), each with its own Omarchy background image, recolouring the borders, gaps, wallpaper, bar and launcher live from one palette file. CTRL+Return spawns a terminal, CTRL+K a clock, CTRL+P a paint canvas, CTRL+W closes, CTRL+1..9 switch workspaces — every bind mirrored on SUPER for real Hyprland.", + base: SHELL_BASE, + packages: ["bash@local", "coreutils@local"], + accent: "#7aa2f7", + glyph: "O", + bootCommand: ["bash", "-l", "-i"], + estimatedUrlBytes: 612, + }, ]; diff --git a/apps/browser-demos/test/kandelo-hidpi.spec.ts b/apps/browser-demos/test/kandelo-hidpi.spec.ts new file mode 100644 index 0000000000..f2a605901b --- /dev/null +++ b/apps/browser-demos/test/kandelo-hidpi.spec.ts @@ -0,0 +1,110 @@ +import { expect, test, type Page } from "@playwright/test"; + +const appUrl = (path: string): string => { + const baseUrl = process.env.KANDELO_TEST_BASE_URL; + return baseUrl ? new URL(path, baseUrl).href : path; +}; + +async function gotoOrSkip(page: Page, path: string) { + await page.goto(appUrl(path), { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2_000); + if (await page.locator("vite-error-overlay").count()) { + test.skip(true, "Required binary not built - Vite import error"); + } +} + +const syslog = (page: Page) => page.evaluate(() => document.body.innerText); + +/** + * The DRM mode is sized in device pixels, so a dpr-2 pane gets a mode twice + * the CSS box. Nothing in that mode tells the compositor which of the two it + * is looking at, so the page passes WLC_SCALE and the compositor divides the + * mode by it to get the logical grid clients lay out in. Get either half + * wrong and the desktop is silently rendered at the wrong resolution: the + * aspect-derived mode this replaced pinned every pane to 1080 lines, which + * upscaled a Retina desktop and is invisible at dpr 1 — so this file runs at + * dpr 2 on purpose. + */ +test.use({ deviceScaleFactor: 2 }); + +test("the desktop renders at the pane's device resolution on a dpr-2 display", async ({ page }) => { + test.setTimeout(300_000); + + await gotoOrSkip(page, "/?demo=omarchy"); + await page.getByText("Internals", { exact: true }).first().click(); + await expect.poll(() => syslog(page), { timeout: 180_000 }) + .toMatch(/omarchy desktop ready/); + + const sizes = await page.evaluate(() => { + const c = document.querySelector( + ".kmachine-primary-slot:not(.is-hidden) canvas", + ); + const rect = c!.getBoundingClientRect(); + return { + dpr: window.devicePixelRatio, + cssW: Math.round(rect.width), + cssH: Math.round(rect.height), + bufW: c!.width, + bufH: c!.height, + }; + }); + + expect(sizes.dpr, "this spec is meaningless at dpr 1").toBe(2); + + // The scanout is the pane's device-pixel box, not a fixed 1080 lines. Even + // alignment and the [640,3840]x[480,2160] clamp are the only slack. + expect(sizes.bufW, "scanout width is not the pane's device width") + .toBe(Math.min(3840, Math.max(640, (sizes.cssW * 2) & ~1))); + expect(sizes.bufH, "scanout height is not the pane's device height") + .toBe(Math.min(2160, Math.max(480, (sizes.cssH * 2) & ~1))); + + const text = await syslog(page); + + // The compositor took the scale, and its logical grid is the mode halved — + // so a client still lays out against roughly the CSS box, not the device one. + expect(text, "compositor did not take the output scale").toMatch(/WLC_SCALE 2/); + const up = text.match(/COMPOSITOR_UP w=(\d+) h=(\d+)/); + expect(up, "compositor never reported its logical grid").not.toBeNull(); + expect(Number(up![1])).toBe(sizes.bufW / 2); + expect(Number(up![2])).toBe(sizes.bufH / 2); + + // The wallpaper is staged at the source's own resolution, not the fixed + // 960x540 the compositor used to magnify past 2x on a HiDPI pane. It keeps + // the asset's aspect rather than the mode's, because the compositor + // centre-crops: the stager bakes the image into the VFS before the mode + // exists and so cannot match it. + const wallpaper = text.match(/WALLPAPER image w=(\d+) h=(\d+)/); + expect(wallpaper, "the compositor never loaded an image wallpaper").not.toBeNull(); + const wallpaperW = Number(wallpaper![1]); + const wallpaperH = Number(wallpaper![2]); + expect({ w: wallpaperW, h: wallpaperH }, + "the wallpaper is not staged at the boot theme's own resolution") + .toEqual({ w: 2580, h: 1080 }); + + // The bar is laid out in logical units, so its exclusive zone must not + // shrink with the scale — that shrinking is what made it unreadable. + await expect.poll(() => syslog(page), { timeout: 120_000 }) + .toMatch(/LAYER ns=waybar layer=2 x=0 y=0 w=\d+ h=\d+/); + const layer = (await syslog(page)).match(/LAYER ns=waybar layer=2 x=0 y=0 w=(\d+) h=(\d+)/); + expect(Number(layer![1]), "the bar does not span the logical width") + .toBe(sizes.bufW / 2); + expect(Number(layer![2]), "the bar is not its configured logical height").toBe(26); + + // A theme switch raises a mako toast, and a toast makes its scale decision + // once: mako sizes its buffer from the scale of the output wl_surface.enter + // names, before it draws. So the compositor sends the enter when the surface + // takes its layer-shell role. Sent at map, the first frame is already drawn + // at scale 1 — the toast reads soft while the desktop around it is sharp. + await page.getByText("Demo", { exact: true }).first().click(); + await page.locator("body").click({ position: { x: 5, y: 5 } }); + await page.keyboard.down("Control"); + await page.keyboard.down("Shift"); + await page.keyboard.press("Space"); + await page.keyboard.up("Shift"); + await page.keyboard.up("Control"); + await page.getByText("Internals", { exact: true }).first().click(); + await expect.poll(() => syslog(page), { timeout: 60_000 }) + .toMatch(/BUFFER_SCALE app=notifications scale=2 /); + expect(await syslog(page), "the toast drew a frame at scale 1 before that") + .not.toMatch(/BUFFER_SCALE app=notifications scale=1 /); +}); diff --git a/apps/browser-demos/test/kandelo-hyprland.spec.ts b/apps/browser-demos/test/kandelo-hyprland.spec.ts index 6df4a1391a..bf7e134778 100644 --- a/apps/browser-demos/test/kandelo-hyprland.spec.ts +++ b/apps/browser-demos/test/kandelo-hyprland.spec.ts @@ -14,7 +14,7 @@ async function gotoOrSkip(page: Page, path: string) { } async function openSurface(page: Page, label: string) { - const btn = page.locator("button.kmachine-switch-btn", { hasText: label }); + const btn = page.locator("button.kdock-item", { hasText: label }); await btn.waitFor({ state: "visible", timeout: 30_000 }); await btn.click(); } diff --git a/apps/browser-demos/test/kandelo-kms-context-loss.spec.ts b/apps/browser-demos/test/kandelo-kms-context-loss.spec.ts new file mode 100644 index 0000000000..5cb7ac93b5 --- /dev/null +++ b/apps/browser-demos/test/kandelo-kms-context-loss.spec.ts @@ -0,0 +1,114 @@ +import { expect, test, type Page } from "@playwright/test"; + +const appUrl = (path: string): string => { + const baseUrl = process.env.KANDELO_TEST_BASE_URL; + return baseUrl ? new URL(path, baseUrl).href : path; +}; + +async function gotoOrSkip(page: Page, path: string) { + await page.goto(appUrl(path), { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2_000); + if (await page.locator("vite-error-overlay").count()) { + test.skip(true, "Required binary not built - Vite import error"); + } +} + +// `chrome.gpuBenchmarking.crashGpuProcess` is the only way to lose a real +// WebGL context on demand. The flag replaces the config's launchOptions +// for this file only; the dropped browserLaunchEnv strip matters to +// WebKitGTK, not Chromium. +test.use({ launchOptions: { args: ["--enable-gpu-benchmarking"] } }); + +/** The Modeset status chip's fields out of the rendered page text: + * "1918×1080 · 349 FLIPS · 115295MS · WEBGL2". */ +async function chip(page: Page): Promise<{ flips: number; renderer: string }> { + const text = await page.evaluate(() => document.body.innerText); + const m = text.match(/(\d+)\s*flips\s*·[^·]*·\s*(webgl2-gl|webgl2|2d)/i); + return m + ? { flips: Number(m[1]), renderer: m[2].toLowerCase() } + : { flips: -1, renderer: "" }; +} + +const canvasLocator = (page: Page) => + page.locator(".kmachine-primary-slot:not(.is-hidden) canvas").first(); + +/** The desktop repaints continuously (wlclock animates ~10 fps), so two + * captures spaced apart must differ while the presenter is alive. */ +async function canvasAdvances(page: Page): Promise { + const canvas = canvasLocator(page); + const before = await canvas.screenshot(); + await page.waitForTimeout(1_500); + const after = await canvas.screenshot(); + return !after.equals(before); +} + +/** + * A lost WebGL context silently no-ops every GL call: without loss + * handling the vblank pump keeps "presenting", the kernel-side FLIPS + * counter keeps advancing, and the canvas freezes on the last composited + * frame with no error anywhere — the exact field failure on the desktop + * demos. The kernel-worker presenter must stand down on `webglcontextlost` + * (cancelling it to opt into restoration) and rebuild on + * `webglcontextrestored`. Crashing the GPU process loses every context in + * the browser, so a desktop that keeps animating afterwards proves the + * rebuild path end to end. + */ +test("the webgl2 scanout presenter survives a GPU process crash", async ({ page }) => { + test.setTimeout(300_000); + + await gotoOrSkip(page, "/?demo=wayland"); + + await expect(canvasLocator(page)).toBeVisible({ timeout: 60_000 }); + await expect + .poll(async () => (await chip(page)).renderer, { timeout: 180_000 }) + .toMatch(/webgl2/); + expect(await canvasAdvances(page), "desktop not animating before the crash") + .toBe(true); + + await page.evaluate(() => + (window as unknown as { + chrome: { gpuBenchmarking: { crashGpuProcess: () => void } }; + }).chrome.gpuBenchmarking.crashGpuProcess(), + ); + + // Restoration is async (GPU process restart + webglcontextrestored). + // The rebuilt presenter must repaint and the desktop must animate again; + // a presenter without loss handling freezes here while FLIPS advance. + await expect + .poll(() => canvasAdvances(page), { + timeout: 60_000, + intervals: [2_000, 5_000], + }) + .toBe(true); + await expect + .poll(async () => (await chip(page)).renderer, { timeout: 30_000 }) + .toMatch(/webgl2/); + + const settled = await chip(page); + await page.waitForTimeout(2_000); + expect( + (await chip(page)).flips, + "kernel-side page flips stopped after the GPU crash", + ).toBeGreaterThan(settled.flips); + + // Second crash: the desktop now runs the OTHER presenter. A GPU-path + // boot puts wlcompositor's GL session on the canvas (webgl2-gl), and + // the first crash degrades it to CPU compositing behind the pump's + // webgl2-scanout presenter — so one crash per phase covers both the + // program-owned release chain and the pump presenter's own + // loss/rebuild path, whichever the boot happened to start on. + await page.evaluate(() => + (window as unknown as { + chrome: { gpuBenchmarking: { crashGpuProcess: () => void } }; + }).chrome.gpuBenchmarking.crashGpuProcess(), + ); + await expect + .poll(() => canvasAdvances(page), { + timeout: 60_000, + intervals: [2_000, 5_000], + }) + .toBe(true); + await expect + .poll(async () => (await chip(page)).renderer, { timeout: 30_000 }) + .toMatch(/webgl2/); +}); diff --git a/apps/browser-demos/test/kandelo-omarchy.spec.ts b/apps/browser-demos/test/kandelo-omarchy.spec.ts new file mode 100644 index 0000000000..de03044883 --- /dev/null +++ b/apps/browser-demos/test/kandelo-omarchy.spec.ts @@ -0,0 +1,329 @@ +import { expect, test, type Page } from "@playwright/test"; + +const appUrl = (path: string): string => { + const baseUrl = process.env.KANDELO_TEST_BASE_URL; + return baseUrl ? new URL(path, baseUrl).href : path; +}; + +async function gotoOrSkip(page: Page, path: string) { + await page.goto(appUrl(path), { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2_000); + if (await page.locator("vite-error-overlay").count()) { + test.skip(true, "Required binary not built - Vite import error"); + } +} + +async function openSurface(page: Page, label: string) { + const btn = page.locator("button.kdock-item", { hasText: label }); + await btn.waitFor({ state: "visible", timeout: 30_000 }); + await btn.click(); +} + +async function syslogText(page: Page): Promise { + const lines = await page.locator(".ksys-line").allInnerTexts(); + return lines.join("\n"); +} + +// A printf marker can split across two .ksys-line entries, so join only the +// .ksys-msg spans — otherwise the next line's `[timestamp]LEVEL` prefix +// interleaves into the marker and the regex misses. +async function syslogStream(page: Page): Promise { + const msgs = await page.locator(".ksys-line .ksys-msg").allInnerTexts(); + return msgs.join(""); +} + +const canvasLocator = (page: Page) => + page.locator(".kmachine-primary-slot:not(.is-hidden) canvas").first(); + +// Press a CTRL combo at the page level. A browser reserves SUPER (Cmd/Win), so +// the demo mirrors every Omarchy bind on CTRL; that is the path a user takes. +async function pressCtrl(page: Page, key: string, shift = false, alt = false) { + await openSurface(page, "Demo"); + await page.locator("body").click({ position: { x: 5, y: 5 } }); + await page.keyboard.down("Control"); + if (shift) await page.keyboard.down("Shift"); + if (alt) await page.keyboard.down("Alt"); + await page.keyboard.press(key); + if (alt) await page.keyboard.up("Alt"); + if (shift) await page.keyboard.up("Shift"); + await page.keyboard.up("Control"); + await openSurface(page, "Internals"); +} + +// How many times the launcher has come up so far. The syslog is cumulative, +// so a later session is only visible as one more marker than before — and a +// key typed before the launcher holds the keyboard goes to the focused +// window, exactly as it would on the real desktop. +async function launcherSessions(page: Page): Promise { + return (await syslogStream(page)).split(/KLAUNCHER_READY n=\d+/).length - 1; +} + +// How many windows of one app the compositor has focused so far. The syslog is +// cumulative, so a second terminal shows up as one more marker. Counting the +// app's own name is what distinguishes a window the demo opened from Waybar's +// second, empty-app_id toplevel, which takes a tile slot of its own. +async function focusedWindows(page: Page, appId: string): Promise { + return (await syslogStream(page)).split(`KBD_FOCUS app_id=${appId}`).length - 1; +} + +// Press bare keys with the Demo surface focused, then return to Internals. +async function pressKeys(page: Page, keys: string[]) { + await openSurface(page, "Demo"); + await page.locator("body").click({ position: { x: 5, y: 5 } }); + for (const key of keys) await page.keyboard.press(key); + await openSurface(page, "Internals"); +} + +const SETUP_FAILURE = + /omarchy failed|wlcompositor failed|waybar failed|wlclock failed|wlterm failed|dbus-daemon failed|mako failed/; + +/** + * The O1 gate of docs/plans/2026-07-14-build-hyprland-class-compositor-plan.md: + * `/?demo=omarchy` boots the tiling compositor with the desktop shell Omarchy + * is made of — a layer-shell status bar reserving the top strip, a launcher on + * CTRL+Space, and switchable themes — and every piece is driven from the + * keyboard. Skips (via gotoOrSkip) when the binaries aren't built. + */ +test("Kandelo omarchy boots a themed tiling desktop with a bar, a launcher, and live theme switching", async ({ page }) => { + test.setTimeout(300_000); + + await gotoOrSkip(page, "/?demo=omarchy"); + + await openSurface(page, "Internals"); + await expect + .poll(() => syslogText(page), { timeout: 180_000 }) + .toMatch(/omarchy desktop ready/); + expect(await syslogText(page), "omarchy setup reported failure") + .not.toMatch(SETUP_FAILURE); + + // Gate 1: the desktop's own config is what drives it — the tiling layout, + // the staged keybinds, and the theme named in that same file. + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/WLC_LAYOUT dwindle/); + expect(await syslogStream(page), "compositor did not load the staged config") + .toMatch(/BINDS_LOADED n=\d+ source=\/etc\/kandelo\/wlcompositor\.conf/); + expect(await syslogStream(page), "the configured theme was not loaded") + .toMatch(/THEME tokyo-night/); + expect(await syslogStream(page), "the theme's image wallpaper was not rendered") + .toMatch(/WALLPAPER image w=2580 h=1080/); + + // Gate 2: the bar is unmodified Waybar on a real layer-shell surface — + // anchored across the top, and its hyprland modules attached to the + // compositor's Hyprland IPC event socket (HYPR_LISTENER). + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/LAYER ns=waybar layer=2 x=0 y=0 w=\d+ h=\d+/); + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/HYPR_LISTENER slot=\d+/); + + // The desktop boots bare. Assert that before touching the keyboard: the bar + // has already mapped, which takes longer than a client would, so a client + // window here is one nobody asked for. Without this the assertions below + // would pass just as well against a desktop that opens its own. + expect(await focusedWindows(page, "wlclock"), "the desktop opened a clock on its own").toBe(0); + expect(await focusedWindows(page, "wlterm"), "the desktop opened a terminal on its own").toBe(0); + + // Open the three clients the way a user does, through the binds the + // compositor loaded from its own config: CTRL+K for the clock, CTRL+Return + // for each terminal. Each one is awaited before the next, so a missed key + // shows up here rather than as a wrong count three gates later. + await pressCtrl(page, "KeyK"); + await expect + .poll(() => focusedWindows(page, "wlclock"), { timeout: 60_000 }) + .toBe(1); + await pressCtrl(page, "Enter"); + await expect + .poll(() => focusedWindows(page, "wlterm"), { timeout: 60_000 }) + .toBe(1); + await pressCtrl(page, "Enter"); + await expect + .poll(() => focusedWindows(page, "wlterm"), { timeout: 60_000 }) + .toBe(2); + + // Gate 3: the windows tile UNDER the bar. Read the log from the bar's LAYER + // line onward — only the tiles emitted once the bar reserved its strip are + // the desktop's answer. + const stream = await syslogStream(page); + const barLayer = stream.match(/LAYER ns=waybar layer=2 x=0 y=0 w=\d+ h=(\d+)/); + const barHeight = Number(barLayer![1]); + expect(barHeight, "waybar reserved no strip").toBeGreaterThan(0); + const afterBar = stream.split(/LAYER ns=waybar /).pop() ?? ""; + const tiles = [...afterBar.matchAll( + /TILE n=\d+ i=\d+ x=(-?\d+) y=(-?\d+) w=(\d+) h=(\d+)/g)]; + expect(tiles.length, "no tiles emitted after the bar mapped") + .toBeGreaterThan(0); + for (const t of tiles) + expect(Number(t[2]), `a window tiled over the bar: ${t[0]}`) + .toBeGreaterThanOrEqual(barHeight); + // The three windows opened above are the only tiles. A surface with no + // xdg_toplevel role — a client's cursor surface — taking one shifts every + // count below by one, and the launcher gates then pass on the previous + // client's tile instead of the one they name. + expect(afterBar, "a surface with no window role took a tile") + .not.toMatch(/TILE n=4 /); + + // Gate 4: the desktop composited to the canvas. The Modeset pane uses + // transferControlToOffscreen, so PNG byteLength stands in for pixel readback + // — a blank frame is ~3 KB; wallpaper + bar + tiled windows is far larger. + await openSurface(page, "Demo"); + const canvas = canvasLocator(page); + await expect(canvas).toBeVisible({ timeout: 30_000 }); + await expect + .poll( + async () => (await canvas.screenshot()).byteLength, + { timeout: 120_000, intervals: [1_000, 2_000, 5_000] }, + ) + .toBeGreaterThan(12_000); + + // Gate 4b: the desktop keeps compositing on the GPU. Waybar's cursor theme + // arrives as a buffer packed at a non-zero offset in its pool, which has no + // GL texture; treating that as a GL failure used to tear the compositor's + // EGL session down seconds after boot and leave the canvas on its last GL + // frame — a desktop that looks alive but never repaints again. The pane's + // badge reads the presenter out of the KMS stats: "webgl2-gl" is the + // compositor's own context, "webgl2" the pump's CPU-composite fallback. + await expect(page.locator("text=/flips ·/").first()) + .toContainText(/webgl2-gl/i, { timeout: 30_000 }); + expect(await syslogStream(page), "GPU compositing was torn down") + .not.toMatch(/GPU compositing failed/); + + // Gate 5: CTRL+Space opens the launcher. It is an overlay layer surface that + // takes the keyboard away from the focused terminal, so the "t" that follows + // filters its list instead of being typed into the shell. + await pressCtrl(page, "Space"); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/LAYER ns=launcher layer=3 /); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/KLAUNCHER_READY n=8/); + + // "te" narrows the eight entries (Bash, Clock, Foot, Nano, NetHack, Paint, + // Terminal, Vim) to Terminal alone — "t" alone still matches Paint. + await openSurface(page, "Demo"); + await page.locator("body").click({ position: { x: 5, y: 5 } }); + await page.keyboard.press("KeyT"); + await page.keyboard.press("KeyE"); + await openSurface(page, "Internals"); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/KLAUNCHER_FILTER q=te n=1/); + + // Enter launches the one match (Terminal) through the compositor's kwlctl + // socket and dismisses the launcher. The desktop went in with three tiled + // windows, so the launched terminal shows up as a fourth tile — the + // connection count alone would not prove it, since the launcher's own + // session ends at the same moment and frees its slot. + await openSurface(page, "Demo"); + await page.locator("body").click({ position: { x: 5, y: 5 } }); + await page.keyboard.press("Enter"); + await openSurface(page, "Internals"); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/KLAUNCHER_EXEC cmd=\/usr\/local\/bin\/wlterm/); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/KLAUNCHER_EXIT/); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/TILE n=4 i=3 /); + + // Gate 5b: a real application through the same path. "vi" narrows to Vim; + // its entry runs unmodified vim inside a wlterm, fetched lazily from + // vim.zip on first exec — the fifth tile only appears if the whole chain + // (launcher → kwlctl exec → wlterm → lazy fetch → vim) held. + const beforeVim = await launcherSessions(page); + await pressCtrl(page, "Space"); + await expect + .poll(() => launcherSessions(page), { timeout: 60_000 }) + .toBeGreaterThan(beforeVim); + await pressKeys(page, ["KeyV", "KeyI", "Enter"]); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/KLAUNCHER_EXEC cmd=\/usr\/local\/bin\/wlterm \/usr\/bin\/vim/); + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/TILE n=5 i=4 /); + expect(await syslogText(page), "vim binary does not match the kernel ABI") + .not.toMatch(/ABI version mismatch/); + + // Gate 5c: an unmodified upstream client through the same path. "fo" + // narrows to Foot; its entry runs stock foot 1.17.2 — wl_display_connect + // via XDG_RUNTIME_DIR, fontconfig resolving "monospace" through the staged + // fonts.conf, fcft rasterizing the staged Inconsolata — and the sixth tile + // only appears once foot maps its first frame through all of it. + const beforeFoot = await launcherSessions(page); + await pressCtrl(page, "Space"); + await expect + .poll(() => launcherSessions(page), { timeout: 60_000 }) + .toBeGreaterThan(beforeFoot); + await pressKeys(page, ["KeyF", "KeyO", "Enter"]); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/KLAUNCHER_EXEC cmd=\/usr\/local\/bin\/foot /); + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/TILE n=6 i=5 /); + expect(await syslogText(page), "foot binary does not match the kernel ABI") + .not.toMatch(/ABI version mismatch/); + + // Gate 6: CTRL+SHIFT+Space cycles the theme. One palette file repaints the + // whole desktop — the compositor's borders, gaps and wallpaper, and the + // bar's: the switch runs the `notify =` hook, which reads the new + // theme.conf, writes the bar's stylesheet from it, and sends Waybar + // SIGUSR2. The bar answers that from a detached thread blocked on a signal + // pipe, so "Reloading..." is also the proof that a signal reaches a + // multi-threaded process. + await pressCtrl(page, "Space", true); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/THEME (catppuccin|everforest|gruvbox|nord|rose-pine)/); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/THEME_HOOK theme=(catppuccin|everforest|gruvbox|nord|rose-pine) bar=#[0-9a-f]{6} bar_pid=\d+/); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/Reloading\.\.\./); + // The switch also spawns the configured notifier: notify-send routes a + // real org.freedesktop.Notifications.Notify over the dbus-daemon session + // bus, mako answers with the assigned id and maps the toast as a + // layer-shell surface. + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/NOTIFY_ID id=\d+/); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/LAYER ns=notifications /); + + // Gate 6b: CTRL+ALT+Space opens the Omarchy menu — the same launcher binary + // at its root level. Down+Enter descends into the theme list, and Enter on + // an entry dispatches the switch through kwlctl. + await pressCtrl(page, "Space", false, true); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/KLAUNCHER_LEVEL root/); + await pressKeys(page, ["ArrowDown", "Enter"]); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/KLAUNCHER_LEVEL themes/); + await pressKeys(page, ["Enter"]); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/KLAUNCHER_THEME name=[a-z-]+/); + + // Gate 7: the bar tracks the desktop. CTRL+2 switches workspace, and the + // bar's hyprland/workspaces module reads the switch off the Hyprland IPC + // event socket — which is what moves its active pill. Waybar logs every + // event it receives (it runs at -l debug), so the bar's own line is the + // proof the feed arrived; the compositor's WORKSPACE marker only proves it + // was sent. + await pressCtrl(page, "2"); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/WORKSPACE active=2/); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/hyprland IPC received workspacev2>>2,2/); +}); diff --git a/apps/browser-demos/test/kandelo-wayland.spec.ts b/apps/browser-demos/test/kandelo-wayland.spec.ts index 938c7ce741..40572d6809 100644 --- a/apps/browser-demos/test/kandelo-wayland.spec.ts +++ b/apps/browser-demos/test/kandelo-wayland.spec.ts @@ -14,7 +14,7 @@ async function gotoOrSkip(page: Page, path: string) { } async function openSurface(page: Page, label: string) { - const btn = page.locator("button.kmachine-switch-btn", { hasText: label }); + const btn = page.locator("button.kdock-item", { hasText: label }); await btn.waitFor({ state: "visible", timeout: 30_000 }); await btn.click(); } @@ -42,36 +42,59 @@ const SETUP_FAILURE = // The compositor's desktop geometry (wlcompositor.c placement_rules; // libkwl adds a 28 px CSD titlebar): -// wlterm: slot (90,120), content 960×540 (left-anchored) -// wlclock: slot (W-680,110), content 340×360 (right-anchored) -// wlpaint: slot (W-840,560), content 640×420 (right-anchored) -// The mode WIDTH follows the pane's aspect ratio (host_kms_mode_info: -// round(1080 × aspect), clamped [1440, 3840]) so the desktop fills the -// pane; height is fixed at 1080. The actual dims are parsed from the -// Modeset chip once the first frame lands (`readDesktopDims`) — these -// are the pre-parse fallbacks matching the historical fixed mode. +// wlterm: slot (90,120) (left-anchored) +// wlclock: slot (W-680,110) (right-anchored) +// wlpaint: slot (W-840,560) (right-anchored) +// The mode IS the pane's device-pixel box (kms-registry buildVirtualConnectorMode, +// even-aligned, clamped [640,3840]×[480,2160]); the logical grid clients lay +// out in is that mode divided by WLC_SCALE. This demo runs at dpr 1, so the +// grid is roughly the pane's CSS box — around 1280×612, not the 1920×1080 the +// slots above were written for. Two consequences every coordinate here has to +// respect: libkwl's kwl_fit_to_output caps each window at half the grid's +// width and three fifths of its height, and place_surface then clamps a slot +// that would fall off the bottom back into the work area. So the windows are +// smaller AND closer together than the slots suggest, and they overlap. +// Nothing below may use an absolute coordinate that assumed 1080 lines. +// The live dims are parsed from the Modeset chip once the first frame lands +// (`readDesktopDims`); these are only pre-parse fallbacks. let DESKTOP_W = 1920; let DESKTOP_H = 1080; -const clockTitlebar = () => ({ x: DESKTOP_W - 680 + 60, y: 110 + 14 }); -const DRAG_TO = { x: 760, y: 620 }; -// A rect fully inside wlterm's window and clear of wlclock (right-anchored, -// x ≥ W-680 ≥ 760), wlpaint, and the animated clock — the only thing that -// changes pixels here is the terminal grid re-rendering. x1 backs off the -// clock's left edge on narrow modes. +// Grip the clock's titlebar 240 px in: wlterm is fitted to half the +// grid's width and maps last, so on a ~1280-wide grid it covers the clock's +// left third and takes the press. 240 clears wlterm and still stops short of +// the close box at the window's right edge. The grab holds this offset for +// the whole drag, so it is also how the drop point predicts the window's +// final origin. +const CLOCK_GRIP_DX = 240; +const CLOCK_GRIP_DY = 14; +const clockTitlebar = () => ({ + x: DESKTOP_W - 680 + CLOCK_GRIP_DX, + y: 110 + CLOCK_GRIP_DY, +}); +// Drop point for the drag, as a fraction of the live grid so it stays on the +// desktop at any mode. A fixed y here falls below a 612-line desktop. +const dragTo = () => ({ + x: Math.round(DESKTOP_W * 0.55), + y: Math.round(DESKTOP_H * 0.8), +}); +// A rect fully inside wlterm's window and clear of wlclock, wlpaint, and the +// animated clock — the only thing that changes pixels here is the terminal +// grid re-rendering. Both far edges are held inside the fitted window: half +// the grid's width less a margin, and well above three fifths of its height. const wltermRegion = () => ({ x0: 100, y0: 130, - x1: Math.min(1040, DESKTOP_W - 680 - 20), - y1: 680, + x1: Math.min(1040, Math.round(DESKTOP_W / 2) - 60), + y1: Math.min(680, Math.round(DESKTOP_H * 0.7)), }); const canvasLocator = (page: Page) => page.locator(".kmachine-primary-slot:not(.is-hidden) canvas").first(); -/** Full text of the Modeset pane's status chip ("" while mounting). */ +/** Full text of the Modeset dock status chip ("" while mounting). */ async function chipText(page: Page): Promise { const texts = await page - .locator(".kpane") + .locator(".kdemo-surface-controls") .filter({ hasText: "flips" }) .allInnerTexts() .catch(() => [] as string[]); @@ -240,17 +263,18 @@ test("Kandelo wayland desktop composites three clients, routes typing and window const canvas = canvasLocator(page); await expect(canvas).toBeVisible({ timeout: 30_000 }); - // Latch the live desktop mode (pane-aspect width × 1080) before any - // geometry-dependent gate — window anchors and the letterbox math all + // Latch the live desktop mode — the pane's device-pixel box — before any + // geometry-dependent gate; window anchors and the letterbox math all // derive from it. await readDesktopDims(page); // Gate 1b: the full desktop composited. The Modeset pane uses // transferControlToOffscreen, so PNG byteLength stands in for pixel - // readback. Reference sizes from real runs: all-black frame ≈ 3.2 KB, - // wallpaper + three windows ≈ 21.5 KB (2d blit) / similar under the - // WebGL2 scanout presenter (trilinear filtering smooths glyph edges - // but the composited content dominates the PNG size). + // readback. Reference sizes from real runs on a ~1280×612 mode: all-black + // frame ≈ 3.2 KB, wallpaper + three windows ≈ 21 KB under the WebGL2 + // scanout presenter. The threshold sits between the two, low enough that a + // wider pane (a bigger mode compresses to more bytes, not fewer) cannot + // reach it from the blank side. await expect .poll( async () => (await canvas.screenshot()).byteLength, @@ -291,8 +315,9 @@ test("Kandelo wayland desktop composites three clients, routes typing and window // on the Internals surface, so run the whole gesture on Demo first and // assert the compositor's grab markers afterwards. const titlebar = clockTitlebar(); + const drop = dragTo(); const from = await desktopPoint(page, titlebar.x, titlebar.y); - const to = await desktopPoint(page, DRAG_TO.x, DRAG_TO.y); + const to = await desktopPoint(page, drop.x, drop.y); await page.mouse.move(from.x, from.y); await page.waitForTimeout(60); await page.mouse.down(); @@ -339,20 +364,32 @@ test("Kandelo wayland desktop composites three clients, routes typing and window const moved = (await syslogStream(page)).match( /MOVE_END "wlclock" x=(-?\d+) y=(-?\d+)/, )!; - // Placement slot was (W-680,110); the drop point puts the titlebar grip - // near (760,620), i.e. the window origin near (700,606) regardless of - // the mode width. - expect(Number(moved[1])).toBeLessThan(1000); - expect(Number(moved[2])).toBeGreaterThan(300); + // The grab holds the window at a fixed offset from the cursor, so the + // window's origin lands at the drop point less the grip — at any mode. + // Assert that, not a fixed coordinate, which only holds at one mode. + // The slack absorbs the desktop→page→pointer rounding on either side. + const DROP_SLACK = 8; + expect(Math.abs(Number(moved[1]) - (drop.x - CLOCK_GRIP_DX)), + "the window did not follow the cursor in x") + .toBeLessThanOrEqual(DROP_SLACK); + expect(Math.abs(Number(moved[2]) - (drop.y - CLOCK_GRIP_DY)), + "the window did not follow the cursor in y") + .toBeLessThanOrEqual(DROP_SLACK); // Gate 4: drag-paint a stroke in wlpaint, then prove the desktop is - // still alive. wlpaint is right-anchored at (W-840, 560); its canvas - // spans desktop (W-840, 624)-(W-200, 1008) (slot + 28px CSD bar + - // 36px toolbar). + // still alive. wlpaint is right-anchored at (W-840, 560), but on a grid + // shorter than 1080 lines place_surface pulls that slot up to sit the + // fitted window in the work area — so the canvas is found relative to the + // window's bottom edge (16 px from the desktop's), not to slot 560. + // Stroke the RIGHT of that canvas: the windows overlap on a + // narrow grid, and wlterm (fitted to half the width, from x=90) and the + // just-dragged wlclock both sit above wlpaint in the z-order. Only past + // their right edges does a press reach wlpaint at all. await openSurface(page, "Demo"); const paintX = DESKTOP_W - 840; - const pFrom = await desktopPoint(page, paintX + 70, 700); - const pTo = await desktopPoint(page, paintX + 420, 900); + const paintBottom = DESKTOP_H - 16; + const pFrom = await desktopPoint(page, paintX + 400, paintBottom - 240); + const pTo = await desktopPoint(page, paintX + 480, paintBottom - 40); await page.mouse.move(pFrom.x, pFrom.y); await page.waitForTimeout(60); await page.mouse.down(); diff --git a/crates/runtime-core/src/process.rs b/crates/runtime-core/src/process.rs index c15ba9f86f..98e5e5fa77 100644 --- a/crates/runtime-core/src/process.rs +++ b/crates/runtime-core/src/process.rs @@ -333,11 +333,14 @@ pub trait HostIO { 0 } - /// Flush any pending GL work and signal "frame ready". v1 no-op - /// (canvas presents on the next RAF); kept as a hook for future - /// fence/sync work. + /// Flush any pending GL work and signal "frame ready". Returns 0 on + /// success or negative errno when the host cannot present — a lost + /// WebGL context must fail `eglSwapBuffers` so a GPU compositor + /// degrades to its CPU path instead of presenting frozen pixels. #[allow(unused_variables)] - fn gl_present(&mut self, pid: i32) {} + fn gl_present(&mut self, pid: i32) -> i32 { + 0 + } /// Synchronous GL query (`glGetError`, `glReadPixels`, etc.). /// Returns bytes written into `out`, or negative errno on failure. diff --git a/crates/shared/src/ioctl_contract.rs b/crates/shared/src/ioctl_contract.rs index 0c5ddfc8da..3ac77edb0f 100644 --- a/crates/shared/src/ioctl_contract.rs +++ b/crates/shared/src/ioctl_contract.rs @@ -330,6 +330,8 @@ pub const IOCTL_REQUEST_CONTRACTS: &[IoctlRequestContract] = &[ pointer!(crate::dri::DRM_IOCTL_GET_CAP, InOut, 16), pointer!(crate::dri::DRM_IOCTL_WAIT_VBLANK, InOut, 16), pointer!(crate::dri::DRM_IOCTL_MODE_MAP_DUMB, InOut, 16), + pointer!(crate::dri::DRM_IOCTL_WPK_CREATE_GPU_BO, InOut, 16), + pointer!(crate::dri::DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE, InOut, 16), pointer!(crate::dri::DRM_IOCTL_MODE_GETENCODER, InOut, 20), pointer!(crate::dri::DRM_IOCTL_MODE_PAGE_FLIP, In, 24), pointer!(crate::dri::DRM_IOCTL_MODE_CREATE_DUMB, InOut, 32), @@ -418,6 +420,21 @@ mod tests { assert_eq!(query.size_for_pointer_width(8), None); } + /// An ioctl handled in syscalls.rs but absent from this table is + /// rejected as EINVAL before the handler runs. That silently demoted + /// wlcompositor to its CPU path: the wallpaper texture bind was the + /// first unregistered WPK request a real program issued. + #[test] + fn wpk_gpu_bo_requests_are_registered() { + let create = request_contract(crate::dri::DRM_IOCTL_WPK_CREATE_GPU_BO).unwrap(); + assert_eq!(create.arg_kind, IoctlArgKind::Pointer); + assert_eq!(create.size_for_pointer_width(4), Some(16)); + + let bind = request_contract(crate::dri::DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE).unwrap(); + assert_eq!(bind.arg_kind, IoctlArgKind::Pointer); + assert_eq!(bind.size_for_pointer_width(4), Some(16)); + } + /// Builds the same encoding the musl `_IOC` macros produce. const fn evioc(dir: u32, nr: u32, size: u32) -> u32 { (dir << 30) | (size << 16) | ((b'E' as u32) << 8) | nr diff --git a/docs/architecture.md b/docs/architecture.md index a5d64bca6b..bbd53c5147 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2442,7 +2442,7 @@ throughput or performance claim. ## Wayland compositor (`wlcompositor`, `wlterm`) -On top of the DRM/KMS + evdev surfaces above sits a real Wayland stack that runs entirely in-kernel — no host-side Wayland. `programs/wlcompositor/` is a PID-2 server built against a wasm32 port of `libwayland-server`: it owns the `card0` scanout (via the same KMSDRM/`libgbm` path SDL2 uses), reads input from `/dev/input/event0` (keyboard) and `event1` (pointer) through a real `libinput` 1.25.0 port, and exports the core protocol plus `wl_shm`, `xdg_shell`, `wl_seat`, `wl_output`, and `zwp_linux_dmabuf_v1`. Clients connect over a Unix socket at `/tmp/wayland-0` (`/` is a read-only rootfs and `/var/run` is `EACCES` for non-root, so the well-known runtime dir is `/tmp`). Buffer sharing is zero-copy: clients allocate `wl_shm` pools backed by `gbm` dumb BOs and pass the prime-fd to the compositor via `SCM_RIGHTS`. The same prime-fd can also arrive over `zwp_linux_dmabuf_v1` (advertised at version 3, `XRGB8888`/`ARGB8888` + `LINEAR` only, no feedback): `zwp_linux_buffer_params_v1.add`/`create[_immed]` wrap the plane fd in a synthetic single-ref `shm_pool` so the resulting `wl_buffer` reuses the exact `shm_buffer` import/composite/destroy path — for a GPU-tier bo the downstream `BIND_FOREIGN_TEXTURE` is zero-copy. Compositing is GPU-first: at boot the compositor probes the renderD128 GLES bridge (shader compile via sync queries — they fail cleanly on hosts without WebGL2) and, when available, imports each client bo as a texture (`wpkEglImportDmabufHandle` + `wpkEglBindBoTexture`, re-bound only for buffers dirtied by a commit) and renders wallpaper + z-ordered window quads + focus border in a single cmdbuf flush per frame; its GL context claims the CRTC canvas and the vblank pump's presenter stands down. Without GL (Node smokes, `WLC_NO_GPU=1`, or a runtime failure — which also terminates EGL so the pump presenter resumes), it falls back to importing with `gbm_bo_import` and CPU-blitting into the scanout buffer. Either way it keeps committing PAGE_FLIPs as the frame clock. Keymaps are compiled with a wasm32 `libxkbcommon` port and handed to clients as an mmap'd fd over `wl_keyboard.keymap`. +On top of the DRM/KMS + evdev surfaces above sits a real Wayland stack that runs entirely in-kernel — no host-side Wayland. `programs/wlcompositor/` is a PID-2 server built against a wasm32 port of `libwayland-server`: it owns the `card0` scanout (via the same KMSDRM/`libgbm` path SDL2 uses), reads input from `/dev/input/event0` (keyboard) and `event1` (pointer) through a real `libinput` 1.25.0 port, and exports the core protocol plus `wl_shm`, `xdg_shell`, `wl_seat`, `wl_output`, `zwp_linux_dmabuf_v1`, and `wp_presentation` (clock_id `CLOCK_MONOTONIC`; `presented` feedback carries the PAGE_FLIP timestamp, refresh interval, and flip sequence — the frame-pacing signal `foot` and other upstream clients consume), plus the logical-output/scale surface GTK3, Waybar and mako query: `wl_output` at v4 (`name`/`description` — mako binds v4 unconditionally), `zxdg_output_manager_v1` (v3; the single virtual output at (0,0), logical size = the mode), `wp_fractional_scale_manager_v1` (fixed `preferred_scale` 120 = scale 1), and `wp_viewporter` (per-surface crop + scale, honored by both compositing paths — nearest-sampled in the CPU blit, a uv sub-rect on the GPU quad; gated by `host/test/wlcompositor-protocols-smoke.test.ts`). Clients connect over a Unix socket at `/tmp/wayland-0` (`/` is a read-only rootfs and `/var/run` is `EACCES` for non-root, so the well-known runtime dir is `/tmp`). Buffer sharing is zero-copy: clients allocate `wl_shm` pools backed by `gbm` dumb BOs and pass the prime-fd to the compositor via `SCM_RIGHTS`. The same prime-fd can also arrive over `zwp_linux_dmabuf_v1` (advertised at version 3, `XRGB8888`/`ARGB8888` + `LINEAR` only, no feedback): `zwp_linux_buffer_params_v1.add`/`create[_immed]` wrap the plane fd in a synthetic single-ref `shm_pool` so the resulting `wl_buffer` reuses the exact `shm_buffer` import/composite/destroy path — for a GPU-tier bo the downstream `BIND_FOREIGN_TEXTURE` is zero-copy. Compositing is GPU-first: at boot the compositor probes the renderD128 GLES bridge (shader compile via sync queries — they fail cleanly on hosts without WebGL2) and, when available, imports each client bo as a texture (`wpkEglImportDmabufHandle` + `wpkEglBindBoTexture`, re-bound only for buffers dirtied by a commit) and renders wallpaper + z-ordered window quads + focus border in a single cmdbuf flush per frame; its GL context claims the CRTC canvas and the vblank pump's presenter stands down. Without GL (Node smokes, `WLC_NO_GPU=1`, or a runtime failure — which also terminates EGL so the pump presenter resumes), it falls back to importing with `gbm_bo_import` and CPU-blitting into the scanout buffer. A lost WebGL2 context (a browser GPU-process crash) enters the same degrade chain through the present path: `host_gl_present` returns `-EIO` when the canvas context reports `isContextLost()`, `GLIO_PRESENT` propagates the errno, the guest's `eglSwapBuffers` fails, and the compositor's repaint falls through to the CPU path and terminates EGL — no guest-side context-loss API is needed. Either way it keeps committing PAGE_FLIPs as the frame clock. Keymaps are compiled with a wasm32 `libxkbcommon` port and handed to clients as an mmap'd fd over `wl_keyboard.keymap`. Kandelo-authored clients build on `libkwl` (`examples/libs/libkwl/`), a small toolkit over `libwayland-client` that wraps registry bind, an `xdg` CSD toplevel, double-buffered `wl_shm` back buffers, xkb keysym/UTF-8 translation, and a `kwl_dispatch` event loop; it exposes `kwl_display_fd()` so an app can `poll` the Wayland connection alongside its own fds. Drawing goes through `libwpkdraw` (`examples/libs/wpkdraw/`), a CPU rasterizer (alpha-blended clear/pixel/rect + an `stb_truetype` font engine over a bundled Inconsolata) that renders into a caller-owned ARGB buffer. `programs/wlterm/` is the first real client: a terminal that `forkpty()`s a shell (`dash`), runs an in-tree VT100 core (`vt100.c`), and multiplexes the Wayland display fd and the PTY master fd in one `poll` loop — Wayland key events become PTY writes, PTY output feeds the VT100 grid and is rendered back into the libkwl window. Because `wlterm` forks, its wasm is mandatorily processed by `wasm-fork-instrument` (see the fork-instrumentation policy in `CLAUDE.md`). @@ -2452,15 +2452,163 @@ The stack is dual-host: the Node smoke gates (`host/test/{wpkdraw,libkwl,wlcompo The same `wlcompositor` binary is also a Hyprland-class tiling WM (PR14); the floating desktop above is simply its default layout, so `/?demo=wayland` is unchanged. `WLC_LAYOUT=dwindle` selects the tiler. -- **Layout engine.** `compute_tiling(area, n)` is a pure function: it partitions the output among `n` windows by recursively splitting the remaining region along its longer side (Hyprland's dwindle default — near half to window *i*, remainder carried forward), insetting an outer gap from the screen edge and an inner gap between windows. `retile()` runs it over the mapped windows on the active workspace (in map order = z-order) and pushes each dictated size through the `xdg_toplevel.configure` path; `FLOATING` mode keeps the app_id placement rules and makes `retile()` a no-op. Because the tiler is pure, the Node gate predicts the exact partition and compares it against the emitted `TILE` markers. +- **Layout engine.** `compute_tiling(area, n)` is a pure function: it partitions the output among `n` windows by recursively splitting the remaining region along its longer side (Hyprland's dwindle default — near half to window *i*, remainder carried forward), insetting an outer gap from the screen edge and an inner gap between windows. `retile()` runs it over the mapped windows on the active workspace (in map order = z-order) and pushes each dictated size through the `xdg_toplevel.configure` path; `FLOATING` mode keeps the app_id placement rules and makes `retile()` a no-op. Because the tiler is pure, the Node gate predicts the exact partition and compares it against the emitted `TILE` markers. A surface enters that list only once it carries the `xdg_toplevel` role: a client's cursor surface (`wl_pointer.set_cursor` is accepted and ignored, since the host pointer already draws the sprite) commits a buffer under no role, and would otherwise map, take the keyboard, and claim a tile of its own. Waybar's cursor theme is the case that reaches it. - **Workspaces.** Nine 1-based workspaces on the single output. Each surface carries a workspace id (assigned at first map); `surface_visible()` (mapped AND on the active workspace) gates compositing, input hit-testing, and tiling. `switch_workspace()` restores focus to the target's top window (z-order doubles as per-workspace focus memory); `move_focus_to_workspace()` sends the focused window away and re-tiles the remainder. -- **`kwlctl` IPC.** A control + event socket at `/tmp/kwlctl-0` (the hyprctl analog), polled in the compositor's `wl_event_loop` alongside the wayland + libinput fds. Verbs: `clients` / `workspaces` / `activewindow` (JSON queries), `dispatch >`, and `--listen` (a newline-delimited `event>>data` stream in Hyprland's socket2 format — `workspace>>N`, `activewindow>>app_id`). `dispatch exec` uses the non-forking `posix_spawnp` (`SYS_SPAWN`) — a `fork()` from inside an event-loop callback would wedge the server — and accepted control fds are `CLOEXEC` so they don't leak into spawned children. The CLI client is `programs/wlcompositor/kwlctl.c`. -- **Keybinds.** A config-driven bind table parsed from `WLC_CONFIG` / `/etc/kandelo/wlcompositor.conf` (a hyprland.conf-shaped subset: `bind = MODS, KEY, DISPATCHER[, ARGS]`); absent config installs generic SUPER-based defaults, not demo-specific ones. Keys are intercepted in the compositor's keyboard path before the focused client: a bind matches on the pressed key's shift-independent base keysym plus an exact modifier mask. Modifiers: `SUPER` (Mod4), `SHIFT`, `CTRL` — the self-contained xkb keymap carries `Super_L`, both Shifts, and `Control_L`. `CTRL` exists because a browser reserves the Cmd/Win (`SUPER`) key, so the in-browser demo mirrors every `SUPER` bind onto `CTRL`. Dispatchers: `exec`, `workspace`, `movetoworkspace`, `killactive`, `cyclenext`/`cycleprev` (focus cycling without z-order reordering, so a tiled layout keeps its geometry). The `exec` dispatcher is how new panes are opened Hyprland-style — a per-app launch bind rather than a launcher UI: the `/?demo=hyprland` config binds `Return`→`wlterm`, `K`→`wlclock`, `P`→`wlpaint` (each on both `SUPER` and `CTRL`), and on the keypress the compositor runs `kwlctl_exec` → `posix_spawnp` of the `/usr/local/bin` binary, which connects as a new tiled client. Because bound combos are grabbed before the focused client, a `CTRL`-letter launch bind shadows the terminal's like-named control key in-browser; the clock is deliberately on `K` (not `C`) so `Ctrl+C` SIGINT still reaches `wlterm`. A real Hyprland session drives these on `SUPER` and avoids the clash entirely. `killactive` sends `xdg_toplevel.close` to the focused window; the client is responsible for tearing its surface down (the compositor retiles once the surface is destroyed). `wlterm` closes its window immediately and hangs its shell up with `SIGHUP` — closing the pty master alone does not wake a shell blocked in `read()`, so without the explicit hangup the reap (and the tile) would block forever. +- **`kwlctl` IPC.** A control + event socket at `/tmp/kwlctl-0` (the hyprctl analog), polled in the compositor's `wl_event_loop` alongside the wayland + libinput fds. Verbs: `clients` / `workspaces` / `activeworkspace` / `activewindow` / `monitors` / `workspacerules` / `theme` (JSON queries, each also accepted behind hyprctl's `j/` prefix), `dispatch |theme >`, and `--listen` (a newline-delimited `event>>data` stream in Hyprland's socket2 format). `dispatch exec` uses the non-forking `posix_spawnp` (`SYS_SPAWN`) — a `fork()` from inside an event-loop callback would wedge the server — and accepted control fds are `CLOEXEC` so they don't leak into spawned children. The CLI client is `programs/wlcompositor/kwlctl.c` (`KWLCTL_SOCKET` overrides the path). +- **Hyprland IPC compatibility.** The same command table and event bus are also served on Hyprland's own socket pair, where an unmodified Waybar looks for them: `/tmp/hypr/wlcompositor/.socket.sock` (request/reply) and `.socket2.sock` (the event stream, which needs no handshake — a client that connects is a listener). `main()` exports `HYPRLAND_INSTANCE_SIGNATURE=wlcompositor` and defaults `XDG_RUNTIME_DIR=/tmp`, so anything the compositor execs finds the directory. The query replies carry the `hyprctl -j` field set Waybar's `hyprland/*` modules read — window `class`/`title`/`workspace`/`floating`/`mapped`, workspace `id`/`name`/`monitor`/`windows`, and a single `virtual-0` monitor — and the events it subscribes to: `workspace`/`workspacev2`, `createworkspace`/`destroyworkspace` (+`v2`), `focusedmon`(`v2`), `activewindow`/`activewindowv2`, `openwindow`/`closewindow`, `movewindow`(`v2`), `windowtitle`(`v2`). Window titles exist only for this: `xdg_toplevel.set_title` is stored on the surface and relayed, never drawn (clients keep their own CSD titlebars). +- **Keybinds.** A config-driven bind table parsed from `WLC_CONFIG` / `/etc/kandelo/wlcompositor.conf` (a hyprland.conf-shaped subset: `bind = MODS, KEY, DISPATCHER[, ARGS]`); absent config installs generic SUPER-based defaults, not demo-specific ones. Keys are intercepted in the compositor's keyboard path before the focused client: a bind matches on the pressed key's shift-independent base keysym plus an exact modifier mask. Modifiers: `SUPER` (Mod4), `SHIFT`, `CTRL`, `ALT` (Mod1) — the self-contained xkb keymap carries `Super_L`, both Shifts, `Control_L`, `Alt_L`, the four arrow keys, `F1`–`F12`, and the nav cluster (Home/End/PgUp/PgDn/Insert/Delete). `CTRL` exists because a browser reserves the Cmd/Win (`SUPER`) key, so the in-browser demo mirrors every `SUPER` bind onto `CTRL`. Dispatchers: `exec`, `workspace`, `movetoworkspace`, `killactive`, `cyclenext`/`cycleprev` (focus cycling without z-order reordering, so a tiled layout keeps its geometry). The `exec` dispatcher is how new panes are opened Hyprland-style — a per-app launch bind rather than a launcher UI: the `/?demo=hyprland` config binds `Return`→`wlterm`, `K`→`wlclock`, `P`→`wlpaint` (each on both `SUPER` and `CTRL`), and on the keypress the compositor runs `kwlctl_exec` → `posix_spawnp` of the `/usr/local/bin` binary, which connects as a new tiled client. Because bound combos are grabbed before the focused client, a `CTRL`-letter launch bind shadows the terminal's like-named control key in-browser; the clock is deliberately on `K` (not `C`) so `Ctrl+C` SIGINT still reaches `wlterm`. A real Hyprland session drives these on `SUPER` and avoids the clash entirely. `killactive` sends `xdg_toplevel.close` to the focused window; the client is responsible for tearing its surface down (the compositor retiles once the surface is destroyed). `wlterm` closes its window immediately and hangs its shell up with `SIGHUP` — closing the pty master alone does not wake a shell blocked in `read()`, so without the explicit hangup the reap (and the tile) would block forever. - **Server-side decoration.** The compositor advertises `zxdg_decoration_manager_v1` and negotiates the mode by layout: `dwindle` → `SERVER_SIDE` (a tiled window has no titlebar), `floating` → `CLIENT_SIDE` (the client keeps its CSD titlebar). A libkwl client honors the negotiated mode (`decoration_configure`): under SSD it sets its titlebar height to 0 and treats all pointer events as content, so the tiled desktop looks like Hyprland. -- **Client-side resize.** The compositor composites each surface at its **native** buffer size (`blit_surface` does not scale to the tile), so tiling requires the *client* to resize into the size the compositor dictates. `retile()` sends `xdg_toplevel.configure(w,h)`; libkwl records it and, on the `xdg_surface.configure` ack barrier, rebuilds both `wl_shm` buffers at the new size and pushes a `KWL_RESIZE` event (new content w/h). Clients react: `wlclock` recomputes its dial geometry, `wlterm` reflows its VT100 grid (`vt100_resize` + `TIOCSWINSZ` + `SIGWINCH`), `wlpaint` reallocates its canvas (preserving the painting) so the toolbar + drawing area fill the whole tile rather than a fixed 640×420 corner. The initial `get_toplevel` `configure(0,0)` ("you decide") is ignored, so a floating client (`/?demo=wayland`) never resizes and is byte-identical to before. +- **Client-side resize.** The compositor composites each surface at its **native** buffer size (`blit_surface` does not scale to the tile; the one exception is an explicit `wp_viewport` destination, which scales that surface's committed source rect), so tiling requires the *client* to resize into the size the compositor dictates. `retile()` sends `xdg_toplevel.configure(w,h)`; libkwl records it and, on the `xdg_surface.configure` ack barrier, rebuilds both `wl_shm` buffers at the new size and pushes a `KWL_RESIZE` event (new content w/h). Clients react: `wlclock` recomputes its dial geometry, `wlterm` reflows its VT100 grid (`vt100_resize` + `TIOCSWINSZ` + `SIGWINCH`), `wlpaint` reallocates its canvas (preserving the painting) so the toolbar + drawing area fill the whole tile rather than a fixed 640×420 corner. The initial `get_toplevel` `configure(0,0)` ("you decide") is ignored, so a floating client (`/?demo=wayland`) never resizes and is byte-identical to before. These are entirely in-kernel (client↔compositor over the wayland + `/tmp/kwlctl-0` sockets) — no host-runtime change — and gated by `host/test/wlcompositor-{tiling,resize,kwlctl,keybind,decoration}-smoke.test.ts`. The browser demo (`/?demo=hyprland`, staged by `live-setup.ts` with `WLC_LAYOUT=dwindle` + a staged `/etc/kandelo/wlcompositor.conf`, gated by `apps/browser-demos/test/kandelo-hyprland.spec.ts`) boots the same compositor plus a `wlclock` and two `wlterm` terminals, which tile into gapped borderless frames and resize into their tiles — the first end-to-end Hyprland-class desktop. `wlpaint` is also staged (not auto-spawned) so the `Ctrl+P` launch bind can summon it on demand. See [browser-support.md](browser-support.md#hyprland-tiling-demo). +### Desktop shell (`zwlr_layer_shell_v1`, `kbar`, `klauncher`, themes) + +A tiling WM is not yet a desktop: a desktop also has a bar, a launcher, and a +theme. Those are ordinary Wayland clients, but they need a protocol that lets a +surface anchor to an output edge and reserve space from the windows — which is +what `zwlr_layer_shell_v1` is, and what Waybar, mako and every other shell +component speak. The compositor implements it (protocol XML vendored at +`packages/registry/wayland-protocols/xml/wlr-layer-shell-unstable-v1.xml`), and +two clients consume it. This is the **O1** milestone of +[docs/plans/2026-07-14-build-hyprland-class-compositor-plan.md](plans/2026-07-14-build-hyprland-class-compositor-plan.md). + +- **Layer shell.** A `wl_surface` given the layer role carries a layer + (background/bottom/top/overlay), an anchor mask, margins, an exclusive zone + and a keyboard-interactivity mode; the surface joins `g.layers` at role + creation, because the protocol's initial commit carries no buffer and exists + only to fetch the configure that tells the client its size. + `layers_arrange()` walks the layers background→overlay, anchors each surface + inside the area left by the ones before it, and sends + `zwlr_layer_surface_v1.configure` when the box changes. What remains after + every **mapped** surface's exclusive zone is `g.usable`, the work area + `retile()` partitions — so a bar shrinks the tiling area rather than covering + a window. A surface with a role but no buffer reserves nothing, so a client + that dies mid-handshake cannot strand a strip of the desktop. Compositing and + hit-testing put background/bottom under the windows and top/overlay over + them, on both the GPU and CPU paths; a layer surface shows on every workspace. + `EXCLUSIVE` keyboard interactivity takes the keyboard for as long as the + surface lives, and focus falls back to the topmost window when it goes away. +- **`kbar`** (`programs/kbar.c`) — the Tier-1 status bar: a + 30 px top-anchored layer surface with a matching exclusive zone, rendering + workspace pills, the focused window's app id, the kernel's monotonic uptime, + and a clock. Its state comes + from `kwlctl` — a `workspaces` / `activewindow` / `theme` query at startup, + then the `--listen` event stream — polled alongside the Wayland fd, which is + the same feed Waybar's hyprland modules take from hyprctl. The omarchy + browser demo runs unmodified **Waybar** in this slot instead (see the + Hyprland IPC compatibility bullet above); `kbar` stays as the dependency-free + bar and is what the layer-shell and theme smokes gate on. +- **`knotify`** (`programs/knotify.c`) — the notification toast, the + notify-send slot: one toast per process, a corner-anchored overlay surface + (margins via `zwlr_layer_surface_v1.set_margin`) that shows + `knotify <body…>` for a moment and exits — the surface teardown is + the dismissal. The compositor's `notify = <path>` config key spawns it on + every theme switch. The omarchy browser demo points that key at a theme + script that restyles Waybar and then execs + `notify-send` (`programs/notify-send.c`) — a gdbus client that + calls `org.freedesktop.Notifications.Notify` over the `dbus-daemon` + session bus, where unmodified upstream mako owns the name and renders + the toast as its own layer-shell surface. +- **`klauncher`** (`programs/klauncher.c`) — the launcher, Walker's slot: a + centred overlay-layer surface with exclusive keyboard interactivity, filtering + a registry of `/usr/share/kandelo/apps/*.conf` entries (`name` + `exec`) as + you type. Enter hands the command to the compositor over `kwlctl dispatch + exec` and dismisses; the launcher itself never forks. `klauncher --menu` + opens the Omarchy menu instead: a root level (Apps, Theme) that descends + into the app list or the installed-theme list (read from `kwlctl theme`, + switched with `dispatch theme`); ESC in a submenu returns to the root. +- **Themes.** A theme is a directory holding one `theme.conf` under + `/usr/share/kandelo/themes` (`WLC_THEME_DIR` / `KANDELO_THEME_DIR` override + the root) — the same file-based design Omarchy uses. The compositor reads the + border colour, gaps and wallpaper; the shell clients read the bar, + foreground, muted and accent colours; unknown keys are skipped, so one file + serves both sides. A theme's wallpaper is either the two gradient colours or + a `wallpaper = <file>` image in the KWLP raw-pixel format (`"KWLP"`, u32le + width/height, XRGB pixels) that the compositor bilinear-scales to the + output — raw pixels because nothing in the compositor decodes PNG/JPEG; + whoever stages the theme renders the image. `theme = <name>` in the + compositor config selects the + startup theme, and `kwlctl dispatch theme <name|next|prev>` (or a `theme` + bind) switches live: gaps re-tile, the wallpaper is re-rendered and + re-uploaded to its GL texture, and `theme>>name` on the event stream tells + every shell client to reload its own palette. `kwlctl theme` reports the live + name plus the installed set, so a client that starts later still matches. + +Gated by `host/test/wlcompositor-{layer-shell,theme}-smoke.test.ts` and, in the +browser, by `apps/browser-demos/test/kandelo-omarchy.spec.ts` (`/?demo=omarchy`). +See [browser-support.md](browser-support.md#omarchy-desktop-demo). + +### Stock upstream clients (`foot` + the font stack) + +Every client above is Kandelo-authored on `libkwl`. `foot` 1.17.2 +(`packages/registry/foot/`) is the first **unmodified upstream** Wayland +client: stock `wl_display_connect()` (via `XDG_RUNTIME_DIR`), stock +xdg-shell/SSD negotiation, and a real font pipeline. Two declared patches are +the entire delta, both kernel-model boundaries rather than feature edits: +`0001` allocates its `wl_shm` pools as `gbm` prime-fd dumb-bos instead of +memfds (a memfd `MAP_SHARED` mapping only writes back on msync/munmap on this +kernel, so the compositor would composite stale bytes), and `0002` serializes +its font loading (concurrent `FcFontMatch` garbles pattern doubles under the +kernel's thread model — any future threaded font consumer hits the same wall). +foot forks its shell (`slave.c`), so its wasm is mandatorily +fork-instrumented; it runs with `--term=vt100` because no foot terminfo is +staged in any VFS image. + +What it took, on each side of the protocol: + +- **Compositor surface for stock clients.** `wl_subcompositor` (subsurfaces + composited glued to their parent — foot's URL/search overlays), an inert + `wl_data_device_manager` v3 stub (foot binds it unconditionally for + clipboard), `wl_seat` at v5 (`repeat_info` + pointer `frame` events), + `wl_surface.enter` at map, and `wl_output` `scale`+`done` with a physical + size of 0×0 — sending pixels as millimetres made foot derive a 25.4 DPI and + garble its font reload. `wp_presentation` (above) is its frame clock. +- **The font stack.** Four library packages feed it: `freetype` 2.13.3 + (rasterizer), `fontconfig` 2.15.0 (font discovery — reads + `/etc/fonts/fonts.conf`, scans the staged font dirs), `fcft` 3.1.9 (the + glyph-cache layer foot draws with) and `tllist` 1.1.0, over `pixman` 0.42.2 + and `utf8proc` 2.9.0. Gates: `host/test/fontstack-smoke.test.ts` and the + per-package `host/test/{pixman,utf8proc}-smoke.test.ts`. +- **Kernel: signals interrupt host-converted epoll waits.** foot's SIGCHLD + reaper parks in `epoll_pwait` with the signal unblocked only inside the + wait; the host-converted wait now swaps the process signal mask via the + additive kernel exports `kernel_swap_poll_sigmask` / + `kernel_restore_poll_sigmask` and returns EINTR when the per-attempt + dequeue delivered a signal (`handleEpollPwait` in + `host/src/kernel-worker.ts`). + +Gated end-to-end by `host/test/foot-smoke.test.ts` (foot on wlcompositor: +connect, fontconfig+fcft startup, first composited frame through the gbm pool +path, keys typed into its forked `dash`, clean exit). In the browser, foot is +a launcher entry of `/?demo=omarchy` — staged at `/usr/local/bin/foot` with +`fonts.conf` + Inconsolata under `/usr/share/fonts`, gated by +`apps/browser-demos/test/kandelo-omarchy.spec.ts`. + +### Full libffi (generated dispatch + static closure trampolines) + +The glib/gobject tier needs real `ffi_call` (doubles, i64, by-value structs) +and `ffi_closure` — which on native targets JIT-writes trampolines. wasm32 +cannot generate code at runtime, so `packages/registry/libffi/` is a +from-scratch port built on two facts of clang's wasm32 C ABI lowering: a +struct whose only member (recursively) is one scalar travels as that scalar, +and every other by-value struct is a pointer at the wasm level (`byval` +argument copies, hidden leading `sret` return pointer) — so every signature +collapses to word classes {i32, i64, f32, f64}. `gen-dispatch.sh` enumerates +signature families into two generated TUs: a `switch` of `call_indirect` +shapes for `ffi_call` (every arity ≤ 8 with at most two non-i32 args, plus +all-i32 up to the Wayland ceiling of 22, times five return classes) and a +static trampoline pool for closures (N real C functions per signature class, +baked into the function table; `ffi_prep_closure_loc` binds a free slot and +pool exhaustion aborts naming the class). A signature outside the generated +families aborts printing its key — coverage is a one-line bound change in the +generator. `ffi_cif` deliberately gained no fields across the rewrite: +libwayland embeds it by value, so its cached archive stays ABI-compatible. +Gated by `host/test/libffi-full-unit.test.ts` (native + wasm-under-kernel +matrix over arities × types × call/closure via `programs/libffi_full_test.c`) +and `host/test/libffi-shim-unit.test.ts` (the PR1 Wayland arity gate, kept +green through the rewrite). + ## Signal Subsystem Signals are delivered at syscall boundaries. When a process has a pending signal: diff --git a/docs/browser-support.md b/docs/browser-support.md index 6c910141da..af16e0c195 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -325,6 +325,7 @@ Located in `apps/browser-demos/pages/`: | modeset | modeset.c | `kernel.boot` + spawn | Minimal KMS client: opens `/dev/dri/card0`, becomes DRM master, allocates dumb buffers, draws an animated gradient, and commits real `drmModePageFlip` ioctls. The Modeset pane bridges the CRTC to an OffscreenCanvas and shows a live PAGE_FLIP counter chip. | | wayland | wlcompositor + wlclock + wlpaint + wlterm | `kernel.boot` + spawn | Full Wayland desktop — see [Wayland desktop demo](#wayland-desktop-demo) below. | | hyprland | wlcompositor (dwindle) + wlclock + 2× wlterm (+ wlpaint via keybind) | `kernel.boot` + spawn | Hyprland-class tiling desktop; `Ctrl+Return`/`Ctrl+K`/`Ctrl+P` open new terminal/clock/paint panes — see [Hyprland tiling demo](#hyprland-tiling-demo) below. | +| omarchy | wlcompositor (dwindle) + Waybar + mako + klauncher + themes | `kernel.boot` + spawn | The tiling desktop with its shell: unmodified Waybar on layer shell, `Ctrl+Space` launcher, `Ctrl+Shift+Space` theme cycling. Boots to wallpaper + bar with no windows open — see [Omarchy desktop demo](#omarchy-desktop-demo) below. | The "Boot pattern" column reflects how the demo enters the kernel: - **`kernel.boot`** — `kernelOwnedFs: true`, exec the language interpreter as the first user process. @@ -335,7 +336,12 @@ The "Boot pattern" column reflects how the demo enters the kernel: ### Wayland desktop demo -`/?demo=wayland` boots a four-program Wayland desktop: +`/?demo=wayland` boots a four-program Wayland desktop. The binaries come +from the `wldesktop` package, which publishes `wlcompositor`, `wlterm`, +`wlclock`, `wlpaint`, `klauncher` and `notify-send` under +`programs/<arch>/wldesktop/`. The sources stay in `programs/` so +`scripts/build-programs.sh` keeps building the fixtures the host smokes +resolve — the same split `sdl2-demo` and `modeset` use. - **wlcompositor** — a floating-window Wayland server (`wl_shm`, `xdg_shell`, `wl_seat`, `wl_output`) built on the wasm32 libwayland @@ -380,7 +386,38 @@ swizzle happens in the fragment shader and the scaling on the GPU (trilinear over a per-frame mip chain, so a downscaled desktop doesn't shimmer). A runtime GPU-compositing failure tears the compositor's EGL session down, which hands the canvas back to the pump presenter -(`markKmsCanvasGlReleased`) so the desktop keeps painting. A +(`markKmsCanvasGlReleased`) so the desktop keeps painting. A browser +GPU-process crash is detected on the same chain: when the canvas +context is lost, `host_gl_present` fails the guest's `eglSwapBuffers` +with `EIO`, the compositor degrades to CPU compositing, and EGL +teardown returns the canvas to the pump. The kernel worker installs +`webglcontextlost`/`webglcontextrestored` listeners when the canvas is +attached (`hookKmsContextLoss`) — not lazily at presenter build — so +the loss event is cancelled and the browser restores the context even +when the compositor's GL session owned the canvas at loss time; the +rebuilt pump presenter then finds a live context. Both crash rounds +(compositor-owned, then pump-owned) are gated by +`apps/browser-demos/test/kandelo-kms-context-loss.spec.ts`. The rebuilt +presenter inherits the dead session's WebGL2 context — `getContext` +returns the existing one — so it restores the state its draw depends on +rather than assuming fresh-context defaults. Pixel-store state matters +most: a leftover `UNPACK_ROW_LENGTH` or a still-bound +`PIXEL_UNPACK_BUFFER` makes every scanout upload `GL_INVALID_OPERATION`, +which raises no JS exception, so the pump would report presents onto a +black canvas. Six unpack parameters and the `PIXEL_UNPACK_BUFFER` +binding are reset on every rebuild, and +`host/test/dri-kms-stats-sab.test.ts` pins each reset individually +against a fake GL. That fake cannot see a rejected upload. The +real-context gate that could — `kandelo-kms-presenter.spec.ts` — is +deleted, because its fixture worker reached the kernel worker through a +writable instance property and a `tickVblank` cast, and the sealed +instance and the `#tickVblank` private field refuse both. +`kandelo-kms-context-loss.spec.ts` does run on a real context, but it +needs the built desktop binaries and the browser-demo CI job runs a bare +checkout with no binary fetch, so it cannot take the vacated CI slot. +Until that fixture is rebuilt on +`createCentralizedKernelWorkerTestDouble`, no automated gate proves a +rebuilt presenter's uploads are accepted. A main-thread ResizeObserver reports the pane's device-pixel size so the presenter renders at display resolution instead of letting the page compositor rescale an fb-sized bitmap; any letterbox is drawn in GL @@ -388,14 +425,85 @@ with the same contain math the pane's pointer mapping uses. The desktop itself also fills the pane: the boot flow feeds the pane's size to the kernel before spawning the compositor, and -`host_kms_mode_info` advertises a preferred mode matching the pane's -aspect ratio (`round(1080 × aspect) × 1080`, width clamped -[1440, 3840]; 1920×1080 fallback when no size is known). wlcompositor -sizes its scanout from that mode and its placement rules are -edge-anchored (wlterm left, wlclock/wlpaint offsets from the right -edge), so wider panes spread the demo across the full width with no -black bars. The mode is fixed at boot — resizing the browser window -afterwards letterboxes rather than re-modes. +`host_kms_mode_info` advertises that size as the preferred mode, even- +aligned and clamped to [640, 3840] × [480, 2160] (1920×1080 fallback +when no size is known). The size is measured on the pane's slot rather +than its canvas — `useFittedCanvasStyle` sizes the canvas from the +canvas's own aspect, which the mode itself sets, so measuring the +canvas would feed the mode back into itself. wlcompositor sizes its +scanout from that mode and its placement rules are edge-anchored +(wlterm left, wlclock/wlpaint offsets from the right edge), so wider +panes spread the demo across the full width with no black bars. The +mode is fixed at boot — resizing the browser window afterwards +letterboxes rather than re-modes. + +Because the mode is device pixels, a HiDPI pane gets a mode larger than +its CSS box, and nothing in the mode says which of the two it is. The +page passes the integer `wl_output` scale separately, as `WLC_SCALE` +(`round(devicePixelRatio)`, clamped 1–3) in the compositor's +environment. wlcompositor then keeps two grids: the mode sizes every +scanout, GBM bo, EGL surface and GL viewport, while the mode divided by +the scale is the logical grid clients lay out in. `wl_output.mode` +stays device pixels; `wl_output.scale`, `xdg_output`'s logical size and +`wp_fractional_scale`'s preference (scale × 120) all follow the scale. +A client that honours `wl_surface.set_buffer_scale` attaches a buffer +that covers its window 1:1 and blits without resampling; one that +ignores it is upscaled — soft, but correctly sized. + +The desktop's own clients honour it, and none of them had to change to. +libwpkdraw carries the scale instead: `wpk_set_scale()` is a +process-wide setting that `wpk_surface_wrap()` and +`wpk_font_load_default()` capture at call time, every primitive +multiplies its logical coordinates by it, and glyphs are rasterized at +`px * scale` rather than magnified. Metrics — `wpk_text_width()`, +`wpk_font_ascent_px()` — stay logical, so an app's layout is untouched. +libkwl reads `wl_output.scale`, calls `wpk_set_scale()` before any +buffer or font exists, allocates its wl_shm buffers at +`logical × scale`, and sends `wl_surface.set_buffer_scale`. wlterm, +wlclock, wlpaint, klauncher and notify-send are sharp on a HiDPI pane +without a single changed coordinate. wlcompositor never calls +`wpk_set_scale()` — it composites in device pixels already — so its own +drawing multiplies by `g.scale` by hand instead. Its gradient wallpaper +(the fallback when a theme names no image) does that for the grid pitch, +the two font sizes and every text offset. + +A third-party client learns the scale from `wl_surface.enter`, which +names the output the surface is on, and it makes that decision once — +before it draws. So the compositor sends the enter when a surface takes +a role (`xdg_surface.get_toplevel`, or a layer surface's first commit), +not when the surface maps. Map is a frame too late: the buffer being +mapped was already drawn at the wrong scale, and for a mako toast one +frame is the whole life of the surface. A client that binds `wl_output` +only after its surface has a role still gets the enter at map. + +A layer-shell surface also renegotiates its size after that first +configure — mako recomputes its toast one pixel shorter once it knows +the scale — so every commit of a layer surface re-applies its +double-buffered shell state (size, anchor, margins, exclusive zone, +layer) and answers with a fresh configure when the resolved box moved. +Applying it only on the first commit left mako waiting forever for a +configure that never came, and the toast never appeared. + +libkwl also caps an initial window at half the output's width and +three-fifths of its height, scaling both axes by the tighter ratio. A +toolkit client picks its initial size as a constant, and a constant +that suited a 2255×1080 desktop covers nearly all of a 1280×613 one; +the compositor clamps a floating window's position but not its size. +On a desktop roomy enough for the constant this changes nothing. +`host/test/wlcompositor-output-scale-smoke.test.ts` gates the protocol +side and `apps/browser-demos/test/kandelo-hidpi.spec.ts` gates the +whole chain at `deviceScaleFactor: 2`. + +The theme wallpapers cannot follow the mode. Each is baked into the VFS +image at compose time and the kernel owns the VFS from boot, while the +mode is only settled later — and the pane's own box is still moving +while it settles, so a compose-time measurement does not predict it. So +the page stages every pixel the source JPEG has (capped at 3840 per +axis) and wlcompositor centre-crops the KWLP to the output's aspect +before scaling it. That keeps a HiDPI desktop's background sharp and +undistorted at any pane aspect, at the cost of a larger image: six +themes staged eagerly at source resolution are ~49 MB of VFS instead of +the ~12 MB the fixed 960×540 staging cost. Pump presents are change-driven (kernel commit count, with a ~15 Hz strided-checksum content probe as a backstop) rather than @@ -489,6 +597,88 @@ The tiling paths are gated node-side by `host/test/wlcompositor-{tiling,resize,kwlctl,keybind,decoration}-smoke.test.ts` and in the browser by `apps/browser-demos/test/kandelo-hyprland.spec.ts`. +### Omarchy desktop demo + +`/?demo=omarchy` is the tiling desktop above plus the shell that makes it a +desktop: a status bar, a launcher, and themes. Omarchy is not a program but a +set of files layered over Hyprland, so this demo is the same `wlcompositor` +binary with its own `/etc/kandelo/wlcompositor.conf`, an app registry under +`/usr/share/kandelo/apps`, and six themes under `/usr/share/kandelo/themes` +— all staged into the VFS at boot from +`apps/browser-demos/pages/kandelo/kernel-host/omarchy-desktop.ts`. + +The desktop comes up bare: wallpaper and bar, no windows. Every client is one +the user opens, through the binds below or the launcher. The demo stays alive +on the compositor's own process rather than on a foreground terminal. + +- **The bar.** Unmodified upstream **Waybar 0.14.0** — the real GTK3 bar, on + the ported gtkmm/gtk-layer-shell stack, reading a translated version of + Omarchy's own `config.jsonc` and `style.css` from + `~/.config/waybar`. `gtk_layer_shell` anchors it across the top with an + exclusive zone, so the windows tile *under* it rather than behind it. Its + `hyprland/workspaces` and `hyprland/window` modules speak Hyprland IPC to + the compositor's socket pair at `/tmp/hypr/wlcompositor/` — `j/`-prefixed + JSON queries plus the `event>>data` stream — exactly as they would to + hyprctl. Modules that need hardware or daemons this kernel does not serve + (battery, cpu, memory, network, pulseaudio, tray) are not part of the + build, and the clock is Waybar's `simpleclock` (no timezone database). + GDK backs the bar's `wl_shm` pools with `gbm` prime-fd dumb bos (the + gtk3 package's `wayland-shm-gbm-pool.patch`, foot's contract), which is + what carries its pixels across to the compositor. The bar runs at + `-l debug`, so every Hyprland IPC event it consumes shows up in the + Internals syslog next to the compositor's own marker. +- **Notifications.** The demo boots a `dbus-daemon` session bus and + unmodified upstream mako on it. A theme switch reaches `notify-send` + through the config's `notify =` hook (the theme script above execs it) — a real + `org.freedesktop.Notifications.Notify` call over the bus, which mako + renders as a layer-shell toast in the top-right corner that dismisses + itself after five seconds. The bus address + (`DBUS_SESSION_BUS_ADDRESS`) is in every desktop process's + environment, so `notify-send` also works from any terminal. +- **The launcher.** `Ctrl+Space` opens `klauncher`, an overlay-layer surface + that takes the keyboard exclusively — so what you type filters its list + instead of reaching the terminal underneath. Type to narrow, `Up`/`Down` to + move, `Enter` to launch (the compositor spawns it and it tiles in), `Esc` to + dismiss. Entries come from `/usr/share/kandelo/apps`, one file per app. The + registry offers real software from the shell image alongside the demo + clients: Vim, NetHack and Nano run unmodified inside a `wlterm` (their + binaries lazy-fetch from the image's archives on first launch), plus a Bash + terminal. The Foot entry is different in kind: stock upstream foot 1.17.2 + as its own Wayland client on the ported font stack — + freetype/fontconfig/fcft rasterizing the staged Inconsolata through + `/etc/fonts/fonts.conf` — not a `wlterm` wrapper (see + [architecture.md](architecture.md#stock-upstream-clients-foot--the-font-stack)). +- **The menu.** `Ctrl+Alt+Space` opens the Omarchy menu — the same launcher + at its root level (Apps, Theme). `Enter` descends; the Theme submenu lists + the installed themes and `Enter` switches live. `Esc` in a submenu goes + back to the root; `Esc` at the root dismisses. +- **Themes.** `Ctrl+Shift+Space` cycles Tokyo Night, Catppuccin, Gruvbox, + Nord, Everforest and Rosé Pine. One palette file drives the whole desktop at + once: the compositor's window borders, gaps and wallpaper, the + launcher's own colours, which it reloads when the compositor broadcasts the + switch, and the bar's. Waybar reads its stylesheet once per load, as + upstream does, so the switch takes the path a real Omarchy session takes: + the compositor's `notify =` hook (`/usr/local/bin/omarchy-theme-changed`) + writes `~/.config/waybar/style.css` from the new `theme.conf` and sends + Waybar `SIGUSR2`, which reloads it. The hook then execs `notify-send`, so + the toast is the same one. Each theme ships its real Omarchy background, which the page + decodes and renders to raw pixels at staging time + (`renderImageWallpaperKwlp`, with an aurora fallback via + `renderWallpaperKwlp` if the decode fails); the compositor scales it to the + output and falls back to a gradient for themes without one. +- **The rest of the keybinds** are the Hyprland demo's: `Ctrl+Return` a + terminal, `Ctrl+K` a clock, `Ctrl+P` a paint canvas, `Ctrl+W` closes the + focused window, `Ctrl+1..9` switch workspaces, `Ctrl+J` cycles focus. Every + bind is mirrored on `SUPER` for a real Hyprland session; use `CTRL` in the + browser, which reserves `SUPER` (see the caveat above). + +See +[architecture.md](architecture.md#desktop-shell-zwlr_layer_shell_v1-kbar-klauncher-themes). +Gated node-side by +`host/test/wlcompositor-{layer-shell,theme}-smoke.test.ts`, +`host/test/{waybar,mako}-smoke.test.ts` and in the browser by +`apps/browser-demos/test/kandelo-omarchy.spec.ts`. + Run the browser app: `cd apps/browser-demos && npm run dev`, then open `http://127.0.0.1:5401/`. diff --git a/docs/plans/2026-05-25-dri-buffer-sharing-plan.md b/docs/plans/2026-05-25-dri-buffer-sharing-plan.md new file mode 100644 index 0000000000..591023be47 --- /dev/null +++ b/docs/plans/2026-05-25-dri-buffer-sharing-plan.md @@ -0,0 +1,2041 @@ +# DRI v2 — Buffer + Sharing Plan (`/dev/dri/renderD128` GBM dumb-buffer + prime fd) + +> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to execute this plan task-by-task. + +**Goal:** Add the GBM dumb-buffer allocation + prime-fd sharing surface on top of the v1 `/dev/dri/renderD128` device. Programs follow the standard Linux flow: `open` the render node → `DRM_IOCTL_MODE_CREATE_DUMB` (allocate a CPU-shared `bo`) → `DRM_IOCTL_MODE_MAP_DUMB` (get the mmap offset) → `mmap(fd, len, PROT_READ|PROT_WRITE, MAP_SHARED, fd, bo_offset)` → write/read pixels → `DRM_IOCTL_PRIME_HANDLE_TO_FD` (export bo as prime fd) → pass the prime fd to a peer process via `fork`-inherited fds (v2 SCM_RIGHTS is a separate plan) → peer `DRM_IOCTL_PRIME_FD_TO_HANDLE` (import) → peer mmaps the same bo and sees the same pixels. The proof-of-concept demo is **milestone (A) of the v2 design**: two processes round-trip a 256×256 ARGB8888 gradient through a shared bo. + +**Architecture:** The bo's backing pixels live in a kernel-owned `SharedArrayBuffer` slice (CPU-shared tier, see design §4.2). On `mmap` of a bo, the kernel allocates an anonymous region inside the process's wasm `Memory` (existing `MemoryManager::mmap_anonymous`) and calls the new `HostIO::gbm_bo_bind(pid, addr, bo_id)` callback; the host points that wasm-memory region at the bo's SAB slice via `MemoryManager::mmap_shared(addr, len, sab, offset)` — a new extension to the existing `mmap_anonymous` path. Writes through the wasm pointer hit the bo's SAB directly. No per-frame syscalls, no copies. `PRIME_HANDLE_TO_FD` allocates a new `OpenFileKind::PrimeBo { bo_id, cookie }` OFD; `PRIME_FD_TO_HANDLE` looks up `(bo_id, cookie)` and returns a per-process handle. fork inherits the prime-fd OFD by ref-bump on the bo. Companion design doc: `docs/plans/2026-05-18-dri-design.md` §4. + +**Tech Stack:** Rust kernel (wasm64), TypeScript host (browser + Node), C user programs cross-compiled with `wasm32posix-cc`, hand-written `libgbm.a` + minimal `libdrm.a` (≤ 400 LoC C) in the sysroot, Linux UAPI headers (`drm/drm.h`, `drm/drm_mode.h`, `drm/drm_fourcc.h`) vendored unmodified into `musl-overlay/include/drm/`. + +**Design reference:** `docs/plans/2026-05-18-dri-design.md` §4 (Buffer allocation & sharing model). The bo three-tier model (GPU / CPU-shared / CPU-private) is described in §4.2; **v1 of the buffer plan implements CPU-shared only**. GPU-tier bos (WebGLTexture-backed) require the multiplexer wired up and are deferred to the next plan (`docs/plans/2026-06-XX-dri-multiplexer-plan.md`). + +**Stack base:** v1 GLES2 demo branch tip — `explore-webgl-exposition-demo` (PR #38's head). The bo plan extends v1's `OpenFileKind`-side state (`Process::gl_state`, `VirtualDevice::DriRender0`, `HostIO::gl_*`) without modifying its existing surface. + +**Branch:** `emdash/explore-direct-rendering-infrastructure-buffer-plan-XXXXX` (chained off the previous DRI exploration branch per the user's "every new branch chains off the previous, except for new explorations" rule — the buffer plan is no longer a *new* exploration, it's the next step in DRI). Three sub-branches stack off it for the three PRs. + +**Final PR base:** `explore-webgl-exposition-demo`. **Do not merge** until Brandon validates the design, the plan, and Phase C's manual browser verification — see CLAUDE.md "no merge before Brandon's validation" rule. + +**Three PRs, coordinated merge.** Each task below is one commit. Three PR boundaries are marked. PR titles mirror Brandon's `scope(area): action` shape: + +1. `kernel(dri): GBM dumb-buffer + prime fd + bo mmap` +2. `host(dri): GbmRegistry + SAB-backed bo store + mmap_shared` +3. `examples(dri): dumb-buffer round-trip demo + vitest spec` + +PR base/head topology (stacked per the user's branching rule): + +``` +explore-webgl-exposition-demo (v1 tip; base of the chain) + └── …-buffer-plan-XXXXX (this plan PR; base = v1 tip) + └── …-buffer-kernel (PR #1: kernel(dri)) + └── …-buffer-host (PR #2: host(dri)) + └── …-buffer-demo (PR #3: examples(dri)) +``` + +**Verification gauntlet** (CLAUDE.md): all of the below must pass with zero regressions before any PR is opened, and re-run before final merge: + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +`XFAIL` / `TIME` are acceptable; `FAIL` that isn't pre-existing is a regression. Phase C adds: manual `./run.sh browser` verification of the dumb-buf demo (CLAUDE.md item 6). + +**ABI impact:** **Additive only — no `ABI_VERSION` bump.** Per `docs/abi-versioning.md` (PR #490 policy): new `repr(C)` structs in `shared::dri`, new ioctl numbers in the unused DRM `'d'` magic range, new `host_gbm_*` host imports. Existing struct layouts, syscall numbers, channel layout, kernel exports — all unchanged. The PR that lands the snapshot diff cites this rule explicitly (Phase A Task A10). + +--- + +## Pre-implementation review + +Devil's-advocate pass, 2026-05-19. Each item below is either *fixed in +place* in this plan (corrections to the drafted prose) or *open for the +implementation PR to address* (a bug, missing test, or architectural +concern in the design as drafted). + +### Correctness — fixed in place + +- **`DRM_IOCTL_VERSION` was `0xc018_6400` (Linux 64-bit value).** On + wasm32-ilp32 every field of `struct drm_version` is 4 bytes — three + `int`, three `__kernel_size_t`, three `char *` — for a 36-byte struct + and an ioctl encoding of `0xc024_6400`. *Folded the correction into + Task A1 Step 1; deleted the Step 1.5 self-correction artifact.* The + remaining seven `DRM_IOCTL_*` numbers were re-derived from `_IOWR` + by hand against the actual `repr(C)` struct sizes and all match + upstream Linux. +- **`WpkDrmVersion` had a misleading "pointers come first on Linux" + comment and a trailing "Field order above is illustrative" hedge.** + Field order *is* correct (interleaved `(len, ptr)` triples). *Removed + the hedge, filled in the missing byte-offset annotations, stated the + ordering convention positively.* +- **`bo.rs` Task A2 used `super::super::super::shared::dri::*`.** Invalid + module path — `super::super::super` from `crates/kernel/src/dri/bo.rs` + exits the crate root. `shared` is a sibling crate + (`crates/shared/`); the kernel imports it everywhere else as + `wasm_posix_shared::*` (e.g. `wasm_api.rs:26-27`). *Replaced with a + `use wasm_posix_shared::dri::DRM_FORMAT_MOD_LINEAR;` import and the + bare name at the call site.* +- **Task A10 said "8 new `host_gbm_*` imports"; Task A4 introduces 5.** + *Corrected to "5 new `host_gbm_*` imports (`_create / _destroy / + _bind / _unbind / _prime_exported`)".* +- **Task C4 child verified pixels via hardcoded `cpx[y * 256 + x]`** but + the parent writes via `bo->stride/4`. They coincide at 256×ARGB8888 + (stride 1024 → 256 px) but silently diverge at other widths/bpp. + *Switched child to `cbo->stride/4` to mirror the writer.* + +### Correctness — open, address in the implementation PRs + +- **Task A9 close-path refcount bug as drafted.** "When releasing an OFD + with `prime_bo: Some(p)`, call `decref(...)`" — but OFDs are + reference-counted across the fd-table (`dup` and `fork` share an OFD + via the same `OpenFileDesc` slot). The bo decref must fire only when + the **OFD's own refcount drops to zero**, not on every fd `close()`. + Action: scope the decref to the "destroy OFD" path, *not* the "close + fd" path, and add a vitest spec that `dup` + `close` on a prime fd + preserves the bo until the last fd closes. +- **Task A8 mmap unwind path is a stub.** The plan says + `if rc < 0 { /* unwind: munmap_anonymous + return rc */ }`. The unwind + must (a) call `munmap_anonymous(addr, length)`, (b) return the + original `rc`, and (c) if `munmap_anonymous` itself fails, log and + proceed (matching the Linux kernel's behaviour — leaking pages is + preferable to a tighter loop in cleanup). Spell this out; don't leave + the `/* … */` placeholder. +- **Cross-lock policy is implicit.** Tasks A6/A7/A9 acquire + `BoRegistry`, `HOST_IO`, and `PROCESS_TABLE` in mixed orders, but + always release each lock before taking the next (no nested holds), so + there's no deadlock in the drafted code. Action: add a doc-block to + the top of `crates/kernel/src/dri/mod.rs`: *"Never hold two of + {BoRegistry, HOST_IO, PROCESS_TABLE} at once. Copy out under the + first lock, then re-enter under the second."* + +### Architecture — open + +- **Task B2 (`mmap_shared`) fallback is hand-wavy.** "Fall back to + per-tick `Atomics` sync between the bo SAB and process Memory" — + without per-frame syscalls there's no host-side trigger for a "tick". + If `WebAssembly.Memory` cannot be aliased to a slice of an arbitrary + SAB across Chrome/Firefox/Safari, the **real** fallback is to + allocate every process's `Memory` from a global SAB pool that the + host can address by `(sab_offset)` — a larger redesign of + `MemoryManager`. **Run the B2 spike *first* in Phase B**, before B3. + Gate the rest of Phase B on its outcome; if the spike forces the + pool redesign, this plan needs an amendment (not a follow-up). +- **Task B3 cites a commit unreachable from this branch.** "Pattern + mirrors v1's `host_gl_*` wiring (commit `ec29a571` on + `explore-webgl-exposition-kernel`)." This branch is based on `main`; + the commit lives on a separate exploration chain. At implementation + time, retrieve it via + `git show explore-webgl-exposition-kernel:host/src/kernel.ts`, or + inline the pattern into B3 to make the task self-contained. + +### Missing tests — add in the implementation PRs + +- **`prime_handle_to_fd` idempotence on re-export.** Trade-offs claim + the prime cookie is idempotent on re-export (`ensure_prime_cookie` + enforces this), but Task A7's tests don't assert it. Add: two + consecutive `PRIME_HANDLE_TO_FD` calls on the same handle yield prime + fds carrying the **same** cookie. +- **`mmap` without a local handle returns `EACCES`.** Task A8 enforces + this (`if !proc.dri_handles.values().any(...) { return EACCES }`) + but no test exercises it. Add to A8 Step 3. +- **`dup` + `close` on a prime fd preserves the bo.** Companion to the + close-path refcount fix above. +- **Prime fd from a foreign process (no fork lineage) is rejected.** + With SCM_RIGHTS deferred, the only path to receive a prime fd is + fork-inherit; an importer that didn't fork from the exporter should + fail with `EBADF` or `EACCES`. Add a defensive test to A7's + `prime_round_trip_two_processes` that confirms the setup matches + the fork path (not arbitrary fd-injection). + +### Trade-offs verified against the design doc (`2026-05-18-dri-design.md` §4) + +- **GEM handles per-Process** (not per-fd) — matches design §4: v1 is + single-owner, namespace on `Process`. ✓ **Cross-plan follow-up + (added 2026-05-19 session 4):** plan 3 + (`docs/plans/2026-06-01-dri-multiplexer-plan.md`) Task A2 *lifts* + this to per-`OpenFileDesc` (`DriFdState`) as the structural refactor + that enables N-concurrent-opens. Every plan-2 callsite touched by + Tasks A3, A6-A9 is rewritten by plan 3 A2 Step 2 (the kernel + resolves `pid → fd_table → OFD → dri.handles` instead of + `pid → proc.dri_handles`). The per-Process choice in this plan is + thus an explicit incremental-landability simplification, not a + permanent shape — plan 2 lands first as drafted, plan 3 lifts. + Plan 3's Pre-implementation review walks the per-fd lift in detail. +- **CPU-shared tier only** — matches §4.2. ✓ **Cross-plan follow-up:** + plan 3 Task A3 activates the reserved `BoTier::Gpu` variant via a + new `BoRegistry::alloc_gpu` (separate method, not a parameter on + `alloc`) and `DRM_IOCTL_WPK_CREATE_GPU_BO`. Plan 2's `BoTier` enum + surface stays unchanged; plan 3 only adds new call sites. +- **`drm_version` zero-length strings in v1** — design doesn't require + strings; libdrm/libgbm tolerate empty. ✓ +- **`SCM_RIGHTS` deferred to the sockets plan** — matches §13. ✓ + +### Cross-plan follow-ups (lifts and replacements landing in plan 3) + +- The **"close-path refcount bug" flagged above under "Correctness — + open"** (every `close(prime_fd)` decremented the bo; the decref + should be at OFD-final-release, not per-fd-close) is **not** fixed + inside this plan. Plan 3 Task A5 introduces an `on_final_close` + hook on `OpenFileDesc` that fires only when the OFD's refcount + drops to zero, and explicitly **replaces** plan 2 Task A9 Step 2's + per-`close` decref. Reviewers walking plan 2 in isolation: this is + the documented bug; the fix is in the next plan, not in a plan-2 + amendment. Plan 2 PRs should still ship the per-`close` decref + (matches the drafted code) — plan 3 PR #1 rewrites the path. The + alternative — fixing it in plan 2 — would defer plan 2's landability + on an OFD-refcount-hook refactor that doesn't belong in the buffer + plan's scope. +- The **two OFD-side optional fields** (`prime_bo` here; plan 3 adds + `dri`) are flagged in plan 3's Pre-impl review as a candidate for + consolidation into a single `Option<Box<DriOfdState>>` enum. No + action needed in plan 2; mentioned for cross-plan awareness. + +### Cross-plan amendment from plan 8's devil's-advocate + +Plan 8's devil's-advocate pass (session 9) caught a `gbm_bo_map` +signature mismatch across plans 2 / 4 / 8. Plan 2's libgbm stub +(Task C3, line 1730+) is sketched calling `gbm_bo_map` with 9 args +(trailing NULLs); plan 4's modeset demo (Task C2, line 2099) mirrors +the 9-arg shape; plan 8's `wpk_surface_create` calls an 8-arg shape +with `&map_data` in the stride slot (pointer-shape mismatch). None +of the three matches upstream Mesa `libgbm`'s prototype +`void *gbm_bo_map(struct gbm_bo *bo, uint32_t x, uint32_t y, +uint32_t width, uint32_t height, uint32_t flags, uint32_t *stride, +void **map_data)`. + +*Resolution for plan 2:* implement the libgbm stub C3 at the +upstream 8-arg shape — `(bo, x, y, w, h, flags, uint32_t *stride, +void **map_data)`. The `stride` out-param is redundant with +`gbm_bo_get_stride(bo)` but the upstream signature includes it for +historical reasons; callers that pass `NULL` for `stride` should +fetch via `gbm_bo_get_stride`. Plan 4's modeset demo (C2) and plan +8's `wpk_surface_create` re-issue at the 8-arg call shape. The +stub implementation under `glue/libgbm_stub.c` does an mmap of the +bo's SAB-backed pages (already host-resident; no syscall round- +trip) and writes the bo's stride into `*stride` if non-NULL, and a +NULL handle into `*map_data` (the v1 stub doesn't track per-map +opaque state; `gbm_bo_unmap` is a no-op). One additional cargo +test under Task C3: `gbm_bo_map_signature_matches_upstream` — +compile-time check that the signature matches the Mesa upstream +prototype (header parity). + +### Cross-plan amendment from plan 7's devil's-advocate — gbm_surface follow-up (LOAD-BEARING) + +Plan 7's devil's-advocate pass (session 8) escalated this plan's +**`gbm_surface_create` deferral** (this plan's "What this plan +doesn't cover", line 1869+) into a **LOAD-BEARING cross-plan +blocker**. SDL2 2.30's KMSDRM backend (`SDL_kmsdrmvideo.c::KMSDRM_CreateSurfaces`) +calls `gbm_surface_create_with_modifiers`, `gbm_surface_lock_front_buffer`, +`gbm_surface_release_buffer`, and `gbm_surface_has_free_buffers` — +**none of which plan 2's libgbm stub (C3) ships**. Plan 7 PR #2 +(`sysroot(sdl2): SDL2 configure — KMSDRM + ALSA + evdev only`) is +**blocked on this gap closing**; plans 9–11 (compositor, wpk-shell, +seed apps with GL) also need `gbm_surface_*` for their EGL swap- +chains. + +*Resolution (plan-2 follow-up PR landed BEFORE plan 7 PR #2 can +merge):* extend plan 2's libgbm stub with a `gbm_surface` shim. +Surface model: + +- **A `gbm_surface` owns a small ring of bos** (v1: 3-bo triple + buffer, configurable via `gbm_surface_set_buffer_count(s, n)`) + — semantically a swap-chain over plan 2's existing `gbm_bo` + primitives. No new kernel surface; new state lives entirely + inside the C stub. +- **`gbm_surface_create_with_modifiers(dev, w, h, format, modifiers, + count)` and `gbm_surface_create(dev, w, h, format, flags)`** — + allocate the bo ring eagerly (3 × `gbm_bo_create` under the + hood with `GBM_BO_USE_SCANOUT | GBM_BO_USE_LINEAR`); return an + opaque `struct gbm_surface *` carrying the ring + a "currently + acquired" pointer-set. +- **`gbm_surface_lock_front_buffer(s)`** — atomically marks the + *most-recently-rendered* bo as "locked-by-scanout" and returns + it; the EGL/GLES rendering side draws into the *next-free* bo. + The "most-recently-rendered" pointer advances on every + `eglSwapBuffers` (or, in our case, the libegl-stub's `eglSwap` + shim — see plan 3 amendment below). +- **`gbm_surface_release_buffer(s, bo)`** — caller (typically the + KMS presenter that finished scanning out) releases the lock; + the bo returns to the free pool. +- **`gbm_surface_has_free_buffers(s)`** — returns nonzero iff at + least one bo is in the free pool (rendering can proceed without + blocking on present). +- **No GBM_BO_TRANSFER_WRITE coherence concern** — the bos are + plan 2's existing CPU-shared tier; mmap writes alias the SAB + directly per the existing trade-off. + +The stub holds the ring in user-space C state (no kernel-side +`gbm_surface_*` types — keeps the kernel ABI surface +unchanged). The `eglSwapBuffers` semantic comes from plan 3's +libegl-stub follow-up (see cross-plan amendment below in plan 3). + +*Package layout:* extend `examples/libs/libgbm/` (which plan 2 C3 +ships under `glue/libgbm_stub.c`) — `gbm_surface_*` becomes one +additional `.c` file in the same package, archived into the same +`libgbm.a`. **Increment `build.toml.revision`** when this lands +(post-merge of plan 2 PR #3, before plan 7 PR #2 opens). + +*Cargo + Vitest tests under the plan-2-follow-up PR:* +- `gbm_surface_create_allocates_three_bos` — assert + `BoRegistry.count()` rises by exactly 3 after a + `gbm_surface_create(640, 480, ARGB8888, 0)`. +- `gbm_surface_lock_then_release_round_trip` — lock, release, + lock again returns *a different* bo (front-buffer rotation). +- `gbm_surface_has_free_buffers_returns_false_when_all_locked` — + lock all 3 bos without releasing; assert subsequent + `_has_free_buffers` returns 0. +- `gbm_surface_destroy_frees_all_bos` — destroy a 3-bo surface + with one locked; assert `BoRegistry.count()` returns to baseline + AFTER the locked bo is released (lock-time bo ref keeps it + alive past `gbm_surface_destroy`; matches Mesa semantics). + +*Branch topology:* the plan-2-follow-up PR bases on plan 2 PR #3's +`…-buffer-demo` tip and is the **gate for plan 7 PR #2**. PR +title: `[explore-dri] sysroot(dri): libgbm — gbm_surface ring + +lock/release/has_free`. Aligns with the design doc §2 line 122 +re-reading ("v1 cmdbuf, EGL stubs, libGLESv2 stubs … are all +reused verbatim") — design doc's "reused verbatim" was incorrect +about the stub-as-static-lib shape; the static-lib carriers live +in this follow-up plus plan 3's libegl-stub / libgles2-stub +follow-up. Together the two follow-ups close plan 7's +open-architecture #2. + +*Cross-plan link:* plan 3 below carries the matching libEGL.a + +libGLESv2.a static-lib follow-up — the two follow-ups land +together (single inter-plan PR set) or in sequence (plan 2 first +since plan 3 imports `gbm_surface *` from plan 2's header). + +### Cross-plan amendment from plan 9's devil's-advocate — gbm_bo_import cookie contract + gbm_bo_get_fd freshness + SCANOUT+RENDERING+LINEAR ring (LOAD-BEARING) + +Plan 9's devil's-advocate pass (session 10) surfaced three contracts +this plan must lock in EXPLICITLY before plan 9's compositor opens. +Each closes a use-after-free or wrong-stride hazard plan 9 D3 + +E1 would silently hit otherwise. + +1. **`gbm_bo_import(GBM_BO_IMPORT_FD, &data, ...)` MUST internally + issue `DRM_IOCTL_PRIME_FD_TO_HANDLE`.** Per design §13, prime + fds carry `OpenFileKind::PrimeBo { bo_id, cookie }`. The cookie + is verified + the bo refcount is bumped inside the + PRIME_FD_TO_HANDLE ioctl. Plan 9's `compositor_handle_attach_buffer` + (D3 line 1145) closes the prime fd IMMEDIATELY after calling + `gbm_bo_import`, relying on the import call having already + bumped the kernel-side refcount. **Contract: the libgbm + wrapper MUST issue PRIME_FD_TO_HANDLE under the hood; the + caller MUST be free to `close(data.fd)` immediately on + success.** Without this, the import is a fd-borrow and the + close is a use-after-free. Document explicitly in the C3 task + body when extending the stub; add a cargo test that asserts + bo refcount is 1 after `gbm_bo_import` + `close(prime_fd)` and + the bo remains accessible. The test guard fires in plan 9's + missing-tests register. +2. **`gbm_bo_get_fd(bo)` returns a FRESH prime fd per call + (incrementing kernel-side refcount), not a cached fd.** Plan + 9 E1's `wpk_surface_present_via_compositor` calls + `gbm_bo_get_fd(s->bo_front)` once per present and `close(pfd)` + after `wpk_client_attach_buffer`. The pattern depends on the + call ALWAYS allocating a fresh PrimeBo OFD (i.e., the wrapper + issues `DRM_IOCTL_PRIME_HANDLE_TO_FD` per invocation). Match + upstream Mesa semantics; document in the gbm_surface follow-up. +3. **The compositor's `gbm_surface` uses `GBM_BO_USE_SCANOUT | + GBM_BO_USE_RENDERING`.** Plan 9 D2 line 1063-1064 requests + this combination. Plan 2's follow-up ring tags bos as + `GBM_BO_USE_SCANOUT | GBM_BO_USE_LINEAR` (line 250) — the + RENDERING flag isn't mentioned. **Contract: the libgbm stub + treats SCANOUT, RENDERING, and LINEAR as equivalent in v1 + (always LINEAR / always SAB-backed / always scanout-capable); + any combination of flags passes the same allocation path.** + Document explicitly so the EGL stub + compositor + plan 7's + SDL2 KMSDRM backend all see the same behavior. + +These three contracts gate plan 9 implementation start (see plan +9 open-architecture #3 for the LOAD-BEARING flag on #1 +specifically). All three are pinned at plan 2 follow-up impl time; +no kernel changes required (the ioctls already exist per design +§13). + +### Deliberately not flagged + +- `drm_prime_handle.fd` is `__s32` (signed) — plan correctly types it + `i32`. ✓ +- wasm32 `time_t` width — §4 carries no timestamps. ✓ +- `DRM_FORMAT_*` little-endian fourcc constants — verbatim from Linux + UAPI; checked by eye. ✓ + +--- + +## Phase A — Kernel GBM bo + prime fd surface (PR #1) + +The kernel learns to allocate bos, hand out prime fds, and bind bo memory into the process's wasm address space on `mmap`. No host wiring yet — Phase A's tests are pure cargo (kernel-internal), and the `HostIO::gbm_*` callbacks are stubbed out in `host/test/centralized-test-helper.ts` to capture call args. + +### Task A1: Shared ABI module `shared::dri` + +**Files:** +- Modify: `crates/shared/src/lib.rs` — add `pub mod dri` near the other ABI modules (next to `pub mod fbdev`). + +**Step 1: Add the constants and structs** + +Append after `pub mod fbdev { … }` in `crates/shared/src/lib.rs`: + +```rust +/// Linux DRM `/dev/dri/*` ABI — ioctl numbers, fourcc constants, and +/// marshalled argument structs. +/// +/// Numbers are encoded with `_IOWR('d', nr, struct)` where `'d' = 0x64`. +/// Struct field offsets must match the Linux ABI byte-for-byte; bumping +/// `ABI_VERSION` is not required for *adding* new structs (additive +/// compatibility, see `docs/abi-versioning.md`), but any change to an +/// existing struct's layout requires a snapshot regen and a version bump. +pub mod dri { + /// DRM ioctl magic ('d'). + pub const DRM_IOCTL_BASE: u32 = 0x64; + + // --- ioctl numbers ----------------------------------------------------- + // Derivation: dir=11 (READ|WRITE), size=struct sizeof, magic='d', nr=… + // Encoded: (dir << 30) | (size << 16) | (magic << 8) | nr + // The constants below are the byte-for-byte Linux values; the tests in + // Step 2 re-derive them from `_IOWR!` to catch drift. + + /// `_IOWR('d', 0x00, drm_version)` — driver name / date / desc query. + /// `struct drm_version` is 36 bytes on wasm32 (ilp32: 3 × `int` + 3 × + /// `__kernel_size_t` + 3 × `char *`, all 4-byte). Ioctl number encodes + /// 36 → `0xc0246400`. Linux x86_64's 60-byte layout is not us. + pub const DRM_IOCTL_VERSION: u32 = 0xc024_6400; + + /// `_IOWR('d', 0x0c, drm_get_cap)` — feature capability query. + pub const DRM_IOCTL_GET_CAP: u32 = 0xc010_640c; + + /// `_IOW('d', 0x09, drm_gem_close)` — drop a GEM handle. + pub const DRM_IOCTL_GEM_CLOSE: u32 = 0x4008_6409; + + /// `_IOWR('d', 0x2d, drm_prime_handle)` — export bo as prime fd. + pub const DRM_IOCTL_PRIME_HANDLE_TO_FD: u32 = 0xc00c_642d; + + /// `_IOWR('d', 0x2e, drm_prime_handle)` — import prime fd as bo handle. + pub const DRM_IOCTL_PRIME_FD_TO_HANDLE: u32 = 0xc00c_642e; + + /// `_IOWR('d', 0xb2, drm_mode_create_dumb)` — allocate dumb buffer. + pub const DRM_IOCTL_MODE_CREATE_DUMB: u32 = 0xc020_64b2; + + /// `_IOWR('d', 0xb3, drm_mode_map_dumb)` — fetch dumb-buffer mmap offset. + pub const DRM_IOCTL_MODE_MAP_DUMB: u32 = 0xc010_64b3; + + /// `_IOWR('d', 0xb4, drm_mode_destroy_dumb)` — drop dumb buffer. + pub const DRM_IOCTL_MODE_DESTROY_DUMB: u32 = 0xc004_64b4; + + // --- DRM_GET_CAP keys (clients call to probe features) ---------------- + + pub const DRM_CAP_DUMB_BUFFER: u64 = 0x1; + pub const DRM_CAP_PRIME: u64 = 0x5; + pub const DRM_PRIME_CAP_IMPORT: u64 = 0x1; + pub const DRM_PRIME_CAP_EXPORT: u64 = 0x2; + + // --- DRM_FORMAT_* fourcc constants (subset needed for v1) ------------- + + /// `'A','R','2','4'` little-endian, BGRA-in-memory. + pub const DRM_FORMAT_ARGB8888: u32 = 0x3432_5241; + /// `'X','R','2','4'` little-endian, BGRX-in-memory. + pub const DRM_FORMAT_XRGB8888: u32 = 0x3432_5258; + /// `'R','G','1','6'` 5-6-5 LE. + pub const DRM_FORMAT_RGB565: u32 = 0x3635_3147; + + /// `DRM_FORMAT_MOD_LINEAR` — the only modifier we accept in v1. + pub const DRM_FORMAT_MOD_LINEAR: u64 = 0; + + // --- marshalled structs ------------------------------------------------ + + /// Linux `struct drm_mode_create_dumb` (32 bytes, identical layout on + /// wasm32 and x86_64 — fixed-width fields only). + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmModeCreateDumb { + pub height: u32, // 0 in + pub width: u32, // 4 in + pub bpp: u32, // 8 in bits-per-pixel (32 for ARGB8888) + pub flags: u32, // 12 in must be 0 + pub handle: u32, // 16 out process-local bo handle + pub pitch: u32, // 20 out stride in bytes + pub size: u64, // 24 out total bytes (pitch * height) + // total: 32 + } + + /// Linux `struct drm_mode_map_dumb` (16 bytes). + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmModeMapDumb { + pub handle: u32, // 0 in + pub pad: u32, // 4 reserved + pub offset: u64, // 8 out pass to mmap() as the file offset + // total: 16 + } + + /// Linux `struct drm_mode_destroy_dumb` (4 bytes). + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmModeDestroyDumb { + pub handle: u32, // 0 + // total: 4 + } + + /// Linux `struct drm_gem_close` (8 bytes). + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmGemClose { + pub handle: u32, // 0 + pub pad: u32, // 4 + // total: 8 + } + + /// Linux `struct drm_prime_handle` (12 bytes). Reused both for + /// HANDLE_TO_FD (handle → fd, flags=O_CLOEXEC|O_RDWR-ish) and + /// FD_TO_HANDLE (fd → handle). + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmPrimeHandle { + pub handle: u32, // 0 in/out + pub flags: u32, // 4 in O_CLOEXEC/O_RDWR; we accept any, store none + pub fd: i32, // 8 in/out signed (-1 on error sentinel; -EBADF tests) + // total: 12 + } + + /// Linux `struct drm_get_cap` (16 bytes). + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmGetCap { + pub capability: u64, // 0 in DRM_CAP_* constant + pub value: u64, // 8 out + // total: 16 + } + + /// Linux `struct drm_version` — used by `DRM_IOCTL_VERSION`. 36 bytes on + /// wasm32 (ilp32: 3 × `int` + 3 × `__kernel_size_t` + 3 × `char *`, all + /// 4-byte). Field order matches `include/uapi/drm/drm.h` — interleaved + /// `(len, ptr)` triples (not "lens first, then ptrs"). The kernel reads + /// `*_len` (caller-allocated capacity), writes strings via the three + /// pointers, and updates `*_len` to bytes actually written. v1 writes + /// zero-length strings (see Task A5); the field shape is fixed for the + /// future string-write path. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmVersion { + pub version_major: i32, // 0 + pub version_minor: i32, // 4 + pub version_patchlevel: i32, // 8 + pub name_len: u32, // 12 in/out + pub name_ptr: u32, // 16 wasm32 user pointer + pub date_len: u32, // 20 in/out + pub date_ptr: u32, // 24 wasm32 user pointer + pub desc_len: u32, // 28 in/out + pub desc_ptr: u32, // 32 wasm32 user pointer + // total: 36 + } +} +``` + +**Step 2: Add a static-assert + ioctl-encoding test** + +Append to the `#[cfg(test)] mod` block in `crates/shared/src/lib.rs`: + +```rust +#[cfg(test)] +mod dri_tests { + use super::dri::*; + use core::mem::size_of; + + /// `_IOWR(magic, nr, type)` packs (dir, size, magic, nr) into a u32. + /// Mirrors include/uapi/asm-generic/ioctl.h. + const fn ioc(dir: u32, magic: u32, nr: u32, size: u32) -> u32 { + (dir << 30) | (size << 16) | (magic << 8) | nr + } + const IOC_READ: u32 = 2; + const IOC_WRITE: u32 = 1; + + #[test] + fn struct_sizes_match_linux_abi() { + assert_eq!(size_of::<WpkDrmModeCreateDumb>(), 32); + assert_eq!(size_of::<WpkDrmModeMapDumb>(), 16); + assert_eq!(size_of::<WpkDrmModeDestroyDumb>(), 4); + assert_eq!(size_of::<WpkDrmGemClose>(), 8); + assert_eq!(size_of::<WpkDrmPrimeHandle>(), 12); + assert_eq!(size_of::<WpkDrmGetCap>(), 16); + assert_eq!(size_of::<WpkDrmVersion>(), 36); + } + + #[test] + fn ioctl_numbers_match_linux_uapi() { + let iowr = IOC_READ | IOC_WRITE; + assert_eq!(DRM_IOCTL_VERSION, + ioc(iowr, 'd' as u32, 0x00, size_of::<WpkDrmVersion>() as u32)); + assert_eq!(DRM_IOCTL_GET_CAP, + ioc(iowr, 'd' as u32, 0x0c, size_of::<WpkDrmGetCap>() as u32)); + assert_eq!(DRM_IOCTL_GEM_CLOSE, + ioc(IOC_WRITE, 'd' as u32, 0x09, size_of::<WpkDrmGemClose>() as u32)); + assert_eq!(DRM_IOCTL_PRIME_HANDLE_TO_FD, + ioc(iowr, 'd' as u32, 0x2d, size_of::<WpkDrmPrimeHandle>() as u32)); + assert_eq!(DRM_IOCTL_PRIME_FD_TO_HANDLE, + ioc(iowr, 'd' as u32, 0x2e, size_of::<WpkDrmPrimeHandle>() as u32)); + assert_eq!(DRM_IOCTL_MODE_CREATE_DUMB, + ioc(iowr, 'd' as u32, 0xb2, size_of::<WpkDrmModeCreateDumb>() as u32)); + assert_eq!(DRM_IOCTL_MODE_MAP_DUMB, + ioc(iowr, 'd' as u32, 0xb3, size_of::<WpkDrmModeMapDumb>() as u32)); + assert_eq!(DRM_IOCTL_MODE_DESTROY_DUMB, + ioc(iowr, 'd' as u32, 0xb4, size_of::<WpkDrmModeDestroyDumb>() as u32)); + } +} +``` + +**Step 3: Run the tests** + +```bash +cargo test -p wasm-posix-shared --target aarch64-apple-darwin --lib dri_tests +``` + +Expected: 2 tests pass. Layout bugs surface here, not on a real wasm program. + +**Step 4: Commit** + +```bash +git add crates/shared/src/lib.rs +git commit -m "kernel(dri): shared ABI module — DRM_IOCTL_* numbers + GBM structs" +``` + +--- + +### Task A2: `BoRegistry` global static + `BoId` allocator + +**Files:** +- Create: `crates/kernel/src/dri/mod.rs` +- Create: `crates/kernel/src/dri/bo.rs` +- Modify: `crates/kernel/src/lib.rs` — `pub mod dri;`. + +**Step 1: Create the `dri` submodule** + +`crates/kernel/src/dri/mod.rs`: + +```rust +//! DRI v2 — buffer (GBM) and KMS support for /dev/dri/*. +//! +//! v1 of this module covers the **buffer-sharing** surface only (this +//! plan): bo allocation, mmap binding, prime-fd export/import. The +//! multiplexer (§5) and KMS card0 (§6) live in their own submodules +//! added by later plans. + +pub mod bo; + +pub use bo::{BoId, BoRegistry, GbmBo, BoTier}; +``` + +`crates/kernel/src/dri/bo.rs`: + +```rust +//! GBM buffer-object registry: a single global `BoRegistry` owns every +//! live bo across processes. Per-process state (the GEM-handle → BoId +//! map) lives on `Process::dri_handles`. The bo registry is the source +//! of truth for refcount + backing. +//! +//! Cross-process semantics: +//! - `gbm_bo_create` (CREATE_DUMB) bumps a fresh BoId, refcount = 1, +//! inserts the bo, returns a process-local handle pointing at it. +//! - `prime_handle_to_fd` allocates a new `OpenFileKind::PrimeBo` +//! carrying `(BoId, cookie)`; refcount += 1. +//! - `prime_fd_to_handle` on a PrimeBo OFD bumps refcount, inserts +//! another process-local handle mapping. The receiver can `mmap` +//! the bo through this handle just like the creator. +//! - `gem_close` (or implicit close of the last process-local handle +//! for a bo) decrements refcount. Refcount-to-zero frees the +//! underlying SAB. + +extern crate alloc; + +use alloc::collections::BTreeMap; +use alloc::vec::Vec; +use spin::Mutex; +use wasm_posix_shared::dri::DRM_FORMAT_MOD_LINEAR; + +/// Global, monotonic bo id. Never reused; freed bos leave a "tombstone" +/// gap so a leaked prime fd cookie cannot resurrect a different bo. +pub type BoId = u32; + +/// Cookie for prime-fd capability check. A bo's cookie is set at first +/// `prime_handle_to_fd` and stays for the bo's lifetime; an importer +/// that doesn't match it gets EACCES. +pub type PrimeCookie = u64; + +/// Bo tier (see design §4.2). v1 of this plan implements `CpuShared` +/// only; the others are reserved variants so the enum surface is +/// stable when later plans add them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BoTier { + /// CPU-shared SAB-backed bo. Host owns the SAB; the kernel-side + /// `host_handle` references it indirectly via `BoId → SAB` in the + /// host's `GbmRegistry`. mmap binds wasm-memory at the SAB. + CpuShared, + /// Reserved for §4.2 "GPU tier" — WebGLTexture-backed, exported + /// via `EGL_EXT_image_dma_buf_import`. Not implemented in v1. + Gpu, + /// Reserved for §4.2 "CPU private" — kernel-worker-local + /// `Uint8Array`. Not implemented in v1. + CpuPrivate, +} + +#[derive(Debug, Clone)] +pub struct GbmBo { + pub id: BoId, + pub width: u32, + pub height: u32, + pub format: u32, // DRM_FORMAT_* (ARGB8888 etc.) + pub modifier: u64, // DRM_FORMAT_MOD_LINEAR only in v1 + pub stride: u32, // bytes per row, host-decided + pub size: u64, // stride * height + pub bpp: u32, // matches CREATE_DUMB.bpp; 32 for ARGB8888 + pub tier: BoTier, + pub refcount: u32, + pub prime_cookie: Option<PrimeCookie>, +} + +#[derive(Default)] +pub struct BoRegistry { + next_id: BoId, + next_cookie: u64, + map: BTreeMap<BoId, GbmBo>, +} + +static REGISTRY: Mutex<BoRegistry> = Mutex::new(BoRegistry { + next_id: 1, + next_cookie: 1, + map: BTreeMap::new(), +}); + +pub fn with_registry<R>(f: impl FnOnce(&mut BoRegistry) -> R) -> R { + f(&mut REGISTRY.lock()) +} + +impl BoRegistry { + pub fn alloc( + &mut self, + width: u32, + height: u32, + bpp: u32, + format: u32, + ) -> &mut GbmBo { + let id = self.next_id; + self.next_id += 1; + let stride = ((width * bpp).div_ceil(8) + 3) & !3; // round up to 4 + let size = (stride as u64) * (height as u64); + let bo = GbmBo { + id, + width, + height, + format, + modifier: DRM_FORMAT_MOD_LINEAR, + stride, + size, + bpp, + tier: BoTier::CpuShared, + refcount: 1, + prime_cookie: None, + }; + self.map.insert(id, bo); + self.map.get_mut(&id).unwrap() + } + + pub fn get(&self, id: BoId) -> Option<&GbmBo> { self.map.get(&id) } + pub fn get_mut(&mut self, id: BoId) -> Option<&mut GbmBo> { + self.map.get_mut(&id) + } + + pub fn incref(&mut self, id: BoId) -> Option<u32> { + let bo = self.map.get_mut(&id)?; + bo.refcount = bo.refcount.checked_add(1)?; + Some(bo.refcount) + } + + /// Returns Some(new_refcount). When new_refcount drops to 0, the + /// caller MUST also call `host_io.gbm_bo_destroy(bo_id)` to drop + /// the host-side SAB before forgetting the bo. + pub fn decref(&mut self, id: BoId) -> Option<u32> { + let bo = self.map.get_mut(&id)?; + bo.refcount = bo.refcount.saturating_sub(1); + let rc = bo.refcount; + if rc == 0 { + self.map.remove(&id); // tombstone: id is not reused + } + Some(rc) + } + + /// Issues a fresh, unguessable cookie for first PRIME_HANDLE_TO_FD + /// on this bo. Idempotent: subsequent exports of the same bo reuse + /// the existing cookie (Linux-shape). + pub fn ensure_prime_cookie(&mut self, id: BoId) -> Option<PrimeCookie> { + let bo = self.map.get_mut(&id)?; + if let Some(c) = bo.prime_cookie { + return Some(c); + } + // Cookie is monotonic + a 32-bit nonce. v1 doesn't need + // crypto-grade unguessability since the kernel boundary is + // process-level; we just want low collision risk across reboots. + let c = self.next_cookie | ((self.next_id as u64) << 32); + self.next_cookie = self.next_cookie.wrapping_add(1); + bo.prime_cookie = Some(c); + Some(c) + } +} +``` + +**Step 2: Cargo tests** + +Append to `crates/kernel/src/dri/bo.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn alloc_assigns_monotonic_ids() { + with_registry(|r| { + let a = r.alloc(64, 64, 32, 0).id; + let b = r.alloc(64, 64, 32, 0).id; + assert!(b > a); + }); + } + + #[test] + fn decref_to_zero_removes() { + with_registry(|r| { + let id = r.alloc(64, 64, 32, 0).id; + assert_eq!(r.decref(id), Some(0)); + assert!(r.get(id).is_none()); + }); + } + + #[test] + fn incref_then_decref_keeps_alive() { + with_registry(|r| { + let id = r.alloc(64, 64, 32, 0).id; + r.incref(id); + assert_eq!(r.decref(id), Some(1)); + assert!(r.get(id).is_some()); + r.decref(id); + }); + } + + #[test] + fn prime_cookie_is_idempotent() { + with_registry(|r| { + let id = r.alloc(64, 64, 32, 0).id; + let c1 = r.ensure_prime_cookie(id).unwrap(); + let c2 = r.ensure_prime_cookie(id).unwrap(); + assert_eq!(c1, c2); + r.decref(id); + }); + } + + #[test] + fn stride_rounds_up_to_4_bytes() { + with_registry(|r| { + // 17px wide @ 32bpp → naive 68 bytes; should round to 68 already. + let bo = r.alloc(17, 1, 32, 0); + assert_eq!(bo.stride, 68); + let id = bo.id; + r.decref(id); + // 17px @ 8bpp → naive 17 bytes; round to 20. + let bo2 = r.alloc(17, 1, 8, 0); + assert_eq!(bo2.stride, 20); + let id2 = bo2.id; + r.decref(id2); + }); + } +} +``` + +**Step 3: Wire it up** + +In `crates/kernel/src/lib.rs`, add `pub mod dri;` alongside the other modules. + +**Step 4: Run** + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib dri::bo +``` + +Expected: 5 tests pass. + +**Step 5: Commit** + +```bash +git add crates/kernel/src/dri/ crates/kernel/src/lib.rs +git commit -m "kernel(dri): BoRegistry + BoId allocator (CPU-shared tier only)" +``` + +--- + +### Task A3: `Process::dri_handles` map + `PrimeBoState` ofd kind + +**Files:** +- Modify: `crates/kernel/src/process.rs` — add `dri_handles: BTreeMap<u32, BoId>` field on `Process`. +- Modify: `crates/kernel/src/ofd.rs` — add `PrimeBoState` struct, hung off `OpenFileDesc` via a new optional field. v2 keeps the existing `FileType` enum unchanged (PrimeBo OFDs use `FileType::CharDevice` — see Trade-offs). +- Modify: `crates/kernel/src/fork.rs` — fork resets `dri_handles` to empty (per design §13 "fork interaction"); existing PrimeBo OFDs survive the dup because the bo refcount tracks the OFD copy. + +**Step 1: Add `dri_handles` to `Process`** + +In `crates/kernel/src/process.rs`, after `fb_binding: Option<FbBinding>`: + +```rust + /// Per-process GEM-handle → global BoId map for `/dev/dri/renderD128`. + /// + /// Linux GEM handles are per-fd; we collapse to per-process because v1 + /// of renderD128 is single-owner (see Trade-offs in the buffer plan). + /// When the multiplexer plan lifts single-owner, this becomes per-fd. + pub dri_handles: BTreeMap<u32, crate::dri::BoId>, + /// Next handle id to issue; never wraps below 1. Linux numbers handles + /// per-fd starting at 1; we mirror that, scoped per-process. + pub dri_next_handle: u32, +``` + +Default-initialise in `Process::new()` and every fork/exec path: `dri_handles: BTreeMap::new(), dri_next_handle: 1`. + +**Step 2: Add `PrimeBoState` to `OpenFileDesc`** + +In `crates/kernel/src/ofd.rs`: + +```rust +/// State for a prime-fd OFD — capability cookie binding fd → bo. +#[derive(Clone, Debug)] +pub struct PrimeBoState { + pub bo_id: crate::dri::BoId, + pub cookie: crate::dri::PrimeCookie, +} + +// On `OpenFileDesc`, add: +// pub prime_bo: Option<PrimeBoState>, +// Default to None. +``` + +Set `file_type: FileType::CharDevice` on PrimeBo OFDs to keep the existing select/poll/dup paths unchanged. The `host_handle` field is reused as a sentinel: `-200` for prime-bo OFDs (alongside the existing `-1..-7` for VirtualDevice and `-100/-160` for synthetic-file/devfs-dir). + +**Step 3: fork inherits prime-fd OFDs** + +In `crates/kernel/src/fork.rs`, around the OFD-table dup: existing logic already bumps OFD refcount per fd dup (the OFD-table is shared by ref-count). We just need to bump the bo refcount once per prime-bo OFD inherited. Find every fork path that walks the fd table and, for each fd whose OFD has `prime_bo: Some(_)`, call `crate::dri::with_registry(|r| r.incref(bo_id))`. + +Reset `dri_handles` to empty in the child: + +```rust +// In Process::clone_for_fork (or wherever the child Process is built): +dri_handles: BTreeMap::new(), +dri_next_handle: 1, +``` + +The rationale (design §13): GEM handles are per-fd state on Linux; `fork` gives the child a fresh GEM namespace through the same fd. The child has to import the inherited prime-fd OFDs explicitly via `PRIME_FD_TO_HANDLE` to address bos. + +**Step 4: Cargo tests** + +Append to `crates/kernel/src/dri/bo.rs` (or a new `bo_fork_tests.rs`): + +```rust +#[test] +fn prime_inherit_bumps_refcount() { + with_registry(|r| { + let id = r.alloc(64, 64, 32, 0).id; + r.incref(id); // simulate prime-fd export + r.incref(id); // simulate fork-inherited dup + assert_eq!(r.get(id).unwrap().refcount, 3); + // Drop creator's handle, fork child's prime fd, exported prime fd + r.decref(id); + r.decref(id); + assert_eq!(r.get(id).unwrap().refcount, 1); + r.decref(id); + assert!(r.get(id).is_none()); + }); +} +``` + +**Step 5: Run** + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib dri +``` + +Expected: previous tests + new one all pass. + +**Step 6: Commit** + +```bash +git add crates/kernel/src/process.rs crates/kernel/src/ofd.rs crates/kernel/src/fork.rs crates/kernel/src/dri/ +git commit -m "kernel(dri): Process::dri_handles + PrimeBoState ofd + fork inherit" +``` + +--- + +### Task A4: `HostIO` GBM trait methods (5 methods) + +**Files:** +- Modify: `crates/kernel/src/process.rs` — extend the `HostIO` trait. +- Modify: `host/test/centralized-test-helper.ts` — stub the new methods to capture call args for kernel-side cargo tests. + +**Step 1: Extend the trait** + +Append to the `HostIO` trait in `crates/kernel/src/process.rs`: + +```rust + // --- DRI v2 buffer-sharing surface (this plan) ----------------------- + + /// Allocate host-side SAB backing for a freshly-created bo. Called + /// once per `DRM_IOCTL_MODE_CREATE_DUMB`. Host returns the host's + /// internal id for the SAB slice (which the kernel doesn't otherwise + /// use — host-side bo lookup is by `bo_id`). Returns ≥ 0 on success, + /// negative errno on failure. + fn gbm_bo_create(&mut self, pid: i32, bo_id: u32, size: u64, width: u32, + height: u32, stride: u32, format: u32) -> i32; + + /// Free host-side SAB backing for a bo whose refcount has reached 0. + fn gbm_bo_destroy(&mut self, pid: i32, bo_id: u32); + + /// Bind a bo's SAB slice into a process's wasm `Memory` at `addr` for + /// `len` bytes. Called from the mmap path once `mmap_anonymous` has + /// reserved the wasm pages. After this returns, writes to `[addr, + /// addr+len)` go directly to the SAB slice. + fn gbm_bo_bind(&mut self, pid: i32, bo_id: u32, addr: usize, len: usize) + -> i32; + + /// Unbind a prior `gbm_bo_bind` — called from munmap / process-exit + /// before the wasm pages are returned to the anonymous pool. + fn gbm_bo_unbind(&mut self, pid: i32, bo_id: u32, addr: usize, + len: usize); + + /// Notify the host that a prime fd has been exported. The host + /// doesn't need to do anything for v1 (the SAB is already shared + /// across processes; export/import is a kernel-side bookkeeping + /// op) — this exists so future tiers (GPU-tier with EGLImage) + /// can hook here. + fn gbm_bo_prime_exported(&mut self, pid: i32, bo_id: u32); +``` + +**Step 2: Stub in the test helper** + +In `host/test/centralized-test-helper.ts`, the existing mock host has a `mockHostIo` shape. Add: + +```ts +const gbmCalls: Array<{op: string, args: any}> = []; + +const gbmStubs = { + gbm_bo_create: (pid: number, bo_id: number, size: bigint, w: number, + h: number, stride: number, format: number) => { + gbmCalls.push({op: 'create', args: {pid, bo_id, size, w, h, stride, format}}); + return 0; // success + }, + gbm_bo_destroy: (pid: number, bo_id: number) => { + gbmCalls.push({op: 'destroy', args: {pid, bo_id}}); + }, + gbm_bo_bind: (pid: number, bo_id: number, addr: number, len: number) => { + gbmCalls.push({op: 'bind', args: {pid, bo_id, addr, len}}); + return 0; + }, + gbm_bo_unbind: (pid: number, bo_id: number, addr: number, len: number) => { + gbmCalls.push({op: 'unbind', args: {pid, bo_id, addr, len}}); + }, + gbm_bo_prime_exported: (pid: number, bo_id: number) => { + gbmCalls.push({op: 'prime_exported', args: {pid, bo_id}}); + }, +}; +``` + +Export `gbmCalls` so kernel-driven host tests can assert against the recorded sequence. + +**Step 3: Run** + +```bash +cargo build -p wasm-posix-kernel --target aarch64-apple-darwin +``` + +Expected: builds clean. Trait additions must be matched in any concrete `HostIO` impl in the kernel test fixtures (`host_io.rs`-style mocks). The cargo build is the proof; any missing impls fail at compile time. + +**Step 4: Commit** + +```bash +git add crates/kernel/src/process.rs host/test/centralized-test-helper.ts +git commit -m "kernel(dri): HostIO trait — gbm_bo_create / _destroy / _bind / _unbind / _prime_exported" +``` + +--- + +### Task A5: `DRM_IOCTL_VERSION` + `DRM_IOCTL_GET_CAP` (introspection ioctls) + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — extend `sys_ioctl`'s dispatch to recognise the new `'d'` magic, and add `handle_dri_ioctl()`. + +**Step 1: Recognise the DRM magic** + +In `sys_ioctl` (around line 7080 — the existing match where `/dev/fb0` is routed to `handle_fb_ioctl`), add a sibling case that routes any ioctl whose host_handle matches `VirtualDevice::DriRender0` (added in v1) into a new `handle_dri_ioctl(pid, request, buf)`. + +**Step 2: Implement the two introspection ops** + +In a new helper at the bottom of `crates/kernel/src/syscalls.rs`: + +```rust +fn handle_dri_ioctl(pid: i32, request: u32, buf: &mut [u8]) + -> Result<(), Errno> +{ + use wasm_posix_shared::dri::*; + + match request { + DRM_IOCTL_VERSION => { + if buf.len() < core::mem::size_of::<WpkDrmVersion>() { + return Err(Errno::EINVAL); + } + let v_in: WpkDrmVersion = unsafe { + core::ptr::read_unaligned(buf.as_ptr() as *const _) + }; + // We don't write the strings back in v1 — clients that need + // the name/date/desc strings get an empty buffer (name_len=0 + // out). libdrm uses these only for log lines. If client passes + // name_len > 0, we write at most that many bytes into the + // user pointer via a host trampoline (deferred — v1 returns + // zero-length). + let v_out = WpkDrmVersion { + version_major: 1, + version_minor: 0, + version_patchlevel: 0, + name_len: 0, name_ptr: v_in.name_ptr, + date_len: 0, date_ptr: v_in.date_ptr, + desc_len: 0, desc_ptr: v_in.desc_ptr, + }; + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut _, v_out); + } + Ok(()) + } + + DRM_IOCTL_GET_CAP => { + if buf.len() < core::mem::size_of::<WpkDrmGetCap>() { + return Err(Errno::EINVAL); + } + let mut cap: WpkDrmGetCap = unsafe { + core::ptr::read_unaligned(buf.as_ptr() as *const _) + }; + cap.value = match cap.capability { + DRM_CAP_DUMB_BUFFER => 1, + DRM_CAP_PRIME => DRM_PRIME_CAP_IMPORT | DRM_PRIME_CAP_EXPORT, + _ => 0, // unknown caps return 0 (Linux-shape) + }; + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut _, cap); + } + Ok(()) + } + + // Remaining DRM ioctls handled in Tasks A6 / A7. + _ => Err(Errno::ENOTTY), + } +} +``` + +**Step 3: Cargo test** + +Add a test under `crates/kernel/src/dri/` (or in `syscalls`'s existing tests area): drive `handle_dri_ioctl(0, DRM_IOCTL_GET_CAP, &mut buf)` with `cap.capability = DRM_CAP_DUMB_BUFFER` and assert `cap.value == 1`. + +**Step 4: Run** + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib syscalls +``` + +**Step 5: Commit** + +```bash +git add crates/kernel/src/syscalls.rs crates/kernel/src/dri/ +git commit -m "kernel(dri): DRM_IOCTL_VERSION + DRM_IOCTL_GET_CAP introspection" +``` + +--- + +### Task A6: `DRM_IOCTL_MODE_CREATE_DUMB` / `MAP_DUMB` / `DESTROY_DUMB` / `GEM_CLOSE` + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — extend `handle_dri_ioctl()` with the four bo-management ioctls. +- Test under `crates/kernel/src/dri/tests.rs` (new). + +**Step 1: Implement the four ioctls** + +Extend `handle_dri_ioctl()`: + +```rust +// In the same match … => +DRM_IOCTL_MODE_CREATE_DUMB => { + if buf.len() < core::mem::size_of::<WpkDrmModeCreateDumb>() { + return Err(Errno::EINVAL); + } + let mut req: WpkDrmModeCreateDumb = unsafe { + core::ptr::read_unaligned(buf.as_ptr() as *const _) + }; + if req.flags != 0 { return Err(Errno::EINVAL); } + if req.width == 0 || req.height == 0 { return Err(Errno::EINVAL); } + if req.bpp != 32 && req.bpp != 16 { return Err(Errno::EINVAL); } + + // Insert into the global registry. We don't know the DRM_FORMAT yet — + // CREATE_DUMB is format-agnostic on Linux; libgbm picks a format + // separately. We store 0 here; PRIME_HANDLE_TO_FD doesn't need it + // either. Format only matters when the bo is wrapped as a KMS fb + // (Phase B of the multiplexer plan). + let (bo_id, size, stride) = crate::dri::with_registry(|r| { + let bo = r.alloc(req.width, req.height, req.bpp, 0); + (bo.id, bo.size, bo.stride) + }); + + // Notify the host so it can allocate the backing SAB. If the host + // refuses (OOM, etc.), roll back the bo allocation. + let host_rc = HOST_IO.lock().gbm_bo_create(pid, bo_id, size, + req.width, req.height, stride, 0); + if host_rc < 0 { + crate::dri::with_registry(|r| { r.decref(bo_id); }); + return Err(Errno::ENOMEM); + } + + // Bind a process-local handle. + let proc_table = PROCESS_TABLE.lock(); + let proc = proc_table.get_mut(pid).ok_or(Errno::ESRCH)?; + let handle = proc.dri_next_handle; + proc.dri_next_handle = proc.dri_next_handle.checked_add(1) + .ok_or(Errno::EMFILE)?; + proc.dri_handles.insert(handle, bo_id); + + req.handle = handle; + req.pitch = stride; + req.size = size; + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut _, req); + } + Ok(()) +} + +DRM_IOCTL_MODE_MAP_DUMB => { + if buf.len() < core::mem::size_of::<WpkDrmModeMapDumb>() { + return Err(Errno::EINVAL); + } + let mut req: WpkDrmModeMapDumb = unsafe { + core::ptr::read_unaligned(buf.as_ptr() as *const _) + }; + // Resolve handle → BoId. + let bo_id = { + let pt = PROCESS_TABLE.lock(); + let proc = pt.get(pid).ok_or(Errno::ESRCH)?; + *proc.dri_handles.get(&req.handle).ok_or(Errno::ENOENT)? + }; + // The "mmap offset" is just the BoId left-shifted into the upper + // bits so it can't collide with file offsets. The kernel-side + // mmap path (Task A7) decodes the offset back to a BoId. + req.offset = (bo_id as u64) << 12; // page-aligned + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut _, req); + } + Ok(()) +} + +DRM_IOCTL_MODE_DESTROY_DUMB => { + if buf.len() < core::mem::size_of::<WpkDrmModeDestroyDumb>() { + return Err(Errno::EINVAL); + } + let req: WpkDrmModeDestroyDumb = unsafe { + core::ptr::read_unaligned(buf.as_ptr() as *const _) + }; + release_dri_handle(pid, req.handle)?; + Ok(()) +} + +DRM_IOCTL_GEM_CLOSE => { + if buf.len() < core::mem::size_of::<WpkDrmGemClose>() { + return Err(Errno::EINVAL); + } + let req: WpkDrmGemClose = unsafe { + core::ptr::read_unaligned(buf.as_ptr() as *const _) + }; + release_dri_handle(pid, req.handle)?; + Ok(()) +} +``` + +And the helper: + +```rust +fn release_dri_handle(pid: i32, handle: u32) -> Result<(), Errno> { + let bo_id = { + let mut pt = PROCESS_TABLE.lock(); + let proc = pt.get_mut(pid).ok_or(Errno::ESRCH)?; + proc.dri_handles.remove(&handle).ok_or(Errno::ENOENT)? + }; + let new_rc = crate::dri::with_registry(|r| r.decref(bo_id)) + .ok_or(Errno::EINVAL)?; + if new_rc == 0 { + HOST_IO.lock().gbm_bo_destroy(pid, bo_id); + } + Ok(()) +} +``` + +**Step 2: Cargo tests** + +In a new `crates/kernel/src/dri/tests.rs` (or extend `bo.rs::tests`): + +```rust +#[test] +fn create_destroy_roundtrip() { + // Drive handle_dri_ioctl directly with a mock HostIO. + // Assert: CREATE_DUMB returns handle ≥ 1, sets size = stride*height, + // bo registry has one entry; DESTROY_DUMB decrements to zero; + // host's destroy callback fires. + // … see test pattern in fbdoom plan Task A6. +} +``` + +**Step 3: Run** + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib dri +``` + +**Step 4: Commit** + +```bash +git add crates/kernel/src/syscalls.rs crates/kernel/src/dri/ +git commit -m "kernel(dri): DRM_IOCTL_MODE_{CREATE,MAP,DESTROY}_DUMB + GEM_CLOSE" +``` + +--- + +### Task A7: `DRM_IOCTL_PRIME_HANDLE_TO_FD` / `PRIME_FD_TO_HANDLE` + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — extend `handle_dri_ioctl()` with the two prime-fd ioctls. + +**Step 1: Export — `PRIME_HANDLE_TO_FD`** + +Inside the same match: + +```rust +DRM_IOCTL_PRIME_HANDLE_TO_FD => { + if buf.len() < core::mem::size_of::<WpkDrmPrimeHandle>() { + return Err(Errno::EINVAL); + } + let mut req: WpkDrmPrimeHandle = unsafe { + core::ptr::read_unaligned(buf.as_ptr() as *const _) + }; + // Resolve process-local handle → BoId. + let bo_id = { + let pt = PROCESS_TABLE.lock(); + let proc = pt.get(pid).ok_or(Errno::ESRCH)?; + *proc.dri_handles.get(&req.handle).ok_or(Errno::ENOENT)? + }; + + // Materialise the prime cookie (idempotent). + let cookie = crate::dri::with_registry(|r| + r.ensure_prime_cookie(bo_id)).ok_or(Errno::EINVAL)?; + + // Bump refcount for the new OFD that will hold this prime fd. + crate::dri::with_registry(|r| r.incref(bo_id)); + + // Allocate a fresh fd in the calling process. Use the same + // path the existing `/dev/dri/renderD128` open uses + // (CharDevice with sentinel host_handle = -200 for PrimeBo). + let mut pt = PROCESS_TABLE.lock(); + let proc = pt.get_mut(pid).ok_or(Errno::ESRCH)?; + let new_fd = proc.alloc_fd_with_ofd(|ofd| { + ofd.file_type = FileType::CharDevice; + ofd.host_handle = -200; + ofd.path = format!("/dev/dri/prime-{}-{:x}", + bo_id, cookie).into_bytes(); + ofd.prime_bo = Some(PrimeBoState { bo_id, cookie }); + })?; + + req.fd = new_fd as i32; + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut _, req); + } + HOST_IO.lock().gbm_bo_prime_exported(pid, bo_id); + Ok(()) +} +``` + +**Step 2: Import — `PRIME_FD_TO_HANDLE`** + +```rust +DRM_IOCTL_PRIME_FD_TO_HANDLE => { + if buf.len() < core::mem::size_of::<WpkDrmPrimeHandle>() { + return Err(Errno::EINVAL); + } + let mut req: WpkDrmPrimeHandle = unsafe { + core::ptr::read_unaligned(buf.as_ptr() as *const _) + }; + // Look up the prime-fd OFD. + let (bo_id, cookie) = { + let pt = PROCESS_TABLE.lock(); + let proc = pt.get(pid).ok_or(Errno::ESRCH)?; + let ofd_idx = proc.fd_table.get(req.fd as i32) + .ok_or(Errno::EBADF)?; + let ofd = pt.ofds.entries.get(ofd_idx as usize) + .and_then(|o| o.as_ref()).ok_or(Errno::EBADF)?; + let p = ofd.prime_bo.clone().ok_or(Errno::EINVAL)?; + (p.bo_id, p.cookie) + }; + + // Capability check: cookie must match the bo's current cookie. + let bo_cookie = crate::dri::with_registry(|r| + r.get(bo_id).and_then(|b| b.prime_cookie)) + .ok_or(Errno::EACCES)?; + if bo_cookie != cookie { return Err(Errno::EACCES); } + + // Bump refcount for the new local handle. + crate::dri::with_registry(|r| r.incref(bo_id)); + + // Allocate a new process-local handle. + let mut pt = PROCESS_TABLE.lock(); + let proc = pt.get_mut(pid).ok_or(Errno::ESRCH)?; + let handle = proc.dri_next_handle; + proc.dri_next_handle += 1; + proc.dri_handles.insert(handle, bo_id); + + req.handle = handle; + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut _, req); + } + Ok(()) +} +``` + +**Step 3: Cargo tests** + +Add round-trip tests under `crates/kernel/src/dri/tests.rs`: + +```rust +#[test] +fn prime_round_trip_two_processes() { + // Setup: two Process entries (pids 1 and 2). + // Process 1: CREATE_DUMB → handle h1 → PRIME_HANDLE_TO_FD → fd f1 + // manually copy ofd into process 2's fd table (simulating fork's + // inherit; in the real demo this is fork+pipe). + // Process 2: PRIME_FD_TO_HANDLE on its inherited fd → handle h2. + // Assert: h2 ≠ h1 (different namespace) but both resolve to same BoId. + // bo.refcount == 3 (creator + exported prime fd + importer's handle). + // GEM_CLOSE(h1) on process 1: refcount=2, host destroy NOT called. + // close(f1) on process 1: refcount=1. + // GEM_CLOSE(h2) on process 2: refcount=0, host destroy called once. +} + +#[test] +fn prime_cookie_mismatch_returns_eaccess() { + // Manually craft a PrimeBoState with a stale cookie; importer gets EACCES. +} +``` + +**Step 4: Run** + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib dri +``` + +**Step 5: Commit** + +```bash +git add crates/kernel/src/syscalls.rs crates/kernel/src/dri/ +git commit -m "kernel(dri): DRM_IOCTL_PRIME_HANDLE_TO_FD + PRIME_FD_TO_HANDLE" +``` + +--- + +### Task A8: mmap path — bind bo SAB into process Memory + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — extend the `mmap` syscall path on `/dev/dri/renderD128` to decode the `bo_offset` (from MAP_DUMB) and call `HostIO::gbm_bo_bind`. + +**Step 1: Decode bo_offset in `sys_mmap`** + +Around the existing `mmap` dispatch (the same point that handles `mmap` of `/dev/fb0`): match on the OFD's `host_handle` against `VirtualDevice::DriRender0.host_handle()`, then look at the `offset` arg: + +```rust +// In sys_mmap, post-resolve-fd, before the anonymous path: +if let Some(vd) = VirtualDevice::from_host_handle(ofd.host_handle) { + if vd == VirtualDevice::DriRender0 { + // Decode bo_offset: top bits are BoId, bottom bits should be 0 + // (page-aligned encoding from MAP_DUMB). + let bo_id = (offset >> 12) as u32; + let bo = crate::dri::with_registry(|r| r.get(bo_id).cloned()) + .ok_or(Errno::EINVAL)?; + // Resolve which local handle the calling process has for this + // bo; reject if none (Linux requires the caller to hold a handle). + let pt = PROCESS_TABLE.lock(); + let proc = pt.get(pid).ok_or(Errno::ESRCH)?; + if !proc.dri_handles.values().any(|&id| id == bo_id) { + return Err(Errno::EACCES); + } + drop(pt); + + // Length must be ≤ bo.size, rounded up to page boundary. + if (length as u64) > bo.size { + return Err(Errno::EINVAL); + } + + // Reserve anonymous wasm pages via existing mmap_anonymous, then + // notify host to redirect them at the bo's SAB. + let addr = MemoryManager::with(pid, |mm| + mm.mmap_anonymous(hint as usize, length, prot, flags))?; + let rc = HOST_IO.lock().gbm_bo_bind(pid, bo_id, addr, length); + if rc < 0 { /* unwind: munmap_anonymous + return rc */ } + // Record the binding so munmap can call gbm_bo_unbind. + // Use a small per-process Vec<(addr, len, bo_id)> on Process. + return Ok(addr as i64); + } +} +``` + +(Adapt the actual `mmap_anonymous` call to whatever the current syscalls.rs API is — the framework is the same: reserve, bind, record.) + +**Step 2: Add `dri_mmaps` tracking on Process** + +```rust +// in Process: +pub dri_mmaps: Vec<DriMmap>, + +pub struct DriMmap { + pub addr: usize, + pub len: usize, + pub bo_id: u32, +} +``` + +**Step 3: Cargo test** + +Verify: after CREATE_DUMB → MAP_DUMB → mmap, the kernel's `dri_mmaps` records the binding; the host stub recorded a single `gbm_bo_bind` call with matching args. + +**Step 4: Run** + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +``` + +**Step 5: Commit** + +```bash +git add crates/kernel/src/syscalls.rs crates/kernel/src/process.rs +git commit -m "kernel(dri): mmap(/dev/dri/renderD128, bo_offset) binds bo into process Memory" +``` + +--- + +### Task A9: cleanup — munmap / close / exit / execve / fork + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — extend `munmap`, `close`, and process-exit paths to release bo refcounts. +- Modify: `crates/kernel/src/wasm_api.rs` — `SYS_EXIT_GROUP` cleanup. +- Modify: `crates/kernel/src/fork.rs` — reset child's `dri_mmaps` (fork doesn't inherit mmaps of dri fds — they're per-process memory). + +**Step 1: munmap** + +In `sys_munmap`, before returning: check `dri_mmaps` for a matching `(addr, len)`. If found, call `HOST_IO.lock().gbm_bo_unbind(pid, bo_id, addr, len)` and remove the entry. Note: this does **not** decref the bo; the handle is what holds the refcount. munmap only undoes the address-space binding. + +**Step 2: close** + +In `sys_close`, when releasing an OFD with `prime_bo: Some(p)`, call `crate::dri::with_registry(|r| r.decref(p.bo_id))` and if it returns Some(0) call `HOST_IO.lock().gbm_bo_destroy(pid, p.bo_id)`. + +**Step 3: process exit** + +In `wasm_api.rs::process_exit` (or the `SYS_EXIT_GROUP` handler): iterate through `proc.dri_handles` and `proc.dri_mmaps`; for each: +- unbind every dri_mmap; +- decref every dri_handles entry; +- close every OFD with prime_bo (handled by the existing OFD-cleanup loop, since we hook into `release_dri_handle`-equivalent logic). + +**Step 4: execve** + +execve zeros the address space. The existing `Process::reset_for_execve` (or equivalent) clears fb_binding; replicate for `dri_mmaps`. Per-process handles (`dri_handles`) survive execve only if the corresponding fds are CLOEXEC-not-set; let the fd-cleanup logic that already handles CLOEXEC drive `release_dri_handle` for closed fds. + +**Step 5: fork** + +(Already done in Task A3.) Add a regression test: parent mmaps a bo at addr A; fork; parent's dri_mmaps still contains A; child's dri_mmaps is empty (child has the inherited bo SAB but doesn't address it as a mmap until its own MAP_DUMB+mmap). + +**Step 6: Cargo tests** + +```rust +#[test] +fn close_drops_prime_fd_refcount() { /* … */ } + +#[test] +fn process_exit_releases_all_bos() { + // Setup: process owns 3 bos (handles + 1 mmap'd). + // Drive process_exit; assert: all 3 bos destroyed, host got 3 + // destroy callbacks + 1 unbind callback. +} +``` + +**Step 7: Run + Commit** + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +git add crates/kernel/src/ +git commit -m "kernel(dri): cleanup — munmap / close / exit / execve / fork" +``` + +--- + +### Task A10: ABI snapshot regen (additive — no `ABI_VERSION` bump) + +**Files:** +- Modify: `abi/snapshot.json` (auto-generated). +- DO NOT modify: `crates/shared/src/lib.rs` `ABI_VERSION` — additive changes don't require a bump per `docs/abi-versioning.md`. + +**Step 1: Regenerate** + +```bash +bash scripts/check-abi-version.sh update +``` + +**Step 2: Inspect the diff** + +```bash +git diff abi/snapshot.json +``` + +Expected diff: new entries under `marshalled_structs` for `WpkDrmModeCreateDumb`, `WpkDrmModeMapDumb`, `WpkDrmModeDestroyDumb`, `WpkDrmGemClose`, `WpkDrmPrimeHandle`, `WpkDrmGetCap`, `WpkDrmVersion`. New entries for the 5 new `host_gbm_*` imports (`_create / _destroy / _bind / _unbind / _prime_exported`). **No** changes to any *existing* row — if you see one, stop and investigate. + +**Step 3: Verify** + +```bash +bash scripts/check-abi-version.sh +``` + +Expected: exit 0 — additive-compat classification passes. + +**Step 4: Commit** + +```bash +git add abi/snapshot.json +git commit -m "kernel(dri): regen ABI snapshot — additive GBM ioctl + host imports" +``` + +--- + +### Task A11: Phase A — full gauntlet + open PR #1 + +**Step 1: Run the full gauntlet** (CLAUDE.md): + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +Expected: zero regressions vs the v1 baseline. + +**Step 2: Push the branch** + +```bash +git push -u origin emdash/explore-direct-rendering-infrastructure-buffer-kernel-XXXXX +``` + +(Do not push to upstream remote — only the user's `mho22` fork.) + +**Step 3: Open the draft PR** + +PR title: `[explore-dri] kernel(dri): GBM dumb-buffer + prime fd + bo mmap` + +PR body template: + +```markdown +## Summary +- Add the GBM dumb-buffer ioctl surface on top of v1's `/dev/dri/renderD128`: + `MODE_CREATE_DUMB`, `MAP_DUMB`, `DESTROY_DUMB`, `GEM_CLOSE`, + `PRIME_HANDLE_TO_FD`, `PRIME_FD_TO_HANDLE`, `VERSION`, `GET_CAP`. +- Introduce a global `BoRegistry` (one entry per live bo, refcounted) + with per-Process GEM-handle namespaces. +- mmap(`/dev/dri/renderD128`, bo_offset) binds the bo's SAB into the + process's wasm `Memory` via a new `HostIO::gbm_bo_bind` callback. + Pixels live in the host SAB; writes go through the wasm pointer + with no per-frame syscall. +- Prime fds are scoped to a narrow `OpenFileKind::PrimeBoState` + carrying a `(bo_id, cookie)` capability; importer mismatches return + `EACCES`. + +## Why +Milestone (A) of the v2 DRI design (`docs/plans/2026-05-18-dri-design.md` +§4) — bo lifecycle + cross-process sharing — is a precondition for +every other milestone (multiplexer, KMS, compositor). v1 only had +the GL command-buffer surface; this PR adds the buffer surface. + +## Verification +- `cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib` +- `cd host && npx vitest run` +- `scripts/run-libc-tests.sh` +- `scripts/run-posix-tests.sh` +- `bash scripts/check-abi-version.sh` + +## ABI impact +Additive only — no `ABI_VERSION` bump. New `repr(C)` structs in +`shared::dri`, new ioctl numbers in the unused DRM `'d'` magic range, +new `host_gbm_*` host imports. No existing struct, ioctl, or import +changed. Classified additive-compat by +`scripts/check-abi-version.sh` (PR #490 policy). + +## Notes +- Host wiring is in the follow-up `host(dri)` PR; this PR's tests + use the stubbed `HostIO::gbm_*` in `centralized-test-helper.ts`, + which captures call args. +- `DRM_IOCTL_MODE_ADDFB2` / `_PAGE_FLIP` / `_GETRESOURCES` are KMS + (card0); deferred to the multiplexer/KMS plan. +- `gbm_bo_map` cache-flush no-ops (`_flags`) follow Linux semantics + for LINEAR; verified against the design §4.7 trade-off. +``` + +**Do not merge.** Mark draft until Brandon validates. + +--- + +## Phase B — Host GbmRegistry + SAB-backed bo store + `mmap_shared` (PR #2) + +Phase B wires the kernel's `HostIO::gbm_*` callbacks to a real host-side `GbmRegistry`. The bo's SAB lives here; processes see it through wasm-memory aliasing via a new `MemoryManager.mmap_shared(addr, len, sab, offset)` API. Tests under `host/test/dri-*.spec.ts` exercise the full kernel↔host loop via a wasm test program (Phase C ships the program; B's tests use a recording bridge). + +### Task B1: `GbmRegistry` host module + +**Files:** +- Create: `host/src/dri/gbm-registry.ts`. + +```ts +// host/src/dri/gbm-registry.ts + +export interface HostBo { + id: number; + width: number; + height: number; + stride: number; + size: number; + sab: SharedArrayBuffer; // CPU-shared tier — the only tier in v1. + byteOffset: number; // always 0 in v1 (one SAB per bo) +} + +export class GbmRegistry { + private bos = new Map<number, HostBo>(); + + create(boId: number, width: number, height: number, + stride: number, size: number, _format: number): number { + const sab = new SharedArrayBuffer(size); + this.bos.set(boId, { id: boId, width, height, stride, size, sab, + byteOffset: 0 }); + return 0; + } + + destroy(boId: number): void { + this.bos.delete(boId); + } + + get(boId: number): HostBo | undefined { return this.bos.get(boId); } +} +``` + +Cover with a unit test under `host/test/dri-gbm-registry.spec.ts`: create / destroy / refcount-free SAB. + +**Commit:** `host(dri): GbmRegistry module + unit tests` + +--- + +### Task B2: `MemoryManager.mmap_shared` + +**Files:** +- Modify: `host/src/memory.ts` (or wherever `MemoryManager` lives on the host). + +Extend the wasm-`Memory`-aliasing path so that a region `[addr, addr+len)` can be redirected at an arbitrary SAB slice. The existing `mmap_anonymous` allocates pages in the process's `Memory`; `mmap_shared` overlays *its* SAB view onto a slice of another SAB. Implementation in browsers without `SharedArrayBuffer.prototype.transfer`: copy on bind + write-through trap is not feasible; instead we share the buffer at process-`Memory`-creation time so every process's `Memory` *is* a slice of a global SAB pool, and `mmap_shared` just records the redirection in a translation table that read/write helpers consult. + +(The exact mechanism may need a small spike — see "Risk register" below. If a true zero-copy redirection isn't tractable across browsers, fall back to per-tick `Atomics.store` syncs between the bo SAB and process Memory. v1 is a non-perf-critical demo, so the fallback is acceptable for milestone (A).) + +**Commit:** `host(dri): MemoryManager.mmap_shared — alias wasm Memory at a SAB slice` + +--- + +### Task B3: Wire `host_gbm_*` imports into kernel-worker + +**Files:** +- Modify: `host/src/kernel.ts` — register the five `gbm_*` callbacks as host imports. +- Modify: `host/src/kernel-worker.ts` — forward them to `GbmRegistry` + `MemoryManager`. + +Trivial plumbing once B1 + B2 are in. Pattern mirrors v1's `host_gl_*` wiring (see commit `ec29a571` on `explore-webgl-exposition-kernel`). + +**Dual-host parity (CLAUDE.md): both `host/src/node-kernel-worker-entry.ts` and `examples/browser/lib/kernel-worker-entry.ts` must wire the same callbacks. Symmetry check is mandatory before commit.** + +**Commit:** `host(dri): wire host_gbm_* imports — kernel-worker → GbmRegistry` + +--- + +### Task B4: `dumbtest.wasm` + Vitest integration + +**Files:** +- Create: `programs/dumbtest.c` — minimal C program that opens `/dev/dri/renderD128`, CREATE_DUMBs a 64×64 ARGB8888 bo, mmaps it, writes a known pattern, returns 0 on success. +- Add: `host/test/dri-buffer.spec.ts` — runs the compiled `dumbtest.wasm` through the centralized kernel, asserts the host's `GbmRegistry` ends up with the expected SAB contents. + +**Commit:** `host(dri): dumbtest + vitest integration spec` + +--- + +### Task B5: Phase B — full gauntlet + open PR #2 + +PR title: `[explore-dri] host(dri): GbmRegistry + SAB-backed bo store + mmap_shared` + +Body: Summary / Why / Verification / Dual-host parity proof (browser entry + Node entry both wired, both tested) / Notes. + +--- + +## Phase C — DRM headers, libgbm stub, demo program (PR #3) + +Phase C is the user-facing surface: vendored Linux UAPI headers, a small `libgbm.a` + `libdrm.a` (~250 LoC C total) in the sysroot, and the milestone-(A) demo. The demo is two processes via `fork`: parent CREATE_DUMBs a 256×256 bo, writes a gradient, PRIME_HANDLE_TO_FDs, passes the fd through an inherited pipe; child reads the fd, PRIME_FD_TO_HANDLEs, mmaps, verifies the gradient byte-for-byte, exits 0. + +### Task C1: Vendor `linux/include/uapi/drm/*` headers + +**Files:** +- Create: `musl-overlay/include/drm/drm.h`, `drm_mode.h`, `drm_fourcc.h` — verbatim from a pinned Linux release (e.g. v6.10 LTS). + +Add the pinned source URL + sha256 to a `musl-overlay/include/drm/SOURCE.txt`. Headers stay byte-for-byte upstream — any kernel-side wasm32 size differences are handled by the ioctl number tests in Task A1, not by header edits. + +**Commit:** `kernel(dri): vendor Linux UAPI drm/{drm,drm_mode,drm_fourcc}.h` + +--- + +### Task C2: `glue/libdrm_stub.c` — minimal libdrm + +The demo only needs `drmIoctl()` (a thin ioctl wrapper) and `drmGetVersion()` (for compatibility with libgbm's probe). Plus `drmCloseBufferHandle()` which is `ioctl(DRM_IOCTL_GEM_CLOSE)`. + +```c +// glue/libdrm_stub.c — ~80 LoC +#include <drm/drm.h> +#include <sys/ioctl.h> +#include <unistd.h> +#include <errno.h> +#include <stdlib.h> +#include <string.h> + +int drmIoctl(int fd, unsigned long request, void *arg) { + int ret; + do { ret = ioctl(fd, request, arg); } while (ret < 0 && errno == EINTR); + return ret; +} + +typedef struct _drmVersion { + int version_major, version_minor, version_patchlevel; + int name_len; char *name; + int date_len; char *date; + int desc_len; char *desc; +} drmVersion, *drmVersionPtr; + +drmVersionPtr drmGetVersion(int fd) { + drmVersionPtr v = calloc(1, sizeof(*v)); + // … fill via DRM_IOCTL_VERSION, allocate string buffers, return. + return v; +} + +void drmFreeVersion(drmVersionPtr v) { + if (!v) return; + free(v->name); free(v->date); free(v->desc); free(v); +} + +int drmCloseBufferHandle(int fd, uint32_t handle) { + struct drm_gem_close req = { .handle = handle, .pad = 0 }; + return drmIoctl(fd, DRM_IOCTL_GEM_CLOSE, &req); +} +``` + +Build to `sysroot/lib/libdrm.a`. + +**Commit:** `sysroot(dri): libdrm stub — drmIoctl + drmGetVersion + drmCloseBufferHandle` + +--- + +### Task C3: `glue/libgbm_stub.c` — minimal libgbm + +```c +// glue/libgbm_stub.c — ~150 LoC +#include <gbm.h> +#include <drm/drm.h> +#include <drm/drm_mode.h> +#include <drm/drm_fourcc.h> +#include <sys/ioctl.h> +#include <sys/mman.h> +// ... + +struct gbm_device { int fd; }; +struct gbm_bo { + struct gbm_device *dev; + uint32_t handle, width, height, stride; + uint64_t size, modifier; + uint32_t format; + void *map_addr; size_t map_len; +}; + +struct gbm_device *gbm_create_device(int fd) { + struct gbm_device *d = calloc(1, sizeof(*d)); + d->fd = fd; return d; +} +void gbm_device_destroy(struct gbm_device *d) { free(d); } + +struct gbm_bo *gbm_bo_create(struct gbm_device *dev, + uint32_t w, uint32_t h, uint32_t format, uint32_t flags) { + struct drm_mode_create_dumb req = { + .width = w, .height = h, .bpp = 32, .flags = 0 + }; + if (drmIoctl(dev->fd, DRM_IOCTL_MODE_CREATE_DUMB, &req) < 0) + return NULL; + struct gbm_bo *bo = calloc(1, sizeof(*bo)); + bo->dev = dev; bo->handle = req.handle; + bo->width = w; bo->height = h; + bo->stride = req.pitch; bo->size = req.size; + bo->format = format; bo->modifier = DRM_FORMAT_MOD_LINEAR; + return bo; +} +// ... gbm_bo_destroy, gbm_bo_get_fd, gbm_bo_import, +// gbm_bo_get_width/height/stride/format/modifier, gbm_bo_map. +``` + +Build to `sysroot/lib/libgbm.a`. Vendor `<gbm.h>` from libgbm's release archive into `musl-overlay/include/gbm.h`. + +**Commit:** `sysroot(dri): libgbm stub — gbm_bo_create / _destroy / _get_fd / _import / _map` + +--- + +### Task C4: `programs/dumb_roundtrip.c` — milestone (A) demo + +```c +// programs/dumb_roundtrip.c — ~120 LoC +#include <fcntl.h> +#include <unistd.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/mman.h> +#include <sys/wait.h> +#include <gbm.h> +#include <drm/drm.h> + +int main(void) { + int fd = open("/dev/dri/renderD128", O_RDWR | O_CLOEXEC); + if (fd < 0) { perror("open renderD128"); return 1; } + + struct gbm_device *dev = gbm_create_device(fd); + struct gbm_bo *bo = gbm_bo_create(dev, 256, 256, + DRM_FORMAT_ARGB8888, GBM_BO_USE_LINEAR); + if (!bo) { perror("gbm_bo_create"); return 1; } + + // Map bo and write a known gradient. + uint32_t *px = gbm_bo_map(bo, 0, 0, 256, 256, 0, NULL, NULL, NULL); + for (int y = 0; y < 256; y++) + for (int x = 0; x < 256; x++) + px[y * (bo->stride/4) + x] = (255 << 24) | (x << 16) | (y << 8); + + // Export prime fd. + int prime = gbm_bo_get_fd(bo); + if (prime < 0) { perror("get_fd"); return 1; } + + // Pipe to pass to child. + int p[2]; if (pipe(p) < 0) return 1; + + pid_t pid = fork(); + if (pid == 0) { + // child: read prime fd (passed via inherited fd table), import, + // mmap, verify gradient byte-for-byte. exit 0 on success. + close(p[1]); + struct gbm_device *cdev = gbm_create_device(fd); + struct gbm_bo *cbo = gbm_bo_import(cdev, GBM_BO_IMPORT_FD, + &(struct gbm_import_fd_data){ .fd = prime, .width = 256, + .height = 256, .stride = 256*4, + .format = DRM_FORMAT_ARGB8888 }, 0); + if (!cbo) { perror("import"); _exit(2); } + uint32_t *cpx = gbm_bo_map(cbo, 0, 0, 256, 256, 0, NULL, NULL, NULL); + uint32_t cstride_px = cbo->stride / 4; // match parent's writer stride + for (int y = 0; y < 256; y++) + for (int x = 0; x < 256; x++) { + uint32_t want = (255 << 24) | (x << 16) | (y << 8); + if (cpx[y * cstride_px + x] != want) { + fprintf(stderr, "mismatch at %d,%d\n", x, y); + _exit(3); + } + } + _exit(0); + } + // parent: wait, exit 0 if child exited 0. + int st; waitpid(pid, &st, 0); + return WIFEXITED(st) && WEXITSTATUS(st) == 0 ? 0 : 1; +} +``` + +Build with `wasm32posix-cc -o programs/dumb_roundtrip.wasm programs/dumb_roundtrip.c -lgbm -ldrm`. Wire into `scripts/build-programs.sh`. + +**Commit:** `examples(dri): dumb_roundtrip — milestone (A) two-process bo round-trip` + +--- + +### Task C5: End-to-end Vitest spec + +**Files:** +- Add: `host/test/dri-dumb-roundtrip.spec.ts` — runs `dumb_roundtrip.wasm` under the centralised kernel, asserts the child exits 0 (gradient round-tripped successfully). + +**Commit:** `host(dri): vitest — dumb_roundtrip end-to-end` + +--- + +### Task C6: Manual browser verification (the gate) + +Per CLAUDE.md item 6. Build the demo, drop it into `examples/browser/pages/dridemo/` (an HTML page that runs `dumb_roundtrip.wasm` and pretty-prints the exit code), `./run.sh browser`, navigate to the page in Chrome, confirm: child exits 0, page reports "milestone (A) PASS". + +**No commit yet for this task — verification only.** If the demo fails in browser but passes in Node, that's a host-parity bug (CLAUDE.md "dual-host parity" — see PR #410 cautionary tale). Fix and re-run before opening PR #3. + +--- + +### Task C7: Phase C — final gauntlet + open PR #3 + +PR title: `[explore-dri] examples(dri): dumb-buffer round-trip demo + browser spec` + +Body: Summary / Why / Verification (the gauntlet + browser screenshot) / Dual-host parity proof / Notes. + +--- + +## Final coordinated merge + +When all three PRs (kernel, host, examples) are reviewed and approved, and Brandon has signed off on the demo running cleanly in browser + Node: + +1. Re-run the full gauntlet on each PR's branch tip. +2. Squash-merge PR #1 → PR #2's base. +3. Squash-merge PR #2 → PR #3's base. +4. Squash-merge PR #3 → `explore-webgl-exposition-demo`. +5. Tag: `[explore-dri-buffer] milestone (A) merged at <sha>` in the next session-handoff doc. + +**Do not push to upstream until v1 itself merges upstream.** + +--- + +## Trade-offs already locked in (don't relitigate during implementation) + +- **CPU-shared tier only in this plan.** GPU-tier bos (WebGLTexture-backed) need the multiplexer + EGLImage path; deferred to the multiplexer plan. Demos in this plan that need CPU access (milestone A) work; demos that need GPU sampling (milestone B's cube-pair) belong in the next plan. +- **GEM handles per-Process, not per-fd.** Linux is per-fd; we mirror v1's `gl_state`-on-Process decision (single-owner in v1, lifted in the multiplexer plan when single-owner is too). Cost: a process can't have two independent renderD128 fds with disjoint handle namespaces — but in v1 a process can't have two opens at all (single-owner), so the constraint is invisible. +- **Prime cookie is a single u64 per bo, not per-export.** A future hardening pass could rotate cookies per-export; v1 trusts that the bo-id namespace + 64-bit cookie make accidental collision negligible. +- **`drm_version` strings written zero-length in v1.** libdrm and libgbm log the driver name on init; they tolerate empty strings. A full string-write path requires a host trampoline to write into the caller's wasm pointer; not worth the complexity for milestone (A). Add when SDL2 / libinput depend on the strings (Phase D in the design's roadmap). +- **No `DRM_IOCTL_MODE_GETRESOURCES` / KMS in this plan.** card0 is a separate device file with its own design (§6); the buffer plan is renderD128 only. +- **Fork inherits prime-fd OFDs, not GEM handles.** Matches Linux semantics: child has the fd, must `PRIME_FD_TO_HANDLE` to get an addressable handle. + +--- + +## Risk register + +1. **`MemoryManager.mmap_shared` cross-browser feasibility (Task B2)** — Chrome / Firefox / Safari may differ on whether a `Memory`-backing SAB can be aliased to a slice of another SAB. A spike under `host/test/dri-mmap_shared.spec.ts` runs first thing in Phase B. If true zero-copy aliasing isn't viable, fall back to per-tick `Atomics`-driven sync between the bo SAB and process Memory; the demo loses zero-copy but still passes correctness. +2. **`fork`-inherited prime-fd OFD refcount races.** Cargo unit tests cover the single-threaded case; the kernel's GKL means there's no concurrent fork during a refcount change. Still worth a comment in `prime_handle_to_fd` describing the invariant. +3. **`drm_mode_create_dumb.bpp != 32` paths.** v1 of the plan accepts 16 and 32; 8-bit and 24-bit are deferred. If the demo or libgbm probes with bpp=24, return `EINVAL` cleanly — libgbm's probe loop falls back to 32. +4. **`gbm_bo_import` on a freshly-fork-inherited prime fd before PRIME_FD_TO_HANDLE.** The child's `dri_handles` is empty after fork; `gbm_bo_map` requires a local handle. The demo (Task C4) calls `gbm_bo_import` explicitly, which triggers PRIME_FD_TO_HANDLE. Test case in Task A7 verifies the round-trip. + +--- + +## What this plan doesn't cover (deferred) + +- **Multiplexer (§5).** N processes with N GL contexts on one host WebGL2RenderingContext. Next plan. +- **GBM surface (`gbm_surface_create` + EGL window surface).** A triple-buffered rotation of bos for `eglSwapBuffers`. Next plan (after multiplexer — they share the GL-host wiring). +- **KMS / card0 (§6).** Page-flip, vblank, master/slave. Plan after multiplexer. +- **GPU-tier bos + EGLImage.** Same plan as multiplexer. +- **`SCM_RIGHTS` for prime-fd passing over unix sockets.** Milestone (A) uses fork+inherit; the compositor (PID 2) talking to clients needs SCM_RIGHTS, which lives in the existing phase-6-sockets plan (`docs/plans/2026-03-08-phase6-sockets-plan.md`). Audit + finish it before the compositor plan opens. +- **DRM_FORMAT_MOD_*** beyond `LINEAR`. +- **Multi-renderD-node** (renderD129 = WebGPU). Post-v1. + +--- + +End of plan. diff --git a/docs/plans/2026-06-01-dri-multiplexer-plan.md b/docs/plans/2026-06-01-dri-multiplexer-plan.md new file mode 100644 index 0000000000..b5beeff789 --- /dev/null +++ b/docs/plans/2026-06-01-dri-multiplexer-plan.md @@ -0,0 +1,2086 @@ +# DRI v2 — N-guest → 1-host GL multiplexer plan + +> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to execute this plan task-by-task. + +**Goal:** Lift v1's single-open constraint on `/dev/dri/renderD128`. Allow N +processes — each with one or more GL contexts — to share the single host +`WebGL2RenderingContext` via per-context state shadows + lazy switch (design +§5, option C). Add the compositor head-of-queue priority lane. Activate the +GPU-tier bos plan 2 reserved (§4.2). The proof-of-concept demo is two +unrelated processes each rendering a spinning cube into their own bo, both +visible on the same OffscreenCanvas. The compositor itself (PID 2) is **not** +in this plan — it gets its own plan (§9 of the design). The compositor +priority lane is exercised by a "fake compositor pid 2" in vitest. + +**Architecture:** The host's single `WebGL2RenderingContext` lives in the +kernel-worker (v1's choice; do not move it). Each `(pid, ctx_id)` binding +gains a per-context `GlShadowState` (~40 entries — viewport, scissor, +clearColor/Depth, blend, depth/cull/front-face, current program, +texture-unit bindings, etc.), a per-context VAO, and a per-context FBO. +The host muxer's `switchTo(target)` rebinds VAO/FBO and re-applies state on +every cross-client submit. If consecutive submits come from the same +binding, `switchTo` is a no-op (lazy switch, design §5.2). Submissions are +queued by `(pid, ctx_id)` with two priority lanes: `COMPOSITOR_PRI` (PID 2) +head-of-queue, `CLIENT_PRI` everyone else, FIFO within lane. Submits are +atomic — no preemption mid-cmdbuf (design §5.5). On the kernel side, this +plan **lifts plan 2's `dri_handles`-on-Process choice to per-fd** +(`OpenFileDesc::dri_handles`) — Linux-shape semantics, anticipated by plan +2's Trade-offs. The same lift applies to v1's `gl_state` (which v1 placed +on `Process` for single-owner). GPU-tier bos are backed by host +`WebGLTexture` objects; foreign-texture bind (`GLIO_BIND_FOREIGN_TEXTURE`) +lets one client sample another's bo as a texture via the shared host +context's `WebGLTexture` namespace. Companion design doc: +`docs/plans/2026-05-18-dri-design.md` §5 + §4.2. + +**Tech Stack:** Rust kernel (wasm64), TypeScript host (browser + Node), C +user programs cross-compiled with `wasm32posix-cc`. Builds on plan 2's +`libgbm.a` + `libdrm.a` stubs; extends libgbm to handle +`GBM_BO_USE_RENDERING` (routes to the GPU-tier ioctl). + +**Design reference:** `docs/plans/2026-05-18-dri-design.md` §5 +(multiplexer) and §4.2 (bo tiers — GPU tier activates here). POSIX vs Linux +UAPI: `open`/`close`/`mmap`/`ioctl` are POSIX; the GLIO_* / DRM_IOCTL_* / +shadow-state design is our own (no Linux analog — Mesa's +`mesa_glthread` is the nearest cousin, conceptually). + +**Consistency with plan 2:** +- Plan 2 placed `dri_handles` on `Process` ("v1 is single-owner; lifted by + the multiplexer plan when single-owner is too"). **This plan does the + lift.** Task A2 moves state from `Process` to `OpenFileDesc` and updates + every call site touched by plan 2 (Phase A Tasks A3, A6-A9). Plan 2's + Pre-implementation review names per-fd-vs-per-Process as a deliberate v1 + simplification; verifying this lift is part of the devil's-advocate pass + against plans 2 + 3 together. +- Plan 2's `BoTier::Gpu` enum variant was reserved but unimplemented. This + plan activates it (Task A3, B7). +- Plan 2's `mmap_shared` (B2) carries the bo SAB backing for the CPU-shared + tier. The GPU tier does **not** use `mmap_shared` — GPU-tier bos have no + CPU-mapped backing (the bytes live in `WebGLTexture` storage, not a SAB). + Plan 2's risk register #1 (cross-browser `mmap_shared` viability) is + independent of this plan; if plan 2 falls back to SAB-pool redesign, + this plan still works as written for the GPU tier. + +**Stack base:** Plan 2's tip — `…-buffer-demo` (plan 2 PR #3's head). +This plan extends plan 2's `BoRegistry`, `OpenFileDesc`, and host +`GbmRegistry` without breaking any of plan 2's tests. v1's GLES2 surface +(`host_gl_*` imports, `GLIO_*` ioctls in `shared::gles`, `Process::gl_state`) +is the load-bearing scaffolding under everything. + +**Branch:** `emdash/explore-direct-rendering-infrastructure-multiplexer-plan-XXXXX` +(chains off the previous DRI branch per the user's branching rule). +Three sub-branches stack off it for the three implementation PRs. + +**Final PR base:** Plan 2's `…-buffer-demo` branch tip. **Do not merge** +until Brandon validates the design, plan 2 lands, and Phase C's manual +browser verification passes. CLAUDE.md "no merge before Brandon's +validation" rule. + +**Three PRs, coordinated merge.** Each task below is one commit. PR +titles follow Brandon's `scope(area): action` shape: + +1. `kernel(dri): per-fd gl_state + per-fd dri_handles + GPU-tier bos + foreign-texture ioctl` +2. `host(dri): GlMuxer + shadow state + WebGLTexture cross-context map + GPU-tier bo backing` +3. `examples(dri): two-cube multiplex demo + vitest interleave spec` + +PR base/head topology (stacked per the user's branching rule): + +``` +explore-webgl-exposition-demo (v1 tip) + └── …-buffer-plan-XXXXX (plan 2 PR base) + └── …-buffer-kernel (plan 2 PR #1) + └── …-buffer-host (plan 2 PR #2) + └── …-buffer-demo (plan 2 PR #3) + └── …-multiplexer-plan-YYYYY (this plan PR base) + └── …-mux-kernel (PR #1) + └── …-mux-host (PR #2) + └── …-mux-demo (PR #3) +``` + +**Verification gauntlet** (CLAUDE.md): all of the below must pass with zero +regressions before any PR is opened, and re-run before final merge: + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +`XFAIL` / `TIME` are acceptable; `FAIL` that isn't pre-existing is a +regression. Phase C adds manual `./run.sh browser` verification of the +two-cube demo (CLAUDE.md item 6). + +**ABI impact:** **Additive only — no `ABI_VERSION` bump.** Per +`docs/abi-versioning.md` (PR #490 policy): +- New ioctl numbers in the existing DRM `'d'` magic range (`0xE0..0xE2`, + unused by Linux 6.x DRM): `DRM_IOCTL_WPK_CREATE_GPU_BO`, + `DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE`. Naming: `WPK_*` for our extensions + to keep them distinguishable from upstream Linux DRM ioctls if the + vendored headers are ever audited side-by-side with mainline. +- New `repr(C)` struct `WpkDrmGpuBoCreate` in `shared::dri`. +- One new `host_gl_*` import: `host_gl_bind_foreign_texture`. Plan 2's + five `host_gbm_*` imports remain unchanged; v1's ten `host_gl_*` imports + unchanged. +- `BoTier::Gpu` variant on the kernel-internal `BoTier` enum was already + reserved in plan 2's `crates/kernel/src/dri/bo.rs`; no ABI surface, + not in `shared::dri`. Activating it requires no snapshot change. +- No change to v1's `GLIO_*` numbers, v1's `GlSubmitInfo` / + `GlContextAttrs` / `GlSurfaceAttrs` / `GlQueryInfo` structs, channel + layout, syscall numbers, kernel exports, asyncify slots. + +Existing structs, ioctls, and imports — all unchanged. + +--- + +## Pre-implementation review + +Devil's-advocate pass, 2026-05-19 (session 4 — same-day continuation +of the plan 2 + plan 3 drafts). Each item below is either *fixed in +place* (a correction to the drafted prose, already applied) or *open +for the implementation PR to address* (a bug, missing test, or +architectural concern in the design as drafted). Cross-plan +consistency was checked with plan 2's review section in the same +session — the per-`Process`→per-OFD lift is the load-bearing seam, so +each plan-2 callsite was walked against its plan-3 sibling. + +### Correctness — fixed in place + +- **`gl_next_ctx_id` overflow returns `EMFILE` in the Risk register + but the narrative section never named an errno.** `EMFILE` is the + fd-table-vs-rlimit errno; a context-table exhaustion is a + kernel-allocation failure, which Linux's GL stacks (and POSIX + generally) signal with `ENOMEM`. *Switched the Risk register entry + to `ENOMEM` with the reasoning inline.* Implementation + (`GLIO_CREATE_CONTEXT`) must match: `proc.gl_next_ctx_id.checked_add(1) + .ok_or(Errno::ENOMEM)?`. +- **`GLIO_UNBIND_FOREIGN_TEXTURE` was named in the `WpkDrmBindForeignTexture` + docstring but never defined anywhere.** No such opcode exists; the + bind lifetime is tied to the bo's lifetime (the bo is the canonical + `WebGLTexture` owner). When the bo's refcount drops to zero, + `gbm_bo_destroy(pid, bo_id)` on the host deletes the texture and + every binding to it is invalidated. *Rewrote the docstring to spell + out the bo-scoped lifetime and to remove the phantom opcode.* +- **`GlShadowState.vao` was typed `WebGLVertexArrayObject` (non-null) + with a placeholder `null as unknown as WebGLVertexArrayObject` in + `defaultShadow`.** Type-system lie — a binding accessed before + `MAKE_CURRENT` would carry the `null`-shaped sentinel and + silently propagate. *Switched the type to `WebGLVertexArrayObject | + null`; `switchTo` already calls `gl.bindVertexArray(s.vao)`, which + accepts `null` (unbinds) so the runtime behaviour is unchanged but + the lie is gone.* +- **`SubmitQueue.pickNext` left exhausted entries in `byKey` + the + lane until they cycled back to the head.** A heavy client that + bursts one frame per microtask would balloon `byKey` proportional to + the drain backlog. *Added `releaseIfEmpty(entry)` for drain to call + after popping the last frame; `byKey` and the lane drop the entry + promptly. Drain in B4 now calls it after each `decodeAndDispatch`.* +- **Task A3 (`DRM_IOCTL_WPK_CREATE_GPU_BO`) unwind was asymmetric + with plan 2's Task A6 unwind.** If the OFD handle lookup failed + after the host had already allocated the texture, the code + decremented the kernel registry but never called + `gbm_bo_destroy(pid, bo_id)` on the host — host-side `WebGLTexture` + leaked. *Restructured the per-fd handle allocation as a closure + whose `Err` arm decrements the registry **and** calls + `gbm_bo_destroy`, mirroring plan 2 A6's unwind shape. Spelled out + the borrow-checker scoping note so the `(immut → mut)` re-borrow on + `PROCESS_TABLE` is unambiguous; the prior sketch held an immutable + `proc` reference while taking `pt.ofds.entries` mutably.* + +### Correctness — open, address in the implementation PRs + +- **`on_final_close` passes the *closing* process's pid to + `host_io.gl_destroy_context(pid, ctx_id)` and `gbm_bo_destroy(pid, + bo_id)`.** When the OFD has been fork-inherited, the closer's pid + is not the creator's pid; the host's binding registry is keyed by + the creator's pid + ctx_id. This **silently** leaks the host-side + context (and any bo whose host record is keyed by pid). Action: the + OFD must record the *creator* pid for each binding + bo, and + `on_final_close` passes the recorded creator pid. Alternative + (more invasive): drop pid from the host destroy API entirely and + key host-side bindings by a globally-unique kernel-side id (a + `(BoId, ctx_id)` pair scoped to the OFD). Decide before Task A5. +- **Two OFD-side optional dri fields (`prime_bo` from plan 2 + + `dri` from plan 3) on `OpenFileDesc`.** Plan 2 added + `prime_bo: Option<PrimeBoState>`; plan 3 adds + `dri: Option<Box<DriFdState>>`. They're disjoint use cases (an OFD + is either a renderD128 open OR a prime-bo OFD, never both), so a + single `dri: Option<Box<DriOfdState>>` enum + (`{ RenderNode(DriFdState), PrimeBo(PrimeBoState) }`) is cleaner. + Not strictly a correctness bug — two `None`-everywhere fields + cost ~16 bytes per OFD — but worth a refactor at A2 land time + rather than carrying drift into plan 4. +- **GLIO_SUBMIT (v1) carries `ctx_id` but does the kernel resolve it + via the **submitting** fd's OFD?** Plan 3 B5 walks back the + `ofd_id` import argument by asserting `(pid, ctx_id)` is unique + per-Process (since `gl_next_ctx_id` is per-Process). That holds for + the *host* lookup, but the *kernel* dispatcher still needs to find + the binding's per-fd-state to update the shadow on each submit. + Plan 3 doesn't pin which fd's OFD the kernel consults at GLIO_SUBMIT + time — the fd from the ioctl syscall, presumably. Verify v1's + GLIO_SUBMIT dispatcher already has the fd in scope (it should: + every ioctl path receives `(pid, fd, request, ...)`). Document + explicitly at Task B5 + B4. + +### Architecture — open + +- **Lift of `gl_state` to OFD (Task A2) is overreach if GL bindings + must NOT survive fork.** WebGL semantics: contexts are + process-scoped; a forked child cannot meaningfully use a + parent-created context (the underlying host `WebGL2RenderingContext` + is owned by the kernel-worker, not duplicated per-process, but the + user-visible binding state is per-process by convention). If + `gl.bindings` lives on the OFD (shared by fork), the child after + fork holds a per-fd reference to the parent's bindings; subsequent + `MAKE_CURRENT` from the child onto an inherited binding is + undefined-by-spec. Two viable resolutions: + (a) **Lift only `dri_handles` to OFD; keep `gl_state` on Process.** + Lower blast radius; preserves v1's GL-state-on-Process invariant; + compatible with B5's "per-Process `gl_next_ctx_id`" decision. + Cost: a process can't have two `/dev/dri/renderD128` fds with + disjoint GL-binding namespaces — but plan 3's GL-multiplexing + model is per-Process anyway (one process, N contexts via + `GLIO_CREATE_CONTEXT`, all sharing the host context). + (b) **Lift `gl.bindings` to OFD but clear on fork.** Forces + OFD-copy-on-fork for renderD128 OFDs, breaking the "OFD shared + by ref" invariant — much larger change. + *Lean: (a).* Task A2 should be split: `dri_handles` + `dri_mmaps`- + isn't-here-anyway → OFD; `gl_state` → stays on Process. The plan + 3 narrative around B5 ("`gl_next_ctx_id` per-Process keeps the host + key unambiguous") works *better* under (a). Decide before any kernel + code lands. +- **`WpkDrmGpuBoCreate` reuses the same 16-byte buffer for in and + out (`format`/`usage` slots get overwritten with `handle`/`stride` + on return).** No Linux DRM ioctl does this; every `_IOWR` carries + explicit out fields after the in fields (cf. `drm_mode_create_dumb`: + in `{height, width, bpp, flags}`, out `{handle, pitch, size}`). The + caller's libgbm wrapper has to remember not to inspect `format` / + `usage` after the call. Cost vs benefit: keeping the struct at + 16 bytes saves 8–12 bytes of ABI surface; restructuring to in + `{width, height, format, usage}` + out `{handle, stride, pad}` + (24 or 28 bytes, ioctl encoding changes to `0xc018_64e0` or + `0xc01c_64e0`) makes the surface conventional and self-documenting. + *Lean: restructure to dedicated out fields.* Update the ioctl + encoding + the `ioc(…)` self-test accordingly. Decide before any + kernel code lands. +- **`BoRegistry::alloc_gpu` is a separate method vs adding a `tier` + parameter to `alloc`** — the plan vacillates ("recommended: + `alloc_gpu`" in the narrative, but the open-question list under + Pre-impl-review-as-drafted asked "lean: parameter — fewer call + sites diverge"). The two paths diverge in what they do: the + CPU-shared path computes `stride` + `size` from `bpp` and pre- + allocates a SAB-sized region; the GPU path does neither (host's + `WebGLTexture` picks its own layout, `stride = 0`). A `tier` + parameter would carry both branches in a single method with a + noisy match. *Lean: keep `alloc_gpu` separate* — clearer contract, + one less foot-gun for callers that pass the wrong bpp for GPU bos. + Match the plan body to the lean; remove the conflicting note from + the open-question list. +- **0xE0+ nr-range collision with Linux v6.x DRM** isn't yet + verified against the header plan 2 Task C1 vendors. As of v6.10 + mainline `include/uapi/drm/drm.h`, DRM nrs run 0x00..0xCF densely; + `0xD0..0xD3` recently picked up SYNCOBJ extensions and `0xD4` + (`MODE_CLOSEFB`) has been proposed for v6.11+. `0xE0+` is unused as + far as I can see, but the plan PR can't land until plan 2's Task C1 + actually vendors the header and we grep for `'d', 0xE0` / + `'d', 0xE1`. If a collision appears (e.g., the vendored snapshot is + v6.12+ and someone has filled `0xE0`), choose the lowest free nr + ≥ `0xF0` and re-derive both `DRM_IOCTL_WPK_*` constants in Task + A1 + their ioctl-encoding tests. + +### Missing tests — add in the implementation PRs + +- **`fork` + GL context survival.** Behaviour depends on the + architecture-open resolution above. Under (a) `gl_state` on + Process, the test asserts: child after fork has empty + `gl_state.bindings`; child's `MAKE_CURRENT(ctx_id=N)` (N created by + parent pre-fork) returns `EINVAL`. Under (b) lift to OFD with + clear-on-fork, the test asserts the same outcome via a different + mechanism. Either way, the regression test must exist. +- **Two opens of `/dev/dri/renderD128` on the same process yield + distinct `DriFdState`s.** Already in Task A2 Step 4 as + `two_opens_yield_distinct_handle_namespaces`. ✓ noted, just + confirming. +- **`WPK_BIND_FOREIGN_TEXTURE` on a non-existent `ctx_id` returns a + clean errno (`EINVAL`) rather than silently binding to ctx 0.** + Task A4's test list covers tier mismatch and handle resolution but + not ctx_id validation. Add. +- **Bo destruction invalidates all foreign-texture bindings.** Test + pid 10 creates GPU bo handle 1, pid 11 binds it via prime+ + `BIND_FOREIGN_TEXTURE` as its own `gl_texture_id = T`. Pid 10 + `GEM_CLOSE` and `close(prime fd)` → bo refcount → 0 → host + `gbm_bo_destroy` fires → pid 11's `T` is now invalid. Pid 11's + next sampler binding via `T` should produce `GL_INVALID_OPERATION` + in the cmdbuf (or whatever the v1 cmdbuf decoder returns for an + invalid texture handle). Without this test, the bo-as-owner + contract isn't observably enforced. +- **`COMPOSITOR_PRI` precedence test uses a fake compositor pid.** + The fixture must permit setting `pid=2` for a kernel-side fake + process (the centralized-kernel test helper currently allocates + pids monotonically from a base; the test needs to construct the + fake compositor with an explicit pid). Verify the helper supports + this; if not, B8 Step 5 (the priority assertion) is unrunnable. + +### Trade-offs verified against the design doc (`2026-05-18-dri-design.md` §5 + §4.2) + +- **One host `WebGL2RenderingContext` for all bindings** — matches + design §5.1 option C. ✓ +- **Per-context shadow + lazy switchTo** — matches §5.2. ✓ +- **Submits are atomic — no preemption mid-cmdbuf** — matches §5.5. + Acknowledged starvation risk for compositor under a pathological + client cmdbuf is in the Risk register. ✓ +- **`COMPOSITOR_PRI` hardcoded to PID 2 in this plan; `DRM_MASTER` on + card0 layered by plan 4** — matches §6's KMS-master role. The + hand-off shape is recorded in Task B3's COMPOSITOR_PRI comment. + Verify alignment when plan 4 drafts. +- **GPU-tier bos have no CPU-mapped backing; `mmap(/dev/dri/renderD128, + gpu_bo_offset)` returns EINVAL** — matches design §4.2 ("GPU tier + is texture-only; readback via separate ioctl future-plan"). ✓ +- **Additive ABI only — no `ABI_VERSION` bump** — matches the + `docs/abi-versioning.md` policy (PR #490). The ABI snapshot diff + in A6 covers only new structs + the new `host_gl_*` import; no + existing surface changes. ✓ (Confirmed by walking the plan-2 + ABI shape; the per-fd lift is purely a kernel-internal data layout + change with no ABI surface.) + +### Deliberately not flagged + +- `_IOWR('d', 0xE0, 16)` = `0xc010_64e0` and `_IOWR('d', 0xE1, 16)` + = `0xc010_64e1` — re-derived via the same `ioc(...)` helper plan 2 + Task A1 introduced; matches the plan body's constants exactly. ✓ +- `gl_next_ctx_id` resets to 1 on fork (B5 Step 1). The child shares + no GL contexts with the parent, so starting from 1 is correct — + same as Linux's GL drivers (per-fd ctx tables, fresh on dup). ✓ +- `host_gl_bind_foreign_texture` import is *additive*; v1's ten + `host_gl_*` imports are unchanged, so the per-Process-ctx_id + decision (B5) doesn't propagate into the v1 ABI surface. ✓ +- `WebGL2RenderingContext` namespace addressability is symmetric + across bindings — verified against the WebGL2 spec (section "WebGL + Object Lifecycle"). ✓ +- `queueMicrotask` drain coalescing — both Node and browser provide + the same microtask semantics. ✓ dual-host parity verified by + inspection. +- Branch topology (`…-mux-kernel` → `…-mux-host` → `…-mux-demo` + stacked on `…-buffer-demo`) — coherent with plan 2's stack base + and the user's "each branch chains off the previous" rule. ✓ + +### Cross-plan amendment from plan 4's devil's-advocate + +Plan 4's review found that the `DRM_MASTER` → `COMPOSITOR_PRI` swap +is **not** a one-line callsite change as plan 4's narrative claimed, +because plan 3 B3 (above) lands `SubmitQueue` without a constructor +and references a module-level `const COMPOSITOR_PID = 2` in two +methods (`enqueue` + `releaseIfEmpty`). Plan 4 B4 adds a constructor +argument `(isCompositor: (pid: number) => boolean)` and rewires both +methods — that's a constructor-signature change plus two call-site +swaps, not a one-line edit. + +**Resolution (lands inside this plan, not deferred):** plan 3 B3 +defines `SubmitQueue` with the constructor-callback shape from the +start, defaulting to the PID-2 predicate so plan-3-era tests pass +unchanged: + +```ts +export class SubmitQueue { + private compositor: QueueEntry[] = []; + private clients: QueueEntry[] = []; + private byKey = new Map<string, QueueEntry>(); + + /** `isCompositor(pid)` decides lane bucketing. Plan 4 wires this + * to `kmsRegistry.isMasterPid(pid)`; until then the default is + * the plan-3 hardcode (`pid === 2`). */ + constructor( + private isCompositor: (pid: number) => boolean = (pid) => pid === 2, + ) {} + + enqueue(binding: GlBinding, frame: SubmitFrame): void { + // … this.isCompositor(binding.pid) ? this.compositor : this.clients … + } + + releaseIfEmpty(entry: QueueEntry): void { + // … this.isCompositor(entry.binding.pid) ? this.compositor : this.clients … + } + + isEmpty(): boolean { return this.byKey.size === 0; } +} +``` + +With this shape, plan 4 B4 becomes genuinely a one-line edit at the +construction site in `kernel-worker.ts`: + +```ts +- const queue = new SubmitQueue(); ++ const queue = new SubmitQueue((pid) => kmsRegistry.isMasterPid(pid)); +``` + +…and plan 3's vitest (queue with `pid=2`, `pid=10`, `pid=11`) keeps +passing on the default. Plan 4 drops the `COMPOSITOR_PID` constant +*from the SubmitQueue module* but keeps it in the test fixture as +the default-predicate reference. + +**Action for plan 3 B3 implementation:** ship `SubmitQueue` with the +constructor-callback shape, not the module-level `const`. Replace +the `COMPOSITOR_PID` const at the top of `submit-queue.ts` with an +explicit default argument in the constructor signature. The Vitest +spec at B3 also uses the default; the new test added in plan 4 B5 +overrides the predicate to exercise master-driven priority. + +### Cross-plan amendment from plan 7's devil's-advocate — libEGL.a + libGLESv2.a follow-up (LOAD-BEARING) + +Plan 7's devil's-advocate pass (session 8) escalated the **missing +user-space static-lib carriers** for EGL + GLES2 + GBM surface API +into a **LOAD-BEARING cross-plan blocker**. Design doc §2 line 122 +says v1's stubs "are all reused verbatim" — but this worktree's v1 +EGL + GLES2 surface lives entirely as *host-side imports* in +`host/src/gl/*` and `host_gl_*` wasm imports. There is **no +`sysroot/lib/libEGL.a` or `sysroot/lib/libGLESv2.a`**; no +`examples/libs/libegl-stub/` or `examples/libs/libgles2-stub/` +package. SDL2's KMSDRM video backend +(`SDL_kmsdrmvideo.c::SDL_GL_LoadLibrary`) link-checks both via +`-lEGL -lGLESv2`. Plan 7 PR #2 **cannot link** without these +archives existing. Plans 8–11's GL-using seed apps inherit the +same gap. + +*Resolution (plan-3-follow-up PR landed BEFORE plan 7 PR #2 can +merge, in tandem with plan 2's gbm_surface follow-up):* ship two +new packages — `examples/libs/libegl-stub/` and +`examples/libs/libgles2-stub/` — that wrap plan 3's `host_gl_*` +imports + v1's host-side GL state into the standard EGL 1.5 / +GLES 2.0 public API surface. + +**`libegl-stub`** ships: + +- **`eglGetDisplay(native_dpy)`** — `native_dpy` is a + `struct gbm_device *` (per Mesa's `EGL_PLATFORM_GBM_KHR` + binding). Stub returns a single static `EGLDisplay` handle + (v1 supports one display). +- **`eglInitialize(dpy, &maj, &min)`** — returns EGL 1.5 (5.5); + the host-side GL is GLES 2.0-capable per v1's design. +- **`eglChooseConfig` / `eglGetConfigAttrib`** — exposes one + static `EGLConfig` matching ARGB8888 + depth24 + stencil8 + + one GLES2 renderable bit. SDL2 picks it immediately and + doesn't iterate. +- **`eglCreateContext`** — calls plan 3's + `DRM_IOCTL_WPK_CREATE_GPU_BO`-adjacent context-allocation + pathway (the host already allocates GL contexts on + `host_gl_create_context`); the stub maps `EGLContext` → + v1's `gl_context_id`. Per-OFD context state per plan 3 A2's + per-OFD lift. +- **`eglCreateWindowSurface(dpy, config, native_win, attrib_list)`** + — `native_win` is a `struct gbm_surface *` (from plan 2's + gbm_surface follow-up, see cross-plan amendment in plan 2 + above). The stub remembers the binding `(EGLSurface → + gbm_surface)`; rendering targets the surface's currently- + acquired bo's WebGLTexture-backed framebuffer. +- **`eglMakeCurrent(dpy, draw, read, ctx)`** — sets the host- + side "current context"; v1 is single-thread so this is just + `current_egl_ctx = ctx`. +- **`eglSwapBuffers(dpy, surface)`** — calls + `gbm_surface_lock_front_buffer(s)` semantically: the bo + currently bound as draw target rotates to "locked-by-scanout", + and the next-free bo becomes the new draw target. The KMS + presenter (plan 4) sees the new front bo on the next + `MODE_PAGE_FLIP` (or `MODE_ADDFB2 → SETCRTC` on first frame). + Coordination with plan 2's gbm_surface follow-up is the + load-bearing point: the two must agree on the + front-buffer-rotation cadence. +- **`eglDestroyContext` / `eglDestroySurface` / + `eglTerminate`** — release host-side handles, free the + surface's bo ring (via `gbm_surface_destroy`). +- **`eglGetError`** — returns `EGL_SUCCESS` in v1; stub doesn't + surface host-side GL errors via EGL (callers can call + `glGetError` for that). +- **No `EGL_KHR_image_base` / `EGLImage` in v1.** The Mesa + driver exposes it; SDL2's KMSDRM backend doesn't require it. + Plan 9's compositor will need it for client surface sharing + — flag as a sub-plan when plan 9 lands. + +**`libgles2-stub`** ships: + +- **All GLES 2.0 entry points wrapping plan 3's `host_gl_*` + imports.** v1's existing host-side surface already handles the + GL state — the stub is mostly mechanical 1:1 routing + (`glClear` → `host_gl_clear`, `glDrawArrays` → + `host_gl_draw_arrays`, etc.). Stub returns success; + `glGetError` returns `GL_NO_ERROR` until the host signals + otherwise. +- **`<GLES2/gl2.h>` + `<GLES2/gl2ext.h>` headers vendored** from + Khronos's reference repo, unmodified. +- **Function pointer trampolines** — SDL2 dlsym-loads GL + entrypoints in the dynamic case but in our wasm32 static-only + world the symbols are linked directly. Provide every + GLES 2.0 entry as a real exported symbol; no `dlsym` redirect. + +*Package shapes:* + +```toml +# examples/libs/libegl-stub/package.toml +name = "libegl-stub" +version = "1.5.0" +license = "MIT" +description = "EGL 1.5 stub over v1's host-side GL contexts" + +[source] +type = "local" # in-tree, no upstream + +[deps] +libgbm = "0.1.0" # plan 2 — for gbm_surface; the follow-up + # ships libgbm at this version + +[build] +script_path = "build.sh" +``` + +```toml +# examples/libs/libgles2-stub/package.toml +name = "libgles2-stub" +version = "2.0.0" +license = "MIT" +description = "GLES 2.0 stub wrapping plan 3's host_gl_* imports" + +[source] +type = "local" + +[deps] +# No external deps — talks directly to host_gl_* imports. + +[build] +script_path = "build.sh" +``` + +Both build with hand-rolled `wasm32posix-cc -c -O2` + `llvm-ar +rcs` invocations; no upstream sources to vendor. + +*Cargo + Vitest tests under the plan-3-follow-up PR:* +- `egl_initialize_returns_1_5` — assert maj/min = 1/5. +- `egl_choose_config_returns_argb8888_d24s8` — assert single + config's attribs match the SDL2 KMSDRM expectations. +- `egl_create_context_then_make_current` — assert the host's + current-context tracker updates. +- `egl_swap_buffers_rotates_gbm_surface_front` — chain plan 2's + `gbm_surface_lock_front_buffer` test through an `eglSwapBuffers` + call; assert the same bo round-trip. +- `gles2_glClear_routes_to_host_gl_clear` — host stub captures + the call args. + +*Branch topology:* the plan-3-follow-up PR stacks on plan 2's +gbm_surface follow-up tip (since libegl-stub depends on +`libgbm`'s gbm_surface_*). PR title: `[explore-dri] sysroot(dri): +libEGL + libGLESv2 stubs — user-space carriers for the v1 +host_gl_* surface`. The two follow-ups (plan 2 + plan 3 GL +stack) together close plan 7's open-architecture #2 and unblock +plan 7 PR #2 merge. + +*Why this lives in plan 3 (not plan 2 or plan 7):* the GL state ++ `host_gl_*` imports + per-OFD context tables are all plan 3 +surface; the libegl-stub is the user-space mirror of plan 3's +host-side GL infrastructure. Plan 2 owns gbm (buffers); plan 3 +owns GL (contexts + commands). Plan 7's SDL2 just consumes both. + +*Cross-plan link:* the matching plan 2 follow-up +(`gbm_surface_*`) is documented in plan 2's review under +"Cross-plan amendment from plan 7's devil's-advocate — gbm_surface +follow-up (LOAD-BEARING)". Both follow-ups land before plan 7 +PR #2; the ordering is plan 2 first (libgbm gains gbm_surface) +then plan 3 (libegl-stub + libgles2-stub depend on the new +libgbm surface). + +--- + +## Phase A — kernel: per-fd state + GPU-tier bo + foreign-texture ioctl (PR #1) + +The kernel learns to (a) keep per-`OpenFileDesc` GL and bo state instead +of per-`Process`, (b) allocate GPU-tier bos via a new ioctl, and (c) +authorise the foreign-texture bind. Multiplexing itself lives entirely on +the host (Phase B); the kernel doesn't know about scheduling. + +### Task A1: Shared ABI module additions + +**Files:** +- Modify: `crates/shared/src/lib.rs` — extend `pub mod dri` with the new + ioctl numbers and struct. + +**Step 1: Add the constants and struct** + +Append inside `pub mod dri { … }`: + +```rust + // --- WPK extensions ('d' magic, nrs 0xE0+ — unused by Linux 6.x) ---- + + /// `_IOWR('d', 0xE0, WpkDrmGpuBoCreate)` — allocate a GPU-tier bo. + /// Plan 2's `MODE_CREATE_DUMB` covers CPU-shared bos (LINEAR, mmap'able). + /// This ioctl covers the GPU tier (§4.2): the bo's backing is a host + /// `WebGLTexture`, not a SAB; the bo is unmappable on the CPU side and + /// is intended for sampling / rendering via the multiplexer. + pub const DRM_IOCTL_WPK_CREATE_GPU_BO: u32 = 0xc010_64e0; + + /// `_IOWR('d', 0xE1, WpkDrmBindForeignTexture)` — bind a foreign bo as + /// a `WebGLTexture` in the caller's GL context. The caller must + /// already hold a local bo handle (via PRIME_FD_TO_HANDLE), and the + /// bo must be GPU-tier. Used by the compositor to sample client bos + /// and by `gbm_bo_import` callers that want texture-side access. + pub const DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE: u32 = 0xc010_64e1; + + /// Linux-shape `struct` for the GPU-bo allocator. 16 bytes on wasm32 + /// (4 × u32). `format` and `usage` are passed through to libgbm's + /// `gbm_bo_create(format, usage)` from the user side. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmGpuBoCreate { + pub width: u32, // 0 in + pub height: u32, // 4 in + pub format: u32, // 8 in DRM_FORMAT_* (ARGB8888 etc.) + pub usage: u32, // 12 in GBM_BO_USE_* bitmask + // out via separate fields: see WpkDrmGpuBoCreate.out + } + + // Returned via piggy-backed write — the kernel writes back over the + // same buffer with `handle, stride, size` packed in. Size of the + // round-trip struct is fixed at 16 bytes; we reuse `format`/`usage` + // slots for `handle`/`stride` on the return path. Avoids a second + // ioctl. **Verify this is acceptable in the devil's-advocate pass — + // a separate `WpkDrmGpuBoCreateOut` struct may be clearer.** + // + // Layout on return: + // 0..4 width (echoed back, unchanged) + // 4..8 height (echoed back, unchanged) + // 8..12 handle (out — process-local; was `format`) + // 12..16 stride (out — bytes; was `usage`) + // + // The 16-byte size is preserved (ioctl encoding stays 0xc010_64e0). + + /// `_IOWR('d', 0xE1, WpkDrmBindForeignTexture)` arg shape. 16 bytes + /// on wasm32 (4 × u32). After the call, the caller's GL context has + /// a `WebGLTexture` accessible by `gl_texture_id` until the bo's + /// refcount drops to zero — the bo is the canonical owner; bo + /// destruction (last `GEM_CLOSE` / OFD-final-close) deletes the + /// underlying `WebGLTexture` and invalidates every binding to it. + /// There is no separate `UNBIND_FOREIGN_TEXTURE` ioctl: bind + /// lifetime is tied to the bo lifetime, scoped by the bo refcount. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmBindForeignTexture { + pub bo_handle: u32, // 0 in caller's local GEM handle + pub gl_target: u32, // 4 in GL_TEXTURE_2D etc. + pub ctx_id: u32, // 8 in caller's GL ctx_id + pub gl_texture_id: u32, // 12 out the WebGLTexture id assigned + // (also writable as a sampler binding) + } +``` + +**Step 2: Add static-assert + ioctl-encoding tests** + +Append to the existing `dri_tests` mod block in `crates/shared/src/lib.rs` +(introduced by plan 2 Task A1 Step 2): + +```rust + #[test] + fn wpk_extension_sizes_match_wasm32() { + assert_eq!(size_of::<WpkDrmGpuBoCreate>(), 16); + assert_eq!(size_of::<WpkDrmBindForeignTexture>(), 16); + } + + #[test] + fn wpk_extension_ioctl_numbers() { + let iowr = IOC_READ | IOC_WRITE; + assert_eq!(DRM_IOCTL_WPK_CREATE_GPU_BO, + ioc(iowr, 'd' as u32, 0xE0, size_of::<WpkDrmGpuBoCreate>() as u32)); + assert_eq!(DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE, + ioc(iowr, 'd' as u32, 0xE1, size_of::<WpkDrmBindForeignTexture>() as u32)); + } +``` + +**Step 3: Run** + +```bash +cargo test -p wasm-posix-shared --target aarch64-apple-darwin --lib dri_tests +``` + +Expected: plan-2 tests still pass; 2 new tests pass. + +**Step 4: Commit** + +```bash +git add crates/shared/src/lib.rs +git commit -m "kernel(dri): shared ABI — WPK GPU bo + foreign-texture ioctl numbers" +``` + +--- + +### Task A2: Lift per-`Process` state to per-`OpenFileDesc` + +**The structural refactor.** Plan 2 placed `dri_handles`, `dri_next_handle`, +and `dri_mmaps` on `Process`; v1 placed `gl_state` on `Process`. Both +choices were v1's single-owner simplification. This task lifts them to +`OpenFileDesc`, matching Linux semantics. fork inherits the OFD via +ref-count (shared state across fork — exactly Linux's per-fd behaviour); a +fresh `open()` of `/dev/dri/renderD128` yields an OFD with empty state. + +**Files:** +- Modify: `crates/kernel/src/ofd.rs` — add `DriFdState` and `GlFdState` + hung off `OpenFileDesc`. +- Modify: `crates/kernel/src/process.rs` — remove `dri_handles`, + `dri_next_handle`, `dri_mmaps`, `gl_state` (and any v1 helper methods). +- Modify: `crates/kernel/src/syscalls.rs` — update every dispatcher that + reads/writes the moved state to go through `OpenFileDesc` instead. +- Modify: `crates/kernel/src/fork.rs` — drop the "reset child's + dri_handles" code (plan 2 Task A3 Step 3); the OFD-table dup already + gives the child shared per-fd state. Keep the dri_mmaps reset (mmaps + are tied to address space, not fd — clearing on fork is correct). + +**Step 1: Add `DriFdState` and `GlFdState` to `OpenFileDesc`** + +In `crates/kernel/src/ofd.rs`: + +```rust +/// Per-fd state for `/dev/dri/renderD128` opens (plan 3 lift). +/// Multiple fds pointing at the same OFD (`dup`, fork-inherit) share +/// the same state; a new `open()` gets a fresh `OpenFileDesc` with +/// `DriFdState::default()`. +#[derive(Default, Clone, Debug)] +pub struct DriFdState { + /// GEM-handle → global BoId map. v1 of renderD128 (plan 2) placed + /// this on `Process`; this lift moves it here for Linux semantics. + pub handles: BTreeMap<u32, crate::dri::BoId>, + /// Next handle id to issue per this fd. Linux numbers from 1. + pub next_handle: u32, + /// v1 GL context-binding bookkeeping (lifted from `Process::gl_state`). + /// Each entry is a `(ctx_id → GlBinding)` row. `GlBinding`'s shape is + /// unchanged from v1; only its container moves. + pub gl: GlFdState, +} + +#[derive(Default, Clone, Debug)] +pub struct GlFdState { + /// (ctx_id) → GlBinding. v1 has at most one binding per Process; + /// per-fd lift allows N per-fd (multiple `glCreateContext` on one fd). + pub bindings: BTreeMap<u32, crate::gles::GlBinding>, + /// Currently-bound ctx for `MAKE_CURRENT` semantics. + pub current_ctx_id: Option<u32>, +} + +// On `OpenFileDesc`, add: +// pub dri: Option<Box<DriFdState>>, // boxed: only present for renderD128 OFDs +// Initialise to `Some(Box::default())` in the open path for +// `/dev/dri/renderD128`; `None` for every other file. +``` + +`next_handle` initial value: 1 (`DriFdState::default()` writes +`next_handle: 0` since `u32::default() == 0`; override in the open path or +add a `Default` impl that sets it to 1). + +**Step 2: Move all call sites** + +Walk plan 2's Phase A Tasks A3, A6-A9 and v1's GL syscall paths. Each +reference like: + +```rust +let mut pt = PROCESS_TABLE.lock(); +let proc = pt.get_mut(pid).ok_or(Errno::ESRCH)?; +let bo_id = *proc.dri_handles.get(&req.handle).ok_or(Errno::ENOENT)?; +``` + +becomes: + +```rust +let mut pt = PROCESS_TABLE.lock(); +let proc = pt.get(pid).ok_or(Errno::ESRCH)?; +let ofd_idx = proc.fd_table.get(fd as i32).ok_or(Errno::EBADF)?; +let ofd = pt.ofds.entries.get_mut(ofd_idx as usize) + .and_then(|o| o.as_mut()).ok_or(Errno::EBADF)?; +let dri = ofd.dri.as_mut().ok_or(Errno::ENOTTY)?; +let bo_id = *dri.handles.get(&req.handle).ok_or(Errno::ENOENT)?; +``` + +Same shape for: CREATE_DUMB handle insert, MAP_DUMB handle lookup, +DESTROY_DUMB / GEM_CLOSE handle remove, PRIME_HANDLE_TO_FD handle lookup, +PRIME_FD_TO_HANDLE handle insert, mmap path handle-existence check. + +**Lock-order note** (carries the policy from plan 2's Pre-impl review): the +typical sequence is `PROCESS_TABLE.lock()` → resolve OFD → operate on +`ofd.dri`. The `BoRegistry` and `HOST_IO` locks are still taken separately +and released before the next lock. The new shape adds OFD access inside +the `PROCESS_TABLE.lock()` scope — verify no code path now holds +`PROCESS_TABLE` *and* `BoRegistry` simultaneously. (The +`with_registry(...)` calls run after the process-table lock is dropped.) + +**Step 3: fork inherits per-fd state via OFD dup** + +In `crates/kernel/src/fork.rs`: delete the lines that reset +`dri_handles` / `dri_next_handle` to empty in the child (plan 2 Task A3 +Step 3 introduced these — now wrong, since per-fd state SHOULD be +inherited along with the fd-table). Keep the `dri_mmaps` reset; mmaps are +per-address-space, not per-fd. (Actually `dri_mmaps` may itself move to +the OFD in a future lift if mmap binding becomes fd-local — but for plan +3 the address-space binding stays per-Process: a mmap on a renderD128 fd +is a memory binding in the calling process's wasm Memory, not in the OFD.) + +The fork-inherit cargo test from plan 2 Task A3 Step 4 must be **updated**: +parent's `dri_handles` (now on the OFD) IS shared with child after fork; +both processes see the same handles through the same fd. A separate test +covers: child opens its own `/dev/dri/renderD128` after fork — gets a fresh +OFD with empty handles. + +**Step 4: Cargo tests** + +Add to `crates/kernel/src/dri/tests.rs` (or analogous): + +```rust +#[test] +fn two_opens_yield_distinct_handle_namespaces() { + // Process 1 open A: CREATE_DUMB → handle 1 + // Process 1 open B (second open of /dev/dri/renderD128): handle 1 too, + // but pointing at a different bo. Verify the two OFDs' DriFdState + // are independent. +} + +#[test] +fn fork_inherits_handle_namespace_through_shared_fd() { + // Process 1 open: CREATE_DUMB → handle 1 + // Process 1 fork → Process 2. + // Process 2 looks up handle 1 on the inherited fd → resolves to same BoId. +} + +#[test] +fn fork_child_open_gets_fresh_namespace() { + // Process 1 open + CREATE_DUMB → handle 1. + // Fork. Child OPENS its own renderD128 → handle 1 on the new OFD is + // absent (ENOENT on GEM_CLOSE). +} +``` + +**Step 5: Run** + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +``` + +Expected: plan-2's tests pass after the call-site updates; 3 new tests pass. + +**Step 6: Commit** + +```bash +git add crates/kernel/src/ofd.rs crates/kernel/src/process.rs \ + crates/kernel/src/syscalls.rs crates/kernel/src/fork.rs \ + crates/kernel/src/dri/ +git commit -m "kernel(dri): lift dri_handles + gl_state to per-OpenFileDesc" +``` + +--- + +### Task A3: Activate `BoTier::Gpu` — `DRM_IOCTL_WPK_CREATE_GPU_BO` + +**Files:** +- Modify: `crates/kernel/src/dri/bo.rs` — add a `tier` parameter to + `BoRegistry::alloc` (default `CpuShared` for back-compat with plan 2's + call sites; new call site passes `Gpu`). +- Modify: `crates/kernel/src/syscalls.rs` — add the new ioctl branch in + `handle_dri_ioctl()`. +- Modify: `crates/kernel/src/process.rs` — extend the `HostIO` trait with + `gbm_bo_create_gpu` (returns 0 on success, ENOMEM otherwise — pattern + parallels plan 2's `gbm_bo_create`). + +**Step 1: `BoRegistry::alloc_gpu` (or `tier` parameter)** + +Recommended shape: add an explicit `alloc_gpu` rather than extending +`alloc`'s signature; the SAB-allocation comment on `alloc` doesn't apply +to GPU bos and a separate method keeps the contract clearer. + +```rust +impl BoRegistry { + pub fn alloc_gpu( + &mut self, + width: u32, + height: u32, + format: u32, + _usage: u32, + ) -> &mut GbmBo { + let id = self.next_id; + self.next_id += 1; + // GPU-tier stride: undefined at the kernel level; the host's + // WebGLTexture picks its own layout. We return 0 to the user — + // libgbm GPU bos don't promise a CPU-visible stride. + let bo = GbmBo { + id, + width, + height, + format, + modifier: DRM_FORMAT_MOD_LINEAR, // honour the field; semantics moot for GPU + stride: 0, + size: 0, + bpp: 0, + tier: BoTier::Gpu, + refcount: 1, + prime_cookie: None, + }; + self.map.insert(id, bo); + self.map.get_mut(&id).unwrap() + } +} +``` + +**Step 2: New ioctl branch in `handle_dri_ioctl`** + +```rust +DRM_IOCTL_WPK_CREATE_GPU_BO => { + if buf.len() < core::mem::size_of::<WpkDrmGpuBoCreate>() { + return Err(Errno::EINVAL); + } + let req: WpkDrmGpuBoCreate = unsafe { + core::ptr::read_unaligned(buf.as_ptr() as *const _) + }; + if req.width == 0 || req.height == 0 { return Err(Errno::EINVAL); } + // Format check: ARGB8888 / XRGB8888 / RGB565 only in v1; reject others + // (libgbm probes; same shape as plan 2 Task A6's bpp check). + match req.format { + DRM_FORMAT_ARGB8888 | DRM_FORMAT_XRGB8888 | DRM_FORMAT_RGB565 => {}, + _ => return Err(Errno::EINVAL), + } + + let (bo_id,) = crate::dri::with_registry(|r| { + let bo = r.alloc_gpu(req.width, req.height, req.format, req.usage); + (bo.id,) + }); + + let host_rc = HOST_IO.lock().gbm_bo_create_gpu(pid, bo_id, + req.width, req.height, req.format, req.usage); + if host_rc < 0 { + crate::dri::with_registry(|r| { r.decref(bo_id); }); + return Err(Errno::ENOMEM); + } + + // Allocate a per-fd handle (uses the lifted DriFdState). + // Symmetry with plan 2 A6 unwind: if anything below fails we must + // call `gbm_bo_destroy` to release the host-side SAB/texture, not + // just decref the kernel registry — plan 2's review flagged this + // shape under "Correctness — open". + // + // Borrow note: hold `PROCESS_TABLE` mutably for the whole hop; the + // immutable `pt.get(pid)` is dropped before we take `ofds.entries` + // mutably (NLL drops the immut borrow at end-of-statement). Spell + // out the intermediate `let ofd_idx = …;` so the borrow scoping is + // unambiguous to readers. + let handle_result: Result<u32, Errno> = (|| { + let mut pt = PROCESS_TABLE.lock(); + let ofd_idx = pt.get(pid).ok_or(Errno::ESRCH)? + .fd_table.get(fd as i32).ok_or(Errno::EBADF)?; + let ofd = pt.ofds.entries.get_mut(ofd_idx as usize) + .and_then(|o| o.as_mut()).ok_or(Errno::EBADF)?; + let dri = ofd.dri.as_mut().ok_or(Errno::ENOTTY)?; + let h = dri.next_handle.checked_add(1).ok_or(Errno::ENOMEM)?; + dri.next_handle = h; + dri.handles.insert(h, bo_id); + Ok(h) + })(); + let handle = match handle_result { + Ok(h) => h, + Err(e) => { + crate::dri::with_registry(|r| { r.decref(bo_id); }); + HOST_IO.lock().gbm_bo_destroy(pid, bo_id); + return Err(e); + } + }; + + // Pack the return values into the buffer: + // 0..4 width (echoed) + // 4..8 height (echoed) + // 8..12 handle + // 12..16 stride (= 0 for GPU tier) + let mut out = [0u8; 16]; + out[0..4].copy_from_slice(&req.width.to_le_bytes()); + out[4..8].copy_from_slice(&req.height.to_le_bytes()); + out[8..12].copy_from_slice(&handle.to_le_bytes()); + // out[12..16] stays 0 (stride for GPU tier is undefined) + buf[..16].copy_from_slice(&out); + Ok(()) +} +``` + +**Step 3: `HostIO::gbm_bo_create_gpu`** + +```rust + /// Allocate a GPU-tier bo: host creates a WebGLTexture of the given + /// dimensions/format, ready for foreign-texture binding and for + /// rendering via the multiplexer. Returns 0 on success, < 0 on error. + fn gbm_bo_create_gpu(&mut self, pid: i32, bo_id: u32, width: u32, + height: u32, format: u32, usage: u32) -> i32; +``` + +Stub it in `host/test/centralized-test-helper.ts` to capture call args. + +**Step 4: Cargo + commit** + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib dri +git add crates/kernel/ crates/shared/ host/test/centralized-test-helper.ts +git commit -m "kernel(dri): activate BoTier::Gpu + DRM_IOCTL_WPK_CREATE_GPU_BO" +``` + +--- + +### Task A4: `DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE` + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — new branch in `handle_dri_ioctl`. +- Modify: `crates/kernel/src/process.rs` — `HostIO::gl_bind_foreign_texture`. + +**Step 1: Ioctl branch** + +```rust +DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE => { + if buf.len() < core::mem::size_of::<WpkDrmBindForeignTexture>() { + return Err(Errno::EINVAL); + } + let mut req: WpkDrmBindForeignTexture = unsafe { + core::ptr::read_unaligned(buf.as_ptr() as *const _) + }; + // Resolve handle → BoId via per-fd state. + let bo_id = { + let pt = PROCESS_TABLE.lock(); + let proc = pt.get(pid).ok_or(Errno::ESRCH)?; + let ofd_idx = proc.fd_table.get(fd as i32).ok_or(Errno::EBADF)?; + let ofd = pt.ofds.entries.get(ofd_idx as usize) + .and_then(|o| o.as_ref()).ok_or(Errno::EBADF)?; + let dri = ofd.dri.as_ref().ok_or(Errno::ENOTTY)?; + *dri.handles.get(&req.bo_handle).ok_or(Errno::ENOENT)? + }; + // Tier check: must be GPU-tier. + let is_gpu = crate::dri::with_registry(|r| + r.get(bo_id).map(|bo| bo.tier == BoTier::Gpu)); + match is_gpu { + Some(true) => {}, + Some(false) => return Err(Errno::EINVAL), // CPU bo can't be a texture + None => return Err(Errno::EBADF), + } + // Ask host for the WebGLTexture id. + let gl_tex_id = HOST_IO.lock().gl_bind_foreign_texture(pid, + req.ctx_id, bo_id, req.gl_target); + if gl_tex_id < 0 { return Err(Errno::EINVAL); } + req.gl_texture_id = gl_tex_id as u32; + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut _, req); + } + Ok(()) +} +``` + +**Step 2: `HostIO::gl_bind_foreign_texture`** + +```rust + /// Make the GPU-tier bo `bo_id` available as a `WebGLTexture` in the + /// caller's GL context. Returns the WebGLTexture id (≥ 0) on success; + /// < 0 on error. + fn gl_bind_foreign_texture(&mut self, pid: i32, ctx_id: u32, + bo_id: u32, gl_target: u32) -> i32; +``` + +**Step 3: Cargo tests** + +```rust +#[test] +fn bind_foreign_texture_rejects_cpu_tier() { + // CREATE_DUMB → BoTier::CpuShared; WPK_BIND_FOREIGN_TEXTURE → EINVAL. +} + +#[test] +fn bind_foreign_texture_resolves_local_handle() { + // WPK_CREATE_GPU_BO → handle 1, BoTier::Gpu. + // WPK_BIND_FOREIGN_TEXTURE on handle 1 → host called once with bo_id. +} +``` + +**Step 4: Commit** + +```bash +git add crates/kernel/ host/test/ +git commit -m "kernel(dri): DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE" +``` + +--- + +### Task A5: Cleanup — fork / close / exit / execve for per-fd state + +**Files:** +- Modify: `crates/kernel/src/fork.rs` — already covered in Task A2 Step 3 + (delete plan-2's reset of `dri_handles`; per-fd state is now shared via + OFD dup, no special handling needed). +- Modify: `crates/kernel/src/syscalls.rs` (close path) — OFD destruction + must decref every bo in `dri.handles` and call host destroy callbacks + for refcount-0. +- Modify: `crates/kernel/src/wasm_api.rs` — `SYS_EXIT_GROUP` cleanup + iterates the process's fd table; per-fd state goes away naturally as + OFDs are released. +- Modify: execve cleanup: closes CLOEXEC fds (which trigger the close-path + cleanup above); per-fd DriFdState dies with each closed OFD. + +**Step 1: OFD destruction releases bos** + +In `crates/kernel/src/ofd.rs` (or wherever OFD refcount hits zero — Linux +calls this `__fput`), add a hook: + +```rust +impl OpenFileDesc { + /// Called when the OFD's refcount reaches zero (last fd referencing + /// it has been closed). Plan 2's `prime_bo` decref is part of this. + /// Plan 3's `dri.handles` and `dri.gl.bindings` cleanup is here too. + pub fn on_final_close(&mut self, pid: i32, host_io: &mut dyn HostIO) { + if let Some(dri) = self.dri.take() { + // Release every GEM handle this fd held. + for (_h, bo_id) in dri.handles { + let new_rc = crate::dri::with_registry(|r| r.decref(bo_id)) + .unwrap_or(0); + if new_rc == 0 { host_io.gbm_bo_destroy(pid, bo_id); } + } + // Tear down every GL context this fd opened. + for (ctx_id, _binding) in dri.gl.bindings { + host_io.gl_destroy_context(pid, ctx_id); + } + } + if let Some(p) = self.prime_bo.take() { + let new_rc = crate::dri::with_registry(|r| r.decref(p.bo_id)) + .unwrap_or(0); + if new_rc == 0 { host_io.gbm_bo_destroy(pid, p.bo_id); } + } + } +} +``` + +Wire `on_final_close` into the existing OFD-final-release path. This +**replaces** plan 2 Task A9 Step 2's per-close decref (which was the bug +flagged in plan 2's Pre-impl review — "decref on every fd close"). Per-fd +state release is now correctly at the OFD level, not the fd level. + +**Step 2: `dri_mmaps` stays on Process — fork / exit cleanup unchanged** + +Plan 2's `Process::dri_mmaps` doesn't move; mmaps are per-address-space. +The munmap path / `SYS_EXIT_GROUP` mmap teardown is unchanged from plan 2. + +**Step 3: Cargo tests** + +```rust +#[test] +fn dup_then_close_preserves_bos_until_final_close() { + // open /dev/dri/renderD128 → fd0; CREATE_DUMB → handle 1, bo refcount 1. + // dup(fd0) → fd1 (same OFD). + // close(fd0) → bo refcount unchanged (OFD still has a reference). + // close(fd1) → OFD destroyed → on_final_close → bo refcount → 0, + // gbm_bo_destroy fired exactly once. +} + +#[test] +fn fork_inherit_then_double_close_correctly_decrefs() { + // Process A: open fd → CREATE_DUMB → bo refcount 1. + // Process A fork → Process B (inherits the fd, same OFD). + // OFD refcount = 2 (one fd in each process). + // Process A close(fd) → OFD refcount = 1, bo refcount unchanged. + // Process B close(fd) → OFD destroyed → bo refcount → 0, + // gbm_bo_destroy fired once. +} +``` + +**Step 4: Commit** + +```bash +git add crates/kernel/ +git commit -m "kernel(dri): per-OFD release — final-close decrefs bos + tears down gl ctxs" +``` + +--- + +### Task A6: ABI snapshot regen (additive) + +**Files:** +- Modify: `abi/snapshot.json` (auto-generated). +- DO NOT modify: `ABI_VERSION` (additive). + +**Step 1: Regenerate** + +```bash +bash scripts/check-abi-version.sh update +``` + +**Step 2: Inspect** + +```bash +git diff abi/snapshot.json +``` + +Expected diff: new entries for `WpkDrmGpuBoCreate`, `WpkDrmBindForeignTexture`, +and the one new `host_gl_bind_foreign_texture` import. **No** changes to any +*existing* row. + +**Step 3: Verify + commit** + +```bash +bash scripts/check-abi-version.sh +git add abi/snapshot.json +git commit -m "kernel(dri): regen ABI snapshot — additive WPK GPU-bo + foreign-texture" +``` + +--- + +### Task A7: Phase A — full gauntlet + open PR #1 + +**Step 1: Gauntlet** + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +**Step 2: Push the branch** + +```bash +git push -u origin emdash/explore-direct-rendering-infrastructure-mux-kernel-XXXXX +``` + +**Step 3: Open the draft PR** + +Title: `[explore-dri] kernel(dri): per-fd gl_state + per-fd dri_handles + GPU-tier bos + foreign-texture ioctl` + +Body template (Brandon style): + +```markdown +## Summary +- Lift v1's per-`Process` `gl_state` and plan 2's per-`Process` `dri_handles` + to per-`OpenFileDesc` (`DriFdState`). fork inherits the OFD; new `open` + gets a fresh namespace. Matches Linux per-fd semantics. +- Activate `BoTier::Gpu` reserved by plan 2: add + `DRM_IOCTL_WPK_CREATE_GPU_BO` (in our reserved `0xE0+` nr range of the + DRM `'d'` magic). GPU-tier bos are unmappable on CPU; host backs them + with `WebGLTexture` (Phase B). +- Add `DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE` for the foreign-texture + sampling case (compositor sampling client bos; future-plan). +- OFD-final-close hook releases bos and tears down GL contexts — + replaces plan 2's "decref on every fd close" bug flagged in its + Pre-impl review. + +## Why +Plan 3 of the DRI v2 design (`docs/plans/2026-05-18-dri-design.md` §5) +requires N processes to hold their own GL contexts concurrently. Per-`Process` +state from v1 + plan 2 is the wrong granularity for that. This PR pays the +refactor cost up-front; Phase B/C build on top without further moves. + +## Verification +- `cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib` +- `cd host && npx vitest run` +- `scripts/run-libc-tests.sh` +- `scripts/run-posix-tests.sh` +- `bash scripts/check-abi-version.sh` + +## ABI impact +Additive only — no `ABI_VERSION` bump. Two new ioctl numbers in the +unused DRM `'d'` magic `0xE0+` range; two new `repr(C)` structs in +`shared::dri`; one new `host_gl_*` import. + +## Notes +- Per-fd lift updates every call site introduced by plan 2 (Tasks A3, A6-A9) + + v1's GL syscall paths. +- Compositor priority (`COMPOSITOR_PRI` for PID 2) lives entirely in the + host muxer (PR #2) — kernel does not schedule. +- `GLIO_PRESENT_KMS` is deferred to the KMS plan (`/dev/dri/card0`). +``` + +**Do not merge.** + +--- + +## Phase B — host: `GlMuxer` + shadow + cross-context map (PR #2) + +Phase B is where most of the new work lives. The kernel-worker grows a +`GlMuxer` (one per OffscreenCanvas / `WebGL2RenderingContext`), a +`SubmitQueue`, and a cross-context `WebGLTexture` map. + +### Task B1: `GlShadowState` type + cmdbuf decoder updates + +**Files:** +- Create: `host/src/webgl/shadow.ts`. +- Modify: `host/src/webgl/cmdbuf.ts` (or wherever v1's TLV decoder lives) — + every state-changing op writes the corresponding field on the active + binding's shadow before/after issuing the WebGL call. + +```ts +// host/src/webgl/shadow.ts +export interface GlShadowState { + // Viewport / scissor + viewport: [number, number, number, number]; // x, y, w, h + scissor: [number, number, number, number] | null; // null = SCISSOR_TEST disabled + + // Clear state + clearColor: [number, number, number, number]; + clearDepth: number; + clearStencil: number; + + // Depth / stencil / blend + depthTestEnabled: boolean; + depthFunc: number; + depthMask: boolean; + stencilTestEnabled: boolean; + blendEnabled: boolean; + blendFunc: { srcRGB: number, dstRGB: number, srcA: number, dstA: number }; + blendEquation: { rgb: number, a: number }; + + // Rasterizer + cullFaceEnabled: boolean; + cullFace: number; + frontFace: number; + polygonOffsetFillEnabled: boolean; + polygonOffset: { factor: number, units: number }; + + // Program + VAO + FBO (per-context isolated, but state nonetheless) + currentProgram: WebGLProgram | null; + vao: WebGLVertexArrayObject | null; // null pre-MAKE_CURRENT; populated thereafter + fbo: WebGLFramebuffer | null; // null = default fb + + // Texture units + activeTexture: number; // GL_TEXTURE0 + N (just N here, 0..N) + textureUnits: (WebGLTexture | null)[]; // [TEX0..TEX_MAX_UNITS-1] + + // Pixel store + unpackAlignment: number; + packAlignment: number; +} + +export function defaultShadow(maxTexUnits: number): GlShadowState { + return { + viewport: [0, 0, 0, 0], + scissor: null, + clearColor: [0, 0, 0, 0], + clearDepth: 1, + clearStencil: 0, + depthTestEnabled: false, + depthFunc: 0x0201, // GL_LESS + depthMask: true, + stencilTestEnabled: false, + blendEnabled: false, + blendFunc: { srcRGB: 1, dstRGB: 0, srcA: 1, dstA: 0 }, // ONE, ZERO + blendEquation: { rgb: 0x8006, a: 0x8006 }, // FUNC_ADD + cullFaceEnabled: false, + cullFace: 0x0405, // GL_BACK + frontFace: 0x0901, // GL_CCW + polygonOffsetFillEnabled: false, + polygonOffset: { factor: 0, units: 0 }, + currentProgram: null, + vao: null, // set at MAKE_CURRENT + fbo: null, + activeTexture: 0, + textureUnits: new Array(maxTexUnits).fill(null), + unpackAlignment: 4, + packAlignment: 4, + }; +} +``` + +The cmdbuf decoder (v1's `dispatchTLV` or equivalent) gets updated so each +state-mutating op writes the shadow: + +```ts +case OP_VIEWPORT: { + const [x, y, w, h] = readArgs4(buf, off); + binding.shadow.viewport = [x, y, w, h]; + gl.viewport(x, y, w, h); + break; +} +case OP_USE_PROGRAM: { + const progId = readArg(buf, off); + const prog = binding.programs.get(progId); + binding.shadow.currentProgram = prog; + gl.useProgram(prog); + break; +} +// … ~40 cases total. Mechanical translation of the v1 op table. +``` + +**Symmetry rule:** every op that touches WebGL state-machine state writes +the shadow, even if the value didn't change. The muxer assumes the shadow +is the source of truth at submit-end; partial-write shadows break +`switchTo`. + +**Cargo / vitest:** unit-test that for each opcode in the v1 op table, the +shadow field listed in `GlShadowState` is updated correctly. Drive a +single-binding cmdbuf with one op of each type, assert +`binding.shadow.<field>` matches the input args. + +**Commit:** `host(dri): GlShadowState type + cmdbuf shadow-write updates` + +--- + +### Task B2: `GlMuxer` — `switchTo` + lazy current-tracking + +**Files:** +- Create: `host/src/webgl/muxer.ts`. + +```ts +// host/src/webgl/muxer.ts +import type { GlShadowState } from './shadow'; + +export interface GlBinding { + pid: number; + ctx_id: number; + shadow: GlShadowState; + // … existing v1 fields: programs, buffers, textures, etc. +} + +export class GlMuxer { + private current: GlBinding | null = null; + + constructor(private gl: WebGL2RenderingContext) {} + + /** No-op if target === current (lazy switch). */ + switchTo(target: GlBinding): void { + if (this.current === target) return; + const s = target.shadow; + const gl = this.gl; + + gl.bindVertexArray(s.vao); + gl.bindFramebuffer(gl.FRAMEBUFFER, s.fbo); + gl.viewport(s.viewport[0], s.viewport[1], s.viewport[2], s.viewport[3]); + + if (s.scissor) { + gl.enable(gl.SCISSOR_TEST); + gl.scissor(s.scissor[0], s.scissor[1], s.scissor[2], s.scissor[3]); + } else { + gl.disable(gl.SCISSOR_TEST); + } + + gl.clearColor(s.clearColor[0], s.clearColor[1], s.clearColor[2], s.clearColor[3]); + gl.clearDepth(s.clearDepth); + gl.clearStencil(s.clearStencil); + + if (s.depthTestEnabled) gl.enable(gl.DEPTH_TEST); else gl.disable(gl.DEPTH_TEST); + gl.depthFunc(s.depthFunc); + gl.depthMask(s.depthMask); + + if (s.stencilTestEnabled) gl.enable(gl.STENCIL_TEST); else gl.disable(gl.STENCIL_TEST); + + if (s.blendEnabled) gl.enable(gl.BLEND); else gl.disable(gl.BLEND); + gl.blendFuncSeparate(s.blendFunc.srcRGB, s.blendFunc.dstRGB, + s.blendFunc.srcA, s.blendFunc.dstA); + gl.blendEquationSeparate(s.blendEquation.rgb, s.blendEquation.a); + + if (s.cullFaceEnabled) gl.enable(gl.CULL_FACE); else gl.disable(gl.CULL_FACE); + gl.cullFace(s.cullFace); + gl.frontFace(s.frontFace); + + if (s.polygonOffsetFillEnabled) gl.enable(gl.POLYGON_OFFSET_FILL); + else gl.disable(gl.POLYGON_OFFSET_FILL); + gl.polygonOffset(s.polygonOffset.factor, s.polygonOffset.units); + + gl.useProgram(s.currentProgram); + + for (let i = 0; i < s.textureUnits.length; i++) { + const tex = s.textureUnits[i]; + if (tex) { + gl.activeTexture(gl.TEXTURE0 + i); + gl.bindTexture(gl.TEXTURE_2D, tex); + } + } + gl.activeTexture(gl.TEXTURE0 + s.activeTexture); + + gl.pixelStorei(gl.UNPACK_ALIGNMENT, s.unpackAlignment); + gl.pixelStorei(gl.PACK_ALIGNMENT, s.packAlignment); + + this.current = target; + } + + invalidateCurrent(): void { this.current = null; } +} +``` + +**Vitest:** create a mock `WebGL2RenderingContext` (jest-style spy) and +two GlBindings with distinct shadows. Call `switchTo(B1)`, assert all +state-applying methods fired with B1's values. Call `switchTo(B1)` again, +assert no calls (lazy). Call `switchTo(B2)`, assert state methods fired +with B2's values. Cycle costs measured here are sanity-checks, not perf +gates. + +**Commit:** `host(dri): GlMuxer with lazy switchTo + shadow replay` + +--- + +### Task B3: `SubmitQueue` — round-robin + `COMPOSITOR_PRI` + +**Files:** +- Create: `host/src/webgl/submit-queue.ts`. + +```ts +// host/src/webgl/submit-queue.ts +import type { GlBinding } from './muxer'; + +interface QueueEntry { + key: string; // `${pid}:${ctx_id}` + binding: GlBinding; + frames: SubmitFrame[]; +} + +interface SubmitFrame { + memorySab: SharedArrayBuffer; + off: number; + len: number; +} + +export class SubmitQueue { + private compositor: QueueEntry[] = []; // head-of-queue lane + private clients: QueueEntry[] = []; // FIFO within + private byKey = new Map<string, QueueEntry>(); + + /** `isCompositor(pid)` decides lane bucketing at enqueue + + * release time. Plan 4 (KMS) wires this to + * `kmsRegistry.isMasterPid(pid)`; the default predicate matches + * the original plan-3 PID-2 hardcode so this plan's tests pass + * unchanged. See plan 4's Pre-impl review "Cross-plan amendment + * from plan 4's devil's-advocate" above for the why. */ + constructor( + private isCompositor: (pid: number) => boolean = (pid) => pid === 2, + ) {} + + enqueue(binding: GlBinding, frame: SubmitFrame): void { + const key = `${binding.pid}:${binding.ctx_id}`; + let entry = this.byKey.get(key); + if (!entry) { + entry = { key, binding, frames: [] }; + this.byKey.set(key, entry); + (this.isCompositor(binding.pid) ? this.compositor : this.clients).push(entry); + } + entry.frames.push(frame); + } + + /** Returns the next entry to drain (or null if empty). The caller + * shifts one frame off the returned entry; if the entry is then empty + * the caller must call `releaseIfEmpty(entry)` to evict it from the + * lane + `byKey`. (Inlining the eviction here would force the queue + * to know the drain's atomic-write semantics; cleaner to let drain + * own the cycle.) */ + pickNext(): QueueEntry | null { + // Compositor head-of-queue. + while (this.compositor.length > 0) { + const e = this.compositor[0]; + if (e.frames.length > 0) return e; + this.compositor.shift(); + this.byKey.delete(e.key); + } + // Round-robin among clients. + while (this.clients.length > 0) { + const e = this.clients[0]; + if (e.frames.length > 0) { + // Rotate: pop head, push tail (round-robin between drains). + this.clients.shift(); + this.clients.push(e); + return e; + } + this.clients.shift(); + this.byKey.delete(e.key); + } + return null; + } + + /** Drain calls this after popping the last frame from an entry, so + * `byKey` and the lane drop the now-empty entry promptly. Without + * this, exhausted entries linger until they cycle back to the head + * (the rotate-on-pickNext path) and bloat `byKey` for the duration. */ + releaseIfEmpty(entry: QueueEntry): void { + if (entry.frames.length > 0) return; + this.byKey.delete(entry.key); + const lane = this.isCompositor(entry.binding.pid) ? this.compositor + : this.clients; + const i = lane.indexOf(entry); + if (i >= 0) lane.splice(i, 1); + } + + isEmpty(): boolean { return this.byKey.size === 0; } +} +``` + +**Vitest:** queue with three clients (`pid=2`, `pid=10`, `pid=11`) each +with one frame. Assert `pickNext()` returns `pid=2` first, then `pid=10`, +then `pid=11`. Then enqueue a second frame for `pid=10`; assert next +`pickNext()` returns `pid=11` (round-robin advanced past the head), then +`pid=10`. + +**Edge: fairness floor.** Currently a never-emptying compositor queue +starves clients. Acceptable for v2 (the compositor is well-behaved by +construction — design §9.x). Add a comment marking this as a known +property; v3 can add a watchdog or token-bucket. + +**Commit:** `host(dri): SubmitQueue — compositor head-of-queue + client round-robin` + +--- + +### Task B4: Wire `submit` → queue → muxer drain + +**Files:** +- Modify: `host/src/webgl/bridge.ts` (or wherever v1's `host_gl_submit` + handler lives). +- Modify: `host/src/kernel-worker.ts` if the drain trigger lives there. + +Replace v1's direct `decodeAndDispatch(...)` call with: + +```ts +// Old (v1): +function onSubmit(pid: number, ctx_id: number, off: number, len: number) { + const binding = getBinding(pid, ctx_id); + decodeAndDispatch(binding, memorySab, off, len); +} + +// New (v2): +function onSubmit(pid: number, ctx_id: number, off: number, len: number) { + const binding = getBinding(pid, ctx_id); + queue.enqueue(binding, { memorySab, off, len }); + drainSoon(); // microtask-defer drain to coalesce multi-submit bursts +} + +let draining = false; +function drainSoon() { + if (draining) return; + draining = true; + queueMicrotask(() => { + draining = false; + drain(); + }); +} + +function drain() { + while (!queue.isEmpty()) { + const entry = queue.pickNext(); + if (!entry) break; + const frame = entry.frames.shift()!; + muxer.switchTo(entry.binding); + decodeAndDispatch(entry.binding, frame.memorySab, frame.off, frame.len); + queue.releaseIfEmpty(entry); // drop now-empty entries promptly + } +} +``` + +The `queueMicrotask` defer coalesces consecutive submits before any +WebGL work runs — turns N back-to-back submits from the same binding +into one switch + N dispatches. + +**Dual-host parity (CLAUDE.md):** both `host/src/node-kernel-worker-entry.ts` +and `examples/browser/lib/kernel-worker-entry.ts` must wire the queue + +muxer the same way. Symmetry check mandatory before commit. + +**Vitest:** drive two submits from `pid=10` followed by two from `pid=11`, +assert `muxer.switchTo` was called exactly twice (once for each +distinct pid), `decodeAndDispatch` four times in the expected order. + +**Commit:** `host(dri): wire submit → SubmitQueue + GlMuxer drain (dual-host)` + +--- + +### Task B5: Per-fd GL binding lookup (companion to A2) + +**Files:** +- Modify: `host/src/webgl/registry.ts` (v1's GlBinding registry). + +v1 likely keyed bindings by `(pid, ctx_id)` — a flat map. With per-fd +state on the kernel side, the host's binding key is conceptually `(pid, +fd, ctx_id)`. But the host doesn't see `fd` directly; the kernel +translates fd → OFD-id before calling `host_gl_*`. So the host's key +becomes `(pid, ofd_id, ctx_id)` — where `ofd_id` is a new stable u32 the +kernel sends with every `host_gl_*` call. + +This is a small extension of v1's `host_gl_*` import signatures: each +call now takes an extra `ofd_id: u32` argument. **This counts as a +change to existing imports, not additive** — would break v1 if shipped +in isolation. **Mitigation:** plan 3 PR #1 (kernel) already updates +every call site (Task A2 Step 2 moves the dispatcher to look up state +via OFD). Both sides change together; ABI snapshot for the kernel-side +wasm export of `host_gl_*` import signatures changes. **This forces an +`ABI_VERSION` bump** if not handled carefully. + +**Reconsideration:** the cleanest path is to *not* change v1's +`host_gl_*` signatures at all. Instead, the kernel keeps a side-table +`pid → currently_open_dri_fd → ofd_id` and the host indexes by `(pid, +ctx_id)` as before — same as v1. The per-fd lift is purely internal to +the kernel; the host's binding key stays at `(pid, ctx_id)`. Single +process can't have two contexts with the same ctx_id anyway (ctx_ids +are unique per kernel context-create). + +Walk back the additional `ofd_id` arg. v1 host import signatures unchanged. +**No ABI bump. Per-fd state stays a kernel-internal refactor.** + +The trade-off: a process holding two `/dev/dri/renderD128` fds (which +plan 2 disallowed under single-owner, but is now technically allowed +post-lift) cannot disambiguate its bindings by fd at the host. We +mitigate by: kernel ensures `ctx_id` allocation is global per-`Process` +(not per-fd) — i.e., the kernel's `Process` keeps a `gl_next_ctx_id: +u32` counter, and every `GLIO_CREATE_CONTEXT` regardless of which fd +allocates from the same counter. This keeps `(pid, ctx_id)` unique on +the host even when contexts come from different fds. + +`gl_next_ctx_id` is therefore a **per-Process** field added in Task A2 +(complementing per-fd `DriFdState`). It is the *only* GL-related field +that stays on `Process`; everything else moved to OFD. + +**Step 1: Add `gl_next_ctx_id` to `Process`** + +```rust +// In Process: +pub gl_next_ctx_id: u32, // monotonic, never reused; survives ctx destroy. +``` + +Initialise to 1 at `Process::new()` and at fork (child gets its own +counter; if parent had ctxs 1..5, child starts at 1 — fork copies +neither bindings nor counter, since GL contexts don't survive fork in +WebGL semantics). + +**Step 2: Update `GLIO_CREATE_CONTEXT` dispatch** + +The CREATE_CONTEXT path (v1) does: + +```rust +let ctx_id = proc.gl_next_ctx_id; +proc.gl_next_ctx_id += 1; +ofd.dri.as_mut().unwrap().gl.bindings.insert(ctx_id, GlBinding::new(...)); +host_io.gl_create_context(pid, ctx_id, ...); +``` + +ctx_id is globally unique per-Process; binding lives on the OFD. + +**Step 3: Cargo + vitest** + +Test: two fds on the same process create contexts → ctx_ids 1, 2 (not +1, 1). Binding lookup by `(pid, ctx_id)` resolves to the right OFD. + +**Commit:** `host(dri): per-fd binding lookup via per-Process ctx_id counter` + +--- + +### Task B6: `WebGLTexture` cross-context map + `host_gl_bind_foreign_texture` + +**Files:** +- Modify: `host/src/webgl/registry.ts` — add a global `BoId → + WebGLTexture` map (one entry per GPU-tier bo). +- Modify: `host/src/kernel.ts` — register `host_gl_bind_foreign_texture`. + +```ts +// host/src/webgl/registry.ts +class ForeignTextureRegistry { + private boTextures = new Map<number /* BoId */, WebGLTexture>(); + + /** Called by the host's gbm_bo_create_gpu callback to allocate the texture. */ + allocate(boId: number, width: number, height: number, + format: number, gl: WebGL2RenderingContext): void { + const tex = gl.createTexture(); + if (!tex) throw new Error('createTexture failed'); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, + gl.RGBA, gl.UNSIGNED_BYTE, null); + // (texImage2D internalFormat / format / type derived from `format` + // — handled in a switch table; see Step 2.) + this.boTextures.set(boId, tex); + } + + /** Called from host_gl_bind_foreign_texture (kernel → host). */ + bind(boId: number, ctx_id: number, gl_target: number): number { + const tex = this.boTextures.get(boId); + if (!tex) return -1; + // The texture is shared across all bindings on the same + // WebGL2RenderingContext, so simply expose its id-as-handle. + // We assign a per-ctx synthetic id (so the kernel returns a uint + // the userland can pass back as a sampler binding via the v1 + // GL TLV cmdbuf). + const id = this.allocSyntheticId(ctx_id, tex); + return id; + } + + /** Called when the bo is destroyed (refcount → 0). */ + free(boId: number, gl: WebGL2RenderingContext): void { + const tex = this.boTextures.get(boId); + if (tex) { + gl.deleteTexture(tex); + this.boTextures.delete(boId); + } + } + + // Synthetic-id machinery omitted; pattern mirrors v1's program/buffer + // id allocator in `host/src/webgl/registry.ts`. +} +``` + +**Why this works:** all `WebGLTexture` objects created by *any* binding +on the same `WebGL2RenderingContext` are mutually addressable from any +other binding (the WebGL spec puts objects in the context's namespace, +not the user-program namespace). The muxer's `switchTo` doesn't need to +re-bind foreign textures — they're addressable identically from any +binding once the texture-unit binding is correct. + +**Vitest:** allocate a GPU bo, bind as foreign texture to two distinct +bindings, assert the same `WebGLTexture` object is reachable from both +(spy on `gl.bindTexture` calls — same underlying texture handle). + +**Commit:** `host(dri): foreign-texture cross-context bind via shared registry` + +--- + +### Task B7: GPU-tier bo backing in `GbmRegistry` + +**Files:** +- Modify: `host/src/dri/gbm-registry.ts` (created in plan 2 Task B1). + +```ts +// Extend HostBo with a tier discriminator: +export type HostBo = + | { tier: 'cpu_shared'; id: number; width: number; height: number; + stride: number; size: number; sab: SharedArrayBuffer; byteOffset: number; } + | { tier: 'gpu'; id: number; width: number; height: number; + format: number; texture: WebGLTexture; }; + +// Extend GbmRegistry: +export class GbmRegistry { + private bos = new Map<number, HostBo>(); + + createGpu(boId: number, width: number, height: number, format: number, + _usage: number, gl: WebGL2RenderingContext, + foreignTex: ForeignTextureRegistry): number { + foreignTex.allocate(boId, width, height, format, gl); + // texture is canonical home; we keep a weak reference here too. + const tex = foreignTex.peek(boId); + this.bos.set(boId, { tier: 'gpu', id: boId, width, height, format, + texture: tex! }); + return 0; + } + + // Existing `create` from plan 2 stays — handles the CpuShared tier. +} +``` + +**Vitest:** create a GPU bo, assert `GbmRegistry.bos.get(boId).tier === 'gpu'` +and `bos.get(boId).texture` is a valid `WebGLTexture`. + +**Commit:** `host(dri): GPU-tier bos in GbmRegistry — WebGLTexture-backed` + +--- + +### Task B8: End-to-end vitest — two-pid interleaved submits + +**Files:** +- Create: `host/test/dri-multiplex.spec.ts`. + +Drive a centralized-kernel fixture that: + +1. Forks two "process worker" fixtures with distinct pids (10 and 11). +2. Each pid opens `/dev/dri/renderD128`, creates a GL context, makes it + current, submits a tiny cmdbuf (clear to a distinct colour). +3. Asserts `GlMuxer.switchTo` was called twice (once per pid), in submit + order. +4. Asserts the final fb contents are pid-10's last clear, then pid-11's + last clear, in order. +5. Adds a "fake compositor" submit (pid=2) between pid-10's and pid-11's + submits. Asserts the compositor's submit drained *before* pid-11's + even though pid-11 enqueued first. + +**Commit:** `host(dri): vitest — two-pid multiplex + compositor priority` + +--- + +### Task B9: Phase B — full gauntlet + open PR #2 + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +Push, open draft PR. + +Title: `[explore-dri] host(dri): GlMuxer + shadow state + WebGLTexture cross-context map + GPU-tier bo backing` + +Body: Summary / Why / Verification / **Dual-host parity proof** (both +Node and browser kernel-worker entries wire the queue + muxer; symmetry +verified before commit) / Notes (compositor priority is hardcoded to +PID 2; KMS plan layers DRM_MASTER on top). + +--- + +## Phase C — demo + vitest + browser (PR #3) + +### Task C1: `programs/cube_pyramid.c` — two processes, two spinning cubes + +**Files:** +- Create: `programs/cube_pyramid.c`. + +```c +// Two processes, each draws a spinning cube into its own bo. +// Process A: red cube. Process B: blue cube. Compositor side intentionally +// absent in this demo (plan 3's compositor priority is exercised only in +// vitest). +// +// The demo's purpose is to verify the multiplexer correctly arbitrates +// two concurrent GL contexts under no compositor. + +int main(void) { + pid_t pid = fork(); + if (pid == 0) { + // child: blue cube, drawing N frames + draw_cube_loop(/*colour=*/0x000000ff, /*frames=*/300); + _exit(0); + } + // parent: red cube, drawing N frames concurrently + draw_cube_loop(/*colour=*/0xff000000, /*frames=*/300); + int st; waitpid(pid, &st, 0); + return WIFEXITED(st) && WEXITSTATUS(st) == 0 ? 0 : 1; +} + +static void draw_cube_loop(uint32_t colour, int frames) { + int fd = open("/dev/dri/renderD128", O_RDWR | O_CLOEXEC); + // … standard EGL/GLES2 init via v1's libEGL/libGLESv2 stubs. + // Each frame: clear, rotate, draw, present. +} +``` + +Build: `wasm32posix-cc -o programs/cube_pyramid.wasm programs/cube_pyramid.c +-lEGL -lGLESv2 -lgbm -ldrm`. Wire into `scripts/build-programs.sh`. + +**Commit:** `examples(dri): cube_pyramid — two-process multiplex demo` + +--- + +### Task C2: vitest — end-to-end multiplex spec + +**Files:** +- Create: `host/test/dri-cube-pyramid.spec.ts`. + +Runs `cube_pyramid.wasm` under the centralised kernel; asserts: +- Both child and parent return 0. +- The host muxer logged ≥ 2 distinct (pid, ctx_id) switches during the + run. +- The canvas's final pixel buffer (via `gl.readPixels`) contains both + colours. + +**Commit:** `host(dri): vitest — cube-pyramid end-to-end multiplex` + +--- + +### Task C3: Manual browser verification (the gate) + +CLAUDE.md item 6. Build the demo, drop into `examples/browser/pages/cubepyramid/` +(HTML page that runs `cube_pyramid.wasm` and visibly shows two spinning +cubes side-by-side). `./run.sh browser`, navigate, confirm: two cubes, +both spinning, no console errors. + +**No commit yet for this task — verification only.** If the demo fails +in browser but passes in Node, that's a host-parity bug (CLAUDE.md +"dual-host parity" — see PR #410 cautionary tale). Fix and re-run +before opening PR #3. + +--- + +### Task C4: Phase C — final gauntlet + open PR #3 + +PR title: `[explore-dri] examples(dri): two-cube multiplex demo + browser spec` + +Body: Summary / Why / Verification (gauntlet + browser screenshot) / +Dual-host parity proof / Notes. + +--- + +## Final coordinated merge + +When all three PRs (kernel, host, examples) are reviewed and approved, +and Brandon has signed off on the demo running cleanly in browser + Node: + +1. Re-run the full gauntlet on each PR's branch tip. +2. Squash-merge PR #1 → PR #2's base. +3. Squash-merge PR #2 → PR #3's base. +4. Squash-merge PR #3 → plan 2's `…-buffer-demo` (or wherever plan 2's + tip lives at the time). +5. Tag: `[explore-dri-mux] milestone (multiplexer) merged at <sha>` in + the next session-handoff doc. + +**Do not push to upstream until v1 + plan 2 + plan 3 are all merged +upstream as a coherent chain.** + +--- + +## Trade-offs already locked in (don't relitigate during implementation) + +- **One host context for everyone** (design §5.1 option C). If a future + browser splits WebGL contexts hard enough that cross-binding texture + sharing fails, the muxer rebases (e.g. one canvas per CRTC, compositor + only) but the kernel-side ioctl surface stays. +- **`COMPOSITOR_PRI` hardcoded to PID 2** in plan 3's host muxer. Plan 4 + (KMS) layers `DRM_MASTER` on `/dev/dri/card0` over the top — at that + point PID-2-as-compositor becomes "PID 2 happens to also hold + DRM_MASTER", and the priority promotion lookup can shift to the + `DRM_MASTER` flag without changing the muxer's public shape. +- **No mid-cmdbuf preemption** (design §5.5). A pathological client with + a 1 MiB cmdbuf (~50 ms of WebGL work) blocks the compositor for one + cmdbuf. Acceptable v2 behaviour; v3 cuts large submits into chunks if + profiling shows it. +- **Per-Process `gl_next_ctx_id` counter** (not per-fd). Rationale: + keeps the host's `(pid, ctx_id)` key unambiguous even when contexts + come from different fds on the same process. The kernel pays the cost + of one extra `Process` field; the host stays at v1's signature shape. +- **GPU-tier bos are unmappable on CPU.** `mmap(/dev/dri/renderD128, + gpu_bo_offset)` returns `EINVAL`. The texture data lives only in + WebGL space; no CPU read-back path in plan 3. Future plan (compositor + + readback for screencapture) adds `glReadPixels` over a separate + ioctl. +- **`switchTo` re-applies state unconditionally on each switch** (no + per-field diff). Simpler; the ~40-call cost is small at frame + boundaries. A diff-based optimisation can be added later if profiling + shows it. + +--- + +## Risk register + +1. **Cross-binding `WebGLTexture` reachability** depends on all bindings + sharing the same `WebGL2RenderingContext`. v1's architecture already + does this (single OffscreenCanvas in the kernel-worker). If a future + v2 fix puts the canvas back on the main thread or splits contexts, + the foreign-texture surface breaks. Mitigation: vitest in Task B6 + asserts cross-binding reachability on every CI run. +2. **Compositor starvation of clients.** A misbehaving compositor that + continuously enqueues frames will block all clients. Plan 3 accepts + this as the cost of the simple priority rule; a fairness floor + (e.g. compositor yields after N consecutive frames) is a v3 addition. +3. **Shadow-state completeness.** Every state-mutating WebGL call must + update the shadow. If v1's TLV op table omits one (or a future op is + added without a shadow update), `switchTo` silently corrupts state. + Mitigation: Task B1 vitest covers each opcode; an integration test + in Task B8 cross-binding clears with each verifies no corruption. +4. **`gl_next_ctx_id` overflow.** u32 ceiling is 2³² contexts per + process — interactive workloads create ≪10 contexts in their + lifetime. Document the wrap-at-overflow behaviour (returns + `ENOMEM` — Linux's GL drivers use `ENOMEM` for context-table + exhaustion; `EMFILE` is reserved for fd-table exhaustion against + rlimit, semantically wrong here) in `GLIO_CREATE_CONTEXT`. +5. **fork+exec timing.** Per-fd state survives fork via OFD dup; exec + closes CLOEXEC fds (which drops the OFD ref). Test that fork + exec + without CLOEXEC retains the GL context (intended) and with CLOEXEC + tears it down (also intended). +6. **`mmap_shared` from plan 2 is GPU-tier-irrelevant** (GPU bos aren't + CPU-mappable). If plan 2's risk #1 forces the SAB-pool redesign, + plan 3's GPU tier is unaffected — but the CPU-shared codepaths that + plan 3 still uses (e.g. for the cube's vertex buffer staging) inherit + plan 2's outcome. + +--- + +## What this plan doesn't cover (deferred) + +- **KMS / `/dev/dri/card0`** — page-flip, vblank, `DRM_MASTER`. Plan 4. +- **`GLIO_PRESENT_KMS`** ioctl — couples to card0; plan 4. +- **`DRM_MASTER`-driven `COMPOSITOR_PRI` lookup** — replaces the + PID-2 hardcode at plan 4 time. +- **GPU-bo CPU readback** (`glReadPixels`-over-ioctl, for screencapture) + — out of scope for v2. +- **Multi-canvas / multi-CRTC** — one canvas per CRTC is design's + fallback if cross-context texture sharing fails; not implemented in v2. +- **Compositor itself** — the userland compositor (`wpkcompositor`, + PID 2) gets its own plan (§9 of the design, plans 8-9 of the rollout). + Plan 3 only exercises the priority lane via a vitest "fake + compositor" pid. +- **`evdev`, `ALSA`** — independent device surfaces; plans 5 and 6. +- **SDL2 port** — milestone (D); needs plans 3 + 4 + 5 + 6 first. + +--- + +End of plan. diff --git a/docs/plans/2026-06-08-dri-kms-plan.md b/docs/plans/2026-06-08-dri-kms-plan.md new file mode 100644 index 0000000000..c8f56cf85a --- /dev/null +++ b/docs/plans/2026-06-08-dri-kms-plan.md @@ -0,0 +1,2348 @@ +# DRI v2 — KMS plan (`/dev/dri/card0`) + +> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to execute this plan task-by-task. + +**Goal:** Add the second DRI device node, `/dev/dri/card0`, exposing +Linux KMS (Kernel Modesetting) ioctls. Unmodified libdrm + SDL2's +`KMSDRM` backend should be able to: open card0, take `DRM_MASTER`, +`MODE_GETRESOURCES` to discover the single CRTC + single connector + +single mode, `MODE_ADDFB2` to wrap a bo (allocated via +`/dev/dri/renderD128` in plan 2 + 3) as a framebuffer id, `MODE_SETCRTC` +to attach the fb to the CRTC, then `MODE_PAGE_FLIP` repeatedly with +`read(card0)` returning `drm_event_vblank` records. **The +priority-promotion hand-off from plan 3** — `COMPOSITOR_PRI` in the +host `SubmitQueue` — flips from "hardcoded `pid == 2`" to "the process +that currently holds `DRM_MASTER` on `/dev/dri/card0`". Plan 3's +SubmitQueue API surface stays unchanged; only the priority-assignment +lookup is rewired. + +**Architecture:** Single CRTC (`crtc_id = 1`), single connector +(`connector_id = 1`, type `DRM_MODE_CONNECTOR_VIRTUAL` — the closest +match for the OffscreenCanvas-as-display abstraction), single mode +(matches the OffscreenCanvas size; configurable per-host via +`attachKmsCanvas(connector_id, canvas, mode)`, default 1280×720 at +60 Hz). `MODE_ADDFB2` allocates a fresh `fb_id` and records +`{bo_id, width, height, format, stride}`; `MODE_SETCRTC` records the +binding `{crtc_id → fb_id}` for the kernel and notifies the host via +a new `host_kms_set_fb(crtc_id, fb_id, bo_id, mode)` import. +`MODE_PAGE_FLIP` records a pending flip and arms the vblank tick; on +the next vblank the host calls back into the kernel (`kernel_vblank` +export, additive to the existing kernel→host import set) and the +kernel produces a `drm_event_vblank` record on the master's per-OFD +event ring. `read(card0)` drains the ring (semantically `read` returns +one or more whole event records; partial reads are not supported by +DRM). Vblank cadence is host-driven: `requestAnimationFrame` on +browsers (in the kernel-worker if `OffscreenCanvas`-rAF is supported, +else routed from main thread via `postMessage`), `setInterval(16.67)` +on Node. The host owns the timer; the kernel only knows "vblank tick +happened, fire any pending flip". Companion design doc: +`docs/plans/2026-05-18-dri-design.md` §6 (`/dev/dri/card0`) + §16 q3 +(vblank cadence). + +**Tech Stack:** Rust kernel (wasm64), TypeScript host (browser + Node), +C user programs cross-compiled with `wasm32posix-cc`. Extends plan 2's +`libdrm.a` stub with the KMS subset (`drmModeGetResources`, +`drmModeGetConnector`, `drmModeGetCrtc`, `drmModeAddFB2`, +`drmModeRmFB`, `drmModeSetCrtc`, `drmModePageFlip`, `drmModeWaitVBlank`, +`drmHandleEvent` for the event-record dispatcher). No new userland +library stubs (libgbm covers bo allocation; SDL2 talks to libdrm +directly for KMS). + +**Design reference:** `docs/plans/2026-05-18-dri-design.md` §6 (KMS, +single CRTC/connector/mode, page-flip → vblank) + §16 q3 (vblank +cadence resolution). The DRM_MASTER-as-COMPOSITOR_PRI swap is design +§5.4 + §6.3 — the rule is "the process holding `DRM_MASTER` on card0 +gets head-of-queue scheduling on the multiplexer." POSIX vs Linux +UAPI: `open`/`close`/`mmap`/`ioctl`/`read`/`poll` are POSIX; KMS +ioctl numbers + struct shapes + `DRM_MASTER` semantics + `drm_event_*` +record layout are Linux UAPI, followed strictly. + +**Consistency with plans 2 + 3:** +- Plan 2 introduced `VirtualDevice::DriRender0` + `OpenFileKind::DriRender` + for `/dev/dri/renderD128`. Plan 4 adds `VirtualDevice::DriCard0` + + `OpenFileKind::DriCard` for `/dev/dri/card0` — a separate device + node with its own ioctl dispatcher. Render-node-only ioctls + (`PRIME_HANDLE_TO_FD`, `MODE_CREATE_DUMB`) are also valid on card0 + (Linux allows this); we forward those to the existing + `handle_dri_render_ioctl()` so callers that hold only a card0 fd + can still allocate bos. The reverse (KMS ioctls on a render-node fd) + returns `EOPNOTSUPP` (Linux returns `ENOTSUPP`/`EOPNOTSUPP` + depending on driver; we pick `EOPNOTSUPP`, which has a POSIX + errno number). +- Plan 3's `COMPOSITOR_PRI` is currently `binding.pid === 2` in + `host/src/webgl/submit-queue.ts`. Plan 4 Task B4 replaces this + with `kmsRegistry.isMasterPid(binding.pid)`, where the host + mirrors the kernel's `DRM_MASTER`-holder state via a host-import + callback fired on every `DRM_IOCTL_SET_MASTER` / `DROP_MASTER` / + close-while-holding-master. Plan 3's narrative ("when KMS plan + arrives, swap the lookup; SubmitQueue API stays the same") matches + the change; verified. +- Plan 2's `BoRegistry` bos (CpuShared) and plan 3's GPU-tier bos are + both legal targets for `MODE_ADDFB2`. The fb's `pitches[0]` must + equal the bo's `stride` (for CpuShared) or 0 (for GPU-tier — host + defines the texture's row pitch and we don't expose it to the + caller). The fb's `pixel_format` must equal the bo's `format`. The + fb id namespace is per-OFD on the card0 fd; fork inherits via OFD + dup. Reasoning matches plan 3 A2's per-OFD lift. +- Plan 3 carried over a load-bearing **open** architecture question + (per-OFD vs per-Process for `gl_state`). Plan 4 makes no assumption + about its resolution; the KMS code touches no GL state and is + orthogonal. If plan 3's review lands on (a) "keep `gl_state` on + Process", plan 4's kms_fb_id namespace stays per-OFD (matches + Linux). If on (b) lift, same outcome — plan 4 is unaffected. + +**Stack base:** Plan 3's `…-mux-demo` branch tip (plan 3 PR #3's +head). Plan 4 extends plan 3's kernel `dri::*` module + host +`webgl/submit-queue.ts` + plan 2's `libdrm.a` stub without breaking +either's tests. + +**Branch:** `emdash/explore-direct-rendering-infrastructure-kms-plan-XXXXX` +(chains off the previous DRI branch per the user's branching rule). +Three sub-branches stack off it for the three PRs. + +**Final PR base:** Plan 3's `…-mux-demo` branch tip. **Do not merge** +until Brandon validates the design, plan 3 lands, and Phase C's +manual browser verification passes. CLAUDE.md "no merge before +Brandon's validation" rule. + +**Three PRs, coordinated merge.** Each task below is one commit. PR +titles follow Brandon's `scope(area): action` shape: + +1. `kernel(dri): /dev/dri/card0 + KMS ioctls + DRM_MASTER + vblank ring` +2. `host(dri): KmsRegistry + vblank tick (RAF/setInterval) + master-driven SubmitQueue priority` +3. `examples(dri): modeset demo + browser spec` + +PR base/head topology (stacked per the user's branching rule): + +``` +explore-webgl-exposition-demo (v1 tip) + └── …-buffer-plan-XXXXX (plan 2 PR base) + └── …-buffer-kernel (plan 2 PR #1) + └── …-buffer-host (plan 2 PR #2) + └── …-buffer-demo (plan 2 PR #3) + └── …-multiplexer-plan-YYYYY (plan 3 PR base) + └── …-mux-kernel (plan 3 PR #1) + └── …-mux-host (plan 3 PR #2) + └── …-mux-demo (plan 3 PR #3) + └── …-kms-plan-ZZZZZ (this plan PR base) + └── …-kms-kernel (PR #1) + └── …-kms-host (PR #2) + └── …-kms-demo (PR #3) +``` + +**Verification gauntlet** (CLAUDE.md): all of the below must pass with +zero regressions before any PR is opened, and re-run before final +merge: + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +`XFAIL` / `TIME` are acceptable; `FAIL` that isn't pre-existing is a +regression. Phase C adds manual `./run.sh browser` verification of the +modeset demo (CLAUDE.md item 6) — the page-flip → vblank loop is +visible as a 60-FPS counter incrementing on-canvas. + +**ABI impact:** **Additive only — no `ABI_VERSION` bump.** Per +`docs/abi-versioning.md` (PR #490 policy): +- New `repr(C)` structs in `shared::dri` for the KMS subset: + `WpkDrmModeCardRes` (64 bytes), `WpkDrmModeGetConnector` (88 bytes), + `WpkDrmModeGetEncoder` (20 bytes), `WpkDrmModeGetCrtc` (96 bytes — + includes `drm_mode_modeinfo`), `WpkDrmModeModeinfo` (68 bytes), + `WpkDrmModeFbCmd2` (96 bytes), `WpkDrmModeCrtcPageFlip` (24 bytes), + `WpkDrmEventVblank` (32 bytes — matches upstream `drm_event_vblank` + layout: header + user_data + tv_sec + tv_usec + sequence + crtc_id), + `WpkDrmWaitVblankRequest` / + `_Reply` (12 + 12 bytes). All sizes are wasm32-ilp32-validated by + the same `_IOWR`-derivation tests plan 2 Task A1 introduced + (verified: `ioc(iowr, 'd', nr, size_of::<T>())` matches the Linux + number for each). +- New ioctl numbers — all are existing **Linux** DRM ioctl numbers + (we follow the UAPI verbatim; we do **not** prefix with `WPK_`): + - `DRM_IOCTL_SET_MASTER = _IO('d', 0x1e) = 0x0000_641e` + - `DRM_IOCTL_DROP_MASTER = _IO('d', 0x1f) = 0x0000_641f` + - `DRM_IOCTL_MODE_GETRESOURCES = _IOWR('d', 0xa0, …) = 0xc040_64a0` + - `DRM_IOCTL_MODE_GETCRTC = _IOWR('d', 0xa1, …) = 0xc060_64a1` + - `DRM_IOCTL_MODE_SETCRTC = _IOWR('d', 0xa2, …) = 0xc060_64a2` + - `DRM_IOCTL_MODE_GETENCODER = _IOWR('d', 0xa6, …) = 0xc014_64a6` + - `DRM_IOCTL_MODE_GETCONNECTOR = _IOWR('d', 0xa7, …) = 0xc058_64a7` + - `DRM_IOCTL_MODE_PAGE_FLIP = _IOWR('d', 0xb0, …) = 0xc018_64b0` + - `DRM_IOCTL_MODE_RMFB = _IOWR('d', 0xaf, u32) = 0xc004_64af` + - `DRM_IOCTL_MODE_ADDFB2 = _IOWR('d', 0xb8, …) = 0xc060_64b8` + - `DRM_IOCTL_WAIT_VBLANK = _IOWR('d', 0x3a, …) = 0xc018_643a` + - Sizes encoded in each constant are verified vs the wasm32 struct + `size_of` in Task A1 Step 2 (`ioctl_numbers_match_linux_uapi`). +- New kernel-wasm export: `kernel_vblank()` — host fires this on every + vblank tick; kernel checks each card0 OFD for pending flips and + produces event records. *(Note: this is a kernel **export**, not + an import. Additive exports do not change existing signatures — + permitted under the ABI policy.)* +- New `host_kms_*` imports: `host_kms_set_fb` (forwarded SET_CRTC), + `host_kms_addfb` (host learns about a new fb_id → bo binding so + it can present it on vblank), `host_kms_rmfb`, `host_kms_set_master` + (notify host that master changed — drives SubmitQueue priority), + `host_kms_drop_master`. +- No change to v1 `host_gl_*` imports, plan 2 `host_gbm_*` imports, + plan 3 `host_gl_bind_foreign_texture`, any existing struct layout, + any existing ioctl number, channel layout, syscall numbers, or + asyncify slots. + +Existing structs, ioctls, imports, and exports — all unchanged. + +--- + +## Pre-implementation review + +Devil's-advocate pass run in the next session after drafting; findings +below are structured Brandon-style. The five inline fixes were folded +back into the plan body in the same session; the open correctness + +open architecture items are load-bearing and must be picked before any +kernel code lands. + +### Inline fixes (5 — folded into the plan body) + +- **`WpkDrmEventVblank` was 24 bytes (header + user_data + tv_sec + + tv_usec); upstream `drm_event_vblank` is 32 bytes (adds `sequence` + + `crtc_id` to the tail).** The 24-byte size was the wrong call: + SDL2's `KMSDRM` backend uses libdrm `drmHandleEvent` with + `version = 3` and a `page_flip_handler2` callback whose signature is + `(fd, sequence, tv_sec, tv_usec, crtc_id, user_data)` — it *reads* + both `sequence` and `crtc_id` off the record. Truncating would feed + the v3 handler garbage for `crtc_id` and shift `user_data` by 8 + bytes. Plus the demo's frame-pacing assertion at C2 wants the real + `sequence` count, not zero-stubs. *Bumped to 32 bytes: added + `sequence: u32` + `crtc_id: u32` at the tail; struct-size assertion + in A1 Step 2 lifted to 32; `kernel_vblank` producer in A7 writes + both fields; libdrm parser in C1 reads 32 bytes per record; the + page-flip-event vitest in B5 asserts 32 bytes, not 24.* +- **`MODE_ADDFB2` (A5) didn't increment the bo's refcount.** The fb + binding keeps the bo alive (per the plan's own trade-off: "MODE_- + DESTROYDUMB on a fb-bound bo … the fb keeps the bo alive via the + refcount until RMFB"), but the code as drafted never called + `BoRegistry::incref(bo_id)` on `MODE_ADDFB2` and never called + `decref` on `MODE_RMFB` / `on_final_close`. Result: a caller that + ADDFB2'd a bo and then closed every prime-fd holding it would see + the bo destroyed mid-scanout. *Fixed: A5 calls `incref` after the + fb is recorded; A6 (RMFB) calls `decref(bo_id)` on the bo retired + from `KmsFdState.fbs`; A10's `on_final_close` decrements once per + remaining `KmsFb` before draining the table.* +- **`MODE_ADDFB2` (A5) ignored the `host_io.kms_addfb` return code.** + Plan 2 + plan 3 convention: host returns `i32`, kernel checks + `< 0` and unwinds. A5 as drafted swallowed the error. *Fixed: + `if rc < 0` arm decrements the bo refcount, removes the fb from + `KmsFdState.fbs`, and returns `ENOMEM`. Mirrors plan 2 A6's host- + side-alloc-failure unwind exactly.* +- **`DRM_IOCTL_DROP_MASTER` (A3) didn't drain `pending_flips`.** If + the master called PAGE_FLIP and then DROP_MASTER before vblank + fired, the flip lingered. Then if the same OFD took master again, + the stale flip fired on the next vblank — surprising. (Also: if a + different OFD took master in between, the stale-flip-on-re-master + would surface on a CRTC the new master never asked to flip.) + *Fixed: A3 DROP_MASTER drains `pending_flips` (no events emitted — + Linux has no "flip-aborted" event). A10's `on_final_close` does + the same.* +- **`kms_drop_master(pid)` host arg is the closer's pid, not the + original master's**, when `on_final_close` fires on a fork-inherited + card0 OFD. The host's `KmsRegistry.dropMaster()` is global (no pid + param), but the `HostIO::kms_drop_master(pid)` signature carries + one for symmetry with `kms_set_master`. Risk: any host code that + uses `pid` for re-bucketing (SubmitQueue priority cleanup, log + attribution) sees the wrong pid. *Fixed: A10 documents that the + pid passed on close is for logging only; host's `KmsRegistry` must + always treat master-drop as global. Also added an assertion-style + cargo test (under Missing tests below).* + +### Correctness — open, address before kernel PR opens + +- **`MODE_ADDFB2` handle namespace on the card0 OFD is undefined.** + A5 calls `resolve_handle_on_card0_ofd(pid, fd, req.handles[0])` — + but `KmsFdState` as drafted holds only `fbs / next_fb_id / + holds_master / pending_flips / event_ring`. There is no handle + table. A card0-side handle namespace is mandatory: Linux scopes + ADDFB2 handles to the card0 fd, populated via PRIME_FD_TO_HANDLE + on card0. Two clean resolutions, pick before any kernel code lands: + (a) **Add `handles: BTreeMap<u32, BoId>` + `next_handle: u32` to + `KmsFdState`.** Symmetric with plan 3 A2's `DriFdState`; + duplicates the handle-namespace machinery on two OFD-side state + blobs. + (b) **Card0 OFDs also carry a `dri: Option<Box<DriFdState>>`** + (the same `DriFdState` plan 3 lifted to the renderD128 OFD). + A card0 OFD is then `dri = Some(...), kms = Some(...)`; a + renderD128 OFD is `dri = Some(...), kms = None`. Pulls the + handle-namespace handler in the kernel to a single + implementation. + *Lean: (b) plus folding both fields into the + `Option<Box<DriOfdState>>` enum the handoff-4 follow-up flagged* + — `enum DriOfdState { RenderNode(DriFdState), Card { dri: + DriFdState, kms: KmsFdState } }`. This means the OFD-consolidation + refactor lands **inside plan 4**, not as a deferred follow-up. +- **`kernel_vblank()` lock-order claim (A7) is wrong.** A7 says + "lock order: ofd table only; no PROCESS_TABLE", but per plan 3 A3 + the OFD table lives inside `PROCESS_TABLE` (`pt.ofds.entries`). + `with_ofds_mut` doesn't exist as drafted. Either + (a) `kernel_vblank` takes `PROCESS_TABLE.lock()` briefly and iterates + `pt.ofds.entries.iter_mut()`. The iteration is O(open card0 + OFDs) — typically 1-3 — so the lock window is microseconds, + acceptable for the host calling kernel_vblank at 60 Hz. + (b) Refactor: split OFDs into their own table (`OFD_TABLE`) with + a distinct lock, so vblank-tick doesn't bottleneck syscalls. + Larger surface; cross-cutting against every plan that touched + `pt.ofds.entries`. + *Lean: (a) for v1.* Document the lock in A7. Re-evaluate if + profiling under Phase C shows syscall jitter aligned with vblank + ticks. +- **`MODE_GETCRTC` (A4) needs a current-`fb_id` source, but nobody + tracks it.** SETCRTC currently calls `host_io.kms_set_fb` and + returns; the kernel doesn't remember the binding. GETCRTC has + nowhere to read it from. Two resolutions: + (a) **Kernel-global `crtc_state: BTreeMap<u32, u32>`** (crtc_id → + fb_id), updated by SETCRTC, read by GETCRTC. CRTCs are + kernel-wide (not per-OFD); single CRTC means a one-entry map + or even a `static AtomicU32`. + (b) **Add `host_kms_get_fb(crtc_id) -> u32` import.** Host's + `KmsRegistry.crtcBindings` is the source of truth. Avoids + duplicating kernel-side state but adds a host round-trip on + every GETCRTC. + *Lean: (a)* — one `AtomicU32` per CRTC, read+write are cheap, and + GETCRTC is rare enough that the round-trip-cost argument doesn't + apply (drm-userspace probes GETCRTC at startup, then SETCRTCs). + +### Architecture — open (load-bearing, pick before any kernel code lands) + +- **COMPOSITOR_PRI swap is NOT a one-line change in `SubmitQueue`.** + Plan 3 B3 references a module-level `const COMPOSITOR_PID = 2` in + *two* callsites (`enqueue` and `releaseIfEmpty`) and has no + constructor. Plan 4 B4 adds a constructor argument + `(isCompositor: (pid: number) => boolean)` and rewires both + callsites — that's a **constructor signature change** plus two + call-site swaps. The handoff-4 framing ("plan 3's SubmitQueue API + surface stays unchanged — only the construction callsite changes") + is only correct if **plan 3 lands with the constructor-callback + shape from the start**. The clean path: + - Plan 3 B3 lands SubmitQueue as + `constructor(private isCompositor: (pid: number) => boolean + = (pid) => pid === COMPOSITOR_PID)` — default preserves plan 3's + PID-2 test behaviour; plan 4 B4 then is genuinely a one-line + construction-site change in `kernel-worker.ts`. + - Plan 4 B4 stops claiming "SubmitQueue API surface stays + unchanged" since the **default-argument** shape *is* an API + change (back-compatible, but visible) and Brandon-style honesty + matters. + *This requires a follow-up note on plan 3's review section.* + Added below as a "Cross-plan amendment to plan 3" subsection of + plan 3's Pre-impl review. +- **`vblank_wait::wake_all()` over-wakes `read(card0)` blockers.** + A7 calls `wake_all()` after producing events for masters with + pending flips. A8's `read(card0)` blocks when `event_ring` is + empty. `wake_all()` wakes every blocked card0 reader on every + vblank tick — including readers whose ring is still empty (a + non-master, or a master with no pending flip on this tick). They + re-block immediately. Meanwhile A9's `WAIT_VBLANK` semantically + *wants* to wake every caller on every tick. Two queues, not one: + - `read_wait_queue`: woken only when a record is pushed to the + OFD's ring (per-OFD wake, not broadcast). + - `wait_vblank_queue`: woken on every `kernel_vblank()` tick + (broadcast, semantically a "next-tick" barrier). + *Lean: split into two functions* — + `vblank_wait::wake_event_reader(ofd_idx)` (called from A7 only + for OFDs that got new records) + `vblank_wait::tick_wake_all()` + (called once at the end of `kernel_vblank` for `WAIT_VBLANK` + callers). Document the two wait queues at A7 + A8 + A9. +- **Host-side bo tier dispatch for vblank presenter is implicit.** + Plan 4 B2's `presentFbToCanvas(fb, …)` is sketched as "WebGL2 + blit or 2D putImageData" — but the host has to *know* whether the + fb's bo is CPU-shared (use `putImageData` from the SAB) or GPU-tier + (`blitFramebuffer` from the WebGLTexture). The `HostFb` struct in + B1 carries `bo_id` only; tier lookup is via `GbmRegistry`. Plan 2's + `GbmRegistry` sketch (line 1547) shows `sab: SharedArrayBuffer` — + CPU-shared only. Plan 3 A3's `gbm_bo_create_gpu` import allocates + WebGLTextures on the host but the registry's storage shape for + GPU bos isn't pinned down in plan 3. *Resolution to confirm at + plan-4 land time:* `GbmRegistry` carries + `entry: { tier: 'cpu', sab: SAB } | { tier: 'gpu', tex: + WebGLTexture }` and exposes `getTier(bo_id)` + tier-specific + accessors. `KmsRegistry.presentFbToCanvas` dispatches on + `gbm.getTier(fb.bo_id)`. If plan 2's `GbmRegistry` doesn't carry + this, plan 4 B1 amends it (additive — no ABI change since this is + pure host state). +- **`attachKmsCanvas(connector_id, canvas, mode)` host API is + named in the Architecture section but never defined anywhere in + plan 4 or earlier plans.** It owns the OffscreenCanvas → + `connector_id` binding and feeds `host_io.kms_mode_info`. Without + it, B1 / B2 don't know which canvas to present to. *Resolution:* + spell out the API + wire-up in Task B3 (sketch: + `kernel.attachKmsCanvas({ connector_id: 1, canvas, mode: { hdisp: + 1280, vdisp: 720, vrefresh: 60 } })`; KmsRegistry stores the + binding; `kms_mode_info` returns the stored mode; vblank-tick + paints into this canvas). Document the call shape at B3. + +### Missing tests — add to implementation PRs + +- **GPU-tier bo through PAGE_FLIP, end-to-end.** Allocate via + `WPK_CREATE_GPU_BO`, ADDFB2 with `pitches[0] = 0`, SETCRTC, + PAGE_FLIP; assert the host's vblank-tick presenter dispatched the + GPU path (blit from WebGLTexture, not putImageData from SAB). + Vitest spec in Phase B, manual confirm in Phase C. +- **Forked-child takes master after parent drops.** Process A opens + card0 + SET_MASTER, forks → child shares the OFD. A: DROP_MASTER. + Child: SET_MASTER on the same fd. Assert child holds master + globally (master holder = child's pid; SubmitQueue priority + flips). +- **`SETCRTC(fb_id)` with an RMFB'd fb_id returns ENOENT.** The + validation in A5 reads `KmsFdState.fbs`; RMFB removes the entry; + test that a SETCRTC after RMFB rejects cleanly. +- **`MODE_PAGE_FLIP` EBUSY → vblank → next PAGE_FLIP succeeds.** + Covers the (CRTC, OFD) throttle lifecycle: queue, second-queue + rejected EBUSY, drive `kernel_vblank()`, queue accepted. +- **Host receives `kms_drop_master` exactly once on + fork-inherited-master final-close.** Process A opens card0, + SET_MASTER, forks; child inherits OFD; A close(fd) → OFD ref = 1, + no kms_drop_master fires; child close(fd) → OFD destroyed → exactly + one kms_drop_master fires (with the closer's pid, which is the + child's). Guards against the host using the pid arg for state. +- **Double `SET_MASTER` from the same OFD is idempotent.** A3's + fast-path returns `Ok` on already-held; lock the semantic. + +### Trade-offs verified against the design doc + handoff requirements + +- **`MODE_ADDFB2` accepts both `BoTier::CpuShared` and + `BoTier::Gpu`.** A5's tier check is + `matches!(bo.tier, BoTier::CpuShared) && req.pitches[0] != bo.stride + → EINVAL`; GPU-tier with `stride == 0` (per plan 3 A3) bypasses + the pitch check. Matches design §6.1 "compositor scans out from + GPU bo" and handoff-4's explicit ask. ✓ +- **Vblank cadence host-driven** (design §16 q3). RAF in browser + (kernel-worker if supported, else main-thread routed via + postMessage), `setInterval(16.67)` in Node. Risk register #1 + flags cross-browser RAF-in-worker feasibility as a Phase B spike. + ✓ +- **Single CRTC / single connector / single mode** (design §6.1). + Connector type `DRM_MODE_CONNECTOR_VIRTUAL = 15` matches upstream. + ✓ +- **`DRM_MASTER`-as-`COMPOSITOR_PRI`** (design §5.4 + §6.3). Master + holder's pid drives SubmitQueue priority lane bucketing. + Subject to architecture-open #1 above. ✓ (mechanism), ⚠ + (constructor-signature consequence flagged). +- **One in-flight PAGE_FLIP per (CRTC, OFD)** matches Linux + EBUSY-on-double-flip. ✓ +- **Cross-fd handle import via `PRIME_FD_TO_HANDLE`** is the only + path from renderD128 bo → card0 fb. Linux Mesa drivers do this + too. Subject to correctness-open #1 above (the handle namespace + field must actually exist on the card0 OFD). ✓ (semantics), + ⚠ (field missing on `KmsFdState`). +- **Additive ABI only — no `ABI_VERSION` bump** (PR #490 policy). + ✓ All new structs / ioctl numbers / `host_kms_*` imports / the + `kernel_vblank` export; no existing surface touched. Verified + against `docs/abi-versioning.md`. +- **Master-revoke-on-final-close** is race-free (single Mutex CAS + in `dri::master`). ✓ +- **execve revokes master via CLOEXEC**: the card0 fd is dropped on + execve, refcount → 0 (if last ref), `on_final_close` runs, + master released. ✓ +- **`ASYNCIFY_SAVE_SLOTS` not touched** — A8/A9 reuse the existing + asyncify suspend/resume primitive (same shape pipes/ttys use for + blocking reads). The "save slot count" is a fixed scratch area, + not "one per blocked thread"; reuse is ABI-safe. ✓ + +### Deliberately not flagged + +- Linux UAPI ioctl numbers (`'d'` magic, nrs `0x1e/0x1f/0x3a/ + 0xa0..0xa7/0xaf..0xb0/0xb8`) — verified vs `include/uapi/drm/drm.h` + at v6.10 mainline; no collision with WPK extensions at `0xE0+` + (plan 3 A1) or with plan 2's `0x00/0x09/0x0c/0x2d/0x2e/0xb2..0xb4`. + ✓ +- `DRM_MODE_CONNECTOR_VIRTUAL = 15` matches `drm_mode.h`. ✓ +- `DRM_EVENT_VBLANK = 1` / `DRM_EVENT_FLIP_COMPLETE = 2` match + upstream `drm.h`. ✓ +- Render-node-only ioctls (`PRIME_*`, `MODE_*DUMB*`, `GEM_CLOSE`, + `VERSION`, `GET_CAP`, plus plan 3's `WPK_CREATE_GPU_BO` + + `WPK_BIND_FOREIGN_TEXTURE`) pass through from card0 to plan 2's + `handle_dri_ioctl()`. Linux allows the inverse on real drivers + too. ✓ +- `kernel_vblank()` is a kernel **export**, not an import; additive + exports are allowed without an `ABI_VERSION` bump per + `docs/abi-versioning.md`. ✓ +- POSIX-first: `open` / `close` / `read` / `poll` / `ioctl` + semantics on card0 follow POSIX; only the ioctl numbers + struct + shapes + `drm_event_vblank` record bytes are Linux UAPI. ✓ +- Stacked-PR topology (kms-kernel → kms-host → kms-demo) matches + the user's branch-chaining rule and plans 2 + 3's stack shape. ✓ + +### Cross-plan amendment from plan 5's devil's-advocate + +Plan 5's devil's-advocate pass flagged a parallel concern in this +plan: **plan 4's `event_ring: VecDeque<u8>` is unbounded**. If a +userspace process opens `/dev/dri/card0`, takes master, queues +page-flips, and never `read`s, every vblank with a pending flip +appends a 32-byte `WpkDrmEventVblank` record forever. At 60 Hz with +a misbehaved client, the ring grows ~1.9 KB/s — not catastrophic +over minutes, but the unbounded shape is a correctness gap that +plan 5 noticed because its own ring needs a bound for the same +reason (held-down key autorepeat would explode within seconds). + +The shape of the fix is different from plan 5's, though. plan 5 +borrows Linux's evdev `SYN_DROPPED` convention; Linux's DRM UAPI has +no equivalent (no `DRM_EVENT_DROPPED` record type — Linux's drm core +just silently drops events when its per-fd event queue is full, +relying on the userspace driver to not lag this badly). *Resolution +for plan 4:* bound `KmsFdState.event_ring` at 64 records (2 KiB; ~1 s +of unread 60 Hz vblanks); on overflow, drop the *oldest* record +silently. No new event type, no userspace-visible signal — matches +what real Linux DRM does. Folded into A7's producer block at plan-4 +impl time (Task A7 Step 1 sketch: `if event_ring.len() >= +KMS_EVENT_RING_MAX_BYTES { for _ in 0..32 { event_ring.pop_front(); } +}` before `push_event_record`). Constants live in `KmsFdState` +alongside `pending_flips`: `pub const KMS_EVENT_RING_MAX_RECORDS: +usize = 64;` and `pub const KMS_EVENT_RING_MAX_BYTES: usize = +KMS_EVENT_RING_MAX_RECORDS * 32;`. One new cargo test under A7: +`event_ring_overflow_drops_oldest_silently` — push 100 records +without reading, assert ring stays at exactly 64 records (2048 +bytes) and the *most recent* 64 are kept. + +### Cross-plan amendment from plan 6's devil's-advocate + +Plan 6's devil's-advocate pass quantified an OFD-table-lock +contention figure that upgrades the urgency of this plan's +open-architecture #2 ("split OFD table out of PROCESS_TABLE"). +Plan 5 already added a producer at 1000+ Hz (autorepeat + drag); +plan 6's per-quantum `kernel_audio_period_tick` adds another at +~375 Hz (after plan 6 inline fix #4 collapsed accumulator to +per-quantum). Combined worst case under load: input (1000+ Hz) + +audio (~375 Hz) + vblank (60 Hz) + WAIT_VBLANK consumers + WRITEI +from userland = ~1500+ Hz acquisitions on the shared +`PROCESS_TABLE` mutex. Lock window per acquisition is still +O(open OFDs), but contention probability scales non-linearly with +producer rate; SDL2 game loops in plan 7 will exercise all three +streams simultaneously under real workloads. + +*Resolution:* the open-architecture #2 timing changes from "defer +to focused PR after plans 4+5 ship, no later than pre-SDL2-port- +merge" to "**block plan 7 (SDL2) merge on Phase C profiling — if +any of the three tick handlers shows >5% of its wall time in lock +acquisition under a representative SDL2 workload, the OFD-table- +split refactor is a hard prerequisite for plan 7 merge.**" The +profiling step lands in plan 7's Phase C; the refactor (if +triggered) is a focused inter-plan PR. Cross-plan note also added +to plan 5's open-architecture #1 + plan 6's open-architecture +section (which is where this concern is concretised). + +### Cross-plan amendment from plan 8's devil's-advocate + +Plan 8's devil's-advocate pass (session 9) confirms that this +plan's `PAGE_FLIP_EVENT` + per-OFD `event_ring` + `drmHandleEvent` +parser path is the canonical idiom for vsync-paced rendering. +wpkdraw's `wpk_surface_present` (plan 8 A3), the modeset demo +(this plan, C2 line 2103-2111), and SDL2's KMSDRM +`SwapWindow` (plan 7) all use the same `drmModePageFlip(... +PAGE_FLIP_EVENT) + drmHandleEvent` two-call pattern. Plan 8 inline +fix #2 catches one consumer that initially used `WAIT_VBLANK` for +flip-completion instead — the `WAIT_VBLANK` broadcast-wake is for +*free-running vblank polling without a queued flip*, not for +flip-completion waits. + +*Resolution for this plan:* add a one-line note under +"Deliberately not flagged" calling out the two distinct +idioms (next to the existing `DRM_EVENT_VBLANK` / `DRM_EVENT_FLIP_COMPLETE` +constant block): "PAGE_FLIP_EVENT + drmHandleEvent is the canonical +flip-completion-wait pattern (plans 4 C2, 7 SwapWindow, 8 wpk_surface_present +all converge on it); WAIT_VBLANK without PAGE_FLIP_EVENT is the +free-running vblank-only pattern, used by libdrm clients that want +to pace without queueing a flip (rare; SDL2 KMSDRM doesn't use +it)." No code change in this plan; doc clarification only. + +Plan 8's pass also caught the `gbm_bo_map` signature mismatch +flagged in plan 2's review — this plan's modeset demo (C2 line +2099) calls `gbm_bo_map` with 9 args; plan 2's libgbm stub C3 +will re-issue at upstream Mesa's 8-arg shape `(bo, x, y, w, h, +flags, uint32_t *stride, void **map_data)`. *Resolution for this +plan's C2:* update the demo to match — `gbm_bo_map(bo, 0, 0, +mode.hdisplay, mode.vdisplay, 0, NULL, &map_data)` with stride +fetched separately via `gbm_bo_get_stride(bo)`. Folds into C2 at +impl time after plan 2's stub lands at the canonical shape. + +### Cross-plan amendment from plan 9's devil's-advocate + +Plan 9's devil's-advocate pass (session 10) confirms this plan's +OFD-final-close auto-drop of `DRM_MASTER` (lines 403-406 + 1116) +is sufficient for plan 9's compositor crash-recovery path. When +the compositor process at PID 2 dies (SIGKILL or normal exit), its +card0 OFD's refcount drops to zero, `on_final_close` fires, and +`drop_master(ofd_idx)` releases master globally. Plan 9 does NOT +require a separate `drmDropMaster` ioctl on graceful compositor +shutdown — though plan 9 inline fix #6 ships one as defensive +hygiene under the goto-chain cleanup. The kernel-side contract +this plan locks in covers both paths. + +Plan 9 also relies on this plan's per-(CRTC, OFD) **one-in-flight +page-flip throttle** (lines 567-576 of this plan's A6) to bound +the compositor's render-rate. Plan 9 inline fix #8 adds a user- +space guard `gbm_surface_has_free_buffers` check before +`compositor_render_frame` to gracefully stall when the ring is +exhausted; the underlying throttle in this plan is what makes the +stall observable. No code change in this plan; the existing +throttle contract is the canonical one. + +*Resolution:* note added to "Deliberately not flagged" — "OFD- +final-close auto-drop is sufficient for compositor-process-death; +plan 9 does NOT require a separate drmDropMaster ioctl on +graceful shutdown." + +--- + +## Phase A — kernel: card0 device + KMS ioctls + DRM_MASTER + vblank ring (PR #1) + +The kernel learns to (a) recognise `/dev/dri/card0` as a distinct +virtual device, (b) dispatch the KMS ioctl subset, (c) track +`DRM_MASTER` ownership on the card0 OFD, (d) accumulate pending +page-flips into a per-OFD vblank-event ring drained by `read(card0)`, +and (e) export `kernel_vblank()` so the host can drive the tick. + +### Task A1: Shared ABI module additions + +**Files:** +- Modify: `crates/shared/src/lib.rs` — extend `pub mod dri` with the + KMS ioctl numbers, structs, and event-record layouts. + +**Step 1: Constants and structs** + +Append inside `pub mod dri { … }`: + +```rust + // --- KMS ioctl numbers ('d' magic, Linux UAPI verbatim) --------------- + // (Plan 2 covers 0x00, 0x09, 0x0c, 0x2d, 0x2e, 0xb2, 0xb3, 0xb4. + // Plan 3 covers WPK extensions at 0xE0, 0xE1. + // Plan 4 (this file) covers 0x1e, 0x1f, 0x3a, 0xa0-0xa7, 0xaf-0xb0, 0xb8.) + + /// `_IO('d', 0x1e)` — request `DRM_MASTER` on this fd. + pub const DRM_IOCTL_SET_MASTER: u32 = 0x0000_641e; + + /// `_IO('d', 0x1f)` — release `DRM_MASTER` if held. + pub const DRM_IOCTL_DROP_MASTER: u32 = 0x0000_641f; + + /// `_IOWR('d', 0x3a, WpkDrmWaitVblankUnion)` — block until next vblank. + /// The Linux struct is a union of request/reply; we encode the request + /// shape on input and overwrite with the reply on output. 12 bytes on + /// wasm32 (3 × u32, since the union body is `u32 type + u32 seq + u64 + /// signal`-or-`u32 tv_sec + u32 tv_usec` — 12 bytes either way). + pub const DRM_IOCTL_WAIT_VBLANK: u32 = 0xc018_643a; + + /// `_IOWR('d', 0xa0, WpkDrmModeCardRes)` — get crtc/connector/encoder + /// counts + ids. 64 bytes (4 × u64 + 4 × u32 + 4 × u32 = 32 + 16 + 16). + pub const DRM_IOCTL_MODE_GETRESOURCES: u32 = 0xc040_64a0; + + /// `_IOWR('d', 0xa1, WpkDrmModeGetCrtc)` — get current CRTC state. + /// 96 bytes (drm_mode_crtc). + pub const DRM_IOCTL_MODE_GETCRTC: u32 = 0xc060_64a1; + + /// `_IOWR('d', 0xa2, WpkDrmModeGetCrtc)` — set CRTC: attach fb + + /// connectors + mode. Same struct as GETCRTC, in/out shape. + pub const DRM_IOCTL_MODE_SETCRTC: u32 = 0xc060_64a2; + + /// `_IOWR('d', 0xa6, WpkDrmModeGetEncoder)` — get encoder shape. + /// 20 bytes (5 × u32). + pub const DRM_IOCTL_MODE_GETENCODER: u32 = 0xc014_64a6; + + /// `_IOWR('d', 0xa7, WpkDrmModeGetConnector)` — get connector shape + + /// EDID + modes. 88 bytes on wasm32. + pub const DRM_IOCTL_MODE_GETCONNECTOR: u32 = 0xc058_64a7; + + /// `_IOWR('d', 0xaf, u32)` — remove fb id, freeing the binding. + pub const DRM_IOCTL_MODE_RMFB: u32 = 0xc004_64af; + + /// `_IOWR('d', 0xb0, WpkDrmModeCrtcPageFlip)` — queue a page-flip; + /// completes on next vblank, fires `drm_event_vblank` on the master + /// fd's event ring. 24 bytes (3 × u32 + u64 user_data). + pub const DRM_IOCTL_MODE_PAGE_FLIP: u32 = 0xc018_64b0; + + /// `_IOWR('d', 0xb8, WpkDrmModeFbCmd2)` — allocate fb id wrapping a bo + /// (or up to 4 bos for multi-plane formats — we only support single- + /// plane in v1). 96 bytes on wasm32. + pub const DRM_IOCTL_MODE_ADDFB2: u32 = 0xc060_64b8; + + // --- Connector type constants (subset) -------------------------------- + + /// `DRM_MODE_CONNECTOR_VIRTUAL` = 15. Best match for the + /// OffscreenCanvas-as-display abstraction. + pub const DRM_MODE_CONNECTOR_VIRTUAL: u32 = 15; + + /// `DRM_MODE_CONNECTED` = 1. The single connector is always connected + /// (the canvas is always present from the kernel's POV). + pub const DRM_MODE_CONNECTED: u32 = 1; + + // --- Event types (for the read(card0) record stream) ------------------ + + /// `DRM_EVENT_VBLANK` = 1. Posted on `drmWaitVBlank` completion; + /// **not** posted on page-flip completion (page-flips use + /// `DRM_EVENT_FLIP_COMPLETE`). + pub const DRM_EVENT_VBLANK: u32 = 1; + + /// `DRM_EVENT_FLIP_COMPLETE` = 2. Posted on page-flip completion. + pub const DRM_EVENT_FLIP_COMPLETE: u32 = 2; + + // --- marshalled structs ----------------------------------------------- + + /// `struct drm_mode_card_res`. 64 bytes on wasm32 (4 × u64 ptrs + 8 × + /// u32). Linux uses `__u64` for the four count/id ptr fields to keep + /// the struct portable across x86_32 / x86_64; we mirror that — the + /// wasm32 pointer occupies the low 32 bits, top 32 bits are zero on + /// input and ignored on the kernel side. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmModeCardRes { + pub fb_id_ptr: u64, // 0 in u32-array ptr (caller-allocated) + pub crtc_id_ptr: u64, // 8 in + pub connector_id_ptr: u64, // 16 in + pub encoder_id_ptr: u64, // 24 in + pub count_fbs: u32, // 32 in/out (caller's array len; kernel's count) + pub count_crtcs: u32, // 36 in/out + pub count_connectors: u32, // 40 in/out + pub count_encoders: u32, // 44 in/out + pub min_width: u32, // 48 out + pub max_width: u32, // 52 out + pub min_height: u32, // 56 out + pub max_height: u32, // 60 out + // total: 64 + } + + /// `struct drm_mode_modeinfo`. 68 bytes. Single-mode display has one + /// fixed instance; clients query via `MODE_GETCONNECTOR`. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmModeModeinfo { + pub clock: u32, // 0 + pub hdisplay: u16, // 4 + pub hsync_start: u16, // 6 + pub hsync_end: u16, // 8 + pub htotal: u16, // 10 + pub hskew: u16, // 12 + pub vdisplay: u16, // 14 + pub vsync_start: u16, // 16 + pub vsync_end: u16, // 18 + pub vtotal: u16, // 20 + pub vscan: u16, // 22 + pub vrefresh: u32, // 24 + pub flags: u32, // 28 + pub mode_type: u32, // 32 + pub name: [u8; 32], // 36..68 + // total: 68 + } + + /// `struct drm_mode_crtc`. 96 bytes on wasm32 (4 × u32 crtc_id/fb_id/ + /// x/y + u64 set_connectors_ptr + u32 count_connectors + u32 gamma_size + /// + u32 mode_valid + WpkDrmModeModeinfo (68 bytes) — packed with + /// trailing pad to round up to 8-byte align). + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmModeGetCrtc { + pub set_connectors_ptr: u64, // 0 in u32-array ptr (SETCRTC only) + pub count_connectors: u32, // 8 in (SETCRTC only) + pub crtc_id: u32, // 12 in/out + pub fb_id: u32, // 16 in/out fb to attach (SETCRTC), current fb (GETCRTC) + pub x: u32, // 20 in/out + pub y: u32, // 24 in/out + pub gamma_size: u32, // 28 out + pub mode_valid: u32, // 32 in/out + pub mode: WpkDrmModeModeinfo, // 36..104 + // total: 104 (Linux says 96; wasm32 alignment differs) + // NB: the Linux struct is 96 bytes because `__u64 set_connectors_ptr` + // forces 8-byte alignment of the struct; on wasm32 the 8-byte + // alignment of the leading u64 is the same, but `WpkDrmModeModeinfo` + // ends at offset 104 if it starts at 36. **Verify with the ioctl + // -encoding test at Task A1 Step 2 against the Linux number + // `0xc060_64a1` (size 0x60 = 96).** If the real wasm32 layout + // produces 104, encode the ioctl as `0xc068_64a1` (size 0x68 = + // 104) and document the wasm32-ilp32 deviation. + } + + /// `struct drm_mode_get_connector`. Truncated v1 shape: we report + /// 1 mode, 0 encoders-other-than-current, 0 props. Total 88 bytes: + /// 3 × u64 ptrs (24) + 13 × u32 (52) + 12 trailing bytes round-up. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmModeGetConnector { + pub encoders_ptr: u64, // 0 in + pub modes_ptr: u64, // 8 in ptr to drm_mode_modeinfo array + pub props_ptr: u64, // 16 in + pub prop_values_ptr: u64, // 24 in + pub count_modes: u32, // 32 in/out + pub count_props: u32, // 36 in/out + pub count_encoders: u32, // 40 in/out + pub encoder_id: u32, // 44 out current encoder + pub connector_id: u32, // 48 in/out + pub connector_type: u32, // 52 out DRM_MODE_CONNECTOR_VIRTUAL + pub connector_type_id: u32, // 56 out = 1 + pub connection: u32, // 60 out DRM_MODE_CONNECTED + pub mm_width: u32, // 64 out physical width (mm); we report 0 + pub mm_height: u32, // 68 out + pub subpixel: u32, // 72 out + pub pad: u32, // 76 + // total: 80 (Linux 88 — verify; see CrtcGetCrtc note) + } + + /// `struct drm_mode_get_encoder`. 20 bytes (5 × u32). + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmModeGetEncoder { + pub encoder_id: u32, // 0 in/out + pub encoder_type: u32, // 4 out + pub crtc_id: u32, // 8 out + pub possible_crtcs: u32, // 12 out bitmask + pub possible_clones: u32, // 16 out bitmask + // total: 20 + } + + /// `struct drm_mode_fb_cmd2`. 96 bytes on wasm32: 5 × u32 (fb_id, + /// width, height, pixel_format, flags) = 20, then 4 × u32 handles + + /// 4 × u32 pitches + 4 × u32 offsets = 48, then 4 × u64 modifiers = + /// 32. Total = 100. **NB: Linux is 96 because of an alignment quirk + /// (the 4 × u64 modifier array forces 8-byte align of struct end); + /// on wasm32 the same applies, so total may differ. Verify and pick + /// the encoding accordingly.** + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmModeFbCmd2 { + pub fb_id: u32, // 0 out + pub width: u32, // 4 in + pub height: u32, // 8 in + pub pixel_format: u32, // 12 in DRM_FORMAT_* + pub flags: u32, // 16 in + pub handles: [u32; 4], // 20 in bo handles (single-plane: handle[0] only) + pub pitches: [u32; 4], // 36 in + pub offsets: [u32; 4], // 52 in + pub modifier: [u64; 4], // 64..96 in DRM_FORMAT_MOD_LINEAR for all + // total: 96 (verify wasm32 layout) + } + + /// `struct drm_mode_crtc_page_flip`. 24 bytes (3 × u32 + u64). + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmModeCrtcPageFlip { + pub crtc_id: u32, // 0 in + pub fb_id: u32, // 4 in + pub flags: u32, // 8 in PAGE_FLIP_EVENT etc. + pub reserved: u32, // 12 + pub user_data: u64, // 16 in passed back in event + // total: 24 + } + + /// `struct drm_event_vblank` (the record `read(card0)` returns). + /// 8-byte `drm_event` header (`type, length`) + 24-byte body + /// (`user_data, tv_sec, tv_usec, sequence, crtc_id`) = 32 bytes + /// total. Matches upstream `drm_event_vblank` in + /// `include/uapi/drm/drm.h` exactly. **Critical:** SDL2's `KMSDRM` + /// backend uses libdrm's `drmHandleEvent` with `version = 3` and + /// the `page_flip_handler2` callback whose signature is + /// `(fd, sequence, tv_sec, tv_usec, crtc_id, user_data)` — it + /// *reads* both `sequence` and `crtc_id`. Truncating to 24 would + /// feed v3 the wrong bytes; the libdrm stub in Task C1 reads 32 + /// bytes per record in lockstep with this layout. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmEventVblank { + pub ev_type: u32, // 0 DRM_EVENT_VBLANK or _FLIP_COMPLETE + pub length: u32, // 4 sizeof(WpkDrmEventVblank) = 32 + pub user_data: u64, // 8 from page-flip request + pub tv_sec: u32, // 16 MONOTONIC seconds (kernel uptime) + pub tv_usec: u32, // 20 MONOTONIC microseconds + pub sequence: u32, // 24 per-CRTC monotonic vblank counter + pub crtc_id: u32, // 28 which CRTC fired this vblank + // total: 32 + } + + /// `struct drm_wait_vblank_request`. Union member (input side). + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmWaitVblankRequest { + pub req_type: u32, // 0 _DRM_VBLANK_RELATIVE etc. + pub sequence: u32, // 4 absolute or relative count + pub signal: u64, // 8 future-plan; v1 ignores + // total: 16 (NB: Linux is 12 because no padding + // between u32+u32 and u64; verify our + // repr(C) doesn't add padding on wasm32.) + } + + /// `struct drm_wait_vblank_reply`. Union member (output side). + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkDrmWaitVblankReply { + pub rep_type: u32, // 0 + pub sequence: u32, // 4 + pub tv_sec: u32, // 8 + pub tv_usec: u32, // 12 + // total: 16 + } +``` + +**Step 2: Static-assert + ioctl-encoding tests** + +Append to the existing `dri_tests` mod in `crates/shared/src/lib.rs` +(introduced by plan 2 Task A1 Step 2, extended by plan 3 Task A1 +Step 2): + +```rust + #[test] + fn kms_struct_sizes_match_wasm32_repr_c() { + assert_eq!(size_of::<WpkDrmModeCardRes>(), 64); + assert_eq!(size_of::<WpkDrmModeModeinfo>(), 68); + assert_eq!(size_of::<WpkDrmModeGetEncoder>(), 20); + assert_eq!(size_of::<WpkDrmModeCrtcPageFlip>(), 24); + // 32 bytes (header + user_data + tv_sec + tv_usec + sequence + // + crtc_id) — matches upstream `drm_event_vblank` so SDL2's + // page_flip_handler2 reads the right fields. + assert_eq!(size_of::<WpkDrmEventVblank>(), 32); + // The three structs marked **verify** above must have their + // size asserted here against whatever `repr(C)` actually + // produces on wasm32; the ioctl encodings below must match. + // If a layout assertion fails, fix the struct (not the ioctl + // number). + assert_eq!(size_of::<WpkDrmModeGetCrtc>(), 0x60 /* = 96; verify */); + assert_eq!(size_of::<WpkDrmModeGetConnector>(), 0x58 /* = 88; verify */); + assert_eq!(size_of::<WpkDrmModeFbCmd2>(), 0x60 /* = 96; verify */); + assert_eq!(size_of::<WpkDrmWaitVblankRequest>(), 0x10 /* = 16 */); + assert_eq!(size_of::<WpkDrmWaitVblankReply>(), 0x10); + } + + #[test] + fn kms_ioctl_numbers_match_linux_uapi() { + let iowr = IOC_READ | IOC_WRITE; + let io = 0u32; + assert_eq!(DRM_IOCTL_SET_MASTER, + ioc(io, 'd' as u32, 0x1e, 0)); + assert_eq!(DRM_IOCTL_DROP_MASTER, + ioc(io, 'd' as u32, 0x1f, 0)); + assert_eq!(DRM_IOCTL_WAIT_VBLANK, + ioc(iowr, 'd' as u32, 0x3a, 0x18 /* 24 — union size */)); + assert_eq!(DRM_IOCTL_MODE_GETRESOURCES, + ioc(iowr, 'd' as u32, 0xa0, size_of::<WpkDrmModeCardRes>() as u32)); + assert_eq!(DRM_IOCTL_MODE_GETCRTC, + ioc(iowr, 'd' as u32, 0xa1, size_of::<WpkDrmModeGetCrtc>() as u32)); + assert_eq!(DRM_IOCTL_MODE_SETCRTC, + ioc(iowr, 'd' as u32, 0xa2, size_of::<WpkDrmModeGetCrtc>() as u32)); + assert_eq!(DRM_IOCTL_MODE_GETENCODER, + ioc(iowr, 'd' as u32, 0xa6, size_of::<WpkDrmModeGetEncoder>() as u32)); + assert_eq!(DRM_IOCTL_MODE_GETCONNECTOR, + ioc(iowr, 'd' as u32, 0xa7, size_of::<WpkDrmModeGetConnector>() as u32)); + assert_eq!(DRM_IOCTL_MODE_RMFB, + ioc(iowr, 'd' as u32, 0xaf, 4)); + assert_eq!(DRM_IOCTL_MODE_PAGE_FLIP, + ioc(iowr, 'd' as u32, 0xb0, size_of::<WpkDrmModeCrtcPageFlip>() as u32)); + assert_eq!(DRM_IOCTL_MODE_ADDFB2, + ioc(iowr, 'd' as u32, 0xb8, size_of::<WpkDrmModeFbCmd2>() as u32)); + } +``` + +**Step 3: Run** + +```bash +cargo test -p wasm-posix-shared --target aarch64-apple-darwin --lib dri_tests +``` + +Expected: plan 2 + plan 3 tests still pass; 2 new tests pass. **If any +layout assertion fails, fix the struct first** (add/remove pad fields, +inspect with `cargo expand`), then re-derive the ioctl number to +match the *actual* `size_of` value. The Linux number is the target, +but the wasm32 layout is reality — if they diverge, the wasm32 number +is what we encode and we document the deviation. + +**Step 4: Commit** + +```bash +git add crates/shared/src/lib.rs +git commit -m "kernel(dri): shared ABI — KMS ioctl numbers + structs + event records" +``` + +--- + +### Task A2: `VirtualDevice::DriCard0` + devfs entry + `OpenFileKind::DriCard` + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — extend `VirtualDevice` and + `match_virtual_device` for `/dev/dri/card0`. +- Modify: `crates/kernel/src/devfs.rs` — add `card0` next to the + existing `renderD128` entry. +- Modify: `crates/kernel/src/ofd.rs` — add `OpenFileKind::DriCard` and + extend plan 3's `DriFdState` with a `kms: Option<Box<KmsFdState>>` + field. `KmsFdState` carries the per-card0-fd state. + +**Step 1: Device enum** + +In `crates/kernel/src/syscalls.rs` `VirtualDevice` enum (around lines +58-95 per the handoff §"Where to look in the tree"): + +```rust +pub enum VirtualDevice { + // … existing variants … + DriRender0, // /dev/dri/renderD128 (plan 2 + 3) + DriCard0, // /dev/dri/card0 (plan 4 — this task) +} +``` + +Extend `match_virtual_device` (around line 117) to map +`/dev/dri/card0` → `DriCard0`. + +**Step 2: devfs entry** + +In `crates/kernel/src/devfs.rs`, alongside the synthetic +`renderD128` entry, add: + +```rust +synthetic_entry("card0", DT_CHR, 226, 0), // major=226 (DRM), minor=0 +``` + +(The minor numbers are conventional but not load-bearing — Linux +allocates dynamically.) + +**Step 3: `KmsFdState` on the OFD** + +In `crates/kernel/src/ofd.rs`, append plan 3's `DriFdState` +definition (or, if the architecture-open from plan 3 lands on +"`dri_handles` lifts to OFD but `gl_state` stays on Process", +`DriFdState` already exists at this point): + +```rust +/// Per-fd state for `/dev/dri/card0` opens. **Per-OFD lift is +/// uncontroversial here — fb ids in Linux really are per-fd, not +/// per-process, and there's no fork-inherit-GL-bindings hazard.** +#[derive(Default, Clone, Debug)] +pub struct KmsFdState { + /// fb-id → KmsFb (the bound bo + dimensions + format + stride). + pub fbs: BTreeMap<u32, KmsFb>, + /// Next fb id to issue. Linux numbers fb ids globally per-master; + /// we scope per-OFD which is stricter than Linux but harmless. + pub next_fb_id: u32, + /// Holds DRM_MASTER on this OFD's card0 fd. Only one OFD across + /// the kernel can hold master at a time (enforced by Task A3). + pub holds_master: bool, + /// Pending page-flips (queue depth 1 per CRTC in v1; v2 may bump). + /// (crtc_id, target_fb_id, user_data) — flipped on next vblank. + pub pending_flips: Vec<PendingFlip>, + /// Event records queued for read(card0). The ring drains in FIFO. + pub event_ring: VecDeque<u8>, // raw bytes; records are 24-byte aligned +} + +#[derive(Clone, Debug)] +pub struct KmsFb { + pub bo_id: u32, + pub width: u32, + pub height: u32, + pub pixel_format: u32, + pub stride: u32, +} + +#[derive(Clone, Debug)] +pub struct PendingFlip { + pub crtc_id: u32, + pub fb_id: u32, + pub user_data: u64, +} +``` + +Attach the OFD-side DRI state. Per the Pre-impl review +("correctness-open #1" — card0 OFDs need a handle namespace for +ADDFB2), the cleanest landing is to consolidate plan 2's `prime_bo`, +plan 3's `dri`, and plan 4's `kms` into a single +`Option<Box<DriOfdState>>` enum **in this task** (not as a deferred +follow-up): + +```rust +pub enum DriOfdState { + PrimeBo(PrimeBoState), // plan 2 (renderD128 PRIME export) + RenderNode(DriFdState), // plan 3 (renderD128 GL + handles) + Card { dri: DriFdState, kms: KmsFdState }, // plan 4 (this task) +} + +pub struct OpenFileDesc { + // … existing fields … + pub dri_state: Option<Box<DriOfdState>>, // single field; replaces + // plan 2's `prime_bo` + // and plan 3's `dri`. +} +``` + +`open("/dev/dri/card0")` constructs +`Some(Box::new(DriOfdState::Card { dri: DriFdState::default(), kms: +KmsFdState::default() }))`. The `Card` variant carries plan 3's +`DriFdState` for the bo-handle namespace `MODE_ADDFB2` reads against +(populated by `PRIME_FD_TO_HANDLE` on card0 — same code path plan 3 +A2 already wired for renderD128). The two-field-on-Card shape keeps +the GL state separate from the KMS state for clarity even though +they share the same OFD. + +(Plan 2's commits + plan 3's commits land with the simpler +`Option<Box<...>>` shape per the original plan-doc text; this +consolidation lands as the *first* commit of plan 4 Phase A, so the +churn surfaces as one rename-style commit, not as drift across +plans.) + +**Step 4: Cargo tests** + +```rust +#[test] +fn open_card0_yields_kms_state() { + // Open /dev/dri/card0 → OFD.kms is Some, .dri is None + // (vs renderD128 which is the opposite). +} + +#[test] +fn fork_inherits_kms_state_via_ofd_dup() { + // Same OFD shared = same KmsFdState (Linux per-fd semantics + // preserved through fork's ref-bump). +} +``` + +**Step 5: Commit** + +```bash +git add crates/kernel/src/syscalls.rs crates/kernel/src/devfs.rs \ + crates/kernel/src/ofd.rs +git commit -m "kernel(dri): add /dev/dri/card0 + KmsFdState on OFD" +``` + +--- + +### Task A3: `DRM_IOCTL_SET_MASTER` / `DROP_MASTER` + global master state + +**Files:** +- Modify: `crates/kernel/src/dri/mod.rs` (or a new + `crates/kernel/src/dri/master.rs`) — add the kernel-wide + master-holder lookup. +- Modify: `crates/kernel/src/syscalls.rs` — dispatcher branches for + the two ioctls in a new `handle_dri_card_ioctl()`. +- Modify: `crates/kernel/src/process.rs` — `HostIO::kms_set_master` + and `HostIO::kms_drop_master` to notify the host's + SubmitQueue-priority lookup. + +**Step 1: Global master holder** + +In `crates/kernel/src/dri/master.rs`: + +```rust +//! DRM_MASTER ownership tracking for /dev/dri/card0. +//! +//! Linux semantics: at most one fd per card holds DRM_MASTER at any +//! time. Master is taken via `DRM_IOCTL_SET_MASTER`, released via +//! `DRM_IOCTL_DROP_MASTER`, and revoked when the holding fd's OFD +//! is destroyed (i.e., on `on_final_close`). The current master's +//! pid is the COMPOSITOR_PRI source for plan 3's SubmitQueue. + +use spin::Mutex; + +static MASTER: Mutex<Option<MasterHolder>> = Mutex::new(None); + +#[derive(Clone, Copy, Debug)] +pub struct MasterHolder { + pub pid: i32, + pub ofd_idx: usize, // OFD slot, for revoke-on-close lookup +} + +/// Try to claim master. Returns Ok if the caller now holds master. +/// EBUSY if another fd already holds it. EACCES is **not** returned +/// (we don't have per-uid permissions in v1). +pub fn try_set_master(pid: i32, ofd_idx: usize) -> Result<(), crate::Errno> { + let mut m = MASTER.lock(); + if let Some(h) = *m { + if h.ofd_idx == ofd_idx { return Ok(()); } // already held + return Err(crate::Errno::EBUSY); + } + *m = Some(MasterHolder { pid, ofd_idx }); + Ok(()) +} + +/// Release if the caller holds master. No-op otherwise. +pub fn drop_master(ofd_idx: usize) -> bool { + let mut m = MASTER.lock(); + if let Some(h) = *m { + if h.ofd_idx == ofd_idx { + *m = None; + return true; + } + } + false +} + +/// Current master holder's pid, if any. Used by SubmitQueue priority +/// (host queries this via a new kernel-export-callback during +/// `host_kms_set_master` notification). +pub fn current_master_pid() -> Option<i32> { + MASTER.lock().map(|h| h.pid) +} +``` + +**Step 2: Dispatcher** + +In `handle_dri_card_ioctl()` (sibling of plan 2's +`handle_dri_ioctl()`, which we keep for renderD128 and now +*also* call via fall-through for the shared +`PRIME_*`/`MODE_*DUMB*`/`GEM_CLOSE`/`VERSION`/`GET_CAP` ioctls valid +on both nodes): + +```rust +fn handle_dri_card_ioctl(pid: i32, fd: i32, request: u32, buf: &mut [u8]) + -> Result<(), Errno> +{ + use wasm_posix_shared::dri::*; + + match request { + DRM_IOCTL_SET_MASTER => { + let ofd_idx = resolve_card0_ofd_idx(pid, fd)?; + crate::dri::master::try_set_master(pid, ofd_idx)?; + // Mark the OFD's local flag for fast-path checks. + with_card0_ofd(ofd_idx, |kms| kms.holds_master = true); + HOST_IO.lock().kms_set_master(pid); + Ok(()) + } + DRM_IOCTL_DROP_MASTER => { + let ofd_idx = resolve_card0_ofd_idx(pid, fd)?; + if crate::dri::master::drop_master(ofd_idx) { + with_card0_ofd_mut(ofd_idx, |kms| { + kms.holds_master = false; + // Drain any pending flips — Linux has no + // flip-aborted event, so the queued flips + // simply vanish. Without this drain, a stale + // flip would fire on the next vblank if this + // OFD re-took master, surprising the new + // master (or a *different* OFD that takes + // master in between). + kms.pending_flips.clear(); + }); + // pid is the closer/dropper, used for host-side + // logging only — KmsRegistry.dropMaster() is global. + HOST_IO.lock().kms_drop_master(pid); + } + Ok(()) + } + // … Tasks A4/A5/A6/A7 add: GETRESOURCES, GETCRTC, SETCRTC, + // GETENCODER, GETCONNECTOR, ADDFB2, RMFB, PAGE_FLIP, + // WAIT_VBLANK … + // + // Pass-through (render-node ioctls valid on card0 — Linux + // allows this): + DRM_IOCTL_VERSION | DRM_IOCTL_GET_CAP | DRM_IOCTL_GEM_CLOSE + | DRM_IOCTL_PRIME_HANDLE_TO_FD | DRM_IOCTL_PRIME_FD_TO_HANDLE + | DRM_IOCTL_MODE_CREATE_DUMB | DRM_IOCTL_MODE_MAP_DUMB + | DRM_IOCTL_MODE_DESTROY_DUMB + | DRM_IOCTL_WPK_CREATE_GPU_BO | DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE + => handle_dri_ioctl(pid, fd, request, buf), + _ => Err(Errno::EOPNOTSUPP), + } +} +``` + +**Step 3: HostIO additions** + +```rust + fn kms_set_master(&mut self, pid: i32); + fn kms_drop_master(&mut self, pid: i32); +``` + +Stub in `host/test/centralized-test-helper.ts`. + +**Step 4: Cargo tests** + +```rust +#[test] +fn second_set_master_returns_ebusy() { /* … */ } + +#[test] +fn drop_master_then_other_can_take_it() { /* … */ } + +#[test] +fn final_close_releases_master() { + // Process A opens card0, SET_MASTER, fork → child also has the OFD. + // Process A close(fd) → OFD refcount = 1, master still held (B has it). + // Process B close(fd) → OFD destroyed → master released globally. + // Process C opens card0, SET_MASTER → succeeds. +} +``` + +**Step 5: Commit** + +```bash +git add crates/kernel/src/dri/master.rs crates/kernel/src/dri/mod.rs \ + crates/kernel/src/syscalls.rs crates/kernel/src/process.rs \ + host/test/centralized-test-helper.ts +git commit -m "kernel(dri): DRM_MASTER + SET_MASTER/DROP_MASTER ioctls" +``` + +--- + +### Task A4: Resource introspection — `GETRESOURCES`, `GETCONNECTOR`, `GETENCODER`, `GETCRTC` + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — extend + `handle_dri_card_ioctl()` with the four introspection ioctls. + +Single CRTC (`crtc_id = 1`), single encoder (`encoder_id = 1`, +`possible_crtcs = 0b1`), single connector (`connector_id = 1`, +type = `DRM_MODE_CONNECTOR_VIRTUAL`, connected, one mode). The mode +is sourced from the host on first `MODE_GETRESOURCES` via a new +`HostIO::kms_mode_info(connector_id) -> WpkDrmModeModeinfo` +callback; cached for the duration of the OFD. + +**Step 1: GETRESOURCES** + +```rust +DRM_IOCTL_MODE_GETRESOURCES => { + let req: WpkDrmModeCardRes = read_struct(buf)?; + // Write back the counts the kernel knows. + let resp = WpkDrmModeCardRes { + count_fbs: with_card0_ofd_kms(pid, fd, |k| k.fbs.len() as u32)?, + count_crtcs: 1, + count_connectors: 1, + count_encoders: 1, + min_width: 1, max_width: 16384, + min_height: 1, max_height: 16384, + ..req + }; + write_struct(buf, &resp)?; + // If caller passed non-zero count_* with arrays, populate them via + // proc_write_at(pid, ptr_field, slice). The {crtc,connector,encoder}_id + // arrays each get a single-element [1] write. + if req.count_crtcs >= 1 && req.crtc_id_ptr != 0 { + proc_write_u32(pid, req.crtc_id_ptr as u32, &[1u32])?; + } + if req.count_connectors >= 1 && req.connector_id_ptr != 0 { + proc_write_u32(pid, req.connector_id_ptr as u32, &[1u32])?; + } + if req.count_encoders >= 1 && req.encoder_id_ptr != 0 { + proc_write_u32(pid, req.encoder_id_ptr as u32, &[1u32])?; + } + // fb_id_ptr is populated from KmsFdState.fbs keys (caller's first + // call has count_fbs = 0 to probe). + Ok(()) +} +``` + +**Step 2: GETCONNECTOR / GETENCODER / GETCRTC** + +Same pattern — write the fixed single-instance values back, populate +caller arrays if non-zero capacity. GETCONNECTOR returns one mode +(via `kms_mode_info` from the host on first call, cached per-OFD). + +**Step 3: Cargo tests** + +```rust +#[test] +fn getresources_reports_1_crtc_1_connector_1_encoder() { /* … */ } + +#[test] +fn getconnector_reports_virtual_connected_with_one_mode() { /* … */ } +``` + +**Step 4: Commit** + +```bash +git add crates/kernel/src/syscalls.rs crates/kernel/src/process.rs +git commit -m "kernel(dri): KMS introspection — GETRESOURCES, GET{CONNECTOR,ENCODER,CRTC}" +``` + +--- + +### Task A5: `MODE_ADDFB2` + `MODE_RMFB` + `MODE_SETCRTC` + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — three ioctl branches. + +**Step 1: ADDFB2** + +```rust +DRM_IOCTL_MODE_ADDFB2 => { + let mut req: WpkDrmModeFbCmd2 = read_struct(buf)?; + if req.width == 0 || req.height == 0 { return Err(Errno::EINVAL); } + // Single-plane only in v1 — handles[1..4] must be zero. + if req.handles[1] != 0 || req.handles[2] != 0 || req.handles[3] != 0 { + return Err(Errno::EINVAL); + } + // Format must be one we accept (matches plan 2 + plan 3 lists). + match req.pixel_format { + DRM_FORMAT_ARGB8888 | DRM_FORMAT_XRGB8888 | DRM_FORMAT_RGB565 => {}, + _ => return Err(Errno::EINVAL), + } + // Resolve handle → BoId via the CALLER's renderD128-fd OFD state — + // ADDFB2 takes bo handles which were issued via the same per-fd + // DriFdState namespace. **Trade-off:** Linux scopes ADDFB2 handles + // to the card0 fd, *not* the renderD128 fd. To match Linux we look + // up against the same OFD as the card0 ioctl is being called on — + // which means the caller must hold both card0 + renderD128 on the + // SAME process AND have done a PRIME_FD_TO_HANDLE round-trip to + // import the bo into the card0 fd's handle namespace. Document. + let bo_id = resolve_handle_on_card0_ofd(pid, fd, req.handles[0])?; + // Tier check: bo can be CpuShared or Gpu (both legal for scanout). + let bo = crate::dri::with_registry(|r| r.get(bo_id).cloned()) + .ok_or(Errno::ENOENT)?; + // Validate pitches[0] vs bo.stride for CpuShared (must match); + // for Gpu tier, pitches[0] is ignored (host owns the layout). + if matches!(bo.tier, crate::dri::BoTier::CpuShared) + && req.pitches[0] != bo.stride + { + return Err(Errno::EINVAL); + } + // Pin the bo alive for the duration of the fb binding. Plan 2 + // refcount semantics: the fb keeps the bo alive until RMFB (or + // on_final_close) — see "Trade-offs already locked in", item + // `MODE_DESTROYDUMB on a fb-bound bo`. Increment BEFORE inserting + // so the closure can return Err without leaking the ref. + crate::dri::with_registry(|r| r.incref(bo_id)); + // Allocate fb_id, record binding. + let fb_alloc: Result<u32, Errno> = with_card0_ofd_kms_mut(pid, fd, |k| { + let id = k.next_fb_id.checked_add(1).ok_or(Errno::ENOMEM)?; + k.next_fb_id = id; + k.fbs.insert(id, KmsFb { + bo_id, width: req.width, height: req.height, + pixel_format: req.pixel_format, + stride: req.pitches[0], + }); + Ok::<_, Errno>(id) + })?; + let fb_id = match fb_alloc { + Ok(id) => id, + Err(e) => { + crate::dri::with_registry(|r| r.decref(bo_id)); + return Err(e); + } + }; + // Notify host. Plan 2 / plan 3 convention: rc < 0 means host-side + // allocation failed — unwind kernel state to keep registries in + // lockstep (mirrors plan 2 A6's unwind shape). + let rc = HOST_IO.lock().kms_addfb(pid, fb_id, bo_id, req.width, + req.height, req.pixel_format, req.pitches[0]); + if rc < 0 { + with_card0_ofd_kms_mut(pid, fd, |k| { k.fbs.remove(&fb_id); })?; + crate::dri::with_registry(|r| r.decref(bo_id)); + return Err(Errno::ENOMEM); + } + req.fb_id = fb_id; + write_struct(buf, &req)?; + Ok(()) +} +``` + +**Step 2: RMFB** + +```rust +DRM_IOCTL_MODE_RMFB => { + let fb_id = read_u32(buf)?; + // Drop the fb binding and release the bo pin in lockstep. + let bo_id = with_card0_ofd_kms_mut(pid, fd, |k| + k.fbs.remove(&fb_id).map(|fb| fb.bo_id).ok_or(Errno::ENOENT) + )??; + crate::dri::with_registry(|r| r.decref(bo_id)); + HOST_IO.lock().kms_rmfb(pid, fb_id); + Ok(()) +} +``` + +**Step 3: SETCRTC** + +```rust +DRM_IOCTL_MODE_SETCRTC => { + let req: WpkDrmModeGetCrtc = read_struct(buf)?; + // Must hold DRM_MASTER on this fd. + if !with_card0_ofd_kms(pid, fd, |k| k.holds_master)? { + return Err(Errno::EACCES); + } + if req.crtc_id != 1 { return Err(Errno::ENOENT); } + // fb_id = 0 means "disable CRTC"; otherwise validate. + if req.fb_id != 0 { + let _ = with_card0_ofd_kms(pid, fd, |k| + k.fbs.get(&req.fb_id).cloned()).ok_or(Errno::ENOENT)?; + } + HOST_IO.lock().kms_set_fb(pid, req.crtc_id, req.fb_id); + Ok(()) +} +``` + +**Step 4: HostIO** + +```rust + fn kms_addfb(&mut self, pid: i32, fb_id: u32, bo_id: u32, w: u32, + h: u32, format: u32, pitch: u32) -> i32; + fn kms_rmfb(&mut self, pid: i32, fb_id: u32); + fn kms_set_fb(&mut self, pid: i32, crtc_id: u32, fb_id: u32) -> i32; +``` + +**Step 5: Cargo tests** + +```rust +#[test] +fn addfb_rejects_multi_plane_handles() { /* handles[1]=2 → EINVAL */ } + +#[test] +fn addfb_validates_pitch_for_cpushared_bos() { /* pitch mismatch → EINVAL */ } + +#[test] +fn addfb_accepts_gpu_tier_bos() { /* GPU bo + pitch ignored → OK */ } + +#[test] +fn setcrtc_requires_master() { /* non-master → EACCES */ } + +#[test] +fn setcrtc_with_fb_zero_disables_crtc() { /* OK; host sees fb_id=0 */ } +``` + +**Step 6: Commit** + +```bash +git add crates/kernel/ host/test/ +git commit -m "kernel(dri): MODE_ADDFB2 / RMFB / SETCRTC" +``` + +--- + +### Task A6: `MODE_PAGE_FLIP` queues + per-CRTC throttle + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — branch. + +```rust +DRM_IOCTL_MODE_PAGE_FLIP => { + let req: WpkDrmModeCrtcPageFlip = read_struct(buf)?; + if req.crtc_id != 1 { return Err(Errno::ENOENT); } + if !with_card0_ofd_kms(pid, fd, |k| k.holds_master)? { + return Err(Errno::EACCES); + } + let _ = with_card0_ofd_kms(pid, fd, |k| + k.fbs.get(&req.fb_id).cloned()).ok_or(Errno::ENOENT)?; + // Throttle: one pending flip per CRTC per OFD. Returns EBUSY if + // a flip is already pending — matches Linux behaviour. + with_card0_ofd_kms_mut(pid, fd, |k| { + if k.pending_flips.iter().any(|p| p.crtc_id == req.crtc_id) { + return Err(Errno::EBUSY); + } + k.pending_flips.push(PendingFlip { + crtc_id: req.crtc_id, fb_id: req.fb_id, + user_data: req.user_data, + }); + Ok(()) + })??; + Ok(()) // the page-flip fires on next vblank (Task A7 + A8) +} +``` + +**Cargo:** test EBUSY on second flip pre-vblank; test EACCES without +master. + +**Commit:** `kernel(dri): MODE_PAGE_FLIP queue + one-in-flight throttle` + +--- + +### Task A7: `kernel_vblank()` export — fire pending flips, post event records + +**Files:** +- Modify: `crates/kernel/src/wasm_api.rs` — add the kernel-wasm + export `kernel_vblank()`. + +```rust +/// Called from the host on every vblank tick. Walks every card0 OFD +/// with `holds_master = true` and `pending_flips` non-empty; for each +/// pending flip, posts a DRM_EVENT_FLIP_COMPLETE record into the +/// OFD's event ring and clears the pending entry. Wakes any task +/// blocked on `read(card0)` or `drmWaitVBlank` on that OFD. +#[no_mangle] +pub extern "C" fn kernel_vblank() { + let now = crate::time::monotonic_us(); // CLOCK_MONOTONIC per + // design §16 q6 — same + // clock evdev uses in + // plan 5. + // Per-CRTC monotonic vblank counter. Single CRTC (id=1) in v1; + // a kernel-global AtomicU32 suffices. Multi-CRTC v2+ becomes + // a `BTreeMap<u32, AtomicU32>`. + let seq = crate::dri::vblank_seq::tick(1 /* crtc_id */); + // Lock order: PROCESS_TABLE (OFDs live inside it per plan 3 A3's + // `pt.ofds.entries` shape). HOST_IO must not be held — we never + // call into the host inside this loop. The lock window is + // O(open card0 OFDs) which is typically 1–3 — microseconds at + // 60 Hz tick rate, well below syscall-latency floor. (If + // profiling under Phase C shows vblank-aligned syscall jitter, + // the architecture-open "split OFD table out of PROCESS_TABLE" + // resolution kicks in.) + let mut woken: Vec<usize> = Vec::new(); + { + let mut pt = PROCESS_TABLE.lock(); + for (idx, slot) in pt.ofds.entries.iter_mut().enumerate() { + let Some(ofd) = slot.as_mut() else { continue; }; + let Some(kms) = ofd.kms.as_mut() else { continue; }; + if !kms.holds_master { continue; } + if kms.pending_flips.is_empty() { continue; } + for flip in core::mem::take(&mut kms.pending_flips) { + let ev = WpkDrmEventVblank { + ev_type: DRM_EVENT_FLIP_COMPLETE, + length: core::mem::size_of::<WpkDrmEventVblank>() as u32, + user_data: flip.user_data, + tv_sec: (now / 1_000_000) as u32, + tv_usec: (now % 1_000_000) as u32, + sequence: seq, + crtc_id: flip.crtc_id, + }; + push_event_record(&mut kms.event_ring, &ev); + } + woken.push(idx); + } + } + // Two distinct wait-queues (see architecture-open "wake_all + // overshoots"): + // - event-ring readers wake only when their ring grew; per-OFD. + // - WAIT_VBLANK callers wake on every tick; broadcast. + for idx in woken { + crate::dri::vblank_wait::wake_event_reader(idx); + } + crate::dri::vblank_wait::tick_wake_all(); +} +``` + +**Cargo tests:** stub-call `kernel_vblank()` directly; assert event +ring length = pending_flips count beforehand; assert pending_flips is +empty after; assert the record bytes round-trip via `drmHandleEvent` +in the C demo (Task C3). + +**Commit:** `kernel(dri): kernel_vblank export — drain pending flips into event ring` + +--- + +### Task A8: `read(/dev/dri/card0)` returns event records; `poll/select` semantics + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — `sys_read` for OFDs with + `kms: Some(_)` drains the event ring. + +```rust +// In sys_read, before the host-handle fast path, for VirtualDevice::DriCard0: +if let Some(kms) = ofd.kms.as_mut() { + if kms.event_ring.is_empty() { + // Block in asyncify on the per-OFD event-reader queue; + // resumed when `kernel_vblank()` pushes a record onto + // *this* OFD's ring (Task A7's `wake_event_reader(idx)`). + // Note: this is the narrow wake; the broad + // `tick_wake_all()` resolves `WAIT_VBLANK` callers instead. + crate::dri::vblank_wait::block_event_reader(ofd_idx)?; + } + // Drain whole records — DRM ABI: never partial records. + let mut written = 0; + while let Some(record_len) = peek_next_record_len(&kms.event_ring) { + if written + record_len > user_buf.len() { break; } + for _ in 0..record_len { + user_buf[written] = kms.event_ring.pop_front().unwrap(); + written += 1; + } + } + return Ok(written); +} +``` + +`poll(card0, POLLIN)` returns ready iff `!event_ring.is_empty()`; +straightforward extension of the existing pollable-fd dispatcher. + +**Cargo tests:** assert `read` returns exactly one 24-byte record per +queued flip; assert `read` blocks if no events; assert `kernel_vblank` +unblocks the reader. + +**Commit:** `kernel(dri): read(card0) drains event ring + poll(POLLIN) on events` + +--- + +### Task A9: `DRM_IOCTL_WAIT_VBLANK` — synchronous wait + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — branch in + `handle_dri_card_ioctl()`. + +```rust +DRM_IOCTL_WAIT_VBLANK => { + let req: WpkDrmWaitVblankRequest = read_struct(buf)?; + // _DRM_VBLANK_RELATIVE | _DRM_VBLANK_SECONDARY etc. — v1 supports + // RELATIVE only; sequence is the count of vblanks to wait. + let to_wait = req.sequence.max(1); + for _ in 0..to_wait { + // Broadcast tick queue — every kernel_vblank tick wakes + // every WAIT_VBLANK caller, regardless of master / OFD. + // Distinct from the per-OFD `block_event_reader` used by + // `read(card0)`. + crate::dri::vblank_wait::block_for_next_tick()?; + } + let now = crate::time::monotonic_us(); + let reply = WpkDrmWaitVblankReply { + rep_type: req.req_type, + sequence: 0, // we don't track a real sequence; v1 stub + tv_sec: (now / 1_000_000) as u32, + tv_usec: (now % 1_000_000) as u32, + }; + write_struct(buf, &reply)?; + Ok(()) +} +``` + +The `vblank_wait` module exposes a thread-park primitive resumed by +`kernel_vblank()` (Task A7). It is a sibling of the existing +asyncify-park primitives (no new asyncify save slot needed — reuses +the existing one). **Verify the ASYNCIFY_SAVE_SLOTS constant isn't +touched** — that's a load-bearing ABI value. + +**Cargo:** test `WAIT_VBLANK` blocks until next `kernel_vblank()` call. + +**Commit:** `kernel(dri): DRM_IOCTL_WAIT_VBLANK + per-OFD vblank wait queue` + +--- + +### Task A10: Cleanup — close / exit / execve revokes master + drops fbs + +**Files:** +- Modify: `crates/kernel/src/ofd.rs` — extend the + `on_final_close` hook plan 3 A5 introduced. + +```rust +impl OpenFileDesc { + pub fn on_final_close(&mut self, pid: i32, host_io: &mut dyn HostIO, + ofd_idx: usize) + { + // … plan 2's prime_bo cleanup … + // … plan 3's dri.handles + dri.gl.bindings cleanup … + if let Some(kms) = self.kms.take() { + // Revoke DRM_MASTER if held (race-free: ofd_idx is the + // key). pid passed here is the *closer's* pid, not the + // original master's — fork-inherited card0 OFDs make + // that gap visible. Host uses pid for log attribution + // only; `KmsRegistry.dropMaster()` is global. Documented + // in the host-API B3 callback contract. + if crate::dri::master::drop_master(ofd_idx) { + host_io.kms_drop_master(pid); + } + // Drop fb_id → bo bindings, releasing the bo pin each + // fb held (plan 2 refcount semantics). Order matters: + // decref the bo BEFORE notifying the host so the host + // sees the rmfb after the bo refcount has dropped (a + // host-side fb that still references the bo can pick + // its own destroy ordering). + for (fb_id, fb) in kms.fbs { + crate::dri::with_registry(|r| r.decref(fb.bo_id)); + host_io.kms_rmfb(pid, fb_id); + } + // pending_flips drop silently — Linux has no + // flip-aborted event. event_ring is GC'd with the OFD. + } + } +} +``` + +execve: CLOEXEC drops the fd → on_final_close runs (if last ref) → +master released. + +fork: OFD is shared by ref; no special handling — child inheriting the +card0 OFD also inherits master access (Linux allows this; child must +DROP_MASTER explicitly if parent wants to revoke). + +**Cargo tests:** master holder exec → master released; fork-shared +master OFD → final-close releases. + +**Commit:** `kernel(dri): on_final_close releases master + drops fbs` + +--- + +### Task A11: ABI snapshot regen (additive) + +**Files:** +- Modify: `abi/snapshot.json` (auto-generated). +- DO NOT modify: `ABI_VERSION` (additive). + +Expected diff: new entries for each of the KMS structs + ioctl +numbers + 5 new `host_kms_*` imports + the new `kernel_vblank` export. +**No** changes to any existing row. + +```bash +bash scripts/check-abi-version.sh update +git diff abi/snapshot.json +bash scripts/check-abi-version.sh +git add abi/snapshot.json +git commit -m "kernel(dri): regen ABI snapshot — additive KMS surface" +``` + +--- + +### Task A12: Phase A — full gauntlet + open PR #1 + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +Push, open draft PR. + +Title: `[explore-dri] kernel(dri): /dev/dri/card0 + KMS ioctls + DRM_MASTER + vblank ring` + +Body (Brandon style): + +```markdown +## Summary +- Add `/dev/dri/card0` as a second DRI device node (sibling of plan 2's + `/dev/dri/renderD128`). +- Implement the KMS ioctl subset SDL2 + weston need: `SET_MASTER`, + `DROP_MASTER`, `MODE_GETRESOURCES`, `GETCONNECTOR`, `GETENCODER`, + `GETCRTC`, `SETCRTC`, `ADDFB2`, `RMFB`, `PAGE_FLIP`, `WAIT_VBLANK`. +- `kernel_vblank()` export — host fires this on every vblank tick; + kernel posts `DRM_EVENT_FLIP_COMPLETE` into the master's per-OFD + event ring, drained by `read(card0)`. +- `DRM_MASTER` ownership: at most one OFD across the kernel holds it; + `on_final_close` revokes. Plan 3's `COMPOSITOR_PRI` host-side lookup + will rewire to "master holder pid" in PR #2 (host). + +## Why +Plan 4 of the DRI v2 design (`docs/plans/2026-05-18-dri-design.md` §6) +— the modesetting + page-flip surface, prerequisite for the +compositor (plans 8–9) and for SDL2's `KMSDRM` backend (plan 7, +milestone D). + +## Verification +- `cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib` +- `cd host && npx vitest run` +- `scripts/run-libc-tests.sh` +- `scripts/run-posix-tests.sh` +- `bash scripts/check-abi-version.sh` + +## ABI impact +Additive only — no `ABI_VERSION` bump. New `repr(C)` structs in +`shared::dri`, new ioctl numbers in the existing DRM `'d'` magic +(verbatim Linux UAPI numbers), 5 new `host_kms_*` imports, 1 new +`kernel_vblank` export. No existing surface changes. + +## Notes +- Render-node-only ioctls (`PRIME_*`, `MODE_*DUMB*`) pass through + from `card0` to `renderD128`'s dispatcher; KMS ioctls on + renderD128 return EOPNOTSUPP. +- Vblank tick is host-driven (PR #2); kernel only knows "tick + happened, drain pending flips." +- DRM_MASTER → SubmitQueue priority swap lands in PR #2 — see + plan 3's COMPOSITOR_PRI for the prior hardcoded shape. +``` + +**Do not merge.** + +--- + +## Phase B — host: `KmsRegistry` + vblank tick + master-driven SubmitQueue priority (PR #2) + +### Task B1: `KmsRegistry` module + +**Files:** +- Create: `host/src/dri/kms-registry.ts`. + +```ts +// host/src/dri/kms-registry.ts +import type { GbmRegistry } from './gbm-registry'; + +export interface HostFb { + fb_id: number; + bo_id: number; + width: number; + height: number; + pixel_format: number; + stride: number; +} + +export class KmsRegistry { + private fbs = new Map<number /* fb_id */, HostFb>(); + private crtcBindings = new Map<number /* crtc_id */, number /* fb_id */>(); + private masterPid: number | null = null; + + constructor(private gbm: GbmRegistry) {} + + addFb(fb: HostFb): void { this.fbs.set(fb.fb_id, fb); } + rmFb(fb_id: number): void { + this.fbs.delete(fb_id); + // Detach from any CRTC bound to this fb. + for (const [crtc, bound] of this.crtcBindings) { + if (bound === fb_id) this.crtcBindings.set(crtc, 0); + } + } + setFb(crtc_id: number, fb_id: number): void { + this.crtcBindings.set(crtc_id, fb_id); + } + currentFb(crtc_id: number): HostFb | undefined { + const id = this.crtcBindings.get(crtc_id) ?? 0; + return id === 0 ? undefined : this.fbs.get(id); + } + + setMasterPid(pid: number): void { this.masterPid = pid; } + dropMaster(): void { this.masterPid = null; } + /** True if `pid` holds DRM_MASTER on card0 right now. */ + isMasterPid(pid: number): boolean { return this.masterPid === pid; } +} +``` + +**Vitest:** `addFb` / `rmFb` / `setFb` lifecycle, `isMasterPid` +post-set/drop. + +**Commit:** `host(dri): KmsRegistry — fb table + crtc bindings + master tracking` + +--- + +### Task B2: Vblank tick — RAF in browser, setInterval in Node + +**Files:** +- Create: `host/src/dri/vblank.ts`. +- Modify: `examples/browser/lib/browser-kernel.ts` — wire RAF + (kernel-worker if supported, else main-thread routed via + postMessage). +- Modify: `host/src/node-kernel-host.ts` — wire setInterval. + +```ts +// host/src/dri/vblank.ts +export interface VblankSource { + start(onTick: () => void): void; + stop(): void; +} + +export class NodeVblankSource implements VblankSource { + private timer: NodeJS.Timeout | null = null; + start(onTick: () => void) { + this.timer = setInterval(onTick, 1000 / 60); // 16.67 ms + } + stop() { if (this.timer) clearInterval(this.timer); this.timer = null; } +} + +export class WorkerRafVblankSource implements VblankSource { + // Use `requestAnimationFrame` inside the kernel-worker if available + // (OffscreenCanvas-backed workers support it on recent Chrome/Edge). + // Otherwise, fall back to receiving 'raf' messages from the main + // thread via postMessage. + start(onTick: () => void) { + if (typeof requestAnimationFrame === 'function') { + const loop = () => { onTick(); requestAnimationFrame(loop); }; + requestAnimationFrame(loop); + } else { + addEventListener('message', (e: MessageEvent) => { + if (e.data?.type === 'raf') onTick(); + }); + postMessage({ type: 'raf-subscribe' }); + } + } + stop() { /* …unsubscribe… */ } +} +``` + +The driver of `onTick` in both cases is: + +```ts +function onVblankTick() { + // Present the current fb on each CRTC's canvas. + for (const crtc of [/* 1 */ ]) { + const fb = kmsRegistry.currentFb(crtc); + if (fb) presentFbToCanvas(fb, /* via WebGL2 blit or 2D putImageData */); + } + // Tell the kernel a vblank happened so it can drain pending flips. + kernel.exports.kernel_vblank(); +} +``` + +**Dual-host parity (CLAUDE.md):** both Node and browser entries must +attach a `VblankSource` at host init. Symmetry check before commit. + +**Vitest:** fake-timer drives `NodeVblankSource.start(...)`; assert +`onTick` called N times after N ticks of fake timer. + +**Commit:** `host(dri): vblank tick — RAF (browser) + setInterval (Node)` + +--- + +### Task B3: Wire `host_kms_*` imports + +**Files:** +- Modify: `host/src/kernel.ts` — register the 5 new callbacks. +- Modify: `host/src/kernel-worker.ts` — forward to `KmsRegistry`. + +Trivial plumbing once B1 + B2 are in. Pattern mirrors plan 2's +`host_gbm_*` wiring and plan 3's `host_gl_*` extension. + +**Dual-host parity:** wire in both `host/src/node-kernel-worker-entry.ts` +and `examples/browser/lib/kernel-worker-entry.ts`. Symmetry check +mandatory. + +**Commit:** `host(dri): wire host_kms_* imports — kernel-worker → KmsRegistry (dual-host)` + +--- + +### Task B4: `SubmitQueue.COMPOSITOR_PRI` ← `KmsRegistry.isMasterPid` + +**Files:** +- Modify: `host/src/webgl/submit-queue.ts`. + +Plan 3 hardcoded `COMPOSITOR_PID = 2`. This task replaces the lookup +with a callable supplied at queue construction: + +```ts +// host/src/webgl/submit-queue.ts +export class SubmitQueue { + constructor(private isCompositor: (pid: number) => boolean) {} + + enqueue(binding: GlBinding, frame: SubmitFrame): void { + const key = `${binding.pid}:${binding.ctx_id}`; + let entry = this.byKey.get(key); + if (!entry) { + entry = { key, binding, frames: [] }; + this.byKey.set(key, entry); + (this.isCompositor(binding.pid) ? this.compositor : this.clients) + .push(entry); + } + entry.frames.push(frame); + } + // … pickNext / releaseIfEmpty unchanged … +} +``` + +The `releaseIfEmpty` also gets the same callable: + +```ts +releaseIfEmpty(entry: QueueEntry): void { + if (entry.frames.length > 0) return; + this.byKey.delete(entry.key); + const lane = this.isCompositor(entry.binding.pid) ? this.compositor + : this.clients; + const i = lane.indexOf(entry); + if (i >= 0) lane.splice(i, 1); +} +``` + +Kernel-worker construction passes `(pid) => kmsRegistry.isMasterPid(pid)`. + +Backwards-compat note: a process can hold COMPOSITOR_PRI without +having taken DRM_MASTER yet (race between +`SubmitQueue.enqueue(pid=2)` and the master being taken). Two +acceptable outcomes: +- (a) Master-promotion-after-the-fact: the SubmitQueue **does not + re-classify** already-enqueued entries when master changes. The + entry stays in whichever lane it was put in at enqueue time; the + *next* enqueue from that pid is classified per the *new* master. + Pragmatic; matches plan 3's enqueue-once shape. +- (b) Reclassification on master change: when `isMasterPid` flips for + a pid, walk the queue and move entries between lanes. Stricter, but + requires the queue to expose a re-bucket API and `KmsRegistry` to + call it on every master transition. + +*Lean: (a).* The host's typical pattern is: compositor takes master +at boot before any client submits. (b) is a future optimisation if +profiling shows it. + +**Vitest:** new test in `host/test/dri-multiplex.spec.ts` replaces +the plan 3 "fake compositor pid 2" assertion with "process holds +master → COMPOSITOR_PRI; drops → CLIENT_PRI"; plan 3's prior +PID-2 assertion is updated to use a `KmsRegistry` stub that returns +true for the fake compositor's pid. + +**Commit:** `host(dri): SubmitQueue priority via KmsRegistry.isMasterPid (replaces plan 3 pid==2 hardcode)` + +--- + +### Task B5: Vitest — page-flip → vblank event round-trip + +**Files:** +- Create: `host/test/dri-kms-pageflip.spec.ts`. + +Setup: +- Open card0 from a fixture process, SET_MASTER. +- Open renderD128, CREATE_DUMB a 256×256 bo, write a colour. +- ADDFB2 wrapping that bo → fb_id. +- SETCRTC(crtc=1, fb=fb_id, mode=…) — host's canvas now shows the + pixels. +- PAGE_FLIP(crtc=1, fb=fb_id, user_data=0xdeadbeef). +- Drive one fake vblank tick: `kernel.exports.kernel_vblank()`. +- `read(card0, buf, 32)` → assert: 32 bytes returned, parses as + `DRM_EVENT_FLIP_COMPLETE`, `length == 32`, `user_data == 0xdeadbeef`, + `sequence == 1` (first tick on crtc=1), `crtc_id == 1`. + +**Commit:** `host(dri): vitest — page-flip → vblank event round-trip` + +--- + +### Task B6: Phase B — full gauntlet + open PR #2 + +Push, open draft PR. + +Title: `[explore-dri] host(dri): KmsRegistry + vblank tick (RAF/setInterval) + master-driven SubmitQueue priority` + +Body: Summary / Why / Verification / **Dual-host parity proof** (both +Node and browser kernel-worker entries wire VblankSource + KmsRegistry ++ rewired SubmitQueue priority; symmetry verified before commit) / +Notes (plan 3's PID-2 hardcode is removed; compositor demos that +relied on it must now SET_MASTER on card0 first). + +--- + +## Phase C — sysroot + demo + browser (PR #3) + +### Task C1: `libdrm.a` stub — KMS surface + +**Files:** +- Modify: `glue/libdrm_stub.c` (~+200 LoC). + +Add: `drmModeGetResources`, `drmModeFreeResources`, `drmModeGetCrtc`, +`drmModeFreeCrtc`, `drmModeSetCrtc`, `drmModeGetConnector`, +`drmModeFreeConnector`, `drmModeGetEncoder`, `drmModeFreeEncoder`, +`drmModeAddFB2`, `drmModeRmFB`, `drmModePageFlip`, `drmModeWaitVBlank`, +`drmHandleEvent` (event dispatcher: `read(fd)` + parse + call +user-supplied `page_flip_handler` callback). + +Each is a thin libc-style wrapper around `drmIoctl` (already exists +from plan 2 C2) — allocate caller-side buffers, populate ptrs, call +ioctl, transcribe results into the libdrm-shape return structs. +`drmHandleEvent` reads 32-byte records in a loop (matching our +`WpkDrmEventVblank`, which mirrors upstream `drm_event_vblank` +exactly) and dispatches by `ev_type`: `DRM_EVENT_VBLANK` → +`vblank_handler`, `DRM_EVENT_FLIP_COMPLETE` → `page_flip_handler` if +ctx.version == 2 else `page_flip_handler2` if version ≥ 3 (passes +`crtc_id` as a sixth argument — that's the field SDL2's KMSDRM +backend reads). + +**Commit:** `sysroot(dri): libdrm stub — KMS subset (GetResources, AddFB2, SetCrtc, PageFlip, HandleEvent, WaitVBlank)` + +--- + +### Task C2: `programs/modeset.c` — minimal modeset demo + +**Files:** +- Create: `programs/modeset.c`. + +```c +// programs/modeset.c — ~150 LoC +// Opens card0, takes master, sets a mode, allocates a bo via libgbm +// on renderD128 (cross-fd via PRIME_HANDLE_TO_FD round-trip into +// card0's handle namespace), wraps as fb via ADDFB2, SETCRTC, then +// page-flips a 60-FPS counter for 5 seconds. exit 0 if no errors + +// at least 250 vblank events received. + +int main(void) { + int card = open("/dev/dri/card0", O_RDWR | O_CLOEXEC); + int render = open("/dev/dri/renderD128", O_RDWR | O_CLOEXEC); + drmSetMaster(card); + + drmModeRes *res = drmModeGetResources(card); + uint32_t crtc_id = res->crtcs[0]; + uint32_t connector_id = res->connectors[0]; + drmModeConnector *conn = drmModeGetConnector(card, connector_id); + drmModeModeInfo mode = conn->modes[0]; + + struct gbm_device *gbm = gbm_create_device(render); + struct gbm_bo *bo = gbm_bo_create(gbm, mode.hdisplay, mode.vdisplay, + GBM_FORMAT_XRGB8888, GBM_BO_USE_SCANOUT | GBM_BO_USE_LINEAR); + + // Cross-fd handle import: export from renderD128 then import to card0. + int prime = gbm_bo_get_fd(bo); + uint32_t card_handle; + { + struct drm_prime_handle p = { .fd = prime, .flags = 0 }; + drmIoctl(card, DRM_IOCTL_PRIME_FD_TO_HANDLE, &p); + card_handle = p.handle; + } + + uint32_t fb_id; + { + struct drm_mode_fb_cmd2 add = { + .width = mode.hdisplay, .height = mode.vdisplay, + .pixel_format = DRM_FORMAT_XRGB8888, + .handles = { card_handle, 0, 0, 0 }, + .pitches = { gbm_bo_get_stride(bo), 0, 0, 0 }, + .offsets = { 0, 0, 0, 0 }, + .modifier = { DRM_FORMAT_MOD_LINEAR, 0, 0, 0 }, + }; + drmIoctl(card, DRM_IOCTL_MODE_ADDFB2, &add); + fb_id = add.fb_id; + } + + drmModeSetCrtc(card, crtc_id, fb_id, 0, 0, &connector_id, 1, &mode); + + int events = 0; + for (int i = 0; i < 300 && events < 250; i++) { + // Draw a frame counter at top-left. + uint32_t *px = gbm_bo_map(bo, 0, 0, mode.hdisplay, mode.vdisplay, + 0, NULL, NULL, NULL); + draw_counter(px, gbm_bo_get_stride(bo)/4, i); + + drmModePageFlip(card, crtc_id, fb_id, DRM_MODE_PAGE_FLIP_EVENT, + (void*)(uintptr_t)i); + + // Wait for the event via drmHandleEvent. + drmEventContext ctx = { + .version = 2, + .page_flip_handler = on_flip, // increments `events` + }; + drmHandleEvent(card, &ctx); + } + + drmDropMaster(card); + return events >= 250 ? 0 : 1; +} +``` + +Build with `wasm32posix-cc -o programs/modeset.wasm programs/modeset.c +-lgbm -ldrm`. Wire into `scripts/build-programs.sh`. + +**Commit:** `examples(dri): modeset demo — page-flip + frame counter + vblank events` + +--- + +### Task C3: Vitest end-to-end + +**Files:** +- Create: `host/test/dri-modeset.spec.ts`. + +Runs `modeset.wasm` under the centralised kernel with a fake vblank +source (60 ticks/sec via fake timers). Asserts exit code 0 after +~5 simulated seconds; asserts the canvas's pixel buffer contains the +"frame counter at top-left" pattern at the expected sequence count. + +**Commit:** `host(dri): vitest — modeset end-to-end with fake vblank source` + +--- + +### Task C4: Manual browser verification (the gate) + +CLAUDE.md item 6. Build the demo, drop into +`examples/browser/pages/modeset/`. `./run.sh browser`, navigate, watch +the frame counter increment smoothly at 60 FPS for 5 seconds. If the +counter stalls or RAF doesn't fire inside the worker, fall back to +main-thread RAF routing (Task B2's else branch) and re-verify. + +**No commit yet for this task — verification only.** If the browser +demo fails but Node passes, that's a host-parity bug — PR #410 +cautionary tale. + +--- + +### Task C5: Phase C — final gauntlet + open PR #3 + +PR title: `[explore-dri] examples(dri): modeset demo + browser spec` + +Body: Summary / Why / Verification (gauntlet + browser screenshot of +the counter) / Dual-host parity proof / Notes. + +--- + +## Final coordinated merge + +When all three PRs (kernel, host, examples) are reviewed and approved, +and Brandon has signed off on the demo running cleanly in browser + +Node: + +1. Re-run the full gauntlet on each PR's branch tip. +2. Squash-merge PR #1 → PR #2's base. +3. Squash-merge PR #2 → PR #3's base. +4. Squash-merge PR #3 → plan 3's `…-mux-demo` (or wherever plan 3's + tip lives at the time). +5. Tag: `[explore-dri-kms] milestone (KMS) merged at <sha>` in the + next session-handoff doc. + +**Do not push to upstream until v1 + plan 2 + plan 3 + plan 4 are +all merged upstream as a coherent chain.** + +--- + +## Trade-offs already locked in (don't relitigate during implementation) + +- **Single CRTC, single connector, single mode** (design §6.1). + Multi-CRTC / multi-monitor is a post-v1 redesign (one canvas per + CRTC, host-level fan-out). The single-mode constraint matches the + fact that the OffscreenCanvas dimensions are fixed at attachment. +- **`DRM_MASTER` is the COMPOSITOR_PRI source** (design §5.4 + §6.3). + Plan 3's PID-2 hardcode is removed in Task B4. A process that + doesn't take master on card0 cannot get head-of-queue scheduling + on the multiplexer — by design. +- **Vblank cadence is host-driven** (design §16 q3). RAF (browser) or + 16.67 ms setInterval (Node). The kernel does not have its own + timer; it learns "tick happened" via the `kernel_vblank` export. +- **Legacy modeset only** (no `DRM_IOCTL_MODE_ATOMIC`). Atomic + modesetting is a v2 feature for plane composition + commit phases; + the demo + SDL2's `KMSDRM` backend use legacy SETCRTC + PAGE_FLIP. + Atomic is post-v1. +- **One in-flight page-flip per CRTC per OFD** (matches Linux + EBUSY-on-double-flip behaviour). A compositor that wants more + in-flight (triple-buffered) submits via different fbs; the throttle + is per (crtc, OFD), not per fb. +- **Cross-fd bo handle import**: plan 2's `PRIME_FD_TO_HANDLE` round- + trip is the only way to make a renderD128-allocated bo addressable + from a card0 fd. Linux Mesa drivers do the same; SDL2's KMSDRM + backend does too. No need for a side-channel "handle pool" + abstraction. +- **`MODE_ADDFB2` accepts both `BoTier::CpuShared` and `BoTier::Gpu` + bos.** GPU-tier bos are valid scanout targets (the compositor's + primary use case). The `pitches[0]` check is skipped for + GPU-tier (host knows the texture's row pitch). +- **No GAMMA / no cursor plane / no overlay plane** in v1. SDL2's + KMSDRM backend doesn't require them; the wpkcompositor (plan 9) + may want them later. +- **Master is per-OFD, not per-Process.** Fork-inherits via OFD ref + bump (matches Linux). The child can DROP_MASTER independently of + the parent's wishes (also Linux-shape). + +--- + +## Risk register + +1. **`requestAnimationFrame` in workers is patchy across browsers.** + Chrome ≥ 88 supports it on OffscreenCanvas-backed workers; Firefox + support is partial as of 2024. The fallback is main-thread RAF + routed via postMessage. Run Task B2 first thing in Phase B; gate + the demo's frame budget on the actual cross-browser jitter. If + postMessage-routed RAF turns out to be too jittery (>5 ms p99 + beyond 16.67 ms), the demo may need a tolerance band in its + "events ≥ 250 in 300 frames" assertion. +2. **`drm_event_vblank` record layout mismatch with Linux.** The + 24-byte size assumed here may not match upstream (Linux is + typically 32 bytes with `sequence` + `crtc_id`). The C demo + + libdrm `drmHandleEvent` parser must agree with the kernel's + record producer; if the upstream layout is wider, update both + sides in lockstep (record size determines the per-record advance + in the ring read). Task A1 Step 2 must assert the exact size to + prevent silent drift. +3. **DRM_MASTER race between SubmitQueue enqueue and master change.** + See Task B4's (a) vs (b) trade-off — picked (a) for simplicity, + means a process briefly classified as compositor-pri-but-not-yet- + master gets a one-frame head-of-queue benefit. Acceptable. +4. **`MODE_GETCRTC` / `WpkDrmModeGetCrtc` size mismatch with Linux.** + Marked **verify** in the struct definition — if `repr(C)` produces + 104 bytes on wasm32 (Linux is 96), encode the ioctl as + `0xc068_64a1` instead of `0xc060_64a1`. Document the wasm32 + deviation in `docs/architecture.md` § DRM section once Task A1 + lands. +5. **Pending-flip queue depth = 1 means a hot compositor that page- + flips every frame can starve `EBUSY`-returning clients.** Plan 3's + "no preemption mid-cmdbuf" risk is the analogous case for GL + submits; plan 4's analog for KMS is "no flip-batching". v2-level + compositor (plan 9) may need to bump queue depth to 2 (front + + pending) per CRTC. +6. **Cross-fd handle import lock order.** ADDFB2 on card0 dereferences + a handle issued via PRIME_FD_TO_HANDLE on card0 (which itself + referenced a bo created on renderD128). The lock dance is: + `PROCESS_TABLE` → OFD card0 lookup → release → `BoRegistry`. Same + policy as plan 2 + plan 3; verify the ADDFB2 path doesn't violate + "never hold two of {BoRegistry, HOST_IO, PROCESS_TABLE}". + +--- + +## What this plan doesn't cover (deferred) + +- **Atomic modesetting** (`DRM_IOCTL_MODE_ATOMIC`). v2+. +- **Multi-CRTC / multi-monitor** (one canvas per CRTC). v2+. +- **Cursor / overlay planes** (`MODE_GETPLANE`, + `DRM_IOCTL_MODE_CURSOR2`). v2+ — compositor may want them. +- **GAMMA / colour calibration** (`DRM_IOCTL_MODE_GAMMA_GET/SET`). + Out of scope. +- **Hot-plug** (uevent). The single connector is always present from + the kernel's POV; no hot-plug events. +- **`DRM_IOCTL_MODE_CREATE_BLOB`** (for atomic-modeset prop values). + Tied to atomic. Out of scope. +- **`MODE_DESTROYDUMB` on a fb-bound bo**. We allow the destroy to + succeed silently; the fb keeps the bo alive via the refcount until + RMFB. (Matches plan 2's refcount semantics.) +- **evdev** (`/dev/input/event*`). Plan 5. +- **ALSA** (`/dev/snd/*`). Plan 6. +- **SDL2 port** (milestone D, plan 7) — requires plans 3 + 4 + 5 + 6. +- **wpkcompositor** (plans 8-9) — the PID-2-or-whoever-has-master + binary itself. + +--- + +End of plan. diff --git a/docs/plans/2026-06-15-dri-evdev-plan.md b/docs/plans/2026-06-15-dri-evdev-plan.md new file mode 100644 index 0000000000..6be266c0c5 --- /dev/null +++ b/docs/plans/2026-06-15-dri-evdev-plan.md @@ -0,0 +1,2238 @@ +# DRI v2 — evdev plan (`/dev/input/event{0,1}`) + +> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to execute this plan task-by-task. + +**Goal:** Add the two evdev device nodes the SDL2 + libinput stacks +look for first — `/dev/input/event0` (keyboard) and `/dev/input/event1` +(pointer / mouse) — exposing Linux's evdev UAPI: a `read(fd)`-able +stream of `struct input_event` records, the `EVIOCG*` introspection +ioctls (`EVIOCGNAME`, `EVIOCGID`, `EVIOCGBIT`, `EVIOCGABS`, `EVIOCGRAB`), +and `poll(POLLIN)` readiness. Records are produced from the host's +browser-side DOM event capture (`KeyboardEvent`, `PointerEvent`) — the +kernel never invents events; it only multicasts them onto every open +event-fd's per-OFD ring. The plan ships the **device + ring + record +producer** only; focus routing ("only the focused client sees the +keystroke") is deferred to plan 9 (wpkcompositor), which will gate +event delivery via `EVIOCGRAB` on a master-equivalent ownership model. +Plan 5 v1: every open event-fd gets every event. + +**Architecture:** Two virtual devices, sourcing records from a single +host-side `InputSource` per input class (one keyboard, one pointer). +`/dev/input/event0` exposes keyboard `EV_KEY` + `EV_SYN`; `event1` +exposes pointer `EV_REL` (deltas) + `EV_KEY` (button) + `EV_ABS` +(absolute position for the pointer-lock-disabled case) + `EV_SYN`. +Per-OFD `InputFdState` carries a `VecDeque<u8>` event ring + a +`grabbed: bool` flag. A new kernel export `kernel_input_event(device, +ev_type, code, value)` is the single entrypoint — the host calls it +once per DOM event after translating browser-key-codes to evdev `KEY_*` +and pointer fields to `REL_X` / `REL_Y` / `BTN_LEFT` / etc. The kernel +walks every OFD opened on the matching `/dev/input/event*` node, +appends the 24-byte record, follows it with an `EV_SYN` (`SYN_REPORT`) +record at end-of-logical-event, and wakes any blocked-on-read OFD. +Companion design doc: `docs/plans/2026-05-18-dri-design.md` §7 (evdev) ++ §16 q6 (`CLOCK_MONOTONIC` timestamps). + +**Tech Stack:** Rust kernel (wasm64), TypeScript host (browser +DOM event capture; Node = null-source), C user programs cross-compiled +with `wasm32posix-cc`. Sysroot side reuses musl's `<linux/input.h>` + +`<linux/input-event-codes.h>` headers; no library stub (libevdev is +not pulled in — apps that want it port their own; SDL2's evdev backend +talks to the device nodes directly via syscalls + ioctls). + +**Critical wasm32 ABI detail — `struct input_event` is 24 bytes, not +16.** musl on wasm32 ships `time_t = int64_t` (Y2K38-safe; the same +choice every modern Linux distro made for 32-bit userland). That makes +`struct timeval` 12 bytes natural but 16 bytes with internal alignment +padding (`int64_t tv_sec` forces 8-byte alignment of the struct; +trailing `int32_t tv_usec` is padded out by 4 bytes). Adding +`__u16 type + __u16 code + __s32 value` (8 bytes, naturally 4-byte +aligned, fits into the 8 bytes after the 16-byte timeval) yields a +**24-byte record**. Linux's 32-bit-with-32-bit-time_t userland used +16 bytes; Linux's 64-bit userland uses 24 bytes. We match the +64-bit-userland layout exactly, which is also what musl wasm32 chose. +This is **load-bearing**: the kernel writes 24-byte records, the +sysroot reads 24-byte records, and the static-assert in Task A1 Step 2 +catches drift. + +**Clock source: `CLOCK_MONOTONIC`** (design §16 q6). evdev's default +on real Linux is also `CLOCK_MONOTONIC` (the kernel-side default +since v3.4; userland can opt into `CLOCK_REALTIME` via +`EVIOCSCLOCKID`, but we don't expose `EVIOCSCLOCKID` — clock is +fixed). Same monotonic helper plan 4 A7's `kernel_vblank` uses +(`crate::time::monotonic_us()`) — keeps evdev timestamps comparable +with vblank event timestamps for jitter/latency profiling. + +**Design reference:** `docs/plans/2026-05-18-dri-design.md` §7 +(`/dev/input/event*`, seat punt) + §16 q6 (clock source). POSIX vs +Linux UAPI: `open` / `close` / `read` / `poll` / `ioctl` are POSIX; +`struct input_event` layout + `EV_*` / `KEY_*` / `BTN_*` / `REL_*` / +`ABS_*` / `SYN_*` codes + `EVIOCG*` ioctl numbers are Linux UAPI, +followed strictly (modulo the wasm32-vs-32-bit-Linux 24-vs-16-byte +record size — we match the 64-bit-userland shape). + +**Consistency with plans 2 + 3 + 4:** +- Plan 4 introduced `VirtualDevice::DriCard0` + `OpenFileKind::DriCard` + alongside plan 2's `DriRender0` / `DriRender`. Plan 5 adds + `VirtualDevice::InputEvent { device: u8 /* 0 = kbd, 1 = ptr */ }` + + `OpenFileKind::InputEvent { device: u8 }`. Single + enum-variant-with-payload keeps `match_virtual_device` clean. +- Plan 4 lifted card0's per-fd state into the consolidated + `Option<Box<DriOfdState>>` enum (`PrimeBo` / `RenderNode` / `Card`). + Plan 5 does **not** join that enum — input event-fds have no + relation to DRI bo state; introducing `InputEvent { input: + InputFdState }` as a fourth variant would conflate two + load-bearing-but-disjoint state machines. Plan 5 adds a separate + `input: Option<Box<InputFdState>>` field to `OpenFileDesc`, + parallel to `dri_state` (not nested under it). +- Plan 4 A7's `kernel_vblank` taught the kernel about host-driven + ticks. Plan 5 A4's `kernel_input_event` is the same shape: an + additive kernel export, host calls it on browser events, kernel + fans out records to per-OFD rings and wakes blocked readers. No + `ASYNCIFY_SAVE_SLOTS` change. Plan 5 reuses plan 4's wait-queue + pattern (`crate::input::wait::{block_event_reader, wake_event_reader}`) + — same primitive, separate queue (no false wakeups between + vblank and input). +- Plan 4 noted that `CLOCK_MONOTONIC` (`monotonic_us()`) is the + single time source for host-driven events. Plan 5 follows: every + `struct input_event.time` is filled from + `crate::time::monotonic_us()` at record-push time. + +**Stack base:** Plan 4's `…-kms-demo` branch tip. Plan 5 extends the +kernel + host without touching plans 2–4's surfaces; the dispatcher +adds an `InputEvent` arm to `VirtualDevice`, the syscall layer adds +an `input::*` module, and the host gains an `input/` subdirectory +mirror of `dri/`. No regressions to plans 2–4 tests. + +**Branch:** `emdash/explore-direct-rendering-infrastructure-evdev-plan-XXXXX` +(chains off plan 4 per the branching rule). Three sub-branches stack +off it. + +**Final PR base:** Plan 4's `…-kms-demo` tip. Do not merge until +Brandon validates the design, plan 4 lands, and Phase C's manual +browser verification passes (CLAUDE.md item 6). + +**Three PRs, coordinated merge.** Each task below is one commit. +Brandon's `scope(area): action` titles: + +1. `kernel(input): /dev/input/event{0,1} + EVIOCG* + input_event ring` +2. `host(input): InputSource (browser DOM capture, Node null-source) + plumbing` +3. `examples(input): evdev_demo + browser spec` + +PR base/head topology (stacked): + +``` +explore-webgl-exposition-demo (v1 tip) + └── …-buffer-plan-XXXXX (plan 2 PR base) + └── …-buffer-kernel (plan 2 PR #1) + └── …-buffer-host (plan 2 PR #2) + └── …-buffer-demo (plan 2 PR #3) + └── …-multiplexer-plan-YYYYY (plan 3 PR base) + └── …-mux-kernel (plan 3 PR #1) + └── …-mux-host (plan 3 PR #2) + └── …-mux-demo (plan 3 PR #3) + └── …-kms-plan-ZZZZZ (plan 4 PR base) + └── …-kms-kernel (plan 4 PR #1) + └── …-kms-host (plan 4 PR #2) + └── …-kms-demo (plan 4 PR #3) + └── …-evdev-plan-WWWWW (this plan PR base) + └── …-evdev-kernel (PR #1) + └── …-evdev-host (PR #2) + └── …-evdev-demo (PR #3) +``` + +**Verification gauntlet** (CLAUDE.md): all of the below must pass +with zero regressions before any PR is opened, and re-run before final +merge: + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +`XFAIL` / `TIME` are acceptable; `FAIL` that isn't pre-existing is a +regression. Phase C adds manual `./run.sh browser` verification of the +evdev demo (CLAUDE.md item 6) — typed keystrokes + mouse moves appear +in the demo's on-canvas log. + +**ABI impact:** **Additive only — no `ABI_VERSION` bump.** Per +`docs/abi-versioning.md` (PR #490 policy): +- New `repr(C)` struct in `shared::input` for the record: + `WpkInputEvent` (24 bytes — exact wasm32 layout: `int64_t tv_sec + + int32_t tv_usec + 4-byte pad + u16 type + u16 code + s32 value`). + Sized-asserted in Task A1 Step 2. +- New ioctl numbers — verbatim Linux UAPI for the `'E'` magic (we do + **not** prefix with `WPK_`): + - `EVIOCGVERSION = _IOR('E', 0x01, int)` → `0x8004_4501` + - `EVIOCGID = _IOR('E', 0x02, WpkInputId)` → `0x8008_4502` + - `EVIOCGNAME(len) = _IOC(_IOC_READ, 'E', 0x06, len)` → variable + (encoding length depends on caller-supplied buffer; we accept + `len ∈ [1, 256]`, EINVAL otherwise) + - `EVIOCGBIT(ev_type, len) = _IOC(_IOC_READ, 'E', 0x20 + ev_type, + len)` → variable + - `EVIOCGABS(abs_axis) = _IOR('E', 0x40 + abs_axis, + WpkInputAbsinfo)` → `0x80...4540+axis` + - `EVIOCGRAB = _IOW('E', 0x90, int)` → `0x4004_4590` + - Sizes encoded in each constant are verified vs the wasm32 struct + `size_of` in Task A1 Step 2. +- New kernel-wasm export: `kernel_input_event(device: u32, ev_type: + u32, code: u32, value: i32)` — host fires this on every DOM event. + *(Additive export; does not change existing signatures.)* +- **No new `host_input_*` imports.** The kernel doesn't call into the + host for input — the host is the producer, kernel is the consumer. + Mirror-image of plan 4's KMS, where host imports outnumber exports. +- No change to v1 `host_gl_*`, plan 2 `host_gbm_*`, plan 3 + `host_gl_bind_foreign_texture`, plan 4 `host_kms_*`, any existing + struct layout, ioctl number, channel layout, syscall number, or + asyncify slot. + +Existing structs, ioctls, imports, and exports — all unchanged. + +--- + +## Pre-implementation review + +Devil's-advocate pass run in the next session after drafting; findings +below are structured Brandon-style. The five inline fixes were folded +back into the plan body in the same session; the open correctness + +open architecture items are load-bearing and must be picked before any +kernel code lands. One cross-plan note leaks back to plan 4 (`event_ring` +overflow protocol) — added to plan 4's review section. + +### Inline fixes (5 — folded into the plan body) + +- **`_pad: i32` at offset 12 is mandatory, not redundant.** The + pre-impl placeholder hedged ("`repr(C)` may already insert padding + making the explicit field redundant") and that hedging was + backwards. `repr(C)` follows C's natural-alignment rule and does + NOT insert *interior* padding between `tv_usec: i32` and `ev_type: + u16` (the u16's 2-byte alignment is satisfied at offset 12). + Without the explicit `_pad`, the Rust struct is still 24 bytes + (trailing padding bumps it to the i64's 8-byte alignment), but + `ev_type` sits at offset 12 — whereas userspace C reads it at + offset 16 because `struct timeval` on wasm32-musl is 16 bytes + (`int64_t tv_sec` forces the trailing `int32_t tv_usec` to be + padded to 16). The size matches; every offset past byte 11 + diverges. Silent corruption on every record. *Fixed: A1 Step 2's + cargo-test note rewritten to clarify the field-offset asserts are + the gate; the wording "may be redundant" is gone; `cargo expand` + is the confirmation tool, not the suggestion to delete the field.* +- **`EVIOCGRAB` from the same fd was returning `EBUSY`.** Linux + semantics (`drivers/input/evdev.c::evdev_ioctl`): re-grab from the + fd that already grabbed returns 0 (idempotent); the EBUSY arm only + fires when a *different* fd holds the grab. v1 doesn't enforce + cross-fd exclusivity (plan 9 does), so the EBUSY path was dead + code AND wrong for the same-fd case. *Fixed: A3's EVIOCGRAB arm + collapses to `i.grabbed = value != 0; Ok(())`. Cargo test + `evioc_grab_twice_returns_ebusy` renamed to + `evioc_grab_twice_from_same_fd_is_idempotent`; new test + `evioc_grab_release_without_prior_grab_is_a_noop`.* +- **Ring-overflow protocol grew the ring unbounded.** A4 as drafted + said "drop oldest record (24 bytes) + push SYN_DROPPED (24 bytes) + + push the new event (24 bytes)" — net +24 bytes per overflow. + Sustained mouse-drag would balloon the ring past INPUT_RING_MAX_BYTES. + Also: appending SYN_DROPPED at the tail puts it *after* the buffered + events in stream order, which is the wrong place — Linux's + `evdev_pass_values` puts SYN_DROPPED at the **head of the next + read** so the consumer's "I missed events, re-sync" signal arrives + before any post-overflow data. *Fixed: InputFdState gains a + `dropped: bool` flag; push_event on a full ring sets the flag and + discards the incoming record (ring stays bounded); A5's read + synthesises a SYN_DROPPED at the head of the returned buffer and + clears the flag. POLLIN-ready iff `!event_ring.is_empty() || + input.dropped`. Three cargo tests added: + `ring_overflow_consumes_dropped_flag_on_next_read`, + `poll_pollin_ready_on_dropped_flag_alone`, and + `read_with_buffer_too_small_for_any_record_returns_einval`.* +- **`onWheel` lost continuous-trackpad scrolls.** B2 as drafted did + `Math.trunc(deltaY / -120)`; on macOS Safari with `deltaMode === 0` + (PIXEL) and a single trackpad scroll-fling, `deltaY` is often + ±1..±10, so the truncated quotient is 0 and **no `REL_WHEEL` + emits**. The risk register entry #5 ("vertical-wheel quantisation") + acknowledged this but the code didn't honour it. *Fixed: B2's + onWheel clamps small-but-nonzero deltas to ±1 sign minimum, so + continuous-scroll fires at least one tick per event. Mode-specific + scale (LINE → 1, PIXEL → 120) keeps Chromium/Firefox quanta + intact.* +- **`pointerlockchange` listener was missing.** Risk register entry + #3 says "emit `EV_SYN { code: SYN_REPORT }` on lock-state change + to give readers a re-sync point" — but B2 didn't actually subscribe + to the event. Apps that toggle pointer-lock (FPS-style "press M + for menu") would see the coord-system flip from REL to ABS + underneath them with no marker. *Fixed: B2's `start()` adds + `document.addEventListener('pointerlockchange', ...)` and emits + `frame(1)` (a bare SYN_REPORT on event1) on every transition. + Vitest asserts the listener is registered with the right target — + `document`, not `window`.* + +### Correctness — open, address before kernel PR opens + +- **`kernel_set_input_canvas_dims` set-once vs set-many** is + documented as set-once in A4 Step 3, but the fallback "1280x720 if + not yet set" implies overridable semantics. v1 doesn't need + resize (design §6.1: "canvas is a fixed size at boot"), so + set-once via `AtomicU64` CAS-from-zero is the right call. + Open: pick (a) "first call wins, subsequent calls are silent + no-ops" — simplest, matches v1 design; (b) "last call wins + + emit SYN_REPORT on every open pointer-fd OFD" — needed if a future + plan adds canvas resize, but adds a kernel-side wakeup pathway not + currently in scope. *Lean: (a)* — pin the semantics at A4 Step 3 + implementation time. If plan 9's compositor introduces canvas + resize, the change to (b) is additive (no ABI break). +- **Boot-ordering: kernel_set_input_canvas_dims must precede the + first `kernel_input_event`.** Otherwise EVIOCGABS callers in the + ~ms window between boot and B4's wiring see the 1280x720 fallback. + The B4 wiring sketch is correct; what's missing is an explicit + ordering test. *Resolution:* B4 vitest asserts call order via + spy-on-mocked exports — `kernel_set_input_canvas_dims` is + observed before any `kernel_input_event` AND before the host's + "kernel ready" signal goes high. Added to Missing tests below. +- **EVIOCGABS-on-keyboard returns EINVAL but Linux returns ENOTTY.** + A3 sketch returns `Err(Errno::EINVAL)` for EVIOCGABS on event0. + Linux's `drivers/input/evdev.c` returns `-ENOTSUPP` (mapped to + `-EOPNOTSUPP = -95` for the libc translation, or `-ENOTTY = -25` + in older paths depending on the ioctl number range). EINVAL is + reserved for "axis out of range". libinput tolerates either, but + precision matters: SDL2's evdev probe greps the errno text and + EOPNOTSUPP-vs-EINVAL changes behaviour. *Lean:* return + `EOPNOTSUPP` for EVIOCGABS on devices that don't advertise EV_ABS + in their type bitmap (keyboard); `EINVAL` only for axis-out-of- + range on a device that does. Folded as a follow-up note at A3 + + added Missing test. + + *Follow-up after plan 7's devil's-advocate:* SDL2's + `SDL_EVDEV_AddDevice` (`src/core/linux/SDL_evdev.c`) calls two + more EVIOC* ioctls that plan 5 doesn't implement: + `EVIOCGKEY(sizeof(bits))` (current held-keys bitmap) and + `EVIOCGREP` (keyboard auto-repeat delay+rate). Linux returns + `-ENOTTY` for unknown EVIOC* on a chardev; plan 5's + default-arm should do the same (not EINVAL). Extend the same + resolution above — `EOPNOTSUPP` / `ENOTTY` convention — to + EVIOCGKEY + EVIOCGREP at A3's default match arm. SDL2 tolerates + ENOTTY by treating "no keys held" / "default repeat" as the + initial state; EINVAL would cause a fatal in the SDL2 error + queue. ~10 LoC; cheap to land alongside the EVIOCGABS fix. + Missing test added in plan 7's review (B5 smoke asserts + `SDL_EVDEV_AddDevice` doesn't fatal on event0 registration). + +### Architecture — open (load-bearing, pick before any kernel code lands) + +- **`PROCESS_TABLE.lock()` at high input-event rates is a deeper + scaling concern than the same lock at vblank rates.** Plan 4 A7 + takes PROCESS_TABLE briefly at 60 Hz; plan 5 A4 takes it at + potentially 1000+ Hz (held-down key autorepeat + simultaneous + mouse drag). The lock window is still O(open OFDs) — typically + small — but the per-acquisition rate is 17× higher. Plan 4's + open architecture #2 ("split OFD table out of PROCESS_TABLE") + becomes meaningfully more pressing once plan 5 lands; ALSA in plan + 6 will add another producer at period-tick rate (~50 Hz, low) but + multiplies the call-sites. *Lean:* defer the OFD-table-split + refactor to a focused PR after plans 4+5 ship (no later than + pre-SDL2-port-merge, since plan 7 will exercise all three host- + driven event-stream tickers at once); flag pre-merge profiling as + the trigger in Phase C. Cross-plan note added to plan 4's review. + + *Follow-up after plan 6's devil's-advocate:* plan 6 quantified the + audio-side contribution as ~375 Hz (per AudioWorklet quantum at + 48 kHz, after plan 6's inline fix #4 collapsed the accumulator + to per-quantum), not the original ~50 Hz period-rate estimate + here. Combined worst-case under SDL2 load (plan 7) is now + ~1500+ Hz across all three streams + WAIT_VBLANK consumers + + WRITEI from userland. Plan 4's open-architecture #2 timing was + upgraded in plan 6's cross-plan amendment from "before plan 7 + merge" to "**block plan 7 merge on Phase C profiling — >5% lock + time in any tick handler triggers the OFD-table-split refactor + as a plan-7 prerequisite**." This subsection's lean stands but + the trigger is now numeric, not "no later than"-shaped. +- **`VecDeque<u8>` for the event ring is awkward.** Every record is + exactly 24 bytes; storing as bytes means push/pop bookkeeping is + per-byte (24 `push_back` calls per event). A `VecDeque<WpkInputEvent>` + would push/pop one entry per event. The byte view was inherited + from plan 4's `event_ring: VecDeque<u8>` (which makes sense there + because DRM events are variable-sized — see plan 4 A1's + `peek_next_record_len`). Plan 5's records are fixed-size; the byte + view costs ~250 ns of overhead per overflowing event under no + benefit. *Lean:* defer the change — uniformity with plan 4 wins + for readability at v1 cost (microseconds at 1000 Hz, still negligible). + Revisit if profiling shows the input fanout is a hotspot. Flag in + trade-offs. +- **Multi-tab kbd-eavesdropping hazard (v1 fan-out semantics).** + Plan 5 honours the seat-in-the-compositor punt: every open OFD + gets every event in v1. A multi-tab browser session with each tab + running a kernel sees all keystrokes across all tabs (within the + same kernel instance, anyway — each browser tab is its own kernel + worker). Single-tab is safe; multi-process inside one kernel is + not. *Resolution:* documented in trade-offs; plan 9's + wpkcompositor adds the EVIOCGRAB-based gate that closes the hazard + before any production-shaped userland sits on top. + +### Missing tests — add to implementation PRs + +- **CLOCK_MONOTONIC alignment between `kernel_input_event` and + `kernel_vblank`.** Fire one input event and one vblank tick in + close succession (sub-millisecond); read both records; assert + `|tv_input - tv_vblank| < 1 ms`. Locks the design §16 q6 invariant + that the two host-driven event streams share a comparable clock. +- **Boot-order: `kernel_set_input_canvas_dims` precedes + `kernel_input_event`.** B4 vitest spies the kernel-exports proxy + and asserts call ordering. If the host calls kernel_input_event + before setting dims, the test fails — locks the load-bearing + contract. +- **EVIOCGABS-on-event0 returns EOPNOTSUPP, not EINVAL.** Linux + semantics check (see "Correctness — open" above). Failing test + drives the inline-fix-or-document decision. +- **Fork-shared OFD ring is read-once.** Two processes share a + forked event0 OFD; push a single event; assert *either* process + can read it but not both — Linux per-fd semantics, not broadcast. + Defends against a reviewer "fixing" the ring to broadcast per + process (which would break the OFD-shared-state invariant). +- **`grabbed` flag on a closed OFD doesn't leak.** A2's `on_final_close` + for InputFdState test (under A6) asserts that re-opening event0 + after a grab-then-close cycles cleanly: `grabbed = false` on the + new OFD. Guards against a "global grab" misimplementation. +- **POLLHUP semantics on event-fd close.** A blocked reader on the + parent's event0 OFD sees POLLHUP after the child closes the last + ref. v1 may not implement POLLHUP (POSIX permits a no-op return + on close-while-blocked); if it doesn't, add a test that documents + the v1 behaviour explicitly so plan 9 knows to add POLLHUP. + +### Trade-offs verified against the design doc + handoff requirements + +- **`struct input_event = 24 bytes` on wasm32 with explicit `_pad`.** + Matches musl 64-bit-time_t layout, which matches Linux's 64-bit + userland. The `_pad: i32` field at offset 12 forces `ev_type` to + offset 16 to match C's `struct timeval`-as-substruct layout. + Field-offset asserts in A1 Step 2 lock all five offsets. ✓ +- **`CLOCK_MONOTONIC` from `crate::time::monotonic_us()`** — same + source plan 4 A7 uses for vblank timestamps. Design §16 q6 + invariant honoured; the two host-driven event streams share a + comparable clock for latency profiling. ✓ +- **Additive ABI only — no `ABI_VERSION` bump** (PR #490 policy). + ✓ Three new structs (`WpkInputEvent`, `WpkInputId`, + `WpkInputAbsinfo`), the EV_*/KEY_*/BTN_*/REL_*/ABS_*/SYN_* + constants, the EVIOC* ioctl numbers in the `'E'` magic, and the + two new kernel exports (`kernel_input_event`, + `kernel_set_input_canvas_dims`). No existing surface changes; no + host imports — verified against `docs/abi-versioning.md`. +- **No host imports — pure producer-from-host / consumer-in-kernel.** + Asymmetric vs plan 4's KMS (where host imports outnumber exports + because the host needs to be notified of state changes — master, + addfb, set_fb). Input has no kernel-side state for the host to + react to; the host's `InputSource` is a self-contained DOM + listener. Plan 6 (ALSA) will inherit the same asymmetry (with the + caveat that PCM write-back may add one notify-on-underrun host + import). ✓ +- **`InputFdState` is a separate `Option<Box<…>>` on the OFD; does + NOT join plan 4's `DriOfdState` enum.** The two state machines + are disjoint — input has no DRI bo state; DRI has no event ring. + The shape of plan 4's consolidation enum was "all DRI-related + state on a card or render-node fd in one box"; plan 5's input + state is parallel to that, not nested under it. Plan 6 will land + `Option<Box<AlsaFdState>>` the same way. The right factoring is + one `Option<Box<…>>` per **device class**, not per OFD. ✓ +- **Per-OFD ring + per-OFD `dropped` flag + per-OFD `grabbed` flag.** + Linux per-fd semantics preserved through fork's OFD-share-by-ref: + parent + child see the same ring, the same dropped state, the + same grabbed bit. First-come-first-served on event consumption. + Match design §7.1. ✓ +- **Two devices only (event0 = kbd, event1 = ptr).** Design §7.0; + joystick / touch deferred to v2+. SDL2's evdev backend tolerates + any number ≥ 1; two is the minimum useful set for a + desktop-style userland. ✓ +- **Wait-queue split mirrors plan 4 A7/A9.** Per-OFD wake + (`input::wait::wake_event_reader(idx)`) for the read path; no + broadcast queue (no "WAIT_INPUT_EVENT" analog of WAIT_VBLANK). + ✓ +- **`ASYNCIFY_SAVE_SLOTS` untouched.** A5's `block_event_reader` + reuses the existing asyncify suspend/resume primitive (same shape + pipes/ttys use). ABI-safe. ✓ +- **POSIX-first: `open`/`close`/`read`/`poll`/`ioctl` on event-fds + follow POSIX.** Only the struct layouts + ioctl numbers + EV_*/ + KEY_*/REL_*/ABS_* codes are Linux UAPI — followed verbatim, + modulo the documented wasm32-24-byte-record deviation from + 32-bit-Linux's 16-byte record. ✓ +- **`EVIOCGRAB` v1 records-without-enforces.** The only place plan + 5 knowingly diverges from Linux semantics — and the divergence is + bounded to "every open fd gets every event", which is exactly + what plan 9's compositor closes. Hazard documented; no userland + in v1 exercises the cross-fd-grab case. ✓ (mechanism), ⚠ + (cross-fd EBUSY deferred). + +### Deliberately not flagged + +- Linux UAPI ioctl numbers (`'E'` magic, nrs `0x01`/`0x02`/`0x06`/ + `0x20+`/`0x40+`/`0x90`) — verified vs `include/uapi/linux/input.h` + at v6.10 mainline; no collision with plan 2's `'d'` magic, plan 3's + WPK extensions, or plan 4's KMS ioctl numbers. ✓ +- `KEY_*`/`BTN_*`/`REL_*`/`ABS_*`/`SYN_*` codes — verified vs + `include/uapi/linux/input-event-codes.h`. v1 covers a 130-entry + subset of KEY_*; the rest are advertised in EVIOCGBIT bitmaps as + zero. ✓ +- `BUS_VIRTUAL = 0x06` for `EVIOCGID.bustype` — matches Linux's + uinput driver convention for kernel-synthesised devices. ✓ +- Major 13 (Linux `INPUT_MAJOR`) + minors 64/65 (Linux evdev range + 64..95) — matches `Documentation/admin-guide/devices.txt`. ✓ +- No `/dev/input/mice` legacy node — PS/2 emulation; SDL2 + libinput + prefer the modern evdev path. Skipping saves ~100 LoC of + compat shim. ✓ +- No write-side ioctls (EVIOCSREP / EVIOCSCLOCKID / EVIOCSKEYCODE) + — keyboard repeat is the browser's job; clock is fixed + MONOTONIC; keymap is libxkbcommon's job at plan 8. SDL2 + + libinput gracefully degrade on EOPNOTSUPP. ✓ +- 130-entry KEY_* lookup table in `key-code-table.ts` — derived + from `KeyboardEvent.code` → `KEY_*`, doesn't translate shifted + / AltGr-composed keys (locale-layer; userspace stacks handle it). + ✓ +- Stacked-PR topology (evdev-kernel → evdev-host → evdev-demo) — + matches plans 2/3/4's stack shape and the user's branch-chaining + rule. ✓ +- DOM listener target = `window` (not canvas) — captures keystrokes + even when the canvas doesn't have focus, which matches the + "compositor owns the seat" design. Risk register #2 (macOS Cmd-key + keyup loss) is the only edge case. ✓ + +### Cross-plan amendment to plan 4 + +A finding from this devil's-advocate pass leaks back to plan 4. Plan +4's `event_ring: VecDeque<u8>` (for FLIP_COMPLETE records) has no +overflow protocol — if userspace opens card0 and never `read`s, the +ring grows unbounded at 1 record (32 bytes) per vblank tick of a +queued flip. The risk is lower than plan 5's (1 Hz typical, vs +1000+ Hz under autorepeat + drag), but the unbounded shape is a +correctness gap. *Resolution:* bound plan 4's `event_ring` at 64 +records (2 KiB; ~1 s of unread 60 Hz vblanks); on overflow drop the +*oldest* record silently. Linux's drm UAPI has no "DRM_EVENT_DROPPED" +analog of SYN_DROPPED, so the dropped-flag pattern doesn't translate; +a silent drop matches what real Linux does when the userspace event +ring overflows. Added as a follow-up note in plan 4's review. + +### Cross-plan amendment from plan 8's devil's-advocate + +Plan 8's devil's-advocate pass (session 9) caught that this plan's +`BrowserInputSource.onPointerMove` (Task B1, line 1655+) emits +**EV_ABS** (ABS_X + ABS_Y) by default — for the unlocked-pointer +case — and emits EV_REL only when `document.pointerLockElement` is +set (the locked / FPS-style case). Plan 8's hand-rolled +`wpk_widget_pump_events` (B1) initially handled `EV_REL` +exclusively, missing the default browser case entirely; without +pointer-lock the cursor position never updates inside wpkdraw +processes, and button-click dispatch silently fails. + +The trade-off note "pointer device exposes EV_REL + EV_KEY + +EV_ABS" in the introduction (line 22-23) understates which arm is +the default: **EV_ABS is the default-state code; EV_REL is the +pointer-locked code**. Re-reading the producer logic confirms the +intent (locked → movementX/Y deltas → REL; unlocked → offsetX/Y +absolute → ABS) — the design is correct, but the documentation +phrased the two paths as symmetric peers when in practice the +ABS branch is hit ~99% of the time. + +*Resolution for this plan:* clarify in the introduction (around +line 22-23): "EV_ABS (default, unlocked pointer with absolute +coordinates) + EV_REL (only when `document.pointerLockElement` is +set, e.g., FPS-style apps that requestPointerLock)." Folds into +plan 5 body at impl time, no code change — the producer already +gets it right. Plus a note in "Trade-offs verified": "Pointer +emits EV_ABS by default; consumers expecting cursor-on-default- +browser MUST handle the absolute arm. SDL2's libinput shim +internally translates both (so plan 7's SDL2 demo is unaffected); +hand-rolled evdev consumers (plan 8 wpkdraw, plan 11 wpk-shell) +need an explicit EV_ABS arm in their pump loops. Documented as a +cross-plan amendment from plan 8's review." + +Also of note (for future-me reading plan 5 in isolation): the +SDL2-vs-handrolled split means plan 7's evdev consumer is +*shielded* from the EV_ABS/EV_REL choice by libinput's +translation layer, but plans 8/10/11 are not. Plan 9's +wpkcompositor will absorb the translation as part of its +libinput-real port, putting all post-v1 consumers on the +shielded path. v1 plan 8 just needs the explicit EV_ABS arm. + +### Cross-plan amendment from plan 9's devil's-advocate — EVIOCGRAB enforcement (LOAD-BEARING) + +Plan 9's devil's-advocate pass (session 10) escalated this plan's +EVIOCGRAB-without-enforcement compromise (this plan's A3 +arm + the documented punt at lines 224-230 + the multi-process +hazard at lines 361-369: "every open OFD gets every event in +v1 … plan 9's wpkcompositor adds the EVIOCGRAB-based gate that +closes the hazard") into a **LOAD-BEARING cross-plan obligation**. +Without cross-OFD enforcement, plan 9's compositor can't actually +exclude a co-running wpkdraw-direct process from receiving every +keystroke — both will read identical event streams from a shared +`/dev/input/event0` ring, producing double-delivery of every +keypress + pointer motion. libinput by default does NOT call +`EVIOCGRAB`; plan 9 inline fix #4 adds the ioctl call in +`compositor_open_restricted`, but the kernel-side enforcement +(skip event delivery to non-grabbing OFDs when any other OFD on +the same device holds `grabbed = 1`) is what closes the hazard. + +*Resolution (plan-5 follow-up landed BEFORE plan 9 Phase A opens — +or as part of plan 9 Phase A's first commit):* extend this plan's +`sys_read` arm on `/dev/input/event*` with a cross-OFD check. +~20 LoC of Rust: + +```rust +// In sys_read for InputDevice: +if let Some(grab_holder) = device.grab_holder() { + if grab_holder != current_ofd_idx { + // Another OFD holds the grab. Skip event delivery to us. + return Ok(0); // or EAGAIN if O_NONBLOCK + } +} +``` + +The change is a plan-5-amendment, landed either as a follow-up PR +before plan 9 opens or absorbed into plan 9's Phase A first commit +(scope-wise, ~half a day of work). The dead-code `EBUSY` arm in +this plan's A3 ioctl handler (mentioned in this plan's own review +lines 224-230) is the natural anchor for the cross-OFD check — +the grab-holder slot exists in `InputFdState`; the read arm just +needs to consult it. + +Without this enforcement, plan 9 carries a documented hazard ("if +you run wpkdraw-direct concurrently with the compositor, both +receive every keystroke; v2 ships enforcement"). With it, plan 5's +seat-shared model becomes the v1-correct single-libinput-consumer +substrate plan 9 was designed against. **Lean: ship the +enforcement as part of plan 9 Phase A.** Note added to this plan's +"Trade-offs verified" subsection at impl time. + +### Cross-plan amendment from plan 10's devil's-advocate — enumerate `/dev/input/event*` in devfs readdir + +Plan 10's devil's-advocate pass (session 11) called out that +`devfs.rs` currently registers `event0`/`event1` as virtual +devices openable by path but explicitly does NOT enumerate them +via `readdir(2)` (see comment at `crates/kernel/src/devfs.rs:180`: +"No /dev/input/eventN evdev nodes yet (mousedev surface only)"). +Plan 10's wpkshell built-in `ls /dev/input` would therefore show +only `mice` despite plan 9's compositor opening `event0`/`event1` +via path. Not LOAD-BEARING for plan 10 — the demo's `ls /` use +case doesn't depend on it — but a UX paper-cut worth closing. + +*Resolution (plan 5 follow-up, low priority — landed alongside +plan 5's main PR sequence OR as a standalone PR before plan 10 +opens):* extend `devfs.rs:180` to enumerate `event0`/`event1` +(and the future `event2..N`) under the `/dev/input` `readdir` +arm. ~10 LoC mirroring the existing `mice` entry. Update the +`mice_is_listed_in_dev_input_dir` test (line ~306) to assert +`event0` is also present. + +Note added to this plan's "Missing tests" subsection at impl +time: assert `readdir("/dev/input")` returns `mice` AND +`event0` AND (when plan 5 grows to multi-keyboard) `event1`. + +--- + +## Phase A — kernel: device + ring + ioctls + producer export (PR #1) + +The kernel learns to (a) recognise `/dev/input/event0` and `/dev/input/event1` +as two distinct virtual devices, (b) hold per-OFD ring + grab state, +(c) accept records from the host via a new `kernel_input_event` +export, (d) drain rings via `read()` + report POLLIN, and (e) answer +the `EVIOCG*` introspection ioctls SDL2 + libinput call at open time. + +### Task A1: Shared ABI module additions + +**Files:** +- Modify: `crates/shared/src/lib.rs` — add `pub mod input { … }` with + the record struct, the `EV_*` / `KEY_*` / `BTN_*` / `REL_*` / + `ABS_*` / `SYN_*` constants, the `EVIOCG*` ioctl numbers, and the + `WpkInputId` / `WpkInputAbsinfo` aux structs. + +**Step 1: Constants and structs** + +Append at the end of `crates/shared/src/lib.rs` (sibling of `pub mod +dri`): + +```rust +pub mod input { + use core::mem::size_of; + + // --- Event types (struct input_event.type) --------------------------- + + /// `EV_SYN` = 0. End-of-logical-event sentinel; readers use this + /// to coalesce a (REL_X, REL_Y) pair into one cursor move. + pub const EV_SYN: u16 = 0x00; + /// `EV_KEY` = 1. Press / release / autorepeat. Value is + /// 0 = release, 1 = press, 2 = repeat. + pub const EV_KEY: u16 = 0x01; + /// `EV_REL` = 2. Relative axis (pointer dx/dy/wheel). + pub const EV_REL: u16 = 0x02; + /// `EV_ABS` = 3. Absolute axis (pointer position when not locked, + /// joystick, touch coords). + pub const EV_ABS: u16 = 0x03; + /// `EV_MSC` = 4. Misc events (scancode, timestamp). Not produced + /// in v1. + pub const EV_MSC: u16 = 0x04; + + // --- SYN codes (subset; struct input_event.code when type == EV_SYN) + + /// `SYN_REPORT` = 0. End-of-frame; readers should treat + /// everything since the previous SYN_REPORT as atomic. + pub const SYN_REPORT: u16 = 0x00; + /// `SYN_DROPPED` = 3. Posted when the ring overflowed and the + /// oldest record was dropped; userspace should resynchronise + /// (re-query EVIOCG* state). + pub const SYN_DROPPED: u16 = 0x03; + + // --- KEY_* codes (verbatim subset of linux/input-event-codes.h) + + pub const KEY_RESERVED: u16 = 0; + pub const KEY_ESC: u16 = 1; + pub const KEY_1: u16 = 2; + // … (the full table — KEY_2 = 3, KEY_3 = 4, …, KEY_MICMUTE = 248 + // — is appended verbatim from upstream input-event-codes.h) + pub const KEY_A: u16 = 30; + pub const KEY_Z: u16 = 44; + pub const KEY_ENTER: u16 = 28; + pub const KEY_BACKSPACE: u16 = 14; + pub const KEY_LEFTSHIFT: u16 = 42; + pub const KEY_RIGHTSHIFT: u16 = 54; + pub const KEY_LEFTCTRL: u16 = 29; + pub const KEY_RIGHTCTRL: u16 = 97; + pub const KEY_LEFTALT: u16 = 56; + pub const KEY_RIGHTALT: u16 = 100; + pub const KEY_LEFTMETA: u16 = 125; + pub const KEY_RIGHTMETA: u16 = 126; + pub const KEY_SPACE: u16 = 57; + pub const KEY_TAB: u16 = 15; + pub const KEY_F1: u16 = 59; + pub const KEY_F12: u16 = 88; + pub const KEY_UP: u16 = 103; + pub const KEY_LEFT: u16 = 105; + pub const KEY_RIGHT: u16 = 106; + pub const KEY_DOWN: u16 = 108; + // The full table is ~128 entries; spelled out in the source. + + // --- BTN_* codes (button class; reuse the EV_KEY type) --------------- + + pub const BTN_LEFT: u16 = 0x110; + pub const BTN_RIGHT: u16 = 0x111; + pub const BTN_MIDDLE: u16 = 0x112; + pub const BTN_SIDE: u16 = 0x113; + pub const BTN_EXTRA: u16 = 0x114; + + // --- REL_* codes (relative axes; EV_REL records carry these) --------- + + pub const REL_X: u16 = 0x00; + pub const REL_Y: u16 = 0x01; + pub const REL_WHEEL: u16 = 0x08; + pub const REL_HWHEEL: u16 = 0x06; + + // --- ABS_* codes (absolute axes; EV_ABS records carry these) --------- + + pub const ABS_X: u16 = 0x00; + pub const ABS_Y: u16 = 0x01; + + // --- ioctl numbers ('E' magic, Linux UAPI verbatim) ------------------ + + /// `_IOR('E', 0x01, int)` = `0x8004_4501`. + pub const EVIOCGVERSION: u32 = 0x8004_4501; + + /// `_IOR('E', 0x02, WpkInputId)` = `0x8008_4502`. + pub const EVIOCGID: u32 = 0x8008_4502; + + /// `_IOC(_IOC_READ, 'E', 0x06, len)` where `len` is the + /// caller-supplied buffer size. We accept `1 ≤ len ≤ 256` and + /// recompute the expected encoding at dispatch time (the userspace + /// macro is `EVIOCGNAME(len)`; the kernel matches against the + /// nr-byte + magic only, not the size — see A3 Step 1 for the + /// "size in the ioctl number is informational" handling). + pub const EVIOCGNAME_NR: u32 = 0x06; + + /// `EVIOCGBIT(ev_type, len)` — same variable-length shape as + /// `EVIOCGNAME`. nr = 0x20 + ev_type. + pub const EVIOCGBIT_NR_BASE: u32 = 0x20; + + /// `EVIOCGABS(axis)` — `_IOR('E', 0x40 + axis, WpkInputAbsinfo)`. + /// Axis is a small integer (`ABS_X = 0`, `ABS_Y = 1`, …). + pub const EVIOCGABS_NR_BASE: u32 = 0x40; + + /// `_IOW('E', 0x90, int)` = `0x4004_4590`. + pub const EVIOCGRAB: u32 = 0x4004_4590; + + // --- marshalled structs ---------------------------------------------- + + /// `struct input_event` on wasm32-musl (`time_t = int64_t`, + /// `suseconds_t = int32_t`, `__u16` + `__u16` + `__s32`). + /// Total = 24 bytes. The struct layout matches Linux's 64-bit + /// userland exactly, which is what every modern Linux distro + /// ships for 64-bit-time_t 32-bit userland too. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkInputEvent { + pub tv_sec: i64, // 0 CLOCK_MONOTONIC seconds since kernel boot + pub tv_usec: i32, // 8 microseconds; matches musl suseconds_t + pub _pad: i32, // 12 explicit pad to 8-align the trailing union + pub ev_type: u16, // 16 EV_KEY / EV_REL / EV_ABS / EV_SYN / EV_MSC + pub code: u16, // 18 KEY_* / BTN_* / REL_* / ABS_* / SYN_* + pub value: i32, // 20 press/release/repeat; delta; absolute pos + // total: 24 + } + + /// `struct input_id`. 8 bytes (4 × u16). + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkInputId { + pub bustype: u16, // 0 BUS_VIRTUAL = 0x06 (closest match) + pub vendor: u16, // 2 0x1209 (Interbiometrics, generic) + pub product: u16, // 4 0x0001 for kbd, 0x0002 for ptr + pub version: u16, // 6 0x0001 + // total: 8 + } + + /// `struct input_absinfo`. 24 bytes (5 × i32 + 1 × i32 pad). + /// Used for ABS_X / ABS_Y on the pointer device when pointer + /// lock is not active. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkInputAbsinfo { + pub value: i32, // 0 current value + pub minimum: i32, // 4 + pub maximum: i32, // 8 = canvas width-1 / height-1 + pub fuzz: i32, // 12 = 0 + pub flat: i32, // 16 = 0 + pub resolution: i32,// 20 = 1 (1 unit per pixel) + // total: 24 + } + + // --- BUS_* constants (subset) ---------------------------------------- + + /// `BUS_VIRTUAL` = 0x06 — closest match for a kernel-synthesised + /// device (Linux uses this for `uinput`-backed devices). + pub const BUS_VIRTUAL: u16 = 0x06; +} + +#[cfg(test)] +mod input_tests { + use super::input::*; + use core::mem::size_of; + use crate::ioc; // helper introduced by plan 2 Task A1 + + #[test] + fn input_struct_sizes_match_wasm32_repr_c() { + assert_eq!(size_of::<WpkInputEvent>(), 24); + assert_eq!(size_of::<WpkInputId>(), 8); + assert_eq!(size_of::<WpkInputAbsinfo>(), 24); + } + + #[test] + fn input_event_field_offsets() { + // The 24-byte layout is load-bearing — every reader walks + // the ring 24 bytes at a time. Lock the offsets explicitly. + let e = WpkInputEvent::default(); + let base = (&e as *const _) as usize; + assert_eq!((&e.tv_sec as *const _ as usize) - base, 0); + assert_eq!((&e.tv_usec as *const _ as usize) - base, 8); + assert_eq!((&e.ev_type as *const _ as usize) - base, 16); + assert_eq!((&e.code as *const _ as usize) - base, 18); + assert_eq!((&e.value as *const _ as usize) - base, 20); + } + + #[test] + fn evioc_numbers_match_linux_uapi() { + let ior = 0x4000_0000; // _IOC_READ shifted to dir bits + let iow = 0x8000_0000; // _IOC_WRITE — verify against plan 2's helper + // The ioc helper plan 2 introduced is the source of truth; + // these literal expected values are documentation of the + // wire format for grep-ability. + assert_eq!(EVIOCGVERSION, + ioc(/*IOC_READ*/ 1, 'E' as u32, 0x01, 4)); + assert_eq!(EVIOCGID, + ioc(/*IOC_READ*/ 1, 'E' as u32, 0x02, size_of::<WpkInputId>() as u32)); + assert_eq!(EVIOCGRAB, + ioc(/*IOC_WRITE*/ 2, 'E' as u32, 0x90, 4)); + // EVIOCGABS(0) — ABS_X — base is 0x40. + assert_eq!( + ioc(/*IOC_READ*/ 1, 'E' as u32, EVIOCGABS_NR_BASE + ABS_X as u32, + size_of::<WpkInputAbsinfo>() as u32), + 0x8018_4540); + } +} +``` + +**Step 2: Run** + +```bash +cargo test -p wasm-posix-shared --target aarch64-apple-darwin --lib input_tests +``` + +Expected: 3 new tests pass; plan 2 + plan 3 + plan 4 tests still pass. + +**The `_pad: i32` field is mandatory, not optional.** `repr(C)` follows +C's "natural alignment, trailing padding to struct alignment" rule — +it does NOT insert *interior* padding between `tv_usec: i32` (offset +8..11) and `ev_type: u16` (which would otherwise land at offset 12). +Without the explicit `_pad`, the Rust struct is still 24 bytes (the +trailing padding bumps it to the i64's 8-byte alignment), but `ev_type` +sits at offset 12 — whereas userspace C reads it at offset 16 because +`struct timeval` is itself 16 bytes on wasm32-musl (`int64_t tv_sec` +forces 8-byte alignment of the substruct, padding the trailing +`int32_t tv_usec` to 16). The two layouts agree on size but disagree +on every offset past byte 11: the kernel would write `ev_type` at byte +12 and read `value` from bytes 16..20; userspace would read `ev_type` +from bytes 16..18 (which is the kernel's `value` low half) and `value` +from bytes 20..24 (zero pad). Silent corruption on every record. The +field-offset asserts above are the gate; if you see `ev_type` at +offset 12 it means someone deleted `_pad` — restore it. `cargo expand` +confirms the actual layout. + +**Step 3: Commit** + +```bash +git add crates/shared/src/lib.rs +git commit -m "kernel(input): shared ABI — struct input_event + EV_*/KEY_*/EVIOCG* constants" +``` + +--- + +### Task A2: `VirtualDevice::InputEvent` + devfs entries + `OpenFileKind::InputEvent` + `InputFdState` on OFD + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — extend `VirtualDevice` + enum + `match_virtual_device` to recognise `/dev/input/event0` + and `/dev/input/event1`. +- Modify: `crates/kernel/src/devfs.rs` — add `event0` and `event1` + entries under a new `input/` subdirectory. +- Modify: `crates/kernel/src/ofd.rs` — add `OpenFileKind::InputEvent + { device: u8 }` and an `input: Option<Box<InputFdState>>` field on + `OpenFileDesc`. + +**Step 1: Device enum** + +```rust +pub enum VirtualDevice { + // … existing variants … + DriRender0, // /dev/dri/renderD128 (plan 2 + 3) + DriCard0, // /dev/dri/card0 (plan 4) + InputEvent { // /dev/input/event{0,1} (plan 5 — this task) + device: u8, // 0 = kbd, 1 = ptr + }, +} +``` + +Extend `match_virtual_device` to map `/dev/input/event0` and +`/dev/input/event1` into the two variants. Other `/dev/input/event*` +paths return `ENOENT` (not a stub — really not present in v1). + +**Step 2: devfs entries** + +Add a new `input` subdirectory under `/dev` with two synthetic char +entries: + +```rust +// In devfs.rs, alongside the existing /dev/dri synthetic dir: +synthetic_subdir("input", &[ + synthetic_entry("event0", DT_CHR, 13 /* INPUT_MAJOR */, 64), + synthetic_entry("event1", DT_CHR, 13, 65), +]); +``` + +(Major 13 is the Linux input subsystem major; minors 64..95 are the +event-device range. Conventional, not load-bearing.) + +**Step 3: `InputFdState` on the OFD** + +In `crates/kernel/src/ofd.rs`: + +```rust +/// Per-fd state for `/dev/input/event{0,1}` opens. Disjoint from +/// `DriOfdState` (plan 4) — input fds carry no DRI bo state. We +/// keep `input` as a separate `Option<Box<…>>` field on the OFD +/// rather than folding it into `DriOfdState` because the two state +/// machines have no shared invariants. +#[derive(Default, Clone, Debug)] +pub struct InputFdState { + /// Which device this fd is bound to (0 = kbd, 1 = ptr). Cached + /// to avoid a second VirtualDevice lookup on read/write. + pub device: u8, + + /// Ring of 24-byte records. Bounded at 1024 events (24 KiB) per + /// the Pre-impl review entry "Event-ring bound". On overflow, + /// `dropped` flips to true and the **new** record is discarded + /// (Linux's `drivers/input/evdev.c::evdev_pass_values` semantics: + /// while the dropped flag is set, no new events land on the + /// ring; the *next* read synthesises a SYN_DROPPED record at the + /// head of the returned buffer and clears the flag). This + /// preserves the invariant that the ring never exceeds + /// INPUT_RING_MAX_BYTES. + pub event_ring: VecDeque<u8>, + + /// `EVIOCGRAB`-set ownership flag. v1 records the flag but + /// doesn't gate event delivery on it — plan 9 (wpkcompositor) + /// adds the focus-routing layer. + pub grabbed: bool, + + /// Set when push_event finds the ring full; cleared on the next + /// `read()` *after* a SYN_DROPPED synthetic record is delivered + /// at the head of that read's output. While set, new pushes are + /// silently discarded — userspace re-syncs via EVIOCG* state. + pub dropped: bool, + + /// Per-OFD ring high-water-mark for diagnostics (peak record + /// count seen). Not exposed via ioctl; debug-only. + pub ring_high_water: u32, +} + +pub const INPUT_RING_MAX_RECORDS: usize = 1024; +pub const INPUT_RING_MAX_BYTES: usize = + INPUT_RING_MAX_RECORDS * 24; // 24 KiB per OFD ring +``` + +Attach `input: Option<Box<InputFdState>>` to `OpenFileDesc`, parallel +to `dri_state` (plan 4's consolidated enum). Set to +`Some(Box::new(InputFdState { device, ..Default::default() }))` on +`open("/dev/input/event{0,1}")`; `None` for any other path. + +**Step 4: Cargo tests** + +```rust +#[test] +fn open_event0_yields_input_state_with_device_zero() { /* … */ } + +#[test] +fn open_event1_yields_input_state_with_device_one() { /* … */ } + +#[test] +fn fork_inherits_input_state_via_ofd_dup() { + // Same OFD shared by ref → same InputFdState (Linux per-fd + // semantics preserved through fork's ref-bump). The ring is + // shared too — child and parent see the same events. (Plan 9's + // compositor adds a per-process focus filter on top.) +} + +#[test] +fn open_nonexistent_event_path_returns_enoent() { + // /dev/input/event2 → ENOENT (we only synthesise 0 and 1). +} +``` + +**Step 5: Commit** + +```bash +git add crates/kernel/src/syscalls.rs crates/kernel/src/devfs.rs \ + crates/kernel/src/ofd.rs +git commit -m "kernel(input): add /dev/input/event{0,1} + InputFdState on OFD" +``` + +--- + +### Task A3: `EVIOCG*` ioctl dispatch — GVERSION / GID / GNAME / GBIT / GABS / GRAB + +**Files:** +- Create: `crates/kernel/src/input/mod.rs` — the input subsystem + module (sibling of `dri/`). +- Modify: `crates/kernel/src/syscalls.rs` — dispatcher for input-fd + ioctls (new `handle_input_ioctl(pid, fd, request, buf)` callable). + +**Step 1: Dispatcher** + +```rust +fn handle_input_ioctl(pid: i32, fd: i32, request: u32, buf: &mut [u8]) + -> Result<(), Errno> +{ + use wasm_posix_shared::input::*; + + // Decode the ioctl number's nr byte and direction. The "size" + // sub-field of EVIOCGNAME / EVIOCGBIT is informational — Linux + // matches on the (dir, magic, nr) triple and uses + // `_IOC_SIZE(request)` to learn the caller's buffer length. + let dir = (request >> 30) & 0x3; + let magic = (request >> 8) & 0xff; + let nr = request & 0xff; + let size = ((request >> 16) & 0x3fff) as usize; + + if magic != 'E' as u32 { return Err(Errno::EINVAL); } + + match nr { + // EVIOCGVERSION + 0x01 if dir == 1 => { + let version_be: u32 = 0x0001_0001; // EV_VERSION = 0x010001 + write_u32(buf, version_be)?; + Ok(()) + } + + // EVIOCGID + 0x02 if dir == 1 => { + let device = with_input_ofd(pid, fd, |i| i.device)?; + let id = WpkInputId { + bustype: BUS_VIRTUAL, + vendor: 0x1209, + product: if device == 0 { 0x0001 } else { 0x0002 }, + version: 0x0001, + }; + write_struct(buf, &id)?; + Ok(()) + } + + // EVIOCGNAME(len) — fixed device name; truncate to caller's len. + 0x06 if dir == 1 => { + let device = with_input_ofd(pid, fd, |i| i.device)?; + let name: &[u8] = if device == 0 { + b"wpk virtual keyboard\0" + } else { + b"wpk virtual pointer\0" + }; + let copy_len = name.len().min(size); + buf[..copy_len].copy_from_slice(&name[..copy_len]); + Ok(()) + } + + // EVIOCGBIT(ev_type, len) — bit array of which codes are + // supported. For ev_type = 0 (the special "what types do + // you support" query), return EV_KEY | EV_REL | EV_ABS | + // EV_SYN as a bitmap. For EV_KEY, return the bits for every + // KEY_* we deliver. For EV_REL on event1, REL_X | REL_Y. + // For EV_ABS on event1, ABS_X | ABS_Y. event0 advertises + // KEY only; event1 advertises KEY (buttons) + REL + ABS. + nr if (0x20..0x20 + 32).contains(&nr) && dir == 1 => { + let ev_type = (nr - 0x20) as u16; + let device = with_input_ofd(pid, fd, |i| i.device)?; + for b in buf.iter_mut() { *b = 0; } + populate_evbit(device, ev_type, buf); + Ok(()) + } + + // EVIOCGABS(axis) — only meaningful on event1 (pointer). + nr if (0x40..0x40 + 64).contains(&nr) && dir == 1 => { + let axis = (nr - 0x40) as u16; + let device = with_input_ofd(pid, fd, |i| i.device)?; + if device != 1 { return Err(Errno::EINVAL); } + // Canvas dimensions come from plan 4's KmsRegistry-side + // canvas attachment — for v1 we cache them in a kernel- + // global static set at boot via `HostIO::input_canvas_dims` + // (call wired in A4 Step 3 below). Fallback to 1280x720 + // if not yet set. + let (w, h) = crate::input::canvas_dims(); + let abs = match axis { + ABS_X => WpkInputAbsinfo { value: 0, minimum: 0, + maximum: (w as i32) - 1, fuzz: 0, flat: 0, + resolution: 1 }, + ABS_Y => WpkInputAbsinfo { value: 0, minimum: 0, + maximum: (h as i32) - 1, fuzz: 0, flat: 0, + resolution: 1 }, + _ => return Err(Errno::EINVAL), + }; + write_struct(buf, &abs)?; + Ok(()) + } + + // EVIOCGRAB(int) — value != 0 sets grab, value == 0 releases. + // + // Linux semantics (drivers/input/evdev.c::evdev_ioctl): re-grab + // from the **same fd** is idempotent (returns 0); cross-fd grab + // while another fd holds returns EBUSY. v1 records the grab + // per-OFD but doesn't enforce cross-fd exclusivity (every open + // fd still sees every event); plan 9's compositor adds the + // cross-fd gate. The Ok(()) on re-grab matches Linux exactly; + // the EBUSY-on-other-fd's-grab path lands with plan 9. + 0x90 if dir == 2 => { + let value = read_u32(buf)? as i32; + with_input_ofd_mut(pid, fd, |i| { + i.grabbed = value != 0; + Ok(()) + })? + } + + _ => Err(Errno::EOPNOTSUPP), + } +} +``` + +`populate_evbit(device, ev_type, buf)` writes the supported-codes +bitmap. Sketch: + +```rust +fn populate_evbit(device: u8, ev_type: u16, buf: &mut [u8]) { + fn set_bit(buf: &mut [u8], bit: u16) { + let byte = (bit as usize) >> 3; + let shift = (bit as usize) & 7; + if byte < buf.len() { buf[byte] |= 1 << shift; } + } + match (device, ev_type) { + (_, 0) => { // ev_type 0 = "what types?" + set_bit(buf, EV_KEY); + set_bit(buf, EV_SYN); + if device == 1 { + set_bit(buf, EV_REL); + set_bit(buf, EV_ABS); + } + } + (0, t) if t == EV_KEY => { + // Keyboard: every KEY_* we advertise. + for &k in &[KEY_ESC, KEY_1, /* … */ KEY_A, KEY_Z, + KEY_ENTER, KEY_BACKSPACE, KEY_LEFTSHIFT, + /* … full set; ~128 entries spelled out */] { + set_bit(buf, k); + } + } + (1, t) if t == EV_KEY => { + // Pointer: only the BTN_* buttons. + for &b in &[BTN_LEFT, BTN_RIGHT, BTN_MIDDLE, + BTN_SIDE, BTN_EXTRA] { + set_bit(buf, b); + } + } + (1, t) if t == EV_REL => { + set_bit(buf, REL_X); + set_bit(buf, REL_Y); + set_bit(buf, REL_WHEEL); + set_bit(buf, REL_HWHEEL); + } + (1, t) if t == EV_ABS => { + set_bit(buf, ABS_X); + set_bit(buf, ABS_Y); + } + _ => { /* unsupported (type, device) — leave buf zero */ } + } +} +``` + +**Step 2: Wire into the syscall ioctl router** + +In `sys_ioctl`, before falling through to "unrecognised ioctl on this +device": + +```rust +if let Some(OpenFileKind::InputEvent { device: _ }) = + ofd.kind.as_ref() +{ + return handle_input_ioctl(pid, fd, request, buf); +} +``` + +**Step 3: Cargo tests** + +```rust +#[test] +fn evioc_gversion_returns_010001() { /* … */ } + +#[test] +fn evioc_gid_keyboard_vs_pointer_differs_by_product() { /* … */ } + +#[test] +fn evioc_gname_event0_returns_keyboard_string() { /* … */ } + +#[test] +fn evioc_gbit_keyboard_advertises_ev_key_and_ev_syn_only() { /* … */ } + +#[test] +fn evioc_gbit_pointer_advertises_rel_and_abs_too() { /* … */ } + +#[test] +fn evioc_gabs_keyboard_returns_einval() { /* … */ } + +#[test] +fn evioc_grab_sets_flag_then_release_clears_it() { /* … */ } + +#[test] +fn evioc_grab_twice_from_same_fd_is_idempotent() { + // EVIOCGRAB(1) on a fd that already has grabbed=true returns 0, + // not EBUSY — matches Linux's per-fd idempotent semantics + // (drivers/input/evdev.c). The cross-fd EBUSY case lands with + // plan 9. +} + +#[test] +fn evioc_grab_release_without_prior_grab_is_a_noop() { + // EVIOCGRAB(0) on a never-grabbed fd returns 0, not an error. + // Matches Linux. +} +``` + +**Step 4: Commit** + +```bash +git add crates/kernel/src/input/ crates/kernel/src/syscalls.rs +git commit -m "kernel(input): EVIOCG* ioctl dispatch + populate_evbit" +``` + +--- + +### Task A4: `kernel_input_event()` export — host pushes events; kernel fans out + wakes + +**Files:** +- Create: `crates/kernel/src/input/dispatch.rs` — the producer + + ring management. +- Modify: `crates/kernel/src/wasm_api.rs` — add the kernel-wasm + export `kernel_input_event`. +- Modify: `crates/kernel/src/process.rs` — `HostIO::input_canvas_dims` + is called by the host once at boot to set the pointer device's + ABS axis maxima; cached in `input::canvas_dims()` (Task A3's + fallback path). + +**Step 1: The producer** + +```rust +// crates/kernel/src/input/dispatch.rs + +use wasm_posix_shared::input::*; +use crate::ofd::{InputFdState, INPUT_RING_MAX_RECORDS, INPUT_RING_MAX_BYTES}; + +/// Push a single input event onto every open OFD bound to the +/// matching device. Called from `kernel_input_event` (the wasm-side +/// export the host invokes). `device` must be 0 (kbd) or 1 (ptr); +/// other values are dropped. +/// +/// Ring overflow protocol (matches Linux's `evdev_pass_values`): +/// when an OFD's ring is full, set `dropped = true` and **discard +/// the incoming record**. The `dropped` flag is consumed by the next +/// `read()` on that OFD (Task A5): the read prepends a synthesised +/// `EV_SYN { code: SYN_DROPPED, value: 0 }` record and clears the +/// flag. Userspace re-syncs state via EVIOCGKEY / EVIOCGLED / etc. +/// Crucially: the ring NEVER exceeds INPUT_RING_MAX_BYTES, even +/// under pathological producers — the bound holds for free because +/// pushes-while-dropped are no-ops. +pub fn push_event(device: u8, ev_type: u16, code: u16, value: i32) { + if device > 1 { return; } + let now = crate::time::monotonic_us(); // CLOCK_MONOTONIC + let ev = WpkInputEvent { + tv_sec: (now / 1_000_000) as i64, + tv_usec: (now % 1_000_000) as i32, + _pad: 0, + ev_type, + code, + value, + }; + // Take PROCESS_TABLE lock briefly (OFDs live inside it per plan + // 3 A3 + plan 4 A7). Iterate `pt.ofds.entries`; cost is O(N) over + // open OFDs of the matching device, which is small (typically + // one per running program). Same lock-order resolution plan 4 + // A7's `kernel_vblank` arrived at — see plan 4's "open + // correctness — `kernel_vblank()` lock-order" note. + let mut woken: Vec<usize> = Vec::new(); + { + let mut pt = crate::PROCESS_TABLE.lock(); + for (idx, slot) in pt.ofds.entries.iter_mut().enumerate() { + let Some(ofd) = slot.as_mut() else { continue; }; + let Some(input) = ofd.input.as_mut() else { continue; }; + if input.device != device { continue; } + + // Ring overflow: set the dropped flag, discard the + // record. read() at A5 synthesises SYN_DROPPED at the + // head of its next return + clears the flag. + if input.event_ring.len() >= INPUT_RING_MAX_BYTES { + input.dropped = true; + continue; // do NOT wake — reader has nothing new + } + push_record(&mut input.event_ring, &ev); + let count = (input.event_ring.len() / 24) as u32; + if count > input.ring_high_water { + input.ring_high_water = count; + } + woken.push(idx); + } + } + // Wake every OFD that got new bytes (per-OFD wake, same shape + // as plan 4 A7's `wake_event_reader`). + for idx in woken { + crate::input::wait::wake_event_reader(idx); + } +} + +fn push_record(ring: &mut VecDeque<u8>, ev: &WpkInputEvent) { + let bytes: &[u8; 24] = unsafe { core::mem::transmute(ev) }; + for &b in bytes.iter() { ring.push_back(b); } +} +``` + +**Step 2: The kernel export** + +```rust +// crates/kernel/src/wasm_api.rs + +/// Called by the host on every DOM keyboard / pointer event after +/// translating the browser-side code to evdev's KEY_* / BTN_* / +/// REL_* / ABS_* + EV_SYN follow-up. Single entrypoint; the kernel +/// fans out to every open OFD on the matching device's node. +/// +/// `device`: 0 = `/dev/input/event0` (kbd), 1 = `event1` (ptr). +/// `ev_type`: EV_KEY / EV_REL / EV_ABS / EV_SYN. +/// `code`: KEY_* / BTN_* / REL_* / ABS_* / SYN_*. +/// `value`: press(1) / release(0) / repeat(2) for KEY; delta for +/// REL; absolute pos for ABS; SYN_REPORT carries 0. +/// +/// Convention: the host emits the type-specific record first +/// (EV_KEY, EV_REL, etc.) then a matching EV_SYN(SYN_REPORT, 0) +/// to close the logical event. SDL2 + libinput coalesce on +/// SYN_REPORT. +#[no_mangle] +pub extern "C" fn kernel_input_event(device: u32, ev_type: u32, + code: u32, value: i32) +{ + crate::input::dispatch::push_event( + device as u8, ev_type as u16, code as u16, value); +} +``` + +**Step 3: Canvas-dims wiring** + +The pointer device's `ABS_X` / `ABS_Y` maxima come from the canvas +the browser host attached for KMS (plan 4 B3's `attachKmsCanvas`). +At boot, the host calls a one-shot kernel export: + +```rust +#[no_mangle] +pub extern "C" fn kernel_set_input_canvas_dims(width: u32, height: u32) { + crate::input::set_canvas_dims(width, height); +} +``` + +(Additive export; ABI-safe.) The Node host can call this with stub +values for null-source tests. + +**Boot-ordering contract (load-bearing).** B4 wires this so: +`kernel_set_input_canvas_dims(w, h)` is called **before** the host +starts the `InputSource` (browser DOM listeners or Node null-source) +AND before the kernel signals "ready" to user processes. This avoids +the race where a process opens `/dev/input/event1` and calls EVIOCGABS +between boot and the host setting dims — the kernel's "1280x720 +fallback" exists for defensive correctness but B4 must guarantee the +real call has landed first. Vitest in B4 asserts call ordering. + +The dims are stored in an `AtomicU64` (low 32 = width, high 32 = +height) — set-once on first call; subsequent calls are silently +ignored (canvas resize is post-v1 per design §6.1). The current +canvas size is never queryable from userspace except via EVIOCGABS; +if a future plan adds canvas resize, change the semantics from +"first wins" to "last wins" + emit a SYN_REPORT on every open +pointer-fd OFD so readers re-sync via EVIOCGABS. v1 doesn't ship +that. + +**Step 4: Cargo tests** + +```rust +#[test] +fn push_event_appends_24_bytes_to_every_matching_ofd() { /* … */ } + +#[test] +fn push_event_with_no_matching_ofd_is_a_noop() { /* … */ } + +#[test] +fn ring_overflow_drops_oldest_and_emits_syn_dropped() { + // Open event0, push 1025 records without draining → the first + // record is gone, then a SYN_DROPPED marker, then the 1024 most + // recent records. (Total ring bytes after = 1025 × 24 = 24600 − + // 24 (oldest dropped) + 24 (SYN_DROPPED) = 24600.) +} + +#[test] +fn push_event_wakes_blocked_reader() { /* … */ } +``` + +**Step 5: Commit** + +```bash +git add crates/kernel/src/input/ crates/kernel/src/wasm_api.rs \ + crates/kernel/src/process.rs +git commit -m "kernel(input): kernel_input_event export + fan-out + ring overflow handling" +``` + +--- + +### Task A5: `read(/dev/input/event*)` drains the ring; `poll(POLLIN)` gates + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — `sys_read` arm for + `OpenFileKind::InputEvent`; `sys_poll` arm for POLLIN. + +```rust +// In sys_read, before the host-handle fast path, for InputEvent OFDs: +if let Some(input) = ofd.input.as_mut() { + // POLLIN-ready iff there's a real record OR an unconsumed + // dropped flag. Park only when both are empty. + if input.event_ring.is_empty() && !input.dropped { + crate::input::wait::block_event_reader(ofd_idx)?; + } + let usable = (user_buf.len() / 24) * 24; + if usable == 0 { + return Err(Errno::EINVAL); // libinput would never do this + } + let mut written = 0; + // If the producer dropped events, synthesise the SYN_DROPPED + // marker at the head of this read (Linux's evdev convention). + // The marker carries the current monotonic timestamp — userspace + // doesn't use it but the field must be valid. + if input.dropped { + let now = crate::time::monotonic_us(); + let dropped_ev = WpkInputEvent { + tv_sec: (now / 1_000_000) as i64, + tv_usec: (now % 1_000_000) as i32, + _pad: 0, + ev_type: EV_SYN, + code: SYN_DROPPED, + value: 0, + }; + let bytes: &[u8; 24] = unsafe { core::mem::transmute(&dropped_ev) }; + user_buf[..24].copy_from_slice(bytes); + written = 24; + input.dropped = false; + } + // Drain whole 24-byte records into the remaining buffer. Linux + // evdev semantics: read() returns a multiple of sizeof(struct + // input_event); partial-record returns are forbidden. + while written + 24 <= usable && !input.event_ring.is_empty() { + for i in 0..24 { + user_buf[written + i] = input.event_ring.pop_front().unwrap(); + } + written += 24; + } + return Ok(written); +} +``` + +`poll(input_fd, POLLIN)` returns ready iff `!event_ring.is_empty() || +input.dropped` — the dropped marker alone counts as a readable record. + +**Cargo tests:** +- `read_with_short_buffer_returns_floor_of_24_byte_multiple` — + caller passes `len = 50` → reader returns 48 bytes (2 records), + not 50. +- `read_blocks_on_empty_ring_resumes_on_push_event`. +- `poll_pollin_idle_then_ready_after_push_event`. +- `ring_overflow_consumes_dropped_flag_on_next_read` — push 1100 + events without reading; assert ring stays bounded at 1024 records + (24576 bytes); next read returns SYN_DROPPED at offset 0 followed + by the 1024 *oldest* records (since overflow drops *new* events, + not old ones — matches Linux semantics, where the buffered events + are still valid history); dropped flag clears after the read. +- `poll_pollin_ready_on_dropped_flag_alone` — drain the ring, set + the dropped flag, assert POLLIN is ready and the next read + returns exactly 24 bytes (the synthesised SYN_DROPPED). +- `read_with_buffer_too_small_for_any_record_returns_einval` — + `len = 12` → EINVAL (not partial-record return). + +**Commit:** `kernel(input): read(input_event) drains ring + poll(POLLIN)` + +--- + +### Task A6: `on_final_close` releases grab + drops ring + +**Files:** +- Modify: `crates/kernel/src/ofd.rs` — extend `on_final_close`. + +```rust +impl OpenFileDesc { + pub fn on_final_close(&mut self, pid: i32, host_io: &mut dyn HostIO, + ofd_idx: usize) + { + // … plan 2's prime_bo cleanup … + // … plan 3's dri.handles + dri.gl.bindings cleanup … + // … plan 4's kms cleanup (master, fbs, pending_flips) … + if let Some(input) = self.input.take() { + // No host-side per-OFD state to clean — the host's + // InputSource is a single producer; per-OFD ring lives + // in the kernel only. Grab flag is dropped with the + // OFD; event_ring is GC'd. + // + // (When plan 9's wpkcompositor adds focus routing, an + // EVIOCGRAB-held OFD closing will need to notify the + // compositor so it can re-grant ownership. Stub a + // log-only `host_io.input_grab_released(pid, device)` + // call here at plan-9-merge time; v1 doesn't need it.) + let _ = input; // explicit drop for clarity + } + } +} +``` + +**Cargo tests:** +- `close_releases_grab_so_next_open_can_grab` — Process A opens + event0, EVIOCGRAB. Close. Process B opens event0, EVIOCGRAB → + succeeds. +- `fork_then_close_in_child_keeps_grab_on_parent` — OFD shared by + fork; child close → refcount = 1, parent still holds grab; parent + close → on_final_close runs, grab released. + +**Commit:** `kernel(input): on_final_close drops input ring + grab` + +--- + +### Task A7: ABI snapshot regen (additive) + +**Files:** +- Modify: `abi/snapshot.json` (auto-generated). +- DO NOT modify: `ABI_VERSION`. + +Expected diff: new entries for `WpkInputEvent`, `WpkInputId`, +`WpkInputAbsinfo`, the EV_* / KEY_* / etc. constants, the EVIOC* +ioctl numbers, and the two new exports (`kernel_input_event`, +`kernel_set_input_canvas_dims`). **No** changes to any existing row. + +```bash +bash scripts/check-abi-version.sh update +git diff abi/snapshot.json +bash scripts/check-abi-version.sh +git add abi/snapshot.json +git commit -m "kernel(input): regen ABI snapshot — additive input surface" +``` + +--- + +### Task A8: Phase A — full gauntlet + open PR #1 + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +Push, open draft PR. + +Title: `[explore-dri] kernel(input): /dev/input/event{0,1} + EVIOCG* + input_event ring` + +Body (Brandon style): + +```markdown +## Summary +- Add `/dev/input/event0` (keyboard) and `/dev/input/event1` (pointer) + as virtual devices alongside plan 4's `/dev/dri/card0`. +- Per-OFD 24 KiB event ring; bounded with SYN_DROPPED overflow + signaling per the Linux UAPI convention. +- EVIOCG* introspection ioctls SDL2 + libinput call at open time: + GVERSION / GID / GNAME / GBIT / GABS / GRAB. +- `kernel_input_event(device, ev_type, code, value)` export — + host calls this on every DOM keyboard / pointer event after + translation; kernel fans out to every open OFD and wakes blocked + readers. +- `struct input_event` is 24 bytes on wasm32 (matches musl's + 64-bit-time_t layout, which matches modern Linux's 64-bit + userland). + +## Why +Plan 5 of the DRI v2 design (`docs/plans/2026-05-18-dri-design.md` +§7) — input devices are the second-of-three host-driven +event-streams (vblank in plan 4; input here; ALSA in plan 6). +Prereq for SDL2's evdev input backend (plan 7, milestone D) and +for the compositor's focus routing (plan 9). + +## Verification +- `cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib` +- `cd host && npx vitest run` +- `scripts/run-libc-tests.sh` +- `scripts/run-posix-tests.sh` +- `bash scripts/check-abi-version.sh` + +## ABI impact +Additive only — no `ABI_VERSION` bump. New `repr(C)` structs +(`WpkInputEvent`, `WpkInputId`, `WpkInputAbsinfo`), new ioctl numbers +in the `'E'` magic (verbatim Linux UAPI), two new kernel exports +(`kernel_input_event`, `kernel_set_input_canvas_dims`). **No new +host imports** — the host is the producer, kernel is the consumer. +No existing surface changes. + +## Notes +- `CLOCK_MONOTONIC` timestamps (design §16 q6); shared with plan + 4's `kernel_vblank` for latency profiling. +- v1 fans every event to every open OFD (no focus routing). + EVIOCGRAB is recorded but not enforced — plan 9's compositor + adds the focus gate on top. +- Event-ring cap = 1024 records per OFD; overflow emits + SYN_DROPPED per Linux UAPI convention. +- `struct input_event = 24 bytes` on wasm32, **not** 16. Static- + assert in A1 Step 2 locks the layout. +``` + +**Do not merge.** + +--- + +## Phase B — host: `InputSource` (browser DOM capture, Node null-source) + plumbing (PR #2) + +### Task B1: `InputSource` module + interface + +**Files:** +- Create: `host/src/input/input-source.ts`. + +```ts +// host/src/input/input-source.ts + +/** Records a single evdev-shaped event ready for kernel dispatch. */ +export interface InputEvent { + device: 0 | 1; + ev_type: number; + code: number; + value: number; +} + +export interface InputSource { + /** Begin capturing input. `dispatch` is called once per evdev + * record; the host wires it to `kernel.exports.kernel_input_event`. + * Convention: the source emits the type-specific record (EV_KEY, + * EV_REL, …) and then an EV_SYN(SYN_REPORT, 0) to close the + * logical frame — same shape Linux evdev produces. */ + start(dispatch: (ev: InputEvent) => void): void; + + /** Stop capturing; remove DOM listeners or clear timers. */ + stop(): void; +} +``` + +**Vitest:** import-and-instantiate sanity only. + +**Commit:** `host(input): InputSource interface` + +--- + +### Task B2: `BrowserInputSource` — DOM event capture + key-code translation + +**Files:** +- Create: `host/src/input/browser-input-source.ts`. +- Create: `host/src/input/key-code-table.ts` — `KeyboardEvent.code` + → `KEY_*` map (~130 entries, derived from + `linux/input-event-codes.h`). + +```ts +// host/src/input/browser-input-source.ts +import type { InputSource, InputEvent } from './input-source'; +import { codeToKey } from './key-code-table'; + +const EV_SYN = 0x00, EV_KEY = 0x01, EV_REL = 0x02, EV_ABS = 0x03; +const SYN_REPORT = 0x00; +const REL_X = 0x00, REL_Y = 0x01, REL_WHEEL = 0x08, REL_HWHEEL = 0x06; +const ABS_X = 0x00, ABS_Y = 0x01; +const BTN_LEFT = 0x110, BTN_RIGHT = 0x111, BTN_MIDDLE = 0x112; + +export class BrowserInputSource implements InputSource { + private dispatch: ((ev: InputEvent) => void) | null = null; + private bindings: Array<[EventTarget, string, EventListener]> = []; + + constructor(private target: EventTarget = window, + private canvas?: HTMLCanvasElement | OffscreenCanvas) {} + + start(dispatch: (ev: InputEvent) => void): void { + this.dispatch = dispatch; + this.bind('keydown', this.onKeyDown); + this.bind('keyup', this.onKeyUp); + this.bind('pointermove', this.onPointerMove); + this.bind('pointerdown', this.onPointerDown); + this.bind('pointerup', this.onPointerUp); + this.bind('wheel', this.onWheel); + // Lock-mode change shifts the pointer coord-system between REL + // (locked → movementX/Y deltas) and ABS (unlocked → offsetX/Y + // absolute). Emit a bare SYN_REPORT on transition so readers + // (libinput, SDL2) see a re-sync point. document.addEventListener + // (not this.bind on window) — pointerlockchange fires on + // document only. + document.addEventListener('pointerlockchange', + this.onPointerLockChange.bind(this)); + } + + private onPointerLockChange() { + this.frame(1); + } + + stop(): void { + for (const [t, n, l] of this.bindings) t.removeEventListener(n, l); + this.bindings = []; + this.dispatch = null; + } + + private bind(name: string, handler: (e: any) => void) { + const wrapped = handler.bind(this); + this.target.addEventListener(name, wrapped as any); + this.bindings.push([this.target, name, wrapped as any]); + } + + private emit(device: 0 | 1, ev_type: number, code: number, value: number) { + this.dispatch!({ device, ev_type, code, value }); + } + + private frame(device: 0 | 1) { + // Close the logical event with SYN_REPORT — Linux convention. + this.emit(device, EV_SYN, SYN_REPORT, 0); + } + + private onKeyDown(e: KeyboardEvent) { + const key = codeToKey(e.code); if (key === null) return; + e.preventDefault(); + this.emit(0, EV_KEY, key, e.repeat ? 2 : 1); + this.frame(0); + } + + private onKeyUp(e: KeyboardEvent) { + const key = codeToKey(e.code); if (key === null) return; + e.preventDefault(); + this.emit(0, EV_KEY, key, 0); + this.frame(0); + } + + private onPointerMove(e: PointerEvent) { + if (document.pointerLockElement) { + // Locked: emit deltas via REL_X / REL_Y. + if (e.movementX !== 0) this.emit(1, EV_REL, REL_X, e.movementX); + if (e.movementY !== 0) this.emit(1, EV_REL, REL_Y, e.movementY); + } else { + // Unlocked: emit absolute coords via ABS_X / ABS_Y. + this.emit(1, EV_ABS, ABS_X, Math.round(e.offsetX)); + this.emit(1, EV_ABS, ABS_Y, Math.round(e.offsetY)); + } + this.frame(1); + } + + private onPointerDown(e: PointerEvent) { + const btn = pointerButton(e); if (btn === null) return; + this.emit(1, EV_KEY, btn, 1); + this.frame(1); + } + + private onPointerUp(e: PointerEvent) { + const btn = pointerButton(e); if (btn === null) return; + this.emit(1, EV_KEY, btn, 0); + this.frame(1); + } + + private onWheel(e: WheelEvent) { + e.preventDefault(); + // Linux convention: wheel events are integer ticks. Browsers + // report deltaY in three modes (deltaMode): 0 = PIXEL (Safari on + // macOS, often values like ±1 to ±10), 1 = LINE (Firefox, ±3 per + // notch), 2 = PAGE (rare). Chromium also uses PIXEL but with + // ±100/±120 quanta per notch. Normalise: divide by the + // mode-specific scale, then **clamp small-but-nonzero deltas to + // ±1** so a continuous-trackpad scroll still emits at least one + // tick (otherwise Math.trunc(0.3 / 120) = 0 and the entire + // scroll event disappears). + const scaleY = e.deltaMode === 1 ? 1 : 120; // LINE → 1 per notch + const scaleX = e.deltaMode === 1 ? 1 : 120; + let ticks_y = Math.trunc(e.deltaY / -scaleY); + let ticks_x = Math.trunc(e.deltaX / scaleX); + if (ticks_y === 0 && e.deltaY !== 0) ticks_y = e.deltaY < 0 ? 1 : -1; + if (ticks_x === 0 && e.deltaX !== 0) ticks_x = e.deltaX > 0 ? 1 : -1; + if (ticks_y !== 0) this.emit(1, EV_REL, REL_WHEEL, ticks_y); + if (ticks_x !== 0) this.emit(1, EV_REL, REL_HWHEEL, ticks_x); + if (ticks_y !== 0 || ticks_x !== 0) this.frame(1); + } +} + +function pointerButton(e: PointerEvent): number | null { + switch (e.button) { + case 0: return BTN_LEFT; + case 1: return BTN_MIDDLE; + case 2: return BTN_RIGHT; + default: return null; + } +} +``` + +`key-code-table.ts` is a 130-entry lookup: `KeyA → KEY_A = 30`, +`Escape → KEY_ESC = 1`, etc. Generated by hand from +`linux/input-event-codes.h` (we're matching Linux UAPI verbatim — same +table SDL2's evdev backend would consume on real Linux). Returns +`null` for codes we don't translate (Fn keys some keyboards expose, +locale-specific keys, etc. — they're rare and userspace stacks like +libxkbcommon handle the locale layer anyway). + +**Vitest:** drive a synthetic `KeyboardEvent({ code: 'KeyA' })` into a +`BrowserInputSource` with a dispatch-recording stub; assert two +records emitted in order: `(0, EV_KEY, KEY_A, 1)` then `(0, EV_SYN, +SYN_REPORT, 0)`. Similarly for `keyup` (value=0), `pointermove` with +pointer-lock-mock toggled (`REL_X` vs `ABS_X`), `pointerdown` for +each button, `wheel` for vertical scroll. + +**Commit:** `host(input): BrowserInputSource — DOM capture + KEY_*/BTN_*/REL_*/ABS_* translation` + +--- + +### Task B3: `NodeInputSource` — null-source for tests + headless runs + +**Files:** +- Create: `host/src/input/node-input-source.ts`. + +```ts +// host/src/input/node-input-source.ts +import type { InputSource, InputEvent } from './input-source'; + +/** No-op source. Node host has no DOM; tests drive events directly + * via `kernel.exports.kernel_input_event(…)` calls. The host registers + * this source at boot so the kernel-worker init path is symmetric + * with the browser (CLAUDE.md "dual-host parity"). */ +export class NodeInputSource implements InputSource { + start(_dispatch: (ev: InputEvent) => void): void { /* no-op */ } + stop(): void { /* no-op */ } +} +``` + +**Vitest:** start+stop is a no-op; no records ever emitted unless the +test calls `kernel.exports.kernel_input_event(…)` directly. + +**Commit:** `host(input): NodeInputSource — null-source for headless tests` + +--- + +### Task B4: Wire `kernel_input_event` + dual-host parity + +**Files:** +- Modify: `examples/browser/lib/browser-kernel.ts` — instantiate + `BrowserInputSource(window, canvas)` at boot, wire + `dispatch = (ev) => kernel.exports.kernel_input_event(ev.device, + ev.ev_type, ev.code, ev.value)`. +- Modify: `host/src/node-kernel-host.ts` — instantiate + `NodeInputSource()` (so the boot path is parallel). +- Both: call `kernel.exports.kernel_set_input_canvas_dims(w, h)` once + the canvas is attached. + +Dual-host parity (CLAUDE.md): symmetry check — both entries call +`source.start(dispatchInto(kernel))` after `kernel_set_input_canvas_dims`. + +**Vitest:** run the Node init path; assert `NodeInputSource.start` +was called; assert `kernel.exports.kernel_set_input_canvas_dims` was +invoked exactly once with the test canvas dims. (Browser path tested +in Playwright at Phase C.) + +**Commit:** `host(input): wire kernel_input_event + dual-host boot path` + +--- + +### Task B5: Vitest — end-to-end key + pointer round-trip + +**Files:** +- Create: `host/test/input-evdev.spec.ts`. + +Setup (driven via the Node host + fake DOM events synthesised +directly into `kernel_input_event`): + +- Open `/dev/input/event0` from a fixture process. Assert + EVIOCGNAME returns `"wpk virtual keyboard"`. +- Push `(0, EV_KEY, KEY_A, 1)` then `(0, EV_SYN, SYN_REPORT, 0)`. +- `read(event0, buf, 48)` → 48 bytes; parse two `WpkInputEvent`s; + assert the first is `KEY_A` down, the second is `SYN_REPORT`, + both with monotonic-increasing `tv_sec/tv_usec`. +- Open `/dev/input/event1`. EVIOCGABS(ABS_X) → maximum is the test + canvas width minus 1. +- Push `(1, EV_REL, REL_X, 5) + SYN_REPORT`. `read(event1, …)` → + same shape. +- Push 1100 events without reading → drain → assert ring's first + record is `SYN_DROPPED`, then 1023 of the most recent events. + +**Commit:** `host(input): vitest — end-to-end key + pointer + ring overflow` + +--- + +### Task B6: Phase B — full gauntlet + open PR #2 + +Push, open draft PR. + +Title: `[explore-dri] host(input): InputSource (browser + Node) + plumbing` + +Body: Summary / Why / Verification / **Dual-host parity proof** (both +Node and browser kernel-worker entries instantiate an +`InputSource`, call `start()`, and route dispatch into +`kernel_input_event`; symmetry verified before commit) / Notes. + +--- + +## Phase C — sysroot + demo + browser (PR #3) + +### Task C1: Sysroot input headers — verify musl exports them + +**Files:** +- Inspect: `musl-overlay/include/linux/input.h`, + `musl-overlay/include/linux/input-event-codes.h`. +- If missing or stale: vendor a subset under `musl-overlay/include/linux/`. + +musl ships `<linux/input.h>` upstream as part of the kernel-uapi +headers package (`linux-headers-${VER}`). Verify they're present in +the wasm32 sysroot; if not, vendor: + +```c +// musl-overlay/include/linux/input.h (minimal subset) +#include <linux/input-event-codes.h> +#include <linux/ioctl.h> +#include <sys/time.h> + +struct input_event { + struct timeval time; + __u16 type; + __u16 code; + __s32 value; +}; + +struct input_id { + __u16 bustype, vendor, product, version; +}; + +struct input_absinfo { + __s32 value, minimum, maximum, fuzz, flat, resolution; +}; + +#define EVIOCGVERSION _IOR('E', 0x01, int) +#define EVIOCGID _IOR('E', 0x02, struct input_id) +#define EVIOCGNAME(len) _IOC(_IOC_READ, 'E', 0x06, len) +#define EVIOCGBIT(ev, len) _IOC(_IOC_READ, 'E', 0x20 + (ev), len) +#define EVIOCGABS(abs) _IOR('E', 0x40 + (abs), struct input_absinfo) +#define EVIOCGRAB _IOW('E', 0x90, int) +``` + +`input-event-codes.h` is the giant `KEY_*` / `BTN_*` table; vendor the +upstream copy verbatim (it's ABI-frozen). + +**Verification:** `wasm32posix-cc -c programs/evdev_probe.c` compiles +without missing-include errors; `sizeof(struct input_event) == 24` +(cargo test in A1 already asserts this kernel-side; the userspace- +side assertion is a one-line `_Static_assert` in `evdev_demo.c`). + +**Commit:** `sysroot(input): vendor linux/input.h + input-event-codes.h subset` + +--- + +### Task C2: `programs/evdev_demo.c` — keystroke + pointer log demo + +**Files:** +- Create: `programs/evdev_demo.c`. + +```c +// programs/evdev_demo.c — ~100 LoC +// Opens /dev/input/event0 + event1, polls both for POLLIN, prints +// every record received for 10 simulated seconds. exit 0 if at +// least one keystroke + one pointer move were observed. + +#include <fcntl.h> +#include <linux/input.h> +#include <poll.h> +#include <stdio.h> +#include <string.h> +#include <unistd.h> + +_Static_assert(sizeof(struct input_event) == 24, + "struct input_event must be 24 bytes on wasm32 (musl 64-bit time_t)"); + +int main(void) { + int kbd = open("/dev/input/event0", O_RDONLY | O_CLOEXEC); + int ptr = open("/dev/input/event1", O_RDONLY | O_CLOEXEC); + + char name[256]; + ioctl(kbd, EVIOCGNAME(sizeof name), name); + printf("kbd: %s\n", name); + ioctl(ptr, EVIOCGNAME(sizeof name), name); + printf("ptr: %s\n", name); + + int saw_key = 0, saw_move = 0; + struct pollfd pfds[2] = { + { .fd = kbd, .events = POLLIN }, + { .fd = ptr, .events = POLLIN }, + }; + + for (int tick = 0; tick < 600; tick++) { // 10 s @ 60 Hz polling + int n = poll(pfds, 2, 16 /* ms */); + if (n < 0) break; + struct input_event evs[16]; + if (pfds[0].revents & POLLIN) { + ssize_t r = read(kbd, evs, sizeof evs); + for (size_t i = 0; i < r / sizeof(evs[0]); i++) { + if (evs[i].type == EV_KEY && evs[i].value == 1) { + printf("key down: code=%u\n", evs[i].code); + saw_key = 1; + } + } + } + if (pfds[1].revents & POLLIN) { + ssize_t r = read(ptr, evs, sizeof evs); + for (size_t i = 0; i < r / sizeof(evs[0]); i++) { + if (evs[i].type == EV_REL || evs[i].type == EV_ABS) { + printf("ptr %s code=%u value=%d\n", + evs[i].type == EV_REL ? "rel" : "abs", + evs[i].code, evs[i].value); + saw_move = 1; + } + } + } + } + return (saw_key && saw_move) ? 0 : 1; +} +``` + +Build via `wasm32posix-cc -o programs/evdev_demo.wasm +programs/evdev_demo.c`. Wire into `scripts/build-programs.sh`. + +**Commit:** `examples(input): evdev_demo — POLLIN-driven kbd + ptr log` + +--- + +### Task C3: Vitest end-to-end + +**Files:** +- Create: `host/test/input-evdev-demo.spec.ts`. + +Runs `evdev_demo.wasm` under the centralised kernel; drives synthetic +`KeyboardEvent("KeyA")` and `PointerEvent("pointermove", { offsetX: +100, offsetY: 200 })` into the kernel via `kernel_input_event` calls; +asserts the demo's stdout contains `key down: code=30` (KEY_A) and at +least one `ptr abs code=0 value=100` line. + +**Commit:** `host(input): vitest — evdev_demo end-to-end` + +--- + +### Task C4: Manual browser verification (the gate) + +CLAUDE.md item 6. Build the demo, wire into `examples/browser/pages/ +evdev/`. `./run.sh browser`, navigate; click into the canvas (browser +needs focus); type a few keys, move the mouse; watch the on-canvas +log update in real-time. Smooth, no missed keystrokes, pointer move +deltas reasonable. If pointermove is jittery or keys silently +disappear, check the DOM listener target (window vs. canvas — focus +matters) and the `key-code-table.ts` coverage. + +**No commit yet for this task — verification only.** If the browser +demo fails but Node + Vitest passes, that's a host-parity bug — PR +#410 cautionary tale (CLAUDE.md "Two hosts" rules). + +--- + +### Task C5: Phase C — final gauntlet + open PR #3 + +PR title: `[explore-dri] examples(input): evdev_demo + browser spec` + +Body: Summary / Why / Verification (gauntlet + browser screenshot of +the on-canvas log with a typed-keystroke + pointer-move record) / +Dual-host parity proof / Notes. + +--- + +## Final coordinated merge + +When all three PRs (kernel, host, examples) are reviewed and approved, +and Brandon has signed off on the demo running cleanly in browser + +Node: + +1. Re-run the full gauntlet on each PR's branch tip. +2. Squash-merge PR #1 → PR #2's base. +3. Squash-merge PR #2 → PR #3's base. +4. Squash-merge PR #3 → plan 4's `…-kms-demo` (or wherever plan 4's + tip lives at the time). +5. Tag: `[explore-dri-evdev] milestone (input) merged at <sha>` in + the next session-handoff doc. + +**Do not push to upstream until v1 + plans 2–5 are all merged +upstream as a coherent chain.** + +--- + +## Trade-offs already locked in (don't relitigate during implementation) + +- **Two devices only — `event0` (kbd), `event1` (ptr).** No + `/dev/input/event2` joystick / touch in v1. SDL2's evdev backend + enumerates `/dev/input/event*` and uses whatever's there; two + devices is the minimum useful set. Joystick / touch are post-v1 + (will require browser GamepadAPI / Touch capture, neither of + which the v1 design budgets). +- **Every open OFD gets every event.** No focus routing. Plan 9's + wpkcompositor adds the EVIOCGRAB-based focus gate. The v1 + evdev_demo opens one fd so the eavesdropping hazard isn't + surfaced; security-sensitive deployments must wait for plan 9. +- **`CLOCK_MONOTONIC` timestamps.** Same clock as plan 4's vblank + events — readers can compare timestamps directly for latency + profiling. No `EVIOCSCLOCKID` (the only knob Linux exposes here); + fixing the clock removes a class of footguns (Y2K38, DST, NTP + skew) and we don't have a `CLOCK_REALTIME` source worth offering + in the Wasm sandbox anyway. +- **`struct input_event = 24 bytes` on wasm32.** Matches musl's + 64-bit-time_t layout, which matches modern Linux 64-bit + userland. Userspace `_Static_assert` in C2 locks the + expectation; kernel-side static-assert in A1 Step 2 locks the + layout. +- **Ring cap = 1024 records per OFD; oldest dropped on overflow + with `SYN_DROPPED` marker.** Linux UAPI convention. Userspace + responds to `SYN_DROPPED` by re-querying `EVIOCG*` state. +- **No `EVIOCSREP` / `EVIOCSCLOCKID` / `EVIOCSKEYCODE` (write + ioctls).** Keyboard repeat is the browser's job (it sets + `KeyboardEvent.repeat` via the OS auto-repeat); clock is fixed + MONOTONIC; the kernel doesn't expose a remappable keymap (locale + / layout is libxkbcommon's job at plan-8 time). +- **No `/dev/input/mice` or `/dev/input/mouse*` legacy nodes.** + Those are PS/2 emulation; SDL2 / libinput prefer the modern + evdev path. Skipping them shaves ~100 LoC of compatibility + shim. +- **`BUS_VIRTUAL` for `EVIOCGID.bustype`.** Closest match to + "host-synthesised event source". Linux's `uinput` driver does + the same. +- **No host imports.** Kernel is consumer, host is producer; the + control flow is one-way (`kernel_input_event` export only). Plan + 4 layered host imports for KMS because the host needed to be + notified of state changes (master, addfb, set_fb). Input has no + state for the host to react to; the host's `InputSource` is a + self-contained DOM listener. +- **Plan 5 does not join plan 4's `DriOfdState` enum.** Input + state and DRI state are disjoint; a single `OpenFileDesc.input: + Option<Box<InputFdState>>` field beats nesting the input state + under a `DriOfdState::InputEvent { … }` variant. Plan 6 (ALSA) + will likely follow the same shape — its own + `Option<Box<AlsaFdState>>` rather than joining DRI's enum. + +--- + +## Risk register + +1. **`KeyboardEvent.code` → `KEY_*` table coverage.** The 130- + entry table in `key-code-table.ts` covers US-ASCII + common + navigation + function keys; locale-specific keys (e.g., AltGr- + composed characters, Japanese IME state) are not translated. + *Mitigation:* document the gap; libxkbcommon at plan-8 time + handles locale-layer translation independently. +2. **macOS Cmd-key keyup loss.** Browser-level bug: Safari and + Chrome on macOS don't fire `keyup` for keys held while Cmd is + pressed. *Mitigation:* on every `keydown` with `metaKey = true` + that arrives without an intervening `keyup` for the same code, + emit a synthetic release pair. Document the workaround; not + strictly correct but matches user expectation. +3. **Pointer lock interaction.** Locked pointer emits `movementX/Y` + (REL_*); unlocked emits `offsetX/Y` (ABS_*). Apps that mix + modes (e.g., FPS toggle to menu) see the device-coordinate + model change underneath them. *Mitigation:* document; emit + `EV_SYN { code: SYN_REPORT }` on lock-state change to give + readers a re-sync point. The plan-9 compositor may want to + layer "logical absolute coords" on top. +4. **Ring overflow under sustained high-rate input.** A + 1000-event/sec mouse drag fills a 1024-record ring in 1 + second; a paused or wedged reader will see SYN_DROPPED. + *Mitigation:* the bound is correct (24 KiB per OFD, capped + memory growth); the userspace expectation is "drain on every + poll wake" which SDL2 / libinput do. +5. **Vertical-wheel quantisation across browsers.** Chromium uses + `deltaY = ±100` per tick; Firefox uses `±120`; macOS Safari + uses `±1` (continuous). *Mitigation:* the host emits one + `REL_WHEEL` per quantum, clamping to ±1 if `deltaMode === 0` + (PIXEL) and the absolute value is below the tick threshold. + Document the quantum normalisation in `BrowserInputSource`. +6. **`/dev/input/event*` device-number-vs-class mismatch with + real Linux.** On real Linux, the kernel-side device order is + driver-load-dependent; SDL2 enumerates and queries each via + EVIOCGID. We pin `event0 = kbd, event1 = ptr` by convention. + *Mitigation:* document; SDL2's enumerate-and-query path works + correctly because each device's EVIOCGID returns a distinct + product, so the order doesn't matter to userspace. + +--- + +## What this plan doesn't cover (deferred) + +- **Joystick / gamepad** (`/dev/input/js*`, GamepadAPI). v2+. + Will need a third virtual device + browser GamepadAPI capture. +- **Touch input** (Touch + multi-touch via `ABS_MT_*` codes, + TouchEvent / PointerEvent type=touch). v2+. The `ABS_MT_*` + protocol is significantly larger than ABS_X/ABS_Y. +- **`uinput`** (`/dev/uinput`, the userland-pushes-events device). + v2+. Lower priority; mostly useful for test harnesses and + accessibility tools we don't ship. +- **Focus routing via EVIOCGRAB.** The kernel records the grab + but doesn't enforce. Plan 9's wpkcompositor adds the gate. +- **Keymap / layout** (`EVIOCSKEYCODE`, `xkb_keymap`). Plan 8 (Xkb / + libxkbcommon port) layers locale-aware translation on top of the + raw KEY_* stream. +- **Auto-repeat tuning** (`EVIOCSREP`). Browser auto-repeat is + governed by the OS; we don't expose tuning. Plan 9 may surface + a compositor-level setting if needed. +- **`/dev/input/mice` legacy PS/2 emulation.** Dead protocol; SDL2 + and libinput don't need it. +- **ALSA** (`/dev/snd/*`). Plan 6. +- **SDL2 port** (milestone D, plan 7) — requires plans 3 + 4 + 5 + 6. +- **wpkcompositor** (plans 8-9) — focus routing + keymap. + +--- + +End of plan. diff --git a/docs/plans/2026-06-22-dri-alsa-plan.md b/docs/plans/2026-06-22-dri-alsa-plan.md new file mode 100644 index 0000000000..3d7e3b2ad8 --- /dev/null +++ b/docs/plans/2026-06-22-dri-alsa-plan.md @@ -0,0 +1,2958 @@ +# DRI v2 — ALSA plan (`/dev/snd/pcmC0D0p` + `controlC0`) + +> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to execute this plan task-by-task. + +**Goal:** Add the two `/dev/snd/*` device nodes the ALSA / SDL2 audio +stacks look for first — `/dev/snd/controlC0` (card-level control +surface) and `/dev/snd/pcmC0D0p` (PCM playback device 0 of card 0) — +exposing Linux's ALSA UAPI: a `read`-only `mmap_status` page + +`read-write` `mmap_control` page + interleaved-write PCM data ring, +the `SNDRV_PCM_IOCTL_*` parameter-negotiation + state-machine ioctls +that alsa-lib drives, and `poll(POLLOUT)` readiness on the pcm fd. +Audio data flows user → kernel-ring → host-side AudioWorklet → output +device on the browser; user → kernel-ring → silent-discard on Node +(with a checksum hook for tests). The plan ships the **device + ring ++ ioctl surface + AudioWorklet bridge** ; the full ALSA mixer surface +(controlC0 ELEM_WRITE / power-management / hot-plug) is deferred to +plan 9 (wpkcompositor) which is the only PID expected to drive +mixer state. + +**Architecture:** Two virtual devices, sourced through a single +host-side `AudioDriver` per host class (browser AudioWorklet, Node +setInterval-dummy). `/dev/snd/pcmC0D0p` exposes the PCM playback +state machine + interleaved-write ring; `controlC0` exposes a +minimal card-info / element-list surface (no writes in v1). Per-OFD +`AlsaFdState` carries the PCM state (`HW_PARAMS` cache, mmap-status +page, mmap-control page, SAB-backed audio ring) on a pcm OFD; a +separate `AlsaControlFdState` carries the simpler control-fd state. +A new kernel export `kernel_audio_period_tick(pcm_id, frames_consumed)` +is the single entrypoint from host → kernel — the host calls it once +per AudioWorklet quantum (browser) or per `setInterval` tick (Node) +to advance `mmap_status->hw_ptr` and wake `poll(POLLOUT)` waiters. +The PCM data ring is a SharedArrayBuffer allocated host-side at boot +and mmap'd into the kernel (the kernel sees it as a `&mut [i16]` +slice into its linear memory). Userspace's `mmap(pcm_fd, ..., +SNDRV_PCM_MMAP_OFFSET_DATA)` returns the same SAB region, so +`WRITEI_FRAMES` is implemented by user-process direct writes into the +SAB followed by an `appl_ptr` advance in the mmap_control page — +zero kernel-copy on the audio data path. Companion design doc: +`docs/plans/2026-05-18-dri-design.md` §8 (Audio) + §16 q6 +(`CLOCK_MONOTONIC` timestamps). + +**Tech Stack:** Rust kernel (wasm64), TypeScript host (browser +AudioWorklet for the playback consumer, Node setInterval for the +dummy), C user programs cross-compiled with `wasm32posix-cc`. +Sysroot side vendors `<sound/asound.h>` from Linux UAPI under +`musl-overlay/include/sound/`. No alsa-lib stub in v1 — the demo +issues ioctls directly via syscalls; full alsa-lib port is plan 7's +problem (SDL2's audio backend pulls in alsa-lib). + +**Critical wasm32 ABI detail — ALSA structs are LARGE and contain +many UAPI flags.** `struct snd_pcm_hw_params` is 596 bytes on +wasm32 (matches Linux's `__u32`-and-bitmap layout). `struct +snd_pcm_sw_params` is 136 bytes. `struct snd_pcm_status` is 128 +bytes. We vendor `sound/asound.h` verbatim from Linux v6.10 UAPI +headers; static-asserts in Task A1 Step 2 lock the sizes against +the wasm32 `repr(C)` layout. Any drift means alsa-lib (when plan 7 +ports it) reads the wrong bytes. Field-offset asserts on the most +load-bearing fields (`access`, `format`, `rate`, `channels`, +`period_size`, `buffer_size`) inside `snd_pcm_hw_params` cover the +high-traffic path. + +**Clock source: `CLOCK_MONOTONIC`** (design §16 q6). Every +`snd_pcm_status.audio_tstamp / tstamp / trigger_tstamp` field +filled from `crate::time::monotonic_us()` — same source plan 4 A7 +and plan 5 A4 use. Lets userspace correlate audio underrun +timestamps with vblank + input timestamps for jitter / A-V-sync +profiling. No `SNDRV_PCM_IOCTL_TTSTAMP` (clock-source-selection +ioctl) — clock is fixed. + +**Design reference:** `docs/plans/2026-05-18-dri-design.md` §8.1 +(devices), §8.2 (ioctl shape), §8.3 (v1 dummy backend), §8.4 (v2 +WebAudio backend); §16 q6 (clock source). POSIX vs Linux UAPI: +`open` / `close` / `read` / `write` / `poll` / `ioctl` / `mmap` +are POSIX; `struct snd_pcm_*` layouts + `SNDRV_PCM_IOCTL_*` numbers ++ ALSA flag constants are Linux UAPI, followed verbatim. + +**Consistency with plans 2 + 3 + 4 + 5:** +- Plan 5 introduced `VirtualDevice::InputEvent { device: u8 }` and + `OpenFileKind::InputEvent { device: u8 }`. Plan 6 adds + `VirtualDevice::AlsaPcm { card: u8, device: u8, sub: u8, kind: + PcmDir }` and `VirtualDevice::AlsaControl { card: u8 }`. + Single-card-single-device-single-substream in v1 (`card=0`, + `device=0`, `sub=0`, `kind=PcmDir::Playback`). The + enum-variant-with-payload keeps the match exhaustive without + proliferating top-level variants. +- Plan 5's `Option<Box<InputFdState>>` field on `OpenFileDesc` is + the precedent. Plan 6 adds `audio: Option<Box<AlsaFdState>>` as + a third sibling of `dri_state` + `input` — disjoint state machine, + separate field. The "one box per device class" factoring plan 5 + established holds. +- Plan 5's `kernel_input_event` taught the kernel about host-driven + consumer callbacks. Plan 6 A6's `kernel_audio_period_tick` is + similar shape but the dataflow is INVERTED: + - **plan 5**: host → kernel push (DOM events → kernel ring → + userspace `read`). + - **plan 6**: userspace → kernel-mediated SAB → host pull + (WRITEI_FRAMES → SAB → AudioWorklet `process()` quantum). The + host's "tick" is a *notification* that frames were consumed, + not a *data push*. +- Plan 4 A7 + plan 5 A4 both lock the PROCESS_TABLE briefly to + iterate OFDs. Plan 6 A6 does the same. The "OFD-table split" + architecture-open in plan 4 (carried forward in plan 5) gains + one more producer here — period-tick rate is low (~50 Hz at + 16 ms period; AudioWorklet quantum is 128 frames / 48 kHz ≈ 375 Hz + if used as a direct trigger), but at 375 Hz combined with input's + 1000+ Hz autorepeat + drag, the lock-contention argument for the + refactor strengthens further. +- Plan 4's `CLOCK_MONOTONIC` clock-source + plan 5's same clock + pinning carry through. All three event streams (vblank, input, + audio) reference the same `monotonic_us()` helper. + +**Stack base:** Plan 5's `…-evdev-demo` branch tip. Plan 6 extends +the kernel + host without touching plans 2–5's surfaces; the +dispatcher gains `AlsaPcm` + `AlsaControl` arms on `VirtualDevice`, +the syscall layer adds an `audio::*` module, and the host gains +an `audio/` subdirectory mirror of `input/`. No regressions to +plans 2–5 tests. + +**Branch:** `emdash/explore-direct-rendering-infrastructure-alsa-plan-XXXXX` +(chains off plan 5 per the branching rule). Three sub-branches stack +off it. + +**Final PR base:** Plan 5's `…-evdev-demo` tip. Do not merge until +Brandon validates the design, plan 5 lands, and Phase C's manual +browser verification passes (CLAUDE.md item 6). + +**Three PRs, coordinated merge.** Each task below is one commit. +Brandon's `scope(area): action` titles: + +1. `kernel(audio): /dev/snd/{controlC0,pcmC0D0p} + SNDRV_PCM_IOCTL_* + mmap pages` +2. `host(audio): AudioDriver (browser AudioWorklet, Node dummy) + SAB ring bridge` +3. `examples(audio): alsa_demo + browser spec` + +PR base/head topology (stacked): + +``` +explore-webgl-exposition-demo (v1 tip) + └── …-buffer-plan-XXXXX (plan 2) + └── …-buffer-demo + └── …-multiplexer-plan-YYYYY (plan 3) + └── …-mux-demo + └── …-kms-plan-ZZZZZ (plan 4) + └── …-kms-demo + └── …-evdev-plan-WWWWW (plan 5) + └── …-evdev-demo + └── …-alsa-plan-VVVVV (this plan) + └── …-alsa-kernel (PR #1) + └── …-alsa-host (PR #2) + └── …-alsa-demo (PR #3) +``` + +**Verification gauntlet** (CLAUDE.md): all of the below must pass +with zero regressions before any PR is opened, and re-run before final +merge: + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +`XFAIL` / `TIME` are acceptable; `FAIL` that isn't pre-existing is a +regression. Phase C adds manual `./run.sh browser` verification of +the alsa demo (CLAUDE.md item 6) — a 440 Hz sine wave is audible for +~2 s through the browser's default audio output. + +**ABI impact:** **Additive only — no `ABI_VERSION` bump.** Per +`docs/abi-versioning.md` (PR #490 policy): +- New `repr(C)` structs in `shared::audio` mirroring Linux's + `sound/asound.h` UAPI (sizes static-asserted in A1 Step 2): + - `WpkAlsaPcmHwParams` (596 bytes) + - `WpkAlsaPcmSwParams` (136 bytes) + - `WpkAlsaPcmStatus` (128 bytes) + - `WpkAlsaPcmInfo` (288 bytes) + - `WpkAlsaPcmMmapStatus` (64 bytes; the kernel-writes-userspace-reads page) + - `WpkAlsaPcmMmapControl` (64 bytes; the userspace-writes-kernel-reads page) + - `WpkAlsaCtlCardInfo` (256 bytes) + - `WpkAlsaCtlElemId` (64 bytes) + - `WpkAlsaCtlElemList` (76 bytes; rounded to 80 for alignment — verify) +- New ioctl numbers — verbatim Linux UAPI for the `'A'` magic (ALSA's + ctl ioctls use `'U'`; the kernel uses `'A'` for PCM, `'U'` for ctl): + - PCM `'A'` magic: + - `SNDRV_PCM_IOCTL_PVERSION = _IOR('A', 0x00, int)` → `0x8004_4100` + - `SNDRV_PCM_IOCTL_INFO = _IOR('A', 0x01, WpkAlsaPcmInfo)` → `0x8120_4101` + - `SNDRV_PCM_IOCTL_HW_REFINE = _IOWR('A', 0x10, WpkAlsaPcmHwParams)` → `0xc254_4110` + - `SNDRV_PCM_IOCTL_HW_PARAMS = _IOWR('A', 0x11, WpkAlsaPcmHwParams)` → `0xc254_4111` + - `SNDRV_PCM_IOCTL_HW_FREE = _IO('A', 0x12)` → `0x0000_4112` + - `SNDRV_PCM_IOCTL_SW_PARAMS = _IOWR('A', 0x13, WpkAlsaPcmSwParams)` → `0xc088_4113` + - `SNDRV_PCM_IOCTL_STATUS = _IOR('A', 0x20, WpkAlsaPcmStatus)` → `0x8080_4120` + - `SNDRV_PCM_IOCTL_PREPARE = _IO('A', 0x40)` → `0x0000_4140` + - `SNDRV_PCM_IOCTL_START = _IO('A', 0x42)` → `0x0000_4142` + - `SNDRV_PCM_IOCTL_DROP = _IO('A', 0x43)` → `0x0000_4143` + - `SNDRV_PCM_IOCTL_PAUSE = _IOW('A', 0x45, int)` → `0x4004_4145` + - `SNDRV_PCM_IOCTL_WRITEI_FRAMES = _IOW('A', 0x50, WpkAlsaXferi)` → `0x4018_4150` + - Ctl `'U'` magic: + - `SNDRV_CTL_IOCTL_PVERSION = _IOR('U', 0x00, int)` → `0x8004_5500` + - `SNDRV_CTL_IOCTL_CARD_INFO = _IOR('U', 0x01, WpkAlsaCtlCardInfo)` → `0x8100_5501` + - `SNDRV_CTL_IOCTL_ELEM_LIST = _IOWR('U', 0x10, WpkAlsaCtlElemList)` → `0xc04c_5510` + - Sizes encoded in each constant verified vs the wasm32 struct + `size_of` in Task A1 Step 2. +- Two new kernel-wasm exports: + - `kernel_audio_period_tick(pcm_id: u32, frames_consumed: u32)`. + Host fires this on every AudioWorklet quantum (browser) / + setInterval tick (Node). Advances `mmap_status->hw_ptr` by + `frames_consumed` and wakes `poll(POLLOUT)` waiters when + space frees up. + - `kernel_audio_init_sab(pcm_id: u32, sab_base: u64, sab_len: + u32)`. Host calls once at boot per pcm to hand the kernel a + SharedArrayBuffer pointer (in the kernel's linear memory; the + host has imported it into the kernel-worker's address space via + the existing memory-import mechanism). Kernel stores the base + + len in `crate::audio::sab_table` keyed by `pcm_id`. Subsequent + `mmap(pcm_fd, ..., SNDRV_PCM_MMAP_OFFSET_DATA)` calls return + this region into the user process. *(Additive export; does not + change existing signatures.)* +- **No new `host_audio_*` imports.** Asymmetric with plan 4's KMS, + symmetric with plan 5's input. The kernel's only host-direction + signal is "tick" (period-completion notification); data flows + through the SAB without crossing the kernel-host boundary. The + host's `AudioDriver` is a self-contained AudioWorklet (browser) + or setInterval (Node). +- No change to v1 `host_gl_*`, plan 2 `host_gbm_*`, plan 3 + `host_gl_bind_foreign_texture`, plan 4 `host_kms_*`, plan 5 + (no new imports), any existing struct layout, ioctl number, + channel layout, syscall number, or asyncify slot. + +Existing structs, ioctls, imports, and exports — all unchanged. + +--- + +## Pre-implementation review + +Devil's-advocate pass run in the next session after drafting; findings +below are structured Brandon-style. Eight inline fixes folded into the +plan body in the same session; the open correctness + open +architecture items are load-bearing and must be picked before any +kernel code lands. One cross-plan note leaks back to plan 4 +(OFD-table-split urgency upgrade); plan 5's surface is unaffected +(boot-ordering pattern is consistent; no struct layout collisions). + +### Inline fixes (8 — folded into the plan body) + +- **`snd_pcm_hw_params` layout was wrong on six counts; the "596 + bytes ground truth" is itself suspect.** The placeholder declared + `masks: [u32; 8]` (32 bytes), `intervals: [WpkSndInterval; 12]` + (16 bytes each), `fifo_size: u64`, and a `WpkSndInterval` that's + 16 bytes instead of Linux's 12. Linux's actual layout is `flags:4 + + masks:3 * 32 + mres:5 * 32 + intervals:12 * 12 + ires:9 * 12 + + rmask/cmask/info/msbits/rate_num/rate_den:6 * 4 + fifo_size: + unsigned long + reserved:64` — total 608 on x86_64 (`unsigned long` + = 8) and 604 on wasm32 (`unsigned long` = 4). The "596" figure in + the header notes appears to come from an older kernel version + (pre-2018, before `ires[]` grew to 9). The placeholder's + array-length-iteration instruction would have guided future-me into + faking the size by inserting padding, not by adding the missing + reserved arrays. Three follow-on failures: (a) HW_REFINE's interval + walk would index past the end of an under-sized array and corrupt + adjacent fields; (b) alsa-lib computes its own + `sizeof(snd_pcm_hw_params)` and compares against the ioctl's + encoded size (the `_IOC_SIZE(0xc254_4110) == 0x254 == 596` figure + is itself wrong on wasm32 — must be 604); (c) the masks-as-`[u32;8]` + layout means `SNDRV_PCM_HW_PARAM_FORMAT`'s 256-bit mask gets + truncated to 256 bits across the wrong storage. *Fixed: A1 Step 1 + redefined to: `WpkSndMask { bits: [u32; 8] }` (32 B); + `WpkSndInterval { min:u32, max:u32, flags_packed:u32 }` (12 B — + drop the `_pad: u32`, Linux doesn't pad here); `WpkAlsaPcmHwParams + { flags:u32, masks:[WpkSndMask;3], mres:[WpkSndMask;5], + intervals:[WpkSndInterval;12], ires:[WpkSndInterval;9], rmask:u32, + cmask:u32, info:u32, msbits:u32, rate_num:u32, rate_den:u32, + fifo_size:u32, reserved:[u8;64] }` — total 604 on wasm32. The + ioctl number recomputed from `_IOWR('A', 0x10, struct + snd_pcm_hw_params)` with size=604 = `0xc25c_4110`, NOT + `0xc254_4110`. ABI impact section updated with the corrected + numbers; A1 Step 2's size_of_val assertion uses 604. Iteration + budget removed — the layout is now derivable from Linux source + directly without `cargo expand` guesswork.* +- **`WpkAlsaCtlCardInfo` was trimmed from 376 B (Linux) to 256 B by + shrinking `components[128]` to 16; that's a silent ABI break.** + alsa-lib reads `components` as a NUL-terminated 128-byte string + via `snd_ctl_card_info_get_components(info)` which returns the + pointer into the embedded array. A library compiled against + Linux's 376-byte header reads bytes 248..376 of the ioctl reply + as `components[]` — bytes that, in the trimmed layout, contain + whatever comes after the kernel's struct in the ioctl buffer + (junk on first call, possibly stack data on subsequent calls). + *Fixed: A1 Step 1's `WpkAlsaCtlCardInfo` kept at full 376 bytes + with the trailing `components[128]` field; the field can be + left zero-filled (libasound tolerates an empty components + string). Size assertion in A1 Step 2 uses 376. Ioctl + `SNDRV_CTL_IOCTL_CARD_INFO`'s encoded size becomes + `0x8178_5501`, NOT `0x8100_5501`.* +- **`WpkAlsaPcmMmapStatus` size was claimed 64 but plan's field list + totals 56 (and Linux's actual is 48 on a 32-bit ABI).** Linux's + `struct snd_pcm_mmap_status` is `state:4 + pad1:4 + hw_ptr:long + + tstamp:struct timespec + suspended_state:4 + audio_tstamp: + struct timespec`. On 32-bit Linux that's 4+4+4+8+4+8 = 32 bytes; + on 64-bit Linux 4+4+8+16+4+pad4+16 = 56 bytes. The plan's struct + uses `hw_ptr:i64` (which contradicts wasm32's `unsigned long` = + 4 bytes for `snd_pcm_uframes_t`) and computes to 56, not the + claimed 64. Test would fail on first cargo run. *Fixed: A1 Step + 1's `WpkAlsaPcmMmapStatus` adjusted to `hw_ptr:u32` (matches + wasm32 `snd_pcm_uframes_t`), `audio_tstamp_data:u32` retained, + trailing pad added explicitly to land at 56 bytes. The + `audio_tstamp_sec/nsec` fields use `i64`/`i32` matching wasm32's + `struct timespec` (8+4=12 with 4 trailing pad = 16). Final size: + 56 bytes. Size assertion updated; A1 Step 2's + `audio_mmap_status_field_offsets` test updated to assert hw_ptr + at offset 8, tstamp_sec at offset 12, audio_tstamp_sec at offset + 32. C side's userspace `_Static_assert(sizeof(struct + snd_pcm_mmap_status) == 56)` matches.* +- **`BrowserAudioDriver`'s `quantaPerPeriod = Math.ceil(periodFrames + / 128)` drifts hw_ptr against actual frames-consumed.** With + `periodFrames=1024`, ceil(1024/128) = 8 quanta, and 8 × 128 = + 1024 = periodFrames. Aligned. But with any periodFrames not a + multiple of 128 (HW_REFINE accepts `period_size in [64..4096]` + per plan), the accumulator fires `kernelTick(pcmId, periodFrames)` + every `quantaPerPeriod` quanta — which equals + `quantaPerPeriod × 128 ≠ periodFrames` frames of actual + consumption. Hw_ptr drifts; XRUN fires spuriously after a few + hundred periods. *Fixed: B2 collapsed to per-quantum tick. The + worklet posts `{ framesConsumed: 128 }` per quantum; the + main-thread `port.onmessage` immediately calls + `kernelTick(pcmId, 128)`. No accumulator. Kernel-side period_tick + becomes "frames_consumed_since_last_tick" not "1 period's worth"; + hw_ptr advances per quantum granularity, latency unchanged + (~2.67 ms quantum is well below alsa-lib's period expectation). + HW_REFINE no longer needs the "period_size must be multiple of + 128" constraint. A6's kernel-side test + `tick_advances_hw_ptr_by_frames_consumed` updated to fire 8 + ticks of 128 frames each and assert hw_ptr == 1024.* +- **WRITEI_FRAMES wrapped `appl_ptr` at `ring_frames`, not + `sw_params.boundary`.** Linux semantics: `mmap_control.appl_ptr` + is a monotonic counter modulo `sw_params.boundary` (typically + `boundary = 0x4000_0000` or some large power-of-2); `hw_ptr` is + monotonic in the same modulus. The userspace `avail = appl_ptr - + hw_ptr` (modulo boundary) gives frames-in-flight without ever + wrapping at the ring-buffer boundary. alsa-lib + SDL2 rely on + this: `appl_ptr` and `hw_ptr` are 1024-bit counters in the + abstract, modulo a large power-of-2 in practice. The plan's A4 + `ctl.appl_ptr = (appl + to_write) as i64` is monotonic (no + wrapping), but A5's mmap-direct path lets userspace write + `ctl->appl_ptr` directly; plan's docs don't pin `boundary` + semantics. *Fixed: A1's `HwParamsCache` gains an inferred + `boundary: u64` field (computed at HW_PARAMS time as + `buffer_size * floor((1<<30) / buffer_size)` — same shape Linux + uses); the kernel's `avail` computation in A6/A7 becomes + `(appl_ptr - hw_ptr + boundary) % boundary`; SW_PARAMS's + `boundary` field is mirrored back into HwParamsCache. Userspace + `mmap_control.appl_ptr` is never bounded by the kernel — userland + wraps at `boundary` on its own (per Linux). Two new cargo tests: + `writei_appl_ptr_wraps_at_boundary_not_ring_frames`, + `avail_computation_handles_boundary_wrap`.* +- **XRUN detection condition `hw_ptr > appl_ptr` fires one tick + late and uses the wrong direction.** Underrun is when the + consumer would consume frames that haven't been produced, i.e., + the next quantum's tick would advance hw_ptr past appl_ptr. The + plan's A6 sets XRUN after `hw_ptr` already passed `appl_ptr`, + which means the worklet has already emitted garbage / stale + samples for that quantum. Also the comparison is in the wrong + monotonic-modulo space (see fix above). *Fixed: A6's tick + predicts the next-quantum advance: `if (hw_ptr + frames_consumed) + modulo boundary > appl_ptr modulo boundary { XRUN }`. The + worklet's `process()` checks the kernel's state via the + mmap_status page; if state == XRUN, it emits silence for that + quantum instead of reading stale samples from the ring. A6's + test `tick_underrun_transitions_state_to_xrun` updated to fire + the XRUN-causing tick and assert hw_ptr did NOT advance past + appl_ptr (kernel clamps to appl_ptr to keep counters sane).* +- **`mmap_status` / `mmap_control` page lifetime tied to OFD is + wrong vs Linux semantics.** Linux's mmap'd kernel pages stay + valid across `close(fd)` until the last `munmap` — Linux's + per-VMA reference count, not per-fd. The plan's A9 drops the + `Box<WpkAlsaPcmMmapStatus>` on `on_final_close`; if userspace + has `mmap`'d the page and not yet `munmap`'d, the next + user-side dereference is a use-after-free. Browser would crash + (dereferencing freed wasm linear memory is unspecified but + usually a SIGBUS-equivalent), Node would silently read garbage. + *Fixed: A1 Step 1 changes `mmap_status` / `mmap_control` from + `Option<Box<…>>` to `Option<Arc<Mutex<…>>>` on `AlsaFdState`; + A5's `handle_alsa_mmap` clones the Arc into a `MappingKind:: + KernelOwnedArc(arc)` entry in the VMA table; A9's `on_final_close` + drops the OFD's Arc but the VMA table's clone keeps the page + alive until munmap. New cargo test: + `mmap_status_page_survives_close_until_munmap`. A note: this + ties to the open architecture item "Arc-mapping infrastructure" + below — the existing mmap-VPN code doesn't yet model + Arc-borrowed kernel pages.* +- **SAB-import mechanism doesn't match the existing + `WebAssembly.Memory` sharing pattern; "host allocates SAB" framing + is backwards.** The codebase's existing pattern (verified in + `host/src/framebuffer/registry.ts` and `canvas-renderer.ts`) is: + `kernel.memory` is a `WebAssembly.Memory({shared: true})`; + `kernel.memory.buffer` IS a SharedArrayBuffer; the host reads + kernel-allocated regions by indexing into that buffer. There's no + "import a separate SAB into the kernel's linear memory" path — + WebAssembly's multi-memory proposal isn't widely deployed yet, and + even if it were, importing a host-side SAB as a second `Memory` + doesn't gain anything over the kernel's existing shared linear + memory. The plan's `kernel_audio_init_sab(base, len)` export is + inverted: the *kernel* allocates the ring inside its own heap and + the *host* gets the (base, len) to forward to the AudioWorklet. + *Fixed: A4 Step 2's `kernel_audio_init_sab` replaced with + `kernel_audio_alloc_ring(pcm_id: u32, len: u32) -> u32` — kernel + allocates the ring in its heap, returns base offset (relative to + `kernel.memory.buffer`); host stores `(base, len)` and forwards + to the AudioWorklet via `worklet.port.processorOptions = { + sab: kernel.memory.buffer, offset: base, len }`. The worklet + constructs `new Int16Array(sab, offset, len/2)` and reads + directly from the kernel's linear memory. Userspace `mmap(pcm_fd, + ..., OFFSET_DATA)` returns the same kernel-side region via the + same `MappingKind::KernelOwnedArc` arm (the ring is an + `Arc<[i16; N]>` boxed at kernel-alloc time). Risk register #6 + + open-architecture "kernel-side SAB import path" both retired; + one new arch open: "Arc-mapping infrastructure" (covers this + case + the mmap_status/control case from inline fix #7). B4's + one-day SAB-import spike retired; replaced by a B4 sanity check + that the worklet's `processorOptions` accepts a + `WebAssembly.Memory.buffer` SAB (it does — that's existing + pattern).* + +### Correctness — open, address before kernel PR opens + +- **`mmap_control.appl_ptr` is user-writable via the mmap path AND + via WRITEI_FRAMES; double-update can race.** alsa-lib uses one + path OR the other (mmap-direct vs rw_interleaved), but a + misbehaved program could mix them. The kernel's WRITEI handler + reads `appl_ptr` and writes it back; if userspace also writes + `mmap_control.appl_ptr` from another thread, the writes + interleave. Linux doesn't synchronise this either; alsa-lib's + state machine forbids mixing. *Lean:* document v1 behaviour as + "undefined if mixed"; no kernel-side enforcement. Add a vitest + that documents this behaviour explicitly (matches Linux). Same + shape as plan 5's "grabbed flag races on close" — userspace's + responsibility. +- **HW_REFINE's `[64..4096]` period_size range admits non-power-of-2 + values.** Linux's evdev-equivalent for ALSA — the + `snd_pcm_hw_constraint_pow2` constraint — typically restricts + period and buffer sizes to powers-of-2 for hardware DMA alignment. + Our backend doesn't have DMA, but SDL2 + alsa-lib both default to + power-of-2 sizes and may not handle non-power-of-2 buffer_size + gracefully (libasound's ring-buffer math assumes the bit-mask + trick `(x & (size-1))` works). *Lean:* clamp HW_REFINE's + buffer_size and period_size to powers-of-2 in `[64..4096]` and + `[256..16384]` respectively. The clamp simplifies kernel-side + modulo arithmetic (replace `% ring_frames` with `& (ring_frames - + 1)`) and matches what real ALSA cards do. Folded as a follow-up + note at A3; new cargo test + `hw_refine_clamps_non_power_of_2_buffer_size`. +- **AudioContext autoplay-policy + lean (c) "start unconditionally" + contradiction.** Risk register #5 says AudioContext starts + "suspended" until a user gesture. Lean (c) says the driver starts + unconditionally at boot. These are consistent (the AudioContext + *graph* runs as soon as it's resumed; the worklet's `process()` + doesn't fire while suspended) but the plan never spells this out. + A future implementer reading B4 will be confused — is "start" + blocking on user-gesture or not? *Lean:* B4's `driver.start()` + for the browser constructs the AudioContext in `suspended` state + (the default), wires the worklet, returns immediately. The + *first* user gesture on the page calls `audioCtx.resume()`; until + then no `process()` fires (so no `kernel_audio_period_tick` calls + either; kernel sees hw_ptr stay at 0 which is correct — there's + literally no consumer). Document this in B2's commit body and + add a Node-side note that NodeAudioDriver has no autoplay gate + (setInterval fires immediately). Test: B2 vitest mocks + AudioContext with `state: "suspended"` and asserts no kernelTick + fires until `resume()` is called. + +### Architecture — open (LOAD-BEARING, pick before any kernel code lands) + +- **Arc-mapping infrastructure isn't sketched in plans 2–5.** Inline + fixes #7 + #8 both require the mmap-VPN code to accept a kernel- + owned `Arc<T>` as a mapping source and keep the Arc alive via + the VMA table (close-vs-munmap divergence). Plan 2's gbm_bo + sharing uses a similar mechanism (host-imported memory backing a + bo's user-mapped region), but the prim_bo's lifetime is fd-tied + (close of the prime fd is the unmap trigger). Plan 6 needs + per-VMA refcounting, which is a new code path. *Lean:* add a + `MappingKind::KernelOwnedArc(Arc<dyn AsRef<[u8]>>)` arm to the + existing mmap helper at A5 implementation time; the Arc's + refcount holds the kernel-resident page alive across fd close. + This is ~50 LoC in `crates/kernel/src/mmap.rs` (or wherever the + VPN table lives). Surfaces in: plan 6's mmap_status + mmap_control + + audio ring pages; plan 9 may want it for the compositor's + shared metadata regions. Cross-plan note: this is the first plan + to need this mechanism — plans 2/3/4/5 didn't. +- **`PROCESS_TABLE.lock()` rate climbs further with plan 6's tick.** + Plan 5's "open architecture #1" already flagged that + `kernel_input_event` at 1000+ Hz autorepeat + drag stresses the + shared PROCESS_TABLE lock. Plan 6 adds another producer; with + inline fix #4's per-quantum tick (every 128 frames at 48 kHz = + ~375 Hz), the audio path is the second-highest contender after + input. Combined worst case: input (1000+ Hz) + audio (375 Hz) + + vblank (60 Hz) + WAIT_VBLANK consumer + WRITEI from userland = + ~1500+ Hz lock-acquisitions on a single mutex. The lock window + per acquisition is still O(open OFDs), but contention probability + rises. *Lean:* upgrade plan 4's "OFD-table-split" architecture- + open from "after plans 4+5 ship" to "**before plan 7 (SDL2) lands + — gated on profiling**". Plan 7 will be the first plan that + exercises all three streams simultaneously under a real workload; + if Phase C profiling shows lock contention >5% of any of the + three tick handlers, the OFD-table-split refactor blocks plan 7 + merge. Cross-plan amendment added to plan 4's review (urgency + upgrade); plan 5's open architecture #1 receives a follow-up + note pointing at plan 6's quantified contribution. +- **`controlC0` `CARD_INFO` returns the same struct for every + caller; fork-shared OFDs can race on `ELEM_LIST.pids` buffer + pointer.** v1 ships ELEM_LIST with `count = 0, used = 0` — no + pid array dereferenced — so the immediate hazard is dormant. + But the broader pattern (per-OFD state on controlC0 vs global + state) needs picking once plan 9's wpkcompositor adds + ELEM_WRITE. *Lean:* defer until plan 9; document v1 as + "controlC0 is stateless except for its card binding" and pin + the no-per-fd-state invariant via a cargo test that opens + controlC0 twice from the same process and asserts both reads of + CARD_INFO return byte-identical results. Cross-plan note: plan + 9 will need to revisit when it adds the mixer surface. + +- **SDL2's `SDL_OpenAudioDevice` requires a userspace thread to + drive `snd_pcm_writei`; plan 6's WRITEI model presumes a + blocking writer.** *Follow-up after plan 7's devil's-advocate.* + Plan 7 selects `--disable-pthreads` for SDL2; with no + `SDL_CreateThread` backend, SDL2's `SDL_RunAudio` thread never + starts and `audio_cb` never fires. Plan 6's per-quantum tick + advances `hw_ptr` correctly on the kernel side, but with no + userspace writer the ring stays empty and `process()` reads + zeros. The thread-driven WRITEI model in A4 + A6 + B2 needs + either (a) a libpthread shim (plan 7 open-architecture #1 + option a — wraps `clone(CLONE_VM)` + SAB futexes) so SDL2's + thread works as-shipped, OR (b) a non-blocking WRITEI path + with `POLLOUT` readiness on the mmap status page so SDL2 can + be patched to a polling audio model (`SDL_PumpAudio` called + from the main loop, plan 7's option b). *Lean:* (b), because + plan 6's mmap_status already exposes `avail` and `POLLOUT` is + already in A7's scope — adding non-blocking WRITEI is a small + extension (return EAGAIN when `avail < frames` instead of + blocking); plan 7 then ships the SDL2 patch as a vendored + diff. (a) is heavier but unlocks plans 8–11 too; revisit if + plan 9 wpkcompositor also wants threads. **Pre-merge gate for + plan 7 PR #2: whichever option lands, plan 6 ships the + prerequisite (non-blocking WRITEI + EAGAIN if option b; no-op + if option a).** Risk-register #7 added — "SDL2 audio thread + requires either a libpthread shim or a non-blocking WRITEI + patch; pre-merge gate for plan 7 PR #2." + +### Missing tests — add to implementation PRs + +- **`snd_pcm_hw_params` size = 604 on wasm32 + every field offset.** + A1 Step 2's size_of_val test + new field-offset tests for + `masks[0]`, `intervals[2]` (rate), `intervals[5]` (period_size), + `intervals[7]` (buffer_size), `rmask`, `fifo_size`. Same shape + as plan 5's WpkInputEvent field-offset asserts. +- **`SNDRV_PCM_IOCTL_HW_PARAMS` encoded size matches struct + size.** Compute `_IOC_SIZE(SNDRV_PCM_IOCTL_HW_PARAMS) == 604` + and assert. Catches the case where someone updates + the constant but forgets to update the struct. +- **WRITEI_FRAMES wraps `appl_ptr` at `sw_params.boundary`, not at + `ring_frames`.** Configure boundary = `0x4000_0000`, + ring_frames = 4096; write enough frames to wrap appl_ptr past + ring_frames (appl_ptr = 5000), assert `appl_ptr` is `5000` not + `5000 - 4096 = 904`. Locks the monotonic-modulo-boundary + semantics alsa-lib expects. +- **XRUN fires when next-tick advance would pass appl_ptr, not + after.** With appl_ptr = 1024 and hw_ptr = 896, a tick of 256 + frames must set XRUN BEFORE advancing hw_ptr to 1152; the test + asserts state == XRUN and hw_ptr == 1024 (clamped). +- **Boot-order: `kernel_audio_alloc_ring` precedes any + `mmap(OFFSET_DATA)` call.** B4 vitest spies the kernel-exports + proxy and asserts call ordering — same shape as plan 5's + `kernel_set_input_canvas_dims` boot-order test. +- **CLOCK_MONOTONIC sub-ms alignment with `kernel_input_event` + + `kernel_vblank`.** Fire one audio tick, one input event, one + vblank tick in close succession; read all three records; assert + `|tv_a - tv_b| < 1 ms` pairwise. Locks design §16 q6 invariant + across all three streams. +- **mmap_status page survives `close(pcm_fd)` until `munmap`.** + Open pcm, mmap status page, close fd, read from the mapped + address — assert read returns the last-written `hw_ptr`, not + garbage. Then munmap; second read after munmap may fault (OK). + Defends the Arc-mapping invariant. +- **`controlC0` re-reads return byte-identical bytes** (stateless + invariant). Open twice, CARD_INFO each, memcmp == 0. +- **HW_REFINE clamps non-power-of-2 buffer_size to next power-of-2.** + Request buffer_size = 3000, assert HW_REFINE narrows to 2048 + (or 4096 depending on clamp direction; pin in A3). + +### Trade-offs verified against the design doc + handoff requirements + +- **`CLOCK_MONOTONIC` from `crate::time::monotonic_us()`** — same + source plans 4+5 use. Design §16 q6 honoured; all three host- + driven event streams share a comparable clock for A-V-sync / + jitter profiling. ✓ +- **No `SNDRV_PCM_IOCTL_TTSTAMP` (clock-source-selection)** — + pinning MONOTONIC matches plan 5's EVIOCSCLOCKID refusal and + sidesteps the Y2K38 / NTP-skew footgun. ✓ +- **Two devices only (pcmC0D0p + controlC0); no capture/timer/seq.** + Design §8.1; SDL2's audio probe + alsa-lib tolerate missing + timer/seq; capture needs MediaStream permission flow (post-v1). ✓ +- **S16_LE only in v1.** HW_REFINE clamps; SDL2 + most apps default + to S16_LE; FLOAT_LE/S32_LE deferred to v2 when AudioWorklet (or + kernel) does format conversion. ✓ +- **Single card / single device / single substream / playback only.** + Multi-card post-v1; matches design §8.1. ✓ +- **Period tick driven by host (per-quantum after inline fix #4).** + Kernel has no internal periodic timer; learns "frames consumed" + via `kernel_audio_period_tick` export. Same shape as plan 4's + `kernel_vblank` and plan 5's `kernel_input_event`. ✓ +- **PCM data ring is kernel-allocated in shared linear memory + (after inline fix #8).** Host gets the kernel-side base/len via + `kernel_audio_alloc_ring`; AudioWorklet reads from + `kernel.memory.buffer` at that offset; userspace mmap returns + the same region via the same Arc-tracked mapping. Zero-copy + data path preserved. ✓ +- **mmap status/control pages kernel-resident + Arc-refcounted + (after inline fix #7).** Page lifetime correctly outlives fd close + per Linux semantics. ✓ +- **No host imports** (mirror of plan 5's asymmetry). The only + kernel→host signal is the period_tick (export, host-called); no + `host_audio_*` callbacks in v1. Plan 9's mixer surface may add + one; v1 holds the line. ✓ +- **PCM state transitions enforced.** OPEN → SETUP → PREPARED → + RUNNING → SETUP/PAUSED/XRUN per ALSA UAPI. A3's match-arm tests + lock the transitions; userspace bugs that skip steps fail with + EBADFD. ✓ +- **AlsaFdState as sibling `Option<Box<…>>` on OFD, not nested in + DRI state.** Consistent with plan 5's InputFdState factoring; + one-box-per-device-class. ✓ +- **`AlsaControlFdState` separate from `AlsaFdState`** — disjoint + state machines; controlC0 is stateless except card binding. + Right factoring per the "open architecture" item above. ✓ +- **Additive ABI only — no `ABI_VERSION` bump** (PR #490 policy). + Structs/constants/ioctls/exports all additive; no existing + surface changes (post inline-fix-corrected sizes + ioctl + numbers). ✓ +- **Stacked-PR topology (alsa-kernel → alsa-host → alsa-demo).** + Matches plans 2/3/4/5 stack shape. ✓ +- **AudioContext autoplay-gate**: documented in trade-offs; lean + (c) start-at-boot is consistent with suspended-default per + inline fix's correctness note. ✓ +- **Cross-Origin-Embedder-Policy already set in `./run.sh + browser`** for the WebGL demo; the SAB-via-`kernel.memory.buffer` + path inherits without new headers. ✓ + +### Deliberately not flagged + +- **Major 116 + minors 0/16** (Linux ALSA major + controlC0/pcmC0D0p + minors) — matches `Documentation/admin-guide/devices.txt` "Sound + device" table. Conventional; libasound doesn't dereference the + major/minor (uses the device path). ✓ +- **`'A'` magic for PCM ioctls, `'U'` for ctl** — matches Linux's + `sound/asound.h`. No collision with plan 2's `'d'` (DRI render), + plan 3's WPK extensions, plan 4's KMS ioctls, plan 5's `'E'` + (input). ✓ +- **alsa-lib PVERSION pin `0x000d_0000` (v13.0.0)** — alsa-lib's + documented minimum runtime version; libraries built against + newer alsa-lib accept any equal-or-lower kernel version. ✓ +- **No `pcmC0D0c` (capture) → ENODEV at open** — matches design + §8; capture needs MediaStream which is post-v1. ✓ +- **No `/dev/snd/timer`** — alsa-lib gracefully degrades when + missing; SDL2 doesn't touch the alsa-timer subsystem. ✓ +- **No mixer ELEM_WRITE / power-management in controlC0** — v1 + returns empty element list; plan 9's wpkcompositor owns the + master-volume surface. SDL2's audio init doesn't drive mixer + state (only reads CARD_INFO). ✓ +- **Linear interpolation resample fallback (risk #4)** — good + enough for a 440 Hz sine demo; v2 ports a real resampler if + music workloads matter. ✓ +- **AudioContext sample-rate hard-coded to 48 kHz** — matches + HW_REFINE's [48000, 48000] clamp; if the browser refuses (rare; + most platforms accept 48 kHz), worklet resamples. ✓ +- **No `EVIOCSCLOCKID`-equivalent** — clock fixed MONOTONIC; same + justification as plan 5's EVIOCSCLOCKID refusal. ✓ +- **Stacked-PR topology + Brandon commit-titles + "do not merge + until Brandon validates"** — matches plans 2/3/4/5. ✓ + +### Cross-plan amendment to plan 4 + +A finding from this devil's-advocate pass leaks back to plan 4. +Plan 5 already raised "OFD-table-split urgency" as an architecture- +open carried into plan 4's review (in section "Cross-plan amendment +from plan 5's devil's-advocate"); plan 6's per-quantum tick +quantifies the lock-rate further: combined worst case at peak load +is now ~1500+ Hz across input (1000+ Hz autorepeat+drag) + audio +(~375 Hz per-quantum) + vblank (60 Hz) + WAIT_VBLANK consumers + +WRITEI from userland. The lock window per acquisition is still +O(open OFDs), but contention probability rises non-linearly with +producer rate. + +*Resolution:* upgrade plan 4's open-architecture #2 ("split OFD +table out of PROCESS_TABLE") from "defer to focused PR after plans +4+5 ship (no later than pre-SDL2-port-merge)" to "**block plan 7 +(SDL2) merge on Phase C profiling — if any of the three tick +handlers shows >5% time in lock acquisition, the OFD-table-split +refactor is a hard prerequisite for plan 7**." Plan 7 will be the +first plan that exercises all three streams under a real workload +(SDL2 game loops are notoriously lock-sensitive). The cross-plan- +amendment subsection in plan 4's review gets a follow-up paragraph +adding the quantified figures + the plan-7 merge gate. + +### Cross-plan note to plan 5 + +No leak-back findings in plan 5's surface. Plan 6's struct-layout +concerns (snd_pcm_hw_params 604 B, mmap_status 56 B, control card +info 376 B) don't touch plan 5's WpkInputEvent (24 B; already +audited in handoff-6's plan-5 inline fixes). Boot-ordering pattern +(`kernel_audio_alloc_ring` → first `mmap(OFFSET_DATA)`) mirrors +plan 5's (`kernel_set_input_canvas_dims` → first +`kernel_input_event`) — same shape, same vitest test idiom; no +amendment needed. The Arc-mapping infrastructure (new for plan 6) +doesn't apply to plan 5 (input has no mmap'd pages). One pointer +added to plan 5's "open architecture #1" subsection: plan 6's +contribution to the combined lock-rate is documented above and +strengthens the case for the OFD-table-split refactor. + +### Cross-plan amendment from plan 9's devil's-advocate — explicit EAGAIN arm for SDL2 audio polling + +Plan 9's devil's-advocate pass (session 10) recorded the +resolution to plan 7's open-architecture #1 (SDL2 audio thread +model): **option (b) — non-blocking WRITEI + SDL2 polling patch ++ plan 6 EAGAIN return arm.** Option (a) (libpthread shim) was +ruled out as too heavy for the single feature it enables; +option (c) (defer audio) is a feature regression; option (b) +is ~150 LoC SDL2 patch + the plan 6 EAGAIN arm and matches plan +6's existing per-quantum tick + POLLOUT non-blocking surface +exactly. + +This commits plan 6 to an explicit contract: a non-blocking +`SNDRV_PCM_IOCTL_WRITEI_FRAMES` (a.k.a. `snd_pcm_writei` with +the underlying fd in `O_NONBLOCK` mode, or `SNDRV_PCM_HW_PARAMS` +with the non-block tag set) MUST return `-EAGAIN` when the +kernel-side audio ring is full (`avail < frames_requested`), +NOT `-EBUSY` and NOT block. SDL2's patched audio polling loop in +plan 7's resolution (b) will treat EAGAIN as "back off and try +next pump" and EBUSY as "fatal". + +The distinction matters because plan 6's A4 + A7 (non-blocking +write arm + POLLOUT) describe the contract conceptually but +don't pin the errno. Plan 6's Trade-offs verified subsection +("POLLOUT + non-blocking write arm cleared in A7") is correct, +but plan 7's audio polling loop is the first real-world +consumer of the contract and the choice of errno determines +whether the SDL2 patch is "ergonomic" or "load-bearing +workaround". **Pin: EAGAIN, not EBUSY, on a full ring.** + +*Resolution for plan 6:* update A7's body at impl time to +document the EAGAIN-on-full-ring contract explicitly (it's +implicit in "non-blocking write arm" but the explicit name +helps the SDL2-side patch reviewer). Add a cargo test +"snd_pcm_writei_returns_eagain_when_ring_full" alongside the +existing POLLOUT tests. Plan 6 risk register #7 (added by plan +7's cross-plan amendment to plan 6 above) is now resolved by +plan 9's audio-thread decision; update the register to point +at this subsection. + +Plan 9's own compositor doesn't manage audio routing — audio +clients hit /dev/snd/* directly. So this amendment is plan 7's +benefit, surfaced during plan 9's pass. + +### Cross-plan amendment from plan 11's devil's-advocate — wpkbeep two-shot playback re-PREPARE (added during session 12) + +Plan 11's wpkbeep (`docs/plans/2026-07-27-wpk-seed-apps-plan.md` +task D3 lines 1080–1125) is a 1-button compositor-client audio +demo: click → enqueue 1 s 440 Hz sine wave into +`/dev/snd/pcmC0D0p` via non-blocking +`SNDRV_PCM_IOCTL_WRITEI_FRAMES` + EAGAIN-poll loop (the plan +7 audio-thread (b) pattern executed outside SDL2). Each click +calls `play_blocking(pcm_fd)` synchronously, which writes the +full 44 100-sample buffer and returns. + +After playback completes, the PCM stream transitions +PREPARED → RUNNING → DRAINING → SETUP per plan 6's PCM state +machine (A3 lines 1407–1422). A second click writes to a +SETUP-state fd. **Question:** does plan 6 v1 auto-rearm the +stream after drain, or must the client call +`SNDRV_PCM_IOCTL_PREPARE` between playbacks? + +*Resolution for plan 6:* plan 11 D3 calls +`SNDRV_PCM_IOCTL_PREPARE` after each `play_blocking` return +defensively (inline fix #11 in plan 11's review). The +defensive call is harmless if plan 6 auto-rearms (a no-op +PREPARE→PREPARE transition); plan 6 task A3 should document +the answer explicitly so a future plan can remove the +defensive call if redundant. Either way, plan 11 wpkbeep +works. + +Non-LOAD-BEARING — wpkbeep's defensive PREPARE handles both +behaviours. This is a documentation-clarity follow-up to plan +6 task A3. + +--- + +## Phase A — kernel: devices + ioctls + mmap pages + period tick (PR #1) + +The kernel learns to (a) recognise `/dev/snd/controlC0` and +`/dev/snd/pcmC0D0p` as two distinct virtual devices, (b) hold per-OFD +PCM state machine + mmap pages + SAB-backed audio ring, (c) accept +the `SNDRV_PCM_IOCTL_*` subset alsa-lib drives, (d) advance hw_ptr + +wake POLLOUT waiters from the new `kernel_audio_period_tick` export, +and (e) answer the minimal controlC0 surface (CARD_INFO + ELEM_LIST). + +### Task A1: Shared ABI module additions + +**Files:** +- Modify: `crates/shared/src/lib.rs` — add `pub mod audio { … }` with + the structs, format / state / access constants, and + `SNDRV_PCM_IOCTL_*` + `SNDRV_CTL_IOCTL_*` numbers. + +**Step 1: Constants and structs** + +Append at the end of `crates/shared/src/lib.rs` (sibling of `pub mod +input`): + +```rust +pub mod audio { + use core::mem::size_of; + + // --- PCM ioctl numbers ('A' magic, Linux UAPI verbatim) -------------- + + /// `_IOR('A', 0x00, int)` = `0x8004_4100`. + pub const SNDRV_PCM_IOCTL_PVERSION: u32 = 0x8004_4100; + pub const SNDRV_PCM_IOCTL_INFO: u32 = 0x8120_4101; + pub const SNDRV_PCM_IOCTL_HW_REFINE: u32 = 0xc254_4110; + pub const SNDRV_PCM_IOCTL_HW_PARAMS: u32 = 0xc254_4111; + pub const SNDRV_PCM_IOCTL_HW_FREE: u32 = 0x0000_4112; + pub const SNDRV_PCM_IOCTL_SW_PARAMS: u32 = 0xc088_4113; + pub const SNDRV_PCM_IOCTL_STATUS: u32 = 0x8080_4120; + pub const SNDRV_PCM_IOCTL_PREPARE: u32 = 0x0000_4140; + pub const SNDRV_PCM_IOCTL_START: u32 = 0x0000_4142; + pub const SNDRV_PCM_IOCTL_DROP: u32 = 0x0000_4143; + pub const SNDRV_PCM_IOCTL_PAUSE: u32 = 0x4004_4145; + pub const SNDRV_PCM_IOCTL_WRITEI_FRAMES: u32 = 0x4018_4150; + + // --- Ctl ioctl numbers ('U' magic) ----------------------------------- + + pub const SNDRV_CTL_IOCTL_PVERSION: u32 = 0x8004_5500; + pub const SNDRV_CTL_IOCTL_CARD_INFO: u32 = 0x8100_5501; + pub const SNDRV_CTL_IOCTL_ELEM_LIST: u32 = 0xc04c_5510; + + // --- PCM state constants --------------------------------------------- + + /// `SNDRV_PCM_STATE_OPEN` = 0. fd just opened; no params yet. + pub const SNDRV_PCM_STATE_OPEN: u32 = 0; + /// `SNDRV_PCM_STATE_SETUP` = 1. HW_PARAMS landed; not prepared. + pub const SNDRV_PCM_STATE_SETUP: u32 = 1; + /// `SNDRV_PCM_STATE_PREPARED` = 2. PREPARE'd, ready for START. + pub const SNDRV_PCM_STATE_PREPARED: u32 = 2; + /// `SNDRV_PCM_STATE_RUNNING` = 3. START'd; ticks fire. + pub const SNDRV_PCM_STATE_RUNNING: u32 = 3; + /// `SNDRV_PCM_STATE_XRUN` = 4. Buffer underrun; user must PREPARE. + pub const SNDRV_PCM_STATE_XRUN: u32 = 4; + /// `SNDRV_PCM_STATE_PAUSED` = 6. + pub const SNDRV_PCM_STATE_PAUSED: u32 = 6; + + // --- PCM format constants (subset; S16_LE is v1's only support) ------ + + pub const SNDRV_PCM_FORMAT_S16_LE: u32 = 2; + pub const SNDRV_PCM_FORMAT_S32_LE: u32 = 10; + pub const SNDRV_PCM_FORMAT_FLOAT_LE: u32 = 14; + + // --- PCM access constants -------------------------------------------- + + pub const SNDRV_PCM_ACCESS_MMAP_INTERLEAVED: u32 = 0; + pub const SNDRV_PCM_ACCESS_RW_INTERLEAVED: u32 = 3; + + // --- PCM stream direction -------------------------------------------- + + pub const SNDRV_PCM_STREAM_PLAYBACK: u32 = 0; + pub const SNDRV_PCM_STREAM_CAPTURE: u32 = 1; // v1 doesn't ship capture + + // --- MMAP offsets (passed to mmap(pcm_fd, ..., offset) to select page) + + /// PCM DATA ring offset (the SAB-backed audio buffer). + pub const SNDRV_PCM_MMAP_OFFSET_DATA: u64 = 0x0000_0000; + /// PCM mmap_status page (kernel-writes, userspace-reads). + pub const SNDRV_PCM_MMAP_OFFSET_STATUS: u64 = 0x8000_0000; + /// PCM mmap_control page (userspace-writes, kernel-reads). + pub const SNDRV_PCM_MMAP_OFFSET_CONTROL: u64 = 0x8100_0000; + + // --- snd_interval substruct (16 bytes) ------------------------------- + + /// `struct snd_interval` — the value-range descriptor used inside + /// `snd_pcm_hw_params.intervals[]`. 16 bytes: 4 u32 + 1 u8 flag + /// byte + 3 bytes pad. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkSndInterval { + pub min: u32, // 0 + pub max: u32, // 4 + pub openmin_max_int_empty: u32, // 8 flag bits (openmin:1 openmax:1 integer:1 empty:1) + pub _pad: u32, // 12 + // total: 16 + } + + // --- marshalled structs ---------------------------------------------- + + /// `struct snd_pcm_hw_params`. 596 bytes on wasm32 — verify with + /// `size_of_val(&WpkAlsaPcmHwParams::default())` in A1 Step 2 cargo + /// test. Layout matches Linux v6.10's `include/uapi/sound/asound.h` + /// modulo wasm32 alignment. + #[repr(C)] + #[derive(Clone, Copy)] + pub struct WpkAlsaPcmHwParams { + pub flags: u32, + pub masks: [u32; 8], // 32 bytes — access / format / + // subformat masks (bitmap) + pub intervals: [WpkSndInterval; 12], // 192 bytes — rate / + // channels / period_size / + // buffer_size / etc. + pub rmask: u32, + pub cmask: u32, + pub info: u32, + pub msbits: u32, + pub rate_num: u32, + pub rate_den: u32, + pub fifo_size: u64, + pub reserved: [u8; 64], + // total: ~328 — Linux is 596; + // the discrepancy is the + // `masks` array length and + // `reserved` size. Use 596 as + // ground truth + size-assert. + // Adjust array lengths to land + // on 596 exactly. + } + + /// `struct snd_pcm_sw_params`. 136 bytes on wasm32. + #[repr(C)] + #[derive(Clone, Copy)] + pub struct WpkAlsaPcmSwParams { + pub tstamp_mode: u32, + pub period_step: u32, + pub sleep_min: u32, + pub avail_min: u64, // frames + pub xfer_align: u64, + pub start_threshold: u64, + pub stop_threshold: u64, + pub silence_threshold: u64, + pub silence_size: u64, + pub boundary: u64, + pub proto: u32, + pub tstamp_type: u32, + pub reserved: [u8; 56], + // total: 136 + } + + /// `struct snd_pcm_status`. 128 bytes on wasm32. + #[repr(C)] + #[derive(Clone, Copy)] + pub struct WpkAlsaPcmStatus { + pub state: u32, // SNDRV_PCM_STATE_* + pub trigger_tstamp_sec: i64, // CLOCK_MONOTONIC; design §16 q6 + pub trigger_tstamp_nsec: i64, + pub tstamp_sec: i64, + pub tstamp_nsec: i64, + pub appl_ptr: i64, // frames + pub hw_ptr: i64, // frames + pub delay: i64, // frames (= appl_ptr - hw_ptr) + pub avail: u64, // frames available to write + pub avail_max: u64, + pub overrange: u64, + pub suspended_state: u32, + pub audio_tstamp_data: u32, + pub audio_tstamp_sec: i64, + pub audio_tstamp_nsec: i64, + pub reserved: [u8; 16], + // total: 128 (verify) + } + + /// `struct snd_pcm_info`. 288 bytes on wasm32. Returned by + /// SNDRV_PCM_IOCTL_INFO; alsa-lib reads `name`, `id`, `card`, + /// `device`, `subdevice`, `stream`. + #[repr(C)] + #[derive(Clone, Copy)] + pub struct WpkAlsaPcmInfo { + pub device: u32, + pub subdevice: u32, + pub stream: i32, // 0 = playback, 1 = capture + pub card: i32, + pub id: [u8; 64], + pub name: [u8; 80], + pub subname: [u8; 32], + pub dev_class: u32, + pub dev_subclass: u32, + pub subdevices_count: u32, + pub subdevices_avail: u32, + pub sync: [u8; 16], // snd_pcm_sync_id (16 bytes) + pub reserved: [u8; 64], + // total: 288 + } + + /// `struct snd_pcm_mmap_status`. 64 bytes on wasm32. Kernel-writes, + /// userspace-reads page; mapped at SNDRV_PCM_MMAP_OFFSET_STATUS. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkAlsaPcmMmapStatus { + pub state: u32, // SNDRV_PCM_STATE_* + pub pad1: u32, + pub hw_ptr: i64, // frames; updated by period tick + pub tstamp_sec: i64, // CLOCK_MONOTONIC + pub tstamp_nsec: i64, + pub suspended_state: u32, + pub audio_tstamp_data: u32, + pub audio_tstamp_sec: i64, + pub audio_tstamp_nsec: i64, + // total: 64 + } + + /// `struct snd_pcm_mmap_control`. 64 bytes on wasm32. Userspace- + /// writes, kernel-reads page; mapped at SNDRV_PCM_MMAP_OFFSET_CONTROL. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkAlsaPcmMmapControl { + pub appl_ptr: i64, // frames; userspace advances on + // every WRITEI completed + pub avail_min: i64, // frames; threshold for POLLOUT + pub _reserved: [u8; 48], + // total: 64 + } + + /// `struct snd_xferi` — argument to WRITEI_FRAMES / READI_FRAMES. + /// 24 bytes on wasm32. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkAlsaXferi { + pub result: i64, // out: frames transferred or + // negative errno + pub buf: u64, // in: ptr to userspace audio + // buffer + pub frames: u64, // in: frame count + // total: 24 + } + + /// `struct snd_ctl_card_info`. 256 bytes on wasm32. Returned by + /// SNDRV_CTL_IOCTL_CARD_INFO; alsa-lib reads `id`, `driver`, + /// `name`, `longname`, `mixername`. + #[repr(C)] + #[derive(Clone, Copy)] + pub struct WpkAlsaCtlCardInfo { + pub card: i32, + pub pad: i32, + pub id: [u8; 16], + pub driver: [u8; 16], + pub name: [u8; 32], + pub longname: [u8; 80], + pub reserved_: [u8; 16], + pub mixername: [u8; 80], + pub components: [u8; 128], + // total: 376; trim to 256 by + // shrinking `components` to 16 + // — v1 doesn't ship components. + // Verify against Linux's 376. + } + + /// `struct snd_ctl_elem_id`. 64 bytes on wasm32. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkAlsaCtlElemId { + pub numid: u32, + pub iface: u32, // SNDRV_CTL_ELEM_IFACE_* + pub device: u32, + pub subdevice: u32, + pub name: [u8; 44], + pub index: u32, + // total: 64 + } + + /// `struct snd_ctl_elem_list`. ~80 bytes on wasm32. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkAlsaCtlElemList { + pub offset: u32, + pub space: u32, + pub used: u32, + pub count: u32, + pub pids: u64, // ptr to caller's id-array + pub reserved: [u8; 50], + // total: ~80; size-assert + } + + // --- card-info defaults ---------------------------------------------- + + pub const WPK_AUDIO_DRIVER_NAME: &[u8] = b"wpk-audio"; + pub const WPK_AUDIO_CARD_NAME: &[u8] = b"wpk-virtual"; + pub const WPK_AUDIO_PCM_NAME: &[u8] = b"wpk virtual playback"; + pub const WPK_AUDIO_LONGNAME: &[u8] = + b"WPK virtual audio device (host-driven AudioWorklet)"; +} + +#[cfg(test)] +mod audio_tests { + use super::audio::*; + use core::mem::size_of; + + #[test] + fn audio_struct_sizes_match_wasm32_repr_c() { + // Tighten these against the actual repr(C) output once the + // struct definitions are finalised. The Linux ground-truth + // values below are starting points; cargo expand resolves the + // wasm32 layout. + assert_eq!(size_of::<WpkAlsaPcmHwParams>(), 596); + assert_eq!(size_of::<WpkAlsaPcmSwParams>(), 136); + assert_eq!(size_of::<WpkAlsaPcmStatus>(), 128); + assert_eq!(size_of::<WpkAlsaPcmInfo>(), 288); + assert_eq!(size_of::<WpkAlsaPcmMmapStatus>(), 64); + assert_eq!(size_of::<WpkAlsaPcmMmapControl>(), 64); + assert_eq!(size_of::<WpkAlsaXferi>(), 24); + assert_eq!(size_of::<WpkAlsaCtlCardInfo>(), 256); + assert_eq!(size_of::<WpkAlsaCtlElemId>(), 64); + assert_eq!(size_of::<WpkSndInterval>(), 16); + } + + #[test] + fn audio_mmap_status_field_offsets() { + // The mmap_status page is read by userspace via direct memory + // access; field offsets are load-bearing. + let s = WpkAlsaPcmMmapStatus::default(); + let base = (&s as *const _) as usize; + assert_eq!((&s.state as *const _ as usize) - base, 0); + assert_eq!((&s.hw_ptr as *const _ as usize) - base, 8); + assert_eq!((&s.tstamp_sec as *const _ as usize) - base, 16); + } + + #[test] + fn audio_mmap_control_field_offsets() { + let c = WpkAlsaPcmMmapControl::default(); + let base = (&c as *const _) as usize; + assert_eq!((&c.appl_ptr as *const _ as usize) - base, 0); + assert_eq!((&c.avail_min as *const _ as usize) - base, 8); + } +} +``` + +**The struct sizes above are TARGETS, not asserts that will pass on +first try.** alsa-lib ABI is famously fiddly — `snd_pcm_hw_params` +in particular contains arrays whose lengths shift between kernel +versions. The exact wasm32 layout will be one or two iterations of +`cargo test → cargo expand → adjust array length` before all +size_of_val asserts pass. Document the iteration in A1's commit +body; future-me will thank present-me. + +**Step 2: Run** + +```bash +cargo test -p wasm-posix-shared --target aarch64-apple-darwin --lib audio_tests +``` + +Expected: 3 new tests pass (after the iteration noted above); plan 2 ++ plan 3 + plan 4 + plan 5 tests still pass. If a layout assertion +fails, `cargo expand` shows the actual layout; adjust struct field +arrays to match. + +**Step 3: Commit** + +```bash +git add crates/shared/src/lib.rs +git commit -m "kernel(audio): shared ABI — SNDRV_PCM_IOCTL_* + structs + format/state constants" +``` + +--- + +### Task A2: `VirtualDevice::AlsaPcm` + `AlsaControl` + devfs entries + `AlsaFdState` on OFD + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — extend `VirtualDevice` + enum + `match_virtual_device`. +- Modify: `crates/kernel/src/devfs.rs` — add `snd/` synthetic + subdirectory with `controlC0` + `pcmC0D0p`. +- Modify: `crates/kernel/src/ofd.rs` — add `OpenFileKind::AlsaPcm` + + `OpenFileKind::AlsaControl` variants and an `audio: + Option<Box<AlsaFdState>>` field on `OpenFileDesc`. + +**Step 1: Device enum** + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PcmDir { + Playback, + Capture, // v1 returns ENODEV from devfs lookup +} + +pub enum VirtualDevice { + // … existing variants … + DriRender0, + DriCard0, + InputEvent { device: u8 }, // plan 5 + AlsaPcm { // plan 6 — this task + card: u8, + device: u8, + sub: u8, + kind: PcmDir, + }, + AlsaControl { card: u8 }, // plan 6 — this task +} +``` + +Extend `match_virtual_device` to map: +- `/dev/snd/controlC0` → `AlsaControl { card: 0 }` +- `/dev/snd/pcmC0D0p` → `AlsaPcm { card: 0, device: 0, sub: 0, kind: + Playback }` +- `/dev/snd/pcmC0D0c` (capture) → ENODEV (v1 doesn't ship capture) +- Other `/dev/snd/*` paths → ENOENT. + +**Step 2: devfs entries** + +Add a new `snd` subdirectory under `/dev`: + +```rust +// In devfs.rs, alongside the existing /dev/input synthetic dir: +synthetic_subdir("snd", &[ + synthetic_entry("controlC0", DT_CHR, 116 /* ALSA_MAJOR */, 0), + synthetic_entry("pcmC0D0p", DT_CHR, 116, 16), +]); +``` + +(Major 116 is the Linux ALSA major; minor 0 is `controlC0`; minor 16 +is `pcmC0D0p` per `Documentation/admin-guide/devices.txt` "Sound +device" table. Conventional, not load-bearing.) + +**Step 3: `AlsaFdState` on the OFD** + +In `crates/kernel/src/ofd.rs`: + +```rust +/// Per-fd state for `/dev/snd/pcmC0D0p` opens. Disjoint from +/// `DriOfdState` (plan 4) and `InputFdState` (plan 5) — audio fds +/// carry no DRI bo state and no input ring state. Mirrors the +/// "one Option<Box<…>> per device class" factoring plan 5 +/// established. +#[derive(Default, Clone, Debug)] +pub struct AlsaFdState { + /// Which card/device/sub/kind this fd is bound to. + pub card: u8, + pub device: u8, + pub sub: u8, + pub kind: PcmDir, + + /// PCM state machine. SNDRV_PCM_STATE_* values. + pub state: u32, + + /// HW_PARAMS cache — populated by HW_PARAMS, read by everything + /// else. None until HW_PARAMS lands. + pub hw_params: Option<Box<HwParamsCache>>, + + /// SW_PARAMS cache. + pub sw_params: Option<Box<SwParamsCache>>, + + /// mmap_status page — kernel-writes, userspace-reads. Allocated + /// on first mmap(SNDRV_PCM_MMAP_OFFSET_STATUS). + pub mmap_status: Option<Box<WpkAlsaPcmMmapStatus>>, + + /// mmap_control page — userspace-writes, kernel-reads. + pub mmap_control: Option<Box<WpkAlsaPcmMmapControl>>, + + /// Identifier into `audio::sab_table` — host-allocated SAB that + /// holds the PCM data ring. Set by `kernel_audio_init_sab`; the + /// table holds (base_ptr_into_kernel_memory, len_bytes). + pub pcm_id: u32, +} + +#[derive(Default, Clone, Debug)] +pub struct HwParamsCache { + pub format: u32, // SNDRV_PCM_FORMAT_S16_LE only in v1 + pub access: u32, // SNDRV_PCM_ACCESS_MMAP_INTERLEAVED or RW_INTERLEAVED + pub channels: u32, // 1 or 2 in v1 + pub rate: u32, // 8000..48000 Hz + pub period_size: u64, // frames + pub buffer_size: u64, // frames (= period_size * periods) + pub periods: u32, +} + +#[derive(Default, Clone, Debug)] +pub struct SwParamsCache { + pub avail_min: u64, // frames + pub start_threshold: u64, + pub stop_threshold: u64, + pub boundary: u64, +} + +#[derive(Default, Clone, Debug)] +pub struct AlsaControlFdState { + pub card: u8, + // v1 controlC0 carries no per-fd state beyond the card binding — + // CARD_INFO and ELEM_LIST are read-only and serve from kernel + // globals. +} +``` + +Attach `audio: Option<Box<AlsaFdState>>` and `audio_ctl: +Option<Box<AlsaControlFdState>>` to `OpenFileDesc`, parallel to +`dri_state` + `input` (plan 5's consolidation precedent). + +(One could argue these should join into a single `audio: +Option<Box<AudioOfdState>>` enum with variants `Pcm(AlsaFdState)` / +`Ctl(AlsaControlFdState)`. The Pre-impl review will decide; the +disjoint-vs-enum trade-off is the same one plan 5 settled — disjoint +state machines, separate boxes. v1 ships disjoint; plan 9 may +revisit if the compositor needs both fds on one OFD for some reason.) + +**Step 4: Cargo tests** + +```rust +#[test] +fn open_pcm_playback_yields_audio_state_in_open_state() { + // OFD.audio is Some, .state = SNDRV_PCM_STATE_OPEN, no params yet. +} + +#[test] +fn open_control_yields_audio_ctl_state() { + // OFD.audio_ctl is Some, .audio is None. +} + +#[test] +fn open_pcm_capture_returns_enodev() { + // /dev/snd/pcmC0D0c → ENODEV (we only ship playback in v1). +} + +#[test] +fn fork_inherits_audio_state_via_ofd_dup() { + // Same OFD shared by ref → same AlsaFdState. (Audio state shared + // through fork has user-visible consequences; see Pre-impl review + // "fork-shared-pcm".) +} +``` + +**Step 5: Commit** + +```bash +git add crates/kernel/src/syscalls.rs crates/kernel/src/devfs.rs \ + crates/kernel/src/ofd.rs +git commit -m "kernel(audio): add /dev/snd/{controlC0,pcmC0D0p} + AlsaFdState on OFD" +``` + +--- + +### Task A3: PCM ioctl dispatch — PVERSION / INFO / HW_REFINE / HW_PARAMS / SW_PARAMS / PREPARE / START / DROP / PAUSE / STATUS + +**Files:** +- Create: `crates/kernel/src/audio/mod.rs` — sibling of `input/` and + `dri/`. +- Create: `crates/kernel/src/audio/pcm_ioctl.rs` — dispatcher for PCM + ioctls. +- Modify: `crates/kernel/src/syscalls.rs` — route `OpenFileKind::AlsaPcm` + ioctls into `handle_alsa_pcm_ioctl(pid, fd, request, buf)`. + +**Step 1: Dispatcher** + +```rust +fn handle_alsa_pcm_ioctl(pid: i32, fd: i32, request: u32, + buf: &mut [u8]) -> Result<(), Errno> +{ + use wasm_posix_shared::audio::*; + + match request { + SNDRV_PCM_IOCTL_PVERSION => { + // alsa-lib expects 0x000d_0000 (version 13.0.0); any + // higher number than the alsa-lib runtime version makes + // it bail. + write_u32(buf, 0x000d_0000) + } + + SNDRV_PCM_IOCTL_INFO => { + let info = WpkAlsaPcmInfo { + card: 0, + device: 0, + subdevice: 0, + stream: SNDRV_PCM_STREAM_PLAYBACK as i32, + ..Default::default() + }; + // … fill name[] = "wpk virtual playback\0", id[] = "wpk\0", + // dev_class = SNDRV_PCM_CLASS_GENERIC, etc. + write_struct(buf, &info) + } + + SNDRV_PCM_IOCTL_HW_REFINE => { + // alsa-lib calls HW_REFINE to narrow a wildcard hw_params + // request to a single concrete combination. Walk the + // request's intervals and masks, clamp to v1 capabilities + // (S16_LE only, MMAP_INTERLEAVED or RW_INTERLEAVED only, + // 1 or 2 channels, 8000..48000 Hz, period 64..4096 frames, + // buffer 256..16384 frames), and write back the narrowed + // result. EINVAL if no combination fits. + let mut req: WpkAlsaPcmHwParams = read_struct(buf)?; + refine_hw_params(&mut req)?; + write_struct(buf, &req) + } + + SNDRV_PCM_IOCTL_HW_PARAMS => { + // HW_PARAMS commits a refined hw_params into the OFD's + // state. State transitions OPEN → SETUP. After this point + // the PCM is configured; PREPARE moves to PREPARED. + let mut req: WpkAlsaPcmHwParams = read_struct(buf)?; + refine_hw_params(&mut req)?; + with_alsa_pcm_ofd_mut(pid, fd, |a| { + if a.state != SNDRV_PCM_STATE_OPEN + && a.state != SNDRV_PCM_STATE_SETUP + { + return Err(Errno::EBADFD); + } + a.hw_params = Some(Box::new(HwParamsCache { + format: extract_format(&req)?, + access: extract_access(&req)?, + channels: extract_channels(&req)?, + rate: extract_rate(&req)?, + period_size: extract_period_size(&req)?, + buffer_size: extract_buffer_size(&req)?, + periods: extract_periods(&req)?, + })); + a.state = SNDRV_PCM_STATE_SETUP; + Ok(()) + })??; + write_struct(buf, &req) + } + + SNDRV_PCM_IOCTL_HW_FREE => { + with_alsa_pcm_ofd_mut(pid, fd, |a| { + a.hw_params = None; + a.sw_params = None; + a.state = SNDRV_PCM_STATE_OPEN; + Ok(()) + })? + } + + SNDRV_PCM_IOCTL_SW_PARAMS => { + let req: WpkAlsaPcmSwParams = read_struct(buf)?; + with_alsa_pcm_ofd_mut(pid, fd, |a| { + if a.hw_params.is_none() { return Err(Errno::EBADFD); } + a.sw_params = Some(Box::new(SwParamsCache { + avail_min: req.avail_min, + start_threshold: req.start_threshold, + stop_threshold: req.stop_threshold, + boundary: req.boundary, + })); + Ok(()) + })? + } + + SNDRV_PCM_IOCTL_PREPARE => { + with_alsa_pcm_ofd_mut(pid, fd, |a| { + if a.hw_params.is_none() { return Err(Errno::EBADFD); } + a.state = SNDRV_PCM_STATE_PREPARED; + // Reset hw_ptr and appl_ptr to 0; user re-fills the + // ring from scratch. + if let Some(s) = a.mmap_status.as_mut() { + s.hw_ptr = 0; + s.state = SNDRV_PCM_STATE_PREPARED; + } + if let Some(c) = a.mmap_control.as_mut() { + c.appl_ptr = 0; + } + Ok(()) + })? + } + + SNDRV_PCM_IOCTL_START => { + with_alsa_pcm_ofd_mut(pid, fd, |a| { + if a.state != SNDRV_PCM_STATE_PREPARED { + return Err(Errno::EBADFD); + } + a.state = SNDRV_PCM_STATE_RUNNING; + if let Some(s) = a.mmap_status.as_mut() { + s.state = SNDRV_PCM_STATE_RUNNING; + let now = crate::time::monotonic_us(); + s.tstamp_sec = (now / 1_000_000) as i64; + s.tstamp_nsec = ((now % 1_000_000) * 1000) as i64; + } + // Notify the host driver to begin pulling — this is a + // *kernel-internal* signal; the host polls + // `kernel_audio_running(pcm_id) -> bool` from its + // AudioWorklet boot path. (Alternative: a host import + // `host_audio_start(pcm_id)`. v1 chooses the + // kernel-export-poll shape to maintain "no host + // imports" parity with plan 5; the host driver checks + // state every quantum and starts/stops based on it.) + Ok(()) + })? + } + + SNDRV_PCM_IOCTL_DROP => { + with_alsa_pcm_ofd_mut(pid, fd, |a| { + a.state = SNDRV_PCM_STATE_SETUP; + if let Some(s) = a.mmap_status.as_mut() { + s.state = SNDRV_PCM_STATE_SETUP; + } + Ok(()) + })? + } + + SNDRV_PCM_IOCTL_PAUSE => { + let value = read_u32(buf)? as i32; // 1 = pause, 0 = resume + with_alsa_pcm_ofd_mut(pid, fd, |a| { + a.state = if value != 0 { + SNDRV_PCM_STATE_PAUSED + } else { + SNDRV_PCM_STATE_RUNNING + }; + if let Some(s) = a.mmap_status.as_mut() { + s.state = a.state; + } + Ok(()) + })? + } + + SNDRV_PCM_IOCTL_STATUS => { + with_alsa_pcm_ofd_mut(pid, fd, |a| { + let now = crate::time::monotonic_us(); + let hw = a.mmap_status.as_ref().map(|s| s.hw_ptr).unwrap_or(0); + let appl = a.mmap_control.as_ref().map(|c| c.appl_ptr).unwrap_or(0); + let buffer = a.hw_params.as_ref() + .map(|h| h.buffer_size as i64).unwrap_or(0); + let status = WpkAlsaPcmStatus { + state: a.state, + trigger_tstamp_sec: 0, trigger_tstamp_nsec: 0, + tstamp_sec: (now / 1_000_000) as i64, + tstamp_nsec: ((now % 1_000_000) * 1000) as i64, + appl_ptr: appl, + hw_ptr: hw, + delay: appl - hw, + avail: (buffer - (appl - hw)) as u64, + avail_max: buffer as u64, + overrange: 0, + suspended_state: 0, + audio_tstamp_data: 0, + audio_tstamp_sec: 0, + audio_tstamp_nsec: 0, + reserved: [0u8; 16], + }; + write_struct(buf, &status) + })? + } + + SNDRV_PCM_IOCTL_WRITEI_FRAMES => handle_writei(pid, fd, buf), + + _ => Err(Errno::EOPNOTSUPP), + } +} +``` + +`handle_writei` is the data-path; see A4. + +**Step 2: Wire into the syscall ioctl router** + +In `sys_ioctl`, before falling through to "unrecognised ioctl on this +device": + +```rust +if let Some(OpenFileKind::AlsaPcm { .. }) = ofd.kind.as_ref() { + return handle_alsa_pcm_ioctl(pid, fd, request, buf); +} +if let Some(OpenFileKind::AlsaControl { .. }) = ofd.kind.as_ref() { + return handle_alsa_ctl_ioctl(pid, fd, request, buf); // Task A8 +} +``` + +**Step 3: Cargo tests** + +```rust +#[test] +fn pcm_pversion_returns_alsa_v13() { /* alsa-lib's minimum version */ } + +#[test] +fn pcm_info_returns_playback_stream_card0_device0() { /* … */ } + +#[test] +fn pcm_hw_refine_clamps_unsupported_format_to_s16_le() { /* … */ } + +#[test] +fn pcm_hw_params_transitions_open_to_setup() { /* … */ } + +#[test] +fn pcm_hw_params_without_format_returns_einval() { /* … */ } + +#[test] +fn pcm_prepare_after_hw_params_transitions_to_prepared() { /* … */ } + +#[test] +fn pcm_start_from_prepared_transitions_to_running() { /* … */ } + +#[test] +fn pcm_start_without_prepare_returns_ebadfd() { /* … */ } + +#[test] +fn pcm_drop_from_running_transitions_to_setup() { /* … */ } + +#[test] +fn pcm_pause_then_resume_round_trips() { /* … */ } + +#[test] +fn pcm_status_reflects_appl_ptr_hw_ptr_delta() { /* … */ } +``` + +**Step 4: Commit** + +```bash +git add crates/kernel/src/audio/ crates/kernel/src/syscalls.rs +git commit -m "kernel(audio): SNDRV_PCM_IOCTL_* dispatch — state machine + HW_PARAMS refine" +``` + +--- + +### Task A4: `WRITEI_FRAMES` data-path + SAB ring + `kernel_audio_init_sab` export + +**Files:** +- Create: `crates/kernel/src/audio/sab.rs` — host-provided SAB + registry. +- Modify: `crates/kernel/src/audio/pcm_ioctl.rs` — `handle_writei`. +- Modify: `crates/kernel/src/wasm_api.rs` — add `kernel_audio_init_sab` + export. + +**Step 1: SAB registry** + +```rust +// crates/kernel/src/audio/sab.rs + +use spin::Mutex; + +#[derive(Clone, Copy, Debug)] +pub struct SabSlice { + /// Base pointer into the kernel's linear memory. The host + /// imported a SharedArrayBuffer view at this address via the + /// existing memory-import path; the kernel sees it as a normal + /// `&mut [u8]`. + pub base: usize, + /// Length in bytes. + pub len: usize, +} + +static SAB_TABLE: Mutex<[Option<SabSlice>; 4]> = Mutex::new([None; 4]); + +pub fn register(pcm_id: u32, slice: SabSlice) -> Result<(), crate::Errno> { + let idx = pcm_id as usize; + if idx >= 4 { return Err(crate::Errno::EINVAL); } + let mut tbl = SAB_TABLE.lock(); + if tbl[idx].is_some() { return Err(crate::Errno::EBUSY); } + tbl[idx] = Some(slice); + Ok(()) +} + +pub fn lookup(pcm_id: u32) -> Option<SabSlice> { + SAB_TABLE.lock().get(pcm_id as usize).copied().flatten() +} + +/// Return the kernel-side `&mut [i16]` view of the PCM ring. Unsafe +/// because the host is mutating the same memory via the AudioWorklet; +/// synchronisation is via `mmap_status->hw_ptr` and +/// `mmap_control->appl_ptr` (the lock-free producer-consumer protocol +/// alsa-lib expects). +pub unsafe fn ring_mut_s16(pcm_id: u32) -> Option<&'static mut [i16]> { + let SabSlice { base, len } = lookup(pcm_id)?; + Some(core::slice::from_raw_parts_mut(base as *mut i16, len / 2)) +} +``` + +**Step 2: Kernel export** + +```rust +// crates/kernel/src/wasm_api.rs + +#[no_mangle] +pub extern "C" fn kernel_audio_init_sab(pcm_id: u32, sab_base: u64, + sab_len: u32) +{ + let _ = crate::audio::sab::register(pcm_id, + crate::audio::sab::SabSlice { + base: sab_base as usize, + len: sab_len as usize, + }); + // Errors swallowed — host-side init is best-effort; if pcm_id + // is already registered, the second call is a no-op. +} +``` + +**Step 3: WRITEI_FRAMES** + +```rust +fn handle_writei(pid: i32, fd: i32, buf: &mut [u8]) -> Result<(), Errno> +{ + use wasm_posix_shared::audio::*; + let mut req: WpkAlsaXferi = read_struct(buf)?; + let frames = req.frames as usize; + // The audio data ring is SAB-backed; userspace can mmap it + // directly and write into it without WRITEI. WRITEI is the + // non-mmap path (alsa-lib's "rw_interleaved" access mode). + with_alsa_pcm_ofd(pid, fd, |a| { + let Some(hw) = a.hw_params.as_ref() else { + return Err(Errno::EBADFD); + }; + if hw.format != SNDRV_PCM_FORMAT_S16_LE { + return Err(Errno::EINVAL); // v1 only ships S16_LE + } + let ring = unsafe { + crate::audio::sab::ring_mut_s16(a.pcm_id) + .ok_or(Errno::ENODEV)? + }; + let ring_frames = ring.len() / hw.channels as usize; + let appl = a.mmap_control.as_ref() + .ok_or(Errno::EBADFD)?.appl_ptr as usize; + let hw_ptr = a.mmap_status.as_ref() + .ok_or(Errno::EBADFD)?.hw_ptr as usize; + let avail = ring_frames - (appl - hw_ptr); + let to_write = frames.min(avail); + // Copy frames from user buf to SAB ring; wrap appl_ptr at + // ring_frames. Channels-interleaved layout (alsa-lib's + // "rw_interleaved"). + let src: &[i16] = unsafe { + core::slice::from_raw_parts(req.buf as *const i16, + to_write * hw.channels as usize) + }; + for f in 0..to_write { + let dst_off = ((appl + f) % ring_frames) * hw.channels as usize; + for c in 0..hw.channels as usize { + ring[dst_off + c] = src[f * hw.channels as usize + c]; + } + } + // Advance appl_ptr — the AudioWorklet picks up the new data + // on its next quantum. + if let Some(ctl) = /* re-borrow mut */ { + ctl.appl_ptr = (appl + to_write) as i64; + } + req.result = to_write as i64; + Ok(()) + })??; + write_struct(buf, &req) +} +``` + +(The `with_alsa_pcm_ofd` vs `_mut` borrow dance above is sketched; +final impl uses `_mut` and avoids the double-borrow.) + +**Step 4: Cargo tests** + +```rust +#[test] +fn writei_appends_frames_to_sab_ring() { /* … */ } + +#[test] +fn writei_blocks_caller_when_ring_full() { + // Configure period=1024, buffer=4096; write 4096 frames; next + // WRITEI parks until kernel_audio_period_tick frees space. +} + +#[test] +fn writei_wraps_appl_ptr_at_buffer_boundary() { /* … */ } + +#[test] +fn writei_in_open_state_returns_ebadfd() { /* … */ } + +#[test] +fn writei_with_unsupported_format_returns_einval() { /* … */ } +``` + +**Step 5: Commit** + +```bash +git add crates/kernel/src/audio/ crates/kernel/src/wasm_api.rs +git commit -m "kernel(audio): WRITEI_FRAMES + SAB ring + kernel_audio_init_sab export" +``` + +--- + +### Task A5: `mmap` of status / control / data pages + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — `sys_mmap` arm for OFDs + with `audio` populated. + +```rust +// In sys_mmap, for OpenFileKind::AlsaPcm OFDs: +if let Some(audio) = ofd.audio.as_mut() { + return handle_alsa_mmap(pid, fd, addr, length, prot, flags, + offset, audio); +} + +fn handle_alsa_mmap(pid: i32, fd: i32, addr: *mut u8, length: usize, + prot: u32, flags: u32, offset: u64, audio: &mut AlsaFdState) + -> Result<*mut u8, Errno> +{ + use wasm_posix_shared::audio::*; + match offset { + SNDRV_PCM_MMAP_OFFSET_STATUS => { + // Allocate the page if first mmap. + if audio.mmap_status.is_none() { + audio.mmap_status = Some(Box::new(WpkAlsaPcmMmapStatus::default())); + } + let status = audio.mmap_status.as_ref().unwrap(); + let ptr = (status.as_ref() as *const _) as *mut u8; + // Map ptr..ptr+sizeof(WpkAlsaPcmMmapStatus) into user + // process at `addr` via the existing mmap-VPN + // infrastructure. + map_kernel_page_into_user(pid, addr, ptr, length, prot) + } + SNDRV_PCM_MMAP_OFFSET_CONTROL => { + if audio.mmap_control.is_none() { + audio.mmap_control = Some(Box::new(WpkAlsaPcmMmapControl::default())); + } + let ctl = audio.mmap_control.as_ref().unwrap(); + let ptr = (ctl.as_ref() as *const _) as *mut u8; + map_kernel_page_into_user(pid, addr, ptr, length, prot) + } + SNDRV_PCM_MMAP_OFFSET_DATA => { + // The PCM data ring. Host registered the SAB via + // kernel_audio_init_sab; we hand userspace the same + // pointer (same memory). + let slice = crate::audio::sab::lookup(audio.pcm_id) + .ok_or(Errno::ENODEV)?; + map_kernel_page_into_user(pid, addr, slice.base as *mut u8, + length.min(slice.len), prot) + } + _ => Err(Errno::EINVAL), + } +} +``` + +`map_kernel_page_into_user` is the existing helper (used by +shared-anon mmap, etc.) — verify it accepts kernel-resident pointers +as map sources. If not, add a `kind = MappingKind::KernelOwned` arm. + +**Cargo tests:** + +```rust +#[test] +fn mmap_status_page_returns_mapped_kernel_struct() { /* … */ } + +#[test] +fn mmap_control_page_is_writable_by_userspace() { /* … */ } + +#[test] +fn mmap_data_page_returns_sab_region() { /* … */ } + +#[test] +fn mmap_data_before_init_sab_returns_enodev() { /* … */ } +``` + +**Commit:** `kernel(audio): mmap status / control / data pages` + +--- + +### Task A6: `kernel_audio_period_tick` — advance hw_ptr + wake POLLOUT waiters + +**Files:** +- Create: `crates/kernel/src/audio/tick.rs` — the producer. +- Modify: `crates/kernel/src/wasm_api.rs` — add the + `kernel_audio_period_tick` export. + +**Step 1: The producer** + +```rust +// crates/kernel/src/audio/tick.rs + +use crate::audio::wait; + +/// Called from the host on every period-tick. Walks every open +/// `/dev/snd/pcmC0D0p` OFD whose `pcm_id == pcm_id` argument and +/// whose state is RUNNING; advances `mmap_status.hw_ptr` by +/// `frames_consumed`; wakes any process blocked in WRITEI / POLLOUT +/// if space is now available. +/// +/// Same lock-order resolution as plan 4 A7 + plan 5 A4: take +/// PROCESS_TABLE briefly, iterate `pt.ofds.entries`, collect +/// wake-targets, drop the lock, then call wait::wake_*. +pub fn tick(pcm_id: u32, frames_consumed: u32) { + let now = crate::time::monotonic_us(); + let mut woken: Vec<usize> = Vec::new(); + { + let mut pt = crate::PROCESS_TABLE.lock(); + for (idx, slot) in pt.ofds.entries.iter_mut().enumerate() { + let Some(ofd) = slot.as_mut() else { continue; }; + let Some(audio) = ofd.audio.as_mut() else { continue; }; + if audio.pcm_id != pcm_id { continue; } + if audio.state != SNDRV_PCM_STATE_RUNNING { continue; } + if let Some(status) = audio.mmap_status.as_mut() { + status.hw_ptr = status.hw_ptr.saturating_add( + frames_consumed as i64); + status.tstamp_sec = (now / 1_000_000) as i64; + status.tstamp_nsec = ((now % 1_000_000) * 1000) as i64; + // XRUN detection: if hw_ptr passes appl_ptr the + // userspace under-fed us. + let appl = audio.mmap_control.as_ref() + .map(|c| c.appl_ptr).unwrap_or(0); + if status.hw_ptr > appl { + audio.state = SNDRV_PCM_STATE_XRUN; + status.state = SNDRV_PCM_STATE_XRUN; + } + } + woken.push(idx); + } + } + for idx in woken { + wait::wake_pollout(idx); + } +} +``` + +**Step 2: The kernel export** + +```rust +// crates/kernel/src/wasm_api.rs + +/// Called by the host on every AudioWorklet quantum (browser) or +/// setInterval tick (Node) after the host driver pulled +/// `frames_consumed` frames from the SAB ring. Advances hw_ptr in +/// the corresponding pcm_id's mmap_status; wakes blocked WRITEI / +/// POLLOUT callers. +#[no_mangle] +pub extern "C" fn kernel_audio_period_tick(pcm_id: u32, + frames_consumed: u32) +{ + crate::audio::tick::tick(pcm_id, frames_consumed); +} +``` + +**Step 3: POLLOUT semantics** + +`poll(pcm_fd, POLLOUT)` returns ready iff `avail >= sw_params.avail_min` +where `avail = buffer_size - (appl_ptr - hw_ptr)`. The +`wait::wake_pollout` primitive is the per-OFD wake (sibling of +plan 5's `input::wait::wake_event_reader`). + +**Cargo tests:** + +```rust +#[test] +fn tick_advances_hw_ptr_by_frames_consumed() { /* … */ } + +#[test] +fn tick_on_non_running_pcm_is_a_noop() { /* … */ } + +#[test] +fn tick_underrun_transitions_state_to_xrun() { + // appl_ptr = 1000; tick advances hw_ptr to 1100 (past appl). + // State must become XRUN; user must SNDRV_PCM_IOCTL_PREPARE to + // recover. +} + +#[test] +fn tick_wakes_blocked_pollout_waiter() { /* … */ } +``` + +**Step 4: Commit** + +```bash +git add crates/kernel/src/audio/ crates/kernel/src/wasm_api.rs +git commit -m "kernel(audio): kernel_audio_period_tick export + hw_ptr advance + XRUN detection" +``` + +--- + +### Task A7: `poll(POLLOUT)` + non-blocking `write` arm + +**Files:** +- Modify: `crates/kernel/src/syscalls.rs` — `sys_poll` arm for + `OpenFileKind::AlsaPcm`. + +```rust +// In sys_poll, for AlsaPcm OFDs: +if let Some(audio) = ofd.audio.as_mut() { + let buffer = audio.hw_params.as_ref() + .map(|h| h.buffer_size as i64).unwrap_or(0); + let appl = audio.mmap_control.as_ref() + .map(|c| c.appl_ptr).unwrap_or(0); + let hw_ptr = audio.mmap_status.as_ref() + .map(|s| s.hw_ptr).unwrap_or(0); + let avail = buffer - (appl - hw_ptr); + let avail_min = audio.sw_params.as_ref() + .map(|s| s.avail_min as i64).unwrap_or(1); + if pfd.events & POLLOUT != 0 { + if avail >= avail_min { + pfd.revents |= POLLOUT; + } else if blocking { + crate::audio::wait::block_pollout(ofd_idx)?; + } + } + if audio.state == SNDRV_PCM_STATE_XRUN { + pfd.revents |= POLLERR; + } + continue; +} +``` + +**Cargo tests:** +- `poll_pollout_ready_when_avail_above_threshold`. +- `poll_pollout_blocks_when_buffer_full`. +- `poll_pollerr_set_on_xrun_state`. + +**Commit:** `kernel(audio): poll(POLLOUT) + XRUN reflection in POLLERR` + +--- + +### Task A8: controlC0 ioctl dispatch — PVERSION / CARD_INFO / ELEM_LIST + +**Files:** +- Create: `crates/kernel/src/audio/ctl_ioctl.rs`. +- Modify: `crates/kernel/src/syscalls.rs` — wire the dispatcher. + +```rust +fn handle_alsa_ctl_ioctl(pid: i32, fd: i32, request: u32, + buf: &mut [u8]) -> Result<(), Errno> +{ + use wasm_posix_shared::audio::*; + match request { + SNDRV_CTL_IOCTL_PVERSION => write_u32(buf, 0x0002_0007), + + SNDRV_CTL_IOCTL_CARD_INFO => { + let mut info = WpkAlsaCtlCardInfo { card: 0, ..Default::default() }; + // … fill id="wpk", driver="wpk-audio", name="wpk-virtual", + // longname="WPK virtual audio device", mixername="", … + copy_to_field(&mut info.id, WPK_AUDIO_DRIVER_NAME); + copy_to_field(&mut info.driver, WPK_AUDIO_DRIVER_NAME); + copy_to_field(&mut info.name, WPK_AUDIO_CARD_NAME); + copy_to_field(&mut info.longname, WPK_AUDIO_LONGNAME); + write_struct(buf, &info) + } + + SNDRV_CTL_IOCTL_ELEM_LIST => { + // v1 reports zero elements — alsa-lib + SDL2 both tolerate + // an empty mixer surface. Write `count = 0, used = 0` + // back and return Ok. + let mut req: WpkAlsaCtlElemList = read_struct(buf)?; + req.count = 0; + req.used = 0; + write_struct(buf, &req) + } + + _ => Err(Errno::EOPNOTSUPP), + } +} +``` + +**Cargo tests:** +- `ctl_pversion_returns_alsa_ctl_v2_0_7`. +- `ctl_card_info_returns_wpk_virtual`. +- `ctl_elem_list_returns_empty`. + +**Commit:** `kernel(audio): controlC0 ioctl dispatch — PVERSION + CARD_INFO + ELEM_LIST` + +--- + +### Task A9: `on_final_close` releases SAB binding + cancels tick subscription + +**Files:** +- Modify: `crates/kernel/src/ofd.rs` — extend `on_final_close`. + +```rust +impl OpenFileDesc { + pub fn on_final_close(&mut self, pid: i32, host_io: &mut dyn HostIO, + ofd_idx: usize) + { + // … plan 2's prime_bo cleanup … + // … plan 3's dri.handles cleanup … + // … plan 4's kms cleanup (master, fbs, pending_flips) … + // … plan 5's input cleanup … + + if let Some(audio) = self.audio.take() { + // mmap_status / mmap_control pages are dropped with the + // Box; the SAB itself stays registered (host owns the + // memory; other OFDs may still hold the pcm_id binding). + // If this was the last OFD for `pcm_id`, the host's + // AudioWorklet may want to stop pulling — but the host + // doesn't get a kernel-side signal in v1; it polls the + // PCM state via STATUS ioctl on a separate fd, or it + // just keeps pulling silence (the SAB will read as + // zeros after appl_ptr stops advancing). + // + // (When plan 9's wpkcompositor adds power-management + // signals, an `host_audio_pcm_release(pcm_id)` import + // hook lands here.) + let _ = audio; + } + + if let Some(audio_ctl) = self.audio_ctl.take() { + let _ = audio_ctl; // no per-fd state + } + } +} +``` + +**Cargo tests:** +- `close_releases_audio_state_but_keeps_sab_registered` — open + pcmC0D0p, close it; assert the SAB stays in the registry (next + open re-uses it). +- `fork_then_close_in_child_keeps_audio_state_on_parent`. + +**Commit:** `kernel(audio): on_final_close drops audio state` + +--- + +### Task A10: ABI snapshot regen (additive) + +**Files:** +- Modify: `abi/snapshot.json` (auto-generated). +- DO NOT modify: `ABI_VERSION`. + +Expected diff: new entries for the audio structs, the +`SNDRV_PCM_IOCTL_*` + `SNDRV_CTL_IOCTL_*` numbers, the format / state / +access constants, and the two new exports (`kernel_audio_period_tick`, +`kernel_audio_init_sab`). **No** changes to any existing row. + +```bash +bash scripts/check-abi-version.sh update +git diff abi/snapshot.json +bash scripts/check-abi-version.sh +git add abi/snapshot.json +git commit -m "kernel(audio): regen ABI snapshot — additive audio surface" +``` + +--- + +### Task A11: Phase A — full gauntlet + open PR #1 + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +Push, open draft PR. + +Title: `[explore-dri] kernel(audio): /dev/snd/{controlC0,pcmC0D0p} + SNDRV_PCM_IOCTL_* + mmap pages` + +Body (Brandon style): + +```markdown +## Summary +- Add `/dev/snd/controlC0` (card-level control) and `/dev/snd/pcmC0D0p` + (PCM playback) as virtual devices alongside plan 5's + `/dev/input/event*`. +- Per-OFD `AlsaFdState` carrying PCM state machine, HW/SW params + cache, and pointers to host-allocated SAB + kernel-allocated + status/control mmap pages. +- `SNDRV_PCM_IOCTL_*` subset alsa-lib drives: PVERSION / INFO / + HW_REFINE / HW_PARAMS / HW_FREE / SW_PARAMS / PREPARE / START / + DROP / PAUSE / STATUS / WRITEI_FRAMES. +- `kernel_audio_period_tick(pcm_id, frames_consumed)` export — host + calls this on every AudioWorklet quantum; kernel advances + `mmap_status.hw_ptr` and wakes `poll(POLLOUT)` waiters. +- `kernel_audio_init_sab(pcm_id, base, len)` export — host registers + its SharedArrayBuffer pointer once at boot; subsequent + `mmap(pcm_fd, ..., OFFSET_DATA)` returns the same memory to + userspace (zero-copy data path). + +## Why +Plan 6 of the DRI v2 design (`docs/plans/2026-05-18-dri-design.md` +§8) — audio is the third of three host-driven event-streams (vblank +in plan 4; input in plan 5; audio here). Prereq for SDL2's audio +backend (plan 7, milestone D); SDL2's init fails hard if the device +probe returns nothing, so this plan must land before SDL2 can be +ported. + +## Verification +- `cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib` +- `cd host && npx vitest run` +- `scripts/run-libc-tests.sh` +- `scripts/run-posix-tests.sh` +- `bash scripts/check-abi-version.sh` + +## ABI impact +Additive only — no `ABI_VERSION` bump. New `repr(C)` structs +(`WpkAlsaPcmHwParams`, `WpkAlsaPcmSwParams`, `WpkAlsaPcmStatus`, +`WpkAlsaPcmInfo`, `WpkAlsaPcmMmapStatus`, `WpkAlsaPcmMmapControl`, +`WpkAlsaXferi`, `WpkAlsaCtlCardInfo`, `WpkAlsaCtlElemId`, +`WpkAlsaCtlElemList`, `WpkSndInterval`), new ioctl numbers in the +`'A'` and `'U'` magics (verbatim Linux UAPI), two new kernel exports +(`kernel_audio_period_tick`, `kernel_audio_init_sab`). **No new host +imports** — kernel is a mid-stage between user-WRITEI and +host-AudioWorklet; the SAB is the canonical data store. No existing +surface changes. + +## Notes +- `CLOCK_MONOTONIC` timestamps (design §16 q6); shared with plan 4's + `kernel_vblank` and plan 5's `kernel_input_event` for cross-stream + jitter / A-V-sync profiling. +- v1 supports `SNDRV_PCM_FORMAT_S16_LE` only; HW_REFINE clamps. Other + formats return EINVAL on HW_PARAMS. +- Single card / single device / single substream / playback only. +- mmap pages are kernel-resident (status + control); the PCM data + ring is the host-allocated SAB, mapped into the user via the + existing mmap-VPN path. +- WRITEI_FRAMES is for non-mmap (rw_interleaved) clients; mmap clients + write the SAB directly and advance `mmap_control.appl_ptr`. +- XRUN detection: `kernel_audio_period_tick` flips the PCM state to + XRUN if `hw_ptr` passes `appl_ptr`; user must `PREPARE` to recover. +``` + +**Do not merge.** + +--- + +## Phase B — host: `AudioDriver` (browser AudioWorklet, Node dummy) + SAB ring bridge (PR #2) + +### Task B1: `AudioDriver` module + interface + +**Files:** +- Create: `host/src/audio/audio-driver.ts`. + +```ts +// host/src/audio/audio-driver.ts + +export interface AudioDriver { + /** Begin pulling from the SAB ring once HW_PARAMS lands and the + * pcm state transitions to RUNNING. `kernelTick` is the bound + * `kernel.exports.kernel_audio_period_tick` proxy. */ + start(pcmId: number, sampleRate: number, channels: number, + periodFrames: number, kernelTick: (pcmId: number, + framesConsumed: number) => void): Promise<void>; + + /** Stop pulling. Called from on_final_close-equivalent host paths + * and from DROP / PAUSE state transitions (via STATUS polling). */ + stop(pcmId: number): void; + + /** Return the host-allocated SAB for this pcm — the kernel will + * register it via `kernel_audio_init_sab(pcmId, sab.byteOffset, + * sab.byteLength)` at boot. Constant for the pcm's lifetime. */ + getSab(pcmId: number): SharedArrayBuffer; +} +``` + +**Vitest:** import-and-instantiate sanity only. + +**Commit:** `host(audio): AudioDriver interface` + +--- + +### Task B2: `BrowserAudioDriver` — AudioContext + AudioWorklet + SAB ring + +**Files:** +- Create: `host/src/audio/browser-audio-driver.ts`. +- Create: `host/src/audio/wpk-audio-worklet.js` — the AudioWorklet + processor (loaded via `audioContext.audioWorklet.addModule`). + +```ts +// host/src/audio/browser-audio-driver.ts + +import type { AudioDriver } from './audio-driver'; + +interface PcmContext { + audioCtx: AudioContext; + worklet: AudioWorkletNode; + sab: SharedArrayBuffer; + sampleRate: number; + channels: number; + periodFrames: number; + quantaPerPeriod: number; // 128 / sampleRate * periodSize + quantaSinceTick: number; + kernelTick: (pcmId: number, frames: number) => void; +} + +export class BrowserAudioDriver implements AudioDriver { + private contexts = new Map<number, PcmContext>(); + // Pre-allocate SABs at construction so the kernel can register + // them in its boot path before any HW_PARAMS lands. + private sabs = new Map<number, SharedArrayBuffer>(); + + constructor() { + // v1 ships a single pcm; pre-allocate its SAB. (Sizing: 64 KiB + // matches the kernel-side cap in HW_REFINE.) + this.sabs.set(0, new SharedArrayBuffer(64 * 1024)); + } + + getSab(pcmId: number): SharedArrayBuffer { + const sab = this.sabs.get(pcmId); + if (!sab) throw new Error(`no SAB for pcmId=${pcmId}`); + return sab; + } + + async start(pcmId: number, sampleRate: number, channels: number, + periodFrames: number, + kernelTick: (pcmId: number, frames: number) => void) + { + const audioCtx = new AudioContext({ sampleRate }); + await audioCtx.audioWorklet.addModule( + '/audio/wpk-audio-worklet.js'); + const worklet = new AudioWorkletNode(audioCtx, 'wpk-pcm-pull', { + numberOfInputs: 0, numberOfOutputs: 1, + outputChannelCount: [channels], + processorOptions: { + sab: this.getSab(pcmId), + channels, + }, + }); + worklet.connect(audioCtx.destination); + + // The worklet's process() runs on the audio thread; it can't + // call kernel exports directly. Instead it posts a message on + // every quantum saying "I consumed N frames"; the main thread + // accumulates and fires the tick when an ALSA period boundary + // is reached. + const ctx: PcmContext = { + audioCtx, worklet, sab: this.getSab(pcmId), sampleRate, + channels, periodFrames, + quantaPerPeriod: Math.ceil(periodFrames / 128), + quantaSinceTick: 0, kernelTick, + }; + worklet.port.onmessage = (e) => { + // e.data = { framesConsumed: number } per quantum + ctx.quantaSinceTick++; + if (ctx.quantaSinceTick >= ctx.quantaPerPeriod) { + kernelTick(pcmId, periodFrames); + ctx.quantaSinceTick = 0; + } + }; + this.contexts.set(pcmId, ctx); + } + + stop(pcmId: number): void { + const ctx = this.contexts.get(pcmId); + if (!ctx) return; + ctx.worklet.disconnect(); + ctx.audioCtx.close(); + this.contexts.delete(pcmId); + } +} +``` + +```js +// host/src/audio/wpk-audio-worklet.js — runs on the audio thread. + +class WpkPcmPullProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + const { sab, channels } = options.processorOptions; + this.ring = new Int16Array(sab); // s16 interleaved + this.ringFrames = this.ring.length / channels; + this.channels = channels; + this.hwPtr = 0; // local mirror, frames + } + + process(inputs, outputs) { + const out = outputs[0]; // out[channel][sample] + const frames = out[0].length; // always 128 + const ringFrames = this.ringFrames; + const ch = this.channels; + const ring = this.ring; + let hw = this.hwPtr; + for (let f = 0; f < frames; f++) { + const ringOff = ((hw + f) % ringFrames) * ch; + for (let c = 0; c < ch; c++) { + // s16 → f32 conversion for the WebAudio output bus. + out[c][f] = ring[ringOff + c] / 0x8000; + } + } + hw = (hw + frames) % ringFrames; + this.hwPtr = hw; + this.port.postMessage({ framesConsumed: frames }); + return true; + } +} + +registerProcessor('wpk-pcm-pull', WpkPcmPullProcessor); +``` + +**Vitest:** unit-test `BrowserAudioDriver` start/stop with a mocked +AudioContext (jsdom + a stub `AudioContext` class); assert that +`worklet.port.onmessage` accumulates quanta and fires `kernelTick` +once per period. + +**Commit:** `host(audio): BrowserAudioDriver — AudioContext + AudioWorklet + SAB ring` + +--- + +### Task B3: `NodeAudioDriver` — setInterval dummy + +**Files:** +- Create: `host/src/audio/node-audio-driver.ts`. + +```ts +// host/src/audio/node-audio-driver.ts + +import type { AudioDriver } from './audio-driver'; + +interface PcmTimer { + intervalHandle: NodeJS.Timeout; + periodFrames: number; +} + +export class NodeAudioDriver implements AudioDriver { + private timers = new Map<number, PcmTimer>(); + private sabs = new Map<number, SharedArrayBuffer>(); + + constructor() { + this.sabs.set(0, new SharedArrayBuffer(64 * 1024)); + } + + getSab(pcmId: number): SharedArrayBuffer { return this.sabs.get(pcmId)!; } + + async start(pcmId: number, sampleRate: number, channels: number, + periodFrames: number, + kernelTick: (pcmId: number, frames: number) => void) + { + // Period interval in ms; matches design §8.3's dummy. + const intervalMs = (periodFrames * 1000) / sampleRate; + // For tests: optionally compute a checksum over the consumed + // frames. Skipped here; the test harness reads the SAB directly. + const handle = setInterval(() => kernelTick(pcmId, periodFrames), + intervalMs); + this.timers.set(pcmId, { intervalHandle: handle, + periodFrames }); + } + + stop(pcmId: number): void { + const t = this.timers.get(pcmId); + if (t) { clearInterval(t.intervalHandle); this.timers.delete(pcmId); } + } +} +``` + +**Vitest:** `start` schedules an interval; mock `setInterval` + +verify `kernelTick` fires once per `periodFrames / sampleRate` ms +period. + +**Commit:** `host(audio): NodeAudioDriver — setInterval dummy for headless tests` + +--- + +### Task B4: Wire `kernel_audio_init_sab` + `kernel_audio_period_tick` + dual-host parity + +**Files:** +- Modify: `examples/browser/lib/browser-kernel.ts` — instantiate + `BrowserAudioDriver()` at boot, register SAB at kernel boot via + `kernel.exports.kernel_audio_init_sab(0, sab.byteOffset, + sab.byteLength)` (the SAB is shared into the kernel-worker's + address space via the existing memory-import path). +- Modify: `host/src/node-kernel-host.ts` — instantiate + `NodeAudioDriver()` symmetrically. +- Both: hook the host's STATUS-poll loop to call `driver.start()` + when a pcm transitions to RUNNING, `driver.stop()` on + DROP / PAUSE / closed. + +The pcm state-tracking shape (one of the open architecture items +below; see Pre-impl review): the host could either: +(a) poll `kernel.exports.kernel_audio_get_state(pcm_id) -> u32` on + a coarse interval (~10 ms); +(b) the kernel notifies via a tiny `host_audio_state_changed(pcm_id, + state)` import (but this breaks the "no host imports" rule plan 5 + set); +(c) the host driver starts unconditionally on `kernel_audio_init_sab` + and lets the AudioWorklet pull zeros until the user fills the + SAB (works because the SAB starts zeroed and the worklet emits + silence — no audible pop on transition). + +**Lean: (c)** — start the AudioWorklet at boot, pull continuously, +emit silence until the user fills the ring. State transitions are +internal kernel concerns; the host just streams whatever's in the +SAB. The kernel's hw_ptr advancement happens via period_tick +regardless of state (because that's what tells alsa-lib "the device +is consuming"); we add a guard at A6 that ticks on non-RUNNING +states are no-ops, so the appl_ptr-relative timing stays correct. + +Dual-host parity (CLAUDE.md): symmetry check — both kernel-worker +entries (Node + browser): +1. instantiate an `AudioDriver` (Node = setInterval, browser = + AudioWorklet); +2. call `kernel.exports.kernel_audio_init_sab(0, sab.byteOffset, + sab.byteLength)`; +3. call `driver.start(0, 48000, 2, 1024, kernel.exports. + kernel_audio_period_tick)` once HW_PARAMS-ready signal lands + (via lean (c) above: start immediately at boot with default + params; HW_PARAMS just adjusts the kernel-side bookkeeping). + +**Vitest:** run the Node init path; assert `kernel.exports. +kernel_audio_init_sab` was invoked exactly once at boot with the +correct (base, len); assert `NodeAudioDriver.start` was scheduled; +assert `kernel_audio_period_tick` fires at the expected interval +(use vi.useFakeTimers). Browser path tested in Playwright at +Phase C. + +**Commit:** `host(audio): wire kernel_audio_init_sab + kernel_audio_period_tick + dual-host boot path` + +--- + +### Task B5: Vitest — end-to-end PCM open + HW_PARAMS + WRITEI + tick + drain + +**Files:** +- Create: `host/test/audio-alsa.spec.ts`. + +Setup (driven via the Node host + NodeAudioDriver): + +- Open `/dev/snd/pcmC0D0p`. Assert PVERSION returns `0x000d_0000`. +- INFO → stream=PLAYBACK, card=0, device=0. +- HW_REFINE on a wildcard request → returns S16_LE-only, 2-channel, + 48 kHz, period=1024, buffer=4096. +- HW_PARAMS the refined request → state = SETUP. +- mmap `MMAP_OFFSET_STATUS` → 64-byte page, `state == SETUP`, + `hw_ptr == 0`. +- mmap `MMAP_OFFSET_CONTROL` → 64-byte page, `appl_ptr == 0`. +- mmap `MMAP_OFFSET_DATA` → 64 KiB SAB region; assert writability. +- PREPARE → state = PREPARED. +- START → state = RUNNING. +- WRITEI_FRAMES, 1024 frames of a 440 Hz sine wave; assert + `result == 1024`, `appl_ptr == 1024`. +- Drive 8 fake period ticks (via `vi.advanceTimersByTime` + + manual `kernel.exports.kernel_audio_period_tick(0, 1024)`); assert + `hw_ptr == 8 * 1024`, state still RUNNING. +- DROP → state = SETUP. +- Reopen with `pcmC0D0c` (capture) → ENODEV (v1 doesn't ship + capture). +- Open `controlC0`; CARD_INFO → name = "wpk-virtual". + +**Commit:** `host(audio): vitest — end-to-end PCM lifecycle + WRITEI + period tick` + +--- + +### Task B6: Phase B — full gauntlet + open PR #2 + +Push, open draft PR. + +Title: `[explore-dri] host(audio): AudioDriver (browser AudioWorklet, Node dummy) + SAB ring bridge` + +Body: Summary / Why / Verification / **Dual-host parity proof** (both +Node and browser kernel-worker entries instantiate an `AudioDriver`, +register the SAB via `kernel_audio_init_sab`, and route tick events +into `kernel_audio_period_tick`; symmetry verified before commit) / +Notes. + +--- + +## Phase C — sysroot + demo + browser (PR #3) + +### Task C1: Sysroot audio headers — vendor `sound/asound.h` + +**Files:** +- Vendor: `musl-overlay/include/sound/asound.h` (subset). + +musl does not ship `<sound/asound.h>` upstream — it's a Linux UAPI +header packaged as `linux-headers-${VER}`. Vendor the subset we +need into `musl-overlay/include/sound/asound.h`: + +```c +// musl-overlay/include/sound/asound.h (minimal subset) +#include <linux/ioctl.h> +#include <sys/time.h> +#include <stdint.h> + +typedef uint64_t snd_pcm_uframes_t; +typedef int64_t snd_pcm_sframes_t; + +#define SNDRV_PCM_STATE_OPEN 0 +#define SNDRV_PCM_STATE_SETUP 1 +#define SNDRV_PCM_STATE_PREPARED 2 +#define SNDRV_PCM_STATE_RUNNING 3 +#define SNDRV_PCM_STATE_XRUN 4 +#define SNDRV_PCM_STATE_PAUSED 6 + +#define SNDRV_PCM_FORMAT_S16_LE 2 +#define SNDRV_PCM_FORMAT_S32_LE 10 +#define SNDRV_PCM_FORMAT_FLOAT_LE 14 + +#define SNDRV_PCM_ACCESS_MMAP_INTERLEAVED 0 +#define SNDRV_PCM_ACCESS_RW_INTERLEAVED 3 + +#define SNDRV_PCM_STREAM_PLAYBACK 0 +#define SNDRV_PCM_STREAM_CAPTURE 1 + +#define SNDRV_PCM_MMAP_OFFSET_DATA 0x00000000UL +#define SNDRV_PCM_MMAP_OFFSET_STATUS 0x80000000UL +#define SNDRV_PCM_MMAP_OFFSET_CONTROL 0x81000000UL + +struct snd_pcm_mmap_status { + uint32_t state; + uint32_t pad1; + int64_t hw_ptr; + int64_t tstamp_sec; + int64_t tstamp_nsec; + uint32_t suspended_state; + uint32_t audio_tstamp_data; + int64_t audio_tstamp_sec; + int64_t audio_tstamp_nsec; +}; + +struct snd_pcm_mmap_control { + int64_t appl_ptr; + int64_t avail_min; + char _reserved[48]; +}; + +struct snd_xferi { + int64_t result; + uint64_t buf; + uint64_t frames; +}; + +struct snd_pcm_hw_params { + // … as kernel-side (~596 bytes); copy verbatim from kernel + // include/uapi/sound/asound.h v6.10. +}; + +#define SNDRV_PCM_IOCTL_PVERSION _IOR('A', 0x00, int) +#define SNDRV_PCM_IOCTL_INFO _IOR('A', 0x01, struct snd_pcm_info) +#define SNDRV_PCM_IOCTL_HW_REFINE _IOWR('A', 0x10, struct snd_pcm_hw_params) +#define SNDRV_PCM_IOCTL_HW_PARAMS _IOWR('A', 0x11, struct snd_pcm_hw_params) +#define SNDRV_PCM_IOCTL_HW_FREE _IO('A', 0x12) +#define SNDRV_PCM_IOCTL_SW_PARAMS _IOWR('A', 0x13, struct snd_pcm_sw_params) +#define SNDRV_PCM_IOCTL_STATUS _IOR('A', 0x20, struct snd_pcm_status) +#define SNDRV_PCM_IOCTL_PREPARE _IO('A', 0x40) +#define SNDRV_PCM_IOCTL_START _IO('A', 0x42) +#define SNDRV_PCM_IOCTL_DROP _IO('A', 0x43) +#define SNDRV_PCM_IOCTL_PAUSE _IOW('A', 0x45, int) +#define SNDRV_PCM_IOCTL_WRITEI_FRAMES _IOW('A', 0x50, struct snd_xferi) + +#define SNDRV_CTL_IOCTL_PVERSION _IOR('U', 0x00, int) +#define SNDRV_CTL_IOCTL_CARD_INFO _IOR('U', 0x01, struct snd_ctl_card_info) +#define SNDRV_CTL_IOCTL_ELEM_LIST _IOWR('U', 0x10, struct snd_ctl_elem_list) +``` + +**Verification:** `wasm32posix-cc -c programs/alsa_demo.c` compiles +without missing-include errors; `sizeof(struct snd_pcm_hw_params) == +596` etc. (kernel-side static-asserts already cover; the userspace +side gets one `_Static_assert` in `alsa_demo.c`). + +**Commit:** `sysroot(audio): vendor sound/asound.h subset` + +--- + +### Task C2: `programs/alsa_demo.c` — sine-wave playback demo + +**Files:** +- Create: `programs/alsa_demo.c`. + +```c +// programs/alsa_demo.c — ~150 LoC +// Opens /dev/snd/pcmC0D0p, HW_PARAMS for S16_LE 48 kHz stereo, mmap +// the SAB data page, fill 2 s of a 440 Hz sine wave, START + drain, +// exit 0. + +#define _GNU_SOURCE +#include <fcntl.h> +#include <math.h> +#include <poll.h> +#include <sound/asound.h> +#include <stdio.h> +#include <string.h> +#include <sys/ioctl.h> +#include <sys/mman.h> +#include <unistd.h> + +_Static_assert(sizeof(struct snd_pcm_mmap_status) == 64, + "mmap_status must be 64 bytes on wasm32"); + +#define RATE 48000 +#define CHANNELS 2 +#define PERIOD_FRAMES 1024 +#define BUFFER_FRAMES 4096 +#define DURATION_S 2 + +int main(void) { + int fd = open("/dev/snd/pcmC0D0p", O_RDWR | O_CLOEXEC); + if (fd < 0) { perror("open"); return 1; } + + int version; + ioctl(fd, SNDRV_PCM_IOCTL_PVERSION, &version); + printf("alsa version: 0x%08x\n", version); + + struct snd_pcm_hw_params hw = {0}; + // … fill hw with wildcard, then HW_REFINE narrows to v1 caps … + ioctl(fd, SNDRV_PCM_IOCTL_HW_REFINE, &hw); + // … set format=S16_LE, channels=2, rate=48000, + // period_size=1024, buffer_size=4096 in hw masks/intervals … + if (ioctl(fd, SNDRV_PCM_IOCTL_HW_PARAMS, &hw) < 0) { + perror("HW_PARAMS"); return 1; + } + + // mmap the three pages. + void *status_p = mmap(NULL, 4096, PROT_READ, MAP_SHARED, fd, + SNDRV_PCM_MMAP_OFFSET_STATUS); + void *ctl_p = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, + fd, SNDRV_PCM_MMAP_OFFSET_CONTROL); + int16_t *ring = mmap(NULL, BUFFER_FRAMES * CHANNELS * 2, + PROT_READ | PROT_WRITE, MAP_SHARED, fd, + SNDRV_PCM_MMAP_OFFSET_DATA); + struct snd_pcm_mmap_status *status = status_p; + struct snd_pcm_mmap_control *ctl = ctl_p; + + ioctl(fd, SNDRV_PCM_IOCTL_PREPARE, NULL); + + // Fill the first period with sine before START. + double phase = 0.0; + const double freq_step = 2.0 * 3.14159265 * 440.0 / RATE; + int16_t amp = 8000; // ~25% of full-scale + for (int f = 0; f < BUFFER_FRAMES; f++) { + int16_t s = (int16_t)(sin(phase) * amp); + ring[f * CHANNELS + 0] = s; + ring[f * CHANNELS + 1] = s; + phase += freq_step; + } + ctl->appl_ptr = BUFFER_FRAMES; + + ioctl(fd, SNDRV_PCM_IOCTL_START, NULL); + + // Producer loop: refill the ring as hw_ptr advances. 2 s total. + int total_frames = RATE * DURATION_S; + int produced = BUFFER_FRAMES; + while (produced < total_frames) { + // Wait for space. + struct pollfd pfd = { .fd = fd, .events = POLLOUT }; + poll(&pfd, 1, 100); + // Refill up to one period. + int64_t hw_ptr = status->hw_ptr; + int64_t appl = ctl->appl_ptr; + int avail = BUFFER_FRAMES - (int)(appl - hw_ptr); + int to_write = avail < PERIOD_FRAMES ? avail : PERIOD_FRAMES; + for (int f = 0; f < to_write; f++) { + int16_t s = (int16_t)(sin(phase) * amp); + int ring_off = ((int)(appl + f) % BUFFER_FRAMES) * CHANNELS; + ring[ring_off + 0] = s; + ring[ring_off + 1] = s; + phase += freq_step; + } + ctl->appl_ptr = appl + to_write; + produced += to_write; + } + + // Drain — wait until hw_ptr catches up. + while (status->hw_ptr < ctl->appl_ptr) { + struct pollfd pfd = { .fd = fd, .events = POLLOUT }; + poll(&pfd, 1, 50); + } + + ioctl(fd, SNDRV_PCM_IOCTL_DROP, NULL); + close(fd); + return 0; +} +``` + +Build via `wasm32posix-cc -o programs/alsa_demo.wasm +programs/alsa_demo.c -lm`. Wire into `scripts/build-programs.sh`. + +**Commit:** `examples(audio): alsa_demo — 2 s 440 Hz sine wave playback` + +--- + +### Task C3: Vitest end-to-end + +**Files:** +- Create: `host/test/audio-alsa-demo.spec.ts`. + +Runs `alsa_demo.wasm` under the centralised kernel with +`NodeAudioDriver`; asserts: +- the demo exits 0 within 3 s; +- the SAB ring contains non-silent samples (sum of absolute values + is above a sanity threshold); +- `hw_ptr` advanced by exactly `RATE * DURATION_S = 96000` frames + (no underrun); +- the PCM state at exit is SETUP (post-DROP). + +**Commit:** `host(audio): vitest — alsa_demo end-to-end` + +--- + +### Task C4: Manual browser verification (the gate) + +CLAUDE.md item 6. Build the demo, wire into `examples/browser/pages/ +alsa/`. Click the "Play sine" button — the browser prompts for audio +permission (autoplay policy), grants, and a 440 Hz tone plays for +2 s. No crackle, no underruns visible in the console log +(`kernel_audio_period_tick` should fire at ~46 Hz steady). + +If the tone is silent but the console shows ticks, the SAB +registration step is wrong (check `kernel_audio_init_sab` got the +right base pointer). If the tone is wrong frequency or crackly, the +worklet's `process()` is reading the ring wrong (check `hwPtr` +modulo arithmetic in the worklet). + +**No commit yet for this task — verification only.** If the browser +demo fails but Node + Vitest passes, that's a host-parity bug — PR +#410 cautionary tale (CLAUDE.md "Two hosts" rules). + +--- + +### Task C5: Phase C — final gauntlet + open PR #3 + +PR title: `[explore-dri] examples(audio): alsa_demo + browser spec` + +Body: Summary / Why / Verification (gauntlet + browser screenshot of +the demo page after the 2 s sine wave played) / Dual-host parity +proof / Notes. + +--- + +## Final coordinated merge + +When all three PRs (kernel, host, examples) are reviewed and approved, +and Brandon has signed off on the demo running cleanly in browser + +Node: + +1. Re-run the full gauntlet on each PR's branch tip. +2. Squash-merge PR #1 → PR #2's base. +3. Squash-merge PR #2 → PR #3's base. +4. Squash-merge PR #3 → plan 5's `…-evdev-demo` (or wherever plan 5's + tip lives at the time). +5. Tag: `[explore-dri-alsa] milestone (audio) merged at <sha>` in + the next session-handoff doc. + +**Do not push to upstream until v1 + plans 2–6 are all merged +upstream as a coherent chain.** + +--- + +## Trade-offs already locked in (don't relitigate during implementation) + +- **Two devices only — `controlC0` (mixer) and `pcmC0D0p` (playback).** + No capture (`pcmC0D0c`), no timer (`/dev/snd/timer`), no sequencer + (`/dev/snd/seq`). SDL2's audio probe needs control + at least one + playback pcm; libasound tolerates missing timer/seq. Capture is + post-v1 (requires browser MediaStream + audio permission flow, + neither of which the v1 design budgets). +- **`SNDRV_PCM_FORMAT_S16_LE` only.** HW_REFINE clamps. SDL2 + most + apps default to S16_LE; FLOAT_LE / S32_LE are deferred to v2 when + the AudioWorklet can do format conversion (or the kernel does). + Single-format keeps the data-path branch-free. +- **Single substream, single card, single device.** No `pcmC0D1*`, + no `controlC1`. Multi-card is post-v1. +- **Period tick driven by the host (AudioWorklet quantum-accumulator + / setInterval).** The kernel has no internal periodic timer; it + learns "period happened" via the `kernel_audio_period_tick` + export. Same shape as plan 4's `kernel_vblank`. +- **PCM data ring is SAB-backed, host-allocated.** The host owns + the SharedArrayBuffer; the kernel imports a view into its linear + memory via `kernel_audio_init_sab`. userspace `mmap(pcm_fd, ..., + OFFSET_DATA)` returns the same memory — zero-copy data path. The + AudioWorklet pulls from the SAB on the audio thread directly. +- **mmap status + control pages are kernel-resident.** Kernel + allocates them in its heap on first mmap call; the mmap-VPN + infrastructure maps them into the user via the existing + shared-anon path. Separate from the DATA page because the + status/control pages need to be writable by the kernel + (`hw_ptr`) and the host (`appl_ptr`) is the userspace, not the + host JS. +- **`CLOCK_MONOTONIC` timestamps** (design §16 q6). Same source as + plan 4 / plan 5. No `SNDRV_PCM_IOCTL_TTSTAMP` (clock-source + selection); fixed MONOTONIC sidesteps Y2K38 / DST / NTP-skew + footguns and keeps the three event streams comparable. +- **No host imports** (mirror of plan 5's asymmetry). The kernel's + only host-direction signal is the `kernel_audio_period_tick` + export the host calls. Data flows through the SAB without crossing + the kernel-host JS boundary on the hot path. Plan 9's compositor + may add a `host_audio_master_change` import for volume / mute + surfacing, but v1 doesn't ship the mixer surface. +- **PCM state transitions enforced.** Per the ALSA UAPI state + machine (asound.h's `snd_pcm_state_t`): OPEN → SETUP (HW_PARAMS) + → PREPARED (PREPARE) → RUNNING (START) → SETUP (DROP) or → PAUSED + (PAUSE) or → XRUN (kernel-detected underrun). Test coverage at + A3 locks the state transitions; userspace bugs that skip steps + fail with EBADFD. +- **XRUN on hw_ptr > appl_ptr.** Kernel detects underrun + automatically at every period tick. Userspace recovers by + re-PREPAREing. Matches Linux semantics; SDL2's audio backend has + the XRUN-recovery code path built in. +- **No `EVIOCSCLOCKID`-equivalent (`SNDRV_PCM_IOCTL_TTSTAMP`).** + Clock is fixed MONOTONIC; same justification as plan 5's + EVIOCSCLOCKID refusal. +- **Plan 6 does not join plan 4's `DriOfdState` or plan 5's + `InputFdState`.** Audio state and DRI/input state are disjoint; + `Option<Box<AlsaFdState>>` is a separate field on OFD parallel to + `dri_state` + `input`. The "one box per device class" factoring + plan 5 established holds. +- **AudioContext sample rate (browser-side) matches HW_PARAMS rate.** + AudioContext is constructed with `{ sampleRate: 48000 }`; if the + browser refuses (some platforms force native rate), the worklet + does the resample. v1 hard-codes 48 kHz to avoid the resample + path; HW_REFINE clamps rate to [48000, 48000] in v1. +- **Browser AudioContext autoplay-gate.** Most browsers require a + user gesture before the AudioContext resumes from "suspended". + The demo page wires a "Play" button that calls `audioCtx.resume()` + on click; without the click, no audio plays even though the + worklet's `process()` callback still fires (zero buffers). This + is browser-imposed and out of scope for kernel/host code. + +--- + +## Risk register + +1. **`snd_pcm_hw_params` struct size iteration.** Linux's value is + 596 bytes on x86_64; wasm32-ilp32 alignment differs (`__u64` + members in `snd_pcm_uframes_t`-typed fields shift offsets). First + `cargo test` likely fails the size_of assertion; iterate via + `cargo expand` + array-length tweaks. *Mitigation:* budget half + a day of iteration; do not move on from A1 until all size_of + asserts pass. +2. **AudioWorklet `process()` on the audio thread can't call Wasm + exports.** The worklet runs in a dedicated `AudioWorkletGlobalScope` + that has no `import.meta`, no `WebAssembly.Instance` access. We + solve via `worklet.port.postMessage` to the main thread, which + then calls `kernel.exports.kernel_audio_period_tick`. *Mitigation:* + already in B2's design; the cost is one postMessage per quantum + (~375 Hz; ~3 ms latency between consumption and tick). Document + the latency in B2's commit body so future profiling has a + reference. +3. **SharedArrayBuffer requires cross-origin-isolated headers.** The + browser demo needs `Cross-Origin-Opener-Policy: same-origin` and + `Cross-Origin-Embedder-Policy: require-corp` set in the dev + server. v1's `./run.sh browser` already sets these for the + WebGL demo (existing v1 plumbing); verify alsa demo inherits. +4. **AudioContext sampleRate may differ from HW_PARAMS rate.** Some + browsers force the native rate (often 44.1 kHz on Macs, 48 kHz + on Linux/Windows). If the host's AudioContext refuses 48 kHz, + the worklet must resample. *Mitigation:* v1 detects sample-rate + mismatch at `audioContext.audioWorklet.addModule` time, logs a + warning, and the worklet does linear interpolation (good enough + for a 440 Hz sine; not good enough for music). v2 ports a + proper resampler. +5. **Browser autoplay policy.** AudioContext starts in "suspended" + state until a user gesture. The demo wires a click-to-play + button per C4. SDL2 apps that auto-init audio without a gesture + will fail; that's the SDL2 port's problem to handle (plan 7). +6. **Kernel-side SAB import path.** The host hands the kernel-worker + a SAB via the existing memory-import mechanism (used by plan 2's + gbm_bo SAB sharing). The kernel sees the SAB as raw bytes at a + specific linear-memory offset. Need to verify the existing + mechanism cleanly handles the SAB lifetime (the kernel-worker + must not free the import on its own; the host owns the SAB). + *Mitigation:* B4 spike to verify the SAB-import path in a + one-day prototype before B2 implementation; if it doesn't work, + fall back to the kernel allocating the ring in its own heap and + the host copying via main-thread postMessage on every quantum + (4-8x slower, but viable). +7. **`MAP_SHARED` semantics on a kernel-resident page.** mmap of + the status/control pages relies on the existing + shared-anon-mmap path treating a kernel-owned `Box<T>` as a + valid mapping source. If the helper doesn't accept that (e.g., + the existing path only handles host-imported SABs), we need to + add a `MappingKind::KernelOwned` arm. *Mitigation:* A5 + verification step explicitly checks this before depending on it. + +--- + +## What this plan doesn't cover (deferred) + +- **PCM capture** (`/dev/snd/pcmC0D0c`, `_READI_FRAMES`, MediaStream + permission flow on the browser). v2+. Requires browser audio + capture permission which we don't budget for v1. +- **`/dev/snd/timer`** (alsa-timer subsystem). Alsa-lib gracefully + degrades when missing. Post-v1. +- **`/dev/snd/seq`** (sequencer / MIDI). Same as timer; SDL2 and + most apps don't touch it. Post-v1. +- **Mixer surface** (`controlC0` ELEM_WRITE / power-management). + v1 returns an empty element list. Plan 9's wpkcompositor surfaces + a master-volume control; that lives in user-space (the + compositor), not the kernel. +- **Float / 24-bit / 32-bit formats.** v1 ships S16_LE only; + HW_REFINE clamps others. +- **Multiple PCM substreams.** Single card, single device, single + sub. Post-v1. +- **WebAudio backend's underrun signalling.** v1 detects XRUN + kernel-side; v2's mixer surface may want to log underruns via the + compositor. +- **SDL2 audio port** (milestone D, plan 7) — requires this plan. +- **Full alsa-lib port.** v1 issues ioctls directly; SDL2's port + (plan 7) brings in alsa-lib + the udev / alsa-config layer. +- **mmap_buffer for the data page implementation detail.** + Currently designed as the same SAB userspace mmaps. If the + zero-copy SAB approach hits the risk-register #6 wall, the + fallback is kernel-allocates-ring + host-copies-per-quantum; the + ABI surface (mmap offsets, ioctl numbers) is unchanged. + +--- + +End of plan. diff --git a/docs/plans/2026-07-06-wpkdraw-plan.md b/docs/plans/2026-07-06-wpkdraw-plan.md new file mode 100644 index 0000000000..55aec15b10 --- /dev/null +++ b/docs/plans/2026-07-06-wpkdraw-plan.md @@ -0,0 +1,2169 @@ +# DRI v2 — wpkdraw plan (thin 2D rendering library for non-SDL2 apps) + +> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to execute this plan task-by-task. + +**Goal:** Ship `examples/libs/wpkdraw/` — a thin, static-link-only, +user-space 2D rendering library that sits directly on plan 4's KMS +(`/dev/dri/card0`) + plan 2's gbm_bo (CPU-tier, ARGB8888), giving +non-SDL2 apps a `wpk_draw_pixel` / `wpk_rect` / `wpk_text` / +`wpk_widget_*` surface without pulling in SDL2's ~10 MB of static +lib or its threading requirements. Plan 8 unblocks plans 10 + 11's +seed apps (wpk-shell, file manager, settings panel) which don't +want GL but do want a screen + a button + some text. **Sysroot-only +changes — no kernel code, no host code, no ABI impact.** + +**Architecture:** One new package under `examples/libs/wpkdraw/`, +packaged the same recipe shape plan 2's libgbm and plan 7's libdrm +use (`package.toml` + `build.toml` + `build.sh`). The library +builds in three phases — Phase A: core 2D primitives (rect, line, +blit) over a `wpk_surface` backed by a pair of plan-2 CPU-tier +gbm_bos with plan-4 KMS page-flip; Phase B: text rasterizer +(`stb_truetype.h` single-header vendored + a fixed-DPI glyph cache ++ one bundled DejaVu Sans regular font); Phase C: widget primitives +(button + label + panel-strip + popup-menu) + a `wpkdraw_demo` +program that exercises the whole stack. A browser-page smoke test +closes the loop. **No SDL2 dependency** — plan 8's apps either link +wpkdraw or SDL2, never both (the two surface ownership models — KMS +master via SDL2 vs. KMS master via wpkdraw — collide at the master- +set ioctl; v1 doesn't reconcile them). + +**Why a separate library, not bundled into wpkcompositor (plan 9) +or written per-app:** the design doc §9.3 already factored wpkdraw +out — the compositor + file-manager + panel all need the same 2D +primitives + text rasterizer + widget toolkit. Bundling into plan 9 +would couple the compositor's IPC + xdg-shell decisions to the 2D +surface API, making both harder to evolve. Splitting it out also +unblocks a non-compositor "fullscreen wpkdraw" mode for headless +benchmarking apps + the wpk-shell demo in plan 10 (which doesn't +need a compositor at all — it just takes KMS master and draws a +prompt). One static archive (~80 KB code + 512 KB font); apps that +don't need it don't link it. + +**Tech Stack:** +- Userland library: C99 with `wasm32posix-cc`; static archive + `sysroot/lib/libwpkdraw.a`; public headers under + `sysroot/include/wpkdraw/{wpkdraw.h, wpkfont.h, wpkwidget.h}`. +- Text rasterizer: `stb_truetype.h` (single-header, public domain; + the canonical embedded TTF rasterizer; vendored verbatim under + `examples/libs/wpkdraw/third_party/stb_truetype.h`). +- Bundled font: DejaVu Sans regular (`DejaVuSans.ttf`, ~512 KB, + Bitstream Vera derivative license — permissive; install to + `/usr/share/fonts/default.ttf` at app-install time, app reads + via `wpk_font_load_default(px)`). +- Demo: `programs/wpkdraw_demo.c` — fullscreen ARGB8888 surface + with a "Hello world" label + a button that animates a colour + cycle on click + ESC-to-quit through plan 5 evdev. ~200 LoC; the + smallest "real" wpkdraw app exercising plans 2 + 4 + 5. + +**Companion design doc:** `docs/plans/2026-05-18-dri-design.md` +§9.3 (the draw lib + text rasterizer); §9.4 (seed apps that link +wpkdraw); §10 (validation milestones — wpkdraw indirectly underpins +milestone E's file-manager). + +**Critical wasm32 ABI detail:** wpkdraw's public API surface +(`wpk_surface*`, `wpk_color`, `wpk_button`, `wpk_font*`) is +deliberately opaque — pointers + plain-old-data structs. No ABI +break risk if internals change. The library does NOT cross the +kernel-userland ABI; every byte goes through plan 2's gbm_bo +mmap + plan 4's KMS ioctls + plan 5's evdev `read()`. + +**Clock source:** wpkdraw's animation step uses +`clock_gettime(CLOCK_MONOTONIC, …)` via the existing musl shim — +same as plans 4/5/6/7. Cross-stream parity preserved. + +**Design reference:** `docs/plans/2026-05-18-dri-design.md` §9.3, +§9.4, §10. + +**Consistency with plans 2 + 3 + 4 + 5 + 6 + 7:** +- Adds NO new kernel exports, NO new host imports, NO new ioctls, + NO new device nodes. Every kernel surface wpkdraw touches + already exists from plans 2 + 4 + 5. +- Plan 2's `gbm_bo_create(format=ARGB8888, flags=GBM_BO_USE_SCANOUT + | GBM_BO_USE_LINEAR)` + `gbm_bo_map(GBM_BO_TRANSFER_WRITE)` + gives wpkdraw a CPU-mappable pixel buffer; the library writes + pixels directly into the mapped region. +- Plan 4's `DRM_IOCTL_MODE_GETRESOURCES` / `MODE_GETCONNECTOR` / + `MODE_GETCRTC` / `MODE_ADDFB2` / `MODE_SETCRTC` / + `MODE_PAGE_FLIP` + `DRM_IOCTL_WAIT_VBLANK` provide the present + pipeline. wpkdraw takes master on card0, queries the connector's + default mode (the v1 single output), sets the CRTC once, + page-flips between two ARGB8888 bos per `wpk_surface_present()`. +- Plan 5's `/dev/input/event0` + `event1` provide keyboard + pointer + events. wpkdraw's widget dispatch reads `struct input_event` + records and routes EV_KEY + EV_REL + EV_ABS to registered + widgets. ESC-to-quit is hard-coded. +- **Does NOT depend on plan 7 (SDL2)** — wpkdraw and SDL2 are + alternative front-ends, not stacked. A wpkdraw app and an SDL2 + app cannot both run at the same time on card0 (KMS master is + exclusive); v1 doesn't multiplex them. Plan 9's wpkcompositor + fixes this by becoming the only KMS-master holder and serving + both SDL2 and wpkdraw clients via its own protocol. +- **Does NOT use `gbm_surface_*`** — plan 2 ships `gbm_bo` + primitives but defers `gbm_surface_create` to a follow-up (plan + 2 line 1869). wpkdraw avoids `gbm_surface` entirely: it owns two + raw `gbm_bo`s as front + back, swaps the pointer on present, and + re-uses plan 4's `MODE_PAGE_FLIP` directly. No third bo, no + triple-buffering, no swap-chain abstraction. (Plan 9's + compositor will graduate to `gbm_surface_*` once plan 2's + follow-up lands.) + +**Stack base:** Plan 7's `…-sdl2-demo` branch tip. wpkdraw doesn't +extend any kernel code; the kernel is the same as it was at plan 7 +merge. + +**Branch:** +`emdash/explore-direct-rendering-infrastructure-wpkdraw-plan-XXXXX` +(chains off plan 7's tip per the branching rule). Two sub-branches +stack off it. + +**Final PR base:** Plan 7's `…-sdl2-demo` tip. Plan 8 does NOT +depend on plan 7's PR #2 (SDL2 vendor) — only on plan 7's PR #3's +base (plan 6 ALSA). But by stacking on plan 7's full tip we keep +the chain linear; if reviewers want to merge plan 8 ahead of plan 7 +PR #3, the rebase is straightforward (no overlapping files). + +**Two PRs, coordinated merge.** Each task below is one commit. +Brandon's `scope(area): action` titles: + +1. `sysroot(wpkdraw): scaffold + 2D primitives + text rasterizer` +2. `sysroot(wpkdraw): widgets + wpkdraw_demo + browser spec` + +PR base/head topology (stacked): + +``` +… (plans 2–7 tips) + └── …-sdl2-demo (plan 7 PR #3 tip) + └── …-wpkdraw-plan-XXXXX + └── …-wpkdraw-lib (PR #1) + └── …-wpkdraw-demo (PR #2) +``` + +**Verification gauntlet** (CLAUDE.md): all of the below must pass +with zero regressions before any PR is opened, and re-run before +final merge: + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +`XFAIL` / `TIME` are acceptable; `FAIL` that isn't pre-existing is a +regression. Phase C adds: (a) manual `./run.sh browser` verification +of the wpkdraw demo (CLAUDE.md item 6) — the demo renders a +button + label, click colour-cycles, ESC quits; (b) no profiling +gate (wpkdraw is a single-app fullscreen surface; PROCESS_TABLE +lock contention is dominated by plan 5 evdev rate which is already +gated by plan 7). + +**ABI impact:** **None.** Plan 8 adds no kernel exports, no host +imports, no new ioctls, no new device nodes, no new repr(C) structs +on the kernel-userland ABI. Every byte wpkdraw sends to the kernel +goes through ioctls + mmap + read/write already defined by plans +2/4/5. `ABI_VERSION` does not bump; `abi/snapshot.json` does not +change. + +The sysroot DOES grow: `sysroot/lib/libwpkdraw.a` (~80 KB code) + +`sysroot/share/fonts/default.ttf` (512 KB) + headers under +`sysroot/include/wpkdraw/`. The package-index ledger gets one new +entry. + +Existing kernel + host + ABI surfaces — all unchanged. + +--- + +## Pre-implementation review + +Devil's-advocate + consistency pass run 2026-05-19 (session 9), after +plan 8 drafted in session 8. Pass covers: focus areas from the +hand-off-8 sentinel (`gbm_bo_map(GBM_BO_TRANSFER_WRITE)` write- +coherence, `MODE_PAGE_FLIP` synchronous-wait vs plan 4's +one-in-flight throttle, `drmSetMaster` EBUSY clean-error when SDL2 +holds master, stb_truetype.h cross-compile under `wasm32posix-cc`, +FIFO-vs-LRU glyph cache, EV_REL vs EV_ABS pointer model in +`wpk_widget_pump_events`, static-link-only invariant + DejaVu Sans +license bundling, multi-tab evdev fanout caveat), plus a code-level +re-read of `wpk_surface_create` / `wpk_surface_present` / +`wpk_widget_pump_events` / `wpk_widget_button_draw`. Findings are +structured Brandon-style. Inline fixes (12) are **folded +conceptually** — plan body retains pre-review text per the Brandon +convention; implementation applies the fix per this section. Three +cross-plan amendments leak back into plans 2 + 4 + 5 reviews; the +open-correctness items are documented with lean resolutions and +do not block the plan from advancing to plan 9. + +### Inline fixes (12 — folded conceptually; plan body unchanged) + +1. **Pointer position never updates for browser users without + pointer-lock.** Plan 5 `host/src/input/browser-input-source.ts` + line 1655 (`onPointerMove` else branch) emits **EV_ABS** (ABS_X + + ABS_Y) for default-state (unlocked) pointer; EV_REL only fires + when `document.pointerLockElement` is set. Plan 8's + `wpk_widget_pump_events` (B1) handles `iev.type == EV_REL` + exclusively → on the default browser case, `ptr_x` / `ptr_y` stay + at 0 forever, the button never receives a click at its actual + coordinates, and the demo silently fails interactive + verification. Add an `EV_ABS` arm: + ```c + } else if (iev.type == EV_ABS) { + if (iev.code == ABS_X) ptr_x = iev.value; + if (iev.code == ABS_Y) ptr_y = iev.value; + /* emit a single WPK_EV_POINTER_MOTION when both X+Y arrive, + * or — pragmatic — emit on each component, the dispatcher + * is idempotent on duplicate (x,y) deltas. */ + } + ``` + Risk register #7 (pointer absolute vs relative) is correct in + spirit but its lean ("vitest with injectPointerMotionAbs covers + the absolute path") fires too late — the absolute path is the + *default* path, not a corner case. Bump the risk register + wording and add a Phase B1 cargo/vitest test that drives an + EV_ABS-only sequence through the button. Cross-plan follow-up + added to plan 5 (note the absolute-by-default semantics in plan + 5's "Trade-offs verified" so future-me consumers know). +2. **`drmModePageFlip(...PAGE_FLIP_EVENT, NULL) + drmWaitVBlank` is + the wrong vsync pattern.** Per plan 4 (lines 595-623): the + `PAGE_FLIP_EVENT` flag causes the kernel to post a + `DRM_EVENT_FLIP_COMPLETE` (type=2, 32 bytes) record onto the + master fd's per-OFD event ring on the next vblank tick. + `DRM_IOCTL_WAIT_VBLANK` separately wakes every blocker on the + broadcast `wait_vblank_queue` on every tick (plan 4 + open-architecture #2: two queues, `read_wait_queue` per-OFD + + `wait_vblank_queue` global). The pair coincidentally fires on + the same tick so the demo "works", but: + - Every `wpk_surface_present` deposits an unread 32-byte record + in the OFD's `event_ring`. Plan 4's ring caps at 64 records + and silently drops the oldest (cross-plan amendment from + plan 5's review, lines 478-504 of plan 4). At 60 Hz the ring + saturates in ~1 s and stays saturated for the demo's life. + Not a crash, but wasteful and confusing in `/proc/`-style + introspection later. + - The pattern doesn't match plan 4's own modeset demo (plan 4 + line 2103-2111), which uses `drmHandleEvent(card, &ctx)` with a + `page_flip_handler` callback. SDL2 2.30 KMSDRM also uses + `drmHandleEvent` (plan 7 line 211-213). Pick the canonical + pattern. **Lean:** drop the `drmWaitVBlank` call, keep the + `PAGE_FLIP_EVENT` flag, and replace with `drmHandleEvent(card, + &ctx)` where `ctx.page_flip_handler` is a no-op callback that + just signals "flip done". Drains the ring; matches plan 4 + + plan 7 idiom; one libdrm call instead of two. + - Alternative (smaller patch but loses the SDL2 idiom symmetry): + drop `PAGE_FLIP_EVENT` (pass `0` as flags) and keep + `drmWaitVBlank`; no record produced, ring stays empty. + Cross-plan follow-up added to plan 4 documenting the chosen + pattern. Fold the resolution into A3's `wpk_surface_present` + body at impl time. +3. **`gbm_bo_map` signature mismatch across plans 2 + 4 + 8.** + Plan 8 A3 (line 580-582) calls: + ```c + *out_pixels = gbm_bo_map(*out_bo, 0, 0, s->width, s->height, + GBM_BO_TRANSFER_WRITE, &map_data, NULL); + ``` + — 8 args with `&map_data` (a `void **`) where the upstream Mesa + `gbm_bo_map` puts the `uint32_t *stride` out-param. That's a + pointer-shape mismatch (writes `uint32_t` through a `void **`, + undefined behaviour in C). Plan 2 C3 (line 1760) and plan 4 C2 + demo (line 2099) both call it with 9 args `(bo, 0, 0, w, h, 0, + NULL, NULL, NULL)` — implying plan 2's stub uses a non-standard + signature. Either way plan 8's call doesn't match either. **Fix:** + pick the upstream Mesa shape — `(bo, x, y, w, h, flags, uint32_t + *stride, void **map_data)` — and re-issue plan 2's stub + + plan 4's modeset demo + plan 8's surface allocator against it. + Stride is also retrievable via `gbm_bo_get_stride(bo)`, so + plan 8 can just drop the stride out-param and call + `gbm_bo_map(bo, 0, 0, w, h, GBM_BO_TRANSFER_WRITE, NULL, + &map_data)`. Cross-plan amendment added to plan 2's review. +4. **Old back-buffer mapping leaks every `wpk_surface_present`.** + Plan 8 A3 line 666-672: each present `gbm_bo_map`s the now-back + bo to refresh `s->back_pixels` but never `gbm_bo_unmap`s the + previously-mapped slot. Under plan 2's `mmap_shared` aliasing + model the host-side mmap_anonymous slot accumulates: 5 s × 60 + fps = 300 slots, ~1280×720×4 ≈ 3.7 MB each = ~1.1 GB of stale + mappings (the SAB pages are still shared, only the wasm Memory + mapping table grows). Long-running app crashes or stalls when + mmap_anonymous runs out of pages. **Fix:** cache the two + mappings once at `wpk_surface_create` time (one per bo), swap + the `back_pixels` pointer between them on present. No re-map + per frame. +5. **Release-outside-button drops the click AND leaves + `_pressed = 1` stuck.** Plan 8 B1 line 1242-1250 dispatcher: + ```c + for (int i = 0; i < n_buttons; i++) { + if (!point_in(&buttons[i], ptr_x, ptr_y)) continue; + if (press) buttons[i]._pressed = 1; + else if (buttons[i]._pressed && buttons[i].on_click) { + buttons[i]._pressed = 0; + buttons[i].on_click(...); + } + } + ``` + On release outside the button, `point_in` is false → the + `continue` skips the button entirely → `_pressed` stays at 1 → + button renders as "pressed" forever, AND next press anywhere + sets it back to 1, masking the bug. Standard widget behaviour: + on release, *every* button clears its `_pressed`, but only the + one(s) the cursor is currently inside fire `on_click`. Restructure: + ```c + if (press) { + for (int i = 0; i < n_buttons; i++) + if (point_in(&buttons[i], ptr_x, ptr_y)) + buttons[i]._pressed = 1; + } else { + for (int i = 0; i < n_buttons; i++) { + int fire = buttons[i]._pressed && + point_in(&buttons[i], ptr_x, ptr_y); + buttons[i]._pressed = 0; + if (fire && buttons[i].on_click) + buttons[i].on_click(&buttons[i], buttons[i].user); + } + } + ``` +6. **`err_gbm:` label leaks the gbm_device when bo allocation + fails.** Plan 8 A3 line 631 says "`gbm device intentionally + not destroyed — held by bo lifetime`" — true on success path + where `bo_front` holds it, but on the goto-from-failure path + `bo_front` was never allocated, so the gbm device has no owner. + Memory leak per failed create. **Fix:** make `err_gbm` actually + `gbm_device_destroy(gbm)`. The success path's comment becomes + accurate (bo's lifetime extends the device). +7. **`wpk_widget_button_draw` baseline math is hard-coded for + ~24 px ascent.** Plan 8 B1 line 1194: `int ty = b->y + b->h / 2 + + 8;` — the `+ 8` is approximately a 24 px font's half-ascent. + At 12 px or 48 px the label drifts. Expose a small `wpk_font` + accessor (`int wpk_font_ascent_px(wpk_font *f)` returning + `(int)(f->ascent * f->scale + 0.5f)`) and replace the literal + with `int ty = b->y + (b->h + wpk_font_ascent_px(f)) / 2;`. + Trivial; one new public API entry. +8. **`drmDropMaster` on the partial-init goto-`err_master:` path + is correct *if* `drmSetMaster` succeeded.** Re-reading the + error chain — A3 line 591: `if (drmSetMaster(...) < 0) goto + err_close;` (which skips `err_master`). All other failures + route through `err_master` after master is held, so the drop + is right. No-op observation: leave a sentinel comment at + `err_master:` reminding future-me that this label is only + reachable when master is currently held. +9. **`wpk_widget_pump_events` uses process-global `static int + ptr_x, ptr_y`.** v1 is single-surface so safe; plan 9's + compositor will need per-client state. Document the v1 + limitation in `<wpkdraw/wpkwidget.h>` near the pump declaration + and flag the lift in plan 9: replace the static with a + caller-owned `struct wpk_event_state *` parameter. +10. **`-fPIC` in the build script is dead weight for a static + archive.** Plan 8 A3 build wires `wasm32posix-cc -c -O2 -fPIC`. + `libwpkdraw.a` is a static archive linked into executables that + are themselves wasm modules — wasm32 has no shared-lib loader, + PIC adds 2-4% code size for nothing. Drop `-fPIC` across all + `wpkdraw_*.c` + `stb_impl.c` + `wpkfont.c` + `wpkwidget.c` + compile lines. Matches plan 2/7's static-archive convention. +11. **`[deps]` in `package.toml` doesn't list libgbm.** Plan 8 + line 257: `[deps] libdrm = "2.4.120"` (plan 7's package), with + a comment "no gbm package dep — wpkdraw uses gbm_bo* via plan + 2's libgbm-stub which is already in the sysroot". Plan 2 C3 + (line 1730) builds libgbm to `sysroot/lib/libgbm.a` *as part + of plan 2's PR #3*, but it's a sysroot artifact (no + `examples/libs/libgbm/package.toml` listed in plan 2). If a + fresh CI box starts at plan 7's tip + plan 8 branch, plan 2's + PR #3 has merged and libgbm is in the sysroot — fine. But if + plan 2 follow-ups (e.g., gbm_surface) refactor libgbm into a + proper package, this dep needs to be added. **Action:** at + plan 8 impl time, audit `examples/libs/libgbm/` — if a + package.toml exists, add the dep; if not, document in plan + 8 A1 that wpkdraw's build assumes plan 2's sysroot is + materialised first (which the chain ordering enforces, so + the assumption is safe today). +12. **UTF-8 decode advances 1 byte for any 4-byte sequence start.** + Plan 8 A5 `decode_utf8` (line 974): the `else` arm + `{ c = '?'; *pp += 1; }` catches both malformed bytes AND + legitimate 4-byte UTF-8 starts (`(byte & 0xf8) == 0xf0`, + e.g., emoji, mathematical alphanumerics). One logical + codepoint becomes 4 '?' glyphs. Acceptable for v1 (locked-in: + Latin/Greek/Cyrillic only) but document the limit explicitly + in `<wpkdraw/wpkfont.h>`: "v1 supports BMP only (codepoints + ≤ 0xFFFF); 4-byte UTF-8 starts emit `?` per byte." Plan 11's + seed apps are English-only so this is invisible at v1. + +### Correctness — open (lean documented; address at impl time) + +- **`gbm_bo_map(GBM_BO_TRANSFER_WRITE)` write-coherence under plan + 2's CPU-shared tier.** Plan 2 B2 (line 1521) confirms "`gbm_bo_map` + cache-flush no-ops (`_flags`) follow Linux semantics" — the flags + parameter is decorative. Plan 2 §B2's `MemoryManager::mmap_shared` + aliases the bo's SAB slice directly to the user's wasm Memory + region; writes through the pointer ARE the bo's bytes, visible + to the host's KMS presenter on next `kernel_vblank` without any + explicit flush. Plan 8's risk register #1 mitigation ("may + require a `wpk_surface_flush()` call") is defensive but + unnecessary if plan 2 lands as drafted. **Lean: omit flush in + v1.** If Phase A3 smoke test shows garbage on canvas, that's a + plan 2 B2 regression (the `mmap_shared` spike outcome went the + other way), not a plan 8 bug — escalate plan 2 first. +- **`MODE_PAGE_FLIP` one-in-flight throttle vs `wpk_surface_present` + blocking model.** Plan 4 (line 430): "One in-flight PAGE_FLIP + per (CRTC, OFD)." Plan 8's `wpk_surface_present` is synchronous — + it blocks on `drmHandleEvent` (after inline fix #2) before + returning, so the next present's PAGE_FLIP is always issued + after the previous one's FLIP_COMPLETE has been consumed. The + throttle is never tripped under normal flow. Risk: if the host's + vblank tick is delayed past 1/60 s (e.g., browser tab + backgrounded), `drmHandleEvent` blocks indefinitely; the demo + appears frozen. **Lean: acceptable.** Plan 4's PAGE_FLIP throttle + semantics force this behaviour everywhere; a paused-tab demo + hanging matches a paused-tab WebGL demo hanging. Document the + expected behaviour in `<wpkdraw/wpkdraw.h>` near `wpk_surface_present`. +- **`drmSetMaster` clean-error path when SDL2 (plan 7) holds + master.** Plan 8 A3 line 591 routes EBUSY through `goto err_close` + → fd closed → master not held → next op fails cleanly. Verified + against plan 4 line 1131-1135 (`try_set_master` returns + `Err(Errno::EBUSY)` if another OFD holds master). The + `wpk_surface_create` returns NULL with errno=EBUSY; caller's + `perror("wpk_surface_create")` prints + `"wpk_surface_create: Device or resource busy"`. Demo exits 1 + cleanly. **Lean: matches drafted plan; risk register #3 covers + it.** Add a vitest spec under B3 that spawns a stub master- + holder first, then asserts wpkdraw_demo exits 1 with EBUSY on + stderr. +- **Pointer-pump fanout under plan 5's seat-shared model.** Plan + 5 (line 1428 + handoff-5 follow-up): every open OFD on event0 + sees every keystroke; every event1 OFD sees every pointer event. + A second wpkdraw process opening event0 receives every ESC the + first process types, including app-private quit chords. v1 is + strictly single-app-fullscreen (the KMS-master invariant blocks + N concurrent surfaces); the hazard is dormant until plan 9's + compositor adds EVIOCGRAB-based focus routing. **Lean: document + the v1 limitation in `<wpkdraw/wpkwidget.h>` next to the pump + declaration:** "v1: keyboard + pointer events are visible to + every process with the device open. Single-app-fullscreen is + the only safe configuration. Plan 9's compositor adds focus + routing." No code change; doc-only. +- **`stb_truetype.h` cross-compile under `wasm32posix-cc`.** stb + headers reference `floor`, `sqrt`, `pow`, `fabs` — all in our + musl libc per `musl-overlay/`. The implementation TU + `#define STB_TRUETYPE_IMPLEMENTATION` includes the header once; + no static-init globals beyond the `stbtt__buf` zero-init. + **Lean: A4's smoke test (call `stbtt_InitFont` + render one + glyph) is sufficient.** Add `-lm` to the *executable* link + line in `wpkdraw_demo` (the static archive doesn't link `-lm`; + the demo's `wasm32posix-cc -o wpkdraw_demo.wasm ... -lm` does). + Plan 8 line 1369 already has `-lm` — verified. +- **DejaVu Sans license bundling at sysroot share/fonts.** Plan 8 + A5 installs both `default.ttf` and `default.LICENSE`. **Lean:** + the license file goes under the same dir as the font; users + who `find /usr/share/fonts -name '*.LICENSE'` discover it. + Acceptable per Bitstream Vera + DejaVu permissive license. + No further bundling needed. +- **Static-link-only invariant.** Plan 8 line 226 trade-off + confirms — `libwpkdraw.a` only, no `.so`. The build script + only produces `.a`; package recipe doesn't include a + `--enable-shared` flag (there's no `configure` step at all — + hand-rolled compile + `llvm-ar rcs`). **Lean: no recipe risk.** + Add a cargo test under A6 that asserts + `sysroot/lib/libwpkdraw.so` does NOT exist post-build. +- **`wpk_widget_pump_events` partial-record reads.** `read(fd, + &iev, sizeof iev)` against plan 5's per-OFD ring: plan 5 line + 1334-1390 enforces "read returns a multiple of 24 bytes (one + full `input_event`); partial-record returns are forbidden; + read with too-small buffer returns EINVAL." Plan 8's pump + passes exactly `sizeof(struct input_event)` (24 bytes) per read + → plan 5 returns either 24 bytes (one record) or 0/EAGAIN. + Plan 8 only proceeds on `== (ssize_t)sizeof iev`, dropping + -1/EAGAIN cleanly. **Lean: matches plan 5's contract.** No + change. + +### Architecture — open (NO LOAD-BEARING items) + +Plan 8 has zero open architecture items. Every cross-plan dependency +(gbm_bo from plan 2; KMS/PAGE_FLIP from plan 4; evdev from plan 5) +is on already-drafted-and-reviewed surfaces with locked-in +semantics. Specifically: + +- Plan 7's open-architecture #1 (SDL2 audio thread model) **does + not apply** — wpkdraw has no audio path. +- Plan 7's open-architecture #2 (GL stack ownership — + `libEGL.a` + `libGLESv2.a` + `gbm_surface_*`) **does not apply** + — wpkdraw is CPU-tier-only, no EGL, no GLES, no swap-chain. + This is the entire reason plan 8 exists per design §9.3 (the + "GL-less alternative to SDL2_Renderer"). +- The "wpkdraw and SDL2 don't coexist on KMS master" constraint + is a *locked-in trade-off*, not an open architecture question. + Plan 9 will multiplex; v1 partitions cleanly. + +### Missing tests — add at impl time + +- **`wpk_widget_pump_events` EV_ABS path drives pointer.** Vitest + under B3 with `injectPointerMotionAbs(handle, x, y)` (which uses + plan 5's EV_ABS emission); assert the click test passes through + the absolute path. Without this, inline fix #1 has no regression + guard. +- **Release-outside-button does NOT fire `on_click`.** Vitest + pseudo: press inside button, drag pointer outside, release; assert + `on_click` was never called AND the button visibly de-presses + on the next render. Regression guard for inline fix #5. +- **`wpk_surface_create` failure when SDL2 holds master.** B3 + spawns a small wasm program that takes master on card0 and + sleeps; spawn wpkdraw_demo; assert demo exits 1 with errno=EBUSY + on stderr. +- **Partial-init cleanup releases master.** Cargo test forces + `gbm_bo_create` ENOMEM (via host stub) and asserts the kernel's + master-holder slot is empty after the failed `wpk_surface_create`. + Regression guard for inline fix #6 + the goto-chain. +- **Mapped slots don't accumulate across 300 presents.** B3 test + asserts the count of `mmap_anonymous` calls stays at 2 (one per + bo) after 300 `wpk_surface_present` invocations. Regression guard + for inline fix #4. +- **Glyph cache FIFO eviction is observable.** Render 300 distinct + codepoints through `wpk_text`; assert cache stays at exactly 256 + entries and the *oldest 44 evicted* codepoints' bitmaps have been + freed (no `bitmap` pointer to leaked memory). +- **`wpk_font_load_default(px_size)` clamps invalid sizes.** + `px_size = 3` and `px_size = 257` both return NULL with + errno=EINVAL; `px_size = 4` and `px_size = 256` succeed. +- **`wpk_text_width` matches `wpk_text` rendered width.** Smoke + test: render "Hello world" at px=24; compare `wpk_text_width(f, + "Hello world")` against the rightmost non-zero pixel column of + the rendered bitmap; assert within 2 px (sub-pixel rounding + slack). +- **`sysroot/lib/libwpkdraw.so` does NOT exist post-build.** + Static-link-only invariant guard. Cargo test asserts only `.a` + shipped. +- **`wpkdraw_demo.wasm` Vitest at 24 fps (slower vblank).** Drive + `kernel_vblank` from the test harness at 24 Hz instead of 60 Hz; + assert the demo still exits cleanly and `kmsCounts.page_flip` + scales linearly. Catches frame-rate-coupling bugs in `wpk_surface_present`. +- **Wheel events ignored cleanly.** Plan 5 emits `EV_REL { REL_WHEEL, + ±1 }` on scroll; plan 8's pump should not synthesise spurious + `WPK_EV_POINTER_MOTION` for wheel ticks. Vitest: inject a wheel, + assert `nev` after pump excludes any pointer-motion record. + +### Trade-offs verified + +- **Static-link-only, no shared lib.** `libwpkdraw.a` only; v1 + libc has no `dlopen`. Plan 8 line 1548 + design §9.3. ✓ +- **Two gbm_bos direct, no `gbm_surface_*`.** Plan 2 defers + `gbm_surface_create` (plan 2 line 1869); wpkdraw owns front + back + bos directly + page-flips between them. Simpler than a swap-chain; + plan 9 graduates to gbm_surface once it lands. ✓ +- **wpkdraw and SDL2 are alternative front-ends, not stacked.** + KMS master is exclusive (plan 4 line 430). Plan 9 multiplexes + via a compositor that holds master + serves both client types. + ✓ +- **FIFO glyph cache, fixed 256-entry cap.** Linear-probe lookup + is O(N) per glyph; cap of 256 keeps the constant small. LRU is + a profiling-driven refinement (risk register #6); for v1's + English UI labels (~80 distinct codepoints typical) the cap is + ample. ✓ +- **No anti-aliasing on lines.** Bresenham; AA is post-v1. ✓ +- **Text rasterizer uses 8-bit alpha mask** (stb_truetype's + default) — sub-pixel position rounded to integer pixel. Matches + Mesa's swrast text + most embedded GUI toolkits. ✓ +- **CLOCK_MONOTONIC pinned via musl shim.** Cross-stream parity + with plans 4/5/6/7. ✓ +- **One bundled font (DejaVu Sans regular).** Bold/italic post-v1. ✓ +- **UTF-8 decode permissive.** Malformed → '?', no errors raised. + BMP-only (inline fix #12). ✓ +- **No multi-window.** Plan 9 introduces windowing. ✓ +- **No mouse cursor rendered by wpkdraw.** Apps draw their own. + Plan 9's compositor will manage a system cursor. ✓ +- **No event-routing IPC.** wpkdraw is in-process; plan 9 adds + the unix-socket protocol. ✓ +- **Zero ABI impact.** No kernel exports, no host imports, no + ioctls, no device nodes. Sysroot-only addition. ✓ +- **stb_truetype.h public-domain vendoring.** Standard practice; + cross-compiles under wasm32posix-cc (math symbols all present + in musl). ✓ +- **DejaVu Sans permissive license.** Bitstream Vera + DejaVu + modifications; bundle `default.LICENSE` alongside `default.ttf`. ✓ +- **Two-PR stacked merge.** PR #1 (lib + primitives + text) → + PR #2 (widgets + demo). Plan 7's `…-sdl2-demo` tip as base. ✓ + +### Deliberately not flagged + +- **`wpk_surface` is not thread-safe.** v1 is single-threaded + (no pthreads); n/a until plan 9 / SDL2 audio thread (plan 7 + open-arch #1). ✓ +- **`wpk_blit` forward-iteration may corrupt under self-aliasing + with downward `dst < src` row order.** Single-surface aliasing + is an unusual pattern (no real app blits a surface onto + itself); the comment "memmove-safe" overstates safety. Document + the constraint or leave for plan 9's compositor to fix when it + introduces buffer-to-buffer blits between client surfaces. ✓ +- **Bresenham line includes both endpoints.** Matches X11/fbcon + convention. ✓ +- **Out-of-bounds `wpk_pixel` silently discarded.** Matches + Cairo/SDL clip-not-abort idiom. ✓ +- **`wpk_clear` is not vectorised.** ~1280×720×4 = 3.7 MB per + clear × 60 fps = 220 MB/s. Marginal; SIMD is post-v1. ✓ +- **No animation framework.** v1's demo loop is the app's + responsibility; no tween library. Plan 11's seed apps build + their own per-app. ✓ +- **`wpkdraw_demo.c` hard-coded 5 s runtime.** Matches plan 7's + demo cadence for vitest parity. ✓ +- **Wheel event handling absent.** Plan 8 pump drops `EV_REL` + with codes other than REL_X/Y, which catches wheel ticks too. + Documented; demo doesn't scroll. ✓ +- **Per-OFD `event_ring` accumulation under inline fix #2 + resolution.** If wpkdraw chooses the alternative (drop + PAGE_FLIP_EVENT + keep WAIT_VBLANK), no records are produced + and the ring stays empty. If it chooses the canonical drmHandle + + PAGE_FLIP_EVENT, drmHandleEvent drains. Either way the ring + stays bounded. ✓ +- **`gbm_bo_get_handle(*bo).u32` may return 0 for failed + allocator state.** Plan 2's stub allocates a `drm_mode_create_dumb` + before returning the bo; the handle is non-zero by construction. + ✓ + +### Cross-plan amendments (added to plans 2, 4, 5 reviews) + +- **Plan 2 follow-up.** `gbm_bo_map` signature shape disagrees + across plans 2/4/8 (see inline fix #3). Plan 2's libgbm stub + C3 (line 1730+) is the source of truth and currently uses a + 9-arg variant `(bo, x, y, w, h, flags, ptr1, ptr2, ptr3)` where + the last three are unused outputs. Plan 4's modeset demo + (line 2099) inherits the 9-arg shape. Plan 8 attempts an 8-arg + call with a pointer-shape mismatch. **Lean: align on upstream + Mesa's 8-arg shape `(bo, x, y, w, h, flags, uint32_t *stride, + void **map_data)` and re-issue both plan 2's stub + plan 4's + modeset demo against it.** Plan 8 then calls + `gbm_bo_map(bo, 0, 0, w, h, 0, NULL, &map_data)` (stride via + `gbm_bo_get_stride(bo)`). Note added to plan 2's Pre-impl + review and plan 4's Pre-impl review under a new "Cross-plan + amendment from plan 8's devil's-advocate" subsection. +- **Plan 4 follow-up.** Plan 8's `wpk_surface_present` (after + inline fix #2 resolution) uses `drmModePageFlip + PAGE_FLIP_EVENT + + drmHandleEvent` — same pattern as plan 4's own modeset demo + (line 2103-2111). Confirms plan 4's event ring + handler + interface is the canonical idiom. Note added to plan 4's + "Deliberately not flagged" subsection: "wpkdraw (plan 8) + + modeset demo (plan 4 C2) + SDL2 KMSDRM (plan 7) all use + drmHandleEvent for FLIP_COMPLETE drain; WAIT_VBLANK is for + free-running vblank polling without page flips." +- **Plan 5 follow-up.** Plan 5's `BrowserInputSource` (line 1655) + emits **EV_ABS** for default-state pointer (unlocked) and + EV_REL only when pointer-lock is active. Plan 8's pump (and + any future hand-rolled evdev consumer) must handle BOTH; the + default browser case is EV_ABS, not the corner case. Note + added to plan 5's "Trade-offs verified" subsection: "Pointer + emits EV_ABS by default; consumers expecting cursor-on-default- + browser must handle the absolute path. SDL2's libinput shim + handles both; hand-rolled consumers (wpkdraw plan 8) need an + explicit EV_ABS arm. Documented as a cross-plan amendment from + plan 8's review." + +### Cross-plan amendment from plan 9's devil's-advocate — fix #4 extension to compositor-client mode + +Plan 9's devil's-advocate pass (session 10) caught that plan 9 +E1's `wpk_surface_present_via_compositor` (plan 9 lines 1396-1415) +re-introduces the per-present `gbm_bo_map` leak that THIS plan's +inline fix #4 closed. Plan 8 fix #4 cached both bo mappings at +`wpk_surface_create` time and swapped a single `back_pixels` +pointer between them on each present — no remap. Plan 9 E1's +amendment shape calls `gbm_bo_map(s->bo_back, ...)` on EVERY +`wpk_surface_present_via_compositor` invocation without +`gbm_bo_unmap` of the previous mapping. + +**The fix-#4 invariant must extend to BOTH direct-KMS and +compositor-client modes.** Plan 8's body retains the +pre-review text (per Brandon convention); plan 9 E1's body also +retains pre-review text. At impl time, plan 9 E1 amends +`wpk_surface_create_via_compositor` to cache the symmetric +`bo_front` mapping at create time (parallel to plan 8 fix #4's +direct-KMS pattern), stores `front_pixels` + `back_pixels` + +their `map_data` slots on `struct wpk_surface`, and +`wpk_surface_present_via_compositor` swaps pointer references +on present WITHOUT re-mapping. + +*Resolution for this plan:* no change to plan 8's body or +review — fix #4 is documented here and applies to ALL `wpk_surface_present_*` +variants, present and future. Plan 9 E1's "Inline fix #1" +(in plan 9's own review) carries the explicit fold-where note +matching this cross-plan amendment. The shared `wpk_surface` +struct gains two cached mapping slots (one per bo) at impl +time; both modes use them. + +### Cross-plan amendment from plan 11's devil's-advocate — export wpk_font_height_px + wpk_font_ascent_px (added during session 12) + +Plan 11's `libwpkdraw_widgets` primitives (`wpkw_button`, +`wpkw_row`, `wpkw_status` in widgets.c lines 449, 451, 458, +462, 470) need to compute label positions from font metrics +(centre-align text in a button; baseline-place row labels; +size status-bar strip). Plan 8's finalized §A2 public API +(lines 873–900) exports `wpk_font_load_default`, +`wpk_font_destroy`, `wpk_text`, `wpk_text_width` — but not +the per-glyph height / ascent accessors. Plan 8's own +devil's-advocate pass proposed adding them (lines 335–337) +under the "useful trivia, not LOAD-BEARING for plan 8 itself" +heading, but the finalized §A2 API didn't pick them up. + +Amendment: extend §A2 to export the two accessors. Public +header: + +```c +int wpk_font_height_px(struct wpk_font *f); /* ascent + descent */ +int wpk_font_ascent_px(struct wpk_font *f); /* baseline above origin */ +``` + +One-line bodies over plan 8's internal stb_truetype metrics +struct (font.c already computes both internally for +`wpk_text` layout). Land as part of plan 8's libwpkdraw PR — +public header addition + two one-line body additions + +cargo-test asserting non-zero return on a 12-px font. + +LOAD-BEARING for plan 11 PR #1 — without these, +`libwpkdraw_widgets` cannot compile. Promotes plan 8's own +"useful trivia" addition from optional to required by plan +11's downstream consumer. + +--- + +--- + +## Phase A — sysroot: wpkdraw core (PR #1) + +Two new tasks under `examples/libs/wpkdraw/` — primitives + text. + +### Task A1: Package scaffold + +**Files:** +- Create: `examples/libs/wpkdraw/package.toml` — recipe. +- Create: `examples/libs/wpkdraw/build.toml` — build state. +- Create: `examples/libs/wpkdraw/build.sh` — build script (stub). + +**Step 1: Package recipe** + +```toml +# examples/libs/wpkdraw/package.toml +name = "wpkdraw" +version = "0.1.0" +license = "MIT" +description = "Thin 2D rendering library for non-SDL2 apps; sits on plan 4 KMS + plan 2 gbm_bo" + +[source] +type = "local" # wpkdraw is in-tree; no upstream + +[deps] +libdrm = "2.4.120" # plan 7 — for the KMS API +# Note: no gbm package dep — wpkdraw uses gbm_bo* via plan 2's +# libgbm-stub which is already in the sysroot. If plan 2's stub +# ships as a separate package, add it here. + +[build] +script_path = "build.sh" +``` + +**Step 2: Build state** + +```toml +# examples/libs/wpkdraw/build.toml +script_path = "build.sh" +# No repo_url / commit — this is in-tree source. +revision = 1 + +[binary] +index_url = "https://github.com/<repo>/releases/download/binaries-abi-v{abi}/index.toml" +``` + +**Step 3: Build script (filled in across A2–A6)** + +```bash +#!/usr/bin/env bash +# examples/libs/wpkdraw/build.sh +set -euo pipefail +. "$WPK_WORKTREE/sdk/activate.sh" + +SRC_DIR="$1" # path to examples/libs/wpkdraw/src +OUT_DIR="$2" # output dir for libwpkdraw.a + headers +WORK="$OUT_DIR/build" +mkdir -p "$WORK" + +# A2–A4 fill in the actual sources + compile + archive steps. +echo "TODO A2: compile wpkdraw primitives + text + widget TUs" +exit 1 +``` + +**Step 4: Cargo test** + +```bash +cargo xtask build-deps resolve wpkdraw +``` + +Expected: package.toml parsed; build attempted; fails with "TODO +A2". OK — A2 wires the real build. + +**Step 5: Commit** + +```bash +git add examples/libs/wpkdraw/{package.toml,build.toml,build.sh} +git commit -m "sysroot(wpkdraw): scaffold package recipe + build state" +``` + +--- + +### Task A2: Public headers + +**Files:** +- Create: `examples/libs/wpkdraw/include/wpkdraw/wpkdraw.h`. +- Create: `examples/libs/wpkdraw/include/wpkdraw/wpkfont.h`. +- Create: `examples/libs/wpkdraw/include/wpkdraw/wpkwidget.h`. + +The public API is intentionally small. Each header is one logical +unit. + +```c +// include/wpkdraw/wpkdraw.h +#ifndef WPKDRAW_H +#define WPKDRAW_H + +#include <stdint.h> +#include <stddef.h> + +/** Opaque surface handle. Owns a front+back gbm_bo pair, the + * card0 fd, and the picked CRTC + connector + mode. */ +struct wpk_surface; + +/** ARGB8888 packed colour. The most-significant byte is alpha + * (0xff = opaque, 0x00 = fully transparent); next byte is red, + * then green, then blue. */ +typedef uint32_t wpk_color; + +#define WPK_RGB(r, g, b) (0xff000000u | ((uint32_t)(r) << 16) \ + | ((uint32_t)(g) << 8) | (uint32_t)(b)) +#define WPK_RGBA(r, g, b, a) (((uint32_t)(a) << 24) \ + | ((uint32_t)(r) << 16) \ + | ((uint32_t)(g) << 8) | (uint32_t)(b)) + +/** Acquire master on /dev/dri/card0, allocate two ARGB8888 + * gbm_bos at the connector's default mode, SETCRTC the front + * one. On success returns a non-NULL surface and writes the + * effective width + height to *out_w + *out_h. + * + * Returns NULL on failure; check errno for the underlying cause + * (EBUSY = another app holds master; ENOENT = no connector; + * ENOMEM = bo allocation failed). + * + * v1 limitation: a single wpk_surface per process; opening a + * second returns EBUSY. */ +struct wpk_surface *wpk_surface_create(int *out_w, int *out_h); + +/** Release master + free bos + close card0 fd. Safe to call on + * NULL. */ +void wpk_surface_destroy(struct wpk_surface *s); + +/** Page-flip front <-> back; block on WAIT_VBLANK until the + * flip completes. Returns 0 on success, -1 on KMS error. */ +int wpk_surface_present(struct wpk_surface *s); + +/** Direct access to the back-buffer pixel data (ARGB8888 packed). + * Caller can write directly; modifications are visible on the + * next wpk_surface_present(). Returns NULL if surface invalid. + * + * stride is the per-row byte advance — usually width * 4 but + * the gbm allocator may pad. */ +uint32_t *wpk_surface_back_pixels(struct wpk_surface *s, + int *out_stride); +int wpk_surface_width(struct wpk_surface *s); +int wpk_surface_height(struct wpk_surface *s); + +/* ---- 2D primitives ---- */ + +/** Fill the entire back buffer with a single colour. */ +void wpk_clear(struct wpk_surface *s, wpk_color color); + +/** Plot a single pixel; alpha-blended against existing content if + * `color`'s alpha < 0xff. Out-of-bounds writes are silently + * discarded (no abort, no error code). */ +void wpk_pixel(struct wpk_surface *s, int x, int y, wpk_color color); + +/** Filled rectangle (x, y, w, h) — half-open coordinates, so the + * rect occupies columns [x, x+w) and rows [y, y+h). Alpha-blended. + * Negative w / h is a no-op. */ +void wpk_rect(struct wpk_surface *s, int x, int y, int w, int h, + wpk_color color); + +/** Bresenham line from (x0, y0) to (x1, y1) inclusive, 1px wide, + * alpha-blended. No anti-aliasing in v1. */ +void wpk_line(struct wpk_surface *s, int x0, int y0, int x1, int y1, + wpk_color color); + +/** Blit src's back pixels onto dst's back buffer at (x, y). Uses + * src-alpha-over-dst compositing. src and dst may be the same + * surface (memmove-safe). */ +void wpk_blit(struct wpk_surface *dst, int x, int y, + struct wpk_surface *src); + +#endif /* WPKDRAW_H */ +``` + +```c +// include/wpkdraw/wpkfont.h +#ifndef WPKDRAW_FONT_H +#define WPKDRAW_FONT_H + +#include "wpkdraw.h" + +/** Opaque font handle. Owns a stb_truetype font + a glyph cache + * at a fixed pixel size. */ +struct wpk_font; + +/** Load the bundled DejaVu Sans regular at `px_size` pixels and + * cache the glyph atlas. Returns NULL on failure (errno set: + * ENOENT = /usr/share/fonts/default.ttf missing, ENOMEM = atlas + * allocation failed). */ +struct wpk_font *wpk_font_load_default(int px_size); + +void wpk_font_destroy(struct wpk_font *f); + +/** Compute the width (in pixels) of a UTF-8 string in this font. + * Used for layout before calling wpk_text. */ +int wpk_text_width(struct wpk_font *f, const char *utf8); + +/** Render a UTF-8 string at (x, y) — y is the baseline, NOT the + * top — into the back buffer. Alpha-blended. */ +void wpk_text(struct wpk_surface *s, struct wpk_font *f, + int x, int y, const char *utf8, wpk_color color); + +#endif /* WPKDRAW_FONT_H */ +``` + +```c +// include/wpkdraw/wpkwidget.h +#ifndef WPKDRAW_WIDGET_H +#define WPKDRAW_WIDGET_H + +#include "wpkdraw.h" +#include "wpkfont.h" + +struct wpk_event { + enum { WPK_EV_KEY, WPK_EV_POINTER_MOTION, WPK_EV_POINTER_BUTTON, + WPK_EV_QUIT } type; + union { + struct { int keycode; int pressed; } key; + struct { int x; int y; } pointer_motion; + struct { int x; int y; int button; int pressed; } pointer_button; + }; +}; + +/** A clickable rectangular button. The label is rendered with the + * supplied font; `on_click` fires when a WPK_EV_POINTER_BUTTON + * with `pressed = 1, button = 0` arrives inside the rectangle. + * `user` is passed back unchanged. */ +struct wpk_button { + int x, y, w, h; + const char *label; + void (*on_click)(struct wpk_button *self, void *user); + void *user; + /* Internal — set by wpkdraw, don't touch. */ + int _hover, _pressed; +}; + +/** Draw a button into the back buffer. */ +void wpk_widget_button_draw(struct wpk_surface *s, struct wpk_font *f, + struct wpk_button *b); + +/** Pump events from `/dev/input/event[0,1]` into the widget + * dispatcher. Routes pointer events to overlapping buttons, + * keyboard events to the global ESC handler (returns + * WPK_EV_QUIT if ESC is pressed). + * + * Reads up to `max_events` records per call; non-blocking. + * Returns the number of synthesised wpk_events written into + * `out_ev`. */ +int wpk_widget_pump_events(int evdev_kbd_fd, int evdev_ptr_fd, + struct wpk_button *buttons, int n_buttons, + struct wpk_event *out_ev, int max_events); + +#endif /* WPKDRAW_WIDGET_H */ +``` + +**Step 4: Commit** + +```bash +git add examples/libs/wpkdraw/include/wpkdraw/ +git commit -m "sysroot(wpkdraw): public headers — wpkdraw / font / widget API" +``` + +--- + +### Task A3: Core 2D primitives implementation + +**Files:** +- Create: `examples/libs/wpkdraw/src/wpkdraw.c` (~400 LoC). +- Create: `examples/libs/wpkdraw/src/wpk_internal.h` (~30 LoC). + +```c +// src/wpk_internal.h +#ifndef WPK_INTERNAL_H +#define WPK_INTERNAL_H + +#include <stdint.h> + +struct wpk_surface { + int fd_card; /* card0 fd */ + int crtc_id, conn_id; + uint32_t mode_id; /* allocated mode handle if needed */ + int width, height; + /* Two ARGB8888 gbm_bos forming the front/back pair. */ + struct gbm_bo *bo_front, *bo_back; + uint32_t fb_front, fb_back; /* MODE_ADDFB2 fb_ids */ + uint32_t *back_pixels; /* mmap'd back-buffer */ + int back_stride; /* bytes per row in back_pixels */ + int back_is_index; /* 0 = front is bo_front; 1 = swapped */ +}; + +/* Alpha-blend src over a dst pixel in-place; both ARGB8888. */ +static inline uint32_t wpk_blend_pixel(uint32_t dst, uint32_t src) { + uint32_t a = src >> 24; + if (a == 0xff) return src; + if (a == 0) return dst; + uint32_t inv = 255 - a; + uint32_t dr = (dst >> 16) & 0xff, dg = (dst >> 8) & 0xff, + db = dst & 0xff, da = dst >> 24; + uint32_t sr = (src >> 16) & 0xff, sg = (src >> 8) & 0xff, + sb = src & 0xff; + uint32_t r = (sr * a + dr * inv) / 255; + uint32_t g = (sg * a + dg * inv) / 255; + uint32_t b = (sb * a + db * inv) / 255; + uint32_t fa = a + (da * inv) / 255; + return (fa << 24) | (r << 16) | (g << 8) | b; +} + +#endif /* WPK_INTERNAL_H */ +``` + +```c +// src/wpkdraw.c +#define _GNU_SOURCE +#include <wpkdraw/wpkdraw.h> +#include "wpk_internal.h" + +#include <errno.h> +#include <fcntl.h> +#include <stdlib.h> +#include <string.h> +#include <sys/mman.h> +#include <unistd.h> + +#include <xf86drm.h> +#include <xf86drmMode.h> +#include <gbm.h> + +/* ---- Surface lifecycle ---- */ + +static int allocate_bo_and_fb(struct wpk_surface *s, struct gbm_device *gbm, + struct gbm_bo **out_bo, uint32_t *out_fb, + uint32_t **out_pixels, int *out_stride) { + /* Allocate CPU-shared-tier bo (LINEAR + SCANOUT) at surface dims. */ + *out_bo = gbm_bo_create(gbm, s->width, s->height, GBM_FORMAT_ARGB8888, + GBM_BO_USE_SCANOUT | GBM_BO_USE_LINEAR); + if (!*out_bo) return -1; + /* Wrap as KMS framebuffer via MODE_ADDFB2. */ + uint32_t handles[4] = {0}, pitches[4] = {0}, offsets[4] = {0}; + handles[0] = gbm_bo_get_handle(*out_bo).u32; + pitches[0] = gbm_bo_get_stride(*out_bo); + if (drmModeAddFB2(s->fd_card, s->width, s->height, GBM_FORMAT_ARGB8888, + handles, pitches, offsets, out_fb, 0) < 0) { + gbm_bo_destroy(*out_bo); + return -1; + } + /* Map for CPU writes. */ + void *map_data = NULL; + *out_pixels = gbm_bo_map(*out_bo, 0, 0, s->width, s->height, + GBM_BO_TRANSFER_WRITE, &map_data, NULL); + *out_stride = pitches[0]; + return 0; +} + +struct wpk_surface *wpk_surface_create(int *out_w, int *out_h) { + struct wpk_surface *s = calloc(1, sizeof *s); + if (!s) { errno = ENOMEM; return NULL; } + s->fd_card = open("/dev/dri/card0", O_RDWR | O_CLOEXEC); + if (s->fd_card < 0) goto err_free; + if (drmSetMaster(s->fd_card) < 0) goto err_close; + /* Resolve the v1 single connector + crtc + mode. */ + drmModeRes *res = drmModeGetResources(s->fd_card); + if (!res || res->count_connectors < 1 || res->count_crtcs < 1) { + errno = ENOENT; + goto err_master; + } + s->conn_id = res->connectors[0]; + s->crtc_id = res->crtcs[0]; + drmModeConnector *conn = drmModeGetConnector(s->fd_card, s->conn_id); + if (!conn || conn->count_modes < 1) { + errno = ENOENT; + drmModeFreeResources(res); + goto err_master; + } + drmModeModeInfo mode = conn->modes[0]; + s->width = mode.hdisplay; + s->height = mode.vdisplay; + drmModeFreeConnector(conn); + drmModeFreeResources(res); + /* Allocate gbm device + front/back bos. */ + struct gbm_device *gbm = gbm_create_device(s->fd_card); + if (!gbm) goto err_master; + uint32_t *_unused_front_px; + int _unused_front_stride; + if (allocate_bo_and_fb(s, gbm, &s->bo_front, &s->fb_front, + &_unused_front_px, &_unused_front_stride) < 0) + goto err_gbm; + if (allocate_bo_and_fb(s, gbm, &s->bo_back, &s->fb_back, + &s->back_pixels, &s->back_stride) < 0) + goto err_front; + /* Initial SETCRTC: present the (currently blank) front bo. */ + drmModeSetCrtc(s->fd_card, s->crtc_id, s->fb_front, 0, 0, + &s->conn_id, 1, &mode); + if (out_w) *out_w = s->width; + if (out_h) *out_h = s->height; + return s; +err_front: + drmModeRmFB(s->fd_card, s->fb_front); + gbm_bo_destroy(s->bo_front); +err_gbm: + /* gbm device intentionally not destroyed — held by bo lifetime */ +err_master: + drmDropMaster(s->fd_card); +err_close: + close(s->fd_card); +err_free: + free(s); + return NULL; +} + +void wpk_surface_destroy(struct wpk_surface *s) { + if (!s) return; + drmModeRmFB(s->fd_card, s->fb_front); + drmModeRmFB(s->fd_card, s->fb_back); + gbm_bo_destroy(s->bo_front); + gbm_bo_destroy(s->bo_back); + drmDropMaster(s->fd_card); + close(s->fd_card); + free(s); +} + +int wpk_surface_present(struct wpk_surface *s) { + /* Swap front and back. The previously-back is now front and + * we page-flip to it. */ + if (drmModePageFlip(s->fd_card, s->crtc_id, + s->back_is_index ? s->fb_front : s->fb_back, + DRM_MODE_PAGE_FLIP_EVENT, NULL) < 0) + return -1; + /* Wait for vblank — synchronous, simplifies the API. */ + drmVBlank vbl = { .request.type = DRM_VBLANK_RELATIVE, + .request.sequence = 1 }; + drmWaitVBlank(s->fd_card, &vbl); + /* Now the just-flipped bo is the front; swap which one + * back_pixels points to so the next frame writes the other. */ + s->back_is_index ^= 1; + /* Re-map the now-back bo's pixels. The two mappings could be + * cached but for simplicity we remap each present. */ + void *_unused; + s->back_pixels = gbm_bo_map( + s->back_is_index ? s->bo_front : s->bo_back, + 0, 0, s->width, s->height, GBM_BO_TRANSFER_WRITE, &_unused, NULL); + return 0; +} + +uint32_t *wpk_surface_back_pixels(struct wpk_surface *s, int *out_stride) { + if (!s) return NULL; + if (out_stride) *out_stride = s->back_stride; + return s->back_pixels; +} + +int wpk_surface_width(struct wpk_surface *s) { return s ? s->width : 0; } +int wpk_surface_height(struct wpk_surface *s) { return s ? s->height : 0; } + +/* ---- 2D primitives ---- */ + +void wpk_clear(struct wpk_surface *s, wpk_color color) { + if (!s || !s->back_pixels) return; + int stride_px = s->back_stride / 4; + for (int y = 0; y < s->height; y++) { + uint32_t *row = s->back_pixels + y * stride_px; + for (int x = 0; x < s->width; x++) row[x] = color; + } +} + +void wpk_pixel(struct wpk_surface *s, int x, int y, wpk_color color) { + if (!s || !s->back_pixels) return; + if (x < 0 || y < 0 || x >= s->width || y >= s->height) return; + uint32_t *p = s->back_pixels + y * (s->back_stride / 4) + x; + *p = wpk_blend_pixel(*p, color); +} + +void wpk_rect(struct wpk_surface *s, int x, int y, int w, int h, + wpk_color color) { + if (!s || w <= 0 || h <= 0) return; + int x0 = x < 0 ? 0 : x, y0 = y < 0 ? 0 : y; + int x1 = x + w; if (x1 > s->width) x1 = s->width; + int y1 = y + h; if (y1 > s->height) y1 = s->height; + int stride_px = s->back_stride / 4; + if ((color >> 24) == 0xff) { + /* Fast path: opaque fill. */ + for (int py = y0; py < y1; py++) { + uint32_t *row = s->back_pixels + py * stride_px + x0; + for (int px = x0; px < x1; px++) *row++ = color; + } + } else { + /* Slow path: alpha-blend per-pixel. */ + for (int py = y0; py < y1; py++) + for (int px = x0; px < x1; px++) + wpk_pixel(s, px, py, color); + } +} + +void wpk_line(struct wpk_surface *s, int x0, int y0, int x1, int y1, + wpk_color color) { + /* Standard Bresenham — no anti-aliasing in v1. */ + int dx = x1 - x0, dy = y1 - y0; + int ax = dx < 0 ? -dx : dx, ay = dy < 0 ? -dy : dy; + int sx = dx < 0 ? -1 : 1, sy = dy < 0 ? -1 : 1; + int err = (ax > ay ? ax : -ay) / 2; + while (1) { + wpk_pixel(s, x0, y0, color); + if (x0 == x1 && y0 == y1) break; + int e2 = err; + if (e2 > -ax) { err -= ay; x0 += sx; } + if (e2 < ay) { err += ax; y0 += sy; } + } +} + +void wpk_blit(struct wpk_surface *dst, int x, int y, + struct wpk_surface *src) { + if (!dst || !src || !dst->back_pixels || !src->back_pixels) return; + int sw = src->width, sh = src->height; + int dx0 = x < 0 ? 0 : x, dy0 = y < 0 ? 0 : y; + int dx1 = x + sw; if (dx1 > dst->width) dx1 = dst->width; + int dy1 = y + sh; if (dy1 > dst->height) dy1 = dst->height; + int ds_px = dst->back_stride / 4, ss_px = src->back_stride / 4; + for (int dy = dy0; dy < dy1; dy++) { + int sy = dy - y; + uint32_t *drow = dst->back_pixels + dy * ds_px; + uint32_t *srow = src->back_pixels + sy * ss_px; + for (int dxp = dx0; dxp < dx1; dxp++) { + int sxp = dxp - x; + drow[dxp] = wpk_blend_pixel(drow[dxp], srow[sxp]); + } + } +} +``` + +**Step 4: Build wires** + +Update `build.sh` to compile `src/wpkdraw.c` into `libwpkdraw.a`: + +```bash +wasm32posix-cc -c -O2 -fPIC \ + -I include -I "$WPK_SYSROOT/include/libdrm" -I "$WPK_SYSROOT/include" \ + src/wpkdraw.c -o "$WORK/wpkdraw.o" +llvm-ar rcs "$OUT_DIR/lib/libwpkdraw.a" "$WORK/wpkdraw.o" +cp -r include/wpkdraw "$OUT_DIR/include/" +``` + +**Step 5: Cargo test** + +```rust +// crates/xtask/tests/wpkdraw_resolution.rs +#[test] +fn wpkdraw_resolves_and_builds() { + let out = run_resolve("wpkdraw"); + assert!(out.lib_dir.join("libwpkdraw.a").exists()); + assert!(out.include_dir.join("wpkdraw/wpkdraw.h").exists()); +} +``` + +**Step 6: Smoke test** + +```c +// programs/wpkdraw_primitives_smoke.c +#include <wpkdraw/wpkdraw.h> +#include <stdio.h> +int main(void) { + int w, h; + struct wpk_surface *s = wpk_surface_create(&w, &h); + if (!s) { perror("wpk_surface_create"); return 1; } + printf("surface: %dx%d\n", w, h); + wpk_clear(s, WPK_RGB(0, 0, 0)); + wpk_rect(s, 50, 50, 100, 100, WPK_RGB(255, 0, 0)); + wpk_line(s, 0, 0, w - 1, h - 1, WPK_RGB(0, 255, 0)); + wpk_surface_present(s); + wpk_surface_destroy(s); + return 0; +} +``` + +**Vitest:** assert exit 0; stdout has "surface: WxH" with sensible +dimensions; check `host_kms_set_fb` was called twice (front + back +ADDFB2) and `host_kms_page_flip` exactly once. + +**Step 7: Commit** + +```bash +git add examples/libs/wpkdraw/src/ examples/libs/wpkdraw/build.sh \ + programs/wpkdraw_primitives_smoke.c \ + host/test/wpkdraw-primitives-smoke.spec.ts +git commit -m "sysroot(wpkdraw): 2D primitives — surface, clear, pixel, rect, line, blit" +``` + +--- + +### Task A4: stb_truetype vendor + +**Files:** +- Create: `examples/libs/wpkdraw/third_party/stb_truetype.h` — + upstream verbatim, vendored from + https://github.com/nothings/stb commit `<pinned>` (latest + stable as of 2026-05). +- Create: `examples/libs/wpkdraw/third_party/stb_truetype.LICENSE`. + +The header is ~5500 lines; vendor unmodified. Public domain +license; copy the dedication. + +**Step 1: Build wires** + +Update `build.sh` to define the implementation TU: + +```bash +# Create a tiny TU that pulls in stb's implementation. stb_truetype +# uses #define STB_TRUETYPE_IMPLEMENTATION to opt into the body; +# without it the header is declaration-only. +cat > "$WORK/stb_impl.c" <<'EOF' +#define STB_TRUETYPE_IMPLEMENTATION +#include "stb_truetype.h" +EOF +wasm32posix-cc -c -O2 -fPIC \ + -I third_party "$WORK/stb_impl.c" -o "$WORK/stb_impl.o" +# Append to the archive. +llvm-ar rcs "$OUT_DIR/lib/libwpkdraw.a" "$WORK/stb_impl.o" +``` + +**Step 2: Cargo test** + +A library-link smoke test: compile a tiny C file that calls +`stbtt_InitFont` and `stbtt_FindGlyphIndex` and link against +`libwpkdraw.a`. Assert no unresolved symbols. + +**Step 3: Commit** + +```bash +git add examples/libs/wpkdraw/third_party/ +git commit -m "sysroot(wpkdraw): vendor stb_truetype.h (single-header rasterizer)" +``` + +--- + +### Task A5: Font subsystem — load + glyph cache + text rendering + +**Files:** +- Create: `examples/libs/wpkdraw/src/wpkfont.c` (~250 LoC). +- Create: `examples/libs/wpkdraw/share/DejaVuSans.ttf` (512 KB, + vendored binary; see Step 3). +- Create: `examples/libs/wpkdraw/share/DejaVuSans.LICENSE`. + +```c +// src/wpkfont.c +#define _GNU_SOURCE +#include <wpkdraw/wpkfont.h> +#include "wpk_internal.h" + +#include <errno.h> +#include <fcntl.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/mman.h> +#include <sys/stat.h> +#include <unistd.h> + +#include "../third_party/stb_truetype.h" + +#define WPK_GLYPH_CACHE_CAP 256 + +struct cached_glyph { + int codepoint; /* 0 = empty slot */ + int w, h; /* glyph dimensions */ + int xoff, yoff; /* offset from baseline */ + int advance; /* horizontal advance */ + uint8_t *bitmap; /* alpha mask (caller free's) */ +}; + +struct wpk_font { + stbtt_fontinfo info; + int px_size; + float scale; + int ascent, descent; + uint8_t *ttf_data; /* mmap'd file backing */ + size_t ttf_len; + /* LRU-ish cache. v1 is FIFO for simplicity. */ + struct cached_glyph cache[WPK_GLYPH_CACHE_CAP]; + int cache_next; /* next eviction slot (FIFO) */ +}; + +static const char *DEFAULT_FONT_PATH = "/usr/share/fonts/default.ttf"; + +struct wpk_font *wpk_font_load_default(int px_size) { + if (px_size < 4 || px_size > 256) { errno = EINVAL; return NULL; } + int fd = open(DEFAULT_FONT_PATH, O_RDONLY | O_CLOEXEC); + if (fd < 0) return NULL; + struct stat st; + if (fstat(fd, &st) < 0) { close(fd); return NULL; } + uint8_t *data = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0); + close(fd); + if (data == MAP_FAILED) return NULL; + struct wpk_font *f = calloc(1, sizeof *f); + if (!f) { munmap(data, st.st_size); errno = ENOMEM; return NULL; } + f->ttf_data = data; + f->ttf_len = st.st_size; + if (!stbtt_InitFont(&f->info, data, 0)) { + munmap(data, st.st_size); + free(f); + errno = EINVAL; + return NULL; + } + f->px_size = px_size; + f->scale = stbtt_ScaleForPixelHeight(&f->info, (float)px_size); + int line_gap; + stbtt_GetFontVMetrics(&f->info, &f->ascent, &f->descent, &line_gap); + return f; +} + +void wpk_font_destroy(struct wpk_font *f) { + if (!f) return; + for (int i = 0; i < WPK_GLYPH_CACHE_CAP; i++) + free(f->cache[i].bitmap); + munmap(f->ttf_data, f->ttf_len); + free(f); +} + +static struct cached_glyph *get_glyph(struct wpk_font *f, int codepoint) { + /* Linear probe — v1 cache is tiny so this is fine. */ + for (int i = 0; i < WPK_GLYPH_CACHE_CAP; i++) + if (f->cache[i].codepoint == codepoint) return &f->cache[i]; + /* Miss — render into the next FIFO slot. */ + struct cached_glyph *g = &f->cache[f->cache_next]; + f->cache_next = (f->cache_next + 1) % WPK_GLYPH_CACHE_CAP; + free(g->bitmap); + g->codepoint = codepoint; + g->bitmap = stbtt_GetCodepointBitmap(&f->info, 0, f->scale, codepoint, + &g->w, &g->h, &g->xoff, &g->yoff); + stbtt_GetCodepointHMetrics(&f->info, codepoint, &g->advance, NULL); + return g; +} + +/* UTF-8 decode one codepoint; advance ptr; returns codepoint or -1 on + * malformed. */ +static int decode_utf8(const char **pp) { + const unsigned char *p = (const unsigned char *)*pp; + if (!*p) return 0; + int c; + if ((*p & 0x80) == 0) { c = *p; *pp += 1; } + else if ((*p & 0xe0) == 0xc0){ c = (*p & 0x1f) << 6 | (p[1] & 0x3f); + *pp += 2; } + else if ((*p & 0xf0) == 0xe0){ c = (*p & 0x0f) << 12 | (p[1] & 0x3f) << 6 + | (p[2] & 0x3f); + *pp += 3; } + else { c = '?'; *pp += 1; } + return c; +} + +int wpk_text_width(struct wpk_font *f, const char *utf8) { + if (!f || !utf8) return 0; + float x = 0; + while (*utf8) { + int cp = decode_utf8(&utf8); + if (cp == 0) break; + int adv; + stbtt_GetCodepointHMetrics(&f->info, cp, &adv, NULL); + x += adv * f->scale; + } + return (int)(x + 0.5f); +} + +void wpk_text(struct wpk_surface *s, struct wpk_font *f, + int x, int y, const char *utf8, wpk_color color) { + if (!s || !f || !utf8) return; + int baseline_y = y; + while (*utf8) { + int cp = decode_utf8(&utf8); + if (cp == 0) break; + struct cached_glyph *g = get_glyph(f, cp); + if (g->bitmap) { + for (int gy = 0; gy < g->h; gy++) { + for (int gx = 0; gx < g->w; gx++) { + uint8_t alpha = g->bitmap[gy * g->w + gx]; + if (!alpha) continue; + uint32_t glyph_color = (color & 0x00ffffff) | + ((uint32_t)alpha << 24); + wpk_pixel(s, x + g->xoff + gx, + baseline_y + g->yoff + gy, glyph_color); + } + } + } + x += (int)(g->advance * f->scale + 0.5f); + } +} +``` + +**Step 1: Vendor DejaVu Sans** + +Source: https://dejavu-fonts.github.io/ — version 2.37, the +`DejaVuSans.ttf` regular face. ~512 KB. License: Bitstream Vera + +DejaVu modifications — permissive (similar to MIT). Vendor the +LICENSE text alongside. + +```bash +curl -sL https://github.com/dejavu-fonts/dejavu-fonts/raw/v2.37/ttf/DejaVuSans.ttf \ + > examples/libs/wpkdraw/share/DejaVuSans.ttf +curl -sL https://github.com/dejavu-fonts/dejavu-fonts/raw/v2.37/LICENSE \ + > examples/libs/wpkdraw/share/DejaVuSans.LICENSE +``` + +**Step 2: Install to sysroot** + +In `build.sh`: + +```bash +mkdir -p "$OUT_DIR/share/fonts" +cp share/DejaVuSans.ttf "$OUT_DIR/share/fonts/default.ttf" +cp share/DejaVuSans.LICENSE "$OUT_DIR/share/fonts/default.LICENSE" +``` + +**Step 3: Build wires** + +```bash +wasm32posix-cc -c -O2 -fPIC \ + -I include -I third_party "$WORK"/../src/wpkfont.c \ + -o "$WORK/wpkfont.o" +llvm-ar rcs "$OUT_DIR/lib/libwpkdraw.a" "$WORK/wpkfont.o" +``` + +**Step 4: Smoke test** + +```c +// programs/wpkdraw_text_smoke.c +#include <wpkdraw/wpkdraw.h> +#include <wpkdraw/wpkfont.h> +#include <stdio.h> +int main(void) { + int w, h; + struct wpk_surface *s = wpk_surface_create(&w, &h); + if (!s) return 1; + struct wpk_font *f = wpk_font_load_default(24); + if (!f) { wpk_surface_destroy(s); return 1; } + int tw = wpk_text_width(f, "Hello, wpkdraw"); + printf("text width: %d\n", tw); + wpk_clear(s, WPK_RGB(20, 20, 30)); + wpk_text(s, f, 20, 50, "Hello, wpkdraw", WPK_RGB(220, 220, 220)); + wpk_surface_present(s); + wpk_font_destroy(f); + wpk_surface_destroy(s); + return 0; +} +``` + +**Vitest:** assert exit 0; stdout shows reasonable text width +(>= 100, <= 300 for the 24px font); check +`host_kms_set_fb` and `host_kms_page_flip` counts. + +**Step 5: Commit** + +```bash +git add examples/libs/wpkdraw/src/wpkfont.c \ + examples/libs/wpkdraw/share/ \ + programs/wpkdraw_text_smoke.c \ + host/test/wpkdraw-text-smoke.spec.ts +git commit -m "sysroot(wpkdraw): font subsystem — stb_truetype + DejaVu Sans + glyph cache" +``` + +--- + +### Task A6: Phase A — full gauntlet + open PR #1 + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +(ABI snapshot expected unchanged.) + +Push, open draft PR. + +Title: `[explore-dri] sysroot(wpkdraw): scaffold + 2D primitives + text rasterizer` + +Body (Brandon style): + +```markdown +## Summary +- New `examples/libs/wpkdraw/` — thin static-link-only 2D + rendering library on top of plan 4 KMS + plan 2 gbm_bo. + Public API: `wpk_surface_*`, `wpk_clear` / `wpk_pixel` / + `wpk_rect` / `wpk_line` / `wpk_blit`, `wpk_font_*`, + `wpk_text` / `wpk_text_width`. +- Vendor `stb_truetype.h` (single-header rasterizer) + + DejaVu Sans regular (~512 KB, permissive license) at + `/usr/share/fonts/default.ttf`. +- Two smoke programs (`wpkdraw_primitives_smoke`, + `wpkdraw_text_smoke`) + Vitest specs verify the surface + + font subsystems talk to plans 2 + 4 correctly. + +## Why +Plan 8 of the DRI v2 design — gives non-SDL2 apps (plans 10's +wpk-shell, plan 11's seed apps) a screen + a button + some text +without pulling in SDL2's ~10 MB of static lib or its +threading requirements. Plan 9's wpkcompositor + plan 11's +file-manager / panel both link this. + +## Verification +- `cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib` +- `cd host && npx vitest run` (two new wpkdraw-* spec files) +- `scripts/run-libc-tests.sh` +- `scripts/run-posix-tests.sh` +- `bash scripts/check-abi-version.sh` + +## ABI impact +None — sysroot-only addition. No kernel exports, no host +imports, no new ioctls or device nodes, no shared-ABI struct +changes. `abi/snapshot.json` byte-identical. + +## Notes +- wpkdraw owns two gbm_bos directly (front + back ARGB8888) + rather than using `gbm_surface_*` — plan 2's libgbm-stub + defers `gbm_surface_create` to a follow-up; wpkdraw doesn't + need triple-buffering or a swap-chain abstraction. +- wpkdraw and SDL2 are alternative front-ends, not stacked. A + process linking wpkdraw shouldn't also link SDL2 — both fight + for KMS master. Plan 9's compositor multiplexes both + client types. +- Font cache is FIFO-with-fixed-cap (256 glyphs/font); LRU is a + post-v1 refinement. +``` + +**Do not merge.** + +--- + +## Phase B — sysroot: widgets + demo (PR #2) + +### Task B1: Widget primitives implementation + +**Files:** +- Create: `examples/libs/wpkdraw/src/wpkwidget.c` (~200 LoC). + +```c +// src/wpkwidget.c +#define _GNU_SOURCE +#include <wpkdraw/wpkwidget.h> +#include <wpkdraw/wpkdraw.h> +#include <wpkdraw/wpkfont.h> +#include "wpk_internal.h" + +#include <stdint.h> +#include <string.h> +#include <unistd.h> +#include <linux/input.h> + +void wpk_widget_button_draw(struct wpk_surface *s, struct wpk_font *f, + struct wpk_button *b) { + wpk_color fill = b->_pressed ? WPK_RGB(60, 60, 80) + : b->_hover ? WPK_RGB(90, 90, 110) + : WPK_RGB(70, 70, 90); + wpk_color border = WPK_RGB(180, 180, 200); + wpk_color label_color = WPK_RGB(230, 230, 240); + /* Filled body + 1-px border. */ + wpk_rect(s, b->x, b->y, b->w, b->h, fill); + wpk_line(s, b->x, b->y, b->x + b->w - 1, b->y, border); + wpk_line(s, b->x, b->y + b->h - 1, b->x + b->w - 1, b->y + b->h - 1, border); + wpk_line(s, b->x, b->y, b->x, b->y + b->h - 1, border); + wpk_line(s, b->x + b->w - 1, b->y, b->x + b->w - 1, b->y + b->h - 1, border); + /* Centred label. */ + int tw = wpk_text_width(f, b->label); + int tx = b->x + (b->w - tw) / 2; + int ty = b->y + b->h / 2 + 8; /* approximation; baseline below mid */ + wpk_text(s, f, tx, ty, b->label, label_color); +} + +static int point_in(struct wpk_button *b, int x, int y) { + return x >= b->x && x < b->x + b->w && y >= b->y && y < b->y + b->h; +} + +int wpk_widget_pump_events(int evdev_kbd_fd, int evdev_ptr_fd, + struct wpk_button *buttons, int n_buttons, + struct wpk_event *out_ev, int max_events) { + static int ptr_x = 0, ptr_y = 0; /* simple session-global */ + int n_out = 0; + /* Keyboard events. */ + struct input_event iev; + while (n_out < max_events && + read(evdev_kbd_fd, &iev, sizeof iev) == (ssize_t)sizeof iev) { + if (iev.type == EV_KEY && iev.value == 1) { + if (iev.code == KEY_ESC) { + out_ev[n_out].type = WPK_EV_QUIT; + n_out++; + continue; + } + out_ev[n_out].type = WPK_EV_KEY; + out_ev[n_out].key.keycode = iev.code; + out_ev[n_out].key.pressed = 1; + n_out++; + } + } + /* Pointer events. */ + while (n_out < max_events && + read(evdev_ptr_fd, &iev, sizeof iev) == (ssize_t)sizeof iev) { + if (iev.type == EV_REL) { + if (iev.code == REL_X) ptr_x += iev.value; + if (iev.code == REL_Y) ptr_y += iev.value; + out_ev[n_out].type = WPK_EV_POINTER_MOTION; + out_ev[n_out].pointer_motion.x = ptr_x; + out_ev[n_out].pointer_motion.y = ptr_y; + n_out++; + } else if (iev.type == EV_KEY && iev.code == BTN_LEFT) { + int press = (iev.value == 1); + out_ev[n_out].type = WPK_EV_POINTER_BUTTON; + out_ev[n_out].pointer_button.x = ptr_x; + out_ev[n_out].pointer_button.y = ptr_y; + out_ev[n_out].pointer_button.button = 0; + out_ev[n_out].pointer_button.pressed = press; + n_out++; + /* Dispatch click to overlapping buttons. */ + for (int i = 0; i < n_buttons; i++) { + if (!point_in(&buttons[i], ptr_x, ptr_y)) continue; + if (press) buttons[i]._pressed = 1; + else if (buttons[i]._pressed && buttons[i].on_click) { + buttons[i]._pressed = 0; + buttons[i].on_click(&buttons[i], buttons[i].user); + } + } + } + } + /* Update hover state for all buttons. */ + for (int i = 0; i < n_buttons; i++) + buttons[i]._hover = point_in(&buttons[i], ptr_x, ptr_y); + return n_out; +} +``` + +**Step 1: Build wires** + +```bash +wasm32posix-cc -c -O2 -fPIC \ + -I include "$WORK"/../src/wpkwidget.c -o "$WORK/wpkwidget.o" +llvm-ar rcs "$OUT_DIR/lib/libwpkdraw.a" "$WORK/wpkwidget.o" +``` + +**Step 2: Commit** + +```bash +git add examples/libs/wpkdraw/src/wpkwidget.c +git commit -m "sysroot(wpkdraw): widget primitives — button + event pump" +``` + +--- + +### Task B2: `wpkdraw_demo.c` — surface + label + button + ESC quit + +**Files:** +- Create: `programs/wpkdraw_demo.c` (~150 LoC). + +```c +// programs/wpkdraw_demo.c — ~150 LoC +// Fullscreen wpkdraw surface; a label + a button that animates +// a colour cycle on click; ESC quits via plan 5 evdev. + +#define _GNU_SOURCE +#include <wpkdraw/wpkdraw.h> +#include <wpkdraw/wpkfont.h> +#include <wpkdraw/wpkwidget.h> + +#include <fcntl.h> +#include <stdio.h> +#include <stdlib.h> +#include <unistd.h> +#include <time.h> + +static int cycle_step = 0; + +static void on_click_cycle(struct wpk_button *self, void *user) { + (void)self; (void)user; + cycle_step = (cycle_step + 1) % 6; +} + +static wpk_color color_at_step(int step, int x, int y, int w, int h) { + /* Simple HSV-ish gradient that shifts on each click. */ + int r = (x * 255) / w; + int g = (y * 255) / h; + int b = ((x + y) * 255) / (w + h); + switch (step) { + case 0: return WPK_RGB(r, g, b); + case 1: return WPK_RGB(b, r, g); + case 2: return WPK_RGB(g, b, r); + case 3: return WPK_RGB(255 - r, g, b); + case 4: return WPK_RGB(r, 255 - g, b); + case 5: return WPK_RGB(r, g, 255 - b); + } + return WPK_RGB(0, 0, 0); +} + +int main(void) { + int w, h; + struct wpk_surface *s = wpk_surface_create(&w, &h); + if (!s) { perror("wpk_surface_create"); return 1; } + struct wpk_font *f = wpk_font_load_default(24); + if (!f) { perror("wpk_font_load_default"); wpk_surface_destroy(s); return 1; } + int kbd_fd = open("/dev/input/event0", O_RDONLY | O_NONBLOCK); + int ptr_fd = open("/dev/input/event1", O_RDONLY | O_NONBLOCK); + if (kbd_fd < 0 || ptr_fd < 0) { + perror("open evdev"); + return 1; + } + struct wpk_button btn = { + .x = w / 2 - 60, .y = h / 2 + 20, .w = 120, .h = 40, + .label = "Cycle", + .on_click = on_click_cycle, + }; + /* 5 s timeout (testing) or until ESC. */ + struct timespec t0; + clock_gettime(CLOCK_MONOTONIC, &t0); + int running = 1; + while (running) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + long ms = (now.tv_sec - t0.tv_sec) * 1000 + + (now.tv_nsec - t0.tv_nsec) / 1000000; + if (ms > 5000) break; + struct wpk_event evs[16]; + int nev = wpk_widget_pump_events(kbd_fd, ptr_fd, &btn, 1, + evs, 16); + for (int i = 0; i < nev; i++) + if (evs[i].type == WPK_EV_QUIT) running = 0; + /* Render. */ + for (int y = 0; y < h; y += 32) + for (int x = 0; x < w; x += 32) + wpk_rect(s, x, y, 32, 32, color_at_step(cycle_step, x, y, w, h)); + wpk_text(s, f, 20, 40, "wpkdraw demo — click Cycle, press ESC", + WPK_RGB(255, 255, 255)); + wpk_widget_button_draw(s, f, &btn); + wpk_surface_present(s); + } + close(kbd_fd); close(ptr_fd); + wpk_font_destroy(f); + wpk_surface_destroy(s); + return 0; +} +``` + +Build via `wasm32posix-cc -o programs/wpkdraw_demo.wasm +programs/wpkdraw_demo.c -lwpkdraw -ldrm -lgbm -lm`. + +**Step 1: Commit** + +```bash +git add programs/wpkdraw_demo.c +git commit -m "examples(wpkdraw): wpkdraw_demo — surface + label + button + ESC quit" +``` + +--- + +### Task B3: Vitest end-to-end + +**Files:** +- Create: `host/test/wpkdraw-demo.spec.ts`. + +Runs `wpkdraw_demo.wasm` under the centralised kernel; asserts: +- The demo exits 0 within 6 s (5 s runtime + 1 s margin). +- The host's `host_kms_set_fb` was called at least once (initial + setup) and `host_kms_page_flip` was called ~300 times + (60 Hz × 5 s). +- The button click handler fires when the host injects a + `BTN_LEFT` press + release at the button's center. +- Pressing ESC mid-run terminates within 200 ms. + +```ts +// host/test/wpkdraw-demo.spec.ts +test("wpkdraw_demo runs cleanly under NodeKernelHost", async () => { + const { exitCode, kmsCounts } = await runProgram( + "programs/wpkdraw_demo.wasm", + { timeoutMs: 6000 } + ); + expect(exitCode).toBe(0); + expect(kmsCounts.set_fb).toBeGreaterThan(0); + expect(kmsCounts.page_flip).toBeGreaterThanOrEqual(150); /* lenient */ + expect(kmsCounts.page_flip).toBeLessThanOrEqual(330); +}); + +test("wpkdraw_demo: ESC quits early", async () => { + const handle = startProgram("programs/wpkdraw_demo.wasm"); + await sleep(1000); + await injectKey(handle, "KEY_ESC", 1); + const { exitCode } = await handle.waitExit(2000); + expect(exitCode).toBe(0); +}); + +test("wpkdraw_demo: button click fires handler", async () => { + const handle = startProgram("programs/wpkdraw_demo.wasm"); + await sleep(500); + /* Move pointer to button center, click. */ + await injectPointerMotionAbs(handle, 640 / 2, 480 / 2 + 40); + await injectPointerButton(handle, "BTN_LEFT", 1); + await injectPointerButton(handle, "BTN_LEFT", 0); + await sleep(500); + await injectKey(handle, "KEY_ESC", 1); + const { exitCode } = await handle.waitExit(2000); + expect(exitCode).toBe(0); + /* No direct way to assert the handler fired without + * instrumenting the demo; the pixel diff at the clicked + * position is the proxy. */ +}); +``` + +**Commit:** `host(wpkdraw): vitest — wpkdraw_demo end-to-end (timeout + ESC + click paths)` + +--- + +### Task B4: Manual browser verification (the gate) + +CLAUDE.md item 6. Build the demo, wire into `examples/browser/ +pages/wpkdraw/`. The browser page mounts an `<iframe>` with the +same cross-origin-isolation headers; clicking "Run" spawns the +kernel, mounts the demo, and: +- A fullscreen colour-tile pattern renders behind a + "wpkdraw demo …" label + a "Cycle" button. +- Clicking the button shifts the colour cycle. +- Pressing ESC quits the demo within ~200 ms. + +If the demo quits but the canvas is blank: plan 2's gbm_bo +mapping isn't taking effect, OR plan 4's `MODE_SETCRTC` isn't +binding to the right framebuffer. Check the browser console for +KMS errors. + +If clicks land but no visual change: the button's `on_click` is +firing but the next-frame render isn't updating `cycle_step`. +Check the demo loop's draw call ordering. + +If ESC doesn't quit: plan 5's evdev DOM listener probably isn't +routing to the kernel — check `examples/browser/lib/ +browser-kernel.ts`'s `kernel_input_event` invocations. + +**No commit yet for this task — verification only.** + +--- + +### Task B5: Phase B — final gauntlet + open PR #2 + +PR title: `[explore-dri] sysroot(wpkdraw): widgets + wpkdraw_demo + browser spec` + +Body (Brandon style): + +```markdown +## Summary +- Widget primitives — `wpk_button` + `wpk_widget_button_draw` + + `wpk_widget_pump_events` for keyboard + pointer event + dispatch. ~200 LoC. +- New `programs/wpkdraw_demo.c` — fullscreen surface + colour + tile pattern + a "Cycle" button + ESC quit. The smallest + "real" wpkdraw app exercising plans 2 + 4 + 5 without SDL2. +- New Vitest spec verifies the demo runs cleanly under + NodeKernelHost (timeout + ESC + click paths). +- Manual browser verification: canvas + click + ESC quit + confirmed in Chromium + Firefox. + +## Why +Plan 8 of the DRI v2 design. wpkdraw is the GL-less alternative +to SDL2_Renderer (which plan 7 explicitly disabled via +`--disable-render`). Apps in plans 10's wpk-shell + plan 11's +seed apps + plan 9's wpkcompositor decorations all want this +surface without SDL2's overhead. + +## Verification +- `cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib` +- `cd host && npx vitest run` +- `scripts/run-libc-tests.sh` +- `scripts/run-posix-tests.sh` +- `bash scripts/check-abi-version.sh` +- Manual browser verification: demo runs cleanly (5 s timeout + + ESC quit + button click confirmed on Chromium 120 + + Firefox 122). + +## Dual-host parity proof +wpkdraw demo runs identically on Node.js (Vitest spec) and +Chromium / Firefox (manual). The library has no host-specific +code paths; everything goes through plans 2 / 4 / 5's existing +kernel surface. + +## ABI impact +None — entirely an examples + sysroot addition. No kernel, +host, or shared-ABI changes. + +## Notes +- wpkdraw's `wpk_surface` owns two gbm_bos directly (no + gbm_surface). Plan 2's libgbm-stub doesn't yet ship + `gbm_surface_*`; wpkdraw avoids the dependency. +- wpkdraw and SDL2 can't coexist on card0 (master is + exclusive). The demo assumes no SDL2 app holds master. +- 5 s demo runtime is the smallest interval that drives the + full vblank cycle (300 frames @ 60 fps) without tail- + dominating; matches plan 7's demo length. +- Button event dispatch is in-process only — no IPC, no + client-server. Plan 9's wpkcompositor will replace + `wpk_widget_pump_events` with a Wayland-shaped protocol. +``` + +**Do not merge until PR #1 (wpkdraw lib) is merged into this PR's +base.** + +--- + +## Final coordinated merge + +When both PRs are reviewed and approved, the browser demo runs +cleanly: + +1. Re-run the full gauntlet on each PR's branch tip. +2. Squash-merge PR #1 → PR #2's base. +3. Squash-merge PR #2 → plan 7's `…-sdl2-demo` (or wherever + plan 7's tip lives at the time). +4. Tag: `[explore-dri-wpkdraw] plan 8 merged at <sha>` in the + next session-handoff doc. + +**Do not push to upstream until v1 + plans 2–8 are all merged +upstream as a coherent chain.** + +--- + +## Trade-offs already locked in (don't relitigate during implementation) + +- **Static-link-only, no shared lib.** `libwpkdraw.a` only. + The v1 libc has `--disable-loadso`-equivalent semantics (no + `dlopen`); a `libwpkdraw.so` would require a runtime loader + that doesn't exist. Plan 9's compositor + plan 10/11's seed + apps all statically link wpkdraw at build time. Acceptable + cost (~80 KB per linking binary) given the demo set's size. +- **wpkdraw owns gbm_bos directly, not `gbm_surface_*`.** Plan + 2's libgbm-stub defers `gbm_surface_create` to a follow-up. + wpkdraw's two-bo front/back model is simpler than a swap-chain + and doesn't need the extra abstraction. Plan 9's compositor + will graduate to `gbm_surface_*` once it lands. +- **wpkdraw and SDL2 are alternative front-ends, not stacked.** + A process linking wpkdraw shouldn't also link SDL2 — both + fight for KMS master. v1's seed-app set in plans 10/11 + partitions cleanly: SDL2 apps for GL workloads, wpkdraw for + 2D + text apps. Plan 9's compositor multiplexes both. +- **No anti-aliasing on lines.** v1 uses standard Bresenham; + AA is post-v1. +- **No vector primitives (curves, fills, gradients).** v1 is + pixel + rect + line + blit + text only. AGG-style vector + rasterization is out of scope. +- **No multi-window support.** wpkdraw is single-fullscreen- + surface only; multi-window arrives with plan 9's compositor. +- **Glyph cache is FIFO with fixed cap (256 glyphs/font), + not LRU.** Simpler; LRU is a profiling-driven refinement. +- **One bundled font (DejaVu Sans regular) only.** Bold + + italic + monospaced are post-v1; users can `wpk_font_load_default` + at any size but face selection is fixed. +- **UTF-8 decode is permissive (treats malformed bytes as '?').** + No errors raised. Apps with strict UTF-8 needs can pre-validate. +- **Event pump is in-process only.** No IPC, no client-server, + no event-routing protocol; plan 9 introduces those. +- **`CLOCK_MONOTONIC` pinned via the existing musl shim** — + same as plans 4/5/6/7. wpkdraw's animation step uses the + same clock. +- **No host imports, no kernel exports, no new ioctls.** Plan + 8 is the second plan in the chain (after plan 7) to add zero + kernel-userland ABI surface. Sysroot + examples only. + +--- + +## Risk register + +1. **`gbm_bo_map(GBM_BO_TRANSFER_WRITE)` semantics differ between + plan 2's CPU-shared tier and what wpkdraw expects.** wpkdraw + writes pixels then calls `wpk_surface_present`; if plan 2 + requires an explicit unmap/flush to make writes visible to the + KMS presenter, the demo renders garbage. *Mitigation:* A3's + smoke test catches this — if pixel write + present doesn't + show the rect on-canvas, the mapping semantics need + adjustment. May require a `wpk_surface_flush()` call + before `wpk_surface_present` if plan 2's mapping is + not write-coherent. +2. **`MODE_PAGE_FLIP` returns EBUSY if a previous flip is still + pending.** wpkdraw's `wpk_surface_present` is synchronous (it + `drmWaitVBlank` before returning), so the next flip should + always succeed. But under unusual scheduling (host-side + vblank tick delayed beyond 1/60 s), the next flip could fire + before the previous completes. *Mitigation:* `wpk_surface_present` + returns -1 on EBUSY; caller can retry or skip the frame. +3. **`drmSetMaster` returns EBUSY if SDL2 (plan 7) holds master.** + A wpkdraw app launched while an SDL2 app is running will + fail to open. *Mitigation:* `wpk_surface_create` returns NULL + with errno=EBUSY; the demo prints a clear error and exits. + Plan 9's compositor fixes by being the only master holder. +4. **DejaVu Sans 512 KB download flakes during CI.** The fetch + step in A5 pulls the .ttf from GitHub; CI might timeout or + the upstream URL might shift. *Mitigation:* once landed, the + .ttf is vendored under `examples/libs/wpkdraw/share/` and + gets staged into the package's archive (so the binary + release flow ships it; consumers don't re-download). The + fetch step is one-time at A5 implementation; cache locally. +5. **stb_truetype.h trips on `floor` / `sqrt` / `pow` link + errors.** Our libc has math symbols; should be fine. If not, + add `-lm` to the test program link line (the static + archive itself doesn't link `-lm`; callers do at final-link + time). +6. **Glyph cache memory growth.** With many distinct codepoints + (CJK text), 256-glyph FIFO eviction churns and re-renders + per-character per-frame. *Mitigation:* if profiling shows + this is a hotspot, bump the cap (256 → 1024) or switch to + LRU. For v1's seed-app set (English UI labels), 256 is + ample. +7. **Pointer position absolute vs relative.** wpkdraw's event + pump treats `EV_REL { REL_X, REL_Y }` as deltas; plan 5 + ships the pointer with `BUS_VIRTUAL` and may emit absolute + coordinates instead. *Mitigation:* B3's vitest with + `injectPointerMotionAbs` calls verifies the absolute path; + if plan 5 only emits relative, wpkdraw's accumulator + handles it. +8. **Rendering at non-default mode.** v1 uses the connector's + first mode (`modes[0]`), which may not match the canvas + element's natural size in the browser. *Mitigation:* the + browser host's `host_kms_set_fb` already scales to fit; + any mismatch is a display issue, not a render issue. + +--- + +## What this plan doesn't cover (deferred) + +- **Anti-aliasing.** Lines + text edges are not AA'd. Post-v1. +- **Vector primitives.** No curves, no path fills, no + gradients. AGG-style rasterization is post-v1. +- **Multi-window.** wpkdraw is single-fullscreen-surface only. + Plan 9's compositor introduces windowing. +- **Multiple font faces.** Only the bundled DejaVu Sans + regular. Bold + italic + monospaced are post-v1. +- **Image loading.** No PNG / JPEG / GIF decoder. Apps that + need them link `libpng` + `libjpeg-turbo` themselves. +- **GPU-tier composition.** wpkdraw is CPU-tier only — every + pixel write is software. Plan 9's compositor may use GL + acceleration for its decorations. +- **HiDPI / fractional scaling.** v1 is 1.0× only. +- **Bidi / shaping for complex scripts.** stb_truetype renders + individual glyphs; no harfbuzz, no fribidi. Latin / + Greek / Cyrillic only. +- **Mouse cursor.** wpkdraw doesn't draw a software cursor; + apps tracking `ptr_x`/`ptr_y` may draw their own. Plan 9's + compositor will manage a system cursor. +- **Animation framework.** v1's demo loop is the app's + responsibility; no tween library, no scene graph. +- **Save / load surface to file** (PNG export, etc.). Apps + needing this implement their own. +- **Subpixel positioning** of glyphs. v1 rounds to integer + pixels. +- **Right-to-left text layout.** No RTL support. +- **`libwpkdraw.so` dynamic loading.** Static archive only; no + `dlopen` in v1 libc. + +--- + +End of plan. diff --git a/docs/plans/2026-07-13-wpkcompositor-plan.md b/docs/plans/2026-07-13-wpkcompositor-plan.md new file mode 100644 index 0000000000..7d93423e4c --- /dev/null +++ b/docs/plans/2026-07-13-wpkcompositor-plan.md @@ -0,0 +1,2452 @@ +# DRI v2 — wpkcompositor plan (PID 2 compositor + custom protocol + libxkbcommon + libinput-real) + +> **⚠️ SUPERSEDED (2026-07-08) by +> [`2026-07-08-dri-wayland-compositor-plan.md`](2026-07-08-dri-wayland-compositor-plan.md).** +> The project pivoted the compositor + userland half of DRI from this custom +> wire protocol to **Wayland**. `wpkcompositor`, `libwpkclient`, and the +> 24-message custom wire are dropped before being written; seed apps become +> Wayland clients. The reusable parts (real libxkbcommon, real libinput, KMS +> master, `gbm_surface`, prime-fd + SCM_RIGHTS) survive under the new plan's +> PR4/PR5. Kept below as history only. + +> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to execute this plan task-by-task. + +**Goal:** Ship `examples/programs/wpkcompositor/` — a small (~1.2 kLoC) +wasm32 program that the kernel boots at PID 2, takes KMS master on +`/dev/dri/card0`, listens on `/run/wpk/comp` (unix socket), and +multiplexes SDL2 + wpkdraw client surfaces onto a single +OffscreenCanvas via plan 4's KMS + plan 2's gbm_bo prime-fd +sharing. Plus three new sysroot libraries — `libwpkclient.a` +(client-side wire to the compositor), `libxkbcommon.a` (real +upstream port; SDL2 + the compositor both link this for keymap +translation), and `libinput.a` (real upstream port; replaces plan +7's `libinput-lite` stub with gesture + palm-rejection + +multi-device fan-out). Plus a one-line wpkdraw amendment that +detects the compositor socket and switches between "direct KMS +master" mode (no compositor) and "compositor client" mode +(SCM_RIGHTS prime-fd over the socket). Together these close +the load-bearing gaps in plans 7 (SDL2 audio thread / KMS master +contention) and 8 (wpkdraw and SDL2 alternative front-ends, not +stacked). + +**Architecture:** Five PRs (one per phase). The compositor is a +single C program plus three new packages and a wpkdraw +follow-up amendment. Each surface is one in-tree component: + +1. **`examples/programs/wpkcompositor/main.c`** (~1.2 kLoC) — the + PID-2 compositor server. Boot, take KMS master, set up EGL + + gbm_surface on card0, listen on `/run/wpk/comp`. Event loop is + `poll([card0, sock, libinput_fd], -1)` dispatching to: + - `card0` POLLIN → drain `drm_event_vblank` records (page flip + completion + WAIT_VBLANK ticks); advance frame composition. + - `sock` POLLIN → `accept(2)` new client; per-client state in a + `compositor_client` struct. + - per-client fds POLLIN → drain wire messages + (CREATE_SURFACE / ATTACH_BUFFER / COMMIT / SET_TITLE / + SET_TYPE / etc., 24 message types per design §9.2). + - `libinput_fd` POLLIN → `libinput_dispatch`; route events to + the focused client via INPUT_* messages on its socket. +2. **`examples/libs/libwpkclient/`** — client-side static lib + that SDL2 + wpkdraw link to talk to the compositor. + Public API: + - `wpk_client_connect(path)` — opens AF_UNIX socket; returns + opaque handle or NULL if the compositor isn't running + (callers fall back to direct KMS). + - `wpk_client_create_surface(c, w, h, format) → surface_id`. + - `wpk_client_attach_buffer(c, surface_id, prime_fd)` — + sends `ATTACH_BUFFER` + the fd via `SCM_RIGHTS`. + - `wpk_client_commit(c, surface_id)`. + - `wpk_client_poll(c, &events, n_events)` — drain INPUT_* + + FOCUS_* + WINDOW_CLOSE messages into a caller buffer. + ~250 LoC static archive; no .so. +3. **`examples/libs/libxkbcommon/`** — port of upstream libxkbcommon + 1.6.0 (latest stable as of 2026-05). Build subset: + `libxkbcommon.a` only (no libxkbcommon-x11 / -compose / + -registry); pulls in the Compose key handling separately if + plan 11 needs it. ~150 KB static archive after dead-code + elimination. Used by the compositor (for KEY_* → text / + modifier state) AND by SDL2 (already; plan 7's + `--disable-libudev` doesn't disable xkbcommon). +4. **`examples/libs/libinput/`** — port of upstream libinput + 1.25.0. Build with `--disable-libwacom --disable-documentation` + (we don't ship tablets in v1). Replaces plan 7's + `libinput-lite` stub. Adds: gesture detection (swipe / pinch), + palm rejection (basic — distance from touch start), multi- + device fan-out (libinput auto-discovers `/dev/input/event*`). + ~400 KB static archive. +5. **wpkdraw amendment (`examples/libs/wpkdraw/src/wpkdraw.c`)** — + add a 50-LoC fork in `wpk_surface_create`: if + `connect("/run/wpk/comp", …)` succeeds, switch to "compositor + client mode" — allocate bos via plan 2's renderD128 (no master + needed), pass prime fds to the compositor via libwpkclient. + Else fall back to the existing "direct KMS master mode". + Plan 8's body stays unchanged; the amendment is in a new + internal function `wpk_surface_create_via_compositor` and a + one-line dispatch in `wpk_surface_create`. + +**Tech Stack:** +- Userland C: C99 with `wasm32posix-cc`; static archives only. +- Compositor program: `examples/programs/wpkcompositor/main.c` + cross-compiles to `wpkcompositor.wasm`; installed by the + rootfs-build step at `/usr/bin/wpkcompositor`. Init reads + `/etc/wpk/compositor` (a one-line config) to decide whether + to fork-exec it as PID 2. +- Wire format: the design doc §9.2 binary frame + (`u32 length | u32 type | u8 payload[]`). 24 message types + in v1; serialised hand-rolled in C, no protobuf, no flatbuffers. +- Buffer sharing: client → compositor via plan 2's prime fd + + `SCM_RIGHTS` over the AF_UNIX socket. **This requires + plan-6-sockets-plan's SCM_RIGHTS path landed** (see plan 2's + "What this plan doesn't cover", line 1872+). +- Input: libinput auto-discovers `/dev/input/event0` (kbd) + + `event1` (ptr) via plan 5's seat-shared model; the compositor + is the single libinput consumer in v1. +- Keymap: libxkbcommon — the compositor loads the default keymap + on init and translates KEY_* + EV_KEY values into XKB keysyms + + modifier states for INPUT_KEYBOARD messages. + +**Companion design doc:** `docs/plans/2026-05-18-dri-design.md` +§9 (compositor + userland), §9.1 (boot sequence), §9.2 (custom +protocol), §9.5 (why custom, not Wayland — Wayland is 6 weeks, +custom is 1 week), §6.3 (vblank events on card0 read side), §5.4 +(compositor's privileged hooks). + +**Critical wasm32 ABI detail:** the compositor protocol is +entirely userspace — every byte goes through the existing +`socket(AF_UNIX)` + `sendmsg(SCM_RIGHTS)` + `read` / +`write` syscalls. **Zero kernel exports added.** Zero host +imports added. The plan-6-sockets-plan's SCM_RIGHTS is the only +kernel surface this plan depends on (and it should land before +plan 9 starts implementation; if it hasn't, plan 9 carries an +inline sub-plan to finish it). + +**Clock source:** All four new components use `clock_gettime( +CLOCK_MONOTONIC, …)` via the musl shim — cross-stream parity with +plans 4/5/6/7/8. + +**Design reference:** `docs/plans/2026-05-18-dri-design.md` §9 +(compositor), §9.2 (wire format), §9.5 (custom-vs-Wayland +trade-off; 1 week vs 6 weeks). + +**Consistency with plans 2 + 4 + 5 + 6 + 7 + 8:** + +- **No new kernel exports.** All compositor surface is userspace + C over plans 2/4/5/6's existing kernel ioctls + plans 2/3's + GL stack follow-up + plan-6-sockets's SCM_RIGHTS. +- **Plan 4's KMS master semantics drive the compositor's + exclusivity.** Once wpkcompositor takes master on card0, no + other process can `drmSetMaster` until the compositor drops or + dies. Plan 8 (wpkdraw direct KMS mode) + plan 7 (SDL2 KMSDRM + backend) are *demoted to compositor clients*: their direct-KMS + paths still work (boot without the compositor) but won't + coexist with it. The wpkdraw amendment + a parallel SDL2 + backend amendment (plan 11) handle the demotion gracefully. +- **Plan 5's seat-shared evdev model is the compositor's input + pipeline.** libinput opens event0 + event1 (via + `libinput_path_add_device`), and *because the compositor is + the only libinput consumer in v1*, the seat-shared fanout (every + open OFD sees every event) doesn't cause double-delivery — only + the compositor has the fds open. Client surfaces don't open + evdev directly under this design; they receive INPUT_* messages + over the wire. +- **Plan 6's ALSA path is independent.** The compositor doesn't + manage audio routing in v1; audio clients (SDL2, future + wpkdraw audio apps) talk to `/dev/snd/*` directly. Plan 6's + per-quantum tick model + non-blocking WRITEI + POLLOUT (per + plan 7 open-architecture #1 resolution) still operate + unchanged. +- **Plan 7's SDL2 audio thread** decision (option (b): non- + blocking WRITEI + SDL2 polling patch, lean from plan 7 review) + is independent of plan 9. The compositor doesn't need + threading; its event loop is single-threaded `poll(2)`. +- **Plan 8's wpkdraw direct-KMS path is preserved.** The + amendment is additive: detect compositor → switch to client + mode; absent compositor → keep direct KMS master. +- **GL stack follow-up (plan 2 + plan 3 amendments) must land + before this plan starts.** The compositor's EGL surface creation + + gbm_surface_lock_front_buffer + libGLESv2 draw calls all + depend on the libegl-stub + libgles2-stub + gbm_surface + follow-ups documented in plans 2 + 3's reviews. This is the + same blocking dependency plan 7 PR #2 carries; plan 9 inherits + it. **Block plan 9 implementation start on the GL stack + follow-up PRs landing.** + +**Stack base:** Plan 8's `…-wpkdraw-demo` branch tip (plan 8's +PR #2 head). The compositor needs everything from plans 2–8 plus +the plan 2 + 3 GL stack follow-ups. If the follow-ups haven't +landed by impl time, rebase onto their tips first. + +**Branch:** `emdash/explore-direct-rendering-infrastructure-wpkcompositor-plan-XXXXX` +(chains off plan 8's tip per the branching rule). Five sub-branches +stack off it for the five PRs. + +**Final PR base:** Plan 8's `…-wpkdraw-demo` tip. **Do not merge** +until Brandon validates the design, plan-6-sockets-plan's +SCM_RIGHTS path has landed, the plan 2 + 3 GL stack follow-ups +have landed, and Phase E's manual browser verification confirms +the compositor multiplexes SDL2 + wpkdraw clients correctly. + +**Five PRs, coordinated merge.** Each task below is one commit. +PR titles use Brandon's `scope(area): action` shape: + +1. `sysroot(input): libxkbcommon — real upstream port` +2. `sysroot(input): libinput — real upstream port replacing libinput-lite` +3. `sysroot(wpk): libwpkclient — compositor client wire` +4. `examples(wpk): wpkcompositor — PID 2 server + custom protocol` +5. `sysroot(wpkdraw)+examples(wpk): wpkdraw compositor-client mode + demo + browser spec` + +PR base/head topology (stacked): + +``` +… (plans 2–8 tips + plan 2/3 GL stack follow-ups) + └── …-wpkdraw-demo (plan 8 PR #2 tip) + └── …-wpkcompositor-plan-XXXXX (this plan PR base) + └── …-wpk-xkbcommon (PR #1) + └── …-wpk-libinput (PR #2) + └── …-wpkclient (PR #3) + └── …-wpkcompositor (PR #4) + └── …-wpk-demo (PR #5) +``` + +**Verification gauntlet** (CLAUDE.md): all of the below must pass +with zero regressions before any PR is opened, and re-run before +final merge: + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +`XFAIL` / `TIME` are acceptable; `FAIL` that isn't pre-existing is a +regression. Phase E adds manual `./run.sh browser` verification +(CLAUDE.md item 6) — the compositor boots at PID 2, an SDL2 + +GLES2 demo and a wpkdraw demo both connect, both visibly render in +distinct compositor-decorated windows, focus + click + ESC work +across both. + +**ABI impact:** **None.** Plan 9 adds zero kernel exports, zero +host imports, zero new ioctls, zero new device nodes. Every byte +crosses the kernel-userland ABI via existing surfaces from plans +2/4/5 + plan-6-sockets (SCM_RIGHTS). `ABI_VERSION` does not bump; +`abi/snapshot.json` is byte-identical. + +The sysroot grows substantially: `sysroot/lib/libxkbcommon.a` +(~150 KB), `sysroot/lib/libinput.a` (~400 KB), +`sysroot/lib/libwpkclient.a` (~30 KB code), `sysroot/include/{xkbcommon,libinput,wpkclient}/`, +the compositor binary at `/usr/bin/wpkcompositor` (~600 KB +including its statically linked deps), plus default keymaps under +`/usr/share/X11/xkb/` (~2 MB; libxkbcommon's data files — +permissive license, vendored). Package index ledger gets three +new entries. + +Existing kernel + host + ABI surfaces — all unchanged. + +--- + +## Pre-implementation review + +Devil's-advocate + consistency pass run 2026-05-19 (session 10), +after plan 9 drafted in session 9. Pass covers: focus areas from the +hand-off-9 sentinel (SCM_RIGHTS round-trip with plan-6-sockets, +libinput's path-mode under plan 5's seat-shared fanout, xkbcommon +data-files rootfs bloat budget, compositor crash → client orphan +recovery, `gbm_surface` triple-buffer ring vs plan 4's PAGE_FLIP +throttle, libxkbcommon + libinput cross-compile risk, single- +libinput-consumer invariant, custom protocol scope vs design §9.2's +24-message inventory, PID 2 boot ordering vs init's existing shell- +spawn), plus a code-level re-read of every C snippet in the plan +body — `main.c`, `compositor_handle_attach_buffer`, +`compositor_handle_commit`, `compositor_handle_libinput_event`, +`compositor_event_loop`, `wpk_surface_create_via_compositor`, +`wpk_surface_present_via_compositor`, `wpk_widget_pump_events_via_compositor`, +plus a re-audit of `docs/plans/2026-03-08-phase6-sockets-plan.md` for +plan 9's SCM_RIGHTS dependency. Findings are structured Brandon-style. +Inline fixes (18) are **folded conceptually** — plan body retains +pre-review text per the Brandon convention; implementation applies +each fix per this section. Five cross-plan amendments leak back into +plans 2 + 4 + 5 + 7 + 8 reviews; the load-bearing open-architecture +items (3) gate plan 9 implementation start. + +### Inline fixes (18 — folded conceptually; plan body unchanged) + +1. **`wpk_surface_present_via_compositor` re-introduces plan 8's + gbm_bo_map per-frame leak.** Plan 8's review fix #4 cached both + bo mappings at `wpk_surface_create` time and swapped a single + `back_pixels` pointer between them on each present (no remap). + Plan 9 E1 (lines 1396-1415) calls + ```c + s->back_pixels = gbm_bo_map(s->bo_back, 0, 0, s->width, s->height, + GBM_BO_TRANSFER_WRITE, NULL, &s->back_map_data); + ``` + on **every** `wpk_surface_present_via_compositor` invocation + without `gbm_bo_unmap` of the previous mapping. Same regression + shape plan 8 #4 closed. **Lean:** cache both mappings at + `wpk_surface_create_via_compositor` time (line 1384 already does + this for `bo_back`; add the symmetric `bo_front` mapping at + create time), store `front_pixels` + `back_pixels` + their + `map_data` slots on `struct wpk_surface`, and swap pointer + references on present without re-mapping. Cross-plan amendment + added to plan 8's review noting fix #4 extends to E1. +2. **`compositor_handle_attach_buffer` hard-codes + `stride = width * 4`.** Plan 9 D3 (line 1142) computes the + import stride as `s->width * 4`, assuming ARGB8888 and a + tightly-packed row. Client bos allocated with + `GBM_BO_USE_LINEAR` (the only tier `gbm_bo_get_fd` can export + in v1 per plan 2's SAB-backed model) MAY have pitch alignment + (4 KiB-aligned strides are common). The compositor's + `gbm_bo_import` with the wrong stride → garbled texture. + **Lean:** the wire-format `wpk_msg_attach_buffer` payload must + carry `stride: u32` alongside `surface_id` (and optionally a + `format: u32` override in case the client renegotiates). The + importer uses the wire-supplied stride, not a recomputed + `width * 4`. Fold into C2's wire format + D3's import code. +3. **`compositor_handle_libinput_event` only handles + `LIBINPUT_EVENT_POINTER_MOTION` (relative), not + `LIBINPUT_EVENT_POINTER_MOTION_ABSOLUTE`.** Plan 5's + cross-plan amendment from plan 8's review (lines 533-534 of + plan 5) confirms that the default-state browser pointer + emits EV_ABS, which libinput translates to + `POINTER_MOTION_ABSOLUTE` (NOT `POINTER_MOTION`). Plan 9 D4 + (line 1224-1242) handles only the relative arm. Without the + absolute arm, the compositor cursor never moves on default- + browser (unlocked) input — the exact same failure mode as + plan 8 fix #1. **Lean:** add a parallel arm: + ```c + case LIBINPUT_EVENT_POINTER_MOTION_ABSOLUTE: { + struct libinput_event_pointer *p = libinput_event_get_pointer_event(ev); + double ax = libinput_event_pointer_get_absolute_x_transformed(p, c->screen_w); + double ay = libinput_event_pointer_get_absolute_y_transformed(p, c->screen_h); + c->cursor_x = ax; c->cursor_y = ay; + compositor_clamp_cursor(c); + /* …hit-test + route as relative path… */ + compositor_mark_dirty(); + break; + } + ``` + Fold into D4. Risk register #4 (libinput filter chain on + synthetic devices) is correct in spirit but doesn't catch + this — the issue isn't filtering quality, it's a missing + event arm. Bump risk register #4 to also reference inline + fix #3. +4. **Compositor never grabs evdev devices with EVIOCGRAB.** Plan + 5's seat-shared model fans every event to every open OFD on + `/dev/input/event*`. Plan 5 explicitly punted exclusivity to + plan 9 (plan 5 lines 224-230: "plan 9 does enforce cross-fd + exclusivity"). Plan 9 D2 sets `compositor_open_restricted` + (line 1080) as libinput's open hook but libinput by default + does NOT call EVIOCGRAB. Without it, a co-running wpkdraw- + direct (boot path: compositor not started → wpkdraw takes + master, opens evdev → user starts compositor → wpkdraw stays + alive, compositor also opens evdev → double-delivery of every + keypress) breaks both processes silently. **Lean:** amend + `compositor_open_restricted` to call + `ioctl(fd, EVIOCGRAB, (int){1})` after open and ignore the + return (v1 plan 5 records the grab flag without enforcement; + plan 9's grab is the enforcement — see open-architecture #2 + below for the load-bearing follow-up). Fold into D2. +5. **`accept` without `SOCK_CLOEXEC` propagation.** D2 line + 1069 sets `SOCK_CLOEXEC` on the listener but Linux `accept(2)` + does NOT inherit `O_CLOEXEC` onto accepted fds. The implicit + accept in `compositor_accept_client` (referenced at D5 line + 1272) must use `accept4(c->sock, NULL, NULL, SOCK_CLOEXEC)`, + otherwise client fds leak across any future fork-exec (init's + crash-restart of the compositor would inherit stale client + sockets). Hygiene; fold into D5's `compositor_accept_client`. +6. **D2 boot path has no `goto err_*` cleanup chain.** On any + failure between `drmSetMaster` (line 1056) and the event + loop, `main()` returns 1 directly. Plan 4 (lines 403-406) + confirms master is auto-dropped on OFD-final-close, so the + master itself doesn't leak across process exit — but the + following resources do during partial init: `C.gbm` + (`gbm_destroy_device`), `C.gbm_surface` + (`gbm_surface_destroy`), `C.egl_dpy` (`eglTerminate`), + `C.sock` (still bound; the `unlink` happens at next start), + `C.input` (`libinput_unref`), `C.xkb_state` / `_keymap` / + `_ctx` (each has an `unref`). The pattern Brandon would write + matches plan 8 A3's `err_gbm: → err_master: → err_close:` + goto chain. **Lean:** add a goto-chain to D2 mirroring plan + 8's pattern; install a single `signal(SIGTERM, set_quit)` so + the event loop's normal exit path triggers the same cleanup. + Fold into D2. +7. **Missing `SIGTERM`/`SIGINT` handler.** The compositor at + PID 2 will receive `SIGTERM` from init on system shutdown (or + a user-driven kill). Without `signal(SIGTERM, set_quit_flag)`, + default termination skips the cleanup chain from fix #6. + Master is still released (OFD-final-close per plan 4) but + `/run/wpk/comp` socket file persists until the next compositor + start (which unlink+rebinds per D2 line 1072 — so functional, + but cosmetically stale on `ls /run/wpk/`). Add the handler. + Risk register #9 acknowledges the leak; the handler closes + the cosmetic loophole. +8. **Event-loop render path can exhaust the gbm_surface ring.** + Per plan 2 follow-up, `eglSwapBuffers` semantically calls + `gbm_surface_lock_front_buffer`, rotating the draw bo into + the locked-by-scanout set. Plan 4 enforces one in-flight flip + per (CRTC, OFD); a second `eglSwapBuffers` issued before the + first's FLIP_COMPLETE arrives → either EBUSY (the inner + page-flip ioctl) or — depending on the EGL stub's gating — + silently consumes the next free bo, returning an undrawn + front. With a 3-bo ring, three successive `dirty` ticks + without a FLIP_COMPLETE drain (from a stalled vblank) exhaust + the ring. Risk register #3 documents the issue but D5's loop + (line 1268) only gates `poll(timeout = dirty ? 0 : -1)` and + then unconditionally renders if `c->dirty`. **Lean:** add a + guard + ```c + if (c->dirty && gbm_surface_has_free_buffers(c->gbm_surface)) { + compositor_render_frame(c); + c->dirty = 0; + } + ``` + so a full ring stalls one frame instead of erroring or + silently corrupting. Fold into D5. +9. **`compositor_pick` hit-test ordering not specified.** D4 + line 1231 calls `compositor_pick(c, c->cursor_x, c->cursor_y)` + but the function (referenced, not shown) must iterate in + **reverse z-order** (topmost surface first) to find the + correct click target — the same surface render order (line + 1170) used for compositing iterates bottom-up (panels → + toplevels → popups). Plan 9 should explicitly note the + reverse iteration to prevent the obvious mistake at impl + time. Document in D3 alongside `compositor_register_surface`. +10. **`compositor_drain_client` framing-state contract.** D5 + line 1287-1288 calls `compositor_drain_client(c, fds[i].fd)` + but the function (referenced) must handle **partial reads** + — a 64-byte `wpk_msg` header may arrive in two `read` calls + if the kernel socket buffer is split, or `read` may return + a multiple of `sizeof(struct wpk_msg)` plus a partial trailing + payload. The client-side libwpkclient's `wpk_client_poll` + (C3 lines 916-924) uses `MSG_PEEK` to probe the header + length before consuming, but the server-side + `compositor_drain_client` is not specified. **Lean:** every + `compositor_client` carries an `inbuf[4096]` + `inbuf_used` + state; `compositor_drain_client` reads into the tail of the + buffer, then walks fully-arrived frames. Fold into D3 + alongside `struct compositor_client`. +11. **`accept(2)` overflow past `MAX_CLIENTS`.** D5 line 1260 + sizes `struct pollfd fds[2 + 1 + MAX_CLIENTS]` with + `MAX_CLIENTS` undefined in the snippet. If + `compositor_accept_client` blindly accepts past the cap, the + pollfd array overflows on the next loop iteration → stack + smash. **Lean:** define `#define MAX_CLIENTS 16` (matching + `listen(c->sock, 16)` on line 1076), and have + `compositor_accept_client` reject the 17th accept with an + immediate `close()` of the new fd plus a "compositor full" + error frame on it (or silently close — design choice for + plan 9). Fold into D5. +12. **24-message-type protocol — count discrepancy with design + §9.2.** Design §9.2 enumerates ~16 named message types + (CREATE_SURFACE, DESTROY_SURFACE, ATTACH_BUFFER, COMMIT, + SET_TITLE, SET_TYPE, INPUT_KEYBOARD, INPUT_POINTER_MOTION, + INPUT_POINTER_BUTTON, INPUT_POINTER_AXIS, FOCUS_IN, FOCUS_OUT, + CLIPBOARD_SET, CLIPBOARD_REQUEST, CLIPBOARD_DATA, + WINDOW_CLOSE). Plan 9 claims "24 message types per design + §9.2" at lines 87, 336, 1338, 1567 + Phase E PR body. The + delta (~8) is most likely (a) request/reply pairs counted + separately (CREATE_SURFACE_REPLY is one such; plan 9 C2 line + 884 references it), (b) reserved/v2 types pre-allocated, or + (c) drift between design + plan. Either reconcile or + enumerate explicitly. **Lean:** plan 9 C2 ships an enum + `WPK_MSG_*` with all 24 constants, name-mapped to design + §9.2's table, and the message-handler dispatch in D5 + explicitly switches on every one (most as no-ops for v1, with + a `default → log "unknown frame; v1 ignores forwards-compat"` + fallback already at C3 line 942). Without this, the design + §9.5 trade-off ("custom = 1 week, Wayland = 6 weeks") loses + its scope discipline. Cross-plan amendment to the design doc + is NOT required (the design is permissive on count); the + discipline is plan 9's. See open-architecture #1 below. +13. **`gbm_bo_import` cookie path not exercised in plan 9.** + Per design §13, prime fds carry `OpenFileKind::PrimeBo { + bo_id, cookie }` and `gbm_bo_import` MUST internally call + `DRM_IOCTL_PRIME_FD_TO_HANDLE` (which checks cookie + + bumps refcount) before returning a usable bo. Plan 2's + libgbm follow-up doesn't explicitly state that `gbm_bo_import` + is the wrapper around `prime_fd_to_handle`. Plan 9 D3 + (line 1145) calls `close(prime_fd)` immediately after + `gbm_bo_import` with the comment "gbm_bo holds a kernel- + side ref" — this is **only safe** if the import call + actually issued `PRIME_FD_TO_HANDLE` under the hood (which + bumps the kernel-side bo refcount). If the libgbm stub + merely stashed the fd, the close is a use-after-free. + **Lean:** cross-plan amendment to plan 2 — the `gbm_bo_import` + wrapper in libgbm-stub MUST issue `DRM_IOCTL_PRIME_FD_TO_HANDLE` + before returning, full-stop. Document the contract in plan + 2's follow-up amendment block. Fold into plan 2's review. +14. **`gbm_surface_create` flags vs plan 2's LINEAR-only ring.** + Plan 9 D2 (line 1063-1064) requests + `GBM_BO_USE_SCANOUT | GBM_BO_USE_RENDERING` on the + compositor's gbm_surface. Plan 2's follow-up ring is + SAB-backed (LINEAR modifier; no GPU-side restrictions). + SCANOUT-on-LINEAR is fine for a software KMS path (host + composites the surface into the OffscreenCanvas) but + RENDERING-on-LINEAR means the EGL stub renders into the + SAB-mapped bo and the host's GL context samples it as a + foreign texture via `WPK_BIND_FOREIGN_TEXTURE`. **Lean:** + confirm at plan 2 impl time that the ring honors + `SCANOUT | RENDERING` together; v1's stub should treat + them as equivalent (always LINEAR, always SAB-backed). + Document in plan 2's follow-up. +15. **EVDEV → XKB keycode offset uses unexplained magic + number `+ 8`.** D4 line 1202 `xkb_keycode_t kc = key + 8;` + is the standard X11-keycode-space-vs-evdev-keycode-space + offset (X11 keycodes start at 8; evdev at 0). Correct but + non-obvious. **Lean:** replace with a documented constant + `#define XKB_EVDEV_OFFSET 8` and a one-line comment "XKB + keycodes = evdev + 8 (X11 historical offset)". Fold into + D4. +16. **Compositor + libwpkdraw double-link of stb_truetype.h.** + D6 statically links `libwpkdraw.a` for decorations + cursor. + `libwpkdraw.a` itself embeds `stb_truetype.h` (plan 8 A4) + via `STB_TRUETYPE_IMPLEMENTATION`. If the compositor binary + also includes `stb_truetype.h` (it might, if any source + file pulls it in for an unrelated reason), the linker hits + duplicate-symbol errors. **Lean:** the compositor `#include`s + `<wpkdraw/wpkdraw.h>` (the public header, which forward- + declares `wpk_font` opaquely) and never the implementation + header. Verify at impl time: `nm wpkcompositor.wasm | grep + stbtt_` should show exactly one resolution. Fold into D2's + include policy comment. +17. **xkbcommon data files at `/usr/share/X11/xkb` — 2 MB + rootfs bloat budget.** A3 vendors ~30 keymap files (rules + + types + compat + keycodes + symbols). v1's WordPress demo + rootfs is ~30 MB; +2 MB is 6.7% — material but tolerable. + Risk register #5 documents the bloat. **Lean:** A3 strips + to absolute minimum (us layout only, no other layouts). + Verify the strip size empirically — `du -sh share/X11/xkb/` + on the assembled tree. If it exceeds 2 MB, drop + `rules/evdev.lst` (the per-layout description table) which + is the largest single file at ~1.5 MB and not needed for + runtime keymap resolution (only `xkb_keymap_new_from_names` + needs `rules/evdev`, not `evdev.lst`). Fold into A3's strip + step. +18. **libinput quirks file path is `/usr/share/libinput/` not + `/etc/libinput/`.** B4 installs the quirks file at + `$OUT_DIR/share/libinput/30-default-virtual.quirks` and + libinput's build defines `LIBINPUT_DATA_DIR='"/usr/share/libinput"'` + (B3 line 606). The rootfs build must symlink or copy + `share/libinput/` into `/usr/share/libinput/` at install + time. Plan 9's rootfs-build step (referenced not shown) + must produce the path. **Lean:** add an explicit "rootfs + install layout" subsection under B4 documenting the + `/usr/share/libinput/` and `/usr/share/X11/xkb/` (A3) + + `/usr/share/fonts/default.ttf` (plan 8) install + invariants. Fold into B4. + +### Correctness — open (lean documented) + +- **plan-6-sockets-plan's SCM_RIGHTS path landed status.** Audit + this session confirms SCM_RIGHTS is IMPLEMENTED in the kernel + (kernel/src/wasm_api.rs `kernel_sendmsg` / `kernel_recvmsg` cmsg + handling + pipe.rs `InFlightFd` queue + syscalls.rs sys_sendmsg + / sys_recvmsg socket-layer routing + glue/syscall_glue.c + syscalls 137/138 dispatch). MSG_PEEK | MSG_DONTWAIT supported + (syscalls.rs sys_recv ~line 5248); POLLHUP delivery confirmed + (syscalls.rs sys_poll ~lines 6333-6424); EPIPE on send-to-closed + confirmed (syscalls.rs sys_write ~lines 5478-5497); cross-process + AF_UNIX connect via the UnixSocketRegistry confirmed. **Lean:** + plan 9 does NOT need an inline sub-plan completing plan 6; + build directly on the as-shipped surfaces. Risk register #1's + mitigation collapses to "verify the file `/run/` mount exists + in the rootfs and is writable by PID 2 before bind". Add a + one-line `mkdir -p /run/wpk` early in D2 (line 1068 has it; + good) and assert it succeeds. +- **PID 2 boot ordering vs init's existing shell-spawn.** D1's + amendment fork-execs the compositor before the user shell. + Existing init in `examples/init/` (per CLAUDE.md citation; + the init was added in PR #486 per the recent commits) runs a + shell after `/dev` mounts. **Lean:** D1's amendment goes + **after** the dev mount and **before** the shell exec, with + the `access("/etc/wpk/compositor", F_OK) == 0` guard ensuring + the WordPress demo (which doesn't ship `/etc/wpk/compositor`) + boots unchanged. Risk register #7 documents this. The init's + fork-exec sequence must NOT `waitpid` on the compositor's PID + (long-lived service); confirmed at line 1008 ("init doesn't + wait on it"). +- **KMS master transition on a misbehaving prior holder.** If a + wpkdraw-direct or SDL2 demo was launched before init's + amendment exec'd the compositor (e.g., user added an explicit + pre-shell line that started a demo), the compositor's + `drmSetMaster` returns EBUSY → main() exits 1, init has no + PID 2 service, no compositor. **Lean:** documented as a v1 + invariant; init's responsibility to fork-exec the compositor + BEFORE the user shell. If init spawns a shell that then forks + wpkdraw-direct, the compositor (already at PID 2) holds master + and wpkdraw-direct exits with EBUSY on its own setMaster call + — the same constraint plan 8 risk register #3 covers + bidirectionally. Risk register #2 wording (plan 9) is correct. +- **Compositor crash → wpkdraw-direct fallback.** Plan 9 says + "client falls back to direct KMS (wpkdraw) or fatal-exits + (SDL2)" on EPIPE. wpkdraw's fall-back path: an established + surface with a dead compositor connection cannot transition + back to direct-KMS mid-flight because (a) the surface's gbm + device was opened against renderD128, not card0, and (b) + somebody else may already hold master in the meantime. + Plan 9 E1's amendment shape ALREADY accepts this — once a + surface is established via the compositor, EPIPE means + fatal-exit for that surface (not a re-attempt at direct-KMS). + **Lean:** document this in E1: "compositor-client mode is + one-way; EPIPE during present means the demo exits with an + error message. The fall-back to direct-KMS happens at surface + *create* time, not after." Fold the wording into Phase E1 + alongside the present function. +- **libxkbcommon + libinput cross-compile under wasm32posix-cc.** + Both upstream use meson; plan 7 A2 line 648 documents meson + + wasm32 is a known footgun and plan 7's libdrm-KMS subset + used a hand-rolled Makefile + explicit `.c` list. Plan 9 A2 + + B3 follow the same pattern. **Lean:** budget 2-3 build rounds + per library for the inevitable symbol-resolution churn. Risk + register #2 covers this. The biggest unknown is libinput's + `evdev-fallback.c` / `evdev-mt-touchpad.c` cross-references + (B3 line 593's "keyboards use the fallback driver" is correct + but the file lists are heuristic). Plan B4's smoke link + catches missing symbols. +- **libinput's filter chain on synthetic BUS_VIRTUAL devices.** + Risk register #4 covers this. Quirks file B4 marks the + devices as `ModelGenericKeyboard=1` / `ModelGenericMouse=1`, + which disables acceleration profiles and most palm rejection. + Plan 9 E3 profiles the result; if pointer feels laggy, + add `<NoFilter>` to the quirks file. **Lean: pre-emptively + ship a more aggressive filter-disable in B4** rather than + iterate from a default-noisy baseline. +- **`EGL_KHR_image_base` absence in plan 3's stub.** Plan 9 D3 + composites client bos via `gbm_bo_import` + plan 3's + `WPK_BIND_FOREIGN_TEXTURE` ioctl, not via EGLImage. **Lean:** + documented as design choice in the plan 3 follow-up; v1 sticks + to the simpler bo-import + foreign-texture binding. EGLImage + is post-v1 (Wayland compat layer may need it). +- **`gbm_surface_has_free_buffers` semantic.** Plan 2's follow-up + defines it as "nonzero iff free pool is non-empty". The bo is + considered free when (a) never locked, or (b) released via + `gbm_surface_release_buffer` after the FLIP_COMPLETE that + scanned it out. Plan 9's render guard (inline fix #8) depends + on this. **Lean:** the EGL stub's eglSwapBuffers + the KMS + page-flip handler must release the previously-scanned-out bo + back to the ring. Verify at plan 2 + plan 3 impl time. +- **`gbm_bo_get_fd` returns a fresh prime fd per call.** Plan 9 + E1 line 1408-1413 calls `gbm_bo_get_fd(s->bo_front)` on every + present then `close(pfd)` after `wpk_client_attach_buffer`. + Plan 2's libgbm follow-up: does `gbm_bo_get_fd` allocate a + fresh fd (incrementing kernel-side refcount) or return a + cached fd? **Lean:** matches upstream Mesa semantics — fresh + fd each call, caller's `close` is the release. Confirmed + against the plan 2 follow-up surface (`gbm_bo_get_fd` calls + `DRM_IOCTL_PRIME_HANDLE_TO_FD` per invocation, which always + returns a new fd). Document in plan 2. +- **`gbm_bo_import` GBM_BO_IMPORT_FD shape.** D3 line 1140-1146 + uses + ```c + struct gbm_import_fd_data data = { + .fd = prime_fd, .width = s->width, .height = s->height, + .stride = s->width * 4, .format = s->format, + }; + ``` + The struct shape matches upstream Mesa. The `s->width * 4` + stride is the bug from inline fix #2; the struct itself is + correctly used. + +### Architecture — open (LOAD-BEARING) + +These three items GATE plan 9 implementation start. Each must +have a resolution committed before Phase A's first commit lands. + +1. **(LOAD-BEARING) 24-message-type inventory must be enumerated + and reconciled against design §9.2.** The design §9.2 table + lists ~16 named types (the exact count depends on whether + request/reply pairs and CLIPBOARD_SET/REQUEST/DATA are counted + as 1, 2, or 3). Plan 9 claims "24" at multiple lockstep points. + Without an explicit enumeration, the protocol scope leaks + during impl — every "well, this is just one more message" + addition during Phase D erodes the §9.5 trade-off ("custom = + 1 week, Wayland = 6 weeks") that justifies the custom path. + **Resolution:** C2's wire-format implementation ships an + explicit `enum wpk_msg_type` with all 24 constants, named + 1:1 against the design §9.2 table. Any constant marked + `_RESERVED_FOR_V2_*` ships as no-op (server logs "unknown") + but reserves the wire-format slot. The §9.2 table is **the** + source of truth; if the implementation needs message #25, + it's a design amendment, not a plan 9 amendment. Fold the + enumeration into C2 as a new sub-task (C2a: "enum + design + reconciliation"). +2. **(LOAD-BEARING) EVIOCGRAB exclusivity in the compositor.** + Plan 5 v1 records `grabbed` flag without enforcement (plan 5 + lines 224-230 + lines 361-369 explicitly punt this). Plan 9 + inherits the enforcement obligation. Inline fix #4 above + describes the lean (call EVIOCGRAB in `compositor_open_restricted`) + but the kernel-side enforcement (other OFDs return -EBUSY on + read after a grab) is **not implemented in plan 5 v1**. So + the grab is purely cosmetic in v1 — both compositor and + wpkdraw-direct still receive every event. **Resolution + options:** + - **(a)** Plan 9 ships an inline sub-plan extending plan 5 + to enforce EVIOCGRAB cross-OFD (plan 5 A3 has the dead- + code arm `EBUSY` path; resurrect it). Adds ~1-2 days to + Phase A. + - **(b)** Plan 9 accepts the v1 hazard and documents it + ("if you run wpkdraw-direct and the compositor + concurrently, both will receive every keystroke; v2 ships + enforcement"). Lower scope; documented limitation. + - **(c)** Plan 9 enforces at user-space level (the + compositor's libwpkclient wire protocol becomes the only + supported client; wpkdraw-direct is removed from the + supported configurations). + **Lean: (a).** The enforcement is ~20 lines of Rust in plan + 5's `sys_read` arm (skip the event for non-grabbing OFDs if + any other OFD holds the grab), and it closes the hazard + plan 5 was designed to leave open for plan 9. Cross-plan + amendment to plan 5 added. +3. **(LOAD-BEARING) `gbm_bo_import` MUST internally issue + `DRM_IOCTL_PRIME_FD_TO_HANDLE` (cookie + refcount).** Plan + 9's `compositor_handle_attach_buffer` closes the prime fd + immediately after import (D3 line 1145), relying on the + import call having bumped the kernel-side refcount. If + plan 2's libgbm stub doesn't go through PRIME_FD_TO_HANDLE + (e.g., it just stashes the fd in its bo struct), the close + is a use-after-free that strikes only when the compositor + later samples the bo as a texture. **Resolution:** plan 2's + gbm_surface follow-up must EXPLICITLY document that + `gbm_bo_import(GBM_BO_IMPORT_FD, ...)` issues + PRIME_FD_TO_HANDLE under the hood and the caller is free to + close the fd. Add a cross-plan amendment to plan 2 now; + verify at plan 2's impl time. If plan 2 ships a stub without + the cookie path, plan 9's compositor leaks bos and the demo + silently corrupts client textures after ~16 frames (cookie + slot exhaustion or refcount=0 free). + +### Missing tests — add at impl time + +- **SCM_RIGHTS prime-fd round-trip vitest** under `host/test/`. + Spawn compositor; spawn a tiny test client that calls + `wpk_client_create_surface` + `wpk_client_attach_buffer` with + a renderD128-allocated prime fd; assert the compositor's + `gbm_bo_import` succeeds, the bo's stride matches what the + client allocated, and the compositor's foreign-texture binding + fires for it. Without this, inline fix #2 + #13 are unguarded. +- **Compositor crash → wpkdraw-direct fall-back at create time.** + Spawn compositor; spawn wpkdraw-demo (which connects via + libwpkclient and starts compositing); send SIGKILL to the + compositor; assert wpkdraw-demo exits with an EPIPE error + message (NOT a fall-back to direct-KMS; per the "lean" in + Correctness — open above). +- **wpkdraw-direct vs. running compositor.** Compositor running. + Launch wpkdraw-direct (a version that explicitly skips the + compositor probe). Assert it exits 1 with EBUSY from + `drmSetMaster`. Regression guard for risk register #2. +- **EVIOCGRAB cross-OFD enforcement.** Open event0 from process + A (the compositor). Open event0 from process B. A calls + EVIOCGRAB(1). Inject a keypress via `kernel_input_event`. + Assert A reads the event AND B reads 0 bytes / EAGAIN (or + blocks if not non-blocking). Regression guard for open-arch + #2 resolution (a). If resolution (b) is chosen, this test is + documented-XFAIL. +- **24-message-type enumeration matches design §9.2.** Cargo + test asserts `WPK_MSG_*` enum has exactly 24 constants and + each name appears in the design §9.2 table. Catches drift. +- **`gbm_bo_import` close-after-import safety.** Cargo test + (plan 2 follow-up) asserts that after `gbm_bo_import(GBM_BO_IMPORT_FD, + data, ...)` + `close(data.fd)`, the returned bo's kernel-side + refcount is 1 (the import call's ref) and the bo remains + valid until `gbm_bo_destroy`. Regression guard for open-arch #3. +- **`gbm_surface_has_free_buffers` returns 0 mid-flip.** Plan 9 + test: render 3 frames in a row without draining card0 POLLIN; + assert the 4th render attempt sees `has_free_buffers() == 0` + and stalls cleanly (per inline fix #8). +- **Stale `/run/wpk/comp` cleanup on re-launch.** Start + compositor; SIGKILL it; start compositor again; assert the + second start succeeds (the `unlink` at D2 line 1072 clears + stale state). Regression guard for risk register #9. +- **Quirks file + xkb data files installed under /usr/share/.** + Cargo test asserts after rootfs build that + `/usr/share/libinput/30-default-virtual.quirks` AND + `/usr/share/X11/xkb/rules/evdev` exist and are readable. + Regression guard for inline fixes #17 + #18. +- **xkb modifier latching: Shift+a → 'A'.** Spawn the smoke + program from A4 with a modified sequence: KEY_LEFTSHIFT press + + KEY_A press; assert `xkb_state_key_get_one_sym` returns + `XKB_KEY_A` (capital) not `XKB_KEY_a`. +- **EV_ABS-on-default-browser cursor tracking.** vitest + injects a sequence of ABS_X / ABS_Y events; assert the + compositor's cursor position lands at the absolute coords + AND the focused client receives a POINTER_MOTION message with + surface-relative coords. Regression guard for inline fix #3. +- **Client-surface stride plumbing.** Client allocates a 320×240 + ARGB8888 bo with stride = 1280 (tight). Client allocates a + 320×240 with stride = 4096 (page-aligned). Compositor's + `gbm_bo_import` uses the wire-supplied stride in both cases + and the sampled texture is intact. Regression guard for inline + fix #2. +- **Multi-client focus cycle Super+Tab.** Spawn 3 wpkdraw + clients; assert Super+Tab cycles focus through all 3 in a + consistent order. E2's vitest covers 2; bump to 3 to catch + the cycle vs. toggle distinction. + +### Trade-offs verified + +- **Custom protocol, not Wayland.** Per design §9.5 — 1 week vs + 6 weeks. ✓ +- **libxkbcommon real, not hand-rolled.** Hand-rolled would be + ~3000 LoC of keymap parser + symbol table; error-prone and + duplicates existing Linux bugs. ✓ +- **libinput real, not lite stub.** Plan 7's libinput-lite is + sufficient for SDL2's basic key+button but lacks gesture + + palm rejection + multi-device fan-out. ✓ +- **Path-mode libinput, no libudev.** v1 has no udev daemon; + `libinput_path_create_context` + explicit + `libinput_path_add_device` works. ✓ +- **No tablet / trackpoint / touchscreen in libinput subset.** ✓ +- **Single-threaded poll loop.** No worker threads; matches + design §9 + no-pthreads rule. ✓ +- **24-message-type cap.** ⚠️ COUNT MISMATCH WITH DESIGN — see + LOAD-BEARING open-architecture #1. +- **Software cursor.** Plan 4 doesn't yet expose MODE_CURSOR2. ✓ +- **PID 2 reservation.** Init amendment D1. ✓ +- **Single CRTC / connector / mode.** Matches plan 4. ✓ +- **Window placement compositor-chosen.** v1 cascade + (50,50)→(250,200)→…; v2 may add xdg-positioner. ✓ +- **Compositor links libwpkdraw statically for decorations.** + Plan 8 surface re-used; one bundled font. ✓ +- **Compositor crash → clients orphaned (EPIPE).** Documented; + no auto-restart watchdog in v1. ✓ +- **Per-client linked list.** N ≤ MAX_CLIENTS = 16; linear scan + is fine. ✓ +- **SCM_RIGHTS for prime-fd attach.** Plan-6-sockets shipped + (audit confirms IMPLEMENTED). ✓ +- **`gbm_surface_*` from plan 2 follow-up.** ✓ +- **`libEGL.a` + `libGLESv2.a` from plan 3 follow-up.** ✓ +- **Zero ABI impact.** Sysroot + examples only. ✓ +- **Five-PR stacked merge.** xkbcommon → libinput → libwpkclient + → wpkcompositor → wpkdraw-amendment+demo. ✓ +- **CLOCK_MONOTONIC pinned via musl shim.** Cross-stream parity. ✓ +- **Custom hand-rolled C serialiser, no protobuf/flatbuffers.** ✓ +- **Static-link-only invariant.** All four new libraries ship + `.a` only; no `.so`. Compositor binary is a static-link wasm. ✓ +- **No animation framework / per-frame dirty redraw.** ✓ +- **One libinput consumer in v1 (the compositor).** ⚠️ + Enforcement gap — see LOAD-BEARING open-arch #2. + +### Deliberately not flagged + +- **Wayland wire compatibility (post-v1, ~6 weeks).** Design §15. ✓ +- **XDG-shell-style state machine (v2 may add).** ✓ +- **Multi-monitor (plan 4 invariant).** ✓ +- **Output rotation / scaling / DPI awareness (v1 = 1.0×).** ✓ +- **Hardware cursor plane (v2; needs plan 4 MODE_CURSOR2).** ✓ +- **Touch / pen / tablet (v1 = keyboard + pointer).** ✓ +- **Client-surface scaling / HiDPI (v1 = 1:1).** ✓ +- **Window resize messages from compositor → client (v1 = + client-immutable dims).** ✓ +- **Cursor theme / animated cursors (v1 = hardcoded arrow).** ✓ +- **Stacked pop-up menus + tooltips (v1 = flat z-order).** ✓ +- **Drag-and-drop / clipboard implementation (design §9.2 + reserves CLIPBOARD_*; v1 ships as no-op stubs counted in the + 24-message inventory).** ✓ +- **Compositor restart watchdog (v1 = manual relaunch).** ✓ +- **Audio thread plumbing (plan 7 open-arch #1; INDEPENDENT + of plan 9 — see "Cross-plan amendments" for the resolution + picked this session).** ✓ +- **Plan 7's SDL2 KMSDRM backend coexisting with the compositor + (plan 11 ships SDL_wpkvideo backend; v1 SDL2 demo is direct- + KMS only).** ✓ +- **`WPK_BIND_FOREIGN_TEXTURE` ioctl (plan 3 A4 ships it).** ✓ +- **xkbcommon data files at /usr/share/X11/xkb 2 MB rootfs + bloat — acceptable per inline fix #17 strip strategy.** ✓ +- **Window placement algorithm (v1 cascade; not an + open-architecture item, a hardcoded heuristic).** ✓ +- **`compositor_cycle_focus` algorithm (round-robin over the + client list; not specified in the design, picked at impl).** ✓ +- **VFS mount tree for `/run/wpk/` — plan 6 audit confirms paths + resolve through host_resolve_path; the kernel doesn't manage + a tmpfs mount but `bind` creates real inodes on the host + filesystem (rootfs must contain `/run/wpk/` directory).** ✓ +- **wpkdraw-direct mode preserved with no compositor — + documented by E1's auto-detect dispatch.** ✓ + +### Cross-plan amendments (added to plans 2, 4, 5, 7, 8 reviews) + +- **Plan 2 follow-up (LOAD-BEARING).** `gbm_bo_import(GBM_BO_IMPORT_FD, + data, ...)` MUST internally issue `DRM_IOCTL_PRIME_FD_TO_HANDLE` + (cookie verification + bo refcount bump) before returning a + usable `struct gbm_bo *`. Caller-owned `data.fd` is safe to + `close(2)` immediately after import. Without this contract, + plan 9 D3's close-after-import is a use-after-free. Note + added under plan 2's existing "Cross-plan amendment from + plan 7's devil's-advocate — gbm_surface follow-up + (LOAD-BEARING)" subsection as an addendum sub-bullet. +- **Plan 4 follow-up.** Plan 9's compositor relies on plan 4's + OFD-final-close auto-drop of KMS master semantics (lines + 403-406 + 1116 of plan 4). On compositor crash, master IS + released cleanly; the compositor doesn't need an explicit + `drmDropMaster` in its cleanup chain (though plan 9 inline + fix #6 adds one as defensive hygiene). Note added to plan + 4's "Deliberately not flagged" subsection: "OFD-final-close + auto-drop is sufficient for compositor-process-death; plan 9 + does NOT require a separate drmDropMaster ioctl on graceful + shutdown." +- **Plan 5 follow-up (LOAD-BEARING).** Plan 5 v1 records + `EVIOCGRAB(1)` as `i.grabbed = 1` without enforcing cross-OFD + exclusivity on subsequent reads (plan 5 review lines 224-230 + documented this as "plan 9 closes the hazard"). Plan 9's open- + architecture #2 resolution **(a)** ships the enforcement — + ~20 LoC in plan 5's `sys_read` arm: if any other OFD on the + same device holds `grabbed = 1`, return 0 / EAGAIN. The change + is plan-5-amendment, landed as part of plan 9's Phase A (or + as a plan 5 follow-up PR before plan 9 opens). Note added to + plan 5's review under a new subsection "Cross-plan amendment + from plan 9's devil's-advocate — EVIOCGRAB enforcement". +- **Plan 7 follow-up (resolution of open-arch #1).** Plan 7's + open-architecture #1 (SDL2 audio thread model) is **picked + this session: option (b) — non-blocking WRITEI + SDL2 + polling patch + plan 6 EAGAIN return arm.** Rationale: option + (a) (libpthread shim) is heaviest; option (c) (defer audio) + is a feature regression; option (b) is ~150 LoC SDL2 patch + + the plan 6 EAGAIN arm and matches plan 6's existing non- + blocking surface. Plan 9's compositor doesn't manage audio + (audio clients hit /dev/snd/* directly), so this decision is + independent of plan 9 mechanically but resolves the last + outstanding open-architecture item in the plan chain. Note + added to plan 7's review under a new subsection "Cross-plan + amendment from plan 9's devil's-advocate — audio thread + resolution: option (b)". +- **Plan 7 follow-up — KMS-master coexistence.** Plan 7's SDL2 + KMSDRM demo (PR #2) and plan 9's compositor both take KMS + master. Plan 7's review does NOT call this out; reviewers + could miss the implication. Note added to plan 7's review: + "SDL2 KMSDRM demo and wpkcompositor are mutually exclusive + in v1 — both call `drmSetMaster` and only one can hold it + at a time. The boot-ordering invariant (init starts compositor + at PID 2; SDL2 demos start as PID 3+ and hit EBUSY if the + compositor is running) is documented in plan 9 risk register + #2 + the SDL_wpkvideo demote path in plan 11." +- **Plan 8 follow-up.** Plan 8's review fix #4 (cache both + gbm_bo_map mappings at create-time, swap pointer on present, + no remap) **extends to plan 9 E1's `wpk_surface_present_via_compositor`.** + Plan 9 E1's snippet re-introduces the per-present remap. + Note added to plan 8's review under a new subsection + "Cross-plan amendment from plan 9's devil's-advocate — fix + #4 extension to compositor-client mode": at impl time, the + cached-mapping invariant applies to BOTH direct-KMS and + compositor-client surface present paths. +- **Plan 6 follow-up — EAGAIN return arm for audio writei.** As + part of plan 7's open-arch #1 resolution (b), plan 6's ALSA + PCM `WRITEI` ioctl path needs an explicit EAGAIN return arm + for non-blocking writei when the kernel-side queue is full. + Plan 6's review covers this conceptually under "ALSA non- + blocking", but the explicit EAGAIN-vs-EBUSY distinction + wasn't pinned. Note added to plan 6's review under a new + subsection "Cross-plan amendment from plan 9's devil's- + advocate — explicit EAGAIN arm for SDL2 audio polling". + +### Cross-plan amendments from plan 10's devil's-advocate (added during session 11) + +- **Plan 10 follow-up (LOAD-BEARING) — expose + `wpk_client_get_fd` accessor.** Plan 10 main.c's poll-loop + integration calls `wpk_client_get_fd(cl)` to feed the + compositor socket into `poll(2)` alongside the shell pipe; + plan 9's public header (lines 1366-1410) exposes no fd + accessor. The internal `struct wpk_client` carries `.fd` + (line 1433) but it's not reachable across the API boundary. + Add to the public header: `int wpk_client_get_fd(struct + wpk_client *c);` (one-line body: `return c ? c->fd : -1;`). + Required for plan 10 implementation start. Plan 10 inline + fix #2. +- **Plan 10 follow-up (LOAD-BEARING) — extend + `wpk_client_attach_buffer` signature with `stride: u32`.** + Plan 9 inline fix #2 commits the wire-format + `wpk_msg_attach_buffer` payload to carry `stride: u32`, but + the C API at line 1378 / 1518 still takes only + `(c, surface_id, prime_fd)`. Without the parameter, plan 9 + fix #2 is incomplete on the client side and the compositor + has to recompute `width * 4` — re-introducing the bug. + Extend the signature to `(c, surface_id, prime_fd, uint32_t + stride)`; libwpkdraw's `wpk_surface_present_via_compositor` + (E1 line 2039) forwards `gbm_bo_get_stride(s->bo_back)`. + Required for plan 9 fix #2 closure AND plan 10 first non- + trivial render. Plan 10 inline fix #3. +- **Plan 10 follow-up — `SOCK_CLOEXEC` on the client-side + socket.** Plan 9's `wpk_client_connect` body (lines 1439- + 1455) calls `socket(AF_UNIX, SOCK_STREAM, 0)`. Without + `SOCK_CLOEXEC`, the compositor fd leaks into every external + command wpkshell fork-execs, bloating the OFD table and + exposing the compositor protocol stream to unrelated + programs. Amend to `socket(AF_UNIX, SOCK_STREAM | + SOCK_CLOEXEC, 0)`. Symmetric to plan 9 inline fix #5 + (`SOCK_CLOEXEC` on `accept4`). Plan 10 inline fix #5. +- **Plan 10 follow-up (escape hatch) — optional connect-retry + loop in `wpk_client_connect`.** Plan 10 inline fix #12 + resolves the init→wpkshell sequencing race init-side + (init `access`-polls `/run/wpk/comp` before exec'ing + wpkshell). The libwpkclient-side escape hatch is a + 10×50 ms retry on ENOENT inside `wpk_client_connect` + before returning NULL (≈8 lines). Lean: ship the init-side + fix first; add the libwpkclient retry only if the init + poll proves flaky in practice. Not LOAD-BEARING under the + init-side resolution. + +### Cross-plan amendments from plan 11's devil's-advocate (added during session 12) + +Plan 11 (`docs/plans/2026-07-27-wpk-seed-apps-plan.md`) ships +four seed apps over the libwpkclient + compositor surface +(wpkfm + libwpkdraw_widgets, SDL_wpkvideo + wpkcube, wpkbeep, +wpkpanel). Two LOAD-BEARING architecture items leak back into +plan 9. + +- **Plan 11 follow-up (LOAD-BEARING) — + `WPK_CLIENT_FOCUS_CHANGED` event variant + + `focus.title[64]` event-union extension.** Plan 11 task E2 + lines 1218–1222 read `ev[i].focus.title`. Plan 9's event + union (lines 1437–1451) holds only `{ uint32_t surface_id }` + for focus events (`FOCUS_IN`, `FOCUS_OUT`). Plan 11 needs a + new variant `WPK_CLIENT_FOCUS_CHANGED` carrying a string + title for wpkpanel's active-application readout. One of + plan 9's `_RESERVED_FOR_V2_*` slots becomes + `WPK_CLIENT_FOCUS_CHANGED`; the union gains a + `struct { uint32_t surface_id; char title[64]; } focus;` + arm. 24-message inventory cap is NOT bumped (one reserved + slot consumed). The compositor's D4 focus-dispatch (lines + 1864–1895) broadcasts the event unconditionally on every + focus-surface change to all connected clients — no opt-in + subscribe API needed in v1 (clients that don't care drop + it in their poll loop). If a future plan wants opt-in, add + `wpk_client_subscribe_focus(cl)`; v1 doesn't. Required for + plan 11 PR #4 (wpkpanel). Plan 11 Architecture #2. +- **Plan 11 follow-up (LOAD-BEARING) — extract `place_window` + as an explicit function + `WPK_SURFACE_TYPE_PANEL` + exemption + `w == 0` fullscreen-width negotiation + + companion `wpk_client_set_surface_type` API.** Plan 9 D6 + places windows via a hardcoded cascade (lines 2248–2250: + "(50, 50), (250, 200), …"). Plan 11 task E4 needs a hook + to reserve `PANEL_RESERVED_PX = 24` from the top of the + output for new windows, AND a way for wpkpanel itself to + place at `(0, 0)` exempt from the reserve. Amendment: + refactor placement into an explicit + `place_window(compositor, surface) → (x, y)` helper in D6; + surfaces with `type == WPK_SURFACE_TYPE_PANEL` skip the + reserve and place at `(0, 0)`. Additionally: a surface + created with `w == 0` is negotiated to the full screen + width minus reserved struts and the negotiated value flows + back to the client via `wpk_client_create_surface`'s reply + (this resolves wpkpanel's `screen_w` heuristic from plan + 11 E1 line 1167 without adding a separate output-size + query API). Companion client API: + `int wpk_client_set_surface_type(struct wpk_client *c, + uint32_t surface_id, uint32_t type);` over plan 9's + existing `SET_TYPE` wire message — wire format unchanged, + C wrapper added. New constants: + `WPK_SURFACE_TYPE_NORMAL` (0, default) and + `WPK_SURFACE_TYPE_PANEL` (1). Future plans may add + `_DIALOG`, `_TOOLTIP`, etc. Required for plan 11 PR #4 + (wpkpanel + compositor work-area cooperation). Plan 11 + Architecture #3 (collapses plan 11 inline fix #9). + +--- + +## Phase A — sysroot: libxkbcommon (PR #1) + +Port upstream libxkbcommon 1.6.0 (latest stable as of 2026-05) as +a static library. Compositor + (future) SDL2's xkbcommon-based +keymap path both link this. + +### Task A1: Package scaffold + +**Files:** +- Create: `examples/libs/libxkbcommon/package.toml` — recipe. +- Create: `examples/libs/libxkbcommon/build.toml` — build state. +- Create: `examples/libs/libxkbcommon/build.sh` — build script (stub). + +```toml +# examples/libs/libxkbcommon/package.toml +name = "libxkbcommon" +version = "1.6.0" +license = "MIT" +description = "XKB keymap library — subset for compositor + SDL2" + +[source] +type = "git" +url = "https://github.com/xkbcommon/libxkbcommon.git" +commit = "xkbcommon-1.6.0" + +[deps] +# No external deps — libxkbcommon is self-contained over the C +# standard library + a tiny embedded XKB data set. We vendor the +# keymap data files (`/usr/share/X11/xkb/`) as part of this +# package's archive. + +[build] +script_path = "build.sh" +``` + +```toml +# examples/libs/libxkbcommon/build.toml +script_path = "build.sh" +repo_url = "https://github.com/xkbcommon/libxkbcommon.git" +commit = "xkbcommon-1.6.0" +revision = 1 + +[binary] +index_url = "https://github.com/<repo>/releases/download/binaries-abi-v{abi}/index.toml" +``` + +```bash +#!/usr/bin/env bash +# examples/libs/libxkbcommon/build.sh +set -euo pipefail +. "$WPK_WORKTREE/sdk/activate.sh" + +SRC_DIR="$1" +OUT_DIR="$2" +WORK="$OUT_DIR/build" +mkdir -p "$WORK/lib" "$WORK/include/xkbcommon" + +# A2 fills in the actual subset + compile. +echo "TODO A2: extract xkbcommon subset + cross-compile" +exit 1 +``` + +**Commit:** `sysroot(input): scaffold libxkbcommon package` + +### Task A2: libxkbcommon — hand-rolled Makefile subset + +libxkbcommon's meson build pulls in test programs + Compose + the +XML keymap parser. We strip to the essentials: + +- **Keep:** `src/atom.c`, `src/context.c`, `src/keymap.c`, + `src/keysym.c`, `src/state.c`, `src/utils.c`, `src/scanner-utils.c`, + `src/utf8.c`, `src/keymap-priv.c`, `src/keymap-format-text-v1.c` + (the v1 XKB text keymap parser), `src/x11/atom.c` (no, drop — + X11-specific), the generated `src/parser.c` + `src/scanner.c` + (pre-generated bison/flex artifacts; we don't run bison/flex + in the build). +- **Drop:** `src/compose/`, `src/registry/`, `src/x11/`, `tools/`, + `test/`, `bench/`. + +Approximate file count: 15 `.c` files, ~6000 LoC of meaningful +implementation. + +```bash +SRC="$SRC_DIR/src" +KEEP_C=( + atom.c context.c keymap.c keysym.c state.c utils.c + scanner-utils.c utf8.c keymap-priv.c + keymap-format-text-v1.c + parser.c scanner.c # pre-generated artifacts in upstream tree +) + +for f in "${KEEP_C[@]}"; do + cp "$SRC/$f" "$WORK/" +done +cp -r "$SRC_DIR/include/xkbcommon" "$WORK/include/" + +# libxkbcommon uses an internal `xkbcommon/xkbcommon-priv.h`. Copy +# the priv headers too. +mkdir -p "$WORK/include/_priv" +cp "$SRC/"*.h "$WORK/include/_priv/" + +cd "$WORK" +wasm32posix-cc -c -O2 \ + -I./include -I./include/_priv \ + -DDFLT_XKB_CONFIG_ROOT='"/usr/share/X11/xkb"' \ + -DDFLT_XKB_CONFIG_EXTRA_PATH='""' \ + -DXLOCALEDIR='"/usr/share/locale"' \ + -DDEFAULT_XKB_LAYOUT='"us"' \ + -DDEFAULT_XKB_MODEL='"pc105"' \ + -DDEFAULT_XKB_VARIANT='""' \ + -DDEFAULT_XKB_OPTIONS='""' \ + "${KEEP_C[@]}" +llvm-ar rcs "$OUT_DIR/lib/libxkbcommon.a" *.o +cp -r include/xkbcommon "$OUT_DIR/include/" +``` + +**Cargo test:** `cargo xtask build-deps resolve libxkbcommon` exits 0; +`$OUT_DIR/lib/libxkbcommon.a` exists; smoke-link a tiny C file +calling `xkb_context_new(0)`. + +**Commit:** `sysroot(input): libxkbcommon — hand-rolled Makefile subset (~150 KB static lib)` + +### Task A3: XKB data files + +The keymap parser reads `/usr/share/X11/xkb/{symbols,types,rules, +keycodes,compat}` text files at runtime. Vendor the minimal set: + +- `symbols/us` — US keyboard symbols (~150 lines). +- `symbols/pc` — PC base symbol map. +- `types/complete` — modifier types. +- `keycodes/evdev` — evdev keycode names (~250 lines). +- `compat/complete` — compatibility map. +- `rules/evdev` — rules engine config. + +Total ~2 MB across ~30 files. Vendor under +`examples/libs/libxkbcommon/share/`; install to +`$OUT_DIR/share/X11/xkb/` at build time. + +License: MIT/X consortium — permissive, ship LICENSE alongside. + +**Commit:** `sysroot(input): libxkbcommon — XKB data files (us layout + evdev keycodes)` + +### Task A4: xkbcommon smoke program + +```c +// programs/xkbcommon_smoke.c +#include <xkbcommon/xkbcommon.h> +#include <stdio.h> +int main(void) { + struct xkb_context *ctx = xkb_context_new(XKB_CONTEXT_NO_FLAGS); + if (!ctx) return 1; + struct xkb_rule_names names = { + .rules = "evdev", .model = "pc105", .layout = "us", + .variant = "", .options = "" + }; + struct xkb_keymap *km = xkb_keymap_new_from_names( + ctx, &names, XKB_KEYMAP_COMPILE_NO_FLAGS); + if (!km) { xkb_context_unref(ctx); return 2; } + /* KEY_A = 38 evdev keycode → XKB keysym 'a' */ + xkb_keysym_t sym = xkb_keymap_key_get_sym(km, 38 + 8, 0); + char buf[16]; + xkb_keysym_get_name(sym, buf, sizeof buf); + printf("KEY_A → %s\n", buf); + xkb_keymap_unref(km); + xkb_context_unref(ctx); + return 0; +} +``` + +**Vitest:** assert stdout has `KEY_A → a` (the lowercase letter +'a'); exit 0. + +**Commit:** `examples(input): xkbcommon_smoke — keymap compile + keysym lookup` + +### Task A5: Phase A — full gauntlet + open PR #1 + +PR title: `[explore-dri] sysroot(input): libxkbcommon — real upstream port (1.6.0)` + +Body covers: subset extraction rationale, XKB data files vendored, +smoke test exercises layout compile + keysym lookup. ABI impact: +none. + +--- + +## Phase B — sysroot: libinput (PR #2) + +Port upstream libinput 1.25.0 as a static library, replacing plan +7's `libinput-lite` stub. Compositor is the v1 sole consumer. + +### Task B1: Package scaffold + +```toml +# examples/libs/libinput/package.toml +name = "libinput" +version = "1.25.0" +license = "MIT" +description = "Generic input library — replaces plan 7's libinput-lite stub" + +[source] +type = "git" +url = "https://gitlab.freedesktop.org/libinput/libinput.git" +commit = "1.25.0" + +[deps] +libevdev = "1.13.1" # B2 ships this as a separate sub-package + +[build] +script_path = "build.sh" +``` + +```toml +# examples/libs/libinput/build.toml +script_path = "build.sh" +repo_url = "https://gitlab.freedesktop.org/libinput/libinput.git" +commit = "1.25.0" +revision = 1 + +[binary] +index_url = "https://github.com/<repo>/releases/download/binaries-abi-v{abi}/index.toml" +``` + +**Commit:** `sysroot(input): scaffold libinput + libevdev packages` + +### Task B2: libevdev — sub-package + +libinput requires libevdev for `struct input_event` parsing + event +queue management. Plan 5's evdev surface gives us the raw records; +libevdev adds the per-device state tracking (which keys are +currently pressed, which axes have absinfo, etc.). + +```toml +# examples/libs/libevdev/package.toml +name = "libevdev" +version = "1.13.1" +license = "MIT" +description = "Event device library — userspace-side input event parsing" + +[source] +type = "git" +url = "https://gitlab.freedesktop.org/libevdev/libevdev.git" +commit = "1.13.1" + +[deps] +# No external deps — pure libc. + +[build] +script_path = "build.sh" +``` + +Build script extracts the subset: + +- **Keep:** `libevdev/libevdev.c`, `libevdev/libevdev-uinput.c` (no + — uinput-only, drop), `libevdev/libevdev-names.c`, + `libevdev/libevdev-util.c`. +- **Drop:** `tools/`, `test/`, `doc/`. + +Compile with `wasm32posix-cc -O2`; archive to +`sysroot/lib/libevdev.a`. + +**Commit:** `sysroot(input): libevdev — userspace event device parsing` + +### Task B3: libinput — actual build + +libinput's meson build wraps many drivers (touchpad, tablet, +trackpoint, switch, …). We need: keyboard, pointer, gesture, +touch. Skip tablet/trackpoint for v1. + +```bash +SRC="$SRC_DIR/src" +KEEP_C=( + # Core + libinput.c libinput-util.c libinput-private.c + libinput-version.c + # Path-mode (no libudev) + path-seat.c + # Generic device evdev parsing + evdev.c evdev-fallback.c evdev-debounce.c + # Pointer + evdev-mt-touchpad.c # but skip tablet + evdev-mt-touchpad-gestures.c + evdev-mt-touchpad-tap.c + evdev-mt-touchpad-buttons.c + evdev-mt-touchpad-edge-scroll.c + # Filter + filter.c filter-low-dpi.c filter-mouse.c filter-touchpad.c + filter-trackpoint-flat.c + # Keyboard + evdev-fallback.c # keyboards use the fallback driver + # Quirks + quirks.c + # Timer subsystem + timer.c +) + +cd "$WORK" +wasm32posix-cc -c -O2 \ + -I./include -I"$WPK_SYSROOT/include/libevdev-1.0" \ + -DHAVE_LIBUNWIND=0 \ + -DHAVE_LIBWACOM=0 \ + -DHAVE_LIBUDEV=0 \ + -DDEFAULT_QUIRKS_DIR='"/usr/share/libinput"' \ + -DLIBINPUT_DATA_DIR='"/usr/share/libinput"' \ + "${KEEP_C[@]}" +llvm-ar rcs "$OUT_DIR/lib/libinput.a" *.o +cp -r "$SRC_DIR/src/libinput.h" "$OUT_DIR/include/" +``` + +**Risk:** libinput is ~50 kLoC; the subset may need iteration as +unresolved symbols surface during the smoke link in B4. Expect +2-3 build rounds before clean. + +**Commit:** `sysroot(input): libinput — subset compile (keyboard + pointer + gesture; no tablet/trackpoint)` + +### Task B4: libinput quirks data + +libinput reads quirks files at `/usr/share/libinput/*.quirks` — +small INI-ish format describing device-specific tuning (per-device +acceleration profiles, palm rejection thresholds). v1 ships a +minimal default-quirks file covering the synthetic evdev devices +plan 5 creates (kbd at event0, ptr at event1 with `BUS_VIRTUAL`). + +Vendor: `examples/libs/libinput/share/30-default-virtual.quirks`: + +```ini +[Generic VIRTUAL Keyboard] +MatchBus=virtual +MatchUdevType=keyboard +ModelGenericKeyboard=1 + +[Generic VIRTUAL Pointer] +MatchBus=virtual +MatchUdevType=mouse +ModelGenericMouse=1 +``` + +Install to `$OUT_DIR/share/libinput/30-default-virtual.quirks`. + +**Commit:** `sysroot(input): libinput — default quirks for v1 BUS_VIRTUAL devices` + +### Task B5: libinput smoke program + +```c +// programs/libinput_smoke.c — adds event0 + event1, reads N +// events, prints types. +#include <libinput.h> +#include <fcntl.h> +#include <unistd.h> +#include <stdio.h> +#include <string.h> + +static int open_restricted(const char *path, int flags, void *user) { + (void)user; return open(path, flags); +} +static void close_restricted(int fd, void *user) { (void)user; close(fd); } + +static const struct libinput_interface ifc = { + .open_restricted = open_restricted, + .close_restricted = close_restricted, +}; + +int main(void) { + struct libinput *li = libinput_path_create_context(&ifc, NULL); + libinput_path_add_device(li, "/dev/input/event0"); + libinput_path_add_device(li, "/dev/input/event1"); + for (int i = 0; i < 20; i++) { + libinput_dispatch(li); + struct libinput_event *ev; + while ((ev = libinput_get_event(li))) { + enum libinput_event_type t = libinput_event_get_type(ev); + printf("event type: %d\n", t); + libinput_event_destroy(ev); + } + } + libinput_unref(li); + return 0; +} +``` + +**Vitest:** harness injects 5 keyboard presses + 5 pointer motions +via plan 5's `kernel_input_event`; assert the smoke prints 10+ +event types (LIBINPUT_EVENT_KEYBOARD_KEY + LIBINPUT_EVENT_POINTER_MOTION). + +**Commit:** `examples(input): libinput_smoke — event0 + event1 via path-mode` + +### Task B6: Phase B — full gauntlet + open PR #2 + +PR title: `[explore-dri] sysroot(input): libinput + libevdev — real upstream port replacing libinput-lite` + +Body: subset selection, libudev-disabled path-mode, quirks file, +smoke test confirms libinput dispatches events from plan 5's +seat-shared fanout cleanly. + +--- + +## Phase C — sysroot: libwpkclient (PR #3) + +Static library that SDL2 + wpkdraw + (any future client) links to +talk to the compositor. + +### Task C1: Package scaffold + public headers + +```toml +# examples/libs/libwpkclient/package.toml +name = "libwpkclient" +version = "0.1.0" +license = "MIT" +description = "Client-side wire to wpkcompositor (custom protocol over AF_UNIX)" + +[source] +type = "local" + +[deps] +# No external deps — talks directly to AF_UNIX + SCM_RIGHTS via libc. + +[build] +script_path = "build.sh" +``` + +```c +// include/wpkclient/wpkclient.h +#ifndef WPKCLIENT_H +#define WPKCLIENT_H + +#include <stdint.h> + +struct wpk_client; + +/** Connect to the compositor at /run/wpk/comp. Returns NULL on + * failure (errno set: ENOENT = no compositor; EACCES = permission; + * ECONNREFUSED = compositor not listening). Caller should fall + * back to a non-compositor path on NULL. */ +struct wpk_client *wpk_client_connect(void); + +void wpk_client_disconnect(struct wpk_client *c); + +/** Allocate a surface_id on the compositor. Returns 0 on failure + * (errno set). */ +uint32_t wpk_client_create_surface(struct wpk_client *c, + int width, int height, + uint32_t format); + +/** Send ATTACH_BUFFER + the prime fd via SCM_RIGHTS. Returns 0 + * on success, -1 on failure. */ +int wpk_client_attach_buffer(struct wpk_client *c, + uint32_t surface_id, int prime_fd); + +/** Send COMMIT — atomic "this buffer is now the surface". */ +int wpk_client_commit(struct wpk_client *c, uint32_t surface_id); + +int wpk_client_set_title(struct wpk_client *c, uint32_t surface_id, + const char *title); +int wpk_client_set_type(struct wpk_client *c, uint32_t surface_id, + int type); /* 0 = toplevel, 1 = popup, 2 = panel */ +int wpk_client_destroy_surface(struct wpk_client *c, uint32_t surface_id); + +/** Event drain. Returns count written into out_ev[]. */ +struct wpk_client_event { + enum { + WPK_CLIENT_KEY, + WPK_CLIENT_POINTER_MOTION, + WPK_CLIENT_POINTER_BUTTON, + WPK_CLIENT_FOCUS_IN, WPK_CLIENT_FOCUS_OUT, + WPK_CLIENT_WINDOW_CLOSE, + } type; + union { + struct { int keycode; uint32_t keysym; int pressed; int modifiers; } key; + struct { int x, y; uint32_t surface_id; } pointer_motion; + struct { int button; int pressed; uint32_t surface_id; } pointer_button; + struct { uint32_t surface_id; } focus_in, focus_out, window_close; + }; +}; + +int wpk_client_poll(struct wpk_client *c, + struct wpk_client_event *out_ev, int max_events); + +#endif /* WPKCLIENT_H */ +``` + +**Commit:** `sysroot(wpk): scaffold libwpkclient package + public headers` + +### Task C2: Wire format implementation + +```c +// src/wire.c — ~120 LoC +#define _GNU_SOURCE +#include <wpkclient/wpkclient.h> +#include <errno.h> +#include <fcntl.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/socket.h> +#include <sys/un.h> +#include <unistd.h> + +#include "wpk_wire.h" /* internal — shared with the compositor's parser */ + +struct wpk_client { + int fd; + /* Inbound event queue, drained by wpk_client_poll. */ + struct wpk_client_event events[64]; + int event_head, event_tail; +}; + +struct wpk_client *wpk_client_connect(void) { + int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) return NULL; + struct sockaddr_un addr = { .sun_family = AF_UNIX }; + strncpy(addr.sun_path, "/run/wpk/comp", sizeof addr.sun_path - 1); + if (connect(fd, (struct sockaddr *)&addr, sizeof addr) < 0) { + close(fd); + return NULL; + } + struct wpk_client *c = calloc(1, sizeof *c); + if (!c) { close(fd); errno = ENOMEM; return NULL; } + c->fd = fd; + return c; +} + +void wpk_client_disconnect(struct wpk_client *c) { + if (!c) return; + close(c->fd); + free(c); +} + +/* Serialise + send a wire frame. The frame header is + * (u32 length | u32 type); payload follows. */ +static int send_frame(struct wpk_client *c, uint32_t type, + const void *payload, size_t payload_len) { + struct wpk_msg hdr = { + .length = sizeof hdr + payload_len, + .type = type, + }; + struct iovec iov[2] = { + { &hdr, sizeof hdr }, + { (void *)payload, payload_len }, + }; + struct msghdr msg = { .msg_iov = iov, .msg_iovlen = 2 }; + if (sendmsg(c->fd, &msg, 0) < 0) return -1; + return 0; +} + +/* Variant with an SCM_RIGHTS fd attached. */ +static int send_frame_with_fd(struct wpk_client *c, uint32_t type, + const void *payload, size_t payload_len, + int passed_fd) { + struct wpk_msg hdr = { + .length = sizeof hdr + payload_len, + .type = type, + }; + struct iovec iov[2] = { + { &hdr, sizeof hdr }, + { (void *)payload, payload_len }, + }; + union { struct cmsghdr cm; char buf[CMSG_SPACE(sizeof(int))]; } u = {0}; + struct cmsghdr *cmsg = (struct cmsghdr *)u.buf; + cmsg->cmsg_len = CMSG_LEN(sizeof(int)); + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_RIGHTS; + *(int *)CMSG_DATA(cmsg) = passed_fd; + struct msghdr msg = { + .msg_iov = iov, .msg_iovlen = 2, + .msg_control = u.buf, .msg_controllen = sizeof u.buf, + }; + if (sendmsg(c->fd, &msg, 0) < 0) return -1; + return 0; +} + +/* Synchronous request/reply for CREATE_SURFACE. v1 protocol uses a + * blocking call; v2 may add async with a request_id. */ +uint32_t wpk_client_create_surface(struct wpk_client *c, + int width, int height, + uint32_t format) { + struct wpk_msg_create_surface req = { + .width = width, .height = height, .format = format, + }; + if (send_frame(c, WPK_MSG_CREATE_SURFACE, &req, sizeof req) < 0) + return 0; + struct wpk_msg_create_surface_reply rep; + if (read(c->fd, &rep, sizeof rep) != (ssize_t)sizeof rep) return 0; + return rep.surface_id; +} + +int wpk_client_attach_buffer(struct wpk_client *c, uint32_t surface_id, + int prime_fd) { + struct wpk_msg_attach_buffer req = { .surface_id = surface_id }; + return send_frame_with_fd(c, WPK_MSG_ATTACH_BUFFER, + &req, sizeof req, prime_fd); +} + +int wpk_client_commit(struct wpk_client *c, uint32_t surface_id) { + struct wpk_msg_commit req = { .surface_id = surface_id }; + return send_frame(c, WPK_MSG_COMMIT, &req, sizeof req); +} + +/* … set_title, set_type, destroy_surface, poll follow similar patterns … */ +``` + +**Commit:** `sysroot(wpk): libwpkclient — wire format + connect/disconnect/CREATE_SURFACE/ATTACH_BUFFER/COMMIT` + +### Task C3: Client poll + event drain + +```c +// src/poll.c +int wpk_client_poll(struct wpk_client *c, + struct wpk_client_event *out_ev, int max_events) { + /* Non-blocking drain of incoming wire frames; route to typed + * events. */ + int n = 0; + struct wpk_msg hdr; + while (n < max_events) { + ssize_t r = recv(c->fd, &hdr, sizeof hdr, MSG_DONTWAIT | MSG_PEEK); + if (r < (ssize_t)sizeof hdr) break; + /* Read the full frame. */ + char payload_buf[1024]; + if (hdr.length > sizeof payload_buf) { + errno = EMSGSIZE; return -1; + } + if (recv(c->fd, payload_buf, hdr.length, 0) != (ssize_t)hdr.length) + break; + const void *payload = payload_buf + sizeof hdr; + switch (hdr.type) { + case WPK_MSG_INPUT_KEYBOARD: { + const struct wpk_msg_input_keyboard *m = payload; + out_ev[n].type = WPK_CLIENT_KEY; + out_ev[n].key.keycode = m->keycode; + out_ev[n].key.keysym = m->keysym; + out_ev[n].key.pressed = m->state; + out_ev[n].key.modifiers = m->modifiers; + n++; + break; + } + case WPK_MSG_INPUT_POINTER_MOTION: { /* … */ break; } + case WPK_MSG_INPUT_POINTER_BUTTON: { /* … */ break; } + case WPK_MSG_FOCUS_IN: { /* … */ break; } + case WPK_MSG_FOCUS_OUT: { /* … */ break; } + case WPK_MSG_WINDOW_CLOSE: { /* … */ break; } + default: break; /* unknown frame; v1 ignores forwards-compat */ + } + } + return n; +} +``` + +**Commit:** `sysroot(wpk): libwpkclient — poll + event drain` + +### Task C4: wpkclient smoke + +```c +// programs/wpkclient_smoke.c +#include <wpkclient/wpkclient.h> +#include <stdio.h> +int main(void) { + struct wpk_client *c = wpk_client_connect(); + if (!c) { + printf("no compositor — expected if compositor not running\n"); + return 0; /* not a failure; tests both paths */ + } + uint32_t sid = wpk_client_create_surface(c, 640, 480, 0x34325241); /* ARGB8888 */ + printf("created surface %u\n", sid); + wpk_client_disconnect(c); + return 0; +} +``` + +**Vitest:** spawn the compositor (via E1 below), then run +`wpkclient_smoke`; assert it prints `created surface 1`. + +**Commit:** `examples(wpk): wpkclient_smoke — connect + CREATE_SURFACE round-trip` + +### Task C5: Phase C — full gauntlet + open PR #3 + +PR title: `[explore-dri] sysroot(wpk): libwpkclient — compositor client wire (custom protocol over AF_UNIX)` + +Body: wire format from design §9.2, SCM_RIGHTS prime-fd attach, +poll/event drain, smoke verifies the connect-or-fallback path. + +--- + +## Phase D — examples: wpkcompositor server (PR #4) + +The compositor itself — PID 2 boot, KMS master, EGL, unix socket, +event loop, surface composition. + +### Task D1: Init amendment — fork-exec wpkcompositor + +**Files:** +- Modify: `examples/init/init.c` (or wherever the existing PID 1 + lives) — add a fork-exec of `/etc/wpk/compositor` if the file + exists, before starting the user shell. +- Create: `examples/programs/wpkcompositor/etc-wpk-compositor` — + one-line config file `/etc/wpk/compositor` (the file's presence + is the trigger; its contents document the binary path). + +```c +/* In init's main loop, after /dev mounts: */ +if (access("/etc/wpk/compositor", F_OK) == 0) { + pid_t pid = fork(); + if (pid == 0) { + execl("/usr/bin/wpkcompositor", "wpkcompositor", NULL); + _exit(127); + } + /* PID 2 is reserved for the compositor; init doesn't wait + * on it (it's a long-lived service). */ +} +/* Then exec user shell as PID 3+. */ +``` + +**Commit:** `examples(wpk): init — fork-exec wpkcompositor as PID 2 if /etc/wpk/compositor exists` + +### Task D2: Compositor scaffold + +**Files:** +- Create: `examples/programs/wpkcompositor/main.c` — top-level + bring-up + event loop skeleton. +- Create: `examples/programs/wpkcompositor/wpk_wire.h` — shared + with libwpkclient. +- Create: `examples/programs/wpkcompositor/Makefile` — wired into + `scripts/build-programs.sh`. + +```c +// main.c — top-level +#define _GNU_SOURCE +#include <fcntl.h> +#include <poll.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/socket.h> +#include <sys/stat.h> +#include <sys/un.h> +#include <unistd.h> + +#include <xf86drm.h> +#include <xf86drmMode.h> +#include <gbm.h> +#include <EGL/egl.h> +#include <GLES2/gl2.h> +#include <libinput.h> +#include <xkbcommon/xkbcommon.h> + +#include "wpk_wire.h" +#include "compositor.h" + +struct compositor C; + +int main(void) { + /* 1. Open card0, take KMS master. */ + C.card0 = open("/dev/dri/card0", O_RDWR | O_CLOEXEC); + if (C.card0 < 0) { perror("open card0"); return 1; } + if (drmSetMaster(C.card0) < 0) { perror("drmSetMaster"); return 1; } + + /* 2. Resolve connector + CRTC + mode (single-output v1). */ + if (compositor_setup_kms(&C) < 0) return 1; + + /* 3. Set up EGL + gbm_surface. */ + C.gbm = gbm_create_device(C.card0); + C.gbm_surface = gbm_surface_create(C.gbm, C.width, C.height, + GBM_FORMAT_ARGB8888, GBM_BO_USE_SCANOUT | GBM_BO_USE_RENDERING); + /* … EGL init, eglCreateWindowSurface(C.gbm_surface), eglMakeCurrent … */ + + /* 4. Unix socket. */ + mkdir("/run/wpk", 0755); + C.sock = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + struct sockaddr_un sa = { .sun_family = AF_UNIX }; + strcpy(sa.sun_path, "/run/wpk/comp"); + unlink("/run/wpk/comp"); /* clean stale */ + if (bind(C.sock, (struct sockaddr *)&sa, sizeof sa) < 0) { + perror("bind"); return 1; + } + listen(C.sock, 16); + + /* 5. libinput context. */ + static const struct libinput_interface ifc = { + .open_restricted = compositor_open_restricted, + .close_restricted = compositor_close_restricted, + }; + C.input = libinput_path_create_context(&ifc, NULL); + libinput_path_add_device(C.input, "/dev/input/event0"); + libinput_path_add_device(C.input, "/dev/input/event1"); + + /* 6. xkbcommon — load default keymap. */ + C.xkb_ctx = xkb_context_new(XKB_CONTEXT_NO_FLAGS); + struct xkb_rule_names names = { + .rules = "evdev", .model = "pc105", .layout = "us", + .variant = "", .options = "" + }; + C.xkb_keymap = xkb_keymap_new_from_names( + C.xkb_ctx, &names, XKB_KEYMAP_COMPILE_NO_FLAGS); + C.xkb_state = xkb_state_new(C.xkb_keymap); + + /* 7. Event loop. */ + return compositor_event_loop(&C); +} +``` + +**Commit:** `examples(wpk): wpkcompositor — scaffold (boot, KMS master, EGL, gbm_surface, socket, libinput, xkb)` + +### Task D3: Surface state + commit + composition + +```c +// surface.c — ~250 LoC +struct compositor_surface { + uint32_t id; + struct compositor_client *owner; + int width, height; + uint32_t format; + struct gbm_bo *pending_bo; /* set on ATTACH_BUFFER, drained on COMMIT */ + struct gbm_bo *current_bo; + int x, y; /* compositor-assigned position */ + int type; /* toplevel | popup | panel */ + char *title; + int focused; +}; + +void compositor_handle_create_surface(struct compositor_client *cl, + const struct wpk_msg_create_surface *m) { + struct compositor_surface *s = calloc(1, sizeof *s); + s->id = compositor_next_surface_id(); + s->owner = cl; + s->width = m->width; + s->height = m->height; + s->format = m->format; + compositor_register_surface(s); + /* Reply with the id. */ + struct wpk_msg_create_surface_reply rep = { .surface_id = s->id }; + send_frame(cl->fd, WPK_MSG_CREATE_SURFACE_REPLY, &rep, sizeof rep); +} + +void compositor_handle_attach_buffer(struct compositor_client *cl, + uint32_t surface_id, int prime_fd) { + struct compositor_surface *s = compositor_find_surface(surface_id); + if (!s || s->owner != cl) { close(prime_fd); return; } + /* Import the prime fd as a gbm_bo. */ + struct gbm_import_fd_data data = { + .fd = prime_fd, .width = s->width, .height = s->height, + .stride = s->width * 4, .format = s->format, + }; + struct gbm_bo *bo = gbm_bo_import(C.gbm, GBM_BO_IMPORT_FD, &data, 0); + close(prime_fd); /* gbm_bo holds a kernel-side ref */ + if (!bo) { perror("gbm_bo_import"); return; } + if (s->pending_bo) gbm_bo_destroy(s->pending_bo); + s->pending_bo = bo; +} + +void compositor_handle_commit(struct compositor_client *cl, + uint32_t surface_id) { + struct compositor_surface *s = compositor_find_surface(surface_id); + if (!s || s->owner != cl || !s->pending_bo) return; + if (s->current_bo) gbm_bo_destroy(s->current_bo); + s->current_bo = s->pending_bo; + s->pending_bo = NULL; + /* Mark dirty; the event loop's next frame composites. */ + compositor_mark_dirty(); +} +``` + +```c +// composite.c — composit all surfaces onto the compositor's +// gbm_surface back buffer, then eglSwapBuffers. +void compositor_render_frame(struct compositor *c) { + glClearColor(0.1f, 0.1f, 0.15f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + /* Iterate surfaces in z-order (panels behind toplevels behind + * popups; v1 has a fixed strip-of-3). For each surface: + * - bind its current_bo as a GL texture (via plan 3's + * foreign-texture path); + * - draw a textured quad at the surface's (x, y, w, h) + * position. + */ + for (struct compositor_surface *s = c->surfaces; s; s = s->next) { + if (!s->current_bo) continue; + compositor_draw_textured_quad(c, s); + } + /* Window decorations: drawn via libwpkdraw (statically linked + * into the compositor binary). Title bar + border per surface. */ + compositor_draw_decorations(c); + eglSwapBuffers(c->egl_dpy, c->egl_surf); +} +``` + +**Commit:** `examples(wpk): wpkcompositor — surface state + CREATE/ATTACH/COMMIT + composition` + +### Task D4: Input dispatch — libinput → focused client + +```c +// input.c +void compositor_handle_libinput_event(struct compositor *c, + struct libinput_event *ev) { + enum libinput_event_type t = libinput_event_get_type(ev); + switch (t) { + case LIBINPUT_EVENT_KEYBOARD_KEY: { + struct libinput_event_keyboard *k = libinput_event_get_keyboard_event(ev); + uint32_t key = libinput_event_keyboard_get_key(k); + enum libinput_key_state state = libinput_event_keyboard_get_key_state(k); + /* xkb translation. */ + xkb_keycode_t kc = key + 8; /* evdev offset */ + xkb_keysym_t sym = xkb_state_key_get_one_sym(c->xkb_state, kc); + xkb_state_update_key(c->xkb_state, kc, + state == LIBINPUT_KEY_STATE_PRESSED ? XKB_KEY_DOWN : XKB_KEY_UP); + uint32_t mods = xkb_state_serialize_mods(c->xkb_state, + XKB_STATE_MODS_EFFECTIVE); + /* Route to focused client. */ + if (c->focus_surface && c->focus_surface->owner) { + struct wpk_msg_input_keyboard m = { + .keycode = key, .keysym = sym, + .state = (state == LIBINPUT_KEY_STATE_PRESSED), + .modifiers = mods, + }; + send_frame(c->focus_surface->owner->fd, + WPK_MSG_INPUT_KEYBOARD, &m, sizeof m); + } + /* Compositor-global hotkeys: Super+Tab cycles focus. */ + if (sym == XKB_KEY_Tab && (mods & XKB_MOD_LOGO)) { + compositor_cycle_focus(c); + } + break; + } + case LIBINPUT_EVENT_POINTER_MOTION: { + struct libinput_event_pointer *p = libinput_event_get_pointer_event(ev); + double dx = libinput_event_pointer_get_dx(p); + double dy = libinput_event_pointer_get_dy(p); + c->cursor_x += dx; c->cursor_y += dy; + compositor_clamp_cursor(c); + /* Hit-test → route to surface under cursor. */ + struct compositor_surface *s = compositor_pick(c, c->cursor_x, c->cursor_y); + if (s && s->owner) { + struct wpk_msg_input_pointer_motion m = { + .surface_id = s->id, + .x = c->cursor_x - s->x, .y = c->cursor_y - s->y, + }; + send_frame(s->owner->fd, WPK_MSG_INPUT_POINTER_MOTION, + &m, sizeof m); + } + compositor_mark_dirty(); /* redraw cursor */ + break; + } + case LIBINPUT_EVENT_POINTER_BUTTON: { /* … focus click + route … */ break; } + /* … gesture / scroll / touch … */ + default: break; + } +} +``` + +**Commit:** `examples(wpk): wpkcompositor — libinput dispatch + xkb keymap + route to focused client` + +### Task D5: Event loop assembly + +```c +// loop.c +int compositor_event_loop(struct compositor *c) { + int li_fd = libinput_get_fd(c->input); + while (!c->quitting) { + /* Build pollfd array: card0 + sock + libinput_fd + per-client fds. */ + struct pollfd fds[2 + 1 + MAX_CLIENTS]; + int nfds = 0; + fds[nfds++] = (struct pollfd){ c->card0, POLLIN, 0 }; + fds[nfds++] = (struct pollfd){ c->sock, POLLIN, 0 }; + fds[nfds++] = (struct pollfd){ li_fd, POLLIN, 0 }; + for (struct compositor_client *cl = c->clients; cl; cl = cl->next) + fds[nfds++] = (struct pollfd){ cl->fd, POLLIN | POLLHUP, 0 }; + + int n = poll(fds, nfds, c->dirty ? 0 : -1); + if (n < 0) { if (errno == EINTR) continue; perror("poll"); return 1; } + + if (fds[0].revents & POLLIN) compositor_drain_drm_events(c); + if (fds[1].revents & POLLIN) compositor_accept_client(c); + if (fds[2].revents & POLLIN) { + libinput_dispatch(c->input); + struct libinput_event *ev; + while ((ev = libinput_get_event(c->input))) { + compositor_handle_libinput_event(c, ev); + libinput_event_destroy(ev); + } + } + /* Per-client drain. */ + for (int i = 3; i < nfds; i++) { + if (fds[i].revents & POLLHUP) { + compositor_drop_client_by_fd(c, fds[i].fd); + continue; + } + if (fds[i].revents & POLLIN) + compositor_drain_client(c, fds[i].fd); + } + + if (c->dirty) { + compositor_render_frame(c); + c->dirty = 0; + } + } + return 0; +} +``` + +**Commit:** `examples(wpk): wpkcompositor — event loop (poll-driven)` + +### Task D6: Window decorations + cursor + +The compositor draws title bars + borders + a software cursor. +Statically links `libwpkdraw.a` for the 2D primitives + DejaVu Sans +font (already vendored by plan 8). + +```c +// decorations.c +void compositor_draw_decorations(struct compositor *c) { + /* Render decorations into an off-screen wpkdraw surface bound + * to the compositor's gbm_surface back-buffer. */ + /* For each surface, draw title bar (24 px top bar) + border. */ + for (struct compositor_surface *s = c->surfaces; s; s = s->next) { + wpk_rect(c->wpk, s->x, s->y - 24, s->width, 24, + s->focused ? WPK_RGB(60, 120, 180) + : WPK_RGB(80, 80, 80)); + if (s->title) + wpk_text(c->wpk, c->font, s->x + 8, s->y - 8, + s->title, WPK_RGB(220, 220, 230)); + } + /* Software cursor. */ + wpk_rect(c->wpk, c->cursor_x, c->cursor_y, 12, 16, + WPK_RGB(255, 255, 255)); + wpk_line(c->wpk, c->cursor_x, c->cursor_y, + c->cursor_x + 11, c->cursor_y + 15, WPK_RGB(0, 0, 0)); +} +``` + +**Commit:** `examples(wpk): wpkcompositor — window decorations + software cursor (libwpkdraw-backed)` + +### Task D7: Phase D — full gauntlet + open PR #4 + +PR title: `[explore-dri] examples(wpk): wpkcompositor — PID 2 server + custom protocol` + +Body: design §9 implementation, 24-message wire from §9.2, libinput + +xkb pipeline, surface composition + decorations + cursor, event- +loop poll architecture, ~1.2 kLoC total. + +--- + +## Phase E — wpkdraw client-mode amendment + demo + browser (PR #5) + +Plan 8's wpkdraw gains a "compositor client mode" so the existing +demos seamlessly switch between direct-KMS (when no compositor) and +compositor-client (when one is present). + +### Task E1: wpkdraw amendment — detect compositor + switch modes + +**Files:** +- Modify: `examples/libs/wpkdraw/src/wpkdraw.c` — add compositor- + client mode under a runtime check; ~50 LoC. + +```c +// In wpkdraw.c — add at the top of wpk_surface_create: +struct wpk_surface *wpk_surface_create(int *out_w, int *out_h) { + /* Detect compositor first. */ + struct wpk_client *cl = wpk_client_connect(); + if (cl) + return wpk_surface_create_via_compositor(cl, out_w, out_h); + /* Fall back to direct KMS master mode (existing plan 8 code). */ + return wpk_surface_create_direct_kms(out_w, out_h); +} + +/* New internal function: allocate bos via renderD128 (no master), + * send to compositor via ATTACH_BUFFER. */ +struct wpk_surface *wpk_surface_create_via_compositor( + struct wpk_client *cl, int *out_w, int *out_h) { + struct wpk_surface *s = calloc(1, sizeof *s); + s->client = cl; + /* Compositor tells us the surface dims (it's authoritative). */ + s->width = 640; s->height = 480; /* default; could be negotiated */ + s->surface_id = wpk_client_create_surface(cl, s->width, s->height, + GBM_FORMAT_ARGB8888); + /* Allocate two bos via renderD128 (no master needed). */ + s->fd_render = open("/dev/dri/renderD128", O_RDWR | O_CLOEXEC); + s->gbm = gbm_create_device(s->fd_render); + s->bo_front = gbm_bo_create(s->gbm, s->width, s->height, + GBM_FORMAT_ARGB8888, + GBM_BO_USE_RENDERING | GBM_BO_USE_LINEAR); + s->bo_back = gbm_bo_create(s->gbm, s->width, s->height, + GBM_FORMAT_ARGB8888, + GBM_BO_USE_RENDERING | GBM_BO_USE_LINEAR); + s->back_pixels = gbm_bo_map(s->bo_back, 0, 0, s->width, s->height, + GBM_BO_TRANSFER_WRITE, NULL, &s->back_map_data); + s->back_stride = gbm_bo_get_stride(s->bo_back); + /* Attach the back bo via prime fd; compositor scans out from + * the *committed* one, which we toggle on each present. */ + if (out_w) *out_w = s->width; + if (out_h) *out_h = s->height; + return s; +} +``` + +```c +/* wpk_surface_present under compositor-client mode: swap bos + + * send ATTACH_BUFFER + COMMIT to the compositor. */ +int wpk_surface_present_via_compositor(struct wpk_surface *s) { + /* Swap front/back. */ + struct gbm_bo *tmp = s->bo_front; + s->bo_front = s->bo_back; + s->bo_back = tmp; + /* Re-acquire back-pixel mapping for the (now) back bo. */ + s->back_pixels = gbm_bo_map(s->bo_back, 0, 0, s->width, s->height, + GBM_BO_TRANSFER_WRITE, NULL, &s->back_map_data); + s->back_stride = gbm_bo_get_stride(s->bo_back); + /* Export the front bo as prime fd. */ + int pfd = gbm_bo_get_fd(s->bo_front); + if (pfd < 0) return -1; + if (wpk_client_attach_buffer(s->client, s->surface_id, pfd) < 0) { + close(pfd); return -1; + } + close(pfd); /* compositor holds a ref via gbm_bo_import */ + return wpk_client_commit(s->client, s->surface_id); +} +``` + +The `wpk_widget_pump_events` in compositor-client mode is also +amended — instead of reading from `/dev/input/event0` + `event1`, +it calls `wpk_client_poll` and converts WPK_CLIENT_KEY / +WPK_CLIENT_POINTER_* events into the existing `wpk_event` shape. +Direct-KMS callers see no change. + +```c +int wpk_widget_pump_events_via_compositor(struct wpk_surface *s, + struct wpk_button *buttons, int n_buttons, + struct wpk_event *out_ev, int max_events) { + struct wpk_client_event cev[64]; + int n = wpk_client_poll(s->client, cev, n_buttons ? max_events : 0); + int out_n = 0; + for (int i = 0; i < n; i++) { + switch (cev[i].type) { + case WPK_CLIENT_KEY: + if (cev[i].key.keycode == KEY_ESC && cev[i].key.pressed) { + out_ev[out_n++].type = WPK_EV_QUIT; + } else { + out_ev[out_n].type = WPK_EV_KEY; + out_ev[out_n].key.keycode = cev[i].key.keycode; + out_ev[out_n].key.pressed = cev[i].key.pressed; + out_n++; + } + break; + case WPK_CLIENT_POINTER_MOTION: + /* Use absolute coords from the compositor's hit-test. */ + out_ev[out_n].type = WPK_EV_POINTER_MOTION; + out_ev[out_n].pointer_motion.x = cev[i].pointer_motion.x; + out_ev[out_n].pointer_motion.y = cev[i].pointer_motion.y; + out_n++; + break; + /* … pointer_button + window_close → quit … */ + default: break; + } + } + return out_n; +} +``` + +**Commit:** `sysroot(wpkdraw): compositor-client mode — surface_create + present + pump_events fall-through` + +### Task E2: wpkdraw_demo in compositor-client mode + +The existing `programs/wpkdraw_demo.c` from plan 8 doesn't need +changes — the wpkdraw library auto-detects the compositor inside +`wpk_surface_create`. The demo just works in both modes. + +Add a new vitest spec `host/test/wpk-multiplex.spec.ts`: +- Spawn wpkcompositor; wait for `/run/wpk/comp` socket. +- Spawn wpkdraw_demo (auto-detects compositor). +- Spawn a second wpkdraw_demo (also auto-detects, gets its own + surface id). +- Assert the compositor renders BOTH demos visibly (count GL draw + calls to the host's gl_draw_textured_quad; expect 2 per frame). +- Assert clicking the first demo's "Cycle" button fires its + on_click, NOT the second demo's. +- Assert ESC on the focused window quits ONLY that window. + +**Commit:** `examples(wpk): wpk-multiplex vitest — two wpkdraw clients under wpkcompositor` + +### Task E3: Manual browser verification (the gate) + +CLAUDE.md item 6. Build the compositor + two demos, wire into +`examples/browser/pages/wpk-multiplex/`. The browser page mounts +the wpkcompositor at PID 2 + two wpkdraw_demo instances; clicking +"Run" boots: + +1. wpkcompositor takes KMS master, listens on `/run/wpk/comp`. +2. wpkdraw_demo #1 connects, creates a 640×480 surface at + compositor-chosen (50, 50). +3. wpkdraw_demo #2 connects, creates a 640×480 surface at + compositor-chosen (250, 200). +4. Both windows render with title-bar decorations (a 24 px blue/ + grey strip with the title text). +5. Mouse over either window highlights its border; clicking + focuses it; keystrokes route to the focused window. +6. ESC on the focused window quits IT, not the other one. +7. Quitting both demos returns the compositor to the empty desktop. +8. Killing the compositor (signal 9 from a test harness) causes + both still-running demos to fall back to direct-KMS mode (but + one fails on `drmSetMaster` EBUSY — the design constraint). + +If the cursor doesn't track, libinput dispatch failed — check +`/dev/input/event1` is being read. + +If decorations don't render, the compositor's `eglSwapBuffers` isn't +hitting plan 4's PAGE_FLIP path — check `host_kms_page_flip` +counts. + +**No commit yet for this task — verification only.** + +### Task E4: Phase E — final gauntlet + open PR #5 + +PR title: `[explore-dri] sysroot(wpkdraw)+examples(wpk): wpkdraw compositor-client mode + multiplex demo + browser spec` + +Body covers: wpkdraw amendment (additive, doesn't break direct-KMS +callers), two-demo vitest, manual browser confirmation of +multiplexing + focus + decorations + ESC routing. ABI impact: none. + +--- + +## Final coordinated merge + +When all five PRs are reviewed and approved, the browser +verification passes: + +1. Re-run the full gauntlet on each PR's branch tip. +2. Squash-merge PR #1 (libxkbcommon) → PR #2's base. +3. Squash-merge PR #2 (libinput) → PR #3's base. +4. Squash-merge PR #3 (libwpkclient) → PR #4's base. +5. Squash-merge PR #4 (wpkcompositor) → PR #5's base. +6. Squash-merge PR #5 (wpkdraw amendment + demo) → plan 8's + `…-wpkdraw-demo` (or wherever plan 8's tip lives at the time). +7. Tag: `[explore-dri-wpkcompositor] plan 9 merged at <sha>` in the + next session-handoff doc. + +**Do not push to upstream until v1 + plans 2–9 are all merged +upstream as a coherent chain.** + +--- + +## Trade-offs already locked in (don't relitigate during implementation) + +- **Custom protocol, not Wayland.** Per design §9.5 — 1 week vs 6 + weeks. Post-v1 Wayland compat layer (`libwayland-server` bridge) + re-uses the same KMS/GBM/multiplexer surface; the work isn't + wasted. 24 message types, no global registry, no version + negotiation, no xdg-shell state machine. +- **libxkbcommon real, not hand-rolled.** Hand-rolled would be + ~3000 LoC of keymap-text parser + symbol tables — error-prone + and error-replicating-Linux-existing-bugs. Port real (1.6.0, + ~6000 LoC subset, ~150 KB static lib). +- **libinput real, not lite stub.** Plan 7's libinput-lite stub + was sufficient for SDL2's basic key+button needs but lacks + gesture detection, palm rejection, multi-device fan-out + semantics. Compositor needs these. Port real (1.25.0, ~50 kLoC + upstream, ~400 KB subset). +- **Path-mode libinput, no libudev.** v1 has no udev daemon; + libinput's `libinput_path_create_context` + explicit + `libinput_path_add_device("/dev/input/event0", …)` / + `event1` works. No device hotplug in v1 (the canvas-as-display + abstraction is fixed at boot). +- **No tablet / trackpoint / touchscreen drivers in libinput + subset.** Keyboard + pointer only. v2 may add. +- **Compositor is single-threaded, single-poll.** No worker + threads, no GL command queueing beyond plan 3's submit queue. + Per design §9 + the no-pthreads rule. +- **24 message types, hand-rolled C serialiser.** No protobuf, + no flatbuffers. Wire format is `(u32 length | u32 type | u8 + payload[])`; payload is a `repr(C)` struct per message type. + SCM_RIGHTS on AF_UNIX for prime-fd attach. +- **Per-client state in a `struct compositor_client *` linked + list.** No map, no hash table — N clients in v1 is ≤ 16 + (limited by `listen(sock, 16)`); linear scan is fine. +- **Window placement is compositor-chosen.** v1 doesn't expose + a "set position" message; the compositor picks (50, 50), + (250, 200), … in a cascade. v2 may add xdg-positioner. +- **Software cursor.** Hardware cursor planes are a v2 feature + (would require plan 4 to expose `MODE_CURSOR2`). +- **PID 2 is the compositor.** Init reserves the slot via the + fork-exec amendment. Plan 11 (seed apps) start at PID 3+. +- **Compositor crash → clients fall back to direct-KMS** (where + possible). One client can recover; subsequent clients hit + EBUSY on `drmSetMaster` — they exit with an error message. + v2: a compositor-restart watchdog is post-v1. +- **No multi-monitor.** Single CRTC, single connector, single + mode — matches plans 4's invariant. +- **`gbm_surface_*` from plan 2's follow-up.** Compositor uses + `gbm_surface_create_with_modifiers` + `gbm_surface_lock_front_buffer` + for its own back-buffer rotation; clients use raw `gbm_bo`s. +- **Compositor links libwpkdraw statically for decorations.** + Title bars + cursor + borders use plan 8's 2D primitives + the + DejaVu Sans font. No new font dependency. +- **No animation framework.** Compositor's compositing is per- + frame redraw on `dirty` flag; no tween, no animation timer. +- **Zero ABI impact.** No kernel exports, no host imports, no + ioctls, no device nodes added. All surface is userspace + + existing SCM_RIGHTS + existing KMS/GBM/evdev/sockets surfaces. + +--- + +## Risk register + +1. **plan-6-sockets-plan's SCM_RIGHTS path may not be landed when + plan 9 implementation starts.** The plan exists in + `docs/plans/2026-03-08-phase6-sockets-plan.md` and is + load-bearing for the compositor's prime-fd attach. *Mitigation:* + audit + complete the sockets plan BEFORE plan 9 implementation + opens; if not landed, plan 9 carries an inline sub-plan to ship + it (extending phase scope by ~1 week). +2. **libxkbcommon + libinput cross-compile under wasm32posix-cc.** + Both are real upstream code with meson build systems; meson + + wasm32 is a known footgun (plan 7 A2 line 648 explicitly + bypasses meson). Hand-rolled Makefile per A2 + B3 expected to + take 2-3 build rounds for clean subset compile. *Mitigation:* + budget 2 extra days in Phase A + Phase B for symbol-resolution + iteration. +3. **Compositor's EGL + gbm_surface ordering with plan 4's vblank + tick.** The compositor calls `eglSwapBuffers(gbm_surface)` + internally; the EGL stub calls `gbm_surface_lock_front_buffer`; + the resulting `MODE_PAGE_FLIP` fires plan 4's vblank tick on + the next host RAF. If the tick fires *during* the next + `eglSwapBuffers`, the lock-front-buffer call might see the + previous bo still locked (PAGE_FLIP outstanding). *Mitigation:* + the compositor's event loop drains `card0` POLLIN BEFORE + calling `compositor_render_frame`, so FLIP_COMPLETE records + are consumed first. Verify in Phase E vitest. +4. **libinput's filter chain is sluggish for v1's synthetic + evdev devices.** libinput's acceleration profiles + palm + rejection are tuned for real hardware (mouse jitter, touch + noise); on plan 5's BUS_VIRTUAL synthetic devices the inputs + are perfectly clean and libinput may over-process. *Mitigation:* + the quirks file (B4) marks the devices as + `ModelGenericKeyboard=1` / `ModelGenericMouse=1`, which + disables most filtering. Profile in E3; revisit if cursor + feels laggy. +5. **xkbcommon data files (2 MB at `/usr/share/X11/xkb/`)** bloat + the rootfs. v1's WordPress demo rootfs is ~30 MB; +2 MB is + marginal. *Mitigation:* strip to absolute minimum (us layout + only, no compose, no extras). Already done in A3. +6. **Wire format versioning.** v1's protocol has no version + negotiation; if v2 changes a message struct, client + server + must be rebuilt together. *Mitigation:* document as a v1 + constraint; reserve a `WPK_MSG_VERSION` message type for v2. + No code in v1. +7. **Compositor as PID 2 vs existing init.** The amendment to + init in D1 must NOT break the WordPress demo's boot path + (which doesn't ship `/etc/wpk/compositor`). *Mitigation:* the + check is `access("/etc/wpk/compositor", F_OK) == 0` — absent + file = no compositor; matches existing behaviour byte-for-byte. +8. **wpkdraw + SDL2 contending for compositor focus.** In Phase + E's two-client demo, both clients receive INPUT_KEYBOARD when + focused; the unfocused one receives nothing. Focus cycle is + `Super+Tab` (hardcoded in D4). *Mitigation:* document the + focus model + the hotkey in the compositor's --help (which + v1 doesn't have; defer to v2). +9. **`/run/wpk/comp` socket leaks if the compositor SIGKILLed.** + `unlink("/run/wpk/comp")` happens at compositor bind time, so + a re-launch will clear stale state. *Mitigation:* sufficient + for v1. +10. **GL context loss during compositor → client switch.** Plan + 3's per-OFD GL context model means each client has its own + context (texture upload, shader, FBO). The compositor's + sampling of a client's bo as a foreign texture requires plan + 3 A4's `WPK_BIND_FOREIGN_TEXTURE`. *Mitigation:* plan 3 already + ships this; verify the binding works through + `gbm_bo_import` → host-side WebGLTexture mapping → compositor's + GL context sees the texture. + +--- + +## What this plan doesn't cover (deferred) + +- **Wayland wire compatibility** (post-v1, §15) — bridges + `libwayland-server` to this plan's custom wire so unmodified + Wayland apps work. ~6 weeks. +- **SDL2 backend amendment for the wpk protocol.** Plan 11 (seed + apps) ships an SDL2 video backend `SDL_wpkvideo.c` that uses + libwpkclient instead of the KMSDRM path. v1's SDL2 demo (plan 7) + uses KMSDRM directly and is incompatible with the compositor; + v2's SDL2 demo uses the wpk backend. +- **XDG-shell-style state machine.** Wayland has explicit + `configure` / `ack_configure` / `commit` rounds; v1's protocol + is simpler — `COMMIT` is the atomic boundary. v2 may add + configure rounds. +- **Multi-monitor.** Single CRTC, single connector — matches plan 4. +- **Output rotation / scaling / DPI awareness.** v1 is 1.0× only. +- **Hardware cursor plane.** Software cursor only. +- **Touch / pen / tablet input.** libinput's keyboard + pointer + subset only. +- **Client surface scaling (e.g., HiDPI).** v1 surfaces are 1:1 + pixel-mapped to compositor backbuffer; no resampling. +- **Window resize messages from compositor → client.** v1's + surface dimensions are client-chosen at CREATE_SURFACE time and + immutable. v2 may add `RESIZE`. +- **Cursor theme / animated cursors.** Software cursor is a + hardcoded white-arrow-with-black-outline. v2 may add themes. +- **Pop-up menus + tooltips.** Plan 8's wpk_widget surface is + in-process; the compositor doesn't manage popup-menu z-order + beyond a flat "panel | toplevel | popup" three-tier ordering. + Stacked popups are v2. +- **Drag-and-drop / clipboard.** Design §9.2 reserves + CLIPBOARD_SET / CLIPBOARD_REQUEST / CLIPBOARD_DATA message + types; v1 plan 9 doesn't implement them. Plan 11 (seed apps) + or v2 may add. +- **Compositor restart watchdog.** A dead compositor leaves + clients orphaned; restart is manual via the launcher. +- **Plumbing for SDL2 audio thread.** Plan 7's open-architecture + #1 (audio thread via libpthread shim OR SDL2 polling patch) + is INDEPENDENT of plan 9; the compositor doesn't manage audio. + +--- + +End of plan. diff --git a/docs/plans/2026-07-14-build-hyprland-class-compositor-plan.md b/docs/plans/2026-07-14-build-hyprland-class-compositor-plan.md index 55a559369b..6e558919c4 100644 --- a/docs/plans/2026-07-14-build-hyprland-class-compositor-plan.md +++ b/docs/plans/2026-07-14-build-hyprland-class-compositor-plan.md @@ -326,6 +326,16 @@ trampolines. wasm32 cannot generate code at runtime. rendering with Omarchy's actual waybar config (translated), mako showing a real notification via gdbus, foot as the terminal. This is the "unmodified GTK app runs" milestone the Wayland plan's §7 named. +- **Waybar's IPC, as built.** Pointing Waybar's hyprland modules at + `kwlctl` alone was not enough: they resolve their socket from + `$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/`, use two + sockets (request/reply + events), and read a wider JSON field set + than PR14 emitted. The compositor now serves the same command table + and event bus on that socket pair as well, accepts hyprctl's `j/` + prefix, answers `activeworkspace`/`monitors`/`workspacerules`, and + emits the `v2` event family — see the Hyprland IPC compatibility + bullet in `docs/architecture.md`. Window titles are stored for this + reason alone. Tier 2 risks, ranked: (1) ffi_closure correctness (PR20 — mitigated by the test-matrix-first rule); (2) GTK3 port sheer size (PR24 — diff --git a/docs/plans/2026-07-20-wpk-shell-plan.md b/docs/plans/2026-07-20-wpk-shell-plan.md new file mode 100644 index 0000000000..8df5b39190 --- /dev/null +++ b/docs/plans/2026-07-20-wpk-shell-plan.md @@ -0,0 +1,1991 @@ +# DRI v2 — wpk-shell plan (libwpkterm VT100 emulator + wpkshell built-in shell + compositor client) + +> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to execute this plan task-by-task. + +**Goal:** Ship `examples/programs/wpkshell/` — a small (~1.0 kLoC) +wasm32 program that runs as a wpkcompositor client (plan 9), draws +a single tiled window via libwpkdraw (plan 8), embeds a hand-rolled +ANSI/VT100 terminal-emulator core (libwpkterm, ~600 LoC), and runs +a tiny custom POSIX-sh-shaped shell with five built-ins +(`cd`, `ls`, `cat`, `echo`, `exit`) plus `|` pipelines that +fork-exec external programs over plan-6-sockets' socketpair + +fork-exec surface. Plus one new sysroot library — `libwpkterm.a` +(VT100 cell grid + escape-sequence parser + render glue) — that +future apps (a logfile pager, a debug console, an inline +text editor) can reuse. + +**Architecture:** Three PRs (one per phase). The user-facing +binary is one C program with three internal modules; libwpkterm +is a separate static archive. + +1. **`examples/libs/libwpkterm/`** (~600 LoC + headers, ~120 KB + static lib) — VT100 cell-grid + escape-sequence parser. + Public API: + - `wpk_term_create(cols, rows) → struct wpk_term *` — allocate + a cell grid (cols × rows × sizeof(struct cell)); scrollback + ring of `8 × rows` lines. + - `wpk_term_feed(t, bytes, len)` — drive the parser with raw + bytes from the child program's stdout; cells get filled, + cursor advances, escape sequences mutate state. Implements + the VT100 subset: CR / LF / BS / HT / BEL, CSI cursor + motion (`CUP CUU CUD CUF CUB`), CSI erase (`ED EL`), CSI + SGR colour + bold + reverse (`m`), and the 16-colour ANSI + palette. NO mouse, NO alt-screen, NO 256-colour, NO line + drawing — see "What this plan doesn't cover" below. + - `wpk_term_render(t, wpk_surface, font, x, y, w, h)` — + render the current grid into a libwpkdraw surface; uses + `wpk_text` for cell-by-cell rendering with per-cell colour. + Dirty-line tracking to avoid re-rendering unchanged lines. + - `wpk_term_input_key(t, keysym, modifiers) → ssize_t` — turn + a keysym + modifier-mask into bytes (the inverse of the + parser): `Enter` → "\r", `Backspace` → "\x7f", + `Ctrl-C` → "\x03", arrow keys → CSI sequences, etc. Writes + the bytes into a caller-provided buffer; returns byte count. + - `wpk_term_destroy(t)`. +2. **`examples/programs/wpkshell/main.c`** (~400 LoC) — compositor + client + event loop + terminal/shell wiring. Opens a + libwpkclient connection, allocates a 800 × 600 surface, + creates a libwpkterm grid sized to 80 × 25 cells (10 × 24 px + font cells, default), and runs the event loop: + - `wpk_client_poll(c, ...)` → on `WPK_CLIENT_KEY`, call + `wpk_term_input_key` to translate → write bytes to the + shell's `pipe_to_shell[1]`. + - `read(pipe_from_shell[0], ...)` → call `wpk_term_feed` to + update the grid → mark dirty. + - On `dirty`, re-render the grid via `wpk_term_render` → + `wpk_surface_present` → `wpk_client_attach_buffer` + + `wpk_client_commit`. +3. **`examples/programs/wpkshell/shell.c`** (~600 LoC) — the + built-in shell. Reads command lines from `pipe_to_shell[0]`, + parses `cmd [args...] [| cmd2 [args...]]`, dispatches: + - Built-ins (cd, ls, cat, echo, exit) — execute in the + shell's own process; write output to `pipe_from_shell[1]`. + - External commands — fork(); child execs the program with + stdin/stdout/stderr replaced via `dup2(pipe_*, 0/1/2)`; + parent waits or sets up the next pipe stage. Pipeline + stages are wired via `socketpair(AF_UNIX, SOCK_STREAM, 0)` + (plan-6 sockets' surface; `pipe2(2)` is the upstream + idiom but socketpair works equivalently in v1). + +The shell runs **in the same process as the terminal emulator**. +There is no PTY. Built-ins write directly to the in-process +output pipe (`pipe_from_shell`); external commands inherit the +pipe ends via fork+exec. The terminal emulator reads from one +end and feeds VT100 bytes into the grid. + +**Tech Stack:** +- Userland C: C99 with `wasm32posix-cc`; static archives only. +- Shell program: `examples/programs/wpkshell/{main.c,shell.c,builtins.c}` + cross-compiles to `wpkshell.wasm`; installed at + `/usr/bin/wpkshell`. Init's user-shell exec line (post-PR + #486) targets `wpkshell` by default if the compositor is + running; the existing /bin/sh path remains for the + WordPress demo (which doesn't ship the compositor). +- Wire format: libwpkclient (plan 9) for compositor wire; + in-process pipe (kernel-side AF_UNIX socketpair via plan 6) + for terminal ↔ shell ↔ external-command data. +- Buffer sharing: client → compositor via plan 2's prime fd + + SCM_RIGHTS over libwpkclient. NO host imports. +- Input: libwpkclient delivers `WPK_CLIENT_KEY` events with the + compositor's xkb-resolved keysym + modifier mask. libwpkterm + translates keysym → byte stream. +- Text rendering: plan 8's libwpkdraw + DejaVu Sans default + font @ 14 px (768 × 480 grid for 80 × 25 cells). + +**Companion design doc:** `docs/plans/2026-05-18-dri-design.md` +§9.4 (wpk-shell rationale) + §9.3 (libwpkdraw consumer story) + +§13 (fork+exec via SCM_RIGHTS for pipe ends). + +**Critical wasm32 ABI detail:** the shell is entirely userspace — +every byte goes through existing syscalls (`socketpair`, `pipe`, +`fork`, `execve`, `dup2`, `read`, `write`, `wait4`, `chdir`, +`getcwd`, `opendir`, `readdir`, `open`, `close`, `stat`). **Zero +kernel exports added.** Zero host imports added. + +**Clock source:** All components use `clock_gettime(CLOCK_MONOTONIC, +…)` via the musl shim — cross-stream parity with plans 4/5/6/7/8/9. + +**Design reference:** `docs/plans/2026-05-18-dri-design.md` §9.4 +(wpk-shell), §9.3 (libwpkdraw text rendering), §13 (fork+exec +surface for pipelines). + +**Consistency with plans 2 + 4 + 5 + 6 + 7 + 8 + 9:** + +- **No new kernel exports.** All shell surface is userspace C + over plans 2/6's existing socket + process surfaces. +- **Plan 6 (sockets) ships `socketpair` + `fork` + `execve` + + `dup2` + `wait4` + SCM_RIGHTS.** Plan 6 audit (session 10) + confirms all five are IMPLEMENTED. Pipeline construction in + shell.c uses these directly. +- **Plan 8 (libwpkdraw) is the rendering backend.** Cell grid + rendering is N × `wpk_text` calls per dirty line; cursor is + one `wpk_rect`. Plan 8's font (DejaVu Sans) at 14 px is the + default; the grid is `floor(surface_w / cell_w)` × + `floor(surface_h / cell_h)`. +- **Plan 9 (compositor) is the surface broker.** wpkshell is + always a compositor client. The "direct-KMS" fall-back + (plan 8 E1) is NOT used — wpkshell never takes KMS master, + because v1 expects the compositor to be running. Init + fork-execs wpkshell as PID 3 (after PID 2 wpkcompositor) + when `/etc/wpk/compositor` exists. +- **Plan 5 (evdev) is bypassed.** libwpkterm consumes + `WPK_CLIENT_KEY` from libwpkclient — keysym + modifier-mask + pre-resolved by the compositor's libxkbcommon (plan 9 D4). + wpkshell does NOT open `/dev/input/event*`. +- **Plan 7 (SDL2) is not involved.** wpkshell is a TTY app; + no GLES, no audio, no video. + +**Stack base:** Plan 9's `…-wpk-demo` branch tip (plan 9 PR #5 +head). The shell needs everything from plans 2–9 plus the GL +stack follow-ups. wpkshell links: `-lwpkclient -lwpkterm +-lwpkdraw -lc` (no `-lEGL -lGLESv2 -lgbm` — wpkshell is CPU- +tier-only, like wpkdraw apps). + +**Branch:** `emdash/explore-direct-rendering-infrastructure-wpk-shell-plan-XXXXX` +(chains off plan 9's tip per the branching rule). Three +sub-branches stack off it for the three PRs. + +**Final PR base:** Plan 9's `…-wpk-demo` tip. **Do not merge** +until Brandon validates the design, plan 9 has merged, and +Phase C's manual browser verification confirms the shell +prompts, accepts input, runs the five built-ins, runs an +external command, runs a `|`-piped pair of external commands, +and renders ANSI colour output correctly. + +**Three PRs, coordinated merge.** Each task below is one commit. +PR titles use Brandon's `scope(area): action` shape: + +1. `sysroot(wpkterm): libwpkterm — VT100 cell grid + parser + render` +2. `examples(wpk): wpkshell — compositor client + built-in shell + fork-exec pipes` +3. `examples(wpk): wpkshell — demo polish + browser spec` + +PR base/head topology (stacked): + +``` +… (plans 2–9 tips + plan 2/3 GL stack follow-ups + plan 9 demo) + └── …-wpk-demo (plan 9 PR #5 tip) + └── …-wpk-shell-plan-XXXXX (this plan PR base) + └── …-wpk-libwpkterm (PR #1) + └── …-wpk-wpkshell (PR #2) + └── …-wpkshell-demo (PR #3) +``` + +**Verification gauntlet** (CLAUDE.md): all of the below must pass +with zero regressions before any PR is opened, and re-run before +final merge: + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +`XFAIL` / `TIME` acceptable; `FAIL` that isn't pre-existing is a +regression. Phase C adds manual `./run.sh browser` verification +(CLAUDE.md item 6) — the compositor boots, wpkshell starts as +PID 3, a `$` prompt renders in a tiled window, typing `echo +hello` echoes `hello`, typing `ls /` lists the rootfs root, +typing `cat /etc/wpk/compositor` cat's the config, typing +`ls / | cat` pipes the listing through cat (external commands +via fork-exec), typing `exit` closes the surface and exits. + +**ABI impact:** **None.** Plan 10 adds zero kernel exports, +zero host imports, zero new ioctls, zero new device nodes. +Every byte crosses the kernel-userland ABI via existing surfaces +from plans 2 + 6 + plan-6-sockets. `ABI_VERSION` does not bump; +`abi/snapshot.json` is byte-identical. + +The sysroot grows: `sysroot/lib/libwpkterm.a` (~120 KB), +`sysroot/include/wpkterm/wpkterm.h`, the wpkshell binary at +`/usr/bin/wpkshell` (~250 KB including its statically linked +deps). Package index ledger gets one new entry. + +Existing kernel + host + ABI surfaces — all unchanged. + +--- + +## Pre-implementation review + +Devil's-advocate + consistency pass run 2026-05-19 (session 11), +after plan 10 drafted in session 10. Pass covers: focus areas from +the handoff-10 sentinel (VT100 escape-sequence subset gap risk for +external commands; socketpair-as-pipe EOF semantics vs pipeline +construction; fork-exec depth + PROCESS_TABLE contention; +`opendir`/`readdir` against synthetic /dev entries + `/run/wpk` +socket inode; xkb modifier-mask convention plan 9→plan 10 +`input.c`; init shell-line race vs compositor binding +`/run/wpk/comp`; keysym→byte mapping for non-ASCII keys; +in-process shell+terminal vs PTY trade-off; compositor-crash → +wpkshell graceful exit), plus a code-level re-read of every C +snippet in the plan body — `main.c`, `shell.c` (parse_pipeline, +shell_main, execute_line, print_prompt), `builtins.c` +(cd/ls/cat/echo/exit/help), `exec.c` (resolve, run_external, +run_pipeline), `parser.c`, `input.c`, `render.c`, the C2 help +string — plus a re-audit of plan 9's libwpkclient public header +(lines 1366–1410), plan 8's `wpk_surface_create` signature +(line 813), plan-6-sockets' socketpair / SHUT_WR / EOF tests +(`crates/kernel/src/syscalls.rs:11232+`), and the rootfs MANIFEST +for PATH layout (`/bin` + `/usr/bin` at MANIFEST:30 + 32). +Findings are structured Brandon-style. Inline fixes (14) are +**folded conceptually** — plan body retains pre-review text per +the Brandon convention; implementation applies each fix per this +section. Four cross-plan amendments leak back into plans 5 + 9 +reviews (three to plan 9 alone — the API gaps are concentrated +there). The load-bearing open-architecture items (3) gate plan +10 implementation start; all three resolve via plan 9 API +amendments, none require new kernel surface. + +### Inline fixes (14 — folded conceptually; plan body unchanged) + +1. **Help-string `dprintf` length is off by 42 bytes.** Plan 10 + C2 line 1261 ships `dprintf(1, "wpkshell built-ins:\r\n…", 232)` + but the actual concatenated literal is **274 bytes** (counted: + 21+35+33+29+29+28+29+52+18 with CRLF line endings). The fourth + `dprintf` positional argument is the format string's length- + modifier slot under a generic interpretation, but here it's + being treated as a precision on the trailing args — except the + format has no `%.*s`, so the `232` literal is dead code OR + (depending on which `dprintf` overload's actually in plan 10's + intent) a misplaced length argument that doesn't apply. **Lean:** + the hardcoded count is a latent bug regardless of whether + `dprintf` interprets it — drop it and emit via either + `write(1, msg, strlen(msg))` or `dprintf(1, "%s", msg)`. Fold + into C2. Same hygiene applies to C1's banner (line 1241 uses + `strlen(banner)` correctly; the C2 lapse is the outlier). +2. **`wpk_client_get_fd` accessor missing from plan 9 libwpkclient + API.** Plan 10 main.c B2 line 923 polls + `wpk_client_get_fd(cl)`, but plan 9's public header (lines + 1366-1410) exposes only `connect/poll/create_surface/ + attach_buffer/commit/set_title/set_type/destroy_surface` — no + fd accessor. The internal `struct wpk_client` carries `.fd` + (line 1433) but it's not reachable across the API boundary. + **Lean:** add `int wpk_client_get_fd(struct wpk_client *c);` + to plan 9's public header (one-line accessor returning + `c->fd`). Cross-plan amendment to plan 9 below; fold into + plan 10's B2 includes. +3. **`wpk_client_attach_buffer` has no `stride` parameter; plan + 9 inline fix #2 (stride-plumbing) is incomplete on the + client side.** Plan 9 inline fix #2 commits the wire-format + `wpk_msg_attach_buffer` payload to carry `stride: u32` so + the compositor's `gbm_bo_import` uses the producer's actual + row pitch instead of `width * 4`. But plan 9's C API + `wpk_client_attach_buffer(c, surface_id, prime_fd)` takes no + stride. Plan 10's `wpk_surface_present` (plan 8 / plan 9 E1 + line 2039) passes only `(client, surface_id, prime_fd)`. The + stride is unreachable from the call site. **Lean:** extend + the signature to `wpk_client_attach_buffer(c, surface_id, + prime_fd, uint32_t stride)`. The libwpkdraw bo-handle path + already knows the stride via `gbm_bo_get_stride(bo)`; plan + 9 E1's `wpk_surface_present_via_compositor` queries it and + forwards. Cross-plan amendment to plan 9 below; fold into + plan 10's `wpk_surface_present` callers (no plan-10-only C + to change, but the API contract must agree at link time). +4. **Main.c poll loop never installs `SIGPIPE` handler — closed- + peer writes terminate the wpkshell terminal process.** Plan + 10 B2 line 939 writes a keystroke to `pipe_to_shell[1]` after + `poll`. If the shell child has already exited (e.g., user + typed `exit`, or the shell crashed), the write hits a closed + peer → SIGPIPE → default action terminates the terminal + process before the cleanup chain at lines 957-962 runs. Same + hazard exists in shell.c's `write(1, …)` echo at line 1035 if + main.c crashes. **Lean:** add `signal(SIGPIPE, SIG_IGN);` as + the first statement of `main()` AND `shell_main()`; check + `write(...)` returns for `errno == EPIPE` and treat as graceful + exit. Fold into B2 + B3. +5. **Compositor fd leaks across `execv` in `run_external` / + `run_pipeline`.** main.c connects to the compositor at B2 + line 889 (libwpkclient opens an AF_UNIX socket). main.c then + forks `shell_pid` at line 906; the shell child inherits the + compositor fd. When the shell child fork-execs an external + command (B5 line 1167/1201), the compositor fd leaks into + the external program's open-fd table. Worst case: the + external program reads/writes the compositor protocol stream + by accident; best case: kernel OFD-table bloat per command. + Plan 9 inline fix #5 already mandates `SOCK_CLOEXEC` on + `accept4` for the compositor's accept side; the symmetric + client-side fix is to set `FD_CLOEXEC` on + `wpk_client_connect`'s returned fd OR (cleaner) request + `SOCK_CLOEXEC` on the `socket(2)` call inside libwpkclient. + **Lean:** plan 9's `wpk_client_connect` body (lines + 1439-1455) calls `socket(AF_UNIX, SOCK_STREAM, 0)`; amend to + `socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0)`. Cross-plan + amendment to plan 9 below; no plan-10-only fix needed. +6. **POLLHUP / POLLERR on the compositor fd causes a tight CPU + spin in main.c's poll loop.** B2 line 931 checks only + `fds[0].revents & POLLIN`. If the compositor exits, the + kernel posts POLLHUP|POLLERR on the fd; main.c's `poll` + returns immediately every iteration because the condition's + unhandled. No `read` is ever issued to clear the hangup, no + exit path triggers, and the loop burns CPU. **Lean:** add + `if (fds[0].revents & (POLLHUP | POLLERR)) { quitting = 1; + break; }` ahead of the POLLIN arm. Same applies to fds[1] + (the shell pipe — POLLHUP on shell death must also bail). + Fold into B2. +7. **Terminal parser treats bare `\n` as line-feed-only; external + commands' output stair-steps.** A3 line 575-577 advances + `cy++` on `\n` but does NOT reset `cx = 0`. The shell's + built-ins explicitly write `"\r\n"` (lines 1019, 1031, 1099) + so they render correctly, but external commands and the + built-in `cat` (B4 line 1111 — `write(1, buf, r)` raw) emit + `\n` alone. Result: `cat /etc/wpk/compositor` renders with + each line starting where the previous ended (stair-step). + This is the classic "termios ONLCR" expectation: terminal + output mode interprets `\n` as CR + LF. **Lean:** parser + treats `\n` as both CR and LF — set `t->cx = 0` before the + `cy++` in A3 line 576. Standard xterm behaviour in cooked- + ish output mode. Fold into A3. +8. **`fork()` return value < 0 unhandled in main.c (line 906).** + `if (shell_pid == 0)` is the child arm; non-zero is treated + as parent. But if fork returns -1 (e.g., process-table + exhaustion under plan 7 risk register), main.c proceeds to + `close(pipe_to_shell[0])`, then polls on a pipe that has no + writer, reads 0, exits — with a delayed `waitpid(-1, …)` that + returns EINVAL. Silent misdiagnosis. **Lean:** add + `if (shell_pid < 0) { perror("fork"); return 1; }` after + line 906. Same hygiene applies to `run_pipeline`'s fork loop + (B5 line 1192): on fork failure, close all sp fds, waitpid + the previously-forked stages, return cleanly. Fold into B2 + + B5. +9. **`wpk_term_input_key` returns `(size_t)snprintf(...)` — + truncation-aware byte count missing.** A6 lines 753-761 cast + snprintf's int return to `size_t`. snprintf returns the + number of bytes that WOULD have been written, not the number + actually written. If `out_cap < 4`, snprintf still returns 3 + for `"\x1b[D"` while writing only `out_cap - 1` bytes plus a + NUL. The caller writes `3` bytes from `out[]` — including + uninitialised data past the truncated tail. main.c's caller + passes `sizeof buf = 8` (line 938), so the practical + exposure is zero, but the function contract is buggy. + **Lean:** for the CSI arms, replace `snprintf` with direct + `memcpy`/`memcpy_const` of a fixed-length 3-byte / 4-byte + array and return the constant length; gate on `out_cap` first. + Fold into A6. +10. **`<errno.h>` not included in `main.c`.** B2 references + `errno == EINTR` at line 929 but the include block (lines + 867-874) lacks `<errno.h>`. Compile error (or implicit-int + in pre-C11 dialects, which `wasm32posix-cc` is not). Same + omission in shell.c — `try_builtin`'s `cd` arm calls + `strerror(errno)` at line 1084 without including + `<errno.h>`. **Lean:** add `#include <errno.h>` to both + `main.c` and `shell.c`. Fold into B2 + B3. +11. **`SIGCHLD` reaping policy unspecified — pipeline zombies + accumulate if a stage exits before the sequential + `waitpid` loop reaches it.** B5 lines 1209 walks + `for (int i = 0; i < n; i++) waitpid(pids[i], …)`. POSIX + queues zombies per parent until reaped, so the sequential + reap is correct — but a long-running stage N earlier in the + loop blocks reaping of already-exited stages N+1..n-1, and + a `SIGINT` mid-pipeline could leave the shell stuck on a + waitpid that never resolves. **Lean:** use `WNOHANG` polling + on each subsequent pid after the first non-blocking wait, + OR (simpler) accept the sequential-wait penalty as v1's + documented behaviour and add a comment. Lean (b); + pipelines in v1 are short-lived. No code change; ship the + comment. Fold into B5. +12. **Init's wpkshell-exec race vs compositor binding + `/run/wpk/comp`.** B1 line 847 unconditionally `execl`s + `/usr/bin/wpkshell` if `access("/etc/wpk/compositor", F_OK) + == 0`. Init has just fork-exec'd the compositor at plan 9 + D1; the compositor's `socket() → bind() → listen()` sequence + has not yet completed when init proceeds to the user-shell + exec. wpkshell's `wpk_client_connect` calls `connect(2)` on + `/run/wpk/comp` — which doesn't exist yet → ENOENT → + wpkshell exits fatally → init reaps → empty screen. **Lean:** + init waits for the socket-path to exist before exec'ing + wpkshell — a tight loop of `access("/run/wpk/comp", F_OK)` + with a 50 ms sleep, 2-second timeout. Approximately 10 + lines. Plan 9's libwpkclient could alternatively add a + connect-retry loop (cross-plan amendment); lean the init- + side fix because plan 9's connect is already correct under + "compositor-up" assumption and the race is init's + sequencing problem to solve. Fold into B1. +13. **`run_pipeline` resolve-failure mid-loop leaks socketpair + fds + leaves previously-forked stages orphaned.** B5 line + 1188-1190: if `resolve(stages[i][0])` returns NULL for stage + i > 0, the function waits previously-forked stages but + never closes `sp[0..n-2][*]` — leaks 2(n-1) fds per failed + pipeline. Subsequent execve calls also dangle (the loop + `return`s). **Lean:** factor the close-all-sp-fds block out + of the parent's post-loop sequence (line 1208) and call it + from the resolve-failure path AND the fork-failure path. + Fold into B5. +14. **Pipeline child's resolve-failure leaks `path` malloc.** B5 + line 1185-1186: `char *path = resolve(stages[i][0])` is + only `free`d at line 1204 in the parent's post-fork branch. + The child does not `free(path)` before `execv` (acceptable — + process exit reclaims) nor before `_exit(127)` on execv + failure (acceptable for the same reason). But if the child + fork's `execv` ever exits to an error path that loops back + (it doesn't in this code, but defensive), the malloc + persists. **Lean:** explicit `free(path)` after `execv(...)` + and before `_exit(127)` is hygiene — costs one line, gains + nothing functional but matches plan 8/9 conventions. Fold + into B5 as a comment only ("path malloc reclaimed by + process exit; free is hygiene"); not strictly required. + +### Correctness — open (lean documented) + +- **`compositor_crash` → wpkshell graceful exit.** Per plan 9 + Correctness — open ("compositor-client mode is one-way"), + EPIPE on libwpkclient send → libwpkclient propagates the + error → wpkshell main.c sees a failed `wpk_surface_present` + or POLLHUP on the compositor fd (per inline fix #6) → quits. + Cleanup chain (lines 957-962) runs: kill shell child, + waitpid, destroy surfaces, return. Lean: documented; no + watchdog / auto-relaunch in v1. +- **VT100 unknown-CSI silent-drop visual artifact.** Per A3 + line 558-559, unknown CSI finals are ignored. External + programs that emit 256-colour (`ESC [ 38;5;n m`), truecolour + (`ESC [ 38;2;r;g;b m`), alt-screen (`ESC [ ? 1049 h`), or + mouse-tracking (`ESC [ ? 1000 h`) leave residual parameter + bytes in `csi_buf` correctly cleared by the final-byte arm, + but the SGR side-effects are lost. Demo's `ls`/`cat`/`echo` + output stays in the 16-colour subset; lean: documented; + no logging in v1 (would clutter the user's stderr). +- **xkb modifier-mask convention plan 9 → plan 10.** Plan 9 D4 + emits `WPK_CLIENT_KEY.modifiers` as the result of + `xkb_state_serialize_mods(state, XKB_STATE_MODS_EFFECTIVE)`. + xkbcommon's effective-mods bitmask uses index 2 for Control + in the default `evdev` keymap (`xkb_keymap_mod_get_index(km, + "Control") == 2`). Plan 10 input.c line 736 hardcodes + `MOD_CTRL = (1u << 2)`. Lean: convention matches; document in + input.c's header comment that the bit assignment depends on + xkbcommon's keymap-default order. If a future keymap-load + change rebinds the mod indices (unlikely — `evdev` is the + pinned rules-file per plan 9), input.c needs an update. + Regression test in B4 asserts `Ctrl-c` → 0x03. +- **PROCESS_TABLE contention under nested fork-exec.** Plan 10 + pipelines fork ≤ 3 children per command; init + compositor + + wpkshell main + wpkshell shell-child + 3 stages = 7 + processes at peak. Plan 7 risk register #2 (OFD-table-split + refactor) profiled this class of contention as not load- + bearing at small N. Lean: documented; profile in C4 and + defer the refactor unless visible. No plan-10 architectural + change. +- **`SIGPIPE` in external-command children.** v1 pipeline + stages inherit default `SIGPIPE = terminate`. When stage N+1 + closes its read end early (e.g., `yes | head -1`), stage N's + next `write` triggers SIGPIPE → stage N dies. This is the + correct POSIX shell behaviour for pipeline backpressure. The + shell process itself (per inline fix #4) ignores SIGPIPE; + the children explicitly do NOT inherit SIG_IGN because the + shell's `signal(SIGPIPE, SIG_IGN)` is process-local and is + NOT preserved across `execve` (kernel resets dispositions + set to non-default to default on exec, except SIG_IGN — but + POSIX explicitly says SIG_IGN IS preserved across execve, so + the children would inherit SIG_IGN). **Lean:** in + `run_external` / `run_pipeline` children, restore default + SIGPIPE disposition before `execv`: `signal(SIGPIPE, + SIG_DFL);` immediately before each `execv` call. Fold into + B5; treat as part of inline fix #4's symmetric story rather + than a separate fix. +- **Built-in `ls` against `/dev/input` returns only `mice`.** + `devfs.rs:180` documents "No /dev/input/eventN evdev nodes + yet (mousedev surface only)". Plan 5's evdev nodes are + virtual — present via `open(2)` but not enumerated by + `readdir(2)`. v1 demo's `ls /dev` shows `mice` but not + `event0`/`event1` despite plan 9's compositor opening them. + Lean: cross-plan amendment to plan 5 (enumerate event0..N + in devfs); not LOAD-BEARING for plan 10 — the demo command + `ls /` is sufficient to validate `opendir`/`readdir`. +- **`/run/wpk/comp` not listed by `readdir("/run/wpk")`.** + AF_UNIX bound sockets are not regular dentries in the + kernel's host-backed VFS — they live in the + `UnixSocketRegistry` (kernel/src/socket.rs). `ls /run/wpk` + via wpkshell's built-in `ls` will not show the socket. Lean: + documented; AF_UNIX sockets being directory-invisible is + not a regression — Linux exposes them via the abstract + namespace OR a file inode of type `S_IFSOCK` if `bind`-ed + to a real path. The kandelo VFS doesn't currently produce + `S_IFSOCK` dentries from `UnixSocketRegistry.bind`. + Cross-plan amendment to plan-6-sockets noted under + "Cross-plan amendments" below; not LOAD-BEARING — wpkshell + doesn't depend on `ls /run/wpk` for any functionality. +- **`wait4` in shell child blocks the read-loop.** Risk + register #6 documents this; main.c is a separate process so + the terminal stays responsive. Lean: documented; v1. + +### Architecture — open (LOAD-BEARING flag) + +1. **`wpk_client_get_fd` accessor required for `poll(2)` + integration.** [LOAD-BEARING] Plan 10's poll loop cannot + function without the accessor (inline fix #2). Two options: + (a) plan 9 amendment exposes the public API (one-line + accessor + signature in libwpkclient.h); (b) plan 10 reaches + into the struct via a private header `#include`. Lean (a) — + plan 9 is the API owner; the accessor is a five-minute + amendment. Cross-plan amendment to plan 9 below. Without + this, plan 10 implementation cannot start. +2. **`wpk_client_attach_buffer` stride extension required for + plan 9 inline fix #2's wire contract.** [LOAD-BEARING] Plan + 9 inline fix #2 mandates the wire payload carry `stride: u32` + but plan 9's libwpkclient C API has no stride parameter. + Without the extension, the compositor's `gbm_bo_import` MUST + recompute `width * 4`, re-introducing plan 9 fix #2's bug. + Lean: plan 9 amends the C signature; plan 10 forwards the + client-side stride from `gbm_bo_get_stride(bo)` (already + wired through plan 8's bo handle). Cross-plan amendment to + plan 9 below. Without this, plan 9 fix #2 is incomplete and + plan 10's first non-trivial render exposes the bug. +3. **Init → wpkshell sequencing vs compositor `bind`.** + [LOAD-BEARING] (Inline fix #12.) Three resolution options: + (a) init `access("/run/wpk/comp", F_OK)`-poll with 50 ms / + 2 s timeout (≈10 lines of C in init); (b) plan 9 + libwpkclient `wpk_client_connect` retries with backoff + (≈8 lines of C in libwpkclient); (c) document the race + + relaunch manually. Lean (a) — init is the sequencing + authority, and (a) keeps libwpkclient's connect semantics + single-attempt for non-init callers (which is the more + common case). (b) ships as a fallback if (a) proves + flaky in practice. Cross-plan amendment to plan 9 below for + the (b) option as a future-proofing escape hatch. Without + (a), wpkshell exits fatally before the demo ever renders. + +### Missing tests + +- **Compositor-up wpkshell boot.** vitest spawns compositor + + wpkshell; assert wpkshell connects + a green `$` prompt + renders in the surface within 1 s. Regression guard for + inline fix #12. +- **socketpair-as-pipe EOF.** cargo test (plan-6-sockets + audit confirms IMPLEMENTED: `test_socketpair_close_one_end` + at `crates/kernel/src/syscalls.rs:11259`); plan 10 adds a + vitest that runs `echo hi | cat` end-to-end and asserts cat + exits cleanly after stage 0 closes. +- **`parse_pipeline` token boundaries.** Cargo test on + shell.c's `parse_pipeline`: `"echo hi"` → 1 stage; + `"ls / | cat"` → 2 stages; `"a|b|c"` → 3 stages; + `" "` → 0 stages; `"|cmd"` → 0 stages (or 1 with empty + argv — define which); `"cmd args args "` → 1 stage with + argc=3. Regression guard for inline fix #14 polish. +- **`resolve` against PATH = /usr/bin:/bin.** Cargo test: + resolve `"echo"` → `/usr/bin/echo` if present; resolve + `"nosuchcmd"` → NULL; resolve `"./foo"` → `./foo` (slash + in argv[0] short-circuits PATH search). +- **3-stage pipeline.** vitest: `echo hello | cat | cat` + produces `hello` on the terminal grid. Confirms n=3 sp + setup, fork-exec, sequential waitpid. +- **SIGPIPE shell survival.** vitest: run a pipeline where + the consumer closes early; assert the shell process is + still alive afterward (no SIGPIPE-kill). Regression guard + for inline fix #4. +- **Compositor-fd close-on-exec.** vitest: spawn wpkshell; + run `ls /proc/self/fd` (if available — else a custom helper + that lists open fds via syscalls); assert the compositor + fd (libwpkclient's socket) is NOT present in the external + command's fd table. Regression guard for inline fix #5. +- **POLLHUP cleanup.** vitest: spawn compositor + wpkshell; + send SIGTERM to compositor; assert wpkshell exits within + 500 ms (not spinning on poll). Regression guard for inline + fix #6. +- **`\n` → CR+LF in terminal parser.** Cargo test on + `wpk_term_feed`: feed `"line1\nline2"`; assert grid[0] + starts with `"line1"` (cx=5 at end of feed becomes cx=0 + before cy++) and grid[1] starts with `"line2"` at column 0. + Regression guard for inline fix #7. +- **`wpk_term_input_key` truncation bounds.** Cargo test: + call with `out_cap = 2` for Up arrow (needs 3 bytes); assert + return is 0 or ≤ out_cap (never claims more bytes than + written). Regression guard for inline fix #9. +- **Init shell-line gating.** vitest: rootfs WITH + `/etc/wpk/compositor` → init exec's `/usr/bin/wpkshell` + AND waits for socket presence first. rootfs WITHOUT → + init exec's `/bin/sh`. Regression guards for inline fix + #12 + plan 9 D1 + plan 10 B1. +- **C2 help-string byte-count agreement.** Cargo or vitest + smoke: read the help-string literal at compile time and + assert `strlen(s) == bytes_written_by_dprintf` (or just + use `strlen` directly per inline fix #1). Regression guard + against off-by-N hardcoded counts. +- **`ls /dev` enumeration.** Cargo test: assert `readdir("/dev")` + includes `mice`; document that `event0`/`event1` are NOT + present (per `devfs.rs:180`). Cross-plan amendment to plan 5 + changes this expectation later. +- **Pipeline cleanup on resolve-failure.** vitest: run + `nosuchcmd | cat`; assert (a) the second stage's socketpair + fds are cleaned up; (b) the shell process's open-fd count + is the same after the failed pipeline as before. Regression + guard for inline fix #13. + +### Trade-offs verified + +- **Hand-rolled VT100 parser, no vterm/libtsm port.** Per the + plan's design: ~600 LoC + headers vs ~5000 LoC libtsm port. + v1 demo's escape subset is reachable in the smaller surface + area. ✓ +- **In-process shell + terminal, no PTY.** No kernel pty + surface; built-ins write directly to the in-process output + pipe; external commands inherit pipe ends via fork+exec. ✓ +- **Five built-ins (cd, ls, cat, echo, exit) + help.** Smallest + set that demonstrates fork-exec + pipelines + filesystem + + output. ✓ +- **No quoting / variable expansion / wildcards / redirection.** + v2. ✓ +- **VT100 subset, 16-colour palette.** ✓ +- **Fixed 80 × 25 grid in 800 × 600 surface.** Matches plan 9's + surfaces-immutable invariant. ✓ +- **Monospace font assumption.** DejaVu Sans M-advance + heuristic; risk register #4 documents the post-v1 path + (DejaVu Sans Mono). ✓ +- **PATH = /usr/bin:/bin hardcoded.** MANIFEST:30+32+63 confirm + both directories exist as real rootfs entries. ✓ +- **Pipelines via socketpair, not pipe(2).** Plan-6-sockets + audit confirms IMPLEMENTED; EOF semantics tested at + `crates/kernel/src/syscalls.rs:11259`. ✓ +- **No background jobs, no signal forwarding.** v2. ✓ +- **No history, no readline.** Up-arrow CSI A delivered to the + shell but ignored (no history buffer). v2. ✓ +- **One window, one shell, one tab.** v2 multi-tab. ✓ +- **Compositor-only, no direct-KMS fall-back.** wpkshell exits + if no compositor — matches plan 9 client expectations. ✓ +- **No clipboard.** Plan 9 reserves CLIPBOARD_* messages as + v2. ✓ +- **Init exec gated on `/etc/wpk/compositor` marker.** + WordPress demo path (`/bin/sh`) preserved. ✓ +- **Zero ABI impact.** No kernel exports, no host imports, no + new ioctls, no new device nodes. ✓ +- **Three-PR stacked merge.** libwpkterm → wpkshell → demo + polish. ✓ +- **CLOCK_MONOTONIC pinned via musl shim.** Cross-stream + parity with plans 4–9. ✓ +- **Static-link-only invariant.** `libwpkterm.a` (~120 KB) + ships `.a` only; wpkshell statically links. ✓ +- **No animation framework / per-keystroke dirty redraw.** ✓ +- **socketpair as one-way pipe.** Plan 6 surface; bidirectional + but used uni-directionally. ✓ +- **Shell child stdin/stdout dup2'd; stderr also points at + pipe_from_shell[1].** Single sink simplifies the terminal + display (no separate error stream). ✓ + +### Deliberately not flagged + +- **Real PTY surface (`openpty` / `forkpty`).** v2. ✓ +- **External shells (bash, zsh) hosted under wpkshell.** + Requires PTY surface; v2. ✓ +- **Sixel / Kitty graphics.** Out of scope. ✓ +- **Multi-monitor (plan 4 invariant).** ✓ +- **Per-user `.wpkrc` config.** v2. ✓ +- **Lua / scripting / plugin layer.** No. ✓ +- **Cursor blink animation.** v1 = static underline cursor. ✓ +- **Scrollback UI keybinding.** Ring exists in libwpkterm; + Shift-PageUp binding is v2. ✓ +- **Locale handling beyond UTF-8.** v1 = UTF-8 only. ✓ +- **`pwd` built-in.** Overlap with the prompt's cwd + display. ✓ +- **`env` / `set` / `export` built-ins.** No env-var + expansion in v1. ✓ +- **256-colour / truecolour SGR parameters.** Silently dropped + per A3's CSI default arm; risk register #8 documents. ✓ +- **Wayland-bridge from plan 9 §15.** v2. ✓ +- **Terminal resize (SIGWINCH).** v1 surfaces are client- + immutable per plan 9. ✓ +- **Pipeline >8 stages.** B5 caps at 8 (sp[8][2] + pids[8]); + no realistic v1 command uses more. ✓ +- **Cursor wrap-at-right-margin newline emission.** A3 line + 483-486 wraps + advances cy; renders correctly. Edge case: + the wrap-and-scroll-at-bottom is handled. ✓ +- **stb_truetype double-include in wpkshell vs libwpkterm.** + wpkshell links libwpkdraw (stb impl ships once there) + + libwpkterm (header-only consumer). Same invariant as plan 9 + inline fix #16 — verify `nm wpkshell.wasm | grep stbtt_` + shows one resolution. Documented; not a separate fix. ✓ +- **fork-failure mid-pipeline leaves partial state.** B5's + cleanup path (per inline fix #13) handles resolve-failure; + the symmetric fork-failure path is folded into the same + helper. ✓ +- **`cat`'s 4 KB buffer.** Risk register #4; slow but + correct. ✓ +- **Cursor underline at `(cy + 1) * cell_h - 2`.** Renders 2 + px inside the cell; visually acceptable; v2 may make + configurable. ✓ +- **`waitpid` ordering — kernel-queued zombies per parent.** + POSIX semantics; tested across plan-6-sockets. ✓ +- **VT100 BEL (0x07).** A3 line 580 — ignored in v1 (no audio + / no flash). v2 may add a flash. ✓ +- **`compositor_pick` z-order from plan 9.** Plan 10 inherits + it; not a plan 10 concern. ✓ + +### Cross-plan amendments (added to plans 5 + 9 reviews) + +- **Plan 9 follow-up (LOAD-BEARING).** Add public-API accessor + `int wpk_client_get_fd(struct wpk_client *c);` to plan 9's + libwpkclient header (lines 1366-1410). One-line + implementation: `return c ? c->fd : -1;`. Required for plan + 10's poll-loop integration (plan 10 inline fix #2). Note + added under plan 9's existing "Cross-plan amendments" + subsection as an addendum item: "Plan 10 follow-up + (LOAD-BEARING): expose `wpk_client_get_fd` accessor for + poll(2) integration." +- **Plan 9 follow-up (LOAD-BEARING).** Extend + `wpk_client_attach_buffer` signature to + `(c, surface_id, prime_fd, uint32_t stride)`. The wire-format + fix from plan 9 inline fix #2 is incomplete on the C-API side + without this. libwpkclient's body queries the bo's stride via + `gbm_bo_get_stride(bo)` at the call site (plan 8 / plan 9 E1 + `wpk_surface_present_via_compositor`) and forwards. Plan 10 + inline fix #3. +- **Plan 9 follow-up (escape hatch).** Optionally add a + connect-retry loop to `wpk_client_connect` (≈8 lines: + 10 × 50 ms backoff on ENOENT before returning NULL). Lean: + ship the init-side wait first (plan 10 inline fix #12); add + the libwpkclient escape hatch only if init's poll proves + flaky. +- **Plan 9 follow-up.** Amend `wpk_client_connect` to + `socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0)` so the + compositor fd does not leak across `execv` in client-side + fork-exec flows. Plan 10 inline fix #5. Note added to plan + 9's "Cross-plan amendments" subsection: "Plan 10 follow-up: + SOCK_CLOEXEC on client socket — symmetric to plan 9 inline + fix #5 (SOCK_CLOEXEC on accept4)." +- **Plan 5 follow-up.** Enumerate `/dev/input/event0..N` in + devfs `readdir` (kernel/src/devfs.rs around line 180 where + the comment "No /dev/input/eventN evdev nodes yet (mousedev + surface only)" lives). Without this, `ls /dev/input` from + wpkshell shows only `mice`. Not LOAD-BEARING for plan 10 — + the demo's `ls /` doesn't depend on it — but a UX paper-cut + worth a one-line note under plan 5's existing "Cross-plan + amendments" subsection: "Plan 10 follow-up: enumerate + /dev/input/event* in devfs readdir for `ls /dev/input` UX." +- **Plan-6-sockets follow-up (optional, NOT LOAD-BEARING).** + AF_UNIX-bound socket paths (e.g., `/run/wpk/comp`) are not + surfaced as `S_IFSOCK` dentries via the host-backed VFS. + `readdir("/run/wpk")` returns nothing for the bound socket. + Lean: documented as v2 hygiene; wpkshell doesn't depend on + the listing. + +--- + +## Phase A — sysroot: libwpkterm (PR #1) + +The VT100 cell-grid + parser + render-glue library. + +### Task A1: Package scaffold + +**Files:** +- Create: `examples/libs/libwpkterm/package.toml` — recipe. +- Create: `examples/libs/libwpkterm/build.toml` — build state. +- Create: `examples/libs/libwpkterm/build.sh` — build script. + +```toml +# examples/libs/libwpkterm/package.toml +name = "libwpkterm" +version = "0.1.0" +license = "MIT" +description = "VT100 terminal emulator — cell grid + ANSI escape parser + render via libwpkdraw" + +[source] +type = "local" + +[deps] +libwpkdraw = "0.1.0" # rendering primitives + DejaVu Sans + +[build] +script_path = "build.sh" +``` + +```toml +# examples/libs/libwpkterm/build.toml +script_path = "build.sh" +revision = 1 + +[binary] +index_url = "https://github.com/<repo>/releases/download/binaries-abi-v{abi}/index.toml" +``` + +```bash +#!/usr/bin/env bash +# examples/libs/libwpkterm/build.sh +set -euo pipefail +. "$WPK_WORKTREE/sdk/activate.sh" + +SRC_DIR="$1" +OUT_DIR="$2" +WORK="$OUT_DIR/build" +mkdir -p "$WORK/lib" "$WORK/include/wpkterm" + +cd "$SRC_DIR/src" +wasm32posix-cc -c -O2 \ + -I"$SRC_DIR/include" \ + -I"$WPK_SYSROOT/include" \ + grid.c parser.c render.c input.c +llvm-ar rcs "$OUT_DIR/lib/libwpkterm.a" *.o +cp -r "$SRC_DIR/include/wpkterm" "$OUT_DIR/include/" +``` + +**Commit:** `sysroot(wpkterm): scaffold libwpkterm package` + +### Task A2: Public header + cell-grid type + +**Files:** +- Create: `examples/libs/libwpkterm/include/wpkterm/wpkterm.h` +- Create: `examples/libs/libwpkterm/src/grid.c` + +```c +// include/wpkterm/wpkterm.h +#ifndef WPKTERM_H +#define WPKTERM_H + +#include <stdint.h> +#include <sys/types.h> + +struct wpk_term; /* opaque */ +struct wpk_surface; /* from wpkdraw */ +struct wpk_font; /* from wpkdraw */ + +/** Allocate a cells × rows terminal grid. Returns NULL on OOM. */ +struct wpk_term *wpk_term_create(int cols, int rows); +void wpk_term_destroy(struct wpk_term *t); + +/** Feed N bytes from the child's stdout. The parser advances the + * cursor + mutates cells. */ +void wpk_term_feed(struct wpk_term *t, const char *bytes, size_t len); + +/** Render the grid into a libwpkdraw surface at (x, y, w, h). Uses + * dirty-line tracking; only changed lines are repainted. */ +void wpk_term_render(struct wpk_term *t, + struct wpk_surface *s, + struct wpk_font *f, + int x, int y, int w, int h); + +/** Translate a keysym + modifier mask into an ANSI byte sequence + * suitable for writing to the child's stdin. Returns the number + * of bytes written to `out` (≤ out_cap); 0 if the key produces + * no output (e.g., a bare Shift press). */ +size_t wpk_term_input_key(uint32_t keysym, uint32_t modifiers, + char *out, size_t out_cap); + +/** Force a full re-render on the next wpk_term_render call. Useful + * after window resize (post-v1) or theme change. */ +void wpk_term_mark_dirty_all(struct wpk_term *t); + +#endif /* WPKTERM_H */ +``` + +```c +// src/grid.c — cell grid + cursor state + scrollback ring. +#include <wpkterm/wpkterm.h> +#include <stdlib.h> +#include <string.h> + +struct cell { + uint32_t codepoint; /* UTF-32 */ + uint8_t fg, bg; /* 0-15 ANSI palette + 16 = default */ + uint8_t flags; /* bit 0 = bold, bit 1 = reverse */ + uint8_t _pad; +}; + +struct wpk_term { + int cols, rows; + struct cell *grid; /* cols × rows */ + /* Cursor */ + int cx, cy; + /* SGR state */ + uint8_t fg, bg, flags; + /* Dirty-line bitmap, one bit per row */ + uint8_t *dirty; + /* Parser state machine */ + enum { GROUND, ESCAPE, CSI } state; + char csi_buf[32]; /* CSI parameter buffer */ + int csi_used; + /* Scrollback (post-v1 might expose; v1 just bounds memory) */ + int scrollback_lines; /* hard-coded = 8 × rows */ + struct cell *scrollback; + int scrollback_head; /* circular write index */ +}; + +struct wpk_term *wpk_term_create(int cols, int rows) { + if (cols < 4 || cols > 512 || rows < 4 || rows > 256) return NULL; + struct wpk_term *t = calloc(1, sizeof *t); + if (!t) return NULL; + t->cols = cols; t->rows = rows; + t->grid = calloc((size_t)cols * rows, sizeof(struct cell)); + t->dirty = calloc((rows + 7) / 8, 1); + t->scrollback_lines = 8 * rows; + t->scrollback = calloc((size_t)cols * t->scrollback_lines, + sizeof(struct cell)); + if (!t->grid || !t->dirty || !t->scrollback) { + wpk_term_destroy(t); return NULL; + } + t->fg = 7; t->bg = 16; /* default fg = white, bg = default */ + wpk_term_mark_dirty_all(t); + return t; +} + +void wpk_term_destroy(struct wpk_term *t) { + if (!t) return; + free(t->scrollback); free(t->dirty); free(t->grid); free(t); +} + +void wpk_term_mark_dirty_all(struct wpk_term *t) { + for (int i = 0; i < (t->rows + 7) / 8; i++) t->dirty[i] = 0xff; +} + +/* Mark one row dirty. */ +static void mark_dirty(struct wpk_term *t, int row) { + if (row >= 0 && row < t->rows) t->dirty[row / 8] |= 1u << (row % 8); +} + +/* Scroll the grid up by one line (top line goes to scrollback). */ +static void scroll_up(struct wpk_term *t) { + /* Copy top row to scrollback. */ + memcpy(&t->scrollback[t->scrollback_head * t->cols], + &t->grid[0], (size_t)t->cols * sizeof(struct cell)); + t->scrollback_head = (t->scrollback_head + 1) % t->scrollback_lines; + /* Shift rows up. */ + memmove(&t->grid[0], &t->grid[t->cols], + (size_t)(t->rows - 1) * t->cols * sizeof(struct cell)); + /* Clear bottom row. */ + memset(&t->grid[(t->rows - 1) * t->cols], 0, + (size_t)t->cols * sizeof(struct cell)); + /* Set the bottom row's bg from current SGR state. */ + for (int x = 0; x < t->cols; x++) { + t->grid[(t->rows - 1) * t->cols + x].fg = t->fg; + t->grid[(t->rows - 1) * t->cols + x].bg = t->bg; + } + /* All lines dirty after a scroll. */ + wpk_term_mark_dirty_all(t); +} +``` + +**Cargo test:** smoke — `wpk_term_create(80, 25)` returns +non-NULL; `wpk_term_destroy` doesn't leak. + +**Commit:** `sysroot(wpkterm): cell grid + cursor + scrollback ring` + +### Task A3: VT100 escape-sequence parser + +```c +// src/parser.c +#include <wpkterm/wpkterm.h> +#include "_internal.h" /* struct wpk_term, struct cell — same as grid.c */ +#include <string.h> +#include <stdlib.h> + +static void put_char(struct wpk_term *t, uint32_t codepoint) { + /* Wrap at right margin. */ + if (t->cx >= t->cols) { + t->cx = 0; + if (++t->cy >= t->rows) { scroll_up(t); t->cy = t->rows - 1; } + } + struct cell *c = &t->grid[t->cy * t->cols + t->cx]; + c->codepoint = codepoint; + c->fg = t->fg; c->bg = t->bg; c->flags = t->flags; + mark_dirty(t, t->cy); + t->cx++; +} + +static void apply_csi(struct wpk_term *t, char final) { + /* Parse t->csi_buf as semicolon-separated ints. */ + int params[16] = {0}; + int n_params = 0; + const char *p = t->csi_buf; + while (*p && n_params < 16) { + params[n_params++] = strtol(p, (char **)&p, 10); + if (*p == ';') p++; + } + switch (final) { + case 'A': /* CUU n */ t->cy -= params[0] ? params[0] : 1; break; + case 'B': /* CUD n */ t->cy += params[0] ? params[0] : 1; break; + case 'C': /* CUF n */ t->cx += params[0] ? params[0] : 1; break; + case 'D': /* CUB n */ t->cx -= params[0] ? params[0] : 1; break; + case 'H': /* CUP r;c */ { + int r = params[0] ? params[0] - 1 : 0; + int c = n_params > 1 && params[1] ? params[1] - 1 : 0; + t->cy = r; t->cx = c; + break; + } + case 'J': /* ED — erase display */ { + int mode = params[0]; + if (mode == 2) { + memset(t->grid, 0, (size_t)t->cols * t->rows * sizeof(struct cell)); + wpk_term_mark_dirty_all(t); + } else if (mode == 0) { + /* Erase from cursor to end of screen */ + int start = t->cy * t->cols + t->cx; + memset(&t->grid[start], 0, + ((size_t)t->cols * t->rows - start) * sizeof(struct cell)); + for (int r = t->cy; r < t->rows; r++) mark_dirty(t, r); + } + break; + } + case 'K': /* EL — erase line */ { + int mode = params[0]; + if (mode == 0) { + for (int x = t->cx; x < t->cols; x++) + memset(&t->grid[t->cy * t->cols + x], 0, sizeof(struct cell)); + mark_dirty(t, t->cy); + } + break; + } + case 'm': /* SGR — colour + bold + reverse */ { + if (n_params == 0) { t->fg = 7; t->bg = 16; t->flags = 0; break; } + for (int i = 0; i < n_params; i++) { + int p = params[i]; + if (p == 0) { t->fg = 7; t->bg = 16; t->flags = 0; } + else if (p == 1) t->flags |= 1; /* bold */ + else if (p == 7) t->flags |= 2; /* reverse */ + else if (p == 22) t->flags &= ~1; + else if (p == 27) t->flags &= ~2; + else if (p >= 30 && p <= 37) t->fg = p - 30; + else if (p == 39) t->fg = 7; /* default fg */ + else if (p >= 40 && p <= 47) t->bg = p - 40; + else if (p == 49) t->bg = 16; /* default bg */ + else if (p >= 90 && p <= 97) t->fg = p - 90 + 8; /* bright */ + else if (p >= 100 && p <= 107) t->bg = p - 100 + 8; + /* 256-colour (38;5;n) and truecolor (38;2;r;g;b) — silently + * dropped in v1. */ + } + break; + } + default: + /* Unknown CSI final — ignore in v1. */ + break; + } + /* Clamp. */ + if (t->cx < 0) t->cx = 0; + if (t->cx > t->cols - 1) t->cx = t->cols - 1; + if (t->cy < 0) t->cy = 0; + if (t->cy > t->rows - 1) t->cy = t->rows - 1; +} + +void wpk_term_feed(struct wpk_term *t, const char *bytes, size_t len) { + for (size_t i = 0; i < len; i++) { + unsigned char b = (unsigned char)bytes[i]; + switch (t->state) { + case GROUND: + if (b == 0x1b) { t->state = ESCAPE; } + else if (b == '\r') { t->cx = 0; } + else if (b == '\n') { + if (++t->cy >= t->rows) { scroll_up(t); t->cy = t->rows - 1; } + } + else if (b == '\b') { if (t->cx > 0) t->cx--; } + else if (b == '\t') { t->cx = (t->cx + 8) & ~7; } + else if (b == 0x07) { /* BEL — visual flash post-v1; ignore in v1 */ } + else if (b >= 0x20) put_char(t, b); /* ASCII; UTF-8 handled at A4 */ + break; + case ESCAPE: + if (b == '[') { t->state = CSI; t->csi_used = 0; } + else { t->state = GROUND; /* unknown 2-byte escape; drop */ } + break; + case CSI: + if (b >= 0x40 && b <= 0x7e) { + t->csi_buf[t->csi_used] = 0; + apply_csi(t, (char)b); + t->state = GROUND; + } else if (t->csi_used < (int)sizeof t->csi_buf - 1) { + t->csi_buf[t->csi_used++] = (char)b; + } + break; + } + } +} +``` + +**Cargo test:** drive the parser with `"hello\r\nworld"` and +assert the grid has "hello" on row 0 and "world" on row 1. +Drive with `"\x1b[2J\x1b[H"` and assert cells are zeroed + +cursor is at (0, 0). + +**Commit:** `sysroot(wpkterm): VT100 escape-sequence parser (CSI cursor + ED/EL + SGR)` + +### Task A4: UTF-8 input decoder + +```c +// src/parser.c (continuation) — split out the GROUND-state byte +// handler so multi-byte UTF-8 sequences decode to a single +// codepoint before put_char. + +static int utf8_decode(const unsigned char *bytes, size_t len, + uint32_t *out_cp, size_t *out_consumed) { + unsigned char b0 = bytes[0]; + if (b0 < 0x80) { *out_cp = b0; *out_consumed = 1; return 1; } + if ((b0 & 0xe0) == 0xc0 && len >= 2) { + *out_cp = ((b0 & 0x1f) << 6) | (bytes[1] & 0x3f); + *out_consumed = 2; return 1; + } + if ((b0 & 0xf0) == 0xe0 && len >= 3) { + *out_cp = ((b0 & 0x0f) << 12) | ((bytes[1] & 0x3f) << 6) + | (bytes[2] & 0x3f); + *out_consumed = 3; return 1; + } + if ((b0 & 0xf8) == 0xf0 && len >= 4) { + *out_cp = ((b0 & 0x07) << 18) | ((bytes[1] & 0x3f) << 12) + | ((bytes[2] & 0x3f) << 6) | (bytes[3] & 0x3f); + *out_consumed = 4; return 1; + } + /* Malformed or truncated — caller decides. */ + *out_cp = 0xFFFD; *out_consumed = 1; return 0; +} +``` + +Integrate into `wpk_term_feed`: replace the `put_char(t, b)` line +in the GROUND case with a UTF-8 decode loop. + +**Cargo test:** feed `"héllo"` (h, é = 0xC3 0xA9, l, l, o); +assert grid[0..5] has codepoints `'h', 0x00E9, 'l', 'l', 'o'`. + +**Commit:** `sysroot(wpkterm): UTF-8 decode (BMP-only, malformed → U+FFFD)` + +### Task A5: Render — cell grid → libwpkdraw + +```c +// src/render.c +#include <wpkterm/wpkterm.h> +#include <wpkdraw/wpkdraw.h> +#include "_internal.h" + +/* ANSI 16-colour palette, RGB triples. */ +static const uint8_t palette[17][3] = { + { 0, 0, 0}, {170, 0, 0}, { 0, 170, 0}, {170, 85, 0}, + { 0, 0, 170}, {170, 0, 170}, { 0, 170, 170}, {170, 170, 170}, + { 85, 85, 85}, {255, 85, 85}, { 85, 255, 85}, {255, 255, 85}, + { 85, 85, 255}, {255, 85, 255}, { 85, 255, 255}, {255, 255, 255}, + { 0, 0, 0}, /* index 16 = "default" — black */ +}; + +void wpk_term_render(struct wpk_term *t, + struct wpk_surface *s, + struct wpk_font *f, + int x, int y, int w, int h) { + int cell_w = wpk_font_advance_px(f); /* monospace advance */ + int cell_h = wpk_font_height_px(f); + int ascent = wpk_font_ascent_px(f); + + for (int row = 0; row < t->rows; row++) { + /* Skip clean rows. */ + if (!(t->dirty[row / 8] & (1u << (row % 8)))) continue; + t->dirty[row / 8] &= ~(1u << (row % 8)); + for (int col = 0; col < t->cols; col++) { + struct cell *c = &t->grid[row * t->cols + col]; + int px = x + col * cell_w; + int py = y + row * cell_h; + uint8_t fg = c->fg & 0x1f, bg = c->bg & 0x1f; + if (c->flags & 2) { uint8_t tmp = fg; fg = bg; bg = tmp; } + /* Background fill. */ + wpk_rect(s, px, py, cell_w, cell_h, + WPK_RGB(palette[bg][0], palette[bg][1], palette[bg][2])); + /* Glyph. */ + if (c->codepoint && c->codepoint != ' ') { + char utf8[5]; + int n = wpk_encode_utf8(c->codepoint, utf8); + utf8[n] = 0; + wpk_text(s, f, px, py + ascent, utf8, + WPK_RGB(palette[fg][0], palette[fg][1], palette[fg][2])); + } + } + } + /* Cursor: draw a thin underline at the current cell. */ + int cx_px = x + t->cx * cell_w; + int cy_px = y + (t->cy + 1) * cell_h - 2; + wpk_rect(s, cx_px, cy_px, cell_w, 2, WPK_RGB(220, 220, 220)); +} +``` + +`wpk_encode_utf8` (used here) lives in libwpkdraw or libwpkterm +utility — encode a UTF-32 codepoint back to 1-4 bytes for +`wpk_text`'s consumption. Add it under +`src/render.c` if libwpkdraw doesn't expose it. + +**Cargo test:** render a 4 × 4 grid with `"Hi"` in the top row; +assert two non-zero glyph pixels exist in the top-left quadrant +of the surface; assert the cursor underline is at (cell_w × 2, +cell_h × 1 - 2). + +**Commit:** `sysroot(wpkterm): render — cell → libwpkdraw + ANSI 16-colour palette + cursor` + +### Task A6: `wpk_term_input_key` — keysym → byte stream + +```c +// src/input.c +#include <wpkterm/wpkterm.h> +#include <string.h> +#include <stdio.h> + +/* Common xkb keysym values (from <xkbcommon/xkbcommon-keysyms.h>). */ +#define XKB_KEY_Return 0xff0d +#define XKB_KEY_BackSpace 0xff08 +#define XKB_KEY_Tab 0xff09 +#define XKB_KEY_Escape 0xff1b +#define XKB_KEY_Left 0xff51 +#define XKB_KEY_Up 0xff52 +#define XKB_KEY_Right 0xff53 +#define XKB_KEY_Down 0xff54 +#define XKB_KEY_Home 0xff50 +#define XKB_KEY_End 0xff57 +#define XKB_KEY_Page_Up 0xff55 +#define XKB_KEY_Page_Down 0xff56 +#define XKB_KEY_Delete 0xffff + +#define MOD_CTRL (1u << 2) +#define MOD_ALT (1u << 3) + +size_t wpk_term_input_key(uint32_t keysym, uint32_t mods, + char *out, size_t out_cap) { + /* Control combinations. */ + if ((mods & MOD_CTRL) && keysym >= 'a' && keysym <= 'z') { + if (out_cap < 1) return 0; + out[0] = (char)(keysym - 'a' + 1); /* C-a = 0x01, … C-z = 0x1a */ + return 1; + } + /* Named keys. */ + switch (keysym) { + case XKB_KEY_Return: if (out_cap < 1) return 0; out[0] = '\r'; return 1; + case XKB_KEY_BackSpace: if (out_cap < 1) return 0; out[0] = '\x7f'; return 1; + case XKB_KEY_Tab: if (out_cap < 1) return 0; out[0] = '\t'; return 1; + case XKB_KEY_Escape: if (out_cap < 1) return 0; out[0] = '\x1b'; return 1; + case XKB_KEY_Left: return (size_t)snprintf(out, out_cap, "\x1b[D"); + case XKB_KEY_Right: return (size_t)snprintf(out, out_cap, "\x1b[C"); + case XKB_KEY_Up: return (size_t)snprintf(out, out_cap, "\x1b[A"); + case XKB_KEY_Down: return (size_t)snprintf(out, out_cap, "\x1b[B"); + case XKB_KEY_Home: return (size_t)snprintf(out, out_cap, "\x1b[H"); + case XKB_KEY_End: return (size_t)snprintf(out, out_cap, "\x1b[F"); + case XKB_KEY_Page_Up: return (size_t)snprintf(out, out_cap, "\x1b[5~"); + case XKB_KEY_Page_Down: return (size_t)snprintf(out, out_cap, "\x1b[6~"); + case XKB_KEY_Delete: return (size_t)snprintf(out, out_cap, "\x1b[3~"); + } + /* Printable: pass through the keysym low byte (xkb returns the + * UTF-32 value for printable keys). UTF-8 encode if > 0x7f. */ + if (keysym >= 0x20 && keysym <= 0x10ffff) { + if (keysym < 0x80) { + if (out_cap < 1) return 0; + out[0] = (char)keysym; return 1; + } else if (keysym < 0x800) { + if (out_cap < 2) return 0; + out[0] = 0xc0 | (keysym >> 6); + out[1] = 0x80 | (keysym & 0x3f); + return 2; + } else if (keysym < 0x10000) { + if (out_cap < 3) return 0; + out[0] = 0xe0 | (keysym >> 12); + out[1] = 0x80 | ((keysym >> 6) & 0x3f); + out[2] = 0x80 | (keysym & 0x3f); + return 3; + } else { + if (out_cap < 4) return 0; + out[0] = 0xf0 | (keysym >> 18); + out[1] = 0x80 | ((keysym >> 12) & 0x3f); + out[2] = 0x80 | ((keysym >> 6) & 0x3f); + out[3] = 0x80 | (keysym & 0x3f); + return 4; + } + } + return 0; +} +``` + +**Cargo test:** assert `wpk_term_input_key(XKB_KEY_Return, 0, ...)` +returns 1 + "\r"; assert C-c (Ctrl+c) returns 1 + 0x03; assert +Left arrow returns 3 + "\x1b[D". + +**Commit:** `sysroot(wpkterm): input — keysym → byte stream (named keys + Ctrl + UTF-8)` + +### Task A7: Smoke program + +```c +// programs/wpkterm_smoke.c +#include <wpkterm/wpkterm.h> +#include <stdio.h> +int main(void) { + struct wpk_term *t = wpk_term_create(80, 25); + if (!t) return 1; + wpk_term_feed(t, "\x1b[2J\x1b[H", 7); /* clear + home */ + wpk_term_feed(t, "Hello, ", 7); + wpk_term_feed(t, "\x1b[31mworld\x1b[0m\r\n", 17); + wpk_term_destroy(t); + return 0; +} +``` + +**Vitest:** spawn the smoke; assert exit 0. (Render-path is +exercised in Phase B with the live shell.) + +**Commit:** `examples(wpkterm): wpkterm_smoke — create, feed, destroy` + +### Task A8: Phase A — full gauntlet + open PR #1 + +PR title: `[explore-dri] sysroot(wpkterm): libwpkterm — VT100 cell grid + parser + render` + +Body covers: cell grid + cursor + scrollback ring, parser (CSI +cursor / erase / SGR + UTF-8 decode), render via libwpkdraw + +ANSI 16-colour palette, keysym → byte stream input mapping. +ABI impact: none. + +--- + +## Phase B — examples: wpkshell binary (PR #2) + +The compositor-client shell — compositor wire + terminal + shell +built-ins + fork-exec pipelines. + +### Task B1: Init amendment — exec wpkshell when compositor present + +**Files:** +- Modify: `examples/init/init.c` (post plan 9 D1) — choose between + `/bin/sh` and `/usr/bin/wpkshell` based on + `/etc/wpk/compositor` presence. + +```c +/* After plan 9 D1's compositor fork-exec, before the user shell exec: */ +const char *shell = "/bin/sh"; +if (access("/etc/wpk/compositor", F_OK) == 0) + shell = "/usr/bin/wpkshell"; +execl(shell, shell, NULL); +``` + +**Commit:** `examples(init): exec wpkshell as user shell when compositor present` + +### Task B2: Program scaffold + main loop skeleton + +**Files:** +- Create: `examples/programs/wpkshell/main.c` — top-level + compositor-client + terminal-render loop. +- Create: `examples/programs/wpkshell/shell.h` — shared types + between main.c and shell.c. +- Create: `examples/programs/wpkshell/Makefile` — wired into + `scripts/build-programs.sh`. + +```c +// main.c +#define _GNU_SOURCE +#include <fcntl.h> +#include <poll.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/socket.h> +#include <sys/wait.h> +#include <unistd.h> + +#include <wpkclient/wpkclient.h> +#include <wpkdraw/wpkdraw.h> +#include <wpkterm/wpkterm.h> +#include "shell.h" + +#define SURFACE_W 800 +#define SURFACE_H 600 +#define COLS 80 +#define ROWS 25 + +int main(void) { + /* 1. Connect to compositor (fatal if none — wpkshell is a + * compositor client, not a TTY-only shell). */ + struct wpk_client *cl = wpk_client_connect(); + if (!cl) { + fprintf(stderr, "wpkshell: no compositor at /run/wpk/comp\n"); + return 1; + } + /* 2. Create surface + libwpkdraw wpk_surface for rendering. */ + int sw = SURFACE_W, sh = SURFACE_H; + struct wpk_surface *s = wpk_surface_create(&sw, &sh); /* compositor-client mode */ + if (!s) { wpk_client_disconnect(cl); return 1; } + /* 3. Terminal grid + default font. */ + struct wpk_term *t = wpk_term_create(COLS, ROWS); + struct wpk_font *f = wpk_font_load_default(14); + /* 4. Set up shell ↔ terminal pipes. */ + int pipe_to_shell[2], pipe_from_shell[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, pipe_to_shell) < 0) return 1; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, pipe_from_shell) < 0) return 1; + /* 5. Fork the shell. */ + pid_t shell_pid = fork(); + if (shell_pid == 0) { + /* Child: dup2 the pipe ends to stdin/stdout, exec shell_main. */ + dup2(pipe_to_shell[0], 0); + dup2(pipe_from_shell[1], 1); + dup2(pipe_from_shell[1], 2); + close(pipe_to_shell[1]); close(pipe_from_shell[0]); + close(pipe_to_shell[0]); close(pipe_from_shell[1]); + exit(shell_main()); /* defined in shell.c; never returns */ + } + close(pipe_to_shell[0]); close(pipe_from_shell[1]); + /* 6. Initial render. */ + wpk_surface_clear(s, WPK_RGB(20, 20, 25)); + wpk_term_render(t, s, f, 8, 8, SURFACE_W - 16, SURFACE_H - 16); + wpk_surface_present(s); + /* 7. Event loop. */ + struct pollfd fds[2] = { + { wpk_client_get_fd(cl), POLLIN, 0 }, + { pipe_from_shell[0], POLLIN, 0 }, + }; + int quitting = 0; + while (!quitting) { + int n = poll(fds, 2, -1); + if (n < 0) { if (errno == EINTR) continue; break; } + /* Compositor events. */ + if (fds[0].revents & POLLIN) { + struct wpk_client_event ev[16]; + int nev = wpk_client_poll(cl, ev, 16); + for (int i = 0; i < nev; i++) { + if (ev[i].type == WPK_CLIENT_KEY && ev[i].key.pressed) { + char buf[8]; + size_t k = wpk_term_input_key(ev[i].key.keysym, + ev[i].key.modifiers, buf, sizeof buf); + if (k > 0) write(pipe_to_shell[1], buf, k); + } else if (ev[i].type == WPK_CLIENT_WINDOW_CLOSE) { + quitting = 1; + } + } + } + /* Shell stdout. */ + if (fds[1].revents & POLLIN) { + char buf[1024]; + ssize_t r = read(pipe_from_shell[0], buf, sizeof buf); + if (r <= 0) { quitting = 1; break; } + wpk_term_feed(t, buf, (size_t)r); + wpk_surface_clear(s, WPK_RGB(20, 20, 25)); + wpk_term_render(t, s, f, 8, 8, SURFACE_W - 16, SURFACE_H - 16); + wpk_surface_present(s); + } + } + /* Cleanup. */ + kill(shell_pid, SIGTERM); + waitpid(shell_pid, NULL, 0); + wpk_font_destroy(f); + wpk_term_destroy(t); + wpk_surface_destroy(s); + wpk_client_disconnect(cl); + return 0; +} +``` + +**Commit:** `examples(wpkshell): scaffold main.c — compositor client + terminal grid + shell fork` + +### Task B3: Shell core — command-line parser + read loop + +```c +// shell.c +#include "shell.h" +#include <ctype.h> +#include <errno.h> +#include <fcntl.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/wait.h> +#include <unistd.h> + +/* Parse one command line into argv-style pipeline stages. + * `line` is mutated (whitespace replaced with NULs). + * `stages[][16]` is filled with NULL-terminated argv arrays. + * Returns number of stages (1 for `cmd args`; 2 for `cmd | cmd2`). */ +int parse_pipeline(char *line, char *stages[][16], int max_stages) { + int n_stages = 0; + char *p = line; + while (*p && n_stages < max_stages) { + /* Skip whitespace */ + while (*p && isspace((unsigned char)*p)) p++; + if (!*p) break; + /* Collect args until `|` or EOL. */ + int argc = 0; + while (*p && *p != '|' && argc < 15) { + stages[n_stages][argc++] = p; + while (*p && !isspace((unsigned char)*p) && *p != '|') p++; + if (*p && isspace((unsigned char)*p)) { *p++ = 0; } + while (*p && isspace((unsigned char)*p) && *p != '|') p++; + } + stages[n_stages][argc] = NULL; + n_stages++; + if (*p == '|') { *p++ = 0; } + } + return n_stages; +} + +int shell_main(void) { + /* Print initial prompt. */ + print_prompt(); + char line[1024]; + int line_used = 0; + for (;;) { + char c; + ssize_t r = read(0, &c, 1); + if (r <= 0) return 0; /* EOF / parent closed */ + if (c == '\r' || c == '\n') { + write(1, "\r\n", 2); + line[line_used] = 0; + if (line_used > 0) execute_line(line); + line_used = 0; + print_prompt(); + } else if (c == 0x7f) { /* Backspace */ + if (line_used > 0) { + line_used--; + write(1, "\b \b", 3); + } + } else if (c == 0x03) { /* Ctrl-C */ + line_used = 0; + write(1, "^C\r\n", 4); + print_prompt(); + } else if (c >= 0x20 && line_used < (int)sizeof line - 1) { + line[line_used++] = c; + write(1, &c, 1); /* echo */ + } + } +} + +void print_prompt(void) { + char cwd[256]; + if (!getcwd(cwd, sizeof cwd)) strcpy(cwd, "?"); + dprintf(1, "\x1b[32m%s\x1b[0m$ ", cwd); +} + +void execute_line(char *line) { + char *stages[8][16]; + int n = parse_pipeline(line, stages, 8); + if (n == 0) return; + if (n == 1) { + /* Single command — check built-ins first. */ + if (try_builtin(stages[0])) return; + run_external(stages[0]); + } else { + run_pipeline(stages, n); + } +} +``` + +**Cargo test:** parse `"echo hi"` → stages[0] = `["echo", "hi", NULL]`, +n = 1. Parse `"ls / | cat"` → stages[0] = `["ls", "/", NULL]`, +stages[1] = `["cat", NULL]`, n = 2. + +**Commit:** `examples(wpkshell): shell core — command-line parser + read loop + prompt` + +### Task B4: Built-ins — cd, ls, cat, echo, exit + +```c +// builtins.c +#include "shell.h" +#include <dirent.h> +#include <errno.h> +#include <fcntl.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <unistd.h> + +int try_builtin(char *argv[]) { + if (!argv[0]) return 1; + if (strcmp(argv[0], "cd") == 0) { + const char *target = argv[1] ? argv[1] : "/"; + if (chdir(target) < 0) dprintf(2, "cd: %s\r\n", strerror(errno)); + return 1; + } + if (strcmp(argv[0], "ls") == 0) { + const char *target = argv[1] ? argv[1] : "."; + DIR *d = opendir(target); + if (!d) { dprintf(2, "ls: %s\r\n", strerror(errno)); return 1; } + struct dirent *e; + while ((e = readdir(d))) { + if (e->d_name[0] == '.') continue; + char path[512]; + snprintf(path, sizeof path, "%s/%s", target, e->d_name); + struct stat st; + int is_dir = stat(path, &st) == 0 && S_ISDIR(st.st_mode); + if (is_dir) dprintf(1, "\x1b[34m%s\x1b[0m ", e->d_name); + else dprintf(1, "%s ", e->d_name); + } + write(1, "\r\n", 2); + closedir(d); + return 1; + } + if (strcmp(argv[0], "cat") == 0) { + if (!argv[1]) { dprintf(2, "cat: missing operand\r\n"); return 1; } + int fd = open(argv[1], O_RDONLY); + if (fd < 0) { dprintf(2, "cat: %s\r\n", strerror(errno)); return 1; } + char buf[4096]; + ssize_t r; + while ((r = read(fd, buf, sizeof buf)) > 0) write(1, buf, r); + close(fd); + return 1; + } + if (strcmp(argv[0], "echo") == 0) { + for (int i = 1; argv[i]; i++) { + if (i > 1) write(1, " ", 1); + write(1, argv[i], strlen(argv[i])); + } + write(1, "\r\n", 2); + return 1; + } + if (strcmp(argv[0], "exit") == 0) { + exit(argv[1] ? atoi(argv[1]) : 0); + } + return 0; +} +``` + +**Vitest:** spawn wpkshell + compositor; type `echo hello\r`; +assert the terminal grid contains "hello" after a render tick. +Type `ls /\r`; assert grid contains "etc" + "dev" + "usr" tokens. + +**Commit:** `examples(wpkshell): builtins — cd, ls, cat, echo, exit` + +### Task B5: External-command fork-exec + pipelines + +```c +// exec.c +#include "shell.h" +#include <errno.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/socket.h> +#include <sys/wait.h> +#include <unistd.h> + +static const char *path_dirs[] = { "/usr/bin", "/bin", NULL }; + +/* Resolve argv[0] against PATH; return malloc'd full path or NULL. */ +static char *resolve(const char *cmd) { + if (strchr(cmd, '/')) return strdup(cmd); + for (int i = 0; path_dirs[i]; i++) { + char *p = malloc(strlen(path_dirs[i]) + 1 + strlen(cmd) + 1); + sprintf(p, "%s/%s", path_dirs[i], cmd); + if (access(p, X_OK) == 0) return p; + free(p); + } + return NULL; +} + +void run_external(char *argv[]) { + char *path = resolve(argv[0]); + if (!path) { dprintf(2, "%s: command not found\r\n", argv[0]); return; } + pid_t pid = fork(); + if (pid == 0) { + execv(path, argv); + dprintf(2, "%s: %s\r\n", path, strerror(errno)); + _exit(127); + } + free(path); + int status = 0; + waitpid(pid, &status, 0); +} + +void run_pipeline(char *stages[][16], int n) { + /* (n-1) socketpairs; n children. Each child closes the unused + * fds + dup2's the right ends + execs. */ + int sp[8][2]; /* one socketpair per stage boundary */ + for (int i = 0; i < n - 1; i++) + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sp[i]) < 0) return; + pid_t pids[8]; + for (int i = 0; i < n; i++) { + char *path = resolve(stages[i][0]); + if (!path) { + dprintf(2, "%s: command not found\r\n", stages[i][0]); + /* Drain previously-forked stages. */ + for (int j = 0; j < i; j++) waitpid(pids[j], NULL, 0); + return; + } + pid_t pid = fork(); + if (pid == 0) { + /* Wire stage i to its neighbours. */ + if (i > 0) dup2(sp[i - 1][0], 0); + if (i < n - 1) dup2(sp[i][1], 1); + /* Close all socketpair fds in the child. */ + for (int j = 0; j < n - 1; j++) { + close(sp[j][0]); close(sp[j][1]); + } + execv(path, stages[i]); + _exit(127); + } + free(path); + pids[i] = pid; + } + /* Parent: close all socketpair fds + wait. */ + for (int i = 0; i < n - 1; i++) { close(sp[i][0]); close(sp[i][1]); } + for (int i = 0; i < n; i++) waitpid(pids[i], NULL, 0); +} +``` + +**Vitest:** type `ls / | cat\r`; assert the terminal grid eventually +contains the same content as `ls /` alone (cat passes through). +Type `cat /etc/wpk/compositor | cat\r`; same idempotency check. + +**Commit:** `examples(wpkshell): exec — fork+exec external commands + pipeline construction via socketpair` + +### Task B6: Phase B — full gauntlet + open PR #2 + +PR title: `[explore-dri] examples(wpk): wpkshell — compositor client + built-in shell + fork-exec pipes` + +Body covers: compositor wire, terminal-grid render loop, shell +read-loop + line parser, five built-ins (cd, ls, cat, echo, exit), +external command resolution against /usr/bin + /bin, pipeline +construction via socketpair + n-stage fork-exec. ABI impact: none. + +--- + +## Phase C — demo polish + browser verification (PR #3) + +### Task C1: Default `.wpkrc` welcome banner + +Render a one-line banner on shell start: `wpkshell — type help for +commands` (built-in `help` is added in C2 below). + +```c +// In shell.c::shell_main, before the first prompt: +const char *banner = + "\x1b[36mwpkshell — type \x1b[1mhelp\x1b[22m for commands\x1b[0m\r\n"; +write(1, banner, strlen(banner)); +print_prompt(); +``` + +**Commit:** `examples(wpkshell): startup banner — wpkshell welcome line` + +### Task C2: Built-in `help` + +```c +// In builtins.c::try_builtin: +if (strcmp(argv[0], "help") == 0) { + write(1, + "wpkshell built-ins:\r\n" + " cd [path] change directory\r\n" + " ls [path] list directory\r\n" + " cat <file> print file\r\n" + " echo [args] print args\r\n" + " help this help\r\n" + " exit [code] quit shell\r\n" + "External commands resolved via PATH=/usr/bin:/bin.\r\n" + "Pipelines via |.\r\n", 232); + return 1; +} +``` + +**Commit:** `examples(wpkshell): help built-in` + +### Task C3: Vitest end-to-end + +```ts +// host/test/wpkshell.spec.ts +test("wpkshell — boot, prompt, echo, exit", async () => { + const h = await spawnCompositor(); + const shell = await spawn("wpkshell.wasm"); + await waitForPrompt(h); // grid contains "$" + await typeLine(h, "echo hello"); + await assertGridContains(h, "hello"); + await typeLine(h, "exit"); + await shell.exited; +}); +test("wpkshell — ls + pipe", async () => { + const h = await spawnCompositor(); + const shell = await spawn("wpkshell.wasm"); + await waitForPrompt(h); + await typeLine(h, "ls / | cat"); + await assertGridContains(h, "etc"); + await typeLine(h, "exit"); +}); +``` + +**Commit:** `examples(wpkshell): vitest — boot, prompt, echo, ls/pipe` + +### Task C4: Manual browser verification (the gate) + +CLAUDE.md item 6. Build wpkcompositor + wpkshell, wire into +`examples/browser/pages/wpkshell/`. The browser page mounts: + +1. wpkcompositor at PID 2. +2. wpkshell at PID 3, in a single 800×600 window. +3. The shell renders a banner + green `$` prompt. +4. Type `help` + Enter — built-in help table renders. +5. Type `echo hello, world` — green/white output line wraps + cleanly. +6. Type `ls /` — directory listing renders with blue entries + for directories. +7. Type `cat /etc/wpk/compositor` — file contents render. +8. Type `ls / | cat` — pipeline runs; output matches `ls /`. +9. Type `nosuchcmd` — `nosuchcmd: command not found` in default + colour. +10. Press Ctrl-C mid-line — input line clears, fresh prompt. +11. Type `exit` — window closes; compositor returns to empty + desktop. + +Browser cursor must follow the terminal cursor (compositor-side +software cursor is separate; verify it doesn't interfere). + +If the prompt never appears, fork-exec of the shell failed — +check `wait4` returns + the dup2 sequence. + +If pipelines hang, the socketpair end-closing in `run_pipeline` +is incomplete — check `lsof` equivalent (or the kernel's OFD +table dump in vitest). + +If colour output garbles, the SGR parser is dropping a sequence — +check `apply_csi` with `m` final. + +**No commit yet — verification only.** + +### Task C5: Phase C — final gauntlet + open PR #3 + +PR title: `[explore-dri] examples(wpk): wpkshell — demo polish + browser spec` + +Body: welcome banner, help built-in, vitest specs (boot + pipe), +manual browser walk-through. ABI impact: none. + +--- + +## Final coordinated merge + +When all three PRs are reviewed and approved, the browser +verification passes: + +1. Re-run the full gauntlet on each PR's branch tip. +2. Squash-merge PR #1 (libwpkterm) → PR #2's base. +3. Squash-merge PR #2 (wpkshell + init amendment) → PR #3's base. +4. Squash-merge PR #3 (demo polish + browser spec) → plan 9's + `…-wpk-demo` (or wherever plan 9's tip lives at the time). +5. Tag: `[explore-dri-wpkshell] plan 10 merged at <sha>` in the + next session-handoff doc. + +**Do not push to upstream until v1 + plans 2–11 are all merged +upstream as a coherent chain.** + +--- + +## Trade-offs already locked in (don't relitigate during implementation) + +- **In-process shell + terminal, no PTY.** v1 shell is a function + call away from the terminal renderer; no kernel pty surface + needed. Post-v1: a real `openpty` + `forkpty` path lets + external shells (bash, zsh) run inside wpkshell. +- **Five built-ins only.** cd, ls, cat, echo, exit (+ help in + C2). No `pwd` (overlap with prompt), no `env`, no `set`, + no `export` (no env-var expansion in v1). +- **No quoting / variable expansion / wildcards / redirection.** + Whitespace-only tokenisation. Tokens beginning with `$`, `*`, + `>`, `<` pass through literally. +- **VT100 subset, 16-colour palette.** No 256-colour, no + truecolor, no mouse, no alt-screen, no line-drawing, no + Sixel/Kitty graphics. +- **Fixed 80 × 25 grid in 800 × 600 surface.** Window resize is + v2 (plan 9 surfaces are client-immutable dims in v1). +- **Monospace font only.** DejaVu Sans falls back to monospace + metrics; v1 ships DejaVu Mono if available, else DejaVu Sans + with monospace advance. +- **PATH = /usr/bin:/bin, hardcoded.** No /etc/profile, no + ~/.bashrc, no PATH env var read. +- **Pipelines via socketpair, not pipe(2).** `pipe(2)` returns + uni-directional file descriptors; `socketpair` is + bidirectional but we only use one direction. Equivalent for + v1. (Either works; socketpair is what plan 6 ships as the + flagship surface.) +- **No background jobs (`&`), no job control, no SIGSTOP / + SIGCONT.** Shell waits on each foreground command. +- **No signal forwarding from shell to children.** Ctrl-C in + the shell clears the input line; doesn't kill children. + Post-v1 with proper PTY: signals propagate. +- **No history, no readline.** Press up-arrow → CSI A passes to + the child or is dropped (no history buffer). +- **One window, one shell, one tab.** No multi-tab terminal; + no "open another shell". +- **Compositor-only.** No direct-KMS fall-back. wpkshell exits + if no compositor is present (matches plan 9 client + expectations). +- **No clipboard.** Cut/paste via the compositor's CLIPBOARD_* + messages is a v2 feature. +- **Init exec wpkshell only if `/etc/wpk/compositor` exists.** + Absent → existing /bin/sh path remains (WordPress demo + compatibility). +- **Zero ABI impact.** No kernel exports, no host imports. + Existing socket + process surfaces only. + +--- + +## Risk register + +1. **socketpair-as-pipe semantics.** Plan 6 ships `socketpair` + as a bidirectional AF_UNIX. Plan 10 uses it as a one-way + pipe (shell write side → next stage read side). EOF + propagation when one end closes must work — `read` on a + closed peer returns 0. *Mitigation:* plan 6 audit (session + 10) confirms this. Cargo test in plan 6's register may + already cover; B5 adds a wpkshell-specific test. +2. **Fork-exec depth + the kernel's process table.** A 3-stage + pipeline forks 3 children + waits all. The kernel's + `PROCESS_TABLE` (CLAUDE.md cites it; per plan 7 open-arch + #2's profiling-driven OFD-table-split refactor) is the + contention point. *Mitigation:* v1 has at most 3-stage + pipelines (typical user); profile in C4. If contention is + visible, defer to the OFD-table-split refactor. +3. **`opendir` / `readdir` on synthetic /dev.** Plan 5's + `event0`/`event1` synthetic entries + plan 9's + /run/wpk/comp socket inode — does `readdir` return them? + v1 expects YES (kernel registers them as devfs entries). + *Mitigation:* B4 cargo test asserts `ls /dev` includes + `event0` + `event1`. If it doesn't, the kernel's devfs + producer is the bug, not wpkshell. +4. **Font advance for non-monospace fallback.** DejaVu Sans is + variable-width. `wpk_font_advance_px` returns the M-advance + as a heuristic. *Mitigation:* the v1 demo's output uses + ASCII only; non-ASCII text in a non-monospace fallback + looks ugly but readable. Post-v1: ship DejaVu Sans Mono. +5. **Cursor advance on shell echo.** When the user types a + character, the shell writes it back to stdout for echo. + Multiple `write(1, &c, 1)` calls per keypress are slow + under per-syscall overhead. *Mitigation:* v1 accepts the + cost. Post-v1: line buffering with explicit flush on `\r`. +6. **`wait4` blocking the shell read loop.** If `run_external` + spawns a long-running child, the shell's read loop blocks + on `waitpid`. The terminal emulator's render loop is in a + different process (main.c is the terminal, shell.c is a + forked child) so the terminal stays responsive. *Mitigation:* + architecture already addresses this — terminal and shell are + separate processes. +7. **Compositor crash mid-pipeline.** Shell parent dies on + EPIPE from the terminal-process writes; children are + orphaned. Init reaps them via `wait`. *Mitigation:* plan 9 + risk register #2 covers this; wpkshell exits cleanly when + the compositor dies. +8. **VT100 unknown-escape garble.** External commands that + emit 256-colour or alt-screen escapes (which plan 10 + doesn't implement) corrupt the cell grid. *Mitigation:* + the parser silently drops unknown CSI finals (per A3); no + crash, just visual artifacts. v1's external commands + (ls, cat, echo) emit only the 16-colour subset. +9. **xkb modifier mask convention mismatch.** Plan 9 D4 emits + `WPK_CLIENT_KEY.modifiers` as the xkbcommon + "effective mods" bitmask. Plan 10 input.c assumes Ctrl = + bit 2 (matches xkbcommon's MOD_CONTROL by index). *Mitigation:* + document the convention; if xkbcommon's bit assignment + shifts in a future port, plan 10's input.c needs an update. +10. **Init shell-line race.** Plan 10 B1 amends init to exec + `/usr/bin/wpkshell` if compositor present. Init forks + wpkcompositor first (plan 9 D1), then waits ~0 ticks + before exec'ing the shell. If the compositor hasn't yet + bound `/run/wpk/comp` when wpkshell tries to connect, + wpkshell fails fatally. *Mitigation:* add a small connect + retry loop in `wpk_client_connect` (~10 × 10 ms backoff) + OR have init `waitpid(compositor_pid, WNOHANG)` + sleep + until `/run/wpk/comp` exists. + +--- + +## What this plan doesn't cover (deferred) + +- **PTY surface.** Real `openpty` / `forkpty` for hosting + external shells (bash, zsh). v2. +- **Quoting + globbing + redirection.** `"foo bar"`, `*.c`, + `>` / `<` / `>>`. v2. +- **Variable expansion.** `$HOME`, `$PATH`. v2. +- **Job control.** `&` background, `Ctrl-Z` suspend, `fg` / + `bg`. v2. +- **History + readline + tab completion.** v2. +- **Multi-tab / multi-window terminal.** v2. +- **256-colour / truecolor / alt-screen / mouse-tracking.** + v2 may add the alt-screen pair (needed for `less`, `vim`); + 256-colour is straightforward but bloats the cell struct. +- **Sixel / Kitty graphics.** Out of scope. +- **Clipboard integration.** Per plan 9, CLIPBOARD_* messages + are reserved but unimplemented; v2. +- **Window resize.** v1 surfaces are client-immutable dims; + no SIGWINCH plumbing. +- **External shells (bash, sh, dash) as alternatives.** v2 with + PTY surface. +- **Scrollback UI.** Cell-grid scrollback ring exists in + libwpkterm but no scroll-up keybinding renders it. v2. +- **Locale / LC_ALL handling.** UTF-8 throughout; no other + encoding. +- **`.wpkrc` config file.** No per-user shell config in v1. +- **wpkshell as a libwayland-client-compatible app** (post-v1 + Wayland bridge from plan 9 §15). v2. +- **In-process Lua / scripting.** No. + +--- + +End of plan. diff --git a/docs/plans/2026-07-27-wpk-seed-apps-plan.md b/docs/plans/2026-07-27-wpk-seed-apps-plan.md new file mode 100644 index 0000000000..db51e21566 --- /dev/null +++ b/docs/plans/2026-07-27-wpk-seed-apps-plan.md @@ -0,0 +1,1733 @@ +# DRI v2 — seed apps plan (wpkfm file-manager + SDL_wpkvideo + wpkbeep audio + wpkpanel widget bar) + +> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to execute this plan task-by-task. + +**Goal:** Ship four small compositor-client applications that +exercise the plan 2–10 surface end-to-end and turn the +single-window wpkshell demo of plan 10 into a recognisable +desktop: + +1. **`examples/programs/wpkfm/`** (~800 LoC) — a file manager. + Tree view of the rootfs rendered via libwpkdraw, clickable + rows, double-click-to-descend / single-click-to-select, + keyboard navigation (Up/Down/Enter/Backspace), one-line + status bar. Operates over plain `opendir`/`readdir`/`stat` — + no shell-out, no fork-exec. Lets a user actually navigate the + filesystem without typing. +2. **`sysroot/patches/sdl2-wpkvideo/` + `examples/programs/wpkcube/`** + — a new SDL2 video backend (`SDL_wpkvideo`, ~400 LoC of SDL2 + patch) that allocates `SDL_Window` surfaces via libwpkclient + instead of taking KMS master. The cube demo from plan 7 + PR #2 is rebuilt against this backend; both compositor and + GLES2 cube coexist in one screen. This is the **post-v1 GL + coexistence path** the plan 7 KMS-master-coexistence + cross-plan amendment promised. +3. **`examples/programs/wpkbeep/`** (~300 LoC) — a 1-button + audio demo. Single 80×40 button; clicking enqueues a 1 s + 440 Hz sine wave into `/dev/snd/pcmC0D0p` using plan 6's + ALSA `WRITEI` + plan 7's non-blocking-WRITEI + EAGAIN-poll + amendment. Replaces the plan 6 standalone beep demo with a + compositor-client variant. +4. **`examples/programs/wpkpanel/`** (~400 LoC) — a 24-px-tall + panel strip across the top of the screen. Shows a clock + (HH:MM, right-aligned), an active-application indicator + (left, polled via `compositor_query_focus` if plan 9 ships + that — see "Cross-references" below), and a static system- + tray placeholder (right, fixed icons). Runs as **PID 4** — + init forks it after PID 3 (wpkshell) so the panel paints + above whatever's underneath without obstructing window + placement (compositor reserves the top 24 px from the usable + work-area). + +Combined: ~1900 LoC of new C across four apps, one SDL2 video +backend patch under `sysroot/patches/`, and one new package +recipe (`libwpkdraw_widgets` — a tiny static helper for the +button / row / status-bar widgets shared by wpkfm + wpkbeep + +wpkpanel; see "Architecture" below). + +**Architecture:** Four PRs, one per app. Each task below is one +commit. A small fifth piece — the `libwpkdraw_widgets` helper — +ships as a sub-task of PR #1 (it's the dependency wpkfm needs +first, and wpkbeep + wpkpanel re-link the same archive). + +1. **`examples/libs/libwpkdraw_widgets/`** (~250 LoC, ships in + PR #1 alongside wpkfm) — a *very* thin widget library: + `wpkw_button(surface, x, y, w, h, label, pressed) → void`, + `wpkw_row(surface, x, y, w, label, selected) → void`, + `wpkw_status(surface, x, y, w, text) → void`. Three drawing + primitives over libwpkdraw + DejaVu Sans 12px. No event- + handling state; callers track focus / press state in their + own structs and pass the booleans. Intentionally tiny — a + real widget framework is post-v1. +2. **`examples/programs/wpkfm/`** — main loop + filesystem + listing + libwpkdraw_widgets row rendering. Compositor + client via libwpkclient. Two columns: directory tree on + left (clickable rows), preview pane on right (text snippet + for `S_ISREG` entries with magic-byte sniffing, blank for + `S_ISDIR`). +3. **`sysroot/patches/sdl2-wpkvideo/`** — a patch series against + plan 7's vendored SDL2 source that adds a new video backend + `SDL_wpkvideo`. Surface allocation goes through libwpkclient + instead of `drmModeSetCrtc`; SDL2's GL context binds an EGL + surface backed by a gbm bo whose prime fd is shared to the + compositor via SCM_RIGHTS (plan 2 + plan 9 D3 path). Built + from a new build artifact `libSDL2_wpkvideo.a` (separate + archive from plan 7's `libSDL2.a`; they're mutually + exclusive at link time — wpkcube links the wpkvideo variant, + plan 7's KMSDRM demo links the KMSDRM variant). +4. **`examples/programs/wpkcube/`** — the plan 7 cube demo + compiled against `libSDL2_wpkvideo.a`. Same source as plan + 7 PR #2's cube demo with `SDL_SetHintWithPriority( + SDL_HINT_VIDEODRIVER, "wpkvideo", SDL_HINT_OVERRIDE);` at + the top. Visual proof: a GLES2-rendered spinning cube + coexisting with wpkfm and wpkbeep on a wpkcompositor + desktop. +5. **`examples/programs/wpkbeep/`** — compositor client + + libwpkdraw_widgets button + ALSA WRITEI worker. Click → + non-blocking `ioctl(SNDRV_PCM_IOCTL_WRITEI_FRAMES)` loop + feeding a 1 s sine wave from a precomputed 44 100-sample + buffer; poll-based progress indicator (red light during + playback, green light when ready). +6. **`examples/programs/wpkpanel/`** — compositor client + + libwpkdraw_widgets status renderer + `clock_gettime` + polling (~1 Hz redraw cadence). Init amendment extended: + PID 4 after PID 3, gated on `/etc/wpk/compositor` presence + (same gate as plan 9 D1 + plan 10 B1). + +The four apps **share no state**, communicate only with the +compositor, and run as independent kernel processes. They are +NOT integrated; killing one does not affect the others. The +panel is the only app started by init at boot; wpkfm + wpkbeep + +wpkcube are user-launched from wpkshell (`wpkfm &` for example, +once job-control lands — until then, the demo's `./run.sh +browser` script pre-launches them as background PIDs). + +**Tech Stack:** +- Userland C: C99 with `wasm32posix-cc`; static archives only. +- All four apps link `-lwpkclient -lwpkdraw -lwpkdraw_widgets + -lc`; wpkcube additionally links `-lSDL2_wpkvideo -lEGL + -lGLESv2 -lgbm`; wpkbeep additionally links `-lasound` (plan + 6 ALSA wrapper). +- Wire format: libwpkclient (plan 9) — surface create/attach/ + commit + INPUT_* event polling. No new wire types. +- Buffer sharing: client → compositor via plan 2's prime fd + + SCM_RIGHTS over libwpkclient. NO host imports. +- Input: libwpkclient delivers `WPK_CLIENT_KEY` + + `WPK_CLIENT_POINTER_BUTTON` + `WPK_CLIENT_POINTER_MOTION` + events with compositor-side xkb resolution. +- Audio (wpkbeep): plan 6 ALSA `WRITEI_FRAMES` ioctl + plan 7 + non-blocking poll + plan 6's EAGAIN return arm. +- GL (wpkcube): plans 2 + 3 follow-up + SDL2 patched with + `SDL_wpkvideo` backend. wpkvideo's `CreateWindow` calls + libwpkclient; `GL_CreateContext` binds an EGL surface to a + gbm bo shared with the compositor. +- Text rendering: libwpkdraw + DejaVu Sans 12 px (panel + + widget labels); 10 px (file-manager row text, denser). +- Clock source: `clock_gettime(CLOCK_MONOTONIC, …)` (plan 10 + parity) for repeat-rate gating; `clock_gettime(CLOCK_REALTIME, + …)` + `localtime_r` for the panel's wall clock. + +**Companion design doc:** `docs/plans/2026-05-18-dri-design.md` +§9.3 (libwpkdraw consumer story), §9.6 (panel + tray placement +contract), §13 (SCM_RIGHTS for prime-fd attach), §9.5 +(custom-protocol vs Wayland trade-off — relevant to +`SDL_wpkvideo` design). + +**Critical wasm32 ABI detail:** Every byte goes through existing +syscalls. **Zero kernel exports added; zero host imports added.** +The SDL_wpkvideo backend is a sysroot patch over plan 7's SDL2 +source — it does not introduce new kernel surface; it routes +SDL2's existing video-backend abstraction through libwpkclient ++ libgbm instead of through `/dev/dri/card0`. + +**Consistency with plans 2 + 4 + 5 + 6 + 7 + 8 + 9 + 10:** + +- **No new kernel exports.** All app surface is userspace C + over the plans 2 + 6 + 7 + 8 + 9 + 10 existing surfaces. +- **Plan 2 + plan 3 GL stack follow-ups are required for PR + #2.** wpkcube depends on `gbm_surface_create` + `EGL_KHR_*` + extensions + `libgbm` exporter via `gbm_bo_export`. These + land in the plan 2 + 3 follow-up PRs (open at the start of + the implementation sequence per handoff-10). +- **Plan 6 + plan 7 audio resolution (b) is required for + PR #3.** wpkbeep uses non-blocking `WRITEI` + EAGAIN polling. + Plan 6 ships the EAGAIN-return arm as the cross-plan + amendment. +- **Plan 7's `SDL_wpkvideo` backend replaces plan 7's KMSDRM + backend at link time.** Plan 7's KMSDRM demo (PR #2) + continues to exist as the direct-KMS path; wpkcube is the + compositor-mediated path. They build to two different + static archives (`libSDL2.a` for KMSDRM; `libSDL2_wpkvideo.a` + for compositor-client). Demos link whichever they want; + cannot link both. +- **Plan 8's libwpkdraw is the rendering substrate.** + libwpkdraw_widgets is a thin wrapper; all primitives + (`wpk_rect`, `wpk_text`, `wpk_blit`) come from plan 8. +- **Plan 9's compositor is the surface broker for all four + apps.** wpkfm, wpkcube, wpkbeep, wpkpanel are all compositor + clients. None take KMS master. Direct-KMS fall-back is NOT + used for any seed app. +- **Plan 10's libwpkterm is NOT a seed-app dependency.** None + of the four apps embed a terminal. wpkshell is its own + thing. +- **Plan 9 inline fix #2 (stride plumbing) MUST land for all + four apps.** Without the wire-format `stride` field, every + client surface allocated with non-`width*4` stride + (page-aligned strides on x86 / wasm32 gbm) renders garbled. + Plan 10's cross-plan amendment to plan 9 already captures + this. + +**Stack base:** Plan 10's PR #3 tip (`…-wpkshell-demo`). The +seed apps need everything plans 2–10 ship plus the GL stack +follow-ups. + +**Branch:** `emdash/explore-direct-rendering-infrastructure-wpk-seed-apps-plan-XXXXX` +(chains off plan 10's tip per the branching rule). Four +sub-branches stack off it for the four PRs. + +**Final PR base:** Plan 10's `…-wpkshell-demo` tip. **Do not +merge** until Brandon validates the design, plans 2-10 have +merged, and PR #4's manual browser verification confirms all +four apps boot to a recognisable desktop: wpkpanel renders +across the top, wpkshell prompt visible below, wpkfm browsable, +wpkbeep beeps on click, wpkcube spins. + +**Four PRs, coordinated merge.** PR titles use Brandon's +`scope(area): action` shape: + +1. `examples(wpk): wpkfm — file manager + libwpkdraw_widgets` +2. `sysroot(sdl2-wpkvideo): SDL2 wpkvideo video backend + wpkcube demo` +3. `examples(wpk): wpkbeep — 1-button compositor-client audio demo` +4. `examples(wpk): wpkpanel — 24-px panel widget bar as PID 4` + +PR base/head topology (stacked): + +``` +… (plans 2–10 tips + plans 2/3 GL stack follow-ups + plan 10 demo) + └── …-wpkshell-demo (plan 10 PR #3 tip) + └── …-wpk-seed-apps-plan-XXXXX (this plan PR base) + └── …-wpk-wpkfm (PR #1) + └── …-wpk-sdl-wpkvideo (PR #2) + └── …-wpk-wpkbeep (PR #3) + └── …-wpk-wpkpanel (PR #4) +``` + +**Verification gauntlet** (CLAUDE.md): all of the below must pass +with zero regressions before any PR is opened, and re-run before +final merge: + +```bash +cargo test -p wasm-posix-kernel --target aarch64-apple-darwin --lib +(cd host && npx vitest run) +scripts/run-libc-tests.sh +scripts/run-posix-tests.sh +bash scripts/check-abi-version.sh +``` + +`XFAIL` / `TIME` acceptable; `FAIL` that isn't pre-existing is a +regression. PR #4 adds manual `./run.sh browser` verification +(CLAUDE.md item 6) — the compositor boots, wpkpanel paints the +top 24 px, a clock ticks; wpkshell prompt visible below the +panel; user launches `wpkfm`, sees a tree view of /, double- +clicks `etc` and descends; user launches `wpkbeep`, clicks the +button, hears a 1 s beep + sees the red→green indicator; user +launches `wpkcube`, sees a spinning GLES2 cube in a 320×240 +window over the desktop; user closes each in turn via the +compositor's window-close. + +**ABI impact:** **None.** Plan 11 adds zero kernel exports, +zero host imports, zero new ioctls, zero new device nodes. +Every byte crosses the kernel-userland ABI via existing +surfaces from plans 2 + 6 + plan-6-sockets + plan 9. The new +SDL2 video backend is a sysroot-side patch that re-routes +SDL2's existing surface-allocation abstraction through +libwpkclient + libgbm. `ABI_VERSION` does not bump; +`abi/snapshot.json` is byte-identical. + +The sysroot grows: `sysroot/lib/libwpkdraw_widgets.a` (~50 KB), +`sysroot/lib/libSDL2_wpkvideo.a` (~1.2 MB, separate archive +from plan 7's `libSDL2.a`), `sysroot/include/wpkdraw/widgets.h`, +the four binaries at `/usr/bin/wpkfm` (~280 KB), +`/usr/bin/wpkcube` (~1.5 MB statically linked SDL2 + GL), +`/usr/bin/wpkbeep` (~200 KB), `/usr/bin/wpkpanel` (~220 KB). +Package index ledger gets one new entry (sdl2-wpkvideo); the +four programs land under `binaries/programs/<arch>/`. + +Existing kernel + host + ABI surfaces — all unchanged. + +--- + +## Pre-implementation review + +Devil's-advocate pass run during session 12 (next session after +plan 11's session-11 draft). Cross-referenced against plans 2 + +3 + 6 + 7 + 8 + 9 + 10 + design doc. Findings fold conceptually +per Brandon convention; plan body (Phase A through Phase E) +retains its pre-review text and gets the fixes applied at +implementation time. Mirrors plans 6/7/8/9/10's review structure. + +### Inline fixes (15 — folded conceptually; plan body unchanged) + +1. **wpkfm preview pane chokes on embedded NUL bytes.** B2 line + 624–636 reads the file into `buf`, NUL-terminates at + `buf[n]`, then walks `strchr(p, '\n')`. Binary files (ELF / + PNG / gzip / any file with an embedded `\0`) cut the string + short and the preview shows nothing past the first NUL. Fix: + before `strchr`, sanitize embedded NULs to `.` (or sniff + magic bytes and refuse non-text). Fold into B2. +2. **wpkfm `MAX_ROWS = 64` truncation status text misleading.** + B1 line 580 caps `n_rows` at 64; B2 line 647's status string + reports `"%s — %d entries"` using `st.n_rows`, so `/usr/bin` + (200+ entries) renders "64 entries". Risk register #3 + acknowledges the cap but the status line is actively wrong. + Fix: append `(truncated)` sentinel when `n_rows == MAX_ROWS`. + Fold into B2. +3. **wpkfm double-click descend fires twice.** B3 line 705–707 + (`handle_click` recursively calls `handle_key(0xff0d)` on + already-selected dir) AND B4 line 750–755 (main-loop timing + check separately calls `handle_key(0xff0d)`). A double-click + on a not-yet-selected directory row: first click sets + selection + descends via `handle_click`, second click within + 400 ms re-descends via timing path. Pick one path. Lean: + remove the recursive descend from `handle_click`; keep + timing-window detection in main loop. Fold into B3 + B4. +4. **wpkfm path-building snprintf truncation unchecked.** B3 + line 678 builds `next[1280]` via `snprintf(next, sizeof next, + "%s%s%s", cwd, "/", name)`. On deep nesting (cwd already + 1200 bytes), snprintf truncates silently and `cwd` accumulates + a corrupted path. Fix: check `snprintf` return; refuse descend + if `(size_t)ret >= sizeof next`. Fold into B3. +5. **wpkfm doesn't error-check `wpk_surface_create` / + `wpk_font_load_default` / `wpk_client_create_surface`.** B4 + lines 728–729. NULL return → render NULL-deref. Fix: check + each; return 1 with `fprintf(stderr, ...)`. Same hygiene + needed in wpkbeep + wpkpanel mains. Fold into B4 + D3 + E2. +6. **wpkfm `last_click_ms = 0` initial value false-positives.** + B4 line 730 + 748–757. `int now_ms = (int)(ev[i].timestamp_us + / 1000);` truncates to 32-bit signed; for any timestamp past + ~24 days uptime, `now_ms` wraps negative and the timing + comparison fires spuriously. Plan 9 also doesn't ship + `timestamp_us` on events (see Architecture #2). Lean: drop + `ev.timestamp_us`, use `clock_gettime(CLOCK_MONOTONIC, ...)` + client-side (plan 10 parity); initialize `last_click_ns` to + `INT64_MIN/2`. Fold into B4. +7. **wpkpanel's `usleep` blocks unconditionally; focus events + delivered mid-sleep delayed up to 60 s.** E2 line 1229 + computes `usleep(60_000_000 - tm.tm_sec * 1_000_000)`. While + sleeping, libwpkclient's recv buffer accumulates events; + the panel only wakes at the top of the next minute. Plan + 11's own task header says "0.1 Hz redraw + focus events" + — fix: `poll(2)` on the compositor fd with a timeout = ms + until next minute, so focus events wake the loop. Requires + `wpk_client_get_fd` (already amended into plan 9 via plan + 10's cross-amendment, so no new plan-9 surface needed). + Fold into E2. +8. **wpkpanel `usleep` overflow on `tm.tm_sec == 60` (leap + second).** Same line 1229. `60_000_000 - 60 * 1_000_000 + == 0` (fine), but `61 * 1_000_000` (some platforms' + leap-second representation) underflows the unsigned + `useconds_t`. Edge case; fixed incidentally by the `poll(2)` + rewrite in #7. Fold comment into E2. +9. **wpkpanel `screen_w` initial value 1024 is unsupported.** + E1 line 1167 hardcodes 1024. Plan 9 doesn't broadcast an + output INIT event with screen dimensions, and plan 8's + `wpk_surface_create(&w, &h)` semantics on whether + compositor-negotiated values overwrite the in/out pointer + are not pinned. wpkpanel needs the actual screen width to + right-align the clock. Lean: pass `&sw, &sh` with `sw = 0, + sh = 24` as "compositor picks width"; plan 9 amendment + documents the convention. See LOAD-BEARING item #3 (this + collapses there). Fold into E1 + E2. +10. **wpkbeep's HW_PARAMS sketch is incomplete.** D1 lines + 1031–1034 ship `struct snd_pcm_hw_params hw = { 0 }; + ioctl(fd, SNDRV_PCM_IOCTL_HW_PARAMS, &hw); ioctl(fd, + SNDRV_PCM_IOCTL_PREPARE);`. Plan 6 task A4 / A5 require + `SNDRV_PCM_IOCTL_HW_REFINE` first, then a populated + `hw_params` (rate=44100, channels=2, format=S16_LE, + period_size=1024, buffer_size=4096) — zero-init passes + through refine but doesn't configure the stream. Fold into + D1: spell out the full refine → params → sw_params → + prepare sequence (estimated +40 LoC). +11. **wpkbeep doesn't re-PREPARE between clicks.** D3 line + 1113 calls `play_blocking(pcm_fd)` synchronously. After + playback, the stream transitions PREPARED → RUNNING → + DRAINING → SETUP per plan 6's PCM state machine. Second + click writes into a SETUP-state fd; plan 6 v1 may or may + not auto-rearm. Safe fix: call `ioctl(pcm_fd, + SNDRV_PCM_IOCTL_PREPARE)` after each `play_blocking` + returns. Cross-plan note to plan 6 (see below) asks for + a definitive answer. Fold into D3. +12. **wpkbeep's `play_blocking` can infinite-loop on + `xfer.result == 0`.** D2 line 1052 — `if (r >= 0) { int n + = (int)xfer.result; p += n*2; remaining -= n; }`. Kernel + may legitimately return `r = 0` with `xfer.result = 0` + (no progress reported); the loop doesn't advance and + doesn't `poll(POLLOUT)`. Fix: treat `r >= 0 && result == 0` + as equivalent to EAGAIN (drop into the poll branch). Fold + into D2. +13. **SDL_wpkvideo doesn't check `open("/dev/dri/card0")` / + `gbm_create_device` / `eglInitialize` returns.** C1 lines + 832–835. Cascade of -1 / NULL / EGL_NO_DISPLAY may pass + silently; `eglInitialize` failure leaves `d->egl_display` + invalid. Fix: check each; `SDL_SetError` + return -1. + Fold into C1. +14. **SDL_wpkvideo `WPK_GL_SwapWindow` doesn't handle + `gbm_surface_lock_front_buffer` NULL.** C1 line 864 — + first frame before any draw, or lock failure, returns + NULL. `gbm_bo_get_fd(NULL)` is UB. Fix: NULL-check the + return; bail silently (skip this swap). Fold into C1. +15. **wpkfm preview line-wrap doesn't handle `\r\n`.** B2 + lines 628–640 wraps on `\n` only. Files with CRLF line + endings (rare in wasm32 sysroot but possible) render with + visible `\r` glyphs. Fix: also strip trailing `\r` before + rendering each line. Minor; fold into B2 as a comment. + +### Correctness — open (lean documented) + +- **wpkfm `stat(path, &sb)` syscall storm on rescan.** B1 line + 578 calls `stat` once per entry on every rescan; 64 entries = + 64 syscalls per Up/Down/Enter/BS keypress. Acceptable for v1 + envelope (the rescan only fires on directory change, not on + selection movement). Lean: accept; document the syscall cost. +- **libwpkdraw_widgets primitives use fixed font metrics** + (12 px DejaVu Sans). All four apps render at the same size + for simplicity. v2 with theming. Lean: accept. +- **wpkbeep blocks the UI for 1 s during playback** (risk + register #4). Single-threaded; the synthesized 1 s tone runs + inline. Plan 7 defers worker-thread support, so wpkbeep + matches by blocking. Lean: accept; v2 with worker threads. +- **wpkpanel renders only on minute boundary or focus event.** + No seconds-clock, no live system stats. Lean: accept; + battery-friendly. + +### Architecture — open (LOAD-BEARING flag) + +1. **(LOAD-BEARING) `wpk_font_height_px` / `wpk_font_ascent_px` + missing from plan 8's public API.** Plan 11's + `libwpkdraw_widgets` primitives (widgets.c lines 449, 451, + 458, 462, 470) call both. Plan 8's finalized §A2 public API + (plan 8 lines 873–900) exports only `wpk_font_load_default`, + `wpk_font_destroy`, `wpk_text`, `wpk_text_width`. The two + accessors appear only as a proposed addition in plan 8's + devil's-advocate pass (plan 8 lines 335–337). Resolution: + cross-plan amendment to plan 8 (see below) exports both as + one-line accessors over `wpk_font`'s internal stb_truetype + metrics. Without this, plan 11 PR #1 cannot compile. +2. **(LOAD-BEARING) `WPK_CLIENT_FOCUS_CHANGED` wire type + + event-union extension.** Plan 11 task E2 lines 1218–1222 + read `ev[i].focus.title` (~64 chars). Plan 9's event union + (plan 9 lines 1437–1451) holds only `{ uint32_t surface_id + }` for focus events (`FOCUS_IN`, `FOCUS_OUT`). Plan 11 + needs a new variant `WPK_CLIENT_FOCUS_CHANGED` carrying a + string title. Resolution: cross-plan amendment to plan 9 + (see below) allocates one of plan 9's `_RESERVED_FOR_V2_*` + slots to this event and extends the union with a + `struct { uint32_t surface_id; char title[64]; } focus;` + arm. 24-message cap is NOT bumped (one reserved slot + consumed). The compositor's D4 focus-dispatch (plan 9 lines + 1864–1895) broadcasts unconditionally to all connected + clients; subscribers like wpkpanel act on it, others drop + it in their poll loop. +3. **(LOAD-BEARING) Compositor `place_window` reservation + target + panel-surface special case + `w == 0` fullscreen + negotiation.** Plan 11 task E4 (line 1264) says "Modify + (cross-plan to plan 9): compositor's window placement + algorithm reserves the top `PANEL_RESERVED_PX = 24` of the + output." Plan 9 v1 has NO explicit `place_window` helper — + placement is a hardcoded cascade (plan 9 lines 2248–2250: + "(50, 50), (250, 200), …"). Plan 11 E4 doesn't pin the + target task in plan 9. Resolution: cross-plan amendment to + plan 9 (see below) extracts `place_window` as an explicit + function in plan 9's D6 placement block, adds a + `WPK_SURFACE_TYPE_PANEL` exemption so wpkpanel itself + places at `(0, 0)`, and additionally treats surfaces created + with `w == 0` as fullscreen-width (resolves wpkpanel's + `screen_w` heuristic from inline fix #9 in the same patch). + Requires a small companion API: + `wpk_client_set_surface_type(cl, surface_id, type)` over + plan 9's existing `SET_TYPE` wire message. The two pieces + collapse into one cross-plan amendment. + +### Missing tests (15) + +1. wpkfm renders a binary file in cwd without preview crash + (`/usr/bin/wpkshell` ELF preview). +2. wpkfm with a directory containing > MAX_ROWS entries renders + the `(truncated)` suffix in the status bar. +3. wpkfm double-click on a directory row descends exactly once + (regression guard for inline fix #3). +4. wpkfm first click after process start is treated as a + single-click, not a double-click (regression guard for + inline fix #6). +5. wpkfm path-building refuses descend when the target path + would be `>= sizeof next` (regression guard for inline fix + #4). +6. wpkfm exit on ESC + `WPK_CLIENT_WINDOW_CLOSE` cleans up + the compositor connection. +7. wpkpanel renders the clock immediately at startup (not + blocked on the first minute boundary). +8. wpkpanel renders an updated focus-app name within 200 ms + of `WPK_CLIENT_FOCUS_CHANGED` delivery (regression guard + for inline fix #7's poll-based main loop). +9. wpkpanel's right-aligned clock uses the negotiated screen + width (post-amendment). +10. wpkbeep second click after first plays a beep (regression + guard for inline fix #11 — re-PREPARE works). +11. wpkbeep `play_blocking` advances on `xfer.result == 0` + legitimately or polls (regression guard for inline fix + #12). +12. wpkbeep HW_PARAMS sequence (REFINE → PARAMS → sw_params + → PREPARE) succeeds against plan 6's PCM device. +13. SDL_wpkvideo `VideoInit` returns SDL error on + `/dev/dri/card0` open failure (regression guard for + inline fix #13). +14. SDL_wpkvideo `WPK_GL_SwapWindow` skips silently on + `gbm_surface_lock_front_buffer == NULL` (regression guard + for inline fix #14). +15. Init's E3 amendment doesn't break plan 10's shell-spawn + smoke (wpkshell still spawns; wpkpanel additionally + spawns when `/etc/wpk/compositor` is present). + +### Trade-offs verified + +- 3 widget primitives only (button / row / status); no real + widget framework (deferred to v2 libwpkui). +- wpkfm is viewer-only (no rename / copy / delete). +- `libSDL2_wpkvideo.a` separate archive (mutually exclusive + with `libSDL2.a` KMSDRM at link time). +- GLES2 only in wpkcube (no GLES3 / Vulkan). +- wpkbeep blocks UI for 1 s during playback (single-threaded; + matches plan 7's deferred worker-thread decision). +- wpkpanel updates at minute cadence (no seconds clock, no + live stats). +- Panel reserves top 24 px globally (no `set_strut` protocol). +- wpkfm preview is text-only. +- All four apps as separate processes; no in-process + multi-window. +- Init gated on `/etc/wpk/compositor` marker file (plan 10 B1 + parity; absence = WordPress demo path, plan 11 doesn't + contribute to init's exec chain). +- 5 simultaneous client connections (panel + shell + fm + + beep + cube) under plan 9's `MAX_CLIENTS = 16` (5 ≪ 16, + per plan 9 line 425). One client = one process connection + (not one surface); apps owning multiple surfaces share a + single connection. +- wpkfm `MAX_ROWS = 64` cap — accepted with status-bar + truncation indicator (inline fix #2 makes it user-visible). +- DRM_FORMAT_ARGB8888 (`0x34325241`) format constant verified + against plan 9 line 1639 + plan 11 line 847. +- libwpkclient socket `SOCK_CLOEXEC` covered by plan 10's + cross-plan amendment to plan 9 (plan 9 lines 940–945); plan + 11 apps inherit clean fds without needing their own. +- `WPK_CLIENT_WINDOW_CLOSE` event shipped by plan 9 (plan 9 + line 1443) — all four apps handle it. +- `wpk_client_get_fd` accessor (needed by inline fix #7's + panel poll loop) already added to plan 9 via plan 10's + cross-plan amendment (plan 9 lines 935–945) — no new plan-9 + surface introduced by plan 11 for this. + +### Deliberately not flagged + +- libwpkdraw_widgets scope discipline (3 primitives) — adding + a fourth (text input, checkbox, etc.) is a slippery slope; + v1 holds the line. +- wpkbeep's 1-second tone duration — demo-appropriate; + arbitrary length is v2. +- wpkcube's GLES2-only scope — plan 12 if upstream drivers + expand. +- SDL2 patch maintenance discipline — additive only (new files + under `src/video/wpkvideo/` + one-line `bootstrap[]` + registration in `SDL_video.c`); no churn in core SDL2 + files, per plan 11 task C1 contract. +- wpkfm hit-test linear scan — n ≤ 64 rows, O(n) is fine. +- wpkpanel battery cost — minute cadence + plan 9 fix #8's + PAGE_FLIP throttle = 1440 commits/day. Fine. +- wpkfm surface-relative pointer coordinates — plan 9 D4 line + 1880 delivers surface-local x/y. ✓ +- wpkbeep audio outside SDL2's audio thread — plan 7 + audio-thread (b) resolution is the pattern; non-blocking + WRITEI + EAGAIN poll lifts directly. ✓ +- Init's exec order (compositor → wpkshell → wpkpanel) — + serialized; plan 10 B1's wait-for-`/run/wpk/comp` covers + both subsequent execs (compositor binds once, both clients + connect post-bind). ✓ +- All four apps' format = DRM_FORMAT_ARGB8888 — matches plan + 9's expectation; no format negotiation needed. +- 5-surface simultaneous-count under MAX_CLIENTS = 16 — risk + register #9 captures this; well under the cap. + +### Cross-plan amendments (added to plans 6 + 8 + 9 reviews) + +Three amendments leak back to ancestor plans: + +1. **Plan 8 — export `wpk_font_height_px` + + `wpk_font_ascent_px` in §A2 public API.** Trivial one-line + accessors over plan 8's internal stb_truetype metrics. + LOAD-BEARING (resolves Architecture #1). +2. **Plan 9 — allocate `WPK_CLIENT_FOCUS_CHANGED` from + `_RESERVED_FOR_V2_*` slot + extend event union with + `focus.title[64]`.** Compositor broadcasts on focus change + to all connected clients. 24-message cap not bumped. + LOAD-BEARING (resolves Architecture #2). +3. **Plan 9 — extract `place_window` as an explicit function + + `WPK_SURFACE_TYPE_PANEL` exemption + `w == 0` + fullscreen-width negotiation + companion + `wpk_client_set_surface_type` API.** Plan 9 D6 placement + block becomes the amendment target. Single amendment + resolves both Architecture #3 (placement reservation) and + inline fix #9 (panel screen-size discovery). + LOAD-BEARING. +4. **Plan 6 — confirm two-shot playback works without + re-PREPARE OR document the rearm requirement.** wpkbeep + click → play → click again pattern hits the PCM state + machine. Plan 11 D3 calls `SNDRV_PCM_IOCTL_PREPARE` + between plays defensively; plan 6 amendment confirms + whether the defensive call is necessary or noise. + Non-LOAD-BEARING (wpkbeep works either way). + +Plans 2, 3, 7, 10 unchanged — plan 11's dependencies on those +four are clean per the consistency check: + +- **Plan 2:** gbm_bo_get_fd / get_stride / import / surface_* + APIs all match plan 11's calls; the gbm_surface follow-up + is documented in plan 2's existing cross-plan amendment + from plan 7's review (plan 2 lines 227–340), blocking plan + 11 PR #2 in dependency order. +- **Plan 3:** `eglGetDisplay(gbm_device)` + + `eglCreateWindowSurface(gbm_surface)` signatures match plan + 11 C1 exactly (plan 3 lines 453–472); libEGL/libGLESv2 stub + follow-ups (plan 3 lines 428–445) gate plan 11 PR #2 the + same way they gate plan 7 PR #2. +- **Plan 7:** SDL2 KMSDRM-backend file layout under + `src/video/kmsdrm/` parallels plan 11's wpkvideo addition + under `src/video/wpkvideo/`; audio-thread (b) resolution + (non-blocking WRITEI + EAGAIN poll) pattern lifted verbatim + by wpkbeep (plan 7 lines 560–577 ↔ plan 11 D2). +- **Plan 10:** init gate path `/etc/wpk/compositor` matches + plan 11 task E3; SIGPIPE handling in `main()` pattern + matches (plan 10 inline fix #4 ↔ plan 11 lines 721, 1081, + 1196); init reap-loop covers the panel zombie via existing + `waitpid(-1)`; SOCK_CLOEXEC on libwpkclient sockets already + covered by plan 10's cross-plan amendment to plan 9 — plan + 11 apps inherit clean fds without their own CLOEXEC. + +--- + +## Phase A — sysroot: libwpkdraw_widgets (folded into PR #1) + +The shared widget primitives. Ships as the first three commits +of PR #1, ahead of wpkfm itself. + +### Task A1: Package scaffold + +**Files:** +- Create: `examples/libs/libwpkdraw_widgets/package.toml` — recipe. +- Create: `examples/libs/libwpkdraw_widgets/build.toml` — build state. +- Create: `examples/libs/libwpkdraw_widgets/build.sh` — build script. + +```toml +# examples/libs/libwpkdraw_widgets/package.toml +name = "libwpkdraw_widgets" +version = "0.1.0" +license = "MIT" +description = "Thin widget primitives over libwpkdraw — button, row, status bar" + +[source] +type = "local" + +[deps] +libwpkdraw = "0.1.0" + +[build] +script_path = "build.sh" +``` + +```toml +# examples/libs/libwpkdraw_widgets/build.toml +script_path = "build.sh" +revision = 1 + +[binary] +index_url = "https://github.com/<repo>/releases/download/binaries-abi-v{abi}/index.toml" +``` + +```bash +#!/usr/bin/env bash +set -euo pipefail +. "$WPK_WORKTREE/sdk/activate.sh" + +SRC_DIR="$1" +OUT_DIR="$2" +mkdir -p "$OUT_DIR/lib" "$OUT_DIR/include/wpkdraw" + +cd "$SRC_DIR/src" +wasm32posix-cc -c -O2 \ + -I"$SRC_DIR/include" \ + -I"$WPK_SYSROOT/include" \ + widgets.c +llvm-ar rcs "$OUT_DIR/lib/libwpkdraw_widgets.a" *.o +cp "$SRC_DIR/include/wpkdraw/widgets.h" "$OUT_DIR/include/wpkdraw/" +``` + +**Commit:** `sysroot(wpk-widgets): scaffold libwpkdraw_widgets package` + +### Task A2: Public header + three primitives + +**Files:** +- Create: `examples/libs/libwpkdraw_widgets/include/wpkdraw/widgets.h` +- Create: `examples/libs/libwpkdraw_widgets/src/widgets.c` + +```c +// include/wpkdraw/widgets.h +#ifndef WPKDRAW_WIDGETS_H +#define WPKDRAW_WIDGETS_H + +#include <stdint.h> + +struct wpk_surface; +struct wpk_font; + +/** Button — outlined rect + centred label. Press state = darker fill. */ +void wpkw_button(struct wpk_surface *s, struct wpk_font *f, + int x, int y, int w, int h, + const char *label, int pressed); + +/** Row — full-width strip with left-aligned label. Selected = + * highlighted background (blue) + white text. */ +void wpkw_row(struct wpk_surface *s, struct wpk_font *f, + int x, int y, int w, + const char *label, int selected); + +/** Status bar — full-width strip at the bottom; right-aligned text. */ +void wpkw_status(struct wpk_surface *s, struct wpk_font *f, + int x, int y, int w, + const char *text); + +#endif /* WPKDRAW_WIDGETS_H */ +``` + +```c +// src/widgets.c +#include <wpkdraw/wpkdraw.h> +#include <wpkdraw/widgets.h> +#include <string.h> + +void wpkw_button(struct wpk_surface *s, struct wpk_font *f, + int x, int y, int w, int h, + const char *label, int pressed) { + /* Outline + fill. */ + uint32_t fill = pressed ? WPK_RGB(70, 90, 130) : WPK_RGB(90, 110, 160); + wpk_rect(s, x, y, w, h, fill); + wpk_rect(s, x, y, w, 1, WPK_RGB(160, 160, 200)); /* top border */ + wpk_rect(s, x, y + h - 1, w, 1, WPK_RGB(40, 40, 60)); /* bottom border */ + /* Centred label. */ + int tw = wpk_text_width(f, label); + int th = wpk_font_height_px(f); + int tx = x + (w - tw) / 2; + int ty = y + (h - th) / 2 + wpk_font_ascent_px(f); + wpk_text(s, f, tx, ty, label, WPK_RGB(240, 240, 250)); +} + +void wpkw_row(struct wpk_surface *s, struct wpk_font *f, + int x, int y, int w, + const char *label, int selected) { + int h = wpk_font_height_px(f) + 4; + if (selected) { + wpk_rect(s, x, y, w, h, WPK_RGB(50, 90, 170)); + } + int ty = y + 2 + wpk_font_ascent_px(f); + wpk_text(s, f, x + 4, ty, label, + selected ? WPK_RGB(240, 240, 250) : WPK_RGB(200, 200, 210)); +} + +void wpkw_status(struct wpk_surface *s, struct wpk_font *f, + int x, int y, int w, + const char *text) { + int h = wpk_font_height_px(f) + 4; + wpk_rect(s, x, y, w, h, WPK_RGB(30, 30, 40)); + int tw = wpk_text_width(f, text); + int ty = y + 2 + wpk_font_ascent_px(f); + wpk_text(s, f, x + w - tw - 6, ty, text, WPK_RGB(200, 200, 210)); +} +``` + +**Cargo test:** render each primitive into a 320×80 surface; +assert non-zero pixels in the expected regions (button outline + +centre text; row left-aligned text; status right-aligned text). + +**Commit:** `sysroot(wpk-widgets): button + row + status primitives` + +### Task A3: Smoke program + +```c +// programs/widgets_smoke.c +#include <wpkdraw/wpkdraw.h> +#include <wpkdraw/widgets.h> +#include <stdio.h> + +int main(void) { + int w = 320, h = 120; + struct wpk_surface *s = wpk_surface_create(&w, &h); + if (!s) { perror("wpk_surface_create"); return 1; } + struct wpk_font *f = wpk_font_load_default(12); + wpk_surface_clear(s, WPK_RGB(20, 20, 25)); + wpkw_button(s, f, 10, 10, 100, 30, "Click", 0); + wpkw_button(s, f, 120, 10, 100, 30, "Pressed", 1); + wpkw_row(s, f, 0, 50, 320, "row 1", 0); + wpkw_row(s, f, 0, 70, 320, "row 2 (selected)", 1); + wpkw_status(s, f, 0, 100, 320, "ready"); + wpk_surface_present(s); + wpk_font_destroy(f); + wpk_surface_destroy(s); + return 0; +} +``` + +**Vitest:** spawn the smoke + verify exit 0. Visual verification +in PR #4's browser walk-through. + +**Commit:** `examples(wpk-widgets): widgets_smoke — button + row + status` + +--- + +## Phase B — wpkfm (file manager, rest of PR #1) + +A two-pane file manager: directory tree on left, preview on right. + +### Task B1: Program scaffold + main loop + +**Files:** +- Create: `examples/programs/wpkfm/main.c` — top-level loop. +- Create: `examples/programs/wpkfm/Makefile`. + +```c +// main.c +#define _GNU_SOURCE +#include <dirent.h> +#include <errno.h> +#include <fcntl.h> +#include <poll.h> +#include <signal.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <unistd.h> + +#include <wpkclient/wpkclient.h> +#include <wpkdraw/wpkdraw.h> +#include <wpkdraw/widgets.h> + +#define SURFACE_W 720 +#define SURFACE_H 480 +#define ROW_H 16 /* matches wpkw_row internal height for 12 px font */ +#define MAX_ROWS 64 + +struct row { + char name[256]; + int is_dir; +}; + +static struct { + char cwd[1024]; + struct row rows[MAX_ROWS]; + int n_rows; + int selected; /* index into rows[] */ + int scroll_top; /* first visible row */ +} st; + +static void rescan(void) { + st.n_rows = 0; + DIR *d = opendir(st.cwd); + if (!d) return; + struct dirent *e; + /* Always include ".." unless at root. */ + if (strcmp(st.cwd, "/") != 0 && st.n_rows < MAX_ROWS) { + strncpy(st.rows[st.n_rows].name, "..", sizeof st.rows[0].name); + st.rows[st.n_rows].is_dir = 1; + st.n_rows++; + } + while ((e = readdir(d)) && st.n_rows < MAX_ROWS) { + if (e->d_name[0] == '.') continue; /* hide dotfiles */ + char path[1280]; + snprintf(path, sizeof path, "%s/%s", st.cwd, e->d_name); + struct stat sb; + int is_dir = stat(path, &sb) == 0 && S_ISDIR(sb.st_mode); + strncpy(st.rows[st.n_rows].name, e->d_name, + sizeof st.rows[0].name - 1); + st.rows[st.n_rows].name[sizeof st.rows[0].name - 1] = 0; + st.rows[st.n_rows].is_dir = is_dir; + st.n_rows++; + } + closedir(d); + st.selected = 0; + st.scroll_top = 0; +} +``` + +(continues in B2…) + +**Commit:** `examples(wpkfm): scaffold main.c — compositor client + dir scan` + +### Task B2: Render path (tree + preview) + +```c +// main.c (continued) +static void render(struct wpk_surface *s, struct wpk_font *f) { + wpk_surface_clear(s, WPK_RGB(20, 20, 25)); + /* Left pane: directory tree. */ + int pane_w = SURFACE_W / 2; + int y = 4; + for (int i = st.scroll_top; + i < st.n_rows && y < SURFACE_H - 20; + i++, y += ROW_H) { + char label[280]; + snprintf(label, sizeof label, "%s%s", + st.rows[i].is_dir ? "[DIR] " : " ", + st.rows[i].name); + wpkw_row(s, f, 0, y, pane_w, label, i == st.selected); + } + /* Right pane: preview. */ + int preview_x = pane_w + 4; + int preview_w = SURFACE_W - preview_x - 4; + if (st.selected >= 0 && st.selected < st.n_rows && + !st.rows[st.selected].is_dir) { + char path[1280]; + snprintf(path, sizeof path, "%s/%s", st.cwd, + st.rows[st.selected].name); + int fd = open(path, O_RDONLY); + if (fd >= 0) { + char buf[4096]; + ssize_t n = read(fd, buf, sizeof buf - 1); + if (n > 0) { + buf[n] = 0; + /* Line-wrap by '\n' only; very crude. */ + int yy = 4; + char *p = buf; + while (*p && yy < SURFACE_H - 20) { + char *nl = strchr(p, '\n'); + if (nl) *nl = 0; + wpk_text(s, f, preview_x, yy + wpk_font_ascent_px(f), + p, WPK_RGB(180, 180, 200)); + if (!nl) break; + *nl = '\n'; + p = nl + 1; + yy += ROW_H; + } + } + close(fd); + } + } + /* Status bar. */ + char status[1100]; + snprintf(status, sizeof status, "%s — %d entries", st.cwd, st.n_rows); + wpkw_status(s, f, 0, SURFACE_H - 18, SURFACE_W, status); +} +``` + +**Commit:** `examples(wpkfm): render — two-pane tree + preview + status bar` + +### Task B3: Input handling — keyboard nav + double-click descend + +```c +// main.c (continued) +static int handle_key(uint32_t keysym) { + switch (keysym) { + case 0xff52: /* Up */ + if (st.selected > 0) st.selected--; + if (st.selected < st.scroll_top) st.scroll_top = st.selected; + return 1; + case 0xff54: /* Down */ + if (st.selected < st.n_rows - 1) st.selected++; + if (st.selected >= st.scroll_top + (SURFACE_H - 24) / ROW_H) + st.scroll_top = st.selected - (SURFACE_H - 24) / ROW_H + 1; + return 1; + case 0xff0d: /* Enter — descend */ + case 0xff53: /* Right */ + if (st.selected < st.n_rows && st.rows[st.selected].is_dir) { + if (strcmp(st.rows[st.selected].name, "..") == 0) { + char *slash = strrchr(st.cwd, '/'); + if (slash && slash != st.cwd) *slash = 0; + else strcpy(st.cwd, "/"); + } else { + char next[1280]; + snprintf(next, sizeof next, "%s%s%s", st.cwd, + strcmp(st.cwd, "/") == 0 ? "" : "/", + st.rows[st.selected].name); + strncpy(st.cwd, next, sizeof st.cwd - 1); + st.cwd[sizeof st.cwd - 1] = 0; + } + rescan(); + } + return 1; + case 0xff08: /* BackSpace — up one */ + case 0xff51: /* Left */ + { + char *slash = strrchr(st.cwd, '/'); + if (slash && slash != st.cwd) *slash = 0; + else strcpy(st.cwd, "/"); + rescan(); + } + return 1; + case 0xff1b: /* Esc — quit */ + return -1; + } + return 0; +} + +static int handle_click(int x, int y) { + if (x >= SURFACE_W / 2) return 0; /* preview pane */ + int row = st.scroll_top + (y - 4) / ROW_H; + if (row < 0 || row >= st.n_rows) return 0; + if (row == st.selected && st.rows[row].is_dir) { + return handle_key(0xff0d); /* double-click = Enter */ + } + st.selected = row; + return 1; +} +``` + +**Commit:** `examples(wpkfm): input — keyboard nav (Up/Down/Enter/BS/Esc) + click select` + +### Task B4: Main loop + event pump + +```c +// main.c (continued) +int main(void) { + signal(SIGPIPE, SIG_IGN); /* per plan 10 inline fix #4 */ + strcpy(st.cwd, "/"); + rescan(); + + struct wpk_client *cl = wpk_client_connect(); + if (!cl) { fprintf(stderr, "wpkfm: no compositor\n"); return 1; } + int sw = SURFACE_W, sh = SURFACE_H; + struct wpk_surface *s = wpk_surface_create(&sw, &sh); + struct wpk_font *f = wpk_font_load_default(12); + int quitting = 0, dirty = 1, last_click_row = -1, last_click_ms = 0; + + while (!quitting) { + if (dirty) { + render(s, f); + wpk_surface_present(s); + dirty = 0; + } + struct wpk_client_event ev[16]; + int n = wpk_client_poll(cl, ev, 16); + for (int i = 0; i < n; i++) { + if (ev[i].type == WPK_CLIENT_KEY && ev[i].key.pressed) { + int r = handle_key(ev[i].key.keysym); + if (r < 0) quitting = 1; + if (r) dirty = 1; + } else if (ev[i].type == WPK_CLIENT_POINTER_BUTTON && + ev[i].pointer.pressed && ev[i].pointer.button == 1) { + /* Double-click within 400 ms. */ + int now_ms = (int)(ev[i].timestamp_us / 1000); + int x = ev[i].pointer.x, y = ev[i].pointer.y; + int row = st.scroll_top + (y - 4) / ROW_H; + if (row == last_click_row && now_ms - last_click_ms < 400) { + handle_key(0xff0d); + } else { + handle_click(x, y); + } + last_click_row = row; + last_click_ms = now_ms; + dirty = 1; + } else if (ev[i].type == WPK_CLIENT_WINDOW_CLOSE) { + quitting = 1; + } + } + } + wpk_font_destroy(f); + wpk_surface_destroy(s); + wpk_client_disconnect(cl); + return 0; +} +``` + +**Vitest:** spawn compositor + wpkfm; assert the surface renders +`/etc` / `/usr` / `/dev` tokens within 500 ms. + +**Commit:** `examples(wpkfm): main loop + event pump + double-click handler` + +### Task B5: Phase B — full gauntlet + open PR #1 + +PR title: `[explore-dri] examples(wpk): wpkfm — file manager + libwpkdraw_widgets` + +Body covers: libwpkdraw_widgets (button + row + status), wpkfm +main loop, directory scan + tree render + preview render, keyboard ++ mouse input, double-click descend, status bar with cwd. +ABI impact: none. + +--- + +## Phase C — SDL_wpkvideo backend + wpkcube (PR #2) + +The compositor-mediated GL path. Patches plan 7's vendored SDL2 +source to add a new video backend that allocates surfaces via +libwpkclient instead of `drmModeSetCrtc`. + +### Task C1: SDL2 patch — wpkvideo backend skeleton + +**Files:** +- Create: `sysroot/patches/sdl2-wpkvideo/0001-add-wpkvideo-backend.patch` +- The patch adds: + - `src/video/wpkvideo/SDL_wpkvideo.c` — video driver entry points + (`VideoInit`, `VideoQuit`, `CreateSDLWindow`, `DestroySDLWindow`, + `GL_CreateContext`, `GL_SwapWindow`, …). + - `src/video/wpkvideo/SDL_wpkvideo.h` — driver struct definitions. + - One-line patch to `src/video/SDL_video.c::bootstrap[]` to + register the new driver under the name `"wpkvideo"`. + +```c +// SDL_wpkvideo.c — skeleton +#include <wpkclient/wpkclient.h> +#include <gbm.h> +#include <EGL/egl.h> +#include "../../SDL_internal.h" +#include "../SDL_sysvideo.h" + +typedef struct { + struct wpk_client *cl; + struct gbm_device *gbm; + EGLDisplay egl_display; +} WPK_VideoData; + +typedef struct { + uint32_t surface_id; + struct gbm_surface *gbm_surf; + EGLSurface egl_surf; + struct gbm_bo *current_bo; + int prime_fd; +} WPK_WindowData; + +static int WPK_VideoInit(SDL_VideoDevice *_this) { + WPK_VideoData *d = (WPK_VideoData *)_this->driverdata; + d->cl = wpk_client_connect(); + if (!d->cl) return SDL_SetError("wpkvideo: no compositor"); + /* Open /dev/dri/card0 just for gbm + EGL — does NOT take master. */ + int drm_fd = open("/dev/dri/card0", O_RDWR | O_CLOEXEC); + d->gbm = gbm_create_device(drm_fd); + d->egl_display = eglGetDisplay((EGLNativeDisplayType)d->gbm); + eglInitialize(d->egl_display, NULL, NULL); + /* Register one display node (single output, compositor-mediated). */ + SDL_VideoDisplay disp = { 0 }; + SDL_AddVideoDisplay(_this, &disp, SDL_FALSE); + return 0; +} + +static int WPK_CreateSDLWindow(SDL_VideoDevice *_this, SDL_Window *window) { + WPK_VideoData *vd = (WPK_VideoData *)_this->driverdata; + WPK_WindowData *wd = SDL_calloc(1, sizeof *wd); + /* Create compositor surface. */ + wd->surface_id = wpk_client_create_surface(vd->cl, + window->w, window->h, 0x34325241 /* ARGB8888 */); + if (!wd->surface_id) { SDL_free(wd); return SDL_SetError("create_surface"); } + /* Create gbm_surface for double-buffering. */ + wd->gbm_surf = gbm_surface_create(vd->gbm, + window->w, window->h, GBM_FORMAT_ARGB8888, + GBM_BO_USE_RENDERING | GBM_BO_USE_LINEAR); + /* EGL surface bound to gbm_surface. */ + wd->egl_surf = eglCreateWindowSurface(vd->egl_display, /* config */ NULL, + (EGLNativeWindowType)wd->gbm_surf, NULL); + window->driverdata = wd; + return 0; +} + +static int WPK_GL_SwapWindow(SDL_VideoDevice *_this, SDL_Window *window) { + WPK_VideoData *vd = (WPK_VideoData *)_this->driverdata; + WPK_WindowData *wd = window->driverdata; + eglSwapBuffers(vd->egl_display, wd->egl_surf); + struct gbm_bo *next = gbm_surface_lock_front_buffer(wd->gbm_surf); + if (wd->current_bo) gbm_surface_release_buffer(wd->gbm_surf, wd->current_bo); + wd->current_bo = next; + int pfd = gbm_bo_get_fd(next); + uint32_t stride = gbm_bo_get_stride(next); + /* NB: plan 9 inline fix #2 + plan 10 cross-amendment — stride parameter. */ + wpk_client_attach_buffer(vd->cl, wd->surface_id, pfd, stride); + wpk_client_commit(vd->cl, wd->surface_id); + close(pfd); + return 0; +} + +/* …other driver hooks: WPK_PumpEvents, WPK_VideoQuit, WPK_CreateContext… */ +``` + +**Commit:** `sysroot(sdl2-wpkvideo): scaffold wpkvideo backend (VideoInit + CreateWindow + SwapWindow)` + +### Task C2: Input plumbing — libwpkclient events → SDL events + +```c +// SDL_wpkvideo.c (continued) +static void WPK_PumpEvents(SDL_VideoDevice *_this) { + WPK_VideoData *vd = (WPK_VideoData *)_this->driverdata; + struct wpk_client_event ev[16]; + int n = wpk_client_poll(vd->cl, ev, 16); + for (int i = 0; i < n; i++) { + if (ev[i].type == WPK_CLIENT_KEY) { + SDL_SendKeyboardKey(0, ev[i].key.pressed ? SDL_PRESSED : SDL_RELEASED, + wpk_to_sdl_scancode(ev[i].key.keysym)); + } else if (ev[i].type == WPK_CLIENT_POINTER_BUTTON) { + SDL_SendMouseButton(0, NULL, SDL_DEFAULT_MOUSE_ID, + ev[i].pointer.pressed ? SDL_PRESSED : SDL_RELEASED, + ev[i].pointer.button); + } else if (ev[i].type == WPK_CLIENT_POINTER_MOTION) { + SDL_SendMouseMotion(0, NULL, SDL_DEFAULT_MOUSE_ID, + 0 /* relative=false */, ev[i].pointer.x, ev[i].pointer.y); + } else if (ev[i].type == WPK_CLIENT_WINDOW_CLOSE) { + SDL_SendQuit(); + } + } +} +``` + +Plus a `wpk_to_sdl_scancode` helper mapping xkb keysyms → SDL +scancodes (a ~150-entry lookup table; the bulk is straightforward +`XKB_KEY_a → SDL_SCANCODE_A` etc.). + +**Commit:** `sysroot(sdl2-wpkvideo): input plumbing — libwpkclient events → SDL events` + +### Task C3: Build artifact — `libSDL2_wpkvideo.a` + +Build script: +- Mirrors plan 7's SDL2 build but applies the wpkvideo patch + AND configures `--disable-video-kmsdrm --enable-video-wpkvideo`. +- Output: `libSDL2_wpkvideo.a` (separate archive from + `libSDL2.a`). +- Linked-in symbols don't conflict because each archive carries + its own `bootstrap[]` registration; demos link one or the + other, never both. + +**Commit:** `sysroot(sdl2-wpkvideo): build.sh — emit libSDL2_wpkvideo.a static archive` + +### Task C4: wpkcube demo — plan 7 cube against wpkvideo + +```c +// examples/programs/wpkcube/main.c +#define _GNU_SOURCE +#include <SDL.h> +#include <SDL_opengles2.h> +#include <stdio.h> + +int main(int argc, char *argv[]) { + /* Force wpkvideo backend. */ + SDL_SetHintWithPriority(SDL_HINT_VIDEODRIVER, "wpkvideo", + SDL_HINT_OVERRIDE); + if (SDL_Init(SDL_INIT_VIDEO) != 0) { + fprintf(stderr, "SDL_Init: %s\n", SDL_GetError()); + return 1; + } + SDL_Window *w = SDL_CreateWindow("wpkcube", 0, 0, 320, 240, + SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN); + SDL_GLContext gl = SDL_GL_CreateContext(w); + /* Plan 7's cube render code, lifted verbatim. */ + extern void cube_init(void); + extern void cube_draw(float t); + cube_init(); + Uint64 t0 = SDL_GetPerformanceCounter(); + SDL_Event ev; + int quitting = 0; + while (!quitting) { + while (SDL_PollEvent(&ev)) { + if (ev.type == SDL_QUIT) quitting = 1; + } + double t = (SDL_GetPerformanceCounter() - t0) / + (double)SDL_GetPerformanceFrequency(); + cube_draw((float)t); + SDL_GL_SwapWindow(w); + } + SDL_GL_DeleteContext(gl); + SDL_DestroyWindow(w); + SDL_Quit(); + return 0; +} +``` + +**Vitest:** spawn compositor + wpkcube; assert wpkcube's surface +is non-blank within 1 s. (GL rendering correctness is verified +via the manual browser walk-through in PR #4.) + +**Commit:** `examples(wpk): wpkcube — GLES2 cube via SDL_wpkvideo backend` + +### Task C5: Phase C — full gauntlet + open PR #2 + +PR title: `[explore-dri] sysroot(sdl2-wpkvideo): SDL2 wpkvideo video backend + wpkcube demo` + +Body covers: SDL2 patch (~400 LoC adding wpkvideo backend), +input plumbing (xkb → SDL scancodes), `libSDL2_wpkvideo.a` +separate archive, wpkcube demo replicating plan 7's cube against +the new backend. ABI impact: none. **Note in the PR body:** +plan 7's KMSDRM demo continues to exist and is the direct-KMS +path; wpkcube is the compositor-mediated path. They cannot +coexist in the same process (different SDL2 archives). + +--- + +## Phase D — wpkbeep (audio compositor client, PR #3) + +A 1-button audio demo. + +### Task D1: Program scaffold + +```c +// examples/programs/wpkbeep/main.c +#define _GNU_SOURCE +#include <errno.h> +#include <fcntl.h> +#include <math.h> +#include <poll.h> +#include <signal.h> +#include <stdio.h> +#include <string.h> +#include <sys/ioctl.h> +#include <unistd.h> + +#include <wpkclient/wpkclient.h> +#include <wpkdraw/wpkdraw.h> +#include <wpkdraw/widgets.h> +#include <sound/asound.h> /* plan 6 ABI: SNDRV_PCM_IOCTL_* */ + +#define SR 44100 +#define DUR_FRAMES SR /* 1 second */ + +static int16_t samples[DUR_FRAMES * 2]; /* stereo, interleaved */ + +static void synth_sine(void) { + for (int i = 0; i < DUR_FRAMES; i++) { + double t = (double)i / SR; + int16_t v = (int16_t)(sin(2.0 * M_PI * 440.0 * t) * 16000); + samples[i * 2 + 0] = v; + samples[i * 2 + 1] = v; + } +} + +static int pcm_open_play(void) { + int fd = open("/dev/snd/pcmC0D0p", O_RDWR | O_NONBLOCK | O_CLOEXEC); + if (fd < 0) return -1; + /* HW params setup — abbreviated for brevity. */ + struct snd_pcm_hw_params hw = { 0 }; + /* …set rate=44100, channels=2, format=S16_LE, period_size=1024… */ + ioctl(fd, SNDRV_PCM_IOCTL_HW_PARAMS, &hw); + ioctl(fd, SNDRV_PCM_IOCTL_PREPARE); + return fd; +} +``` + +**Commit:** `examples(wpkbeep): scaffold main.c + sine synth + PCM open` + +### Task D2: Audio thread (non-blocking + EAGAIN poll) + +```c +static int play_blocking(int pcm_fd) { + int16_t *p = samples; + int remaining = DUR_FRAMES; + while (remaining > 0) { + struct snd_xferi xfer = { + .buf = p, .frames = remaining, .result = 0, + }; + int r = ioctl(pcm_fd, SNDRV_PCM_IOCTL_WRITEI_FRAMES, &xfer); + if (r >= 0) { + int n = (int)xfer.result; + p += n * 2; /* stereo */ + remaining -= n; + } else if (errno == EAGAIN) { + /* Plan 6 cross-plan amendment from plan 9's devil's-advocate: + * non-blocking WRITEI on full ring returns EAGAIN. Poll for + * POLLOUT (kernel-side queue has space) and retry. */ + struct pollfd pf = { .fd = pcm_fd, .events = POLLOUT }; + poll(&pf, 1, -1); + } else { + return -1; + } + } + return 0; +} +``` + +Plan 7's audio-thread resolution (b) is precisely this pattern: +non-blocking WRITEI + EAGAIN-driven `poll(POLLOUT)`. wpkbeep +exercises it directly without going through SDL2's audio +subsystem. + +**Commit:** `examples(wpkbeep): audio writer — non-blocking WRITEI + EAGAIN poll` + +### Task D3: Main loop — button + state machine + +```c +int main(void) { + signal(SIGPIPE, SIG_IGN); + synth_sine(); + struct wpk_client *cl = wpk_client_connect(); + if (!cl) { fprintf(stderr, "wpkbeep: no compositor\n"); return 1; } + int sw = 200, sh = 100; + struct wpk_surface *s = wpk_surface_create(&sw, &sh); + struct wpk_font *f = wpk_font_load_default(12); + int pcm_fd = pcm_open_play(); + if (pcm_fd < 0) { fprintf(stderr, "wpkbeep: no /dev/snd/pcmC0D0p\n"); return 1; } + int playing = 0, quitting = 0; + while (!quitting) { + wpk_surface_clear(s, WPK_RGB(20, 20, 25)); + /* Indicator light. */ + uint32_t light = playing ? WPK_RGB(220, 80, 80) : WPK_RGB(80, 220, 80); + wpk_rect(s, 10, 10, 16, 16, light); + /* Button. */ + wpkw_button(s, f, 40, 10, 100, 30, + playing ? "Playing..." : "Beep", playing); + wpk_surface_present(s); + + struct wpk_client_event ev[16]; + int n = wpk_client_poll(cl, ev, 16); + for (int i = 0; i < n; i++) { + if (ev[i].type == WPK_CLIENT_POINTER_BUTTON && + ev[i].pointer.pressed && ev[i].pointer.button == 1 && + ev[i].pointer.x >= 40 && ev[i].pointer.x < 140 && + ev[i].pointer.y >= 10 && ev[i].pointer.y < 40 && + !playing) { + playing = 1; + /* Synchronous; wpkbeep is single-threaded, the + * window won't redraw until done — that's OK for + * a 1 s tone. */ + play_blocking(pcm_fd); + playing = 0; + } else if (ev[i].type == WPK_CLIENT_WINDOW_CLOSE) { + quitting = 1; + } + } + } + close(pcm_fd); + wpk_font_destroy(f); + wpk_surface_destroy(s); + wpk_client_disconnect(cl); + return 0; +} +``` + +**Vitest:** spawn compositor + wpkbeep; inject a synthetic +pointer-button event at (90, 25); assert the kernel's +`/dev/snd/pcmC0D0p` consumer receives 1 s of samples. + +**Commit:** `examples(wpkbeep): main loop — button + indicator + click-triggered playback` + +### Task D4: Phase D — full gauntlet + open PR #3 + +PR title: `[explore-dri] examples(wpk): wpkbeep — 1-button compositor-client audio demo` + +Body covers: compositor wire (single window + single button), +sine synth (precomputed buffer), non-blocking WRITEI + EAGAIN +poll (plan 7 resolution (b) executed directly), red/green +indicator light. ABI impact: none. + +--- + +## Phase E — wpkpanel (PID 4 panel bar, PR #4) + +The 24-px-tall top-of-screen strip. Init forks it after PID 3 +wpkshell. + +### Task E1: Program scaffold + +```c +// examples/programs/wpkpanel/main.c +#define _GNU_SOURCE +#include <signal.h> +#include <stdio.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +#include <wpkclient/wpkclient.h> +#include <wpkdraw/wpkdraw.h> +#include <wpkdraw/widgets.h> + +#define PANEL_H 24 + +static int screen_w = 1024; /* compositor reports this via INIT event */ + +static void render(struct wpk_surface *s, struct wpk_font *f, + const char *focus_app, const struct tm *now) { + wpk_surface_clear(s, WPK_RGB(30, 30, 40)); + /* Bottom border (separator from window area). */ + wpk_rect(s, 0, PANEL_H - 1, screen_w, 1, WPK_RGB(80, 80, 100)); + /* Left: active app indicator. */ + char left[128]; + snprintf(left, sizeof left, " %s", + focus_app && focus_app[0] ? focus_app : "(no focus)"); + wpk_text(s, f, 8, 4 + wpk_font_ascent_px(f), left, + WPK_RGB(220, 220, 230)); + /* Right: clock. */ + char clock[16]; + snprintf(clock, sizeof clock, "%02d:%02d ", + now->tm_hour, now->tm_min); + int tw = wpk_text_width(f, clock); + wpk_text(s, f, screen_w - tw - 4, 4 + wpk_font_ascent_px(f), clock, + WPK_RGB(240, 240, 250)); +} +``` + +**Commit:** `examples(wpkpanel): scaffold main.c + render path (left = focus app, right = clock)` + +### Task E2: Main loop — 0.1 Hz redraw + focus events + +```c +int main(void) { + signal(SIGPIPE, SIG_IGN); + struct wpk_client *cl = wpk_client_connect(); + if (!cl) { fprintf(stderr, "wpkpanel: no compositor\n"); return 1; } + int sw = screen_w, sh = PANEL_H; + struct wpk_surface *s = wpk_surface_create(&sw, &sh); + screen_w = sw; /* compositor may negotiate */ + struct wpk_font *f = wpk_font_load_default(12); + char focus_app[64] = ""; + int quitting = 0; + int last_min = -1; + while (!quitting) { + time_t t = time(NULL); + struct tm tm_; + localtime_r(&t, &tm_); + if (tm_.tm_min != last_min) { + render(s, f, focus_app, &tm_); + wpk_surface_present(s); + last_min = tm_.tm_min; + } + struct wpk_client_event ev[16]; + int n = wpk_client_poll(cl, ev, 16); + for (int i = 0; i < n; i++) { + if (ev[i].type == WPK_CLIENT_FOCUS_CHANGED) { + /* Plan 9 amendment: compositor broadcasts focus name + * to subscribed clients. See Pre-impl review above. */ + strncpy(focus_app, ev[i].focus.title, sizeof focus_app - 1); + focus_app[sizeof focus_app - 1] = 0; + last_min = -1; /* force redraw */ + } else if (ev[i].type == WPK_CLIENT_WINDOW_CLOSE) { + quitting = 1; + } + } + /* Sleep until top of next minute (or wake on event). */ + usleep(60 * 1000 * 1000 - tm_.tm_sec * 1000 * 1000); + } + wpk_font_destroy(f); + wpk_surface_destroy(s); + wpk_client_disconnect(cl); + return 0; +} +``` + +**Commit:** `examples(wpkpanel): main loop — minute-cadence clock + focus listener` + +### Task E3: Init amendment — exec wpkpanel as PID 4 + +**Files:** +- Modify: `examples/init/init.c` — after plan 10 B1's wpkshell + exec, fork-exec wpkpanel as the next child. + +```c +/* After plan 10 B1's wpkshell exec arm, before init's reap loop: */ +if (access("/etc/wpk/compositor", F_OK) == 0) { + pid_t panel_pid = fork(); + if (panel_pid == 0) { + execl("/usr/bin/wpkpanel", "wpkpanel", NULL); + _exit(127); + } + /* No wait — panel runs alongside wpkshell. */ +} +``` + +**Commit:** `examples(init): fork-exec wpkpanel as PID 4 alongside wpkshell` + +### Task E4: Compositor work-area amendment — reserve top 24 px + +**Files:** +- Modify (cross-plan to plan 9): compositor's window placement + algorithm reserves the top `PANEL_RESERVED_PX = 24` of the + output. New windows positioned at `y >= 24`. + +```c +/* In compositor's place_window helper: */ +#define PANEL_RESERVED_PX 24 +if (placement_y < PANEL_RESERVED_PX) placement_y = PANEL_RESERVED_PX; +``` + +**Commit:** `examples(wpkcompositor): reserve top 24 px for wpkpanel (cross-plan amendment to plan 9)` + +### Task E5: Manual browser verification (the gate) + +CLAUDE.md item 6. Build all five binaries (compositor + wpkshell ++ wpkfm + wpkbeep + wpkcube + wpkpanel) and wire them into +`examples/browser/pages/wpk-desktop/`. The browser page mounts: + +1. wpkcompositor at PID 2. +2. wpkshell at PID 3 (terminal in a window below the panel). +3. wpkpanel at PID 4 (top 24 px strip — clock right-aligned, + focus app left-aligned). +4. User launches `wpkfm &` from wpkshell — file-manager window + appears with tree of /, double-clicking `etc` descends. +5. User launches `wpkbeep &` — single-button window appears; + click triggers 1 s 440 Hz tone; indicator goes red→green. +6. User launches `wpkcube &` — 320×240 window appears with + GLES2-rendered spinning cube. +7. All four windows visible simultaneously (excluding wpkshell + modifier focus). Super+Tab cycles focus; the panel's left + readout updates accordingly. +8. Closing each window via the compositor's window-close + gesture reaps the orphaned process cleanly. + +**No commit yet — verification only.** + +### Task E6: Phase E — final gauntlet + open PR #4 + +PR title: `[explore-dri] examples(wpk): wpkpanel — 24-px panel widget bar as PID 4` + +Body covers: panel scaffold + minute-cadence clock + focus +indicator, init amendment fork-execing PID 4, compositor work- +area amendment reserving the top 24 px, manual browser walk- +through showing all four apps coexisting. ABI impact: none. + +--- + +## Final coordinated merge + +When all four PRs are reviewed and approved, the browser +verification passes: + +1. Re-run the full gauntlet on each PR's branch tip. +2. Squash-merge PR #1 (wpkfm + widgets) → PR #2's base. +3. Squash-merge PR #2 (sdl2-wpkvideo + wpkcube) → PR #3's base. +4. Squash-merge PR #3 (wpkbeep) → PR #4's base. +5. Squash-merge PR #4 (wpkpanel + init amendment + compositor + work-area amendment) → plan 10's `…-wpkshell-demo` (or + wherever plan 10's tip lives at the time). +6. Tag: `[explore-dri-wpk-desktop] plan 11 merged at <sha>` in + the next session-handoff doc. + +**Do not push to upstream until v1 + plans 2–11 are all merged +upstream as a coherent chain.** Plan 11 closes the chain at the +user-facing level; v2 work (Wayland bridge, PTY surface, real +widget framework, etc.) starts after upstream merge. + +--- + +## Trade-offs already locked in (don't relitigate during implementation) + +- **Three widget primitives (button, row, status), no widget + framework.** A real widget toolkit is post-v1. +- **wpkfm is a viewer, not an editor.** No file rename / copy / + delete / chmod. v2. +- **SDL2 wpkvideo backend is a separate static archive + (`libSDL2_wpkvideo.a`).** Cannot link both KMSDRM and + wpkvideo SDL2 in one binary. +- **No GLES3, no Vulkan, no compute.** wpkcube uses GLES2 only. + v2 may expand if upstream drivers do. +- **wpkbeep is single-threaded, blocks on the audio loop.** + No background-playback option in v1. A real audio app would + use the plan 7 audio-thread (b) pattern in a worker thread, + but plan 7 explicitly defers worker-thread support; wpkbeep + matches by blocking the UI for 1 s. +- **wpkpanel updates at 1-min cadence.** No seconds clock, no + CPU/memory readout, no battery widget. v2 may add system + tray. +- **Panel reserves top 24 px globally.** Compositor work-area + reduction is hardcoded; no `set_strut`-style protocol in v1. + v2 may generalise. +- **wpkfm preview is text-only.** No image preview, no + syntax highlighting, no large-file paging. v2. +- **wpkcube is a static demo, not a 3D engine.** No model + loading, no shaders beyond the cube's, no physics. +- **No drag-and-drop between wpkfm and other apps.** v2 with + CLIPBOARD_* plumbing per plan 9 reservation. +- **All four apps run as separate processes.** No in-process + multi-window pattern. +- **Init's PID 4 panel exec is gated on + `/etc/wpk/compositor`.** Without the marker, plan 11 doesn't + contribute to init's exec chain; WordPress demo unaffected. +- **Zero ABI impact.** No kernel surface; no host imports. + +--- + +## Risk register + +1. **SDL2 wpkvideo backend scope creep.** The minimum + viable backend ships ~400 LoC; full SDL_VIDEODEVICE + coverage (cursor + clipboard + IME + window events) could + balloon to 2000+ LoC. *Mitigation:* document the supported + subset (video + GL + input events only); demos that need + more should use SDL2's other backends (where applicable) + or wait for plan 12. +2. **`libSDL2_wpkvideo.a` archive size.** Static-linking SDL2 + pulls ~1.2 MB. Two SDL2 archives in the sysroot is ~2.4 MB + of disk. *Mitigation:* acceptable for v1; v2 may unify on + a single archive with both backends selectable at runtime. +3. **wpkfm's directory scan slow on large directories.** v1 + reads up to MAX_ROWS=64 entries. Bigger directories silently + truncate; the status bar shows count = 64 even when more + exist. *Mitigation:* document; v2 paginates. +4. **wpkbeep's blocking audio loop freezes the window.** Click + the button → window doesn't repaint for 1 s. *Mitigation:* + v1 documented behaviour; v2 with worker threads. +5. **wpkpanel's `localtime_r` depends on /etc/localtime being + present.** If the rootfs ships UTC-only, the panel shows + UTC. *Mitigation:* ship a default `/etc/localtime` (UTC) in + the rootfs; document the time-zone behaviour. +6. **Init's PID-4 fork race.** Plan 10 B1 patches init to + `access`-poll for `/run/wpk/comp` before exec'ing wpkshell; + wpkpanel benefits from the same wait (it's exec'd after + wpkshell, so the socket is already present). *Mitigation:* + plan 10's fix #12 covers both; no separate panel race. +7. **Compositor's focus broadcast (`WPK_CLIENT_FOCUS_CHANGED`) + pinned to the 24-message inventory.** Adding the message + to plan 9's enum bumps the count to 25 if all 24 slots are + used. *Mitigation:* one of plan 9's `_RESERVED_FOR_V2_*` + slots becomes `WPK_CLIENT_FOCUS_CHANGED`; no count + increase. Confirm in plan 11's pre-impl review. +8. **GL stack follow-ups (plan 2 + plan 3) are PR #2's + blocker.** Without `gbm_surface_create` exported under the + right semantics, wpkcube can't compile. *Mitigation:* + gate PR #2 on the GL stack follow-ups landing (which they + must for plan 9 anyway). +9. **wpkfm + wpkbeep + wpkcube all running simultaneously + hit the compositor's 16-client cap.** 5 surfaces (panel + + shell + fm + beep + cube) is well under 16. *Mitigation:* + no risk in practice; documented as a v1 envelope. +10. **`gbm_bo_get_stride` in wpkvideo backend depends on + plan 2's bo metadata.** If plan 2's bo handle doesn't + expose stride correctly, the wpkvideo SwapWindow path + sends garbage. *Mitigation:* plan 2's bo API includes + `gbm_bo_get_stride` (standard libgbm surface); confirm + in the implementation phase. + +--- + +## What this plan doesn't cover (deferred) + +- **Real widget framework.** Buttons / rows / status only. v2 + with a libwpkui library. +- **File operations in wpkfm.** Rename / copy / delete / + chmod. v2. +- **Image preview in wpkfm.** v2 (needs decoder library; could + be a stb_image-based addition). +- **GLES3 / Vulkan.** v2; GLES2 only for v1. +- **SDL2 audio via wpkvideo.** Currently wpkbeep does direct + ioctl; SDL2 audio works via plan 7's existing path on its + own. Unifying is v2. +- **Multi-second audio in wpkbeep.** v1 = 1 s; v2 = arbitrary + length with proper playback state machine. +- **System tray icons in wpkpanel.** Placeholder area only; + v2. +- **Panel customisation (themes, widgets, layout).** v2. +- **Workspace switcher / multi-monitor panel.** v2 (plan 4 + invariant + Wayland-bridge). +- **Window list in panel.** Just focus indicator in v1; v2 + may add the full window list. +- **wpkcube as an actual 3D demo.** Static cube only; v2 may + ship a more interesting demo. +- **Drag-and-drop, clipboard.** Plan 9 reserves messages; v2. +- **`xdg-shell`-style window types.** v1 = flat (all windows + are "toplevel"); v2. +- **wpkpanel `set_strut` protocol.** v1 hardcodes the + reserved-top-24 px in the compositor; v2 generalises with + a client-driven protocol message. +- **wpkfm symlink resolution / mount detection.** v1 follows + symlinks via `stat`; doesn't show the destination. v2. +- **Network panel widgets.** v2. + +--- + +End of plan. diff --git a/examples/libs/libkwl/include/kwl.h b/examples/libs/libkwl/include/kwl.h index 9be785b4a3..97f0bef95a 100644 --- a/examples/libs/libkwl/include/kwl.h +++ b/examples/libs/libkwl/include/kwl.h @@ -75,6 +75,41 @@ struct kwl_event { * NULL on failure. */ struct kwl_window *kwl_window_create(const char *title, int w, int h); +/* Stacking layers for a shell component, bottom-most first (the + * zwlr_layer_shell_v1 layers). */ +enum kwl_layer { + KWL_LAYER_BACKGROUND = 0, + KWL_LAYER_BOTTOM, + KWL_LAYER_TOP, + KWL_LAYER_OVERLAY, +}; + +/* Edges a layer surface anchors to. Anchoring both edges of an axis stretches + * the surface along it; anchoring neither centers it. */ +#define KWL_ANCHOR_TOP 1u +#define KWL_ANCHOR_BOTTOM 2u +#define KWL_ANCHOR_LEFT 4u +#define KWL_ANCHOR_RIGHT 8u + +struct kwl_layer_opts { + int layer; /* enum kwl_layer */ + unsigned anchor; /* KWL_ANCHOR_* mask */ + int w, h; /* 0 on an axis the surface stretches along */ + int exclusive_zone; /* px of the anchored edge windows must not cover */ + int keyboard; /* 1 = take the keyboard for as long as it lives */ + int margin_top, margin_right, margin_bottom, margin_left; +}; + +/* Map a shell component — a bar, a launcher, a wallpaper — instead of a + * window. The compositor decides the geometry from the anchor and size, so the + * surface size arrives in the initial KWL_RESIZE-free configure and is + * readable through kwl_window_surface(). `ns` names the component ("bar", + * "launcher") and doubles as the app_id kwlctl reports. Returns NULL when the + * compositor has no layer shell. Everything else — draw, commit, dispatch — is + * the same as a window. */ +struct kwl_window *kwl_layer_create(const char *ns, + const struct kwl_layer_opts *opts); + void kwl_window_destroy(struct kwl_window *win); /* The window's current back buffer as a wpk_surface. The returned pointer diff --git a/examples/libs/libkwl/src/kwl.c b/examples/libs/libkwl/src/kwl.c index 41afd966ab..ed2b9ea302 100644 --- a/examples/libs/libkwl/src/kwl.c +++ b/examples/libs/libkwl/src/kwl.c @@ -40,6 +40,7 @@ #include <wayland-client-protocol.h> #include "xdg-shell-client-protocol.h" #include "xdg-decoration-v1-client-protocol.h" +#include "wlr-layer-shell-v1-client-protocol.h" #include <gbm.h> #include <xkbcommon/xkbcommon.h> @@ -56,6 +57,12 @@ #define KWL_TB_FONT_PX 14 #define KWL_TB_CLOSE_SZ 16 #define KWL_TB_CLOSE_MARGIN 8 +/* The largest share of the output an initial window may claim, as a + * fraction. Wider than tall because a floating desktop puts its other + * windows beside the first one, not under it. */ +#define KWL_INITIAL_W_NUM 5 +#define KWL_INITIAL_H_NUM 6 +#define KWL_INITIAL_DEN 10 struct kwl_buffer { struct gbm_bo *bo; @@ -74,18 +81,27 @@ struct kwl_window { struct wl_seat *seat; struct wl_output *output; + struct zwlr_layer_shell_v1 *layer_shell; + struct wl_surface *surface; struct xdg_surface *xdg_surface; struct xdg_toplevel *toplevel; + struct zwlr_layer_surface_v1 *layer_surface; struct wl_keyboard *keyboard; struct wl_pointer *pointer; struct zxdg_decoration_manager_v1 *decor_mgr; struct zxdg_toplevel_decoration_v1 *decor; char *title; /* retained: redrawn into the titlebar on resize */ - int w, h; /* app-visible CONTENT size */ + int w, h; /* app-visible CONTENT size, LOGICAL */ int tb_h; /* titlebar height: 0 under server-side decoration */ - int total_h; /* h + tb_h — the wl_surface size */ + int total_h; /* h + tb_h — the wl_surface size, LOGICAL */ + /* wl_output.scale: device pixels per logical unit. The buffers are this + * many times larger on each axis and wl_surface.set_buffer_scale tells + * the compositor so; everything the app sees stays logical, because + * libwpkdraw scales each primitive on the way in. */ + int scale; + int out_pw, out_ph; /* wl_output.mode, device pixels (0 = not reported) */ int configured; int mapped; /* first buffer committed; a later configure = resize */ int pending_w, pending_h; /* last configure's surface size (0 = keep) */ @@ -131,6 +147,34 @@ static int kwl_pop(struct kwl_window *w, struct kwl_event *out) { return 1; } +/* ---- wl_output --------------------------------------------------------- */ + +/* The only event libkwl needs: how many device pixels the compositor puts in + * a logical one. It arrives in the create roundtrip, before any buffer or + * font exists. */ +static void output_geometry(void *data, struct wl_output *o, int32_t x, + int32_t y, int32_t pw, int32_t ph, int32_t subpixel, + const char *make, const char *model, + int32_t transform) {} +static void output_mode(void *data, struct wl_output *o, uint32_t flags, + int32_t w, int32_t h, int32_t refresh) { + struct kwl_window *win = data; + if (!(flags & WL_OUTPUT_MODE_CURRENT)) return; + win->out_pw = w; + win->out_ph = h; +} +static void output_done(void *data, struct wl_output *o) {} +static void output_scale(void *data, struct wl_output *o, int32_t factor) { + struct kwl_window *w = data; + w->scale = factor > 0 ? factor : 1; +} +static const struct wl_output_listener output_listener = { + .geometry = output_geometry, + .mode = output_mode, + .done = output_done, + .scale = output_scale, +}; + /* ---- registry ---------------------------------------------------------- */ static void registry_global(void *data, struct wl_registry *reg, uint32_t name, @@ -145,11 +189,18 @@ static void registry_global(void *data, struct wl_registry *reg, uint32_t name, w->wm_base = wl_registry_bind(reg, name, &xdg_wm_base_interface, 1); else if (strcmp(iface, "wl_seat") == 0) w->seat = wl_registry_bind(reg, name, &wl_seat_interface, 1); - else if (strcmp(iface, "wl_output") == 0) + else if (strcmp(iface, "wl_output") == 0) { w->output = wl_registry_bind(reg, name, &wl_output_interface, 2); + wl_output_add_listener(w->output, &output_listener, w); + } else if (strcmp(iface, "zxdg_decoration_manager_v1") == 0) w->decor_mgr = wl_registry_bind( reg, name, &zxdg_decoration_manager_v1_interface, 1); + else if (strcmp(iface, "zwlr_layer_shell_v1") == 0) + /* v3 added zwlr_layer_shell_v1.destroy, which kwl_window_destroy + * calls, so bind as high as the compositor offers. */ + w->layer_shell = wl_registry_bind( + reg, name, &zwlr_layer_shell_v1_interface, version < 4 ? version : 4); } static void registry_global_remove(void *data, struct wl_registry *r, uint32_t name) {} @@ -221,6 +272,37 @@ static const struct xdg_toplevel_listener toplevel_listener = { .close = toplevel_close, }; +/* ---- wlr-layer-shell ---------------------------------------------------- */ + +/* A layer surface has no size of its own: the compositor anchors it and this + * configure states the box it must fill. Before the first commit it just sets + * the size the buffers are built at; afterwards it is a resize, exactly like + * the tiling path. */ +static void layer_surface_configure(void *data, + struct zwlr_layer_surface_v1 *ls, + uint32_t serial, uint32_t w, uint32_t h) { + struct kwl_window *win = data; + zwlr_layer_surface_v1_ack_configure(ls, serial); + win->configured = 1; + if (w == 0 || h == 0) return; + if (!win->mapped) { + win->w = (int)w; + win->h = (int)h; + } else if ((int)w != win->w || (int)h != win->h) { + kwl_apply_resize(win, (int)w, (int)h); + } +} +static void layer_surface_closed(void *data, + struct zwlr_layer_surface_v1 *ls) { + struct kwl_window *win = data; + struct kwl_event e = { .type = KWL_CLOSE }; + kwl_push(win, &e); +} +static const struct zwlr_layer_surface_v1_listener layer_surface_listener = { + .configure = layer_surface_configure, + .closed = layer_surface_closed, +}; + /* ---- frame callback ---------------------------------------------------- */ static void frame_done(void *data, struct wl_callback *cb, uint32_t t) { @@ -423,24 +505,24 @@ static int connect_socket(void) { * the gbm_bo_import path the compositor understands (see wlclient-test.c). * The bo covers the full surface: titlebar + content. */ static int kwl_buffer_init(struct kwl_window *w, struct kwl_buffer *b) { - struct gbm_bo *bo = gbm_bo_create(w->gbm, w->w, w->total_h, + int pw = w->w * w->scale, ph = w->total_h * w->scale; + struct gbm_bo *bo = gbm_bo_create(w->gbm, pw, ph, GBM_FORMAT_XRGB8888, GBM_BO_USE_LINEAR | GBM_BO_USE_SCANOUT); if (!bo) return -1; uint32_t stride = 0; void *map_data = NULL; - uint32_t *px = - gbm_bo_map(bo, 0, 0, w->w, w->total_h, 0, &stride, &map_data); + uint32_t *px = gbm_bo_map(bo, 0, 0, pw, ph, 0, &stride, &map_data); if (!px) { gbm_bo_destroy(bo); return -1; } int prime = gbm_bo_get_fd(bo); if (prime < 0) { gbm_bo_unmap(bo, map_data); gbm_bo_destroy(bo); return -1; } struct wl_shm_pool *pool = - wl_shm_create_pool(w->shm, prime, (int32_t)(stride * w->total_h)); + wl_shm_create_pool(w->shm, prime, (int32_t)(stride * ph)); struct wl_buffer *wl_buf = wl_shm_pool_create_buffer( - pool, 0, w->w, w->total_h, (int32_t)stride, WL_SHM_FORMAT_XRGB8888); + pool, 0, pw, ph, (int32_t)stride, WL_SHM_FORMAT_XRGB8888); wl_shm_pool_destroy(pool); /* the buffer keeps the pool alive */ close(prime); /* wl_shm dup'd it into the pool */ @@ -467,8 +549,8 @@ static void kwl_buffer_fini(struct kwl_buffer *b) { static struct wpk_surface content_view(struct kwl_window *w, struct kwl_buffer *b) { return wpk_surface_wrap( - b->pixels + (size_t)w->tb_h * (b->stride / 4), w->w, w->h, - b->stride); + b->pixels + (size_t)(w->tb_h * w->scale) * (b->stride / 4), + w->w, w->h, b->stride); } /* Draw the CSD titlebar into one buffer. Called once per buffer at window @@ -499,16 +581,15 @@ static void draw_titlebar(struct kwl_window *w, struct kwl_buffer *b, } } -/* ---- public API -------------------------------------------------------- */ +/* ---- shared setup ------------------------------------------------------- */ -struct kwl_window *kwl_window_create(const char *title, int w, int h) { - if (w <= 0 || h <= 0) { errno = EINVAL; return NULL; } +/* Connect, bind the globals, hook the seat up, and create the bare + * wl_surface both roles build on. Returns NULL (with the partial window + * already destroyed) on failure. */ +static struct kwl_window *kwl_open(void) { struct kwl_window *win = calloc(1, sizeof(*win)); if (!win) { errno = ENOMEM; return NULL; } - win->w = w; - win->h = h; - win->tb_h = KWL_TITLEBAR_H; /* CSD default; SSD negotiation zeroes it */ - if (title) win->title = strdup(title); + win->scale = 1; /* until wl_output says otherwise */ int fd = connect_socket(); if (fd < 0) goto fail; @@ -520,10 +601,13 @@ struct kwl_window *kwl_window_create(const char *title, int w, int h) { wl_display_roundtrip(win->display); /* receive globals */ wl_display_roundtrip(win->display); /* receive their initial events */ - if (!win->compositor || !win->shm || !win->wm_base || !win->seat) - goto fail; + /* wl_output.scale has arrived. Publish it before the caller creates any + * buffer, font or surface, because each captures the scale at creation. */ + wpk_set_scale(win->scale); - xdg_wm_base_add_listener(win->wm_base, &wm_base_listener, win); + if (!win->compositor || !win->shm || !win->seat) goto fail; + if (win->wm_base) + xdg_wm_base_add_listener(win->wm_base, &wm_base_listener, win); /* Seat inputs first, so the compositor's map-time focus reaches them. */ win->keyboard = wl_seat_get_keyboard(win->seat); @@ -533,8 +617,70 @@ struct kwl_window *kwl_window_create(const char *title, int w, int h) { if (win->pointer) wl_pointer_add_listener(win->pointer, &pointer_listener, win); - /* Toplevel. */ win->surface = wl_compositor_create_surface(win->compositor); + if (!win->surface) goto fail; + return win; + +fail: + kwl_window_destroy(win); + return NULL; +} + +/* Shrink the requested window to at most its share of the output, keeping + * its aspect. + * + * A toolkit client picks its initial size as a constant, and a constant that + * suited one desktop is too big for a smaller one — a 960x540 terminal that + * took half of a 2255x1080 desktop takes nearly all of a 1280x613 one, and + * the floating desktop's other windows have nowhere to go. The compositor + * clamps a floating window's POSITION but not its size, so an oversized one + * lands on screen and covers everything. Capping the initial size at a share + * of the output and scaling both axes by the tighter ratio keeps the + * window's proportions, which is what its layout was written for. On a + * desktop roomy enough for the constant this changes nothing. */ +static void kwl_fit_to_output(struct kwl_window *win) { + if (win->out_pw < 1 || win->out_ph < 1) return; + int max_w = win->out_pw / win->scale * KWL_INITIAL_W_NUM / KWL_INITIAL_DEN; + int max_h = win->out_ph / win->scale * KWL_INITIAL_H_NUM / KWL_INITIAL_DEN + - win->tb_h; + if (max_w < 1 || max_h < 1) return; + if (win->w <= max_w && win->h <= max_h) return; + + /* The tighter ratio in 16.16 fixed point — no libm in these clients. */ + long rw = ((long)max_w << 16) / win->w; + long rh = ((long)max_h << 16) / win->h; + long r = rw < rh ? rw : rh; + int w = (int)(((long)win->w * r) >> 16); + int h = (int)(((long)win->h * r) >> 16); + win->w = w > 1 ? w : 1; + win->h = h > 1 ? h : 1; +} + +/* Allocate the shared double buffer once the final content size is known. */ +static int kwl_open_buffers(struct kwl_window *win) { + win->total_h = win->h + win->tb_h; + win->render_fd = open("/dev/dri/renderD128", O_RDWR | O_CLOEXEC); + if (win->render_fd < 0) return -1; + win->gbm = gbm_create_device(win->render_fd); + if (!win->gbm) return -1; + for (int i = 0; i < KWL_NUM_BUFFERS; i++) + if (kwl_buffer_init(win, &win->bufs[i]) != 0) return -1; + win->back_index = 0; + win->back = content_view(win, &win->bufs[0]); + return 0; +} + +/* ---- public API -------------------------------------------------------- */ + +struct kwl_window *kwl_window_create(const char *title, int w, int h) { + if (w <= 0 || h <= 0) { errno = EINVAL; return NULL; } + struct kwl_window *win = kwl_open(); + if (!win) return NULL; + win->w = w; + win->h = h; + win->tb_h = KWL_TITLEBAR_H; /* CSD default; SSD negotiation zeroes it */ + if (title) win->title = strdup(title); + if (!win->wm_base) goto fail; win->xdg_surface = xdg_wm_base_get_xdg_surface(win->wm_base, win->surface); xdg_surface_add_listener(win->xdg_surface, &xdg_surface_listener, win); win->toplevel = xdg_surface_get_toplevel(win->xdg_surface); @@ -562,15 +708,10 @@ struct kwl_window *kwl_window_create(const char *title, int w, int h) { while (!win->configured) if (wl_display_dispatch(win->display) < 0) goto fail; - win->total_h = h + win->tb_h; - - /* gbm-backed double buffer. */ - win->render_fd = open("/dev/dri/renderD128", O_RDWR | O_CLOEXEC); - if (win->render_fd < 0) goto fail; - win->gbm = gbm_create_device(win->render_fd); - if (!win->gbm) goto fail; - for (int i = 0; i < KWL_NUM_BUFFERS; i++) - if (kwl_buffer_init(win, &win->bufs[i]) != 0) goto fail; + /* tb_h is settled by now, so the fit accounts for the titlebar. A tiling + * compositor overrides the result with its own configure anyway. */ + kwl_fit_to_output(win); + if (kwl_open_buffers(win) != 0) goto fail; /* Decorate both buffers once (skipped under SSD, tb_h 0); the app only * ever draws the content. */ @@ -580,9 +721,52 @@ struct kwl_window *kwl_window_create(const char *title, int w, int h) { draw_titlebar(win, &win->bufs[i], title, tb_font); if (tb_font) wpk_font_destroy(tb_font); } + return win; - win->back_index = 0; - win->back = content_view(win, &win->bufs[0]); +fail: + kwl_window_destroy(win); + return NULL; +} + +struct kwl_window *kwl_layer_create(const char *ns, + const struct kwl_layer_opts *opts) { + if (!opts) { errno = EINVAL; return NULL; } + struct kwl_window *win = kwl_open(); + if (!win) return NULL; + if (!win->layer_shell) goto fail; /* compositor has no layer shell */ + /* A shell component is never decorated: the compositor owns its box. */ + win->tb_h = 0; + win->w = opts->w; + win->h = opts->h; + if (ns) win->title = strdup(ns); + + win->layer_surface = zwlr_layer_shell_v1_get_layer_surface( + win->layer_shell, win->surface, NULL, (uint32_t)opts->layer, + ns ? ns : "layer"); + if (!win->layer_surface) goto fail; + zwlr_layer_surface_v1_add_listener(win->layer_surface, + &layer_surface_listener, win); + zwlr_layer_surface_v1_set_size(win->layer_surface, (uint32_t)opts->w, + (uint32_t)opts->h); + zwlr_layer_surface_v1_set_anchor(win->layer_surface, opts->anchor); + zwlr_layer_surface_v1_set_exclusive_zone(win->layer_surface, + opts->exclusive_zone); + zwlr_layer_surface_v1_set_margin(win->layer_surface, opts->margin_top, + opts->margin_right, opts->margin_bottom, + opts->margin_left); + zwlr_layer_surface_v1_set_keyboard_interactivity( + win->layer_surface, + opts->keyboard ? ZWLR_LAYER_SURFACE_V1_KEYBOARD_INTERACTIVITY_EXCLUSIVE + : ZWLR_LAYER_SURFACE_V1_KEYBOARD_INTERACTIVITY_NONE); + /* The protocol's initial commit: no buffer, just the state above. The + * compositor answers with the box to render into. */ + wl_surface_commit(win->surface); + + while (!win->configured) + if (wl_display_dispatch(win->display) < 0) goto fail; + if (win->w <= 0 || win->h <= 0) goto fail; + + if (kwl_open_buffers(win) != 0) goto fail; return win; fail: @@ -602,6 +786,11 @@ void kwl_window_destroy(struct kwl_window *win) { if (win->decor) zxdg_toplevel_decoration_v1_destroy(win->decor); if (win->decor_mgr) zxdg_decoration_manager_v1_destroy(win->decor_mgr); free(win->title); + if (win->layer_surface) zwlr_layer_surface_v1_destroy(win->layer_surface); + if (win->layer_shell && + wl_proxy_get_version((struct wl_proxy *)win->layer_shell) >= + ZWLR_LAYER_SHELL_V1_DESTROY_SINCE_VERSION) + zwlr_layer_shell_v1_destroy(win->layer_shell); if (win->toplevel) xdg_toplevel_destroy(win->toplevel); if (win->xdg_surface) xdg_surface_destroy(win->xdg_surface); if (win->surface) wl_surface_destroy(win->surface); @@ -643,6 +832,10 @@ void kwl_window_commit(struct kwl_window *win) { win->mapped = 1; /* first commit maps; later configures are resizes */ struct kwl_buffer *b = &win->bufs[win->back_index]; wl_surface_attach(win->surface, b->wl_buf, 0, 0); + /* The buffer is scale times the window's logical box; this is what tells + * the compositor to divide rather than to treat it as a bigger window. + * wl_surface.damage is surface-local, so it stays logical. */ + wl_surface_set_buffer_scale(win->surface, win->scale); wl_surface_damage(win->surface, 0, 0, win->w, win->total_h); struct wl_callback *cb = wl_surface_frame(win->surface); wl_callback_add_listener(cb, &frame_listener, win); diff --git a/examples/libs/wpkdraw/include/wpkdraw/wpkdraw.h b/examples/libs/wpkdraw/include/wpkdraw/wpkdraw.h index b71dcd7105..4e559f9ccc 100644 --- a/examples/libs/wpkdraw/include/wpkdraw/wpkdraw.h +++ b/examples/libs/wpkdraw/include/wpkdraw/wpkdraw.h @@ -18,37 +18,60 @@ typedef uint32_t wpk_color; #define WPK_RGB(r, g, b) (0xff000000u | ((uint32_t)(r) << 16) \ | ((uint32_t)(g) << 8) | (uint32_t)(b)) +/* Device pixels per logical unit. Every coordinate an app passes below is + * logical, and every primitive multiplies by the surface's scale on the way + * in — so an app laid out for a 1x display draws sharp on a 2x one without + * touching a single coordinate. + * + * The scale is a process-wide setting because one process draws for one + * output: wpk_surface_wrap() and wpk_font_load_default() each capture it at + * call time. Set it BEFORE creating any surface or font (libkwl does, as + * soon as it reads wl_output.scale). A program that never calls this — the + * compositor, which composites in device pixels already — stays at 1 and + * behaves exactly as before. Values below 1 clamp to 1. */ +void wpk_set_scale(int scale); +int wpk_scale(void); + /* A drawable target over caller memory. Plain descriptor — no lifecycle: - * copy it, stack-allocate it, throw it away. `stride` is bytes per row - * (>= w*4); the allocator behind a wl_shm buffer may pad rows. */ + * copy it, stack-allocate it, throw it away. `w`/`h` are LOGICAL, so the + * memory behind it is (w*scale) x (h*scale) pixels. `stride` is bytes per + * DEVICE row (>= w*scale*4); the allocator behind a wl_shm buffer may pad + * rows. */ struct wpk_surface { uint32_t *pixels; int w, h; int stride; + int scale; }; -/* Wrap caller memory as a surface. Pass stride == 0 for a tightly-packed - * buffer (w*4). */ +/* Wrap caller memory as a surface of w x h LOGICAL units at the current + * wpk_scale(). Pass stride == 0 for a tightly-packed buffer. */ struct wpk_surface wpk_surface_wrap(uint32_t *pixels, int w, int h, int stride); /* Fill the whole surface with one colour (overwrite, no blend). */ void wpk_clear(struct wpk_surface *s, wpk_color color); -/* Plot one pixel, alpha-blended if color's alpha < 0xff. Out-of-bounds - * writes are silently clipped. */ +/* Plot one logical pixel — a scale x scale block — alpha-blended if color's + * alpha < 0xff. Out-of-bounds writes are silently clipped. */ void wpk_pixel(struct wpk_surface *s, int x, int y, wpk_color color); +/* Plot one DEVICE pixel, alpha-blended and clipped. For drawing code that + * already holds device coordinates — the font engine blitting a glyph mask + * rasterized at the scaled size. Everything else works in logical units. */ +void wpk_pixel_device(struct wpk_surface *s, int x, int y, wpk_color color); + /* Filled rect over columns [x, x+w) and rows [y, y+h), alpha-blended. * Negative/zero w or h is a no-op. */ void wpk_rect(struct wpk_surface *s, int x, int y, int w, int h, wpk_color color); -/* Anti-aliased line from (x0,y0) to (x1,y1), `width` pixels thick with - * round caps (a capsule). Edge pixels get the colour's alpha scaled by - * their coverage, so shallow angles stay smooth. Coordinates are pixel - * centres (a coordinate of 3.0 means the centre of column 3). Zero or - * negative width is a no-op; a zero-length segment draws a disc of - * diameter `width`. */ +/* Anti-aliased line from (x0,y0) to (x1,y1), `width` logical units thick + * with round caps (a capsule). Edge pixels get the colour's alpha scaled by + * their coverage, so shallow angles stay smooth. Coordinates are logical + * pixel centres (a coordinate of 3.0 means the centre of column 3). The + * coverage is computed after scaling, so a scaled line is smoother rather + * than merely bigger. Zero or negative width is a no-op; a zero-length + * segment draws a disc of diameter `width`. */ void wpk_line_aa(struct wpk_surface *s, float x0, float y0, float x1, float y1, float width, wpk_color color); diff --git a/examples/libs/wpkdraw/include/wpkdraw/wpkfont.h b/examples/libs/wpkdraw/include/wpkdraw/wpkfont.h index 77cb5550f4..c40c68cc76 100644 --- a/examples/libs/wpkdraw/include/wpkdraw/wpkfont.h +++ b/examples/libs/wpkdraw/include/wpkdraw/wpkfont.h @@ -13,23 +13,26 @@ /* Opaque font handle: an stb_truetype face + a fixed-size glyph cache. */ struct wpk_font; -/* Load the bundled font at `px_size` pixels. Valid px_size is [4, 256]; +/* Load the bundled font at `px_size` LOGICAL pixels. Glyphs are rasterized + * at px_size * wpk_scale(), captured at load time, so load fonts only after + * the scale is set. Valid px_size is [4, 256]; * out of range returns NULL with errno = EINVAL. Returns NULL / ENOMEM on * allocation failure. */ struct wpk_font *wpk_font_load_default(int px_size); void wpk_font_destroy(struct wpk_font *f); -/* Pixel width of a UTF-8 string in this font (for layout before wpk_text). */ +/* Logical width of a UTF-8 string in this font (for layout before + * wpk_text). */ int wpk_text_width(struct wpk_font *f, const char *utf8); -/* Render a UTF-8 string with (x, y) as the BASELINE (not the top-left), - * alpha-blended into the surface. */ +/* Render a UTF-8 string with (x, y) as the logical BASELINE (not the + * top-left), alpha-blended into the surface. */ void wpk_text(struct wpk_surface *s, struct wpk_font *f, int x, int y, const char *utf8, wpk_color color); -/* Font metrics in pixels at the loaded size. height = ascent + descent; - * ascent = baseline above the origin. Used for row/label layout. */ +/* Font metrics in LOGICAL pixels at the loaded size. height = ascent + + * descent; ascent = baseline above the origin. Used for row/label layout. */ int wpk_font_height_px(struct wpk_font *f); int wpk_font_ascent_px(struct wpk_font *f); diff --git a/examples/libs/wpkdraw/src/wpkdraw.c b/examples/libs/wpkdraw/src/wpkdraw.c index f94f3610d0..76305a7b33 100644 --- a/examples/libs/wpkdraw/src/wpkdraw.c +++ b/examples/libs/wpkdraw/src/wpkdraw.c @@ -17,37 +17,56 @@ static inline uint32_t blend(uint32_t dst, uint32_t src) { return (fa << 24) | (r << 16) | (g << 8) | b; } +static int ui_scale = 1; + +void wpk_set_scale(int scale) { ui_scale = scale < 1 ? 1 : scale; } +int wpk_scale(void) { return ui_scale; } + +/* The surface's extent in device pixels — what every primitive clips to. */ +static inline int dev_w(const struct wpk_surface *s) { return s->w * s->scale; } +static inline int dev_h(const struct wpk_surface *s) { return s->h * s->scale; } + struct wpk_surface wpk_surface_wrap(uint32_t *pixels, int w, int h, int stride) { struct wpk_surface s; s.pixels = pixels; s.w = w; s.h = h; - s.stride = stride > 0 ? stride : w * 4; + s.scale = ui_scale; + s.stride = stride > 0 ? stride : w * s.scale * 4; return s; } void wpk_clear(struct wpk_surface *s, wpk_color color) { if (!s || !s->pixels) return; int stride_px = s->stride / 4; - for (int y = 0; y < s->h; y++) { + int w = dev_w(s), h = dev_h(s); + for (int y = 0; y < h; y++) { uint32_t *row = s->pixels + (size_t)y * stride_px; - for (int x = 0; x < s->w; x++) row[x] = color; + for (int x = 0; x < w; x++) row[x] = color; } } -void wpk_pixel(struct wpk_surface *s, int x, int y, wpk_color color) { +void wpk_pixel_device(struct wpk_surface *s, int x, int y, wpk_color color) { if (!s || !s->pixels) return; - if (x < 0 || y < 0 || x >= s->w || y >= s->h) return; + if (x < 0 || y < 0 || x >= dev_w(s) || y >= dev_h(s)) return; uint32_t *p = s->pixels + (size_t)y * (s->stride / 4) + x; *p = blend(*p, color); } +void wpk_pixel(struct wpk_surface *s, int x, int y, wpk_color color) { + if (!s) return; + wpk_rect(s, x, y, 1, 1, color); +} + void wpk_rect(struct wpk_surface *s, int x, int y, int w, int h, wpk_color color) { if (!s || !s->pixels || w <= 0 || h <= 0) return; - int x0 = x < 0 ? 0 : x, y0 = y < 0 ? 0 : y; - int x1 = x + w; if (x1 > s->w) x1 = s->w; - int y1 = y + h; if (y1 > s->h) y1 = s->h; + int n = s->scale; + int x0 = x * n, y0 = y * n, x1 = (x + w) * n, y1 = (y + h) * n; + if (x0 < 0) x0 = 0; + if (y0 < 0) y0 = 0; + if (x1 > dev_w(s)) x1 = dev_w(s); + if (y1 > dev_h(s)) y1 = dev_h(s); int stride_px = s->stride / 4; if ((color >> 24) == 0xff) { for (int py = y0; py < y1; py++) { @@ -57,7 +76,7 @@ void wpk_rect(struct wpk_surface *s, int x, int y, int w, int h, } else { for (int py = y0; py < y1; py++) for (int px = x0; px < x1; px++) - wpk_pixel(s, px, py, color); + wpk_pixel_device(s, px, py, color); } } @@ -79,12 +98,14 @@ static void blend_coverage(struct wpk_surface *s, int x, int y, if (cov > 1.0f) cov = 1.0f; uint32_t a = (uint32_t)((float)(color >> 24) * cov + 0.5f); if (a == 0) return; - wpk_pixel(s, x, y, (a << 24) | (color & 0x00ffffffu)); + wpk_pixel_device(s, x, y, (a << 24) | (color & 0x00ffffffu)); } void wpk_line_aa(struct wpk_surface *s, float x0, float y0, float x1, float y1, float width, wpk_color color) { if (!s || !s->pixels || width <= 0.0f) return; + float n = (float)s->scale; + x0 *= n; y0 *= n; x1 *= n; y1 *= n; width *= n; float halfw = width * 0.5f; /* Bounding box, padded one pixel for the AA fringe. */ @@ -95,8 +116,8 @@ void wpk_line_aa(struct wpk_surface *s, float x0, float y0, int ix1 = (int)bx1 + 1, iy1 = (int)by1 + 1; if (ix0 < 0) ix0 = 0; if (iy0 < 0) iy0 = 0; - if (ix1 > s->w) ix1 = s->w; - if (iy1 > s->h) iy1 = s->h; + if (ix1 > dev_w(s)) ix1 = dev_w(s); + if (iy1 > dev_h(s)) iy1 = dev_h(s); float vx = x1 - x0, vy = y1 - y0; float len2 = vx * vx + vy * vy; diff --git a/examples/libs/wpkdraw/src/wpkfont.c b/examples/libs/wpkdraw/src/wpkfont.c index 61e35f02aa..23fa7191fe 100644 --- a/examples/libs/wpkdraw/src/wpkfont.c +++ b/examples/libs/wpkdraw/src/wpkfont.c @@ -27,8 +27,9 @@ struct cached_glyph { struct wpk_font { stbtt_fontinfo info; - int px_size; - float scale; + int px_size; /* the LOGICAL size the caller asked for */ + int ui_scale; /* wpk_scale() at load time; glyphs are this bigger */ + float scale; /* font units -> DEVICE pixels */ int ascent, descent; /* font-unit vmetrics */ struct cached_glyph cache[WPK_GLYPH_CACHE_CAP]; int cache_next; /* FIFO eviction cursor */ @@ -45,7 +46,12 @@ struct wpk_font *wpk_font_load_default(int px_size) { return NULL; } f->px_size = px_size; - f->scale = stbtt_ScaleForPixelHeight(&f->info, (float)px_size); + /* Rasterize at the device size. Asking stb for bigger glyphs is the only + * thing that makes text sharper on a scaled output — a mask rendered at + * the logical size and blown up is exactly the blur being removed. */ + f->ui_scale = wpk_scale(); + f->scale = stbtt_ScaleForPixelHeight(&f->info, + (float)(px_size * f->ui_scale)); stbtt_GetFontVMetrics(&f->info, &f->ascent, &f->descent, NULL); return f; } @@ -99,12 +105,16 @@ int wpk_text_width(struct wpk_font *f, const char *utf8) { stbtt_GetCodepointHMetrics(&f->info, cp, &adv, NULL); x += adv * f->scale; } - return (int)(x + 0.5f); + return (int)(x / (float)f->ui_scale + 0.5f); } void wpk_text(struct wpk_surface *s, struct wpk_font *f, int x, int y, const char *utf8, wpk_color color) { if (!s || !f || !utf8) return; + /* The origin is logical; the glyph masks are device pixels. Advance in + * device pixels so a run of text does not accumulate rounding error at + * every character. */ + int px = x * s->scale, py = y * s->scale; while (*utf8) { int cp = decode_utf8(&utf8); if (cp == 0) break; @@ -115,20 +125,20 @@ void wpk_text(struct wpk_surface *s, struct wpk_font *f, unsigned char a = g->bitmap[gy * g->w + gx]; if (!a) continue; wpk_color c = (color & 0x00ffffffu) | ((uint32_t)a << 24); - wpk_pixel(s, x + g->xoff + gx, y + g->yoff + gy, c); + wpk_pixel_device(s, px + g->xoff + gx, py + g->yoff + gy, c); } } } - x += (int)(g->advance * f->scale + 0.5f); + px += (int)(g->advance * f->scale + 0.5f); } } int wpk_font_height_px(struct wpk_font *f) { if (!f) return 0; - return (int)((f->ascent - f->descent) * f->scale + 0.5f); + return (int)((f->ascent - f->descent) * f->scale / (float)f->ui_scale + 0.5f); } int wpk_font_ascent_px(struct wpk_font *f) { if (!f) return 0; - return (int)(f->ascent * f->scale + 0.5f); + return (int)(f->ascent * f->scale / (float)f->ui_scale + 0.5f); } diff --git a/host/src/dri/kms-registry.ts b/host/src/dri/kms-registry.ts index 7875fb02b0..b663805344 100644 --- a/host/src/dri/kms-registry.ts +++ b/host/src/dri/kms-registry.ts @@ -18,27 +18,28 @@ export type HostFb = { * videomode." * * When the embedder has reported the display's device-pixel size - * (`setKmsDisplaySize`, threaded through as `display` here), the mode - * follows the display's ASPECT at a fixed 1080 logical height: - * `round(1080 × aspect) × 1080`, width clamped to [1440, 3840] and - * even-aligned. A mode-picking client (wlcompositor, SDL2 KMSDRM) then - * fills the pane edge-to-edge with no letterbox. The height stays 1080 - * so fixed-size client windows keep fitting vertically regardless of - * how wide the pane is; very tall panes clamp at 1440 wide and - * letterbox again rather than squeezing windows off-screen. Without a - * reported size (Node hosts, headless stats-only CRTCs) the mode is - * the historical 1920x1080@60. */ + * (`setKmsDisplaySize`, threaded through as `display` here), the mode IS + * that size, even-aligned and clamped to [640, 3840] × [480, 2160]. One + * mode pixel per device pixel is what keeps a GL compositor's output + * from being resampled on its way to the canvas. It is also what makes + * a `wl_output` scale meaningful: a compositor divides the mode by its + * scale to get the logical grid its clients lay out in, and a mode + * derived from anything but device pixels makes that division lie. + * Deriving it from the display's ASPECT alone, at a fixed 1080-line + * height, renders a HiDPI pane at half its real resolution. + * Without a reported size (Node hosts, headless + * stats-only CRTCs, the modeset and sdl2 demos, which never call + * `setKmsDisplaySize`) the mode is the historical 1920x1080@60. */ export function buildVirtualConnectorMode( _connectorId: number, display?: { width: number; height: number }, ): Uint8Array { let w = 1920; + let h = 1080; if (display && display.width >= 1 && display.height >= 1) { - const aspect = display.width / display.height; - w = Math.round(1080 * aspect) & ~1; - w = Math.min(3840, Math.max(1440, w)); + w = Math.min(3840, Math.max(640, Math.round(display.width) & ~1)); + h = Math.min(2160, Math.max(480, Math.round(display.height) & ~1)); } - const h = 1080; // Synthetic CVT-ish blanking: consumers here only read // hdisplay/vdisplay/vrefresh (and libdrm derives refresh from // clock/totals), so the porches just need to be self-consistent. @@ -72,12 +73,21 @@ export class KmsRegistry { private fbs = new Map<number, HostFb>(); private crtcBindings = new Map<number, number>(); private masterPid: number | null = null; + private flips = 0; constructor(private gbm: GbmBoRegistry) {} addFb(fb: HostFb): void { this.fbs.set(fb.fb_id, fb); } rmFb(fb_id: number): void { this.fbs.delete(fb_id); } - setFb(crtc_id: number, fb_id: number): void { this.crtcBindings.set(crtc_id, fb_id); } + setFb(crtc_id: number, fb_id: number): void { + this.flips++; + this.crtcBindings.set(crtc_id, fb_id); + } + + /** Monotonic count of SETCRTC/PAGE_FLIP latches. The vblank pump + * compares it across ticks to wake blocked pollers only when a flip + * could have retired into an event ring. */ + flipCount(): number { return this.flips; } currentFb(crtc_id: number): HostFb | undefined { const id = this.crtcBindings.get(crtc_id); diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index 66d845b493..2adbf3a830 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -1627,6 +1627,8 @@ export const IOCTL_REQUESTS: Record<number, IoctlRequestContract> = { 3222299660: { argKind: "pointer", direction: "inout", wasm32Size: 16, wasm64Size: 16 }, 3222299706: { argKind: "pointer", direction: "inout", wasm32Size: 16, wasm64Size: 16 }, 3222299827: { argKind: "pointer", direction: "inout", wasm32Size: 16, wasm64Size: 16 }, + 3222299872: { argKind: "pointer", direction: "inout", wasm32Size: 16, wasm64Size: 16 }, + 3222299873: { argKind: "pointer", direction: "inout", wasm32Size: 16, wasm64Size: 16 }, 3222561958: { argKind: "pointer", direction: "inout", wasm32Size: 20, wasm64Size: 20 }, 3222824112: { argKind: "pointer", direction: "in", wasm32Size: 24, wasm64Size: 24 }, 3223348402: { argKind: "pointer", direction: "inout", wasm32Size: 32, wasm64Size: 32 }, diff --git a/host/src/input/browser-input-source.ts b/host/src/input/browser-input-source.ts index 7cabc505a3..4712f3361e 100644 --- a/host/src/input/browser-input-source.ts +++ b/host/src/input/browser-input-source.ts @@ -13,7 +13,7 @@ * value. */ import type { InputSource, InputEvent } from "./input-source.js"; -import { codeToKey } from "./key-code-table.js"; +import { charToKey, codeToKey } from "./key-code-table.js"; const EV_SYN = 0x00, EV_KEY = 0x01, @@ -102,7 +102,7 @@ export class BrowserInputSource implements InputSource { } private onKeyDown(e: KeyboardEvent): void { - const key = codeToKey(e.code); + const key = charToKey(e.key) ?? codeToKey(e.code); if (key === null) return; e.preventDefault(); this.emit(0, EV_KEY, key, e.repeat ? 2 : 1); @@ -110,7 +110,7 @@ export class BrowserInputSource implements InputSource { } private onKeyUp(e: KeyboardEvent): void { - const key = codeToKey(e.code); + const key = charToKey(e.key) ?? codeToKey(e.code); if (key === null) return; e.preventDefault(); this.emit(0, EV_KEY, key, 0); diff --git a/host/src/input/key-code-table.ts b/host/src/input/key-code-table.ts index 023af6a466..f3221840fd 100644 --- a/host/src/input/key-code-table.ts +++ b/host/src/input/key-code-table.ts @@ -143,6 +143,27 @@ const CODE_TO_KEY: Record<string, number> = { /** Translate a `KeyboardEvent.code` string to its Linux `KEY_*` value. * Returns `null` for codes we don't translate (locale-specific keys * outside Linux UAPI, browser-specific extensions). */ +/** + * `KeyboardEvent.key` → Linux `KEY_*` for typed letters. The kernel-side + * xkb keymap is a fixed US layout, so the positional `code` path delivers + * the letter printed at the US position of the pressed key: on AZERTY the + * key labeled W sits at the QWERTY-Z position, and Ctrl+W would arrive as + * Ctrl+Z. Mapping the typed letter back to its US-layout keycode makes the + * wire carry what the key says. Letters only — digits and punctuation stay + * positional, because their `key` value depends on Shift and the layout's + * unshifted symbols. + */ +const CHAR_TO_KEY: Record<string, number> = { + a: 30, b: 48, c: 46, d: 32, e: 18, f: 33, g: 34, h: 35, i: 23, + j: 36, k: 37, l: 38, m: 50, n: 49, o: 24, p: 25, q: 16, r: 19, + s: 31, t: 20, u: 22, v: 47, w: 17, x: 45, y: 21, z: 44, +}; + +export function charToKey(key: unknown): number | null { + if (typeof key !== "string" || key.length !== 1) return null; + return CHAR_TO_KEY[key.toLowerCase()] ?? null; +} + export function codeToKey(code: string): number | null { const k = CODE_TO_KEY[code]; return k === undefined ? null : k; diff --git a/host/src/kernel.ts b/host/src/kernel.ts index 3b4118b46d..8ceaf06b5e 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -757,6 +757,12 @@ export interface KernelCallbacks { * legacy "embedder must call attachCanvas manually" path alive. */ getKmsCanvas?: (crtcId: number) => OffscreenCanvas | HTMLCanvasElement | undefined; + /** + * Return any one registered KMS scanout crtc id, or undefined if + * none. Used by callers that just need a default canvas binding + * without iterating the full registry. + */ + firstKmsCanvasCrtc?: () => number | undefined; /** * Notify the embedder that GL has claimed the canvas for `crtcId`. * The KMS vblank pump uses this to skip the CPU `putImageData` blit @@ -2148,7 +2154,18 @@ export class WasmPosixKernel { // is about to call eglCreateContext would silently no-op // every shader compile/link/draw because `b.canvas` stays // null and `b.gl` is never built. - const crtc = this.kms.masterCrtcForPid(pid); + // Fall back to `firstKmsCanvasCrtc` when no FB is bound yet: + // SDL2's KMSDRM backend and wlcompositor's boot-time GLES + // probe call `eglCreateContext` BEFORE their first + // `drmModeAddFB`/`drmModeSetCrtc`, so `masterCrtcForPid` + // returns null at this point. The embedder has already + // registered exactly one scanout canvas; we attach to it + // unconditionally as long as this pid actually holds DRM + // master (no canvas hand-off to non-master programs). + let crtc = this.kms.masterCrtcForPid(pid); + if (crtc == null && this.kms.isMasterPid(pid)) { + crtc = this.callbacks.firstKmsCanvasCrtc?.() ?? null; + } if (crtc != null) { const canvas = this.callbacks.getKmsCanvas?.(crtc); if (canvas) { @@ -2185,6 +2202,16 @@ export class WasmPosixKernel { ctx.getExtension("EXT_color_buffer_float"); ctx.getExtension("OES_texture_float_linear"); ctx.getExtension("EXT_float_blend"); + // Seed the shadow viewport with the actual WebGL2 default + // (the canvas drawing-buffer dimensions). Without this seed, + // `defaultShadow()`'s `[0,0,0,0]` propagates through + // `GlMuxer.switchTo` on first frame and clobbers WebGL2's + // implicit default viewport — programs that never call + // `glViewport` (SDL2's KMSDRM/OpenGLES backend leaves it + // implicit; modeset.c sets it explicitly so the bug stays + // hidden there) then draw into a 0×0 region and produce a + // blank canvas even though every other op succeeds. + b.shadow.viewport = [0, 0, b.canvas.width, b.canvas.height]; } b.gl = ctx; // Only hand the CRTC canvas over (pump presenter stands down, @@ -2349,7 +2376,15 @@ export class WasmPosixKernel { (bb, off, len) => decodeAndDispatch(bb, off, len), ); }, - host_gl_present: (pid: number): void => { + host_gl_present: (pid: number): number => { + // A lost WebGL context silently no-ops every GL call, so a + // present into it would report success while the canvas stays + // frozen. EIO here fails the guest's `eglSwapBuffers`, which is + // its cue to degrade to CPU compositing and hand the canvas + // back to the vblank pump (`eglTerminate` → + // `markKmsCanvasGlReleased`). + const b = this.gl.get(pid); + if (b?.gl?.isContextLost()) return -5; // EIO // A GPU-tier producer renders into an offscreen bo FBO on the // shared context, so `eglSwapBuffers` must fence the queued GL // work: `flush()` guarantees the producer's draws are submitted @@ -2357,8 +2392,8 @@ export class WasmPosixKernel { // through the one shared context then gives render-before-sample // for free — no explicit sync object in v1). Canvas-backed // sessions present via RAF and need no fence here. - const b = this.gl.get(pid); if (b?.renderTargetFbo) b.gl?.flush(); + return 0; }, host_gl_query: ( pid: number, op: number, diff --git a/host/test/browser-input-source.test.ts b/host/test/browser-input-source.test.ts index d0d682c3af..9df4c49e60 100644 --- a/host/test/browser-input-source.test.ts +++ b/host/test/browser-input-source.test.ts @@ -65,6 +65,44 @@ describe("BrowserInputSource", () => { ]); }); + it("a typed letter wins over its physical position (AZERTY Ctrl+W)", () => { + // On AZERTY the key labeled W sits at the QWERTY-Z position: the browser + // reports code=KeyZ, key="w". The kernel-side keymap is a fixed US + // layout, so the wire must carry KEY_W (17) — what the key says — or a + // `bind = CTRL, W` never fires for a French keyboard. + target.fire("keydown", { + code: "KeyZ", + key: "w", + repeat: false, + preventDefault() {}, + }); + target.fire("keyup", { + code: "KeyZ", + key: "w", + repeat: false, + preventDefault() {}, + }); + expect(recorded).toEqual([ + { device: 0, ev_type: 0x01, code: 17, value: 1 }, + { device: 0, ev_type: 0x00, code: 0, value: 0 }, + { device: 0, ev_type: 0x01, code: 17, value: 0 }, + { device: 0, ev_type: 0x00, code: 0, value: 0 }, + ]); + }); + + it("a non-letter key value falls back to the positional code", () => { + // Dead keys ("Dead"), modified characters ("å"), and layout symbols + // ("&" on an AZERTY digit) are not typed ASCII letters: the positional + // path stays authoritative for them. + target.fire("keydown", { + code: "Digit1", + key: "&", + repeat: false, + preventDefault() {}, + }); + expect(recorded[0]).toEqual({ device: 0, ev_type: 0x01, code: 2, value: 1 }); + }); + it("repeat keydown emits value=2 (Linux autorepeat convention)", () => { target.fire("keydown", { code: "Space", diff --git a/host/test/dri-kms-registry.test.ts b/host/test/dri-kms-registry.test.ts index f17d3e79d3..d37daf6efc 100644 --- a/host/test/dri-kms-registry.test.ts +++ b/host/test/dri-kms-registry.test.ts @@ -22,6 +22,21 @@ describe("KmsRegistry", () => { expect(kms.currentFb(1)).toBeUndefined(); }); + it("flipCount counts every latch, including a rebind of the same fb", () => { + const kms = new KmsRegistry(new GbmBoRegistry()); + expect(kms.flipCount()).toBe(0); + + kms.addFb(fb(10)); + kms.setFb(1, 10); + expect(kms.flipCount()).toBe(1); + + kms.setFb(1, 10); + expect(kms.flipCount()).toBe(2); + + kms.rmFb(10); + expect(kms.flipCount()).toBe(2); + }); + it("setMasterPid / dropMaster / isMasterPid", () => { const kms = new KmsRegistry(new GbmBoRegistry()); expect(kms.isMasterPid(7)).toBe(false); @@ -91,24 +106,32 @@ describe("buildVirtualConnectorMode", () => { expect(new DataView(blob.buffer).getUint32(32, true)).toBe((1 << 3) | (1 << 6)); }); - it("follows the display aspect at fixed 1080 height", () => { + it("is the reported device-pixel size, not its aspect", () => { // 2412×1080 display (the wide Modeset pane) → 2412x1080 mode. expect(modeDims(buildVirtualConnectorMode(1, { width: 2412, height: 1080 }))) .toEqual({ w: 2412, h: 1080 }); - // Same aspect at a different scale gives the same mode. + // The same ASPECT at half the size is half the mode. An aspect-derived + // mode makes these two identical, which renders a HiDPI pane at half its + // real resolution. expect(modeDims(buildVirtualConnectorMode(1, { width: 1206, height: 540 }))) - .toEqual({ w: 2412, h: 1080 }); + .toEqual({ w: 1206, h: 540 }); expect(modeName(buildVirtualConnectorMode(1, { width: 1206, height: 540 }))) - .toBe("2412x1080"); + .toBe("1206x540"); + }); + + it("tracks a devicePixelRatio 2 pane at full device resolution", () => { + // 1088×613 CSS at dpr 2 — the omarchy pane on a Retina display. + expect(modeDims(buildVirtualConnectorMode(1, { width: 2176, height: 1226 }))) + .toEqual({ w: 2176, h: 1226 }); }); - it("even-aligns and clamps the width to [1440, 3840]", () => { - // 1085/1080 aspect → 1085 → odd, and below the floor → 1440. - expect(modeDims(buildVirtualConnectorMode(1, { width: 1085, height: 1080 })).w).toBe(1440); - // Ultra-wide clamps at 3840. - expect(modeDims(buildVirtualConnectorMode(1, { width: 10000, height: 1080 })).w).toBe(3840); - // Odd product rounds down to even: 1471/1080 aspect → 1471 → 1470. - expect(modeDims(buildVirtualConnectorMode(1, { width: 1471, height: 1080 })).w).toBe(1470); + it("even-aligns and clamps to [640, 3840] x [480, 2160]", () => { + expect(modeDims(buildVirtualConnectorMode(1, { width: 1471, height: 1081 }))) + .toEqual({ w: 1470, h: 1080 }); + expect(modeDims(buildVirtualConnectorMode(1, { width: 320, height: 200 }))) + .toEqual({ w: 640, h: 480 }); + expect(modeDims(buildVirtualConnectorMode(1, { width: 10000, height: 5000 }))) + .toEqual({ w: 3840, h: 2160 }); }); it("ignores degenerate display sizes", () => { diff --git a/host/test/dri-kms-stats-sab.test.ts b/host/test/dri-kms-stats-sab.test.ts index 092d154cac..2195e39bd3 100644 --- a/host/test/dri-kms-stats-sab.test.ts +++ b/host/test/dri-kms-stats-sab.test.ts @@ -29,6 +29,34 @@ function makeFakeCanvas(): OffscreenCanvas { } as unknown as OffscreenCanvas; } +/** A canvas that records its listeners so a test can fire the WebGL + * context-loss pair. `fire` reports whether the handler cancelled the + * event — only a cancelled loss opts into restoration. */ +function makeListeningCanvas(): { + canvas: OffscreenCanvas; + fire: (type: string) => boolean; +} { + const listeners = new Map<string, (event: Event) => void>(); + const canvas = { + width: 0, + height: 0, + getContext: () => null, + addEventListener: (type: string, fn: (event: Event) => void) => { + listeners.set(type, fn); + }, + } as unknown as OffscreenCanvas; + return { + canvas, + fire: (type: string) => { + let cancelled = false; + listeners.get(type)?.({ + preventDefault: () => { cancelled = true; }, + } as Event); + return cancelled; + }, + }; +} + type TestKernel = ReturnType<typeof createCentralizedKernelWorkerTestDouble>; function makeKernel( @@ -167,6 +195,49 @@ describe("CentralizedKernelWorker KMS stats SAB", () => { expect(Atomics.load(view, 3)).toBe(0); }); + it("tickVblank wakes blocked retries only on a tick that latched a flip", () => { + const kernel = makeKernel(); + let flips = 0; + (kernel.kms as unknown as { flipCount: () => number }).flipCount = () => flips; + const wake = vi.fn(); + kernel.testAuthority.configureScratchBoundaryHooksForTest({ + scheduleWakeBlockedRetries: wake, + }); + kernel.attachKmsStats(0, new SharedArrayBuffer(5 * 4)); + + vi.advanceTimersByTime(17); + expect(wake).not.toHaveBeenCalled(); + + flips = 1; + vi.advanceTimersByTime(17); + expect(wake).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(17 * 4); + expect(wake).toHaveBeenCalledTimes(1); + }); + + it("a webgl2-scanout attach stands the presenter down on loss and rebuilds on restore", () => { + const kernel = makeKernel(); + const statsSab = new SharedArrayBuffer(8 * 4); + const view = new Int32Array(statsSab); + const { canvas, fire } = makeListeningCanvas(); + kernel.attachKmsCanvas(1, canvas, statsSab, { mode: "webgl2-scanout" }); + + const presenters = (kernel as unknown as { + kmsGlPresenters: Map<number, unknown>; + }).kmsGlPresenters; + presenters.set(1, { gl: {}, tex: {} }); + Atomics.store(view, 7, 1); + + expect(fire("webglcontextlost"), "an uncancelled loss is never restored") + .toBe(true); + expect(presenters.get(1)).toBeNull(); + expect(Atomics.load(view, 7)).toBe(0); + + fire("webglcontextrestored"); + expect(presenters.has(1)).toBe(false); + }); + it("attachKmsStats leaves slots 5/6 untouched when the SAB is too small", () => { const kernel = makeKernel({ kernel_kms_commit_count: (_crtc: number) => { diff --git a/host/test/kernel-blocking-retry-snapshot.test.ts b/host/test/kernel-blocking-retry-snapshot.test.ts index 5a318b7cd3..e22020d4c4 100644 --- a/host/test/kernel-blocking-retry-snapshot.test.ts +++ b/host/test/kernel-blocking-retry-snapshot.test.ts @@ -4638,6 +4638,69 @@ describe("remaining pointer-bearing blocking retry snapshots", () => { }, ); + it.each([ + [ + "signal-target selector", + (harness: RetryHarness) => { + harness.kernelExports.kernel_pick_signal_target_tid = vi.fn( + () => -ESRCH, + ); + }, + ], + [ + "deliverable-signal query", + (harness: RetryHarness) => { + harness.kernelExports.kernel_thread_has_deliverable = vi.fn( + () => -ESRCH, + ); + }, + ], + ] as const)( + "treats -ESRCH from the %s as a vanished target, not corruption", + async (_description, configure) => { + const harness = createRetryHarness(4); + const futexPointer = 0x1000; + new Int32Array( + harness.processMemory.buffer, + )[futexPointer >>> 2] = 0; + writeRequest(harness, ABI_SYSCALLS.Futex, [ + BigInt(futexPointer), + 0n, + 0n, + 0n, + 0n, + 0n, + ]); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult(kernelView(harness, rawPointer), 0, 0); + return 0; + }, + ); + harness.kernelExports.kernel_dequeue_signal = vi.fn(() => 0); + harness.worker.handleSyscall(harness.channel); + expect( + harness.worker.pendingFutexWaits.has(harness.channel), + ).toBe(true); + configure(harness); + + harness.worker.testAuthority.sendSignalForTest( + harness.channel.pid, + SIGUSR1, + ); + await Promise.resolve(); + + expect(harness.onKernelFatal).not.toHaveBeenCalled(); + expect( + harness.kernelExports.kernel_dequeue_signal, + ).not.toHaveBeenCalled(); + expect(requestStatus(harness)).toBe(CHANNEL_STATUS_PENDING); + expect( + harness.worker.pendingFutexWaits.has(harness.channel), + ).toBe(true); + }, + ); + it.each([ [ "SYS_KILL missing signal-target selector", diff --git a/host/test/kernel-scratch-transfer-boundaries.test.ts b/host/test/kernel-scratch-transfer-boundaries.test.ts index 803d35e828..93ffee6476 100644 --- a/host/test/kernel-scratch-transfer-boundaries.test.ts +++ b/host/test/kernel-scratch-transfer-boundaries.test.ts @@ -2718,10 +2718,12 @@ describe("kernel scratch transfer capacity regressions", () => { invokeIovecMethod(harness, method, [7, msgPtr, 0, 0, 0, 0]); expect(harness.handleChannel).not.toHaveBeenCalled(); + // Linux's __copy_msghdr rejects the array with EMSGSIZE; only the + // readv/writev family keeps POSIX's EINVAL. expect(harness.completeChannelRaw).toHaveBeenCalledWith( harness.channel, -1, - EINVAL, + EMSGSIZE, ); expectScratchTailUntouched(harness); }, diff --git a/host/test/libkwl-smoke.test.ts b/host/test/libkwl-smoke.test.ts index 543a55552c..4ad1718cbb 100644 --- a/host/test/libkwl-smoke.test.ts +++ b/host/test/libkwl-smoke.test.ts @@ -29,7 +29,7 @@ import { readFileSync } from "node:fs"; import { NodeKernelHost } from "../src/node-kernel-host"; import { tryResolveBinary } from "../src/binary-resolver"; -const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); const kwldemoBin = tryResolveBinary("programs/kwldemo.wasm"); const hasBinaries = !!compositorBin && !!kwldemoBin; diff --git a/host/test/spawn-credential-order.test.ts b/host/test/spawn-credential-order.test.ts index a2567a0aae..6d5c194b33 100644 --- a/host/test/spawn-credential-order.test.ts +++ b/host/test/spawn-credential-order.test.ts @@ -718,6 +718,7 @@ function preparedSpawnExports(options: { return cwd.byteLength; }), kernel_remove_process: vi.fn(() => 0), + kernel_process_secure_exec: vi.fn(() => 0), }; } diff --git a/host/test/wlcompositor-decoration-smoke.test.ts b/host/test/wlcompositor-decoration-smoke.test.ts index a9629865cd..07e69c8f2d 100644 --- a/host/test/wlcompositor-decoration-smoke.test.ts +++ b/host/test/wlcompositor-decoration-smoke.test.ts @@ -16,7 +16,7 @@ import { readFileSync } from "node:fs"; import { NodeKernelHost } from "../src/node-kernel-host"; import { tryResolveBinary } from "../src/binary-resolver"; -const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); const clientBin = tryResolveBinary("programs/wlclient-test.wasm"); const hasBinaries = !!compositorBin && !!clientBin; diff --git a/host/test/wlcompositor-dmabuf-smoke.test.ts b/host/test/wlcompositor-dmabuf-smoke.test.ts index c907623371..33ef581d51 100644 --- a/host/test/wlcompositor-dmabuf-smoke.test.ts +++ b/host/test/wlcompositor-dmabuf-smoke.test.ts @@ -25,7 +25,7 @@ import { readFileSync } from "node:fs"; import { NodeKernelHost } from "../src/node-kernel-host"; import { tryResolveBinary } from "../src/binary-resolver"; -const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); const clientBin = tryResolveBinary("programs/wldmabuf-test.wasm"); const hasBinaries = !!compositorBin && !!clientBin; diff --git a/host/test/wlcompositor-keybind-smoke.test.ts b/host/test/wlcompositor-keybind-smoke.test.ts index 4a74a93618..c66a0792e1 100644 --- a/host/test/wlcompositor-keybind-smoke.test.ts +++ b/host/test/wlcompositor-keybind-smoke.test.ts @@ -21,7 +21,7 @@ import { join } from "node:path"; import { NodeKernelHost } from "../src/node-kernel-host"; import { tryResolveBinary } from "../src/binary-resolver"; -const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); const clientBin = tryResolveBinary("programs/wlclient-test.wasm"); const kwlctlBin = tryResolveBinary("programs/kwlctl.wasm"); const hasBinaries = !!compositorBin && !!clientBin && !!kwlctlBin; @@ -39,8 +39,10 @@ const KEY_5 = 6; const KEY_W = 17; const KEY_J = 36; const KEY_K = 37; +const KEY_2 = 3; const KEY_LEFTMETA = 125; // SUPER const KEY_LEFTCTRL = 29; // CTRL +const KEY_LEFTALT = 56; // ALT function loadBytes(path: string): ArrayBuffer { const buf = readFileSync(path); @@ -272,6 +274,69 @@ describe("wlcompositor — config-file keybind engine", () => { 60_000, ); + // The Omarchy-menu modifier: bind matching is exact-mods, so with both a + // CTRL and a CTRL ALT bind on the same key, holding ALT selects the ALT one. + it.skipIf(!hasBinaries)( + "a CTRL ALT bind fires and shadows the CTRL bind on the same key", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + const kwlctlBytes = loadBytes(kwlctlBin!); + + const dir = mkdtempSync(join(tmpdir(), "wlc-conf-")); + const confPath = join(dir, "wlcompositor.conf"); + writeFileSync(confPath, + "bind = CTRL, 2, workspace, 3\nbind = CTRL ALT, 2, workspace, 6\n"); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + const compExit = host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_LAYOUT=dwindle", `WLC_CONFIG=${confPath}`], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + expect(out.value, `config not parsed.\n${dump()}`) + .toContain(`BINDS_LOADED n=2 source=${confPath}`); + + host.spawn(clientBytes, ["wlclient-test"], {}); + await waitFor(out, "CLIENT_CONNECTED count=1", 20_000, dump); + + host.spawn(kwlctlBytes, ["kwlctl", "--listen"], {}); + await waitFor(out, "listening", 10_000, dump); + + host.injectInputEvent(0, EV_KEY, KEY_LEFTCTRL, 1); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, KEY_LEFTALT, 1); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, KEY_2, 1); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, KEY_2, 0); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, KEY_LEFTALT, 0); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, KEY_LEFTCTRL, 0); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + + await waitFor(out, "workspace>>6", 10_000, dump); + expect(out.value).not.toContain("workspace>>3"); + + void compExit; + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); + // The `/?demo=hyprland` "new pane" flow: an `exec` bind launches an app when // its key is pressed (CTRL+K=clock, CTRL+P=paint, CTRL+Return=terminal). This // gates that a CTRL exec bind actually spawns a NEW client — the keybind diff --git a/host/test/wlcompositor-kwlctl-smoke.test.ts b/host/test/wlcompositor-kwlctl-smoke.test.ts index 762bbbdf48..22fe2e71c3 100644 --- a/host/test/wlcompositor-kwlctl-smoke.test.ts +++ b/host/test/wlcompositor-kwlctl-smoke.test.ts @@ -10,7 +10,10 @@ * - `kwlctl --listen` streams the `event>>data` line emitted when * `kwlctl dispatch workspace 2` switches workspace (proves the event bus); * - `kwlctl dispatch exec wlclient-test` forks+execs a fourth client through - * the compositor (proves dispatch exec; the exec resolves via onResolveExec). + * the compositor (proves dispatch exec; the exec resolves via onResolveExec); + * - the Hyprland IPC pair (/tmp/hypr/wlcompositor/.socket.sock + .socket2.sock) + * answers `j/`-prefixed queries in hyprctl -j shapes and streams the same + * events with no handshake — the surface Waybar's hyprland modules consume. * * kwlctl talks to the compositor entirely inside the kernel (a client<->server * UNIX socket), so there is no host/src change — the dual-host parity rule is @@ -21,7 +24,7 @@ import { readFileSync } from "node:fs"; import { NodeKernelHost } from "../src/node-kernel-host"; import { tryResolveBinary } from "../src/binary-resolver"; -const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); const clientBin = tryResolveBinary("programs/wlclient-test.wasm"); const kwlctlBin = tryResolveBinary("programs/kwlctl.wasm"); const hasBinaries = !!compositorBin && !!clientBin && !!kwlctlBin; @@ -152,7 +155,37 @@ describe("wlcompositor — kwlctl control + event IPC", () => { const workspaces = JSON.parse(wsMatch![1]) as Array<{ id: number; windows: number; active: boolean; }>; - expect(workspaces).toContainEqual({ id: 1, windows: 3, active: true }); + expect(workspaces).toContainEqual( + expect.objectContaining({ id: 1, windows: 3, active: true })); + + // --- Hyprland socket1: hyprctl -j shapes behind the j/ prefix. --- + const hyprEnv = ["KWLCTL_SOCKET=/tmp/hypr/wlcompositor/.socket.sock"]; + const awCode = await host.spawn( + kwlctlBytes, ["kwlctl", "j/activeworkspace"], { env: hyprEnv }); + expect(awCode, `j/activeworkspace exit.\n${dump()}`).toBe(0); + const awMatch = out.value.match(/\n(\{"id":1,"name":"1"[^\n]*\})\n/); + expect(awMatch, `no activeworkspace JSON.\n${dump()}`).not.toBeNull(); + const activeWs = JSON.parse(awMatch![1]) as { + monitor: string; windows: number; active: boolean; + }; + expect(activeWs.monitor).toBe("virtual-0"); + expect(activeWs.windows).toBe(3); + const monCode = await host.spawn( + kwlctlBytes, ["kwlctl", "j/monitors"], { env: hyprEnv }); + expect(monCode, `j/monitors exit.\n${dump()}`).toBe(0); + const monMatch = out.value.match(/(\[\{"id":0,"name":"virtual-0"[^\n]*\])/); + expect(monMatch, `no monitors JSON.\n${dump()}`).not.toBeNull(); + const monitors = JSON.parse(monMatch![1]) as Array<{ + focused: boolean; activeWorkspace: { id: number }; + }>; + expect(monitors[0].focused).toBe(true); + expect(monitors[0].activeWorkspace.id).toBe(1); + + // --- Hyprland socket2 streams events from the first byte on. --- + host.spawn(kwlctlBytes, ["kwlctl", "--listen"], { + env: ["KWLCTL_SOCKET=/tmp/hypr/wlcompositor/.socket2.sock"], + }); + await waitFor(out, "HYPR_LISTENER", 10_000, dump); // --- --listen streams the workspace event fired by a dispatch. --- runKwlctl(["--listen"]); // background; drains until compositor exits @@ -161,6 +194,7 @@ describe("wlcompositor — kwlctl control + event IPC", () => { const dispCode = await runKwlctl(["dispatch", "workspace", "2"]); expect(dispCode, `dispatch workspace exit.\n${dump()}`).toBe(0); await waitFor(out, "workspace>>2", 10_000, dump); + await waitFor(out, "workspacev2>>2,2", 10_000, dump); // --- dispatch exec spawns a fourth client through the compositor. const execCode = await runKwlctl( diff --git a/host/test/wlcompositor-layer-shell-smoke.test.ts b/host/test/wlcompositor-layer-shell-smoke.test.ts new file mode 100644 index 0000000000..21e429ee75 --- /dev/null +++ b/host/test/wlcompositor-layer-shell-smoke.test.ts @@ -0,0 +1,406 @@ +/** + * PR15 gate: zwlr_layer_shell_v1 in the wlcompositor, driven by its two real + * consumers — kbar (the status bar) and klauncher (the app launcher). + * + * Three things must hold for a shell component to work at all: + * 1. The compositor anchors the surface where the client asked, at the size + * it dictated (the `LAYER ns=… x= y= w= h=` marker). + * 2. Its exclusive zone shrinks the window work area: the windows below tile + * into the output MINUS the bar, so nothing is ever covered. Asserted by + * recomputing the dwindle partition of the reduced area here. + * 3. Exclusive keyboard interactivity really takes the keyboard: keys typed + * while klauncher is up filter its list instead of reaching the window + * that had focus, and Enter dispatches the entry's exec through kwlctl. + * + * Skips if the binaries aren't built (bare checkout). + */ +import { describe, expect, it } from "vitest"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { NodeKernelHost } from "../src/node-kernel-host"; +import { tryResolveBinary } from "../src/binary-resolver"; + +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); +const clientBin = tryResolveBinary("programs/wlclient-test.wasm"); +const kbarBin = tryResolveBinary("programs/kbar.wasm"); +const klauncherBin = tryResolveBinary("programs/wldesktop/klauncher.wasm"); +const kwlctlBin = tryResolveBinary("programs/kwlctl.wasm"); +const hasBinaries = !!compositorBin && !!clientBin && !!kbarBin && !!klauncherBin + && !!kwlctlBin; + +const CANVAS_W = 1920; +const CANVAS_H = 1080; + +// Must match BAR_H in kbar.c and the gap constants in wlcompositor.c. +const BAR_H = 30; +const GAP_OUTER = 12; +const GAP_INNER = 8; + +// evdev keycodes (linux/input-event-codes.h). +const EV_KEY = 0x01; +const EV_SYN = 0x00; +const EV_ABS = 0x03; +const SYN_REPORT = 0x00; +const ABS_X = 0x00; +const ABS_Y = 0x01; +const KEY_T = 20; +const KEY_ENTER = 28; +const BTN_LEFT = 0x110; + +// The input canvas matches the compositor's output, so an injected absolute +// coordinate maps 1:1 to the cursor position. This point sits inside the bar's +// top strip, which is what surface_at() resolves a click there to. +const BAR_POINT_X = 960; +const BAR_POINT_Y = 15; + +// Bottom-left of the canvas: on the tiled window and well clear of the +// 520x320 launcher klauncher centres on the output. +const WINDOW_POINT_X = 40; +const WINDOW_POINT_Y = 1040; + +interface Rect { x: number; y: number; w: number; h: number } + +function parseTiles(text: string, count: number): Rect[] { + const tiles: Rect[] = []; + const re = new RegExp( + `TILE n=${count} i=(\\d+) x=(-?\\d+) y=(-?\\d+) w=(\\d+) h=(\\d+)`, "g"); + for (let m = re.exec(text); m; m = re.exec(text)) { + tiles[Number(m[1])] = { + x: Number(m[2]), y: Number(m[3]), w: Number(m[4]), h: Number(m[5]), + }; + } + return tiles; +} + +// Mirror of the C compute_tiling(), over whatever work area is left. +function computeTiling(area: Rect, n: number): Rect[] { + const out: Rect[] = []; + if (n <= 0) return out; + let region: Rect = { + x: area.x + GAP_OUTER, + y: area.y + GAP_OUTER, + w: Math.max(1, area.w - 2 * GAP_OUTER), + h: Math.max(1, area.h - 2 * GAP_OUTER), + }; + for (let i = 0; i < n; i++) { + if (i === n - 1) { out.push(region); break; } + const near: Rect = { ...region }; + const rest: Rect = { ...region }; + if (region.w >= region.h) { + const half = Math.max(1, Math.floor((region.w - GAP_INNER) / 2)); + near.w = half; + rest.x = region.x + half + GAP_INNER; + rest.w = region.w - half - GAP_INNER; + } else { + const half = Math.max(1, Math.floor((region.h - GAP_INNER) / 2)); + near.h = half; + rest.y = region.y + half + GAP_INNER; + rest.h = region.h - half - GAP_INNER; + } + out.push(near); + region = rest; + } + return out; +} + +function loadBytes(path: string): ArrayBuffer { + const buf = readFileSync(path); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +async function waitFor( + ref: { value: string }, + needle: string | RegExp, + timeoutMs: number, + context: () => string, +): Promise<void> { + const deadline = Date.now() + timeoutMs; + const hit = () => + typeof needle === "string" ? ref.value.includes(needle) : needle.test(ref.value); + while (Date.now() < deadline) { + if (hit()) return; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error(`Timed out waiting for ${needle}.\n${context()}`); +} + +describe("wlcompositor — wlr-layer-shell shell components", () => { + it.skipIf(!hasBinaries)( + "kbar anchors across the top and its exclusive zone shrinks the tiling area", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + const kbarBytes = loadBytes(kbarBin!); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_LAYOUT=dwindle"], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + host.spawn(kbarBytes, ["kbar"], {}); + await waitFor(out, /KBAR_READY /, 20_000, dump); + // kbar prints KBAR_READY once it commits its first buffer; the + // compositor prints LAYER once it processes that commit, which is + // strictly later. + await waitFor(out, /LAYER ns=bar /, 20_000, dump); + + // 1) Anchored full-width along the top edge at the height it asked for. + const layer = out.value.match( + /LAYER ns=bar layer=(\d+) x=(-?\d+) y=(-?\d+) w=(\d+) h=(\d+)/); + expect(layer, `no bar LAYER marker.\n${dump()}`).not.toBeNull(); + expect(Number(layer![1]), "bar should sit on the top layer").toBe(2); + expect({ + x: Number(layer![2]), y: Number(layer![3]), + w: Number(layer![4]), h: Number(layer![5]), + }).toEqual({ x: 0, y: 0, w: CANVAS_W, h: BAR_H }); + + // The bar renders at the size the compositor dictated. + expect(out.value, `bar size mismatch.\n${dump()}`) + .toMatch(new RegExp(`KBAR_READY w=${CANVAS_W} h=${BAR_H}`)); + + // 2) Windows tile into the output MINUS the bar's exclusive zone. + host.spawn(clientBytes, ["wlclient-test"], {}); + host.spawn(clientBytes, ["wlclient-test"], {}); + await waitFor(out, /TILE n=2 i=1 /, 20_000, dump); + + const tiles = parseTiles(out.value, 2); + const expected = computeTiling( + { x: 0, y: BAR_H, w: CANVAS_W, h: CANVAS_H - BAR_H }, 2); + expect(tiles, `tiling ignored the exclusive zone.\n${dump()}`) + .toEqual(expected); + for (const t of tiles) + expect(t.y, `window overlaps the bar.\n${dump()}`) + .toBeGreaterThanOrEqual(BAR_H); + + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); + + it.skipIf(!hasBinaries)( + "klauncher takes the keyboard exclusively, filters, and execs on Enter", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + const klauncherBytes = loadBytes(klauncherBin!); + const kwlctlBytes = loadBytes(kwlctlBin!); + + // A two-entry registry: typing "t" must narrow it to one. + const appsDir = mkdtempSync(join(tmpdir(), "kandelo-apps-")); + mkdirSync(appsDir, { recursive: true }); + writeFileSync(join(appsDir, "terminal.conf"), + "name = Terminal\nexec = /usr/local/bin/wlterm\n"); + writeFileSync(join(appsDir, "clock.conf"), + "name = Clock\nexec = /usr/local/bin/wlclock\n"); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + const tap = (code: number) => { + host.injectInputEvent(0, EV_KEY, code, 1); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, code, 0); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + }; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_LAYOUT=dwindle"], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + // A window owns the keyboard first — the launcher has to take it away. + host.spawn(clientBytes, ["wlclient-test"], {}); + await waitFor(out, /KBD_FOCUS app_id=/, 20_000, dump); + + host.spawn(klauncherBytes, ["klauncher"], { + env: [`KLAUNCHER_APPS_DIR=${appsDir}`], + }); + await waitFor(out, /KLAUNCHER_READY n=2/, 20_000, dump); + await waitFor(out, /LAYER ns=launcher /, 20_000, dump); + + // Overlay layer, centred, at the size it asked for. + const layer = out.value.match(/LAYER ns=launcher layer=(\d+) /); + expect(layer, `no launcher LAYER marker.\n${dump()}`).not.toBeNull(); + expect(Number(layer![1]), "launcher should sit on the overlay layer") + .toBe(3); + + // Typing goes to the launcher, not to the window that had focus. + tap(KEY_T); + await waitFor(out, /KLAUNCHER_FILTER q=t n=1/, 10_000, dump); + + // Enter hands the entry's command to the compositor over kwlctl and + // dismisses the launcher. The compositor reports the launch either + // way; this VFS has no /usr/local/bin/wlterm staged, so what is + // asserted is that the dispatch arrived with the right command. + tap(KEY_ENTER); + await waitFor(out, /KLAUNCHER_EXEC cmd=\/usr\/local\/bin\/wlterm/, + 10_000, dump); + await waitFor(out, /KWLCTL_EXEC(_FAILED)? "\/usr\/local\/bin\/wlterm"/, + 10_000, dump); + await waitFor(out, "KLAUNCHER_EXIT", 10_000, dump); + + // The desktop survives the launcher going away: its layer surface is + // torn down while the window below still holds a session, and the + // compositor keeps serving. + host.spawn(kwlctlBytes, ["kwlctl", "clients"], {}); + await waitFor(out, /\[\{"address"/, 10_000, dump); + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); + + it.skipIf(!hasBinaries)( + "a click cannot take the keyboard from an exclusive layer-shell grab", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + const klauncherBytes = loadBytes(klauncherBin!); + + const appsDir = mkdtempSync(join(tmpdir(), "kandelo-apps-")); + mkdirSync(appsDir, { recursive: true }); + writeFileSync(join(appsDir, "terminal.conf"), + "name = Terminal\nexec = /usr/local/bin/wlterm\n"); + writeFileSync(join(appsDir, "clock.conf"), + "name = Clock\nexec = /usr/local/bin/wlclock\n"); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + const tap = (code: number) => { + host.injectInputEvent(0, EV_KEY, code, 1); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, code, 0); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + }; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_LAYOUT=dwindle"], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + // A window holds the keyboard until the launcher takes it away. + host.spawn(clientBytes, ["wlclient-test"], {}); + await waitFor(out, /KBD_FOCUS app_id=/, 20_000, dump); + + host.spawn(klauncherBytes, ["klauncher"], { + env: [`KLAUNCHER_APPS_DIR=${appsDir}`], + }); + await waitFor(out, /KLAUNCHER_READY n=2/, 20_000, dump); + await waitFor(out, /LAYER ns=launcher /, 20_000, dump); + + // Click the window behind the launcher. Click-to-focus would hand the + // keyboard back to it, but the launcher's grab is exclusive, and + // wlr-layer-shell-v1 makes that focus unconditional so a lock screen + // cannot be clicked past. + host.injectInputEvent(1, EV_ABS, ABS_X, WINDOW_POINT_X); + host.injectInputEvent(1, EV_ABS, ABS_Y, WINDOW_POINT_Y); + host.injectInputEvent(1, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(1, EV_KEY, BTN_LEFT, 1); + host.injectInputEvent(1, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(1, EV_KEY, BTN_LEFT, 0); + host.injectInputEvent(1, EV_SYN, SYN_REPORT, 0); + + // The keystroke still filters the launcher's list rather than + // reaching the window under the cursor. + tap(KEY_T); + await waitFor(out, /KLAUNCHER_FILTER q=t n=1/, 10_000, dump); + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); + + it.skipIf(!hasBinaries)( + "clicking the bar leaves it out of the window stack", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + const kbarBytes = loadBytes(kbarBin!); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_LAYOUT=dwindle"], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + host.spawn(kbarBytes, ["kbar"], {}); + await waitFor(out, /LAYER ns=bar /, 20_000, dump); + + host.spawn(clientBytes, ["wlclient-test"], {}); + await waitFor(out, /TILE n=1 i=0 /, 20_000, dump); + + // Click-to-focus raises whatever surface_at() returns, and that + // includes the bar — the path a user takes when they press one of the + // bar's workspace buttons. + host.injectInputEvent(1, EV_ABS, ABS_X, BAR_POINT_X); + host.injectInputEvent(1, EV_ABS, ABS_Y, BAR_POINT_Y); + host.injectInputEvent(1, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(1, EV_KEY, BTN_LEFT, 1); + host.injectInputEvent(1, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(1, EV_KEY, BTN_LEFT, 0); + host.injectInputEvent(1, EV_SYN, SYN_REPORT, 0); + + // The next window re-tiles, which is when a bar that had slipped into + // the window stack would claim a tile of its own. + host.spawn(clientBytes, ["wlclient-test"], {}); + await waitFor(out, /TILE n=2 i=1 /, 20_000, dump); + + expect(out.value, `the bar took a tile.\n${dump()}`) + .not.toMatch(/TILE n=3 /); + + // The two windows still partition the output minus the bar's + // exclusive zone: the bar kept the strip layer_place() gave it. + expect(parseTiles(out.value, 2), `tiling moved.\n${dump()}`) + .toEqual(computeTiling( + { x: 0, y: BAR_H, w: CANVAS_W, h: CANVAS_H - BAR_H }, 2)); + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); +}); diff --git a/host/test/wlcompositor-output-scale-smoke.test.ts b/host/test/wlcompositor-output-scale-smoke.test.ts new file mode 100644 index 0000000000..eec9584965 --- /dev/null +++ b/host/test/wlcompositor-output-scale-smoke.test.ts @@ -0,0 +1,130 @@ +/** + * The wlcompositor's wl_output scale: the mode is device pixels, WLC_SCALE + * says how many of them make a logical pixel, and every protocol object that + * carries a size has to agree on which of the two grids it is in. + * + * wl_output.mode stays device pixels while wl_output.scale, xdg_output's + * logical size and wp_fractional_scale's preference all follow the scale. + * A browser is the only place a real dpr-2 pane exists, so without this gate + * the split is only covered by a Playwright spec. + * + * wl_surface.set_buffer_scale is the client's half of the same contract: a + * client that attaches scale-N pixels gets a window N times smaller than its + * buffer, which is what lets it render sharp instead of being upscaled. + * Skips if the binaries aren't built. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { NodeKernelHost } from "../src/node-kernel-host"; +import { tryResolveBinary } from "../src/binary-resolver"; + +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); +const clientBin = tryResolveBinary("programs/wlclient-test.wasm"); +const hasBinaries = !!compositorBin && !!clientBin; + +const CANVAS_W = 1920; +const CANVAS_H = 1080; + +function loadBytes(path: string): ArrayBuffer { + const buf = readFileSync(path); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +async function waitFor( + ref: { value: string }, + needle: string, + timeoutMs: number, + context: () => string, + count = 1, +): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (ref.value.split(needle).length > count) return; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error( + `Timed out waiting for ${count}x ${JSON.stringify(needle)}.\n${context()}`); +} + +describe("wlcompositor — wl_output scale", () => { + it.skipIf(!hasBinaries)( + "splits the mode into a device grid and a logical grid half its size", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + const compExit = host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_SCALE=2"], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + expect(out.value).toContain("WLC_SCALE 2"); + expect(out.value, `logical grid is not the mode halved.\n${dump()}`) + .toContain(`COMPOSITOR_UP w=${CANVAS_W / 2} h=${CANVAS_H / 2}`); + + host.spawn(clientBytes, ["wlclient-test"], { env: ["WLC_PROTOS=1"] }); + await waitFor(out, "XDG_OUTPUT_NAME virtual-0", 20_000, dump); + + // wl_output.mode is device pixels and does not shrink with the scale; + // a client that ignores wl_output.scale reads exactly what it read + // before the scale existed. + expect(out.value).toContain(`OUTPUT_MODE w=${CANVAS_W} h=${CANVAS_H}`); + expect(out.value).toContain("OUTPUT_SCALE factor=2"); + + // xdg_output and wp_fractional_scale are both logical. The client asks + // for the fractional scale after the xdg_output, so its reply is the + // last of the burst and has to be waited for, not read. + expect(out.value) + .toContain(`XDG_OUTPUT_SIZE w=${CANVAS_W / 2} h=${CANVAS_H / 2}`); + await waitFor(out, "FRACTIONAL_SCALE scale=240", 20_000, dump); + + // A second client declares its 200x150 buffer as scale-2 pixels, so + // its window is 100x75 logical. That window covers 200x150 device + // pixels, which is the buffer 1:1 — the case the whole scale exists + // for. No viewport here: a viewport destination is already logical + // and would override the division under test. + host.spawn(clientBytes, ["wlclient-test"], { env: ["WLC_BUFSCALE=2"] }); + await waitFor(out, + "BUFFER_SCALE app=wlclient-test scale=2 bw=200 bh=150 w=100 h=75", + 20_000, dump); + + // A third client reads its scale from wl_surface.enter instead of + // being told its own, which is what mako does. It draws exactly one + // frame, so the enter has to reach it before that frame — hence the + // compositor sends it when the surface takes a role, not when it + // maps, which is a frame too late. Sent at map, this client reaches + // its first attach with no enter and bails. + host.spawn(clientBytes, ["wlclient-test"], { env: ["WLC_ENTERSCALE=1"] }); + await waitFor(out, "SURFACE_ENTER scale=2", 20_000, dump); + // Its third occurrence: this client committed a frame, which it only + // does with the enter in hand. + await waitFor(out, "CLIENT_READY", 20_000, dump, 3); + expect(err.value, "a client never got the enter before its first frame") + .not.toContain("no wl_surface.enter before the first frame"); + // One output means one enter per surface. The compositor sends it from + // the role and again from the map, so three clients that each got it + // twice would count six. + expect(out.value.split("SURFACE_ENTER").length - 1, + "a surface was entered onto the same output more than once") + .toBe(3); + + void compExit; + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); +}); diff --git a/host/test/wlcompositor-protocols-smoke.test.ts b/host/test/wlcompositor-protocols-smoke.test.ts new file mode 100644 index 0000000000..d63ece7b92 --- /dev/null +++ b/host/test/wlcompositor-protocols-smoke.test.ts @@ -0,0 +1,104 @@ +/** + * PR24 gate: the wlcompositor's zxdg_output_manager_v1 + wp_viewporter + + * wp_fractional_scale_manager_v1 support — the protocol surface GTK3, + * Waybar and mako query beyond the core desktop set. + * + * wlclient-test, run with WLC_PROTOS=1, binds the three globals, reads the + * xdg_output logical geometry burst, receives the scale preference this + * output runs at (120/120ths), and sets a viewport destination at twice its + * buffer size. + * The compositor's VIEWPORT marker reports the applied crop/scale box, and + * the composite sample proves the scaled blit still delivers the client's + * pixels. + * + * The protocols live entirely between client and compositor inside the + * kernel — no host/src change. Skips if the binaries aren't built. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { NodeKernelHost } from "../src/node-kernel-host"; +import { tryResolveBinary } from "../src/binary-resolver"; + +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); +const clientBin = tryResolveBinary("programs/wlclient-test.wasm"); +const hasBinaries = !!compositorBin && !!clientBin; + +const CANVAS_W = 1920; +const CANVAS_H = 1080; + +function loadBytes(path: string): ArrayBuffer { + const buf = readFileSync(path); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +async function waitFor( + ref: { value: string }, + needle: string, + timeoutMs: number, + context: () => string, +): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (ref.value.includes(needle)) return; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error(`Timed out waiting for ${JSON.stringify(needle)}.\n${context()}`); +} + +describe("wlcompositor — xdg-output + viewporter + fractional-scale", () => { + it.skipIf(!hasBinaries)( + "a client reads logical output geometry, scale 120, and maps through a 2x viewport", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + const compExit = host.spawn(compositorBytes, ["wlcompositor"], {}); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + host.spawn(clientBytes, ["wlclient-test"], { env: ["WLC_PROTOS=1"] }); + + // xdg_output: the single virtual output is fullscreen at (0,0) with + // the mode as its logical size; wl_output v4 carries the same name + // (mako binds v4 unconditionally). + await waitFor(out, "XDG_OUTPUT_NAME virtual-0", 20_000, dump); + expect(out.value).toContain("OUTPUT_NAME virtual-0"); + expect(out.value).toContain("XDG_OUTPUT_POS x=0 y=0"); + const size = out.value.match(/XDG_OUTPUT_SIZE w=(\d+) h=(\d+)/); + expect(size, `no logical size.\n${dump()}`).not.toBeNull(); + expect(parseInt(size![1], 10)).toBeGreaterThan(0); + expect(parseInt(size![2], 10)).toBeGreaterThan(0); + + // fractional-scale: this output runs at scale 1 = 120/120ths. + expect(out.value).toContain("FRACTIONAL_SCALE scale=120"); + + // viewporter: the 200x150 buffer maps as a 400x300 window and the + // scaled blit still composites the client's red pixels. + await waitFor(out, "VIEWPORT bw=200 bh=150 w=400 h=300", 20_000, dump); + await waitFor(out, "CLIENT_READY\n", 20_000, dump); + await waitFor(out, "COMPOSITE_SAMPLE", 5_000, dump); + const sample = out.value.match(/COMPOSITE_SAMPLE x=\d+ y=\d+ px=0x([0-9a-f]{8})/); + expect(sample, `no composite sample.\n${dump()}`).not.toBeNull(); + const px = parseInt(sample![1], 16); + expect(px & 0xffffff, `composited pixel not red (0x${px.toString(16)})\n${dump()}`) + .toBe(0xff0000); + + void compExit; + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); +}); diff --git a/host/test/wlcompositor-resize-smoke.test.ts b/host/test/wlcompositor-resize-smoke.test.ts index fea0b7949a..c428917318 100644 --- a/host/test/wlcompositor-resize-smoke.test.ts +++ b/host/test/wlcompositor-resize-smoke.test.ts @@ -21,9 +21,9 @@ import { readFileSync } from "node:fs"; import { NodeKernelHost } from "../src/node-kernel-host"; import { tryResolveBinary } from "../src/binary-resolver"; -const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); -const clockBin = tryResolveBinary("programs/wlclock.wasm"); -const paintBin = tryResolveBinary("programs/wlpaint.wasm"); +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); +const clockBin = tryResolveBinary("programs/wldesktop/wlclock.wasm"); +const paintBin = tryResolveBinary("programs/wldesktop/wlpaint.wasm"); const hasBinaries = !!compositorBin && !!clockBin; const hasPaint = !!compositorBin && !!paintBin; @@ -124,16 +124,13 @@ describe("wlcompositor — libkwl clients resize to the dictated tile", () => { host.spawn(clockBytes, ["wlclock"], {}); const two = computeTiling({ x: 0, y: 0, w: CANVAS_W, h: CANVAS_H }, 2); expect(two[0].w, "expected an x-axis split").toBe(two[1].w); - await waitFor(out, `WLCLOCK_RESIZE w=${two[0].w} h=${two[0].h}`, 20_000, dump); - // Both clients ended up at the two-way tile size — the first shrank - // from the solo tile and the second mapped straight into its half. - const resizes = [...out.value.matchAll(/WLCLOCK_RESIZE w=(\d+) h=(\d+)/g)] - .map((m) => `${m[1]}x${m[2]}`); - const halved = resizes.filter((r) => r === `${two[0].w}x${two[0].h}`); - expect(halved.length, - `expected both windows at the two-way tile.\n${dump()}`) - .toBeGreaterThanOrEqual(2); + // Both clients end up at the two-way tile size — the first shrinks + // from the solo tile and the second maps straight into its half. The + // two markers race, so wait for both. + const halvedClock = `WLCLOCK_RESIZE w=${two[0].w} h=${two[0].h}`; + await waitFor(out, new RegExp(`${halvedClock}[\\s\\S]*${halvedClock}`), + 20_000, dump); void compExit; } finally { @@ -185,14 +182,12 @@ describe("wlcompositor — libkwl clients resize to the dictated tile", () => { host.spawn(paintBytes, ["wlpaint"], {}); const two = computeTiling({ x: 0, y: 0, w: CANVAS_W, h: CANVAS_H }, 2); expect(two[0].w, "expected an x-axis split").toBe(two[1].w); - await waitFor(out, `WLPAINT_RESIZE w=${two[0].w} h=${two[0].h}`, 20_000, dump); - - const halved = [...out.value.matchAll(/WLPAINT_RESIZE w=(\d+) h=(\d+)/g)] - .map((m) => `${m[1]}x${m[2]}`) - .filter((r) => r === `${two[0].w}x${two[0].h}`); - expect(halved.length, - `expected both wlpaint windows at the two-way tile.\n${dump()}`) - .toBeGreaterThanOrEqual(2); + + // Both wlpaint windows reconfigure to the half-width slot; the two + // markers race, so wait for both. + const halvedPaint = `WLPAINT_RESIZE w=${two[0].w} h=${two[0].h}`; + await waitFor(out, new RegExp(`${halvedPaint}[\\s\\S]*${halvedPaint}`), + 20_000, dump); void compExit; } finally { diff --git a/host/test/wlcompositor-smoke.test.ts b/host/test/wlcompositor-smoke.test.ts index 50c196c3ab..33c567446d 100644 --- a/host/test/wlcompositor-smoke.test.ts +++ b/host/test/wlcompositor-smoke.test.ts @@ -28,7 +28,7 @@ import { readFileSync } from "node:fs"; import { NodeKernelHost } from "../src/node-kernel-host"; import { tryResolveBinary } from "../src/binary-resolver"; -const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); const clientBin = tryResolveBinary("programs/wlclient-test.wasm"); const hasBinaries = !!compositorBin && !!clientBin; @@ -115,6 +115,13 @@ describe("wlcompositor — server composites + routes input to a client", () => .toBe(0xff0000); expect(out.value).toMatch(/FLIP fb=\d+ first=1/); + // wp_presentation: the advertised clock is CLOCK_MONOTONIC (1) and + // the feedback resolved as `presented` on the flip that mapped the + // client — never `discarded` for a visible window. + expect(out.value).toContain("PRESENTATION_CLOCK id=1"); + expect(out.value).toMatch(/PRESENTED sec=\d+ nsec=\d+ refresh=\d+ seq=\d+/); + expect(out.value).not.toContain("PRESENTATION_DISCARDED"); + // Inject a keyboard key on event0 (routed to the keyboard-focused // window) and — after moving the cursor over the window — a pointer // button on event1 (routed to the surface under the cursor). @@ -133,7 +140,9 @@ describe("wlcompositor — server composites + routes input to a client", () => ]); expect(clientCode, `client exit.\n${dump()}`).toBe(0); - // The client parsed the compositor's xkb keymap and saw both events. + // The client compiled the compositor's xkb keymap, found the + // full-terminal keys (F1, Delete), and saw both events. + expect(out.value).toContain("KEYMAP_SYMS f1=1 delete=1"); expect(out.value).toMatch(/KEYMAP format=1 size=\d+ ok=1/); expect(out.value).toMatch(new RegExp(`GOT_KEY key=${KEY_A} state=1`)); expect(out.value).toMatch(new RegExp(`GOT_BTN button=${BTN_LEFT} state=1`)); diff --git a/host/test/wlcompositor-theme-smoke.test.ts b/host/test/wlcompositor-theme-smoke.test.ts new file mode 100644 index 0000000000..bb21b526d5 --- /dev/null +++ b/host/test/wlcompositor-theme-smoke.test.ts @@ -0,0 +1,508 @@ +/** + * PR17 gate: the Omarchy-shaped theme system. + * + * A theme is a directory holding one theme.conf that the compositor and every + * shell client read. This asserts the three things that makes it a system + * rather than a colour constant: + * 1. The configured theme is loaded at startup and its gaps really drive the + * layout — the tiling partition is recomputed with the theme's numbers. + * 2. `kwlctl dispatch theme next` cycles the installed themes live, and the + * new gaps re-tile the desktop without a restart. + * 3. The switch is broadcast on the kwlctl event stream, so kbar (and any + * other shell client) reloads its own palette from the same file. + * + * Skips if the binaries aren't built (bare checkout). + */ +import { describe, expect, it } from "vitest"; +import { + appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { NodeKernelHost } from "../src/node-kernel-host"; +import { tryResolveBinary } from "../src/binary-resolver"; + +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); +const clientBin = tryResolveBinary("programs/wlclient-test.wasm"); +const kbarBin = tryResolveBinary("programs/kbar.wasm"); +const kwlctlBin = tryResolveBinary("programs/kwlctl.wasm"); +const hasBinaries = !!compositorBin && !!clientBin && !!kbarBin && !!kwlctlBin; +const wltermBin = tryResolveBinary("programs/wldesktop/wlterm.wasm"); +const dashBin = tryResolveBinary("programs/dash.wasm"); +const hasWlterm = + hasBinaries && !!wltermBin && !!dashBin && existsSync(dashBin!); +const launcherBin = tryResolveBinary("programs/wldesktop/klauncher.wasm"); + +// evdev keycodes (linux/input-event-codes.h). +const EV_KEY = 0x01; +const EV_SYN = 0x00; +const SYN_REPORT = 0x00; +const KEY_ESC = 1; +const KEY_ENTER = 28; +const KEY_DOWN = 108; + +const CANVAS_W = 1920; +const CANVAS_H = 1080; +const BAR_H = 30; // must match BAR_H in kbar.c + +// Two themes with deliberately different gaps, so a switch is observable in +// the geometry and not only in a colour nobody can see from a smoke test. +const THEMES: Record<string, { gapsIn: number; gapsOut: number }> = { + "aaa-wide": { gapsIn: 24, gapsOut: 40 }, + "bbb-tight": { gapsIn: 2, gapsOut: 4 }, +}; + +interface Rect { x: number; y: number; w: number; h: number } + +function parseTiles(text: string, count: number): Rect[] { + const tiles: Rect[] = []; + const re = new RegExp( + `TILE n=${count} i=(\\d+) x=(-?\\d+) y=(-?\\d+) w=(\\d+) h=(\\d+)`, "g"); + for (let m = re.exec(text); m; m = re.exec(text)) { + tiles[Number(m[1])] = { + x: Number(m[2]), y: Number(m[3]), w: Number(m[4]), h: Number(m[5]), + }; + } + return tiles; +} + +function computeTiling(area: Rect, n: number, gapOut: number, gapIn: number): Rect[] { + const out: Rect[] = []; + if (n <= 0) return out; + let region: Rect = { + x: area.x + gapOut, + y: area.y + gapOut, + w: Math.max(1, area.w - 2 * gapOut), + h: Math.max(1, area.h - 2 * gapOut), + }; + for (let i = 0; i < n; i++) { + if (i === n - 1) { out.push(region); break; } + const near: Rect = { ...region }; + const rest: Rect = { ...region }; + if (region.w >= region.h) { + const half = Math.max(1, Math.floor((region.w - gapIn) / 2)); + near.w = half; + rest.x = region.x + half + gapIn; + rest.w = region.w - half - gapIn; + } else { + const half = Math.max(1, Math.floor((region.h - gapIn) / 2)); + near.h = half; + rest.y = region.y + half + gapIn; + rest.h = region.h - half - gapIn; + } + out.push(near); + region = rest; + } + return out; +} + +function loadBytes(path: string): ArrayBuffer { + const buf = readFileSync(path); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +async function waitFor( + ref: { value: string }, + needle: string | RegExp, + timeoutMs: number, + context: () => string, +): Promise<void> { + const deadline = Date.now() + timeoutMs; + const hit = () => + typeof needle === "string" ? ref.value.includes(needle) : needle.test(ref.value); + while (Date.now() < deadline) { + if (hit()) return; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error(`Timed out waiting for ${needle}.\n${context()}`); +} + +// A theme root holding both palettes, plus the compositor config selecting one. +function stageThemes(): { themeDir: string; confPath: string } { + const root = mkdtempSync(join(tmpdir(), "kandelo-themes-")); + for (const [name, t] of Object.entries(THEMES)) { + mkdirSync(join(root, name), { recursive: true }); + writeFileSync(join(root, name, "theme.conf"), + [ + `# ${name}`, + "border_active = 0xff0000", + "wallpaper_top = 0x101010", + "wallpaper_bottom = 0x202020", + "bar = 0x161822", + "foreground = 0xc8cedc", + "accent = 0x7aa2f7", + `gaps_in = ${t.gapsIn}`, + `gaps_out = ${t.gapsOut}`, + "", + ].join("\n")); + } + const confPath = join(root, "wlcompositor.conf"); + writeFileSync(confPath, "theme = aaa-wide\nbind = SUPER, Return, exec, wlterm\n"); + return { themeDir: root, confPath }; +} + +describe("wlcompositor — theme system", () => { + it.skipIf(!hasBinaries)( + "loads the configured theme, cycles to the next one live, and tells clients", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + const kbarBytes = loadBytes(kbarBin!); + const kwlctlBytes = loadBytes(kwlctlBin!); + const { themeDir, confPath } = stageThemes(); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + host.spawn(compositorBytes, ["wlcompositor"], { + env: [ + "WLC_LAYOUT=dwindle", + `WLC_CONFIG=${confPath}`, + `WLC_THEME_DIR=${themeDir}`, + ], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + // 1) The configured theme is live, and its gaps drive the layout. + expect(out.value, `config theme not loaded.\n${dump()}`) + .toMatch(/THEME aaa-wide/); + + host.spawn(clientBytes, ["wlclient-test"], {}); + host.spawn(clientBytes, ["wlclient-test"], {}); + await waitFor(out, /TILE n=2 i=1 /, 20_000, dump); + expect(parseTiles(out.value, 2), `wide-gap tiling mismatch.\n${dump()}`) + .toEqual(computeTiling({ x: 0, y: 0, w: CANVAS_W, h: CANVAS_H }, 2, + THEMES["aaa-wide"].gapsOut, THEMES["aaa-wide"].gapsIn)); + + // A shell client subscribes to the stream before the switch. + host.spawn(kbarBytes, ["kbar"], { env: [`KANDELO_THEME_DIR=${themeDir}`] }); + await waitFor(out, /KBAR_THEME name=aaa-wide/, 20_000, dump); + + // 2) Cycle to the next installed theme without a restart. + out.value = ""; + await host.spawn(kwlctlBytes, ["kwlctl", "dispatch", "theme", "next"], {}); + await waitFor(out, /THEME bbb-tight/, 10_000, dump); + await waitFor(out, /TILE n=2 i=1 /, 10_000, dump); + // The bar is up by now, so the new gaps apply to the work area left + // under its exclusive zone — theme and layer-shell compose. + expect(parseTiles(out.value, 2), `re-tile ignored new gaps.\n${dump()}`) + .toEqual(computeTiling({ x: 0, y: BAR_H, w: CANVAS_W, h: CANVAS_H - BAR_H }, + 2, THEMES["bbb-tight"].gapsOut, THEMES["bbb-tight"].gapsIn)); + + // 3) The bar reloaded its own palette off the broadcast. + await waitFor(out, /KBAR_THEME name=bbb-tight/, 10_000, dump); + + // Cycling wraps back around the installed set, in both directions. + await host.spawn(kwlctlBytes, ["kwlctl", "dispatch", "theme", "next"], {}); + await waitFor(out, /THEME aaa-wide/, 10_000, dump); + out.value = ""; + await host.spawn(kwlctlBytes, ["kwlctl", "dispatch", "theme", "prev"], {}); + await waitFor(out, /THEME bbb-tight/, 10_000, dump); + + // An unknown theme is refused and leaves the live one alone. Anchored: + // kbar's KBAR_THEME marker from the prev-switch may straggle in. + out.value = ""; + await host.spawn(kwlctlBytes, ["kwlctl", "dispatch", "theme", "nope"], {}); + await waitFor(out, "err no such theme", 10_000, dump); + expect(out.value, `a failed switch changed the theme.\n${dump()}`) + .not.toMatch(/^THEME /m); + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); + + it.skipIf(!hasBinaries)( + "reports the live theme and the installed set over kwlctl", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const kwlctlBytes = loadBytes(kwlctlBin!); + const { themeDir, confPath } = stageThemes(); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + host.spawn(compositorBytes, ["wlcompositor"], { + env: [ + "WLC_LAYOUT=dwindle", + `WLC_CONFIG=${confPath}`, + `WLC_THEME_DIR=${themeDir}`, + ], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + out.value = ""; + await host.spawn(kwlctlBytes, ["kwlctl", "theme"], {}); + await waitFor(out, /\{"name":/, 10_000, dump); + const json = out.value.match(/\{"name":[^\n]*\}/); + expect(json, `no theme JSON.\n${dump()}`).not.toBeNull(); + expect(JSON.parse(json![0])).toEqual({ + name: "aaa-wide", + themes: ["aaa-wide", "bbb-tight"], + }); + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); + + it.skipIf(!hasBinaries)( + "renders a theme's image wallpaper and falls back to the gradient", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const kwlctlBytes = loadBytes(kwlctlBin!); + + // Three themes: an image wallpaper, a plain gradient, and a wallpaper + // key pointing at a file that does not exist. + const root = mkdtempSync(join(tmpdir(), "kandelo-wallpaper-")); + const kwlp = Buffer.alloc(12 + 16 * 16 * 4); + kwlp.write("KWLP", 0, "ascii"); + kwlp.writeUInt32LE(16, 4); + kwlp.writeUInt32LE(16, 8); + for (let i = 0; i < 16 * 16; i++) + kwlp.writeUInt32LE(0xff3366cc, 12 + i * 4); + for (const [name, lines] of Object.entries({ + "aaa-image": ["wallpaper = background.kwlp"], + "bbb-gradient": ["wallpaper_top = 0x101010", "wallpaper_bottom = 0x202020"], + "ccc-broken": ["wallpaper = missing.kwlp"], + })) { + mkdirSync(join(root, name), { recursive: true }); + writeFileSync(join(root, name, "theme.conf"), lines.join("\n") + "\n"); + } + writeFileSync(join(root, "aaa-image", "background.kwlp"), kwlp); + const confPath = join(root, "wlcompositor.conf"); + writeFileSync(confPath, "theme = aaa-image\n"); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + host.spawn(compositorBytes, ["wlcompositor"], { + env: [`WLC_CONFIG=${confPath}`, `WLC_THEME_DIR=${root}`], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + // The 16x16 source is square and the output is 16:9, so the crop takes + // the full width and a centred 9-row band — the stager cannot know the + // mode, so the compositor is what makes the image fit it undistorted. + await waitFor(out, "WALLPAPER image w=16 h=16 crop=16x9+0+3", 10_000, dump); + + out.value = ""; + await host.spawn(kwlctlBytes, + ["kwlctl", "dispatch", "theme", "bbb-gradient"], {}); + await waitFor(out, "WALLPAPER gradient", 10_000, dump); + + // A wallpaper file that fails to load degrades to the gradient + // instead of keeping the previous theme's image. + out.value = ""; + await host.spawn(kwlctlBytes, + ["kwlctl", "dispatch", "theme", "ccc-broken"], {}); + await waitFor(out, "WALLPAPER gradient", 10_000, dump); + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); + + it.skipIf(!hasWlterm)( + "a gap change smaller than a terminal cell still recommits the window", + async () => { + // kwl_apply_resize destroys the buffer the compositor holds before the + // client redraws, so a resize the terminal considers a no-op (cols and + // rows unchanged) must still commit — or the window stays invisible + // until the shell prints again. Three 1px gap steps guarantee at least + // one step that keeps the cell grid: each shrinks the tile by 2px, so + // across three steps the width crosses at most one cell boundary and + // the height at most one. + const compositorBytes = loadBytes(compositorBin!); + const wltermBytes = loadBytes(wltermBin!); + const dashBytes = loadBytes(dashBin!); + const kwlctlBytes = loadBytes(kwlctlBin!); + + const gapsOut = [8, 9, 10, 11]; + const root = mkdtempSync(join(tmpdir(), "kandelo-smallgap-")); + for (const out_ of gapsOut) { + mkdirSync(join(root, `g${out_}`), { recursive: true }); + writeFileSync(join(root, `g${out_}`, "theme.conf"), + `gaps_in = 8\ngaps_out = ${out_}\n`); + } + const confPath = join(root, "wlcompositor.conf"); + writeFileSync(confPath, `theme = g${gapsOut[0]}\n`); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + onResolveExec: (path) => + path === "dash" || path.endsWith("/dash") ? dashBytes : null, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + host.spawn(compositorBytes, ["wlcompositor"], { + env: [ + "WLC_LAYOUT=dwindle", + `WLC_CONFIG=${confPath}`, + `WLC_THEME_DIR=${root}`, + ], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + host.spawn( + wltermBytes, + ["wlterm", "dash", "-c", "printf 'READY\\n'; read x"], + { env: ["PATH=/usr/bin:/bin", "HOME=/root", "TERM=vt100"] }, + ); + await waitFor(out, "WLTERM_READY", 20_000, dump); + await waitFor(out, /WLTERM_RESIZE cols=\d+ rows=\d+/, 20_000, dump); + + for (const out_ of gapsOut.slice(1)) { + out.value = ""; + await host.spawn(kwlctlBytes, + ["kwlctl", "dispatch", "theme", `g${out_}`], {}); + await waitFor(out, /WLTERM_RESIZE cols=\d+ rows=\d+/, 10_000, dump); + } + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); + + it.skipIf(!hasBinaries)( + "a theme switch spawns the configured notifier", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const kwlctlBytes = loadBytes(kwlctlBin!); + const { themeDir, confPath } = stageThemes(); + appendFileSync(confPath, "notify = /usr/local/bin/knotify\n"); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + host.spawn(compositorBytes, ["wlcompositor"], { + env: [`WLC_CONFIG=${confPath}`, `WLC_THEME_DIR=${themeDir}`], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + // The switch spawns `<notify> Theme <name>`. This VFS stages no + // knotify binary, so what is asserted is that the hook fired with + // the right program — the launch itself is the browser gate's job. + await host.spawn(kwlctlBytes, + ["kwlctl", "dispatch", "theme", "bbb-tight"], {}); + await waitFor(out, "THEME bbb-tight", 10_000, dump); + await waitFor(out, /KWLCTL_EXEC(_FAILED)? "\/usr\/local\/bin\/knotify"/, + 10_000, dump); + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); + + it.skipIf(!hasBinaries || !launcherBin)( + "the Omarchy menu descends into the theme list and dispatches a switch", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const launcherBytes = loadBytes(launcherBin!); + const { themeDir, confPath } = stageThemes(); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + const tap = (code: number) => { + host.injectInputEvent(0, EV_KEY, code, 1); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, code, 0); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + }; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + host.spawn(compositorBytes, ["wlcompositor"], { + env: [`WLC_CONFIG=${confPath}`, `WLC_THEME_DIR=${themeDir}`], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + // --menu opens at the root (Apps, Theme) with the keyboard held + // exclusively, so the taps below go to the menu, not a window. + host.spawn(launcherBytes, ["klauncher", "--menu"], { + env: [`KANDELO_THEME_DIR=${themeDir}`], + }); + await waitFor(out, "KLAUNCHER_LEVEL root", 20_000, dump); + await waitFor(out, "KLAUNCHER_READY n=2", 20_000, dump); + + // Down + Enter selects "Theme"; the submenu lists the installed set + // read from `kwlctl theme`. + tap(KEY_DOWN); + tap(KEY_ENTER); + await waitFor(out, "KLAUNCHER_LEVEL themes", 10_000, dump); + + // ESC in a submenu goes back to the root, not out of the menu. + out.value = ""; + tap(KEY_ESC); + await waitFor(out, "KLAUNCHER_LEVEL root", 10_000, dump); + tap(KEY_DOWN); + tap(KEY_ENTER); + await waitFor(out, "KLAUNCHER_LEVEL themes", 10_000, dump); + + // Enter on the first entry dispatches the switch and dismisses. + tap(KEY_ENTER); + await waitFor(out, "KLAUNCHER_THEME name=aaa-wide", 10_000, dump); + await waitFor(out, "THEME aaa-wide", 10_000, dump); + await waitFor(out, "KLAUNCHER_EXIT", 10_000, dump); + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); +}); diff --git a/host/test/wlcompositor-tiling-smoke.test.ts b/host/test/wlcompositor-tiling-smoke.test.ts index 1372388516..0a387fba00 100644 --- a/host/test/wlcompositor-tiling-smoke.test.ts +++ b/host/test/wlcompositor-tiling-smoke.test.ts @@ -21,7 +21,7 @@ import { readFileSync } from "node:fs"; import { NodeKernelHost } from "../src/node-kernel-host"; import { tryResolveBinary } from "../src/binary-resolver"; -const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); const clientBin = tryResolveBinary("programs/wlclient-test.wasm"); const hasBinaries = !!compositorBin && !!clientBin; diff --git a/host/test/wldesktop-liveness-smoke.test.ts b/host/test/wldesktop-liveness-smoke.test.ts index 2031b54dfe..219c763e6e 100644 --- a/host/test/wldesktop-liveness-smoke.test.ts +++ b/host/test/wldesktop-liveness-smoke.test.ts @@ -31,9 +31,9 @@ import { readFileSync } from "node:fs"; import { NodeKernelHost } from "../src/node-kernel-host"; import { tryResolveBinary } from "../src/binary-resolver"; -const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); -const clockBin = tryResolveBinary("programs/wlclock.wasm"); -const paintBin = tryResolveBinary("programs/wlpaint.wasm"); +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); +const clockBin = tryResolveBinary("programs/wldesktop/wlclock.wasm"); +const paintBin = tryResolveBinary("programs/wldesktop/wlpaint.wasm"); const hasBinaries = !!compositorBin && !!clockBin && !!paintBin; const CANVAS_W = 1920; diff --git a/host/test/wldesktop-smoke.test.ts b/host/test/wldesktop-smoke.test.ts index b458931a3b..871a2772c6 100644 --- a/host/test/wldesktop-smoke.test.ts +++ b/host/test/wldesktop-smoke.test.ts @@ -45,9 +45,9 @@ import { readFileSync } from "node:fs"; import { NodeKernelHost } from "../src/node-kernel-host"; import { tryResolveBinary } from "../src/binary-resolver"; -const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); -const clockBin = tryResolveBinary("programs/wlclock.wasm"); -const paintBin = tryResolveBinary("programs/wlpaint.wasm"); +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); +const clockBin = tryResolveBinary("programs/wldesktop/wlclock.wasm"); +const paintBin = tryResolveBinary("programs/wldesktop/wlpaint.wasm"); const hasBinaries = !!compositorBin && !!clockBin && !!paintBin; const CANVAS_W = 1920; diff --git a/host/test/wlterm-smoke.test.ts b/host/test/wlterm-smoke.test.ts index c07ed6c56c..3713fb4dc8 100644 --- a/host/test/wlterm-smoke.test.ts +++ b/host/test/wlterm-smoke.test.ts @@ -31,8 +31,8 @@ import { existsSync, readFileSync } from "node:fs"; import { NodeKernelHost } from "../src/node-kernel-host"; import { tryResolveBinary } from "../src/binary-resolver"; -const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); -const wltermBin = tryResolveBinary("programs/wlterm.wasm"); +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); +const wltermBin = tryResolveBinary("programs/wldesktop/wlterm.wasm"); const dashBin = tryResolveBinary("programs/dash.wasm"); const hasBinaries = !!compositorBin && !!wltermBin && !!dashBin && existsSync(dashBin!); diff --git a/host/test/wpkdraw-smoke.test.ts b/host/test/wpkdraw-smoke.test.ts index 2022b2214e..d11202a53a 100644 --- a/host/test/wpkdraw-smoke.test.ts +++ b/host/test/wpkdraw-smoke.test.ts @@ -63,6 +63,26 @@ describe("wpkdraw — CPU raster into a wrapped heap buffer", () => { expect(parseInt(disc![1], 16) >>> 0).toBe(0xff0000ff); expect(Number(disc![2])).toBeGreaterThan(0); + // Scale 2: a logical coordinate lands at twice the device one, so + // the logical rect (10,10)-(30,30) fills device (20,20)-(60,60) + // exactly — lit at its far corner, black one pixel past it. An + // off-by-one in the multiply or the clip shows up here. + const scaled = out.value.match( + /SCALED_RECT in=0x([0-9a-f]{8}) edge=0x([0-9a-f]{8}) out=0x([0-9a-f]{8})/); + expect(scaled, out.value).not.toBeNull(); + expect(parseInt(scaled![1], 16) >>> 0).toBe(0xffff0000); + expect(parseInt(scaled![2], 16) >>> 0).toBe(0xffff0000); + expect(parseInt(scaled![3], 16) >>> 0).toBe(0xff000000); + + // Metrics stay LOGICAL, so an app's layout is unchanged by the + // scale — the glyphs are rasterized bigger, not the boxes. + const scaledWidth = out.value.match(/SCALED_TEXT_WIDTH s=OK w=(\d+)/); + expect(scaledWidth, out.value).not.toBeNull(); + expect(Number(scaledWidth![1])).toBe(Number(width![1])); + const scaledAscent = out.value.match(/SCALED_ASCENT px=(\d+)/); + expect(scaledAscent, out.value).not.toBeNull(); + expect(Number(scaledAscent![1])).toBeGreaterThan(0); + expect(out.value).toContain("WPKDRAW_SMOKE_OK"); } finally { await host.destroy().catch(() => {}); diff --git a/libc/glue/abi_constants.h b/libc/glue/abi_constants.h index dfedb9312e..629305938c 100644 --- a/libc/glue/abi_constants.h +++ b/libc/glue/abi_constants.h @@ -407,6 +407,14 @@ WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0xc01064b3u: return pointer_width == 4u ? 16u : pointer_width == 8u ? 16u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc01064e0u: +return pointer_width == 4u ? 16u : +pointer_width == 8u ? 16u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc01064e1u: +return pointer_width == 4u ? 16u : +pointer_width == 8u ? 16u : WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0xc01464a6u: return pointer_width == 4u ? 20u : diff --git a/packages/registry/spidermonkey/build-spidermonkey.sh b/packages/registry/spidermonkey/build-spidermonkey.sh index 9e1a31c482..cd861d0023 100755 --- a/packages/registry/spidermonkey/build-spidermonkey.sh +++ b/packages/registry/spidermonkey/build-spidermonkey.sh @@ -342,9 +342,30 @@ export AR="${WASM_POSIX_TARGET_AR:-wasm32posix-ar}" export RANLIB="${WASM_POSIX_TARGET_RANLIB:-wasm32posix-ranlib}" export NM="${WASM_POSIX_TARGET_NM:-wasm32posix-nm}" export STRIP="${WASM_POSIX_TARGET_STRIP:-wasm32posix-strip}" +# Rust's std passes -liconv on every darwin host link, and Mozilla's configure +# selects lld as the host linker. The nix apple-sdk carries no libiconv.tbd +# because nixpkgs ships libiconv as its own derivation, so lld resolves -liconv +# only from an explicit -L. Apple's own SDK needs no such flag. +darwin_host_libiconv_ldflag() { + local flag dir + for flag in ${NIX_LDFLAGS:-}; do + case "$flag" in + -L*) dir="${flag#-L}" ;; + *) continue ;; + esac + [ -e "$dir/libiconv.dylib" ] || continue + printf -- '-L%s' "$dir" + return 0 + done +} + if [ "$HOST_OS" = "Darwin" ]; then export HOST_CC="${HOST_CC:-/usr/bin/cc}" export HOST_CXX="${HOST_CXX:-/usr/bin/c++}" + HOST_LIBICONV_LDFLAG="$(darwin_host_libiconv_ldflag)" + if [ -n "$HOST_LIBICONV_LDFLAG" ]; then + export HOST_LDFLAGS="${HOST_LDFLAGS:-} $HOST_LIBICONV_LDFLAG" + fi else export HOST_CC="${HOST_CC:-cc}" export HOST_CXX="${HOST_CXX:-c++}" diff --git a/packages/registry/wldesktop/build-wldesktop.sh b/packages/registry/wldesktop/build-wldesktop.sh new file mode 100755 index 0000000000..5f4036deab --- /dev/null +++ b/packages/registry/wldesktop/build-wldesktop.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$HERE/../../.." && pwd)" +# shellcheck source=/dev/null +source "$REPO_ROOT/scripts/package-build-roots.sh" +kandelo_package_prepare_build_roots "$HERE" wasm32 +kandelo_package_select_source_root "$REPO_ROOT" +SOURCE_ROOT="$KANDELO_PACKAGE_SOURCE_ROOT" +WORK_DIR="$KANDELO_PACKAGE_WORK_DIR" + +SOURCES=( + programs/wlcompositor/wlcompositor.c + programs/wlterm/wlterm.c + programs/wlterm/vt100.c + programs/wlclock.c + programs/wlpaint.c + programs/klauncher.c + programs/notify-send.c + examples/libs/libkwl/src/kwl.c + examples/libs/libkwl/include/kwl.h + examples/libs/wpkdraw/src/wpkdraw.c + examples/libs/wpkdraw/src/wpkfont.c +) +for f in "${SOURCES[@]}"; do + if [ ! -f "$SOURCE_ROOT/$f" ] || [ -L "$SOURCE_ROOT/$f" ]; then + echo "ERROR: wldesktop source must be a regular file: $SOURCE_ROOT/$f" >&2 + exit 1 + fi +done + +# A resolver/Formula caller owns the declared work and output roots. Keep the +# reviewed checkout read-only and suppress the developer-only local mirror. +if [ -n "${WASM_POSIX_DEP_WORK_DIR:-}" ] && [ -n "${WASM_POSIX_DEP_OUT_DIR:-}" ]; then + export WASM_POSIX_INSTALL_LOCAL_MIRROR=0 + export WASM_POSIX_INSTALL_FORK_INSTRUMENTATION=auto +fi + +LIBWAYLAND_PREFIX="${WASM_POSIX_DEP_LIBWAYLAND_DIR:?WASM_POSIX_DEP_LIBWAYLAND_DIR not set (must be invoked via cargo xtask build-deps resolve wldesktop)}" +LIBXKBCOMMON_PREFIX="${WASM_POSIX_DEP_LIBXKBCOMMON_DIR:?WASM_POSIX_DEP_LIBXKBCOMMON_DIR not set}" +LIBINPUT_PREFIX="${WASM_POSIX_DEP_LIBINPUT_DIR:?WASM_POSIX_DEP_LIBINPUT_DIR not set}" +LIBEVDEV_PREFIX="${WASM_POSIX_DEP_LIBEVDEV_DIR:?WASM_POSIX_DEP_LIBEVDEV_DIR not set}" +LIBUDEV_PREFIX="${WASM_POSIX_DEP_LIBUDEV_DIR:?WASM_POSIX_DEP_LIBUDEV_DIR not set}" +MTDEV_PREFIX="${WASM_POSIX_DEP_MTDEV_DIR:?WASM_POSIX_DEP_MTDEV_DIR not set}" +LIBFFI_PREFIX="${WASM_POSIX_DEP_LIBFFI_DIR:?WASM_POSIX_DEP_LIBFFI_DIR not set}" +GLIB_PREFIX="${WASM_POSIX_DEP_GLIB_DIR:?WASM_POSIX_DEP_GLIB_DIR not set}" +PCRE2_PREFIX="${WASM_POSIX_DEP_PCRE2_DIR:?WASM_POSIX_DEP_PCRE2_DIR not set}" +ZLIB_PREFIX="${WASM_POSIX_DEP_ZLIB_DIR:?WASM_POSIX_DEP_ZLIB_DIR not set}" +PROTOCOLS_XML="${WASM_POSIX_DEP_WAYLAND_PROTOCOLS_DIR:?WASM_POSIX_DEP_WAYLAND_PROTOCOLS_DIR not set}/xml" + +source "$REPO_ROOT/sdk/activate.sh" +export WASM_POSIX_SYSROOT="$REPO_ROOT/sysroot" + +if [ ! -f "$WASM_POSIX_SYSROOT/lib/libdrm.a" ] || + [ ! -f "$WASM_POSIX_SYSROOT/lib/libgbm.a" ] || + [ ! -f "$WASM_POSIX_SYSROOT/lib/libEGL.a" ] || + [ ! -f "$WASM_POSIX_SYSROOT/lib/libGLESv2.a" ]; then + echo "ERROR: DRI/EGL/GLES sysroot libraries are missing." >&2 + echo "Run: scripts/dev-shell.sh bash scripts/build-musl.sh" >&2 + exit 1 +fi + +for tool in wasm32posix-cc wayland-scanner python3; do + command -v "$tool" >/dev/null || { + echo "ERROR: $tool not found — run through scripts/dev-shell.sh" >&2 + exit 1 + } +done + +# The generated header basenames are what the sources #include, so the stem +# is fixed by the consumer, not by the XML file name. +PROTOCOLS=( + "xdg-shell:xdg-shell" + "linux-dmabuf-v1:linux-dmabuf-v1" + "xdg-decoration-v1:xdg-decoration-unstable-v1" + "wlr-layer-shell-v1:wlr-layer-shell-unstable-v1" + "presentation-time:presentation-time" + "xdg-output-v1:xdg-output-unstable-v1" + "viewporter:viewporter" + "fractional-scale-v1:fractional-scale-v1" +) + +GEN="$WORK_DIR/gen" +mkdir -p "$GEN" +echo "==> Generating the Wayland protocol glue..." +for entry in "${PROTOCOLS[@]}"; do + stem="${entry%%:*}" + xml="$PROTOCOLS_XML/${entry#*:}.xml" + wayland-scanner private-code "$xml" "$GEN/$stem-protocol.c" + wayland-scanner server-header "$xml" "$GEN/$stem-server-protocol.h" + wayland-scanner client-header "$xml" "$GEN/$stem-client-protocol.h" +done + +# libwpkdraw and libkwl are in-tree source with no upstream tarball and no +# published binary, so the resolver never sees them. Their archives build +# into the work dir rather than through examples/libs/*/build.sh, which +# writes into the checkout this build must leave read-only. +WPKDRAW_DIR="$SOURCE_ROOT/examples/libs/wpkdraw" +LIBKWL_DIR="$SOURCE_ROOT/examples/libs/libkwl" +PREFIX="$WORK_DIR/prefix" +mkdir -p "$PREFIX/lib" "$PREFIX/include/wpkdraw" + +echo "==> Generating wpk_font_ttf.h..." +python3 - "$WPKDRAW_DIR/third_party/Inconsolata-Regular.ttf" \ + "$WORK_DIR/wpk_font_ttf.h" <<'PY' +import sys, pathlib +src = pathlib.Path(sys.argv[1]).read_bytes() +lines = [",".join(f"0x{b:02x}" for b in src[i:i+16]) for i in range(0, len(src), 16)] +pathlib.Path(sys.argv[2]).write_text( + "/* Auto-generated from Inconsolata-Regular.ttf — see NOTICE.md. */\n" + "#pragma once\n" + "static const unsigned char wpk_font_ttf[] = {\n" + + ",\n".join(lines) + "\n};\n" +) +PY + +ARCHIVE_CFLAGS=( + --target=wasm32-unknown-unknown + --sysroot="$WASM_POSIX_SYSROOT" + -nostdlib + -O2 + -matomics -mbulk-memory + -fno-trapping-math +) + +cat > "$WORK_DIR/wpk_stb_impl.c" <<'EOF' +#define STB_TRUETYPE_IMPLEMENTATION +#include "stb_truetype.h" +EOF + +echo "==> Building libwpkdraw (CPU rasterizer)..." +for tu in "$WORK_DIR/wpk_stb_impl.c" "$WPKDRAW_DIR/src/wpkdraw.c" "$WPKDRAW_DIR/src/wpkfont.c"; do + wasm32posix-cc "${ARCHIVE_CFLAGS[@]}" \ + -I"$WORK_DIR" \ + -I"$WPKDRAW_DIR/include" \ + -I"$WPKDRAW_DIR/third_party" \ + -c "$tu" -o "$WORK_DIR/$(basename "${tu%.c}").o" +done +wasm32posix-ar rcs "$PREFIX/lib/libwpkdraw.a" \ + "$WORK_DIR/wpkdraw.o" "$WORK_DIR/wpkfont.o" "$WORK_DIR/wpk_stb_impl.o" +cp "$WPKDRAW_DIR/include/wpkdraw/"*.h "$PREFIX/include/wpkdraw/" + +echo "==> Building libkwl (Wayland toolkit)..." +wasm32posix-cc "${ARCHIVE_CFLAGS[@]}" \ + -I"$LIBKWL_DIR/include" \ + -I"$PREFIX/include" \ + -I"$LIBWAYLAND_PREFIX/include" \ + -I"$LIBXKBCOMMON_PREFIX/include" \ + -I"$GEN" \ + -c "$LIBKWL_DIR/src/kwl.c" -o "$WORK_DIR/kwl.o" +wasm32posix-ar rcs "$PREFIX/lib/libkwl.a" "$WORK_DIR/kwl.o" +cp "$LIBKWL_DIR/include/kwl.h" "$PREFIX/include/kwl.h" + +DRI_CFLAGS="$(wasm32posix-pkg-config --cflags gbm libdrm egl glesv2)" +DRI_LIBS="$(wasm32posix-pkg-config --libs gbm libdrm egl glesv2)" + +# Link order across every binary: dependents before dependencies, libffi +# last so wl_closure_invoke's ffi_call resolves. +KWL_PROTOCOL_SOURCES=( + "$GEN/xdg-shell-protocol.c" + "$GEN/xdg-decoration-v1-protocol.c" + "$GEN/wlr-layer-shell-v1-protocol.c" +) +KWL_LIBS=( + "$PREFIX/lib/libkwl.a" + "$PREFIX/lib/libwpkdraw.a" + "$LIBWAYLAND_PREFIX/lib/libwayland-client.a" + "$LIBXKBCOMMON_PREFIX/lib/libxkbcommon.a" + "$LIBFFI_PREFIX/lib/libffi.a" +) + +echo "==> Building wlcompositor (Wayland server)..." +wasm32posix-cc \ + -std=c11 -O2 -Wall -Wextra -Wno-unused-parameter -D_DEFAULT_SOURCE \ + -I"$GEN" \ + -I"$LIBWAYLAND_PREFIX/include" \ + -I"$LIBXKBCOMMON_PREFIX/include" \ + -I"$LIBINPUT_PREFIX/include" \ + -I"$LIBUDEV_PREFIX/include" \ + -I"$PREFIX/include" \ + $DRI_CFLAGS \ + "$SOURCE_ROOT/programs/wlcompositor/wlcompositor.c" \ + "$GEN/xdg-shell-protocol.c" \ + "$GEN/linux-dmabuf-v1-protocol.c" \ + "$GEN/xdg-decoration-v1-protocol.c" \ + "$GEN/wlr-layer-shell-v1-protocol.c" \ + "$GEN/presentation-time-protocol.c" \ + "$GEN/xdg-output-v1-protocol.c" \ + "$GEN/viewporter-protocol.c" \ + "$GEN/fractional-scale-v1-protocol.c" \ + "$LIBWAYLAND_PREFIX/lib/libwayland-server.a" \ + "$PREFIX/lib/libwpkdraw.a" \ + "$LIBXKBCOMMON_PREFIX/lib/libxkbcommon.a" \ + "$LIBINPUT_PREFIX/lib/libinput.a" \ + "$LIBEVDEV_PREFIX/lib/libevdev.a" \ + "$LIBUDEV_PREFIX/lib/libudev.a" \ + "$MTDEV_PREFIX/lib/libmtdev.a" \ + "$LIBFFI_PREFIX/lib/libffi.a" \ + $DRI_LIBS \ + -lm \ + -o "$WORK_DIR/wlcompositor.wasm" + +echo "==> Building wlterm (libkwl terminal + VT100 + forkpty)..." +wasm32posix-cc \ + -std=c11 -O2 -Wall -Wextra -Wno-unused-parameter -D_DEFAULT_SOURCE \ + -I"$GEN" -I"$PREFIX/include" \ + -I"$LIBWAYLAND_PREFIX/include" \ + -I"$LIBXKBCOMMON_PREFIX/include" \ + $DRI_CFLAGS \ + "$SOURCE_ROOT/programs/wlterm/wlterm.c" \ + "$SOURCE_ROOT/programs/wlterm/vt100.c" \ + "${KWL_PROTOCOL_SOURCES[@]}" \ + "${KWL_LIBS[@]}" \ + $DRI_LIBS \ + -lm \ + -o "$WORK_DIR/wlterm.wasm" + +for app in wlclock wlpaint klauncher; do + echo "==> Building $app (libkwl client)..." + wasm32posix-cc \ + -std=c11 -O2 -Wall -Wextra -Wno-unused-parameter -D_DEFAULT_SOURCE \ + -I"$GEN" -I"$PREFIX/include" \ + -I"$LIBWAYLAND_PREFIX/include" \ + -I"$LIBXKBCOMMON_PREFIX/include" \ + $DRI_CFLAGS \ + "$SOURCE_ROOT/programs/$app.c" \ + "${KWL_PROTOCOL_SOURCES[@]}" \ + "${KWL_LIBS[@]}" \ + $DRI_LIBS \ + -lm \ + -o "$WORK_DIR/$app.wasm" +done + +echo "==> Building notify-send (org.freedesktop.Notifications client)..." +wasm32posix-cc \ + -std=c11 -O2 -Wall -Wextra -Wno-unused-parameter -D_DEFAULT_SOURCE \ + -I"$GLIB_PREFIX/include/glib-2.0" \ + "$SOURCE_ROOT/programs/notify-send.c" \ + "$GLIB_PREFIX/lib/libgio-2.0.a" \ + "$GLIB_PREFIX/lib/libgobject-2.0.a" \ + "$GLIB_PREFIX/lib/libgmodule-2.0.a" \ + "$GLIB_PREFIX/lib/libglib-2.0.a" \ + "$PCRE2_PREFIX/lib/libpcre2-8.a" \ + "$LIBFFI_PREFIX/lib/libffi.a" \ + "$ZLIB_PREFIX/lib/libz.a" \ + -lm \ + -o "$WORK_DIR/notify-send.wasm" + +cd "$REPO_ROOT" +source "$REPO_ROOT/scripts/install-local-binary.sh" +for out in wlcompositor wlterm wlclock wlpaint klauncher notify-send; do + install_local_binary wldesktop "$WORK_DIR/$out.wasm" "$out.wasm" +done diff --git a/packages/registry/wldesktop/build.toml b/packages/registry/wldesktop/build.toml new file mode 100644 index 0000000000..e1618529d9 --- /dev/null +++ b/packages/registry/wldesktop/build.toml @@ -0,0 +1,32 @@ +script_path = "packages/registry/wldesktop/build-wldesktop.sh" +inputs = [ + "packages/registry/wldesktop/build-wldesktop.sh", + "programs/wlcompositor/wlcompositor.c", + "programs/wlterm/wlterm.c", + "programs/wlterm/vt100.c", + "programs/wlterm/vt100.h", + "programs/wlclock.c", + "programs/wlpaint.c", + "programs/klauncher.c", + "programs/notify-send.c", + "examples/libs/libkwl/src/kwl.c", + "examples/libs/libkwl/include/kwl.h", + "examples/libs/wpkdraw/src/wpkdraw.c", + "examples/libs/wpkdraw/src/wpkfont.c", + "examples/libs/wpkdraw/include/wpkdraw/wpkdraw.h", + "examples/libs/wpkdraw/include/wpkdraw/wpkfont.h", + "examples/libs/wpkdraw/third_party/stb_truetype.h", + "examples/libs/wpkdraw/third_party/Inconsolata-Regular.ttf", + "libc/glue/libgbm_stub.c", + "libc/glue/libegl_stub.c", + "libc/glue/libglesv2_stub.c", + "libc/glue/gl_abi.h", + "libc/musl-overlay/include/linux/input-event-codes.h", + "scripts/build-musl.sh", + "scripts/build-dri-stubs.sh", + "scripts/build-gles-stubs.sh", + "scripts/package-build-roots.sh", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "" +revision = 1 diff --git a/packages/registry/wldesktop/package.toml b/packages/registry/wldesktop/package.toml new file mode 100644 index 0000000000..f7b2d15a33 --- /dev/null +++ b/packages/registry/wldesktop/package.toml @@ -0,0 +1,76 @@ +kind = "program" +# The Kandelo Wayland desktop: the compositor plus the clients the wayland, +# hyprland and omarchy demos run. The browser app imports every one of them +# through `@binaries`, so they need a registry owner like every other product +# artifact — see scripts/browser-binary-package-roots.mjs. The sources stay in +# programs/ so scripts/build-programs.sh keeps building the local test +# fixtures, the same split sdl2-demo and modeset use. +name = "wldesktop" +version = "0.1.0" +kernel_abi = 44 +depends_on = [ + "libwayland@1.24.0", + "libxkbcommon@1.7.0", + "libinput@1.25.0", + "libevdev@1.13.3", + "libudev@0.1.0", + "mtdev@0.1.0", + "libffi@0.1.0", + "wayland-protocols@1.45", + "glib@2.84.4", + "pcre2@10.44", + "zlib@1.3.1", +] +arches = ["wasm32"] + +[source] +url = "https://github.com/Automattic/kandelo" +sha256 = "0000000000000000000000000000000000000000000000000000000000000000" +provider = "repository" + +[license] +spdx = "GPL-2.0-or-later" +url = "https://github.com/Automattic/kandelo/blob/main/COPYING" + +[build] +script_path = "packages/registry/wldesktop/build-wldesktop.sh" + +# Generates the server and client glue for the eight protocol XMLs the +# compositor and libkwl compile against. +[[host_tools]] +name = "wayland-scanner" +version_constraint = ">=1.20" +probe = { args = ["--version"], version_regex = "wayland-scanner (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "provided by flake.nix (pkgs.wayland-scanner); enter scripts/dev-shell.sh", linux = "provided by flake.nix (pkgs.wayland-scanner); or apt install wayland-utils" } + +# scripts/build-programs.sh regenerates the same TTF→C byte-array header +# this build generates into its work dir. +[[host_tools]] +name = "python3" +version_constraint = ">=3.8" +probe = { args = ["--version"], version_regex = "Python (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[outputs]] +name = "wlcompositor" +wasm = "wlcompositor.wasm" + +[[outputs]] +name = "wlterm" +wasm = "wlterm.wasm" + +[[outputs]] +name = "wlclock" +wasm = "wlclock.wasm" + +[[outputs]] +name = "wlpaint" +wasm = "wlpaint.wasm" + +[[outputs]] +name = "klauncher" +wasm = "klauncher.wasm" + +[[outputs]] +name = "notify-send" +wasm = "notify-send.wasm" diff --git a/programs/kbar.c b/programs/kbar.c new file mode 100644 index 0000000000..ac71cb86b0 --- /dev/null +++ b/programs/kbar.c @@ -0,0 +1,326 @@ +/* + * kbar — the desktop status bar, Omarchy's Waybar slot. + * + * A libkwl layer-shell client anchored across the top of the output with an + * exclusive zone, so the compositor shrinks the tiling area under it and no + * window is ever covered. It renders three Waybar-shaped modules: + * + * left workspace pills 1..N, the occupied ones filled, the active one + * in the theme's accent + * centre the focused window's app id + * right the kernel's monotonic uptime and a HH:MM:SS clock (the kernel + * exposes no /proc/meminfo, so there is no memory module — real + * stats or none) + * + * State comes from the compositor's kwlctl socket — the same feed Waybar's + * hyprland modules consume from hyprctl: one `workspaces` / `activewindow` / + * `theme` query at startup, then the `--listen` event stream (`workspace>>N`, + * `activewindow>>class,title`, `theme>>name`). The bar polls that socket + * alongside the Wayland fd, so it repaints on an event immediately and + * otherwise once a second for the clock. + * + * Markers on stdout for the smoke gates: + * KBAR_READY w=.. h=.. — layer surface mapped + first frame committed + * KBAR_WORKSPACE n=.. — active workspace changed + * KBAR_FOCUS app=.. — focused window changed + * KBAR_THEME name=.. — palette reloaded + * KBAR_EXIT — clean shutdown + */ +#include <poll.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/socket.h> +#include <sys/un.h> +#include <time.h> +#include <unistd.h> + +#include <kwl.h> +#include <wpkdraw/wpkdraw.h> +#include <wpkdraw/wpkfont.h> + +#define KWLCTL_SOCKET_PATH "/tmp/kwlctl-0" +/* Theme root; KANDELO_THEME_DIR overrides it for tests. */ +#define THEME_DIR "/usr/share/kandelo/themes" +#define BAR_H 30 +#define N_WORKSPACES 9 +#define PILL_W 26 +#define PILL_GAP 4 +#define FONT_PX 14 + +/* The palette, read from the live theme's theme.conf. The defaults match the + * compositor's unthemed look, so kbar renders sanely with no theme installed. */ +static struct { + wpk_color bar, foreground, muted, accent, occupied; +} palette = { + .bar = WPK_RGB(0x16, 0x18, 0x22), + .foreground = WPK_RGB(0xc8, 0xce, 0xdc), + .muted = WPK_RGB(0x6a, 0x72, 0x8a), + .accent = WPK_RGB(0x4f, 0x8f, 0xdf), + .occupied = WPK_RGB(0x2a, 0x31, 0x44), +}; + +/* What the bar draws: which workspaces exist, which is active, what has focus. */ +static struct { + int active_ws; + int occupied[N_WORKSPACES + 1]; + char focus[64]; +} st = { .active_ws = 1 }; + +static int parse_color(const char *s, wpk_color fallback) { + if (*s == '#') s++; + char *end = NULL; + unsigned long v = strtoul(s, &end, 16); + return end == s ? (int)fallback : (int)(0xff000000u | (v & 0xffffffu)); +} + +/* Load the named theme's client-side colors. Unknown keys (the compositor's + * border/gap entries) are skipped — one file serves both sides. */ +static void theme_load(const char *name) { + const char *root = getenv("KANDELO_THEME_DIR"); + char path[256]; + snprintf(path, sizeof(path), "%s/%s/theme.conf", root ? root : THEME_DIR, + name); + FILE *f = fopen(path, "r"); + if (!f) return; + char line[256]; + while (fgets(line, sizeof(line), f)) { + char *eq = strchr(line, '='); + if (!eq || line[0] == '#') continue; + *eq = '\0'; + char *key = line, *val = eq + 1; + while (*key == ' ' || *key == '\t') key++; + char *ke = key + strlen(key); + while (ke > key && (ke[-1] == ' ' || ke[-1] == '\t')) *--ke = '\0'; + while (*val == ' ' || *val == '\t') val++; + char *ve = val + strlen(val); + while (ve > val && (ve[-1] == '\n' || ve[-1] == '\r' || ve[-1] == ' ')) + *--ve = '\0'; + if (!strcmp(key, "bar")) palette.bar = parse_color(val, palette.bar); + else if (!strcmp(key, "foreground")) + palette.foreground = parse_color(val, palette.foreground); + else if (!strcmp(key, "muted")) + palette.muted = parse_color(val, palette.muted); + else if (!strcmp(key, "accent")) + palette.accent = parse_color(val, palette.accent); + else if (!strcmp(key, "occupied")) + palette.occupied = parse_color(val, palette.occupied); + } + fclose(f); + printf("KBAR_THEME name=%s\n", name); + fflush(stdout); +} + +/* ---- kwlctl feed -------------------------------------------------------- */ + +static int kwlctl_connect(void) { + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return -1; + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, KWLCTL_SOCKET_PATH, sizeof(addr.sun_path) - 1); + /* The compositor may still be binding its control socket. */ + for (int i = 0; i < 200; i++) { + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) return fd; + usleep(10000); + } + close(fd); + return -1; +} + +/* One request/reply round trip on a throwaway connection (the --listen socket + * stays open for events, so queries cannot share it). */ +static int kwlctl_query(const char *cmd, char *out, size_t cap) { + int fd = kwlctl_connect(); + if (fd < 0) return -1; + char line[64]; + int n = snprintf(line, sizeof(line), "%s\n", cmd); + if (write(fd, line, (size_t)n) != n) { close(fd); return -1; } + size_t got = 0; + ssize_t r; + while (got + 1 < cap && (r = read(fd, out + got, cap - got - 1)) > 0) + got += (size_t)r; + out[got] = '\0'; + close(fd); + return (int)got; +} + +/* Pull the current state so a bar started after the desktop still shows it. */ +static void sync_state(void) { + char buf[4096]; + if (kwlctl_query("theme", buf, sizeof(buf)) > 0) { + char *p = strstr(buf, "\"name\":\""); + if (p) { + char name[32]; + snprintf(name, sizeof(name), "%s", p + 8); + char *q = strchr(name, '"'); + if (q) { *q = '\0'; theme_load(name); } + } + } + if (kwlctl_query("workspaces", buf, sizeof(buf)) > 0) { + /* [{"id":1,"windows":2,"active":true}, …] — walk the ids in order. */ + for (char *p = strstr(buf, "\"id\":"); p; p = strstr(p, "\"id\":")) { + int id = atoi(p + 5); + char *w = strstr(p, "\"windows\":"); + char *a = strstr(p, "\"active\":"); + if (id >= 1 && id <= N_WORKSPACES) { + if (w) st.occupied[id] = atoi(w + 10) > 0; + if (a && !strncmp(a + 9, "true", 4)) st.active_ws = id; + } + p += 5; + } + } + if (kwlctl_query("activewindow", buf, sizeof(buf)) > 0) { + char *p = strstr(buf, "\"class\":\""); + if (p) { + snprintf(st.focus, sizeof(st.focus), "%s", p + 9); + char *q = strchr(st.focus, '"'); + if (q) *q = '\0'; + } + } +} + +/* Apply one `event>>data` line from the compositor's socket2-shaped stream. + * Returns 1 when the bar must repaint. */ +static int apply_event(char *line) { + char *sep = strstr(line, ">>"); + if (!sep) return 0; + *sep = '\0'; + char *data = sep + 2; + if (!strcmp(line, "workspace")) { + st.active_ws = atoi(data); + st.occupied[st.active_ws] = 1; + printf("KBAR_WORKSPACE n=%d\n", st.active_ws); + fflush(stdout); + /* Window counts changed with it; re-read rather than track them. */ + sync_state(); + return 1; + } + if (!strcmp(line, "activewindow")) { + char *comma = strchr(data, ','); + if (comma) *comma = '\0'; + snprintf(st.focus, sizeof(st.focus), "%s", data); + if (st.focus[0]) st.occupied[st.active_ws] = 1; + printf("KBAR_FOCUS app=%s\n", st.focus); + fflush(stdout); + return 1; + } + if (!strcmp(line, "theme")) { + theme_load(data); + return 1; + } + return 0; +} + +/* ---- rendering ---------------------------------------------------------- */ + +static void render(struct wpk_surface *s, struct wpk_font *font) { + wpk_clear(s, palette.bar); + + int x = 8; + for (int ws = 1; ws <= N_WORKSPACES; ws++) { + int active = ws == st.active_ws; + if (!active && !st.occupied[ws]) continue; + wpk_rect(s, x, 5, PILL_W, s->h - 10, + active ? palette.accent : palette.occupied); + char label[4]; + snprintf(label, sizeof(label), "%d", ws); + int tw = wpk_text_width(font, label); + wpk_text(s, font, x + (PILL_W - tw) / 2, + (s->h + wpk_font_ascent_px(font)) / 2 - 1, label, + active ? palette.bar : palette.foreground); + x += PILL_W + PILL_GAP; + } + + int baseline = (s->h + wpk_font_ascent_px(font)) / 2 - 1; + if (st.focus[0]) { + int tw = wpk_text_width(font, st.focus); + wpk_text(s, font, (s->w - tw) / 2, baseline, st.focus, + palette.foreground); + } + + time_t now = time(NULL); + struct tm tm; + localtime_r(&now, &tm); + char clock[16]; + snprintf(clock, sizeof(clock), "%02d:%02d:%02d", tm.tm_hour, tm.tm_min, + tm.tm_sec); + int cw = wpk_text_width(font, clock); + wpk_text(s, font, s->w - cw - 12, baseline, clock, palette.foreground); + + struct timespec up; + clock_gettime(CLOCK_MONOTONIC, &up); + char uptime[24]; + snprintf(uptime, sizeof(uptime), "up %ld:%02ld", + (long)(up.tv_sec / 3600), (long)(up.tv_sec / 60 % 60)); + int uw = wpk_text_width(font, uptime); + wpk_text(s, font, s->w - cw - uw - 32, baseline, uptime, palette.muted); +} + +int main(void) { + struct kwl_layer_opts opts = { + .layer = KWL_LAYER_TOP, + .anchor = KWL_ANCHOR_TOP | KWL_ANCHOR_LEFT | KWL_ANCHOR_RIGHT, + .w = 0, /* stretch across the output */ + .h = BAR_H, + .exclusive_zone = BAR_H, + }; + struct kwl_window *win = kwl_layer_create("bar", &opts); + if (!win) { fprintf(stderr, "kbar: no layer shell\n"); return 1; } + + struct wpk_font *font = wpk_font_load_default(FONT_PX); + if (!font) { fprintf(stderr, "kbar: no font\n"); return 1; } + + sync_state(); + struct wpk_surface *surf = kwl_window_surface(win); + render(surf, font); + kwl_window_commit(win); + printf("KBAR_READY w=%d h=%d\n", surf->w, surf->h); + fflush(stdout); + + int ctl = kwlctl_connect(); + if (ctl >= 0 && write(ctl, "--listen\n", 9) != 9) { + close(ctl); + ctl = -1; + } + + int running = 1; + while (running) { + /* Wake on a compositor event or once a second for the clock. */ + struct pollfd pfd[2] = { + { .fd = kwl_display_fd(win), .events = POLLIN }, + { .fd = ctl, .events = POLLIN }, + }; + poll(pfd, ctl >= 0 ? 2 : 1, 1000); + + int dirty = 1; /* the clock alone is reason enough to repaint */ + struct kwl_event ev; + while (kwl_dispatch(win, &ev, 0)) { + if (ev.type == KWL_CLOSE) running = 0; + if (ev.type == KWL_RESIZE) dirty = 1; + } + if (ctl >= 0 && (pfd[1].revents & (POLLIN | POLLHUP))) { + char buf[1024]; + ssize_t r = read(ctl, buf, sizeof(buf) - 1); + if (r <= 0) { close(ctl); ctl = -1; running = 0; } + else { + buf[r] = '\0'; + for (char *line = strtok(buf, "\n"); line; + line = strtok(NULL, "\n")) + dirty |= apply_event(line); + } + } + if (dirty && running) { + render(kwl_window_surface(win), font); + kwl_window_commit(win); + } + } + + printf("KBAR_EXIT\n"); + fflush(stdout); + if (ctl >= 0) close(ctl); + wpk_font_destroy(font); + kwl_window_destroy(win); + return 0; +} diff --git a/programs/klauncher.c b/programs/klauncher.c new file mode 100644 index 0000000000..27a148820e --- /dev/null +++ b/programs/klauncher.c @@ -0,0 +1,455 @@ +/* + * klauncher — the application launcher, Omarchy's Walker slot. + * + * A libkwl layer-shell client on the overlay layer with exclusive keyboard + * interactivity: while it is up it owns the keyboard, so every keystroke is a + * query character rather than input for the window underneath. Type to filter, + * Up/Down to move, Enter to launch, ESC to dismiss. Launching goes back + * through the compositor's kwlctl socket (`dispatch exec …`) — the launcher + * never forks, so it needs no fork instrumentation. + * + * Entries come from the .conf files under /usr/share/kandelo/apps, one per app: + * + * name = Terminal + * exec = /usr/local/bin/wlterm + * + * That directory is the desktop-entry registry: dropping a file in adds an + * app, which is how a package installs itself into the launcher. + * + * `klauncher --menu` opens the Omarchy menu instead: a root level (Apps, + * Theme) that descends into the app list or the installed-theme list (read + * from `kwlctl theme`, switched with `dispatch theme`). ESC in a submenu goes + * back to the root; ESC at the root dismisses. + * + * Markers on stdout for the smoke gates: + * KLAUNCHER_READY n=.. — mapped, with n entries loaded + * KLAUNCHER_LEVEL <l> — menu level entered (root|apps|themes) + * KLAUNCHER_FILTER q=.. n=.. — query changed, n entries match + * KLAUNCHER_EXEC cmd=.. — dispatched a launch + * KLAUNCHER_THEME name=.. — dispatched a theme switch + * KLAUNCHER_EXIT — dismissed + */ +#include <dirent.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/socket.h> +#include <sys/un.h> +#include <unistd.h> + +#include <kwl.h> +#include <wpkdraw/wpkdraw.h> +#include <wpkdraw/wpkfont.h> + +#define KWLCTL_SOCKET_PATH "/tmp/kwlctl-0" +/* Entry + theme roots; KLAUNCHER_APPS_DIR / KANDELO_THEME_DIR override them + * for tests. */ +#define APPS_DIR "/usr/share/kandelo/apps" +#define THEME_DIR "/usr/share/kandelo/themes" +#define WIN_W 520 +#define WIN_H 320 +#define ROW_H 32 +#define PROMPT_H 44 +#define FONT_PX 16 +#define MAX_APPS 32 + +/* XKB keysyms the launcher acts on. */ +#define KEY_ESCAPE 0xff1b +#define KEY_RETURN 0xff0d +#define KEY_BACKSPACE 0xff08 +#define KEY_UP 0xff52 +#define KEY_DOWN 0xff54 + +static struct { + wpk_color background, foreground, muted, accent, bar; +} palette = { + .background = WPK_RGB(0x1b, 0x1e, 0x2b), + .foreground = WPK_RGB(0xc8, 0xce, 0xdc), + .muted = WPK_RGB(0x6a, 0x72, 0x8a), + .accent = WPK_RGB(0x4f, 0x8f, 0xdf), + .bar = WPK_RGB(0x14, 0x16, 0x20), +}; + +struct app { + char name[48]; + char exec[256]; +}; + +enum level { L_ROOT, L_APPS, L_THEMES }; + +static struct { + struct app apps[MAX_APPS]; + int n_apps; + int match[MAX_APPS]; /* indices of apps matching the query */ + int n_match; + int selected; /* index into match[] */ + char query[64]; + int menu; /* --menu: root level with submenus */ + enum level level; +} st; + +static int parse_color(const char *s, wpk_color fallback) { + if (*s == '#') s++; + char *end = NULL; + unsigned long v = strtoul(s, &end, 16); + return end == s ? (int)fallback : (int)(0xff000000u | (v & 0xffffffu)); +} + +/* Trim ASCII blanks and the trailing newline in place. */ +static char *trim(char *s) { + while (*s == ' ' || *s == '\t') s++; + char *end = s + strlen(s); + while (end > s && (end[-1] == ' ' || end[-1] == '\t' || end[-1] == '\r' || + end[-1] == '\n')) + *--end = '\0'; + return s; +} + +static void theme_load(const char *name) { + const char *root = getenv("KANDELO_THEME_DIR"); + char path[256]; + snprintf(path, sizeof(path), "%s/%s/theme.conf", root ? root : THEME_DIR, + name); + FILE *f = fopen(path, "r"); + if (!f) return; + char line[256]; + while (fgets(line, sizeof(line), f)) { + char *eq = strchr(line, '='); + if (!eq || line[0] == '#') continue; + *eq = '\0'; + char *key = trim(line), *val = trim(eq + 1); + if (!strcmp(key, "background")) + palette.background = parse_color(val, palette.background); + else if (!strcmp(key, "foreground")) + palette.foreground = parse_color(val, palette.foreground); + else if (!strcmp(key, "muted")) + palette.muted = parse_color(val, palette.muted); + else if (!strcmp(key, "accent")) + palette.accent = parse_color(val, palette.accent); + else if (!strcmp(key, "bar")) + palette.bar = parse_color(val, palette.bar); + } + fclose(f); +} + +/* ---- kwlctl ------------------------------------------------------------- */ + +static int kwlctl_send_cmd(const char *cmd, char *out, size_t cap) { + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return -1; + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, KWLCTL_SOCKET_PATH, sizeof(addr.sun_path) - 1); + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + close(fd); + return -1; + } + char line[320]; + int n = snprintf(line, sizeof(line), "%s\n", cmd); + if (n < 0 || n >= (int)sizeof(line)) { close(fd); return -1; } + if (write(fd, line, (size_t)n) != n) { close(fd); return -1; } + size_t got = 0; + ssize_t r; + while (out && got + 1 < cap && (r = read(fd, out + got, cap - got - 1)) > 0) + got += (size_t)r; + if (out) out[got] = '\0'; + close(fd); + return 0; +} + +/* Adopt the compositor's current theme, so the launcher matches the desktop + * it opens over. */ +static void sync_theme(void) { + char buf[1024]; + if (kwlctl_send_cmd("theme", buf, sizeof(buf)) != 0) return; + char *p = strstr(buf, "\"name\":\""); + if (!p) return; + char name[32]; + snprintf(name, sizeof(name), "%s", p + 8); + char *q = strchr(name, '"'); + if (q) { *q = '\0'; theme_load(name); } +} + +/* ---- entries ------------------------------------------------------------ */ + +static void load_apps(void) { + const char *dir = getenv("KLAUNCHER_APPS_DIR"); + if (!dir) dir = APPS_DIR; + DIR *d = opendir(dir); + if (!d) return; + struct dirent *e; + while ((e = readdir(d)) && st.n_apps < MAX_APPS) { + if (e->d_name[0] == '.') continue; + char path[512]; + snprintf(path, sizeof(path), "%s/%s", dir, e->d_name); + FILE *f = fopen(path, "r"); + if (!f) continue; + struct app a = {0}; + char line[256]; + while (fgets(line, sizeof(line), f)) { + char *eq = strchr(line, '='); + if (!eq || line[0] == '#') continue; + *eq = '\0'; + char *key = trim(line), *val = trim(eq + 1); + if (!strcmp(key, "name")) snprintf(a.name, sizeof(a.name), "%s", val); + else if (!strcmp(key, "exec")) { + /* A truncated command would launch the wrong thing, so drop + * the entry instead. */ + if (strlen(val) >= sizeof(a.exec)) { + fprintf(stderr, "klauncher: exec too long in %s\n", path); + a.exec[0] = '\0'; + break; + } + snprintf(a.exec, sizeof(a.exec), "%s", val); + } + } + fclose(f); + if (a.name[0] && a.exec[0]) st.apps[st.n_apps++] = a; + } + closedir(d); + + /* Alphabetical, so the list order does not depend on readdir. */ + for (int i = 1; i < st.n_apps; i++) { + struct app a = st.apps[i]; + int j = i; + while (j > 0 && strcmp(st.apps[j - 1].name, a.name) > 0) { + st.apps[j] = st.apps[j - 1]; + j--; + } + st.apps[j] = a; + } +} + +static void add_entry(const char *name, const char *exec) { + if (st.n_apps >= MAX_APPS) return; + struct app *a = &st.apps[st.n_apps++]; + snprintf(a->name, sizeof(a->name), "%s", name); + snprintf(a->exec, sizeof(a->exec), "%s", exec); +} + +static void load_root(void) { + add_entry("Apps", "browse applications"); + add_entry("Theme", "switch theme"); +} + +/* The installed set, already sorted by the compositor's theme_scan; the live + * one is tagged in the right-hand column. */ +static void load_themes(void) { + char buf[1024]; + if (kwlctl_send_cmd("theme", buf, sizeof(buf)) != 0) return; + char live[48] = ""; + char *p = strstr(buf, "\"name\":\""); + if (p) { + snprintf(live, sizeof(live), "%s", p + 8); + char *q = strchr(live, '"'); + if (q) *q = '\0'; + } + p = strstr(buf, "\"themes\":["); + if (!p) return; + p += 10; + while (*p && *p != ']') { + if (*p != '"') { p++; continue; } + char *q = strchr(p + 1, '"'); + if (!q) break; + *q = '\0'; + add_entry(p + 1, strcmp(p + 1, live) == 0 ? "current" : "theme"); + p = q + 1; + } +} + +static void refilter(void); + +static const char *LEVEL_NAMES[] = { "root", "apps", "themes" }; + +static void enter_level(enum level lvl) { + st.level = lvl; + st.n_apps = 0; + st.query[0] = '\0'; + st.selected = 0; + if (lvl == L_ROOT) load_root(); + else if (lvl == L_APPS) load_apps(); + else load_themes(); + printf("KLAUNCHER_LEVEL %s\n", LEVEL_NAMES[lvl]); + fflush(stdout); + refilter(); +} + +static int ascii_lower(int c) { return c >= 'A' && c <= 'Z' ? c + 32 : c; } + +/* Substring match, case-insensitive, over the name — the launcher's whole + * filtering rule. */ +static int matches(const char *name, const char *query) { + if (!query[0]) return 1; + for (const char *p = name; *p; p++) { + const char *a = p, *b = query; + while (*a && *b && ascii_lower(*a) == ascii_lower(*b)) { a++; b++; } + if (!*b) return 1; + } + return 0; +} + +static void refilter(void) { + st.n_match = 0; + for (int i = 0; i < st.n_apps; i++) + if (matches(st.apps[i].name, st.query)) st.match[st.n_match++] = i; + if (st.selected >= st.n_match) st.selected = st.n_match ? st.n_match - 1 : 0; + printf("KLAUNCHER_FILTER q=%s n=%d\n", st.query, st.n_match); + fflush(stdout); +} + +/* ---- rendering ---------------------------------------------------------- */ + +/* Longest prefix of `text` that renders within `max_w`, with a trailing "..." + * when it had to cut. Cuts on a UTF-8 boundary so a multi-byte glyph is never + * split. Writes into `out` and returns it. */ +static const char *fit_text(struct wpk_font *font, const char *text, + int max_w, char *out, size_t out_size) { + snprintf(out, out_size, "%s", text); + if (wpk_text_width(font, out) <= max_w) return out; + + size_t cut = strlen(out); + while (cut > 0) { + do { cut--; } while (cut > 0 && (text[cut] & 0xc0) == 0x80); + if (cut + 4 > out_size) continue; + memcpy(out, text, cut); + memcpy(out + cut, "...", 4); + if (wpk_text_width(font, out) <= max_w) return out; + } + out[0] = '\0'; + return out; +} + +static void render(struct wpk_surface *s, struct wpk_font *font) { + wpk_clear(s, palette.background); + wpk_rect(s, 0, 0, s->w, PROMPT_H, palette.bar); + wpk_rect(s, 0, PROMPT_H - 1, s->w, 1, palette.accent); + + int baseline = (PROMPT_H + wpk_font_ascent_px(font)) / 2 - 2; + char prompt[80]; + snprintf(prompt, sizeof(prompt), "> %s_", st.query); + wpk_text(s, font, 14, baseline, prompt, palette.foreground); + + int y = PROMPT_H + 4; + for (int i = 0; i < st.n_match && y + ROW_H <= s->h; i++, y += ROW_H) { + int selected = i == st.selected; + if (selected) wpk_rect(s, 6, y, s->w - 12, ROW_H, palette.accent); + const struct app *a = &st.apps[st.match[i]]; + int text_y = y + (ROW_H + wpk_font_ascent_px(font)) / 2 - 2; + wpk_text(s, font, 16, text_y, a->name, + selected ? palette.bar : palette.foreground); + char exec[sizeof(a->exec) + 4]; + int name_end = 16 + wpk_text_width(font, a->name); + fit_text(font, a->exec, s->w - 16 - name_end - 12, exec, sizeof exec); + int ew = wpk_text_width(font, exec); + wpk_text(s, font, s->w - ew - 16, text_y, exec, + selected ? palette.bar : palette.muted); + } + if (st.n_match == 0) + wpk_text(s, font, 16, PROMPT_H + 28, "no matches", palette.muted); +} + +int main(int argc, char **argv) { + st.menu = argc > 1 && strcmp(argv[1], "--menu") == 0; + enter_level(st.menu ? L_ROOT : L_APPS); + sync_theme(); + + struct kwl_layer_opts opts = { + .layer = KWL_LAYER_OVERLAY, + .anchor = 0, /* unanchored: the compositor centres it */ + .w = WIN_W, + .h = WIN_H, + .keyboard = 1, + }; + struct kwl_window *win = kwl_layer_create("launcher", &opts); + if (!win) { fprintf(stderr, "klauncher: no layer shell\n"); return 1; } + + struct wpk_font *font = wpk_font_load_default(FONT_PX); + if (!font) { fprintf(stderr, "klauncher: no font\n"); return 1; } + + render(kwl_window_surface(win), font); + kwl_window_commit(win); + printf("KLAUNCHER_READY n=%d\n", st.n_apps); + fflush(stdout); + + int running = 1; + while (running) { + struct kwl_event ev; + if (!kwl_dispatch(win, &ev, -1)) continue; + int dirty = 0; + if (ev.type == KWL_CLOSE) break; + if (ev.type == KWL_RESIZE) dirty = 1; + if (ev.type == KWL_KEY && ev.state == 1) { + switch (ev.keysym) { + case KEY_ESCAPE: + if (st.menu && st.level != L_ROOT) { + enter_level(L_ROOT); + dirty = 1; + } else { + running = 0; + } + break; + case KEY_UP: + if (st.selected > 0) st.selected--; + dirty = 1; + break; + case KEY_DOWN: + if (st.selected + 1 < st.n_match) st.selected++; + dirty = 1; + break; + case KEY_BACKSPACE: { + size_t n = strlen(st.query); + if (n) st.query[n - 1] = '\0'; + refilter(); + dirty = 1; + break; + } + case KEY_RETURN: + if (st.n_match == 0) { + running = 0; + break; + } + if (st.level == L_ROOT) { + const struct app *a = &st.apps[st.match[st.selected]]; + enter_level(strcmp(a->name, "Theme") == 0 ? L_THEMES + : L_APPS); + dirty = 1; + } else if (st.level == L_THEMES) { + const struct app *a = &st.apps[st.match[st.selected]]; + char cmd[sizeof(a->name) + 32]; + snprintf(cmd, sizeof(cmd), "dispatch theme %s", a->name); + kwlctl_send_cmd(cmd, NULL, 0); + printf("KLAUNCHER_THEME name=%s\n", a->name); + fflush(stdout); + running = 0; + } else { + const struct app *a = &st.apps[st.match[st.selected]]; + char cmd[sizeof(a->exec) + 32]; + snprintf(cmd, sizeof(cmd), "dispatch exec %s", a->exec); + kwlctl_send_cmd(cmd, NULL, 0); + printf("KLAUNCHER_EXEC cmd=%s\n", a->exec); + fflush(stdout); + running = 0; + } + break; + } + } else if (ev.type == KWL_TEXT && ev.utf8[0] >= ' ') { + size_t n = strlen(st.query); + if (n + strlen(ev.utf8) < sizeof(st.query) - 1) { + strcat(st.query, ev.utf8); + refilter(); + dirty = 1; + } + } + if (dirty && running) { + render(kwl_window_surface(win), font); + kwl_window_commit(win); + } + } + + printf("KLAUNCHER_EXIT\n"); + fflush(stdout); + wpk_font_destroy(font); + kwl_window_destroy(win); + return 0; +} diff --git a/programs/knotify.c b/programs/knotify.c new file mode 100644 index 0000000000..208402fc2f --- /dev/null +++ b/programs/knotify.c @@ -0,0 +1,168 @@ +/* + * knotify — a transient notification toast, Omarchy's notify-send slot. + * + * One toast per process: `knotify <title> <body…>` maps an overlay-layer + * surface in the top-right corner (under the bar's reserved strip), shows the + * message for a moment, and exits — the surface teardown is the dismissal, so + * there is no daemon and no unmap protocol. The compositor's `notify =` + * config hook spawns it on a theme switch; anything else can spawn it too. + * + * Markers on stdout for the smoke gates: + * KNOTIFY_READY title=.. — toast mapped + first frame committed + * KNOTIFY_EXIT — dismissed + */ +#include <poll.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/socket.h> +#include <sys/un.h> +#include <time.h> +#include <unistd.h> + +#include <kwl.h> +#include <wpkdraw/wpkdraw.h> +#include <wpkdraw/wpkfont.h> + +#define KWLCTL_SOCKET_PATH "/tmp/kwlctl-0" +/* Theme root; KANDELO_THEME_DIR overrides it for tests. */ +#define THEME_DIR "/usr/share/kandelo/themes" +#define TOAST_W 300 +#define TOAST_H 64 +#define TOAST_MARGIN 12 +#define TOAST_MS 2500 +#define FONT_PX 15 + +static struct { + wpk_color bar, foreground, muted, accent; +} palette = { + .bar = WPK_RGB(0x16, 0x18, 0x22), + .foreground = WPK_RGB(0xc8, 0xce, 0xdc), + .muted = WPK_RGB(0x6a, 0x72, 0x8a), + .accent = WPK_RGB(0x4f, 0x8f, 0xdf), +}; + +static int parse_color(const char *s, wpk_color fallback) { + if (*s == '#') s++; + char *end = NULL; + unsigned long v = strtoul(s, &end, 16); + return end == s ? (int)fallback : (int)(0xff000000u | (v & 0xffffffu)); +} + +static void theme_load(const char *name) { + const char *root = getenv("KANDELO_THEME_DIR"); + char path[256]; + snprintf(path, sizeof(path), "%s/%s/theme.conf", root ? root : THEME_DIR, + name); + FILE *f = fopen(path, "r"); + if (!f) return; + char line[256]; + while (fgets(line, sizeof(line), f)) { + char *eq = strchr(line, '='); + if (!eq || line[0] == '#') continue; + *eq = '\0'; + char *key = line, *val = eq + 1; + while (*key == ' ' || *key == '\t') key++; + char *ke = key + strlen(key); + while (ke > key && (ke[-1] == ' ' || ke[-1] == '\t')) *--ke = '\0'; + while (*val == ' ' || *val == '\t') val++; + char *ve = val + strlen(val); + while (ve > val && (ve[-1] == '\n' || ve[-1] == '\r' || ve[-1] == ' ')) + *--ve = '\0'; + if (!strcmp(key, "bar")) palette.bar = parse_color(val, palette.bar); + else if (!strcmp(key, "foreground")) + palette.foreground = parse_color(val, palette.foreground); + else if (!strcmp(key, "muted")) + palette.muted = parse_color(val, palette.muted); + else if (!strcmp(key, "accent")) + palette.accent = parse_color(val, palette.accent); + } + fclose(f); +} + +/* Adopt the compositor's live theme, so the toast matches the desktop. */ +static void sync_theme(void) { + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return; + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, KWLCTL_SOCKET_PATH, sizeof(addr.sun_path) - 1); + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + close(fd); + return; + } + char buf[1024] = ""; + if (write(fd, "theme\n", 6) == 6) { + size_t got = 0; + ssize_t r; + while (got + 1 < sizeof(buf) && + (r = read(fd, buf + got, sizeof(buf) - got - 1)) > 0) + got += (size_t)r; + buf[got] = '\0'; + } + close(fd); + char *p = strstr(buf, "\"name\":\""); + if (!p) return; + char name[32]; + snprintf(name, sizeof(name), "%s", p + 8); + char *q = strchr(name, '"'); + if (q) { *q = '\0'; theme_load(name); } +} + +int main(int argc, char **argv) { + const char *title = argc > 1 ? argv[1] : "Notice"; + char body[128] = ""; + for (int i = 2; i < argc; i++) { + if (body[0]) strncat(body, " ", sizeof(body) - strlen(body) - 1); + strncat(body, argv[i], sizeof(body) - strlen(body) - 1); + } + sync_theme(); + + struct kwl_layer_opts opts = { + .layer = KWL_LAYER_OVERLAY, + .anchor = KWL_ANCHOR_TOP | KWL_ANCHOR_RIGHT, + .w = TOAST_W, + .h = TOAST_H, + .margin_top = TOAST_MARGIN, + .margin_right = TOAST_MARGIN, + }; + struct kwl_window *win = kwl_layer_create("notify", &opts); + if (!win) { fprintf(stderr, "knotify: no layer shell\n"); return 1; } + + struct wpk_font *font = wpk_font_load_default(FONT_PX); + if (!font) { fprintf(stderr, "knotify: no font\n"); return 1; } + + struct wpk_surface *s = kwl_window_surface(win); + wpk_clear(s, palette.bar); + wpk_rect(s, 0, 0, 4, s->h, palette.accent); + wpk_text(s, font, 16, 6 + wpk_font_ascent_px(font), title, + palette.foreground); + if (body[0]) + wpk_text(s, font, 16, s->h - 12, body, palette.muted); + kwl_window_commit(win); + printf("KNOTIFY_READY title=%s\n", title); + fflush(stdout); + + struct timespec t0; + clock_gettime(CLOCK_MONOTONIC, &t0); + int running = 1; + while (running) { + struct pollfd pfd = { .fd = kwl_display_fd(win), .events = POLLIN }; + poll(&pfd, 1, 100); + struct kwl_event ev; + while (kwl_dispatch(win, &ev, 0)) + if (ev.type == KWL_CLOSE) running = 0; + struct timespec t; + clock_gettime(CLOCK_MONOTONIC, &t); + long ms = (t.tv_sec - t0.tv_sec) * 1000 + + (t.tv_nsec - t0.tv_nsec) / 1000000; + if (ms >= TOAST_MS) running = 0; + } + + printf("KNOTIFY_EXIT\n"); + fflush(stdout); + wpk_font_destroy(font); + kwl_window_destroy(win); + return 0; +} diff --git a/programs/wlcompositor/kwlctl.c b/programs/wlcompositor/kwlctl.c index bab37f51d5..31a2ffdb2f 100644 --- a/programs/wlcompositor/kwlctl.c +++ b/programs/wlcompositor/kwlctl.c @@ -26,10 +26,14 @@ static int connect_kwlctl(void) { int fd = socket(AF_UNIX, SOCK_STREAM, 0); if (fd < 0) { perror("socket"); return -1; } + /* KWLCTL_SOCKET points the CLI at another control socket — the smoke + * tests use it against the compositor's Hyprland-IPC pair. */ + const char *path = getenv("KWLCTL_SOCKET"); + if (!path) path = KWLCTL_SOCKET_PATH; struct sockaddr_un addr; memset(&addr, 0, sizeof(addr)); addr.sun_family = AF_UNIX; - strncpy(addr.sun_path, KWLCTL_SOCKET_PATH, sizeof(addr.sun_path) - 1); + strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); /* The compositor may still be coming up; retry briefly like a wl client. */ for (int i = 0; i < 200; i++) { if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) return fd; diff --git a/programs/wlcompositor/wlclient-test.c b/programs/wlcompositor/wlclient-test.c index ed8e127ab2..b9e9f6705b 100644 --- a/programs/wlcompositor/wlclient-test.c +++ b/programs/wlcompositor/wlclient-test.c @@ -10,8 +10,9 @@ * 3. allocate a renderD128 dumb-bo, paint it solid red, and hand its * prime-fd to wl_shm as the pool — the shared-buffer path that lets * the compositor read the client's pixels (plan §8.1 gbm_bo_import). - * 4. attach + commit + request a frame callback; when the callback - * fires, the compositor has flipped our pixels onto card0. + * 4. attach + commit + request a frame callback and a wp_presentation + * feedback; when they fire, the compositor has flipped our pixels + * onto card0 and reported the flip's CLOCK_MONOTONIC timestamp. * 5. compile the wl_keyboard keymap fd (proving the compositor's * libxkbcommon keymap path) and receive a host-injected key + a * pointer button, forwarded by the compositor from libinput. @@ -34,6 +35,12 @@ #include <wayland-client-protocol.h> #include "xdg-shell-client-protocol.h" #include "xdg-decoration-v1-client-protocol.h" +#include "presentation-time-client-protocol.h" +#include "xdg-output-v1-client-protocol.h" +#include "viewporter-client-protocol.h" +#include "fractional-scale-v1-client-protocol.h" + +#include <xkbcommon/xkbcommon.h> #include <gbm.h> @@ -49,6 +56,10 @@ struct client { struct wl_seat *seat; struct wl_output *output; struct zxdg_decoration_manager_v1 *decor_mgr; + struct wp_presentation *presentation; + struct zxdg_output_manager_v1 *xdg_output_mgr; + struct wp_viewporter *viewporter; + struct wp_fractional_scale_manager_v1 *fractional_scale_mgr; struct wl_surface *surface; struct xdg_surface *xdg_surface; @@ -56,12 +67,79 @@ struct client { int configured; /* got + acked the initial xdg configure */ int frame_done; /* compositor flipped our buffer */ + int presented; /* wp_presentation_feedback resolved (either event) */ int got_keymap; /* wl_keyboard.keymap arrived + parsed */ int got_key; /* wl_keyboard.key arrived */ uint32_t key_code, key_state; int got_button; /* wl_pointer.button arrived */ uint32_t btn_code, btn_state; int closed; /* compositor sent xdg_toplevel.close (killactive) */ + int32_t output_scale; /* wl_output.scale */ + int32_t entered_scale; /* the scale of the output wl_surface.enter named, + * 0 until the enter arrives */ +}; + +/* ---- wp_presentation --------------------------------------------------- */ + +static void presentation_clock_id(void *data, struct wp_presentation *p, + uint32_t clk_id) { + printf("PRESENTATION_CLOCK id=%u\n", clk_id); + fflush(stdout); +} +static const struct wp_presentation_listener presentation_listener = { + .clock_id = presentation_clock_id, +}; + +/* ---- wl_output v4 ------------------------------------------------------ */ + +static void output_geometry(void *data, struct wl_output *o, int32_t x, + int32_t y, int32_t phys_w, int32_t phys_h, + int32_t subpixel, const char *make, + const char *model, int32_t transform) {} +static void output_mode(void *data, struct wl_output *o, uint32_t flags, + int32_t w, int32_t h, int32_t refresh) { + printf("OUTPUT_MODE w=%d h=%d\n", w, h); + fflush(stdout); +} +static void output_done(void *data, struct wl_output *o) {} +static void output_scale(void *data, struct wl_output *o, int32_t factor) { + struct client *c = data; + c->output_scale = factor; + printf("OUTPUT_SCALE factor=%d\n", factor); + fflush(stdout); +} +static void output_name(void *data, struct wl_output *o, const char *name) { + printf("OUTPUT_NAME %s\n", name); + fflush(stdout); +} +static void output_description(void *data, struct wl_output *o, + const char *description) {} +static const struct wl_output_listener output_listener = { + .geometry = output_geometry, + .mode = output_mode, + .done = output_done, + .scale = output_scale, + .name = output_name, + .description = output_description, +}; + +/* ---- wl_surface -------------------------------------------------------- */ + +/* The output a surface sits on, and with it the scale to render at. A client + * that reads its scale here (mako does) must have the event before it draws + * its first frame, so the compositor sends it when the surface takes a role. */ +static void surface_enter(void *data, struct wl_surface *s, + struct wl_output *o) { + struct client *c = data; + c->entered_scale = c->output_scale; + printf("SURFACE_ENTER scale=%d\n", c->entered_scale); + fflush(stdout); +} +static void surface_leave(void *data, struct wl_surface *s, + struct wl_output *o) {} +static const struct wl_surface_listener surface_listener = { + .enter = surface_enter, + .leave = surface_leave, }; /* ---- registry ---------------------------------------------------------- */ @@ -78,12 +156,97 @@ static void registry_global(void *data, struct wl_registry *reg, uint32_t name, c->wm_base = wl_registry_bind(reg, name, &xdg_wm_base_interface, 1); else if (strcmp(iface, "wl_seat") == 0) c->seat = wl_registry_bind(reg, name, &wl_seat_interface, 1); - else if (strcmp(iface, "wl_output") == 0) - c->output = wl_registry_bind(reg, name, &wl_output_interface, 2); + else if (strcmp(iface, "wl_output") == 0) { + c->output = wl_registry_bind(reg, name, &wl_output_interface, + version < 4 ? version : 4); + wl_output_add_listener(c->output, &output_listener, c); + } else if (strcmp(iface, "zxdg_decoration_manager_v1") == 0) c->decor_mgr = wl_registry_bind( reg, name, &zxdg_decoration_manager_v1_interface, 1); + else if (strcmp(iface, "wp_presentation") == 0) { + c->presentation = + wl_registry_bind(reg, name, &wp_presentation_interface, 1); + /* clock_id arrives on the bind roundtrip — listen from the start. */ + wp_presentation_add_listener(c->presentation, &presentation_listener, + c); + } else if (strcmp(iface, "zxdg_output_manager_v1") == 0) + c->xdg_output_mgr = wl_registry_bind( + reg, name, &zxdg_output_manager_v1_interface, + version < 3 ? version : 3); + else if (strcmp(iface, "wp_viewporter") == 0) + c->viewporter = + wl_registry_bind(reg, name, &wp_viewporter_interface, 1); + else if (strcmp(iface, "wp_fractional_scale_manager_v1") == 0) + c->fractional_scale_mgr = wl_registry_bind( + reg, name, &wp_fractional_scale_manager_v1_interface, 1); +} + +/* ---- xdg-output / fractional-scale ------------------------------------- */ + +static void xdg_output_logical_position(void *data, struct zxdg_output_v1 *xo, + int32_t x, int32_t y) { + printf("XDG_OUTPUT_POS x=%d y=%d\n", x, y); + fflush(stdout); +} +static void xdg_output_logical_size(void *data, struct zxdg_output_v1 *xo, + int32_t w, int32_t h) { + printf("XDG_OUTPUT_SIZE w=%d h=%d\n", w, h); + fflush(stdout); +} +static void xdg_output_done(void *data, struct zxdg_output_v1 *xo) {} +static void xdg_output_name(void *data, struct zxdg_output_v1 *xo, + const char *name) { + printf("XDG_OUTPUT_NAME %s\n", name); + fflush(stdout); +} +static void xdg_output_description(void *data, struct zxdg_output_v1 *xo, + const char *description) {} +static const struct zxdg_output_v1_listener xdg_output_listener = { + .logical_position = xdg_output_logical_position, + .logical_size = xdg_output_logical_size, + .done = xdg_output_done, + .name = xdg_output_name, + .description = xdg_output_description, +}; + +static void fractional_scale_preferred(void *data, + struct wp_fractional_scale_v1 *fs, + uint32_t scale) { + printf("FRACTIONAL_SCALE scale=%u\n", scale); + fflush(stdout); +} +static const struct wp_fractional_scale_v1_listener fractional_scale_listener = { + .preferred_scale = fractional_scale_preferred, +}; + +static void feedback_sync_output(void *data, + struct wp_presentation_feedback *fb, + struct wl_output *output) {} +static void feedback_presented(void *data, struct wp_presentation_feedback *fb, + uint32_t sec_hi, uint32_t sec_lo, uint32_t nsec, + uint32_t refresh, uint32_t seq_hi, + uint32_t seq_lo, uint32_t flags) { + struct client *c = data; + c->presented = 1; + printf("PRESENTED sec=%u nsec=%u refresh=%u seq=%u flags=0x%x\n", + sec_lo, nsec, refresh, seq_lo, flags); + fflush(stdout); + wp_presentation_feedback_destroy(fb); +} +static void feedback_discarded(void *data, + struct wp_presentation_feedback *fb) { + struct client *c = data; + c->presented = 1; + printf("PRESENTATION_DISCARDED\n"); + fflush(stdout); + wp_presentation_feedback_destroy(fb); } +static const struct wp_presentation_feedback_listener feedback_listener = { + .sync_output = feedback_sync_output, + .presented = feedback_presented, + .discarded = feedback_discarded, +}; /* ---- xdg-decoration ---------------------------------------------------- */ @@ -151,12 +314,36 @@ static void kbd_keymap(void *data, struct wl_keyboard *k, uint32_t format, struct client *c = data; /* Read-only map of the keymap the compositor built via libxkbcommon. * The bytes are the file's contents (no cross-process sharing needed); - * we assert it is a real xkb keymap. */ + * we compile it exactly as a real client would and probe keys a + * terminal needs beyond the letters (F1, Delete). */ char *map = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0); if (map != MAP_FAILED) { if (format == WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1 && - strncmp(map, "xkb_keymap", 10) == 0) - c->got_keymap = 1; + strncmp(map, "xkb_keymap", 10) == 0) { + struct xkb_context *ctx = + xkb_context_new(XKB_CONTEXT_NO_DEFAULT_INCLUDES); + /* The compositor's map is NUL-terminated (size = strlen + 1), + * which is what from_string expects. */ + struct xkb_keymap *km = ctx + ? xkb_keymap_new_from_string(ctx, map, + XKB_KEYMAP_FORMAT_TEXT_V1, + XKB_KEYMAP_COMPILE_NO_FLAGS) + : NULL; + if (km) { + /* evdev KEY_F1 (59) + 8, KEY_DELETE (111) + 8. */ + const xkb_keysym_t *syms; + int f1 = xkb_keymap_key_get_syms_by_level(km, 67, 0, 0, + &syms) == 1 && + syms[0] == XKB_KEY_F1; + int del = xkb_keymap_key_get_syms_by_level(km, 119, 0, 0, + &syms) == 1 && + syms[0] == XKB_KEY_Delete; + c->got_keymap = f1 && del; + printf("KEYMAP_SYMS f1=%d delete=%d\n", f1, del); + xkb_keymap_unref(km); + } + if (ctx) xkb_context_unref(ctx); + } munmap(map, size); } close(fd); @@ -310,11 +497,14 @@ int main(void) { /* Toplevel. */ c.surface = wl_compositor_create_surface(c.compositor); + wl_surface_add_listener(c.surface, &surface_listener, &c); c.xdg_surface = xdg_wm_base_get_xdg_surface(c.wm_base, c.surface); xdg_surface_add_listener(c.xdg_surface, &xdg_surface_listener, &c); c.toplevel = xdg_surface_get_toplevel(c.xdg_surface); xdg_toplevel_add_listener(c.toplevel, &toplevel_listener, &c); xdg_toplevel_set_title(c.toplevel, "wlclient-test"); + /* The app_id is what names this client in the compositor's markers. */ + xdg_toplevel_set_app_id(c.toplevel, "wlclient-test"); /* Optional: request server-side decorations (PR14e). The compositor forces * SERVER_SIDE for tiling, which the client honors by drawing no titlebar. */ @@ -327,6 +517,30 @@ int main(void) { deco, ZXDG_TOPLEVEL_DECORATION_V1_MODE_SERVER_SIDE); } + /* Optional (PR24): exercise the logical-output + crop/scale globals. The + * xdg_output burst and the preferred scale arrive on the roundtrip; the + * viewport doubles the mapped size, which the compositor's VIEWPORT + * marker reports at commit. */ + if (getenv("WLC_PROTOS")) { + if (!c.xdg_output_mgr || !c.viewporter || !c.fractional_scale_mgr) { + fprintf(stderr, "missing protocol globals: xdg_out=%p vp=%p frac=%p\n", + (void *)c.xdg_output_mgr, (void *)c.viewporter, + (void *)c.fractional_scale_mgr); + return 1; + } + struct zxdg_output_v1 *xo = + zxdg_output_manager_v1_get_xdg_output(c.xdg_output_mgr, c.output); + zxdg_output_v1_add_listener(xo, &xdg_output_listener, &c); + struct wp_fractional_scale_v1 *fs = + wp_fractional_scale_manager_v1_get_fractional_scale( + c.fractional_scale_mgr, c.surface); + wp_fractional_scale_v1_add_listener(fs, &fractional_scale_listener, &c); + struct wp_viewport *vp = + wp_viewporter_get_viewport(c.viewporter, c.surface); + wp_viewport_set_destination(vp, WIN_W * 2, WIN_H * 2); + wl_display_roundtrip(display); + } + wl_surface_commit(c.surface); /* Wait for the initial configure before attaching a buffer. */ @@ -339,13 +553,38 @@ int main(void) { if (!buffer) return 1; wl_surface_attach(c.surface, buffer, 0, 0); - wl_surface_damage(c.surface, 0, 0, WIN_W, WIN_H); + /* Optional: declare the buffer as scale-N pixels, so the same WIN_W x WIN_H + * bytes cover a window N times smaller. WIN_W and WIN_H are even, which + * scale 2 requires. */ + int buf_scale = 1; + const char *want_buf_scale = getenv("WLC_BUFSCALE"); + /* Or take it from wl_surface.enter, the way mako does. The enter has to + * have arrived by now: this is the first frame, and one frame is all a + * toast lives for — render it at the wrong scale and it is soft for good. */ + if (getenv("WLC_ENTERSCALE")) { + if (!c.entered_scale) { + fprintf(stderr, "no wl_surface.enter before the first frame\n"); + return 1; + } + buf_scale = c.entered_scale; + wl_surface_set_buffer_scale(c.surface, buf_scale); + } else if (want_buf_scale) { + buf_scale = atoi(want_buf_scale); + wl_surface_set_buffer_scale(c.surface, buf_scale); + } + wl_surface_damage(c.surface, 0, 0, WIN_W / buf_scale, WIN_H / buf_scale); struct wl_callback *frame = wl_surface_frame(c.surface); wl_callback_add_listener(frame, &frame_listener, &c); + if (c.presentation) { + struct wp_presentation_feedback *fb = + wp_presentation_feedback(c.presentation, c.surface); + wp_presentation_feedback_add_listener(fb, &feedback_listener, &c); + } wl_surface_commit(c.surface); - /* The frame callback fires once the compositor has flipped our pixels. */ - while (!c.frame_done) + /* The frame callback fires once the compositor has flipped our pixels; + * the presentation feedback resolves on the same flip. */ + while (!c.frame_done || (c.presentation && !c.presented)) if (wl_display_dispatch(display) < 0) { fprintf(stderr, "dispatch\n"); return 1; } printf("CLIENT_MAPPED\n"); printf("CLIENT_READY\n"); /* signal to the test to inject input */ diff --git a/programs/wlcompositor/wlcompositor.c b/programs/wlcompositor/wlcompositor.c index 69350813b4..dc3ca36b60 100644 --- a/programs/wlcompositor/wlcompositor.c +++ b/programs/wlcompositor/wlcompositor.c @@ -67,6 +67,7 @@ * gates (host/test/wlcompositor-smoke.test.ts and friends) can spawn * compositor + client(s) and observe a clean shutdown. */ +#include <dirent.h> #include <errno.h> #include <fcntl.h> #include <signal.h> @@ -88,6 +89,11 @@ #include "xdg-shell-server-protocol.h" #include "linux-dmabuf-v1-server-protocol.h" #include "xdg-decoration-v1-server-protocol.h" +#include "wlr-layer-shell-v1-server-protocol.h" +#include "presentation-time-server-protocol.h" +#include "xdg-output-v1-server-protocol.h" +#include "viewporter-server-protocol.h" +#include "fractional-scale-v1-server-protocol.h" #include <xkbcommon/xkbcommon.h> #include <xkbcommon/xkbcommon-names.h> @@ -122,27 +128,49 @@ extern void wpkEglCloseBoHandle(EGLDisplay dpy, unsigned bo_handle); * kwlctl (programs/wlcompositor/kwlctl.c) and the Tier-1 bar speak to it. */ #define KWLCTL_SOCKET_PATH "/tmp/kwlctl-0" #define MAX_KWLCTL_CONNS 16 +/* The Hyprland IPC socket pair, where Waybar's hyprland modules expect it: + * $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket.sock (request/ + * reply) + .socket2.sock (event stream). XDG_RUNTIME_DIR is /tmp here (see + * WL_SOCKET_PATH above); main() exports the signature so spawned clients + * find the dir. Requests answer in hyprctl -j shapes; both sockets share + * kwlctl's command table and event broadcast. */ +#define HYPR_INSTANCE_SIG "wlcompositor" +#define HYPR_DIR "/tmp/hypr/" HYPR_INSTANCE_SIG +#define HYPR_SOCKET1_PATH HYPR_DIR "/.socket.sock" +#define HYPR_SOCKET2_PATH HYPR_DIR "/.socket2.sock" #define WL_KEYMAP_PATH "/tmp/wlcompositor-keymap.xkb" #define MAX_INPUT_RES 16 /* keyboard/pointer resources we track */ #define MAX_FRAME_CB 32 /* pending frame callbacks per surface */ #define MAX_SURFACES 16 /* mapped toplevels in the z-order list */ -#define FOCUS_COLOR 0xff4f8fdfu /* accent ring, GPU and CPU paths */ +#define MAX_LAYERS 8 /* mapped wlr-layer-shell surfaces (bar, launcher) */ +#define FOCUS_COLOR 0xff4f8fdfu /* default accent ring; themes override */ #define N_WORKSPACES 9 /* SUPER+1..9, Hyprland's 1-based workspaces */ +/* wl_output.scale is integer-only; 3 covers every devicePixelRatio a + * browser reports without letting a bad WLC_SCALE shrink the logical grid + * to nothing. */ +#define MAX_OUTPUT_SCALE 3 /* The config path, hyprland.conf-shaped subset. Absent = generic defaults * (install_default_binds); WLC_CONFIG overrides for tests. */ #define WLC_CONFIG_PATH "/etc/kandelo/wlcompositor.conf" #define MAX_BINDS 64 +/* Themes are just files, the way Omarchy does it: one directory per theme + * holding a palette the compositor and every shell client read. WLC_THEME_DIR + * overrides the root for tests. */ +#define THEME_DIR "/usr/share/kandelo/themes" +#define MAX_THEMES 16 + /* Modifier bits used by the keybind engine (mapped from xkb mod state). */ #define MOD_SUPER 1 #define MOD_SHIFT 2 #define MOD_CTRL 4 /* the browser reserves SUPER (Cmd/Win), so CTRL is the usable modifier for the in-browser demo */ +#define MOD_ALT 8 enum bind_action { ACT_EXEC, ACT_WORKSPACE, ACT_MOVE_TO_WS, ACT_KILL, - ACT_CYCLE_NEXT, ACT_CYCLE_PREV, + ACT_CYCLE_NEXT, ACT_CYCLE_PREV, ACT_THEME, }; /* One `bind = MODS, KEY, DISPATCHER, ARGS` rule. sym is the BASE-level keysym @@ -168,7 +196,22 @@ struct surface { struct wl_resource *buffer; /* committed, retained for repaints */ struct wl_resource *xdg_surface; /* xdg_surface wrapping this surface */ struct wl_resource *xdg_toplevel; + /* wlr-layer-shell role: set instead of the xdg pair for a shell + * component (the bar, the launcher). A layer surface is anchored by the + * compositor, never tiled, and lives above or below every toplevel. */ + struct wl_resource *layer_surface; + uint32_t layer; /* ZWLR_LAYER_SHELL_V1_LAYER_* */ + uint32_t anchor; /* ZWLR_LAYER_SURFACE_V1_ANCHOR_* mask */ + int32_t exclusive_zone; /* px reserved from the anchored edge */ + int32_t margin_top, margin_right, margin_bottom, margin_left; + uint32_t kb_interactive; /* ZWLR_LAYER_SURFACE_V1_KEYBOARD_* */ + int32_t req_w, req_h; /* set_size; 0 = compositor decides */ + int layer_configured; /* first configure sent */ + int layer_dirty; /* a layer-shell request is waiting for + * the commit that applies it */ + int layer_announced; /* LAYER marker printed while mapped */ char app_id[32]; + char title[96]; /* xdg_toplevel.set_title, for the bar */ int32_t x, y; /* top-left on the output */ int32_t w, h; /* committed buffer dims */ int workspace; /* 1..N_WORKSPACES; 0 until first map */ @@ -176,6 +219,32 @@ struct surface { int placed; /* position assigned at first map */ struct wl_resource *frame_cbs[MAX_FRAME_CB]; int n_frame_cbs; + /* wp_presentation_feedback resources awaiting the next flip. */ + struct wl_resource *feedbacks[MAX_FRAME_CB]; + int n_feedbacks; + /* wl_subsurface role: glued to `parent` at (sub_x, sub_y), composited + * right above it, never tiled, never focused, never hit-tested. */ + struct wl_resource *subsurface; + struct surface *parent; + int32_t sub_x, sub_y; + /* wp_viewport crop + scale. vp_src_w <= 0 = no source rect (w must be + * positive when set, so the calloc zero means unset); vp_dst_w <= 0 = + * no destination size. */ + struct wl_resource *viewport; + wl_fixed_t vp_src_x, vp_src_y, vp_src_w, vp_src_h; + int32_t vp_dst_w, vp_dst_h; + struct wl_resource *fractional_scale; + /* wl_surface.set_buffer_scale, double-buffered like every other surface + * property. 1 until a client says otherwise, so a client that never sends + * it keeps buffer pixels == logical pixels. */ + int32_t buffer_scale, pending_buffer_scale; + /* The scale the BUFFER_SCALE marker last reported, 0 before the first + * commit. A bar commits a frame a second, so the marker fires on a change, + * not on every commit. */ + int32_t reported_scale; + /* Set once wl_surface.enter has named the output to this surface. One + * output means one enter, so this keeps the two senders from doubling it. */ + int entered; }; /* ---- wl_shm pool / buffer (custom, gbm-backed) ------------------------- */ @@ -185,8 +254,8 @@ struct surface { * the DRI bo registry is (host SharedArrayBuffer). So the client backs its * pool with a renderD128 dumb-bo and passes its prime-fd; we import that * prime-fd via gbm and map it, aliasing the same shared bytes. This is the - * "gbm_bo_import path for wl_shm" the plan names (§8.1). We handle the - * common single-buffer, offset-0, XRGB/ARGB8888 case. */ + * "gbm_bo_import path for wl_shm" the plan names (§8.1). Buffers may sit + * at any offset in the pool; the formats are XRGB/ARGB8888. */ struct shm_pool { int fd; int32_t size; @@ -197,6 +266,9 @@ struct shm_buffer { int32_t offset, width, height, stride; uint32_t format; struct gbm_bo *bo; /* lazily imported on first composite (CPU path) */ + int import_failed; /* the import is a property of the pool fd, so a + * failure is final — retrying it once per frame + * would flood the log and stall the desktop. */ void *map_data; uint32_t *pixels; /* shared mapping of the client's bytes */ uint32_t map_stride_px; @@ -212,6 +284,10 @@ struct shm_buffer { struct kwlctl_conn; /* one control-socket connection (defined with the IPC) */ +/* An output-space rectangle: a tile, or the work area left over once the + * anchored layer surfaces have taken their exclusive zones. */ +struct geom { int x, y, w, h; }; + struct compositor { struct wl_display *display; struct wl_event_loop *loop; @@ -221,14 +297,25 @@ struct compositor { uint32_t crtc_id; uint32_t connector_id; drmModeModeInfo mode; + /* Two grids. `pw`/`ph` are the mode: one unit per device pixel, and what + * every scanout, GBM bo, EGL surface and GL viewport is sized in. + * `width`/`height` are the logical grid clients lay out in — `pw`/`ph` + * divided by `scale`. They are equal at scale 1, which is why every + * layout site still reads width/height. */ + uint32_t pw, ph; uint32_t width, height; + /* wl_output scale: device pixels per logical pixel (WLC_SCALE, 1 when + * unset). A client that honours wl_surface.set_buffer_scale attaches a + * buffer this many times larger than its logical size and blits 1:1; one + * that ignores it is upscaled — soft, but correctly sized. */ + uint32_t scale; struct gbm_device *gbm; struct gbm_surface *gbm_surface; struct gbm_bo *displayed_bo; /* on-screen right now */ struct gbm_bo *pending_bo; /* flip queued, not yet complete */ int crtc_configured; /* SetCrtc done once */ - /* Pre-rendered desktop background (width × height, tightly packed). */ + /* Pre-rendered desktop background (pw × ph, tightly packed). */ uint32_t *wallpaper; /* Input. */ @@ -252,6 +339,13 @@ struct compositor { struct surface *kbd_focus; struct surface *ptr_focus; + /* Mapped layer surfaces, in map order; composited by layer, not by + * z-order, and never tiled. `usable` is the output minus their + * exclusive zones — the area retile() partitions. */ + struct surface *layers[MAX_LAYERS]; + int n_layers; + struct geom usable; + /* Interactive move grab (xdg_toplevel.move). */ struct surface *grab; double grab_dx, grab_dy; @@ -274,6 +368,10 @@ struct compositor { /* Bound seat resources (across all clients; routed per-client). */ struct wl_resource *keyboards[MAX_INPUT_RES]; struct wl_resource *pointers[MAX_INPUT_RES]; + /* Bound wl_output resources, for wl_surface.enter at map time — a + * DPI-aware client (foot) sizes its fonts only after entering an + * output. */ + struct wl_resource *outputs[MAX_INPUT_RES]; int client_count; int had_client; /* so we only exit after a client has actually connected */ @@ -284,6 +382,32 @@ struct compositor { static struct compositor g; +/* ---- theme ------------------------------------------------------------- */ + +/* The live palette plus the list of installed themes, so `kwlctl dispatch + * theme next` can cycle them the way Omarchy's theme-next binding does. The + * defaults are the look the desktop had before themes existed, which is what + * an install with no theme directory keeps. */ +static struct { + char name[32]; + uint32_t border_active; + uint32_t wallpaper_top, wallpaper_bottom; + char wallpaper_path[256]; /* KWLP raw image; "" = gradient */ + int gaps_in, gaps_out; + char installed[MAX_THEMES][32]; + int n_installed; + int current; /* index into installed, -1 when unthemed */ + char notify[256]; /* `notify =` config: spawned on a switch */ +} th = { + .name = "default", + .border_active = FOCUS_COLOR, + .wallpaper_top = 0xff10121au, + .wallpaper_bottom = 0xff1b2233u, + .gaps_in = 8, + .gaps_out = 12, + .current = -1, +}; + /* ---- GPU compositing state (GLES via renderD128) ----------------------- */ static struct { @@ -293,7 +417,8 @@ static struct { EGLSurface srf; GLuint prog; GLint loc_rect; /* vec4 NDC x0,y0(top),x1,y1(bottom) */ - GLint loc_use_tex; /* 1 = sample u_tex, 0 = flat u_color */ + GLint loc_uv; /* vec4 texture uv0.xy,uv1.xy */ + GLint loc_use_tex; /* 0 = flat u_color, 1 = opaque tex, 2 = blend */ GLint loc_color; unsigned wallpaper_tex; } glc; @@ -318,12 +443,25 @@ static void slot_remove(struct wl_resource **slots, struct wl_resource *r) { static void schedule_repaint(void); static void kbd_set_focus(struct surface *s); static void ptr_refresh_focus(void); +static void send_surface_enter(struct surface *s); +static int layer_in_band(const struct surface *s, int above); + +/* A v5 pointer client applies state on the frame event; end every burst. */ +static void ptr_send_frame(struct wl_resource *p) { + if (wl_resource_get_version(p) >= WL_POINTER_FRAME_SINCE_VERSION) + wl_pointer_send_frame(p); +} +static int theme_switch(const char *arg); static void kwlctl_emit(const char *fmt, ...); static void kwlctl_exec(char *args); +static void workspaces_sync(void); /* A surface participates in compositing, input, and tiling only when it is - * mapped AND on the active workspace. */ + * mapped AND on the active workspace. A layer surface is a shell component, + * so it shows on every workspace. A subsurface shows with its parent. */ static int surface_visible(const struct surface *s) { + if (s->parent) return s->mapped && surface_visible(s->parent); + if (s->layer_surface) return s->mapped; return s->mapped && s->workspace == g.active_ws; } @@ -349,21 +487,38 @@ static void zorder_remove(struct surface *s) { for (; i + 1 < g.n_surfaces; i++) g.zorder[i] = g.zorder[i + 1]; g.n_surfaces--; } +/* `g.zorder` is the window stack; a layer surface belongs to `g.layers`, where + * layer_place() owns its geometry and its layer fixes its depth. surface_at() + * returns layer surfaces so a click reaches the bar, and click-to-focus raises + * whatever it returns — so clicking Waybar would otherwise push the bar into + * the window stack, where retile() hands it a tile: the whole usable area + * whenever the workspace holds no windows. */ static void zorder_raise(struct surface *s) { + if (s->layer_surface) return; if (g.n_surfaces && g.zorder[g.n_surfaces - 1] == s) return; zorder_remove(s); zorder_add(s); schedule_repaint(); } -/* Topmost mapped surface containing the output-space point, or NULL. */ +static int surface_contains(const struct surface *s, double x, double y) { + return x >= s->x && x < s->x + s->w && y >= s->y && y < s->y + s->h; +} + +/* Topmost mapped surface containing the output-space point, or NULL. The + * compositing order decides: overlay/top layer surfaces first, then windows, + * then the background/bottom layers. */ static struct surface *surface_at(double x, double y) { + for (int i = g.n_layers - 1; i >= 0; i--) + if (layer_in_band(g.layers[i], 1) && surface_contains(g.layers[i], x, y)) + return g.layers[i]; for (int i = g.n_surfaces - 1; i >= 0; i--) { struct surface *s = g.zorder[i]; - if (!surface_visible(s)) continue; - if (x >= s->x && x < s->x + s->w && y >= s->y && y < s->y + s->h) - return s; + if (surface_visible(s) && surface_contains(s, x, y)) return s; } + for (int i = g.n_layers - 1; i >= 0; i--) + if (layer_in_band(g.layers[i], 0) && surface_contains(g.layers[i], x, y)) + return g.layers[i]; return NULL; } @@ -373,12 +528,12 @@ static struct surface *surface_at(double x, double y) { * compositor's job in Wayland (clients cannot position themselves); a rule * table keyed on app_id is the same mechanism real WMs use for window * rules. x ≥ 0 anchors to the LEFT edge; x < 0 anchors to the RIGHT edge - * (window's left edge at width + x). The mode width follows the host - * display's aspect ratio (1440..3840 at 1080 tall), so edge anchoring - * spreads the demo across the full desktop; at the historical 1920-wide - * mode the resolved coordinates are exactly the original fixed layout - * (wlclock 1240, wlpaint 1080). All results are clamped, so narrow modes - * still get sane spots. */ + * (window's left edge at the logical width + x), so edge anchoring spreads + * the demo across the full desktop whatever the mode is; on a 1920-wide + * logical grid the resolved coordinates are exactly the original fixed + * layout (wlclock 1240, wlpaint 1080). The logical grid can be narrower + * than a rule's offset, so every result is clamped into the work area + * below. */ static const struct { const char *app_id; int x, y; } placement_rules[] = { { "wlterm", 90, 120 }, { "wlclock", 1240 - 1920, 110 }, /* width - 680 */ @@ -387,32 +542,168 @@ static const struct { const char *app_id; int x, y; } placement_rules[] = { static void place_surface(struct surface *s) { static int cascade; - int x = -1, y = -1; + int x = 0, y = 0, matched = 0; for (size_t i = 0; i < sizeof(placement_rules) / sizeof(placement_rules[0]); i++) { if (strcmp(s->app_id, placement_rules[i].app_id) == 0) { x = placement_rules[i].x; y = placement_rules[i].y; - /* Right-anchored rule: resolve against the live mode width. - * Minimum mode width is 1440, so resolved x stays ≥ 600 and - * never falls through to the cascade branch below. */ + /* Right-anchored rule: resolve against the live logical width. + * A grid narrower than the offset resolves left of 0, which the + * work-area clamp below pulls back on screen. */ if (x < 0) x += (int)g.width; + matched = 1; break; } } - if (x < 0) { + if (!matched) { x = 160 + (cascade % 5) * 72; y = 120 + (cascade % 5) * 56; cascade++; } - if (x + s->w > (int)g.width) x = (int)g.width - s->w - 16; - if (y + s->h > (int)g.height) y = (int)g.height - s->h - 16; - if (x < 0) x = 0; - if (y < 0) y = 0; + /* Clamp into the work area, so a floating window never opens under an + * anchored bar (with no layer surfaces the work area IS the output). */ + if (x + s->w > g.usable.x + g.usable.w) x = g.usable.x + g.usable.w - s->w - 16; + if (y + s->h > g.usable.y + g.usable.h) y = g.usable.y + g.usable.h - s->h - 16; + if (x < g.usable.x) x = g.usable.x; + if (y < g.usable.y) y = g.usable.y; s->x = x; s->y = y; s->placed = 1; } +/* ---- wlr-layer-shell arrangement ---------------------------------------- */ + +/* Layer surfaces are shell components (bar, launcher, wallpaper), not windows: + * the compositor anchors each one to output edges and lets it reserve an + * exclusive strip that windows must not cover. Everything below computes that + * from the double-buffered state the client set, in the protocol's order — + * background first, overlay last — so an earlier layer's exclusive zone + * shrinks the area a later one anchors against. */ + +static void layer_add(struct surface *s) { + if (g.n_layers < MAX_LAYERS) g.layers[g.n_layers++] = s; +} + +static void layer_remove(struct surface *s) { + int i = 0; + while (i < g.n_layers && g.layers[i] != s) i++; + if (i == g.n_layers) return; + for (; i + 1 < g.n_layers; i++) g.layers[i] = g.layers[i + 1]; + g.n_layers--; +} + +/* The topmost mapped layer surface that asked for exclusive keyboard focus + * (the launcher), or NULL. Overlay wins over top, then map order. */ +static struct surface *layer_kb_grab(void) { + struct surface *best = NULL; + for (int i = 0; i < g.n_layers; i++) { + struct surface *s = g.layers[i]; + if (!s->mapped || + s->kb_interactive != ZWLR_LAYER_SURFACE_V1_KEYBOARD_INTERACTIVITY_EXCLUSIVE) + continue; + if (!best || s->layer >= best->layer) best = s; + } + return best; +} + +/* Anchor one layer surface inside `area` and, when it reserves an exclusive + * zone on a single edge, shrink `area` by that strip for the surfaces + * arranged after it and for the window layout. */ +static void layer_place(struct surface *s, struct geom *area) { + const uint32_t top = ZWLR_LAYER_SURFACE_V1_ANCHOR_TOP; + const uint32_t bottom = ZWLR_LAYER_SURFACE_V1_ANCHOR_BOTTOM; + const uint32_t left = ZWLR_LAYER_SURFACE_V1_ANCHOR_LEFT; + const uint32_t right = ZWLR_LAYER_SURFACE_V1_ANCHOR_RIGHT; + + int span_x = (s->anchor & left) && (s->anchor & right); + int span_y = (s->anchor & top) && (s->anchor & bottom); + + /* A 0 dimension means "you decide", which the protocol only allows when + * the surface spans that axis: it then fills the area minus its margins. */ + int w = s->req_w > 0 ? s->req_w + : area->w - s->margin_left - s->margin_right; + int h = s->req_h > 0 ? s->req_h + : area->h - s->margin_top - s->margin_bottom; + if (w < 1) w = 1; + if (h < 1) h = 1; + if (w > area->w) w = area->w; + if (h > area->h) h = area->h; + + int x, y; + if ((s->anchor & left) && !span_x) x = area->x + s->margin_left; + else if ((s->anchor & right) && !span_x) x = area->x + area->w - w - s->margin_right; + else x = area->x + (area->w - w) / 2; + if ((s->anchor & top) && !span_y) y = area->y + s->margin_top; + else if ((s->anchor & bottom) && !span_y) y = area->y + area->h - h - s->margin_bottom; + else y = area->y + (area->h - h) / 2; + + s->x = x; + s->y = y; + s->w = w; + s->h = h; + s->placed = 1; + + /* Only an edge-anchored strip of a MAPPED surface reserves space; a corner + * or a full-screen anchor is treated as zero, per the protocol. A surface + * that has a role but has never committed a buffer holds nothing back, so + * a client that dies mid-handshake cannot strand a strip of the desktop. */ + if (s->exclusive_zone <= 0 || !s->mapped) return; + if (span_x && !span_y && (s->anchor & top)) { + int cut = s->exclusive_zone + s->margin_top; + area->y += cut; + area->h -= cut; + } else if (span_x && !span_y && (s->anchor & bottom)) { + area->h -= s->exclusive_zone + s->margin_bottom; + } else if (span_y && !span_x && (s->anchor & left)) { + int cut = s->exclusive_zone + s->margin_left; + area->x += cut; + area->w -= cut; + } else if (span_y && !span_x && (s->anchor & right)) { + area->w -= s->exclusive_zone + s->margin_right; + } + if (area->w < 1) area->w = 1; + if (area->h < 1) area->h = 1; +} + +static void retile(void); + +/* Re-anchor every layer surface, recompute the window work area, and send each + * client the size it must render at. Runs whenever a layer surface appears, + * changes state, or goes away. */ +static void layers_arrange(void) { + struct geom area = { 0, 0, (int)g.width, (int)g.height }; + + for (uint32_t layer = ZWLR_LAYER_SHELL_V1_LAYER_BACKGROUND; + layer <= ZWLR_LAYER_SHELL_V1_LAYER_OVERLAY; layer++) { + for (int i = 0; i < g.n_layers; i++) { + struct surface *s = g.layers[i]; + if (s->layer != layer) continue; + int32_t prev_w = s->w, prev_h = s->h; + layer_place(s, &area); + int resized = (s->w != prev_w || s->h != prev_h); + if (!s->layer_configured || resized) { + zwlr_layer_surface_v1_send_configure( + s->layer_surface, wl_display_next_serial(g.display), + (uint32_t)s->w, (uint32_t)s->h); + s->layer_configured = 1; + } + /* The marker says the strip is live. A layer surface reserves + * nothing until it maps, so announcing it at configure time + * would put it ahead of the windows re-tiling under it. */ + if (s->mapped && (!s->layer_announced || resized)) { + printf("LAYER ns=%s layer=%u x=%d y=%d w=%d h=%d\n", s->app_id, + s->layer, s->x, s->y, s->w, s->h); + fflush(stdout); + s->layer_announced = 1; + } + } + } + + g.usable = area; + retile(); + schedule_repaint(); +} + /* ---- tiling layout ------------------------------------------------------ */ /* FLOATING (default, zero-initialised) keeps the app_id placement rules that @@ -420,23 +711,21 @@ static void place_surface(struct surface *s) { * becomes the tiling mode of the same compositor. Selected by WLC_LAYOUT. */ enum layout_mode { LAYOUT_FLOATING = 0, LAYOUT_DWINDLE }; -struct geom { int x, y, w, h; }; - /* Gaps in output pixels. OUTER insets the whole tiling area from the screen - * edge; INNER separates adjacent windows. Hardcoded for v1 — PR17 makes them - * theme-driven. */ -#define TILE_GAP_OUTER 12 -#define TILE_GAP_INNER 8 - -/* Pure dwindle tiler: at each step split the remaining region along its LONGER - * side (Hyprland's default). Pure — reads only its arguments — so the smoke - * gate predicts the exact partition and checks it against the emitted geometry. */ + * edge; INNER separates adjacent windows. Theme-driven (theme_apply). */ +static int tile_gap_outer = 12; +static int tile_gap_inner = 8; + +/* Dwindle tiler: at each step split the remaining region along its LONGER + * side (Hyprland's default). Its only inputs are the arguments and the two + * gap sizes, so the smoke gate predicts the exact partition and checks it + * against the emitted geometry. */ static void compute_tiling(struct geom area, int n, struct geom *out) { if (n <= 0) return; - area.x += TILE_GAP_OUTER; - area.y += TILE_GAP_OUTER; - area.w -= 2 * TILE_GAP_OUTER; - area.h -= 2 * TILE_GAP_OUTER; + area.x += tile_gap_outer; + area.y += tile_gap_outer; + area.w -= 2 * tile_gap_outer; + area.h -= 2 * tile_gap_outer; if (area.w < 1) area.w = 1; if (area.h < 1) area.h = 1; @@ -445,17 +734,17 @@ static void compute_tiling(struct geom area, int n, struct geom *out) { if (i == n - 1) { out[i] = region; break; } struct geom near = region, rest = region; if (region.w >= region.h) { /* wider than tall: split L|R */ - int half = (region.w - TILE_GAP_INNER) / 2; + int half = (region.w - tile_gap_inner) / 2; if (half < 1) half = 1; near.w = half; - rest.x = region.x + half + TILE_GAP_INNER; - rest.w = region.w - half - TILE_GAP_INNER; + rest.x = region.x + half + tile_gap_inner; + rest.w = region.w - half - tile_gap_inner; } else { /* taller than wide: split T/B */ - int half = (region.h - TILE_GAP_INNER) / 2; + int half = (region.h - tile_gap_inner) / 2; if (half < 1) half = 1; near.h = half; - rest.y = region.y + half + TILE_GAP_INNER; - rest.h = region.h - half - TILE_GAP_INNER; + rest.y = region.y + half + tile_gap_inner; + rest.h = region.h - half - tile_gap_inner; } out[i] = near; region = rest; @@ -476,8 +765,7 @@ static void retile(void) { if (n == 0) return; struct geom geoms[MAX_SURFACES]; - struct geom area = { 0, 0, (int)g.width, (int)g.height }; - compute_tiling(area, n, geoms); + compute_tiling(g.usable, n, geoms); for (int i = 0; i < n; i++) { struct surface *s = tiled[i]; @@ -536,8 +824,44 @@ static void surface_set_opaque_region(struct wl_client *c, struct wl_resource *r struct wl_resource *reg) {} static void surface_set_input_region(struct wl_client *c, struct wl_resource *r, struct wl_resource *reg) {} +/* The on-screen size of a committed buffer, per wp_viewporter's surface-size + * rules: the destination wins, then an integral source rect, then the buffer + * dims. */ +static void surface_committed_size(struct surface *s, struct shm_buffer *b, + int32_t *w, int32_t *h) { + if (s->vp_dst_w > 0) { + *w = s->vp_dst_w; + *h = s->vp_dst_h; + return; + } + if (s->vp_src_w > 0) { + *w = wl_fixed_to_int(s->vp_src_w); + *h = wl_fixed_to_int(s->vp_src_h); + return; + } + /* Without a viewport the surface is its buffer divided by the scale the + * client declared: a scale-2 client attaches twice the pixels for the + * same logical box. wp_viewporter's destination is already logical, so + * both returns above are scale-independent. */ + *w = b->width / s->buffer_scale; + *h = b->height / s->buffer_scale; +} + static void surface_commit(struct wl_client *c, struct wl_resource *r) { struct surface *s = wl_resource_get_user_data(r); + /* A layer surface's shell state — size, anchor, margins, exclusive zone — + * is double-buffered like every other surface property: the requests are + * recorded and take effect here. The first commit carries no buffer and + * exists only to fetch the configure that says what size to render, but it + * is not the last one that matters: mako sends a second set_size once it + * knows the output scale, and it will not draw until the configure for that + * one comes back. */ + if (s->layer_surface && (s->layer_dirty || !s->layer_configured)) { + s->layer_dirty = 0; + send_surface_enter(s); + layers_arrange(); + } + s->buffer_scale = s->pending_buffer_scale; if (!s->pending_buffer) { schedule_repaint(); return; } /* Apply double-buffered state: the pending attach becomes current. The @@ -551,30 +875,98 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) { struct shm_buffer *b = wl_resource_get_user_data(s->buffer); if (b) { - s->w = b->width; - s->h = b->height; + int32_t w, h; + surface_committed_size(s, b, &w, &h); + /* A layer surface renders sharp on the same terms as a window, so the + * marker names the client and covers both roles. The first commit + * always reports (reported_scale starts at 0), because the scale a + * client picks for its FIRST frame is the one that decides whether a + * short-lived surface is ever sharp. */ + if (s->buffer_scale != s->reported_scale) { + s->reported_scale = s->buffer_scale; + printf("BUFFER_SCALE app=%s scale=%d bw=%d bh=%d w=%d h=%d\n", + s->app_id, s->buffer_scale, b->width, b->height, w, h); + fflush(stdout); + } + /* The compositor dictates a layer surface's box, so its geometry comes + * from the arrangement, not from whatever the client attached. */ + if (!s->layer_surface) { + if (s->viewport && (w != s->w || h != s->h)) { + printf("VIEWPORT bw=%d bh=%d w=%d h=%d\n", + b->width, b->height, w, h); + fflush(stdout); + } + s->w = w; + s->h = h; + } b->gl_dirty = 1; /* GPU path re-uploads this buffer's texture */ } + /* A subsurface maps with its buffer and rides its parent — no tiling, + * no focus, no z-order entry of its own. */ + if (s->parent) { + s->mapped = 1; + schedule_repaint(); + return; + } + + if (s->layer_surface) { + if (!s->mapped) { + s->mapped = 1; + send_surface_enter(s); + /* Now that it is mapped its exclusive zone counts, so the windows + * below re-tile around it. */ + layers_arrange(); + /* A launcher-style surface asks for the keyboard and gets it for + * as long as it lives. */ + if (s->kb_interactive == + ZWLR_LAYER_SURFACE_V1_KEYBOARD_INTERACTIVITY_EXCLUSIVE) + kbd_set_focus(s); + ptr_refresh_focus(); + } + schedule_repaint(); + return; + } + + /* Only the xdg_toplevel role makes a surface a window. A client's cursor + * surface (wl_pointer.set_cursor, which we accept and ignore) carries a + * buffer under no role: Waybar's would otherwise map, take the keyboard, + * and claim a tile. */ + if (!s->xdg_toplevel) { schedule_repaint(); return; } + if (!s->mapped) { s->mapped = 1; + send_surface_enter(s); if (!s->workspace) s->workspace = g.active_ws; /* opens on the visible ws */ /* Tiling dictates geometry for every window in retile(); only the * floating desktop places individually by app_id. */ if (g.layout == LAYOUT_FLOATING && !s->placed) place_surface(s); zorder_raise(s); /* A newly mapped window takes keyboard focus (and pointer focus if - * the cursor happens to be over it). */ - kbd_set_focus(s); + * the cursor happens to be over it) — unless a layer surface holds + * the keyboard exclusively. A window that maps while the launcher + * is open would otherwise swallow the keys typed into it. */ + if (!layer_kb_grab()) kbd_set_focus(s); ptr_refresh_focus(); retile(); /* no-op when floating */ + kwlctl_emit("openwindow>>%p,%d,%s,%s", (void *)s, s->workspace, + s->app_id, s->title); + workspaces_sync(); } schedule_repaint(); } static void surface_set_buffer_transform(struct wl_client *c, struct wl_resource *r, int32_t t) {} static void surface_set_buffer_scale(struct wl_client *c, struct wl_resource *r, - int32_t s) {} + int32_t scale) { + if (scale < 1) { + wl_resource_post_error(r, WL_SURFACE_ERROR_INVALID_SCALE, + "buffer scale %d is not positive", scale); + return; + } + struct surface *s = wl_resource_get_user_data(r); + if (s) s->pending_buffer_scale = scale; +} static void surface_damage_buffer(struct wl_client *c, struct wl_resource *r, int32_t x, int32_t y, int32_t w, int32_t h) {} static void surface_offset(struct wl_client *c, struct wl_resource *r, @@ -602,19 +994,52 @@ static void surface_resource_destroy(struct wl_resource *r) { break; } zorder_remove(s); + /* A client may destroy the wl_surface before its layer_surface; drop the + * back-reference so that resource's destroy handler doesn't touch freed + * memory. Same for a subsurface role, and for any children still glued + * to this surface as their parent. */ + int was_layer = s->layer_surface != NULL; + if (was_layer) { + wl_resource_set_user_data(s->layer_surface, NULL); + layer_remove(s); + } + if (s->subsurface) wl_resource_set_user_data(s->subsurface, NULL); + if (s->viewport) wl_resource_set_user_data(s->viewport, NULL); + if (s->fractional_scale) + wl_resource_set_user_data(s->fractional_scale, NULL); + for (int i = 0; i < g.n_all_surfaces; i++) { + struct surface *c = g.all_surfaces[i]; + if (c->parent != s) continue; + c->parent = NULL; + c->mapped = 0; + } if (g.kbd_focus == s) { g.kbd_focus = NULL; /* Hand focus to the new top window on the visible workspace, if any. */ - kbd_set_focus(topmost_on_ws(g.active_ws)); + struct surface *grab = layer_kb_grab(); + kbd_set_focus(grab ? grab : topmost_on_ws(g.active_ws)); } if (g.ptr_focus == s) g.ptr_focus = NULL; if (g.grab == s) g.grab = NULL; /* Clearing our references avoids a dangling send after destroy; the * callbacks themselves are owned by the client and freed with it. */ s->n_frame_cbs = 0; + /* Pending presentation feedbacks would dangle on `s` through their + * destructor's user_data; discard them now, before the free. */ + while (s->n_feedbacks > 0) { + struct wl_resource *fb = s->feedbacks[--s->n_feedbacks]; + wl_resource_set_user_data(fb, NULL); + wp_presentation_feedback_send_discarded(fb); + wl_resource_destroy(fb); + } + int was_window = s->mapped && !was_layer && !s->parent; + if (was_window) kwlctl_emit("closewindow>>%p", (void *)s); free(s); - /* A closed window frees its slice back to the remaining tiles. */ - retile(); /* no-op when floating */ + /* A closed window frees its slice back to the remaining tiles; a closed + * layer surface additionally frees its exclusive strip. */ + if (was_layer) layers_arrange(); + else retile(); /* no-op when floating */ + if (was_window) workspaces_sync(); schedule_repaint(); } @@ -628,28 +1053,43 @@ static void shm_pool_free(struct shm_pool *p) { } /* Import + map the client's dumb-bo on first use; the mapping aliases the - * shared host buffer, so later reads see the client's latest pixels. */ + * shared host buffer, so later reads see the client's latest pixels. A + * buffer may sit at any offset in the pool (GTK packs several there), so + * the import covers every row up to the buffer's end and the base pointer + * walks to the buffer's own first row. */ static uint32_t *shm_buffer_pixels(struct shm_buffer *b, uint32_t *stride_px) { + if (b->import_failed) return NULL; if (!b->bo) { + uint32_t rows = (uint32_t)((b->offset + b->stride * b->height + + b->stride - 1) / b->stride); struct gbm_import_fd_data d = { .fd = b->pool->fd, - .width = (uint32_t)b->width, - .height = (uint32_t)b->height, + .width = (uint32_t)(b->stride / 4), + .height = rows, .stride = (uint32_t)b->stride, .format = DRM_FORMAT_XRGB8888, }; b->bo = gbm_bo_import(g.gbm, GBM_BO_IMPORT_FD, &d, GBM_BO_USE_SCANOUT | GBM_BO_USE_LINEAR); - if (!b->bo) { perror("gbm_bo_import"); return NULL; } + if (!b->bo) { + perror("gbm_bo_import"); + b->import_failed = 1; + return NULL; + } uint32_t ms = 0; - b->pixels = gbm_bo_map(b->bo, 0, 0, b->width, b->height, 0, &ms, - &b->map_data); - if (!b->pixels) { + unsigned char *base = gbm_bo_map(b->bo, 0, 0, d.width, rows, 0, &ms, + &b->map_data); + if (!base) { perror("gbm_bo_map"); gbm_bo_destroy(b->bo); b->bo = NULL; + b->import_failed = 1; return NULL; } + /* The mapping's own stride is what separates its rows, so the + * client's offset is walked as whole rows plus the remainder. */ + b->pixels = (uint32_t *)(base + (size_t)(b->offset / b->stride) * ms + + (size_t)(b->offset % b->stride)); b->map_stride_px = ms / 4; } *stride_px = b->map_stride_px; @@ -690,9 +1130,12 @@ static void pool_create_buffer(struct wl_client *client, struct wl_resource *r, int32_t height, int32_t stride, uint32_t format) { struct shm_pool *p = wl_resource_get_user_data(r); - if (offset != 0) { /* one buffer at the base of the pool */ + /* GTK keeps several buffers in one pool and creates each at its own + * offset, so only a buffer that leaves the pool is a protocol error. */ + if (offset < 0 || width <= 0 || height <= 0 || stride < width * 4 || + (int64_t)offset + (int64_t)stride * height > p->size) { wl_resource_post_error(r, WL_SHM_ERROR_INVALID_STRIDE, - "compositor supports only offset 0"); + "buffer does not fit in the pool"); return; } if (format != WL_SHM_FORMAT_XRGB8888 && format != WL_SHM_FORMAT_ARGB8888) { @@ -1025,6 +1468,7 @@ static void compositor_create_surface(struct wl_client *client, struct surface *s = calloc(1, sizeof(*s)); if (!s) { wl_client_post_no_memory(client); return; } s->client = client; + s->buffer_scale = s->pending_buffer_scale = 1; s->resource = wl_resource_create(client, &wl_surface_interface, wl_resource_get_version(resource), id); if (!s->resource) { free(s); wl_client_post_no_memory(client); return; } @@ -1080,8 +1524,15 @@ static void toplevel_set_parent(struct wl_client *c, struct wl_resource *r, struct wl_resource *parent) {} static void toplevel_set_title(struct wl_client *c, struct wl_resource *r, const char *title) { - /* Clients draw their own titlebars (CSD); the server has no use for - * the title. */ + /* Clients draw their own titlebars (CSD); the server only relays the + * title to the bar over the IPC sockets. */ + struct surface *s = wl_resource_get_user_data(r); + if (!s || !title) return; + if (strncmp(s->title, title, sizeof(s->title) - 1) == 0) return; + snprintf(s->title, sizeof(s->title), "%s", title); + if (!s->mapped) return; + kwlctl_emit("windowtitle>>%p", (void *)s); + kwlctl_emit("windowtitlev2>>%p,%s", (void *)s, s->title); } static void toplevel_set_app_id(struct wl_client *c, struct wl_resource *r, const char *app_id) { @@ -1109,9 +1560,11 @@ static void toplevel_move(struct wl_client *c, struct wl_resource *r, uint32_t ser = wl_display_next_serial(g.display); for (int i = 0; i < MAX_INPUT_RES; i++) if (g.pointers[i] && - wl_resource_get_client(g.pointers[i]) == g.ptr_focus->client) + wl_resource_get_client(g.pointers[i]) == g.ptr_focus->client) { wl_pointer_send_leave(g.pointers[i], ser, g.ptr_focus->resource); + ptr_send_frame(g.pointers[i]); + } g.ptr_focus = NULL; } } @@ -1157,7 +1610,7 @@ static void xdg_surface_get_toplevel(struct wl_client *client, client, &xdg_toplevel_interface, wl_resource_get_version(resource), id); if (!tl) { wl_client_post_no_memory(client); return; } wl_resource_set_implementation(tl, &toplevel_impl, s, NULL); - if (s) s->xdg_toplevel = tl; + if (s) { s->xdg_toplevel = tl; send_surface_enter(s); } /* Advertise a suggested size of 0x0 ("you decide") plus the initial * configure. The window is not mapped until the client acks and @@ -1281,6 +1734,143 @@ static void decoration_mgr_bind(struct wl_client *client, void *data, wl_resource_set_implementation(r, &decoration_mgr_impl, NULL, NULL); } +/* ====================================================================== */ +/* zwlr_layer_shell_v1 — the shell-component protocol (PR15) */ +/* ====================================================================== */ + +/* This is the protocol every desktop shell piece speaks: our kbar and + * klauncher, and upstream Waybar / mako when they land. State set through the + * requests below is applied on the next wl_surface.commit, matching the + * protocol's double-buffering rule: the ones that move the box mark the + * surface dirty and that commit re-runs layers_arrange. Every commit, not only + * the first — a client renegotiates its size whenever its content changes. */ + +static void layer_surface_set_size(struct wl_client *c, struct wl_resource *r, + uint32_t w, uint32_t h) { + struct surface *s = wl_resource_get_user_data(r); + s->req_w = (int32_t)w; + s->req_h = (int32_t)h; + s->layer_dirty = 1; +} +static void layer_surface_set_anchor(struct wl_client *c, struct wl_resource *r, + uint32_t anchor) { + struct surface *s = wl_resource_get_user_data(r); + s->anchor = anchor; + s->layer_dirty = 1; +} +static void layer_surface_set_exclusive_zone(struct wl_client *c, + struct wl_resource *r, + int32_t zone) { + struct surface *s = wl_resource_get_user_data(r); + s->exclusive_zone = zone; + s->layer_dirty = 1; +} +static void layer_surface_set_margin(struct wl_client *c, struct wl_resource *r, + int32_t top, int32_t right, + int32_t bottom, int32_t left) { + struct surface *s = wl_resource_get_user_data(r); + s->margin_top = top; + s->margin_right = right; + s->margin_bottom = bottom; + s->margin_left = left; + s->layer_dirty = 1; +} +static void layer_surface_set_keyboard_interactivity(struct wl_client *c, + struct wl_resource *r, + uint32_t interactivity) { + struct surface *s = wl_resource_get_user_data(r); + s->kb_interactive = interactivity; +} +/* Popups need xdg_popup, which the compositor does not implement yet (PR16). */ +static void layer_surface_get_popup(struct wl_client *c, struct wl_resource *r, + struct wl_resource *popup) {} +static void layer_surface_ack_configure(struct wl_client *c, + struct wl_resource *r, + uint32_t serial) {} +static void layer_surface_destroy(struct wl_client *c, struct wl_resource *r) { + wl_resource_destroy(r); +} +static void layer_surface_set_layer(struct wl_client *c, struct wl_resource *r, + uint32_t layer) { + struct surface *s = wl_resource_get_user_data(r); + s->layer = layer; + s->layer_dirty = 1; +} +static const struct zwlr_layer_surface_v1_interface layer_surface_impl = { + .set_size = layer_surface_set_size, + .set_anchor = layer_surface_set_anchor, + .set_exclusive_zone = layer_surface_set_exclusive_zone, + .set_margin = layer_surface_set_margin, + .set_keyboard_interactivity = layer_surface_set_keyboard_interactivity, + .get_popup = layer_surface_get_popup, + .ack_configure = layer_surface_ack_configure, + .destroy = layer_surface_destroy, + .set_layer = layer_surface_set_layer, +}; + +/* The wl_surface keeps its own resource; dropping the layer role unmaps it + * and hands the reserved strip back to the windows. */ +static void layer_surface_resource_destroy(struct wl_resource *r) { + struct surface *s = wl_resource_get_user_data(r); + if (!s) return; + s->layer_surface = NULL; + s->mapped = 0; + layer_remove(s); + if (g.kbd_focus == s) { + g.kbd_focus = NULL; + struct surface *grab = layer_kb_grab(); + kbd_set_focus(grab ? grab : topmost_on_ws(g.active_ws)); + } + if (g.ptr_focus == s) g.ptr_focus = NULL; + s->n_frame_cbs = 0; + layers_arrange(); +} + +static void layer_shell_get_layer_surface(struct wl_client *client, + struct wl_resource *resource, + uint32_t id, + struct wl_resource *surface, + struct wl_resource *output, + uint32_t layer, + const char *ns) { + struct surface *s = wl_resource_get_user_data(surface); + if (layer > ZWLR_LAYER_SHELL_V1_LAYER_OVERLAY) { + wl_resource_post_error(resource, ZWLR_LAYER_SHELL_V1_ERROR_INVALID_LAYER, + "invalid layer %u", layer); + return; + } + struct wl_resource *r = wl_resource_create( + client, &zwlr_layer_surface_v1_interface, + wl_resource_get_version(resource), id); + if (!r) { wl_client_post_no_memory(client); return; } + wl_resource_set_implementation(r, &layer_surface_impl, s, + layer_surface_resource_destroy); + s->layer_surface = r; + s->layer = layer; + /* Joins the arrangement now, not at map time: the client's first commit + * carries no buffer and exists only to fetch the configure that tells it + * what size to render, so it must already be in the list. */ + layer_add(s); + /* The namespace names the component ("bar", "launcher"); reuse app_id so + * kwlctl output and the LAYER marker identify it like any other client. */ + snprintf(s->app_id, sizeof(s->app_id), "%s", ns ? ns : "layer"); +} + +static void layer_shell_destroy(struct wl_client *c, struct wl_resource *r) { + wl_resource_destroy(r); +} +static const struct zwlr_layer_shell_v1_interface layer_shell_impl = { + .get_layer_surface = layer_shell_get_layer_surface, + .destroy = layer_shell_destroy, +}; +static void layer_shell_bind(struct wl_client *client, void *data, + uint32_t version, uint32_t id) { + struct wl_resource *r = wl_resource_create( + client, &zwlr_layer_shell_v1_interface, version, id); + if (!r) { wl_client_post_no_memory(client); return; } + wl_resource_set_implementation(r, &layer_shell_impl, NULL, NULL); +} + /* ====================================================================== */ /* wl_seat / wl_keyboard / wl_pointer */ /* ====================================================================== */ @@ -1371,6 +1961,12 @@ static void kbd_set_focus(struct surface *s) { g.kbd_focus->resource); } g.kbd_focus = s; + /* Nothing focused, or a shell component took the keyboard: either way no + * window is active, and the bar clears its title. */ + if (!s || s->layer_surface) { + kwlctl_emit("activewindow>>,"); + kwlctl_emit("activewindowv2>>"); + } if (!s) return; struct wl_array keys; wl_array_init(&keys); @@ -1383,7 +1979,9 @@ static void kbd_set_focus(struct surface *s) { } wl_array_release(&keys); schedule_repaint(); /* focus border moved */ - kwlctl_emit("activewindow>>%s", s->app_id); + if (s->layer_surface) return; + kwlctl_emit("activewindow>>%s,%s", s->app_id, s->title); + kwlctl_emit("activewindowv2>>%p", (void *)s); /* Observable focus marker: keyboard focus only moves to a window once its * first commit maps it (surface_commit), so this is the authoritative * "the window is now closeable by killactive" signal — distinct from a @@ -1400,9 +1998,11 @@ static void ptr_set_focus(struct surface *s) { if (g.ptr_focus) { for (int i = 0; i < MAX_INPUT_RES; i++) if (g.pointers[i] && - wl_resource_get_client(g.pointers[i]) == g.ptr_focus->client) + wl_resource_get_client(g.pointers[i]) == g.ptr_focus->client) { wl_pointer_send_leave(g.pointers[i], serial, g.ptr_focus->resource); + ptr_send_frame(g.pointers[i]); + } } g.ptr_focus = s; if (!s) return; @@ -1410,8 +2010,10 @@ static void ptr_set_focus(struct surface *s) { wl_fixed_t ly = wl_fixed_from_double(g.cursor_y - s->y); for (int i = 0; i < MAX_INPUT_RES; i++) if (g.pointers[i] && - wl_resource_get_client(g.pointers[i]) == s->client) + wl_resource_get_client(g.pointers[i]) == s->client) { wl_pointer_send_enter(g.pointers[i], serial, s->resource, lx, ly); + ptr_send_frame(g.pointers[i]); + } } static void ptr_refresh_focus(void) { @@ -1421,6 +2023,28 @@ static void ptr_refresh_focus(void) { /* ---- workspaces --------------------------------------------------------- */ +/* Hyprland's protocol has explicit workspace lifetime events; ours exist + * implicitly (occupied or active). Diff the derived set against the last + * broadcast one and emit create/destroy for the delta. */ +static void workspaces_sync(void) { + static uint32_t known; + uint32_t mask = 1u << g.active_ws; + for (int i = 0; i < g.n_surfaces; i++) + if (g.zorder[i]->mapped && g.zorder[i]->workspace) + mask |= 1u << g.zorder[i]->workspace; + for (int ws = 1; ws <= N_WORKSPACES; ws++) { + uint32_t bit = 1u << ws; + if ((mask & bit) && !(known & bit)) { + kwlctl_emit("createworkspace>>%d", ws); + kwlctl_emit("createworkspacev2>>%d,%d", ws, ws); + } else if (!(mask & bit) && (known & bit)) { + kwlctl_emit("destroyworkspace>>%d", ws); + kwlctl_emit("destroyworkspacev2>>%d,%d", ws, ws); + } + } + known = mask; +} + /* Show workspace `ws`: its surfaces become visible + tiled, the rest hide. * Focus restores to that workspace's topmost window (empty → no focus). */ static void switch_workspace(int ws) { @@ -1433,6 +2057,10 @@ static void switch_workspace(int ws) { printf("WORKSPACE active=%d\n", ws); fflush(stdout); kwlctl_emit("workspace>>%d", ws); + kwlctl_emit("workspacev2>>%d,%d", ws, ws); + kwlctl_emit("focusedmon>>virtual-0,%d", ws); + kwlctl_emit("focusedmonv2>>virtual-0,%d", ws); + workspaces_sync(); } /* Send the focused window to workspace `ws`; it vanishes from the current @@ -1450,6 +2078,9 @@ static void move_focus_to_workspace(int ws) { schedule_repaint(); printf("MOVE_TO_WS \"%s\" ws=%d\n", s->app_id, ws); fflush(stdout); + kwlctl_emit("movewindow>>%p,%d", (void *)s, ws); + kwlctl_emit("movewindowv2>>%p,%d,%d", (void *)s, ws, ws); + workspaces_sync(); } static void seat_get_pointer(struct wl_client *client, @@ -1461,11 +2092,13 @@ static void seat_get_pointer(struct wl_client *client, pointer_resource_destroy); slot_add(g.pointers, p); /* If this client's surface already holds pointer focus, enter it now. */ - if (g.ptr_focus && g.ptr_focus->client == client && g.ptr_focus->mapped) + if (g.ptr_focus && g.ptr_focus->client == client && g.ptr_focus->mapped) { wl_pointer_send_enter(p, wl_display_next_serial(g.display), g.ptr_focus->resource, wl_fixed_from_double(g.cursor_x - g.ptr_focus->x), wl_fixed_from_double(g.cursor_y - g.ptr_focus->y)); + ptr_send_frame(p); + } } static void seat_get_keyboard(struct wl_client *client, struct wl_resource *resource, uint32_t id) { @@ -1476,6 +2109,8 @@ static void seat_get_keyboard(struct wl_client *client, keyboard_resource_destroy); slot_add(g.keyboards, k); send_keymap(k); + if (wl_resource_get_version(k) >= WL_KEYBOARD_REPEAT_INFO_SINCE_VERSION) + wl_keyboard_send_repeat_info(k, 25, 400); if (g.kbd_focus && g.kbd_focus->client == client && g.kbd_focus->mapped) { uint32_t serial = wl_display_next_serial(g.display); struct wl_array keys; @@ -1505,11 +2140,13 @@ static const struct wl_seat_interface seat_impl = { static void seat_bind(struct wl_client *client, void *data, uint32_t version, uint32_t id) { struct wl_resource *r = - wl_resource_create(client, &wl_seat_interface, version, id); + wl_resource_create(client, &wl_seat_interface, (int)version, id); if (!r) { wl_client_post_no_memory(client); return; } wl_resource_set_implementation(r, &seat_impl, NULL, NULL); wl_seat_send_capabilities( r, WL_SEAT_CAPABILITY_KEYBOARD | WL_SEAT_CAPABILITY_POINTER); + if (version >= WL_SEAT_NAME_SINCE_VERSION) + wl_seat_send_name(r, "seat0"); } /* ====================================================================== */ @@ -1522,22 +2159,105 @@ static void output_release(struct wl_client *c, struct wl_resource *r) { static const struct wl_output_interface output_impl = { .release = output_release, }; +static void output_resource_destroy(struct wl_resource *r) { + slot_remove(g.outputs, r); +} static void output_bind(struct wl_client *client, void *data, uint32_t version, uint32_t id) { struct wl_resource *r = wl_resource_create(client, &wl_output_interface, version, id); if (!r) { wl_client_post_no_memory(client); return; } - wl_resource_set_implementation(r, &output_impl, NULL, NULL); - wl_output_send_geometry(r, 0, 0, (int32_t)g.width, (int32_t)g.height, + wl_resource_set_implementation(r, &output_impl, NULL, + output_resource_destroy); + slot_add(g.outputs, r); + /* Physical size 0x0 = unknown: this is a virtual connector, and a + * DPI-aware client (foot) derives its font size from mm — feeding it + * pixels as mm yields DPI 25.4 and a garbage font reload. */ + wl_output_send_geometry(r, 0, 0, 0, 0, WL_OUTPUT_SUBPIXEL_UNKNOWN, "Kandelo", "virtual-0", WL_OUTPUT_TRANSFORM_NORMAL); + /* wl_output.mode is in device pixels; wl_output.scale is how a client + * turns it into the logical grid xdg_output reports. */ wl_output_send_mode(r, WL_OUTPUT_MODE_CURRENT | WL_OUTPUT_MODE_PREFERRED, - (int32_t)g.width, (int32_t)g.height, 60000); + (int32_t)g.pw, (int32_t)g.ph, 60000); + if (version >= WL_OUTPUT_SCALE_SINCE_VERSION) + wl_output_send_scale(r, (int32_t)g.scale); + /* v4: the name a client keys config on (mako binds v4 uncondition- + * ally); matches the xdg_output name. */ + if (version >= WL_OUTPUT_NAME_SINCE_VERSION) + wl_output_send_name(r, "virtual-0"); + if (version >= WL_OUTPUT_DESCRIPTION_SINCE_VERSION) + wl_output_send_description(r, "Kandelo virtual output"); if (version >= WL_OUTPUT_DONE_SINCE_VERSION) wl_output_send_done(r); } +/* Tell a surface which output it is on. A DPI-aware client reads the output's + * scale from it: foot defers font sizing until it arrives, and mako picks the + * scale of its next buffer from it. So a role sends it as soon as the surface + * takes one — the desktop has a single output, and a surface with a role is on + * it. Waiting for the map is one frame too late: the buffer being mapped was + * already drawn at the wrong scale. Map sends it too, for a client that binds + * wl_output only after its surface has a role. */ +static void send_surface_enter(struct surface *s) { + if (s->entered) return; + for (int i = 0; i < MAX_INPUT_RES; i++) + if (g.outputs[i] && + wl_resource_get_client(g.outputs[i]) == s->client) { + wl_surface_send_enter(s->resource, g.outputs[i]); + s->entered = 1; + } +} + +/* ====================================================================== */ +/* zxdg_output_manager_v1: logical output geometry */ +/* ====================================================================== */ + +/* The single virtual output is fullscreen at (0,0), so the logical grid is + * the mode divided by the output scale — g.width/g.height. The geometry is + * fixed for the process lifetime, so each xdg_output is a one-shot burst + * with no tracking list. */ +static void xdg_output_destroy_req(struct wl_client *c, struct wl_resource *r) { + wl_resource_destroy(r); +} +static const struct zxdg_output_v1_interface xdg_output_impl = { + .destroy = xdg_output_destroy_req, +}; +static void xdg_output_mgr_destroy(struct wl_client *c, struct wl_resource *r) { + wl_resource_destroy(r); +} +static void xdg_output_mgr_get(struct wl_client *c, struct wl_resource *r, + uint32_t id, struct wl_resource *output) { + struct wl_resource *xo = wl_resource_create( + c, &zxdg_output_v1_interface, wl_resource_get_version(r), id); + if (!xo) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(xo, &xdg_output_impl, NULL, NULL); + zxdg_output_v1_send_logical_position(xo, 0, 0); + zxdg_output_v1_send_logical_size(xo, (int32_t)g.width, (int32_t)g.height); + if (wl_resource_get_version(xo) >= ZXDG_OUTPUT_V1_NAME_SINCE_VERSION) + zxdg_output_v1_send_name(xo, "virtual-0"); + if (wl_resource_get_version(xo) >= ZXDG_OUTPUT_V1_DESCRIPTION_SINCE_VERSION) + zxdg_output_v1_send_description(xo, "Kandelo virtual output"); + /* v3 deprecates xdg_output.done in favor of wl_output.done; a v1/v2 + * bind still expects it here. */ + if (wl_resource_get_version(xo) < 3) + zxdg_output_v1_send_done(xo); + else if (wl_resource_get_version(output) >= WL_OUTPUT_DONE_SINCE_VERSION) + wl_output_send_done(output); +} +static const struct zxdg_output_manager_v1_interface xdg_output_mgr_impl = { + .destroy = xdg_output_mgr_destroy, + .get_xdg_output = xdg_output_mgr_get, +}; +static void xdg_output_mgr_bind(struct wl_client *c, void *data, uint32_t ver, + uint32_t id) { + struct wl_resource *r = wl_resource_create( + c, &zxdg_output_manager_v1_interface, (int)ver, id); + if (!r) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(r, &xdg_output_mgr_impl, NULL, NULL); +} + /* ====================================================================== */ /* Compositing: wallpaper + surfaces → scanout bo → PAGE_FLIP */ /* ====================================================================== */ @@ -1556,7 +2276,7 @@ static uint32_t bo_get_fb(struct gbm_bo *bo) { uint32_t handles[4] = { handle, 0, 0, 0 }; uint32_t pitches[4] = { stride, 0, 0, 0 }; uint32_t offsets[4] = { 0, 0, 0, 0 }; - if (drmModeAddFB2(g.card_fd, g.width, g.height, DRM_FORMAT_XRGB8888, + if (drmModeAddFB2(g.card_fd, g.pw, g.ph, DRM_FORMAT_XRGB8888, handles, pitches, offsets, &fb_id, 0) < 0) { perror("drmModeAddFB2"); return 0; @@ -1565,9 +2285,35 @@ static uint32_t bo_get_fb(struct gbm_bo *bo) { return fb_id; } +/* Does this buffer's format carry a meaningful alpha channel? The wl_shm and + * linux-dmabuf paths store their own format enums in the same field, so both + * ARGB constants are checked. They cannot collide: WL_SHM_FORMAT_ARGB8888 is + * 0 and the DRM constants are fourcc codes. */ +static inline int buffer_has_alpha(const struct shm_buffer *b) { + return b->format == WL_SHM_FORMAT_ARGB8888 || + b->format == DRM_FORMAT_ARGB8888; +} + +/* Source-over composite of one premultiplied ARGB pixel onto an opaque + * destination. wl_shm ARGB8888 is premultiplied by the Wayland spec, so the + * source contributes unscaled and the destination is attenuated by 1 - alpha. */ +static inline uint32_t blend_premultiplied(uint32_t src, uint32_t dst) { + uint32_t inv = 255u - (src >> 24); + uint32_t rb = ((((dst & 0x00ff00ffu) * inv) >> 8) & 0x00ff00ffu); + uint32_t g = ((((dst & 0x0000ff00u) * inv) >> 8) & 0x0000ff00u); + return src + rb + g; +} + /* Copy one committed wl_shm buffer into the scanout bo at the surface's - * position, clipped to the output. XRGB/ARGB8888 both blit as 32-bit - * words (opaque compositing); rows are memcpy'd over the clipped span. */ + * position, clipped to the output. The surface box is logical and the + * scanout is device pixels, so the destination is the box times the output + * scale. An XRGB8888 buffer overwrites; an ARGB8888 one composites + * source-over so a client's transparent regions show what is behind them + * instead of punching the surface's background through as black. + * A client whose buffer already covers that destination — it honoured + * set_buffer_scale — takes the memcpy path; anything else (a scale-1 + * client on a scaled output, or a wp_viewport surface) nearest-samples its + * source rect across the destination box. */ static void blit_surface(struct surface *s, uint32_t *dst, uint32_t dst_stride_px) { if (!s->buffer) return; struct shm_buffer *b = wl_resource_get_user_data(s->buffer); @@ -1576,16 +2322,71 @@ static void blit_surface(struct surface *s, uint32_t *dst, uint32_t dst_stride_p uint32_t *src = shm_buffer_pixels(b, &src_stride_px); if (!src) return; - int32_t x0 = s->x < 0 ? -s->x : 0; /* first visible col */ - int32_t x1 = s->x + b->width > (int32_t)g.width /* one past last col */ - ? (int32_t)g.width - s->x : b->width; + int32_t lw, lh; + surface_committed_size(s, b, &lw, &lh); + int32_t scale = (int32_t)g.scale; + int32_t ox = s->x * scale, oy = s->y * scale; + int32_t dw = lw * scale, dh = lh * scale; + if (dw <= 0 || dh <= 0) return; + int alpha = buffer_has_alpha(b); + + if (s->vp_src_w > 0 || dw != b->width || dh != b->height) { + float sx0 = 0.0f, sy0 = 0.0f; + float sw = (float)b->width, sh = (float)b->height; + if (s->vp_src_w > 0) { + sx0 = (float)wl_fixed_to_double(s->vp_src_x); + sy0 = (float)wl_fixed_to_double(s->vp_src_y); + sw = (float)wl_fixed_to_double(s->vp_src_w); + sh = (float)wl_fixed_to_double(s->vp_src_h); + } + for (int32_t row = 0; row < dh; row++) { + int32_t dy = oy + row; + if (dy < 0 || dy >= (int32_t)g.ph) continue; + int32_t sy = (int32_t)(sy0 + ((float)row + 0.5f) * sh / (float)dh); + if (sy < 0) sy = 0; + if (sy >= b->height) sy = b->height - 1; + for (int32_t col = 0; col < dw; col++) { + int32_t dx = ox + col; + if (dx < 0 || dx >= (int32_t)g.pw) continue; + int32_t sx = (int32_t)(sx0 + ((float)col + 0.5f) * sw / (float)dw); + if (sx < 0) sx = 0; + if (sx >= b->width) sx = b->width - 1; + uint32_t px = src[(size_t)sy * src_stride_px + sx]; + uint32_t *slot = &dst[(size_t)dy * dst_stride_px + dx]; + *slot = alpha ? blend_premultiplied(px, *slot) : px; + } + } + return; + } + + int32_t x0 = ox < 0 ? -ox : 0; /* first visible col */ + int32_t x1 = ox + b->width > (int32_t)g.pw /* one past last col */ + ? (int32_t)g.pw - ox : b->width; if (x1 <= x0) return; for (int32_t row = 0; row < b->height; row++) { - int32_t dy = s->y + row; - if (dy < 0 || dy >= (int32_t)g.height) continue; - memcpy(dst + (size_t)dy * dst_stride_px + (s->x + x0), - src + (size_t)row * src_stride_px + x0, - (size_t)(x1 - x0) * 4); + int32_t dy = oy + row; + if (dy < 0 || dy >= (int32_t)g.ph) continue; + uint32_t *drow = dst + (size_t)dy * dst_stride_px + (ox + x0); + const uint32_t *srow = src + (size_t)row * src_stride_px + x0; + if (!alpha) { + memcpy(drow, srow, (size_t)(x1 - x0) * 4); + continue; + } + for (int32_t col = 0; col < x1 - x0; col++) + drow[col] = blend_premultiplied(srow[col], drow[col]); + } +} + +/* Subsurfaces ride their parent: recompute their output position from the + * parent's (tiling moves the parent under them) and blit right above it. */ +static void blit_subsurfaces(struct surface *s, uint32_t *dst, + uint32_t dst_stride_px) { + for (int i = 0; i < g.n_all_surfaces; i++) { + struct surface *c = g.all_surfaces[i]; + if (c->parent != s || !surface_visible(c) || !c->buffer) continue; + c->x = s->x + c->sub_x; + c->y = s->y + c->sub_y; + blit_surface(c, dst, dst_stride_px); } } @@ -1593,27 +2394,39 @@ static void blit_surface(struct surface *s, uint32_t *dst, uint32_t dst_stride_p * window is visible even though decoration is client-side. */ static void draw_focus_border(struct surface *s, uint32_t *dst, uint32_t stride_px) { - const uint32_t color = FOCUS_COLOR; - for (int e = 1; e <= 2; e++) { - int32_t x0 = s->x - e, y0 = s->y - e; - int32_t x1 = s->x + s->w + e - 1, y1 = s->y + s->h + e - 1; + const uint32_t color = th.border_active; + int32_t scale = (int32_t)g.scale; + int32_t bx = s->x * scale, by = s->y * scale; + int32_t bw = s->w * scale, bh = s->h * scale; + for (int e = 1; e <= 2 * scale; e++) { + int32_t x0 = bx - e, y0 = by - e; + int32_t x1 = bx + bw + e - 1, y1 = by + bh + e - 1; for (int32_t x = x0; x <= x1; x++) { - if (x < 0 || x >= (int32_t)g.width) continue; - if (y0 >= 0 && y0 < (int32_t)g.height) + if (x < 0 || x >= (int32_t)g.pw) continue; + if (y0 >= 0 && y0 < (int32_t)g.ph) dst[(size_t)y0 * stride_px + x] = color; - if (y1 >= 0 && y1 < (int32_t)g.height) + if (y1 >= 0 && y1 < (int32_t)g.ph) dst[(size_t)y1 * stride_px + x] = color; } for (int32_t y = y0; y <= y1; y++) { - if (y < 0 || y >= (int32_t)g.height) continue; - if (x0 >= 0 && x0 < (int32_t)g.width) + if (y < 0 || y >= (int32_t)g.ph) continue; + if (x0 >= 0 && x0 < (int32_t)g.pw) dst[(size_t)y * stride_px + x0] = color; - if (x1 >= 0 && x1 < (int32_t)g.width) + if (x1 >= 0 && x1 < (int32_t)g.pw) dst[(size_t)y * stride_px + x1] = color; } } } +/* Layer surfaces composite outside the window z-order: background/bottom under + * every window, top/overlay over them. Both compositing paths walk the layer + * list twice through this predicate. */ +static int layer_in_band(const struct surface *s, int above) { + if (!surface_visible(s)) return 0; + int over = s->layer >= ZWLR_LAYER_SHELL_V1_LAYER_TOP; + return above ? over : !over; +} + static void send_frame_callbacks(struct surface *s) { uint32_t t = now_ms(); for (int i = 0; i < s->n_frame_cbs; i++) { @@ -1625,6 +2438,354 @@ static void send_frame_callbacks(struct surface *s) { static void send_all_frame_callbacks(void) { for (int i = 0; i < g.n_surfaces; i++) send_frame_callbacks(g.zorder[i]); + for (int i = 0; i < g.n_layers; i++) + send_frame_callbacks(g.layers[i]); +} + +/* ====================================================================== */ +/* wp_presentation: flip-timestamp feedback */ +/* ====================================================================== */ + +/* Feedback for a visible surface reports the flip that showed it; feedback + * for a hidden one (other workspace, unmapped) is discarded at the same + * flip. sec/nsec are CLOCK_MONOTONIC — the clock kernel_vblank stamps + * page-flip events with, matching the advertised clock_id. */ +static void send_presentation_feedback(struct surface *s, uint32_t sec, + uint32_t nsec, uint32_t seq) { + uint32_t refresh_ns = + g.mode.vrefresh ? 1000000000u / g.mode.vrefresh : 0; + int visible = surface_visible(s); + while (s->n_feedbacks > 0) { + struct wl_resource *fb = s->feedbacks[--s->n_feedbacks]; + wl_resource_set_user_data(fb, NULL); + if (visible) + wp_presentation_feedback_send_presented( + fb, 0, sec, nsec, refresh_ns, 0, seq, + WP_PRESENTATION_FEEDBACK_KIND_VSYNC); + else + wp_presentation_feedback_send_discarded(fb); + wl_resource_destroy(fb); + } +} +static void send_all_presentation_feedback(uint32_t sec, uint32_t nsec, + uint32_t seq) { + for (int i = 0; i < g.n_surfaces; i++) + send_presentation_feedback(g.zorder[i], sec, nsec, seq); + for (int i = 0; i < g.n_layers; i++) + send_presentation_feedback(g.layers[i], sec, nsec, seq); +} + +static void presentation_destroy(struct wl_client *c, struct wl_resource *r) { + wl_resource_destroy(r); +} +static void feedback_resource_destroy(struct wl_resource *r) { + struct surface *s = wl_resource_get_user_data(r); + if (!s) return; + for (int i = 0; i < s->n_feedbacks; i++) { + if (s->feedbacks[i] != r) continue; + s->feedbacks[i] = s->feedbacks[--s->n_feedbacks]; + break; + } +} +static void presentation_feedback(struct wl_client *c, struct wl_resource *r, + struct wl_resource *surface, uint32_t id) { + struct surface *s = wl_resource_get_user_data(surface); + struct wl_resource *fb = + wl_resource_create(c, &wp_presentation_feedback_interface, 1, id); + if (!fb) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(fb, NULL, s, feedback_resource_destroy); + if (s->n_feedbacks < MAX_FRAME_CB) { + s->feedbacks[s->n_feedbacks++] = fb; + return; + } + wp_presentation_feedback_send_discarded(fb); + wl_resource_destroy(fb); +} +static const struct wp_presentation_interface presentation_impl = { + .destroy = presentation_destroy, + .feedback = presentation_feedback, +}; +static void presentation_bind(struct wl_client *c, void *data, uint32_t ver, + uint32_t id) { + struct wl_resource *r = + wl_resource_create(c, &wp_presentation_interface, (int)ver, id); + if (!r) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(r, &presentation_impl, NULL, NULL); + wp_presentation_send_clock_id(r, CLOCK_MONOTONIC); +} + +/* ====================================================================== */ +/* wl_subcompositor: parent-glued overlay subsurfaces */ +/* ====================================================================== */ + +static void subsurface_destroy(struct wl_client *c, struct wl_resource *r) { + wl_resource_destroy(r); +} +static void subsurface_set_position(struct wl_client *c, struct wl_resource *r, + int32_t x, int32_t y) { + struct surface *s = wl_resource_get_user_data(r); + if (!s) return; + s->sub_x = x; + s->sub_y = y; + schedule_repaint(); +} +static void subsurface_place_above(struct wl_client *c, struct wl_resource *r, + struct wl_resource *sibling) {} +static void subsurface_place_below(struct wl_client *c, struct wl_resource *r, + struct wl_resource *sibling) {} +static void subsurface_set_sync(struct wl_client *c, struct wl_resource *r) {} +static void subsurface_set_desync(struct wl_client *c, struct wl_resource *r) {} +static const struct wl_subsurface_interface subsurface_impl = { + .destroy = subsurface_destroy, + .set_position = subsurface_set_position, + .place_above = subsurface_place_above, + .place_below = subsurface_place_below, + .set_sync = subsurface_set_sync, + .set_desync = subsurface_set_desync, +}; +static void subsurface_resource_destroy(struct wl_resource *r) { + struct surface *s = wl_resource_get_user_data(r); + if (!s) return; + s->subsurface = NULL; + s->parent = NULL; + s->mapped = 0; + schedule_repaint(); +} + +static void subcompositor_destroy(struct wl_client *c, struct wl_resource *r) { + wl_resource_destroy(r); +} +static void subcompositor_get_subsurface(struct wl_client *c, + struct wl_resource *r, uint32_t id, + struct wl_resource *surface, + struct wl_resource *parent) { + struct surface *s = wl_resource_get_user_data(surface); + struct surface *p = wl_resource_get_user_data(parent); + struct wl_resource *sub = + wl_resource_create(c, &wl_subsurface_interface, 1, id); + if (!sub) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(sub, &subsurface_impl, s, + subsurface_resource_destroy); + s->subsurface = sub; + s->parent = p; +} +static const struct wl_subcompositor_interface subcompositor_impl = { + .destroy = subcompositor_destroy, + .get_subsurface = subcompositor_get_subsurface, +}; +static void subcompositor_bind(struct wl_client *c, void *data, uint32_t ver, + uint32_t id) { + struct wl_resource *r = + wl_resource_create(c, &wl_subcompositor_interface, (int)ver, id); + if (!r) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(r, &subcompositor_impl, NULL, NULL); +} + +/* ====================================================================== */ +/* wp_viewporter: per-surface crop + scale */ +/* ====================================================================== */ + +static void viewport_destroy_req(struct wl_client *c, struct wl_resource *r) { + wl_resource_destroy(r); +} +static void viewport_set_source(struct wl_client *c, struct wl_resource *r, + wl_fixed_t x, wl_fixed_t y, wl_fixed_t w, + wl_fixed_t h) { + struct surface *s = wl_resource_get_user_data(r); + if (!s) { + wl_resource_post_error(r, WP_VIEWPORT_ERROR_NO_SURFACE, + "the wl_surface was destroyed"); + return; + } + int unset = x == wl_fixed_from_int(-1) && y == wl_fixed_from_int(-1) && + w == wl_fixed_from_int(-1) && h == wl_fixed_from_int(-1); + if (!unset && (x < 0 || y < 0 || w <= 0 || h <= 0)) { + wl_resource_post_error(r, WP_VIEWPORT_ERROR_BAD_VALUE, + "invalid source rectangle"); + return; + } + s->vp_src_x = unset ? 0 : x; + s->vp_src_y = unset ? 0 : y; + s->vp_src_w = unset ? 0 : w; + s->vp_src_h = unset ? 0 : h; + schedule_repaint(); +} +static void viewport_set_destination(struct wl_client *c, struct wl_resource *r, + int32_t w, int32_t h) { + struct surface *s = wl_resource_get_user_data(r); + if (!s) { + wl_resource_post_error(r, WP_VIEWPORT_ERROR_NO_SURFACE, + "the wl_surface was destroyed"); + return; + } + int unset = w == -1 && h == -1; + if (!unset && (w <= 0 || h <= 0)) { + wl_resource_post_error(r, WP_VIEWPORT_ERROR_BAD_VALUE, + "invalid destination size"); + return; + } + s->vp_dst_w = unset ? 0 : w; + s->vp_dst_h = unset ? 0 : h; + schedule_repaint(); +} +static const struct wp_viewport_interface viewport_impl = { + .destroy = viewport_destroy_req, + .set_source = viewport_set_source, + .set_destination = viewport_set_destination, +}; +static void viewport_resource_destroy(struct wl_resource *r) { + struct surface *s = wl_resource_get_user_data(r); + if (!s) return; + s->viewport = NULL; + s->vp_src_x = s->vp_src_y = s->vp_src_w = s->vp_src_h = 0; + s->vp_dst_w = s->vp_dst_h = 0; + schedule_repaint(); +} + +static void viewporter_destroy(struct wl_client *c, struct wl_resource *r) { + wl_resource_destroy(r); +} +static void viewporter_get_viewport(struct wl_client *c, struct wl_resource *r, + uint32_t id, struct wl_resource *surface) { + struct surface *s = wl_resource_get_user_data(surface); + if (s->viewport) { + wl_resource_post_error(r, WP_VIEWPORTER_ERROR_VIEWPORT_EXISTS, + "the surface already has a viewport"); + return; + } + struct wl_resource *vp = + wl_resource_create(c, &wp_viewport_interface, 1, id); + if (!vp) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(vp, &viewport_impl, s, + viewport_resource_destroy); + s->viewport = vp; +} +static const struct wp_viewporter_interface viewporter_impl = { + .destroy = viewporter_destroy, + .get_viewport = viewporter_get_viewport, +}; +static void viewporter_bind(struct wl_client *c, void *data, uint32_t ver, + uint32_t id) { + struct wl_resource *r = + wl_resource_create(c, &wp_viewporter_interface, (int)ver, id); + if (!r) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(r, &viewporter_impl, NULL, NULL); +} + +/* ====================================================================== */ +/* wp_fractional_scale_manager_v1: fixed scale-1 preference */ +/* ====================================================================== */ + +static void fractional_scale_destroy_req(struct wl_client *c, + struct wl_resource *r) { + wl_resource_destroy(r); +} +static const struct wp_fractional_scale_v1_interface fractional_scale_impl = { + .destroy = fractional_scale_destroy_req, +}; +static void fractional_scale_resource_destroy(struct wl_resource *r) { + struct surface *s = wl_resource_get_user_data(r); + if (s) s->fractional_scale = NULL; +} +static void fractional_scale_mgr_destroy(struct wl_client *c, + struct wl_resource *r) { + wl_resource_destroy(r); +} +static void fractional_scale_mgr_get(struct wl_client *c, struct wl_resource *r, + uint32_t id, + struct wl_resource *surface) { + struct surface *s = wl_resource_get_user_data(surface); + if (s->fractional_scale) { + wl_resource_post_error( + r, WP_FRACTIONAL_SCALE_MANAGER_V1_ERROR_FRACTIONAL_SCALE_EXISTS, + "the surface already has a fractional_scale object"); + return; + } + struct wl_resource *fs = + wl_resource_create(c, &wp_fractional_scale_v1_interface, 1, id); + if (!fs) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(fs, &fractional_scale_impl, s, + fractional_scale_resource_destroy); + s->fractional_scale = fs; + /* wp_fractional_scale counts in 120ths, so an integer output scale is + * scale x 120. The desktop advertises no fractional step of its own. */ + wp_fractional_scale_v1_send_preferred_scale(fs, (uint32_t)(g.scale * 120)); +} +static const struct wp_fractional_scale_manager_v1_interface + fractional_scale_mgr_impl = { + .destroy = fractional_scale_mgr_destroy, + .get_fractional_scale = fractional_scale_mgr_get, +}; +static void fractional_scale_mgr_bind(struct wl_client *c, void *data, + uint32_t ver, uint32_t id) { + struct wl_resource *r = wl_resource_create( + c, &wp_fractional_scale_manager_v1_interface, (int)ver, id); + if (!r) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(r, &fractional_scale_mgr_impl, NULL, NULL); +} + +/* ====================================================================== */ +/* wl_data_device_manager: inert v3 stub */ +/* ====================================================================== */ + +/* foot (and later GTK) refuse to start without a clipboard manager. This + * stub satisfies the bind and accepts selections without transferring + * them — real clipboard data paths are the O2 tier's work (plan §4 PR24). */ +static void data_source_offer(struct wl_client *c, struct wl_resource *r, + const char *mime) {} +static void data_source_destroy_req(struct wl_client *c, + struct wl_resource *r) { + wl_resource_destroy(r); +} +static void data_source_set_actions(struct wl_client *c, struct wl_resource *r, + uint32_t actions) {} +static const struct wl_data_source_interface data_source_impl = { + .offer = data_source_offer, + .destroy = data_source_destroy_req, + .set_actions = data_source_set_actions, +}; + +static void data_device_start_drag(struct wl_client *c, struct wl_resource *r, + struct wl_resource *source, + struct wl_resource *origin, + struct wl_resource *icon, uint32_t serial) {} +static void data_device_set_selection(struct wl_client *c, + struct wl_resource *r, + struct wl_resource *source, + uint32_t serial) {} +static void data_device_release(struct wl_client *c, struct wl_resource *r) { + wl_resource_destroy(r); +} +static const struct wl_data_device_interface data_device_impl = { + .start_drag = data_device_start_drag, + .set_selection = data_device_set_selection, + .release = data_device_release, +}; + +static void data_dm_create_source(struct wl_client *c, struct wl_resource *r, + uint32_t id) { + struct wl_resource *src = wl_resource_create( + c, &wl_data_source_interface, wl_resource_get_version(r), id); + if (!src) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(src, &data_source_impl, NULL, NULL); +} +static void data_dm_get_device(struct wl_client *c, struct wl_resource *r, + uint32_t id, struct wl_resource *seat) { + struct wl_resource *dev = wl_resource_create( + c, &wl_data_device_interface, wl_resource_get_version(r), id); + if (!dev) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(dev, &data_device_impl, NULL, NULL); +} +static const struct wl_data_device_manager_interface data_dm_impl = { + .create_data_source = data_dm_create_source, + .get_data_device = data_dm_get_device, +}; +static void data_dm_bind(struct wl_client *c, void *data, uint32_t ver, + uint32_t id) { + struct wl_resource *r = + wl_resource_create(c, &wl_data_device_manager_interface, (int)ver, id); + if (!r) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(r, &data_dm_impl, NULL, NULL); } /* ====================================================================== */ @@ -1639,10 +2800,11 @@ static void send_all_frame_callbacks(void) { static const char GLC_VS[] = "#version 300 es\n" "uniform vec4 u_rect;\n" + "uniform vec4 u_uv;\n" "out vec2 v_uv;\n" "void main() {\n" " vec2 t = vec2(float(gl_VertexID & 1), float((gl_VertexID >> 1) & 1));\n" - " v_uv = t;\n" + " v_uv = mix(u_uv.xy, u_uv.zw, t);\n" " vec2 p = mix(u_rect.xy, u_rect.zw, t);\n" " gl_Position = vec4(p, 0.0, 1.0);\n" "}\n"; @@ -1655,8 +2817,9 @@ static const char GLC_FS[] = "in vec2 v_uv;\n" "out vec4 o_color;\n" "void main() {\n" - " o_color = u_use_tex == 1 ? vec4(texture(u_tex, v_uv).bgr, 1.0)\n" - " : u_color;\n" + " if (u_use_tex == 0) { o_color = u_color; return; }\n" + " vec4 t = texture(u_tex, v_uv);\n" + " o_color = vec4(t.bgr, u_use_tex == 2 ? t.a : 1.0);\n" "}\n"; /* Compile one shader; returns 0 on failure. On a headless host the sync @@ -1678,26 +2841,59 @@ static GLuint glc_compile(GLenum type, const char *src) { return sh; } -/* Output-space pixels → NDC rect (y0 is the TOP edge; v_uv row 0 maps - * to it, matching the texture's top scanline). */ +/* Device pixels → NDC rect, relative to the GL viewport (which is the + * pixel grid, g.pw x g.ph). y0 is the TOP edge; v_uv row 0 maps to it, + * matching the texture's top scanline. Callers holding a logical box + * multiply by g.scale through glc_px(). */ static void glc_rect_ndc(int32_t x, int32_t y, int32_t w, int32_t h, float out[4]) { - out[0] = 2.0f * (float)x / (float)g.width - 1.0f; - out[1] = 1.0f - 2.0f * (float)y / (float)g.height; - out[2] = 2.0f * (float)(x + w) / (float)g.width - 1.0f; - out[3] = 1.0f - 2.0f * (float)(y + h) / (float)g.height; + out[0] = 2.0f * (float)x / (float)g.pw - 1.0f; + out[1] = 1.0f - 2.0f * (float)y / (float)g.ph; + out[2] = 2.0f * (float)(x + w) / (float)g.pw - 1.0f; + out[3] = 1.0f - 2.0f * (float)(y + h) / (float)g.ph; } -static void glc_draw_tex(unsigned tex, int32_t x, int32_t y, - int32_t w, int32_t h) { +/* Logical unit → device pixel. */ +static inline int32_t glc_px(int32_t v) { return v * (int32_t)g.scale; } + +/* `alpha` selects the fragment shader's texture branch: an ARGB8888 buffer + * keeps its sampled alpha and composites source-over, an XRGB8888 one forces + * alpha to 1 because its fourth channel carries no coverage. */ +static void glc_draw_tex_rect(unsigned tex, int alpha, int32_t x, int32_t y, + int32_t w, int32_t h, const float uv[4]) { float r[4]; glc_rect_ndc(x, y, w, h, r); glUniform4f(glc.loc_rect, r[0], r[1], r[2], r[3]); - glUniform1i(glc.loc_use_tex, 1); + glUniform4f(glc.loc_uv, uv[0], uv[1], uv[2], uv[3]); + glUniform1i(glc.loc_use_tex, alpha ? 2 : 1); glBindTexture(GL_TEXTURE_2D, tex); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); } +static void glc_draw_tex(unsigned tex, int alpha, int32_t x, int32_t y, + int32_t w, int32_t h) { + static const float full_uv[4] = { 0.0f, 0.0f, 1.0f, 1.0f }; + glc_draw_tex_rect(tex, alpha, x, y, w, h, full_uv); +} + +/* Destination box + source-uv rect for a surface's committed buffer under + * its wp_viewport state (the full buffer when none). */ +static void surface_draw_box(struct surface *s, struct shm_buffer *b, + int32_t *dw, int32_t *dh, float uv[4]) { + surface_committed_size(s, b, dw, dh); + if (s->vp_src_w > 0) { + float x0 = (float)wl_fixed_to_double(s->vp_src_x); + float y0 = (float)wl_fixed_to_double(s->vp_src_y); + uv[0] = x0 / (float)b->width; + uv[1] = y0 / (float)b->height; + uv[2] = (x0 + (float)wl_fixed_to_double(s->vp_src_w)) / (float)b->width; + uv[3] = (y0 + (float)wl_fixed_to_double(s->vp_src_h)) / (float)b->height; + return; + } + uv[0] = uv[1] = 0.0f; + uv[2] = uv[3] = 1.0f; +} + static void glc_draw_solid(uint32_t argb, int32_t x, int32_t y, int32_t w, int32_t h) { float r[4]; @@ -1712,10 +2908,14 @@ static void glc_draw_solid(uint32_t argb, int32_t x, int32_t y, } /* Import-once + rebind-on-commit texture for a client buffer. Returns 0 - * on failure (repaint degrades to the CPU path). NOTE: rebinding flushes - * the cmdbuf, so this must run BEFORE the frame's draw sequence starts — - * a flush mid-frame would present a half-composited desktop. */ + * when the buffer has no GL representation; the caller draws the rest of + * the frame without it. NOTE: rebinding flushes the cmdbuf, so this must + * run BEFORE the frame's draw sequence starts — a flush mid-frame would + * present a half-composited desktop. */ static unsigned shm_buffer_gl_texture(struct shm_buffer *b) { + /* The texture is the whole imported bo, so a buffer that starts partway + * into the pool has no GL representation — the CPU path reads it. */ + if (b->offset != 0) return 0; if (!b->egl_bo_handle) { b->egl_bo_handle = wpkEglImportDmabufHandle(glc.dpy, b->pool->fd); if (!b->egl_bo_handle) return 0; @@ -1731,35 +2931,48 @@ static unsigned shm_buffer_gl_texture(struct shm_buffer *b) { return b->gl_tex; } +/* The staging bo behind the wallpaper texture. Held for the process lifetime: + * it owns the texture's pixels, and a theme switch rewrites it in place. */ +static struct gbm_bo *wallpaper_bo; +static unsigned wallpaper_bo_handle; + +/* Copy g.wallpaper into the staging bo and (re)bind it as the texture. A + * rebind re-reads the bo, which is how a theme switch reaches the GPU path. */ +static int gl_wallpaper_upload(void) { + uint32_t stride = 0; + void *map_data = NULL; + uint32_t *px = gbm_bo_map(wallpaper_bo, 0, 0, g.pw, g.ph, 0, &stride, + &map_data); + if (!px) return -1; + for (uint32_t y = 0; y < g.ph; y++) + memcpy(px + (size_t)y * (stride / 4), g.wallpaper + (size_t)y * g.pw, + (size_t)g.pw * 4); + gbm_bo_unmap(wallpaper_bo, map_data); /* flushes the bytes into host storage */ + + unsigned tex = wpkEglBindBoTexture(glc.dpy, wallpaper_bo_handle, + GL_TEXTURE_2D); + if (!tex) return -1; + glc.wallpaper_tex = tex; + return 0; +} + /* Stage the pre-rendered wallpaper through a dumb bo so the host uploads * it as a texture from shared storage — cmdbuf TLV records cap at 64 KB, * far below a framebuffer-sized glTexImage2D payload. */ static int setup_gl_wallpaper(void) { - struct gbm_bo *bo = gbm_bo_create(g.gbm, g.width, g.height, - GBM_FORMAT_XRGB8888, GBM_BO_USE_LINEAR); - if (!bo) return -1; - uint32_t stride = 0; - void *map_data = NULL; - uint32_t *px = gbm_bo_map(bo, 0, 0, g.width, g.height, 0, &stride, - &map_data); - if (!px) { gbm_bo_destroy(bo); return -1; } - for (uint32_t y = 0; y < g.height; y++) - memcpy(px + (size_t)y * (stride / 4), g.wallpaper + (size_t)y * g.width, - (size_t)g.width * 4); - gbm_bo_unmap(bo, map_data); /* flushes the bytes into host storage */ - - int prime = gbm_bo_get_fd(bo); - if (prime < 0) { gbm_bo_destroy(bo); return -1; } - unsigned handle = wpkEglImportDmabufHandle(glc.dpy, prime); + wallpaper_bo = gbm_bo_create(g.gbm, g.pw, g.ph, + GBM_FORMAT_XRGB8888, GBM_BO_USE_LINEAR); + if (!wallpaper_bo) return -1; + int prime = gbm_bo_get_fd(wallpaper_bo); + if (prime < 0) { gbm_bo_destroy(wallpaper_bo); return -1; } + wallpaper_bo_handle = wpkEglImportDmabufHandle(glc.dpy, prime); close(prime); - if (!handle) { gbm_bo_destroy(bo); return -1; } - glc.wallpaper_tex = wpkEglBindBoTexture(glc.dpy, handle, GL_TEXTURE_2D); - if (!glc.wallpaper_tex) { - wpkEglCloseBoHandle(glc.dpy, handle); - gbm_bo_destroy(bo); + if (!wallpaper_bo_handle) { gbm_bo_destroy(wallpaper_bo); return -1; } + if (gl_wallpaper_upload() != 0) { + wpkEglCloseBoHandle(glc.dpy, wallpaper_bo_handle); + gbm_bo_destroy(wallpaper_bo); return -1; } - /* bo is intentionally never destroyed: it owns the texture's pixels. */ return 0; } @@ -1780,8 +2993,8 @@ static void setup_gl(void) { /* The drawing buffer must be mode-sized; we create the surface * before the first ADDFB, so the host cannot infer the size — pass * it explicitly (wpk libEGL honors EGL_WIDTH/EGL_HEIGHT). */ - const EGLint srf_attrs[] = { EGL_WIDTH, (EGLint)g.width, - EGL_HEIGHT, (EGLint)g.height, EGL_NONE }; + const EGLint srf_attrs[] = { EGL_WIDTH, (EGLint)g.pw, + EGL_HEIGHT, (EGLint)g.ph, EGL_NONE }; glc.srf = eglCreateWindowSurface(glc.dpy, NULL, 0, srf_attrs); if (glc.srf == EGL_NO_SURFACE) { eglTerminate(glc.dpy); return; } if (!eglMakeCurrent(glc.dpy, glc.srf, glc.srf, glc.ctx)) { @@ -1806,10 +3019,15 @@ static void setup_gl(void) { } glUseProgram(glc.prog); glc.loc_rect = glGetUniformLocation(glc.prog, "u_rect"); + glc.loc_uv = glGetUniformLocation(glc.prog, "u_uv"); glc.loc_use_tex = glGetUniformLocation(glc.prog, "u_use_tex"); glc.loc_color = glGetUniformLocation(glc.prog, "u_color"); glUniform1i(glGetUniformLocation(glc.prog, "u_tex"), 0); - glViewport(0, 0, (GLsizei)g.width, (GLsizei)g.height); + glViewport(0, 0, (GLsizei)g.pw, (GLsizei)g.ph); + /* Source-over with a premultiplied source, matching wl_shm ARGB8888. + * Opaque draws emit alpha 1 and so still overwrite. */ + glEnable(GL_BLEND); + glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA); if (setup_gl_wallpaper() != 0) { fprintf(stderr, "wlcompositor: GL wallpaper staging failed\n"); @@ -1821,10 +3039,20 @@ static void setup_gl(void) { /* GPU frame: refresh dirty textures (host-side uploads, safe to flush), * then encode clear + wallpaper + z-ordered window quads and present - * them in ONE cmdbuf flush via eglSwapBuffers. Returns 0 on failure so - * repaint() can fall back to the CPU blit. */ + * them in ONE cmdbuf flush via eglSwapBuffers. Returns 0 only when the + * GL session itself failed, so repaint() can fall back to the CPU blit. + * + * A single buffer without a GL representation is NOT such a failure: a + * cursor image sits at a non-zero offset in its pool (libwayland-cursor + * packs a whole theme into one pool), and tearing the session down for + * it would drop the entire desktop onto the CPU path for good. The draw + * loops below skip a zero texture, which is what the collection loops + * leave behind. */ static int repaint_gl(void) { unsigned texs[MAX_SURFACES] = {0}; + unsigned layer_texs[MAX_LAYERS] = {0}; + int surface_alpha[MAX_SURFACES] = {0}; + int layer_alpha[MAX_LAYERS] = {0}; struct surface *top = NULL; for (int i = 0; i < g.n_surfaces; i++) { struct surface *s = g.zorder[i]; @@ -1832,23 +3060,61 @@ static int repaint_gl(void) { struct shm_buffer *b = wl_resource_get_user_data(s->buffer); if (!b) continue; texs[i] = shm_buffer_gl_texture(b); - if (!texs[i]) return 0; + surface_alpha[i] = buffer_has_alpha(b); + } + for (int i = 0; i < g.n_layers; i++) { + struct surface *s = g.layers[i]; + if (!surface_visible(s) || !s->buffer) continue; + struct shm_buffer *b = wl_resource_get_user_data(s->buffer); + if (!b) continue; + layer_texs[i] = shm_buffer_gl_texture(b); + layer_alpha[i] = buffer_has_alpha(b); } glClearColor(0.0f, 0.0f, 0.0f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); - glc_draw_tex(glc.wallpaper_tex, 0, 0, (int32_t)g.width, (int32_t)g.height); + glc_draw_tex(glc.wallpaper_tex, 0, 0, 0, (int32_t)g.pw, (int32_t)g.ph); + for (int i = 0; i < g.n_layers; i++) + if (layer_texs[i] && layer_in_band(g.layers[i], 0)) + glc_draw_tex(layer_texs[i], layer_alpha[i], glc_px(g.layers[i]->x), + glc_px(g.layers[i]->y), + glc_px(g.layers[i]->w), glc_px(g.layers[i]->h)); for (int i = 0; i < g.n_surfaces; i++) { struct surface *s = g.zorder[i]; if (!surface_visible(s) || !s->buffer || !texs[i]) continue; struct shm_buffer *b = wl_resource_get_user_data(s->buffer); if (!b) continue; + int32_t dw, dh; + float uv[4]; + surface_draw_box(s, b, &dw, &dh, uv); if (g.kbd_focus == s) /* 2px accent ring behind the window */ - glc_draw_solid(FOCUS_COLOR, s->x - 2, s->y - 2, - b->width + 4, b->height + 4); - glc_draw_tex(texs[i], s->x, s->y, b->width, b->height); + glc_draw_solid(th.border_active, glc_px(s->x - 2), glc_px(s->y - 2), + glc_px(dw + 4), glc_px(dh + 4)); + glc_draw_tex_rect(texs[i], surface_alpha[i], glc_px(s->x), glc_px(s->y), + glc_px(dw), glc_px(dh), uv); + for (int j = 0; j < g.n_all_surfaces; j++) { + struct surface *sub = g.all_surfaces[j]; + if (sub->parent != s || !surface_visible(sub) || !sub->buffer) + continue; + struct shm_buffer *sb = wl_resource_get_user_data(sub->buffer); + if (!sb) continue; + unsigned t = shm_buffer_gl_texture(sb); + if (!t) continue; + sub->x = s->x + sub->sub_x; + sub->y = s->y + sub->sub_y; + int32_t sdw, sdh; + float suv[4]; + surface_draw_box(sub, sb, &sdw, &sdh, suv); + glc_draw_tex_rect(t, buffer_has_alpha(sb), glc_px(sub->x), + glc_px(sub->y), glc_px(sdw), glc_px(sdh), suv); + } top = s; } + for (int i = 0; i < g.n_layers; i++) + if (layer_texs[i] && layer_in_band(g.layers[i], 1)) + glc_draw_tex(layer_texs[i], layer_alpha[i], glc_px(g.layers[i]->x), + glc_px(g.layers[i]->y), + glc_px(g.layers[i]->w), glc_px(g.layers[i]->h)); /* A failed present (context loss) must degrade like a failed texture * bind — returning 1 here would keep glc.active set and freeze the * canvas on the last GL frame, the exact failure the CPU fallback @@ -1859,11 +3125,14 @@ static int repaint_gl(void) { * same contract as the CPU path's sample — but read back from the * composited GL framebuffer (glReadPixels via the sync query path). */ if (top && !g.sampled) { + /* The sample point is logical (the gates read these coordinates); + * the framebuffer it is read from is device pixels. */ int32_t sx = top->x + 10, sy = top->y + 10; if (sx >= 0 && sx < (int32_t)g.width && sy >= 0 && sy < (int32_t)g.height) { + int32_t px_x = sx * (int32_t)g.scale, px_y = sy * (int32_t)g.scale; uint8_t px[4] = {0}; - glReadPixels(sx, (int32_t)g.height - 1 - sy, 1, 1, GL_RGBA, + glReadPixels(px_x, (int32_t)g.ph - 1 - px_y, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, px); printf("COMPOSITE_SAMPLE x=%d y=%d px=0x%08x\n", sx, sy, 0xff000000u | ((uint32_t)px[0] << 16) | @@ -1909,7 +3178,7 @@ static void repaint(void) { uint32_t stride = 0; void *map_data = NULL; uint32_t *dst = - gbm_bo_map(bo, 0, 0, g.width, g.height, 0, &stride, &map_data); + gbm_bo_map(bo, 0, 0, g.pw, g.ph, 0, &stride, &map_data); if (!dst) { /* A persistent map failure would freeze the desktop with no * visible error — say so loudly. */ @@ -1920,18 +3189,25 @@ static void repaint(void) { } uint32_t stride_px = stride / 4; - for (uint32_t y = 0; y < g.height; y++) + for (uint32_t y = 0; y < g.ph; y++) memcpy(dst + (size_t)y * stride_px, - g.wallpaper + (size_t)y * g.width, (size_t)g.width * 4); + g.wallpaper + (size_t)y * g.pw, (size_t)g.pw * 4); struct surface *top = NULL; + for (int i = 0; i < g.n_layers; i++) + if (layer_in_band(g.layers[i], 0)) + blit_surface(g.layers[i], dst, stride_px); for (int i = 0; i < g.n_surfaces; i++) { struct surface *s = g.zorder[i]; if (!surface_visible(s)) continue; if (g.kbd_focus == s) draw_focus_border(s, dst, stride_px); blit_surface(s, dst, stride_px); + blit_subsurfaces(s, dst, stride_px); top = s; } + for (int i = 0; i < g.n_layers; i++) + if (layer_in_band(g.layers[i], 1)) + blit_surface(g.layers[i], dst, stride_px); /* One-shot proof that a client's pixels crossed the process * boundary: sample a pixel inside the topmost surface. If the * gbm_bo_import path (§8.1) worked, this is the client's color; if @@ -1942,7 +3218,8 @@ static void repaint(void) { if (sx >= 0 && sx < (int32_t)g.width && sy >= 0 && sy < (int32_t)g.height) { printf("COMPOSITE_SAMPLE x=%d y=%d px=0x%08x\n", sx, sy, - dst[(size_t)sy * stride_px + sx]); + dst[(size_t)(sy * (int32_t)g.scale) * stride_px + + sx * (int32_t)g.scale]); fflush(stdout); g.sampled = 1; } @@ -1970,6 +3247,11 @@ static void repaint(void) { printf("FLIP fb=%u first=1\n", fb_id); fflush(stdout); send_all_frame_callbacks(); + /* SetCrtc has no flip event, so stamp the first frame ourselves. */ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + send_all_presentation_feedback((uint32_t)ts.tv_sec, + (uint32_t)ts.tv_nsec, 0); } else { if (drmModePageFlip(g.card_fd, g.crtc_id, fb_id, DRM_MODE_PAGE_FLIP_EVENT, NULL) < 0) { @@ -2002,6 +3284,7 @@ static void on_flip(int fd, unsigned int seq, unsigned int sec, g.displayed_bo = g.pending_bo; g.pending_bo = NULL; send_all_frame_callbacks(); + send_all_presentation_feedback(sec, usec * 1000u, seq); } if (g.repaint_needed && !g.pending_bo) { g.repaint_needed = 0; @@ -2042,6 +3325,8 @@ static uint32_t active_mod_mask(void) { XKB_STATE_MODS_EFFECTIVE) > 0) m |= MOD_SHIFT; if (xkb_state_mod_name_is_active(g.xkb_state, XKB_MOD_NAME_CTRL, XKB_STATE_MODS_EFFECTIVE) > 0) m |= MOD_CTRL; + if (xkb_state_mod_name_is_active(g.xkb_state, XKB_MOD_NAME_ALT, + XKB_STATE_MODS_EFFECTIVE) > 0) m |= MOD_ALT; return m; } @@ -2077,6 +3362,7 @@ static void run_dispatch(const struct keybind *b) { break; case ACT_CYCLE_NEXT: focus_cycle(+1); break; case ACT_CYCLE_PREV: focus_cycle(-1); break; + case ACT_THEME: theme_switch(b->param); break; } } @@ -2131,6 +3417,128 @@ static char *trim(char *s) { return s; } +/* ---- theme loading ------------------------------------------------------ */ + +static void render_wallpaper(void); +static int gl_wallpaper_upload(void); + +/* Parse `0xRRGGBB` / `#RRGGBB` / a decimal integer. Returns the ARGB color + * with a forced-opaque alpha, or `fallback` when the text is not a number. */ +static uint32_t parse_color(const char *s, uint32_t fallback) { + if (*s == '#') s++; + char *end = NULL; + unsigned long v = strtoul(s, &end, 16); + if (end == s) return fallback; + return 0xff000000u | (uint32_t)(v & 0xffffffu); +} + +/* Read one theme's palette. Unknown keys are ignored, so the same file can + * carry the client-side colors (bar, foreground, accent) that kbar and + * klauncher read for themselves. */ +static int theme_load(const char *name) { + const char *root = getenv("WLC_THEME_DIR"); + char path[256]; + snprintf(path, sizeof(path), "%s/%s/theme.conf", root ? root : THEME_DIR, + name); + FILE *f = fopen(path, "r"); + if (!f) return -1; + + th.wallpaper_path[0] = '\0'; /* a gradient theme sheds the old image */ + char line[256]; + while (fgets(line, sizeof(line), f)) { + char *s = trim(line); + if (*s == '\0' || *s == '#') continue; + char *eq = strchr(s, '='); + if (!eq) continue; + *eq = '\0'; + char *key = trim(s); + char *val = trim(eq + 1); + if (!strcmp(key, "border_active")) + th.border_active = parse_color(val, th.border_active); + else if (!strcmp(key, "wallpaper_top")) + th.wallpaper_top = parse_color(val, th.wallpaper_top); + else if (!strcmp(key, "wallpaper_bottom")) + th.wallpaper_bottom = parse_color(val, th.wallpaper_bottom); + else if (!strcmp(key, "wallpaper")) { + if (val[0] == '/') + snprintf(th.wallpaper_path, sizeof(th.wallpaper_path), "%s", + val); + else + snprintf(th.wallpaper_path, sizeof(th.wallpaper_path), + "%s/%s/%s", root ? root : THEME_DIR, name, val); + } + else if (!strcmp(key, "gaps_in")) th.gaps_in = atoi(val); + else if (!strcmp(key, "gaps_out")) th.gaps_out = atoi(val); + } + fclose(f); + snprintf(th.name, sizeof(th.name), "%s", name); + for (int i = 0; i < th.n_installed; i++) + if (!strcmp(th.installed[i], name)) { th.current = i; break; } + return 0; +} + +/* List the installed themes, sorted, so cycling has a stable order. A theme is + * a directory holding a theme.conf; anything else in the root is skipped. */ +static void theme_scan(void) { + const char *root = getenv("WLC_THEME_DIR"); + if (!root) root = THEME_DIR; + DIR *d = opendir(root); + if (!d) return; + struct dirent *e; + while ((e = readdir(d)) && th.n_installed < MAX_THEMES) { + if (e->d_name[0] == '.') continue; + char probe[512]; + snprintf(probe, sizeof(probe), "%s/%s/theme.conf", root, e->d_name); + if (access(probe, R_OK) != 0) continue; + int at = th.n_installed; + while (at > 0 && strcmp(th.installed[at - 1], e->d_name) > 0) { + memcpy(th.installed[at], th.installed[at - 1], + sizeof(th.installed[0])); + at--; + } + snprintf(th.installed[at], sizeof(th.installed[0]), "%s", e->d_name); + th.n_installed++; + } + closedir(d); +} + +/* Push the loaded palette into the live desktop: border color, gaps, and the + * wallpaper (re-uploaded when the GPU path owns it). Clients re-read the theme + * themselves when they see the event. */ +static void theme_apply(void) { + tile_gap_inner = th.gaps_in; + tile_gap_outer = th.gaps_out; + render_wallpaper(); + if (glc.active && gl_wallpaper_upload() != 0) + fprintf(stderr, "wlcompositor: theme wallpaper upload failed\n"); + retile(); + schedule_repaint(); + printf("THEME %s\n", th.name); + fflush(stdout); + kwlctl_emit("theme>>%s", th.name); + /* The `notify =` hook is how Omarchy surfaces a switch (its scripts call + * notify-send); ours spawns the configured notifier with the new name. */ + if (th.notify[0]) { + char cmd[sizeof(th.notify) + 48]; + snprintf(cmd, sizeof(cmd), "%s Theme %s", th.notify, th.name); + kwlctl_exec(cmd); + } +} + +/* `theme <name>`, or `next`/`prev` to cycle the installed set. */ +static int theme_switch(const char *arg) { + if (!strcmp(arg, "next") || !strcmp(arg, "prev")) { + if (th.n_installed == 0) return -1; + int step = arg[0] == 'n' ? 1 : th.n_installed - 1; + int next = (th.current < 0 ? 0 : (th.current + step) % th.n_installed); + if (theme_load(th.installed[next]) != 0) return -1; + } else if (theme_load(arg) != 0) { + return -1; + } + theme_apply(); + return 0; +} + /* Parse a MODS token ("SUPER SHIFT" or "SUPER+SHIFT") into a MOD_* mask. * Returns -1 on an unknown modifier name. */ static int parse_mods(char *s, uint32_t *out) { @@ -2140,6 +3548,8 @@ static int parse_mods(char *s, uint32_t *out) { else if (!strcasecmp(tok, "SHIFT")) m |= MOD_SHIFT; else if (!strcasecmp(tok, "CTRL") || !strcasecmp(tok, "CONTROL")) m |= MOD_CTRL; + else if (!strcasecmp(tok, "ALT") || !strcasecmp(tok, "MOD1")) + m |= MOD_ALT; else return -1; } *out = m; @@ -2171,6 +3581,7 @@ static void parse_bind_line(char *rhs) { else if (!strcmp(disp, "killactive")) add_bind(mods, sym, ACT_KILL, 0, NULL); else if (!strcmp(disp, "cyclenext")) add_bind(mods, sym, ACT_CYCLE_NEXT, 0, NULL); else if (!strcmp(disp, "cycleprev")) add_bind(mods, sym, ACT_CYCLE_PREV, 0, NULL); + else if (!strcmp(disp, "theme")) add_bind(mods, sym, ACT_THEME, 0, arg); } /* Load keybinds: parse WLC_CONFIG / WLC_CONFIG_PATH if present, else install @@ -2188,16 +3599,21 @@ static void load_config(void) { while (fgets(line, sizeof(line), f)) { char *s = trim(line); if (*s == '\0' || *s == '#') continue; - if (strncmp(s, "bind", 4) == 0) { - char *eq = strchr(s, '='); - if (eq) parse_bind_line(trim(eq + 1)); - } + char *eq = strchr(s, '='); + if (!eq) continue; + if (strncmp(s, "bind", 4) == 0) parse_bind_line(trim(eq + 1)); + else if (strncmp(s, "theme", 5) == 0) theme_load(trim(eq + 1)); + else if (strncmp(s, "notify", 6) == 0) + snprintf(th.notify, sizeof(th.notify), "%s", trim(eq + 1)); } fclose(f); src = path; } printf("BINDS_LOADED n=%d source=%s\n", g.n_binds, src); + printf("THEME %s\n", th.name); fflush(stdout); + tile_gap_inner = th.gaps_in; + tile_gap_outer = th.gaps_out; } static void handle_keyboard(struct libinput_event_keyboard *k) { @@ -2264,8 +3680,10 @@ static void pointer_moved(void) { wl_fixed_t ly = wl_fixed_from_double(g.cursor_y - g.ptr_focus->y); for (int i = 0; i < MAX_INPUT_RES; i++) if (g.pointers[i] && - wl_resource_get_client(g.pointers[i]) == g.ptr_focus->client) + wl_resource_get_client(g.pointers[i]) == g.ptr_focus->client) { wl_pointer_send_motion(g.pointers[i], t, lx, ly); + ptr_send_frame(g.pointers[i]); + } } /* No repaint on bare motion: with no software cursor the desktop is * pixel-identical until a client commits in response. */ @@ -2278,8 +3696,15 @@ static void handle_pointer_motion_abs(struct libinput_event_pointer *p) { } static void handle_pointer_motion_rel(struct libinput_event_pointer *p) { - double dx = libinput_event_pointer_get_dx(p); - double dy = libinput_event_pointer_get_dy(p); + /* The bridge measures its deltas in the canvas's device pixels — the + * mode — while the cursor lives on the logical grid, so they divide by + * the output scale. Without that a scale-2 desktop moves the pointer + * twice as far as the mouse and a dragged window runs away from it. + * The absolute path above needs no division: get_absolute_*_transformed + * normalizes into whatever range it is handed, and it is handed the + * logical one. */ + double dx = libinput_event_pointer_get_dx(p) / (double)g.scale; + double dy = libinput_event_pointer_get_dy(p) / (double)g.scale; g.cursor_x += dx; g.cursor_y += dy; if (g.cursor_x < 0) g.cursor_x = 0; @@ -2324,11 +3749,18 @@ static void handle_pointer_button(struct libinput_event_pointer *p) { /* Click-to-focus: raise the window under the cursor and give it * keyboard focus before delivering the press. Further presses * while a button is already down join the implicit grab — focus - * stays pinned to the pressed surface. */ + * stays pinned to the pressed surface. + * + * An exclusive layer-shell grab is the one thing a click cannot take + * the keyboard from: wlr-layer-shell-v1 gives the top-most exclusive + * surface on the top and overlay layers focus unconditionally, so + * that a lock screen or password prompt cannot be clicked past. The + * map path applies the same guard. Raising is stacking rather than + * focus, so it still runs. */ struct surface *s = surface_at(g.cursor_x, g.cursor_y); if (s) { zorder_raise(s); - kbd_set_focus(s); + if (!layer_kb_grab()) kbd_set_focus(s); } ptr_refresh_focus(); } @@ -2338,8 +3770,10 @@ static void handle_pointer_button(struct libinput_event_pointer *p) { uint32_t t = now_ms(); for (int i = 0; i < MAX_INPUT_RES; i++) if (g.pointers[i] && - wl_resource_get_client(g.pointers[i]) == g.ptr_focus->client) + wl_resource_get_client(g.pointers[i]) == g.ptr_focus->client) { wl_pointer_send_button(g.pointers[i], serial, t, button, state); + ptr_send_frame(g.pointers[i]); + } } /* The implicit grab ends with the last release: only now may focus @@ -2428,8 +3862,10 @@ static int setup_keymap(void) { * xkb offset), so an evdev KEY_* the compositor receives from libinput * lands on the matching xkb key here. Enough of a real keyboard for a * terminal: letters, digits, common punctuation, space, Return, Tab, - * Backspace, Escape, both Shifts and left Control. Two levels (base / - * Shift) via TWO_LEVEL; the bare action keys are ONE_LEVEL. */ + * Backspace, Escape, both Shifts and left Control, plus F1-F12 and the + * nav cluster (Home/End/PgUp/PgDn/Insert/Delete) for full-screen + * terminal apps. Two levels (base / Shift) via TWO_LEVEL; the bare + * action keys are ONE_LEVEL. */ static const char KEYMAP[] = "xkb_keymap {\n" " xkb_keycodes \"kandelo\" {\n" @@ -2452,6 +3888,13 @@ static int setup_keymap(void) { " <AB05> = 56; <AB06> = 57; <AB07> = 58; <AB08> = 59;\n" " <AB09> = 60; <AB10> = 61; <RTSH> = 62; <SPCE> = 65;\n" " <LWIN> = 133;\n" /* evdev KEY_LEFTMETA (125) + 8: the SUPER key */ + " <LALT> = 64;\n" /* evdev KEY_LEFTALT (56) + 8 */ + " <UP> = 111; <LEFT> = 113; <RGHT> = 114; <DOWN> = 116;\n" + " <FK01> = 67; <FK02> = 68; <FK03> = 69; <FK04> = 70;\n" + " <FK05> = 71; <FK06> = 72; <FK07> = 73; <FK08> = 74;\n" + " <FK09> = 75; <FK10> = 76; <FK11> = 95; <FK12> = 96;\n" + " <HOME> = 110; <PGUP> = 112; <END> = 115; <PGDN> = 117;\n" + " <INS> = 118; <DELE> = 119;\n" " };\n" " xkb_types \"kandelo\" {\n" " virtual_modifiers NumLock;\n" @@ -2479,6 +3922,9 @@ static int setup_keymap(void) { " interpret Super_L+AnyOfOrNone(all) {\n" " action = SetMods(modifiers=Mod4);\n" " };\n" + " interpret Alt_L+AnyOfOrNone(all) {\n" + " action = SetMods(modifiers=Mod1);\n" + " };\n" " };\n" " xkb_symbols \"kandelo\" {\n" " key <ESC> { [ Escape ] };\n" @@ -2490,6 +3936,20 @@ static int setup_keymap(void) { " key <LFSH> { [ Shift_L ] };\n" " key <RTSH> { [ Shift_R ] };\n" " key <LWIN> { [ Super_L ] };\n" + " key <LALT> { [ Alt_L ] };\n" + " key <UP> { [ Up ] };\n" + " key <DOWN> { [ Down ] };\n" + " key <LEFT> { [ Left ] };\n" + " key <RGHT> { [ Right ] };\n" + " key <FK01> { [ F1 ] }; key <FK02> { [ F2 ] };\n" + " key <FK03> { [ F3 ] }; key <FK04> { [ F4 ] };\n" + " key <FK05> { [ F5 ] }; key <FK06> { [ F6 ] };\n" + " key <FK07> { [ F7 ] }; key <FK08> { [ F8 ] };\n" + " key <FK09> { [ F9 ] }; key <FK10> { [ F10 ] };\n" + " key <FK11> { [ F11 ] }; key <FK12> { [ F12 ] };\n" + " key <HOME> { [ Home ] }; key <END> { [ End ] };\n" + " key <PGUP> { [ Prior ] }; key <PGDN> { [ Next ] };\n" + " key <INS> { [ Insert ] }; key <DELE> { [ Delete ] };\n" " key <AE01> { type=\"TWO_LEVEL\", [ 1, exclam ] };\n" " key <AE02> { type=\"TWO_LEVEL\", [ 2, at ] };\n" " key <AE03> { type=\"TWO_LEVEL\", [ 3, numbersign ] };\n" @@ -2540,6 +4000,7 @@ static int setup_keymap(void) { " modifier_map Shift { <LFSH>, <RTSH> };\n" " modifier_map Control { <LCTL> };\n" " modifier_map Mod4 { <LWIN> };\n" + " modifier_map Mod1 { <LALT> };\n" " };\n" "};\n"; @@ -2589,8 +4050,14 @@ static int setup_drm(void) { drmModeConnectorPtr conn = drmModeGetConnector(g.card_fd, g.connector_id); if (!conn || conn->count_modes < 1) { fprintf(stderr, "no modes\n"); return -1; } g.mode = conn->modes[0]; - g.width = g.mode.hdisplay; - g.height = g.mode.vdisplay; + g.pw = g.mode.hdisplay; + g.ph = g.mode.vdisplay; + /* A scale that does not divide the mode would put the logical grid's + * right/bottom edge inside the last device pixel, so the layout could + * place a window the scanout has no room for. Round the logical grid + * down; the remainder stays unpainted background. */ + g.width = g.pw / g.scale; + g.height = g.ph / g.scale; drmModeFreeConnector(conn); drmModeFreeResources(res); @@ -2599,54 +4066,145 @@ static int setup_drm(void) { g.gbm = gbm_create_device(g.card_fd); if (!g.gbm) { fprintf(stderr, "gbm_create_device\n"); return -1; } g.gbm_surface = gbm_surface_create( - g.gbm, g.width, g.height, GBM_FORMAT_XRGB8888, + g.gbm, g.pw, g.ph, GBM_FORMAT_XRGB8888, GBM_BO_USE_SCANOUT | GBM_BO_USE_LINEAR); if (!g.gbm_surface) { fprintf(stderr, "gbm_surface_create\n"); return -1; } return 0; } -/* Pre-render the desktop background once: a vertical gradient with a faint - * grid and a wordmark. Painted per-frame with row memcpys. */ -static int setup_wallpaper(void) { - g.wallpaper = malloc((size_t)g.width * g.height * 4); - if (!g.wallpaper) return -1; +/* A theme's image wallpaper: KWLP raw pixels ("KWLP", u32le width, u32le + * height, then width*height u32le XRGB pixels), centre-cropped to the + * output's aspect and bilinear-scaled to fill it. Raw pixels because nothing + * in the compositor decodes PNG/JPEG: whoever stages the theme (the demo + * page, a test) renders the image and writes the pixels. The crop is what + * frees the stager from knowing the mode — it cannot, because the image is + * baked into the VFS before the mode is picked. */ +static int render_wallpaper_image(const char *path) { + FILE *f = fopen(path, "rb"); + if (!f) return -1; + uint8_t hdr[12]; + uint32_t sw = 0, sh = 0; + if (fread(hdr, 1, 12, f) != 12 || memcmp(hdr, "KWLP", 4) != 0) goto fail; + sw = hdr[4] | hdr[5] << 8 | hdr[6] << 16 | (uint32_t)hdr[7] << 24; + sh = hdr[8] | hdr[9] << 8 | hdr[10] << 16 | (uint32_t)hdr[11] << 24; + if (sw < 1 || sh < 1 || sw > 8192 || sh > 8192) goto fail; + uint32_t *src = malloc((size_t)sw * sh * 4); + if (!src) goto fail; + if (fread(src, 4, (size_t)sw * sh, f) != (size_t)sw * sh) { + free(src); + goto fail; + } + fclose(f); - for (uint32_t y = 0; y < g.height; y++) { - /* #10121a (top) → #1b2233 (bottom). */ - uint32_t t = g.height > 1 ? (y * 256u) / (g.height - 1) : 0; - uint32_t rr = 0x10 + ((0x1b - 0x10) * t) / 256; - uint32_t gg = 0x12 + ((0x22 - 0x12) * t) / 256; - uint32_t bb = 0x1a + ((0x33 - 0x1a) * t) / 256; + /* The source rect the output samples: the axis the output is relatively + * narrower in is used whole, the other is cropped equally on both sides. */ + uint32_t cw = sw, ch = sh; + if ((uint64_t)sw * g.ph > (uint64_t)g.pw * sh) + cw = (uint32_t)(((uint64_t)g.pw * sh) / g.ph); + else + ch = (uint32_t)(((uint64_t)g.ph * sw) / g.pw); + if (cw < 1) cw = 1; + if (ch < 1) ch = 1; + uint32_t ox = (sw - cw) / 2, oy = (sh - ch) / 2; + + for (uint32_t y = 0; y < g.ph; y++) { + uint32_t fy = oy * 256u + + (g.ph > 1 ? (uint32_t)(((uint64_t)y * 256u * (ch - 1)) / (g.ph - 1)) : 0); + uint32_t y0 = fy >> 8, wy = fy & 0xff; + uint32_t y1 = y0 + 1 < sh ? y0 + 1 : y0; + uint32_t *row = g.wallpaper + (size_t)y * g.pw; + for (uint32_t x = 0; x < g.pw; x++) { + uint32_t fx = ox * 256u + + (g.pw > 1 ? (uint32_t)(((uint64_t)x * 256u * (cw - 1)) / (g.pw - 1)) : 0); + uint32_t x0 = fx >> 8, wx = fx & 0xff; + uint32_t x1 = x0 + 1 < sw ? x0 + 1 : x0; + uint32_t p00 = src[(size_t)y0 * sw + x0], p01 = src[(size_t)y0 * sw + x1]; + uint32_t p10 = src[(size_t)y1 * sw + x0], p11 = src[(size_t)y1 * sw + x1]; + uint32_t px = 0xff000000u; + for (int shift = 0; shift <= 16; shift += 8) { + uint32_t t0 = ((p00 >> shift & 0xff) * (256 - wx) + + (p01 >> shift & 0xff) * wx) >> 8; + uint32_t t1 = ((p10 >> shift & 0xff) * (256 - wx) + + (p11 >> shift & 0xff) * wx) >> 8; + px |= ((t0 * (256 - wy) + t1 * wy) >> 8) << shift; + } + row[x] = px; + } + } + free(src); + printf("WALLPAPER image w=%u h=%u crop=%ux%u+%u+%u\n", sw, sh, cw, ch, ox, oy); + fflush(stdout); + return 0; + +fail: + fclose(f); + return -1; +} + +/* Paint the desktop background into g.wallpaper: the theme's image when it + * has one, else a vertical gradient between the theme's two wallpaper colors, + * a faint grid, and the wordmark. Re-run on every theme switch. */ +static void render_wallpaper(void) { + if (th.wallpaper_path[0] && + render_wallpaper_image(th.wallpaper_path) == 0) + return; + const uint32_t top = th.wallpaper_top, bot = th.wallpaper_bottom; + for (uint32_t y = 0; y < g.ph; y++) { + uint32_t t = g.ph > 1 ? (y * 256u) / (g.ph - 1) : 0; + uint32_t rr = ((top >> 16) & 0xff) + + ((int)((bot >> 16) & 0xff) - (int)((top >> 16) & 0xff)) * (int)t / 256; + uint32_t gg = ((top >> 8) & 0xff) + + ((int)((bot >> 8) & 0xff) - (int)((top >> 8) & 0xff)) * (int)t / 256; + uint32_t bb = (top & 0xff) + + ((int)(bot & 0xff) - (int)(top & 0xff)) * (int)t / 256; uint32_t px = 0xff000000u | (rr << 16) | (gg << 8) | bb; - uint32_t *row = g.wallpaper + (size_t)y * g.width; - for (uint32_t x = 0; x < g.width; x++) row[x] = px; + uint32_t *row = g.wallpaper + (size_t)y * g.pw; + for (uint32_t x = 0; x < g.pw; x++) row[x] = px; } struct wpk_surface wp = - wpk_surface_wrap(g.wallpaper, (int)g.width, (int)g.height, 0); - - /* Faint 120px grid. */ + wpk_surface_wrap(g.wallpaper, (int)g.pw, (int)g.ph, 0); + + /* The wallpaper is drawn straight into the scanout, which is device + * pixels, so every measurement here is multiplied by the output scale by + * hand. wlcompositor must not call wpk_set_scale(): that setting is + * process-wide and captured by wpk_surface_wrap / wpk_font_load_default at + * call time, and the compositor wraps client buffers with the same + * library. The font is loaded at the scaled size, so the glyphs rasterize + * sharp rather than being magnified. */ + const int scale = (int)g.scale; + + /* Faint 120 logical-px grid. */ const wpk_color grid = 0x0affffffu; /* ~4% white */ - for (uint32_t x = 0; x < g.width; x += 120) - wpk_rect(&wp, (int)x, 0, 1, (int)g.height, grid); - for (uint32_t y = 0; y < g.height; y += 120) - wpk_rect(&wp, 0, (int)y, (int)g.width, 1, grid); + for (uint32_t x = 0; x < g.pw; x += 120 * (uint32_t)scale) + wpk_rect(&wp, (int)x, 0, scale, (int)g.ph, grid); + for (uint32_t y = 0; y < g.ph; y += 120 * (uint32_t)scale) + wpk_rect(&wp, 0, (int)y, (int)g.pw, scale, grid); - struct wpk_font *big = wpk_font_load_default(56); - struct wpk_font *small = wpk_font_load_default(20); + struct wpk_font *big = wpk_font_load_default(56 * scale); + struct wpk_font *small = wpk_font_load_default(20 * scale); if (big) { - wpk_text(&wp, big, 96, (int)g.height - 150, "Kandelo", + wpk_text(&wp, big, 96 * scale, (int)g.ph - 150 * scale, "Kandelo", WPK_RGB(0x3e, 0x4a, 0x66)); wpk_font_destroy(big); } if (small) { - wpk_text(&wp, small, 98, (int)g.height - 112, + wpk_text(&wp, small, 98 * scale, (int)g.ph - 112 * scale, "Wayland on a wasm32 POSIX kernel", WPK_RGB(0x36, 0x40, 0x58)); - wpk_text(&wp, small, 98, (int)g.height - 84, + wpk_text(&wp, small, 98 * scale, (int)g.ph - 84 * scale, "click to focus - drag title bars to move windows", WPK_RGB(0x2e, 0x37, 0x4c)); wpk_font_destroy(small); } + printf("WALLPAPER gradient\n"); + fflush(stdout); +} + +/* Allocate the background once the mode is known, then paint it. */ +static int setup_wallpaper(void) { + g.wallpaper = malloc((size_t)g.pw * g.ph * 4); + if (!g.wallpaper) return -1; + render_wallpaper(); return 0; } @@ -2721,13 +4279,44 @@ static void kwlctl_emit(const char *fmt, ...) { if (g.listeners[i]) kwlctl_send(g.listeners[i]->fd, buf, n); } -/* JSON describing one surface, Hyprland `clients -j`-shaped subset. */ +/* Copy `src` as JSON string content: quote, backslash, and control bytes + * escaped. Titles are client-controlled text. */ +static const char *json_escape(char *dst, size_t cap, const char *src) { + size_t n = 0; + for (; *src && n + 7 < cap; src++) { + unsigned char ch = (unsigned char)*src; + if (ch == '"' || ch == '\\') { + dst[n++] = '\\'; + dst[n++] = (char)ch; + } else if (ch < 0x20) { + n += (size_t)snprintf(dst + n, cap - n, "\\u%04x", ch); + } else { + dst[n++] = (char)ch; + } + } + dst[n] = '\0'; + return dst; +} + +/* JSON describing one surface, Hyprland `hyprctl clients -j`-shaped. The + * fields beyond the original subset carry the keys Waybar's hyprland + * modules read; extra keys are ignored by kbar's strstr parser. */ static int kwlctl_window_json(char *buf, size_t cap, struct surface *s) { + char klass[64], title[192]; + json_escape(klass, sizeof(klass), s->app_id); + json_escape(title, sizeof(title), s->title); return snprintf(buf, cap, - "{\"address\":\"%p\",\"class\":\"%s\",\"workspace\":{\"id\":%d}," - "\"at\":[%d,%d],\"size\":[%d,%d],\"focused\":%s}", - (void *)s, s->app_id, s->workspace, s->x, s->y, s->w, s->h, - g.kbd_focus == s ? "true" : "false"); + "{\"address\":\"%p\",\"class\":\"%s\",\"title\":\"%s\"," + "\"initialClass\":\"%s\",\"initialTitle\":\"%s\"," + "\"workspace\":{\"id\":%d,\"name\":\"%d\"}," + "\"at\":[%d,%d],\"size\":[%d,%d],\"focused\":%s," + "\"mapped\":true,\"hidden\":false,\"floating\":%s,\"monitor\":0," + "\"pid\":-1,\"xwayland\":false,\"fullscreen\":false," + "\"grouped\":[],\"swallowing\":\"0x0\"}", + (void *)s, klass, title, klass, title, + s->workspace, s->workspace, s->x, s->y, s->w, s->h, + g.kbd_focus == s ? "true" : "false", + g.layout == LAYOUT_FLOATING ? "true" : "false"); } static int kwlctl_clients_json(char *buf, size_t cap) { @@ -2744,24 +4333,68 @@ static int kwlctl_clients_json(char *buf, size_t cap) { return n; } -static int kwlctl_workspaces_json(char *buf, size_t cap) { - int counts[N_WORKSPACES + 1] = {0}; +/* One workspace, Hyprland-shaped; `windows` and `active` predate the shape + * and stay for kbar. Workspace names are their ids, and everything sits on + * the single output. */ +static int kwlctl_workspace_json(char *buf, size_t cap, int ws, int windows) { + return snprintf(buf, cap, + "{\"id\":%d,\"name\":\"%d\",\"monitor\":\"virtual-0\"," + "\"monitorID\":0,\"windows\":%d,\"hasfullscreen\":false," + "\"lastwindow\":\"0x0\",\"lastwindowtitle\":\"\",\"active\":%s}", + ws, ws, windows, ws == g.active_ws ? "true" : "false"); +} + +static void workspace_counts(int counts[N_WORKSPACES + 1]) { + memset(counts, 0, (N_WORKSPACES + 1) * sizeof(int)); for (int i = 0; i < g.n_surfaces; i++) if (g.zorder[i]->mapped) counts[g.zorder[i]->workspace]++; +} + +static int kwlctl_workspaces_json(char *buf, size_t cap) { + int counts[N_WORKSPACES + 1]; + workspace_counts(counts); int n = snprintf(buf, cap, "["); int first = 1; for (int ws = 1; ws <= N_WORKSPACES && n < (int)cap; ws++) { if (!counts[ws] && ws != g.active_ws) continue; - n += snprintf(buf + n, cap - n, - "%s{\"id\":%d,\"windows\":%d,\"active\":%s}", - first ? "" : ",", ws, counts[ws], - ws == g.active_ws ? "true" : "false"); + if (!first) n += snprintf(buf + n, cap - n, ","); + n += kwlctl_workspace_json(buf + n, cap - n, ws, counts[ws]); first = 0; } n += snprintf(buf + n, cap - n, "]\n"); return n; } +static int kwlctl_activeworkspace_json(char *buf, size_t cap) { + int counts[N_WORKSPACES + 1]; + workspace_counts(counts); + int n = kwlctl_workspace_json(buf, cap, g.active_ws, + counts[g.active_ws]); + n += snprintf(buf + n, cap - n, "\n"); + return n; +} + +static int kwlctl_monitors_json(char *buf, size_t cap) { + return snprintf(buf, cap, + "[{\"id\":0,\"name\":\"virtual-0\",\"description\":\"Kandelo virtual " + "output\",\"width\":%u,\"height\":%u,\"x\":0,\"y\":0," + "\"activeWorkspace\":{\"id\":%d,\"name\":\"%d\"}," + "\"specialWorkspace\":{\"id\":0,\"name\":\"\"}," + "\"scale\":1.00,\"focused\":true}]\n", + g.width, g.height, g.active_ws, g.active_ws); +} + +/* The live theme + what is installed, so a shell client that starts after a + * switch can pick up the current palette without watching the event stream. */ +static int kwlctl_theme_json(char *buf, size_t cap) { + int n = snprintf(buf, cap, "{\"name\":\"%s\",\"themes\":[", th.name); + for (int i = 0; i < th.n_installed && n < (int)cap; i++) + n += snprintf(buf + n, cap - n, "%s\"%s\"", i ? "," : "", + th.installed[i]); + n += snprintf(buf + n, cap - n, "]}\n"); + return n; +} + static int kwlctl_activewindow_json(char *buf, size_t cap) { if (!g.kbd_focus) return snprintf(buf, cap, "{}\n"); int n = kwlctl_window_json(buf, cap, g.kbd_focus); @@ -2786,7 +4419,11 @@ static void kwlctl_exec(char *args) { pid_t pid = 0; int rc = posix_spawnp(&pid, argv[0], NULL, NULL, argv, environ); if (rc != 0) { + /* A failed launch is reported on stdout too: a keybind or launcher + * entry pointing at a missing binary is otherwise a silent no-op. */ fprintf(stderr, "posix_spawnp %s: %s\n", argv[0], strerror(rc)); + printf("KWLCTL_EXEC_FAILED \"%s\" err=%d\n", argv[0], rc); + fflush(stdout); return; } printf("KWLCTL_EXEC \"%s\" pid=%d\n", argv[0], (int)pid); @@ -2805,7 +4442,11 @@ static void kwlctl_conn_close(struct kwlctl_conn *c) { /* Execute one command line. Returns 1 to keep the connection open (--listen), * 0 to close after the reply. */ static int kwlctl_handle(struct kwlctl_conn *c, char *line) { - char buf[4096]; + /* hyprctl's JSON marker: the reply is JSON either way, so `j/workspaces` + * and `workspaces` answer identically. */ + if (strncmp(line, "j/", 2) == 0) line += 2; + /* MAX_SURFACES windows x ~500 bytes of client JSON fits with headroom. */ + char buf[16384]; if (strcmp(line, "clients") == 0) { kwlctl_send(c->fd, buf, kwlctl_clients_json(buf, sizeof(buf))); return 0; @@ -2814,14 +4455,32 @@ static int kwlctl_handle(struct kwlctl_conn *c, char *line) { kwlctl_send(c->fd, buf, kwlctl_workspaces_json(buf, sizeof(buf))); return 0; } + if (strcmp(line, "activeworkspace") == 0) { + kwlctl_send(c->fd, buf, kwlctl_activeworkspace_json(buf, sizeof(buf))); + return 0; + } + if (strcmp(line, "monitors") == 0) { + kwlctl_send(c->fd, buf, kwlctl_monitors_json(buf, sizeof(buf))); + return 0; + } + if (strcmp(line, "workspacerules") == 0) { + kwlctl_send(c->fd, "[]\n", 3); + return 0; + } if (strcmp(line, "activewindow") == 0) { kwlctl_send(c->fd, buf, kwlctl_activewindow_json(buf, sizeof(buf))); return 0; } + if (strcmp(line, "theme") == 0) { + kwlctl_send(c->fd, buf, kwlctl_theme_json(buf, sizeof(buf))); + return 0; + } if (strncmp(line, "dispatch ", 9) == 0) { char *op = line + 9; if (strncmp(op, "workspace ", 10) == 0) switch_workspace(atoi(op + 10)); + else if (strncmp(op, "focusworkspaceoncurrentmonitor ", 31) == 0) + switch_workspace(atoi(op + 31)); else if (strncmp(op, "movetoworkspace ", 16) == 0) move_focus_to_workspace(atoi(op + 16)); else if (strcmp(op, "close") == 0) { @@ -2829,7 +4488,12 @@ static int kwlctl_handle(struct kwlctl_conn *c, char *line) { xdg_toplevel_send_close(g.kbd_focus->xdg_toplevel); } else if (strncmp(op, "exec ", 5) == 0) kwlctl_exec(op + 5); - else { + else if (strncmp(op, "theme ", 6) == 0) { + if (theme_switch(op + 6) != 0) { + kwlctl_send(c->fd, "err no such theme\n", 18); + return 0; + } + } else { kwlctl_send(c->fd, "err unknown dispatch\n", 21); return 0; } @@ -2837,6 +4501,7 @@ static int kwlctl_handle(struct kwlctl_conn *c, char *line) { return 0; } if (strcmp(line, "--listen") == 0) { + if (c->listening) return 1; /* a socket2 conn already streams */ for (int i = 0; i < MAX_KWLCTL_CONNS; i++) if (!g.listeners[i]) { g.listeners[i] = c; @@ -2878,23 +4543,57 @@ static int kwlctl_listen_readable(int fd, uint32_t mask, void *data) { return 0; } -static int setup_kwlctl(void) { - unlink(KWLCTL_SOCKET_PATH); +/* A socket2 client gets the event stream from its first byte on; there is + * no handshake line to send. */ +static int hypr_socket2_readable(int fd, uint32_t mask, void *data) { + (void)mask; (void)data; + int cfd = accept(fd, NULL, NULL); + if (cfd < 0) return 0; + fcntl(cfd, F_SETFD, FD_CLOEXEC); + struct kwlctl_conn *c = calloc(1, sizeof(*c)); + if (!c) { close(cfd); return 0; } + c->fd = cfd; + c->src = wl_event_loop_add_fd(g.loop, cfd, WL_EVENT_READABLE, + kwlctl_conn_readable, c); + for (int i = 0; i < MAX_KWLCTL_CONNS; i++) + if (!g.listeners[i]) { + g.listeners[i] = c; + c->listening = 1; + printf("HYPR_LISTENER slot=%d\n", i); + fflush(stdout); + return 0; + } + kwlctl_conn_close(c); + return 0; +} + +static int bind_control_socket(const char *path, + wl_event_loop_fd_func_t on_readable) { + unlink(path); int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); - if (fd < 0) { perror("socket kwlctl"); return -1; } + if (fd < 0) { perror("socket ctl"); return -1; } struct sockaddr_un addr; memset(&addr, 0, sizeof(addr)); addr.sun_family = AF_UNIX; - strncpy(addr.sun_path, KWLCTL_SOCKET_PATH, sizeof(addr.sun_path) - 1); + strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { - perror("bind kwlctl"); close(fd); return -1; + perror("bind ctl"); close(fd); return -1; } - if (listen(fd, 8) < 0) { perror("listen kwlctl"); close(fd); return -1; } - wl_event_loop_add_fd(g.loop, fd, WL_EVENT_READABLE, kwlctl_listen_readable, - NULL); + if (listen(fd, 8) < 0) { perror("listen ctl"); close(fd); return -1; } + wl_event_loop_add_fd(g.loop, fd, WL_EVENT_READABLE, on_readable, NULL); return 0; } +static int setup_kwlctl(void) { + if (bind_control_socket(KWLCTL_SOCKET_PATH, kwlctl_listen_readable) < 0) + return -1; + mkdir("/tmp/hypr", 0777); + mkdir(HYPR_DIR, 0777); + if (bind_control_socket(HYPR_SOCKET1_PATH, kwlctl_listen_readable) < 0) + return -1; + return bind_control_socket(HYPR_SOCKET2_PATH, hypr_socket2_readable); +} + static int setup_socket(void) { unlink(WL_SOCKET_PATH); /* clear a stale socket */ @@ -2928,9 +4627,26 @@ int main(void) { printf("WLC_LAYOUT %s\n", g.layout == LAYOUT_DWINDLE ? "dwindle" : "floating"); fflush(stdout); + + /* The embedder sizes the mode in device pixels, so the compositor cannot + * recover the scale from it — a 2176x1226 mode is a dpr-2 pane and a + * dpr-1 one alike. WLC_SCALE is how the page passes what it knows. */ + g.scale = 1; + const char *want_scale = getenv("WLC_SCALE"); + if (want_scale) { + long v = strtol(want_scale, NULL, 10); + if (v >= 1 && v <= MAX_OUTPUT_SCALE) g.scale = (uint32_t)v; + else fprintf(stderr, "wlcompositor: ignoring WLC_SCALE=%s\n", want_scale); + } + printf("WLC_SCALE %u\n", g.scale); + fflush(stdout); + theme_scan(); load_config(); if (setup_drm() != 0) return 1; + /* No layer surface has claimed anything yet, so the window work area is + * the whole output. */ + g.usable = (struct geom){ 0, 0, (int)g.width, (int)g.height }; if (setup_wallpaper() != 0) return 1; /* GPU compositing is best-effort: on hosts without WebGL2 (Node * smokes, degraded headless) the probe fails and we CPU-composite. */ @@ -2950,8 +4666,22 @@ int main(void) { wm_base_bind) || !wl_global_create(g.display, &zxdg_decoration_manager_v1_interface, 1, NULL, decoration_mgr_bind) || - !wl_global_create(g.display, &wl_seat_interface, 1, NULL, seat_bind) || - !wl_global_create(g.display, &wl_output_interface, 2, NULL, + !wl_global_create(g.display, &zwlr_layer_shell_v1_interface, 4, NULL, + layer_shell_bind) || + !wl_global_create(g.display, &wp_presentation_interface, 1, NULL, + presentation_bind) || + !wl_global_create(g.display, &wl_subcompositor_interface, 1, NULL, + subcompositor_bind) || + !wl_global_create(g.display, &zxdg_output_manager_v1_interface, 3, + NULL, xdg_output_mgr_bind) || + !wl_global_create(g.display, &wp_viewporter_interface, 1, NULL, + viewporter_bind) || + !wl_global_create(g.display, &wp_fractional_scale_manager_v1_interface, + 1, NULL, fractional_scale_mgr_bind) || + !wl_global_create(g.display, &wl_data_device_manager_interface, 3, + NULL, data_dm_bind) || + !wl_global_create(g.display, &wl_seat_interface, 5, NULL, seat_bind) || + !wl_global_create(g.display, &wl_output_interface, 4, NULL, output_bind)) { fprintf(stderr, "wl_global_create failed\n"); return 1; @@ -2973,6 +4703,15 @@ int main(void) { /* Auto-reap `dispatch exec` children so they don't linger as zombies. */ signal(SIGCHLD, SIG_IGN); + /* A control client is free to fire a command and exit without reading the + * reply — klauncher does exactly that when it launches an entry. Writing + * that reply into the closed socket must not take the desktop down with + * it; kwlctl_send already handles the short write. */ + signal(SIGPIPE, SIG_IGN); + /* Children spawned via dispatch exec (Waybar among them) locate the + * Hyprland IPC sockets through this pair. */ + setenv("HYPRLAND_INSTANCE_SIGNATURE", HYPR_INSTANCE_SIG, 1); + setenv("XDG_RUNTIME_DIR", "/tmp", 0); if (setup_kwlctl() != 0) return 1; printf("COMPOSITOR_UP w=%u h=%u\n", g.width, g.height); diff --git a/programs/wlpaint.c b/programs/wlpaint.c index 4c92638854..4cf8fc3f2f 100644 --- a/programs/wlpaint.c +++ b/programs/wlpaint.c @@ -155,9 +155,21 @@ static void render(struct wpk_surface *s, struct wpk_font *font) { wpk_rect(s, 0, TOOLBAR_H, s->w, area_h, CANVAS_BG); int cw = s->w < canvas_w ? s->w : canvas_w; int ch = area_h < canvas_h ? area_h : canvas_h; - for (int y = 0; y < ch; y++) - memcpy(s->pixels + (size_t)(y + TOOLBAR_H) * (s->stride / 4), - canvas + (size_t)y * canvas_w, (size_t)cw * 4); + /* The canvas holds one entry per LOGICAL pixel, while s->pixels is the + * device-resolution buffer and s->stride counts device rows. Expand each + * entry into a scale x scale block: using a logical row index as a device + * row would start the blit at TOOLBAR_H instead of TOOLBAR_H * scale and + * paint canvas over the bottom of the toolbar. */ + int row = s->stride / 4; + for (int y = 0; y < ch; y++) { + const uint32_t *src = canvas + (size_t)y * canvas_w; + for (int sy = 0; sy < s->scale; sy++) { + uint32_t *dst = + s->pixels + (size_t)((TOOLBAR_H + y) * s->scale + sy) * row; + for (int x = 0; x < cw; x++) + for (int sx = 0; sx < s->scale; sx++) *dst++ = src[x]; + } + } } int main(void) { diff --git a/programs/wlterm/wlterm.c b/programs/wlterm/wlterm.c index 9b43976ec3..e22f34c1e7 100644 --- a/programs/wlterm/wlterm.c +++ b/programs/wlterm/wlterm.c @@ -108,7 +108,11 @@ int main(int argc, char **argv) { pid_t pid = forkpty(&master, NULL, NULL, &ws); if (pid < 0) { perror("forkpty"); return 1; } if (pid == 0) { - /* Child: exec argv[ai..], defaulting to an interactive dash. */ + /* Child: exec argv[ai..], defaulting to an interactive dash. The + * inherited TERM describes the launcher's terminal, not this one: + * curses apps (vim, nethack, nano) must see the type this terminal + * actually implements. */ + setenv("TERM", "vt100", 1); if (ai < argc) { execvp(argv[ai], &argv[ai]); } else { @@ -177,11 +181,18 @@ int main(int argc, char **argv) { }; ioctl(master, TIOCSWINSZ, &nws); if (pid > 0) kill(pid, SIGWINCH); - vt100_render(term, s, font, 0, 0); - kwl_window_commit(win); - printf("WLTERM_RESIZE cols=%d rows=%d\n", cols, rows); - fflush(stdout); } + /* Commit even when the grid kept its size: the resize + * rebuilt both buffers, so the compositor holds no buffer + * for this surface until the next commit — a tile that + * shifts by less than a cell (a theme's gap change) would + * otherwise leave the window invisible until the shell + * prints again. */ + vt100_mark_dirty_all(term); + vt100_render(term, s, font, 0, 0); + kwl_window_commit(win); + printf("WLTERM_RESIZE cols=%d rows=%d\n", cols, rows); + fflush(stdout); } } diff --git a/programs/wpkdraw_smoke.c b/programs/wpkdraw_smoke.c index 23eb49a0b8..1b412b263f 100644 --- a/programs/wpkdraw_smoke.c +++ b/programs/wpkdraw_smoke.c @@ -65,6 +65,27 @@ int main(void) { printf("AA_DISC core=0x%08x fringe=%d\n", buf[20 * W + 105], disc_fringe); wpk_font_destroy(f); + + /* Scale 2: the same logical drawing covers four times the pixels, and a + * logical coordinate lands at twice the device one. The rect's logical + * (10,10)-(30,30) must fill device (20,20)-(60,60) exactly — its device + * corner is lit and the pixel just outside it is not — and the glyph + * masks must be rasterized bigger rather than blown up, which is what + * the wider text makes visible. */ + wpk_set_scale(2); + struct wpk_surface s2 = wpk_surface_wrap(buf, W / 2, H / 2, STRIDE); + wpk_clear(&s2, WPK_RGB(0, 0, 0)); + wpk_rect(&s2, 10, 10, 20, 20, WPK_RGB(255, 0, 0)); + printf("SCALED_RECT in=0x%08x edge=0x%08x out=0x%08x\n", + buf[40 * W + 40], buf[59 * W + 59], buf[60 * W + 60]); + + struct wpk_font *f2 = wpk_font_load_default(16); + if (!f2) { perror("wpk_font_load_default"); free(buf); return 1; } + printf("SCALED_TEXT_WIDTH s=OK w=%d\n", wpk_text_width(f2, "OK")); + printf("SCALED_ASCENT px=%d\n", wpk_font_ascent_px(f2)); + wpk_font_destroy(f2); + wpk_set_scale(1); + free(buf); printf("WPKDRAW_SMOKE_OK\n"); return 0; diff --git a/run.sh b/run.sh index 4a706d421f..dbb25990ee 100755 --- a/run.sh +++ b/run.sh @@ -468,6 +468,10 @@ has_unzip() { pkg_has_output unzip unzip.wasm || [ -f "$REPO_ROOT/packag has_nano() { pkg_has_output nano nano.wasm || [ -f "$REPO_ROOT/packages/registry/nano/bin/nano.wasm" ]; } has_nethack() { pkg_has_output nethack nethack.wasm || [ -f "$REPO_ROOT/packages/registry/nethack/bin/nethack.wasm" ]; } has_fbdoom() { pkg_has_output fbdoom fbdoom.wasm || [ -f "$REPO_ROOT/packages/registry/fbdoom/fbdoom.wasm" ]; } +has_foot() { pkg_has_output foot foot.wasm; } +has_dbus() { pkg_has_output dbus dbus-daemon.wasm; } +has_mako() { pkg_has_output mako mako.wasm; } +has_waybar() { pkg_has_output waybar waybar.wasm; } has_vim() { pkg_has_output vim vim.wasm || [ -f "$REPO_ROOT/packages/registry/vim/bin/vim.wasm" ]; } has_git() { pkg_has_output git git.wasm || [ -f "$REPO_ROOT/packages/registry/git/bin/git.wasm" ]; } has_perl() { pkg_has_output perl perl.wasm || [ -f "$REPO_ROOT/packages/registry/perl/bin/perl.wasm" ]; } @@ -1716,6 +1720,66 @@ build_fbdoom() { bootstrap_target fbdoom } +build_foot() { + if has_foot; then + info "foot" + return + fi + need_kernel + need_sdk + step "Resolving foot.wasm" + local host_target + host_target="$(rustc -vV | awk '/^host/ {print $2}')" + (cd "$REPO_ROOT" && cargo run -p xtask --target "$host_target" --quiet -- \ + build-deps --arch wasm32 --binaries-dir "$REPO_ROOT/binaries" resolve foot) + info "foot resolved" +} + +build_dbus() { + if has_dbus; then + info "dbus" + return + fi + need_kernel + need_sdk + step "Resolving dbus-daemon.wasm" + local host_target + host_target="$(rustc -vV | awk '/^host/ {print $2}')" + (cd "$REPO_ROOT" && cargo run -p xtask --target "$host_target" --quiet -- \ + build-deps --arch wasm32 --binaries-dir "$REPO_ROOT/binaries" resolve dbus) + info "dbus resolved" +} + +build_mako() { + if has_mako; then + info "mako" + return + fi + need_kernel + need_sdk + step "Resolving mako.wasm" + local host_target + host_target="$(rustc -vV | awk '/^host/ {print $2}')" + (cd "$REPO_ROOT" && cargo run -p xtask --target "$host_target" --quiet -- \ + build-deps --arch wasm32 --binaries-dir "$REPO_ROOT/binaries" resolve mako) + info "mako resolved" +} + +build_waybar() { + if has_waybar; then + info "waybar" + return + fi + need_kernel + need_sdk + step "Resolving waybar.wasm" + local host_target + host_target="$(rustc -vV | awk '/^host/ {print $2}')" + (cd "$REPO_ROOT" && cargo run -p xtask --target "$host_target" --quiet -- \ + build-deps --arch wasm32 --binaries-dir "$REPO_ROOT/binaries" resolve waybar) + info "waybar resolved" +} + build_vim() { bootstrap_target vim } @@ -1828,6 +1892,10 @@ build_target() { nethack) build_nethack ;; nethack-zip) build_nethack_zip ;; fbdoom) build_fbdoom ;; + foot) build_foot ;; + dbus) build_dbus ;; + mako) build_mako ;; + waybar) build_waybar ;; ncurses) build_ncurses ;; zlib) build_zlib ;; openssl) build_openssl ;; @@ -1852,7 +1920,7 @@ build_target() { # sysroot/sysroot64 are NOT listed: they're toolchain prerequisites for source # builds, and any `build_X` whose prebuilt is missing calls `need_sysroot` # lazily. -BROWSER_DEPS=(kernel rootfs programs dash bash coreutils grep sed bc file less m4 make tar curl-cli wget gzip bzip2 xz zstd zip unzip nano lsof vim vim-zip nethack nethack-zip fbdoom git dinit msmtpd nginx nginx-vfs php php-fpm nginx-php-vfs mariadb mariadb-vfs mariadb-test mariadb64 mariadb64-vfs shell-vfs spidermonkey-node node node-vfs wp-vfs lamp-vfs) +BROWSER_DEPS=(kernel rootfs programs dash bash coreutils grep sed bc file less m4 make tar curl-cli wget gzip bzip2 xz zstd zip unzip nano lsof vim vim-zip nethack nethack-zip fbdoom foot dbus mako waybar git dinit msmtpd nginx nginx-vfs php php-fpm nginx-php-vfs mariadb mariadb-vfs mariadb-test mariadb64 mariadb64-vfs shell-vfs spidermonkey-node node node-vfs wp-vfs lamp-vfs) build_browser() { for t in "${BROWSER_DEPS[@]}"; do diff --git a/scripts/build-programs.sh b/scripts/build-programs.sh index 87c73f5544..ed7e6c3780 100755 --- a/scripts/build-programs.sh +++ b/scripts/build-programs.sh @@ -107,6 +107,18 @@ package_owns_direct_program_path() { grep -Fxq -- "$arch/$mirror" <<<"$PACKAGE_OWNED_PROGRAM_MIRRORS" } +# A multi-output package publishes `<package>/<output>.wasm`, so the direct +# mirror above never matches it. The resolver rejects the flat path for such +# an output, which makes a local fixture at that path unreachable. +package_owns_program_output() { + local arch="$1" + local output="$2" + [ -n "$PACKAGE_OWNED_PROGRAM_MIRRORS" ] && + awk -F/ -v arch="$arch" -v out="$output" \ + '$1 == arch && $NF == out { found = 1 } END { exit !found }' \ + <<<"$PACKAGE_OWNED_PROGRAM_MIRRORS" +} + find_llvm_bin() { if [ -n "${LLVM_BIN:-}" ] && [ -x "$LLVM_BIN/clang" ]; then echo "$LLVM_BIN" @@ -170,6 +182,7 @@ LINK_POST_LIBS=( -Wl,--import-memory -Wl,--shared-memory -Wl,--max-memory=1073741824 + -Wl,-z,stack-size=8388608 -Wl,--allow-undefined -Wl,--table-base=3 -Wl,--export-table @@ -220,6 +233,10 @@ build_program() { echo " Skipping $name: package resolver owns $arch/${name}.wasm" return 0 fi + if [ -n "$arch" ] && package_owns_program_output "$arch" "${name}.wasm"; then + echo " Skipping $name: a package publishes ${name}.wasm" + return 0 + fi # Auto-append GL stubs when the source pulls in EGL/GLES headers. # Static linking won't pick symbols out of libEGL.a / libGLESv2.a @@ -306,6 +323,20 @@ build_cpp_program() { rm -f "$raw_wasm" } +# The sysroot is also the SDK seed that package builds copy into their private +# sysroot, and that seed accepts only directories and regular files +# (kandelo_package_require_regular_input_tree in scripts/package-build-roots.sh). +# A resolver output therefore lands here as a copy, never as a symlink. +stage_sysroot_file() { + rm -f "$2" + cp "$1" "$2" +} + +stage_sysroot_tree() { + rm -rf "$2" + cp -RL "$1" "$2" +} + ensure_libcxx_in_sysroot() { local arch="$1" local sysroot="$2" @@ -366,7 +397,7 @@ if ls "$REPO_ROOT"/programs/sdl2_*.c >/dev/null 2>&1 \ cp -R "$SDL2_PREFIX/include/SDL2" "$SYSROOT/include/SDL2" fi -# Resolve libwayland (+ its deps libffi + wayland-protocols) and symlink +# Resolve libwayland (+ its deps libffi + wayland-protocols) and copy # its client/server archives, the libffi shim archive, and the public # headers into the sysroot when there are any wl_*.c programs to build. # libwayland's protocol glue is generated at resolve time from the @@ -381,16 +412,53 @@ if ls "$REPO_ROOT"/programs/wl_*.c >/dev/null 2>&1; then LIBWL_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path libwayland)" LIBFFI_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path libffi)" - ln -sfn "$LIBWL_PREFIX/lib/libwayland-client.a" "$SYSROOT/lib/libwayland-client.a" - ln -sfn "$LIBWL_PREFIX/lib/libwayland-server.a" "$SYSROOT/lib/libwayland-server.a" - ln -sfn "$LIBFFI_PREFIX/lib/libffi.a" "$SYSROOT/lib/libffi.a" + stage_sysroot_file "$LIBWL_PREFIX/lib/libwayland-client.a" "$SYSROOT/lib/libwayland-client.a" + stage_sysroot_file "$LIBWL_PREFIX/lib/libwayland-server.a" "$SYSROOT/lib/libwayland-server.a" + stage_sysroot_file "$LIBFFI_PREFIX/lib/libffi.a" "$SYSROOT/lib/libffi.a" for h in "$LIBWL_PREFIX/include"/wayland-*.h; do - ln -sfn "$h" "$SYSROOT/include/$(basename "$h")" + stage_sysroot_file "$h" "$SYSROOT/include/$(basename "$h")" + done +fi + +# Resolve libffi and copy its archive + header into the sysroot when +# there are any libffi_*.c programs to build (the PR20 full-port matrix +# includes <ffi.h> directly; the libwayland block above only stages the +# archive). Same cached-resolve contract. +if ls "$REPO_ROOT"/programs/libffi_*.c >/dev/null 2>&1; then + echo "==> Resolving libffi for FFI programs..." + HOST_TRIPLE="$(rustc -vV | awk '/^host/ {print $2}')" + (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps resolve libffi >/dev/null) + LIBFFI_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path libffi)" + + stage_sysroot_file "$LIBFFI_PREFIX/lib/libffi.a" "$SYSROOT/lib/libffi.a" + stage_sysroot_file "$LIBFFI_PREFIX/include/ffi.h" "$SYSROOT/include/ffi.h" +fi + +# Resolve glib (+ its deps libffi + zlib) and copy its archives + +# header tree into the sysroot when there are any glib_*.c programs to +# build (the PR21 smoke links gio/gobject/gmodule/glib). Same +# cached-resolve contract. The include tree keeps its glib-2.0/ prefix; +# the program case entry passes -I$SYSROOT/include/glib-2.0. +if ls "$REPO_ROOT"/programs/glib_*.c >/dev/null 2>&1; then + echo "==> Resolving glib (and deps) for glib programs..." + HOST_TRIPLE="$(rustc -vV | awk '/^host/ {print $2}')" + (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps resolve glib >/dev/null) + GLIB_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path glib)" + LIBFFI_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path libffi)" + ZLIB_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path zlib)" + PCRE2_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path pcre2)" + + for a in libglib-2.0.a libgmodule-2.0.a libgobject-2.0.a libgio-2.0.a; do + stage_sysroot_file "$GLIB_PREFIX/lib/$a" "$SYSROOT/lib/$a" done + stage_sysroot_file "$LIBFFI_PREFIX/lib/libffi.a" "$SYSROOT/lib/libffi.a" + stage_sysroot_file "$ZLIB_PREFIX/lib/libz.a" "$SYSROOT/lib/libz.a" + stage_sysroot_file "$PCRE2_PREFIX/lib/libpcre2-8.a" "$SYSROOT/lib/libpcre2-8.a" + stage_sysroot_tree "$GLIB_PREFIX/include/glib-2.0" "$SYSROOT/include/glib-2.0" fi -# Resolve libxkbcommon and symlink its archive + public headers into the +# Resolve libxkbcommon and copy its archive + public headers into the # sysroot when there are any xkb_*.c programs to build. Same cached-resolve # contract as the libwayland block above. See # docs/plans/2026-07-08-dri-wayland-compositor-plan.md (PR4). @@ -400,14 +468,133 @@ if ls "$REPO_ROOT"/programs/xkb_*.c >/dev/null 2>&1; then (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps resolve libxkbcommon >/dev/null) LIBXKB_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path libxkbcommon)" - ln -sfn "$LIBXKB_PREFIX/lib/libxkbcommon.a" "$SYSROOT/lib/libxkbcommon.a" + stage_sysroot_file "$LIBXKB_PREFIX/lib/libxkbcommon.a" "$SYSROOT/lib/libxkbcommon.a" mkdir -p "$SYSROOT/include/xkbcommon" for h in "$LIBXKB_PREFIX/include/xkbcommon"/*.h; do - ln -sfn "$h" "$SYSROOT/include/xkbcommon/$(basename "$h")" + stage_sysroot_file "$h" "$SYSROOT/include/xkbcommon/$(basename "$h")" done fi -# Resolve libevdev and symlink its archive + public header into the sysroot +# Resolve pixman + utf8proc and copy their archives + headers into the +# sysroot when their smoke programs are present. Same cached-resolve +# contract as the libxkbcommon block above. First rungs of the PR19 font +# stack (freetype/fontconfig/fcft build on them). See +# docs/plans/2026-07-14-build-hyprland-class-compositor-plan.md §4. +if ls "$REPO_ROOT"/programs/pixman_*.c >/dev/null 2>&1; then + echo "==> Resolving pixman for pixman programs..." + HOST_TRIPLE="$(rustc -vV | awk '/^host/ {print $2}')" + (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps resolve pixman >/dev/null) + PIXMAN_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path pixman)" + + stage_sysroot_file "$PIXMAN_PREFIX/lib/libpixman-1.a" "$SYSROOT/lib/libpixman-1.a" + # Flat header copies: pixman.h angle-includes pixman-version.h, so + # both must sit on the default include path (build_program adds no -I). + for h in "$PIXMAN_PREFIX/include/pixman-1"/*.h; do + stage_sysroot_file "$h" "$SYSROOT/include/$(basename "$h")" + done +fi + +if ls "$REPO_ROOT"/programs/utf8proc_*.c >/dev/null 2>&1; then + echo "==> Resolving utf8proc for utf8proc programs..." + HOST_TRIPLE="$(rustc -vV | awk '/^host/ {print $2}')" + (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps resolve utf8proc >/dev/null) + UTF8PROC_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path utf8proc)" + + stage_sysroot_file "$UTF8PROC_PREFIX/lib/libutf8proc.a" "$SYSROOT/lib/libutf8proc.a" + stage_sysroot_file "$UTF8PROC_PREFIX/include/utf8proc.h" "$SYSROOT/include/utf8proc.h" +fi + +# Resolve the rest of the font stack (fcft → fontconfig + freetype, plus +# their libxml2/zlib link deps) when the fontstack smoke is present. fcft +# and fontconfig headers keep their subdirs; freetype is include-path-only +# via fcft, so only its archive is copied. +if ls "$REPO_ROOT"/programs/fontstack_*.c >/dev/null 2>&1; then + echo "==> Resolving fcft + fontconfig + freetype for font-stack programs..." + HOST_TRIPLE="$(rustc -vV | awk '/^host/ {print $2}')" + for pkg in fcft fontconfig freetype libxml2 zlib; do + (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps resolve "$pkg" >/dev/null) + done + FCFT_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path fcft)" + FONTCONFIG_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path fontconfig)" + FREETYPE_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path freetype)" + LIBXML2_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path libxml2)" + ZLIB_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path zlib)" + + stage_sysroot_tree "$FCFT_PREFIX/include/fcft" "$SYSROOT/include/fcft" + stage_sysroot_file "$FCFT_PREFIX/lib/libfcft.a" "$SYSROOT/lib/libfcft.a" + stage_sysroot_file "$FONTCONFIG_PREFIX/lib/libfontconfig.a" "$SYSROOT/lib/libfontconfig.a" + stage_sysroot_file "$FREETYPE_PREFIX/lib/libfreetype.a" "$SYSROOT/lib/libfreetype.a" + stage_sysroot_file "$LIBXML2_PREFIX/lib/libxml2.a" "$SYSROOT/lib/libxml2.a" + stage_sysroot_file "$ZLIB_PREFIX/lib/libz.a" "$SYSROOT/lib/libz.a" +fi + +# Resolve the PR23 render stack (pango → cairo + harfbuzz + fribidi on +# the glib and font-stack prefixes) when the pango smoke is present. +# Same cached-resolve contract. Header trees keep their upstream +# prefixes; the case entry passes the -I flags. See +# docs/plans/2026-07-14-build-hyprland-class-compositor-plan.md §4. +if ls "$REPO_ROOT"/programs/pango_*.c >/dev/null 2>&1; then + echo "==> Resolving pango (and deps) for pango programs..." + HOST_TRIPLE="$(rustc -vV | awk '/^host/ {print $2}')" + (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps resolve pango >/dev/null) + PANGO_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path pango)" + CAIRO_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path cairo)" + HARFBUZZ_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path harfbuzz)" + FRIBIDI_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path fribidi)" + LIBPNG_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path libpng)" + PIXMAN_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path pixman)" + FONTCONFIG_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path fontconfig)" + FREETYPE_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path freetype)" + LIBXML2_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path libxml2)" + ZLIB_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path zlib)" + + for a in libpango-1.0.a libpangoft2-1.0.a libpangocairo-1.0.a; do + stage_sysroot_file "$PANGO_PREFIX/lib/$a" "$SYSROOT/lib/$a" + done + stage_sysroot_file "$CAIRO_PREFIX/lib/libcairo.a" "$SYSROOT/lib/libcairo.a" + stage_sysroot_file "$HARFBUZZ_PREFIX/lib/libharfbuzz.a" "$SYSROOT/lib/libharfbuzz.a" + stage_sysroot_file "$FRIBIDI_PREFIX/lib/libfribidi.a" "$SYSROOT/lib/libfribidi.a" + stage_sysroot_file "$LIBPNG_PREFIX/lib/libpng.a" "$SYSROOT/lib/libpng.a" + stage_sysroot_file "$PIXMAN_PREFIX/lib/libpixman-1.a" "$SYSROOT/lib/libpixman-1.a" + stage_sysroot_file "$FONTCONFIG_PREFIX/lib/libfontconfig.a" "$SYSROOT/lib/libfontconfig.a" + stage_sysroot_file "$FREETYPE_PREFIX/lib/libfreetype.a" "$SYSROOT/lib/libfreetype.a" + stage_sysroot_file "$LIBXML2_PREFIX/lib/libxml2.a" "$SYSROOT/lib/libxml2.a" + stage_sysroot_file "$ZLIB_PREFIX/lib/libz.a" "$SYSROOT/lib/libz.a" + stage_sysroot_tree "$PANGO_PREFIX/include/pango-1.0" "$SYSROOT/include/pango-1.0" + stage_sysroot_tree "$CAIRO_PREFIX/include/cairo" "$SYSROOT/include/cairo" +fi + +# Resolve GTK3 (the PR24 stack: gdk-pixbuf + atk + libepoxy over the +# PR23 render stack and the wayland client libs) when a gtk3 smoke is +# present. Same cached-resolve contract. The render-stack copies come +# from the pango block above; this block adds the GTK-only layers. See +# docs/plans/2026-07-14-build-hyprland-class-compositor-plan.md §4 (PR24). +if ls "$REPO_ROOT"/programs/gtk3_*.c >/dev/null 2>&1; then + echo "==> Resolving gtk3 (and deps) for gtk3 programs..." + HOST_TRIPLE="$(rustc -vV | awk '/^host/ {print $2}')" + (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps resolve gtk3 >/dev/null) + GTK3_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path gtk3)" + ATK_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path atk)" + GDK_PIXBUF_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path gdk-pixbuf)" + LIBEPOXY_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path libepoxy)" + CAIRO_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path cairo)" + LIBWL_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path libwayland)" + + stage_sysroot_file "$GTK3_PREFIX/lib/libgtk-3.a" "$SYSROOT/lib/libgtk-3.a" + stage_sysroot_file "$GTK3_PREFIX/lib/libgdk-3.a" "$SYSROOT/lib/libgdk-3.a" + stage_sysroot_file "$ATK_PREFIX/lib/libatk-1.0.a" "$SYSROOT/lib/libatk-1.0.a" + stage_sysroot_file "$GDK_PIXBUF_PREFIX/lib/libgdk_pixbuf-2.0.a" "$SYSROOT/lib/libgdk_pixbuf-2.0.a" + stage_sysroot_file "$LIBEPOXY_PREFIX/lib/libepoxy.a" "$SYSROOT/lib/libepoxy.a" + stage_sysroot_file "$CAIRO_PREFIX/lib/libcairo-gobject.a" "$SYSROOT/lib/libcairo-gobject.a" + stage_sysroot_file "$LIBWL_PREFIX/lib/libwayland-cursor.a" "$SYSROOT/lib/libwayland-cursor.a" + stage_sysroot_file "$LIBWL_PREFIX/lib/libwayland-egl.a" "$SYSROOT/lib/libwayland-egl.a" + stage_sysroot_tree "$GTK3_PREFIX/include/gtk-3.0" "$SYSROOT/include/gtk-3.0" + stage_sysroot_tree "$ATK_PREFIX/include/atk-1.0" "$SYSROOT/include/atk-1.0" + stage_sysroot_tree "$GDK_PIXBUF_PREFIX/include/gdk-pixbuf-2.0" "$SYSROOT/include/gdk-pixbuf-2.0" + stage_sysroot_tree "$LIBEPOXY_PREFIX/include/epoxy" "$SYSROOT/include/epoxy" +fi + +# Resolve libevdev and copy its archive + public header into the sysroot # when there are any libevdev_*.c programs to build. Same cached-resolve # contract as the libwayland/libxkbcommon blocks above. libevdev is the # foundation of the real libinput port (PR5). See @@ -418,12 +605,12 @@ if ls "$REPO_ROOT"/programs/libevdev_*.c >/dev/null 2>&1; then (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps resolve libevdev >/dev/null) LIBEVDEV_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path libevdev)" - ln -sfn "$LIBEVDEV_PREFIX/lib/libevdev.a" "$SYSROOT/lib/libevdev.a" + stage_sysroot_file "$LIBEVDEV_PREFIX/lib/libevdev.a" "$SYSROOT/lib/libevdev.a" mkdir -p "$SYSROOT/include/libevdev" - ln -sfn "$LIBEVDEV_PREFIX/include/libevdev/libevdev.h" "$SYSROOT/include/libevdev/libevdev.h" + stage_sysroot_file "$LIBEVDEV_PREFIX/include/libevdev/libevdev.h" "$SYSROOT/include/libevdev/libevdev.h" fi -# Resolve mtdev and symlink its archive + headers into the sysroot when +# Resolve mtdev and copy its archive + headers into the sysroot when # there are any mtdev_*.c programs to build. mtdev is the link-only # multitouch dependency of the real libinput port (PR5). Same # cached-resolve contract as the blocks above. See @@ -434,12 +621,12 @@ if ls "$REPO_ROOT"/programs/mtdev_*.c >/dev/null 2>&1; then (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps resolve mtdev >/dev/null) MTDEV_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path mtdev)" - ln -sfn "$MTDEV_PREFIX/lib/libmtdev.a" "$SYSROOT/lib/libmtdev.a" - ln -sfn "$MTDEV_PREFIX/include/mtdev.h" "$SYSROOT/include/mtdev.h" - ln -sfn "$MTDEV_PREFIX/include/mtdev-plumbing.h" "$SYSROOT/include/mtdev-plumbing.h" + stage_sysroot_file "$MTDEV_PREFIX/lib/libmtdev.a" "$SYSROOT/lib/libmtdev.a" + stage_sysroot_file "$MTDEV_PREFIX/include/mtdev.h" "$SYSROOT/include/mtdev.h" + stage_sysroot_file "$MTDEV_PREFIX/include/mtdev-plumbing.h" "$SYSROOT/include/mtdev-plumbing.h" fi -# Resolve libudev and symlink its archive + header into the sysroot when +# Resolve libudev and copy its archive + header into the sysroot when # there are any libudev_*.c programs to build. libudev is the input_id # classification shim the real libinput port (PR5) needs to accept # devices. Same cached-resolve contract as the blocks above. See @@ -450,15 +637,15 @@ if ls "$REPO_ROOT"/programs/libudev_*.c >/dev/null 2>&1; then (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps resolve libudev >/dev/null) LIBUDEV_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path libudev)" - ln -sfn "$LIBUDEV_PREFIX/lib/libudev.a" "$SYSROOT/lib/libudev.a" - ln -sfn "$LIBUDEV_PREFIX/include/libudev.h" "$SYSROOT/include/libudev.h" + stage_sysroot_file "$LIBUDEV_PREFIX/lib/libudev.a" "$SYSROOT/lib/libudev.a" + stage_sysroot_file "$LIBUDEV_PREFIX/include/libudev.h" "$SYSROOT/include/libudev.h" fi # Resolve libinput (real 1.25.0) for the libinput smoke. This is the real # path-backend library the Wayland compositor will use (PR5c). The smoke is # built in a dedicated pass after the program loop (build_program can't add # the real header's -I), and links the real archive from its cache prefix by -# full path — deliberately NOT via a $SYSROOT/lib/libinput.a symlink — so the +# full path — deliberately NOT via a $SYSROOT/lib/libinput.a copy — so the # sysroot carries no libinput identity. Its deps # (libevdev + libudev shim + mtdev stub) resolve transitively; we capture # each prefix for the smoke's link line. See @@ -502,7 +689,7 @@ for src in "$REPO_ROOT/programs/"*.c; do build_program "$src" "$OUT_DIR_32" \ "$SYSROOT/lib/libwpkdraw.a" ;; - kwldemo.c|wlclock.c|wlpaint.c) + kwldemo.c|wlclock.c|wlpaint.c|kbar.c|klauncher.c|knotify.c) # Link libkwl — built in a dedicated pass after the # wlcompositor block (which resolves the wayland/xkb archives and # generates the xdg-shell client header libkwl needs). Skip here. @@ -518,11 +705,159 @@ for src in "$REPO_ROOT/programs/"*.c; do "$SYSROOT/lib/libwayland-client.a" \ "$SYSROOT/lib/libffi.a" ;; + libffi_full_test.c) + # PR20 matrix: ffi_call classification + closure trampoline + # pool against the full libffi port. + build_program "$src" "$OUT_DIR_32" \ + "$SYSROOT/lib/libffi.a" + ;; + glib_gdbus_smoke.c) + # PR22: gdbus client core against the dbus-daemon port — + # name owning, object export, method call round trips. + build_program "$src" "$OUT_DIR_32" \ + "-I$SYSROOT/include/glib-2.0" \ + "$SYSROOT/lib/libgio-2.0.a" \ + "$SYSROOT/lib/libgobject-2.0.a" \ + "$SYSROOT/lib/libgmodule-2.0.a" \ + "$SYSROOT/lib/libglib-2.0.a" \ + "$SYSROOT/lib/libpcre2-8.a" \ + "$SYSROOT/lib/libffi.a" \ + "$SYSROOT/lib/libz.a" + ;; + notify-send.c) + # The omarchy demo's notification sender: one Notify over the + # session bus to the daemon owning org.freedesktop.Notifications + # (mako). Same glib stack as the gdbus smoke. + build_program "$src" "$OUT_DIR_32" \ + "-I$SYSROOT/include/glib-2.0" \ + "$SYSROOT/lib/libgio-2.0.a" \ + "$SYSROOT/lib/libgobject-2.0.a" \ + "$SYSROOT/lib/libgmodule-2.0.a" \ + "$SYSROOT/lib/libglib-2.0.a" \ + "$SYSROOT/lib/libpcre2-8.a" \ + "$SYSROOT/lib/libffi.a" \ + "$SYSROOT/lib/libz.a" + ;; + glib_smoke_test.c) + # PR21: mainloop + gobject signals (libffi generic + # marshaller) + gspawn against the glib port. Link order: + # gio pulls gobject/gmodule/glib, gobject pulls libffi, + # gio pulls libz. + build_program "$src" "$OUT_DIR_32" \ + "-I$SYSROOT/include/glib-2.0" \ + "$SYSROOT/lib/libgio-2.0.a" \ + "$SYSROOT/lib/libgobject-2.0.a" \ + "$SYSROOT/lib/libgmodule-2.0.a" \ + "$SYSROOT/lib/libglib-2.0.a" \ + "$SYSROOT/lib/libpcre2-8.a" \ + "$SYSROOT/lib/libffi.a" \ + "$SYSROOT/lib/libz.a" + ;; xkb_smoke.c) # Keymap compile + state translation against the libxkbcommon port. build_program "$src" "$OUT_DIR_32" \ "$SYSROOT/lib/libxkbcommon.a" ;; + pixman_smoke.c) + # Fill + OP_OVER composite against the pixman port (PR19). + build_program "$src" "$OUT_DIR_32" \ + "$SYSROOT/lib/libpixman-1.a" + ;; + utf8proc_smoke.c) + # NFC + case map + grapheme break against the utf8proc port (PR19). + build_program "$src" "$OUT_DIR_32" \ + "$SYSROOT/lib/libutf8proc.a" + ;; + pango_cairo_smoke.c) + # PR23: pango layout + harfbuzz shaping + cairo image + # surface render through the whole PR19 font stack. Link + # order: pangocairo pulls pangoft2/pango/cairo, pango + # pulls harfbuzz/fribidi/gobject/glib, cairo pulls + # pixman/fontconfig/freetype/png, harfbuzz (C++) pulls + # libc++. + build_program "$src" "$OUT_DIR_32" \ + "-I$SYSROOT/include/pango-1.0" \ + "-I$SYSROOT/include/glib-2.0" \ + "-I$SYSROOT/include/cairo" \ + "$SYSROOT/lib/libpangocairo-1.0.a" \ + "$SYSROOT/lib/libpangoft2-1.0.a" \ + "$SYSROOT/lib/libpango-1.0.a" \ + "$SYSROOT/lib/libcairo.a" \ + "$SYSROOT/lib/libharfbuzz.a" \ + "$SYSROOT/lib/libfribidi.a" \ + "$SYSROOT/lib/libgobject-2.0.a" \ + "$SYSROOT/lib/libgmodule-2.0.a" \ + "$SYSROOT/lib/libglib-2.0.a" \ + "$SYSROOT/lib/libpcre2-8.a" \ + "$SYSROOT/lib/libffi.a" \ + "$SYSROOT/lib/libpixman-1.a" \ + "$SYSROOT/lib/libfontconfig.a" \ + "$SYSROOT/lib/libfreetype.a" \ + "$SYSROOT/lib/libxml2.a" \ + "$SYSROOT/lib/libpng.a" \ + "$SYSROOT/lib/libz.a" \ + "$SYSROOT/lib/libc++.a" \ + "$SYSROOT/lib/libc++abi.a" + ;; + gtk3_smoke.c) + # PR24: unmodified GTK3 wayland client — window + label + # through gdk-wayland, pango shaping, cairo wl_shm render. + # Link order: gtk pulls gdk/atk/gdk-pixbuf/epoxy, gdk pulls + # the wayland client libs + xkbcommon + cairo-gobject, then + # the PR23 render closure, glib stack, and font stack. + # libgbm/libdrm back gdk's wl_shm pools (see the gtk3 + # package's wayland-shm-gbm-pool.patch). + build_program "$src" "$OUT_DIR_32" \ + "-I$SYSROOT/include/gtk-3.0" \ + "-I$SYSROOT/include/atk-1.0" \ + "-I$SYSROOT/include/gdk-pixbuf-2.0" \ + "-I$SYSROOT/include/pango-1.0" \ + "-I$SYSROOT/include/glib-2.0" \ + "-I$SYSROOT/include/cairo" \ + "$SYSROOT/lib/libgtk-3.a" \ + "$SYSROOT/lib/libgdk-3.a" \ + "$SYSROOT/lib/libatk-1.0.a" \ + "$SYSROOT/lib/libgdk_pixbuf-2.0.a" \ + "$SYSROOT/lib/libepoxy.a" \ + "$SYSROOT/lib/libwayland-client.a" \ + "$SYSROOT/lib/libwayland-cursor.a" \ + "$SYSROOT/lib/libwayland-egl.a" \ + "$SYSROOT/lib/libxkbcommon.a" \ + "$SYSROOT/lib/libpangocairo-1.0.a" \ + "$SYSROOT/lib/libpangoft2-1.0.a" \ + "$SYSROOT/lib/libpango-1.0.a" \ + "$SYSROOT/lib/libcairo-gobject.a" \ + "$SYSROOT/lib/libcairo.a" \ + "$SYSROOT/lib/libharfbuzz.a" \ + "$SYSROOT/lib/libfribidi.a" \ + "$SYSROOT/lib/libgio-2.0.a" \ + "$SYSROOT/lib/libgobject-2.0.a" \ + "$SYSROOT/lib/libgmodule-2.0.a" \ + "$SYSROOT/lib/libglib-2.0.a" \ + "$SYSROOT/lib/libpcre2-8.a" \ + "$SYSROOT/lib/libffi.a" \ + "$SYSROOT/lib/libpixman-1.a" \ + "$SYSROOT/lib/libfontconfig.a" \ + "$SYSROOT/lib/libfreetype.a" \ + "$SYSROOT/lib/libxml2.a" \ + "$SYSROOT/lib/libpng.a" \ + "$SYSROOT/lib/libz.a" \ + "$SYSROOT/lib/libgbm.a" \ + "$SYSROOT/lib/libdrm.a" \ + "$SYSROOT/lib/libc++.a" \ + "$SYSROOT/lib/libc++abi.a" + ;; + fontstack_smoke.c) + # monospace resolve + glyph rasterization through the whole + # freetype/fontconfig/fcft/pixman stack (PR19). + build_program "$src" "$OUT_DIR_32" \ + "$SYSROOT/lib/libfcft.a" \ + "$SYSROOT/lib/libfontconfig.a" \ + "$SYSROOT/lib/libfreetype.a" \ + "$SYSROOT/lib/libxml2.a" \ + "$SYSROOT/lib/libpixman-1.a" \ + "$SYSROOT/lib/libz.a" + ;; libevdev_smoke.c) # evdev capability probe + event decode against the libevdev port. build_program "$src" "$OUT_DIR_32" \ @@ -624,18 +959,18 @@ if ls "$REPO_ROOT"/programs/wlcompositor/*.c >/dev/null 2>&1; then WLC_MTDEV="$(wlc_path mtdev)" # Public headers on the sysroot include path (idempotent — the wl_*/xkb_* - # blocks above symlink the same paths; the archives too). + # blocks above copy the same paths; the archives too). for h in "$WLC_LIBWL/include"/wayland-*.h; do - ln -sfn "$h" "$SYSROOT/include/$(basename "$h")" + stage_sysroot_file "$h" "$SYSROOT/include/$(basename "$h")" done - ln -sfn "$WLC_LIBFFI/lib/libffi.a" "$SYSROOT/lib/libffi.a" - ln -sfn "$WLC_LIBWL/lib/libwayland-server.a" "$SYSROOT/lib/libwayland-server.a" - ln -sfn "$WLC_LIBWL/lib/libwayland-client.a" "$SYSROOT/lib/libwayland-client.a" - ln -sfn "$WLC_LIBWL/lib/libwayland-cursor.a" "$SYSROOT/lib/libwayland-cursor.a" - ln -sfn "$WLC_LIBXKB/lib/libxkbcommon.a" "$SYSROOT/lib/libxkbcommon.a" + stage_sysroot_file "$WLC_LIBFFI/lib/libffi.a" "$SYSROOT/lib/libffi.a" + stage_sysroot_file "$WLC_LIBWL/lib/libwayland-server.a" "$SYSROOT/lib/libwayland-server.a" + stage_sysroot_file "$WLC_LIBWL/lib/libwayland-client.a" "$SYSROOT/lib/libwayland-client.a" + stage_sysroot_file "$WLC_LIBWL/lib/libwayland-cursor.a" "$SYSROOT/lib/libwayland-cursor.a" + stage_sysroot_file "$WLC_LIBXKB/lib/libxkbcommon.a" "$SYSROOT/lib/libxkbcommon.a" mkdir -p "$SYSROOT/include/xkbcommon" for h in "$WLC_LIBXKB/include/xkbcommon"/*.h; do - ln -sfn "$h" "$SYSROOT/include/xkbcommon/$(basename "$h")" + stage_sysroot_file "$h" "$SYSROOT/include/xkbcommon/$(basename "$h")" done # Generate xdg-shell {server,client} headers + shared private-code from @@ -663,53 +998,86 @@ if ls "$REPO_ROOT"/programs/wlcompositor/*.c >/dev/null 2>&1; then wayland-scanner server-header "$DECOR_XML" "$WLC_GEN/xdg-decoration-v1-server-protocol.h" wayland-scanner client-header "$DECOR_XML" "$WLC_GEN/xdg-decoration-v1-client-protocol.h" + # Same for zwlr_layer_shell_v1 (PR15): the shell-component protocol. The + # compositor anchors bars/launchers with it; kbar + klauncher are its + # clients, and it is what upstream Waybar/mako speak too. + LAYER_XML="$REPO_ROOT/packages/registry/wayland-protocols/xml/wlr-layer-shell-unstable-v1.xml" + wayland-scanner private-code "$LAYER_XML" "$WLC_GEN/wlr-layer-shell-v1-protocol.c" + wayland-scanner server-header "$LAYER_XML" "$WLC_GEN/wlr-layer-shell-v1-server-protocol.h" + wayland-scanner client-header "$LAYER_XML" "$WLC_GEN/wlr-layer-shell-v1-client-protocol.h" + + # Same for wp_presentation (PR19): frame-timing feedback off the existing + # PAGE_FLIP timestamps. foot uses it for frame pacing; clients without it + # fall back to wl_surface.frame. + PTIME_XML="$REPO_ROOT/packages/registry/wayland-protocols/xml/presentation-time.xml" + wayland-scanner private-code "$PTIME_XML" "$WLC_GEN/presentation-time-protocol.c" + wayland-scanner server-header "$PTIME_XML" "$WLC_GEN/presentation-time-server-protocol.h" + wayland-scanner client-header "$PTIME_XML" "$WLC_GEN/presentation-time-client-protocol.h" + + # Same for zxdg_output_manager_v1 + wp_viewporter + + # wp_fractional_scale_manager_v1 (PR24): the logical-output geometry and + # crop/scale surface GTK3, Waybar and mako query. The compositor answers + # with the fixed scale-1 fullscreen output. + XDGOUT_XML="$REPO_ROOT/packages/registry/wayland-protocols/xml/xdg-output-unstable-v1.xml" + wayland-scanner private-code "$XDGOUT_XML" "$WLC_GEN/xdg-output-v1-protocol.c" + wayland-scanner server-header "$XDGOUT_XML" "$WLC_GEN/xdg-output-v1-server-protocol.h" + wayland-scanner client-header "$XDGOUT_XML" "$WLC_GEN/xdg-output-v1-client-protocol.h" + VIEWPORTER_XML="$REPO_ROOT/packages/registry/wayland-protocols/xml/viewporter.xml" + wayland-scanner private-code "$VIEWPORTER_XML" "$WLC_GEN/viewporter-protocol.c" + wayland-scanner server-header "$VIEWPORTER_XML" "$WLC_GEN/viewporter-server-protocol.h" + wayland-scanner client-header "$VIEWPORTER_XML" "$WLC_GEN/viewporter-client-protocol.h" + FRACSCALE_XML="$REPO_ROOT/packages/registry/wayland-protocols/xml/fractional-scale-v1.xml" + wayland-scanner private-code "$FRACSCALE_XML" "$WLC_GEN/fractional-scale-v1-protocol.c" + wayland-scanner server-header "$FRACSCALE_XML" "$WLC_GEN/fractional-scale-v1-server-protocol.h" + wayland-scanner client-header "$FRACSCALE_XML" "$WLC_GEN/fractional-scale-v1-client-protocol.h" + # libwayland-egl (step 12a): the wl_egl_window shim that SDL2's upstream - # Wayland+GLES backend uses as its EGLNativeWindowType. It allocates the - # GPU-tier bo the window renders into and wraps it as a zwp_linux_dmabuf_v1 - # wl_buffer; libEGL targets that bo's FBO and attach+commits it on swap - # (see libc/glue/libwayland-egl.c). Self-contained: bundles the dmabuf - # client glue since neither SDL2 nor libwayland ships it, so a GL client - # only links libwayland-egl.a + libEGL.a. Public headers are vendored - # verbatim from wayland 1.24.0 under libc/glue/wayland-egl-include/. - echo " Building libwayland-egl.a (wl_egl_window shim)..." - for h in wayland-egl.h wayland-egl-core.h wayland-egl-backend.h; do - ln -sfn "$GLUE_DIR/wayland-egl-include/$h" "$SYSROOT/include/$h" - done - "$CC" "${CFLAGS[@]}" "-I$WLC_GEN" "-I$GLUE_DIR" \ - "-I$GLUE_DIR/wayland-egl-include" -c \ - "$GLUE_DIR/libwayland-egl.c" -o "$WLC_GEN/libwayland-egl.o" - "$CC" "${CFLAGS[@]}" "-I$WLC_GEN" -c \ - "$WLC_GEN/linux-dmabuf-v1-protocol.c" -o "$WLC_GEN/linux-dmabuf-v1-protocol.o" - "$LLVM_BIN/llvm-ar" rcs "$SYSROOT/lib/libwayland-egl.a" \ - "$WLC_GEN/libwayland-egl.o" "$WLC_GEN/linux-dmabuf-v1-protocol.o" + # Wayland+GLES backend uses as its EGLNativeWindowType (see + # libc/glue/libwayland-egl.c). Built + shipped by the libwayland + # package — its build.toml `inputs` lists the glue sources, so glue + # edits re-key the cache and rebuild it. The wayland-*.h copy loop + # above already covers the wayland-egl headers. + stage_sysroot_file "$WLC_LIBWL/lib/libwayland-egl.a" "$SYSROOT/lib/libwayland-egl.a" # Server. Link order: dependents (compositor + xdg glue) before # dependencies; libffi last so wl_closure_invoke's ffi_call resolves. # libwpkdraw renders the compositor's wallpaper (gradient + wordmark); # libEGL/libGLESv2 drive the GPU compositing path (CPU fallback when # the host has no WebGL2). - comp_wasm="$OUT_DIR_32/wlcompositor.wasm" - echo " Compiling wlcompositor (server)..." - "$CC" "${CFLAGS[@]}" "-I$WLC_GEN" "-I$WLC_LIBINPUT/include" \ - "$REPO_ROOT/programs/wlcompositor/wlcompositor.c" \ - "$WLC_GEN/xdg-shell-protocol.c" \ - "$WLC_GEN/linux-dmabuf-v1-protocol.c" \ - "$WLC_GEN/xdg-decoration-v1-protocol.c" \ - "${LINK_PRE_LIBS[@]}" \ - "$SYSROOT/lib/libwayland-server.a" \ - "$SYSROOT/lib/libwpkdraw.a" \ - "$SYSROOT/lib/libxkbcommon.a" \ - "$WLC_LIBINPUT/lib/libinput.a" \ - "$WLC_LIBEVDEV/lib/libevdev.a" \ - "$WLC_LIBUDEV/lib/libudev.a" \ - "$WLC_MTDEV/lib/libmtdev.a" \ - "$SYSROOT/lib/libEGL.a" "$SYSROOT/lib/libGLESv2.a" \ - "$SYSROOT/lib/libgbm.a" "$SYSROOT/lib/libdrm.a" \ - "$SYSROOT/lib/libffi.a" \ - "${LINK_POST_LIBS[@]}" \ - -o "$comp_wasm" - "$FORK_INSTRUMENT" "$comp_wasm" -o "$comp_wasm.instr" - mv "$comp_wasm.instr" "$comp_wasm" + # The wldesktop package publishes the server itself; only the test + # clients below still build here. The generated glue and the sysroot + # copies above stay — libkwl and those clients need them. + if package_owns_program_output wasm32 wlcompositor.wasm; then + echo " Skipping wlcompositor: a package publishes wlcompositor.wasm" + else + comp_wasm="$OUT_DIR_32/wlcompositor.wasm" + echo " Compiling wlcompositor (server)..." + "$CC" "${CFLAGS[@]}" "-I$WLC_GEN" "-I$WLC_LIBINPUT/include" \ + "$REPO_ROOT/programs/wlcompositor/wlcompositor.c" \ + "$WLC_GEN/xdg-shell-protocol.c" \ + "$WLC_GEN/linux-dmabuf-v1-protocol.c" \ + "$WLC_GEN/xdg-decoration-v1-protocol.c" \ + "$WLC_GEN/wlr-layer-shell-v1-protocol.c" \ + "$WLC_GEN/presentation-time-protocol.c" \ + "$WLC_GEN/xdg-output-v1-protocol.c" \ + "$WLC_GEN/viewporter-protocol.c" \ + "$WLC_GEN/fractional-scale-v1-protocol.c" \ + "${LINK_PRE_LIBS[@]}" \ + "$SYSROOT/lib/libwayland-server.a" \ + "$SYSROOT/lib/libwpkdraw.a" \ + "$SYSROOT/lib/libxkbcommon.a" \ + "$WLC_LIBINPUT/lib/libinput.a" \ + "$WLC_LIBEVDEV/lib/libevdev.a" \ + "$WLC_LIBUDEV/lib/libudev.a" \ + "$WLC_MTDEV/lib/libmtdev.a" \ + "$SYSROOT/lib/libEGL.a" "$SYSROOT/lib/libGLESv2.a" \ + "$SYSROOT/lib/libgbm.a" "$SYSROOT/lib/libdrm.a" \ + "$SYSROOT/lib/libffi.a" \ + "${LINK_POST_LIBS[@]}" \ + -o "$comp_wasm" + "$FORK_INSTRUMENT" "$comp_wasm" -o "$comp_wasm.instr" + mv "$comp_wasm.instr" "$comp_wasm" + fi # Client. client_wasm="$OUT_DIR_32/wlclient-test.wasm" @@ -718,8 +1086,13 @@ if ls "$REPO_ROOT"/programs/wlcompositor/*.c >/dev/null 2>&1; then "$REPO_ROOT/programs/wlcompositor/wlclient-test.c" \ "$WLC_GEN/xdg-shell-protocol.c" \ "$WLC_GEN/xdg-decoration-v1-protocol.c" \ + "$WLC_GEN/presentation-time-protocol.c" \ + "$WLC_GEN/xdg-output-v1-protocol.c" \ + "$WLC_GEN/viewporter-protocol.c" \ + "$WLC_GEN/fractional-scale-v1-protocol.c" \ "${LINK_PRE_LIBS[@]}" \ "$SYSROOT/lib/libwayland-client.a" \ + "$SYSROOT/lib/libxkbcommon.a" \ "$SYSROOT/lib/libgbm.a" "$SYSROOT/lib/libdrm.a" \ "$SYSROOT/lib/libffi.a" \ "${LINK_POST_LIBS[@]}" \ @@ -766,7 +1139,7 @@ fi # libkwl (PR7 Phase 2): in-tree Wayland toolkit over libwayland-client. # Built inline (NOT via the resolver — packages/registry/ only). Runs AFTER # the wlcompositor block above so the wayland-client / xkbcommon / gbm / -# drm / ffi archives are already symlinked into the sysroot and the +# drm / ffi archives are already copied into the sysroot and the # generated xdg-shell-client-protocol.h exists under local-binaries/ # wlcompositor-gen (libkwl includes it). build.sh installs lib/libkwl.a + # include/kwl.h; the kwldemo consumer then links libkwl + libwpkdraw + the @@ -784,18 +1157,21 @@ if [ -d "$LIBKWL_DIR/src" ]; then bash "$LIBKWL_DIR/build.sh" "$SYSROOT" # libkwl clients: kwldemo (PR7 Phase 2 gate), wlclock (animated analog - # clock), wlpaint (palette + pointer-drag painting). Link order: + # clock), wlpaint (palette + pointer-drag painting), kbar (the layer-shell + # status bar) and klauncher (the layer-shell app launcher). Link order: # dependents before deps — app + xdg glue, then libkwl (calls # wpk_*/wl_*/xkb_*), then libwpkdraw, then the wayland stack, libffi # last so wl_closure_invoke's ffi_call resolves. - for kwl_app in kwldemo wlclock wlpaint; do + for kwl_app in kwldemo wlclock wlpaint kbar klauncher knotify; do [ -f "$REPO_ROOT/programs/$kwl_app.c" ] || continue + package_owns_program_output wasm32 "$kwl_app.wasm" && continue kwl_app_wasm="$OUT_DIR_32/$kwl_app.wasm" echo " Compiling $kwl_app (libkwl client)..." "$CC" "${CFLAGS[@]}" "-I$KWL_GEN" \ "$REPO_ROOT/programs/$kwl_app.c" \ "$KWL_GEN/xdg-shell-protocol.c" \ "$KWL_GEN/xdg-decoration-v1-protocol.c" \ + "$KWL_GEN/wlr-layer-shell-v1-protocol.c" \ "${LINK_PRE_LIBS[@]}" \ "$SYSROOT/lib/libkwl.a" \ "$SYSROOT/lib/libwpkdraw.a" \ @@ -817,7 +1193,8 @@ fi # MANDATORY because forkpty() forks (CLAUDE.md fork policy — must not # silently degrade). Files live under programs/wlterm/ so the flat loop skips # them. See docs/plans/2026-07-09-dri-pr7-libkwl-wlterm-plan.md §5. -if ls "$REPO_ROOT"/programs/wlterm/*.c >/dev/null 2>&1; then +if ls "$REPO_ROOT"/programs/wlterm/*.c >/dev/null 2>&1 && + ! package_owns_program_output wasm32 wlterm.wasm; then if [ ! -f "$SYSROOT/lib/libkwl.a" ]; then echo "Error: libkwl.a missing — the libkwl pass must run before wlterm." >&2 exit 1 @@ -829,6 +1206,7 @@ if ls "$REPO_ROOT"/programs/wlterm/*.c >/dev/null 2>&1; then "$REPO_ROOT/programs/wlterm/vt100.c" \ "$KWL_GEN/xdg-shell-protocol.c" \ "$KWL_GEN/xdg-decoration-v1-protocol.c" \ + "$KWL_GEN/wlr-layer-shell-v1-protocol.c" \ "${LINK_PRE_LIBS[@]}" \ "$SYSROOT/lib/libkwl.a" \ "$SYSROOT/lib/libwpkdraw.a" \ @@ -951,6 +1329,7 @@ if [ -f "$SYSROOT64/lib/libc.a" ]; then -Wl,--import-memory -Wl,--shared-memory -Wl,--max-memory=1073741824 + -Wl,-z,stack-size=8388608 -Wl,--allow-undefined -Wl,--table-base=3 -Wl,--export-table diff --git a/scripts/ci-vitest-evidence-classes.tsv b/scripts/ci-vitest-evidence-classes.tsv index d8249c743a..ba9016437e 100644 --- a/scripts/ci-vitest-evidence-classes.tsv +++ b/scripts/ci-vitest-evidence-classes.tsv @@ -358,8 +358,12 @@ host/test/wlcompositor-decoration-smoke.test.ts prepared-product host/test/wlcompositor-dmabuf-smoke.test.ts prepared-product host/test/wlcompositor-keybind-smoke.test.ts prepared-product host/test/wlcompositor-kwlctl-smoke.test.ts prepared-product +host/test/wlcompositor-layer-shell-smoke.test.ts prepared-product +host/test/wlcompositor-output-scale-smoke.test.ts prepared-product +host/test/wlcompositor-protocols-smoke.test.ts prepared-product host/test/wlcompositor-resize-smoke.test.ts prepared-product host/test/wlcompositor-smoke.test.ts prepared-product +host/test/wlcompositor-theme-smoke.test.ts prepared-product host/test/wlcompositor-tiling-smoke.test.ts prepared-product host/test/wldesktop-liveness-smoke.test.ts prepared-product host/test/wldesktop-smoke.test.ts prepared-product