using System.Collections.Concurrent; using System.Diagnostics; using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using RadicadeServer.DB; using RadicadeServer.Storage; using RadicadeServer.Utils; namespace RadicadeServer.API.Studio; public sealed class StudioBuildWorker : BackgroundService { private const byte WindowsDesktopTarget = 0; private const int UnityBundleVersion = 1; private readonly ConcurrentQueue jobs = new(); private readonly SemaphoreSlim signal = new(0); private readonly ILogger logger; private readonly IConfiguration configuration; public StudioBuildWorker(ILogger logger, IConfiguration configuration) { this.logger = logger; this.configuration = configuration; } public void Enqueue(BuildJob job) { jobs.Enqueue(job); signal.Release(); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { await signal.WaitAsync(stoppingToken); if (!jobs.TryDequeue(out var job)) continue; try { await BuildWindowsAsync(job, stoppingToken); } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Local Studio Windows build failed for {CloudBuildId}.", job.Build.CloudBuildId); job.Update(job.Build with { CompletedAt = DateTime.UtcNow, Error = CleanError(ex.GetBaseException().Message), UnityAssetId = string.Empty }); } } } private async Task BuildWindowsAsync(BuildJob job, CancellationToken cancellationToken) { var unityPath = configuration["Radicade:Studio:UnityPath"]; var projectPath = configuration["Radicade:Studio:ProjectPath"]; if (string.IsNullOrWhiteSpace(unityPath) || !File.Exists(unityPath)) throw new InvalidOperationException("Configure Radicade:Studio:UnityPath to the installed Unity 2020.3.33f1 Editor.exe."); if (string.IsNullOrWhiteSpace(projectPath) || !Directory.Exists(projectPath)) throw new InvalidOperationException("Configure Radicade:Studio:ProjectPath to the complete local Rec Room Studio ProjectTemplate."); var source = await GetBuildSourceAsync(job, cancellationToken); var manifestAssetId = source.ManifestAssetId; if (!StudioUnityAssetRegistry.IsValidAssetId(manifestAssetId)) throw new InvalidOperationException("The saved room has no valid Studio source-manifest ID; upload the room from Studio again."); var manifest = await StudioUnityAssetRegistry.ReadAsync(manifestAssetId, cancellationToken); if (manifest == null || string.IsNullOrWhiteSpace(manifest.Filename)) throw new InvalidOperationException("Studio source manifest was not found in local CDN storage."); var manifestFile = Path.Combine(DBConfig.CDNStorage, manifest.Filename); using var document = JsonDocument.Parse(await File.ReadAllBytesAsync(manifestFile, cancellationToken)); if (!document.RootElement.TryGetProperty("Version", out var versionElement) || versionElement.ValueKind != JsonValueKind.Number || versionElement.GetInt32() is < 1 or > 2) throw new InvalidDataException("This worker requires source manifest version 1 or 2."); if (!document.RootElement.TryGetProperty("MainSceneProjectRelativePath", out var scenePathElement) || scenePathElement.ValueKind != JsonValueKind.String || !document.RootElement.TryGetProperty("Files", out var filesElement) || filesElement.ValueKind != JsonValueKind.Array) throw new InvalidDataException("Source manifest does not contain a scene path and file list."); var scenePath = scenePathElement.GetString(); if (string.IsNullOrWhiteSpace(scenePath) || !IsSafeAssetPath(scenePath)) throw new InvalidDataException("Source manifest has an invalid main-scene path."); var sourceFiles = filesElement.EnumerateArray().ToArray(); if (sourceFiles.Length == 0) throw new InvalidDataException("Source manifest file list is empty."); var sourcePaths = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var file in sourceFiles) { if (!file.TryGetProperty("p", out var pathElement) || pathElement.ValueKind != JsonValueKind.String || !file.TryGetProperty("n", out var storageElement) || storageElement.ValueKind != JsonValueKind.String) throw new InvalidDataException("Source manifest contains a file without a path or storage filename."); var sourcePath = pathElement.GetString(); var storageName = storageElement.GetString(); if (!IsSafeAssetPath(sourcePath) || !StudioUnityAssetRegistry.IsValidAssetId(storageName)) throw new InvalidDataException("Source manifest contains an invalid path or storage filename."); if (!sourcePaths.Add(sourcePath!)) throw new InvalidDataException("Source manifest contains a duplicate project path."); if (file.TryGetProperty("g", out var guidElement) && guidElement.ValueKind == JsonValueKind.String) { var guid = guidElement.GetString(); if (!string.IsNullOrWhiteSpace(guid) && (guid.Length != 32 || !guid.All(Uri.IsHexDigit))) throw new InvalidDataException("Source manifest contains an invalid Unity GUID."); } } if (!sourcePaths.Contains(scenePath!)) throw new InvalidDataException("Manifest main scene path is not present in the file list."); var scenePathValue = scenePath!; var sceneName = Path.GetFileNameWithoutExtension(scenePathValue); if (string.IsNullOrWhiteSpace(sceneName)) throw new InvalidDataException("Source manifest has an invalid main-scene name."); projectPath = Path.GetFullPath(projectPath); var assetsPath = Path.GetFullPath(Path.Combine(projectPath, "Assets")); var editorDir = Path.Combine(assetsPath, "Editor"); var editorDirectoryExisted = Directory.Exists(editorDir); var editorMetaExisted = File.Exists(editorDir + ".meta"); var runnerPath = Path.Combine(editorDir, "RadicadeLocalStudioBuild.cs"); var runnerMetaPath = runnerPath + ".meta"; if (editorDirectoryExisted && (File.Exists(runnerPath) || File.Exists(runnerMetaPath))) throw new InvalidOperationException("Assets/Editor/RadicadeLocalStudioBuild.cs already exists; refusing to overwrite project content."); var buildRoot = Path.Combine(DBConfig.CDNStorage, ".studio-builds", job.Build.CloudBuildId); Directory.CreateDirectory(buildRoot); if (Directory.EnumerateFileSystemEntries(buildRoot).Any()) throw new InvalidOperationException("This build ID already has worker files; refusing to reuse its output directory."); var runnerContents = CreateRunnerSource(); var argsFile = Path.Combine(buildRoot, "build-args.json"); var sourceGuid = Guid.NewGuid().ToString("N"); var ownsEditorDirectory = false; var ownsRunner = false; var ownsRunnerMeta = false; var args = new BuildArguments(manifestAssetId, manifest.Filename, scenePathValue, sceneName, job.RoomId, job.SubRoomId, job.SaveId, job.AccountId, job.CloudBuildId, configuration["Radicade:Studio:LocalHttpBaseUrl"]?.TrimEnd('/') ?? "http://127.0.0.1:3031", source.RoomJson, source.SubRoomJson); try { if (!Directory.Exists(editorDir)) { Directory.CreateDirectory(editorDir); ownsEditorDirectory = !editorDirectoryExisted; } await File.WriteAllTextAsync(argsFile, JsonSerializer.Serialize(args), cancellationToken); await using (var runnerFile = new FileStream(runnerPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, useAsync: true)) { ownsRunner = true; await runnerFile.WriteAsync(Encoding.UTF8.GetBytes(runnerContents), cancellationToken); } await using (var runnerMetaFile = new FileStream(runnerMetaPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, useAsync: true)) { ownsRunnerMeta = true; var metadata = $"fileFormatVersion: 2\nguid: {sourceGuid}\nMonoImporter:\n externalObjects: {{}}\n serializedVersion: 2\n defaultReferences: []\n executionOrder: 0\n icon: {{instanceID: 0}}\n userData: \n assetBundleName: \n assetBundleVariant: \n"; await runnerMetaFile.WriteAsync(Encoding.UTF8.GetBytes(metadata), cancellationToken); } await RunUnityAsync(unityPath, projectPath, argsFile, buildRoot, cancellationToken); var resultPath = Path.Combine(buildRoot, "build-result.json"); if (!File.Exists(resultPath)) throw new InvalidOperationException("Unity exited without producing a build result."); var result = JsonSerializer.Deserialize(await File.ReadAllTextAsync(resultPath, cancellationToken), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidDataException("Unity returned an invalid build result."); if (!string.IsNullOrWhiteSpace(result.Error)) throw new InvalidOperationException(result.Error); if (result.BundlePaths == null || result.BundlePaths.Length is < 1 or > 2 || result.BundlePaths.Any(path => !File.Exists(path))) throw new InvalidDataException("Unity did not produce the Windows room asset bundles."); var uploadedBundles = new List(); foreach (var bundlePath in result.BundlePaths) { await using var bundle = File.OpenRead(bundlePath); uploadedBundles.Add(await StudioAssetStorage.Save(bundle, cancellationToken)); } await StudioUnityAssetRegistry.AddBakedAssetAsync(manifestAssetId, new StudioBakedUnityAsset(manifestAssetId, WindowsDesktopTarget, UnityBundleVersion, uploadedBundles[0].Filename, uploadedBundles.Count > 1 ? uploadedBundles[1].Filename : null), cancellationToken); job.Update(job.Build with { CompletedAt = DateTime.UtcNow, Error = null, UnityAssetId = manifestAssetId }); logger.LogInformation("Local Studio Windows build finished for {CloudBuildId}; uploaded {BundleCount} bundles.", job.Build.CloudBuildId, uploadedBundles.Count); } finally { if (ownsRunner) { try { File.Delete(runnerPath); } catch (IOException ex) { logger.LogWarning(ex, "Could not remove temporary Studio build runner {RunnerPath}.", runnerPath); } } if (ownsRunnerMeta) { try { File.Delete(runnerMetaPath); } catch (IOException ex) { logger.LogWarning(ex, "Could not remove temporary Studio build runner metadata {RunnerPath}.", runnerMetaPath); } } if (ownsEditorDirectory && Directory.Exists(editorDir) && !Directory.EnumerateFileSystemEntries(editorDir).Any()) { try { Directory.Delete(editorDir); if (!editorMetaExisted) File.Delete(editorDir + ".meta"); } catch (IOException ex) { logger.LogWarning(ex, "Could not remove temporary Studio Editor directory {EditorDirectory}.", editorDir); } } } } private async Task GetBuildSourceAsync(BuildJob job, CancellationToken cancellationToken) { await using var db = await job.DbFactory.CreateDbContextAsync(cancellationToken); var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == job.RoomId, cancellationToken) ?? throw new InvalidOperationException("The requested room no longer exists."); var subRoom = await db.SubRooms.AsNoTracking() .FirstOrDefaultAsync(x => x.RoomId == job.RoomId && x.SubRoomId == job.SubRoomId, cancellationToken) ?? throw new InvalidOperationException("The requested room subroom no longer exists."); if (job.SaveId != 1) throw new InvalidOperationException("Only the current saved Studio room version can be built on this server."); var save = RoomUtils.BuildCurrentSave(subRoom); var roomDto = new { RoomId = room.RoomId, Name = room.Name, Description = room.Description, ImageName = room.ImageName, Accessibility = (int)room.Accessibility, SupportsScreens = room.SupportsScreens, SupportsWalkVR = room.SupportsWalkVR, SupportsTeleportVR = room.SupportsTeleportVR, SupportsJuniors = room.SupportsJuniors, WarningMask = (int)room.WarningMask, CustomWarning = room.CustomWarning, MaxPlayerCalculationMode = (int)room.MaxPlayerCalculationMode, CreatedAt = room.CreatedAt, SubRooms = Array.Empty(), Tags = Array.Empty(), Roles = Array.Empty() }; var subRoomDto = new { SubRoomId = subRoom.SubRoomId, Name = subRoom.Name, UnitySceneId = subRoom.UnitySceneId, MaxPlayers = subRoom.MaxPlayers, CurrentSave = new { SubRoomDataSaveId = save.SubRoomDataSaveId, SubRoomId = save.SubRoomId, UnityAssetId = save.UnityAssetId, DataBlob = save.DataBlob, DataBlobHash = (string?)null, PersistenceVersion = save.PersistenceVersion, OMVersion = (byte)save.OMVersion, SavedByAccountId = save.SavedByAccountId == null ? null : (int?)save.SavedByAccountId, Description = save.Description, CreatedAt = save.CreatedAt }, Accessibility = (int)subRoom.Accessibility }; var serializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = null }; return new BuildSource(subRoom.UnityAssetId, JsonSerializer.Serialize(roomDto, serializerOptions), JsonSerializer.Serialize(subRoomDto, serializerOptions)); } private async Task RunUnityAsync(string unityPath, string projectPath, string argsFile, string buildRoot, CancellationToken cancellationToken) { var editorLog = Path.Combine(buildRoot, "unity-editor.log"); var startInfo = new ProcessStartInfo { FileName = unityPath, WorkingDirectory = projectPath, UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; foreach (var argument in new[] { "-batchmode", "-nographics", "-logFile", editorLog, "-projectPath", projectPath, "-executeMethod", "RadicadeLocalStudioBuild.Build", "-radicadeStudioBuildArgs", argsFile }) startInfo.ArgumentList.Add(argument); using var process = new Process { StartInfo = startInfo }; if (!process.Start()) throw new InvalidOperationException("Could not start Unity batch build."); var stdout = process.StandardOutput.ReadToEndAsync(cancellationToken); var stderr = process.StandardError.ReadToEndAsync(cancellationToken); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromMinutes(30)); try { await process.WaitForExitAsync(timeout.Token); } catch (OperationCanceledException) { try { process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { } if (!cancellationToken.IsCancellationRequested) throw new TimeoutException("Unity build exceeded the 30 minute worker timeout."); throw; } var output = (await stdout) + Environment.NewLine + (await stderr); if (process.ExitCode == 0) return; var logText = File.Exists(editorLog) ? await File.ReadAllTextAsync(editorLog, cancellationToken) : string.Empty; var lastMessage = (output + Environment.NewLine + logText).Split('\n', StringSplitOptions.RemoveEmptyEntries) .Select(x => x.Trim()).LastOrDefault(x => x.Contains("Error", StringComparison.OrdinalIgnoreCase) || x.Contains("Exception", StringComparison.OrdinalIgnoreCase)); throw new InvalidOperationException(string.IsNullOrWhiteSpace(lastMessage) ? $"Unity batch build failed with exit code {process.ExitCode}. See worker log {editorLog}." : $"Unity batch build failed: {lastMessage}"); } private static bool IsSafeAssetPath(string? path) => !string.IsNullOrWhiteSpace(path) && path.StartsWith("Assets/", StringComparison.Ordinal) && !Path.IsPathRooted(path) && !path.Contains('\\') && !path.Contains(':') && path.Split('/').All(segment => segment.Length > 0 && segment is not ("." or "..")) && path.IndexOfAny(Path.GetInvalidPathChars()) < 0; private static string CleanError(string message) => message.Length <= 1200 ? message : message[..1200]; private static string CreateRunnerSource() => """ using System; using System.IO; using System.Linq; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; using System.Text.Json; using UnityEditor.Build.Pipeline; using UnityEditor.Build.Pipeline.Utilities; using UnityEditor.Build.Pipeline.Interfaces; using System.Threading.Tasks; using RecRoom.Core.Studio; public static class RadicadeLocalStudioBuild { [Serializable] public sealed class BuildArguments { public string ManifestAssetId { get; set; } public string ManifestFilename { get; set; } public string ScenePath { get; set; } public string SceneName { get; set; } public long RoomId { get; set; } public long SubRoomId { get; set; } public long SaveId { get; set; } public long AccountId { get; set; } public string CloudBuildId { get; set; } public string LocalHttpBaseUrl { get; set; } public string RoomJson { get; set; } public string SubRoomJson { get; set; } } [Serializable] public sealed class SourceManifest { public string MainSceneProjectRelativePath { get; set; } public SourceFile[] Files { get; set; } } [Serializable] public sealed class SourceFile { [System.Text.Json.Serialization.JsonPropertyName("n")] public string n { get; set; } [System.Text.Json.Serialization.JsonPropertyName("p")] public string p { get; set; } [System.Text.Json.Serialization.JsonPropertyName("g")] public string g { get; set; } } public sealed class BuildResult { public string[] BundlePaths { get; set; } = Array.Empty(); public string Error { get; set; } } public static async void Build() { var argsPath = ReadArg("-radicadeStudioBuildArgs"); var args = JsonSerializer.Deserialize(File.ReadAllText(argsPath)) ?? throw new InvalidDataException("Build arguments file was invalid."); var resultPath = Path.Combine(Path.GetDirectoryName(argsPath), "build-result.json"); var result = new BuildResult(); var projectRoot = Directory.GetParent(Application.dataPath).FullName; var stagingName = "RadicadeBuildTemp-" + args.CloudBuildId; var roomSource = Path.Combine(Application.dataPath, stagingName); var stagingAssetPath = "Assets/" + stagingName; var ownsStagingFolder = false; try { var projectRootFull = Path.GetFullPath(projectRoot) + Path.DirectorySeparatorChar; if (Directory.Exists(roomSource)) throw new InvalidOperationException("Worker staging folder already exists; refusing to overwrite it."); Directory.CreateDirectory(roomSource); ownsStagingFolder = true; using var client = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(90) }; var manifestUrl = args.LocalHttpBaseUrl + "/studiocdn/roomunityasset/" + Uri.EscapeDataString(args.ManifestFilename); var manifest = JsonSerializer.Deserialize(client.GetStringAsync(manifestUrl).GetAwaiter().GetResult()) ?? throw new InvalidDataException("Source manifest response was invalid."); if (manifest == null || manifest.Files == null) throw new InvalidDataException("Source manifest response was invalid."); if (!String.Equals(manifest.MainSceneProjectRelativePath, args.ScenePath, StringComparison.Ordinal)) throw new InvalidDataException("Queued scene path does not match the source manifest."); if (!manifest.Files.Any(file => String.Equals(file.p, args.ScenePath, StringComparison.OrdinalIgnoreCase))) throw new InvalidDataException("The source manifest does not include its main scene."); var sourcePaths = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); foreach (var file in manifest.Files) { var storageName = file.n; var relativePath = file.p; if (String.IsNullOrWhiteSpace(storageName) || storageName.Length != 40 || !storageName.All(character => character <= 0x7f && Char.IsLetterOrDigit(character)) || String.IsNullOrWhiteSpace(relativePath) || !relativePath.StartsWith("Assets/", StringComparison.Ordinal) || Path.IsPathRooted(relativePath) || relativePath.Contains('\\') || relativePath.Contains(':') || relativePath.Split('/').Any(segment => segment.Length == 0 || segment == ".." || segment == ".") || relativePath.IndexOfAny(Path.GetInvalidPathChars()) >= 0 || !sourcePaths.Add(relativePath)) throw new InvalidDataException("The source manifest contains an unsafe or duplicate path."); if (!String.IsNullOrEmpty(file.g) && (file.g.Length != 32 || !file.g.All(Uri.IsHexDigit))) throw new InvalidDataException("The source manifest contains an invalid Unity GUID."); var stagingRelativePath = stagingAssetPath + relativePath.Substring("Assets".Length); var destination = Path.GetFullPath(Path.Combine(projectRoot, stagingRelativePath.Replace('/', Path.DirectorySeparatorChar))); if (!destination.StartsWith(projectRootFull, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Manifest path escaped the project."); if (relativePath.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) { var metaAssetPath = "Assets/" + stagingName + relativePath.Substring("Assets".Length); var targetAssetPath = metaAssetPath.Substring(0, metaAssetPath.Length - ".meta".Length); if (!manifest.Files.Any(x => String.Equals(x.p, targetAssetPath, StringComparison.OrdinalIgnoreCase))) throw new InvalidDataException("Manifest contains a .meta file without its source asset."); } Directory.CreateDirectory(Path.GetDirectoryName(destination)); var bytes = client.GetByteArrayAsync(args.LocalHttpBaseUrl + "/studiocdn/unityasset/" + Uri.EscapeDataString(storageName)).GetAwaiter().GetResult(); File.WriteAllBytes(destination, bytes); if (relativePath.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) { var guidLine = File.ReadLines(destination).Select(line => line.Trim()) .FirstOrDefault(line => line.StartsWith("guid:", StringComparison.Ordinal)); var expectedGuid = manifest.Files.First(x => String.Equals(x.p, relativePath.Substring(0, relativePath.Length - ".meta".Length), StringComparison.OrdinalIgnoreCase)).g; if (guidLine == null || String.IsNullOrWhiteSpace(expectedGuid) || !String.Equals(guidLine.Substring("guid:".Length).Trim(), expectedGuid, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("A source .meta file does not match its manifest GUID."); } if (!relativePath.EndsWith(".meta", StringComparison.OrdinalIgnoreCase) && !String.IsNullOrWhiteSpace(file.g)) { var metaPath = destination + ".meta"; if (File.Exists(metaPath)) { var existingGuid = File.ReadLines(metaPath) .Select(line => line.Trim()) .FirstOrDefault(line => line.StartsWith("guid:", StringComparison.Ordinal)); if (existingGuid != null && !String.Equals(existingGuid.Substring("guid:".Length).Trim(), file.g, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("A source .meta file does not match its manifest GUID."); if (existingGuid == null) throw new InvalidDataException("A source .meta file has no Unity GUID."); } else File.WriteAllText(metaPath, "fileFormatVersion: 2\nguid: " + file.g + "\n"); } } if (!manifest.Files.Any(x => String.Equals(x.p, args.ScenePath + ".meta", StringComparison.OrdinalIgnoreCase)) && String.IsNullOrWhiteSpace(manifest.Files.First(x => String.Equals(x.p, args.ScenePath, StringComparison.OrdinalIgnoreCase)).g)) throw new InvalidDataException("Main scene has neither a .meta file nor a Unity GUID."); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); var scenePath = stagingAssetPath + args.ScenePath.Substring("Assets".Length); var scene = AssetDatabase.LoadAssetAtPath(scenePath); if (scene == null) throw new InvalidDataException("Studio main scene was not imported: " + scenePath); var openedScene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); var roomModel = CreateRoomModel(args); InvokeRoomPreparation(openedScene, roomModel); var output = Path.Combine(Path.GetDirectoryName(argsPath), "output"); Directory.CreateDirectory(output); var rejectedDependencyPaths = GetRejectedDependencyPaths(scene); if (EditorUserBuildSettings.activeBuildTarget != (BuildTarget)5 && !EditorUserBuildSettings.SwitchActiveBuildTarget((BuildTargetGroup)1, (BuildTarget)5)) throw new InvalidOperationException("Unity could not switch to the Windows standalone build target."); await Task.Delay(TimeSpan.FromSeconds(5)); const int version = 1; var sceneBundleName = "content.0." + version + ".assetbundle"; var rejectedBundleName = "rejectedDeps.0." + version + ".assetbundle"; var builds = rejectedDependencyPaths.Count == 0 ? new[] { new AssetBundleBuild { assetBundleName = sceneBundleName, assetBundleVariant = null, assetNames = new[] { scenePath }, addressableNames = null } } : new[] { new AssetBundleBuild { assetBundleName = sceneBundleName, assetBundleVariant = null, assetNames = new[] { scenePath }, addressableNames = null }, new AssetBundleBuild { assetBundleName = rejectedBundleName, assetBundleVariant = null, assetNames = rejectedDependencyPaths.ToArray(), addressableNames = null } }; var parameters = new BundleBuildParameters((BuildTarget)5, (BuildTargetGroup)1, output) { BundleCompression = BuildCompression.LZ4 }; var content = new BundleBuildContent((System.Collections.Generic.IEnumerable)builds); IBundleBuildResults buildResults = new BundleBuildResults(); var code = ContentPipeline.BuildAssetBundles((IBundleBuildParameters)parameters, (IBundleBuildContent)content, ref buildResults); if ((int)code != 0 && (int)code != 1) throw new InvalidOperationException("Asset bundle pipeline returned " + code); var sceneBundlePath = Path.Combine(output, sceneBundleName); var rejectedBundlePath = Path.Combine(output, rejectedBundleName); if (!File.Exists(sceneBundlePath)) throw new InvalidOperationException("Unity did not create the Windows scene bundle."); if (rejectedDependencyPaths.Count == 0) { if (File.Exists(rejectedBundlePath)) File.Delete(rejectedBundlePath); result.BundlePaths = new[] { sceneBundlePath }; } else { if (!File.Exists(rejectedBundlePath)) throw new InvalidOperationException("Unity did not create the Windows rejected-dependency bundle."); result.BundlePaths = new[] { sceneBundlePath, rejectedBundlePath }; } AssetDatabase.DeleteAsset(stagingAssetPath); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); } catch (Exception ex) { result.Error = ex.GetBaseException().Message; Debug.LogException(ex); } finally { try { if (ownsStagingFolder) { AssetDatabase.DeleteAsset(stagingAssetPath); if (Directory.Exists(roomSource)) Directory.Delete(roomSource, true); var stagingMeta = roomSource + ".meta"; if (File.Exists(stagingMeta)) File.Delete(stagingMeta); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); } } catch (IOException) { } } File.WriteAllText(resultPath, JsonSerializer.Serialize(result)); EditorApplication.Exit(String.IsNullOrEmpty(result.Error) ? 0 : 1); } private static object CreateRoomModel(BuildArguments args) { var roomType = FindType("RoomDetailsDTO"); var subRoomType = FindType("SubRoomDTO"); var saveType = FindType("SubRoomDataSaveDTO"); var room = Activator.CreateInstance(roomType, true); var subRoom = Activator.CreateInstance(subRoomType, true); var save = Activator.CreateInstance(saveType, true); PopulateProperties(room, args.RoomJson); PopulateProperties(subRoom, args.SubRoomJson); PopulateProperties(save, ExtractCurrentSaveJson(args.SubRoomJson)); subRoomType.GetProperty("CurrentSave").SetValue(subRoom, save); var subRoomsProperty = roomType.GetProperty("SubRooms"); var subRooms = (System.Collections.IList)Activator.CreateInstance(typeof(System.Collections.Generic.List<>).MakeGenericType(subRoomsProperty.PropertyType.GetGenericArguments()[0])); subRooms.Add(subRoom); subRoomsProperty.SetValue(room, subRooms); var modelType = FindType("BOFMOKEGMFC"); var constructor = modelType.GetConstructors(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) .Single(x => x.GetParameters().Length == 2); return constructor.Invoke(new[] { room, subRoom }); } private static string ExtractCurrentSaveJson(string json) { using var document = JsonDocument.Parse(json); return document.RootElement.GetProperty("CurrentSave").GetRawText(); } private static void PopulateProperties(object target, string json) { using var document = JsonDocument.Parse(json); foreach (var property in target.GetType().GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public)) { if (!property.CanWrite || !document.RootElement.TryGetProperty(property.Name, out var value)) continue; object converted; if (property.PropertyType.IsEnum) converted = Enum.ToObject(property.PropertyType, value.GetInt32()); else if (Nullable.GetUnderlyingType(property.PropertyType)?.IsEnum == true) converted = Enum.ToObject(Nullable.GetUnderlyingType(property.PropertyType), value.GetInt32()); else converted = System.Text.Json.JsonSerializer.Deserialize(value.GetRawText(), property.PropertyType); property.SetValue(target, converted); } } private static void InvokeRoomPreparation(Scene scene, object roomModel) { var type = FindType("CKMKDCCLONK"); var instance = type.GetProperty("ONCPNMDGGKG", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic).GetValue(null); var method = type.GetMethod("MDEOKEFNEIO", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public); method.Invoke(instance, new[] { (object)scene, Activator.CreateInstance(typeof(Scene)), roomModel }); } private static System.Collections.Generic.List GetRejectedDependencyPaths(SceneAsset scene) { var type = FindType("PCCMIBMBNDP"); var instance = type.GetProperty("ONCPNMDGGKG", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic).GetValue(null); var method = type.GetMethod("OCDABPILJAK", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public); var arguments = new object[] { scene, null }; method.Invoke(instance, arguments); return (System.Collections.Generic.List)arguments[1]; } private static Type FindType(string name) { var assemblies = AppDomain.CurrentDomain.GetAssemblies(); var type = assemblies.Select(assembly => assembly.GetType("RecRoom.Core.Studio.RecNet." + name, false)) .FirstOrDefault(value => value != null) ?? assemblies.Select(assembly => assembly.GetType(name, false)).FirstOrDefault(value => value != null); if (type != null) return type; foreach (var assembly in assemblies) { try { type = assembly.GetTypes().FirstOrDefault(candidate => candidate.Name == name); if (type != null) return type; } catch (System.Reflection.ReflectionTypeLoadException ex) { type = ex.Types.FirstOrDefault(candidate => candidate?.Name == name); if (type != null) return type; } } throw new InvalidOperationException("Required local Rec Room Studio type was not loaded: " + name); } private static string ReadArg(string name) { var args = Environment.GetCommandLineArgs(); for (var i = 0; i + 1 < args.Length; i++) if (args[i] == name) return args[i + 1]; throw new InvalidOperationException("Missing command-line argument " + name); } } """; private sealed record BuildSource(string ManifestAssetId, string RoomJson, string SubRoomJson); private sealed record BuildArguments(string ManifestAssetId, string ManifestFilename, string ScenePath, string SceneName, long RoomId, long SubRoomId, long SaveId, long AccountId, string CloudBuildId, string LocalHttpBaseUrl, string RoomJson, string SubRoomJson); private sealed record BuildResult(string[] BundlePaths, string? Error); public sealed class BuildJob { public BuildJob(string cloudBuildId, long roomId, long subRoomId, long saveId, long accountId, IDbContextFactory dbFactory, StudioCloudBuild build, Action update) { CloudBuildId = cloudBuildId; RoomId = roomId; SubRoomId = subRoomId; SaveId = saveId; AccountId = accountId; DbFactory = dbFactory; Build = build; Update = update; } public string CloudBuildId { get; } public long RoomId { get; } public long SubRoomId { get; } public long SaveId { get; } public long AccountId { get; } public IDbContextFactory DbFactory { get; } public StudioCloudBuild Build { get; } public Action Update { get; } } }