182 lines
11 KiB
Markdown
182 lines
11 KiB
Markdown
# 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
|
||
|
||
D1–D5 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.
|