use crate::assets::Assets;
use anyhow::{anyhow, bail, Context, Result};
use std::{
fs,
path::{Path, PathBuf},
};
const ASSETS_VERSION: u32 = 1;
const SDK_VERSION_REQ: &str = concat!(
env!("CARGO_PKG_VERSION_MAJOR"),
".",
env!("CARGO_PKG_VERSION_MINOR")
);
pub const TEMPLATE_CODE: &str = r#"#![no_std]
use pixel8::*;
game!(MyGame { x: 60, y: 70 });
struct MyGame {
x: i16,
y: i16,
}
impl Game for MyGame {
fn update(&mut self, ctx: &mut Context) {
if ctx.btn(Button::Left) { self.x -= 1; }
if ctx.btn(Button::Right) { self.x += 1; }
if ctx.btn(Button::Up) { self.y -= 1; }
if ctx.btn(Button::Down) { self.y += 1; }
}
fn draw(&self, gfx: &mut Graphics) {
gfx.clear(Color::DARK_BLUE);
gfx.print("Hello, Pixel8!", 36, 48, Color::WHITE);
gfx.rect_fill(self.x, self.y, 8, 8, Color::PINK).unwrap();
}
}
"#;
pub struct Project {
pub dir: PathBuf,
pub name: String,
pub code: String,
pub current: String,
pub assets: Assets,
}
impl Project {
pub fn create(dir: &Path, name: &str) -> Result<Self> {
let name = sanitize_name(name)?;
if dir.exists()
&& dir
.read_dir()
.map(|mut d| d.next().is_some())
.unwrap_or(true)
{
bail!(
"directory {} already exists and is not empty",
dir.display()
);
}
fs::create_dir_all(dir.join("src"))?;
fs::write(
dir.join("Cargo.toml"),
format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
pixel8 = {{ version = "{SDK_VERSION_REQ}", default-features = false }}
# Standalone workspace so the project builds anywhere, independent of the
# Pixel8 source tree.
[workspace]
[profile.release]
opt-level = "s"
lto = true
panic = "abort"
"#
),
)?;
fs::write(dir.join("src/lib.rs"), TEMPLATE_CODE)?;
fs::write(dir.join(".gitignore"), "/target\n")?;
fs::create_dir_all(dir.join(".cargo"))?;
fs::write(
dir.join(".cargo/config.toml"),
"[build]\n\
target = \"wasm32-unknown-unknown\"\n\
\n\
[target.wasm32-unknown-unknown]\n\
rustflags = [\"-C\", \"link-arg=-z\", \"-C\", \"link-arg=stack-size=32768\"]\n",
)?;
let mut assets = Assets::default();
assets.meta.name = name.clone();
let project = Self {
dir: dir.to_path_buf(),
name,
code: TEMPLATE_CODE.to_string(),
current: "lib.rs".into(),
assets,
};
project.save()?;
Ok(project)
}
pub fn load(dir: &Path) -> Result<Self> {
let manifest = fs::read_to_string(dir.join("Cargo.toml")).with_context(|| {
format!("{} is not a Pixel8 project (no Cargo.toml)", dir.display())
})?;
let name = parse_crate_name(&manifest)
.ok_or_else(|| anyhow!("Could not find package name in Cargo.toml"))?;
let code = fs::read_to_string(dir.join("src/lib.rs")).unwrap_or_default();
let assets = match fs::read(dir.join("assets.pixel8.json")) {
Ok(bytes) => decode_assets(&bytes)?,
Err(_) => {
let mut a = Assets::default();
a.meta.name = name.clone();
a
}
};
Ok(Self {
dir: dir.to_path_buf(),
name,
code,
current: "lib.rs".into(),
assets,
})
}
pub fn save(&self) -> Result<()> {
fs::write(self.dir.join("src").join(&self.current), &self.code)?;
fs::write(
self.dir.join("assets.pixel8.json"),
encode_assets(&self.assets)?,
)?;
Ok(())
}
pub fn file_names(&self) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(self.dir.join("src"))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let path = e.path();
if path.extension().is_some_and(|x| x == "rs") {
path.file_name().map(|n| n.to_string_lossy().into_owned())
} else {
None
}
})
.collect();
names.sort();
if let Some(i) = names.iter().position(|n| n == "lib.rs") {
let lib = names.remove(i);
names.insert(0, lib);
}
names
}
pub fn switch_to(&mut self, name: &str) -> Result<()> {
let path = self.dir.join("src").join(name);
let code = fs::read_to_string(&path)
.with_context(|| format!("could not read {}", path.display()))?;
self.current = name.to_string();
self.code = code;
Ok(())
}
pub fn create_file(&mut self, name: &str) -> Result<String> {
let file = normalize_file_name(name)?;
let path = self.dir.join("src").join(&file);
if path.exists() {
bail!("{file} already exists");
}
let stem = file.strip_suffix(".rs").unwrap();
let lib_path = self.dir.join("src/lib.rs");
let mut lib = fs::read_to_string(&lib_path).unwrap_or_default();
lib.insert_str(module_insert_offset(&lib), &format!("mod {stem};\n"));
fs::write(&lib_path, &lib)?;
fs::write(&path, "")?;
self.current = file.clone();
self.code = String::new();
Ok(file)
}
pub fn lib_source(&self) -> String {
if self.current == "lib.rs" {
self.code.clone()
} else {
fs::read_to_string(self.dir.join("src/lib.rs")).unwrap_or_default()
}
}
pub fn wasm_path(&self) -> PathBuf {
self.dir
.join("target/wasm32-unknown-unknown/release")
.join(format!("{}.wasm", self.name.replace('-', "_")))
}
}
pub fn encode_assets(assets: &Assets) -> Result<Vec<u8>> {
let versioned = crate::wire::Versioned {
version: ASSETS_VERSION,
inner: assets,
};
Ok(crate::wire::to_readable_json(&versioned)?.into_bytes())
}
pub fn decode_assets(bytes: &[u8]) -> Result<Assets> {
let versioned: crate::wire::Versioned<Assets> = serde_json::from_slice(bytes)
.context("assets.pixel8.json is not valid Pixel8 asset JSON")?;
if versioned.version != ASSETS_VERSION {
bail!(
"assets.pixel8.json is format version {}, but this Pixel8 needs \
version {ASSETS_VERSION}; recreate or re-import the cart",
versioned.version
);
}
let assets = versioned.inner;
crate::assets::validate(&assets)?;
Ok(assets)
}
fn sanitize_name(name: &str) -> Result<String> {
let name: String = name
.chars()
.map(|c| {
if c == '-' {
'_'
} else {
c.to_ascii_lowercase()
}
})
.collect();
if name.is_empty()
|| !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|| name.starts_with(|c: char| c.is_ascii_digit())
{
bail!("project names must be [a-z_][a-z0-9_]*");
}
Ok(name)
}
fn normalize_file_name(name: &str) -> Result<String> {
let name = name.trim();
let stem = name.strip_suffix(".rs").unwrap_or(name);
if stem.is_empty()
|| stem.contains(['/', '\\', '.'])
|| !stem
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
|| stem.starts_with(|c: char| c.is_ascii_digit())
{
bail!("file name must be a module name, e.g. enemy or enemy.rs");
}
Ok(format!("{stem}.rs"))
}
fn module_insert_offset(lib: &str) -> usize {
let mut offset = 0;
for line in lib.split_inclusive('\n') {
let trimmed = line.trim_start();
if trimmed.starts_with("#![") || trimmed.starts_with("//") || trimmed.trim().is_empty() {
offset += line.len();
} else {
break;
}
}
offset
}
fn parse_crate_name(manifest: &str) -> Option<String> {
manifest.lines().find_map(|line| {
let line = line.trim();
let rest = line.strip_prefix("name")?.trim_start().strip_prefix('=')?;
let rest = rest.trim();
let rest = rest.strip_prefix('"')?;
let end = rest.find('"')?;
Some(rest[..end].to_string())
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assets::Note;
#[test]
fn create_load_roundtrip() {
let dir = std::env::temp_dir().join(format!("pixel8_test_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let mut p = Project::create(&dir.join("mygame"), "MyGame").unwrap();
p.assets.sprites.set(0, 0, 8);
p.code = "// changed".into();
p.save().unwrap();
let q = Project::load(&dir.join("mygame")).unwrap();
assert_eq!(q.name, "mygame");
assert_eq!(q.code, "// changed");
assert_eq!(q.assets.sprites.get(0, 0), 8);
assert!(q
.wasm_path()
.ends_with("target/wasm32-unknown-unknown/release/mygame.wasm"));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn bad_names_rejected() {
assert!(sanitize_name("8ball").is_err());
assert!(sanitize_name("").is_err());
assert!(sanitize_name("my game").is_err());
assert_eq!(sanitize_name("My-Game").unwrap(), "my_game");
}
#[test]
fn example_assets_load_in_the_current_format() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples");
for dir in [
"sprite_move",
"platformer",
"sfx_demo",
"music_demo",
"stress",
] {
let path = root.join(dir).join("assets.pixel8.json");
let bytes =
std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
decode_assets(&bytes).unwrap_or_else(|e| panic!("decode {}: {e}", path.display()));
}
}
#[test]
fn assets_version_is_checked() {
assert!(decode_assets(b"NOTJSON").is_err());
let v2 = serde_json::to_vec(&crate::wire::Versioned {
version: 2,
inner: &Assets::default(),
})
.unwrap();
assert!(decode_assets(&v2).is_err());
let bytes = encode_assets(&Assets::default()).unwrap();
let text = String::from_utf8(bytes.clone()).unwrap();
assert!(text.contains("\"version\": 1"), "{text}");
assert!(decode_assets(&bytes).is_ok());
}
#[test]
fn encoded_assets_are_human_readable() {
let mut a = Assets::default();
a.sprites.set(0, 0, 0x0f);
a.map.set(0, 0, 0x2a);
a.sfx[0].notes[0] = Note {
pitch: 33,
wave: 3,
volume: 5,
effect: 0,
};
let json = String::from_utf8(encode_assets(&a).unwrap()).unwrap();
assert!(json.contains("\"version\": 1"), "{json}");
assert!(json.contains("\"f000"), "sprite row:\n{json}");
assert!(json.contains("\"2a00"), "map row:\n{json}");
assert!(json.contains("[33,3,5,0]"), "notes:\n{json}");
}
#[test]
fn create_scaffolds_a_no_std_project() {
let dir = std::env::temp_dir().join(format!("pixel8_nostd_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
Project::create(&dir.join("g"), "g").unwrap();
let lib = fs::read_to_string(dir.join("g/src/lib.rs")).unwrap();
let manifest = fs::read_to_string(dir.join("g/Cargo.toml")).unwrap();
assert!(lib.contains("#![no_std]"), "lib.rs:\n{lib}");
let dep = format!(
r#"pixel8 = {{ version = "{}.{}", default-features = false }}"#,
env!("CARGO_PKG_VERSION_MAJOR"),
env!("CARGO_PKG_VERSION_MINOR"),
);
assert!(manifest.contains(&dep), "Cargo.toml:\n{manifest}");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn create_writes_cargo_config_with_stack_size() {
let dir = std::env::temp_dir().join(format!("pixel8_cfg_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
Project::create(&dir.join("g"), "g").unwrap();
let cfg = fs::read_to_string(dir.join("g/.cargo/config.toml")).unwrap();
assert!(cfg.contains("wasm32-unknown-unknown"), "config: {cfg}");
assert!(cfg.contains("stack-size=32768"), "config: {cfg}");
assert!(
cfg.contains("[build]") && cfg.contains("target = \"wasm32-unknown-unknown\""),
"config: {cfg}"
);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn lists_creates_and_switches_files() {
let dir = std::env::temp_dir().join(format!("pixel8_files_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let mut p = Project::create(&dir.join("g"), "g").unwrap();
assert_eq!(p.current, "lib.rs");
assert_eq!(p.file_names(), vec!["lib.rs".to_string()]);
let new = p.create_file("enemy").unwrap();
assert_eq!(new, "enemy.rs");
assert_eq!(p.current, "enemy.rs");
assert_eq!(p.code, "");
assert!(dir.join("g/src/enemy.rs").exists());
let lib = fs::read_to_string(dir.join("g/src/lib.rs")).unwrap();
assert!(lib.starts_with("#![no_std]"), "lib.rs:\n{lib}");
assert!(lib.contains("\nmod enemy;\n"), "lib.rs:\n{lib}");
assert!(
lib.find("#![no_std]") < lib.find("mod enemy;"),
"mod must come after the inner attribute:\n{lib}"
);
assert_eq!(p.lib_source(), lib);
assert_eq!(
p.file_names(),
vec!["lib.rs".to_string(), "enemy.rs".to_string()]
);
p.code = "// enemy code\n".into();
p.save().unwrap();
p.switch_to("lib.rs").unwrap();
assert_eq!(p.current, "lib.rs");
assert!(p.code.starts_with("#![no_std]"));
assert!(p.code.contains("\nmod enemy;\n"));
p.switch_to("enemy.rs").unwrap();
assert_eq!(p.code, "// enemy code\n");
assert!(p.create_file("enemy").is_err());
assert!(p.create_file("9bad").is_err());
assert!(p.create_file("a/b").is_err());
assert!(p.create_file("Enemy").is_err());
fs::remove_dir_all(&dir).unwrap();
}
}