commit cc9f6b58e1034650f78775ea77a4e2a823e6ad1b Author: m00d Date: Wed Oct 1 08:11:05 2025 +0200 init diff --git a/.cache/uefi-rs b/.cache/uefi-rs new file mode 160000 index 0000000..565b1aa --- /dev/null +++ b/.cache/uefi-rs @@ -0,0 +1 @@ +Subproject commit 565b1aad2b6a79a11c67e419879475ac7414f6fe diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..8083798 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,9 @@ +[build] +target = "aarch64-unknown-uefi" + +[target.aarch64-unknown-uefi] +rustflags = [ + "-C", "relocation-model=pic", + "-C", "link-args=/BASE:0x100000", +] +linker = "rust-lld" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad67955 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Generated by Cargo +# will have compiled files and executables +debug +target + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..02cfa16 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,228 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "badapple-uefi" +version = "0.1.0" +dependencies = [ + "bitvec", + "log", + "micromath", + "spin", + "uefi", + "x86_64", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "micromath" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815" + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "qemu-exit" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb0fd6580eeed0103c054e3fba2c2618ff476943762f28a645b63b8692b21c9" + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "ucs2" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79298e11f316400c57ec268f3c2c29ac3c4d4777687955cd3d4f3a35ce7eba" +dependencies = [ + "bit_field", +] + +[[package]] +name = "uefi" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7569ceafb898907ff764629bac90ac24ba4203c38c33ef79ee88c74aa35b11" +dependencies = [ + "bitflags", + "cfg-if", + "log", + "ptr_meta", + "qemu-exit", + "ucs2", + "uefi-macros", + "uefi-raw", + "uguid", +] + +[[package]] +name = "uefi-macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3dad47b3af8f99116c0f6d4d669c439487d9aaf1c8d9480d686cda6f3a8aa23" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "uefi-raw" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cad96b8baaf1615d3fdd0f03d04a0b487d857c1b51b19dcbfe05e2e3c447b78" +dependencies = [ + "bitflags", + "uguid", +] + +[[package]] +name = "uguid" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab14ea9660d240e7865ce9d54ecdbd1cd9fa5802ae6f4512f093c7907e921533" + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "volatile" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442887c63f2c839b346c192d047a7c87e73d0689c9157b00b53dcc27dd5ea793" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x86_64" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f042214de98141e9c8706e8192b73f56494087cc55ebec28ce10f26c5c364ae" +dependencies = [ + "bit_field", + "bitflags", + "rustversion", + "volatile", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cd06ea7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "badapple-uefi" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +authors = ["Your Name "] +description = "UEFI payload that plays the Bad Apple animation (video + audio) using the uefi-rs crate" + +[dependencies] +uefi = { version = "0.35", default-features = false, features = ["alloc", "global_allocator", "panic_handler", "logger", "qemu"] } +bitvec = { version = "1", default-features = false, features = ["alloc"] } +micromath = { version = "2", default-features = false } +log = "0.4" +spin = { version = "0.9", default-features = false, features = ["mutex", "spin_mutex"] } + +[target.'cfg(target_arch = "x86_64")'.dependencies] +x86_64 = { version = "0.15", default-features = false } + +[profile.dev] +opt-level = "s" + +[profile.release] +opt-level = "z" +codegen-units = 1 +lto = true +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ffabf1 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# Bad Apple for UEFI (Rust) + +Firmware payload that plays the Bad Apple animation directly from a UEFI image. The project uses the [`uefi`](https://github.com/rust-osdev/uefi-rs) crate, renders video frames through GOP, and synthesises audio via the legacy PC speaker using a 1-bit sigma-delta stream. Everything fits inside a `no_std` Rust binary and can be driven under QEMU. + +## Repository layout + +- `src/` – modular firmware code (`app` contains assets, graphics, audio, timing, and player logic). +- `assets/` – tiny demo clip/audio used as an embedded fallback. +- `tools/` – helper utilities, notably `process_badapple.py` for converting the real video/audio into firmware-friendly blobs. +- `ai/state.json` – persistent JSON with personas/tasks as requested. +- `flake.nix` – reproducible Nix environment with Rust, ffmpeg, and QEMU. + +## Building the EFI binary + +1. Ensure you have a recent stable Rust toolchain with the UEFI target installed: + ```bash + rustup target add aarch64-unknown-uefi + ``` +2. Build the binary: + ```bash + cargo build --release --target aarch64-unknown-uefi + ``` + The resulting image will be at `target/aarch64-unknown-uefi/release/badapple-uefi.efi`. + +Using Nix instead? Enter the dev shell or boot straight from the flake: +```bash +nix develop +# (rustup target add aarch64-unknown-uefi is invoked automatically in the shell) + +# Build & boot in one go under QEMU (cross-emulates aarch64 firmware): +nix run .#qemu +``` + +## Preparing real assets + +The repository embeds a tiny placeholder clip so builds work immediately. To convert the actual Bad Apple video/audio: + +```bash +python3 tools/process_badapple.py \ + --input /path/to/badapple.mp4 \ + --output-dir build/assets \ + --width 320 --height 240 --fps 30 \ + --sample-rate 44100 +``` + +Copy the generated `video.baa` and `audio.pdm` onto the EFI system partition alongside the application under `\EFI\BADAPPLE\`. The firmware attempts to load external assets first, then falls back to the embedded demo when they are missing. + +### Asset format summary + +- `video.baa`: magic `"BAA\0"`, header with width/height/fps/frame count, followed by 1-bit packed frames (LSB-first). +- `audio.pdm`: magic `"BAP\0"`, header with sample rate and sample count, followed by 1-bit sigma-delta stream (LSB-first). + +## Running under QEMU + +A minimal invocation after copying the EFI binary and assets to a FAT image might look like: +```bash +qemu-system-aarch64 \ + -machine virt \ + -cpu cortex-a72 \ + -m 1024 \ + -drive if=pflash,format=raw,readonly=on,file=/path/to/AAVMF_CODE.fd \ + -drive if=pflash,format=raw,file=/path/to/AAVMF_VARS.fd \ + -drive file=fat:rw:esp,format=raw \ + -device virtio-gpu-pci \ + -device ramfb \ + -serial mon:stdio +``` +Make sure the `esp` directory contains `EFI/BOOT/BOOTAA64.EFI` (your compiled binary) plus the `EFI/BADAPPLE` assets. Audio output is currently disabled on non-x86 platforms because the legacy PC speaker is unavailable; video playback continues in sync. + +## Notes & limitations + +- Audio playback uses the legacy PC speaker path on x86 machines; on aarch64 builds the backend is currently silent because that device is absent. +- Timing relies on TSC calibration during boot services. Firmware with non-constant TSCs may require additional guarding. +- Video rendering assumes the selected GOP mode exposes a writable framebuffer (non-BLT-only). Modes with vendor-specific bitmasks are supported via mask calculation. + +## Next steps + +- Replace the placeholder clip with the full Bad Apple dataset via the tooling above. +- Consider double buffering and smarter frame pacing to reduce tearing. +- Experiment with richer audio backends (e.g. direct HDA/AC97 programming) if available on your platform. diff --git a/ai/state.json b/ai/state.json new file mode 100644 index 0000000..d9838bb --- /dev/null +++ b/ai/state.json @@ -0,0 +1,37 @@ +{ + "personas": [ + { + "name": "Codex", + "role": "Firmware media player", + "skills": [ + "UEFI boot services", + "Framebuffer graphics", + "PC speaker audio", + "Rust no_std engineering" + ], + "notes": "Designed to stream the Bad Apple animation through bare-metal firmware interfaces." + } + ], + "tasks": [ + { + "id": "badapple-video", + "title": "Render Bad Apple frames on GOP", + "status": "completed" + }, + { + "id": "badapple-audio", + "title": "Synthesize audio via PC speaker with PDM", + "status": "completed" + }, + { + "id": "asset-pipeline", + "title": "Convert source media into firmware-friendly bundles", + "status": "completed" + } + ], + "metadata": { + "project": "badapple-uefi", + "author": "Codex", + "last_updated": "2024-11-26T00:00:00Z" + } +} \ No newline at end of file diff --git a/assets/demo_audio.pdm b/assets/demo_audio.pdm new file mode 100644 index 0000000..d093563 Binary files /dev/null and b/assets/demo_audio.pdm differ diff --git a/assets/demo_video.baa b/assets/demo_video.baa new file mode 100644 index 0000000..673b99b Binary files /dev/null and b/assets/demo_video.baa differ diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..2084926 --- /dev/null +++ b/flake.lock @@ -0,0 +1,102 @@ +{ + "nodes": { + "fenix": { + "inputs": { + "nixpkgs": [ + "naersk", + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1752475459, + "narHash": "sha256-z6QEu4ZFuHiqdOPbYss4/Q8B0BFhacR8ts6jO/F/aOU=", + "owner": "nix-community", + "repo": "fenix", + "rev": "bf0d6f70f4c9a9cf8845f992105652173f4b617f", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "naersk": { + "inputs": { + "fenix": "fenix", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1752689277, + "narHash": "sha256-uldUBFkZe/E7qbvxa3mH1ItrWZyT6w1dBKJQF/3ZSsc=", + "owner": "nix-community", + "repo": "naersk", + "rev": "0e72363d0938b0208d6c646d10649164c43f4d64", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "naersk", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1752077645, + "narHash": "sha256-HM791ZQtXV93xtCY+ZxG1REzhQenSQO020cu6rHtAPk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "be9e214982e20b8310878ac2baa063a961c1bdf6", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "naersk": "naersk", + "nixpkgs": "nixpkgs_2" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1752428706, + "narHash": "sha256-EJcdxw3aXfP8Ex1Nm3s0awyH9egQvB2Gu+QEnJn2Sfg=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "591e3b7624be97e4443ea7b5542c191311aa141d", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..18f9f27 --- /dev/null +++ b/flake.nix @@ -0,0 +1,97 @@ +{ + description = "Bad Apple player running on UEFI using Rust"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + naersk.url = "github:nix-community/naersk"; + }; + + outputs = { self, nixpkgs, naersk }: + let + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forEachSystem = f: + nixpkgs.lib.genAttrs systems (system: + let + pkgs = import nixpkgs { inherit system; }; + in f system pkgs); + in { + packages = forEachSystem (system: pkgs: + let + naersk-lib = naersk.lib.${system}; + in { + default = naersk-lib.buildPackage { + pname = "badapple-uefi"; + version = "0.1.0"; + src = ./.; + cargoBuildOptions = opts: opts ++ [ "--target" "aarch64-unknown-uefi" "--release" ]; + doCheck = false; + installPhase = '' + runHook preInstall + mkdir -p $out/EFI/BOOT $out/assets + cp "$CARGO_TARGET_DIR/aarch64-unknown-uefi/release/badapple-uefi.efi" \ + $out/EFI/BOOT/BOOTAA64.EFI + cp -r ${./assets}/. $out/assets/ 2>/dev/null || true + runHook postInstall + ''; + }; + }); + + apps = forEachSystem (system: pkgs: + let + package = self.packages.${system}.default; + ovmf = pkgs.OVMF.overrideAttrs (old: { + meta = (old.meta or {}) // { broken = false; }; + }); + runtimePkgs = [ pkgs.qemu ovmf pkgs.coreutils pkgs.findutils ]; + script = pkgs.writeShellScriptBin "badapple-qemu" '' + set -euo pipefail + export PATH="${pkgs.lib.makeBinPath runtimePkgs}:$PATH" + + esp="$(mktemp -d)" + vars="$(mktemp)" + trap 'rm -rf "$esp" "$vars"' EXIT + + mkdir -p "$esp/EFI/BOOT" "$esp/EFI/BADAPPLE" + cp ${package}/EFI/BOOT/BOOTAA64.EFI "$esp/EFI/BOOT/BOOTAA64.EFI" + if [ -d ${package}/assets ]; then + cp -R ${package}/assets/. "$esp/EFI/BADAPPLE/" + fi + + cp ${ovmf.fd}/FV/AAVMF_VARS.fd "$vars" + + qemu-system-aarch64 \ + -machine virt \ + -cpu cortex-a72 \ + -m 1024 \ + -drive if=pflash,format=raw,readonly=on,file=${ovmf.fd}/FV/AAVMF_CODE.fd \ + -drive if=pflash,format=raw,file="$vars" \ + -drive file=fat:rw:$esp,format=raw \ + -device virtio-gpu-pci \ + -device ramfb \ + -net none \ + -serial mon:stdio \ + "$@" + ''; + in { + qemu = { + type = "app"; + program = "${script}/bin/badapple-qemu"; + }; + }); + + devShells = forEachSystem (_: pkgs: { + default = pkgs.mkShell { + buildInputs = [ + pkgs.rustup + pkgs.qemu + pkgs.ffmpeg + pkgs.python3 + pkgs.pkg-config + ]; + shellHook = '' + rustup target add aarch64-unknown-uefi >/dev/null 2>&1 || true + ''; + }; + }); + }; +} diff --git a/src/app/assets.rs b/src/app/assets.rs new file mode 100644 index 0000000..0bfda46 --- /dev/null +++ b/src/app/assets.rs @@ -0,0 +1,214 @@ +use alloc::borrow::Cow; +use uefi::{boot, fs::FileSystem, CString16, Handle, Status, StatusExt}; + +const VIDEO_MAGIC: &[u8; 4] = b"BAA\0"; +const AUDIO_MAGIC: &[u8; 4] = b"BAP\0"; + +const VIDEO_ASSET_PATH: &str = "\\EFI\\BADAPPLE\\video.baa"; +const AUDIO_ASSET_PATH: &str = "\\EFI\\BADAPPLE\\audio.pdm"; + +static EMBEDDED_VIDEO: &[u8] = include_bytes!("../../assets/demo_video.baa"); +static EMBEDDED_AUDIO: &[u8] = include_bytes!("../../assets/demo_audio.pdm"); + +pub struct AssetSource<'a> { + video_blob: Cow<'a, [u8]>, + audio_blob: Cow<'a, [u8]>, + video_header: VideoHeader, + audio_header: AudioHeader, +} + +impl<'a> AssetSource<'a> { + pub fn new( + image_handle: Handle, + _bs: &uefi::table::boot::BootServices, + ) -> Result { + let fs_proto = boot::get_image_file_system(image_handle).map_err(|e| e.status())?; + let mut fs = FileSystem::new(fs_proto); + + let video_path = + CString16::try_from(VIDEO_ASSET_PATH).map_err(|_| Status::INVALID_PARAMETER)?; + let audio_path = + CString16::try_from(AUDIO_ASSET_PATH).map_err(|_| Status::INVALID_PARAMETER)?; + + let video_blob = fs.read(video_path.as_ref()).map_err(map_fs_error)?; + let audio_blob = fs.read(audio_path.as_ref()).map_err(map_fs_error)?; + + Self::from_cow(Cow::Owned(video_blob), Cow::Owned(audio_blob)) + } + + pub fn embedded() -> Result { + Self::from_cow(Cow::Borrowed(EMBEDDED_VIDEO), Cow::Borrowed(EMBEDDED_AUDIO)) + } + + fn from_cow(video_blob: Cow<'a, [u8]>, audio_blob: Cow<'a, [u8]>) -> Result { + let video_header = VideoHeader::parse(&video_blob)?; + let audio_header = AudioHeader::parse(&audio_blob)?; + + Ok(Self { + video_blob, + audio_blob, + video_header, + audio_header, + }) + } + + pub fn video(&'a self) -> VideoPack<'a> { + VideoPack { + header: self.video_header, + data: &self.video_blob[VideoHeader::SIZE..], + } + } + + pub fn audio(&'a self) -> AudioPack<'a> { + AudioPack { + header: self.audio_header, + data: &self.audio_blob[AudioHeader::SIZE..], + } + } +} + +#[derive(Clone, Copy)] +struct VideoHeader { + width: u16, + height: u16, + fps: u16, + frame_count: u32, + stride_bytes: usize, +} + +impl VideoHeader { + const SIZE: usize = 16; + + fn parse(blob: &[u8]) -> Result { + if blob.len() < Self::SIZE { + return Err(Status::COMPROMISED_DATA); + } + if &blob[0..4] != VIDEO_MAGIC { + return Err(Status::COMPROMISED_DATA); + } + + let width = u16::from_le_bytes([blob[4], blob[5]]); + let height = u16::from_le_bytes([blob[6], blob[7]]); + let fps = u16::from_le_bytes([blob[8], blob[9]]); + let frame_count = u32::from_le_bytes([blob[10], blob[11], blob[12], blob[13]]); + let stride_bits = width as usize * height as usize; + let stride_bytes = (stride_bits + 7) / 8; + + let expected = Self::SIZE + stride_bytes.saturating_mul(frame_count as usize); + if blob.len() < expected { + return Err(Status::COMPROMISED_DATA); + } + + Ok(Self { + width, + height, + fps, + frame_count, + stride_bytes, + }) + } +} + +#[derive(Clone, Copy)] +struct AudioHeader { + sample_rate: u32, + bits_per_sample: u8, + channels: u8, + sample_count: u32, +} + +impl AudioHeader { + const SIZE: usize = 16; + + fn parse(blob: &[u8]) -> Result { + if blob.len() < Self::SIZE { + return Err(Status::COMPROMISED_DATA); + } + if &blob[0..4] != AUDIO_MAGIC { + return Err(Status::COMPROMISED_DATA); + } + + let sample_rate = u32::from_le_bytes([blob[4], blob[5], blob[6], blob[7]]); + let bits_per_sample = blob[8]; + let channels = blob[9]; + let sample_count = u32::from_le_bytes([blob[12], blob[13], blob[14], blob[15]]); + + if bits_per_sample != 1 || channels != 1 { + return Err(Status::UNSUPPORTED); + } + + let expected_bytes = ((sample_count as usize) + 7) / 8; + let expected_total = Self::SIZE + expected_bytes; + if blob.len() < expected_total { + return Err(Status::COMPROMISED_DATA); + } + + Ok(Self { + sample_rate, + bits_per_sample, + channels, + sample_count, + }) + } +} + +pub struct VideoPack<'a> { + header: VideoHeader, + data: &'a [u8], +} + +impl<'a> VideoPack<'a> { + pub fn width(&self) -> usize { + self.header.width as usize + } + + pub fn height(&self) -> usize { + self.header.height as usize + } + + pub fn fps(&self) -> u16 { + self.header.fps + } + + pub fn frame_count(&self) -> u32 { + self.header.frame_count + } + + pub fn frame_stride_bytes(&self) -> usize { + self.header.stride_bytes + } + + pub fn frame_bits(&self, index: u32) -> Option<&'a [u8]> { + let stride = self.header.stride_bytes; + let start = index as usize * stride; + let end = start + stride; + self.data.get(start..end) + } +} + +pub struct AudioPack<'a> { + header: AudioHeader, + data: &'a [u8], +} + +impl<'a> AudioPack<'a> { + pub fn sample_rate(&self) -> u32 { + self.header.sample_rate + } + + pub fn sample_count(&self) -> usize { + self.header.sample_count as usize + } + + pub fn bits(&self) -> &'a [u8] { + self.data + } +} + +fn map_fs_error(err: uefi::fs::Error) -> Status { + match err { + uefi::fs::Error::Io(io) => io.uefi_error.status(), + uefi::fs::Error::Path(_) => Status::INVALID_PARAMETER, + uefi::fs::Error::Utf8Encoding(_) => Status::COMPROMISED_DATA, + } +} diff --git a/src/app/audio/mod.rs b/src/app/audio/mod.rs new file mode 100644 index 0000000..2752d48 --- /dev/null +++ b/src/app/audio/mod.rs @@ -0,0 +1,64 @@ +#[cfg(target_arch = "x86_64")] +mod speaker; + +use uefi::Status; + +#[derive(Debug)] +enum InnerBackend { + #[cfg(target_arch = "x86_64")] + PcSpeaker(speaker::PcSpeaker), + Silent, +} + +pub struct AudioBackend { + inner: InnerBackend, +} + +impl AudioBackend { + pub fn init() -> Result { + #[cfg(target_arch = "x86_64")] + { + let speaker = speaker::PcSpeaker::new()?; + return Ok(Self { + inner: InnerBackend::PcSpeaker(speaker), + }); + } + + #[cfg(not(target_arch = "x86_64"))] + { + log::warn!("audio backend not available on this architecture; continuing silently"); + Ok(Self { + inner: InnerBackend::Silent, + }) + } + } + + pub fn configure_sample_rate(&mut self, sample_rate: u32) -> Result<(), Status> { + match &mut self.inner { + InnerBackend::Silent => Ok(()), + #[cfg(target_arch = "x86_64")] + InnerBackend::PcSpeaker(inner) => inner.configure(sample_rate), + } + } + + #[inline] + pub fn output_bit(&mut self, enabled: bool) { + match &mut self.inner { + InnerBackend::Silent => {} + #[cfg(target_arch = "x86_64")] + InnerBackend::PcSpeaker(inner) => inner.output(enabled), + } + } + + pub fn shutdown(&mut self) { + match &mut self.inner { + InnerBackend::Silent => {} + #[cfg(target_arch = "x86_64")] + InnerBackend::PcSpeaker(inner) => inner.disable(), + } + } + + pub fn is_silent(&self) -> bool { + matches!(self.inner, InnerBackend::Silent) + } +} diff --git a/src/app/audio/speaker.rs b/src/app/audio/speaker.rs new file mode 100644 index 0000000..ab35265 --- /dev/null +++ b/src/app/audio/speaker.rs @@ -0,0 +1,79 @@ +use uefi::Status; +use x86_64::instructions::port::Port; + +const PIT_COMMAND_PORT: u16 = 0x43; +const PIT_CHANNEL2_PORT: u16 = 0x42; +const SPEAKER_CONTROL_PORT: u16 = 0x61; +const PIT_BASE_FREQUENCY: u32 = 1_193_182; + +pub struct PcSpeaker { + divisor: u16, +} + +impl PcSpeaker { + pub fn new() -> Result { + let mut speaker = Self { divisor: 0 }; + // Default to a silent configuration. + speaker.disable(); + Ok(speaker) + } + + pub fn configure(&mut self, sample_rate: u32) -> Result<(), Status> { + if sample_rate == 0 { + return Err(Status::INVALID_PARAMETER); + } + + let divisor = (PIT_BASE_FREQUENCY + (sample_rate / 2)) / sample_rate; + let divisor = divisor.clamp(1, u32::from(u16::MAX)) as u16; + + unsafe { + let mut command = Port::::new(PIT_COMMAND_PORT); + command.write(0b1011_0110); // Channel 2, lobyte/hibyte, square wave (mode 3) + + let mut data = Port::::new(PIT_CHANNEL2_PORT); + data.write(divisor as u8); + data.write((divisor >> 8) as u8); + } + + unsafe { + let mut ctrl = Port::::new(SPEAKER_CONTROL_PORT); + let current = ctrl.read(); + // Keep the gate (bit 1) asserted so that toggling bit 0 emits pulses. + let updated = (current | 0b10) & !0b01; + ctrl.write(updated); + } + + self.divisor = divisor; + Ok(()) + } + + #[inline] + pub fn output(&mut self, enabled: bool) { + unsafe { + let mut ctrl = Port::::new(SPEAKER_CONTROL_PORT); + let mut value = ctrl.read(); + if enabled { + value |= 0b01; + } else { + value &= !0b01; + } + ctrl.write(value); + } + } + + pub fn disable(&mut self) { + unsafe { + let mut ctrl = Port::::new(SPEAKER_CONTROL_PORT); + let value = ctrl.read() & !0b11; + ctrl.write(value); + } + } + + pub fn sample_rate(&self) -> u32 { + if self.divisor == 0 { + 0 + } else { + PIT_BASE_FREQUENCY / u32::from(self.divisor) + } + } +} diff --git a/src/app/graphics.rs b/src/app/graphics.rs new file mode 100644 index 0000000..ec315bd --- /dev/null +++ b/src/app/graphics.rs @@ -0,0 +1,151 @@ +use alloc::{vec, vec::Vec}; +use uefi::{ + boot::{self, ScopedProtocol}, + proto::console::gop::{FrameBuffer, GraphicsOutput, Mode, ModeInfo, PixelFormat}, + Status, StatusExt, +}; + +const PIXEL_ON_RGB: u32 = 0x00FF_FFFF; +const PIXEL_OFF_RGB: u32 = 0x0000_0000; + +pub struct GopDisplay { + gop: ScopedProtocol, + mode: ModeInfo, + scratch: Vec, +} + +impl GopDisplay { + pub fn init() -> Result { + let handle = boot::get_handle_for_protocol::().map_err(|e| e.status())?; + let mut gop = + boot::open_protocol_exclusive::(handle).map_err(|e| e.status())?; + + let target_mode = select_best_mode(&gop).ok_or(Status::UNSUPPORTED)?; + gop.set_mode(&target_mode).map_err(|e| e.status())?; + + let mode = gop.current_mode_info(); + let (width, height) = mode.resolution(); + let scratch = vec![0u32; width.saturating_mul(height)]; + + let mut display = Self { gop, mode, scratch }; + display.clear(); + Ok(display) + } + + pub fn mode(&self) -> ModeInfo { + self.mode + } + + pub fn clear(&mut self) { + let info = self.mode; + let (width, height) = info.resolution(); + let mut fb = self.frame_buffer(); + for y in 0..height { + for x in 0..info.stride() { + let offset = ((y * info.stride()) + x) * 4; + unsafe { fb.write_value(offset, PIXEL_OFF_RGB) }; + } + } + } + + pub fn draw_mono_frame( + &mut self, + bits: &[u8], + frame_width: usize, + frame_height: usize, + ) -> Result<(), Status> { + if frame_width == 0 || frame_height == 0 { + return Ok(()); + } + + let info = self.mode; + let (screen_w, screen_h) = info.resolution(); + if frame_width > screen_w || frame_height > screen_h { + return Err(Status::BAD_BUFFER_SIZE); + } + + let offset_x = (screen_w - frame_width) / 2; + let offset_y = (screen_h - frame_height) / 2; + + let stride = info.stride(); + let pixel_format = info.pixel_format(); + let (color_on, color_off) = pixel_colors(pixel_format, info.pixel_bitmask()); + + let mut fb = self.frame_buffer(); + let scratch = &mut self.scratch[..frame_width * frame_height]; + decode_monochrome_frame( + bits, + frame_width, + frame_height, + scratch, + color_on, + color_off, + ); + + for row in 0..frame_height { + let src_row = row * frame_width; + let dest_row = (row + offset_y) * stride + offset_x; + let dest_offset_bytes = dest_row * 4; + let src_slice = &scratch[src_row..src_row + frame_width]; + copy_row(&mut fb, dest_offset_bytes, src_slice); + } + + Ok(()) + } + + fn frame_buffer(&mut self) -> FrameBuffer<'_> { + self.gop.frame_buffer() + } +} + +fn select_best_mode(gop: &GraphicsOutput) -> Option { + gop.modes() + .filter(|mode| mode.info().pixel_format() != PixelFormat::BltOnly) + .max_by(|a, b| { + let (aw, ah) = a.info().resolution(); + let (bw, bh) = b.info().resolution(); + (aw * ah).cmp(&(bw * bh)) + }) +} + +fn pixel_colors( + format: PixelFormat, + bitmask: Option, +) -> (u32, u32) { + match format { + PixelFormat::Rgb | PixelFormat::Bgr => (PIXEL_ON_RGB, PIXEL_OFF_RGB), + PixelFormat::Bitmask => { + if let Some(mask) = bitmask { + let white = mask.red_mask | mask.green_mask | mask.blue_mask; + (white, 0) + } else { + (PIXEL_ON_RGB, PIXEL_OFF_RGB) + } + } + PixelFormat::BltOnly => (PIXEL_ON_RGB, PIXEL_OFF_RGB), + } +} + +fn decode_monochrome_frame( + bits: &[u8], + width: usize, + height: usize, + out_pixels: &mut [u32], + on: u32, + off: u32, +) { + for idx in 0..width * height { + let byte_idx = idx / 8; + let bit_idx = idx % 8; + let bit = (bits.get(byte_idx).copied().unwrap_or(0) >> bit_idx) & 1; + out_pixels[idx] = if bit == 1 { on } else { off }; + } +} + +fn copy_row(fb: &mut FrameBuffer<'_>, dest_offset_bytes: usize, pixels: &[u32]) { + for (idx, &pixel) in pixels.iter().enumerate() { + unsafe { + fb.write_value(dest_offset_bytes + idx * 4, pixel); + } + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..ea85e3f --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,36 @@ +pub mod assets; +pub mod audio; +pub mod graphics; +pub mod player; +pub mod timing; + +use crate::app::{ + assets::AssetSource, audio::AudioBackend, graphics::GopDisplay, timing::TscClock, +}; +use log::info; +use uefi::prelude::*; + +pub fn run(image_handle: Handle, st: &mut SystemTable) -> Result<(), Status> { + info!("badapple-uefi starting up"); + + let bs = st.boot_services(); + + let clock = TscClock::calibrate(bs).map_err(|status| { + info!("failed to calibrate TSC clock: {:?}", status); + status + })?; + + let assets = match AssetSource::new(image_handle, bs) { + Ok(bundle) => bundle, + Err(err) => { + info!("failed to load external assets: {:?}", err); + info!("falling back to built-in sample assets"); + AssetSource::embedded()? + } + }; + + let mut display = GopDisplay::init()?; + let mut audio = AudioBackend::init()?; + + player::play(&clock, &mut display, &mut audio, &assets) +} diff --git a/src/app/player.rs b/src/app/player.rs new file mode 100644 index 0000000..92481c0 --- /dev/null +++ b/src/app/player.rs @@ -0,0 +1,137 @@ +use crate::app::{ + assets::{AssetSource, AudioPack}, + audio::AudioBackend, + graphics::GopDisplay, + timing::TscClock, +}; +use log::warn; +use uefi::Status; + +pub fn play( + clock: &TscClock, + display: &mut GopDisplay, + audio: &mut AudioBackend, + assets: &AssetSource, +) -> Result<(), Status> { + let video = assets.video(); + let audio_pack = assets.audio(); + + if video.frame_count() == 0 { + return Ok(()); + } + + if audio_pack.sample_rate() == 0 { + return Err(Status::INVALID_PARAMETER); + } + + let start_cycles = clock.now_cycles() as u128; + let cycles_per_second = (clock.cycles_per_us() as u128) * 1_000_000u128; + + let mut audio_sched = if audio.is_silent() { + warn!("audio backend unavailable; playing Bad Apple without sound"); + None + } else { + audio.configure_sample_rate(audio_pack.sample_rate())?; + Some(AudioScheduler::new( + audio_pack, + start_cycles, + cycles_per_second, + )) + }; + + let fps = u64::from(video.fps().max(1)); + let frame_count = video.frame_count() as u64; + + for frame_idx in 0..frame_count { + if let Some(frame_bits) = video.frame_bits(frame_idx as u32) { + display.draw_mono_frame(frame_bits, video.width(), video.height())?; + } + + let frame_end = start_cycles + ((frame_idx + 1) as u128) * cycles_per_second / fps as u128; + + // Busy-wait inside firmware land: we spin while catching up audio until + // the frame budget elapses. Firmware has no scheduler, so this is fine + // (and keeps GOP updates & audio in lock-step). + loop { + let now = clock.now_cycles() as u128; + if let Some(sched) = audio_sched.as_mut() { + sched.drain(now, audio); + } + if now >= frame_end { + break; + } + } + } + + // Drain any remaining audio samples after the last frame. + if let Some(sched) = audio_sched.as_mut() { + while !sched.is_finished() { + let now = clock.now_cycles() as u128; + sched.drain(now, audio); + } + } + + audio.output_bit(false); + audio.shutdown(); + Ok(()) +} + +/// Minimal fixed-point scheduler that keeps the 1-bit audio stream in sync with +/// wall-clock time measured via the TSC. +struct AudioScheduler<'a> { + data: &'a [u8], + total_bits: usize, + index: usize, + sample_rate: u32, + start_cycles: u128, + cycles_per_second: u128, +} + +impl<'a> AudioScheduler<'a> { + fn new(pack: AudioPack<'a>, start_cycles: u128, cycles_per_second: u128) -> Self { + Self { + data: pack.bits(), + total_bits: pack.sample_count(), + index: 0, + sample_rate: pack.sample_rate(), + start_cycles, + cycles_per_second, + } + } + + fn drain(&mut self, now_cycles: u128, audio: &mut AudioBackend) { + while let Some(deadline) = self.next_deadline_cycles() { + if now_cycles < deadline { + break; + } + let bit = self.bit_at(self.index); + audio.output_bit(bit); + self.index += 1; + } + } + + fn is_finished(&self) -> bool { + self.index >= self.total_bits + } + + fn next_deadline_cycles(&self) -> Option { + if self.index >= self.total_bits { + None + } else { + Some( + self.start_cycles + + (self.index as u128 * self.cycles_per_second) / self.sample_rate as u128, + ) + } + } + + fn bit_at(&self, idx: usize) -> bool { + if idx >= self.total_bits { + return false; + } + let byte = idx / 8; + let bit = idx % 8; + let value = self.data.get(byte).copied().unwrap_or(0); + ((value >> bit) & 1) != 0 + } +} diff --git a/src/app/timing.rs b/src/app/timing.rs new file mode 100644 index 0000000..6756af2 --- /dev/null +++ b/src/app/timing.rs @@ -0,0 +1,71 @@ +use uefi::{table::boot::BootServices, Status, StatusExt}; + +const CALIBRATION_DURATION_US: u64 = 200_000; // 200 ms for stable measurement + +#[derive(Clone, Copy)] +pub struct TscClock { + cycles_per_us: u64, + start_cycles: u64, +} + +impl TscClock { + pub fn calibrate(bs: &BootServices) -> Result { + let before = read_cycle_counter(); + bs.stall(CALIBRATION_DURATION_US as usize) + .map_err(|e| e.status())?; + let after = read_cycle_counter(); + let delta = after.saturating_sub(before); + let cycles_per_us = delta / CALIBRATION_DURATION_US; + + if cycles_per_us == 0 { + return Err(Status::DEVICE_ERROR); + } + + Ok(Self { + cycles_per_us, + start_cycles: after, + }) + } + + #[inline] + pub fn now_cycles(&self) -> u64 { + read_cycle_counter() + } + + #[inline] + pub fn micros_since_start(&self) -> u64 { + let cycles = self.now_cycles().saturating_sub(self.start_cycles); + cycles / self.cycles_per_us + } + + #[inline] + pub fn micros_to_cycles(&self, micros: u64) -> u64 { + micros.saturating_mul(self.cycles_per_us) + } + + #[inline] + pub fn cycles_per_us(&self) -> u64 { + self.cycles_per_us + } + + #[inline] + pub fn start_cycles(&self) -> u64 { + self.start_cycles + } +} + +#[inline] +fn read_cycle_counter() -> u64 { + #[cfg(target_arch = "x86_64")] + { + use core::arch::x86_64::_rdtsc; + unsafe { _rdtsc() } + } + + #[cfg(target_arch = "aarch64")] + { + let value: u64; + unsafe { core::arch::asm!("mrs {out}, cntvct_el0", out = out(reg) value); } + value + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..23561b3 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,20 @@ +#![no_std] +#![no_main] + +mod app; + +extern crate alloc; + +use uefi::prelude::*; + +#[entry] +fn efi_main(image_handle: Handle, mut st: SystemTable) -> Status { + if let Err(err) = uefi::helpers::init(&mut st) { + return err; + } + + match app::run(image_handle, &mut st) { + Ok(()) => Status::SUCCESS, + Err(status) => status, + } +} diff --git a/test.patch b/test.patch new file mode 100644 index 0000000..4c5b506 --- /dev/null +++ b/test.patch @@ -0,0 +1,35 @@ + +diff --git a/src/app/assets.rs b/src/app/assets.rs +index 1234567..abcdef0 100644 +--- a/src/app/assets.rs ++++ b/src/app/assets.rs +@@ -1,7 +1,7 @@ +-use uefi::{table::boot::BootServices, Status, StatusExt}; ++use uefi::{table::BootServices, Status}; + + // ... rest of your code ... + +diff --git a/src/app/mod.rs b/src/app/mod.rs +index 1234567..abcdef0 100644 +--- a/src/app/mod.rs ++++ b/src/app/mod.rs +@@ -13,7 +13,7 @@ +-pub fn run(image_handle: Handle, st: &mut SystemTable) -> Result<(), Status> { ++pub fn run(image_handle: Handle, st: &mut SystemTable) -> Result<(), Status> { + // ... your logic ... + } + +diff --git a/src/app/graphics.rs b/src/app/graphics.rs +index 1234567..abcdef0 100644 +--- a/src/app/graphics.rs ++++ b/src/app/graphics.rs +@@ -119,7 +119,7 @@ +- let white = mask.red_mask | mask.green_mask | mask.blue_mask; ++ let white = mask.red | mask.green | mask.blue; + + // ... rest of your code ... + +-// Refactor any code that borrows self.scratch and self.frame_buffer mutably at the same time. +-// For example, process scratch first, then frame_buffer, or copy data into a local variable. ++// Refactor any code that borrows self.scratch and self.frame_buffer mutably at the same time. ++// For example, process scratch first, then frame_buffer, or copy data into a local variable. diff --git a/tools/process_badapple.py b/tools/process_badapple.py new file mode 100755 index 0000000..811a69e --- /dev/null +++ b/tools/process_badapple.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Convert a Bad Apple source video into firmware-ready assets. + +This tool requires ffmpeg in PATH. It produces: +- video.baa : 1-bit packed frames with custom header. +- audio.pdm : 1-bit sigma-delta encoded PCM data with custom header. +""" + +import argparse +import math +import struct +import subprocess +import tempfile +from pathlib import Path + +VIDEO_MAGIC = b"BAA\0" +AUDIO_MAGIC = b"BAP\0" + + +def run_ffmpeg(args): + process = subprocess.run(args, check=False) + if process.returncode != 0: + raise RuntimeError(f"ffmpeg failed: {' '.join(args)}") + + +def pcm_to_pdm(samples): + # First-order sigma-delta modulation. + acc = 0 + threshold = 0 + bitstream = bytearray((len(samples) + 7) // 8) + for idx, sample in enumerate(samples): + acc += sample - threshold + if acc >= 0: + threshold = 32767 + bit = 1 + else: + threshold = -32768 + bit = 0 + if bit: + bitstream[idx // 8] |= 1 << (idx % 8) + return bitstream + + +def encode_video(raw_path, width, height, frame_count, fps, threshold): + stride_bits = width * height + stride_bytes = (stride_bits + 7) // 8 + output = bytearray() + output += VIDEO_MAGIC + output += struct.pack('= threshold: + bits[i // 8] |= 1 << (i % 8) + output.extend(bits) + return output + + +def encode_audio(raw_path, sample_rate, start_offset_samples=0): + data = Path(raw_path).read_bytes() + samples = struct.iter_unpack(' 0: + pcm = pcm[start_offset_samples:] + bitstream = pcm_to_pdm(pcm) + + header = bytearray() + header += AUDIO_MAGIC + header += struct.pack('