using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Text.Json; using DorkNet.Models.Notification; using DorkNet.Server.Auth; using DorkNet.Server.Controllers.Match; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.Rooms.V2; /// /// api.rec.net/api/rooms/* — DB-backed implementation of the watch's /// Rooms tab. /// /// Replaces the previous stubs in MiscStubsController for: /// v1/hot, v1/filters, v1/tags, v1/search, v2/baserooms, v2/myrooms, /// v2/mybookmarks, v2/mymoderated, v2/mysubscribed, v2/myrecent, /// v2/name/{name}, v2/{id}, v3/{id}, v3/details/{id}, v4/details/{id}, /// roomserver/rooms/{id}, v1/personaldetails/{id}, v2/personaldetails/{id}. /// /// All endpoints serialize Rooms via RoomService.ToWireRoom which matches /// Room.Deserialize at RVA 0x114E430 (PascalCase keys, all 16 required /// fields plus optional VR-low / mobile / mic-mute flags). /// [ApiController] public class RoomsController( RoomService rooms, PlaylistService playlists, DorkNetDbContext db, PlayerPresenceService presence, OnlinePresenceService onlinePresence, DomainConfig domain, NotificationService notifications, ServerSettingsService serverSettings, ILogger logger) : ControllerBase { private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this); // ── Browse / Hot / Search ──────────────────────────────────────────── /// /// `Rooms.GetHotRooms(roomScoreType, tags)` — the watch's "Trending" tab. /// `roomScoreType` is ignored (we always sort by HotScore). `tags` is /// passed through as a substring filter against the comma-separated /// TagsCsv column (e.g. `#community`, `#recroomoriginal`). /// [HttpGet("api/rooms/v1/hot")] public async Task HotV1( [FromQuery] string? roomScoreType, [FromQuery] string? tags) => Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList()); /// GET rooms/hot — the room-server "hot rooms" list. /// Response is the PAGED wrapper: the 2023 client's issuing method /// NLDBPDCNNCF.FANOAMOPHBE returns Task<FFCPFGBNLHN> /// (NLDBPDCNNCF.txt:2865 + literal at :2983) and FFCPFGBNLHN's reader /// requires Results + TotalResults /// (PJPBJKLDMNA.txt ISIL 040/059). // The roomserver/ twin is mandatory: FANOAMOPHBE lives on the same // NLDBPDCNNCF client whose requests all arrive with the roomserver/ // prefix in this deployment (docs/recroom-2023-room-save.md:83-93), so // the bare-only registration 404'd for the 2023 client. [HttpGet("rooms/hot")] [HttpGet("roomserver/rooms/hot")] public async Task HotRoomServer( [FromQuery] int skip = 0, [FromQuery] int take = 100) { // The tag filter is a REPEATED query field (`tag=a&tag=b`) — the // client's IFLHPHIPFNC closure adds one entry per selected chip — // so a `[FromQuery] string? tag` binding silently dropped every tag // past the first. Read the raw collection and union the per-tag hits. var tagFilters = Request.Query["tag"] .Concat(Request.Query["tags"]) .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); List all; if (tagFilters.Count == 0) { all = await rooms.HotAsync(null, take: 200); } else { var merged = new List(); var seen = new HashSet(); foreach (var filter in tagFilters) foreach (var room in await rooms.HotAsync(filter, take: 200)) if (seen.Add(room.Id)) merged.Add(room); all = merged.OrderByDescending(r => r.HotScore).ToList(); } var page = all .Skip(Math.Max(skip, 0)) .Take(Math.Clamp(take, 1, 100)) .Select(RoomService.ToWireRoom) .ToList(); return Ok(new { Results = page, TotalResults = all.Count, }); } [HttpGet("rooms/topcreators")] [HttpGet("roomserver/rooms/topcreators")] public async Task TopCreators([FromQuery] int skip = 0, [FromQuery] int take = 100) { var rows = await PublicRoomQuery() .OrderByDescending(r => r.HotScore) .ThenByDescending(r => r.VisitCount) .Take(250) .ToListAsync(); var page = rows .GroupBy(r => r.CreatorPlayerId) .Select(g => g.First()) .Skip(Math.Max(skip, 0)) .Take(Math.Clamp(take, 1, 100)) .ToList(); return Ok(await BuildRoomServerListAsync(page)); } [HttpGet("rooms/contestwinners")] [HttpGet("roomserver/rooms/contestwinners")] public async Task ContestWinners([FromQuery] int skip = 0, [FromQuery] int take = 100) { var rows = await PublicRoomQuery() .Where(r => EF.Functions.Like(r.TagsCsv, "%contest%") || EF.Functions.Like(r.TagsCsv, "%winner%") || EF.Functions.Like(r.TagsCsv, "%featured%")) .OrderByDescending(r => r.HotScore) .ThenBy(r => r.Name) .Skip(Math.Max(skip, 0)) .Take(Math.Clamp(take, 1, 100)) .ToListAsync(); if (rows.Count == 0) { rows = await PublicRoomQuery() .OrderByDescending(r => r.HotScore) .ThenBy(r => r.Name) .Skip(Math.Max(skip, 0)) .Take(Math.Clamp(take, 1, 100)) .ToListAsync(); } return Ok(await BuildRoomServerListAsync(rows)); } [HttpGet("rooms/fromcreators")] [HttpPost("rooms/fromcreators")] [HttpGet("roomserver/rooms/fromcreators")] [HttpPost("roomserver/rooms/fromcreators")] public async Task FromCreators([FromQuery] int skip = 0, [FromQuery] int take = 100) { var creatorIds = Request.Query["creatorId"] .Concat(Request.Query["creatorIds"]) .Concat(Request.Query["accountId"]) .Concat(Request.Query["accountIds"]) .Concat(Request.Query["id"]) .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Select(v => long.TryParse(v, out var id) ? id : 0) .Where(id => id > 0) .Distinct() .ToList(); var query = PublicRoomQuery(); if (creatorIds.Count > 0) query = query.Where(r => creatorIds.Contains(r.CreatorPlayerId)); var rows = await query .OrderByDescending(r => r.HotScore) .ThenByDescending(r => r.UpdatedAt) .Skip(Math.Max(skip, 0)) .Take(Math.Clamp(take, 1, 100)) .ToListAsync(); return Ok(await BuildRoomServerListAsync(rows)); } /// GET rooms/magic_door — the Magic Door destination. /// The response is a WRAPPER, not the bare room: the client's /// MOOCMPOIIGM returns Task<GAPJAGDNFEO> /// (NLDBPDCNNCF.txt:4503) and that reader requires the keys /// RefreshesAt, RefreshIntervalMinutes and Room /// (MIFGPMGEMEK.txt ISIL 046/073/097). Returning the room object /// directly left Room null and MagicDoorManager with no /// destination. The partySize query param is accepted and /// ignored — we have no party-size-aware curation. [HttpGet("rooms/magic_door")] [HttpGet("roomserver/rooms/magic_door")] public async Task MagicDoor([FromQuery] int? partySize) { var room = await PublicRoomQuery() .OrderByDescending(r => r.HotScore) .ThenBy(r => r.Id) .FirstOrDefaultAsync(); if (room is null) return NotFound(); // The door re-rolls its destination on the client every // RefreshIntervalMinutes; RefreshesAt is when the current pick // expires. Keep the two consistent so the client's local timer // and its next fetch line up. const int refreshIntervalMinutes = 10; return Ok(new { Room = (await BuildRoomServerListAsync(new[] { room })).First(), RefreshesAt = DateTime.UtcNow.AddMinutes(refreshIntervalMinutes) .ToString("yyyy-MM-ddTHH:mm:ssZ"), RefreshIntervalMinutes = refreshIntervalMinutes, }); } /// /// GET /roomserver/rooms/recommendations — the home-tab /// "Recommended" carousel. Wire shape is NOT a flat /// List<Room>; the watch's /// EJDCNGBEICB.JDKLBNBIEAH at EJDCNGBEICB.txt:1349,1430 /// returns IPromise<List<ANNKHNFLMNP>> and /// at /// ANNKHNFLMNP.txt:43-97 reads SeedRoom (sub-object, /// KLCOGEIGEBJ i.e. full Room) and Rooms (list of /// Rooms) as required keys. Returning [{room}, …] raised /// KeyNotFoundException: Failed to find key 'Rooms' when /// deserializing object of type KLCOGEIGEBJ. /// /// We group the hot list into one "card" per room — that gives the /// watch one row per featured room with that room as its own /// SeedRoom and a single-element Rooms list. Cheap, semantically /// correct, and avoids the deserializer-crashing empty-Rooms /// edge case the watch hits when a group has no rooms. /// [HttpGet("roomserver/rooms/recommendations")] [HttpGet("rooms/recommendations")] public async Task Recommendations( [FromQuery(Name = "splitTestId")] int? splitTestId, [FromQuery(Name = "splitTestValue")] int? splitTestValue) { var hot = await rooms.HotAsync(null, take: 12); if (hot.Count == 0) return Ok(Array.Empty()); var roomIds = hot.Select(r => r.Id).ToList(); var sceneRows = await db.RoomScenes .Where(s => roomIds.Contains(s.RoomId)) .OrderBy(s => s.OrderIndex) .ToListAsync(); var scenesByRoom = sceneRows .GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); var wireRooms = hot .Select(r => BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id))) .ToList(); // One ANNKHNFLMNP entry per room: SeedRoom = the room itself, // Rooms = [that room]. The watch enumerates the outer list to // build the row list; each row shows its SeedRoom as headline. var result = wireRooms.Select(room => new Dictionary(StringComparer.Ordinal) { ["SeedRoom"] = room, ["Rooms"] = new List { room }, }).ToList(); return Ok(result); } [HttpGet("rooms/{roomId:long}/similar")] [HttpGet("roomserver/rooms/{roomId:long}/similar")] public async Task SimilarRooms(long roomId, [FromQuery] int take = 12) { var seed = await rooms.GetByIdAsync(roomId); if (seed is null) return NotFound(); var tags = (seed.TagsCsv ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToArray(); var query = PublicRoomQuery().Where(r => r.Id != roomId); if (tags.Length > 0) { var primaryTag = tags[0]; query = query.Where(r => EF.Functions.Like(r.TagsCsv, $"%{primaryTag}%")); } var similar = await query .OrderByDescending(r => r.HotScore) .ThenBy(r => r.Name) .Take(Math.Clamp(take, 1, 100)) .ToListAsync(); var seedWire = (await BuildRoomServerListAsync(new[] { seed })).First(); var similarWire = await BuildRoomServerListAsync(similar); return Ok(new Dictionary(StringComparer.Ordinal) { ["SeedRoom"] = seedWire, ["Rooms"] = similarWire, }); } /// /// `Rooms.SearchForRooms(query)` — free-text room search. /// [HttpGet("api/rooms/v1/search")] [HttpGet("api/rooms/v2/search")] public async Task Search( [FromQuery(Name = "query")] string? query, [FromQuery(Name = "value")] string? value) => Ok((await rooms.SearchAsync(query ?? value ?? string.Empty)) .Select(RoomService.ToWireRoom).ToList()); /// GET rooms/search — the room-server search route. /// Unlike the api/rooms/v* variants (flat List<Room>), /// this one's client contract is a PAGED wrapper object /// { Results, TotalResults } (IDLBPALJJDJ : PagedResult<Room>); /// returning a bare array crashes the watch's key-based reader. /// Confirmed for 2023 too: NLDBPDCNNCF.MOBJJDBNBMF returns /// Task<FFCPFGBNLHN> (NLDBPDCNNCF.txt:2733) whose reader /// requires Results/TotalResults (PJPBJKLDMNA.txt ISIL 040/059). // roomserver/ twin: the 2023 caller is on NLDBPDCNNCF, whose requests all // carry the roomserver/ prefix (docs/recroom-2023-room-save.md:83-93). [HttpGet("rooms/search")] [HttpGet("roomserver/rooms/search")] public async Task SearchRoomServer( [FromQuery(Name = "query")] string? query, [FromQuery(Name = "value")] string? value, [FromQuery] int skip = 0, [FromQuery] int take = 100) { var all = (await rooms.SearchAsync(query ?? value ?? string.Empty)).ToList(); var page = all .Skip(Math.Max(skip, 0)) .Take(Math.Clamp(take, 1, 100)) .Select(RoomService.ToWireRoom) .ToList(); return Ok(new { Results = page, TotalResults = all.Count, }); } /// GET api/rooms/v2/live — currently-active rooms /// (rooms with at least one player presence). Same shape as Hot /// — order by HotScore as a proxy for "live activity" since we /// don't track instance population separately yet. [HttpGet("api/rooms/v2/live")] public async Task Live([FromQuery] string? roomScoreType) => Ok((await rooms.HotAsync(null)).Select(RoomService.ToWireRoom).ToList()); /// /// `Rooms.GetBaseRooms` — the room-creation "base room" picker. A fixed, /// admin-curated list of room NAMES (server setting /// BaseRoomNamesJson, default ), resolved to rooms /// in the configured order. Names are looked up via /// , which also finds /// HiddenFromBrowse rooms like MakerRoom. Edit the list from the admin SPA /// (api/admin/v1/settings/base-rooms). /// [HttpGet("api/rooms/v2/baserooms")] [HttpGet("api/rooms/v3/baserooms")] // The 2023 client issues this from NLDBPDCNNCF, whose requests all carry // the roomserver/ prefix in this deployment — without the twin the base/AG // room list 404s and the room browser's base section is empty. [HttpGet("rooms/base")] [HttpGet("roomserver/rooms/base")] public async Task BaseRooms() { var names = await serverSettings.GetBaseRoomNamesAsync(); var result = new List(); foreach (var name in names) { var room = await rooms.GetByNameAsync(name); if (room is not null) result.Add(RoomService.ToWireRoom(room)); } return Ok(result); } /// /// `Rooms.GetFilters` — pinned/popular tag chips shown above the room /// list in the watch. /// // Tag wire format: NO leading '#'. The watch's RoomFilterChip prepends // '#' at render time, so sending "#community" produces "##community" in // the UI. Names stored in the DB also drop the prefix. [HttpGet("api/rooms/v1/filters")] public async Task Filters() { var tags = await serverSettings.GetPlayMenuTagsAsync(); return Ok(new { PinnedFilters = tags.PinnedTags, PopularFilters = tags.PopularTags, TrendingFilters = tags.TrendingTags, }); } [HttpGet("api/rooms/v1/tags")] public async Task Tags() { var tags = await serverSettings.GetPlayMenuTagsAsync(); return Ok(tags.PinnedTags .Concat(tags.PopularTags) .Concat(tags.TrendingTags) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray()); } [HttpGet("api/rooms/v1/pinnedtags")] public async Task PinnedTags() => Ok((await serverSettings.GetPlayMenuTagsAsync()).PinnedTags); [HttpGet("api/rooms/v1/populartags")] public async Task PopularTags() => Ok((await serverSettings.GetPlayMenuTagsAsync()).PopularTags); [HttpGet("api/rooms/v1/trendingtags")] public async Task TrendingTags() => Ok((await serverSettings.GetPlayMenuTagsAsync()).TrendingTags); // ── Single room lookups ────────────────────────────────────────────── /// /// `Rooms.GetByName(roomName)` — used when the client constructs the /// share-link target or when /goto/room/{name} runs. Falls back to a /// synthesised "Room_" for unknown names so the deserializer /// doesn't crash on 404. /// [HttpGet("api/rooms/v2/name/{roomName}")] [HttpGet("api/rooms/v3/name/{roomName}")] public async Task ByName(string roomName) { var r = await rooms.GetByNameAsync(roomName); return Ok(RoomService.ToWireRoom(r ?? Synthetic(roomName))); } /// /// `Rooms.GetById(roomId)` — same flat shape as ByName. /// [HttpGet("api/rooms/v2/{roomId:long}")] [HttpGet("api/rooms/v3/{roomId:long}")] public async Task ById(long roomId) { var r = await rooms.GetByIdAsync(roomId); return Ok(RoomService.ToWireRoom(r ?? Synthetic($"Room_{roomId}", roomId))); } [HttpGet("rooms/{roomId:long}")] 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 .Where(s => s.RoomId == room.Id) .OrderBy(s => s.OrderIndex) .ToListAsync(); var roles = await db.RoomRoles .Where(r => r.RoomId == room.Id) .ToListAsync(); return Ok(BuildRoomServerDetails( room, sceneRows, roles: roles, unityAssetTarget: unityAssetTarget, unityAssetVersion: unityAssetVersion)); } /// DELETE rooms/{roomId} — the owner's "delete room" /// button. Client method NLDBPDCNNCF.FPDBNHOHIAO formats /// "rooms/{0}" and moves verb ordinal 4 (= DELETE) into r8 /// (NLDBPDCNNCF.txt ISIL 021/030 around :4897); its return type is the /// body-less promise LDGADANDBIO (CAOCJPMJMDB.txt:339), so the /// response payload is never parsed. /// /// The only DELETE for rooms/{id} in the codebase lived on /// AdminController, which is rooted at api/admin/v1 — the game /// path therefore 405'd on the bare route and 404'd on the roomserver/ /// one, so owners could not delete their rooms at all. /// /// Soft-archive (State = 1) exactly like /// AdminController.DeleteRoom: a hard delete would orphan the /// room's saves, visits, bookmarks and role rows. Archived rooms drop /// out of every browse/search query (all of which filter /// State == 0). [HttpDelete("rooms/{roomId:long}")] [HttpDelete("roomserver/rooms/{roomId:long}")] [Authorize] public async Task DeleteRoom(long roomId) { var me = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId != me) { // Co-owners (Role 0) can delete too — same gate the rename path uses. var coOwner = await db.RoomRoles.AnyAsync(rr => rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && rr.Role == 0); if (!coOwner) return Forbid(); } room.State = 1; // Archived room.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); logger.LogInformation("[room-delete] room={Room} by={By}", roomId, me); return Ok(new { Result = 0, room.Id, room.State }); } // ── RoomDetails (the boot-critical one) ───────────────────────────── // // /v4/details/{roomId} is hit *during* the matchmaking goto flow. // RoomDetails.Deserialize at RVA 0x114C580 expects PascalCase keys: // Room (object), Scenes (list), CoOwners/InvitedCoOwners, // Moderators/InvitedModerators, Hosts/InvitedHosts (all int lists), // CheerCount, FavoriteCount, VisitCount, Tags. // // Scenes is populated for every room. The persistence coroutine // (`RoomPersistenceManager+`) runs // unconditionally on scene load and dereferences // `Rooms.LocalRoomScene.DataBlobName` on its first MoveNext — so // returning Scenes=[] makes LocalRoomScene null and NPEs the boot. // // The historical "Scenes=[] short-circuits the persistence manager" // comment that used to live here was the reverse of what actually // happens in this build. Confirmed empirically with the [req] log: // /v4/details/1 fires before scene load for the dorm too, so // Rooms.LocalRoom is populated and the coroutine just needs a // populated Scenes[0] to get a non-null LocalRoomScene. /// GET roomserver/rooms/{roomId}/subrooms/{subRoomId}/datahistory /// — drives the watch's "Restore to old version" UI in the room- /// settings tab. Per-subroom (not per-room) — the watch builds the /// URL from the template /// String.Format("rooms/{0}/subrooms/{1}/datahistory", roomId, subRoomId) /// (verified at EJDCNGBEICB.txt:1190); the roomserver/ /// prefix is added by RecNet's URL builder for everything on the /// rooms host (api.localhost proxies it through the same prefix as the /// rest of the rooms API — see all the other roomserver/rooms/... /// routes in this controller). /// /// SubRoomId semantics: the watch's 2020.12 build treats /// "SubRoomId" as the , NOT /// rec.net's long random Guid-like SubRoomId. The heartbeat reports /// subRoom=11 for the 12th scene in RoomDetails.SubRooms[]; /// this endpoint must filter by OrderIndex to match. The zip-importer /// stamps each SubRooms/<scene>/History/ blob's /// with the scene's /// OrderIndex at insert time — the rec.net SubRoomId is captured in /// the sidecar JSON for provenance but isn't what gets indexed. /// /// Wire shape: List<RoomDataHistoryDTO>. The /// watch's DHOCPFIOKHD.PPGFHEDFBEA deserializer reads EXACTLY /// four PascalCase keys (DHOCPFIOKHD.txt:136-150): /// SubRoomId (long, strict GetKey), DataBlob (string), /// SavedByAccountId (long, strict GetKey), CreatedAt /// (DateTime, GetKeyOrDefault). Missing strict keys throw /// KeyNotFoundException in LitJson and the watch shows the /// "Could not retrieve save history from server :-(" toast from /// UIRestoreRoomDialog.txt:1970. Extra keys are ignored. /// /// Pre-2026-05 rows (and any rows the live save flow inserts, /// which doesn't populate SubRoomId yet) have a null SubRoomId and /// surface under every sub-room's history list — back-compat. [HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")] [HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")] public async Task SubRoomDataHistory(long roomId, long subRoomId) { var blobs = await db.RoomDataBlobs .Where(b => b.RoomId == roomId && (b.SubRoomId == subRoomId || b.SubRoomId == null)) // Skip the canonical per-scene blob from the import — the watch // is asking for HISTORY, and serving the current `room___.room` // back to it as an option would make `restore to current` a // confusing no-op entry in the list. .Where(b => !b.BlobName.StartsWith($"room_{roomId}_")) .OrderByDescending(b => b.UploadedAt) .Take(50) .Select(b => new { SubRoomId = subRoomId, DataBlob = b.BlobName, SavedByAccountId = b.UploadedByPlayerId, CreatedAt = b.UploadedAt, }) .ToListAsync(); 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. /// /// Wire shape is a PAGED OBJECT, not a bare list. The 2023 /// client's GetSubRoomDataSaves (RecNet.Runtime /// NLDBPDCNNCF.LDLMCGBIJFH, state machine IOAKBKKDFEE, sends /// skip/take) deserializes /// PagedResultsDTO<SubRoomDataSaveDTO> — /// {"Results":[…],"TotalResults":N} per the Studio decompile /// (RRStudio-decomp PagedResultsDTO.cs, plain .NET names). A /// bare array throws expected:'{', actual:'[' in the strict /// reader, which kills the whole calling flow: room clone dies with /// the message-less "Failed to copy room" and the private-instance /// button silently does nothing. [HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")] [HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")] // Studio editor asks for the same page without the heavy Unity-asset // payload (CLAJDMOFFOS.PGGIWOJWHOM). Our rows never carry inline asset // bytes, so the same handler serves both. [HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/no_unity_assets")] [HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/no_unity_assets")] [Authorize] public async Task SubRoomSaves( long roomId, long subRoomId, [FromQuery] int skip = 0, [FromQuery] int take = 50) { 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, // Match BuildStudioSaveWire's key set — the strict readers // key on CreatedAt/CreatedByAccountId (SavedAt was our own // invention and no reader consumes it). CreatedAt = (h.UploadedAt == default ? DateTime.UtcNow : h.UploadedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"), CreatedByAccountId = h.UploadedByPlayerId, }); } var total = saves.Count; var page = saves .Skip(Math.Max(0, skip)) .Take(take > 0 ? take : 50) .ToList(); return Ok(new { Results = page, TotalResults = total, }); } /// 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, CreatedAt = (scene?.DataModifiedAt ?? room.UpdatedAt) is var created && created != default ? created.ToString("yyyy-MM-ddTHH:mm:ssZ") : DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"), 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 /// historical blob name from the datahistory list. /// is RoomSceneEntity.OrderIndex (same convention as the GET above). /// /// Effect: stamp the chosen scene's DataBlobName to the historical /// blob and, when restoring the room's current entry scene, also /// update the room-level CurrentDataBlobName so a fresh /v4/details /// returns the restored bytes too. Then push SubscriptionUpdateRoom /// so any clients already cached on the room reload the scene. /// /// Returns CreateModifyRoomSceneResponse — same shape as /// /api/rooms/v1/datahistory/restore — so the watch's /// ProcessCreateModifyRoomSceneResponse chain can deserialise and /// flip the Restore dialog out of its "applying" state. [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/restoredata")] [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/restoredata")] [Authorize] public async Task SubRoomRestoreData( long roomId, long subRoomId, [FromForm] string? filename) { if (string.IsNullOrWhiteSpace(filename)) return BadRequest(new { error = "missing_filename" }); var pid = this.RequireCurrentPlayerId(); var room = await rooms.GetByIdAsync(roomId); if (room is null) return NotFound(new { error = "room_not_found" }); if (room.CreatorPlayerId != pid) return Forbid(); var blob = await db.RoomDataBlobs .FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId); if (blob is null) return NotFound(new { error = "blob_not_found", filename }); var sceneRows = await db.RoomScenes .Where(s => s.RoomId == roomId) .OrderBy(s => s.OrderIndex) .ToListAsync(); var sceneRow = sceneRows.FirstOrDefault(s => s.OrderIndex == (int)subRoomId); if (sceneRow is null) return NotFound(new { error = "subroom_not_found", subRoomId }); var restoredAt = DateTime.UtcNow; sceneRow.DataBlobName = filename; sceneRow.DataModifiedAt = restoredAt; // Only stamp the room-level CurrentDataBlobName when restoring the // entry scene (OrderIndex 0). Restoring a non-entry sub-room // shouldn't rewrite what the room loads at the front door. if (sceneRow.OrderIndex == 0) { await db.Rooms .Where(r => r.Id == roomId) .ExecuteUpdateAsync(s => s .SetProperty(r => r.CurrentDataBlobName, filename) .SetProperty(r => r.UpdatedAt, restoredAt)); room.CurrentDataBlobName = filename; room.UpdatedAt = restoredAt; } await db.SaveChangesAsync(); var pushRoom = room.IsDormRoom ? CloneWithCreator(room, pid, room.CurrentDataBlobName) : CloneWithCreator(room, room.CreatorPlayerId, room.CurrentDataBlobName); var roomDetailsPayload = BuildRoomDetails(pushRoom, sceneRows, sceneRow.OrderIndex, filename); await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload); logger.LogInformation( "[rooms-restore] player={PlayerId} room={RoomId} subRoom={SubRoomId} scene={Scene} blob={Blob}", pid, roomId, subRoomId, sceneRow.Name, filename); // Watch's response deserializer is RecNetResult — // the same legacy success/value/error envelope used by // roomserver/rooms/{id}/subrooms/{id}/data. `value` must be the // flattened /roomserver/rooms/{id} DTO, not the modern // { Room, Scenes } details payload pushed over SubscriptionUpdateRoom. var roomServerDetails = BuildRoomServerDetails(pushRoom, sceneRows, filename); return Ok(new { success = true, value = roomServerDetails, error = string.Empty }); } /// POST api/rooms/v4/saveData — the watch's /// RecNet.Rooms.UploadLocalRoomSceneData save flow. /// Body shape mirrors RecNet.Rooms+SaveRoomSceneRequest /// (Cpp2IL_CS RecNet/Rooms.cs:858): RoomSceneId, RoomDataFilename, /// InventionUsages[], CreatorActionContext, RequestPlayerId. /// /// Persists the new CurrentDataBlobName on the room /// (and, for dorms, the per-player DormStateEntity) so the next /// /v4/details + cdn fetch loads what just got uploaded. Returns /// a populated RoomScene JSON — that's what the watch's /// continuation callback (b__1 in DisplayClass124_0) expects; /// returning an empty body trips a "HTTP Error 404" path in /// BestHTTP's Future framework even though the HTTP status was /// fine. [HttpPost("api/rooms/v4/saveData")] [Authorize] public async Task SaveData([FromBody] SaveRoomSceneRequest body) => await SaveDataCore(body); private async Task SaveDataCore( SaveRoomSceneRequest body, long? routeRoomId = null, bool wrapCreateModifyResponse = false) { var pid = this.RequireCurrentPlayerId(); // Resolve the room. The watch sends RoomSceneId (the scene's // OrderIndex per BuildRoomDetails above — 0 for single-scene // rooms). To find the parent room we cross-reference the // player's current presence (which RecordResponseAsync stamps // on every /goto). This is the only signal we have without a // RoomId on the request body. var current = presence.GetRoom(pid); if (current is null && routeRoomId is null) { // No active room — without it we can't pick a target row. // Return a degenerate RoomScene so the deserializer doesn't // throw, but flag CanMatchmakeInto=false so the watch // doesn't think the save succeeded. var missingPresenceScene = new { RoomSceneId = body.RoomSceneId, RoomId = 0L, RoomSceneLocationId = "76d98498-60a1-430c-ab76-b54a29b7a163", Name = "Home", IsSandbox = false, DataBlobName = body.RoomDataFilename ?? string.Empty, MaxPlayers = 8, CanMatchmakeInto = false, DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"), }; return Ok(wrapCreateModifyResponse ? new { success = true, value = missingPresenceScene, error = string.Empty } : missingPresenceScene); } var roomId = routeRoomId ?? current!.RoomId; var room = await rooms.GetByIdAsync(roomId); if (room is null) return NotFound(new { error = "room_not_found" }); var newBlob = body.EffectiveRoomDataFilename.Trim(); if (string.IsNullOrWhiteSpace(newBlob)) { logger.LogWarning( "[rooms-save] missing blob filename player={PlayerId} room={RoomId} requestedScene={RequestedSceneId} routeRoom={RouteRoomId}", pid, roomId, body.RoomSceneId, routeRoomId); return BadRequest(new { error = "missing_room_data_filename" }); } var savedAt = DateTime.UtcNow; // Multi-scene rooms have a RoomScenes row per scene. The 2020 // client usually sends the scene's OrderIndex, but some flows // send a stale/non-index id while the local room is still a // single-scene dorm/custom room. In that common single-scene // case, stamp the only row anyway; otherwise the pushed // RoomDetails can keep Scenes[0].DataBlobName at v(N) while // Rooms.CurrentDataBlobName is v(N+1), and the post-save reload // reads stale bytes. var sceneRowsForRoom = await db.RoomScenes .Where(s => s.RoomId == roomId) .OrderBy(s => s.OrderIndex) .ToListAsync(); var sceneRow = sceneRowsForRoom.FirstOrDefault(s => s.OrderIndex == (int)body.RoomSceneId) ?? (sceneRowsForRoom.Count == 1 ? sceneRowsForRoom[0] : null); // Dorms: per-player state, keyed by the calling player so two // people in the same dorm room ID save into separate slots. if (room.IsDormRoom) { if (room.CreatorPlayerId != pid) return Forbid(); var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid); if (dormState is null) { dormState = new DormStateEntity { PlayerId = pid }; db.DormStates.Add(dormState); } dormState.CurrentDataBlobName = newBlob; dormState.UpdatedAt = savedAt; } else { var canSave = room.CreatorPlayerId == pid || await db.Players.AnyAsync(p => p.Id == pid && p.IsAdmin) || await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == pid && r.Accepted && r.Role == 0); if (!canSave) return Forbid(); } if (sceneRow is not null) { sceneRow.DataBlobName = newBlob; sceneRow.DataModifiedAt = savedAt; } await db.Rooms .Where(r => r.Id == roomId) .ExecuteUpdateAsync(s => s .SetProperty(r => r.CurrentDataBlobName, newBlob) .SetProperty(r => r.UpdatedAt, savedAt)); await db.SaveChangesAsync(); room.CurrentDataBlobName = newBlob; room.UpdatedAt = savedAt; // The watch's MasterUploadRoomDataBlobCoroutine waits for BOTH // the /upload + /saveData responses AND a SubscriptionUpdateRoom // SignalR push before flipping the save state machine out of // SAVING. Without this push the spinner stays "Saving..." forever // even though both HTTP calls returned 200 — verified via the // RoomDetailsUpdatedCallback closure in // RoomPersistenceManager+<>c__DisplayClass174_0. // // For dorms we re-shape with the caller as creator + their own // DormStateEntity blob name so the watch's set_LocalRoomDetails // matches LocalRoomDetails.RoomId and triggers // SafeRaiseRoomDetailsUpdated(roomId). var pushRoom = room; if (room.IsDormRoom) pushRoom = CloneWithCreator(room, pid, newBlob); else pushRoom = CloneWithCreator(room, room.CreatorPlayerId, newBlob); foreach (var scene in sceneRowsForRoom) { if (sceneRow is not null && scene.Id == sceneRow.Id) { scene.DataBlobName = newBlob; scene.DataModifiedAt = savedAt; } } var savedSceneId = sceneRow?.OrderIndex ?? body.RoomSceneId; var roomDetailsPayload = BuildRoomDetails(pushRoom, sceneRowsForRoom, savedSceneId, newBlob); logger.LogInformation( "[rooms-save] saveData player={PlayerId} room={RoomId} requestedScene={RequestedSceneId} savedScene={SavedSceneId} blob={BlobName} sceneRow={SceneRowFound} scenes={SceneCount}", pid, roomId, body.RoomSceneId, savedSceneId, newBlob, sceneRow is not null, sceneRowsForRoom.Count); var activeInstanceId = current is not null && current.RoomId == roomId ? current.RoomInstanceId : 0L; var playersInInstance = activeInstanceId == 0 ? new[] { pid } : onlinePresence.OnlinePlayerIds() .Where(playerId => { var playerRoom = presence.GetRoom(playerId); return playerRoom is not null && playerRoom.RoomId == roomId && playerRoom.RoomInstanceId == activeInstanceId; }) .Append(pid) .Distinct() .ToArray(); logger.LogInformation( "[rooms-save] fanout room update player={PlayerId} room={RoomId} instance={InstanceId} blob={BlobName} recipients={RecipientCount}", pid, roomId, activeInstanceId, newBlob, playersInInstance.Length); if (activeInstanceId != 0) { var updatedPrivateInstances = await db.PrivateInstances .Where(instance => instance.Id == activeInstanceId && instance.RoomId == roomId) .ExecuteUpdateAsync(setters => setters .SetProperty(instance => instance.DataBlob, newBlob)); if (updatedPrivateInstances > 0) { logger.LogInformation( "[rooms-save] updated private instance blob room={RoomId} instance={InstanceId} blob={BlobName}", roomId, activeInstanceId, newBlob); } } var updatedRoomInstances = new Dictionary(); foreach (var playerId in playersInInstance) { var currentPresence = presence.GetRoom(playerId); if (currentPresence is null && playerId == pid && current is not null) { currentPresence = current; } if (currentPresence is null || currentPresence.RoomId != roomId) continue; currentPresence.DataBlob = newBlob; presence.SetRoom(playerId, currentPresence); updatedRoomInstances[playerId] = currentPresence; } // SubscriptionUpdateRoom delivers the new RoomDetails (with // Scenes[0].DataBlobName = newBlob). Rooms.OnSubscriptionUpdateRoom // → StoreRoomDetailsInCache → set_LocalRoomDetails iterates // Scenes for one matching RoomSceneId == LocalSubRoomId and writes // LocalRoomScene to that scene, which is what DownloadRoomData // BlobCoroutine reads when PostSaveReloading runs. foreach (var playerId in playersInInstance) { await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload); } // RoomInstanceUpdate keeps the matchmaking-side dataBlob and any // friends-list "in room X" indicators in sync. No nudge — live // doesn't send one, and the fake sub-room id confuses the watch's // OnLocalGameSessionUpdated scene re-resolve. foreach (var (playerId, currentPresence) in updatedRoomInstances) { await notifications.NotifyAsync( playerId, PushNotificationId.SubscriptionUpdateGameSession, currentPresence); } // PresenceUpdate keeps the next heartbeat aligned with the blob // applied through the room-details push above. foreach (var (playerId, currentPresence) in updatedRoomInstances) { var presencePayload = new { playerId = (int)playerId, statusVisibility = 1, deviceClass = 0, vrMovementMode = 0, roomInstance = new { roomInstanceId = currentPresence.RoomInstanceId, roomId = currentPresence.RoomId, subRoomId = currentPresence.SubRoomId, roomInstanceType = currentPresence.RoomInstanceType, location = currentPresence.Location, dataBlob = currentPresence.DataBlob, eventId = currentPresence.EventId, clubId = currentPresence.ClubId, roomCode = currentPresence.RoomCode, photonRegionId = currentPresence.PhotonRegionId, photonRoomId = currentPresence.PhotonRoomId, name = currentPresence.NameWire, maxCapacity = currentPresence.MaxCapacity, isFull = currentPresence.IsFull, isPrivate = currentPresence.IsPrivate, isInProgress = currentPresence.IsInProgress, EncryptVoiceChat = currentPresence.EncryptVoiceChat, }, isOnline = true, // 2023.03.21 client build version. MUST be the STRING // "20230317" — the client's presence DTO reads appVersion // with a string reader, so an int crashes the SignalR // message ("expected:'String Begin Token'") and aborts the // update. Must also match the joining client's version. appVersion = "20230317", }; await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload); } var savedScene = new { RoomSceneId = savedSceneId, RoomId = roomId, RoomSceneLocationId = sceneRow?.RoomSceneLocationId ?? room.LocationReplicationId, Name = sceneRow?.Name ?? "Home", IsSandbox = sceneRow?.IsSandbox ?? false, DataBlobName = newBlob, MaxPlayers = sceneRow?.MaxPlayers ?? 8, CanMatchmakeInto = sceneRow?.CanMatchmakeInto ?? true, DataModifiedAt = savedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"), }; if (wrapCreateModifyResponse) { // 2023 nested-JSON save commit: the response deserializer // (NEOPBOMGIOG via KMBHAKAHNGH mapper) REQUIRES value = // { Room: , SubRoomDataSave: } — both // non-null, or its post-parse validator NREs and the watch // reports "Failed to save room" even though the save // persisted. Room reuses the GET rooms/{id} shape; the save // reuses BuildStudioSaveWire (JKIFFPPAJNK keys per // PELEHJLMKJO mapper: SubRoomDataSaveId/SubRoomId/ // UnityAssetId/DataBlob/DataBlobHash/SavedByAccountId/ // SavedOnPlatform/SavedOnDeviceClass/Description/CreatedAt). if (body.SubRoomData is not null) { var subRoomId = (long)(sceneRow?.OrderIndex ?? (int)body.RoomSceneId); var value = new { Room = BuildRoomServerDetails(pushRoom, sceneRowsForRoom, newBlob), SubRoomDataSave = BuildStudioSaveWire( room, sceneRow, subRoomId, sceneRow?.StudioSubRoomDataSaveId ?? 0L, unityAssetTarget: null, unityAssetVersion: null), }; return Ok(new { success = true, value, error = string.Empty }); } var roomServerDetails = BuildRoomServerDetails(pushRoom, sceneRowsForRoom, newBlob); return Ok(new { success = true, value = roomServerDetails, error = string.Empty }); } return Ok(savedScene); } public class SaveRoomSceneRequest { public long RoomSceneId { get; set; } public string? RoomDataFilename { get; set; } [System.Text.Json.Serialization.JsonPropertyName("filename")] public string? Filename { get; set; } public List? InventionUsages { get; set; } public string? InventionUsageBase64 { get; set; } public CreatorActionContextDto? CreatorActionContext { get; set; } public long RequestPlayerId { get; set; } public int SaveRequestPlayerId { get; set; } // 2023 client shape. rooms/{id}/subrooms/{id}/data arrives as JSON: // { "UnityAssetId": null, // "RoomData": { "Filename": "roommeta_….bin", "Hash": …, "OwnershipProof": … }, // "SubRoomData": { "Filename": "dorm_p…_vN.dat", "Hash": …, "OwnershipProof": … } } // SubRoomData.Filename is the scene save blob (the FileType=1 // /upload result) — that's what must become DataBlobName. // RoomData.Filename is the FileType=6 room-metadata blob. public long? UnityAssetId { get; set; } public BlobRefDto? RoomData { get; set; } public BlobRefDto? SubRoomData { get; set; } public string EffectiveRoomDataFilename => RoomDataFilename ?? Filename ?? SubRoomData?.Filename ?? string.Empty; } public class BlobRefDto { public string? Filename { get; set; } public string? Hash { get; set; } public string? OwnershipProof { get; set; } } public class CreatorActionContextDto { public bool IsTeachableMomentRunning { get; set; } } /// POST api/rooms/v1/restore — switch a room back to a /// previous save. Body: roomId, blobName (or version id). Updates /// the player's DormStateEntity (for dorms) or RoomEntity /// CurrentDataBlobName (for owned rooms) so the next visit loads /// the historical bytes. /// POST api/rooms/v1/datahistory/restore — restore a /// room to a previous save. /// /// Request body: RestoreDataHistoryRequest serialized by /// Unity's JsonUtility.ToJson, which uses C# field names. /// Verified at Cpp2IL_ISIL/.../Rooms.txt:13513-14 — the only /// field on the request type is the long history id at offset +16, /// and Unity's serializer names it roomDataHistoryId. We /// look up by that id, find its /// parent room, and rewrite CurrentDataBlobName (and the /// dorm-state row for personal dorms) so the next room reload /// downloads the historical bytes. /// /// Response: CreateModifyRoomSceneResponse — keys are /// PascalCase per Cpp2IL_ISIL/.../Rooms_NestedType_CreateModifyRoomSceneResponse.txt: /// Result (int, CreateModifyRoomStatus: 0=Success), and /// RoomScene (the restored scene). The watch's /// ProcessCreateModifyRoomSceneResponse chain expects both; /// returning a bare {restored: "..."} like the old impl makes /// the watch's deserializer throw and the restore silently fails. /// The legacy form-urlencoded path stays under /// /api/rooms/v1/restore for the admin SPA's restore UI. /// public sealed class RestoreDataHistoryRequest { // Unity JsonUtility serializes C# private backing fields by // their declared name. The watch's request type has a single // field which Unity emits as "roomDataHistoryId" (camelCase) — // [JsonPropertyName] here matches that wire key explicitly so // System.Text.Json picks it up regardless of any future global // naming policy. [System.Text.Json.Serialization.JsonPropertyName("roomDataHistoryId")] public long RoomDataHistoryId { get; set; } } [HttpPost("api/rooms/v1/datahistory/restore")] [Authorize] public async Task RestoreDataHistory([FromBody] RestoreDataHistoryRequest body) { var pid = this.RequireCurrentPlayerId(); var blob = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.Id == body.RoomDataHistoryId); if (blob is null) return NotFound(new { error = "version_not_found" }); var room = await rooms.GetByIdAsync(blob.RoomId); if (room is null) return NotFound(new { error = "room_not_found" }); // Ownership: only the dorm owner / room creator can restore. // Admin override is intentionally NOT included here — restore // is destructive (overrides the player's chosen state) so we // require the actual creator. if (room.CreatorPlayerId != pid) return Forbid(); if (room.IsDormRoom) { var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid); if (dormState is null) { dormState = new DormStateEntity { PlayerId = pid }; db.DormStates.Add(dormState); } dormState.CurrentDataBlobName = blob.BlobName; dormState.UpdatedAt = DateTime.UtcNow; } await db.Rooms .Where(r => r.Id == room.Id) .ExecuteUpdateAsync(s => s .SetProperty(r => r.CurrentDataBlobName, blob.BlobName) .SetProperty(r => r.UpdatedAt, DateTime.UtcNow)); await db.SaveChangesAsync(); // Push the same SubscriptionUpdateRoom the save flow sends so // the watch's StoreRoomDetailsInCache → SafeRaiseRoomDetailsUpdated // chain fires and any open room-detail panels refresh. var pushRoom = room.IsDormRoom ? CloneWithCreator(room, pid, blob.BlobName) : CloneWithCreator(room, room.CreatorPlayerId, blob.BlobName); var allScenes = await db.RoomScenes .Where(s => s.RoomId == room.Id) .OrderBy(s => s.OrderIndex) .ToListAsync(); var roomDetailsPayload = BuildRoomDetails(pushRoom, allScenes, 0L, blob.BlobName); await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload); // CreateModifyRoomSceneResponse shape. RoomScene fields match // RecNet.RoomScene.Deserialize (PascalCase, RoomSceneId, // RoomId, RoomSceneLocationId, Name, IsSandbox, DataBlobName, // MaxPlayers, CanMatchmakeInto, DataModifiedAt). return Ok(new { Result = 0, // CreateModifyRoomStatus.Success RoomScene = new { RoomSceneId = 0L, RoomId = room.Id, RoomSceneLocationId = room.LocationReplicationId, Name = room.Name, IsSandbox = false, DataBlobName = blob.BlobName, MaxPlayers = 8, CanMatchmakeInto = true, DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"), }, }); } /// Legacy form-urlencoded restore — kept for the admin SPA's /// "Restore version" picker which posts {roomId, blobName} fields. /// New callers (the watch) use the JSON variant above. [HttpPost("api/rooms/v1/restore")] [Authorize] public async Task RestoreVersionLegacy( [FromForm] long roomId, [FromForm] string? blobName, [FromForm] long version = 0) { var pid = this.RequireCurrentPlayerId(); var room = await rooms.GetByIdAsync(roomId); if (room is null) return NotFound(new { error = "room_not_found" }); var blob = !string.IsNullOrEmpty(blobName) ? await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName && b.RoomId == roomId) : await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.Id == version && b.RoomId == roomId); if (blob is null) return NotFound(new { error = "version_not_found" }); if (room.CreatorPlayerId != pid) return Forbid(); if (room.IsDormRoom) { var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid); if (dormState is null) { dormState = new DormStateEntity { PlayerId = pid }; db.DormStates.Add(dormState); } dormState.CurrentDataBlobName = blob.BlobName; dormState.UpdatedAt = DateTime.UtcNow; } await db.Rooms .Where(r => r.Id == roomId) .ExecuteUpdateAsync(s => s .SetProperty(r => r.CurrentDataBlobName, blob.BlobName) .SetProperty(r => r.UpdatedAt, DateTime.UtcNow)); await db.SaveChangesAsync(); return Ok(new { restored = blob.BlobName }); } /// GET api/rooms/v{1,2}/personaldetails/{roomId} — /// per-caller bookmark/cheer flags for one room. Wire shape verified /// against RecNet.PersonalRoomDetails.Deserialize /// (Cpp2IL_ISIL/.../PersonalRoomDetails.txt:85-91): /// IsCheering - bool, REQUIRED (Util.GetKey, throws if missing) /// IsBookmarked - bool, REQUIRED /// Both keys are PascalCase. Returning the full room-details payload /// here (which is what we used to do) makes the deserialiser throw /// KeyNotFoundException for IsCheering, and that blocks the entire /// /goto/* flow because RecNet.Rooms.GetPersonalRoomDetails is /// awaited before SessionManager.JoinRoom can run. [HttpGet("api/rooms/v2/personaldetails/{roomId:long}")] [HttpGet("api/rooms/v1/personaldetails/{roomId:long}")] public async Task PersonalDetails(long roomId) { var pid = CurrentPlayerId ?? 0; bool cheering = false, bookmarked = false; if (pid > 0) { cheering = await db.Cheers.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid); bookmarked = await db.RoomBookmarks.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid); } return Ok(new { IsCheering = cheering, IsBookmarked = bookmarked }); } /// /// GET /roomserver/rooms/{id}/interactionby/me — sibling of /// personaldetails but DIFFERENT wire shape. Per /// EJDCNGBEICB.txt:8219,8266 the deserialiser is /// CJODCLDGFCF, which reads Cheered + Favorited /// (PascalCase, both bool, both required — verified at /// CJODCLDGFCF.txt:118,123). Sharing the /// {IsCheering, IsBookmarked} payload with personaldetails /// throws KeyNotFoundException on the watch and stalls the /// room-tile UI. /// [HttpGet("roomserver/rooms/{roomId:long}/interactionby/me")] [HttpGet("rooms/{roomId:long}/interactionby/me")] public async Task RoomInteractionByMe(long roomId) { var pid = CurrentPlayerId ?? 0; bool cheered = false, favorited = false; if (pid > 0) { cheered = await db.Cheers.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid); favorited = await db.RoomBookmarks.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid); } return Ok(new { Cheered = cheered, Favorited = favorited }); } /// /// GET /roomserver/playlists/{id}/interactionby/me — same /// CJODCLDGFCF wire shape as the room variant. Per /// EJDCNGBEICB.txt:8600 the URL is /// "playlists/{0}/interactionby/me". Reads from /// ; an anonymous caller /// (no JWT) reads as (false, false) so the watch's pre-login /// browse renders cleanly. /// [HttpGet("roomserver/playlists/{playlistId:long}/interactionby/me")] [HttpGet("playlists/{playlistId:long}/interactionby/me")] public async Task PlaylistInteractionByMe(long playlistId) { var pid = CurrentPlayerId ?? 0; if (pid <= 0) return Ok(new { Cheered = false, Favorited = false }); var (cheered, favorited) = await playlists.InteractionForAsync(playlistId, pid); return Ok(new { Cheered = cheered, Favorited = favorited }); } [HttpGet("api/rooms/v4/details/{roomId:long}")] [HttpGet("api/rooms/v3/details/{roomId:long}")] [HttpGet("roomserver/rooms/{roomId:long}")] public async Task Details(long roomId) { var r = await rooms.GetByIdAsync(roomId); var room = r ?? Synthetic($"Room_{roomId}", roomId); // Dorm-specific shaping: each player has their own first-class // dorm row. Only the dorm's actual creator should see Creator // role / Maker Pen. Guests loading someone else's dorm must see // the owner as CreatorPlayerId, while still receiving the // owner's DormStateEntity blob so they load the same room state. if (room.IsDormRoom) { room = await ShapeDormRoomAsync(room); } var scenes = await db.RoomScenes .Where(s => s.RoomId == room.Id) .OrderBy(s => s.OrderIndex) .ToListAsync(); var roomRoles = await db.RoomRoles .Where(r => r.RoomId == room.Id) .ToListAsync(); if (Request.Path.StartsWithSegments("/roomserver", StringComparison.OrdinalIgnoreCase)) return Ok(BuildRoomServerDetails(room, scenes, roles: roomRoles)); return Ok(BuildRoomDetails(room, scenes, roles: roomRoles)); } [HttpGet("roomserver/rooms")] [HttpGet("rooms")] public async Task RoomServerDetailsByName([FromQuery(Name = "name")] string? roomName) { if (string.IsNullOrWhiteSpace(roomName)) return Ok(Array.Empty()); var pid = CurrentPlayerId ?? 0; RoomEntity? room; if (roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase) && pid > 0) { room = await rooms.EnsurePersonalDormAsync(pid); } else { room = await rooms.GetByNameAsync(roomName); } room ??= Synthetic(roomName); if (room.IsDormRoom) { // Stamp the wire Name with the requested name so the watch's // name-keyed room cache (OJMCBOKJFOF.NHBPIIGDAJP) can find it. room = await ShapeDormRoomAsync(room, DormNameOverride(roomName)); } var scenes = await db.RoomScenes .Where(s => s.RoomId == room.Id) .OrderBy(s => s.OrderIndex) .ToListAsync(); var roomRoles = await db.RoomRoles .Where(r => r.RoomId == room.Id) .ToListAsync(); return Ok(BuildRoomServerDetails(room, scenes, roles: roomRoles)); } /// GET /roomserver/rooms/bulk?name=X&name=Y or /// ?id=1&id=2 — bulk room cache lookup. Decomp-verified /// in EJDCNGBEICB: the Int64 overload writes query key id /// and the string overload writes name. Returns the same /// per-room object that /roomserver/rooms/{id} emits; /// unresolved entries are silently skipped (watch handles a shorter /// list fine, but a 404 wedges the room browser). [HttpGet("roomserver/rooms/bulk")] [HttpGet("rooms/bulk")] public async Task RoomServerBulkByName() { var names = Request.Query["name"].Where(n => !string.IsNullOrWhiteSpace(n)).ToArray(); var ids = Request.Query["id"] .Where(v => !string.IsNullOrWhiteSpace(v)) .SelectMany(v => v!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Select(v => long.TryParse(v, out var id) ? id : 0) .Where(id => id > 0) .Distinct() .ToArray(); if (names.Length == 0 && ids.Length == 0) return Ok(Array.Empty()); var pid = CurrentPlayerId ?? 0; var results = new List(names.Length + ids.Length); var emitted = new HashSet(); foreach (var raw in names) { var name = raw!.Trim(); RoomEntity? room; if (name.Equals("DormRoom", StringComparison.OrdinalIgnoreCase) && pid > 0) room = await rooms.EnsurePersonalDormAsync(pid); else room = await rooms.GetByNameAsync(name); room ??= Synthetic(name); if (room.IsDormRoom) { // Stamp the wire Name with the requested name so the watch's // name-keyed room cache (OJMCBOKJFOF.NHBPIIGDAJP) finds it. room = await ShapeDormRoomAsync(room, DormNameOverride(name)); } var scenes = await db.RoomScenes .Where(s => s.RoomId == room.Id) .OrderBy(s => s.OrderIndex) .ToListAsync(); var roomRoles = await db.RoomRoles .Where(r => r.RoomId == room.Id) .ToListAsync(); results.Add(BuildRoomServerDetails(room, scenes, roles: roomRoles)); emitted.Add(room.Id); } foreach (var id in ids) { if (!emitted.Add(id)) continue; var room = await rooms.GetByIdAsync(id); if (room is null) continue; if (room.IsDormRoom) { room = await ShapeDormRoomAsync(room); } var scenes = await db.RoomScenes .Where(s => s.RoomId == room.Id) .OrderBy(s => s.OrderIndex) .ToListAsync(); var roomRoles = await db.RoomRoles .Where(r => r.RoomId == room.Id) .ToListAsync(); results.Add(BuildRoomServerDetails(room, scenes, roles: roomRoles)); } return Ok(results); } /// /// Shallow clone of a RoomEntity with a different CreatorPlayerId /// and (optionally) a different CurrentDataBlobName. Used to give /// the per-caller dorm the appearance of being authored by the /// caller and pointing at the caller's own most recent save. /// Doesn't touch the DB — purely a response-shaping utility. /// /// When a dorm was resolved via the magic name "DormRoom", /// returns that requested name (verbatim, so casing matches what the /// watch will look the cache up by) to stamp onto the wire room. /// Returns null for any other request name so the dorm keeps its real /// "Dorm_{playerId}" name in by-id / direct-name responses. private static string? DormNameOverride(string requestedName) => requestedName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase) ? requestedName : null; private async Task ShapeDormRoomAsync(RoomEntity room, string? overrideName = null) { var dormBlobName = await rooms.ResolveDormDataBlobNameAsync(room.CreatorPlayerId, room.Id); var dormName = overrideName ?? await rooms.BuildPersonalDormDisplayNameAsync(room.CreatorPlayerId); return CloneWithCreator(room, room.CreatorPlayerId, dormBlobName, dormName); } public static RoomEntity CloneWithCreator( RoomEntity src, long creatorId, string? overrideBlobName = null, string? overrideName = null) => new() { Id = src.Id, // overrideName lets the by-name resolvers stamp the wire Name with // the name the client actually requested. The personal-dorm entity // is named "Dorm_{playerId}", but the watch resolves it via the // magic name "DormRoom" (HomeScreenFlow.Button_DormRoom → // RunJoinRoom("DormRoom") → OJMCBOKJFOF.NHBPIIGDAJP("DormRoom")). // That call caches the response in a Dictionary and // looks it back up by the REQUESTED string ("DormRoom"); if the // wire Name is "Dorm_{id}" the lookup misses → the promise rejects // with "No such room" → the "contact recroom.happyfox.com" toast. // Stamping Name="DormRoom" here makes the lookup hit, the same way // RecCenter (whose entity Name IS "RecCenter") already works. Name = overrideName ?? src.Name, Description = src.Description, CreatorPlayerId = creatorId, ImageName = src.ImageName, State = src.State, Accessibility = src.Accessibility, SupportsLevelVoting = src.SupportsLevelVoting, IsAGRoom = src.IsAGRoom, IsDormRoom = src.IsDormRoom, CloningAllowed = src.CloningAllowed, SupportsVRLow = src.SupportsVRLow, SupportsMobile = src.SupportsMobile, SupportsScreens = src.SupportsScreens, SupportsWalkVR = src.SupportsWalkVR, SupportsTeleportVR = src.SupportsTeleportVR, AllowsJuniors = src.AllowsJuniors, AllowNewUsers = src.AllowNewUsers, MinLevel = src.MinLevel, MaxPlayerCalculationMode = src.MaxPlayerCalculationMode, LoadScreensJson = src.LoadScreensJson, PromoImagesJson = src.PromoImagesJson, PromoExternalContentJson = src.PromoExternalContentJson, RoomWarningMask = src.RoomWarningMask, CustomRoomWarning = src.CustomRoomWarning, DisableMicAutoMute = src.DisableMicAutoMute, LocationReplicationId = src.LocationReplicationId, IsStudioRoom = src.IsStudioRoom, IsRoomLinkedToRecRoomStudio = src.IsRoomLinkedToRecRoomStudio, StudioSessionId = src.StudioSessionId, TagsCsv = src.TagsCsv, CheerCount = src.CheerCount, FavoriteCount = src.FavoriteCount, VisitCount = src.VisitCount, HotScore = src.HotScore, CreatedAt = src.CreatedAt, UpdatedAt = src.UpdatedAt, CurrentDataBlobName = overrideBlobName ?? src.CurrentDataBlobName, }; /// /// Wire shape for the RoomDetails object — matches RoomDetails.Deserialize /// at RVA 0x114C580. JSON key names DO NOT necessarily match the C# /// property/field names from dump.cs — the deserializer is hand-written /// and uses different (mostly PascalCase) literal strings. Verified /// empirically: this exact shape gets past Deserialize successfully. /// public static object BuildRoomDetails( RoomEntity room, IReadOnlyList? sceneRows = null, long? overrideSceneId = null, string? overrideDataBlobName = null, IReadOnlyList? roles = null) { // Tags is a list of OBJECTS, not strings. RoomDetails.Deserialize // does Util.DeserializeList(tags) — each entry is parsed as // a Dictionary with `Type` (int RoomTagType: 0=Auto, 1=PlayerAdded, // 2=AG) and `Tag` (string). Sending bare strings crashes: // InvalidCastException: Unable to cast 'String' to 'Dictionary`2' var tags = string.IsNullOrEmpty(room.TagsCsv) ? Array.Empty() : room.TagsCsv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(t => (object)new { Type = RoomTagTypeForWire(t), Tag = t, IsPrimaryGenre = false, }) .ToArray(); // Scenes population: every room must produce a Scenes[0] entry // with RoomSceneId=0 (matching the matchmaking SubRoomId=0) so // that Rooms.set_LocalRoomDetails actually transitions // Rooms.LocalRoomScene to the new room's scene reference. // // Why every room: the setter (verified in Cpp2IL ISIL of // RecNet.Rooms.set_LocalRoomDetails) iterates Scenes looking // for a RoomSceneId match; if none matches (Scenes=[] or // mismatched), it falls through WITHOUT writing LocalRoomScene // — so the field stays stale at whatever the previous room set. // After the dorm fix landed, that stale value started leaking // into the rec center: the dorm sets LocalRoomScene → user // goes to rec center → if we send Scenes=[], LocalRoomScene // STAYS pointing at the dorm scene → DownloadRoomDataBlob // Coroutine reads scene.DataBlobName from the dorm and // downloads room_1_v1.dat (smoking gun in the server log) // during the rec center load. The rec center master init // blocks → FROSTBITE. // // The DataBlobName field is what determines whether the // persistence coroutine actually downloads anything. // OnRoomDataBlobNameChanged (RoomPersistenceManager.txt ISIL // {027-040}) short-circuits when DataBlobName is empty: it // sets GetRoomDataBlobPromise to a CompletedPromise // with empty bytes and returns immediately — no HTTP fetch, // no DeserializeRoomDataBlobCoroutine, the master init // proceeds with default-zero permissions. // // If a room has a saved blob, use it. Otherwise point at // a synthetic CDN key, which misses S3 and is served by // RoomDataBlobService.GetDefaultBlob. That default is a captured // PersistedRoomData (v38); the older synthetic role-only blob and // empty DataBlobName path both leave the 2023 room-permissions // runtime without data it dereferences during spawn. var roleList = roles ?? Array.Empty(); var dataBlobName = CurrentOrSyntheticDataBlobName(room); // Multi-scene rooms (imported via tools/import-room.ps1) carry one // RoomSceneEntity row per sub-room. Emit the full Scenes[] array // when present so cross-scene portals (Lobby→Ch1, etc.) can // resolve. Single-scene rooms (every legacy room, every dorm, // every AG-Original) fall back to the synthesised Scenes[0] from // RoomEntity fields. object[] scenes; if (sceneRows is { Count: > 0 }) { scenes = sceneRows .Select(s => (object)new { RoomSceneId = (long)s.OrderIndex, RoomId = room.Id, RoomSceneLocationId = s.RoomSceneLocationId, Name = s.Name, IsSandbox = s.IsSandbox, DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) && (overrideSceneId == s.OrderIndex || sceneRows.Count == 1) ? overrideDataBlobName : SceneOrSyntheticDataBlobName(room, s.DataBlobName, dataBlobName), MaxPlayers = s.MaxPlayers, CanMatchmakeInto = s.CanMatchmakeInto, DataModifiedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"), }) .ToArray(); } else { scenes = new object[] { new { RoomSceneId = 0L, // MUST match RoomInstance.SubRoomId RoomId = room.Id, RoomSceneLocationId = room.LocationReplicationId, Name = "Home", IsSandbox = false, DataBlobName = dataBlobName, MaxPlayers = room.MaxCapacity, CanMatchmakeInto = true, // Use the room's actual UpdatedAt so the watch's // "is my local cache stale?" check sees an accurate // freshness timestamp. Hardcoding the build date // (2020-03-06) made the watch flag every dorm visit // as "room is not up to date" because the cached // local copy was always newer than the server's // declared timestamp. DataModifiedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"), }, }; } // Owners tile (UIRoomDetails bottom-left) reads CoOwners — the // 2020 RoomDetails.Deserialize reads keys WITHOUT the "Ids" // suffix (verified at RoomDetails.txt:1096/1141/1146/1191/1196: // "CoOwners" / "InvitedCoOwners" / "Moderators" / // "InvitedModerators" / "Hosts" / "InvitedHosts"). The // GetListKey calls are strict — wrong names throw // KeyNotFoundException → "Failed to get room details: // Malformed Response" → dorm load stalls in a redirect loop. // // CreatorPlayerId is always implicitly a CoOwner so RR Originals // surface Coach (id=1) as the owner instead of rendering blank. // Additional grants come from RoomRoleEntity rows when supplied; // callers without role data (clone/myrooms/etc.) ship just the // implicit creator. int[] PlayersIn(int role, bool accepted) => roleList .Where(r => r.Role == role && r.Accepted == accepted) .Select(r => (int)r.PlayerId) .ToArray(); var coOwnerSet = new HashSet { (int)room.CreatorPlayerId }; foreach (var pid in PlayersIn(0, true)) coOwnerSet.Add(pid); return new { Room = RoomService.ToWireRoom(room), Scenes = scenes, CoOwners = coOwnerSet.ToArray(), InvitedCoOwners = PlayersIn(0, false), Moderators = PlayersIn(1, true), InvitedModerators = PlayersIn(1, false), Hosts = PlayersIn(2, true), InvitedHosts = PlayersIn(2, false), CheerCount = room.CheerCount, FavoriteCount = room.FavoriteCount, VisitCount = room.VisitCount, // VisitorCount is the official Rec.Net "unique visitors" // companion field (see Stats payload at apim.rec.net). // 2020 RoomDetails.Deserialize doesn't read it but leaving // it on the wire is harmless and lets the admin UI / any // future client surface the number. VisitorCount = room.VisitorCount, Tags = tags, }; } /// /// 2020.12 /roomserver/rooms/{id}?include=301 shape. The client /// deserializes this directly as PPENFJMFPNE, not as the older /// { Room, Scenes, ... } wrapper. /// public static object BuildRoomServerDetails( RoomEntity room, IReadOnlyList? sceneRows = null, string? overrideDataBlobName = null, IReadOnlyList? roles = null, int? unityAssetTarget = null, int? unityAssetVersion = null) { var roleList = roles ?? Array.Empty(); // 2023's room-permissions runtime needs a real PersistedRoomData // source during spawn. If no saved blob exists, use the captured // default via the synthetic CDN path. var dataBlobName = CurrentOrSyntheticDataBlobName(room); var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"); // The blob the room actually loads from: sub-room 0's, override and // all. `dataBlobName` above is the per-ROOM name, which is the wrong // answer whenever a caller passes an override — a dorm's data is // per-PLAYER (dorm_p{id}_v*.dat) and arrives that way. var primarySubRoom = sceneRows is { Count: > 0 } ? sceneRows.FirstOrDefault(s => s.OrderIndex == 0) ?? sceneRows[0] : null; var primarySubRoomDataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && (sceneRows?.Count ?? 0) <= 1 ? overrideDataBlobName! : primarySubRoom is not null ? SceneOrSyntheticDataBlobName(room, primarySubRoom.DataBlobName, dataBlobName) : dataBlobName; object[] subRooms = sceneRows is { Count: > 0 } ? sceneRows.Select(s => { var dataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1 ? overrideDataBlobName : 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, // Same rule as the populated branch: honour the override, // so a dorm with no scene rows still points at that // player's own save rather than a per-room name. DataBlob = primarySubRoomDataBlob, 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, }, }, }; var wireRoles = new List { BuildRoomAccountRoleWire(room.CreatorPlayerId, 30), }; wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire)); var tags = string.IsNullOrEmpty(room.TagsCsv) ? Array.Empty() : room.TagsCsv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(t => (object)new { Type = RoomTagTypeForWire(t), Tag = t, IsPrimaryGenre = false, }) .ToArray(); return new { RoomId = room.Id, Name = room.Name, Description = room.Description, ImageName = room.ImageName, WarningMask = room.RoomWarningMask, CustomWarning = room.CustomRoomWarning, CreatorAccountId = room.CreatorPlayerId, State = room.State, Accessibility = room.Accessibility, SupportsLevelVoting = room.SupportsLevelVoting, IsRRO = room.IsAGRoom, SupportsScreens = room.SupportsScreens, SupportsWalkVR = room.SupportsWalkVR, SupportsTeleportVR = room.SupportsTeleportVR, SupportsVRLow = room.SupportsVRLow, SupportsQuest2 = true, SupportsMobile = room.SupportsMobile, SupportsJuniors = room.AllowsJuniors, MinLevel = room.MinLevel, CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"), Stats = new { CheerCount = room.CheerCount, FavoriteCount = room.FavoriteCount, VisitorCount = room.VisitorCount, VisitCount = room.VisitCount, }, IsDorm = room.IsDormRoom, CloningAllowed = room.CloningAllowed, DisableMicAutoMute = room.DisableMicAutoMute, DisableRoomComments = false, EncryptVoiceChat = false, LoadScreenLocked = false, PersistenceVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0, IsDeveloperOwned = room.IsStudioRoom || HasRoomTag(room, "developer"), MaxPlayerCalculationMode = room.MaxPlayerCalculationMode, SubRooms = subRooms, Roles = wireRoles, LoadScreens = JsonArrayOrEmpty(room.LoadScreensJson), PromoImages = JsonStringArrayOrEmpty(room.PromoImagesJson), PromoExternalContent = JsonArrayOrEmpty(room.PromoExternalContentJson), Tags = tags, // The client's room-details reader (GLEGPFFPDBE) registers these // five alongside the rest. A registered slot wants a value, and // leaving one absent is not the same as sending null — it is what // makes the reader throw mid-deserialise on a response the server // already answered 200 to. Kept last so the additions are visible // against the reader's own list; key ORDER does not matter to a // name-keyed reader, presence does. // Must be the SAME blob sub-room 0 resolves to, override included. // A dorm's data is per-player (dorm_p{id}_v*.dat) and arrives here // as overrideDataBlobName; the room-level name is per-ROOM // (room_116_*), so reporting that instead hands the client another // player's dorm — or one that was never written — and it rejects // the load outright with "the data in the room you are trying to // load is corrupt". DataBlob = subRooms.Length > 0 ? primarySubRoomDataBlob : dataBlobName, DataBlobHash = (string?)null, // The room-level cap is the room's own advertised capacity, which is // what RoomEntity.MaxCapacity exists for and what matchmaking hands // to RoomInstance.MaxCapacity. Each sub-room carries its own // MaxPlayers in the SubRooms array — they are separate settings, so // this must not be read off sub-room 0. MaxPlayers = room.MaxCapacity, // ToxMod is Rec Room's hosted voice moderation. There is no such // service here, so it is off — the client only uses this to decide // whether to show the indicator. ToxmodEnabled = false, RankingContext = (object?)null, }; } private static int RoomTagTypeForWire(string tag) { if (string.Equals(tag, "beta", StringComparison.OrdinalIgnoreCase)) return 1; return tag.ToLowerInvariant() switch { "rrstudio" or "community" or "screen" or "walkvr" or "teleportvr" or "junior" or "pickup" => 2, _ => 0, }; } private static bool HasRoomTag(RoomEntity room, string tag) { return !string.IsNullOrWhiteSpace(room.TagsCsv) && room.TagsCsv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Any(t => string.Equals(t, tag, StringComparison.OrdinalIgnoreCase)); } private static JsonElement[] JsonArrayOrEmpty(string? json) { if (string.IsNullOrWhiteSpace(json)) return Array.Empty(); try { return JsonSerializer.Deserialize(json) ?? Array.Empty(); } catch (JsonException) { return Array.Empty(); } } private static string[] JsonStringArrayOrEmpty(string? json) { if (string.IsNullOrWhiteSpace(json)) return Array.Empty(); try { return JsonSerializer.Deserialize(json) ?? Array.Empty(); } catch (JsonException) { return Array.Empty(); } } /// 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 ─────────────────────────────────────────────────── /// /// `Rooms.CloneRoom(roomIdToClone, name)` — POST api/rooms/v1/clone /// with body `{"Name":"","RoomId":}`. /// /// Response shape (CreateModifyRoomResponse.Deserialize, RVA 0xAF1950): /// Result (int CreateModifyRoomStatus: 0=Success, 1=Unknown, /// 2=PermissionDenied, 3=RoomNotActive, /// 4=RoomDoesNotExist, 5=RoomHasNoDataBlob, …) /// RoomDetails (RoomDetails object, only populated on Result=0) /// [HttpPost("api/rooms/v1/clone")] [Authorize] public async Task Clone([FromBody] CloneRoomRequest req) { var pid = CurrentPlayerId; if (pid is null) return Unauthorized(); // CloneAsync sets the CreateModifyRoomStatus code itself // (Success/RoomDoesNotExist/DuplicateName/InappropriateName) so the // watch can show the right toast instead of a generic "Unknown". var result = await rooms.CloneAsync(req.RoomId, req.Name ?? string.Empty, pid.Value); return Ok(new { Result = result.Status, RoomDetails = result.Room is null ? null : BuildRoomDetails(result.Room), }); } public class CloneRoomRequest { public string? Name { get; set; } public long RoomId { get; set; } } // ── My-Rooms tabs ──────────────────────────────────────────────────── // 2020.12 OJMCBOKJFOF populates its local rooms cache from these // URLs at boot (verified via metadata strings: "rooms/createdby/me", // "rooms/moderatedby/me", "rooms/visitedby/me"). The 2020.03 client // used /api/rooms/v2/myrooms, while 2020.12 moved the calls under // /roomserver/rooms/*. Keep both route families on the same handlers. [HttpGet("api/rooms/v2/myrooms")] [HttpGet("api/rooms/v1/createdby/me")] [HttpGet("api/rooms/v2/createdby/me")] [HttpGet("api/rooms/v3/createdby/me")] [HttpGet("roomserver/rooms/createdby/me")] [HttpGet("roomserver/rooms/ownedby/me")] [HttpGet("rooms/createdby/me")] [HttpGet("rooms/ownedby/me")] [Authorize] public async Task MyRooms() { var pid = CurrentPlayerId; if (pid is null) return Ok(Array.Empty()); return Ok((await rooms.CreatedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList()); } [HttpGet("api/rooms/v1/createdby/{otherPlayerId:long}")] [HttpGet("api/rooms/v2/createdby/{otherPlayerId:long}")] [HttpGet("api/rooms/v3/createdby/{otherPlayerId:long}")] [HttpGet("roomserver/rooms/createdby/{otherPlayerId:long}")] [HttpGet("roomserver/rooms/ownedby/{otherPlayerId:long}")] [HttpGet("rooms/createdby/{otherPlayerId:long}")] [HttpGet("rooms/ownedby/{otherPlayerId:long}")] public async Task CreatedByOther(long otherPlayerId) => Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList()); /// Per-player showcase curation, stored in the existing generic /// player-settings table as a CSV of room ids. Colon-namespaced to match /// the other server-owned keys (settings:partyinvite). private const string ShowcaseSettingKey = "rooms:showcase"; private const int ShowcaseMaxRooms = 50; /// GET showcase/{accountId} — the profile "Showcase Rooms" /// carousel (RoomListModel.JMGLGEKHHAK.PlayerShowcaseRooms = 7, /// dump.cs:2042498, driven by /// AccountModelController.RoomsShowcaseLinkImpl at dump.cs:2113041). /// Distinct from PlayerCreatedRooms = 5, which is the /// rooms/createdby/{id} list above. /// /// Verb. NLDBPDCNNCF.EJPNHLIJNPM formats the literal /// "showcase/{0}" (NLDBPDCNNCF.txt ISIL 021 at :4484) and moves the /// HTTPMethods ordinal 0 (= GET) into rdx immediately before the /// dispatch call (ISIL 031/032 at :4494-4495). No cmov, so GET is the only /// verb. The path arg is boxed as System.Int32 (ISIL 017) — the /// account id. /// /// Response shape. The issuing method's return type is the /// contract: Task<List<System.Int64>> /// (NLDBPDCNNCF.txt:4422) — a BARE JSON array of room ids, not Room /// objects and not a {Results,TotalResults} container. The Rooms the /// UI renders are a purely client-side projection: the cache entry /// <GetRoomsShowcase>b__0 is typed /// Task<IReadOnlyList<Int64>> /// (IBEOONPEELF_NestedType_HHDHJPIJBJB.txt:14) and the outer /// IBEOONPEELF.EJPNHLIJNPM resolves those ids through the room cache /// to FGLDKEJLAKB<IReadOnlyList<NEMINAEBALC>> /// (IBEOONPEELF.txt:8952) before RoomListModel consumes it /// (RoomListModel.txt ISIL 742 at :5819). /// /// Backing data. showcase/{0} is the only showcase /// literal in the binary — the 2023 client can read the list but never /// writes it (curation lived on the website), so the curated order is kept /// in the existing per-player settings store under /// and is writable through /// PUT /settings/v1/{accountId} with no new schema. Curated ids are /// re-validated against Rooms on every read so a room that has since been /// archived or privatised drops off the profile, and the curated order is /// preserved. A player who never curated one falls back to the rooms they /// actually created, most prominent first — the showcase the carousel is /// there to display. /// /// Unauthenticated on purpose: this is another player's profile /// surface, and the sibling rooms/createdby/{id} is open too. The /// owner viewing their own profile additionally sees their non-public /// picks; everyone else sees public rooms only. [HttpGet("showcase/{accountId:long}")] // roomserver/ twin: EJPNHLIJNPM lives on NLDBPDCNNCF, whose requests all // arrive with the roomserver/ prefix in this deployment // (docs/recroom-2023-room-save.md:83-93) — same reason every other // NLDBPDCNNCF route in this file is registered twice. [HttpGet("roomserver/showcase/{accountId:long}")] public async Task RoomsShowcase(long accountId) { var viewerIsOwner = CurrentPlayerId == accountId; var curatedCsv = await db.PlayerSettings.AsNoTracking() .Where(s => s.PlayerId == accountId && s.Key == ShowcaseSettingKey) .Select(s => s.Value) .FirstOrDefaultAsync(); var curatedIds = (curatedCsv ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(v => long.TryParse(v, out var id) ? id : 0L) .Where(id => id > 0) .Distinct() .Take(ShowcaseMaxRooms) .ToList(); if (curatedIds.Count > 0) { var visible = await db.Rooms.AsNoTracking() .Where(r => curatedIds.Contains(r.Id) && r.State == 0 && !r.IsDormRoom && !r.HiddenFromBrowse && (r.Accessibility == 1 || viewerIsOwner)) .Select(r => r.Id) .ToListAsync(); var visibleSet = visible.ToHashSet(); // Curated order is the display order — filter, don't re-sort. var ordered = curatedIds.Where(visibleSet.Contains).ToList(); if (ordered.Count > 0) return Ok(ordered); } var fallback = await db.Rooms.AsNoTracking() .Where(r => r.CreatorPlayerId == accountId && r.State == 0 && !r.IsDormRoom && !r.HiddenFromBrowse && (r.Accessibility == 1 || viewerIsOwner)) .OrderByDescending(r => r.HotScore) .ThenByDescending(r => r.VisitCount) .ThenByDescending(r => r.UpdatedAt) .Select(r => r.Id) .Take(ShowcaseMaxRooms) .ToListAsync(); return Ok(fallback); } [HttpGet("api/rooms/v1/visitedby/me")] [HttpGet("api/rooms/v2/visitedby/me")] [HttpGet("api/rooms/v3/visitedby/me")] [HttpGet("roomserver/rooms/visitedby/me")] [HttpGet("rooms/visitedby/me")] [Authorize] public async Task VisitedByMe() { var pid = CurrentPlayerId; if (pid is null) return Ok(Array.Empty()); return await VisitedByForViewerAsync(pid.Value, pid.Value); } [HttpGet("rooms/visitedby/{playerId:long}")] [HttpGet("roomserver/rooms/visitedby/{playerId:long}")] public async Task VisitedBy(long playerId) { return await VisitedByForViewerAsync(playerId, CurrentPlayerId); } private async Task VisitedByForViewerAsync(long playerId, long? viewerPlayerId) { var rows = await db.RoomVisits.AsNoTracking() .Where(v => v.PlayerId == playerId) .Join( db.Rooms.AsNoTracking(), visit => visit.RoomId, room => room.Id, (visit, room) => new { visit, room }) .Where(x => x.room.Accessibility == 1 || (viewerPlayerId.HasValue && x.room.CreatorPlayerId == viewerPlayerId.Value)) .OrderByDescending(x => x.visit.LastVisitAt) .Select(x => x.room) .Take(100) .ToListAsync(); if (rows.Count == 0) return Ok(Array.Empty()); return Ok(await BuildRoomServerListAsync(rows)); } [HttpGet("api/rooms/v1/moderatedby/me")] [HttpGet("api/rooms/v2/moderatedby/me")] [HttpGet("api/rooms/v3/moderatedby/me")] [HttpGet("roomserver/rooms/moderatedby/me")] [HttpGet("rooms/moderatedby/me")] [Authorize] public async Task ModeratedByMe() { var pid = CurrentPlayerId; if (pid is null) return Ok(Array.Empty()); var ids = await db.RoomRoles.AsNoTracking() .Where(r => r.PlayerId == pid.Value && r.Accepted) .OrderByDescending(r => r.GrantedAt) .Select(r => r.RoomId) .Distinct() .Take(100) .ToListAsync(); var rows = await db.Rooms.AsNoTracking() .Where(r => ids.Contains(r.Id)) .OrderByDescending(r => r.UpdatedAt) .ToListAsync(); return Ok(await BuildRoomServerListAsync(rows)); } [HttpGet("api/rooms/v1/favoritedby/me")] [HttpGet("api/rooms/v2/favoritedby/me")] [HttpGet("api/rooms/v3/favoritedby/me")] [HttpGet("roomserver/rooms/favoritedby/me")] [HttpGet("rooms/favoritedby/me")] [Authorize] public async Task FavoritedByMe() { var pid = CurrentPlayerId; if (pid is null) return Ok(Array.Empty()); var rows = await rooms.BookmarkedByAsync(pid.Value); return Ok(await BuildRoomServerListAsync(rows)); } [HttpGet("api/rooms/v1/cheeredby/me")] [HttpGet("api/rooms/v2/cheeredby/me")] [HttpGet("api/rooms/v3/cheeredby/me")] [HttpGet("roomserver/rooms/cheeredby/me")] [HttpGet("rooms/cheeredby/me")] [Authorize] public async Task CheeredByMe() { var pid = CurrentPlayerId; if (pid is null) return Ok(Array.Empty()); var ids = await db.Cheers.AsNoTracking() .Where(c => c.FromPlayerId == pid.Value && c.TargetRoomId != 0) .OrderByDescending(c => c.CheeredAt) .Select(c => c.TargetRoomId) .Distinct() .Take(100) .ToListAsync(); var rows = await db.Rooms.AsNoTracking() .Where(r => ids.Contains(r.Id)) .OrderByDescending(r => r.HotScore) .ToListAsync(); return Ok(await BuildRoomServerListAsync(rows)); } [HttpGet("api/rooms/v2/mybookmarks")] [HttpGet("api/rooms/v2/mybookmarkedrooms")] [Authorize] public async Task MyBookmarks() { var pid = CurrentPlayerId; if (pid is null) return Ok(Array.Empty()); return Ok((await rooms.BookmarkedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList()); } [HttpGet("api/rooms/v2/mymoderated")] [HttpGet("api/rooms/v1/modrooms")] [HttpGet("api/rooms/v2/mysubscribed")] [HttpGet("api/rooms/v2/myrecent")] public IActionResult MyOtherTabs() => Ok(Array.Empty()); /// Per-(player, room) persistence blob, base64. Lives in the /// generic key/value table — the same /// "no schema change needed" trick MatchPlayerController uses for its /// matchmaking prefs (MatchPlayerController.cs:213-217). The Value column /// is declared MaxLength(1024) but SQLite stores it as unconstrained /// TEXT and EF Core does not run DataAnnotations validation on SaveChanges, /// so oversized blobs round-trip intact. private static string PlayerRoomDataKey(long roomId) => $"roomplayerdata:{roomId}"; /// Fallback blob handed to a player who has never saved anything /// in this room. CAE= is a one-field protobuf the client parses as /// "empty, version 1"; a truly empty string makes the 2023 reader throw. private const string DefaultPlayerRoomData = "CAE="; /// GET rooms/{roomId}/playerdata/me — the per-room player /// blob (CircuitsV2 "player persistence"). Client contract is /// Task<ILKMFMCOPPO> whose reader requires the key /// Data (OPNPNPMKLGI.txt ISIL 034/060); the request is issued from /// NLDBPDCNNCF (NLDBPDCNNCF_NestedType_IGENGEIMGMJ.txt:169), so it arrives /// with the roomserver/ prefix — the bare-only registration 404'd. /// This used to return a hardcoded CAE= for everyone; it now returns /// whatever last stored. [HttpGet("player_room_data/{roomId:long}")] [HttpGet("rooms/{roomId:long}/playerdata/me")] [HttpGet("roomserver/rooms/{roomId:long}/playerdata/me")] [Authorize] public async Task PlayerDataForMe(long roomId) { var pid = CurrentPlayerId; if (pid is null) return Unauthorized(); var room = await db.Rooms.AsNoTracking() .Where(r => r.Id == roomId) .Select(r => new { r.Id, r.VisitCount, r.CheerCount, r.FavoriteCount }) .FirstOrDefaultAsync(); var favorited = await db.RoomBookmarks .AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid.Value); var cheered = await db.Cheers .AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid.Value); var visit = await db.RoomVisits.AsNoTracking() .Where(v => v.RoomId == roomId && v.PlayerId == pid.Value) .Select(v => new { v.VisitCount, v.FirstVisitAt, v.LastVisitAt }) .FirstOrDefaultAsync(); var key = PlayerRoomDataKey(roomId); var stored = await db.PlayerSettings.AsNoTracking() .Where(s => s.PlayerId == pid.Value && s.Key == key) .Select(s => s.Value) .FirstOrDefaultAsync(); return Ok(new { Data = string.IsNullOrEmpty(stored) ? DefaultPlayerRoomData : stored, RoomId = room?.Id ?? roomId, PlayerId = pid.Value, Favorite = favorited, Favorited = favorited, IsFavorite = favorited, Cheer = cheered, Cheered = cheered, IsCheered = cheered, IsBookmarked = favorited, IsCheering = cheered, VisitCount = visit?.VisitCount ?? 0, FirstVisitedAt = visit?.FirstVisitAt, LastVisitedAt = visit?.LastVisitAt, RoomVisitCount = room?.VisitCount ?? 0, RoomCheerCount = room?.CheerCount ?? 0, RoomFavoriteCount = room?.FavoriteCount ?? 0, }); } public sealed class PlayerRoomDataRequest { public string? Data { get; set; } } /// PUT rooms/{roomId}/playerdata/me — persist the caller's /// per-room blob. Client method NLDBPDCNNCF.LIHFBBCDAGO(long, string) /// ("save player room data") moves verb ordinal 3 = PUT into r8 and posts a /// single form field data /// (NLDBPDCNNCF.txt ISIL 045/061/065 around :2566-2586; form key at /// NLDBPDCNNCF_NestedType_ANPKFGHOOAJ.txt ISIL 015). Its return type is the /// non-generic Task, so no response body is parsed. /// /// Only the GET was registered before, so every save 405'd and per-room /// player state never persisted. [HttpPut("rooms/{roomId:long}/playerdata/me")] [HttpPost("rooms/{roomId:long}/playerdata/me")] [HttpPut("roomserver/rooms/{roomId:long}/playerdata/me")] [HttpPost("roomserver/rooms/{roomId:long}/playerdata/me")] [HttpPut("player_room_data/{roomId:long}")] [Authorize] public async Task SavePlayerDataForMe( long roomId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] PlayerRoomDataRequest req) { var pid = CurrentPlayerId; if (pid is null) return Unauthorized(); var data = req.Data ?? string.Empty; var key = PlayerRoomDataKey(roomId); var row = await db.PlayerSettings .FirstOrDefaultAsync(s => s.PlayerId == pid.Value && s.Key == key); if (row is null) { db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = pid.Value, Key = key, Value = data, }); } else { row.Value = data; } await db.SaveChangesAsync(); // The client ignores the body (non-generic Task); the echo is for // curl/admin debugging only. return Ok(new { Result = 0, RoomId = roomId, PlayerId = pid.Value, Data = data }); } /// GET rooms/requiring/{restriction} — the restricted-rooms /// list. The path segment is mode.ToString().ToLower() /// (Matchmaking.txt ISIL 067-072 around :21263, concat onto the literal /// "rooms/requiring/" at :21264). /// /// Element type is String, not Int64 — verified, do not "fix" this /// to room ids. The issuing method /// Matchmaking.POIMPHMONON(IOCOJFJEDCM) (Matchmaking.txt:21030) /// resolves its promise as FGLDKEJLAKB<List<String>> /// (typeof load at Matchmaking.txt:21327, continuation delegate /// Action<List<String>> at :21315) and hands the payload to /// <RequestRoomRestrictions>b__2(List<String> rooms) /// (Matchmaking_NestedType_PMNEOAMKLJL.txt:14), which forwards it to /// Matchmaking.IINEGHJJBAN(IEnumerable<String>, IOCOJFJEDCM) /// (:20758) where the strings become the KEYS of the restriction map /// (List<IOCOJFJEDCM> per key, ISIL 069-079 at :20963-20974). /// So a bare array of room NAMES is the correct wire shape. Failures log /// "Failed to get restricted rooms list: " (Matchmaking.txt:21306). [HttpGet("rooms/requiring/{restriction}")] [HttpGet("roomserver/rooms/requiring/{restriction}")] [Authorize] public async Task RoomsRequiring(string restriction) { var key = (restriction ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant(); if (key.Length == 0) return Ok(new List()); IQueryable query = db.Rooms.AsNoTracking() .Where(r => !r.HiddenFromBrowse && r.State == 0); query = key switch { "developer" or "studio" or "rrstudio" => query.Where(r => r.IsStudioRoom || r.IsRoomLinkedToRecRoomStudio || EF.Functions.Like(r.TagsCsv, "%developer%") || EF.Functions.Like(r.TagsCsv, "%studio%")), "rrplus" or "recroomplus" => query.Where(r => EF.Functions.Like(r.TagsCsv, "%rrplus%") || EF.Functions.Like(r.TagsCsv, "%recroomplus%")), _ => query.Where(r => EF.Functions.Like(r.TagsCsv, $"%{key}%")), }; var rows = await query .OrderByDescending(r => r.HotScore) .ThenBy(r => r.Name) .Select(r => r.Name) .Take(100) .ToListAsync(); return Ok(rows); } [HttpGet("rooms/curated_playlists")] [HttpGet("roomserver/rooms/curated_playlists")] [Authorize] public async Task CuratedPlaylistsCompat() { var curated = await playlists.CuratedAsync(); return Ok(curated.Select(p => p.Id).ToList()); } [HttpGet("clubhousesearch/mostactivenow")] [HttpGet("roomserver/clubhousesearch/mostactivenow")] [Authorize] public async Task MostActiveClubhouses() { var rows = await db.Clubs.AsNoTracking() .Where(c => c.State == 0 && c.ClubhouseRoomId != null) .GroupJoin( db.ClubMemberships.AsNoTracking(), c => c.Id, m => m.ClubId, (club, memberships) => new { Club = club, MemberCount = memberships.Count(), }) .Join( db.Rooms.AsNoTracking(), c => c.Club.ClubhouseRoomId!.Value, r => r.Id, (c, room) => new { c.Club, c.MemberCount, Room = room, }) .OrderByDescending(x => x.MemberCount) .ThenByDescending(x => x.Room.HotScore) .Take(50) .ToListAsync(); return Ok(rows.Select(x => new { ClubId = x.Club.Id, x.Club.Name, x.Club.Description, MainImageName = x.Club.ImageName, ImageName = x.Club.ImageName, State = x.Club.State, CreatorAccountId = x.Club.CreatorPlayerId, Category = x.Club.Category, Visibility = x.Club.Visibility, Joinability = x.Club.Joinability, x.Club.AllowJuniors, MemberCount = x.MemberCount, x.Club.IsRRO, x.Club.ClubhouseRoomId, x.Club.ClubType, Room = RoomService.ToWireRoom(x.Room), RoomId = x.Room.Id, RoomName = x.Room.Name, }).ToList()); } // ── Bookmark mutations ─────────────────────────────────────────────── [HttpPost("api/rooms/v1/bookmark/{roomId:long}")] [HttpPost("api/rooms/v2/bookmark/{roomId:long}")] [Authorize] public async Task Bookmark(long roomId) { var pid = CurrentPlayerId; if (pid is null) return Unauthorized(); await rooms.BookmarkAsync(pid.Value, roomId); return Ok(new { success = true, error = "" }); } [HttpDelete("api/rooms/v1/bookmark/{roomId:long}")] [HttpDelete("api/rooms/v2/bookmark/{roomId:long}")] [Authorize] public async Task Unbookmark(long roomId) { var pid = CurrentPlayerId; if (pid is null) return Unauthorized(); await rooms.UnbookmarkAsync(pid.Value, roomId); return Ok(new { success = true, error = "" }); } private IQueryable PublicRoomQuery() => db.Rooms.AsNoTracking().Where(r => r.State == 0 && r.Accessibility == 1 && !r.IsDormRoom && !r.HiddenFromBrowse); private async Task> BuildRoomServerListAsync(IReadOnlyCollection roomRows) { if (roomRows.Count == 0) return new List(); var ids = roomRows.Select(r => r.Id).Distinct().ToList(); var sceneRows = await db.RoomScenes.AsNoTracking() .Where(s => ids.Contains(s.RoomId)) .OrderBy(s => s.OrderIndex) .ToListAsync(); var scenesByRoom = sceneRows .GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); var roleRows = await db.RoomRoles.AsNoTracking() .Where(r => ids.Contains(r.RoomId)) .ToListAsync(); var rolesByRoom = roleRows .GroupBy(r => r.RoomId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); return roomRows .Select(r => BuildRoomServerDetails( r, scenesByRoom.GetValueOrDefault(r.Id), roles: rolesByRoom.GetValueOrDefault(r.Id))) .ToList(); } // ── Helpers ────────────────────────────────────────────────────────── /// GET api/rooms/v1/agRoomIds — list of every /// seeded AG / RR-Original room id. Used by the watch to bias the /// room browser toward official rooms first. // Bare array of Int64 — the 2023 issuing method GBONEKLGFFK returns // Task> (NLDBPDCNNCF.txt:1886, literal at :1929). The // roomserver/ twin is what that client actually requests (same prefix // rule as rooms/base, docs/recroom-2023-room-save.md:83-93). [HttpGet("api/rooms/v1/agRoomIds")] [HttpGet("rooms/rro_ids")] [HttpGet("roomserver/rooms/rro_ids")] public async Task AgRoomIds() => Ok(await rooms.AgRoomIdsAsync()); /// GET api/rooms/v1/featuredRoomGroup + /// v3/featured — the "Featured" carousel on the watch's /// rooms tab. Returns the top-12 AG rooms by HotScore. [HttpGet("api/rooms/v1/featuredRoomGroup")] [HttpGet("api/rooms/v3/featured")] public async Task Featured() => Ok(new { Name = "Featured", FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12), }); /// GET featuredrooms/current — the watch's pointer /// to the current "Featured" room group. Caller: /// EJDCNGBEICB.CFKDADKHAGB. Wire shape is NMPFCIJPODA /// with required keys FeaturedRoomGroupId (long) and /// Name (string) — a pointer record, NOT the room list /// itself (the watch fetches the list separately via /// api/rooms/v3/featured). Returning the /// shape here throws KeyNotFoundException on /// the watch's group-id lookup. // Same roomserver/ prefix requirement as rooms/base. [HttpGet("featuredrooms/current")] [HttpGet("roomserver/featuredrooms/current")] public async Task FeaturedRoomsCurrent() { var ids = await rooms.FeaturedAgRoomIdsAsync(12); var featuredRooms = await db.Rooms .Where(r => ids.Contains(r.Id)) .ToListAsync(); var byId = featuredRooms.ToDictionary(r => r.Id); // The featured-tile reader looks for RoomName, which ToWireRoom does not // emit — every tile's caption came back null. Re-project the wire object // into a dictionary so the extra key sits alongside the real ones rather // than nesting them. var wireRooms = ids .Where(byId.ContainsKey) .Select(id => { var room = byId[id]; var wire = RoomService.ToWireRoom(room); var map = new Dictionary(); foreach (var prop in wire.GetType().GetProperties()) map[prop.Name] = prop.GetValue(wire); map["RoomName"] = room.Name; return (object)map; }) .ToList(); return Ok(new { FeaturedRoomGroupId = 1L, Name = "Featured", Rooms = wireRooms, }); } /// POST api/rooms/v1/bookmark — toggle bookmark /// state for a room. Body: RoomId + optional Bookmark /// flag (default = add). [HttpPost("api/rooms/v1/bookmark")] [Authorize] public async Task BookmarkV1( [FromForm(Name = "RoomId")] long? roomId, [FromForm(Name = "Bookmark")] bool? bookmark) { if (roomId is not long rid) return Ok(new { success = true }); var pid = this.RequireCurrentPlayerId(); if (bookmark == false) await rooms.UnbookmarkAsync(pid, rid); else await rooms.BookmarkAsync(pid, rid); return Ok(new { success = true, error = "" }); } /// POST rooms/{roomId}/name — rename a room. /// Watch path: EJDCNGBEICB.DNOGGKKHMHI(long roomId, string newName) /// (analytics tag "change room name"). Body shape: form-urlencoded /// Name=... or a bare string in the body. Returns the /// updated Room DTO (PPENFJMFPNE : KLCOGEIGEBJ). /// /// Caller must be the room creator OR a co-owner. Other /// validations: name length 1..40, no leading/trailing whitespace, /// uniqueness against other Rooms.Name. Failures return /// the watch's CreateModifyRoomStatus-style payload with /// a non-zero Result so the rename toast surfaces a real reason. /// /// We accept both POST and PUT because the watch's ISIL /// doesn't expose the explicit HTTPMethod literal at this call /// site — covering both is cheap and means we can't get bitten /// by the wrong one. /// /// 2023 client: NLDBPDCNNCF.LHNDKOJNKBE /// ("change room name", NLDBPDCNNCF.txt:5069) moves verb ordinal 3 = PUT, /// sends the lowercase form key name /// (NLDBPDCNNCF_NestedType_LMADOAEHOPP.txt ISIL 015) and returns /// Task<FGCPNAACHIK> — the FULL room-details object, not a /// status wrapper. It also carries the roomserver/ prefix like every /// other room mutation from that client /// (docs/recroom-2023-room-save.md:83-93), so the bare-only routes 404'd. /// The old signature mixed [FromForm] with a [FromBody] /// parameter, which under [ApiController] makes the form PUT 415 /// before the handler runs — hence the shared FormOrJsonModelBinder. public sealed class RenameBody { public string? Name { get; set; } } [HttpPost("rooms/{roomId:long}/name")] [HttpPut("rooms/{roomId:long}/name")] [HttpPost("roomserver/rooms/{roomId:long}/name")] [HttpPut("roomserver/rooms/{roomId:long}/name")] [Authorize] public async Task RenameRoom( long roomId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body) { var newName = body?.Name; if (string.IsNullOrWhiteSpace(newName)) return BadRequest(new { Result = 1, error = "empty_name" }); newName = newName.Trim(); if (newName.Length is < 1 or > 40) return BadRequest(new { Result = 1, error = "invalid_length", length = newName.Length }); var me = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(new { Result = 4, error = "room_not_found" }); if (room.CreatorPlayerId != me) { // RoomRoles co-owner check — same authorisation as the // existing modify endpoint, lets co-owners rename too. var coOwner = await db.RoomRoles.AnyAsync(rr => rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1)); if (!coOwner) return Forbid(); } // Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118). var collision = await db.Rooms.AnyAsync(r => r.Id != roomId && r.Name == newName); if (collision) return Conflict(new { Result = 5, error = "name_taken" }); room.Name = newName; room.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); logger.LogInformation("[room-rename] room={Room} by={By} → '{Name}'", roomId, me, newName); // Full room details, not the old minimal {Result,RoomId,Name,…} object: // the 2023 issuing method returns Task (NLDBPDCNNCF.txt:5069) // and that reader needs Room/Scenes/CoOwners/Stats — the trimmed payload // made a successful rename surface as a parse failure in the client. return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } /// POST rooms/{roomId}/subrooms/{subRoomId}/name — /// rename a scene/sub-room within a multi-scene room. Watch path: /// EJDCNGBEICB.PAENPJCDLNJ(long roomId, long subRoomId, string newName). /// Updates the matching after /// verifying ownership and intra-room uniqueness. /// /// 2023 client: NLDBPDCNNCF.EJOHFBMFEGB /// ("change subroom name", NLDBPDCNNCF.txt:8694) is verb 3 = PUT with form /// key name and returns Task<FGCPNAACHIK>. Same /// [FromForm]+[FromBody] 415 hazard as the room rename above, so it binds /// through the shared FormOrJsonModelBinder and echoes full details. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")] [Authorize] public async Task RenameSubroom( long roomId, long subRoomId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body) { var newName = body?.Name; if (string.IsNullOrWhiteSpace(newName)) return BadRequest(new { Result = 1, error = "empty_name" }); newName = newName.Trim(); if (newName.Length is < 1 or > 40) return BadRequest(new { Result = 1, error = "invalid_length", length = newName.Length }); var me = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(new { Result = 4, error = "room_not_found" }); if (room.CreatorPlayerId != me) { var coOwner = await db.RoomRoles.AnyAsync(rr => rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1)); if (!coOwner) return Forbid(); } var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId); if (scene is null) return NotFound(new { Result = 4, error = "subroom_not_found", subRoomId }); // Uniqueness within this room — same scene name twice would // make /goto/room/X/Y ambiguous against the RoomScenes index // on (RoomId, Name) at DorkNetDbContext.cs:153. var collision = await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.OrderIndex != subRoomId && s.Name == newName); if (collision) return Conflict(new { Result = 5, error = "subroom_name_taken" }); scene.Name = newName; scene.DataModifiedAt = DateTime.UtcNow; await db.SaveChangesAsync(); logger.LogInformation("[subroom-rename] room={Room} sub={Sub} by={By} → '{Name}'", roomId, subRoomId, me, newName); return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } /// POST api/rooms/v1/cheer — cheer a room (body /// form). Idempotent per (caller, room) — duplicate taps don't /// inflate CheerCount. [HttpPost("api/rooms/v1/cheer")] [Authorize] public async Task CheerRoom( [FromForm(Name = "RoomId")] long? roomId, [FromForm(Name = "Type")] int? type) { if (roomId is not long rid) return Ok(new { success = true }); var me = this.RequireCurrentPlayerId(); var (newCount, already) = await rooms.CheerRoomAsync(me, rid, type ?? 0); return Ok(new { RoomId = rid, CheerCount = newCount, AlreadyCheered = already }); } /// POST api/rooms/v1/uncheer — remove a previously /// applied room cheer. [HttpPost("api/rooms/v1/uncheer")] [HttpPost("api/rooms/v1/uncheer/{roomId:long}")] [Authorize] public async Task UncheerRoom( long? roomId, [FromForm(Name = "RoomId")] long? roomIdForm, [FromForm(Name = "Type")] int? type) { var rid = roomId ?? roomIdForm ?? 0; if (rid == 0) return Ok(new { success = true }); var me = this.RequireCurrentPlayerId(); var already = await rooms.UncheerRoomAsync(me, rid, type ?? 0); return Ok(new { RoomId = rid, AlreadyUncheered = already }); } /// GET api/rooms/v1/cheers/{roomId} — did the /// caller cheer this room, and what's the current count? [HttpGet("api/rooms/v1/cheers/{roomId:long}")] [Authorize] public async Task CheckCheered(long roomId) { var me = this.CurrentPlayerId(); var (count, iCheered) = await rooms.GetCheerStateAsync(me ?? 0, roomId); return Ok(new { RoomId = roomId, CheerCount = count, ICheered = iCheered }); } // ── Bare-path interactionby/me toggles ───────────────────────────── // // Verbs come straight from the 2023 client's four one-liner methods on // NLDBPDCNNCF (each a non-generic Task, so no response body is parsed): // "cheer room" :13467 r8 = 3 (PUT) // "uncheer room" :13550 r8 = 4 (DELETE) // "favorite room" :13633 r8 = 3 (PUT) // "unfavorite room" :13716 r8 = 4 (DELETE) // All four are issued from NLDBPDCNNCF, so they arrive with the // roomserver/ prefix (docs/recroom-2023-room-save.md:83-93) — without the // twins the cheer/favorite buttons 404'd and failed silently. [HttpPost("rooms/{roomId:long}/interactionby/me/cheer")] [HttpPut("rooms/{roomId:long}/interactionby/me/cheer")] [HttpPost("roomserver/rooms/{roomId:long}/interactionby/me/cheer")] [HttpPut("roomserver/rooms/{roomId:long}/interactionby/me/cheer")] [Authorize] public async Task BareCheer(long roomId) { var me = this.RequireCurrentPlayerId(); await rooms.CheerRoomAsync(me, roomId, 0); return Ok(new { success = true }); } [HttpDelete("rooms/{roomId:long}/interactionby/me/cheer")] [HttpDelete("roomserver/rooms/{roomId:long}/interactionby/me/cheer")] [Authorize] public async Task BareUncheer(long roomId) { var me = this.RequireCurrentPlayerId(); await rooms.UncheerRoomAsync(me, roomId, 0); return Ok(new { success = true }); } [HttpPost("rooms/{roomId:long}/interactionby/me/favorite")] [HttpPut("rooms/{roomId:long}/interactionby/me/favorite")] [HttpPost("roomserver/rooms/{roomId:long}/interactionby/me/favorite")] [HttpPut("roomserver/rooms/{roomId:long}/interactionby/me/favorite")] [Authorize] public async Task BareFavorite(long roomId) { var me = this.RequireCurrentPlayerId(); await rooms.BookmarkAsync(me, roomId); return Ok(new { success = true }); } [HttpDelete("rooms/{roomId:long}/interactionby/me/favorite")] [HttpDelete("roomserver/rooms/{roomId:long}/interactionby/me/favorite")] [Authorize] public async Task BareUnfavorite(long roomId) { var me = this.RequireCurrentPlayerId(); await rooms.UnbookmarkAsync(me, roomId); return Ok(new { success = true }); } // ── Rooms-and-Playlists union (2020.12 watch) ──────────────────────── /// /// GET /roomserver/roomsandplaylists/hot — the 2020.12 watch's /// merged "Trending" / "New" tab. Two URL variants exist with /// DIFFERENT wire shapes: /// /// 1. /roomserver/roomsandplaylists/hot (this endpoint) /// Watch caller: EJDCNGBEICB.EBBHGHEGBKG /// Signature: IPromise<HJKAOMOICJG> /// Wire shape: { TotalResults, Results: [union] } /// (verified at EJDCNGBEICB.txt:2571,2645 + /// HJKAOMOICJG.txt:39-50) /// 2. /roomserver/hot_roomsandplaylists/{tags-joined} /// Watch caller: OJMCBOKJFOF.EBBHGHEGBKG /// Signature: IPromise<List<MKAMHOIHOJK>> /// Wire shape: bare list, see /// (verified at OJMCBOKJFOF.txt:4607,4743) /// /// Returning a bare list here throws /// InvalidCastException: Unable to cast object of type 'List`1' /// to type 'Dictionary`2' on the watch — the wrapped /// HJKAOMOICJG deserializer reads TotalResults as a top-level /// dict key, and a JSON array body fails that cast. /// /// Inside Results: union entries discriminated by lowercase /// roomId key per the MKAMHOIHOJK factory at /// MKAMHOIHOJK.txt:621 — see /// + . /// /// Why rooms-only despite the "and playlists" name: the /// Hot tab of the watch's Play page mounts this endpoint and used to /// interleave playlist tiles next to room tiles, but design intent is /// that playlists appear only under the Moods/Playlists section (their /// own endpoints — /api/curatedroomplaylists + /// /roomserver/playlists/{id}). The watch's union deserializer /// happily accepts a Results list that's 100% rooms, so we keep the /// wrapper shape but drop the playlist half. /// [HttpGet("roomserver/roomsandplaylists/hot")] [HttpGet("roomsandplaylists/hot")] public async Task RoomsAndPlaylistsHot([FromQuery] string? tag) { var (entries, total) = await BuildHotRoomsAsync(tag); return Ok(new Dictionary(StringComparer.Ordinal) { ["TotalResults"] = (long)total, ["Results"] = entries, }); } /// /// GET /roomserver/hot_roomsandplaylists/{tagsJoined} — /// path-style sibling of with a /// BARE List<MKAMHOIHOJK> shape (no TotalResults /// wrapper). Tags are comma-joined into the URL path per /// OJMCBOKJFOF.txt:4739,4743 (String.Join(",", tags) /// followed by String.Concat("hot_roomsandplaylists/", joined)). /// Empty path is legal — equivalent to "no tag filter". /// [HttpGet("roomserver/hot_roomsandplaylists/{tagsJoined?}")] [HttpGet("hot_roomsandplaylists/{tagsJoined?}")] [HttpGet("rooms/hot_roomsandplaylists/{tagsJoined?}")] public async Task HotRoomsAndPlaylistsPath(string? tagsJoined) { // Watch joins multiple tags with "," — use the first one as a // substring filter since RoomService.HotAsync takes a single // tag arg. Empty / null = unfiltered. The 2023 client embeds // &skip/&take in the same segment (see ParsePagedPathSegment). // // Returns rooms-only for the same reason as the wrapped // RoomsAndPlaylistsHot endpoint above: playlists belong in // Moods, not in the Hot tab. See that endpoint's doc-comment. var (tagsValue, _, _) = ParsePagedPathSegment(tagsJoined, defaultTake: 100); var firstTag = string.IsNullOrWhiteSpace(tagsValue) ? null : tagsValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .FirstOrDefault(); var (entries, _) = await BuildHotRoomsAsync(firstTag); return Ok(entries); } /// GET hot_rooms/{tagsJoined?} — rooms-only variant /// of . Caller: /// OJMCBOKJFOF.DLPLPKCNLNA. Same MKAMHOIHOJK union /// entry shape as hot_roomsandplaylists, just skips playlist /// rows so the watch's rooms-only browse tab doesn't render /// playlist tiles that have nowhere to go. [HttpGet("hot_rooms/{tagsJoined?}")] [HttpGet("rooms/hot_rooms/{tagsJoined?}")] [HttpGet("roomserver/hot_rooms/{tagsJoined?}")] public async Task HotRoomsOnlyPath(string? tagsJoined) { var (tagsValue, _, _) = ParsePagedPathSegment(tagsJoined, defaultTake: 12); var firstTag = string.IsNullOrWhiteSpace(tagsValue) ? null : tagsValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .FirstOrDefault(); var roomList = await rooms.HotAsync(firstTag, take: 12); var roomIds = roomList.Select(r => r.Id).ToList(); var sceneRows = roomIds.Count == 0 ? new List() : await db.RoomScenes.Where(s => roomIds.Contains(s.RoomId)) .OrderBy(s => s.OrderIndex).ToListAsync(); var scenesByRoom = sceneRows.GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); return Ok(roomList .Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id))) .ToList()); } /// /// Shared body for the two hot variants — pulls hot rooms only, /// fans out sub-room rows in one query to avoid N+1, and returns /// the union-shape entries the watch's factory expects (each entry /// happens to be a room dict; the factory accepts that). Returns /// (entries, totalCount) so the wrapped variant can fill in /// TotalResults. /// /// Take limit raised to 100 (was 12) — the Hot tab used to /// surface only the top dozen rooms which is far less than the /// catalog the watch's browse UI can actually render. The watch /// paginates client-side if it needs to chunk further. /// private async Task<(List> Entries, int Total)> BuildHotRoomsAsync(string? tag) { var roomList = await rooms.HotAsync(tag, take: 100); var roomIds = roomList.Select(r => r.Id).ToList(); var sceneRows = roomIds.Count == 0 ? new List() : await db.RoomScenes .Where(s => roomIds.Contains(s.RoomId)) .OrderBy(s => s.OrderIndex) .ToListAsync(); var scenesByRoom = sceneRows .GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); var entries = roomList .Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id))) .ToList(); return (entries, entries.Count); } /// /// GET /api/curatedroomplaylists — the editorial "Curated" /// row on the watch's playlists tab. /// /// Wire shape: List<Int64> — JUST the playlist IDs, not /// the full playlist objects. Verified against /// FPNPBJBCMKB.EPPPMHMMGME (signature /// IPromise<List<long>>) and the call site at /// BrowseRoomsScreen.txt:2806 which constructs an /// Action<List<Int64>> callback. Returning rich /// objects here throws /// InvalidCastException: Unable to cast object of type 'List`1' /// to type 'Dictionary`2' on the watch ("Failed to get curated /// room playlists: Malformed Response"). The watch fetches each /// playlist's details separately via the playlist-detail endpoints. /// [HttpGet("api/curatedroomplaylists")] public async Task CuratedRoomPlaylists() { var curated = await playlists.CuratedAsync(); return Ok(curated.Select(p => p.Id).ToList()); } /// /// GET /roomserver/playlists/{id} — single-playlist details /// with members. The watch deserializes this as BMFAGMFKODA /// (verified at BMFAGMFKODA.txt:182-191) which: /// 1. Calls KMKPEOGJDFK.PPGFHEDFBEA to read the base /// Playlist union fields (PlaylistId + all /// MKAMHOIHOJK base keys). /// 2. Reads required key Rooms — List<KLCOGEIGEBJ> /// (the playlist's member rooms, full Room dicts). /// 3. Reads required key Tags — List<DPHPFLGAICI> /// (tag rows with Type + Tag). /// Returning just the KMKPEOGJDFK shape (no Rooms/Tags) throws /// KeyNotFoundException: Failed to find key 'Rooms' when /// deserializing object of type KLCOGEIGEBJ on the watch. /// [HttpGet("roomserver/playlists/{id:long}")] [HttpGet("playlists/{id:long}")] public async Task PlaylistDetails(long id) { var p = await db.Playlists.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); if (p is null) return NotFound(); // Pull member rooms in playlist order, then fan out room + // scene rows so each entry can render as a full KLCOGEIGEBJ. var roomIds = await db.PlaylistRooms .Where(pr => pr.PlaylistId == id) .OrderBy(pr => pr.OrderIndex) .Select(pr => pr.RoomId) .ToListAsync(); List memberRooms; Dictionary> scenesByRoom; if (roomIds.Count == 0) { memberRooms = new List(); scenesByRoom = new Dictionary>(); } else { var roomRows = await db.Rooms .Where(r => roomIds.Contains(r.Id)) .ToListAsync(); // Preserve playlist order — db.Rooms.Where doesn't guarantee // it matches the OrderBy on PlaylistRooms.OrderIndex. var roomById = roomRows.ToDictionary(r => r.Id); memberRooms = roomIds .Select(rid => roomById.TryGetValue(rid, out var r) ? r : null) .Where(r => r is not null) .Select(r => r!) .ToList(); var sceneRows = await db.RoomScenes .Where(s => roomIds.Contains(s.RoomId)) .OrderBy(s => s.OrderIndex) .ToListAsync(); scenesByRoom = sceneRows .GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); } var roomsWire = memberRooms .Select(r => BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id))) .ToList(); // Tags wire shape: [{Type:int, Tag:string}] — same shape used in // BuildRoomServerDetails for Room.Tags. Split TagsCsv if present. var tagsWire = string.IsNullOrWhiteSpace(p.TagsCsv) ? new List() : p.TagsCsv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(t => (object)new { Type = RoomTagTypeForWire(t), Tag = t, IsPrimaryGenre = false, }) .ToList(); // Start from BuildPlaylistUnionEntry for base + PlaylistId, then // splice in the Rooms + Tags lists. Dictionary lets us add the // extra keys after the base build. var result = BuildPlaylistUnionEntry(p); result["Rooms"] = roomsWire; result["Tags"] = tagsWire; return Ok(result); } /// /// GET /roomserver/roomsandplaylists/search?query=X — search /// surface used by the watch's friend-mention / room-search affordance. /// Verified at EJDCNGBEICB.txt:2486,2560 — signature /// IPromise<HJKAOMOICJG>. Per /// at HJKAOMOICJG.txt:39-50 /// the response object reads TotalResults (Int64) and /// Results (List of MKAMHOIHOJK union entries — same /// Room/Playlist factory used by /hot). /// /// Distinct from /roomserver/search_roomsandplaylists/{query} /// (path-param shape from OJMCBOKJFOF.txt:4567); both URLs /// reach the same data but differ in shape — the path-param sibling /// returns a bare List<MKAMHOIHOJK> without the /// TotalResults wrapper. /// [HttpGet("roomserver/roomsandplaylists/search")] [HttpGet("roomsandplaylists/search")] public async Task RoomsAndPlaylistsSearch([FromQuery] string? query) { var (merged, total) = await BuildSearchUnionAsync(query); return Ok(new Dictionary(StringComparer.Ordinal) { ["TotalResults"] = (long)total, ["Results"] = merged, }); } /// /// GET /roomserver/search_roomsandplaylists/{query} — free-text /// search across both rooms and playlists, BARE-LIST variant. Per /// OJMCBOKJFOF.txt:4439,4567 the signature is /// IPromise<List<MKAMHOIHOJK>> — no TotalResults /// wrapper. URL is search_roomsandplaylists/{query} /// (query in path, not query-string). /// [HttpGet("roomserver/search_roomsandplaylists/{query}")] [HttpGet("search_roomsandplaylists/{query}")] [HttpGet("rooms/search_roomsandplaylists/{query}")] public async Task SearchRoomsAndPlaylists(string query) { var (text, _, _) = ParsePagedPathSegment(query, defaultTake: 50); var (merged, _) = await BuildSearchUnionAsync(text); return Ok(merged); } /// GET search_rooms/{query} — rooms-only search /// variant. Same MKAMHOIHOJK union entry shape as /// search_roomsandplaylists/{query}, just skips playlist /// rows. /// /// The 2023 client's play-menu search (IBEOONPEELF.SearchRooms, /// URL builder GGDPFFDEDKN) calls the rooms/-prefixed /// variant and embeds paging in the SAME path segment: /// rooms/search_rooms/{query}&skip={skip}&take={take}. /// Missing the prefixed route = "Error refreshing rooms from source /// ServerSearch" in the play menu. [HttpGet("search_rooms/{query}")] [HttpGet("rooms/search_rooms/{query}")] [HttpGet("roomserver/search_rooms/{query}")] public async Task SearchRoomsOnly(string query) { var (text, skip, take) = ParsePagedPathSegment(query, defaultTake: 50); if (string.IsNullOrWhiteSpace(text)) return Ok(Array.Empty()); var roomList = (await rooms.SearchAsync(text, take: skip + take)) .Skip(skip).Take(take).ToList(); var roomIds = roomList.Select(r => r.Id).ToList(); var sceneRows = roomIds.Count == 0 ? new List() : await db.RoomScenes.Where(s => roomIds.Contains(s.RoomId)) .OrderBy(s => s.OrderIndex).ToListAsync(); var scenesByRoom = sceneRows.GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); return Ok(roomList .Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id))) .ToList()); } /// Split a 2023-style browse-source path segment into its /// value and paging. The 2023 URL builder (GGDPFFDEDKN) /// formats search_rooms/{0}&skip={1}&take={2} / /// hot_rooms/{0}&skip={1}&take={2} — the paging pairs /// land inside the final path segment, not the query string, so /// route params capture "pizza&skip=0&take=20" verbatim. /// 2020 URLs have no '&' and pass through unchanged. private static (string Value, int Skip, int Take) ParsePagedPathSegment(string? raw, int defaultTake) { if (string.IsNullOrEmpty(raw)) return (string.Empty, 0, defaultTake); var parts = raw.Split('&'); var skip = 0; var take = defaultTake; foreach (var pair in parts.Skip(1)) { var kv = pair.Split('=', 2); if (kv.Length != 2 || !int.TryParse(kv[1], out var n)) continue; if (kv[0].Equals("skip", StringComparison.OrdinalIgnoreCase)) skip = n; else if (kv[0].Equals("take", StringComparison.OrdinalIgnoreCase)) take = n; } return (parts[0], Math.Max(0, skip), Math.Clamp(take, 1, 100)); } /// /// Shared body for the two search variants. Empty/whitespace query /// returns an empty result without round-tripping the DB. /// private async Task<(List> Entries, int Total)> BuildSearchUnionAsync(string? query) { if (string.IsNullOrWhiteSpace(query)) return (new List>(), 0); // Sequential — shared scoped DbContext isn't thread-safe. var roomList = await rooms.SearchAsync(query); var playlistList = await playlists.SearchAsync(query); var roomIds = roomList.Select(r => r.Id).ToList(); var sceneRows = await db.RoomScenes .Where(s => roomIds.Contains(s.RoomId)) .OrderBy(s => s.OrderIndex) .ToListAsync(); var scenesByRoom = sceneRows .GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); var merged = InterleaveUnion( roomList.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id))), playlistList.Select(BuildPlaylistUnionEntry)); return (merged, roomList.Count + playlistList.Count); } /// Round-robin merge of two enumerables so the watch's /// hot row alternates between rooms and playlists rather than /// stacking one type on top. Once one side runs out the remainder /// of the other side spills out in order. private static List> InterleaveUnion( IEnumerable> a, IEnumerable> b) { var listA = a.ToList(); var listB = b.ToList(); var output = new List>(listA.Count + listB.Count); int i = 0, j = 0; while (i < listA.Count || j < listB.Count) { if (i < listA.Count) output.Add(listA[i++]); if (j < listB.Count) output.Add(listB[j++]); } return output; } /// /// Build a union-shape Room entry — same payload as /// but with the additional /// lowercase roomId key that the 2020.12 /// MKAMHOIHOJK factory uses as its Room-vs-Playlist /// discriminator (factory at MKAMHOIHOJK.txt:621 calls /// Dictionary.ContainsKey("roomId"); the actual Room /// deserializer at KLCOGEIGEBJ.txt:208-234 reads /// PascalCase RoomId). Must include BOTH keys. /// private static IDictionary BuildRoomUnionEntry( RoomEntity room, IReadOnlyList? sceneRows) { // Start from the canonical RoomServerDetails shape so the union // entry carries every field the 2020.12 client expects on a // Room subclass (SubRooms, Roles, Stats, etc.). Then add the // lowercase 'roomId' discriminator key on top. var details = BuildRoomServerDetails(room, sceneRows); // Convert anonymous record into a mutable dictionary so we can // add the lowercase 'roomId' key that anonymous types can't // express (you can't have two properties differing only in // letter casing on a single anonymous type). var dict = new Dictionary(StringComparer.Ordinal); foreach (var prop in details.GetType().GetProperties()) { var value = prop.GetValue(details); if (value is not null) dict[prop.Name] = value; } // Discriminator key — lowercase 'roomId'. Without this the // MKAMHOIHOJK factory falls through to the Playlist branch // and PlaylistId.PPGFHEDFBEA throws on the missing key. dict["roomId"] = room.Id; return dict; } /// /// Build a union-shape Playlist entry — the /// branch of MKAMHOIHOJK. Keys read at /// MKAMHOIHOJK.txt:516-612 (PascalCase base keys) and /// KMKPEOGJDFK.txt:68 (PascalCase PlaylistId). /// Omits the lowercase roomId key so the factory's /// Room-vs-Playlist branch falls through to the Playlist /// constructor. /// public static IDictionary BuildPlaylistUnionEntry(PlaylistEntity p) { return new Dictionary(StringComparer.Ordinal) { ["PlaylistId"] = p.Id, ["Name"] = p.Name, ["Description"] = p.Description, ["ImageName"] = p.ImageName, ["CreatorAccountId"] = p.CreatorPlayerId, ["State"] = 0, ["Accessibility"] = 1, ["WarningMask"] = 0, ["CustomWarning"] = string.Empty, ["SupportsLevelVoting"] = false, // Playlists are watchable from every device the // member rooms support; advertise the broadest set so // the watch never filters them out by capability. ["IsRRO"] = p.IsCurated, ["SupportsScreens"] = true, ["SupportsWalkVR"] = true, ["SupportsTeleportVR"] = true, ["SupportsVRLow"] = true, ["SupportsQuest2"] = true, ["SupportsMobile"] = true, ["SupportsJuniors"] = true, ["CreatedAt"] = (p.CreatedAt == default ? DateTime.UtcNow : p.CreatedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"), ["Stats"] = new { CheerCount = p.CheerCount, FavoriteCount = p.FavoriteCount, VisitorCount = p.VisitorCount, VisitCount = p.VisitCount, }, }; } // ── Subroom mutations (bare path) ────────────────────────────────── public sealed class CreateSubRoomRequest { public string? Name { get; set; } public string? RoomSceneLocationId { get; set; } public int? MaxPlayers { get; set; } public bool? IsSandbox { get; set; } } /// POST rooms/{id}/subrooms — append a new scene. /// Owner-gated. OrderIndex = max+1, Name unique within room. /// /// 2023 client: NLDBPDCNNCF.CMHBCOIBDME ("add new /// subroom", NLDBPDCNNCF.txt:9396) is verb 2 = POST with a single form key /// name (NLDBPDCNNCF_NestedType_AIPHLHBILDN.txt ISIL 015) and returns /// Task<FGCPNAACHIK>. It needs the roomserver/ twin, a binder /// that accepts the form body ([FromBody] 415s it), and full room /// details in the response instead of the scene row. [HttpPost("rooms/{roomId:long}/subrooms")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms")] [Authorize] public async Task AddSubRoom( long roomId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req) { var pid = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId != pid) return Forbid(); var name = (req.Name ?? string.Empty).Trim(); if (name.Length == 0) name = "NewScene"; var collision = await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == name); if (collision) return Conflict(new { error = "name_taken" }); var nextOrder = (await db.RoomScenes .Where(s => s.RoomId == roomId) .Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1; var scene = new RoomSceneEntity { RoomId = roomId, OrderIndex = nextOrder, Name = name, RoomSceneLocationId = req.RoomSceneLocationId ?? "a75f7547-79eb-47c6-8986-6767abcb4f92", MaxPlayers = req.MaxPlayers ?? 8, IsSandbox = req.IsSandbox ?? false, }; db.RoomScenes.Add(scene); await db.SaveChangesAsync(); return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } /// DELETE rooms/{id}/subrooms/{sub}. The 2023 caller /// NLDBPDCNNCF.HGNGMIMJFJL ("delete subroom", NLDBPDCNNCF.txt:9827) /// moves verb ordinal 4 = DELETE and returns /// Task<FGCPNAACHIK>; the only registration here used to be the /// bare one (the roomserver/ twin at the top of the file is the GET), so /// deleting a subroom 404'd, and the {Result:0} body was not the /// details object the client re-parses. [HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")] [HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")] [Authorize] public async Task DeleteSubRoom(long roomId, long subRoomId) { var pid = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId != pid) return Forbid(); var scene = await db.RoomScenes .FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId); if (scene is null) return NotFound(); if (scene.OrderIndex == 0) return BadRequest(new { error = "cannot_delete_entry_scene" }); db.RoomScenes.Remove(scene); await db.SaveChangesAsync(); return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } // Subroom mutation DTOs. Property names mirror the LOWERCASE form keys the // 2023 client actually sends (FormOrJsonModelBinder matches case-insensitively): // maxPlayers NLDBPDCNNCF_NestedType_BLNOGFGHIIF.txt ISIL 020 // accessibility NLDBPDCNNCF_NestedType_GNCLJBNJEKP.txt ISIL 020 // name/accessibility/maxPlayers ..._JEEACJHABAF.txt ISIL 021/035/048 // newRoomId NLDBPDCNNCF_NestedType_COIOGADEBKJ.txt ISIL 020 // The older {Value:…} DTOs matched no client key at all, and being bound // [FromBody] they 415'd the form body before the handler ran. public sealed class SubRoomMaxPlayersRequest { public int? MaxPlayers { get; set; } } // Accessibility is typed string, not int, on purpose — the two client call // sites disagree about how they stringify it. The standalone // "change subroom accessibility" closure boxes the value as // typeof(JHAOFLCJNAL) (GNCLJBNJEKP.txt ISIL 015), and the form writer's // object overload falls through to a virtual ToString() for anything that // isn't String/DateTime (BNDIAONDFFF.txt ISIL 107-110) — i.e. an enum // MEMBER NAME goes on the wire. The bulk "modify" closure boxes the very // same value as typeof(System.Int32) (JEEACJHABAF.txt ISIL 031) and sends a // number. ParseSubRoomAccessibility below accepts both spellings. public sealed class SubRoomAccessibilityRequest { public string? Accessibility { get; set; } } public sealed class SubRoomModifyRequest { public string? Name { get; set; } public string? Accessibility { get; set; } public int? MaxPlayers { get; set; } public bool? IsSandbox { get; set; } public bool? CanMatchmakeInto { get; set; } public string? RoomSceneLocationId { get; set; } } public sealed class SubRoomMoveRequest { /// Target ROOM for the scene — the 2023 "move subroom" flow /// re-parents a scene into another room. Nullable<Int64> on the /// wire (NLDBPDCNNCF.MGGALMIAMBG, NLDBPDCNNCF.txt:9659). public long? NewRoomId { get; set; } /// Legacy in-room reorder used by the admin/import tooling. /// Not something the 2023 client sends. public int? NewIndex { get; set; } } /// One row of the per-subroom role-permission matrix the 2023 /// client PUTs to subrooms/{sub}/permissions. Keys verified in the /// writer HDOJMEBCAGP.txt ISIL 050/069/085/101/117. public sealed class SubRoomPermissionEntry { // Field order + CLR types come from the BNLPBMJJOMM constructor // (BNLPBMJJOMM.txt:126): (String Permission, OMMBGJMJJPN Role, // Boolean Override, FEOJFGECKGH Type, String Value). The two enum // members serialize as numbers (the role writer boxes them // System.Int32 — BFKNABDHMGG.txt ISIL 015), hence int? here. public string? Permission { get; set; } public int? Role { get; set; } public bool? Override { get; set; } public int? Type { get; set; } public string? Value { get; set; } } public sealed class PublishSubRoomSaveRequest { public long? SubRoomDataSaveId { get; set; } public long? SaveId { get; set; } public long? StagedSubRoomDataSaveId { get; set; } public string? DataBlob { get; set; } public string? DataBlobName { get; set; } public string? Filename { get; set; } } // The "Max Player Count in This Subroom" slider (SubroomModel. // ChangeSubRoomMaxPlayers) POSTs here on the roomserver host. Like its // siblings (accessibility/permissions/modify) the client prefixes the // path with "roomserver/", so the bare-only routes 404'd and the slider's // change silently never persisted — i.e. the limit couldn't be changed. // The value flows back to the host via the subroom details MaxPlayers, // which sets the Photon room cap. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [Authorize] public Task SubRoomMaxPlayers( long roomId, long subRoomId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMaxPlayersRequest req) => MutateScene(roomId, subRoomId, s => { if (req.MaxPlayers is int v) s.MaxPlayers = Math.Max(1, v); }); /// PUT rooms/{id}/subrooms/{sub}/accessibility. Client: /// NLDBPDCNNCF.OAINKEAFPGE(long, long, JHAOFLCJNAL) /// (NLDBPDCNNCF.txt:9032, verb 3), form key accessibility, response /// Task<FGCPNAACHIK>. We only model the matchmaking bit /// (), so Private collapses to /// "not a matchmaking target" and everything else to "is one". [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [Authorize] public Task SubRoomAccessibility( long roomId, long subRoomId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomAccessibilityRequest req) => MutateScene(roomId, subRoomId, s => { if (ParseSubRoomAccessibility(req.Accessibility) is int a) s.CanMatchmakeInto = a != AccessibilityPrivate; }); /// PUT rooms/{id}/subrooms/{sub}/permissions — the /// role-permission matrix. Client: /// NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList<BNLPBMJJOMM>) /// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT /// form-encode: the closure calls BNDIAONDFFF.FJLLPHFOOJJ(String) /// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string /// (BNDIAONDFFF.txt:275, mov dword ptr [rbx+38h],4) — i.e. a raw /// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON /// deserialize into the List). /// /// Scope note — deliberately not persisted. DorkNet has no /// per-subroom permission storage: has no /// column for it and there is no permissions table. Adding one means an /// entity + DbContext + migration, which is outside this file. Until then /// the matrix is accepted, logged, and the room details echoed back so the /// client's promise resolves instead of 415-ing; the values do not survive /// a round-trip. The old handler silently bound an unrelated DTO whose keys /// the client never sends, which was strictly worse. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [Authorize] public async Task SubRoomPermissions( long roomId, long subRoomId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List req) { var pid = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId != pid) return Forbid(); var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId); if (scene is null) return NotFound(); await db.SaveChangesAsync(); logger.LogWarning( "[subroom-permissions] room={Room} sub={Sub} by={By} entries={Count} — accepted but NOT persisted (no permission storage yet)", roomId, subRoomId, pid, req?.Count ?? 0); return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } // JHAOFLCJNAL member values. The numeric leg is verified (the "modify" // closure boxes the same value as System.Int32 — JEEACJHABAF.txt ISIL 031); // the NAMES are inferred from the room-accessibility enum in the nearest // dump with real names (RecRoom-2023.06.21 dump.cs:1293997-1294004 // MKDFFJGGKDL { Private = 0, Public = 1, Unlisted = 2 }), which is the enum // Enum.ToString() would spell out on the standalone accessibility call. private const int AccessibilityPrivate = 0; private static int? ParseSubRoomAccessibility(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return null; var value = raw.Trim(); if (int.TryParse(value, out var numeric)) return numeric; if (value.StartsWith("priv", StringComparison.OrdinalIgnoreCase)) return 0; if (value.StartsWith("pub", StringComparison.OrdinalIgnoreCase)) return 1; if (value.StartsWith("unlist", StringComparison.OrdinalIgnoreCase)) return 2; return null; } [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")] [Authorize] public async Task PublishSubRoomSave(long roomId, long subRoomId) { var pid = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId != pid) return Forbid(); var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId); if (scene is null) return NotFound(); var request = await ReadPublishSubRoomSaveRequestAsync(); var saveId = request.SubRoomDataSaveId ?? request.SaveId ?? request.StagedSubRoomDataSaveId; var dataBlob = request.DataBlobName ?? request.DataBlob ?? request.Filename; if (!string.IsNullOrWhiteSpace(dataBlob)) { var blobExists = await db.RoomDataBlobs.AnyAsync(b => b.RoomId == roomId && b.BlobName == dataBlob); if (!blobExists) return NotFound(new { error = "blob_not_found", dataBlob }); scene.DataBlobName = dataBlob; if (scene.OrderIndex == 0) room.CurrentDataBlobName = dataBlob; } if (saveId is > 0) scene.StudioSubRoomDataSaveId = saveId.Value; scene.DataModifiedAt = DateTime.UtcNow; room.UpdatedAt = scene.DataModifiedAt; await db.SaveChangesAsync(); // "restore subroom save" returns Task (NLDBPDCNNCF.txt:10476), // so the scene row alone made a successful restore read as a failure. return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } private async Task ReadPublishSubRoomSaveRequestAsync() { var request = new PublishSubRoomSaveRequest(); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); if (long.TryParse(FirstFormValue(form, "subRoomDataSaveId", "SubRoomDataSaveId", "saveId", "SaveId", "stagedSubRoomDataSaveId", "StagedSubRoomDataSaveId"), out var saveId)) request.SubRoomDataSaveId = saveId; request.DataBlobName = FirstFormValue(form, "dataBlobName", "DataBlobName", "dataBlob", "DataBlob", "filename", "Filename"); return request; } if (long.TryParse(Request.Query["subRoomDataSaveId"].FirstOrDefault() ?? Request.Query["SubRoomDataSaveId"].FirstOrDefault() ?? Request.Query["saveId"].FirstOrDefault() ?? Request.Query["SaveId"].FirstOrDefault() ?? Request.Query["stagedSubRoomDataSaveId"].FirstOrDefault() ?? Request.Query["StagedSubRoomDataSaveId"].FirstOrDefault(), out var querySaveId)) request.SubRoomDataSaveId = querySaveId; request.DataBlobName = Request.Query["dataBlobName"].FirstOrDefault() ?? Request.Query["DataBlobName"].FirstOrDefault() ?? Request.Query["dataBlob"].FirstOrDefault() ?? Request.Query["DataBlob"].FirstOrDefault() ?? Request.Query["filename"].FirstOrDefault() ?? Request.Query["Filename"].FirstOrDefault(); if ((Request.ContentLength ?? 0) <= 0) return request; try { var body = await JsonSerializer.DeserializeAsync( Request.Body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); return body ?? request; } catch (JsonException) { return request; } } /// POST rooms/{id}/subrooms/{sub}/clone — duplicate /// a scene (DataBlobName + location pointer). New scene goes at the /// end with " Copy" suffix; per-room name uniqueness preserved. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")] [Authorize] public async Task CloneSubRoom(long roomId, long subRoomId) { var pid = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId != pid) return Forbid(); var src = await db.RoomScenes .FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId); if (src is null) return NotFound(); var baseName = $"{src.Name} Copy"; var newName = baseName; int suffix = 1; while (await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == newName)) { suffix += 1; newName = $"{baseName} {suffix}"; } var nextOrder = (await db.RoomScenes.Where(s => s.RoomId == roomId) .Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1; var clone = new RoomSceneEntity { RoomId = roomId, OrderIndex = nextOrder, Name = newName, RoomSceneLocationId = src.RoomSceneLocationId, DataBlobName = src.DataBlobName, MaxPlayers = src.MaxPlayers, IsSandbox = src.IsSandbox, CanMatchmakeInto = src.CanMatchmakeInto, }; db.RoomScenes.Add(clone); await db.SaveChangesAsync(); // Task ("clone subroom", NLDBPDCNNCF.txt:9554) — the scene // row is not what the client re-parses. return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } // Subroom settings save POSTs here on the roomserver host ("failed to // modify room" = the bare-only routes 404'd). Same roomserver/ prefix as // the sibling subroom mutations. // // Body is form-urlencoded name/accessibility/maxPlayers ("modify room", // NLDBPDCNNCF.txt:10846 verb 3; keys at JEEACJHABAF.txt ISIL 021/035/048). // [FromBody] rejected that with 415 before the handler ran, and the DTO had // no 'accessibility' member at all, so the bulk subroom-settings save was a // guaranteed failure. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")] [Authorize] public Task ModifySubRoom( long roomId, long subRoomId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomModifyRequest req) => MutateScene(roomId, subRoomId, s => { if (!string.IsNullOrWhiteSpace(req.Name)) s.Name = req.Name.Trim(); if (req.MaxPlayers is int mp) s.MaxPlayers = Math.Max(1, mp); if (req.IsSandbox is bool sb) s.IsSandbox = sb; if (req.CanMatchmakeInto is bool cmi) s.CanMatchmakeInto = cmi; if (ParseSubRoomAccessibility(req.Accessibility) is int a) s.CanMatchmakeInto = a != AccessibilityPrivate; if (req.RoomSceneLocationId is not null) s.RoomSceneLocationId = req.RoomSceneLocationId; }); /// POST rooms/{id}/subrooms/{sub}/move. /// /// The 2023 semantics are "move to another ROOM", not "reorder". /// NLDBPDCNNCF.MGGALMIAMBG(long roomId, long subRoomId, /// Nullable<Int64> newRoomId) ("move subroom", /// NLDBPDCNNCF.txt:9659, verb 2 = POST) sends the single form key /// newRoomId (COIOGADEBKJ.txt ISIL 013-020, boxed as /// Nullable<Int64>) and returns /// Task<FGCPNAACHIK>. The old handler bound /// [FromBody] {NewIndex} — a key the client never sends, on a body /// binding that 415s form posts — and reordered scenes in place instead. /// /// The caller must own BOTH rooms. The scene is re-parented to the end /// of the target room's scene list and the source room's remaining scenes /// are re-packed so OrderIndex stays a dense 0..n-1 sequence — the /// wire RoomSceneId and matchmaking SubRoomId are that index. /// Scene 0 is the room's entry scene and cannot be moved out. /// newIndex (no client sends it) keeps the old in-room reorder for /// the import tooling. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")] [Authorize] public async Task MoveSubRoom( long roomId, long subRoomId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req) { var pid = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId != pid) return Forbid(); var scenes = await db.RoomScenes .Where(s => s.RoomId == roomId).OrderBy(s => s.OrderIndex).ToListAsync(); var moving = scenes.FirstOrDefault(s => s.OrderIndex == subRoomId); if (moving is null) return NotFound(); // `newRoomId` is Nullable on the wire and the form writer // stringifies it with ToString(), which yields "" for the empty case — // so an absent target arrives as a blank field, not a missing one. // Treat "no target and no index" as a no-op; the old code fell through // to `NewIndex ?? 0` and silently reordered the scene to the front. if (req.NewRoomId is not long targetRoomId || targetRoomId == roomId) { if (req.NewIndex is not int requestedIndex) return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); var newIndex = Math.Clamp(requestedIndex, 0, scenes.Count - 1); scenes.Remove(moving); scenes.Insert(newIndex, moving); await RepackSceneOrderAsync(scenes); return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } // Cross-room move — the path the 2023 client actually takes. var target = await db.Rooms.FirstOrDefaultAsync(r => r.Id == targetRoomId); if (target is null) return NotFound(new { error = "target_room_not_found", targetRoomId }); if (target.CreatorPlayerId != pid) return Forbid(); if (moving.OrderIndex == 0) return BadRequest(new { error = "cannot_move_entry_scene" }); // Names are unique per room (RoomScenes index on (RoomId, Name)). var movedName = moving.Name; var suffix = 1; while (await db.RoomScenes.AnyAsync(s => s.RoomId == targetRoomId && s.Name == movedName)) { suffix += 1; movedName = $"{moving.Name} {suffix}"; } var targetOrder = (await db.RoomScenes.Where(s => s.RoomId == targetRoomId) .Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1; moving.RoomId = targetRoomId; moving.OrderIndex = targetOrder; moving.Name = movedName; moving.DataModifiedAt = DateTime.UtcNow; room.UpdatedAt = moving.DataModifiedAt; target.UpdatedAt = moving.DataModifiedAt; await db.SaveChangesAsync(); // Re-pack the source room so its remaining scenes keep dense indices // (same sentinel dance as the in-room reorder above). await RepackSceneOrderAsync(scenes.Where(s => s.Id != moving.Id).ToList()); logger.LogInformation("[subroom-move] scene={Scene} room={From}→{To} by={By}", moving.Id, roomId, targetRoomId, pid); return Ok(await BuildRoomServerDetailsWithRolesAsync(targetRoomId)); } /// Rewrite to 0..n-1 in /// list order. The unique (RoomId, OrderIndex) index would clash if rows /// were written in place, so flush a sentinel offset first, then settle. private async Task RepackSceneOrderAsync(List scenes) { if (scenes.Count == 0) return; const int sentinelOffset = 100_000; for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i + sentinelOffset; await db.SaveChangesAsync(); for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i; await db.SaveChangesAsync(); } /// POST roomserver/rooms/{id}/subrooms/{sub}/data — /// commit the blob returned by storage/upload as the current /// scene save. The 2020 watch posts this as form-urlencoded /// (filename, inventionUsage, savedByAccountId), /// while some tooling uses JSON. The 2023 client posts JSON with /// nested blob refs — {UnityAssetId, RoomData:{Filename,Hash, /// OwnershipProof}, SubRoomData:{Filename,…}} — where /// SubRoomData.Filename is the scene save (FileType=1 upload) /// and RoomData.Filename the FileType=6 room-metadata blob. /// All variants delegate to the existing saveData path so room, /// scene, dorm-state, presence, and subscription fanout stay /// consistent. [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")] [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")] [Authorize] public async Task SubRoomData(long roomId, long subRoomId) { var body = await ReadSaveRoomSceneRequestAsync(); body.RoomSceneId = subRoomId; return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true); } private async Task ReadSaveRoomSceneRequestAsync() { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); var request = new SaveRoomSceneRequest { Filename = FirstFormValue(form, "filename", "Filename"), RoomDataFilename = FirstFormValue(form, "roomDataFilename", "RoomDataFilename", "roomDataFileName", "RoomDataFileName"), InventionUsageBase64 = FirstFormValue(form, "inventionUsage", "InventionUsage", "inventionUsageBase64", "InventionUsageBase64"), }; if (long.TryParse(FirstFormValue(form, "requestPlayerId", "RequestPlayerId", "savedByAccountId", "SavedByAccountId"), out var requestPlayerId)) request.RequestPlayerId = requestPlayerId; if (int.TryParse(FirstFormValue(form, "saveRequestPlayerId", "SaveRequestPlayerId"), out var saveRequestPlayerId)) request.SaveRequestPlayerId = saveRequestPlayerId; return request; } if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) { try { return await JsonSerializer.DeserializeAsync( Request.Body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new SaveRoomSceneRequest(); } catch (JsonException) { return new SaveRoomSceneRequest(); } } return new SaveRoomSceneRequest(); } private static string? FirstFormValue(IFormCollection form, params string[] keys) { foreach (var key in keys) { var value = form[key].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(value)) return value; } return null; } /// Shared owner-gate + mutate + save for the subroom setting /// endpoints. Responds with the FULL room details: every 2023 caller that /// lands here (maxplayers / accessibility / modify) is typed /// Task<FGCPNAACHIK>, so the old scene-row payload made a /// successful mutation surface as a client-side parse failure. private async Task MutateScene(long roomId, long subRoomId, Action mutator) { var pid = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId != pid) return Forbid(); var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId); if (scene is null) return NotFound(); mutator(scene); scene.DataModifiedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } private async Task GetOrCreateSceneForMutationAsync(RoomEntity room, long subRoomId) { var scene = await db.RoomScenes .FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId); if (scene is not null || subRoomId != 0) return scene; scene = new RoomSceneEntity { RoomId = room.Id, OrderIndex = 0, Name = "Home", RoomSceneLocationId = room.LocationReplicationId, DataBlobName = CurrentOrSyntheticDataBlobName(room), MaxPlayers = room.MaxCapacity, IsSandbox = false, CanMatchmakeInto = true, DataModifiedAt = DateTime.UtcNow, }; db.RoomScenes.Add(scene); return scene; } // SceneWire (the old {Result,RoomId,RoomSceneId,…} scene-row payload) is // gone: every subroom mutation the 2023 client issues is typed // Task and re-parses the full room details, so all of these // handlers now go through BuildRoomServerDetailsWithRolesAsync. // ── Bare-path roles ──────────────────────────────────────────────── /// GET rooms/{id}/roles — account role DTOs consumed by /// 2023's EFHPLDPNGIM deserializer /// (NLDBPDCNNCF.LAJDCNIMBAB → Task<List<EFHPLDPNGIM>>, /// NLDBPDCNNCF.txt:2111, verb 0 = GET). Like every other NLDBPDCNNCF call it /// arrives with the roomserver/ prefix, so the bare-only registration /// 404'd the permissions screen's list fetch. [HttpGet("rooms/{roomId:long}/roles")] [HttpGet("roomserver/rooms/{roomId:long}/roles")] public async Task RolesList(long roomId) { var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync(); var list = new List(rows.Count + 1) { BuildRoomAccountRoleWire(room.CreatorPlayerId, 30), }; list.AddRange(rows.Select(BuildRoomRoleGrantWire)); return Ok(list); } private static string CurrentOrSyntheticDataBlobName(RoomEntity room) { if (room.IsDormRoom) return RoomService.ResolveWireRoomDataBlobName(room.Id, room.CurrentDataBlobName); if (RoomService.IsBakedOriginalRoom(room) && string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return string.Empty; return !string.IsNullOrWhiteSpace(room.CurrentDataBlobName) ? room.CurrentDataBlobName : RoomService.SyntheticDefaultRoomDataBlobName(room.Id); } private static string SceneOrSyntheticDataBlobName(RoomEntity room, string? sceneBlobName, string fallbackBlobName) { if (!string.IsNullOrWhiteSpace(sceneBlobName) && !(room.IsDormRoom && RoomService.IsLegacySyntheticDefaultRoomDataBlobName(room.Id, sceneBlobName))) return sceneBlobName; 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); } /// GET rooms/{id}/roles/{playerId} — one role row /// (NLDBPDCNNCF.PCFOIPELKHN → Task<EFHPLDPNGIM>, /// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list /// above. [HttpGet("rooms/{roomId:long}/roles/{playerId:long}")] [HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")] public async Task RoleForPlayer(long roomId, long playerId) { var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId == playerId) return Ok(BuildRoomAccountRoleWire(playerId, 30)); var row = await db.RoomRoles .FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId); if (row is null) return NotFound(); return Ok(BuildRoomRoleGrantWire(row)); } public sealed class GrantRoleRequest { public int? Role { get; set; } } /// PUT rooms/{id}/roles/{playerId} — owner grants /// a role to a player. Auto-accepted (no invite step). /// NLDBPDCNNCF.LMIEIJMCEAB ("change room account role", /// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key role carrying a /// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full /// FGCPNAACHIK details — which this already does. Only the /// roomserver/ twin was missing. [HttpPut("rooms/{roomId:long}/roles/{playerId:long}")] [HttpPost("rooms/{roomId:long}/roles/{playerId:long}")] [HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPost("roomserver/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")] [Authorize] public async Task GrantRole(long roomId, long playerId) { var me = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (!await CanManageRoomRolesAsync(room, me)) return Forbid(); var role = await ReadClientRoomRoleAsync(); if (role is null) return BadRequest(new { error = "missing_role" }); if (role.Value == 0) return await ClearRoomAccountRoleAsync(room, playerId); if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole)) return BadRequest(new { error = "invalid_role", role }); var existing = await db.RoomRoles .FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole); if (existing is null) { await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole); db.RoomRoles.Add(new RoomRoleEntity { RoomId = roomId, PlayerId = playerId, Role = roomRole, Accepted = true, GrantedByPlayerId = me, }); } else { existing.Accepted = true; } await db.SaveChangesAsync(); return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } /// POST rooms/{id}/roles/{playerId}/invite — same /// as grant but Accepted=false; the target's accept-invite flow /// flips the flag (separate endpoint, not yet exposed). For now /// invited rows surface in RoomDetails.InvitedCoOwners etc. /// NLDBPDCNNCF.KIJDCLINOIC ("invite account to role", /// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix. [HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPost("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")] [Authorize] public async Task InviteRole(long roomId, long playerId) { var me = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (!await CanManageRoomRolesAsync(room, me)) return Forbid(); var role = await ReadClientRoomRoleAsync(); if (role is null) return BadRequest(new { error = "missing_role" }); if (role.Value == 0) return await ClearRoomAccountRoleAsync(room, playerId); if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole)) return BadRequest(new { error = "invalid_role", role }); var existing = await db.RoomRoles .FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole); if (existing is null) { await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole); db.RoomRoles.Add(new RoomRoleEntity { RoomId = roomId, PlayerId = playerId, Role = roomRole, Accepted = false, GrantedByPlayerId = me, }); } else { existing.Accepted = false; } await db.SaveChangesAsync(); return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } private async Task ClearRoomAccountRoleAsync(RoomEntity room, long playerId) { if (room.CreatorPlayerId == playerId) return BadRequest(new { error = "cannot_change_creator_role" }); var rows = await db.RoomRoles .Where(r => r.RoomId == room.Id && r.PlayerId == playerId) .ToListAsync(); if (rows.Count > 0) db.RoomRoles.RemoveRange(rows); await db.SaveChangesAsync(); return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id)); } private async Task CanManageRoomRolesAsync(RoomEntity room, long playerId) { if (room.CreatorPlayerId == playerId) return true; return await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Accepted && r.Role == 0); } private async Task ReadClientRoomRoleAsync() { int parsed; if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed; if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed; } if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed; if (int.TryParse(Request.Query["Role"].FirstOrDefault(), out parsed)) return parsed; if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) { try { var body = await JsonSerializer.DeserializeAsync( Request.Body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (body?.Role is int bodyRole) return bodyRole; } catch (JsonException) { return null; } } return null; } private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole) { var rows = await db.RoomRoles .Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role != keepRole) .ToListAsync(); if (rows.Count > 0) db.RoomRoles.RemoveRange(rows); } private static bool TryMapClientRoleToRoomRole(int clientRole, out int roomRole) { roomRole = clientRole switch { 30 => 0, // CoOwner 20 => 1, // Moderator 10 => 2, // Host _ => -1, }; return roomRole >= 0; } private static object BuildRoomRoleGrantWire(RoomRoleEntity role) => BuildRoomAccountRoleWire( role.PlayerId, ToClientRoomRole(role.Role), role.Accepted, role.GrantedByPlayerId); private static object BuildRoomAccountRoleWire( long accountId, int role, bool accepted = true, long? lastChangedByAccountId = null) => new { AccountId = ToClientAccountId(accountId), Role = accepted ? role : 0, LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId), InvitedRole = accepted ? 0 : role, }; private static int ToClientRoomRole(int roomRole) => roomRole switch { 0 => 30, // CoOwner 1 => 20, // Moderator 2 => 10, // Host _ => 0, }; private static int ToClientAccountId(long accountId) => accountId > int.MaxValue ? int.MaxValue : accountId < int.MinValue ? int.MinValue : (int)accountId; private static int? ToClientAccountId(long? accountId) => accountId.HasValue ? ToClientAccountId(accountId.Value) : null; private async Task BuildRoomServerDetailsWithRolesAsync(long roomId) { var room = await db.Rooms.FirstAsync(r => r.Id == roomId); var scenes = await db.RoomScenes .Where(s => s.RoomId == roomId) .OrderBy(s => s.OrderIndex) .ToListAsync(); var roles = await db.RoomRoles .Where(r => r.RoomId == roomId) .ToListAsync(); return BuildRoomServerDetails(room, scenes, roles: roles); } /// /// Synthesise a placeholder Room when the client asks for one we don't /// have in the DB. Keeps the deserializer happy — every required key /// is populated. Safe defaults: public, AG, default DormRoom location. /// /// Special case: roomId=1 is the canonical DormRoom id used by /// GoToController when the client visits its own dorm. Make sure the /// /v4/details/1 follow-up shows "DormRoom" rather than "Room_1". /// private static RoomEntity Synthetic(string name, long? id = null) { var isDorm = (id == 1) || name.Equals("DormRoom", StringComparison.OrdinalIgnoreCase); var displayName = isDorm ? "DormRoom" : name; return new RoomEntity { Id = id ?? Math.Abs((long)name.GetHashCode()), Name = displayName, Description = isDorm ? "Your private dorm — yours alone, decorated however you like." : $"{displayName} room", CreatorPlayerId = 1, State = 0, Accessibility = isDorm ? 0 /* Private */ : 1 /* Public */, IsAGRoom = !isDorm, IsDormRoom = isDorm, LocationReplicationId = "76d98498-60a1-430c-ab76-b54a29b7a163", }; } }