diff --git a/src/infra/logging/session.rs b/src/infra/logging/session.rs index 68d01a2..7a5b867 100644 --- a/src/infra/logging/session.rs +++ b/src/infra/logging/session.rs @@ -50,6 +50,25 @@ pub struct LaunchVerification { pub process_lifetime_ms: Option, pub exit_code: Option, pub log_growth_observed: bool, + pub windows_username: Option, + pub windows_user_path: Option, + pub key_paths_detected: HashMap, + pub steam_client_exposed: bool, + pub last_successful_startup_milestone: String, + pub dependency_families_detected: Vec, + pub steam_runtime_exe: Option, + pub steam_runtime_args: Vec, + pub steam_runtime_exit_code: Option, + pub steam_runtime_lifetime_ms: Option, + pub steam_runtime_milestone: String, + pub steam_running_before_launch: bool, + pub steam_auto_start_attempted: bool, + pub steam_auto_start_failed: bool, + pub steam_api_initialized: Option, + pub steam_ownership_confirmed: Option, + pub steam_client_artifact: Option, // "local", "windows", "host" + pub log_head: Vec, + pub log_tail: Vec, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] diff --git a/src/infra/logging/wine_capture.rs b/src/infra/logging/wine_capture.rs index 87e9a11..75eca2c 100644 --- a/src/infra/logging/wine_capture.rs +++ b/src/infra/logging/wine_capture.rs @@ -1,6 +1,83 @@ +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum StartupMilestone { + None = 0, + InitialProcessBootstrap = 1, + SteamBootstrapInitialized = 2, + GameLocalDllsLoaded = 3, + GraphicsRendererInitStarted = 4, + RunningState = 5, +} + +impl std::fmt::Display for StartupMilestone { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::None => "none", + Self::InitialProcessBootstrap => "initial_process_bootstrap", + Self::SteamBootstrapInitialized => "steam_bootstrap_initialized", + Self::GameLocalDllsLoaded => "game_local_dlls_loaded", + Self::GraphicsRendererInitStarted => "graphics_renderer_init_started", + Self::RunningState => "running_state", + }; + write!(f, "{}", s) + } +} + +pub fn detect_startup_milestone(log_line: &str) -> Option { + let line_lower = log_line.to_lowercase(); + + // Steam Runtime specific milestones (can be used for background Steam too) + if line_lower.contains("steam.exe") && line_lower.contains("starting") { + return Some(StartupMilestone::InitialProcessBootstrap); + } + + // Generic bootstrap markers + if line_lower.contains("wine_init") || line_lower.contains("ntdll:ldrload_dll") || line_lower.contains("kernelbase:loadlibrary") { + return Some(StartupMilestone::InitialProcessBootstrap); + } + + // Steam bootstrap + if line_lower.contains("steamapi_init") || line_lower.contains("steamapi_restartappifnecessary") || line_lower.contains("steam_client: initialized") || line_lower.contains("steamapi_init_all") { + return Some(StartupMilestone::SteamBootstrapInitialized); + } + + // Game local DLLs (representative families for Batman/Amnesia/Metro) + if line_lower.contains("loaddll") && ( + line_lower.contains("physx") || line_lower.contains("gfsdk") || line_lower.contains("nvtt") || // Batman + line_lower.contains("sdl2") || line_lower.contains("newton") || line_lower.contains("devil") || // Amnesia + line_lower.contains("physxdevice") || line_lower.contains("4a_backend") // Metro + ) { + return Some(StartupMilestone::GameLocalDllsLoaded); + } + + // Renderer initialization + if line_lower.contains("d3d11internalcreatedevice") || + line_lower.contains("dxvk: v") || + line_lower.contains("vkd3d-proton: v") || + line_lower.contains("presenter: actual swapchain properties") || + line_lower.contains("wined3d: v") { + return Some(StartupMilestone::GraphicsRendererInitStarted); + } + + // Likely running state + if line_lower.contains("initialization complete") || line_lower.contains("game: main loop started") { + return Some(StartupMilestone::RunningState); + } + + None +} + pub fn classify_graphics_evidence(log_line: &str) -> Option { let line_lower = log_line.to_lowercase(); + // Noise Filter: generic Wine loader noise is ignored for evidence but preserved for milestones + if line_lower.contains("find_builtin_dll") || + line_lower.contains("winemac.drv") || + line_lower.contains("winecoreaudio.drv") || + line_lower.contains("xinput") || + line_lower.contains("libegl") { + return None; + } + // DXVK signatures if line_lower.contains("info: dxvk:") || line_lower.contains("dxvk: v") || @@ -32,11 +109,65 @@ pub fn classify_graphics_evidence(log_line: &str) -> Option { if line_lower.contains("winemac.drv") { return None; } + + // Specific middleware/dependency failure patterns + if line_lower.contains("physx") { + return Some(format!("PhysX/Middleware failure: {}", log_line.trim())); + } + return Some(format!("DLL Load Failure: {}", log_line.trim())); } if line_lower.contains("not found") && line_lower.contains("which is needed by") { return Some(format!("DLL Dependency Missing: {}", log_line.trim())); } + // Steam/Client patterns + if line_lower.contains("failed to create steam.exe") || + line_lower.contains("cannot find 'steam.exe'") || + (line_lower.contains("steam.exe") && (line_lower.contains("not found") || line_lower.contains("failed"))) { + return Some(format!("Steam Client/Environment Failure: {}", log_line.trim())); + } + + if line_lower.contains("steamapi_init") && (line_lower.contains("failed") || line_lower.contains("error")) { + return Some(format!("SteamAPI Initialization Failed: {}", log_line.trim())); + } + + if line_lower.contains("steamapi_restartappifnecessary") && (line_lower.contains("returning true") || line_lower.contains("restarting")) { + return Some(format!("Steam Handoff Triggered Restart: {}", log_line.trim())); + } + + if line_lower.contains("steamapi_issteamrunning") && line_lower.contains("did not locate") { + return Some(format!("SteamAPI Connection Failed (Steam not found): {}", log_line.trim())); + } + + if line_lower.contains("user does not own") || line_lower.contains("ownership failed") { + return Some(format!("Steam Ownership Validation Failed: {}", log_line.trim())); + } + + if line_lower.contains("tried to access steam interface") && line_lower.contains("before steamapi_init") { + return Some(format!("SteamAPI Access Violation: {}", log_line.trim())); + } + + // Steam Client Artifact Detection + if line_lower.contains("loaddll") || line_lower.contains("load_module") { + if line_lower.contains("steam_api.dll") || line_lower.contains("steam_api64.dll") { + return Some(format!("Steam Client Artifact: local ({})", log_line.trim())); + } + if line_lower.contains("steamclient.dll") || line_lower.contains("steamclient64.dll") { + if line_lower.contains("program files") || line_lower.contains("steam") { + return Some(format!("Steam Client Artifact: windows ({})", log_line.trim())); + } + } + if line_lower.contains("lsteamclient") || line_lower.contains("steamclient.so") { + return Some(format!("Steam Client Artifact: host ({})", log_line.trim())); + } + } + + // Override/Policy regressions + if line_lower.contains("invalid dll") || + (line_lower.contains("failed to load") && (line_lower.contains("d3d11"))) { + return Some(format!("Override Policy Conflict: {}", log_line.trim())); + } + None } diff --git a/src/infra/runners/tests.rs b/src/infra/runners/tests.rs index c1f8385..19e3cfe 100644 --- a/src/infra/runners/tests.rs +++ b/src/infra/runners/tests.rs @@ -33,6 +33,7 @@ mod tests { proton_path: Some("/tmp/proton".to_string()), target_architecture: crate::models::ExecutableArchitecture::X86_64, dll_resolutions: Vec::new(), + verification_ptr: std::ptr::null_mut(), } } @@ -111,6 +112,7 @@ mod tests { proton_path: Some(runner_path.to_string_lossy().to_string()), target_architecture: crate::models::ExecutableArchitecture::X86_64, dll_resolutions: Vec::new(), + verification_ptr: std::ptr::null_mut(), }; let runner = WineTkgRunner; diff --git a/src/infra/runners/trait.rs b/src/infra/runners/trait.rs index 4430cd5..9c1d3a8 100644 --- a/src/infra/runners/trait.rs +++ b/src/infra/runners/trait.rs @@ -13,8 +13,12 @@ pub struct LaunchContext { pub proton_path: Option, pub target_architecture: crate::models::ExecutableArchitecture, pub dll_resolutions: Vec, + pub verification_ptr: *mut crate::infra::logging::LaunchVerification, // HACK: for Runner to write diagnostics } +unsafe impl Send for LaunchContext {} +unsafe impl Sync for LaunchContext {} + #[derive(Debug, Clone, Default)] pub struct CommandSpec { pub program: PathBuf, diff --git a/src/infra/runners/wine_tkg.rs b/src/infra/runners/wine_tkg.rs index 2e7a931..8ae6872 100644 --- a/src/infra/runners/wine_tkg.rs +++ b/src/infra/runners/wine_tkg.rs @@ -13,7 +13,13 @@ impl Runner for WineTkgRunner { fn name(&self) -> &str { "Wine-TKG" } async fn prepare_prefix(&self, ctx: &LaunchContext) -> std::result::Result<(), LaunchError> { let library_root = PathBuf::from(&ctx.launcher_config.steam_library_path); - let use_steam_runtime = ctx.user_config.as_ref().map(|c| c.use_steam_runtime).unwrap_or(false); + + let is_batman = ctx.app.app_id == 209000; + let requires_steam_runtime = is_batman; + + let use_steam_runtime = ctx.user_config.as_ref() + .map(|c| c.use_steam_runtime) + .unwrap_or(requires_steam_runtime); let steam_prefix_mode = ctx.user_config.as_ref() .map(|c| c.steam_prefix_mode.clone()) .unwrap_or(ctx.launcher_config.steam_prefix_mode.clone()); @@ -35,26 +41,31 @@ impl Runner for WineTkgRunner { tracing::info!("Steam Runtime Prefix Mode: {:?}", steam_prefix_mode); if use_steam_runtime { - let base_config = crate::config::config_dir().map_err(|e| LaunchError::new(LaunchErrorKind::Environment, "failed to get config dir").with_source(e))?; - let master_prefix = base_config.join("master_steam_prefix"); - - tracing::info!("Looking for Master Steam in: {}", master_prefix.display()); - - match find_master_steam_dir(&master_prefix) { + let steam_cfg = crate::utils::get_master_steam_config(); + tracing::info!("Unified Master Steam resolution (Game Launch):"); + tracing::info!(" - Root Dir: {}", steam_cfg.root_dir.display()); + tracing::info!(" - Wine Prefix: {}", steam_cfg.wine_prefix.display()); + tracing::info!(" - Layout Kind: {}", steam_cfg.layout_kind); + + let master_steam_dir = match &steam_cfg.steam_exe { + Some(exe) => exe.parent().unwrap().to_path_buf(), None => { return Err(LaunchError::new( LaunchErrorKind::Environment, format!( "use_steam_runtime is enabled but steam.exe was not found in {}.\n\ Go to Settings → 'Install / Manage Windows Steam Runtime' first.", - master_prefix.display() + steam_cfg.wine_prefix.display() ) - ).with_context("master_prefix", master_prefix.to_string_lossy())); + ).with_context("master_prefix", steam_cfg.wine_prefix.to_string_lossy())); } - Some(master_steam_dir) => { - let (prefix_steam_dir, steam_wineprefix) = match steam_prefix_mode { + }; + + tracing::info!(" - Steam Exe: {}", steam_cfg.steam_exe.as_ref().unwrap().display()); + + let (prefix_steam_dir, steam_wineprefix) = match steam_prefix_mode { crate::models::SteamPrefixMode::Shared => { - (master_steam_dir.clone(), crate::utils::resolve_master_wineprefix()) + (master_steam_dir.clone(), steam_cfg.wine_prefix.clone()) } crate::models::SteamPrefixMode::PerGame => { let target_steam_dir = effective_game_prefix @@ -114,11 +125,11 @@ impl Runner for WineTkgRunner { } } - (target_steam_dir, effective_game_prefix.clone()) - } - }; + (target_steam_dir, effective_game_prefix.clone()) + } + }; - tracing::debug!("Runtime Steam dir : {}", prefix_steam_dir.display()); + tracing::debug!("Runtime Steam dir : {}", prefix_steam_dir.display()); tracing::debug!("Runtime WINEPREFIX : {}", steam_wineprefix.display()); SteamClient::write_headless_steam_cfg(&prefix_steam_dir); @@ -155,7 +166,16 @@ impl Runner for WineTkgRunner { steam_args.push("-bigpicture".to_string()); } - if SteamClient::is_steam_running_in_prefix(&steam_wineprefix) { + let steam_running = SteamClient::is_steam_running_in_prefix(&steam_wineprefix); + + unsafe { + if !ctx.verification_ptr.is_null() { + let v = &mut *ctx.verification_ptr; + v.steam_running_before_launch = steam_running; + } + } + + if steam_running { println!("✅ Steam already running in prefix — skipping spawn"); } else { let proton = if let Some(forced) = ctx.launcher_config @@ -181,8 +201,8 @@ impl Runner for WineTkgRunner { .env("WINEPREFIX", &steam_wineprefix) .env( "WINEDLLOVERRIDES", - "vstdlib_s=n;tier0_s=n;steamclient=n;steamclient64=n;\ - steam_api=n;steam_api64=n;lsteamclient=;\ + "vstdlib_s=n,b;tier0_s=n,b;steamclient=n,b;steamclient64=n,b;\ + steam_api=n,b;steam_api64=n,b;lsteamclient=;\ GameOverlayRenderer=n;GameOverlayRenderer64=n", ) .env("WINEPATH", "C:\\Program Files (x86)\\Steam") @@ -196,10 +216,29 @@ impl Runner for WineTkgRunner { println!("Args: {:?}", steam_cmd.get_args().collect::>()); println!("--------------------------"); + // Record Steam runtime diagnostics + unsafe { + if !ctx.verification_ptr.is_null() { + let v = &mut *ctx.verification_ptr; + v.steam_runtime_exe = Some(steam_cmd.get_program().to_string_lossy().to_string()); + v.steam_runtime_args = steam_cmd.get_args().map(|a| a.to_string_lossy().to_string()).collect(); + v.steam_runtime_milestone = "steam_process_spawn_requested".to_string(); + v.steam_auto_start_attempted = true; + } + } + + let start_time = std::time::Instant::now(); let mut steam_process = steam_cmd.spawn().map_err(|e| LaunchError::new(LaunchErrorKind::Process, "Failed to spawn background Steam").with_source(anyhow!(e)))?; - println!("Waiting for Steam to initialise (max 30s)..."); + unsafe { + if !ctx.verification_ptr.is_null() { + (*ctx.verification_ptr).steam_runtime_milestone = "steam_process_spawned".to_string(); + } + } + + let readiness_timeout = 8; + println!("Waiting for Steam to initialise (max {}s)...", readiness_timeout); let steam_pid_path = prefix_steam_dir.join("steam.pid"); let steam_pipe = steam_wineprefix.join("drive_c/windows/temp/.steampath"); @@ -207,12 +246,20 @@ impl Runner for WineTkgRunner { let steam_logs_dir = prefix_steam_dir.join("logs"); let ready = 'wait: { - for i in 0..30 { + for i in 0..readiness_timeout { tokio::time::sleep(std::time::Duration::from_secs(1)).await; // Crash detection — bail immediately if let Ok(Some(status)) = steam_process.try_wait() { println!("❌ FATAL: Background Steam exited after {}s with: {}", i + 1, status); + unsafe { + if !ctx.verification_ptr.is_null() { + let v = &mut *ctx.verification_ptr; + v.steam_runtime_exit_code = status.code(); + v.steam_runtime_lifetime_ms = Some(start_time.elapsed().as_millis() as u64); + v.steam_runtime_milestone = "steam_process_exited_early".to_string(); + } + } break 'wait false; } @@ -240,21 +287,34 @@ impl Runner for WineTkgRunner { .unwrap_or(0); if log_count >= 2 { println!("✅ Steam ready after {}s ({} log files found)", i + 1, log_count); + unsafe { + if !ctx.verification_ptr.is_null() { + (*ctx.verification_ptr).steam_runtime_milestone = "steam_ready_signal_observed".to_string(); + } + } break 'wait true; } println!(" Waiting... {}s", i + 1); } - println!("⚠️ Steam did not signal ready after 30s, launching game anyway"); + println!("⚠️ Steam did not signal ready after {}s, launching game anyway", readiness_timeout); + unsafe { + if !ctx.verification_ptr.is_null() { + (*ctx.verification_ptr).steam_runtime_milestone = "steam_ready_timeout".to_string(); + } + } true }; if !ready { - return Err(LaunchError::new(LaunchErrorKind::Process, "Background Steam crashed before the game could start")); + unsafe { + if !ctx.verification_ptr.is_null() { + (*ctx.verification_ptr).steam_auto_start_failed = true; + } + } + return Err(LaunchError::new(LaunchErrorKind::Process, "Background Steam crashed before the game could start")); } } - } - } } // Write steam_appid.txt to the game working directory @@ -263,7 +323,13 @@ impl Runner for WineTkgRunner { .clone() .ok_or_else(|| LaunchError::new(LaunchErrorKind::GameData, format!("game {} is not installed", ctx.app.app_id)))?, ); - let executable = install_dir.join(&ctx.launch_info.executable.replace('\\', "/")); + + let exe_rel = ctx.launch_info.executable.replace('\\', "/"); + let executable = if Path::new(&exe_rel).is_absolute() { + PathBuf::from(&exe_rel) + } else { + install_dir.join(&exe_rel) + }; let game_working_dir: PathBuf = ctx.launch_info.workingdir .as_deref() .filter(|s| !s.is_empty()) @@ -299,10 +365,21 @@ impl Runner for WineTkgRunner { ); env.insert("SteamAppId".to_string(), app_id_str.clone()); - env.insert("SteamGameId".to_string(), app_id_str); + env.insert("SteamGameId".to_string(), app_id_str.clone()); + env.insert("STEAM_COMPAT_APP_ID".to_string(), app_id_str); env.insert("WINEPREFIX".to_string(), effective_game_prefix.to_string_lossy().to_string()); env.insert("STEAM_COMPAT_DATA_PATH".to_string(), compat_data_path.to_string_lossy().to_string()); + // Add user identity context if available + if let Ok(session) = crate::config::load_session().await { + if let Some(steam_id) = session.steam_id { + env.insert("SteamUser".to_string(), steam_id.to_string()); + } + if let Some(account_name) = session.account_name { + env.insert("SteamAppUser".to_string(), account_name); + } + } + let glc = ctx.user_config.as_ref() .map(|c| c.graphics_layers.clone()) .unwrap_or_default(); @@ -310,18 +387,26 @@ impl Runner for WineTkgRunner { .map(|c| c.steam_launch_config.no_overlay) .unwrap_or(true); - let install_dir = PathBuf::from( - ctx.app.install_path - .clone() - .ok_or_else(|| LaunchError::new(LaunchErrorKind::GameData, format!("game {} is not installed", ctx.app.app_id)))?, - ); - let executable = install_dir.join(&ctx.launch_info.executable.replace('\\', "/")); - let game_working_dir: PathBuf = ctx.launch_info.workingdir - .as_deref() - .filter(|s| !s.is_empty()) - .map(|wd| install_dir.join(wd.replace('\\', "/"))) - .or_else(|| executable.parent().map(|p| p.to_path_buf())) - .unwrap_or_else(|| install_dir.clone()); + let game_working_dir: PathBuf = { + let install_dir = PathBuf::from( + ctx.app.install_path + .clone() + .ok_or_else(|| LaunchError::new(LaunchErrorKind::GameData, format!("game {} is not installed", ctx.app.app_id)))?, + ); + + let exe_rel = ctx.launch_info.executable.replace('\\', "/"); + let executable = if Path::new(&exe_rel).is_absolute() { + PathBuf::from(&exe_rel) + } else { + install_dir.join(&exe_rel) + }; + ctx.launch_info.workingdir + .as_deref() + .filter(|s| !s.is_empty()) + .map(|wd| install_dir.join(wd.replace('\\', "/"))) + .or_else(|| executable.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| install_dir.clone()) + }; // Resolve proton version for component detection and DLL path building let proton = if let Some(forced) = ctx.launcher_config @@ -392,13 +477,24 @@ impl Runner for WineTkgRunner { if res.chosen_provider == crate::launch::dll_provider_resolver::DllProvider::GameLocal || (res.chosen_provider == crate::launch::dll_provider_resolver::DllProvider::Custom && !use_symlinks) || (res.chosen_provider == crate::launch::dll_provider_resolver::DllProvider::Runner && res.name.contains("nvapi")) { + + // Do not emit overrides for DLLs that are handled via internal capabilities + if res.chosen_provider == crate::launch::dll_provider_resolver::DllProvider::Internal { + tracing::info!("Resolved DLL {} is handled internally (alias), skipping explicit override", res.name); + continue; + } + // Ensure native wins for game-local or non-symlinked custom DLLs if !dll_overrides.contains(&format!("{}=n", res.name)) { + tracing::info!("Adding native override for resolved DLL: {} (provider: {:?})", res.name, res.chosen_provider); dll_overrides.push_str(&format!(";{}=n", res.name)); } + } else if res.chosen_provider == crate::launch::dll_provider_resolver::DllProvider::Internal { + tracing::info!("Resolved DLL {} is handled internally (alias), skipping explicit override", res.name); } } + tracing::info!("Final WINEDLLOVERRIDES: {}", dll_overrides); env.insert("WINEDLLOVERRIDES".to_string(), dll_overrides); // Track effective state for diagnostics (HACK: should ideally be done in a separate stage) @@ -645,7 +741,13 @@ impl Runner for WineTkgRunner { .clone() .ok_or_else(|| LaunchError::new(LaunchErrorKind::GameData, format!("game {} is not installed", ctx.app.app_id)))?, ); - let executable = install_dir.join(&ctx.launch_info.executable.replace('\\', "/")); + + let exe_rel = ctx.launch_info.executable.replace('\\', "/"); + let executable = if Path::new(&exe_rel).is_absolute() { + PathBuf::from(&exe_rel) + } else { + install_dir.join(&exe_rel) + }; let game_working_dir: PathBuf = ctx.launch_info.workingdir .as_deref() .filter(|s| !s.is_empty()) @@ -707,24 +809,3 @@ impl Runner for WineTkgRunner { } } -fn find_master_steam_exe(prefix: &Path) -> Option { - let candidates = [ - "pfx/drive_c/Program Files (x86)/Steam/steam.exe", - "pfx/drive_c/Program Files/Steam/steam.exe", - "drive_c/Program Files (x86)/Steam/steam.exe", - "drive_c/Program Files/Steam/steam.exe", - ]; - - for rel_path in candidates { - let full_path = prefix.join(rel_path); - if full_path.exists() { - return Some(full_path); - } - } - - None -} - -fn find_master_steam_dir(prefix: &Path) -> Option { - find_master_steam_exe(prefix).and_then(|exe| exe.parent().map(|p| p.to_path_buf())) -} diff --git a/src/launch/dll_provider_resolver.rs b/src/launch/dll_provider_resolver.rs index 128c91e..8a57be6 100644 --- a/src/launch/dll_provider_resolver.rs +++ b/src/launch/dll_provider_resolver.rs @@ -56,8 +56,6 @@ impl DllProviderResolver { "d3d8".into(), "d3d9".into(), "dxgi".into(), - "d3d10".into(), - "d3d10_1".into(), "d3d10core".into(), "d3d11".into(), "d3d12".into(), @@ -273,7 +271,7 @@ impl DllProviderResolver { // 3. System Priority // For now, we use a simplified check for system paths let system_paths = match dll_name { - "d3d8" | "d3d9" | "d3d10" | "d3d10_1" | "d3d10core" | "d3d11" | "dxgi" => vec![ + "d3d8" | "d3d9" | "d3d10core" | "d3d11" | "dxgi" => vec![ "/usr/lib/dxvk/x64", "/usr/lib/x86_64-linux-gnu/dxvk", ], @@ -324,7 +322,7 @@ impl DllProviderResolver { custom_vkd3d_proton_path: Option<&Path>, ) -> Option { let dll_filename = format!("{}.dll", dll_name); - let is_dxvk = matches!(dll_name, "d3d8" | "d3d9" | "d3d10" | "d3d10_1" | "d3d10core" | "d3d11" | "dxgi"); + let is_dxvk = matches!(dll_name, "d3d8" | "d3d9" | "d3d10core" | "d3d11" | "dxgi"); let is_vkd3d_proton = matches!(dll_name, "d3d12" | "d3d12core"); let is_vkd3d = matches!(dll_name, "libvkd3d-1" | "libvkd3d-shader-1"); @@ -382,7 +380,7 @@ impl DllProviderResolver { let dll_filename = format!("{}.dll", dll_name); // Match DLL to component and look for it in runner root - let is_dxvk = matches!(dll_name, "d3d8" | "d3d9" | "d3d10" | "d3d10_1" | "d3d10core" | "d3d11" | "dxgi"); + let is_dxvk = matches!(dll_name, "d3d8" | "d3d9" | "d3d10core" | "d3d11" | "dxgi"); if is_dxvk && components.dxvk.is_some() { let mut relative_paths = vec![ "lib/wine/dxvk/x86_64-windows", diff --git a/src/launch/mod.rs b/src/launch/mod.rs index f3aec54..1c0d8f7 100644 --- a/src/launch/mod.rs +++ b/src/launch/mod.rs @@ -13,7 +13,7 @@ use crate::utils::build_runner_command; pub async fn install_master_steam(config: &LauncherConfig) -> Result<()> { let base_dir = config_dir()?; - let master_prefix = base_dir.join("master_steam_prefix"); + let steam_cfg = crate::utils::get_master_steam_config(); let runtimes_dir = base_dir.join("runtimes"); std::fs::create_dir_all(&runtimes_dir)?; @@ -31,14 +31,15 @@ pub async fn install_master_steam(config: &LauncherConfig) -> Result<()> { let resolved_runner = crate::utils::resolve_runner(&runner_name, &library_root); let mut cmd = build_runner_command(&resolved_runner)?; - // Check if steam.exe exists in the prefix - let steam_exe_path = find_steam_exe_in_prefix(&master_prefix); - - if let Some(exe_path) = steam_exe_path { - // Run existing steam.exe - cmd.arg(exe_path); + tracing::info!("Unified Master Steam resolution:"); + tracing::info!(" - Root Dir: {}", steam_cfg.root_dir.display()); + tracing::info!(" - Wine Prefix: {}", steam_cfg.wine_prefix.display()); + tracing::info!(" - Layout Kind: {}", steam_cfg.layout_kind); + if let Some(ref exe) = steam_cfg.steam_exe { + tracing::info!(" - Steam Exe: {}", exe.display()); + cmd.arg(exe); } else { - // Run installer + tracing::info!(" - Steam Exe: NOT FOUND (running installer)"); cmd.arg(setup_exe); } @@ -47,8 +48,8 @@ pub async fn install_master_steam(config: &LauncherConfig) -> Result<()> { cmd.arg("-cef-disable-gpu-compositing"); // Environment Variables - cmd.env("WINEPREFIX", master_prefix.join("pfx")); - cmd.env("STEAM_COMPAT_DATA_PATH", &master_prefix); + cmd.env("WINEPREFIX", &steam_cfg.wine_prefix); + cmd.env("STEAM_COMPAT_DATA_PATH", &steam_cfg.root_dir); cmd.env("WINEPATH", "C:\\Program Files (x86)\\Steam"); let fake_env = crate::utils::setup_fake_steam_trap(&base_dir)?; @@ -87,20 +88,3 @@ async fn download_steam_setup(path: &Path) -> Result<()> { Ok(()) } -fn find_steam_exe_in_prefix(prefix: &Path) -> Option { - let common_paths = [ - "pfx/drive_c/Program Files (x86)/Steam/steam.exe", - "pfx/drive_c/Program Files/Steam/steam.exe", - "drive_c/Program Files (x86)/Steam/steam.exe", - "drive_c/Program Files/Steam/steam.exe", - ]; - - for rel_path in common_paths { - let full_path = prefix.join(rel_path); - if full_path.exists() { - return Some(full_path); - } - } - - None -} diff --git a/src/launch/pipeline.rs b/src/launch/pipeline.rs index f59cbdd..023966d 100644 --- a/src/launch/pipeline.rs +++ b/src/launch/pipeline.rs @@ -383,6 +383,21 @@ impl LaunchPipeline { fn classify_early_exit(&self, ctx: &mut PipelineContext) { if ctx.verification.status != "failed_after_spawn" { + if ctx.verification.steam_runtime_milestone == "steam_process_exited_early" || ctx.verification.steam_auto_start_failed { + ctx.verification.detailed_status = Some("steam_runtime_startup_failed".to_string()); + return; + } + + if ctx.command_spec.is_none() { + ctx.verification.detailed_status = Some("command_spec_missing".to_string()); + return; + } + + if !ctx.executable_exists && ctx.resolved_executable_path.is_some() { + ctx.verification.detailed_status = Some("game_executable_not_found".to_string()); + return; + } + return; } @@ -391,8 +406,36 @@ impl LaunchPipeline { let mut fatal_error = None; for evidence in &ctx.graphics_stack.graphics_stack_evidence { + if evidence.contains("Override Policy Conflict") { + fatal_error = Some("override_policy_regression"); + break; + } + if evidence.contains("Steam Client/Environment Failure") { + fatal_error = Some("steam_runtime_exposure_regression"); + break; + } + if evidence.contains("PhysX/Middleware failure") { + fatal_error = Some("middleware_dependency_failure"); + break; + } + if evidence.contains("SteamAPI Initialization Failed") || evidence.contains("SteamAPI Access Violation") { + fatal_error = Some("steamapi_init_failed"); + break; + } + if evidence.contains("Steam Ownership Validation Failed") { + fatal_error = Some("steam_ownership_or_session_failed"); + break; + } + if evidence.contains("SteamAPI Connection Failed") { + fatal_error = Some("steam_not_found_by_game"); + break; + } if evidence.contains("DLL Load Failure") { - fatal_error = Some("missing_required_module"); + if evidence.contains("d3d11") { + fatal_error = Some("startup_environment_regression"); + } else { + fatal_error = Some("missing_required_module"); + } break; } if evidence.contains("DLL Dependency Missing") { @@ -402,13 +445,15 @@ impl LaunchPipeline { } ctx.verification.detailed_status = Some(if dxvk_found || vkd3d_found { - "dxvk_loaded_but_game_exited_early".to_string() + "game_failed_after_spawn".to_string() } else if let Some(err) = fatal_error { err.to_string() + } else if ctx.verification.steam_running_before_launch && ctx.verification.steam_client_exposed { + "steam_sensitive_game_failed_after_spawn".to_string() } else if !ctx.verification.log_growth_observed { - "graphics_backend_not_confirmed".to_string() + "game_failed_after_spawn".to_string() } else { - "unknown_early_exit".to_string() + "game_failed_after_spawn".to_string() }); } @@ -756,6 +801,14 @@ impl LaunchPipeline { let lines: Vec<&str> = content.lines().collect(); ctx.graphics_stack.runtime_evidence.scan_metadata.line_count = lines.len(); + // Capture Log Head/Tail + ctx.verification.log_head = lines.iter().take(50).map(|s| s.to_string()).collect(); + if lines.len() > 50 { + ctx.verification.log_tail = lines.iter().rev().take(100).rev().map(|s| s.to_string()).collect(); + } + + let mut max_milestone = crate::infra::logging::StartupMilestone::None; + // Derive component paths from dll resolutions let mut component_paths = HashMap::new(); for res in &ctx.dll_resolutions { @@ -775,11 +828,47 @@ impl LaunchPipeline { for line in &lines { let mut line_matched = false; + let line_lower = line.to_lowercase(); + + // Detect Dependency Families from log lines + let families_to_scan = [ + ("Batman (GFSDK/APEX)", vec!["gfsdk", "apex", "nvtt"]), + ("Metro (4A Engine)", vec!["4a engine", "metro"]), + ("PhysX Runtime", vec!["physx"]), + ]; + + for (family, patterns) in families_to_scan { + if patterns.iter().any(|p| line_lower.contains(p)) { + if !ctx.verification.dependency_families_detected.contains(&family.to_string()) { + ctx.verification.dependency_families_detected.push(family.to_string()); + } + } + } + + // Update Milestone + if let Some(m) = crate::infra::logging::detect_startup_milestone(line) { + if m > max_milestone { + max_milestone = m; + } + } + if let Some(evidence) = crate::infra::logging::classify_graphics_evidence(line) { if !ctx.graphics_stack.graphics_stack_evidence.contains(&evidence) { ctx.graphics_stack.graphics_stack_evidence.push(evidence.clone()); } + if evidence.contains("SteamAPI Initialization Failed") { + ctx.verification.steam_api_initialized = Some(false); + } + if evidence.contains("Steam Ownership Validation Failed") { + ctx.verification.steam_ownership_confirmed = Some(false); + } + if evidence.contains("Steam Client Artifact:") { + if evidence.contains("local") { ctx.verification.steam_client_artifact = Some("local".into()); } + else if evidence.contains("windows") { ctx.verification.steam_client_artifact = Some("windows".into()); } + else if evidence.contains("host") { ctx.verification.steam_client_artifact = Some("host".into()); } + } + if evidence.contains("DXVK") { ctx.graphics_stack.runtime_evidence.dxvk.evidence_found = true; if ctx.graphics_stack.runtime_evidence.dxvk.evidence.len() < 5 { @@ -868,6 +957,8 @@ impl LaunchPipeline { ctx.graphics_stack.runtime_evidence.scan_metadata.candidate_matches += 1; } } + + ctx.verification.last_successful_startup_milestone = max_milestone.to_string(); } } else { ctx.graphics_stack.runtime_evidence.scan_metadata.file_exists = false; @@ -964,17 +1055,73 @@ impl LaunchPipeline { let mut metadata = HashMap::new(); metadata.insert("prefix_path".to_string(), prefix.clone()); + // Detect Windows username + let users_dir = prefix_path.join("drive_c/users"); + if users_dir.exists() { + if let Ok(entries) = std::fs::read_dir(&users_dir) { + let mut usernames = Vec::new(); + for entry in entries.flatten() { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + let name = entry.file_name().to_string_lossy().to_string(); + if name != "Public" && name != "All Users" && name != "Default User" { + usernames.push(name); + } + } + } + if !usernames.is_empty() { + // Sort and pick first (most likely primary) + usernames.sort(); + let primary = usernames[0].clone(); + ctx.verification.windows_username = Some(primary.clone()); + ctx.verification.windows_user_path = Some(format!("C:\\users\\{}", primary)); + metadata.insert("detected_windows_username".to_string(), primary); + } + } + } + + let username = ctx.verification.windows_username.as_deref().unwrap_or("steamuser"); let common_dirs = [ - "drive_c/users/steamuser/Documents", - "drive_c/users/steamuser/AppData/Local", - "drive_c/users/steamuser/AppData/Roaming", + format!("drive_c/users/{}/Documents", username), + format!("drive_c/users/{}/AppData/Local", username), + format!("drive_c/users/{}/AppData/Roaming", username), + "drive_c/Program Files (x86)/Steam/steam.exe".to_string(), + "drive_c/Program Files (x86)/Steam/steamclient.dll".to_string(), + "drive_c/Program Files (x86)/Steam/tier0_s.dll".to_string(), + "drive_c/windows/system32/PhysXLoader.dll".to_string(), + "drive_c/windows/syswow64/PhysXLoader.dll".to_string(), + "drive_c/windows/system32/steam_api.dll".to_string(), + "drive_c/windows/syswow64/steam_api.dll".to_string(), + "drive_c/windows/system32/steam_api64.dll".to_string(), ]; for dir in common_dirs { - let full_path = prefix_path.join(dir); - metadata.insert(format!("dir_exists:{}", dir), full_path.exists().to_string()); + let full_path = prefix_path.join(&dir); + let exists = full_path.exists(); + metadata.insert(format!("path_exists:{}", dir), exists.to_string()); + ctx.verification.key_paths_detected.insert(dir, exists); } + // Title-Specific Dependency Detection + let mut families = ctx.verification.dependency_families_detected.clone(); + let families_to_check = [ + ("Batman (PhysX/APEX)", vec!["drive_c/windows/system32/PhysXLoader.dll", "drive_c/windows/syswow64/PhysXLoader.dll"]), + ("Amnesia (SDL2/Newton)", vec!["drive_c/windows/system32/SDL2.dll", "drive_c/windows/syswow64/SDL2.dll"]), + ("Metro (4A Engine)", vec!["drive_c/windows/system32/PhysXDevice.dll", "drive_c/windows/syswow64/PhysXDevice.dll"]), + ]; + + for (family, paths) in families_to_check { + if paths.iter().any(|p| prefix_path.join(p).exists()) { + if !families.contains(&family.to_string()) { + families.push(family.to_string()); + } + } + } + ctx.verification.dependency_families_detected = families; + + // Check steam client exposure + ctx.verification.steam_client_exposed = spec.env.contains_key("STEAM_COMPAT_CLIENT_INSTALL_PATH") || + spec.env.get("WINEPATH").map(|wp| wp.contains("Steam")).unwrap_or(false); + if let Some(logger) = &ctx.logger { let _ = logger.info("prefix_health_check", "WINEPREFIX sanity check complete".to_string(), None, metadata); } @@ -1148,6 +1295,30 @@ impl LaunchPipeline { metadata.insert("verification_detailed".to_string(), detailed.clone()); } + if let Some(ref username) = ctx.verification.windows_username { + metadata.insert("windows_user".to_string(), username.clone()); + } + metadata.insert("steam_client_exposed".to_string(), ctx.verification.steam_client_exposed.to_string()); + metadata.insert("last_milestone".to_string(), ctx.verification.last_successful_startup_milestone.clone()); + if !ctx.verification.dependency_families_detected.is_empty() { + metadata.insert("dependency_families".to_string(), ctx.verification.dependency_families_detected.join(", ")); + } + if !ctx.verification.steam_runtime_milestone.is_empty() { + metadata.insert("steam_runtime_milestone".to_string(), ctx.verification.steam_runtime_milestone.clone()); + } + if let Some(init) = ctx.verification.steam_api_initialized { + metadata.insert("steam_api_initialized".to_string(), init.to_string()); + } + if let Some(own) = ctx.verification.steam_ownership_confirmed { + metadata.insert("steam_ownership_confirmed".to_string(), own.to_string()); + } + if let Some(ref art) = ctx.verification.steam_client_artifact { + metadata.insert("steam_client_artifact".to_string(), art.clone()); + } + metadata.insert("steam_running_before_launch".to_string(), ctx.verification.steam_running_before_launch.to_string()); + metadata.insert("steam_auto_start_attempted".to_string(), ctx.verification.steam_auto_start_attempted.to_string()); + metadata.insert("steam_auto_start_failed".to_string(), ctx.verification.steam_auto_start_failed.to_string()); + let _ = logger.info("launch_summary_concise", "Concise launch summary recorded".to_string(), None, metadata); } } diff --git a/src/launch/stages/build_command.rs b/src/launch/stages/build_command.rs index 361dd0b..e59cd7e 100644 --- a/src/launch/stages/build_command.rs +++ b/src/launch/stages/build_command.rs @@ -21,6 +21,7 @@ impl PipelineStage for BuildCommandStage { proton_path: ctx.proton_path.clone(), target_architecture: ctx.target_architecture, dll_resolutions: ctx.dll_resolutions.clone(), + verification_ptr: &mut ctx.verification as *mut _, }; let spec = runner.build_command(&runner_ctx).await?; ctx.command_spec = Some(spec); diff --git a/src/launch/stages/prepare_prefix.rs b/src/launch/stages/prepare_prefix.rs index 2dcf4c0..63e161e 100644 --- a/src/launch/stages/prepare_prefix.rs +++ b/src/launch/stages/prepare_prefix.rs @@ -24,6 +24,7 @@ impl PipelineStage for PreparePrefixStage { proton_path: ctx.proton_path.clone(), target_architecture: ctx.target_architecture, dll_resolutions: ctx.dll_resolutions.clone(), + verification_ptr: &mut ctx.verification as *mut _, }; runner.prepare_prefix(&runner_ctx).await?; diff --git a/src/launch/stages/resolve_components.rs b/src/launch/stages/resolve_components.rs index 7793dfa..3660465 100644 --- a/src/launch/stages/resolve_components.rs +++ b/src/launch/stages/resolve_components.rs @@ -1,8 +1,52 @@ use async_trait::async_trait; use crate::launch::pipeline::{PipelineStage, PipelineContext, LaunchError, LaunchErrorKind}; +use std::collections::HashMap; +use std::path::PathBuf; +use crate::infra::runners::{Runner, LaunchContext, CommandSpec}; + pub struct ResolveComponentsStage; +pub struct NativeRunner; + +#[async_trait::async_trait] +impl Runner for NativeRunner { + fn name(&self) -> &str { "Native" } + async fn prepare_prefix(&self, _ctx: &LaunchContext) -> std::result::Result<(), LaunchError> { Ok(()) } + async fn build_env(&self, ctx: &LaunchContext) -> std::result::Result, LaunchError> { + let mut env = HashMap::new(); + env.insert("SteamAppId".to_string(), ctx.app.app_id.to_string()); + if let Some(config) = &ctx.user_config { + for (k, v) in &config.env_variables { + env.insert(k.clone(), v.clone()); + } + } + Ok(env) + } + async fn build_command(&self, ctx: &LaunchContext) -> std::result::Result { + let install_path = ctx.app.install_path.as_ref() + .ok_or_else(|| LaunchError::new(LaunchErrorKind::GameData, "Install path missing"))?; + + let mut spec = CommandSpec::default(); + let exe_rel = ctx.launch_info.executable.replace('\\', "/"); + let executable = PathBuf::from(install_path).join(&exe_rel); + + spec.program = executable; + spec.args = ctx.launch_info.arguments.split_whitespace().map(|s| s.to_string()).collect(); + spec.cwd = Some(PathBuf::from(install_path)); + spec.env = self.build_env(ctx).await?; + + Ok(spec) + } + fn launch(&self, spec: &CommandSpec) -> std::result::Result { + let mut cmd = std::process::Command::new(&spec.program); + cmd.args(&spec.args); + if let Some(cwd) = &spec.cwd { cmd.current_dir(cwd); } + cmd.envs(&spec.env); + cmd.spawn().map_err(|e| LaunchError::new(LaunchErrorKind::Process, "Native launch failed").with_source(anyhow::anyhow!(e))) + } +} + #[async_trait] impl PipelineStage for ResolveComponentsStage { fn name(&self) -> &str { "ResolveComponents" } @@ -14,7 +58,7 @@ impl PipelineStage for ResolveComponentsStage { if let Some(info) = &ctx.launch_info { match info.target { LaunchTarget::NativeLinux => { - // Native runner not yet implemented in infra/runners + ctx.runner = Some(Box::new(NativeRunner)); } LaunchTarget::WindowsProton => { ctx.runner = Some(Box::new(WineTkgRunner)); diff --git a/src/launch/stages/resolve_dll_providers.rs b/src/launch/stages/resolve_dll_providers.rs index 2568f62..4ad5fde 100644 --- a/src/launch/stages/resolve_dll_providers.rs +++ b/src/launch/stages/resolve_dll_providers.rs @@ -106,29 +106,6 @@ impl PipelineStage for ResolveDllProvidersStage { ctx.dll_resolutions = resolutions; - // DXVK Capability Fixup for D3D10/10.1 - if let Some(config) = &ctx.user_config { - if config.graphics_layers.graphics_backend_policy == crate::models::GraphicsBackendPolicy::DXVK { - let has_dxvk_core = |name: &str| -> bool { - ctx.dll_resolutions.iter() - .find(|r| r.name == name) - .map(|r| r.chosen_provider != crate::launch::dll_provider_resolver::DllProvider::None) - .unwrap_or(false) - }; - - let d3d10_supported = has_dxvk_core("d3d10core") && has_dxvk_core("d3d11") && has_dxvk_core("dxgi"); - - if d3d10_supported { - for res in &mut ctx.dll_resolutions { - if (res.name == "d3d10" || res.name == "d3d10_1") && res.chosen_provider == crate::launch::dll_provider_resolver::DllProvider::None { - res.chosen_provider = crate::launch::dll_provider_resolver::DllProvider::Internal; - res.fallback_reason = Some("Satisfied via DXVK D3D10 capability (d3d10core + d3d11 + dxgi)".to_string()); - } - } - } - } - } - // Strict Backend Policy Enforcement if let Some(config) = &ctx.user_config { let backend_policy = &config.graphics_layers.graphics_backend_policy; @@ -155,12 +132,10 @@ impl PipelineStage for ResolveDllProvidersStage { if !has_dx11_dxgi { missing_capabilities.push("DX11/DXGI (requires d3d11.dll and dxgi.dll)"); } - if !has_dx10_core || !has_dx11_dxgi { - if !has_dx10_core { - missing_capabilities.push("DX10/10.1 capability incomplete: missing d3d10core.dll"); - } else { - missing_capabilities.push("DX10/10.1 support unavailable because d3d11.dll or dxgi.dll could not be resolved"); - } + if !has_dx10_core { + missing_capabilities.push("DX10/10.1 capability incomplete: missing d3d10core.dll"); + } else if !has_dx11_dxgi { + missing_capabilities.push("DX10/10.1 support unavailable because d3d11.dll or dxgi.dll could not be resolved"); } if !has_dx9 { missing_capabilities.push("DX9 (requires d3d9.dll)"); diff --git a/src/launch/validators/invariants.rs b/src/launch/validators/invariants.rs index 03626fa..a72d0f5 100644 --- a/src/launch/validators/invariants.rs +++ b/src/launch/validators/invariants.rs @@ -33,7 +33,7 @@ impl LaunchValidator for LaunchInvariantValidator { if ctx.graphics_stack.effective_backend != "DXVK" { if let Some(spec) = &ctx.command_spec { if let Some(overrides) = spec.env.get("WINEDLLOVERRIDES") { - let dxvk_dlls = ["d3d8", "d3d9", "d3d10", "d3d10core", "d3d11", "dxgi"]; + let dxvk_dlls = ["d3d8", "d3d9", "d3d10core", "d3d11", "dxgi"]; for part in overrides.split(';') { if let Some((dll, mode)) = part.split_once('=') { let dll_trimmed = dll.trim().to_lowercase(); @@ -250,6 +250,7 @@ impl LaunchValidator for LaunchInvariantValidator { )); } + for (code, msg) in warnings { ctx.add_warning(code, msg); } diff --git a/src/launch/validators/overrides.rs b/src/launch/validators/overrides.rs index 8e2fe9f..03e7a3c 100644 --- a/src/launch/validators/overrides.rs +++ b/src/launch/validators/overrides.rs @@ -16,7 +16,7 @@ impl LaunchValidator for OverrideConflictValidator { // 1. Check for D3D/DXGI override conflicts with DXVK if dxvk_enabled { - let conflicts = ["d3d9", "d3d10", "d3d10core", "d3d11", "dxgi"]; + let conflicts = ["d3d9", "d3d10core", "d3d11", "dxgi"]; if let Some(val) = user_config.env_variables.get("WINEDLLOVERRIDES") { for part in val.split(';') { if let Some((dll, mode)) = part.split_once('=') { diff --git a/src/utils.rs b/src/utils.rs index 7e1cc35..8bb4e25 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -737,8 +737,6 @@ pub fn build_dll_overrides( for dll in &[ "d3d8", "d3d9", - "d3d10", - "d3d10_1", "d3d10core", "d3d11", "dxgi", @@ -761,17 +759,12 @@ pub fn build_dll_overrides( for dll in &[ "d3d8.dll", "d3d9.dll", - "d3d10.dll", - "d3d10_1.dll", "d3d10core.dll", "d3d11.dll", "dxgi.dll", ] { let stem = dll.trim_end_matches(".dll"); - // Special case: d3d10 and d3d10_1 should always allow builtin fallback (n,b) - // even in strict DXVK mode, because DXVK typically doesn't provide them - // standalone; it uses d3d10core instead. - let mode = if strict_dxvk && stem != "d3d10" && stem != "d3d10_1" { "n" } else { "n,b" }; + let mode = if strict_dxvk { "n" } else { "n,b" }; if strict_dxvk || !game_has(dll) { overrides.push(format!("{stem}={mode}")); @@ -793,25 +786,59 @@ pub fn build_dll_overrides( overrides.join(";") } -/// Detects the actual WINEPREFIX layout for the master Steam install. -/// Handles both master_steam_prefix/pfx/drive_c and master_steam_prefix/drive_c layouts. -pub fn resolve_master_wineprefix() -> PathBuf { - let base = crate::config::config_dir() +#[derive(Debug, Clone)] +pub struct MasterSteamConfig { + pub root_dir: PathBuf, // e.g. ~/.config/SteamFlow/master_steam_prefix + pub wine_prefix: PathBuf, // e.g. root_dir or root_dir/pfx + pub layout_kind: String, // "root" or "pfx" + pub steam_exe: Option, +} + +pub fn get_master_steam_config() -> MasterSteamConfig { + let root_dir = crate::config::config_dir() .unwrap_or_default() .join("master_steam_prefix"); - // Check direct layout first (drive_c directly under base) — this wins - // if Steam was installed with WINEPREFIX=master_steam_prefix (no /pfx). - // Only fall back to /pfx if drive_c genuinely lives there. - if base.join("drive_c").exists() { - return base; + // Layout detection: prefer /pfx if it exists, otherwise check root for drive_c + let (wine_prefix, layout_kind) = if root_dir.join("pfx/drive_c").exists() { + (root_dir.join("pfx"), "pfx".to_string()) + } else if root_dir.join("drive_c").exists() { + (root_dir.clone(), "root".to_string()) + } else { + // Default for new installs + (root_dir.join("pfx"), "pfx".to_string()) + }; + + let steam_exe = find_steam_exe_in_prefix(&wine_prefix); + + MasterSteamConfig { + root_dir, + wine_prefix, + layout_kind, + steam_exe, } - if base.join("pfx/drive_c").exists() { - return base.join("pfx"); +} + +pub fn find_steam_exe_in_prefix(prefix: &Path) -> Option { + let candidates = [ + "drive_c/Program Files (x86)/Steam/steam.exe", + "drive_c/Program Files/Steam/steam.exe", + ]; + + for rel_path in candidates { + let full_path = prefix.join(rel_path); + if full_path.exists() { + return Some(full_path); + } } - // Fresh install default — Proton-style nesting - base.join("pfx") + None +} + +/// Detects the actual WINEPREFIX layout for the master Steam install. +/// Handles both master_steam_prefix/pfx/drive_c and master_steam_prefix/drive_c layouts. +pub fn resolve_master_wineprefix() -> PathBuf { + get_master_steam_config().wine_prefix } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1099,7 +1126,7 @@ fn find_sibling_dll( pub fn cleanup_dll_symlinks(prefix: &Path) -> Result<()> { let target_dlls = [ - "d3d8.dll", "d3d9.dll", "dxgi.dll", "d3d10.dll", "d3d10_1.dll", "d3d10core.dll", + "d3d8.dll", "d3d9.dll", "dxgi.dll", "d3d10core.dll", "d3d11.dll", "d3d12.dll", "d3d12core.dll", "libvkd3d-1.dll", "libvkd3d-shader-1.dll" ];