1use crate::{assets::Assets, font, palette};
23use anyhow::{anyhow, bail, Result};
24use serde::{Deserialize, Serialize};
25
26pub const CART_VERSION: u16 = 1;
29const CART_MAGIC: &[u8; 6] = b"PIXEL8";
30const CHUNK_TYPE: [u8; 4] = *b"pxRt";
31const PNG_SIG: [u8; 8] = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
32const MAX_PAYLOAD: usize = 64 * 1024 * 1024;
34pub const MAX_WASM_SIZE: usize = 131_072;
38pub const WASM_PAGE_SIZE: usize = 65_536;
40pub const MEMORY_CAP: usize = 131_072;
43
44pub fn initial_memory_bytes(wasm: &[u8]) -> Option<usize> {
48 let engine = wasmi::Engine::default();
49 let module = wasmi::Module::new(&engine, wasm).ok()?;
50 match module.get_export("memory")? {
51 wasmi::ExternType::Memory(mt) => Some(mt.minimum() as usize * WASM_PAGE_SIZE),
52 _ => None,
53 }
54}
55
56#[derive(Serialize, Deserialize)]
58pub struct Cart {
59 #[serde(with = "crate::wire::base64_bytes")]
61 pub wasm: Vec<u8>,
62 pub assets: Assets,
63 pub source: Option<String>,
65}
66
67pub fn save_png(cart: &Cart, path: &std::path::Path) -> Result<()> {
69 let bytes = encode(cart)?;
70 std::fs::write(path, bytes)?;
71 Ok(())
72}
73
74pub fn load_png(path: &std::path::Path) -> Result<Cart> {
76 let bytes = std::fs::read(path)?;
77 decode(&bytes)
78}
79
80pub fn encode(cart: &Cart) -> Result<Vec<u8>> {
82 validate(cart)?;
83 let label = render_label(&cart.assets);
84
85 let mut png = PNG_SIG.to_vec();
86 let mut ihdr = Vec::with_capacity(13);
88 ihdr.extend((LABEL_W as u32).to_be_bytes());
89 ihdr.extend((LABEL_H as u32).to_be_bytes());
90 ihdr.extend([8, 6, 0, 0, 0]);
91 write_chunk(&mut png, *b"IHDR", &ihdr);
92
93 let mut raw = Vec::with_capacity(LABEL_H * (1 + LABEL_W * 4));
95 for y in 0..LABEL_H {
96 raw.push(0);
97 raw.extend_from_slice(&label[y * LABEL_W * 4..(y + 1) * LABEL_W * 4]);
98 }
99 let idat = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 8);
100 write_chunk(&mut png, *b"IDAT", &idat);
101
102 let mut payload = CART_MAGIC.to_vec();
104 payload.extend(CART_VERSION.to_le_bytes());
105 let body = serde_json::to_vec(cart)?;
106 payload.extend(miniz_oxide::deflate::compress_to_vec(&body, 8));
107 write_chunk(&mut png, CHUNK_TYPE, &payload);
108
109 write_chunk(&mut png, *b"IEND", &[]);
110 Ok(png)
111}
112
113pub fn is_cart(bytes: &[u8]) -> bool {
117 if !bytes.starts_with(&PNG_SIG) {
118 return false;
119 }
120 let mut rest = &bytes[8..];
121 while rest.len() >= 12 {
122 let len = u32::from_be_bytes(rest[0..4].try_into().unwrap()) as usize;
123 let ctype: [u8; 4] = rest[4..8].try_into().unwrap();
124 if ctype == CHUNK_TYPE {
125 return true;
126 }
127 if &ctype == b"IEND" || rest.len() < 12 + len {
128 return false;
129 }
130 rest = &rest[12 + len..];
131 }
132 false
133}
134
135pub fn decode(bytes: &[u8]) -> Result<Cart> {
137 if !bytes.starts_with(&PNG_SIG) {
138 bail!("Not a PNG file");
139 }
140 let mut rest = &bytes[8..];
141 let mut payload = None;
142 while rest.len() >= 12 {
143 let len = u32::from_be_bytes(rest[0..4].try_into().unwrap()) as usize;
144 let ctype: [u8; 4] = rest[4..8].try_into().unwrap();
145 if rest.len() < 12 + len {
146 bail!("Truncated PNG chunk");
147 }
148 let data = &rest[8..8 + len];
149 if ctype == CHUNK_TYPE {
150 let crc = u32::from_be_bytes(rest[8 + len..12 + len].try_into().unwrap());
151 let mut h = crc32fast::Hasher::new();
152 h.update(&ctype);
153 h.update(data);
154 if h.finalize() != crc {
155 bail!("Cart data is corrupted (bad checksum)");
156 }
157 payload = Some(data.to_vec());
158 }
159 if &ctype == b"IEND" {
160 break;
161 }
162 rest = &rest[12 + len..];
163 }
164 let payload = payload.ok_or_else(|| anyhow!("PNG has no Pixel8 cart data"))?;
165
166 let body = payload
167 .strip_prefix(CART_MAGIC.as_slice())
168 .ok_or_else(|| anyhow!("Bad cart magic"))?;
169 if body.len() < 2 {
170 bail!("Truncated cart header");
171 }
172 let version = u16::from_le_bytes(body[0..2].try_into().unwrap());
173 if version != CART_VERSION {
174 bail!("Cart format version {version} is not supported (this is version {CART_VERSION})");
175 }
176 let raw = miniz_oxide::inflate::decompress_to_vec_with_limit(&body[2..], MAX_PAYLOAD)
177 .map_err(|e| anyhow!("Cart data is corrupted: {e}"))?;
178 let cart: Cart = serde_json::from_slice(&raw)?;
179 validate(&cart)?;
180 Ok(cart)
181}
182
183fn validate(cart: &Cart) -> Result<()> {
185 if !cart.wasm.starts_with(b"\0asm") {
186 bail!("Cart payload is not a wasm module");
187 }
188 if cart.wasm.len() > MAX_WASM_SIZE {
189 bail!(
190 "Cart wasm is {} bytes; the limit is {} (128K)",
191 cart.wasm.len(),
192 MAX_WASM_SIZE
193 );
194 }
195 crate::assets::validate(&cart.assets)?;
196 Ok(())
197}
198
199fn write_chunk(out: &mut Vec<u8>, ctype: [u8; 4], data: &[u8]) {
200 out.extend((data.len() as u32).to_be_bytes());
201 out.extend(ctype);
202 out.extend_from_slice(data);
203 let mut h = crc32fast::Hasher::new();
204 h.update(&ctype);
205 h.update(data);
206 out.extend(h.finalize().to_be_bytes());
207}
208
209pub fn encode_screen_png(fb: &crate::fb::Framebuffer, scale: usize) -> Vec<u8> {
212 let scale = scale.max(1);
213 let (w, h) = (128 * scale, 128 * scale);
214 let mut png = PNG_SIG.to_vec();
215 let mut ihdr = Vec::with_capacity(13);
216 ihdr.extend((w as u32).to_be_bytes());
217 ihdr.extend((h as u32).to_be_bytes());
218 ihdr.extend([8, 6, 0, 0, 0]);
219 write_chunk(&mut png, *b"IHDR", &ihdr);
220
221 let pixels = fb.pixels();
222 let mut raw = Vec::with_capacity(h * (1 + w * 4));
223 for y in 0..h {
224 raw.push(0);
225 for x in 0..w {
226 let c = pixels[(y / scale) * 128 + x / scale];
227 raw.extend(palette::rgba(c));
228 }
229 }
230 write_chunk(
231 &mut png,
232 *b"IDAT",
233 &miniz_oxide::deflate::compress_to_vec_zlib(&raw, 8),
234 );
235 write_chunk(&mut png, *b"IEND", &[]);
236 png
237}
238
239pub const LABEL_W: usize = 160;
245pub const LABEL_H: usize = 205;
246
247const TRANSPARENT: u8 = 0xff;
248
249struct Canvas {
251 px: Vec<u8>,
252}
253
254impl Canvas {
255 fn new() -> Self {
256 Self {
257 px: vec![TRANSPARENT; LABEL_W * LABEL_H],
258 }
259 }
260
261 fn set(&mut self, x: i32, y: i32, c: u8) {
262 if (0..LABEL_W as i32).contains(&x) && (0..LABEL_H as i32).contains(&y) {
263 self.px[y as usize * LABEL_W + x as usize] = c;
264 }
265 }
266
267 fn fill(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, c: u8) {
268 for y in y0..=y1 {
269 for x in x0..=x1 {
270 self.set(x, y, c);
271 }
272 }
273 }
274
275 fn text(&mut self, s: &str, x: i32, y: i32, c: u8, scale: i32) {
276 let mut cx = x;
277 for ch in s.chars() {
278 let rows = font::glyph(ch);
279 for (ry, row) in rows.iter().enumerate() {
280 for rx in 0..3 {
281 if row & (0b100 >> rx) != 0 {
282 self.fill(
283 cx + rx * scale,
284 y + ry as i32 * scale,
285 cx + rx * scale + scale - 1,
286 y + ry as i32 * scale + scale - 1,
287 c,
288 );
289 }
290 }
291 }
292 cx += font::GLYPH_W * scale;
293 }
294 }
295
296 fn text_centered(&mut self, s: &str, y: i32, c: u8, scale: i32) {
297 let w = font::text_width(s) * scale;
298 self.text(s, (LABEL_W as i32 - w) / 2, y, c, scale);
299 }
300}
301
302fn render_label(assets: &Assets) -> Vec<u8> {
304 let mut c = Canvas::new();
305 let (w, h) = (LABEL_W as i32, LABEL_H as i32);
306
307 c.fill(0, 0, w - 1, h - 1, palette::col::LIGHT_GREY);
309 for dy in 0..4 {
310 for dx in 0..4 {
311 if dx + dy < 4 {
312 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); }
317 }
318 }
319 c.fill(0, h - 6, w - 1, h - 1, palette::col::DARK_GREY);
321 c.fill(w - 3, 0, w - 1, h - 1, palette::col::DARK_GREY);
322
323 let stripe_cols = [
325 palette::col::RED,
326 palette::col::ORANGE,
327 palette::col::YELLOW,
328 palette::col::GREEN,
329 palette::col::BLUE,
330 palette::col::PINK,
331 ];
332 for (i, col) in stripe_cols.iter().enumerate() {
333 let seg = (w - 32) / stripe_cols.len() as i32;
334 c.fill(
335 16 + i as i32 * seg,
336 8,
337 16 + (i as i32 + 1) * seg - 1,
338 12,
339 *col,
340 );
341 }
342
343 let label_px = assets.label.clone().unwrap_or_else(default_label);
345 c.fill(14, 22, 14 + 131, 22 + 131, palette::col::BLACK);
346 for y in 0..128 {
347 for x in 0..128 {
348 c.set(16 + x, 24 + y, label_px[(y * 128 + x) as usize] & 0x0f);
349 }
350 }
351
352 let title = if assets.meta.name.is_empty() {
354 "untitled"
355 } else {
356 &assets.meta.name
357 };
358 c.text_centered(title, 162, palette::col::DARK_BLUE, 1);
359 if !assets.meta.author.is_empty() {
360 c.text_centered(
361 &format!("by {}", assets.meta.author),
362 172,
363 palette::col::DARK_GREY,
364 1,
365 );
366 }
367 c.text_centered("pixel8 cartridge", 190, palette::col::DARK_GREY, 1);
368
369 let mut rgba = vec![0u8; LABEL_W * LABEL_H * 4];
371 for (i, &p) in c.px.iter().enumerate() {
372 let out = &mut rgba[i * 4..i * 4 + 4];
373 if p == TRANSPARENT {
374 out.copy_from_slice(&[0, 0, 0, 0]);
375 } else {
376 out.copy_from_slice(&palette::rgba(p));
377 }
378 }
379 rgba
380}
381
382pub fn default_label() -> Vec<u8> {
384 let mut px = vec![palette::col::DARK_BLUE; 128 * 128];
385 for y in (4..128).step_by(8) {
387 for x in (4..128).step_by(8) {
388 px[y * 128 + x] = palette::col::DARK_PURPLE;
389 }
390 }
391 let mut set = |x: i32, y: i32, c: u8| {
393 if (0..128).contains(&x) && (0..128).contains(&y) {
394 px[y as usize * 128 + x as usize] = c;
395 }
396 };
397 let word = "pixel8";
398 let scale = 4;
399 let w = font::text_width(word) * scale;
400 let x0 = (128 - w) / 2 + scale / 2;
401 let y0 = 48;
402 for (i, ch) in word.chars().enumerate() {
403 let rows = font::glyph(ch);
404 let color = [8u8, 9, 10, 11, 12, 14][i % 6];
405 for (ry, row) in rows.iter().enumerate() {
406 for rx in 0..3i32 {
407 if row & (0b100 >> rx) != 0 {
408 for dy in 0..scale {
409 for dx in 0..scale {
410 set(
411 x0 + (i as i32 * font::GLYPH_W + rx) * scale + dx,
412 y0 + ry as i32 * scale + dy,
413 color,
414 );
415 }
416 }
417 }
418 }
419 }
420 }
421 px
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use crate::assets::Note;
428
429 #[test]
430 fn initial_memory_one_page() {
431 let wasm = wat::parse_str("(module (memory (export \"memory\") 1))").unwrap();
432 assert_eq!(initial_memory_bytes(&wasm), Some(65_536));
433 }
434
435 #[test]
436 fn initial_memory_two_pages() {
437 let wasm = wat::parse_str("(module (memory (export \"memory\") 2))").unwrap();
438 assert_eq!(initial_memory_bytes(&wasm), Some(131_072));
439 }
440
441 #[test]
442 fn initial_memory_three_pages() {
443 let wasm = wat::parse_str("(module (memory (export \"memory\") 3))").unwrap();
444 assert_eq!(initial_memory_bytes(&wasm), Some(196_608));
445 }
446
447 #[test]
448 fn initial_memory_no_memory_is_none() {
449 let wasm = wat::parse_str("(module)").unwrap();
450 assert_eq!(initial_memory_bytes(&wasm), None);
451 }
452
453 #[test]
454 fn initial_memory_non_memory_export_is_none() {
455 let wasm = wat::parse_str("(module (func (export \"f\")))").unwrap();
456 assert_eq!(initial_memory_bytes(&wasm), None);
457 }
458
459 #[test]
460 fn initial_memory_invalid_bytes_is_none() {
461 assert_eq!(initial_memory_bytes(&[0, 1, 2, 3]), None);
462 }
463
464 fn test_cart() -> Cart {
465 let mut assets = Assets::default();
466 assets.meta.name = "roundtrip".into();
467 assets.meta.author = "tester".into();
468 assets.sprites.set(5, 5, 14);
469 assets.map.set(2, 3, 7);
470 assets.sfx[1].notes[0] = Note {
471 pitch: 40,
472 wave: 2,
473 volume: 6,
474 effect: 1,
475 };
476 Cart {
477 wasm: b"\0asm\x01\0\0\0".to_vec(),
478 assets,
479 source: Some("fn main() {}".into()),
480 }
481 }
482
483 #[test]
484 fn png_roundtrip() {
485 let cart = test_cart();
486 let png = encode(&cart).unwrap();
487 assert!(png.starts_with(&PNG_SIG), "output is a real png");
488 let back = decode(&png).unwrap();
489 assert_eq!(back.wasm, cart.wasm);
490 assert_eq!(back.assets.meta.name, "roundtrip");
491 assert_eq!(back.assets.sprites.get(5, 5), 14);
492 assert_eq!(back.assets.map.get(2, 3), 7);
493 assert_eq!(back.assets.sfx[1].notes[0].pitch, 40);
494 assert_eq!(back.source.as_deref(), Some("fn main() {}"));
495 }
496
497 #[test]
498 fn playable_cart_has_no_source() {
499 let mut cart = test_cart();
500 cart.source = None;
501 let back = decode(&encode(&cart).unwrap()).unwrap();
502 assert!(back.source.is_none());
503 }
504
505 #[test]
506 fn corrupted_payload_is_rejected() {
507 let cart = test_cart();
508 let mut png = encode(&cart).unwrap();
509 let pos = png
511 .windows(4)
512 .position(|w| w == CHUNK_TYPE)
513 .expect("chunk present")
514 + 20;
515 png[pos] ^= 0xff;
516 let err = decode(&png).map(|_| ()).unwrap_err().to_string();
517 assert!(err.contains("corrupted"), "{err}");
518 }
519
520 #[test]
521 fn non_cart_png_is_rejected() {
522 let mut png = PNG_SIG.to_vec();
523 write_chunk(&mut png, *b"IHDR", &[0; 13]);
524 write_chunk(&mut png, *b"IEND", &[]);
525 let err = decode(&png).map(|_| ()).unwrap_err().to_string();
526 assert!(err.contains("no Pixel8 cart data"), "{err}");
527 }
528
529 #[test]
530 fn bad_wasm_is_rejected() {
531 let mut cart = test_cart();
532 cart.wasm = b"not wasm".to_vec();
533 assert!(encode(&cart).is_err());
534 }
535
536 #[test]
537 fn oversized_wasm_is_rejected() {
538 let mut cart = test_cart();
539 cart.wasm = b"\0asm\x01\0\0\0".to_vec();
540 cart.wasm.resize(MAX_WASM_SIZE + 1, 0);
541 assert!(
542 encode(&cart).is_err(),
543 "wasm over 128K must be rejected at export"
544 );
545 }
546
547 #[test]
548 fn max_size_wasm_is_accepted() {
549 let mut cart = test_cart();
550 cart.wasm = b"\0asm\x01\0\0\0".to_vec();
551 cart.wasm.resize(MAX_WASM_SIZE, 0);
552 assert!(
553 encode(&cart).is_ok(),
554 "wasm at exactly 128K must be accepted"
555 );
556 }
557
558 #[test]
559 fn validate_rejects_oversized_wasm() {
560 let mut cart = test_cart();
561 cart.wasm = b"\0asm\x01\0\0\0".to_vec();
562 cart.wasm.resize(MAX_WASM_SIZE + 1, 0);
563 let err = validate(&cart).unwrap_err().to_string();
564 assert!(
565 err.contains("the limit is"),
566 "expected size-gate message, got: {err}"
567 );
568 }
569
570 #[test]
571 fn future_version_is_rejected() {
572 let cart = test_cart();
573 let mut png = encode(&cart).unwrap();
574 let pos = png
576 .windows(10)
577 .position(|w| w[0..4] == CHUNK_TYPE && &w[4..10] == CART_MAGIC)
578 .unwrap();
579 let vpos = pos + 10;
580 png[vpos] = 0xfe;
581 assert!(decode(&png).is_err());
584 }
585}