Rust · 12506 bytes Raw Blame History
1 //! Proton version detection and management
2
3 use crate::config::WandaConfig;
4 use crate::error::{Result, WandaError};
5 use crate::steam::SteamInstallation;
6 use std::cmp::Ordering;
7 use std::fmt;
8 use std::path::{Path, PathBuf};
9 use tracing::{debug, info, warn};
10
11 /// Proton compatibility level with WeMod
12 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
13 pub enum ProtonCompatibility {
14 /// Known to work well with WeMod (GE-Proton 8+)
15 Recommended,
16 /// Should work with WeMod
17 Supported,
18 /// May work but not tested
19 Experimental,
20 /// Known to have issues
21 Unsupported,
22 }
23
24 impl fmt::Display for ProtonCompatibility {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26 match self {
27 ProtonCompatibility::Recommended => write!(f, "Recommended"),
28 ProtonCompatibility::Supported => write!(f, "Supported"),
29 ProtonCompatibility::Experimental => write!(f, "Experimental"),
30 ProtonCompatibility::Unsupported => write!(f, "Unsupported"),
31 }
32 }
33 }
34
35 /// A detected Proton installation
36 #[derive(Debug, Clone)]
37 pub struct ProtonVersion {
38 /// Display name (e.g., "GE-Proton9-5", "Proton 8.0")
39 pub name: String,
40 /// Path to the Proton installation
41 pub path: PathBuf,
42 /// Parsed version numbers (major, minor, patch)
43 pub version: (u32, u32, u32),
44 /// Whether this is a GE-Proton build
45 pub is_ge: bool,
46 /// Whether this is Proton Experimental
47 pub is_experimental: bool,
48 /// Compatibility level with WeMod
49 pub compatibility: ProtonCompatibility,
50 }
51
52 impl ProtonVersion {
53 /// Get the path to the proton executable
54 pub fn proton_exe(&self) -> PathBuf {
55 self.path.join("proton")
56 }
57
58 /// Get the path to the Wine binary
59 pub fn wine_exe(&self) -> PathBuf {
60 self.path.join("files/bin/wine64")
61 }
62 }
63
64 impl PartialOrd for ProtonVersion {
65 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
66 Some(self.cmp(other))
67 }
68 }
69
70 impl Ord for ProtonVersion {
71 fn cmp(&self, other: &Self) -> Ordering {
72 // Prefer GE-Proton over standard
73 match (self.is_ge, other.is_ge) {
74 (true, false) => return Ordering::Greater,
75 (false, true) => return Ordering::Less,
76 _ => {}
77 }
78 // Then compare by version
79 self.version.cmp(&other.version)
80 }
81 }
82
83 impl PartialEq for ProtonVersion {
84 fn eq(&self, other: &Self) -> bool {
85 self.name == other.name && self.path == other.path
86 }
87 }
88
89 impl Eq for ProtonVersion {}
90
91 /// Manages Proton version discovery and selection
92 pub struct ProtonManager {
93 /// All discovered Proton versions
94 pub versions: Vec<ProtonVersion>,
95 }
96
97 impl ProtonManager {
98 /// Discover all Proton versions on the system
99 pub fn discover(steam: &SteamInstallation, config: &WandaConfig) -> Result<Self> {
100 let mut versions = Vec::new();
101
102 // Search in Steam's common folder (official Proton)
103 let common_dir = steam.root_path.join("steamapps/common");
104 Self::scan_directory(&common_dir, &mut versions);
105
106 // Search in compatibility tools directory (GE-Proton, custom builds)
107 let compat_tools_dir = steam.root_path.join("compatibilitytools.d");
108 Self::scan_directory(&compat_tools_dir, &mut versions);
109
110 // System-wide compatibility tools
111 let system_compat = PathBuf::from("/usr/share/steam/compatibilitytools.d");
112 if system_compat.exists() {
113 Self::scan_directory(&system_compat, &mut versions);
114 }
115
116 // User-configured additional paths
117 for path in &config.proton.search_paths {
118 if path.exists() {
119 Self::scan_directory(path, &mut versions);
120 }
121 }
122
123 // Sort by preference (GE-Proton first, then by version descending)
124 versions.sort_by(|a, b| b.cmp(a));
125
126 info!("Discovered {} Proton versions", versions.len());
127 for v in &versions {
128 debug!(
129 " {} ({:?}) at {}",
130 v.name,
131 v.compatibility,
132 v.path.display()
133 );
134 }
135
136 Ok(Self { versions })
137 }
138
139 /// Scan a directory for Proton installations
140 fn scan_directory(dir: &Path, versions: &mut Vec<ProtonVersion>) {
141 let entries = match std::fs::read_dir(dir) {
142 Ok(e) => e,
143 Err(_) => return,
144 };
145
146 for entry in entries.flatten() {
147 let path = entry.path();
148 if path.is_dir() {
149 if let Some(version) = Self::detect_proton(&path) {
150 versions.push(version);
151 }
152 }
153 }
154 }
155
156 /// Detect if a directory contains a Proton installation
157 fn detect_proton(path: &Path) -> Option<ProtonVersion> {
158 // Proton directories should have a 'proton' script
159 let proton_script = path.join("proton");
160 if !proton_script.exists() {
161 return None;
162 }
163
164 let name = path.file_name()?.to_str()?.to_string();
165
166 // Parse version from name
167 let (version, is_ge, is_experimental) = Self::parse_version_from_name(&name);
168 let compatibility = Self::determine_compatibility(&name, version, is_ge, is_experimental);
169
170 Some(ProtonVersion {
171 name,
172 path: path.to_path_buf(),
173 version,
174 is_ge,
175 is_experimental,
176 compatibility,
177 })
178 }
179
180 /// Parse version numbers from Proton directory name
181 fn parse_version_from_name(name: &str) -> ((u32, u32, u32), bool, bool) {
182 let is_ge = name.contains("GE-Proton") || name.contains("Proton-GE");
183 let is_experimental = name.contains("Experimental");
184
185 // Try to extract version numbers
186 // GE-Proton format: "GE-Proton9-5" -> (9, 5, 0)
187 // Standard format: "Proton 8.0-5" -> (8, 0, 5)
188 // Experimental: "Proton - Experimental" -> (999, 0, 0) to sort high
189
190 if is_experimental {
191 return ((999, 0, 0), is_ge, is_experimental);
192 }
193
194 let version = Self::extract_version_numbers(name);
195 (version, is_ge, is_experimental)
196 }
197
198 /// Extract version numbers from a string
199 fn extract_version_numbers(s: &str) -> (u32, u32, u32) {
200 let numbers: Vec<u32> = s
201 .chars()
202 .filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-')
203 .collect::<String>()
204 .split(|c| c == '.' || c == '-')
205 .filter_map(|n| n.parse().ok())
206 .collect();
207
208 match numbers.as_slice() {
209 [major, minor, patch, ..] => (*major, *minor, *patch),
210 [major, minor] => (*major, *minor, 0),
211 [major] => (*major, 0, 0),
212 [] => (0, 0, 0),
213 }
214 }
215
216 /// Determine compatibility level based on version info
217 fn determine_compatibility(
218 name: &str,
219 version: (u32, u32, u32),
220 is_ge: bool,
221 is_experimental: bool,
222 ) -> ProtonCompatibility {
223 // Proton Experimental is recommended for WeMod
224 // GE-Proton 10.x has wow64 mode issues that cause WeMod to crash
225 if is_experimental {
226 return ProtonCompatibility::Recommended;
227 }
228
229 // GE-Proton 9.x and below should work (before wow64 mode)
230 if is_ge && version.0 >= 8 && version.0 <= 9 {
231 return ProtonCompatibility::Supported;
232 }
233
234 // GE-Proton 10.x has known wow64 issues with WeMod
235 if is_ge && version.0 >= 10 {
236 return ProtonCompatibility::Experimental;
237 }
238
239 // GE-Proton 7.x might work
240 if is_ge && version.0 >= 7 {
241 return ProtonCompatibility::Supported;
242 }
243
244 // Standard Proton 8+ should work
245 if version.0 >= 8 {
246 return ProtonCompatibility::Supported;
247 }
248
249 // Wine-GE has known issues
250 if name.contains("Wine-GE") {
251 return ProtonCompatibility::Unsupported;
252 }
253
254 // Older versions might have issues
255 if version.0 < 7 {
256 return ProtonCompatibility::Unsupported;
257 }
258
259 ProtonCompatibility::Experimental
260 }
261
262 /// Get the recommended Proton version for WeMod
263 pub fn get_recommended(&self) -> Option<&ProtonVersion> {
264 // First try to find a Recommended version
265 if let Some(v) = self
266 .versions
267 .iter()
268 .find(|v| v.compatibility == ProtonCompatibility::Recommended)
269 {
270 return Some(v);
271 }
272
273 // Fall back to Supported
274 if let Some(v) = self
275 .versions
276 .iter()
277 .find(|v| v.compatibility == ProtonCompatibility::Supported)
278 {
279 return Some(v);
280 }
281
282 // Fall back to Experimental
283 self.versions
284 .iter()
285 .find(|v| v.compatibility == ProtonCompatibility::Experimental)
286 }
287
288 /// Find a Proton version by name
289 pub fn find_by_name(&self, name: &str) -> Option<&ProtonVersion> {
290 let name_lower = name.to_lowercase();
291 self.versions
292 .iter()
293 .find(|v| v.name.to_lowercase().contains(&name_lower))
294 }
295
296 /// Get a version by preference: user-specified, then recommended
297 pub fn get_preferred(&self, config: &WandaConfig) -> Result<&ProtonVersion> {
298 // Check user preference first
299 if let Some(ref preferred) = config.proton.preferred_version {
300 if let Some(v) = self.find_by_name(preferred) {
301 match v.compatibility {
302 ProtonCompatibility::Unsupported => {
303 warn!(
304 "Preferred Proton version '{}' is {} — it has known compatibility issues with WeMod",
305 v.name, v.compatibility
306 );
307 if let Some(rec) = self.get_recommended() {
308 warn!(
309 "Consider switching to '{}' ({}): wanda init --proton '{}'",
310 rec.name, rec.compatibility, rec.name
311 );
312 }
313 }
314 ProtonCompatibility::Experimental => {
315 warn!(
316 "Preferred Proton version '{}' is {} — GE-Proton 10.x wow64 mode can break WeMod's renderer",
317 v.name, v.compatibility
318 );
319 if let Some(rec) = self.get_recommended() {
320 warn!(
321 "Consider switching to '{}' ({}): wanda init --proton '{}'",
322 rec.name, rec.compatibility, rec.name
323 );
324 }
325 }
326 _ => {}
327 }
328 return Ok(v);
329 }
330 warn!(
331 "Preferred Proton version '{}' not found, using recommended",
332 preferred
333 );
334 }
335
336 self.get_recommended().ok_or(WandaError::ProtonNotFound)
337 }
338 }
339
340 #[cfg(test)]
341 mod tests {
342 use super::*;
343
344 #[test]
345 fn test_version_parsing() {
346 let (v, is_ge, _) = ProtonManager::parse_version_from_name("GE-Proton9-5");
347 assert_eq!(v, (9, 5, 0));
348 assert!(is_ge);
349
350 let (v, is_ge, _) = ProtonManager::parse_version_from_name("Proton 8.0-5");
351 assert_eq!(v, (8, 0, 5));
352 assert!(!is_ge);
353 }
354
355 #[test]
356 fn test_compatibility() {
357 // Proton Experimental is now recommended (GE-Proton 10.x has wow64 issues)
358 let compat =
359 ProtonManager::determine_compatibility("Proton - Experimental", (0, 0, 0), false, true);
360 assert_eq!(compat, ProtonCompatibility::Recommended);
361
362 // GE-Proton 9.x is supported (before wow64 issues)
363 let compat =
364 ProtonManager::determine_compatibility("GE-Proton9-5", (9, 5, 0), true, false);
365 assert_eq!(compat, ProtonCompatibility::Supported);
366
367 // GE-Proton 10.x is experimental (wow64 issues)
368 let compat =
369 ProtonManager::determine_compatibility("GE-Proton10-28", (10, 28, 0), true, false);
370 assert_eq!(compat, ProtonCompatibility::Experimental);
371
372 let compat =
373 ProtonManager::determine_compatibility("Proton 6.0", (6, 0, 0), false, false);
374 assert_eq!(compat, ProtonCompatibility::Unsupported);
375 }
376 }
377