:3
This commit is contained in:
@@ -0,0 +1,1363 @@
|
||||
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<string> UnityEngineVersions { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
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<ExportRoom>(
|
||||
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<ExportRoom>(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<SubRooms>();
|
||||
int savesImported = 0;
|
||||
int blobsCopied = 0;
|
||||
int savesSkipped = 0;
|
||||
int bakedAssetsImported = 0;
|
||||
int assetBundlesCopied = 0;
|
||||
int assetBundlesMissing = 0;
|
||||
var unityEngineVersions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
SubRooms? playableSubRoom = null;
|
||||
SubRoomDataSave? playableCurrentSave = null;
|
||||
|
||||
foreach (ExportSubRoom listedSubRoom in exportRoom.SubRooms ?? new List<ExportSubRoom>())
|
||||
{
|
||||
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<ExportSubRoom>(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<ExportSave>(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<BakedUnityAsset>();
|
||||
foreach (string bakedJsonPath in Directory.EnumerateFiles(
|
||||
saveDirectory,
|
||||
"bakedUnityAsset-*.json",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
BakedUnityAsset? bakedAsset;
|
||||
try
|
||||
{
|
||||
bakedAsset = ReadJson<BakedUnityAsset>(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<string>(),
|
||||
OMVersion = RoomDB.NormalizeLegacyOMVersion(sourceSave.OMVersion),
|
||||
UgcSubVersion = NormalizeUgcSubVersion(sourceSave.UgcSubVersion),
|
||||
ModerationState = sourceSave.ModerationState,
|
||||
Tags = sourceSave.Tags ?? new List<string>(),
|
||||
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<SubRoomPermission>()
|
||||
};
|
||||
|
||||
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<Roles>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AccountId = creatorAccountId,
|
||||
Role = Role.Creator,
|
||||
InvitedRole = Role.None
|
||||
}
|
||||
},
|
||||
|
||||
DataBlob = playableCurrentSave?.RoomDataBlob,
|
||||
UgcVersion = 1,
|
||||
Tags = new List<Tags>(),
|
||||
PromoImages = new List<string>(),
|
||||
PromoExternalContent = new List<PromoExternalContent>(),
|
||||
LoadScreens = new List<LoadScreens>()
|
||||
};
|
||||
|
||||
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<ImportResult> 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<ExportRoom>(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<string>(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<BakedUnityAsset>(
|
||||
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<List<(string Name, string Url)>> 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<List<(string Name, bool IsDirectory)>> ListRemoteDirectoryAsync(
|
||||
string url,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string html = await DownloadClient.GetStringAsync(url, cancellationToken);
|
||||
var entries = new List<(string Name, bool IsDirectory)>();
|
||||
var seen = new HashSet<string>(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<ExportSubRoom>())
|
||||
.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<SubRooms>();
|
||||
|
||||
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<SubRoomPermission>()
|
||||
});
|
||||
}
|
||||
|
||||
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<Roles>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AccountId = creatorAccountId,
|
||||
Role = Role.Creator,
|
||||
InvitedRole = Role.None
|
||||
}
|
||||
},
|
||||
DataBlob = null,
|
||||
UgcVersion = 1,
|
||||
Tags = new List<Tags>
|
||||
{
|
||||
new() { Tag = "base", Type = TagType.Auto },
|
||||
new() { Tag = "rro", Type = TagType.AGOnly }
|
||||
},
|
||||
PromoImages = new List<string>(),
|
||||
PromoExternalContent = new List<PromoExternalContent>(),
|
||||
LoadScreens = new List<LoadScreens>()
|
||||
};
|
||||
|
||||
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<ExportSubRoom>();
|
||||
int savesExported = 0;
|
||||
int bakedAssetsExported = 0;
|
||||
var exportedBundleNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (SubRooms subRoom in room.SubRooms ?? new List<SubRooms>())
|
||||
{
|
||||
string subRoomDirectory = Path.Combine(outputRoot, subRoom.SubRoomId.ToString());
|
||||
string savesDirectory = Path.Combine(subRoomDirectory, "Saves");
|
||||
Directory.CreateDirectory(savesDirectory);
|
||||
|
||||
List<SubRoomDataSave> 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<BakedUnityAsset>())
|
||||
{
|
||||
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<T>(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<T>(string path)
|
||||
{
|
||||
string json = File.ReadAllText(path, Encoding.UTF8);
|
||||
return JsonSerializer.Deserialize<T>(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<ExportSubRoom>? 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<string>? 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<string>? Tags { get; set; }
|
||||
public int ModerationState { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user