use anyhow::{anyhow, bail, Context, Result};
use pixel8_console::{
builder, sdk_path,
shell::{self, Shell},
webexport,
};
#[cfg(feature = "window")]
use pixel8_console::{
frame_duration, gpu,
shell::{Key, Mods},
};
use pixel8_runtime::{
cart::{self, Cart},
project::Project,
};
#[cfg(feature = "window")]
use std::sync::Arc;
use std::{
path::{Path, PathBuf},
time::Instant,
};
#[cfg(feature = "window")]
use winit::{
application::ApplicationHandler,
dpi::LogicalSize,
event::{ElementState, MouseButton, WindowEvent},
event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
keyboard::{KeyCode, NamedKey, PhysicalKey},
window::{Window, WindowId},
};
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let strs: Vec<&str> = args.iter().map(String::as_str).collect();
match strs.as_slice() {
["help" | "--help" | "-h"] => {
print_help();
Ok(())
}
["new", dir] => headless_new(Path::new(dir)),
["build", dir] => headless_build(Path::new(dir)),
["export", dir, out, rest @ ..] => headless_export(
Path::new(dir),
Path::new(out),
!rest.contains(&"--no-source"),
),
["extract", png, dir] => headless_extract(Path::new(png), Path::new(dir)),
["import-pico8", rest @ ..] => headless_import_pico8_cli(rest),
["export-web", input, out] => headless_export_web(Path::new(input), Path::new(out)),
["verify", png] => headless_verify(Path::new(png)),
["snap", project, outdir] => headless_snap(Path::new(project), Path::new(outdir)),
["run", path] => run_windowed(Some(path.to_string()), true),
["run"] => {
print_help();
bail!("Usage: pixel8 run <dir|cart.png>");
}
["tui", ..] => {
bail!("The terminal frontend is the separate `pixel8-tui` binary (cargo install pixel8-tui)")
}
[] => run_windowed(None, false),
[path] => run_windowed(Some(path.to_string()), false),
_ => {
print_help();
bail!("Unrecognized arguments: {args:?}");
}
}
}
fn print_help() {
println!(
"Pixel8 {} - A fantasy console for Rust\n\n\
Usage:\n\
\x20 pixel8 Boot the console\n\
\x20 pixel8 <dir|cart.png> Boot with a cart loaded\n\
\x20 pixel8 run <dir|cart.png> Boot, load, and run immediately\n\
\x20 pixel8 new <dir> Create a project (headless)\n\
\x20 pixel8 build <dir> Compile a project to wasm (headless)\n\
\x20 pixel8 export <dir> <out.png> [--no-source]\n\
\x20 Build + export a PNG cart (headless)\n\
\x20 pixel8 extract <cart.png> <dir>\n\
\x20 Turn an editable cart into a project\n\
\x20 pixel8 import-pico8 <cart.p8|.p8.png> [dir]\n\
\x20 Import a PICO-8 cart's assets into a new project\n\
\x20 (dir defaults to the cart's name)\n\
\x20 pixel8 import-pico8 <cart.p8|.p8.png> --into <project-dir>\n\
\x20 [--sprites R] [--sfx R] [--music R]\n\
\x20 Append selected assets into an existing project\n\
\x20 pixel8 export-web <dir|cart.png> <out.html>\n\
\x20 Export a self-contained playable web page\n\
\x20 pixel8 verify <cart.png> Load a cart and run 60 frames headless",
shell::VERSION
);
}
fn headless_new(dir: &Path) -> Result<()> {
let name = dir
.file_name()
.ok_or_else(|| anyhow!("Bad directory name"))?
.to_string_lossy()
.into_owned();
Project::create(dir, &name)?;
println!("Created {}", dir.display());
Ok(())
}
fn headless_build(dir: &Path) -> Result<()> {
let project = Project::load(dir)?;
let result = builder::run_build(dir, Instant::now());
if !result.success {
for line in &result.errors {
eprintln!("{line}");
}
bail!("Build failed");
}
println!(
"Built {} ({:.1}s)",
project.wasm_path().display(),
result.duration.as_secs_f32()
);
for line in &result.warnings {
eprintln!("{line}");
}
Ok(())
}
fn headless_export(dir: &Path, out: &Path, include_source: bool) -> Result<()> {
let project = Project::load(dir)?;
let result = builder::run_build(dir, Instant::now());
if !result.success {
for line in &result.errors {
eprintln!("{line}");
}
bail!("Build failed");
}
let wasm = std::fs::read(project.wasm_path()).context("Reading built wasm")?;
let cart = Cart {
wasm,
assets: project.assets.clone(),
source: include_source.then(|| project.code.clone()),
};
cart::save_png(&cart, out)?;
println!("Exported {}", out.display());
Ok(())
}
fn headless_extract(png: &Path, dir: &Path) -> Result<()> {
let cart = cart::load_png(png)?;
let source = cart
.source
.ok_or_else(|| anyhow!("Cart has no embedded source (playable-only cart)"))?;
let mut project = Project::create(dir, &cart.assets.meta.name)?;
project.code = source;
project.assets = cart.assets;
project.save()?;
println!("Extracted into {}", dir.display());
Ok(())
}
fn headless_import_pico8(src: &Path, dir: &Path) -> Result<()> {
pixel8_runtime::pico8::import_project(src, dir)?;
println!("Imported {} into {}", src.display(), dir.display());
Ok(())
}
fn flag_value<'a>(next: Option<&'a &'a str>, flag: &str) -> Result<&'a str> {
match next {
Some(&v) if !v.starts_with("--") => Ok(v),
_ => bail!("{flag} needs a value"),
}
}
fn headless_import_pico8_cli(args: &[&str]) -> Result<()> {
let (mut src, mut dir, mut into) = (None, None, None);
let (mut sprites, mut sfx, mut music) = (None, None, None);
let mut it = args.iter();
while let Some(&a) = it.next() {
match a {
"--into" => into = Some(flag_value(it.next(), "--into")?),
"--sprites" => sprites = Some(flag_value(it.next(), "--sprites")?),
"--sfx" => sfx = Some(flag_value(it.next(), "--sfx")?),
"--music" => music = Some(flag_value(it.next(), "--music")?),
flag if flag.starts_with("--") => bail!("unknown flag {flag}"),
pos if src.is_none() => src = Some(pos),
pos if dir.is_none() => dir = Some(pos),
pos => bail!("unexpected argument {pos}"),
}
}
let Some(src) = src else {
bail!(
"Usage: pixel8 import-pico8 <cart.p8|.p8.png> [dir]\n or: \
pixel8 import-pico8 <cart.p8|.p8.png> --into <project-dir> \
[--sprites R] [--sfx R] [--music R]"
);
};
let src = Path::new(src);
match into {
Some(into) => {
if dir.is_some() {
bail!("--into supplies the destination; do not also pass a positional dir");
}
let sel = pixel8_runtime::pico8::Selection::parse(sprites, sfx, music)?;
headless_import_pico8_into(src, Path::new(into), &sel)
}
None => {
if sprites.is_some() || sfx.is_some() || music.is_some() {
bail!("--sprites/--sfx/--music only apply with --into <project-dir>");
}
let dir = dir
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(pixel8_runtime::pico8::default_dir_name(src)));
headless_import_pico8(src, &dir)
}
}
}
fn headless_import_pico8_into(
src: &Path,
dir: &Path,
sel: &pixel8_runtime::pico8::Selection,
) -> Result<()> {
let mut project = Project::load(dir)?;
let assets = pixel8_runtime::pico8::parse_file(src)?;
let report = pixel8_runtime::pico8::append_pico8_assets(&mut project.assets, &assets, sel)?;
project.save()?;
for line in report.summary_lines() {
println!("Imported {line} into {}", dir.display());
}
for w in &report.warnings {
eprintln!("warning: {w}");
}
Ok(())
}
fn headless_export_web(input: &Path, out: &Path) -> Result<()> {
let cart = if input.extension().is_some_and(|e| e == "png") {
cart::load_png(input)?
} else {
let project = Project::load(input)?;
let result = builder::run_build(input, Instant::now());
if !result.success {
for line in &result.errors {
eprintln!("{line}");
}
bail!("Build failed");
}
let wasm = std::fs::read(project.wasm_path()).context("Reading built wasm")?;
Cart {
wasm,
assets: project.assets.clone(),
source: None,
}
};
webexport::export_html(&cart, out, &webexport::web_crate_dir(&sdk_path()))?;
println!("Exported {}", out.display());
Ok(())
}
fn headless_verify(png: &Path) -> Result<()> {
use pixel8_runtime::{audio::AudioHandle, storage::Storage, vm::GameVm};
let cart = cart::load_png(png)?;
let mut vm = GameVm::load(
&cart.wasm,
&cart.assets,
AudioHandle::dummy(),
Storage::default(),
)
.context("Loading cart into the VM")?;
for frame in 0..60 {
vm.call_update()
.and_then(|()| vm.call_draw())
.map_err(|e| anyhow!("Frame {frame}: {e}"))?;
}
let drew_something = vm.state().fb.pixels().iter().any(|&p| p != 0);
println!(
"OK: {} ran 60 frames{}",
cart.assets.meta.name,
if drew_something {
""
} else {
" (blank screen)"
}
);
Ok(())
}
fn headless_snap(project: &Path, outdir: &Path) -> Result<()> {
use pixel8_runtime::{audio::AudioHandle, cart::encode_screen_png};
std::fs::create_dir_all(outdir)?;
let mut shell = Shell::new(AudioHandle::dummy(), sdk_path());
shell.startup_load(&project.to_string_lossy());
let shots = [
(shell::Mode::Console, "console"),
(shell::Mode::Code, "code"),
(shell::Mode::Sprite, "sprite"),
(shell::Mode::Map, "map"),
(shell::Mode::Sfx, "sfx"),
(shell::Mode::Music, "music"),
];
for (mode, name) in shots {
if mode == shell::Mode::Console {
shell.mode = mode;
} else {
shell.switch_editor(mode);
}
for _ in 0..3 {
shell.tick();
}
let png = encode_screen_png(shell.draw(), 3);
std::fs::write(outdir.join(format!("{name}.png")), png)?;
}
println!("Wrote screenshots to {}", outdir.display());
Ok(())
}
#[cfg(feature = "window")]
fn run_windowed(load: Option<String>, auto_run: bool) -> Result<()> {
#[cfg(feature = "audio")]
let audio_out = pixel8_runtime::audio::AudioOutput::start();
#[cfg(feature = "audio")]
let audio = audio_out
.as_ref()
.map(|a| a.handle())
.unwrap_or_else(pixel8_runtime::audio::AudioHandle::dummy);
#[cfg(not(feature = "audio"))]
let audio = pixel8_runtime::audio::AudioHandle::dummy();
let mut shell = Shell::new(audio, sdk_path());
if let Some(path) = load {
shell.startup_load(&path);
if auto_run {
shell.startup_run();
}
}
let event_loop = EventLoop::new()?;
event_loop.set_control_flow(ControlFlow::WaitUntil(Instant::now()));
let mut app = App {
window: None,
gpu: None,
shell,
mods: Mods::default(),
last_title: String::new(),
next_tick: Instant::now(),
#[cfg(feature = "audio")]
_audio_out: audio_out,
};
event_loop.run_app(&mut app)?;
Ok(())
}
#[cfg(not(feature = "window"))]
fn run_windowed(_load: Option<String>, _auto_run: bool) -> Result<()> {
bail!(
"This pixel8 build has no windowed frontend (`window` feature off). The headless \
subcommands still work, and `pixel8-tui` runs the console in a terminal."
);
}
#[cfg(feature = "window")]
struct App {
window: Option<Arc<Window>>,
gpu: Option<gpu::Gpu>,
shell: Shell,
mods: Mods,
last_title: String,
next_tick: Instant,
#[cfg(feature = "audio")]
_audio_out: Option<pixel8_runtime::audio::AudioOutput>,
}
#[cfg(feature = "window")]
impl App {
fn game_button(code: KeyCode) -> Option<usize> {
Some(match code {
KeyCode::ArrowLeft => 0,
KeyCode::ArrowRight => 1,
KeyCode::ArrowUp => 2,
KeyCode::ArrowDown => 3,
KeyCode::KeyZ | KeyCode::KeyC | KeyCode::KeyN => 4,
KeyCode::KeyX | KeyCode::KeyV | KeyCode::KeyM => 5,
_ => return None,
})
}
fn shell_key(logical: &winit::keyboard::Key) -> Option<Key> {
use winit::keyboard::Key as WKey;
Some(match logical {
WKey::Named(NamedKey::ArrowLeft) => Key::Left,
WKey::Named(NamedKey::ArrowRight) => Key::Right,
WKey::Named(NamedKey::ArrowUp) => Key::Up,
WKey::Named(NamedKey::ArrowDown) => Key::Down,
WKey::Named(NamedKey::Backspace) => Key::Backspace,
WKey::Named(NamedKey::Delete) => Key::Delete,
WKey::Named(NamedKey::Enter) => Key::Enter,
WKey::Named(NamedKey::Tab) => Key::Tab,
WKey::Named(NamedKey::Escape) => Key::Escape,
WKey::Named(NamedKey::Home) => Key::Home,
WKey::Named(NamedKey::End) => Key::End,
WKey::Named(NamedKey::PageUp) => Key::PageUp,
WKey::Named(NamedKey::PageDown) => Key::PageDown,
WKey::Named(NamedKey::Space) => Key::Char(' '),
WKey::Named(NamedKey::F1) => Key::ToggleStats,
WKey::Named(NamedKey::F6) => Key::CaptureLabel,
WKey::Character(s) => Key::Char(s.chars().next()?),
_ => return None,
})
}
}
#[cfg(feature = "window")]
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() {
return;
}
let attrs = Window::default_attributes()
.with_title("Pixel8")
.with_inner_size(LogicalSize::new(512.0, 512.0))
.with_min_inner_size(LogicalSize::new(128.0, 128.0));
let window = match event_loop.create_window(attrs) {
Ok(w) => Arc::new(w),
Err(e) => {
eprintln!("pixel8: Could not open a window: {e}");
event_loop.exit();
return;
}
};
window.set_cursor_visible(false);
match gpu::Gpu::new(window.clone(), event_loop.owned_display_handle()) {
Ok(g) => {
self.gpu = Some(g);
self.window = Some(window);
}
Err(e) => {
eprintln!("pixel8: Graphics init failed: {e:#}");
event_loop.exit();
}
}
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
if let Some(g) = &mut self.gpu {
g.resize(size.width, size.height);
}
}
WindowEvent::ModifiersChanged(m) => {
let s = m.state();
self.mods = Mods {
ctrl: s.control_key(),
shift: s.shift_key(),
alt: s.alt_key(),
};
}
WindowEvent::KeyboardInput { event, .. } => {
if let PhysicalKey::Code(code) = event.physical_key {
if let Some(b) = Self::game_button(code) {
self.shell
.set_button(b, event.state == ElementState::Pressed);
}
}
if event.state == ElementState::Pressed {
if let Some(key) = Self::shell_key(&event.logical_key) {
self.shell.key(key, self.mods);
}
}
}
WindowEvent::CursorMoved { position, .. } => {
if let Some(g) = &self.gpu {
let (x, y) = g.viewport().window_to_screen(position.x, position.y);
self.shell.mouse.x = x;
self.shell.mouse.y = y;
}
}
WindowEvent::MouseInput { state, button, .. } => {
let down = state == ElementState::Pressed;
match button {
MouseButton::Left => {
if down {
self.shell.mouse.left_pressed = true;
}
self.shell.mouse.left = down;
}
MouseButton::Right => {
if down {
self.shell.mouse.right_pressed = true;
}
self.shell.mouse.right = down;
}
_ => {}
}
}
WindowEvent::RedrawRequested => {
let shell = &mut self.shell;
let fb = shell.draw();
if let Some(g) = &mut self.gpu {
if let Err(e) = g.render(fb) {
eprintln!("pixel8: Render error: {e:#}");
}
}
}
_ => {}
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
let now = Instant::now();
let mut ticked = false;
let frame = frame_duration(self.shell.tick_fps());
while Instant::now() >= self.next_tick {
self.shell.tick();
self.next_tick += frame;
ticked = true;
if now > self.next_tick + frame * 10 {
self.next_tick = now + frame;
}
}
if self.shell.want_exit {
event_loop.exit();
return;
}
if ticked {
let title = self.shell.window_title();
if title != self.last_title {
if let Some(w) = &self.window {
w.set_title(&title);
}
self.last_title = title;
}
if let Some(w) = &self.window {
w.request_redraw();
}
}
event_loop.set_control_flow(ControlFlow::WaitUntil(self.next_tick));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn into_rejects_positional_dir() {
let err = headless_import_pico8_cli(&["c.p8", "mydir", "--into", "dest", "--sfx", "0"])
.unwrap_err();
assert!(err.to_string().contains("positional"), "got: {err}");
}
#[test]
fn selection_flags_require_into() {
let err = headless_import_pico8_cli(&["c.p8", "--sprites", "0-3"]).unwrap_err();
assert!(err.to_string().contains("--into"), "got: {err}");
}
#[test]
fn into_requires_a_selection() {
let err = headless_import_pico8_cli(&["c.p8", "--into", "dest"]).unwrap_err();
assert!(err.to_string().contains("at least one"), "got: {err}");
}
#[test]
fn unknown_flag_is_rejected() {
let err = headless_import_pico8_cli(&["c.p8", "--into", "dest", "--bogus"]).unwrap_err();
assert!(err.to_string().contains("bogus"), "got: {err}");
}
#[test]
fn flag_without_value_is_rejected() {
let err = headless_import_pico8_cli(&["c.p8", "--into", "--sfx", "0"]).unwrap_err();
assert!(
err.to_string().contains("--into needs a value"),
"got: {err}"
);
}
}