Merge claude/display-service-arch-a42261: display service v1 — framebuffer compositor (D1-D5)

A user-space display service: owns the write-combining framebuffer, composites a
z-ordered layer stack into a cacheable back buffer, presents only the damaged
region, and is driven over IPC by runtime.display. Proven end to end by the
display-demo client. Kernel changes: a display device node + write-combining
mmio_map, and a page-by-page mmap that lifts the 1 MiB per-call cap. Deferred by
design: shared-memory client surfaces, a native mode-setting backend, and vsync.
This commit is contained in:
Daniel Samson 2026-07-14 07:57:43 +01:00
commit 88ed3c5417
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
21 changed files with 1757 additions and 32 deletions

View File

@ -348,6 +348,13 @@ pub fn build(b: *std.Build) void {
// The block protocol, so runtime.block (the block-device client) can speak it.
runtime_module.addImport("block-protocol", block_protocol_module);
// The display protocol, so runtime.display (the compositor client) and the display
// service both speak it through the runtime, like the other protocol modules.
const display_protocol_module = b.addModule("display-protocol", .{
.root_source_file = b.path("system/services/display/protocol.zig"),
});
runtime_module.addImport("display-protocol", display_protocol_module);
// The power protocol: system power's domain-named surface (docs/power.md).
const power_protocol_module = b.addModule("power-protocol", .{
.root_source_file = b.path("system/services/power/protocol.zig"),
@ -459,6 +466,8 @@ pub fn build(b: *std.Build) void {
// The FAT filesystem server: mounts the block device and serves it into the VFS
// at /mnt/usb. Its engine (engine.zig / on-disk.zig) is imported relatively.
const fat_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "fat", "system/services/fat/fat.zig");
const display_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "display", "system/services/display/display.zig");
const display_demo_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "display-demo", "system/services/display-demo/display-demo.zig");
const fat_test_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "fat-test", "system/services/fat/fat-test.zig");
const pci_bus_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "pci-bus", "system/drivers/pci-bus/pci-bus.zig");
// The PCI bus driver decodes each function's class triple to human names in its
@ -526,6 +535,10 @@ pub fn build(b: *std.Build) void {
mk_run.addFileArg(fat_exe.getEmittedBin());
mk_run.addArg("fat-test");
mk_run.addFileArg(fat_test_exe.getEmittedBin());
mk_run.addArg("display");
mk_run.addFileArg(display_exe.getEmittedBin());
mk_run.addArg("display-demo");
mk_run.addFileArg(display_demo_exe.getEmittedBin());
mk_run.addArg("pci-bus");
mk_run.addFileArg(pci_bus_exe.getEmittedBin());
mk_run.addArg("crash-test");
@ -563,6 +576,7 @@ pub fn build(b: *std.Build) void {
.{ usb_hid_mouse_exe, "system/drivers" },
.{ usb_storage_exe, "system/drivers" },
.{ fat_exe, "system/services" },
.{ display_exe, "system/services" },
.{ log_flush_exe, "system/services" },
}) |entry| {
const step = b.addInstallArtifact(entry[0], .{ .dest_dir = .{ .override = .{ .custom = entry[1] } } });
@ -759,6 +773,8 @@ pub fn build(b: *std.Build) void {
"system/services/vfs/protocol.zig", // NodeKind / DirectoryEntry sizes + op values
"system/services/fat/on-disk.zig", // FAT on-disk struct sizes + type detection
"system/services/fat/engine.zig", // FAT read/write over a RAM-backed image
"system/services/display/compositor.zig", // Rect math + fill/composite/blit-tile
"system/services/display/protocol.zig", // pack(): native pixel encoding per format
}) |root| {
const mod_tests = b.addTest(.{
.root_module = b.createModule(.{

View File

@ -69,7 +69,12 @@ rather than restate it. Roughly in the order things happen at runtime:
mouse, joystick): why a synchronous rendezvous can't fan out to many listeners, the
asynchronous `ipc_send` primitive built to fix it, and the per-device subscribe/publish
service layered on top.
19. **[halting.md](halting.md) — halting.** Why a kernel can't just "exit", and
19. **[display.md](display.md) — the display service.** The display half of the GUI
track: a user-space compositor that owns the framebuffer, composes a layer stack into
a double buffer, and presents it. Why GOP and the PCI display device are two views of
one controller, the device-node + write-combining handoff, and what flicker-free buys
that tear-free doesn't. Plan: [display-plan.md](display-plan.md).
20. **[halting.md](halting.md) — halting.** Why a kernel can't just "exit", and
how `while (true) hlt` parks the CPU safely once there's nothing left to do.
Start with the north star:

181
docs/display-plan.md Normal file
View File

@ -0,0 +1,181 @@
# Display service — build plan (v1: the dumb-framebuffer compositor)
The ordered, checkpointable build-out for [display.md](display.md). Each milestone is
small, lands on its own, and ends in a **verifiable gate** — shaped for a `/loop` run.
Read [display.md](display.md) first for the *why*; this is the *what* and the *order*.
## Locked decisions (do not relitigate)
- **Handoff = device node + write-combining `mmio_map`.** The kernel seeds a synthetic
`display0` node from `BootInformation.framebuffer`; the service claims + WC-maps it.
(Not a bespoke `framebuffer_map` syscall — the device route inherits ownership,
release-on-death, and re-claim-on-restart.)
- **v1 = the full compositor pipeline on the dumb framebuffer.** One `display` service
owns the LFB + a cacheable back buffer + a layer stack; double-buffer + damage-driven
present; clients draw via server-side commands. **No** runtime mode-setting, **no**
shared-memory surfaces — both deferred (see display.md, "What v1 does not do").
## Conventions
Follow [coding-standards.md](coding-standards.md): spell out non-acronym abbreviations in
full, kebab-case file names, no `Co-Authored-By` trailers on commits. New user binaries
go through `addUserBinary` in [build.zig](../build.zig) and get packed into the
initial-ramdisk; protocols are `b.addModule("…-protocol", …)` and imported into the
`runtime` module.
## How to verify along the way
- `zig build test` — host unit tests (compositor math: layer clipping, damage merge,
pitch/format blits are all host-testable with a fake framebuffer).
- `python3 test/qemu_test.py <case>` — boots the real kernel in QEMU; assert on the
serial log ([tests.zig](../system/kernel/tests.zig) is the registry).
- The `run-efi` target renders to QEMU's display (`-device VGA,edid=on,xres=1280,yres=720`)
— a screenshot confirms pixels for the milestones whose gate is visual.
---
## D1 — The handoff primitive (kernel) ✅
Make the boot framebuffer reachable and mappable **write-combining** from user space.
- [x] [device-abi.zig](../system/devices/device-abi.zig): added `DeviceClass.display`; a
`DisplayInfo{ width, height, pitch, format }` carried on the descriptor; a
`flags` field on `ResourceDescriptor` + `resource_flag_write_combining`.
- [x] [devices-broker.zig](../system/kernel/devices-broker.zig): `seedDisplay(base, w, h,
pitch, format)` publishes a root-level `display` node with one WC-flagged `memory`
resource `[base, height*pitch]` + the `DisplayInfo`; `displayDevice()` /
`displayClaimed()`. Seeded from `kmain` after `devices_broker.init`.
- [x] [process.zig](../system/kernel/process.zig) `systemMmioMap` + paging
(`mapUserDeviceInto` gains a `write_combining` bool): a resource's WC flag maps it
through the WC PAT slot (`setupPat`) instead of strong-uncacheable.
- [x] [console.zig](../system/kernel/console.zig): `setSuppressed` quiesces `write` while
the display device is claimed (driven from `systemDeviceClaim` / release); the
terminal panic + exception paths clear it first so a dying machine still draws.
**Gate (met, automated):** the `display` kernel test (`python3 test/qemu_test.py display`,
`displayTest` in [tests.zig](../system/kernel/tests.zig)) asserts the seeded node's shape
and geometry, then walks the real claim + `mmio_map` path into a throwaway address space
and verifies the leaf is **write-combining** (PAT entry 4: PAT bit set, PCD/PWT clear) —
with an uncacheable-still-uncacheable regression guard. Chosen over the original
screenshot-of-a-fill gate because it proves the *actual* WC property headlessly; the
visible fill folds into D2's gate (the service clears the screen through the back buffer).
Regression-checked: `discovery`, `ioport`, `claim-release`, `supervision`, `device-list`,
`device-manager` all still pass with the +1 device in the table.
## D2 — Service skeleton, protocol, runtime module ✅
Stand up the named service and the double-buffer, no layers yet.
- [x] `system/services/display/protocol.zig`: `Operation{ info, create_layer,
configure_layer, destroy_layer, fill_rect, blit_tile, damage, present }`; `extern`
`Request`/`Reply`; size + `maximum_payload` consts. (Model: block/protocol.zig.)
- [x] [abi.zig](../system/abi.zig): `ServiceId.display = 9`.
- [x] `system/services/display/display.zig`: `main` → enumerate + claim + WC-map the LFB
(front) → `mmap` a cacheable back buffer of `height*pitch``runtime.service.run`.
`info` and a whole-screen `present` (back → front) are live; layer ops fail-stub
until D3. Init clears the back buffer and presents it — the double-buffer path.
- [x] [library/runtime/display.zig](../library/runtime/runtime.zig) (+ barrel export of
`display` and `display_protocol`): `info()` and `present()`, cached `.display`
lookup with retry (model: block.zig).
- [x] [init.zig](../system/services/init/init.zig): `"display"` added to `boot_services`.
- [x] [build.zig](../build.zig): `display-protocol` module on the runtime; `display` exe
via `addUserBinary`; packed into the initial-ramdisk; installed to
`/system/services/display`.
- [x] **Kernel fix the back buffer surfaced:** `mmap` was capped at 256 pages (1 MiB) by
a fixed kernel-stack `frames` array. Rewrote `systemMmap` to map page-by-page with
rollback (no scratch array) and raised the cap to 8192 pages (32 MiB) — enough for a
4K back buffer. A real limitation met, exactly the kind this project chases.
**Gate (met, automated):** `python3 test/qemu_test.py display-service` spawns the
compositor and matches its own serial heartbeats — `display: online {w}x{h} pitch …`
followed by `display: presented frame 0` — which it prints only after the whole
claim → WC-map → back-buffer → clear → present chain succeeds (matched on serial like the
fault cases, since a lone blocking service can't reschedule the in-kernel test context to
poll). Regression-checked: `usermem`, `heap` (the `mmap` rewrite), `init` (the boot-list
addition), and D1's `display` all still pass.
## D3 — Layer stack + compositor + damage present ✅
The heart: composite an ordered layer stack, present only what changed.
- [x] A layer table (16 slots): each `Layer` = position, z, visible, a server-owned
`mmap`'d surface (freed on `destroy_layer`). `damage` accumulates the dirty screen
region since the last present.
- [x] `create_layer` / `configure_layer` (damages old + new footprints) / `destroy_layer`,
`fill_rect`, `blit_tile` (reads the inline tile from the IPC payload, unaligned-safe),
`damage`, `present`.
- [x] Pure, host-tested [compositor.zig](../system/services/display/compositor.zig): `Rect`
(intersect/unite), `Surface`, `fillRect`, `composite` (opaque, clipped to a damage
rect), `blitTile`. `present` clears the damaged region to the wallpaper, paints the
visible layers bottom-to-top (z-sorted), and flushes just that rect back → front (WC).
Colour packing (rgbx/bgrx) is `protocol.pack`, also host-tested.
- [x] Host tests (`zig build test`, green): rect intersect/unite, `fillRect` clipping +
`stride > width` padding, `composite` overlap-shows-top + damage clipping, `blitTile`
unaligned read + clipping, and `pack` for both pixel formats.
**Gate (met):** `zig build test` green for the compositor + pack unit tests, **and** the
`display-service` case's startup self-check composites two overlapping layers on the real
framebuffer and reads back the composited pixels — overlap = top layer, outside = bottom
layer — logging `display: compositor self-check ok` (matched by the harness).
## D4 — Client API + the demo client ✅
Prove the pipeline end-to-end from a separate process.
- [x] Finished [runtime/display.zig](../library/runtime/runtime.zig): a `Layer` handle with
`fill` / `blitTile` (inline tile) / `configure` (move/restack/show) / `damage` /
`destroy`, `createLayer`, and a `color(r,g,b)` helper (caches the mode, packs via
`protocol.pack`). Coordinates are signed over the wire (`@bitCast` both ways).
- [x] `system/services/display-demo/`: a hardware-free client (the `input-source` analog)
— a full-screen wallpaper layer, a rectangle that slides back and forth (moved by
`configure` each frame, so the compositor repaints old + new), and a cursor layer;
presents in a loop paced by `runtime.time`. Wired into build + initial-ramdisk.
- [x] **Bug this surfaced:** `protocol.message_maximum` was 4096, but the kernel caps
every IPC message at `MESSAGE_MAXIMUM` = 256 — so `replyWait` rejected the oversized
receive buffer with `-E2BIG` and the serve loop had been *spinning* since D2 (unseen,
as D2/D3 matched init-time heartbeats). Set it to 256; `blit_tile` is now explicitly
a small-tile path (≤ 54 px inline), larger bitmaps being the deferred shm surface.
**Gate (met):** `python3 test/qemu_test.py display-demo` spawns the service + `display-demo`;
the demo drives a run of frames of motion through the layer client API and logs
`display-demo: ok` (the visible motion is a screenshot via `zig build run-x86-64`).
Regression-checked: `zig build test`, `display` (D1), and `display-service` (D2/D3) all
still pass, and the default `zig build` is clean.
## D5 — Test cases + docs ✅
- [x] The three integration cases exist and pass: `display` (D1 handoff, kernel),
`display-service` (D2/D3 compositor + self-check), and `display-demo` (D4 full
pipeline: spawn `display` + `display-demo`, match `display-demo: ok`) —
[tests.zig](../system/kernel/tests.zig) + [qemu_test.py](../test/qemu_test.py). Plus
the pure host tests (`zig build test`).
- [x] [display.md](display.md) updated to the built state (the "Verifying it" section names
the real cases); [README index](README.md) entry present (#19); the `display-track`
memory marked DONE with the commits.
**Gate (met):** `python3 test/qemu_test.py display display-service display-demo` all pass,
`zig build test` is green, and the default `zig build` is clean.
---
## v1 status: complete
D1D5 done. The display service is a working framebuffer compositor: it owns the
framebuffer (write-combining), composites a z-ordered layer stack into a cacheable back
buffer, presents only the damaged region, and is driven over IPC by the `runtime.display`
client — proven end-to-end by a separate demo process. Two limitations are deliberate and
documented (docs/display.md): no runtime mode-setting (native backend) and no true vsync
(no vblank on a dumb framebuffer). Next steps are the Deferred items below.
---
## Deferred (explicitly not in this plan)
- **Shared-memory surfaces** — generalize M13 capability passing to memory objects
(`shm_create`/`shm_map`), so bitmap clients hand the compositor a rendered surface
instead of drawing commands. The compositor's layer model already anticipates it.
- **Native backend (Bochs DISPI, then virtio-gpu)** — behind the same internal backend
interface as the dumb framebuffer: EDID mode list + runtime resolution/bpp change +
(eventually) a vblank/flip path for true vsync.
- **Driver/compositor process split** — only when a second backend or a second head makes
the abstraction pay for itself.

264
docs/display.md Normal file
View File

@ -0,0 +1,264 @@
# The display service: a framebuffer compositor
The [framebuffer](framebuffer.md) the loader hands over is a flat block of pixel
memory, and the kernel's [bootstrap console](../system/kernel/console.zig) draws text
into it directly. That console is a stop-gap. The **display service**
(`system/services/display/`) is the real thing: an ordinary ring-3 process that *owns*
the framebuffer, composes a stack of **layers** into an off-screen back buffer, and
**presents** finished frames to the screen — the display half of the GUI track
([vision.md](vision.md)), the sibling of the [input service](input.md).
This note is the architecture and the reasoning behind it. The concrete build order
lives in [display-plan.md](display-plan.md).
## First, a distinction that shapes everything: GOP vs. the PCI device
It is tempting to think "the GOP framebuffer" and "the VGA-compatible display
controller in the PCIe tree" are two different things. They are not — they are **two
interfaces to the same silicon, at different times and different levels**, and knowing
which one you're holding decides what you can do.
- **GOP is firmware's *temporary* driver** for the display controller. It gives you a
linear framebuffer pointer and can set video modes — but only until
`ExitBootServices`. The loader already leans on this: [`queryFramebuffer`](../boot/efi.zig)
reads the monitor's EDID, picks the native mode, and calls `set_mode` **before**
exiting ([gop.md](gop.md)). Once the kernel runs, GOP is **gone** — no `set_mode`, no
mode list, no EDID. What survives is the frozen snapshot in
[`BootInformation.framebuffer`](../system/boot-handoff.zig): `{base, width, height,
pitch, format}`, and nothing more.
- **The PCI class-0x03 device is the raw controller** — BARs, config space, registers,
IO ports. It is what you actually *own* after boot. On QEMU's emulated adapter
([`-device VGA,edid=on`](../build.zig), the Bochs VBE/DISPI model) the `base` GOP handed
you *is* that device's linear-framebuffer BAR — the same physical memory, seen through
a different door. On a real discrete GPU, GOP's `base` is an aperture inside the GPU's
VRAM BAR. danos already decodes this device
([pci-class.zig](../system/devices/pci-class.zig) has the full `display` namespace, and
`pci-bus` already reports it to the [device manager](device-manager.md) with its class
triple) — but nothing binds it yet.
What that difference costs you, concretely:
| You want to… | Dumb GOP framebuffer (boot handoff) | Native device driver (PCI 0x03) |
|-------------------------------------------|-------------------------------------|------------------------------------------|
| **Report** the current mode | ✅ from the handoff | ✅ |
| **Change resolution / bpp at runtime** | ❌ GOP is gone | ✅ program DISPI regs / virtio-gpu queue |
| **Re-read EDID, enumerate monitor modes** | ❌ | ✅ the device exposes an EDID block |
| **Refresh rate** | ❌ (virtual anyway) | only a real KMS driver — far future |
| **vblank / tear-free present** | ❌ no vblank signal | ✅ vblank IRQ + page-flip (real GPUs) |
| **Works on the Pi (no PCI VGA)** | ✅ VideoCore hands a simple FB | ✗ per-device |
The lesson: the **portable base for the whole GUI stack is the GOP / boot-handoff linear
framebuffer**. Runtime mode-setting is a *per-device upgrade* layered on top — and on
the Raspberry Pis there is no PCI VGA at all, so the neutral framebuffer is the only
thing all three target machines share. That is why the display service is built on the
dumb framebuffer first, with the native backend as an optional module behind the same
interface.
## Two constraints this service exists to meet
Like the input service — which existed partly to motivate the asynchronous
[`ipc_send`](ipc.md) primitive — the display service runs straight into two limits the
rest of the system hasn't had to face:
1. **The framebuffer is kernel-only today.** It arrives through the boot handoff, is
mapped into the kernel's physmap, and is touched only by
[`console.zig`](../system/kernel/console.zig). It is *not* a
[devices-broker](../system/kernel/devices-broker.zig) node, so
`device.claim`/`mmio_map` cannot reach it, and there is no framebuffer
[syscall](syscall.md). A user-space display service needs a **new mechanism just to
touch the pixels**. (See "The handoff" below — this is built.)
2. **danos has no cross-process shared memory.** The memory syscalls are `mmap`
(private, zeroed), `mmio_map` (a *claimed device's* MMIO), and `dma_alloc` (new
pinned physical). The block driver's "pass a buffer by physical address" trick
([block/protocol.zig](../system/services/block/protocol.zig)) works *only because its
consumer is DMA hardware*. A compositor that CPU-reads and blends client layers can't
use it — it would have to *map* another process's memory, which nothing allows. This
is deferred (see "What v1 does not do"), because v1 sidesteps it entirely.
## Architecture
```
kernel ── owns the boot framebuffer; bootstrap console only
│ seeds a "display0" device node from BootInformation.framebuffer
│ (ResourceKind.memory = [base, height*pitch], write-combining hint,
│ plus DisplayInfo{width, height, pitch, format})
display service (system/services/display/, ServiceId.display) ← the compositor
│ device.claim(display0) → mmio_map(WRITE-COMBINING) = FRONT buffer (the LFB)
│ mmap(cacheable) a BACK buffer of the same geometry
│ owns: an ordered LAYER STACK + a per-frame DAMAGE list
│ loop: composite dirty layers → back buffer → present dirty rects → front
│ backend is an INTERNAL interface: {gop-fb} today; {bochs-dispi, virtio-gpu} later
▼ reached by name (ipc_lookup); clients drive it over the display protocol
┌────────────────────────────────────┬──────────────────────────────────────┐
drawing clients (v1) surface clients (deferred)
runtime.display commands: runtime.display surfaces:
create_layer / configure_layer shm_create → pass as a capability →
fill_rect / blit_tile / damage the compositor maps & composites the
present client-rendered bitmap directly
```
The bring-up sequence mirrors a hardware driver's — it is the
[`usb-xhci-bus` `initialise`](../system/drivers/usb-xhci-bus/usb-xhci-bus.zig) shape
(claim → `mmio_map` → run loop) — and the request/reply service shell is the
[FAT](../system/services/fat/fat.zig) / [input](../system/services/input/input.zig) shape
([`runtime.service.run`](../library/runtime/service.zig) with a `protocol.zig` of
`extern struct` messages and an `Operation` tag).
**One process, for now.** v1 is a *single* service that both owns the framebuffer and
composites — it does not split a "framebuffer driver" from a "compositor" the way input
splits `ps2-bus` from the input service. The backend (dumb FB vs. a native GPU) is an
*internal* interface, not a process boundary. That boundary earns its keep only when a
second backend or a second monitor appears; until then it is complexity with no payoff.
## The handoff: a device node + a write-combining map
The framebuffer crosses into user space through the machinery that already exists for
every other device, rather than a bespoke syscall — so it inherits ownership,
release-on-death, and re-claim-on-restart for free (the [resilience](resilience.md)
story: a crashed display service returns the LFB to the kernel, and its restart
re-claims it).
- The kernel seeds a synthetic **`display0`** node into the
[devices-broker](../system/kernel/devices-broker.zig) at init, from
`BootInformation.framebuffer`: one `ResourceKind.memory` resource spanning
`[base, height*pitch]`, tagged **write-combining**, plus a small
`DisplayInfo{width, height, pitch, format}` (the memory resource says *where* and *how
big*; `DisplayInfo` says how to *interpret* the bytes).
- The service `device.claim`s it and `mmio_map`s the resource. The map is
**write-combining**, not the strong-uncacheable that `mmio_map` uses for register
MMIO. The kernel already programs a WC PAT slot for its own console
([`setupPat`](../system/kernel/architecture/x86_64/paging.zig)); this reaches it from
the user mapping path. **This matters:** an uncacheable framebuffer makes the
back→front blit unusably slow.
- On `claim`, the kernel's bootstrap console goes quiet, so the two never fight over the
LFB. A panic is the one exception — by then the service is likely dead anyway, and a
panic on screen wins.
The display service is a **named boot service**: `init` spawns it by name alongside
`vfs`/`input`/`device-manager` ([init.zig](../system/services/init/init.zig)), and it
self-discovers `display0` with `device.enumerate`. The [device manager](device-manager.md)
matching path (PCI class 0x03 → a driver) is reserved for the future *native* backend, not
this singleton synthetic node.
## Double buffering and the write-combining discipline
Two buffers, with deliberately different memory types:
- The **front buffer** is the LFB — **write-combining**: fast to *write*, slow to
*read*. The rule is therefore **never read the front buffer**. Only ever stream into
it, sequentially.
- The **back buffer** is ordinary **cacheable** RAM (`mmap`), the same geometry. All
compositing happens here, where reads and read-modify-write blends are cheap.
So a frame is: compose every dirty layer into the cacheable back buffer, then **present**
— copy the changed regions back→front in sequential, WC-friendly writes. Two details the
[framebuffer](framebuffer.md) note already establishes carry over: step rows by `pitch`,
not `width*4`; and handle both `rgbx` and `bgrx` [pixel formats](gop.md).
## Flicker vs. tearing — what double buffering does and doesn't buy
These are two different artifacts, and the dumb framebuffer fixes exactly one of them:
- **Flicker** is the user seeing intermediate, half-drawn states (a clear-then-redraw
flash). Double buffering **eliminates it completely** — the screen only ever receives
whole, finished frames.
- **Tearing** is a present landing while the display's scanout beam is mid-frame, so the
top of the screen shows the new frame and the bottom the old. Avoiding it requires
presenting during the vertical blank (**vsync**) — which needs a vblank signal. **A
dumb GOP framebuffer has no vblank.**
So v1 is **flicker-free**, and it *minimizes* the tear window by presenting only damaged
rectangles (less to copy → a smaller window in which the beam can catch a half-updated
frame), but it is **not tear-free**. Genuine vsync waits for a backend with a vblank IRQ
or a flush/flip path — a native-device capability, not something the firmware
framebuffer can offer. Stated plainly here so the limitation is understood, not
discovered.
## Layers and the client protocol
The compositor holds an **ordered stack of layers**. Each layer has a rectangle, a
z-order, a visibility flag, and a surface. Presenting walks the stack bottom-to-top,
painting each dirty layer into the back buffer, then flushes the damage to the front.
In v1 the surfaces are **server-owned**, and clients draw into them with a small
immediate-mode command protocol — essentially the model early X used, and enough for a
shell, a terminal, a cursor, and a wallpaper:
| Operation | Meaning |
|--------------------|---------------------------------------------------------------|
| `info` | report `{width, height, pitch, format}` of the display |
| `create_layer` | allocate a server-owned surface, return a layer handle |
| `configure_layer` | set a layer's rect, z-order, visibility |
| `destroy_layer` | release a layer |
| `fill_rect` | fill a rectangle of a layer with a colour |
| `blit_tile` | copy a small client-supplied pixel tile into a layer (inline) |
| `damage` | mark a region of a layer dirty |
| `present` | composite dirty layers and flush to the screen |
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
client). Keeping the protocol to rectangles and tiles keeps the compositor small and the
policy in the client.
## `runtime.display`
Clients speak the protocol through a new [`library/runtime/display.zig`](../library/runtime/runtime.zig),
the [`runtime.block`](../library/runtime/block.zig) shape (a cached `.display` lookup
with a boot-race retry): `display.info()`, a `Layer` handle with `fill` / `blitTile` /
`damage`, and `present()`. Application code never issues the raw syscalls — it calls the
runtime, as with every other danos service.
## What v1 does not do (and why that's fine)
Two capabilities are deliberately out of the first cut. Neither reshapes anything above;
both are clean additions behind the interfaces v1 establishes.
- **Client-rendered surfaces (shared memory).** The fast path for a bitmap-heavy app is
to render into its *own* buffer and hand the compositor a *reference*, not a stream of
commands. That needs the missing cross-process shared-memory primitive — best built as
the natural generalization of the existing M13 [capability passing](driver-model.md)
from *endpoints* to *memory objects* (`shm_create(len) → {cap, vaddr}`, pass `cap` on
an `ipc_call`, receiver `shm_map(cap) → vaddr`). v1 avoids it because server-owned
surfaces already prove the whole pipeline.
- **Runtime mode-setting (a native backend).** Detecting the EDID mode list and changing
resolution / bpp at runtime needs the raw PCI device. The first native backend is
Bochs DISPI — the register interface QEMU's `-device VGA` exposes — behind the same
internal backend interface the dumb framebuffer sits behind. Refresh-rate and colour
management (a gamma LUT) are real-GPU-KMS territory, far beyond this.
## Verifying it
Three QEMU test cases ([tests.zig](../system/kernel/tests.zig), `python3
test/qemu_test.py <case>`), each layering on the last:
- **`display`** — the kernel handoff: the seeded `display` device is shaped correctly and
the claim → `mmio_map` leaf is genuinely **write-combining** (PAT entry 4), asserted at
the page-table level.
- **`display-service`** — the compositor comes up: it claims the framebuffer, allocates
the cacheable back buffer, presents a cleared frame through the double-buffer path
(`display: online … / presented frame 0`), and a startup **self-check** composites two
overlapping layers on the real framebuffer and reads them back — overlap = the top
layer — logging `display: compositor self-check ok`.
- **`display-demo`** — the full pipeline from a separate process: the hardware-free
[`display-demo`](../system/services/display-demo/) client (the
[`input-source`](../system/services/input-source/) analog) drives layers — a wallpaper, a
sliding rectangle, a cursor — through the layer client API and heartbeats
`display-demo: ok`, proving a frame travelled client → compositor → screen, exactly as
the [input test](input.md) proves an event travels source → service → subscriber. The
visible motion itself is a screenshot away via `zig build run-x86-64`.
The compositor's pixel math (rectangle clipping, fill, composite, tile blit) and colour
packing are additionally covered by pure host unit tests under `zig build test`.
## See also
- [framebuffer.md](framebuffer.md) — the linear framebuffer, pitch vs. width, `volatile`.
- [gop.md](gop.md) — GOP, and why only linear RGBX/BGRX modes are paintable.
- [input.md](input.md) — the sibling service; the async `ipc_send` fan-out.
- [driver-model.md](driver-model.md) — claim / `mmio_map`, capability passing, the trust model.
- [device-manager.md](device-manager.md) — matching and supervision (the native backend's route).
- [display-plan.md](display-plan.md) — the ordered build-out.

171
library/runtime/display.zig Normal file
View File

@ -0,0 +1,171 @@
//! User-space display client: talk to the display service (query the mode, and from D3
//! create layers, draw, and present) without hand-rolling the IPC. The `runtime.block`
//! shape: a cached `.display` lookup with a boot-race retry, then extern-struct request/
//! reply marshalling. See system/services/display/ and docs/display.md.
const std = @import("std");
const ipc = @import("ipc.zig");
const system = @import("system.zig");
const protocol = @import("display-protocol");
/// The display's current mode, as `info()` reports it.
pub const Info = struct {
width: u32,
height: u32,
pitch: u32, // bytes per row (may exceed width*4; see docs/framebuffer.md)
format: u32, // a device-abi DisplayFormat value (0 = rgbx, 1 = bgrx)
};
/// The service endpoint, looked up once and cached.
var handle: ?ipc.Handle = null;
/// Look up the display service, retrying while it comes up (a client races its
/// registration at boot). Returns the endpoint, or null if it never appears.
fn service() ?ipc.Handle {
if (handle) |h| return h;
var attempts: usize = 0;
while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.display)) |h| {
handle = h;
return h;
}
system.sleep(50);
}
return null;
}
/// Send one request, receive its reply; true on a zero status. `out` receives the reply
/// so callers can read `info`/`layer` fields on success.
fn transact(request: protocol.Request, out: *protocol.Reply) bool {
const h = service() orelse return false;
var req = request;
var reply: [protocol.reply_size]u8 = undefined;
const len = ipc.call(h, std.mem.asBytes(&req), &reply) catch return false;
if (len < protocol.reply_size) return false;
out.* = std.mem.bytesToValue(protocol.Reply, reply[0..protocol.reply_size]);
return out.status == 0;
}
/// The display's current mode, or null if the service never came up.
pub fn info() ?Info {
var reply: protocol.Reply = undefined;
if (!transact(.{ .operation = @intFromEnum(protocol.Operation.info) }, &reply)) return null;
return .{ .width = reply.width, .height = reply.height, .pitch = reply.pitch, .format = reply.format };
}
/// Composite the dirty layers and flush the frame to the screen.
pub fn present() bool {
var reply: protocol.Reply = undefined;
return transact(.{ .operation = @intFromEnum(protocol.Operation.present) }, &reply);
}
/// The mode, cached after the first `info()` so `color()` doesn't round-trip per pixel.
var mode: ?Info = null;
fn cachedInfo() ?Info {
if (mode) |m| return m;
const i = info() orelse return null;
mode = i;
return i;
}
/// The native pixel value for an 8-bit-per-channel colour, in the display's format. A
/// client packs colours through this so it never has to know the byte order itself.
pub fn color(r: u8, g: u8, b: u8) u32 {
const format = if (cachedInfo()) |i| i.format else 0;
return protocol.pack(format, r, g, b);
}
/// A handle to a server-owned layer: a positioned, z-ordered surface the client draws
/// into by command. Create with `createLayer`; drawing and moves take effect on the next
/// `present`. Coordinates are signed (a layer may sit partly off-screen).
pub const Layer = struct {
id: u32,
/// Fill a rectangle of this layer (layer-local coordinates) with a native `colour`.
pub fn fill(self: Layer, x: i32, y: i32, w: u32, h: u32, colour: u32) bool {
var reply: protocol.Reply = undefined;
return transact(.{
.operation = @intFromEnum(protocol.Operation.fill_rect),
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
.width = w,
.height = h,
.colour = colour,
}, &reply);
}
/// Copy a `w`×`h` tile of native pixels (row-major, little-endian bytes) into this
/// layer at (`x`, `y`). The tile rides inline in the request, so `w*h*4` must fit
/// `protocol.maximum_payload`.
pub fn blitTile(self: Layer, x: i32, y: i32, w: u32, h: u32, pixels: []const u8) bool {
var request = protocol.Request{
.operation = @intFromEnum(protocol.Operation.blit_tile),
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
.width = w,
.height = h,
};
const header = std.mem.asBytes(&request);
if (header.len + pixels.len > protocol.message_maximum) return false;
var buffer: [protocol.message_maximum]u8 = undefined;
@memcpy(buffer[0..header.len], header);
@memcpy(buffer[header.len..][0..pixels.len], pixels);
const h_svc = service() orelse return false;
var reply: [protocol.reply_size]u8 = undefined;
const len = ipc.call(h_svc, buffer[0 .. header.len + pixels.len], &reply) catch return false;
if (len < protocol.reply_size) return false;
return std.mem.bytesToValue(protocol.Reply, reply[0..protocol.reply_size]).status == 0;
}
/// Move / restack / show or hide the layer.
pub fn configure(self: Layer, x: i32, y: i32, z: u32, visible: bool) bool {
var reply: protocol.Reply = undefined;
return transact(.{
.operation = @intFromEnum(protocol.Operation.configure_layer),
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
.z = z,
.visible = if (visible) 1 else 0,
}, &reply);
}
/// Mark a rectangle of this layer (layer-local) dirty for the next present for when
/// the layer's pixels changed without a drawing call the compositor already tracked.
pub fn damage(self: Layer, x: i32, y: i32, w: u32, h: u32) bool {
var reply: protocol.Reply = undefined;
return transact(.{
.operation = @intFromEnum(protocol.Operation.damage),
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
.width = w,
.height = h,
}, &reply);
}
/// Release the layer and its surface.
pub fn destroy(self: Layer) bool {
var reply: protocol.Reply = undefined;
return transact(.{ .operation = @intFromEnum(protocol.Operation.destroy_layer), .layer = self.id }, &reply);
}
};
/// Create a server-owned layer of `w`×`h` pixels at screen (`x`, `y`) with stacking order
/// `z` (higher is nearer the front), initially visible. Returns a handle, or null.
pub fn createLayer(x: i32, y: i32, w: u32, h: u32, z: u32) ?Layer {
var reply: protocol.Reply = undefined;
if (!transact(.{
.operation = @intFromEnum(protocol.Operation.create_layer),
.x = @bitCast(x),
.y = @bitCast(y),
.width = w,
.height = h,
.z = z,
.visible = 1,
}, &reply)) return null;
return .{ .id = reply.layer };
}

View File

@ -46,6 +46,12 @@ pub const usb = @import("usb.zig");
/// usb-storage). See library/runtime/block.zig.
pub const block = @import("block.zig");
/// Display-service client: query the mode, and (from D3) create layers, draw, and
/// present frames. See library/runtime/display.zig and system/services/display/.
pub const display = @import("display.zig");
/// The display wire protocol (shared with the display service and its clients).
pub const display_protocol = @import("display-protocol");
/// The danos-native file API (open/read/write/list over the user-space VFS) the
/// layer danos programs use directly, and where the operations that later become
/// `std.os.danos` are staged. See docs/zig-self-hosting.md.

View File

@ -183,6 +183,7 @@ pub const ServiceId = enum(u32) {
usb_bus = 6, // the xHCI host-controller driver's transfer endpoint; USB class drivers look it up and `callCap`-open their device to get a private per-device transfer channel (docs/driver-model.md)
block = 7, // a block-device driver (USB mass storage today): read/write of fixed-size blocks, the storage a filesystem sits on
fat = 8, // the FAT filesystem server; the VFS mounts it and forwards paths under its mount point (/mnt/usb) to it
display = 9, // the display service: owns the framebuffer, composites a layer stack, presents frames (docs/display.md)
_,
};

View File

@ -38,6 +38,13 @@ pub const DeviceClass = enum(u32) {
/// resources; the (class, subclass, protocol) triple that says what it is
/// travels in the bus report's identity, not here.
usb_device,
/// A scanout framebuffer: a linear region of pixel memory the display service
/// claims and maps. Unlike the other classes this one is not firmware-discovered
/// the kernel seeds it from the loader's [[boot-handoff]] framebuffer
/// (`devices_broker.seedDisplay`). Its one `memory` resource is the framebuffer,
/// flagged write-combining; the geometry to interpret it travels in
/// `DeviceDescriptor.display`.
display,
unknown,
};
@ -59,10 +66,39 @@ pub const ResourceDescriptor = extern struct {
kind: u64, // a ResourceKind value
start: u64,
len: u64,
/// A bitmask of `resource_flag_*` hints. Zero for a plain register/RAM window;
/// the kernel reads it when it maps the resource. Defaulted so every existing
/// literal (which never set flags) keeps compiling and lays out identically.
flags: u64 = 0,
};
/// `ResourceDescriptor.flags`: map this `memory` resource **write-combining** rather
/// than strong-uncacheable for a framebuffer, where batched bursts to pixel memory
/// are the whole point (an uncacheable framebuffer blit is glacial). See
/// `mmio_map` (system/kernel/process.zig) and `setupPat` (/x86_64/paging.zig).
pub const resource_flag_write_combining: u64 = 1 << 0;
pub const maximum_device_resources = 8;
/// The byte order of a display's pixels mirrors the loader's `PixelFormat`
/// ([[boot-handoff]]) with the same numeric values, but lives here so user space
/// (which must never import the loaderkernel handoff) can name it. Only the two
/// linear 32-bpp layouts a console can paint into exist; see docs/gop.md.
pub const DisplayFormat = enum(u32) {
rgbx = 0, // byte 0 = Red, 1 = Green, 2 = Blue, 3 = reserved
bgrx = 1, // byte 0 = Blue, 1 = Green, 2 = Red, 3 = reserved
};
/// The geometry of a `display` device's framebuffer, carried in its descriptor so a
/// claiming driver knows how to interpret the pixel bytes its `memory` resource maps.
/// `pitch` is bytes per row (may exceed `width * 4`; see docs/framebuffer.md).
pub const DisplayInfo = extern struct {
width: u32 = 0, // visible pixels per row
height: u32 = 0, // visible rows
pitch: u32 = 0, // bytes from one row's start to the next
format: u32 = 0, // a DisplayFormat value
};
/// `DeviceDescriptor.parent` for a device with no parent a root of the device tree.
pub const no_parent: u64 = ~@as(u64, 0);
@ -92,4 +128,9 @@ pub const DeviceDescriptor = extern struct {
resource_count: u64,
hid: [8]u8,
resources: [maximum_device_resources]ResourceDescriptor,
// Framebuffer geometry, meaningful only when `class` is `DeviceClass.display`
// (zeroed otherwise). Kept here a class-specific field on the shared descriptor
// the same way `pci_class` is meaningful only for `pci_device` and `hid` only for
// `acpi_device`.
display: DisplayInfo = .{},
};

View File

@ -168,10 +168,18 @@ pub fn mapUserPageInto(root: u64, virtual: u64, physical: u64, writable: bool, e
paging.mapUserInto(root, virtual, physical, writable, executable);
}
/// Map a device MMIO window into address space `root`: strong-uncacheable, RW+NX,
/// and marked so teardown won't free the MMIO frames as RAM. For IO passthrough.
pub fn mapUserDeviceInto(root: u64, virtual: u64, physical: u64, len: u64) void {
paging.mapUserDeviceInto(root, virtual, physical, len);
/// Map a device MMIO window into address space `root`: RW+NX, and marked so teardown
/// won't free the MMIO frames as RAM. `write_combining` picks the cache type
/// false = strong-uncacheable (registers), true = write-combining (a framebuffer).
/// For IO passthrough.
pub fn mapUserDeviceInto(root: u64, virtual: u64, physical: u64, len: u64, write_combining: bool) void {
paging.mapUserDeviceInto(root, virtual, physical, len, write_combining);
}
/// Is the user leaf mapping `virtual` in address space `root` write-combining? Null if
/// unmapped. For tests verifying the framebuffer map's cache type.
pub fn userLeafIsWriteCombining(root: u64, virtual: u64) ?bool {
return paging.leafIsWriteCombining(root, virtual);
}
/// Map coherent DMA RAM into address space `root`: strong-uncacheable, RW+NX, but

View File

@ -320,8 +320,12 @@ pub fn mapUserInto(pml4: u64, virtual: u64, physical: u64, writable_page: bool,
/// RAM allocator (`freeSubtree`). RW + NX; the caller places `virtual` in a
/// user-exclusive range (PML4[225]). Both `virtual` and `physical` are page-aligned by
/// the caller; a sub-page `physical` offset is the caller's to re-apply.
pub fn mapUserDeviceInto(pml4: u64, virtual: u64, physical: u64, len: u64) void {
const flags: u64 = present | user | writable | no_execute | pcd | pwt | device_grant;
pub fn mapUserDeviceInto(pml4: u64, virtual: u64, physical: u64, len: u64, write_combining: bool) void {
// Registers are strong-uncacheable (PCD|PWT). A framebuffer instead wants
// write-combining the PAT bit (bit 7 in a 4 KiB PTE) with PCD=PWT=0 selects PAT
// entry 4, which `setupPat` programs to WC so pixel writes batch into bursts.
const cache: u64 = if (write_combining) pte_pat else (pcd | pwt);
const flags: u64 = present | user | writable | no_execute | device_grant | cache;
const first = physical & ~@as(u64, page_size - 1);
const last = (physical + (if (len == 0) 1 else len) - 1) & ~@as(u64, page_size - 1);
var off: u64 = 0;
@ -362,6 +366,33 @@ pub fn mapUserDmaInto(pml4: u64, virtual: u64, physical: u64, len: u64) void {
}
}
/// The raw leaf entry mapping `virtual` in the address space rooted at `pml4`, or null
/// if any level of the walk is absent. **Read-only** never allocates or descends into
/// a missing table (unlike the `map*` paths' `descendUser`). Stops at the first huge
/// leaf. For tests and introspection that need a page's actual flag bits.
pub fn leafEntryOf(pml4: u64, virtual: u64) ?u64 {
const l4 = tableAt(pml4)[(virtual >> 39) & 0x1FF];
if (l4 & present == 0) return null;
const l3 = tableAt(l4 & address_mask)[(virtual >> 30) & 0x1FF];
if (l3 & present == 0) return null;
if (l3 & page_size_bit != 0) return l3; // 1 GiB leaf
const l2 = tableAt(l3 & address_mask)[(virtual >> 21) & 0x1FF];
if (l2 & present == 0) return null;
if (l2 & page_size_bit != 0) return l2; // 2 MiB leaf
const l1 = tableAt(l2 & address_mask)[(virtual >> 12) & 0x1FF];
if (l1 & present == 0) return null;
return l1;
}
/// Is the 4 KiB leaf mapping `virtual` write-combining the PAT bit set with PCD and
/// PWT clear, which `setupPat` makes PAT entry 4 (WC)? Null if unmapped. The device
/// mapping path (`mapUserDeviceInto`) always uses 4 KiB leaves, so bit 7 (`pte_pat`)
/// is the PAT selector in play.
pub fn leafIsWriteCombining(pml4: u64, virtual: u64) ?bool {
const e = leafEntryOf(pml4, virtual) orelse return null;
return (e & pte_pat != 0) and (e & pcd == 0) and (e & pwt == 0);
}
/// Create a new address space: a fresh PML4 with an empty user half and the
/// kernel's higher half shared in (copying PML4[256..512), whose entries point
/// at the kernel's PDPTs pre-created at init and never restaled, so growth in

View File

@ -20,6 +20,12 @@ const boot_handoff = @import("boot-handoff");
var con: Console = undefined;
var con_present: bool = false;
/// Set while a user-space display service owns the framebuffer: `write` falls silent so
/// the kernel doesn't paint over the compositor. Driven by the display device's
/// claim/release (system/kernel/process.zig). The terminal panic/exception paths clear
/// it first (`setSuppressed(false)`) a dying machine's message wins over any display.
var suppressed: bool = false;
/// Set up the console over `fb`, or mark it absent if there's no usable
/// framebuffer. Clears the screen when present.
pub fn init(fb: boot_handoff.Framebuffer) void {
@ -41,13 +47,20 @@ pub fn present() bool {
return con_present;
}
/// Output sink: draw `bytes` on screen. A no-op when no framebuffer is present,
/// so it's always safe to call.
/// Output sink: draw `bytes` on screen. A no-op when no framebuffer is present, or
/// while a display service owns the screen (`suppressed`), so it's always safe to call.
pub fn write(bytes: []const u8) void {
if (!con_present) return;
if (!con_present or suppressed) return;
for (bytes) |c| con.putChar(c);
}
/// Quiesce (or resume) the bootstrap console. Set true when a display service claims the
/// framebuffer; set false when that claim is released, or by the panic path to force a
/// last message onto a screen a (now-irrelevant) service was holding.
pub fn setSuppressed(value: bool) void {
suppressed = value;
}
/// The console font, embedded at compile time. cp850-8x16, PSF2 format:
/// a 32-byte header, then 256 glyphs of 16 bytes each (one byte per 8-pixel
/// row). We index glyphs straight by byte value, so ASCII maps 1:1.

View File

@ -36,6 +36,11 @@ var devices: [maximum_devices]device_abi.DeviceDescriptor = undefined;
var claimed: [maximum_devices]?u32 = .{null} ** maximum_devices; // owner task id, or null
var count: usize = 0;
/// The id of the seeded framebuffer node (`seedDisplay`), or null when the machine
/// handed over no framebuffer. Lets the process layer recognise the display claim
/// (to quiesce the bootstrap console) without threading the id through every caller.
var display_device: ?u64 = null;
/// Devices discovery found but the table had no room for. Non-zero means the machine
/// is bigger than `maximum_devices` and some hardware is simply invisible to drivers
/// which would otherwise be an entirely silent failure. Logged at boot.
@ -45,10 +50,55 @@ pub var dropped: usize = 0;
pub fn init(device_tree: *const platform.DeviceTree) void {
count = 0;
dropped = 0;
display_device = null;
for (&claimed) |*c| c.* = null;
walk(device_tree.root, device_abi.no_parent);
}
/// Publish the loader's framebuffer as a `display` device a root-level node with one
/// write-combining `memory` resource over the linear framebuffer and its geometry in
/// `.display`. The framebuffer is *not* firmware-discovered (it rides the
/// [[boot-handoff]], not the device tree), so it is seeded explicitly, after `init`.
/// Returns the new device id, or null when there is no framebuffer (headless) or the
/// table is full. Idempotent-ish: only ever call once per boot.
pub fn seedDisplay(base: u64, width: u32, height: u32, pitch: u32, format: u32) ?u64 {
if (base == 0 or width == 0 or height == 0) return null; // headless
if (count >= maximum_devices) {
dropped += 1;
return null;
}
var d = std.mem.zeroes(device_abi.DeviceDescriptor);
d.id = count;
d.parent = device_abi.no_parent;
d.class = @intFromEnum(device_abi.DeviceClass.display);
d.pci_class = device_abi.no_pci_class;
d.resource_count = 1;
d.resources[0] = .{
.kind = @intFromEnum(device_abi.ResourceKind.memory),
.start = base,
.len = @as(u64, height) * pitch,
.flags = device_abi.resource_flag_write_combining,
};
d.display = .{ .width = width, .height = height, .pitch = pitch, .format = format };
devices[count] = d;
display_device = d.id;
count += 1;
return d.id;
}
/// The id of the seeded framebuffer device, or null when none was seeded.
pub fn displayDevice() ?u64 {
return display_device;
}
/// Whether the framebuffer device is currently claimed by some process. The bootstrap
/// console uses this (via the process layer) to fall silent while a display service
/// owns the screen, and to resume if that service dies and its claim is released.
pub fn displayClaimed() bool {
const id = display_device orelse return false;
return ownerOf(id) != null;
}
/// Record `node` (unless it's the synthetic root) and recurse, threading the id we
/// assigned it down to its children as their parent.
fn walk(node: *platform.Device, parent_id: u64) void {

View File

@ -195,6 +195,13 @@ fn kmain(boot_information: *const BootInformation) noreturn {
log.print("/system/kernel: WARNING {d} device(s) dropped — table full\n", .{devices_broker.dropped});
}
// Publish the loader's framebuffer as a claimable `display` device, so a
// user-space display service can take it over the same claim + mmio_map path as
// any other hardware (it is not firmware-discovered; it rides the boot handoff).
if (devices_broker.seedDisplay(fb.base, fb.width, fb.height, fb.pitch, @intFromEnum(fb.format))) |display_id| {
log.print("/system/kernel: framebuffer device {d} seeded ({d}x{d}, pitch {d}, write-combining)\n", .{ display_id, fb.width, fb.height, fb.pitch });
}
// Install the device-IRQ trampolines, so a driver's irq_bind has vectors to
// land on. Every line stays masked until something binds it (ioapic.init).
irq.init();
@ -479,6 +486,9 @@ fn onException(state: *const architecture.CpuState) noreturn {
}
log.checkpoint(cp_exception);
// The machine is going down: force the console back on even if a display service
// was holding the framebuffer, so the exception actually reaches the screen.
console.setSuppressed(false);
const core = scheduler.currentCpuIndex();
// A fault is user-facing enough to paint on screen too (via statusPrint), on
// top of the diagnostic log.
@ -501,6 +511,7 @@ pub const panic = std.debug.FullPanic(struct {
_ = first_trace_address;
log.checkpoint(cp_panic);
log.recordPanic(message);
console.setSuppressed(false); // a panic outranks any display service holding the screen
status("\nKERNEL PANIC: ");
status(message);
status("\n");

View File

@ -28,6 +28,7 @@ const parameters = @import("parameters");
const architecture = @import("architecture");
const pmm = @import("pmm.zig");
const scheduler = @import("scheduler.zig");
const console = @import("console.zig");
const sync = @import("sync.zig");
const ipc = @import("ipc-synchronous.zig");
const devices_broker = @import("devices-broker.zig");
@ -80,9 +81,11 @@ pub const device_arena_end: u64 = device_arena_base + (4 << 30);
pub const dma_arena_base: u64 = 0x0000_7200_0000_0000;
pub const dma_arena_end: u64 = dma_arena_base + (256 << 20); // 256 MiB per process
/// Largest single `mmap` grant, in pages (1 MiB). The user heap grows in small
/// chunks, so this bound is generous; it also caps the frame scratch array below.
const maximum_mmap_pages = 256;
/// Largest single `mmap` grant, in pages (32 MiB). Big enough for a display service's
/// back buffer at up to 4K (3840x2160x4 8100 pages); the user heap otherwise grows in
/// small chunks. `systemMmap` maps page by page with rollback, so this is only a sanity
/// bound (and an overflow guard on the page count), not the size of any scratch array.
const maximum_mmap_pages = 8192;
/// Ceiling on a process's argv entries, including argv[0]. Arguments are spawn
/// parameters ("you are the driver for device 12"), not bulk data IPC carries
@ -301,12 +304,19 @@ fn systemDeviceEnumerate(state: *architecture.CpuState) void {
/// device_claim(id) -> 0/-1: take exclusive ownership of a device for this process.
fn systemDeviceClaim(state: *architecture.CpuState) void {
const device_id = architecture.systemCallArg(state, 0);
const claim_flags = sync.enter();
defer sync.leave(claim_flags);
if (devices_broker.claim(architecture.systemCallArg(state, 0), scheduler.current().id))
architecture.setSystemCallResult(state, 0)
else
fail(state);
if (devices_broker.claim(device_id, scheduler.current().id)) {
// A display service just took the framebuffer quiesce the bootstrap console
// so the kernel and the service don't scribble over each other's pixels. The
// claim releases (and the console resumes) automatically if the service dies;
// see releaseTaskResourcesLocked.
if (devices_broker.displayDevice()) |display_id| {
if (device_id == display_id) console.setSuppressed(true);
}
architecture.setSystemCallResult(state, 0);
} else fail(state);
}
/// mmio_map(device_id, resource_index) -> vaddr: map a claimed device's MMIO window into
@ -341,7 +351,10 @@ fn systemMmioMap(state: *architecture.CpuState) void {
const base_v = t.device_map_next;
if (base_v + pages * page_size > device_arena_end) return fail(state);
architecture.mapUserDeviceInto(t.aspace, base_v, r.start, r.len);
// A framebuffer resource asks (via its flag) to be mapped write-combining rather
// than the strong-uncacheable default that register MMIO needs.
const write_combining = (r.flags & device_abi.resource_flag_write_combining) != 0;
architecture.mapUserDeviceInto(t.aspace, base_v, r.start, r.len, write_combining);
t.device_map_next = base_v + pages * page_size;
architecture.setSystemCallResult(state, base_v + (r.start & (page_size - 1))); // register base
}
@ -601,6 +614,9 @@ fn releaseTaskResourcesLocked(t: *scheduler.Task) void {
recordExitLocked(t);
irq.releaseOwner(t.id);
devices_broker.releaseAllOwnedBy(t.id);
// If that dropped the framebuffer claim (this task was the display service), let the
// bootstrap console draw again the screen is nobody's now, so panics/status land.
if (!devices_broker.displayClaimed()) console.setSuppressed(false);
// The dying task's signal endpoint and one-shot timers go with it.
if (t.signal_endpoint) |raw| {
ipc.dropRef(@ptrCast(@alignCast(raw)));
@ -1024,21 +1040,26 @@ fn systemMmap(state: *architecture.CpuState) void {
const base = t.heap_next;
if (base + pages * page_size > heap_arena_end) return fail(state); // arena exhausted
// Reserve all frames up front so a mid-way exhaustion rolls back cleanly
// (no partially-mapped grant leaks into the address space).
var frames: [maximum_mmap_pages]u64 = undefined;
var got: usize = 0;
while (got < pages) : (got += 1) {
frames[got] = pmm.alloc() orelse {
for (frames[0..got]) |f| pmm.free(f);
// Map page by page. On mid-way frame exhaustion, roll back the pages already mapped
// (unmap + free) so no partial grant leaks into the address space the same
// all-or-nothing guarantee as before, but without a fixed scratch array, so the
// per-call size can be a multi-MiB framebuffer.
var mapped: usize = 0;
while (mapped < pages) : (mapped += 1) {
const frame = pmm.alloc() orelse {
var i: usize = 0;
while (i < mapped) : (i += 1) {
const va = base + i * page_size;
if (architecture.translate(t.aspace, va)) |physical| {
architecture.unmapUserPageInto(t.aspace, va);
pmm.free(physical);
}
}
return fail(state);
};
}
for (frames[0..pages], 0..) |frame, i| {
const destination: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frame));
@memset(destination[0..page_size], 0); // hand out zeroed memory
architecture.mapUserPageInto(t.aspace, base + i * page_size, frame, true, false); // RW + NX
architecture.mapUserPageInto(t.aspace, base + mapped * page_size, frame, true, false); // RW + NX
}
t.heap_next = base + pages * page_size;
architecture.setSystemCallResult(state, base);

View File

@ -95,6 +95,12 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
iommuTest();
} else if (eql(case, "ioport")) {
ioPortTest();
} else if (eql(case, "display")) {
displayTest(boot_information);
} else if (eql(case, "display-service")) {
displayServiceTest(boot_information);
} else if (eql(case, "display-demo")) {
displayDemoTest(boot_information);
} else if (eql(case, "clock")) {
clockTest();
} else if (eql(case, "smp")) {
@ -2305,6 +2311,72 @@ fn inputTest(boot_information: *const BootInformation) void {
result();
}
/// D2 the display service comes up. Spawn it from the initial_ramdisk; it claims the
/// framebuffer the kernel seeded (D1), maps it write-combining, allocates a cacheable
/// back buffer, and proves the double-buffer path by clearing that buffer and presenting
/// it. Its `display: online WxH` + `display: presented frame 0` heartbeats are the
/// markers seeing them proves a user-space compositor took the framebuffer and pushed
/// a whole composed frame to the screen, without ever drawing straight to the LFB.
fn displayServiceTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: display-service\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
// Spawn the compositor and hand it the core. Its own serial heartbeats `display:
// online WxH` and `display: presented frame 0` are what the harness matches (it
// reads serial directly, like the fault cases). We don't poll for them in-kernel: a
// single service that comes up and blocks doesn't reschedule this bring-up context
// (there is no other runnable task to bounce control back through), so the honest
// observation point is the service's output itself, not a check() proxy here.
if (!spawnNamed(rd, "display")) {
log("display-service: could not spawn the display service\n", .{});
result();
return;
}
scheduler.setPriority(1); // below the service, so it runs and comes up first
while (true) scheduler.yield();
}
/// D4 a separate process drives the compositor. Spawn the display service and the
/// hardware-free `display-demo` client, which creates a wallpaper, a moving rectangle,
/// and a cursor and presents a run of frames. Its `display-demo: ok` heartbeat printed
/// only after it drove frames of motion through the layer client API and the compositor
/// is the harness's marker (the visible motion itself is a screenshot away via run-x86-64).
/// The demo keeps presenting, so unlike a lone blocking service the scheduler stays busy;
/// we still match on serial rather than poll, for consistency.
fn displayDemoTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: display-demo\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
if (!spawnNamed(rd, "display")) {
log("display-demo: could not spawn the display service\n", .{});
result();
return;
}
_ = spawnNamed(rd, "display-demo");
scheduler.setPriority(1); // below the service + demo, so they run
while (true) scheduler.yield();
}
/// Process arguments, end to end: spawn args-echo bare (its argv[0] is the
/// initial-ramdisk name). Instance 1 sees argc == 1 and respawns itself through
/// `system_spawn` with the extra arguments "alpha beta-42" the syscall argument
@ -2627,8 +2699,8 @@ fn ioPassTest() void {
result();
return;
};
// Map it the way mmio_map does (device grant), then tear the space down.
architecture.mapUserDeviceInto(aspace, process.device_arena_base, frame, abi.page_size);
// Map it the way mmio_map does (device grant, strong-uncacheable), then tear the space down.
architecture.mapUserDeviceInto(aspace, process.device_arena_base, frame, abi.page_size, false);
architecture.destroyAddressSpace(aspace);
// The page tables were reclaimed; the device-granted frame must not have been.
@ -2638,6 +2710,75 @@ fn ioPassTest() void {
result();
}
/// D1 the framebuffer handoff primitive. The kernel seeds the loader's framebuffer as
/// a claimable `display` device with a write-combining `memory` resource; a display
/// service reaches it over the ordinary claim + mmio_map path. Prove the whole chain:
/// the node is present and correctly shaped, it maps, and the point of D1 the
/// mapping is genuinely write-combining, not the strong-uncacheable default that would
/// make a framebuffer blit glacial.
fn displayTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: display\n", .{});
const fb = boot_information.framebuffer;
if (!fb.present()) {
// Headless: nothing to seed. Not a failure of the mechanism, so pass cleanly.
log("display: no framebuffer (headless); skipping\n", .{});
result();
return;
}
// The kernel seeded a display device in kmain, right after devices_broker.init.
const display_id = devices_broker.displayDevice() orelse {
check("a framebuffer display device was seeded", false);
result();
return;
};
var buffer: [64]device_abi.DeviceDescriptor = undefined;
const n = @min(devices_broker.enumerate(&buffer), buffer.len);
check("the seeded display id is enumerable", display_id < n);
if (display_id >= n) {
result();
return;
}
const d = buffer[@intCast(display_id)];
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 has exactly one resource", d.resource_count == 1);
const r = d.resources[0];
check("that resource is a memory window", r.kind == @intFromEnum(device_abi.ResourceKind.memory));
check("it spans the whole framebuffer", r.start == fb.base and r.len == @as(u64, fb.height) * fb.pitch);
check("it is flagged write-combining", (r.flags & device_abi.resource_flag_write_combining) != 0);
// Walk the real claim + map path a display service would, into a throwaway address
// space, and confirm the leaf's cache type. We never run this space (no CR3 load)
// we only read back the page-table entries so aliasing the same physical page at
// two cache types below is inert.
const aspace = architecture.createAddressSpace() orelse {
check("created a fresh address space", false);
result();
return;
};
defer architecture.destroyAddressSpace(aspace);
const page_base = fb.base & ~@as(u64, abi.page_size - 1);
architecture.mapUserDeviceInto(aspace, process.device_arena_base, page_base, abi.page_size, true);
check(
"the framebuffer maps write-combining (PAT entry 4: PAT bit set, PCD/PWT clear)",
architecture.userLeafIsWriteCombining(aspace, process.device_arena_base) == true,
);
// Regression guard: the strong-uncacheable default is still that, so WC is a real
// choice the flag makes, not the only behaviour.
architecture.mapUserDeviceInto(aspace, process.device_arena_base + abi.page_size, page_base, abi.page_size, false);
check(
"a register window still maps strong-uncacheable",
architecture.userLeafIsWriteCombining(aspace, process.device_arena_base + abi.page_size) == false,
);
log("display: mapped {d}x{d} pitch {d} (write-combining)\n", .{ fb.width, fb.height, fb.pitch });
result();
}
fn faultInvalidOpcode() void {
log("DANOS-TEST-BEGIN: fault-ud\n", .{});
asm volatile ("ud2");

View File

@ -0,0 +1,66 @@
//! system/services/display-demo a hardware-free client of the display service, the
//! `input-source` analog for the compositor. It creates a wallpaper, a rectangle it moves
//! each frame, and a small cursor, then drives the compositor in a present loop proof
//! that a *separate process* can compose a moving scene through the display service over
//! IPC, exercising the layer client API and damage-driven present end to end
//! (docs/display.md). It logs `display-demo: ok` once it has driven a run of frames.
const runtime = @import("runtime");
const display = runtime.display;
const system = runtime.system;
const time = runtime.time;
pub fn main() void {
const mode = display.info() orelse {
_ = system.write("display-demo: no display service\n");
return;
};
// A full-screen wallpaper under everything.
const wallpaper = display.createLayer(0, 0, mode.width, mode.height, 0) orelse return createFailed();
_ = wallpaper.fill(0, 0, mode.width, mode.height, display.color(0x10, 0x18, 0x28));
// A rectangle that slides back and forth.
const box_w: u32 = 140;
const box_h: u32 = 100;
const box_y: i32 = 200;
const box = display.createLayer(0, box_y, box_w, box_h, 1) orelse return createFailed();
_ = box.fill(0, 0, box_w, box_h, display.color(0xE0, 0x60, 0x40));
// A little cursor on top.
const cursor = display.createLayer(40, 40, 12, 12, 2) orelse return createFailed();
_ = cursor.fill(0, 0, 12, 12, display.color(0xF0, 0xF0, 0xF0));
_ = display.present();
_ = system.write("display-demo: scene up; animating\n");
const span: i32 = @as(i32, @intCast(mode.width)) - @as(i32, @intCast(box_w));
var x: i32 = 0;
var dx: i32 = 8;
var frame: u32 = 0;
while (true) : (frame += 1) {
x += dx;
if (x <= 0) {
x = 0;
dx = -dx;
} else if (x >= span) {
x = span;
dx = -dx;
}
_ = box.configure(x, box_y, 1, true); // move it; the compositor repaints old + new
_ = display.present();
// A run of frames drawn through the compositor is the automated proof (the visible
// motion is a screenshot away via `zig build run-x86-64`).
if (frame == 20) _ = system.write("display-demo: ok\n");
time.sleep(time.Duration.fromMillis(30));
}
}
fn createFailed() void {
_ = system.write("display-demo: create failed\n");
}
pub const panic = runtime.panic;
comptime {
_ = &runtime.start._start;
}

View File

@ -0,0 +1,192 @@
//! The compositor's pure core: rectangle math and the three blitting primitives the
//! display service composes frames from fill a rectangle of a surface, composite one
//! surface onto another clipped to a damage rectangle, and copy a client-supplied pixel
//! tile in. Deliberately free of any syscall or `runtime` dependency (it takes plain
//! pixel pointers), so it is host-tested under `zig build test`. The service
//! (system/services/display/display.zig) wires real mmap'd surfaces and the framebuffer
//! to it. Pixels are opaque native 32-bit values v1 layers don't alpha-blend, and
//! channel order (rgbx/bgrx) is the caller's concern (see protocol.pack).
const std = @import("std");
/// An axis-aligned rectangle in pixels. Signed, so a surface partly off-screen (a layer
/// dragged past an edge) clips with plain arithmetic. Half-open: covers [x, x+w) × [y, y+h).
pub const Rect = struct {
x: i32,
y: i32,
w: i32,
h: i32,
pub const empty = Rect{ .x = 0, .y = 0, .w = 0, .h = 0 };
pub fn init(x: i32, y: i32, w: i32, h: i32) Rect {
return .{ .x = x, .y = y, .w = w, .h = h };
}
pub fn isEmpty(r: Rect) bool {
return r.w <= 0 or r.h <= 0;
}
pub fn right(r: Rect) i32 {
return r.x + r.w;
}
pub fn bottom(r: Rect) i32 {
return r.y + r.h;
}
/// The overlap of two rectangles, or an empty rectangle if they don't touch.
pub fn intersect(a: Rect, b: Rect) Rect {
const x0 = @max(a.x, b.x);
const y0 = @max(a.y, b.y);
const x1 = @min(a.right(), b.right());
const y1 = @min(a.bottom(), b.bottom());
return .{ .x = x0, .y = y0, .w = x1 - x0, .h = y1 - y0 };
}
/// The bounding box of two rectangles. An empty operand contributes nothing (returns
/// the other), so folding damage rectangles with `unite` from `empty` yields their
/// bounding box.
pub fn unite(a: Rect, b: Rect) Rect {
if (a.isEmpty()) return b;
if (b.isEmpty()) return a;
const x0 = @min(a.x, b.x);
const y0 = @min(a.y, b.y);
const x1 = @max(a.right(), b.right());
const y1 = @max(a.bottom(), b.bottom());
return .{ .x = x0, .y = y0, .w = x1 - x0, .h = y1 - y0 };
}
};
/// 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).
pub const Surface = struct {
pixels: [*]u32,
stride: u32, // pixels per row
width: u32,
height: u32,
pub fn bounds(s: Surface) Rect {
return .{ .x = 0, .y = 0, .w = @intCast(s.width), .h = @intCast(s.height) };
}
inline fn row(s: Surface, y: u32) [*]u32 {
return s.pixels + @as(usize, y) * s.stride;
}
};
/// Fill `rect` of `s` with the native pixel `colour`, clipped to `s`'s bounds.
pub fn fillRect(s: Surface, rect: Rect, colour: u32) void {
const c = rect.intersect(s.bounds());
if (c.isEmpty()) return;
var y: i32 = c.y;
while (y < c.bottom()) : (y += 1) {
const r = s.row(@intCast(y));
var x: i32 = c.x;
while (x < c.right()) : (x += 1) r[@intCast(x)] = colour;
}
}
/// Composite the whole of `layer` onto `dst` with the layer's top-left at (`dx`, `dy`),
/// painting only the pixels that fall inside `clip` (a `dst`-space rectangle) and inside
/// `dst`. Opaque copy. This is the primitive `present` repeats over the visible layer
/// stack, bottom to top, for each damaged region.
pub fn composite(dst: Surface, dx: i32, dy: i32, layer: Surface, clip: Rect) void {
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());
if (region.isEmpty()) return;
var y: i32 = region.y;
while (y < region.bottom()) : (y += 1) {
const src = layer.row(@intCast(y - dy));
const d = dst.row(@intCast(y));
var x: i32 = region.x;
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,
/// tightly packed) into `dst` at (`dx`, `dy`), clipped to `dst`'s bounds. `src` is read
/// with `readInt` because it comes straight out of an IPC message buffer and carries no
/// alignment guarantee. 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 {
if (src.len < @as(usize, w) * h * 4) return;
var ty: u32 = 0;
while (ty < h) : (ty += 1) {
const yy = dy + @as(i32, @intCast(ty));
if (yy < 0 or yy >= dst.height) continue;
const drow = dst.row(@intCast(yy));
var tx: u32 = 0;
while (tx < w) : (tx += 1) {
const xx = dx + @as(i32, @intCast(tx));
if (xx < 0 or xx >= dst.width) continue;
const off = (@as(usize, ty) * w + tx) * 4;
drow[@intCast(xx)] = std.mem.readInt(u32, src[off..][0..4], .little);
}
}
}
// --- tests ------------------------------------------------------------------
test "rect intersect: overlap and disjoint" {
try std.testing.expectEqual(Rect.init(5, 5, 5, 5), Rect.init(0, 0, 10, 10).intersect(Rect.init(5, 5, 10, 10)));
try std.testing.expect(Rect.init(0, 0, 10, 10).intersect(Rect.init(20, 20, 5, 5)).isEmpty());
}
test "rect unite: bounding box, empty is identity" {
const a = Rect.init(2, 2, 4, 4);
try std.testing.expectEqual(Rect.init(2, 1, 10, 5), a.unite(Rect.init(10, 1, 2, 2)));
try std.testing.expectEqual(a, a.unite(Rect.empty));
try std.testing.expectEqual(a, Rect.empty.unite(a));
}
test "fillRect clips to surface and honours stride padding" {
// A 4×3 surface inside a 6-wide allocation (stride 6 > width 4), like pitch padding.
var mem = [_]u32{0} ** (6 * 3);
const s = Surface{ .pixels = &mem, .stride = 6, .width = 4, .height = 3 };
fillRect(s, Rect.init(-1, -1, 3, 3), 0xAB); // straddles the top-left corner
try std.testing.expectEqual(@as(u32, 0xAB), mem[0 * 6 + 0]);
try std.testing.expectEqual(@as(u32, 0xAB), mem[1 * 6 + 1]);
try std.testing.expectEqual(@as(u32, 0), mem[0 * 6 + 2]); // beyond the 2-wide fill
try std.testing.expectEqual(@as(u32, 0), mem[2 * 6 + 0]); // row 2 untouched
try std.testing.expectEqual(@as(u32, 0), mem[0 * 6 + 4]); // stride padding untouched
}
test "composite: overlap shows the top layer, clipped to damage" {
var back = [_]u32{0} ** (8 * 8);
const dst = Surface{ .pixels = &back, .stride = 8, .width = 8, .height = 8 };
var lo = [_]u32{0x11} ** (4 * 4);
var hi = [_]u32{0x22} ** (4 * 4);
const low = Surface{ .pixels = &lo, .stride = 4, .width = 4, .height = 4 };
const high = Surface{ .pixels = &hi, .stride = 4, .width = 4, .height = 4 };
composite(dst, 0, 0, low, dst.bounds()); // bottom at (0,0)
composite(dst, 2, 2, high, dst.bounds()); // top overlaps at (2,2)
try std.testing.expectEqual(@as(u32, 0x11), back[0 * 8 + 0]); // bottom-only
try std.testing.expectEqual(@as(u32, 0x22), back[3 * 8 + 3]); // overlap top wins
try std.testing.expectEqual(@as(u32, 0x22), back[5 * 8 + 5]); // top-only
try std.testing.expectEqual(@as(u32, 0), back[7 * 8 + 7]); // neither
}
test "composite honours the damage rectangle" {
var back = [_]u32{0} ** (8 * 8);
const dst = Surface{ .pixels = &back, .stride = 8, .width = 8, .height = 8 };
var fill = [_]u32{0x33} ** (8 * 8);
const layer = Surface{ .pixels = &fill, .stride = 8, .width = 8, .height = 8 };
composite(dst, 0, 0, layer, Rect.init(2, 2, 2, 2)); // only this damage region
try std.testing.expectEqual(@as(u32, 0x33), back[2 * 8 + 2]);
try std.testing.expectEqual(@as(u32, 0x33), back[3 * 8 + 3]);
try std.testing.expectEqual(@as(u32, 0), back[1 * 8 + 1]); // outside damage
try std.testing.expectEqual(@as(u32, 0), back[4 * 8 + 4]); // outside damage
}
test "blitTile copies a packed tile, clipping and reading unaligned bytes" {
var back = [_]u32{0} ** (4 * 4);
const dst = Surface{ .pixels = &back, .stride = 4, .width = 4, .height = 4 };
// A 2×2 tile in a byte buffer offset by one byte, so reads are unaligned.
var raw = [_]u8{0} ** (1 + 2 * 2 * 4);
const tile = raw[1..];
for (0..4) |i| std.mem.writeInt(u32, tile[i * 4 ..][0..4], @intCast(0xA0 + i), .little);
blitTile(dst, 3, 3, tile, 2, 2); // bottom-right corner; only (3,3) lands on-surface
try std.testing.expectEqual(@as(u32, 0xA0), back[3 * 4 + 3]);
try std.testing.expectEqual(@as(u32, 0), back[0]); // nothing else touched
}

View File

@ -0,0 +1,397 @@
//! /system/services/display the display service (docs/display.md). A ring-3 process
//! that claims the framebuffer the kernel seeded (docs/display-plan.md D1), owns it as a
//! **write-combining front buffer**, composites an ordered stack of **layers** into a
//! **cacheable back buffer**, and presents finished frames the GUI track's compositor,
//! the sibling of the input service. Reached by name over `ServiceId.display`.
//!
//! A layer is a server-owned surface (its own cacheable buffer) with a screen position,
//! z-order, and visibility. Clients create layers and draw into them by command
//! (`fill_rect`, `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,
//! flush it to the screen. The pixel math lives in the pure, host-tested
//! [compositor.zig](compositor.zig); this file wires real surfaces and the framebuffer to
//! it. Shared-memory client surfaces are a later milestone (docs/display.md).
const std = @import("std");
const runtime = @import("runtime");
const compositor = @import("compositor.zig");
const protocol = runtime.display_protocol;
const ipc = runtime.ipc;
const system = runtime.system;
const device = runtime.device;
const Rect = compositor.Rect;
const Surface = compositor.Surface;
/// The claimed framebuffer and its off-screen twin. The front buffer is the LFB
/// write-combining, so it is **only ever written**, never read; all compositing happens
/// in the cacheable back buffer, which is then streamed to the front (docs/display.md).
const Display = struct {
device_id: u64,
front: [*]volatile u8, // the LFB (write-combining)
back: [*]u8, // cacheable, same geometry
width: u32,
height: u32,
pitch: u32, // bytes per row (shared by both buffers)
format: u32, // a device-abi DisplayFormat value
frames: u64 = 0,
};
var display: Display = undefined;
/// The wallpaper the compositor clears damaged regions to before painting layers.
var background: u32 = 0;
/// 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
/// screen region since the last `present`, so a present touches only what changed.
const maximum_layers = 16;
const Layer = struct {
used: bool = false,
x: i32 = 0,
y: i32 = 0,
z: u32 = 0,
visible: bool = false,
surface: Surface = undefined,
surface_len: usize = 0, // for munmap on destroy
};
var layers: [maximum_layers]Layer = [_]Layer{.{}} ** maximum_layers;
var damage: Rect = Rect.empty;
/// Enumeration buffer kept off the stack a `DeviceDescriptor` is large, and this
/// service only ever needs one scan.
var device_table: [64]device.DeviceDescriptor = undefined;
// --- geometry helpers -------------------------------------------------------
fn screenRect() Rect {
return .{ .x = 0, .y = 0, .w = @intCast(display.width), .h = @intCast(display.height) };
}
fn backSurface() Surface {
return .{
.pixels = @ptrCast(@alignCast(display.back)),
.stride = display.pitch / 4, // pitch is bytes; a 32-bpp row is pitch/4 pixels
.width = display.width,
.height = display.height,
};
}
fn layerScreenRect(l: *const Layer) Rect {
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.
fn addDamage(r: Rect) void {
damage = damage.unite(r.intersect(screenRect()));
}
// --- layer operations (called from onMessage and the self-check) ------------
fn freeLayer() ?u32 {
for (&layers, 0..) |*l, i| {
if (!l.used) return @intCast(i);
}
return null;
}
/// A used layer by id, or null if the id is out of range or free.
fn layerAt(id: u32) ?*Layer {
if (id >= maximum_layers or !layers[id].used) return null;
return &layers[id];
}
fn createLayer(x: i32, y: i32, w: u32, h: u32, z: u32, visible: bool) ?u32 {
if (w == 0 or h == 0) return null;
const slot = freeLayer() orelse return null;
const len = @as(usize, w) * h * 4;
const base = system.mmap(len, system.PROT_READ | system.PROT_WRITE);
if (system.mmapFailed(base)) return null;
layers[slot] = .{
.used = true,
.x = x,
.y = y,
.z = z,
.visible = visible,
.surface = .{ .pixels = @ptrFromInt(base), .stride = w, .width = w, .height = h },
.surface_len = len,
};
return slot;
}
fn fillLayer(id: u32, local: Rect, colour: u32) bool {
const l = layerAt(id) orelse return false;
compositor.fillRect(l.surface, local, colour);
// Damage in screen space = the fill, translated by the layer origin, within the layer.
const screen = Rect{ .x = l.x + local.x, .y = l.y + local.y, .w = local.w, .h = local.h };
addDamage(screen.intersect(layerScreenRect(l)));
return true;
}
fn blitLayer(id: u32, x: i32, y: i32, w: u32, h: u32, pixels: []const u8) bool {
const l = layerAt(id) orelse return false;
compositor.blitTile(l.surface, x, y, pixels, w, h);
const screen = Rect{ .x = l.x + x, .y = l.y + y, .w = @intCast(w), .h = @intCast(h) };
addDamage(screen.intersect(layerScreenRect(l)));
return true;
}
fn configureLayer(id: u32, x: i32, y: i32, z: u32, visible: bool) bool {
const l = layerAt(id) orelse return false;
addDamage(layerScreenRect(l)); // the old footprint must repaint
l.x = x;
l.y = y;
l.z = z;
l.visible = visible;
addDamage(layerScreenRect(l)); // and the new one
return true;
}
fn destroyLayer(id: u32) bool {
const l = layerAt(id) orelse return false;
addDamage(layerScreenRect(l));
_ = system.munmap(@intFromPtr(l.surface.pixels), l.surface_len);
l.* = .{};
return true;
}
// --- compositing + present --------------------------------------------------
/// Repaint the damaged region `clip` of the back buffer: clear it to the background, then
/// paint every visible layer that overlaps it, bottom to top (ascending z).
fn compositeInto(clip: Rect) void {
const back = backSurface();
compositor.fillRect(back, clip, background);
// z-order the used, visible layers (n 16; a plain insertion sort of indices).
var order: [maximum_layers]u32 = undefined;
var n: usize = 0;
for (layers, 0..) |l, i| {
if (l.used and l.visible) {
order[n] = @intCast(i);
n += 1;
}
}
var a: usize = 1;
while (a < n) : (a += 1) {
const key = order[a];
var b: usize = a;
while (b > 0 and layers[order[b - 1]].z > layers[key].z) : (b -= 1) order[b] = order[b - 1];
order[b] = key;
}
for (order[0..n]) |i| {
const l = layers[i];
compositor.composite(back, l.x, l.y, l.surface, clip);
}
}
/// Stream the damaged rectangle from the cacheable back buffer to the write-combining
/// front buffer, row by row (sequential writes what WC memory wants; we never read the
/// front buffer). Only the visible width of each row is touched.
fn flushRect(rect: Rect) void {
const c = rect.intersect(screenRect());
if (c.isEmpty()) return;
var y: i32 = c.y;
while (y < c.bottom()) : (y += 1) {
const off = @as(usize, @intCast(y)) * display.pitch;
const src: [*]const u32 = @ptrCast(@alignCast(display.back + off));
const dst: [*]volatile u32 = @ptrCast(@alignCast(display.front + off));
var x: i32 = c.x;
while (x < c.right()) : (x += 1) dst[@intCast(x)] = src[@intCast(x)];
}
}
/// Composite and flush the accumulated damage, then clear it. A no-op when nothing is
/// dirty. The frame counter advances regardless, so callers can name frames.
fn present() void {
const dirty = damage.intersect(screenRect());
if (!dirty.isEmpty()) {
compositeInto(dirty);
flushRect(dirty);
}
damage = Rect.empty;
display.frames += 1;
}
// --- startup self-check -----------------------------------------------------
/// Prove the compositor wiring on the real framebuffer: two overlapping opaque layers,
/// composited, must show the top layer in the overlap and the bottom layer outside it.
/// Exercises the whole path mmap surfaces, the z-sort, damage, composite into the back
/// buffer and reads the composited result back. Cleans up after itself.
fn selfCheck() void {
const red = protocol.pack(display.format, 0xC0, 0x20, 0x20);
const green = protocol.pack(display.format, 0x20, 0xC0, 0x20);
const bottom = createLayer(100, 100, 80, 80, 0, true) orelse return fail_check("create");
const top = createLayer(140, 140, 80, 80, 1, true) orelse return fail_check("create");
_ = fillLayer(bottom, Rect.init(0, 0, 80, 80), red);
_ = fillLayer(top, Rect.init(0, 0, 80, 80), green);
present();
const back = backSurface();
const overlap = back.pixels[@as(usize, 150) * back.stride + 150]; // in both layers top
const bottom_only = back.pixels[@as(usize, 110) * back.stride + 110]; // bottom only
_ = destroyLayer(top);
_ = destroyLayer(bottom);
present(); // repaint the self-check region back to the background
if (overlap == green and bottom_only == red) {
_ = system.write("display: compositor self-check ok\n");
} else {
_ = system.write("display: compositor self-check FAILED\n");
}
}
fn fail_check(_: []const u8) void {
_ = system.write("display: compositor self-check FAILED (setup)\n");
}
// --- service ----------------------------------------------------------------
/// The framebuffer node the kernel seeded (`DeviceClass.display`), or null if none.
fn findDisplay() ?device.DeviceDescriptor {
const total = device.enumerate(&device_table);
const n = @min(total, device_table.len);
for (device_table[0..n]) |d| {
if (d.class == @intFromEnum(device.DeviceClass.display)) return d;
}
return null;
}
fn initialise(endpoint: ipc.Handle) bool {
_ = endpoint;
// Find the framebuffer, retrying while device discovery catches up with our spawn.
var tries: u32 = 0;
const found = while (tries < 100) : (tries += 1) {
if (findDisplay()) |d| break d;
system.sleep(50);
} else {
_ = system.write("display: no framebuffer device (headless?)\n");
return false; // clean exit: nothing to drive
};
if (!device.claim(found.id)) {
_ = system.write("display: could not claim the framebuffer\n");
return false;
}
// Resource 0 is the framebuffer memory window; the kernel maps it write-combining
// because the resource carries that flag (docs/display-plan.md D1).
const front_base = device.mmioMap(found.id, 0) orelse {
_ = system.write("display: could not map the framebuffer\n");
return false;
};
const geometry = found.display;
const size = @as(usize, geometry.height) * geometry.pitch;
const back_base = system.mmap(size, system.PROT_READ | system.PROT_WRITE);
if (system.mmapFailed(back_base)) {
_ = system.write("display: could not allocate the back buffer\n");
return false;
}
display = .{
.device_id = found.id,
.front = @ptrFromInt(front_base),
.back = @ptrFromInt(back_base),
.width = geometry.width,
.height = geometry.height,
.pitch = geometry.pitch,
.format = geometry.format,
};
background = protocol.pack(display.format, 0x20, 0x30, 0x48); // a dark slate wallpaper
// Clear the whole screen through the back buffer present path (double buffering:
// no direct-to-LFB drawing).
addDamage(screenRect());
present();
var line: [96]u8 = undefined;
_ = system.write(std.fmt.bufPrint(&line, "display: online {d}x{d} pitch {d} format {d}\n", .{
display.width, display.height, display.pitch, display.format,
}) catch "display: online\n");
_ = system.write("display: presented frame 0\n");
selfCheck();
return true;
}
fn writeReply(reply: []u8, value: protocol.Reply) usize {
const bytes = std.mem.asBytes(&value);
@memcpy(reply[0..bytes.len], bytes);
return bytes.len;
}
fn ok(reply: []u8) usize {
return writeReply(reply, .{ .status = 0 });
}
fn fail(reply: []u8) usize {
return writeReply(reply, .{ .status = -1 });
}
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
_ = sender;
_ = capability;
if (message.len < protocol.request_size) return fail(reply);
const request = std.mem.bytesToValue(protocol.Request, message[0..protocol.request_size]);
const payload = message[protocol.request_size..];
// Switch on the raw operation value an out-of-range one must fail cleanly, not
// panic an `@enumFromInt`.
switch (request.operation) {
@intFromEnum(protocol.Operation.info) => return writeReply(reply, .{
.status = 0,
.width = display.width,
.height = display.height,
.pitch = display.pitch,
.format = display.format,
}),
@intFromEnum(protocol.Operation.create_layer) => {
// x/y are signed coordinates carried in the u32 wire fields reinterpret the
// bits (@bitCast), don't range-check (@intCast) which a negative would fail.
const slot = createLayer(@bitCast(request.x), @bitCast(request.y), request.width, request.height, request.z, request.visible != 0) orelse return fail(reply);
return writeReply(reply, .{ .status = 0, .layer = slot });
},
@intFromEnum(protocol.Operation.configure_layer) => {
return if (configureLayer(request.layer, @bitCast(request.x), @bitCast(request.y), request.z, request.visible != 0)) ok(reply) else fail(reply);
},
@intFromEnum(protocol.Operation.destroy_layer) => {
return if (destroyLayer(request.layer)) ok(reply) else fail(reply);
},
@intFromEnum(protocol.Operation.fill_rect) => {
const local = Rect.init(@bitCast(request.x), @bitCast(request.y), @intCast(request.width), @intCast(request.height));
return if (fillLayer(request.layer, local, request.colour)) ok(reply) else fail(reply);
},
@intFromEnum(protocol.Operation.blit_tile) => {
return if (blitLayer(request.layer, @bitCast(request.x), @bitCast(request.y), request.width, request.height, payload)) ok(reply) else fail(reply);
},
@intFromEnum(protocol.Operation.damage) => {
const l = layerAt(request.layer) orelse return fail(reply);
const screen = Rect{ .x = l.x + @as(i32, @bitCast(request.x)), .y = l.y + @as(i32, @bitCast(request.y)), .w = @intCast(request.width), .h = @intCast(request.height) };
addDamage(screen.intersect(layerScreenRect(l)));
return ok(reply);
},
@intFromEnum(protocol.Operation.present) => {
present();
return ok(reply);
},
else => return fail(reply),
}
}
pub fn main() void {
runtime.service.run(protocol.message_maximum, .{
.service = .display,
.init = initialise,
.on_message = onMessage,
});
}
pub const panic = runtime.panic;
comptime {
_ = &runtime.start._start;
}

View File

@ -0,0 +1,90 @@
//! The display wire protocol what a client says to the display service over its
//! well-known `.display` endpoint. extern-struct messages with an `Operation` tag, the
//! same shape as block/vfs/input protocols. The compositor owns the framebuffer and an
//! ordered stack of **layers**; a client creates layers, draws into them with these
//! operations, marks damage, and asks for a `present`. v1 surfaces are server-owned (a
//! client draws by command); shared-memory surfaces are a later milestone (docs/display.md).
const std = @import("std");
pub const Operation = enum(u32) {
/// info() -> { width, height, pitch, format }: the display's current mode.
info = 0,
/// create_layer(x, y, width, height, z) -> { layer }: a new server-owned surface.
create_layer = 1,
/// configure_layer(layer, x, y, z, visible): move, restack, show, or hide a layer.
configure_layer = 2,
/// destroy_layer(layer): release a layer.
destroy_layer = 3,
/// fill_rect(layer, x, y, width, height, colour): fill a rectangle of a layer.
fill_rect = 4,
/// blit_tile(layer, x, y, width, height, <inline pixels>): copy a small pixel tile in.
blit_tile = 5,
/// damage(layer, x, y, width, height): mark a region dirty for the next present.
damage = 6,
/// present(): composite the dirty layers and flush to the screen.
present = 7,
};
/// The fixed request header. A `blit_tile`'s pixel payload (width*height 32-bit pixels)
/// follows this header inline in the same message, up to `maximum_payload`.
pub const Request = extern struct {
operation: u32,
layer: u32 = 0, // create/configure/destroy/fill/blit/damage: the target layer
x: u32 = 0,
y: u32 = 0,
width: u32 = 0,
height: u32 = 0,
z: u32 = 0, // create_layer / configure_layer: stacking order (higher = in front)
colour: u32 = 0, // fill_rect: the fill colour (native pixel value)
visible: u32 = 1, // configure_layer: 0 hides the layer
reserved: u32 = 0,
};
pub const Reply = extern struct {
status: i32, // 0 on success, negative on failure
reserved: u32 = 0,
// info():
width: u32 = 0,
height: u32 = 0,
pitch: u32 = 0,
format: u32 = 0, // a device-abi DisplayFormat value (0 = rgbx, 1 = bgrx)
// create_layer():
layer: u32 = 0,
reserved2: u32 = 0,
};
/// The IPC message size the kernel caps every message at `MESSAGE_MAXIMUM` (256 bytes,
/// system/kernel/ipc-synchronous.zig), so this matches it (a larger receive/reply buffer
/// is rejected with -E2BIG). A `blit_tile` therefore carries only a *small* tile inline
/// `maximum_payload` bytes = up to 54 pixels, enough for a cursor or small sprite; larger
/// bitmaps are the deferred shared-memory surface path (docs/display.md).
pub const message_maximum: usize = 256;
pub const request_size: usize = @sizeOf(Request);
pub const reply_size: usize = @sizeOf(Reply);
pub const maximum_payload: usize = message_maximum - request_size;
/// Pack an 8-bit-per-channel colour into the display's native 32-bit pixel for `format`
/// (a device-abi `DisplayFormat`: 0 = rgbx, 1 = bgrx). Shared so a `colour` in a
/// `fill_rect` request means the same thing to the client that sends it and the
/// compositor that paints it. Little-endian memory, reserved byte 0: rgbx puts red in
/// the low byte, bgrx puts blue there.
pub fn pack(format: u32, r: u8, g: u8, b: u8) u32 {
const rr: u32 = r;
const gg: u32 = g;
const bb: u32 = b;
return switch (format) {
1 => bb | (gg << 8) | (rr << 16), // bgrx
else => rr | (gg << 8) | (bb << 16), // rgbx
};
}
test "pack encodes native byte order for rgbx and bgrx" {
// rgbx: red in the low byte, blue in byte 2.
try std.testing.expectEqual(@as(u32, 0x0000_00AA), pack(0, 0xAA, 0, 0));
try std.testing.expectEqual(@as(u32, 0x00AA_0000), pack(0, 0, 0, 0xAA));
// bgrx: blue in the low byte, red in byte 2.
try std.testing.expectEqual(@as(u32, 0x0000_00AA), pack(1, 0, 0, 0xAA));
try std.testing.expectEqual(@as(u32, 0x00AA_0000), pack(1, 0xAA, 0, 0));
try std.testing.expectEqual(@as(u32, 0x0000_3020), pack(0, 0x20, 0x30, 0)); // green in byte 1
}

View File

@ -30,7 +30,7 @@ const log_path = "/mnt/usb/DANOS.LOG";
/// microkernel keeps such choices in user space, not the kernel. Drivers are absent
/// on purpose: the device manager owns those. (A future init reads this from a
/// manifest under /system/services instead of a hardcoded list.)
const boot_services = [_][]const u8{ "vfs", "input", "device-manager", "fat" };
const boot_services = [_][]const u8{ "vfs", "input", "device-manager", "fat", "display" };
var children: [boot_services.len]u32 = .{0} ** boot_services.len;
var child_count: usize = 0;

View File

@ -165,6 +165,26 @@ CASES = [
{"name": "ioport",
"expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# Display handoff (D1): the kernel seeds the loader's framebuffer as a claimable
# `display` device with a write-combining memory resource; the claim + mmio_map path
# maps it, and the leaf is genuinely write-combining (PAT entry 4), not the UC default.
{"name": "display",
"expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# Display service (D2/D3): the user-space compositor claims the framebuffer, allocates
# a cacheable back buffer, clears it, and presents that composed frame (double-buffer
# path); then a startup self-check composites two overlapping layers and confirms the
# overlap shows the top layer (D3). Matched on the service's own heartbeats.
{"name": "display-service",
"expect": r"display: online \d+x\d+ pitch \d+[\s\S]*display: presented frame 0[\s\S]*display: compositor self-check ok",
"fail": r"display: could not|self-check FAILED|CPU EXCEPTION|KERNEL PANIC"},
# Display demo (D4): a separate process (display-demo) drives the compositor over the
# layer client API — wallpaper + a moving rectangle + a cursor, presented in a loop.
# `display-demo: ok` is printed only after it drove a run of frames of motion through
# the service (the visible motion is a screenshot via `zig build run-x86-64`).
{"name": "display-demo",
"expect": r"display-demo: scene up[\s\S]*display-demo: ok",
"fail": r"display-demo: (no display|create failed)|display: could not|CPU EXCEPTION|KERNEL PANIC"},
# Monotonic clock (clock() syscall source): calibrated, advancing, never backwards.
{"name": "clock",
"expect": r"DANOS-TEST-RESULT: PASS",