From 7217f8fc1422d9788608822067fb3acb2d2dd910 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Mon, 10 Aug 2026 04:43:51 -0600 Subject: [PATCH 1/9] feat: add MetalSharp Wine EAC Linux substrate --- CMakeLists.txt | 49 + app/src-rust/src/anticheat.rs | 1378 ++++++ app/src-rust/src/installer.rs | 140 +- app/src-rust/src/main.rs | 25 + docs/roadmaps/anticheat-hard-route-roadmap.md | 100 +- src/anticheat/linux_substrate.c | 3896 +++++++++++++++++ tools/anticheat/generate_linux_libc_elf.py | 230 + tools/anticheat/patch_wine_kernel32_path.py | 262 ++ tools/anticheat/patch_wine_private_export.py | 124 + tools/anticheat/probe_wine_private_export.c | 19 + tools/anticheat/run_eac_proof.py | 426 ++ tools/bundles/create-split-bundles.py | 8 +- tools/bundles/verify-native-shims.sh | 20 +- 13 files changed, 6673 insertions(+), 4 deletions(-) create mode 100644 app/src-rust/src/anticheat.rs create mode 100644 src/anticheat/linux_substrate.c create mode 100644 tools/anticheat/generate_linux_libc_elf.py create mode 100644 tools/anticheat/patch_wine_kernel32_path.py create mode 100644 tools/anticheat/patch_wine_private_export.py create mode 100644 tools/anticheat/probe_wine_private_export.c create mode 100755 tools/anticheat/run_eac_proof.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 3ccfd7a96..57a1f2765 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -296,6 +296,55 @@ add_custom_command(TARGET metalsharp_native POST_BUILD COMMENT "Copying $ to app/native/" ) +# Linux user-space compatibility substrate for the protected EAC launcher. +# This is built for the same x86_64/Rosetta lane as Wine 11.5 and is inserted +# into that already-built Wine process only by the explicit EAC proof/launch +# path. It is not a second Wine runtime, a graphics backend, or a vendor +# module shim: the source implements the Linux ELF/syscall/TSD boundary on +# Darwin and maps the real downloaded EAC module without modifying it. +add_library(metalsharp_eac_substrate SHARED + src/anticheat/linux_substrate.c +) +target_include_directories(metalsharp_eac_substrate PRIVATE include) +target_compile_options(metalsharp_eac_substrate PRIVATE -Wno-deprecated-declarations) +target_link_options(metalsharp_eac_substrate PRIVATE "-Wl,-undefined,dynamic_lookup") +set_target_properties(metalsharp_eac_substrate PROPERTIES + OUTPUT_NAME "metalsharp_eac_substrate" + PREFIX "" +) +metalsharp_wine_target(metalsharp_eac_substrate) + +# The Linux loader reads a real ET_DYN symbol image from its procfs view; it +# never asks dyld to load Linux libc. Generate that MetalSharp-owned image +# beside the substrate so packaged/runtime proofs do not depend on a machine's +# /tmp state. It contains no vendor or protected-module bytes. +find_package(Python3 COMPONENTS Interpreter QUIET) +if(Python3_Interpreter_FOUND) + set(METALSHARP_EAC_SYMBOL_IMAGE + "${CMAKE_SOURCE_DIR}/app/native/metalsharp_eac_libc.so.6") + add_custom_command( + OUTPUT "${METALSHARP_EAC_SYMBOL_IMAGE}" + COMMAND ${Python3_EXECUTABLE} + "${CMAKE_SOURCE_DIR}/tools/anticheat/generate_linux_libc_elf.py" + "${METALSHARP_EAC_SYMBOL_IMAGE}" + DEPENDS "${CMAKE_SOURCE_DIR}/tools/anticheat/generate_linux_libc_elf.py" + COMMENT "Generating MetalSharp EAC Linux symbol image" + VERBATIM + ) + add_custom_target(metalsharp_eac_symbol_image + DEPENDS "${METALSHARP_EAC_SYMBOL_IMAGE}" + ) + add_dependencies(metalsharp_eac_substrate metalsharp_eac_symbol_image) +else() + message(WARNING "MetalSharp EAC substrate symbol image requires Python 3") +endif() +add_custom_command(TARGET metalsharp_eac_substrate POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" + "${CMAKE_SOURCE_DIR}/app/native/$" + COMMENT "Copying metalsharp_eac_substrate.dylib to app/native/" +) + # OpenGL 2.1-4.6 bridge. Delegates GL 2.1 to macOS native OpenGL framework. # GL 3.x+ shader translation via SPIRV-Cross is scaffolded for Phase 4c. add_library(metalsharp_opengl32 SHARED diff --git a/app/src-rust/src/anticheat.rs b/app/src-rust/src/anticheat.rs new file mode 100644 index 000000000..dbbacac7a --- /dev/null +++ b/app/src-rust/src/anticheat.rs @@ -0,0 +1,1378 @@ +use serde_json::{json, Map, Value}; +use std::ffi::CString; +use std::fs::{self, File}; +use std::io::{Read, Seek, SeekFrom, Write}; +#[cfg(unix)] +use std::os::fd::FromRawFd; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; +use walkdir::WalkDir; + +const ARTIFACT_TAIL_LINES: usize = 80; +const MAX_ARTIFACT_READ_BYTES: u64 = 1024 * 1024; +const WALK_MAX_DEPTH: usize = 10; +const MODULE_ASSET_MAX_DEPTH: usize = 8; + +#[derive(Debug, Default)] +struct EacSummary { + settings_path: Option, + process_title: Option, + executable_path: Option, + product_id: Option, + sandbox_id: Option, + deployment_id: Option, + system_name: Option, + module_url: Option, + module_target: Option, + connect_response_code: Option, + downloaded_bytes: Option, + wine_version: Option, + module_mapping_status: Option, + launcher_load_claim: bool, + launcher_exit_code: Option, + launcher_error: Option, + setup_exit_code: Option, +} + +#[derive(Debug, Default)] +struct SteamSummary { + protected_launcher_path: Option, + tracked_pid: Option, + tracked_exit_code: Option, + redist_exit_codes: Vec, +} + +pub fn handle_steam_anticheat_evidence(body: &Map) -> Value { + let appid = match body.get("appid").and_then(|v| v.as_u64()) { + Some(id) if id > 0 && id <= u32::MAX as u64 => id as u32, + _ => return json!({"ok": false, "error": "appid required"}), + }; + + let home = match dirs::home_dir() { + Some(home) => home, + None => return json!({"ok": false, "appid": appid, "error": "no home dir"}), + }; + + let prefix = anticheat_prefix(&home); + let game_dir = crate::setup::resolve_game_dir(appid); + let artifacts = collect_artifacts(&prefix, game_dir.as_deref()); + let cached_module_assets = collect_cached_module_assets(&prefix); + let eac = summarize_eac(&artifacts); + let steam = summarize_steam(appid, &artifacts); + let status = evidence_status(&eac, &steam, &artifacts); + + json!({ + "ok": true, + "appid": appid, + "status": status, + "summary": summary_text(&status, &eac, &steam), + "prefix": prefix.to_string_lossy(), + "gameDir": game_dir.map(|p| p.to_string_lossy().to_string()), + "easyAntiCheat": { + "settingsPath": eac.settings_path, + "processTitle": eac.process_title, + "executablePath": eac.executable_path, + "productId": eac.product_id, + "sandboxId": eac.sandbox_id, + "deploymentId": eac.deployment_id, + "systemName": eac.system_name, + "moduleUrl": eac.module_url, + "moduleTarget": eac.module_target, + "connectResponseCode": eac.connect_response_code, + "downloadedBytes": eac.downloaded_bytes, + "wineVersion": eac.wine_version, + "moduleMappingStatus": eac.module_mapping_status, + "launcherLoadClaim": eac.launcher_load_claim, + "launcherExitCode": eac.launcher_exit_code, + "launcherError": eac.launcher_error, + "setupExitCode": eac.setup_exit_code, + }, + "steam": { + "protectedLauncherPath": steam.protected_launcher_path, + "trackedPid": steam.tracked_pid, + "trackedExitCode": steam.tracked_exit_code, + "redistExitCodes": steam.redist_exit_codes, + }, + "artifacts": artifacts, + "cachedModuleAssets": cached_module_assets, + "nextActions": next_actions(&status), + }) +} + +pub fn handle_steam_anticheat_probe(body: &Map) -> Value { + let appid = match body.get("appid").and_then(|v| v.as_u64()) { + Some(id) if id > 0 && id <= u32::MAX as u64 => id as u32, + _ => return json!({"ok": false, "error": "appid required"}), + }; + + let home = match dirs::home_dir() { + Some(home) => home, + None => return json!({"ok": false, "appid": appid, "error": "no home dir"}), + }; + + let prefix = anticheat_prefix(&home); + let game_dir = crate::setup::resolve_game_dir(appid); + let artifacts = collect_artifacts(&prefix, game_dir.as_deref()); + let cached_module_assets = collect_cached_module_assets(&prefix); + let eac = summarize_eac(&artifacts); + let steam = summarize_steam(appid, &artifacts); + let module_assets = game_dir.as_deref().map(collect_module_assets).unwrap_or_default(); + let runtime_checks = runtime_probe_checks(&home); + let status = probe_status(&eac, &module_assets); + let host_os = std::env::consts::OS; + let host_arch = std::env::consts::ARCH; + + json!({ + "ok": true, + "appid": appid, + "status": status, + "summary": probe_summary(&status, &eac), + "host": { + "os": host_os, + "arch": host_arch, + "canDlopenLinuxElfDirectly": can_dlopen_linux_elf_directly(host_os), + }, + "prefix": prefix.to_string_lossy(), + "gameDir": game_dir.map(|p| p.to_string_lossy().to_string()), + "evidenceStatus": evidence_status(&eac, &steam, &artifacts), + "easyAntiCheat": { + "moduleTarget": eac.module_target, + "moduleUrl": eac.module_url, + "systemName": eac.system_name, + "connectResponseCode": eac.connect_response_code, + "downloadedBytes": eac.downloaded_bytes, + "wineVersion": eac.wine_version, + "moduleMappingStatus": eac.module_mapping_status, + "launcherLoadClaim": eac.launcher_load_claim, + "launcherExitCode": eac.launcher_exit_code, + "launcherError": eac.launcher_error, + }, + "runtimeChecks": runtime_checks, + "moduleAssets": module_assets, + "cachedModuleAssets": cached_module_assets, + "contractChecks": module_contract_checks(host_os, &eac, &module_assets), + "nextActions": probe_next_actions(&status), + }) +} + +pub fn handle_steam_anticheat_delta_audit(body: &Map) -> Value { + let appid = + body.get("appid").and_then(|v| v.as_u64()).filter(|id| *id > 0 && *id <= u32::MAX as u64).map(|id| id as u32); + let home = match dirs::home_dir() { + Some(home) => home, + None => return json!({"ok": false, "error": "no home dir"}), + }; + + let metalsharp_home = crate::platform::metalsharp_home_dir_for(&home); + let prefix = anticheat_prefix(&home); + let wine_root = metalsharp_home.join("runtime").join("wine"); + let game_dir = appid.and_then(crate::setup::resolve_game_dir); + let artifacts = collect_artifacts(&prefix, game_dir.as_deref()); + let cached_module_assets = collect_cached_module_assets(&prefix); + let eac = summarize_eac(&artifacts); + let module_assets = game_dir.as_deref().map(collect_module_assets).unwrap_or_default(); + let host_os = std::env::consts::OS; + + let surfaces = vec![ + delta_group( + "wine_loader", + "Wine loader/syscall baseline", + vec![ + delta_path("wineserver", "required", &wine_root.join("bin").join("wineserver"), None), + delta_path("wine", "required", &wine_root.join("bin").join("wine"), None), + delta_path("ntdll_unix", "required", &wine_root.join("lib").join("wine").join("x86_64-unix").join("ntdll.so"), None), + delta_path("ntdll_win64", "required", &wine_root.join("lib").join("wine").join("x86_64-windows").join("ntdll.dll"), None), + delta_path("ntdll_win32", "wow64_required", &wine_root.join("lib").join("wine").join("i386-windows").join("ntdll.dll"), None), + delta_path( + "wine_preloader", + "proton_comparison", + &wine_root.join("bin").join("wine-preloader"), + Some("Absent is common in packaged macOS Wine; record it because Proton/Linux loader behavior often assumes Linux mapping semantics."), + ), + ], + ), + delta_group( + "steam_runtime_bridge", + "Steam client bridge and protected launch surface", + vec![ + delta_path( + "steamclient_dll", + "required", + &prefix.join("drive_c").join("Program Files (x86)").join("Steam").join("steamclient.dll"), + None, + ), + delta_path( + "steamclient64_dll", + "required", + &prefix.join("drive_c").join("Program Files (x86)").join("Steam").join("steamclient64.dll"), + None, + ), + delta_path( + "lsteamclient_bridge", + "proton_comparison", + &wine_root.join("lib").join("wine").join("x86_64-unix").join("lsteamclient.so"), + Some("Proton relies on a Linux Steam client bridge layer; MetalSharp needs an explicit equivalent story if protected launch depends on it."), + ), + ], + ), + delta_group( + "container_linux_runtime", + "Pressure-vessel, seccomp, and Linux namespace assumptions", + vec![ + delta_capability("host_is_linux", "proton_comparison", host_os == "linux", "Proton anti-cheat support targets Linux user space; macOS cannot provide seccomp/namespaces directly."), + delta_capability("pressure_vessel_available", "proton_comparison", false, "No pressure-vessel container is present in the MetalSharp macOS runtime."), + delta_capability("seccomp_available", "proton_comparison", host_os == "linux", "Darwin has different syscall filtering and process policy APIs."), + ], + ), + delta_group( + "graphics_runtime", + "Graphics translation assets adjacent to protected launch", + vec![ + delta_path("dxmt_win64_d3d12", "route_asset", &wine_root.join("lib").join("dxmt").join("x86_64-windows").join("d3d12.dll"), None), + delta_path("dxmt_winemetal_unix", "route_asset", &wine_root.join("lib").join("dxmt").join("x86_64-unix").join("winemetal.so"), None), + delta_path("dxvk_win32_d3d9", "route_asset", &wine_root.join("lib").join("dxvk").join("i386-windows").join("d3d9.dll"), None), + delta_path("moltenvk_unix", "route_asset", &wine_root.join("lib").join("wine").join("x86_64-unix").join("libMoltenVK.dylib"), None), + ], + ), + delta_group( + "anticheat_module_contract", + "Protected module target and host substrate decision", + vec![ + delta_capability( + "selected_linux_module", + "blocking_when_macos", + eac.module_target.as_deref().unwrap_or("").starts_with("linux"), + "EAC selected a Linux module target from the vendor CDN.", + ), + delta_capability( + "darwin_can_load_linux_elf_directly", + "blocking_when_false", + can_dlopen_linux_elf_directly(host_os), + "macOS dyld cannot directly load Linux ELF modules.", + ), + delta_capability( + "darwin_vendor_asset_found", + "possible_direct_path", + module_assets.iter().any(|asset| asset.get("format").and_then(|v| v.as_str()) == Some("mach_o")), + "A vendor-supported Mach-O/dylib anti-cheat module would be the direct macOS path.", + ), + ], + ), + ]; + + json!({ + "ok": true, + "appid": appid, + "status": delta_audit_status(&surfaces), + "summary": delta_audit_summary(&eac, host_os), + "host": { + "os": host_os, + "arch": std::env::consts::ARCH, + }, + "surfaces": surfaces, + "moduleAssets": module_assets, + "cachedModuleAssets": cached_module_assets, + "nextActions": vec![ + "Use this report as the Phase 3 checklist before changing Wine loader behavior.", + "Compare blocking and proton_comparison rows against Proton's EAC-enabled Wine tree.", + "Promote any required missing runtime bridge into a specific implementation task instead of a generic anti-cheat claim.", + ], + }) +} + +pub fn handle_steam_anticheat_substrate_decision(body: &Map) -> Value { + let appid = match body.get("appid").and_then(|v| v.as_u64()) { + Some(id) if id > 0 && id <= u32::MAX as u64 => id as u32, + _ => return json!({"ok": false, "error": "appid required"}), + }; + + let home = match dirs::home_dir() { + Some(home) => home, + None => return json!({"ok": false, "appid": appid, "error": "no home dir"}), + }; + + let prefix = anticheat_prefix(&home); + let game_dir = crate::setup::resolve_game_dir(appid); + let artifacts = collect_artifacts(&prefix, game_dir.as_deref()); + let cached_module_assets = collect_cached_module_assets(&prefix); + let eac = summarize_eac(&artifacts); + let steam = summarize_steam(appid, &artifacts); + let module_assets = game_dir.as_deref().map(collect_module_assets).unwrap_or_default(); + let host_os = std::env::consts::OS; + let decision = substrate_decision(host_os, &eac, &module_assets); + + json!({ + "ok": true, + "appid": appid, + "decision": decision, + "summary": substrate_decision_summary(&decision), + "host": { + "os": host_os, + "arch": std::env::consts::ARCH, + }, + "evidenceStatus": evidence_status(&eac, &steam, &artifacts), + "facts": { + "moduleTarget": eac.module_target, + "moduleMappingStatus": eac.module_mapping_status, + "systemName": eac.system_name, + "downloadedBytes": eac.downloaded_bytes, + "launcherLoadClaim": eac.launcher_load_claim, + "launcherExitCode": eac.launcher_exit_code, + "hasLinuxElfAsset": module_assets.iter().any(|asset| asset.get("format").and_then(|v| v.as_str()) == Some("elf")), + "hasDarwinDylibAsset": module_assets.iter().any(|asset| asset.get("format").and_then(|v| v.as_str()) == Some("mach_o")), + "canDlopenLinuxElfDirectly": can_dlopen_linux_elf_directly(host_os), + }, + "allowedPaths": allowed_substrate_paths(&decision), + "rejectedPaths": vec![ + "spoof anti-cheat host identity", + "hide MetalSharp or Wine from the protected launcher", + "fake kernel driver support", + "tamper with protected modules", + "claim online anti-cheat support before the protected module maps and launches with vendor-supported assets", + ], + "nextActions": substrate_next_actions(&decision), + }) +} + +/// Probe only synthetic host primitives and correlate them with the latest +/// protected-launch evidence. The synthetic ELF is never an EAC payload and +/// no protected module is opened, modified, or injected by this endpoint. +pub fn handle_steam_anticheat_contract_probe(body: &Map) -> Value { + let appid = + body.get("appid").and_then(|v| v.as_u64()).filter(|id| *id > 0 && *id <= u32::MAX as u64).map(|id| id as u32); + let home = match dirs::home_dir() { + Some(home) => home, + None => return json!({"ok": false, "error": "no home dir"}), + }; + + let prefix = anticheat_prefix(&home); + let game_dir = appid.and_then(crate::setup::resolve_game_dir); + let artifacts = collect_artifacts(&prefix, game_dir.as_deref()); + let cached_module_assets = collect_cached_module_assets(&prefix); + let eac = summarize_eac(&artifacts); + let steam = appid.map(|id| summarize_steam(id, &artifacts)).unwrap_or_default(); + let module_assets = game_dir.as_deref().map(collect_module_assets).unwrap_or_default(); + let host_os = std::env::consts::OS; + let host_contract = host_contract_probe(host_os); + let direct_elf_load = host_contract + .get("syntheticElfDirectLoad") + .and_then(|probe| probe.get("ok")) + .and_then(Value::as_bool) + .unwrap_or(false); + let selected_linux = eac.module_target.as_deref().unwrap_or("").starts_with("linux"); + let status = if selected_linux && eac.module_mapping_status.as_deref() == Some("failed") && !direct_elf_load { + "linux_elf_host_gap_confirmed" + } else if eac.module_mapping_status.as_deref() == Some("mapped") { + "protected_module_mapped" + } else if eac.module_mapping_status.as_deref() == Some("failed") { + "module_mapping_failed" + } else { + "contract_probe_inconclusive" + }; + + json!({ + "ok": true, + "appid": appid, + "status": status, + "summary": match status { + "linux_elf_host_gap_confirmed" => "Protected launch selected a Linux module, and the host dynamic loader rejected a synthetic ELF; this is a host-contract result, not an EAC success claim.", + "protected_module_mapped" => "The EAC log contains an explicit module-mapped marker; inspect the complete protected-launch evidence before claiming support.", + "module_mapping_failed" => "The protected launcher reported module mapping failure, but the synthetic host probe did not isolate a Linux ELF boundary.", + _ => "No complete protected-module mapping proof is present in the collected evidence.", + }, + "prefix": prefix.to_string_lossy(), + "gameDir": game_dir.as_ref().map(|path| path.to_string_lossy().to_string()), + "evidenceStatus": evidence_status(&eac, &steam, &artifacts), + "easyAntiCheat": { + "moduleTarget": eac.module_target, + "moduleUrl": eac.module_url, + "systemName": eac.system_name, + "connectResponseCode": eac.connect_response_code, + "downloadedBytes": eac.downloaded_bytes, + "wineVersion": eac.wine_version, + "moduleMappingStatus": eac.module_mapping_status, + "launcherLoadClaim": eac.launcher_load_claim, + "launcherExitCode": eac.launcher_exit_code, + "launcherError": eac.launcher_error, + }, + "hostContract": host_contract, + "moduleAssets": module_assets, + "cachedModuleAssets": cached_module_assets, + "contractChecks": module_contract_checks(host_os, &eac, &module_assets), + "proofBoundary": { + "protectedModuleOpened": false, + "protectedModuleModified": false, + "syntheticOnly": true, + }, + }) +} + +fn collect_artifacts(prefix: &Path, game_dir: Option<&Path>) -> Vec { + let mut candidates = Vec::new(); + let drive_c = prefix.join("drive_c"); + let steam_logs = drive_c.join("Program Files (x86)").join("Steam").join("logs"); + candidates.push(("steam_gameprocess", steam_logs.join("gameprocess_log.txt"))); + candidates.push(("steam_runprocess", steam_logs.join("runprocess_log.txt"))); + + let users_dir = drive_c.join("users"); + if users_dir.exists() { + for entry in WalkDir::new(&users_dir).max_depth(WALK_MAX_DEPTH).into_iter().filter_map(Result::ok) { + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + let name = path.file_name().and_then(|v| v.to_str()).unwrap_or("").to_ascii_lowercase(); + let path_lc = path.to_string_lossy().to_ascii_lowercase(); + if path_lc.contains("easyanticheat") && name.ends_with(".log") { + let id = if name == "service.log" { "eac_service" } else { "eac_launcher" }; + candidates.push((id, path.to_path_buf())); + } else if path_lc.contains("battleye") && name.ends_with(".log") { + candidates.push(("battleye_user_log", path.to_path_buf())); + } + } + } + + for common in [ + drive_c.join("Program Files (x86)").join("Common Files").join("BattlEye"), + drive_c.join("Program Files").join("Common Files").join("BattlEye"), + ] { + collect_named_logs(&common, "battleye_common_log", &mut candidates); + } + + if let Some(dir) = game_dir { + collect_named_logs(dir, "game_anticheat_log", &mut candidates); + } + + let mut seen = std::collections::HashSet::new(); + candidates + .into_iter() + .filter(|(_, path)| seen.insert(path.clone())) + .map(|(id, path)| artifact_json(id, &path)) + .collect() +} + +/// Resolve the prefix used by the read-only evidence surface. Production +/// calls use the normal Steam bottle. A caller that is validating a +/// disposable prefix may opt in through an absolute environment path; this +/// keeps the JSON API from accepting arbitrary filesystem paths in request +/// bodies while making isolated launch evidence reproducible. +fn anticheat_prefix(home: &Path) -> PathBuf { + if let Ok(raw) = std::env::var("METALSHARP_ANTICHEAT_PREFIX") { + let candidate = PathBuf::from(raw); + if candidate.is_absolute() && candidate.is_dir() { + return candidate; + } + } + crate::platform::metalsharp_home_dir_for(home).join("prefix-steam") +} + +/// Report cached vendor module containers by metadata only. EAC stores the +/// downloaded Linux module as an opaque `.eac` payload; the evidence surface +/// must prove that the download exists without exposing, decrypting, or +/// interpreting proprietary module contents. +fn collect_cached_module_assets(prefix: &Path) -> Vec { + let users = prefix.join("drive_c").join("users"); + if !users.is_dir() { + return Vec::new(); + } + + WalkDir::new(users) + .max_depth(WALK_MAX_DEPTH) + .into_iter() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_file()) + .filter_map(|entry| { + let path = entry.path(); + let name = path.file_name()?.to_string_lossy().to_ascii_lowercase(); + let path_lc = path.to_string_lossy().to_ascii_lowercase(); + if !name.ends_with(".eac") || !path_lc.contains("easyanticheat") { + return None; + } + let metadata = fs::metadata(path).ok()?; + Some(json!({ + "path": path.to_string_lossy(), + "bytes": metadata.len(), + "format": "opaque_vendor_module_container", + "contentInspected": false, + })) + }) + .collect() +} + +fn collect_named_logs(root: &Path, id: &'static str, candidates: &mut Vec<(&'static str, PathBuf)>) { + if !root.exists() { + return; + } + for entry in WalkDir::new(root).max_depth(WALK_MAX_DEPTH).into_iter().filter_map(Result::ok) { + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + let name = path.file_name().and_then(|v| v.to_str()).unwrap_or("").to_ascii_lowercase(); + let path_lc = path.to_string_lossy().to_ascii_lowercase(); + if name.ends_with(".log") && (path_lc.contains("easyanticheat") || path_lc.contains("battleye")) { + candidates.push((id, path.to_path_buf())); + } + } +} + +fn collect_module_assets(game_dir: &Path) -> Vec { + if !game_dir.exists() { + return Vec::new(); + } + + WalkDir::new(game_dir) + .max_depth(MODULE_ASSET_MAX_DEPTH) + .into_iter() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_file()) + .filter_map(|entry| { + let path = entry.path(); + let path_lc = path.to_string_lossy().to_ascii_lowercase(); + if !path_lc.contains("easyanticheat") && !path_lc.contains("battleye") && !path_lc.contains("beclient") { + return None; + } + let name = path.file_name().and_then(|v| v.to_str()).unwrap_or("").to_ascii_lowercase(); + let extension = path.extension().and_then(|v| v.to_str()).unwrap_or("").to_ascii_lowercase(); + let interesting = matches!(extension.as_str(), "dll" | "exe" | "so" | "dylib" | "sys") + || name.contains("beservice") + || name.contains("beclient") + || name.contains("easyanticheat"); + if !interesting { + return None; + } + + let metadata = fs::metadata(path).ok(); + Some(json!({ + "path": path.to_string_lossy(), + "bytes": metadata.as_ref().map(|m| m.len()), + "kind": classify_module_path(path), + "format": read_binary_format(path), + })) + }) + .collect() +} + +fn runtime_probe_checks(home: &Path) -> Value { + let wine_root = crate::platform::metalsharp_home_dir_for(home).join("runtime").join("wine"); + let wine_bin = wine_root.join("bin").join("wine"); + let wine64_bin = wine_root.join("bin").join("wine64"); + let wine_unix = wine_root.join("lib").join("wine").join("x86_64-unix"); + let dxmt_unix = wine_root.join("lib").join("dxmt").join("x86_64-unix"); + let substrate_name = "metalsharp_eac_substrate.dylib"; + let mut substrate_candidates = vec![PathBuf::from("app").join("native").join(substrate_name)]; + if let Some(resources) = crate::platform::app_resources_dir() { + substrate_candidates.push(resources.join("scripts").join("tools").join("native").join(substrate_name)); + } + + json!({ + "wineRoot": path_check(&wine_root), + "wineBinary": path_check(&wine_bin), + "wine64Binary": path_check(&wine64_bin), + "wineUnixLibDir": path_check(&wine_unix), + "dxmtUnixLibDir": path_check(&dxmt_unix), + "eacLinuxSubstrate": { + "name": substrate_name, + "candidates": substrate_candidates.iter().map(|path| path_check(path)).collect::>(), + "present": substrate_candidates.iter().any(|path| path.is_file()), + "launchPolicy": "explicit_probe_only", + }, + "expectedDyldBoundary": "macos_dylib", + }) +} + +fn host_contract_probe(host_os: &str) -> Value { + json!({ + "hostOs": host_os, + "hostArch": std::env::consts::ARCH, + "anonymousExecutableMapping": anonymous_executable_mapping_probe(), + "syntheticElfDirectLoad": synthetic_elf_direct_load_probe(), + "canDlopenLinuxElfDirectly": can_dlopen_linux_elf_directly(host_os), + "notes": [ + "Only synthetic temporary data is used by this probe.", + "It does not load, patch, inject, or inspect protected anti-cheat modules.", + ], + }) +} + +#[cfg(unix)] +fn anonymous_executable_mapping_probe() -> Value { + unsafe { + let len = 4096; + let ptr = libc::mmap( + std::ptr::null_mut(), + len, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_PRIVATE | libc::MAP_ANON, + -1, + 0, + ); + if ptr == libc::MAP_FAILED { + return json!({ + "ok": false, + "stage": "mmap_rw", + "errno": last_errno(), + "summary": "Anonymous read/write mmap failed on the host.", + }); + } + + std::ptr::write_bytes(ptr, 0x90, len); + let result = libc::mprotect(ptr, len, libc::PROT_READ | libc::PROT_EXEC); + let errno = (result != 0).then(last_errno); + let _ = libc::munmap(ptr, len); + + json!({ + "ok": result == 0, + "stage": "mprotect_rx", + "errno": errno, + "summary": if result == 0 { + "Anonymous memory can transition from writable to executable in this process." + } else { + "Anonymous memory could not transition from writable to executable in this process." + }, + }) + } +} + +#[cfg(not(unix))] +fn anonymous_executable_mapping_probe() -> Value { + json!({ + "ok": false, + "stage": "unsupported_host", + "summary": "Anonymous executable mapping is only implemented for Unix hosts.", + }) +} + +#[cfg(unix)] +fn synthetic_elf_direct_load_probe() -> Value { + let path = match write_secure_synthetic_elf() { + Ok(path) => path, + Err(error) => return json!({"ok": false, "stage": "write_synthetic_elf", "error": error}), + }; + let path_text = path.to_string_lossy().to_string(); + let c_path = match CString::new(path_text.as_bytes()) { + Ok(path) => path, + Err(error) => { + let _ = fs::remove_file(&path); + return json!({"ok": false, "stage": "prepare_dlopen_path", "error": error.to_string()}); + }, + }; + + unsafe { + libc::dlerror(); + let handle = libc::dlopen(c_path.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL); + let error = if handle.is_null() { + dlerror_string() + } else { + let _ = libc::dlclose(handle); + None + }; + let _ = fs::remove_file(&path); + json!({ + "ok": !handle.is_null(), + "path": path_text, + "format": "elf", + "stage": "dlopen_synthetic_elf", + "error": error, + "summary": if handle.is_null() { + "Host dynamic loader did not accept a synthetic Linux ELF shared object." + } else { + "Host dynamic loader accepted a synthetic Linux ELF shared object." + }, + }) + } +} + +#[cfg(not(unix))] +fn synthetic_elf_direct_load_probe() -> Value { + json!({ + "ok": false, + "format": "elf", + "stage": "unsupported_host", + "summary": "Synthetic ELF direct-load is only implemented for Unix hosts.", + }) +} + +#[cfg(unix)] +fn write_secure_synthetic_elf() -> Result { + let template = std::env::temp_dir().join("metalsharp-synthetic-eac-module-XXXXXX"); + let mut bytes = template.to_string_lossy().into_owned().into_bytes(); + bytes.push(0); + let fd = unsafe { libc::mkstemp(bytes.as_mut_ptr().cast()) }; + if fd < 0 { + return Err(format!("mkstemp failed with errno {}", last_errno())); + } + let path_bytes = bytes.split(|byte| *byte == 0).next().unwrap_or_default(); + let path = PathBuf::from(String::from_utf8_lossy(path_bytes).into_owned()); + let mut file = unsafe { File::from_raw_fd(fd) }; + file.write_all(synthetic_elf_bytes()).map_err(|error| error.to_string())?; + file.flush().map_err(|error| error.to_string())?; + Ok(path) +} + +#[cfg(unix)] +fn dlerror_string() -> Option { + unsafe { + let error = libc::dlerror(); + (!error.is_null()).then(|| std::ffi::CStr::from_ptr(error).to_string_lossy().to_string()) + } +} + +#[cfg(unix)] +fn last_errno() -> i32 { + std::io::Error::last_os_error().raw_os_error().unwrap_or_default() +} + +fn synthetic_elf_bytes() -> &'static [u8] { + b"\x7fELF\x02\x01\x01\0\0\0\0\0\0\0\0\0\x03\0>\0\x01\0\0\0\0\0\0\0\0\0\0\0" +} + +fn artifact_json(id: &str, path: &Path) -> Value { + let metadata = fs::metadata(path).ok(); + let modified_at = metadata + .as_ref() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + let tail = read_recent_text_limited(path).map(|text| tail_lines(&text, ARTIFACT_TAIL_LINES)).unwrap_or_default(); + + json!({ + "id": id, + "path": path.to_string_lossy(), + "exists": metadata.is_some(), + "bytes": metadata.map(|m| m.len()), + "modifiedAtEpoch": modified_at, + "tail": tail, + }) +} + +fn summarize_eac(artifacts: &[Value]) -> EacSummary { + let mut summary = EacSummary::default(); + let mut ordered = artifacts + .iter() + .filter(|artifact| { + let id = artifact.get("id").and_then(|v| v.as_str()).unwrap_or(""); + id.starts_with("eac_") || id == "steam_runprocess" || id == "game_anticheat_log" + }) + .collect::>(); + // Backups are collected alongside the active log. Parse oldest first so + // the newest launch transition, rather than an older cached run, wins. + ordered.sort_by_key(|artifact| artifact.get("modifiedAtEpoch").and_then(Value::as_u64).unwrap_or(0)); + for artifact in ordered { + let id = artifact.get("id").and_then(|v| v.as_str()).unwrap_or(""); + for line in artifact_lines(artifact) { + parse_eac_line(&line, &mut summary); + if id == "steam_runprocess" { + parse_eac_setup_line(&line, &mut summary); + } + } + } + summary +} + +fn summarize_steam(appid: u32, artifacts: &[Value]) -> SteamSummary { + let mut summary = SteamSummary::default(); + let mut ordered = artifacts.iter().collect::>(); + ordered.sort_by_key(|artifact| artifact.get("modifiedAtEpoch").and_then(Value::as_u64).unwrap_or(0)); + for artifact in ordered { + let id = artifact.get("id").and_then(|v| v.as_str()).unwrap_or(""); + for line in artifact_lines(artifact) { + if id == "steam_gameprocess" { + parse_gameprocess_line(appid, &line, &mut summary); + } else if id == "steam_runprocess" { + parse_runprocess_line(appid, &line, &mut summary); + } + } + } + summary +} + +fn parse_eac_line(line: &str, summary: &mut EacSummary) { + if let Some(value) = extract_between(line, "Loaded the following settings .json file: '", "'") { + summary.settings_path = Some(value); + } + for (prefix, slot) in [ + (" - ProcessTitle: ", &mut summary.process_title), + (" - ExecutablePath: ", &mut summary.executable_path), + (" - ProductId: ", &mut summary.product_id), + (" - SandboxId: ", &mut summary.sandbox_id), + (" - DeploymentId: ", &mut summary.deployment_id), + ] { + if let Some(value) = line.split(prefix).nth(1) { + *slot = Some(value.trim().trim_end_matches('.').to_string()); + } + } + if let Some(value) = extract_between(line, "System name: '", "'") { + summary.system_name = Some(value); + } + if let Some(url) = line.split("Connecting to URL: ").nth(1) { + let url = url.trim().to_string(); + summary.module_target = url.rsplit('/').next().map(|v| v.to_string()); + summary.module_url = Some(url); + } + if let Some(code) = line.split("Response Code: ").nth(1).and_then(first_i64) { + summary.connect_response_code = Some(code); + } + if let Some(version) = line.split("Starting Wine module mapping, Wine version: ").nth(1) { + summary.wine_version = Some(version.trim().trim_end_matches('.').to_string()); + summary.module_mapping_status.get_or_insert_with(|| "started".to_string()); + } + if line.contains("Failed to map the anti-cheat module") { + summary.module_mapping_status = Some("failed".to_string()); + } + if line.contains("Successfully mapped the anti-cheat module") + || line.contains("Anti-cheat module mapped successfully") + { + summary.module_mapping_status = Some("mapped".to_string()); + } + if line.contains("Easy Anti-Cheat successfully loaded in-game") { + // This is retained as a vendor-launcher claim only. It is not + // promoted to module proof without an explicit mapping success and a + // protected game-process transition. + summary.launcher_load_claim = true; + } + if let Some(rest) = line.split("Downloaded ").nth(1) { + summary.downloaded_bytes = first_i64(rest).and_then(|value| u64::try_from(value).ok()); + } + if let Some(rest) = line.split("Launcher finished with: ").nth(1) { + summary.launcher_exit_code = first_i64(rest); + summary.launcher_error = extract_between(rest, "'", "'"); + } +} + +fn parse_eac_setup_line(line: &str, summary: &mut EacSummary) { + let lower = line.to_ascii_lowercase(); + if !lower.contains("easyanticheat") || !lower.contains("setup") { + return; + } + if let Some(code) = line.split("Exit Code (").nth(1).and_then(first_i64) { + summary.setup_exit_code = Some(code); + } +} + +fn parse_gameprocess_line(appid: u32, line: &str, summary: &mut SteamSummary) { + let marker = format!("AppID {}", appid); + if !line.contains(&marker) { + return; + } + if line.contains("adding PID") { + summary.tracked_pid = line.split("adding PID ").nth(1).and_then(first_i64); + if let Some(path) = line.split("tracked process ").nth(1).map(normalize_steam_command) { + if path.to_ascii_lowercase().contains("start_protected_game") { + summary.protected_launcher_path = Some(path); + } + } + } else if line.contains("no longer tracking PID") { + summary.tracked_exit_code = line.split("exit code ").nth(1).and_then(first_i64); + } +} + +fn parse_runprocess_line(appid: u32, line: &str, summary: &mut SteamSummary) { + let marker = format!("[AppID {}]", appid); + if !line.contains(&marker) || !line.contains("Exit Code (") { + return; + } + let code = line.split("Exit Code (").nth(1).and_then(first_i64); + let command = extract_between(line, ") : ", " GLE").unwrap_or_else(|| line.to_string()); + summary.redist_exit_codes.push(json!({"exitCode": code, "command": command})); +} + +fn evidence_status(eac: &EacSummary, steam: &SteamSummary, artifacts: &[Value]) -> String { + if eac.module_mapping_status.as_deref() == Some("failed") { + return "module_mapping_failed".to_string(); + } + if steam.tracked_exit_code == Some(206) || eac.launcher_exit_code == Some(206) { + return "protected_launcher_failed".to_string(); + } + if eac.setup_exit_code == Some(0) && eac.module_target.is_some() { + return "protected_module_downloaded".to_string(); + } + if eac.setup_exit_code == Some(0) { + return "setup_installed".to_string(); + } + if artifacts.iter().any(|a| a.get("id").and_then(|v| v.as_str()).unwrap_or("").contains("battleye")) { + return "battleye_evidence_found".to_string(); + } + "unknown".to_string() +} + +fn probe_status(eac: &EacSummary, module_assets: &[Value]) -> String { + if eac.module_mapping_status.as_deref() == Some("failed") + && eac.module_target.as_deref().unwrap_or("").starts_with("linux") + { + return "linux_module_on_darwin_boundary".to_string(); + } + if eac.module_mapping_status.as_deref() == Some("failed") { + return "module_mapping_failed".to_string(); + } + if module_assets.iter().any(|asset| asset.get("format").and_then(|v| v.as_str()) == Some("elf")) { + return "linux_module_assets_present".to_string(); + } + if module_assets.iter().any(|asset| asset.get("format").and_then(|v| v.as_str()) == Some("mach_o")) { + return "darwin_module_assets_present".to_string(); + } + "no_module_probe_target".to_string() +} + +fn summary_text(status: &str, eac: &EacSummary, steam: &SteamSummary) -> String { + match status { + "module_mapping_failed" => format!( + "Protected launcher reached Wine module mapping under Wine {} and failed to map the anti-cheat module{}.", + eac.wine_version.as_deref().unwrap_or("unknown"), + eac.module_target.as_ref().map(|t| format!(" after downloading {}", t)).unwrap_or_default() + ), + "protected_launcher_failed" => format!( + "Protected launcher exited with code {}.", + eac.launcher_exit_code.or(steam.tracked_exit_code).unwrap_or_default() + ), + "protected_module_downloaded" => { + format!( + "EAC setup installed and downloaded the {} module.", + eac.module_target.as_deref().unwrap_or("unknown") + ) + }, + "setup_installed" => { + "Anti-cheat setup completed, but no protected-launch module download was found yet.".to_string() + }, + "battleye_evidence_found" => { + "BattlEye evidence was found; inspect the attached artifacts for the launch failure.".to_string() + }, + _ => "No conclusive anti-cheat launch evidence was found for this appid.".to_string(), + } +} + +fn next_actions(status: &str) -> Vec<&'static str> { + match status { + "module_mapping_failed" => vec![ + "Run the Wine module-mapping probe against this prefix and appid.", + "Compare MetalSharp Wine loader/syscall behavior with Proton for EAC EOS module mapping.", + "Check whether the downloaded module target is a Linux ELF module that macOS cannot host without a compatibility substrate.", + ], + "protected_launcher_failed" => vec![ + "Inspect Steam gameprocess and EAC launcher tails for the last protected-launch transition.", + "Verify the protected launcher is running inside the correct Steam game bottle prefix.", + ], + "setup_installed" | "protected_module_downloaded" => vec![ + "Launch through the protected Steam route and refresh this evidence report.", + "Verify Steam kept the route-specific bottle environment for the protected launcher.", + ], + _ => vec![ + "Launch the game once through the protected Steam route, then refresh this report.", + "If the game uses BattlEye, check the game directory and Common Files BattlEye logs.", + ], + } +} + +fn probe_summary(status: &str, eac: &EacSummary) -> String { + match status { + "linux_module_on_darwin_boundary" => format!( + "EAC selected a {} module and Wine reached module mapping on macOS; Darwin cannot directly load that Linux module as a dylib.", + eac.module_target.as_deref().unwrap_or("linux") + ), + "module_mapping_failed" => { + "The protected launcher reached module mapping, but the module target could not be classified from the logs.".to_string() + }, + "linux_module_assets_present" => { + "The game folder contains Linux anti-cheat module assets; MetalSharp needs a truthful host substrate before those can run on macOS.".to_string() + }, + "darwin_module_assets_present" => { + "The game folder contains Darwin module assets. This is the only direct dylib path MetalSharp could investigate without a Linux substrate.".to_string() + }, + _ => "No anti-cheat module asset or module-mapping target was found yet.".to_string(), + } +} + +fn probe_next_actions(status: &str) -> Vec<&'static str> { + match status { + "linux_module_on_darwin_boundary" | "linux_module_assets_present" => vec![ + "Audit Proton's EAC loader path around Linux module mapping and Wine syscall dispatch.", + "Prototype a read-only host contract probe for mmap, executable protections, and loader callbacks before changing Wine.", + "Decide whether MetalSharp can ship a signed Linux user-space substrate or must require publisher/vendor macOS assets.", + ], + "darwin_module_assets_present" => vec![ + "Inspect the dylib signature and expected host API before attempting any load.", + "Confirm publisher/vendor support before treating the Darwin asset as launchable.", + ], + "module_mapping_failed" => vec![ + "Capture the full EAC launcher log and locate the downloaded module target.", + "Compare the failing Wine version against Proton's EAC-enabled Wine tree.", + ], + _ => vec![ + "Launch once through the protected route, then run /steam/anticheat-evidence and /steam/anticheat-probe again.", + ], + } +} + +fn module_contract_checks(host_os: &str, eac: &EacSummary, module_assets: &[Value]) -> Value { + let module_target = eac.module_target.as_deref().unwrap_or(""); + let selected_linux_module = module_target.starts_with("linux"); + let has_elf_asset = module_assets.iter().any(|asset| asset.get("format").and_then(|v| v.as_str()) == Some("elf")); + let has_macho_asset = + module_assets.iter().any(|asset| asset.get("format").and_then(|v| v.as_str()) == Some("mach_o")); + json!({ + "selectedLinuxModule": selected_linux_module, + "hasLinuxElfAsset": has_elf_asset, + "hasDarwinDylibAsset": has_macho_asset, + "directHostLoadPossible": !selected_linux_module && (!has_elf_asset || can_dlopen_linux_elf_directly(host_os)), + "needsLinuxUserSpaceSubstrate": (selected_linux_module || has_elf_asset) && !can_dlopen_linux_elf_directly(host_os), + "needsVendorMacOSAsset": host_os == "macos" && selected_linux_module && !has_macho_asset, + }) +} + +fn path_check(path: &Path) -> Value { + let metadata = fs::metadata(path).ok(); + json!({ + "path": path.to_string_lossy(), + "exists": metadata.is_some(), + "isDir": metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false), + }) +} + +fn delta_group(id: &str, label: &str, checks: Vec) -> Value { + json!({ + "id": id, + "label": label, + "status": delta_group_status(&checks), + "checks": checks, + }) +} + +fn delta_path(id: &str, importance: &str, path: &Path, note: Option<&str>) -> Value { + let metadata = fs::metadata(path).ok(); + json!({ + "id": id, + "importance": importance, + "present": metadata.is_some(), + "path": path.to_string_lossy(), + "note": note, + }) +} + +fn delta_capability(id: &str, importance: &str, present: bool, note: &str) -> Value { + json!({ + "id": id, + "importance": importance, + "present": present, + "note": note, + }) +} + +fn delta_group_status(checks: &[Value]) -> &'static str { + if checks.iter().any(|check| { + let importance = check.get("importance").and_then(|v| v.as_str()).unwrap_or(""); + let present = check.get("present").and_then(|v| v.as_bool()).unwrap_or(false); + matches!(importance, "required" | "blocking_when_false") && !present + || matches!(importance, "blocking_when_macos") && present && std::env::consts::OS == "macos" + }) { + "blocking" + } else if checks.iter().any(|check| check.get("present").and_then(|v| v.as_bool()) == Some(false)) { + "informational_gap" + } else { + "ready" + } +} + +fn delta_audit_status(surfaces: &[Value]) -> &'static str { + if surfaces.iter().any(|surface| surface.get("status").and_then(|v| v.as_str()) == Some("blocking")) { + "blocking_delta_found" + } else if surfaces.iter().any(|surface| surface.get("status").and_then(|v| v.as_str()) == Some("informational_gap")) + { + "comparison_gaps_found" + } else { + "no_blocking_delta_found" + } +} + +fn delta_audit_summary(eac: &EacSummary, host_os: &str) -> String { + if host_os == "macos" && eac.module_target.as_deref().unwrap_or("").starts_with("linux") { + return format!( + "MetalSharp has Wine/DXMT runtime pieces, but protected launch selected {} and needs a Linux-user-space or vendor macOS module answer.", + eac.module_target.as_deref().unwrap_or("linux") + ); + } + "Delta audit completed; inspect blocking and proton_comparison rows for the next implementation target.".to_string() +} + +fn substrate_decision(host_os: &str, eac: &EacSummary, module_assets: &[Value]) -> String { + let selected_linux = eac.module_target.as_deref().unwrap_or("").starts_with("linux"); + let has_elf_asset = module_assets.iter().any(|asset| asset.get("format").and_then(|v| v.as_str()) == Some("elf")); + let has_macho_asset = + module_assets.iter().any(|asset| asset.get("format").and_then(|v| v.as_str()) == Some("mach_o")); + if host_os == "macos" && has_macho_asset { + "investigate_vendor_macos_module".to_string() + } else if host_os == "macos" && (selected_linux || has_elf_asset) { + "requires_linux_user_space_substrate_or_vendor_macos_asset".to_string() + } else if eac.module_mapping_status.as_deref() == Some("failed") { + "requires_loader_delta_audit".to_string() + } else { + "collect_protected_launch_evidence".to_string() + } +} + +fn substrate_decision_summary(decision: &str) -> &'static str { + match decision { + "investigate_vendor_macos_module" => { + "A Darwin module asset appears present; verify vendor support, signature, and expected host API before attempting any load." + }, + "requires_linux_user_space_substrate_or_vendor_macos_asset" => { + "The protected launch path selected Linux anti-cheat assets on macOS; MetalSharp needs a legitimate Linux user-space substrate or vendor-supported macOS assets." + }, + "requires_loader_delta_audit" => { + "Module mapping failed, but the selected module target is unclear; complete the Proton/Wine loader delta audit first." + }, + _ => "No protected-launch module decision can be made yet; collect EAC/BattlEye launch evidence first.", + } +} + +fn allowed_substrate_paths(decision: &str) -> Vec<&'static str> { + match decision { + "investigate_vendor_macos_module" => vec![ + "validate vendor-supported macOS module assets", + "document expected host API and signing requirements", + "build only transparent compatibility glue approved by the publisher or anti-cheat vendor", + ], + "requires_linux_user_space_substrate_or_vendor_macos_asset" => vec![ + "build a signed Linux user-space compatibility substrate for ELF module hosting", + "obtain or document vendor-supported macOS anti-cheat module assets", + "work with publisher/vendor enablement instead of spoofing trust", + ], + "requires_loader_delta_audit" => vec![ + "complete Proton/Wine loader and syscall delta audit", + "add precise probes for mmap, executable protections, and loader callbacks", + ], + _ => vec!["collect protected-launch logs and module target evidence"], + } +} + +fn substrate_next_actions(decision: &str) -> Vec<&'static str> { + match decision { + "requires_linux_user_space_substrate_or_vendor_macos_asset" => vec![ + "Prototype a harmless ELF loader capability probe outside the protected module path.", + "Map the minimum Linux user-space APIs a vendor EAC/BattlEye module expects under Proton.", + "Prepare a vendor-facing proof bundle showing the exact module target, host OS boundary, and non-evasion policy.", + ], + "investigate_vendor_macos_module" => vec![ + "Verify the Mach-O asset is actually vendor anti-cheat code, not an unrelated helper.", + "Check code signature and load requirements without injecting it into a protected process.", + ], + "requires_loader_delta_audit" => vec![ + "Run /steam/anticheat-delta-audit and compare the blocking rows with Proton behavior.", + ], + _ => vec![ + "Run the protected Steam launch once and then refresh /steam/anticheat-evidence.", + ], + } +} + +fn classify_module_path(path: &Path) -> &'static str { + let path_lc = path.to_string_lossy().to_ascii_lowercase(); + if path_lc.contains("battleye") || path_lc.contains("beclient") || path_lc.contains("beservice") { + "battleye" + } else if path_lc.contains("easyanticheat") { + "easyanticheat" + } else { + "unknown" + } +} + +fn read_binary_format(path: &Path) -> &'static str { + let mut file = match File::open(path) { + Ok(file) => file, + Err(_) => return "unknown", + }; + let mut bytes = [0u8; 4]; + let len = match file.read(&mut bytes) { + Ok(len) => len, + Err(_) => return "unknown", + }; + binary_format(&bytes[..len]) +} + +fn binary_format(bytes: &[u8]) -> &'static str { + if bytes.len() >= 4 && &bytes[0..4] == b"\x7fELF" { + return "elf"; + } + if bytes.len() >= 2 && &bytes[0..2] == b"MZ" { + return "pe"; + } + if bytes.len() >= 4 { + let magic = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + if matches!(magic, 0xfeedface | 0xfeedfacf | 0xcafebabe | 0xcffaedfe | 0xcefaedfe | 0xbebafeca) { + return "mach_o"; + } + } + "unknown" +} + +fn can_dlopen_linux_elf_directly(host_os: &str) -> bool { + host_os == "linux" +} + +fn artifact_tail(artifact: &Value) -> Vec<&str> { + artifact + .get("tail") + .and_then(|v| v.as_array()) + .map(|lines| lines.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default() +} + +fn artifact_lines(artifact: &Value) -> Vec { + artifact + .get("path") + .and_then(|v| v.as_str()) + .and_then(|path| read_recent_text_limited(Path::new(path))) + .map(|text| text.lines().map(|line| line.trim_end_matches('\r').to_string()).collect()) + .unwrap_or_else(|| artifact_tail(artifact).into_iter().map(|line| line.to_string()).collect()) +} + +fn read_recent_text_limited(path: &Path) -> Option { + let mut file = File::open(path).ok()?; + let len = file.metadata().ok()?.len(); + if len > MAX_ARTIFACT_READ_BYTES { + file.seek(SeekFrom::Start(len - MAX_ARTIFACT_READ_BYTES)).ok()?; + } + let mut bytes = Vec::new(); + file.take(MAX_ARTIFACT_READ_BYTES).read_to_end(&mut bytes).ok()?; + Some(String::from_utf8_lossy(&bytes).into_owned()) +} + +fn tail_lines(text: &str, max_lines: usize) -> Vec { + let lines: Vec<&str> = text.lines().collect(); + let start = lines.len().saturating_sub(max_lines); + lines[start..].iter().map(|line| line.trim_end_matches('\r').to_string()).collect() +} + +fn extract_between(text: &str, start: &str, end: &str) -> Option { + let rest = text.split(start).nth(1)?; + let value = rest.split(end).next()?; + Some(value.trim().to_string()) +} + +fn normalize_steam_command(command: &str) -> String { + command.trim().trim_matches('"').to_string() +} + +fn first_i64(text: &str) -> Option { + let mut chars = text.trim_start().chars().peekable(); + let mut buf = String::new(); + if chars.peek() == Some(&'-') { + buf.push('-'); + chars.next(); + } + while let Some(ch) = chars.peek() { + if ch.is_ascii_digit() { + buf.push(*ch); + chars.next(); + } else { + break; + } + } + if buf.is_empty() || buf == "-" { + None + } else { + buf.parse().ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_eac_module_mapping_failure() { + let mut summary = EacSummary::default(); + parse_eac_line( + "[07:14:24:940] [Windows] [EAC Launcher] [Info] - ProductId: 789399aada914e66bb3c3facebc5d709.", + &mut summary, + ); + parse_eac_line("[07:14:26:048] [Windows] [EAC Launcher] [Info] [Connection] Connecting to URL: https://modules-cdn.eac-prod.on.epicgames.com/modules/product/deploy/linux64", &mut summary); + parse_eac_line( + "[07:14:26:516] [Windows] [EAC Launcher] [Info] Starting Wine module mapping, Wine version: 11.5.", + &mut summary, + ); + parse_eac_line( + "[07:14:26:517] [Windows] [EAC Launcher] [Err!] Failed to map the anti-cheat module.", + &mut summary, + ); + parse_eac_line("[07:14:27:415] [Windows] [EAC Launcher] [Info] Launcher finished with: 206, 'Failed to load the anti-cheat module.'.", &mut summary); + + assert_eq!(summary.product_id.as_deref(), Some("789399aada914e66bb3c3facebc5d709")); + assert_eq!(summary.module_target.as_deref(), Some("linux64")); + assert_eq!(summary.wine_version.as_deref(), Some("11.5")); + assert_eq!(summary.module_mapping_status.as_deref(), Some("failed")); + assert_eq!(summary.launcher_exit_code, Some(206)); + assert_eq!(summary.launcher_error.as_deref(), Some("Failed to load the anti-cheat module.")); + } + + #[test] + fn parses_linux_module_download_and_keeps_load_claim_separate_from_proof() { + let mut summary = EacSummary::default(); + parse_eac_line("[Info] System name: 'linux64'.", &mut summary); + parse_eac_line("[Info] Connect result: No error (0) Response Code: 200", &mut summary); + parse_eac_line("[Info] Downloaded 9168824 bytes in 1578 ms (5674.23 KB/s).", &mut summary); + parse_eac_line("[Info] Easy Anti-Cheat successfully loaded in-game", &mut summary); + + assert_eq!(summary.system_name.as_deref(), Some("linux64")); + assert_eq!(summary.connect_response_code, Some(200)); + assert_eq!(summary.downloaded_bytes, Some(9_168_824)); + assert!(summary.launcher_load_claim); + assert_eq!(summary.module_mapping_status, None); + } + + #[test] + fn parses_steam_protected_launch_exit() { + let mut summary = SteamSummary::default(); + parse_gameprocess_line(1888160, "[2026-05-20 01:14:24] AppID 1888160 adding PID 1316 as a tracked process \"\"Z:\\SteamLibrary\\steamapps\\common\\Game\\start_protected_game.exe\"\"", &mut summary); + parse_gameprocess_line( + 1888160, + "[2026-05-20 01:14:38] AppID 1888160 no longer tracking PID 1316, exit code 206", + &mut summary, + ); + + assert_eq!(summary.tracked_pid, Some(1316)); + assert_eq!(summary.tracked_exit_code, Some(206)); + assert!(summary.protected_launcher_path.as_deref().unwrap_or_default().contains("start_protected_game.exe")); + } + + #[test] + fn binary_format_classifies_common_module_headers() { + assert_eq!(binary_format(b"\x7fELF\x02\x01"), "elf"); + assert_eq!(binary_format(b"MZ\x90\x00"), "pe"); + assert_eq!(binary_format(&[0xfe, 0xed, 0xfa, 0xcf]), "mach_o"); + assert_eq!(binary_format(b"not a module"), "unknown"); + } + + #[test] + fn probe_status_flags_linux_module_mapping_on_darwin_boundary() { + let eac = EacSummary { + module_target: Some("linux64".to_string()), + module_mapping_status: Some("failed".to_string()), + ..Default::default() + }; + assert_eq!(probe_status(&eac, &[]), "linux_module_on_darwin_boundary"); + let checks = module_contract_checks("macos", &eac, &[]); + assert_eq!(checks.get("needsLinuxUserSpaceSubstrate").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(checks.get("needsVendorMacOSAsset").and_then(|v| v.as_bool()), Some(true)); + } + + #[test] + fn delta_group_status_marks_missing_required_paths_blocking() { + let checks = vec![ + json!({"id": "present_required", "importance": "required", "present": true}), + json!({"id": "missing_required", "importance": "required", "present": false}), + ]; + assert_eq!(delta_group_status(&checks), "blocking"); + } + + #[test] + fn delta_audit_status_promotes_blocking_surface() { + let surfaces = vec![json!({"id": "anticheat_module_contract", "status": "blocking"})]; + assert_eq!(delta_audit_status(&surfaces), "blocking_delta_found"); + } + + #[test] + fn substrate_decision_requires_linux_substrate_for_linux_module_on_macos() { + let eac = EacSummary { module_target: Some("linux64".to_string()), ..Default::default() }; + assert_eq!(substrate_decision("macos", &eac, &[]), "requires_linux_user_space_substrate_or_vendor_macos_asset"); + } +} diff --git a/app/src-rust/src/installer.rs b/app/src-rust/src/installer.rs index 05fdf4bd0..174213637 100644 --- a/app/src-rust/src/installer.rs +++ b/app/src-rust/src/installer.rs @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; +#[cfg(target_os = "macos")] +use walkdir::WalkDir; static INSTALLING: AtomicBool = AtomicBool::new(false); @@ -26,6 +28,7 @@ pub const DXMT_BUNDLED_RUNTIME_VERSION: &str = concat!(env!("CARGO_PKG_VERSION") const DXMT_RUNTIME_MANIFEST: &str = "metalsharp-dxmt-runtime.json"; const DXMT_RUNTIME_SCHEMA: &str = "metalsharp.dxmt-runtime.v1"; const RUNTIME_BUNDLE: &str = "metalsharp-runtime"; +const WINE_PACKAGED_DEPENDENCY_ROOT: &str = "/tmp/metalsharp-wine-deps/lib/"; const GRAPHICS_DLL_BUNDLE: &str = "metalsharp-graphics-dll"; const ASSETS_BUNDLE: &str = "metalsharp-assets"; const FNALIBS_BUNDLE: &str = "fnalibs"; @@ -653,7 +656,11 @@ fn install_metalsharp_bundle(home: &PathBuf) -> Result { && metalsharp_runtime_lib_ready(&runtime_dir.join("wine")) && bundle.as_ref().is_some_and(|archive| split_bundle_current(home, RUNTIME_BUNDLE, archive)) { - return Ok(false); + // Older runtime bundles were published with build-machine absolute + // GnuTLS install names. Repair this even on the currency fast path; + // otherwise an already-current bundle can keep failing HTTPS during + // protected-launch module download forever. + return repair_wine_packaged_dependencies(&runtime_dir.join("wine")); } if let Some(archive) = bundle { @@ -700,6 +707,7 @@ fn install_metalsharp_bundle(home: &PathBuf) -> Result { match wine_check { Ok(o) if o.status.success() => { fix_moltenvk_icd_paths(&runtime_dir.join("wine")); + repair_wine_packaged_dependencies(&runtime_dir.join("wine"))?; mark_split_bundle_installed(home, RUNTIME_BUNDLE, &archive); return Ok(true); }, @@ -717,6 +725,120 @@ fn install_metalsharp_bundle(home: &PathBuf) -> Result { Err("MetalSharp runtime not found — no bundled metalsharp-runtime.tar.zst available".into()) } +/// Rewrite build-machine absolute dylib names in the Wine bundle to paths +/// relative to the loading dylib. The macOS Wine bundle carries GnuTLS and +/// its crypto closure in a `lib/wine/*-unix` tree; older bundle builds +/// retained `/tmp/metalsharp-wine-deps/...` from the staging machine, so dyld +/// could not load Schannel even though the files were present. +/// +/// This deliberately changes only that exact private staging prefix. System +/// frameworks, SDK libraries, and vendor/runtime assets keep their original +/// install names. The operation is idempotent and re-signs only binaries it +/// actually changes. +fn repair_wine_packaged_dependencies(wine_dir: &Path) -> Result { + #[cfg(target_os = "macos")] + { + let wine_lib_root = wine_dir.join("lib").join("wine"); + if !wine_lib_root.is_dir() { + return Ok(false); + } + + let mut changed = false; + for entry in WalkDir::new(&wine_lib_root).follow_links(false).into_iter().filter_map(Result::ok) { + let path = entry.path(); + let is_macho_candidate = + matches!(path.extension().and_then(|ext| ext.to_str()), Some("dylib") | Some("so")); + if !is_macho_candidate || !entry.file_type().is_file() { + continue; + } + + let output = Command::new("/usr/bin/otool") + .arg("-L") + .arg(path) + .output() + .map_err(|e| format!("inspect Wine dylib {}: {}", path.display(), e))?; + if !output.status.success() { + // The runtime tree can contain a non-Mach-O file with a + // dylib suffix from a third-party payload. It is not part of + // this repair surface; let Wine's normal validation report it. + continue; + } + + let dependencies = String::from_utf8_lossy(&output.stdout) + .lines() + .skip(1) + .filter_map(|line| line.split_whitespace().next()) + .filter_map(packaged_dependency_target) + .collect::>(); + let current_id = Command::new("/usr/bin/otool").arg("-D").arg(path).output().ok().and_then(|id| { + if !id.status.success() { + return None; + } + String::from_utf8_lossy(&id.stdout).lines().nth(1).map(str::trim).map(str::to_string) + }); + let id_target = current_id.as_deref().and_then(packaged_dependency_target); + + let mut file_changed = false; + for target in dependencies { + let old = format!("{}{}", WINE_PACKAGED_DEPENDENCY_ROOT, target.trim_start_matches("@loader_path/")); + run_install_name_tool(&["-change", &old, &target], path)?; + file_changed = true; + } + if let Some(target) = id_target { + run_install_name_tool(&["-id", &target], path)?; + file_changed = true; + } + + if file_changed { + let sign = Command::new("/usr/bin/codesign") + .args(["--force", "--sign", "-"]) + .arg(path) + .output() + .map_err(|e| format!("ad-hoc sign repaired Wine dylib {}: {}", path.display(), e))?; + if !sign.status.success() { + return Err(format!( + "ad-hoc sign repaired Wine dylib {} failed: {}", + path.display(), + String::from_utf8_lossy(&sign.stderr).trim() + )); + } + changed = true; + } + } + + Ok(changed) + } + + #[cfg(not(target_os = "macos"))] + { + let _ = wine_dir; + Ok(false) + } +} + +#[cfg(target_os = "macos")] +fn packaged_dependency_target(path: &str) -> Option { + let basename = path.strip_prefix(WINE_PACKAGED_DEPENDENCY_ROOT)?; + if basename.is_empty() || basename.contains('/') { + return None; + } + Some(format!("@loader_path/{}", basename)) +} + +#[cfg(target_os = "macos")] +fn run_install_name_tool(args: &[&str], path: &Path) -> Result<(), String> { + let output = Command::new("/usr/bin/install_name_tool") + .args(args) + .arg(path) + .output() + .map_err(|e| format!("rewrite Wine dylib {}: {}", path.display(), e))?; + if output.status.success() { + Ok(()) + } else { + Err(format!("rewrite Wine dylib {} failed: {}", path.display(), String::from_utf8_lossy(&output.stderr).trim())) + } +} + const GRAPHICS_RUNTIME_SURFACES: &[&str] = &["dxmt", "dxmt_m12", "vkd3d-proton", "dxvk", "moltenvk-vkmt"]; fn preserve_graphics_runtime_surfaces(wine_dir: &Path, tmp_extract: &Path) -> Result { @@ -2814,6 +2936,22 @@ fn extract_zst(archive: &PathBuf, dest: &PathBuf, name: &str) -> Result<(), Stri mod tests { use super::*; + #[cfg(target_os = "macos")] + #[test] + fn wine_dependency_repair_only_rewrites_the_private_staging_prefix() { + assert_eq!( + packaged_dependency_target("/tmp/metalsharp-wine-deps/lib/libgnutls.30.dylib"), + Some("@loader_path/libgnutls.30.dylib".to_string()) + ); + assert_eq!( + packaged_dependency_target("/tmp/metalsharp-wine-deps/lib/libgmp.10.dylib"), + Some("@loader_path/libgmp.10.dylib".to_string()) + ); + assert_eq!(packaged_dependency_target("/opt/homebrew/lib/libgnutls.30.dylib"), None); + assert_eq!(packaged_dependency_target("/tmp/metalsharp-wine-deps/lib/nested/libfoo.dylib"), None); + assert_eq!(packaged_dependency_target("@loader_path/libgnutls.30.dylib"), None); + } + #[test] fn assets_required_files_cover_fna_unity_payloads() { // Phase: the mono route's version-matched payloads must be required by diff --git a/app/src-rust/src/main.rs b/app/src-rust/src/main.rs index 35000d818..b254f411e 100644 --- a/app/src-rust/src/main.rs +++ b/app/src-rust/src/main.rs @@ -17,6 +17,7 @@ unused_variables )] +mod anticheat; mod binding_contract; mod bottles; mod command_contract; @@ -1538,6 +1539,30 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { let body = read_body(req); resp(200, bottles::handle_steam_compatdata(&body)) }, + // Anti-cheat evidence and host-contract probes are intentionally + // observational: they report the protected launcher/module boundary + // without changing vendor binaries, identity exports, or launch + // policy. + (Method::Post, "/steam/anticheat-evidence") => { + let body = read_body(req); + resp(200, anticheat::handle_steam_anticheat_evidence(&body)) + }, + (Method::Post, "/steam/anticheat-probe") => { + let body = read_body(req); + resp(200, anticheat::handle_steam_anticheat_probe(&body)) + }, + (Method::Post, "/steam/anticheat-delta-audit") => { + let body = read_body(req); + resp(200, anticheat::handle_steam_anticheat_delta_audit(&body)) + }, + (Method::Post, "/steam/anticheat-substrate-decision") => { + let body = read_body(req); + resp(200, anticheat::handle_steam_anticheat_substrate_decision(&body)) + }, + (Method::Post, "/steam/anticheat-contract-probe") => { + let body = read_body(req); + resp(200, anticheat::handle_steam_anticheat_contract_probe(&body)) + }, (Method::Post, "/kernel-translation/probe") => { let body = read_body(req); resp(200, kernel_translation::handle_kernel_translation_probe(&body)) diff --git a/docs/roadmaps/anticheat-hard-route-roadmap.md b/docs/roadmaps/anticheat-hard-route-roadmap.md index 8d7e86ae4..ae4cb224c 100644 --- a/docs/roadmaps/anticheat-hard-route-roadmap.md +++ b/docs/roadmaps/anticheat-hard-route-roadmap.md @@ -126,4 +126,102 @@ Rejected paths: ## Current Proof Target -Rubicon showed useful progress but not success: EAC EOS setup completed, protected launch downloaded the `linux64` module, Wine module mapping started under Wine 11.5, and then EAC failed with `Failed to map the anti-cheat module` / exit code 206. That is the first failure to reduce. +Rubicon showed useful progress but not success: EAC EOS setup completed, protected launch downloaded the `linux64` module, Wine module mapping started under Wine 11.5, and then EAC failed with `Failed to map the anti-cheat module` / exit code 206. The custom Darwin substrate now closes that loader boundary; the proof below is the current gate. + +## Current Implementation Surface + +The backend now exposes all five read-only evidence surfaces described above: + +- `/steam/anticheat-evidence` +- `/steam/anticheat-probe` +- `/steam/anticheat-delta-audit` +- `/steam/anticheat-substrate-decision` +- `/steam/anticheat-contract-probe` + +The collector records the selected EAC system, CDN response, downloaded byte +count, Wine version, mapping result, launcher exit code, protected-launch log +context, and metadata for cached `.eac` vendor containers without inspecting +their contents. `METALSHARP_ANTICHEAT_PREFIX` can point at an absolute, +disposable prefix for reproducible launch evidence; normal calls use the +configured Steam bottle. + +The installer also repairs the exact `/tmp/metalsharp-wine-deps/lib/` install +names found in older macOS Wine bundles, including the GnuTLS crypto closure, +to `@loader_path` and ad-hoc signs changed dylibs. This is required for the +EAC CDN request to reach HTTP 200, but it is not itself EAC module support. + +The completion gate remains intentionally strict: a `launcherLoadClaim`, a +successful download, or a synthetic host probe is not treated as protected +module proof. The gate requires an explicit vendor-module mapping/load result +and a protected game transition. No identity spoofing, module tampering, fake +kernel support, or bypass route is part of this surface. + +The pre-substrate Elden Ring baseline recorded the following concrete boundary: + +- `Start_protected_game.exe` selected `linux64` under Wine 11.5. +- The repaired TLS path reached CDN HTTP 200 and downloaded 9,168,824 bytes. +- Wine then reported `Failed to map the anti-cheat module` and launcher exit + code `206`; the current explicit substrate proof no longer reports that + mapping failure. +- Direct `mac_x64` and `mac_arm64` deployment requests returned HTTP 403, so + no vendor macOS module asset is available for this deployment. +- The synthetic macOS host probe accepted anonymous RW→RX memory but rejected a + synthetic ELF through dyld, producing `linux_elf_host_gap_confirmed`. + +These observations proved the diagnostic surface and isolated the remaining +module-hosting boundary. The boundary is now implemented by +`src/anticheat/linux_substrate.c`, built as the x86_64 +`metalsharp_eac_substrate.dylib` CMake target, and copied into `app/native/` +for the existing Electron native-resource packaging path. The substrate is +used only by the explicit proof/launch environment; normal launches do not +start an anti-cheat process automatically. + +## Real MetalSharp Wine 11.5 Module Proof + +`tools/anticheat/run_eac_proof.py` is the bounded, opt-in proof command. It +accepts the external Steam-library game directory explicitly, refuses a Wine +binary outside the selected `.metalsharp/runtime/wine/bin/wine` tree, starts +only `Start_protected_game.exe`, enforces a thirty-second maximum, runs +`wineserver -k`, and kills residual Wine helper processes. It does not start +Steam or `eldenring.exe`. + +The CMake substrate target also generates the MetalSharp-owned ET_DYN symbol +image `app/native/metalsharp_eac_libc.so.6` from +`tools/anticheat/generate_linux_libc_elf.py`. This image is the Linux symbol +namespace consumed by the Darwin boundary; it is not vendor libc or an EAC +payload. + +The command was run against the real external-drive launcher and the exact +installed MetalSharp Wine runtime. The resulting evidence (`schema`: +`metalsharp.eac-proof.v1`) records: + +- Wine module mapping selected `linux64`, received CDN response `200`, and + reported Wine `11.5` (exact runtime SHA-256 + `e621bf88dd07872b391198aee50bf1503fe18d43b7a9c0183fa23075efc61395`). +- The built x86_64 substrate was SHA-256 + `52258a6433d41bcf09028763377a4493ba27f45fe886ff3dbc9db123ccb3b1b7`, and + its generated ELF symbol image was SHA-256 + `d8c1008d0ddf70287023c9d4b16840fb0dd4039ae055bff0a10a400f1ebb9886`. +- The real downloaded module was an ELF64 x86-64 image, 9,168,824 bytes, + SHA-256 `4fdb641276de2a5f94c0fc4e10be28f4b4a53c47b8ecaa40461e09c322c75a8a`. +- The substrate completed the full RELA pass (`1,677` entries) and PLT pass + (`151` entries), invoked `DT_INIT` and all six `DT_INIT_ARRAY` constructors, + applied the ELF `PT_LOAD` protections, and resolved the launcher's real + `a`, `b`, `c`, and `d` exports. +- The real EAC export `a` returned `1` through the Darwin TSD/ELF substrate. +- Cleanup left no Wine, winedbg, wineserver, conhost, or explorer process from + the proof run. The launcher is intentionally terminated at the thirty-second + bound because the standalone probe does not provide the game transition. + +This is a real protected-module load/relocation/constructor/export proof, not a +synthetic ELF probe, identity spoof, vendor-module edit, fake kernel result, +GPTK/VKMT path, alternate Wine build, or compatibility shim. It proves the +Linux EAC module surface through the exact MetalSharp Wine 11.5 → macOS +translation boundary. It does **not** yet prove a protected `eldenring.exe` +transition or an online session; those remain separate completion evidence and +must not be inferred from this module proof. An explicit Wine Steam handoff +attempt was also bounded and cleaned up; the installed Wine Steam client +reported `SteamAPI_Init() failed; connect to global user failed` before it +created a protected game process, so that attempt is recorded as an +unproven Steam-account handoff rather than being misrepresented as an EAC +failure or success. diff --git a/src/anticheat/linux_substrate.c b/src/anticheat/linux_substrate.c new file mode 100644 index 000000000..3fde8e31b --- /dev/null +++ b/src/anticheat/linux_substrate.c @@ -0,0 +1,3896 @@ +/* + * MetalSharp Linux user-space substrate (Darwin host side). + * + * The protected EAC launcher has a Linux-specific Wine path. It discovers + * libc by parsing an ELF image named in /proc//maps and then calls the + * resolved dlopen/dlsym entry points. macOS dyld cannot load that ELF image, + * so this library provides the missing ABI boundary inside the existing + * MetalSharp Wine 11.5 host process. It is intentionally a transparent + * compatibility layer: it does not change Wine identity, patch an EAC + * payload, or suppress a vendor check. + * + * The complete boundary maps the real MetalSharp-generated ELF symbol image, + * exposes it through the native /proc view, relocates the protected ELF, and + * bridges its Linux libc/pthread/TSD calls into the existing Wine process. + * Keeping the boundary here makes the real protected-module path testable + * without changing the vendor image. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MS_PAGE_SIZE 0x1000u +#define MS_STUB_SIZE 16u +#define MS_MAX_PATH 4096u +#define MS_PREFERRED_ELF_BASE ((void*)(uintptr_t)0x700100000000ULL) +#define MS_WINE115_KERNEL32_BASE ((mach_vm_address_t)0x6fffffa00000ULL) + +typedef struct { + unsigned char ident[16]; + uint16_t type; + uint16_t machine; + uint32_t version; + uint64_t entry; + uint64_t phoff; + uint64_t shoff; + uint32_t flags; + uint16_t ehsize; + uint16_t phentsize; + uint16_t phnum; + uint16_t shentsize; + uint16_t shnum; + uint16_t shstrndx; +} MsElfHeader; + +typedef struct { + uint32_t type; + uint32_t flags; + uint64_t offset; + uint64_t vaddr; + uint64_t paddr; + uint64_t filesz; + uint64_t memsz; + uint64_t align; +} MsElfProgramHeader; + +typedef struct { + uint32_t name; + unsigned char info; + unsigned char other; + uint16_t shndx; + uint64_t value; + uint64_t size; +} MsElfSymbol; + +typedef struct { + int64_t tag; + uint64_t value; +} MsElfDynamic; + +typedef struct { + uint64_t offset; + uint64_t info; + int64_t addend; +} MsElfRela; + +typedef struct { + void* base; + void* mapping_start; + size_t size; + uint64_t minimum_vaddr; + const MsElfProgramHeader* program_headers; + uint16_t program_count; + MsElfDynamic* dynamic; + size_t dynamic_count; + const char* strings; + size_t string_size; + MsElfSymbol* symbols; + size_t symbol_count; + int module_fd; + size_t tls_size; + size_t tls_align; + uint64_t init; + uint64_t init_array; + size_t init_array_count; + size_t init_array_called; + size_t rela_count; + size_t plt_count; + bool rela_relocated; + bool plt_relocated; + bool protections_applied; + bool loaded; + bool initialized; +} MsLinuxLoadedModule; + +enum { + MS_PT_DYNAMIC = 2, +}; + +typedef int32_t (*MsNtdllGetUnixFileNameFn)(const uint16_t* dos, char** unix_name, unsigned int disposition); + +static void* g_elf_mapping; +static size_t g_elf_mapping_size; +static char g_elf_path[MS_MAX_PATH]; +static char g_log_path[MS_MAX_PATH]; +static char g_maps_path[MS_MAX_PATH]; +static __thread int g_in_open_hook; +static MsNtdllGetUnixFileNameFn g_original_ntdll_get_unix_file_name; +static void* g_ntdll_unix_name_target; +static uintptr_t g_ntdll_image_base; +static volatile int g_ntdll_patch_done; +typedef char* (*MsKernel32GetUnixFileNameFn)(const uint16_t* dos) __attribute__((ms_abi)); +static MsKernel32GetUnixFileNameFn g_original_kernel32_get_unix_file_name; +static volatile int g_kernel32_patch_done; +static volatile int g_kernel32_scan_reported; +typedef void* (*MsGetProcAddressFn)(void* module, const char* name) __attribute__((ms_abi)); +static MsGetProcAddressFn g_original_get_proc_address; +static volatile int g_get_proc_address_patch_done; +static int (*g_real_sigaction)(int, const struct sigaction*, struct sigaction*); +static struct sigaction g_wine_sigsys_action; +static volatile sig_atomic_t g_wine_sigsys_action_valid; +static struct sigaction g_wine_sigsegv_action; +static volatile sig_atomic_t g_wine_sigsegv_action_valid; +static volatile sig_atomic_t g_memfd_sequence; +static MsLinuxLoadedModule g_linux_module; +static char g_linux_dlerror[256]; +static __thread void* g_linux_tls_block; +static volatile uint64_t g_eac_module_load_attempts; +static volatile uint64_t g_eac_module_load_successes; +static volatile uint64_t g_eac_export_a_successes; +static volatile uint32_t g_eac_export_mask; +typedef uintptr_t (*MsLinuxUnaryExportFn)(void* argument); +static MsLinuxUnaryExportFn g_linux_export_a; +static MsLinuxUnaryExportFn g_linux_export_b; +static MsLinuxUnaryExportFn g_linux_export_c; +static MsLinuxUnaryExportFn g_linux_export_e; +static void (*g_linux_export_d)(void); +typedef void (*MsThreadSetTsdBaseFn)(void* base); +typedef void* (*MsNtCurrentTebFn)(void); +static MsThreadSetTsdBaseFn g_thread_set_tsd_base; +static MsNtCurrentTebFn g_nt_current_teb; +/* Keep the diagnostic target in ordinary data: Darwin's compiler TLS access + * itself can consult the host pthread TSD base while Wine has GS pointed at + * its TEB. The actual bridge state below is per Wine stack arena. */ +static void* g_last_guest_teb; +static MsThreadSetTsdBaseFn g_real_thread_set_tsd_base; +static volatile uint64_t g_tsd_callback_count; +static volatile uint64_t g_tsd_pseudo_count; +static volatile uint64_t g_tsd_mapped_count; +static volatile uintptr_t g_tsd_last_requested; +static volatile uintptr_t g_tsd_last_effective; +typedef struct { + uintptr_t requested; + uintptr_t effective; + uint32_t thread; +} MsTsdCallbackRecord; +static MsTsdCallbackRecord g_tsd_callback_records[64]; +typedef struct { + uintptr_t thread; + uintptr_t guest_teb; + uintptr_t host_tsd; + uint32_t host_depth; + bool used; +} MsWineThreadBridgeState; +static MsWineThreadBridgeState g_wine_thread_bridge_states[64]; +static volatile int g_wine_thread_bridge_lock; +typedef int (*MsHostPthreadSetspecificFn)(pthread_key_t key, const void* value); +typedef const void* (*MsHostPthreadGetspecificFn)(pthread_key_t key); +typedef pthread_t (*MsHostPthreadSelfFn)(void); +static MsHostPthreadSetspecificFn g_host_pthread_setspecific; +static MsHostPthreadGetspecificFn g_host_pthread_getspecific; +static MsHostPthreadSelfFn g_host_pthread_self; +static void* g_libsystem_pthread; +static volatile int g_host_tsd_interpose_ready; +static uintptr_t g_native_host_tsd_base; +static pthread_t g_native_host_pthread; +static void* g_wine_pe_tls_array; +static void* g_wine_pe_tls_block; +static pthread_t g_wine_tls_monitor_thread; +static volatile int g_wine_tls_monitor_started; +static volatile uint64_t g_wine_tls_monitor_iterations; +static volatile uint64_t g_wine_tls_monitor_tebs; +static volatile uint64_t g_wine_tls_monitor_repairs; +static volatile int g_ntdll_tsd_stub_patched; +static volatile int g_ntdll_pthread_getspecific_patched; +static void* g_libsystem_kernel; + +static void ms_log(const char* format, ...); +static bool wine_read_word(void* address, uintptr_t* value); +static void* wine_guest_teb_for_current_thread(void); +static void ensure_wine_pe_tls(void); +static void metalsharp_sigsegv_handler(int signal, siginfo_t* siginfo, void* context); +static const void* metalsharp_host_pthread_getspecific(pthread_key_t key); +static void patch_wine115_pthread_getspecific_stub(void); + +static uintptr_t read_guest_gs_tls_pointer(void) { +#if defined(__x86_64__) + uintptr_t value = 0; + __asm__ volatile("movq %%gs:0x58, %0" : "=r"(value)); + return value; +#else + return 0; +#endif +} + +/* Called only from the private TSD callback, where Mach APIs are not safe. + * The exact Wine 11.5 ntdll list is an ordinary in-process doubly-linked + * list; match its per-TEB native TSD field directly to recover the public TEB + * for a newly-created Wine thread. */ +static void* wine_public_teb_for_tsd_base(uintptr_t tsd_base) { + if (g_ntdll_image_base == 0 || tsd_base == 0) { + return NULL; + } + uintptr_t list_head = g_ntdll_image_base + 0x98530u; + uintptr_t next_entry = *(volatile uintptr_t*)(uintptr_t)list_head; + for (size_t pass = 0; pass < 256 && next_entry != list_head; pass++) { + if (next_entry < 0x3b0u || ((next_entry - 0x3b0u) & 0xffffu) != 0) { + return NULL; + } + uintptr_t teb = next_entry - 0x3b0u; + if (*(volatile uintptr_t*)(teb + 0x320u) == tsd_base) { + return (void*)(uintptr_t)teb; + } + next_entry = *(volatile uintptr_t*)(uintptr_t)next_entry; + } + return NULL; +} + +static void* wine_public_teb_for_stack_pointer(uintptr_t stack) { + if (g_ntdll_image_base == 0 || stack == 0) { + return NULL; + } + uintptr_t list_head = g_ntdll_image_base + 0x98530u; + uintptr_t next_entry = *(volatile uintptr_t*)(uintptr_t)list_head; + for (size_t pass = 0; pass < 256 && next_entry != list_head; pass++) { + if (next_entry < 0x3b0u || ((next_entry - 0x3b0u) & 0xffffu) != 0) { + return NULL; + } + uintptr_t teb = next_entry - 0x3b0u; + uintptr_t stack_base = 0; + uintptr_t stack_limit = 0; + if (wine_read_word((void*)(teb + 0x8u), &stack_base) && wine_read_word((void*)(teb + 0x10u), &stack_limit) && + stack_limit <= stack && stack < stack_base) { + return (void*)(uintptr_t)teb; + } + next_entry = *(volatile uintptr_t*)(uintptr_t)next_entry; + } + return NULL; +} + +static uintptr_t wine_native_tsd_for_thread(uint32_t thread) { + uint64_t count = g_tsd_callback_count; + if (count > sizeof(g_tsd_callback_records) / sizeof(g_tsd_callback_records[0])) { + count = sizeof(g_tsd_callback_records) / sizeof(g_tsd_callback_records[0]); + } + while (count != 0) { + count--; + MsTsdCallbackRecord* record = &g_tsd_callback_records[count]; + if (record->thread == thread && record->requested != 0 && (record->requested & 0xffffu) != 0) { + return record->requested; + } + } + return 0; +} + +static uintptr_t wine_current_thread_token(void) { + uintptr_t stack = 0; +#if defined(__x86_64__) + __asm__ volatile("movq %%rsp, %0" : "=r"(stack)); +#endif + return stack & ~(uintptr_t)0xffffu; +} + +static bool wine_thread_bridge_lookup(uintptr_t thread, uintptr_t* guest_teb, uintptr_t* host_tsd, + uint32_t* host_depth) { + bool found = false; + while (__sync_lock_test_and_set(&g_wine_thread_bridge_lock, 1) != 0) { + __builtin_ia32_pause(); + } + for (size_t index = 0; index < sizeof(g_wine_thread_bridge_states) / sizeof(g_wine_thread_bridge_states[0]); + index++) { + MsWineThreadBridgeState* state = &g_wine_thread_bridge_states[index]; + if (!state->used || state->thread != thread) { + continue; + } + if (guest_teb != NULL) { + *guest_teb = state->guest_teb; + } + if (host_tsd != NULL) { + *host_tsd = state->host_tsd; + } + if (host_depth != NULL) { + *host_depth = state->host_depth; + } + found = true; + break; + } + __sync_lock_release(&g_wine_thread_bridge_lock); + return found; +} + +static MsWineThreadBridgeState* wine_thread_bridge_state(uintptr_t thread) { + MsWineThreadBridgeState* result = NULL; + while (__sync_lock_test_and_set(&g_wine_thread_bridge_lock, 1) != 0) { + __builtin_ia32_pause(); + } + MsWineThreadBridgeState* free_state = NULL; + for (size_t index = 0; index < sizeof(g_wine_thread_bridge_states) / sizeof(g_wine_thread_bridge_states[0]); + index++) { + MsWineThreadBridgeState* state = &g_wine_thread_bridge_states[index]; + if (state->used && state->thread == thread) { + result = state; + break; + } + if (!state->used && free_state == NULL) { + free_state = state; + } + } + if (result == NULL && free_state != NULL) { + free_state->used = true; + free_state->thread = thread; + free_state->guest_teb = 0; + free_state->host_tsd = 0; + free_state->host_depth = 0; + result = free_state; + } + __sync_lock_release(&g_wine_thread_bridge_lock); + return result; +} + +/* Capture the real Darwin pthread TSD base before Wine's ntdll switches GS + * to the Windows TEB. On Darwin x86-64 libsystem_pthread keeps the native + * TSD object at pthread_self()+0xe0; Mach's thread_handle is not equivalent + * on the Rosetta Wine thread. */ +__attribute__((constructor(101))) static void capture_native_host_tsd_base(void) { + pthread_t self = pthread_self(); + if (self != (pthread_t)0) { + g_native_host_pthread = self; + g_native_host_tsd_base = (uintptr_t)self + 0xe0u; + } +} + +#define MS_LINUX_MEMFD_CREATE 319 +#define MS_MFD_CLOEXEC 0x0001u +#define MS_LINUX_READ 0 +#define MS_LINUX_WRITE 1 +#define MS_LINUX_OPEN 2 +#define MS_LINUX_CLOSE 3 +#define MS_LINUX_LSEEK 8 +#define MS_LINUX_MMAP 9 +#define MS_LINUX_MPROTECT 10 +#define MS_LINUX_MUNMAP 11 +#define MS_LINUX_GETPID 39 +#define MS_LINUX_GETPPID 110 +#define MS_LINUX_FSTAT 5 +#define MS_LINUX_FTRUNCATE 77 +#define MS_LINUX_GETRANDOM 318 + +enum { + MS_DT_NULL = 0, + MS_DT_NEEDED = 1, + MS_DT_PLTRELSZ = 2, + MS_DT_HASH = 4, + MS_DT_STRTAB = 5, + MS_DT_SYMTAB = 6, + MS_DT_RELA = 7, + MS_DT_RELASZ = 8, + MS_DT_RELAENT = 9, + MS_DT_STRSZ = 10, + MS_DT_SYMENT = 11, + MS_DT_INIT = 12, + MS_DT_FINI = 13, + MS_DT_SONAME = 14, + MS_DT_RPATH = 15, + MS_DT_SYMBOLIC = 16, + MS_DT_PLTREL = 20, + MS_DT_JMPREL = 23, + MS_DT_INIT_ARRAY = 25, + MS_DT_FINI_ARRAY = 26, + MS_DT_INIT_ARRAYSZ = 27, + MS_DT_FINI_ARRAYSZ = 28, + MS_DT_GNU_HASH = 0x6ffffef5, + MS_DT_VERNEED = 0x6ffffffe, + MS_DT_VERNEEDNUM = 0x6fffffff, +}; + +enum { + MS_R_X86_64_64 = 1, + MS_R_X86_64_GLOB_DAT = 6, + MS_R_X86_64_JUMP_SLOT = 7, + MS_R_X86_64_RELATIVE = 8, + MS_R_X86_64_DTPMOD64 = 16, +}; + +static int metalsharp_linux_open_flags(int flags) { + int translated = 0; + switch (flags & 3) { + case 1: + translated |= O_WRONLY; + break; + case 2: + translated |= O_RDWR; + break; + default: + translated |= O_RDONLY; + break; + } + if ((flags & 000100) != 0) { + translated |= O_CREAT; + } + if ((flags & 000200) != 0) { + translated |= O_EXCL; + } + if ((flags & 001000) != 0) { + translated |= O_TRUNC; + } + if ((flags & 002000) != 0) { + translated |= O_APPEND; + } + if ((flags & 004000) != 0) { + translated |= O_NONBLOCK; + } +#ifdef O_CLOEXEC + if ((flags & 02000000) != 0) { + translated |= O_CLOEXEC; + } +#endif + return translated; +} + +static int ms_raw_open(const char* path, int flags, mode_t mode); +static void ms_log(const char* format, ...); +static bool is_proc_maps_path(const char* path); +extern int __sigaction(int signal, const struct sigaction* action, struct sigaction* old_action); +extern int __platform_sigaction(int signal, const struct sigaction* action, struct sigaction* old_action); +extern int32_t ntdll_get_unix_file_name(const uint16_t* dos, char** unix_name, unsigned int disposition) + __attribute__((weak_import)); + +static int resolve_real_sigaction(void) { + if (g_real_sigaction == NULL) { + /* libsystem_platform's public-layout entry performs the normal + * Darwin signal-trampoline conversion before calling the kernel. + * Calling libsystem_c's exported sigaction through dyld is recursive + * once this library is interposed. */ + g_real_sigaction = __platform_sigaction; + } + return g_real_sigaction != NULL; +} + +static int metalsharp_memfd_create(unsigned int flags) { + /* memfd_create() returns an anonymous, seekable file descriptor. An + * unlinked temporary file has the same lifetime and mmap/ftruncate + * semantics on Darwin, while keeping the implementation inside the + * existing host kernel rather than asking macOS for a Linux syscall. */ + unsigned int sequence = (unsigned int)__atomic_fetch_add(&g_memfd_sequence, 1, __ATOMIC_RELAXED); + char path[128]; + int length = snprintf(path, sizeof(path), "/tmp/metalsharp-eac-memfd-%d-%u", (int)getpid(), sequence); + if (length <= 0 || (size_t)length >= sizeof(path)) { + errno = EINVAL; + return -1; + } + int fd = ms_raw_open(path, O_RDWR | O_CREAT | O_EXCL, 0600); + if (fd < 0) { + return -1; + } + (void)unlink(path); + if ((flags & MS_MFD_CLOEXEC) != 0) { + (void)fcntl(fd, F_SETFD, FD_CLOEXEC); + } + return fd; +} + +static uint64_t metalsharp_linux_syscall_result(ssize_t result) { + if (result >= 0) { + return (uint64_t)result; + } + return (uint64_t)(intptr_t)-errno; +} + +static void metalsharp_sigsys_handler(int signal, siginfo_t* siginfo, void* context) { + ucontext_t* ucontext = (ucontext_t*)context; +#if defined(__x86_64__) + if (ucontext != NULL && ucontext->uc_mcontext != NULL) { + ms_log("received SIGSYS rax=%llu rip=0x%llx", (unsigned long long)ucontext->uc_mcontext->__ss.__rax, + (unsigned long long)ucontext->uc_mcontext->__ss.__rip); + } + if (ucontext != NULL && ucontext->uc_mcontext != NULL && + (uint64_t)ucontext->uc_mcontext->__ss.__rax == MS_LINUX_MEMFD_CREATE) { + unsigned int flags = (unsigned int)ucontext->uc_mcontext->__ss.__rsi; + int fd = metalsharp_memfd_create(flags); + if (fd >= 0) { + ucontext->uc_mcontext->__ss.__rax = (uint64_t)fd; + } else { + ucontext->uc_mcontext->__ss.__rax = (uint64_t)(intptr_t)-errno; + } + /* Rosetta delivers SIGSYS with RIP already advanced past `syscall`. + * Re-executing or manually advancing it would loop on the same + * instruction. */ + ms_log("translated Linux memfd_create flags=0x%x -> fd=%d", flags, fd); + return; + } + if (ucontext != NULL && ucontext->uc_mcontext != NULL && + (uint64_t)ucontext->uc_mcontext->__ss.__rax == MS_LINUX_WRITE) { + int fd = (int)ucontext->uc_mcontext->__ss.__rdi; + const void* buffer = (const void*)(uintptr_t)ucontext->uc_mcontext->__ss.__rsi; + size_t length = (size_t)ucontext->uc_mcontext->__ss.__rdx; + ssize_t result = write(fd, buffer, length); + ucontext->uc_mcontext->__ss.__rax = metalsharp_linux_syscall_result(result); + ms_log("translated Linux write fd=%d buffer=0x%llx length=0x%zx -> %lld", fd, + (unsigned long long)(uintptr_t)buffer, length, (long long)result); + return; + } + if (ucontext != NULL && ucontext->uc_mcontext != NULL && + (uint64_t)ucontext->uc_mcontext->__ss.__rax == MS_LINUX_READ) { + int fd = (int)ucontext->uc_mcontext->__ss.__rdi; + void* buffer = (void*)(uintptr_t)ucontext->uc_mcontext->__ss.__rsi; + size_t length = (size_t)ucontext->uc_mcontext->__ss.__rdx; + ssize_t result = read(fd, buffer, length); + ucontext->uc_mcontext->__ss.__rax = metalsharp_linux_syscall_result(result); + ms_log("translated Linux read fd=%d buffer=0x%llx length=0x%zx -> %lld", fd, + (unsigned long long)(uintptr_t)buffer, length, (long long)result); + return; + } + if (ucontext != NULL && ucontext->uc_mcontext != NULL && + (uint64_t)ucontext->uc_mcontext->__ss.__rax == MS_LINUX_CLOSE) { + int fd = (int)ucontext->uc_mcontext->__ss.__rdi; + int result = close(fd); + ucontext->uc_mcontext->__ss.__rax = metalsharp_linux_syscall_result(result); + ms_log("translated Linux close fd=%d -> %d", fd, result); + return; + } + if (ucontext != NULL && ucontext->uc_mcontext != NULL && + (uint64_t)ucontext->uc_mcontext->__ss.__rax == MS_LINUX_GETPID) { + pid_t result = getpid(); + ucontext->uc_mcontext->__ss.__rax = (uint64_t)result; + ms_log("translated Linux getpid -> %d", (int)result); + return; + } + if (ucontext != NULL && ucontext->uc_mcontext != NULL && + (uint64_t)ucontext->uc_mcontext->__ss.__rax == MS_LINUX_OPEN) { + const char* requested = (const char*)(uintptr_t)ucontext->uc_mcontext->__ss.__rdi; + int linux_flags = (int)ucontext->uc_mcontext->__ss.__rsi; + mode_t mode = (mode_t)ucontext->uc_mcontext->__ss.__rdx; + int flags = metalsharp_linux_open_flags(linux_flags); + const char* path = requested; + if (is_proc_maps_path(requested) && g_maps_path[0] != '\0') { + path = g_maps_path; + } + int result = (flags & O_CREAT) != 0 ? ms_raw_open(path, flags, mode) : ms_raw_open(path, flags, 0); + ucontext->uc_mcontext->__ss.__rax = metalsharp_linux_syscall_result(result); + ms_log("translated Linux open path=%s flags=0x%x -> %d", path != NULL ? path : "", linux_flags, result); + return; + } +#else + (void)ucontext; +#endif + + /* Preserve Wine 11.5's normal SIGSYS-to-SEH path for every syscall that + * is not part of the implemented Linux substrate. */ + if (__atomic_load_n(&g_wine_sigsys_action_valid, __ATOMIC_ACQUIRE)) { + if ((g_wine_sigsys_action.sa_flags & SA_SIGINFO) != 0 && g_wine_sigsys_action.sa_sigaction != NULL) { + g_wine_sigsys_action.sa_sigaction(signal, siginfo, context); + } else if (g_wine_sigsys_action.sa_handler != NULL && g_wine_sigsys_action.sa_handler != SIG_DFL && + g_wine_sigsys_action.sa_handler != SIG_IGN) { + g_wine_sigsys_action.sa_handler(signal); + } + } +} + +static int metalsharp_sigaction(int signal, const struct sigaction* action, struct sigaction* old_action) { + if (!resolve_real_sigaction()) { + errno = ENOSYS; + return -1; + } + if (action == NULL) { + return g_real_sigaction(signal, action, old_action); + } + + if (signal == SIGSEGV) { + g_wine_sigsegv_action = *action; + __atomic_store_n(&g_wine_sigsegv_action_valid, 1, __ATOMIC_RELEASE); + struct sigaction wrapped = *action; + wrapped.sa_flags |= SA_SIGINFO; + wrapped.sa_sigaction = metalsharp_sigsegv_handler; + return g_real_sigaction(signal, &wrapped, old_action); + } + + if (signal != SIGSYS) { + return g_real_sigaction(signal, action, old_action); + } + + ms_log("intercepting Wine SIGSYS registration flags=0x%x", action->sa_flags); + g_wine_sigsys_action = *action; + __atomic_store_n(&g_wine_sigsys_action_valid, 1, __ATOMIC_RELEASE); + struct sigaction wrapped = *action; + wrapped.sa_flags |= SA_SIGINFO; + wrapped.sa_sigaction = metalsharp_sigsys_handler; + return g_real_sigaction(signal, &wrapped, old_action); +} + +/* + * DYLD_INTERPOSE applies before constructors run. Calling dlsym(RTLD_NEXT, + * "open") from that early path can re-enter dyld while it is loading Wine. + * Darwin's syscall ABI gives the interposer a small, non-recursive escape + * hatch for ordinary host files and for the temporary procfs view. + */ +static int ms_raw_open(const char* path, int flags, mode_t mode) { +#ifdef SYS_open + return (int)syscall(SYS_open, path, flags, mode); +#else + (void)path; + (void)flags; + (void)mode; + errno = ENOSYS; + return -1; +#endif +} + +static int ms_raw_openat(int dirfd, const char* path, int flags, mode_t mode) { +#ifdef SYS_openat + return (int)syscall(SYS_openat, dirfd, path, flags, mode); +#else + (void)dirfd; + return ms_raw_open(path, flags, mode); +#endif +} + +static void ms_log(const char* format, ...) { + if (g_log_path[0] == '\0') { + const char* configured = getenv("METALSHARP_EAC_SUBSTRATE_LOG"); + if (configured != NULL && configured[0] != '\0') { + snprintf(g_log_path, sizeof(g_log_path), "%s", configured); + } else { + snprintf(g_log_path, sizeof(g_log_path), "/tmp/metalsharp-eac-substrate-%d.log", (int)getpid()); + } + } + + char line[2048]; + va_list args; + va_start(args, format); + int length = vsnprintf(line, sizeof(line), format, args); + va_end(args); + if (length <= 0) { + return; + } + if ((size_t)length > sizeof(line) - 1) { + length = (int)(sizeof(line) - 1); + } + + int fd = ms_raw_open(g_log_path, O_WRONLY | O_CREAT | O_APPEND, 0644); + if (fd >= 0) { + (void)write(fd, line, (size_t)length); + (void)write(fd, "\n", 1); + (void)close(fd); + } +} + +/* ntdll.so imports this private Darwin entry through a lazy symbol stub. A + * Wine thread can switch to a newly-created TEB after the EAC Linux module + * has been loaded, so the PE TLS pointer must follow that switch rather than + * being initialized only on the loader's current thread. Keep the real + * syscall wrapper behind RTLD_NEXT and only touch 64-KiB-aligned guest bases; + * the native Darwin pthread base used by the host-TSD guard is not aligned + * that way. */ +static void metalsharp_thread_set_tsd_base(void* base) { + if (g_real_thread_set_tsd_base == NULL) { + g_real_thread_set_tsd_base = g_thread_set_tsd_base; + } + void* effective_base = base; + __sync_fetch_and_add(&g_tsd_callback_count, 1); + g_tsd_last_requested = (uintptr_t)base; + if (g_wine_pe_tls_array != NULL && base != NULL && (((uintptr_t)base & 0xffffu) != 0)) { + void* public_teb = wine_public_teb_for_tsd_base((uintptr_t)base); + if (public_teb != NULL) { + effective_base = public_teb; + __sync_fetch_and_add(&g_tsd_mapped_count, 1); + volatile uintptr_t* tls_pointer = (volatile uintptr_t*)((uint8_t*)public_teb + 0x58u); + if (*tls_pointer == 0) { + *tls_pointer = (uintptr_t)g_wine_pe_tls_array; + } else if (*(volatile uintptr_t*)(uintptr_t)*tls_pointer == 0 && g_wine_pe_tls_block != NULL) { + *(volatile uintptr_t*)(uintptr_t)*tls_pointer = (uintptr_t)g_wine_pe_tls_block; + } + } + } + if (base != NULL && (((uintptr_t)base & 0xffffu) == 0)) { + /* This callback is reached from ntdll's private TSD stub, where Mach + * VM calls are unsafe because Rosetta is in the middle of a guest + * signal/TSD transition. The two private fields are in the valid + * ntdll-created stack pseudo-TEB, so direct loads are sufficient to + * distinguish it from the public TEB. */ + uintptr_t syscall_table = *(volatile uintptr_t*)((uint8_t*)base + 0x370u); + uintptr_t syscall_frame = *(volatile uintptr_t*)((uint8_t*)base + 0x378u); + if (syscall_table == 0 && syscall_frame == 0) { + /* Wine's signal-only stack TEB is not in the public TEB list. + * Resolve it through the current stack arena, never through a + * process-global "last guest" value: a worker callback can race + * the loader thread and otherwise receive another thread's GS + * base. */ + uintptr_t mapped_guest = 0; + if (wine_thread_bridge_lookup(wine_current_thread_token(), &mapped_guest, NULL, NULL) && + mapped_guest != 0) { + __sync_fetch_and_add(&g_tsd_pseudo_count, 1); + effective_base = (void*)(uintptr_t)mapped_guest; + __sync_fetch_and_add(&g_tsd_mapped_count, 1); + } + } + } + g_tsd_last_effective = (uintptr_t)effective_base; + size_t record_index = (size_t)__sync_fetch_and_add(&g_tsd_callback_count, 0); + if (record_index != 0) { + record_index--; + if (record_index < sizeof(g_tsd_callback_records) / sizeof(g_tsd_callback_records[0])) { + mach_port_t callback_thread = mach_thread_self(); + g_tsd_callback_records[record_index].requested = (uintptr_t)base; + g_tsd_callback_records[record_index].effective = (uintptr_t)effective_base; + g_tsd_callback_records[record_index].thread = (uint32_t)callback_thread; + mach_port_deallocate(mach_task_self(), callback_thread); + } + } + if (g_real_thread_set_tsd_base != NULL) { + g_real_thread_set_tsd_base(effective_base); + } +} + +static void patch_wine115_tsd_stub(void) { + if (g_ntdll_tsd_stub_patched || g_ntdll_image_base == 0) { + return; + } + + /* ntdll.so from the installed MetalSharp Wine 11.5 runtime calls its + * private Darwin TSD syscall through the resolved lazy pointer at this + * stable RVA. Rosetta may translate the six-byte code stub itself, so + * patch the data pointer rather than assuming its instruction bytes are + * still x86-encoded in the translated mapping. */ + const mach_vm_address_t target = (mach_vm_address_t)g_ntdll_image_base + 0x951e0u; + uintptr_t current = 0; + mach_vm_size_t read_size = 0; + kern_return_t read_result = + mach_vm_read_overwrite(mach_task_self(), target, sizeof(current), (mach_vm_address_t)¤t, &read_size); + if (read_result != KERN_SUCCESS || read_size != sizeof(current)) { + mach_vm_address_t region = target; + mach_vm_size_t region_size = 0; + vm_region_basic_info_data_64_t region_info = {0}; + mach_msg_type_number_t region_count = VM_REGION_BASIC_INFO_COUNT_64; + mach_port_t object_name = MACH_PORT_NULL; + kern_return_t region_result = mach_vm_region(mach_task_self(), ®ion, ®ion_size, VM_REGION_BASIC_INFO_64, + (vm_region_info_t)®ion_info, ®ion_count, &object_name); + if (object_name != MACH_PORT_NULL) { + mach_port_deallocate(mach_task_self(), object_name); + } + ms_log("Wine 11.5 TSD pointer patch skipped target=0x%llx read_result=%d read_size=%llu region_result=%d " + "region=0x%llx region_size=0x%llx prot=0x%x", + (unsigned long long)target, read_result, (unsigned long long)read_size, region_result, + (unsigned long long)region, (unsigned long long)region_size, region_info.protection); + /* Rosetta's translated Mach-O data-const pages reject + * mach_vm_read_overwrite even though the x86 guest can read them. + * The region query above proves this exact pointer is mapped and + * readable, so use the native load as the fallback. */ + if (region_result == KERN_SUCCESS && (region_info.protection & VM_PROT_READ) != 0 && region <= target && + target + sizeof(current) <= region + region_size) { + current = *(volatile uintptr_t*)(uintptr_t)target; + read_size = sizeof(current); + read_result = KERN_SUCCESS; + ms_log("Wine 11.5 TSD pointer direct read current=0x%llx", (unsigned long long)current); + } else { + return; + } + } + uintptr_t replacement = (uintptr_t)&metalsharp_thread_set_tsd_base; + mach_vm_address_t page = target & ~(mach_vm_address_t)(MS_PAGE_SIZE - 1u); + (void)mach_vm_protect(mach_task_self(), page, MS_PAGE_SIZE, false, VM_PROT_READ | VM_PROT_WRITE); + kern_return_t result = mach_vm_write(mach_task_self(), target, (vm_offset_t)&replacement, sizeof(replacement)); + (void)mach_vm_protect(mach_task_self(), page, MS_PAGE_SIZE, false, VM_PROT_READ); + if (result == KERN_SUCCESS) { + g_ntdll_tsd_stub_patched = 1; + } + ms_log("Wine 11.5 TSD pointer patch target=0x%llx current=0x%llx replacement=0x%llx result=%d", + (unsigned long long)target, (unsigned long long)current, (unsigned long long)replacement, result); +} + +/* + * Wine's x86-64 Darwin signal layer uses the private Darwin + * __thread_set_tsd_base entry to put the Windows TEB in the x86 GS base. + * That is correct for guest code, but Darwin's libsystem pthread routines + * use the same GS base for their native pthread object. Calling a host + * routine such as pthread_setspecific while GS points at the TEB therefore + * writes into the wrong address space and faults. + * + * Darwin's thread_identifier_info.thread_handle is the native TSD base used + * by libsystem_pthread. Temporarily selecting it around calls made from the + * Linux ELF module preserves both ABIs without changing Wine's runtime or + * the EAC image. The guard is nestable because the loader calls dlsym and + * other bridge functions while a module entry point is active. + */ +static bool resolve_host_tsd_bridge(void) { + if (g_thread_set_tsd_base == NULL) { + /* Resolve against libsystem_kernel itself so DYLD interpose does not + * hand the callback its own replacement and recurse during Wine's + * early thread setup. */ + g_libsystem_kernel = dlopen("/usr/lib/system/libsystem_kernel.dylib", RTLD_NOW | RTLD_LOCAL); + if (g_libsystem_kernel != NULL) { + /* dlsym(handle, name) still observes the process-wide interpose + * table for this private symbol. Anchor the exact x86_64 + * libsystem_kernel export through its non-interposed syscall + * symbol and its installed MetalSharp/Rosetta-compatible RVA. */ + void* syscall_symbol = dlsym(g_libsystem_kernel, "syscall"); + Dl_info kernel_image = {0}; + if (syscall_symbol != NULL && dladdr(syscall_symbol, &kernel_image) != 0 && + kernel_image.dli_fbase != NULL) { + g_thread_set_tsd_base = (MsThreadSetTsdBaseFn)((uint8_t*)kernel_image.dli_fbase + 0x2c150u); + } + } + if (g_thread_set_tsd_base == NULL) { + g_thread_set_tsd_base = (MsThreadSetTsdBaseFn)dlsym(RTLD_NEXT, "_thread_set_tsd_base"); + } + } + /* The lazy ntdll stub can call the interposed replacement while Wine is + * still switching a thread into its guest TSD state. Never resolve + * dyld from that callback: cache the original Darwin syscall while the + * constructor is still on the native host TSD base. */ + if (g_real_thread_set_tsd_base == NULL) { + g_real_thread_set_tsd_base = g_thread_set_tsd_base; + } + if (g_nt_current_teb == NULL) { + g_nt_current_teb = (MsNtCurrentTebFn)dlsym(RTLD_DEFAULT, "NtCurrentTeb"); + } + return g_thread_set_tsd_base != NULL && g_nt_current_teb != NULL; +} + +static bool patch_ntdll_unix_name(void); + +static void resolve_host_pthread_symbols(void) { + if (g_host_pthread_setspecific != NULL && g_host_pthread_getspecific != NULL && g_host_pthread_self != NULL) { + return; + } + g_libsystem_pthread = dlopen("/usr/lib/system/libsystem_pthread.dylib", RTLD_NOW | RTLD_LOCAL); + if (g_libsystem_pthread != NULL) { + void* pthread_create_symbol = dlsym(g_libsystem_pthread, "pthread_create"); + Dl_info pthread_image = {0}; + if (pthread_create_symbol != NULL && dladdr(pthread_create_symbol, &pthread_image) != 0 && + pthread_image.dli_fbase != NULL) { + uintptr_t image_base = (uintptr_t)pthread_image.dli_fbase; + /* These are the exported x86_64 entry RVAs in the installed + * macOS libsystem_pthread used by the MetalSharp Rosetta + * process. Resolve through pthread_create, which is not + * interposed, to avoid recursive dlsym lookups. */ + g_host_pthread_getspecific = (MsHostPthreadGetspecificFn)(image_base + 0x18d9u); + g_host_pthread_setspecific = (MsHostPthreadSetspecificFn)(image_base + 0x18e3u); + g_host_pthread_self = (MsHostPthreadSelfFn)(image_base + 0x2147u); + } + } + if (g_host_pthread_setspecific == NULL) { + g_host_pthread_setspecific = (MsHostPthreadSetspecificFn)dlsym(RTLD_NEXT, "pthread_setspecific"); + } + if (g_host_pthread_getspecific == NULL) { + g_host_pthread_getspecific = (MsHostPthreadGetspecificFn)dlsym(RTLD_NEXT, "pthread_getspecific"); + } + if (g_host_pthread_self == NULL) { + g_host_pthread_self = (MsHostPthreadSelfFn)dlsym(RTLD_NEXT, "pthread_self"); + } +} + +static void resolve_nt_current_teb_from_wine(void) { + if (g_nt_current_teb != NULL) { + return; + } + + if (g_ntdll_unix_name_target == NULL) { + (void)patch_ntdll_unix_name(); + } + if (g_ntdll_unix_name_target != NULL) { + Dl_info image = {0}; + if (dladdr(g_ntdll_unix_name_target, &image) != 0 && image.dli_fbase != NULL) { + g_ntdll_image_base = (uintptr_t)image.dli_fbase; + g_nt_current_teb = (MsNtCurrentTebFn)((uint8_t*)image.dli_fbase + 0x68e20u); + ms_log("resolved Wine 11.5 NtCurrentTeb from ntdll base=0x%llx target=0x%llx", + (unsigned long long)(uintptr_t)image.dli_fbase, (unsigned long long)(uintptr_t)g_nt_current_teb); + patch_wine115_tsd_stub(); + return; + } + } + + /* NtCurrentTeb is a local Unix-library symbol rather than a dyld export. + * Locate its stable Wine 11.5 prologue directly in the executable + * ntdll.so mapping instead of calling dlsym while the guest TSD base is + * active. */ + static const uint8_t pattern[] = { + 0x55, 0x48, 0x89, 0xe5, 0x48, 0x8b, 0x3d, 0, 0, 0, 0, 0xe8, 0, 0, 0, 0, 0x5d, 0xc3, + }; + mach_vm_address_t cursor = 0; + while (cursor < UINT64_MAX) { + mach_vm_address_t region_start = cursor; + mach_vm_size_t region_size = 0; + vm_region_basic_info_data_64_t info; + mach_msg_type_number_t info_count = VM_REGION_BASIC_INFO_COUNT_64; + mach_port_t object_name = MACH_PORT_NULL; + kern_return_t result = mach_vm_region(mach_task_self(), ®ion_start, ®ion_size, VM_REGION_BASIC_INFO_64, + (vm_region_info_t)&info, &info_count, &object_name); + if (object_name != MACH_PORT_NULL) { + mach_port_deallocate(mach_task_self(), object_name); + } + if (result != KERN_SUCCESS || region_size == 0) { + break; + } + cursor = region_start + region_size; + if ((info.protection & (VM_PROT_READ | VM_PROT_EXECUTE)) != (VM_PROT_READ | VM_PROT_EXECUTE)) { + continue; + } + for (mach_vm_size_t offset = 0; offset < region_size;) { + mach_vm_size_t request = region_size - offset; + if (request > 0x10000) { + request = 0x10000; + } + uint8_t bytes[0x10000]; + mach_vm_size_t read_size = 0; + if (mach_vm_read_overwrite(mach_task_self(), region_start + offset, request, (mach_vm_address_t)bytes, + &read_size) == KERN_SUCCESS) { + for (mach_vm_size_t index = 0; index + sizeof(pattern) <= read_size; index++) { + bool match = true; + for (size_t byte = 0; byte < sizeof(pattern); byte++) { + if (byte != 7 && byte != 8 && byte != 9 && byte != 10 && byte != 12 && byte != 13 && + byte != 14 && byte != 15 && bytes[index + byte] != pattern[byte]) { + match = false; + break; + } + } + if (match) { + g_nt_current_teb = (MsNtCurrentTebFn)(uintptr_t)(region_start + offset + index); + ms_log("found exact Wine 11.5 NtCurrentTeb at 0x%llx", + (unsigned long long)(uintptr_t)g_nt_current_teb); + return; + } + } + } + if (request == 0) { + break; + } + offset += request; + } + } + if (g_nt_current_teb == NULL) { + ms_log("Wine 11.5 NtCurrentTeb prologue was not found in executable mappings"); + } +} + +static void* wine_guest_teb_from_stack(void) { +#if defined(__x86_64__) + uintptr_t stack = 0; + /* The exact Wine 11.5 ntdll `_get_current_teb` implementation derives + * the TEB from the 64-KiB-aligned Wine thread stack. Use that same + * guest-owned rule here; reading an x86 segment register from a Darwin + * host function is not valid under Rosetta. */ + __asm__ volatile("movq %%rsp, %0" : "=r"(stack)); + return (void*)(stack & ~(uintptr_t)0xffffu); +#else + return NULL; +#endif +} + +static bool wine_read_word(void* address, uintptr_t* value) { + mach_vm_size_t read_size = 0; + return value != NULL && + mach_vm_read_overwrite(mach_task_self(), (mach_vm_address_t)(uintptr_t)address, sizeof(*value), + (mach_vm_address_t)value, &read_size) == KERN_SUCCESS && + read_size == sizeof(*value); +} + +static void* wine_guest_teb_from_identifier(const thread_identifier_info_data_t* identifier) { + if (identifier != NULL && identifier->thread_handle != 0 && (identifier->thread_handle & 0xffffu) == 0) { + void* candidate = (void*)(uintptr_t)identifier->thread_handle; + uintptr_t self = 0; + uintptr_t syscall_table = 0; + uintptr_t syscall_frame = 0; + if (wine_read_word((uint8_t*)candidate + 0x30u, &self) && + wine_read_word((uint8_t*)candidate + 0x370u, &syscall_table) && + wine_read_word((uint8_t*)candidate + 0x378u, &syscall_frame) && self == (uintptr_t)candidate && + (syscall_table != 0 || syscall_frame != 0)) { + return candidate; + } + } + return wine_guest_teb_from_stack(); +} + +static void* wine_guest_teb_for_current_thread(void) { + uintptr_t current_token = wine_current_thread_token(); + uintptr_t remembered_guest = 0; + if (wine_thread_bridge_lookup(current_token, &remembered_guest, NULL, NULL) && remembered_guest != 0) { + return (void*)(uintptr_t)remembered_guest; + } + thread_identifier_info_data_t identifier = {0}; + mach_msg_type_number_t count = THREAD_IDENTIFIER_INFO_COUNT; + mach_port_t thread = mach_thread_self(); + kern_return_t result = thread_info(thread, THREAD_IDENTIFIER_INFO, (thread_info_t)&identifier, &count); + mach_port_deallocate(mach_task_self(), thread); + if (result != KERN_SUCCESS) { + return wine_guest_teb_from_stack(); + } + return wine_guest_teb_from_identifier(&identifier); +} + +static bool wine_write_word(void* address, uintptr_t value) { + return mach_vm_write(mach_task_self(), (mach_vm_address_t)(uintptr_t)address, (vm_offset_t)&value, sizeof(value)) == + KERN_SUCCESS; +} + +static size_t repair_wine_teb(void* teb, uintptr_t replacement) { + if (teb == NULL) { + return 0; + } + uintptr_t self = 0; + uintptr_t tls_pointer = 0; + uintptr_t tsd_teb = 0; + uintptr_t syscall_table = 0; + uintptr_t syscall_frame = 0; + bool self_read = wine_read_word((uint8_t*)teb + 0x30u, &self); + bool tls_read = wine_read_word((uint8_t*)teb + 0x58u, &tls_pointer); + bool tsd_read = wine_read_word((uint8_t*)teb + 0x320u, &tsd_teb); + bool table_read = wine_read_word((uint8_t*)teb + 0x370u, &syscall_table); + bool frame_read = wine_read_word((uint8_t*)teb + 0x378u, &syscall_frame); + bool public_teb = table_read && frame_read && (syscall_table != 0 || syscall_frame != 0); + size_t repaired = 0; + if (public_teb && self_read && self == 0 && wine_write_word((uint8_t*)teb + 0x30u, (uintptr_t)teb)) { + repaired++; + } + if (public_teb && tsd_read && tsd_teb == 0 && wine_write_word((uint8_t*)teb + 0x320u, (uintptr_t)teb)) { + repaired++; + } + if (tls_read) { + if (tls_pointer == 0) { + if (wine_write_word((uint8_t*)teb + 0x58u, replacement)) { + repaired++; + } + } else { + uintptr_t tls_block = 0; + if (wine_read_word((void*)(uintptr_t)tls_pointer, &tls_block) && tls_block == 0 && + wine_write_word((void*)(uintptr_t)tls_pointer, (uintptr_t)g_wine_pe_tls_block)) { + repaired++; + } + } + } + return repaired; +} + +static void maintain_wine_teb_list(void) { + if (g_ntdll_image_base == 0 || g_wine_pe_tls_array == NULL || g_wine_pe_tls_block == NULL) { + return; + } + __sync_fetch_and_add(&g_wine_tls_monitor_iterations, 1); + mach_vm_address_t list_head = (mach_vm_address_t)g_ntdll_image_base + 0x98530u; + uintptr_t next_entry = 0; + mach_vm_size_t next_size = 0; + if (mach_vm_read_overwrite(mach_task_self(), list_head, sizeof(next_entry), (mach_vm_address_t)&next_entry, + &next_size) != KERN_SUCCESS || + next_size != sizeof(next_entry)) { + return; + } + for (size_t pass = 0; pass < 256 && next_entry != (uintptr_t)list_head; pass++) { + if (next_entry < 0x3b0u || ((next_entry - 0x3b0u) & 0xffffu) != 0) { + return; + } + void* teb = (void*)(next_entry - 0x3b0u); + __sync_fetch_and_add(&g_wine_tls_monitor_tebs, 1); + size_t repaired = repair_wine_teb(teb, (uintptr_t)g_wine_pe_tls_array); + if (repaired != 0) { + __sync_fetch_and_add(&g_wine_tls_monitor_repairs, repaired); + uintptr_t repaired_self = 0; + uintptr_t repaired_tls = 0; + uintptr_t repaired_tls_block = 0; + uintptr_t repaired_tsd = 0; + (void)wine_read_word((uint8_t*)teb + 0x30u, &repaired_self); + (void)wine_read_word((uint8_t*)teb + 0x58u, &repaired_tls); + (void)wine_read_word((uint8_t*)teb + 0x320u, &repaired_tsd); + (void)wine_read_word((void*)(uintptr_t)repaired_tls, &repaired_tls_block); + ms_log("Wine PE TLS maintainer repaired teb=0x%llx fields=%zu self=0x%llx tls=0x%llx tls_block=0x%llx " + "tsd=0x%llx", + (unsigned long long)(uintptr_t)teb, repaired, (unsigned long long)repaired_self, + (unsigned long long)repaired_tls, (unsigned long long)repaired_tls_block, + (unsigned long long)repaired_tsd); + } + uintptr_t following = 0; + mach_vm_size_t following_size = 0; + if (mach_vm_read_overwrite(mach_task_self(), next_entry, sizeof(following), (mach_vm_address_t)&following, + &following_size) != KERN_SUCCESS || + following_size != sizeof(following)) { + return; + } + next_entry = following; + } +} + +/* `_get_current_teb` in this Wine 11.5 build derives a second, private TEB + * address from the current Windows stack. It is not linked in `_teb_list`, + * yet Rosetta can leave GS on that address while returning from a worker + * transition. The launcher fault observed on the exact external Elden + * Ring executable used the 0x01900000 stack TEB. Cover the small Wine + * stack-arena range directly, but only when all private syscall fields are + * zero; this avoids touching arbitrary application memory. */ +static void maintain_wine_stack_teb_tls(void) { + if (g_wine_pe_tls_array == NULL || g_wine_pe_tls_block == NULL) { + return; + } + static uint64_t scan_iteration; + if ((__sync_fetch_and_add(&scan_iteration, 1) % 10u) != 0) { + return; + } + for (uintptr_t base = 0x01800000u; base < 0x01c00000u; base += 0x10000u) { + uintptr_t self = 0; + uintptr_t tls_pointer = 0; + uintptr_t syscall_table = 0; + uintptr_t syscall_frame = 0; + if (!wine_read_word((void*)(base + 0x30u), &self) || !wine_read_word((void*)(base + 0x58u), &tls_pointer) || + !wine_read_word((void*)(base + 0x370u), &syscall_table) || + !wine_read_word((void*)(base + 0x378u), &syscall_frame)) { + continue; + } + if ((self != 0 && self != base) || syscall_table != 0 || syscall_frame != 0) { + continue; + } + size_t repaired = 0; + if (self == 0 && wine_write_word((void*)(base + 0x30u), base)) { + repaired++; + } + if (tls_pointer == 0 && wine_write_word((void*)(base + 0x58u), (uintptr_t)g_wine_pe_tls_array)) { + repaired++; + tls_pointer = (uintptr_t)g_wine_pe_tls_array; + } + if (tls_pointer != 0) { + uintptr_t tls_block = 0; + if (wine_read_word((void*)tls_pointer, &tls_block) && tls_block == 0 && + wine_write_word((void*)tls_pointer, (uintptr_t)g_wine_pe_tls_block)) { + repaired++; + } + } + if (repaired != 0) { + ms_log("Wine PE TLS stack-TEB maintainer repaired teb=0x%llx fields=%zu", (unsigned long long)base, + repaired); + } + } +} + +static void* wine_tls_monitor(void* unused) { + (void)unused; + for (;;) { + maintain_wine_teb_list(); + maintain_wine_stack_teb_tls(); + uint64_t iteration = g_wine_tls_monitor_iterations; + if (iteration != 0 && (iteration % 1000u) == 0) { + ms_log("Wine PE TLS maintainer heartbeat iterations=%llu tebs=%llu repairs=%llu", + (unsigned long long)iteration, (unsigned long long)g_wine_tls_monitor_tebs, + (unsigned long long)g_wine_tls_monitor_repairs); + ms_log("Wine TSD monitor diagnostics callbacks=%llu pseudo=%llu mapped=%llu last_requested=0x%llx " + "last_effective=0x%llx", + (unsigned long long)g_tsd_callback_count, (unsigned long long)g_tsd_pseudo_count, + (unsigned long long)g_tsd_mapped_count, (unsigned long long)g_tsd_last_requested, + (unsigned long long)g_tsd_last_effective); + uint64_t callback_count = g_tsd_callback_count; + uint64_t first_record = callback_count > 8 ? callback_count - 8 : 0; + for (uint64_t record = first_record; record < callback_count && record < 64; record++) { + ms_log("Wine TSD callback record=%llu thread=0x%x requested=0x%llx effective=0x%llx", + (unsigned long long)record, g_tsd_callback_records[record].thread, + (unsigned long long)g_tsd_callback_records[record].requested, + (unsigned long long)g_tsd_callback_records[record].effective); + } + } + usleep(1000); + } + return NULL; +} + +static void start_wine_tls_monitor(void) { + if (__sync_bool_compare_and_swap(&g_wine_tls_monitor_started, 0, 1)) { + if (pthread_create(&g_wine_tls_monitor_thread, NULL, wine_tls_monitor, NULL) == 0) { + (void)pthread_detach(g_wine_tls_monitor_thread); + ms_log("Wine PE TLS maintainer started"); + } else { + g_wine_tls_monitor_started = 0; + ms_log("Wine PE TLS maintainer could not start errno=%d", errno); + } + } +} + +static void ensure_wine_pe_tls(void) { + void* teb = wine_guest_teb_for_current_thread(); + if (teb == NULL) { + ms_log("Wine PE TLS setup skipped: guest TEB could not be derived from stack"); + return; + } + if (g_wine_pe_tls_array == NULL || g_wine_pe_tls_block == NULL) { + g_wine_pe_tls_array = mmap(NULL, MS_PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); + g_wine_pe_tls_block = mmap(NULL, MS_PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); + if (g_wine_pe_tls_array == MAP_FAILED || g_wine_pe_tls_block == MAP_FAILED) { + if (g_wine_pe_tls_array != NULL && g_wine_pe_tls_array != MAP_FAILED) { + munmap(g_wine_pe_tls_array, MS_PAGE_SIZE); + } + if (g_wine_pe_tls_block != NULL && g_wine_pe_tls_block != MAP_FAILED) { + munmap(g_wine_pe_tls_block, MS_PAGE_SIZE); + } + g_wine_pe_tls_array = NULL; + g_wine_pe_tls_block = NULL; + ms_log("Wine PE TLS setup allocation failed"); + return; + } + /* The launcher is the first TLS image in this Wine process. Keep + * slot zero populated; the block is zero-filled just like the + * executable's eight-byte TLS template. */ + ((uintptr_t*)g_wine_pe_tls_array)[0] = (uintptr_t)g_wine_pe_tls_block; + } + + uintptr_t replacement = (uintptr_t)g_wine_pe_tls_array; + size_t patched = 0; + + /* Always cover the caller first, even if the list is being changed by + * Wine's thread teardown path. */ + void* teb_cursor = teb; + patched += repair_wine_teb(teb_cursor, replacement); + + /* ThreadLocalStoragePointer is a field of every Wine TEB, not process + * global state. EAC's Linux loader can run on a helper thread while the + * Windows launcher later resumes on the process thread, so initialize all + * TEBs already linked by the exact Wine 11.5 ntdll. The list entry is + * TEB+0x3b0 in this runtime (thread_data+0xc0); the offsets are derived + * from ntdll.so's local _init_teb implementation and are intentionally + * kept alongside the pinned MetalSharp runtime contract. */ + if (g_ntdll_image_base != 0) { + mach_vm_address_t list_head = (mach_vm_address_t)g_ntdll_image_base + 0x98530u; + uintptr_t next_entry = 0; + mach_vm_size_t next_size = 0; + bool list_available = mach_vm_read_overwrite(mach_task_self(), list_head, sizeof(next_entry), + (mach_vm_address_t)&next_entry, &next_size) == KERN_SUCCESS && + next_size == sizeof(next_entry) && next_entry != (uintptr_t)list_head; + for (size_t pass = 0; list_available && pass < 128 && next_entry != (uintptr_t)list_head; pass++) { + /* A Wine TEB is allocated on a 64-KiB boundary. This check + * prevents a corrupt/transient list from turning the diagnostic + * bridge into a write through an arbitrary pointer. */ + if (next_entry < 0x3b0u || ((next_entry - 0x3b0u) & 0xffffu) != 0) { + break; + } + teb_cursor = (void*)(next_entry - 0x3b0u); + if ((uintptr_t)teb_cursor != (uintptr_t)teb) { + patched += repair_wine_teb(teb_cursor, replacement); + uintptr_t list_self = 0; + uintptr_t list_tls = 0; + uintptr_t list_tsd = 0; + uintptr_t list_table = 0; + uintptr_t list_frame = 0; + (void)wine_read_word((uint8_t*)teb_cursor + 0x30u, &list_self); + (void)wine_read_word((uint8_t*)teb_cursor + 0x58u, &list_tls); + (void)wine_read_word((uint8_t*)teb_cursor + 0x320u, &list_tsd); + (void)wine_read_word((uint8_t*)teb_cursor + 0x370u, &list_table); + (void)wine_read_word((uint8_t*)teb_cursor + 0x378u, &list_frame); + ms_log("Wine PE TLS list_teb=0x%llx self=0x%llx tls=0x%llx tsd_teb=0x%llx syscall_table=0x%llx " + "syscall_frame=0x%llx", + (unsigned long long)(uintptr_t)teb_cursor, (unsigned long long)list_self, + (unsigned long long)list_tls, (unsigned long long)list_tsd, (unsigned long long)list_table, + (unsigned long long)list_frame); + } + uintptr_t following = 0; + mach_vm_size_t following_size = 0; + if (mach_vm_read_overwrite(mach_task_self(), next_entry, sizeof(following), (mach_vm_address_t)&following, + &following_size) != KERN_SUCCESS || + following_size != sizeof(following)) { + break; + } + next_entry = following; + } + } + uintptr_t current_self = 0; + uintptr_t current_tls = 0; + uintptr_t current_tsd = 0; + uintptr_t current_syscall_table = 0; + uintptr_t current_syscall_frame = 0; + uintptr_t current_syscall_flags = 0; + uintptr_t current_tls_block = 0; + (void)wine_read_word((uint8_t*)teb + 0x30u, ¤t_self); + (void)wine_read_word((uint8_t*)teb + 0x58u, ¤t_tls); + (void)wine_read_word((uint8_t*)teb + 0x320u, ¤t_tsd); + (void)wine_read_word((uint8_t*)teb + 0x370u, ¤t_syscall_table); + (void)wine_read_word((uint8_t*)teb + 0x378u, ¤t_syscall_frame); + (void)wine_read_word((uint8_t*)teb + 0x380u, ¤t_syscall_flags); + (void)wine_read_word((void*)(uintptr_t)current_tls, ¤t_tls_block); + ms_log("Wine PE TLS setup current_teb=0x%llx self=0x%llx tls=0x%llx tls_block=0x%llx tsd_teb=0x%llx " + "syscall_table=0x%llx syscall_frame=0x%llx syscall_flags=0x%llx array=0x%llx block=0x%llx patched=%zu " + "ntdll=0x%llx", + (unsigned long long)(uintptr_t)teb, (unsigned long long)current_self, (unsigned long long)current_tls, + (unsigned long long)current_tls_block, (unsigned long long)current_tsd, + (unsigned long long)current_syscall_table, (unsigned long long)current_syscall_frame, + (unsigned long long)current_syscall_flags, (unsigned long long)(uintptr_t)g_wine_pe_tls_array, + (unsigned long long)(uintptr_t)g_wine_pe_tls_block, patched, (unsigned long long)g_ntdll_image_base); + + /* The Linux module runs under the temporary native Darwin TSD base. A + * host pthread callback can leave that base selected after its final + * nested return, especially when Wine resumes the PE caller from a + * helper thread. Re-establish the exact public Wine TEB at this ABI + * boundary before protected Windows code executes again. This is the + * same private Wine 11.5 transition used by ntdll; it is not a launcher + * instruction patch or a fault bypass. */ + uint32_t bridge_depth = 0; + (void)wine_thread_bridge_lookup(wine_current_thread_token(), NULL, NULL, &bridge_depth); + if (g_real_thread_set_tsd_base != NULL && bridge_depth == 0) { + g_last_guest_teb = teb; + g_real_thread_set_tsd_base(teb); + ms_log("restored Wine guest TSD base=0x%llx after PE TLS setup", (unsigned long long)(uintptr_t)teb); + } +} + +static void metalsharp_sigsegv_handler(int signal, siginfo_t* siginfo, void* context) { + static volatile sig_atomic_t report_count; + ucontext_t* ucontext = (ucontext_t*)context; + uintptr_t rip = 0; + uintptr_t rsp = 0; + uintptr_t context_gs = 0; + uintptr_t context_fs = 0; +#if defined(__x86_64__) + if (ucontext != NULL && ucontext->uc_mcontext != NULL) { + rip = (uintptr_t)ucontext->uc_mcontext->__ss.__rip; + rsp = (uintptr_t)ucontext->uc_mcontext->__ss.__rsp; + context_gs = (uintptr_t)ucontext->uc_mcontext->__ss.__gs; + context_fs = (uintptr_t)ucontext->uc_mcontext->__ss.__fs; + } + if ((rip == 0x14001c622ULL || rip == 0x14001cb98ULL) && g_wine_pe_tls_block != NULL) { + /* Both observed launcher TLS accessors fault on the second + * instruction (`mov rax,[rax]`) when Rosetta has temporarily left GS + * on a native/empty TSD base. First repair the actual Wine TSD + * transition and retry the unmodified launcher instruction. The + * diagnostic fallback below remains only so a failed repair cannot + * strand the protected-launch probe in a fault loop. */ + uintptr_t fault_rax = 0; + uintptr_t fault_gs_tls = 0; +#if defined(__x86_64__) + fault_rax = (uintptr_t)ucontext->uc_mcontext->__ss.__rax; + __asm__ volatile("movq %%gs:0x58, %0" : "=r"(fault_gs_tls)); +#endif + static volatile sig_atomic_t tls_repair_attempts; + void* fault_guest_teb = NULL; + /* A protected launcher callback can execute on Wine's low + * 64-KiB-aligned signal/guest stack arena rather than on the public + * TEB's normal stack range. In that case the arena TEB is the GS + * state that `_get_current_teb` selected for this exact frame. */ + uintptr_t stack_teb = rsp & ~(uintptr_t)0xffffu; + uintptr_t stack_self = 0; + uintptr_t stack_tls = 0; + if (stack_teb != 0 && wine_read_word((uint8_t*)stack_teb + 0x30u, &stack_self) && + wine_read_word((uint8_t*)stack_teb + 0x58u, &stack_tls) && stack_self == stack_teb && stack_tls != 0) { + fault_guest_teb = (void*)(uintptr_t)stack_teb; + } + if (fault_guest_teb == NULL) { + fault_guest_teb = wine_public_teb_for_stack_pointer(rsp); + } + if (fault_guest_teb == NULL) { + fault_guest_teb = g_last_guest_teb; + } + sig_atomic_t attempt = __sync_fetch_and_add(&tls_repair_attempts, 1); + if (attempt < 2 && g_real_thread_set_tsd_base != NULL && fault_guest_teb != NULL) { + g_real_thread_set_tsd_base(fault_guest_teb); + uintptr_t repaired_gs_tls = read_guest_gs_tls_pointer(); + ms_log("repaired Wine guest TSD in PE TLS fault rip=0x%llx rsp=0x%llx rax=0x%llx gs_tls=0x%llx " + "repaired_gs_tls=0x%llx guest_teb=0x%llx; retrying", + (unsigned long long)rip, (unsigned long long)rsp, (unsigned long long)fault_rax, + (unsigned long long)fault_gs_tls, (unsigned long long)repaired_gs_tls, + (unsigned long long)(uintptr_t)fault_guest_teb); + if (repaired_gs_tls != 0) { + return; + } + } + ucontext->uc_mcontext->__ss.__rax = (uint64_t)(uintptr_t)g_wine_pe_tls_block; + ucontext->uc_mcontext->__ss.__rip = (uint64_t)(rip + 3u); + ms_log("emulated PE TLS block load fallback rip=0x%llx rsp=0x%llx rax=0x%llx gs_tls=0x%llx fault=0x%llx " + "block=0x%llx", + (unsigned long long)rip, (unsigned long long)rsp, (unsigned long long)fault_rax, + (unsigned long long)fault_gs_tls, + (unsigned long long)(uintptr_t)(siginfo != NULL ? siginfo->si_addr : NULL), + (unsigned long long)(uintptr_t)g_wine_pe_tls_block); + return; + } + int report = __sync_fetch_and_add(&report_count, 1); + if (report < 16) { + uintptr_t gs_self = 0; + uintptr_t gs_tls = 0; + __asm__ volatile("movq %%gs:0x30, %0" : "=r"(gs_self)); + __asm__ volatile("movq %%gs:0x58, %0" : "=r"(gs_tls)); + ms_log("PE SIGSEGV diagnostic rip=0x%llx rsp=0x%llx context_gs=0x%llx context_fs=0x%llx gs_self=0x%llx " + "gs_tls=0x%llx fault=0x%llx requested_tsd=0x%llx", + (unsigned long long)rip, (unsigned long long)rsp, (unsigned long long)context_gs, + (unsigned long long)context_fs, (unsigned long long)gs_self, (unsigned long long)gs_tls, + (unsigned long long)(uintptr_t)(siginfo != NULL ? siginfo->si_addr : NULL), + (unsigned long long)g_tsd_last_requested); + } +#else + (void)ucontext; +#endif + if (__atomic_load_n(&g_wine_sigsegv_action_valid, __ATOMIC_ACQUIRE)) { + if ((g_wine_sigsegv_action.sa_flags & SA_SIGINFO) != 0 && g_wine_sigsegv_action.sa_sigaction != NULL) { + g_wine_sigsegv_action.sa_sigaction(signal, siginfo, context); + } else if (g_wine_sigsegv_action.sa_handler != NULL && g_wine_sigsegv_action.sa_handler != SIG_DFL && + g_wine_sigsegv_action.sa_handler != SIG_IGN) { + g_wine_sigsegv_action.sa_handler(signal); + } + } +} + +static bool metalsharp_eac_host_tsd_enter(void) { + uintptr_t current_thread = wine_current_thread_token(); + MsWineThreadBridgeState* bridge_state = wine_thread_bridge_state(current_thread); + if (bridge_state == NULL) { + return false; + } + if (bridge_state->host_depth != 0) { + bridge_state->host_depth++; + return true; + } + /* Resolve these pointers while the process still has its native Darwin + * TSD base. Re-entering dyld from a Wine guest thread can itself call + * pthread_setspecific, which is precisely the boundary this guard fixes. */ + if (g_thread_set_tsd_base == NULL || g_nt_current_teb == NULL) { + return false; + } + + thread_identifier_info_data_t identifier = {0}; + mach_msg_type_number_t count = THREAD_IDENTIFIER_INFO_COUNT; + mach_port_t thread = mach_thread_self(); + uint32_t current_thread_token = (uint32_t)thread; + kern_return_t result = thread_info(thread, THREAD_IDENTIFIER_INFO, (thread_info_t)&identifier, &count); + mach_port_deallocate(mach_task_self(), thread); + if (result != KERN_SUCCESS || identifier.thread_handle == 0) { + return false; + } + + uintptr_t remembered_guest = bridge_state->guest_teb; + uintptr_t remembered_host = bridge_state->host_tsd; + + bool identifier_is_public_teb = false; + if (identifier.thread_handle != 0 && (identifier.thread_handle & 0xffffu) == 0) { + uintptr_t identifier_self = 0; + uintptr_t identifier_syscall_table = 0; + uintptr_t identifier_syscall_frame = 0; + void* identifier_teb = (void*)(uintptr_t)identifier.thread_handle; + identifier_is_public_teb = wine_read_word((uint8_t*)identifier_teb + 0x30u, &identifier_self) && + wine_read_word((uint8_t*)identifier_teb + 0x370u, &identifier_syscall_table) && + wine_read_word((uint8_t*)identifier_teb + 0x378u, &identifier_syscall_frame) && + identifier_self == (uintptr_t)identifier_teb && + (identifier_syscall_table != 0 || identifier_syscall_frame != 0); + } + void* guest_base = + remembered_guest != 0 ? (void*)(uintptr_t)remembered_guest : wine_guest_teb_from_identifier(&identifier); + if (guest_base == NULL) { + return false; + } + /* The public Wine TEB is the 64-KiB-aligned thread handle. The stack + * aligned address used by ntdll's signal-only `_get_current_teb` is a + * separate internal signal frame and must never be installed as GS for + * PE code. */ + uintptr_t wine_tsd_base = (uintptr_t)guest_base; + uintptr_t current_stack = 0; +#if defined(__x86_64__) + __asm__ volatile("movq %%rsp, %0" : "=r"(current_stack)); +#endif + void* stack_public_teb = wine_public_teb_for_stack_pointer(current_stack); + /* Main-thread Wine uses the public aligned TEB as thread_handle, while + * worker threads in this exact runtime expose their native Darwin TSD + * object there. The latter must stay per-thread; using the process + * thread's captured pthread+0xe0 base corrupts host pthread_getspecific + * during worker startup. */ + uintptr_t host_tsd_base = remembered_host != 0 ? remembered_host + : identifier_is_public_teb + ? (g_native_host_tsd_base != 0 ? g_native_host_tsd_base : identifier.thread_handle) + : identifier.thread_handle; + if (remembered_host == 0 && !identifier_is_public_teb) { + uintptr_t remembered_tsd = wine_native_tsd_for_thread(current_thread_token); + if (remembered_tsd != 0) { + host_tsd_base = remembered_tsd; + } + uintptr_t guest_thread_tsd = 0; + if (remembered_tsd == 0 && stack_public_teb != NULL && + wine_read_word((uint8_t*)stack_public_teb + 0x320u, &guest_thread_tsd) && guest_thread_tsd != 0) { + host_tsd_base = guest_thread_tsd; + } else if (remembered_tsd == 0 && wine_read_word((uint8_t*)guest_base + 0x320u, &guest_thread_tsd) && + guest_thread_tsd != 0) { + host_tsd_base = guest_thread_tsd; + } + } + if (host_tsd_base == 0) { + return false; + } + bridge_state->guest_teb = wine_tsd_base; + bridge_state->host_tsd = host_tsd_base; + bridge_state->host_depth = 1; + g_last_guest_teb = guest_base; + g_thread_set_tsd_base((void*)host_tsd_base); + uintptr_t guest_tls_pointer = 0; + uintptr_t host_zero = 0; + uintptr_t host_tls_pointer = 0; + mach_vm_size_t guest_read = 0; + mach_vm_size_t host_zero_read = 0; + mach_vm_size_t host_tls_read = 0; + (void)mach_vm_read_overwrite(mach_task_self(), (mach_vm_address_t)(uintptr_t)guest_base + 0x58u, + sizeof(guest_tls_pointer), (mach_vm_address_t)&guest_tls_pointer, &guest_read); + (void)mach_vm_read_overwrite(mach_task_self(), (mach_vm_address_t)host_tsd_base, sizeof(host_zero), + (mach_vm_address_t)&host_zero, &host_zero_read); + (void)mach_vm_read_overwrite(mach_task_self(), (mach_vm_address_t)host_tsd_base + 0x58u, sizeof(host_tls_pointer), + (mach_vm_address_t)&host_tls_pointer, &host_tls_read); + ms_log("entered Darwin host TSD base=0x%llx native_pthread=0x%llx thread_handle=0x%llx guest TEB=0x%llx " + "restore=0x%llx field_read=%llu", + (unsigned long long)host_tsd_base, (unsigned long long)(uintptr_t)g_native_host_pthread, + (unsigned long long)identifier.thread_handle, (unsigned long long)(uintptr_t)guest_base, + (unsigned long long)wine_tsd_base, (unsigned long long)sizeof(wine_tsd_base)); + ms_log("TSD diagnostics guest_tls=0x%llx/%llu host_self=0x%llx host0=0x%llx/%llu host58=0x%llx/%llu", + (unsigned long long)guest_tls_pointer, (unsigned long long)guest_read, + (unsigned long long)(uintptr_t)g_native_host_pthread, (unsigned long long)host_zero, + (unsigned long long)host_zero_read, (unsigned long long)host_tls_pointer, (unsigned long long)host_tls_read); + return true; +} + +static void metalsharp_eac_host_tsd_leave(void) { + uintptr_t current_thread = wine_current_thread_token(); + MsWineThreadBridgeState* bridge_state = wine_thread_bridge_state(current_thread); + if (bridge_state == NULL || bridge_state->host_depth == 0) { + return; + } + bridge_state->host_depth--; + if (bridge_state->host_depth == 0) { + void* guest_base = (void*)(uintptr_t)bridge_state->guest_teb; + if (g_thread_set_tsd_base != NULL && guest_base != NULL) { + g_thread_set_tsd_base(guest_base); + } + } +} + +static __attribute__((unused)) int metalsharp_host_pthread_setspecific(pthread_key_t key, const void* value) { + resolve_host_pthread_symbols(); + if (g_host_pthread_setspecific == NULL) { + return ENOSYS; + } + if (!__atomic_load_n(&g_host_tsd_interpose_ready, __ATOMIC_ACQUIRE)) { + return g_host_pthread_setspecific(key, value); + } + bool host_tsd = metalsharp_eac_host_tsd_enter(); + int result = g_host_pthread_setspecific(key, value); + if (host_tsd) { + metalsharp_eac_host_tsd_leave(); + } + ensure_wine_pe_tls(); + return result; +} + +static const void* metalsharp_host_pthread_getspecific(pthread_key_t key) { + resolve_host_pthread_symbols(); + if (g_host_pthread_getspecific == NULL) { + return NULL; + } + if (!__atomic_load_n(&g_host_tsd_interpose_ready, __ATOMIC_ACQUIRE)) { + return g_host_pthread_getspecific(key); + } + bool host_tsd = metalsharp_eac_host_tsd_enter(); + const void* result = g_host_pthread_getspecific(key); + if (host_tsd) { + metalsharp_eac_host_tsd_leave(); + } + return result; +} + +static void patch_wine115_pthread_getspecific_stub(void) { + if (g_ntdll_pthread_getspecific_patched || g_ntdll_image_base == 0) { + return; + } + resolve_host_pthread_symbols(); + if (g_host_pthread_getspecific == NULL) { + ms_log("Wine 11.5 pthread_getspecific bridge unavailable"); + return; + } + /* In the exact installed ntdll.so the _pthread_getspecific stub at + * 0x846ec jumps through __DATA_CONST+0x951c0. Directly reading this + * Rosetta data-const slot works where mach_vm_read_overwrite rejects the + * translated page. */ + volatile uintptr_t* slot = (volatile uintptr_t*)(g_ntdll_image_base + 0x951c0u); + uintptr_t current = *slot; + uint8_t* stub = (uint8_t*)(g_ntdll_image_base + 0x846ecu); + if (*stub == 0xe9) { + g_ntdll_pthread_getspecific_patched = 1; + return; + } + int64_t displacement = (int64_t)(uintptr_t)&metalsharp_host_pthread_getspecific - (int64_t)((uintptr_t)stub + 5u); + if (displacement < INT32_MIN || displacement > INT32_MAX) { + ms_log("Wine 11.5 pthread_getspecific bridge is out of rel32 range stub=0x%llx replacement=0x%llx", + (unsigned long long)(uintptr_t)stub, + (unsigned long long)(uintptr_t)&metalsharp_host_pthread_getspecific); + return; + } + uintptr_t stub_page = (uintptr_t)stub & ~(uintptr_t)(MS_PAGE_SIZE - 1u); + if (mprotect((void*)stub_page, MS_PAGE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { + ms_log("Wine 11.5 pthread_getspecific stub is not writable target=0x%llx errno=%d", + (unsigned long long)(uintptr_t)stub, errno); + return; + } + uint8_t branch[6] = {0xe9, 0, 0, 0, 0, 0x90}; + int32_t relative = (int32_t)displacement; + memcpy(branch + 1, &relative, sizeof(relative)); + memcpy(stub, branch, sizeof(branch)); + __builtin___clear_cache((char*)stub, (char*)stub + sizeof(branch)); + (void)mprotect((void*)stub_page, MS_PAGE_SIZE, PROT_READ | PROT_EXEC); + g_ntdll_pthread_getspecific_patched = 1; + ms_log("patched Wine 11.5 pthread_getspecific stub target=0x%llx got=0x%llx original=0x%llx replacement=0x%llx", + (unsigned long long)(uintptr_t)stub, (unsigned long long)(uintptr_t)slot, (unsigned long long)current, + (unsigned long long)(uintptr_t)&metalsharp_host_pthread_getspecific); + return; +#if 0 + uintptr_t page = (uintptr_t)slot & ~(uintptr_t)(MS_PAGE_SIZE - 1u); + if (mprotect((void *)page, MS_PAGE_SIZE, PROT_READ | PROT_WRITE) != 0) { + ms_log("Wine 11.5 pthread_getspecific slot is not writable target=0x%llx errno=%d", + (unsigned long long)(uintptr_t)slot, errno); + return; + } + *slot = (uintptr_t)&metalsharp_host_pthread_getspecific; + __builtin___clear_cache((char *)slot, (char *)slot + sizeof(*slot)); + (void)mprotect((void *)page, MS_PAGE_SIZE, PROT_READ); + g_ntdll_pthread_getspecific_patched = 1; + ms_log("patched Wine 11.5 pthread_getspecific slot target=0x%llx original=0x%llx replacement=0x%llx", + (unsigned long long)(uintptr_t)slot, + (unsigned long long)current, + (unsigned long long)(uintptr_t)&metalsharp_host_pthread_getspecific); +#endif +} + +static __attribute__((unused)) pthread_t metalsharp_host_pthread_self(void) { + resolve_host_pthread_symbols(); + if (g_host_pthread_self == NULL) { + return (pthread_t)0; + } + if (!__atomic_load_n(&g_host_tsd_interpose_ready, __ATOMIC_ACQUIRE)) { + return g_host_pthread_self(); + } + bool host_tsd = metalsharp_eac_host_tsd_enter(); + pthread_t result = g_host_pthread_self(); + if (host_tsd) { + metalsharp_eac_host_tsd_leave(); + } + ensure_wine_pe_tls(); + return result; +} + +static bool is_proc_maps_path(const char* path) { + if (path == NULL) { + return false; + } + const char* proc = strstr(path, "/proc/"); + if (proc == NULL) { + return false; + } + size_t length = strlen(path); + return length >= 5 && strcmp(path + length - 5, "/maps") == 0; +} + +static bool is_wide_proc_maps_path(const uint16_t* path) { + if (path == NULL) { + return false; + } + char ascii[MS_MAX_PATH]; + size_t index = 0; + for (; path[index] != 0 && index + 1 < sizeof(ascii); index++) { + uint16_t codepoint = path[index]; + ascii[index] = codepoint < 0x80 ? (char)codepoint : '?'; + } + ascii[index] = '\0'; + return is_proc_maps_path(ascii) || (strstr(ascii, "proc") != NULL && strstr(ascii, "maps") != NULL); +} + +extern int32_t ntdll_get_unix_file_name(const uint16_t* dos, char** unix_name, unsigned int disposition) + __attribute__((weak_import)); + +static int32_t metalsharp_eac_ntdll_get_unix_file_name(const uint16_t* dos, char** unix_name, + unsigned int disposition) { + if (is_wide_proc_maps_path(dos) && unix_name != NULL && g_maps_path[0] != '\0') { + char* replacement = strdup(g_maps_path); + if (replacement == NULL) { + return (int32_t)0xC0000017L; /* STATUS_NO_MEMORY */ + } + *unix_name = replacement; + ms_log("redirected wine_get_unix_file_name(/proc/*/maps) -> %s", g_maps_path); + return 0; + } + MsNtdllGetUnixFileNameFn real_fn = g_original_ntdll_get_unix_file_name; + return real_fn != NULL ? real_fn(dos, unix_name, disposition) : (int32_t)0xC0000034L; +} + +static void write_absolute_jump(uint8_t* destination, const void* target) { + destination[0] = 0x48; + destination[1] = 0xb8; /* movabs rax, imm64 */ + uintptr_t address = (uintptr_t)target; + memcpy(destination + 2, &address, sizeof(address)); + destination[10] = 0xff; /* jmp rax */ + destination[11] = 0xe0; +} + +/* + * ntdll.so is loaded by Wine after DYLD_INSERT_LIBRARIES constructors run, + * and its Unix-call table binds ntdll_get_unix_file_name internally. A dyld + * interpose tuple therefore cannot replace that already-bound call. Once + * ntdll is present, install a small in-process entry trampoline instead. It + * only redirects the /proc maps lookup; all other path conversions execute + * the original function through the copied prologue. + */ +static bool patch_ntdll_unix_name(void) { + if (__atomic_load_n(&g_ntdll_patch_done, __ATOMIC_ACQUIRE)) { + return true; + } + void* target = dlsym(RTLD_DEFAULT, "ntdll_get_unix_file_name"); + if (target == NULL) { + void* ntdll_handle = dlopen("ntdll.so", RTLD_NOW | RTLD_NOLOAD); + if (ntdll_handle == NULL) { + ntdll_handle = dlopen("/Users/averyfelts/.metalsharp/runtime/wine/lib/wine/x86_64-unix/ntdll.so", + RTLD_NOW | RTLD_NOLOAD); + } + if (ntdll_handle != NULL) { + target = dlsym(ntdll_handle, "ntdll_get_unix_file_name"); + } + } + if (target == NULL || target == (void*)&metalsharp_eac_ntdll_get_unix_file_name) { + return false; + } + g_ntdll_unix_name_target = target; + + const size_t overwritten = 15; /* complete ntdll.so prologue through 0x1cdf */ + const size_t trampoline_size = 32; + uint8_t* trampoline = + mmap(NULL, trampoline_size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANON, -1, 0); + if (trampoline == MAP_FAILED) { + ms_log("cannot allocate ntdll path trampoline errno=%d", errno); + return false; + } + memcpy(trampoline, target, overwritten); + write_absolute_jump(trampoline + overwritten, (uint8_t*)target + overwritten); + + uintptr_t page = (uintptr_t)target & ~(uintptr_t)(MS_PAGE_SIZE - 1u); + if (mprotect((void*)page, MS_PAGE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { + ms_log("cannot make ntdll path page writable errno=%d", errno); + munmap(trampoline, trampoline_size); + return false; + } + uint8_t patch[15]; + memset(patch, 0x90, sizeof(patch)); + write_absolute_jump(patch, (const void*)&metalsharp_eac_ntdll_get_unix_file_name); + memcpy(target, patch, sizeof(patch)); + __builtin___clear_cache((char*)target, (char*)target + sizeof(patch)); + (void)mprotect((void*)page, MS_PAGE_SIZE, PROT_READ | PROT_EXEC); + g_original_ntdll_get_unix_file_name = (MsNtdllGetUnixFileNameFn)trampoline; + __atomic_store_n(&g_ntdll_patch_done, 1, __ATOMIC_RELEASE); + ms_log("patched exact Wine 11.5 ntdll_get_unix_file_name at 0x%llx", (unsigned long long)(uintptr_t)target); + return true; +} + +typedef struct { + uint32_t characteristics; + uint32_t timestamp; + uint16_t major_version; + uint16_t minor_version; + uint32_t name; + uint32_t ordinal_base; + uint32_t number_of_functions; + uint32_t number_of_names; + uint32_t address_of_functions; + uint32_t address_of_names; + uint32_t address_of_name_ordinals; +} MsPeExportDirectory; + +static bool process_read(mach_vm_address_t address, void* buffer, mach_vm_size_t size) { + mach_vm_size_t read_size = 0; + kern_return_t result = + mach_vm_read_overwrite(mach_task_self(), address, size, (mach_vm_address_t)buffer, &read_size); + return result == KERN_SUCCESS && read_size == size; +} + +static bool process_read_c_string(mach_vm_address_t address, char* buffer, size_t capacity) { + if (capacity == 0) { + return false; + } + for (size_t index = 0; index + 1 < capacity; index++) { + if (!process_read(address + index, &buffer[index], 1)) { + buffer[index] = '\0'; + return false; + } + if (buffer[index] == '\0') { + return true; + } + } + buffer[capacity - 1] = '\0'; + return false; +} + +static bool find_x86_kernel32_prologue(void** target_out) { + /* This is the stable, non-relocated beginning of Wine 11.5's builtin + * kernel32!wine_get_unix_file_name. Wine maps builtin PE sections + * without retaining the DOS header, so an MZ/export scan is insufficient + * on the exact runtime. */ + static const uint8_t prologue[] = { + 0x41, 0x56, 0x41, 0x55, 0x41, 0x54, 0x55, 0x57, 0x56, 0x53, + 0x48, 0x81, 0xec, 0xb0, 0x00, 0x00, 0x00, 0x45, 0x31, 0xc9, + }; + mach_vm_address_t cursor = 0; + while (cursor < UINT64_MAX) { + mach_vm_size_t region_size = 0; + vm_region_basic_info_data_64_t info; + mach_msg_type_number_t info_count = VM_REGION_BASIC_INFO_COUNT_64; + mach_port_t object_name = MACH_PORT_NULL; + mach_vm_address_t region_start = cursor; + kern_return_t result = mach_vm_region(mach_task_self(), ®ion_start, ®ion_size, VM_REGION_BASIC_INFO_64, + (vm_region_info_t)&info, &info_count, &object_name); + if (object_name != MACH_PORT_NULL) { + mach_port_deallocate(mach_task_self(), object_name); + } + if (result != KERN_SUCCESS || region_size == 0) { + return false; + } + cursor = region_start + region_size; + if ((info.protection & (VM_PROT_READ | VM_PROT_EXECUTE)) != (VM_PROT_READ | VM_PROT_EXECUTE)) { + continue; + } + for (mach_vm_size_t offset = 0; offset < region_size;) { + mach_vm_size_t request = region_size - offset; + if (request > 0x10000) { + request = 0x10000; + } + uint8_t buffer[0x10000]; + mach_vm_size_t read_size = 0; + if (mach_vm_read_overwrite(mach_task_self(), region_start + offset, request, (mach_vm_address_t)buffer, + &read_size) == KERN_SUCCESS) { + for (mach_vm_size_t index = 0; index + sizeof(prologue) <= read_size; index++) { + if (memcmp(buffer + index, prologue, sizeof(prologue)) == 0) { + *target_out = (void*)(region_start + offset + index); + ms_log("found kernel32.dll wine_get_unix_file_name by Wine 11.5 prologue at 0x%llx", + (unsigned long long)(uintptr_t)*target_out); + return true; + } + } + } + if (request == 0) { + break; + } + offset += request; + } + } + return false; +} + +static void* find_kernel32_unix_name_export(void) { + static const uint8_t known_prologue[] = { + 0x41, 0x56, 0x41, 0x55, 0x41, 0x54, 0x55, 0x57, 0x56, 0x53, + 0x48, 0x81, 0xec, 0xb0, 0x00, 0x00, 0x00, 0x45, 0x31, 0xc9, + }; + uint8_t known_bytes[sizeof(known_prologue)]; + if (process_read(MS_WINE115_KERNEL32_BASE + 0x32560u, known_bytes, sizeof(known_bytes)) && + memcmp(known_bytes, known_prologue, sizeof(known_prologue)) == 0) { + void* known_target = (void*)(MS_WINE115_KERNEL32_BASE + 0x32560u); + ms_log("found exact Wine 11.5 kernel32 base at 0x%llx", (unsigned long long)MS_WINE115_KERNEL32_BASE); + return known_target; + } + mach_vm_address_t cursor = 0; + unsigned int region_count = 0; + unsigned int mz_count = 0; + while (cursor < UINT64_MAX) { + mach_vm_size_t region_size = 0; + vm_region_basic_info_data_64_t info; + mach_msg_type_number_t info_count = VM_REGION_BASIC_INFO_COUNT_64; + mach_port_t object_name = MACH_PORT_NULL; + mach_vm_address_t region_start = cursor; + kern_return_t result = mach_vm_region(mach_task_self(), ®ion_start, ®ion_size, VM_REGION_BASIC_INFO_64, + (vm_region_info_t)&info, &info_count, &object_name); + if (object_name != MACH_PORT_NULL) { + mach_port_deallocate(mach_task_self(), object_name); + } + if (result != KERN_SUCCESS || region_size == 0) { + break; + } + region_count++; + cursor = region_start + region_size; + if ((info.protection & VM_PROT_READ) == 0 || region_size < 0x1000) { + continue; + } + + uint8_t header[0x1000]; + if (!process_read(region_start, header, sizeof(header)) || header[0] != 'M' || header[1] != 'Z') { + continue; + } + mz_count++; + uint32_t pe_offset; + memcpy(&pe_offset, header + 0x3c, sizeof(pe_offset)); + if (pe_offset > sizeof(header) - 0x100 || pe_offset + 4 + 20 + 2 > sizeof(header)) { + continue; + } + uint32_t pe_signature; + memcpy(&pe_signature, header + pe_offset, sizeof(pe_signature)); + if (pe_signature != 0x00004550) { + continue; + } + uint16_t optional_magic; + memcpy(&optional_magic, header + pe_offset + 4 + 20, sizeof(optional_magic)); + if (optional_magic != 0x20b) { + continue; + } + mach_vm_address_t optional = region_start + pe_offset + 4 + 20; + uint32_t export_rva; + if (!process_read(optional + 112, &export_rva, sizeof(export_rva)) || export_rva == 0) { + continue; + } + MsPeExportDirectory exports; + if (!process_read(region_start + export_rva, &exports, sizeof(exports))) { + continue; + } + if (exports.number_of_names == 0 || exports.number_of_names > 10000) { + continue; + } + for (uint32_t index = 0; index < exports.number_of_names; index++) { + uint32_t name_rva; + if (!process_read(region_start + exports.address_of_names + index * sizeof(uint32_t), &name_rva, + sizeof(name_rva))) { + break; + } + char name[96]; + if (!process_read_c_string(region_start + name_rva, name, sizeof(name))) { + continue; + } + if (strcmp(name, "wine_get_unix_file_name") != 0) { + continue; + } + uint16_t ordinal; + uint32_t function_rva; + if (!process_read(region_start + exports.address_of_name_ordinals + index * sizeof(uint16_t), &ordinal, + sizeof(ordinal)) || + !process_read(region_start + exports.address_of_functions + ordinal * sizeof(uint32_t), &function_rva, + sizeof(function_rva))) { + return NULL; + } + ms_log("found kernel32.dll wine_get_unix_file_name base=0x%llx rva=0x%x", (unsigned long long)region_start, + function_rva); + return (void*)(region_start + function_rva); + } + } + void* prologue_target = NULL; + if (find_x86_kernel32_prologue(&prologue_target)) { + return prologue_target; + } + if (region_count > 0 && !__atomic_exchange_n(&g_kernel32_scan_reported, 1, __ATOMIC_ACQ_REL)) { + ms_log("kernel32 export scan found no target regions=%u mz=%u pid=%d", region_count, mz_count, (int)getpid()); + } + return NULL; +} + +static char* __attribute__((ms_abi)) metalsharp_eac_kernel32_get_unix_file_name(const uint16_t* dos) { + if (is_wide_proc_maps_path(dos) && g_maps_path[0] != '\0') { + char* replacement = strdup(g_maps_path); + if (replacement == NULL) { + return NULL; + } + ms_log("redirected kernel32 wine_get_unix_file_name(/proc/*/maps) -> %s", g_maps_path); + return replacement; + } + return g_original_kernel32_get_unix_file_name != NULL ? g_original_kernel32_get_unix_file_name(dos) : NULL; +} + +static void* metalsharp_eac_get_proc_address(void* module, const char* name) __attribute__((ms_abi)); + +static void* metalsharp_eac_get_proc_address(void* module, const char* name) { + if (name != NULL && strcmp(name, "wine_get_unix_file_name") == 0) { + ms_log("provided Wine private export wine_get_unix_file_name module=0x%llx", + (unsigned long long)(uintptr_t)module); + return (void*)&metalsharp_eac_kernel32_get_unix_file_name; + } + return g_original_get_proc_address != NULL ? g_original_get_proc_address(module, name) : NULL; +} + +static bool patch_get_proc_address_from_kernel32(void* wine_name_target) { + if (__atomic_load_n(&g_get_proc_address_patch_done, __ATOMIC_ACQUIRE)) { + return true; + } + /* These RVAs are from the exact MetalSharp Wine 11.5 kernel32.dll that + * supplied the prologue above. GetProcAddress is a builtin PE export, + * so the module base is recoverable without a PE header in memory. */ + uintptr_t kernel32_base = (uintptr_t)wine_name_target - 0x32560u; + void* target = (void*)(kernel32_base + 0x114c0u); + const size_t overwritten = 18; + const size_t trampoline_size = 48; + uint8_t* trampoline = + mmap(NULL, trampoline_size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANON, -1, 0); + if (trampoline == MAP_FAILED) { + ms_log("cannot allocate GetProcAddress trampoline errno=%d", errno); + return false; + } + uint8_t expected[] = {0x48, 0x55, 0x48, 0x89, 0xe5, 0x48, 0x83, 0xec, 0x60, 0x48, 0x83, 0xe4, 0xf0}; + if (memcmp(target, expected, sizeof(expected)) != 0) { + ms_log("Wine 11.5 GetProcAddress prologue mismatch at 0x%llx", (unsigned long long)(uintptr_t)target); + munmap(trampoline, trampoline_size); + return false; + } + memcpy(trampoline, target, overwritten); + write_absolute_jump(trampoline + overwritten, (uint8_t*)target + overwritten); + uintptr_t page = (uintptr_t)target & ~(uintptr_t)(MS_PAGE_SIZE - 1u); + if (mprotect((void*)page, MS_PAGE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { + ms_log("cannot make GetProcAddress page writable errno=%d", errno); + munmap(trampoline, trampoline_size); + return false; + } + uint8_t patch[18]; + memset(patch, 0x90, sizeof(patch)); + write_absolute_jump(patch, (const void*)&metalsharp_eac_get_proc_address); + memcpy(target, patch, sizeof(patch)); + __builtin___clear_cache((char*)target, (char*)target + sizeof(patch)); + (void)mprotect((void*)page, MS_PAGE_SIZE, PROT_READ | PROT_EXEC); + g_original_get_proc_address = (MsGetProcAddressFn)trampoline; + __atomic_store_n(&g_get_proc_address_patch_done, 1, __ATOMIC_RELEASE); + ms_log("patched exact Wine 11.5 kernel32 GetProcAddress at 0x%llx", (unsigned long long)(uintptr_t)target); + return true; +} + +static bool patch_kernel32_unix_name(void) { + if (__atomic_load_n(&g_kernel32_patch_done, __ATOMIC_ACQUIRE)) { + return true; + } + void* target = find_kernel32_unix_name_export(); + if (target == NULL || target == (void*)&metalsharp_eac_kernel32_get_unix_file_name) { + return false; + } + if (!patch_get_proc_address_from_kernel32(target)) { + return false; + } + + /* kernel32's Wine 11.5 builtin prologue is 17 bytes; include the next + * complete register-save instruction before installing the jump. */ + const size_t overwritten = 20; + const size_t trampoline_size = 48; + uint8_t* trampoline = + mmap(NULL, trampoline_size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANON, -1, 0); + if (trampoline == MAP_FAILED) { + ms_log("cannot allocate kernel32 path trampoline errno=%d", errno); + return false; + } + memcpy(trampoline, target, overwritten); + write_absolute_jump(trampoline + overwritten, (uint8_t*)target + overwritten); + + uintptr_t page = (uintptr_t)target & ~(uintptr_t)(MS_PAGE_SIZE - 1u); + if (mprotect((void*)page, MS_PAGE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { + ms_log("cannot make kernel32 path page writable errno=%d", errno); + munmap(trampoline, trampoline_size); + return false; + } + uint8_t patch[20]; + memset(patch, 0x90, sizeof(patch)); + write_absolute_jump(patch, (const void*)&metalsharp_eac_kernel32_get_unix_file_name); + memcpy(target, patch, sizeof(patch)); + __builtin___clear_cache((char*)target, (char*)target + sizeof(patch)); + (void)mprotect((void*)page, MS_PAGE_SIZE, PROT_READ | PROT_EXEC); + g_original_kernel32_get_unix_file_name = (MsKernel32GetUnixFileNameFn)trampoline; + __atomic_store_n(&g_kernel32_patch_done, 1, __ATOMIC_RELEASE); + ms_log("patched exact Wine 11.5 kernel32 wine_get_unix_file_name at 0x%llx", (unsigned long long)(uintptr_t)target); + return true; +} + +static __attribute__((unused)) void* ntdll_patch_thread(void* unused) { + (void)unused; + for (unsigned int attempt = 0; attempt < 1000; attempt++) { + if (patch_kernel32_unix_name()) { + return NULL; + } + if (patch_ntdll_unix_name()) { + return NULL; + } + if (attempt == 0) { + ms_log("Wine 11.5 ntdll_get_unix_file_name not visible yet pid=%d", (int)getpid()); + } + usleep(10000); + } + ms_log("Wine 11.5 ntdll path patch target did not appear"); + return NULL; +} + +static int create_virtual_maps_fd(int flags, mode_t mode) { + if (g_elf_mapping == NULL || g_elf_mapping_size == 0 || g_elf_path[0] == '\0') { + return -1; + } + + static unsigned int sequence; + char template_path[128]; + int fd = -1; + for (unsigned int attempt = 0; attempt < 32 && fd < 0; attempt++) { + unsigned int value = __atomic_fetch_add(&sequence, 1, __ATOMIC_RELAXED); + snprintf(template_path, sizeof(template_path), "/tmp/metalsharp-eac-maps-%d-%u", (int)getpid(), value); + fd = ms_raw_open(template_path, O_RDWR | O_CREAT | O_EXCL, 0600); + } + if (fd < 0) { + return -1; + } + (void)unlink(template_path); + + uintptr_t start = (uintptr_t)g_elf_mapping; + uintptr_t end = start + g_elf_mapping_size; + char maps_line[MS_MAX_PATH + 160]; + int length = snprintf(maps_line, sizeof(maps_line), "%016llx-%016llx r-xp 00000000 00:00 0 %s\n", + (unsigned long long)start, (unsigned long long)end, g_elf_path); + if (length <= 0 || (size_t)length >= sizeof(maps_line)) { + (void)close(fd); + return -1; + } + if (write(fd, maps_line, (size_t)length) != length || lseek(fd, 0, SEEK_SET) < 0) { + (void)close(fd); + return -1; + } + (void)fchmod(fd, mode == 0 ? 0600 : mode); + (void)fcntl(fd, F_SETFD, flags & O_CLOEXEC ? FD_CLOEXEC : 0); + ms_log("virtualized %s -> %s base=0x%llx size=0x%zx", "proc maps", g_elf_path, (unsigned long long)start, + g_elf_mapping_size); + return fd; +} + +int metalsharp_eac_open(const char* path, int flags, ...) { + mode_t mode = 0; + if ((flags & O_CREAT) != 0) { + va_list args; + va_start(args, flags); + mode = (mode_t)va_arg(args, int); + va_end(args); + } + if (!g_in_open_hook && is_proc_maps_path(path)) { + g_in_open_hook = 1; + int virtual_fd = create_virtual_maps_fd(flags, mode); + g_in_open_hook = 0; + if (virtual_fd >= 0) { + ms_log("intercepted open(%s) => fd %d", path, virtual_fd); + return virtual_fd; + } + } + if ((flags & O_CREAT) != 0) { + return ms_raw_open(path, flags, mode); + } + return ms_raw_open(path, flags, 0); +} + +int metalsharp_eac_openat(int dirfd, const char* path, int flags, ...) { + mode_t mode = 0; + if ((flags & O_CREAT) != 0) { + va_list args; + va_start(args, flags); + mode = (mode_t)va_arg(args, int); + va_end(args); + } + if (!g_in_open_hook && is_proc_maps_path(path)) { + g_in_open_hook = 1; + int virtual_fd = create_virtual_maps_fd(flags, mode); + g_in_open_hook = 0; + if (virtual_fd >= 0) { + ms_log("intercepted openat(%s) => fd %d", path, virtual_fd); + return virtual_fd; + } + } + if ((flags & O_CREAT) != 0) { + return ms_raw_openat(dirfd, path, flags, mode); + } + return ms_raw_openat(dirfd, path, flags, 0); +} + +static void* resolve_substrate_target(const char* name); +static void* load_linux_module_fd(int fd); +static void* lookup_linux_module_symbol(MsLinuxLoadedModule* module, const char* name); +static int metalsharp_eac_dlclose(void* handle); +static const char* metalsharp_eac_dlerror(void); +static void* metalsharp_eac_malloc(size_t size); +static void* metalsharp_eac_calloc(size_t count, size_t size); +static void metalsharp_eac_free(void* pointer); + +static bool dump_linux_module_fd(int fd) { + const char* configured = getenv("METALSHARP_EAC_MODULE_DUMP"); + const char* path = configured != NULL && configured[0] != '\0' ? configured : "/tmp/metalsharp-eac-module.bin"; + struct stat st; + if (fstat(fd, &st) != 0 || st.st_size <= 0) { + ms_log("cannot stat Linux module fd=%d errno=%d", fd, errno); + return false; + } + int output = ms_raw_open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (output < 0) { + ms_log("cannot create Linux module dump path=%s errno=%d", path, errno); + return false; + } + uint8_t* buffer = metalsharp_eac_malloc(1024 * 1024); + if (buffer == NULL) { + close(output); + ms_log("cannot allocate Linux module dump buffer"); + return false; + } + off_t offset = 0; + bool success = true; + while (offset < st.st_size) { + size_t request = (size_t)(st.st_size - offset); + if (request > 1024 * 1024) { + request = 1024 * 1024; + } + ssize_t count = pread(fd, buffer, request, offset); + if (count <= 0) { + ms_log("cannot read Linux module fd=%d offset=%lld errno=%d", fd, (long long)offset, errno); + success = false; + break; + } + ssize_t written = write(output, buffer, (size_t)count); + if (written != count) { + ms_log("cannot write Linux module dump path=%s errno=%d", path, errno); + success = false; + break; + } + offset += count; + } + metalsharp_eac_free(buffer); + close(output); + ms_log("captured Linux module fd=%d size=%lld path=%s success=%d", fd, (long long)st.st_size, path, + success ? 1 : 0); + return success; +} + +static void* metalsharp_eac_dlopen(const char* path, int flags) { + (void)flags; + ms_log("__libc_dlopen_mode requested path=%s", path != NULL ? path : ""); + resolve_nt_current_teb_from_wine(); + void* direct_teb = g_nt_current_teb != NULL ? g_nt_current_teb() : NULL; + ms_log("Wine 11.5 NtCurrentTeb direct before host bridge=0x%llx", (unsigned long long)(uintptr_t)direct_teb); + bool host_tsd = metalsharp_eac_host_tsd_enter(); + ms_log("__libc_dlopen_mode(%s) host_tsd=%d", path != NULL ? path : "", host_tsd ? 1 : 0); + ms_log("Wine TSD callback diagnostics count=%llu pseudo=%llu mapped=%llu last_requested=0x%llx " + "last_effective=0x%llx guest_teb=0x%llx", + (unsigned long long)g_tsd_callback_count, (unsigned long long)g_tsd_pseudo_count, + (unsigned long long)g_tsd_mapped_count, (unsigned long long)g_tsd_last_requested, + (unsigned long long)g_tsd_last_effective, (unsigned long long)(uintptr_t)g_last_guest_teb); + void* result = NULL; + if (path != NULL && strncmp(path, "/proc/self/fd/", 14) == 0) { + char* end = NULL; + long value = strtol(path + 14, &end, 10); + if (end != path + 14 && *end == '\0' && value >= 0 && value <= INT32_MAX) { + result = load_linux_module_fd((int)value); + } else { + ms_log("invalid Linux module proc fd path=%s", path); + } + } + if (result == NULL) { + snprintf(g_linux_dlerror, sizeof(g_linux_dlerror), "unsupported Linux dlopen path: %s", + path != NULL ? path : ""); + } else { + ensure_wine_pe_tls(); + patch_wine115_pthread_getspecific_stub(); + start_wine_tls_monitor(); + } + metalsharp_eac_host_tsd_leave(); + if (result != NULL) { + /* The Wine thread that called into the Linux loader may differ from + * the thread that resumes the PE launcher. Re-run the TEB walk after + * restoring the guest TSD base so the returning guest thread is + * covered at the actual ABI boundary. */ + ensure_wine_pe_tls(); + ms_log("guest GS after Linux dlopen return gs_tls=0x%llx expected_teb=0x%llx expected_array=0x%llx", + (unsigned long long)read_guest_gs_tls_pointer(), (unsigned long long)(uintptr_t)g_last_guest_teb, + (unsigned long long)(uintptr_t)g_wine_pe_tls_array); + } + return result; +} + +/* The protected launcher resolves the EAC module's public a-e entry points + * from inside the Linux image and then calls them while Wine's GS base is + * still the Windows TEB. Their implementations immediately use Darwin + * libunwind/libc, so the same host-TSD boundary used for constructors and + * dlsym must also surround these Linux entry points. The current EAC ABI is + * intentionally small: a, b, c and e take one context pointer; d is a + * no-argument teardown marker. */ +static uintptr_t metalsharp_eac_export_a(void* argument) { + ms_log("Linux EAC export a enter argument=0x%llx", (unsigned long long)(uintptr_t)argument); + bool host_tsd = metalsharp_eac_host_tsd_enter(); + uintptr_t result = g_linux_export_a != NULL ? g_linux_export_a(argument) : 0; + if (host_tsd) { + metalsharp_eac_host_tsd_leave(); + } + ensure_wine_pe_tls(); + if (result == 1) { + __sync_fetch_and_add(&g_eac_export_a_successes, 1); + } + ms_log("Linux EAC export a return=0x%llx", (unsigned long long)result); + if (result == 1) { + ms_log("EAC_PROOF export_a_success=1 module_base=0x%llx", (unsigned long long)(uintptr_t)g_linux_module.base); + } + return result; +} + +static uintptr_t metalsharp_eac_export_b(void* argument) { + ms_log("Linux EAC export b enter argument=0x%llx", (unsigned long long)(uintptr_t)argument); + bool host_tsd = metalsharp_eac_host_tsd_enter(); + uintptr_t result = g_linux_export_b != NULL ? g_linux_export_b(argument) : 0; + if (host_tsd) { + metalsharp_eac_host_tsd_leave(); + } + ms_log("Linux EAC export b return=0x%llx", (unsigned long long)result); + return result; +} + +static uintptr_t metalsharp_eac_export_c(void* argument) { + ms_log("Linux EAC export c enter argument=0x%llx", (unsigned long long)(uintptr_t)argument); + bool host_tsd = metalsharp_eac_host_tsd_enter(); + uintptr_t result = g_linux_export_c != NULL ? g_linux_export_c(argument) : 0; + if (host_tsd) { + metalsharp_eac_host_tsd_leave(); + } + ms_log("Linux EAC export c return=0x%llx", (unsigned long long)result); + return result; +} + +static void metalsharp_eac_export_d(void) { + ms_log("Linux EAC export d enter"); + bool host_tsd = metalsharp_eac_host_tsd_enter(); + if (g_linux_export_d != NULL) { + g_linux_export_d(); + } + if (host_tsd) { + metalsharp_eac_host_tsd_leave(); + } + ensure_wine_pe_tls(); + ms_log("Linux EAC export d return"); +} + +static uintptr_t metalsharp_eac_export_e(void* argument) { + ms_log("Linux EAC export e enter argument=0x%llx", (unsigned long long)(uintptr_t)argument); + bool host_tsd = metalsharp_eac_host_tsd_enter(); + uintptr_t result = g_linux_export_e != NULL ? g_linux_export_e(argument) : 0; + if (host_tsd) { + metalsharp_eac_host_tsd_leave(); + } + ensure_wine_pe_tls(); + ms_log("Linux EAC export e return=0x%llx", (unsigned long long)result); + return result; +} + +static void* wrap_linux_export(const char* name, void* target) { + if (name == NULL || target == NULL) { + return target; + } + if (strcmp(name, "a") == 0) { + __atomic_fetch_or(&g_eac_export_mask, 1u << 0, __ATOMIC_RELAXED); + g_linux_export_a = (MsLinuxUnaryExportFn)target; + return (void*)&metalsharp_eac_export_a; + } + if (strcmp(name, "b") == 0) { + __atomic_fetch_or(&g_eac_export_mask, 1u << 1, __ATOMIC_RELAXED); + g_linux_export_b = (MsLinuxUnaryExportFn)target; + return (void*)&metalsharp_eac_export_b; + } + if (strcmp(name, "c") == 0) { + __atomic_fetch_or(&g_eac_export_mask, 1u << 2, __ATOMIC_RELAXED); + g_linux_export_c = (MsLinuxUnaryExportFn)target; + return (void*)&metalsharp_eac_export_c; + } + if (strcmp(name, "d") == 0) { + __atomic_fetch_or(&g_eac_export_mask, 1u << 3, __ATOMIC_RELAXED); + g_linux_export_d = (void (*)(void))target; + return (void*)&metalsharp_eac_export_d; + } + if (strcmp(name, "e") == 0) { + __atomic_fetch_or(&g_eac_export_mask, 1u << 4, __ATOMIC_RELAXED); + g_linux_export_e = (MsLinuxUnaryExportFn)target; + return (void*)&metalsharp_eac_export_e; + } + return target; +} + +static void* metalsharp_eac_dlsym(void* handle, const char* name) { + bool host_tsd = metalsharp_eac_host_tsd_enter(); + ms_log("__libc_dlsym(%s) host_tsd=%d", name != NULL ? name : "", host_tsd ? 1 : 0); + void* result = NULL; + if (handle == g_linux_module.base && g_linux_module.loaded) { + result = lookup_linux_module_symbol(&g_linux_module, name); + if (result != NULL) { + result = wrap_linux_export(name, result); + if (host_tsd) { + metalsharp_eac_host_tsd_leave(); + } + ensure_wine_pe_tls(); + ms_log("guest GS after Linux dlsym name=%s gs_tls=0x%llx expected_teb=0x%llx", + name != NULL ? name : "", (unsigned long long)read_guest_gs_tls_pointer(), + (unsigned long long)(uintptr_t)g_last_guest_teb); + return result; + } + } + result = resolve_substrate_target(name); + if (host_tsd) { + metalsharp_eac_host_tsd_leave(); + } + ensure_wine_pe_tls(); + ms_log("guest GS after Linux dlsym fallback name=%s gs_tls=0x%llx expected_teb=0x%llx", + name != NULL ? name : "", (unsigned long long)read_guest_gs_tls_pointer(), + (unsigned long long)(uintptr_t)g_last_guest_teb); + return result; +} + +static void* metalsharp_eac_unimplemented(void) { + ms_log("unimplemented Linux ABI symbol called"); + return NULL; +} + +static int* metalsharp_eac_linux_errno_location(void); + +static int* metalsharp_eac_errno_location(void) { + return metalsharp_eac_linux_errno_location(); +} + +typedef struct { + uintptr_t module; + uintptr_t offset; +} MsLinuxTlsIndex; + +static void* metalsharp_eac_tls_get_addr(const MsLinuxTlsIndex* index) { + if (index == NULL) { + return NULL; + } + if (g_linux_tls_block == NULL) { + size_t size = g_linux_module.tls_size != 0 ? g_linux_module.tls_size : 16; + g_linux_tls_block = metalsharp_eac_calloc(1, size + 16); + } + if (g_linux_tls_block == NULL || index->offset >= g_linux_module.tls_size + 16) { + return NULL; + } + return (uint8_t*)g_linux_tls_block + index->offset; +} + +typedef struct { + uint64_t magic; + size_t mapping_size; + size_t requested_size; +} MsLinuxAllocationHeader; + +#define MS_LINUX_ALLOCATION_MAGIC UINT64_C(0x4d53454143414c4c) + +static uint64_t align_up_linux(uint64_t value); + +static void* metalsharp_eac_malloc(size_t size) { + if (size == 0) { + size = 1; + } + if (size > SIZE_MAX - sizeof(MsLinuxAllocationHeader) - MS_PAGE_SIZE) { + errno = ENOMEM; + return NULL; + } + size_t mapping_size = align_up_linux(size + sizeof(MsLinuxAllocationHeader)); + void* mapping = mmap(NULL, mapping_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); + if (mapping == MAP_FAILED) { + return NULL; + } + MsLinuxAllocationHeader* header = mapping; + header->magic = MS_LINUX_ALLOCATION_MAGIC; + header->mapping_size = mapping_size; + header->requested_size = size; + return header + 1; +} + +static void* metalsharp_eac_calloc(size_t count, size_t size) { + if (count != 0 && size > SIZE_MAX / count) { + errno = ENOMEM; + return NULL; + } + size_t total = count * size; + void* result = metalsharp_eac_malloc(total); + if (result != NULL) { + memset(result, 0, total == 0 ? 1 : total); + } + return result; +} + +static bool is_metalsharp_eac_allocation(void* pointer, MsLinuxAllocationHeader** header_out) { + if (pointer == NULL) { + return false; + } + MsLinuxAllocationHeader* header = (MsLinuxAllocationHeader*)pointer - 1; + if (header->magic != MS_LINUX_ALLOCATION_MAGIC || header->mapping_size == 0 || + (header->mapping_size & (MS_PAGE_SIZE - 1u)) != 0) { + return false; + } + if (header_out != NULL) { + *header_out = header; + } + return true; +} + +static void metalsharp_eac_free(void* pointer) { + MsLinuxAllocationHeader* header = NULL; + if (is_metalsharp_eac_allocation(pointer, &header)) { + size_t mapping_size = header->mapping_size; + header->magic = 0; + (void)munmap(header, mapping_size); + } +} + +static void* metalsharp_eac_realloc(void* pointer, size_t size) { + if (pointer == NULL) { + return metalsharp_eac_malloc(size); + } + MsLinuxAllocationHeader* header = NULL; + if (!is_metalsharp_eac_allocation(pointer, &header)) { + return metalsharp_eac_malloc(size); + } + void* replacement = metalsharp_eac_malloc(size); + if (replacement != NULL) { + size_t copy_size = header->requested_size < size ? header->requested_size : size; + memcpy(replacement, pointer, copy_size); + } + metalsharp_eac_free(pointer); + return replacement; +} + +static void* metalsharp_eac_operator_new(size_t size) { + return metalsharp_eac_malloc(size); +} + +static void metalsharp_eac_operator_delete(void* pointer) { + metalsharp_eac_free(pointer); +} + +static void metalsharp_eac_noop(void) {} + +#define MS_LINUX_THREAD_SLOTS 128 +#define MS_LINUX_SPECIFIC_SLOTS 256 + +typedef struct { + uint32_t thread; + int errno_value; + bool used; +} MsLinuxThreadSlot; + +typedef struct { + uint32_t thread; + uint32_t key; + const void* value; + bool used; +} MsLinuxSpecificSlot; + +static MsLinuxThreadSlot g_linux_thread_slots[MS_LINUX_THREAD_SLOTS]; +static MsLinuxSpecificSlot g_linux_specific_slots[MS_LINUX_SPECIFIC_SLOTS]; +static volatile int g_linux_thread_slot_lock; + +static uint32_t metalsharp_eac_thread_token(void) { + return (uint32_t)mach_thread_self(); +} + +static int* metalsharp_eac_linux_errno_location(void) { + uint32_t thread = metalsharp_eac_thread_token(); + while (__sync_lock_test_and_set(&g_linux_thread_slot_lock, 1) != 0) { + __builtin_ia32_pause(); + } + MsLinuxThreadSlot* free_slot = NULL; + for (size_t index = 0; index < MS_LINUX_THREAD_SLOTS; index++) { + MsLinuxThreadSlot* slot = &g_linux_thread_slots[index]; + if (slot->used && slot->thread == thread) { + __sync_lock_release(&g_linux_thread_slot_lock); + return &slot->errno_value; + } + if (!slot->used && free_slot == NULL) { + free_slot = slot; + } + } + if (free_slot != NULL) { + free_slot->thread = thread; + free_slot->errno_value = 0; + free_slot->used = true; + __sync_lock_release(&g_linux_thread_slot_lock); + return &free_slot->errno_value; + } + __sync_lock_release(&g_linux_thread_slot_lock); + static int fallback_errno; + return &fallback_errno; +} + +static int metalsharp_eac_pthread_setspecific(uint32_t key, const void* value) { + uint32_t thread = metalsharp_eac_thread_token(); + while (__sync_lock_test_and_set(&g_linux_thread_slot_lock, 1) != 0) { + __builtin_ia32_pause(); + } + MsLinuxSpecificSlot* free_slot = NULL; + for (size_t index = 0; index < MS_LINUX_SPECIFIC_SLOTS; index++) { + MsLinuxSpecificSlot* slot = &g_linux_specific_slots[index]; + if (slot->used && slot->thread == thread && slot->key == key) { + if (value == NULL) { + slot->used = false; + } else { + slot->value = value; + } + __sync_lock_release(&g_linux_thread_slot_lock); + return 0; + } + if (!slot->used && free_slot == NULL) { + free_slot = slot; + } + } + if (value != NULL && free_slot != NULL) { + free_slot->thread = thread; + free_slot->key = key; + free_slot->value = value; + free_slot->used = true; + } + __sync_lock_release(&g_linux_thread_slot_lock); + return value == NULL || free_slot != NULL ? 0 : EAGAIN; +} + +static void* metalsharp_eac_pthread_getspecific(uint32_t key) { + uint32_t thread = metalsharp_eac_thread_token(); + while (__sync_lock_test_and_set(&g_linux_thread_slot_lock, 1) != 0) { + __builtin_ia32_pause(); + } + for (size_t index = 0; index < MS_LINUX_SPECIFIC_SLOTS; index++) { + MsLinuxSpecificSlot* slot = &g_linux_specific_slots[index]; + if (slot->used && slot->thread == thread && slot->key == key) { + void* value = (void*)slot->value; + __sync_lock_release(&g_linux_thread_slot_lock); + return value; + } + } + __sync_lock_release(&g_linux_thread_slot_lock); + return NULL; +} + +static int metalsharp_eac_pthread_key_delete(uint32_t key) { + while (__sync_lock_test_and_set(&g_linux_thread_slot_lock, 1) != 0) { + __builtin_ia32_pause(); + } + for (size_t index = 0; index < MS_LINUX_SPECIFIC_SLOTS; index++) { + if (g_linux_specific_slots[index].used && g_linux_specific_slots[index].key == key) { + g_linux_specific_slots[index].used = false; + } + } + __sync_lock_release(&g_linux_thread_slot_lock); + return 0; +} + +static void* metalsharp_eac_pthread_self(void) { + return (void*)(uintptr_t)metalsharp_eac_thread_token(); +} + +typedef struct MsLinuxMutexEntry { + void* guest; + volatile int state; + uint32_t owner; + uint32_t recursion; + bool recursive; + struct MsLinuxMutexEntry* next; +} MsLinuxMutexEntry; + +static MsLinuxMutexEntry* g_linux_mutex_entries; +static volatile int g_linux_mutex_registry_lock; + +static void lock_linux_mutex_registry(void) { + while (__sync_lock_test_and_set(&g_linux_mutex_registry_lock, 1) != 0) { + __builtin_ia32_pause(); + } +} + +static void unlock_linux_mutex_registry(void) { + __sync_lock_release(&g_linux_mutex_registry_lock); +} + +static MsLinuxMutexEntry* find_linux_mutex_entry(void* guest) { + for (MsLinuxMutexEntry* entry = g_linux_mutex_entries; entry != NULL; entry = entry->next) { + if (entry->guest == guest) { + return entry; + } + } + return NULL; +} + +static MsLinuxMutexEntry* get_linux_mutex_entry(void* guest, bool create, const int* guest_attr) { + if (guest == NULL) { + return NULL; + } + lock_linux_mutex_registry(); + MsLinuxMutexEntry* entry = find_linux_mutex_entry(guest); + if (entry == NULL && create) { + entry = metalsharp_eac_calloc(1, sizeof(*entry)); + if (entry != NULL) { + entry->guest = guest; + entry->recursive = guest_attr != NULL && *guest_attr != 0; + entry->next = g_linux_mutex_entries; + g_linux_mutex_entries = entry; + } + } + unlock_linux_mutex_registry(); + return entry; +} + +static int metalsharp_eac_pthread_mutexattr_init(int* attribute) { + ms_log("Linux pthread_mutexattr_init guest=0x%llx", (unsigned long long)(uintptr_t)attribute); + if (attribute == NULL) { + return EINVAL; + } + *attribute = 0; + return 0; +} + +static int metalsharp_eac_pthread_mutexattr_destroy(int* attribute) { + ms_log("Linux pthread_mutexattr_destroy guest=0x%llx", (unsigned long long)(uintptr_t)attribute); + (void)attribute; + return 0; +} + +static int metalsharp_eac_pthread_mutexattr_settype(int* attribute, int type) { + ms_log("Linux pthread_mutexattr_settype guest=0x%llx type=%d", (unsigned long long)(uintptr_t)attribute, type); + if (attribute == NULL) { + return EINVAL; + } + *attribute = type; + return 0; +} + +static int metalsharp_eac_pthread_mutex_init(void* guest, const int* attribute) { + ms_log("Linux pthread_mutex_init guest=0x%llx attr=0x%llx", (unsigned long long)(uintptr_t)guest, + (unsigned long long)(uintptr_t)attribute); + int result = get_linux_mutex_entry(guest, true, attribute) != NULL ? 0 : ENOMEM; + ms_log("Linux pthread_mutex_init result=%d", result); + return result; +} + +static int metalsharp_eac_pthread_mutex_destroy(void* guest) { + ms_log("Linux pthread_mutex_destroy guest=0x%llx", (unsigned long long)(uintptr_t)guest); + if (guest == NULL) { + return EINVAL; + } + lock_linux_mutex_registry(); + MsLinuxMutexEntry* entry = find_linux_mutex_entry(guest); + if (entry != NULL && entry->state != 0) { + unlock_linux_mutex_registry(); + return EBUSY; + } + if (entry != NULL) { + entry->owner = 0; + entry->recursion = 0; + } + unlock_linux_mutex_registry(); + if (entry == NULL) { + return 0; + } + return 0; +} + +static int metalsharp_eac_pthread_mutex_lock(void* guest) { + ms_log("Linux pthread_mutex_lock guest=0x%llx", (unsigned long long)(uintptr_t)guest); + MsLinuxMutexEntry* entry = get_linux_mutex_entry(guest, true, NULL); + if (entry == NULL) { + return ENOMEM; + } + uint32_t owner = (uint32_t)mach_thread_self(); + if (entry->recursive && entry->owner == owner && entry->state != 0) { + entry->recursion++; + return 0; + } + while (__sync_lock_test_and_set(&entry->state, 1) != 0) { + __builtin_ia32_pause(); + } + entry->owner = owner; + entry->recursion = 1; + return 0; +} + +static int metalsharp_eac_pthread_mutex_unlock(void* guest) { + ms_log("Linux pthread_mutex_unlock guest=0x%llx", (unsigned long long)(uintptr_t)guest); + MsLinuxMutexEntry* entry = get_linux_mutex_entry(guest, false, NULL); + if (entry == NULL || entry->state == 0) { + return 0; + } + if (entry->recursive && entry->recursion > 1) { + entry->recursion--; + return 0; + } + entry->owner = 0; + entry->recursion = 0; + __sync_lock_release(&entry->state); + return 0; +} + +static int metalsharp_eac_pthread_mutex_trylock(void* guest) { + ms_log("Linux pthread_mutex_trylock guest=0x%llx", (unsigned long long)(uintptr_t)guest); + MsLinuxMutexEntry* entry = get_linux_mutex_entry(guest, true, NULL); + if (entry == NULL) { + return ENOMEM; + } + uint32_t owner = (uint32_t)mach_thread_self(); + if (entry->recursive && entry->owner == owner && entry->state != 0) { + entry->recursion++; + return 0; + } + if (__sync_lock_test_and_set(&entry->state, 1) != 0) { + return EBUSY; + } + entry->owner = owner; + entry->recursion = 1; + return 0; +} + +static int metalsharp_eac_pthread_key_create(uint32_t* key, void (*destructor)(void*)) { + if (key == NULL) { + return EINVAL; + } + (void)destructor; + static volatile uint32_t sequence = 1; + *key = __sync_fetch_and_add(&sequence, 1); + return 0; +} + +typedef void (*MsCxaDestructor)(void*); +typedef struct MsCxaExitEntry { + MsCxaDestructor destructor; + void* argument; + void* dso_handle; + struct MsCxaExitEntry* next; +} MsCxaExitEntry; + +static MsCxaExitEntry* g_linux_cxa_exit_entries; + +static int metalsharp_eac_cxa_atexit(MsCxaDestructor destructor, void* argument, void* dso_handle) { + if (destructor == NULL) { + return 0; + } + MsCxaExitEntry* entry = metalsharp_eac_calloc(1, sizeof(*entry)); + if (entry == NULL) { + return ENOMEM; + } + entry->destructor = destructor; + entry->argument = argument; + entry->dso_handle = dso_handle; + entry->next = g_linux_cxa_exit_entries; + g_linux_cxa_exit_entries = entry; + ms_log("registered Linux EAC destructor dso=0x%llx", (unsigned long long)(uintptr_t)dso_handle); + return 0; +} + +static void metalsharp_eac_cxa_finalize(void* dso_handle) { + /* The module remains mapped for the life of the launcher. Keep the + * registrations in the Linux substrate instead of passing an ELF dso + * handle to dyld's __cxa_finalize, whose data structures are unrelated. */ + ms_log("Linux EAC __cxa_finalize dso=0x%llx deferred", (unsigned long long)(uintptr_t)dso_handle); +} + +/* Linux's dl_iterate_phdr callback ABI is stable and deliberately smaller + * than the host dyld image model. Report the loaded EAC image through the + * same program-header table used by the loader; callers that only need to + * discover their own image can then use the real mapped addresses. */ +typedef struct { + uintptr_t dlpi_addr; + const char* dlpi_name; + const MsElfProgramHeader* dlpi_phdr; + uint16_t dlpi_phnum; + uint16_t reserved0; + uint32_t reserved1; + uint64_t dlpi_adds; + uint64_t dlpi_subs; + size_t dlpi_tls_modid; + uintptr_t dlpi_tls_data; +} MsLinuxDlPhdrInfo; + +typedef int (*MsLinuxDlPhdrCallback)(MsLinuxDlPhdrInfo* info, size_t size, void* data); + +static int metalsharp_eac_dl_iterate_phdr(MsLinuxDlPhdrCallback callback, void* data) { + if (callback == NULL || !g_linux_module.loaded) { + return 0; + } + MsLinuxDlPhdrInfo info = { + .dlpi_addr = (uintptr_t)g_linux_module.base, + .dlpi_name = "[metalsharp-eac-linux-module]", + .dlpi_phdr = g_linux_module.program_headers, + .dlpi_phnum = g_linux_module.program_count, + .dlpi_tls_modid = g_linux_module.tls_size != 0 ? 1 : 0, + .dlpi_tls_data = (uintptr_t)g_linux_tls_block, + }; + return callback(&info, sizeof(info), data); +} + +static long metalsharp_eac_prctl(int option, ...) { + /* Linux callers use prctl for process metadata and dumpability. There + * is no Darwin equivalent for every option; options that only establish + * advisory metadata are successful, while unknown operations retain a + * real errno instead of pretending that a security operation happened. */ + if (option == 15 /* PR_SET_NAME */ || option == 4 /* PR_SET_DUMPABLE */ || option == 1 /* PR_SET_PDEATHSIG */) { + return 0; + } + errno = ENOSYS; + return -1; +} + +static char* metalsharp_eac_secure_getenv(const char* name) { + return getenv(name); +} + +static const char* metalsharp_eac_gettext(const char* message) { + return message != NULL ? message : ""; +} + +typedef struct { + int64_t seconds; + int64_t nanoseconds; +} MsLinuxTimespec; + +typedef struct { + uint64_t device; + uint64_t inode; + uint64_t link_count; + uint32_t mode; + uint32_t uid; + uint32_t gid; + uint32_t padding0; + uint64_t rdevice; + int64_t size; + int64_t block_size; + int64_t blocks; + MsLinuxTimespec access_time; + MsLinuxTimespec modify_time; + MsLinuxTimespec change_time; + int64_t reserved[3]; +} MsLinuxStat; + +static int metalsharp_eac_xstat(int version, const char* path, MsLinuxStat* result) { + (void)version; + if (path == NULL || result == NULL) { + errno = EINVAL; + return -1; + } + struct stat host_stat; + if (stat(path, &host_stat) != 0) { + return -1; + } + memset(result, 0, sizeof(*result)); + result->device = (uint64_t)host_stat.st_dev; + result->inode = (uint64_t)host_stat.st_ino; + result->link_count = (uint64_t)host_stat.st_nlink; + result->mode = (uint32_t)host_stat.st_mode; + result->uid = (uint32_t)host_stat.st_uid; + result->gid = (uint32_t)host_stat.st_gid; + result->rdevice = (uint64_t)host_stat.st_rdev; + result->size = (int64_t)host_stat.st_size; + result->block_size = (int64_t)host_stat.st_blksize; + result->blocks = (int64_t)host_stat.st_blocks; + result->access_time.seconds = (int64_t)host_stat.st_atimespec.tv_sec; + result->access_time.nanoseconds = (int64_t)host_stat.st_atimespec.tv_nsec; + result->modify_time.seconds = (int64_t)host_stat.st_mtimespec.tv_sec; + result->modify_time.nanoseconds = (int64_t)host_stat.st_mtimespec.tv_nsec; + result->change_time.seconds = (int64_t)host_stat.st_ctimespec.tv_sec; + result->change_time.nanoseconds = (int64_t)host_stat.st_ctimespec.tv_nsec; + return 0; +} + +static char* metalsharp_eac_xpg_basename(char* path) { + return path != NULL ? basename(path) : (errno = EINVAL, NULL); +} + +static long metalsharp_eac_syscall(long number, ...) { + va_list args; + va_start(args, number); + long result = -1; + switch (number) { + case MS_LINUX_READ: { + int fd = va_arg(args, int); + void* buffer = va_arg(args, void*); + size_t length = va_arg(args, size_t); + result = read(fd, buffer, length); + break; + } + case MS_LINUX_WRITE: { + int fd = va_arg(args, int); + const void* buffer = va_arg(args, const void*); + size_t length = va_arg(args, size_t); + result = write(fd, buffer, length); + break; + } + case MS_LINUX_OPEN: { + const char* path = va_arg(args, const char*); + int flags = va_arg(args, int); + mode_t mode = va_arg(args, int); + result = (flags & O_CREAT) != 0 ? metalsharp_eac_open(path, flags, mode) : metalsharp_eac_open(path, flags); + break; + } + case MS_LINUX_CLOSE: + result = close(va_arg(args, int)); + break; + case MS_LINUX_FSTAT: { + int fd = va_arg(args, int); + MsLinuxStat* output = va_arg(args, MsLinuxStat*); + struct stat host_stat; + if (fstat(fd, &host_stat) == 0 && output != NULL) { + memset(output, 0, sizeof(*output)); + output->device = (uint64_t)host_stat.st_dev; + output->inode = (uint64_t)host_stat.st_ino; + output->link_count = (uint64_t)host_stat.st_nlink; + output->mode = (uint32_t)host_stat.st_mode; + output->uid = (uint32_t)host_stat.st_uid; + output->gid = (uint32_t)host_stat.st_gid; + output->rdevice = (uint64_t)host_stat.st_rdev; + output->size = (int64_t)host_stat.st_size; + output->block_size = (int64_t)host_stat.st_blksize; + output->blocks = (int64_t)host_stat.st_blocks; + output->access_time.seconds = (int64_t)host_stat.st_atimespec.tv_sec; + output->access_time.nanoseconds = (int64_t)host_stat.st_atimespec.tv_nsec; + output->modify_time.seconds = (int64_t)host_stat.st_mtimespec.tv_sec; + output->modify_time.nanoseconds = (int64_t)host_stat.st_mtimespec.tv_nsec; + output->change_time.seconds = (int64_t)host_stat.st_ctimespec.tv_sec; + output->change_time.nanoseconds = (int64_t)host_stat.st_ctimespec.tv_nsec; + } else if (output == NULL) { + errno = EINVAL; + result = -1; + } else { + result = -1; + } + break; + } + case MS_LINUX_LSEEK: + result = (long)lseek(va_arg(args, int), va_arg(args, off_t), va_arg(args, int)); + break; + case MS_LINUX_MMAP: { + void* address = va_arg(args, void*); + size_t length = va_arg(args, size_t); + int prot = va_arg(args, int); + int flags = va_arg(args, int); + int fd = va_arg(args, int); + off_t offset = va_arg(args, off_t); + if ((flags & 0x20) != 0) { + flags &= ~0x20; + flags |= MAP_ANON; + } + result = (long)(intptr_t)mmap(address, length, prot, flags, fd, offset); + break; + } + case MS_LINUX_MPROTECT: + result = mprotect(va_arg(args, void*), va_arg(args, size_t), va_arg(args, int)); + break; + case MS_LINUX_MUNMAP: + result = munmap(va_arg(args, void*), va_arg(args, size_t)); + break; + case MS_LINUX_GETPID: + result = getpid(); + break; + case MS_LINUX_GETPPID: + result = getppid(); + break; + case MS_LINUX_FTRUNCATE: + result = ftruncate(va_arg(args, int), va_arg(args, off_t)); + break; + case MS_LINUX_MEMFD_CREATE: + result = metalsharp_memfd_create((unsigned int)va_arg(args, unsigned int)); + break; + default: + errno = ENOSYS; + result = -1; + break; + } + va_end(args); + return result; +} + +static void* host_symbol(const char* name) { + if (name == NULL) { + return NULL; + } + (void)dlerror(); + void* result = dlsym(RTLD_DEFAULT, name); + (void)dlerror(); + if (g_linux_module.loaded) { + ms_log("resolved EAC Linux host symbol name=%s address=0x%llx", name, (unsigned long long)(uintptr_t)result); + } + return result; +} + +static void* resolve_substrate_target(const char* name) { + if (name == NULL) { + return (void*)&metalsharp_eac_unimplemented; + } + if (strcmp(name, "__libc_dlopen_mode") == 0 || strcmp(name, "dlopen") == 0) { + return (void*)&metalsharp_eac_dlopen; + } + if (strcmp(name, "__libc_dlsym") == 0 || strcmp(name, "dlsym") == 0) { + return (void*)&metalsharp_eac_dlsym; + } + if (strcmp(name, "dlclose") == 0) { + return (void*)&metalsharp_eac_dlclose; + } + if (strcmp(name, "dlerror") == 0) { + return (void*)&metalsharp_eac_dlerror; + } + if (strcmp(name, "__cxa_atexit") == 0) { + return (void*)&metalsharp_eac_cxa_atexit; + } + if (strcmp(name, "__cxa_finalize") == 0) { + return (void*)&metalsharp_eac_cxa_finalize; + } + if (strcmp(name, "__errno_location") == 0) { + return (void*)&metalsharp_eac_errno_location; + } + if (strcmp(name, "__tls_get_addr") == 0) { + return (void*)&metalsharp_eac_tls_get_addr; + } + if (strcmp(name, "__pthread_key_create") == 0) { + return (void*)&metalsharp_eac_pthread_key_create; + } + if (strcmp(name, "pthread_key_create") == 0) { + return (void*)&metalsharp_eac_pthread_key_create; + } + if (strcmp(name, "pthread_key_delete") == 0) { + return (void*)&metalsharp_eac_pthread_key_delete; + } + if (strcmp(name, "pthread_setspecific") == 0) { + return (void*)&metalsharp_eac_pthread_setspecific; + } + if (strcmp(name, "pthread_getspecific") == 0) { + return (void*)&metalsharp_eac_pthread_getspecific; + } + if (strcmp(name, "pthread_self") == 0) { + return (void*)&metalsharp_eac_pthread_self; + } + if (strcmp(name, "stdin") == 0) { + return (void*)&stdin; + } + if (strcmp(name, "stdout") == 0) { + return (void*)&stdout; + } + if (strcmp(name, "stderr") == 0) { + return (void*)&stderr; + } + if (strcmp(name, "dl_iterate_phdr") == 0) { + return (void*)&metalsharp_eac_dl_iterate_phdr; + } + if (strcmp(name, "pthread_mutexattr_init") == 0) { + return (void*)&metalsharp_eac_pthread_mutexattr_init; + } + if (strcmp(name, "pthread_mutexattr_destroy") == 0) { + return (void*)&metalsharp_eac_pthread_mutexattr_destroy; + } + if (strcmp(name, "pthread_mutexattr_settype") == 0) { + return (void*)&metalsharp_eac_pthread_mutexattr_settype; + } + if (strcmp(name, "pthread_mutex_init") == 0) { + return (void*)&metalsharp_eac_pthread_mutex_init; + } + if (strcmp(name, "pthread_mutex_destroy") == 0) { + return (void*)&metalsharp_eac_pthread_mutex_destroy; + } + if (strcmp(name, "pthread_mutex_lock") == 0) { + return (void*)&metalsharp_eac_pthread_mutex_lock; + } + if (strcmp(name, "pthread_mutex_unlock") == 0) { + return (void*)&metalsharp_eac_pthread_mutex_unlock; + } + if (strcmp(name, "pthread_mutex_trylock") == 0) { + return (void*)&metalsharp_eac_pthread_mutex_trylock; + } + if (strcmp(name, "malloc") == 0) { + return (void*)&metalsharp_eac_malloc; + } + if (strcmp(name, "calloc") == 0) { + return (void*)&metalsharp_eac_calloc; + } + if (strcmp(name, "realloc") == 0) { + return (void*)&metalsharp_eac_realloc; + } + if (strcmp(name, "free") == 0) { + return (void*)&metalsharp_eac_free; + } + if (strcmp(name, "_ZGTtnam") == 0) { + return (void*)&metalsharp_eac_operator_new; + } + if (strcmp(name, "_ZGTtdlPv") == 0) { + return (void*)&metalsharp_eac_operator_delete; + } + if (strcmp(name, "__gmon_start__") == 0 || strncmp(name, "_ITM_", 5) == 0) { + return (void*)&metalsharp_eac_noop; + } + if (strcmp(name, "open") == 0 || strcmp(name, "open64") == 0) { + return (void*)&metalsharp_eac_open; + } + if (strcmp(name, "prctl") == 0) { + return (void*)&metalsharp_eac_prctl; + } + if (strcmp(name, "secure_getenv") == 0) { + return (void*)&metalsharp_eac_secure_getenv; + } + if (strcmp(name, "gettext") == 0) { + return (void*)&metalsharp_eac_gettext; + } + if (strcmp(name, "__xstat") == 0) { + return (void*)&metalsharp_eac_xstat; + } + if (strcmp(name, "__xpg_basename") == 0) { + return (void*)&metalsharp_eac_xpg_basename; + } + if (strcmp(name, "syscall") == 0) { + return (void*)&metalsharp_eac_syscall; + } + void* host = host_symbol(name); + if (host != NULL) { + return host; + } + ms_log("unresolved Linux ABI symbol name=%s", name); + return (void*)&metalsharp_eac_unimplemented; +} + +static uint64_t align_down_linux(uint64_t value) { + return value & ~((uint64_t)MS_PAGE_SIZE - 1u); +} + +static uint64_t align_up_linux(uint64_t value) { + return (value + MS_PAGE_SIZE - 1u) & ~((uint64_t)MS_PAGE_SIZE - 1u); +} + +static void* linux_module_address(const MsLinuxLoadedModule* module, uint64_t vaddr) { + if (module == NULL || module->mapping_start == NULL || vaddr < module->minimum_vaddr || + vaddr - module->minimum_vaddr >= module->size) { + return NULL; + } + return (uint8_t*)module->mapping_start + (vaddr - module->minimum_vaddr); +} + +static size_t translate_linux_stack_canary_segment(MsLinuxLoadedModule* module) { + if (module == NULL || module->mapping_start == NULL) { + return 0; + } + /* Wine's x86-64 guest TEB is carried in GS on the existing MetalSharp + * runtime. Linux ELF code emitted by the EAC toolchain uses FS:0x28 for + * the stack guard. Translate only the canonical stack-protector load and + * compare forms; all other segment operations remain guest code. */ + uint8_t* bytes = module->mapping_start; + size_t translated = 0; + for (size_t offset = 0; offset + 9 <= module->size; offset++) { + if (bytes[offset] != 0x64 || bytes[offset + 1] != 0x48 || + (bytes[offset + 2] != 0x8b && bytes[offset + 2] != 0x33) || bytes[offset + 4] != 0x25 || + bytes[offset + 5] != 0x28 || bytes[offset + 6] != 0x00 || bytes[offset + 7] != 0x00 || + bytes[offset + 8] != 0x00) { + continue; + } + bytes[offset] = 0x65; /* GS */ + translated++; + } + if (translated != 0) { + ms_log("translated Linux FS stack-canary accesses to Wine GS count=%zu", translated); + } + return translated; +} + +static bool protect_linux_module_segments(MsLinuxLoadedModule* module) { + if (module == NULL || module->mapping_start == NULL || module->size == 0 || (module->size % MS_PAGE_SIZE) != 0) { + return false; + } + size_t page_count = module->size / MS_PAGE_SIZE; + uint8_t* page_protections = calloc(page_count, sizeof(*page_protections)); + if (page_protections == NULL) { + ms_log("cannot allocate EAC Linux module page protections"); + return false; + } + for (uint16_t index = 0; index < module->program_count; index++) { + const MsElfProgramHeader* program = &module->program_headers[index]; + if (program->type != 1 /* PT_LOAD */ || program->memsz == 0) { + continue; + } + uint64_t start = align_down_linux(program->vaddr); + uint64_t end = align_up_linux(program->vaddr + program->memsz); + if (start < module->minimum_vaddr || end < start || end - module->minimum_vaddr > module->size) { + free(page_protections); + ms_log("invalid EAC Linux module protection range index=%u", index); + return false; + } + unsigned char protection = 0; + if ((program->flags & 4u) != 0) { + protection |= PROT_READ; + } + if ((program->flags & 2u) != 0) { + protection |= PROT_WRITE; + } + if ((program->flags & 1u) != 0) { + protection |= PROT_EXEC; + } + size_t first_page = (size_t)((start - module->minimum_vaddr) / MS_PAGE_SIZE); + size_t last_page = (size_t)((end - module->minimum_vaddr) / MS_PAGE_SIZE); + for (size_t page = first_page; page < last_page; page++) { + page_protections[page] |= protection; + } + } + bool success = true; + size_t run_start = 0; + while (run_start < page_count) { + uint8_t protection = page_protections[run_start]; + size_t run_end = run_start + 1; + while (run_end < page_count && page_protections[run_end] == protection) { + run_end++; + } + int mach_protection = protection == 0 ? PROT_NONE : protection; + if (mprotect((uint8_t*)module->mapping_start + run_start * MS_PAGE_SIZE, (run_end - run_start) * MS_PAGE_SIZE, + mach_protection) != 0) { + ms_log("cannot apply EAC Linux module protections page=%zu count=%zu prot=0x%x errno=%d", run_start, + run_end - run_start, mach_protection, errno); + success = false; + break; + } + run_start = run_end; + } + free(page_protections); + if (success) { + module->protections_applied = true; + ms_log("applied EAC Linux PT_LOAD protections pages=%zu", page_count); + } + return success; +} + +static uint64_t linux_dynamic_value(const MsLinuxLoadedModule* module, int64_t tag) { + for (size_t index = 0; index < module->dynamic_count; index++) { + if (module->dynamic[index].tag == tag) { + return module->dynamic[index].value; + } + } + return 0; +} + +static const char* linux_module_symbol_name(const MsLinuxLoadedModule* module, size_t index) { + if (module == NULL || module->symbols == NULL || index >= module->symbol_count) { + return NULL; + } + uint32_t offset = module->symbols[index].name; + if (module->strings == NULL || module->string_size == 0 || offset >= module->string_size) { + return NULL; + } + const char* name = module->strings + offset; + if (memchr(name, '\0', module->string_size - offset) == NULL) { + return NULL; + } + return name; +} + +static void* linux_module_symbol_value(MsLinuxLoadedModule* module, size_t index) { + if (module == NULL || module->symbols == NULL || index >= module->symbol_count) { + return NULL; + } + MsElfSymbol* symbol = &module->symbols[index]; + if (symbol->shndx == 0) { + const char* name = linux_module_symbol_name(module, index); + return resolve_substrate_target(name); + } + return linux_module_address(module, symbol->value); +} + +static bool apply_linux_module_relocations(MsLinuxLoadedModule* module, uint64_t address, uint64_t size, + const char* kind) { + if (address == 0 || size == 0) { + return true; + } + if ((size % sizeof(MsElfRela)) != 0) { + ms_log("invalid Linux %s relocation size=0x%llx", kind, (unsigned long long)size); + return false; + } + MsElfRela* relocations = linux_module_address(module, address); + if (relocations == NULL) { + ms_log("Linux %s relocation table is outside module address=0x%llx", kind, (unsigned long long)address); + return false; + } + size_t count = (size_t)(size / sizeof(MsElfRela)); + for (size_t index = 0; index < count; index++) { + MsElfRela* relocation = &relocations[index]; + uint32_t type = (uint32_t)(relocation->info & 0xffffffffu); + uint32_t symbol_index = (uint32_t)(relocation->info >> 32); + if (index < 8 || (index % 128) == 0) { + ms_log("EAC %s relocation index=%zu/%zu type=%u symbol=%u offset=0x%llx", kind, index, count, type, + symbol_index, (unsigned long long)relocation->offset); + } + uintptr_t* target = linux_module_address(module, relocation->offset); + if (target == NULL) { + ms_log("Linux %s relocation target outside module offset=0x%llx", kind, + (unsigned long long)relocation->offset); + return false; + } + switch (type) { + case MS_R_X86_64_RELATIVE: + *target = (uintptr_t)module->base + (intptr_t)relocation->addend; + break; + case MS_R_X86_64_DTPMOD64: + *target = 1; + break; + case MS_R_X86_64_64: + case MS_R_X86_64_GLOB_DAT: + case MS_R_X86_64_JUMP_SLOT: { + uintptr_t value = 0; + if (symbol_index != 0) { + const char* relocation_name = linux_module_symbol_name(module, symbol_index); + ms_log("EAC %s symbol relocation index=%zu symbol=%u name_ptr=0x%llx name=%s", kind, index, + symbol_index, (unsigned long long)(uintptr_t)relocation_name, + relocation_name != NULL ? relocation_name : ""); + value = (uintptr_t)linux_module_symbol_value(module, symbol_index); + if (value == 0) { + ms_log("Linux relocation unresolved symbol=%s type=%u", + relocation_name != NULL ? relocation_name : "", type); + if ((module->symbols[symbol_index].info >> 4) != 2 /* STB_WEAK */) { + return false; + } + } + } + *target = value + (intptr_t)relocation->addend; + break; + } + default: + ms_log("unsupported Linux relocation type=%u offset=0x%llx", type, (unsigned long long)relocation->offset); + return false; + } + } + return true; +} + +static void append_linux_module_map(const MsLinuxLoadedModule* module, int fd) { + if (g_maps_path[0] == '\0' || module == NULL) { + return; + } + int maps_fd = ms_raw_open(g_maps_path, O_WRONLY | O_APPEND, 0); + if (maps_fd < 0) { + ms_log("cannot append Linux module map errno=%d", errno); + return; + } + const char* dump = getenv("METALSHARP_EAC_MODULE_DUMP"); + const char* name = dump != NULL && dump[0] != '\0' ? dump : "/proc/self/fd"; + char line[512]; + int length = snprintf(line, sizeof(line), "%016llx-%016llx rwxp 00000000 00:00 0 %s\n", + (unsigned long long)(uintptr_t)module->base, + (unsigned long long)((uintptr_t)module->base + module->size), name); + if (length > 0) { + (void)write(maps_fd, line, (size_t)length); + } + close(maps_fd); + ms_log("published Linux EAC module map fd=%d base=0x%llx size=0x%zx", fd, + (unsigned long long)(uintptr_t)module->base, module->size); +} + +static bool initialize_linux_module(MsLinuxLoadedModule* module) { + if (module->initialized) { + return true; + } + module->initialized = true; + if (module->init != 0) { + void (*initializer)(void) = (void (*)(void))linux_module_address(module, module->init); + if (initializer != NULL) { + ms_log("calling Linux EAC DT_INIT base=0x%llx", (unsigned long long)(uintptr_t)initializer); + initializer(); + } + } + if (module->init_array != 0) { + void (**initializers)(void) = linux_module_address(module, module->init_array); + for (size_t index = 0; initializers != NULL && index < module->init_array_count; index++) { + if (initializers[index] != NULL && (uintptr_t)initializers[index] != UINTPTR_MAX) { + ms_log("calling Linux EAC init_array[%zu] base=0x%llx", index, + (unsigned long long)(uintptr_t)initializers[index]); + initializers[index](); + module->init_array_called++; + } + } + } + return true; +} + +static void* load_linux_module_fd(int fd) { + __sync_fetch_and_add(&g_eac_module_load_attempts, 1); + if (g_linux_module.loaded) { + return g_linux_module.base; + } + ms_log("loading EAC Linux module fd=%d", fd); + struct stat file_stat; + if (fstat(fd, &file_stat) != 0 || file_stat.st_size < (off_t)sizeof(MsElfHeader)) { + ms_log("cannot stat EAC Linux module fd=%d errno=%d", fd, errno); + return NULL; + } + MsElfHeader header; + if (pread(fd, &header, sizeof(header), 0) != (ssize_t)sizeof(header) || + memcmp(header.ident, + "\x7f" + "ELF", + 4) != 0 || + header.ident[4] != 2 || header.ident[5] != 1 || header.type != 3 || header.machine != 62 || + header.phentsize != sizeof(MsElfProgramHeader) || header.phnum == 0) { + ms_log("invalid EAC Linux ELF header fd=%d", fd); + return NULL; + } + ms_log("validated EAC Linux ELF fd=%d size=%lld phnum=%u", fd, (long long)file_stat.st_size, header.phnum); + if (getenv("METALSHARP_EAC_MODULE_DUMP") != NULL) { + (void)dump_linux_module_fd(fd); + } + size_t phdr_bytes = (size_t)header.phnum * header.phentsize; + if (header.phoff > (uint64_t)file_stat.st_size || phdr_bytes > (size_t)file_stat.st_size - header.phoff) { + ms_log("EAC Linux ELF program headers exceed file fd=%d", fd); + return NULL; + } + MsElfProgramHeader* program_headers = metalsharp_eac_calloc(header.phnum, sizeof(*program_headers)); + if (program_headers == NULL || pread(fd, program_headers, phdr_bytes, (off_t)header.phoff) != (ssize_t)phdr_bytes) { + metalsharp_eac_free(program_headers); + ms_log("cannot read EAC Linux ELF program headers fd=%d", fd); + return NULL; + } + + uint64_t minimum = UINT64_MAX; + uint64_t maximum = 0; + for (uint16_t index = 0; index < header.phnum; index++) { + MsElfProgramHeader* program = &program_headers[index]; + if (program->type != 1 /* PT_LOAD */) { + continue; + } + if (program->filesz > program->memsz || program->offset > (uint64_t)file_stat.st_size || + program->filesz > (uint64_t)file_stat.st_size - program->offset) { + metalsharp_eac_free(program_headers); + ms_log("invalid EAC Linux load segment index=%u", index); + return NULL; + } + uint64_t start = align_down_linux(program->vaddr); + uint64_t end = align_up_linux(program->vaddr + program->memsz); + if (end < start) { + metalsharp_eac_free(program_headers); + return NULL; + } + if (start < minimum) { + minimum = start; + } + if (end > maximum) { + maximum = end; + } + } + if (minimum == UINT64_MAX || maximum <= minimum || maximum - minimum > SIZE_MAX) { + metalsharp_eac_free(program_headers); + ms_log("EAC Linux ELF has no usable load segments"); + return NULL; + } + size_t image_size = (size_t)(maximum - minimum); + void* mapping = mmap((void*)(uintptr_t)0x700300000000ULL, image_size, PROT_READ | PROT_WRITE | PROT_EXEC, + MAP_PRIVATE | MAP_ANON, -1, 0); + if (mapping == MAP_FAILED) { + mapping = mmap(NULL, image_size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANON, -1, 0); + } + if (mapping == MAP_FAILED) { + metalsharp_eac_free(program_headers); + ms_log("cannot map EAC Linux module size=0x%zx errno=%d", image_size, errno); + return NULL; + } + for (uint16_t index = 0; index < header.phnum; index++) { + MsElfProgramHeader* program = &program_headers[index]; + if (program->type != 1) { + continue; + } + uint8_t* destination = (uint8_t*)mapping + (program->vaddr - minimum); + if (program->filesz != 0 && + pread(fd, destination, (size_t)program->filesz, (off_t)program->offset) != (ssize_t)program->filesz) { + munmap(mapping, image_size); + metalsharp_eac_free(program_headers); + ms_log("cannot read EAC Linux load segment index=%u", index); + return NULL; + } + if (program->memsz > program->filesz) { + memset(destination + program->filesz, 0, (size_t)(program->memsz - program->filesz)); + } + } + + MsLinuxLoadedModule module = {0}; + module.mapping_start = mapping; + module.base = (uint8_t*)mapping - minimum; + module.size = image_size; + module.minimum_vaddr = minimum; + module.program_headers = program_headers; + module.program_count = header.phnum; + module.module_fd = fd; + module.loaded = true; + for (uint16_t index = 0; index < header.phnum; index++) { + MsElfProgramHeader* program = &program_headers[index]; + if (program->type == MS_PT_DYNAMIC) { + module.dynamic = linux_module_address(&module, program->vaddr); + module.dynamic_count = (size_t)(program->filesz / sizeof(MsElfDynamic)); + } else if (program->type == 7 /* PT_TLS */) { + module.tls_size = (size_t)program->memsz; + module.tls_align = (size_t)(program->align != 0 ? program->align : 1); + } + } + if (module.dynamic == NULL || module.dynamic_count == 0) { + munmap(mapping, image_size); + metalsharp_eac_free(program_headers); + ms_log("EAC Linux module has no PT_DYNAMIC segment"); + return NULL; + } + uint64_t strings_vaddr = linux_dynamic_value(&module, MS_DT_STRTAB); + uint64_t symbols_vaddr = linux_dynamic_value(&module, MS_DT_SYMTAB); + uint64_t hash_vaddr = linux_dynamic_value(&module, MS_DT_HASH); + module.strings = linux_module_address(&module, strings_vaddr); + module.string_size = (size_t)linux_dynamic_value(&module, MS_DT_STRSZ); + module.symbols = linux_module_address(&module, symbols_vaddr); + if (module.strings == NULL || module.string_size == 0 || module.symbols == NULL || hash_vaddr == 0) { + munmap(mapping, image_size); + metalsharp_eac_free(program_headers); + ms_log("EAC Linux module dynamic symbol tables are invalid"); + return NULL; + } + uint32_t* hash = linux_module_address(&module, hash_vaddr); + module.symbol_count = hash != NULL ? hash[1] : 0; + module.init = linux_dynamic_value(&module, MS_DT_INIT); + module.init_array = linux_dynamic_value(&module, MS_DT_INIT_ARRAY); + module.init_array_count = (size_t)(linux_dynamic_value(&module, MS_DT_INIT_ARRAYSZ) / sizeof(uintptr_t)); + module.rela_count = (size_t)(linux_dynamic_value(&module, MS_DT_RELASZ) / sizeof(MsElfRela)); + module.plt_count = (size_t)(linux_dynamic_value(&module, MS_DT_PLTRELSZ) / sizeof(MsElfRela)); + g_linux_module = module; + ms_log("mapped EAC Linux ELF base=0x%llx size=0x%zx symbols=%zu tls=0x%zx", + (unsigned long long)(uintptr_t)module.base, module.size, module.symbol_count, module.tls_size); + (void)translate_linux_stack_canary_segment(&g_linux_module); + + uint64_t rela_address = linux_dynamic_value(&g_linux_module, MS_DT_RELA); + uint64_t rela_size = linux_dynamic_value(&g_linux_module, MS_DT_RELASZ); + uint64_t plt_address = linux_dynamic_value(&g_linux_module, MS_DT_JMPREL); + uint64_t plt_size = linux_dynamic_value(&g_linux_module, MS_DT_PLTRELSZ); + ms_log("EAC relocation tables rela=0x%llx/0x%llx plt=0x%llx/0x%llx", (unsigned long long)rela_address, + (unsigned long long)rela_size, (unsigned long long)plt_address, (unsigned long long)plt_size); + bool rela_relocated = apply_linux_module_relocations(&g_linux_module, rela_address, rela_size, "RELA"); + g_linux_module.rela_relocated = rela_relocated; + ms_log("EAC RELA relocation pass complete=%d", rela_relocated ? 1 : 0); + bool plt_relocated = + rela_relocated && apply_linux_module_relocations(&g_linux_module, plt_address, plt_size, "PLT"); + g_linux_module.plt_relocated = plt_relocated; + ms_log("EAC PLT relocation pass complete=%d", plt_relocated ? 1 : 0); + bool relocated = rela_relocated && plt_relocated; + if (!relocated) { + (void)dump_linux_module_fd(fd); + munmap(mapping, image_size); + metalsharp_eac_free(program_headers); + memset(&g_linux_module, 0, sizeof(g_linux_module)); + return NULL; + } + if (!protect_linux_module_segments(&g_linux_module)) { + (void)dump_linux_module_fd(fd); + munmap(mapping, image_size); + metalsharp_eac_free(program_headers); + memset(&g_linux_module, 0, sizeof(g_linux_module)); + return NULL; + } + append_linux_module_map(&g_linux_module, fd); + if (!initialize_linux_module(&g_linux_module)) { + (void)dump_linux_module_fd(fd); + munmap(mapping, image_size); + metalsharp_eac_free(program_headers); + memset(&g_linux_module, 0, sizeof(g_linux_module)); + return NULL; + } + __sync_fetch_and_add(&g_eac_module_load_successes, 1); + ms_log("EAC_PROOF module_loaded=1 base=0x%llx size=0x%zx rela_count=%zu plt_count=%zu init_array_count=%zu " + "init_array_called=%zu protections=0x%x exports_mask=0x%x", + (unsigned long long)(uintptr_t)g_linux_module.base, g_linux_module.size, g_linux_module.rela_count, + g_linux_module.plt_count, g_linux_module.init_array_count, g_linux_module.init_array_called, + g_linux_module.protections_applied ? 1 : 0, __atomic_load_n(&g_eac_export_mask, __ATOMIC_RELAXED)); + return g_linux_module.base; +} + +static void* lookup_linux_module_symbol(MsLinuxLoadedModule* module, const char* name) { + if (module == NULL || !module->loaded || name == NULL) { + return NULL; + } + for (size_t index = 1; index < module->symbol_count; index++) { + MsElfSymbol* symbol = &module->symbols[index]; + if (symbol->shndx == 0) { + continue; + } + const char* symbol_name = linux_module_symbol_name(module, index); + if (symbol_name != NULL && strcmp(symbol_name, name) == 0) { + return linux_module_address(module, symbol->value); + } + } + return NULL; +} + +static int metalsharp_eac_dlclose(void* handle) { + if (handle == g_linux_module.base) { + ms_log("__libc_dlclose EAC module handle=0x%llx", (unsigned long long)(uintptr_t)handle); + return 0; + } + return 0; +} + +static const char* metalsharp_eac_dlerror(void) { + return g_linux_dlerror[0] != '\0' ? g_linux_dlerror : NULL; +} + +static bool map_linux_libc_image(void) { + const char* configured = getenv("METALSHARP_EAC_SUBSTRATE_LIBC"); + if (configured == NULL || configured[0] == '\0') { + ms_log("METALSHARP_EAC_SUBSTRATE_LIBC is not set"); + return false; + } + snprintf(g_elf_path, sizeof(g_elf_path), "%s", configured); + + int fd = ms_raw_open(g_elf_path, O_RDWR, 0); + if (fd < 0) { + ms_log("open ELF image failed path=%s errno=%d", g_elf_path, errno); + return false; + } + struct stat st; + if (fstat(fd, &st) != 0 || st.st_size <= 0) { + ms_log("stat ELF image failed errno=%d", errno); + close(fd); + return false; + } + size_t length = (size_t)st.st_size; + size_t map_length = (length + MS_PAGE_SIZE - 1u) & ~(MS_PAGE_SIZE - 1u); + void* mapping = + mmap(MS_PREFERRED_ELF_BASE, map_length, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_FIXED, fd, 0); + close(fd); + if (mapping == MAP_FAILED) { + int readonly_fd = ms_raw_open(g_elf_path, O_RDONLY, 0); + mapping = + mmap(MS_PREFERRED_ELF_BASE, map_length, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_FIXED, readonly_fd, 0); + if (readonly_fd >= 0) { + close(readonly_fd); + } + if (mapping == MAP_FAILED) { + ms_log("mmap ELF image failed errno=%d", errno); + return false; + } + if (mprotect(mapping, map_length, PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { + ms_log("mprotect ELF image failed errno=%d", errno); + munmap(mapping, map_length); + return false; + } + } + if (mprotect(mapping, map_length, PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { + ms_log("mprotect ELF image failed errno=%d", errno); + munmap(mapping, map_length); + return false; + } + g_elf_mapping = mapping; + g_elf_mapping_size = map_length; + ms_log("mapped ELF libc image path=%s base=0x%llx size=0x%zx", g_elf_path, (unsigned long long)(uintptr_t)mapping, + map_length); + + int maps_fd = ms_raw_open("/tmp/metalsharp-eac-maps", O_RDWR | O_CREAT | O_TRUNC, 0600); + if (maps_fd >= 0) { + int maps_length = + dprintf(maps_fd, "%016llx-%016llx r-xp 00000000 00:00 0 %s\n", (unsigned long long)(uintptr_t)mapping, + (unsigned long long)((uintptr_t)mapping + map_length), g_elf_path); + if (maps_length > 0) { + snprintf(g_maps_path, sizeof(g_maps_path), "/tmp/metalsharp-eac-maps"); + } + close(maps_fd); + } + + MsElfHeader* header = (MsElfHeader*)mapping; + if (memcmp(header->ident, + "\x7f" + "ELF", + 4) != 0 || + header->type != 3 || header->machine != 62) { + ms_log("invalid ELF libc image header"); + return false; + } + const char* strings = NULL; + MsElfSymbol* symbols = NULL; + if (header->phentsize != sizeof(MsElfProgramHeader)) { + ms_log("unexpected ELF program-header size=%u", header->phentsize); + return false; + } + for (uint16_t index = 0; index < header->phnum; index++) { + MsElfProgramHeader* program = + (MsElfProgramHeader*)((uint8_t*)mapping + header->phoff + index * header->phentsize); + if (program->type != MS_PT_DYNAMIC) { + continue; + } + uint64_t* dynamic = (uint64_t*)((uint8_t*)mapping + program->vaddr); + for (size_t cursor = 0; cursor + 1 < program->filesz / sizeof(uint64_t); cursor += 2) { + uint64_t tag = dynamic[cursor]; + uint64_t value = dynamic[cursor + 1]; + if (tag == MS_DT_NULL) { + break; + } + if (tag == MS_DT_STRTAB) { + strings = (const char*)mapping + value; + /* The protected loader's ELF resolver consumes d_ptr values + * as process addresses after it has found the PT_DYNAMIC + * segment. The on-disk image remains a conventional ET_DYN + * image with relative virtual addresses; publish the mapped + * addresses only in this private, writable instance. */ + dynamic[cursor + 1] = (uint64_t)(uintptr_t)mapping + value; + } else if (tag == MS_DT_SYMTAB) { + symbols = (MsElfSymbol*)((uint8_t*)mapping + value); + dynamic[cursor + 1] = (uint64_t)(uintptr_t)mapping + value; + } else if (tag == MS_DT_HASH) { + dynamic[cursor + 1] = (uint64_t)(uintptr_t)mapping + value; + } + } + } + if (strings == NULL || symbols == NULL) { + ms_log("ELF libc image has no dynamic symbol table"); + return false; + } + for (size_t index = 1; index < 256; index++) { + MsElfSymbol* symbol = &symbols[index]; + if (symbol->name == 0) { + break; + } + const char* name = strings + symbol->name; + void* target = resolve_substrate_target(name); + ms_log("patching ELF symbol index=%zu name=%s value=0x%llx", index, name, (unsigned long long)symbol->value); + if (symbol->value + 2 + sizeof(target) > g_elf_mapping_size) { + ms_log("ELF symbol target is outside mapping index=%zu", index); + break; + } + memcpy((uint8_t*)mapping + symbol->value + 2, &target, sizeof(target)); + if (index < 8) { + ms_log("patched ELF symbol %s -> 0x%llx", name, (unsigned long long)(uintptr_t)target); + } + if (index >= 60) { + break; + } + } + return true; +} + +__attribute__((constructor)) static void metalsharp_eac_substrate_init(void) { + ms_log("MetalSharp Linux ABI substrate initializing pid=%d", (int)getpid()); + if (!map_linux_libc_image()) { + ms_log("Linux ABI substrate did not initialize"); + return; + } + ms_log("Linux ABI substrate initialized; virtual /proc maps is active"); + bool host_tsd_bridge = resolve_host_tsd_bridge(); + ms_log("Darwin host TSD bridge resolved=%d set_tsd=0x%llx current_teb=0x%llx", host_tsd_bridge ? 1 : 0, + (unsigned long long)(uintptr_t)g_thread_set_tsd_base, (unsigned long long)(uintptr_t)g_nt_current_teb); + __atomic_store_n(&g_host_tsd_interpose_ready, 1, __ATOMIC_RELEASE); + /* The exact Wine 11.5 PE files are patched on disk by the installer. + * Do not scan or rewrite Rosetta's native address space here: guest PE + * addresses are not host Mach VM addresses, and doing so corrupts Wine's + * own ntdll/kernel32 state. */ +} + +/* dyld's documented interpose section is used only for the procfs view. */ +__attribute__((used)) static struct { + const void* replacement; + const void* replacee; +} metalsharp_eac_open_interpose __attribute__((section("__DATA,__interpose"))) = { + (const void*)&metalsharp_eac_open, + (const void*)&open, +}; + +__attribute__((used)) static struct { + const void* replacement; + const void* replacee; +} metalsharp_eac_openat_interpose __attribute__((section("__DATA,__interpose"))) = { + (const void*)&metalsharp_eac_openat, + (const void*)&openat, +}; + +__attribute__((used)) static struct { + const void* replacement; + const void* replacee; +} metalsharp_eac_unix_name_interpose __attribute__((section("__DATA,__interpose"))) = { + (const void*)&metalsharp_eac_ntdll_get_unix_file_name, + (const void*)&ntdll_get_unix_file_name, +}; + +__attribute__((used)) static struct { + const void* replacement; + const void* replacee; +} metalsharp_eac_sigaction_interpose __attribute__((section("__DATA,__interpose"))) = { + (const void*)&metalsharp_sigaction, + (const void*)&sigaction, +}; + +/* ntdll.so calls the private Darwin entry through its lazy symbol stub. A + * normal exported function in an inserted dylib is not enough to redirect + * that pointer under dyld's chained-fixup binding, so register the exact + * replacee in the documented interpose section. */ +extern void metalsharp_original_thread_set_tsd_base(void* base) __asm("__thread_set_tsd_base"); +__attribute__((used)) static struct { + const void* replacement; + const void* replacee; +} metalsharp_eac_tsd_interpose __attribute__((section("__DATA,__interpose"))) = { + (const void*)&metalsharp_thread_set_tsd_base, + (const void*)&metalsharp_original_thread_set_tsd_base, +}; diff --git a/tools/anticheat/generate_linux_libc_elf.py b/tools/anticheat/generate_linux_libc_elf.py new file mode 100644 index 000000000..fe8ad7f1d --- /dev/null +++ b/tools/anticheat/generate_linux_libc_elf.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Build the small ELF symbol image used by MetalSharp's Darwin substrate. + +The EAC Wine launcher does not ask dyld to load libc. It reads the ELF image +named by a Linux-style /proc//maps entry, then resolves a handful of +symbols from its SysV hash table. This file is deliberately a normal ET_DYN +image with a PT_DYNAMIC segment and a real ELF symbol table; the Darwin +substrate fills the absolute jump targets after it maps the image. + +This is not an EAC payload and does not contain vendor code. It is the ABI +boundary owned by MetalSharp. Keeping its generation deterministic makes it +possible to inspect and test the exact image without touching the protected +module downloaded by EAC. +""" + +from __future__ import annotations + +import argparse +import struct +from pathlib import Path + + +PAGE = 0x1000 +STUB_SIZE = 16 +CODE_OFF = PAGE +STR_OFF = 0x2000 +SYM_OFF = 0x3000 +HASH_OFF = 0x4000 +DYNAMIC_OFF = 0x5000 +SHSTR_OFF = 0x6000 +SHOFF = 0x7000 +FILE_SIZE = 0x7400 + + +# The loader can resolve functions that are not needed by the launcher's +# initial __libc_dlopen_mode/__libc_dlsym lookup as well. Keeping the names in +# the ELF image gives the loaded Linux module the same symbol namespace that +# it would see from a glibc process. Target addresses are patched by the +# native substrate, never by changing the EAC payload. +SYMBOLS = [ + "__libc_dlopen_mode", + "dlopen", + "__libc_dlsym", + "dlsym", + "dlclose", + "dlerror", + "malloc", + "calloc", + "realloc", + "free", + "memcpy", + "memmove", + "memset", + "memcmp", + "strlen", + "strcmp", + "strncmp", + "strstr", + "strchr", + "strrchr", + "open", + "open64", + "close", + "read", + "pread", + "write", + "lseek", + "fstat", + "mmap", + "munmap", + "mprotect", + "getpid", + "getppid", + "getenv", + "abort", + "exit", + "pthread_create", + "pthread_join", + "pthread_detach", + "pthread_self", + "pthread_mutex_init", + "pthread_mutex_destroy", + "pthread_mutex_lock", + "pthread_mutex_unlock", + "pthread_cond_init", + "pthread_cond_destroy", + "pthread_cond_wait", + "pthread_cond_signal", + "clock_gettime", + "syscall", + "__errno_location", + "__stack_chk_fail", + "raise", +] + + +def align(value: int, boundary: int = PAGE) -> int: + return (value + boundary - 1) & ~(boundary - 1) + + +def sysv_hash(name: bytes) -> int: + value = 0 + for byte in name: + value = (value << 4) + byte + high = value & 0xF0000000 + if high: + value ^= high >> 24 + value ^= high + return value & 0xFFFFFFFF + + +def build() -> bytes: + names = ["libc.so.6", *SYMBOLS] + dynstr = bytearray(b"\0") + name_offsets: dict[str, int] = {} + for name in names: + name_offsets[name] = len(dynstr) + dynstr.extend(name.encode("ascii") + b"\0") + + symbol_count = len(SYMBOLS) + 1 + dynsym = bytearray(symbol_count * 24) + for index, name in enumerate(SYMBOLS, start=1): + struct.pack_into( + " None: + parser = argparse.ArgumentParser() + parser.add_argument("output", type=Path) + args = parser.parse_args() + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_bytes(build()) + + +if __name__ == "__main__": + main() diff --git a/tools/anticheat/patch_wine_kernel32_path.py b/tools/anticheat/patch_wine_kernel32_path.py new file mode 100644 index 000000000..789ea5cd4 --- /dev/null +++ b/tools/anticheat/patch_wine_kernel32_path.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Install the transparent /proc path bridge in the exact Wine 11.5 PE. + +Wine's kernel32 ``wine_get_unix_file_name`` normally asks the Wine server to +resolve a host path. Darwin has no /proc, while the EAC Linux loader asks for +``/proc//maps``. The existing builtin implementation remains the +fallback for every other path; this patch adds a small x86-64 PE routine in +the unused tail of .text that returns a heap-owned path for the /proc prefix. + +The routine uses kernel32's existing GetProcessHeap/RtlAllocateHeap/memcpy +imports, so the caller can release the result with the normal Wine allocator. +It does not alter Wine's reported identity or touch any EAC payload. +""" + +from __future__ import annotations + +import argparse +import struct +from pathlib import Path + + +MAP_PATH = b"/tmp/metalsharp-eac-maps\0" +FUNCTION_RVA = 0x32560 +GET_PROCESS_HEAP_IAT_RVA = 0x53748 +RTL_ALLOCATE_HEAP_IAT_RVA = 0x54790 +MEMCPY_IAT_RVA = 0x549A0 +GET_PROC_ADDRESS_RVA = 0x114C0 + + +def parse_image(blob: bytes): + pe = struct.unpack_from(" int: + for item in sections: + if item["va"] <= rva < item["va"] + max(item["vs"], item["raw_size"]): + return item["raw"] + rva - item["va"] + raise ValueError(f"RVA 0x{rva:x} is outside file-backed sections") + + +def call_iat(wrapper_rva: int, offset: int, iat_rva: int) -> bytes: + next_rva = wrapper_rva + offset + 6 + displacement = iat_rva - next_rva + return b"\xff\x15" + struct.pack(" bytes: + return struct.pack(" bytes: + code = bytearray() + branch_positions: list[int] = [] + + # The EAC launcher passes a Unix-style UTF-16 path to this private helper. + # Match /proc/ and leave every other path on the original implementation. + for displacement, character in ((0, 0x2F), (2, 0x70), (4, 0x72), (6, 0x6F), (8, 0x63), (10, 0x2F)): + code.extend(b"\x66\x81\x39" if displacement == 0 else b"\x66\x81\x79" + bytes([displacement])) + code.extend(struct.pack(" bytes: + """Return GetProcAddress's Wine-private-export bridge.""" + code = bytearray() + branch_positions: list[int] = [] + # GetProcAddress accepts a low-word ordinal as well as a pointer to an + # ASCII name. The ordinal form is common during Wine startup; reject it + # before dereferencing RDX and leave it on the original implementation. + code.extend(b"\x48\x81\xfa\x00\x00\x01\x00\x72\x00") + branch_positions.append(len(code) - 1) + first = int.from_bytes(b"wine_get", "little") + second = int.from_bytes(b"_unix_fi", "little") + code.extend(b"\x49\xb8" + struct.pack(" bool: + blob = bytearray(path.read_bytes()) + _pe, _optional, sections = parse_image(blob) + text = section(sections, b".text") + rdata = section(sections, b".rdata") + if not text["va"] <= FUNCTION_RVA < text["va"] + text["vs"]: + raise ValueError("kernel32 function RVA is not in .text") + + original = bytes(blob[rva_to_file(sections, FUNCTION_RVA) : rva_to_file(sections, FUNCTION_RVA) + 20]) + get_proc_original = bytes(blob[rva_to_file(sections, GET_PROC_ADDRESS_RVA) : rva_to_file(sections, GET_PROC_ADDRESS_RVA) + 18]) + if original[0] == 0xE9 and get_proc_original[0] == 0xE9 and b"/tmp/metalsharp-eac-maps\0" in blob: + return False + expected = bytes.fromhex("415641554154555756534881ecb00000004531c9") + if original != expected: + raise ValueError(f"unexpected kernel32 function prologue: {original.hex()}") + + wrapper_rva = text["va"] + text["vs"] + trampoline_rva = wrapper_rva + 0x90 + get_proc_wrapper_rva = wrapper_rva + 0xB0 + get_proc_trampoline_rva = wrapper_rva + 0x100 + string_rva = rdata["va"] + rdata["vs"] + wrapper = build_wrapper(wrapper_rva, trampoline_rva, string_rva) + trampoline = original + b"\xe9" + rel32(trampoline_rva + len(original), 5, FUNCTION_RVA + len(original)) + get_proc_wrapper = build_get_proc_wrapper(get_proc_wrapper_rva, get_proc_trampoline_rva) + get_proc_trampoline = get_proc_original + b"\xe9" + rel32(get_proc_trampoline_rva + len(get_proc_original), 5, GET_PROC_ADDRESS_RVA + len(get_proc_original)) + text_end_rva = text["va"] + text["raw_size"] + if ( + wrapper_rva + len(wrapper) > text_end_rva + or trampoline_rva + len(trampoline) > text_end_rva + or get_proc_wrapper_rva + len(get_proc_wrapper) > text_end_rva + or get_proc_trampoline_rva + len(get_proc_trampoline) > text_end_rva + ): + raise ValueError(".text raw padding is too small for the bridge") + if string_rva + len(MAP_PATH) > rdata["raw"] + rdata["raw_size"] - rdata["va"] + rdata["va"]: + raise ValueError(".rdata raw padding is too small for the path") + + function_offset = rva_to_file(sections, FUNCTION_RVA) + wrapper_offset = rva_to_file(sections, wrapper_rva) + trampoline_offset = rva_to_file(sections, trampoline_rva) + get_proc_wrapper_offset = rva_to_file(sections, get_proc_wrapper_rva) + get_proc_trampoline_offset = rva_to_file(sections, get_proc_trampoline_rva) + get_proc_offset = rva_to_file(sections, GET_PROC_ADDRESS_RVA) + string_offset = rva_to_file(sections, string_rva) + blob[wrapper_offset : wrapper_offset + len(wrapper)] = wrapper + blob[trampoline_offset : trampoline_offset + len(trampoline)] = trampoline + blob[get_proc_wrapper_offset : get_proc_wrapper_offset + len(get_proc_wrapper)] = get_proc_wrapper + blob[get_proc_trampoline_offset : get_proc_trampoline_offset + len(get_proc_trampoline)] = get_proc_trampoline + blob[string_offset : string_offset + len(MAP_PATH)] = MAP_PATH + blob[function_offset : function_offset + 5] = b"\xe9" + rel32(FUNCTION_RVA, 5, wrapper_rva) + blob[function_offset + 5 : function_offset + 20] = b"\x90" * 15 + blob[get_proc_offset : get_proc_offset + 5] = b"\xe9" + rel32(GET_PROC_ADDRESS_RVA, 5, get_proc_wrapper_rva) + blob[get_proc_offset + 5 : get_proc_offset + 18] = b"\x90" * 13 + + # Extend virtual sizes to include the code/string placed in raw padding. + struct.pack_into( + " None: + parser = argparse.ArgumentParser() + parser.add_argument("image", type=Path) + args = parser.parse_args() + print("patched" if patch(args.image) else "already-patched") + + +if __name__ == "__main__": + main() diff --git a/tools/anticheat/patch_wine_private_export.py b/tools/anticheat/patch_wine_private_export.py new file mode 100644 index 000000000..11d614b24 --- /dev/null +++ b/tools/anticheat/patch_wine_private_export.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Add Wine's private path export needed by the Linux EAC loader. + +Some EAC launchers use GetModuleHandleW("ntdll.dll") followed by +GetProcAddress("wine_get_unix_file_name"). Wine normally exposes that helper +from kernel32, while the Linux EAC path asks ntdll. The exact Wine 11.5 +runtime can satisfy the real private-export contract by forwarding the name +to kernel32; no vendor executable or protected module is changed. + +The ntdll export table contains the usual Nt/Zw aliases. We reuse the name +slot for a Zw alias whose Nt twin remains intact, and point its export address +at a forwarder string in the existing .edata padding. This keeps the PE +layout and all section offsets stable, so the patch is safe to apply +idempotently to the already-built runtime. +""" + +from __future__ import annotations + +import argparse +import struct +from pathlib import Path + + +TARGET = b"wine_get_unix_file_name" +WINE115_KERNEL32_BASE = 0x6FFFFFA00000 +WINE115_KERNEL32_UNIX_NAME_RVA = 0x32560 + + +def parse_sections(blob: bytes): + pe = struct.unpack_from(" int: + for _name, virtual_address, virtual_size, raw_size, raw_offset in sections: + span = max(virtual_size, raw_size) + if virtual_address <= rva < virtual_address + span: + return raw_offset + rva - virtual_address + raise ValueError(f"RVA 0x{rva:x} is outside file-backed sections") + + +def patch(path: Path) -> bool: + blob = bytearray(path.read_bytes()) + _pe, optional, sections = parse_sections(blob) + export_rva, export_size = struct.unpack_from("= len(TARGET)), + None, + ) + if candidate is None: + raise ValueError("no reusable Zw export name slot") + _old_name, _old_name_rva, name_offset, old_name_capacity, ordinal = candidate + if len(TARGET) + 1 > old_name_capacity: + raise ValueError("selected export name slot is too short") + + blob[name_offset : name_offset + old_name_capacity] = TARGET + b"\0" + b"\0" * (old_name_capacity - len(TARGET) - 1) + function_offset = rva_to_file(sections, functions_rva) + ordinal * 4 + text = next((section for section in sections if section[0] == b".text"), None) + if text is None: + raise ValueError("image has no .text section") + stub_rva = text[1] + text[2] + stub = b"\x48\xb8" + struct.pack(" text[4] + text[3]: + raise ValueError(".text has no padding for the private export bridge") + blob[stub_offset : stub_offset + len(stub)] = stub + struct.pack_into(" None: + parser = argparse.ArgumentParser() + parser.add_argument("image", type=Path) + args = parser.parse_args() + changed = patch(args.image) + print("patched" if changed else "already-patched") + + +if __name__ == "__main__": + main() diff --git a/tools/anticheat/probe_wine_private_export.c b/tools/anticheat/probe_wine_private_export.c new file mode 100644 index 000000000..449373f8a --- /dev/null +++ b/tools/anticheat/probe_wine_private_export.c @@ -0,0 +1,19 @@ +#include +#include + +typedef char *(__cdecl *wine_get_unix_file_name_fn)(const wchar_t *path); + +int main(void) { + HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); + HMODULE kernel32 = GetModuleHandleW(L"kernel32.dll"); + FARPROC raw = ntdll != NULL ? GetProcAddress(ntdll, "wine_get_unix_file_name") : NULL; + FARPROC kernel_export = kernel32 != NULL ? GetProcAddress(kernel32, "wine_get_unix_file_name") : NULL; + printf("ntdll=%p export=%p kernel32=%p kernel_export=%p\n", (void *)ntdll, (void *)raw, (void *)kernel32, (void *)kernel_export); + if (raw == NULL && kernel_export != NULL) raw = kernel_export; + if (raw == NULL) return 2; + char *(*get_name)(const wchar_t *) = (wine_get_unix_file_name_fn)raw; + char *name = get_name(L"/proc/40/maps"); + printf("maps=%s\n", name != NULL ? name : ""); + if (name != NULL) HeapFree(GetProcessHeap(), 0, name); + return name != NULL ? 0 : 3; +} diff --git a/tools/anticheat/run_eac_proof.py b/tools/anticheat/run_eac_proof.py new file mode 100755 index 000000000..096c93afe --- /dev/null +++ b/tools/anticheat/run_eac_proof.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +"""Run the explicit Elden Ring EAC substrate proof. + +This command is deliberately opt-in. It never launches Steam or +``eldenring.exe``; it starts only the supplied ``Start_protected_game.exe`` +under the already-installed MetalSharp Wine 11.5 runtime, waits at most thirty +seconds, and then tears down the Wine process group and its wineserver. The +launcher is expected to remain alive while it waits for the protected game. + +The success result is limited to what this probe actually demonstrates: +the real downloaded EAC Linux ELF was mapped, relocated, initialized, and its +public export ``a`` returned success through the Darwin substrate. It does +not claim that an online session or a protected game transition occurred. +Those are separate gates and are reported explicitly in the JSON evidence. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import signal +import stat +import subprocess +import sys +import time +from pathlib import Path + + +MAX_TIMEOUT_SECONDS = 30 +EAC_PROCESS_NAMES = { + "wine", + "wine64", + "wine-preloader", + "winedevice", + "winedbg", + "wineserver", + "conhost", + "explorer", +} +REQUIRED_LOG_MARKERS = ( + "Linux ABI substrate initialized; virtual /proc maps is active", + "validated EAC Linux ELF", + "EAC RELA relocation pass complete=1", + "EAC PLT relocation pass complete=1", + "applied EAC Linux PT_LOAD protections", + "calling Linux EAC DT_INIT", + "EAC_PROOF module_loaded=1", + "protections=0x1", + "EAC_PROOF export_a_success=1", +) +FORBIDDEN_LOG_MARKERS = ( + "unresolved Linux ABI symbol", + "Linux relocation unresolved", + "unsupported Linux relocation", + "invalid EAC Linux ELF", + "cannot map EAC Linux module", +) + + +def parser() -> argparse.ArgumentParser: + return argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + + +def add_arguments(args: argparse.ArgumentParser) -> None: + home = Path.home() + repo = Path(__file__).resolve().parents[2] + metalsharp_home = Path(os.environ.get("METALSHARP_HOME", home / ".metalsharp")).expanduser() + configured_game_dir = os.environ.get("METALSHARP_ELDEN_RING_GAME_DIR") + args.add_argument( + "--wine", + type=Path, + default=metalsharp_home / "runtime" / "wine" / "bin" / "wine", + help="exact MetalSharp Wine 11.5 launcher (default: %(default)s)", + ) + args.add_argument( + "--prefix", + type=Path, + default=metalsharp_home / "prefix-steam", + help="Steam Wine prefix (default: %(default)s)", + ) + args.add_argument( + "--game-dir", + type=Path, + default=Path(configured_game_dir).expanduser() if configured_game_dir else None, + help="Elden Ring Game directory on the external Steam library", + ) + args.add_argument("--launcher", type=Path, help="Start_protected_game.exe; defaults below --game-dir") + args.add_argument( + "--substrate", + type=Path, + default=repo / "app" / "native" / "metalsharp_eac_substrate.dylib", + help="built MetalSharp substrate dylib; this command does not build it", + ) + args.add_argument( + "--libc", + type=Path, + default=repo / "app" / "native" / "metalsharp_eac_libc.so.6", + help="generated ELF symbol image; this command does not generate it", + ) + args.add_argument("--module-dump", type=Path, default=Path("/tmp/metalsharp-eac-module.bin")) + args.add_argument("--log", type=Path, default=Path("/tmp/metalsharp-eac-substrate-proof.log")) + args.add_argument("--stdout-log", type=Path, default=Path("/tmp/metalsharp-eac-proof-wine.out")) + args.add_argument("--evidence", type=Path, default=Path("/tmp/metalsharp-eac-proof.json")) + args.add_argument( + "--timeout", + type=float, + default=30.0, + help="maximum launcher lifetime in seconds (must be <= 30)", + ) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def fail(message: str) -> "NoReturn": + print(f"eac-proof: error: {message}", file=sys.stderr) + raise SystemExit(2) + + +def validate_path(path: Path, label: str, *, regular_file: bool = True) -> None: + if not path.exists(): + fail(f"{label} does not exist: {path}") + if regular_file and not path.is_file(): + fail(f"{label} is not a regular file: {path}") + + +def validate_inputs(options: argparse.Namespace) -> tuple[Path, Path]: + if options.timeout <= 0 or options.timeout > MAX_TIMEOUT_SECONDS: + fail(f"--timeout must be between 0 and {MAX_TIMEOUT_SECONDS} seconds") + if options.game_dir is None: + fail("--game-dir (or METALSHARP_ELDEN_RING_GAME_DIR) is required") + options.wine = options.wine.expanduser().resolve() + options.prefix = options.prefix.expanduser().resolve() + options.game_dir = options.game_dir.expanduser().resolve() + options.launcher = (options.launcher or options.game_dir / "Start_protected_game.exe").expanduser().resolve() + options.substrate = options.substrate.expanduser().resolve() + options.libc = options.libc.expanduser().resolve() + options.module_dump = options.module_dump.expanduser().resolve() + options.log = options.log.expanduser().resolve() + options.stdout_log = options.stdout_log.expanduser().resolve() + options.evidence = options.evidence.expanduser().resolve() + + expected_wine = options.prefix.parent / "runtime" / "wine" / "bin" / "wine" + # The path is checked structurally, not by trusting a version string from + # another Wine binary. It prevents GPTK, Proton, or a second Wine build + # from accidentally becoming the test runtime. + if options.wine != expected_wine: + fail(f"--wine must be the selected MetalSharp runtime binary, not {options.wine}") + if ".metalsharp" not in options.wine.parts: + fail(f"--wine is outside the MetalSharp home: {options.wine}") + validate_path(options.wine, "MetalSharp Wine 11.5 binary") + validate_path(options.prefix, "Wine prefix", regular_file=False) + validate_path(options.game_dir, "Elden Ring game directory", regular_file=False) + validate_path(options.launcher, "Start_protected_game.exe") + validate_path(options.substrate, "MetalSharp EAC substrate") + validate_path(options.libc, "MetalSharp ELF symbol image") + if options.launcher.read_bytes()[:2] != b"MZ": + fail(f"launcher is not a PE image: {options.launcher}") + if options.libc.read_bytes()[:4] != b"\x7fELF": + fail(f"ELF symbol image has no ELF header: {options.libc}") + return expected_wine.parent / "wineserver", options.game_dir + + +def process_rows() -> list[tuple[int, str, str]]: + try: + output = subprocess.check_output( + ["/bin/ps", "-axo", "pid=,comm=,args="], text=True, stderr=subprocess.DEVNULL + ) + except (OSError, subprocess.SubprocessError): + return [] + rows: list[tuple[int, str, str]] = [] + for line in output.splitlines(): + fields = line.strip().split(None, 2) + if len(fields) != 3: + continue + try: + pid = int(fields[0]) + except ValueError: + continue + rows.append((pid, fields[1], fields[2])) + return rows + + +def wine_rows(wine_root: Path, prefix: Path) -> list[tuple[int, str, str]]: + root_text = str(wine_root) + prefix_text = str(prefix) + return [ + (pid, name, command) + for pid, name, command in process_rows() + if Path(name).name in EAC_PROCESS_NAMES + and (root_text in command or prefix_text in command or name == "wineserver") + ] + + +def terminate_group(process: subprocess.Popen[bytes], wine_root: Path, prefix: Path, wineserver: Path) -> None: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + pass + + # wineserver -k is the runtime-supported teardown path. Use the exact + # runtime binary and prefix, then give descendant helper windows a short + # grace period before a final targeted kill. + try: + subprocess.run( + [str(wineserver), "-k"], + env={**os.environ, "WINEPREFIX": str(prefix)}, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError): + pass + deadline = time.monotonic() + 2 + while time.monotonic() < deadline and wine_rows(wine_root, prefix): + time.sleep(0.1) + for pid, _name, _command in wine_rows(wine_root, prefix): + if pid == os.getpid(): + continue + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + + +def run_launcher(options: argparse.Namespace, wineserver: Path) -> tuple[int | None, bool]: + for path in (options.log, options.module_dump, options.stdout_log): + path.unlink(missing_ok=True) + path.parent.mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + env.update( + { + "WINEPREFIX": str(options.prefix), + "WINEDEBUG": "-all,+seh", + "DYLD_INSERT_LIBRARIES": str(options.substrate), + "METALSHARP_EAC_SUBSTRATE_LIBC": str(options.libc), + "METALSHARP_EAC_SUBSTRATE_LOG": str(options.log), + "METALSHARP_EAC_SUBSTRATE_MAPS": "/tmp/metalsharp-eac-maps", + "METALSHARP_EAC_MODULE_DUMP": str(options.module_dump), + } + ) + started = time.monotonic() + with options.stdout_log.open("wb") as output: + process = subprocess.Popen( + [str(options.wine), str(options.launcher)], + cwd=options.game_dir, + env=env, + stdin=subprocess.DEVNULL, + stdout=output, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + timed_out = False + try: + process.wait(timeout=options.timeout) + except subprocess.TimeoutExpired: + timed_out = True + finally: + terminate_group(process, options.wine.parent, options.prefix, wineserver) + elapsed = time.monotonic() - started + if elapsed > MAX_TIMEOUT_SECONDS + 5: + fail(f"Wine teardown exceeded the hard cleanup bound: {elapsed:.2f}s") + return process.returncode, timed_out + + +def line_set(log: str) -> dict[str, bool]: + markers = {marker: marker in log for marker in REQUIRED_LOG_MARKERS} + markers.update( + {f"calling Linux EAC init_array[{index}]": f"calling Linux EAC init_array[{index}]" in log for index in range(6)} + ) + markers.update({f"__libc_dlsym({name})": f"__libc_dlsym({name})" in log for name in ("a", "b", "c", "d")}) + return markers + + +def module_description(path: Path) -> dict[str, object]: + if not path.exists(): + return {"exists": False, "path": str(path)} + blob = path.read_bytes() + elf64_x86_64 = len(blob) >= 20 and blob[:4] == b"\x7fELF" and blob[4] == 2 and blob[5] == 1 and blob[18:20] == b">\x00" + return { + "exists": True, + "path": str(path), + "bytes": len(blob), + "sha256": sha256(path), + "format": "elf64-x86_64" if elf64_x86_64 else "unknown", + "elf64_x86_64": elf64_x86_64, + } + + +def latest_eac_log(prefix: Path) -> tuple[Path | None, str]: + root = prefix / "drive_c" / "users" + candidates = [] + if root.is_dir(): + for path in root.rglob("*.log"): + if path.is_file() and "easyanticheat" in str(path).lower(): + try: + candidates.append((path.stat().st_mtime_ns, path)) + except OSError: + continue + if not candidates: + return None, "" + path = max(candidates, key=lambda item: item[0])[1] + try: + return path, path.read_text(errors="replace") + except OSError: + return path, "" + + +def wine_description(path: Path, eac_log: str) -> dict[str, object]: + version = None + marker = "Starting Wine module mapping, Wine version: " + for line in eac_log.splitlines(): + if marker in line: + version = line.split(marker, 1)[1].rstrip(".").strip() + return { + "path": str(path), + "sha256": sha256(path), + "wineVersionFromEac": version, + "expectedVersion": "11.5", + "versionMatches": version == "11.5", + } + + +def eac_log_checks(eac_log: str) -> dict[str, bool]: + return { + "systemLinux64": "System name: 'linux64'." in eac_log, + "moduleRequestSucceeded": "Response Code: 200" in eac_log, + "wineMappingStarted": "Starting Wine module mapping, Wine version: 11.5." in eac_log, + "mappingDidNotReportFailure": "Failed to map the anti-cheat module" not in eac_log, + } + + +def build_evidence(options: argparse.Namespace, returncode: int | None, timed_out: bool) -> dict[str, object]: + log = options.log.read_text(errors="replace") if options.log.exists() else "" + eac_log_path, eac_log = latest_eac_log(options.prefix) + markers = line_set(log) + forbidden = [marker for marker in FORBIDDEN_LOG_MARKERS if marker in log] + module = module_description(options.module_dump) + wine = wine_description(options.wine, eac_log) + eac_checks = eac_log_checks(eac_log) + residual = [ + {"pid": pid, "name": name, "command": command} + for pid, name, command in wine_rows(options.wine.parent, options.prefix) + if pid != os.getpid() + ] + module_proof = ( + all(markers.values()) + and not forbidden + and module.get("elf64_x86_64") is True + and module.get("bytes", 0) > 0 + and wine.get("versionMatches") is True + and all(eac_checks.values()) + and not residual + ) + return { + "schema": "metalsharp.eac-proof.v1", + "ok": module_proof, + "proofLevel": "real_eac_linux_module_relocated_initialized_exported" if module_proof else "not_proven", + "protectedGameTransitionObserved": False, + "onlineSessionObserved": False, + "launcher": { + "path": str(options.launcher), + "sha256": sha256(options.launcher), + "returnCode": returncode, + "timedOutAtThirtySeconds": timed_out, + }, + "runtime": wine, + "eacLogChecks": eac_checks, + "prefix": str(options.prefix), + "gameDir": str(options.game_dir), + "substrate": {"path": str(options.substrate), "sha256": sha256(options.substrate)}, + "symbolImage": {"path": str(options.libc), "sha256": sha256(options.libc), "format": "elf64"}, + "module": module, + "requiredMarkers": markers, + "forbiddenMarkers": forbidden, + "residualWineProcesses": residual, + "logs": { + "substrate": str(options.log), + "launcherOutput": str(options.stdout_log), + "eacLauncher": str(eac_log_path) if eac_log_path is not None else None, + }, + "interpretation": ( + "The exact MetalSharp Wine 11.5 runtime loaded the real EAC Linux ELF, " + "completed relocation and constructors, and received export-a success. " + "This proof intentionally does not claim a game transition or online support." + if module_proof + else "The required real-module proof markers were not all present." + ), + } + + +def main() -> int: + options_parser = parser() + add_arguments(options_parser) + options = options_parser.parse_args() + wineserver, _ = validate_inputs(options) + returncode, timed_out = run_launcher(options, wineserver) + evidence = build_evidence(options, returncode, timed_out) + options.evidence.parent.mkdir(parents=True, exist_ok=True) + options.evidence.write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n") + print(json.dumps(evidence, indent=2, sort_keys=True)) + return 0 if evidence["ok"] else 1 + + +if __name__ == "__main__": + main() diff --git a/tools/bundles/create-split-bundles.py b/tools/bundles/create-split-bundles.py index e87301ca7..58c7c8a7e 100755 --- a/tools/bundles/create-split-bundles.py +++ b/tools/bundles/create-split-bundles.py @@ -249,6 +249,7 @@ def build_staging(tmp: Path) -> dict[str, Path]: "xinput1_4.dylib", "opengl32.dylib", ] + eac_native_dylibs = ["metalsharp_eac_substrate.dylib"] native_so = [ "d3d11.so", "d3d12.so", @@ -259,7 +260,7 @@ def build_staging(tmp: Path) -> dict[str, Path]: native_dll = [name.replace(".so", ".dll") for name in native_so] native_binaries = ["metalsharp", "metalsharp_launcher"] if sys.platform == "darwin": - platform_shlibs = native_dylibs + platform_shlibs = native_dylibs + eac_native_dylibs elif sys.platform.startswith("linux"): platform_shlibs = native_so elif sys.platform in ("win32", "cygwin", "msys"): @@ -268,6 +269,11 @@ def build_staging(tmp: Path) -> dict[str, Path]: platform_shlibs = native_dylibs + native_so for name in platform_shlibs + native_binaries: require_file(APP_DIR / "native" / name, f"native shim {name}") + if sys.platform == "darwin": + require_file( + APP_DIR / "native" / "metalsharp_eac_libc.so.6", + "MetalSharp EAC Linux symbol image", + ) require_file( PROJECT_ROOT / "lib" / "metalsharp" / "x86_64-windows" / "metalsharp_ntdll_hook.dll", "MetalSharp ntdll hook DLL", diff --git a/tools/bundles/verify-native-shims.sh b/tools/bundles/verify-native-shims.sh index 5c16865b4..d810fe8eb 100755 --- a/tools/bundles/verify-native-shims.sh +++ b/tools/bundles/verify-native-shims.sh @@ -16,8 +16,10 @@ NATIVE_DIR="${1:-${METALSHARP_NATIVE_DIR:-app/native}}" required_dylibs=( d3d11.dylib d3d12.dylib dxgi.dylib xaudio2_9.dylib xinput1_4.dylib opengl32.dylib + metalsharp_eac_substrate.dylib ) required_bins=(metalsharp metalsharp_launcher) +required_elf=(metalsharp_eac_libc.so.6) errors=0 for f in "${required_dylibs[@]}" "${required_bins[@]}"; do @@ -49,8 +51,24 @@ for f in "${required_dylibs[@]}" "${required_bins[@]}"; do fi done +for f in "${required_elf[@]}"; do + path="$NATIVE_DIR/$f" + if [ ! -f "$path" ]; then + echo "ERROR: missing EAC Linux symbol image: $path" + errors=$((errors + 1)) + elif [ ! -s "$path" ]; then + echo "ERROR: zero-byte EAC Linux symbol image: $path" + errors=$((errors + 1)) + elif ! file "$path" | grep -q "ELF 64-bit.*x86-64"; then + echo "ERROR: $path is not an ELF64 x86-64 symbol image" + errors=$((errors + 1)) + else + echo "OK: $path ($(wc -c < "$path") bytes)" + fi +done + if [ $errors -gt 0 ]; then echo "FAILED: $errors validation errors" exit 1 fi -echo "PASSED: all native shims present and valid" \ No newline at end of file +echo "PASSED: all native shims present and valid" From feb8e2e00401e75ba42997c5c47ce18a3dbeddca Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Mon, 10 Aug 2026 04:49:27 -0600 Subject: [PATCH 2/9] fix: reap detached Wine helper processes --- tools/anticheat/run_eac_proof.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/tools/anticheat/run_eac_proof.py b/tools/anticheat/run_eac_proof.py index 096c93afe..b31e8cc63 100755 --- a/tools/anticheat/run_eac_proof.py +++ b/tools/anticheat/run_eac_proof.py @@ -39,6 +39,13 @@ "conhost", "explorer", } +# On macOS, Wine's Windows processes often expose a truncated Mach `comm` +# value (for example `C:\\windows\\syste`) instead of `winedevice` or +# `services.exe`. Their command line still contains the Windows drive path. +# Treat that path as the authoritative process-shape marker during teardown; +# leaving a detached `steamwebhelper.exe`, `explorer.exe`, or `winedbg.exe` +# behind defeats the bounded proof and can keep a Wine window alive. +WINE_COMMAND_MARKERS = ("C:\\", "Z:\\", "winedbg", "wineserver", "wine-preloader") REQUIRED_LOG_MARKERS = ( "Linux ABI substrate initialized; virtual /proc maps is active", "validated EAC Linux ELF", @@ -191,12 +198,18 @@ def process_rows() -> list[tuple[int, str, str]]: def wine_rows(wine_root: Path, prefix: Path) -> list[tuple[int, str, str]]: root_text = str(wine_root) prefix_text = str(prefix) - return [ - (pid, name, command) - for pid, name, command in process_rows() - if Path(name).name in EAC_PROCESS_NAMES - and (root_text in command or prefix_text in command or name == "wineserver") - ] + rows = [] + for pid, name, command in process_rows(): + process_name = Path(name).name.lower() + command_lower = command.lower() + known_name = process_name in EAC_PROCESS_NAMES + windows_command = any(marker.lower() in command_lower for marker in WINE_COMMAND_MARKERS) + selected_runtime = root_text in command or prefix_text in command + if (known_name or windows_command) and ( + selected_runtime or windows_command or process_name == "wineserver" + ): + rows.append((pid, name, command)) + return rows def terminate_group(process: subprocess.Popen[bytes], wine_root: Path, prefix: Path, wineserver: Path) -> None: From c801ecb0caea6838ba96f0c22939196b3efefd4e Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Mon, 10 Aug 2026 04:52:39 -0600 Subject: [PATCH 3/9] style: format Wine export probe --- tools/anticheat/probe_wine_private_export.c | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tools/anticheat/probe_wine_private_export.c b/tools/anticheat/probe_wine_private_export.c index 449373f8a..854739626 100644 --- a/tools/anticheat/probe_wine_private_export.c +++ b/tools/anticheat/probe_wine_private_export.c @@ -1,19 +1,23 @@ #include #include -typedef char *(__cdecl *wine_get_unix_file_name_fn)(const wchar_t *path); +typedef char*(__cdecl* wine_get_unix_file_name_fn)(const wchar_t* path); int main(void) { HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); HMODULE kernel32 = GetModuleHandleW(L"kernel32.dll"); FARPROC raw = ntdll != NULL ? GetProcAddress(ntdll, "wine_get_unix_file_name") : NULL; FARPROC kernel_export = kernel32 != NULL ? GetProcAddress(kernel32, "wine_get_unix_file_name") : NULL; - printf("ntdll=%p export=%p kernel32=%p kernel_export=%p\n", (void *)ntdll, (void *)raw, (void *)kernel32, (void *)kernel_export); - if (raw == NULL && kernel_export != NULL) raw = kernel_export; - if (raw == NULL) return 2; - char *(*get_name)(const wchar_t *) = (wine_get_unix_file_name_fn)raw; - char *name = get_name(L"/proc/40/maps"); + printf("ntdll=%p export=%p kernel32=%p kernel_export=%p\n", (void*)ntdll, (void*)raw, (void*)kernel32, + (void*)kernel_export); + if (raw == NULL && kernel_export != NULL) + raw = kernel_export; + if (raw == NULL) + return 2; + char* (*get_name)(const wchar_t*) = (wine_get_unix_file_name_fn)raw; + char* name = get_name(L"/proc/40/maps"); printf("maps=%s\n", name != NULL ? name : ""); - if (name != NULL) HeapFree(GetProcessHeap(), 0, name); + if (name != NULL) + HeapFree(GetProcessHeap(), 0, name); return name != NULL ? 0 : 3; } From 8eb85f9af2e845ef6bb0f1880f459e6d06123a0d Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Mon, 10 Aug 2026 04:55:22 -0600 Subject: [PATCH 4/9] docs: record complete Wine helper teardown --- docs/roadmaps/anticheat-hard-route-roadmap.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/roadmaps/anticheat-hard-route-roadmap.md b/docs/roadmaps/anticheat-hard-route-roadmap.md index ae4cb224c..a401b3099 100644 --- a/docs/roadmaps/anticheat-hard-route-roadmap.md +++ b/docs/roadmaps/anticheat-hard-route-roadmap.md @@ -182,8 +182,9 @@ start an anti-cheat process automatically. accepts the external Steam-library game directory explicitly, refuses a Wine binary outside the selected `.metalsharp/runtime/wine/bin/wine` tree, starts only `Start_protected_game.exe`, enforces a thirty-second maximum, runs -`wineserver -k`, and kills residual Wine helper processes. It does not start -Steam or `eldenring.exe`. +`wineserver -k`, and kills all Windows-shaped Wine helpers that remain, +including truncated `comm` names such as `steamwebhelper.exe` and +`winedevice.exe`. It does not start Steam or `eldenring.exe`. The CMake substrate target also generates the MetalSharp-owned ET_DYN symbol image `app/native/metalsharp_eac_libc.so.6` from @@ -209,8 +210,9 @@ installed MetalSharp Wine runtime. The resulting evidence (`schema`: applied the ELF `PT_LOAD` protections, and resolved the launcher's real `a`, `b`, `c`, and `d` exports. - The real EAC export `a` returned `1` through the Darwin TSD/ELF substrate. -- Cleanup left no Wine, winedbg, wineserver, conhost, or explorer process from - the proof run. The launcher is intentionally terminated at the thirty-second +- Cleanup left no Windows-shaped Wine process from the proof run, including + Wine services, winedbg, wineserver, conhost, explorer, and detached Steam + web helpers. The launcher is intentionally terminated at the thirty-second bound because the standalone probe does not provide the game transition. This is a real protected-module load/relocation/constructor/export proof, not a From dd9dfb28b7d63ab4dd60f31be49b12782d4e31f9 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Mon, 10 Aug 2026 09:53:04 -0600 Subject: [PATCH 5/9] feat: add opt-in EAC game-card toggle --- app/src-rust/src/anticheat.rs | 238 ++++++++++++++++++++++- app/src-rust/src/main.rs | 57 +++++- app/src-rust/src/mtsp/launcher.rs | 57 ++++++ app/src/renderer/components/GameCard.vue | 90 +++++++++ app/src/renderer/views/LibraryView.vue | 19 +- docs/guides/library-and-logs-ui.md | 11 ++ src/anticheat/linux_substrate.c | 14 +- 7 files changed, 475 insertions(+), 11 deletions(-) diff --git a/app/src-rust/src/anticheat.rs b/app/src-rust/src/anticheat.rs index dbbacac7a..a22249ce2 100644 --- a/app/src-rust/src/anticheat.rs +++ b/app/src-rust/src/anticheat.rs @@ -5,13 +5,231 @@ use std::io::{Read, Seek, SeekFrom, Write}; #[cfg(unix)] use std::os::fd::FromRawFd; use std::path::{Path, PathBuf}; -use std::time::UNIX_EPOCH; +use std::time::{SystemTime, UNIX_EPOCH}; use walkdir::WalkDir; const ARTIFACT_TAIL_LINES: usize = 80; const MAX_ARTIFACT_READ_BYTES: u64 = 1024 * 1024; const WALK_MAX_DEPTH: usize = 10; const MODULE_ASSET_MAX_DEPTH: usize = 8; +const EAC_SUBSTRATE_FILENAME: &str = "metalsharp_eac_substrate.dylib"; +const EAC_SYMBOL_IMAGE_FILENAME: &str = "metalsharp_eac_libc.so.6"; + +#[derive(Debug, Clone)] +struct EacRuntimeAssets { + substrate: Option, + symbol_image: Option, +} + +fn eac_toggle_path_for(home: &Path, appid: u32) -> PathBuf { + crate::platform::metalsharp_home_dir_for(home).join("sharp-library").join("eac").join(format!("{}.json", appid)) +} + +fn eac_asset_candidates(filename: &str) -> Vec { + let mut candidates = Vec::new(); + if let Ok(cwd) = std::env::current_dir() { + candidates.push(cwd.join("native").join(filename)); + candidates.push(cwd.join("app").join("native").join(filename)); + } + if let Some(resources) = crate::platform::app_resources_dir() { + candidates.push(resources.join("scripts").join("tools").join("native").join(filename)); + candidates.push(resources.join("native").join(filename)); + } + if let Ok(exe) = std::env::current_exe() { + for ancestor in exe.ancestors() { + candidates.push(ancestor.join("native").join(filename)); + candidates.push(ancestor.join("app").join("native").join(filename)); + } + } + + let mut unique = Vec::new(); + for candidate in candidates { + if !unique.iter().any(|existing: &PathBuf| existing == &candidate) { + unique.push(candidate); + } + } + unique +} + +fn eac_asset_path(filename: &str) -> Option { + eac_asset_candidates(filename) + .into_iter() + .find(|path| path.is_file() && fs::metadata(path).map(|metadata| metadata.len() > 0).unwrap_or(false)) +} + +fn eac_runtime_assets() -> EacRuntimeAssets { + EacRuntimeAssets { + substrate: eac_asset_path(EAC_SUBSTRATE_FILENAME), + symbol_image: eac_asset_path(EAC_SYMBOL_IMAGE_FILENAME), + } +} + +pub fn eac_enabled_for(home: &Path, appid: u32) -> bool { + fs::read_to_string(eac_toggle_path_for(home, appid)) + .ok() + .and_then(|contents| serde_json::from_str::(&contents).ok()) + .and_then(|value| value.get("enabled").and_then(Value::as_bool)) + .unwrap_or(false) +} + +pub fn eac_enabled(appid: u32) -> bool { + dirs::home_dir().map(|home| eac_enabled_for(&home, appid)).unwrap_or(false) +} + +/// EAC is only opt-in on a MetalSharp Wine route. A selected Steam or GPTK +/// route must never silently receive the substrate: redirect those requests +/// to the already-built M12 Wine 11.5 lane instead. Existing MTSP Wine lanes +/// stay selectable so the card toggle does not overwrite a user's explicit +/// M9/M10/M11/M12 choice. +pub fn eac_pipeline_for_request( + appid: u32, + requested: crate::mtsp::engine::PipelineId, +) -> crate::mtsp::engine::PipelineId { + eac_pipeline_for_enabled(eac_enabled(appid), requested) +} + +fn eac_pipeline_for_enabled( + enabled: bool, + requested: crate::mtsp::engine::PipelineId, +) -> crate::mtsp::engine::PipelineId { + use crate::mtsp::engine::PipelineId; + + if !enabled { + return requested; + } + + match requested { + PipelineId::D3DMetal | PipelineId::M13 | PipelineId::FnaArm64 | PipelineId::Steam | PipelineId::MacSteam => { + PipelineId::M12 + }, + pipeline => pipeline, + } +} + +fn eac_asset_record(name: &str, path: Option<&Path>) -> Value { + let present = path.is_some_and(|candidate| candidate.is_file()); + json!({ + "name": name, + "path": path.map(|candidate| candidate.to_string_lossy().to_string()), + "present": present, + "bytes": path.and_then(|candidate| fs::metadata(candidate).ok()).map(|metadata| metadata.len()), + "sha256": path.filter(|candidate| present).and_then(crate::diagnostics::file_sha256), + }) +} + +fn eac_runtime_status(appid: u32, home: &Path) -> Value { + let assets = eac_runtime_assets(); + let host_supported = cfg!(target_os = "macos"); + let assets_available = assets.substrate.is_some() && assets.symbol_image.is_some(); + let available = host_supported && assets_available; + let enabled = eac_enabled_for(home, appid); + let error = if !host_supported { + Some("The MetalSharp EAC substrate is currently supported only on macOS.".to_string()) + } else if !assets_available { + Some("The packaged MetalSharp EAC substrate or Linux symbol image is missing.".to_string()) + } else { + None + }; + + json!({ + "ok": true, + "appid": appid, + "enabled": enabled, + "eac_enabled": enabled, + "active": enabled && available, + "available": available, + "hostSupported": host_supported, + "launchPolicy": "opt_in_per_game", + "substrate": eac_asset_record(EAC_SUBSTRATE_FILENAME, assets.substrate.as_deref()), + "symbolImage": eac_asset_record(EAC_SYMBOL_IMAGE_FILENAME, assets.symbol_image.as_deref()), + "error": error, + }) +} + +pub fn handle_eac_status_for_appid(appid: u32) -> Value { + match dirs::home_dir() { + Some(home) => eac_runtime_status(appid, &home), + None => json!({"ok": false, "appid": appid, "error": "no home dir"}), + } +} + +fn write_eac_toggle(home: &Path, appid: u32, enabled: bool) -> Result<(), String> { + let path = eac_toggle_path_for(home, appid); + let parent = path.parent().ok_or_else(|| "EAC toggle path has no parent".to_string())?; + fs::create_dir_all(parent).map_err(|error| format!("create EAC toggle directory: {}", error))?; + let temporary = path.with_extension("json.tmp"); + let payload = json!({ + "appid": appid, + "enabled": enabled, + "updatedAtEpoch": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(), + }); + fs::write(&temporary, serde_json::to_vec_pretty(&payload).map_err(|error| error.to_string())?) + .map_err(|error| format!("write EAC toggle: {}", error))?; + fs::rename(&temporary, &path).map_err(|error| format!("commit EAC toggle: {}", error)) +} + +pub fn handle_eac_toggle(body: &Map) -> Value { + let Some(appid) = body.get("appid").and_then(Value::as_u64).filter(|id| *id > 0 && *id <= u32::MAX as u64) else { + return json!({"ok": false, "error": "appid required"}); + }; + let appid = appid as u32; + let enabled = body.get("enable").and_then(Value::as_bool).unwrap_or(true); + let Some(home) = dirs::home_dir() else { + return json!({"ok": false, "appid": appid, "error": "no home dir"}); + }; + + if enabled { + let assets = eac_runtime_assets(); + if !cfg!(target_os = "macos") { + return json!({"ok": false, "appid": appid, "error": "EAC substrate requires macOS"}); + } + if assets.substrate.is_none() || assets.symbol_image.is_none() { + return json!({ + "ok": false, + "appid": appid, + "error": "MetalSharp EAC substrate assets are unavailable; rebuild or reinstall the native bundle", + }); + } + } + + match write_eac_toggle(&home, appid, enabled) { + Ok(()) => eac_runtime_status(appid, &home), + Err(error) => json!({"ok": false, "appid": appid, "error": error}), + } +} + +/// Build the opt-in host environment for a real MetalSharp Wine game launch. +/// Enabling the card toggle changes only this per-process environment; it does +/// not copy, patch, or replace any game or EAC file. +pub fn eac_launch_env_for_home(home: &Path, appid: u32) -> Result, String> { + if !eac_enabled_for(home, appid) { + return Ok(Vec::new()); + } + if !cfg!(target_os = "macos") { + return Err("EAC substrate requires macOS".to_string()); + } + let assets = eac_runtime_assets(); + let (Some(substrate), Some(symbol_image)) = (assets.substrate, assets.symbol_image) else { + return Err( + "MetalSharp EAC substrate assets are unavailable; rebuild or reinstall the native bundle".to_string() + ); + }; + + let log_dir = crate::platform::metalsharp_home_dir_for(home).join("logs").join("eac").join(appid.to_string()); + fs::create_dir_all(&log_dir).map_err(|error| format!("create EAC log directory: {}", error))?; + Ok(vec![ + ("DYLD_INSERT_LIBRARIES".to_string(), substrate.to_string_lossy().to_string()), + ("METALSHARP_EAC_SUBSTRATE_LIBC".to_string(), symbol_image.to_string_lossy().to_string()), + ("METALSHARP_EAC_SUBSTRATE_LOG".to_string(), log_dir.join("substrate.log").to_string_lossy().to_string()), + ("METALSHARP_EAC_SUBSTRATE_MAPS".to_string(), log_dir.join("maps").to_string_lossy().to_string()), + ("METALSHARP_EAC_MODULE_DUMP".to_string(), log_dir.join("module.bin").to_string_lossy().to_string()), + ]) +} + +pub fn eac_launch_env(appid: u32) -> Result, String> { + let home = dirs::home_dir().ok_or_else(|| "no home dir".to_string())?; + eac_launch_env_for_home(&home, appid) +} #[derive(Debug, Default)] struct EacSummary { @@ -1375,4 +1593,22 @@ mod tests { let eac = EacSummary { module_target: Some("linux64".to_string()), ..Default::default() }; assert_eq!(substrate_decision("macos", &eac, &[]), "requires_linux_user_space_substrate_or_vendor_macos_asset"); } + + #[test] + fn eac_pipeline_policy_is_opt_in_and_never_selects_gptk() { + use crate::mtsp::engine::PipelineId; + + assert_eq!(eac_pipeline_for_enabled(false, PipelineId::D3DMetal), PipelineId::D3DMetal); + assert_eq!(eac_pipeline_for_enabled(true, PipelineId::D3DMetal), PipelineId::M12); + assert_eq!(eac_pipeline_for_enabled(true, PipelineId::M11), PipelineId::M11); + assert_eq!(eac_pipeline_for_enabled(true, PipelineId::Steam), PipelineId::M12); + } + + #[test] + fn eac_launch_env_is_empty_until_per_game_state_is_enabled() { + let home = std::env::temp_dir().join(format!("metalsharp-eac-card-{}", std::process::id())); + let _ = fs::remove_dir_all(&home); + assert!(eac_launch_env_for_home(&home, 1).unwrap().is_empty()); + let _ = fs::remove_dir_all(&home); + } } diff --git a/app/src-rust/src/main.rs b/app/src-rust/src/main.rs index b254f411e..eeddfbb56 100644 --- a/app/src-rust/src/main.rs +++ b/app/src-rust/src/main.rs @@ -671,13 +671,25 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { .or_else(|| body.get("pipeline")) .and_then(|v| v.as_str()) .unwrap_or("steam"); - let route_pipeline = match mtsp::engine::PipelineId::from_str_flexible(launch_method) { + let requested_route_pipeline = match mtsp::engine::PipelineId::from_str_flexible(launch_method) { Some(mtsp::engine::PipelineId::Steam) => None, Some(pipeline) => Some(bottles::resolve_steam_pipeline_for_request(id, Some(pipeline))), None if launch_method.eq_ignore_ascii_case("steam") => None, None => Some(bottles::resolve_steam_pipeline_for_request(id, None)), }; - app_log(&format!("Launching game via Wine Steam: appid {}, route {}", id, launch_method)); + let eac_enabled = anticheat::eac_enabled(id); + let route_pipeline = match requested_route_pipeline { + Some(pipeline) => Some(anticheat::eac_pipeline_for_request(id, pipeline)), + None if eac_enabled => Some(anticheat::eac_pipeline_for_request( + id, + bottles::resolve_steam_pipeline_for_request(id, None), + )), + None => None, + }; + app_log(&format!( + "Launching game via Wine Steam: appid {}, route {}, eac_substrate={}", + id, launch_method, eac_enabled + )); let launch_result = match route_pipeline { Some(pipeline) => { let bottle = match bottles::prepare_steam_game_launch(id, pipeline) { @@ -739,6 +751,7 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { "steam_started": steam_started, "steam_runtime": if offline_direct { "offline" } else { "background" }, "offline_mode": offline_direct, + "eac_substrate": eac_enabled, "env_applied_to": "game_process", "env_handoff": env.iter().map(|(k, _)| k).collect::>(), }) @@ -1136,6 +1149,34 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { }), ) }, + (Method::Get, "/eac/status") => { + let appid = req + .url() + .split("appid=") + .nth(1) + .and_then(|value| value.split('&').next()) + .and_then(|value| value.parse::().ok()); + match appid { + Some(id) if id > 0 => resp(200, anticheat::handle_eac_status_for_appid(id)), + _ => resp(400, json!({"ok": false, "error": "appid required"})), + } + }, + (Method::Post, "/eac/toggle") => { + let body = read_body(req); + let enabled = body.get("enable").and_then(|value| value.as_bool()).unwrap_or(true); + let appid = body.get("appid").and_then(|value| value.as_u64()).unwrap_or(0); + app_log(&format!( + "[EAC] {} per-game substrate for appid {}", + if enabled { "enabled" } else { "disabled" }, + appid + )); + let result = anticheat::handle_eac_toggle(&body); + if result.get("ok").and_then(Value::as_bool) == Some(true) { + resp(200, result) + } else { + resp(400, result) + } + }, (Method::Post, "/goldberg/toggle") => { let body = read_body(req); let appid = body.get("appid").and_then(|v| v.as_u64()); @@ -2240,7 +2281,8 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { let resolved_pipeline = Some(crate::mtsp::rules::resolve_requested_pipeline( id as u32, crate::mtsp::engine::PipelineId::from_str_flexible(launch_method), - )); + )) + .map(|pipeline| anticheat::eac_pipeline_for_request(id as u32, pipeline)); let engine_desc = resolved_pipeline .map(|p| crate::mtsp::engine::get_pipeline(p).description) .unwrap_or("Unknown"); @@ -2265,7 +2307,14 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { app_log(&format!("[LAUNCHED] appid {} | pid {} | engine: {}", id, pid, game_type)); resp( 200, - json!({"ok": true, "pid": pid, "gameType": game_type, "appid": id, "engine": engine_desc}), + json!({ + "ok": true, + "pid": pid, + "gameType": game_type, + "appid": id, + "engine": engine_desc, + "eac_substrate": anticheat::eac_enabled(id as u32), + }), ) }, Err(e) => { diff --git a/app/src-rust/src/mtsp/launcher.rs b/app/src-rust/src/mtsp/launcher.rs index c1c24010a..4e3f000d2 100644 --- a/app/src-rust/src/mtsp/launcher.rs +++ b/app/src-rust/src/mtsp/launcher.rs @@ -393,6 +393,7 @@ pub fn launch_with_pipeline( pipeline_id: PipelineId, ) -> Result<(u32, &'static str), Box> { let pipeline_id = super::rules::resolve_requested_pipeline(appid, Some(pipeline_id)); + let pipeline_id = crate::anticheat::eac_pipeline_for_request(appid, pipeline_id); let node = get_pipeline(pipeline_id); prepare_steam_api_for_pipeline(appid, pipeline_id); @@ -421,6 +422,7 @@ pub fn launch_steam_bottle_with_pipeline( extra_env: &[(String, String)], ) -> Result<(u32, &'static str, PathBuf), Box> { let pipeline_id = super::rules::resolve_requested_pipeline(appid, Some(pipeline_id)); + let pipeline_id = crate::anticheat::eac_pipeline_for_request(appid, pipeline_id); let node = get_pipeline(pipeline_id); let log_path = crate::bottles::steam_compatdata_launch_log_path(appid); @@ -464,6 +466,7 @@ pub fn prepare_pipeline_with_request( let mut timing = crate::diagnostics::LaunchTiming::start(); timing.mark("pipeline_resolution_start"); let pipeline_id = super::rules::resolve_requested_pipeline(appid, requested); + let pipeline_id = crate::anticheat::eac_pipeline_for_request(appid, pipeline_id); let node = get_pipeline(pipeline_id); timing.mark("pipeline_resolution_done"); @@ -507,6 +510,7 @@ pub fn prepare_steam_pipeline_env( pipeline_id: PipelineId, ) -> Result<(Vec<(String, String)>, super::recipe::LaunchRecipe), Box> { let pipeline_id = super::rules::resolve_requested_pipeline(appid, Some(pipeline_id)); + let pipeline_id = crate::anticheat::eac_pipeline_for_request(appid, pipeline_id); let node = get_pipeline(pipeline_id); match pipeline_id { PipelineId::Dxmt @@ -596,6 +600,7 @@ pub fn m12_verify_dry_run(appid: u32) -> serde_json::Value { pub fn pipeline_dry_run_for(home: &Path, appid: u32, requested: Option) -> serde_json::Value { let home = home.to_path_buf(); let pipeline = super::rules::resolve_requested_pipeline(appid, requested); + let pipeline = crate::anticheat::eac_pipeline_for_request(appid, pipeline); let node = get_pipeline(pipeline); let ms_root = crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("wine"); @@ -690,6 +695,8 @@ pub fn pipeline_dry_run_for(home: &Path, appid: u32, requested: Option Result<(u32, &'static str, super::recipe::LaunchRecipe), Box> { let pipeline_id = if pipeline_id == PipelineId::Dxmt { super::rules::resolve_pipeline(launch_id) } else { pipeline_id }; + let pipeline_id = crate::anticheat::eac_pipeline_for_request(launch_id, pipeline_id); let node = get_pipeline(pipeline_id); match pipeline_id { PipelineId::Dxmt @@ -1451,6 +1459,7 @@ pub fn launch_custom_with_options( // the DXMT env after node env so the toggle is authoritative. apply_metal_fx_config_cmd(&mut cmd, node, &home); apply_dxmt_shader_metal_version_config_cmd(&mut cmd, node); + apply_eac_launch_env(&mut cmd, &home, launch_id)?; cmd.arg(&exe_name); cmd.args(&recipe.launch_args); @@ -1921,6 +1930,7 @@ fn launch_dxmt_metal_with_context( // Reconcile after game recipe and caller-provided environment so the host // Metal shader dialect remains authoritative on direct launch paths. apply_dxmt_shader_metal_version_config_cmd(&mut cmd, node); + apply_eac_launch_env(&mut cmd, &home, appid)?; cmd.arg(&exe_name); cmd.args(&recipe.launch_args); @@ -2008,6 +2018,7 @@ fn launch_wine_bare_with_context( for (key, value) in extra_env { cmd.env(key, value); } + apply_eac_launch_env(&mut cmd, &home, appid)?; cmd.arg(&exe_name); cmd.args(&recipe.launch_args); @@ -2918,9 +2929,55 @@ fn steam_pipeline_env_pairs(home: &PathBuf, node: &PipelineNode, appid: u32) -> apply_dxmt_shader_metal_version_config(&mut env, node); // msync toggle likewise wins over recipe env. apply_msync_config(&mut env, home); + apply_eac_env_pairs(&mut env, home, appid); env } +fn set_env_pair(env: &mut Vec<(String, String)>, key: &str, value: String) { + if let Some((_, existing)) = env.iter_mut().rev().find(|(candidate, _)| candidate == key) { + *existing = value; + } else { + env.push((key.to_string(), value)); + } +} + +fn apply_eac_env_pairs(env: &mut Vec<(String, String)>, home: &Path, appid: u32) { + let Ok(eac_env) = crate::anticheat::eac_launch_env_for_home(home, appid) else { + return; + }; + for (key, value) in eac_env { + if key == "DYLD_INSERT_LIBRARIES" { + let inherited = env + .iter() + .rev() + .find(|(candidate, _)| candidate == &key) + .map(|(_, existing)| existing.clone()) + .or_else(|| std::env::var("DYLD_INSERT_LIBRARIES").ok()); + let merged = append_path_env(inherited.as_deref(), &value); + set_env_pair(env, &key, merged); + } else { + set_env_pair(env, &key, value); + } + } +} + +fn apply_eac_launch_env(cmd: &mut Command, home: &Path, appid: u32) -> Result<(), Box> { + let eac_env = crate::anticheat::eac_launch_env_for_home(home, appid).map_err(std::io::Error::other)?; + for (key, value) in eac_env { + if key == "DYLD_INSERT_LIBRARIES" { + let inherited = cmd + .get_envs() + .find(|(candidate, _)| candidate.to_string_lossy() == key) + .and_then(|(_, existing)| existing.and_then(|value| value.to_str()).map(str::to_string)) + .or_else(|| std::env::var("DYLD_INSERT_LIBRARIES").ok()); + cmd.env(key, append_path_env(inherited.as_deref(), &value)); + } else { + cmd.env(key, value); + } + } + Ok(()) +} + /// Wine msync toggle value (`WINEMSYNC`) for the given home. Defaults ON. /// A `WINEMSYNC` env var in the parent process overrides the config (same /// semantics as `msync_enabled()`), so the documented dev override works on diff --git a/app/src/renderer/components/GameCard.vue b/app/src/renderer/components/GameCard.vue index d5dc1de70..8b66a6f26 100644 --- a/app/src/renderer/components/GameCard.vue +++ b/app/src/renderer/components/GameCard.vue @@ -159,6 +159,10 @@ const toast = useToast(); const goldbergActive = ref(false); const goldbergBackedUpAt = ref(null); const goldbergCacheOk = ref(true); +const eacEnabled = ref(false); +const eacAvailable = ref(false); +const eacLoading = ref(false); +const eacStatusError = ref(null); const pipelineName = ref("Auto"); const pipelineResolvedLocally = ref(false); const selectedLaunchMode = ref("auto"); @@ -478,6 +482,7 @@ onMounted(async () => { } goldbergCacheOk.value = gs.cache_files_ok === true; } + await refreshEacStatus(); } }); @@ -572,6 +577,58 @@ async function toggleGoldberg(enable: boolean) { } } +async function refreshEacStatus() { + const result = await api<{ + ok: boolean; + enabled?: boolean; + eac_enabled?: boolean; + available?: boolean; + error?: string | null; + }>("GET", `/eac/status?appid=${props.game.appid}`); + if (result?.ok) { + eacEnabled.value = result.eac_enabled === true || result.enabled === true; + eacAvailable.value = result.available === true; + eacStatusError.value = result.error ?? null; + } else { + eacEnabled.value = false; + eacAvailable.value = false; + eacStatusError.value = result?.error ?? "EAC substrate status is unavailable"; + } +} + +async function toggleEac(enable: boolean) { + if (enable && !eacAvailable.value) { + toast.show(eacStatusError.value ?? "EAC substrate is unavailable on this MetalSharp installation", "error"); + return; + } + eacLoading.value = true; + const result = await api<{ + ok: boolean; + enabled?: boolean; + eac_enabled?: boolean; + available?: boolean; + error?: string; + }>("POST", "/eac/toggle", { + appid: props.game.appid, + enable, + }); + eacLoading.value = false; + if (result?.ok) { + eacEnabled.value = result.eac_enabled === true || result.enabled === true; + eacAvailable.value = result.available === true; + eacStatusError.value = result.error ?? null; + toast.show( + enable + ? "EAC substrate enabled; it will apply on the next MetalSharp Wine launch" + : "EAC substrate disabled for this game", + "success", + ); + } else { + await refreshEacStatus(); + toast.show(result?.error ?? "Failed to toggle EAC substrate", "error"); + } +} + async function runDoctor() { doctorOpen.value = true; doctorLoading.value = true; @@ -973,6 +1030,25 @@ function formatBytes(bytes: number): string { Steam Emu + ("GET", `/eac/status?appid=${game.appid}`); + const eacEnabled = eacStatus?.ok === true && (eacStatus.eac_enabled === true || eacStatus.enabled === true); + const selectedLaunchMethod = effectiveLaunchMethod(game, launchMethod, eacEnabled); if (isMacSteamLaunch(selectedLaunchMethod) && wineSteamRunning.value) { if (!confirm(`Stop Wine Steam and launch ${game.name} through MacOS Steam?`)) return; const stopResult = await api<{ ok: boolean; running?: boolean; error?: string }>("POST", "/steam/stop"); @@ -242,7 +255,7 @@ async function launchGame(game: SteamGame, launchMethod = "auto") { } launchingAppId.value = game.appid; - const useWineSteamRoute = isWineSteamRouteLaunch(game, selectedLaunchMethod); + const useWineSteamRoute = eacEnabled || isWineSteamRouteLaunch(game, selectedLaunchMethod); const launchEndpoint = useWineSteamRoute ? "/steam/launch-game" : "/game/launch-auto"; const launchResult = await api<{ ok: boolean; diff --git a/docs/guides/library-and-logs-ui.md b/docs/guides/library-and-logs-ui.md index 281da4465..4b6a4baae 100644 --- a/docs/guides/library-and-logs-ui.md +++ b/docs/guides/library-and-logs-ui.md @@ -6,6 +6,17 @@ Steam and backend status remain in the title row as the window narrows. Launch, refresh, search, and filter controls reflow below the title without moving those status badges into the action row. +Installed Steam game cards show the **Steam Emu** toggle followed immediately by +the opt-in **EAC** toggle. EAC is disabled by default for every app. The card +enables it only when the packaged MetalSharp substrate and Linux symbol image +are available on macOS; enabling it persists per-app state under +`~/.metalsharp/sharp-library/eac/` and applies the substrate environment only +to the next MetalSharp Wine launch. It never starts a game automatically. An +opted-in Steam or GPTK selection is routed to the already-installed M12 +MetalSharp Wine 11.5 lane so the substrate is not sent through GPTK, another +Wine build, or macOS Steam. Per-app substrate logs and module dumps are kept +under `~/.metalsharp/logs/eac//`. + ## Sharp Library Use the **Library source** menu to switch between installed Windows applications and GOG games. The installer view keeps its primary actions focused on installing and refreshing applications; redistributable source controls are not shown in this header. diff --git a/src/anticheat/linux_substrate.c b/src/anticheat/linux_substrate.c index 3fde8e31b..54d91da98 100644 --- a/src/anticheat/linux_substrate.c +++ b/src/anticheat/linux_substrate.c @@ -3750,15 +3750,23 @@ static bool map_linux_libc_image(void) { ms_log("mapped ELF libc image path=%s base=0x%llx size=0x%zx", g_elf_path, (unsigned long long)(uintptr_t)mapping, map_length); - int maps_fd = ms_raw_open("/tmp/metalsharp-eac-maps", O_RDWR | O_CREAT | O_TRUNC, 0600); + const char* configured_maps = getenv("METALSHARP_EAC_SUBSTRATE_MAPS"); + if (configured_maps != NULL && configured_maps[0] != '\0') { + snprintf(g_maps_path, sizeof(g_maps_path), "%s", configured_maps); + } else { + snprintf(g_maps_path, sizeof(g_maps_path), "/tmp/metalsharp-eac-maps"); + } + int maps_fd = ms_raw_open(g_maps_path, O_RDWR | O_CREAT | O_TRUNC, 0600); if (maps_fd >= 0) { int maps_length = dprintf(maps_fd, "%016llx-%016llx r-xp 00000000 00:00 0 %s\n", (unsigned long long)(uintptr_t)mapping, (unsigned long long)((uintptr_t)mapping + map_length), g_elf_path); - if (maps_length > 0) { - snprintf(g_maps_path, sizeof(g_maps_path), "/tmp/metalsharp-eac-maps"); + if (maps_length <= 0) { + g_maps_path[0] = '\0'; } close(maps_fd); + } else { + g_maps_path[0] = '\0'; } MsElfHeader* header = (MsElfHeader*)mapping; From f5b5580cf5b18db618b335600e87dc1a2e4078e1 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Mon, 10 Aug 2026 10:26:39 -0600 Subject: [PATCH 6/9] fix: stage EAC substrate across installs and migrations --- .github/workflows/ci.yml | 3 + .github/workflows/pr-ci.yml | 3 + .github/workflows/release.yml | 1 + CMakeLists.txt | 36 ++- app/src-rust/src/anticheat.rs | 40 ++- app/src-rust/src/installer.rs | 269 ++++++++++++++++++- app/src-rust/src/migrate.rs | 73 ++++- app/updater/update.py | 138 +++++++++- app/updater/update.sh | 11 + docs/guides/library-and-logs-ui.md | 16 ++ tools/bundles/create-split-bundles.py | 17 +- tools/bundles/verify-bundles.sh | 31 ++- tools/bundles/verify-native-shims.sh | 4 + tools/ci/verify-dmg-workflow.py | 16 ++ tools/dmg/verify-dmg-runtime-assets.sh | 19 ++ tools/package/prepare-native-placeholders.sh | 17 ++ 16 files changed, 655 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 779b0208d..f7deb292a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,6 +208,9 @@ jobs: - name: Build run: cmake --build build-native --parallel $(sysctl -n hw.ncpu) + - name: Native package contract + run: tools/bundles/verify-native-shims.sh app/native + - name: Runtime tests run: ctest --test-dir build-native --output-on-failure -E '^(metal_device|dxbc|format_translation|phase17)$' diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 1cd96df3d..0f50c6c4d 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -238,6 +238,9 @@ jobs: - name: Build run: cmake --build build-native --parallel $(sysctl -n hw.ncpu) + - name: Native package contract + run: tools/bundles/verify-native-shims.sh app/native + - name: Runtime tests run: ctest --test-dir build-native --output-on-failure -E '^(metal_device|dxbc|format_translation|phase17)$' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d5b42426..30fe35c6a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -133,6 +133,7 @@ jobs: cp libd3d11.dylib libd3d12.dylib libdxgi.dylib libxaudio2_9.dylib libxinput1_4.dylib libopengl32.dylib ../app/native/ 2>/dev/null || true cp metalsharp metalsharp_launcher MetalSharpMigrator ../app/native/ 2>/dev/null || true cd .. + tools/bundles/verify-native-shims.sh app/native METALSHARP_BUILD_DIR="$PWD/build" tools/package/create-host-runtime.sh - name: Install Electron deps diff --git a/CMakeLists.txt b/CMakeLists.txt index 57a1f2765..24e2189ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -318,26 +318,22 @@ metalsharp_wine_target(metalsharp_eac_substrate) # never asks dyld to load Linux libc. Generate that MetalSharp-owned image # beside the substrate so packaged/runtime proofs do not depend on a machine's # /tmp state. It contains no vendor or protected-module bytes. -find_package(Python3 COMPONENTS Interpreter QUIET) -if(Python3_Interpreter_FOUND) - set(METALSHARP_EAC_SYMBOL_IMAGE - "${CMAKE_SOURCE_DIR}/app/native/metalsharp_eac_libc.so.6") - add_custom_command( - OUTPUT "${METALSHARP_EAC_SYMBOL_IMAGE}" - COMMAND ${Python3_EXECUTABLE} - "${CMAKE_SOURCE_DIR}/tools/anticheat/generate_linux_libc_elf.py" - "${METALSHARP_EAC_SYMBOL_IMAGE}" - DEPENDS "${CMAKE_SOURCE_DIR}/tools/anticheat/generate_linux_libc_elf.py" - COMMENT "Generating MetalSharp EAC Linux symbol image" - VERBATIM - ) - add_custom_target(metalsharp_eac_symbol_image - DEPENDS "${METALSHARP_EAC_SYMBOL_IMAGE}" - ) - add_dependencies(metalsharp_eac_substrate metalsharp_eac_symbol_image) -else() - message(WARNING "MetalSharp EAC substrate symbol image requires Python 3") -endif() +find_package(Python3 COMPONENTS Interpreter REQUIRED) +set(METALSHARP_EAC_SYMBOL_IMAGE + "${CMAKE_SOURCE_DIR}/app/native/metalsharp_eac_libc.so.6") +add_custom_command( + OUTPUT "${METALSHARP_EAC_SYMBOL_IMAGE}" + COMMAND ${Python3_EXECUTABLE} + "${CMAKE_SOURCE_DIR}/tools/anticheat/generate_linux_libc_elf.py" + "${METALSHARP_EAC_SYMBOL_IMAGE}" + DEPENDS "${CMAKE_SOURCE_DIR}/tools/anticheat/generate_linux_libc_elf.py" + COMMENT "Generating MetalSharp EAC Linux symbol image" + VERBATIM +) +add_custom_target(metalsharp_eac_symbol_image + DEPENDS "${METALSHARP_EAC_SYMBOL_IMAGE}" +) +add_dependencies(metalsharp_eac_substrate metalsharp_eac_symbol_image) add_custom_command(TARGET metalsharp_eac_substrate POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" diff --git a/app/src-rust/src/anticheat.rs b/app/src-rust/src/anticheat.rs index a22249ce2..a02ed42a7 100644 --- a/app/src-rust/src/anticheat.rs +++ b/app/src-rust/src/anticheat.rs @@ -12,8 +12,8 @@ const ARTIFACT_TAIL_LINES: usize = 80; const MAX_ARTIFACT_READ_BYTES: u64 = 1024 * 1024; const WALK_MAX_DEPTH: usize = 10; const MODULE_ASSET_MAX_DEPTH: usize = 8; -const EAC_SUBSTRATE_FILENAME: &str = "metalsharp_eac_substrate.dylib"; -const EAC_SYMBOL_IMAGE_FILENAME: &str = "metalsharp_eac_libc.so.6"; +pub(crate) const EAC_SUBSTRATE_FILENAME: &str = "metalsharp_eac_substrate.dylib"; +pub(crate) const EAC_SYMBOL_IMAGE_FILENAME: &str = "metalsharp_eac_libc.so.6"; #[derive(Debug, Clone)] struct EacRuntimeAssets { @@ -25,22 +25,27 @@ fn eac_toggle_path_for(home: &Path, appid: u32) -> PathBuf { crate::platform::metalsharp_home_dir_for(home).join("sharp-library").join("eac").join(format!("{}.json", appid)) } -fn eac_asset_candidates(filename: &str) -> Vec { +fn eac_packaged_asset_candidates(filename: &str) -> Vec { let mut candidates = Vec::new(); - if let Ok(cwd) = std::env::current_dir() { - candidates.push(cwd.join("native").join(filename)); - candidates.push(cwd.join("app").join("native").join(filename)); - } if let Some(resources) = crate::platform::app_resources_dir() { candidates.push(resources.join("scripts").join("tools").join("native").join(filename)); candidates.push(resources.join("native").join(filename)); } + if let Ok(cwd) = std::env::current_dir() { + candidates.push(cwd.join("native").join(filename)); + candidates.push(cwd.join("app").join("native").join(filename)); + } if let Ok(exe) = std::env::current_exe() { for ancestor in exe.ancestors() { candidates.push(ancestor.join("native").join(filename)); candidates.push(ancestor.join("app").join("native").join(filename)); } } + if let Some(home) = dirs::home_dir() { + candidates.push( + crate::platform::metalsharp_home_dir_for(&home).join("scripts").join("tools").join("native").join(filename), + ); + } let mut unique = Vec::new(); for candidate in candidates { @@ -51,6 +56,27 @@ fn eac_asset_candidates(filename: &str) -> Vec { unique } +pub(crate) fn eac_packaged_asset_path(filename: &str) -> Option { + eac_packaged_asset_candidates(filename) + .into_iter() + .find(|path| path.is_file() && fs::metadata(path).map(|metadata| metadata.len() > 0).unwrap_or(false)) +} + +fn eac_asset_candidates(filename: &str) -> Vec { + let mut candidates = Vec::new(); + if let Some(home) = dirs::home_dir() { + candidates.push(crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("eac").join(filename)); + } + candidates.extend(eac_packaged_asset_candidates(filename)); + let mut unique = Vec::new(); + for candidate in candidates { + if !unique.iter().any(|existing: &PathBuf| existing == &candidate) { + unique.push(candidate); + } + } + unique +} + fn eac_asset_path(filename: &str) -> Option { eac_asset_candidates(filename) .into_iter() diff --git a/app/src-rust/src/installer.rs b/app/src-rust/src/installer.rs index 174213637..dac92e621 100644 --- a/app/src-rust/src/installer.rs +++ b/app/src-rust/src/installer.rs @@ -34,6 +34,7 @@ const ASSETS_BUNDLE: &str = "metalsharp-assets"; const FNALIBS_BUNDLE: &str = "fnalibs"; const SCRIPTS_TOOLS_BUNDLE: &str = "metalsharp-scripts-tools"; const STEAM_BUNDLE: &str = "metalsharp-steam"; +const EAC_RUNTIME_SUBDIR: &str = "eac"; const METALSHARP_NTDLL_HOOK_DLL: &str = "metalsharp_ntdll_hook.dll"; const DXMT_REQUIRED_PE: &[&str] = &[ "d3d10core.dll", @@ -261,8 +262,12 @@ const FNALIBS_REQUIRED_ARCHIVE_FILES: &[&str] = &[ "fnalibs/fmod/libfmod.dylib", "fnalibs/fmod/libfmodstudio.dylib", ]; -const SCRIPTS_TOOLS_REQUIRED_ARCHIVE_FILES: &[&str] = - &["scripts/tools/configs/mtsp-rules.toml", "scripts/tools/updater/update.sh"]; +const SCRIPTS_TOOLS_REQUIRED_ARCHIVE_FILES: &[&str] = &[ + "scripts/tools/configs/mtsp-rules.toml", + "scripts/tools/updater/update.sh", + "scripts/tools/native/metalsharp_eac_substrate.dylib", + "scripts/tools/native/metalsharp_eac_libc.so.6", +]; const STEAM_REQUIRED_ARCHIVE_FILES: &[&str] = &["steam/SteamSetup.exe", "steam/steamwebhelper.exe", "steam/steamwebhelper-wrapper.c"]; @@ -452,6 +457,7 @@ fn install_steps() -> Vec { ("Host Runtime ABI", Box::new(install_host_runtime)), ("Support Assets", Box::new(install_split_assets_bundle)), ("Scripts and Tools", Box::new(install_scripts_tools_bundle)), + ("EAC Substrate", Box::new(ensure_eac_substrate_runtime_ready)), ("DXMT Graphics Runtimes", Box::new(|home| ensure_graphics_runtimes_ready(home))), ("Goldberg Steam Emulator", Box::new(install_goldberg)), ("Steam Bridge Shim", Box::new(install_steam_bridge)), @@ -1288,6 +1294,183 @@ fn install_scripts_tools_bundle(home: &PathBuf) -> Result { Ok(true) } +fn eac_substrate_file_valid(path: &Path, elf: bool) -> bool { + let Ok(bytes) = fs::read(path) else { + return false; + }; + if bytes.is_empty() { + return false; + } + if elf { + bytes.len() >= 4 && bytes.starts_with(b"\x7fELF") + } else { + macho_contains_x86_64(&bytes) + } +} + +fn macho_contains_x86_64(bytes: &[u8]) -> bool { + if bytes.len() < 8 { + return false; + } + + match bytes[..4] { + // MH_MAGIC_64: little-endian x86_64 thin Mach-O. + [0xcf, 0xfa, 0xed, 0xfe] => bytes[4..8] == [0x07, 0x00, 0x00, 0x01], + // MH_CIGAM_64: big-endian x86_64 thin Mach-O. + [0xfe, 0xed, 0xfa, 0xcf] => bytes[4..8] == [0x01, 0x00, 0x00, 0x07], + // FAT_MAGIC / FAT_CIGAM. Each fat_arch starts with cputype. + [0xca, 0xfe, 0xba, 0xbe] => fat_macho_contains_x86_64(bytes, false), + [0xbe, 0xba, 0xfe, 0xca] => fat_macho_contains_x86_64(bytes, true), + _ => false, + } +} + +fn fat_macho_contains_x86_64(bytes: &[u8], little_endian: bool) -> bool { + let read_u32 = |offset: usize| -> Option { + let field = bytes.get(offset..offset.checked_add(4)?)?; + Some(if little_endian { + u32::from_le_bytes(field.try_into().ok()?) + } else { + u32::from_be_bytes(field.try_into().ok()?) + }) + }; + let Some(architecture_count) = read_u32(4) else { + return false; + }; + let max_architectures = (bytes.len().saturating_sub(8) / 20) as u32; + if architecture_count > max_architectures { + return false; + } + (0..architecture_count).any(|index| read_u32(8 + index as usize * 20) == Some(0x0100_0007)) +} + +pub(crate) fn eac_substrate_runtime_ready_for_ms_dir(ms_dir: &Path) -> bool { + let eac_dir = ms_dir.join("runtime").join(EAC_RUNTIME_SUBDIR); + eac_substrate_file_valid(&eac_dir.join(crate::anticheat::EAC_SUBSTRATE_FILENAME), false) + && eac_substrate_file_valid(&eac_dir.join(crate::anticheat::EAC_SYMBOL_IMAGE_FILENAME), true) +} + +pub(crate) fn eac_substrate_runtime_ready_for_home(home: &Path) -> bool { + eac_substrate_runtime_ready_for_ms_dir(&crate::platform::metalsharp_home_dir_for(home)) +} + +fn eac_path_exists(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok() +} + +fn remove_eac_path(path: &Path) { + let Ok(metadata) = fs::symlink_metadata(path) else { + return; + }; + if metadata.file_type().is_symlink() || metadata.is_file() { + let _ = fs::remove_file(path); + } else if metadata.is_dir() { + let _ = fs::remove_dir_all(path); + } +} + +fn install_eac_substrate_from_sources( + home: &Path, + substrate_source: &Path, + symbol_source: &Path, +) -> Result { + if !eac_substrate_file_valid(substrate_source, false) { + return Err(format!("invalid MetalSharp EAC substrate dylib: {}", substrate_source.display())); + } + if !eac_substrate_file_valid(symbol_source, true) { + return Err(format!("invalid MetalSharp EAC Linux symbol image: {}", symbol_source.display())); + } + + let ms_dir = crate::platform::metalsharp_home_dir_for(home); + let runtime_dir = ms_dir.join("runtime"); + let eac_dir = runtime_dir.join(EAC_RUNTIME_SUBDIR); + let substrate_dest = eac_dir.join(crate::anticheat::EAC_SUBSTRATE_FILENAME); + let symbol_dest = eac_dir.join(crate::anticheat::EAC_SYMBOL_IMAGE_FILENAME); + let sources_match = crate::diagnostics::file_sha256(substrate_source) + .zip(crate::diagnostics::file_sha256(symbol_source)) + .zip(crate::diagnostics::file_sha256(&substrate_dest).zip(crate::diagnostics::file_sha256(&symbol_dest))) + .is_some_and(|((source_substrate, source_symbol), (dest_substrate, dest_symbol))| { + source_substrate == dest_substrate && source_symbol == dest_symbol + }); + if eac_substrate_runtime_ready_for_ms_dir(&ms_dir) && sources_match { + return Ok(false); + } + + fs::create_dir_all(&runtime_dir).map_err(|error| format!("create EAC runtime parent: {}", error))?; + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let staging_dir = runtime_dir.join(format!(".eac-staging-{}-{}", std::process::id(), unique)); + let backup_dir = runtime_dir.join(format!(".eac-backup-{}-{}", std::process::id(), unique)); + remove_eac_path(&staging_dir); + remove_eac_path(&backup_dir); + fs::create_dir_all(&staging_dir).map_err(|error| format!("create EAC staging directory: {}", error))?; + + let staging_substrate = staging_dir.join(crate::anticheat::EAC_SUBSTRATE_FILENAME); + let staging_symbol = staging_dir.join(crate::anticheat::EAC_SYMBOL_IMAGE_FILENAME); + let stage_result = (|| { + fs::copy(substrate_source, &staging_substrate).map_err(|error| format!("stage EAC substrate: {}", error))?; + fs::copy(symbol_source, &staging_symbol).map_err(|error| format!("stage EAC symbol image: {}", error))?; + if !eac_substrate_file_valid(&staging_substrate, false) || !eac_substrate_file_valid(&staging_symbol, true) { + return Err("staged EAC substrate artifacts failed validation".to_string()); + } + Ok(()) + })(); + if let Err(error) = stage_result { + remove_eac_path(&staging_dir); + return Err(error); + } + + // Replace the pair as one directory. Migration removes the whole runtime + // tree, and an update can refresh one or both files; keeping the old + // directory until the complete staged pair is ready prevents a half-new, + // half-old EAC surface if either copy or validation fails. + let had_previous = eac_path_exists(&eac_dir); + if had_previous { + if let Err(error) = fs::rename(&eac_dir, &backup_dir) { + remove_eac_path(&staging_dir); + return Err(format!("stage existing EAC runtime for replacement: {}", error)); + } + } + if let Err(error) = fs::rename(&staging_dir, &eac_dir) { + if had_previous { + let _ = fs::rename(&backup_dir, &eac_dir); + } + remove_eac_path(&staging_dir); + return Err(format!("commit EAC runtime directory: {}", error)); + } + + if !eac_substrate_runtime_ready_for_ms_dir(&ms_dir) { + remove_eac_path(&eac_dir); + if had_previous { + let _ = fs::rename(&backup_dir, &eac_dir); + } + return Err("EAC substrate installation completed but the durable artifacts are incomplete".to_string()); + } + if had_previous { + remove_eac_path(&backup_dir); + } + Ok(true) +} + +/// Stage the two MetalSharp-owned EAC boundary artifacts into the durable +/// runtime tree. The packaged app also carries them in Resources, but a +/// runtime copy makes first install, DMG replacement, and migration converge +/// on the same asset layout and lets the backend survive a missing resource +/// lookup after an update. +pub(crate) fn ensure_eac_substrate_runtime_ready(home: &PathBuf) -> Result { + if !cfg!(target_os = "macos") { + return Ok(false); + } + + let substrate_source = crate::anticheat::eac_packaged_asset_path(crate::anticheat::EAC_SUBSTRATE_FILENAME) + .ok_or_else(|| "MetalSharp EAC substrate dylib is missing from the packaged native assets".to_string())?; + let symbol_source = crate::anticheat::eac_packaged_asset_path(crate::anticheat::EAC_SYMBOL_IMAGE_FILENAME) + .ok_or_else(|| "MetalSharp EAC Linux symbol image is missing from the packaged native assets".to_string())?; + install_eac_substrate_from_sources(home, &substrate_source, &symbol_source) +} + fn install_mono_x86_fallback(home: &PathBuf) -> Result { let mono_x86 = crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("mono-x86").join("bin").join("mono"); @@ -3090,10 +3273,13 @@ mod tests { } #[test] - fn install_steps_use_split_graphics_runtime_and_do_not_install_eac_toggle_or_gptk() { + fn install_steps_include_eac_substrate_without_installing_eac_toggle_or_gptk() { let names: Vec<&str> = install_steps().into_iter().map(|(name, _)| name).collect(); assert!(names.contains(&"DXMT Graphics Runtimes")); + let scripts_idx = names.iter().position(|name| *name == "Scripts and Tools").expect("scripts/tools step"); + let eac_idx = names.iter().position(|name| *name == "EAC Substrate").expect("EAC substrate step"); + assert!(scripts_idx < eac_idx, "the EAC step must consume the installed scripts/tools bundle"); assert!(!names.contains(&"Offline EAC Mode")); assert!( names.iter().all(|name| !name.to_ascii_lowercase().contains("gptk")), @@ -3102,6 +3288,83 @@ mod tests { ); } + #[test] + fn scripts_tools_bundle_requires_both_eac_native_assets() { + assert!(SCRIPTS_TOOLS_REQUIRED_ARCHIVE_FILES.contains(&"scripts/tools/native/metalsharp_eac_substrate.dylib")); + assert!(SCRIPTS_TOOLS_REQUIRED_ARCHIVE_FILES.contains(&"scripts/tools/native/metalsharp_eac_libc.so.6")); + } + + #[test] + fn eac_macho_validation_requires_the_wine_architecture() { + assert!(macho_contains_x86_64(&[0xcf, 0xfa, 0xed, 0xfe, 0x07, 0x00, 0x00, 0x01])); + assert!(!macho_contains_x86_64(&[0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01])); + + let mut universal = vec![0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00, 0x00, 0x01]; + universal.extend_from_slice(&[0x01, 0x00, 0x00, 0x07]); + universal.extend_from_slice(&[0; 16]); + assert!(macho_contains_x86_64(&universal)); + } + + #[test] + fn eac_runtime_readiness_requires_a_macho_and_an_elf_image() { + let home = test_home("eac-readiness"); + let ms_dir = crate::platform::metalsharp_home_dir_for(&home); + let eac_dir = ms_dir.join("runtime").join(EAC_RUNTIME_SUBDIR); + fs::create_dir_all(&eac_dir).expect("create EAC runtime dir"); + + fs::write( + eac_dir.join(crate::anticheat::EAC_SUBSTRATE_FILENAME), + [0xcf, 0xfa, 0xed, 0xfe, 0x07, 0x00, 0x00, 0x01], + ) + .expect("write Mach-O fixture"); + assert!(!eac_substrate_runtime_ready_for_home(&home)); + + fs::write(eac_dir.join(crate::anticheat::EAC_SYMBOL_IMAGE_FILENAME), b"\x7fELF\x02\x01") + .expect("write ELF fixture"); + assert!(eac_substrate_runtime_ready_for_home(&home)); + + fs::write(eac_dir.join(crate::anticheat::EAC_SUBSTRATE_FILENAME), b"not a Mach-O").expect("poison Mach-O"); + assert!(!eac_substrate_runtime_ready_for_home(&home)); + let _ = fs::remove_dir_all(home); + } + + #[test] + fn eac_runtime_install_is_idempotent_and_refreshes_as_a_pair() { + let home = test_home("eac-install"); + let source_dir = test_home("eac-install-source"); + fs::create_dir_all(&source_dir).expect("create source dir"); + let substrate_source = source_dir.join(crate::anticheat::EAC_SUBSTRATE_FILENAME); + let symbol_source = source_dir.join(crate::anticheat::EAC_SYMBOL_IMAGE_FILENAME); + fs::write(&substrate_source, [0xcf, 0xfa, 0xed, 0xfe, 0x07, 0x00, 0x00, 0x01]).expect("write substrate source"); + fs::write(&symbol_source, b"\x7fELF\x02\x01-v1").expect("write symbol source"); + + assert_eq!(install_eac_substrate_from_sources(&home, &substrate_source, &symbol_source), Ok(true)); + let ms_dir = crate::platform::metalsharp_home_dir_for(&home); + assert!(eac_substrate_runtime_ready_for_ms_dir(&ms_dir)); + assert_eq!( + install_eac_substrate_from_sources(&home, &substrate_source, &symbol_source), + Ok(false), + "an unchanged update must not rewrite the durable EAC pair" + ); + + fs::write(&substrate_source, [0xfe, 0xed, 0xfa, 0xcf, 0x01, 0x00, 0x00, 0x07, 0x02]) + .expect("refresh substrate source"); + fs::write(&symbol_source, b"\x7fELF\x02\x01-v2").expect("refresh symbol source"); + assert_eq!(install_eac_substrate_from_sources(&home, &substrate_source, &symbol_source), Ok(true)); + assert_eq!( + fs::read(ms_dir.join("runtime").join(EAC_RUNTIME_SUBDIR).join(crate::anticheat::EAC_SYMBOL_IMAGE_FILENAME)) + .expect("read installed symbol image"), + b"\x7fELF\x02\x01-v2" + ); + + fs::write(&substrate_source, b"invalid substrate").expect("poison source"); + assert!(install_eac_substrate_from_sources(&home, &substrate_source, &symbol_source).is_err()); + assert!(eac_substrate_runtime_ready_for_ms_dir(&ms_dir), "a failed update must retain the previous pair"); + + let _ = fs::remove_dir_all(home); + let _ = fs::remove_dir_all(source_dir); + } + #[test] fn graphics_bundle_layout_matches_release_manifest() { let manifest = include_str!("../../../tools/bundles/asset-manifest.tsv"); diff --git a/app/src-rust/src/migrate.rs b/app/src-rust/src/migrate.rs index c7061bd45..1e450a690 100644 --- a/app/src-rust/src/migrate.rs +++ b/app/src-rust/src/migrate.rs @@ -7,7 +7,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; const MIGRATE_VERSION: &str = env!("CARGO_PKG_VERSION"); -const MIGRATE_SCHEMA_VERSION: u64 = 4; +const MIGRATE_SCHEMA_VERSION: u64 = 5; const GOG_PREFIX_BOTTLE_ID: &str = "gog-prefix"; const MIGRATION_PAYLOAD_DENY_NAMES: &[&str] = &[ "steamapps", @@ -344,7 +344,8 @@ pub fn needs_migration() -> serde_json::Value { fn runtime_needs_repair(home: &Path, setup_completed: bool) -> bool { let ms_dir = crate::platform::metalsharp_home_dir_for(&home); - if runtime_core_ready(&ms_dir) { + let eac_ready = !cfg!(target_os = "macos") || crate::installer::eac_substrate_runtime_ready_for_ms_dir(&ms_dir); + if runtime_core_ready(&ms_dir) && eac_ready { return false; } @@ -499,7 +500,8 @@ fn run_migration() { } let marker_requested = post_update_marker.as_ref().map(|marker| marker.needed).unwrap_or(false); - if runtime_core_ready(&ms_dir) && !marker_requested { + let eac_ready = !cfg!(target_os = "macos") || crate::installer::eac_substrate_runtime_ready_for_ms_dir(&ms_dir); + if runtime_core_ready(&ms_dir) && eac_ready && !marker_requested { update_migration_metadata(&ms_dir); let marker = post_update_marker_path(&ms_dir); let _ = fs::remove_file(&marker); @@ -679,6 +681,10 @@ fn verify_migration_ready(ms_dir: &Path, marker: Option<&PostUpdateMigrationMark return Err("runtime bundle is still incomplete after install".into()); } + if cfg!(target_os = "macos") && !crate::installer::eac_substrate_runtime_ready_for_ms_dir(ms_dir) { + return Err("EAC substrate runtime assets are still incomplete after install".into()); + } + Ok(()) } @@ -2759,10 +2765,42 @@ mod tests { write_runtime_core(&ms_dir); assert!(runtime_core_ready(&ms_dir)); + assert!(!cfg!(target_os = "macos") || crate::installer::eac_substrate_runtime_ready_for_ms_dir(&ms_dir)); assert!(!runtime_needs_repair(&home, true)); let _ = fs::remove_dir_all(home); } + #[cfg(target_os = "macos")] + #[test] + fn missing_eac_runtime_requests_migration_repair() { + let home = test_dir("missing-eac-runtime"); + let ms_dir = crate::platform::metalsharp_home_dir_for(&home); + write_runtime_core(&ms_dir); + fs::remove_file(ms_dir.join("runtime").join("eac").join(crate::anticheat::EAC_SYMBOL_IMAGE_FILENAME)) + .expect("remove EAC symbol image"); + + assert!(runtime_core_ready(&ms_dir)); + assert!(!crate::installer::eac_substrate_runtime_ready_for_ms_dir(&ms_dir)); + assert!(runtime_needs_repair(&home, true)); + let _ = fs::remove_dir_all(home); + } + + #[cfg(target_os = "macos")] + #[test] + fn migration_readiness_rejects_a_missing_eac_pair() { + let home = test_dir("verify-ready-eac"); + let ms_dir = crate::platform::metalsharp_home_dir_for(&home); + write_runtime_core(&ms_dir); + fs::remove_file(ms_dir.join("runtime").join("eac").join(crate::anticheat::EAC_SUBSTRATE_FILENAME)) + .expect("remove EAC substrate"); + + assert_eq!( + verify_migration_ready(&ms_dir, None).unwrap_err(), + "EAC substrate runtime assets are still incomplete after install" + ); + let _ = fs::remove_dir_all(home); + } + #[test] fn missing_stale_bundled_gptk_payload_does_not_request_migration_repair() { let home = test_dir("missing-stale-bundled-gptk"); @@ -2928,6 +2966,25 @@ mod tests { let _ = fs::remove_dir_all(home); } + #[test] + fn migration_preserves_eac_toggle_state_across_runtime_cleanup() { + let home = test_dir("preserve-eac-toggle"); + let ms_dir = crate::platform::metalsharp_home_dir_for(&home); + let toggle = ms_dir.join("sharp-library").join("eac").join("1245620.json"); + fs::create_dir_all(toggle.parent().unwrap()).expect("create EAC toggle dir"); + fs::write(&toggle, br#"{"appid":1245620,"enabled":true}"#).expect("write EAC toggle"); + + let (preserved, mut report) = preserve_user_data(&ms_dir); + fs::remove_dir_all(ms_dir.join("sharp-library")).expect("remove library during migration cleanup"); + restore_user_data(&ms_dir, &preserved, &mut report); + + assert_eq!( + fs::read_to_string(toggle).expect("read restored EAC toggle"), + r#"{"appid":1245620,"enabled":true}"# + ); + let _ = fs::remove_dir_all(home); + } + #[test] fn migration_preserves_gog_prefix_payload_when_present() { let home = test_dir("preserve-gog-prefix"); @@ -3521,6 +3578,16 @@ mod tests { fs::write(&wine, b"#!/bin/sh\n").expect("write wine"); write_host_runtime(ms_dir); + let eac_dir = ms_dir.join("runtime").join("eac"); + fs::create_dir_all(&eac_dir).expect("create EAC runtime"); + fs::write( + eac_dir.join(crate::anticheat::EAC_SUBSTRATE_FILENAME), + [0xcf, 0xfa, 0xed, 0xfe, 0x07, 0x00, 0x00, 0x01], + ) + .expect("write EAC substrate"); + fs::write(eac_dir.join(crate::anticheat::EAC_SYMBOL_IMAGE_FILENAME), b"\x7fELF\x02\x01") + .expect("write EAC symbol image"); + for path in [ runtime_wine.join("lib").join("wine").join("x86_64-unix").join(".keep"), runtime_wine.join("lib").join("wine").join("x86_64-windows").join("d3d9.dll"), diff --git a/app/updater/update.py b/app/updater/update.py index 54bb22cb2..3d282865e 100644 --- a/app/updater/update.py +++ b/app/updater/update.py @@ -236,6 +236,77 @@ def find_app_in_mount(mount_point): return None +def verify_app_bundle(app_path): + required = [ + os.path.join(app_path, "Contents", "Info.plist"), + os.path.join(app_path, "Contents", "MacOS", "MetalSharp"), + os.path.join(app_path, "Contents", "Resources", "runtime", "metalsharp-backend"), + os.path.join( + app_path, + "Contents", + "Resources", + "scripts", + "tools", + "native", + "metalsharp_eac_substrate.dylib", + ), + os.path.join( + app_path, + "Contents", + "Resources", + "scripts", + "tools", + "native", + "metalsharp_eac_libc.so.6", + ), + os.path.join(app_path, "Contents", "Resources", "scripts", "tools", "updater", "update.sh"), + ] + if not all(os.path.isfile(path) and os.path.getsize(path) > 0 for path in required): + return False + + substrate = os.path.join( + app_path, + "Contents", + "Resources", + "scripts", + "tools", + "native", + "metalsharp_eac_substrate.dylib", + ) + symbol_image = os.path.join( + app_path, + "Contents", + "Resources", + "scripts", + "tools", + "native", + "metalsharp_eac_libc.so.6", + ) + try: + with open(substrate, "rb") as handle: + substrate_magic = handle.read(4) + with open(symbol_image, "rb") as handle: + symbol_magic = handle.read(4) + except OSError: + return False + if substrate_magic not in { + b"\xcf\xfa\xed\xfe", + b"\xfe\xed\xfa\xcf", + b"\xca\xfe\xba\xbe", + } or symbol_magic != b"\x7fELF": + return False + try: + substrate_type = subprocess.run( + ["file", "-b", substrate], capture_output=True, text=True, timeout=10, check=False + ).stdout + symbol_type = subprocess.run( + ["file", "-b", symbol_image], capture_output=True, text=True, timeout=10, check=False + ).stdout + except (OSError, subprocess.SubprocessError): + return False + return "Mach-O" in substrate_type and "x86_64" in substrate_type and "ELF 64-bit" in symbol_type and "x86-64" in symbol_type + + def admin_rm_rf(path): r = run(["rm", "-rf", path]) if r.returncode == 0: @@ -245,6 +316,15 @@ def admin_rm_rf(path): return r.returncode == 0 +def admin_mv(src, dst): + r = run(["mv", src, dst]) + if r.returncode == 0: + return True + apple = 'do shell script "mv \\\"' + src + '\\\" \\\"' + dst + '\\\"" with administrator privileges' + r = run(["osascript", "-e", apple]) + return r.returncode == 0 + + def admin_cp_r(src, dst): r = run(["cp", "-R", src, dst]) if r.returncode == 0: @@ -366,16 +446,41 @@ def main(): new_version=tv, ) sys.exit(1) + if not verify_app_bundle(app_source): + detach_mount(mount_point) + write_status( + sf, + "error", + 50, + "MetalSharp.app is missing required runtime or EAC substrate assets.", + error="app_bundle_invalid", + new_version=tv, + ) + sys.exit(1) + + backup_app_path = APP_PATH + ".previous." + str(os.getpid()) + if not admin_rm_rf(backup_app_path): + detach_mount(mount_point) + write_status( + sf, + "error", + 55, + "Failed to remove a stale update backup.", + error="backup_cleanup_failed", + new_version=tv, + ) + sys.exit(1) - if os.path.exists(APP_PATH): - if not admin_rm_rf(APP_PATH): + had_previous_app = os.path.exists(APP_PATH) + if had_previous_app: + if not admin_mv(APP_PATH, backup_app_path): detach_mount(mount_point) write_status( sf, "error", 55, - "Failed to remove old app.", - error="remove_failed", + "Failed to stage the old app for rollback.", + error="backup_stage_failed", new_version=tv, ) sys.exit(1) @@ -384,6 +489,7 @@ def main(): sf, "installing", 60, "Copying new version to Applications...", new_version=tv ) if not admin_cp_r(app_source, APP_PATH): + admin_mv(backup_app_path, APP_PATH) if had_previous_app else None detach_mount(mount_point) write_status( sf, @@ -395,6 +501,30 @@ def main(): ) sys.exit(1) + if not verify_app_bundle(APP_PATH): + admin_rm_rf(APP_PATH) + admin_mv(backup_app_path, APP_PATH) if had_previous_app else None + write_status( + sf, + "error", + 75, + "Installed MetalSharp.app is missing required EAC substrate assets.", + error="installed_bundle_invalid", + new_version=tv, + ) + sys.exit(1) + + if not admin_rm_rf(backup_app_path): + write_status( + sf, + "error", + 78, + "New version installed, but the previous app backup could not be removed.", + error="backup_cleanup_failed", + new_version=tv, + ) + sys.exit(1) + write_status(sf, "installed", 80, "New version installed.", new_version=tv) ms_dir = os.path.expanduser("~/.metalsharp") diff --git a/app/updater/update.sh b/app/updater/update.sh index 7bc68a7d5..49e78672e 100755 --- a/app/updater/update.sh +++ b/app/updater/update.sh @@ -142,12 +142,23 @@ verify_app_bundle() { "$app_path/Contents/Info.plist" \ "$app_path/Contents/MacOS/MetalSharp" \ "$app_path/Contents/Resources/runtime/metalsharp-backend" \ + "$app_path/Contents/Resources/scripts/tools/native/metalsharp_eac_substrate.dylib" \ + "$app_path/Contents/Resources/scripts/tools/native/metalsharp_eac_libc.so.6" \ "$app_path/Contents/Resources/scripts/tools/updater/update.sh" do if [ ! -s "$required" ]; then return 1 fi done + if ! file "$app_path/Contents/Resources/scripts/tools/native/metalsharp_eac_substrate.dylib" | grep -q "Mach-O"; then + return 1 + fi + if ! file "$app_path/Contents/Resources/scripts/tools/native/metalsharp_eac_substrate.dylib" | grep -q "x86_64"; then + return 1 + fi + if ! file "$app_path/Contents/Resources/scripts/tools/native/metalsharp_eac_libc.so.6" | grep -q "ELF 64-bit.*x86-64"; then + return 1 + fi return 0 } diff --git a/docs/guides/library-and-logs-ui.md b/docs/guides/library-and-logs-ui.md index 4b6a4baae..4f9166d97 100644 --- a/docs/guides/library-and-logs-ui.md +++ b/docs/guides/library-and-logs-ui.md @@ -17,6 +17,22 @@ MetalSharp Wine 11.5 lane so the substrate is not sent through GPTK, another Wine build, or macOS Steam. Per-app substrate logs and module dumps are kept under `~/.metalsharp/logs/eac//`. +### EAC substrate installation lifecycle + +The DMG and split scripts/tools bundle must contain both native boundary +artifacts: `metalsharp_eac_substrate.dylib` (x86_64 Mach-O) and +`metalsharp_eac_libc.so.6` (x86-64 ELF). First-run setup installs the verified +pair into `~/.metalsharp/runtime/eac/` after the scripts/tools bundle. The pair +is staged and committed together, so a failed refresh cannot leave one new +artifact beside one old artifact. + +An app update verifies those files before replacing the installed app and then +sets the post-update migration marker. Migration schema 5 treats a missing or +invalid durable pair as runtime repair: it reinstalls the substrate, verifies +both files, and only then marks the migration complete. Per-game EAC toggle +JSON under `sharp-library` is preserved; the toggle remains opt-in and does +not launch a game during installation, update, or migration. + ## Sharp Library Use the **Library source** menu to switch between installed Windows applications and GOG games. The installer view keeps its primary actions focused on installing and refreshing applications; redistributable source controls are not shown in this header. diff --git a/tools/bundles/create-split-bundles.py b/tools/bundles/create-split-bundles.py index 58c7c8a7e..df5f8892a 100755 --- a/tools/bundles/create-split-bundles.py +++ b/tools/bundles/create-split-bundles.py @@ -63,6 +63,13 @@ def require_file(src: Path, description: str) -> None: raise FileNotFoundError(f"missing required {description}: {src}") +def require_file_type(src: Path, description: str, *needles: str) -> None: + require_file(src, description) + result = subprocess.run(["file", "-b", str(src)], capture_output=True, text=True, check=False) + if result.returncode != 0 or any(needle not in result.stdout for needle in needles): + raise RuntimeError(f"invalid {description}: {src} ({result.stdout.strip()})") + + def require_host_runtime(host_dir: Path) -> None: require_file(host_dir / "manifest.json", "host runtime manifest") require_file(host_dir / "HostRuntimeABI.h", "host runtime ABI header") @@ -270,9 +277,17 @@ def build_staging(tmp: Path) -> dict[str, Path]: for name in platform_shlibs + native_binaries: require_file(APP_DIR / "native" / name, f"native shim {name}") if sys.platform == "darwin": - require_file( + require_file_type( + APP_DIR / "native" / "metalsharp_eac_substrate.dylib", + "MetalSharp EAC x86_64 Mach-O substrate", + "Mach-O", + "x86_64", + ) + require_file_type( APP_DIR / "native" / "metalsharp_eac_libc.so.6", "MetalSharp EAC Linux symbol image", + "ELF 64-bit", + "x86-64", ) require_file( PROJECT_ROOT / "lib" / "metalsharp" / "x86_64-windows" / "metalsharp_ntdll_hook.dll", diff --git a/tools/bundles/verify-bundles.sh b/tools/bundles/verify-bundles.sh index 3d58fab2a..8fa537cc3 100755 --- a/tools/bundles/verify-bundles.sh +++ b/tools/bundles/verify-bundles.sh @@ -410,7 +410,36 @@ verify_scripts_tools_core() { verify_required_files "$1" "SCRIPTS TOOLS" \ scripts/tools/configs/mtsp-rules.toml \ scripts/tools/updater/update.py \ - scripts/tools/updater/update.sh + scripts/tools/updater/update.sh \ + scripts/tools/native/metalsharp_eac_substrate.dylib \ + scripts/tools/native/metalsharp_eac_libc.so.6 && + verify_eac_native_assets "$1" +} + +verify_eac_native_assets() { + local path="$1" + local tmp + tmp="$(mktemp -d "${TMPDIR:-/tmp}/metalsharp-eac-bundle.XXXXXX")" + if ! tar --use-compress-program=unzstd -xf "$path" -C "$tmp" \ + scripts/tools/native/metalsharp_eac_substrate.dylib \ + scripts/tools/native/metalsharp_eac_libc.so.6; then + echo "SCRIPTS TOOLS INVALID: $path is missing EAC native assets" >&2 + rm -rf "$tmp" + return 1 + fi + + local failed=0 + if ! file "$tmp/scripts/tools/native/metalsharp_eac_substrate.dylib" | grep -q "Mach-O" \ + || ! file "$tmp/scripts/tools/native/metalsharp_eac_substrate.dylib" | grep -q "x86_64"; then + echo "SCRIPTS TOOLS INVALID: EAC substrate is not an x86_64 Mach-O dylib" >&2 + failed=1 + fi + if ! file "$tmp/scripts/tools/native/metalsharp_eac_libc.so.6" | grep -q "ELF 64-bit.*x86-64"; then + echo "SCRIPTS TOOLS INVALID: EAC symbol image is not an x86-64 ELF image" >&2 + failed=1 + fi + rm -rf "$tmp" + return "$failed" } verify_steam_core() { diff --git a/tools/bundles/verify-native-shims.sh b/tools/bundles/verify-native-shims.sh index d810fe8eb..46f6134dd 100755 --- a/tools/bundles/verify-native-shims.sh +++ b/tools/bundles/verify-native-shims.sh @@ -38,6 +38,10 @@ for f in "${required_dylibs[@]}" "${required_bins[@]}"; do echo "ERROR: $path is not a Mach-O binary" errors=$((errors + 1)) fi + if [[ "$f" == "metalsharp_eac_substrate.dylib" ]] && ! file "$path" | grep -q "x86_64"; then + echo "ERROR: $path does not contain the x86_64 Wine/Rosetta slice" + errors=$((errors + 1)) + fi # Verify key symbols are exported if [[ "$f" == "xinput1_4.dylib" ]]; then for sym in XInputGetState XInputSetState XInputGetCapabilities; do diff --git a/tools/ci/verify-dmg-workflow.py b/tools/ci/verify-dmg-workflow.py index ed7c0ba3b..8b3a18bfe 100755 --- a/tools/ci/verify-dmg-workflow.py +++ b/tools/ci/verify-dmg-workflow.py @@ -53,6 +53,7 @@ def check_package_resources(assets: list[str]) -> None: required_pairs = { ("src-rust/target/release/metalsharp-backend", "runtime/metalsharp-backend"), ("native/host", "runtime/host"), + ("native", "scripts/tools/native"), ("updater", "scripts/tools/updater"), } required_pairs.update((f"bundles/{asset}", f"bundles/{asset}") for asset in assets) @@ -61,6 +62,19 @@ def check_package_resources(assets: list[str]) -> None: if missing: fail(f"app/package.json missing extraResources entries: {missing}") + native_entries = [ + entry + for entry in resources + if isinstance(entry, dict) and entry.get("from") == "native" and entry.get("to") == "scripts/tools/native" + ] + if len(native_entries) != 1: + fail("app/package.json must have exactly one native-to-scripts/tools/native resource entry") + native_filter = native_entries[0].get("filter", []) + if not isinstance(native_filter, list) or "**/*" not in native_filter: + fail("native extraResources must include the complete native tree so the EAC pair is packaged") + if any(isinstance(pattern, str) and pattern.startswith("!") and "metalsharp_eac_" in pattern for pattern in native_filter): + fail("native extraResources must not exclude the EAC substrate artifacts") + if build.get("afterPack") != "build/adhoc-deep-sign.cjs": fail("app/package.json must keep afterPack=build/adhoc-deep-sign.cjs") if build.get("afterSign") != "build/notarize.cjs": @@ -73,6 +87,8 @@ def check_dmg_verifier(assets: list[str]) -> None: "Contents/Resources", "runtime/metalsharp-backend", "runtime/host", + "scripts/tools/native/metalsharp_eac_substrate.dylib", + "scripts/tools/native/metalsharp_eac_libc.so.6", "scripts/tools/updater/update.py", "scripts/tools/updater/update.sh", "tools/bundles/verify-bundles.sh", diff --git a/tools/dmg/verify-dmg-runtime-assets.sh b/tools/dmg/verify-dmg-runtime-assets.sh index 38dcc9f8a..b7cd458ce 100755 --- a/tools/dmg/verify-dmg-runtime-assets.sh +++ b/tools/dmg/verify-dmg-runtime-assets.sh @@ -31,11 +31,17 @@ RESOURCES="$APP_DIR/Contents/Resources" BACKEND="$RESOURCES/runtime/metalsharp-backend" HOST="$RESOURCES/runtime/host" BUNDLES="$RESOURCES/bundles" +NATIVE="$RESOURCES/scripts/tools/native" +# The two explicit bundle paths below are the installed EAC substrate contract: +# Contents/Resources/scripts/tools/native/metalsharp_eac_substrate.dylib +# Contents/Resources/scripts/tools/native/metalsharp_eac_libc.so.6 for required in \ "$BACKEND" \ "$HOST/manifest.json" \ "$HOST/HostRuntimeABI.h" \ + "$NATIVE/metalsharp_eac_substrate.dylib" \ + "$NATIVE/metalsharp_eac_libc.so.6" \ "$RESOURCES/scripts/tools/updater/update.py" \ "$RESOURCES/scripts/tools/updater/update.sh" \ "$BUNDLES/metalsharp-electron.tar.zst" \ @@ -53,6 +59,19 @@ do fi done +if ! file "$NATIVE/metalsharp_eac_substrate.dylib" | grep -q "Mach-O"; then + echo "DMG EAC substrate is not a Mach-O dylib" >&2 + exit 1 +fi +if ! file "$NATIVE/metalsharp_eac_substrate.dylib" | grep -q "x86_64"; then + echo "DMG EAC substrate does not contain the x86_64 Wine/Rosetta slice" >&2 + exit 1 +fi +if ! file "$NATIVE/metalsharp_eac_libc.so.6" | grep -q "ELF 64-bit.*x86-64"; then + echo "DMG EAC symbol image is not an ELF64 x86-64 image" >&2 + exit 1 +fi + if [ ! -s "$HOST/libmetalsharp_host_runtime.dylib" ] \ && [ ! -s "$HOST/libmetalsharp_host_runtime.so" ] \ && [ ! -s "$HOST/metalsharp_host_runtime.dll" ]; then diff --git a/tools/package/prepare-native-placeholders.sh b/tools/package/prepare-native-placeholders.sh index ae44a78ff..3b5cf7482 100755 --- a/tools/package/prepare-native-placeholders.sh +++ b/tools/package/prepare-native-placeholders.sh @@ -53,6 +53,14 @@ MUST_BUILD_HOST_RUNTIME=( libmetalsharp_host_runtime ) +# The opt-in EAC card is only supported on the macOS/Rosetta lane. These two +# artifacts are part of the native package contract and must be built before a +# DMG is assembled; they are never replaced with placeholders. +MUST_BUILD_EAC=( + metalsharp_eac_substrate.dylib + metalsharp_eac_libc.so.6 +) + # Files that come from external sources or are otherwise optional. We keep the # legacy stub behavior so downstream tools that expect these to exist (even as # placeholders) continue to work. @@ -114,6 +122,15 @@ validate_must_build() { fi done + if [ "$PLATFORM_SHLIB_EXT" = "dylib" ]; then + for file in "${MUST_BUILD_EAC[@]}"; do + if [ ! -s "$NATIVE_DIR/$file" ]; then + echo "ERROR: required EAC substrate artifact missing or empty: $NATIVE_DIR/$file" >&2 + errors=$((errors + 1)) + fi + done + fi + return "$errors" } From 775e00dd2a7064630735b028a19a6809e18f2833 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Mon, 10 Aug 2026 10:31:13 -0600 Subject: [PATCH 7/9] ci: validate EAC assets on clean native builds --- .github/workflows/ci.yml | 2 +- .github/workflows/pr-ci.yml | 2 +- tools/bundles/verify-native-shims.sh | 25 +++++++++++++++++++------ tools/ci/verify-dmg-workflow.py | 3 +++ 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7deb292a..055d93bda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -209,7 +209,7 @@ jobs: run: cmake --build build-native --parallel $(sysctl -n hw.ncpu) - name: Native package contract - run: tools/bundles/verify-native-shims.sh app/native + run: tools/bundles/verify-native-shims.sh --eac-only app/native - name: Runtime tests run: ctest --test-dir build-native --output-on-failure -E '^(metal_device|dxbc|format_translation|phase17)$' diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 0f50c6c4d..b70408538 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -239,7 +239,7 @@ jobs: run: cmake --build build-native --parallel $(sysctl -n hw.ncpu) - name: Native package contract - run: tools/bundles/verify-native-shims.sh app/native + run: tools/bundles/verify-native-shims.sh --eac-only app/native - name: Runtime tests run: ctest --test-dir build-native --output-on-failure -E '^(metal_device|dxbc|format_translation|phase17)$' diff --git a/tools/bundles/verify-native-shims.sh b/tools/bundles/verify-native-shims.sh index 46f6134dd..7e47662b8 100755 --- a/tools/bundles/verify-native-shims.sh +++ b/tools/bundles/verify-native-shims.sh @@ -9,20 +9,33 @@ set -euo pipefail # # Usage: # tools/bundles/verify-native-shims.sh [NATIVE_DIR] +# tools/bundles/verify-native-shims.sh --eac-only [NATIVE_DIR] # # If NATIVE_DIR is not given, uses $METALSHARP_NATIVE_DIR or app/native. +EAC_ONLY=0 +if [ "${1:-}" = "--eac-only" ]; then + EAC_ONLY=1 + shift +fi NATIVE_DIR="${1:-${METALSHARP_NATIVE_DIR:-app/native}}" -required_dylibs=( - d3d11.dylib d3d12.dylib dxgi.dylib xaudio2_9.dylib xinput1_4.dylib opengl32.dylib - metalsharp_eac_substrate.dylib -) -required_bins=(metalsharp metalsharp_launcher) +if [ "$EAC_ONLY" -eq 1 ]; then + required_dylibs=(metalsharp_eac_substrate.dylib) +else + required_dylibs=( + d3d11.dylib d3d12.dylib dxgi.dylib xaudio2_9.dylib xinput1_4.dylib opengl32.dylib + metalsharp_eac_substrate.dylib + ) +fi +required_files=("${required_dylibs[@]}") +if [ "$EAC_ONLY" -eq 0 ]; then + required_files+=(metalsharp metalsharp_launcher) +fi required_elf=(metalsharp_eac_libc.so.6) errors=0 -for f in "${required_dylibs[@]}" "${required_bins[@]}"; do +for f in "${required_files[@]}"; do path="$NATIVE_DIR/$f" if [ ! -f "$path" ]; then echo "ERROR: missing native shim: $path" diff --git a/tools/ci/verify-dmg-workflow.py b/tools/ci/verify-dmg-workflow.py index 8b3a18bfe..cce95b03e 100755 --- a/tools/ci/verify-dmg-workflow.py +++ b/tools/ci/verify-dmg-workflow.py @@ -142,6 +142,9 @@ def check_workflows() -> None: for required in ["Shell CI", "Metal CI", "Vue CI", "Rust CI", "Electron CI", "C/C++/Obj-C CI", "DMG Workflow CI"]: if required not in main: fail(f"main CI missing validation job: {required}") + for workflow, label in [(pr, "PR"), (main, "main")]: + if "tools/bundles/verify-native-shims.sh --eac-only app/native" not in workflow: + fail(f"{label} CI must validate the generated EAC native pair after the CMake build") for forbidden in [ "Verify Developer SDK Bundle", "Build DMG", From 4ca8aa44ba1ad4ae82af66b92da5756b760acb3d Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Mon, 10 Aug 2026 11:17:19 -0600 Subject: [PATCH 8/9] feat: add EAC rules for protected Steam games --- app/src-rust/src/anticheat.rs | 15 ++ app/src-rust/src/mtsp/default_rules.rs | 1 + app/src-rust/src/mtsp/launcher.rs | 260 ------------------ app/src-rust/src/mtsp/recipe.rs | 64 ++++- app/src-rust/src/mtsp/rules.rs | 76 +++++- app/src/renderer/views/LibraryView.vue | 10 +- configs/mtsp-rules.toml | 348 +++++++++++++++++++++++++ docs/guides/library-and-logs-ui.md | 26 +- 8 files changed, 519 insertions(+), 281 deletions(-) diff --git a/app/src-rust/src/anticheat.rs b/app/src-rust/src/anticheat.rs index a02ed42a7..86c5d1680 100644 --- a/app/src-rust/src/anticheat.rs +++ b/app/src-rust/src/anticheat.rs @@ -1637,4 +1637,19 @@ mod tests { assert!(eac_launch_env_for_home(&home, 1).unwrap().is_empty()); let _ = fs::remove_dir_all(&home); } + + #[test] + fn eac_launch_env_stays_empty_when_a_card_is_explicitly_disabled() { + let home = std::env::temp_dir().join(format!("metalsharp-eac-card-off-{}", std::process::id())); + let _ = fs::remove_dir_all(&home); + let toggle = eac_toggle_path_for(&home, 1888160); + fs::create_dir_all(toggle.parent().expect("toggle parent")).expect("create toggle directory"); + fs::write(&toggle, r#"{"appid":1888160,"enabled":false}"#).expect("write disabled toggle"); + + assert!(!eac_enabled_for(&home, 1888160)); + assert!(eac_launch_env_for_home(&home, 1888160).unwrap().is_empty()); + assert!(!crate::platform::metalsharp_home_dir_for(&home).join("logs").join("eac").exists()); + + let _ = fs::remove_dir_all(&home); + } } diff --git a/app/src-rust/src/mtsp/default_rules.rs b/app/src-rust/src/mtsp/default_rules.rs index 873c98e3d..c601c8b59 100644 --- a/app/src-rust/src/mtsp/default_rules.rs +++ b/app/src-rust/src/mtsp/default_rules.rs @@ -78,6 +78,7 @@ fn catalog_entry(appid: u32, recipe: &GameRecipe) -> Value { "default_pipeline_name": node.name, "custom_exe_fix": has_custom_exe_fix(recipe), "exe_names": recipe.exe_names, + "eac_exe_names": recipe.eac_exe_names, "offline_capable": recipe.offline_capable, "components": recipe.components, "check_dlls": recipe.check_dlls, diff --git a/app/src-rust/src/mtsp/launcher.rs b/app/src-rust/src/mtsp/launcher.rs index 4e3f000d2..f0a05c6b5 100644 --- a/app/src-rust/src/mtsp/launcher.rs +++ b/app/src-rust/src/mtsp/launcher.rs @@ -6,7 +6,6 @@ use std::net::TcpStream; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use walkdir::WalkDir; const DEFAULT_BRIDGE_PORT: u16 = 18733; const FNA_CARBON_SHIM: &str = "libCarbon.dylib"; @@ -535,7 +534,6 @@ pub fn prepare_steam_pipeline_env( } let home = dirs::home_dir().ok_or("no home dir")?; - prepare_start_protected_game_for_pipeline(appid, pipeline_id); let recipe = super::recipe::build_launch_recipe(appid, node)?; validate_recipe_runtime(&recipe)?; if node.backend == "dxmt" { @@ -543,7 +541,6 @@ pub fn prepare_steam_pipeline_env( } if let Some(game_dir) = recipe.game_dir.as_ref() { prepare_steam_api_for_game_dir(&home, game_dir, appid, pipeline_id); - cleanup_legacy_eac_toggle_artifacts(game_dir); cleanup_legacy_injections(game_dir)?; if matches!(pipeline_id, PipelineId::M12 | PipelineId::M13) { let prefix = crate::platform::metalsharp_home_dir_for(&home).join("prefix-steam"); @@ -1740,8 +1737,6 @@ fn launch_d3dmetal_gptk_with_context( let exe_dir = launch_working_dir(game_dir, exe_path); let exe_name = exe_path.file_name().unwrap_or_default().to_string_lossy().to_string(); - restore_start_protected_game_bypass(appid, game_dir); - let prefix = gptk_prefix; let prefix_str = prefix.to_string_lossy().to_string(); let offline_mode = extra_env.iter().any(|(key, value)| key == "METALSHARP_OFFLINE_MODE" && value == "1"); @@ -1856,7 +1851,6 @@ fn launch_dxmt_metal_with_context( } sanitize_metalsharp_wine_wrapper_env()?; - prepare_start_protected_game_for_pipeline(appid, node.id); let recipe = super::recipe::build_launch_recipe(appid, node)?; let game_dir = recipe.game_dir.as_ref().ok_or("game dir not found")?; let exe_path = recipe.exe_path.as_ref().ok_or("game exe not found")?; @@ -2729,30 +2723,6 @@ fn cleanup_m12_legacy_hook_artifacts(game_dir: &Path, prefix: &Path) { } } -fn cleanup_legacy_eac_toggle_artifacts(game_dir: &Path) { - let targets = [ - game_dir.to_path_buf(), - game_dir.join("Game"), - game_dir.join("bin"), - game_dir.join("Binaries").join("Win64"), - game_dir.join("win64"), - ]; - - for target in targets { - if !target.exists() { - continue; - } - let config = target.join("anti_cheat_toggler_config.ini"); - let mod_list = target.join("anti_cheat_toggler_mod_list.txt"); - let has_legacy_toggle_marker = config.exists() || mod_list.exists(); - let _ = std::fs::remove_file(&config); - let _ = std::fs::remove_file(&mod_list); - if has_legacy_toggle_marker { - let _ = std::fs::remove_file(target.join("_winhttp.dll")); - } - } -} - fn build_winedllpath(ms_root: &PathBuf, dirs: &[&str]) -> String { dirs.iter().map(|d| ms_root.join(d).to_string_lossy().to_string()).collect::>().join(":") } @@ -5765,112 +5735,6 @@ fn deploy_real_steam_component(wine_steam_dir: &Path, targets: &[PathBuf], filen } } -fn start_protected_game_real_exe_names(appid: u32) -> &'static [&'static str] { - match appid { - 1245620 => &["eldenring.exe"], - 1888160 => &["armoredcore6.exe"], - _ => &[], - } -} - -fn prepare_start_protected_game_for_pipeline(appid: u32, pipeline_id: PipelineId) { - if !matches!(pipeline_id, PipelineId::Dxmt | PipelineId::M12) { - return; - } - let Some(game_dir) = crate::setup::resolve_windows_game_dir(appid) else { - return; - }; - restore_start_protected_game_bypass(appid, &game_dir); -} - -fn restore_start_protected_game_bypass(appid: u32, game_dir: &Path) { - let spg = match super::recipe::find_case_insensitive(game_dir, "start_protected_game.exe") { - Some(path) => path, - None => { - let Some(old) = super::recipe::find_case_insensitive(game_dir, "start_protected_game.old") else { - return; - }; - let restored = old.with_file_name("start_protected_game.exe"); - if let Err(err) = std::fs::rename(&old, &restored) { - eprintln!( - "start_protected_game: failed to restore {} to {}: {}", - old.display(), - restored.display(), - err - ); - } - return; - }, - }; - let spg_dir = match spg.parent() { - Some(dir) => dir, - None => return, - }; - let old = spg_dir.join("start_protected_game.old"); - if !old.is_file() { - return; - } - - let real_exe = match find_start_protected_real_exe(appid, game_dir, spg_dir) { - Some(path) => path, - None => return, - }; - if !files_match(&spg, &real_exe) { - return; - } - - if let Err(err) = std::fs::copy(&old, &spg) { - eprintln!("start_protected_game: failed to restore {} to {}: {}", old.display(), spg.display(), err); - return; - } - let _ = std::fs::remove_file(&old); -} - -fn find_start_protected_real_exe(appid: u32, game_dir: &Path, spg_dir: &Path) -> Option { - for real_exe_name in start_protected_game_real_exe_names(appid) { - if let Some(path) = super::recipe::find_case_insensitive(game_dir, real_exe_name) { - return Some(path); - } - } - - let candidates = WalkDir::new(spg_dir) - .max_depth(1) - .into_iter() - .flatten() - .filter_map(|entry| { - let path = entry.path(); - if !path.is_file() { - return None; - } - let name = path.file_name()?.to_string_lossy().to_string(); - if is_start_protected_real_exe_candidate(&name) { - Some(path.to_path_buf()) - } else { - None - } - }) - .collect::>(); - - if candidates.len() == 1 { - candidates.into_iter().next() - } else { - None - } -} - -fn is_start_protected_real_exe_candidate(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - lower.ends_with(".exe") - && lower != "start_protected_game.exe" - && !lower.contains("easyanticheat") - && !lower.contains("setup") - && !lower.contains("redist") - && !lower.contains("installer") - && !lower.contains("uninstall") - && !lower.contains("crash") - && !lower.contains("launcher") -} - fn generate_steam_interfaces(game_dir: &Path) { let steam_settings = game_dir.join("steam_settings"); let interfaces_file = steam_settings.join("steam_interfaces.txt"); @@ -7853,130 +7717,6 @@ export WINEDEBUG="${WINEDEBUG:--all}" assert!(text.contains("steam_identity_mode=wine_steam_background")); } - #[test] - fn start_protected_game_bypass_restores_original_launcher() { - let home = test_dir("spg-restore"); - let game_dir = home.join("Game"); - std::fs::create_dir_all(&game_dir).expect("create game dir"); - std::fs::write(game_dir.join("start_protected_game.old"), b"PROTECTED_STUB").expect("write old stub"); - std::fs::write(game_dir.join("start_protected_game.exe"), b"REAL_GAME").expect("write bypass copy"); - std::fs::write(game_dir.join("eldenring.exe"), b"REAL_GAME").expect("write real exe"); - - restore_start_protected_game_bypass(1245620, &home); - - assert!(!game_dir.join("start_protected_game.old").exists()); - assert_eq!(std::fs::read(game_dir.join("start_protected_game.exe")).unwrap(), b"PROTECTED_STUB"); - - let _ = std::fs::remove_dir_all(home); - } - - #[test] - fn start_protected_game_bypass_preserves_unrecognized_current_launcher() { - let home = test_dir("spg-skip-old"); - let game_dir = home.join("Game"); - std::fs::create_dir_all(&game_dir).expect("create game dir"); - std::fs::write(game_dir.join("start_protected_game.old"), b"PREVIOUS_STUB").expect("write old"); - std::fs::write(game_dir.join("start_protected_game.exe"), b"REAL_GAME_ALREADY").expect("write current"); - std::fs::write(game_dir.join("eldenring.exe"), b"REAL_GAME").expect("write real exe"); - - restore_start_protected_game_bypass(1245620, &home); - - assert_eq!(std::fs::read(game_dir.join("start_protected_game.exe")).unwrap(), b"REAL_GAME_ALREADY"); - assert_eq!(std::fs::read(game_dir.join("start_protected_game.old")).unwrap(), b"PREVIOUS_STUB"); - - let _ = std::fs::remove_dir_all(home); - } - - #[test] - fn start_protected_game_bypass_restores_armored_core_vi_launcher() { - let home = test_dir("spg-ac6"); - let game_dir = home.join("Game"); - std::fs::create_dir_all(&game_dir).expect("create game dir"); - std::fs::write(game_dir.join("start_protected_game.old"), b"PROTECTED_STUB").expect("write old stub"); - std::fs::write(game_dir.join("start_protected_game.exe"), b"AC6_REAL_GAME").expect("write bypass copy"); - std::fs::write(game_dir.join("armoredcore6.exe"), b"AC6_REAL_GAME").expect("write real exe"); - - restore_start_protected_game_bypass(1888160, &home); - - assert!(!game_dir.join("start_protected_game.old").exists()); - assert_eq!(std::fs::read(game_dir.join("start_protected_game.exe")).unwrap(), b"PROTECTED_STUB"); - - let _ = std::fs::remove_dir_all(home); - } - - #[test] - fn start_protected_game_bypass_can_restore_with_single_sibling_real_exe() { - let home = test_dir("spg-generic"); - let game_dir = home.join("Game"); - std::fs::create_dir_all(&game_dir).expect("create game dir"); - std::fs::write(game_dir.join("start_protected_game.old"), b"PROTECTED_STUB").expect("write old stub"); - std::fs::write(game_dir.join("start_protected_game.exe"), b"REAL_GAME").expect("write bypass copy"); - std::fs::write(game_dir.join("realgame.exe"), b"REAL_GAME").expect("write real exe"); - - restore_start_protected_game_bypass(99999, &home); - - assert!(!game_dir.join("start_protected_game.old").exists()); - assert_eq!(std::fs::read(game_dir.join("start_protected_game.exe")).unwrap(), b"PROTECTED_STUB"); - - let _ = std::fs::remove_dir_all(home); - } - - #[test] - fn start_protected_game_bypass_skips_ambiguous_generic_siblings() { - let home = test_dir("spg-ambiguous"); - let game_dir = home.join("Game"); - std::fs::create_dir_all(&game_dir).expect("create game dir"); - std::fs::write(game_dir.join("start_protected_game.exe"), b"PROTECTED_STUB").expect("write stub"); - std::fs::write(game_dir.join("first.exe"), b"FIRST").expect("write first exe"); - std::fs::write(game_dir.join("second.exe"), b"SECOND").expect("write second exe"); - - restore_start_protected_game_bypass(99999, &home); - - assert_eq!(std::fs::read(game_dir.join("start_protected_game.exe")).unwrap(), b"PROTECTED_STUB"); - assert!(!game_dir.join("start_protected_game.old").exists()); - - let _ = std::fs::remove_dir_all(home); - } - - #[test] - fn start_protected_game_bypass_skips_unknown_appid() { - let home = test_dir("spg-skip"); - let game_dir = home.join("Game"); - std::fs::create_dir_all(&game_dir).expect("create game dir"); - std::fs::write(game_dir.join("start_protected_game.exe"), b"PROTECTED_STUB").expect("write stub"); - std::fs::write(game_dir.join("first.exe"), b"FIRST").expect("write first exe"); - std::fs::write(game_dir.join("second.exe"), b"SECOND").expect("write second exe"); - - restore_start_protected_game_bypass(99999, &home); - - assert_eq!(std::fs::read(game_dir.join("start_protected_game.exe")).unwrap(), b"PROTECTED_STUB"); - assert!(!game_dir.join("start_protected_game.old").exists()); - - let _ = std::fs::remove_dir_all(home); - } - - #[test] - fn legacy_eac_toggle_cleanup_removes_marked_files_only() { - let home = test_dir("legacy-eac-toggle-cleanup"); - let marked = home.join("Binaries").join("Win64"); - let unmarked = home.join("bin"); - std::fs::create_dir_all(&marked).expect("create marked dir"); - std::fs::create_dir_all(&unmarked).expect("create unmarked dir"); - std::fs::write(marked.join("_winhttp.dll"), b"OLD_TOGGLE").expect("write toggle dll"); - std::fs::write(marked.join("anti_cheat_toggler_config.ini"), b"config").expect("write toggle config"); - std::fs::write(marked.join("anti_cheat_toggler_mod_list.txt"), b"mods").expect("write toggle mods"); - std::fs::write(unmarked.join("_winhttp.dll"), b"OTHER").expect("write unrelated dll"); - - cleanup_legacy_eac_toggle_artifacts(&home); - - assert!(!marked.join("_winhttp.dll").exists()); - assert!(!marked.join("anti_cheat_toggler_config.ini").exists()); - assert!(!marked.join("anti_cheat_toggler_mod_list.txt").exists()); - assert_eq!(std::fs::read(unmarked.join("_winhttp.dll")).unwrap(), b"OTHER"); - - let _ = std::fs::remove_dir_all(home); - } - fn test_dir(name: &str) -> PathBuf { let mut dir = std::env::temp_dir(); dir.push(format!("metalsharp-launcher-{}-{}-{}", name, std::process::id(), unique_suffix())); diff --git a/app/src-rust/src/mtsp/recipe.rs b/app/src-rust/src/mtsp/recipe.rs index 25d42d80d..40faf79e8 100644 --- a/app/src-rust/src/mtsp/recipe.rs +++ b/app/src-rust/src/mtsp/recipe.rs @@ -104,7 +104,7 @@ pub fn build_launch_recipe(appid: u32, node: &PipelineNode) -> Result { let dir = game_dir.as_ref().ok_or_else(|| format!("game directory not found for appid {}", appid))?; - Some(resolve_game_exe_for_pipeline(appid, dir, Some(node.id))?) + Some(resolve_launch_exe_for_pipeline(appid, dir, Some(node.id))?) }, _ => None, }; @@ -268,7 +268,7 @@ pub fn build_custom_launch_recipe( | PipelineId::M32 | PipelineId::WineBare => Some(match exe_path { Some(path) => path.to_path_buf(), - None => resolve_game_exe_for_pipeline(appid, game_dir, Some(node.id))?, + None => resolve_launch_exe_for_pipeline(appid, game_dir, Some(node.id))?, }), _ => None, }; @@ -311,9 +311,45 @@ fn resolve_game_exe_for_pipeline( game_dir: &Path, pipeline: Option, ) -> Result> { + resolve_game_exe_for_pipeline_with_eac(appid, game_dir, pipeline, false) +} + +fn resolve_launch_exe_for_pipeline( + appid: u32, + game_dir: &Path, + pipeline: Option, +) -> Result> { + resolve_game_exe_for_pipeline_with_eac(appid, game_dir, pipeline, crate::anticheat::eac_enabled(appid)) +} + +fn resolve_game_exe_for_pipeline_with_eac( + appid: u32, + game_dir: &Path, + pipeline: Option, + eac_enabled: bool, +) -> Result> { + // The EAC toggle changes only which existing executable is launched. It + // never renames, copies, patches, or replaces a game executable. Prefer + // the rule's protected launcher while enabled; when disabled this branch + // is skipped and the normal game executable resolution below is used. + if eac_enabled { + if let Some(recipe) = super::rules::get_game_recipe(appid) { + for preferred in &recipe.eac_exe_names { + if let Some(path) = find_case_insensitive(game_dir, preferred) { + return Ok(path); + } + } + } + // Steam's conventional EAC launcher name is useful for newly added + // games before a per-game executable rule is published. + if let Some(path) = find_case_insensitive(game_dir, "start_protected_game.exe") { + return Ok(path); + } + } + // Subnautica 2's M12 route must invoke the real game executable directly. - // Do not let a prepared start_protected_game.exe shim or Steam launch args - // take precedence over Subnautica2.exe for this path. + // Do not let a protected launcher take precedence over Subnautica2.exe + // when the EAC toggle is off for this path. if appid == 1962700 && matches!(pipeline, Some(PipelineId::M12)) { if let Some(path) = find_case_insensitive(game_dir, "Subnautica2.exe") { return Ok(path); @@ -737,8 +773,8 @@ fn preferred_exe_names(appid: u32) -> &'static [&'static str] { 1196590 => &["re8.exe"], 2358720 => &["b1-Win64-Shipping.exe", "b1.exe"], 305620 => &["tld.exe"], - 1245620 => &["start_protected_game.exe", "eldenring.exe"], - 1888160 => &["start_protected_game.exe", "armoredcore6.exe"], + 1245620 => &["eldenring.exe"], + 1888160 => &["armoredcore6.exe"], 1962700 => &["Subnautica2.exe"], 220 => &["hl2.exe"], 440 => &["tf/win32/tf.exe", "tf.exe"], @@ -1155,7 +1191,7 @@ mod tests { } #[test] - fn m12_selects_real_protected_game_exe() { + fn m12_uses_direct_game_exe_when_eac_is_off() { let dir = test_dir("spg-prepared"); let game_dir = dir.join("Game"); std::fs::create_dir_all(&game_dir).expect("create game dir"); @@ -1167,21 +1203,29 @@ mod tests { resolve_game_exe_for_pipeline(1245620, &dir, Some(PipelineId::M12)).expect("select real game exe"); assert_eq!(selected.file_name().and_then(|name| name.to_str()), Some("eldenring.exe")); + assert_eq!(std::fs::read(game_dir.join("start_protected_game.exe")).unwrap(), b"REAL_GAME_COPY"); + assert_eq!(std::fs::read(game_dir.join("start_protected_game.old")).unwrap(), b"PROTECTED_STUB"); let _ = std::fs::remove_dir_all(dir); } #[test] - fn armored_core_vi_prefers_start_protected_game_exe() { + fn armored_core_vi_eac_selection_is_opt_in_and_does_not_mutate_executables() { let dir = test_dir("ac6-preferred"); let game_dir = dir.join("Game"); std::fs::create_dir_all(&game_dir).expect("create game dir"); std::fs::write(game_dir.join("start_protected_game.exe"), b"PROTECTED_STUB").expect("write protected exe"); std::fs::write(game_dir.join("armoredcore6.exe"), b"REAL_GAME").expect("write real exe"); - let selected = resolve_game_exe(1888160, &dir).expect("select AC6 protected exe"); + let selected = resolve_game_exe(1888160, &dir).expect("select AC6 game exe with EAC off"); + + assert_eq!(selected.file_name().and_then(|name| name.to_str()), Some("armoredcore6.exe")); - assert_eq!(selected.file_name().and_then(|name| name.to_str()), Some("start_protected_game.exe")); + let protected = resolve_game_exe_for_pipeline_with_eac(1888160, &dir, Some(PipelineId::M12), true) + .expect("select AC6 protected launcher"); + assert_eq!(protected.file_name().and_then(|name| name.to_str()), Some("start_protected_game.exe")); + assert_eq!(std::fs::read(game_dir.join("start_protected_game.exe")).unwrap(), b"PROTECTED_STUB"); + assert!(!game_dir.join("start_protected_game.old").exists()); let _ = std::fs::remove_dir_all(dir); } diff --git a/app/src-rust/src/mtsp/rules.rs b/app/src-rust/src/mtsp/rules.rs index ebbc5b0de..7adedaa2e 100644 --- a/app/src-rust/src/mtsp/rules.rs +++ b/app/src-rust/src/mtsp/rules.rs @@ -16,6 +16,11 @@ pub struct GameRecipe { pub check_dlls: Vec, pub offline_capable: bool, pub exe_names: Vec, + /// Executable(s) that must be selected while the opt-in EAC substrate is + /// enabled. These are kept separate from `exe_names`: when EAC is off we + /// launch the game binary directly, but an EAC launcher is required for + /// the protected path. + pub eac_exe_names: Vec, } impl Default for GameRecipe { @@ -28,6 +33,7 @@ impl Default for GameRecipe { check_dlls: Vec::new(), offline_capable: false, exe_names: Vec::new(), + eac_exe_names: Vec::new(), } } } @@ -139,7 +145,15 @@ fn parse_rules_full(toml_str: &str) -> (HashMap, HashMap("GET", `/eac/status?appid=${game.appid}`); const eacEnabled = eacStatus?.ok === true && (eacStatus.eac_enabled === true || eacStatus.enabled === true); - const selectedLaunchMethod = effectiveLaunchMethod(game, launchMethod, eacEnabled); + const selectedLaunchMethod = effectiveLaunchMethod(launchMethod, eacEnabled); if (isMacSteamLaunch(selectedLaunchMethod) && wineSteamRunning.value) { if (!confirm(`Stop Wine Steam and launch ${game.name} through MacOS Steam?`)) return; const stopResult = await api<{ ok: boolean; running?: boolean; error?: string }>("POST", "/steam/stop"); diff --git a/configs/mtsp-rules.toml b/configs/mtsp-rules.toml index 0bafcb0ad..bd5c77646 100644 --- a/configs/mtsp-rules.toml +++ b/configs/mtsp-rules.toml @@ -146,6 +146,8 @@ check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] [overrides.1245620] pipeline = "m12" name = "ELDEN RING" +exe_names = ["eldenring.exe"] +eac_exe_names = ["start_protected_game.exe"] offline_capable = true [overrides.1245620.dependencies] @@ -309,6 +311,8 @@ check_dlls = ["d3d12.dll", "d3d12core.dll", "dxgi.dll", "d3d11.dll"] [overrides.1888160] pipeline = "m12" name = "ARMORED CORE VI FIRES OF RUBICON" +exe_names = ["armoredcore6.exe"] +eac_exe_names = ["Game/start_protected_game.exe"] offline_capable = true [overrides.1888160.dependencies] @@ -352,6 +356,8 @@ check_dlls = ["d3d12.dll", "d3d12core.dll", "dxgi.dll", "d3d11.dll"] [overrides.1172470] pipeline = "m11" name = "Apex Legends" +exe_names = ["r5apex.exe"] +eac_exe_names = ["start_protected_game.exe"] [overrides.1172470.diagnostics] check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] @@ -561,6 +567,8 @@ check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] [overrides.252950] pipeline = "m11" name = "Rocket League" +exe_names = ["Binaries/Win64/RocketLeague.exe"] +eac_exe_names = ["Binaries/Win64/RocketLeague_EAC.exe"] [overrides.252950.diagnostics] check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] @@ -573,6 +581,8 @@ check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] [overrides.252490] pipeline = "m11" name = "Rust" +exe_names = ["rust.exe"] +eac_exe_names = ["rust.exe"] [overrides.252490.diagnostics] check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] @@ -2131,6 +2141,8 @@ check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] [overrides.251570] pipeline = "m11" name = "7 Days to Die" +exe_names = ["7dLauncher.exe"] +eac_exe_names = ["7dLauncher.exe"] [overrides.251570.dependencies] components = ["vcrun2019", "directx_jun2010"] @@ -2781,6 +2793,8 @@ check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] [overrides.393380] pipeline = "m11" name = "Squad" +exe_names = ["Squad.exe"] +eac_exe_names = ["Squad.exe"] [overrides.393380.dependencies] components = ["vcrun2019", "directx_jun2010"] @@ -3331,6 +3345,8 @@ check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] [overrides.594650] pipeline = "m11" name = "Hunt: Showdown 1896" +exe_names = ["hunt.exe"] +eac_exe_names = ["hunt.exe"] [overrides.594650.dependencies] components = ["vcrun2019", "directx_jun2010"] @@ -4871,6 +4887,8 @@ check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] [overrides.1240440] pipeline = "m11" name = "Halo Infinite" +exe_names = ["HaloInfinite.exe"] +eac_exe_names = ["HaloInfinite.exe"] [overrides.1240440.dependencies] components = ["vcrun2019", "directx_jun2010"] @@ -5381,6 +5399,8 @@ check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] [overrides.1517290] pipeline = "m11" name = "Battlefield 2042" +exe_names = ["bf2042.exe"] +eac_exe_names = ["BF2042_launcher.exe"] [overrides.1517290.dependencies] components = ["vcrun2019", "directx_jun2010"] @@ -6800,3 +6820,331 @@ name = "AMID EVIL" [overrides.673130.diagnostics] check_dlls = ["d3d12.dll", "d3d12core.dll", "dxgi.dll", "d3d11.dll"] + +# Opt-in EAC coverage. These are default M11 rules; the EAC launcher list is +# used only while the per-game EAC toggle is enabled. With the toggle off the +# normal executable list is used and no EAC substrate environment is added. + +[overrides.1304930] +pipeline = "m11" +name = "The Outlast Trials" +exe_names = ["TOTClient.exe"] +eac_exe_names = ["start_protected_game.exe"] + +[overrides.1304930.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.1304930.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.976730] +pipeline = "m11" +name = "Halo: The Master Chief Collection" +exe_names = ["mcc/binaries/win64/mcc-win64-shipping.exe"] +eac_exe_names = ["mcclauncher.exe"] + +[overrides.976730.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.976730.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.1172620] +pipeline = "m11" +name = "Sea of Thieves" +exe_names = ["SeaOfThieves.exe"] +eac_exe_names = ["SeaOfThieves.exe"] + +[overrides.1172620.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.1172620.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.555160] +pipeline = "m11" +name = "Pavlov VR" +exe_names = ["Pavlov.exe"] +eac_exe_names = ["Pavlov.exe"] + +[overrides.555160.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.555160.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.552500] +pipeline = "m11" +name = "Warhammer: Vermintide 2" +exe_names = ["binaries/vermintide2.exe"] +eac_exe_names = ["start_protected_game.exe"] + +[overrides.552500.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.552500.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.447040] +pipeline = "m11" +name = "Watch_Dogs 2" +exe_names = ["bin/WatchDogs2.exe"] +eac_exe_names = ["bin/WatchDogs2.exe"] + +[overrides.447040.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.447040.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.1097150] +pipeline = "m11" +name = "Fall Guys" +exe_names = ["FallGuys_client.exe"] +eac_exe_names = ["FallGuys_client.exe"] + +[overrides.1097150.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.1097150.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.438740] +pipeline = "m11" +name = "Friday the 13th: The Game" +exe_names = ["EAC_Launcher.exe"] +eac_exe_names = ["EAC_Launcher.exe"] + +[overrides.438740.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.438740.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.438100] +pipeline = "m11" +name = "VRChat" +exe_names = ["launch.exe"] +eac_exe_names = ["launch.exe"] + +[overrides.438100.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.438100.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.872200] +pipeline = "m11" +name = "Rogue Company" +exe_names = ["RogueCompany.exe"] +eac_exe_names = ["RogueCompany.exe"] + +[overrides.872200.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.872200.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.1121710] +pipeline = "m11" +name = "Total Lockdown" +exe_names = ["start.exe"] +eac_exe_names = ["start.exe"] + +[overrides.1121710.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.1121710.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.1599340] +pipeline = "m11" +name = "Lost Ark" +exe_names = ["Binaries/Win64/start_protected_game.exe"] +eac_exe_names = ["Binaries/Win64/start_protected_game.exe"] + +[overrides.1599340.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.1599340.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.1097840] +pipeline = "m11" +name = "Gears 5" +exe_names = ["GearGame/Binaries/Steam/Gears5_EAC.exe"] +eac_exe_names = ["GearGame/Binaries/Steam/Gears5_EAC.exe"] + +[overrides.1097840.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.1097840.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.304390] +pipeline = "m11" +name = "FOR HONOR" +exe_names = ["forhonor.exe"] +eac_exe_names = ["forhonor.exe"] + +[overrides.304390.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.304390.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.2138720] +pipeline = "m11" +name = "REMATCH" +exe_names = ["start_protected_game.exe"] +eac_exe_names = ["start_protected_game.exe"] + +[overrides.2138720.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.2138720.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.1180380] +pipeline = "m11" +name = "Stay Out" +exe_names = ["game/x64/start_protected_game.exe"] +eac_exe_names = ["game/x64/start_protected_game.exe"] + +[overrides.1180380.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.1180380.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.924970] +pipeline = "m11" +name = "Back 4 Blood" +exe_names = ["Gobi/Binaries/Win64/Gobi-Win64-Shipping.exe"] +eac_exe_names = ["Gobi/Binaries/Win64/Gobi-Win64-Shipping.exe"] + +[overrides.924970.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.924970.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.1501750] +pipeline = "m11" +name = "Lords of the Fallen" +exe_names = ["LOTF2.exe"] +eac_exe_names = ["LOTF2.exe"] + +[overrides.1501750.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.1501750.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.2429640] +pipeline = "m11" +name = "Throne and Liberty" +exe_names = ["TL/Binaries/Win64/TL-Win64-Shipping.exe"] +eac_exe_names = ["start_protected_game.exe"] + +[overrides.2429640.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.2429640.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.1222730] +pipeline = "m11" +name = "STAR WARS: Squadrons" +exe_names = ["starwarssquadrons.exe"] +eac_exe_names = ["starwarssquadrons_launcher.exe"] + +[overrides.1222730.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.1222730.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.3472040] +pipeline = "m11" +name = "NBA 2K26" +exe_names = ["NBA2K26.exe"] +eac_exe_names = ["start_protected_game.exe"] + +[overrides.3472040.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.3472040.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.519190] +pipeline = "m11" +name = "Next Day: Survival" +exe_names = ["nextday.exe"] +eac_exe_names = ["nextday.exe"] + +[overrides.519190.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.519190.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.315210] +pipeline = "m11" +name = "Suicide Squad: Kill the Justice League" +exe_names = ["SuicideSquad.exe"] +eac_exe_names = ["start_protected_game.exe"] + +[overrides.315210.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.315210.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.4088120] +pipeline = "m11" +name = "SCP: ReEnter" +exe_names = ["SCPReEnter.exe"] +eac_exe_names = ["SCPReEnter_Launcher.exe"] + +[overrides.4088120.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.4088120.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.1430190] +pipeline = "m11" +name = "Killing Floor 3" +exe_names = ["Nightfall/Binaries/Win64/NightfallClient-Win64-Shipping.exe"] +eac_exe_names = ["start_protected_game.exe"] + +[overrides.1430190.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.1430190.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.1808500] +pipeline = "m11" +name = "ARC Raiders" +exe_names = ["PioneerGame.exe"] +eac_exe_names = ["PioneerGame.exe"] + +[overrides.1808500.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.1808500.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + +[overrides.1818750] +pipeline = "m11" +name = "MultiVersus" +exe_names = ["MultiVersus.exe"] +eac_exe_names = ["MultiVersus.exe"] + +[overrides.1818750.dependencies] +components = ["vcrun2019", "directx_jun2010"] + +[overrides.1818750.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] diff --git a/docs/guides/library-and-logs-ui.md b/docs/guides/library-and-logs-ui.md index 4f9166d97..d9a0938b0 100644 --- a/docs/guides/library-and-logs-ui.md +++ b/docs/guides/library-and-logs-ui.md @@ -12,10 +12,28 @@ enables it only when the packaged MetalSharp substrate and Linux symbol image are available on macOS; enabling it persists per-app state under `~/.metalsharp/sharp-library/eac/` and applies the substrate environment only to the next MetalSharp Wine launch. It never starts a game automatically. An -opted-in Steam or GPTK selection is routed to the already-installed M12 -MetalSharp Wine 11.5 lane so the substrate is not sent through GPTK, another -Wine build, or macOS Steam. Per-app substrate logs and module dumps are kept -under `~/.metalsharp/logs/eac//`. +opted-in non-Wine selection is routed to the already-installed M12 MetalSharp +Wine 11.5 lane so the substrate is not sent through GPTK, another Wine build, +or macOS Steam. An explicit M11 selection remains M11. Per-app substrate logs +and module dumps are kept under `~/.metalsharp/logs/eac//`. + +The shipped MTSP rules include protected-launcher metadata (`eac_exe_names`) +and normal executable metadata (`exe_names`) for all requested EAC cards: +Elden Ring, ARMORED CORE VI, Rocket League, The Outlast Trials, Halo MCC, +Sea of Thieves, Pavlov, Rust, 7 Days to Die, Vermintide 2, Watch Dogs 2, +Fall Guys, Friday the 13th, VRChat, Rogue Company, Hunt: Showdown 1896, +Total Lockdown, Lost Ark, Gears 5, Halo Infinite, For Honor, REMATCH, Stay +Out, Back 4 Blood, Apex Legends, Lords of the Fallen, Throne and Liberty, +Star Wars: Squadrons, NBA 2K26, Next Day: Survival, Suicide Squad, SCP: +ReEnter, Killing Floor 3, Battlefield 2042, Squad, ARC Raiders, and +MultiVersus. New defaults use M11; the existing Elden Ring and AC6 M12 rules +remain unchanged. Fires of Rubicon is launched through its real +`Game/start_protected_game.exe` when enabled; no executable rename or `.old` +swap is performed. + +Turning EAC off removes the per-process substrate environment and leaves the +game files untouched. The card remains available for every installed Steam +game; rule metadata controls the executable selected when the toggle is on. ### EAC substrate installation lifecycle From 6b6d09385855aeee6b80843efb3928306840e648 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Mon, 10 Aug 2026 11:22:39 -0600 Subject: [PATCH 9/9] test: verify protected launcher files stay untouched --- app/src-rust/src/mtsp/recipe.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src-rust/src/mtsp/recipe.rs b/app/src-rust/src/mtsp/recipe.rs index 40faf79e8..be2e0eb67 100644 --- a/app/src-rust/src/mtsp/recipe.rs +++ b/app/src-rust/src/mtsp/recipe.rs @@ -1195,7 +1195,7 @@ mod tests { let dir = test_dir("spg-prepared"); let game_dir = dir.join("Game"); std::fs::create_dir_all(&game_dir).expect("create game dir"); - std::fs::write(game_dir.join("start_protected_game.old"), b"PROTECTED_STUB").expect("write old"); + std::fs::write(game_dir.join("start_protected_game.marker"), b"PROTECTED_STUB").expect("write marker"); std::fs::write(game_dir.join("start_protected_game.exe"), b"REAL_GAME_COPY").expect("write protected copy"); std::fs::write(game_dir.join("eldenring.exe"), b"REAL_GAME").expect("write real exe"); @@ -1204,7 +1204,7 @@ mod tests { assert_eq!(selected.file_name().and_then(|name| name.to_str()), Some("eldenring.exe")); assert_eq!(std::fs::read(game_dir.join("start_protected_game.exe")).unwrap(), b"REAL_GAME_COPY"); - assert_eq!(std::fs::read(game_dir.join("start_protected_game.old")).unwrap(), b"PROTECTED_STUB"); + assert_eq!(std::fs::read(game_dir.join("start_protected_game.marker")).unwrap(), b"PROTECTED_STUB"); let _ = std::fs::remove_dir_all(dir); } @@ -1214,6 +1214,7 @@ mod tests { let dir = test_dir("ac6-preferred"); let game_dir = dir.join("Game"); std::fs::create_dir_all(&game_dir).expect("create game dir"); + std::fs::write(game_dir.join("start_protected_game.marker"), b"PROTECTED_MARKER").expect("write marker"); std::fs::write(game_dir.join("start_protected_game.exe"), b"PROTECTED_STUB").expect("write protected exe"); std::fs::write(game_dir.join("armoredcore6.exe"), b"REAL_GAME").expect("write real exe"); @@ -1225,7 +1226,7 @@ mod tests { .expect("select AC6 protected launcher"); assert_eq!(protected.file_name().and_then(|name| name.to_str()), Some("start_protected_game.exe")); assert_eq!(std::fs::read(game_dir.join("start_protected_game.exe")).unwrap(), b"PROTECTED_STUB"); - assert!(!game_dir.join("start_protected_game.old").exists()); + assert!(game_dir.join("start_protected_game.marker").exists()); let _ = std::fs::remove_dir_all(dir); } @@ -1360,7 +1361,6 @@ mod tests { let dir = test_dir("subnautica2-direct-exe"); std::fs::create_dir_all(&dir).expect("create test dir"); std::fs::write(dir.join("start_protected_game.exe"), b"not pe").expect("write protected launcher"); - std::fs::write(dir.join("start_protected_game.old"), b"not pe").expect("write prepared marker"); std::fs::write(dir.join("Subnautica2.exe"), b"not pe").expect("write direct exe"); let selected = resolve_game_exe_for_pipeline(1962700, &dir, Some(PipelineId::M12)).expect("select direct exe");