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
+12 -11
View File
@@ -40,21 +40,22 @@ used ring**. Those two together (pixel-readback + flush-ack) are the automated s
--- ---
## V1 — The scanout backend seam (refactor, no behaviour change) ## V1 — The scanout backend seam (refactor, no behaviour change) ✅
Extract scanout from the compositor so today's path becomes one backend among future ones. Extract scanout from the compositor so today's path becomes one backend among future ones.
- [ ] A `Backend` interface in `system/services/display/`: `surface() -> {ptr, pitch, - [x] `system/services/display/backend.zig`: a `Backend` tagged union with `info()`,
format, width, height}`, `present(damage: Rect)`, and capability flags `surface()` (the cacheable compose target), `present(damage)`, and capability flags
(`canModeSet`, `hasVsync`, both false for now). (`canModeSet`/`hasVsync`, both false for GOP).
- [ ] Wrap the v1 GOP path as `GopBackend` (claim the `display` node, WC-map the LFB, - [x] The v1 GOP path is now `backend.Gop` (claims the `display` node, WC-maps the LFB,
`present` = the current damage-rect WC copy). The compositor composes into keeps the cacheable back buffer, `present` = the damage-rect WC copy). display.zig
`backend.surface()` and calls `backend.present(damage)` — no direct LFB references composes into `backend.surface()` and calls `backend.present(damage)` — no LFB or
left in the compositor core. framebuffer geometry left in the compositor core.
- [ ] Pure backend-selection logic factored so it's host-testable. - [x] The selection decision is the pure `chooseKind(native_available)` (gop unless a
native driver announced), split from the syscall-bound `select()`/`Gop.init()`.
**Gate:** `display-service` + `display-demo` still pass unchanged (pure refactor; GOP is **Gate (met):** `display-service` + `display-demo` pass **unchanged** (pure refactor; GOP
the only backend), and `zig build test` stays green. is the only backend), and `zig build test` stays green.
## V2 — The `shm` cross-process memory capability (kernel) ## V2 — The `shm` cross-process memory capability (kernel)
+174
View File
@@ -0,0 +1,174 @@
//! The compositor's **scanout backend** — how a finished frame reaches the panel
//! (docs/display-v2.md). The compositor composes its layer stack into the backend's
//! cacheable `surface()` and calls `present(damage)`; everything device-specific lives
//! here. Today there is one backend, `Gop` — the firmware framebuffer: a cacheable back
//! buffer streamed write-combining to the linear framebuffer. A native virtio-gpu backend
//! slots in beside it later (V4); the compositor never learns which is active.
const std = @import("std");
const runtime = @import("runtime");
const compositor = @import("compositor.zig");
const system = runtime.system;
const device = runtime.device;
const Rect = compositor.Rect;
const Surface = compositor.Surface;
/// The current display mode, as a backend reports it.
pub const Info = struct { width: u32, height: u32, pitch: u32, format: u32 };
/// Enumeration scratch — a `DeviceDescriptor` is large, and only one scan is ever needed.
var device_table: [64]device.DeviceDescriptor = undefined;
/// The GOP framebuffer backend: claims the kernel-seeded `display` device, maps the linear
/// framebuffer write-combining as the front buffer, and keeps a cacheable back buffer of
/// the same geometry as the compose target. `present` streams the damaged rectangle from
/// the back buffer to the LFB (sequential WC writes; the LFB is never read). No mode-set,
/// no vsync — the portable floor (docs/display-v2.md).
pub const Gop = struct {
device_id: u64,
front: [*]volatile u8, // the LFB (write-combining)
back: [*]u8, // cacheable compose target, same geometry
width: u32,
height: u32,
pitch: u32,
format: u32,
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;
}
/// Claim the framebuffer (retrying while discovery catches up), map the LFB, and
/// allocate the back buffer. Null if there is no framebuffer or a mapping fails.
pub fn init() ?Gop {
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 null;
};
if (!device.claim(found.id)) {
_ = system.write("display: could not claim the framebuffer\n");
return null;
}
// 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 null;
};
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 null;
}
return .{
.device_id = found.id,
.front = @ptrFromInt(front_base),
.back = @ptrFromInt(back_base),
.width = geometry.width,
.height = geometry.height,
.pitch = geometry.pitch,
.format = geometry.format,
};
}
pub fn info(self: *const Gop) Info {
return .{ .width = self.width, .height = self.height, .pitch = self.pitch, .format = self.format };
}
/// The cacheable compose target (the back buffer).
pub fn surface(self: *const Gop) Surface {
return .{
.pixels = @ptrCast(@alignCast(self.back)),
.stride = self.pitch / 4, // pitch is bytes; a 32-bpp row is pitch/4 pixels
.width = self.width,
.height = self.height,
};
}
/// Stream the damaged rectangle from the back buffer to the write-combining LFB, row by
/// row (sequential writes — what WC memory wants; the LFB is never read).
pub fn present(self: *const Gop, damage: Rect) void {
const c = damage.intersect(.{ .x = 0, .y = 0, .w = @intCast(self.width), .h = @intCast(self.height) });
if (c.isEmpty()) return;
var y: i32 = c.y;
while (y < c.bottom()) : (y += 1) {
const off = @as(usize, @intCast(y)) * self.pitch;
const src: [*]const u32 = @ptrCast(@alignCast(self.back + off));
const dst: [*]volatile u32 = @ptrCast(@alignCast(self.front + off));
var x: i32 = c.x;
while (x < c.right()) : (x += 1) dst[@intCast(x)] = src[@intCast(x)];
}
}
};
/// The pluggable scanout backend. A tagged union so the compositor holds one value and
/// dispatches without caring which is active; a `virtio` variant joins `gop` at V4.
pub const Backend = union(enum) {
gop: Gop,
pub fn info(self: *const Backend) Info {
return switch (self.*) {
inline else => |*b| b.info(),
};
}
pub fn surface(self: *const Backend) Surface {
return switch (self.*) {
inline else => |*b| b.surface(),
};
}
pub fn present(self: *const Backend, damage: Rect) void {
switch (self.*) {
inline else => |*b| b.present(damage),
}
}
/// Whether this backend supports runtime mode-setting (GOP: no; a native driver: yes).
pub fn canModeSet(self: *const Backend) bool {
return switch (self.*) {
.gop => false,
};
}
/// Whether this backend has a vblank/fence for tear-free present (GOP: no).
pub fn hasVsync(self: *const Backend) bool {
return switch (self.*) {
.gop => false,
};
}
};
/// Which backend to use. The pure selection *decision* is `chooseKind`; `select` below
/// binds it to the (syscall-bound) bring-up.
pub const Kind = enum { gop, virtio };
/// The selection decision, factored out of bring-up so it stays pure and host-testable:
/// prefer a native driver when one has announced itself (docs/display-v2.md V4), else the
/// GOP floor. Trivial today; it grows real inputs when native detection lands.
pub fn chooseKind(native_available: bool) Kind {
return if (native_available) .virtio else .gop;
}
/// Pick and bring up the best available backend. Today the GOP framebuffer is the only one
/// (`chooseKind(false)` → `.gop`), so this is `Gop.init()`. V4 adds the native-if-present
/// branch, with GOP as the floor.
pub fn select() ?Backend {
return switch (chooseKind(false)) {
.gop => .{ .gop = Gop.init() orelse return null },
.virtio => unreachable, // no native detection yet (V4)
};
}
test "selection prefers native when present, else the gop floor" {
try std.testing.expectEqual(Kind.gop, chooseKind(false));
try std.testing.expectEqual(Kind.virtio, chooseKind(true));
}
+48 -133
View File
@@ -1,43 +1,32 @@
//! /system/services/display — the display service (docs/display.md). A ring-3 process //! /system/services/display — the display service (docs/display.md, docs/display-v2.md).
//! that claims the framebuffer the kernel seeded (docs/display-plan.md D1), owns it as a //! A ring-3 compositor: it composes an ordered stack of **layers** into a cacheable
//! **write-combining front buffer**, composites an ordered stack of **layers** into a //! surface and presents finished frames. Scanout — how a frame reaches the panel — is a
//! **cacheable back buffer**, and presents finished frames — the GUI track's compositor, //! pluggable **backend** ([backend.zig](backend.zig)): the GOP framebuffer today, a native
//! the sibling of the input service. Reached by name over `ServiceId.display`. //! 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, //! 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 //! z-order, and visibility. Clients create layers, draw into them by command (`fill_rect`,
//! (`fill_rect`, `blit_tile`), mark `damage`, and ask for a `present`; the compositor //! `blit_tile`), mark `damage`, and ask for a `present`; the compositor repaints only the
//! repaints only the damaged region — clear it, paint the visible layers bottom-to-top, //! damaged region — clear it, paint the visible layers bottom-to-top into the backend's
//! flush it to the screen. The pixel math lives in the pure, host-tested //! surface, then `backend.present(damage)`. Shared-memory client surfaces are later
//! [compositor.zig](compositor.zig); this file wires real surfaces and the framebuffer to //! (docs/display-v2.md).
//! it. Shared-memory client surfaces are a later milestone (docs/display.md).
const std = @import("std"); const std = @import("std");
const runtime = @import("runtime"); const runtime = @import("runtime");
const compositor = @import("compositor.zig"); const compositor = @import("compositor.zig");
const backend_mod = @import("backend.zig");
const protocol = runtime.display_protocol; const protocol = runtime.display_protocol;
const ipc = runtime.ipc; const ipc = runtime.ipc;
const system = runtime.system; const system = runtime.system;
const device = runtime.device;
const Rect = compositor.Rect; const Rect = compositor.Rect;
const Surface = compositor.Surface; const Surface = compositor.Surface;
/// The claimed framebuffer and its off-screen twin. The front buffer is the LFB — /// The active scanout backend — GOP today, a native driver when one is present.
/// write-combining, so it is **only ever written**, never read; all compositing happens var backend: backend_mod.Backend = undefined;
/// in the cacheable back buffer, which is then streamed to the front (docs/display.md). var frames: u64 = 0;
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 wallpaper the compositor clears damaged regions to before painting layers. /// The wallpaper the compositor clears damaged regions to before painting layers.
var background: u32 = 0; var background: u32 = 0;
@@ -60,23 +49,11 @@ const Layer = struct {
var layers: [maximum_layers]Layer = [_]Layer{.{}} ** maximum_layers; var layers: [maximum_layers]Layer = [_]Layer{.{}} ** maximum_layers;
var damage: Rect = Rect.empty; 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 ------------------------------------------------------- // --- geometry helpers -------------------------------------------------------
fn screenRect() Rect { fn screenRect() Rect {
return .{ .x = 0, .y = 0, .w = @intCast(display.width), .h = @intCast(display.height) }; const m = backend.info();
} return .{ .x = 0, .y = 0, .w = @intCast(m.width), .h = @intCast(m.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,
};
} }
fn layerScreenRect(l: *const Layer) Rect { fn layerScreenRect(l: *const Layer) Rect {
@@ -159,11 +136,11 @@ fn destroyLayer(id: u32) bool {
// --- compositing + present -------------------------------------------------- // --- compositing + present --------------------------------------------------
/// Repaint the damaged region `clip` of the back buffer: clear it to the background, then /// Repaint the damaged region `clip` of the backend's compose surface: clear it to the
/// paint every visible layer that overlaps it, bottom to top (ascending z). /// background, then paint every visible layer that overlaps it, bottom to top (ascending z).
fn compositeInto(clip: Rect) void { fn compositeInto(clip: Rect) void {
const back = backSurface(); const target = backend.surface();
compositor.fillRect(back, clip, background); compositor.fillRect(target, clip, background);
// z-order the used, visible layers (n ≤ 16; a plain insertion sort of indices). // z-order the used, visible layers (n ≤ 16; a plain insertion sort of indices).
var order: [maximum_layers]u32 = undefined; var order: [maximum_layers]u32 = undefined;
@@ -184,56 +161,42 @@ fn compositeInto(clip: Rect) void {
for (order[0..n]) |i| { for (order[0..n]) |i| {
const l = layers[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 /// Composite the accumulated damage into the backend's surface, hand it to the backend to
/// front buffer, row by row (sequential writes — what WC memory wants; we never read the /// put on screen, then clear the damage. A no-op when nothing is dirty. The frame counter
/// front buffer). Only the visible width of each row is touched. /// advances regardless, so callers can name frames.
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.
fn present() void { fn present() void {
const dirty = damage.intersect(screenRect()); const dirty = damage.intersect(screenRect());
if (!dirty.isEmpty()) { if (!dirty.isEmpty()) {
compositeInto(dirty); compositeInto(dirty);
flushRect(dirty); backend.present(dirty);
} }
damage = Rect.empty; damage = Rect.empty;
display.frames += 1; frames += 1;
} }
// --- startup self-check ----------------------------------------------------- // --- 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. /// 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 /// Exercises the whole path — mmap surfaces, the z-sort, damage, composite into the
/// buffer — and reads the composited result back. Cleans up after itself. /// backend surface — and reads the composited result back. Cleans up after itself.
fn selfCheck() void { fn selfCheck() void {
const red = protocol.pack(display.format, 0xC0, 0x20, 0x20); const format = backend.info().format;
const green = protocol.pack(display.format, 0x20, 0xC0, 0x20); 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 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"); const top = createLayer(140, 140, 80, 80, 1, true) orelse return fail_check("create");
_ = fillLayer(bottom, Rect.init(0, 0, 80, 80), red); _ = fillLayer(bottom, Rect.init(0, 0, 80, 80), red);
_ = fillLayer(top, Rect.init(0, 0, 80, 80), green); _ = fillLayer(top, Rect.init(0, 0, 80, 80), green);
present(); present();
const back = backSurface(); const surface = backend.surface();
const overlap = back.pixels[@as(usize, 150) * back.stride + 150]; // in both layers → top const overlap = surface.pixels[@as(usize, 150) * surface.stride + 150]; // in both → top
const bottom_only = back.pixels[@as(usize, 110) * back.stride + 110]; // bottom only const bottom_only = surface.pixels[@as(usize, 110) * surface.stride + 110]; // bottom only
_ = destroyLayer(top); _ = destroyLayer(top);
_ = destroyLayer(bottom); _ = destroyLayer(bottom);
@@ -252,67 +215,22 @@ fn fail_check(_: []const u8) void {
// --- service ---------------------------------------------------------------- // --- 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 { fn initialise(endpoint: ipc.Handle) bool {
_ = endpoint; _ = endpoint;
// Find the framebuffer, retrying while device discovery catches up with our spawn. // Pick the scanout backend (GOP today). It logs the reason on failure.
var tries: u32 = 0; backend = backend_mod.select() orelse return false;
const found = while (tries < 100) : (tries += 1) { const mode = backend.info();
if (findDisplay()) |d| break d; background = protocol.pack(mode.format, 0x20, 0x30, 0x48); // a dark slate wallpaper
system.sleep(50);
} else {
_ = system.write("display: no framebuffer device (headless?)\n");
return false; // clean exit: nothing to drive
};
if (!device.claim(found.id)) { // Clear the whole screen through the compose surface → present path (double buffering:
_ = system.write("display: could not claim the framebuffer\n"); // no direct-to-scanout drawing).
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).
addDamage(screenRect()); addDamage(screenRect());
present(); present();
var line: [96]u8 = undefined; var line: [96]u8 = undefined;
_ = system.write(std.fmt.bufPrint(&line, "display: online {d}x{d} pitch {d} format {d}\n", .{ _ = 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"); }) catch "display: online\n");
_ = system.write("display: presented frame 0\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 // Switch on the raw operation value — an out-of-range one must fail cleanly, not
// panic an `@enumFromInt`. // panic an `@enumFromInt`.
switch (request.operation) { switch (request.operation) {
@intFromEnum(protocol.Operation.info) => return writeReply(reply, .{ @intFromEnum(protocol.Operation.info) => {
.status = 0, const m = backend.info();
.width = display.width, return writeReply(reply, .{ .status = 0, .width = m.width, .height = m.height, .pitch = m.pitch, .format = m.format });
.height = display.height, },
.pitch = display.pitch,
.format = display.format,
}),
@intFromEnum(protocol.Operation.create_layer) => { @intFromEnum(protocol.Operation.create_layer) => {
// x/y are signed coordinates carried in the u32 wire fields — reinterpret the // 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. // bits (@bitCast), don't range-check (@intCast) which a negative would fail.