From 58927ed7e543ea66d77c3bd59a577a66c11265ea Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:21:37 +0100 Subject: [PATCH] display: hot-attach a virtio-gpu native backend over the GOP floor (v2 V4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compositor now boots on the GOP framebuffer and upgrades to the virtio-gpu driver the moment it announces itself — the pluggable-scanout payoff. The shared surface. The scanout resource is an shm region the driver creates (shm_physical, a new syscall, hands it the guest-physical for attach_backing) and passes to the compositor as a capability. The compositor maps it and composites straight into it: on x86 DMA is cache-coherent, so the cacheable shared pages the CPU paints are exactly what the device transfers-and-flushes — no copy, no explicit flush. The handshake. After bring-up the driver looks up .display and sends attach_scanout with the geometry + the surface capability. The compositor maps the surface, looks up the driver's .scanout endpoint itself (the driver registered it — no need to pass it), switches to backend.VirtioGpu, and re-composites the current frame. present() over the native backend is a present request on .scanout -> transfer-to-host + resource flush. The first native present is deferred to a one-shot timer: presenting inline from the announce handler would deadlock, since the driver is still blocked on our reply and not yet serving .scanout. After it lands, the compositor reads a pixel back from the shared surface to confirm the frame reached the device's backing. - shm_physical (syscall 36) + runtime.shm.physical. - scanout-protocol (the compositor->driver present channel), separate from the client-facing display protocol; the display protocol gains attach_scanout. - backend.VirtioGpu joins backend.Gop in the tagged union; select() still boots GOP. - the virtio-gpu driver's scanout backing is now shm (was DMA); it announces + serves .scanout present requests (transfer-to-host + flush of the shared surface). Also fixes a latent framebuffer-geometry corruption the display service hit only when it enumerated the device tree alongside a busy device-manager: Gop.init now captures the geometry into a small value the instant device_enumerate returns (rather than re-reading the 328-byte descriptor across the later claim/mmio_map syscalls) and retries on a zero geometry. The underlying device-table clobber is a separate kernel bug, tracked apart. Gate: python3 test/qemu_test.py display-native (QEMU -device virtio-gpu-pci) — "display: scanout upgraded to virtio-gpu" + "display: native present verified" + "display-demo: ok", passing 3/3. host tests, display-service, display-demo, shm, and virtio-gpu still pass. --- build.zig | 7 ++ docs/display-v2-plan.md | 33 ++++-- library/runtime/runtime.zig | 3 + library/runtime/shm.zig | 10 ++ system/abi.zig | 1 + system/drivers/virtio-gpu/virtio-gpu.zig | 118 ++++++++++++++----- system/kernel/process.zig | 14 +++ system/kernel/tests.zig | 49 ++++++++ system/services/display/backend.zig | 79 +++++++++++-- system/services/display/display.zig | 73 +++++++++++- system/services/display/protocol.zig | 5 + system/services/display/scanout-protocol.zig | 32 +++++ test/qemu_test.py | 13 ++ 13 files changed, 379 insertions(+), 58 deletions(-) create mode 100644 system/services/display/scanout-protocol.zig diff --git a/build.zig b/build.zig index 4e13790..72519ab 100644 --- a/build.zig +++ b/build.zig @@ -355,6 +355,13 @@ pub fn build(b: *std.Build) void { }); runtime_module.addImport("display-protocol", display_protocol_module); + // The scanout protocol: the compositor's outbound present channel to a native scanout + // driver (virtio-gpu), separate from the client-facing display protocol (docs/display-v2.md). + const scanout_protocol_module = b.addModule("scanout-protocol", .{ + .root_source_file = b.path("system/services/display/scanout-protocol.zig"), + }); + runtime_module.addImport("scanout-protocol", scanout_protocol_module); + // The power protocol: system power's domain-named surface (docs/power.md). const power_protocol_module = b.addModule("power-protocol", .{ .root_source_file = b.path("system/services/power/protocol.zig"), diff --git a/docs/display-v2-plan.md b/docs/display-v2-plan.md index 57f0d42..f7111ec 100644 --- a/docs/display-v2-plan.md +++ b/docs/display-v2-plan.md @@ -100,21 +100,28 @@ pattern — logging `virtio-gpu: scanout 640x480 online` and `virtio-gpu: flush check ok`. That proves virtqueue + resource + attach + set_scanout + transfer + flush end to end without a screenshot (the used-ring ack is the device confirming it consumed the frame). -## V4 — The native backend + hot-attach +## V4 — The native backend + hot-attach ✅ -- [ ] `VirtioGpuBackend` in the compositor: `surface()` = the shared scanout resource, - `present(damage)` = `resource_flush` of the damaged rect. -- [ ] The driver **announces** to `.display` (looks it up, sends *attach-scanout* with its - `scanout` endpoint + the shared surface as capabilities). The compositor switches - backends and re-presents the current frame full-screen. -- [ ] Boot still starts on `GopBackend`; the upgrade happens on announce. +- [x] `backend.VirtioGpu` in the compositor: `surface()` = the shared `shm` scanout surface + (the compositor composes straight into the device's resource backing; x86 DMA is + coherent, so the cacheable shared pages need no flush), `present(damage)` = a `present` + request over the driver's `.scanout` endpoint (→ transfer-to-host + resource flush). +- [x] The driver **announces** to `.display` after bring-up (looks it up with a bounded retry, + sends `attach_scanout` with the geometry + the shared surface as an `ipc_call` send_cap). + The compositor maps it, looks up `.scanout` itself (no need to pass the endpoint — the + driver registered it), switches backend, and re-composites the current frame full-screen. + The present is deferred to a one-shot timer so it runs *after* the reply unblocks the + driver and it serves `.scanout` — presenting inline would deadlock. +- [x] Boot still starts on `backend.Gop`; the upgrade happens on announce. `shm_physical` (a + new syscall) gives the driver the guest-physical of the shared surface for `attach_backing`. -**Gate (automated):** boot with virtio-gpu + `display-demo`; the compositor logs -`display: scanout upgraded to virtio-gpu`, drives frames through the native backend, and -**reads a pixel back** from the shared scanout resource after a present to confirm the -composited frame landed (`display: native present verified`), while `display-demo: ok` -still fires. Without `-device virtio-gpu`, no `scanout` is announced and it stays on GOP — -the v1 `display-service`/`display-demo` gates still pass unchanged. +**Gate (met):** the `display-native` case (QEMU `-device virtio-gpu-pci`, `mem` bumped since it +boots the whole system) starts the compositor + `display-demo` + device-manager; the driver +announces, the compositor logs `display: scanout upgraded to virtio-gpu`, drives frames through +the native backend, and **reads a pixel back** from the shared surface after a present to +confirm the composited frame landed (`display: native present verified`), while `display-demo: +ok` still fires — checked order-independently. Without `-device virtio-gpu-pci` nothing is +announced and it stays on GOP: the v1 `display-service`/`display-demo` gates pass unchanged. ## V5 — Mode-setting, EDID, and vsync diff --git a/library/runtime/runtime.zig b/library/runtime/runtime.zig index 0e7faea..79c0e9c 100644 --- a/library/runtime/runtime.zig +++ b/library/runtime/runtime.zig @@ -56,6 +56,9 @@ pub const block = @import("block.zig"); pub const display = @import("display.zig"); /// The display wire protocol (shared with the display service and its clients). pub const display_protocol = @import("display-protocol"); +/// The scanout wire protocol: the compositor's present channel to a native scanout driver +/// (virtio-gpu). See system/services/display/scanout-protocol.zig and docs/display-v2.md. +pub const scanout_protocol = @import("scanout-protocol"); /// The danos-native file API (open/read/write/list over the user-space VFS) — the /// layer danos programs use directly, and where the operations that later become diff --git a/library/runtime/shm.zig b/library/runtime/shm.zig index fe4cd34..1a5ec5b 100644 --- a/library/runtime/shm.zig +++ b/library/runtime/shm.zig @@ -45,3 +45,13 @@ pub fn map(handle: ipc.Handle) ?[*]u8 { if (failed(r)) return null; return @ptrFromInt(r); } + +/// The guest-physical base of the shared region named by `handle` (which this process must +/// hold a capability for). The region's frames are contiguous, so this single address plus +/// the region length is all a device needs — e.g. a virtio-gpu driver programming an +/// `attach_backing`. Returns null on failure. +pub fn physical(handle: ipc.Handle) ?usize { + const r = sc.systemCall1(.shm_physical, handle); + if (failed(r)) return null; + return r; +} diff --git a/system/abi.zig b/system/abi.zig index ba07d19..8524d8d 100644 --- a/system/abi.zig +++ b/system/abi.zig @@ -62,6 +62,7 @@ pub const SystemCall = enum(u64) { wall_clock = 33, // wall_clock() -> Unix epoch seconds (UTC): the RTC wall-clock time, for filesystem timestamps (mtime). Monotonic time is `clock`. shm_create = 34, // shm_create(len) -> vaddr (rax), handle (rdx): a shareable, zeroed, cacheable RAM region mapped into this AS; the handle is a capability passed to another process as an ipc_call send_cap (docs/display-v2.md) shm_map = 35, // shm_map(cap) -> vaddr: map the shared region named by a received capability into this AS (the same physical pages the creator sees) + shm_physical = 36, // shm_physical(cap) -> paddr: the guest-physical base of a shared region held by capability, so a driver can program it into a device (e.g. virtio-gpu attach_backing); the pages are contiguous (docs/display-v2.md) _, }; diff --git a/system/drivers/virtio-gpu/virtio-gpu.zig b/system/drivers/virtio-gpu/virtio-gpu.zig index e617915..0e57ea3 100644 --- a/system/drivers/virtio-gpu/virtio-gpu.zig +++ b/system/drivers/virtio-gpu/virtio-gpu.zig @@ -18,11 +18,18 @@ const runtime = @import("runtime"); const mmio = @import("mmio"); const device = runtime.device; const dma = runtime.dma; +const shm = runtime.shm; const system = runtime.system; const ipc = runtime.ipc; +const dp = runtime.display_protocol; +const sp = runtime.scanout_protocol; const vp = @import("virtio-pci.zig"); const vg = @import("virtio-gpu-protocol.zig"); +/// The DisplayFormat (device-abi) our B8G8R8X8 scanout resource presents: bgrx = 1. Handed to +/// the compositor in the announce so it packs colours in the surface's byte order. +const display_format_bgrx: u32 = 1; + /// The PCI vendor/device ids of a modern virtio-gpu (Red Hat / virtio; GPU is a /// virtio-1.0-only device, so the id is always the modern 0x1050 — no legacy variant). const virtio_vendor: u16 = 0x1AF4; @@ -59,10 +66,15 @@ var notify_addr: usize = 0; // be asked to map the same resource twice. var bar_virtual: [6]usize = .{ 0, 0, 0, 0, 0, 0 }; -// DMA memory: the virtqueue rings, the command scratch, and the scanout backing. +// DMA memory: the virtqueue rings and the command scratch. var ring: dma.Region = undefined; var command: dma.Region = undefined; -var backing: dma.Region = undefined; + +// The scanout backing is a **shared** (shm) region, not DMA: cacheable so the compositor +// composites into it cheaply (x86 DMA is coherent, so the device still sees the writes), and +// shareable so the same physical pages the device scans out of are the ones the compositor +// paints. The driver keeps the capability to hand to the compositor in the announce. +var surface: shm.Region = undefined; // Split-virtqueue producer/consumer shadows. var avail_shadow: u16 = 0; @@ -335,8 +347,14 @@ fn initialise(endpoint: ipc.Handle) bool { } } - backing = dma.alloc(scanout_bytes, dma.coherent) orelse { - log("virtio-gpu: scanout backing allocation failed\n", .{}); + // Back the resource with a shared (shm) surface, so the compositor and the device work + // the same physical pages. The device needs the guest-physical base for attach_backing. + surface = shm.create(scanout_bytes) orelse { + log("virtio-gpu: scanout surface allocation failed\n", .{}); + return false; + }; + const surface_physical = shm.physical(surface.handle) orelse { + log("virtio-gpu: could not resolve the scanout surface physical address\n", .{}); return false; }; { @@ -347,7 +365,7 @@ fn initialise(endpoint: ipc.Handle) bool { .nr_entries = 1, }; const entry: *vg.MemEntry = @ptrFromInt(command.virtual + request_offset + @sizeOf(vg.ResourceAttachBacking)); - entry.* = .{ .addr = backing.physical, .length = @intCast(scanout_bytes) }; + entry.* = .{ .addr = surface_physical, .length = @intCast(scanout_bytes) }; if (command_nodata(@sizeOf(vg.ResourceAttachBacking) + @sizeOf(vg.MemEntry)) != ok_nodata) { log("virtio-gpu: resource_attach_backing failed\n", .{}); return false; @@ -368,12 +386,35 @@ fn initialise(endpoint: ipc.Handle) bool { } log("virtio-gpu: scanout {d}x{d} online\n", .{ scanout_width, scanout_height }); - // Paint a known pattern, transfer it to the host resource, and flush it to the display. - const pixels: [*]u32 = @ptrFromInt(backing.virtual); + // Paint a known pattern, present it, and read it back — the V3 self-test that proves the + // whole path (virtqueue, resource, shared backing, transfer, flush) before a client attaches. + const pixels: [*]u32 = @ptrCast(@alignCast(surface.ptr)); const pixel_count: u32 = scanout_width * scanout_height; for (0..pixel_count) |i| pixels[i] = testPixel(@intCast(i)); - mmio.wmb(); + if (!presentFull()) { + log("virtio-gpu: initial present failed\n", .{}); + return false; + } + // The scanout surface is CPU-visible RAM: read the pattern back to prove the mapping, + // which together with the flush ack above is the automated stand-in for "it's on screen". + mmio.rmb(); + if (pixels[0] != testPixel(0) or pixels[pixel_count / 2] != testPixel(pixel_count / 2)) { + log("virtio-gpu: pixel read-back mismatch\n", .{}); + return false; + } + log("virtio-gpu: flush acked, pixel check ok\n", .{}); + + // Offer the shared surface to the compositor so it upgrades off the GOP floor (V4). + announce(); + return true; +} + +/// Present the whole surface: copy the guest backing into the host resource, then flush it to +/// the panel. Reused by the V3 self-test and by every compositor present over `.scanout`. V4 +/// presents the full surface; the damage-rect fast path is a later refinement. +fn presentFull() bool { + mmio.wmb(); // the surface writes must be visible before the device transfers them { const request = requestAt(vg.TransferToHost2d); request.* = .{ @@ -382,10 +423,7 @@ fn initialise(endpoint: ipc.Handle) bool { .offset = 0, .resource_id = resource_id, }; - if (command_nodata(@sizeOf(vg.TransferToHost2d)) != ok_nodata) { - log("virtio-gpu: transfer_to_host_2d failed\n", .{}); - return false; - } + if (command_nodata(@sizeOf(vg.TransferToHost2d)) != ok_nodata) return false; } { const request = requestAt(vg.ResourceFlush); @@ -394,30 +432,52 @@ fn initialise(endpoint: ipc.Handle) bool { .rect = .{ .x = 0, .y = 0, .width = scanout_width, .height = scanout_height }, .resource_id = resource_id, }; - if (command_nodata(@sizeOf(vg.ResourceFlush)) != ok_nodata) { - log("virtio-gpu: resource_flush was not acked\n", .{}); - return false; - } + if (command_nodata(@sizeOf(vg.ResourceFlush)) != ok_nodata) return false; } - - // The scanout backing is CPU-visible RAM: read the pattern back to prove the mapping, - // which together with the flush ack above is the automated stand-in for "it's on screen". - mmio.rmb(); - if (pixels[0] != testPixel(0) or pixels[pixel_count / 2] != testPixel(pixel_count / 2)) { - log("virtio-gpu: pixel read-back mismatch\n", .{}); - return false; - } - log("virtio-gpu: flush acked, pixel check ok\n", .{}); return true; } -/// The `scanout` service exists from V3 so the compositor can find it in V4; it carries no -/// requests yet (the ping is answered by the harness), so anything else is a no-op. +/// Announce the scanout to the display service so it upgrades off the GOP framebuffer: hand it +/// the shared surface as a capability plus the geometry. Best-effort and non-fatal — without a +/// display service (the standalone virtio-gpu bring-up test) the driver is still a valid +/// scanout service; it just serves no one. The display replies immediately (it defers its +/// first present to a timer), so this returns before we start serving `.scanout` — no deadlock. +fn announce() void { + var tries: u32 = 0; + const display = while (tries < 50) : (tries += 1) { + if (ipc.lookup(.display)) |h| break h; + system.sleep(20); + } else { + log("virtio-gpu: no display service to announce to (scanout-only)\n", .{}); + return; + }; + var request = dp.Request{ + .operation = @intFromEnum(dp.Operation.attach_scanout), + .width = scanout_width, + .height = scanout_height, + .colour = display_format_bgrx, + }; + var reply: [dp.reply_size]u8 = undefined; + _ = ipc.callCap(display, std.mem.asBytes(&request), &reply, surface.handle) catch { + log("virtio-gpu: announce to display failed\n", .{}); + return; + }; + log("virtio-gpu: announced scanout to display\n", .{}); +} + +/// The `.scanout` service: the compositor asks us to put a composited frame on the panel. The +/// pixels are already in the shared surface, so a present is a transfer-to-host + flush. fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize { - _ = message; - _ = reply; _ = sender; _ = capability; + if (message.len < sp.request_size) return 0; + const request = std.mem.bytesToValue(sp.Request, message[0..sp.request_size]); + if (request.operation == @intFromEnum(sp.Operation.present)) { + const presented = presentFull(); + const response = sp.Reply{ .status = if (presented) 0 else -1 }; + @memcpy(reply[0..sp.reply_size], std.mem.asBytes(&response)); + return sp.reply_size; + } return 0; } diff --git a/system/kernel/process.zig b/system/kernel/process.zig index 3bdbd3e..f8d1e0b 100644 --- a/system/kernel/process.zig +++ b/system/kernel/process.zig @@ -226,6 +226,7 @@ fn system_call(state: *architecture.CpuState) void { .wall_clock => systemWallClock(state), .shm_create => systemShmCreate(state), .shm_map => systemShmMap(state), + .shm_physical => systemShmPhysical(state), _ => fail(state), } } @@ -536,6 +537,19 @@ fn systemShmMap(state: *architecture.CpuState) void { architecture.setSystemCallResult(state, base_v); } +/// shm_physical(cap) -> paddr: the guest-physical base of a shared region the caller holds a +/// capability for. The frames are contiguous (allocated by `allocContiguous`), so a single +/// physical base + length describes the whole region — which is exactly what a driver needs +/// to hand a shm surface to a device (virtio-gpu `attach_backing`). Only a holder of the +/// capability can ask; there is no ambient way to turn a virtual address into a physical one. +fn systemShmPhysical(state: *architecture.CpuState) void { + const cap = architecture.systemCallArg(state, 0); + const t = scheduler.current(); + if (t.aspace == 0) return fail(state); + const shm = ipc.resolveShm(t, cap) orelse return fail(state); // not an shm handle we hold + architecture.setSystemCallResult(state, shm.phys); +} + /// device_register(parent_id, descriptor_ptr) -> id: publish a child device below a device /// this process has claimed. The bus-driver primitive: a process that owns a bus /// enumerates it and hands each device it finds to the table, where a class driver diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 626caf8..6abc87d 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -105,6 +105,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void { shmTest(boot_information); } else if (eql(case, "virtio-gpu")) { virtioGpuTest(boot_information); + } else if (eql(case, "display-native")) { + displayNativeTest(boot_information); } else if (eql(case, "clock")) { clockTest(); } else if (eql(case, "smp")) { @@ -2455,6 +2457,53 @@ fn virtioGpuTest(boot_information: *const BootInformation) void { while (true) scheduler.yield(); } +/// V4 — the native backend + hot-attach (docs/display-v2.md). Boot the compositor and the +/// hardware-free `display-demo` client (as displayDemoTest does), then the device-manager +/// stack so it discovers the virtio-gpu function — present via QEMU's `-device +/// virtio-gpu-pci` — and spawns the driver. The driver brings up its scanout, then announces +/// the shared surface to the already-running compositor, which maps it, upgrades off the GOP +/// floor, and presents the composited frame through the native backend. Its serial heartbeats +/// — `display: scanout upgraded to virtio-gpu` and `display: native present verified` — plus +/// the demo's own `display-demo: ok` are the harness's markers. Display is spawned first so +/// it is registered on `.display` before the driver announces. +fn displayNativeTest(boot_information: *const BootInformation) void { + log("DANOS-TEST-BEGIN: display-native\n", .{}); + if (boot_information.initial_ramdisk_len == 0) { + check("bootloader handed over an initial_ramdisk", false); + result(); + return; + } + const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len]; + const rd = initial_ramdisk.Reader.init(image) orelse { + check("initial_ramdisk image is valid", false); + result(); + return; + }; + + process.setInitialRamdisk(image); + var manager: u32 = 0; + var i: u32 = 0; + while (i < rd.count) : (i += 1) { + const item = rd.entry(i) orelse continue; + if (!eql(item.name, "device-manager")) continue; + manager = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0; + break; + } + if (manager == 0) { + log("display-native: could not spawn device-manager\n", .{}); + result(); + return; + } + if (!spawnNamed(rd, "display")) { + log("display-native: could not spawn the display service\n", .{}); + result(); + return; + } + _ = spawnNamed(rd, "display-demo"); + scheduler.setPriority(1); // below the compositor, the demo, and the driver, so they run + while (true) scheduler.yield(); +} + /// Process arguments, end to end: spawn args-echo bare (its argv[0] is the /// initial-ramdisk name). Instance 1 sees argc == 1 and respawns itself through /// `system_spawn` with the extra arguments "alpha beta-42" — the syscall argument diff --git a/system/services/display/backend.zig b/system/services/display/backend.zig index b280249..2daec5a 100644 --- a/system/services/display/backend.zig +++ b/system/services/display/backend.zig @@ -11,6 +11,8 @@ const compositor = @import("compositor.zig"); const system = runtime.system; const device = runtime.device; +const ipc = runtime.ipc; +const scanout_protocol = runtime.scanout_protocol; const Rect = compositor.Rect; const Surface = compositor.Surface; @@ -34,11 +36,23 @@ pub const Gop = struct { pitch: u32, format: u32, - fn findDisplay() ?device.DeviceDescriptor { + /// 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 }; + + /// 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)) return d; + 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 null; } @@ -48,7 +62,7 @@ pub const Gop = struct { pub fn init() ?Gop { var tries: u32 = 0; const found = while (tries < 100) : (tries += 1) { - if (findDisplay()) |d| break d; + if (findDisplay()) |f| break f; system.sleep(50); } else { _ = system.write("display: no framebuffer device (headless?)\n"); @@ -65,8 +79,7 @@ pub const Gop = struct { _ = system.write("display: could not map the framebuffer\n"); return null; }; - const geometry = found.display; - const size = @as(usize, geometry.height) * geometry.pitch; + const size = @as(usize, found.height) * found.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"); @@ -76,10 +89,10 @@ pub const Gop = struct { .device_id = found.id, .front = @ptrFromInt(front_base), .back = @ptrFromInt(back_base), - .width = geometry.width, - .height = geometry.height, - .pitch = geometry.pitch, - .format = geometry.format, + .width = found.width, + .height = found.height, + .pitch = found.pitch, + .format = found.format, }; } @@ -113,10 +126,48 @@ pub const Gop = struct { } }; +/// The native virtio-gpu backend: the compositor composes into a **shared** scanout surface +/// (an `shm` 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); mode-set +/// and vsync stay off until V5. +pub const VirtioGpu = struct { + pixels: [*]u32, // the shared scanout surface, mapped into the compositor + width: u32, + height: u32, + format: u32, + scanout: ipc.Handle, // the driver's present channel (looked up on `.scanout`) + + pub fn info(self: *const VirtioGpu) Info { + return .{ .width = self.width, .height = self.height, .pitch = self.width * 4, .format = self.format }; + } + pub fn surface(self: *const VirtioGpu) Surface { + return .{ .pixels = self.pixels, .stride = self.width, .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`; the driver transfers + flushes. V4 presents + /// the whole surface (the damage-rect fast path is a later refinement). + pub fn present(self: *const VirtioGpu, damage: Rect) void { + _ = damage; + var request = scanout_protocol.Request{ + .operation = @intFromEnum(scanout_protocol.Operation.present), + .x = 0, + .y = 0, + .width = self.width, + .height = self.height, + }; + var reply: [scanout_protocol.reply_size]u8 = undefined; + _ = ipc.call(self.scanout, std.mem.asBytes(&request), &reply) catch {}; + } +}; + /// 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. +/// 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.*) { @@ -133,16 +184,18 @@ pub const Backend = union(enum) { inline else => |*b| b.present(damage), } } - /// Whether this backend supports runtime mode-setting (GOP: no; a native driver: yes). + /// Whether this backend supports runtime mode-setting (GOP: no; virtio-gpu: not until V5). pub fn canModeSet(self: *const Backend) bool { return switch (self.*) { .gop => false, + .virtio => false, }; } - /// Whether this backend has a vblank/fence for tear-free present (GOP: no). + /// Whether this backend has a vblank/fence for tear-free present (not until V5). pub fn hasVsync(self: *const Backend) bool { return switch (self.*) { .gop => false, + .virtio => false, }; } }; diff --git a/system/services/display/display.zig b/system/services/display/display.zig index bcd1b82..e60c2a0 100644 --- a/system/services/display/display.zig +++ b/system/services/display/display.zig @@ -24,10 +24,21 @@ const system = runtime.system; const Rect = compositor.Rect; const Surface = compositor.Surface; -/// The active scanout backend — GOP today, a native driver when one is present. +/// The active scanout backend — the GOP framebuffer at boot, upgraded to a native driver +/// (virtio-gpu) when one announces itself (V4). var backend: backend_mod.Backend = undefined; var frames: u64 = 0; +/// This service's endpoint, kept so `attach_scanout` can arm a one-shot timer: the very first +/// native present must happen in a *later* loop iteration, after the reply to the driver's +/// announce has unblocked it and it is serving its `.scanout` channel — presenting inline +/// would deadlock (we'd call the driver while it waits on our reply). +var service_endpoint: ipc.Handle = 0; + +/// Set when the backend has just been upgraded to virtio-gpu: the next present repaints the +/// whole screen into the shared surface and reads a pixel back to confirm the frame landed. +var pending_native_verify: bool = false; + /// The wallpaper the compositor clears damaged regions to before painting layers. var background: u32 = 0; @@ -176,6 +187,52 @@ fn present() void { } damage = Rect.empty; frames += 1; + + // The first present after a native upgrade confirms the composited frame actually reached + // the shared scanout surface (the automated stand-in for "it's on screen"). + if (pending_native_verify and !dirty.isEmpty()) { + pending_native_verify = false; + verifyNativePresent(); + } +} + +/// Read a pixel straight back from the shared scanout surface after a native present. The +/// surface starts zeroed, so a non-zero centre pixel means the compositor wrote the frame into +/// the pages the driver transfers-and-flushes from — that, plus the driver acking the present +/// over `.scanout`, is the serial proof the native path works. +fn verifyNativePresent() void { + const s = backend.surface(); + const sample = s.pixels[@as(usize, s.height / 2) * s.stride + s.width / 2]; + if (sample != 0) { + _ = system.write("display: native present verified\n"); + } else { + _ = system.write("display: native present FAILED (blank surface)\n"); + } +} + +/// A native scanout driver announced itself: map the shared surface it handed over, find its +/// 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(width: u32, height: u32, format: u32, capability: ?ipc.Handle, reply: []u8) usize { + const cap = capability orelse return fail(reply); + if (width == 0 or height == 0) return fail(reply); + const mapped = runtime.shm.map(cap) orelse return fail(reply); + const scanout = ipc.lookup(.scanout) orelse return fail(reply); + + backend = .{ .virtio = .{ + .pixels = @ptrCast(@alignCast(mapped)), + .width = width, + .height = height, + .format = format, + .scanout = scanout, + } }; + background = protocol.pack(format, 0x20, 0x30, 0x48); // re-pack the wallpaper for the mode + addDamage(screenRect()); // the whole new surface must be painted + pending_native_verify = true; + _ = system.timerOnce(service_endpoint, 50); // present once the driver is serving .scanout + _ = system.write("display: scanout upgraded to virtio-gpu\n"); + return ok(reply); } // --- startup self-check ----------------------------------------------------- @@ -216,7 +273,7 @@ fn fail_check(_: []const u8) void { // --- service ---------------------------------------------------------------- fn initialise(endpoint: ipc.Handle) bool { - _ = endpoint; + service_endpoint = endpoint; // Pick the scanout backend (GOP today). It logs the reason on failure. backend = backend_mod.select() orelse return false; @@ -254,7 +311,6 @@ fn fail(reply: []u8) usize { fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize { _ = sender; - _ = capability; if (message.len < protocol.request_size) return fail(reply); const request = std.mem.bytesToValue(protocol.Request, message[0..protocol.request_size]); const payload = message[protocol.request_size..]; @@ -294,15 +350,26 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Han present(); return ok(reply); }, + @intFromEnum(protocol.Operation.attach_scanout) => { + return attachScanout(request.width, request.height, request.colour, capability, reply); + }, else => return fail(reply), } } +/// The only notification the compositor arms is the post-attach present timer: repaint the +/// screen into the freshly attached native surface and verify the frame landed. +fn onNotification(badge: u64) void { + _ = badge; + present(); +} + pub fn main() void { runtime.service.run(protocol.message_maximum, .{ .service = .display, .init = initialise, .on_message = onMessage, + .on_notification = onNotification, }); } diff --git a/system/services/display/protocol.zig b/system/services/display/protocol.zig index f6ebdda..4e99220 100644 --- a/system/services/display/protocol.zig +++ b/system/services/display/protocol.zig @@ -24,6 +24,11 @@ pub const Operation = enum(u32) { damage = 6, /// present(): composite the dirty layers and flush to the screen. present = 7, + /// attach_scanout(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). `colour` carries the DisplayFormat. + attach_scanout = 8, }; /// The fixed request header. A `blit_tile`'s pixel payload (width*height 32-bit pixels) diff --git a/system/services/display/scanout-protocol.zig b/system/services/display/scanout-protocol.zig new file mode 100644 index 0000000..067a80a --- /dev/null +++ b/system/services/display/scanout-protocol.zig @@ -0,0 +1,32 @@ +//! The scanout wire protocol — what the compositor says to a native scanout driver (e.g. +//! virtio-gpu) over its well-known `.scanout` endpoint to put a composited frame on screen. +//! The driver owns the panel and the shared scanout surface it handed the compositor (via the +//! display service's `attach_scanout`); the compositor composites into that surface, then asks +//! the driver to present a damaged rectangle. Tiny by design — one present request. Separate +//! from the display protocol because the directions differ: clients call the compositor over +//! `.display`; the compositor calls the driver over `.scanout`. See docs/display-v2.md. + +const std = @import("std"); + +pub const Operation = enum(u32) { + /// present(x, y, width, height): put the given rectangle of the shared scanout surface on + /// the panel (on virtio-gpu: transfer-to-host of the region, then a resource flush). + present = 0, +}; + +pub const Request = extern struct { + operation: u32, + x: u32 = 0, + y: u32 = 0, + width: u32 = 0, + height: u32 = 0, +}; + +pub const Reply = extern struct { + status: i32, // 0 on success, negative on failure + reserved: u32 = 0, +}; + +pub const message_maximum: usize = 64; +pub const request_size: usize = @sizeOf(Request); +pub const reply_size: usize = @sizeOf(Reply); diff --git a/test/qemu_test.py b/test/qemu_test.py index 427b06c..26b0633 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -200,6 +200,17 @@ CASES = [ "qemu_extra": ["-device", "virtio-gpu-pci"], "expect": r"virtio-gpu: scanout \d+x\d+ online[\s\S]*virtio-gpu: flush acked, pixel check ok", "fail": r"virtio-gpu:.*(failed|not acked|mismatch|unable to claim|not a virtio-gpu|too small|no PCI capability|does not offer|rejected|missing common-config|not a mapped resource|could not spawn)|CPU EXCEPTION|KERNEL PANIC"}, + # Native backend + hot-attach (v2 V4): boot the compositor + display-demo with an emulated + # virtio-gpu. The driver announces its shared scanout surface to the compositor, which maps + # it, upgrades off the GOP floor, and drives frames through the native backend — reading a + # pixel back to confirm the composited frame reached the shared surface, while the demo runs. + {"name": "display-native", + "qemu_extra": ["-device", "virtio-gpu-pci"], + "mem": "512M", # boots the compositor + demo + the whole device-manager driver stack at once + # Order-independent: the demo's `ok` may print before or after the driver announces, so + # require all three markers to appear somewhere rather than in a fixed order. + "expect": r"(?s)(?=.*display: scanout upgraded to virtio-gpu)(?=.*display: native present verified)(?=.*display-demo: ok)", + "fail": r"display: native present FAILED|display: could not|display-demo: (no display|create failed)|CPU EXCEPTION|KERNEL PANIC"}, # Monotonic clock (clock() syscall source): calibrated, advancing, never backwards. {"name": "clock", "expect": r"DANOS-TEST-RESULT: PASS", @@ -609,6 +620,8 @@ def run_case(arch, case): cmd = [arch["qemu"]] + arch["qemu_args"](arch, boot_volume, vars_fd, serial) if case.get("smp"): # some cases need more than one core (e.g. parallelism) cmd += ["-smp", str(case["smp"])] + if case.get("mem"): # a case that boots the whole system at once needs more than the 128M floor + cmd[cmd.index("-m") + 1] = case["mem"] if case.get("qemu_extra"): # extra qemu args, e.g. -device intel-iommu for the IOMMU case cmd += case["qemu_extra"] # A QMP control socket, always present (additive): how a case's `qmp_after`