1use crate::assets::Assets;
18use anyhow::{anyhow, bail, Context, Result};
19use std::{
20 fs,
21 path::{Path, PathBuf},
22};
23
24const ASSETS_VERSION: u32 = 1;
29
30const SDK_VERSION_REQ: &str = concat!(
36 env!("CARGO_PKG_VERSION_MAJOR"),
37 ".",
38 env!("CARGO_PKG_VERSION_MINOR")
39);
40
41pub 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
69pub struct Project {
71 pub dir: PathBuf,
72 pub name: String,
74 pub code: String,
75 pub current: String,
77 pub assets: Assets,
78}
79
80impl Project {
81 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 "[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 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 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 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 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 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 let stem = file.strip_suffix(".rs").unwrap();
235 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 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 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
268pub 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
277pub 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
313fn 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
330fn 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 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 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 assert!(decode_assets(b"NOTJSON").is_err());
414 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 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 assert!(json.contains("\"version\": 1"), "{json}");
443 assert!(json.contains("\"f000"), "sprite row:\n{json}");
445 assert!(json.contains("\"2a00"), "map row:\n{json}");
447 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 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 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 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 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 assert_eq!(
514 p.file_names(),
515 vec!["lib.rs".to_string(), "enemy.rs".to_string()]
516 );
517
518 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 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}