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<PathBuf> {
// 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<String> {
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<String> {
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}");
}
}