display: framebuffer handoff primitive + service design (D1)

Kick off the display service track (docs/display.md, docs/display-plan.md): a
user-space compositor that owns the framebuffer. GOP and the PCI display device
are two views of one controller; GOP dies at ExitBootServices, so the portable
base is the boot-handoff linear framebuffer.

D1 makes that framebuffer reachable from user space over the existing device
claim/mmio_map path rather than a bespoke syscall:

- device-abi: a `display` DeviceClass, a DisplayInfo{w,h,pitch,format} on the
  descriptor, and a flags field on resources with a write-combining bit.
- devices-broker: seedDisplay() publishes the loader's framebuffer as a
  root-level `display` node (one WC-flagged memory resource); kmain seeds it
  after discovery. displayDevice()/displayClaimed() track the claim.
- paging/mmio_map: mapUserDeviceInto gains a write_combining bool — a WC-flagged
  resource maps through PAT entry 4 instead of strong-uncacheable (an
  uncacheable framebuffer blit is glacial).
- console: falls silent while a display service holds the framebuffer, and is
  forced back on by the panic/exception paths.

Gate: the `display` kernel test asserts the seeded node's shape and that the
claim + mmio_map leaf is genuinely write-combining (PAT bit set, PCD/PWT clear).
Regression-checked discovery/ioport/claim-release/supervision/device-list/
device-manager with the +1 device in the table.
This commit is contained in:
Daniel Samson
2026-07-14 00:54:56 +01:00
parent f157a93c9c
commit cd812cc00e
12 changed files with 663 additions and 17 deletions
+146
View File
@@ -0,0 +1,146 @@
# 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.
- [ ] `system/services/display/protocol.zig`: `Operation{ info, create_layer,
configure_layer, destroy_layer, fill_rect, blit_tile, damage, present }`; `extern`
`Request`/`Reply`; `message_maximum`/`request_size`/`reply_size` consts. (Model:
[block/protocol.zig](../system/services/block/protocol.zig).)
- [ ] [abi.zig](../system/abi.zig): `ServiceId.display = 9`.
- [ ] `system/services/display/display.zig`: `main` → enumerate + claim + WC-map the LFB
(front) → `mmap` a cacheable back buffer of `height*pitch` → `runtime.service.run(
.{ .service = .display, .init, .on_message })`. Implement `info` and a whole-screen
`present` (back → front) first.
- [ ] [library/runtime/display.zig](../library/runtime/runtime.zig) (+ export in
`runtime.zig`): `info()`, and a stub `present()`. Cached `.display` lookup with
retry (model: [block.zig](../library/runtime/block.zig)).
- [ ] [init.zig](../system/services/init/init.zig): add `"display"` to `boot_services`.
- [ ] [build.zig](../build.zig): `display-protocol` module; `display` exe via
`addUserBinary`; import the protocol into `runtime` and into the exe; pack into the
initial-ramdisk; install to `/system/services/display`.
**Gate:** boot; `init` spawns `display`; it logs `display: online {w}x{h}` and clears the
screen to a colour **through the back buffer → present path** (double buffering proven —
no direct-to-LFB drawing). Screenshot confirms.
## D3 — Layer stack + compositor + damage present
The heart: composite an ordered layer stack, present only what changed.
- [ ] A layer table (fixed capacity, like the input service's subscriber table): each
layer = rect, z-order, visible flag, a server-owned surface (`mmap` cacheable).
- [ ] Implement `create_layer` / `configure_layer` / `destroy_layer`, `fill_rect`,
`blit_tile` (inline tile in the IPC message), `damage`.
- [ ] `composite()`: walk layers bottom-to-top, paint dirty regions into the back buffer
(clip to layer rect ∩ damage; handle `rgbx`/`bgrx`; step by `pitch`).
- [ ] `present()`: flush merged damage rects back → front (sequential WC writes).
- [ ] Host tests (`zig build test`): layer clipping, damage-rect merge, and a
`blit`/`fill` against a fake in-memory framebuffer for both pixel formats and a
`pitch > width*4` case.
**Gate:** `zig build test` green for the compositor unit tests; an in-service self-check
composites two overlapping layers and the overlap shows the top layer's colour.
## D4 — Client API + the demo client
Prove the pipeline end-to-end from a separate process.
- [ ] Finish [runtime/display.zig](../library/runtime/runtime.zig): a `Layer` handle with
`fill` / `blitTile` / `damage`, plus `present()`.
- [ ] `system/services/display-demo/`: a hardware-free client (the
[`input-source`](../system/services/input-source/) analog) — a wallpaper layer, a
layer with a rectangle it moves each frame, and a small cursor layer; `present`s in
a loop paced by [`runtime.time`](../library/runtime/time.zig). Wire into build +
initial-ramdisk.
**Gate:** run `run-efi`; a screenshot (or two, apart in time) shows the wallpaper, the
cursor, and the rectangle in different positions — motion, from a client, through the
compositor, on screen.
## D5 — Test case + docs
- [ ] `test/qemu_test.py display` + `displayTest` in
[tests.zig](../system/kernel/tests.zig): boot, spawn `display` + `display-demo`,
pass on `display: presented frame {N}` and `display-demo: ok` heartbeats.
- [ ] Flip [display.md](display.md)'s status notes from "planned/built" as appropriate;
confirm the [README index](README.md) entry; update the `display-track` memory to
DONE with the commit.
**Gate:** `python3 test/qemu_test.py display` passes in CI-equivalent local run.
---
## 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.