diff --git a/src/BedrockBoot.Core/Global/PathsList.cs b/src/BedrockBoot.Core/Global/PathsList.cs index fe23947c..dc9bbc9b 100644 --- a/src/BedrockBoot.Core/Global/PathsList.cs +++ b/src/BedrockBoot.Core/Global/PathsList.cs @@ -52,6 +52,14 @@ public static readonly string public static readonly string NeoProtonPath = Path.Combine(RootConfigPath, "BedrockBoot.Linux", "xuserProject"); public static readonly string PreFixPath = Path.Combine(ProtonPath, "game_prefix"); + + /// + /// 真实 Wine 前缀目录。 + /// Proton/umu 以 STEAM_COMPAT_DATA_PATH/pfx 作为前缀(STEAM_COMPAT_DATA_PATH 即 PreFixPath), + /// 而 PreFixPath 自身只是 compat data 目录,里面没有 drive_c/system.reg。 + /// 因此注册表改动、cryptbase、GameInput、wineboot 都必须作用于 WinePrefixPath。 + /// + public static readonly string WinePrefixPath = Path.Combine(PreFixPath, "pfx"); public static readonly string GamePatch = Path.Combine(NeoProtonPath, "GamePatch"); public static readonly string EasyTierPath = Path.Combine(PaperConnectPath, "EasyTier"); diff --git a/src/BedrockBoot.Core/Models/Download/MultiThreadDownloader.cs b/src/BedrockBoot.Core/Models/Download/MultiThreadDownloader.cs index 05bbf6db..ff060dbf 100644 --- a/src/BedrockBoot.Core/Models/Download/MultiThreadDownloader.cs +++ b/src/BedrockBoot.Core/Models/Download/MultiThreadDownloader.cs @@ -193,7 +193,19 @@ public async Task DownloadAsync(string url, string filePath, IProgress 0 && supportsRange) { Console.WriteLine($@"服务器支持断点续传且文件大小已知 ({fileSize} bytes),使用多线程下载..."); - await DownloadMultiPartAsync(uri, filePath, fileSize, progress, cancellationToken); + try + { + await DownloadMultiPartAsync(uri, filePath, fileSize, progress, cancellationToken); + } + catch (RangeNotHonoredException ex) + { + // 有些镜像(例如 gh-proxy 系代理)会声称支持 Range,实际却忽略 Range + // 直接返回整份文件,多线程合并结果必然损坏 —— 必须回退单线程, + // 绝不能把拼错的坏文件留在目标路径上。 + Console.WriteLine($@"多线程下载不可用({ex.Message}),回退单线程下载..."); + TryDelete(filePath); + await DownloadSinglePartAsync(uri, filePath, fileSize, progress, cancellationToken); + } } else if (fileSize > 0 && !supportsRange) { @@ -301,9 +313,10 @@ await DownloadPartWithRetryAsync( /* 忽略取消异常 */ } - // 合并临时文件 + // 合并临时文件(同时校验分段长度与总长度) Console.WriteLine(@"开始合并临时文件..."); - await MergeTempFilesAsync(tempFiles, filePath, cancellationToken); + await MergeTempFilesAsync(tempFiles, parts.Select(p => p.End - p.Start + 1).ToArray(), fileSize, + filePath, cancellationToken); // 报告最终进度 progressManager.ReportFinalProgress(); @@ -336,6 +349,12 @@ await DownloadPartAsync(uri, start, end, tempFilePath, partIndex, downloadInfo, progressManager, cancellationToken); return; } + // 服务器不按 Range 返回属于协议级问题,重试没有意义: + // 直接向上抛,由 DownloadAsync 回退到单线程下载。 + catch (RangeNotHonoredException) + { + throw; + } catch (Exception ex) when (retry < maxRetries) { Console.WriteLine($@"分段 {partIndex} 下载失败,第 {retry + 1} 次重试: {ex.Message}"); @@ -375,7 +394,18 @@ private async Task DownloadPartAsync(Uri uri, long start, long end, using var response = await partHttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - response.EnsureSuccessStatusCode(); + + // 分段必须由服务器以 206 Partial Content + 匹配的 Content-Range 回应。 + // 若服务器忽略 Range 返回 200(整份文件),每个分段都会写入整份数据, + // 合并后一定是坏文件(历史 bug:862,210,941 字节的包被拼成 1,508,869,147 字节)。 + if (response.StatusCode != HttpStatusCode.PartialContent) + throw new RangeNotHonoredException( + $"服务器未按 Range 返回分段 {partIndex}(HTTP {(int)response.StatusCode})"); + + var contentRange = response.Content.Headers.ContentRange; + if (contentRange == null || contentRange.From != start || contentRange.To != end) + throw new RangeNotHonoredException( + $"分段 {partIndex} 的 Content-Range 不符(请求 {start}-{end},返回 {contentRange?.ToString() ?? "空"})"); using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); using var fileStream = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write, FileShare.None, @@ -414,6 +444,11 @@ private async Task DownloadPartAsync(Uri uri, long start, long end, } } + var expectedBytes = end - start + 1; + if (partDownloaded != expectedBytes) + throw new RangeNotHonoredException( + $"分段 {partIndex} 长度不符(收到 {partDownloaded},应为 {expectedBytes})"); + Console.WriteLine($@"分段 {partIndex} 下载完成: {partDownloaded} bytes, 耗时: {stopwatch.Elapsed.TotalSeconds:F2}s"); } @@ -512,6 +547,12 @@ await DownloadPartAsync(uri, start, end, tempFilePath, partIndex, downloadInfo, totalDownloadedBytes, reportProgress, cancellationToken); return; } + // 服务器不按 Range 返回属于协议级问题,重试没有意义: + // 直接向上抛,由 DownloadAsync 回退到单线程下载。 + catch (RangeNotHonoredException) + { + throw; + } catch (Exception ex) when (retry < maxRetries) { Console.WriteLine($@"分段 {partIndex} 下载失败,第 {retry + 1} 次重试: {ex.Message}"); @@ -549,7 +590,18 @@ private async Task DownloadPartAsync(Uri uri, long start, long end, using var response = await partHttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - response.EnsureSuccessStatusCode(); + + // 分段必须由服务器以 206 Partial Content + 匹配的 Content-Range 回应。 + // 若服务器忽略 Range 返回 200(整份文件),每个分段都会写入整份数据, + // 合并后一定是坏文件(历史 bug:862,210,941 字节的包被拼成 1,508,869,147 字节)。 + if (response.StatusCode != HttpStatusCode.PartialContent) + throw new RangeNotHonoredException( + $"服务器未按 Range 返回分段 {partIndex}(HTTP {(int)response.StatusCode})"); + + var contentRange = response.Content.Headers.ContentRange; + if (contentRange == null || contentRange.From != start || contentRange.To != end) + throw new RangeNotHonoredException( + $"分段 {partIndex} 的 Content-Range 不符(请求 {start}-{end},返回 {contentRange?.ToString() ?? "空"})"); using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); using var fileStream = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write, FileShare.None, @@ -590,6 +642,11 @@ private async Task DownloadPartAsync(Uri uri, long start, long end, reportProgress(false); } + var expectedBytes = end - start + 1; + if (partDownloaded != expectedBytes) + throw new RangeNotHonoredException( + $"分段 {partIndex} 长度不符(收到 {partDownloaded},应为 {expectedBytes})"); + Console.WriteLine($@"分段 {partIndex} 下载完成: {partDownloaded} bytes, 耗时: {stopwatch.Elapsed.TotalSeconds:F2}s"); } @@ -624,9 +681,13 @@ private async Task DownloadSinglePartAsync(Uri uri, string filePath, long fileSi await _httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); + // 同样先写 .part,长度校验通过后再原子替换,避免留下半个文件 + var partPath = filePath + ".part"; + TryDelete(partPath); + using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); using var fileStream = - new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, _bufferSize, true); + new FileStream(partPath, FileMode.Create, FileAccess.Write, FileShare.None, _bufferSize, true); var buffer = new byte[_bufferSize]; int bytesRead; @@ -647,11 +708,29 @@ void ReportProgressIfNeeded() } } - while ((bytesRead = await responseStream.ReadAsync(buffer, cancellationToken)) > 0) + try { - await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); - totalBytesRead += bytesRead; - ReportProgressIfNeeded(); + while ((bytesRead = await responseStream.ReadAsync(buffer, cancellationToken)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); + totalBytesRead += bytesRead; + ReportProgressIfNeeded(); + } + + fileStream.Flush(); + + if (fileSize > 0 && totalBytesRead != fileSize) + throw new RangeNotHonoredException( + $"下载长度不符(收到 {totalBytesRead},应为 {fileSize}),已丢弃不完整文件"); + + fileStream.Close(); + File.Move(partPath, filePath, true); + } + catch + { + fileStream.Close(); + TryDelete(partPath); + throw; } progress?.Report(new DownloadProgress @@ -668,9 +747,12 @@ private async Task DownloadAsStreamAsync(Uri uri, string filePath, IProgress 0) + try { - await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); - totalBytesRead += bytesRead; - ReportProgressIfNeeded(); + while ((bytesRead = await responseStream.ReadAsync(buffer, cancellationToken)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); + totalBytesRead += bytesRead; + ReportProgressIfNeeded(); + } + + fileStream.Flush(); + fileStream.Close(); + File.Move(partPath, filePath, true); + } + catch + { + fileStream.Close(); + TryDelete(partPath); + throw; } progress?.Report(new DownloadProgress @@ -705,7 +800,8 @@ void ReportProgressIfNeeded() }); } - private async Task MergeTempFilesAsync(string[] tempFiles, string outputPath, CancellationToken cancellationToken) + private async Task MergeTempFilesAsync(string[] tempFiles, long[] expectedPartSizes, long expectedFileSize, + string outputPath, CancellationToken cancellationToken) { await Task.Delay(1000, cancellationToken); @@ -715,40 +811,94 @@ private async Task MergeTempFilesAsync(string[] tempFiles, string outputPath, Ca throw new FileNotFoundException( $"合并临时文件失败: 缺失 {missingFiles.Length} 个分段文件: {string.Join(", ", missingFiles)}"); + // 逐段核对长度:任一分段长度不对(例如服务器忽略了 Range),合并结果必坏 + for (var i = 0; i < tempFiles.Length; i++) + { + var actual = new FileInfo(tempFiles[i]).Length; + var expected = i < expectedPartSizes.Length ? expectedPartSizes[i] : -1; + if (expected >= 0 && actual != expected) + throw new RangeNotHonoredException( + $"分段 {i} 长度不符({actual},应为 {expected}),拒绝合并损坏文件"); + } + + // 先写到 .part,校验总长度后再原子替换目标文件 —— 保证目标路径上只会出现完整文件 + var partPath = outputPath + ".part"; + TryDelete(partPath); + // 增加缓冲区大小 const int largeBufferSize = 81920 * 4; // 320KB缓冲区 - using var outputStream = new BufferedStream( - new FileStream(outputPath, FileMode.Create, FileAccess.Write, - FileShare.None, largeBufferSize, FileOptions.WriteThrough | FileOptions.Asynchronous), - largeBufferSize * 2); + try + { + using (var outputStream = new BufferedStream( + new FileStream(partPath, FileMode.Create, FileAccess.Write, + FileShare.None, largeBufferSize, FileOptions.WriteThrough | FileOptions.Asynchronous), + largeBufferSize * 2)) + { + // 按顺序处理文件 + var sortedFiles = tempFiles + .Select(f => new FileInfo(f)) + .ToArray(); + + foreach (var fileInfo in sortedFiles) + { + cancellationToken.ThrowIfCancellationRequested(); - // 按顺序处理文件 - var sortedFiles = tempFiles - .Select(f => new FileInfo(f)) - .ToArray(); + Console.WriteLine($@"正在合并文件: {fileInfo.FullName}, 大小: {fileInfo.Length} bytes"); - foreach (var fileInfo in sortedFiles) - { - cancellationToken.ThrowIfCancellationRequested(); + using var inputStream = new BufferedStream( + new FileStream(fileInfo.FullName, FileMode.Open, + FileAccess.Read, FileShare.Read, largeBufferSize, + FileOptions.SequentialScan | FileOptions.Asynchronous), + largeBufferSize * 2); - Console.WriteLine($@"正在合并文件: {fileInfo.FullName}, 大小: {fileInfo.Length} bytes"); + var buffer = new byte[largeBufferSize]; + int bytesRead; - using var inputStream = new BufferedStream( - new FileStream(fileInfo.FullName, FileMode.Open, - FileAccess.Read, FileShare.Read, largeBufferSize, - FileOptions.SequentialScan | FileOptions.Asynchronous), - largeBufferSize * 2); + // 手动复制以支持进度报告 + while ((bytesRead = await inputStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) + await outputStream.WriteAsync(buffer, 0, bytesRead, cancellationToken); + } + + await outputStream.FlushAsync(cancellationToken); + } - var buffer = new byte[largeBufferSize]; - int bytesRead; + var mergedSize = new FileInfo(partPath).Length; + if (expectedFileSize > 0 && mergedSize != expectedFileSize) + throw new RangeNotHonoredException( + $"合并结果长度不符({mergedSize},应为 {expectedFileSize}),已丢弃损坏文件"); - // 手动复制以支持进度报告 - while ((bytesRead = await inputStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) - await outputStream.WriteAsync(buffer, 0, bytesRead, cancellationToken); + File.Move(partPath, outputPath, true); } + catch + { + TryDelete(partPath); + throw; + } + } - await outputStream.FlushAsync(cancellationToken); + /// 删除文件并忽略错误(用于清理损坏/临时产物)。 + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (Exception ex) + { + Console.WriteLine($@"删除文件失败 {path}: {ex.Message}"); + } + } + + /// + /// 服务器未按请求的 Range 返回数据(例如忽略 Range 直接返回整份文件)。 + /// 出现该异常时必须回退单线程下载,否则合并结果必然损坏。 + /// + private sealed class RangeNotHonoredException : Exception + { + public RangeNotHonoredException(string message) : base(message) + { + } } private void CleanupTempFiles(string[] tempFiles) diff --git a/src/BedrockBoot.Linux/Models/Game/EasyLauncher.cs b/src/BedrockBoot.Linux/Models/Game/EasyLauncher.cs index ee6e8b6c..3b16d1f9 100644 --- a/src/BedrockBoot.Linux/Models/Game/EasyLauncher.cs +++ b/src/BedrockBoot.Linux/Models/Game/EasyLauncher.cs @@ -468,7 +468,7 @@ private bool PrepareNeoLaunchEnvironment() } ProtonPatcher.Patch(ProtonNeoCore.ProtonRootPath); - GameInputInstaller.Install(PathsList.PreFixPath, VersionInfo.VersionPath); + GameInputInstaller.Install(PathsList.WinePrefixPath, VersionInfo.VersionPath); GdkFixups.PatchLhcXcurlGate(VersionInfo.VersionPath); GdkFixups.BumpStackReserve(Path.Combine(VersionInfo.VersionPath, VersionInfo.BodyFile)); @@ -485,7 +485,7 @@ private void EnsureGameInputInstalled() var protonConfig = ProtonCore.GetVersionConfig(_linuxLaunchInfo!.ProtonPath); bool isInstalled = VersionInfo.VersionStatus.GameInputInstalled - && Path.Exists(_linuxLaunchInfo.PrefixPath) + && Path.Exists(PathsList.WinePrefixPath) && protonConfig.IsGameInputInstalled; if (isInstalled) return; @@ -501,7 +501,7 @@ private void EnsureGameInputInstalled() } else { - if (!InstallGameInputWithWine(PathsList.PreFixPath, msiPath)) return; + if (!InstallGameInputWithWine(PathsList.WinePrefixPath, msiPath)) return; } Console.WriteLine("GameInput 安装完毕"); @@ -562,7 +562,7 @@ private static void InstallCryptbase() { var builtin = Path.Combine(ProtonNeoCore.ProtonRootPath, "files", "lib", "wine", "x86_64-windows", "cryptbase.dll"); - var dst = Path.Combine(PathsList.PreFixPath, "drive_c", "windows", "system32", "cryptbase.dll"); + var dst = Path.Combine(PathsList.WinePrefixPath, "drive_c", "windows", "system32", "cryptbase.dll"); if (File.Exists(builtin) && !File.Exists(dst)) { diff --git a/src/BedrockBoot.Linux/Models/Pack/Wine/WinePrefix.cs b/src/BedrockBoot.Linux/Models/Pack/Wine/WinePrefix.cs index 35ea414d..d7ac7e06 100644 --- a/src/BedrockBoot.Linux/Models/Pack/Wine/WinePrefix.cs +++ b/src/BedrockBoot.Linux/Models/Pack/Wine/WinePrefix.cs @@ -47,7 +47,7 @@ public static bool IsReady(string prefix) public static bool Boot() { - var pfx = PathsList.PreFixPath; + var pfx = PathsList.WinePrefixPath; if (IsReady(pfx)) { Console.WriteLine("Wine prefix ready"); @@ -64,6 +64,11 @@ public static bool Boot() UseShellExecute = false, }; psi.EnvironmentVariables["WINEPREFIX"] = pfx; + // proton 脚本要求 STEAM_COMPAT_DATA_PATH(前缀会被解析为 <它>/pfx), + // 以及 STEAM_COMPAT_CLIENT_INSTALL_PATH;缺任一个都会直接报错退出。 + psi.EnvironmentVariables["STEAM_COMPAT_DATA_PATH"] = PathsList.PreFixPath; + psi.EnvironmentVariables["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".steam", "steam"); psi.EnvironmentVariables["WINEDEBUG"] = "-all"; psi.EnvironmentVariables["SDL_VIDEODRIVER"] = "dummy"; @@ -131,27 +136,31 @@ public static void ApplyWinegdkPrereqs() var user = new List { + // Wine 内置的 amd_ags_x64 无法从自身 PE 读出 AGS 版本号(err 1812), + // 游戏(RenderDragon)会因此反复调用 agsInit 死循环:窗口白屏、主线程 100% CPU、无响应。 + // 直接禁用该 DLL,游戏会走「没有 AGS」的普通分支(本地 AMD 机器不加载它也能正常运行)。 + WineRegistry.RegSz(@"Software\Wine\DllOverrides", "amd_ags_x64", "disabled"), WineRegistry.RegSz("Environment", "MICROSOFT_WINDOWSAPPRUNTIME_BOOTSTRAP_INITIALIZE_SHOWUI", "0"), WineRegistry.RegSz("Environment", "MICROSOFT_WINDOWSAPPRUNTIME_BOOTSTRAP_INITIALIZE_FAILFAST", "0"), WineRegistry.RegSz("Environment", "MICROSOFT_WINDOWSAPPRUNTIME_DEPLOYMENT_INITIALIZE_ONERRORSHOWUI", "0"), }; - WineRegistry.UpdatePrefix(PathsList.PreFixPath, machine.ToArray(), user.ToArray()); + WineRegistry.UpdatePrefix(PathsList.WinePrefixPath, machine.ToArray(), user.ToArray()); Console.WriteLine("WineGDK prereqs applied"); } public static void SetRefreshToken(string token) { - WineRegistry.UpdatePrefix(PathsList.PreFixPath, + WineRegistry.UpdatePrefix(PathsList.WinePrefixPath, machine: new[] { WineRegistry.RegSz(PathsList.WinegdkReg, "RefreshToken", token) }); Console.WriteLine("Refresh token written to Wine registry"); } public static void RemoveRefreshToken() { - var systemReg = Path.Combine(PathsList.PreFixPath, "system.reg"); + var systemReg = Path.Combine(PathsList.WinePrefixPath, "system.reg"); if (!File.Exists(systemReg)) return; - WineRegistry.UpdatePrefix(PathsList.PreFixPath, + WineRegistry.UpdatePrefix(PathsList.WinePrefixPath, machine: new[] { WineRegistry.RegDelete(PathsList.WinegdkReg, "RefreshToken") }); } } \ No newline at end of file