//! A Rust server root: that it is one, and which modding framework it runs. //! //! **The framework is detected, never asked** (docs/modules/rust/PLAN.md §34.2.3). The plugin is one //! file that runs unchanged on Oxide and on Carbon (R19); what differs is only the directory it goes //! in, and the server itself already says which framework it has. Asking would be one more answer an //! operator could get wrong. //! //! The marker files were read off the two rigs (2026-09-26), one per framework, before this code //! relied on them: //! //! - **Oxide:** `RustDedicated_Data/Managed/Oxide.Rust.dll`. //! - **Carbon:** `carbon/managed/Carbon.dll`. //! //! Both present is refused: Oxide and Carbon cannot run in one install (R21), so a tree carrying both //! is one mid-migration, and guessing would put the plugin where one of them never looks. //! //! **Carbon's directories are its defaults.** The plan expected a moved plugins directory to be //! readable from `carbon/config.json`; on 2.0.259, the build R19 was proven on, that file has no //! folder keys at all — Carbon's directories move only by a launch argument, which an installer //! cannot see. `carbon/plugins/` and `carbon/configs/` are what every stock install uses. use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; /// Which framework loads the plugin. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Framework { Oxide, Carbon, } impl Framework { pub fn as_str(self) -> &'static str { match self { Self::Oxide => "oxide", Self::Carbon => "carbon", } } /// The file whose presence says this framework is installed. fn marker(self, root: &Path) -> PathBuf { match self { Self::Oxide => root .join("RustDedicated_Data") .join("Managed") .join("Oxide.Rust.dll"), Self::Carbon => root.join("carbon").join("managed").join("Carbon.dll"), } } /// Where the framework loads plugins from. pub fn plugins_dir(self, root: &Path) -> PathBuf { match self { Self::Oxide => root.join("oxide").join("plugins"), Self::Carbon => root.join("carbon").join("plugins"), } } /// Where the framework keeps each plugin's config. Oxide says `config`, Carbon `configs`. pub fn config_dir(self, root: &Path) -> PathBuf { match self { Self::Oxide => root.join("oxide").join("config"), Self::Carbon => root.join("carbon").join("configs"), } } } /// A validated Rust server root. #[derive(Debug, Clone)] pub struct RustServer { pub path: PathBuf, pub framework: Framework, } /// The file this installer owns in a server root: the plugin. pub const PLUGIN_FILE: &str = "RunicGateway.cs"; /// The plugin's config, which is the website's once written (§34.2.3). pub const PLUGIN_CONFIG: &str = "RunicGateway.json"; impl RustServer { pub fn plugins_dir(&self) -> PathBuf { self.framework.plugins_dir(&self.path) } pub fn plugin_path(&self) -> PathBuf { self.plugins_dir().join(PLUGIN_FILE) } pub fn plugin_config_path(&self) -> PathBuf { self.framework.config_dir(&self.path).join(PLUGIN_CONFIG) } } /// Validates `path` as a Rust server root and detects its framework. pub fn open(path: &Path) -> Result { let path = std::fs::canonicalize(path) .map(crate::servuo::strip_extended_prefix) .with_context(|| format!("{} does not exist", path.display()))?; if !path.is_dir() { bail!("{} is not a directory", path.display()); } if !path.join("RustDedicated").is_file() && !path.join("RustDedicated.exe").is_file() { bail!( "{} is not a Rust server root: it has no RustDedicated or RustDedicated.exe", path.display() ); } let oxide = Framework::Oxide.marker(&path).is_file(); let carbon = Framework::Carbon.marker(&path).is_file(); let framework = match (oxide, carbon) { (true, false) => Framework::Oxide, (false, true) => Framework::Carbon, (true, true) => bail!( "{} has both Oxide ({}) and Carbon ({}). They cannot run in one install, so this tree \ is part-way through a migration. Remove the one you are leaving, then run again.", path.display(), Framework::Oxide.marker(&path).display(), Framework::Carbon.marker(&path).display() ), (false, false) => bail!( "{} has neither Oxide nor Carbon installed, and the bridge is a plugin for one of \ them. Install one (looked for {} and {}), start the server once so it lays out its \ directories, and run this again.", path.display(), Framework::Oxide.marker(&path).display(), Framework::Carbon.marker(&path).display() ), }; Ok(RustServer { path, framework }) } /// Whether a RustDedicated is running out of this root. /// /// Informational, unlike ServUO's refusal: both frameworks load and unload a plugin file while the /// server runs, so an install under a live server is simply one that takes effect at once (§34.2.3). /// `doctor` also uses it, to tell a server that is down from one whose plugin is silent. pub fn is_running(root: &Path) -> bool { use sysinfo::{ProcessRefreshKind, RefreshKind, System}; let system = System::new_with_specifics( RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()), ); let root = normalize(&root.to_string_lossy()); system.processes().values().any(|process| { let exe = process .exe() .map(|p| normalize(&p.to_string_lossy())) .unwrap_or_default(); exe.starts_with(&root) && exe.contains("rustdedicated") }) } fn normalize(s: &str) -> String { s.to_lowercase().replace('\\', "/") } #[cfg(test)] mod tests { use super::*; use crate::util::TempDir; fn server(dir: &Path, frameworks: &[Framework]) { std::fs::write(dir.join("RustDedicated"), b"").unwrap(); for f in frameworks { let marker = f.marker(dir); std::fs::create_dir_all(marker.parent().unwrap()).unwrap(); std::fs::write(marker, b"").unwrap(); } } #[test] fn each_framework_is_detected_and_places_the_plugin_its_own_way() { for (f, plugins, config) in [ (Framework::Oxide, "oxide/plugins", "oxide/config"), (Framework::Carbon, "carbon/plugins", "carbon/configs"), ] { let dir = TempDir::new("rg-rust-root").unwrap(); server(dir.path(), &[f]); let root = open(dir.path()).unwrap(); assert_eq!(root.framework, f); let rel = |p: PathBuf| { p.strip_prefix(&root.path) .unwrap() .to_string_lossy() .replace('\\', "/") }; assert_eq!(rel(root.plugins_dir()), plugins); assert_eq!( rel(root.plugin_config_path()), format!("{config}/RunicGateway.json") ); } } #[test] fn both_frameworks_are_refused_and_so_is_neither() { let dir = TempDir::new("rg-rust-both").unwrap(); server(dir.path(), &[Framework::Oxide, Framework::Carbon]); let err = open(dir.path()).unwrap_err().to_string(); assert!( err.contains("both Oxide") && err.contains("Carbon"), "{err}" ); let dir = TempDir::new("rg-rust-none").unwrap(); server(dir.path(), &[]); let err = open(dir.path()).unwrap_err().to_string(); assert!(err.contains("neither Oxide nor Carbon"), "{err}"); } #[test] fn a_directory_without_the_game_is_not_a_server_root() { let dir = TempDir::new("rg-rust-empty").unwrap(); let err = open(dir.path()).unwrap_err().to_string(); assert!(err.contains("RustDedicated"), "{err}"); } }