mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
5ab28b1d4f
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.
225 lines
12 KiB
Markdown
225 lines
12 KiB
Markdown
# 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
|
|
|
|
1. **Upload room metadata** — `POST https://storage.<apex>/upload`,
|
|
multipart (`File`=file.bin, `FileType=6`, optional `References`).
|
|
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"}`.
|
|
2. **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 both `Filename`/`filename` casings; `Hash` and
|
|
`OwnershipProof` may be null.
|
|
3. **Commit** — `POST https://rooms.<apex>/rooms/{roomId}/subrooms/{subRoomId}/data`
|
|
with JSON:
|
|
|
|
```json
|
|
{
|
|
"UnityAssetId": null,
|
|
"RoomData": { "Filename": "roommeta_….bin", "Hash": null, "OwnershipProof": null },
|
|
"SubRoomData": { "Filename": "dorm_p…_vN.dat", "Hash": null, "OwnershipProof": null }
|
|
}
|
|
```
|
|
|
|
`SubRoomData.Filename` is the scene save and must become the room's
|
|
`CurrentDataBlobName` (and the dorm-state row for dorms).
|
|
`RoomData.Filename` is the FileType=6 metadata blob.
|
|
|
|
**Response contract** (deserializer `NEOPBOMGIOG`, mapper
|
|
`KMBHAKAHNGH`): legacy `{success, value, error}` envelope where
|
|
`value` MUST contain BOTH keys, non-null:
|
|
|
|
```json
|
|
{
|
|
"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
|
|
the `Room` DTO must carry every key the `FGCPNAACHIK` mapper
|
|
(`GLEGPFFPDBE`) reads, including the ones the include-masked
|
|
`GET rooms/{id}` path gets away without: **DataBlob**,
|
|
**DataBlobHash**, **MaxPlayers**, **ToxmodEnabled**, and a non-null
|
|
**RankingContext** object, plus SubRooms/Roles/Tags/Stats.
|
|
`SubRoomDataSave` keys per the `JKIFFPPAJNK` mapper (`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`/scene `DataBlobName`
|
|
cleared). 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 empty `DataBlobName` on 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`): calls
|
|
`rooms/search_rooms/{query}&skip={n}&take={n}` — `rooms/`-prefixed
|
|
AND paging embedded in the final path segment. Same segment style on
|
|
`hot_rooms`, `hot_roomsandplaylists`, `search_roomsandplaylists`
|
|
(parsed by `RoomsController.ParsePagedPathSegment`).
|
|
- **Room photo gallery** (`KLJOGJHBONK`, "Could not show images for
|
|
room"): `api/images/v{2-5}/room/{id}?sort=CheerCount_Desc&filter=PublicOnly`
|
|
— `sort`/`filter` arrive as enum NAMES; binding them as `int` makes
|
|
`[ApiController]` auto-400. Bound as strings, mapped in
|
|
`ImagesController.ParseSort`.
|
|
|
|
Tests: `DorkNet.Server.Tests/RoomSave2023Tests.cs`,
|
|
`DorkNet.Server.Tests/RoomBrowse2023Tests.cs`.
|