1use crate::{
6 builder::{spawn_build, BuildJob},
7 editor::{
8 code::CodeEditor,
9 file_picker::{FilePicker, PickerAction},
10 map::MapEditor,
11 music::MusicEditor,
12 sfx::SfxEditor,
13 sprite::SpriteEditor,
14 },
15 ui::{self, Mouse},
16 watch::{FileChange, FileWatch, SourceTreeWatch},
17};
18use anyhow::{anyhow, bail, Result};
19use pixel8_runtime::{
20 assets::Assets,
21 audio::AudioHandle,
22 cart::{self, Cart},
23 clipboard::Pasted,
24 fb::Framebuffer,
25 font,
26 palette::col,
27 project::{decode_assets, encode_assets, Project},
28 storage::Storage,
29 vm::{GameVm, RuntimeError, UI_FPS},
30};
31use std::{
32 collections::VecDeque,
33 path::PathBuf,
34 time::{Duration, Instant, SystemTime},
35};
36
37pub const VERSION: &str = env!("CARGO_PKG_VERSION");
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Key {
42 Char(char),
43 Left,
44 Right,
45 Up,
46 Down,
47 Backspace,
48 Delete,
49 Enter,
50 Tab,
51 Escape,
52 Home,
53 End,
54 PageUp,
55 PageDown,
56 CaptureLabel,
58 ToggleStats,
60}
61
62#[derive(Debug, Clone, Copy, Default)]
63pub struct Mods {
64 pub ctrl: bool,
65 pub shift: bool,
66 pub alt: bool,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Mode {
71 Console,
72 Run,
73 Code,
74 Sprite,
75 Map,
76 Sfx,
77 Music,
78}
79
80pub const EDITOR_MODES: [Mode; 5] = [Mode::Code, Mode::Sprite, Mode::Map, Mode::Sfx, Mode::Music];
82
83enum Loaded {
85 None,
86 Project(Project),
88 Cart {
91 cart: Cart,
92 path: PathBuf,
93 },
94}
95
96fn assets_of(loaded: &mut Loaded) -> Option<&mut Assets> {
97 match loaded {
98 Loaded::None => None,
99 Loaded::Project(p) => Some(&mut p.assets),
100 Loaded::Cart { cart, .. } => Some(&mut cart.assets),
101 }
102}
103
104fn assets_ref(loaded: &Loaded) -> Option<&Assets> {
105 match loaded {
106 Loaded::None => None,
107 Loaded::Project(p) => Some(&p.assets),
108 Loaded::Cart { cart, .. } => Some(&cart.assets),
109 }
110}
111
112struct ProjectWatch {
115 code: FileWatch,
116 assets: FileWatch,
117 source_tree: SourceTreeWatch,
118}
119
120impl ProjectWatch {
121 fn new(p: &Project) -> Self {
122 let assets_baseline = encode_assets(&p.assets).unwrap_or_default();
123 Self {
124 code: FileWatch::new(p.dir.join("src/lib.rs"), p.code.clone().into_bytes()),
125 assets: FileWatch::new(p.dir.join("assets.pixel8.json"), assets_baseline),
126 source_tree: SourceTreeWatch::new(&p.dir),
127 }
128 }
129
130 fn sync(&mut self, p: &Project) {
133 self.code.mark_synced(p.code.clone().into_bytes());
134 self.assets
135 .mark_synced(encode_assets(&p.assets).unwrap_or_default());
136 self.source_tree.sync();
137 }
138}
139
140struct CartWatch {
143 path: PathBuf,
144 synced_mtime: Option<SystemTime>,
145 baseline: Vec<u8>,
147}
148
149impl CartWatch {
150 fn new(path: PathBuf, baseline: Vec<u8>) -> Self {
151 let synced_mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
152 Self {
153 path,
154 synced_mtime,
155 baseline,
156 }
157 }
158
159 fn mark_synced(&mut self, baseline: Vec<u8>) {
162 self.baseline = baseline;
163 self.synced_mtime = std::fs::metadata(&self.path)
164 .and_then(|m| m.modified())
165 .ok();
166 }
167
168 fn advanced(&mut self) -> Option<SystemTime> {
170 let mtime = std::fs::metadata(&self.path)
171 .and_then(|m| m.modified())
172 .ok()?;
173 let advanced = self.synced_mtime.map(|prev| mtime > prev).unwrap_or(true);
174 if advanced {
175 self.synced_mtime = Some(mtime);
178 Some(mtime)
179 } else {
180 None
181 }
182 }
183}
184
185fn stat_color(frac: f32) -> u8 {
188 if frac < 0.7 {
189 col::GREEN
190 } else if frac <= 0.9 {
191 col::YELLOW
192 } else {
193 col::RED
194 }
195}
196
197fn fps_color(measured: f32, target: u32) -> u8 {
199 let ratio = if target > 0 {
200 measured / target as f32
201 } else {
202 1.0
203 };
204 if ratio >= 0.95 {
205 col::GREEN
206 } else if ratio >= 0.8 {
207 col::YELLOW
208 } else {
209 col::RED
210 }
211}
212
213fn stats_overlay(fb: &mut Framebuffer, cpu_u: f32, cpu_d: f32, used: u32, fps: f32, target: u32) {
222 let used_frac = used as f32 / 131_072.0;
223 let lines = [
224 format!("CPU U {:>5.1}%", cpu_u * 100.0),
225 format!("CPU D {:>5.1}%", cpu_d * 100.0),
226 format!(
227 "MEM {:<4}{:>5.1}%",
228 format!("{}K", used / 1024),
229 used_frac * 100.0
230 ),
231 format!("FPS {:>6.1}", fps),
232 ];
233 let colors = [
234 stat_color(cpu_u),
235 stat_color(cpu_d),
236 stat_color(used_frac),
237 fps_color(fps, target),
238 ];
239 let w = lines.iter().map(|l| l.len()).max().unwrap_or(0) as i32 * 4 + 1;
240 let x0 = 127 - w;
241 fb.rectfill(x0, 0, 127, 4 * 7, col::BLACK);
242 for (i, (line, &color)) in lines.iter().zip(colors.iter()).enumerate() {
243 fb.print(line, x0 + 1, 1 + i as i32 * 7, color);
244 }
245}
246
247const STATS_WINDOW: Duration = Duration::from_secs(1);
249
250#[derive(Debug)]
272struct StatsMeter {
273 t0: Instant,
275 peak_update: f32,
277 peak_draw: f32,
278 published: Option<(f32, f32)>,
281 mem_used: u32,
283}
284
285impl StatsMeter {
286 fn cpu_update(&self) -> f32 {
288 self.published.map_or(0.0, |(u, _)| u)
289 }
290
291 fn cpu_draw(&self) -> f32 {
293 self.published.map_or(0.0, |(_, d)| d)
294 }
295
296 fn mem_used(&self) -> u32 {
298 self.mem_used
299 }
300
301 fn sample<F>(&mut self, now: Instant, cpu_update: f32, cpu_draw: f32, mem: F)
306 where
307 F: FnOnce() -> u32,
308 {
309 self.peak_update = self.peak_update.max(cpu_update);
310 self.peak_draw = self.peak_draw.max(cpu_draw);
311 if self.published.is_none() || now.duration_since(self.t0) >= STATS_WINDOW {
312 self.publish(now, mem);
313 }
314 }
315
316 fn reset(&mut self) {
319 *self = Self::default();
320 }
321
322 fn publish<F>(&mut self, now: Instant, mem: F)
325 where
326 F: FnOnce() -> u32,
327 {
328 self.published = Some((self.peak_update, self.peak_draw));
329 self.mem_used = mem();
330 self.peak_update = 0.0;
331 self.peak_draw = 0.0;
332 self.t0 = now;
333 }
334}
335
336impl Default for StatsMeter {
337 fn default() -> Self {
338 Self {
339 t0: Instant::now(),
341 peak_update: 0.0,
342 peak_draw: 0.0,
343 published: None,
344 mem_used: 0,
345 }
346 }
347}
348
349const CAPTURE_FLASH_FRAMES: u32 = 6;
351
352fn capture_flash_overlay(fb: &mut Framebuffer) {
357 fb.cls(col::WHITE);
358}
359
360enum ConsoleLine {
361 Text {
362 text: String,
363 color: u8,
364 },
365 Stripe,
367}
368
369pub struct Shell {
370 pub mode: Mode,
371 last_editor: Mode,
372 loaded: Loaded,
373 vm: Option<GameVm>,
374 audio: AudioHandle,
375 fb: Framebuffer,
376 frame: u64,
377
378 lines: VecDeque<ConsoleLine>,
380 input: String,
381 cursor: usize,
382 history: Vec<String>,
383 history_pos: Option<usize>,
384 scroll_back: usize,
385
386 build: Option<BuildJob>,
388 run_after_build: bool,
389 toast: Option<(String, u8, u64)>,
392
393 wasm_mtime: Option<SystemTime>,
395
396 project_watch: Option<ProjectWatch>,
398 cart_watch: Option<CartWatch>,
399
400 pub mouse: Mouse,
402
403 code_ed: CodeEditor,
405 file_picker: FilePicker,
406 previous_file: Option<String>,
409 sprite_ed: SpriteEditor,
410 map_ed: MapEditor,
411 sfx_ed: SfxEditor,
412 music_ed: MusicEditor,
413
414 pub want_exit: bool,
415 cwd: PathBuf,
417 sdk_path: PathBuf,
418 storage_root: Option<PathBuf>,
421
422 show_stats: bool,
424 fps_frames: u32,
426 fps_t0: Instant,
427 fps_val: f32,
428 stats_meter: StatsMeter,
430
431 capture_flash: u32,
433
434 hide_cursor: bool,
439}
440
441const TEXT_COLS: usize = 31;
442const PROMPT_COL: u8 = col::WHITE;
443
444impl Shell {
445 pub fn new(audio: AudioHandle, sdk_path: PathBuf) -> Self {
446 let mut shell = Self {
447 mode: Mode::Console,
448 last_editor: Mode::Code,
449 loaded: Loaded::None,
450 vm: None,
451 audio,
452 fb: Framebuffer::new(),
453 frame: 0,
454 lines: VecDeque::new(),
455 input: String::new(),
456 cursor: 0,
457 history: Vec::new(),
458 history_pos: None,
459 scroll_back: 0,
460 build: None,
461 run_after_build: false,
462 toast: None,
463 wasm_mtime: None,
464 project_watch: None,
465 cart_watch: None,
466 mouse: Mouse::default(),
467 code_ed: CodeEditor::new(),
468 file_picker: FilePicker::new(),
469 previous_file: None,
470 sprite_ed: SpriteEditor::new(),
471 map_ed: MapEditor::new(),
472 sfx_ed: SfxEditor::new(),
473 music_ed: MusicEditor::new(),
474 want_exit: false,
475 cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
476 sdk_path,
477 storage_root: None,
478 show_stats: false,
479 fps_frames: 0,
480 fps_t0: Instant::now(),
481 fps_val: 0.0,
482 stats_meter: StatsMeter::default(),
483 capture_flash: 0,
484 hide_cursor: false,
485 };
486 shell.boot();
487 shell
488 }
489
490 pub fn set_hide_cursor(&mut self, hide: bool) {
493 self.hide_cursor = hide;
494 }
495
496 fn boot(&mut self) {
497 self.lines.push_back(ConsoleLine::Stripe);
498 self.say(&format!("Pixel8 {VERSION}"), col::WHITE);
499 self.say("A fantasy console for Rust", col::LIGHT_GREY);
500 self.say("", col::WHITE);
501 self.say("Type help for help", col::LIGHT_GREY);
502 self.say("", col::WHITE);
503 }
504
505 pub fn say(&mut self, text: &str, color: u8) {
507 if text.is_empty() {
508 self.push_line(String::new(), color);
509 return;
510 }
511 for raw in text.split('\n') {
512 let mut rest = raw;
513 loop {
514 let take = rest
515 .char_indices()
516 .nth(TEXT_COLS)
517 .map(|(i, _)| i)
518 .unwrap_or(rest.len());
519 self.push_line(rest[..take].to_string(), color);
520 rest = &rest[take..];
521 if rest.is_empty() {
522 break;
523 }
524 }
525 }
526 }
527
528 fn toast(&mut self, text: &str, color: u8, secs: f32) {
530 self.toast = Some((text.to_string(), color, self.frame + (secs * 30.0) as u64));
531 }
532
533 fn push_line(&mut self, text: String, color: u8) {
534 self.lines.push_back(ConsoleLine::Text { text, color });
535 while self.lines.len() > 300 {
536 self.lines.pop_front();
537 }
538 self.scroll_back = 0;
539 }
540
541 pub fn assets(&self) -> Option<&Assets> {
546 match &self.loaded {
547 Loaded::None => None,
548 Loaded::Project(p) => Some(&p.assets),
549 Loaded::Cart { cart, .. } => Some(&cart.assets),
550 }
551 }
552
553 pub fn assets_mut(&mut self) -> Option<&mut Assets> {
554 match &mut self.loaded {
555 Loaded::None => None,
556 Loaded::Project(p) => Some(&mut p.assets),
557 Loaded::Cart { cart, .. } => Some(&mut cart.assets),
558 }
559 }
560
561 fn code(&self) -> Option<&str> {
562 match &self.loaded {
563 Loaded::None => None,
564 Loaded::Project(p) => Some(&p.code),
565 Loaded::Cart { cart, .. } => cart.source.as_deref(),
566 }
567 }
568
569 fn set_code(&mut self, code: String) {
570 match &mut self.loaded {
571 Loaded::None => {}
572 Loaded::Project(p) => p.code = code,
573 Loaded::Cart { cart, .. } => cart.source = Some(code),
574 }
575 }
576
577 fn project_file_names(&self) -> Vec<String> {
578 match &self.loaded {
579 Loaded::Project(p) => p.file_names(),
580 _ => Vec::new(),
581 }
582 }
583
584 fn current_file_name(&self) -> String {
585 match &self.loaded {
586 Loaded::Project(p) => p.current.clone(),
587 _ => String::new(),
588 }
589 }
590
591 fn run_picker_action(&mut self, action: PickerAction) {
592 match action {
593 PickerAction::Switch(name) => self.select_file(&name),
594 PickerAction::Create(name) => self.create_file_in_project(&name),
595 }
596 }
597
598 fn open_file_picker(&mut self) {
601 let files = self.project_file_names();
602 let refs: Vec<&str> = files.iter().map(String::as_str).collect();
603 let current = self.current_file_name();
604 self.file_picker
605 .open(&refs, ¤t, self.previous_file.as_deref());
606 }
607
608 fn select_file(&mut self, name: &str) {
612 enum R {
613 NoOp,
614 Err(String),
615 Ok(String, PathBuf, String),
616 }
617 let r = match &mut self.loaded {
618 Loaded::Project(p) if p.current != name => {
619 let previous = p.current.clone();
620 match p.save() {
621 Err(e) => R::Err(format!("could not save {previous}: {e}")),
622 Ok(()) => match p.switch_to(name) {
623 Ok(()) => R::Ok(p.code.clone(), p.dir.join("src").join(name), previous),
624 Err(e) => R::Err(format!("{e}")),
625 },
626 }
627 }
628 _ => R::NoOp,
629 };
630 match r {
631 R::Ok(code, path, previous) => {
632 self.previous_file = Some(previous);
633 self.code_ed.set_text(&code);
634 if let Some(w) = &mut self.project_watch {
635 w.code = FileWatch::new(path, code.into_bytes());
636 w.source_tree.sync();
639 }
640 }
641 R::Err(msg) => {
642 self.say(&msg, col::RED);
643 self.toast(&msg, col::RED, 3.0);
644 }
645 R::NoOp => {}
646 }
647 }
648
649 fn create_file_in_project(&mut self, name: &str) {
651 enum R {
652 Err(String),
653 Ok(String, PathBuf, String),
654 }
655 let r = match &mut self.loaded {
656 Loaded::Project(p) => {
657 let previous = p.current.clone();
658 match p.save() {
659 Err(e) => R::Err(format!("could not save {previous}: {e}")),
660 Ok(()) => match p.create_file(name) {
661 Ok(new) => R::Ok(p.code.clone(), p.dir.join("src").join(&new), previous),
662 Err(e) => R::Err(format!("{e}")),
663 },
664 }
665 }
666 _ => return,
667 };
668 match r {
669 R::Ok(code, path, previous) => {
670 self.previous_file = Some(previous);
671 self.code_ed.set_text(&code);
672 if let Some(w) = &mut self.project_watch {
673 w.code = FileWatch::new(path, code.into_bytes());
674 w.source_tree.sync();
676 }
677 }
678 R::Err(msg) => {
679 self.say(&msg, col::RED);
680 self.toast(&msg, col::RED, 3.0);
681 }
682 }
683 }
684
685 fn cart_name(&self) -> String {
686 self.assets()
687 .map(|a| a.meta.name.clone())
688 .unwrap_or_else(|| "no cart".into())
689 }
690
691 pub fn window_title(&self) -> String {
694 match &self.loaded {
695 Loaded::None => "Pixel8".into(),
696 _ => format!("{} - Pixel8", self.cart_name()),
697 }
698 }
699
700 pub fn set_button(&mut self, b: usize, down: bool) {
706 if let Some(vm) = &mut self.vm {
707 vm.state_mut().input.set_button(b, down);
708 }
709 }
710
711 pub fn key(&mut self, key: Key, mods: Mods) {
712 if self.file_picker.is_open() {
714 let files = self.project_file_names();
715 let refs: Vec<&str> = files.iter().map(String::as_str).collect();
716 if let Some(action) = self.file_picker.key(key, mods, &refs) {
717 self.run_picker_action(action);
718 }
719 return;
720 }
721 if key == Key::ToggleStats {
723 self.show_stats = !self.show_stats;
724 return;
725 }
726 if mods.ctrl {
727 match key {
728 Key::Char('r') => {
729 self.cmd_run();
730 return;
731 }
732 Key::Char('s') => {
733 self.cmd_save_quiet();
734 return;
735 }
736 Key::Char('o') => {
737 if self.mode == Mode::Code && matches!(self.loaded, Loaded::Project(_)) {
738 self.open_file_picker();
739 }
740 return;
741 }
742 _ => {}
743 }
744 }
745
746 match self.mode {
747 Mode::Run => {
748 if key == Key::Escape {
749 self.stop_run("");
750 } else if key == Key::CaptureLabel {
751 self.capture_label();
752 }
753 }
754 Mode::Console => self.console_key(key, mods),
755 _ => self.editor_key(key, mods),
756 }
757 }
758
759 fn editor_key(&mut self, key: Key, mods: Mods) {
760 if key == Key::Escape {
761 self.mode = Mode::Console;
762 return;
763 }
764 if mods.alt {
766 let cur = EDITOR_MODES
767 .iter()
768 .position(|m| *m == self.mode)
769 .unwrap_or(0);
770 match key {
771 Key::Left => {
772 self.switch_editor(
773 EDITOR_MODES[(cur + EDITOR_MODES.len() - 1) % EDITOR_MODES.len()],
774 );
775 return;
776 }
777 Key::Right => {
778 self.switch_editor(EDITOR_MODES[(cur + 1) % EDITOR_MODES.len()]);
779 return;
780 }
781 _ => {}
782 }
783 }
784 if mods.ctrl {
787 match key {
788 Key::Char('c')
789 if matches!(
790 self.mode,
791 Mode::Sprite | Mode::Sfx | Mode::Music | Mode::Code | Mode::Map
792 ) =>
793 {
794 self.cmd_copy();
795 return;
796 }
797 Key::Char('x') if matches!(self.mode, Mode::Code | Mode::Map) => {
798 self.cmd_cut();
799 return;
800 }
801 Key::Char('v')
802 if matches!(
803 self.mode,
804 Mode::Sprite | Mode::Sfx | Mode::Music | Mode::Code | Mode::Map
805 ) =>
806 {
807 self.cmd_paste();
808 return;
809 }
810 _ => {}
811 }
812 }
813 if self.loaded_none() {
814 return;
815 }
816 let audio = self.audio.clone();
817 match self.mode {
818 Mode::Code => {
819 let mut code = self.code().unwrap_or_default().to_string();
820 self.code_ed.key(key, mods, &mut code);
821 self.set_code(code);
822 }
823 Mode::Sprite => {
824 if let Some(a) = assets_of(&mut self.loaded) {
825 self.sprite_ed.key(key, mods, a);
826 }
827 }
828 Mode::Map => {
829 if let Some(a) = assets_of(&mut self.loaded) {
830 self.map_ed.key(key, mods, a);
831 }
832 }
833 Mode::Sfx => {
834 if let Some(a) = assets_of(&mut self.loaded) {
835 self.sfx_ed.key(key, mods, a, &audio);
836 }
837 }
838 Mode::Music => {
839 if let Some(a) = assets_of(&mut self.loaded) {
840 self.music_ed.key(key, mods, a, &audio);
841 }
842 }
843 _ => {}
844 }
845 }
846
847 pub fn switch_editor(&mut self, mode: Mode) {
848 self.file_picker.close();
849 self.map_ed.cancel_drag();
852 if self.loaded_none() {
853 self.say("No cart loaded. Try: new mygame", col::RED);
854 self.mode = Mode::Console;
855 return;
856 }
857 if mode == Mode::Code {
858 let code = self.code().unwrap_or_default().to_string();
859 self.code_ed.set_text(&code);
860 }
861 self.mode = mode;
862 self.last_editor = mode;
863 }
864
865 fn loaded_none(&self) -> bool {
866 matches!(self.loaded, Loaded::None)
867 }
868
869 fn console_key(&mut self, key: Key, _mods: Mods) {
870 match key {
871 Key::Char(c) => {
872 self.input.insert(self.byte_at(self.cursor), c);
873 self.cursor += 1;
874 }
875 Key::Backspace => {
876 if self.cursor > 0 {
877 self.cursor -= 1;
878 let at = self.byte_at(self.cursor);
879 self.input.remove(at);
880 }
881 }
882 Key::Delete => {
883 if self.cursor < self.input.chars().count() {
884 let at = self.byte_at(self.cursor);
885 self.input.remove(at);
886 }
887 }
888 Key::Left => self.cursor = self.cursor.saturating_sub(1),
889 Key::Right => self.cursor = (self.cursor + 1).min(self.input.chars().count()),
890 Key::Home => self.cursor = 0,
891 Key::End => self.cursor = self.input.chars().count(),
892 Key::Up => {
893 if !self.history.is_empty() {
894 let pos = match self.history_pos {
895 None => self.history.len() - 1,
896 Some(p) => p.saturating_sub(1),
897 };
898 self.history_pos = Some(pos);
899 self.input = self.history[pos].clone();
900 self.cursor = self.input.chars().count();
901 }
902 }
903 Key::Down => {
904 if let Some(p) = self.history_pos {
905 if p + 1 < self.history.len() {
906 self.history_pos = Some(p + 1);
907 self.input = self.history[p + 1].clone();
908 } else {
909 self.history_pos = None;
910 self.input.clear();
911 }
912 self.cursor = self.input.chars().count();
913 }
914 }
915 Key::PageUp => self.scroll_back = (self.scroll_back + 5).min(self.lines.len()),
916 Key::PageDown => self.scroll_back = self.scroll_back.saturating_sub(5),
917 Key::Enter => {
918 let cmd = self.input.clone();
919 self.say(&format!("> {cmd}"), col::LIGHT_GREY);
920 if !cmd.trim().is_empty() {
921 self.history.push(cmd.clone());
922 }
923 self.history_pos = None;
924 self.input.clear();
925 self.cursor = 0;
926 self.exec(&cmd);
927 }
928 Key::Escape => {
929 if !self.loaded_none() {
930 self.switch_editor(self.last_editor);
931 }
932 }
933 Key::Tab | Key::CaptureLabel | Key::ToggleStats => {}
934 }
935 }
936
937 fn byte_at(&self, char_idx: usize) -> usize {
938 self.input
939 .char_indices()
940 .nth(char_idx)
941 .map(|(i, _)| i)
942 .unwrap_or(self.input.len())
943 }
944
945 fn exec(&mut self, cmd: &str) {
950 let parts: Vec<&str> = cmd.split_whitespace().collect();
951 let Some(&verb) = parts.first() else { return };
952 let args = &parts[1..];
953 let result = match verb.to_ascii_lowercase().as_str() {
954 "help" => {
955 self.cmd_help(args);
956 Ok(())
957 }
958 "new" => self.cmd_new(args),
959 "load" => self.cmd_load(args),
960 "reload" => self.cmd_reload(),
961 "save" => self.cmd_save(args),
962 "run" => {
963 self.cmd_run();
964 Ok(())
965 }
966 "export" => self.cmd_export(args),
967 "import" => self.cmd_import(args),
968 "import-pico8" | "importp8" => self.cmd_import_pico8(args),
969 "info" => {
970 self.cmd_info();
971 Ok(())
972 }
973 "ls" | "dir" => self.cmd_ls(),
974 "cls" => {
975 self.lines.clear();
976 Ok(())
977 }
978 "title" => self.cmd_meta(args, |a, v| a.meta.name = v),
979 "author" => self.cmd_meta(args, |a, v| a.meta.author = v),
980 "code" => {
981 self.switch_editor(Mode::Code);
982 Ok(())
983 }
984 "sprite" | "gfx" => {
985 self.switch_editor(Mode::Sprite);
986 Ok(())
987 }
988 "map" => {
989 self.switch_editor(Mode::Map);
990 Ok(())
991 }
992 "sfx" => {
993 self.switch_editor(Mode::Sfx);
994 Ok(())
995 }
996 "music" => {
997 self.switch_editor(Mode::Music);
998 Ok(())
999 }
1000 "keys" => {
1001 self.cmd_keys();
1002 Ok(())
1003 }
1004 "reboot" => {
1005 self.vm = None;
1006 self.audio.stop_all();
1007 self.loaded = Loaded::None;
1008 self.lines.clear();
1009 self.boot();
1010 Ok(())
1011 }
1012 "exit" | "quit" | "shutdown" => {
1013 self.want_exit = true;
1014 Ok(())
1015 }
1016 other => Err(anyhow!("Syntax error: {other}\nType help for help")),
1017 };
1018 if let Err(e) = result {
1019 self.say(&e.to_string(), col::RED);
1020 }
1021 }
1022
1023 fn cmd_help(&mut self, args: &[&str]) {
1024 if args.first() == Some(&"keys") {
1025 self.cmd_keys();
1026 return;
1027 }
1028 for (c, d) in [
1029 ("new <name>", "Create a project"),
1030 ("load <dir|cart.png>", "Load a cart"),
1031 ("reload", "Re-read from disk, drop edits"),
1032 ("save", "Save project to disk"),
1033 ("run", "Build + run (esc stops)"),
1034 ("export <f.png|f.html>", "Export cart (PNG or web)"),
1035 ("import <f.png> <dir>", "Cart -> project"),
1036 ("import-pico8 <f> [dir]", "PICO-8 cart -> new project"),
1037 (
1038 "import-pico8 <f> --into ...",
1039 "Append assets to loaded project",
1040 ),
1041 ("info", "Cart metadata"),
1042 ("title/author <text>", "Set metadata"),
1043 ("code/sprite/map/sfx/music", "Editors (esc)"),
1044 ("ls, cls, keys, reboot, exit", ""),
1045 ] {
1046 self.say(c, col::WHITE);
1047 if !d.is_empty() {
1048 self.say(&format!(" {d}"), col::LIGHT_GREY);
1049 }
1050 }
1051 }
1052
1053 fn cmd_keys(&mut self) {
1054 for (k, d) in [
1055 ("esc", "Console <-> editor / stop"),
1056 ("ctrl+r", "Run cart"),
1057 ("ctrl+s", "Save + build check"),
1058 ("ctrl+z / ctrl+y", "Undo / redo (in editors)"),
1059 ("alt+left/right", "Switch editor"),
1060 ("arrows + z/x", "Game buttons"),
1061 ("f1", "Toggle resource stats"),
1062 ("f6", "Capture label (running)"),
1063 ] {
1064 self.say(&format!("{k:14} {d}"), col::LIGHT_GREY);
1065 }
1066 }
1067
1068 fn cmd_new(&mut self, args: &[&str]) -> Result<()> {
1069 let Some(name) = args.first() else {
1070 bail!("Usage: new <name>");
1071 };
1072 let dir = self.cwd.join(name);
1073 let project = Project::create(&dir, name)?;
1074 self.say(&format!("Created ./{name}"), col::GREEN);
1075 self.code_ed.set_text(&project.code);
1076 self.project_watch = Some(ProjectWatch::new(&project));
1077 self.cart_watch = None;
1078 self.loaded = Loaded::Project(project);
1079 Ok(())
1080 }
1081
1082 pub fn startup_load(&mut self, path: &str) {
1084 if let Err(e) = self.cmd_load(&[path]) {
1085 self.say(&e.to_string(), col::RED);
1086 }
1087 }
1088
1089 pub fn startup_run(&mut self) {
1093 self.cmd_run();
1094 }
1095
1096 fn cmd_load(&mut self, args: &[&str]) -> Result<()> {
1097 let Some(path) = args.first() else {
1098 bail!("Usage: load <dir|cart.png>");
1099 };
1100 let path = self.cwd.join(path);
1101 if path.extension().is_some_and(|e| e == "png") {
1102 let cart = cart::load_png(&path)?;
1103 let name = cart.assets.meta.name.clone();
1104 let has_src = cart.source.is_some();
1105 self.code_ed.set_text(
1106 cart.source
1107 .as_deref()
1108 .unwrap_or("// No source in this cart"),
1109 );
1110 self.project_watch = None;
1111 let cart_baseline = encode_assets(&cart.assets).unwrap_or_default();
1112 self.cart_watch = Some(CartWatch::new(path.clone(), cart_baseline));
1113 self.loaded = Loaded::Cart { cart, path };
1114 self.say(&format!("Loaded cart: {name}"), col::GREEN);
1115 if !has_src {
1116 self.say("(Playable cart, no source)", col::LIGHT_GREY);
1117 }
1118 } else {
1119 let project = Project::load(&path)?;
1120 self.code_ed.set_text(&project.code);
1121 self.say(&format!("Loaded {}", project.name), col::GREEN);
1122 self.project_watch = Some(ProjectWatch::new(&project));
1123 self.cart_watch = None;
1124 self.loaded = Loaded::Project(project);
1125 }
1126 Ok(())
1127 }
1128
1129 fn cmd_reload(&mut self) -> Result<()> {
1132 let path = match &self.loaded {
1133 Loaded::None => bail!("Nothing loaded"),
1134 Loaded::Project(p) => p.dir.clone(),
1135 Loaded::Cart { path, .. } => path.clone(),
1136 };
1137 let path_str = path.to_string_lossy().into_owned();
1138 self.cmd_load(&[&path_str])
1139 }
1140
1141 fn cmd_save(&mut self, _args: &[&str]) -> Result<()> {
1142 let message = match &mut self.loaded {
1143 Loaded::None => bail!("Nothing to save"),
1144 Loaded::Project(p) => {
1145 p.save()?;
1146 "Saved".to_string()
1147 }
1148 Loaded::Cart { cart, path } => {
1149 cart::save_png(cart, path)?;
1150 format!("Saved {}", path.display())
1151 }
1152 };
1153 if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
1155 w.sync(p);
1156 }
1157 if let (Loaded::Cart { cart, .. }, Some(w)) = (&self.loaded, &mut self.cart_watch) {
1159 w.mark_synced(encode_assets(&cart.assets).unwrap_or_default());
1160 }
1161 self.say(&message, col::GREEN);
1162 Ok(())
1163 }
1164
1165 fn cmd_paste(&mut self) {
1168 let text = match crate::clipboard::read_text() {
1170 Ok(t) => t,
1171 Err(_) => return self.set_editor_status("no text on clipboard".into()),
1172 };
1173 if self.mode == Mode::Code {
1174 if self.code().is_none() {
1175 return self.set_editor_status("load a project first".into());
1176 }
1177 let mut code = self.code().unwrap_or_default().to_string();
1178 self.code_ed.paste_text(&mut code, &text);
1179 self.set_code(code);
1180 return;
1181 }
1182 match pixel8_runtime::clipboard::parse(&text) {
1183 Ok(pasted) => self.apply_paste(pasted),
1184 Err(_) => self.set_editor_status("nothing to paste".into()),
1185 }
1186 }
1187
1188 fn cmd_copy(&mut self) {
1190 let blob = match self.mode {
1191 Mode::Code => {
1192 if self.code().is_none() {
1193 return self.set_editor_status("load a project first".into());
1194 }
1195 let code = self.code().unwrap_or_default().to_string();
1196 self.code_ed.copy(&code)
1197 }
1198 Mode::Sprite | Mode::Sfx | Mode::Music | Mode::Map => {
1199 let Some(a) = assets_of(&mut self.loaded) else {
1200 return self.set_editor_status("load a project first".into());
1201 };
1202 match self.mode {
1203 Mode::Sprite => Some(self.sprite_ed.copy(a)),
1204 Mode::Sfx => Some(self.sfx_ed.copy(a)),
1205 Mode::Music => Some(self.music_ed.copy(a)),
1206 Mode::Map => self.map_ed.copy_selection(a, false),
1207 _ => unreachable!(),
1208 }
1209 }
1210 _ => None,
1211 };
1212 if let Some(text) = blob {
1213 if crate::clipboard::write_text(&text).is_err() {
1214 self.set_editor_status("clipboard unavailable".into());
1215 }
1216 }
1217 }
1218
1219 fn cmd_cut(&mut self) {
1221 let blob = match self.mode {
1222 Mode::Code => {
1223 if self.code().is_none() {
1224 return self.set_editor_status("load a project first".into());
1225 }
1226 let mut code = self.code().unwrap_or_default().to_string();
1227 let cut = self.code_ed.cut(&mut code);
1228 if cut.is_some() {
1229 self.set_code(code);
1230 }
1231 cut
1232 }
1233 Mode::Map => {
1234 let Some(a) = assets_of(&mut self.loaded) else {
1235 return self.set_editor_status("load a project first".into());
1236 };
1237 self.map_ed.copy_selection(a, true)
1238 }
1239 _ => None,
1240 };
1241 if let Some(text) = blob {
1242 if crate::clipboard::write_text(&text).is_err() {
1243 self.set_editor_status("clipboard unavailable".into());
1244 }
1245 }
1246 }
1247
1248 fn apply_paste(&mut self, pasted: Pasted) {
1251 let Some(a) = assets_of(&mut self.loaded) else {
1252 return self.set_editor_status("load a project first".into());
1253 };
1254 match self.mode {
1255 Mode::Sprite => self.sprite_ed.paste(&pasted, a),
1256 Mode::Sfx => self.sfx_ed.paste(&pasted, a),
1257 Mode::Music => self.music_ed.paste(&pasted, a),
1258 Mode::Map => self.map_ed.paste(&pasted),
1259 _ => {}
1260 }
1261 }
1262
1263 fn set_editor_status(&mut self, msg: String) {
1265 match self.mode {
1266 Mode::Sprite => self.sprite_ed.set_status(msg),
1267 Mode::Sfx => self.sfx_ed.set_status(msg),
1268 Mode::Music => self.music_ed.set_status(msg),
1269 Mode::Code => self.code_ed.set_status(msg),
1270 Mode::Map => self.map_ed.set_status(msg),
1271 _ => {}
1272 }
1273 }
1274
1275 fn cmd_save_quiet(&mut self) {
1279 if let Err(e) = self.cmd_save(&[]) {
1280 let msg = e.to_string();
1281 self.say(&msg, col::RED);
1282 self.toast(&msg, col::RED, 3.0);
1283 return;
1284 }
1285 self.toast("Saved", col::GREEN, 1.5);
1286 if self.build.is_none() {
1287 if let Loaded::Project(p) = &self.loaded {
1288 let dir = p.dir.clone();
1289 self.build = Some(spawn_build(&dir));
1290 self.run_after_build = false;
1291 }
1292 }
1293 }
1294
1295 pub fn cmd_run(&mut self) {
1296 self.audio.stop_all();
1297 self.vm = None;
1298 match &self.loaded {
1299 Loaded::None => self.say("No cart loaded", col::RED),
1300 Loaded::Cart { .. } => match self.start_vm_from_loaded() {
1301 Ok(()) => {}
1302 Err(e) => self.show_error("boot", &e.to_string()),
1303 },
1304 Loaded::Project(p) => {
1305 let dir = p.dir.clone();
1306 if self.build.is_some() {
1307 self.say("Already compiling...", col::ORANGE);
1308 self.toast("Already building...", col::ORANGE, 1.5);
1309 return;
1310 }
1311 if !self.reconcile_for_build() {
1314 self.say("Disk & editor both changed", col::ORANGE);
1315 self.say("Save or reload to resolve", col::ORANGE);
1316 self.toast("Conflict: save or reload", col::ORANGE, 3.0);
1317 return;
1318 }
1319 self.mode = Mode::Console;
1320 self.say("Compiling...", col::LIGHT_GREY);
1321 self.build = Some(spawn_build(&dir));
1322 self.run_after_build = true;
1323 }
1324 }
1325 }
1326
1327 fn reconcile_for_build(&mut self) -> bool {
1331 let Loaded::Project(_) = &self.loaded else {
1332 return true;
1333 };
1334 let (code_mem, assets_mem) = match &self.loaded {
1336 Loaded::Project(p) => (
1337 p.code.clone().into_bytes(),
1338 encode_assets(&p.assets).unwrap_or_default(),
1339 ),
1340 _ => return true,
1341 };
1342 let Some(w) = &mut self.project_watch else {
1343 return true;
1344 };
1345 let code_change = w.code.poll(&code_mem);
1347 let assets_change = w.assets.poll(&assets_mem);
1348 w.source_tree.poll();
1351 if matches!(code_change, FileChange::Conflict)
1356 || matches!(assets_change, FileChange::Conflict)
1357 || w.code.in_conflict()
1358 || w.assets.in_conflict()
1359 {
1360 return false;
1361 }
1362 if let FileChange::Adopt(bytes) = code_change {
1363 let text = String::from_utf8_lossy(&bytes).into_owned();
1364 self.code_ed.set_text(&text);
1365 if let Loaded::Project(p) = &mut self.loaded {
1366 p.code = text;
1367 }
1368 }
1369 if let FileChange::Adopt(bytes) = assets_change {
1370 match decode_assets(&bytes) {
1371 Ok(assets) => {
1372 if let Loaded::Project(p) = &mut self.loaded {
1373 p.assets = assets;
1374 }
1375 }
1376 Err(_) => self.resync_assets_watcher(),
1379 }
1380 }
1381 let needs_flush = match (&self.loaded, &self.project_watch) {
1384 (Loaded::Project(p), Some(w)) => {
1385 p.code.as_bytes() != w.code.baseline()
1386 || encode_assets(&p.assets).unwrap_or_default() != w.assets.baseline()
1387 }
1388 _ => false,
1389 };
1390 if needs_flush {
1391 if let Loaded::Project(p) = &self.loaded {
1392 let _ = p.save();
1393 }
1394 if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
1395 w.sync(p);
1396 }
1397 }
1398 true
1399 }
1400
1401 fn resync_assets_watcher(&mut self) {
1405 if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
1406 w.assets
1407 .mark_synced(encode_assets(&p.assets).unwrap_or_default());
1408 }
1409 }
1410
1411 fn start_vm_from_loaded(&mut self) -> Result<()> {
1412 self.vm = None;
1415 let (wasm, assets) = match &self.loaded {
1416 Loaded::None => bail!("No cart loaded"),
1417 Loaded::Cart { cart, .. } => (cart.wasm.clone(), cart.assets.clone()),
1418 Loaded::Project(p) => {
1419 let path = p.wasm_path();
1420 let wasm = std::fs::read(&path)
1421 .map_err(|_| anyhow!("Cart not built yet ({})", path.display()))?;
1422 self.wasm_mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
1423 (wasm, p.assets.clone())
1424 }
1425 };
1426 let storage = match &self.storage_root {
1427 Some(root) => Storage::for_cart_in(root, &assets.meta.name),
1428 None => Storage::for_cart(&assets.meta.name),
1429 };
1430 let vm = GameVm::load(&wasm, &assets, self.audio.clone(), storage)?;
1431 self.vm = Some(vm);
1432 self.stats_meter.reset();
1435 self.mode = Mode::Run;
1436 Ok(())
1437 }
1438
1439 fn cmd_export(&mut self, args: &[&str]) -> Result<()> {
1440 let mut include_source = true;
1441 let mut controls = true;
1442 let mut file = None;
1443 for a in args {
1444 match *a {
1445 "-nosrc" => include_source = false,
1446 "-noctrl" => controls = false,
1447 _ => file = Some(*a),
1448 }
1449 }
1450 let file = file
1451 .map(|f| f.to_string())
1452 .unwrap_or_else(|| format!("{}.png", self.cart_name()));
1453 let out = self.cwd.join(&file);
1454 if file.ends_with(".html") {
1455 let cart = self.make_cart(false)?;
1457 self.say("Exporting for web...", col::LIGHT_GREY);
1458 let web_dir = crate::webexport::web_crate_dir(&self.sdk_path);
1459 crate::webexport::export_html(&cart, &out, &web_dir, controls)?;
1460 } else {
1461 let cart = self.make_cart(include_source)?;
1462 cart::save_png(&cart, &out)?;
1463 }
1464 self.say(&format!("Exported {file}"), col::GREEN);
1465 Ok(())
1466 }
1467
1468 fn make_cart(&self, include_source: bool) -> Result<Cart> {
1469 match &self.loaded {
1470 Loaded::None => bail!("No cart loaded"),
1471 Loaded::Cart { cart, .. } => Ok(Cart {
1472 wasm: cart.wasm.clone(),
1473 assets: cart.assets.clone(),
1474 source: if include_source {
1475 cart.source.clone()
1476 } else {
1477 None
1478 },
1479 }),
1480 Loaded::Project(p) => {
1481 let wasm = std::fs::read(p.wasm_path())
1482 .map_err(|_| anyhow!("Cart not built yet. Type run first"))?;
1483 Ok(Cart {
1484 wasm,
1485 assets: p.assets.clone(),
1486 source: include_source.then(|| p.lib_source()),
1487 })
1488 }
1489 }
1490 }
1491
1492 fn cmd_import(&mut self, args: &[&str]) -> Result<()> {
1493 let (Some(png), Some(dir)) = (args.first(), args.get(1)) else {
1494 bail!("Usage: import <cart.png> <dir>");
1495 };
1496 let cart = cart::load_png(&self.cwd.join(png))?;
1497 let Some(source) = &cart.source else {
1498 bail!("Cart has no source (playable-only)");
1499 };
1500 let dir = self.cwd.join(dir);
1501 let mut project = Project::create(&dir, &cart.assets.meta.name)?;
1502 project.code = source.clone();
1503 project.assets = cart.assets.clone();
1504 project.save()?;
1505 self.say(&format!("Imported into {}", dir.display()), col::GREEN);
1506 self.code_ed.set_text(&project.code);
1507 self.loaded = Loaded::Project(project);
1508 if let Loaded::Project(p) = &self.loaded {
1509 self.project_watch = Some(ProjectWatch::new(p));
1510 }
1511 self.cart_watch = None;
1512 Ok(())
1513 }
1514
1515 fn cmd_import_pico8(&mut self, args: &[&str]) -> Result<()> {
1518 if args.contains(&"--into") {
1519 return self.cmd_import_pico8_into(args);
1520 }
1521 let Some(src) = args.first() else {
1522 bail!("Usage: import-pico8 <cart.p8|cart.p8.png> [dir]");
1523 };
1524 let src = self.cwd.join(src);
1525 let dir = match args.get(1) {
1527 Some(d) => self.cwd.join(d),
1528 None => self.cwd.join(pixel8_runtime::pico8::default_dir_name(&src)),
1529 };
1530 let project = pixel8_runtime::pico8::import_project(&src, &dir)?;
1531 self.say(
1532 &format!("Imported assets into {}", dir.display()),
1533 col::GREEN,
1534 );
1535 self.code_ed.set_text(&project.code);
1536 self.loaded = Loaded::Project(project);
1537 if let Loaded::Project(p) = &self.loaded {
1538 self.project_watch = Some(ProjectWatch::new(p));
1539 }
1540 self.cart_watch = None;
1541 Ok(())
1542 }
1543
1544 fn cmd_import_pico8_into(&mut self, args: &[&str]) -> Result<()> {
1547 let (mut src, mut sprites, mut sfx, mut music) = (None, None, None, None);
1548 let mut it = args.iter();
1549 while let Some(&a) = it.next() {
1550 match a {
1551 "--into" => {} "--sprites" => sprites = Some(flag_value(it.next(), "--sprites")?),
1553 "--sfx" => sfx = Some(flag_value(it.next(), "--sfx")?),
1554 "--music" => music = Some(flag_value(it.next(), "--music")?),
1555 flag if flag.starts_with("--") => bail!("unknown flag {flag}"),
1556 pos if src.is_none() => src = Some(pos),
1557 pos => bail!("unexpected argument {pos}"),
1558 }
1559 }
1560 let Some(src) = src else {
1561 bail!("Usage: import-pico8 <cart> --into [--sprites R] [--sfx R] [--music R]");
1562 };
1563 let sel = pixel8_runtime::pico8::Selection::parse(sprites, sfx, music)?;
1564
1565 let report = {
1566 let Loaded::Project(project) = &mut self.loaded else {
1567 bail!("import-pico8 --into needs a project loaded; use `new` or `load` first");
1568 };
1569 let assets = pixel8_runtime::pico8::parse_file(&self.cwd.join(src))?;
1570 let report =
1571 pixel8_runtime::pico8::append_pico8_assets(&mut project.assets, &assets, &sel)?;
1572 project.save()?;
1573 report
1574 };
1575 if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
1578 w.sync(p);
1579 }
1580 for line in report.summary_lines() {
1581 self.say(&format!("Imported {line}"), col::GREEN);
1582 }
1583 for w in &report.warnings {
1584 self.say(w, col::YELLOW);
1585 }
1586 Ok(())
1587 }
1588
1589 fn cmd_info(&mut self) {
1590 match self.assets() {
1591 None => self.say("No cart loaded", col::RED),
1592 Some(a) => {
1593 let (name, author, version) = (
1594 a.meta.name.clone(),
1595 a.meta.author.clone(),
1596 a.meta.version.clone(),
1597 );
1598 let label = if a.label.is_some() {
1599 "Captured"
1600 } else {
1601 "Default"
1602 };
1603 let kind = match &self.loaded {
1604 Loaded::Project(p) => format!("Project {}", p.dir.display()),
1605 Loaded::Cart { path, .. } => format!("Cart {}", path.display()),
1606 Loaded::None => unreachable!(),
1607 };
1608 self.say(&format!("Title: {name}"), col::WHITE);
1609 self.say(&format!("Author: {author}"), col::WHITE);
1610 self.say(&format!("Version: {version}"), col::WHITE);
1611 self.say(&format!("Label: {label}"), col::LIGHT_GREY);
1612 self.say(&kind, col::LIGHT_GREY);
1613 }
1614 }
1615 }
1616
1617 fn cmd_ls(&mut self) -> Result<()> {
1618 let mut entries: Vec<_> = std::fs::read_dir(&self.cwd)?
1619 .filter_map(|e| e.ok())
1620 .map(|e| {
1621 let name = e.file_name().to_string_lossy().into_owned();
1622 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
1623 (name, is_dir)
1624 })
1625 .filter(|(n, _)| !n.starts_with('.'))
1626 .collect();
1627 entries.sort();
1628 for (name, is_dir) in entries.into_iter().take(40) {
1629 if is_dir {
1630 self.say(&format!("{name}/"), col::BLUE);
1631 } else if name.ends_with(".png") {
1632 self.say(&name, col::PINK);
1633 } else {
1634 self.say(&name, col::LIGHT_GREY);
1635 }
1636 }
1637 Ok(())
1638 }
1639
1640 fn cmd_meta(&mut self, args: &[&str], set: impl FnOnce(&mut Assets, String)) -> Result<()> {
1641 if args.is_empty() {
1642 bail!("Missing text");
1643 }
1644 let value = args.join(" ");
1645 match self.assets_mut() {
1646 None => bail!("No cart loaded"),
1647 Some(a) => {
1648 set(a, value);
1649 self.say("OK", col::GREEN);
1650 Ok(())
1651 }
1652 }
1653 }
1654
1655 fn capture_label(&mut self) {
1656 let Some(vm) = &self.vm else { return };
1657 let pixels = vm.state().fb.pixels().to_vec();
1658 if let Some(a) = self.assets_mut() {
1659 a.label = Some(pixels);
1660 }
1661 self.capture_flash = CAPTURE_FLASH_FRAMES;
1663 self.say("Label captured", col::GREEN);
1664 }
1665
1666 fn stop_run(&mut self, message: &str) {
1667 self.vm = None;
1668 self.audio.stop_all();
1669 self.mode = Mode::Console;
1670 if !message.is_empty() {
1671 self.say(message, col::RED);
1672 }
1673 }
1674
1675 fn show_error(&mut self, phase: &str, message: &str) {
1676 self.stop_run("");
1677 self.say("", col::WHITE);
1678 self.say(&format!("** Error in {phase} **"), col::RED);
1679 for line in message.lines().take(12) {
1680 self.say(line, col::ORANGE);
1681 }
1682 }
1683
1684 fn runtime_error(&mut self, e: RuntimeError) {
1685 self.show_error(e.phase, &e.message);
1686 }
1687
1688 pub fn tick_fps(&self) -> u32 {
1696 match (self.mode, &self.vm) {
1697 (Mode::Run, Some(vm)) => vm.fps(),
1698 _ => UI_FPS,
1699 }
1700 }
1701
1702 pub fn tick(&mut self) {
1703 self.frame += 1;
1704
1705 if let Some(job) = &self.build {
1707 if let Some(result) = job.poll() {
1708 self.build = None;
1709 if result.success {
1710 let msg = format!("Build ok ({:.1}s)", result.duration.as_secs_f32());
1711 self.say(&msg, col::GREEN);
1712 self.toast(&msg, col::GREEN, 2.0);
1713 for w in &result.warnings {
1714 self.say(w, col::ORANGE);
1715 }
1716 if self.run_after_build {
1717 self.run_after_build = false;
1718 if let Err(e) = self.start_vm_from_loaded() {
1719 self.show_error("boot", &e.to_string());
1720 }
1721 }
1722 } else {
1723 self.run_after_build = false;
1724 self.stop_run("");
1731 let n = result
1732 .errors
1733 .iter()
1734 .filter(|l| l.starts_with("error"))
1735 .count();
1736 self.say(&format!("Build failed ({n} errors)"), col::RED);
1737 for line in &result.errors {
1738 let color = if line.starts_with("error") {
1739 col::RED
1740 } else {
1741 col::ORANGE
1742 };
1743 self.say(line, color);
1744 }
1745 }
1746 }
1747 }
1748
1749 self.poll_project_watch();
1750 self.poll_cart_watch();
1751 self.check_hot_reload();
1752
1753 match self.mode {
1754 Mode::Run => {
1755 if self.vm.is_some() {
1756 let fps_val = self.fps_val;
1757 let vm = self.vm.as_mut().unwrap();
1758 vm.state_mut().set_measured_fps(fps_val);
1759 let logs = std::mem::take(&mut vm.state_mut().logs);
1760 let result = vm.call_update().and_then(|()| vm.call_draw());
1761 let (cpu_u, cpu_d) = (vm.cpu_update(), vm.cpu_draw());
1762 self.stats_meter
1768 .sample(Instant::now(), cpu_u, cpu_d, || vm.mem_used_bytes());
1769 for l in logs {
1770 self.say(&l, col::LIGHT_GREY);
1771 }
1772 if let Err(e) = result {
1773 self.runtime_error(e);
1774 }
1775 } else {
1776 self.mode = Mode::Console;
1777 }
1778 }
1779 Mode::Console => {}
1780 _ => {
1781 if !self.file_picker.is_open() {
1783 if let Some(target) = ui::tab_bar_click(&self.mouse) {
1784 self.switch_editor(EDITOR_MODES[target]);
1785 }
1786 }
1787 let mouse = self.mouse;
1788 let audio = self.audio.clone();
1789 match self.mode {
1790 Mode::Code => {
1791 if self.file_picker.is_open() {
1792 let files = self.project_file_names();
1793 let refs: Vec<&str> = files.iter().map(String::as_str).collect();
1794 if let Some(action) = self.file_picker.tick(&mouse, &refs) {
1795 self.run_picker_action(action);
1796 }
1797 } else if !self.loaded_none() {
1798 if ui::filename_clicked(&mouse, &self.current_file_name())
1800 && matches!(self.loaded, Loaded::Project(_))
1801 {
1802 self.open_file_picker();
1803 } else {
1804 let code = self.code().unwrap_or_default().to_string();
1805 self.code_ed.tick(&mouse, &code);
1806 }
1807 }
1808 }
1809 Mode::Sprite => {
1810 if let Some(a) = assets_of(&mut self.loaded) {
1811 self.sprite_ed.tick(&mouse, a);
1812 }
1813 }
1814 Mode::Map => {
1815 if let Some(a) = assets_of(&mut self.loaded) {
1816 self.map_ed.tick(&mouse, a);
1817 }
1818 }
1819 Mode::Sfx => {
1820 if let Some(a) = assets_of(&mut self.loaded) {
1821 self.sfx_ed.tick(&mouse, a, &audio);
1822 }
1823 }
1824 Mode::Music => {
1825 if let Some(a) = assets_of(&mut self.loaded) {
1826 self.music_ed.tick(&mouse, a, &audio);
1827 }
1828 if let Some(n) = self.music_ed.take_edit_request() {
1830 self.sfx_ed.select(n);
1831 self.switch_editor(Mode::Sfx);
1832 }
1833 }
1834 _ => {}
1835 }
1836 }
1837 }
1838 self.mouse.end_frame();
1839 }
1840
1841 fn check_hot_reload(&mut self) {
1842 if !self.frame.is_multiple_of(30) {
1843 return;
1844 }
1845 let Loaded::Project(p) = &self.loaded else {
1846 return;
1847 };
1848 let Ok(meta) = std::fs::metadata(p.wasm_path()) else {
1849 return;
1850 };
1851 let Ok(mtime) = meta.modified() else {
1852 return;
1853 };
1854 match self.wasm_mtime {
1855 Some(prev) if mtime > prev => {
1856 self.wasm_mtime = Some(mtime);
1857 if self.mode == Mode::Run {
1860 match self.start_vm_from_loaded() {
1861 Ok(()) => self.say("Hot reloaded", col::GREEN),
1862 Err(e) => self.show_error("reload", &e.to_string()),
1863 }
1864 }
1865 }
1866 Some(_) => {}
1867 None => self.wasm_mtime = Some(mtime),
1868 }
1869 }
1870
1871 fn poll_project_watch(&mut self) {
1875 if !self.frame.is_multiple_of(30) || self.build.is_some() {
1876 return;
1877 }
1878 let (code_mem, assets_mem) = match &self.loaded {
1879 Loaded::Project(p) => (
1880 p.code.clone().into_bytes(),
1881 encode_assets(&p.assets).unwrap_or_default(),
1882 ),
1883 _ => return,
1884 };
1885 let Some(w) = &mut self.project_watch else {
1886 return;
1887 };
1888 let assets_change = w.assets.poll(&assets_mem);
1889 let code_change = w.code.poll(&code_mem);
1890 let source_changed = w.source_tree.poll();
1891 let code_conflicted = w.code.in_conflict();
1892
1893 match assets_change {
1895 FileChange::Adopt(bytes) => match decode_assets(&bytes) {
1896 Ok(assets) => {
1897 if let Loaded::Project(p) = &mut self.loaded {
1898 p.assets = assets;
1899 }
1900 self.say("Assets reloaded from disk", col::GREEN);
1901 if self.mode == Mode::Run {
1902 if let Err(e) = self.start_vm_from_loaded() {
1903 self.show_error("reload", &e.to_string());
1904 }
1905 }
1906 }
1907 Err(_) => {
1909 self.resync_assets_watcher();
1910 self.say("assets.pixel8.json on disk is unreadable", col::ORANGE);
1911 }
1912 },
1913 FileChange::Conflict => {
1914 self.say("assets.pixel8.json changed on disk;", col::ORANGE);
1915 self.say("You have unsaved edits", col::ORANGE);
1916 }
1917 FileChange::None => {}
1918 }
1919
1920 match code_change {
1922 FileChange::Adopt(bytes) => {
1923 let text = String::from_utf8_lossy(&bytes).into_owned();
1924 self.code_ed.set_text(&text);
1925 if let Loaded::Project(p) = &mut self.loaded {
1926 p.code = text;
1927 }
1928 }
1929 FileChange::Conflict => {
1930 self.say(
1931 &format!("src/{} changed on disk;", self.current_file_name()),
1932 col::ORANGE,
1933 );
1934 self.say("Save or reload to resolve", col::ORANGE);
1935 }
1936 FileChange::None => {}
1937 }
1938
1939 if source_changed && !code_conflicted {
1943 let dir = match &self.loaded {
1944 Loaded::Project(p) => p.dir.clone(),
1945 _ => return,
1946 };
1947 self.say("Source changed, rebuilding...", col::LIGHT_GREY);
1948 self.toast("Rebuilding...", col::LIGHT_GREY, 1.5);
1949 self.build = Some(spawn_build(&dir));
1950 self.run_after_build = matches!(self.mode, Mode::Run | Mode::Console);
1953 }
1954 }
1955
1956 fn poll_cart_watch(&mut self) {
1959 if !self.frame.is_multiple_of(30) {
1960 return;
1961 }
1962 let (path, in_memory, baseline) = match (&self.loaded, &mut self.cart_watch) {
1963 (Loaded::Cart { cart, .. }, Some(w)) => {
1964 if w.advanced().is_none() {
1965 return;
1966 }
1967 (
1968 w.path.clone(),
1969 encode_assets(&cart.assets).unwrap_or_default(),
1970 w.baseline.clone(),
1971 )
1972 }
1973 _ => return,
1974 };
1975 let new_cart = match cart::load_png(&path) {
1976 Ok(c) => c,
1977 Err(e) => {
1978 self.show_error("reload", &e.to_string());
1979 return;
1980 }
1981 };
1982 let disk = encode_assets(&new_cart.assets).unwrap_or_default();
1983 match crate::watch::reconcile(&baseline, &disk, &in_memory) {
1984 crate::watch::Reconcile::Unchanged => {}
1985 crate::watch::Reconcile::Adopt(_) => {
1986 self.code_ed.set_text(
1987 new_cart
1988 .source
1989 .as_deref()
1990 .unwrap_or("// No source in this cart"),
1991 );
1992 if let Some(w) = &mut self.cart_watch {
1993 w.baseline = disk;
1994 }
1995 self.loaded = Loaded::Cart {
1996 cart: new_cart,
1997 path,
1998 };
1999 self.say("Cart reloaded from disk", col::GREEN);
2000 if self.mode == Mode::Run {
2001 if let Err(e) = self.start_vm_from_loaded() {
2002 self.show_error("reload", &e.to_string());
2003 }
2004 }
2005 }
2006 crate::watch::Reconcile::Conflict => {
2007 self.say("Cart changed on disk;", col::ORANGE);
2010 self.say("You have unsaved edits", col::ORANGE);
2011 }
2012 }
2013 }
2014
2015 fn meter_fps(&mut self) {
2024 self.fps_frames += 1;
2025 let elapsed = self.fps_t0.elapsed();
2026 if elapsed >= Duration::from_millis(500) {
2027 self.fps_val = self.fps_frames as f32 / elapsed.as_secs_f32();
2028 self.fps_frames = 0;
2029 self.fps_t0 = Instant::now();
2030 }
2031 }
2032
2033 pub fn draw(&mut self) -> &Framebuffer {
2034 self.meter_fps();
2035 match self.mode {
2036 Mode::Run => {
2037 if self.show_stats {
2038 let fps = self.fps_val;
2039 let cpu_u = self.stats_meter.cpu_update();
2042 let cpu_d = self.stats_meter.cpu_draw();
2043 let used = self.stats_meter.mem_used();
2044 if let Some(vm) = self.vm.as_mut() {
2045 let target = vm.fps();
2046 stats_overlay(&mut vm.state_mut().fb, cpu_u, cpu_d, used, fps, target);
2047 }
2048 }
2049 if self.capture_flash > 0 {
2050 if let Some(vm) = self.vm.as_mut() {
2051 capture_flash_overlay(&mut vm.state_mut().fb);
2052 }
2053 self.capture_flash -= 1;
2054 }
2055 if let Some(vm) = &self.vm {
2056 return &vm.state().fb;
2057 }
2058 &self.fb
2059 }
2060 Mode::Console => {
2061 self.draw_console();
2062 &self.fb
2063 }
2064 _ => {
2065 self.fb.reset_state();
2066 self.fb.cls(col::DARK_GREY);
2067 let mouse = self.mouse;
2068 match self.mode {
2069 Mode::Code => {
2070 let code = self.code().unwrap_or_default().to_string();
2071 self.code_ed.draw(&mut self.fb, &code);
2072 if self.file_picker.is_open() {
2073 let files = self.project_file_names();
2074 let refs: Vec<&str> = files.iter().map(String::as_str).collect();
2075 let current = self.current_file_name();
2076 self.file_picker.draw(&mut self.fb, &refs, ¤t);
2077 }
2078 }
2079 Mode::Sprite => {
2080 if let Some(a) = assets_ref(&self.loaded) {
2081 self.sprite_ed.draw(&mut self.fb, a);
2082 }
2083 }
2084 Mode::Map => {
2085 if let Some(a) = assets_ref(&self.loaded) {
2086 self.map_ed.draw(&mut self.fb, a);
2087 }
2088 }
2089 Mode::Sfx => {
2090 if let Some(a) = assets_ref(&self.loaded) {
2091 self.sfx_ed.draw(&mut self.fb, a, &self.audio);
2092 }
2093 }
2094 Mode::Music => {
2095 if let Some(a) = assets_ref(&self.loaded) {
2096 self.music_ed.draw(&mut self.fb, a, &self.audio);
2097 }
2098 }
2099 _ => {}
2100 }
2101 ui::draw_tab_bar(&mut self.fb, self.mode);
2102 match self.mode {
2105 Mode::Code => {
2106 let name = self.current_file_name();
2107 ui::code_filename(&mut self.fb, &name);
2108 }
2109 Mode::Sfx => ui::mode_buttons(&mut self.fb, self.sfx_ed.is_pitch()),
2110 Mode::Sprite => ui::view_buttons(&mut self.fb, self.sprite_ed.is_fullscreen()),
2111 Mode::Map => ui::view_buttons(&mut self.fb, self.map_ed.is_fullscreen()),
2112 Mode::Music => {
2113 ui::mode_buttons(&mut self.fb, !self.music_ed.is_grid());
2114 if self.music_ed.is_grid() {
2115 ui::pat_sfx_toggle(&mut self.fb, self.music_ed.grid_sfx());
2116 }
2117 }
2118 _ => {}
2119 }
2120 self.draw_toast();
2121 if !self.file_picker.is_open() {
2125 if let Some(i) = ui::tab_bar_hover(&mouse) {
2126 ui::status_bar(&mut self.fb, ui::tab_name(i));
2127 }
2128 }
2129 if !self.hide_cursor {
2130 ui::draw_cursor(&mut self.fb, &mouse);
2131 }
2132 &self.fb
2133 }
2134 }
2135 }
2136
2137 fn draw_toast(&mut self) {
2140 let msg = if self.build.is_some() {
2141 let dots = ".".repeat(1 + (self.frame as usize / 10) % 3);
2142 Some((format!("Building{dots}"), col::ORANGE))
2143 } else {
2144 match &self.toast {
2145 Some((text, color, expires)) if self.frame < *expires => {
2146 Some((text.clone(), *color))
2147 }
2148 _ => {
2149 self.toast = None;
2150 None
2151 }
2152 }
2153 };
2154 if let Some((text, color)) = msg {
2155 self.fb.rectfill(0, 120, 127, 127, col::BLACK);
2156 self.fb.print(&text, 2, 121, color);
2157 }
2158 }
2159
2160 fn draw_console(&mut self) {
2161 self.fb.reset_state();
2162 self.fb.cls(col::BLACK);
2163 let rows = ((120 - 2) / font::GLYPH_H) as usize;
2166
2167 let total = self.lines.len();
2169 let end = total.saturating_sub(self.scroll_back);
2170 let start = end.saturating_sub(rows);
2171 let mut y = 2;
2172 for line in self.lines.iter().skip(start).take(end - start) {
2173 match line {
2174 ConsoleLine::Text { text, color } => {
2175 self.fb.print(text, 2, y, *color);
2176 }
2177 ConsoleLine::Stripe => {
2178 for (i, c) in [8u8, 9, 10, 11, 12, 13, 14, 15].iter().enumerate() {
2179 self.fb
2180 .rectfill(2 + i as i32 * 6, y, 2 + i as i32 * 6 + 4, y + 3, *c);
2181 }
2182 }
2183 }
2184 y += font::GLYPH_H;
2185 }
2186
2187 if self.build.is_some() {
2189 let dots = ".".repeat(1 + (self.frame as usize / 10) % 3);
2190 self.fb
2191 .print(&format!("Compiling{dots}"), 2, y, col::ORANGE);
2192 return;
2193 }
2194 let prompt = format!("> {}", self.input);
2195 self.fb.print(&prompt, 2, y, PROMPT_COL);
2196 if (self.frame / 8).is_multiple_of(2) {
2197 let cx = 2 + (2 + self.cursor as i32) * 4;
2198 self.fb
2199 .rectfill(cx, y, cx + 3, y + font::GLYPH_H - 2, col::RED);
2200 }
2201 }
2202}
2203
2204fn flag_value<'a>(next: Option<&'a &'a str>, flag: &str) -> Result<&'a str> {
2207 match next {
2208 Some(&v) if !v.starts_with("--") => Ok(v),
2209 _ => bail!("{flag} needs a value"),
2210 }
2211}
2212
2213#[cfg(test)]
2214mod tests {
2215 use super::*;
2216 use std::path::Path;
2217
2218 #[test]
2219 fn stats_overlay_draws_top_right_panel() {
2220 let text_pixels = |fb: &Framebuffer, xr: std::ops::Range<i32>| {
2221 let mut n = 0;
2222 for y in 0..28 {
2223 for x in xr.clone() {
2224 if fb.pget(x, y) != col::BLACK {
2225 n += 1;
2226 }
2227 }
2228 }
2229 n
2230 };
2231 let mut fb = Framebuffer::new();
2232 stats_overlay(&mut fb, 0.342, 0.51, 32768, 30.0, 30);
2233 assert!(
2237 text_pixels(&fb, 78..86) > 0,
2238 "decimal CPU widened the panel left"
2239 );
2240 assert_eq!(
2241 text_pixels(&fb, 0..60),
2242 0,
2243 "overlay leaves the top-left untouched"
2244 );
2245 }
2246
2247 fn stats_meter() -> (StatsMeter, Instant) {
2250 (StatsMeter::default(), Instant::now())
2251 }
2252
2253 fn stats_frame(
2257 m: &mut StatsMeter,
2258 now: Instant,
2259 cpu: (f32, f32),
2260 mem: u32,
2261 reads: &mut Vec<u32>,
2262 ) {
2263 m.sample(now, cpu.0, cpu.1, || {
2264 reads.push(mem);
2265 mem
2266 });
2267 }
2268
2269 #[test]
2270 fn stats_meter_publishes_its_first_sample_at_once() {
2271 let (mut m, t) = stats_meter();
2272 let mut reads = Vec::new();
2273 assert_eq!((m.cpu_update(), m.mem_used()), (0.0, 0));
2274 stats_frame(&mut m, t, (0.4, 0.2), 4096, &mut reads);
2275 assert_eq!((m.cpu_update(), m.cpu_draw()), (0.4, 0.2));
2276 assert_eq!(m.mem_used(), 4096);
2278 }
2279
2280 #[test]
2283 fn stats_meter_holds_its_reading_within_the_window() {
2284 let (mut m, t) = stats_meter();
2285 m.sample(t, 0.4, 0.2, || 0);
2286 for (i, cpu) in [0.9_f32, 0.05, 0.7, 0.01, 0.99].iter().enumerate() {
2287 m.sample(
2288 t + Duration::from_millis(100 * (i as u64 + 1)),
2289 *cpu,
2290 *cpu,
2291 || 0,
2292 );
2293 let held = (m.cpu_update(), m.cpu_draw());
2294 assert_eq!(held, (0.4, 0.2), "ratcheted mid-window");
2295 }
2296 }
2297
2298 #[test]
2299 fn stats_meter_publishes_the_window_peak_at_the_boundary() {
2300 let (mut m, t) = stats_meter();
2301 m.sample(t, 0.9, 0.9, || 0);
2304 m.sample(t + Duration::from_millis(200), 0.2, 0.5, || 0);
2305 m.sample(t + Duration::from_millis(500), 0.6, 0.3, || 0);
2306 m.sample(t + Duration::from_millis(1000), 0.4, 0.1, || 0);
2307 assert_eq!((m.cpu_update(), m.cpu_draw()), (0.6, 0.5));
2308 }
2309
2310 #[test]
2311 fn stats_meter_starts_a_fresh_window_after_publishing() {
2312 let (mut m, t) = stats_meter();
2313 m.sample(t, 1.0, 1.0, || 0);
2314 m.sample(t + Duration::from_millis(1000), 1.0, 1.0, || 0);
2315 m.sample(t + Duration::from_millis(1500), 0.1, 0.1, || 0);
2318 m.sample(t + Duration::from_millis(2000), 0.1, 0.1, || 0);
2319 assert_eq!((m.cpu_update(), m.cpu_draw()), (0.1, 0.1));
2320 }
2321
2322 #[test]
2326 fn stats_meter_publishes_a_window_of_zeros_as_zero() {
2327 let (mut m, t) = stats_meter();
2328 m.sample(t, 0.5, 0.5, || 0);
2329 assert_eq!((m.cpu_update(), m.cpu_draw()), (0.5, 0.5));
2330 for i in 1..=10 {
2331 m.sample(t + Duration::from_millis(100 * i), 0.0, 0.0, || 0);
2332 }
2333 assert_eq!((m.cpu_update(), m.cpu_draw()), (0.0, 0.0));
2334 }
2335
2336 #[test]
2339 fn stats_meter_publishes_a_frame_longer_than_the_window() {
2340 let (mut m, t) = stats_meter();
2341 let mut reads = Vec::new();
2342 stats_frame(&mut m, t, (0.1, 0.1), 1024, &mut reads);
2343 stats_frame(
2344 &mut m,
2345 t + Duration::from_secs(4),
2346 (0.8, 0.7),
2347 2048,
2348 &mut reads,
2349 );
2350 assert_eq!((m.cpu_update(), m.cpu_draw()), (0.8, 0.7));
2351 assert_eq!(reads, [1024, 2048]);
2352 }
2353
2354 #[test]
2355 fn stats_meter_publishes_memory_on_the_window_beat() {
2356 let (mut m, t) = stats_meter();
2357 let mut reads = Vec::new();
2358 stats_frame(&mut m, t, (0.1, 0.1), 1024, &mut reads);
2359 stats_frame(
2360 &mut m,
2361 t + Duration::from_millis(500),
2362 (0.1, 0.1),
2363 8192,
2364 &mut reads,
2365 );
2366 assert_eq!(m.mem_used(), 1024, "mid-window memory must not move");
2367 stats_frame(
2368 &mut m,
2369 t + Duration::from_millis(1000),
2370 (0.1, 0.1),
2371 8192,
2372 &mut reads,
2373 );
2374 assert_eq!(m.mem_used(), 8192);
2375 }
2376
2377 #[test]
2381 fn stats_meter_reads_memory_once_per_window() {
2382 let (mut m, t) = stats_meter();
2383 let mut reads = Vec::new();
2384 for i in 0..=180 {
2386 let now = t + Duration::from_millis(1000 * i / 60);
2387 stats_frame(&mut m, now, (0.1, 0.1), 1024 * (i as u32 + 1), &mut reads);
2388 }
2389 assert_eq!(reads.len(), 4, "read memory {} times, not 4", reads.len());
2391 }
2392
2393 #[test]
2394 fn stats_meter_reset_clears_the_reading() {
2395 let (mut m, t) = stats_meter();
2396 let mut reads = Vec::new();
2397 stats_frame(&mut m, t, (0.5, 0.5), 4096, &mut reads);
2398 m.reset();
2399 assert_eq!((m.cpu_update(), m.cpu_draw()), (0.0, 0.0));
2400 assert_eq!(m.mem_used(), 0, "kept the old cart's memory figure");
2401 stats_frame(
2404 &mut m,
2405 t + Duration::from_millis(10),
2406 (0.3, 0.7),
2407 512,
2408 &mut reads,
2409 );
2410 assert_eq!(
2411 (m.cpu_update(), m.cpu_draw(), m.mem_used()),
2412 (0.3, 0.7, 512)
2413 );
2414 }
2415
2416 fn test_shell() -> Shell {
2417 let sdk = Path::new(env!("CARGO_MANIFEST_DIR")).join("../pixel8");
2418 let mut shell = Shell::new(AudioHandle::dummy(), sdk);
2419 shell.storage_root =
2422 Some(std::env::temp_dir().join(format!("pixel8_test_storage_{}", std::process::id())));
2423 shell
2424 }
2425
2426 #[test]
2431 fn restart_saves_storage_before_the_new_vm_loads() {
2432 use pixel8_runtime::{assets::Assets, cart::Cart, storage::Storage};
2433 const COUNTER_CART: &str = r#"
2434 (module
2435 (import "pixel8" "storage_get" (func $sget (param i32 i32 i32 i32) (result i32)))
2436 (import "pixel8" "storage_set" (func $sset (param i32 i32 i32 i32) (result i32)))
2437 (memory (export "memory") 1)
2438 (data (i32.const 0) "n")
2439 (func (export "pixel8_init")
2440 (local $n i32)
2441 (if (i32.eq (call $sget (i32.const 0) (i32.const 1) (i32.const 8) (i32.const 1))
2442 (i32.const 1))
2443 (then (local.set $n (i32.sub (i32.load8_u (i32.const 8)) (i32.const 48)))))
2444 (i32.store8 (i32.const 8) (i32.add (i32.const 49) (local.get $n)))
2445 (drop (call $sset (i32.const 0) (i32.const 1) (i32.const 8) (i32.const 1))))
2446 (func (export "pixel8_update"))
2447 (func (export "pixel8_draw")))
2448 "#;
2449 let root = std::env::temp_dir().join(format!("pixel8_restart_{}", std::process::id()));
2450 let _ = std::fs::remove_dir_all(&root);
2451 let mut shell = test_shell();
2452 shell.storage_root = Some(root.clone());
2453 shell.loaded = Loaded::Cart {
2454 cart: Cart {
2455 wasm: wat::parse_str(COUNTER_CART).unwrap(),
2456 assets: Assets::default(),
2457 source: None,
2458 },
2459 path: PathBuf::from("counter.png"),
2460 };
2461 shell.start_vm_from_loaded().expect("first start"); shell.start_vm_from_loaded().expect("restart"); shell.vm = None; let s = Storage::for_cart_in(&root, &Assets::default().meta.name);
2465 assert_eq!(
2466 s.get_json("n").as_deref(),
2467 Some("2"),
2468 "the restart must persist the first run's write before the second reads"
2469 );
2470 std::fs::remove_dir_all(&root).unwrap();
2471 }
2472
2473 #[test]
2477 fn running_a_cart_reads_its_memory_once_a_second() {
2478 use pixel8_runtime::{assets::Assets, cart::Cart};
2479 const COUNTING_CART: &str = r#"
2482 (module
2483 (memory (export "memory") 1)
2484 (global $calls (mut i32) (i32.const 0))
2485 (func (export "pixel8_init"))
2486 (func (export "pixel8_update"))
2487 (func (export "pixel8_draw"))
2488 (func (export "pixel8_mem_used") (result i32)
2489 (global.set $calls (i32.add (global.get $calls) (i32.const 1)))
2490 (i32.mul (global.get $calls) (i32.const 1024))))
2491 "#;
2492 let mut shell = test_shell();
2493 shell.loaded = Loaded::Cart {
2494 cart: Cart {
2495 wasm: wat::parse_str(COUNTING_CART).unwrap(),
2496 assets: Assets::default(),
2497 source: None,
2498 },
2499 path: PathBuf::from("counting.png"),
2500 };
2501 shell.show_stats = true;
2502 shell.start_vm_from_loaded().expect("start");
2503 for _ in 0..30 {
2506 shell.tick();
2507 shell.draw();
2508 }
2509 assert_eq!(
2510 shell.stats_meter.mem_used(),
2511 1024,
2512 "the overlay is not holding the first frame's reading"
2513 );
2514 let probe = shell.vm.as_mut().unwrap().mem_used_bytes();
2517 assert_eq!(
2518 probe,
2519 2048,
2520 "pixel8_mem_used ran {} times over 30 frames, not once",
2521 probe / 1024 - 1
2522 );
2523 }
2524
2525 fn point_cart_at_local_sdk(project_dir: &Path) {
2528 let sdk = Path::new(env!("CARGO_MANIFEST_DIR"))
2529 .join("../pixel8")
2530 .canonicalize()
2531 .unwrap();
2532 let manifest_path = project_dir.join("Cargo.toml");
2533 let manifest = std::fs::read_to_string(&manifest_path).unwrap();
2534 let dep = format!(
2538 "version = \"{}.{}\"",
2539 env!("CARGO_PKG_VERSION_MAJOR"),
2540 env!("CARGO_PKG_VERSION_MINOR"),
2541 );
2542 let patched = manifest.replace(&dep, &format!("path = {:?}", sdk.display().to_string()));
2543 assert_ne!(patched, manifest, "no `{dep}` to redirect in:\n{manifest}");
2544 std::fs::write(&manifest_path, patched).unwrap();
2545 }
2546
2547 #[test]
2548 fn window_title_reflects_loaded_cart() {
2549 let dir = std::env::temp_dir().join(format!("pixel8_title_{}", std::process::id()));
2550 let _ = std::fs::remove_dir_all(&dir);
2551 let mut shell = test_shell();
2552 assert_eq!(shell.window_title(), "Pixel8");
2553
2554 let project_dir = dir.join("game");
2555 Project::create(&project_dir, "game").unwrap();
2556 shell
2557 .cmd_load(&[project_dir.to_str().unwrap()])
2558 .expect("load project");
2559 assert_eq!(shell.window_title(), "game - Pixel8");
2560 std::fs::remove_dir_all(&dir).unwrap();
2561 }
2562
2563 #[test]
2566 fn ctrl_s_saves_and_builds_with_feedback() {
2567 let dir = std::env::temp_dir().join(format!("pixel8_shell_test_{}", std::process::id()));
2568 let _ = std::fs::remove_dir_all(&dir);
2569 let mut shell = test_shell();
2570 let project_dir = dir.join("game");
2571 Project::create(&project_dir, "game").unwrap();
2572 point_cart_at_local_sdk(&project_dir);
2573
2574 shell
2575 .cmd_load(&[project_dir.to_str().unwrap()])
2576 .expect("load project");
2577 shell.switch_editor(Mode::Code);
2578
2579 for c in "//x".chars() {
2581 shell.key(Key::Char(c), Mods::default());
2582 }
2583 shell.key(Key::Enter, Mods::default());
2584 shell.key(
2585 Key::Char('s'),
2586 Mods {
2587 ctrl: true,
2588 ..Default::default()
2589 },
2590 );
2591
2592 let code = std::fs::read_to_string(project_dir.join("src/lib.rs")).unwrap();
2594 assert!(code.starts_with("//x\n"), "edit was saved");
2595 assert_eq!(shell.toast.as_ref().unwrap().0, "Saved");
2596 assert!(shell.build.is_some(), "background build spawned");
2597
2598 shell.draw();
2600 assert_eq!(shell.fb.pget(0, 120), col::BLACK, "toast bar drawn");
2601
2602 for _ in 0..(120 * 30) {
2604 shell.tick();
2605 if shell.build.is_none() {
2606 break;
2607 }
2608 std::thread::sleep(std::time::Duration::from_millis(33));
2609 }
2610 assert!(shell.build.is_none(), "build finished in time");
2611 let (text, color, _) = shell.toast.as_ref().unwrap();
2612 assert!(text.starts_with("Build ok"), "got: {text}");
2613 assert_eq!(*color, col::GREEN);
2614 assert!(
2615 shell.mode == Mode::Code,
2616 "stays in the editor; no mode switch"
2617 );
2618 std::fs::remove_dir_all(&dir).unwrap();
2619 }
2620
2621 #[test]
2623 fn save_build_failure_is_reported() {
2624 let dir = std::env::temp_dir().join(format!("pixel8_shell_fail_{}", std::process::id()));
2625 let _ = std::fs::remove_dir_all(&dir);
2626 let mut shell = test_shell();
2627 let project_dir = dir.join("game");
2628 let mut project = Project::create(&project_dir, "game").unwrap();
2629 project.code = "fn broken( {".into();
2630 project.save().unwrap();
2631
2632 shell
2633 .cmd_load(&[project_dir.to_str().unwrap()])
2634 .expect("load project");
2635 shell.switch_editor(Mode::Code);
2636 shell.key(
2637 Key::Char('s'),
2638 Mods {
2639 ctrl: true,
2640 ..Default::default()
2641 },
2642 );
2643 assert!(shell.build.is_some());
2644 for _ in 0..(120 * 30) {
2645 shell.tick();
2646 if shell.build.is_none() {
2647 break;
2648 }
2649 std::thread::sleep(std::time::Duration::from_millis(33));
2650 }
2651 assert_eq!(
2654 shell.mode,
2655 Mode::Console,
2656 "expected console mode after build failure"
2657 );
2658 let summary = shell.lines.iter().find_map(|l| match l {
2659 ConsoleLine::Text { text, color } if text.starts_with("Build failed") => {
2660 Some((text.clone(), *color))
2661 }
2662 _ => None,
2663 });
2664 let (text, color) = summary.expect("Build failed summary not found in console lines");
2665 assert!(text.starts_with("Build failed"), "got: {text}");
2666 assert_eq!(color, col::RED);
2667 std::fs::remove_dir_all(&dir).unwrap();
2668 }
2669
2670 #[test]
2673 fn run_does_not_clobber_external_edits() {
2674 let dir = std::env::temp_dir().join(format!("pixel8_run_noclobber_{}", std::process::id()));
2675 let _ = std::fs::remove_dir_all(&dir);
2676 let mut shell = test_shell();
2677 let project_dir = dir.join("game");
2678 Project::create(&project_dir, "game").unwrap();
2679 shell
2680 .cmd_load(&[project_dir.to_str().unwrap()])
2681 .expect("load");
2682
2683 let lib = project_dir.join("src/lib.rs");
2685 let original = std::fs::read_to_string(&lib).unwrap();
2686 let edited = format!("// EXTERNAL EDIT\n{original}");
2687 std::fs::write(&lib, &edited).unwrap();
2689 let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
2690 std::fs::OpenOptions::new()
2691 .write(true)
2692 .open(&lib)
2693 .unwrap()
2694 .set_modified(later)
2695 .unwrap();
2696
2697 shell.cmd_run();
2698
2699 let after = std::fs::read_to_string(&lib).unwrap();
2702 assert!(
2703 after.starts_with("// EXTERNAL EDIT\n"),
2704 "external edit survived run; got:\n{after}"
2705 );
2706
2707 for _ in 0..(120 * 30) {
2709 shell.tick();
2710 if shell.build.is_none() {
2711 break;
2712 }
2713 std::thread::sleep(std::time::Duration::from_millis(33));
2714 }
2715 std::fs::remove_dir_all(&dir).unwrap();
2716 }
2717
2718 #[test]
2722 fn startup_run_arms_the_deferred_run() {
2723 let dir = std::env::temp_dir().join(format!("pixel8_startup_run_{}", std::process::id()));
2724 let _ = std::fs::remove_dir_all(&dir);
2725 let mut shell = test_shell();
2726 let project_dir = dir.join("game");
2727 Project::create(&project_dir, "game").unwrap();
2728
2729 shell.startup_load(project_dir.to_str().unwrap());
2730 assert_eq!(shell.mode, Mode::Console, "loaded, still at the console");
2731
2732 shell.startup_run();
2733 assert!(shell.build.is_some(), "startup_run spawns the build");
2734 assert!(
2735 shell.run_after_build,
2736 "and arms the run to start once it is built"
2737 );
2738
2739 std::fs::remove_dir_all(&dir).unwrap();
2741 }
2742
2743 #[test]
2746 fn new_arms_the_project_watcher() {
2747 let dir = std::env::temp_dir().join(format!("pixel8_new_watch_{}", std::process::id()));
2748 let _ = std::fs::remove_dir_all(&dir);
2749 let mut shell = test_shell();
2750 shell.cwd = dir.clone();
2751 shell.cmd_new(&["game"]).expect("new");
2752 assert!(shell.project_watch.is_some(), "new should arm the watcher");
2753 std::fs::remove_dir_all(&dir).unwrap();
2754 }
2755
2756 #[test]
2759 fn external_edit_auto_builds_and_runs() {
2760 let dir = std::env::temp_dir().join(format!("pixel8_autobuild_{}", std::process::id()));
2761 let _ = std::fs::remove_dir_all(&dir);
2762 let mut shell = test_shell();
2763 let project_dir = dir.join("game");
2764 Project::create(&project_dir, "game").unwrap();
2765 point_cart_at_local_sdk(&project_dir);
2766
2767 shell
2768 .cmd_load(&[project_dir.to_str().unwrap()])
2769 .expect("load");
2770 assert_eq!(shell.mode, Mode::Console);
2771
2772 let lib = project_dir.join("src/lib.rs");
2774 let original = std::fs::read_to_string(&lib).unwrap();
2775 std::fs::write(&lib, format!("// auto\n{original}")).unwrap();
2776 let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
2777 std::fs::OpenOptions::new()
2778 .write(true)
2779 .open(&lib)
2780 .unwrap()
2781 .set_modified(later)
2782 .unwrap();
2783
2784 let mut entered_run = false;
2786 for _ in 0..(180 * 30) {
2787 shell.tick();
2788 if shell.mode == Mode::Run {
2789 entered_run = true;
2790 break;
2791 }
2792 std::thread::sleep(std::time::Duration::from_millis(33));
2793 }
2794 assert!(entered_run, "external edit should auto-build and run");
2795 std::fs::remove_dir_all(&dir).unwrap();
2796 }
2797
2798 #[test]
2801 fn external_png_change_reloads_cart() {
2802 use pixel8_runtime::cart::{self, Cart};
2803 let dir = std::env::temp_dir().join(format!("pixel8_pngreload_{}", std::process::id()));
2804 let _ = std::fs::remove_dir_all(&dir);
2805 std::fs::create_dir_all(&dir).unwrap();
2806 let mut shell = test_shell();
2807
2808 let project_dir = dir.join("game");
2809 let project = Project::create(&project_dir, "game").unwrap();
2810 let png = dir.join("game.png");
2811
2812 let mut cart = Cart {
2815 wasm: b"\0asm\x01\0\0\0".to_vec(),
2816 assets: project.assets.clone(),
2817 source: Some("// v1".into()),
2818 };
2819 cart::save_png(&cart, &png).unwrap();
2820 shell.cmd_load(&[png.to_str().unwrap()]).expect("load png");
2821 let before = shell.cart_name();
2822
2823 cart.assets.meta.name = "renamed".into();
2825 cart::save_png(&cart, &png).unwrap();
2826 let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
2827 std::fs::OpenOptions::new()
2828 .write(true)
2829 .open(&png)
2830 .unwrap()
2831 .set_modified(later)
2832 .unwrap();
2833
2834 for _ in 0..(60 * 30) {
2835 shell.tick();
2836 if shell.cart_name() != before {
2837 break;
2838 }
2839 std::thread::sleep(std::time::Duration::from_millis(33));
2840 }
2841 assert_eq!(shell.cart_name(), "renamed", "external PNG change adopted");
2842 std::fs::remove_dir_all(&dir).unwrap();
2843 }
2844
2845 #[test]
2848 fn saving_png_does_not_self_conflict() {
2849 use pixel8_runtime::{
2850 cart::{self, Cart},
2851 project::encode_assets,
2852 };
2853 let dir = std::env::temp_dir().join(format!("pixel8_pngsave_{}", std::process::id()));
2854 let _ = std::fs::remove_dir_all(&dir);
2855 std::fs::create_dir_all(&dir).unwrap();
2856 let mut shell = test_shell();
2857 let project = Project::create(&dir.join("game"), "game").unwrap();
2858 let png = dir.join("game.png");
2859 let cart = Cart {
2860 wasm: b"\0asm\x01\0\0\0".to_vec(),
2861 assets: project.assets.clone(),
2862 source: Some("// v1".into()),
2863 };
2864 cart::save_png(&cart, &png).unwrap();
2865 shell.cmd_load(&[png.to_str().unwrap()]).expect("load png");
2866
2867 if let Some(a) = shell.assets_mut() {
2869 a.meta.name = "edited".into();
2870 }
2871 shell.cmd_save(&[]).expect("save");
2872
2873 let in_mem = encode_assets(shell.assets().unwrap()).unwrap_or_default();
2875 assert_eq!(
2876 shell.cart_watch.as_ref().unwrap().baseline,
2877 in_mem,
2878 "save re-baselined the cart watcher"
2879 );
2880
2881 for _ in 0..(2 * 30) {
2883 shell.tick();
2884 }
2885 assert_eq!(shell.cart_name(), "edited");
2886 std::fs::remove_dir_all(&dir).unwrap();
2887 }
2888
2889 #[test]
2892 fn reload_takes_disk_version() {
2893 let dir = std::env::temp_dir().join(format!("pixel8_reload_{}", std::process::id()));
2894 let _ = std::fs::remove_dir_all(&dir);
2895 let mut shell = test_shell();
2896 let project_dir = dir.join("game");
2897 Project::create(&project_dir, "game").unwrap();
2898 shell
2899 .cmd_load(&[project_dir.to_str().unwrap()])
2900 .expect("load");
2901
2902 let lib = project_dir.join("src/lib.rs");
2904 std::fs::write(&lib, "// DISK VERSION\n").unwrap();
2905
2906 shell.cmd_reload().expect("reload");
2907 let code = shell.code().unwrap_or_default().to_string();
2908 assert!(
2909 code.starts_with("// DISK VERSION"),
2910 "reload took disk; got:\n{code}"
2911 );
2912 std::fs::remove_dir_all(&dir).unwrap();
2913 }
2914
2915 #[test]
2919 fn run_aborts_on_conflict_and_does_not_clobber() {
2920 let dir = std::env::temp_dir().join(format!("pixel8_run_conflict_{}", std::process::id()));
2921 let _ = std::fs::remove_dir_all(&dir);
2922 let mut shell = test_shell();
2923 let project_dir = dir.join("game");
2924 Project::create(&project_dir, "game").unwrap();
2925 shell
2926 .cmd_load(&[project_dir.to_str().unwrap()])
2927 .expect("load");
2928
2929 if let Loaded::Project(p) = &mut shell.loaded {
2931 p.code = "// IN-CONSOLE EDIT\n".into();
2932 }
2933 let lib = project_dir.join("src/lib.rs");
2935 std::fs::write(&lib, "// EXTERNAL EDIT\n").unwrap();
2936 let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
2937 std::fs::OpenOptions::new()
2938 .write(true)
2939 .open(&lib)
2940 .unwrap()
2941 .set_modified(later)
2942 .unwrap();
2943
2944 shell.cmd_run();
2946 assert_eq!(shell.mode, Mode::Console, "first run aborts on conflict");
2947 assert!(shell.build.is_none(), "no build started on conflict");
2948 assert_eq!(
2949 std::fs::read_to_string(&lib).unwrap(),
2950 "// EXTERNAL EDIT\n",
2951 "disk untouched after the first run"
2952 );
2953
2954 shell.cmd_run();
2956 assert_eq!(shell.mode, Mode::Console, "second run still aborts");
2957 assert!(shell.build.is_none(), "second run starts no build");
2958 assert_eq!(
2959 std::fs::read_to_string(&lib).unwrap(),
2960 "// EXTERNAL EDIT\n",
2961 "disk still untouched after the second run"
2962 );
2963
2964 std::fs::remove_dir_all(&dir).unwrap();
2965 }
2966
2967 #[test]
2968 fn picker_creates_and_switches_files_in_the_shell() {
2969 let dir = std::env::temp_dir().join(format!("pixel8_pick_{}", std::process::id()));
2970 let _ = std::fs::remove_dir_all(&dir);
2971 let mut shell = test_shell();
2972 let project_dir = dir.join("game");
2973 Project::create(&project_dir, "game").unwrap();
2974 shell
2975 .cmd_load(&[project_dir.to_str().unwrap()])
2976 .expect("load project");
2977 shell.switch_editor(Mode::Code);
2978
2979 let ctrl = Mods {
2981 ctrl: true,
2982 ..Default::default()
2983 };
2984 shell.key(Key::Char('o'), ctrl);
2985 assert!(shell.file_picker.is_open());
2986 shell.key(Key::Down, Mods::default()); shell.key(Key::Enter, Mods::default()); for c in "enemy".chars() {
2989 shell.key(Key::Char(c), Mods::default());
2990 }
2991 shell.key(Key::Enter, Mods::default()); assert!(!shell.file_picker.is_open());
2994 assert!(project_dir.join("src/enemy.rs").exists());
2995 let lib = std::fs::read_to_string(project_dir.join("src/lib.rs")).unwrap();
2996 assert!(lib.starts_with("#![no_std]"), "lib.rs:\n{lib}");
2998 assert!(lib.contains("\nmod enemy;\n"), "lib.rs:\n{lib}");
2999 assert_eq!(shell.code().unwrap(), "");
3001
3002 shell.key(Key::Char('o'), ctrl);
3004 shell.key(Key::Enter, Mods::default()); assert!(shell.code().unwrap().contains("\nmod enemy;\n"));
3006 std::fs::remove_dir_all(&dir).unwrap();
3007 }
3008
3009 #[test]
3010 fn clicking_the_filename_opens_the_picker() {
3011 let dir = std::env::temp_dir().join(format!("pixel8_fnclick_{}", std::process::id()));
3012 let _ = std::fs::remove_dir_all(&dir);
3013 let mut shell = test_shell();
3014 let project_dir = dir.join("game");
3015 Project::create(&project_dir, "game").unwrap();
3016 shell
3017 .cmd_load(&[project_dir.to_str().unwrap()])
3018 .expect("load project");
3019 shell.switch_editor(Mode::Code);
3020
3021 assert!(!shell.file_picker.is_open());
3022 let name = shell.current_file_name();
3024 shell.mouse = Mouse {
3025 x: 3,
3026 y: 2,
3027 left_pressed: true,
3028 ..Default::default()
3029 };
3030 assert!(ui::filename_clicked(&shell.mouse, &name));
3031 shell.tick();
3032 assert!(
3033 shell.file_picker.is_open(),
3034 "filename click opens the picker"
3035 );
3036 std::fs::remove_dir_all(&dir).unwrap();
3037 }
3038
3039 #[test]
3042 fn select_file_save_failure_aborts_and_reports() {
3043 let dir = std::env::temp_dir().join(format!("pixel8_savefail_{}", std::process::id()));
3044 let _ = std::fs::remove_dir_all(&dir);
3045 let mut shell = test_shell();
3046 let project_dir = dir.join("game");
3047 Project::create(&project_dir, "game").unwrap();
3048
3049 std::fs::write(project_dir.join("src/other.rs"), "").unwrap();
3051
3052 shell
3053 .cmd_load(&[project_dir.to_str().unwrap()])
3054 .expect("load project");
3055 shell.switch_editor(Mode::Code);
3056
3057 let lib_path = project_dir.join("src/lib.rs");
3060 std::fs::remove_file(&lib_path).unwrap();
3061 std::fs::create_dir(&lib_path).unwrap();
3062
3063 shell.select_file("other.rs");
3065
3066 assert_eq!(
3068 shell.current_file_name(),
3069 "lib.rs",
3070 "switch must be aborted when save fails"
3071 );
3072
3073 let toast = shell
3075 .toast
3076 .as_ref()
3077 .expect("toast must be set on save error");
3078 assert_eq!(toast.1, col::RED, "toast must be red");
3079 assert!(
3080 toast.0.contains("lib.rs"),
3081 "toast must name the file that failed to save; got: {}",
3082 toast.0
3083 );
3084
3085 std::fs::remove_dir_all(&dir).unwrap();
3086 }
3087
3088 #[test]
3089 fn import_pico8_into_appends_to_loaded_project() {
3090 let dir = std::env::temp_dir().join(format!("pixel8_shell_into_{}", std::process::id()));
3091 let _ = std::fs::remove_dir_all(&dir);
3092 std::fs::create_dir_all(&dir).unwrap();
3093
3094 let mut row0 = vec![b'0'; 128];
3096 row0[0] = b'c';
3097 let p8 = format!(
3098 "pico-8 cartridge // http://www.pico-8.com\nversion 41\n__gfx__\n{}\n",
3099 String::from_utf8(row0).unwrap()
3100 );
3101 std::fs::write(dir.join("src.p8"), p8).unwrap();
3102
3103 let mut shell = test_shell();
3104 shell.cwd = dir.clone();
3105 shell.cmd_new(&["dest"]).expect("new");
3107 shell.assets_mut().expect("loaded").sprites.set(0, 0, 5);
3110
3111 shell.exec("import-pico8 src.p8 --into --sprites 0");
3112
3113 match &shell.loaded {
3116 Loaded::Project(p) => assert_eq!(p.name, "dest"),
3117 _ => panic!("expected the dest project to remain loaded"),
3118 }
3119 let assets = shell.assets().expect("a project is loaded");
3120 assert_eq!(assets.sprites.get(0, 0), 5);
3122 assert_eq!(assets.sprites.get(8, 0), 0xc);
3124
3125 std::fs::remove_dir_all(&dir).unwrap();
3126 }
3127
3128 #[test]
3129 fn additive_import_does_not_trigger_a_rebuild() {
3130 let dir =
3131 std::env::temp_dir().join(format!("pixel8_into_norebuild_{}", std::process::id()));
3132 let _ = std::fs::remove_dir_all(&dir);
3133 std::fs::create_dir_all(&dir).unwrap();
3134
3135 let mut row0 = vec![b'0'; 128];
3136 row0[0] = b'c';
3137 let p8 = format!(
3138 "pico-8 cartridge // http://www.pico-8.com\nversion 41\n__gfx__\n{}\n",
3139 String::from_utf8(row0).unwrap()
3140 );
3141 std::fs::write(dir.join("src.p8"), p8).unwrap();
3142
3143 let mut shell = test_shell();
3144 shell.cwd = dir.clone();
3145 shell.cmd_new(&["dest"]).expect("new");
3146
3147 shell.exec("import-pico8 src.p8 --into --sprites 0");
3148
3149 for _ in 0..40 {
3153 shell.tick();
3154 }
3155 assert!(
3156 shell.build.is_none(),
3157 "additive import must not spawn a build"
3158 );
3159 assert_eq!(shell.mode, Mode::Console, "must stay at the console");
3160 assert!(!shell.run_after_build, "must not arm a run");
3161
3162 std::fs::remove_dir_all(&dir).unwrap();
3163 }
3164
3165 #[test]
3166 fn apply_paste_routes_map_blobs_to_the_map_editor() {
3167 use pixel8_runtime::clipboard::Pasted;
3168 let dir = std::env::temp_dir().join(format!("pixel8_mappaste_{}", std::process::id()));
3169 let _ = std::fs::remove_dir_all(&dir);
3170 std::fs::create_dir_all(&dir).unwrap();
3171
3172 let mut shell = test_shell();
3173 shell.cwd = dir.clone();
3174 shell.cmd_new(&["dest"]).expect("new");
3175 shell.mode = Mode::Map;
3176
3177 shell.apply_paste(Pasted::Map {
3178 w: 1,
3179 h: 1,
3180 tiles: vec![7],
3181 });
3182 assert!(shell.map_ed.has_paste_buffer());
3183
3184 std::fs::remove_dir_all(&dir).unwrap();
3185 }
3186
3187 #[test]
3188 fn apply_paste_routes_sfx_to_the_sfx_editor() {
3189 use pixel8_runtime::clipboard::{Pasted, SfxClip, Slotted};
3190 let dir = std::env::temp_dir().join(format!("pixel8_paste_{}", std::process::id()));
3191 let _ = std::fs::remove_dir_all(&dir);
3192 std::fs::create_dir_all(&dir).unwrap();
3193
3194 let mut shell = test_shell();
3195 shell.cwd = dir.clone();
3196 shell.cmd_new(&["dest"]).expect("new");
3197 shell.mode = Mode::Sfx; let mut sfx = pixel8_runtime::assets::Sfx::default();
3200 sfx.notes[0].pitch = 21;
3201 sfx.notes[0].volume = 4;
3202 let clip = SfxClip {
3203 records: vec![Slotted { src: 0, value: sfx }],
3204 patterns: vec![],
3205 };
3206 shell.apply_paste(Pasted::Sfx(clip));
3207
3208 let assets = assets_ref(&shell.loaded).unwrap();
3209 assert_eq!(assets.sfx[0].notes[0].pitch, 21);
3210
3211 std::fs::remove_dir_all(&dir).unwrap();
3212 }
3213
3214 #[test]
3215 fn hide_cursor_suppresses_the_software_cursor() {
3216 let mut shell = test_shell();
3217 shell.mode = Mode::Sprite;
3219 shell.mouse.x = 40;
3220 shell.mouse.y = 40;
3221 let with_cursor = shell.draw().pixels().to_vec();
3222
3223 shell.set_hide_cursor(true);
3224 let hidden = shell.draw().pixels().to_vec();
3225 assert_ne!(
3226 with_cursor, hidden,
3227 "hiding the cursor must change the frame"
3228 );
3229
3230 shell.set_hide_cursor(false);
3232 shell.mouse = Mouse::default();
3233 let no_pointer = shell.draw().pixels().to_vec();
3234 assert_eq!(
3235 hidden, no_pointer,
3236 "a hidden cursor leaves no pointer behind"
3237 );
3238 }
3239}