From 8995000259def9c049e72ebc7192e0bef47ebee9 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sat, 19 Sep 2026 11:21:19 -0400 Subject: [PATCH] [www] room converter --- CLAUDE.md | 8 + apps/www/src/client/App.tsx | 38 +- apps/www/src/room-converter.ts | 451 +++++++++++++++++++++++ apps/www/src/test/room-converter.test.ts | 102 +++++ 4 files changed, 591 insertions(+), 8 deletions(-) create mode 100644 apps/www/src/room-converter.ts create mode 100644 apps/www/src/test/room-converter.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index bc367b0..4616ff4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -229,6 +229,14 @@ inconsistency here without checking the client first. `GiftPackage` null — no box is minted. `GET /econ/customAvatarItems/v1/owned` is that table joined to the item record PLUS everything the caller created (drafts included): a creator owns their own through `CreatorAccountId`, and the bag refuses to sell it to them. +- A chat report (`api`: `POST /api/chatreport/createChatReport`) is form-encoded and sends + `ReportCategory` as an enum NAME (`Discriminatory`), where every other report sends the + number. It names no player: the reported one is the message's sender, read from `message`. + The name is mapped onto the numeric `KickReportCategory` the `report` table stores + (`chatReportCategory`); only `Discriminatory` has been observed, so an unmapped name is + filed as 0 with the name kept in the details — extend the alias table as more are seen. + The reply is the `{ success, error: "" }` envelope the other reports use, which is an + ASSUMPTION here: what the client does with this response has not been observed. - A friend request is SHOWN to the target as a Message of type 4 `FriendInvite` (`api`: `/api/relationships/v2/sendfriendrequest`), and an acceptance to the requester as type 40 `FriendRequestAccepted` (`acceptfriendrequest`, and the crossing-request auto-accept), diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 260f4a9..64e0ce5 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -12,6 +12,7 @@ import { QUEST_DOWNLOAD_URL, SOURCE_REPO, } from '../links' +import { downgradeRoom, needsDowngrade } from '../room-converter' // The session token, the worker hostnames and the `call` every request goes through — // see api.ts for why they're a module of their own rather than defined here. import { @@ -215,14 +216,32 @@ const CLIENT_BUILD_DATE = `${GAME_VERSION.slice(0, 4)}-${GAME_VERSION.slice(4, 6 * `/` name every `DataBlob` field holds. * * This is the same two-step the game does: the bytes go to `storage` first, and only its - * generated name is handed to `rooms`. Nothing about the file is inspected here — a room - * blob is an opaque Unity payload, and the server doesn't parse it either, so the only - * honest validation available is whether the game can load it afterwards. + * generated name is handed to `rooms`. Nothing about the file is validated here — the + * server doesn't parse a room blob either, so the only honest validation available is + * whether the game can load it afterwards. Hand this the file `prepareRoomBlob` returned. */ async function uploadRoomBlob(file: File): Promise { return uploadToStorage(file, FILE_TYPE_ROOM_SAVE) } +/** + * The file that actually gets stored for a picked scene file. A `.binpb` is a scene taken + * from a newer build, which the build this server runs can't parse as it stands, so it is + * downgraded first (see `room-converter.ts`); anything else passes through untouched. + * + * Done here in the browser, before either request, because the upload goes straight to + * `storage` and the save's `Hash` has to describe the bytes that were stored — so both + * the upload and `blobHash` must be given this file, not the one that was picked. + */ +async function prepareRoomBlob(file: File): Promise<{ file: File; converted: boolean }> { + if (!needsDowngrade(file.name)) return { file, converted: false } + const { data } = downgradeRoom(new Uint8Array(await file.arrayBuffer())) + return { + file: new File([data], file.name, { type: 'application/octet-stream' }), + converted: true, + } +} + /** * Post a file to `storage` under one `UploadFileType` and return the generated * `/` key. The type decides the bucket folder, and so which worker can serve @@ -1870,7 +1889,8 @@ function BlobUpload({ e.preventDefault() if (!file) return void run(async () => { - const [filename, hash] = await Promise.all([uploadRoomBlob(file), blobHash(file)]) + const { file: blob, converted } = await prepareRoomBlob(file) + const [filename, hash] = await Promise.all([uploadRoomBlob(blob), blobHash(blob)]) onRoomChange( await saveSubRoomBlob(roomId, subRoomId, { filename, @@ -1882,9 +1902,10 @@ function BlobUpload({ setFile(null) setDescription('') if (input.current) input.current.value = '' + const uploaded = converted ? 'Converted from .binpb, uploaded' : 'Uploaded' return publish - ? 'Uploaded and published — players load this scene now.' - : 'Uploaded and staged. Publish it in game to make it live.' + ? `${uploaded} and published — players load this scene now.` + : `${uploaded} and staged. Publish it in game to make it live.` }) }} > @@ -1898,8 +1919,9 @@ function BlobUpload({

New and lightly tested. Nothing here checks the file — the server stores whatever it is and the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so scene data - from a room built on anything newer may not load at all. Download the save above and keep it - before replacing it. + from a room built on anything newer may not load at all. A .binpb file is + converted for this build before it is stored, which removes its circuits. Download the save + above and keep it before replacing it.