use crate::{assets::Assets, font, palette};
use anyhow::{anyhow, bail, Result};
use serde::{Deserialize, Serialize};
pub const CART_VERSION: u16 = 1;
const CART_MAGIC: &[u8; 6] = b"PIXEL8";
const CHUNK_TYPE: [u8; 4] = *b"pxRt";
const PNG_SIG: [u8; 8] = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
const MAX_PAYLOAD: usize = 64 * 1024 * 1024;
pub const MAX_WASM_SIZE: usize = 131_072;
pub const WASM_PAGE_SIZE: usize = 65_536;
pub const MEMORY_CAP: usize = 131_072;
pub fn initial_memory_bytes(wasm: &[u8]) -> Option<usize> {
let engine = wasmi::Engine::default();
let module = wasmi::Module::new(&engine, wasm).ok()?;
match module.get_export("memory")? {
wasmi::ExternType::Memory(mt) => Some(mt.minimum() as usize * WASM_PAGE_SIZE),
_ => None,
}
}
#[derive(Serialize, Deserialize)]
pub struct Cart {
#[serde(with = "crate::wire::base64_bytes")]
pub wasm: Vec<u8>,
pub assets: Assets,
pub source: Option<String>,
}
pub fn save_png(cart: &Cart, path: &std::path::Path) -> Result<()> {
let bytes = encode(cart)?;
std::fs::write(path, bytes)?;
Ok(())
}
pub fn load_png(path: &std::path::Path) -> Result<Cart> {
let bytes = std::fs::read(path)?;
decode(&bytes)
}
pub fn encode(cart: &Cart) -> Result<Vec<u8>> {
validate(cart)?;
let label = render_label(&cart.assets);
let mut png = PNG_SIG.to_vec();
let mut ihdr = Vec::with_capacity(13);
ihdr.extend((LABEL_W as u32).to_be_bytes());
ihdr.extend((LABEL_H as u32).to_be_bytes());
ihdr.extend([8, 6, 0, 0, 0]);
write_chunk(&mut png, *b"IHDR", &ihdr);
let mut raw = Vec::with_capacity(LABEL_H * (1 + LABEL_W * 4));
for y in 0..LABEL_H {
raw.push(0);
raw.extend_from_slice(&label[y * LABEL_W * 4..(y + 1) * LABEL_W * 4]);
}
let idat = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 8);
write_chunk(&mut png, *b"IDAT", &idat);
let mut payload = CART_MAGIC.to_vec();
payload.extend(CART_VERSION.to_le_bytes());
let body = serde_json::to_vec(cart)?;
payload.extend(miniz_oxide::deflate::compress_to_vec(&body, 8));
write_chunk(&mut png, CHUNK_TYPE, &payload);
write_chunk(&mut png, *b"IEND", &[]);
Ok(png)
}
pub fn is_cart(bytes: &[u8]) -> bool {
if !bytes.starts_with(&PNG_SIG) {
return false;
}
let mut rest = &bytes[8..];
while rest.len() >= 12 {
let len = u32::from_be_bytes(rest[0..4].try_into().unwrap()) as usize;
let ctype: [u8; 4] = rest[4..8].try_into().unwrap();
if ctype == CHUNK_TYPE {
return true;
}
if &ctype == b"IEND" || rest.len() < 12 + len {
return false;
}
rest = &rest[12 + len..];
}
false
}
pub fn decode(bytes: &[u8]) -> Result<Cart> {
if !bytes.starts_with(&PNG_SIG) {
bail!("Not a PNG file");
}
let mut rest = &bytes[8..];
let mut payload = None;
while rest.len() >= 12 {
let len = u32::from_be_bytes(rest[0..4].try_into().unwrap()) as usize;
let ctype: [u8; 4] = rest[4..8].try_into().unwrap();
if rest.len() < 12 + len {
bail!("Truncated PNG chunk");
}
let data = &rest[8..8 + len];
if ctype == CHUNK_TYPE {
let crc = u32::from_be_bytes(rest[8 + len..12 + len].try_into().unwrap());
let mut h = crc32fast::Hasher::new();
h.update(&ctype);
h.update(data);
if h.finalize() != crc {
bail!("Cart data is corrupted (bad checksum)");
}
payload = Some(data.to_vec());
}
if &ctype == b"IEND" {
break;
}
rest = &rest[12 + len..];
}
let payload = payload.ok_or_else(|| anyhow!("PNG has no Pixel8 cart data"))?;
let body = payload
.strip_prefix(CART_MAGIC.as_slice())
.ok_or_else(|| anyhow!("Bad cart magic"))?;
if body.len() < 2 {
bail!("Truncated cart header");
}
let version = u16::from_le_bytes(body[0..2].try_into().unwrap());
if version != CART_VERSION {
bail!("Cart format version {version} is not supported (this is version {CART_VERSION})");
}
let raw = miniz_oxide::inflate::decompress_to_vec_with_limit(&body[2..], MAX_PAYLOAD)
.map_err(|e| anyhow!("Cart data is corrupted: {e}"))?;
let cart: Cart = serde_json::from_slice(&raw)?;
validate(&cart)?;
Ok(cart)
}
fn validate(cart: &Cart) -> Result<()> {
if !cart.wasm.starts_with(b"\0asm") {
bail!("Cart payload is not a wasm module");
}
if cart.wasm.len() > MAX_WASM_SIZE {
bail!(
"Cart wasm is {} bytes; the limit is {} (128K)",
cart.wasm.len(),
MAX_WASM_SIZE
);
}
crate::assets::validate(&cart.assets)?;
Ok(())
}
fn write_chunk(out: &mut Vec<u8>, ctype: [u8; 4], data: &[u8]) {
out.extend((data.len() as u32).to_be_bytes());
out.extend(ctype);
out.extend_from_slice(data);
let mut h = crc32fast::Hasher::new();
h.update(&ctype);
h.update(data);
out.extend(h.finalize().to_be_bytes());
}
pub fn encode_screen_png(fb: &crate::fb::Framebuffer, scale: usize) -> Vec<u8> {
let scale = scale.max(1);
let (w, h) = (128 * scale, 128 * scale);
let mut png = PNG_SIG.to_vec();
let mut ihdr = Vec::with_capacity(13);
ihdr.extend((w as u32).to_be_bytes());
ihdr.extend((h as u32).to_be_bytes());
ihdr.extend([8, 6, 0, 0, 0]);
write_chunk(&mut png, *b"IHDR", &ihdr);
let pixels = fb.pixels();
let mut raw = Vec::with_capacity(h * (1 + w * 4));
for y in 0..h {
raw.push(0);
for x in 0..w {
let c = pixels[(y / scale) * 128 + x / scale];
raw.extend(palette::rgba(c));
}
}
write_chunk(
&mut png,
*b"IDAT",
&miniz_oxide::deflate::compress_to_vec_zlib(&raw, 8),
);
write_chunk(&mut png, *b"IEND", &[]);
png
}
pub const LABEL_W: usize = 160;
pub const LABEL_H: usize = 205;
const TRANSPARENT: u8 = 0xff;
struct Canvas {
px: Vec<u8>,
}
impl Canvas {
fn new() -> Self {
Self {
px: vec![TRANSPARENT; LABEL_W * LABEL_H],
}
}
fn set(&mut self, x: i32, y: i32, c: u8) {
if (0..LABEL_W as i32).contains(&x) && (0..LABEL_H as i32).contains(&y) {
self.px[y as usize * LABEL_W + x as usize] = c;
}
}
fn fill(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, c: u8) {
for y in y0..=y1 {
for x in x0..=x1 {
self.set(x, y, c);
}
}
}
fn text(&mut self, s: &str, x: i32, y: i32, c: u8, scale: i32) {
let mut cx = x;
for ch in s.chars() {
let rows = font::glyph(ch);
for (ry, row) in rows.iter().enumerate() {
for rx in 0..3 {
if row & (0b100 >> rx) != 0 {
self.fill(
cx + rx * scale,
y + ry as i32 * scale,
cx + rx * scale + scale - 1,
y + ry as i32 * scale + scale - 1,
c,
);
}
}
}
cx += font::GLYPH_W * scale;
}
}
fn text_centered(&mut self, s: &str, y: i32, c: u8, scale: i32) {
let w = font::text_width(s) * scale;
self.text(s, (LABEL_W as i32 - w) / 2, y, c, scale);
}
}
fn render_label(assets: &Assets) -> Vec<u8> {
let mut c = Canvas::new();
let (w, h) = (LABEL_W as i32, LABEL_H as i32);
c.fill(0, 0, w - 1, h - 1, palette::col::LIGHT_GREY);
for dy in 0..4 {
for dx in 0..4 {
if dx + dy < 4 {
c.set(dx, dy, TRANSPARENT); c.set(w - 1 - dx, dy, TRANSPARENT); c.set(dx, h - 1 - dy, TRANSPARENT); c.set(w - 1 - dx, h - 1 - dy, TRANSPARENT); }
}
}
c.fill(0, h - 6, w - 1, h - 1, palette::col::DARK_GREY);
c.fill(w - 3, 0, w - 1, h - 1, palette::col::DARK_GREY);
let stripe_cols = [
palette::col::RED,
palette::col::ORANGE,
palette::col::YELLOW,
palette::col::GREEN,
palette::col::BLUE,
palette::col::PINK,
];
for (i, col) in stripe_cols.iter().enumerate() {
let seg = (w - 32) / stripe_cols.len() as i32;
c.fill(
16 + i as i32 * seg,
8,
16 + (i as i32 + 1) * seg - 1,
12,
*col,
);
}
let label_px = assets.label.clone().unwrap_or_else(default_label);
c.fill(14, 22, 14 + 131, 22 + 131, palette::col::BLACK);
for y in 0..128 {
for x in 0..128 {
c.set(16 + x, 24 + y, label_px[(y * 128 + x) as usize] & 0x0f);
}
}
let title = if assets.meta.name.is_empty() {
"untitled"
} else {
&assets.meta.name
};
c.text_centered(title, 162, palette::col::DARK_BLUE, 1);
if !assets.meta.author.is_empty() {
c.text_centered(
&format!("by {}", assets.meta.author),
172,
palette::col::DARK_GREY,
1,
);
}
c.text_centered("pixel8 cartridge", 190, palette::col::DARK_GREY, 1);
let mut rgba = vec![0u8; LABEL_W * LABEL_H * 4];
for (i, &p) in c.px.iter().enumerate() {
let out = &mut rgba[i * 4..i * 4 + 4];
if p == TRANSPARENT {
out.copy_from_slice(&[0, 0, 0, 0]);
} else {
out.copy_from_slice(&palette::rgba(p));
}
}
rgba
}
pub fn default_label() -> Vec<u8> {
let mut px = vec![palette::col::DARK_BLUE; 128 * 128];
for y in (4..128).step_by(8) {
for x in (4..128).step_by(8) {
px[y * 128 + x] = palette::col::DARK_PURPLE;
}
}
let mut set = |x: i32, y: i32, c: u8| {
if (0..128).contains(&x) && (0..128).contains(&y) {
px[y as usize * 128 + x as usize] = c;
}
};
let word = "pixel8";
let scale = 4;
let w = font::text_width(word) * scale;
let x0 = (128 - w) / 2 + scale / 2;
let y0 = 48;
for (i, ch) in word.chars().enumerate() {
let rows = font::glyph(ch);
let color = [8u8, 9, 10, 11, 12, 14][i % 6];
for (ry, row) in rows.iter().enumerate() {
for rx in 0..3i32 {
if row & (0b100 >> rx) != 0 {
for dy in 0..scale {
for dx in 0..scale {
set(
x0 + (i as i32 * font::GLYPH_W + rx) * scale + dx,
y0 + ry as i32 * scale + dy,
color,
);
}
}
}
}
}
}
px
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assets::Note;
#[test]
fn initial_memory_one_page() {
let wasm = wat::parse_str("(module (memory (export \"memory\") 1))").unwrap();
assert_eq!(initial_memory_bytes(&wasm), Some(65_536));
}
#[test]
fn initial_memory_two_pages() {
let wasm = wat::parse_str("(module (memory (export \"memory\") 2))").unwrap();
assert_eq!(initial_memory_bytes(&wasm), Some(131_072));
}
#[test]
fn initial_memory_three_pages() {
let wasm = wat::parse_str("(module (memory (export \"memory\") 3))").unwrap();
assert_eq!(initial_memory_bytes(&wasm), Some(196_608));
}
#[test]
fn initial_memory_no_memory_is_none() {
let wasm = wat::parse_str("(module)").unwrap();
assert_eq!(initial_memory_bytes(&wasm), None);
}
#[test]
fn initial_memory_non_memory_export_is_none() {
let wasm = wat::parse_str("(module (func (export \"f\")))").unwrap();
assert_eq!(initial_memory_bytes(&wasm), None);
}
#[test]
fn initial_memory_invalid_bytes_is_none() {
assert_eq!(initial_memory_bytes(&[0, 1, 2, 3]), None);
}
fn test_cart() -> Cart {
let mut assets = Assets::default();
assets.meta.name = "roundtrip".into();
assets.meta.author = "tester".into();
assets.sprites.set(5, 5, 14);
assets.map.set(2, 3, 7);
assets.sfx[1].notes[0] = Note {
pitch: 40,
wave: 2,
volume: 6,
effect: 1,
};
Cart {
wasm: b"\0asm\x01\0\0\0".to_vec(),
assets,
source: Some("fn main() {}".into()),
}
}
#[test]
fn png_roundtrip() {
let cart = test_cart();
let png = encode(&cart).unwrap();
assert!(png.starts_with(&PNG_SIG), "output is a real png");
let back = decode(&png).unwrap();
assert_eq!(back.wasm, cart.wasm);
assert_eq!(back.assets.meta.name, "roundtrip");
assert_eq!(back.assets.sprites.get(5, 5), 14);
assert_eq!(back.assets.map.get(2, 3), 7);
assert_eq!(back.assets.sfx[1].notes[0].pitch, 40);
assert_eq!(back.source.as_deref(), Some("fn main() {}"));
}
#[test]
fn playable_cart_has_no_source() {
let mut cart = test_cart();
cart.source = None;
let back = decode(&encode(&cart).unwrap()).unwrap();
assert!(back.source.is_none());
}
#[test]
fn corrupted_payload_is_rejected() {
let cart = test_cart();
let mut png = encode(&cart).unwrap();
let pos = png
.windows(4)
.position(|w| w == CHUNK_TYPE)
.expect("chunk present")
+ 20;
png[pos] ^= 0xff;
let err = decode(&png).map(|_| ()).unwrap_err().to_string();
assert!(err.contains("corrupted"), "{err}");
}
#[test]
fn non_cart_png_is_rejected() {
let mut png = PNG_SIG.to_vec();
write_chunk(&mut png, *b"IHDR", &[0; 13]);
write_chunk(&mut png, *b"IEND", &[]);
let err = decode(&png).map(|_| ()).unwrap_err().to_string();
assert!(err.contains("no Pixel8 cart data"), "{err}");
}
#[test]
fn bad_wasm_is_rejected() {
let mut cart = test_cart();
cart.wasm = b"not wasm".to_vec();
assert!(encode(&cart).is_err());
}
#[test]
fn oversized_wasm_is_rejected() {
let mut cart = test_cart();
cart.wasm = b"\0asm\x01\0\0\0".to_vec();
cart.wasm.resize(MAX_WASM_SIZE + 1, 0);
assert!(
encode(&cart).is_err(),
"wasm over 128K must be rejected at export"
);
}
#[test]
fn max_size_wasm_is_accepted() {
let mut cart = test_cart();
cart.wasm = b"\0asm\x01\0\0\0".to_vec();
cart.wasm.resize(MAX_WASM_SIZE, 0);
assert!(
encode(&cart).is_ok(),
"wasm at exactly 128K must be accepted"
);
}
#[test]
fn validate_rejects_oversized_wasm() {
let mut cart = test_cart();
cart.wasm = b"\0asm\x01\0\0\0".to_vec();
cart.wasm.resize(MAX_WASM_SIZE + 1, 0);
let err = validate(&cart).unwrap_err().to_string();
assert!(
err.contains("the limit is"),
"expected size-gate message, got: {err}"
);
}
#[test]
fn future_version_is_rejected() {
let cart = test_cart();
let mut png = encode(&cart).unwrap();
let pos = png
.windows(10)
.position(|w| w[0..4] == CHUNK_TYPE && &w[4..10] == CART_MAGIC)
.unwrap();
let vpos = pos + 10;
png[vpos] = 0xfe;
assert!(decode(&png).is_err());
}
}