From 4027eabd9452f5363d78c43b7818432ab5c6938b Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Tue, 18 Aug 2026 11:26:51 -0400 Subject: [PATCH] First light: the kernel paints a framebuffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Loon program draws rectangles on a 640x480 display, as the kernel, through an `Fb` effect that falls through the handler stack to a ramfb driver — the same shape as `Console.write` falling through to the UART. - src/fwcfg.rs: QEMU fw_cfg via its DMA interface, to find `etc/ramfb`. - src/ramfb.rs: a linear XRGB framebuffer in RAM that QEMU scans out. Tell it where the buffer is once; drawing is writing memory. No queues, no interrupts. - `Fb` effects: width, height, clear, fill-rect, present. Integer-only ABI (coords, sizes, 0xRRGGBB). Raster is Rust behind rect-sized primitives on purpose — per-pixel Loon would be ~150 ms/frame at current speed. - boot/gui.oo runs only when the machine has a display; headless boots skip it and `make check` is unaffected. Without a ramfb, Fb ops raise a loud error naming the missing device. - `make gui` boots with a cocoa window and stays up. `make screenshot` boots headless, waits for the GUI, and pulls the frame over QMP as a PNG (tools/screenshot.py, no dependencies) — the CI-shaped way to prove the display works. Not yet: text, input, host-side Fb parity, time in the event loop. Co-Authored-By: Claude Opus 5 --- crates/loon-kernel/.gitignore | 1 + crates/loon-kernel/Makefile | 11 ++- crates/loon-kernel/README.md | 28 +++++- crates/loon-kernel/boot/gui.oo | 31 +++++++ crates/loon-kernel/build.rs | 1 + crates/loon-kernel/src/eir/vm.rs | 25 ++++++ crates/loon-kernel/src/fwcfg.rs | 115 +++++++++++++++++++++++++ crates/loon-kernel/src/main.rs | 68 +++++++++++++-- crates/loon-kernel/src/mmio.rs | 12 +++ crates/loon-kernel/src/ramfb.rs | 88 +++++++++++++++++++ crates/loon-kernel/tools/screenshot.py | 71 +++++++++++++++ 11 files changed, 440 insertions(+), 11 deletions(-) create mode 100644 crates/loon-kernel/boot/gui.oo create mode 100644 crates/loon-kernel/src/fwcfg.rs create mode 100644 crates/loon-kernel/src/ramfb.rs create mode 100755 crates/loon-kernel/tools/screenshot.py diff --git a/crates/loon-kernel/.gitignore b/crates/loon-kernel/.gitignore index 2f7896d..52831ba 100644 --- a/crates/loon-kernel/.gitignore +++ b/crates/loon-kernel/.gitignore @@ -1 +1,2 @@ target/ +screenshot.png diff --git a/crates/loon-kernel/Makefile b/crates/loon-kernel/Makefile index fe1e8b9..bbd9c18 100644 --- a/crates/loon-kernel/Makefile +++ b/crates/loon-kernel/Makefile @@ -6,7 +6,7 @@ KERNEL := target/riscv64gc-unknown-none-elf/release/loon-kernel QEMU := qemu-system-riscv64 QFLAGS := -machine virt -cpu rv64 -smp 1 -m 128M -nographic -bios default -.PHONY: build run check clean +.PHONY: build run gui screenshot check clean build: cargo build --release @@ -15,6 +15,15 @@ build: run: build $(QEMU) $(QFLAGS) -serial mon:stdio -kernel $(KERNEL) +# Boot with a display. The kernel finds the ramfb, runs boot/gui.oo, and +# stays up so there is something to look at. Close the window to quit. +gui: build + $(QEMU) $(QFLAGS) -serial mon:stdio -device ramfb -display cocoa -kernel $(KERNEL) + +# Boot headless with a display, grab the framebuffer over QMP as a PNG. +screenshot: build + @python3 tools/screenshot.py screenshot.png + # The same program on the host, for comparison. Run from the workspace root: # this directory's .cargo/config.toml pins a bare-metal target that the host # build must not inherit. diff --git a/crates/loon-kernel/README.md b/crates/loon-kernel/README.md index b729aaa..ebfbce2 100644 --- a/crates/loon-kernel/README.md +++ b/crates/loon-kernel/README.md @@ -8,9 +8,11 @@ the outermost handler is a UART driver rather than a call into Linux. brew install qemu rustup target add riscv64gc-unknown-none-elf -make run # boot it -make host # run the same program on the host -make check # boot it and diff the two +make run # boot it (serial console) +make gui # boot it with a display — the kernel paints a framebuffer +make screenshot # boot headless, grab the framebuffer over QMP as a PNG +make host # run the same program on the host +make check # boot it and diff the two ``` ## What is here @@ -22,9 +24,13 @@ make check # boot it and diff the two | `src/mmio.rs` | the one place that touches device registers | | `src/heap.rs` | first-fit free-list allocator over RAM above the image | | `src/sbi.rs` | the slice of SBI we need (power off) | +| `src/fwcfg.rs` | QEMU fw_cfg, via its DMA interface — used to find and configure the ramfb | +| `src/ramfb.rs` | the display: a linear XRGB framebuffer in RAM that QEMU scans out | +| `tools/screenshot.py` | headless boot + QMP `screendump` → PNG | | `src/eir/` | boot-image decoder and the EIR interpreter | | `boot/init.oo` | the init program — ordinary Loon | | `boot/mandel.oo` | a Mandelbrot set, because a kernel that boots should get to do one gratuitous thing | +| `boot/gui.oo` | first light: a Loon program painting the framebuffer through `Fb` effects | The host toolchain is not in this crate's build graph. `build.rs` shells out to `loon image`, which compiles `boot/init.oo` to a boot image; the kernel @@ -89,6 +95,22 @@ What remains is roughly 500 ns/op against ~7 ns/op for a minimal native dispatch loop under the same emulator. That gap is real and unexplained; chasing it needs an idle machine and a profiler, not more guessing. +## The display + +`Fb` is an effect (`width`, `height`, `clear`, `fill-rect`, `present`) that +falls through the Loon handler stack to the ramfb driver, exactly as +`Console.write` falls through to the UART. `boot/gui.oo` runs only when the +machine was booted with `-device ramfb`; without one, `Fb` ops raise a loud +error naming the missing device, and the headless boot never invokes them. + +Raster stays in Rust behind rectangle-sized primitives on purpose: at the +interpreter's current speed, per-pixel Loon would be ~150 ms per 640×480 +frame. What lives in Loon is the *what*, not the *how*. + +Not yet: text (needs an embedded bitmap font), input (virtio-input — the next +real piece of work), a host-side `Fb` handler so `make check` can diff the +GUI the way it diffs the console, and any notion of time in the event loop. + ## Known limits - **Cooperative only.** No timer interrupt yet, so a pure loop owns the diff --git a/crates/loon-kernel/boot/gui.oo b/crates/loon-kernel/boot/gui.oo new file mode 100644 index 0000000..fbe656e --- /dev/null +++ b/crates/loon-kernel/boot/gui.oo @@ -0,0 +1,31 @@ +; First light. A Loon program painting a framebuffer, as the kernel. +; +; `Fb` is an effect like any other: nothing here knows whether the pixels +; land in a ramfb, a virtio-gpu, a PPM file on the host, or a simulation +; that only checks the draw calls. This one runs when the machine has a +; display (`make gui`) and is skipped when it does not. + +[effect Fb + [width [] Int] + [height [] Int] + [clear [Int] Unit] + [fill-rect [Int Int Int Int Int] Unit] + [present [] Unit]] + +; A column of bars, each a little further along, each a little bluer. +[fn bar [i] + [let w [Fb.width]] + [let y [+ 40 [* i 36]]] + [let len [+ 120 [* i 44]]] + [let blue [+ 96 [* i 16]]] + [Fb.fill-rect 40 y len 24 [+ [* 40 65536] [+ [* 60 256] blue]]]] + +[fn main [] + [Fb.clear 1710618] ; 0x1a1a1a + [each [range 0 10] bar] + ; the loon: a big off-white square with a dark eye + [let w [Fb.width]] + [Fb.fill-rect [- w 200] 60 140 140 15658734] ; 0xeeeeee + [Fb.fill-rect [- w 130] 100 24 24 1710618] + [Fb.present] + [println [str "painted " w "x" [Fb.height]]]] diff --git a/crates/loon-kernel/build.rs b/crates/loon-kernel/build.rs index bde3783..6963e4d 100644 --- a/crates/loon-kernel/build.rs +++ b/crates/loon-kernel/build.rs @@ -18,6 +18,7 @@ fn main() { ("bench", "LOON_BENCH_IMAGE"), ("loop", "LOON_LOOP_IMAGE"), ("mandel", "LOON_MANDEL_IMAGE"), + ("gui", "LOON_GUI_IMAGE"), ] { let src = manifest.join(format!("boot/{name}.oo")); let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join(format!("{name}.img")); diff --git a/crates/loon-kernel/src/eir/vm.rs b/crates/loon-kernel/src/eir/vm.rs index 467148b..536283e 100644 --- a/crates/loon-kernel/src/eir/vm.rs +++ b/crates/loon-kernel/src/eir/vm.rs @@ -23,6 +23,10 @@ pub trait Host { fn write(&mut self, s: &str); /// Monotonic ticks since boot. fn ticks(&mut self) -> i64; + /// Framebuffer, if the machine has one. Ops are named rather than + /// enumerated so the VM does not have to know what a display can do; the + /// host decides, and says loudly when it can't. + fn fb(&mut self, op: &str, args: &[i64]) -> Result, String>; } /// Operands read out of registers for one instruction. @@ -785,6 +789,27 @@ impl<'m, H: Host> Vm<'m, H> { Ok(Val::Unit) } ("Clock", "now") | ("Clock", "ticks") => Ok(Val::Int(self.host.ticks())), + ("Fb", op) => { + // Everything a framebuffer takes is an integer: coordinates, + // sizes, 0xRRGGBB colours. + let mut ints = Vec::with_capacity(args.len()); + for a in args { + match a { + Val::Int(n) => ints.push(*n), + Val::Float(f) => ints.push(*f as i64), + v => { + return Err(alloc::format!( + "Fb.{op}: expected an integer argument, got a {}", + v.type_name() + )) + } + } + } + Ok(match self.host.fb(op, &ints)? { + Some(n) => Val::Int(n), + None => Val::Unit, + }) + } ("Fail", "fail") => Err(alloc::format!( "unhandled failure: {}", args.first().map(show).unwrap_or_default() diff --git a/crates/loon-kernel/src/fwcfg.rs b/crates/loon-kernel/src/fwcfg.rs new file mode 100644 index 0000000..0e75a06 --- /dev/null +++ b/crates/loon-kernel/src/fwcfg.rs @@ -0,0 +1,115 @@ +//! QEMU fw_cfg — the firmware configuration channel. +//! +//! A tiny key/value store the emulator exposes to the guest; on `virt` it +//! sits at 0x1010_0000. We use exactly one thing from it: the `etc/ramfb` +//! file, whose contents tell QEMU where our framebuffer lives. Everything +//! goes through the DMA interface, which is byte-order-defined (big-endian) +//! and does not care about the register's access width. + +use core::sync::atomic::{fence, Ordering}; + +use crate::mmio; + +const BASE: usize = 0x1010_0000; +const SELECTOR: usize = BASE + 0x08; +const DMA: usize = BASE + 0x10; + +const KEY_FILE_DIR: u16 = 0x0019; + +const CTL_ERROR: u32 = 1 << 0; +const CTL_READ: u32 = 1 << 1; +const CTL_SELECT: u32 = 1 << 3; +const CTL_WRITE: u32 = 1 << 4; + +/// One DMA descriptor, in memory, all fields big-endian. +#[repr(C)] +struct DmaAccess { + control: u32, + length: u32, + address: u64, +} + +/// One entry of the file directory: `struct FWCfgFile`. +#[repr(C)] +struct File { + size: u32, + select: u16, + _reserved: u16, + name: [u8; 56], +} + +pub struct FwCfg; + +impl FwCfg { + /// Issue one DMA transfer and wait for it. `control` carries the op bits + /// (and, if selecting, the key in the upper half). + unsafe fn dma(&self, control: u32, buf: *mut u8, len: usize) -> Result<(), ()> { + let desc = DmaAccess { + control: control.to_be(), + length: (len as u32).to_be(), + address: (buf as u64).to_be(), + }; + // The device reads the descriptor and the buffer straight from RAM. + fence(Ordering::SeqCst); + mmio::write64(DMA, (&desc as *const DmaAccess as u64).to_be()); + // Completion is signalled by the device clearing `control`. + loop { + fence(Ordering::SeqCst); + let c = u32::from_be(core::ptr::read_volatile(&desc.control)); + if c == 0 { + return Ok(()); + } + if c & CTL_ERROR != 0 { + return Err(()); + } + core::hint::spin_loop(); + } + } + + unsafe fn read(&self, key: u16, buf: &mut [u8]) -> Result<(), ()> { + self.dma( + ((key as u32) << 16) | CTL_SELECT | CTL_READ, + buf.as_mut_ptr(), + buf.len(), + ) + } + + /// Continue reading the currently selected item. + unsafe fn read_more(&self, buf: &mut [u8]) -> Result<(), ()> { + self.dma(CTL_READ, buf.as_mut_ptr(), buf.len()) + } + + /// Find a named file and return its selector key. + pub fn find(&self, name: &str) -> Option { + unsafe { + let mut count = [0u8; 4]; + self.read(KEY_FILE_DIR, &mut count).ok()?; + let count = u32::from_be_bytes(count); + for _ in 0..count { + let mut raw = [0u8; core::mem::size_of::()]; + self.read_more(&mut raw).ok()?; + let f: File = core::ptr::read_unaligned(raw.as_ptr() as *const File); + let n = f.name.iter().position(|&b| b == 0).unwrap_or(f.name.len()); + if &f.name[..n] == name.as_bytes() { + return Some(u16::from_be(f.select)); + } + } + None + } + } + + /// Overwrite a file's contents (only meaningful for the few writable + /// ones, like `etc/ramfb`). + pub fn write(&self, key: u16, data: &[u8]) -> Result<(), ()> { + unsafe { + // Selecting via the register first is belt and braces: some + // firmware paths do it and it costs nothing. + mmio::write16(SELECTOR, key.to_be()); + self.dma( + ((key as u32) << 16) | CTL_SELECT | CTL_WRITE, + data.as_ptr() as *mut u8, + data.len(), + ) + } + } +} diff --git a/crates/loon-kernel/src/main.rs b/crates/loon-kernel/src/main.rs index 9c77b8b..4e17350 100644 --- a/crates/loon-kernel/src/main.rs +++ b/crates/loon-kernel/src/main.rs @@ -17,8 +17,10 @@ static HEAP: heap::Heap = heap::Heap::new(); #[macro_use] mod uart; mod eir; +mod fwcfg; mod heap; mod mmio; +mod ramfb; mod sbi; // Set up a stack and clear .bss before anything Rust-shaped runs. `a0` holds @@ -99,7 +101,26 @@ pub extern "C" fn kmain(hart: usize, dtb: usize) -> ! { micros_since(t0), micros_since(BOOT.load(core::sync::atomic::Ordering::Relaxed)), ); - sbi::shutdown(false) + // If the machine has a display, hand it to the GUI program and + // stay up so there is something to look at. Otherwise we are a + // headless run and the polite thing is to power off. + match ramfb::Ramfb::init(640, 480) { + Some(fb) => { + println!("framebuffer {}x{} — running gui", fb.width, fb.height); + if let Err(e) = run_on( + include_bytes!(env!("LOON_GUI_IMAGE")), + Machine { fb: Some(fb) }, + ) { + println!("gui failed: {e}"); + sbi::shutdown(true); + } + println!("gui up — close the window or ^A x to quit"); + loop { + unsafe { core::arch::asm!("wfi") }; + } + } + None => sbi::shutdown(false), + } } Err(e) => { println!(); @@ -111,7 +132,9 @@ pub extern "C" fn kmain(hart: usize, dtb: usize) -> ! { /// The machine, as the VM sees it. Effects that no Loon handler caught /// arrive here, which is the only place in the system that touches hardware. -struct Machine; +struct Machine { + fb: Option, +} impl eir::vm::Host for Machine { fn write(&mut self, s: &str) { @@ -119,9 +142,37 @@ impl eir::vm::Host for Machine { } fn ticks(&mut self) -> i64 { - let t: u64; - unsafe { core::arch::asm!("rdtime {}", out(reg) t) }; - t as i64 + now() as i64 + } + + fn fb(&mut self, op: &str, a: &[i64]) -> Result, alloc::string::String> { + let Some(fb) = self.fb.as_mut() else { + return Err(alloc::format!( + "Fb.{op}: this machine has no framebuffer (boot with -device ramfb)" + )); + }; + let arg = |i: usize| -> Result { + a.get(i) + .copied() + .ok_or_else(|| alloc::format!("Fb.{op}: missing argument {i}")) + }; + match op { + "width" => Ok(Some(fb.width as i64)), + "height" => Ok(Some(fb.height as i64)), + "clear" => { + fb.clear(arg(0)? as u32); + Ok(None) + } + "fill-rect" => { + fb.fill_rect(arg(0)?, arg(1)?, arg(2)?, arg(3)?, arg(4)? as u32); + Ok(None) + } + "present" => { + fb.present(); + Ok(None) + } + _ => Err(alloc::format!("Fb.{op}: no such framebuffer operation")), + } } } @@ -140,8 +191,11 @@ fn micros_since(start: u64) -> u64 { } fn run_init(image: &[u8]) -> Result<(), alloc::string::String> { + run_on(image, Machine { fb: None }) +} + +fn run_on(image: &[u8], mut machine: Machine) -> Result<(), alloc::string::String> { let module = eir::decode::decode(image)?; - let mut machine = Machine; let mut vm = eir::vm::Vm::new(&module, &mut machine).with_fuel(500_000_000); vm.run()?; Ok(()) @@ -152,7 +206,7 @@ fn run_init(image: &[u8]) -> Result<(), alloc::string::String> { /// interpreter rather than the console. fn run_bench_named(name: &str, image: &[u8]) -> Result<(), alloc::string::String> { let module = eir::decode::decode(image)?; - let mut machine = Machine; + let mut machine = Machine { fb: None }; let mut vm = eir::vm::Vm::new(&module, &mut machine).with_fuel(2_000_000_000); let t = now(); diff --git a/crates/loon-kernel/src/mmio.rs b/crates/loon-kernel/src/mmio.rs index 8c48c5b..6e0af27 100644 --- a/crates/loon-kernel/src/mmio.rs +++ b/crates/loon-kernel/src/mmio.rs @@ -19,3 +19,15 @@ pub unsafe fn read8(addr: usize) -> u8 { pub unsafe fn write8(addr: usize, val: u8) { core::ptr::write_volatile(addr as *mut u8, val) } + +/// # Safety +/// `addr` must be a valid, naturally aligned MMIO register. +pub unsafe fn write16(addr: usize, val: u16) { + core::ptr::write_volatile(addr as *mut u16, val) +} + +/// # Safety +/// `addr` must be a valid, naturally aligned MMIO register. +pub unsafe fn write64(addr: usize, val: u64) { + core::ptr::write_volatile(addr as *mut u64, val) +} diff --git a/crates/loon-kernel/src/ramfb.rs b/crates/loon-kernel/src/ramfb.rs new file mode 100644 index 0000000..190d52b --- /dev/null +++ b/crates/loon-kernel/src/ramfb.rs @@ -0,0 +1,88 @@ +//! ramfb — a linear framebuffer in guest RAM that QEMU scans out. +//! +//! The simplest display a VM can have: we own a `width * height` array of +//! XRGB pixels, tell QEMU where it is once through fw_cfg, and from then on +//! drawing is writing memory. No command queue, no interrupts, no GPU. Boot +//! with `-device ramfb` and a real `-display` to see it. + +use alloc::vec; +use alloc::vec::Vec; + +use crate::fwcfg::FwCfg; + +/// DRM_FORMAT_XRGB8888 — 'XR24' as a little-endian fourcc. +const FOURCC_XRGB8888: u32 = 0x3432_5258; + +/// The config record QEMU expects in `etc/ramfb`, all fields big-endian. +#[repr(C, packed)] +struct Cfg { + addr: u64, + fourcc: u32, + flags: u32, + width: u32, + height: u32, + stride: u32, +} + +pub struct Ramfb { + pub width: u32, + pub height: u32, + pixels: Vec, +} + +impl Ramfb { + /// Allocate a framebuffer and point QEMU at it. `None` if the machine + /// has no `etc/ramfb` — i.e. was booted without `-device ramfb`. + pub fn init(width: u32, height: u32) -> Option { + let key = FwCfg.find("etc/ramfb")?; + let pixels = vec![0u32; (width * height) as usize]; + let cfg = Cfg { + addr: (pixels.as_ptr() as u64).to_be(), + fourcc: FOURCC_XRGB8888.to_be(), + flags: 0, + width: width.to_be(), + height: height.to_be(), + stride: (width * 4).to_be(), + }; + let bytes = unsafe { + core::slice::from_raw_parts( + &cfg as *const Cfg as *const u8, + core::mem::size_of::(), + ) + }; + FwCfg.write(key, bytes).ok()?; + Some(Ramfb { + width, + height, + pixels, + }) + } + + pub fn clear(&mut self, color: u32) { + self.pixels.fill(color); + } + + /// Fill a rectangle, clipped to the screen. Coordinates are signed so a + /// shape can hang off any edge without the caller doing arithmetic. + pub fn fill_rect(&mut self, x: i64, y: i64, w: i64, h: i64, color: u32) { + let (sw, sh) = (self.width as i64, self.height as i64); + let x0 = x.max(0); + let y0 = y.max(0); + let x1 = (x + w).min(sw); + let y1 = (y + h).min(sh); + if x0 >= x1 || y0 >= y1 { + return; + } + for row in y0..y1 { + let start = (row * sw + x0) as usize; + let end = (row * sw + x1) as usize; + self.pixels[start..end].fill(color); + } + } + + /// Nothing to flush — QEMU reads the buffer on its own refresh timer. + /// Kept as the seam where a double buffer or a dirty-rect hint would go. + pub fn present(&mut self) { + core::sync::atomic::fence(core::sync::atomic::Ordering::SeqCst); + } +} diff --git a/crates/loon-kernel/tools/screenshot.py b/crates/loon-kernel/tools/screenshot.py new file mode 100755 index 0000000..57877de --- /dev/null +++ b/crates/loon-kernel/tools/screenshot.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Boot the kernel headless with a ramfb, wait for the GUI to come up, and +grab the framebuffer through QMP as a PNG. + +This is how the display gets verified without a window: the same path a CI +box would use. Prints the output path on success and exits non-zero if the +GUI never reported in. +""" +import json, os, socket, struct, subprocess, sys, tempfile, time, zlib + +KERNEL = "target/riscv64gc-unknown-none-elf/release/loon-kernel" +OUT = sys.argv[1] if len(sys.argv) > 1 else "screenshot.png" + +tmp = tempfile.mkdtemp() +sock = os.path.join(tmp, "qmp.sock") +ppm = os.path.join(tmp, "fb.ppm") +serial = open(os.path.join(tmp, "serial.txt"), "w+b") + +qemu = subprocess.Popen( + ["qemu-system-riscv64", "-machine", "virt", "-cpu", "rv64", "-smp", "1", + "-m", "128M", "-nographic", "-serial", "mon:stdio", "-bios", "default", + "-device", "ramfb", "-display", "none", + "-qmp", f"unix:{sock},server,nowait", "-kernel", KERNEL], + stdin=subprocess.DEVNULL, stdout=serial, stderr=subprocess.STDOUT, +) + +def serial_text(): + serial.seek(0) + return serial.read().decode("utf-8", "replace") + +deadline = time.time() + 120 +while time.time() < deadline: + if "gui up" in serial_text() or qemu.poll() is not None: + break + time.sleep(0.5) + +if "gui up" not in serial_text(): + print("gui never came up. serial:\n" + serial_text(), file=sys.stderr) + qemu.kill() + sys.exit(1) + +s = socket.socket(socket.AF_UNIX) +s.connect(sock) +f = s.makefile("rw") +f.readline() # greeting + +def cmd(o): + f.write(json.dumps(o) + "\n"); f.flush() + while True: + line = json.loads(f.readline()) + if "return" in line or "error" in line: + return line + +cmd({"execute": "qmp_capabilities"}) +cmd({"execute": "screendump", "arguments": {"filename": ppm}}) +cmd({"execute": "quit"}) +qemu.wait() + +# PPM -> PNG, no dependencies. +data = open(ppm, "rb").read() +magic, dims, _maxval, px = data.split(b"\n", 3) +w, h = map(int, dims.split()) +raw = b"".join(b"\x00" + px[y * w * 3:(y + 1) * w * 3] for y in range(h)) +def chunk(t, b): + return struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b) & 0xFFFFFFFF) +png = (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"")) +open(OUT, "wb").write(png) +print(OUT)