From 413052129d0f71ef79b8cd44413c3f2f57fb1b74 Mon Sep 17 00:00:00 2001 From: std-external <201375710+std-external@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:00:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(everest):=20=E5=AE=89=E8=A3=85=E5=99=A8?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E5=B4=A9=E6=BA=83=E6=97=B6=E7=BB=99=E5=87=BA?= =?UTF-8?q?=E8=83=BD=E7=85=A7=E5=81=9A=E7=9A=84=E6=8F=90=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E8=80=8C=E4=B8=8D=E6=98=AF=E5=8F=AA=E7=94=A9=E4=B8=80=E6=AE=B5?= =?UTF-8?q?=E6=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MiniInstaller 有时不是报安装错误,而是直接死在 .NET 运行时自己的原生代码里 (AccessViolationException 打在 relinker 的 String.StartsWith → Globalization P/Invoke 上)。这段栈对用户没有用,还会让人以为游戏或 CeleMod 坏了。 CELEMOD 侧确认过的事实:MiniInstaller 的 StandardMode 是在本进程里单线程调 MonoMod 的(DepCalls.RunMonoMod → MonoModder.AutoPatch),MonoModder 自己没有 任何 Parallel/Task,官方 Everest 也没有并行 relink/并行加载开关(那两个 EVEREST_PARALLEL_* 是 EverestUltra 的),所以这次崩溃不是重链竞态,而是进程里 被别的东西打坏了原生内存/运行时:常见是杀毒软件、显卡驱动的游戏内覆盖层之类 注入到每个进程的东西,或者游戏目录里那份 piton-runtime 被打坏。 安装失败时按栈里的特征串认出这类崩溃,在原始错误后面补一条能照做的建议 (先关实时防护和游戏内覆盖层再装一次;不行就手动解压 Main.zip 跑 MiniInstaller-win64.exe),本地包安装走同一个提示。其他安装失败原样返回。 --- src-tauri/src/backend.rs | 67 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index 24a1165..9eb1001 100644 --- a/src-tauri/src/backend.rs +++ b/src-tauri/src/backend.rs @@ -5486,6 +5486,32 @@ fn with_file_in_use_hint(message: String) -> String { ) } +/// MiniInstaller sometimes dies inside the .NET runtime's own native code +/// instead of reporting an install error: an AccessViolationException straight +/// out of the relinker (`String.StartsWith` -> the Globalization P/Invoke). That +/// crash comes from a native component that has nothing to do with the game +/// (anti-virus / GPU driver overlays injecting into every process, or a damaged +/// runtime next to the game), and MiniInstaller runs MonoMod single-threaded, so +/// the install itself was not racing. The raw stack makes people think their +/// game or CeleMod is broken, so point at what actually helps and at the manual +/// install as the fallback. +fn with_installer_crash_hint(message: String) -> String { + const CRASH_MARKERS: [&str; 3] = [ + "System.AccessViolationException", + "Attempted to read or write protected memory", + "尝试读取或写入受保护的内存", + ]; + if !CRASH_MARKERS.iter().any(|marker| message.contains(marker)) { + return message; + } + format!( + "{message}\n\n这不是游戏文件坏了:安装程序在跑的时候被系统里的东西打断了(常见是杀毒软件/安全卫士的实时防护,\ + 或者显卡驱动带的游戏内覆盖层)。先临时关掉它们再装一次;还是不行就手动装:\ + 去 everestapi.github.io 下载 Everest 的 Main.zip,解压到游戏目录(和 Celeste.exe 放在一起),\ + 再双击运行里面的 MiniInstaller-win64.exe。" + ) +} + #[tauri::command] fn download_and_install_everest(game_path: String, url: String, on_event: Channel) { std::thread::spawn(move || { @@ -5526,7 +5552,7 @@ fn download_and_install_everest(game_path: String, url: String, on_event: Channe ), Err(error) => { let error = logged_error(format!("Failed to install Everest: {error:#}")); - let error = with_file_in_use_hint(error); + let error = with_installer_crash_hint(with_file_in_use_hint(error)); send_event( &on_event, vec![serde_json::json!("Failed"), serde_json::json!(error)], @@ -5720,6 +5746,7 @@ fn install_local_packages( let error = logged_error(format!( "Failed to install local package {file_name}: {error:#}" )); + let error = with_installer_crash_hint(error); LocalPackageInstallResult { file: file_name, package_type, @@ -6342,6 +6369,44 @@ mod keyboard_input_tests { } } +#[cfg(test)] +mod everest_install_hint_tests { + use super::with_installer_crash_hint; + + #[test] + fn appends_a_hint_to_an_installer_native_crash() { + let message = "Failed to install Everest: Command failed with error: Fatal error. \ + System.AccessViolationException: Attempted to read or write protected \ + memory. at MonoMod.MonoModder.FindType"; + let hinted = with_installer_crash_hint(message.to_string()); + assert!( + hinted.starts_with(message), + "the install error must stay first" + ); + assert!(hinted.contains("everestapi.github.io")); + assert!(hinted.contains("MiniInstaller-win64.exe")); + } + + #[test] + fn recognizes_the_localized_crash_message() { + let hinted = with_installer_crash_hint( + "System.AccessViolationException: 尝试读取或写入受保护的内存。".to_string(), + ); + assert!(hinted.contains("杀毒软件")); + } + + #[test] + fn leaves_other_install_failures_alone() { + for message in [ + "Failed to install Everest: Failed to replace C:\\Celeste\\Celeste.dll. Close Celeste", + "Failed to install Everest: The zip is not an Everest install package for this platform", + "Failed to install local package mod.zip: unsupported zip", + ] { + assert_eq!(with_installer_crash_hint(message.to_string()), message); + } + } +} + #[cfg(test)] mod legacy_loader_tests { use super::configure_legacy_loader_environment; From baf6d3fc4a139e451a6c7fa0be952bf831691af0 Mon Sep 17 00:00:00 2001 From: std-external <201375710+std-external@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:07:17 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(everest):=20=E5=AE=89=E8=A3=85=E5=99=A8?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E5=B4=A9=E6=BA=83=E5=90=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=87=8D=E8=A3=85=E4=B8=80=E6=AC=A1=EF=BC=8C=E5=B9=B6=E8=AE=A9?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E5=99=A8=E8=BF=9B=E7=A8=8B=E9=81=BF=E5=BC=80?= =?UTF-8?q?=20ICU=20=E6=AF=94=E8=BE=83=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈这类崩溃是偶发的:同一台机器、同样的步骤,之前成功过一次。所以它的 性质是「偶发 + 崩在运行时原生层」,不是确定性的环境损坏。 - everest.rs:抽出 extract_everest_archive,安装失败且错误串带原生崩溃特征 (is_installer_native_crash:AccessViolationException / 受保护内存中英文)时, 重新解压一遍再跑一次 MiniInstaller(崩溃那次会把游戏目录留在半装状态,不 重解压直接重跑不安全),只重试一次,两次都崩才把错误抛给用户。 - everest.rs:在 Windows 上给安装器子进程设 DOTNET_SYSTEM_GLOBALIZATION_USENLS=1。 MonoMod 在 FindType 里用文化相关的 String.StartsWith 比依赖名,.NET 在非 en* 区域设置下会走 ICU(dotnet/runtime#132702 是同一个栈,运行时团队判定该路径是 外部堆破坏的最早受害者);让安装器进程用 NLS 只影响这些 ASCII 前缀的比较结果 (完全一致),对子进程来说是安全的默认值。 - backend.rs:提示文案改成「已经自动重装过一遍还是崩」,并把重试放在第一位。 cargo test --lib: 119 passed(新增 3 个:崩溃特征识别、失败后确实重试并成功、连续崩两次不循环)。 --- src-tauri/src/backend.rs | 32 +++---- src-tauri/src/everest.rs | 198 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 198 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index 9eb1001..e21bf69 100644 --- a/src-tauri/src/backend.rs +++ b/src-tauri/src/backend.rs @@ -5486,29 +5486,23 @@ fn with_file_in_use_hint(message: String) -> String { ) } -/// MiniInstaller sometimes dies inside the .NET runtime's own native code -/// instead of reporting an install error: an AccessViolationException straight -/// out of the relinker (`String.StartsWith` -> the Globalization P/Invoke). That -/// crash comes from a native component that has nothing to do with the game -/// (anti-virus / GPU driver overlays injecting into every process, or a damaged -/// runtime next to the game), and MiniInstaller runs MonoMod single-threaded, so -/// the install itself was not racing. The raw stack makes people think their -/// game or CeleMod is broken, so point at what actually helps and at the manual -/// install as the fallback. +/// MiniInstaller died in native code (again, after the automatic retry in +/// `everest::install_everest_archive_with_steps`). The stack belongs to the +/// runtime, not to the game: `MonoModder.FindType` compares dependency names +/// with a culture-sensitive `String.StartsWith`, .NET routes that through ICU, +/// and dotnet/runtime#132702 is the same crash with the runtime team's verdict +/// that this path is the earliest *victim* of heap corruption written elsewhere. +/// The raw stack makes people think their game or CeleMod is broken, so point at +/// what actually helps and at the manual install as the fallback. fn with_installer_crash_hint(message: String) -> String { - const CRASH_MARKERS: [&str; 3] = [ - "System.AccessViolationException", - "Attempted to read or write protected memory", - "尝试读取或写入受保护的内存", - ]; - if !CRASH_MARKERS.iter().any(|marker| message.contains(marker)) { + if !everest::is_installer_native_crash(&message) { return message; } format!( - "{message}\n\n这不是游戏文件坏了:安装程序在跑的时候被系统里的东西打断了(常见是杀毒软件/安全卫士的实时防护,\ - 或者显卡驱动带的游戏内覆盖层)。先临时关掉它们再装一次;还是不行就手动装:\ - 去 everestapi.github.io 下载 Everest 的 Main.zip,解压到游戏目录(和 Celeste.exe 放在一起),\ - 再双击运行里面的 MiniInstaller-win64.exe。" + "{message}\n\n这不是游戏文件坏了:安装程序跑的时候被系统里的东西打断了(CeleMod 已经自动重装过一遍,还是崩)。\ + 这类崩溃常常是偶发的,再点一次安装往往就过了;连着几次都崩,就临时关掉杀毒软件/安全卫士的实时防护、\ + 显卡驱动的游戏内覆盖层再装;还是不行就手动装:去 everestapi.github.io 下载 Everest 的 Main.zip,\ + 解压到游戏目录(和 Celeste.exe 放在一起),再双击运行里面的 MiniInstaller-win64.exe。" ) } diff --git a/src-tauri/src/everest.rs b/src-tauri/src/everest.rs index f209364..7643e8b 100644 --- a/src-tauri/src/everest.rs +++ b/src-tauri/src/everest.rs @@ -468,6 +468,40 @@ pub fn is_everest_ultra(game_path: &Path) -> bool { }) } +/// The install has to survive a MiniInstaller process that dies in native code +/// (see [`is_installer_native_crash`]). On Windows that crash keeps landing in +/// the ICU path .NET uses for culture-sensitive string comparison: +/// `String.StartsWith` -> `CompareInfo.IsPrefix` -> `GlobalizationNative_StartsWith`. +/// MonoMod hits it in `MonoModder.FindType` (`CrawlDependencies` checks whether a +/// dependency name starts with `"System.Private"`), and dotnet/runtime#132702 is +/// the same stack: a Chinese (non-`en*`) system locale is what sends these ASCII +/// prefixes through ICU at all, and the runtime team's verdict there was that the +/// path is the earliest *victim* of heap corruption written elsewhere. +/// +/// Asking the runtime for NLS instead of ICU for the installer process only +/// changes how those ASCII prefixes are compared (identical results), so it is a +/// safe default for a child process we own. +#[cfg(target_os = "windows")] +const INSTALLER_GLOBALIZATION_ENV: [(&str, &str); 1] = + [("DOTNET_SYSTEM_GLOBALIZATION_USENLS", "1")]; +#[cfg(not(target_os = "windows"))] +const INSTALLER_GLOBALIZATION_ENV: [(&str, &str); 0] = []; + +/// MiniInstaller sometimes fails with a native fault instead of an install +/// error: the .NET runtime prints `Fatal error. System.AccessViolationException` +/// to stderr and fails the process fast. Those runs are *intermittent* (the same +/// machine and the same steps succeed on a retry), and they leave the game +/// directory half-updated, so the useful reaction is to run the whole install +/// again once instead of telling the user their game is broken. +pub fn is_installer_native_crash(message: &str) -> bool { + const CRASH_MARKERS: [&str; 3] = [ + "System.AccessViolationException", + "Attempted to read or write protected memory", + "尝试读取或写入受保护的内存", + ]; + CRASH_MARKERS.iter().any(|marker| message.contains(marker)) +} + fn run_command( installer_path: PathBuf, step_label: &str, @@ -480,6 +514,7 @@ fn run_command( let mut cmd = Command::new(&installer_path); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); + cmd.envs(INSTALLER_GLOBALIZATION_ENV); #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x08000000; #[cfg(target_os = "windows")] @@ -592,19 +627,12 @@ pub fn is_everest_install_archive(path: &Path) -> anyhow::Result { Ok(has_installer) } -fn install_everest_archive_with_steps( +fn extract_everest_archive( game_path: &Path, archive_path: &Path, extract_step: &str, - installer_step: &str, progress_callback: &mut dyn FnMut(String, f32), ) -> anyhow::Result<()> { - #[cfg(target_os = "android")] - crate::android::can_install()?; - if !is_everest_install_archive(archive_path)? { - bail!("The zip is not an Everest install package for this platform"); - } - progress_callback(extract_step.to_string(), 0.0); let mut archive = zip::ZipArchive::new(std::fs::File::open(archive_path)?)?; @@ -652,12 +680,50 @@ fn install_everest_archive_with_steps( } } + Ok(()) +} + +fn install_everest_archive_with_steps( + game_path: &Path, + archive_path: &Path, + extract_step: &str, + installer_step: &str, + progress_callback: &mut dyn FnMut(String, f32), +) -> anyhow::Result<()> { + #[cfg(target_os = "android")] + crate::android::can_install()?; + if !is_everest_install_archive(archive_path)? { + bail!("The zip is not an Everest install package for this platform"); + } + + extract_everest_archive(game_path, archive_path, extract_step, progress_callback)?; + + let installer_path = game_path.join(installer_name()?); progress_callback(installer_step.to_string(), 0.0); - run_command( - game_path.join(installer_name()?), - installer_step, - progress_callback, - ) + let error = match run_command(installer_path.clone(), installer_step, progress_callback) { + Ok(()) => return Ok(()), + Err(error) => error, + }; + + // A native crash in MiniInstaller is intermittent: the same machine installs + // fine on the next attempt, and the failing run left the game directory + // half-updated. Re-extract and run the installer once more before giving up, + // which is exactly what the user would otherwise do by hand. + if !is_installer_native_crash(&format!("{error:#}")) { + return Err(error); + } + + progress_callback( + format!("{installer_step}: 安装器崩溃,正在重新解压并重试一次"), + 0.0, + ); + extract_everest_archive(game_path, archive_path, extract_step, progress_callback)?; + progress_callback(installer_step.to_string(), 0.0); + run_command(installer_path, installer_step, progress_callback).map_err(|second| { + second.context(format!( + "安装器连续两次在原生代码里崩溃(第一次:{error:#})" + )) + }) } pub fn install_everest_archive( @@ -704,6 +770,112 @@ pub fn download_and_install_everest( ) } +#[cfg(test)] +mod installer_retry_tests { + use super::is_installer_native_crash; + use std::fs; + use std::io::Write; + use std::path::Path; + + fn write_everest_archive(path: &Path, installer_name: &str, script: &str) { + let file = fs::File::create(path).unwrap(); + let mut writer = zip::ZipWriter::new(file); + for (name, contents) in [ + (format!("main/{installer_name}"), script.as_bytes()), + ("main/Celeste.Mod.mm.dll".to_string(), b"dll".as_slice()), + ] { + writer + .start_file(name, zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(contents).unwrap(); + } + writer.finish().unwrap(); + } + + #[test] + fn recognizes_the_runtime_native_crash() { + assert!(is_installer_native_crash( + "Failed to install Everest: Command failed with error: Fatal error. \ + System.AccessViolationException: Attempted to read or write protected memory." + )); + assert!(is_installer_native_crash( + "System.AccessViolationException: 尝试读取或写入受保护的内存。" + )); + assert!(!is_installer_native_crash( + "Failed to replace C:\\Celeste\\Celeste.dll. Close Celeste and any program using this file" + )); + } + + #[cfg(unix)] + #[test] + fn retries_the_whole_install_once_after_a_native_crash() { + use super::install_everest_archive_with_steps; + + let root = std::env::temp_dir().join(format!("celemod-retry-{}", std::process::id())); + let game = root.join("game"); + fs::create_dir_all(&game).unwrap(); + let archive = root.join("everest.zip"); + write_everest_archive( + &archive, + super::installer_name().unwrap(), + "#!/bin/sh\nn=$(cat attempts 2>/dev/null || echo 0)\nn=$((n+1))\necho $n > attempts\n\ + if [ \"$n\" -lt 2 ]; then\n\ + echo 'Fatal error. System.AccessViolationException: Attempted to read or write protected memory.' >&2\n\ + exit 134\nfi\n", + ); + + let result = install_everest_archive_with_steps( + &game, + &archive, + "extract", + "install", + &mut |_, _| {}, + ); + assert!( + result.is_ok(), + "the second attempt must be used: {result:?}" + ); + assert_eq!( + fs::read_to_string(game.join("attempts")).unwrap().trim(), + "2" + ); + fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn reports_an_installer_that_keeps_crashing_instead_of_looping() { + use super::install_everest_archive_with_steps; + + let root = std::env::temp_dir().join(format!("celemod-retry-fail-{}", std::process::id())); + let game = root.join("game"); + fs::create_dir_all(&game).unwrap(); + let archive = root.join("everest.zip"); + write_everest_archive( + &archive, + super::installer_name().unwrap(), + "#!/bin/sh\nn=$(cat attempts 2>/dev/null || echo 0)\nn=$((n+1))\necho $n > attempts\n\ + echo 'Fatal error. System.AccessViolationException: Attempted to read or write protected memory.' >&2\n\ + exit 134\n", + ); + + let error = install_everest_archive_with_steps( + &game, + &archive, + "extract", + "install", + &mut |_, _| {}, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("连续两次")); + assert_eq!( + fs::read_to_string(game.join("attempts")).unwrap().trim(), + "2" + ); + fs::remove_dir_all(root).unwrap(); + } +} + #[cfg(test)] mod tests { use super::{CatalogWait, is_everest_ultra, wait_for_catalog};