use anyhow::{bail, Context, Result}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use crate::config::BuildConfig; /// Where the kernel that ends up in the hekate `uImage` comes from. #[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] pub enum KernelSource { /// The known-good switchroot L4T kernel (display/panel/regulator patches). /// This is the default because a vanilla kernel cannot drive the Switch /// DSI panel. Switchroot, /// The kernel built by this project, wrapped into a uImage with `mkimage`. /// Only useful once the kernel carries the L4T/switchroot patch set. Built, } /// SD-card path (relative to the card root) that hekate's L4T loader reads. const BOOT_PREFIX: &str = "switchroot/alarm"; /// Block device holding the root filesystem, as seen by the L4T initramfs. /// /// The Switch exposes the SD card as `mmcblk0` and the Arch root is partition 2 /// (`mmcblk0p1` is the FAT boot partition). Pinning this stops the switchroot /// initramfs from probing every block device (and from stopping at the first /// `switchroot`-labelled partition it finds). const ROOTDEV: &str = "mmcblk0p2"; /// hekate boot entry that points at `BOOT_PREFIX`. const INI_NAME: &str = "L4T-alarm.ini"; /// Reference L4T install shipped in this repository. const SWITCHROOT_REF: &str = "switchroot/ubuntu-jammy"; /// Files that make up the L4T boot prefix, besides the kernel. const L4T_SUPPORT_FILES: &[&str] = &["bl31.bin", "bl33.bin", "nx-plat.dtimg", "initramfs", "boot.scr"]; /// Load/entry address used by the switchroot L4T kernel image. const UIMAGE_ADDR: &str = "0x80200000"; /// Build the hekate/L4T boot tree for the Switch. /// /// Produces, under `output_dir`: /// /// ```text /// bootloader/ini/L4T-alarm.ini # hekate "More configs..." entry /// switchroot/alarm/uImage # kernel /// switchroot/alarm/bl31.bin # ATF secure monitor /// switchroot/alarm/bl33.bin # L4T bootloader stage /// switchroot/alarm/nx-plat.dtimg # platform device trees /// switchroot/alarm/initramfs # root-fs discovery / mount /// ``` pub fn build_hekate_payload( config: &BuildConfig, build_dir: &Path, output_dir: &Path, kernel_source: KernelSource, ) -> Result<()> { let project_root = std::env::current_dir().context("failed to resolve project root")?; let reference = project_root.join(SWITCHROOT_REF); let boot_dir = output_dir.join(BOOT_PREFIX); let ini_dir = output_dir.join("bootloader/ini"); fs::create_dir_all(&boot_dir) .with_context(|| format!("failed to create {}", boot_dir.display()))?; fs::create_dir_all(&ini_dir) .with_context(|| format!("failed to create {}", ini_dir.display()))?; // Drop the obsolete, non-functional boot files produced by earlier // revisions so they cannot shadow the correct entry on the SD card. for stale in [ "l4t", "hekate_ipl.ini", "hekate.json", "l4t.ini", "boot.scr", "ALARM_BOOT_FILES.txt", ] { let path = output_dir.join(stale); if path.is_dir() { let _ = fs::remove_dir_all(&path); } else if path.exists() { let _ = fs::remove_file(&path); } } // 0. hekate's own runtime files: sys libs (lp0/minerva), Nyx GUI, res, // payloads, update.bin. Without these hekate refuses to boot L4T with // "missing lp0 (sleep) lib / missing minerva lib / update bootloader // folder!" and Nyx is unavailable. let hekate_dir = ensure_hekate_bootloader(build_dir, &config.tegra.bootloader.hekate_version)?; let stale_bdk = output_dir.join("bootloader/bdk"); if stale_bdk.exists() { let _ = fs::remove_dir_all(&stale_bdk); } copy_dir_recursive(&hekate_dir, &output_dir.join("bootloader"))?; // 1. hekate entry (written after the bundle so it is not overwritten). write_hekate_entry(config, &ini_dir)?; // 2. Kernel image. match kernel_source { KernelSource::Switchroot => { let src = reference.join("uImage"); if !src.exists() { bail!( "switchroot kernel not found at {}.\n\ Copy the L4T boot files from a switchroot release, or use \ `--kernel built` to wrap the locally built kernel.", src.display() ); } copy_file(&src, &boot_dir.join("uImage"))?; println!("Using switchroot L4T kernel (known-good display support)"); } KernelSource::Built => { // Ship both: the raw Image is what the boot script actually uses // (`booti`, no decompression), and the uImage is kept for reference // and as a fallback for a `bootm`-based script. copy_file(&build_dir.join("kernel/Image"), &boot_dir.join("Image"))?; make_uimage(&build_dir.join("kernel/Image"), &boot_dir.join("uImage"))?; println!( "Copied the raw kernel Image and wrapped it into uImage \ (the boot script boots the Image with `booti`)" ); } } // 3. Support files (ATF, bootloader stage, device trees, initramfs). for name in L4T_SUPPORT_FILES { let src = reference.join(name); if !src.exists() { bail!( "required L4T boot file '{}' missing at {}.\n\ These files come from a switchroot L4T release; place them in {}.", name, src.display(), reference.display() ); } copy_file(&src, &boot_dir.join(name))?; } // 3b. Mainline boot set. The L4T `boot.scr` loads a Device Tree *image* // (nx-plat.dtimg), picks an entry by SKU and then validates it against // ${fdt_id_text}/${fdt_rev} — properties only L4T device trees carry. // On a mainline DTB that check fails and the script reboots in a loop, // so for a locally built kernel swap in our own script and a plain dtb. if kernel_source == KernelSource::Built { let scr = project_root.join("device-tree/boot-mainline.scr"); if !scr.exists() { bail!( "mainline boot script not found at {}.\n\ Build it with:\n \ mkimage -A arm64 -O linux -T script -C none -n \"ALARM mainline boot\" \ -d device-tree/boot-mainline.cmd device-tree/boot-mainline.scr", scr.display() ); } copy_file(&scr, &boot_dir.join("boot.scr"))?; println!("Using the mainline boot script (boot.scr)"); let dtb = build_dir.join("device-tree/tegra210-nintendo-switch.dtb"); if !dtb.exists() { bail!( "Switch device tree not found at {}.\n\ Run `alarm-build kernel` first: install_switch_dt() injects the \ DTS into the kernel tree so `make dtbs` emits it.", dtb.display() ); } copy_file(&dtb, &boot_dir.join("switch.dtb"))?; println!("Using the mainline Switch device tree (switch.dtb)"); } // 4. Bootlogo / menu icon (optional). copy_optional(&reference.join("bootlogo_ubuntu.bmp"), &boot_dir.join("bootlogo.bmp"))?; copy_optional(&reference.join("icon_ubuntu_hue.bmp"), &boot_dir.join("icon.bmp"))?; println!("L4T boot tree created at: {}", output_dir.display()); println!(" entry : bootloader/ini/{}", INI_NAME); println!(" prefix : /{}", BOOT_PREFIX); Ok(()) } /// Write `bootloader/ini/L4T-alarm.ini` in hekate's L4T schema. /// /// See `switchroot/ubuntu-jammy/README_CONFIG.txt` for the full list of keys. fn write_hekate_entry(config: &BuildConfig, ini_dir: &Path) -> Result<()> { let path = ini_dir.join(INI_NAME); fs::write(&path, hekate_entry(config)) .with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } /// Render the hekate L4T entry. Split out so it can be unit tested without /// touching the filesystem. /// /// Keys: /// * `l4t=1` / `boot_prefixes` / `id` — tell hekate's L4T loader where to look. /// * `rootdev=mmcblk0p2` — pin the root filesystem instead of letting the /// initramfs auto-detect it. /// * `rootlabel_retries=1` — with `rootdev` given, a single label lookup is /// enough; do not spend seconds retrying. /// * `r2p_action=self` — reboot-to-payload returns to hekate. /// * `fbconsole=0` — leave the framebuffer alone; the 4.9 L4T kernel drives the /// DSI panel itself. /// * `bootargs_extra` — appended to the kernel command line: /// - `usb_logging` creates the USB serial gadget (`/dev/ttyGS0`); this is the /// only console available without extra hardware, and the init script uses /// it; /// - `auto_rootdev_disable` stops the initramfs from overriding `rootdev`. /// /// Keep this comment-free: hekate's ini parser treats a `#` line as a comment, /// but a `;` line inside a section is parsed as a key/value pair and forwarded /// into U-Boot's environment, which can break the boot script. fn hekate_entry(config: &BuildConfig) -> String { format!( "[L4T ALARM]\n\ l4t=1\n\ boot_prefixes=/{prefix}/\n\ id={label}\n\ rootdev={rootdev}\n\ rootlabel_retries=1\n\ r2p_action=self\n\ fbconsole=0\n\ bootargs_extra=usb_logging auto_rootdev_disable\n", prefix = BOOT_PREFIX, label = config.image.label, rootdev = ROOTDEV, ) } /// Wrap a raw arm64 `Image` into a gzip-compressed uImage for hekate. fn make_uimage(kernel: &Path, dest: &Path) -> Result<()> { if !kernel.exists() { bail!( "kernel image not found at {}.\nRun `alarm-build kernel` first, or use \ `--kernel switchroot`.", kernel.display() ); } let gz = dest.with_extension("gz"); // gzip -9 -n: deterministic output, maximum compression. let file = fs::File::create(&gz) .with_context(|| format!("failed to create {}", gz.display()))?; let status = Command::new("gzip") .args(["-9", "-n", "-c"]) .arg(kernel) .stdout(file) .status() .context("failed to run gzip (is it installed?)")?; if !status.success() { bail!("gzip failed for {}", kernel.display()); } let status = Command::new("mkimage") .args([ "-A", "arm64", "-O", "linux", "-T", "kernel", "-C", "gzip", "-a", UIMAGE_ADDR, "-e", UIMAGE_ADDR, "-n", "ALARM L4T", "-d", ]) .arg(&gz) .arg(dest) .status() .context("failed to run mkimage (install the 'u-boot-tools' package)")?; let _ = fs::remove_file(&gz); if !status.success() { bail!("mkimage failed"); } Ok(()) } fn copy_file(src: &Path, dst: &Path) -> Result<()> { fs::copy(src, dst) .with_context(|| format!("failed to copy {} -> {}", src.display(), dst.display()))?; Ok(()) } fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { fs::create_dir_all(dst)?; for entry in fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))? { let entry = entry?; let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); if src_path.is_dir() { copy_dir_recursive(&src_path, &dst_path)?; } else if src_path.is_file() { copy_file(&src_path, &dst_path)?; } } Ok(()) } /// Official hekate release API. const HEKATE_API: &str = "https://api.github.com/repos/CTCaer/hekate/releases"; /// Locate (or download) a complete hekate `bootloader/` folder. /// /// hekate loads `bootloader/sys/libsys_lp0.bso` (sleep) and /// `bootloader/sys/libsys_minerva.bso` (RAM config) from the SD card at boot, /// and the Nyx menu lives in `bootloader/nyx/`. fn ensure_hekate_bootloader(build_dir: &Path, version: &str) -> Result { // 1. Explicit override: a directory containing `sys/` (the bootloader dir // itself, or a parent that contains `bootloader/`). if let Some(dir) = std::env::var_os("HEKATE_BOOTLOADER_DIR") { let dir = PathBuf::from(dir); for candidate in [dir.clone(), dir.join("bootloader")] { if candidate.join("sys").is_dir() { return Ok(candidate); } } bail!( "HEKATE_BOOTLOADER_DIR does not contain a 'sys' directory: {}", dir.display() ); } // 2. Bundled resources next to the project. let project_root = std::env::current_dir().context("failed to resolve project root")?; for candidate in [ project_root.join("resources/hekate/bootloader"), project_root.join("resources/bootloader"), project_root.join("bootloader"), ] { if candidate.join("sys").is_dir() { return Ok(candidate); } } // 3. A previously downloaded copy. let cached = build_dir.join("hekate/bootloader"); if cached.join("sys").is_dir() { return Ok(cached); } // 4. Download the official release bundle. download_hekate_bootloader(build_dir, version)?; if cached.join("sys").is_dir() { Ok(cached) } else { bail!( "hekate download did not contain bootloader/sys/*.bso (looked in {})", cached.display() ) } } /// Download and extract the hekate release zip into `build_dir/hekate`. fn download_hekate_bootloader(build_dir: &Path, version: &str) -> Result<()> { let dest = build_dir.join("hekate"); fs::create_dir_all(&dest)?; let api = if version.trim().is_empty() { format!("{HEKATE_API}/latest") } else { format!("{HEKATE_API}/tags/v{}", version.trim_start_matches('v')) }; println!("Fetching hekate release info from {}", api); let json = http_get(&api)?; let zip_url = select_zip_asset(&json) .with_context(|| format!("no hekate_ctcaer_*.zip asset found for {api}"))?; let zip = dest.join("hekate.zip"); println!("Downloading hekate bootloader bundle..."); download_file(&zip_url, &zip)?; extract_zip(&zip, &dest) } /// Find the `hekate_ctcaer_*.zip` download URL in a release JSON body. fn select_zip_asset(json: &str) -> Option { let value: serde_json::Value = serde_json::from_str(json).ok()?; value .get("assets")? .as_array()? .iter() .filter_map(|asset| { let name = asset.get("name")?.as_str()?; if name.starts_with("hekate_ctcaer") && name.ends_with(".zip") { asset .get("browser_download_url")? .as_str() .map(str::to_string) } else { None } }) .next() } fn http_get(url: &str) -> Result { let output = if which::which("curl").is_ok() { Command::new("curl") .args(["-fsSL", "-H", "User-Agent: alarm-build"]) .arg(url) .output() .context("failed to run curl")? } else if which::which("wget").is_ok() { Command::new("wget") .args(["-qO-"]) .arg(url) .output() .context("failed to run wget")? } else { bail!("neither curl nor wget is installed; cannot fetch {}", url); }; if !output.status.success() { bail!("failed to fetch {} (is there network access?)", url); } Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } fn download_file(url: &str, dest: &Path) -> Result<()> { let status = if which::which("curl").is_ok() { Command::new("curl") .args(["-fL", "--retry", "3", "-o"]) .arg(dest) .arg(url) .status() } else { Command::new("wget") .arg("-O") .arg(dest) .arg(url) .status() } .context("failed to run downloader")?; if !status.success() { bail!("failed to download {}", url); } Ok(()) } fn extract_zip(zip: &Path, dest: &Path) -> Result<()> { for tool in ["bsdtar", "unzip", "7z"] { if which::which(tool).is_err() { continue; } let status = match tool { "bsdtar" => Command::new(tool) .arg("-xf") .arg(zip) .arg("-C") .arg(dest) .status(), "unzip" => Command::new(tool) .args(["-oq"]) .arg(zip) .arg("-d") .arg(dest) .status(), _ => Command::new(tool) .args(["x", "-y"]) .arg(format!("-o{}", dest.display())) .arg(zip) .status(), } .with_context(|| format!("failed to run {}", tool))?; if status.success() { return Ok(()); } } bail!("no working zip extractor found (install unzip, bsdtar, or 7z)") } fn copy_optional(src: &Path, dst: &Path) -> Result<()> { if src.exists() { copy_file(src, dst)?; } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::config::BuildConfig; #[test] fn entry_uses_hekate_l4t_schema() { let config = BuildConfig::default_for_odnx02_a2(); let entry = hekate_entry(&config); // These keys are what make hekate's L4T loader find and boot the // kernel; without them the screen stays black. assert!(entry.contains("l4t=1"), "missing l4t=1:\n{entry}"); assert!( entry.contains("boot_prefixes=/switchroot/alarm/"), "missing boot_prefixes:\n{entry}" ); assert!(entry.contains("id=ALARM"), "missing id:\n{entry}"); assert!( entry.contains("[L4T ALARM]"), "missing entry header:\n{entry}" ); // The root device must be pinned, and the USB serial gadget requested: // without rootdev the initramfs probes every block device, without // usb_logging there is no serial console. assert!(entry.contains("rootdev=mmcblk0p2"), "missing rootdev:\n{entry}"); assert!( entry.contains("usb_logging"), "missing usb_logging in bootargs_extra:\n{entry}" ); assert!( entry.contains("auto_rootdev_disable"), "missing auto_rootdev_disable:\n{entry}" ); // Kernel command line keys must not leak into the ini. assert!(!entry.contains("console=tty"), "leaked kernel args:\n{entry}"); assert!(!entry.contains("wakeetrae2"), "leaked junk args:\n{entry}"); } }