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
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
3 changed files with 234 additions and 144 deletions

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.
- [ ] A `Backend` interface in `system/services/display/`: `surface() -> {ptr, pitch,
format, width, height}`, `present(damage: Rect)`, and capability flags
(`canModeSet`, `hasVsync`, both false for now).
- [ ] Wrap the v1 GOP path as `GopBackend` (claim the `display` node, WC-map the LFB,
`present` = the current damage-rect WC copy). The compositor composes into
`backend.surface()` and calls `backend.present(damage)` — no direct LFB references
left in the compositor core.
- [ ] Pure backend-selection logic factored so it's host-testable.
- [x] `system/services/display/backend.zig`: a `Backend` tagged union with `info()`,
`surface()` (the cacheable compose target), `present(damage)`, and capability flags
(`canModeSet`/`hasVsync`, both false for GOP).
- [x] The v1 GOP path is now `backend.Gop` (claims the `display` node, WC-maps the LFB,
keeps the cacheable back buffer, `present` = the damage-rect WC copy). display.zig
composes into `backend.surface()` and calls `backend.present(damage)` — no LFB or
framebuffer geometry left in the compositor core.
- [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
the only backend), and `zig build test` stays green.
**Gate (met):** `display-service` + `display-demo` pass **unchanged** (pure refactor; GOP
is the only backend), and `zig build test` stays green.
## V2 — The `shm` cross-process memory capability (kernel)

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));
}

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.