Skip to main content

pixel8_runtime/
assets.rs

1//! Shared asset data models: sprite sheet, map, SFX, music, metadata.
2//!
3//! These are the in-memory structures every part of Pixel8 agrees on —
4//! the editors mutate them, the runtime draws/plays from them, and the
5//! cartridge format serializes them. Sizes are fixed on purpose: the
6//! constraints are part of the console's identity.
7
8use anyhow::{bail, Result};
9use serde::{Deserialize, Serialize};
10
11/// Side length of one sprite in pixels.
12pub const SPRITE_SIZE: usize = 8;
13/// The sprite sheet is 128x128 pixels: 16x16 sprites = 256 sprites.
14pub const SHEET_W: usize = 128;
15pub const SHEET_H: usize = 128;
16pub const SPRITES_PER_ROW: usize = SHEET_W / SPRITE_SIZE;
17pub const SPRITE_COUNT: usize = 256;
18
19/// Map dimensions in tiles.
20pub const MAP_W: usize = 128;
21pub const MAP_H: usize = 64;
22
23/// Number of SFX slots and music patterns.
24pub const SFX_COUNT: usize = 64;
25pub const MUSIC_COUNT: usize = 64;
26/// Notes per SFX.
27pub const SFX_LEN: usize = 32;
28/// Audio channels.
29pub const CHANNELS: usize = 4;
30
31/// Typed handle for a sprite on the sheet (`0..256`).
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
33pub struct SpriteId(pub u8);
34
35/// Typed handle for an SFX slot (`0..64`).
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub struct SfxId(pub u8);
38
39/// Typed handle for a music pattern (`0..64`).
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
41pub struct MusicId(pub u8);
42
43/// 128x128 indexed-color sprite sheet plus one flag byte per sprite.
44#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct SpriteSheet {
46    /// One palette index per pixel, row-major, `SHEET_W * SHEET_H` long.
47    #[serde(with = "crate::wire::pixel_rows")]
48    pub pixels: Vec<u8>,
49    /// Eight user flags per sprite, used for map layers and game logic.
50    #[serde(with = "crate::wire::hex_string")]
51    pub flags: Vec<u8>,
52}
53
54impl Default for SpriteSheet {
55    fn default() -> Self {
56        Self {
57            pixels: vec![0; SHEET_W * SHEET_H],
58            flags: vec![0; SPRITE_COUNT],
59        }
60    }
61}
62
63impl SpriteSheet {
64    /// Read a pixel from sheet coordinates. Out of bounds returns 0.
65    pub fn get(&self, x: i32, y: i32) -> u8 {
66        if (0..SHEET_W as i32).contains(&x) && (0..SHEET_H as i32).contains(&y) {
67            self.pixels[(y as usize) * SHEET_W + x as usize]
68        } else {
69            0
70        }
71    }
72
73    /// Write a pixel at sheet coordinates. Out of bounds is ignored.
74    pub fn set(&mut self, x: i32, y: i32, color: u8) {
75        if (0..SHEET_W as i32).contains(&x) && (0..SHEET_H as i32).contains(&y) {
76            self.pixels[(y as usize) * SHEET_W + x as usize] = color & 0x0f;
77        }
78    }
79
80    /// Read pixel `(px, py)` of sprite `n`, where `px`/`py` may run past 8
81    /// to read neighboring sprites (used by multi-sprite `spr` calls).
82    pub fn sprite_pixel(&self, n: u32, px: i32, py: i32) -> u8 {
83        let n = (n as usize) % SPRITE_COUNT;
84        let sx = (n % SPRITES_PER_ROW * SPRITE_SIZE) as i32 + px;
85        let sy = (n / SPRITES_PER_ROW * SPRITE_SIZE) as i32 + py;
86        self.get(sx, sy)
87    }
88
89    /// All eight flags of sprite `n` as a bitmask.
90    pub fn flags(&self, n: u32) -> u8 {
91        self.flags[(n as usize) % SPRITE_COUNT]
92    }
93
94    /// Set or clear one flag (`0..8`) of sprite `n`.
95    pub fn set_flag(&mut self, n: u32, flag: u8, value: bool) {
96        let f = &mut self.flags[(n as usize) % SPRITE_COUNT];
97        if value {
98            *f |= 1 << (flag & 7);
99        } else {
100            *f &= !(1 << (flag & 7));
101        }
102    }
103}
104
105/// 128x64 tile map; each cell holds a sprite number (0 = empty).
106#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct MapData {
108    #[serde(with = "crate::wire::tile_rows")]
109    pub tiles: Vec<u8>,
110}
111
112impl Default for MapData {
113    fn default() -> Self {
114        Self {
115            tiles: vec![0; MAP_W * MAP_H],
116        }
117    }
118}
119
120impl MapData {
121    pub fn get(&self, x: i32, y: i32) -> u8 {
122        if (0..MAP_W as i32).contains(&x) && (0..MAP_H as i32).contains(&y) {
123            self.tiles[(y as usize) * MAP_W + x as usize]
124        } else {
125            0
126        }
127    }
128
129    pub fn set(&mut self, x: i32, y: i32, tile: u8) {
130        if (0..MAP_W as i32).contains(&x) && (0..MAP_H as i32).contains(&y) {
131            self.tiles[(y as usize) * MAP_W + x as usize] = tile;
132        }
133    }
134}
135
136/// Waveforms available to the synthesizer, in classic tracker spirit.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
138#[repr(u8)]
139pub enum Waveform {
140    Triangle = 0,
141    TiltedSaw = 1,
142    Saw = 2,
143    Square = 3,
144    Pulse = 4,
145    Organ = 5,
146    Noise = 6,
147    Phaser = 7,
148}
149
150impl Waveform {
151    pub fn from_u8(v: u8) -> Self {
152        match v & 7 {
153            0 => Self::Triangle,
154            1 => Self::TiltedSaw,
155            2 => Self::Saw,
156            3 => Self::Square,
157            4 => Self::Pulse,
158            5 => Self::Organ,
159            6 => Self::Noise,
160            _ => Self::Phaser,
161        }
162    }
163}
164
165/// Per-step note effects.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[repr(u8)]
168pub enum SfxEffect {
169    None = 0,
170    Slide = 1,
171    Vibrato = 2,
172    Drop = 3,
173    FadeIn = 4,
174    FadeOut = 5,
175    ArpFast = 6,
176    ArpSlow = 7,
177}
178
179impl SfxEffect {
180    pub fn from_u8(v: u8) -> Self {
181        match v & 7 {
182            0 => Self::None,
183            1 => Self::Slide,
184            2 => Self::Vibrato,
185            3 => Self::Drop,
186            4 => Self::FadeIn,
187            5 => Self::FadeOut,
188            6 => Self::ArpFast,
189            _ => Self::ArpSlow,
190        }
191    }
192}
193
194/// One step of an SFX: pitch (0..64, where 33 = A-4 = 440 Hz), waveform,
195/// volume (0..8, 0 = silent) and effect.
196#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(into = "[u8; 4]", from = "[u8; 4]")]
198pub struct Note {
199    pub pitch: u8,
200    /// Timbre, packed like PICO-8's SFX waveform nibble: bits 0-2 are the
201    /// index and bit 3 is the *custom-instrument* flag. With the flag clear,
202    /// the index (0..8) picks a built-in [`Waveform`]; with it set, the index
203    /// names another SFX slot (0..8) used as a custom instrument. Use
204    /// [`Note::instrument`] / [`Note::wave_index`] rather than reading the
205    /// raw bits.
206    pub wave: u8,
207    pub volume: u8,
208    pub effect: u8,
209}
210
211/// Bit 3 of [`Note::wave`]: set when the note plays another SFX as a custom
212/// instrument instead of a built-in waveform.
213pub const NOTE_CUSTOM_FLAG: u8 = 0x08;
214
215impl Note {
216    /// The waveform/instrument index (0..8), with the custom-instrument flag
217    /// stripped off.
218    pub fn wave_index(&self) -> u8 {
219        self.wave & 7
220    }
221
222    /// `Some(slot)` when this note plays SFX `slot` (0..8) as a custom
223    /// instrument; `None` when it uses a built-in waveform.
224    pub fn instrument(&self) -> Option<u8> {
225        (self.wave & NOTE_CUSTOM_FLAG != 0).then_some(self.wave & 7)
226    }
227}
228
229impl From<Note> for [u8; 4] {
230    fn from(n: Note) -> Self {
231        [n.pitch, n.wave, n.volume, n.effect]
232    }
233}
234
235impl From<[u8; 4]> for Note {
236    fn from([pitch, wave, volume, effect]: [u8; 4]) -> Self {
237        Self {
238            pitch,
239            wave,
240            volume,
241            effect,
242        }
243    }
244}
245
246/// A drawn custom-waveform instrument occupying SFX slots `0..8`. When present,
247/// the slot is used as an instrument timbre (one signed sample per step) by
248/// notes that reference it, rather than as a sequence of notes.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250pub struct CustomWave {
251    /// One signed sample per step; the editor draws values in `-16..=15`.
252    pub samples: [i8; SFX_LEN],
253    /// Pitch the waveform an octave down (PICO-8's "bass" toggle).
254    pub bass: bool,
255}
256
257/// One sound effect: 32 steps played at a configurable speed.
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259pub struct Sfx {
260    pub notes: [Note; SFX_LEN],
261    /// Duration of one step in 1/128ths of a second (1..=255).
262    pub speed: u8,
263    /// Loop start step. Looping is active when `loop_end > loop_start`.
264    pub loop_start: u8,
265    /// Loop end step (exclusive).
266    pub loop_end: u8,
267    /// Per-SFX filter switches, matching PICO-8's: replace the noise voice
268    /// with pure white noise.
269    pub noiz: bool,
270    /// Buzzier, harmonically richer timbre.
271    pub buzz: bool,
272    /// Detune a second voice against the first. `0` off; `1` a slight,
273    /// flange-like detune; `2` an octave-ish second voice.
274    pub detune: u8,
275    /// Echo with a short delay. `0` off; `1`/`2` are the two delay lengths.
276    pub reverb: u8,
277    /// Low-pass softening. `0` off; `1`/`2` are the two strengths.
278    pub dampen: u8,
279    /// `Some` only for slots `0..8` that are drawn-waveform instruments.
280    #[serde(default)]
281    pub custom_wave: Option<CustomWave>,
282}
283
284impl Default for Sfx {
285    fn default() -> Self {
286        Self {
287            notes: [Note::default(); SFX_LEN],
288            speed: 16,
289            loop_start: 0,
290            loop_end: 0,
291            noiz: false,
292            buzz: false,
293            detune: 0,
294            reverb: 0,
295            dampen: 0,
296            custom_wave: None,
297        }
298    }
299}
300
301impl Sfx {
302    /// True when no step is audible — used to skip empty slots.
303    pub fn is_empty(&self) -> bool {
304        self.notes.iter().all(|n| n.volume == 0)
305    }
306
307    /// Set the filter switches from PICO-8's packed filter byte (the 65th
308    /// byte of an on-cart SFX): bit 1 noiz, bit 2 buzz, then base-3 digits
309    /// for detune (÷8), reverb (÷24) and dampen (÷72). Bit 0 is PICO-8's
310    /// editor mode and carries no sound, so it is ignored.
311    pub fn set_filters(&mut self, byte: u8) {
312        self.noiz = byte & 2 != 0;
313        self.buzz = byte & 4 != 0;
314        self.detune = byte / 8 % 3;
315        self.reverb = byte / 24 % 3;
316        self.dampen = byte / 72 % 3;
317    }
318
319    /// PICO-8's packed filter byte — the inverse of [`Sfx::set_filters`]: bit 1
320    /// noiz, bit 2 buzz, then base-3 digits for detune (x8), reverb (x24) and
321    /// dampen (x72). Bit 0 (PICO-8's editor mode) is always left clear.
322    pub fn filters_byte(&self) -> u8 {
323        let mut byte = 0u8;
324        if self.noiz {
325            byte |= 2;
326        }
327        if self.buzz {
328            byte |= 4;
329        }
330        byte += self.detune * 8;
331        byte += self.reverb * 24;
332        byte += self.dampen * 72;
333        byte
334    }
335}
336
337/// One music pattern: an SFX slot per channel, plus flow control flags.
338/// A song is a chain of patterns; playback walks forward from the started
339/// pattern until it hits `stop_at_end` or loops back.
340#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
341pub struct MusicPattern {
342    /// SFX index per channel; `None` leaves the channel free for game SFX.
343    pub channels: [Option<u8>; CHANNELS],
344    /// Jump back to the most recent `loop_start` pattern when this ends.
345    pub loop_back: bool,
346    /// Marks a loop target for `loop_back`.
347    pub loop_start: bool,
348    /// Stop the song after this pattern.
349    pub stop_at_end: bool,
350}
351
352impl Default for MusicPattern {
353    fn default() -> Self {
354        Self {
355            channels: [None; CHANNELS],
356            loop_back: false,
357            loop_start: false,
358            stop_at_end: false,
359        }
360    }
361}
362
363impl MusicPattern {
364    pub fn is_empty(&self) -> bool {
365        self.channels.iter().all(|c| c.is_none())
366    }
367}
368
369/// Cart metadata shown on the label and in the console.
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct Metadata {
372    pub name: String,
373    pub author: String,
374    pub version: String,
375}
376
377impl Default for Metadata {
378    fn default() -> Self {
379        Self {
380            name: "untitled".into(),
381            author: String::new(),
382            version: "0.1.0".into(),
383        }
384    }
385}
386
387/// Everything a cart owns besides code: the complete asset bundle.
388#[derive(Clone, Serialize, Deserialize)]
389pub struct Assets {
390    pub meta: Metadata,
391    pub sprites: SpriteSheet,
392    pub map: MapData,
393    pub sfx: Vec<Sfx>,
394    pub music: Vec<MusicPattern>,
395    /// Optional 128x128 indexed-color label image (cart screenshot).
396    #[serde(with = "crate::wire::pixel_rows_opt", default)]
397    pub label: Option<Vec<u8>>,
398}
399
400impl Default for Assets {
401    fn default() -> Self {
402        Self {
403            meta: Metadata::default(),
404            sprites: SpriteSheet::default(),
405            map: MapData::default(),
406            sfx: vec![Sfx::default(); SFX_COUNT],
407            music: vec![MusicPattern::default(); MUSIC_COUNT],
408            label: None,
409        }
410    }
411}
412
413/// Check that a bundle carries exactly the fixed-size collections Pixel8
414/// requires. The editors only ever build correctly-sized bundles, but a
415/// corrupted or hand-edited `assets.pixel8.json` (or cart) can deserialize with
416/// mismatched lengths; running such a bundle would panic the renderer on
417/// an out-of-bounds sprite, map or label read. Every loader validates here
418/// so a bad bundle fails with a clear message instead of crashing.
419pub fn validate(assets: &Assets) -> Result<()> {
420    if assets.sprites.pixels.len() != SHEET_W * SHEET_H
421        || assets.sprites.flags.len() != SPRITE_COUNT
422        || assets.map.tiles.len() != MAP_W * MAP_H
423        || assets.sfx.len() != SFX_COUNT
424        || assets.music.len() != MUSIC_COUNT
425    {
426        bail!("Cart assets have invalid dimensions");
427    }
428    if let Some(label) = &assets.label {
429        if label.len() != SHEET_W * SHEET_H {
430            bail!("Cart label has invalid dimensions");
431        }
432    }
433    Ok(())
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    #[test]
441    fn validate_accepts_default_and_rejects_bad_dimensions() {
442        assert!(validate(&Assets::default()).is_ok());
443
444        let mut a = Assets::default();
445        a.sprites.pixels.truncate(10);
446        assert!(validate(&a).is_err(), "short sprite sheet must be rejected");
447
448        let mut a = Assets::default();
449        a.sfx.pop();
450        assert!(validate(&a).is_err(), "missing sfx slot must be rejected");
451
452        let a = Assets {
453            label: Some(vec![0; 8]),
454            ..Default::default()
455        };
456        assert!(validate(&a).is_err(), "wrong-size label must be rejected");
457
458        let a = Assets {
459            label: Some(vec![0; SHEET_W * SHEET_H]),
460            ..Default::default()
461        };
462        assert!(validate(&a).is_ok(), "correctly-sized label is allowed");
463    }
464
465    #[test]
466    fn assets_json_roundtrip() {
467        let mut a = Assets::default();
468        a.meta.name = "test cart".into();
469        a.sprites.set(3, 4, 9);
470        a.sprites.set_flag(1, 2, true);
471        a.map.set(10, 5, 42);
472        a.sfx[0].notes[0] = Note {
473            pitch: 33,
474            wave: 3,
475            volume: 5,
476            effect: 0,
477        };
478        a.music[0].channels[0] = Some(0);
479
480        let bytes = serde_json::to_vec(&a).unwrap();
481        let b: Assets = serde_json::from_slice(&bytes).unwrap();
482        assert_eq!(b.meta.name, "test cart");
483        assert_eq!(b.sprites.get(3, 4), 9);
484        assert_eq!(b.sprites.flags(1), 0b100);
485        assert_eq!(b.map.get(10, 5), 42);
486        assert_eq!(b.sfx[0].notes[0].pitch, 33);
487        assert_eq!(b.music[0].channels[0], Some(0));
488    }
489
490    #[test]
491    fn note_custom_instrument_flag() {
492        let builtin = Note {
493            wave: 3,
494            ..Default::default()
495        };
496        assert_eq!(builtin.wave_index(), 3);
497        assert_eq!(builtin.instrument(), None);
498
499        let custom = Note {
500            wave: NOTE_CUSTOM_FLAG | 2,
501            ..Default::default()
502        };
503        assert_eq!(custom.wave_index(), 2);
504        assert_eq!(custom.instrument(), Some(2));
505    }
506
507    #[test]
508    fn sfx_filter_byte_decodes() {
509        let mut s = Sfx::default();
510        // 0x86 = 2(noiz) + 4(buzz) + 8(detune 1) + 48(reverb 2) + 64(dampen ?)
511        // -> detune 1, reverb 2, dampen 1; bit 0 (editor mode) ignored.
512        s.set_filters(0x86);
513        assert!(s.noiz && s.buzz);
514        assert_eq!((s.detune, s.reverb, s.dampen), (1, 2, 1));
515
516        let mut off = Sfx::default();
517        off.set_filters(0x01); // only editor-mode bit -> no audible switches
518        assert!(!off.noiz && !off.buzz);
519        assert_eq!((off.detune, off.reverb, off.dampen), (0, 0, 0));
520    }
521
522    #[test]
523    fn sprite_pixel_addresses_sheet() {
524        let mut s = SpriteSheet::default();
525        // Sprite 17 sits at sheet position (8, 8).
526        s.set(8, 8, 12);
527        assert_eq!(s.sprite_pixel(17, 0, 0), 12);
528        assert_eq!(s.sprite_pixel(16, 8, 0), 12);
529    }
530
531    #[test]
532    fn custom_wave_roundtrips_and_defaults_none() {
533        // A fresh SFX has no custom waveform.
534        assert!(Sfx::default().custom_wave.is_none());
535
536        let mut a = Assets::default();
537        a.sfx[0].custom_wave = Some(CustomWave {
538            samples: [3; SFX_LEN],
539            bass: true,
540        });
541        let bytes = serde_json::to_vec(&a).unwrap();
542        let b: Assets = serde_json::from_slice(&bytes).unwrap();
543        let w = b.sfx[0].custom_wave.as_ref().expect("wave kept");
544        assert_eq!(w.samples[0], 3);
545        assert!(w.bass);
546        assert!(b.sfx[1].custom_wave.is_none());
547    }
548}