320 lines
15 KiB
Zig
320 lines
15 KiB
Zig
//! 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 device = @import("driver");
|
|
const ipc = @import("ipc");
|
|
const time = @import("time");
|
|
const memory = @import("memory");
|
|
const logging = @import("logging");
|
|
const compositor = @import("compositor.zig");
|
|
|
|
const envelope = @import("envelope");
|
|
const scanout_protocol = @import("scanout-protocol");
|
|
const Rect = compositor.Rect;
|
|
const Surface = compositor.Surface;
|
|
|
|
/// The current display mode, as a backend reports it. `refresh_hz` is the panel's
|
|
/// refresh rate from EDID (0 = unknown) — the frame clock's pacing seed; without vblank
|
|
/// it fixes the rate, never the phase (docs/display-v2.md, "Fenced is not vsync").
|
|
pub const Info = struct { width: u32, height: u32, pitch: u32, format: u32, refresh_hz: 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 present fence — 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,
|
|
refresh_hz: u32, // from the boot EDID via the display0 node (0 = unknown)
|
|
|
|
/// The framebuffer's id and geometry, captured together. `findDisplay` reads these out of
|
|
/// the enumeration table and returns them by value, so the caller never re-reads the table
|
|
/// across later syscalls (`device_enumerate` writes the whole table straight into this
|
|
/// process's memory; reading a descriptor's tail again after other syscalls have run is a
|
|
/// window we simply avoid by copying the few fields we need up front).
|
|
const Found = struct { id: u64, width: u32, height: u32, pitch: u32, format: u32, refresh_hz: u32 };
|
|
|
|
/// The first `display`-class device with a *valid* (non-zero) geometry, or null. A zero
|
|
/// geometry is treated as "not ready yet" so the caller retries — a real framebuffer always
|
|
/// has a non-zero width, height, and pitch.
|
|
fn findDisplay() ?Found {
|
|
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)) continue;
|
|
if (d.display.width == 0 or d.display.height == 0 or d.display.pitch == 0) continue;
|
|
return .{ .id = d.id, .width = d.display.width, .height = d.display.height, .pitch = d.display.pitch, .format = d.display.format, .refresh_hz = d.display.refresh_hz };
|
|
}
|
|
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()) |f| break f;
|
|
time.sleepMillis(50);
|
|
} else {
|
|
_ = logging.write("display: no framebuffer device (headless?)\n");
|
|
return null;
|
|
};
|
|
|
|
if (!device.claim(found.id)) {
|
|
_ = logging.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 {
|
|
_ = logging.write("display: could not map the framebuffer\n");
|
|
return null;
|
|
};
|
|
const size = @as(usize, found.height) * found.pitch;
|
|
const back_base = memory.mmap(size, memory.PROT_READ | memory.PROT_WRITE);
|
|
if (memory.mmapFailed(back_base)) {
|
|
_ = logging.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 = found.width,
|
|
.height = found.height,
|
|
.pitch = found.pitch,
|
|
.format = found.format,
|
|
.refresh_hz = found.refresh_hz,
|
|
};
|
|
}
|
|
|
|
pub fn info(self: *const Gop) Info {
|
|
return .{ .width = self.width, .height = self.height, .pitch = self.pitch, .format = self.format, .refresh_hz = self.refresh_hz };
|
|
}
|
|
|
|
/// 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 each 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). The rows
|
|
/// are copied by `presentSpan` below, which widens the stores by hand: `volatile`
|
|
/// keeps the compiler from eliding or reordering framebuffer writes, but it also
|
|
/// forbids it from merging them, so a naive per-pixel loop is stuck at one 4-byte
|
|
/// store per iteration. Keeping each copy small (the damage list) and each store wide
|
|
/// shrinks the window in which scanout can sample a half-written frame.
|
|
pub fn present(self: *const Gop, damage: []const Rect) void {
|
|
const bounds = Rect{ .x = 0, .y = 0, .w = @intCast(self.width), .h = @intCast(self.height) };
|
|
for (damage) |rect| {
|
|
const c = rect.intersect(bounds);
|
|
if (c.isEmpty()) continue;
|
|
const span: usize = @intCast(c.w);
|
|
var y: i32 = c.y;
|
|
while (y < c.bottom()) : (y += 1) {
|
|
const offset = @as(usize, @intCast(y)) * self.pitch + @as(usize, @intCast(c.x)) * 4;
|
|
const source: [*]const u32 = @ptrCast(@alignCast(self.back + offset));
|
|
const front_row: [*]volatile u32 = @ptrCast(@alignCast(self.front + offset));
|
|
presentSpan(front_row, source, span);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/// Copy `count` pixels into the write-combining front buffer with 8-byte volatile stores
|
|
/// (plus a 4-byte head/tail where the span isn't 8-aligned — pixel spans are always
|
|
/// 4-aligned). The loads come from the cacheable back buffer and are assembled into a
|
|
/// `u64` in registers, so nothing here reads the front buffer.
|
|
fn presentSpan(destination: [*]volatile u32, source: [*]const u32, count: usize) void {
|
|
var i: usize = 0;
|
|
if (i < count and (@intFromPtr(destination) & 7) != 0) {
|
|
destination[0] = source[0];
|
|
i = 1;
|
|
}
|
|
while (i + 2 <= count) : (i += 2) {
|
|
const pair = @as(u64, source[i]) | (@as(u64, source[i + 1]) << 32);
|
|
const wide: *volatile u64 = @ptrCast(@alignCast(destination + i));
|
|
wide.* = pair;
|
|
}
|
|
if (i < count) destination[i] = source[i];
|
|
}
|
|
|
|
/// A display mode the native backend can switch to.
|
|
pub const Mode = scanout_protocol.Mode;
|
|
|
|
/// The native virtio-gpu backend: the compositor composes into a **shared** scanout surface
|
|
/// (a shared-memory region the driver created and handed over) and `present` asks the driver to put
|
|
/// a frame on the panel over its `.scanout` endpoint. Unlike GOP there is no local copy — the
|
|
/// surface *is* the device's resource backing, so compositing writes land straight where the
|
|
/// driver transfers-and-flushes from (x86 DMA is cache-coherent, so the cacheable shared pages
|
|
/// need no explicit flush). Built by the display service when a driver announces (V4). The
|
|
/// surface is sized to the driver's largest mode, so `stride` (its row width) is fixed while
|
|
/// `width`/`height` — the active mode — change under `setMode` (V5).
|
|
pub const VirtioGpu = struct {
|
|
pixels: [*]u32, // the shared scanout surface, mapped into the compositor
|
|
stride: u32, // the surface's row stride in pixels (the driver's max mode width) — fixed
|
|
width: u32, // the active mode
|
|
height: u32,
|
|
format: u32,
|
|
refresh_hz: u32, // from the driver's EDID read, carried in the announce (0 = unknown)
|
|
scanout: ipc.Handle, // the driver's present + mode channel (looked up on `.scanout`)
|
|
|
|
pub fn info(self: *const VirtioGpu) Info {
|
|
return .{ .width = self.width, .height = self.height, .pitch = self.stride * 4, .format = self.format, .refresh_hz = self.refresh_hz };
|
|
}
|
|
pub fn surface(self: *const VirtioGpu) Surface {
|
|
return .{ .pixels = self.pixels, .stride = self.stride, .width = self.width, .height = self.height };
|
|
}
|
|
/// Ask the driver to present. The composited pixels are already in the shared surface, so
|
|
/// this is a single request over `.scanout` regardless of how many damage rectangles
|
|
/// accumulated; the driver transfers + fenced-flushes the whole frame.
|
|
pub fn present(self: *const VirtioGpu, damage: []const Rect) void {
|
|
_ = damage;
|
|
_ = call(self.scanout, .present, .{ .width = self.width, .height = self.height });
|
|
}
|
|
/// Fill `out` with the driver's offered modes; returns how many were written.
|
|
pub fn modes(self: *const VirtioGpu, out: []Mode) usize {
|
|
const answered = call(self.scanout, .get_modes, {}) orelse return 0;
|
|
const offered = Scanout.decodeReply(.get_modes, answered.packet[0..answered.len]) orelse return 0;
|
|
const count = @min(@min(offered.count, scanout_protocol.max_modes), out.len);
|
|
for (0..count) |i| out[i] = offered.modes[i];
|
|
return count;
|
|
}
|
|
/// Change the scanout resolution. On success the active `width`/`height` update (the shared
|
|
/// surface — sized to the max mode — is unchanged, so `stride` stays put).
|
|
pub fn setMode(self: *VirtioGpu, w: u32, h: u32) bool {
|
|
if (w == 0 or h == 0 or w > self.stride) return false;
|
|
_ = call(self.scanout, .set_mode, .{ .width = w, .height = h }) orelse return false;
|
|
self.width = w;
|
|
self.height = h;
|
|
return true;
|
|
}
|
|
};
|
|
|
|
const Scanout = scanout_protocol.Protocol;
|
|
|
|
/// A reply the driver answered with, kept whole so the caller can decode the
|
|
/// verb's own fixed part out of it.
|
|
const Answered = struct {
|
|
packet: [scanout_protocol.message_maximum]u8,
|
|
len: usize,
|
|
};
|
|
|
|
/// One request at the scanout driver. Null covers both a transport failure and a
|
|
/// driver that refused — a present that did not happen is a present that did not
|
|
/// happen, and this backend has nothing to do about either but skip the frame.
|
|
fn call(
|
|
scanout: ipc.Handle,
|
|
comptime operation: Scanout.Operation,
|
|
request: Scanout.RequestOf(operation),
|
|
) ?Answered {
|
|
var packet: [scanout_protocol.message_maximum]u8 = undefined;
|
|
const framed = Scanout.encodeRequest(operation, 0, request, &.{}, &packet) orelse return null;
|
|
var answered: Answered = .{ .packet = undefined, .len = 0 };
|
|
answered.len = ipc.call(scanout, framed, &answered.packet) catch return null;
|
|
const status = envelope.statusOf(answered.packet[0..answered.len]) orelse return null;
|
|
if (status.status != 0) return null;
|
|
return answered;
|
|
}
|
|
|
|
/// The pluggable scanout backend. A tagged union so the compositor holds one value and
|
|
/// dispatches without caring which is active; the `virtio` native backend joins `gop` at V4.
|
|
pub const Backend = union(enum) {
|
|
gop: Gop,
|
|
virtio: VirtioGpu,
|
|
|
|
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: []const Rect) void {
|
|
switch (self.*) {
|
|
inline else => |*b| b.present(damage),
|
|
}
|
|
}
|
|
/// The modes this backend can switch to (none for GOP); returns how many were written.
|
|
pub fn modes(self: *const Backend, out: []Mode) usize {
|
|
return switch (self.*) {
|
|
.virtio => |*v| v.modes(out),
|
|
.gop => 0,
|
|
};
|
|
}
|
|
/// Change the resolution; false if this backend can't mode-set or the mode was refused.
|
|
pub fn setMode(self: *Backend, w: u32, h: u32) bool {
|
|
return switch (self.*) {
|
|
.virtio => |*v| v.setMode(w, h),
|
|
.gop => false,
|
|
};
|
|
}
|
|
/// Whether this backend supports runtime mode-setting (GOP: no; virtio-gpu: yes, V5).
|
|
pub fn canModeSet(self: *const Backend) bool {
|
|
return switch (self.*) {
|
|
.gop => false,
|
|
.virtio => true,
|
|
};
|
|
}
|
|
/// Whether this backend's present is **fenced** — it completes only once the device has
|
|
/// consumed the frame (virtio-gpu: every flush carries a fence the used-ring ack waits on).
|
|
/// A fence gives completion feedback and tear-free snapshot presents; it is *not* vblank —
|
|
/// nothing paces presents to the display's refresh (base virtio-gpu 2D has no vblank event
|
|
/// at all). True vsync needs a native driver's vblank interrupt. See docs/display-v2.md,
|
|
/// "Fenced is not vsync".
|
|
pub fn hasFencedPresent(self: *const Backend) bool {
|
|
return switch (self.*) {
|
|
.gop => false,
|
|
.virtio => true,
|
|
};
|
|
}
|
|
};
|
|
|
|
/// 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));
|
|
}
|