[studio] prefix assets

This commit is contained in:
Devin Zuczek
2026-10-09 01:37:19 -04:00
parent f9beba0dae
commit ef65b505cc
16 changed files with 384 additions and 410 deletions
+14 -23
View File
@@ -21,13 +21,9 @@ import { bindPlaceholders, chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds'
import { Accessibility, Role } from './enums'
import { countPlayersByRoom } from './presence-db'
import { resyncInstanceCapacity } from './room-instance-db'
import {
bakedStudioUnityAssets,
isMissingStudioAssetTable,
listStudioUnityAssetFiles,
} from './studio-unity-assets'
import { bakedUnityAssets, listUnityAssetBuilds } from './unity-assets-db'
import type { StudioUnityAssetFile } from './studio-unity-assets'
import type { UnityAssetBuild } from './unity-assets-db'
/** Schema DDL (mirror of the head migration schema, sans the seed INSERT). */
export const ROOM_SCHEMA_DDL: string[] = [
@@ -2287,10 +2283,10 @@ async function attachCurrentSaves(
}
/**
* Fill `UnitySubAssets` on saves that point at a stored Studio build. Maker-pen
* saves have no `UnityAssetId` and are left untouched, including their empty
* arrays. A missing studio table is the same answer: the scene blob still loads,
* and the bundle list stays empty until the migration exists.
* Fill `UnitySubAssets` on saves that point at a stored Studio build (rows of
* `unity_asset`). Maker-pen saves have no `UnityAssetId` and are left untouched,
* including their empty arrays; so is a save whose asset has no stored build —
* the scene blob still loads, and the bundle list stays empty.
*
* Only main bundles are listed. Stripped bundles stay in the bucket.
*/
@@ -2306,23 +2302,18 @@ export async function attachStudioUnityAssets(
),
]
if (ids.length === 0) return
let files: StudioUnityAssetFile[]
try {
files = await listStudioUnityAssetFiles(db, ids)
} catch (err) {
if (isMissingStudioAssetTable(err)) return
throw err
}
const byAsset = new Map<string, StudioUnityAssetFile[]>()
for (const file of files) {
const list = byAsset.get(file.unityAssetId) ?? []
list.push(file)
byAsset.set(file.unityAssetId, list)
const builds = await listUnityAssetBuilds(db, ids)
// Rows come back with the id lowercased; a save may spell its GUID either way.
const byAsset = new Map<string, UnityAssetBuild[]>()
for (const build of builds) {
const list = byAsset.get(build.UnityAssetId) ?? []
list.push(build)
byAsset.set(build.UnityAssetId, list)
}
for (const save of saves) {
const id = save.UnityAssetId
if (typeof id !== 'string') continue
const baked = bakedStudioUnityAssets(byAsset.get(id) ?? [])
const baked = bakedUnityAssets(byAsset.get(id.toLowerCase()) ?? [])
if (baked.length === 0) continue
save.UnitySubAssets = baked
}
+45 -161
View File
@@ -1,20 +1,24 @@
/**
* Baked Rec Room Studio bundles, stored by the studio worker.
* Rec Room Studio cloud builds.
*
* The studio migration owns the tables. This module only reads them, and tests
* apply {@link STUDIO_UNITY_ASSET_SCHEMA_DDL} directly. A room load must keep
* working before that migration has been applied, so a missing table is
* reported by {@link isMissingStudioAssetTable} and the caller leaves the save
* the way a maker-pen room already looks.
* The editor builds Windows and Android asset bundles on the creator's PC and posts them
* to the `studio` worker, which stores the bytes in the shared `recflare-cdn` bucket,
* records each bundle as a row of `unity_asset` (see unity-assets-db.ts — the same table
* the game's bulk lookup reads) and files the build itself in `studio_cloud_build`, the
* one table this module describes. The studio migration owns that table; this module
* reads it, and tests apply {@link STUDIO_CLOUD_BUILD_SCHEMA_DDL} directly.
*
* The filename column is what a client requests. New builds store a name that
* contains the unity asset id, because the download route looks the file up by
* filename alone and two rooms can otherwise both upload `win.assetbundle`.
* A room load must keep working before that migration has been applied, so a missing
* table is reported by {@link isMissingStudioAssetTable} and the caller falls back to
* whatever it knows without the build record.
*/
import { bindPlaceholders, chunkForBinds } from './d1-binds'
import { listUnityAssetBuilds } from './unity-assets-db'
export const STUDIO_UNITY_ASSET_SCHEMA_DDL: string[] = [
import type { UnityAssetBuild } from './unity-assets-db'
/** Schema DDL (mirror of apps/studio/migrations/0001_studio_cloud_build.sql, less the dropped file table). */
export const STUDIO_CLOUD_BUILD_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS studio_cloud_build (
cloud_build_id TEXT PRIMARY KEY,
room_id INTEGER NOT NULL,
@@ -28,174 +32,72 @@ export const STUDIO_UNITY_ASSET_SCHEMA_DDL: string[] = [
)`,
`CREATE INDEX IF NOT EXISTS idx_studio_cloud_build_room
ON studio_cloud_build (room_id, sub_room_id, started_at)`,
`CREATE TABLE IF NOT EXISTS studio_unity_asset_file (
unity_asset_id TEXT NOT NULL,
platform TEXT NOT NULL,
kind TEXT NOT NULL,
filename TEXT NOT NULL,
sha256 TEXT NOT NULL,
byte_length INTEGER NOT NULL,
r2_key TEXT NOT NULL,
PRIMARY KEY (unity_asset_id, platform, kind)
)`,
]
/** The version stamped on every baked file this server stores. */
/** The `Version` stamped on every bundle a Studio build stores. */
export const STUDIO_ASSET_VERSION = 1
/** The platforms a local Studio build posts, named as the multipart fields name them. */
export type StudioBundlePlatform = 'windows' | 'android'
export type StudioBundleKind = 'main' | 'stripped'
export interface StudioUnityAssetFile {
unityAssetId: string
platform: StudioBundlePlatform
kind: StudioBundleKind
filename: string
/** Lowercase hex SHA-256, the form the studio upload stores. */
sha256: string
byteLength: number
r2Key: string
}
/**
* One baked bundle as a room save lists it. PascalCase, like the rest of
* `CurrentSave`. `Target` uses the numbers the game sends as `unityAssetTarget`
* on a custom avatar: 0 is Windows, 2 is Android/Quest.
*/
export interface BakedStudioUnityAsset {
UnityAssetId: string
Target: number
Version: number
Filename: string
/** Base64 SHA-256. An empty string is not a hash. */
Hash: string
}
interface StudioUnityAssetFileRow {
unity_asset_id: string
platform: string
kind: string
filename: string
sha256: string
byte_length: number
r2_key: string
}
/**
* The name a client downloads. Unique per build, platform, and kind, so a
* filename lookup cannot return another room's bundle.
* A Studio bundle's `unity_asset.filename` — the name the client downloads, which it
* prefixes with `/room/` (see {@link studioBundleR2Key}). Laid out like the official
* game's room assets (`unity/<roomId>/<name>.assetbundle`) with `studio` in place of
* `unity`, so a room's bundles sit together under its id. Unique per build, platform,
* and kind, so two rooms that both uploaded `win.assetbundle` cannot collide.
*/
export function publicStudioBundleFilename(
roomId: number,
unityAssetId: string,
platform: string,
platform: StudioBundlePlatform,
kind: string
): string {
const id = unityAssetId.replace(/-/g, '').toLowerCase()
return `${id}.${platform}.${kind}.assetbundle`
return `studio/${roomId}/${id}.${platform}.${kind}.assetbundle`
}
/**
* 0 for Windows, 2 for Android/Quest. Anything else is treated as Windows:
* those are the only two platforms a local build stores.
* Where the bytes behind a `unity_asset.filename` live in the shared `recflare-cdn`
* bucket. The game client fetches every room asset under `/room/`, straight from the
* bucket, so the key is the filename with that prefix — `room/studio/<roomId>/<name>`.
*/
export function studioAssetTarget(platform: string): number {
export function studioBundleR2Key(filename: string): string {
return `room/${filename}`
}
/**
* 0 for Windows, 2 for Android/Quest — the `Target` a bundle is stored under, the same
* numbers the game sends as `unityAssetTarget` on a custom avatar.
*/
export function studioAssetTarget(platform: StudioBundlePlatform): number {
return platform === 'android' ? 2 : 0
}
/** Base64 SHA-256, which is the hash encoding avatar and invention downloads use. */
export function sha256HexToBase64(hex: string): string {
const clean = hex.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(clean)) return ''
const bytes = new Uint8Array(32)
for (let i = 0; i < 32; i++) bytes[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary)
}
/** True when D1 failed because the studio asset tables have not been migrated. */
/** True when D1 failed because the studio cloud-build table has not been migrated. */
export function isMissingStudioAssetTable(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err)
return message.toLowerCase().includes('no such table')
}
function toStudioUnityAssetFile(row: StudioUnityAssetFileRow): StudioUnityAssetFile | null {
if (row.platform !== 'windows' && row.platform !== 'android') return null
if (row.kind !== 'main' && row.kind !== 'stripped') return null
return {
unityAssetId: row.unity_asset_id,
platform: row.platform,
kind: row.kind,
filename: row.filename,
sha256: row.sha256,
byteLength: row.byte_length,
r2Key: row.r2_key,
}
}
/** Every stored file for the given unity asset ids. An empty id list reads nothing. */
export async function listStudioUnityAssetFiles(
db: D1Database,
unityAssetIds: string[]
): Promise<StudioUnityAssetFile[]> {
const ids = [...new Set(unityAssetIds.filter((id) => id !== ''))]
if (ids.length === 0) return []
const pages = await Promise.all(
chunkForBinds(ids).map((chunk) =>
db
.prepare(
`SELECT unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key
FROM studio_unity_asset_file
WHERE unity_asset_id IN (${bindPlaceholders(chunk)})`
)
.bind(...chunk)
.all<StudioUnityAssetFileRow>()
)
)
return pages
.flatMap((page) => page.results)
.map(toStudioUnityAssetFile)
.filter((file): file is StudioUnityAssetFile => file !== null)
}
/**
* The one file stored under `filename`. Null when none matches, and also when
* more than one row shares the name: serving either would hand the client the
* wrong room's bundle.
*/
export async function findStudioUnityAssetByFilename(
db: D1Database,
filename: string
): Promise<StudioUnityAssetFile | null> {
const { results } = await db
.prepare(
`SELECT unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key
FROM studio_unity_asset_file WHERE filename = ?1`
)
.bind(filename)
.all<StudioUnityAssetFileRow>()
if (results.length !== 1) return null
return toStudioUnityAssetFile(results[0]!)
}
export interface StudioUnityAssetRecord {
unityAssetId: string
/** 0 when the cloud-build row is gone. The route can fall back to the save's author. */
createdByAccountId: number
files: StudioUnityAssetFile[]
/** Every stored bundle of the asset, stripped ones included. */
builds: UnityAssetBuild[]
}
/**
* One unity asset and the account that built it. Null when no files are stored.
* A missing cloud-build table leaves `createdByAccountId` at 0; a missing file
* table throws, and {@link isMissingStudioAssetTable} tells the caller apart.
* One unity asset and the account that built it. Null when no bundle is stored. A
* missing cloud-build table leaves `createdByAccountId` at 0.
*/
export async function getStudioUnityAsset(
db: D1Database,
unityAssetId: string
): Promise<StudioUnityAssetRecord | null> {
const files = await listStudioUnityAssetFiles(db, [unityAssetId])
if (files.length === 0) return null
const builds = await listUnityAssetBuilds(db, [unityAssetId])
if (builds.length === 0) return null
let createdByAccountId = 0
try {
const row = await db
@@ -209,23 +111,5 @@ export async function getStudioUnityAsset(
} catch (err) {
if (!isMissingStudioAssetTable(err)) throw err
}
return { unityAssetId, createdByAccountId, files }
}
/**
* The main bundles of these files, Windows then Android. Stripped bundles stay
* in storage and are left off this list: a client that loads every entry would
* otherwise replace the scene with the stripped one.
*/
export function bakedStudioUnityAssets(files: StudioUnityAssetFile[]): BakedStudioUnityAsset[] {
return files
.filter((file) => file.kind === 'main')
.map((file) => ({
UnityAssetId: file.unityAssetId,
Target: studioAssetTarget(file.platform),
Version: STUDIO_ASSET_VERSION,
Filename: file.filename,
Hash: sha256HexToBase64(file.sha256),
}))
.sort((a, b) => a.Target - b.Target || a.Filename.localeCompare(b.Filename))
return { unityAssetId, createdByAccountId, builds }
}
+118 -28
View File
@@ -3,42 +3,54 @@
*
* `unity_asset` holds one row per BUILD of a unity asset: the asset's GUID, the `Target`
* it was built for (0 Windows, 2 Android/Quest — the same numbers a custom avatar's
* `unityAssetTarget` uses), its `Version`, and the storage blob the client downloads
* (`Filename`, a `<date>/<guid>` path, and its base64 SHA-256 `Hash`). The client asks for
* these in bulk from `POST /unity_assets/baked/bulk` (the `rooms` worker) while loading a
* room — the step Player.log times as `AssetBundle_Download` — and downloads `Filename`
* from the CDN; a lookup that answers no row, or a row without a `Filename`, simply
* downloads nothing, with no error anywhere.
* `unityAssetTarget` uses), its `Version`, the `Kind` of bundle (`main`, the scene the
* client loads; `stripped`, the editor-only variant the Studio upload also stores), and
* the storage blob the client downloads (`Filename`, fetched as `/room/<Filename>` straight
* from the shared `recflare-cdn` bucket, and its base64 SHA-256 `Hash`).
*
* This is a separate store from the Rec Room Studio builds in `studio_unity_asset_file`
* (see studio-unity-assets.ts), which the studio worker writes per platform and kind and
* which `GET /rooms/{id}` folds onto `CurrentSave.UnitySubAssets`. Nothing writes this
* table yet; it is the record the bulk lookup reads.
* Two things read it. The client asks for main bundles in bulk from
* `POST /unity_assets/baked/bulk` (the `rooms` worker) while loading a room — the step
* Player.log times as `AssetBundle_Download` — and `GET /rooms/{id}` folds the same main
* bundles onto `CurrentSave.UnitySubAssets`; either way the client then downloads
* `/room/<Filename>` from the bucket, and a lookup that answers no row,
* or a row without a `Filename`, simply downloads nothing, with no error anywhere.
*
* The `rooms` worker owns the table and its migration — see
* apps/rooms/migrations/0024_unity_asset.sql.
* The `studio` worker writes it: a Rec Room Studio build posts a Windows + Android bundle
* pair (plus optional stripped ones), stored here at {@link STUDIO_ASSET_VERSION} with the
* cloud-build record in `studio_cloud_build` (see studio-unity-assets.ts).
*
* The `rooms` worker owns the table and its migrations — see
* apps/rooms/migrations/0024_unity_asset.sql and 0026_unity_asset_kind.sql.
*/
import { bindPlaceholders, chunkForBinds } from './d1-binds'
/** Schema DDL (mirror of apps/rooms/migrations/0024_unity_asset.sql) — tests apply this. */
/** Schema DDL (mirror of apps/rooms/migrations/0026_unity_asset_kind.sql) — tests apply this. */
export const UNITY_ASSET_SCHEMA_DDL: string[] = [
// A unity asset is built once per target, and may be rebuilt at a new version, so the
// three together name a row. `unity_asset_id` is stored lowercase: a GUID's case is not
// part of its identity and the client is not consistent about it.
// A unity asset is built once per target and kind, and may be rebuilt at a new version,
// so the four together name a row. `unity_asset_id` is stored lowercase: a GUID's case
// is not part of its identity and the client is not consistent about it.
`CREATE TABLE IF NOT EXISTS unity_asset (
unity_asset_id TEXT NOT NULL,
target INTEGER NOT NULL,
version INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'main',
filename TEXT NOT NULL,
hash TEXT NOT NULL,
PRIMARY KEY (unity_asset_id, target, version)
PRIMARY KEY (unity_asset_id, target, version, kind)
)`,
]
/**
* Which bundle of a build a row is. `main` is the scene the game loads; `stripped` is the
* editor-only variant Rec Room Studio uploads beside it, stored but never listed to the
* game — a client that loaded every row would replace the scene with the stripped one.
*/
export type UnityAssetKind = 'main' | 'stripped'
/**
* One baked bundle as the client reads it — its five-key DTO, in this order. An element of
* the bare array `POST /unity_assets/baked/bulk` answers.
* the bare array `POST /unity_assets/baked/bulk` answers, and of a save's `UnitySubAssets`.
*/
export interface BakedUnityAsset {
/** GUID. */
@@ -46,16 +58,22 @@ export interface BakedUnityAsset {
/** 0 Windows, 2 Android/Quest. */
Target: number
Version: number
/** The storage blob path the client downloads, `<date>/<guid>`. */
/** The storage blob the client downloads, as `/room/<Filename>` from the bucket. */
Filename: string
/** Base64 SHA-256 of the blob. */
Hash: string
}
/** One stored row: a baked bundle and which kind of bundle it is. */
export interface UnityAssetBuild extends BakedUnityAsset {
Kind: UnityAssetKind
}
interface UnityAssetRow {
unity_asset_id: string
target: number
version: number
kind: string
filename: string
hash: string
}
@@ -68,19 +86,54 @@ const toBakedUnityAsset = (row: UnityAssetRow): BakedUnityAsset => ({
Hash: row.hash,
})
/** Record a build. Replaces the row for the same (asset, target, version). */
export async function putUnityAsset(db: D1Database, asset: BakedUnityAsset): Promise<void> {
await db
const toUnityAssetBuild = (row: UnityAssetRow): UnityAssetBuild => ({
...toBakedUnityAsset(row),
Kind: row.kind === 'stripped' ? 'stripped' : 'main',
})
/**
* The statement that records a build, for callers that batch it with other writes.
* Replaces the row for the same (asset, target, version, kind).
*/
export function unityAssetUpsert(
db: D1Database,
asset: BakedUnityAsset,
kind: UnityAssetKind = 'main'
): D1PreparedStatement {
return db
.prepare(
`INSERT OR REPLACE INTO unity_asset (unity_asset_id, target, version, filename, hash)
VALUES (?1, ?2, ?3, ?4, ?5)`
`INSERT OR REPLACE INTO unity_asset (unity_asset_id, target, version, kind, filename, hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`
)
.bind(asset.UnityAssetId.toLowerCase(), asset.Target, asset.Version, asset.Filename, asset.Hash)
.bind(
asset.UnityAssetId.toLowerCase(),
asset.Target,
asset.Version,
kind,
asset.Filename,
asset.Hash
)
}
/** Record a build. Replaces the row for the same (asset, target, version, kind). */
export async function putUnityAsset(
db: D1Database,
asset: BakedUnityAsset,
kind: UnityAssetKind = 'main'
): Promise<void> {
await unityAssetUpsert(db, asset, kind).run()
}
/** Forget every build of `unityAssetId`, all targets and kinds. The blobs stay in R2. */
export async function deleteUnityAssetBuilds(db: D1Database, unityAssetId: string): Promise<void> {
await db
.prepare('DELETE FROM unity_asset WHERE unity_asset_id = ?1')
.bind(unityAssetId.toLowerCase())
.run()
}
/**
* The baked bundles for `unityAssetIds` on `target` — one entry per asset that has one, in
* The main bundles for `unityAssetIds` on `target` — one entry per asset that has one, in
* the order asked, assets with no build left out. An asset built more than once is served
* ONCE: the build at the requested `version` when there is one, otherwise the newest. The
* request's version is a preference, not a filter, because the one capture pairing the two
@@ -102,8 +155,9 @@ export async function getBakedUnityAssets(
chunkForBinds(ids, 2).map((chunk) =>
db
.prepare(
`SELECT unity_asset_id, target, version, filename, hash FROM unity_asset
WHERE target = ?1 AND unity_asset_id IN (${bindPlaceholders(chunk, 2)})
`SELECT unity_asset_id, target, version, kind, filename, hash FROM unity_asset
WHERE target = ?1 AND kind = 'main'
AND unity_asset_id IN (${bindPlaceholders(chunk, 2)})
ORDER BY (version = ?2) DESC, version DESC`
)
.bind(target, version, ...chunk)
@@ -117,3 +171,39 @@ export async function getBakedUnityAssets(
}
return ids.flatMap((id) => best.get(id) ?? [])
}
/**
* Every stored build of the given unity assets — all targets, versions and kinds. Ids are
* matched case-insensitively and served as stored (lowercase). An empty id list reads
* nothing.
*/
export async function listUnityAssetBuilds(
db: D1Database,
unityAssetIds: string[]
): Promise<UnityAssetBuild[]> {
const ids = [...new Set(unityAssetIds.filter((id) => id !== '').map((id) => id.toLowerCase()))]
if (ids.length === 0) return []
const pages = await Promise.all(
chunkForBinds(ids).map((chunk) =>
db
.prepare(
`SELECT unity_asset_id, target, version, kind, filename, hash FROM unity_asset
WHERE unity_asset_id IN (${bindPlaceholders(chunk)})`
)
.bind(...chunk)
.all<UnityAssetRow>()
)
)
return pages.flatMap((page) => page.results).map(toUnityAssetBuild)
}
/**
* The main bundles among `builds` as a save lists them: Windows then Android, the client's
* five-key DTO. Stripped bundles are left off — see {@link UnityAssetKind}.
*/
export function bakedUnityAssets(builds: UnityAssetBuild[]): BakedUnityAsset[] {
return builds
.filter((build) => build.Kind === 'main')
.map(({ Kind: _kind, ...baked }) => baked)
.sort((a, b) => a.Target - b.Target || a.Filename.localeCompare(b.Filename))
}