use crate::{
picker,
platform::{InputSnapshot, Platform},
};
use anyhow::Result;
use pixel8_runtime::{
audio::AudioHandle,
cart,
fb::{Framebuffer, HEIGHT},
palette::col,
storage::Storage,
ui,
vm::{GameVm, UI_FPS},
};
use std::{
path::{Path, PathBuf},
time::{Duration, Instant},
};
#[derive(Default)]
pub struct Controls {
combo_frames: u32,
}
pub enum ControlAction {
None,
BackToPicker,
Quit,
ToggleFps,
}
impl Controls {
pub fn update(&mut self, snap: &InputSnapshot, fps: u32) -> ControlAction {
if snap.quit_requested {
return ControlAction::Quit;
}
if snap.select && snap.start {
return ControlAction::Quit;
}
if snap.select {
return ControlAction::BackToPicker;
}
if snap.fps_toggle {
return ControlAction::ToggleFps;
}
if snap.buttons[4] && snap.buttons[5] {
self.combo_frames += 1;
if self.combo_frames >= fps.max(1) {
self.combo_frames = 0;
return ControlAction::BackToPicker;
}
} else {
self.combo_frames = 0;
}
ControlAction::None
}
}
pub enum Flow {
Quit,
BackToPicker,
}
fn frame_duration(fps: u32) -> Duration {
Duration::from_nanos(1_000_000_000 / fps.max(1) as u64)
}
pub struct App {
platform: Box<dyn Platform>,
audio: AudioHandle,
smoke: Option<u32>,
}
impl App {
pub fn new(platform: Box<dyn Platform>, audio: AudioHandle, smoke: Option<u32>) -> App {
App {
platform,
audio,
smoke,
}
}
pub fn picker(&mut self, dir: &Path) -> Result<()> {
loop {
let carts = picker::scan_carts(dir)?;
match self.picker_loop(dir, &carts)? {
Some(path) => match self.play(&path) {
Ok(Flow::BackToPicker) => continue,
Ok(Flow::Quit) => return Ok(()),
Err(e) => {
eprintln!("pixel8-player: {e:#}");
continue;
}
},
None => return Ok(()),
}
}
}
pub fn play(&mut self, path: &Path) -> Result<Flow> {
eprintln!("pixel8-player: loading {}", path.display());
let cart = match cart::load_png(path) {
Ok(c) => c,
Err(e) => return self.show_error(&format!("load failed\n{e}")),
};
self.audio.stop_all();
let storage = Storage::for_cart(&cart.assets.meta.name);
let mut vm = match GameVm::load(&cart.wasm, &cart.assets, self.audio.clone(), storage) {
Ok(vm) => Some(vm),
Err(e) => return self.show_error(&format!("boot failed\n{e}")),
};
let fps = vm.as_ref().map(GameVm::fps).unwrap_or(UI_FPS);
let frame = frame_duration(fps);
eprintln!("pixel8-player: running {}", path.display());
let mut controls = Controls::default();
let mut error_fb: Option<Framebuffer> = None;
let mut next = Instant::now();
let mut frames = 0u32;
let mut show_fps = false;
let mut fps_frames = 0u32;
let mut fps_t0 = Instant::now();
let mut fps_val = 0.0f32;
loop {
let snap = self.platform.poll();
match controls.update(&snap, fps) {
ControlAction::Quit => {
self.audio.stop_all();
return Ok(Flow::Quit);
}
ControlAction::BackToPicker => {
self.audio.stop_all();
return Ok(Flow::BackToPicker);
}
ControlAction::ToggleFps => show_fps = !show_fps,
ControlAction::None => {}
}
if let Some(v) = vm.as_mut() {
let input = &mut v.state_mut().input;
for (b, pressed) in snap.buttons.iter().enumerate() {
input.set_button(b, *pressed);
}
}
if let Some(v) = vm.as_mut() {
v.state_mut().set_measured_fps(fps_val);
}
if let Some(v) = vm.as_mut() {
if let Err(e) = v.call_update().and_then(|()| v.call_draw()) {
eprintln!("pixel8-player: runtime error: {e}");
self.audio.stop_all();
let mut fb = ui::error_screen(&e.to_string());
fb.print("hold o+x to exit", 2, HEIGHT - 7, col::LIGHT_GREY);
error_fb = Some(fb);
vm = None;
}
}
if show_fps {
if let Some(v) = vm.as_mut() {
picker::draw_fps_overlay(&mut v.state_mut().fb, fps_val, fps);
}
}
if let Some(v) = &vm {
self.platform.present(&v.state().fb)?;
} else if let Some(fb) = &error_fb {
self.platform.present(fb)?;
}
frames += 1;
if self.smoke.is_some_and(|n| frames >= n) {
return Ok(Flow::Quit);
}
self.pace(&mut next, frame, &mut fps_frames, &mut fps_t0, &mut fps_val);
}
}
fn show_error(&mut self, message: &str) -> Result<Flow> {
eprintln!("pixel8-player: {}", message.replace('\n', ": "));
self.audio.stop_all();
let mut fb = ui::error_screen(message);
fb.print("select/b: back", 2, HEIGHT - 7, col::LIGHT_GREY);
let mut controls = Controls::default();
let mut next = Instant::now();
let mut shown = 0u32;
loop {
let snap = self.platform.poll();
match controls.update(&snap, UI_FPS) {
ControlAction::Quit => return Ok(Flow::Quit),
ControlAction::BackToPicker => return Ok(Flow::BackToPicker),
_ => {}
}
if snap.buttons[4] || snap.buttons[5] {
return Ok(Flow::BackToPicker);
}
self.platform.present(&fb)?;
shown += 1;
if self.smoke.is_some_and(|n| shown >= n) {
return Ok(Flow::Quit);
}
Self::sleep_until(&mut next, frame_duration(UI_FPS));
}
}
fn picker_loop(&mut self, dir: &Path, carts: &[PathBuf]) -> Result<Option<PathBuf>> {
let mut sel = 0usize;
let mut frame = 0u32;
let mut next = Instant::now();
let mut prev: Option<InputSnapshot> = None;
let quit_row = carts.len();
loop {
let snap = self.platform.poll();
if snap.quit_requested || snap.select {
return Ok(None);
}
if let Some(p) = &prev {
let edge = |i: usize| snap.buttons[i] && !p.buttons[i];
if edge(2) {
sel = sel.saturating_sub(1);
}
if edge(3) {
sel = (sel + 1).min(quit_row);
}
if edge(4) || edge(5) {
if sel == quit_row {
return Ok(None);
}
return Ok(Some(carts[sel].clone()));
}
}
prev = Some(snap);
let fb = picker::draw_picker(dir, carts, sel, frame);
self.platform.present(&fb)?;
frame += 1;
if self.smoke.is_some_and(|n| frame >= n) {
return Ok(None);
}
Self::sleep_until(&mut next, frame_duration(UI_FPS));
}
}
fn pace(
&self,
next: &mut Instant,
frame: Duration,
fps_frames: &mut u32,
fps_t0: &mut Instant,
fps_val: &mut f32,
) {
let now = Instant::now();
*fps_frames += 1;
let elapsed = now.duration_since(*fps_t0);
if elapsed >= Duration::from_millis(500) {
*fps_val = *fps_frames as f32 / elapsed.as_secs_f32();
*fps_frames = 0;
*fps_t0 = now;
}
Self::sleep_until(next, frame);
}
fn sleep_until(next: &mut Instant, frame: Duration) {
*next += frame;
let now = Instant::now();
if *next > now {
std::thread::sleep(*next - now);
} else {
*next = now;
}
}
}
#[cfg(test)]
mod controls_tests {
use super::*;
use crate::platform::InputSnapshot;
fn snap(buttons: [bool; 6]) -> InputSnapshot {
InputSnapshot {
buttons,
..Default::default()
}
}
#[test]
fn hold_o_and_x_returns_to_picker_after_one_second() {
let mut c = Controls::default();
let held = snap([false, false, false, false, true, true]); for _ in 0..59 {
assert!(matches!(c.update(&held, 60), ControlAction::None));
}
assert!(matches!(c.update(&held, 60), ControlAction::BackToPicker));
}
#[test]
fn releasing_o_or_x_resets_the_combo() {
let mut c = Controls::default();
let both = snap([false, false, false, false, true, true]);
let one = snap([false, false, false, false, true, false]);
for _ in 0..30 {
c.update(&both, 60);
}
c.update(&one, 60); for _ in 0..59 {
assert!(matches!(c.update(&both, 60), ControlAction::None));
}
assert!(matches!(c.update(&both, 60), ControlAction::BackToPicker));
}
#[test]
fn select_plus_start_quits() {
let mut c = Controls::default();
let s = InputSnapshot {
select: true,
start: true,
..Default::default()
};
assert!(matches!(c.update(&s, 60), ControlAction::Quit));
}
#[test]
fn select_alone_returns_to_picker() {
let mut c = Controls::default();
let s = InputSnapshot {
select: true,
..Default::default()
};
assert!(matches!(c.update(&s, 60), ControlAction::BackToPicker));
}
#[test]
fn fps_toggle_is_an_edge() {
let mut c = Controls::default();
let on = InputSnapshot {
fps_toggle: true,
..Default::default()
};
assert!(matches!(c.update(&on, 60), ControlAction::ToggleFps));
}
}
#[cfg(test)]
mod app_tests {
use super::*;
use crate::platform::{null::NullPlatform, InputSnapshot};
use pixel8_runtime::input::Button;
use std::process::Command;
#[test]
fn stress_cart_cpu_quota_trips_entering_level_five() {
let cart = stress_cart("trip");
let mut right = InputSnapshot::default();
right.buttons[Button::Right as usize] = true;
let input: Vec<InputSnapshot> = (0..6)
.flat_map(|_| [right, InputSnapshot::default()])
.collect();
let frames = input.len() as u32;
let (platform, presented) = NullPlatform::scripted_with_capture(input);
let mut app = App::new(Box::new(platform), AudioHandle::dummy(), Some(frames));
assert!(matches!(app.play(cart.path()).unwrap(), Flow::Quit));
let presented = presented.borrow();
assert_eq!(presented.len(), frames as usize);
let quota_error = cpu_quota_error_screen();
let tripped_at = presented
.iter()
.position(|frame| frame.as_slice() == quota_error.pixels())
.map(|frame| frame / 2 + 1);
assert_eq!(
tripped_at,
Some(5),
"the stress cart's CPU quota tripped entering level {tripped_at:?}, not level 5"
);
}
#[test]
fn stress_cart_level_four_uses_most_of_the_budget() {
let cart = stress_cart("margin");
let cart = cart::load_png(cart.path()).expect("the generated stress cart loads");
let mut vm = GameVm::load(
&cart.wasm,
&cart.assets,
AudioHandle::dummy(),
Storage::default(),
)
.expect("the generated stress cart starts");
for level in 1..=4 {
for pressed in [true, false] {
vm.state_mut()
.input
.set_button(Button::Right as usize, pressed);
vm.call_update()
.unwrap_or_else(|error| panic!("updating CPU level {level}: {error}"));
vm.call_draw()
.unwrap_or_else(|error| panic!("drawing CPU level {level}: {error}"));
}
}
let cpu = vm.cpu_update();
assert!(
(0.82..=0.90).contains(&cpu),
"CPU level 4 used {:.1}% of the budget (measured: 85.5%)",
cpu * 100.0
);
}
#[test]
fn smoke_picker_presents_then_quits() {
let dir = std::env::temp_dir().join(format!("pixel8_app_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(Box::new(NullPlatform::new()), AudioHandle::dummy(), Some(3));
app.picker(&dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn quit_requested_leaves_picker_immediately() {
let dir = std::env::temp_dir().join(format!("pixel8_app2_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let snap = InputSnapshot {
quit_requested: true,
..Default::default()
};
let platform = Box::new(NullPlatform::scripted(vec![snap]));
let mut app = App::new(platform, AudioHandle::dummy(), None);
app.picker(&dir).unwrap(); std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn held_face_button_on_entry_does_not_launch() {
let dir = std::env::temp_dir().join(format!("pixel8_app3_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("test.png"), b"dummy").unwrap();
let held = InputSnapshot {
buttons: [false, false, false, false, true, false],
..Default::default()
};
let frames = vec![held, held, held];
let platform = Box::new(NullPlatform::scripted(frames));
let mut app = App::new(platform, AudioHandle::dummy(), Some(3));
let carts = vec![dir.join("test.png")];
let result = app.picker_loop(&dir, &carts).unwrap();
assert!(
result.is_none(),
"held button on entry must not launch a cart (got {result:?})"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn empty_dir_quit_row_exits() {
let pressed = InputSnapshot {
buttons: [false, false, false, false, true, false],
..Default::default()
};
let frames = vec![InputSnapshot::default(), pressed];
let platform = Box::new(NullPlatform::scripted(frames));
let mut app = App::new(platform, AudioHandle::dummy(), Some(10));
let result = app
.picker_loop(Path::new("/tmp/pixel8-empty"), &[])
.unwrap();
assert!(
result.is_none(),
"pressing on the quit row exits the picker"
);
}
#[test]
fn navigating_to_quit_row_exits() {
let carts = vec![PathBuf::from("a.png"), PathBuf::from("b.png")]; let down = InputSnapshot {
buttons: [false, false, false, true, false, false],
..Default::default()
};
let none = InputSnapshot::default();
let press = InputSnapshot {
buttons: [false, false, false, false, true, false],
..Default::default()
};
let frames = vec![none, down, none, down, none, press];
let platform = Box::new(NullPlatform::scripted(frames));
let mut app = App::new(platform, AudioHandle::dummy(), Some(20));
let result = app
.picker_loop(Path::new("/tmp/pixel8-carts"), &carts)
.unwrap();
assert!(
result.is_none(),
"navigating to the quit row and pressing exits"
);
}
#[test]
fn fresh_press_after_release_launches() {
let dir = std::env::temp_dir().join(format!("pixel8_app4_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("test.png"), b"dummy").unwrap();
let held = InputSnapshot {
buttons: [false, false, false, false, true, false],
..Default::default()
};
let released = InputSnapshot::default();
let frames = vec![held, held, released, held];
let platform = Box::new(NullPlatform::scripted(frames));
let mut app = App::new(platform, AudioHandle::dummy(), Some(10));
let carts = vec![dir.join("test.png")];
let result = app.picker_loop(&dir, &carts).unwrap();
assert_eq!(
result,
Some(carts[0].clone()),
"genuine press after release must launch the selected cart"
);
std::fs::remove_dir_all(&dir).unwrap();
}
fn cpu_quota_error_screen() -> Framebuffer {
let mut fb =
ui::error_screen("Runtime error in update:\nupdate() ran too long\n(infinite loop?)");
fb.print("hold o+x to exit", 2, HEIGHT - 7, col::LIGHT_GREY);
fb
}
fn stress_cart(name: &str) -> TempCart {
let player = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let project = player.join("../examples/stress");
let target = player.join("../target/tests/stress-cart-headless");
let build = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
.args(["build", "--release", "--target", "wasm32-unknown-unknown"])
.current_dir(&project)
.env("CARGO_TARGET_DIR", &target)
.env("CARGO_TERM_COLOR", "never")
.env_remove("RUSTFLAGS")
.env_remove("CARGO_ENCODED_RUSTFLAGS")
.output()
.expect("cargo runs");
assert!(
build.status.success(),
"building the stress cart failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let wasm = target.join("wasm32-unknown-unknown/release/stress.wasm");
let wasm = std::fs::read(&wasm)
.unwrap_or_else(|error| panic!("reading {}: {error}", wasm.display()));
let dir = std::env::temp_dir().join(format!(
"pixel8-player-stress-cart-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("stress.png");
cart::save_png(
&cart::Cart {
wasm,
assets: Default::default(),
source: None,
},
&path,
)
.unwrap();
TempCart { dir, path }
}
struct TempCart {
dir: PathBuf,
path: PathBuf,
}
impl TempCart {
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempCart {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
}