Files
recflare/apps/www/src/room-converter.ts
T
2026-09-19 11:27:01 -04:00

455 lines
13 KiB
TypeScript

/**
* 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 a scene file is, by its name, the newer format. Only a default: the upload form
* ticks its convert box from this and the owner decides.
*/
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 }
}