boot: a shared UI, and a graphical splash on UEFI

The first piece of the two loaders that is genuinely common code rather
than a contract they both honour. It can be, because the difference
between the firmwares is entirely in how a framebuffer is *obtained* --
VBE in real mode, or GOP -- and not in what you do with one afterwards.
boot/common/ui.c takes a base, a size and a stride and draws; it knows
nothing about disks, memory maps or mode setting, which is what lets it
compile into a 16/32/64-bit BIOS loader and a PE/COFF UEFI application
without either leaking into it.

The look is deliberately quiet: dark ground, the mark centred, one thin
bar beneath it, nothing on screen that is not load-bearing. The whole
group is laid out from the centre outwards rather than from the top down,
so it sits identically at 800x600 and at 1600x1200 instead of drifting up
the screen as the mode grows.

UEFI had no graphical splash at all -- it printed text to a console the
QEMU window is usually not even showing. It draws the same screen as the
BIOS path now, with the bar tracking bytes of kernel actually written
rather than a segment count, which says nothing about how long each takes.

Two ordering bugs found by looking at the pixels rather than the code:
setup_framebuffer ran *after* the kernel was read, so every progress tick
went to a framebuffer that did not exist yet and the splash then painted
over the result; and that also put the resolution prompt after the
slowest step instead of at the start. The display is set up first now.

Verified from the host with screendump: background is exactly SCH_BG, the
bar is 4px tall and width/4 wide at y=495 on a 1280x800 screen, which is
where centring the group puts it. UEFI still boots to cpu0; BIOS
bootcheck 27/27.

Not yet shared: the BIOS loader still draws with its own copy of this
code. Deleting that copy in favour of this one is the next step and the
point of the exercise -- until then this is one loader using shared code
and one duplicating it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-08-10 22:26:54 +02:00
commit 64e4835524
4 changed files with 378 additions and 4 deletions

View file

@ -390,10 +390,12 @@ kernel: $(BUILD)/kernel/kernel
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
UEFI_CFLAGS = --target=x86_64-unknown-windows -ffreestanding -fshort-wchar -mno-red-zone -fno-stack-protector -Wall -Wextra -O1 -I./include UEFI_CFLAGS = --target=x86_64-unknown-windows -ffreestanding -fshort-wchar -mno-red-zone -fno-stack-protector -Wall -Wextra -O1 -I./include
$(BUILD)/uefi/BOOTX64.EFI: boot/uefi/main.c boot/uefi/efi.h include/schboot.h $(BUILD)/uefi/BOOTX64.EFI: boot/uefi/main.c boot/uefi/efi.h include/schboot.h \
boot/common/ui.c boot/common/ui.h
@mkdir -p $(BUILD)/uefi @mkdir -p $(BUILD)/uefi
clang $(UEFI_CFLAGS) -c boot/uefi/main.c -o $(BUILD)/uefi/main.obj clang $(UEFI_CFLAGS) -c boot/uefi/main.c -o $(BUILD)/uefi/main.obj
lld-link -subsystem:efi_application -entry:EfiMain -nodefaultlib $(BUILD)/uefi/main.obj -out:$@ clang $(UEFI_CFLAGS) -c boot/common/ui.c -o $(BUILD)/uefi/ui.obj
lld-link -subsystem:efi_application -entry:EfiMain -nodefaultlib $(BUILD)/uefi/main.obj $(BUILD)/uefi/ui.obj -out:$@
uefi: $(BUILD)/uefi/BOOTX64.EFI uefi: $(BUILD)/uefi/BOOTX64.EFI

262
boot/common/ui.c Normal file
View file

@ -0,0 +1,262 @@
/* SPDX-License-Identifier: Apache-2.0 */
/*
* The loader's user interface. See ui.h for why this can be shared at all.
*
* The look is deliberately quiet: a dark ground, the mark centred, one thin
* bar beneath it, and no text that is not load-bearing. A loader is not a
* place to be interesting -- it is looked at for four seconds by someone who
* wants to be somewhere else, and the useful thing it can do is stay still and
* tell them it is working.
*/
#include "ui.h"
#include "../../include/theme.h"
#include "../../include/font.h"
#include "../../include/logo.h"
/* "Draw the glyph, leave the background alone." Not a colour anyone can mean:
* the palette is 24-bit, so the high byte is free for a sentinel. */
#define SCH_BG_NONE 0xFF000000u
static struct ui_fb FB;
static int ACTIVE;
/* Progress state. Kept here rather than passed around because the caller ticks
* it from inside a read loop that has no business carrying UI state. */
static ui_u32 PROG_TOTAL, PROG_DONE;
static int PROG_X, PROG_Y, PROG_W, PROG_H;
static int PROG_LAST_PX; /* pixels already filled */
static int PROG_ACTIVE;
/* Layout constants. One place, because a splash that is laid out by scattered
* literals is one nobody dares move. */
#define LOGO_GAP 28 /* logo bottom to product name */
#define NAME_GAP 22 /* product name to the bar */
#define SUB_GAP 18 /* bar to the subtitle under it */
#define BAR_W_FRAC 4 /* bar is width/4 ... */
#define BAR_W_MIN 220 /* ... but never narrower than this */
#define BAR_H 4 /* thin: it is a hint, not a gauge */
#define GLYPH_W 8
#define GLYPH_H 16
void
ui_init(const struct ui_fb *fb)
{
ACTIVE = 0;
if (!fb || !fb->base || !fb->width || !fb->height)
return;
FB = *fb;
if (!FB.stride)
FB.stride = FB.width * 4;
ACTIVE = 1;
}
int ui_active(void) { return ACTIVE; }
ui_u32 ui_width(void) { return ACTIVE ? FB.width : 0; }
ui_u32 ui_height(void) { return ACTIVE ? FB.height : 0; }
static void
put(int x, int y, ui_u32 colour)
{
if (!ACTIVE || x < 0 || y < 0 || (ui_u32)x >= FB.width || (ui_u32)y >= FB.height)
return;
*(ui_u32 *)(FB.base + (ui_u64)y * FB.stride + (ui_u64)x * 4) = colour;
}
void
ui_fill_rect(int x, int y, int w, int h, ui_u32 colour)
{
if (!ACTIVE)
return;
for (int r = 0; r < h; r++) {
int yy = y + r;
if (yy < 0 || (ui_u32)yy >= FB.height)
continue;
/* One row at a time through a row pointer: put() per pixel costs a
* bounds check and a multiply for every one of them, and this is used
* to clear a whole screen. */
ui_u32 *row = (ui_u32 *)(FB.base + (ui_u64)yy * FB.stride);
for (int c = 0; c < w; c++) {
int xx = x + c;
if (xx >= 0 && (ui_u32)xx < FB.width)
row[xx] = colour;
}
}
}
void
ui_clear(void)
{
ui_fill_rect(0, 0, (int)FB.width, (int)FB.height, SCH_BG);
}
static void
glyph(int x, int y, ui_u8 c, ui_u32 colour, ui_u32 bg)
{
for (int row = 0; row < GLYPH_H; row++) {
ui_u8 bits = font_data[c][row];
for (int col = 0; col < GLYPH_W; col++) {
if (bits & (0x80 >> col))
put(x + col, y + row, colour);
else if (bg != SCH_BG_NONE)
put(x + col, y + row, bg);
}
}
}
void
ui_text(int x, int y, const char *s, ui_u32 colour, ui_u32 bg)
{
if (!ACTIVE || !s)
return;
for (int i = 0; s[i]; i++)
glyph(x + i * GLYPH_W, y, (ui_u8)s[i], colour, bg);
}
static int
slen(const char *s)
{
int n = 0;
while (s && s[n])
n++;
return n;
}
void
ui_text_centered(int y, const char *s, ui_u32 colour)
{
if (!ACTIVE)
return;
int w = slen(s) * GLYPH_W;
ui_text(((int)FB.width - w) / 2, y, s, colour, SCH_BG_NONE);
}
void
ui_logo(int y)
{
if (!ACTIVE)
return;
int x0 = ((int)FB.width - LOGO_W) / 2;
/* The logo is a 4-bit coverage mask, not a bitmap: each nibble says how
* much of that pixel the mark covers, and the colour is blended against the
* background by that amount. Drawing it as on/off would give a mark with
* stepped edges that looks wrong at every size. */
for (int y1 = 0; y1 < LOGO_H; y1++) {
for (int x1 = 0; x1 < LOGO_W; x1++) {
unsigned idx = (unsigned)y1 * LOGO_W + (unsigned)x1;
unsigned cov = (logo_mask[idx >> 1] >> ((idx & 1) ? 0 : 4)) & 0xF;
if (!cov)
continue;
ui_u32 fg = SCH_FG, bg = SCH_BG;
ui_u32 r = (((fg >> 16) & 0xFF) * cov + ((bg >> 16) & 0xFF) * (15 - cov)) / 15;
ui_u32 g = (((fg >> 8) & 0xFF) * cov + ((bg >> 8) & 0xFF) * (15 - cov)) / 15;
ui_u32 b = (((fg ) & 0xFF) * cov + ((bg ) & 0xFF) * (15 - cov)) / 15;
put(x0 + x1, y + y1, (r << 16) | (g << 8) | b);
}
}
}
/* Where the bar goes. Derived, not stored, so nothing can set it and then have
* the screen change underneath it. */
static void
bar_geometry(void)
{
PROG_W = (int)FB.width / BAR_W_FRAC;
if (PROG_W < BAR_W_MIN)
PROG_W = BAR_W_MIN;
if (PROG_W > (int)FB.width - 80)
PROG_W = (int)FB.width - 80;
PROG_H = BAR_H;
PROG_X = ((int)FB.width - PROG_W) / 2;
/* Vertically: the whole group is centred, and the bar sits below the logo
* and the name. Anchoring to the centre rather than to the top is what
* keeps the arrangement identical from 800x600 up to 1600x1200. */
int group_h = LOGO_H + LOGO_GAP + GLYPH_H + NAME_GAP + PROG_H;
int top = ((int)FB.height - group_h) / 2;
PROG_Y = top + LOGO_H + LOGO_GAP + GLYPH_H + NAME_GAP;
}
void
ui_splash(const char *product, const char *subtitle)
{
if (!ACTIVE)
return;
ui_clear();
bar_geometry();
int group_h = LOGO_H + LOGO_GAP + GLYPH_H + NAME_GAP + PROG_H;
int top = ((int)FB.height - group_h) / 2;
ui_logo(top);
ui_text_centered(top + LOGO_H + LOGO_GAP, product, SCH_FG);
if (subtitle)
ui_text_centered(PROG_Y + PROG_H + SUB_GAP, subtitle, SCH_DIM);
}
void
ui_progress_begin(const char *label, ui_u32 total)
{
if (!ACTIVE)
return;
bar_geometry();
PROG_TOTAL = total ? total : 1;
PROG_DONE = 0;
PROG_LAST_PX = 0;
PROG_ACTIVE = 1;
/* The unfilled track. Drawn once: repainting it on every tick is what makes
* a progress bar flicker. */
ui_fill_rect(PROG_X, PROG_Y, PROG_W, PROG_H, SCH_ACCENT_DIM);
if (label)
ui_progress_label(label);
}
void
ui_progress_label(const char *label)
{
if (!ACTIVE || !label)
return;
/* Clear the line first. A shorter label after a longer one would otherwise
* leave the tail of the old one behind. */
int y = PROG_Y + PROG_H + SUB_GAP;
ui_fill_rect(0, y, (int)FB.width, GLYPH_H, SCH_BG);
ui_text_centered(y, label, SCH_DIM);
}
void
ui_progress_tick(ui_u32 done)
{
if (!ACTIVE || !PROG_ACTIVE)
return;
if (done > PROG_TOTAL)
done = PROG_TOTAL;
PROG_DONE = done;
int px = (int)(((ui_u64)PROG_DONE * (ui_u64)PROG_W) / PROG_TOTAL);
/* Only the newly filled part. Filling from zero each time is O(width) per
* tick and visibly slower than the thing it is reporting on. */
if (px > PROG_LAST_PX) {
ui_fill_rect(PROG_X + PROG_LAST_PX, PROG_Y, px - PROG_LAST_PX, PROG_H,
SCH_ACCENT);
PROG_LAST_PX = px;
}
}
void
ui_progress_end(void)
{
if (!ACTIVE || !PROG_ACTIVE)
return;
ui_progress_tick(PROG_TOTAL);
PROG_ACTIVE = 0;
}

71
boot/common/ui.h Normal file
View file

@ -0,0 +1,71 @@
/* SPDX-License-Identifier: Apache-2.0 */
/*
* The loader's user interface, shared by both firmwares.
*
* This is the first piece of the two loaders that is genuinely common code
* rather than a contract they both honour. It can be, because the difference
* between BIOS and UEFI is entirely in how you *obtain* a framebuffer -- VBE in
* real mode, or GOP -- and not in what you do with one afterwards. By the time
* there is a base address, a size and a stride, there is nothing firmware-
* specific left to say.
*
* Everything here draws and nothing here decides. No disk, no memory map, no
* mode setting: a caller hands over a framebuffer and asks for pixels. That is
* what makes it compilable into a 16/32/64-bit BIOS loader and into a PE/COFF
* UEFI application without either of them leaking into it.
*
* Types are spelled out rather than taken from a header, because the two
* callers do not agree on one: the BIOS side has uchar/uint32 from the kernel
* tree, the UEFI side has UINT8/UINT32 from the EFI headers, and this file has
* to compile inside both.
*/
#ifndef SCHOS_BOOT_UI_H
#define SCHOS_BOOT_UI_H
typedef unsigned char ui_u8;
typedef unsigned short ui_u16;
typedef unsigned int ui_u32;
typedef unsigned long long ui_u64;
/* A linear framebuffer, 32 bits per pixel. Both firmwares can produce one and
* neither can produce anything this code could use if they could not. */
struct ui_fb {
ui_u8 *base;
ui_u32 width;
ui_u32 height;
ui_u32 stride; /* bytes per row, from the driver -- never width*4 */
};
/* Hand over the framebuffer. Passing a null base, or a zero size, leaves the
* UI inert: every call below then does nothing, so a caller with no display
* does not need to guard each one. */
void ui_init(const struct ui_fb *fb);
int ui_active(void);
ui_u32 ui_width(void);
ui_u32 ui_height(void);
void ui_clear(void);
void ui_fill_rect(int x, int y, int w, int h, ui_u32 colour);
void ui_text(int x, int y, const char *s, ui_u32 colour, ui_u32 bg);
void ui_text_centered(int y, const char *s, ui_u32 colour);
/* The logo, centred horizontally, top-left at the given y. */
void ui_logo(int y);
/* The splash: logo, product name, and the progress bar beneath it.
*
* Laid out from the centre outwards rather than from the top down, so it looks
* the same at 800x600 and at 1280x1024 instead of drifting up the screen as the
* mode grows.
*/
void ui_splash(const char *product, const char *subtitle);
/* The progress bar. `total` is whatever unit the caller counts in; ticks are
* cumulative, and the bar only ever moves forward. */
void ui_progress_begin(const char *label, ui_u32 total);
void ui_progress_label(const char *label);
void ui_progress_tick(ui_u32 done);
void ui_progress_end(void);
#endif /* SCHOS_BOOT_UI_H */

View file

@ -26,6 +26,7 @@
#include "efi.h" #include "efi.h"
#include "../../include/schboot.h" #include "../../include/schboot.h"
#include "../common/ui.h"
// Where the kernel expects to find things comes from schboot.h, included // Where the kernel expects to find things comes from schboot.h, included
// above: the hand-off is one contract and both loaders write the same one. // above: the hand-off is one contract and both loaders write the same one.
@ -182,6 +183,22 @@ load_elf(EFI_FILE_PROTOCOL *file)
UINT64 lowest = ~0ULL, highest = 0; UINT64 lowest = ~0ULL, highest = 0;
// Two passes' worth of information from one: the loop below both loads and
// reports, so the bar tracks bytes actually written rather than a segment
// count that says nothing about how long each takes.
UINT64 total = 0, done = 0;
for (UINT16 i = 0; i < eh.e_phnum; i++) {
struct elf64_phdr ph;
UINTN m = sizeof ph;
if (EFI_ERROR(file->SetPosition(file, eh.e_phoff + (UINT64)i * eh.e_phentsize)))
break;
if (EFI_ERROR(file->Read(file, &m, &ph)) || m != sizeof ph)
break;
if (ph.p_type == PT_LOAD)
total += ph.p_memsz;
}
ui_progress_begin("Loading SchOS", (ui_u32)(total ? total : 1));
for (UINT16 i = 0; i < eh.e_phnum; i++) { for (UINT16 i = 0; i < eh.e_phnum; i++) {
struct elf64_phdr ph; struct elf64_phdr ph;
n = sizeof ph; n = sizeof ph;
@ -223,6 +240,9 @@ load_elf(EFI_FILE_PROTOCOL *file)
zero((void *)(UINTN)(ph.p_paddr + ph.p_filesz), zero((void *)(UINTN)(ph.p_paddr + ph.p_filesz),
(UINTN)(ph.p_memsz - ph.p_filesz)); (UINTN)(ph.p_memsz - ph.p_filesz));
done += ph.p_memsz;
ui_progress_tick((ui_u32)done);
if (ph.p_paddr < lowest) lowest = ph.p_paddr; if (ph.p_paddr < lowest) lowest = ph.p_paddr;
if (ph.p_paddr + ph.p_memsz > highest) highest = ph.p_paddr + ph.p_memsz; if (ph.p_paddr + ph.p_memsz > highest) highest = ph.p_paddr + ph.p_memsz;
} }
@ -446,6 +466,17 @@ setup_framebuffer(void)
*(UINT8 *)(vbe + 25) = (UINT8)bpp; *(UINT8 *)(vbe + 25) = (UINT8)bpp;
*(UINT32 *)(vbe + 40) = fb; *(UINT32 *)(vbe + 40) = fb;
// The same UI the BIOS loader draws, on the framebuffer this one just
// obtained. Everything above this point is firmware-specific; nothing below
// it is, which is the whole reason it can be shared.
struct ui_fb uifb;
uifb.base = (ui_u8 *)(UINTN)fb;
uifb.width = info->HorizontalResolution;
uifb.height = info->VerticalResolution;
uifb.stride = stride;
ui_init(&uifb);
ui_splash("SchOS", "Starting up");
print(L"schos: framebuffer "); print_hex(fb); print(L"schos: framebuffer "); print_hex(fb);
print(L" "); print_hex(info->HorizontalResolution); print(L" "); print_hex(info->HorizontalResolution);
print(L"x"); print_hex(info->VerticalResolution); print(L"x"); print_hex(info->VerticalResolution);
@ -536,14 +567,22 @@ EfiMain(EFI_HANDLE image, EFI_SYSTEM_TABLE *systab)
ST->ConOut->ClearScreen(ST->ConOut); ST->ConOut->ClearScreen(ST->ConOut);
print(L"SchOS UEFI loader\r\n"); print(L"SchOS UEFI loader\r\n");
// The display comes first, before the kernel is read.
//
// It used to be the other way round, which meant the resolution prompt
// appeared after the slowest step and the progress bar was drawn into a
// framebuffer that did not exist yet -- every tick was silently discarded
// and then the splash painted over the result. Setting the mode first also
// puts the menu where a person expects it: at the start.
UINT32 fb = setup_framebuffer();
EFI_FILE_PROTOCOL *file = open_kernel(image); EFI_FILE_PROTOCOL *file = open_kernel(image);
UINT64 entry = load_elf(file); UINT64 entry = load_elf(file);
ui_progress_end();
file->Close(file); file->Close(file);
print(L"schos: kernel entry "); print_hex(entry); print(L"\r\n"); print(L"schos: kernel entry "); print_hex(entry); print(L"\r\n");
UINT32 fb = setup_framebuffer();
// Read while boot services still exist and the system table is guaranteed // Read while boot services still exist and the system table is guaranteed
// valid; written to low memory further down, after the firmware has let go // valid; written to low memory further down, after the firmware has let go
// of it, for the same reason the e820 map is. // of it, for the same reason the e820 map is.