zeroed-some/wanda / 02ba0b5

Browse files

prefix: public mscorlib patch, wineserver env, wemod_app_exe resolver

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
02ba0b5bb5e0cac97682f43650da25dc7ba063ca
Parents
cce3b1d
Tree
e4f42ef

2 changed files

StatusFile+-
M crates/wanda-core/src/prefix/builder.rs 337 37
M crates/wanda-core/src/prefix/manager.rs 29 1
crates/wanda-core/src/prefix/builder.rsmodified
@@ -2,11 +2,20 @@
22
 
33
 use crate::error::{Result, WandaError};
44
 use crate::steam::ProtonVersion;
5
+use futures::StreamExt;
56
 use std::collections::HashMap;
67
 use std::path::{Path, PathBuf};
8
+use std::time::Duration;
79
 use tokio::process::Command;
10
+use tokio::time::timeout;
811
 use tracing::{debug, error, info, warn};
912
 
13
+/// Microsoft .NET Framework 4.8 offline installer (stable URL)
14
+const DOTNET48_URL: &str = "https://download.visualstudio.microsoft.com/download/pr/2d6bb6b2-226a-4baa-bdec-798822606ff1/8494001c276a4b96804cde7829c04d7f/ndp48-x86-x64-allos-enu.exe";
15
+const DOTNET48_FILENAME: &str = "ndp48-x86-x64-allos-enu.exe";
16
+/// Timeout for the .NET installer (5 minutes)
17
+const DOTNET_INSTALL_TIMEOUT: Duration = Duration::from_secs(300);
18
+
1019
 /// Builds and initializes Wine prefixes for WeMod
1120
 pub struct PrefixBuilder<'a> {
1221
     /// Base path for the wanda prefix (STEAM_COMPAT_DATA_PATH equivalent)
@@ -83,6 +92,13 @@ impl<'a> PrefixBuilder<'a> {
8392
         // Tell winetricks which wine to use
8493
         env.insert("WINE".to_string(), wine_path.to_string_lossy().to_string());
8594
 
95
+        // Tell winetricks which wineserver to use (must match wine version)
96
+        let wineserver_path = self.proton.path.join("files/bin/wineserver");
97
+        if wineserver_path.exists() {
98
+            debug!("Using Proton wineserver: {}", wineserver_path.display());
99
+            env.insert("WINESERVER".to_string(), wineserver_path.to_string_lossy().to_string());
100
+        }
101
+
86102
         // Proton lib paths for finding dependencies
87103
         let proton_lib64 = self.proton.path.join("files/lib64");
88104
         let proton_lib = self.proton.path.join("files/lib");
@@ -118,6 +134,22 @@ impl<'a> PrefixBuilder<'a> {
118134
         // Disable Wine debug output to prevent OOM from massive log accumulation
119135
         env.insert("WINEDEBUG".to_string(), "-all".to_string());
120136
 
137
+        // Pass display variables for GUI installers (required for .NET, vcrun, etc.)
138
+        if let Ok(disp) = std::env::var("DISPLAY") {
139
+            info!("Captured DISPLAY={}", disp);
140
+            env.insert("DISPLAY".to_string(), disp);
141
+        } else {
142
+            warn!("DISPLAY not set in environment!");
143
+        }
144
+        if let Ok(wayland_disp) = std::env::var("WAYLAND_DISPLAY") {
145
+            info!("Captured WAYLAND_DISPLAY={}", wayland_disp);
146
+            env.insert("WAYLAND_DISPLAY".to_string(), wayland_disp);
147
+        }
148
+        if let Ok(xdg_dir) = std::env::var("XDG_RUNTIME_DIR") {
149
+            info!("Captured XDG_RUNTIME_DIR={}", xdg_dir);
150
+            env.insert("XDG_RUNTIME_DIR".to_string(), xdg_dir);
151
+        }
152
+
121153
         // Log all environment variables at trace level
122154
         for (key, value) in &env {
123155
             debug!("ENV {}={}", key, value);
@@ -140,54 +172,63 @@ impl<'a> PrefixBuilder<'a> {
140172
         // Initialize with wineboot
141173
         self.init_wineboot().await?;
142174
 
143
-        // Try to install .NET Framework (but don't fail if it doesn't work)
144
-        match self.install_dotnet().await {
145
-            Ok(_) => info!(".NET Framework installed successfully"),
146
-            Err(e) => {
147
-                warn!(".NET Framework installation failed: {}", e);
148
-                warn!("WeMod may still work - continuing with installation");
149
-            }
150
-        }
151
-
152
-        // Install additional dependencies (optional)
153
-        self.install_dependencies().await?;
175
+        // Patch mscorlib.dll version so WeMod's .NET check passes.
176
+        // WeMod reads the PE FileVersion from mscorlib.dll and requires >= 4.7.2556.0.
177
+        // Wine Mono ships 4.0.30319.1 which fails the check. We patch the version
178
+        // resource to report 4.8.9232.0 (.NET 4.8) — Wine Mono's runtime still works
179
+        // fine, this is purely metadata in the PE resource section.
180
+        self.patch_mscorlib_version()?;
154181
 
155182
         info!("Prefix built successfully");
156183
         Ok(())
157184
     }
158185
 
159
-    /// Initialize the prefix with wineboot
186
+    /// Initialize the prefix with wineboot via Proton's script
187
+    ///
188
+    /// We must run through `./proton run wineboot` rather than calling wine64
189
+    /// directly so that Proton sets up its own prefix layout: steam.exe stubs,
190
+    /// DLL symlinks into the Proton distribution, lsteamclient, etc. Without
191
+    /// this, `proton waitforexitandrun` fails with STATUS_DLL_NOT_FOUND
192
+    /// (c0000135) because the symlinks point nowhere.
160193
     async fn init_wineboot(&self) -> Result<()> {
161
-        info!("Initializing Wine prefix with wineboot...");
194
+        info!("Initializing Wine prefix via Proton script...");
162195
 
163
-        let wine_path = self.get_wine_path();
164
-        let env = self.build_env();
196
+        let proton_script = self.proton.path.join("proton");
197
+        let env = self.build_proton_env();
165198
 
166
-        info!("Running: {} wineboot --init", wine_path.display());
199
+        info!(
200
+            "Running: ./proton run wineboot --init (from {})",
201
+            self.proton.path.display()
202
+        );
167203
 
168
-        let output = Command::new(&wine_path)
204
+        let output = Command::new(&proton_script)
205
+            .arg("run")
169206
             .arg("wineboot")
170207
             .arg("--init")
208
+            .current_dir(&self.proton.path)
171209
             .envs(&env)
172210
             .output()
173211
             .await
174212
             .map_err(|e| WandaError::PrefixCreationFailed {
175
-                path: self.prefix_path.clone(),
176
-                reason: format!("wineboot failed to start: {}", e),
213
+                path: self.base_path.clone(),
214
+                reason: format!("Proton wineboot failed to start: {}", e),
177215
             })?;
178216
 
179217
         if !output.status.success() {
180218
             let stderr = String::from_utf8_lossy(&output.stderr);
181219
             let stdout = String::from_utf8_lossy(&output.stdout);
182
-            error!("wineboot stdout: {}", stdout);
183
-            error!("wineboot stderr: {}", stderr);
220
+            error!("proton wineboot stdout: {}", stdout);
221
+            error!("proton wineboot stderr: {}", stderr);
184222
             return Err(WandaError::PrefixCreationFailed {
185
-                path: self.prefix_path.clone(),
186
-                reason: format!("wineboot failed with exit code: {:?}", output.status.code()),
223
+                path: self.base_path.clone(),
224
+                reason: format!(
225
+                    "Proton wineboot failed with exit code: {:?}",
226
+                    output.status.code()
227
+                ),
187228
             });
188229
         }
189230
 
190
-        debug!("wineboot completed successfully");
231
+        debug!("Proton wineboot completed successfully");
191232
 
192233
         // Wait for wineserver to finish
193234
         self.wait_wineserver().await;
@@ -195,6 +236,145 @@ impl<'a> PrefixBuilder<'a> {
195236
         Ok(())
196237
     }
197238
 
239
+    /// Build environment for running through the Proton script.
240
+    ///
241
+    /// Proton uses STEAM_COMPAT_DATA_PATH (not WINEPREFIX) and creates
242
+    /// the actual Wine prefix at `$STEAM_COMPAT_DATA_PATH/pfx/`.
243
+    fn build_proton_env(&self) -> HashMap<String, String> {
244
+        let mut env = HashMap::new();
245
+
246
+        // Proton manages WINEPREFIX internally from this path
247
+        env.insert(
248
+            "STEAM_COMPAT_DATA_PATH".to_string(),
249
+            self.base_path.to_string_lossy().to_string(),
250
+        );
251
+
252
+        // Try to find Steam root from Proton's location for Steam integration
253
+        if let Some(steam_root) = self.find_steam_root() {
254
+            debug!("Derived Steam root: {}", steam_root.display());
255
+            env.insert(
256
+                "STEAM_COMPAT_CLIENT_INSTALL_PATH".to_string(),
257
+                steam_root.to_string_lossy().to_string(),
258
+            );
259
+        }
260
+
261
+        env.insert("PROTON_NO_ESYNC".to_string(), "1".to_string());
262
+        env.insert("PROTON_NO_FSYNC".to_string(), "1".to_string());
263
+        env.insert("WINEDEBUG".to_string(), "-all".to_string());
264
+
265
+        // Display variables for GUI initialization
266
+        if let Ok(disp) = std::env::var("DISPLAY") {
267
+            env.insert("DISPLAY".to_string(), disp);
268
+        }
269
+        if let Ok(wayland) = std::env::var("WAYLAND_DISPLAY") {
270
+            env.insert("WAYLAND_DISPLAY".to_string(), wayland);
271
+        }
272
+        if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
273
+            env.insert("XDG_RUNTIME_DIR".to_string(), xdg);
274
+        }
275
+
276
+        env
277
+    }
278
+
279
+    /// Walk up from Proton's path to find the Steam root directory.
280
+    ///
281
+    /// Proton lives under either:
282
+    ///   `<steam>/compatibilitytools.d/<version>/`  (GE-Proton, custom)
283
+    ///   `<steam>/steamapps/common/<version>/`      (official Proton)
284
+    fn find_steam_root(&self) -> Option<PathBuf> {
285
+        let mut current = self.proton.path.as_path();
286
+        for _ in 0..5 {
287
+            current = current.parent()?;
288
+            // Steam root has steam.sh (native) or ubuntu12_32 (runtime)
289
+            if current.join("steam.sh").exists()
290
+                || current.join("ubuntu12_32").exists()
291
+                || current.join("steamapps").exists()
292
+            {
293
+                return Some(current.to_path_buf());
294
+            }
295
+        }
296
+        None
297
+    }
298
+
299
+    /// Patch mscorlib.dll PE version info so WeMod's .NET check passes.
300
+    ///
301
+    /// WeMod parses the VS_FIXEDFILEINFO from mscorlib.dll and requires
302
+    /// FileVersion >= 4.7.2556.0. Wine Mono ships 4.0.30319.1 which fails.
303
+    /// We patch dwFileVersionMS/LS and dwProductVersionMS/LS to report
304
+    /// 4.8.9232.0 (.NET Framework 4.8). This is safe because the version
305
+    /// resource is metadata only — Wine Mono's actual runtime is unaffected.
306
+    pub fn patch_mscorlib_version(&self) -> Result<()> {
307
+        // .NET 4.8 version: 4.8.9232.0
308
+        let new_file_version_ms: u32 = (4 << 16) | 8;      // major=4, minor=8
309
+        let new_file_version_ls: u32 = (9232 << 16) | 0;    // build=9232, revision=0
310
+
311
+        let paths = [
312
+            self.prefix_path.join("drive_c/windows/Microsoft.NET/Framework64/v4.0.30319/mscorlib.dll"),
313
+            self.prefix_path.join("drive_c/windows/Microsoft.NET/Framework/v4.0.30319/mscorlib.dll"),
314
+        ];
315
+
316
+        let signature: [u8; 4] = 0xFEEF04BDu32.to_le_bytes();
317
+
318
+        for path in &paths {
319
+            if !path.exists() {
320
+                debug!("mscorlib.dll not found at {}, skipping", path.display());
321
+                continue;
322
+            }
323
+
324
+            let mut data = std::fs::read(path).map_err(|e| WandaError::PrefixCreationFailed {
325
+                path: path.clone(),
326
+                reason: format!("Failed to read mscorlib.dll: {}", e),
327
+            })?;
328
+
329
+            // Find VS_FIXEDFILEINFO signature
330
+            let pos = data
331
+                .windows(4)
332
+                .position(|w| w == signature);
333
+
334
+            let pos = match pos {
335
+                Some(p) => p,
336
+                None => {
337
+                    warn!("VS_FIXEDFILEINFO signature not found in {}", path.display());
338
+                    continue;
339
+                }
340
+            };
341
+
342
+            // Verify we have enough bytes after the signature
343
+            // Layout: signature(4) + struc_version(4) + file_ver_ms(4) + file_ver_ls(4)
344
+            //       + product_ver_ms(4) + product_ver_ls(4)
345
+            if pos + 24 > data.len() {
346
+                warn!("VS_FIXEDFILEINFO too close to end of file in {}", path.display());
347
+                continue;
348
+            }
349
+
350
+            // Read current version for logging
351
+            let old_ms = u32::from_le_bytes(data[pos + 8..pos + 12].try_into().unwrap());
352
+            let old_ls = u32::from_le_bytes(data[pos + 12..pos + 16].try_into().unwrap());
353
+            info!(
354
+                "Patching {} version {}.{}.{}.{} -> 4.8.9232.0",
355
+                path.display(),
356
+                old_ms >> 16, old_ms & 0xFFFF,
357
+                old_ls >> 16, old_ls & 0xFFFF,
358
+            );
359
+
360
+            // Patch FileVersion (offset +8, +12 from signature)
361
+            data[pos + 8..pos + 12].copy_from_slice(&new_file_version_ms.to_le_bytes());
362
+            data[pos + 12..pos + 16].copy_from_slice(&new_file_version_ls.to_le_bytes());
363
+
364
+            // Patch ProductVersion (offset +16, +20 from signature)
365
+            data[pos + 16..pos + 20].copy_from_slice(&new_file_version_ms.to_le_bytes());
366
+            data[pos + 20..pos + 24].copy_from_slice(&new_file_version_ls.to_le_bytes());
367
+
368
+            std::fs::write(path, &data).map_err(|e| WandaError::PrefixCreationFailed {
369
+                path: path.clone(),
370
+                reason: format!("Failed to write patched mscorlib.dll: {}", e),
371
+            })?;
372
+        }
373
+
374
+        info!("mscorlib.dll version patched for WeMod compatibility");
375
+        Ok(())
376
+    }
377
+
198378
     /// Wait for wineserver to finish
199379
     async fn wait_wineserver(&self) {
200380
         let env = self.build_env();
@@ -214,22 +394,141 @@ impl<'a> PrefixBuilder<'a> {
214394
             .await;
215395
     }
216396
 
217
-    /// Install .NET Framework 4.8 (required for WeMod)
397
+    /// Install .NET Framework 4.8 (required for WeMod's trainer engine)
398
+    ///
399
+    /// Downloads the official Microsoft offline installer and runs it directly
400
+    /// via Proton — avoids winetricks which corrupts 64-bit Proton prefixes
401
+    /// by changing the Windows version and removing Wine Mono.
218402
     pub async fn install_dotnet(&self) -> Result<()> {
219
-        info!("Installing .NET Framework 4.8 via winetricks...");
220
-        info!("This may take 10-15 minutes and requires internet connection");
221
-
222
-        // Try dotnet48 first, fall back to dotnet40 if it fails
223
-        match self.install_winetricks_verbose(&["dotnet48"]).await {
224
-            Ok(_) => return Ok(()),
225
-            Err(e) => {
226
-                warn!("dotnet48 failed: {}", e);
227
-                warn!("Trying dotnet40 as fallback...");
403
+        info!("Installing .NET Framework 4.8 via direct Proton install...");
404
+
405
+        // Download the offline installer to cache
406
+        let cache_dir = dirs::cache_dir()
407
+            .unwrap_or_else(|| PathBuf::from("/tmp"))
408
+            .join("wanda/downloads");
409
+        std::fs::create_dir_all(&cache_dir)?;
410
+
411
+        let installer_path = cache_dir.join(DOTNET48_FILENAME);
412
+
413
+        if installer_path.exists() {
414
+            info!("Using cached .NET installer: {}", installer_path.display());
415
+        } else {
416
+            info!("Downloading .NET 4.8 offline installer...");
417
+            self.download_file(DOTNET48_URL, &installer_path).await?;
418
+            info!("Downloaded to: {}", installer_path.display());
419
+        }
420
+
421
+        // Run the installer via Proton's runinprefix verb.
422
+        // This uses Proton's wine with proper DLL overrides and library paths
423
+        // WITHOUT changing Windows version or removing Wine Mono.
424
+        let proton_script = self.proton.path.join("proton");
425
+        let env = self.build_proton_env();
426
+
427
+        info!("Running .NET installer via Proton (timeout: {:?})...", DOTNET_INSTALL_TIMEOUT);
428
+
429
+        let install_future = Command::new(&proton_script)
430
+            .arg("runinprefix")
431
+            .arg(&installer_path)
432
+            .arg("/q")        // Quiet mode
433
+            .arg("/norestart") // Don't try to restart
434
+            .current_dir(&self.proton.path)
435
+            .envs(&env)
436
+            .stdout(std::process::Stdio::inherit())
437
+            .stderr(std::process::Stdio::inherit())
438
+            .status();
439
+
440
+        match timeout(DOTNET_INSTALL_TIMEOUT, install_future).await {
441
+            Ok(Ok(status)) => {
442
+                if status.success() {
443
+                    info!(".NET 4.8 installed successfully");
444
+                } else {
445
+                    let code = status.code();
446
+                    // .NET installer exit codes:
447
+                    // 0 = success, 1602 = user cancelled, 1603 = fatal error,
448
+                    // 1641/3010 = success (reboot needed), 5100 = prerequisites
449
+                    match code {
450
+                        Some(1641) | Some(3010) => {
451
+                            info!(".NET 4.8 installed (reboot requested but not needed under Wine)");
452
+                        }
453
+                        _ => {
454
+                            warn!(".NET installer exited with code: {:?}", code);
455
+                            return Err(WandaError::WinetricksFailed {
456
+                                reason: format!(".NET 4.8 installer failed with exit code {:?}", code),
457
+                            });
458
+                        }
459
+                    }
460
+                }
461
+            }
462
+            Ok(Err(e)) => {
463
+                return Err(WandaError::WinetricksFailed {
464
+                    reason: format!("Failed to run .NET installer: {}", e),
465
+                });
466
+            }
467
+            Err(_) => {
468
+                warn!(".NET installer timed out after {:?}", DOTNET_INSTALL_TIMEOUT);
469
+                return Err(WandaError::WinetricksFailed {
470
+                    reason: format!(".NET installer timed out after {:?}", DOTNET_INSTALL_TIMEOUT),
471
+                });
472
+            }
473
+        }
474
+
475
+        // Wait for wineserver to finish
476
+        self.wait_wineserver().await;
477
+
478
+        Ok(())
479
+    }
480
+
481
+    /// Download a file from a URL to a local path
482
+    async fn download_file(&self, url: &str, dest: &Path) -> Result<()> {
483
+        let client = reqwest::Client::builder()
484
+            .timeout(Duration::from_secs(600))
485
+            .build()
486
+            .map_err(|e| WandaError::WinetricksFailed {
487
+                reason: format!("Failed to create HTTP client: {}", e),
488
+            })?;
489
+
490
+        let response = client.get(url).send().await.map_err(|e| {
491
+            WandaError::WinetricksFailed {
492
+                reason: format!("Failed to download {}: {}", url, e),
228493
             }
494
+        })?;
495
+
496
+        if !response.status().is_success() {
497
+            return Err(WandaError::WinetricksFailed {
498
+                reason: format!("Download failed with HTTP {}", response.status()),
499
+            });
229500
         }
230501
 
231
-        // Try dotnet40 as fallback
232
-        self.install_winetricks_verbose(&["dotnet40"]).await
502
+        let total_size = response.content_length().unwrap_or(0);
503
+        info!("Downloading {} ({:.1} MB)...", DOTNET48_FILENAME, total_size as f64 / 1_048_576.0);
504
+
505
+        let tmp_path = dest.with_extension("tmp");
506
+        let mut file = tokio::fs::File::create(&tmp_path).await.map_err(|e| {
507
+            WandaError::WinetricksFailed {
508
+                reason: format!("Failed to create file: {}", e),
509
+            }
510
+        })?;
511
+
512
+        let mut stream = response.bytes_stream();
513
+        while let Some(chunk) = stream.next().await {
514
+            let chunk = chunk.map_err(|e| WandaError::WinetricksFailed {
515
+                reason: format!("Download interrupted: {}", e),
516
+            })?;
517
+            tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
518
+                .await
519
+                .map_err(|e| WandaError::WinetricksFailed {
520
+                    reason: format!("Failed to write file: {}", e),
521
+                })?;
522
+        }
523
+
524
+        // Atomic rename
525
+        tokio::fs::rename(&tmp_path, dest).await.map_err(|e| {
526
+            WandaError::WinetricksFailed {
527
+                reason: format!("Failed to move downloaded file: {}", e),
528
+            }
529
+        })?;
530
+
531
+        Ok(())
233532
     }
234533
 
235534
     /// Install additional dependencies
@@ -274,6 +573,7 @@ impl<'a> PrefixBuilder<'a> {
274573
             // Run winetricks with output going directly to terminal (not collected in memory)
275574
             // This prevents OOM when installing large components like dotnet48
276575
             let status = Command::new("winetricks")
576
+                .arg("-q")      // Unattended mode - no GUI dialogs
277577
                 .arg("--force") // Force installation even if already installed
278578
                 .arg(component)
279579
                 .envs(&env)
@@ -347,7 +647,7 @@ mod tests {
347647
         let builder = PrefixBuilder::new(Path::new("/tmp/test"), &proton);
348648
         let env = builder.build_env();
349649
 
350
-        assert_eq!(env.get("WINEPREFIX"), Some(&"/tmp/test".to_string()));
650
+        assert_eq!(env.get("WINEPREFIX"), Some(&"/tmp/test/pfx".to_string()));
351651
         assert_eq!(env.get("WINEARCH"), Some(&"win64".to_string()));
352652
     }
353653
 }
crates/wanda-core/src/prefix/manager.rsmodified
@@ -106,11 +106,39 @@ impl WandaPrefix {
106106
         wemod_path
107107
     }
108108
 
109
-    /// Get the WeMod executable path
109
+    /// Get the WeMod executable path (Squirrel stub)
110110
     pub fn wemod_exe(&self) -> PathBuf {
111111
         self.wemod_path().join("WeMod.exe")
112112
     }
113113
 
114
+    /// Get the real WeMod executable inside the versioned app directory
115
+    ///
116
+    /// WeMod uses Squirrel for updates. The root WeMod.exe is a small stub
117
+    /// that spawns the real Electron app from `app-{version}/WeMod.exe` and
118
+    /// exits. For standalone mode we need the real exe so Proton keeps the
119
+    /// Wine session alive.
120
+    pub fn wemod_app_exe(&self) -> Option<PathBuf> {
121
+        let wemod_dir = self.wemod_path();
122
+        let mut best: Option<(String, PathBuf)> = None;
123
+
124
+        if let Ok(entries) = std::fs::read_dir(&wemod_dir) {
125
+            for entry in entries.flatten() {
126
+                let name = entry.file_name().to_string_lossy().to_string();
127
+                if name.starts_with("app-") && entry.path().is_dir() {
128
+                    let exe = entry.path().join("WeMod.exe");
129
+                    if exe.exists() {
130
+                        // Pick the highest version directory
131
+                        if best.as_ref().map_or(true, |(v, _)| name > *v) {
132
+                            best = Some((name, exe));
133
+                        }
134
+                    }
135
+                }
136
+            }
137
+        }
138
+
139
+        best.map(|(_, path)| path)
140
+    }
141
+
114142
     /// Get alternative WeMod paths for checking installation
115143
     pub fn wemod_paths_all(&self) -> Vec<PathBuf> {
116144
         vec![