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