Author SHA1 Message Date
Daniel Samson ec6e888076 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.
2026-07-21 11:37:29 +01:00
Daniel Samson 4f02f75602 display: a ~60 Hz frame clock — presents are scheduled, not immediate
Client 'present' requests and cursor pokes no longer repaint on the spot:
they accumulate damage and arm a one-shot 16 ms timer, and the tick
composites everything pending as one frame. A fast mouse previously
turned every input event into a full present (100+/s); now any number of
draws and moves inside one interval coalesce into a single repaint.

No backend has a real vblank to pace by (docs/display-v2.md, 'Fenced is
not vsync'), so this is the software stand-in — the same strategy Linux
uses atop virtio-gpu. Bring-up paths that need pixels synchronously
(initialise, self-checks) still present directly.

onNotification now handles the message and timer badge bits
independently: one coalesced badge can carry both, and the old
either/or dispatch would have dropped a tick.

All six display QEMU cases pass.
2026-07-21 11:26:49 +01:00
Daniel Samson 16618d2cdc display: stop calling the fenced present 'vsync' — it isn't
The virtio-gpu present fence completes when the device has consumed the
frame: real completion feedback, and tear-freedom by snapshot semantics.
It is not a vblank — base virtio-gpu 2D has no display-refresh event at
all (Linux fakes one with a timer), so nothing paces presents to the
monitor. The code and docs claimed vsync anyway; now they don't.

- backend.hasVsync -> hasFencedPresent, with an honest doc comment
- marker 'display: vsync present ok' -> 'display: fenced present ok'
  (display-modeset test expectation updated, passes)
- display-v2.md gains a 'Fenced is not vsync' note; the vsync claims in
  both v2 docs are corrected
- true vsync arrives with a native driver's vblank IRQ, or approximated
  by a compositor frame clock
2026-07-21 11:24:23 +01:00
Daniel Samson 15107f54be build: run-x86-64-gpu — the interactive twin of the display-native test
Boots the usual VGA/GOP floor plus a virtio-gpu-pci adapter, so the
device-manager stack spawns the driver and the compositor upgrades to the
native fenced-present backend interactively. QEMU shows one head per
adapter: the live output is the virtio-gpu console in the View menu (the
VGA head freezes at the moment of upgrade). 512M, matching display-native.
2026-07-21 11:22:07 +01:00
Daniel Samson 23f915c593 display: damage-rect list + tile-grid trackers, vectorizable pixel loops, wide WC stores
Tearing mitigation for the GOP floor, attacking the copy window from three sides:

- Damage is no longer one bounding box. Two trackers, A/B-switchable at
  compile time (display.zig damage_mode): DamageList (free-form dirty rects,
  overlap-merged) and TileGrid (fixed 64-px tiles, exact O(1) marking, runs
  coalesced back into rects). Far-apart changes — the cursor here, an
  animating layer there — no longer unite into one huge repaint.
- fillRect/composite/blitTile now work in row spans (@memset/@memcpy), so
  the compiler vectorizes them and ReleaseSafe bounds checks drop to per-row.
- The back->front present streams 8-byte volatile stores (presentSpan);
  Backend.present takes the rect list, so each present copies only what
  changed, faster.

Host tests cover both trackers; the display QEMU cases all pass.
2026-07-21 11:22:00 +01:00
daniel 981a4af7e0 Merge display-mouse-thread: the compositor tracks the mouse (Shape A)
The display becomes danos's first multi-threaded service. A mouse-listener
thread runs beside the compositor loop; the compositor stays the single owner
of the framebuffer, and the cursor it draws is a top-z layer moved from the
listener's accumulated position over a single-slot latest-value CursorChannel
(Thread.Mutex) with a coalesced self-ipc.send poke that wakes the parked
replyWait (docs/display.md, docs/threading.md).

Two kernel-level findings this surfaced, both fixed:
  - IPC handles are per-Task, not per-address-space — a thread reaches another
    thread's endpoint via ipc.lookup() to install its own handle.
  - Concurrent IPC from two threads raced unlocked kernel state (flaky #GP);
    create_ipc_endpoint / ipc_register / ipc_lookup now take the big kernel
    lock like call/reply_wait/send already did (the kernel heap has no lock of
    its own yet — heap.zig: "a lock comes with threads/SMP").

Also decouples display-demo: it no longer draws a cursor (the service owns it)
or blocks its animation loop on a mouse read, so a normal boot shows one
responsive cursor and animation that runs independent of the mouse. The
display-demo test now spawns the input service to keep that independence honest.

New test display-cursor (smp:4) drives synthetic motion end to end. Verified:
zig build, zig build test, and the full 29-case QEMU guardrail suite all green.
2026-07-21 02:41:45 +01:00
16 changed files with 612 additions and 131 deletions
+18 -6
View File
@@ -97,7 +97,7 @@ fn boot() !noreturn {
} }
/// A display resolution in pixels. /// 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) /// 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. /// 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. // Each pixel is 32 bits, so the byte pitch is 4 * pixels-per-row.
.pitch = info.pixels_per_scan_line * 4, .pitch = info.pixels_per_scan_line * 4,
.format = try pixelFormat(info.pixel_format), .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; return null;
} }
/// Parse the native resolution from a raw EDID block. The first Detailed Timing /// Parse the native resolution and refresh rate from a raw EDID block. The first
/// Descriptor (at byte 54) is the preferred — i.e. native — mode by convention; /// Detailed Timing Descriptor (at byte 54) is the preferred — i.e. native — mode by
/// its active pixel counts are split across low bytes and the high nibbles of /// convention; its active pixel counts are split across low bytes and the high nibbles
/// later bytes. /// 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 { fn edidNative(edid: []const u8) ?Resolution {
if (edid.len < 128) return null; if (edid.len < 128) return null;
// Every EDID begins with this fixed 8-byte header. // 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 w = @as(u32, dtd[2]) | (@as(u32, dtd[4] & 0xf0) << 4);
const h = @as(u32, dtd[5]) | (@as(u32, dtd[7] & 0xf0) << 4); const h = @as(u32, dtd[5]) | (@as(u32, dtd[7] & 0xf0) << 4);
if (w == 0 or h == 0) return null; 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, /// Open the kernel on the volume we booted from, read it into a pool buffer,
+47
View File
@@ -850,6 +850,53 @@ pub fn build(b: *std.Build) void {
const run_efi_step = b.step("run-x86-64", "Boot the x86-64 kernel in QEMU (UEFI/OVMF); serial0 is logged to zig-out/qemu-test/run-x86-64-serial0-<timestamp>.log"); const run_efi_step = b.step("run-x86-64", "Boot the x86-64 kernel in QEMU (UEFI/OVMF); serial0 is logged to zig-out/qemu-test/run-x86-64-serial0-<timestamp>.log");
run_efi_step.dependOn(&run_efi.step); run_efi_step.dependOn(&run_efi.step);
// --- run-x86-64-gpu: the same boot plus a virtio-gpu adapter ---
// The VGA device still supplies the boot (GOP) framebuffer the compositor starts
// on; the virtio-gpu function is discovered by the device-manager stack, its
// driver announces a shared scanout, and the compositor upgrades off the GOP
// floor to fenced, tear-free native presents (docs/display-v2.md).
// This is the interactive twin of the `display-native` test case, and 512M
// matches it (the whole driver stack + the compositor's surfaces at once).
// QEMU shows one head per adapter: pick the virtio-gpu head in the View menu
// to watch the native output.
const run_gpu = b.addSystemCommand(&.{
"qemu-system-x86_64",
"-device",
"qemu-xhci,id=xhci",
"-device",
"usb-mouse,bus=xhci.0",
"-device",
"usb-kbd,bus=xhci.0",
"-machine",
"q35",
"-m",
"512M",
"-drive",
b.fmt("if=pflash,format=raw,readonly=on,file={s}", .{ovmf_code}),
});
run_gpu.addArg("-drive");
run_gpu.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out);
run_gpu.addArg("-drive");
run_gpu.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image_serial);
run_gpu.addArgs(&.{
"-device",
"usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0",
"-net",
"none",
"-vga",
"none",
"-device",
"VGA,edid=on,xres=1280,yres=720",
"-device",
"virtio-gpu-pci",
});
const gpu_serial_log = b.fmt("{s}/run-x86-64-gpu-serial0-{s}.log", .{ log_dir, timestamp(b) });
run_gpu.addArgs(&.{ "-serial", b.fmt("file:{s}", .{gpu_serial_log}) });
run_gpu.step.dependOn(&make_log_dir.step);
const run_gpu_step = b.step("run-x86-64-gpu", "Boot in QEMU with a virtio-gpu adapter: the compositor upgrades to fenced (tear-free) native presents; watch the virtio-gpu head in QEMU's View menu");
run_gpu_step.dependOn(&run_gpu.step);
// const run_cmd = b.addRunArtifact(exe); // const run_cmd = b.addRunArtifact(exe);
// const run_step = b.step("run", "Run the app"); // const run_step = b.step("run", "Run the app");
// run_step.dependOn(&run_cmd.step); // run_step.dependOn(&run_cmd.step);
+9 -7
View File
@@ -6,7 +6,7 @@ lands on its own and ends in a **verifiable gate** — shaped for a `/loop` run,
## Locked decisions (do not relitigate) ## Locked decisions (do not relitigate)
- **First native backend = virtio-gpu** (VM standard: mode-set + present/flush + vsync). - **First native backend = virtio-gpu** (VM standard: mode-set + fenced present/flush).
- **Dynamic hot-attach**: boot on GOP, upgrade to native when the driver **announces** - **Dynamic hot-attach**: boot on GOP, upgrade to native when the driver **announces**
(push, not polling); re-attach across driver restarts; GOP is the floor for "no driver (push, not polling); re-attach across driver restarts; GOP is the floor for "no driver
ever," not a live fall-back after a reprogram. ever," not a live fall-back after a reprogram.
@@ -46,7 +46,7 @@ Extract scanout from the compositor so today's path becomes one backend among fu
- [x] `system/services/display/backend.zig`: a `Backend` tagged union with `info()`, - [x] `system/services/display/backend.zig`: a `Backend` tagged union with `info()`,
`surface()` (the cacheable compose target), `present(damage)`, and capability flags `surface()` (the cacheable compose target), `present(damage)`, and capability flags
(`canModeSet`/`hasVsync`, both false for GOP). (`canModeSet`/`hasFencedPresent`, both false for GOP).
- [x] The v1 GOP path is now `backend.Gop` (claims the `display` node, WC-maps the LFB, - [x] The v1 GOP path is now `backend.Gop` (claims the `display` node, WC-maps the LFB,
keeps the cacheable back buffer, `present` = the damage-rect WC copy). display.zig keeps the cacheable back buffer, `present` = the damage-rect WC copy). display.zig
composes into `backend.surface()` and calls `backend.present(damage)` — no LFB or composes into `backend.surface()` and calls `backend.present(damage)` — no LFB or
@@ -123,7 +123,7 @@ confirm the composited frame landed (`display: native present verified`), while
ok` still fires — checked order-independently. Without `-device virtio-gpu-pci` nothing is 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. announced and it stays on GOP: the v1 `display-service`/`display-demo` gates pass unchanged.
## V5 — Mode-setting, EDID, and vsync ✅ ## V5 — Mode-setting, EDID, and fenced presents ✅
- [x] The driver negotiates `VIRTIO_GPU_F_EDID` (when offered) and reads the monitor's EDID, - [x] The driver negotiates `VIRTIO_GPU_F_EDID` (when offered) and reads the monitor's EDID,
logging its preferred mode; it offers a small mode list over `.scanout` `get_modes`. The logging its preferred mode; it offers a small mode list over `.scanout` `get_modes`. The
@@ -131,14 +131,16 @@ announced and it stays on GOP: the v1 `display-service`/`display-demo` gates pas
scanout rectangle (no resource/surface churn) — a runtime resolution change. `runtime.display` scanout rectangle (no resource/surface churn) — a runtime resolution change. `runtime.display`
gains `modes()` / `setMode()` (display-protocol `get_modes`/`set_mode`, forwarded to the backend). gains `modes()` / `setMode()` (display-protocol `get_modes`/`set_mode`, forwarded to the backend).
- [x] Every `resource_flush` is issued fenced (`VIRTIO_GPU_FLAG_FENCE`); the device signals the - [x] Every `resource_flush` is issued fenced (`VIRTIO_GPU_FLAG_FENCE`); the device signals the
fence when the frame is on screen, which the used-ring ack the synchronous present waits on fence when it has consumed the frame, which the used-ring ack the synchronous present waits
already gates — a tear-free present. on already gates — a tear-free present. (Completion feedback, **not vblank**: base
- [x] `backend.VirtioGpu` reports `canModeSet` / `hasVsync` = true. virtio-gpu 2D has no display-refresh event, so nothing paces presents to the monitor —
see the "Fenced is not vsync" note in [display-v2.md](display-v2.md).)
- [x] `backend.VirtioGpu` reports `canModeSet` / `hasFencedPresent` = true.
**Gate (met):** the `display-modeset` case (reusing the display-native boot) upgrades to **Gate (met):** the `display-modeset` case (reusing the display-native boot) upgrades to
virtio-gpu, queries the driver's modes, `setMode`s to a different resolution, and confirms the virtio-gpu, queries the driver's modes, `setMode`s to a different resolution, and confirms the
change by reading the backend's geometry back (`display: mode set to {w}x{h}, verified`); the change by reading the backend's geometry back (`display: mode set to {w}x{h}, verified`); the
fenced present path is exercised and confirmed (`display: vsync present ok`) — both from serial, fenced present path is exercised and confirmed (`display: fenced present ok`) — both from serial,
passing 3/3. The driver also logs the EDID preferred mode (`virtio-gpu: EDID preferred mode …`). passing 3/3. The driver also logs the EDID preferred mode (`virtio-gpu: EDID preferred mode …`).
## V6 — Resilience (restart + re-attach) + tests + docs ✅ ## V6 — Resilience (restart + re-attach) + tests + docs ✅
+18 -10
View File
@@ -2,7 +2,7 @@
**Status: complete (V1–V6).** The compositor boots on the GOP framebuffer and, when a **Status: complete (V1–V6).** The compositor boots on the GOP framebuffer and, when a
virtio-gpu driver announces itself, hot-attaches a native backend over the shared `shm` virtio-gpu driver announces itself, hot-attaches a native backend over the shared `shm`
scanout surface — with runtime mode-setting, EDID, and fenced (vsync) presents, and it scanout surface — with runtime mode-setting, EDID, and fenced presents, and it
re-attaches across driver restarts. All serial-gated (see [display-v2-plan.md](display-v2-plan.md)). re-attaches across driver restarts. All serial-gated (see [display-v2-plan.md](display-v2-plan.md)).
v1 ([display.md](display.md)) is a compositor that owns the **GOP framebuffer** — it v1 ([display.md](display.md)) is a compositor that owns the **GOP framebuffer** — it
@@ -25,10 +25,10 @@ The compositor itself (layers, back buffer, damage) does not change. Only the la
scanout backend (selected at runtime — GOP by default, native when it appears) scanout backend (selected at runtime — GOP by default, native when it appears)
│ │
├─ GopBackend the v1 path: WC copy back→front to the firmware LFB. ├─ GopBackend the v1 path: WC copy back→front to the firmware LFB.
│ Always available. No mode-set, no vsync. THE FLOOR. │ Always available. No mode-set, no present fence. THE FLOOR.
│ │
└─ VirtioGpuBackend talks to a virtio-gpu driver process over a `scanout` └─ VirtioGpuBackend talks to a virtio-gpu driver process over a `scanout`
service: present via a shared resource + flush (real vsync), service: present via a shared resource + fenced flush,
EDID mode list, runtime mode-set. EDID mode list, runtime mode-set.
``` ```
@@ -37,8 +37,8 @@ A **backend** is a small interface the compositor calls:
- `surface()` → the pixels to compose into and their geometry `{ptr, pitch, format, w, h}` - `surface()` → the pixels to compose into and their geometry `{ptr, pitch, format, w, h}`
(the LFB for GOP; a shared scanout resource for virtio-gpu), (the LFB for GOP; a shared scanout resource for virtio-gpu),
- `present(damage: Rect)` → make the damaged region visible (a no-op-ish WC copy for GOP; - `present(damage: Rect)` → make the damaged region visible (a no-op-ish WC copy for GOP;
a virtio flush, optionally vsync-fenced, for the native path), a fenced virtio flush for the native path),
- capability queries — `canModeSet`, `hasVsync` — and, when supported, `modes()` / - capability queries — `canModeSet`, `hasFencedPresent` — and, when supported, `modes()` /
`setMode(m)`. `setMode(m)`.
The compositor composes into `surface()` and calls `present(damage)` exactly as it does The compositor composes into `surface()` and calls `present(damage)` exactly as it does
@@ -98,23 +98,31 @@ compositor when a second backend arrives"). It claims the virtio-gpu PCI functio
at a chosen mode for **runtime mode-setting**, at a chosen mode for **runtime mode-setting**,
- registers a `scanout` service and announces to the display service. - registers a `scanout` service and announces to the display service.
Its `resource_flush` is the real **present** — and gives a genuine **vsync/tear-free** Its `resource_flush` is the real **present** — and gives a **fenced, tear-free** path a
path a dumb GOP framebuffer can't. dumb GOP framebuffer can't.
**Fenced is not vsync.** The fence completes when the device has *consumed* the frame:
real completion feedback, and tear-freedom by snapshot semantics (the host displays
discrete transferred frames, never a half-written surface). It is **not** a vblank —
base virtio-gpu 2D has no display-refresh event at all (Linux's driver for this device
fakes one with a software timer), so nothing paces presents to the monitor's refresh.
Refresh-paced presents need either a native driver's vblank interrupt (delivered over
the existing IRQ-as-IPC path) or the compositor's own frame clock.
## What v2 unlocks — and its honest scope ## What v2 unlocks — and its honest scope
Behind the abstraction, a native backend gives runtime **mode-setting** (resolution / Behind the abstraction, a native backend gives runtime **mode-setting** (resolution /
refresh / bpp), **EDID** enumeration, and **vsync**. But only on devices we have a driver refresh / bpp), **EDID** enumeration, and **fenced presents**. But only on devices we have a driver
for — realistically **VMs** (virtio-gpu, and later maybe Bochs DISPI). Real discrete GPUs for — realistically **VMs** (virtio-gpu, and later maybe Bochs DISPI). Real discrete GPUs
need per-vendor KMS-class drivers that aren't getting written, so they **stay on GOP** — need per-vendor KMS-class drivers that aren't getting written, so they **stay on GOP** —
which is genuinely fine (v1 on the NVIDIA box is smooth). So v2's real value is twofold: which is genuinely fine (v1 on the NVIDIA box is smooth). So v2's real value is twofold:
the **pluggable architecture** (a driver slots in when one exists) and a **rich, vsync'd the **pluggable architecture** (a driver slots in when one exists) and a **rich, fenced
path in VMs**, where danos development happens. The framebuffer floor never goes away. path in VMs**, where danos development happens. The framebuffer floor never goes away.
## Locked decisions ## Locked decisions
- **First native backend: virtio-gpu** — the VM standard; gives mode-set + a real - **First native backend: virtio-gpu** — the VM standard; gives mode-set + a real
present/flush (and vsync), and exercises the whole pluggable design. Tested with QEMU present/flush (fenced), and exercises the whole pluggable design. Tested with QEMU
`-device virtio-gpu`. `-device virtio-gpu`.
- **Dynamic hot-attach** — boot on GOP, upgrade to native on the driver's announce, - **Dynamic hot-attach** — boot on GOP, upgrade to native on the driver's announce,
re-attach across driver restarts; GOP is the floor for "no driver ever," not a live re-attach across driver restarts; GOP is the floor for "no driver ever," not a live
+9 -1
View File
@@ -196,13 +196,21 @@ shell, a terminal, a cursor, and a wallpaper:
| `fill_rect` | fill a rectangle of a layer with a colour | | `fill_rect` | fill a rectangle of a layer with a colour |
| `blit_tile` | copy a small client-supplied pixel tile into a layer (inline) | | `blit_tile` | copy a small client-supplied pixel tile into a layer (inline) |
| `damage` | mark a region of a layer dirty | | `damage` | mark a region of a layer dirty |
| `present` | composite dirty layers and flush to the screen | | `present` | request a repaint: composited at the next frame-clock tick |
Text is intentionally *not* an operation — a client renders glyphs by blitting tiles Text is intentionally *not* an operation — a client renders glyphs by blitting tiles
(the [PSF font](../system/kernel/font.psf) path the console already uses can move into a (the [PSF font](../system/kernel/font.psf) path the console already uses can move into a
client). Keeping the protocol to rectangles and tiles keeps the compositor small and the client). Keeping the protocol to rectangles and tiles keeps the compositor small and the
policy in the client. policy in the client.
`present` is a *request*, not an immediate flush: the compositor runs a ~60 Hz **frame
clock** (a one-shot kernel timer re-armed on demand), and each tick composites all the
damage accumulated since the last one. Any number of client presents and cursor moves
inside one interval coalesce into a single repaint — the software stand-in for vblank
pacing on backends that have none (all of them today; see
[display-v2.md](display-v2.md), "Fenced is not vsync"). Bring-up paths that must put
pixels on screen synchronously (initialisation, the self-checks) bypass the clock.
## `runtime.display` ## `runtime.display`
Clients speak the protocol through a new [`library/runtime/display.zig`](../library/runtime/runtime.zig), Clients speak the protocol through a new [`library/runtime/display.zig`](../library/runtime/runtime.zig),
+5
View File
@@ -37,6 +37,11 @@ pub const Framebuffer = extern struct {
height: u32, // visible rows (e.g. 1080) height: u32, // visible rows (e.g. 1080)
pitch: u32, // bytes from the start of one row to the start of the next pitch: u32, // bytes from the start of one row to the start of the next
format: PixelFormat, 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. /// Whether a usable framebuffer was handed over.
pub fn present(self: Framebuffer) bool { pub fn present(self: Framebuffer) bool {
+1
View File
@@ -97,6 +97,7 @@ pub const DisplayInfo = extern struct {
height: u32 = 0, // visible rows height: u32 = 0, // visible rows
pitch: u32 = 0, // bytes from one row's start to the next pitch: u32 = 0, // bytes from one row's start to the next
format: u32 = 0, // a DisplayFormat value 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. /// `DeviceDescriptor.parent` for a device with no parent — a root of the device tree.
+20 -5
View File
@@ -53,13 +53,18 @@ const offered_modes = [_]Mode{ .{ .width = 640, .height = 480 }, .{ .width = 800
var current_width: u32 = offered_modes[0].width; var current_width: u32 = offered_modes[0].width;
var current_height: u32 = offered_modes[0].height; var current_height: u32 = offered_modes[0].height;
/// Monotonic fence id for fenced (vsync) flushes; the device signals the fence when the flush /// Monotonic fence id for fenced flushes; the device signals the fence when the flush is
/// is complete, which its used-ring ack already gates our synchronous present on. /// 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; var fence_next: u64 = 1;
/// Whether the device offered VIRTIO_GPU_F_EDID, so `get_edid` is worth issuing. /// Whether the device offered VIRTIO_GPU_F_EDID, so `get_edid` is worth issuing.
var edid_available = false; 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 /// 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 /// 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. /// driver reduce queue_size), keeping the whole ring inside one page.
@@ -467,10 +472,18 @@ fn readEdid() void {
} }
// The first detailed timing descriptor (EDID base-block offset 54) is the preferred mode: // 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). // 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 e = &response.edid;
const h_active = @as(u32, e[56]) | (@as(u32, e[58] & 0xF0) << 4); 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 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 /// Present the whole surface: copy the guest backing into the host resource, then flush it to
@@ -492,8 +505,9 @@ fn presentFull() bool {
if (command_nodata(@sizeOf(vg.TransferToHost2d)) != ok_nodata) return false; if (command_nodata(@sizeOf(vg.TransferToHost2d)) != ok_nodata) return false;
} }
{ {
// A fenced flush (vsync): the device signals the fence when the frame is actually on // A fenced flush: the device signals the fence once it has consumed the frame — which
// screen — which its used-ring ack, what our synchronous submit waits on, already gates. // 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); const request = requestAt(vg.ResourceFlush);
request.* = .{ request.* = .{
.hdr = .{ .type = @intFromEnum(vg.CmdType.resource_flush), .flags = vg.flag_fence, .fence_id = fence_next }, .hdr = .{ .type = @intFromEnum(vg.CmdType.resource_flush), .flags = vg.flag_fence, .fence_id = fence_next },
@@ -548,6 +562,7 @@ fn announce() void {
var request = dp.Request{ var request = dp.Request{
.operation = @intFromEnum(dp.Operation.attach_scanout), .operation = @intFromEnum(dp.Operation.attach_scanout),
.x = max_width, // the shared surface's row stride in pixels (it is sized to the max mode) .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, .width = current_width,
.height = current_height, .height = current_height,
.colour = display_format_bgrx, .colour = display_format_bgrx,
+2 -2
View File
@@ -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`. /// [[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 /// 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. /// 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 (base == 0 or width == 0 or height == 0) return null; // headless
if (count >= maximum_devices) { if (count >= maximum_devices) {
dropped += 1; dropped += 1;
@@ -79,7 +79,7 @@ pub fn seedDisplay(base: u64, width: u32, height: u32, pitch: u32, format: u32)
.len = @as(u64, height) * pitch, .len = @as(u64, height) * pitch,
.flags = device_abi.resource_flag_write_combining, .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; devices[count] = d;
display_device = d.id; display_device = d.id;
count += 1; count += 1;
+2 -2
View File
@@ -200,8 +200,8 @@ fn kmain(boot_information: *const BootInformation) noreturn {
// Publish the loader's framebuffer as a claimable `display` device, so a // 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 // 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). // 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| { 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}, write-combining)\n", .{ display_id, fb.width, fb.height, fb.pitch }); 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 // Install the device-IRQ trampolines, so a driver's irq_bind has vectors to
+1
View File
@@ -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("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 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); check("it has exactly one resource", d.resource_count == 1);
const r = d.resources[0]; const r = d.resources[0];
check("that resource is a memory window", r.kind == @intFromEnum(device_abi.ResourceKind.memory)); check("that resource is a memory window", r.kind == @intFromEnum(device_abi.ResourceKind.memory));
+61 -25
View File
@@ -16,8 +16,10 @@ const scanout_protocol = runtime.scanout_protocol;
const Rect = compositor.Rect; const Rect = compositor.Rect;
const Surface = compositor.Surface; const Surface = compositor.Surface;
/// The current display mode, as a backend reports it. /// The current display mode, as a backend reports it. `refresh_hz` is the panel's
pub const Info = struct { width: u32, height: u32, pitch: u32, format: u32 }; /// 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. /// Enumeration scratch — a `DeviceDescriptor` is large, and only one scan is ever needed.
var device_table: [64]device.DeviceDescriptor = undefined; var device_table: [64]device.DeviceDescriptor = undefined;
@@ -26,7 +28,7 @@ var device_table: [64]device.DeviceDescriptor = undefined;
/// framebuffer write-combining as the front buffer, and keeps a cacheable back buffer of /// 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 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, /// the back buffer to the LFB (sequential WC writes; the LFB is never read). No mode-set,
/// no vsync — the portable floor (docs/display-v2.md). /// no present fence — the portable floor (docs/display-v2.md).
pub const Gop = struct { pub const Gop = struct {
device_id: u64, device_id: u64,
front: [*]volatile u8, // the LFB (write-combining) front: [*]volatile u8, // the LFB (write-combining)
@@ -35,13 +37,14 @@ pub const Gop = struct {
height: u32, height: u32,
pitch: u32, pitch: u32,
format: 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 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 /// 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 /// 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 /// 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). /// 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 /// 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 /// 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| { for (device_table[0..n]) |*d| {
if (d.class != @intFromEnum(device.DeviceClass.display)) continue; if (d.class != @intFromEnum(device.DeviceClass.display)) continue;
if (d.display.width == 0 or d.display.height == 0 or d.display.pitch == 0) 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; return null;
} }
@@ -93,11 +96,12 @@ pub const Gop = struct {
.height = found.height, .height = found.height,
.pitch = found.pitch, .pitch = found.pitch,
.format = found.format, .format = found.format,
.refresh_hz = found.refresh_hz,
}; };
} }
pub fn info(self: *const Gop) Info { 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). /// The cacheable compose target (the back buffer).
@@ -110,22 +114,48 @@ pub const Gop = struct {
}; };
} }
/// Stream the damaged rectangle from the back buffer to the write-combining LFB, row by /// Stream each damaged rectangle from the back buffer to the write-combining LFB, row
/// row (sequential writes — what WC memory wants; the LFB is never read). /// by row (sequential writes — what WC memory wants; the LFB is never read). The rows
pub fn present(self: *const Gop, damage: Rect) void { /// are copied by `presentSpan` below, which widens the stores by hand: `volatile`
const c = damage.intersect(.{ .x = 0, .y = 0, .w = @intCast(self.width), .h = @intCast(self.height) }); /// keeps the compiler from eliding or reordering framebuffer writes, but it also
if (c.isEmpty()) return; /// forbids it from merging them, so a naive per-pixel loop is stuck at one 4-byte
var y: i32 = c.y; /// store per iteration. Keeping each copy small (the damage list) and each store wide
while (y < c.bottom()) : (y += 1) { /// shrinks the window in which scanout can sample a half-written frame.
const off = @as(usize, @intCast(y)) * self.pitch; pub fn present(self: *const Gop, damage: []const Rect) void {
const src: [*]const u32 = @ptrCast(@alignCast(self.back + off)); const bounds = Rect{ .x = 0, .y = 0, .w = @intCast(self.width), .h = @intCast(self.height) };
const dst: [*]volatile u32 = @ptrCast(@alignCast(self.front + off)); for (damage) |rect| {
var x: i32 = c.x; const c = rect.intersect(bounds);
while (x < c.right()) : (x += 1) dst[@intCast(x)] = src[@intCast(x)]; 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. /// A display mode the native backend can switch to.
pub const Mode = scanout_protocol.Mode; pub const Mode = scanout_protocol.Mode;
@@ -143,17 +173,19 @@ pub const VirtioGpu = struct {
width: u32, // the active mode width: u32, // the active mode
height: u32, height: u32,
format: 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`) scanout: ipc.Handle, // the driver's present + mode channel (looked up on `.scanout`)
pub fn info(self: *const VirtioGpu) Info { 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 { pub fn surface(self: *const VirtioGpu) Surface {
return .{ .pixels = self.pixels, .stride = self.stride, .width = self.width, .height = self.height }; 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 /// 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 + fenced-flushes. /// this is a single request over `.scanout` regardless of how many damage rectangles
pub fn present(self: *const VirtioGpu, damage: Rect) void { /// accumulated; the driver transfers + fenced-flushes the whole frame.
pub fn present(self: *const VirtioGpu, damage: []const Rect) void {
_ = damage; _ = damage;
var request = scanout_protocol.Request{ var request = scanout_protocol.Request{
.operation = @intFromEnum(scanout_protocol.Operation.present), .operation = @intFromEnum(scanout_protocol.Operation.present),
@@ -210,7 +242,7 @@ pub const Backend = union(enum) {
inline else => |*b| b.surface(), inline else => |*b| b.surface(),
}; };
} }
pub fn present(self: *const Backend, damage: Rect) void { pub fn present(self: *const Backend, damage: []const Rect) void {
switch (self.*) { switch (self.*) {
inline else => |*b| b.present(damage), inline else => |*b| b.present(damage),
} }
@@ -236,9 +268,13 @@ pub const Backend = union(enum) {
.virtio => true, .virtio => true,
}; };
} }
/// Whether this backend has a vblank/fence for tear-free present (virtio-gpu: yes, V5 — every /// Whether this backend's present is **fenced** — it completes only once the device has
/// flush is fenced, so the device signals completion when the frame is actually on screen). /// consumed the frame (virtio-gpu: every flush carries a fence the used-ring ack waits on).
pub fn hasVsync(self: *const Backend) bool { /// 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.*) { return switch (self.*) {
.gop => false, .gop => false,
.virtio => true, .virtio => true,
+285 -25
View File
@@ -57,6 +57,177 @@ pub const Rect = struct {
} }
}; };
/// The dirty screen regions accumulated between presents. Kept as a *list* of rectangles,
/// not one bounding box: when two small things move far apart — the cursor on one side of
/// the screen, an animating layer on the other — a single bounding box unites them into a
/// huge region, and presenting it streams megabytes to the framebuffer for a few thousand
/// changed pixels. The long copy widens the window in which scanout (or QEMU's display
/// refresh) samples a half-written frame — visible as tearing and cursor trails. Small
/// separate rectangles keep each copy, and that window, tight.
///
/// A new rectangle that overlaps an existing entry is united into it (repainting a modest
/// superset is harmless — compositing is idempotent); the grown entry is *not* re-merged
/// against the rest, so entries may overlap, which costs only a duplicate repaint. When
/// the table is full the newcomer folds into the last entry — degrading toward the old
/// bounding-box behaviour instead of dropping damage.
pub const DamageList = struct {
pub const capacity = 16;
rects: [capacity]Rect = [_]Rect{Rect.empty} ** capacity,
count: usize = 0,
pub fn add(self: *DamageList, r: Rect) void {
if (r.isEmpty()) return;
for (self.rects[0..self.count]) |*existing| {
if (!existing.intersect(r).isEmpty()) {
existing.* = existing.unite(r);
return;
}
}
if (self.count < capacity) {
self.rects[self.count] = r;
self.count += 1;
return;
}
self.rects[capacity - 1] = self.rects[capacity - 1].unite(r);
}
pub fn isEmpty(self: *const DamageList) bool {
return self.count == 0;
}
pub fn slice(self: *const DamageList) []const Rect {
return self.rects[0..self.count];
}
pub fn clear(self: *DamageList) void {
self.count = 0;
}
};
/// The alternative damage tracker: a **fixed tile grid**, the scheme browser compositors
/// and tile-based GPUs use. The screen is divided into `tile_size`-pixel tiles up front;
/// `add` marks the tiles a rectangle touches (a bit per tile — merging is free and exact,
/// no heuristics), and `collect` walks the grid turning runs of adjacent dirty tiles into
/// repaint rectangles (horizontal runs, then equal-span rows merged vertically, so
/// full-screen damage collapses back to a single rectangle).
///
/// Trade-off against `DamageList`: tracking is O(1) with a strictly bounded worst case
/// (never more than the dirty tiles), but repaints are quantized — a 1-pixel change
/// repaints a whole tile. Which wins depends on the workload; the display service has a
/// compile-time switch (`damage_mode`) to compare them.
pub const TileGrid = struct {
pub const tile_size = 64;
pub const maximum_columns = 128; // supports screens up to 8192 px wide…
pub const maximum_rows = 128; // …and 8192 px tall (beyond that, edge tiles stretch)
pub const maximum_tiles = maximum_columns * maximum_rows;
/// The most rectangles `collect` produces; extras fold into the last (never dropped).
pub const maximum_rects = 64;
width: u32 = 0,
height: u32 = 0,
columns: u32 = 0,
rows: u32 = 0,
dirty_count: u32 = 0,
dirty: [maximum_tiles]bool = [_]bool{false} ** maximum_tiles,
/// Size the grid for a screen. Also clears it — callers reset on a geometry change,
/// where the mode-set paths damage the whole new screen anyway.
pub fn reset(self: *TileGrid, width: u32, height: u32) void {
self.width = width;
self.height = height;
self.columns = @min((width + tile_size - 1) / tile_size, maximum_columns);
self.rows = @min((height + tile_size - 1) / tile_size, maximum_rows);
self.clear();
}
pub fn matches(self: *const TileGrid, width: u32, height: u32) bool {
return self.width == width and self.height == height;
}
pub fn isEmpty(self: *const TileGrid) bool {
return self.dirty_count == 0;
}
pub fn clear(self: *TileGrid) void {
@memset(&self.dirty, false);
self.dirty_count = 0;
}
/// Mark every tile `r` touches. Clips to the screen first, so out-of-range
/// rectangles are harmless.
pub fn add(self: *TileGrid, r: Rect) void {
const screen = Rect{ .x = 0, .y = 0, .w = @intCast(self.width), .h = @intCast(self.height) };
const c = r.intersect(screen);
if (c.isEmpty()) return;
const column_first: u32 = @intCast(@divTrunc(c.x, tile_size));
const row_first: u32 = @intCast(@divTrunc(c.y, tile_size));
const column_last: u32 = @min(@as(u32, @intCast(@divTrunc(c.right() - 1, tile_size))), self.columns - 1);
const row_last: u32 = @min(@as(u32, @intCast(@divTrunc(c.bottom() - 1, tile_size))), self.rows - 1);
var row = row_first;
while (row <= row_last) : (row += 1) {
var column = column_first;
while (column <= column_last) : (column += 1) {
const index = row * self.columns + column;
if (!self.dirty[index]) {
self.dirty[index] = true;
self.dirty_count += 1;
}
}
}
}
/// The screen rectangle covered by tiles [column_first, column_end) of `row`. Edge
/// tiles clamp to the true screen size (the last column/row may be partial — or, on a
/// screen wider than the grid supports, stretched to cover the remainder).
fn tileSpanRect(self: *const TileGrid, column_first: u32, column_end: u32, row: u32) Rect {
const x: i32 = @intCast(column_first * tile_size);
const y: i32 = @intCast(row * tile_size);
const right: i32 = if (column_end >= self.columns) @intCast(self.width) else @intCast(column_end * tile_size);
const bottom: i32 = if (row + 1 >= self.rows) @intCast(self.height) else @intCast((row + 1) * tile_size);
return .{ .x = x, .y = y, .w = right - x, .h = bottom - y };
}
/// Turn the dirty tiles into repaint rectangles in `out`: coalesce each row's runs of
/// adjacent dirty tiles, then merge a run into the rectangle directly above it when
/// the spans match — so a dirty block of tiles becomes one rectangle. Returns the
/// filled prefix of `out`.
pub fn collect(self: *const TileGrid, out: []Rect) []Rect {
var count: usize = 0;
var row: u32 = 0;
while (row < self.rows) : (row += 1) {
var column: u32 = 0;
while (column < self.columns) {
if (!self.dirty[row * self.columns + column]) {
column += 1;
continue;
}
var run_end = column + 1;
while (run_end < self.columns and self.dirty[row * self.columns + run_end]) run_end += 1;
const rect = self.tileSpanRect(column, run_end, row);
column = run_end;
var merged = false;
for (out[0..count]) |*existing| {
if (existing.x == rect.x and existing.w == rect.w and existing.bottom() == rect.y) {
existing.h += rect.h;
merged = true;
break;
}
}
if (merged) continue;
if (count < out.len) {
out[count] = rect;
count += 1;
} else {
out[count - 1] = out[count - 1].unite(rect);
}
}
}
return out[0..count];
}
};
/// A block of 32-bit pixels: `pixels` addressed row-major with `stride` pixels between /// A block of 32-bit pixels: `pixels` addressed row-major with `stride` pixels between
/// row starts (≥ width — the framebuffer's stride is pitch/4, a layer's is its width). /// row starts (≥ width — the framebuffer's stride is pitch/4, a layer's is its width).
pub const Surface = struct { pub const Surface = struct {
@@ -74,15 +245,17 @@ pub const Surface = struct {
} }
}; };
/// Fill `rect` of `s` with the native pixel `colour`, clipped to `s`'s bounds. /// Fill `rect` of `s` with the native pixel `colour`, clipped to `s`'s bounds. Each row is
/// one `@memset` over the clipped span, so the compiler vectorizes it and the bounds check
/// runs once per row, not once per pixel.
pub fn fillRect(s: Surface, rect: Rect, colour: u32) void { pub fn fillRect(s: Surface, rect: Rect, colour: u32) void {
const c = rect.intersect(s.bounds()); const c = rect.intersect(s.bounds());
if (c.isEmpty()) return; if (c.isEmpty()) return;
const x0: usize = @intCast(c.x);
const span: usize = @intCast(c.w);
var y: i32 = c.y; var y: i32 = c.y;
while (y < c.bottom()) : (y += 1) { while (y < c.bottom()) : (y += 1) {
const r = s.row(@intCast(y)); @memset((s.row(@intCast(y)) + x0)[0..span], colour);
var x: i32 = c.x;
while (x < c.right()) : (x += 1) r[@intCast(x)] = colour;
} }
} }
@@ -94,35 +267,36 @@ pub fn composite(dst: Surface, dx: i32, dy: i32, layer: Surface, clip: Rect) voi
const on_screen = Rect{ .x = dx, .y = dy, .w = @intCast(layer.width), .h = @intCast(layer.height) }; const on_screen = Rect{ .x = dx, .y = dy, .w = @intCast(layer.width), .h = @intCast(layer.height) };
const region = on_screen.intersect(clip).intersect(dst.bounds()); const region = on_screen.intersect(clip).intersect(dst.bounds());
if (region.isEmpty()) return; if (region.isEmpty()) return;
const span: usize = @intCast(region.w);
const dst_x: usize = @intCast(region.x);
const src_x: usize = @intCast(region.x - dx);
var y: i32 = region.y; var y: i32 = region.y;
while (y < region.bottom()) : (y += 1) { while (y < region.bottom()) : (y += 1) {
const src = layer.row(@intCast(y - dy)); const source_row = layer.row(@intCast(y - dy)) + src_x;
const d = dst.row(@intCast(y)); const destination_row = dst.row(@intCast(y)) + dst_x;
var x: i32 = region.x; @memcpy(destination_row[0..span], source_row[0..span]);
while (x < region.right()) : (x += 1) {
d[@intCast(x)] = src[@intCast(x - dx)];
}
} }
} }
/// Copy a `w`×`h` tile of native pixels from `src` (raw little-endian bytes, row-major, /// Copy a `w`×`h` tile of native pixels from `src` (raw little-endian bytes, row-major,
/// tightly packed) into `dst` at (`dx`, `dy`), clipped to `dst`'s bounds. `src` is read /// tightly packed) into `dst` at (`dx`, `dy`), clipped to `dst`'s bounds. `src` comes
/// with `readInt` because it comes straight out of an IPC message buffer and carries no /// straight out of an IPC message buffer and carries no alignment guarantee, so each
/// alignment guarantee. Returns without touching anything if `src` is short. /// clipped row is a byte-wise `@memcpy` — which equals the old per-pixel little-endian
/// `readInt` on every danos target (all little-endian) without the alignment concern.
/// Returns without touching anything if `src` is short.
pub fn blitTile(dst: Surface, dx: i32, dy: i32, src: []const u8, w: u32, h: u32) void { pub fn blitTile(dst: Surface, dx: i32, dy: i32, src: []const u8, w: u32, h: u32) void {
if (src.len < @as(usize, w) * h * 4) return; if (src.len < @as(usize, w) * h * 4) return;
var ty: u32 = 0; const region = Rect.init(dx, dy, @intCast(w), @intCast(h)).intersect(dst.bounds());
while (ty < h) : (ty += 1) { if (region.isEmpty()) return;
const yy = dy + @as(i32, @intCast(ty)); const span: usize = @intCast(region.w);
if (yy < 0 or yy >= dst.height) continue; const tile_x: usize = @intCast(region.x - dx);
const drow = dst.row(@intCast(yy)); const dst_x: usize = @intCast(region.x);
var tx: u32 = 0; var y: i32 = region.y;
while (tx < w) : (tx += 1) { while (y < region.bottom()) : (y += 1) {
const xx = dx + @as(i32, @intCast(tx)); const tile_y: usize = @intCast(y - dy);
if (xx < 0 or xx >= dst.width) continue; const offset = (tile_y * w + tile_x) * 4;
const off = (@as(usize, ty) * w + tx) * 4; const destination_row = dst.row(@intCast(y)) + dst_x;
drow[@intCast(xx)] = std.mem.readInt(u32, src[off..][0..4], .little); @memcpy(std.mem.sliceAsBytes(destination_row[0..span]), src[offset..][0 .. span * 4]);
}
} }
} }
@@ -179,6 +353,92 @@ test "composite honours the damage rectangle" {
try std.testing.expectEqual(@as(u32, 0), back[4 * 8 + 4]); // outside damage try std.testing.expectEqual(@as(u32, 0), back[4 * 8 + 4]); // outside damage
} }
test "damage list keeps disjoint rectangles separate and merges overlap" {
var list = DamageList{};
list.add(Rect.init(0, 0, 10, 10));
list.add(Rect.init(100, 100, 10, 10)); // far away: its own entry
try std.testing.expectEqual(@as(usize, 2), list.slice().len);
list.add(Rect.init(5, 5, 10, 10)); // overlaps the first: united into it
try std.testing.expectEqual(@as(usize, 2), list.slice().len);
try std.testing.expectEqual(Rect.init(0, 0, 15, 15), list.slice()[0]);
try std.testing.expect(!list.isEmpty());
list.clear();
try std.testing.expect(list.isEmpty());
}
test "damage list folds overflow into the last entry instead of dropping it" {
var list = DamageList{};
var i: i32 = 0;
while (i < DamageList.capacity) : (i += 1) {
list.add(Rect.init(i * 100, 0, 10, 10)); // disjoint: fills every slot
}
try std.testing.expectEqual(@as(usize, DamageList.capacity), list.slice().len);
const overflow = Rect.init(0, 5000, 10, 10);
list.add(overflow);
try std.testing.expectEqual(@as(usize, DamageList.capacity), list.slice().len);
const last = list.slice()[DamageList.capacity - 1];
try std.testing.expect(!last.intersect(overflow).isEmpty()); // still covered
}
test "damage list ignores empty rectangles" {
var list = DamageList{};
list.add(Rect.empty);
try std.testing.expect(list.isEmpty());
}
test "tile grid coalesces a run of adjacent tiles into one rectangle" {
var grid = TileGrid{};
grid.reset(256, 128); // 4×2 tiles of 64 px
grid.add(Rect.init(10, 10, 100, 10)); // spans tiles (0,0) and (1,0)
var scratch: [TileGrid.maximum_rects]Rect = undefined;
const rects = grid.collect(&scratch);
try std.testing.expectEqual(@as(usize, 1), rects.len);
try std.testing.expectEqual(Rect.init(0, 0, 128, 64), rects[0]);
}
test "tile grid: full-screen damage collapses back to a single rectangle" {
var grid = TileGrid{};
grid.reset(1280, 720); // 20×12 tiles; the bottom row is partial (720 = 11*64 + 16)
grid.add(Rect.init(0, 0, 1280, 720));
var scratch: [TileGrid.maximum_rects]Rect = undefined;
const rects = grid.collect(&scratch);
try std.testing.expectEqual(@as(usize, 1), rects.len);
try std.testing.expectEqual(Rect.init(0, 0, 1280, 720), rects[0]);
}
test "tile grid keeps far-apart damage as separate rectangles" {
var grid = TileGrid{};
grid.reset(1280, 720);
grid.add(Rect.init(0, 0, 10, 10)); // top-left tile
grid.add(Rect.init(1000, 600, 10, 10)); // a far-away tile
var scratch: [TileGrid.maximum_rects]Rect = undefined;
const rects = grid.collect(&scratch);
try std.testing.expectEqual(@as(usize, 2), rects.len);
}
test "tile grid clamps edge tiles to the true screen size" {
var grid = TileGrid{};
grid.reset(100, 100); // 2×2 tiles, both partial in each axis
grid.add(Rect.init(0, 0, 100, 100));
var scratch: [TileGrid.maximum_rects]Rect = undefined;
const rects = grid.collect(&scratch);
try std.testing.expectEqual(@as(usize, 1), rects.len);
try std.testing.expectEqual(Rect.init(0, 0, 100, 100), rects[0]);
}
test "tile grid clear empties it and reset resizes it" {
var grid = TileGrid{};
grid.reset(256, 256);
grid.add(Rect.init(0, 0, 256, 256));
try std.testing.expect(!grid.isEmpty());
grid.clear();
try std.testing.expect(grid.isEmpty());
try std.testing.expect(grid.matches(256, 256));
grid.reset(512, 512);
try std.testing.expect(!grid.matches(256, 256));
try std.testing.expect(grid.isEmpty());
}
test "blitTile copies a packed tile, clipping and reading unaligned bytes" { test "blitTile copies a packed tile, clipping and reading unaligned bytes" {
var back = [_]u32{0} ** (4 * 4); var back = [_]u32{0} ** (4 * 4);
const dst = Surface{ .pixels = &back, .stride = 4, .width = 4, .height = 4 }; const dst = Surface{ .pixels = &back, .stride = 4, .width = 4, .height = 4 };
+121 -38
View File
@@ -10,7 +10,9 @@
//! z-order, and visibility. Clients create layers, draw into them by command (`fill_rect`, //! z-order, and visibility. Clients create layers, draw into them by command (`fill_rect`,
//! `blit_tile`), mark `damage`, and ask for a `present`; the compositor repaints only the //! `blit_tile`), mark `damage`, and ask for a `present`; the compositor repaints only the
//! damaged region — clear it, paint the visible layers bottom-to-top into the backend's //! damaged region — clear it, paint the visible layers bottom-to-top into the backend's
//! surface, then `backend.present(damage)`. Shared-memory client surfaces are later //! surface, then `backend.present(damage)`. Presents are paced by a ~60 Hz **frame clock**
//! (see `schedulePresent`), so any number of client presents and cursor moves inside one
//! interval coalesce into a single frame. Shared-memory client surfaces are later
//! (docs/display-v2.md). //! (docs/display-v2.md).
const std = @import("std"); const std = @import("std");
@@ -50,8 +52,10 @@ var pending_modeset_check: bool = false;
var background: u32 = 0; var background: u32 = 0;
/// The layer stack. A fixed table (a compositor has few top-level surfaces during /// The layer stack. A fixed table (a compositor has few top-level surfaces during
/// bring-up); each used slot owns an mmap'd surface. `damage` accumulates the dirty /// bring-up); each used slot owns an mmap'd surface. `damage_list` accumulates the dirty
/// screen region since the last `present`, so a present touches only what changed. /// screen rectangles since the last `present`, so a present touches only what changed —
/// and keeps far-apart changes (the cursor here, an animating layer there) as *separate*
/// small copies rather than one huge bounding box (see compositor.DamageList).
const maximum_layers = 16; const maximum_layers = 16;
const Layer = struct { const Layer = struct {
@@ -65,7 +69,67 @@ const Layer = struct {
}; };
var layers: [maximum_layers]Layer = [_]Layer{.{}} ** maximum_layers; var layers: [maximum_layers]Layer = [_]Layer{.{}} ** maximum_layers;
var damage: Rect = Rect.empty;
/// Which damage tracker drives `present` — a compile-time A/B switch (both are in
/// compositor.zig with the trade-off discussion):
/// .list — free-form dirty rectangles (tight bounds, heuristic merging)
/// .grid — a fixed 64-px tile grid (exact O(1) merging, tile-quantized repaints)
const DamageMode = enum { list, grid };
const damage_mode: DamageMode = .grid;
var damage_list: compositor.DamageList = .{};
var damage_grid: compositor.TileGrid = .{};
/// The **frame clock**: client `present` requests and cursor motion don't repaint
/// immediately — they accumulate damage and arm a one-shot timer, and the tick composites
/// everything pending as one frame. That paces presents to ~60 Hz no matter how fast
/// clients draw or the mouse moves (previously every mouse event became a full present).
/// No backend has a real vblank to pace by (docs/display-v2.md, "Fenced is not vsync");
/// 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.
///
/// 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 {
if (frame_timer_armed) return;
frame_timer_armed = true;
_ = system.timerOnce(service_endpoint, frame_interval_milliseconds);
}
/// A timer landing — the frame clock, or the deferred first native present armed by
/// `attach_scanout`: present the accumulated damage, then run the one-shot mode-set
/// self-check if the native upgrade queued it.
fn frameTick() void {
frame_timer_armed = false;
present();
if (pending_modeset_check) {
pending_modeset_check = false;
modesetSelfCheck();
}
}
// --- geometry helpers ------------------------------------------------------- // --- geometry helpers -------------------------------------------------------
@@ -78,9 +142,20 @@ fn layerScreenRect(l: *const Layer) Rect {
return .{ .x = l.x, .y = l.y, .w = @intCast(l.surface.width), .h = @intCast(l.surface.height) }; return .{ .x = l.x, .y = l.y, .w = @intCast(l.surface.width), .h = @intCast(l.surface.height) };
} }
/// Add `r` (screen coordinates) to the pending damage, clipped to the screen. /// Add `r` (screen coordinates) to the pending damage, clipped to the screen. In grid
/// mode the grid re-sizes itself lazily when the screen geometry changes — every
/// geometry-changing path (`attach_scanout`, `set_mode`) damages the whole new screen
/// right after, so damage pending from the old geometry is safely superseded.
fn addDamage(r: Rect) void { fn addDamage(r: Rect) void {
damage = damage.unite(r.intersect(screenRect())); const clipped = r.intersect(screenRect());
switch (damage_mode) {
.list => damage_list.add(clipped),
.grid => {
const mode = backend.info();
if (!damage_grid.matches(mode.width, mode.height)) damage_grid.reset(mode.width, mode.height);
damage_grid.add(clipped);
},
}
} }
// --- layer operations (called from onMessage and the self-check) ------------ // --- layer operations (called from onMessage and the self-check) ------------
@@ -183,21 +258,29 @@ fn compositeInto(clip: Rect) void {
} }
} }
/// Composite the accumulated damage into the backend's surface, hand it to the backend to /// Composite each accumulated damage rectangle into the backend's surface, hand the list
/// put on screen, then clear the damage. A no-op when nothing is dirty. The frame counter /// to the backend to put on screen, then clear the damage. A no-op when nothing is dirty.
/// advances regardless, so callers can name frames. /// The frame counter advances regardless, so callers can name frames.
fn present() void { fn present() void {
const dirty = damage.intersect(screenRect()); var scratch: [compositor.TileGrid.maximum_rects]Rect = undefined;
if (!dirty.isEmpty()) { const dirty: []const Rect = switch (damage_mode) {
compositeInto(dirty); .list => damage_list.slice(),
.grid => damage_grid.collect(&scratch),
};
const had_damage = dirty.len != 0;
if (had_damage) {
for (dirty) |region| compositeInto(region);
backend.present(dirty); backend.present(dirty);
} }
damage = Rect.empty; switch (damage_mode) {
.list => damage_list.clear(),
.grid => damage_grid.clear(),
}
frames += 1; frames += 1;
// The first present after a native upgrade confirms the composited frame actually reached // 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"). // the shared scanout surface (the automated stand-in for "it's on screen").
if (pending_native_verify and !dirty.isEmpty()) { if (pending_native_verify and had_damage) {
pending_native_verify = false; pending_native_verify = false;
verifyNativePresent(); verifyNativePresent();
} }
@@ -221,7 +304,7 @@ fn verifyNativePresent() void {
/// present channel, switch the backend to virtio-gpu, and queue a full-screen repaint. The /// 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 /// present is deferred to a timer (see `service_endpoint`) so it happens after this reply
/// unblocks the driver and it starts serving `.scanout`. /// 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); const cap = capability orelse return fail(reply);
if (width == 0 or height == 0 or stride < width) 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); const mapped = runtime.shm.map(cap) orelse return fail(reply);
@@ -240,9 +323,11 @@ fn attachScanout(stride: u32, width: u32, height: u32, format: u32, capability:
.width = width, .width = width,
.height = height, .height = height,
.format = format, .format = format,
.refresh_hz = refresh_hz,
.scanout = scanout, .scanout = scanout,
} }; } };
background = protocol.pack(format, 0x20, 0x30, 0x48); // re-pack the wallpaper for the mode 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 addDamage(screenRect()); // the whole new surface must be painted
pending_native_verify = true; pending_native_verify = true;
if (!reattach) pending_modeset_check = true; // the mode-set self-check runs once, on first upgrade if (!reattach) pending_modeset_check = true; // the mode-set self-check runs once, on first upgrade
@@ -257,7 +342,8 @@ fn attachScanout(stride: u32, width: u32, height: u32, format: u32, capability:
/// After the native upgrade is verified, prove the runtime-resolution-change and fenced-present /// After the native upgrade is verified, prove the runtime-resolution-change and fenced-present
/// paths: query the driver's modes, switch to one that differs from the current, re-composite /// paths: query the driver's modes, switch to one that differs from the current, re-composite
/// the whole screen at the new size, and confirm the backend now reports that geometry. The /// the whole screen at the new size, and confirm the backend now reports that geometry. The
/// present goes through the driver's fenced flush, so a clean present is a vsync present. /// present goes through the driver's fenced flush, so a clean present is a *fenced* present —
/// completion-acknowledged and tear-free, not vblank-paced (docs/display-v2.md).
fn modesetSelfCheck() void { fn modesetSelfCheck() void {
if (!backend.canModeSet()) return; if (!backend.canModeSet()) return;
var mode_list: [4]backend_mod.Mode = undefined; var mode_list: [4]backend_mod.Mode = undefined;
@@ -289,7 +375,7 @@ fn modesetSelfCheck() void {
if (now.width == wanted.width and now.height == wanted.height) { if (now.width == wanted.width and now.height == wanted.height) {
var line: [80]u8 = undefined; var line: [80]u8 = undefined;
_ = system.write(std.fmt.bufPrint(&line, "display: mode set to {d}x{d}, verified\n", .{ now.width, now.height }) catch "display: mode set, verified\n"); _ = system.write(std.fmt.bufPrint(&line, "display: mode set to {d}x{d}, verified\n", .{ now.width, now.height }) catch "display: mode set, verified\n");
if (backend.hasVsync()) _ = system.write("display: vsync present ok\n"); if (backend.hasFencedPresent()) _ = system.write("display: fenced present ok\n");
} else { } else {
_ = system.write("display: mode set FAILED (geometry unchanged)\n"); _ = system.write("display: mode set FAILED (geometry unchanged)\n");
} }
@@ -443,15 +529,15 @@ fn mouseListener(width: u32, height: u32) void {
} }
} }
/// Consume the latest cursor position from the channel and repaint the cursor layer at /// Consume the latest cursor position from the channel and move the cursor layer to it.
/// it. Runs on the main loop (the compositor owner) in response to a listener poke. /// Runs on the main loop (the compositor owner) in response to a listener poke.
/// `configureLayer` damages both the old and new footprints, so a plain `present` /// `configureLayer` damages both the old and new footprints; the frame clock presents
/// repaints exactly the two rectangles that changed. /// them at the next tick, so a fast mouse coalesces to at most ~60 repaints a second.
fn renderCursor() void { fn renderCursor() void {
const snapshot = cursor_channel.take() orelse return; const snapshot = cursor_channel.take() orelse return;
const id = cursor_layer orelse return; const id = cursor_layer orelse return;
_ = configureLayer(id, snapshot.x, snapshot.y, cursor_z, true); _ = configureLayer(id, snapshot.x, snapshot.y, cursor_z, true);
present(); schedulePresent();
if (!cursor_tracking_reported and if (!cursor_tracking_reported and
@abs(snapshot.x - cursor_origin_x) >= cursor_report_threshold and @abs(snapshot.x - cursor_origin_x) >= cursor_report_threshold and
@abs(snapshot.y - cursor_origin_y) >= cursor_report_threshold) @abs(snapshot.y - cursor_origin_y) >= cursor_report_threshold)
@@ -500,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", .{ _ = system.write(std.fmt.bufPrint(&line, "display: online {d}x{d} pitch {d} format {d}\n", .{
mode.width, mode.height, mode.pitch, mode.format, mode.width, mode.height, mode.pitch, mode.format,
}) catch "display: online\n"); }) catch "display: online\n");
updateFrameClock();
_ = system.write("display: presented frame 0\n"); _ = system.write("display: presented frame 0\n");
selfCheck(); selfCheck();
@@ -561,11 +648,13 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Han
return ok(reply); return ok(reply);
}, },
@intFromEnum(protocol.Operation.present) => { @intFromEnum(protocol.Operation.present) => {
present(); // Scheduled, not immediate: the frame clock composites the accumulated damage
// at the next tick, so back-to-back client presents coalesce into one frame.
schedulePresent();
return ok(reply); return ok(reply);
}, },
@intFromEnum(protocol.Operation.attach_scanout) => { @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) => { @intFromEnum(protocol.Operation.set_mode) => {
if (!backend.setMode(request.width, request.height)) return fail(reply); if (!backend.setMode(request.width, request.height)) return fail(reply);
@@ -591,21 +680,15 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Han
} }
} }
/// Two notification sources reach the compositor. A **message-notification** is a poke /// Two notification sources reach the compositor, and one coalesced badge can carry
/// from the mouse-listener thread (a buffered self-`ipc.send`, `notify_message_bit`): /// both, so each bit is handled independently. A **message-notification** is a poke from
/// repaint the cursor at its latest channel position. Anything else is the post-attach /// the mouse-listener thread (a buffered self-`ipc.send`, `notify_message_bit`): fold the
/// present **timer**: repaint into the freshly attached native surface, verify the frame /// newest cursor position into the scene. A **timer** (`notify_timer_bit`) is the frame
/// landed, then run the one-shot mode-set self-check (V5). /// clock — or the deferred first native present after `attach_scanout` — either way,
/// present the accumulated damage.
fn onNotification(badge: u64) void { fn onNotification(badge: u64) void {
if (badge & ipc.notify_message_bit != 0) { if (badge & ipc.notify_message_bit != 0) renderCursor();
renderCursor(); if (badge & ipc.notify_timer_bit != 0) frameTick();
return;
}
present(); // native present + verify (first timer fire after the upgrade)
if (pending_modeset_check) {
pending_modeset_check = false;
modesetSelfCheck();
}
} }
pub fn main() void { pub fn main() void {
+7 -5
View File
@@ -24,11 +24,13 @@ pub const Operation = enum(u32) {
damage = 6, damage = 6,
/// present(): composite the dirty layers and flush to the screen. /// present(): composite the dirty layers and flush to the screen.
present = 7, present = 7,
/// attach_scanout(x=stride, width, height, colour=format) + <surface capability>: a native /// attach_scanout(x=stride, y=refresh_hz, width, height, colour=format) + <surface
/// scanout driver announces itself, handing over the shared scanout surface as an `ipc_call` /// capability>: a native scanout driver announces itself, handing over the shared scanout
/// send_cap. The compositor maps it, looks up the driver's `.scanout` present channel, and /// surface as an `ipc_call` send_cap. The compositor maps it, looks up the driver's
/// upgrades off the GOP floor (docs/display-v2.md V4). `x` is the surface's row stride in /// `.scanout` present channel, and upgrades off the GOP floor (docs/display-v2.md V4).
/// pixels, `colour` the DisplayFormat. /// `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, attach_scanout = 8,
/// set_mode(width, height): change the display resolution — only a native backend that /// 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). /// reports `canModeSet` honours it; on the GOP floor it fails (docs/display-v2.md V5).
+6 -5
View File
@@ -221,15 +221,16 @@ CASES = [
# require all three markers to appear somewhere rather than in a fixed order. # 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)", "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"}, "fail": r"display: native present FAILED|display: could not|display-demo: (no display|create failed)|CPU EXCEPTION|KERNEL PANIC"},
# Mode-set + EDID + vsync (v2 V5): same boot as display-native. After upgrading, the # Mode-set + EDID + fenced presents (v2 V5): same boot as display-native. After upgrading,
# compositor queries the driver's modes, switches to a different resolution, and confirms the # the compositor queries the driver's modes, switches to a different resolution, and confirms
# backend now reports it; the fenced present path makes it a vsync present. (The driver also # the backend now reports it; each present is fenced — completion-acknowledged and tear-free,
# logs the EDID preferred mode during bring-up.) Reuses the display-native kernel scenario. # not vblank-paced (docs/display-v2.md, "Fenced is not vsync"). (The driver also logs the
# EDID preferred mode during bring-up.) Reuses the display-native kernel scenario.
{"name": "display-modeset", {"name": "display-modeset",
"build_case": "display-native", "build_case": "display-native",
"qemu_extra": ["-device", "virtio-gpu-pci"], "qemu_extra": ["-device", "virtio-gpu-pci"],
"mem": "512M", "mem": "512M",
"expect": r"(?s)(?=.*display: mode set to \d+x\d+, verified)(?=.*display: vsync present ok)", "expect": r"(?s)(?=.*display: mode set to \d+x\d+, verified)(?=.*display: fenced present ok)",
"fail": r"display: mode set FAILED|display: mode-set self-check: |display: native present FAILED|CPU EXCEPTION|KERNEL PANIC"}, "fail": r"display: mode set FAILED|display: mode-set self-check: |display: native present FAILED|CPU EXCEPTION|KERNEL PANIC"},
# Resilience: driver restart + re-attach (v2 V6). device-manager (in test-scanout-restart # Resilience: driver restart + re-attach (v2 V6). device-manager (in test-scanout-restart
# mode) kills the virtio-gpu driver once after it hellos; the restart policy respawns it, it # mode) kills the virtio-gpu driver once after it hellos; the restart policy respawns it, it