diff --git a/boot/efi.zig b/boot/efi.zig index 6114311..c3d72bb 100644 --- a/boot/efi.zig +++ b/boot/efi.zig @@ -97,7 +97,7 @@ fn boot() !noreturn { } /// A display resolution in pixels. -const Resolution = struct { width: u32, height: u32 }; +const Resolution = struct { width: u32, height: u32, refresh_hz: u32 }; /// Switch the GPU to the monitor's native resolution (when we can determine it) /// and read the resulting graphics mode into our own framebuffer description. @@ -128,6 +128,10 @@ fn queryFramebuffer(bs: *uefi.tables.BootServices) !boot_handoff.Framebuffer { // Each pixel is 32 bits, so the byte pitch is 4 * pixels-per-row. .pitch = info.pixels_per_scan_line * 4, .format = try pixelFormat(info.pixel_format), + // The refresh rate rides the EDID preferred timing. If the firmware kept a + // non-native mode it may not describe that mode exactly — but it is the panel's + // own clock, a far better frame-clock seed than a hardcoded 60 Hz. + .refresh_hz = if (native) |n| n.refresh_hz else 0, }; } @@ -176,10 +180,12 @@ fn nativeResolution(bs: *uefi.tables.BootServices, handles: []uefi.Handle) ?Reso return null; } -/// Parse the native resolution from a raw EDID block. The first Detailed Timing -/// Descriptor (at byte 54) is the preferred — i.e. native — mode by convention; -/// its active pixel counts are split across low bytes and the high nibbles of -/// later bytes. +/// Parse the native resolution and refresh rate from a raw EDID block. The first +/// Detailed Timing Descriptor (at byte 54) is the preferred — i.e. native — mode by +/// convention; its active pixel counts are split across low bytes and the high nibbles +/// of later bytes. The refresh rate is derived, not stored: the descriptor carries the +/// pixel clock (10 kHz units) and the active+blanking extents, and +/// refresh = clock / (horizontal total × vertical total). fn edidNative(edid: []const u8) ?Resolution { if (edid.len < 128) return null; // Every EDID begins with this fixed 8-byte header. @@ -193,7 +199,13 @@ fn edidNative(edid: []const u8) ?Resolution { const w = @as(u32, dtd[2]) | (@as(u32, dtd[4] & 0xf0) << 4); const h = @as(u32, dtd[5]) | (@as(u32, dtd[7] & 0xf0) << 4); if (w == 0 or h == 0) return null; - return .{ .width = w, .height = h }; + + const clock_hz = (@as(u64, dtd[0]) | (@as(u64, dtd[1]) << 8)) * 10_000; + const h_blank = @as(u64, dtd[3]) | (@as(u64, dtd[4] & 0x0f) << 8); + const v_blank = @as(u64, dtd[6]) | (@as(u64, dtd[7] & 0x0f) << 8); + const total = (@as(u64, w) + h_blank) * (@as(u64, h) + v_blank); + const refresh: u32 = if (total == 0) 0 else @intCast((clock_hz + total / 2) / total); + return .{ .width = w, .height = h, .refresh_hz = refresh }; } /// Open the kernel on the volume we booted from, read it into a pool buffer, diff --git a/system/boot-handoff.zig b/system/boot-handoff.zig index d357c2b..2c10024 100644 --- a/system/boot-handoff.zig +++ b/system/boot-handoff.zig @@ -37,6 +37,11 @@ pub const Framebuffer = extern struct { height: u32, // visible rows (e.g. 1080) pitch: u32, // bytes from the start of one row to the start of the next format: PixelFormat, + /// The panel's refresh rate in Hz, computed from its EDID preferred timing (pixel + /// clock / total pixels per frame) while GOP was still alive — the one moment it is + /// readable (docs/gop.md). 0 = unknown (no EDID). The display service paces its + /// frame clock by it; without vblank this fixes the *rate*, never the *phase*. + refresh_hz: u32 = 0, /// Whether a usable framebuffer was handed over. pub fn present(self: Framebuffer) bool { diff --git a/system/devices/device-abi.zig b/system/devices/device-abi.zig index 4c394d2..26cd6de 100644 --- a/system/devices/device-abi.zig +++ b/system/devices/device-abi.zig @@ -97,6 +97,7 @@ pub const DisplayInfo = extern struct { height: u32 = 0, // visible rows pitch: u32 = 0, // bytes from one row's start to the next format: u32 = 0, // a DisplayFormat value + refresh_hz: u32 = 0, // panel refresh rate from EDID (0 = unknown); see boot-handoff }; /// `DeviceDescriptor.parent` for a device with no parent — a root of the device tree. diff --git a/system/drivers/virtio-gpu/virtio-gpu.zig b/system/drivers/virtio-gpu/virtio-gpu.zig index bd88f3c..273702e 100644 --- a/system/drivers/virtio-gpu/virtio-gpu.zig +++ b/system/drivers/virtio-gpu/virtio-gpu.zig @@ -61,6 +61,10 @@ var fence_next: u64 = 1; /// Whether the device offered VIRTIO_GPU_F_EDID, so `get_edid` is worth issuing. var edid_available = false; +/// The panel refresh rate parsed from the EDID preferred timing (0 = unknown). Carried to +/// the compositor in the announce so its frame clock paces to the panel, not a guess. +var edid_refresh_hz: u32 = 0; + /// The control virtqueue. We drive it synchronously — one command, notify, poll the used /// ring — so a depth of 16 is ample; we ask the device to shrink to it (virtio 1.0 lets the /// driver reduce queue_size), keeping the whole ring inside one page. @@ -468,10 +472,18 @@ fn readEdid() void { } // The first detailed timing descriptor (EDID base-block offset 54) is the preferred mode: // active pixels are 12-bit, low byte + high nibble (bytes 2/4 horizontal, 5/7 vertical). + // The refresh rate is derived from the same descriptor: pixel clock (bytes 0-1, 10 kHz + // units) over total (active + blanking) pixels per frame — the loader does the identical + // computation for the boot framebuffer (boot/efi.zig edidNative). const e = &response.edid; const h_active = @as(u32, e[56]) | (@as(u32, e[58] & 0xF0) << 4); const v_active = @as(u32, e[59]) | (@as(u32, e[61] & 0xF0) << 4); - log("virtio-gpu: EDID preferred mode {d}x{d}\n", .{ h_active, v_active }); + const clock_hz = (@as(u64, e[54]) | (@as(u64, e[55]) << 8)) * 10_000; + const h_blank = @as(u64, e[57]) | (@as(u64, e[58] & 0x0F) << 8); + const v_blank = @as(u64, e[60]) | (@as(u64, e[61] & 0x0F) << 8); + const total = (@as(u64, h_active) + h_blank) * (@as(u64, v_active) + v_blank); + if (total != 0) edid_refresh_hz = @intCast((clock_hz + total / 2) / total); + log("virtio-gpu: EDID preferred mode {d}x{d} @ {d} Hz\n", .{ h_active, v_active, edid_refresh_hz }); } /// Present the whole surface: copy the guest backing into the host resource, then flush it to @@ -550,6 +562,7 @@ fn announce() void { var request = dp.Request{ .operation = @intFromEnum(dp.Operation.attach_scanout), .x = max_width, // the shared surface's row stride in pixels (it is sized to the max mode) + .y = edid_refresh_hz, // the panel refresh from EDID (0 = unknown) — the frame-clock seed .width = current_width, .height = current_height, .colour = display_format_bgrx, diff --git a/system/kernel/devices-broker.zig b/system/kernel/devices-broker.zig index ab15cfe..cec0008 100644 --- a/system/kernel/devices-broker.zig +++ b/system/kernel/devices-broker.zig @@ -61,7 +61,7 @@ pub fn init(device_tree: *const platform.DeviceTree) void { /// [[boot-handoff]], not the device tree), so it is seeded explicitly, after `init`. /// Returns the new device id, or null when there is no framebuffer (headless) or the /// table is full. Idempotent-ish: only ever call once per boot. -pub fn seedDisplay(base: u64, width: u32, height: u32, pitch: u32, format: u32) ?u64 { +pub fn seedDisplay(base: u64, width: u32, height: u32, pitch: u32, format: u32, refresh_hz: u32) ?u64 { if (base == 0 or width == 0 or height == 0) return null; // headless if (count >= maximum_devices) { dropped += 1; @@ -79,7 +79,7 @@ pub fn seedDisplay(base: u64, width: u32, height: u32, pitch: u32, format: u32) .len = @as(u64, height) * pitch, .flags = device_abi.resource_flag_write_combining, }; - d.display = .{ .width = width, .height = height, .pitch = pitch, .format = format }; + d.display = .{ .width = width, .height = height, .pitch = pitch, .format = format, .refresh_hz = refresh_hz }; devices[count] = d; display_device = d.id; count += 1; diff --git a/system/kernel/kernel.zig b/system/kernel/kernel.zig index cd0b460..fcc7aec 100644 --- a/system/kernel/kernel.zig +++ b/system/kernel/kernel.zig @@ -200,8 +200,8 @@ fn kmain(boot_information: *const BootInformation) noreturn { // Publish the loader's framebuffer as a claimable `display` device, so a // user-space display service can take it over the same claim + mmio_map path as // any other hardware (it is not firmware-discovered; it rides the boot handoff). - if (devices_broker.seedDisplay(fb.base, fb.width, fb.height, fb.pitch, @intFromEnum(fb.format))) |display_id| { - log.print("/system/kernel: framebuffer device {d} seeded ({d}x{d}, pitch {d}, write-combining)\n", .{ display_id, fb.width, fb.height, fb.pitch }); + if (devices_broker.seedDisplay(fb.base, fb.width, fb.height, fb.pitch, @intFromEnum(fb.format), fb.refresh_hz)) |display_id| { + log.print("/system/kernel: framebuffer device {d} seeded ({d}x{d}, pitch {d}, {d} Hz, write-combining)\n", .{ display_id, fb.width, fb.height, fb.pitch, fb.refresh_hz }); } // Install the device-IRQ trampolines, so a driver's irq_bind has vectors to diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index d531977..c27f154 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -3404,6 +3404,7 @@ fn displayTest(boot_information: *const BootInformation) void { check("the node is class display", d.class == @intFromEnum(device_abi.DeviceClass.display)); check("it carries the framebuffer geometry", d.display.width == fb.width and d.display.height == fb.height and d.display.pitch == fb.pitch); + check("it carries the panel refresh rate", d.display.refresh_hz == fb.refresh_hz); check("it has exactly one resource", d.resource_count == 1); const r = d.resources[0]; check("that resource is a memory window", r.kind == @intFromEnum(device_abi.ResourceKind.memory)); diff --git a/system/services/display/backend.zig b/system/services/display/backend.zig index 78c9d22..b2cb924 100644 --- a/system/services/display/backend.zig +++ b/system/services/display/backend.zig @@ -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 }; diff --git a/system/services/display/display.zig b/system/services/display/display.zig index be8beae..3a5639d 100644 --- a/system/services/display/display.zig +++ b/system/services/display/display.zig @@ -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); diff --git a/system/services/display/protocol.zig b/system/services/display/protocol.zig index 4098106..11ead14 100644 --- a/system/services/display/protocol.zig +++ b/system/services/display/protocol.zig @@ -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) + : 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) + : 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).