Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
+646
View File
@@ -0,0 +1,646 @@
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<BuildJob> jobs = new();
private readonly SemaphoreSlim signal = new(0);
private readonly ILogger<StudioBuildWorker> logger;
private readonly IConfiguration configuration;
public StudioBuildWorker(ILogger<StudioBuildWorker> 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<string>(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<BuildResult>(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<StudioAssetUpload>();
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<BuildSource> 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<object>(),
Tags = Array.Empty<object>(),
Roles = Array.Empty<object>()
};
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<string>();
public string Error { get; set; }
}
public static async void Build()
{
var argsPath = ReadArg("-radicadeStudioBuildArgs");
var args = JsonSerializer.Deserialize<BuildArguments>(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<SourceManifest>(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<string>(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<SceneAsset>(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<AssetBundleBuild>)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<string> 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<string>)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<RadicadeServer.DB.RecDBContext> dbFactory, StudioCloudBuild build,
Action<StudioCloudBuild> 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<RadicadeServer.DB.RecDBContext> DbFactory { get; }
public StudioCloudBuild Build { get; }
public Action<StudioCloudBuild> Update { get; }
}
}