Skip to main content

pixel8_runtime/
project.rs

1//! On-disk project layout used while developing a cart.
2//!
3//! A Pixel8 project is a real Cargo crate plus an asset bundle:
4//!
5//! ```text
6//! mygame/
7//!   Cargo.toml      # generated by `new`, builds a cdylib for wasm32
8//!   src/lib.rs      # the game code edited inside Pixel8 (or outside!)
9//!   assets.pixel8.json  # sprites/map/sfx/music/metadata (JSON, versioned)
10//!   target/         # cargo build output
11//! ```
12//!
13//! Keeping the project a normal crate is what makes the external-editor
14//! workflow work: `cargo build --target wasm32-unknown-unknown` from a
15//! terminal produces exactly what the in-console `run` uses.
16
17use crate::assets::Assets;
18use anyhow::{anyhow, bail, Context, Result};
19use std::{
20    fs,
21    path::{Path, PathBuf},
22};
23
24/// `assets.pixel8.json` format version, so a future (post-release) format change
25/// can reject older files with a clear message instead of mis-parsing them.
26/// Until a cart format ships, format changes just regenerate the example assets
27/// and leave this at 1.
28const ASSETS_VERSION: u32 = 1;
29
30/// Version requirement for the SDK dependency of a project created by `new`.
31///
32/// Every crate in the workspace inherits one version, so this crate's major.minor is also the
33/// published SDK's. Pinning major.minor (not the patch) lets a project pick up SDK patch releases
34/// with a plain `cargo update`.
35const SDK_VERSION_REQ: &str = concat!(
36    env!("CARGO_PKG_VERSION_MAJOR"),
37    ".",
38    env!("CARGO_PKG_VERSION_MINOR")
39);
40
41/// Default game source created by `new`.
42pub const TEMPLATE_CODE: &str = r#"#![no_std]
43
44use pixel8::*;
45
46game!(MyGame { x: 60, y: 70 });
47
48struct MyGame {
49    x: i16,
50    y: i16,
51}
52
53impl Game for MyGame {
54    fn update(&mut self, ctx: &mut Context) {
55        if ctx.btn(Button::Left) { self.x -= 1; }
56        if ctx.btn(Button::Right) { self.x += 1; }
57        if ctx.btn(Button::Up) { self.y -= 1; }
58        if ctx.btn(Button::Down) { self.y += 1; }
59    }
60
61    fn draw(&self, gfx: &mut Graphics) {
62        gfx.clear(Color::DARK_BLUE);
63        gfx.print("Hello, Pixel8!", 36, 48, Color::WHITE);
64        gfx.rect_fill(self.x, self.y, 8, 8, Color::PINK).unwrap();
65    }
66}
67"#;
68
69/// A loaded project: code + assets + where they live.
70pub struct Project {
71    pub dir: PathBuf,
72    /// Crate name (also the wasm artifact name, with `-` mapped to `_`).
73    pub name: String,
74    pub code: String,
75    /// Path (relative to `src/`) of the file currently held in `code`.
76    pub current: String,
77    pub assets: Assets,
78}
79
80impl Project {
81    /// Create a fresh project directory with template code and empty assets.
82    pub fn create(dir: &Path, name: &str) -> Result<Self> {
83        let name = sanitize_name(name)?;
84        if dir.exists()
85            && dir
86                .read_dir()
87                .map(|mut d| d.next().is_some())
88                .unwrap_or(true)
89        {
90            bail!(
91                "directory {} already exists and is not empty",
92                dir.display()
93            );
94        }
95        fs::create_dir_all(dir.join("src"))?;
96        fs::write(
97            dir.join("Cargo.toml"),
98            format!(
99                r#"[package]
100name = "{name}"
101version = "0.1.0"
102edition = "2021"
103
104[lib]
105crate-type = ["cdylib"]
106
107[dependencies]
108pixel8 = {{ version = "{SDK_VERSION_REQ}", default-features = false }}
109
110# Standalone workspace so the project builds anywhere, independent of the
111# Pixel8 source tree.
112[workspace]
113
114[profile.release]
115opt-level = "s"
116lto = true
117panic = "abort"
118"#
119            ),
120        )?;
121        fs::write(dir.join("src/lib.rs"), TEMPLATE_CODE)?;
122        fs::write(dir.join(".gitignore"), "/target\n")?;
123        fs::create_dir_all(dir.join(".cargo"))?;
124        fs::write(
125            dir.join(".cargo/config.toml"),
126            // Default to the wasm target so plain `cargo build`/`check`/`test`
127            // just work: a `#![no_std]` cart can't build for the host (its
128            // unwinding panic strategy isn't supported without std), and wasm32
129            // is what carts compile to anyway. The console still passes
130            // `--target wasm32-unknown-unknown` explicitly, which matches.
131            //
132            // The rustflags set the shadow-stack reserve to 32 KiB (32768
133            // bytes), the cart's own default. This is tunable: edit
134            // stack-size here to give the cart more or less stack. Target-
135            // scoped so host tooling is unaffected. The console builds
136            // straight and honors whatever value is set here; a value large
137            // enough to push the cart's initial memory over the 128 K cap is
138            // reported as an error at build time.
139            "[build]\n\
140             target = \"wasm32-unknown-unknown\"\n\
141             \n\
142             [target.wasm32-unknown-unknown]\n\
143             rustflags = [\"-C\", \"link-arg=-z\", \"-C\", \"link-arg=stack-size=32768\"]\n",
144        )?;
145        let mut assets = Assets::default();
146        assets.meta.name = name.clone();
147        let project = Self {
148            dir: dir.to_path_buf(),
149            name,
150            code: TEMPLATE_CODE.to_string(),
151            current: "lib.rs".into(),
152            assets,
153        };
154        project.save()?;
155        Ok(project)
156    }
157
158    /// Load an existing project directory.
159    pub fn load(dir: &Path) -> Result<Self> {
160        let manifest = fs::read_to_string(dir.join("Cargo.toml")).with_context(|| {
161            format!("{} is not a Pixel8 project (no Cargo.toml)", dir.display())
162        })?;
163        let name = parse_crate_name(&manifest)
164            .ok_or_else(|| anyhow!("Could not find package name in Cargo.toml"))?;
165        let code = fs::read_to_string(dir.join("src/lib.rs")).unwrap_or_default();
166        let assets = match fs::read(dir.join("assets.pixel8.json")) {
167            Ok(bytes) => decode_assets(&bytes)?,
168            Err(_) => {
169                let mut a = Assets::default();
170                a.meta.name = name.clone();
171                a
172            }
173        };
174        Ok(Self {
175            dir: dir.to_path_buf(),
176            name,
177            code,
178            current: "lib.rs".into(),
179            assets,
180        })
181    }
182
183    /// Write the open file and assets back to disk.
184    pub fn save(&self) -> Result<()> {
185        fs::write(self.dir.join("src").join(&self.current), &self.code)?;
186        fs::write(
187            self.dir.join("assets.pixel8.json"),
188            encode_assets(&self.assets)?,
189        )?;
190        Ok(())
191    }
192
193    /// The `*.rs` files directly under `src/`, sorted with `lib.rs` first.
194    pub fn file_names(&self) -> Vec<String> {
195        let mut names: Vec<String> = fs::read_dir(self.dir.join("src"))
196            .into_iter()
197            .flatten()
198            .flatten()
199            .filter_map(|e| {
200                let path = e.path();
201                if path.extension().is_some_and(|x| x == "rs") {
202                    path.file_name().map(|n| n.to_string_lossy().into_owned())
203                } else {
204                    None
205                }
206            })
207            .collect();
208        names.sort();
209        if let Some(i) = names.iter().position(|n| n == "lib.rs") {
210            let lib = names.remove(i);
211            names.insert(0, lib);
212        }
213        names
214    }
215
216    /// Persist nothing here; load `src/<name>` into `code` and make it current.
217    pub fn switch_to(&mut self, name: &str) -> Result<()> {
218        let path = self.dir.join("src").join(name);
219        let code = fs::read_to_string(&path)
220            .with_context(|| format!("could not read {}", path.display()))?;
221        self.current = name.to_string();
222        self.code = code;
223        Ok(())
224    }
225
226    /// Create a new flat module under `src/`, wire it into `lib.rs`, and open it.
227    pub fn create_file(&mut self, name: &str) -> Result<String> {
228        let file = normalize_file_name(name)?;
229        let path = self.dir.join("src").join(&file);
230        if path.exists() {
231            bail!("{file} already exists");
232        }
233        // `normalize_file_name` guarantees the `.rs` suffix.
234        let stem = file.strip_suffix(".rs").unwrap();
235        // Wire the module into lib.rs, read fresh from disk so an unrelated open
236        // file's buffer cannot clobber external edits to lib.rs. The `mod` must
237        // go after any leading inner attributes (e.g. `#![no_std]`), which have
238        // to stay at the very top or the crate fails to compile.
239        let lib_path = self.dir.join("src/lib.rs");
240        let mut lib = fs::read_to_string(&lib_path).unwrap_or_default();
241        lib.insert_str(module_insert_offset(&lib), &format!("mod {stem};\n"));
242        fs::write(&lib_path, &lib)?;
243        fs::write(&path, "")?;
244        self.current = file.clone();
245        self.code = String::new();
246        Ok(file)
247    }
248
249    /// The `lib.rs` source, regardless of which file is open. Used for the
250    /// source embedded in an exported cart.
251    pub fn lib_source(&self) -> String {
252        if self.current == "lib.rs" {
253            self.code.clone()
254        } else {
255            fs::read_to_string(self.dir.join("src/lib.rs")).unwrap_or_default()
256        }
257    }
258
259    /// Where `cargo build --release --target wasm32-unknown-unknown` puts
260    /// the cart wasm.
261    pub fn wasm_path(&self) -> PathBuf {
262        self.dir
263            .join("target/wasm32-unknown-unknown/release")
264            .join(format!("{}.wasm", self.name.replace('-', "_")))
265    }
266}
267
268/// Serialize assets as versioned, human-readable JSON.
269pub fn encode_assets(assets: &Assets) -> Result<Vec<u8>> {
270    let versioned = crate::wire::Versioned {
271        version: ASSETS_VERSION,
272        inner: assets,
273    };
274    Ok(crate::wire::to_readable_json(&versioned)?.into_bytes())
275}
276
277/// Parse an assets file, checking the format version.
278pub fn decode_assets(bytes: &[u8]) -> Result<Assets> {
279    let versioned: crate::wire::Versioned<Assets> = serde_json::from_slice(bytes)
280        .context("assets.pixel8.json is not valid Pixel8 asset JSON")?;
281    if versioned.version != ASSETS_VERSION {
282        bail!(
283            "assets.pixel8.json is format version {}, but this Pixel8 needs \
284             version {ASSETS_VERSION}; recreate or re-import the cart",
285            versioned.version
286        );
287    }
288    let assets = versioned.inner;
289    crate::assets::validate(&assets)?;
290    Ok(assets)
291}
292
293fn sanitize_name(name: &str) -> Result<String> {
294    let name: String = name
295        .chars()
296        .map(|c| {
297            if c == '-' {
298                '_'
299            } else {
300                c.to_ascii_lowercase()
301            }
302        })
303        .collect();
304    if name.is_empty()
305        || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
306        || name.starts_with(|c: char| c.is_ascii_digit())
307    {
308        bail!("project names must be [a-z_][a-z0-9_]*");
309    }
310    Ok(name)
311}
312
313/// Validate a new source-file name and return it with a `.rs` suffix. Flat
314/// module names only: `[a-z_][a-z0-9_]*`, optionally already suffixed `.rs`.
315fn normalize_file_name(name: &str) -> Result<String> {
316    let name = name.trim();
317    let stem = name.strip_suffix(".rs").unwrap_or(name);
318    if stem.is_empty()
319        || stem.contains(['/', '\\', '.'])
320        || !stem
321            .chars()
322            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
323        || stem.starts_with(|c: char| c.is_ascii_digit())
324    {
325        bail!("file name must be a module name, e.g. enemy or enemy.rs");
326    }
327    Ok(format!("{stem}.rs"))
328}
329
330/// Byte offset in `lib.rs` at which to insert a `mod` declaration: past any
331/// leading inner attributes (`#![...]`), inner doc comments and blank lines,
332/// which must precede every item or the crate fails to compile.
333fn module_insert_offset(lib: &str) -> usize {
334    let mut offset = 0;
335    for line in lib.split_inclusive('\n') {
336        let trimmed = line.trim_start();
337        if trimmed.starts_with("#![") || trimmed.starts_with("//") || trimmed.trim().is_empty() {
338            offset += line.len();
339        } else {
340            break;
341        }
342    }
343    offset
344}
345
346fn parse_crate_name(manifest: &str) -> Option<String> {
347    // Tiny TOML peek: the first `name = "..."` line in the file. Good
348    // enough for manifests Pixel8 generates and typical hand edits.
349    manifest.lines().find_map(|line| {
350        let line = line.trim();
351        let rest = line.strip_prefix("name")?.trim_start().strip_prefix('=')?;
352        let rest = rest.trim();
353        let rest = rest.strip_prefix('"')?;
354        let end = rest.find('"')?;
355        Some(rest[..end].to_string())
356    })
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::assets::Note;
363
364    #[test]
365    fn create_load_roundtrip() {
366        let dir = std::env::temp_dir().join(format!("pixel8_test_{}", std::process::id()));
367        let _ = fs::remove_dir_all(&dir);
368        let mut p = Project::create(&dir.join("mygame"), "MyGame").unwrap();
369        p.assets.sprites.set(0, 0, 8);
370        p.code = "// changed".into();
371        p.save().unwrap();
372
373        let q = Project::load(&dir.join("mygame")).unwrap();
374        assert_eq!(q.name, "mygame");
375        assert_eq!(q.code, "// changed");
376        assert_eq!(q.assets.sprites.get(0, 0), 8);
377        assert!(q
378            .wasm_path()
379            .ends_with("target/wasm32-unknown-unknown/release/mygame.wasm"));
380        fs::remove_dir_all(&dir).unwrap();
381    }
382
383    #[test]
384    fn bad_names_rejected() {
385        assert!(sanitize_name("8ball").is_err());
386        assert!(sanitize_name("").is_err());
387        assert!(sanitize_name("my game").is_err());
388        assert_eq!(sanitize_name("My-Game").unwrap(), "my_game");
389    }
390
391    #[test]
392    fn example_assets_load_in_the_current_format() {
393        // The committed example carts must stay loadable; this catches an
394        // assets-format change that forgets to regenerate them.
395        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples");
396        for dir in [
397            "sprite_move",
398            "platformer",
399            "sfx_demo",
400            "music_demo",
401            "stress",
402        ] {
403            let path = root.join(dir).join("assets.pixel8.json");
404            let bytes =
405                std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
406            decode_assets(&bytes).unwrap_or_else(|e| panic!("decode {}: {e}", path.display()));
407        }
408    }
409
410    #[test]
411    fn assets_version_is_checked() {
412        // Not JSON at all -> error.
413        assert!(decode_assets(b"NOTJSON").is_err());
414        // A complete, valid-shape bundle at a different version is rejected by
415        // the version check (not merely a parse error).
416        let v2 = serde_json::to_vec(&crate::wire::Versioned {
417            version: 2,
418            inner: &Assets::default(),
419        })
420        .unwrap();
421        assert!(decode_assets(&v2).is_err());
422        // A freshly encoded bundle carries the current version and round-trips.
423        let bytes = encode_assets(&Assets::default()).unwrap();
424        let text = String::from_utf8(bytes.clone()).unwrap();
425        assert!(text.contains("\"version\": 1"), "{text}");
426        assert!(decode_assets(&bytes).is_ok());
427    }
428
429    #[test]
430    fn encoded_assets_are_human_readable() {
431        let mut a = Assets::default();
432        a.sprites.set(0, 0, 0x0f);
433        a.map.set(0, 0, 0x2a);
434        a.sfx[0].notes[0] = Note {
435            pitch: 33,
436            wave: 3,
437            volume: 5,
438            effect: 0,
439        };
440        let json = String::from_utf8(encode_assets(&a).unwrap()).unwrap();
441        // Version envelope.
442        assert!(json.contains("\"version\": 1"), "{json}");
443        // Sprite sheet row 0 is a hex-nibble string beginning with the set pixel.
444        assert!(json.contains("\"f000"), "sprite row:\n{json}");
445        // Map row 0 is hex bytes; tile 0 is 0x2a.
446        assert!(json.contains("\"2a00"), "map row:\n{json}");
447        // Notes render as inline quads.
448        assert!(json.contains("[33,3,5,0]"), "notes:\n{json}");
449    }
450
451    #[test]
452    fn create_scaffolds_a_no_std_project() {
453        let dir = std::env::temp_dir().join(format!("pixel8_nostd_{}", std::process::id()));
454        let _ = fs::remove_dir_all(&dir);
455        Project::create(&dir.join("g"), "g").unwrap();
456        let lib = fs::read_to_string(dir.join("g/src/lib.rs")).unwrap();
457        let manifest = fs::read_to_string(dir.join("g/Cargo.toml")).unwrap();
458        assert!(lib.contains("#![no_std]"), "lib.rs:\n{lib}");
459        // The SDK comes from crates.io, pinned to the major.minor this crate was built at: every
460        // workspace crate shares one version.
461        let dep = format!(
462            r#"pixel8 = {{ version = "{}.{}", default-features = false }}"#,
463            env!("CARGO_PKG_VERSION_MAJOR"),
464            env!("CARGO_PKG_VERSION_MINOR"),
465        );
466        assert!(manifest.contains(&dep), "Cargo.toml:\n{manifest}");
467        fs::remove_dir_all(&dir).unwrap();
468    }
469
470    #[test]
471    fn create_writes_cargo_config_with_stack_size() {
472        let dir = std::env::temp_dir().join(format!("pixel8_cfg_{}", std::process::id()));
473        let _ = fs::remove_dir_all(&dir);
474        Project::create(&dir.join("g"), "g").unwrap();
475        let cfg = fs::read_to_string(dir.join("g/.cargo/config.toml")).unwrap();
476        assert!(cfg.contains("wasm32-unknown-unknown"), "config: {cfg}");
477        assert!(cfg.contains("stack-size=32768"), "config: {cfg}");
478        // Default build target so plain `cargo build`/`check` target wasm and a
479        // no_std cart doesn't fail with "unwinding panics are not supported".
480        assert!(
481            cfg.contains("[build]") && cfg.contains("target = \"wasm32-unknown-unknown\""),
482            "config: {cfg}"
483        );
484        fs::remove_dir_all(&dir).unwrap();
485    }
486
487    #[test]
488    fn lists_creates_and_switches_files() {
489        let dir = std::env::temp_dir().join(format!("pixel8_files_{}", std::process::id()));
490        let _ = fs::remove_dir_all(&dir);
491        let mut p = Project::create(&dir.join("g"), "g").unwrap();
492        assert_eq!(p.current, "lib.rs");
493        assert_eq!(p.file_names(), vec!["lib.rs".to_string()]);
494
495        // Create a new module: file on disk, `mod` wired into lib.rs, opened.
496        let new = p.create_file("enemy").unwrap();
497        assert_eq!(new, "enemy.rs");
498        assert_eq!(p.current, "enemy.rs");
499        assert_eq!(p.code, "");
500        assert!(dir.join("g/src/enemy.rs").exists());
501        let lib = fs::read_to_string(dir.join("g/src/lib.rs")).unwrap();
502        // The `#![no_std]` inner attribute must stay at the very top; the `mod`
503        // is wired in after it, or the crate would not compile.
504        assert!(lib.starts_with("#![no_std]"), "lib.rs:\n{lib}");
505        assert!(lib.contains("\nmod enemy;\n"), "lib.rs:\n{lib}");
506        assert!(
507            lib.find("#![no_std]") < lib.find("mod enemy;"),
508            "mod must come after the inner attribute:\n{lib}"
509        );
510        assert_eq!(p.lib_source(), lib);
511
512        // Listing shows both, lib.rs first.
513        assert_eq!(
514            p.file_names(),
515            vec!["lib.rs".to_string(), "enemy.rs".to_string()]
516        );
517
518        // Edit the new file, save, switch away and back.
519        p.code = "// enemy code\n".into();
520        p.save().unwrap();
521        p.switch_to("lib.rs").unwrap();
522        assert_eq!(p.current, "lib.rs");
523        assert!(p.code.starts_with("#![no_std]"));
524        assert!(p.code.contains("\nmod enemy;\n"));
525        p.switch_to("enemy.rs").unwrap();
526        assert_eq!(p.code, "// enemy code\n");
527
528        // Duplicate and invalid names are rejected.
529        assert!(p.create_file("enemy").is_err());
530        assert!(p.create_file("9bad").is_err());
531        assert!(p.create_file("a/b").is_err());
532        assert!(p.create_file("Enemy").is_err());
533        fs::remove_dir_all(&dir).unwrap();
534    }
535}