diff --git a/docs/configuration.md b/docs/configuration.md index 70f5e082..1d0d60fc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,7 +10,14 @@ The easiest way to configure XEarthLayer is with the interactive setup wizard: xearthlayer setup ``` -The wizard auto-detects your X-Plane installation, system hardware (CPU, memory, storage type), and recommends optimal settings. It handles all the configuration below automatically. +The wizard auto-detects your X-Plane installation, system hardware (CPU, memory, storage type), free disk space, and GPU adapters, then recommends optimal settings. The wizard runs four steps: + +1. **X-Plane Custom Scenery** — auto-detected from your X-Plane install, with fallback to manual entry. +2. **Package Location** — where regional scenery packages live on disk. +3. **Cache Configuration** — cache directory, disk cache size (defaults to 25% of free space, floored to 10 GB), DDS-to-chunk disk ratio, memory cache size (defaults to RAM ÷ 12, clamped to 500 MB – RAM ÷ 4), and disk I/O profile (NVMe / SSD / HDD / auto). +4. **DDS Encoding** — picks ISPC (CPU) by default, or offers to offload encoding to a secondary GPU when **two or more GPU adapters** are detected. The wizard warns against picking the GPU X-Plane renders on. Single-GPU systems skip the choice and stay on ISPC. + +GPU enumeration can take 10–30 seconds on multi-adapter systems while drivers are probed; a spinner shows progress. ## Manual Configuration diff --git a/xearthlayer-cli/src/commands/setup/wizard.rs b/xearthlayer-cli/src/commands/setup/wizard.rs index 9ecbc0f9..72985924 100644 --- a/xearthlayer-cli/src/commands/setup/wizard.rs +++ b/xearthlayer-cli/src/commands/setup/wizard.rs @@ -10,19 +10,24 @@ //! //! - System detection: [`xearthlayer::system::SystemInfo`] //! - Recommendations: [`xearthlayer::system::RecommendedSettings`] +//! - GPU enumeration: [`xearthlayer::system::gpu`] //! - X-Plane detection: [`xearthlayer::config::detect_scenery_dir`] //! - Configuration: [`xearthlayer::config::ConfigFile`] -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::time::Duration; use console::style; use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; +use indicatif::{ProgressBar, ProgressStyle}; use xearthlayer::config::DiskIoProfile; use xearthlayer::config::{ - config_file_path, detect_scenery_dir, format_size, ConfigFile, SceneryDetectionResult, + config_file_path, detect_scenery_dir, format_size, ConfigFile, SceneryDetectionResult, GB, MB, +}; +use xearthlayer::system::{ + enumerate_gpus, GpuAdapter, SystemInfo, MIN_DISK_CACHE_BYTES, MIN_MEMORY_CACHE_BYTES, }; -use xearthlayer::system::SystemInfo; use crate::error::CliError; @@ -41,27 +46,20 @@ pub struct SetupConfig { pub memory_cache_size: usize, /// Disk cache size in bytes pub disk_cache_size: usize, + /// Fraction of disk cache budget allocated to encoded DDS tiles + pub dds_disk_ratio: f64, + /// Texture compressor backend: "software", "ispc", or "gpu" + pub texture_compressor: String, + /// GPU device selector when compressor is "gpu". Ignored otherwise but + /// always written to keep the config file shape consistent. + pub texture_gpu_device: String, } /// Run the interactive setup wizard. pub fn run_wizard() -> Result<(), CliError> { let theme = ColorfulTheme::default(); - // Print banner - println!(); - println!( - "{}", - style("╔══════════════════════════════════════════════════╗").cyan() - ); - println!( - "{}", - style("║ XEarthLayer Setup Wizard ║").cyan() - ); - println!( - "{}", - style("╚══════════════════════════════════════════════════╝").cyan() - ); - println!(); + print_banner(); // Check for existing config let config_path = config_file_path(); @@ -77,7 +75,6 @@ pub fn run_wizard() -> Result<(), CliError> { println!(); } ExistingConfigAction::BackupAndReplace => { - // Create backup let backup_path = config_path.with_extension("ini.backup"); std::fs::copy(&config_path, &backup_path) .map_err(|e| CliError::Config(format!("Failed to backup config: {}", e)))?; @@ -88,50 +85,63 @@ pub fn run_wizard() -> Result<(), CliError> { } // Step 1: X-Plane Custom Scenery - println!( - "{}", - style("Step 1: X-Plane Custom Scenery").bold().underlined() - ); - println!(); + print_step_header("Step 1: X-Plane Custom Scenery"); let xplane_scenery_dir = step_xplane(&theme)?; // Step 2: Package Location - println!(); - println!("{}", style("Step 2: Package Location").bold().underlined()); - println!(); + print_step_header("Step 2: Package Location"); let package_dir = step_package_location(&theme)?; - // Step 3: Cache Location - println!(); - println!("{}", style("Step 3: Cache Location").bold().underlined()); - println!(); - let cache_dir = step_cache_location(&theme)?; + // Step 3: Cache Configuration (directory + budgets + I/O profile) + print_step_header("Step 3: Cache Configuration"); + let cache_settings = step_cache(&theme)?; - // Step 4: System Configuration - println!(); - println!( - "{}", - style("Step 4: System Configuration").bold().underlined() - ); - println!(); - let system_info = SystemInfo::detect(&cache_dir); - let (memory_cache_size, disk_cache_size, disk_io_profile) = - step_system_config(&theme, &system_info)?; + // Step 4: DDS Encoding (GPU selection if multi-GPU) + print_step_header("Step 4: DDS Encoding"); + let (texture_compressor, texture_gpu_device) = step_encoding(&theme)?; // Build setup config let setup_config = SetupConfig { xplane_scenery_dir, package_dir, - cache_dir, - disk_io_profile, - memory_cache_size, - disk_cache_size, + cache_dir: cache_settings.cache_dir, + disk_io_profile: cache_settings.disk_io_profile, + memory_cache_size: cache_settings.memory_cache_size, + disk_cache_size: cache_settings.disk_cache_size, + dds_disk_ratio: cache_settings.dds_disk_ratio, + texture_compressor, + texture_gpu_device, }; - // Write configuration write_config(&setup_config)?; + print_completion_message(); + Ok(()) +} + +fn print_banner() { + println!(); + println!( + "{}", + style("╔══════════════════════════════════════════════════╗").cyan() + ); + println!( + "{}", + style("║ XEarthLayer Setup Wizard ║").cyan() + ); + println!( + "{}", + style("╚══════════════════════════════════════════════════╝").cyan() + ); + println!(); +} + +fn print_step_header(title: &str) { + println!(); + println!("{}", style(title).bold().underlined()); + println!(); +} - // Print completion message +fn print_completion_message() { println!(); println!( "{}", @@ -162,8 +172,6 @@ pub fn run_wizard() -> Result<(), CliError> { println!(" 3. Start XEarthLayer:"); println!(" {}", style("xearthlayer").cyan()); println!(); - - Ok(()) } /// Action to take when config already exists. @@ -316,23 +324,75 @@ fn step_package_location(theme: &ColorfulTheme) -> Result { } } -/// Step 3: Configure cache location. -fn step_cache_location(theme: &ColorfulTheme) -> Result { - let default_path = dirs::home_dir() +/// Output of Step 3, the consolidated cache configuration. +struct CacheSettings { + cache_dir: PathBuf, + disk_io_profile: DiskIoProfile, + memory_cache_size: usize, + disk_cache_size: usize, + dds_disk_ratio: f64, +} + +/// Step 3: Cache directory + disk budget + memory budget + I/O profile. +/// +/// This step absorbs what used to be split between "cache location" and +/// "system configuration" — the budgets are derived from system info, so +/// it makes more sense for them to live next to the cache directory choice. +fn step_cache(theme: &ColorfulTheme) -> Result { + let default_cache_dir = dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".xearthlayer") .join("cache"); - println!("Where should XEarthLayer store cached tiles?"); - println!(); - println!("Default: {}", style(default_path.display()).cyan()); + // 3a. Cache directory selection + let cache_dir = prompt_cache_directory(theme, &default_cache_dir)?; - // Detect storage type for default location - let storage_info = SystemInfo::detect(&default_path); + // 3b. Detect hardware for the chosen cache directory + let system_info = SystemInfo::detect(&cache_dir); + println!(); + println!("{}", style("Detected Hardware:").bold()); + println!(" CPU Cores: {}", style(system_info.cpu_cores).cyan()); + println!( + " System Memory: {}", + style(system_info.memory_display()).cyan() + ); println!( - "Detected storage: {}", - style(storage_info.storage_display()).cyan() + " Cache Storage: {}", + style(system_info.storage_display()).cyan() ); + if system_info.cache_path_available_bytes > 0 { + println!( + " Available: {}", + style(format_size(system_info.cache_path_available_bytes as usize)).cyan() + ); + } + println!(); + + // 3c. Disk cache size + let disk_cache_size = prompt_disk_cache_size(theme, &system_info)?; + + // 3d. DDS disk ratio + let dds_disk_ratio = prompt_dds_disk_ratio(theme)?; + + // 3e. Memory cache size + let memory_cache_size = prompt_memory_cache_size(theme, &system_info)?; + + // 3f. I/O profile (default to detected) + let disk_io_profile = prompt_disk_io_profile(theme, system_info.disk_io_profile)?; + + Ok(CacheSettings { + cache_dir, + disk_io_profile, + memory_cache_size, + disk_cache_size, + dds_disk_ratio, + }) +} + +fn prompt_cache_directory(theme: &ColorfulTheme, default_path: &Path) -> Result { + println!("Where should XEarthLayer store cached tiles?"); + println!(); + println!("Default: {}", style(default_path.display()).cyan()); println!(); let use_default = Confirm::with_theme(theme) @@ -343,140 +403,278 @@ fn step_cache_location(theme: &ColorfulTheme) -> Result { if use_default { println!(" {} {}", style("✓").green(), default_path.display()); - Ok(default_path) + Ok(default_path.to_path_buf()) } else { let path: String = Input::with_theme(theme) .with_prompt("Cache directory path") .default(default_path.display().to_string()) .interact_text() .map_err(|e| CliError::Config(format!("Input error: {}", e)))?; - let path = PathBuf::from(path); println!(" {} {}", style("✓").green(), path.display()); Ok(path) } } -/// Step 4: Configure system settings based on detected hardware. -fn step_system_config( +fn prompt_disk_cache_size( theme: &ColorfulTheme, system_info: &SystemInfo, -) -> Result<(usize, usize, DiskIoProfile), CliError> { - println!("{}", style("Detected Hardware:").bold()); - println!(" CPU Cores: {}", style(system_info.cpu_cores).cyan()); - println!( - " System Memory: {}", - style(system_info.memory_display()).cyan() - ); - println!( - " Cache Storage: {}", - style(system_info.storage_display()).cyan() - ); +) -> Result { + let recommended = system_info.recommended_disk_cache(); + let recommended_gb = recommended / GB; + let available_gb = (system_info.cache_path_available_bytes / GB as u64) as usize; + + println!("{}", style("Disk cache size:").bold()); + if available_gb > 0 { + println!( + " ℹ Available: {} GB — default is 25% floored to nearest 10 GB ({} GB)", + available_gb, recommended_gb + ); + } else { + println!( + " ℹ Default is 25% of free space; floor is {} GB", + MIN_DISK_CACHE_BYTES / GB + ); + } + + let chosen_gb: usize = Input::with_theme(theme) + .with_prompt("Disk cache size (GB)") + .default(recommended_gb) + .interact_text() + .map_err(|e| CliError::Config(format!("Input error: {}", e)))?; + + if available_gb > 0 && chosen_gb > available_gb { + println!( + " {} {} GB exceeds available space ({} GB) — proceeding anyway", + style("⚠").yellow(), + chosen_gb, + available_gb + ); + } + let final_gb = chosen_gb.max(MIN_DISK_CACHE_BYTES / GB); + if final_gb != chosen_gb { + println!(" {} Clamped to minimum {} GB", style("ℹ").cyan(), final_gb); + } + println!(" {} {} GB", style("✓").green(), final_gb); + Ok(final_gb * GB) +} + +fn prompt_dds_disk_ratio(theme: &ColorfulTheme) -> Result { + const DEFAULT_RATIO: f64 = 0.6; println!(); + println!("{}", style("DDS disk ratio:").bold()); + println!(" ℹ Proportion of disk cache for encoded DDS tiles vs raw image chunks."); + println!(" Recommended to leave at default unless you know you need to change it."); + + let raw: String = Input::with_theme(theme) + .with_prompt("DDS disk ratio") + .default(format!("{}", DEFAULT_RATIO)) + .interact_text() + .map_err(|e| CliError::Config(format!("Input error: {}", e)))?; + + let ratio: f64 = raw + .parse() + .map_err(|_| CliError::Config(format!("'{}' is not a number", raw)))?; + let ratio = ratio.clamp(0.0, 1.0); + println!(" {} {}", style("✓").green(), ratio); + Ok(ratio) +} - let recommended_memory = system_info.recommended_memory_cache(); - let recommended_disk = system_info.recommended_disk_cache(); - let recommended_profile = system_info.disk_io_profile; +fn prompt_memory_cache_size( + theme: &ColorfulTheme, + system_info: &SystemInfo, +) -> Result { + let recommended = system_info.recommended_memory_cache(); + let recommended_mb = recommended / MB; + let total_mb = system_info.total_memory / MB; + let max_mb = (system_info.total_memory / 4) / MB; + let min_mb = MIN_MEMORY_CACHE_BYTES / MB; - println!("{}", style("Recommended Settings:").bold()); + println!(); + println!("{}", style("Memory cache size:").bold()); println!( - " Memory Cache: {} (of {} available)", - style(system_info.recommended_memory_cache_display()).cyan(), - system_info.memory_display() + " ℹ System RAM: {} MB — default is RAM ÷ 12 ({} MB)", + total_mb, recommended_mb ); println!( - " Disk Cache: {}", - style(format_size(recommended_disk)).cyan() + " Allowed range: {} MB – {} MB (clamped if outside)", + min_mb, max_mb ); + + let chosen_mb: usize = Input::with_theme(theme) + .with_prompt("Memory cache size (MB)") + .default(recommended_mb) + .interact_text() + .map_err(|e| CliError::Config(format!("Input error: {}", e)))?; + + let clamped_mb = chosen_mb.clamp(min_mb, max_mb.max(min_mb)); + if clamped_mb != chosen_mb { + println!( + " {} Clamped to {} MB (was {} MB)", + style("ℹ").cyan(), + clamped_mb, + chosen_mb + ); + } + println!(" {} {} MB", style("✓").green(), clamped_mb); + Ok(clamped_mb * MB) +} + +fn prompt_disk_io_profile( + theme: &ColorfulTheme, + detected: DiskIoProfile, +) -> Result { + println!(); + println!("{}", style("Disk I/O profile:").bold()); println!( - " I/O Profile: {} ({})", - style(system_info.recommended_disk_io_profile()).cyan(), - match recommended_profile { - DiskIoProfile::Nvme => "high concurrency", - DiskIoProfile::Ssd => "moderate concurrency", - DiskIoProfile::Hdd => "low concurrency", - DiskIoProfile::Auto => "auto-detect", + " ℹ Detected: {} (default). Override only if you have a specific reason.", + style(profile_label(detected)).cyan() + ); + + let profiles = ["auto", "nvme", "ssd", "hdd"]; + let default_idx = match detected { + DiskIoProfile::Nvme => 1, + DiskIoProfile::Ssd => 2, + DiskIoProfile::Hdd => 3, + DiskIoProfile::Auto => 0, + }; + + let idx = Select::with_theme(theme) + .with_prompt("I/O profile") + .items(&profiles) + .default(default_idx) + .interact() + .map_err(|e| CliError::Config(format!("Selection error: {}", e)))?; + + let profile = match idx { + 1 => DiskIoProfile::Nvme, + 2 => DiskIoProfile::Ssd, + 3 => DiskIoProfile::Hdd, + _ => DiskIoProfile::Auto, + }; + println!(" {} {}", style("✓").green(), profile_label(profile)); + Ok(profile) +} + +fn profile_label(profile: DiskIoProfile) -> &'static str { + match profile { + DiskIoProfile::Nvme => "NVMe", + DiskIoProfile::Ssd => "SSD", + DiskIoProfile::Hdd => "HDD", + DiskIoProfile::Auto => "Auto", + } +} + +/// Step 4: DDS encoding backend (and GPU device selection if applicable). +/// +/// Returns `(compressor, gpu_device)` ready to write to config. The +/// `gpu_device` is always populated even when ISPC is selected so that +/// switching to GPU later in `config set` doesn't require revisiting +/// this step. +fn step_encoding(theme: &ColorfulTheme) -> Result<(String, String), CliError> { + let adapters = enumerate_with_spinner(); + + if adapters.len() < 2 { + // Single adapter (or none): GPU selection has no meaningful + // choice. Stick with the safer ISPC default. + if adapters.is_empty() { + println!( + "{}", + style("No GPU adapters detected — using ISPC (CPU-based, recommended).").cyan() + ); + } else { + println!( + "{}", + style(format!( + "Single GPU detected ({}) — using ISPC (CPU-based, avoids competing with X-Plane).", + adapters[0] + )) + .cyan() + ); } + return Ok(("ispc".to_string(), "integrated".to_string())); + } + + println!("Multiple GPUs detected:"); + for (i, adapter) in adapters.iter().enumerate() { + println!(" {}. {}", i + 1, adapter); + } + println!(); + println!( + "{}", + style( + "⚠ Do NOT select the GPU that X-Plane uses for rendering — this will\n cause frame drops. If unsure, keep the default (ISPC)." + ) + .yellow() ); println!(); - let accept_recommended = Confirm::with_theme(theme) - .with_prompt("Accept recommended settings?") - .default(true) + let mut items: Vec = vec!["ISPC (CPU, recommended default)".to_string()]; + for adapter in &adapters { + items.push(format!("GPU: {}", adapter)); + } + + let idx = Select::with_theme(theme) + .with_prompt("Encoding backend") + .items(&items) + .default(0) .interact() - .map_err(|e| CliError::Config(format!("Confirm error: {}", e)))?; + .map_err(|e| CliError::Config(format!("Selection error: {}", e)))?; - if accept_recommended { - println!(" {} Using recommended settings", style("✓").green()); - Ok((recommended_memory, recommended_disk, recommended_profile)) + if idx == 0 { + println!(" {} ISPC (CPU)", style("✓").green()); + Ok(("ispc".to_string(), "integrated".to_string())) } else { - const GB: usize = 1024 * 1024 * 1024; + let adapter = &adapters[idx - 1]; + let gpu_device = adapter.config_value(&adapters); + println!( + " {} GPU: {} (config: {})", + style("✓").green(), + adapter, + gpu_device + ); + Ok(("gpu".to_string(), gpu_device)) + } +} - // Custom memory cache - let memory_gb: usize = Input::with_theme(theme) - .with_prompt("Memory cache size (GB)") - .default(recommended_memory / GB) - .interact_text() - .map_err(|e| CliError::Config(format!("Input error: {}", e)))?; - let memory_cache = memory_gb * GB; +/// Enumerate adapters while showing a spinner — wgpu's first call can +/// take 30+ seconds on multi-adapter systems while it opens each +/// driver. Without feedback the wizard appears frozen. +fn enumerate_with_spinner() -> Vec { + let spinner = ProgressBar::new_spinner(); + spinner.set_style( + ProgressStyle::with_template("{spinner:.cyan} {msg}") + .unwrap_or_else(|_| ProgressStyle::default_spinner()), + ); + spinner.set_message("Detecting GPUs (this may take a moment)…"); + spinner.enable_steady_tick(Duration::from_millis(120)); - // Custom disk cache - let disk_gb: usize = Input::with_theme(theme) - .with_prompt("Disk cache size (GB)") - .default(recommended_disk / GB) - .interact_text() - .map_err(|e| CliError::Config(format!("Input error: {}", e)))?; - let disk_cache = disk_gb * GB; - - // I/O profile - let profiles = vec!["auto", "nvme", "ssd", "hdd"]; - let default_idx = match recommended_profile { - DiskIoProfile::Nvme => 1, - DiskIoProfile::Ssd => 2, - DiskIoProfile::Hdd => 3, - DiskIoProfile::Auto => 0, - }; - - let profile_idx = Select::with_theme(theme) - .with_prompt("Disk I/O profile") - .items(&profiles) - .default(default_idx) - .interact() - .map_err(|e| CliError::Config(format!("Selection error: {}", e)))?; - - let profile = match profile_idx { - 1 => DiskIoProfile::Nvme, - 2 => DiskIoProfile::Ssd, - 3 => DiskIoProfile::Hdd, - _ => DiskIoProfile::Auto, - }; - - println!(" {} Custom settings configured", style("✓").green()); - Ok((memory_cache, disk_cache, profile)) - } + let adapters = enumerate_gpus(); + + spinner.finish_and_clear(); + adapters } /// Write the setup configuration to config.ini. fn write_config(setup: &SetupConfig) -> Result<(), CliError> { - // Load existing config or create default let mut config = ConfigFile::load().unwrap_or_default(); - // Update X-Plane settings if let Some(ref scenery_dir) = setup.xplane_scenery_dir { config.xplane.scenery_dir = Some(scenery_dir.clone()); config.packages.custom_scenery_path = Some(scenery_dir.clone()); } - // Update package settings config.packages.install_location = Some(setup.package_dir.clone()); - // Update cache settings config.cache.directory = setup.cache_dir.clone(); config.cache.memory_size = setup.memory_cache_size; config.cache.disk_size = setup.disk_cache_size; + config.cache.dds_disk_ratio = setup.dds_disk_ratio; config.cache.disk_io_profile = setup.disk_io_profile; - // Ensure directories exist + config.texture.compressor = setup.texture_compressor.clone(); + config.texture.gpu_device = setup.texture_gpu_device.clone(); + if let Some(parent) = setup.package_dir.parent() { std::fs::create_dir_all(parent).ok(); } @@ -484,7 +682,6 @@ fn write_config(setup: &SetupConfig) -> Result<(), CliError> { std::fs::create_dir_all(parent).ok(); } - // Save config config .save() .map_err(|e| CliError::Config(format!("Failed to save config: {}", e)))?; diff --git a/xearthlayer/src/config/mod.rs b/xearthlayer/src/config/mod.rs index 2ef06a53..8ac346eb 100644 --- a/xearthlayer/src/config/mod.rs +++ b/xearthlayer/src/config/mod.rs @@ -101,7 +101,7 @@ pub use file::{ DEFAULT_WEB_API_PORT, }; pub use keys::{ConfigKey, ConfigKeyError, SENSITIVE_VALUE_MASK}; -pub use size::{format_size, parse_size, Size, SizeParseError}; +pub use size::{format_size, parse_size, Size, SizeParseError, GB, KB, MB}; pub use storage::{ DiskIoProfile, DEFAULT_CPU_FALLBACK, HDD_BLOCKING_CEILING, HDD_BLOCKING_SCALING_FACTOR, HDD_IO_CEILING, HDD_IO_SCALING_FACTOR, NVME_BLOCKING_CEILING, NVME_BLOCKING_SCALING_FACTOR, diff --git a/xearthlayer/src/config/size.rs b/xearthlayer/src/config/size.rs index b13ebd58..a9a458e6 100644 --- a/xearthlayer/src/config/size.rs +++ b/xearthlayer/src/config/size.rs @@ -3,6 +3,13 @@ use std::fmt; use thiserror::Error; +/// 1 kibibyte in bytes (1024). +pub const KB: usize = 1024; +/// 1 mebibyte in bytes (1024²). +pub const MB: usize = KB * 1024; +/// 1 gibibyte in bytes (1024³). +pub const GB: usize = MB * 1024; + /// Error parsing a size string. #[derive(Debug, Error, PartialEq, Eq)] #[error("Invalid size '{input}' - expected format like '2GB', '500MB', or '1024KB'")] @@ -90,10 +97,6 @@ pub fn parse_size(s: &str) -> Result { /// assert_eq!(format_size(1536 * 1024 * 1024), "1.5 GB"); /// ``` pub fn format_size(bytes: usize) -> String { - const GB: usize = 1024 * 1024 * 1024; - const MB: usize = 1024 * 1024; - const KB: usize = 1024; - if bytes >= GB { let value = bytes as f64 / GB as f64; if value.fract() == 0.0 { @@ -130,11 +133,11 @@ impl Size { } pub fn from_gb(gb: usize) -> Self { - Self(gb * 1024 * 1024 * 1024) + Self(gb * GB) } pub fn from_mb(mb: usize) -> Self { - Self(mb * 1024 * 1024) + Self(mb * MB) } } diff --git a/xearthlayer/src/dds/compressor.rs b/xearthlayer/src/dds/compressor.rs index 0b73326f..d3a4978a 100644 --- a/xearthlayer/src/dds/compressor.rs +++ b/xearthlayer/src/dds/compressor.rs @@ -288,20 +288,12 @@ mod gpu { pub fn create_gpu_resources( gpu_device: &str, ) -> Result<(wgpu::Device, wgpu::Queue, GpuBlockCompressor, String), DdsError> { - let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { - backends: wgpu::Backends::all(), - ..Default::default() - }); - - let adapters: Vec = - pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all())); - if adapters.is_empty() { - return Err(DdsError::CompressionFailed( - "No GPU adapters available".to_string(), - )); - } - - let adapter = select_adapter(&adapters, gpu_device)?; + // Enumeration + selection live in `system::gpu` so the wizard's + // adapter→config-string mapping (`GpuAdapter::config_value`) stays + // in lockstep with this module's config-string→adapter mapping. + let adapters = crate::system::enumerate_gpus_raw(); + let adapter = crate::system::find_gpu(&adapters, gpu_device) + .map_err(|e| DdsError::CompressionFailed(e.to_string()))?; let info = adapter.get_info(); let adapter_name = format!("{} ({:?}, {:?})", info.name, info.device_type, info.backend); @@ -322,54 +314,6 @@ mod gpu { Ok((device, queue, compressor, adapter_name)) } - fn select_adapter<'a>( - adapters: &'a [wgpu::Adapter], - gpu_device: &str, - ) -> Result<&'a wgpu::Adapter, DdsError> { - // Try device type match first - let target_type = match gpu_device.to_lowercase().as_str() { - "integrated" => Some(wgpu::DeviceType::IntegratedGpu), - "discrete" => Some(wgpu::DeviceType::DiscreteGpu), - _ => None, - }; - - if let Some(device_type) = target_type { - if let Some(adapter) = adapters - .iter() - .find(|a| a.get_info().device_type == device_type) - { - return Ok(adapter); - } - } else { - // Name substring match (case-insensitive) - let needle = gpu_device.to_lowercase(); - if let Some(adapter) = adapters - .iter() - .find(|a| a.get_info().name.to_lowercase().contains(&needle)) - { - return Ok(adapter); - } - } - - // Build error with available adapters list - let available: Vec = adapters - .iter() - .map(|a| { - let info = a.get_info(); - format!( - " - {} ({:?}, {:?})", - info.name, info.device_type, info.backend - ) - }) - .collect(); - - Err(DdsError::CompressionFailed(format!( - "No GPU adapter matching '{}'. Available adapters:\n{}", - gpu_device, - available.join("\n"), - ))) - } - impl ImageCompressor for WgpuCompressor { fn compress(&self, image: &RgbaImage, format: DdsFormat) -> Result, DdsError> { let width = image.width(); diff --git a/xearthlayer/src/diagnostics/report.rs b/xearthlayer/src/diagnostics/report.rs index 1ddcf9b6..c99edb1a 100644 --- a/xearthlayer/src/diagnostics/report.rs +++ b/xearthlayer/src/diagnostics/report.rs @@ -507,24 +507,16 @@ impl fmt::Display for SystemReport { } writeln!(f)?; - // GPU Compute Adapters (wgpu) + // GPU Compute Adapters (wgpu) — uses the shared enumerate to + // keep this surface in lockstep with the wizard's GPU step. { writeln!(f, "## GPU Compute Adapters")?; - let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { - backends: wgpu::Backends::all(), - ..Default::default() - }); - let adapters = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all())); + let adapters = crate::system::enumerate_gpus(); if adapters.is_empty() { writeln!(f, " (none found)")?; } else { for (i, adapter) in adapters.iter().enumerate() { - let info = adapter.get_info(); - writeln!( - f, - " [{}] {} ({:?}, {:?})", - i, info.name, info.device_type, info.backend - )?; + writeln!(f, " [{}] {}", i, adapter)?; } } writeln!(f)?; diff --git a/xearthlayer/src/system/gpu.rs b/xearthlayer/src/system/gpu.rs new file mode 100644 index 00000000..3c503063 --- /dev/null +++ b/xearthlayer/src/system/gpu.rs @@ -0,0 +1,340 @@ +//! GPU adapter enumeration for the wizard and diagnostics report. +//! +//! Wraps `wgpu::Instance::enumerate_adapters` so callers don't have to +//! depend on wgpu types directly. Also provides the inverse mapping — +//! adapter → `texture.gpu_device` config string — so the wizard's +//! selection can round-trip back through the encoder's `select_adapter` +//! at runtime. +//! +//! # Cost +//! +//! Enumeration takes a noticeable amount of time on multi-adapter +//! systems (observed ~30s on a host with NVIDIA + AMD + GL fallback) +//! because each adapter opens its driver to query info. Callers should +//! treat this as blocking I/O and either do it on a worker thread or +//! show a progress indicator. + +use std::fmt; +use thiserror::Error; + +/// A GPU adapter classification, mirroring `wgpu::DeviceType` but without +/// leaking the wgpu type into our public API. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GpuKind { + /// Discrete GPU — typically high-performance, dedicated VRAM. + Discrete, + /// Integrated GPU — typically lower power, shared with system memory. + Integrated, + /// Virtual GPU (e.g., GPU passthrough in a VM). + Virtual, + /// Software/CPU rasterizer fallback (llvmpipe, WARP, etc.). + Cpu, + /// Could not be classified. + Other, +} + +impl GpuKind { + /// Human-readable label suitable for wizard UI. + pub fn display(self) -> &'static str { + match self { + GpuKind::Discrete => "Discrete", + GpuKind::Integrated => "Integrated", + GpuKind::Virtual => "Virtual", + GpuKind::Cpu => "Software fallback", + GpuKind::Other => "Other", + } + } +} + +/// A single enumerated GPU adapter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GpuAdapter { + /// Adapter name reported by the driver (e.g., "NVIDIA GeForce RTX 4070"). + pub name: String, + /// Kind/class of the adapter. + pub kind: GpuKind, + /// Backend (Vulkan, Metal, DX12, GL, etc.) reported by wgpu, lowercased. + pub backend: String, +} + +impl GpuAdapter { + /// Build a metadata-only `GpuAdapter` from a live `wgpu::Adapter`. + pub fn from_wgpu(adapter: &wgpu::Adapter) -> Self { + let info = adapter.get_info(); + Self { + name: info.name, + kind: gpu_kind_from(info.device_type), + backend: format!("{:?}", info.backend).to_lowercase(), + } + } + + /// Compute the `texture.gpu_device` config value that will reselect + /// this adapter via [`find_adapter`] at runtime. + /// + /// When the adapter's kind is unique within `all_adapters` and is + /// either Integrated or Discrete, the kind keyword is preferred + /// (`"integrated"` or `"discrete"`) — these are stable across driver + /// updates and easy for users to read in their config file. When the + /// kind is ambiguous (e.g., two discrete GPUs) or otherwise unhelpful + /// (Virtual, Cpu, Other), the adapter name is returned so that + /// the case-insensitive substring match in [`select_index`] picks it. + /// + /// The pairing of [`config_value`](Self::config_value) and + /// [`select_index`] is enforced by `config_value_and_select_index_round_trip` + /// — whenever you touch one, run the test to confirm the other still + /// finds what was written. + pub fn config_value(&self, all_adapters: &[GpuAdapter]) -> String { + let same_kind_count = all_adapters.iter().filter(|a| a.kind == self.kind).count(); + match self.kind { + GpuKind::Integrated if same_kind_count == 1 => "integrated".to_string(), + GpuKind::Discrete if same_kind_count == 1 => "discrete".to_string(), + _ => self.name.clone(), + } + } +} + +impl fmt::Display for GpuAdapter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} ({}, {})", + self.name, + self.kind.display(), + self.backend + ) + } +} + +/// Errors from [`find_adapter`] and related selection helpers. +#[derive(Debug, Error)] +pub enum GpuSelectError { + /// No GPU adapters were visible at all (driver issue, headless host). + #[error("No GPU adapters available")] + NoneAvailable, + /// At least one adapter was found, but none matched the selector. + #[error("No GPU adapter matching '{gpu_device}'. Available adapters:\n{available}")] + NoMatch { + gpu_device: String, + available: String, + }, +} + +/// Enumerate all GPU adapters as live `wgpu::Adapter` handles. +/// +/// Most callers should prefer [`enumerate`] which returns metadata-only +/// `GpuAdapter` records. Use this when you need the live adapter to +/// call `request_device` on (e.g., the DDS GPU compressor). +/// +/// **This is blocking and slow** (see module docs). Returns an empty +/// vector if no adapters are visible. +pub fn enumerate_raw() -> Vec { + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + ..Default::default() + }); + pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all())) +} + +/// Enumerate all GPU adapters as metadata-only [`GpuAdapter`] records. +pub fn enumerate() -> Vec { + enumerate_raw().iter().map(GpuAdapter::from_wgpu).collect() +} + +/// Find a live `wgpu::Adapter` matching the given `gpu_device` selector. +/// +/// Selector semantics: +/// - `"integrated"` or `"discrete"` (case-insensitive): match by [`GpuKind`] +/// - anything else: case-insensitive substring match against adapter name +/// +/// This is the inverse of [`GpuAdapter::config_value`] — they round-trip +/// for any adapter present in `adapters` (verified by the round-trip +/// test in this module). +pub fn find_adapter<'a>( + adapters: &'a [wgpu::Adapter], + gpu_device: &str, +) -> Result<&'a wgpu::Adapter, GpuSelectError> { + if adapters.is_empty() { + return Err(GpuSelectError::NoneAvailable); + } + let metadata: Vec = adapters.iter().map(GpuAdapter::from_wgpu).collect(); + select_index(&metadata, gpu_device) + .map(|idx| &adapters[idx]) + .ok_or_else(|| GpuSelectError::NoMatch { + gpu_device: gpu_device.to_string(), + available: metadata + .iter() + .map(|a| format!(" - {}", a)) + .collect::>() + .join("\n"), + }) +} + +/// Pure selection logic operating on metadata-only adapter records. +/// +/// Returns the index of the first adapter matching `gpu_device`, or +/// `None` if no match is found. Exposed at module visibility so the +/// round-trip test can verify the inverse of [`GpuAdapter::config_value`] +/// without needing live `wgpu::Adapter` instances. +fn select_index(adapters: &[GpuAdapter], gpu_device: &str) -> Option { + let needle = gpu_device.to_lowercase(); + let target_kind = match needle.as_str() { + "integrated" => Some(GpuKind::Integrated), + "discrete" => Some(GpuKind::Discrete), + _ => None, + }; + if let Some(kind) = target_kind { + adapters.iter().position(|a| a.kind == kind) + } else { + adapters + .iter() + .position(|a| a.name.to_lowercase().contains(&needle)) + } +} + +fn gpu_kind_from(device_type: wgpu::DeviceType) -> GpuKind { + match device_type { + wgpu::DeviceType::DiscreteGpu => GpuKind::Discrete, + wgpu::DeviceType::IntegratedGpu => GpuKind::Integrated, + wgpu::DeviceType::VirtualGpu => GpuKind::Virtual, + wgpu::DeviceType::Cpu => GpuKind::Cpu, + wgpu::DeviceType::Other => GpuKind::Other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn adapter(name: &str, kind: GpuKind) -> GpuAdapter { + GpuAdapter { + name: name.to_string(), + kind, + backend: "vulkan".to_string(), + } + } + + #[test] + fn config_value_uses_kind_keyword_when_unique() { + let adapters = vec![ + adapter("AMD Radeon Graphics", GpuKind::Integrated), + adapter("NVIDIA GeForce RTX 4070", GpuKind::Discrete), + ]; + assert_eq!(adapters[0].config_value(&adapters), "integrated"); + assert_eq!(adapters[1].config_value(&adapters), "discrete"); + } + + #[test] + fn config_value_falls_back_to_name_when_kind_ambiguous() { + // Two discrete GPUs: kind keyword can't disambiguate, so the + // wizard must write the full name to ensure the right one is + // selected at runtime. + let adapters = vec![ + adapter("NVIDIA GeForce RTX 4070", GpuKind::Discrete), + adapter("NVIDIA Quadro P2000", GpuKind::Discrete), + ]; + assert_eq!( + adapters[0].config_value(&adapters), + "NVIDIA GeForce RTX 4070" + ); + assert_eq!(adapters[1].config_value(&adapters), "NVIDIA Quadro P2000"); + } + + #[test] + fn config_value_uses_name_for_software_fallback() { + // Software/Cpu/Virtual/Other adapters are never selectable by + // kind keyword (the encoder doesn't know those words), so we + // always emit the name. + let adapters = vec![adapter("llvmpipe (LLVM 17)", GpuKind::Cpu)]; + assert_eq!(adapters[0].config_value(&adapters), "llvmpipe (LLVM 17)"); + } + + #[test] + fn display_renders_all_three_fields() { + let a = adapter("Intel Arc A770", GpuKind::Discrete); + assert_eq!(a.to_string(), "Intel Arc A770 (Discrete, vulkan)"); + } + + #[test] + fn select_index_matches_kind_keyword() { + let adapters = vec![ + adapter("AMD Radeon Graphics", GpuKind::Integrated), + adapter("NVIDIA GeForce RTX 4070", GpuKind::Discrete), + ]; + assert_eq!(select_index(&adapters, "integrated"), Some(0)); + assert_eq!(select_index(&adapters, "INTEGRATED"), Some(0)); + assert_eq!(select_index(&adapters, "discrete"), Some(1)); + } + + #[test] + fn select_index_matches_name_substring_case_insensitive() { + let adapters = vec![ + adapter("AMD Radeon Graphics", GpuKind::Integrated), + adapter("NVIDIA GeForce RTX 4070", GpuKind::Discrete), + ]; + assert_eq!(select_index(&adapters, "rtx"), Some(1)); + assert_eq!(select_index(&adapters, "RADEON"), Some(0)); + } + + #[test] + fn select_index_returns_none_for_no_match() { + let adapters = vec![adapter("Intel UHD 630", GpuKind::Integrated)]; + assert_eq!(select_index(&adapters, "discrete"), None); + assert_eq!(select_index(&adapters, "nvidia"), None); + } + + #[test] + fn config_value_and_select_index_round_trip() { + // For every adapter in every realistic enumeration shape, the + // config_value we'd write to config.ini must round-trip back to + // the same adapter via select_index. This is the SSOT guarantee + // that pairs the wizard (config_value) with the encoder + // (find_adapter / select_index). + let scenarios = vec![ + // Typical multi-GPU laptop / workstation + vec![ + adapter("AMD Radeon Graphics", GpuKind::Integrated), + adapter("NVIDIA GeForce RTX 4070", GpuKind::Discrete), + ], + // Dual-discrete render farm + vec![ + adapter("NVIDIA GeForce RTX 4070", GpuKind::Discrete), + adapter("NVIDIA Quadro P2000", GpuKind::Discrete), + ], + // Single GPU + vec![adapter("Intel Arc A770", GpuKind::Discrete)], + // Headless / software fallback + vec![adapter("llvmpipe (LLVM 17)", GpuKind::Cpu)], + // Mixed kinds with a virtual adapter (cloud GPU passthrough) + vec![ + adapter("Intel UHD Graphics 630", GpuKind::Integrated), + adapter("Virtual GPU", GpuKind::Virtual), + adapter("NVIDIA Tesla T4", GpuKind::Discrete), + ], + ]; + for adapters in scenarios { + for (i, adapter) in adapters.iter().enumerate() { + let cv = adapter.config_value(&adapters); + let idx = select_index(&adapters, &cv).unwrap_or_else(|| { + panic!( + "config_value '{}' for {:?} did not round-trip via select_index", + cv, adapter + ) + }); + assert_eq!( + idx, i, + "config_value '{}' selected index {} but {:?} is at index {}", + cv, idx, adapter, i + ); + } + } + } + + #[test] + fn gpu_kind_display_strings() { + assert_eq!(GpuKind::Discrete.display(), "Discrete"); + assert_eq!(GpuKind::Integrated.display(), "Integrated"); + assert_eq!(GpuKind::Virtual.display(), "Virtual"); + assert_eq!(GpuKind::Cpu.display(), "Software fallback"); + assert_eq!(GpuKind::Other.display(), "Other"); + } +} diff --git a/xearthlayer/src/system/hardware.rs b/xearthlayer/src/system/hardware.rs index ef09e939..6d86f333 100644 --- a/xearthlayer/src/system/hardware.rs +++ b/xearthlayer/src/system/hardware.rs @@ -63,6 +63,10 @@ pub struct SystemInfo { pub storage_type: StorageType, /// The underlying DiskIoProfile for configuration pub disk_io_profile: DiskIoProfile, + /// Bytes available to a non-privileged user at the cache path's + /// filesystem. Used to size the recommended disk cache. Zero if + /// detection failed (e.g., path does not exist yet). + pub cache_path_available_bytes: u64, } impl SystemInfo { @@ -86,18 +90,31 @@ impl SystemInfo { let total_memory = detect_total_memory(); let disk_io_profile = DiskIoProfile::Auto.resolve_for_path(cache_path); let storage_type = StorageType::from_disk_io_profile(disk_io_profile); + let cache_path_available_bytes = available_bytes_for(cache_path); Self { cpu_cores, total_memory, storage_type, disk_io_profile, + cache_path_available_bytes, } } /// Create SystemInfo with specific values (for testing). #[cfg(test)] pub fn new(cpu_cores: usize, total_memory: usize, storage_type: StorageType) -> Self { + Self::new_with_disk(cpu_cores, total_memory, storage_type, 0) + } + + /// Create SystemInfo with specific values including available disk (for testing). + #[cfg(test)] + pub fn new_with_disk( + cpu_cores: usize, + total_memory: usize, + storage_type: StorageType, + cache_path_available_bytes: u64, + ) -> Self { let disk_io_profile = match storage_type { StorageType::Nvme => DiskIoProfile::Nvme, StorageType::Ssd => DiskIoProfile::Ssd, @@ -110,6 +127,7 @@ impl SystemInfo { total_memory, storage_type, disk_io_profile, + cache_path_available_bytes, } } @@ -119,20 +137,19 @@ impl SystemInfo { /// Get recommended memory cache size in bytes. /// - /// Based on total system memory: - /// - < 8GB RAM: 2GB cache - /// - 8-31GB RAM: 8GB cache - /// - 32-63GB RAM: 12GB cache - /// - 64+ GB RAM: 16GB cache + /// Computed as `RAM / 12`, clamped to a 500 MB floor and a `RAM / 4` + /// ceiling. The cache is intentionally a small request absorber, not a + /// working set holder; the on-disk DDS cache holds the working set. pub fn recommended_memory_cache(&self) -> usize { recommended_memory_cache(self.total_memory) } /// Get recommended disk cache size in bytes. /// - /// Currently returns a fixed 40GB recommendation. + /// Computed as 25% of available space at the cache path's filesystem, + /// floored to the nearest 10 GB. Minimum 10 GB to avoid thrashing. pub fn recommended_disk_cache(&self) -> usize { - recommended_disk_cache() + recommended_disk_cache(self.cache_path_available_bytes) } /// Get recommended disk I/O profile string for configuration. @@ -167,6 +184,23 @@ impl SystemInfo { } } +/// Return the bytes available at the filesystem holding `path`. +/// +/// Walks up the path until it finds an existing ancestor — this matters +/// during setup, when the cache directory may not exist yet but its +/// parent chain does. Returns 0 if no ancestor can be statted. +fn available_bytes_for(path: &Path) -> u64 { + use crate::system::filesystem::fs_info; + let mut probe: Option<&Path> = Some(path); + while let Some(p) = probe { + if p.exists() { + return fs_info(p).map(|i| i.available_bytes).unwrap_or(0); + } + probe = p.parent(); + } + 0 +} + /// Detect the number of logical CPU cores. /// /// Falls back to 4 if detection fails. @@ -219,8 +253,7 @@ const fn fallback_memory() -> usize { #[cfg(test)] mod tests { use super::*; - - const GB: usize = 1024 * 1024 * 1024; + use crate::config::GB; #[test] fn test_detect_cpu_cores_returns_positive() { @@ -247,10 +280,11 @@ mod tests { #[test] fn test_system_info_recommendations() { - // 16GB system with SSD - let info = SystemInfo::new(8, 16 * GB, StorageType::Ssd); - assert_eq!(info.recommended_memory_cache(), 8 * GB); - assert_eq!(info.recommended_disk_cache(), 40 * GB); + // 16GB system with SSD, 230GB available disk + let info = SystemInfo::new_with_disk(8, 16 * GB, StorageType::Ssd, 230 * GB as u64); + // 16GB / 12 = ~1.33GB, well above 500MB floor and well below 4GB ceiling + assert_eq!(info.recommended_memory_cache(), 16 * GB / 12); + assert_eq!(info.recommended_disk_cache(), 50 * GB); assert_eq!(info.recommended_disk_io_profile(), "auto"); } @@ -264,7 +298,18 @@ mod tests { fn test_system_info_display_formatting() { let info = SystemInfo::new(8, 16 * GB, StorageType::Ssd); assert_eq!(info.memory_display(), "16 GB"); - assert_eq!(info.recommended_memory_cache_display(), "8 GB"); + // 16GB / 12 = 1.333... GB → format_size renders as "1.3 GB" + assert_eq!(info.recommended_memory_cache_display(), "1.3 GB"); assert_eq!(info.storage_display(), "SATA SSD"); } + + #[test] + fn available_bytes_for_walks_up_to_existing_ancestor() { + // /tmp definitely exists; a child path under it does not. The + // helper should still return a non-zero number by falling back + // to the existing ancestor. + let phantom = Path::new("/tmp/xearthlayer-test-nonexistent-child-path-12345/cache"); + let bytes = available_bytes_for(phantom); + assert!(bytes > 0, "Should report bytes from existing ancestor /tmp"); + } } diff --git a/xearthlayer/src/system/mod.rs b/xearthlayer/src/system/mod.rs index 17fb425b..8b839fd9 100644 --- a/xearthlayer/src/system/mod.rs +++ b/xearthlayer/src/system/mod.rs @@ -27,12 +27,17 @@ //! - Any other UI that needs hardware detection pub mod filesystem; +pub mod gpu; mod hardware; mod recommendations; pub use filesystem::{fs_info, is_immutable_os, FilesystemInfo}; +pub use gpu::{ + enumerate as enumerate_gpus, enumerate_raw as enumerate_gpus_raw, find_adapter as find_gpu, + GpuAdapter, GpuKind, GpuSelectError, +}; pub use hardware::{detect_cpu_cores, detect_total_memory, StorageType, SystemInfo}; pub use recommendations::{ recommended_disk_cache, recommended_disk_io_profile, recommended_memory_cache, - RecommendedSettings, + RecommendedSettings, MIN_DISK_CACHE_BYTES, MIN_MEMORY_CACHE_BYTES, }; diff --git a/xearthlayer/src/system/recommendations.rs b/xearthlayer/src/system/recommendations.rs index 684a2752..461f32c8 100644 --- a/xearthlayer/src/system/recommendations.rs +++ b/xearthlayer/src/system/recommendations.rs @@ -3,11 +3,19 @@ //! Provides functions to calculate optimal XEarthLayer settings based on //! detected hardware capabilities. -use crate::config::format_size; -use crate::config::DiskIoProfile; +use crate::config::{format_size, DiskIoProfile, GB, MB}; -/// Size constants for clarity. -const GB: usize = 1024 * 1024 * 1024; +/// Floor for the memory-cache recommendation. +/// +/// The memory cache is intentionally a small request absorber rather than a +/// working set holder (the on-disk DDS cache is the working set). Even on +/// very small RAM systems, dropping below 500 MB starves the absorber. See +/// `xearthlayer/src/cache/providers/memory.rs` for the rationale. +pub const MIN_MEMORY_CACHE_BYTES: usize = 500 * MB; + +/// Floor for the disk-cache recommendation. Below this, the disk cache is +/// thrashing constantly and provides little benefit. +pub const MIN_DISK_CACHE_BYTES: usize = 10 * GB; /// Recommended configuration settings based on system hardware. /// @@ -29,21 +37,26 @@ impl RecommendedSettings { /// # Arguments /// /// * `total_memory` - Total system memory in bytes + /// * `available_disk` - Bytes available at the cache directory's filesystem /// * `detected_profile` - Storage type detected for cache location - pub fn for_system(total_memory: usize, detected_profile: DiskIoProfile) -> Self { + pub fn for_system( + total_memory: usize, + available_disk: u64, + detected_profile: DiskIoProfile, + ) -> Self { Self { memory_cache: recommended_memory_cache(total_memory), - disk_cache: recommended_disk_cache(), + disk_cache: recommended_disk_cache(available_disk), disk_io_profile: detected_profile, } } - /// Get formatted memory cache size (e.g., "8 GB"). + /// Get formatted memory cache size (e.g., "1.4 GB"). pub fn memory_cache_display(&self) -> String { format_size(self.memory_cache) } - /// Get formatted disk cache size (e.g., "40 GB"). + /// Get formatted disk cache size (e.g., "50 GB"). pub fn disk_cache_display(&self) -> String { format_size(self.disk_cache) } @@ -54,52 +67,63 @@ impl RecommendedSettings { } } -/// Calculate recommended memory cache size based on total system memory. -/// -/// # Memory Cache Sizing Rules -/// -/// | System RAM | Cache Size | -/// |------------|------------| -/// | < 8 GB | 2 GB | -/// | 8-31 GB | 8 GB | -/// | 32-63 GB | 12 GB | -/// | 64+ GB | 16 GB | +/// Calculate recommended memory cache size from total system memory. /// -/// # Arguments -/// -/// * `total_memory` - Total system memory in bytes -/// -/// # Returns +/// The memory cache is intentionally a small request absorber, not a working +/// set holder — the on-disk DDS cache is the working set. The formula is +/// `RAM / 12`, clamped to `[MIN_MEMORY_CACHE_BYTES, RAM / 4]`. /// -/// Recommended memory cache size in bytes +/// # Examples /// -/// # Example +/// | System RAM | Recommended cache | +/// |------------|------------------| +/// | 4 GB | 500 MB (floor) | +/// | 8 GB | ~683 MB | +/// | 16 GB | ~1.3 GB | +/// | 32 GB | ~2.7 GB | +/// | 64 GB | ~5.3 GB | +/// | 128 GB | ~10.7 GB | /// /// ``` /// use xearthlayer::system::recommended_memory_cache; /// -/// let cache_size = recommended_memory_cache(32 * 1024 * 1024 * 1024); // 32 GB RAM -/// assert_eq!(cache_size, 12 * 1024 * 1024 * 1024); // 12 GB cache +/// let cache = recommended_memory_cache(16 * 1024 * 1024 * 1024); // 16 GB RAM +/// assert!(cache >= 1_300_000_000 && cache <= 1_500_000_000); // ~1.3 GB /// ``` pub fn recommended_memory_cache(total_memory: usize) -> usize { - match total_memory { - m if m < 8 * GB => 2 * GB, - m if m < 32 * GB => 8 * GB, - m if m < 64 * GB => 12 * GB, - _ => 16 * GB, - } + let raw = total_memory / 12; + let ceiling = total_memory / 4; + raw.clamp(MIN_MEMORY_CACHE_BYTES, ceiling.max(MIN_MEMORY_CACHE_BYTES)) } -/// Get recommended disk cache size. +/// Calculate recommended disk cache size from available disk space. /// -/// Currently returns a fixed 40GB recommendation, which provides good -/// coverage for typical flight sessions without consuming excessive disk space. +/// Targets 25% of free space, floored to the nearest 10 GB so the value +/// is round and predictable in the wizard. Will not recommend less than +/// `MIN_DISK_CACHE_BYTES` even if available space is very low — below +/// that threshold the cache thrashes and provides little benefit. /// -/// # Returns +/// # Examples /// -/// Recommended disk cache size in bytes (40 GB) -pub fn recommended_disk_cache() -> usize { - 40 * GB +/// | Available | Recommended | +/// |-----------|-------------| +/// | 8 GB | 10 GB (floor) | +/// | 80 GB | 20 GB | +/// | 230 GB | 50 GB | +/// | 1 TB | 250 GB | +/// +/// ``` +/// use xearthlayer::system::recommended_disk_cache; +/// +/// let cache = recommended_disk_cache(230 * 1024 * 1024 * 1024); // 230 GB free +/// assert_eq!(cache, 50 * 1024 * 1024 * 1024); // 50 GB +/// ``` +pub fn recommended_disk_cache(available_bytes: u64) -> usize { + let quarter = available_bytes / 4; + let floor_step = MIN_DISK_CACHE_BYTES as u64; + let floored = (quarter / floor_step) * floor_step; + let bytes = floored.max(floor_step); + usize::try_from(bytes).unwrap_or(usize::MAX) } /// Get recommended disk I/O profile string for configuration. @@ -124,63 +148,76 @@ mod tests { use super::*; #[test] - fn test_memory_cache_under_8gb() { - // 4GB system -> 2GB cache - assert_eq!(recommended_memory_cache(4 * GB), 2 * GB); - // 7GB system -> 2GB cache - assert_eq!(recommended_memory_cache(7 * GB), 2 * GB); + fn memory_cache_floored_at_500mb_for_tiny_systems() { + // 4GB / 12 ~= 341MB, below the 500MB floor. + assert_eq!(recommended_memory_cache(4 * GB), MIN_MEMORY_CACHE_BYTES); + // 1GB / 12 ~= 85MB, also clamped — but ceiling (1GB/4 = 256MB) is + // even lower; we still must honor the absolute floor for sanity. + assert_eq!(recommended_memory_cache(1 * GB), MIN_MEMORY_CACHE_BYTES); } #[test] - fn test_memory_cache_8_to_32gb() { - // 8GB system -> 8GB cache - assert_eq!(recommended_memory_cache(8 * GB), 8 * GB); - // 16GB system -> 8GB cache - assert_eq!(recommended_memory_cache(16 * GB), 8 * GB); - // 31GB system -> 8GB cache - assert_eq!(recommended_memory_cache(31 * GB), 8 * GB); + fn memory_cache_uses_ram_div_12_in_normal_range() { + // 16GB / 12 = 1.333... GB + assert_eq!(recommended_memory_cache(16 * GB), 16 * GB / 12); + // 32GB / 12 = 2.666... GB + assert_eq!(recommended_memory_cache(32 * GB), 32 * GB / 12); + // 64GB / 12 = 5.333... GB + assert_eq!(recommended_memory_cache(64 * GB), 64 * GB / 12); + // 128GB / 12 = 10.666... GB + assert_eq!(recommended_memory_cache(128 * GB), 128 * GB / 12); } #[test] - fn test_memory_cache_32_to_64gb() { - // 32GB system -> 12GB cache - assert_eq!(recommended_memory_cache(32 * GB), 12 * GB); - // 48GB system -> 12GB cache - assert_eq!(recommended_memory_cache(48 * GB), 12 * GB); - // 63GB system -> 12GB cache - assert_eq!(recommended_memory_cache(63 * GB), 12 * GB); + fn memory_cache_capped_at_quarter_ram() { + // RAM / 12 < RAM / 4 always, so the ceiling never bites in normal + // operation. The clamp only matters at the floor (tiny systems). + // We still verify it doesn't accidentally exceed the cap. + for ram in &[8 * GB, 16 * GB, 64 * GB, 256 * GB] { + assert!( + recommended_memory_cache(*ram) <= ram / 4, + "{} GB RAM cache exceeded RAM/4 ceiling", + ram / GB + ); + } } #[test] - fn test_memory_cache_64gb_plus() { - // 64GB system -> 16GB cache - assert_eq!(recommended_memory_cache(64 * GB), 16 * GB); - // 128GB system -> 16GB cache - assert_eq!(recommended_memory_cache(128 * GB), 16 * GB); + fn disk_cache_floored_at_10gb() { + // Tiny disk → still recommend 10GB so we don't hand the user a + // useless thrashing-cache config. + assert_eq!(recommended_disk_cache(0), MIN_DISK_CACHE_BYTES); + assert_eq!(recommended_disk_cache(8 * GB as u64), MIN_DISK_CACHE_BYTES); } #[test] - fn test_disk_cache_fixed() { - assert_eq!(recommended_disk_cache(), 40 * GB); + fn disk_cache_25_percent_floored_to_10gb_step() { + // 80GB free → 25% = 20GB → already a 10GB multiple + assert_eq!(recommended_disk_cache(80 * GB as u64), 20 * GB); + // 230GB free → 25% = 57.5GB → floored to 50GB + assert_eq!(recommended_disk_cache(230 * GB as u64), 50 * GB); + // 1TB free → 25% = 256GB → floored to 250GB + assert_eq!(recommended_disk_cache(1024 * GB as u64), 250 * GB); } #[test] - fn test_io_profile_nvme() { + fn io_profile_nvme_is_explicit() { assert_eq!(recommended_disk_io_profile(DiskIoProfile::Nvme), "nvme"); } #[test] - fn test_io_profile_others_return_auto() { + fn io_profile_others_default_to_auto() { assert_eq!(recommended_disk_io_profile(DiskIoProfile::Ssd), "auto"); assert_eq!(recommended_disk_io_profile(DiskIoProfile::Hdd), "auto"); assert_eq!(recommended_disk_io_profile(DiskIoProfile::Auto), "auto"); } #[test] - fn test_recommended_settings() { - let settings = RecommendedSettings::for_system(32 * GB, DiskIoProfile::Nvme); - assert_eq!(settings.memory_cache, 12 * GB); - assert_eq!(settings.disk_cache, 40 * GB); + fn recommended_settings_combines_inputs() { + let settings = + RecommendedSettings::for_system(32 * GB, 230 * GB as u64, DiskIoProfile::Nvme); + assert_eq!(settings.memory_cache, 32 * GB / 12); + assert_eq!(settings.disk_cache, 50 * GB); assert_eq!(settings.disk_io_profile, DiskIoProfile::Nvme); assert_eq!(settings.disk_io_profile_str(), "nvme"); }