From b2b9f0bf4673eaebbbc1ce5262ae8920ac2d1428 Mon Sep 17 00:00:00 2001
From: zznty <94796179+zznty@users.noreply.github.com>
Date: Sat, 2 Aug 2025 03:58:42 +0700
Subject: [PATCH] add r2r for launcher and game assemblies
---
CringeBootstrap/CringeBootstrap.csproj | 2 +
CringeBootstrap/CrossGen/CrossGenService.cs | 248 ++++++++++++++++++
.../GameDirectoryAssemblyLoadContext.cs | 8 +-
CringeBootstrap/Program.cs | 38 ++-
4 files changed, 290 insertions(+), 6 deletions(-)
create mode 100644 CringeBootstrap/CrossGen/CrossGenService.cs
diff --git a/CringeBootstrap/CringeBootstrap.csproj b/CringeBootstrap/CringeBootstrap.csproj
index e41551e..cbef390 100644
--- a/CringeBootstrap/CringeBootstrap.csproj
+++ b/CringeBootstrap/CringeBootstrap.csproj
@@ -8,6 +8,7 @@
true
preview
true
+ true
true
true
win-x64
@@ -28,6 +29,7 @@
+
diff --git a/CringeBootstrap/CrossGen/CrossGenService.cs b/CringeBootstrap/CrossGen/CrossGenService.cs
new file mode 100644
index 0000000..5609487
--- /dev/null
+++ b/CringeBootstrap/CrossGen/CrossGenService.cs
@@ -0,0 +1,248 @@
+using System.Collections.Immutable;
+using System.Diagnostics;
+using System.IO.Compression;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text;
+using NuGet;
+using NuGet.Deps;
+using NuGet.Versioning;
+
+namespace CringeBootstrap.CrossGen;
+
+internal class CrossGenService(string gameDirectoryPath, string cachePath)
+{
+ private readonly ImmutableHashSet _excludedAssemblies = ImmutableHashSet.Create(
+ StringComparer.OrdinalIgnoreCase,
+ "VRage.NativeAftermath.dll" // managed C++ is not supported
+ );
+
+ // assembly with game version constant so hash always changes with game updates
+ private const string CacheKeyFileName = "SpaceEngineers.Game.dll";
+
+ private readonly string _crossGenCachePath = Directory.CreateDirectory(Path.Join(cachePath, "R2R")).FullName;
+
+ public void CleanCache()
+ {
+ foreach (var directory in Directory.EnumerateDirectories(_crossGenCachePath))
+ {
+ try
+ {
+ Directory.Delete(directory, true);
+ }
+ catch (IOException e)
+ {
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine("Failed to clean previous crossgen cache");
+ Console.ResetColor();
+ Console.WriteLine(e);
+ }
+ }
+ }
+
+ ///
+ /// Run crossgen and return path to game assemblies directory
+ ///
+ /// The path to game assemblies directory either original or R2R
+ public string RunCrossGen()
+ {
+ var cacheDirectory = Path.Join(_crossGenCachePath, GetCacheKey());
+ if (Directory.Exists(cacheDirectory))
+ {
+ Console.WriteLine("Crossgen cache hit");
+ return cacheDirectory;
+ }
+
+ Console.WriteLine("Starting coldstart crossgen");
+
+ CleanCache();
+
+ var crossGenPath = DownloadCrossGenAsync().GetAwaiter().GetResult();
+ if (crossGenPath is null)
+ return gameDirectoryPath;
+
+ var inputAssemblies = CollectInputAssemblies();
+ ImmutableHashSet references = [..CollectFrameworkReferencesAsync().GetAwaiter().GetResult(), ..inputAssemblies];
+ references = references.WithComparer(StringComparer.OrdinalIgnoreCase);
+
+ Directory.CreateDirectory(cacheDirectory);
+
+ for (var index = 0; index < inputAssemblies.Length; index++)
+ {
+ var inputAssembly = inputAssemblies[index];
+ var inputReferences = references.Remove(inputAssembly);
+
+ var startInfo = new ProcessStartInfo(crossGenPath, [
+ "--targetos:windows",
+ "--targetarch:x64",
+ "--Ot",
+ ..inputReferences.SelectMany(x => new [] { "-r", x }),
+ "--out", Path.Join(cacheDirectory, Path.GetFileName(inputAssembly)),
+ inputAssembly
+ ])
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ };
+
+ Console.WriteLine($"Running crossgen... {index/(inputAssemblies.Length-1.0):P0}");
+
+ using var process = Process.Start(startInfo);
+
+ var outputStringBuilder = new StringBuilder();
+ var errorStringBuilder = new StringBuilder();
+ if (process is not null)
+ {
+ process.OutputDataReceived += (_, args) =>
+ {
+ if (!string.IsNullOrEmpty(args.Data))
+ outputStringBuilder.AppendLine(args.Data);
+ };
+
+ process.ErrorDataReceived += (_, args) =>
+ {
+ if (!string.IsNullOrEmpty(args.Data))
+ errorStringBuilder.AppendLine(args.Data);
+ };
+
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+ process.WaitForExit();
+ }
+
+ if (process is not null && process.ExitCode == 0) continue;
+
+ string? logFilePath = null;
+ if (process is not null)
+ {
+ logFilePath = Path.Join(cachePath, $"{Path.GetFileName(inputAssembly)}.log");
+ File.WriteAllText(logFilePath, outputStringBuilder.ToString());
+ File.AppendAllText(logFilePath, errorStringBuilder.ToString());
+ }
+
+ LogCrossGenException($"Crossgen failed! {(logFilePath is not null ? $"Log saved to: {logFilePath}" : string.Empty)} Skipping crossgen", new Exception($"Crossgen failed for {inputAssembly}"));
+ CleanCache();
+ return gameDirectoryPath;
+ }
+
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.WriteLine("Crossgen finished");
+ Console.ResetColor();
+ return cacheDirectory;
+ }
+
+ private static async Task> CollectFrameworkReferencesAsync()
+ {
+ var dotnetPacksPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet", "shared");
+ if (!Directory.Exists(dotnetPacksPath))
+ throw new Exception($"Dotnet shared packs not found in {dotnetPacksPath}");
+
+ const string runtimePackId = "Microsoft.NETCore.App";
+ const string desktopPackId = "Microsoft.WindowsDesktop.App";
+
+ return
+ [
+ ..await CollectFrameworkReferencesFromPackAsync(dotnetPacksPath, runtimePackId),
+ ..await CollectFrameworkReferencesFromPackAsync(dotnetPacksPath, desktopPackId)
+ ];
+ }
+
+ private static async ValueTask> CollectFrameworkReferencesFromPackAsync(string packsPath, string packId)
+ {
+ var packDirPath = Path.Join(packsPath, packId, Environment.Version.ToString());
+ var packPath = Path.Join(packDirPath, $"{packId}.deps.json");
+ if (!File.Exists(packPath))
+ throw new Exception($"Dotnet shared pack {packId} not found in {packPath}");
+
+ await using var stream = File.OpenRead(packPath);
+ var ((runtimeFramework, _), _, targets, _) = await DependencyManifestSerializer.DeserializeAsync(stream);
+ var (_, runtime, _) = targets[runtimeFramework].Values.First();
+
+ return [..runtime!.Keys.Select(b => Path.Join(packDirPath, b))];
+ }
+
+ private ImmutableArray CollectInputAssemblies()
+ {
+ return [
+ ..Directory.EnumerateFiles(gameDirectoryPath, "*.dll")
+ .Where(IsManagedAssembly)
+ ];
+ }
+
+ private bool IsManagedAssembly(string path)
+ {
+ if (_excludedAssemblies.Contains(Path.GetFileName(path)))
+ return false;
+
+ try
+ {
+ AssemblyName.GetAssemblyName(path);
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ private async Task DownloadCrossGenAsync()
+ {
+ const string nugetUrl = "https://api.nuget.org/v3/index.json";
+ const string toolName = "crossgen2.exe";
+ const string packageId = "Microsoft.NETCore.App.Crossgen2.win-x64";
+ var nugetCachePath = Path.Join(cachePath, "x64", $"net{Environment.Version.Major}.{Environment.Version.Minor}");
+
+ var packagePath = Directory.CreateDirectory(Path.Join(nugetCachePath, packageId, Environment.Version.ToString()));
+ var toolPath = Path.Join(packagePath.FullName, "tools", toolName);
+ if (File.Exists(toolPath))
+ return toolPath;
+
+ using var httpClient = new HttpClient();
+ try
+ {
+ var client = await NuGetClient.CreateFromIndexUrlAsync(nugetUrl, httpClient);
+
+ if (!packagePath.Exists) packagePath.Create();
+
+ await using var stream =
+ await client!.GetPackageContentStreamAsync(packageId, new NuGetVersion(Environment.Version));
+ await using var memStream = new MemoryStream();
+ await stream.CopyToAsync(memStream);
+ memStream.Position = 0;
+ using var archive = new ZipArchive(memStream, ZipArchiveMode.Read);
+ archive.ExtractToDirectory(packagePath.FullName);
+
+ if (!File.Exists(toolPath))
+ {
+ LogCrossGenException("Failed to find crossgen", new FileNotFoundException("Failed to find crossgen", toolPath));
+ return null;
+ }
+ }
+ catch (IOException e)
+ {
+ LogCrossGenException("Failed to extract crossgen", e);
+ return null;
+ }
+ catch (Exception e)
+ {
+ LogCrossGenException("Failed to download crossgen", e);
+ return null;
+ }
+
+ return toolPath;
+ }
+
+ private static void LogCrossGenException(string message, Exception e)
+ {
+ Console.ForegroundColor = ConsoleColor.Red;
+ Console.WriteLine(message);
+ Console.ResetColor();
+ Console.WriteLine(e);
+ }
+
+ private string GetCacheKey()
+ {
+ using var stream = File.OpenRead(Path.Join(gameDirectoryPath, CacheKeyFileName));
+ return Convert.ToHexStringLower(SHA256.HashData(stream));
+ }
+}
\ No newline at end of file
diff --git a/CringeBootstrap/GameDirectoryAssemblyLoadContext.cs b/CringeBootstrap/GameDirectoryAssemblyLoadContext.cs
index 32d245b..93ca2d9 100644
--- a/CringeBootstrap/GameDirectoryAssemblyLoadContext.cs
+++ b/CringeBootstrap/GameDirectoryAssemblyLoadContext.cs
@@ -9,14 +9,17 @@ namespace CringeBootstrap;
public class GameDirectoryAssemblyLoadContext : AssemblyLoadContext, ICoreLoadContext
{
private readonly string _dir;
+ private readonly string _unmanagedAssembliesDir;
+
private static readonly ImmutableHashSet ReferenceAssemblies = ["netstandard"];
// Assembly simple names are case-insensitive per the runtime behavior
// (see SimpleNameToFileNameMapTraits for the TPA lookup hash).
private readonly Dictionary _assemblyNames = new(StringComparer.OrdinalIgnoreCase);
- public GameDirectoryAssemblyLoadContext(string dir) : base("CringeBootstrap")
+ public GameDirectoryAssemblyLoadContext(string dir, string unmanagedAssembliesDir) : base("CringeBootstrap")
{
_dir = dir;
+ _unmanagedAssembliesDir = unmanagedAssembliesDir;
var files = Directory.GetFiles(dir, "*.dll");
foreach (var file in files)
{
@@ -73,7 +76,8 @@ public class GameDirectoryAssemblyLoadContext : AssemblyLoadContext, ICoreLoadCo
return base.LoadUnmanagedDll(unmanagedDllName);
// prefer System32 over ours
- ReadOnlySpan dirs = [Environment.SystemDirectory, _dir];
+ // avoid using _dir because it may be a crossgen directory without unmanaged assemblies
+ ReadOnlySpan dirs = [Environment.SystemDirectory, _unmanagedAssembliesDir];
foreach (var dir in dirs)
{
var path = Path.Join(dir, unmanagedDllName);
diff --git a/CringeBootstrap/Program.cs b/CringeBootstrap/Program.cs
index c3fabbe..124649b 100644
--- a/CringeBootstrap/Program.cs
+++ b/CringeBootstrap/Program.cs
@@ -1,9 +1,9 @@
using System.Diagnostics;
-using System.Reflection;
using System.Reflection.Metadata;
using System.Runtime.Loader;
using CringeBootstrap;
using CringeBootstrap.Abstractions;
+using CringeBootstrap.CrossGen;
using Velopack;
#if DEBUG
@@ -41,15 +41,45 @@ AssemblyLoadContext.Default.Resolving += (loadContext, name) =>
#endif
var dir = Path.GetDirectoryName(args[0])!;
-var context = new GameDirectoryAssemblyLoadContext(dir);
+var gameDir = dir;
+
+var customEntrypoint = Environment.GetEnvironmentVariable("DOTNET_BOOTSTRAP_ENTRYPOINT");
+
+if (
+#if !DEBUG // disable crossgen for plugins userdev, but leave for debug
+ customEntrypoint is null &&
+#endif
+ !args.Contains("--skip-crossgen", StringComparer.OrdinalIgnoreCase))
+{
+ var cacheDir = Directory.CreateDirectory(Path.Join(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "CringeLauncher", "cache"));
+
+ var crossGenService = new CrossGenService(gameDir, cacheDir.FullName);
+
+ try
+ {
+ dir = crossGenService.RunCrossGen();
+ }
+ catch (Exception e)
+ {
+ Console.ForegroundColor = ConsoleColor.Red;
+ Console.WriteLine("Crossgen encountered a fatal error and will be skipped for this session.");
+ Console.ResetColor();
+ Console.WriteLine(e);
+
+ crossGenService.CleanCache();
+ }
+}
+
+var context = new GameDirectoryAssemblyLoadContext(dir, gameDir);
// a list of assemblies which are not in the game binaries but reference them
context.AddDependencyOverride("CringeLauncher");
context.AddDependencyOverride("CringePlugins");
context.AddDependencyOverride("EOSSDK");
-var entrypoint = Environment.GetEnvironmentVariable("DOTNET_BOOTSTRAP_ENTRYPOINT") ??
- "CringeLauncher.Launcher, CringeLauncher";
+var entrypoint = customEntrypoint ?? "CringeLauncher.Launcher, CringeLauncher";
if (!TypeName.TryParse(entrypoint, out var entrypointName) ||
entrypointName.AssemblyName is null)
{