SchOS/docs/architecture/graphics.md
Nexoniarz 182cc76588 gfx: the second head shows the boot, not just what comes after it
Two things, both asked for.

**The crash report no longer says which monitors it reached.** "The report is
on the virtio-gpu display too" was a line about the crash screen printed on the
crash screen. The report says what happened and where to read it again; that is
not part of it.

**And the second head now shows the F8 menu and the boot.** It used to be black
until vgpud started several seconds into userspace -- through the splash,
through the menu, through every boot message -- which from the outside is
indistinguishable from a display that does not work. Three stages now cover the
whole boot and hand over to each other without coordinating:

*The loader.* LocateProtocol() answers with the first GOP and offers no way to
ask for the rest, so the rest are found by handle and fed the primary's
framebuffer as a Blt source at the points where a screen is finished: the menu,
the resolution chooser, system information, the splash, each segment of the
kernel load. Blt rather than a second framebuffer to draw into, because a
virtio GOP does not have one -- the same fact that made a virtio-only machine
reboot-loop two commits ago.

*The kernel.* gfx remembers which rows were drawn, as one span rather than a
list of rectangles, because console output is lines and a span is what lines
make. display_mirror_console() copies that span across: a line of boot text is
one cell-row transfer, not a screen. Primed once after pci_init(), because a
display that has just appeared has a black scanout and the console it should be
showing was drawn by the loader into a framebuffer it knows nothing about.

*Then vgpud*, from the same console framebuffer, unchanged.

**Two bugs found building it, both worth the retelling.**

The mirror ran per character from inside vt_feed -- which is inside the console
lock -- and vga_disp_acquire() announces its back buffer with a cprintf. A CPU
taking a lock it already holds, with a call chain that said exactly that. It
runs at the end of consolewrite() and cprintf() now, outside the lock, which is
also the better batching: one transfer per write instead of one per character.

And "is this the console's own screen" was asked of the pointer acquire()
returned. vga_fb hands back a back buffer it copies onto the console's
framebuffer afterwards, so on a one-screen machine the answer was "a different
screen" -- and the console was mirrored onto itself through two megabytes of
intermediate, allocated for the purpose. display_ops grew scanout_phys, which
is a fact about the hardware rather than a policy a driver can get subtly
wrong, and it is asked first: it has no side effects and acquire() has two.

Verified with two heads under QEMU, screendumping the virtio head every 400 ms
from power-on: splash, splash, splash, then the console, and it stays. The F8
menu appears on it; choosing 1024x768 there brings both heads up at 1024x768
byte-identical. A panic still reaches both. Both supported configurations: one
boot each, no traps, no panics, socktest and memfdtest green.

One gap remains and is documented rather than hidden: while the kernel's driver
resets the device during probe, QEMU shows "Display output is not active" for a
fraction of a second. A device being taken over has no scanout until the new
driver sets one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:47:35 +02:00

502 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Graphics, text and the console
Four things share this area and they are deliberately separate: the kernel's own
drawing surface, the compositor's display interface, the text-rendering layer,
and the crash screen.
---
## The two display interfaces, and why there are two
This distinction causes confusion every time somebody meets it, so it is the
first thing here.
| | [`gfx.h`](../../include/gfx.h) | [`display.h`](../../include/display.h) |
|---|---|---|
| **What** | The kernel's own drawing surface | The compositor's interface |
| **About** | Pixels and text | *Frames* |
| **Operations** | `draw_pixel`, `fill_rect`, `putc`, `text_at` | `acquire`, `present`, `wait_vblank` |
| **Buffering** | None. Direct writes to the scanout. | Double-buffered |
| **Users** | The console and PSOGM | Schcom |
| **Must work when** | **Nothing else in the system does** | Userspace is healthy |
That last row is the reason they are not one interface. The console and the
crash screen cannot depend on a back buffer being allocatable, on a driver
having a working present path, or on anything that could be the thing that just
broke.
### `gfx` — the scanout
A driver that owns a linear framebuffer publishes it with
`gfx_attach_scanout()`, and everything above draws through `gfx_*` calls.
This used to be `kernel/dev/vga.c`, and every consumer called it by that name:
`panic.c` drew the crash screen with `vga_fill_rect`, the console printed with
`vga_putc`, `/dev/fb0` handed out `vga_fb_phys()`. None of that has anything to
do with a VGA — it is bitmap drawing over a linear framebuffer, and the only
VGA-specific part was where the framebuffer came from. A second display driver
is now a second caller of one function rather than an edit to the crash screen.
`main()` names no display driver. Whichever one owns the framebuffer the loader
left running says so before any bus is enumerated, because the console draws
long before PCI is walked.
### `display` — the class
`struct display_ops`, found with `device_find_class(DEVCLASS_DISPLAY)`.
`acquire()` allocates the back buffer **on first call, never at boot** — it is
around two megabytes at 800×600, and a machine whose compositor never starts
should not pay that. A driver that cannot double-buffer returns 0, and the
caller draws directly and accepts tearing rather than not drawing at all.
`wait_vblank` is optional for the same reason.
`echo d > /proc/sysrq` drives the whole path and draws a test pattern.
## Drivers
| | |
|---|---|
| **VBE framebuffer** (`gpu/vga/`) | Whatever the loader set up. Always present; this is what the splash and the console are drawn on. |
| **virtio-gpu** (`gpu/virtio_gpu/`) | 2D, written from the OASIS specification. Full path: `GET_DISPLAY_INFO`, `RESOURCE_CREATE_2D`, `RESOURCE_ATTACH_BACKING`, `SET_SCANOUT`, `TRANSFER_TO_HOST_2D`, `RESOURCE_FLUSH`. |
virtio-gpu is offered the bus **before** the VBE driver, so on a machine with
both, the device with real mode setting and an explicit flush wins the display
class. `vga_pci` still claims the stdvga card underneath, which is what the boot
splash is drawn on — so `-vga std` must stay on the QEMU command line even when
adding `-device virtio-gpu-pci`, because the loader's splash goes through VBE
and there is no virtio path that early.
### A framebuffer at physical zero is not a framebuffer
That last sentence used to be advice. It is now enforced, because ignoring it
did not produce a machine with no splash — it produced a **reboot loop**.
virtio-gpu has no linear aperture, so OVMF's virtio GOP implements `Blt` and
nothing else. On a machine whose only display is one, the firmware reports a
width and a height and a `FrameBufferBase` of **zero**. Two places took that at
face value:
- `kmain()` tested `if (bi.ptr && …)` — with `bi.ptr` already run through
`P2V()`, and `P2V(0)` is `KERNBASE`, which is not zero. The guard could not
fail, and its own comment said *"drawing into address zero is a worse failure
than staying quiet"*. It now tests the physical value before translating it.
- `vga_fb_init()` attached the scanout regardless, one layer down.
Either one wrote four megabytes over physical 0 — the real-mode IVT, the BIOS
data area, the AP trampoline at `0x7000`, the early page tables — and the
machine reset before it could print anything, came back to the firmware, and
did it again.
Both refuse now and say so. A machine with no usable linear framebuffer gets no
graphical console, which is the truth about it; the serial line still has
everything.
**A virtio-gpu-only machine still does not reach a shell.** It no longer
corrupts memory and it explains itself, but the console path assumes a
framebuffer — `vt_init()` reports a terminal of `0 by 0` cells — and that path
has never been exercised. Supported today is stdvga, with virtio-gpu optional
beside it.
`/dev/fb0` speaks the Linux fbdev interface: geometry through the fbdev ioctls,
and `mmap` of the real framebuffer at its real physical address.
> `mmap` of `/dev/fb0` is also **the kernel's only chance to notice that the
> screen no longer belongs to the text console** — after that the drawing
> happens with no further syscalls. `fb_mapped()` is called there, and
> `fbclose()` puts it back.
## What the second head shows
A machine with both a stdvga and a virtio-gpu has two displays, and `vgpud` —
the out-of-kernel driver, `cmd/vgpud/main.c` — owns the second one. For a long
time it drew a test card there: colour bands, gradients, a checkerboard, and
its own pid in binary along the bottom. That was scaffolding for
[../plan/hybrid.md](../plan/hybrid.md)'s H2 and H3, where a screenshot had to
answer *did a userspace process put this here* and *did the driver come back*.
Both of those are closed, so the test card became the Shift mark. Which was
honest about ownership and useless about everything else: **a second monitor
showing a logo for ever is indistinguishable from a second monitor that does
not work**, and that is exactly what it got called.
It shows the console now.
### Before vgpud there is, the kernel and the loader do it
`vgpud` starts several seconds into userspace. Until then the second head used
to be black — through the splash, through the F8 menu, through every boot
message — which from the outside is indistinguishable from a display that does
not work. Three stages now cover the whole boot, and they hand over to each
other without coordinating:
**The loader.** `LocateProtocol()` answers with the first GOP and offers no way
to ask for the rest, so the rest are found by handle (`LocateHandleBuffer`,
`ByProtocol`). Each is fed the *primary's framebuffer* as a `Blt` source, at
the points where a screen is finished: the F8 menu, the resolution chooser,
system information, the splash, each segment of the kernel load. Blt rather
than a second framebuffer to draw into, because a virtio GOP does not have one
— the same fact that made a virtio-only machine reboot-loop.
**The kernel.** `display_mirror_console()` copies the console's newly-drawn
rows to the display class whenever that is a *different screen*. Cheap because
`gfx` remembers which rows those were: a line of boot text is one cell-row
transfer, not a screen.
Two things about that were learned the hard way, and both are load-bearing:
- **It runs outside the console lock**, at the end of `consolewrite()` and
`cprintf()` rather than per character inside them. A display driver is
entitled to say something — `vga_disp_acquire()` announces its back buffer —
and `cprintf()` from inside that lock is a CPU taking a lock it already
holds. The panic's call chain said so plainly, which is the argument for
keeping the call chain in the report.
- **"Is this the console's own screen" is asked of the scanout**, not of the
buffer `acquire()` returns. `vga_fb` hands back a back buffer that it later
copies onto the console's framebuffer, so comparing pointers answered "a
different screen" about the only screen there is — and mirrored the console
onto itself through two megabytes of intermediate. `display_ops` grew
`scanout_phys` to answer it as a fact about the hardware.
**Then vgpud**, from the same console framebuffer, by the mechanism below. The
handover needs no coordination in either direction: the moment `vgpud` claims
the device the kernel's `display_acquire()` returns nothing, and if `vgpud`
exits the kernel's driver takes it back and the first line of output repaints
the screen.
There is one visible gap, and it is honest: while the kernel's driver resets
the device during probe, QEMU shows *"Display output is not active"* for a
fraction of a second. A device being taken over has no scanout until the new
driver sets one, and the same blink happens when `vgpud` claims it.
### A copy with a diff
The console framebuffer is mapped through `/dev/fb0`; the virtio backing store
is already mapped, because `vgpud` allocated it. So the backing holds, at all
times, exactly what was last sent to the device — which means it *is* the
previous frame, and answering "what changed" needs no third buffer. Ten times
a second: compare each row, skip the ones that match, copy the ones that do
not, and transfer the single rectangle spanning the rows that changed.
This is why `present()` became `present_rect()`. Moving one line of text with a
full-screen transfer of a 1280×800 resource is most of a megabyte of copy for a
few hundred bytes of difference, and the loop runs whether or not anything is
happening.
Mapping `/dev/fb0` does not take the screen away from the console — the
mapping is of the same physical pages the console draws into, and the kernel's
`fb_mapped()` only arranges a redraw when the fd is *closed*. `vgpud` never
closes it.
A head larger than the console centres it on the theme ground. A head smaller
shows the top-left corner, which is where a terminal puts everything worth
seeing. Scaling is the alternative and it is the wrong one: text resampled to a
non-integer ratio is text you cannot read.
**This is not the endpoint.** A compositor (Gate 3) will want this head for
itself, and when there is one, the mirror goes away. Until then the honest
thing for a working display to show is the system running on it.
### The F8 resolution is the head's resolution
`vgpud` used to size itself from `GET_DISPLAY_INFO`, which answers with the
size of the *host's window* — a number the person running QEMU sets by dragging
a corner, and which says nothing about the machine. The resolution the F8 menu
offered at boot is the one that was actually chosen, and it is the console's.
A head that mirrors the console and ignores that choice shows the right pixels
in the wrong mode: pick 1024×768 at the menu and the second display stayed at
whatever the window was, with the console letterboxed inside it. So
`console_open()` runs *before* `bring_up()`, and the mode comes from the
console when there is one.
That ordering is the whole of it, and it is not cosmetic: the DMA allocation is
sized from those numbers and fixed for the life of the process, so a mode
chosen after the fact could only ever be smaller than the window that was
already paid for.
### /run is not a tmpfs
Found while testing the above, and older than it: `schmode` reported *"cannot
reach vgpud on /run/vgpud — it is not running"* about a process that was
running perfectly well, on every boot after the first.
`bind()` creates a node at the path, and `/run` is a directory on the root
filesystem rather than a tmpfs. So the node outlived the process, and the boot.
The next boot's `vgpud` claimed the device, drew, held it — and failed `bind()`
on a name that already existed, leaving a driver nobody could talk to and an
error message that named the wrong cause.
`vgpud` unlinks the path before binding it, which is what a Unix daemon does.
The usual objection — that it lets a second instance steal a live socket — does
not apply: two `vgpud`s cannot coexist, because the first holds the PCI claim
and the second is refused the device long before it reaches `bind()`. The claim
is the mutual exclusion; the socket node never was.
## Text
[`include/text.h`](../../include/text.h) + `kernel/dev/text.c`.
**The font was never the problem — the character-cell grid was.** The console
draws text by indexing `font_data[256][16]` and stepping the cursor eight
pixels. A grid cannot do variable-width glyphs, cannot position text anywhere
except on a multiple of eight, cannot anti-alias, and cannot represent a
codepoint above 255. Swapping the bitmap for a nicer bitmap changes none of that.
So the text layer fixes three things:
- **Glyphs have metrics** — size, bearing, advance. That *is* variable-width
text; everything else follows from it.
- **It draws into a `struct surface`, not into the screen.** A compositor draws
off-screen and presents; rendering that writes straight to the VGA aperture is
unusable to it.
- **The font is behind `struct font_ops`.** The bitmap font is one
implementation and a rasteriser is another, arriving without any caller
changing — glyphs carry 8-bit coverage, so an anti-aliased outline renderer
already has somewhere to put its output.
### The TrueType rasteriser
`kernel/dev/ttf.c`: `glyf` outlines, composite glyphs with their component
transforms, `cmap` format 4, anti-aliased scanline fill, with a host harness at
`tools/ttftest.c`. A direct-mapped glyph cache in a 64 KB pool: one reserved
slot per printable ASCII character, plus 224 shared slots keyed by codepoint
for everything above it.
**It is the console font.** The regular weight is linked into the kernel image
by the `consfont` rule in the Makefile and opened at 16 px before `vt_init()`,
which is why `gfx_font_init()` prints nothing — there is no terminal to print
to yet. A failure there is not fatal; the 8x16 bitmap font is still the
fallback, and `gfx_font_ready()` says which one is in use.
**Two bugs here were the same bug.** `KSTACKSIZE` is 4096, and both the render
path and the composite-glyph path put multi-kilobyte outlines on it as locals.
They corrupted whatever was below the stack, which surfaced as user processes
faulting on addresses they never touched and a panic inside `freevm()` — never
anywhere near a font. Neither showed in the host harness, which has an 8 MB
stack. That is the standing hazard of a rasteriser built from one source for
both: the host is most forgiving about exactly the resource the kernel is
strictest about. The outlines are `static` now, one per recursion level, and
`load_simple()` is split out so the leaf's 512-byte flag array is not
multiplied by the recursion depth.
**Component transforms are applied, not skipped.** An earlier version read a
composite component's offset and stepped over its scale, reasoning that a wrong
transform is worse than none. The left single quote in JetBrains Mono is the
comma glyph with a scale of -1 — a 180-degree rotation — and its offset only
makes sense after the rotation. Dropping the scale put the mark twelve pixels
into a ten-pixel cell, where it was clipped away completely, so the character
read as missing from the font rather than as misplaced.
**The typefaces**, both licence-checked at source:
| Font | Where | Licence |
|---|---|---|
| **JetBrains Mono** | terminal and TTY | SIL OFL 1.1 |
| **Roboto** | desktop and compositor | Apache-2.0 |
JetBrains Mono declares **no Reserved Font Name**, which matters more than it
sounds: pre-rasterising a font into bitmaps at build time is a modification, and
an RFN would forbid keeping the name on the result.
## The console
`kernel/core/console.c`. A cursor, scrolling, and an ANSI escape parser
including SGR colours and the erase operations.
**`cons_serial_only` starts set.** The display is the product; the serial line
is the diagnostics. A machine that boots by printing `pci: 6 devices` and
`vga: display controller 1234:1111, aperture fd000000` to the screen the user is
looking at is showing them a developer's log, and that single fact is most of
what makes an OS look like a hobby project. Everything still goes out the UART.
`console_takeover()` clears the flag on the first `exec()`.
The console uses a cell grid whose size comes from the console font's measured
advance rather than a hardcoded 8x16. Porting it onto the text layer's
proportional path and deleting the grid is open work.
Two things the ANSI parser got wrong until a large program nobody here wrote
exercised it properly:
**`ESC[m` with no parameters is a reset**, and `sgr()` iterated its parameter
list — which is empty in that case, so the loop body never ran and the reset was
dropped. ECMA-48 says an omitted parameter takes its default and SGR's default
is 0. Programs emit the bare form constantly, so every colour set became
permanent; with a *background* colour that meant the whole console went
white-on-white and stayed there, which reads as a corrupted framebuffer rather
than a missing branch.
**OSC sequences must be consumed even when they are ignored.** `ESC ]` was not a
state, so the payload printed as text and a hyperlinked path arrived as
`Disk (]8;;file:///\]8;;\)`. There is no window title to set here and no
hyperlink to follow, but discarding the body is not optional — the alternative
is printing somebody's URL into the middle of their output.
### Input
Keys reach both `/dev/input/eventN` and the tty line discipline, which is
correct for two consumers of one keyboard and completely wrong for a full-screen
program — the shell behind it quietly accumulates every keystroke and runs the
lot at the next Enter.
`EVIOCGRAB` is the fix and it is a primitive rather than a patch. Three details
matter more than the feature:
- **The grab is released by the `close` hook, not by the program.** A program
that grabs the keyboard and then crashes would otherwise leave the console
permanently deaf, with no way to type the command that fixes it.
- **Modifier state is still tracked while grabbed**, or releasing the grab hands
the line discipline a stuck Shift.
- `/dev/input/eventN` **drops its backlog on open** and only queues while
something has it open. Without that, opening the device replayed the
keystrokes of the command line that had launched the program.
## PSOGM — the crash screen
`kernel/core/panic.c`. The Purple Screen of Guru Meditation. Built around three
decisions:
**It takes the whole screen.** It used to dim what was there and float a panel
over it, on the argument that a crash screen which wipes the display tells you
the machine is gone while one that leaves your work visible tells you it
stopped. That looked good and was wrong: the thing is called a Purple Screen,
and half a screen of abandoned work behind the report is a distraction at the
worst possible moment.
**It is not frightening.** No red, no capitals, no "FATAL". A calm sentence,
the Shift mark — the same one the machine drew on the splash it started with —
then the facts. Whoever is reading this is already having a bad time.
The mark replaced **Guru**, a chamfered face drawn from primitives that lived
only here. Guru's argument was identity: `:(` is recognisably Microsoft's, and
a crash screen is the screenshot most likely to leave the machine. That holds.
What does not follow is that the answer is a *second* mark — see
[../branding/README.md](../branding/README.md).
**It says considerably more than it needs to.** Everything cheap to collect is
collected: stop code, fault address, which CPU, which process, how far into the
boot, where the kernel is loaded, and a call chain — and since
[debugging.md](debugging.md)'s symbol table, the call chain is *names*:
`panic+0x5f`, `proc_sysrq_cmd+0xb3`, `sys_writev+0x12b`. All of it goes to the
serial line in the same order, which is what `bootcheck` reads and what you get
when the crash happens before there is a framebuffer at all.
Once painted, `panicked` is set and `consputc` spins rather than writing, so
nothing can scribble over the report.
### It reaches every display, including the ones the kernel gave away
The report used to go to the console framebuffer and stop there. On a machine
with two heads that meant one screen saying the kernel was dead and another
showing a shell prompt from a few seconds earlier — the most misleading thing
a screen can do.
`display_panic_present()` in `kernel/dev/device.c` walks the rest of them, and
there are two cases:
**The kernel still drives it.** `display_acquire()` hands back a buffer; the
report is copied in and presented. This is the ordinary path, and it does
nothing at all when the display class's buffer *is* the console's — a machine
with one screen does not get the work done twice.
**A process drives it.** Harder, and the one worth having. The kernel let go of
the device deliberately, and the process holding it will never run again —
there is no scheduling after a panic. So `vgpud` registers its scanout in
advance (`PCIIOC_PANICFB`), the kernel paints the report into that memory, and
the driver the device was taken from is asked to put it on screen one last
time: `panic_present` in `struct pci_driver`.
For virtio-gpu that means resetting the device out from under a driver that is
still nominally running — safe here and nowhere else, because nothing is going
to run again — and rebuilding one queue from 20 KB of static memory. No
allocation, no locks, no sleeping, and every spin bounded: a crash screen that
hangs has taken away the serial log's last chance to be read.
Neither announces itself on the serial line. The report says what happened and
where to read it again; which monitors it reached is not part of that, and a
crash screen is not the place to narrate the crash screen.
### It is no longer the only copy
This screen used to be the only artefact of a panic — this file said so, and so
did `panic.c`'s own header. That stopped being true when the kernel log started
outliving the kernel, and the closing lines changed to match:
```
This report is in /proc/kmsg after a restart.
schdbg, or Ctrl-G on the console, asks more.
PSOGM -- Purple Screen of Guru Meditation
```
The first line replaced an `addr2line` command, which the call chain no longer
needs. It is the more useful instruction by a wide margin, and it is the one
nobody would guess: **restart and read it** beats photographing the screen.
The QR code below is still drawn, and still earns its place — it is the only
channel that works when the machine will not come back at all.
### The QR code
`kernel/core/qr.c`. **A panic report is worth nothing if it cannot leave a
machine whose network stack is dead**, and a phone camera is an out-of-band
channel needing no driver on our side.
It encodes the **report itself**, not a URL — it reaches nothing. Deliberately
small in three ways: byte mode only, EC level L only (the screen is a clean,
evenly lit, high-contrast source; the damage QR's stronger levels protect
against does not apply), and versions 1–6 only, stopping short of version 7's
extra version-information blocks and version 10's wider count field.
134 bytes is a large crash report. If a payload ever needs more, the honest fix
is to shorten the report.
It draws unconditionally rather than on a keypress, which is better: it costs
nothing, and requiring input would mean the one case where input is least
trustworthy is also the case where the report is hardest to retrieve. Verified
by `make crashshot && make crashqr`, which decodes the symbol out of the
captured framebuffer.
## One palette
[`include/theme.h`](../../include/theme.h). There were three unrelated designs
and the seams showed: the splash was deep purple with gold text deliberately
matching the *panic* screen, the console was cream with near-black text, and so
a healthy boot was dressed as a crash and then abruptly became an amber
terminal.
> **The rule: normal states are calm and dark; only failure is loud.**
Boot, console and shell share `SCH_BG`/`SCH_FG` — near-black with a slight blue
cast rather than pure `#000`, because pure black on an LCD reads as "the machine
is off". PSOGM keeps the full-bleed purple entirely to itself, which is what
makes it mean something.
---
## What it does not do
- **The console still has its cell grid.** Cells are now a Unicode codepoint
drawn by the TrueType rasteriser rather than a CP437 byte drawn from a bitmap,
and the cell size follows the font's measured advance — but it is still a
fixed grid, and the text layer's proportional path is unused by it.
- **No mode setting.** Resolution is whatever the loader asked for. There is no
`/dev/dri`-style device and no way to change it at runtime.
- **No double buffering in practice.** `display_ops.acquire` is specified and
the compositor needs it; virtio-gpu binds but exposes no modeset API, and
`wait_vblank` is unimplemented everywhere.
- **No hardware acceleration of any kind**, and none is planned before 2D blit.
- **No CFF/Type 2 charstrings**, so OTF fonts do not render — that is a separate
`font_ops` provider and a bytecode interpreter, not an extension of the `glyf`
path. TrueType hinting is deliberately out of scope.
- **No text layout library.** Advance widths exist per glyph; kerning, shaping
and line breaking do not.
- **The glyph cache has 224 slots above ASCII**, direct-mapped by codepoint
modulo that. One European language fits without collisions; mixing scripts
costs collisions, and a full pool is emptied and refilled rather than evicted
entry by entry, because a linear pool cannot reclaim one glyph's bytes.
- **No composite point-matching.** Components positioned by matching a point in
the base rather than by an offset are placed at the origin. Fonts that use it
are rare and none in the tree do.
- **PSOGM renders and stops.** The interactive crash mode described in
[../plan/roadmap.md](../plan/roadmap.md) — walk the stack, read another
process's memory, dump page tables — is designed and not built.