mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
647 lines
39 KiB
C#
647 lines
39 KiB
C#
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; }
|
|
}
|
|
}
|