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) {