using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using Mocha2023.Classes.DBs.DBClasses; using static Mocha2023.Classes.DBs.DBClasses.RoomDBClasses; namespace Mocha2023.Classes.DBs; public static class ShowdownImporter { private const int CompatiblePersistenceVersion = 38; private const int CompatibleUgcSubVersion = 0; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip, NumberHandling = JsonNumberHandling.AllowReadingFromString }; public sealed record ImportResult( long RoomId, int SubRoomsImported, int SavesImported, int BlobsCopied, int SavesSkipped, string PlayableSubRoomName, long PlayableSubRoomId) { public int BakedAssetsImported { get; init; } public int AssetBundlesCopied { get; init; } public int AssetBundlesMissing { get; init; } public bool ImageCopied { get; init; } public IReadOnlyList UnityEngineVersions { get; init; } = Array.Empty(); } public static ImportResult ImportUnityMetadata( string json, long creatorAccountId = 1, bool replaceExisting = true) { if (string.IsNullOrWhiteSpace(json)) throw new InvalidDataException("The room metadata JSON is empty."); using JsonDocument document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip }); JsonElement root = document.RootElement; foreach (string wrapper in new[] { "room", "Room", "value", "Value", "data", "Data" }) { if (root.ValueKind == JsonValueKind.Object && TryGetProperty(root, wrapper, out JsonElement nested) && nested.ValueKind == JsonValueKind.Object) { root = nested; break; } } ExportRoom exportRoom = JsonSerializer.Deserialize( root.GetRawText(), JsonOptions) ?? throw new InvalidDataException("Could not deserialize the room metadata JSON."); return ImportUnityExport(exportRoom, creatorAccountId, replaceExisting); } public static ImportResult Import( string exportRoot, long creatorAccountId = 1, bool replaceExisting = true) { exportRoot = Path.GetFullPath(exportRoot.Trim().Trim('"')); string roomJsonPath = Path.Combine(exportRoot, "room.json"); if (!File.Exists(roomJsonPath)) throw new FileNotFoundException("room.json was not found.", roomJsonPath); ExportRoom exportRoom = ReadJson(roomJsonPath); if (string.IsNullOrWhiteSpace(exportRoom.Name)) throw new InvalidDataException("room.json does not contain a valid Name."); string roomBlobDestination = Path.Combine(Program.dataDir, "CDN", "room"); string assetBundleDestination = Path.Combine(Program.dataDir, "CDN", "assetbundles"); string imageDestination = Path.Combine(Program.dataDir, "Images"); Directory.CreateDirectory(roomBlobDestination); Directory.CreateDirectory(assetBundleDestination); Directory.CreateDirectory(imageDestination); string? assetBundleSource = FindDirectory(exportRoot, "AssetBundles"); long assignedRoomId = RoomDB.GetNextRoomId(); string normalizedRoomName = exportRoom.Name.Trim().TrimStart('^'); Room? existingByName = RoomDB.Rooms.FindAll().FirstOrDefault(room => string.Equals(room.Name, normalizedRoomName, StringComparison.OrdinalIgnoreCase)); if (replaceExisting) { if (existingByName != null) { RoomDB.SubRoomDataSaves.DeleteMany(save => save.RoomId == existingByName.RoomId); RoomDB.Rooms.Delete(existingByName.RoomId); } } else if (existingByName != null) { throw new InvalidOperationException( $"A room named \"{normalizedRoomName}\" already exists. Enable replaceExisting to overwrite it."); } long nextLocalSaveId = RoomDB.SubRoomDataSaves.Count() == 0 ? 1 : Convert.ToInt64(RoomDB.SubRoomDataSaves.Max(save => save.SubRoomDataSaveId)) + 1; var importedSubRooms = new List(); int savesImported = 0; int blobsCopied = 0; int savesSkipped = 0; int bakedAssetsImported = 0; int assetBundlesCopied = 0; int assetBundlesMissing = 0; var unityEngineVersions = new HashSet(StringComparer.OrdinalIgnoreCase); SubRooms? playableSubRoom = null; SubRoomDataSave? playableCurrentSave = null; foreach (ExportSubRoom listedSubRoom in exportRoom.SubRooms ?? new List()) { string subRoomDirectory = Path.Combine(exportRoot, listedSubRoom.SubRoomId.ToString()); string subRoomJsonPath = Path.Combine(subRoomDirectory, "subRoom.json"); if (!File.Exists(subRoomJsonPath)) { Console.WriteLine($"[ROOM IMPORT] Missing subRoom.json for {listedSubRoom.SubRoomId}; skipped."); continue; } ExportSubRoom sourceSubRoom = ReadJson(subRoomJsonPath); MergeSubRoomMetadata(listedSubRoom, sourceSubRoom); string savesDirectory = Path.Combine(subRoomDirectory, "Saves"); if (!Directory.Exists(savesDirectory)) { Console.WriteLine($"[ROOM IMPORT] Missing Saves directory for {sourceSubRoom.SubRoomId}; skipped."); continue; } var importedForThisSubRoom = new List<(long SourceSaveId, SubRoomDataSave LocalSave)>(); foreach (string saveDirectory in Directory.EnumerateDirectories(savesDirectory)) { string directoryName = Path.GetFileName(saveDirectory); string saveJsonPath = Path.Combine(saveDirectory, directoryName + ".json"); if (!File.Exists(saveJsonPath)) { saveJsonPath = Directory.EnumerateFiles(saveDirectory, "*.json") .FirstOrDefault(path => !Path.GetFileName(path).StartsWith( "bakedUnityAsset-", StringComparison.OrdinalIgnoreCase)) ?? string.Empty; } if (string.IsNullOrWhiteSpace(saveJsonPath) || !File.Exists(saveJsonPath)) { savesSkipped++; continue; } ExportSave sourceSave; try { sourceSave = ReadJson(saveJsonPath); } catch (Exception exception) { Console.WriteLine($"[ROOM IMPORT] Invalid save JSON {saveJsonPath}: {exception.Message}"); savesSkipped++; continue; } string safeBlobName = Path.GetFileName(sourceSave.DataBlob ?? string.Empty); string sourceBlobPath = Path.Combine(saveDirectory, safeBlobName); if (string.IsNullOrWhiteSpace(safeBlobName) || !File.Exists(sourceBlobPath)) { Console.WriteLine($"[ROOM IMPORT] Missing blob for source save {sourceSave.SubRoomDataSaveId}; skipped."); savesSkipped++; continue; } string destinationBlobPath = Path.Combine(roomBlobDestination, safeBlobName); if (CopyIfDifferent(sourceBlobPath, destinationBlobPath)) blobsCopied++; var bakedAssets = new List(); foreach (string bakedJsonPath in Directory.EnumerateFiles( saveDirectory, "bakedUnityAsset-*.json", SearchOption.TopDirectoryOnly)) { BakedUnityAsset? bakedAsset; try { bakedAsset = ReadJson(bakedJsonPath); } catch (Exception exception) { Console.WriteLine($"[ROOM IMPORT] Invalid baked asset JSON {bakedJsonPath}: {exception.Message}"); continue; } bakedAsset.UnityAssetId = string.IsNullOrWhiteSpace(bakedAsset.UnityAssetId) ? sourceSave.UnityAssetId ?? string.Empty : bakedAsset.UnityAssetId.Trim(); bakedAsset.Filename = Path.GetFileName(bakedAsset.Filename ?? string.Empty); bakedAsset.UnityVersion = ParseUnityVersionFromMetadataName(Path.GetFileName(bakedJsonPath)); if (!string.IsNullOrWhiteSpace(bakedAsset.UnityVersion)) unityEngineVersions.Add(bakedAsset.UnityVersion); if (string.IsNullOrWhiteSpace(bakedAsset.UnityAssetId) || string.IsNullOrWhiteSpace(bakedAsset.Filename)) { Console.WriteLine($"[ROOM IMPORT] Baked asset metadata {bakedJsonPath} is missing an ID or filename."); continue; } if (assetBundleSource == null) { bakedAsset.IsAvailable = false; assetBundlesMissing++; } else { string sourceBundlePath = Path.Combine(assetBundleSource, bakedAsset.Filename); string destinationBundlePath = Path.Combine(assetBundleDestination, bakedAsset.Filename); if (!File.Exists(sourceBundlePath)) { bakedAsset.IsAvailable = false; assetBundlesMissing++; Console.WriteLine( $"[ROOM IMPORT] Missing asset bundle {bakedAsset.Filename} " + $"for UnityAssetId={bakedAsset.UnityAssetId} target={bakedAsset.Target}."); } else { if (CopyIfDifferent(sourceBundlePath, destinationBundlePath)) assetBundlesCopied++; string actualBundleHash = ComputeSha256Base64(destinationBundlePath); if (!string.Equals(bakedAsset.Hash, actualBundleHash, StringComparison.Ordinal)) { Console.WriteLine( $"[ROOM IMPORT] Asset bundle hash normalized " + $"file={bakedAsset.Filename} old={bakedAsset.Hash ?? "null"} " + $"new={actualBundleHash}"); bakedAsset.Hash = actualBundleHash; } bakedAsset.IsAvailable = true; } } bakedAssets.Add(bakedAsset); bakedAssetsImported++; } string actualHash = ComputeSha256Base64(destinationBlobPath); bool isSingleBlobBakedSave = !string.IsNullOrWhiteSpace(sourceSave.UnityAssetId); var localSave = new SubRoomDataSave { SubRoomDataSaveId = nextLocalSaveId++, RoomId = assignedRoomId, SubRoomId = sourceSubRoom.SubRoomId, SavedByAccountId = creatorAccountId, DataBlob = safeBlobName, RoomDataBlob = isSingleBlobBakedSave ? safeBlobName : null, DataBlobHash = actualHash, PersistenceVersion = NormalizePersistenceVersion(sourceSave.PersistenceVersion), SavedOnPlatform = sourceSave.SavedOnPlatform, SavedOnDeviceClass = sourceSave.SavedOnDeviceClass, Description = sourceSave.Description, CreatedAt = NormalizeCreatedAt(sourceSave.CreatedAt), IsPublished = sourceSave.ModerationState != 0, UnityAssetId = sourceSave.UnityAssetId, ReferencedUnityAssetIds = sourceSave.ReferencedUnityAssetIds ?? new List(), OMVersion = RoomDB.NormalizeLegacyOMVersion(sourceSave.OMVersion), UgcSubVersion = NormalizeUgcSubVersion(sourceSave.UgcSubVersion), ModerationState = sourceSave.ModerationState, Tags = sourceSave.Tags ?? new List(), BakedUnityAssets = bakedAssets }; RoomDB.SubRoomDataSaves.Insert(localSave); importedForThisSubRoom.Add((sourceSave.SubRoomDataSaveId, localSave)); savesImported++; } if (importedForThisSubRoom.Count == 0) { Console.WriteLine($"[ROOM IMPORT] Subroom {sourceSubRoom.Name} has no usable saves; skipped."); continue; } long requestedCurrentSaveId = sourceSubRoom.CurrentSave?.SubRoomDataSaveId ?? listedSubRoom.CurrentSave?.SubRoomDataSaveId ?? 0; (long SourceSaveId, SubRoomDataSave LocalSave) selectedItem = importedForThisSubRoom.FirstOrDefault(item => item.SourceSaveId == requestedCurrentSaveId); if (selectedItem.LocalSave == null) { selectedItem = importedForThisSubRoom .OrderByDescending(item => item.LocalSave.CreatedAt) .ThenByDescending(item => item.SourceSaveId) .First(); } SubRoomDataSave currentSave = selectedItem.LocalSave; Console.WriteLine( $"[ROOM IMPORT SELECT] subroom={sourceSubRoom.SubRoomId}:{sourceSubRoom.Name} " + $"sourceSave={selectedItem.SourceSaveId} requested={requestedCurrentSaveId} " + $"created={currentSave.CreatedAt:O} blob={currentSave.DataBlob} " + $"persistence={currentSave.PersistenceVersion} " + $"unityAsset={currentSave.UnityAssetId ?? "none"}"); int accessibilityValue = Enum.IsDefined(typeof(RoomAccessibility), sourceSubRoom.Accessibility) ? sourceSubRoom.Accessibility : (int)RoomAccessibility.Public; var localSubRoom = new SubRooms { SubRoomId = sourceSubRoom.SubRoomId, RoomId = assignedRoomId, Name = string.IsNullOrWhiteSpace(sourceSubRoom.Name) ? "Home" : sourceSubRoom.Name, DataBlob = currentSave.DataBlob, SubRoomDataSaveId = currentSave.SubRoomDataSaveId, SubRoomDataSave = currentSave, IsSandbox = sourceSubRoom.IsSandbox, MaxPlayers = Math.Clamp(sourceSubRoom.MaxPlayers > 0 ? sourceSubRoom.MaxPlayers : 6, 1, 100), Accessibility = (RoomAccessibility)accessibilityValue, UnitySceneId = sourceSubRoom.UnitySceneId ?? string.Empty, SavedByAccountId = creatorAccountId, Permissions = new List() }; importedSubRooms.Add(localSubRoom); bool isNamedHome = string.Equals(localSubRoom.Name, "Home", StringComparison.OrdinalIgnoreCase); bool isPublic = localSubRoom.Accessibility != RoomAccessibility.Private; bool currentIsPublic = playableSubRoom == null || playableSubRoom.Accessibility != RoomAccessibility.Private; if (playableSubRoom == null || (isPublic && !currentIsPublic) || (isPublic == currentIsPublic && isNamedHome)) { playableSubRoom = localSubRoom; playableCurrentSave = currentSave; } } if (importedSubRooms.Count == 0 && exportRoom.SubRooms?.Any(subRoom => !string.IsNullOrWhiteSpace(subRoom.UnitySceneId)) == true) { return ImportUnityExport(exportRoom, creatorAccountId, replaceExisting: false); } if (importedSubRooms.Count == 0) throw new InvalidDataException("No compatible save blobs or UnitySceneId subrooms could be imported."); playableSubRoom ??= importedSubRooms[0]; playableCurrentSave ??= RoomDB.SubRoomDataSaves.FindById(playableSubRoom.SubRoomDataSaveId); playableSubRoom.Name = "Home"; importedSubRooms.Remove(playableSubRoom); importedSubRooms.Insert(0, playableSubRoom); bool imageCopied = CopyRoomImage(exportRoot, exportRoom.ImageName, imageDestination, out string imageName); int roomAccessibility = Enum.IsDefined(typeof(RoomAccessibility), exportRoom.Accessibility) ? exportRoom.Accessibility : (int)RoomAccessibility.Public; bool hasBakedAssets = importedSubRooms.Any(subRoom => RoomDB.SubRoomDataSaves.FindById(subRoom.SubRoomDataSaveId)?.BakedUnityAssets?.Count > 0); var room = new Room { RoomId = assignedRoomId, IsDorm = false, MaxPlayerCalculationMode = 0, MaxPlayers = Math.Clamp( exportRoom.MaxPlayers > 0 ? exportRoom.MaxPlayers : playableSubRoom.MaxPlayers, 1, 100), CloningAllowed = false, DisableMicAutoMute = false, DisableRoomComments = false, EncryptVoiceChat = false, ToxmodEnabled = true, LoadScreenLocked = false, PersistenceVersion = NormalizePersistenceVersion( playableCurrentSave?.PersistenceVersion ?? exportRoom.PersistenceVersion), AutoLocalizeRoom = false, IsDeveloperOwned = true, Name = exportRoom.Name.Trim().TrimStart('^'), Description = string.IsNullOrWhiteSpace(exportRoom.Description) ? "Imported room export." : exportRoom.Description, ImageName = imageName, WarningMask = 0, CustomWarning = null, CreatorAccountId = creatorAccountId, State = RoomState.Active, Accessibility = (RoomAccessibility)roomAccessibility, SupportsLevelVoting = false, IsRRO = false, IsBaseRoom = false, CreativeToolsBetaEnabled = hasBakedAssets, SupportsScreens = true, SupportsWalkVR = true, SupportsTeleportVR = true, SupportsVRLow = true, SupportsQuest2 = true, SupportsMobile = true, SupportsJuniors = true, MinLevel = 0, CreatedAt = DateTime.UtcNow, Stats = new Stats(), RankedEntityId = assignedRoomId.ToString(), RankingContext = null, SubRooms = importedSubRooms, Roles = new List { new() { AccountId = creatorAccountId, Role = Role.Creator, InvitedRole = Role.None } }, DataBlob = playableCurrentSave?.RoomDataBlob, UgcVersion = 1, Tags = new List(), PromoImages = new List(), PromoExternalContent = new List(), LoadScreens = new List() }; if (!RoomDB.Rooms.Upsert(room)) throw new InvalidOperationException("LiteDB failed to upsert the imported room."); Room? verifiedRoom = RoomDB.Rooms.FindById(room.RoomId); if (verifiedRoom?.SubRooms == null || verifiedRoom.SubRooms.Count == 0) throw new InvalidOperationException("The room was written, but LiteDB returned no subrooms after import."); Console.WriteLine( $"[ROOM IMPORT] Imported {room.Name} room={room.RoomId} " + $"subrooms={importedSubRooms.Count} saves={savesImported} " + $"bakedAssets={bakedAssetsImported} bundlesCopied={assetBundlesCopied} " + $"bundlesMissing={assetBundlesMissing} playable={playableSubRoom.SubRoomId}:{playableSubRoom.Name}"); return new ImportResult( room.RoomId, importedSubRooms.Count, savesImported, blobsCopied, savesSkipped, playableSubRoom.Name, playableSubRoom.SubRoomId) { BakedAssetsImported = bakedAssetsImported, AssetBundlesCopied = assetBundlesCopied, AssetBundlesMissing = assetBundlesMissing, ImageCopied = imageCopied, UnityEngineVersions = unityEngineVersions.OrderBy(value => value).ToArray() }; } private static readonly HttpClient DownloadClient = new() { Timeout = TimeSpan.FromMinutes(10) }; public static async Task ImportFromUrlAsync( string rootUrl, long creatorAccountId = 1, bool replaceExisting = true, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(rootUrl) || !Uri.TryCreate(rootUrl, UriKind.Absolute, out Uri? rootUri) || (rootUri.Scheme != Uri.UriSchemeHttp && rootUri.Scheme != Uri.UriSchemeHttps)) { throw new InvalidDataException("Enter a valid http:// or https:// URL to the room's own folder."); } string normalizedRoot = rootUrl.TrimEnd('/') + "/"; string importId = Guid.NewGuid().ToString("N"); string stagingRoot = Path.Combine(Program.dataDir, "Temp", "RoomImports", importId); Directory.CreateDirectory(stagingRoot); const long maxTotalBytes = 1024L * 1024L * 1024L; const int maxSubRooms = 500; const int maxSavesPerSubRoom = 500; long totalDownloadedBytes = 0; async Task DownloadFileAsync(string fileUrl, string destinationPath) { using HttpResponseMessage response = await DownloadClient.GetAsync( fileUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (!response.IsSuccessStatusCode) throw new InvalidDataException($"Could not download {fileUrl} ({(int)response.StatusCode})."); Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); await using Stream input = await response.Content.ReadAsStreamAsync(cancellationToken); await using var output = new FileStream( destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); byte[] buffer = new byte[81920]; while (true) { int read = await input.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); if (read == 0) break; totalDownloadedBytes += read; if (totalDownloadedBytes > maxTotalBytes) { throw new InvalidDataException( "This room's files add up to more than the 1 GiB limit for URL imports. " + "Download it and use the ZIP import instead."); } await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); } } try { List<(string Name, bool IsDirectory)> rootEntries = await ListRemoteDirectoryAsync(normalizedRoot, cancellationToken); if (!rootEntries.Any(entry => !entry.IsDirectory && string.Equals(entry.Name, "room.json", StringComparison.OrdinalIgnoreCase))) { throw new InvalidDataException( "No room.json was found at that URL - point this at a room's own folder " + "(the page that directly lists room.json and the subroom folders)."); } await DownloadFileAsync(normalizedRoot + "room.json", Path.Combine(stagingRoot, "room.json")); ExportRoom exportRoom = ReadJson(Path.Combine(stagingRoot, "room.json")); if (!string.IsNullOrWhiteSpace(exportRoom.ImageName)) { string imageFileName = Path.GetFileName(exportRoom.ImageName); (string Name, bool IsDirectory) imageEntry = rootEntries.FirstOrDefault(entry => !entry.IsDirectory && string.Equals(entry.Name, imageFileName, StringComparison.OrdinalIgnoreCase)); if (imageEntry.Name != null) { try { await DownloadFileAsync( normalizedRoot + Uri.EscapeDataString(imageEntry.Name), Path.Combine(stagingRoot, imageEntry.Name)); } catch (Exception ex) { Console.WriteLine($"[ROOM IMPORT URL] Could not fetch thumbnail: {ex.Message}"); } } } var referencedBundleFilenames = new HashSet(StringComparer.OrdinalIgnoreCase); int subRoomsFound = 0; foreach ((string Name, bool IsDirectory) entry in rootEntries.Where(entry => entry.IsDirectory && !string.Equals(entry.Name, "AssetBundles", StringComparison.OrdinalIgnoreCase) && long.TryParse(entry.Name, out _))) { if (subRoomsFound >= maxSubRooms) break; string subRoomUrl = normalizedRoot + Uri.EscapeDataString(entry.Name) + "/"; List<(string Name, bool IsDirectory)> subRoomEntries = await ListRemoteDirectoryAsync(subRoomUrl, cancellationToken); if (!subRoomEntries.Any(e => !e.IsDirectory && string.Equals(e.Name, "subRoom.json", StringComparison.OrdinalIgnoreCase))) { continue; } subRoomsFound++; string localSubRoomDir = Path.Combine(stagingRoot, entry.Name); await DownloadFileAsync(subRoomUrl + "subRoom.json", Path.Combine(localSubRoomDir, "subRoom.json")); (string Name, bool IsDirectory) savesFolder = subRoomEntries.FirstOrDefault(e => e.IsDirectory && string.Equals(e.Name, "Saves", StringComparison.OrdinalIgnoreCase)); if (savesFolder.Name == null) continue; string savesUrl = subRoomUrl + "Saves/"; List<(string Name, bool IsDirectory)> saveEntries = await ListRemoteDirectoryAsync(savesUrl, cancellationToken); int savesForThisSubRoom = 0; foreach ((string Name, bool IsDirectory) saveEntry in saveEntries.Where(e => e.IsDirectory)) { if (savesForThisSubRoom >= maxSavesPerSubRoom) break; savesForThisSubRoom++; string saveUrl = savesUrl + Uri.EscapeDataString(saveEntry.Name) + "/"; List<(string Name, bool IsDirectory)> saveFileEntries = await ListRemoteDirectoryAsync(saveUrl, cancellationToken); string localSaveDir = Path.Combine(localSubRoomDir, "Saves", saveEntry.Name); foreach ((string Name, bool IsDirectory) file in saveFileEntries.Where(e => !e.IsDirectory)) { await DownloadFileAsync( saveUrl + Uri.EscapeDataString(file.Name), Path.Combine(localSaveDir, file.Name)); if (file.Name.StartsWith("bakedUnityAsset-", StringComparison.OrdinalIgnoreCase) && file.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) { try { BakedUnityAsset baked = ReadJson( Path.Combine(localSaveDir, file.Name)); if (!string.IsNullOrWhiteSpace(baked.Filename)) referencedBundleFilenames.Add(Path.GetFileName(baked.Filename)); } catch (Exception ex) { Console.WriteLine( $"[ROOM IMPORT URL] Unreadable baked asset metadata {file.Name}: {ex.Message}"); } } } } } if (subRoomsFound == 0) { throw new InvalidDataException( "No subroom folders with a subRoom.json were found under that URL."); } if (referencedBundleFilenames.Count > 0 && rootEntries.Any(entry => entry.IsDirectory && string.Equals(entry.Name, "AssetBundles", StringComparison.OrdinalIgnoreCase))) { string bundlesUrl = normalizedRoot + "AssetBundles/"; string localBundlesDir = Path.Combine(stagingRoot, "AssetBundles"); foreach (string filename in referencedBundleFilenames) { try { await DownloadFileAsync( bundlesUrl + Uri.EscapeDataString(filename), Path.Combine(localBundlesDir, filename)); } catch (Exception ex) { Console.WriteLine( $"[ROOM IMPORT URL] Could not fetch asset bundle {filename}: {ex.Message}"); } } } return Import(stagingRoot, creatorAccountId, replaceExisting); } finally { try { if (Directory.Exists(stagingRoot)) Directory.Delete(stagingRoot, recursive: true); } catch (Exception ex) { Console.WriteLine($"[ROOM IMPORT URL] Could not clean up staging folder: {ex.Message}"); } } } public static async Task> DiscoverRoomFoldersAsync( string rootUrl, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(rootUrl) || !Uri.TryCreate(rootUrl, UriKind.Absolute, out Uri? rootUri) || (rootUri.Scheme != Uri.UriSchemeHttp && rootUri.Scheme != Uri.UriSchemeHttps)) { throw new InvalidDataException("Enter a valid http:// or https:// URL to the rooms index folder."); } string normalizedRoot = rootUrl.TrimEnd('/') + "/"; List<(string Name, bool IsDirectory)> entries = await ListRemoteDirectoryAsync(normalizedRoot, cancellationToken); if (entries.Any(entry => !entry.IsDirectory && string.Equals(entry.Name, "room.json", StringComparison.OrdinalIgnoreCase))) { string singleName = rootUri.Segments.Length > 0 ? Uri.UnescapeDataString(rootUri.Segments[^1].Trim('/')) : normalizedRoot; return new List<(string Name, string Url)> { (string.IsNullOrWhiteSpace(singleName) ? normalizedRoot : singleName, normalizedRoot) }; } const int maxCandidates = 300; var found = new List<(string Name, string Url)>(); foreach ((string Name, bool IsDirectory) entry in entries.Where(entry => entry.IsDirectory && !string.Equals(entry.Name, "AssetBundles", StringComparison.OrdinalIgnoreCase))) { cancellationToken.ThrowIfCancellationRequested(); if (found.Count >= maxCandidates) break; string candidateUrl = normalizedRoot + Uri.EscapeDataString(entry.Name) + "/"; List<(string Name, bool IsDirectory)> candidateEntries; try { candidateEntries = await ListRemoteDirectoryAsync(candidateUrl, cancellationToken); } catch (Exception ex) { Console.WriteLine($"[ROOM IMPORT URL DISCOVER] Skipping {candidateUrl}: {ex.Message}"); continue; } if (candidateEntries.Any(e => !e.IsDirectory && string.Equals(e.Name, "room.json", StringComparison.OrdinalIgnoreCase))) { found.Add((entry.Name, candidateUrl)); } } if (found.Count == 0) { throw new InvalidDataException( "No room folders (containing room.json) were found directly under that URL."); } return found; } private static async Task> ListRemoteDirectoryAsync( string url, CancellationToken cancellationToken) { string html = await DownloadClient.GetStringAsync(url, cancellationToken); var entries = new List<(string Name, bool IsDirectory)>(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (Match match in Regex.Matches(html, "href\\s*=\\s*\"([^\"]+)\"", RegexOptions.IgnoreCase)) { string href = System.Net.WebUtility.HtmlDecode(match.Groups[1].Value).Trim(); if (string.IsNullOrWhiteSpace(href) || href.StartsWith('#') || href.StartsWith('?') || href is "../" or ".." or "/" or "./") { continue; } if (href.Contains("://")) { if (!Uri.TryCreate(href, UriKind.Absolute, out Uri? absolute) || !Uri.TryCreate(url, UriKind.Absolute, out Uri? baseUri) || !string.Equals(absolute.Host, baseUri.Host, StringComparison.OrdinalIgnoreCase)) { continue; } } bool isDirectory = href.EndsWith('/'); string trimmed = href.TrimEnd('/'); string rawName = trimmed.Contains('/') ? trimmed[(trimmed.LastIndexOf('/') + 1)..] : trimmed; string name = Uri.UnescapeDataString(rawName); if (string.IsNullOrWhiteSpace(name) || name is ".." or "." || name.Contains('\0')) continue; if (seen.Add(name)) entries.Add((name, isDirectory)); } return entries; } private static ImportResult ImportUnityExport( ExportRoom exportRoom, long creatorAccountId, bool replaceExisting) { if (string.IsNullOrWhiteSpace(exportRoom.Name)) throw new InvalidDataException("Room metadata must contain a Name."); var sourceSubRooms = (exportRoom.SubRooms ?? new List()) .Where(subRoom => !string.IsNullOrWhiteSpace(subRoom.UnitySceneId)) .ToList(); long assignedRoomId = RoomDB.GetNextRoomId(); string normalizedRoomName = exportRoom.Name.Trim().TrimStart('^'); Room? existingByName = RoomDB.Rooms.FindAll().FirstOrDefault(room => string.Equals(room.Name, normalizedRoomName, StringComparison.OrdinalIgnoreCase)); if (sourceSubRooms.Count == 0 && !string.IsNullOrWhiteSpace(exportRoom.UnitySceneId)) { sourceSubRooms.Add(new ExportSubRoom { RoomId = assignedRoomId, SubRoomId = exportRoom.SubRoomId, Name = "Home", UnitySceneId = exportRoom.UnitySceneId, MaxPlayers = exportRoom.MaxPlayers, Accessibility = exportRoom.Accessibility }); } if (sourceSubRooms.Count == 0) throw new InvalidDataException("No UnitySceneId was found in the room metadata."); if (replaceExisting) { if (existingByName != null) { RoomDB.SubRoomDataSaves.DeleteMany(save => save.RoomId == existingByName.RoomId); RoomDB.Rooms.Delete(existingByName.RoomId); } } else if (existingByName != null) { throw new InvalidOperationException( $"A room named \"{normalizedRoomName}\" already exists. Enable replaceExisting to overwrite it."); } long generatedSubRoomId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var importedSubRooms = new List(); foreach ((ExportSubRoom source, int index) in sourceSubRooms.Select((value, index) => (value, index))) { long subRoomId = source.SubRoomId > 0 ? source.SubRoomId : generatedSubRoomId + index; int accessibilityValue = Enum.IsDefined(typeof(RoomAccessibility), source.Accessibility) ? source.Accessibility : (int)RoomAccessibility.Public; importedSubRooms.Add(new SubRooms { SubRoomId = subRoomId, RoomId = assignedRoomId, Name = string.IsNullOrWhiteSpace(source.Name) ? $"Scene {index + 1}" : source.Name, DataBlob = null, SubRoomDataSaveId = 0, SubRoomDataSave = null, IsSandbox = source.IsSandbox, MaxPlayers = source.MaxPlayers > 0 ? Math.Clamp(source.MaxPlayers, 1, 100) : Math.Clamp(exportRoom.MaxPlayers > 0 ? exportRoom.MaxPlayers : 6, 1, 100), Accessibility = (RoomAccessibility)accessibilityValue, UnitySceneId = source.UnitySceneId!.Trim(), SavedByAccountId = creatorAccountId, Permissions = new List() }); } SubRooms playableSubRoom = importedSubRooms.FirstOrDefault(subRoom => string.Equals(subRoom.Name, "Home", StringComparison.OrdinalIgnoreCase)) ?? importedSubRooms[0]; playableSubRoom.Name = "Home"; importedSubRooms.Remove(playableSubRoom); importedSubRooms.Insert(0, playableSubRoom); int roomAccessibility = Enum.IsDefined(typeof(RoomAccessibility), exportRoom.Accessibility) ? exportRoom.Accessibility : (int)RoomAccessibility.Public; var room = new Room { RoomId = assignedRoomId, IsDorm = false, MaxPlayerCalculationMode = 0, MaxPlayers = Math.Clamp( exportRoom.MaxPlayers > 0 ? exportRoom.MaxPlayers : playableSubRoom.MaxPlayers, 1, 100), CloningAllowed = false, DisableMicAutoMute = false, DisableRoomComments = false, EncryptVoiceChat = false, ToxmodEnabled = true, LoadScreenLocked = false, PersistenceVersion = NormalizePersistenceVersion( exportRoom.PersistenceVersion > 0 ? exportRoom.PersistenceVersion : 27), AutoLocalizeRoom = false, IsDeveloperOwned = true, Name = exportRoom.Name.Trim().TrimStart('^'), Description = string.IsNullOrWhiteSpace(exportRoom.Description) ? "Imported built-in Unity scene room." : exportRoom.Description, ImageName = exportRoom.ImageName ?? string.Empty, WarningMask = 0, CustomWarning = null, CreatorAccountId = creatorAccountId, State = RoomState.Active, Accessibility = (RoomAccessibility)roomAccessibility, SupportsLevelVoting = false, IsRRO = true, IsBaseRoom = true, SupportsScreens = true, SupportsWalkVR = true, SupportsTeleportVR = true, SupportsVRLow = true, SupportsQuest2 = true, SupportsMobile = true, SupportsJuniors = true, MinLevel = 0, CreatedAt = DateTime.UtcNow, Stats = new Stats(), RankedEntityId = assignedRoomId.ToString(), RankingContext = null, SubRooms = importedSubRooms, Roles = new List { new() { AccountId = creatorAccountId, Role = Role.Creator, InvitedRole = Role.None } }, DataBlob = null, UgcVersion = 1, Tags = new List { new() { Tag = "base", Type = TagType.Auto }, new() { Tag = "rro", Type = TagType.AGOnly } }, PromoImages = new List(), PromoExternalContent = new List(), LoadScreens = new List() }; if (!RoomDB.Rooms.Upsert(room)) throw new InvalidOperationException("LiteDB failed to save the imported Unity-scene room."); return new ImportResult( room.RoomId, importedSubRooms.Count, 0, 0, 0, playableSubRoom.Name, playableSubRoom.SubRoomId); } public sealed record RoomExportResult( long RoomId, string ExportRoot, int SubRoomsExported, int SavesExported, bool ImageExported, int BakedAssetsExported, int AssetBundlesExported); public static RoomExportResult ExportRoomToDirectory( long roomId, string outputRoot) { RoomDBClasses.Room room = RoomDB.GetRoom(roomId); if (room == null || room.RoomId <= 0) throw new FileNotFoundException($"Room {roomId} was not found."); Directory.CreateDirectory(outputRoot); string roomBlobSource = Path.Combine(Program.dataDir, "CDN", "room"); string assetBundleSourceRoot = Path.Combine(Program.dataDir, "CDN", "assetbundles"); string imageSourceRoot = Path.Combine(Program.dataDir, "Images"); string assetBundleDestination = Path.Combine(outputRoot, "AssetBundles"); var exportSubRooms = new List(); int savesExported = 0; int bakedAssetsExported = 0; var exportedBundleNames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (SubRooms subRoom in room.SubRooms ?? new List()) { string subRoomDirectory = Path.Combine(outputRoot, subRoom.SubRoomId.ToString()); string savesDirectory = Path.Combine(subRoomDirectory, "Saves"); Directory.CreateDirectory(savesDirectory); List saves = RoomDB.SubRoomDataSaves .Find(save => save.RoomId == roomId && save.SubRoomId == subRoom.SubRoomId) .OrderBy(save => save.CreatedAt) .ToList(); foreach (SubRoomDataSave save in saves) { string safeBlobName = Path.GetFileName(save.DataBlob ?? string.Empty); string sourceBlobPath = Path.Combine(roomBlobSource, safeBlobName); if (string.IsNullOrWhiteSpace(safeBlobName) || !File.Exists(sourceBlobPath)) { Console.WriteLine( $"[ROOM EXPORT] Missing blob for save {save.SubRoomDataSaveId}; skipped."); continue; } string saveDirectoryName = save.SubRoomDataSaveId.ToString(); string saveDirectory = Path.Combine(savesDirectory, saveDirectoryName); Directory.CreateDirectory(saveDirectory); File.Copy( sourceBlobPath, Path.Combine(saveDirectory, safeBlobName), overwrite: true); var exportSave = new ExportSave { SubRoomDataSaveId = save.SubRoomDataSaveId, SubRoomId = save.SubRoomId, UnityAssetId = save.UnityAssetId, DataBlob = safeBlobName, DataBlobHash = save.DataBlobHash, ReferencedUnityAssetIds = save.ReferencedUnityAssetIds, PersistenceVersion = save.PersistenceVersion.GetValueOrDefault(), OMVersion = save.OMVersion, UgcSubVersion = save.UgcSubVersion, SavedOnPlatform = save.SavedOnPlatform, SavedOnDeviceClass = save.SavedOnDeviceClass, Description = save.Description, Tags = save.Tags, ModerationState = save.ModerationState, CreatedAt = save.CreatedAt }; WriteJson(Path.Combine(saveDirectory, saveDirectoryName + ".json"), exportSave); int bakedIndex = 0; foreach (BakedUnityAsset bakedAsset in save.BakedUnityAssets ?? new List()) { string versionSuffix = string.IsNullOrWhiteSpace(bakedAsset.UnityVersion) ? string.Empty : $"-Unity_{bakedAsset.UnityVersion.Replace('.', '_')}"; string bakedJsonName = $"bakedUnityAsset-{bakedIndex++}{versionSuffix}.json"; WriteJson(Path.Combine(saveDirectory, bakedJsonName), bakedAsset); bakedAssetsExported++; string bundleFileName = Path.GetFileName(bakedAsset.Filename ?? string.Empty); if (string.IsNullOrWhiteSpace(bundleFileName)) continue; string bundleSourcePath = Path.Combine(assetBundleSourceRoot, bundleFileName); if (!File.Exists(bundleSourcePath)) continue; if (exportedBundleNames.Add(bundleFileName)) { Directory.CreateDirectory(assetBundleDestination); File.Copy( bundleSourcePath, Path.Combine(assetBundleDestination, bundleFileName), overwrite: true); } } savesExported++; } var exportSubRoom = new ExportSubRoom { RoomId = roomId, SubRoomId = subRoom.SubRoomId, Name = subRoom.Name, UnitySceneId = subRoom.UnitySceneId, IsSandbox = subRoom.IsSandbox, MaxPlayers = subRoom.MaxPlayers, Accessibility = (int)subRoom.Accessibility, CurrentSave = new ExportSave { SubRoomDataSaveId = subRoom.SubRoomDataSaveId } }; WriteJson(Path.Combine(subRoomDirectory, "subRoom.json"), exportSubRoom); exportSubRooms.Add(exportSubRoom); } if (savesExported == 0) throw new InvalidDataException($"Room {roomId} has no save blobs on disk to export."); bool imageExported = false; if (!string.IsNullOrWhiteSpace(room.ImageName)) { string imageSourcePath = Path.Combine(imageSourceRoot, room.ImageName); if (File.Exists(imageSourcePath)) { File.Copy( imageSourcePath, Path.Combine(outputRoot, Path.GetFileName(room.ImageName)), overwrite: true); imageExported = true; } } var exportRoom = new ExportRoom { RoomId = room.RoomId, Name = room.Name, Description = room.Description, ImageName = room.ImageName, MaxPlayers = room.MaxPlayers, Accessibility = (int)room.Accessibility, PersistenceVersion = room.PersistenceVersion, SubRoomId = room.SubRooms?.FirstOrDefault()?.SubRoomId ?? 0, UnitySceneId = room.SubRooms?.FirstOrDefault()?.UnitySceneId, SubRooms = exportSubRooms }; WriteJson(Path.Combine(outputRoot, "room.json"), exportRoom); return new RoomExportResult( room.RoomId, outputRoot, exportSubRooms.Count, savesExported, imageExported, bakedAssetsExported, exportedBundleNames.Count); } private static void WriteJson(string path, T value) { File.WriteAllText( path, JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true })); } private static void MergeSubRoomMetadata(ExportSubRoom listed, ExportSubRoom source) { listed.Name = string.IsNullOrWhiteSpace(source.Name) ? listed.Name : source.Name; listed.UnitySceneId = string.IsNullOrWhiteSpace(source.UnitySceneId) ? listed.UnitySceneId : source.UnitySceneId; listed.MaxPlayers = source.MaxPlayers > 0 ? source.MaxPlayers : listed.MaxPlayers; listed.Accessibility = source.Accessibility; listed.IsSandbox = source.IsSandbox; listed.CurrentSave = source.CurrentSave ?? listed.CurrentSave; } private static bool CopyRoomImage( string exportRoot, string? requestedImageName, string destinationRoot, out string imageName) { string requestedFileName = Path.GetFileName(requestedImageName ?? string.Empty); imageName = requestedFileName; string? sourcePath = null; if (!string.IsNullOrWhiteSpace(requestedFileName)) { sourcePath = Directory.EnumerateFiles(exportRoot, "*", SearchOption.TopDirectoryOnly) .FirstOrDefault(path => string.Equals( Path.GetFileName(path), requestedFileName, StringComparison.OrdinalIgnoreCase)); } sourcePath ??= Directory.EnumerateFiles(exportRoot, "*", SearchOption.TopDirectoryOnly) .FirstOrDefault(path => IsImageExtension(Path.GetExtension(path))); if (sourcePath == null) { imageName = string.Empty; return false; } imageName = Path.GetFileName(sourcePath); string destinationPath = Path.Combine(destinationRoot, imageName); return CopyIfDifferent(sourcePath, destinationPath) || File.Exists(destinationPath); } private static bool IsImageExtension(string extension) => extension.Equals(".png", StringComparison.OrdinalIgnoreCase) || extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) || extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) || extension.Equals(".webp", StringComparison.OrdinalIgnoreCase) || extension.Equals(".gif", StringComparison.OrdinalIgnoreCase); private static string? FindDirectory(string root, string name) { string direct = Path.Combine(root, name); if (Directory.Exists(direct)) return direct; return Directory.EnumerateDirectories(root, "*", SearchOption.TopDirectoryOnly) .FirstOrDefault(path => string.Equals( Path.GetFileName(path), name, StringComparison.OrdinalIgnoreCase)); } private static bool CopyIfDifferent(string sourcePath, string destinationPath) { Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); if (File.Exists(destinationPath)) { var sourceInfo = new FileInfo(sourcePath); var destinationInfo = new FileInfo(destinationPath); if (sourceInfo.Length == destinationInfo.Length && string.Equals( ComputeSha256Base64(sourcePath), ComputeSha256Base64(destinationPath), StringComparison.Ordinal)) { return false; } } File.Copy(sourcePath, destinationPath, overwrite: true); return true; } private static string? ParseUnityVersionFromMetadataName(string fileName) { const string marker = "Unity_"; int markerIndex = fileName.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase); if (markerIndex < 0) return null; int start = markerIndex + marker.Length; int end = fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? fileName.Length - 5 : fileName.Length; if (start >= end) return null; return fileName[start..end].Replace('_', '.'); } private static int NormalizePersistenceVersion(int sourceVersion) { if (sourceVersion <= 0) return CompatiblePersistenceVersion; return Math.Min(sourceVersion, CompatiblePersistenceVersion); } private static int NormalizeUgcSubVersion(int sourceVersion) { return CompatibleUgcSubVersion; } private static DateTime NormalizeCreatedAt(DateTime createdAt) { if (createdAt == default) return DateTime.UtcNow; return createdAt.Kind == DateTimeKind.Utc ? createdAt : createdAt.ToUniversalTime(); } private static bool TryGetProperty( JsonElement element, string propertyName, out JsonElement value) { if (element.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in element.EnumerateObject()) { if (property.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase)) { value = property.Value; return true; } } } value = default; return false; } private static T ReadJson(string path) { string json = File.ReadAllText(path, Encoding.UTF8); return JsonSerializer.Deserialize(json, JsonOptions) ?? throw new InvalidDataException($"Could not deserialize {path}."); } private static string ComputeSha256Base64(string path) { using FileStream stream = File.OpenRead(path); return Convert.ToBase64String(SHA256.HashData(stream)); } private sealed class ExportRoom { public long RoomId { get; set; } public string Name { get; set; } = string.Empty; public string? Description { get; set; } public string? ImageName { get; set; } public int MaxPlayers { get; set; } public int Accessibility { get; set; } public int PersistenceVersion { get; set; } public long SubRoomId { get; set; } public string? UnitySceneId { get; set; } public List? SubRooms { get; set; } } private sealed class ExportSubRoom { public long RoomId { get; set; } public long SubRoomId { get; set; } public string Name { get; set; } = string.Empty; public string? UnitySceneId { get; set; } public ExportSave? CurrentSave { get; set; } public bool IsSandbox { get; set; } public int MaxPlayers { get; set; } public int Accessibility { get; set; } } private sealed class ExportSave { public long SubRoomDataSaveId { get; set; } public long SubRoomId { get; set; } public string? UnityAssetId { get; set; } public string DataBlob { get; set; } = string.Empty; public string? DataBlobHash { get; set; } public List? ReferencedUnityAssetIds { get; set; } public int PersistenceVersion { get; set; } public int OMVersion { get; set; } public int UgcSubVersion { get; set; } public int? SavedOnPlatform { get; set; } public int? SavedOnDeviceClass { get; set; } public string? Description { get; set; } public List? Tags { get; set; } public int ModerationState { get; set; } public DateTime CreatedAt { get; set; } } }