A protocol is reached by name now, not by a compile-time integer. Init is PID 1 and already knows which binary it started, so init serves /protocol as a vfs backend: bind claims a contract with the provider's endpoint attached, open answers with that endpoint as the reply's capability, and readdir lists what is bound with the task and binary behind it. The kernel reserves the prefix — nothing may mount over it, under it, or unmount it — and ServiceId, ipc_register and ipc_lookup are gone, their syscall numbers left vacant. A bind is authorized by who the caller *is*: the kernel-stamped binary together with the supervising task's identity, matched against /system/configuration/protocol.csv. Identity, not spelling — spawn is ungated, so an attacker can run any bundled binary, and a name-only rule would have let it launder grants through an init of its own making. A name a live process holds is refused to everyone else; a dead one's is released. Three review rounds against a hostile ring-3 process found what 108 green tests could not, because the suite contains no attacker. Publishing init's supervision endpoint as the registry put PID 1's mailbox in every process's hands, where two forged bytes reached the shutdown path: privileged traffic is now believed only from the task that holds the contract it speaks for. A capability arriving on a request outlived every path that ignored it, one handle per call until the table was full — in init, and in the harness ten services share — so the arriving capability is owned by the turn and released unless a handler says otherwise. And the kernel let anyone holding an endpoint handle aim signals, timers, exit notices and interrupts at it: binding now requires having created it. Suite 108/108. The new protocol-registry case asserts eleven properties, each one an attack that must fail.
715 lines
32 KiB
Zig
715 lines
32 KiB
Zig
//! /system/services/display — the display service (docs/display.md, docs/display-v2.md).
|
|
//! A ring-3 compositor: it composes an ordered stack of **layers** into a cacheable
|
|
//! surface and presents finished frames. Scanout — how a frame reaches the panel — is a
|
|
//! pluggable **backend** ([backend.zig](backend.zig)): the GOP framebuffer today, a native
|
|
//! virtio-gpu driver later; this file never learns which is active. It owns the layer stack
|
|
//! and damage tracking; the pixel math is the pure, host-tested
|
|
//! [compositor.zig](compositor.zig).
|
|
//!
|
|
//! A layer is a server-owned surface (its own cacheable buffer) with a screen position,
|
|
//! z-order, and visibility. Clients create layers, draw into them by command (`fill_rect`,
|
|
//! `blit_tile`), mark `damage`, and ask for a `present`; the compositor repaints only the
|
|
//! damaged region — clear it, paint the visible layers bottom-to-top into the backend's
|
|
//! surface, then `backend.present(damage)`. Presents are paced by a ~60 Hz **frame clock**
|
|
//! (see `schedulePresent`), so any number of client presents and cursor moves inside one
|
|
//! interval coalesce into a single frame. Shared-memory client surfaces are later
|
|
//! (docs/display-v2.md).
|
|
|
|
const std = @import("std");
|
|
const channel = @import("channel");
|
|
const ipc = @import("ipc");
|
|
const input = @import("input-client");
|
|
const Thread = @import("thread").Thread;
|
|
const service = @import("service");
|
|
const time = @import("time");
|
|
const display = @import("display-client");
|
|
const memory = @import("memory");
|
|
const logging = @import("logging");
|
|
const compositor = @import("compositor.zig");
|
|
const backend_mod = @import("backend.zig");
|
|
|
|
const display_protocol = @import("display-protocol");
|
|
const Rect = compositor.Rect;
|
|
const Surface = compositor.Surface;
|
|
|
|
/// The active scanout backend — the GOP framebuffer at boot, upgraded to a native driver
|
|
/// (virtio-gpu) when one announces itself (V4).
|
|
var backend: backend_mod.Backend = undefined;
|
|
var frames: u64 = 0;
|
|
|
|
/// This service's endpoint, kept so `attach_scanout` can arm a one-shot timer: the very first
|
|
/// native present must happen in a *later* loop iteration, after the reply to the driver's
|
|
/// announce has unblocked it and it is serving its `.scanout` channel — presenting inline
|
|
/// would deadlock (we'd call the driver while it waits on our reply).
|
|
var service_endpoint: ipc.Handle = 0;
|
|
|
|
/// Set when the backend has just been upgraded to virtio-gpu: the next present repaints the
|
|
/// whole screen into the shared surface and reads a pixel back to confirm the frame landed.
|
|
var pending_native_verify: bool = false;
|
|
|
|
/// Set alongside it: after the native present is verified, run the mode-set self-check once
|
|
/// (query the driver's modes, switch to a different one, confirm the geometry changed) — the
|
|
/// serial proof the runtime-resolution-change + fenced-present paths work (V5).
|
|
var pending_modeset_check: bool = false;
|
|
|
|
/// The wallpaper the compositor clears damaged regions to before painting layers.
|
|
var background: u32 = 0;
|
|
|
|
/// The layer stack. A fixed table (a compositor has few top-level surfaces during
|
|
/// bring-up); each used slot owns an mmap'd surface. `damage_list` accumulates the dirty
|
|
/// screen rectangles since the last `present`, so a present touches only what changed —
|
|
/// and keeps far-apart changes (the cursor here, an animating layer there) as *separate*
|
|
/// small copies rather than one huge bounding box (see compositor.DamageList).
|
|
const maximum_layers = 16;
|
|
|
|
const Layer = struct {
|
|
used: bool = false,
|
|
x: i32 = 0,
|
|
y: i32 = 0,
|
|
z: u32 = 0,
|
|
visible: bool = false,
|
|
surface: Surface = undefined,
|
|
surface_len: usize = 0, // for munmap on destroy
|
|
};
|
|
|
|
var layers: [maximum_layers]Layer = [_]Layer{.{}} ** maximum_layers;
|
|
|
|
/// Which damage tracker drives `present` — a compile-time A/B switch (both are in
|
|
/// compositor.zig with the trade-off discussion):
|
|
/// .list — free-form dirty rectangles (tight bounds, heuristic merging)
|
|
/// .grid — a fixed 64-px tile grid (exact O(1) merging, tile-quantized repaints)
|
|
const DamageMode = enum { list, grid };
|
|
const damage_mode: DamageMode = .grid;
|
|
|
|
var damage_list: compositor.DamageList = .{};
|
|
var damage_grid: compositor.TileGrid = .{};
|
|
|
|
/// The **frame clock**: client `present` requests and cursor motion don't repaint
|
|
/// immediately — they accumulate damage and arm a one-shot timer, and the tick composites
|
|
/// everything pending as one frame. That paces presents to ~60 Hz no matter how fast
|
|
/// clients draw or the mouse moves (previously every mouse event became a full present).
|
|
/// No backend has a real vblank to pace by (docs/display-v2.md, "Fenced is not vsync");
|
|
/// this is the software stand-in, the same strategy Linux uses atop virtio-gpu. Bring-up
|
|
/// paths that need pixels on screen *now* (initialise, the self-checks) still call
|
|
/// `present()` directly.
|
|
///
|
|
/// The interval comes from the *active backend's* panel refresh rate (EDID: the loader
|
|
/// captures it for the GOP floor while firmware still runs; the native driver reads its
|
|
/// own and carries it in the announce). `updateFrameClock` re-derives it whenever the
|
|
/// backend changes — the boot framebuffer's clock dies with the GOP floor at upgrade.
|
|
/// Without a rate the clock defaults to 60 Hz, and it is clamped to [30, 120] Hz so a
|
|
/// mis-parsed EDID can neither starve nor flood the compositor.
|
|
var frame_interval_milliseconds: u64 = 16;
|
|
var frame_timer_armed = false;
|
|
|
|
/// Derive the frame-clock interval from the active backend's refresh rate and log what
|
|
/// the clock is now pacing to. Called at bring-up and again on every backend change.
|
|
fn updateFrameClock() void {
|
|
const reported = backend.info().refresh_hz;
|
|
const rate: u64 = if (reported == 0) 60 else @min(@max(reported, 30), 120);
|
|
frame_interval_milliseconds = @max(1000 / rate, 1);
|
|
var line: [96]u8 = undefined;
|
|
_ = logging.write(std.fmt.bufPrint(&line, "display: frame clock {d} Hz ({s})\n", .{
|
|
1000 / frame_interval_milliseconds,
|
|
if (reported == 0) "default" else "panel EDID",
|
|
}) catch return);
|
|
}
|
|
|
|
/// Arm the frame clock unless a tick is already pending: any number of requests inside
|
|
/// one interval coalesce into that single tick's present.
|
|
fn schedulePresent() void {
|
|
if (frame_timer_armed) return;
|
|
frame_timer_armed = true;
|
|
_ = time.timerOnce(service_endpoint, frame_interval_milliseconds);
|
|
}
|
|
|
|
/// A timer landing — the frame clock, or the deferred first native present armed by
|
|
/// `attach_scanout`: present the accumulated damage, then run the one-shot mode-set
|
|
/// self-check if the native upgrade queued it.
|
|
fn frameTick() void {
|
|
frame_timer_armed = false;
|
|
present();
|
|
if (pending_modeset_check) {
|
|
pending_modeset_check = false;
|
|
modesetSelfCheck();
|
|
}
|
|
}
|
|
|
|
// --- geometry helpers -------------------------------------------------------
|
|
|
|
fn screenRect() Rect {
|
|
const m = backend.info();
|
|
return .{ .x = 0, .y = 0, .w = @intCast(m.width), .h = @intCast(m.height) };
|
|
}
|
|
|
|
fn layerScreenRect(l: *const Layer) Rect {
|
|
return .{ .x = l.x, .y = l.y, .w = @intCast(l.surface.width), .h = @intCast(l.surface.height) };
|
|
}
|
|
|
|
/// Add `r` (screen coordinates) to the pending damage, clipped to the screen. In grid
|
|
/// mode the grid re-sizes itself lazily when the screen geometry changes — every
|
|
/// geometry-changing path (`attach_scanout`, `set_mode`) damages the whole new screen
|
|
/// right after, so damage pending from the old geometry is safely superseded.
|
|
fn addDamage(r: Rect) void {
|
|
const clipped = r.intersect(screenRect());
|
|
switch (damage_mode) {
|
|
.list => damage_list.add(clipped),
|
|
.grid => {
|
|
const mode = backend.info();
|
|
if (!damage_grid.matches(mode.width, mode.height)) damage_grid.reset(mode.width, mode.height);
|
|
damage_grid.add(clipped);
|
|
},
|
|
}
|
|
}
|
|
|
|
// --- layer operations (called from onMessage and the self-check) ------------
|
|
|
|
fn freeLayer() ?u32 {
|
|
for (&layers, 0..) |*l, i| {
|
|
if (!l.used) return @intCast(i);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// A used layer by id, or null if the id is out of range or free.
|
|
fn layerAt(id: u32) ?*Layer {
|
|
if (id >= maximum_layers or !layers[id].used) return null;
|
|
return &layers[id];
|
|
}
|
|
|
|
fn createLayer(x: i32, y: i32, w: u32, h: u32, z: u32, visible: bool) ?u32 {
|
|
if (w == 0 or h == 0) return null;
|
|
const slot = freeLayer() orelse return null;
|
|
const len = @as(usize, w) * h * 4;
|
|
const base = memory.mmap(len, memory.PROT_READ | memory.PROT_WRITE);
|
|
if (memory.mmapFailed(base)) return null;
|
|
layers[slot] = .{
|
|
.used = true,
|
|
.x = x,
|
|
.y = y,
|
|
.z = z,
|
|
.visible = visible,
|
|
.surface = .{ .pixels = @ptrFromInt(base), .stride = w, .width = w, .height = h },
|
|
.surface_len = len,
|
|
};
|
|
return slot;
|
|
}
|
|
|
|
fn fillLayer(id: u32, local: Rect, colour: u32) bool {
|
|
const l = layerAt(id) orelse return false;
|
|
compositor.fillRect(l.surface, local, colour);
|
|
// Damage in screen space = the fill, translated by the layer origin, within the layer.
|
|
const screen = Rect{ .x = l.x + local.x, .y = l.y + local.y, .w = local.w, .h = local.h };
|
|
addDamage(screen.intersect(layerScreenRect(l)));
|
|
return true;
|
|
}
|
|
|
|
fn blitLayer(id: u32, x: i32, y: i32, w: u32, h: u32, pixels: []const u8) bool {
|
|
const l = layerAt(id) orelse return false;
|
|
compositor.blitTile(l.surface, x, y, pixels, w, h);
|
|
const screen = Rect{ .x = l.x + x, .y = l.y + y, .w = @intCast(w), .h = @intCast(h) };
|
|
addDamage(screen.intersect(layerScreenRect(l)));
|
|
return true;
|
|
}
|
|
|
|
fn configureLayer(id: u32, x: i32, y: i32, z: u32, visible: bool) bool {
|
|
const l = layerAt(id) orelse return false;
|
|
addDamage(layerScreenRect(l)); // the old footprint must repaint
|
|
l.x = x;
|
|
l.y = y;
|
|
l.z = z;
|
|
l.visible = visible;
|
|
addDamage(layerScreenRect(l)); // and the new one
|
|
return true;
|
|
}
|
|
|
|
fn destroyLayer(id: u32) bool {
|
|
const l = layerAt(id) orelse return false;
|
|
addDamage(layerScreenRect(l));
|
|
_ = memory.munmap(@intFromPtr(l.surface.pixels), l.surface_len);
|
|
l.* = .{};
|
|
return true;
|
|
}
|
|
|
|
// --- compositing + present --------------------------------------------------
|
|
|
|
/// Repaint the damaged region `clip` of the backend's compose surface: clear it to the
|
|
/// background, then paint every visible layer that overlaps it, bottom to top (ascending z).
|
|
fn compositeInto(clip: Rect) void {
|
|
const target = backend.surface();
|
|
compositor.fillRect(target, clip, background);
|
|
|
|
// z-order the used, visible layers (n ≤ 16; a plain insertion sort of indices).
|
|
var order: [maximum_layers]u32 = undefined;
|
|
var n: usize = 0;
|
|
for (layers, 0..) |l, i| {
|
|
if (l.used and l.visible) {
|
|
order[n] = @intCast(i);
|
|
n += 1;
|
|
}
|
|
}
|
|
var a: usize = 1;
|
|
while (a < n) : (a += 1) {
|
|
const key = order[a];
|
|
var b: usize = a;
|
|
while (b > 0 and layers[order[b - 1]].z > layers[key].z) : (b -= 1) order[b] = order[b - 1];
|
|
order[b] = key;
|
|
}
|
|
|
|
for (order[0..n]) |i| {
|
|
const l = layers[i];
|
|
compositor.composite(target, l.x, l.y, l.surface, clip);
|
|
}
|
|
}
|
|
|
|
/// Composite each accumulated damage rectangle into the backend's surface, hand the list
|
|
/// to the backend to put on screen, then clear the damage. A no-op when nothing is dirty.
|
|
/// The frame counter advances regardless, so callers can name frames.
|
|
fn present() void {
|
|
var scratch: [compositor.TileGrid.maximum_rects]Rect = undefined;
|
|
const dirty: []const Rect = switch (damage_mode) {
|
|
.list => damage_list.slice(),
|
|
.grid => damage_grid.collect(&scratch),
|
|
};
|
|
const had_damage = dirty.len != 0;
|
|
if (had_damage) {
|
|
for (dirty) |region| compositeInto(region);
|
|
backend.present(dirty);
|
|
}
|
|
switch (damage_mode) {
|
|
.list => damage_list.clear(),
|
|
.grid => damage_grid.clear(),
|
|
}
|
|
frames += 1;
|
|
|
|
// The first present after a native upgrade confirms the composited frame actually reached
|
|
// the shared scanout surface (the automated stand-in for "it's on screen").
|
|
if (pending_native_verify and had_damage) {
|
|
pending_native_verify = false;
|
|
verifyNativePresent();
|
|
}
|
|
}
|
|
|
|
/// Read a pixel straight back from the shared scanout surface after a native present. The
|
|
/// surface starts zeroed, so a non-zero centre pixel means the compositor wrote the frame into
|
|
/// the pages the driver transfers-and-flushes from — that, plus the driver acking the present
|
|
/// over `.scanout`, is the serial proof the native path works.
|
|
fn verifyNativePresent() void {
|
|
const s = backend.surface();
|
|
const sample = s.pixels[@as(usize, s.height / 2) * s.stride + s.width / 2];
|
|
if (sample != 0) {
|
|
_ = logging.write("display: native present verified\n");
|
|
} else {
|
|
_ = logging.write("display: native present FAILED (blank surface)\n");
|
|
}
|
|
}
|
|
|
|
/// A native scanout driver announced itself: map the shared surface it handed over, find its
|
|
/// present channel, switch the backend to virtio-gpu, and queue a full-screen repaint. The
|
|
/// present is deferred to a timer (see `service_endpoint`) so it happens after this reply
|
|
/// unblocks the driver and it starts serving `.scanout`.
|
|
/// The surface arrives as the call's capability, and the harness's ownership rule
|
|
/// applies: nothing here claims it, so the turn closes it on every path. Safe
|
|
/// because a **mapping holds its own kernel reference** (system/kernel/process.zig
|
|
/// `systemSharedMemoryMap`) — the pixels stay ours after the handle naming them
|
|
/// goes, and a driver that dies and re-announces no longer costs a handle slot
|
|
/// per restart.
|
|
fn attachScanout(stride: u32, width: u32, height: u32, format: u32, refresh_hz: u32, arrived: *ipc.Arrival, reply: []u8) usize {
|
|
const cap = arrived.peek() orelse return fail(reply);
|
|
if (width == 0 or height == 0 or stride < width) return fail(reply);
|
|
const mapped = memory.sharedMap(cap) orelse return fail(reply);
|
|
const scanout = channel.openEndpoint("scanout") orelse return fail(reply);
|
|
// A second announce means the driver died and was restarted (V6): re-attach to its fresh
|
|
// scanout. (The previous shared mapping leaks — there is no shared_memory_unmap syscall yet — but the
|
|
// frames are the dead driver's, reclaimed on its exit; a handful across a crash is benign.)
|
|
const reattach = switch (backend) {
|
|
.virtio => true,
|
|
else => false,
|
|
};
|
|
|
|
backend = .{ .virtio = .{
|
|
.pixels = @ptrCast(@alignCast(mapped)),
|
|
.stride = stride,
|
|
.width = width,
|
|
.height = height,
|
|
.format = format,
|
|
.refresh_hz = refresh_hz,
|
|
.scanout = scanout,
|
|
} };
|
|
background = display_protocol.pack(format, 0x20, 0x30, 0x48); // re-pack the wallpaper for the mode
|
|
updateFrameClock(); // the GOP floor's clock dies here — pace by the GPU's EDID now
|
|
addDamage(screenRect()); // the whole new surface must be painted
|
|
pending_native_verify = true;
|
|
if (!reattach) pending_modeset_check = true; // the mode-set self-check runs once, on first upgrade
|
|
_ = time.timerOnce(service_endpoint, 50); // present once the driver is serving .scanout
|
|
_ = logging.write(if (reattach)
|
|
"display: scanout re-attached\n"
|
|
else
|
|
"display: scanout upgraded to virtio-gpu\n");
|
|
return ok(reply);
|
|
}
|
|
|
|
/// After the native upgrade is verified, prove the runtime-resolution-change and fenced-present
|
|
/// paths: query the driver's modes, switch to one that differs from the current, re-composite
|
|
/// the whole screen at the new size, and confirm the backend now reports that geometry. The
|
|
/// present goes through the driver's fenced flush, so a clean present is a *fenced* present —
|
|
/// completion-acknowledged and tear-free, not vblank-paced (docs/display-v2.md).
|
|
fn modesetSelfCheck() void {
|
|
if (!backend.canModeSet()) return;
|
|
var mode_list: [4]backend_mod.Mode = undefined;
|
|
const count = backend.modes(&mode_list);
|
|
if (count == 0) {
|
|
_ = logging.write("display: mode-set self-check: no modes reported\n");
|
|
return;
|
|
}
|
|
const current = backend.info();
|
|
var target: ?backend_mod.Mode = null;
|
|
for (mode_list[0..count]) |m| {
|
|
if (m.width != current.width or m.height != current.height) {
|
|
target = m;
|
|
break;
|
|
}
|
|
}
|
|
const wanted = target orelse {
|
|
_ = logging.write("display: mode-set self-check: no alternate mode offered\n");
|
|
return;
|
|
};
|
|
if (!backend.setMode(wanted.width, wanted.height)) {
|
|
_ = logging.write("display: mode set FAILED\n");
|
|
return;
|
|
}
|
|
addDamage(screenRect()); // repaint the whole screen at the new resolution, then present it
|
|
present();
|
|
|
|
const now = backend.info();
|
|
if (now.width == wanted.width and now.height == wanted.height) {
|
|
var line: [80]u8 = undefined;
|
|
_ = logging.write(std.fmt.bufPrint(&line, "display: mode set to {d}x{d}, verified\n", .{ now.width, now.height }) catch "display: mode set, verified\n");
|
|
if (backend.hasFencedPresent()) _ = logging.write("display: fenced present ok\n");
|
|
} else {
|
|
_ = logging.write("display: mode set FAILED (geometry unchanged)\n");
|
|
}
|
|
}
|
|
|
|
// --- startup self-check -----------------------------------------------------
|
|
|
|
/// Prove the compositor wiring on the real backend: two overlapping opaque layers,
|
|
/// composited, must show the top layer in the overlap and the bottom layer outside it.
|
|
/// Exercises the whole path — mmap surfaces, the z-sort, damage, composite into the
|
|
/// backend surface — and reads the composited result back. Cleans up after itself.
|
|
fn selfCheck() void {
|
|
const format = backend.info().format;
|
|
const red = display_protocol.pack(format, 0xC0, 0x20, 0x20);
|
|
const green = display_protocol.pack(format, 0x20, 0xC0, 0x20);
|
|
const bottom = createLayer(100, 100, 80, 80, 0, true) orelse return fail_check("create");
|
|
const top = createLayer(140, 140, 80, 80, 1, true) orelse return fail_check("create");
|
|
_ = fillLayer(bottom, Rect.init(0, 0, 80, 80), red);
|
|
_ = fillLayer(top, Rect.init(0, 0, 80, 80), green);
|
|
present();
|
|
|
|
const surface = backend.surface();
|
|
const overlap = surface.pixels[@as(usize, 150) * surface.stride + 150]; // in both → top
|
|
const bottom_only = surface.pixels[@as(usize, 110) * surface.stride + 110]; // bottom only
|
|
|
|
_ = destroyLayer(top);
|
|
_ = destroyLayer(bottom);
|
|
present(); // repaint the self-check region back to the background
|
|
|
|
if (overlap == green and bottom_only == red) {
|
|
_ = logging.write("display: compositor self-check ok\n");
|
|
} else {
|
|
_ = logging.write("display: compositor self-check FAILED\n");
|
|
}
|
|
}
|
|
|
|
fn fail_check(_: []const u8) void {
|
|
_ = logging.write("display: compositor self-check FAILED (setup)\n");
|
|
}
|
|
|
|
// --- cursor + mouse-input thread --------------------------------------------
|
|
//
|
|
// The compositor is the single owner of the framebuffer: only the main service
|
|
// loop touches `backend` and the layer stack. A dedicated listener thread (spawned
|
|
// in `initialise`) blocks on the input service's mouse stream, accumulates relative
|
|
// motion into an absolute cursor position, and hands that position to the main loop
|
|
// through `cursor_channel` — a single-slot latest-value cell (the renderer wants
|
|
// where the cursor *is*, not a replay of every delta). The listener never touches
|
|
// the compositor; it only writes the channel and pokes the main loop awake with a
|
|
// self-directed `ipc.send`, which arrives as a message-notification in the service
|
|
// loop (docs/threading.md, docs/display.md). Shared fate: a fault in the listener
|
|
// takes the whole display down and the supervisor restarts it (docs/resilience.md).
|
|
|
|
const cursor_size = 10; // a small square sprite — enough to prove tracking
|
|
const cursor_z = 0xFFFF_FFFF; // always above client layers
|
|
const cursor_report_threshold = 5; // px of travel before the tracking marker latches
|
|
|
|
var cursor_layer: ?u32 = null;
|
|
var cursor_origin_x: i32 = 0;
|
|
var cursor_origin_y: i32 = 0;
|
|
/// Latched once the cursor has demonstrably tracked a run of motion end to end
|
|
/// (source -> input service -> listener -> channel -> render): the `display-cursor`
|
|
/// test's success marker.
|
|
var cursor_tracking_reported: bool = false;
|
|
|
|
const poke_byte = [_]u8{0}; // the poke carries no payload; the value lives in the channel
|
|
|
|
/// Shared between the listener thread (producer) and the main loop (consumer).
|
|
/// Latest-value semantics with a coalesced wake: at most one poke is queued while
|
|
/// the main loop has not drained the last one, so a fast mouse cannot flood the
|
|
/// service endpoint.
|
|
const CursorChannel = struct {
|
|
lock: Thread.Mutex = .{},
|
|
poke_endpoint: ipc.Handle = 0,
|
|
x: i32 = 0,
|
|
y: i32 = 0,
|
|
buttons: u32 = 0,
|
|
dirty: bool = false,
|
|
poke_pending: bool = false,
|
|
|
|
const Snapshot = struct { x: i32, y: i32, buttons: u32 };
|
|
|
|
/// Producer (listener thread): record the newest position and, unless a wake is
|
|
/// already queued, poke the main loop awake.
|
|
fn publish(self: *CursorChannel, x: i32, y: i32, buttons: u32) void {
|
|
self.lock.lock();
|
|
self.x = x;
|
|
self.y = y;
|
|
self.buttons = buttons;
|
|
self.dirty = true;
|
|
const need_poke = !self.poke_pending;
|
|
if (need_poke) self.poke_pending = true;
|
|
self.lock.unlock();
|
|
if (need_poke) _ = ipc.send(self.poke_endpoint, &poke_byte);
|
|
}
|
|
|
|
/// Consumer (main loop): take the latest position, or null if nothing changed
|
|
/// since the last take. Clears the wake latch so the next publish pokes again.
|
|
fn take(self: *CursorChannel) ?Snapshot {
|
|
self.lock.lock();
|
|
defer self.lock.unlock();
|
|
self.poke_pending = false;
|
|
if (!self.dirty) return null;
|
|
self.dirty = false;
|
|
return .{ .x = self.x, .y = self.y, .buttons = self.buttons };
|
|
}
|
|
};
|
|
|
|
var cursor_channel: CursorChannel = .{};
|
|
|
|
fn clampAxis(value: i32, max: i32) i32 {
|
|
if (value < 0) return 0;
|
|
if (value > max) return max;
|
|
return value;
|
|
}
|
|
|
|
/// The mouse-listener thread. Blocks on the input service's mouse stream, accumulates
|
|
/// relative motion into an absolute position clamped to the screen, and publishes each
|
|
/// update. Runs for the life of the process; a parked `next()` leaves the core free to
|
|
/// halt (docs/halting.md). It reads only its own state and the channel — never the
|
|
/// compositor — so no lock guards the framebuffer.
|
|
fn mouseListener(width: u32, height: u32) void {
|
|
var mouse = input.subscribeMouse() orelse {
|
|
_ = logging.write("display: mouse subscribe failed\n");
|
|
return;
|
|
};
|
|
// Our own handle to the compositor's endpoint. IPC handles are per-thread, so we
|
|
// cannot reuse the main thread's — this thread resolves and opens
|
|
// `/protocol/display` exactly like any other client would, once at startup, and
|
|
// gets its own handle. There is no special mechanism for reaching yourself: the
|
|
// registry does not know or care that the provider is this process. A poke posted
|
|
// here wakes the compositor loop parked in replyWait (docs/threading.md: handles
|
|
// do not cross threads).
|
|
cursor_channel.poke_endpoint = channel.openEndpoint("display") orelse {
|
|
_ = logging.write("display: mouse listener could not reach the compositor endpoint\n");
|
|
return;
|
|
};
|
|
const max_x: i32 = @as(i32, @intCast(width)) - 1;
|
|
const max_y: i32 = @as(i32, @intCast(height)) - 1;
|
|
var x: i32 = @divTrunc(max_x, 2);
|
|
var y: i32 = @divTrunc(max_y, 2);
|
|
var buttons: u32 = 0;
|
|
while (true) {
|
|
const event = mouse.next() orelse continue;
|
|
// Switch on the raw kind (not @enumFromInt, which would panic on a scroll or
|
|
// future kind): motion moves the cursor, anything else just updates buttons.
|
|
if (event.kind == @intFromEnum(input.MouseEventKind.motion)) {
|
|
x = clampAxis(x + event.dx, max_x);
|
|
y = clampAxis(y + event.dy, max_y);
|
|
} else {
|
|
buttons = event.buttons;
|
|
}
|
|
cursor_channel.publish(x, y, buttons);
|
|
}
|
|
}
|
|
|
|
/// Consume the latest cursor position from the channel and move the cursor layer to it.
|
|
/// Runs on the main loop (the compositor owner) in response to a listener poke.
|
|
/// `configureLayer` damages both the old and new footprints; the frame clock presents
|
|
/// them at the next tick, so a fast mouse coalesces to at most ~60 repaints a second.
|
|
fn renderCursor() void {
|
|
const snapshot = cursor_channel.take() orelse return;
|
|
const id = cursor_layer orelse return;
|
|
_ = configureLayer(id, snapshot.x, snapshot.y, cursor_z, true);
|
|
schedulePresent();
|
|
if (!cursor_tracking_reported and
|
|
@abs(snapshot.x - cursor_origin_x) >= cursor_report_threshold and
|
|
@abs(snapshot.y - cursor_origin_y) >= cursor_report_threshold)
|
|
{
|
|
cursor_tracking_reported = true;
|
|
_ = logging.write("display: cursor tracking mouse ok\n");
|
|
}
|
|
}
|
|
|
|
/// Create the cursor sprite (a top-z square) at screen centre and spawn the listener
|
|
/// thread. Called from `initialise` once the backend is up. If either step fails the
|
|
/// display still serves drawing clients — it just has no cursor.
|
|
fn startCursorTracking() void {
|
|
const mode = backend.info();
|
|
cursor_origin_x = @divTrunc(@as(i32, @intCast(mode.width)), 2);
|
|
cursor_origin_y = @divTrunc(@as(i32, @intCast(mode.height)), 2);
|
|
const id = createLayer(cursor_origin_x, cursor_origin_y, cursor_size, cursor_size, cursor_z, true) orelse {
|
|
_ = logging.write("display: could not create cursor layer\n");
|
|
return;
|
|
};
|
|
cursor_layer = id;
|
|
_ = fillLayer(id, Rect.init(0, 0, cursor_size, cursor_size), display_protocol.pack(mode.format, 0xF0, 0xF0, 0xF0));
|
|
present(); // show the cursor at its start position
|
|
|
|
_ = Thread.spawn(.{}, mouseListener, .{ mode.width, mode.height }) catch {
|
|
_ = logging.write("display: could not spawn mouse listener\n");
|
|
};
|
|
}
|
|
|
|
// --- service ----------------------------------------------------------------
|
|
|
|
fn initialise(endpoint: ipc.Handle) bool {
|
|
service_endpoint = endpoint;
|
|
|
|
// Pick the scanout backend (GOP today). It logs the reason on failure.
|
|
backend = backend_mod.select() orelse return false;
|
|
const mode = backend.info();
|
|
background = display_protocol.pack(mode.format, 0x20, 0x30, 0x48); // a dark slate wallpaper
|
|
|
|
// Clear the whole screen through the compose surface → present path (double buffering:
|
|
// no direct-to-scanout drawing).
|
|
addDamage(screenRect());
|
|
present();
|
|
|
|
var line: [96]u8 = undefined;
|
|
_ = logging.write(std.fmt.bufPrint(&line, "display: online {d}x{d} pitch {d} format {d}\n", .{
|
|
mode.width, mode.height, mode.pitch, mode.format,
|
|
}) catch "display: online\n");
|
|
updateFrameClock();
|
|
_ = logging.write("display: presented frame 0\n");
|
|
|
|
selfCheck();
|
|
|
|
// Bring up the cursor and the mouse-listener thread now that the backend is live.
|
|
startCursorTracking();
|
|
return true;
|
|
}
|
|
|
|
fn writeReply(reply: []u8, value: display_protocol.Reply) usize {
|
|
const bytes = std.mem.asBytes(&value);
|
|
@memcpy(reply[0..bytes.len], bytes);
|
|
return bytes.len;
|
|
}
|
|
|
|
fn ok(reply: []u8) usize {
|
|
return writeReply(reply, .{ .status = 0 });
|
|
}
|
|
|
|
fn fail(reply: []u8) usize {
|
|
return writeReply(reply, .{ .status = -1 });
|
|
}
|
|
|
|
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
|
|
_ = sender;
|
|
if (message.len < display_protocol.request_size) return fail(reply);
|
|
const request = std.mem.bytesToValue(display_protocol.Request, message[0..display_protocol.request_size]);
|
|
const payload = message[display_protocol.request_size..];
|
|
// Switch on the raw operation value — an out-of-range one must fail cleanly, not
|
|
// panic an `@enumFromInt`.
|
|
switch (request.operation) {
|
|
@intFromEnum(display_protocol.Operation.info) => {
|
|
const m = backend.info();
|
|
return writeReply(reply, .{ .status = 0, .width = m.width, .height = m.height, .pitch = m.pitch, .format = m.format });
|
|
},
|
|
@intFromEnum(display_protocol.Operation.create_layer) => {
|
|
// x/y are signed coordinates carried in the u32 wire fields — reinterpret the
|
|
// bits (@bitCast), don't range-check (@intCast) which a negative would fail.
|
|
const slot = createLayer(@bitCast(request.x), @bitCast(request.y), request.width, request.height, request.z, request.visible != 0) orelse return fail(reply);
|
|
return writeReply(reply, .{ .status = 0, .layer = slot });
|
|
},
|
|
@intFromEnum(display_protocol.Operation.configure_layer) => {
|
|
return if (configureLayer(request.layer, @bitCast(request.x), @bitCast(request.y), request.z, request.visible != 0)) ok(reply) else fail(reply);
|
|
},
|
|
@intFromEnum(display_protocol.Operation.destroy_layer) => {
|
|
return if (destroyLayer(request.layer)) ok(reply) else fail(reply);
|
|
},
|
|
@intFromEnum(display_protocol.Operation.fill_rect) => {
|
|
const local = Rect.init(@bitCast(request.x), @bitCast(request.y), @intCast(request.width), @intCast(request.height));
|
|
return if (fillLayer(request.layer, local, request.colour)) ok(reply) else fail(reply);
|
|
},
|
|
@intFromEnum(display_protocol.Operation.blit_tile) => {
|
|
return if (blitLayer(request.layer, @bitCast(request.x), @bitCast(request.y), request.width, request.height, payload)) ok(reply) else fail(reply);
|
|
},
|
|
@intFromEnum(display_protocol.Operation.damage) => {
|
|
const l = layerAt(request.layer) orelse return fail(reply);
|
|
const screen = Rect{ .x = l.x + @as(i32, @bitCast(request.x)), .y = l.y + @as(i32, @bitCast(request.y)), .w = @intCast(request.width), .h = @intCast(request.height) };
|
|
addDamage(screen.intersect(layerScreenRect(l)));
|
|
return ok(reply);
|
|
},
|
|
@intFromEnum(display_protocol.Operation.present) => {
|
|
// Scheduled, not immediate: the frame clock composites the accumulated damage
|
|
// at the next tick, so back-to-back client presents coalesce into one frame.
|
|
schedulePresent();
|
|
return ok(reply);
|
|
},
|
|
@intFromEnum(display_protocol.Operation.attach_scanout) => {
|
|
return attachScanout(request.x, request.width, request.height, request.colour, request.y, arrived, reply);
|
|
},
|
|
@intFromEnum(display_protocol.Operation.set_mode) => {
|
|
if (!backend.setMode(request.width, request.height)) return fail(reply);
|
|
addDamage(screenRect()); // repaint the whole screen at the new resolution
|
|
present();
|
|
return ok(reply);
|
|
},
|
|
@intFromEnum(display_protocol.Operation.get_modes) => {
|
|
var list: [4]backend_mod.Mode = undefined;
|
|
const count = backend.modes(&list);
|
|
var response = display_protocol.ModesReply{ .status = 0, .count = @intCast(count), .modes = undefined };
|
|
for (0..display_protocol.max_modes) |i| {
|
|
response.modes[i] = if (i < count)
|
|
.{ .width = list[i].width, .height = list[i].height }
|
|
else
|
|
.{ .width = 0, .height = 0 };
|
|
}
|
|
const bytes = std.mem.asBytes(&response);
|
|
@memcpy(reply[0..bytes.len], bytes);
|
|
return bytes.len;
|
|
},
|
|
else => return fail(reply),
|
|
}
|
|
}
|
|
|
|
/// Two notification sources reach the compositor, and one coalesced badge can carry
|
|
/// both, so each bit is handled independently. A **message-notification** is a poke from
|
|
/// the mouse-listener thread (a buffered self-`ipc.send`, `notify_message_bit`): fold the
|
|
/// newest cursor position into the scene. A **timer** (`notify_timer_bit`) is the frame
|
|
/// clock — or the deferred first native present after `attach_scanout` — either way,
|
|
/// present the accumulated damage.
|
|
fn onNotification(badge: u64) void {
|
|
if (badge & ipc.notify_message_bit != 0) renderCursor();
|
|
if (badge & ipc.notify_timer_bit != 0) frameTick();
|
|
}
|
|
|
|
pub fn main() void {
|
|
service.run(display_protocol.message_maximum, .{
|
|
.service = "display",
|
|
.init = initialise,
|
|
.on_message = onMessage,
|
|
.on_notification = onNotification,
|
|
});
|
|
}
|