SchOS/docs/architecture/graphics.md
Nexoniarz a4cfecd19c vgpud: the second head shows the console, not a logo
"but Virtio GPU is still non functional. it only shows logo" -- and that was
a fair reading of it. A second monitor showing a mark for ever is
indistinguishable from a second monitor that does not work.

The mark was the right answer to the question hybrid.md asked: H2 and H3 needed
a screenshot that proved a *userspace process* had drawn something, and that
the driver came back after being killed. Both are closed. What was left was a
display whose entire output was a logo, on a machine whose console was sitting
on the other head.

So vgpud mirrors the console onto it.

**A copy with a diff.** /dev/fb0 maps the console's framebuffer; the virtio
backing store is already mapped, because vgpud allocated it. The backing
therefore holds, at all times, exactly what was last sent to the device --
which means it *is* the previous frame, and "what changed" needs no third
buffer. Ten times a second: compare rows, skip the ones that match, copy the
ones that do not, transfer the one rectangle spanning the rows that changed.

Hence present() becoming present_rect(). Moving one line of text through a
full-screen transfer of a 1280x800 resource is most of a megabyte of copy for
a few hundred bytes of difference, and this loop runs whether or not anything
is happening.

Mapping /dev/fb0 does not take the screen from the console: the pages are the
ones the console draws into, and fb_mapped() only arranges a redraw when the fd
is closed. This one never is.

Sizes differ, so a head larger than the console centres it on the theme ground
the mark already draws, and a smaller head 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. The blocking accept() became poll() with the frame interval as its
timeout, because holding the device is no longer the only thing to do.

Not the endpoint: a compositor will want this head for itself, and when there
is one this goes away. Until then the honest thing for a working display to
show is the system that is running on it.

**And a bug older than any of it.** schmode reported "cannot reach vgpud on
/run/vgpud -- it is not running" about a process that was running perfectly
well. Every boot after the first, on any image whose root is writable, and
nothing to do with this change -- it reproduces on the previous commit, 3 runs
in 4.

bind() creates a node at the path, and /run is a directory on the root
filesystem rather than a tmpfs. The node outlived the process that bound it and
the boot it was bound in, so the next boot's vgpud claimed the device, drew,
held it -- and then failed bind() on a name that already existed. A driver
nobody could talk to, and an error message naming the wrong cause.

It unlinks before binding now, which is what a Unix daemon does. The usual
objection, that this lets a second instance steal a live socket, does not apply
here: two vgpuds 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.

Verified by screendumping both heads over QMP: with the console at 1280x800 the
two are byte-identical, and after `schmode 1024 768` the virtio head carries
the console at the new mode. Three consecutive boots of one image all reach a
working control socket, where the previous commit managed one. Both supported
configurations: one boot each, no traps, no panics, socktest and memfdtest
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:31:18 +02:00

20 KiB
Raw Blame History

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 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'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.

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.

/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 vgpuds 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 + 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.

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'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 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. 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 — walk the stack, read another process's memory, dump page tables — is designed and not built.