using Microsoft.CodeAnalysis; using Sandbox; using System.Text.Json; namespace Facepunch.PackageBuild; /// /// Headless, managed-only package compiler shipped inside the engine build (game/bin/managed). Takes a package /// revision's .cll code archives, compiles them with this build's own Sandbox.Compiling + /// Sandbox.Generator against this build's managed assemblies, and writes the resulting .dll(s) plus a /// JSON result. /// /// The sbox.web CLL compile worker downloads the engine build for a given commit and invokes this tool, so each /// package is compiled with the exact engine version it targets — the RPC/serialization hashes always match that /// engine's clients. See sbox.web Docs/CompileToolchainVersioning.md. /// /// It only drives the compiler (no engine/native boot), so the worker needs just the managed assemblies to run it. /// /// /// PackageBuild --archive <file.cll> [--archive ...] --out <dir> [--refs <dir> ...] /// --archive, -a (>=1) A .cll code archive to compile — the package's own code plus any bundled deps. /// --out, -o (required) Directory to write compiled .dll files into. /// --refs, -r A directory of managed reference DLLs to compile against. Repeatable. /// Defaults to this tool's own directory (the build's bin/managed). /// /// /// stdout: one JSON object — { success, dlls: [{ assemblyName, file }], errors: [] }. /// stderr: human-readable progress/diagnostics. Exit code 0 = success, 1 = failure or bad args. /// static class Program { public static async Task Main( string[] args ) { try { return await RunAsync( args ); } catch ( Exception e ) { return Fail( $"PackageBuild crashed: {e}" ); } } static async Task RunAsync( string[] args ) { var archivePaths = new List(); var refDirs = new List(); string outDir = null; for ( var i = 0; i < args.Length; i++ ) { switch ( args[i] ) { case "--archive" or "-a" when i + 1 < args.Length: archivePaths.Add( args[++i] ); break; case "--out" or "-o" when i + 1 < args.Length: outDir = args[++i]; break; case "--refs" or "-r" when i + 1 < args.Length: refDirs.Add( args[++i] ); break; default: if ( args[i].EndsWith( ".cll", StringComparison.OrdinalIgnoreCase ) ) archivePaths.Add( args[i] ); break; } } if ( archivePaths.Count == 0 || string.IsNullOrEmpty( outDir ) ) return Fail( "Usage: PackageBuild --archive [--archive ...] --out [--refs ...]" ); // Default references to this tool's own directory — in a shipped build that is game/bin/managed, i.e. the // exact engine assemblies + toolchain this build ships. if ( refDirs.Count == 0 ) refDirs.Add( AppContext.BaseDirectory ); var archives = new List(); foreach ( var path in archivePaths ) { if ( !File.Exists( path ) ) return Fail( $"Archive not found: {path}" ); archives.Add( new ArchiveEntry { Archive = new CodeArchive( await File.ReadAllBytesAsync( path ) ) } ); } Console.Error.WriteLine( $"Compiling {archives.Count} archive(s) against {refDirs.Count} reference dir(s)..." ); var result = await CompileArchivesAsync( archives, refDirs ); if ( !result.Success ) { foreach ( var error in result.Errors ) Console.Error.WriteLine( $" {error}" ); WriteResult( new CompileResult { Success = false, Errors = result.Errors } ); return 1; } Directory.CreateDirectory( outDir ); var fullOut = Path.GetFullPath( outDir ); var written = new List(); foreach ( var file in result.Files ) { if ( file.AssemblyData is null ) continue; // AssemblyName comes from the archive's CompilerName (untrusted), so make sure it can't escape --out. var dllPath = Path.GetFullPath( Path.Combine( outDir, $"{file.AssemblyName}.dll" ) ); if ( !dllPath.StartsWith( fullOut + Path.DirectorySeparatorChar, StringComparison.Ordinal ) ) return Fail( $"Refusing to write outside --out: assembly name '{file.AssemblyName}' resolves to '{dllPath}'." ); await File.WriteAllBytesAsync( dllPath, file.AssemblyData ); Console.Error.WriteLine( $" -> {dllPath} ({file.AssemblyData.Length:N0} bytes)" ); written.Add( new CompiledDll { AssemblyName = file.AssemblyName, File = dllPath } ); } WriteResult( new CompileResult { Success = true, Dlls = written } ); return 0; } static int Fail( string error ) { Console.Error.WriteLine( error ); WriteResult( new CompileResult { Success = false, Errors = [error] } ); return 1; } static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; static void WriteResult( CompileResult result ) => Console.Out.WriteLine( JsonSerializer.Serialize( result, JsonOpts ) ); // --- compile core (low-level CompileGroup path, ported from the sbox.web CLL worker — no engine boot) --- static async Task CompileArchivesAsync( List archives, List refDirs ) { var result = new CompilationOutput(); using var group = new CompileGroup( "package-build" ); group.ReferenceProvider = new EngineReferenceProvider( refDirs ); group.PrintErrorsInConsole = false; CompileGroup.SuppressBuildNotifications = false; foreach ( var entry in archives ) group.GetOrCreateCompiler( entry.Archive.CompilerName ).UpdateFromArchive( entry.Archive ); // BuildAsync handles dependency ordering, parallel compilation, Razor, source generators, blacklist walker, Emit. if ( !await group.BuildAsync() ) { result.Errors = group.BuildResult.Diagnostics .Where( d => d.Severity >= DiagnosticSeverity.Error ) .Select( FormatDiagnostic ) .ToList(); // A build exception (e.g. a missing reference) isn't a compile diagnostic; don't report success-with-no-output. if ( result.Errors.Count == 0 ) result.Errors.Add( "Build failed with an internal build exception (no compile diagnostics)" ); return result; } foreach ( var output in group.BuildResult.Output ) { result.Files.Add( new CompiledFile { AssemblyName = output.Compiler.AssemblyName, AssemblyData = output.AssemblyData } ); } return result; } static string FormatDiagnostic( Diagnostic d ) { var location = d.Location?.SourceTree?.FilePath; var line = d.Location?.GetLineSpan().StartLinePosition; var loc = location is not null ? $" - {location}:{line}" : ""; return $"{d.GetMessage()}{loc}"; } } /// /// Resolves metadata references from directories of managed DLLs. Enumerates paths up front (cheap) and only /// builds a for names the compiler actually looks up (each tool run is one job, /// so lazy loading avoids parsing hundreds of unused engine DLLs per compile). /// class EngineReferenceProvider : ICompileReferenceProvider { readonly Dictionary _paths = new( StringComparer.OrdinalIgnoreCase ); readonly Dictionary _cache = new( StringComparer.OrdinalIgnoreCase ); public EngineReferenceProvider( IEnumerable dirs ) { foreach ( var dir in dirs ) { if ( !Directory.Exists( dir ) ) continue; // Recurse (bin/managed lays some DLLs out in subfolders); first name wins. foreach ( var dll in Directory.GetFiles( dir, "*.dll", SearchOption.AllDirectories ) ) _paths.TryAdd( Path.GetFileNameWithoutExtension( dll ), dll ); } } public PortableExecutableReference Lookup( string reference ) { if ( _cache.TryGetValue( reference, out var cached ) ) return cached; if ( !_paths.TryGetValue( reference, out var path ) ) return null; try { return _cache[reference] = MetadataReference.CreateFromFile( path ); } catch { return _cache[reference] = null; } // skip unreadable / native DLLs } } class ArchiveEntry { public CodeArchive Archive { get; set; } } class CompilationOutput { public List Files { get; set; } = []; public List Errors { get; set; } = []; public bool Success => Errors.Count == 0; } class CompiledFile { public string AssemblyName { get; set; } public byte[] AssemblyData { get; set; } } class CompileResult { public bool Success { get; set; } public List Dlls { get; set; } = []; public List Errors { get; set; } = []; } class CompiledDll { public string AssemblyName { get; set; } public string File { get; set; } }