pixel8-runtime 0.1.0

Pixel8 fantasy console runtime: VM, framebuffer, input, audio, assets, carts
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
//! On-disk project layout used while developing a cart.
//!
//! A Pixel8 project is a real Cargo crate plus an asset bundle:
//!
//! ```text
//! mygame/
//!   Cargo.toml      # generated by `new`, builds a cdylib for wasm32
//!   src/lib.rs      # the game code edited inside Pixel8 (or outside!)
//!   assets.pixel8.json  # sprites/map/sfx/music/metadata (JSON, versioned)
//!   target/         # cargo build output
//! ```
//!
//! Keeping the project a normal crate is what makes the external-editor
//! workflow work: `cargo build --target wasm32-unknown-unknown` from a
//! terminal produces exactly what the in-console `run` uses.

use crate::assets::Assets;
use anyhow::{anyhow, bail, Context, Result};
use std::{
    fs,
    path::{Path, PathBuf},
};

/// `assets.pixel8.json` format version, so a future (post-release) format change
/// can reject older files with a clear message instead of mis-parsing them.
/// Until a cart format ships, format changes just regenerate the example assets
/// and leave this at 1.
const ASSETS_VERSION: u32 = 1;

/// Version requirement for the SDK dependency of a project created by `new`.
///
/// Every crate in the workspace inherits one version, so this crate's major.minor is also the
/// published SDK's. Pinning major.minor (not the patch) lets a project pick up SDK patch releases
/// with a plain `cargo update`.
const SDK_VERSION_REQ: &str = concat!(
    env!("CARGO_PKG_VERSION_MAJOR"),
    ".",
    env!("CARGO_PKG_VERSION_MINOR")
);

/// Default game source created by `new`.
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();
    }
}
"#;

/// A loaded project: code + assets + where they live.
pub struct Project {
    pub dir: PathBuf,
    /// Crate name (also the wasm artifact name, with `-` mapped to `_`).
    pub name: String,
    pub code: String,
    /// Path (relative to `src/`) of the file currently held in `code`.
    pub current: String,
    pub assets: Assets,
}

impl Project {
    /// Create a fresh project directory with template code and empty assets.
    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"),
            // Default to the wasm target so plain `cargo build`/`check`/`test`
            // just work: a `#![no_std]` cart can't build for the host (its
            // unwinding panic strategy isn't supported without std), and wasm32
            // is what carts compile to anyway. The console still passes
            // `--target wasm32-unknown-unknown` explicitly, which matches.
            //
            // The rustflags set the shadow-stack reserve to 32 KiB (32768
            // bytes), the cart's own default. This is tunable: edit
            // stack-size here to give the cart more or less stack. Target-
            // scoped so host tooling is unaffected. The console builds
            // straight and honors whatever value is set here; a value large
            // enough to push the cart's initial memory over the 128 K cap is
            // reported as an error at build time.
            "[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)
    }

    /// Load an existing project directory.
    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,
        })
    }

    /// Write the open file and assets back to disk.
    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(())
    }

    /// The `*.rs` files directly under `src/`, sorted with `lib.rs` first.
    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
    }

    /// Persist nothing here; load `src/<name>` into `code` and make it current.
    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(())
    }

    /// Create a new flat module under `src/`, wire it into `lib.rs`, and open it.
    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");
        }
        // `normalize_file_name` guarantees the `.rs` suffix.
        let stem = file.strip_suffix(".rs").unwrap();
        // Wire the module into lib.rs, read fresh from disk so an unrelated open
        // file's buffer cannot clobber external edits to lib.rs. The `mod` must
        // go after any leading inner attributes (e.g. `#![no_std]`), which have
        // to stay at the very top or the crate fails to compile.
        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)
    }

    /// The `lib.rs` source, regardless of which file is open. Used for the
    /// source embedded in an exported cart.
    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()
        }
    }

    /// Where `cargo build --release --target wasm32-unknown-unknown` puts
    /// the cart wasm.
    pub fn wasm_path(&self) -> PathBuf {
        self.dir
            .join("target/wasm32-unknown-unknown/release")
            .join(format!("{}.wasm", self.name.replace('-', "_")))
    }
}

/// Serialize assets as versioned, human-readable JSON.
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())
}

/// Parse an assets file, checking the format version.
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)
}

/// Validate a new source-file name and return it with a `.rs` suffix. Flat
/// module names only: `[a-z_][a-z0-9_]*`, optionally already suffixed `.rs`.
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"))
}

/// Byte offset in `lib.rs` at which to insert a `mod` declaration: past any
/// leading inner attributes (`#![...]`), inner doc comments and blank lines,
/// which must precede every item or the crate fails to compile.
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> {
    // Tiny TOML peek: the first `name = "..."` line in the file. Good
    // enough for manifests Pixel8 generates and typical hand edits.
    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() {
        // The committed example carts must stay loadable; this catches an
        // assets-format change that forgets to regenerate them.
        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() {
        // Not JSON at all -> error.
        assert!(decode_assets(b"NOTJSON").is_err());
        // A complete, valid-shape bundle at a different version is rejected by
        // the version check (not merely a parse error).
        let v2 = serde_json::to_vec(&crate::wire::Versioned {
            version: 2,
            inner: &Assets::default(),
        })
        .unwrap();
        assert!(decode_assets(&v2).is_err());
        // A freshly encoded bundle carries the current version and round-trips.
        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();
        // Version envelope.
        assert!(json.contains("\"version\": 1"), "{json}");
        // Sprite sheet row 0 is a hex-nibble string beginning with the set pixel.
        assert!(json.contains("\"f000"), "sprite row:\n{json}");
        // Map row 0 is hex bytes; tile 0 is 0x2a.
        assert!(json.contains("\"2a00"), "map row:\n{json}");
        // Notes render as inline quads.
        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}");
        // The SDK comes from crates.io, pinned to the major.minor this crate was built at: every
        // workspace crate shares one version.
        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}");
        // Default build target so plain `cargo build`/`check` target wasm and a
        // no_std cart doesn't fail with "unwinding panics are not supported".
        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()]);

        // Create a new module: file on disk, `mod` wired into lib.rs, opened.
        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();
        // The `#![no_std]` inner attribute must stay at the very top; the `mod`
        // is wired in after it, or the crate would not compile.
        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);

        // Listing shows both, lib.rs first.
        assert_eq!(
            p.file_names(),
            vec!["lib.rs".to_string(), "enemy.rs".to_string()]
        );

        // Edit the new file, save, switch away and back.
        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");

        // Duplicate and invalid names are rejected.
        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();
    }
}