mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] room converter
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
* `<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
|
||||
* 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<string> {
|
||||
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
|
||||
* `<date>/<uuid>` 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({
|
||||
<p className="muted blob-upload-caveat">
|
||||
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 <code>.binpb</code> file is
|
||||
converted for this build before it is stored, which removes its circuits. Download the save
|
||||
above and keep it before replacing it.
|
||||
</p>
|
||||
<label className="blob-upload-file">
|
||||
Scene data 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 }
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
Reference in New Issue
Block a user