//! Runtime LSL keyword catalog with OpenSim capability refresh support. #![allow(clippy::missing_errors_doc)] // Public signatures mirror the compatibility map. use std::collections::HashMap; use std::io::Read as _; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; use std::time::Duration; use base64::Engine as _; use flate2::read::GzDecoder; use libremetaverse_structured_data::{OSD, OSDParser}; use libremetaverse_types::UUID; use libremetaverse_types::compat::{ CancellationToken, EventHandler, FrozenDictionary, Subscription, Uri, }; use crate::{Error, GridClient, LslSyntaxLslCategory, LslSyntaxLslKeyword, Simulator}; const VERSION_KEY: &str = "llsd-lsl-syntax-version"; const SYNTAX_FEATURE: &str = "LSLSyntaxId"; const SYNTAX_CAPABILITY: &str = "LSLSyntax"; const MAX_SYNTAX_BYTES: usize = 8 * 1024 * 1024; const FETCH_WAIT: Duration = Duration::from_secs(20); const DEFAULT_SYNTAX_GZIP_BASE64: &str = include_str!("../resources/keywords_lsl_default.xml.gz.b64"); static KEYWORDS: OnceLock>> = OnceLock::new(); fn keyword_store() -> &'static RwLock> { KEYWORDS.get_or_init(|| RwLock::new(HashMap::new())) } struct SyntaxEvents { next_id: AtomicU64, handlers: Mutex)>>, } impl Default for SyntaxEvents { fn default() -> Self { Self { next_id: AtomicU64::new(1), handlers: Mutex::new(Vec::new()), } } } struct LslSyntaxInner { events: Arc, client: Mutex>, syntax_id: Mutex, subscriptions: Mutex>, } /// Process-wide LSL keyword catalog and per-registration change notification. #[derive(Clone)] pub struct LslSyntax(Arc); impl LslSyntax { pub fn new_with_constructor() -> Result { let syntax = Self::empty(); syntax.load_default()?; Ok(syntax) } pub fn new_with_grid_client(client: GridClient) -> Result { let syntax = Self::empty(); syntax.register(client)?; Ok(syntax) } fn empty() -> Self { Self(Arc::new(LslSyntaxInner { events: Arc::new(SyntaxEvents::default()), client: Mutex::new(None), syntax_id: Mutex::new(UUID::zero()), subscriptions: Mutex::new(Vec::new()), })) } pub fn subscribe_syntax_changed(&self, handler: EventHandler<()>) -> Subscription { let id = self.0.events.next_id.fetch_add(1, Ordering::Relaxed); self.0 .events .handlers .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .push((id, handler)); let events = Arc::downgrade(&self.0.events); Subscription::new(move || { if let Some(events) = events.upgrade() { events .handlers .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .retain(|(candidate, _)| *candidate != id); } }) } pub fn keywords() -> FrozenDictionary { FrozenDictionary( keyword_store() .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(), ) } pub fn register(&self, client: GridClient) -> Result<(), Error> { *self .0 .client .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(client.clone()); // Always establish a useful catalog, including on OpenSim grids that // do not advertise the optional LSLSyntax capability. self.load_default()?; let inner = Arc::clone(&self.0); let network = client.native_network()?; let client_for_change = client.clone(); let changed = network.native_subscribe_sim_changed(Arc::new(move |_| { if let Ok(network) = client_for_change.native_network() && let Some(simulator) = network.native_current_sim() { LslSyntaxInner::observe_simulator(&inner, simulator, false); } })); self.retain_subscription(changed); if let Some(simulator) = network.native_current_sim() { LslSyntaxInner::observe_simulator(&self.0, simulator, true); } Ok(()) } fn retain_subscription(&self, subscription: Subscription) { self.0 .subscriptions .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .push(subscription); } fn load_default(&self) -> Result<(), Error> { let compact = DEFAULT_SYNTAX_GZIP_BASE64 .split_ascii_whitespace() .collect::(); let compressed = base64::engine::general_purpose::STANDARD .decode(compact) .map_err(|_| Error::InvalidOperation)?; let mut decoder = GzDecoder::new(compressed.as_slice()); let mut bytes = Vec::new(); decoder .by_ref() .take((MAX_SYNTAX_BYTES + 1) as u64) .read_to_end(&mut bytes) .map_err(|_| Error::InvalidOperation)?; if bytes.len() > MAX_SYNTAX_BYTES { return Err(Error::Argument); } self.0.parse_and_publish(&bytes) } } impl LslSyntaxInner { fn observe_simulator(this: &Arc, simulator: Simulator, wait: bool) { if let Some(caps) = simulator.native_caps() { let weak = Arc::downgrade(this); let subscription = caps.subscribe_capabilities_received(Some(Arc::new(move |args| { if let Some(inner) = weak.upgrade() { inner.refresh(args.simulator(), false); } }))); this.subscriptions .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .push(subscription); } this.refresh(simulator, wait); } fn refresh(self: &Arc, simulator: Simulator, wait: bool) { let syntax_id = match simulator.features.get(SYNTAX_FEATURE.to_owned()) { Ok(Some(value)) => match value.as_uuid() { Ok(value) => value, Err(_) => return, }, _ => return, }; if *self .syntax_id .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) == syntax_id { return; } let Some(client) = self .client .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone() else { return; }; let cache = cache_path(&client, syntax_id); if let Ok(bytes) = std::fs::read(&cache) && self.parse_and_publish(&bytes).is_ok() { *self .syntax_id .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = syntax_id; return; } let Ok(Some(uri)) = simulator.native_capability_uri(SYNTAX_CAPABILITY) else { return; }; self.fetch(client, uri, syntax_id, cache, wait); } fn fetch( self: &Arc, client: GridClient, uri: Uri, syntax_id: UUID, cache: PathBuf, wait: bool, ) { let inner = Arc::clone(self); let (completed, receiver) = std::sync::mpsc::sync_channel(1); let spawn = std::thread::Builder::new() .name("lsl-syntax-fetch".to_owned()) .spawn(move || { let outcome = fetch_bytes(&client, uri).and_then(|bytes| { inner.parse_and_publish(&bytes)?; persist_cache(&cache, &bytes)?; *inner .syntax_id .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = syntax_id; Ok(()) }); let _ = completed.send(outcome); }); if spawn.is_err() { return; } if wait { let _ = receiver.recv_timeout(FETCH_WAIT); } } fn parse_and_publish(&self, bytes: &[u8]) -> Result<(), Error> { if bytes.is_empty() || bytes.len() > MAX_SYNTAX_BYTES { return Err(Error::Argument); } let OSD::Map(root) = OSDParser::deserialize_with_bytes(bytes.to_vec())? else { return Err(Error::Argument); }; let parsed = parse_keywords(&root)?; if parsed.is_empty() { return Err(Error::Argument); } *keyword_store() .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = parsed; let handlers = self .events .handlers .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .iter() .map(|(_, handler)| Arc::clone(handler)) .collect::>(); for handler in handlers { let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handler(()))); } Ok(()) } } fn fetch_bytes(client: &GridClient, uri: Uri) -> Result, Error> { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .map_err(|_| Error::InvalidOperation)?; let http = client.native_http_caps_client(); let (response, bytes) = runtime.block_on(http.get(uri, CancellationToken::default(), None))?; if !response.is_success_status_code() || bytes.is_empty() || bytes.len() > MAX_SYNTAX_BYTES { return Err(Error::InvalidOperation); } Ok(bytes) } fn cache_path(client: &GridClient, syntax_id: UUID) -> PathBuf { Path::new(&client.settings_ref().asset_cache.dir).join(format!("keywords_lsl_{syntax_id}.xml")) } fn persist_cache(path: &Path, bytes: &[u8]) -> Result<(), Error> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|_| Error::InvalidOperation)?; } std::fs::write(path, bytes).map_err(|_| Error::InvalidOperation) } fn parse_keywords( root: &HashMap, ) -> Result, Error> { if root .get(VERSION_KEY) .map(OSD::as_integer) .transpose()? .is_some_and(|version| version != 2) { return Err(Error::Argument); } let mut keywords = HashMap::new(); let mut groups = root.iter().collect::>(); groups.sort_by(|left, right| left.0.cmp(right.0)); for (group, value) in groups { if group == VERSION_KEY { continue; } let OSD::Map(items) = value else { continue; }; let Some(category) = category(group) else { continue; }; let mut items = items.iter().collect::>(); items.sort_by(|left, right| left.0.cmp(right.0)); for (keyword, value) in items { let OSD::Map(attributes) = value else { continue; }; let tooltip = tooltip(group, keyword, attributes)?; let value = LslSyntaxLslKeyword { category, keyword: keyword.clone(), tooltip, deprecated: boolean_attribute(attributes, "deprecated")?, god_mode: boolean_attribute(attributes, "god-mode")?, }; if keywords.insert(keyword.clone(), value).is_some() { return Err(Error::Argument); } } } Ok(keywords) } fn category(group: &str) -> Option { match group { "controls" => Some(LslSyntaxLslCategory::Control), "types" => Some(LslSyntaxLslCategory::Datatype), "constants" => Some(LslSyntaxLslCategory::Constant), "events" => Some(LslSyntaxLslCategory::Event), "functions" => Some(LslSyntaxLslCategory::Function), _ => None, } } fn tooltip(group: &str, keyword: &str, attributes: &HashMap) -> Result { let mut output = attributes .get("tooltip") .map(OSD::as_string) .transpose()? .unwrap_or_default(); if attributes.contains_key("tooltip") { output.push('\n'); } match group { "constants" => { let type_ = string_attribute(attributes, "type")?; let value = string_attribute(attributes, "value")?; output.push_str(&format!(" Type: {type_}-{value}")); } "events" => { output.push_str(&format!( "{keyword} ({})", parse_arguments(attributes.get("arguments"))? )); } "functions" => { let return_ = string_attribute(attributes, "return")?; output.push_str(&format!( "{return_} {keyword} ({})\n", parse_arguments(attributes.get("arguments"))? )); let energy = attributes .get("energy") .map(OSD::as_string) .transpose()? .unwrap_or_else(|| "0.0".to_owned()); output.push_str(&format!("Energy: {energy}")); if let Some(sleep) = attributes.get("sleep") { output.push_str(&format!(", Sleep: {}", sleep.as_string()?)); } } _ => {} } Ok(output) } fn parse_arguments(value: Option<&OSD>) -> Result { let Some(OSD::Array(arguments)) = value else { return Ok(String::new()); }; let mut rendered = Vec::new(); for argument in arguments { let OSD::Map(argument) = argument else { continue; }; let mut values = argument.iter().collect::>(); values.sort_by(|left, right| left.0.cmp(right.0)); for (name, type_) in values { rendered.push(format!("{} {name}", type_.as_string()?)); } } Ok(rendered.join(", ")) } fn string_attribute(attributes: &HashMap, name: &str) -> Result { attributes .get(name) .map(OSD::as_string) .transpose() .map(Option::unwrap_or_default) .map_err(Into::into) } fn boolean_attribute(attributes: &HashMap, name: &str) -> Result { attributes .get(name) .map(OSD::as_boolean) .transpose() .map(|value| value.unwrap_or(false)) .map_err(Into::into) } #[cfg(test)] mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; static TEST_LOCK: Mutex<()> = Mutex::new(()); #[test] fn embedded_catalog_is_complete_and_typed() { let _guard = TEST_LOCK.lock().unwrap(); let _syntax = LslSyntax::new_with_constructor().unwrap(); let keywords = LslSyntax::keywords().0; assert!(keywords.len() > 500); assert_eq!( keywords.get("llSay").unwrap().category, LslSyntaxLslCategory::Function ); assert_eq!( keywords.get("state_entry").unwrap().category, LslSyntaxLslCategory::Event ); assert_eq!( keywords.get("integer").unwrap().category, LslSyntaxLslCategory::Datatype ); } #[test] fn parsed_catalog_replaces_snapshot_and_notifies_subscribers() { let _guard = TEST_LOCK.lock().unwrap(); let syntax = LslSyntax::empty(); let notifications = Arc::new(AtomicUsize::new(0)); let observed = Arc::clone(¬ifications); let _subscription = syntax.subscribe_syntax_changed(Arc::new(move |()| { observed.fetch_add(1, Ordering::Relaxed); })); let function = OSD::Map(HashMap::from([ ("return".to_owned(), OSD::String("integer".to_owned())), ("energy".to_owned(), OSD::String("10.0".to_owned())), ("deprecated".to_owned(), OSD::Boolean(true)), ( "arguments".to_owned(), OSD::Array(vec![OSD::Map(HashMap::from([( "message".to_owned(), OSD::String("string".to_owned()), )]))]), ), ])); let document = OSD::Map(HashMap::from([ (VERSION_KEY.to_owned(), OSD::Integer(2)), ( "functions".to_owned(), OSD::Map(HashMap::from([("osTest".to_owned(), function)])), ), ])); let bytes = OSDParser::serialize_llsd_xml_bytes(document).unwrap(); syntax.0.parse_and_publish(&bytes).unwrap(); assert_eq!(notifications.load(Ordering::Relaxed), 1); let keywords = LslSyntax::keywords().0; let keyword = keywords.get("osTest").unwrap(); assert!(keyword.deprecated); assert_eq!(keyword.category, LslSyntaxLslCategory::Function); assert!(keyword.tooltip.contains("integer osTest (string message)")); assert!(keyword.tooltip.contains("Energy: 10.0")); } #[test] fn registration_on_opensim_without_syntax_capability_uses_default_catalog() { let _guard = TEST_LOCK.lock().unwrap(); let client = GridClient::builder().build().unwrap(); let syntax = LslSyntax::new_with_grid_client(client).unwrap(); assert!(LslSyntax::keywords().0.contains_key("llOwnerSay")); assert_eq!( syntax .0 .subscriptions .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .len(), 1 ); } }