diff --git a/Cargo.lock b/Cargo.lock index fb9a221..34ed946 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,6 +40,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", "getrandom 0.3.4", "once_cell", "version_check", @@ -1838,6 +1839,7 @@ dependencies = [ "libretro-ffi", "reqwest", "rfd", + "rhai", "rubato", "rust-ini", "serde", @@ -1911,6 +1913,15 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +dependencies = [ + "spin", +] + [[package]] name = "nom" version = "7.1.3" @@ -2312,6 +2323,9 @@ name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "portable-atomic", +] [[package]] name = "openssl-probe" @@ -2812,6 +2826,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rhai" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1" +dependencies = [ + "ahash", + "bitflags 2.10.0", + "no-std-compat", + "num-traits", + "once_cell", + "rhai_codegen", + "smallvec", + "smartstring", + "thin-vec", + "web-time", +] + +[[package]] +name = "rhai_codegen" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4322a2a4e8cf30771dd9f27f7f37ca9ac8fe812dddd811096a98483080dabe6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ring" version = "0.17.14" @@ -3153,6 +3196,17 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + [[package]] name = "smithay-client-toolkit" version = "0.19.2" @@ -3197,6 +3251,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" @@ -3297,6 +3357,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thin-vec" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d" + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index 698b01a..54ce47a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ libloading = "0.9.0" libretro-ffi = { version = "0.1.0", path = "libretro-ffi" } reqwest = "0.13.1" rfd = "0.17.1" +rhai = { version = "1.24.0", features = ["sync"] } rubato = "1.0.0" rust-ini = "0.21.0" serde = { version = "1.0.193", features = ["derive"] } diff --git a/src/core/mod.rs b/src/core/mod.rs index f371e71..7ee17fb 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -38,6 +38,7 @@ pub struct Core { savestate_buffer: Box<[u8]>, } unsafe impl Send for Core {} +unsafe impl Sync for Core {} #[expect(clippy::type_complexity)] pub struct CoreImpl { @@ -47,7 +48,7 @@ pub struct CoreImpl { frame: Frame, input: *const [u8], input_ports: *const [InputPort], - audio_callback: Option<*mut dyn FnMut(&[AudioFrame])>, + audio_callback: Option<*mut (dyn FnMut(&[AudioFrame]) + Send)>, trace_context: *mut retro_trace_ctx_t, trace_buffer: Vec, } @@ -55,6 +56,12 @@ pub struct CoreImpl { // The input_callback cannot be safely sent across threads, but we are careful to never do so. unsafe impl Send for CoreImpl {} +impl Drop for CoreImpl { + fn drop(&mut self) { + unsafe { (symbols().retro_deinit)() } + } +} + pub struct Frame { pub width: usize, pub height: usize, @@ -171,11 +178,20 @@ impl Core { lock().get_memory_byte(addr).copied() } + pub fn read_memory_le(&self, addr: usize, width: u8) -> Option { + let mut core = lock(); + let mut v = 0; + for offset in 0..width as usize { + v |= (*core.get_memory_byte(addr + offset)? as u64) << (offset * 8); + } + Some(v) + } + pub fn run_frame( &mut self, input: &[u8], input_ports: &[crate::tas::input::InputPort], - mut audio_callback: impl FnMut(&[AudioFrame]), + mut audio_callback: impl FnMut(&[AudioFrame]) + Send, ) { let run = *symbols().retro_run; unsafe { @@ -186,8 +202,8 @@ impl Core { // used during the execution of this function. The explicit transmute is needed // due to https://github.com/rust-lang/rust/pull/136776 core.audio_callback = Some(std::mem::transmute::< - *mut dyn FnMut(&[AudioFrame]), - *mut (dyn FnMut(&[AudioFrame]) + 'static), + *mut (dyn FnMut(&[AudioFrame]) + Send), + *mut (dyn FnMut(&[AudioFrame]) + Send + 'static), >( std::ptr::addr_of_mut!(audio_callback) as *mut _ )); diff --git a/src/main.rs b/src/main.rs index 11f8953..f9bec04 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use anyhow::Result; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod core; +mod rhai; mod tas; mod ui; diff --git a/src/rhai.rs b/src/rhai.rs new file mode 100644 index 0000000..fa31a00 --- /dev/null +++ b/src/rhai.rs @@ -0,0 +1,73 @@ +use crate::core::Core; + +/// Extension trait for [`rhai::Engine`]. +pub trait EngineExt { + /// Register the `mem` rhai module. + /// + /// This module contains functions to interact with the guest's memory. + fn register_mem_module(&mut self); +} + +impl EngineExt for rhai::Engine { + fn register_mem_module(&mut self) { + self.register_static_module("mem", mem().into()); + } +} + +type RhaiResult = Result>; + +fn mem() -> rhai::Module { + let mut module = rhai::Module::new(); + rhai::FuncRegistration::new("u8") + .with_volatility(true) + .set_into_module( + &mut module, + |ctx: rhai::NativeCallContext, address: i64| -> RhaiResult { + let c: &Core = ctx.tag().unwrap().clone().cast(); + usize::try_from(address) + .ok() + .and_then(|address| c.read_memory_byte(address)) + .ok_or_else(|| format!("out of bounds read at {address}").into()) + }, + ); + rhai::FuncRegistration::new("u16le") + .with_volatility(true) + .set_into_module( + &mut module, + |ctx: rhai::NativeCallContext, address: i64| -> RhaiResult { + let c: &Core = ctx.tag().unwrap().clone().cast(); + let res = usize::try_from(address) + .ok() + .and_then(|address| c.read_memory_le(address, 2)) + .ok_or_else(|| format!("out of bounds read of 2 bytes at {address}"))?; + Ok(res as u16) + }, + ); + rhai::FuncRegistration::new("u32le") + .with_volatility(true) + .set_into_module( + &mut module, + |ctx: rhai::NativeCallContext, address: i64| -> RhaiResult { + let c: &Core = ctx.tag().unwrap().clone().cast(); + let res = usize::try_from(address) + .ok() + .and_then(|address| c.read_memory_le(address, 4)) + .ok_or_else(|| format!("out of bounds read of 4 bytes at {address}"))?; + Ok(res as u32) + }, + ); + rhai::FuncRegistration::new("u64le") + .with_volatility(true) + .set_into_module( + &mut module, + |ctx: rhai::NativeCallContext, address: i64| -> RhaiResult { + let c: &Core = ctx.tag().unwrap().clone().cast(); + usize::try_from(address) + .ok() + .and_then(|address| c.read_memory_le(address, 8)) + .ok_or_else(|| format!("out of bounds read of 8 bytes at {address}").into()) + }, + ); + + module +} diff --git a/src/tas/mod.rs b/src/tas/mod.rs index 1d98a24..d376bc2 100644 --- a/src/tas/mod.rs +++ b/src/tas/mod.rs @@ -153,12 +153,12 @@ impl Tas { &self.movie } - pub fn read_ram_watch(&self, watch: &movie::RamWatch) -> Option { - let mut v = 0; - for offset in 0..watch.format.width as usize { - v |= (self.core.read_memory_byte(watch.address + offset)? as u64) << (offset * 8); - } - Some(v) + pub fn read_ram_watch( + &self, + watch: &movie::RamWatch, + rhai_engine: &mut rhai::Engine, + ) -> anyhow::Result { + watch.value.execute(&self.core, rhai_engine) } pub fn ramwatches_mut(&mut self) -> &mut Vec { @@ -195,7 +195,7 @@ impl Tas { pub fn run_guest_frame( &mut self, - audio_callback: &mut impl FnMut(&[core::AudioFrame]), + audio_callback: &mut (impl FnMut(&[core::AudioFrame]) + Send), ) -> &core::Frame { self.trace = None; if self @@ -237,7 +237,7 @@ impl Tas { pub fn run_host_frame( &mut self, - mut audio_callback: impl FnMut(&[core::AudioFrame]), + mut audio_callback: impl FnMut(&[core::AudioFrame]) + Send, ) -> &core::Frame { // Determine how many guest frames have elapsed since the last host frame let time = Instant::now(); diff --git a/src/tas/movie/mod.rs b/src/tas/movie/mod.rs index 5b7819e..5041fd4 100644 --- a/src/tas/movie/mod.rs +++ b/src/tas/movie/mod.rs @@ -1,10 +1,10 @@ -use std::rc::Rc; +use std::{rc::Rc, sync::OnceLock}; use anyhow::Context; use serde::{Deserialize, Serialize}; use crate::{ - core, + core::{self, Core}, tas::edit::{Pattern, PatternBuf}, }; @@ -35,11 +35,60 @@ pub struct Movie { #[derive(Serialize, Deserialize, Clone)] pub struct RamWatch { pub name: String, - pub address: usize, - pub format: RamWatchFormat, + #[serde(flatten)] + pub value: RamWatchValue, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RamWatchValue { + /// Simple RAM watch value that fetches from an address using a specific format. + Simple { + address: usize, + format: RamWatchFormat, + }, + /// Complex RAM watch value that runs a rhai script to compute the value + RhaiScript { + source: String, + /// The AST is initialized on demand. + /// Since the source may be invalid (e.g. from a hand-edited file), the ast may fail to + /// compile and this is stored as a `Result`. + #[serde(skip)] + ast: OnceLock>, + }, +} + +impl RamWatchValue { + pub fn execute(&self, core: &Core, rhai_engine: &mut rhai::Engine) -> anyhow::Result { + match self { + Self::Simple { address, format } => Ok(format.format_value( + core.read_memory_le(*address, format.width) + .with_context(|| { + format!("out of bounds read at {address} (format: {format:?})") + })?, + )), + Self::RhaiScript { source, ast } => { + let ast = match ast.get_or_init(|| rhai_engine.compile(source)) { + Ok(v) => v, + Err(err) => { + return Err(err.clone()) + .context("failed to compile RAM watch expression")?; + } + }; + rhai_engine.set_default_tag(rhai::Dynamic::from(unsafe { + std::mem::transmute::<&Core, &'static Core>(core) + })); + let result = rhai_engine.eval_ast::(ast); + rhai_engine.set_default_tag(0); + Ok(result + .context("failed to execute RAM watch script")? + .to_string()) + } + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct RamWatchFormat { pub width: u8, pub hex: bool, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2fe1175..888c57c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -224,6 +224,10 @@ impl Ui { ignore_events |= self.draw_hash_mismatch(ui); if let Some(error) = &self.reported_error { + if error.is_fatal { + return; + } + ignore_events = true; const ID: &str = "Error"; match ui @@ -245,6 +249,7 @@ impl Ui { } else { if ui.button("Close") { ui.close_current_popup(); + self.reported_error = None; } } } @@ -252,9 +257,6 @@ impl Ui { ui.open_popup(ID); } } - if error.is_fatal { - return; - } } if let Some(download_progress) = self.download_progress.as_ref() { diff --git a/src/ui/ramwatch.rs b/src/ui/ramwatch.rs index 17b9674..dd85275 100644 --- a/src/ui/ramwatch.rs +++ b/src/ui/ramwatch.rs @@ -1,19 +1,27 @@ use imgui::Ui; -use crate::tas::{Tas, movie}; +use crate::{ + rhai::EngineExt, + tas::{Tas, movie}, +}; +use std::sync::OnceLock; pub struct RamWatch { pub opened: bool, editor: Editor, dragged_item: Option, + rhai_engine: rhai::Engine, } struct Editor { state: EditorState, should_focus: bool, name: String, + /// `true` if script must be used instead of address and format + use_script: bool, address: String, format: movie::RamWatchFormat, + rhai_script: String, } #[derive(Copy, Clone, PartialEq)] @@ -26,23 +34,41 @@ enum EditorState { impl Editor { fn init_from_ram_watch(&mut self, watch: &movie::RamWatch) { self.name = watch.name.clone(); - self.address = format!("{:#04X}", watch.address); - self.format = watch.format.clone(); + match &watch.value { + movie::RamWatchValue::Simple { address, format } => { + self.use_script = false; + self.address = format!("{:#04X}", address); + self.format = format.clone(); + } + movie::RamWatchValue::RhaiScript { source, .. } => { + self.use_script = true; + self.rhai_script = source.clone(); + } + } } - fn to_ram_watch(&self) -> Option { + fn to_ram_watch(&self, rhai_engine: &rhai::Engine) -> Option { let name = self.name.trim().to_string(); if name.is_empty() { return None; } - let address = - usize::from_str_radix(self.address.strip_prefix("0x").unwrap_or(&self.address), 16) - .ok()?; - Some(movie::RamWatch { - name, - address, - format: self.format.clone(), - }) + let value = if self.use_script { + let ast = rhai_engine.compile(&self.rhai_script).ok()?; + movie::RamWatchValue::RhaiScript { + source: self.rhai_script.clone(), + ast: OnceLock::from(Ok(ast)), + } + } else { + movie::RamWatchValue::Simple { + address: usize::from_str_radix( + self.address.strip_prefix("0x").unwrap_or(&self.address), + 16, + ) + .ok()?, + format: self.format.clone(), + } + }; + Some(movie::RamWatch { name, value }) } } @@ -52,22 +78,27 @@ impl Default for Editor { state: EditorState::Closed, should_focus: false, name: String::new(), + use_script: false, address: String::new(), format: movie::RamWatchFormat { width: 1, hex: true, signed: false, }, + rhai_script: String::new(), } } } impl RamWatch { pub fn new() -> RamWatch { + let mut rhai_engine = rhai::Engine::new(); + rhai_engine.register_mem_module(); RamWatch { opened: true, editor: Editor::default(), dragged_item: None, + rhai_engine, } } @@ -93,43 +124,58 @@ impl RamWatch { } ui.input_text("Name", &mut self.editor.name).build(); - ui.input_text("Address", &mut self.editor.address).build(); - - let group = ui.begin_group(); - ui.columns(2, "ram_watch_editor", false); - - ui.text("Size"); - ui.radio_button("8 bits", &mut self.editor.format.width, 1); - ui.radio_button("16 bits", &mut self.editor.format.width, 2); - ui.radio_button("32 bits", &mut self.editor.format.width, 3); - ui.radio_button("64 bits", &mut self.editor.format.width, 4); - + ui.columns(2, "ram_watch_editor_script_switch", false); + ui.radio_button("Use address", &mut self.editor.use_script, false); ui.next_column(); - - ui.text("Format"); - ui.radio_button("Hex", &mut self.editor.format.hex, true); - ui.radio_button("Decimal", &mut self.editor.format.hex, false); - - ui.checkbox("Signed", &mut self.editor.format.signed); - group.end(); + ui.radio_button("Use script", &mut self.editor.use_script, true); + ui.columns(1, "ram_watch_editor_script_switch_end", false); + + if self.editor.use_script { + let mut text_size = ui.content_region_avail(); + text_size[1] -= ui.text_line_height_with_spacing() * 2.; + ui.input_text_multiline("Script", &mut self.editor.rhai_script, text_size) + .no_horizontal_scroll(true) + .build(); + } else { + ui.input_text("Address", &mut self.editor.address).build(); + + let group = ui.begin_group(); + ui.columns(2, "ram_watch_editor", false); + + ui.text("Size"); + ui.radio_button("8 bits", &mut self.editor.format.width, 1); + ui.radio_button("16 bits", &mut self.editor.format.width, 2); + ui.radio_button("32 bits", &mut self.editor.format.width, 3); + ui.radio_button("64 bits", &mut self.editor.format.width, 4); + + ui.next_column(); + + ui.text("Format"); + ui.radio_button("Hex", &mut self.editor.format.hex, true); + ui.radio_button("Decimal", &mut self.editor.format.hex, false); + + ui.checkbox("Signed", &mut self.editor.format.signed); + group.end(); + } ui.columns(1, "ram_watch_confirm", false); - let button_width = ui.calc_text_size(" Cancel OK ")[0]; + let button_width = ui.calc_text_size(" Cancel OK ")[0]; ui.set_cursor_pos([ ui.window_size()[0] - button_width, ui.window_size()[1] - ui.text_line_height_with_spacing() * 2., ]); - if ui.button("Cancel") || ui.is_key_pressed(imgui::Key::Escape) { ui.close_current_popup(); self.editor.state = EditorState::Closed; } ui.same_line(); - let watch = self.editor.to_ram_watch(); + let watch = self.editor.to_ram_watch(&self.rhai_engine); ui.enabled(watch.is_some(), || { if ui.button("OK") - || (watch.is_some() && ui.is_key_pressed(imgui::Key::Enter)) + || (watch.is_some() + && !self.editor.use_script + && ui.is_key_pressed(imgui::Key::Enter)) { ui.close_current_popup(); let watch = watch.unwrap(); @@ -157,12 +203,16 @@ impl RamWatch { .opened(&mut self.opened) .size([128., 448.], imgui::Condition::FirstUseEver) .build(|| { - if let Some(_token) = ui.begin_table("ram_watches", 3) { + if let Some(_token) = ui.begin_table_with_flags( + "ram_watches", + 3, + imgui::TableFlags::SIZING_FIXED_FIT | imgui::TableFlags::RESIZABLE, + ) { // 3 columns: name, value, delete ui.table_setup_column("Name"); ui.table_setup_column_with(imgui::TableColumnSetup { name: "Value", - flags: imgui::TableColumnFlags::WIDTH_FIXED, + flags: imgui::TableColumnFlags::WIDTH_STRETCH, init_width_or_weight: ui.calc_text_size("00000000")[0], ..Default::default() }); @@ -209,8 +259,13 @@ impl RamWatch { ui.text(&watch.name); ui.table_next_column(); - if let Some(v) = tas.read_ram_watch(watch) { - ui.text(watch.format.format_value(v)); + match tas.read_ram_watch(watch, &mut self.rhai_engine) { + Ok(text) => ui.text(text), + Err(err) => { + ui.text(format!("{err:#}")); + eprintln!("failed to execute ramwatch: {err:#}"); + eprintln!("Sources: {:?}", watch.value); + } } ui.table_next_column();