display: pace the frame clock by the panel's EDID refresh rate

Both EDID moments the system has are now captured and carried to the
compositor's frame clock:

- EFI: the loader derives refresh from the preferred detailed timing
  (pixel clock / total pixels) while GOP is still alive — the only moment
  it is readable — and hands it through the boot handoff into the
  display0 node's DisplayInfo (new refresh_hz field, 0 = unknown).
- GPU: the virtio-gpu driver derives the same figure from its own EDID
  read and carries it in the attach_scanout announce (request.y).

updateFrameClock() re-derives the interval from the active backend's
info at bring-up and again on every backend change — the boot
framebuffer's clock dies with the GOP floor at upgrade, replaced by the
GPU's rate. Unknown rate defaults to 60 Hz; the result is clamped to
[30, 120] Hz so a mis-parsed EDID can neither starve nor flood the
compositor. Rate only, never phase: without vblank, presents still
free-run (docs/display-v2.md, 'Fenced is not vsync').

Observed in QEMU: OVMF exposes no EDID for the VGA adapter, so the GOP
floor logs 'frame clock 62 Hz (default)' (real firmware does expose it);
the virtio-gpu EDID advertises 75 Hz and the upgrade logs 'frame clock
76 Hz (panel EDID)'. The display kernel test asserts refresh_hz rides
the seeded node; all 7 display QEMU cases pass.
This commit is contained in:
Daniel Samson
2026-07-21 11:37:29 +01:00
parent 4f02f75602
commit ec6e888076
10 changed files with 87 additions and 25 deletions
+11 -6
View File
@@ -16,8 +16,10 @@ const scanout_protocol = runtime.scanout_protocol;
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 };
/// 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;
@@ -35,13 +37,14 @@ pub const Gop = struct {
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 };
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
@@ -52,7 +55,7 @@ pub const Gop = struct {
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 };
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;
}
@@ -93,11 +96,12 @@ pub const Gop = struct {
.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 };
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).
@@ -169,10 +173,11 @@ pub const VirtioGpu = struct {
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 };
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 };
+26 -3
View File
@@ -88,9 +88,29 @@ var damage_grid: compositor.TileGrid = .{};
/// this is the software stand-in, the same strategy Linux uses atop virtio-gpu. Bring-up
/// paths that need pixels on screen *now* (initialise, the self-checks) still call
/// `present()` directly.
const frame_interval_milliseconds = 16;
///
/// The interval comes from the *active backend's* panel refresh rate (EDID: the loader
/// captures it for the GOP floor while firmware still runs; the native driver reads its
/// own and carries it in the announce). `updateFrameClock` re-derives it whenever the
/// backend changes — the boot framebuffer's clock dies with the GOP floor at upgrade.
/// Without a rate the clock defaults to 60 Hz, and it is clamped to [30, 120] Hz so a
/// mis-parsed EDID can neither starve nor flood the compositor.
var frame_interval_milliseconds: u64 = 16;
var frame_timer_armed = false;
/// Derive the frame-clock interval from the active backend's refresh rate and log what
/// the clock is now pacing to. Called at bring-up and again on every backend change.
fn updateFrameClock() void {
const reported = backend.info().refresh_hz;
const rate: u64 = if (reported == 0) 60 else @min(@max(reported, 30), 120);
frame_interval_milliseconds = @max(1000 / rate, 1);
var line: [96]u8 = undefined;
_ = system.write(std.fmt.bufPrint(&line, "display: frame clock {d} Hz ({s})\n", .{
1000 / frame_interval_milliseconds,
if (reported == 0) "default" else "panel EDID",
}) catch return);
}
/// Arm the frame clock unless a tick is already pending: any number of requests inside
/// one interval coalesce into that single tick's present.
fn schedulePresent() void {
@@ -284,7 +304,7 @@ fn verifyNativePresent() void {
/// present channel, switch the backend to virtio-gpu, and queue a full-screen repaint. The
/// present is deferred to a timer (see `service_endpoint`) so it happens after this reply
/// unblocks the driver and it starts serving `.scanout`.
fn attachScanout(stride: u32, width: u32, height: u32, format: u32, capability: ?ipc.Handle, reply: []u8) usize {
fn attachScanout(stride: u32, width: u32, height: u32, format: u32, refresh_hz: u32, capability: ?ipc.Handle, reply: []u8) usize {
const cap = capability orelse return fail(reply);
if (width == 0 or height == 0 or stride < width) return fail(reply);
const mapped = runtime.shm.map(cap) orelse return fail(reply);
@@ -303,9 +323,11 @@ fn attachScanout(stride: u32, width: u32, height: u32, format: u32, capability:
.width = width,
.height = height,
.format = format,
.refresh_hz = refresh_hz,
.scanout = scanout,
} };
background = protocol.pack(format, 0x20, 0x30, 0x48); // re-pack the wallpaper for the mode
updateFrameClock(); // the GOP floor's clock dies here — pace by the GPU's EDID now
addDamage(screenRect()); // the whole new surface must be painted
pending_native_verify = true;
if (!reattach) pending_modeset_check = true; // the mode-set self-check runs once, on first upgrade
@@ -564,6 +586,7 @@ fn initialise(endpoint: ipc.Handle) bool {
_ = system.write(std.fmt.bufPrint(&line, "display: online {d}x{d} pitch {d} format {d}\n", .{
mode.width, mode.height, mode.pitch, mode.format,
}) catch "display: online\n");
updateFrameClock();
_ = system.write("display: presented frame 0\n");
selfCheck();
@@ -631,7 +654,7 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Han
return ok(reply);
},
@intFromEnum(protocol.Operation.attach_scanout) => {
return attachScanout(request.x, request.width, request.height, request.colour, capability, reply);
return attachScanout(request.x, request.width, request.height, request.colour, request.y, capability, reply);
},
@intFromEnum(protocol.Operation.set_mode) => {
if (!backend.setMode(request.width, request.height)) return fail(reply);
+7 -5
View File
@@ -24,11 +24,13 @@ pub const Operation = enum(u32) {
damage = 6,
/// present(): composite the dirty layers and flush to the screen.
present = 7,
/// attach_scanout(x=stride, width, height, colour=format) + <surface capability>: a native
/// scanout driver announces itself, handing over the shared scanout surface as an `ipc_call`
/// send_cap. The compositor maps it, looks up the driver's `.scanout` present channel, and
/// upgrades off the GOP floor (docs/display-v2.md V4). `x` is the surface's row stride in
/// pixels, `colour` the DisplayFormat.
/// attach_scanout(x=stride, y=refresh_hz, width, height, colour=format) + <surface
/// capability>: a native scanout driver announces itself, handing over the shared scanout
/// surface as an `ipc_call` send_cap. The compositor maps it, looks up the driver's
/// `.scanout` present channel, and upgrades off the GOP floor (docs/display-v2.md V4).
/// `x` is the surface's row stride in pixels, `y` the panel refresh rate from the
/// driver's EDID read (0 = unknown; paces the compositor's frame clock), `colour` the
/// DisplayFormat.
attach_scanout = 8,
/// set_mode(width, height): change the display resolution — only a native backend that
/// reports `canModeSet` honours it; on the GOP floor it fails (docs/display-v2.md V5).