boot/uefi: an F8 menu, with the resolution chooser inside it

UEFI had no boot menu at all, and asked about resolution in a prompt of
its own before anything else. Two questions at two moments through two
mechanisms is what made the firmwares feel like different programs.

There is one window now: press F8 (or 'o' -- a serial console sends no
scan codes, and that is how this is usually driven) and the same panel
the BIOS loader shows appears, with resolution as a row in it. Selecting
it steps to the next 32bpp mode at or above 800x600 and redraws; the
framebuffer is re-read every pass, because changing mode moves it and
drawing where it used to be is a black screen with no explanation.

Two layout bugs, both visible and both arithmetic:

  - TITLE_H was 34 while the heading alone occupies PANEL_PAD + GLYPH_H
    = 34, so the rule at TITLE_H - 6 was drawn *through* the lettering
    and the first row started on the heading's baseline. TITLE_H is
    derived from its parts now and cannot drift from them again.
  - the selected row's highlight was offset by a fixed 3px against a
    24px row, so it sat on the text rather than around it. It is centred
    on the glyph box: (ROW_H - GLYPH_H) / 2 above and below.

Verified from the host: the panel is centred at 640 on a 1280 screen, the
highlight is exactly ROW_H tall and clear of the rule, and Enter boots
through to cpu0. bootcheck 108 [ok], iso builds.

The BIOS resolution chooser is still a separate text prompt, and that is
not an oversight to fix later in the same way. VBE mode setting is an
INT 10h call, so it can only happen in real mode -- before there is any
framebuffer to draw a menu in. Offering it from the graphical menu means
either returning to real mode from long mode, or entering unreal mode to
draw the menu before the switch. Both are real mode-switching work in the
riskiest code in the tree, and neither is a tidy-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-08-11 09:04:57 +02:00
commit 55a585792e
2 changed files with 156 additions and 76 deletions

View file

@ -316,9 +316,19 @@ metal_text_centered(int y, const char *s)
}
}
#define ROW_H 22
/* Panel metrics.
*
* TITLE_H is the whole title band -- the padding above the heading, the
* heading itself, and the air beneath it before the rule. It was 34 while the
* heading alone needs 18 + 16 = 34, so the rule at TITLE_H - 6 was drawn
* *through* the lettering and the first row started on the heading's baseline.
* Derived from its parts now, so it cannot drift from them again.
*/
#define ROW_H 24
#define PANEL_PAD 18
#define TITLE_H 34
#define TITLE_GAP 14 /* heading to the rule */
#define TITLE_H (PANEL_PAD + GLYPH_H + TITLE_GAP) /* = 48 */
#define ROW_LIFT ((ROW_H - GLYPH_H) / 2) /* highlight overhang */
static void
panel_frame(const char *title, int rows, const char *hint,
@ -342,7 +352,8 @@ panel_frame(const char *title, int rows, const char *hint,
panel(x, y, w, h);
metal_text_centered(y + PANEL_PAD, title);
ui_fill_rect(x + PANEL_PAD, y + TITLE_H - 6, w - PANEL_PAD * 2, 1, METAL_EDGE);
ui_fill_rect(x + PANEL_PAD, y + PANEL_PAD + GLYPH_H + TITLE_GAP / 2,
w - PANEL_PAD * 2, 1, METAL_EDGE);
if (hint)
ui_text_centered(y + h + 16, hint, SCH_DIM);
@ -367,7 +378,10 @@ ui_menu(const char *title, const char *const *items, int n, int selected,
/* The selected row is the only lit thing on the plate. Filling it
* with the accent and putting the label in the ground colour reads
* at a glance; an outline does not, at this size. */
ui_fill_rect(x + PANEL_PAD / 2, ry - 3, w - PANEL_PAD, ROW_H,
/* Centred on the glyph box: (ROW_H - GLYPH_H) / 2 above it, the
* same below. Off-by-a-few here is what makes a highlight look
* like it is sitting on the text rather than around it. */
ui_fill_rect(x + PANEL_PAD / 2, ry - ROW_LIFT, w - PANEL_PAD, ROW_H,
SCH_ACCENT);
ui_text(x + PANEL_PAD, ry, items[i], PANEL_BG, SCH_BG_NONE);
} else {

View file

@ -92,6 +92,10 @@ static EFI_BOOT_SERVICES *BS;
*/
static int SPLASH_UP;
/* Set by the boot menu; handed to the kernel through the same flag byte the
* BIOS loader writes, so a verbose boot means the same thing either way. */
static int VERBOSE_BOOT;
static void
serial_putc(char c)
{
@ -380,37 +384,53 @@ read_dec(void)
}
// Pick a GOP mode. Returns the mode number to set, or the current one.
static UINT32
choose_mode(EFI_GRAPHICS_OUTPUT_PROTOCOL *gop)
/* ---------------------------------------------------------------------------
* The boot menu: the same one the BIOS loader shows, on the same panel.
*
* Resolution is a row in here rather than a prompt of its own before it. Two
* questions asked at two different moments by two different mechanisms is what
* made the two firmwares feel like different programs.
*
* This is the half of it the UEFI side can do properly: GOP sets a mode at any
* point before ExitBootServices, so the framebuffer exists first and the
* chooser can be drawn in it. The BIOS side cannot -- see boot/bios/stage2.S.
* ------------------------------------------------------------------------- */
static void
u64_to_dec(UINT64 v, char *out, int cap)
{
UINT32 current = gop->Mode->Mode;
print(L"Press R for resolution... ");
CHAR16 c = wait_key(1500);
if (c != L'r' && c != L'R') {
print(L"keeping ");
print_dec(gop->Mode->Info->HorizontalResolution); print(L"x");
print_dec(gop->Mode->Info->VerticalResolution); print(L"\r\n");
return current;
char tmp[24];
int n = 0;
if (v == 0) tmp[n++] = '0';
while (v && n < 23) { tmp[n++] = (char)('0' + (v % 10)); v /= 10; }
int i = 0;
while (n > 0 && i < cap - 1) out[i++] = tmp[--n];
out[i] = 0;
}
// List what the firmware actually offers, 32bpp only: the console, the
// fbdev ioctls and Schcom all assume it, and PixelFormat 2 is a packed
// format this loader does not describe to the kernel.
print(L"\r\n");
for (UINT32 i = 0; i < gop->Mode->MaxMode; i++) {
EFI_GRAPHICS_OUTPUT_MODE_INFORMATION *dbg = 0;
UINTN dsz = 0;
EFI_STATUS dst = gop->QueryMode(gop, i, &dsz, &dbg);
print(L" raw "); print_dec(i); print(L" st="); print_hex(dst);
if (!EFI_ERROR(dst) && dbg) {
print(L" "); print_dec(dbg->HorizontalResolution);
print(L"x"); print_dec(dbg->VerticalResolution);
print(L" fmt="); print_dec(dbg->PixelFormat);
static void
res_row(EFI_GRAPHICS_OUTPUT_PROTOCOL *gop, char *out, int cap)
{
char wa[24], ha[24];
u64_to_dec(gop->Mode->Info->HorizontalResolution, wa, sizeof wa);
u64_to_dec(gop->Mode->Info->VerticalResolution, ha, sizeof ha);
int i = 0;
for (const char *p = "Resolution: "; *p && i < cap - 1; p++) out[i++] = *p;
for (char *p = wa; *p && i < cap - 1; p++) out[i++] = *p;
if (i < cap - 1) out[i++] = 'x';
for (char *p = ha; *p && i < cap - 1; p++) out[i++] = *p;
out[i] = 0;
}
print(L"\r\n");
}
for (UINT32 i = 0; i < gop->Mode->MaxMode; i++) {
/* Step to the next 32bpp mode at or above 800x600, wrapping. Cycling one row
* needs no scrolling state to get wrong, and a loader does not need a list. */
static void
next_mode(EFI_GRAPHICS_OUTPUT_PROTOCOL *gop)
{
UINT32 start = gop->Mode->Mode;
for (UINT32 n = 1; n <= gop->Mode->MaxMode; n++) {
UINT32 i = (start + n) % gop->Mode->MaxMode;
EFI_GRAPHICS_OUTPUT_MODE_INFORMATION *info = 0;
UINTN size = 0;
if (EFI_ERROR(gop->QueryMode(gop, i, &size, &info)) || !info)
@ -419,52 +439,96 @@ choose_mode(EFI_GRAPHICS_OUTPUT_PROTOCOL *gop)
continue;
if (info->HorizontalResolution < 800 || info->VerticalResolution < 600)
continue;
print(L" ");
print_dec(i); print(L") ");
print_dec(info->HorizontalResolution); print(L"x");
print_dec(info->VerticalResolution); print(L"\r\n");
gop->SetMode(gop, i);
return;
}
}
print(L" mode number, or Enter to type a size: ");
UINT32 want = read_dec();
static int
boot_menu(EFI_GRAPHICS_OUTPUT_PROTOCOL *gop)
{
int selected = 0;
char resbuf[48];
for (;;) {
/* Re-read the framebuffer every pass: choosing a resolution moves it,
* and drawing where it used to be is a black screen with no
* explanation. */
struct ui_fb uifb;
uifb.base = (ui_u8 *)(UINTN)gop->Mode->FrameBufferBase;
uifb.width = gop->Mode->Info->HorizontalResolution;
uifb.height = gop->Mode->Info->VerticalResolution;
uifb.stride = gop->Mode->Info->PixelsPerScanLine * 4;
ui_init(&uifb);
res_row(gop, resbuf, sizeof resbuf);
const char *items[4];
items[0] = "Boot SchOS";
items[1] = "Boot SchOS (verbose)";
items[2] = resbuf;
items[3] = "Reboot to firmware";
ui_menu("Boot Manager", items, 4, selected,
"W/S or 1-4 to choose, Enter to select");
EFI_INPUT_KEY key;
CHAR16 c = 0;
UINT16 scan = 0;
for (;;) {
if (ST->ConIn &&
!EFI_ERROR(ST->ConIn->ReadKeyStroke(ST->ConIn, &key))) {
c = key.UnicodeChar; scan = key.ScanCode; break;
}
BS->Stall(10000);
}
if (scan == 0x01) { selected = (selected + 3) % 4; continue; } /* up */
if (scan == 0x02) { selected = (selected + 1) % 4; continue; } /* down */
if (c == L'w' || c == L'W') { selected = (selected + 3) % 4; continue; }
if (c == L's' || c == L'S') { selected = (selected + 1) % 4; continue; }
if (c >= L'1' && c <= L'4') { selected = (int)(c - L'1'); continue; }
if (c == L'\r' || c == L'\n') {
if (selected == 0) return 0;
if (selected == 1) return 1;
if (selected == 2) { next_mode(gop); continue; }
if (selected == 3) {
ui_clear();
ui_text_centered((int)ui_height() / 2, "Halted -- reset to reach firmware setup",
0xB9BEC7u);
for (;;) __asm__ volatile("hlt");
}
}
}
}
/* Wait briefly for F8, exactly as the BIOS loader does, and show the same
* menu if it comes. One prompt, one key, one screen -- the resolution chooser
* lives in that menu rather than in a second prompt of its own.
*/
static int
f8_window(EFI_GRAPHICS_OUTPUT_PROTOCOL *gop)
{
print(L"Press F8 for boot options... ");
EFI_INPUT_KEY key;
for (UINTN waited = 0; waited < 1500; waited += 10) {
if (ST->ConIn && !EFI_ERROR(ST->ConIn->ReadKeyStroke(ST->ConIn, &key))) {
/* SCAN_F8 is 0x12. A serial console sends no scan codes at all, so
* 'o' is accepted too -- otherwise this is unreachable for anyone
* working over a serial line, which is how it is usually driven. */
if (key.ScanCode == 0x12 || key.UnicodeChar == L'o' ||
key.UnicodeChar == L'O') {
print(L"\r\n");
// Entering nothing means "let me type it instead". Zero is a valid mode
// number, so the two are told apart by whether anything was typed at all;
// read_dec returns 0 for an empty line, and mode 0 is reachable from the
// typed path anyway.
if (want != 0 || gop->Mode->MaxMode == 0) {
if (want < gop->Mode->MaxMode)
return want;
print(L"schos: no such mode, keeping the current one\r\n");
return current;
return boot_menu(gop);
}
print(L" width: ");
UINT32 w = read_dec();
print(L"\r\n height: ");
UINT32 h = read_dec();
print(L"\r\n");
// 800x600 is the floor the system is built around.
if (w < 800 || h < 600) {
print(L"schos: below the 800x600 minimum, keeping the current mode\r\n");
return current;
break;
}
for (UINT32 i = 0; i < gop->Mode->MaxMode; i++) {
EFI_GRAPHICS_OUTPUT_MODE_INFORMATION *info = 0;
UINTN size = 0;
if (EFI_ERROR(gop->QueryMode(gop, i, &size, &info)) || !info)
continue;
if (info->PixelFormat > 1)
continue;
if (info->HorizontalResolution == w && info->VerticalResolution == h)
return i;
BS->Stall(10000);
}
print(L"schos: no such 32bpp mode, keeping the current one\r\n");
return current;
print(L"booting\r\n");
return 0;
}
static UINT32
@ -478,11 +542,12 @@ setup_framebuffer(void)
return 0;
}
UINT32 pick = choose_mode(gop);
if (pick != gop->Mode->Mode)
gop->SetMode(gop, pick);
// F8 first, on whatever mode the firmware left us in, because the menu has
// to be visible in order to offer a different one. Anything it changes is
// reflected below, which is why nothing is read from gop->Mode until after.
VERBOSE_BOOT = f8_window(gop);
// Re-read after SetMode: Info and FrameBufferBase both describe the mode
// Re-read after any mode change: Info and FrameBufferBase both describe the mode
// that is current, and the framebuffer can move when the mode changes.
EFI_GRAPHICS_OUTPUT_MODE_INFORMATION *info = gop->Mode->Info;
UINT32 fb = (UINT32)gop->Mode->FrameBufferBase;
@ -696,6 +761,7 @@ EfiMain(EFI_HANDLE image, EFI_SYSTEM_TABLE *systab)
copy((void *)SCHBOOT_E820_MAP, e820, nent * sizeof e820[0]);
*(UINT32 *)SCHBOOT_E820_COUNT = nent;
*(UINT64 *)SCHBOOT_RSDP_ADDR = rsdp;
*(UINT8 *)SCHBOOT_VERBOSE_FLAG = (UINT8)VERBOSE_BOOT;
// The page tables mboot_entry would have built. entry64low's first act is
// a movabs to 0xffff8000...., so the high half must be mapped before the