diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5acf4b3..3a9cf5e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -85,8 +85,8 @@ jobs: cargo -V rustc -V - - name: Build - shell: bash + - run: sudo apt install -qq -y libudev-dev + - shell: bash run: $BUILD_CMD build --release --target=${{ matrix.job.target }} - name: Set binary name & path diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e28e613..f46a0e6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,6 +22,7 @@ jobs: - uses: arduino/setup-task@v1 with: repo-token: ${{ github.token }} + - run: sudo apt install -qq -y libudev-dev - run: task test lint: @@ -34,6 +35,7 @@ jobs: - uses: arduino/setup-task@v1 with: repo-token: ${{ github.token }} + - run: sudo apt install -qq -y libudev-dev - run: task lint markdownlint-cli: diff --git a/Cargo.lock b/Cargo.lock index 330fc44..cd94113 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -241,6 +241,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67ba02a97a2bd10f4b59b25c7973101c79642302776489e030cd13cdab09ed15" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.11", +] + [[package]] name = "colorchoice" version = "1.0.3" @@ -253,6 +262,16 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -450,9 +469,9 @@ dependencies = [ [[package]] name = "firefly-types" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc84abd0f8de79086fecf404c30fc8cd266e72bb16eb92803f3e7e2be728a223" +checksum = "e87d85fdb19db129d33583d4d149f5e4dea8ca5e6799b0ef619e71a470ecadd0" dependencies = [ "postcard", "serde", @@ -465,6 +484,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "cobs 0.3.0", "crossterm", "data-encoding", "directories", @@ -477,6 +497,7 @@ dependencies = [ "rustyline", "serde", "serde_json", + "serialport", "sha2", "toml", "ureq", @@ -747,6 +768,16 @@ dependencies = [ "serde", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -815,6 +846,26 @@ dependencies = [ "libc", ] +[[package]] +name = "libudev" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" +dependencies = [ + "libc", + "libudev-sys", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -843,6 +894,15 @@ version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +[[package]] +name = "mach2" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.7.4" @@ -880,6 +940,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -1026,7 +1097,7 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "170a2601f67cc9dba8edd8c4870b15f71a6a2dc196daec8c83f72b59dff628a8" dependencies = [ - "cobs", + "cobs 0.2.3", "embedded-io 0.4.0", "embedded-io 0.6.1", "serde", @@ -1116,7 +1187,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom", "libredox", - "thiserror", + "thiserror 2.0.11", ] [[package]] @@ -1254,7 +1325,7 @@ dependencies = [ "libc", "log", "memchr", - "nix", + "nix 0.29.0", "radix_trie", "unicode-segmentation", "unicode-width", @@ -1330,6 +1401,25 @@ dependencies = [ "serde", ] +[[package]] +name = "serialport" +version = "4.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ecfc4858c2266c7695d8b8460bbd612fa81bd2e250f5f0dd16195e4b4f8b3d8" +dependencies = [ + "bitflags 2.8.0", + "cfg-if", + "core-foundation", + "core-foundation-sys", + "io-kit-sys", + "libudev", + "mach2", + "nix 0.26.4", + "scopeguard", + "unescaper", + "winapi", +] + [[package]] name = "sha2" version = "0.10.8" @@ -1455,13 +1545,33 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.11", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1525,6 +1635,15 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "unescaper" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c878a167baa8afd137494101a688ef8c67125089ff2249284bd2b5f9bfedb815" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "unicode-ident" version = "1.0.15" @@ -1955,7 +2074,7 @@ dependencies = [ "displaydoc", "indexmap", "memchr", - "thiserror", + "thiserror 2.0.11", "zstd", ] diff --git a/Cargo.toml b/Cargo.toml index d70adc8..c65ce4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,8 @@ anyhow = "1.0.95" chrono = { version = "0.4.39", default-features = false, features = ["clock"] } # Framework for parsing CLI args clap = { version = "4.5.29", features = ["derive"] } +# Detect message boundaries in serial port output from device +cobs = "0.3.0" # TUI for the "monitor" command, colored terminal output crossterm = "0.28.1" # Convert binary hash into hex @@ -31,7 +33,7 @@ data-encoding = "2.8.0" # Find the best place to sotre the VFS directories = "6.0.0" # Serialize app config into meta file in the ROM -firefly-types = { version = "0.5.0" } +firefly-types = { version = "0.5.1" } # Decode wav files hound = "3.5.1" # Parse PNG images @@ -52,6 +54,7 @@ rustyline = "15.0.0" serde = { version = "1.0.217", features = ["serde_derive", "derive"] } # Deserialize JSON API responses from the firefly catalog. serde_json = "1.0.138" +serialport = "4.7.0" # Calculate file checksum sha2 = "0.10.8" # Deserialize firefly.toml diff --git a/src/args.rs b/src/args.rs index 850bdaa..295ab90 100644 --- a/src/args.rs +++ b/src/args.rs @@ -57,6 +57,9 @@ pub enum Commands { /// Show runtime stats for a running device (or emulator). Monitor(MonitorArgs), + /// Show live runtime logs from a running device. + Logs(LogsArgs), + /// Inspect contents of the ROM: files, metadata, wasm binary. Inspect(InspectArgs), @@ -244,7 +247,22 @@ pub struct EmulatorArgs { } #[derive(Debug, Parser)] -pub struct MonitorArgs {} +pub struct MonitorArgs { + #[arg(long, default_value = None)] + pub port: Option, + + #[arg(long, default_value_t = 115_200)] + pub baud_rate: u32, +} + +#[derive(Debug, Parser)] +pub struct LogsArgs { + #[arg(long)] + pub port: String, + + #[arg(long, default_value_t = 115_200)] + pub baud_rate: u32, +} #[derive(Debug, Parser)] pub struct InspectArgs { diff --git a/src/cli.rs b/src/cli.rs index 3bf223c..6f88400 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -14,6 +14,7 @@ pub fn run_command(vfs: PathBuf, command: &Commands) -> anyhow::Result<()> { Commands::Boards(args) => cmd_boards(&vfs, args), Commands::Cheat(args) => cmd_cheat(args), Commands::Monitor(args) => cmd_monitor(&vfs, args), + Commands::Logs(args) => cmd_logs(args), Commands::Inspect(args) => cmd_inspect(&vfs, args), Commands::Repl(args) => cmd_repl(&vfs, args), Commands::Key(KeyCommands::New(args)) => cmd_key_new(&vfs, args), diff --git a/src/commands/build.rs b/src/commands/build.rs index e8427ff..6406bb9 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -30,7 +30,7 @@ use std::path::{Path, PathBuf}; static TIPS: &[&str] = &[ "keep an eye on the binary size: bigger binary often means slower code", "if the app hits `unreachable`, use `log_debug` to find out where", - "you can use build_args option in firefly.toml to customize the build command", + "you can use `build_args` option in `firefly.toml` to customize the build command", "if your game has multiple levels/scenes, use a separate sprite file for each", "prefer using 32 bit float over 64 bit float", "using shapes instead of sprites might save memory and improve performance", @@ -46,6 +46,7 @@ static TIPS: &[&str] = &[ "you can customize TinyGo build with a custom target.json in the project root", "make sure to test your game with multiplayer", "images using 4 or less colors are twice smaller", + "when debugging an app, call `set_seed` in `boot` to make the randomness predictable", // covering CLI subcommands "you can use `wasm2wat` and `firefly_cli inspect` to inspect the app binary", "use `firefly_cli export` to share the app with your friends", diff --git a/src/commands/logs.rs b/src/commands/logs.rs new file mode 100644 index 0000000..e5f6d49 --- /dev/null +++ b/src/commands/logs.rs @@ -0,0 +1,60 @@ +use crate::args::LogsArgs; +use anyhow::{Context, Result}; +use firefly_types::{serial::Response, Encode}; +use std::time::Duration; + +pub fn cmd_logs(args: &LogsArgs) -> Result<()> { + let mut port = serialport::new(&args.port, args.baud_rate) + .timeout(Duration::from_millis(10)) + .open() + .context("open the serial port")?; + let mut buf = Vec::new(); + println!("listening..."); + loop { + let mut chunk = vec![0; 64]; + let n = match port.read(chunk.as_mut_slice()) { + Ok(n) => n, + Err(err) => { + if err.kind() == std::io::ErrorKind::TimedOut { + continue; + } + return Err(err).context("read from serial port"); + } + }; + + buf.extend_from_slice(&chunk[..n]); + loop { + let (frame, rest) = advance(&buf); + buf = Vec::from(rest); + if frame.is_empty() { + break; + } + match Response::decode(&frame) { + Ok(Response::Log(log)) => println!("{log}"), + Ok(_) => (), + Err(err) => println!("invalid message: {err}"), + }; + } + } +} + +// Given the binary stream so far, read the first COBS frame and return the rest of bytes. +pub(super) fn advance(chunk: &[u8]) -> (Vec, &[u8]) { + let max_len = chunk.len(); + let mut out_buf = vec![0; max_len]; + let mut dec = cobs::CobsDecoder::new(&mut out_buf); + match dec.push(chunk) { + Ok(Some((n_out, n_in))) => { + let msg = Vec::from(&out_buf[..n_out]); + (msg, &chunk[n_in..]) + } + Ok(None) => (Vec::new(), chunk), + Err(err) => match err { + cobs::DecodeError::EmptyFrame => (Vec::new(), &[]), + cobs::DecodeError::InvalidFrame { decoded_bytes } => { + (Vec::new(), &chunk[decoded_bytes..]) + } + cobs::DecodeError::TargetBufTooSmall => unreachable!(), + }, + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 0c0539f..604c1b3 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -8,6 +8,7 @@ mod export; mod import; mod inspect; mod keys; +mod logs; mod monitor; mod name; mod new; @@ -24,6 +25,7 @@ pub use export::cmd_export; pub use import::cmd_import; pub use inspect::cmd_inspect; pub use keys::{cmd_key_add, cmd_key_new, cmd_key_priv, cmd_key_pub, cmd_key_rm}; +pub use logs::cmd_logs; pub use monitor::cmd_monitor; pub use name::{cmd_name_generate, cmd_name_get, cmd_name_set}; pub use new::cmd_new; diff --git a/src/commands/monitor.rs b/src/commands/monitor.rs index be116a9..480cadf 100644 --- a/src/commands/monitor.rs +++ b/src/commands/monitor.rs @@ -1,3 +1,4 @@ +use super::logs::advance; use crate::args::MonitorArgs; use crate::net::connect; use anyhow::{Context, Result}; @@ -6,7 +7,7 @@ use firefly_types::{serial, Encode}; use std::io::{self, Read, Write}; use std::net::TcpStream; use std::path::Path; -use std::time::Duration; +use std::time::{Duration, Instant}; const COL1: u16 = 8; const COL2: u16 = 16; @@ -14,65 +15,176 @@ const RBORD: u16 = 21; const KB: u32 = 1024; const MB: u32 = 1024 * KB; +type Port = Box; + +#[derive(Default)] struct Stats { update: Option, render: Option, cpu: Option, mem: Option, + /// The last reported log record. + log: Option, + /// When the last message was received. + last_msg: Option, +} + +impl Stats { + const fn is_default(&self) -> bool { + self.update.is_none() + && self.render.is_none() + && self.cpu.is_none() + && self.mem.is_none() + && self.log.is_none() + } } pub fn cmd_monitor(_vfs: &Path, args: &MonitorArgs) -> Result<()> { - execute!(io::stdout(), terminal::EnterAlternateScreen)?; - execute!(io::stdout(), cursor::Hide)?; - terminal::enable_raw_mode()?; - let res = run_monitor(args); - terminal::disable_raw_mode()?; - execute!(io::stdout(), terminal::LeaveAlternateScreen)?; + execute!(io::stdout(), terminal::EnterAlternateScreen).context("enter alt screen")?; + execute!(io::stdout(), cursor::Hide).context("hide cursor")?; + terminal::enable_raw_mode().context("enable raw mode")?; + let res = if let Some(port) = &args.port { + monitor_device(port, args) + } else { + monitor_emulator() + }; + terminal::disable_raw_mode().context("disable raw mode")?; + execute!(io::stdout(), terminal::LeaveAlternateScreen).context("leave alt screen")?; res } -fn run_monitor(_args: &MonitorArgs) -> Result<()> { - let mut stream = connect_verbose()?; - let mut stats = Stats { - update: None, - render: None, - cpu: None, - mem: None, - }; +fn monitor_device(port: &str, args: &MonitorArgs) -> Result<()> { + let mut port = connect_device(port, args)?; + let mut stats = Stats::default(); + request_device_stats(&mut port, &mut stats)?; + let mut buf = Vec::new(); loop { if should_exit() { return Ok(()); } - let mut buf = vec![0; 64]; - let size = stream.read(&mut buf).context("read response")?; - if size == 0 { - stream = connect().context("reconnecting")?; - continue; + buf = read_device(&mut port, buf, &mut stats)?; + render_stats(&stats).context("render stats")?; + } +} + +/// Connect to running emulator using serial USB port (JTag-over-USB). +fn connect_device(port: &str, args: &MonitorArgs) -> Result { + let port = serialport::new(port, args.baud_rate) + .timeout(Duration::from_millis(10)) + .open() + .context("open the serial port")?; + + execute!( + io::stdout(), + terminal::Clear(terminal::ClearType::All), + cursor::MoveTo(0, 0), + style::Print("waiting for stats..."), + )?; + Ok(port) +} + +fn monitor_emulator() -> Result<()> { + let mut stream = connect_emulator()?; + let mut stats = Stats::default(); + loop { + if should_exit() { + return Ok(()); } - let resp = serial::Response::decode(&buf[..size]).context("decode response")?; - match resp { - serial::Response::Cheat(_) => {} - serial::Response::Fuel(cb, fuel) => { - use serial::Callback::*; - match cb { - Update => stats.update = Some(fuel), - Render => stats.render = Some(fuel), - RenderLine | Cheat | Boot => {} - } - } - serial::Response::CPU(cpu) => { - if cpu.total_ns > 0 { - stats.cpu = Some(cpu); - } - } - serial::Response::Memory(mem) => stats.mem = Some(mem), - }; + stream = read_emulator(stream, &mut stats)?; render_stats(&stats).context("render stats")?; } } +/// Receive and parse one stats message from emulator. +fn read_emulator(mut stream: TcpStream, stats: &mut Stats) -> Result { + let mut buf = vec![0; 64]; + let size = stream.read(&mut buf).context("read response")?; + if size == 0 { + let stream = connect().context("reconnecting")?; + return Ok(stream); + } + parse_stats(stats, &buf[..size])?; + Ok(stream) +} + +/// Receive and parse one stats message from device. +fn read_device(port: &mut Port, mut buf: Vec, stats: &mut Stats) -> Result> { + let mut chunk = vec![0; 64]; + let n = match port.read(chunk.as_mut_slice()) { + Ok(n) => n, + Err(err) => { + if err.kind() == std::io::ErrorKind::TimedOut { + request_device_stats(port, stats)?; + return Ok(buf); + } + return Err(err).context("read from serial port"); + } + }; + + stats.last_msg = Some(Instant::now()); + buf.extend_from_slice(&chunk[..n]); + loop { + let (frame, rest) = advance(&buf); + buf = Vec::from(rest); + if frame.is_empty() { + break; + } + parse_stats(stats, &frame)?; + } + Ok(buf) +} + +/// Send a message into the running device requesting to enable stats collection. +fn request_device_stats(port: &mut Port, stats: &mut Stats) -> Result<()> { + let now = Instant::now(); + let should_update = match stats.last_msg { + Some(last_msg) => { + let elapsed = now - last_msg; + let deadline = Duration::from_secs(2); + elapsed > deadline + } + None => true, + }; + if should_update { + stats.last_msg = Some(now); + let req = serial::Request::Stats(true); + let buf = req.encode_vec().context("encode request")?; + port.write_all(&buf[..]).context("send request")?; + port.flush().context("flush request")?; + }; + Ok(()) +} + +/// Parse raw stats message using postcard. Does NOT handle COBS frames. +fn parse_stats(stats: &mut Stats, buf: &[u8]) -> Result<()> { + let resp = serial::Response::decode(buf).context("decode response")?; + match resp { + serial::Response::Cheat(_) => {} + serial::Response::Log(log) => { + let now = chrono::Local::now().format("%H:%M:%S"); + let log = format!("[{now}] {log}"); + stats.log = Some(log); + } + serial::Response::Fuel(cb, fuel) => { + use serial::Callback::*; + match cb { + Update => stats.update = Some(fuel), + Render => stats.render = Some(fuel), + RenderLine | Cheat | Boot => {} + } + } + serial::Response::CPU(cpu) => { + if cpu.total_ns > 0 { + stats.cpu = Some(cpu); + } + } + serial::Response::Memory(mem) => stats.mem = Some(mem), + }; + Ok(()) +} + /// Connect to a running emulator and enable stats. -fn connect_verbose() -> Result { +fn connect_emulator() -> Result { execute!( io::stdout(), terminal::Clear(terminal::ClearType::All), @@ -125,7 +237,11 @@ fn should_exit() -> bool { false } +/// Display stats in the terminal. fn render_stats(stats: &Stats) -> Result<()> { + if stats.is_default() { + return Ok(()); + } execute!(io::stdout(), terminal::Clear(terminal::ClearType::All))?; if let Some(cpu) = &stats.cpu { render_cpu(cpu).context("render cpu table")?; @@ -139,6 +255,9 @@ fn render_stats(stats: &Stats) -> Result<()> { if let Some(memory) = &stats.mem { render_memory(memory).context("render memory table")?; }; + if let Some(log) = &stats.log { + render_log(log).context("render logs")?; + }; Ok(()) } @@ -256,6 +375,11 @@ fn render_memory(memory: &serial::Memory) -> anyhow::Result<()> { Ok(()) } +fn render_log(log: &str) -> anyhow::Result<()> { + execute!(io::stdout(), cursor::MoveTo(3, 13), style::Print(log),)?; + Ok(()) +} + fn format_ns(ns: u32) -> String { if ns > 10_000_000 { return format!("{:>4} ms", ns / 1_000_000);