display: pluggable scanout backend seam (v2 V1)

Extract scanout from the compositor into backend.zig — a `Backend` tagged union
with info()/surface()/present(damage) and canModeSet/hasVsync flags. The v1 GOP
path becomes `backend.Gop` (claim the display node, WC-map the LFB, keep the
cacheable back buffer, present = the damage-rect WC copy); display.zig now
composes into backend.surface() and calls backend.present(damage), with no LFB or
framebuffer geometry left in the compositor core. The selection decision is the
pure chooseKind(native_available), split from the syscall-bound bring-up, ready
for the native-if-present branch at V4.

Pure refactor: display-service, display-demo, and zig build test all pass
unchanged.
This commit is contained in:
Daniel Samson
2026-07-14 09:40:15 +01:00
parent f3342118f5
commit 9333d0572f
3 changed files with 234 additions and 144 deletions
+48 -133
View File
@@ -1,43 +1,32 @@
//! /system/services/display — the display service (docs/display.md). A ring-3 process
//! that claims the framebuffer the kernel seeded (docs/display-plan.md D1), owns it as a
//! **write-combining front buffer**, composites an ordered stack of **layers** into a
//! **cacheable back buffer**, and presents finished frames — the GUI track's compositor,
//! the sibling of the input service. Reached by name over `ServiceId.display`.
//! /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 and 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,
//! flush it to the screen. The pixel math lives in the pure, host-tested
//! [compositor.zig](compositor.zig); this file wires real surfaces and the framebuffer to
//! it. Shared-memory client surfaces are a later milestone (docs/display.md).
//! 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)`. Shared-memory client surfaces are later
//! (docs/display-v2.md).
const std = @import("std");
const runtime = @import("runtime");
const compositor = @import("compositor.zig");
const backend_mod = @import("backend.zig");
const protocol = runtime.display_protocol;
const ipc = runtime.ipc;
const system = runtime.system;
const device = runtime.device;
const Rect = compositor.Rect;
const Surface = compositor.Surface;
/// The claimed framebuffer and its off-screen twin. The front buffer is the LFB —
/// write-combining, so it is **only ever written**, never read; all compositing happens
/// in the cacheable back buffer, which is then streamed to the front (docs/display.md).
const Display = struct {
device_id: u64,
front: [*]volatile u8, // the LFB (write-combining)
back: [*]u8, // cacheable, same geometry
width: u32,
height: u32,
pitch: u32, // bytes per row (shared by both buffers)
format: u32, // a device-abi DisplayFormat value
frames: u64 = 0,
};
var display: Display = undefined;
/// The active scanout backend — GOP today, a native driver when one is present.
var backend: backend_mod.Backend = undefined;
var frames: u64 = 0;
/// The wallpaper the compositor clears damaged regions to before painting layers.
var background: u32 = 0;
@@ -60,23 +49,11 @@ const Layer = struct {
var layers: [maximum_layers]Layer = [_]Layer{.{}} ** maximum_layers;
var damage: Rect = Rect.empty;
/// Enumeration buffer kept off the stack — a `DeviceDescriptor` is large, and this
/// service only ever needs one scan.
var device_table: [64]device.DeviceDescriptor = undefined;
// --- geometry helpers -------------------------------------------------------
fn screenRect() Rect {
return .{ .x = 0, .y = 0, .w = @intCast(display.width), .h = @intCast(display.height) };
}
fn backSurface() Surface {
return .{
.pixels = @ptrCast(@alignCast(display.back)),
.stride = display.pitch / 4, // pitch is bytes; a 32-bpp row is pitch/4 pixels
.width = display.width,
.height = display.height,
};
const m = backend.info();
return .{ .x = 0, .y = 0, .w = @intCast(m.width), .h = @intCast(m.height) };
}
fn layerScreenRect(l: *const Layer) Rect {
@@ -159,11 +136,11 @@ fn destroyLayer(id: u32) bool {
// --- compositing + present --------------------------------------------------
/// Repaint the damaged region `clip` of the back buffer: clear it to the background, then
/// paint every visible layer that overlaps it, bottom to top (ascending z).
/// 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 back = backSurface();
compositor.fillRect(back, clip, background);
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;
@@ -184,56 +161,42 @@ fn compositeInto(clip: Rect) void {
for (order[0..n]) |i| {
const l = layers[i];
compositor.composite(back, l.x, l.y, l.surface, clip);
compositor.composite(target, l.x, l.y, l.surface, clip);
}
}
/// Stream the damaged rectangle from the cacheable back buffer to the write-combining
/// front buffer, row by row (sequential writes — what WC memory wants; we never read the
/// front buffer). Only the visible width of each row is touched.
fn flushRect(rect: Rect) void {
const c = rect.intersect(screenRect());
if (c.isEmpty()) return;
var y: i32 = c.y;
while (y < c.bottom()) : (y += 1) {
const off = @as(usize, @intCast(y)) * display.pitch;
const src: [*]const u32 = @ptrCast(@alignCast(display.back + off));
const dst: [*]volatile u32 = @ptrCast(@alignCast(display.front + off));
var x: i32 = c.x;
while (x < c.right()) : (x += 1) dst[@intCast(x)] = src[@intCast(x)];
}
}
/// Composite and flush the accumulated damage, then clear it. A no-op when nothing is
/// dirty. The frame counter advances regardless, so callers can name frames.
/// Composite the accumulated damage into the backend's surface, hand it 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 {
const dirty = damage.intersect(screenRect());
if (!dirty.isEmpty()) {
compositeInto(dirty);
flushRect(dirty);
backend.present(dirty);
}
damage = Rect.empty;
display.frames += 1;
frames += 1;
}
// --- startup self-check -----------------------------------------------------
/// Prove the compositor wiring on the real framebuffer: two overlapping opaque layers,
/// 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 back
/// buffer — and reads the composited result back. Cleans up after itself.
/// 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 red = protocol.pack(display.format, 0xC0, 0x20, 0x20);
const green = protocol.pack(display.format, 0x20, 0xC0, 0x20);
const format = backend.info().format;
const red = protocol.pack(format, 0xC0, 0x20, 0x20);
const green = 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 back = backSurface();
const overlap = back.pixels[@as(usize, 150) * back.stride + 150]; // in both layers → top
const bottom_only = back.pixels[@as(usize, 110) * back.stride + 110]; // bottom only
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);
@@ -252,67 +215,22 @@ fn fail_check(_: []const u8) void {
// --- service ----------------------------------------------------------------
/// The framebuffer node the kernel seeded (`DeviceClass.display`), or null if none.
fn findDisplay() ?device.DeviceDescriptor {
const total = device.enumerate(&device_table);
const n = @min(total, device_table.len);
for (device_table[0..n]) |d| {
if (d.class == @intFromEnum(device.DeviceClass.display)) return d;
}
return null;
}
fn initialise(endpoint: ipc.Handle) bool {
_ = endpoint;
// Find the framebuffer, retrying while device discovery catches up with our spawn.
var tries: u32 = 0;
const found = while (tries < 100) : (tries += 1) {
if (findDisplay()) |d| break d;
system.sleep(50);
} else {
_ = system.write("display: no framebuffer device (headless?)\n");
return false; // clean exit: nothing to drive
};
// Pick the scanout backend (GOP today). It logs the reason on failure.
backend = backend_mod.select() orelse return false;
const mode = backend.info();
background = protocol.pack(mode.format, 0x20, 0x30, 0x48); // a dark slate wallpaper
if (!device.claim(found.id)) {
_ = system.write("display: could not claim the framebuffer\n");
return false;
}
// Resource 0 is the framebuffer memory window; the kernel maps it write-combining
// because the resource carries that flag (docs/display-plan.md D1).
const front_base = device.mmioMap(found.id, 0) orelse {
_ = system.write("display: could not map the framebuffer\n");
return false;
};
const geometry = found.display;
const size = @as(usize, geometry.height) * geometry.pitch;
const back_base = system.mmap(size, system.PROT_READ | system.PROT_WRITE);
if (system.mmapFailed(back_base)) {
_ = system.write("display: could not allocate the back buffer\n");
return false;
}
display = .{
.device_id = found.id,
.front = @ptrFromInt(front_base),
.back = @ptrFromInt(back_base),
.width = geometry.width,
.height = geometry.height,
.pitch = geometry.pitch,
.format = geometry.format,
};
background = protocol.pack(display.format, 0x20, 0x30, 0x48); // a dark slate wallpaper
// Clear the whole screen through the back buffer → present path (double buffering:
// no direct-to-LFB drawing).
// 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;
_ = system.write(std.fmt.bufPrint(&line, "display: online {d}x{d} pitch {d} format {d}\n", .{
display.width, display.height, display.pitch, display.format,
mode.width, mode.height, mode.pitch, mode.format,
}) catch "display: online\n");
_ = system.write("display: presented frame 0\n");
@@ -343,13 +261,10 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Han
// Switch on the raw operation value — an out-of-range one must fail cleanly, not
// panic an `@enumFromInt`.
switch (request.operation) {
@intFromEnum(protocol.Operation.info) => return writeReply(reply, .{
.status = 0,
.width = display.width,
.height = display.height,
.pitch = display.pitch,
.format = display.format,
}),
@intFromEnum(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(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.