[www] room converter

This commit is contained in:
Devin Zuczek
2026-09-19 11:21:19 -04:00
parent 5e2fad31df
commit 8995000259
4 changed files with 591 additions and 8 deletions
+8
View File
@@ -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 `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 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. 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`: - 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 `/api/relationships/v2/sendfriendrequest`), and an acceptance to the requester as type 40
`FriendRequestAccepted` (`acceptfriendrequest`, and the crossing-request auto-accept), `FriendRequestAccepted` (`acceptfriendrequest`, and the crossing-request auto-accept),
+30 -8
View File
@@ -12,6 +12,7 @@ import {
QUEST_DOWNLOAD_URL, QUEST_DOWNLOAD_URL,
SOURCE_REPO, SOURCE_REPO,
} from '../links' } from '../links'
import { downgradeRoom, needsDowngrade } from '../room-converter'
// The session token, the worker hostnames and the `call` every request goes through — // 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. // see api.ts for why they're a module of their own rather than defined here.
import { import {
@@ -215,14 +216,32 @@ const CLIENT_BUILD_DATE = `${GAME_VERSION.slice(0, 4)}-${GAME_VERSION.slice(4, 6
* `<date>/<uuid>` name every `DataBlob` field holds. * `<date>/<uuid>` name every `DataBlob` field holds.
* *
* This is the same two-step the game does: the bytes go to `storage` first, and only its * 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 * generated name is handed to `rooms`. Nothing about the file is validated here — the
* blob is an opaque Unity payload, and the server doesn't parse it either, so the only * server doesn't parse a room blob either, so the only honest validation available is
* honest validation available is whether the game can load it afterwards. * whether the game can load it afterwards. Hand this the file `prepareRoomBlob` returned.
*/ */
async function uploadRoomBlob(file: File): Promise<string> { async function uploadRoomBlob(file: File): Promise<string> {
return uploadToStorage(file, FILE_TYPE_ROOM_SAVE) 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 * Post a file to `storage` under one `UploadFileType` and return the generated
* `<date>/<uuid>` key. The type decides the bucket folder, and so which worker can serve * `<date>/<uuid>` key. The type decides the bucket folder, and so which worker can serve
@@ -1870,7 +1889,8 @@ function BlobUpload({
e.preventDefault() e.preventDefault()
if (!file) return if (!file) return
void run(async () => { 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( onRoomChange(
await saveSubRoomBlob(roomId, subRoomId, { await saveSubRoomBlob(roomId, subRoomId, {
filename, filename,
@@ -1882,9 +1902,10 @@ function BlobUpload({
setFile(null) setFile(null)
setDescription('') setDescription('')
if (input.current) input.current.value = '' if (input.current) input.current.value = ''
const uploaded = converted ? 'Converted from .binpb, uploaded' : 'Uploaded'
return publish return publish
? 'Uploaded and published — players load this scene now.' ? `${uploaded} and published — players load this scene now.`
: 'Uploaded and staged. Publish it in game to make it live.' : `${uploaded} and staged. Publish it in game to make it live.`
}) })
}} }}
> >
@@ -1898,8 +1919,9 @@ function BlobUpload({
<p className="muted blob-upload-caveat"> <p className="muted blob-upload-caveat">
New and lightly tested. Nothing here checks the file — the server stores whatever it is and 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 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 from a room built on anything newer may not load at all. A <code>.binpb</code> file is
before replacing it. converted for this build before it is stored, which removes its circuits. Download the save
above and keep it before replacing it.
</p> </p>
<label className="blob-upload-file"> <label className="blob-upload-file">
Scene data file Scene data file
+451
View File
@@ -0,0 +1,451 @@
/**
* Downgrade a newer Rec Room scene file (`.binpb`) into one the build this server runs can
* parse. A port of `recroom_downgrader.py` v4, producing the same bytes it does.
*
* A scene is a protobuf message with no schema available, so everything here works on the
* wire format — field numbers and wire types — and never on meaning. Four patches:
*
* 1. fn30, the minimum version the file asks for → 123 (first top-level occurrence).
* 2. fn100 = 0xFFFFFFFFFFFFFFFF, stripped from each object blob (top-level fn2).
* 3. fn14.fn5, stripped from each object's fn14 sub-message.
* 4. fn28, the circuit layer, removed WHEREVER it occurs in the message tree. Done at the
* field level rather than by searching for strings, so half a circuit graph is never
* left behind.
*
* A nested value is only treated as a message if the whole of it parses as one; anything
* else is left byte-for-byte alone. A parse error never throws out of here — the rest of
* the input is copied through unchanged, which is what the original does.
*
* Untouched fields are copied as their original bytes. The original decodes and re-encodes
* their varints instead, so the two differ only on an overlong varint, which no protobuf
* writer emits.
*
* No runtime dependencies, so it's safe to pull into the client bundle.
*/
const FN100_SENTINEL = 0xffff_ffff_ffff_ffffn
const TARGET_VERSION = 123
const CIRCUIT_FIELD = 28
const WT_VARINT = 0
const WT_FIXED64 = 1
const WT_LEN = 2
const WT_FIXED32 = 5
export interface DowngradeStats {
schemaVersion: number
originalVersion: number
versionPatched: boolean
objectBlobs: number
objectsModified: number
fn100Removed: number
fn5Removed: number
circuitFieldsRemoved: number
circuitBytesRemoved: number
inputBytes: number
outputBytes: number
}
/** Whether an uploaded scene file is the newer format that needs downgrading first. */
export function needsDowngrade(filename: string): boolean {
return filename.toLowerCase().endsWith('.binpb')
}
/**
* A varint as a double. Nothing here needs one exactly past 2^53: a length that large is
* past the end of any buffer, and a field number that large matches none of the ones
* patched. A value that overflows to Infinity compares the same way, so a run of garbage
* is refused rather than mis-read. The one value that IS needed exactly, fn100's, goes
* through `readVarintBig`.
*/
function readVarint(data: Uint8Array, pos: number): [value: number, pos: number] {
let result = 0
let scale = 1
while (pos < data.length) {
const b = data[pos++]
result += (b & 0x7f) * scale
if (!(b & 0x80)) return [result, pos]
scale *= 128
}
throw new Error('truncated varint')
}
function readVarintBig(data: Uint8Array, pos: number): [value: bigint, pos: number] {
let result = 0n
let shift = 0n
while (pos < data.length) {
const b = data[pos++]
result |= BigInt(b & 0x7f) << shift
if (!(b & 0x80)) return [result, pos]
shift += 7n
}
throw new Error('truncated varint')
}
/**
* A field's tag. The wire type is read off the first byte, where its three bits always
* are, so it stays exact however large the varint runs.
*/
function readTag(data: Uint8Array, pos: number): [fn: number, wt: number, pos: number] {
const wt = data[pos] & 7
const [tag, next] = readVarint(data, pos)
return [Math.floor(tag / 8), wt, next]
}
function encodeVarint(value: number): Uint8Array {
const out: number[] = []
for (;;) {
const b = value % 128
value = Math.floor(value / 128)
if (value) {
out.push(b | 0x80)
} else {
out.push(b)
break
}
}
return Uint8Array.from(out)
}
function concat(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
let pos = 0
for (const part of parts) {
out.set(part, pos)
pos += part.length
}
return out
}
/** A length-delimited field rebuilt around a new payload, keeping its original tag bytes. */
function withPayload(tag: Uint8Array, payload: Uint8Array): Uint8Array[] {
return [tag, encodeVarint(payload.length), payload]
}
/** The position immediately after a field's value. Throws if it runs off the end. */
function skipField(data: Uint8Array, pos: number, wt: number): number {
if (wt === WT_VARINT) return readVarint(data, pos)[1]
let end: number
if (wt === WT_FIXED64) {
end = pos + 8
} else if (wt === WT_LEN) {
const [length, payloadPos] = readVarint(data, pos)
end = payloadPos + length
} else if (wt === WT_FIXED32) {
end = pos + 4
} else {
throw new Error(`unsupported protobuf wire type ${wt}`)
}
if (end > data.length) throw new Error('truncated field')
return end
}
/** Recursively remove fn28 from a blob already known to parse as a message. */
function stripCircuitFields(blob: Uint8Array, stats: DowngradeStats): Uint8Array {
const out: Uint8Array[] = []
let pos = 0
while (pos < blob.length) {
const fieldStart = pos
try {
const [fn, wt, valuePos] = readTag(blob, pos)
if (fn === CIRCUIT_FIELD) {
const fieldEnd = skipField(blob, valuePos, wt)
stats.circuitFieldsRemoved += 1
stats.circuitBytesRemoved += fieldEnd - fieldStart
pos = fieldEnd
continue
}
if (wt === WT_LEN) {
const [length, payloadPos] = readVarint(blob, valuePos)
const payloadEnd = payloadPos + length
if (payloadEnd > blob.length) throw new Error('truncated length-delimited field')
const [payload, removed] = stripCircuitFieldsSafe(
blob.subarray(payloadPos, payloadEnd),
stats
)
if (removed) {
out.push(...withPayload(blob.subarray(fieldStart, valuePos), payload))
} else {
out.push(blob.subarray(fieldStart, payloadEnd))
}
pos = payloadEnd
continue
}
const fieldEnd = skipField(blob, valuePos, wt)
out.push(blob.subarray(fieldStart, fieldEnd))
pos = fieldEnd
} catch {
out.push(blob.subarray(fieldStart))
break
}
}
return concat(out)
}
/**
* `stripCircuitFields`, but only for a blob that parses as a message end to end — which is
* what tells "a message with no circuits in it" from a string or other opaque bytes.
*/
function stripCircuitFieldsSafe(
blob: Uint8Array,
stats: DowngradeStats
): [blob: Uint8Array, removed: boolean] {
const before = stats.circuitFieldsRemoved
try {
let pos = 0
while (pos < blob.length) {
const [, wt, valuePos] = readTag(blob, pos)
pos = skipField(blob, valuePos, wt)
}
} catch {
return [blob, false]
}
const stripped = stripCircuitFields(blob, stats)
return [stripped, stats.circuitFieldsRemoved > before]
}
/**
* An fn14 sub-message with field 5 (the LinkedItem UUID) removed. Lengths are deliberately
* NOT bounds-checked, as in the original: a field that runs off the end is kept, clamped
* to what is there, and ends the walk.
*/
function stripFn5FromFn14(blob: Uint8Array): Uint8Array {
const out: Uint8Array[] = []
let pos = 0
while (pos < blob.length) {
const fieldStart = pos
try {
const [fn, wt, valuePos] = readTag(blob, pos)
let fieldEnd: number
if (wt === WT_VARINT) {
fieldEnd = readVarint(blob, valuePos)[1]
} else if (wt === WT_LEN) {
const [length, payloadPos] = readVarint(blob, valuePos)
fieldEnd = payloadPos + length
} else if (wt === WT_FIXED32) {
fieldEnd = valuePos + 4
} else if (wt === WT_FIXED64) {
fieldEnd = valuePos + 8
} else {
out.push(blob.subarray(fieldStart))
break
}
if (fn !== 5) out.push(blob.subarray(fieldStart, fieldEnd))
pos = fieldEnd
} catch {
out.push(blob.subarray(fieldStart))
break
}
}
return concat(out)
}
/** One object (top-level fn2): strip fn100 and fn14.fn5, and remove circuit fields below it. */
function processObjectBlob(blob: Uint8Array, stats: DowngradeStats): Uint8Array {
const out: Uint8Array[] = []
let pos = 0
let changed = false
while (pos < blob.length) {
const fieldStart = pos
try {
const [fn, wt, valuePos] = readTag(blob, pos)
if (fn === CIRCUIT_FIELD) {
const fieldEnd = skipField(blob, valuePos, wt)
stats.circuitFieldsRemoved += 1
stats.circuitBytesRemoved += fieldEnd - fieldStart
changed = true
pos = fieldEnd
continue
}
if (fn === 100 && wt === WT_VARINT) {
const [value, fieldEnd] = readVarintBig(blob, valuePos)
if (value === FN100_SENTINEL) {
stats.fn100Removed += 1
changed = true
} else {
out.push(blob.subarray(fieldStart, fieldEnd))
}
pos = fieldEnd
continue
}
if (fn === 14 && wt === WT_LEN) {
// Not bounds-checked, as in the original: an fn14 that runs off the end is
// clamped, and comes out re-framed at the length that was really there.
const [length, payloadPos] = readVarint(blob, valuePos)
const stripped = stripFn5FromFn14(blob.subarray(payloadPos, payloadPos + length))
if (stripped.length !== length) {
stats.fn5Removed += 1
changed = true
}
out.push(...withPayload(blob.subarray(fieldStart, valuePos), stripped))
pos = payloadPos + length
continue
}
if (wt === WT_LEN) {
const [length, payloadPos] = readVarint(blob, valuePos)
const payloadEnd = payloadPos + length
if (payloadEnd > blob.length) throw new Error('truncated nested object')
const [payload, removed] = stripCircuitFieldsSafe(
blob.subarray(payloadPos, payloadEnd),
stats
)
if (removed) {
out.push(...withPayload(blob.subarray(fieldStart, valuePos), payload))
changed = true
} else {
out.push(blob.subarray(fieldStart, payloadEnd))
}
pos = payloadEnd
continue
}
let fieldEnd: number
if (wt === WT_VARINT) {
fieldEnd = readVarint(blob, valuePos)[1]
} else if (wt === WT_FIXED32) {
fieldEnd = valuePos + 4
} else if (wt === WT_FIXED64) {
fieldEnd = valuePos + 8
} else {
out.push(blob.subarray(fieldStart))
break
}
out.push(blob.subarray(fieldStart, fieldEnd))
pos = fieldEnd
} catch {
out.push(blob.subarray(fieldStart))
break
}
}
if (changed) stats.objectsModified += 1
return concat(out)
}
/**
* Set the first top-level fn30 varint to the target version. The walk stops at the first
* thing it can't read, so an fn30 behind a malformed field is left as it was.
*/
function patchFn30(data: Uint8Array<ArrayBuffer>, stats: DowngradeStats): Uint8Array<ArrayBuffer> {
let pos = 0
try {
while (pos < data.length) {
const [fn, wt, valuePos] = readTag(data, pos)
if (fn === 30 && wt === WT_VARINT) {
const [value, fieldEnd] = readVarint(data, valuePos)
stats.originalVersion = value
stats.versionPatched = value !== TARGET_VERSION
return concat([
data.subarray(0, pos),
encodeVarint(30 * 8 + WT_VARINT),
encodeVarint(TARGET_VERSION),
data.subarray(fieldEnd),
])
}
if (wt === WT_VARINT) {
pos = readVarint(data, valuePos)[1]
} else if (wt === WT_LEN) {
const [length, payloadPos] = readVarint(data, valuePos)
pos = payloadPos + length
} else if (wt === WT_FIXED32) {
pos = valuePos + 4
} else if (wt === WT_FIXED64) {
pos = valuePos + 8
} else {
break
}
}
} catch {
// Unreadable from here on: nothing to patch.
}
return data
}
/** Downgrade a scene file. Never throws on malformed input — see the note at the top. */
export function downgradeRoom(data: Uint8Array): {
data: Uint8Array<ArrayBuffer>
stats: DowngradeStats
} {
const stats: DowngradeStats = {
schemaVersion: 0,
originalVersion: 0,
versionPatched: false,
objectBlobs: 0,
objectsModified: 0,
fn100Removed: 0,
fn5Removed: 0,
circuitFieldsRemoved: 0,
circuitBytesRemoved: 0,
inputBytes: data.length,
outputBytes: 0,
}
const out: Uint8Array[] = []
let pos = 0
while (pos < data.length) {
const fieldStart = pos
try {
const [fn, wt, valuePos] = readTag(data, pos)
if (fn === CIRCUIT_FIELD) {
const fieldEnd = skipField(data, valuePos, wt)
stats.circuitFieldsRemoved += 1
stats.circuitBytesRemoved += fieldEnd - fieldStart
pos = fieldEnd
continue
}
if (wt === WT_VARINT) {
const [value, fieldEnd] = readVarint(data, valuePos)
if (fn === 1) stats.schemaVersion = value
out.push(data.subarray(fieldStart, fieldEnd))
pos = fieldEnd
} else if (wt === WT_LEN) {
const [length, payloadPos] = readVarint(data, valuePos)
const payloadEnd = payloadPos + length
if (payloadEnd > data.length) throw new Error('truncated top-level field')
const blob = data.subarray(payloadPos, payloadEnd)
const tag = data.subarray(fieldStart, valuePos)
if (fn === 2) {
stats.objectBlobs += 1
out.push(...withPayload(tag, processObjectBlob(blob, stats)))
} else {
const [payload, removed] = stripCircuitFieldsSafe(blob, stats)
if (removed) {
out.push(...withPayload(tag, payload))
} else {
out.push(data.subarray(fieldStart, payloadEnd))
}
}
pos = payloadEnd
} else if (wt === WT_FIXED32) {
out.push(data.subarray(fieldStart, valuePos + 4))
pos = valuePos + 4
} else if (wt === WT_FIXED64) {
out.push(data.subarray(fieldStart, valuePos + 8))
pos = valuePos + 8
} else {
out.push(data.subarray(fieldStart))
break
}
} catch {
out.push(data.subarray(fieldStart))
break
}
}
const result = patchFn30(concat(out), stats)
stats.outputBytes = result.length
return { data: result, stats }
}
+102
View File
@@ -0,0 +1,102 @@
import { expect, it } from 'vitest'
import { downgradeRoom, needsDowngrade } from '../room-converter'
// Hand-built protobuf, since no schema exists for a scene: every expectation below is on
// the wire bytes, which is also all the converter ever looks at.
function varint(value: bigint): number[] {
const out: number[] = []
for (;;) {
const b = Number(value & 0x7fn)
value >>= 7n
if (value) {
out.push(b | 0x80)
} else {
out.push(b)
return out
}
}
}
const num = (fn: number, value: bigint | number) => [
...varint(BigInt(fn << 3)),
...varint(BigInt(value)),
]
const len = (fn: number, payload: number[]) => [
...varint(BigInt((fn << 3) | 2)),
...varint(BigInt(payload.length)),
...payload,
]
const text = (s: string) => [...new TextEncoder().encode(s)]
const run = (bytes: number[]) => {
const { data, stats } = downgradeRoom(Uint8Array.from(bytes))
return { bytes: [...data], stats }
}
const SENTINEL = 0xffff_ffff_ffff_ffffn
it('only a .binpb needs downgrading', () => {
expect(needsDowngrade('MyRoom.binpb')).toBe(true)
expect(needsDowngrade('MYROOM.BINPB')).toBe(true)
expect(needsDowngrade('MyRoom.room')).toBe(false)
expect(needsDowngrade('binpb')).toBe(false)
})
it('applies all four patches', () => {
const circuit = len(28, text('Circuit Board'))
const { bytes, stats } = run([
...num(1, 9),
...len(2, [
...num(3, 7),
...num(100, SENTINEL),
...len(14, [...num(1, 4), ...len(5, text('uuid')), ...num(6, 2)]),
// A circuit two messages down, and one directly on the object.
...len(7, len(8, [...num(1, 1), ...circuit])),
...circuit,
]),
...circuit,
...len(9, [...num(1, 1), ...circuit]),
...num(30, 140),
])
expect(bytes).toEqual([
...num(1, 9),
...len(2, [
...num(3, 7),
...len(14, [...num(1, 4), ...num(6, 2)]),
...len(7, len(8, num(1, 1))),
]),
...len(9, num(1, 1)),
...num(30, 123),
])
expect(stats).toMatchObject({
schemaVersion: 9,
originalVersion: 140,
versionPatched: true,
objectBlobs: 1,
objectsModified: 1,
fn100Removed: 1,
fn5Removed: 1,
circuitFieldsRemoved: 4,
})
expect(stats.inputBytes - stats.outputBytes).toBeGreaterThan(stats.circuitBytesRemoved)
})
it('keeps an fn100 that is not the sentinel, and only patches the first fn30', () => {
const input = [...len(2, num(100, 5)), ...num(30, 123), ...num(30, 200)]
const { bytes, stats } = run(input)
expect(bytes).toEqual(input)
expect(stats).toMatchObject({ fn100Removed: 0, objectsModified: 0, versionPatched: false })
})
it('leaves bytes that only look like a circuit field alone', () => {
// 0xE2 is a field-28 tag, but the string around it doesn't parse as a message end to
// end, so it is opaque and must survive untouched.
const input = len(3, [0xe2, 0x01, 0x02, 0x41, 0x42, 0xff])
expect(run(input).bytes).toEqual(input)
})
it('copies a truncated file through instead of throwing', () => {
const input = [...num(1, 9), ...len(2, num(3, 7)).slice(0, -1)]
expect(run(input).bytes).toEqual(input)
expect(run([]).bytes).toEqual([])
})