danos/system/drivers/virtio-gpu/virtio-gpu.zig

555 lines
26 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! /system/drivers/virtio-gpu — the virtio-gpu (virtio 1.0, modern PCI) display driver.
//! The device manager spawns it for the display/other PCI function (class 0x0380) whose
//! config space says vendor 0x1AF4 / device 0x1050; this instance claims that device and
//! brings up a single 2D scanout.
//!
//! V3 (this increment): the whole path end to end, proven from serial without a screenshot.
//! Claim the function, map its config space (resource 0) and the BAR that carries the
//! virtio structures, walk the vendor capabilities to find common-config / notify, reset
//! and negotiate VERSION_1, stand up the control virtqueue in DMA memory, then drive the
//! GPU: RESOURCE_CREATE_2D → ATTACH_BACKING (a coherent DMA buffer) → SET_SCANOUT, paint a
//! known test pattern, TRANSFER_TO_HOST_2D → RESOURCE_FLUSH, and **wait for the device's
//! used-ring ack**. Reading the backing back confirms it is CPU-visible; the ack confirms
//! the device consumed the frame. The compositor backend, hot-attach, mode-set/EDID, and
//! restart/re-attach are V4V6. See docs/display-v2.md.
const std = @import("std");
const device = @import("driver");
const ipc = @import("ipc");
const process = @import("process");
const service = @import("service");
const time = @import("time");
const device_manager = @import("driver");
const memory = @import("memory");
const logging = @import("logging");
const mmio = @import("mmio");
const pci = @import("pci");
const display_protocol = @import("display-protocol");
const scanout_protocol = @import("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 scanout resource + shared surface are sized to the *largest* mode we offer; a mode
/// change (V5) re-points the scanout rectangle within it, so the resource, its backing, and
/// the shared surface never churn — and the surface's row stride is always `max_width`, which
/// the compositor is told in the announce. Kept modest so the backing is an easy contiguous run.
const max_width: u32 = 800;
const max_height: u32 = 600;
const scanout_bytes: usize = @as(usize, max_width) * max_height * 4;
const resource_id: u32 = 1;
/// The modes this scanout offers (all ≤ max). The first is the mode it comes up in.
const Mode = struct { width: u32, height: u32 };
const offered_modes = [_]Mode{ .{ .width = 640, .height = 480 }, .{ .width = 800, .height = 600 } };
/// The active mode — the scanout rectangle within the max-sized surface. Changed by `set_mode`.
var current_width: u32 = offered_modes[0].width;
var current_height: u32 = offered_modes[0].height;
/// Monotonic fence id for fenced flushes; the device signals the fence when the flush is
/// complete, which its used-ring ack already gates our synchronous present on. Completion
/// feedback, not vblank — nothing here is paced to the display's refresh.
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.
const queue_size: u16 = 16;
const desc_offset: usize = 0; // 16 * 16 = 256 bytes
const avail_offset: usize = 256; // flags + idx + ring[16] + used_event = 38 bytes
const used_offset: usize = 1024; // flags + idx + ring[16] + avail_event = 134 bytes
/// The command scratch: the request the device reads, then its response, in one DMA page.
const request_offset: usize = 0;
const response_offset: usize = 2048;
var device_id: u64 = 0;
// Mapped virtio structures (virtual addresses into the device's BAR).
var common_base: usize = 0;
var notify_base: usize = 0;
var notify_multiplier: u32 = 0;
var notify_addr: usize = 0;
// DMA memory: the virtqueue rings and the command scratch.
var ring: memory.DmaRegion = undefined;
var command: memory.DmaRegion = undefined;
// The scanout backing is a **shared** (shared-memory) 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: memory.SharedRegion = undefined;
// Split-virtqueue producer/consumer shadows.
var avail_shadow: u16 = 0;
var used_shadow: u16 = 0;
// --- common-config register access (little-endian MMIO at `common_base`) ---------------
fn cfgRead(comptime T: type, comptime field: []const u8) T {
return mmio.readRegister(T, common_base + @offsetOf(vp.CommonCfg, field));
}
fn cfgWrite(comptime T: type, comptime field: []const u8, value: T) void {
mmio.writeRegister(T, common_base + @offsetOf(vp.CommonCfg, field), value);
}
/// Write a 64-bit common-config register as two 32-bit halves (low then high) — the widest
/// access every virtio-pci host is required to accept for the queue-address registers.
fn cfgWrite64(comptime field: []const u8, value: u64) void {
const at = common_base + @offsetOf(vp.CommonCfg, field);
mmio.writeRegister(u32, at, @truncate(value));
mmio.writeRegister(u32, at + 4, @truncate(value >> 32));
}
fn orStatus(bit: u8) void {
cfgWrite(u8, "device_status", cfgRead(u8, "device_status") | bit);
}
// --- the control virtqueue -------------------------------------------------------------
/// Publish the two-descriptor chain (request read by the device, response written by it),
/// notify the control queue, and wait for the device to return the buffer on the used ring.
fn submit(request_len: usize, response_len: usize) bool {
const desc: [*]vp.Desc = @ptrFromInt(ring.virtual + desc_offset);
desc[0] = .{
.addr = command.physical + request_offset,
.len = @intCast(request_len),
.flags = vp.desc_flag_next,
.next = 1,
};
desc[1] = .{
.addr = command.physical + response_offset,
.len = @intCast(response_len),
.flags = vp.desc_flag_write,
.next = 0,
};
const avail_ring: [*]u16 = @ptrFromInt(ring.virtual + avail_offset + 4);
avail_ring[avail_shadow % queue_size] = 0; // head of the chain is descriptor 0
mmio.writeMemoryBarrier();
avail_shadow +%= 1;
mmio.writeRegister(u16, ring.virtual + avail_offset + 2, avail_shadow); // avail.idx
mmio.writeMemoryBarrier();
mmio.writeRegister(u16, notify_addr, 0); // ring the control queue's doorbell
return waitUsed();
}
/// Spin, then sleep-poll, on the used-ring index until the device advances it. QEMU
/// processes the notify on its own thread, so the ack usually lands immediately; the sleep
/// fallback covers a device that defers it without burning the CPU.
fn waitUsed() bool {
var tries: u32 = 0;
while (tries < 2000) : (tries += 1) {
mmio.readMemoryBarrier();
const idx = mmio.readRegister(u16, ring.virtual + used_offset + 2); // used.idx
if (idx != used_shadow) {
used_shadow = idx;
return true;
}
if (tries > 8) time.sleepMillis(1);
}
return false;
}
/// The type field of the response the device wrote — `resp_ok_nodata` on success.
fn responseType() u32 {
const response: *vg.CtrlHdr = @ptrFromInt(command.virtual + response_offset);
return response.type;
}
/// Submit a command whose response is a bare header, returning its response type (0 if the
/// device never acked).
fn command_nodata(request_len: usize) u32 {
if (!submit(request_len, @sizeOf(vg.CtrlHdr))) return 0;
return responseType();
}
const ok_nodata: u32 = @intFromEnum(vg.CmdType.resp_ok_nodata);
fn requestAt(comptime T: type) *T {
return @ptrFromInt(command.virtual + request_offset);
}
/// A deterministic, recognisable pixel so a read-back is a real check, not a tautology.
fn testPixel(index: u32) u32 {
return 0xFF00_0000 | (index *% 0x9E37_79B1);
}
// --- bring-up --------------------------------------------------------------------------
fn initialise(endpoint: ipc.Handle) bool {
_ = endpoint;
if (!device.claim(device_id)) {
std.log.info("unable to claim device {d}", .{device_id});
return false;
}
var descriptors: [64]device.DeviceDescriptor = undefined;
const total = device.enumerate(&descriptors);
const descriptor = for (descriptors[0..@min(total, descriptors.len)]) |*d| {
if (d.id == device_id) break d;
} else {
std.log.info("device {d} not in the device tree", .{device_id});
return false;
};
// Config space is resource 0. The registry (/system/configuration/devices.csv) bound this driver by the
// exact virtio-gpu identity (vendor 0x1AF4 / device 0x1050), so there is no re-confirm to
// do here any more — map config space and enable memory-space decode + bus mastering (the
// device DMAs the ring and backing out of RAM; pci-bus only preserves whatever the firmware
// left, and a secondary display is often left disabled).
var function = pci.Function.map(device_id, descriptor) orelse {
std.log.info("config-space map failed", .{});
return false;
};
function.enableMemoryAndBusMaster();
// Walk the capability list for the virtio common-config and notify structures (V3 needs
// only those two). The generic PCI mechanics — header fields, BAR decode, the capability
// chain — are library/device/pci; the virtio cfg_type dispatch stays here. We map only the
// common/notify BARs (the other virtio caps, and the cfg_pci back-door's placeholder
// bar=0/offset=0, reference BARs we never touch, so mapping them would only log noise).
var caps = function.capabilities();
while (caps.next()) |cap| {
if (cap.id != vp.pci_cap_vendor) continue;
const cfg_type = mmio.readRegister(u8, cap.offset + 3);
if (cfg_type != vp.cfg_common and cfg_type != vp.cfg_notify) continue;
const bar = mmio.readRegister(u8, cap.offset + 4);
const offset = mmio.readRegister(u32, cap.offset + 8);
if (function.mapBar(bar)) |bar_base| {
if (cfg_type == vp.cfg_common) {
common_base = bar_base + offset;
} else {
notify_base = bar_base + offset;
notify_multiplier = mmio.readRegister(u32, cap.offset + 16); // virtio_pci_notify_cap tail
}
}
}
if (common_base == 0 or notify_base == 0) {
std.log.info("missing common-config or notify capability", .{});
return false;
}
// Reset, then the modern feature handshake: acknowledge, take driver ownership, require
// VERSION_1 and offer nothing else, and confirm the device accepts that.
cfgWrite(u8, "device_status", 0);
orStatus(vp.status_acknowledge);
orStatus(vp.status_driver);
// Low feature word (device-specific): note whether the device offers EDID (bit 1).
cfgWrite(u32, "device_feature_select", 0);
edid_available = cfgRead(u32, "device_feature") & vg.feature_edid != 0;
// High feature word: VERSION_1 (bit 32) is required for a modern device.
cfgWrite(u32, "device_feature_select", vp.feature_version_1_word);
if (cfgRead(u32, "device_feature") & vp.feature_version_1_bit == 0) {
std.log.info("device does not offer VERSION_1 (not a modern device)", .{});
return false;
}
// Accept exactly VERSION_1, plus EDID when the device offered it (never a feature it didn't).
cfgWrite(u32, "driver_feature_select", 0);
cfgWrite(u32, "driver_feature", if (edid_available) vg.feature_edid else 0);
cfgWrite(u32, "driver_feature_select", vp.feature_version_1_word);
cfgWrite(u32, "driver_feature", vp.feature_version_1_bit);
orStatus(vp.status_features_ok);
if (cfgRead(u8, "device_status") & vp.status_features_ok == 0) {
std.log.info("device rejected the negotiated features", .{});
return false;
}
// Stand up the control virtqueue (queue 0) in coherent DMA memory.
cfgWrite(u16, "queue_select", 0);
const device_qsize = cfgRead(u16, "queue_size");
if (device_qsize < queue_size) {
std.log.info("control queue too small ({d})", .{device_qsize});
return false;
}
ring = memory.dmaAlloc(4096, memory.dma_coherent) orelse {
std.log.info("virtqueue allocation failed", .{});
return false;
};
command = memory.dmaAlloc(4096, memory.dma_coherent) orelse {
std.log.info("command-buffer allocation failed", .{});
return false;
};
mmio.writeRegister(u16, ring.virtual + avail_offset, 1); // VIRTQ_AVAIL_F_NO_INTERRUPT: we poll
cfgWrite(u16, "queue_size", queue_size);
cfgWrite64("queue_desc", ring.physical + desc_offset);
cfgWrite64("queue_driver", ring.physical + avail_offset);
cfgWrite64("queue_device", ring.physical + used_offset);
cfgWrite(u16, "queue_msix_vector", 0xFFFF); // VIRTIO_MSI_NO_VECTOR
cfgWrite(u16, "queue_enable", 1);
cfgWrite(u16, "queue_select", 0);
notify_addr = notify_base + @as(usize, cfgRead(u16, "queue_notify_off")) * notify_multiplier;
orStatus(vp.status_driver_ok);
// Drive the GPU: create a 2D resource at the *max* mode, back it with a shared surface, and
// scan out the current-mode rectangle within it.
{
const request = requestAt(vg.ResourceCreate2d);
request.* = .{
.hdr = .{ .type = @intFromEnum(vg.CmdType.resource_create_2d) },
.resource_id = resource_id,
.format = vg.format_b8g8r8x8_unorm,
.width = max_width,
.height = max_height,
};
if (command_nodata(@sizeOf(vg.ResourceCreate2d)) != ok_nodata) {
std.log.info("resource_create_2d failed", .{});
return false;
}
}
// Back the resource with a shared (shared-memory) surface, so the compositor and the device work
// the same physical pages. The device needs the guest-physical base for attach_backing.
surface = memory.sharedCreate(scanout_bytes) orelse {
std.log.info("scanout surface allocation failed", .{});
return false;
};
const surface_physical = memory.sharedPhysical(surface.handle) orelse {
std.log.info("could not resolve the scanout surface physical address", .{});
return false;
};
// Bind the shared surface into this device's IOMMU domain so the GPU may DMA the
// framebuffer (attach_backing points it here). The ring/command buffers are
// dma_alloc'd and auto-bound; a shared-memory surface needs an explicit bind. We keep
// the handle (it is also passed to the display service), so do not close it. No-op
// without an IOMMU.
if (!device.dmaBind(device_id, surface.handle)) {
std.log.info("could not bind the scanout surface for DMA", .{});
return false;
}
{
const request = requestAt(vg.ResourceAttachBacking);
request.* = .{
.hdr = .{ .type = @intFromEnum(vg.CmdType.resource_attach_backing) },
.resource_id = resource_id,
.nr_entries = 1,
};
const entry: *vg.MemEntry = @ptrFromInt(command.virtual + request_offset + @sizeOf(vg.ResourceAttachBacking));
entry.* = .{ .addr = surface_physical, .length = @intCast(scanout_bytes) };
if (command_nodata(@sizeOf(vg.ResourceAttachBacking) + @sizeOf(vg.MemEntry)) != ok_nodata) {
std.log.info("resource_attach_backing failed", .{});
return false;
}
}
if (!setScanoutRect()) {
std.log.info("set_scanout failed", .{});
return false;
}
std.log.info("scanout {d}x{d} online", .{ current_width, current_height });
// Hello the device manager so it counts us as up (and does not stop us at the hello
// deadline). Role: device — we claim one PCI function and serve its scanout; we report
// no children. A restarted instance re-hellos here and re-announces below — the compositor
// re-attaches to the fresh scanout (V6). Best-effort: standalone bring-up has no manager.
_ = device_manager.hello(.device, device_id);
// Read the monitor's EDID (best-effort, when the device offers it) — the mode list a real
// driver derives from it; we log the preferred mode and keep our fixed offered list.
readEdid();
// 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: usize = @as(usize, max_width) * max_height;
for (0..pixel_count) |i| pixels[i] = testPixel(@intCast(i));
if (!presentFull()) {
std.log.info("initial present failed", .{});
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.readMemoryBarrier();
if (pixels[0] != testPixel(0) or pixels[pixel_count / 2] != testPixel(@intCast(pixel_count / 2))) {
std.log.info("pixel read-back mismatch", .{});
return false;
}
std.log.info("flush acked, pixel check ok", .{});
// Offer the shared surface to the compositor so it upgrades off the GOP floor (V4).
announce();
return true;
}
/// Point scanout 0 at the current-mode rectangle of the resource. Reused by initial bring-up
/// and by `set_mode`.
fn setScanoutRect() bool {
const request = requestAt(vg.SetScanout);
request.* = .{
.hdr = .{ .type = @intFromEnum(vg.CmdType.set_scanout) },
.rect = .{ .x = 0, .y = 0, .width = current_width, .height = current_height },
.scanout_id = 0,
.resource_id = resource_id,
};
return command_nodata(@sizeOf(vg.SetScanout)) == ok_nodata;
}
/// Read and log the monitor's preferred mode from its EDID (VIRTIO_GPU_F_EDID). Best-effort:
/// a device that doesn't offer EDID, or a missing/short block, is logged and ignored.
fn readEdid() void {
if (!edid_available) {
std.log.info("EDID not offered by device", .{});
return;
}
const request = requestAt(vg.GetEdid);
request.* = .{ .hdr = .{ .type = @intFromEnum(vg.CmdType.get_edid) }, .scanout = 0 };
if (!submit(@sizeOf(vg.GetEdid), @sizeOf(vg.RespEdid))) {
std.log.info("EDID request not acked", .{});
return;
}
const response: *vg.RespEdid = @ptrFromInt(command.virtual + response_offset);
if (response.hdr.type != @intFromEnum(vg.CmdType.resp_ok_edid) or response.size < 64) {
std.log.info("EDID unavailable", .{});
return;
}
// 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);
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);
std.log.info("EDID preferred mode {d}x{d} @ {d} Hz", .{ h_active, v_active, edid_refresh_hz });
}
/// 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.writeMemoryBarrier(); // the surface writes must be visible before the device transfers them
{
// Transfer the current-mode rectangle from the guest backing to the host resource. The
// device uses the resource's (max) width as the row stride, so the top-left rect at
// offset 0 is exactly the visible area — the compositor composes at that same stride.
const request = requestAt(vg.TransferToHost2d);
request.* = .{
.hdr = .{ .type = @intFromEnum(vg.CmdType.transfer_to_host_2d) },
.rect = .{ .x = 0, .y = 0, .width = current_width, .height = current_height },
.offset = 0,
.resource_id = resource_id,
};
if (command_nodata(@sizeOf(vg.TransferToHost2d)) != ok_nodata) return false;
}
{
// A fenced flush: the device signals the fence once it has consumed the frame — which
// its used-ring ack, what our synchronous submit waits on, already gates. Completion
// feedback and a tear-free snapshot, not vblank pacing.
const request = requestAt(vg.ResourceFlush);
request.* = .{
.hdr = .{ .type = @intFromEnum(vg.CmdType.resource_flush), .flags = vg.flag_fence, .fence_id = fence_next },
.rect = .{ .x = 0, .y = 0, .width = current_width, .height = current_height },
.resource_id = resource_id,
};
fence_next += 1;
if (command_nodata(@sizeOf(vg.ResourceFlush)) != ok_nodata) return false;
}
return true;
}
/// 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;
time.sleepMillis(20);
} else {
std.log.info("no display service to announce to (scanout-only)", .{});
return;
};
var request = display_protocol.Request{
.operation = @intFromEnum(display_protocol.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,
};
var reply: [display_protocol.reply_size]u8 = undefined;
_ = ipc.callCap(display, std.mem.asBytes(&request), &reply, surface.handle) catch {
std.log.info("announce to display failed", .{});
return;
};
std.log.info("announced scanout to display", .{});
}
/// A `scanout_protocol.Reply{status}` written into `reply`.
fn scanoutStatus(reply: []u8, ok: bool) usize {
const response = scanout_protocol.Reply{ .status = if (ok) 0 else -1 };
@memcpy(reply[0..scanout_protocol.reply_size], std.mem.asBytes(&response));
return scanout_protocol.reply_size;
}
/// The `.scanout` service: the compositor drives present / mode queries here. The pixels are
/// already in the shared surface, so a present is a transfer-to-host + fenced flush; a mode
/// change just re-points the scanout rectangle (the surface is sized to the largest mode).
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
_ = sender;
_ = capability;
if (message.len < scanout_protocol.request_size) return 0;
const request = std.mem.bytesToValue(scanout_protocol.Request, message[0..scanout_protocol.request_size]);
switch (request.operation) {
@intFromEnum(scanout_protocol.Operation.present) => return scanoutStatus(reply, presentFull()),
@intFromEnum(scanout_protocol.Operation.get_modes) => {
var response = scanout_protocol.ModesReply{ .status = 0, .count = offered_modes.len, .modes = undefined };
for (0..scanout_protocol.max_modes) |i| {
response.modes[i] = if (i < offered_modes.len)
.{ .width = offered_modes[i].width, .height = offered_modes[i].height }
else
.{ .width = 0, .height = 0 };
}
@memcpy(reply[0..scanout_protocol.modes_reply_size], std.mem.asBytes(&response));
return scanout_protocol.modes_reply_size;
},
@intFromEnum(scanout_protocol.Operation.set_mode) => {
const w = request.width;
const h = request.height;
if (w == 0 or h == 0 or w > max_width or h > max_height) return scanoutStatus(reply, false);
current_width = w;
current_height = h;
return scanoutStatus(reply, setScanoutRect());
},
else => return 0,
}
}
pub fn main(init: process.Init) void {
const argument = init.arguments.get(1) orelse {
_ = logging.write("virtio-gpu: missing device id (argv[1])\n");
return;
};
device_id = std.fmt.parseInt(u64, argument, 10) catch {
std.log.info("malformed device id '{s}'", .{argument});
return;
};
service.run(256, .{
.service = .scanout,
.init = initialise,
.on_message = onMessage,
});
}