diff --git a/packages/oneclient_app/src/view/app/settings/minecraft.rs b/packages/oneclient_app/src/view/app/settings/minecraft.rs index de877528..e9f8cbc7 100644 --- a/packages/oneclient_app/src/view/app/settings/minecraft.rs +++ b/packages/oneclient_app/src/view/app/settings/minecraft.rs @@ -10,6 +10,11 @@ use crate::hooks::{use_dispatch, use_settings_snapshot}; use crate::theme::colors; use crate::view::app::settings::{section_header, settings_row}; +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +use crate::components::toggle_controlled; +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +use oneclient_core::settings::SettingsOsExtra; + #[derive(PartialEq)] pub struct SettingsMinecraft; @@ -77,7 +82,7 @@ impl Component for SettingsMinecraft { batched.update_global_profile(update); }); - settings_page() + let page = settings_page() .child(section_header("GAME")) .child(settings_row( IconType::Maximize01, @@ -112,7 +117,7 @@ impl Component for SettingsMinecraft { "What to do when content you installed from the browser has a newer version. Packs from bundles are not affected.", update_mode_field( profile.browser_update_mode.unwrap_or_default(), - dispatch, + dispatch.clone(), ), )) .child(section_header("PROCESS")) @@ -139,11 +144,44 @@ impl Component for SettingsMinecraft { TextInput::new(post_exit_command) .placeholder("echo 'Game exited'") .width(Size::px(220.)), - )) - .into_element() + )); + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + let page = page + .child(section_header("GRAPHICS")) + .child(discrete_gpu_row(profile.os_extra.clone(), dispatch)); + + page.into_element() } } +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn discrete_gpu_row( + os_extra: Option, + dispatch: crate::Actions, +) -> impl IntoElement { + let current = os_extra.unwrap_or_default(); + let on = current.use_discrete_gpu.unwrap_or(false); + + let on_toggle: EventHandler<()> = (move |()| { + dispatch.update_global_profile(ProfileUpdate { + os_extra: Patch::Set(SettingsOsExtra { + use_discrete_gpu: Some(!on), + ..current.clone() + }), + ..Default::default() + }); + }) + .into(); + + settings_row( + IconType::Rocket02, + "Use Discrete GPU", + "Render the game on the dedicated graphics card. Does nothing on a machine with only one GPU, and draws noticeably more power on a laptop.", + toggle_controlled(on, on_toggle), + ) +} + #[allow(clippy::too_many_arguments)] fn build_update( fullscreen: bool, diff --git a/packages/oneclient_cluster/src/profile.rs b/packages/oneclient_cluster/src/profile.rs index a23c69e4..3ea546eb 100644 --- a/packages/oneclient_cluster/src/profile.rs +++ b/packages/oneclient_cluster/src/profile.rs @@ -37,14 +37,17 @@ cfg_select! { } not(any(target_os = "windows", target_os = "macos")) => { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(default)] pub struct SettingsOsExtra { pub enable_gamemode: Option, + pub use_discrete_gpu: Option, } impl Default for SettingsOsExtra { fn default() -> Self { Self { enable_gamemode: Some(true), + use_discrete_gpu: Some(false), } } } diff --git a/packages/oneclient_core/src/game/gpu.rs b/packages/oneclient_core/src/game/gpu.rs new file mode 100644 index 00000000..ae59dcfb --- /dev/null +++ b/packages/oneclient_core/src/game/gpu.rs @@ -0,0 +1,276 @@ +use std::path::Path; + +const VENDOR_NVIDIA: u16 = 0x10de; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Gpu { + pub index: u32, + pub vendor_id: u16, + /// The card the firmware booted with + /// `None` on the non-PCI platforms that never publish the attribute + pub boot_vga: Option, + pub pci_address: Option, +} + +/// Empty whenever offload does not apply +pub fn offload_env(gpus: &[Gpu]) -> Vec<(&'static str, String)> { + if gpus.len() < 2 { + return Vec::new(); + } + + let Some(boot) = gpus.iter().find(|gpu| gpu.boot_vga == Some(true)) else { + return Vec::new(); + }; + + if boot.vendor_id == VENDOR_NVIDIA { + return Vec::new(); + } + + let Some(target) = gpus.iter().find(|gpu| gpu.boot_vga != Some(true)) else { + return Vec::new(); + }; + + let prime = target + .pci_address + .as_deref() + .and_then(pci_tag) + .unwrap_or_else(|| "1".to_string()); + + let mut env = vec![("DRI_PRIME", prime)]; + + // Only once the card we picked is actually NVIDIA's + if target.vendor_id == VENDOR_NVIDIA { + env.push(("__NV_PRIME_RENDER_OFFLOAD", "1".to_string())); + env.push(("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string())); + env.push(("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string())); + } + + env +} + +#[cfg(target_os = "linux")] +pub fn detect() -> Vec { + read_drm_devices(Path::new("/sys/class/drm")) +} + +fn read_drm_devices(root: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(root) else { + return Vec::new(); + }; + + let mut gpus = Vec::new(); + + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + + // `card0` is a device; `card0-HDMI-A-1` is a connector hanging off it + let Some(index) = name.strip_prefix("card").and_then(|n| n.parse::().ok()) else { + continue; + }; + + let device = entry.path().join("device"); + let Some(vendor_id) = read_hex(&device.join("vendor")) else { + continue; + }; + + gpus.push(Gpu { + index, + vendor_id, + boot_vga: read_flag(&device.join("boot_vga")), + pci_address: std::fs::read_link(&device) + .ok() + .and_then(|link| link.file_name()?.to_str().map(str::to_string)), + }); + } + + gpus.sort_by_key(|gpu| gpu.index); + gpus +} + +fn read_hex(path: &Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + let raw = raw.trim(); + u16::from_str_radix(raw.strip_prefix("0x").unwrap_or(raw), 16).ok() +} + +fn read_flag(path: &Path) -> Option { + Some(std::fs::read_to_string(path).ok()?.trim() == "1") +} + +/// Mesa builds its tag as `pci-%04x_%02x_%02x_%1u`, which is the sysfs `0000:01:00.0` spelling with the separators swapped +fn pci_tag(address: &str) -> Option { + let (domain, rest) = address.split_once(':')?; + let (bus, rest) = rest.split_once(':')?; + let (device, function) = rest.split_once('.')?; + + let widths = [(domain, 4), (bus, 2), (device, 2), (function, 1)]; + if widths + .iter() + .any(|(part, width)| part.len() != *width || !part.bytes().all(|b| b.is_ascii_hexdigit())) + { + return None; + } + + Some(format!("pci-{domain}_{bus}_{device}_{function}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const NVIDIA: u16 = 0x10de; + const AMD: u16 = 0x1002; + const INTEL: u16 = 0x8086; + + fn gpu(index: u32, vendor_id: u16, boot_vga: Option) -> Gpu { + Gpu { + index, + vendor_id, + boot_vga, + pci_address: None, + } + } + + #[test] + fn a_lone_card_is_never_offloaded_from() { + for vendor in [AMD, NVIDIA, INTEL] { + assert!(offload_env(&[gpu(0, vendor, Some(true))]).is_empty()); + } + assert!(offload_env(&[]).is_empty()); + } + + #[test] + fn an_all_amd_hybrid_never_names_nvidia() { + let env = offload_env(&[gpu(0, AMD, Some(true)), gpu(1, AMD, Some(false))]); + + assert_eq!(env, vec![("DRI_PRIME", "1".to_string())]); + } + + #[test] + fn an_intel_amd_hybrid_never_names_nvidia() { + let env = offload_env(&[gpu(0, INTEL, Some(true)), gpu(1, AMD, Some(false))]); + + assert_eq!(env, vec![("DRI_PRIME", "1".to_string())]); + } + + #[test] + fn an_optimus_laptop_gets_the_nvidia_variables() { + let env = offload_env(&[gpu(0, INTEL, Some(true)), gpu(1, NVIDIA, Some(false))]); + + assert_eq!( + env, + vec![ + ("DRI_PRIME", "1".to_string()), + ("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()), + ("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()), + ("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()), + ] + ); + } + + #[test] + fn nothing_happens_when_the_discrete_card_already_drives_the_display() { + // Desktop with the monitor on the NVIDIA card and the iGPU still on. + // `DRI_PRIME` would move Mesa onto the integrated one + let env = offload_env(&[gpu(0, NVIDIA, Some(true)), gpu(1, INTEL, Some(false))]); + + assert!(env.is_empty()); + } + + #[test] + fn nothing_happens_when_no_card_claims_to_be_the_boot_gpu() { + let env = offload_env(&[gpu(0, INTEL, None), gpu(1, NVIDIA, None)]); + + assert!(env.is_empty()); + } + + #[test] + fn the_target_card_is_named_by_pci_address_when_sysfs_offers_one() { + let mut target = gpu(1, NVIDIA, Some(false)); + target.pci_address = Some("0000:01:00.0".to_string()); + + let env = offload_env(&[gpu(0, INTEL, Some(true)), target]); + + assert_eq!(env[0], ("DRI_PRIME", "pci-0000_01_00_0".to_string())); + } + + #[test] + fn a_malformed_pci_address_falls_back_to_the_ordinal() { + let mut target = gpu(1, AMD, Some(false)); + target.pci_address = Some("not-an-address".to_string()); + + let env = offload_env(&[gpu(0, AMD, Some(true)), target]); + + assert_eq!(env[0], ("DRI_PRIME", "1".to_string())); + } + + #[test] + fn pci_tags_match_the_shape_mesa_builds() { + assert_eq!(pci_tag("0000:01:00.0").as_deref(), Some("pci-0000_01_00_0")); + assert_eq!(pci_tag("10000:af:1f.7").as_deref(), None, "domain is 4 wide"); + assert_eq!(pci_tag("0000:1:00.0"), None, "bus is 2 wide"); + assert_eq!(pci_tag("0000:01:00"), None, "no function"); + assert_eq!(pci_tag("0000:0g:00.0"), None, "not hex"); + } + + fn write(path: &Path, contents: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); + } + + #[test] + fn reads_vendor_boot_flag_and_address_out_of_a_sysfs_tree() { + let scratch = polyio::testing::ScratchDir::new("gpu-sysfs"); + let drm = scratch.join("drm"); + + // sysfs spells these with a trailing newline + write(&drm.join("card0").join("device").join("vendor"), "0x8086\n"); + write(&drm.join("card0").join("device").join("boot_vga"), "1\n"); + write(&drm.join("card1").join("device").join("vendor"), "0x10de\n"); + write(&drm.join("card1").join("device").join("boot_vga"), "0\n"); + + let gpus = read_drm_devices(&drm); + + assert_eq!(gpus.len(), 2); + assert_eq!(gpus[0].index, 0); + assert_eq!(gpus[0].vendor_id, INTEL); + assert_eq!(gpus[0].boot_vga, Some(true)); + assert_eq!(gpus[1].vendor_id, NVIDIA); + assert_eq!(gpus[1].boot_vga, Some(false)); + + // and the whole point: this pair earns the NVIDIA variables + assert_eq!(offload_env(&gpus).len(), 4); + } + + #[test] + fn skips_connectors_and_anything_without_a_vendor() { + let scratch = polyio::testing::ScratchDir::new("gpu-connectors"); + let drm = scratch.join("drm"); + + write(&drm.join("card0").join("device").join("vendor"), "0x1002\n"); + // a connector, not a device + write( + &drm.join("card0-HDMI-A-1").join("device").join("vendor"), + "0x1002\n", + ); + // renderD128 sits alongside the cards and is the same device again + write(&drm.join("renderD128").join("device").join("vendor"), "0x1002\n"); + // present but unreadable as a device + std::fs::create_dir_all(drm.join("card9")).unwrap(); + + let gpus = read_drm_devices(&drm); + + assert_eq!(gpus.len(), 1, "one card, counted once"); + assert_eq!(gpus[0].vendor_id, AMD); + } + + #[test] + fn a_missing_drm_tree_is_not_an_error() { + let scratch = polyio::testing::ScratchDir::new("gpu-missing"); + + assert!(read_drm_devices(&scratch.join("nope")).is_empty()); + } +} diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 0d42e1fd..b4f52ed9 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -626,6 +626,10 @@ fn base_command(profile: &GameSettingsProfile, java_path: &str) -> Command { fn apply_env(command: &mut Command, profile: &GameSettingsProfile) { command.env_remove("_JAVA_OPTIONS"); + + #[cfg(target_os = "linux")] + apply_discrete_gpu(command, profile); + if let Some(env) = &profile.launch_env { for pair in env.split_whitespace() { if let Some((key, value)) = pair.split_once('=') { @@ -635,6 +639,36 @@ fn apply_env(command: &mut Command, profile: &GameSettingsProfile) { } } +#[cfg(target_os = "linux")] +fn apply_discrete_gpu(command: &mut Command, profile: &GameSettingsProfile) { + let requested = profile + .os_extra + .as_ref() + .and_then(|extra| extra.use_discrete_gpu) + .unwrap_or(false); + + if !requested { + return; + } + + let gpus = crate::game::gpu::detect(); + let env = crate::game::gpu::offload_env(&gpus); + + if env.is_empty() { + tracing::info!( + gpus = gpus.len(), + "discrete GPU was requested but nothing here is a valid offload target; \ + leaving the renderer alone" + ); + return; + } + + for (key, value) in env { + tracing::debug!(key, value, "offloading the game to the discrete GPU"); + command.env(key, value); + } +} + #[tracing::instrument(skip(cwd), fields(hook), level = "debug")] async fn run_hook(hook: Option<&str>, cwd: &Path) { let Some(hook) = hook.map(str::trim).filter(|h| !h.is_empty()) else { diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index 39bd11a7..b62609f3 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -1,5 +1,7 @@ mod analytics; mod error; +#[cfg(any(target_os = "linux", test))] +mod gpu; mod launch; mod log_replay; mod process;