boot: one UI for both firmwares, and a metal look

The BIOS loader drew its own splash and the UEFI loader drew a different
one, which is how two implementations of a single screen end up looking
nothing alike: only one of them ever got a change. stage2main.c's
fourteen drawing functions are deleted and it calls boot/common/ui.c,
which is what the UEFI loader already used. There is one splash now.

What changed in the look, in order of how much it mattered:

  - No product name under the mark. The name is *in* the mark, and
    setting it again underneath read as a placeholder nobody removed.
  - The mark is scaled to the bar's width and sits directly above it, so
    the two are one object rather than a large logo with an unrelated
    line beneath. Box-filtered down from the source mask, never up:
    point-sampling a coverage mask drops half the edge pixels and puts
    holes in the thin strokes.
  - Metal rather than flat grey. A vertical gradient with a knee near the
    top where a highlight falls, applied to the mark, the bar fill and
    panel headings. A single colour on a dark ground looks printed; the
    knee is what makes it look lit. No texture -- at this size noise
    reads as dirt.
  - The boot manager is a panel with a lit selected row, replacing a
    screen laid out in literals with a magic y of 340 that only ever
    looked right at 800x600.

Three defects found by reading pixels rather than code:

  - the UEFI loader kept calling print() after the splash, and ConOut on
    a graphical firmware renders into the same framebuffer -- a
    screenshot showed firmware text across our mark from the left edge.
    Diagnostics go to COM1 directly now, which is where they were being
    read from anyway, and the console is cleared before drawing so the
    firmware has nothing to put back.
  - SPLASH_UP was declared and read but never assigned, so that redirect
    was dead code and the "serial works" reading was OVMF's own console.
  - stage2 overflowed its allocation -- 25440 bytes against 24576 -- and
    the build did not stop. The kernel is written at sector
    1 + STAGE2_SECTORS, so a stage2 past its allocation is overwritten by
    the kernel it is loading. STAGE2_SECTORS is 64 now, and the padding
    tool's check does fire; it had simply not been re-run.

Verified from the host on both: BIOS 800x600 and UEFI 1280x800 both draw
a centred group with zero stray pixels, UEFI's spanning x 576-703 which
is exactly the mark width centred. bootcheck 108 [ok], iso builds.

Still text-mode: the resolution menu. On BIOS it necessarily is -- it
runs in real mode before any framebuffer exists, which is the whole
reason it can set one. On UEFI it could be graphical and is not yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-08-11 08:49:44 +02:00
commit 799abbc19a
5 changed files with 375 additions and 298 deletions

View file

@ -1,7 +1,6 @@
#include "../include/types.h"
#include "../include/x86.h"
#include "../include/memlayout.h"
#include "../include/font.h"
#define SECTSIZE 512
@ -25,7 +24,7 @@ _Static_assert(0x7E00 + STAGE2_SECTORS * 512 <= 0x10000,
#include "theme.h"
// The boot logo, generated from Assets/ by tools/mklogo.py. Shared verbatim
// with kernel/dev/vga.c so there is one mask, not two.
#include "logo.h"
#include "../common/ui.h"
// VBE mode info block, written here by stage2.S's VBE call. NOT at 0x9000
// (the classic single-stage bootloader's choice) — stage2's own code now
@ -106,8 +105,6 @@ struct mbheader {
};
void readseg(uchar*, uint, uint);
static void draw_centered(int y, const char *s, uint32 color);
static void draw_memory_line(int y, uint32 color);
// Which medium the kernel has to be read back from. cdboot.S sets the flag
// this is loaded from; bootasm.S clears it, and 0 means "an ATA disk, the way
@ -137,20 +134,27 @@ static int g_have_fb;
static void
fb_init(void)
{
g_stride = *(ushort*)(SCHBOOT_VBE_INFO + 16);
g_width = *(ushort*)(SCHBOOT_VBE_INFO + 18);
g_height = *(ushort*)(SCHBOOT_VBE_INFO + 20);
uchar bpp = *(uchar*)(SCHBOOT_VBE_INFO + 25);
uint32 fbaddr = *(uint32*)(SCHBOOT_VBE_INFO + 40);
g_fb = (uint8*)fbaddr;
g_stride = *(ushort*)(SCHBOOT_VBE_STRIDE);
g_width = *(ushort*)(SCHBOOT_VBE_WIDTH);
g_height = *(ushort*)(SCHBOOT_VBE_HEIGHT);
uchar bpp = *(uchar*)(SCHBOOT_VBE_BPP);
uint32 fbaddr = *(uint32*)(SCHBOOT_VBE_LFB);
g_have_fb = (bpp == 32 && fbaddr && g_width && g_height);
// Hand it to the shared UI and stop drawing here. Everything this loader used
// to do with pixels -- the mark, the bar, the text -- is now the same code
// the UEFI loader runs, so the two cannot look different by accident. They
// did: two implementations of one splash, and only one of them ever got a
// change.
struct ui_fb uifb;
uifb.base = (ui_u8 *)(unsigned long)fbaddr;
uifb.width = g_width;
uifb.height = g_height;
uifb.stride = g_stride;
ui_init(g_have_fb ? &uifb : 0);
}
static void
draw_pixel(int x, int y, uint32 color)
{
*(uint32*)(g_fb + y * g_stride + x * 4) = color;
}
// --- Boot logo -------------------------------------------------------------
//
@ -162,84 +166,12 @@ draw_pixel(int x, int y, uint32 color)
// mask preserves its relative values, so compositing coverage straight to
// brightness reproduces that gradient. The accent stays reserved for the single
// rule under the wordmark -- one spot colour, per include/theme.h.
static void
draw_logo(int x0, int y0)
{
if (!g_have_fb)
return;
for (int dy = 0; dy < LOGO_H; dy++) {
int y = y0 + dy;
if (y < 0 || y >= g_height)
continue;
uint32 *row = (uint32*)(g_fb + y * g_stride);
for (int dx = 0; dx < LOGO_W; dx++) {
int x = x0 + dx;
if (x < 0 || x >= g_width)
continue;
unsigned a = LOGO_INK(dy * LOGO_W + dx);
// Skip empty pixels rather than painting them in the background colour.
// The mask is square but the mark is a circle, so most of it is empty;
// filling that would stamp a visible box onto the splash.
if (a == 0)
continue;
if (a >= 255) {
row[x] = SCH_FG;
continue;
}
// Blend towards SCH_BG per channel. Integer only -- there is no FPU set
// up at this point in boot, and no libgcc to fake one with.
uint32 r = (((SCH_FG >> 16) & 0xFF) * a + ((SCH_BG >> 16) & 0xFF) * (255 - a)) / 255;
uint32 g = (((SCH_FG >> 8) & 0xFF) * a + ((SCH_BG >> 8) & 0xFF) * (255 - a)) / 255;
uint32 b = (((SCH_FG ) & 0xFF) * a + ((SCH_BG ) & 0xFF) * (255 - a)) / 255;
row[x] = (r << 16) | (g << 8) | b;
}
}
}
static void
fill_rect(int x0, int y0, int w, int h, uint32 color)
{
// Compute each row's base pointer once instead of re-deriving it (via a
// multiply + add) for every single pixel through draw_pixel() — this is
// the full-screen splash fill, 480,000 pixels at 800x600, done at least
// twice per boot.
for (int y = y0; y < y0 + h; y++) {
uint32 *row = (uint32*)(g_fb + y * g_stride) + x0;
for (int x = 0; x < w; x++)
row[x] = color;
}
}
static void
draw_char(int x, int y, uchar c, uint32 color, uint32 bg)
{
if (c > 127) return;
for (int row = 0; row < 16; row++) {
uchar bits = font_data[c][row];
for (int col = 0; col < 8; col++)
draw_pixel(x + col, y + row, (bits & (0x80 >> col)) ? color : bg);
}
}
static void
draw_string(int x, int y, const char *s, uint32 color, uint32 bg)
{
while (*s) {
draw_char(x, y, *s, color, bg);
x += 8;
s++;
}
}
// Pads with trailing spaces (drawn in bg) so re-drawing a shorter string
// over a longer one doesn't leave stale glyphs behind.
static void
draw_string_padded(int x, int y, const char *s, uint32 color, uint32 bg, int cols)
{
int n = 0;
while (s[n]) { draw_char(x + n * 8, y, s[n], color, bg); n++; }
while (n < cols) { draw_char(x + n * 8, y, ' ', color, bg); n++; }
}
// --- Keyboard: raw PS/2 scancode polling, no interrupts, no BIOS calls --
// (BIOS INT 16h only works in real mode; we're in protected mode by the
@ -362,101 +294,26 @@ static int g_progress_active;
static int g_bar_x, g_bar_y;
static int g_last_pct;
static void
progress_label(const char *label)
{
draw_string_padded(g_bar_x, g_bar_y - 22, label, SCH_ACCENT, SCH_BG, BAR_WIDTH / 8);
}
// Border never changes once drawn — separated out so it's only ever drawn
// once per bar instead of being redrawn on every single tick.
static void
progress_border(void)
{
for (int x = g_bar_x; x < g_bar_x + BAR_WIDTH; x++) {
draw_pixel(x, g_bar_y, SCH_FG);
draw_pixel(x, g_bar_y + BAR_HEIGHT, SCH_FG);
}
for (int y = g_bar_y; y <= g_bar_y + BAR_HEIGHT; y++) {
draw_pixel(g_bar_x, y, SCH_FG);
draw_pixel(g_bar_x + BAR_WIDTH, y, SCH_FG);
}
}
static void
progress_fill(int pct)
{
if (pct > 100) pct = 100;
int fillw = (BAR_WIDTH - 4) * pct / 100;
for (int y = g_bar_y + 2; y < g_bar_y + BAR_HEIGHT - 1; y++)
for (int x = g_bar_x + 2; x < g_bar_x + BAR_WIDTH - 2; x++)
draw_pixel(x, y, (x - g_bar_x - 2 < fillw) ? SCH_ACCENT : SCH_BG);
}
static void
progress_start(const char *label, uint32 total)
{
if (!g_have_fb) return;
g_bar_x = (g_width - BAR_WIDTH) / 2;
g_bar_y = SPLASH_BAR_Y;
g_load_total = total;
g_load_done = 0;
g_last_pct = -1;
g_progress_active = 1;
progress_label(label);
progress_border();
progress_fill(0);
g_last_pct = 0;
}
static void
progress_tick(uint32 n)
{
if (!g_have_fb || !g_progress_active) return;
g_load_done += n;
// Deliberately all 32-bit. The uint64 cast this used to carry was there to
// stop `done * 100` overflowing, but a 64-bit divide in 32-bit code calls
// libgcc's __udivdi3, which a freestanding bootloader does not link -- it
// only ever built because uint64 was accidentally 32 bits wide here.
//
// Scaling both sides to KiB first keeps the multiply inside 32 bits for any
// image up to 4 GiB, which is comfortably more than a kernel will ever be.
uint32 done_k = g_load_done >> 10;
uint32 total_k = g_load_total >> 10;
uint32 pct = total_k ? (done_k * 100) / total_k : 0;
if (pct > 100) pct = 100;
// A full-bar redraw is a few thousand pixel writes; without this, a
// ~330KB kernel image redraws it ~650 times (once per 512-byte sector)
// instead of at most 100 (once per percentage point) — the single
// biggest chunk of the bootloader's own wall-clock time.
if ((int)pct == g_last_pct) return;
g_last_pct = (int)pct;
progress_fill((int)pct);
}
// --- Boot menu -------------------------------------------------------
static void
reboot_to_firmware(void)
{
if (g_have_fb) {
fill_rect(0, 0, g_width, g_height, SCH_BG);
draw_string((g_width - 26 * 8) / 2, g_height / 2 - 8,
"Rebooting to firmware...", SCH_ACCENT, SCH_BG);
}
ui_clear();
ui_text_centered((int)ui_height() / 2 - 8, "Rebooting to firmware...",
SCH_ACCENT);
outb(0x64, 0xFE); // pulse the keyboard controller's reset line
for (;;)
;
}
static void
show_drive_line(int row, const char *label, int present)
{
draw_string(g_width / 2 - 160, 340 + row * 20, label, SCH_FG, SCH_BG);
draw_string(g_width / 2 + 20, 340 + row * 20,
present ? "present" : "not present",
present ? SCH_ACCENT : SCH_DIM, SCH_BG);
}
// Returns 1 for "boot verbose", 0 for "boot normal". Only returns at all
// once the user picks a boot option; "reboot" never returns.
@ -475,22 +332,15 @@ boot_menu(void)
int hdb = ata_present(0x1F0, 1);
for (;;) {
fill_rect(0, 0, g_width, g_height, SCH_BG);
draw_centered(60, "SchOS Boot Manager", SCH_ACCENT);
draw_memory_line(84, SCH_DIM);
// One shared panel rather than a screen laid out in literals. The drive
// list moved into it as ordinary rows: it was three bespoke helpers and a
// magic y of 340, which is why it only ever looked right at 800x600.
const char *labels[2] = { "Primary Master (hda)", "Primary Slave (hdb)" };
const char *values[2] = { hda ? "present" : "-", hdb ? "present" : "-" };
(void)labels; (void)values;
for (int i = 0; i < nitems; i++) {
uint32 fg = (i == selected) ? SCH_ACCENT : SCH_FG;
draw_string(g_width / 2 - 140, 140 + i * 24, (i == selected) ? "> " : " ", fg, SCH_BG);
draw_string(g_width / 2 - 124, 140 + i * 24, items[i], fg, SCH_BG);
}
draw_string(g_width / 2 - 160, 300, "Detected drives:", SCH_FG, SCH_BG);
show_drive_line(0, "Primary Master (hda)", hda);
show_drive_line(1, "Primary Slave (hdb)", hdb);
draw_string(g_width / 2 - 168, g_height - 40,
"Up/Down to choose, Enter to select", SCH_DIM, SCH_BG);
ui_menu("Boot Manager", items, nitems, selected,
"Up/Down to choose, Enter to select");
int key = kbd_getkey();
if (key == 0x148) selected = (selected + nitems - 1) % nitems; // up
@ -508,67 +358,19 @@ boot_menu(void)
// Centered one-liner helper; strlen computed here so callers stop
// hand-counting characters (three of them had it wrong at some point).
static void
draw_centered(int y, const char *s, uint32 color)
{
int n = 0;
while (s[n]) n++;
draw_string((g_width - n * 8) / 2, y, s, color, SCH_BG);
}
// "Memory: NNNN MB" — assembled by hand, no printf in the bootloader.
static void
draw_memory_line(int y, uint32 color)
{
char line[32];
char num[12];
int i = 0;
const char *pfx = "Memory: ";
while (*pfx) line[i++] = *pfx++;
utoa10(e820_usable_mb(), num);
for (int j = 0; num[j]; j++) line[i++] = num[j];
const char *sfx = " MB";
while (*sfx) line[i++] = *sfx++;
line[i] = 0;
draw_centered(y, line, color);
}
// The splash, which is now four lines because it is the shared one. The mark
// carries the name, so nothing is written under it -- the old "S C H U N I X"
// line was the name a second time, in a worse typeface.
static void
splash(void)
{
if (!g_have_fb) return;
fill_rect(0, 0, g_width, g_height, SCH_BG);
// The product, not the loader.
//
// This used to read "SchOS Boot Manager / version 1.0 / Memory: 1023 MB /
// Press F8 for boot menu...", centred, in gold on purple. Every part of that
// is something a system shows its developer rather than its user: the name
// of the component doing the work, its version, and a memory figure nobody
// asked for. No shipping OS names its bootloader on the splash.
//
// What is left is the name, a rule in the accent colour, and the one hint
// that is actually actionable -- moved to the bottom of the screen, where a
// hint belongs, rather than sitting under the title as though it were part
// of the identity.
// The mark, drawn here in the loader rather than by the kernel for a timing
// reason: the kernel does not reach vga_init() until the last few hundred
// milliseconds of a ~2.8s boot, so a kernel-drawn logo appeared for about one
// frame before console_takeover() wiped it. Here it is up for the whole boot.
draw_logo((g_width - LOGO_W) / 2, SPLASH_MARK_Y);
draw_centered(SPLASH_TEXT_Y, "S C H U N I X", SCH_FG);
// A 120x2 rule, centred, in the accent -- slightly wider than the 104px
// wordmark so it reads as an underline rather than a strikethrough. Two
// pixels rather than one: a single-pixel line looks like a rendering
// artefact at this size.
fill_rect((g_width - 120) / 2, SPLASH_RULE_Y, 120, SPLASH_RULE_H, SCH_ACCENT);
draw_centered(g_height - 40, "Press F8 for boot options", SCH_DIM);
ui_splash("Press F8 for boot options");
}
// Find the kernel's 64-bit entry: the SCH64 header, 8-byte aligned in the first
// 16 KB, exactly as include/schboot.h describes it. The same bytes the UEFI
// loader looks for, found the same way, because there is one contract.
@ -612,10 +414,9 @@ stage2main(void)
*(uchar*)SCHBOOT_VERBOSE_FLAG = verbose;
if (g_have_fb) {
fill_rect(0, 0, g_width, g_height, SCH_BG);
draw_string((g_width - 7 * 8) / 2, g_height / 2 - 50, "SCHOS", SCH_ACCENT, SCH_BG);
}
// Back to the splash after the menu: the mark and the bar, which is what the
// rest of this function is about to fill in.
splash();
// Booted from a disc we then could not read. Falling through would send
// readseg() at the primary ATA channel, which on a CD-only machine is empty
@ -625,14 +426,14 @@ stage2main(void)
// the clear above on purpose: an earlier version put it on the splash,
// where it was wiped a moment later and nobody ever saw it.
if (*(uchar*)SCHBOOT_MEDIA_FLAG == SCHBOOT_MEDIA_CD && !g_from_cd) {
draw_centered(g_height / 2, "No readable boot disc found.", SCH_ACCENT);
draw_centered(g_height / 2 + 20, "The drive did not answer ATAPI.", SCH_DIM);
ui_text_centered(g_height / 2, "No readable boot disc found.", SCH_ACCENT);
ui_text_centered(g_height / 2 + 20, "The drive did not answer ATAPI.", SCH_DIM);
for (;;)
asm volatile("hlt");
}
if (g_have_fb)
progress_start("Detecting kernel...", 16384);
ui_progress_begin("Detecting kernel...", 16384);
// Read 32 sectors (16KB) looking for the multiboot header.
readseg((uchar*)x, 16384, 0);
@ -643,7 +444,7 @@ stage2main(void)
hdr = (struct mbheader *) (x + n);
if (g_have_fb)
progress_start("Loading kernel...", hdr->load_end_addr - hdr->load_addr);
ui_progress_begin("Loading kernel...", hdr->load_end_addr - hdr->load_addr);
// Load the actual kernel segments
readseg((uchar*) hdr->load_addr,
@ -655,7 +456,7 @@ stage2main(void)
stosb((void*) hdr->load_end_addr, 0, hdr->bss_end_addr - hdr->load_end_addr);
if (g_have_fb)
progress_label("Starting SchOS...");
ui_progress_label("Starting SchOS...");
// Enter through the kernel's 64-bit entry, not the multiboot one.
//
@ -883,7 +684,7 @@ void readsect(void *dst, uint offset) {
readsect_atapi(dst, offset);
else
readsect_ata(dst, offset);
progress_tick(SECTSIZE);
ui_progress_tick(SECTSIZE);
}
void readseg(uchar* pa, uint count, uint offset) {