The 2023 client sends rooms/{id}/clone through the same RecNet rooms
helper as "save room" and "restore subroom save", which reads the
{success, value, error} envelope. The clone handler answered with the
bare room details, so the client found no success flag and rejected the
reply with an empty error, even though the room had been created.
Clone now returns the envelope with the full room details in value, and
refusals (unknown source, cloning not allowed) are a 200 with
success=false and a message instead of a bodyless 404/403.
12 KiB
2023 client room-save flow
Wire contract for the 2023.03.21 client's room save, reverse-engineered
from OGPDOMCNIFM.UploadRoomDataBlobAndSyncReload
(RecRoom.RoomLoading.Runtime) and verified against dev server traffic
on 2026-07-07. Diagnosed while fixing "Failed to save room" /
"An error occurred" save failures.
Sequence
-
Upload room metadata —
POST https://storage.<apex>/upload, multipart (File=file.bin,FileType=6, optionalReferences). FileType 6 = RoomMetadata is new in 2023 (2020 enum stopped at 5=Invention). Small payload (~4 bytes observed for a dorm). Response:{"filename": "roommeta_p<player>_<guid>.bin"}. -
Upload scene save — same endpoint,
FileType=1(RoomSave), payload is the scene blob. Response:{"filename": "<blob>.dat"}(e.g.dorm_p<id>_v<N>.dat). The upload response DTO is{Filename, Hash, OwnershipProof}— the client accepts bothFilename/filenamecasings;HashandOwnershipProofmay be null. -
Commit —
POST https://rooms.<apex>/rooms/{roomId}/subrooms/{subRoomId}/datawith JSON:{ "UnityAssetId": null, "RoomData": { "Filename": "roommeta_….bin", "Hash": null, "OwnershipProof": null }, "SubRoomData": { "Filename": "dorm_p…_vN.dat", "Hash": null, "OwnershipProof": null } }SubRoomData.Filenameis the scene save and must become the room'sCurrentDataBlobName(and the dorm-state row for dorms).RoomData.Filenameis the FileType=6 metadata blob.Response contract (deserializer
NEOPBOMGIOG, mapperKMBHAKAHNGH): legacy{success, value, error}envelope wherevalueMUST contain BOTH keys, non-null:{ "success": true, "value": { "Room": { …same DTO as GET rooms/{id} (BuildRoomServerDetails)… }, "SubRoomDataSave": { "SubRoomDataSaveId": 0, "SubRoomId": 0, "UnityAssetId": "", "DataBlob": "<blob>.dat", "SavedByAccountId": 1, "SavedOnPlatform": 7, "SavedOnDeviceClass": 2, "CreatedAt": "…", … } }, "error": "" }The client parses each field then runs a Dispose walk (
NEOPBOMGIOG.FKDDCNLJOLF, reached from the SaveRoom commit continuation) that dereferences the mapped children — a missing nested object or scalar leaves a null it NREs on, and the save persists server-side but the watch shows "Failed to save room". So theRoomDTO must carry every key theFGCPNAACHIKmapper (GLEGPFFPDBE) reads, including the ones the include-maskedGET rooms/{id}path gets away without: DataBlob, DataBlobHash, MaxPlayers, ToxmodEnabled, and a non-null RankingContext object, plus SubRooms/Roles/Tags/Stats.SubRoomDataSavekeys per theJKIFFPPAJNKmapper (PELEHJLMKJO): SubRoomDataSaveId, SubRoomId, UnityAssetId, DataBlob, DataBlobHash, SavedByAccountId, SavedOnPlatform, SavedOnDeviceClass, Description, CreatedAt.
Server handling: StorageController.Upload (FileType 6 →
UploadGenericAsync("roommeta"), CDN-servable) and
RoomsController.SubRoomData → ReadSaveRoomSceneRequestAsync (nested
2023 JSON via BlobRefDto) → SaveDataCore.
Failure modes seen
| Symptom (client) | Cause |
|---|---|
NDIKGKCFOCG: An error occurred at UploadRoomDataBlobAndSyncReload, upload frame (KPLOPGMJOLE) in stack |
storage.<apex> host not routed at the edge (Traefik 404 page not found) — the save dies before reaching DorkNet. Probe https://storage.<apex>/healthz. |
NDIKGKCFOCG: Failed to save room, no upload frame in stack |
Commit POST rejected — historically 400 missing_room_data_filename because the server parsed only the flat 2020 body and missed SubRoomData.Filename. |
NDIKGKCFOCG: Failed to save room preceded by a NullReferenceException at NEOPBOMGIOG.FKDDCNLJOLF |
Commit returned 200 but value wasn't {Room, SubRoomDataSave} — the save actually persisted; only the response parse failed. |
Room clone (2023)
The in-room "copy room" flow (RoomModel.CopyRoom →
RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN) POSTs
roomserver/rooms/{id}/clone with an x-www-form-urlencoded name=…
body — the same roomserver/ prefix as every other room mutation from
that client. A bare-only rooms/{id}/clone route 404s with an empty
body, which the client's promise layer surfaces as a message-less
Failed to copy room: Exception of type 'CEMNLBKJABA' was thrown.
The response is the legacy {success, value, error} envelope, the same
one "save room" and "restore subroom save" use: the clone goes through the
same RecNet rooms helper (NLDBPDCNNCF.BDBHCEGKGDC, which takes an op
name). value is the FULL room-details object (FGCPNAACHIK, same shape
as GET roomserver/rooms/{id}). A bare room-details body has no success,
so the client rejects it with an empty error and shows the same
message-less Failed to copy room, even though the room was created.
Refusals (unknown source, cloning not allowed) are HTTP 200 with
success: false and the message in error.
Blob semantics on clone (RoomsModerationController.BareClone):
- First-party template source (IsAGRoom + system-owned, e.g. the
RecCenter seed): the clone keeps the copied scenes but gets a
fresh empty blob (
CurrentDataBlobName/sceneDataBlobNamecleared). Any blob on the template row is a MakerPen overlay against the shared baked scene and must not leak into clones; AG room details also require an emptyDataBlobNameon the wire. - User-owned source (including AG-flagged clones of RROs): blobs are copied so "copy room" carries the player's MakerPen edits.
Clones are created private (Accessibility=0) regardless of the
source's visibility; the owner publishes via
rooms/{id}/accessibility when ready.
Sub-room saves list is a PAGED OBJECT
GET rooms/{id}/subrooms/{sub}/saves (and …/saves/no_unity_assets)
must return {"Results":[…],"TotalResults":N} —
PagedResultsDTO<SubRoomDataSaveDTO> per the Studio decompile
(RRStudio-decomp/...RecNet/PagedResultsDTO.cs); the 2023 client's
GetSubRoomDataSaves sends skip/take and its strict reader throws
expected:'{', actual:'[' on a bare array. Because callers run inside
larger flows, the bare-array bug surfaced as "Failed to copy room:
Exception of type '…' was thrown" (clone) and a dead private-instance
button — not as a saves error.
Private rooms: one instance per sub-room
Private rooms (Accessibility=0) allow exactly ONE instance per
sub-room. GoToController.ApplyNewInstanceAsync funnels every join
mode (plain matchmake, JoinMode=1 new-public, JoinMode=2 new-private)
into the same deterministic invite-gated instance
(PrivateInstanceService.EnsureForPrivateRoomAsync, id marker
0xEE0000, keyed on room+subroom — dorms keep their own 0xDD0000
flow). Joins by players who are neither the creator, an accepted
role-holder (co-owner/mod/host), nor an instance invitee return
errorCode=4 (RoomDoesNotExist) so private rooms don't leak their
existence. Tests: PrivateRoomInstanceTests.cs.
Baked Rec Room Originals and data blobs (Rec Royale)
Baked RRO rooms are seeded with empty DataBlobName / CurrentDataBlobName,
which the details builders and matchmaking resolvers pass through as an
empty DataBlob. The 2023 client treats that as "no room data": it never
downloads a blob, so the room has no room roles (no Maker Pen even though
the CDN overlays permissive roles for AG rooms), no CV2 room-settings
chips, no mood engine and nothing to save into. IsRRO (= IsAGRoom) is
independent of the blob, so a baked room CAN carry one: the client loads
the baked map from its own assets and layers the blob's persistence views
on top — exactly how Rec Center behaves.
Rec Royale needs this to be a game at all: the Frontier scene has no baked
game rule (contrast PaintballTeamBattle_GameRuleWrapperCircuit, which is
baked into the paintball scene). Its rule is the Maker Pen chip
RecRoyaleSolos_GameRuleWrapperCircuit / RecRoyaleSquads_GameRuleWrapperCircuit
(BattleRoyaleGameRuleWrapper : GameRuleCircuitWrapperTool), which real
Rec Room delivered inside the room blob. Without it there is no
BattleRoyaleManager in the scene.
Stub shape matters. Until something is saved the CDN synthesises the blob,
and the client fetches it through RecNet.Rooms.GetRoomData and rejects
one it deems unusable — a clone surfaces that as the bare "Failed to copy
room", a baked room as a silent load with no room roles (no Maker Pen).
Compared against Rec Center's working room_100_v1.dat, the old stub was
stamped PersistedRoomVersion 19 (client ceiling 16; only the S3-hit path
clamped) and lacked the save skeleton (last_save_time, activity_id =
LocationReplicationId, connectable graph, scene settings, object model,
room_mood_*). RoomDataBlobService.BuildRroEditableBlob now emits that
skeleton per room at version 16.
Second requirement — game-role objects. Both reference saves carry three
PersistenceViewData entries whose game_role_node_data (field 50) holds a
PlayerGameRoleData: the room's default role (Rec Center: "Rec Center",
Stunt Runner: "StuntRunner"; GUID c48bac02-… in both), "In-Game"
(role_id 2097152, GUID 2c721342-…) and "Eliminated" (role_id
4194304, rank 1015, can_move false, GUID 0b9ec39e-…). Their
spawnable_tool_data.prefab_id is the GameRoleNode chip prefab
(c46e1535… raw). Since 2023 permissions hang off the player's GameRole,
a blob without these leaves a co-owner with no Maker Pen. The stub now
generates Rec Royale's own set (view ids derived from the activity id).
Game-rule chip. A newer-client export of a custom Rec Royale room showed
the saved RecRoyaleSquads_GameRuleWrapperCircuit chip is minimal:
spawnable_tool_data.prefab_id 857f7500…, tagged_tool_data tag
recroyalesquadsgameruleci (prefab name lower-cased, 25 chars),
circuit_node_data.id, and an EMPTY game_rule_wrapper_data (field 41).
Prefab ids live in the client's spawnable-tool table in
resources.assets: after SpawnableTool_Runtime_<ENUM> comes a 20-byte
hash (len-prefixed), two u32s, then the 16-byte prefab id (len-prefixed),
then the enum again, display name and tag. Extracted:
Solos 983db81a68927f419edb0daf1243e93e, Squads
857f750016ca77408ae9f72f9877748a (matches the save), StuntRunner
28276d92470ff34ab05f72b2707a04d1. Stunt Runner's other rule-ish object
(84eb8434…, game_configuration_data field 40) is the generic Game
Configuration chip, optional — GameConfigurationTool falls back to its
baked asset. The stub emits the wrapper chip for the two Rec Royale rooms.
Attaching a real save: POST api/admin/v1/rooms/{id}/subrooms/{sub}/blob/upload
(multipart file, ?mode=basics|full) rewrites any-version
PersistedRoomData for the room — see docs/admin.md → "Upload save".
Used to A/B a generated stub against a genuine custom-Rec-Royale save.
Admin switch: POST api/admin/v1/rooms/{id}/subrooms/{sub}/blob/enable
stamps RoomService.SyntheticDefaultRoomDataBlobName(id) on the scene and
room; .../blob/reset clears it. See docs/admin.md → "RRO room data
blob" for the full procedure (enable → spawn the game-rule chip → save).
Related 2023 quirks fixed alongside
- Play-menu search (
IBEOONPEELF.SearchRooms): callsrooms/search_rooms/{query}&skip={n}&take={n}—rooms/-prefixed AND paging embedded in the final path segment. Same segment style onhot_rooms,hot_roomsandplaylists,search_roomsandplaylists(parsed byRoomsController.ParsePagedPathSegment). - Room photo gallery (
KLJOGJHBONK, "Could not show images for room"):api/images/v{2-5}/room/{id}?sort=CheerCount_Desc&filter=PublicOnly—sort/filterarrive as enum NAMES; binding them asintmakes[ApiController]auto-400. Bound as strings, mapped inImagesController.ParseSort.
Tests: DorkNet.Server.Tests/RoomSave2023Tests.cs,
DorkNet.Server.Tests/RoomBrowse2023Tests.cs.