danos/system/services/display/backend.zig

311 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 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;
var request = scanout_protocol.Request{
.operation = @intFromEnum(scanout_protocol.Operation.present),
.width = self.width,
.height = self.height,
};
var reply: [scanout_protocol.reply_size]u8 = undefined;
_ = ipc.call(self.scanout, std.mem.asBytes(&request), &reply) catch {};
}
/// Fill `out` with the driver's offered modes; returns how many were written.
pub fn modes(self: *const VirtioGpu, out: []Mode) usize {
var request = scanout_protocol.Request{ .operation = @intFromEnum(scanout_protocol.Operation.get_modes) };
var reply: [scanout_protocol.modes_reply_size]u8 = undefined;
const n = ipc.call(self.scanout, std.mem.asBytes(&request), &reply) catch return 0;
if (n < scanout_protocol.modes_reply_size) return 0;
const answer = std.mem.bytesToValue(scanout_protocol.ModesReply, reply[0..scanout_protocol.modes_reply_size]);
if (answer.status != 0) return 0;
const count = @min(@min(answer.count, scanout_protocol.max_modes), out.len);
for (0..count) |i| out[i] = answer.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;
var request = scanout_protocol.Request{
.operation = @intFromEnum(scanout_protocol.Operation.set_mode),
.width = w,
.height = h,
};
var reply: [scanout_protocol.reply_size]u8 = undefined;
const n = ipc.call(self.scanout, std.mem.asBytes(&request), &reply) catch return false;
if (n < scanout_protocol.reply_size) return false;
if (std.mem.bytesToValue(scanout_protocol.Reply, reply[0..scanout_protocol.reply_size]).status != 0) return false;
self.width = w;
self.height = h;
return true;
}
};
/// 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));
}