metropolis/node/core/bios_bootcode: Add legacy bootcode

This change provides a legacy bootcode that shows the user that they
are using an invalid configuration, e.g. not use UEFI. This can be
tested with "qemu-system-i386 -hda bazel-bin/metropolis/node/image.img".

Closes monogon-dev/monogon#142

Change-Id: I3337a70125010aec110ad75647346310cac76d37
Reviewed-on: https://review.monogon.dev/c/monogon/+/3748
Tested-by: Jenkins CI
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
diff --git a/metropolis/node/BUILD.bazel b/metropolis/node/BUILD.bazel
index 3eccd5f..1638083 100644
--- a/metropolis/node/BUILD.bazel
+++ b/metropolis/node/BUILD.bazel
@@ -1,8 +1,9 @@
 load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+load("@rules_pkg//:pkg.bzl", "pkg_zip")
 load("//osbase/build:def.bzl", "erofs_image", "verity_image")
 load("//osbase/build:efi.bzl", "efi_unified_kernel_image")
+load("//osbase/build/genosrelease:defs.bzl", "os_release")
 load("//osbase/build/mkimage:def.bzl", "node_image")
-load("@rules_pkg//:pkg.bzl", "pkg_zip")
 
 go_library(
     name = "node",
@@ -127,6 +128,7 @@
 node_image(
     name = "image",
     abloader = "//metropolis/node/core/abloader",
+    bios_bootcode = "//metropolis/node/core/bios_bootcode",
     kernel = ":kernel_efi",
     system = ":verity_rootfs",
     visibility = [
@@ -136,8 +138,6 @@
     ],
 )
 
-load("//osbase/build/genosrelease:defs.bzl", "os_release")
-
 os_release(
     name = "os-release-info",
     os_id = "metropolis-node",
diff --git a/metropolis/node/core/bios_bootcode/BUILD.bazel b/metropolis/node/core/bios_bootcode/BUILD.bazel
new file mode 100644
index 0000000..4b02276
--- /dev/null
+++ b/metropolis/node/core/bios_bootcode/BUILD.bazel
@@ -0,0 +1,17 @@
+load("//metropolis/node/core/bios_bootcode/genlogo:def.bzl", "gen_logo")
+
+gen_logo(
+    name = "logo.asm",
+    logo = ":logo.png",
+)
+
+genrule(
+    name = "bios_bootcode",
+    srcs = [
+        ":boot.asm",
+        ":logo.asm",
+    ],
+    outs = ["boot.bin"],
+    cmd = "nasm -d LOGO=$(location :logo.asm) $(location :boot.asm) -f bin -o $@",
+    visibility = ["//visibility:public"],
+)
diff --git a/metropolis/node/core/bios_bootcode/README.md b/metropolis/node/core/bios_bootcode/README.md
new file mode 100644
index 0000000..4480344
--- /dev/null
+++ b/metropolis/node/core/bios_bootcode/README.md
@@ -0,0 +1,11 @@
+# BIOS Bootcode
+
+This package contains legacy bootcode which is displayed to non-UEFI users.
+It's sole purpose is to explain users their wrongdoing and tell them to use UEFI.
+It also shows a cute ascii-art logo.
+
+## Build
+ 
+Bazel generates the logo content with `genlogo`.
+It takes a black/white png-file and converts it to RLE encoded data,
+which is rendered as ascii-art at runtime.
diff --git a/metropolis/node/core/bios_bootcode/boot.asm b/metropolis/node/core/bios_bootcode/boot.asm
new file mode 100644
index 0000000..222e913
--- /dev/null
+++ b/metropolis/node/core/bios_bootcode/boot.asm
@@ -0,0 +1,89 @@
+org 7c00h
+
+start:
+	jmp main
+
+; si: string data, null terminated
+; di: start offset
+writestring:
+	mov al, [si]
+	or al, al
+	jz writestring_done
+	inc si
+	mov byte [fs:di], al
+	add di, 2
+	jmp writestring
+writestring_done:
+	ret
+
+; si: rle encoded data (high bit == color, lower 7: length)
+; di: start offset
+writegfx:
+	mov al, [si]
+	or al, al
+	jz writegfx_done
+	inc si
+
+	mov cl, al
+	and cx, 0b01111111
+	shr al, 7
+
+writegfx_nextinner:
+	or al, al
+	jz writegfx_space
+	mov byte [fs:di], 'M'
+writegfx_space:
+	add di, 2
+	sub cx, 1
+	jz writegfx
+	jmp writegfx_nextinner
+writegfx_done:
+	ret
+
+main:
+	xor ax, ax
+	mov ds, ax
+
+	; set mode 3 (text 80x25, 16 color)
+	mov ax, 0x3
+	int 0x10
+
+	; set up fs segment to point at framebuffer
+	mov ax, 0xb800
+	mov fs, ax
+
+	mov di, 4
+	mov si, logo
+	call writegfx
+
+	mov di, 3400
+	mov si, line1
+	call writestring
+
+	mov di, 3544
+	mov si, line2
+	call writestring
+
+end:
+	jmp end
+
+; Workaround to pass file as argument
+%macro incdef 1
+    %push _incdef_
+	%defstr %$file %{1}
+	%include %{$file}
+	%pop
+%endmacro
+
+incdef LOGO
+
+line1:
+	db "Hi there! Didn't see you coming in.", 0
+
+line2:
+	db "Unfortunately, Metropolis can only boot in UEFI mode.", 0
+
+db 0x55
+db 0xAA
+
+; We don't fill the rest with zeros, as this is done by mkimage and friends.
\ No newline at end of file
diff --git a/metropolis/node/core/bios_bootcode/genlogo/BUILD.bazel b/metropolis/node/core/bios_bootcode/genlogo/BUILD.bazel
new file mode 100644
index 0000000..eb31744
--- /dev/null
+++ b/metropolis/node/core/bios_bootcode/genlogo/BUILD.bazel
@@ -0,0 +1,14 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "genlogo_lib",
+    srcs = ["main.go"],
+    importpath = "source.monogon.dev/metropolis/node/core/bios_bootcode/genlogo",
+    visibility = ["//visibility:private"],
+)
+
+go_binary(
+    name = "genlogo",
+    embed = [":genlogo_lib"],
+    visibility = ["//visibility:public"],
+)
diff --git a/metropolis/node/core/bios_bootcode/genlogo/def.bzl b/metropolis/node/core/bios_bootcode/genlogo/def.bzl
new file mode 100644
index 0000000..11a56cd
--- /dev/null
+++ b/metropolis/node/core/bios_bootcode/genlogo/def.bzl
@@ -0,0 +1,34 @@
+def _build_logo_impl(ctx):
+    arguments = ctx.actions.args()
+
+    arguments.add_all(["--input"] + ctx.files.logo)
+    output = ctx.actions.declare_file("logo.asm")
+    arguments.add_all(["--output", output])
+
+    ctx.actions.run(
+        outputs = [output],
+        inputs = ctx.files.logo,
+        arguments = [arguments],
+        executable = ctx.executable._genlogo,
+    )
+
+    return DefaultInfo(
+        files = depset([output]),
+    )
+
+    pass
+
+gen_logo = rule(
+    implementation = _build_logo_impl,
+    attrs = {
+        "logo": attr.label(
+            allow_single_file = True,
+        ),
+        "_genlogo": attr.label(
+            default = Label(":genlogo"),
+            allow_single_file = True,
+            executable = True,
+            cfg = "exec",
+        ),
+    },
+)
diff --git a/metropolis/node/core/bios_bootcode/genlogo/main.go b/metropolis/node/core/bios_bootcode/genlogo/main.go
new file mode 100644
index 0000000..23686fd
--- /dev/null
+++ b/metropolis/node/core/bios_bootcode/genlogo/main.go
@@ -0,0 +1,88 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Monogon Project Authors
+
+package main
+
+import (
+	"flag"
+	"fmt"
+	"image/color"
+	"image/png"
+	"log"
+	"os"
+)
+
+func main() {
+	input := flag.String("input", "", "")
+	output := flag.String("output", "", "")
+	flag.Parse()
+
+	if *input == "" || *output == "" {
+		log.Fatal("missing input or output flag")
+	}
+
+	inputFile, err := os.Open(*input)
+	if err != nil {
+		log.Fatal("Error opening image file:", err)
+		return
+	}
+	defer inputFile.Close()
+
+	img, err := png.Decode(inputFile)
+	if err != nil {
+		log.Fatal("Error decoding image:", err)
+	}
+
+	if img.Bounds().Dx() != 80 || img.Bounds().Dy() != 20 {
+		log.Fatal("Image dimensions must be 80x20")
+	}
+
+	var linear []uint8
+	for y := 0; y < img.Bounds().Dy(); y++ {
+		for x := 0; x < img.Bounds().Dx(); x++ {
+			gray := color.GrayModel.Convert(img.At(x, y)).(color.Gray).Y
+			linear = append(linear, gray)
+		}
+	}
+
+	// Perform RLE compression
+	var rle []uint8
+	for len(linear) > 0 {
+		val := linear[0]
+		l := uint8(1)
+		for i := 1; i < len(linear); i++ {
+			if linear[i] != val {
+				break
+			}
+			l++
+		}
+
+		L := l
+		for l > 0 {
+			block := l
+			if block > 127 {
+				block = 127
+			}
+			rle = append(rle, (val<<7)|block)
+			l -= block
+		}
+		linear = linear[L:]
+	}
+
+	rle = append(rle, 0)
+
+	outputFile, err := os.Create(*output)
+	if err != nil {
+		log.Fatalf("failed creating output file: %v", err)
+	}
+	defer outputFile.Close()
+
+	outputFile.WriteString("logo: db ")
+	for i, r := range rle {
+		if i > 0 {
+			outputFile.WriteString(", ")
+		}
+		fmt.Fprintf(outputFile, "0x%02x", r)
+	}
+	outputFile.WriteString("\n")
+}
diff --git a/metropolis/node/core/bios_bootcode/logo.png b/metropolis/node/core/bios_bootcode/logo.png
new file mode 100644
index 0000000..5f395df
--- /dev/null
+++ b/metropolis/node/core/bios_bootcode/logo.png
Binary files differ
diff --git a/osbase/build/mkimage/def.bzl b/osbase/build/mkimage/def.bzl
index 3f9327b..33323d7 100644
--- a/osbase/build/mkimage/def.bzl
+++ b/osbase/build/mkimage/def.bzl
@@ -1,22 +1,30 @@
 def _node_image_impl(ctx):
     img_file = ctx.actions.declare_file(ctx.label.name + ".img")
+
+    arguments = ctx.actions.args()
+    arguments.add_all([
+        "-efi",
+        ctx.file.kernel.path,
+        "-system",
+        ctx.file.system.path,
+        "-abloader",
+        ctx.file.abloader.path,
+        "-out",
+        img_file.path,
+    ])
+
+    if len(ctx.files.bios_bootcode) != 0:
+        arguments.add_all(["-bios_bootcode", ctx.file.bios_bootcode.path])
+
     ctx.actions.run(
         mnemonic = "MkImage",
         executable = ctx.executable._mkimage,
-        arguments = [
-            "-efi",
-            ctx.file.kernel.path,
-            "-system",
-            ctx.file.system.path,
-            "-abloader",
-            ctx.file.abloader.path,
-            "-out",
-            img_file.path,
-        ],
+        arguments = [arguments],
         inputs = [
             ctx.file.kernel,
             ctx.file.system,
             ctx.file.abloader,
+            ctx.file.bios_bootcode,
         ],
         outputs = [img_file],
     )
@@ -45,6 +53,14 @@
             mandatory = True,
             allow_single_file = True,
         ),
+        "bios_bootcode": attr.label(
+            doc = """
+            Optional label to the BIOS bootcode which gets placed at the start of the first block of the image.
+            Limited to 440 bytes, padding is not required. It is only used by legacy BIOS boot.
+        """,
+            mandatory = False,
+            allow_single_file = True,
+        ),
         "_mkimage": attr.label(
             doc = "The mkimage executable.",
             default = "//osbase/build/mkimage",
diff --git a/osbase/build/mkimage/main.go b/osbase/build/mkimage/main.go
index 37691a9..5e2b082 100644
--- a/osbase/build/mkimage/main.go
+++ b/osbase/build/mkimage/main.go
@@ -40,17 +40,19 @@
 func main() {
 	// Fill in the image parameters based on flags.
 	var (
-		efiPayload      string
-		systemImage     string
-		abLoaderPayload string
-		nodeParams      string
-		outputPath      string
-		diskUUID        string
-		cfg             osimage.Params
+		efiPayload          string
+		systemImage         string
+		abLoaderPayload     string
+		biosBootCodePayload string
+		nodeParams          string
+		outputPath          string
+		diskUUID            string
+		cfg                 osimage.Params
 	)
 	flag.StringVar(&efiPayload, "efi", "", "Path to the UEFI payload used")
 	flag.StringVar(&systemImage, "system", "", "Path to the system partition image used")
 	flag.StringVar(&abLoaderPayload, "abloader", "", "Path to the abloader payload used")
+	flag.StringVar(&biosBootCodePayload, "bios_bootcode", "", "Optional path to the BIOS bootcode which gets placed at the start of the first block of the image. Limited to 440 bytes, padding is not required. It is only used by legacy BIOS boot.")
 	flag.StringVar(&nodeParams, "node_parameters", "", "Path to Node Parameters to be written to the ESP (default: don't write Node Parameters)")
 	flag.StringVar(&outputPath, "out", "", "Path to the resulting disk image or block device")
 	flag.Int64Var(&cfg.PartitionSize.Data, "data_partition_size", 2048, "Override the data partition size (default 2048 MiB). Used only when generating image files.")
@@ -95,6 +97,14 @@
 		cfg.NodeParameters = np
 	}
 
+	if biosBootCodePayload != "" {
+		bp, err := os.ReadFile(biosBootCodePayload)
+		if err != nil {
+			log.Fatalf("while opening BIOS bootcode at %q: %v", biosBootCodePayload, err)
+		}
+		cfg.BIOSBootCode = bp
+	}
+
 	// TODO(#254): Build and use dynamically-grown block devices
 	cfg.Output, err = blockdev.CreateFile(outputPath, 512, 10*1024*1024)
 	if err != nil {
diff --git a/osbase/build/mkimage/osimage/osimage.go b/osbase/build/mkimage/osimage/osimage.go
index d139d6b..2f497a8 100644
--- a/osbase/build/mkimage/osimage/osimage.go
+++ b/osbase/build/mkimage/osimage/osimage.go
@@ -92,6 +92,9 @@
 	// PartitionSize specifies a size for the ESP, Metropolis System and
 	// Metropolis data partition.
 	PartitionSize PartitionSizeInfo
+	// BIOSBootCode provides the optional contents for the protective MBR
+	// block which gets executed by legacy BIOS boot.
+	BIOSBootCode []byte
 }
 
 type plan struct {
@@ -155,6 +158,7 @@
 	}
 
 	params.tbl.ID = params.DiskGUID
+	params.tbl.BootCode = p.BIOSBootCode
 	params.efiPartition = &gpt.Partition{
 		Type: gpt.PartitionTypeEFISystem,
 		Name: ESPLabel,