[www] optional downgrade

This commit is contained in:
Devin Zuczek
2026-09-19 11:27:01 -04:00
parent 8995000259
commit c7ee2bab53
2 changed files with 60 additions and 16 deletions
+56 -15
View File
@@ -225,21 +225,31 @@ async function uploadRoomBlob(file: File): Promise<string> {
} }
/** /**
* The file that actually gets stored for a picked scene file. A `.binpb` is a scene taken * The scene files the upload form takes, by extension: a `.room` is a save as this server
* from a newer build, which the build this server runs can't parse as it stands, so it is * stores it, a `.binpb` a scene taken from a newer build. Only checked in the browser —
* downgraded first (see `room-converter.ts`); anything else passes through untouched. * the blob is opaque to the server, which stores whatever it is given.
*/
const ROOM_BLOB_EXTENSIONS = ['.room', '.binpb']
function isRoomBlobFile(filename: string): boolean {
const name = filename.toLowerCase()
return ROOM_BLOB_EXTENSIONS.some((ext) => name.endsWith(ext))
}
/**
* The file that actually gets stored for a picked scene file. A scene taken from a newer
* build can't be parsed by the build this server runs as it stands, so when the owner asks
* for it the file is downgraded first (see `room-converter.ts`); otherwise it passes
* through untouched.
* *
* Done here in the browser, before either request, because the upload goes straight to * 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 * `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. * 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 }> { async function prepareRoomBlob(file: File, downgrade: boolean): Promise<File> {
if (!needsDowngrade(file.name)) return { file, converted: false } if (!downgrade) return file
const { data } = downgradeRoom(new Uint8Array(await file.arrayBuffer())) const { data } = downgradeRoom(new Uint8Array(await file.arrayBuffer()))
return { return new File([data], file.name, { type: 'application/octet-stream' })
file: new File([data], file.name, { type: 'application/octet-stream' }),
converted: true,
}
} }
/** /**
@@ -1877,6 +1887,11 @@ function BlobUpload({
const [file, setFile] = useState<File | null>(null) const [file, setFile] = useState<File | null>(null)
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [publish, setPublish] = useState(true) const [publish, setPublish] = useState(true)
// Whether to convert the file for this build before storing it. Picking a file sets it
// from the extension — on for a `.binpb`, off for a `.room` — and the owner can overrule
// that either way: a `.binpb` that already loads shouldn't lose its circuits for nothing.
const [downgrade, setDowngrade] = useState(false)
const [fileError, setFileError] = useState('')
// The file input is uncontrolled — React can't set its value — so clearing the picked // The file input is uncontrolled — React can't set its value — so clearing the picked
// file after a save takes a handle on the element itself. // file after a save takes a handle on the element itself.
const input = useRef<HTMLInputElement>(null) const input = useRef<HTMLInputElement>(null)
@@ -1889,7 +1904,7 @@ function BlobUpload({
e.preventDefault() e.preventDefault()
if (!file) return if (!file) return
void run(async () => { void run(async () => {
const { file: blob, converted } = await prepareRoomBlob(file) const blob = await prepareRoomBlob(file, downgrade)
const [filename, hash] = await Promise.all([uploadRoomBlob(blob), blobHash(blob)]) const [filename, hash] = await Promise.all([uploadRoomBlob(blob), blobHash(blob)])
onRoomChange( onRoomChange(
await saveSubRoomBlob(roomId, subRoomId, { await saveSubRoomBlob(roomId, subRoomId, {
@@ -1900,9 +1915,10 @@ function BlobUpload({
}) })
) )
setFile(null) setFile(null)
setDowngrade(false)
setDescription('') setDescription('')
if (input.current) input.current.value = '' if (input.current) input.current.value = ''
const uploaded = converted ? 'Converted from .binpb, uploaded' : 'Uploaded' const uploaded = downgrade ? 'Converted for this build, 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.`
@@ -1919,19 +1935,35 @@ 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. A <code>.binpb</code> file is from a room built on anything newer may not load at all. Converting a file for this build
converted for this build before it is stored, which removes its circuits. Download the save before it is stored gets around that, but removes its circuits. Download the save above and
above and keep it before replacing it. keep it before replacing it.
</p> </p>
<label className="blob-upload-file"> <label className="blob-upload-file">
Scene data file Scene data file
<input <input
ref={input} ref={input}
type="file" type="file"
onChange={(e) => setFile(e.target.files?.[0] ?? null)} accept={ROOM_BLOB_EXTENSIONS.join(',')}
onChange={(e) => {
// `accept` only filters the picker — a file dropped on the input, or picked
// under "All files", still arrives here, so the extension is checked again.
const picked = e.target.files?.[0] ?? null
if (picked && !isRoomBlobFile(picked.name)) {
setFileError('Pick a .room or .binpb file.')
setFile(null)
setDowngrade(false)
e.target.value = ''
return
}
setFileError('')
setFile(picked)
setDowngrade(picked !== null && needsDowngrade(picked.name))
}}
required required
/> />
</label> </label>
{fileError && <p className="error">{fileError}</p>}
<label className="blob-upload-note"> <label className="blob-upload-note">
Save comment<span className="optional">optional</span> Save comment<span className="optional">optional</span>
<input <input
@@ -1946,6 +1978,15 @@ function BlobUpload({
<input type="checkbox" checked={publish} onChange={(e) => setPublish(e.target.checked)} /> <input type="checkbox" checked={publish} onChange={(e) => setPublish(e.target.checked)} />
Publish it straight away Publish it straight away
</label> </label>
<label className="check">
<input
type="checkbox"
checked={downgrade}
disabled={file === null}
onChange={(e) => setDowngrade(e.target.checked)}
/>
Downgrade room for compatibility
</label>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>} {done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending || file === null}> <button type="submit" disabled={pending || file === null}>
+4 -1
View File
@@ -46,7 +46,10 @@ export interface DowngradeStats {
outputBytes: number outputBytes: number
} }
/** Whether an uploaded scene file is the newer format that needs downgrading first. */ /**
* 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 { export function needsDowngrade(filename: string): boolean {
return filename.toLowerCase().endsWith('.binpb') return filename.toLowerCase().endsWith('.binpb')
} }