From 1d4777e3da406dc15b5f6484fd2c8e82622ab7eb Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:11:21 +0300 Subject: [PATCH] feat: Enhance RoomZipImportController to support Unity asset bundles - Added support for .assetbundle files in RoomZipImportController. - Introduced new properties in RoomSceneEntity for handling Studio asset metadata. - Updated ZipImport method to handle asset bundles during bulk imports. - Enhanced CdnController to serve Unity asset bundles via a new route. - Modified GoToController to include Unity asset ID in matchmaking responses. - Updated database schema to accommodate new fields related to Studio asset bundles. - Improved URL coverage documentation to reflect new routes for asset bundles. --- .gitignore | 2 + DorkNet.ClientMod/Mod.cs | 301 ++++++++++- .../API/Inventions/InventionsController.cs | 26 +- .../API/Rooms/V2/RoomsController.cs | 505 +++++++++++++++++- .../Admin/RoomZipImportController.cs | 84 ++- .../Controllers/Cdn/CdnController.cs | 113 +++- .../Controllers/Match/GoToController.cs | 37 +- .../Data/Entities/RoomSceneEntity.cs | 12 + DorkNet.Server/Data/url-coverage.md | 13 + DorkNet.Server/Startup/DatabaseBootstrap.cs | 37 ++ 10 files changed, 1081 insertions(+), 49 deletions(-) diff --git a/.gitignore b/.gitignore index 522b129..4ec5dd0 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,5 @@ Recovered(*)/ local-releases/** +DorkNet.Models/bin-verify/** +DorkNet.Models/bin-verify2/** diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index 0915862..3279a5e 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -22,6 +22,7 @@ using HarmonyLib; using Il2CppInterop.Runtime; using Il2CppInterop.Runtime.InteropTypes; +using Il2CppInterop.Runtime.InteropTypes.Arrays; using MelonLoader; using System; using System.Collections; @@ -185,6 +186,24 @@ public class Mod : MelonMod "NotifyServerCertificate", prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix)); } + if (Cfg.EnableDebugConsole || Cfg.EnableDevWatchButton) + Log.Msg($"[debugconsole] armed: toggleKey={Cfg.DebugConsoleToggleKey}, commands={DebugConsolePatches.FormatConfiguredCommandsForLog()}"); + // Image CDN signature verification. Server responses still include + // Content-Signature, but the official RSA public key is baked into + // GameAssembly and the matching private key is not available. This + // method is the verifier in the 2023 build (JFDPIJFDHAD.HOCPMNEKIHP); + // skipping it lets signed local CDN/image bytes load. + TryPatchByName("JFDPIJFDHAD", + "HOCPMNEKIHP", + args: new[] + { + typeof(string), + typeof(Il2CppStructArray), + typeof(Il2CppStructArray), + typeof(Il2CppStructArray), + typeof(Il2CppStructArray) + }, + prefix: nameof(ImageSignaturePatches.VerifyImageSignature_Prefix)); RegisterQuitTracePatches(logMisses: true); RegisterAntiTamperCallbackPatches(logMisses: true); RegisterToxModPatches(logMisses: true); @@ -316,6 +335,9 @@ public class Mod : MelonMod postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix)); Log.Msg($"[questsize] armed: target {Cfg.QuestMaxTeamSize} for [{string.Join(", ", Cfg.QuestMaxTeamSizeRooms)}]"); } + + RegisterStudioTracePatches(); + Log.Msg("=== Client patches registered ==="); } @@ -731,6 +753,11 @@ public class Mod : MelonMod RegisterToxModPatches(logMisses: false); } + if (Cfg.EnableDebugConsole || Cfg.EnableDevWatchButton) + DebugConsolePatches.PollToggleKey(); + + if (Cfg.DiagnoseDevMenu || Cfg.EnableDevWatchButton) + DevMenuProbe.Tick(); } public override void OnApplicationQuit() @@ -1036,7 +1063,7 @@ public class Mod : MelonMod { // Look in all three patch holder classes — small enough that a // linear scan is cheaper than per-class lookups. - foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) }) + foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ImageSignaturePatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) }) { var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static); if (m is not null) return m; @@ -1249,6 +1276,27 @@ public class Mod : MelonMod return true; } + /// Studio baked-asset load trace. Registered directly from the + /// live client-patch flow (the deep diagnostics path + /// is not wired into init, so probes + /// placed there never run). Pinpoints where the baked-Studio-scene chain + /// breaks: the unity-asset getter (does the client ever request a + /// bundle), prefab registration (does a bundle ever load), and the prefab + /// lookups that fail with "Expected a prefab, but found none". + /// RecRoomObjectPrefabManager is name-preserved; the getter is the + /// obfuscated NLDBPDCNNCF.FKLDMPMFLBD. Each is a single overload. + private void RegisterStudioTracePatches() + { + TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "RegisterPrefabs", + postfix: nameof(RoomLoadDiagnostics.StudioRegisterPrefabs_Postfix)); + TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "TryGetPrefab", + postfix: nameof(RoomLoadDiagnostics.StudioTryGetPrefab_Postfix)); + TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "IsKnownPrefab", + postfix: nameof(RoomLoadDiagnostics.StudioIsKnownPrefab_Postfix)); + TryPatchByName("NLDBPDCNNCF", "FKLDMPMFLBD", + prefix: nameof(RoomLoadDiagnostics.StudioUnityAssetFetch_Prefix)); + } + private bool PatchKnownRoomLoadMoveNext(bool logMiss) { const string label = "room-load-state-machines.MoveNext"; @@ -1409,6 +1457,66 @@ internal static class RoomLoadDiagnostics return __exception; } + // ── Studio baked-asset load trace ────────────────────────────────── + // Pinpoints where the baked-Studio-scene load chain breaks. The room + // joins but renders no custom geometry and logs "Expected a prefab, + // but found none"; the server serves everything but the client never + // requests a bundle. These probes answer: (1) does the client ever + // call the RecNet "get unity asset" API (FKLDMPMFLBD); (2) does it + // ever register baked prefabs; (3) which prefab GUIDs does it look up + // and miss. + + /// RecNet unity-asset getter + /// NLDBPDCNNCF.FKLDMPMFLBD(string unityAssetId, byte target, int version, CancellationToken) + /// — the call that builds unity_assets/{id}/{target}/{version}. If + /// this never fires, the client decided not to fetch the bundle at all + /// (trigger missing upstream); if it fires, the URL/host/response is the + /// problem. + public static void StudioUnityAssetFetch_Prefix(object? __0, object? __1, object? __2) + { + DiagnosticPatches.Write( + $"[studio] UNITY-ASSET FETCH id={__0?.ToString() ?? ""} " + + $"target={__1?.ToString() ?? "?"} version={__2?.ToString() ?? "?"}"); + WriteStack("studio-assetfetch"); + } + + /// RecRoomObjectPrefabManager.RegisterPrefabs — baked + /// prefabs being registered after a bundle loads. If this never fires, + /// no bundle was loaded. + public static void StudioRegisterPrefabs_Postfix(object? __0) + { + int count = -1; + try + { + if (__0 is not null) + { + var p = __0.GetType().GetProperty("Count"); + if (p?.GetValue(__0) is int c) count = c; + } + } + catch { /* best-effort count */ } + DiagnosticPatches.Write($"[studio] RegisterPrefabs count={count}"); + WriteStack("studio-registerprefabs"); + } + + /// RecRoomObjectPrefabManager.TryGetPrefab(Guid, out GameObject) + /// — the lookup that fails with "Expected a prefab, but found none". + /// Logs the GUID + whether it resolved. + public static void StudioTryGetPrefab_Postfix(object? __0, bool __result) + { + if (!ShouldLog("studio-trygetprefab", 200)) return; + DiagnosticPatches.Write( + $"[studio] TryGetPrefab guid={__0?.ToString() ?? ""} found={__result}"); + } + + /// RecRoomObjectPrefabManager.IsKnownPrefab(Guid). + public static void StudioIsKnownPrefab_Postfix(object? __0, bool __result) + { + if (!ShouldLog("studio-isknownprefab", 200)) return; + DiagnosticPatches.Write( + $"[studio] IsKnownPrefab guid={__0?.ToString() ?? ""} known={__result}"); + } + public static string FormatException(Exception? ex, object? fallback) { if (ex is null) return fallback?.ToString() ?? ""; @@ -1634,12 +1742,13 @@ internal static class RoomLoadDiagnostics // // Everything is resolved by reflection at runtime so the SAME code works on // both client builds despite their differing obfuscation: -// * The show/hide toggle is the one non-compiler-generated private -// instance void(bool) on DebugConsole — ShowInputField on 2020.03, -// MCNBJFECHLJ on 2020.12. We try the readable name, then the obfuscated -// one, then fall back to that structural match. -// * The live component is found via UnityEngine.Object.FindObjectOfType -// (name-stable) rather than the SingletonMonoBehaviour accessor. +// * The show/hide toggle is the one private instance void(bool) on +// DebugConsole — ShowInputField on 2020.03, MCNBJFECHLJ on 2020.12, +// KIAMDEDKFBC on 2023.03. We try the known names, then fall back to +// a structural match. +// * The live component is found via Resources.FindObjectsOfTypeAll +// using an Il2CppSystem.Type argument; this interop build does not +// expose UnityEngine.Object.FindObjectOfType(System.Type). // * Execute(string) keeps its readable name on both builds (kept here as // a scripted-command entry point even though the hotkey drives the UI). internal static class DebugConsolePatches @@ -1656,12 +1765,23 @@ internal static class DebugConsolePatches private static bool _consoleResolved; private static Type? _consoleType; private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool) - private static MethodInfo? _findObjectOfType; // UnityEngine.Object.FindObjectOfType(Type) + private static MethodInfo? _setConsoleText; // DebugConsole.SetConsoleText(string) // Harmony prefix returning false → skips the original CheatManager // detector, so a tripped heuristic never runs its punish/drop path. public static bool SuppressCheatDetected_Prefix() => false; + public static string FormatConfiguredCommandsForLog() + { + var commands = GetConfiguredCommands(); + if (commands.Count == 0) return ""; + + var parts = new List(); + foreach (var command in commands) + parts.Add($"{command.Label}={command.Command}"); + return string.Join(", ", parts); + } + // Polled every frame from Mod.OnUpdate while EnableDebugConsole is set. public static void PollToggleKey() { @@ -1697,7 +1817,7 @@ internal static class DebugConsolePatches if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; } try { - var active = _findObjectOfType?.Invoke(null, new object[] { _consoleType }); + var active = FindConsoleInstance(); Mod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "")}, activeInstance={(active is null ? "null" : "found")}"); } catch (Exception ex) { Mod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); } @@ -1750,13 +1870,9 @@ internal static class DebugConsolePatches // name, then a structural fallback that fits both builds. _toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) }) ?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) }) + ?? AccessTools.Method(_consoleType, "KIAMDEDKFBC", new[] { typeof(bool) }) ?? FindToggleByShape(_consoleType); if (_toggleMethod is null) Mod.Log.Warning("[debugconsole] show/hide method not found"); - - var objType = Mod.ResolveType("UnityEngine.Object"); - _findObjectOfType = objType is null ? null - : AccessTools.Method(objType, "FindObjectOfType", new[] { typeof(Type) }); - if (_findObjectOfType is null) Mod.Log.Warning("[debugconsole] Object.FindObjectOfType(Type) not found"); } // The console's show/hide is the single private, instance, void method @@ -1782,10 +1898,10 @@ internal static class DebugConsolePatches public static void Toggle() { if (!_consoleResolved) ResolveConsole(); - if (_consoleType is null || _toggleMethod is null || _findObjectOfType is null) return; + if (_consoleType is null || _toggleMethod is null) return; try { - var instance = _findObjectOfType.Invoke(null, new object[] { _consoleType }); + var instance = FindConsoleInstance(); if (instance is null) { Mod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build"); @@ -1798,10 +1914,109 @@ internal static class DebugConsolePatches _consoleVisible = !_consoleVisible; _toggleMethod.Invoke(instance, new object[] { _consoleVisible }); Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}"); + if (_consoleVisible) + ShowConfiguredCommands(instance); } catch (Exception ex) { Mod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); } } + private static object? FindConsoleInstance() + { + if (_consoleType is null) return null; + try + { + var il2cppType = ToIl2CppType(_consoleType); + if (il2cppType is null) return null; + + var resources = Mod.ResolveType("UnityEngine.Resources"); + var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType }); + var count = ArrLen(all); + for (var i = 0; i < count; i++) + { + var item = ArrItem(all, i); + if (item is not null) return item; + } + + var objType = Mod.ResolveType("UnityEngine.Object"); + var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType); + return findOne?.Invoke(null, new[] { il2cppType }); + } + catch (Exception ex) + { + Mod.Log.Warning($"[debugconsole] FindConsoleInstance failed: {ex.Message}"); + return null; + } + } + + private static void ShowConfiguredCommands(object instance) + { + try + { + if (_consoleType is null) return; + _setConsoleText ??= AccessTools.Method(_consoleType, "SetConsoleText", new[] { typeof(string) }); + if (_setConsoleText is null) + { + Mod.Log.Warning($"[debugconsole] SetConsoleText(string) not found; configured commands: {FormatConfiguredCommandsForLog()}"); + return; + } + + _setConsoleText.Invoke(instance, new object[] { BuildConfiguredCommandText() }); + Mod.Log.Msg($"[debugconsole] displayed configured commands: {FormatConfiguredCommandsForLog()}"); + } + catch (Exception ex) + { + Mod.Log.Warning($"[debugconsole] failed to display configured commands: {ex.Message}"); + } + } + + private static string BuildConfiguredCommandText() + { + var commands = GetConfiguredCommands(); + var sb = new StringBuilder(); + sb.AppendLine("DorkNet debug commands"); + sb.AppendLine(); + if (commands.Count == 0) + { + sb.AppendLine("No commands configured in DevCommands."); + } + else + { + for (var i = 0; i < commands.Count; i++) + sb.AppendLine($"{i + 1}. {commands[i].Label}: {commands[i].Command}"); + } + sb.AppendLine(); + sb.AppendLine("Type a command and press Enter."); + return sb.ToString().TrimEnd(); + } + + private static List GetConfiguredCommands() + { + var commands = new List(); + foreach (var entry in Mod.Cfg.DevCommands) + { + if (string.IsNullOrWhiteSpace(entry)) continue; + var split = entry.IndexOf('='); + var label = split >= 0 ? entry[..split].Trim() : entry.Trim(); + var command = split >= 0 ? entry[(split + 1)..].Trim() : entry.Trim(); + if (string.IsNullOrWhiteSpace(command)) continue; + if (string.IsNullOrWhiteSpace(label)) label = command; + commands.Add(new ConfiguredCommand(label, command)); + } + return commands; + } + + private readonly struct ConfiguredCommand + { + public ConfiguredCommand(string label, string command) + { + Label = label; + Command = command; + } + + public string Label { get; } + public string Command { get; } + } + private static void EnableShowButton() { if (_consoleType is null) return; @@ -1817,6 +2032,52 @@ internal static class DebugConsolePatches } catch { /* best-effort — the toggle still works without it */ } } + + private static object? ToIl2CppType(Type managed) + { + try + { + var il2cppTypeType = Mod.ResolveType("Il2CppInterop.Runtime.Il2CppType"); + var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) }) + ?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) }); + if (from is null) return null; + var args = from.GetParameters().Length == 2 + ? new object[] { managed, true } + : new object[] { managed }; + return from.Invoke(null, args); + } + catch { return null; } + } + + private static MethodInfo? OneArgMethod(Type? type, string name, object arg) + { + if (type is null) return null; + foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (method.Name != name) continue; + var parameters = method.GetParameters(); + if (parameters.Length == 1 && parameters[0].ParameterType.IsInstanceOfType(arg)) + return method; + } + return null; + } + + private static int ArrLen(object? array) + { + if (array is null) return 0; + if (array is Array managedArray) return managedArray.Length; + var type = array.GetType(); + var length = type.GetProperty("Length") ?? type.GetProperty("Count"); + return (length?.GetValue(array) as int?) ?? 0; + } + + private static object? ArrItem(object? array, int index) + { + if (array is null) return null; + if (array is Array managedArray) return managedArray.GetValue(index); + var getItem = array.GetType().GetMethod("get_Item", new[] { typeof(int) }); + return getItem?.Invoke(array, new object[] { index }); + } } @@ -3545,6 +3806,14 @@ internal static class TlsPatches } } +internal static class ImageSignaturePatches +{ + public static bool VerifyImageSignature_Prefix() + { + return false; + } +} + internal static class QuitTracePatches { public static bool ApplicationQuit_Prefix() diff --git a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs index 4d80168..e3dd0da 100644 --- a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs +++ b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs @@ -135,9 +135,31 @@ public class InventionsController( [HttpPost("api/inventions/v1/dormskinsfromids")] public async Task Batch([FromBody] InventionBatchRequest req) { - if (req?.InventionIds is null || req.InventionIds.Count == 0) + return await BatchIdsAsync(req?.InventionIds ?? []); + } + + [HttpGet("api/inventions/v1/batch")] + [HttpGet("api/inventions/v2/batch")] + [HttpGet("api/inventions/v1/dormskinsfromids")] + public async Task BatchFromQuery() + { + var ids = Request.Query + .SelectMany(q => q.Value) + .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Select(v => long.TryParse(v, out var id) ? id : 0L) + .Where(id => id > 0) + .Distinct() + .Take(200) + .ToList(); + + return await BatchIdsAsync(ids); + } + + private async Task BatchIdsAsync(IReadOnlyCollection requestedIds) + { + if (requestedIds.Count == 0) return Ok(Array.Empty()); - var ids = req.InventionIds.Take(200).ToList(); + var ids = requestedIds.Take(200).ToList(); var rows = await db.Inventions .Where(i => !i.IsDeleted && ids.Contains(i.Id)) .ToListAsync(); diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index b587e25..d0f4315 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -218,7 +218,10 @@ public class RoomsController( } [HttpGet("rooms/{roomId:long}")] - public async Task RoomServerById(long roomId) + public async Task RoomServerById( + long roomId, + [FromQuery] int? unityAssetTarget, + [FromQuery] int? unityAssetVersion) { var room = await rooms.GetByIdAsync(roomId) ?? Synthetic($"Room_{roomId}", roomId); var sceneRows = await db.RoomScenes @@ -229,7 +232,12 @@ public class RoomsController( .Where(r => r.RoomId == room.Id) .ToListAsync(); - return Ok(BuildRoomServerDetails(room, sceneRows, roles: roles)); + return Ok(BuildRoomServerDetails( + room, + sceneRows, + roles: roles, + unityAssetTarget: unityAssetTarget, + unityAssetVersion: unityAssetVersion)); } // ── RoomDetails (the boot-critical one) ───────────────────────────── @@ -312,6 +320,270 @@ public class RoomsController( return Ok(blobs); } + /// GET rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId} + /// — Studio room save metadata. The 2023 client can load baked Studio + /// scenes by first asking this endpoint which Unity asset bundle file + /// belongs to the save, then downloading it from + /// cdn.../room/{Filename}.assetbundle. The importer persists + /// the exact bundle filenames from the Studio dump on RoomScenes, so + /// this response is backed by the imported archive bytes rather than a + /// placeholder. + [HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/{saveId:long}")] + [HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/{saveId:long}")] + [Authorize] + public async Task StudioSubRoomSave( + long roomId, + long subRoomId, + long saveId, + [FromQuery] int? unityAssetTarget, + [FromQuery] int? unityAssetVersion) + { + var room = await rooms.GetByIdAsync(roomId); + if (room is null) return NotFound(new { error = "room_not_found", roomId }); + + var sceneRows = await db.RoomScenes + .Where(s => s.RoomId == roomId) + .OrderBy(s => s.OrderIndex) + .ToListAsync(); + var scene = sceneRows.FirstOrDefault(s => s.OrderIndex == (int)subRoomId) + ?? sceneRows.FirstOrDefault(s => s.StudioSubRoomDataSaveId == saveId); + if (scene is null) return NotFound(new { error = "subroom_not_found", roomId, subRoomId }); + + return Ok(BuildStudioSaveWire(room, scene, subRoomId, saveId, unityAssetTarget, unityAssetVersion)); + } + + /// GET rooms/{roomId}/subrooms — the subroom list the + /// 2023 client / Studio editor fetches + /// (String.Format("rooms/{0}/subrooms", roomId)). Emits the same + /// per-subroom shape as the SubRooms[] entries inside + /// /roomserver/rooms/{id}, so a single-scene room synthesises one + /// "Home" entry and a multi-scene imported room enumerates its + /// RoomScenes. + [HttpGet("rooms/{roomId:long}/subrooms")] + [HttpGet("roomserver/rooms/{roomId:long}/subrooms")] + [Authorize] + public async Task SubRooms(long roomId) + { + var room = await rooms.GetByIdAsync(roomId); + if (room is null) return NotFound(new { error = "room_not_found", roomId }); + + var sceneRows = await db.RoomScenes + .Where(s => s.RoomId == roomId) + .OrderBy(s => s.OrderIndex) + .ToListAsync(); + + var wire = sceneRows.Count > 0 + ? sceneRows.Select(s => BuildSubRoomWire(room, s)).ToArray() + : new[] { BuildSubRoomWire(room, null) }; + return Ok(wire); + } + + /// GET rooms/{roomId}/subrooms/{subRoomId} — one subroom + /// (String.Format("rooms/{0}/subrooms/{1}", roomId, subRoomId)). + /// is the RoomScene OrderIndex. + [HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}")] + [HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")] + [Authorize] + public async Task SubRoom(long roomId, long subRoomId) + { + var room = await rooms.GetByIdAsync(roomId); + if (room is null) return NotFound(new { error = "room_not_found", roomId }); + + var scene = await db.RoomScenes + .Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId) + .FirstOrDefaultAsync(); + // A single-scene room has no RoomScene row; subRoom 0 is the + // synthesised "Home" scene. + if (scene is null && subRoomId != 0) + return NotFound(new { error = "subroom_not_found", roomId, subRoomId }); + + return Ok(BuildSubRoomWire(room, scene)); + } + + /// GET rooms/{roomId}/subrooms/{subRoomId}/data — the + /// current persisted save for the subroom + /// (String.Format("rooms/{0}/subrooms/{1}/data", roomId, subRoomId)). + /// Returns the same SubRoomDataSave shape as + /// /saves/{saveId} resolved to the scene's current save id. + [HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")] + [HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")] + [Authorize] + public async Task SubRoomData( + long roomId, + long subRoomId, + [FromQuery] int? unityAssetTarget, + [FromQuery] int? unityAssetVersion) + { + var room = await rooms.GetByIdAsync(roomId); + if (room is null) return NotFound(new { error = "room_not_found", roomId }); + + var scene = await db.RoomScenes + .Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId) + .FirstOrDefaultAsync(); + if (scene is null && subRoomId != 0) + return NotFound(new { error = "subroom_not_found", roomId, subRoomId }); + + var saveId = scene?.StudioSubRoomDataSaveId ?? 0L; + return Ok(BuildStudioSaveWire(room, scene, subRoomId, saveId, unityAssetTarget, unityAssetVersion)); + } + + /// GET rooms/{roomId}/subrooms/{subRoomId}/saves — the + /// list of saves for a subroom + /// (String.Format("rooms/{0}/subrooms/{1}/saves", roomId, subRoomId)). + /// Returns the current save plus any historical blobs recorded for the + /// subroom, newest first, each in the SubRoomDataSave shape. + [HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")] + [HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")] + [Authorize] + public async Task SubRoomSaves(long roomId, long subRoomId) + { + var room = await rooms.GetByIdAsync(roomId); + if (room is null) return NotFound(new { error = "room_not_found", roomId }); + + var scene = await db.RoomScenes + .Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId) + .FirstOrDefaultAsync(); + if (scene is null && subRoomId != 0) + return NotFound(new { error = "subroom_not_found", roomId, subRoomId }); + + var currentSaveId = scene?.StudioSubRoomDataSaveId ?? 0L; + var saves = new List + { + BuildStudioSaveWire(room, scene, subRoomId, currentSaveId, null, null), + }; + + // Historical blobs for this subroom (the same rows the datahistory + // endpoint surfaces), surfaced as prior saves so the Studio editor's + // version picker has the full set. Distinct by blob name, current + // save excluded (already first in the list). + var history = await db.RoomDataBlobs + .Where(b => b.RoomId == roomId && b.SubRoomId == subRoomId) + .OrderByDescending(b => b.UploadedAt) + .Take(50) + .ToListAsync(); + foreach (var h in history) + { + if (!string.IsNullOrWhiteSpace(scene?.DataBlobName) && + string.Equals(h.BlobName, scene!.DataBlobName, StringComparison.OrdinalIgnoreCase)) + continue; + saves.Add(new + { + SubRoomDataSaveId = currentSaveId, + SubRoomId = subRoomId, + UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty, + SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty, + ReferencedUnityAssetIds = Array.Empty(), + DataBlob = h.BlobName, + DataBlobName = h.BlobName, + PersistenceVersion = 41, + OMVersion = 0, + SavedByAccountId = h.UploadedByPlayerId, + SavedOnPlatform = 7, + SavedOnDeviceClass = 2, + SavedAt = (h.UploadedAt == default ? DateTime.UtcNow : h.UploadedAt) + .ToString("yyyy-MM-ddTHH:mm:ssZ"), + }); + } + + return Ok(saves); + } + + /// Build one subroom entry in the wire shape the watch's + /// SubRooms[] array uses (see BuildRoomServerDetails). A + /// null yields the synthesised "Home" scene for + /// single-scene rooms. + private object BuildSubRoomWire(RoomEntity room, RoomSceneEntity? scene) + { + var fallback = CurrentOrSyntheticDataBlobName(room); + var orderIndex = scene?.OrderIndex ?? 0; + var dataBlob = scene is null + ? fallback + : SceneOrSyntheticDataBlobName(room, scene.DataBlobName, fallback); + var modified = scene?.DataModifiedAt ?? room.UpdatedAt; + return new + { + SubRoomId = (long)orderIndex, + RoomId = room.Id, + Name = scene?.Name ?? "Home", + DataBlob = dataBlob, + DataSavedAt = (modified == default ? DateTime.UtcNow : modified) + .ToString("yyyy-MM-ddTHH:mm:ssZ"), + IsSandbox = scene?.IsSandbox ?? false, + MaxPlayers = scene?.MaxPlayers ?? 8, + Accessibility = room.Accessibility, + UnitySceneId = scene?.RoomSceneLocationId ?? room.LocationReplicationId, + UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty, + SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty, + CurrentSave = new + { + SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L, + SubRoomId = (long)orderIndex, + UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty, + SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty, + ReferencedUnityAssetIds = Array.Empty(), + DataBlob = dataBlob, + }, + }; + } + + /// Build the SubRoomDataSave wire object for a Studio + /// save — shared by /saves/{saveId}, /data, and the first + /// entry of /saves. A null (single-scene + /// room) returns a save with no baked bundles, pointing at the room's + /// current data blob. + private object BuildStudioSaveWire( + RoomEntity room, + RoomSceneEntity? scene, + long subRoomId, + long saveId, + int? unityAssetTarget, + int? unityAssetVersion) + { + var bundles = StudioBundlesForScene(scene, saveId); + + if (bundles.Count == 0 && scene is not null) + { + logger.LogWarning( + "[rooms-studio-save] no asset bundles room={RoomId} subRoom={SubRoomId} save={SaveId} scene={Scene} csvLen={CsvLen}", + room.Id, subRoomId, saveId, scene.Name, scene.StudioAssetBundleNamesCsv?.Length ?? 0); + } + + var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty; + var bakedAssets = BuildStudioBakedAssets(room, scene, saveId, unityAssetTarget, unityAssetVersion); + var primary = bakedAssets.FirstOrDefault(); + var unityAsset = BuildStudioUnityAsset(room, scene, bakedAssets); + var dataBlob = !string.IsNullOrWhiteSpace(scene?.DataBlobName) + ? scene!.DataBlobName + : CurrentOrSyntheticDataBlobName(room); + + logger.LogInformation( + "[rooms-studio-save] room={RoomId} subRoom={SubRoomId} save={SaveId} scene={Scene} target={Target} version={Version} primary={Primary} bundles={BundleCount}", + room.Id, subRoomId, saveId, scene?.Name ?? "Home", unityAssetTarget, unityAssetVersion, + primary?.Filename ?? string.Empty, bundles.Count); + + return new + { + SubRoomDataSaveId = saveId, + SubRoomId = subRoomId, + UnityAssetId = unityAssetId, + SubRoomUnityAssetId = unityAssetId, + ReferencedUnityAssetIds = Array.Empty(), + DataBlob = dataBlob, + DataBlobName = dataBlob, + PersistenceVersion = 41, + OMVersion = 0, + SavedByAccountId = room.CreatorPlayerId, + SavedOnPlatform = 7, + SavedOnDeviceClass = 2, + CreatedByAccountId = room.CreatorPlayerId, + UnityAssetHash = string.Empty, + UnityAsset = unityAsset, + UnityAssetFilename = primary?.Filename ?? string.Empty, + BakedUnityAssets = bakedAssets, + UnitySubAssets = Array.Empty(), + }; + } + /// POST roomserver/rooms/{roomId}/subrooms/{subRoomId}/restoredata /// — the watch's "Go" button in the Restore Room dialog. Body is /// form-urlencoded with a single filename field carrying the @@ -1300,7 +1572,9 @@ public class RoomsController( RoomEntity room, IReadOnlyList? sceneRows = null, string? overrideDataBlobName = null, - IReadOnlyList? roles = null) + IReadOnlyList? roles = null, + int? unityAssetTarget = null, + int? unityAssetVersion = null) { var roleList = roles ?? Array.Empty(); // 2023's room-permissions runtime needs a real PersistedRoomData @@ -1311,34 +1585,118 @@ public class RoomsController( .ToString("yyyy-MM-ddTHH:mm:ssZ"); object[] subRooms = sceneRows is { Count: > 0 } - ? sceneRows.Select(s => (object)new + ? sceneRows.Select(s => { - SubRoomId = (long)s.OrderIndex, - RoomId = room.Id, - Name = s.Name, - DataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1 + var dataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1 ? overrideDataBlobName - : SceneOrSyntheticDataBlobName(room, s.DataBlobName, dataBlobName), - DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt) - .ToString("yyyy-MM-ddTHH:mm:ssZ"), - IsSandbox = s.IsSandbox, - MaxPlayers = s.MaxPlayers, - Accessibility = room.Accessibility, - UnitySceneId = s.RoomSceneLocationId, + : SceneOrSyntheticDataBlobName(room, s.DataBlobName, dataBlobName); + // A scene with a baked Studio asset (non-empty UnityAssetId) + // must advertise a Studio-era PersistenceVersion on its + // CurrentSave. The 2023 client gates the baked-Addressables + // load path on this: a version of 0 (the old default) makes + // it treat the save as a pre-baked legacy blob and it never + // fetches unity_assets/{id}/{target}/{version}, so the + // persistence views reference prefabs that were never loaded + // ("Expected a prefab, but found none"). 41 matches the real + // RecRocks export (room.json PersistenceVersion=41). + var baked = !string.IsNullOrWhiteSpace(s.StudioUnityAssetId); + var saveVersion = baked ? StudioPersistenceVersion : 0; + var bakedAssets = BuildStudioBakedAssets( + room, + s, + s.StudioSubRoomDataSaveId ?? 0L, + unityAssetTarget, + unityAssetVersion); + var unityAsset = bakedAssets.FirstOrDefault(); + var unityAssetParent = BuildStudioUnityAsset(room, s, bakedAssets); + return (object)new + { + ReplicationId = s.RoomSceneLocationId, + PersistenceVersion = saveVersion, + SupportsJoinInProgress = true, + UseLevelBasedMatchmaking = false, + UseAgeBasedMatchmaking = false, + UseRecRoyaleMatchmaking = false, + SubRoomId = (long)s.OrderIndex, + RoomId = room.Id, + Name = s.Name, + UnityAssetId = s.StudioUnityAssetId, + SubRoomUnityAssetId = s.StudioUnityAssetId, + UnityAsset = unityAsset?.Filename ?? string.Empty, + UnityAssetHash = string.Empty, + DataBlob = dataBlob, + DataBlobHash = (string?)null, + DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt) + .ToString("yyyy-MM-ddTHH:mm:ssZ"), + IsSandbox = s.IsSandbox, + MaxPlayers = s.MaxPlayers, + Accessibility = room.Accessibility, + UnitySceneId = s.RoomSceneLocationId, + CurrentSave = new + { + SubRoomDataSaveId = s.StudioSubRoomDataSaveId ?? 0L, + SubRoomId = (long)s.OrderIndex, + UnityAssetId = s.StudioUnityAssetId, + SubRoomUnityAssetId = s.StudioUnityAssetId, + CreatedByAccountId = room.CreatorPlayerId, + ReferencedUnityAssetIds = Array.Empty(), + DataBlob = dataBlob, + DataBlobHash = (string?)null, + PersistenceVersion = saveVersion, + OMVersion = 0, + SavedByAccountId = room.CreatorPlayerId, + SavedOnPlatform = 0, + SavedOnDeviceClass = 2, + Description = string.Empty, + ModerationState = 0, + CreatedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt) + .ToString("yyyy-MM-ddTHH:mm:ssZ"), + UgcSubVersion = saveVersion, + UnityAssetHash = string.Empty, + UnityAsset = unityAssetParent, + UnityAssetFilename = unityAsset?.Filename ?? string.Empty, + BakedUnityAssets = bakedAssets, + UnitySubAssets = Array.Empty(), + }, + LastModeratedSaveModerationState = 0, + DefaultMatchmakingPolicy = 0, + ShouldAutoStageSaves = true, + StagedSubRoomDataSaveId = (long?)null, + }; }).ToArray() : new object[] { new { + ReplicationId = room.LocationReplicationId, + PersistenceVersion = 0, + SupportsJoinInProgress = true, + UseLevelBasedMatchmaking = false, + UseAgeBasedMatchmaking = false, + UseRecRoyaleMatchmaking = false, SubRoomId = 0L, RoomId = room.Id, Name = "Home", + UnityAssetId = string.Empty, + SubRoomUnityAssetId = string.Empty, + UnityAsset = string.Empty, + UnityAssetHash = string.Empty, DataBlob = dataBlobName, + DataBlobHash = (string?)null, DataSavedAt = updatedAt, IsSandbox = false, MaxPlayers = 8, Accessibility = room.Accessibility, UnitySceneId = room.LocationReplicationId, + CurrentSave = new + { + SubRoomDataSaveId = 0L, + SubRoomId = 0L, + UnityAssetId = string.Empty, + SubRoomUnityAssetId = string.Empty, + ReferencedUnityAssetIds = Array.Empty(), + DataBlob = dataBlobName, + }, }, }; @@ -1389,6 +1747,19 @@ public class RoomsController( DisableMicAutoMute = room.DisableMicAutoMute, DisableRoomComments = false, EncryptVoiceChat = false, + // Studio/UGC version family. The 2023 client reads UgcVersion to + // decide the asset-bundle version it requests + // (unity_assets/{id}/{target}/{UgcVersion}) and PersistenceVersion + // to recognise a baked Studio room at all. Matches the real + // RecRocks room.json (UgcVersion=1, PersistenceVersion=41). For a + // non-Studio room these stay at the legacy 0 so behaviour is + // unchanged. + PersistenceVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0, + UgcVersion = room.IsStudioRoom ? StudioUgcVersion : 0, + UgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0, + MinUgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0, + PublishState = 0, + MaxPlayerCalculationMode = 0, SubRooms = subRooms, Roles = wireRoles, LoadScreens = Array.Empty(), @@ -1398,6 +1769,18 @@ public class RoomsController( }; } + /// PersistenceVersion / UgcSubVersion stamped on a baked Studio + /// scene's CurrentSave. The 2023 client's room-load gate treats anything + /// below the Studio-asset-bundle era as a legacy non-baked save and skips + /// the Addressables bundle fetch. 41 matches the real RecRocks export + /// (room.json + the per-save sidecars). + public const int StudioPersistenceVersion = 41; + + /// Room-level UgcVersion for a Studio room. The client uses this + /// as the asset-bundle version in unity_assets/{id}/{target}/{version} + /// (RecRocks bundles bake at v1, room.json UgcVersion=1). + public const int StudioUgcVersion = 1; + // ── Clone / create ─────────────────────────────────────────────────── @@ -1564,7 +1947,7 @@ public class RoomsController( public async Task RoomsRequiring(string restriction) { var key = (restriction ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant(); - if (key.Length == 0) return Ok(new List()); + if (key.Length == 0) return Ok(new List()); IQueryable query = db.Rooms.AsNoTracking() .Where(r => !r.HiddenFromBrowse && r.State == 0); @@ -1585,9 +1968,10 @@ public class RoomsController( var rows = await query .OrderByDescending(r => r.HotScore) .ThenBy(r => r.Name) + .Select(r => r.Name) .Take(100) .ToListAsync(); - return Ok(rows.Select(RoomService.ToWireRoom).ToList()); + return Ok(rows); } [HttpGet("rooms/curated_playlists")] @@ -2709,6 +3093,93 @@ public class RoomsController( return fallbackBlobName; } + private sealed record StudioBundleInfo(long SaveId, int Target, int Version, string Filename); + + private sealed record BakedUnityAssetWire( + string UnityAssetId, + long CreatedByAccountId, + string Filename, + int Target, + int Version, + string Hash); + + private sealed record UnityAssetWire( + string UnityAssetId, + long CreatedByAccountId, + string Filename, + BakedUnityAssetWire[] BakedUnityAssets); + + private static BakedUnityAssetWire[] BuildStudioBakedAssets( + RoomEntity room, + RoomSceneEntity? scene, + long saveId, + int? unityAssetTarget, + int? unityAssetVersion) + { + var bundles = StudioBundlesForScene(scene, saveId); + if (unityAssetTarget is int target) + bundles = bundles.Where(b => b.Target == target).ToList(); + if (unityAssetVersion is int requestedVersion && bundles.Any(b => b.Version == requestedVersion)) + bundles = bundles.Where(b => b.Version == requestedVersion).ToList(); + + var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty; + return bundles + .OrderBy(b => b.Target) + .ThenByDescending(b => b.Version) + .Select(b => new BakedUnityAssetWire( + unityAssetId, + room.CreatorPlayerId, + b.Filename, + b.Target, + b.Version, + string.Empty)) + .ToArray(); + } + + private static UnityAssetWire? BuildStudioUnityAsset( + RoomEntity room, + RoomSceneEntity? scene, + BakedUnityAssetWire[] bakedAssets) + { + var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty; + if (string.IsNullOrWhiteSpace(unityAssetId)) + return null; + + var filename = bakedAssets.FirstOrDefault()?.Filename ?? string.Empty; + return new UnityAssetWire(unityAssetId, room.CreatorPlayerId, filename, bakedAssets); + } + + private static List StudioBundlesForScene(RoomSceneEntity? scene, long saveId) + { + return (scene?.StudioAssetBundleNamesCsv ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(ParseStudioBundleName) + .Where(b => b is not null) + .Cast() + .Where(b => b.SaveId == saveId || scene?.StudioSubRoomDataSaveId == saveId) + .OrderBy(b => b.Target) + .ThenByDescending(b => b.Version) + .ToList(); + } + + private static StudioBundleInfo? ParseStudioBundleName(string fileName) + { + var name = Path.GetFileName(fileName); + var match = System.Text.RegularExpressions.Regex.Match( + name, + @"^(?\d+)__bundle_t(?\d+)(?:_v(?\d+))?\.assetbundle$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant); + if (!match.Success) return null; + if (!long.TryParse(match.Groups["save"].Value, out var saveId)) return null; + if (!int.TryParse(match.Groups["target"].Value, out var target)) return null; + var version = 0; + if (match.Groups["version"].Success && + !int.TryParse(match.Groups["version"].Value, out version)) + return null; + return new StudioBundleInfo(saveId, target, version, name); + } + [HttpGet("rooms/{roomId:long}/roles/{playerId:long}")] public async Task RoleForPlayer(long roomId, long playerId) { diff --git a/DorkNet.Server/Controllers/Admin/RoomZipImportController.cs b/DorkNet.Server/Controllers/Admin/RoomZipImportController.cs index 4da86e3..d119c60 100644 --- a/DorkNet.Server/Controllers/Admin/RoomZipImportController.cs +++ b/DorkNet.Server/Controllers/Admin/RoomZipImportController.cs @@ -59,6 +59,7 @@ public class RoomZipImportController( ILogger logger) : ControllerBase { private long CurrentAdminId => this.RequireCurrentPlayerId(); + private const long MaxArchiveBytes = 12_000_000_000L; // ── Room-blob normaliser toggle ───────────────────────────────────── // The normaliser does two things now (see RoomBlobNormalizerService): @@ -128,6 +129,7 @@ public class RoomZipImportController( ".mp4" => "video/mp4", ".htr" => "application/octet-stream", ".room" => "application/octet-stream", + ".assetbundle" => "application/octet-stream", ".meta" => "application/octet-stream", ".inv" => "application/octet-stream", ".dat" => "application/octet-stream", @@ -220,6 +222,7 @@ public class RoomZipImportController( /// missing, the importer derives it from the SubRooms/<scene> /// folder name suffix (SubRoomName__<subRoomId>). public long? SubRoomId { get; set; } + public string? UnityAssetId { get; set; } public string? DataBlob { get; set; } public DateTime? CreatedAt { get; set; } public string? Description { get; set; } @@ -268,7 +271,7 @@ public class RoomZipImportController( [HttpPost("zip-bulk-import")] [DisableRequestSizeLimit] - [RequestFormLimits(MultipartBodyLengthLimit = 5_000_000_000)] // 5 GB + [RequestFormLimits(MultipartBodyLengthLimit = MaxArchiveBytes)] public async Task ZipImport( [FromForm(Name = "archive")] IFormFile? archive, [FromForm(Name = "creatorPlayerId")] long? creatorPlayerId, @@ -320,8 +323,8 @@ public class RoomZipImportController( [HttpPost("zip-upload-init")] public ActionResult ChunkInit([FromBody] ChunkInitRequest body) { - if (body.TotalBytes <= 0 || body.TotalBytes > 5_000_000_000) - return BadRequest(new { error = "invalid_size", maxBytes = 5_000_000_000L, gotBytes = body.TotalBytes }); + if (body.TotalBytes <= 0 || body.TotalBytes > MaxArchiveBytes) + return BadRequest(new { error = "invalid_size", maxBytes = MaxArchiveBytes, gotBytes = body.TotalBytes }); // Prune stale sessions before allocating a new one. Anything // older than 2 hours with no activity is abandoned. @@ -914,9 +917,11 @@ public class RoomZipImportController( // it reflects whether the diagnostic parse succeeded, not // whether we actually applied the normalised output. NormalizedOk: norm.Normalized, + StudioUnityAssetId: null, HtrAssets: htrAssets, PvImages: pvImages, Polaroids: polaroids, + AssetBundles: new List<(string Name, ZipArchiveEntry Entry)>(), History: history)); logger.LogInformation( "[zip-import] room='{Room}' scene='{Scene}' blob={Blob} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol}", @@ -1044,9 +1049,11 @@ public class RoomZipImportController( var htrAssetCount = 0; var pvImageCount = 0; var polaroidCount = 0; + var assetBundleCount = 0; var allHtrAssets = ordered.SelectMany(s => s.HtrAssets).ToList(); var allPvImages = ordered.SelectMany(s => s.PvImages).ToList(); var allPolaroids = ordered.SelectMany(s => s.Polaroids).ToList(); + var allAssetBundles = ordered.SelectMany(s => s.AssetBundles).ToList(); // De-dupe within the archive first (same .htr can appear under // multiple scenes), then de-dupe against the DB. Both layers @@ -1054,6 +1061,7 @@ public class RoomZipImportController( var uniqueHtrNames = allHtrAssets.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var uniquePvNames = allPvImages.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var uniquePolaroidNames = allPolaroids.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var uniqueAssetBundleNames = allAssetBundles.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var existingHtrNames = new HashSet( await db.RoomDataBlobs @@ -1070,9 +1078,15 @@ public class RoomZipImportController( .Where(b => uniquePolaroidNames.Contains(b.BlobName)) .Select(b => b.BlobName).ToListAsync(), StringComparer.OrdinalIgnoreCase); + var existingAssetBundleNames = new HashSet( + await db.RoomDataBlobs + .Where(b => uniqueAssetBundleNames.Contains(b.BlobName)) + .Select(b => b.BlobName).ToListAsync(), + StringComparer.OrdinalIgnoreCase); var htrAlreadyInDbAtStart = existingHtrNames.Count; var pvAlreadyInDbAtStart = existingPvNames.Count; var polaroidAlreadyInDbAtStart = existingPolaroidNames.Count; + var assetBundleAlreadyInDbAtStart = existingAssetBundleNames.Count; // History (SubRooms//History/.room) — pre-load // existing names so the de-dup HashSet covers prior imports + same @@ -1173,6 +1187,17 @@ public class RoomZipImportController( await WriteBlobAsync(0, polName, bytes, creator); polaroidCount++; } + // Studio baked Unity asset bundles. Keep original filenames; + // Studio save metadata points the 2023 client at + // cdn.../room/.assetbundle. + foreach (var (bundleName, bundleEntry) in allAssetBundles) + { + if (existingAssetBundleNames.Contains(bundleName)) continue; + existingAssetBundleNames.Add(bundleName); + var bytes = await ReadAllAsync(bundleEntry); + await WriteBlobAsync(0, bundleName, bytes, creator); + assetBundleCount++; + } // Room-root opaque marker files (e.g. .meta — 6-byte // sentinel the exporter drops, purpose unclear but watching // for fetches just in case). Rename to room__.meta @@ -1234,6 +1259,9 @@ public class RoomZipImportController( Accessibility = details.Accessibility ?? 1, IsAGRoom = false, IsDormRoom = details.IsDorm ?? false, + IsStudioRoom = entries.ContainsKey(studioDetailsKey), + IsRoomLinkedToRecRoomStudio = entries.ContainsKey(studioDetailsKey), + StudioSessionId = details.RoomId?.ToString() ?? TryParseStudioId(ZipFileName(roomFolder))?.ToString() ?? string.Empty, CloningAllowed = details.CloningAllowed ?? true, SupportsLevelVoting = details.SupportsLevelVoting ?? false, SupportsVRLow = details.SupportsVRLow ?? true, @@ -1246,7 +1274,7 @@ public class RoomZipImportController( CustomRoomWarning = details.CustomWarning ?? string.Empty, DisableMicAutoMute = details.DisableMicAutoMute ?? false, LocationReplicationId = entryLocation, - TagsCsv = JoinTags(details.Tags), + TagsCsv = EnsureStudioTags(JoinTags(details.Tags), entries.ContainsKey(studioDetailsKey)), CheerCount = details.Stats?.CheerCount ?? 0, FavoriteCount = details.Stats?.FavoriteCount ?? 0, VisitCount = details.Stats?.VisitCount ?? 0, @@ -1266,6 +1294,11 @@ public class RoomZipImportController( Name = s.SceneName, RoomSceneLocationId = s.Manifest.UnitySceneId ?? entryLocation, DataBlobName = MakeBlobName(s.SceneName), + StudioSubRoomDataSaveId = s.Manifest.CurrentSave?.SubRoomDataSaveId, + StudioUnityAssetId = s.StudioUnityAssetId ?? string.Empty, + StudioAssetBundleNamesCsv = string.Join(',', s.AssetBundles + .Select(a => a.Name) + .Distinct(StringComparer.OrdinalIgnoreCase)), MaxPlayers = s.Manifest.MaxPlayers ?? 8, IsSandbox = s.Manifest.IsSandbox ?? false, CanMatchmakeInto = true, @@ -1282,7 +1315,8 @@ public class RoomZipImportController( Reason = $"name={name} scenes={ordered.Count} entry={entry.SceneName} src={entrySource} " + $"htr={htrAssetCount}new+{htrAlreadyInDbAtStart}existed " + $"pv={pvImageCount}new+{pvAlreadyInDbAtStart}existed " + - $"polaroids={polaroidCount}new+{polaroidAlreadyInDbAtStart}existed", + $"polaroids={polaroidCount}new+{polaroidAlreadyInDbAtStart}existed " + + $"bundles={assetBundleCount}new+{assetBundleAlreadyInDbAtStart}existed", }); await db.SaveChangesAsync(); await tx.CommitAsync(); @@ -1318,6 +1352,12 @@ public class RoomZipImportController( newlyImported = polaroidCount, alreadyInDb = polaroidAlreadyInDbAtStart, }, + assetBundles = new + { + referenced = uniqueAssetBundleNames.Count, + newlyImported = assetBundleCount, + alreadyInDb = assetBundleAlreadyInDbAtStart, + }, history = new { referenced = allHistoryNames.Count, @@ -1350,6 +1390,7 @@ public class RoomZipImportController( maxPlayers = s.Manifest.MaxPlayers, htrAssets = s.HtrAssets.Count, pvImages = s.PvImages.Count, + assetBundles = s.AssetBundles.Count, }), }; } @@ -1430,9 +1471,19 @@ public class RoomZipImportController( var htrAssets = new List<(string Name, ZipArchiveEntry Entry)>(); var pvImages = new List<(string Name, ZipArchiveEntry Entry)>(); var polaroids = new List<(string Name, ZipArchiveEntry Entry)>(); + var assetBundles = new List<(string Name, ZipArchiveEntry Entry)>(); foreach (var save in saves) { + var bundlePrefix = $"{savesPrefix}{save.SaveId}__bundle_"; + foreach (var (path, assetEntry) in entries.Where(kv => + kv.Key.StartsWith(bundlePrefix, StringComparison.OrdinalIgnoreCase) + && kv.Key.EndsWith(".assetbundle", StringComparison.OrdinalIgnoreCase) + && !kv.Key[bundlePrefix.Length..].Contains('/'))) + { + assetBundles.Add((Path.GetFileName(path), assetEntry)); + } + var refPrefix = $"{savesPrefix}{save.SaveId}__ref_"; foreach (var (path, assetEntry) in entries.Where(kv => kv.Key.StartsWith(refPrefix, StringComparison.OrdinalIgnoreCase) @@ -1481,16 +1532,18 @@ public class RoomZipImportController( Bytes: persistedBytes, RawBytes: blobBytes.Length, NormalizedOk: norm.Normalized, + StudioUnityAssetId: current.Sidecar.UnityAssetId, HtrAssets: htrAssets, PvImages: pvImages, Polaroids: polaroids, + AssetBundles: assetBundles, History: history)); logger.LogInformation( - "[zip-import] studio-dump room='{Room}' scene='{Scene}' save={SaveId} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol} history={History}", + "[zip-import] studio-dump room='{Room}' scene='{Scene}' save={SaveId} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol} bundles={Bundles} history={History}", roomName, sceneName, current.SaveId, blobBytes.Length, norm.Bytes.Length, normalizeBlobs ? "normaliser" : "raw", - htrAssets.Count, pvImages.Count, polaroids.Count, history.Count); + htrAssets.Count, pvImages.Count, polaroids.Count, assetBundles.Count, history.Count); } return (resolvedScenes, missingScenes); @@ -1791,15 +1844,32 @@ public class RoomZipImportController( return values.Count > 0 ? string.Join(',', values!) : "community"; } + private static string EnsureStudioTags(string tagsCsv, bool isStudioDump) + { + if (!isStudioDump) return tagsCsv; + var values = (tagsCsv ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + if (!values.Contains("community", StringComparer.OrdinalIgnoreCase)) + values.Add("community"); + if (!values.Contains("studio", StringComparer.OrdinalIgnoreCase)) + values.Add("studio"); + if (!values.Contains("developer", StringComparer.OrdinalIgnoreCase)) + values.Add("developer"); + return string.Join(',', values); + } + private sealed record ResolvedSubroom( SubRoomDto Manifest, string SceneName, byte[] Bytes, int RawBytes, bool NormalizedOk, + string? StudioUnityAssetId, List<(string Name, ZipArchiveEntry Entry)> HtrAssets, List<(string Name, ZipArchiveEntry Entry)> PvImages, List<(string Name, ZipArchiveEntry Entry)> Polaroids, + List<(string Name, ZipArchiveEntry Entry)> AssetBundles, List History); /// One historical save under SubRooms/<scene>/History/. diff --git a/DorkNet.Server/Controllers/Cdn/CdnController.cs b/DorkNet.Server/Controllers/Cdn/CdnController.cs index 552bc11..1fba869 100644 --- a/DorkNet.Server/Controllers/Cdn/CdnController.cs +++ b/DorkNet.Server/Controllers/Cdn/CdnController.cs @@ -110,11 +110,12 @@ public class CdnController( // constraint they must be doubled — so `[^/]` becomes `[[^/]]`. // // Extension list: image formats + room/.htr/.inv blobs that the - // watch fetches directly, plus video formats (mp4/webm/mov/m4v) so + // watch fetches directly, Studio .assetbundle files, plus video + // formats (mp4/webm/mov/m4v) so // community-board video blobs uploaded via /admin/v1/communityboard/ // video/upload (which writes `cb_video_.mp4` etc.) resolve // through the same bare-filename catch-all. - [Route("/{path:regex(^[[^/]]+\\.(png|jpg|jpeg|webp|gif|dat|bin|holotar|inv|room|htr|mp4|webm|mov|m4v)$)}")] + [Route("/{path:regex(^[[^/]]+\\.(png|jpg|jpeg|webp|gif|dat|bin|holotar|inv|room|htr|assetbundle|mp4|webm|mov|m4v)$)}")] [AcceptVerbs("GET", "HEAD")] public async Task Serve(string? path) { @@ -140,6 +141,113 @@ public class CdnController( return await ServeCdn(fullPath); } + // ── Studio baked Unity asset-bundle download ─────────────────────── + /// GET unity_assets/{assetId}/{target}/{version} — the + /// 2023 client's asset-bundle download for baked Studio scenes. The + /// client builds this URL itself in RecNet.Runtime: + /// String.Format("unity_assets/{0}/{1}/{2}", + /// WebUtility.UrlEncode(unityAssetId), (byte)target, (int)version) + /// after reading UnityAssetId / Target / Version + /// from rooms/{id}/subrooms/{sid}/saves/{saveId}. It does NOT + /// fetch the bare {filename}.assetbundle the save metadata names + /// — so the bundle bytes only resolve through this id-addressed route. + /// + /// Resolution: the importer stores each baked bundle in S3 under + /// its original filename ({saveId}__bundle_t{target}_v{version}.assetbundle) + /// and stamps the owning scene's + /// + . + /// We look the scene up by StudioUnityAssetId == assetId, pick the + /// bundle whose embedded target+version match, and stream its bytes. + /// + /// A genuine miss 404s — handing back a default room blob here + /// (as the room-blob path does) would feed the Unity AssetBundle loader + /// garbage and fail the scene load far less legibly than an honest + /// 404. + [Route("/unity_assets/{assetId}/{target:int}/{version:int}")] + [AcceptVerbs("GET", "HEAD")] + public async Task ServeUnityAsset(string assetId, int target, int version) + { + assetId = (assetId ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(assetId)) return NotFound(); + + // The Studio UnityAssetId is the per-save asset id from the dump + // sidecar, unique per baked save, so this maps to one scene in + // practice; tolerate >1 by scanning each candidate's bundle list. + var scenes = await db.RoomScenes + .Where(s => s.StudioUnityAssetId == assetId) + .ToListAsync(); + + var candidates = scenes + .SelectMany(s => (s.StudioAssetBundleNamesCsv ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Select(ParseBundleName) + .Where(b => b is not null) + .Cast<(int Target, int Version, string Filename)>() + .Where(b => b.Target == target) + .ToList(); + + // Exact match first; otherwise serve the highest available version + // for the requested target (a Studio re-bake bumps the version, but + // a cached client manifest may still ask for a superseded one). + var pick = candidates.FirstOrDefault(b => b.Version == version); + if (pick.Filename is null && candidates.Count > 0) + pick = candidates.OrderByDescending(b => b.Version).First(); + + if (pick.Filename is null) + { + logger.LogWarning("[unity_assets] no bundle assetId={AssetId} target={Target} version={Version} scenes={Scenes}", + assetId, target, version, scenes.Count); + return NotFound(); + } + + var (bucket, key) = BlobRouter.Route(pick.Filename); + byte[]? bytes = null; + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + bytes = await storage.GetAsync(bucket, key, cts.Token); + } + catch (Exception ex) + { + logger.LogWarning(ex, "[unity_assets] S3 GetAsync threw {Bucket}/{Key}", bucket, key); + } + + if (bytes is not { Length: > 0 }) + { + logger.LogWarning("[unity_assets] MISS assetId={AssetId} file={File} bucket={Bucket} key={Key}", + assetId, pick.Filename, bucket, key); + return NotFound(); + } + + logger.LogInformation("[unity_assets] hit assetId={AssetId} target={Target} version={Version} file={File} bytes={Bytes}", + assetId, target, version, pick.Filename, bytes.Length); + Response.Headers.CacheControl = "public, max-age=3600"; + signatures.AddContentSignature(Response, bytes); + return new FileContentResult(bytes, "application/octet-stream"); + } + + /// Parse a Studio baked-bundle filename + /// ({saveId}__bundle_t{target}_v{version}.assetbundle, or older + /// {saveId}__bundle_t{target}.assetbundle) into its + /// target/version/filename. Mirrors the parser in RoomsController so the + /// id-addressed CDN route and the save-metadata endpoint agree on the + /// same naming contract. + private static (int Target, int Version, string Filename)? ParseBundleName(string fileName) + { + var name = Path.GetFileName(fileName); + var match = System.Text.RegularExpressions.Regex.Match( + name, + @"^(?\d+)__bundle_t(?\d+)(?:_v(?\d+))?\.assetbundle$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant); + if (!match.Success) return null; + if (!int.TryParse(match.Groups["target"].Value, out var target)) return null; + var version = 0; + if (match.Groups["version"].Success && + !int.TryParse(match.Groups["version"].Value, out version)) + return null; + return (target, version, name); + } + // ── Image pipeline (img.{apex}) ──────────────────────────────────── private async Task ServeImage(string? path) { @@ -512,6 +620,7 @@ public class CdnController( ".mp4" or ".m4v" => "video/mp4", ".webm" => "video/webm", ".mov" => "video/quicktime", + ".assetbundle" => "application/octet-stream", _ => "application/octet-stream", }; } diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs index 4d62ee6..1bf0221 100644 --- a/DorkNet.Server/Controllers/Match/GoToController.cs +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -247,7 +247,7 @@ public class GoToController( /// location/blob when we actually resolved one. private static void ApplySubRoomMutation( MatchmakingResponseDto resp, long subId, string subRoomName, - string? subLocation, string? subDataBlob) + string? subLocation, string? subDataBlob, string? subRoomUnityAssetId = null) { resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId; resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}"; @@ -255,6 +255,7 @@ public class GoToController( resp.RoomInstance.SubRoomId = subId; if (subLocation is not null) resp.RoomInstance.Location = subLocation; if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob; + resp.RoomInstance.SubRoomUnityAssetId = subRoomUnityAssetId ?? string.Empty; } /// POST match.*/matchmake/room/{roomId} — the 2023 @@ -329,14 +330,20 @@ public class GoToController( { var scene = await db.RoomScenes.AsNoTracking() .Where(s => s.RoomId == resp.RoomInstance.RoomId && s.OrderIndex == subRoomId) - .Select(s => new { s.Name, s.RoomSceneLocationId, s.DataBlobName }) + .Select(s => new { s.Name, s.RoomSceneLocationId, s.DataBlobName, s.StudioUnityAssetId }) .FirstOrDefaultAsync(); if (scene is not null) { logger.LogInformation( - "[matchmake-subroom] {Room}/{Sub} → scene '{Name}' loc={Loc} blob={Blob}", - roomId, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName); - ApplySubRoomMutation(resp, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName); + "[matchmake-subroom] {Room}/{Sub} → scene '{Name}' loc={Loc} blob={Blob} unityAsset={UnityAsset}", + roomId, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName, scene.StudioUnityAssetId); + ApplySubRoomMutation( + resp, + subRoomId, + scene.Name, + scene.RoomSceneLocationId, + scene.DataBlobName, + scene.StudioUnityAssetId); } else { @@ -684,6 +691,7 @@ public class GoToController( IsPrivate = true, IsInProgress = true, DataBlob = inst.DataBlob, + SubRoomUnityAssetId = await ResolveSubRoomUnityAssetIdAsync(inst.RoomId, inst.SubRoomId), }, }; } @@ -818,6 +826,7 @@ public class GoToController( IsPrivate = room.IsPrivate, IsInProgress = room.IsInProgress, DataBlob = room.DataBlob, + SubRoomUnityAssetId = room.SubRoomUnityAssetId, EventId = room.EventId, Name = room.Name, RoomInstanceType = room.RoomInstanceType, @@ -1200,6 +1209,7 @@ public class GoToController( ? $"^dormroom_p{ownerForName}" : $"^{roomName.ToLowerInvariant()}_{roomId}"; var dataBlob = await ResolveInitialDataBlobAsync(roomId, isDorm, dormOwnerId); + var subRoomUnityAssetId = await ResolveSubRoomUnityAssetIdAsync(roomId, 0); var resp = new MatchmakingResponseDto { @@ -1228,6 +1238,7 @@ public class GoToController( IsPrivate = false, IsInProgress = true, DataBlob = dataBlob, + SubRoomUnityAssetId = subRoomUnityAssetId, }, }; logger.LogInformation( @@ -1292,6 +1303,14 @@ public class GoToController( return RoomService.SyntheticDefaultRoomDataBlobName(room.Id); } + private async Task ResolveSubRoomUnityAssetIdAsync(long roomId, long subRoomId) + { + return await db.RoomScenes.AsNoTracking() + .Where(s => s.RoomId == roomId && s.OrderIndex == subRoomId) + .Select(s => s.StudioUnityAssetId) + .FirstOrDefaultAsync() ?? string.Empty; + } + /// /// Stable id-per-room-name map for well-known rooms; unknown names get /// a hash-based id so each room name routes to its own roomId space. @@ -1399,6 +1418,13 @@ public class RoomInstanceDto [JsonPropertyName("dataBlob")] public string DataBlob { get; set; } = string.Empty; + // 2023 RoomLoading.LoadRoomCommand carries this separately from + // dataBlob. If it is empty, the loader can deserialize room view data + // before the Studio asset bundle has registered its prefabs, producing + // "Bad View Data detected. Expected a prefab, but found none". + [JsonPropertyName("subRoomUnityAssetId")] + public string SubRoomUnityAssetId { get; set; } = string.Empty; + [JsonPropertyName("eventId")] public long EventId { get; set; } = 0; @@ -1472,6 +1498,7 @@ public class RoomInstanceDto IsPrivate = room.IsPrivate, IsInProgress = room.IsInProgress, DataBlob = string.Empty, + SubRoomUnityAssetId = room.SubRoomUnityAssetId, EventId = room.EventId, Name = room.Name, }; diff --git a/DorkNet.Server/Data/Entities/RoomSceneEntity.cs b/DorkNet.Server/Data/Entities/RoomSceneEntity.cs index e58cb6f..93bebb5 100644 --- a/DorkNet.Server/Data/Entities/RoomSceneEntity.cs +++ b/DorkNet.Server/Data/Entities/RoomSceneEntity.cs @@ -52,6 +52,18 @@ public class RoomSceneEntity [MaxLength(128)] public string DataBlobName { get; set; } = string.Empty; + /// Original RecNet Studio save id for this sub-room, when + /// the scene was imported from a Studio dump. Used by + /// rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId} to return the + /// baked Unity asset bundle filenames the 2023 client downloads. + public long? StudioSubRoomDataSaveId { get; set; } + + [MaxLength(64)] + public string StudioUnityAssetId { get; set; } = string.Empty; + + [MaxLength(4096)] + public string StudioAssetBundleNamesCsv { get; set; } = string.Empty; + public int MaxPlayers { get; set; } = 8; public bool IsSandbox { get; set; } = false; public bool CanMatchmakeInto { get; set; } = true; diff --git a/DorkNet.Server/Data/url-coverage.md b/DorkNet.Server/Data/url-coverage.md index 0435a6b..b937bdd 100644 --- a/DorkNet.Server/Data/url-coverage.md +++ b/DorkNet.Server/Data/url-coverage.md @@ -303,6 +303,11 @@ Note: counts are approximate because several client URLs (`api/avatar/v3/saved`, | POST | `api/rooms/v1/bookmark` | REAL | `MissingEndpointsController.BookmarkRoom` — upserts/removes a `RoomBookmarkEntity` based on the `Bookmark` form field | | GET | `api/rooms/v1/datahistory/{roomId}` | REAL | `Rooms.V2.RoomsController.DataHistory` — list of historical scene snapshots | | POST | `api/rooms/v1/datahistory/restore` | REAL | `Rooms.V2.RoomsController.RestoreDataHistory` — rolls a room's scenes back to a snapshot | +| GET | `rooms/{roomId}/subrooms` | REAL | `Rooms.V2.RoomsController.SubRooms` — Studio/2023 subroom list (one synthesised "Home" for single-scene rooms; `RoomScenes` rows otherwise) | +| GET | `rooms/{roomId}/subrooms/{subRoomId}` | REAL | `Rooms.V2.RoomsController.SubRoom` — one subroom by `RoomScene.OrderIndex` | +| GET | `rooms/{roomId}/subrooms/{subRoomId}/data` | REAL | `Rooms.V2.RoomsController.SubRoomData` — current `SubRoomDataSave` for the subroom | +| GET | `rooms/{roomId}/subrooms/{subRoomId}/saves` | REAL | `Rooms.V2.RoomsController.SubRoomSaves` — current save + historical blobs, newest first | +| GET | `rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}` | REAL | `Rooms.V2.RoomsController.StudioSubRoomSave` — Studio save metadata + baked `unity_assets` bundle list the 2023 client downloads | | POST | `api/rooms/v2/report` | REAL | `RoomsModerationController.Report` — files a room report | | GET | `api/rooms/v1/modrooms` | REAL | `Rooms.V2.RoomsController.MyModerated` (route alias `api/rooms/v1/modrooms`) — rooms the caller moderates | | GET | `api/rooms/v2/myrooms` | REAL | `Rooms.V2.RoomsController.MyRooms` | @@ -383,6 +388,14 @@ These all live on `match.rec.net` (locally: `match.localhost`). | POST | `matchmake/none` | REAL | `Match.MatchController.MatchmakeNone` — returns the caller's current room (or dorm) without changing it | | POST | `player/heartbeat` | REAL | `Match.MatchPlayerController.Heartbeat` — refreshes the caller's `PlayerPresenceService` entry; also has a duplicate in `ApiNamespaceStubsController.Heartbeat` for the api-host case | +### CDN host (`cdn.rec.net` / `storage` / `data`) + +Served by `Controllers/Cdn/CdnController` (catch-all that wins routing on every host). + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET/HEAD | `unity_assets/{assetId}/{target}/{version}` | REAL | `Cdn.CdnController.ServeUnityAsset` — Studio baked asset-bundle download. The 2023 client builds this URL itself from a save's `UnityAssetId`/`Target`/`Version`; resolves the owning `RoomScene` by `StudioUnityAssetId`, picks the matching `{saveId}__bundle_t{target}_v{version}.assetbundle` from `StudioAssetBundleNamesCsv`, streams it from S3. Without it imported Studio geometry never renders | + ## Catch-all wildcards (informational) Routes registered as `[Route("api//{*path}")]` in `ApiNamespaceStubsController` (`Controllers/API/Stubs/ApiNamespaceStubsController.cs`): diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index eefd2a9..20f49ea 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -106,6 +106,7 @@ public static class DatabaseBootstrap await EnsureInventorySchemaAsync(db); await EnsureLeaderboardSchemaAsync(db); await EnsureRoomColumnsAsync(db); + await EnsureRoomSceneColumnsAsync(db); await EnsureInventionColumnsAsync(db); await EnsureMarch2023TablesAsync(db); @@ -334,6 +335,42 @@ public static class DatabaseBootstrap catch { } } + private static async Task EnsureRoomSceneColumnsAsync(DorkNetDbContext db) + { + var provider = db.Database.ProviderName ?? string.Empty; + if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase)) + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""RoomScenes"" ADD COLUMN IF NOT EXISTS ""StudioSubRoomDataSaveId"" bigint NULL;"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""RoomScenes"" ADD COLUMN IF NOT EXISTS ""StudioUnityAssetId"" character varying(64) NOT NULL DEFAULT '';"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""RoomScenes"" ADD COLUMN IF NOT EXISTS ""StudioAssetBundleNamesCsv"" character varying(4096) NOT NULL DEFAULT '';"); + return; + } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""RoomScenes"" ADD COLUMN ""StudioSubRoomDataSaveId"" INTEGER NULL;"); + } + catch { } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""RoomScenes"" ADD COLUMN ""StudioUnityAssetId"" TEXT NOT NULL DEFAULT '';"); + } + catch { } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""RoomScenes"" ADD COLUMN ""StudioAssetBundleNamesCsv"" TEXT NOT NULL DEFAULT '';"); + } + catch { } + } + private static async Task EnsureServerSettingsColumnsAsync(DorkNetDbContext db) { var provider = db.Database.ProviderName ?? string.Empty;