[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
+4 -3
View File
@@ -18,9 +18,10 @@ Objects are keyed by prefix — `sigs/` (anti-cheat signatures), `room/` (saved
scenes, and room images by their bare `ImageName`), `invention/` (invention data), `data/` scenes, and room images by their bare `ImageName`), `invention/` (invention data), `data/`
(generic client uploads), `avatar/` (custom avatar item assetbundles) — and served as `application/octet-stream`; the worker never interprets what it hands back. (generic client uploads), `avatar/` (custom avatar item assetbundles) — and served as `application/octet-stream`; the worker never interprets what it hands back.
`/unityasset/{filename}` is the exception: a Rec Room Studio room bundle is stored under `/unityasset/{filename}` is the exception: a Rec Room Studio room bundle is stored under
`studio-room-bundles/`, and the route looks the filename up in `studio_unity_asset_file` `studio/<roomId>/` (the game's own `unity/<roomId>/` layout, under another prefix), its key
(the studio worker owns that table) before streaming the same way. `HEAD` on that path is recorded as the filename of a `unity_asset` row (the rooms worker owns that table; the
200 when exactly one bundle has the name. studio worker writes the rows), and the route serves only a filename that table records.
`HEAD` on that path is 200 when a bundle is stored under the name.
Reads are unauthenticated: a caller needs the exact key, which only comes from an Reads are unauthenticated: a caller needs the exact key, which only comes from an
authenticated call to another worker. authenticated call to another worker.
+20 -23
View File
@@ -354,10 +354,11 @@ const app = new Hono<App>()
) )
// Rec Room Studio room bundles. The filename is the one a room save or // Rec Room Studio room bundles. The filename is the one a room save or
// `GET …/unityasset` advertises. The bytes live under the key the studio upload // `GET …/unityasset` advertises — the R2 key itself (`studio/<roomId>/…`), as
// wrote (`studio-room-bundles/…`), which is not the URL, so this looks the name // `unity_asset.filename` records it. Only a recorded filename is served, so this
// up. Reads stay unauthenticated, same as `/avatar/` and `/room/`. HEAD is the // checks the table before streaming the key. Reads stay unauthenticated, same as
// editor's "already uploaded?" check: 200 exists, 404 does not. // `/avatar/` and `/room/`. HEAD is the editor's "already uploaded?" check: 200
// exists, 404 does not.
.on( .on(
'HEAD', 'HEAD',
'/unityasset/:filename{.+}', '/unityasset/:filename{.+}',
@@ -365,7 +366,7 @@ const app = new Hono<App>()
tags: ['Assets'], tags: ['Assets'],
summary: 'Check that a Rec Room Studio room bundle exists', summary: 'Check that a Rec Room Studio room bundle exists',
description: [ description: [
'200 when `filename` names exactly one stored Studio bundle, 404 otherwise. The', '200 when `filename` names a stored Studio bundle, 404 otherwise. The',
'Studio editor treats this HEAD as “already uploaded?” before it downloads the bytes', 'Studio editor treats this HEAD as “already uploaded?” before it downloads the bytes',
'with GET. No body.', 'with GET. No body.',
].join(' '), ].join(' '),
@@ -386,12 +387,9 @@ const app = new Hono<App>()
description: [ description: [
'Streams the Studio asset bundle stored for `filename`. The name comes from a', 'Streams the Studio asset bundle stored for `filename`. The name comes from a',
'room save’s `UnitySubAssets` (or from `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`', 'room save’s `UnitySubAssets` (or from `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`',
'on the rooms worker). The object itself lives at the R2 key the studio upload', 'on the rooms worker) and is the R2 key the studio upload stored the bytes under.',
'recorded, so this route looks the filename up rather than using it as the key.', 'Only a filename recorded in `unity_asset` is served: anything else is a 404, as is',
'', 'a missing table. The worker does not interpret the bytes.',
'A filename that matches more than one stored bundle is a 404, so two rooms that',
'uploaded the same basename cannot be served as each other. A missing studio table',
'is the same 404. The worker does not interpret the bytes.',
].join(' '), ].join(' '),
parameters: [ parameters: [
keyParam('filename', 'The bundle filename from the room save.', true), keyParam('filename', 'The bundle filename from the room save.', true),
@@ -403,20 +401,19 @@ const app = new Hono<App>()
) )
/** /**
* The R2 key for a Studio bundle filename. Null when the name is unknown, ambiguous, * The R2 key for a Studio bundle filename — the filename itself, once `unity_asset` says
* or the studio tables have not been migrated yet. * a build is stored under it. Null when no build is, or the table has not been migrated
* yet (the rooms worker owns it).
*/ */
async function unityAssetR2Key(db: D1Database, filename: string): Promise<string | null> { async function unityAssetR2Key(db: D1Database, filename: string): Promise<string | null> {
try { try {
// Same lookup as `@repo/domain` `findStudioUnityAssetByFilename`. Inlined so this // Inlined rather than imported so this worker does not take a dependency on the
// worker does not take a dependency on the domain package. More than one row is a // domain package.
// miss: two rooms must not be served each other's bundle. const row = await db
const { results } = await db .prepare(`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`)
.prepare(`SELECT r2_key FROM studio_unity_asset_file WHERE filename = ?1`)
.bind(filename) .bind(filename)
.all<{ r2_key: string }>() .first<{ stored: number }>()
if (results.length !== 1) return null return row ? filename : null
return results[0]!.r2_key
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
if (message.toLowerCase().includes('no such table')) return null if (message.toLowerCase().includes('no such table')) return null
@@ -476,8 +473,8 @@ app.get(
'`application/octet-stream`; the worker never interprets what it hands back. Reads', '`application/octet-stream`; the worker never interprets what it hands back. Reads',
'are unauthenticated — a caller needs the exact key, which only comes from an', 'are unauthenticated — a caller needs the exact key, which only comes from an',
'authenticated call to another worker. `/unityasset/{filename}` is a Studio room', 'authenticated call to another worker. `/unityasset/{filename}` is a Studio room',
'bundle: the filename is looked up in `studio_unity_asset_file` and the stored R2', 'bundle: the filename is the R2 key, streamed once `unity_asset` records it.',
'key is streamed. `HEAD` on that path is 200 when exactly one bundle has the name.', '`HEAD` on that path is 200 when a bundle is stored under the name.',
'', '',
'This worker only READS. Uploads go through the `storage` worker, which writes the', 'This worker only READS. Uploads go through the `storage` worker, which writes the',
'same bucket, and images are served by `img` rather than from here.', 'same bucket, and images are served by `img` rather than from here.',
+4 -3
View File
@@ -6,9 +6,10 @@ export type Env = SharedHonoEnv & {
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens // with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
// signed by `auth` verify here. // signed by `auth` verify here.
JWT_SECRET: SecretsStoreSecret JWT_SECRET: SecretsStoreSecret
// Shared `recflare` database, read-only here. Studio bundle downloads resolve a // Shared `recflare` database, read-only here. Studio bundle downloads check that
// public filename to its R2 key through `studio_unity_asset_file`. The studio // `unity_asset` records the requested filename (which is the R2 key) before
// worker owns that table's migration; this binding has no migrations_dir. // streaming it. The rooms worker owns that table's migration; this binding has no
// migrations_dir.
DB: D1Database DB: D1Database
// R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and // R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and
// room build data under `room/<name>`. // room build data under `room/<name>`.
+25 -22
View File
@@ -286,33 +286,35 @@ describe('cdn endpoints', () => {
expect(res.status).toBe(404) expect(res.status).toBe(404)
}) })
test('GET and HEAD /unityasset/:filename stream one stored studio bundle', async () => { test('GET and HEAD /unityasset/:filename stream a stored studio bundle', async () => {
await env.DB.prepare('DROP TABLE IF EXISTS studio_unity_asset_file').run() await env.DB.prepare('DROP TABLE IF EXISTS unity_asset').run()
const absent = await exports.default.fetch(`${ORIGIN}/unityasset/not-stored.assetbundle`) const absent = await exports.default.fetch(`${ORIGIN}/unityasset/not-stored.assetbundle`)
expect(absent.status).toBe(404) expect(absent.status).toBe(404)
// The rooms worker's table (UNITY_ASSET_SCHEMA_DDL in @repo/domain), inlined like
// the route's own lookup is.
await env.DB.prepare( await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS studio_unity_asset_file ( `CREATE TABLE IF NOT EXISTS unity_asset (
unity_asset_id TEXT NOT NULL, unity_asset_id TEXT NOT NULL,
platform TEXT NOT NULL, target INTEGER NOT NULL,
kind TEXT NOT NULL, version INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'main',
filename TEXT NOT NULL, filename TEXT NOT NULL,
sha256 TEXT NOT NULL, hash TEXT NOT NULL,
byte_length INTEGER NOT NULL, PRIMARY KEY (unity_asset_id, target, version, kind)
r2_key TEXT NOT NULL,
PRIMARY KEY (unity_asset_id, platform, kind)
)` )`
).run() ).run()
const filename = 'abc111.windows.main.assetbundle' // The filename IS the R2 key.
const key = `studio-room-bundles/asset-a/windows/main/${filename}` const filename = 'studio/20930/abc111.windows.main.assetbundle'
const insert = (unityAssetId: string, r2Key: string) => await env.DB.prepare(
env.DB.prepare( `INSERT INTO unity_asset (unity_asset_id, target, version, kind, filename, hash)
`INSERT INTO studio_unity_asset_file VALUES ('asset-a', 0, 1, 'main', ?1, 'n2SnR+G5fxMfq7a0Rylsm28CAeefs8U1bmx36JtqgGo=')`
(unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key) )
VALUES (?1, 'windows', 'main', ?2, 'ab', 4, ?3)` .bind(filename)
).bind(unityAssetId, filename, r2Key) .run()
await insert('asset-a', key).run() await env.CDN_ASSETS.put(filename, new Uint8Array([1, 2, 3, 4]))
await env.CDN_ASSETS.put(key, new Uint8Array([1, 2, 3, 4])) // A key in the bucket that no row records is not served.
await env.CDN_ASSETS.put('studio/20930/unrecorded.assetbundle', new Uint8Array([9]))
const res = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`) const res = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
@@ -325,10 +327,11 @@ describe('cdn endpoints', () => {
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0)) expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404) expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
expect(
(await exports.default.fetch(`${ORIGIN}/unityasset/studio/20930/unrecorded.assetbundle`))
.status
).toBe(404)
expect((await exports.default.fetch(`${ORIGIN}/unityasset/a..b`)).status).toBe(400) expect((await exports.default.fetch(`${ORIGIN}/unityasset/a..b`)).status).toBe(400)
await insert('asset-b', `studio-room-bundles/asset-b/windows/main/${filename}`).run()
expect((await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`)).status).toBe(404)
}) })
test('GET /openapi.json documents every route', async () => { test('GET /openapi.json documents every route', async () => {
@@ -0,0 +1,26 @@
-- `unity_asset` gains a `kind`: Rec Room Studio builds store a `main` bundle (the scene the
-- client loads) and, optionally, a `stripped` editor-only bundle per target, and both are
-- rows here now that the studio worker writes this table instead of its own
-- `studio_unity_asset_file` (dropped by the studio migration 0002). The studio rows map
-- platform → target (windows 0, android 2), the R2 key → `filename` and the base64
-- SHA-256 → `hash`.
--
-- Generated from packages/domain/src/unity-assets-db.ts (UNITY_ASSET_SCHEMA_DDL) — keep in
-- sync. `kind` joins the primary key, which SQLite cannot alter in place, so the rows are
-- copied into a new table and KEPT: every existing row is a main bundle.
CREATE TABLE unity_asset_new (
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, kind)
);
INSERT INTO unity_asset_new (unity_asset_id, target, version, kind, filename, hash)
SELECT unity_asset_id, target, version, 'main', filename, hash FROM unity_asset;
DROP TABLE unity_asset;
ALTER TABLE unity_asset_new RENAME TO unity_asset;
+13 -22
View File
@@ -8,6 +8,7 @@ import {
applyRoomTagEdit, applyRoomTagEdit,
attachStudioUnityAssets, attachStudioUnityAssets,
autocompleteRoomSearch, autocompleteRoomSearch,
bakedUnityAssets,
banPlayerFromRoom, banPlayerFromRoom,
canManageRoom, canManageRoom,
canManageRoomById, canManageRoomById,
@@ -53,7 +54,6 @@ import {
getVisitedRooms, getVisitedRooms,
incrementRoomExperience, incrementRoomExperience,
inviteRoomRole, inviteRoomRole,
isMissingStudioAssetTable,
isPlayerBannedFromRoom, isPlayerBannedFromRoom,
isRoomOwner, isRoomOwner,
MessageType, MessageType,
@@ -78,9 +78,6 @@ import {
setRoomProgression, setRoomProgression,
setRoomRole, setRoomRole,
setSubRoomPermissions, setSubRoomPermissions,
sha256HexToBase64,
STUDIO_ASSET_VERSION,
studioAssetTarget,
toggleCheer, toggleCheer,
toggleFavorite, toggleFavorite,
transferRoomOwnership, transferRoomOwnership,
@@ -3819,33 +3816,27 @@ const app = new Hono<App>()
const save = saves.find((row) => row.UnityAssetId === unityAssetId) const save = saves.find((row) => row.UnityAssetId === unityAssetId)
if (!save) return c.notFound() if (!save) return c.notFound()
let asset const asset = await getStudioUnityAsset(c.env.DB, unityAssetId)
try {
asset = await getStudioUnityAsset(c.env.DB, unityAssetId)
} catch (err) {
if (isMissingStudioAssetTable(err)) return c.notFound()
throw err
}
if (!asset) return c.notFound() if (!asset) return c.notFound()
const mains = asset.files // Main bundles, Windows first; the Windows one (or the first there is) supplies
.filter((file) => file.kind === 'main') // the top-level `filename`/`hash`.
.sort((a, b) => studioAssetTarget(a.platform) - studioAssetTarget(b.platform)) const mains = bakedUnityAssets(asset.builds)
const source = mains.find((file) => file.platform === 'windows') ?? mains[0] const source = mains.find((build) => build.Target === 0) ?? mains[0]
if (!source) return c.notFound() if (!source) return c.notFound()
const savedBy = typeof save.SavedByAccountId === 'number' ? save.SavedByAccountId : 0 const savedBy = typeof save.SavedByAccountId === 'number' ? save.SavedByAccountId : 0
return c.json({ return c.json({
unityAssetId: asset.unityAssetId, unityAssetId: asset.unityAssetId,
createdByAccountId: asset.createdByAccountId || savedBy, createdByAccountId: asset.createdByAccountId || savedBy,
bakedUnityAssets: mains.map((file) => ({ bakedUnityAssets: mains.map((build) => ({
unityAssetId: file.unityAssetId, unityAssetId: build.UnityAssetId,
target: studioAssetTarget(file.platform), target: build.Target,
version: STUDIO_ASSET_VERSION, version: build.Version,
filename: file.filename, filename: build.Filename,
})), })),
filename: source.filename, filename: source.Filename,
hash: sha256HexToBase64(source.sha256), hash: source.Hash,
}) })
} }
) )
+21 -26
View File
@@ -24,8 +24,7 @@ import {
ROOM_SCHEMA_DDL, ROOM_SCHEMA_DDL,
ROOM_XP_SCHEMA_DDL, ROOM_XP_SCHEMA_DDL,
seedRoomWithSubRooms, seedRoomWithSubRooms,
sha256HexToBase64, STUDIO_CLOUD_BUILD_SCHEMA_DDL,
STUDIO_UNITY_ASSET_SCHEMA_DDL,
SUBROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL, UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain' } from '@repo/domain'
@@ -6006,17 +6005,16 @@ describe('rooms endpoints', () => {
}) })
}) })
// A Rec Room Studio build is a unity asset on the save. The game loads the scene from // A Rec Room Studio build is a unity asset on the save: `unity_asset` rows the studio
// `CurrentSave.DataBlob` and the bundles from `UnitySubAssets`. Maker-pen saves stay // worker wrote. The game loads the scene from `CurrentSave.DataBlob` and the bundles from
// empty arrays, and a missing studio table must not fail the room read. // `UnitySubAssets`. Maker-pen saves stay empty arrays, and a missing studio cloud-build
// table must not fail the room read.
describe('studio room bundles', () => { describe('studio room bundles', () => {
const ASSET = '11111111-2222-4333-8444-555555555555' const ASSET = '11111111-2222-4333-8444-555555555555'
const WIN_HEX = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' const winName = publicStudioBundleFilename(9901, ASSET, 'windows', 'main')
const ANDROID_HEX = 'ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb' const androidName = publicStudioBundleFilename(9901, ASSET, 'android', 'main')
const winName = publicStudioBundleFilename(ASSET, 'windows', 'main') const winHash = '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='
const androidName = publicStudioBundleFilename(ASSET, 'android', 'main') const androidHash = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs='
const winHash = sha256HexToBase64(WIN_HEX)
const androidHash = sha256HexToBase64(ANDROID_HEX)
const save = (unityAssetId?: string) => ({ const save = (unityAssetId?: string) => ({
UnitySubAssets: [], UnitySubAssets: [],
@@ -6038,8 +6036,8 @@ describe('studio room bundles', () => {
}) })
async function reset() { async function reset() {
await env.DB.prepare('DROP TABLE IF EXISTS studio_unity_asset_file').run()
await env.DB.prepare('DROP TABLE IF EXISTS studio_cloud_build').run() await env.DB.prepare('DROP TABLE IF EXISTS studio_cloud_build').run()
await env.DB.prepare('DELETE FROM unity_asset WHERE unity_asset_id = ?1').bind(ASSET).run()
await env.DB.prepare('DELETE FROM subroom_save WHERE sub_room_id IN (99011, 99021)').run() await env.DB.prepare('DELETE FROM subroom_save WHERE sub_room_id IN (99011, 99021)').run()
await env.DB.prepare('DELETE FROM subroom WHERE room_id IN (9901, 9902)').run() await env.DB.prepare('DELETE FROM subroom WHERE room_id IN (9901, 9902)').run()
await env.DB.prepare('DELETE FROM room WHERE room_id IN (9901, 9902)').run() await env.DB.prepare('DELETE FROM room WHERE room_id IN (9901, 9902)').run()
@@ -6080,7 +6078,7 @@ describe('studio room bundles', () => {
} }
async function storeFiles() { async function storeFiles() {
for (const sql of STUDIO_UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(sql).run() for (const sql of STUDIO_CLOUD_BUILD_SCHEMA_DDL) await env.DB.prepare(sql).run()
await env.DB.prepare( await env.DB.prepare(
`INSERT INTO studio_cloud_build `INSERT INTO studio_cloud_build
(cloud_build_id, room_id, sub_room_id, sub_room_data_save_id, unity_asset_id, (cloud_build_id, room_id, sub_room_id, sub_room_data_save_id, unity_asset_id,
@@ -6090,22 +6088,19 @@ describe('studio room bundles', () => {
) )
.bind(ASSET) .bind(ASSET)
.run() .run()
const insert = async (platform: string, kind: string, filename: string, sha256: string) => { const insert = (target: number, kind: 'main' | 'stripped', filename: string, hash: string) =>
await env.DB.prepare( putUnityAsset(
`INSERT INTO studio_unity_asset_file env.DB,
(unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key) { UnityAssetId: ASSET, Target: target, Version: 1, Filename: filename, Hash: hash },
VALUES (?1, ?2, ?3, ?4, ?5, 4, ?6)` kind
) )
.bind(ASSET, platform, kind, filename, sha256, `studio-room-bundles/${filename}`) await insert(0, 'main', winName, winHash)
.run() await insert(2, 'main', androidName, androidHash)
}
await insert('windows', 'main', winName, WIN_HEX)
await insert('android', 'main', androidName, ANDROID_HEX)
await insert( await insert(
'windows', 0,
'stripped', 'stripped',
publicStudioBundleFilename(ASSET, 'windows', 'stripped'), publicStudioBundleFilename(9901, ASSET, 'windows', 'stripped'),
WIN_HEX winHash
) )
} }
+7 -5
View File
@@ -7,7 +7,7 @@ page is WWW `/device`. This worker answers the editor's build list, stores a
locally built Windows and Android bundle pair, and answers the co-owner locally built Windows and Android bundle pair, and answers the co-owner
presence list. It does not serve the bundle bytes. A stored build is loaded from presence list. It does not serve the bundle bytes. A stored build is loaded from
Rooms (`CurrentSave.UnitySubAssets`, and `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`) Rooms (`CurrentSave.UnitySubAssets`, and `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`)
and from the CDN at `GET /unityasset/{filename}`. and from the bucket at `/room/{filename}`.
| Method | Path | Purpose | | Method | Path | Purpose |
| ------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -37,10 +37,12 @@ pnpm turbo deploy
### Migrate ### Migrate
This worker owns `studio_cloud_build` and `studio_unity_asset_file` on the shared This worker owns `studio_cloud_build` on the shared `recflare` database (`migrations/`,
`recflare` database (`migrations/`, tracked in its own `d1_migrations_studio` table). tracked in its own `d1_migrations_studio` table). The bundles themselves are rows of the
Deploying does not apply them; the cdn worker reads `studio_unity_asset_file` and rooms worker's `unity_asset` (one per platform and kind: target 0 Windows / 2 Android,
answers 404 for every Studio bundle until this has run. `studio/<roomId>/<name>.assetbundle` as `filename` — the bytes sit at `room/<filename>` in
the bucket, the prefix the client adds — base64 SHA-256 as `hash`), so a stored build needs
the rooms migrations too. Deploying does not apply either.
```sh ```sh
just migrate -F studio # remote just migrate -F studio # remote
@@ -0,0 +1,5 @@
-- A stored bundle is a row of the rooms worker's `unity_asset` now (its migration 0026
-- added the `kind` column this table carried): platform → target, r2_key → filename,
-- sha256 → hash. `studio_cloud_build` stays this worker's. Nothing is carried over.
DROP TABLE IF EXISTS studio_unity_asset_file;
+4 -3
View File
@@ -10,11 +10,12 @@ export type Env = SharedHonoEnv & {
JWT_SECRET: SecretsStoreSecret JWT_SECRET: SecretsStoreSecret
// Shared `recflare` D1. Room ownership lives in the `room` blob (the `rooms` // Shared `recflare` D1. Room ownership lives in the `room` blob (the `rooms`
// worker owns that schema) and who is standing where lives in `presence`. // worker owns that schema) and who is standing where lives in `presence`.
// This worker migrates its own cloud-build tables (`studio_cloud_build`, // This worker migrates its own cloud-build table (`studio_cloud_build`), writes
// `studio_unity_asset_file`) and only reads the room tables. // each bundle it stores as a row of the rooms worker's `unity_asset`, and only
// reads the room tables.
DB: D1Database DB: D1Database
// Shared `recflare-cdn` bucket. Local Studio builds land under // Shared `recflare-cdn` bucket. Local Studio builds land under
// `studio-room-bundles/`. The same bucket name the rooms and cdn workers bind. // `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
CDN_ASSETS: R2Bucket CDN_ASSETS: R2Bucket
} }
+38 -59
View File
@@ -1,36 +1,16 @@
import { import {
deleteUnityAssetBuilds,
getSubRoom, getSubRoom,
markRoomAsRecRoomStudio, markRoomAsRecRoomStudio,
publicStudioBundleFilename, publicStudioBundleFilename,
setSubRoomSaveUnityAssetId, setSubRoomSaveUnityAssetId,
STUDIO_ASSET_VERSION,
studioAssetTarget,
studioBundleR2Key,
unityAssetUpsert,
} from '@repo/domain' } from '@repo/domain'
/** Mirrors migrations/0001_studio_cloud_build.sql. Tests apply this; deploy uses the file. */ import type { StudioBundlePlatform, UnityAssetKind } from '@repo/domain'
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,
sub_room_id INTEGER NOT NULL,
sub_room_data_save_id INTEGER NOT NULL,
unity_asset_id TEXT NOT NULL,
created_by_account_id INTEGER NOT NULL,
started_at TEXT NOT NULL,
completed_at TEXT NOT NULL,
error TEXT
)`,
`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)
)`,
]
export interface CloudBuildRow { export interface CloudBuildRow {
cloudBuildId: string cloudBuildId: string
@@ -41,8 +21,8 @@ export interface CloudBuildRow {
createdByAccountId: number createdByAccountId: number
} }
export type BundlePlatform = 'windows' | 'android' export type BundlePlatform = StudioBundlePlatform
export type BundleKind = 'main' | 'stripped' export type BundleKind = UnityAssetKind
export interface LocalBundleFile { export interface LocalBundleFile {
platform: BundlePlatform platform: BundlePlatform
@@ -103,16 +83,22 @@ export async function listCloudBuilds(
return { results: results.map(toCloudBuild), totalResults: Number(total?.n ?? 0) } return { results: results.map(toCloudBuild), totalResults: Number(total?.n ?? 0) }
} }
async function sha256Hex(bytes: ArrayBuffer): Promise<string> { /** Base64 SHA-256 — the encoding `unity_asset.hash` (and every other bundle hash) uses. */
async function sha256Base64(bytes: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes) const digest = await crypto.subtle.digest('SHA-256', bytes)
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('') let binary = ''
for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte)
return btoa(binary)
} }
/** /**
* Store a PC-built Windows + Android pair as a cloud build that is already finished. * Store a PC-built Windows + Android pair as a cloud build that is already finished.
* The bytes go to R2. The build row points at the subroom's current save, that save's * The bytes go to R2 under `room/` and each bundle becomes a `unity_asset` row — target
* `UnityAssetId` is set to the new asset, and the room is marked as a Studio room the * 0 Windows / 2 Android, its kind, the `filename` the client prefixes with `/room/` to
* first time (`BecameRRStudioRoomAt`). * download it (so the R2 key is `room/<filename>`), the base64 SHA-256 as `hash`. The
* build row points at the subroom's current save, that save's `UnityAssetId` is set
* to the new asset, and the room is marked as a Studio room the first time
* (`BecameRRStudioRoomAt`).
* *
* Returns null when the subroom has no current save — the caller uploaded bundles * Returns null when the subroom has no current save — the caller uploaded bundles
* before Upload created one. * before Upload created one.
@@ -140,34 +126,30 @@ export async function storeLocalCloudBuild(
const stored = await Promise.all( const stored = await Promise.all(
files.map(async (file) => { files.map(async (file) => {
const sha256 = await sha256Hex(file.bytes) // The client downloads `/room/<filename>` from the bucket, so the stored name is
// The download route looks a bundle up by filename alone, so the stored // this build's, not the uploaded basename two rooms can share.
// name is this build's, not the uploaded basename two rooms can share. const filename = publicStudioBundleFilename(roomId, unityAssetId, file.platform, file.kind)
const filename = publicStudioBundleFilename(unityAssetId, file.platform, file.kind) const hash = await sha256Base64(file.bytes)
const r2Key = `studio-room-bundles/${unityAssetId}/${file.platform}/${file.kind}/${filename}` await bucket.put(studioBundleR2Key(filename), file.bytes)
await bucket.put(r2Key, file.bytes) return { ...file, filename, hash }
return { ...file, filename, sha256, r2Key }
}) })
) )
const discardObjects = () => Promise.all(stored.map((file) => bucket.delete(file.r2Key))) const discardObjects = () =>
Promise.all(stored.map((file) => bucket.delete(studioBundleR2Key(file.filename))))
try { try {
await db.batch([ await db.batch([
...stored.map((file) => ...stored.map((file) =>
db unityAssetUpsert(
.prepare( db,
`INSERT INTO studio_unity_asset_file {
(unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key) UnityAssetId: unityAssetId,
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)` Target: studioAssetTarget(file.platform),
) Version: STUDIO_ASSET_VERSION,
.bind( Filename: file.filename,
unityAssetId, Hash: file.hash,
file.platform, },
file.kind, file.kind
file.filename,
file.sha256,
file.bytes.byteLength,
file.r2Key
) )
), ),
db db
@@ -190,10 +172,7 @@ export async function storeLocalCloudBuild(
.prepare('DELETE FROM studio_cloud_build WHERE cloud_build_id = ?1') .prepare('DELETE FROM studio_cloud_build WHERE cloud_build_id = ?1')
.bind(cloudBuildId) .bind(cloudBuildId)
.run() .run()
await db await deleteUnityAssetBuilds(db, unityAssetId)
.prepare('DELETE FROM studio_unity_asset_file WHERE unity_asset_id = ?1')
.bind(unityAssetId)
.run()
await discardObjects() await discardObjects()
return null return null
} }
+35 -28
View File
@@ -1,9 +1,14 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test' import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, describe, expect, it } from 'vitest' import { beforeAll, describe, expect, it } from 'vitest'
import { PRESENCE_SCHEMA_DDL, ROOM_SCHEMA_DDL, setPresence, SUBROOM_SCHEMA_DDL } from '@repo/domain' import {
PRESENCE_SCHEMA_DDL,
import { STUDIO_CLOUD_BUILD_SCHEMA_DDL } from '../../local-builds' ROOM_SCHEMA_DDL,
setPresence,
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
SUBROOM_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import type { Env } from '../../context' import type { Env } from '../../context'
@@ -90,6 +95,8 @@ async function standIn(
beforeAll(async () => { beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET) await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
for (const stmt of STUDIO_CLOUD_BUILD_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of STUDIO_CLOUD_BUILD_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// `unity_asset` is the rooms worker's table; a build writes one row per bundle into it.
for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run()
}) })
it('response with hello world', async () => { it('response with hello world', async () => {
@@ -357,30 +364,33 @@ describe('local cloud builds', () => {
completed_at: first.value.startedAt, completed_at: first.value.startedAt,
}) })
// Each bundle is a `unity_asset` row: target 0 Windows / 2 Android, its kind, the R2
// key as the filename, and the base64 SHA-256 of the bytes as the hash.
const { results: files } = await env.DB.prepare( const { results: files } = await env.DB.prepare(
`SELECT platform, kind, filename, byte_length, r2_key `SELECT target, version, kind, filename, hash
FROM studio_unity_asset_file WHERE unity_asset_id = ?1 FROM unity_asset WHERE unity_asset_id = ?1
ORDER BY platform, kind` ORDER BY target, kind`
) )
.bind(first.value.unityAssetId) .bind(first.value.unityAssetId)
.all<{ .all<{ target: number; version: number; kind: string; filename: string; hash: string }>()
platform: string
kind: string
filename: string
byte_length: number
r2_key: string
}>()
const publicName = (platform: string, kind: string) => const publicName = (platform: string, kind: string) =>
`${first.value.unityAssetId.replace(/-/g, '')}.${platform}.${kind}.assetbundle` `studio/${LOCAL_ROOM}/${first.value.unityAssetId.replace(/-/g, '')}.${platform}.${kind}.assetbundle`
expect( expect(
files.map((file) => [file.platform, file.kind, file.filename, file.byte_length]) files.map((file) => [file.target, file.version, file.kind, file.filename, file.hash])
).toEqual([ ).toEqual([
['android', 'main', publicName('android', 'main'), 2], [0, 1, 'main', publicName('windows', 'main'), 'n2SnR+G5fxMfq7a0Rylsm28CAeefs8U1bmx36JtqgGo='],
['windows', 'main', publicName('windows', 'main'), 4], [
['windows', 'stripped', publicName('windows', 'stripped'), 3], 0,
1,
'stripped',
publicName('windows', 'stripped'),
'ZqZ1cVH47lXbEncWx+Pc4L6AdLZOIO2lQuXB5GypxB4=',
],
[2, 1, 'main', publicName('android', 'main'), 'xCUiEotJGT3ozUXY91ic1+CF5l8ThkDVfUSC5fcYliM='],
]) ])
const win = files.find((file) => file.platform === 'windows' && file.kind === 'main') const win = files.find((file) => file.target === 0 && file.kind === 'main')
const stored = await env.CDN_ASSETS.get(win!.r2_key) // The bytes sit under `room/` — the prefix the client adds to the filename.
const stored = await env.CDN_ASSETS.get(`room/${win!.filename}`)
expect(stored).not.toBeNull() expect(stored).not.toBeNull()
expect([...new Uint8Array(await stored!.arrayBuffer())]).toEqual([1, 2, 3, 4]) expect([...new Uint8Array(await stored!.arrayBuffer())]).toEqual([1, 2, 3, 4])
@@ -477,16 +487,13 @@ describe('local cloud builds', () => {
expect(created.success).toBe(true) expect(created.success).toBe(true)
const { results: files } = await env.DB.prepare( const { results: files } = await env.DB.prepare(
`SELECT platform, byte_length, r2_key FROM studio_unity_asset_file `SELECT target, filename FROM unity_asset
WHERE unity_asset_id = ?1 AND kind = 'main' ORDER BY platform` WHERE unity_asset_id = ?1 AND kind = 'main' ORDER BY target`
) )
.bind(created.value.unityAssetId) .bind(created.value.unityAssetId)
.all<{ platform: string; byte_length: number; r2_key: string }>() .all<{ target: number; filename: string }>()
expect(files.map((f) => [f.platform, f.byte_length])).toEqual([ expect(files.map((f) => f.target)).toEqual([0, 2])
['android', droidBytes.length], const win = await env.CDN_ASSETS.get(`room/${files[0]!.filename}`)
['windows', winBytes.length],
])
const win = await env.CDN_ASSETS.get(files[1]!.r2_key)
expect([...new Uint8Array(await win!.arrayBuffer())]).toEqual(winBytes) expect([...new Uint8Array(await win!.arrayBuffer())]).toEqual(winBytes)
}) })
}) })
+4 -3
View File
@@ -5,8 +5,9 @@
"compatibility_date": "2026-06-16", "compatibility_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
// Shared `recflare` D1. Room co-owners come off the `room` blob and who is in a // Shared `recflare` D1. Room co-owners come off the `room` blob and who is in a
// subroom comes off `presence` (other workers own those tables). This worker // subroom comes off `presence` (other workers own those tables), and a stored
// owns the cloud-build tables, migrated from `migrations/`; its own // bundle is a row of the rooms worker's `unity_asset`. This worker owns the
// cloud-build table, migrated from `migrations/`; its own
// migrations_table keeps that history separate from the other workers' // migrations_table keeps that history separate from the other workers'
// migrations on the shared database. The "local" placeholder is replaced with // migrations on the shared database. The "local" placeholder is replaced with
// RECFLARE_D1 at deploy time. // RECFLARE_D1 at deploy time.
@@ -20,7 +21,7 @@
} }
], ],
// Shared `recflare-cdn` R2 bucket (also bound by `rooms` / `cdn` / `storage`). // Shared `recflare-cdn` R2 bucket (also bound by `rooms` / `cdn` / `storage`).
// Local Studio asset bundles live here under `studio-room-bundles/`. // Local Studio asset bundles live here under `room/studio/<roomId>/`.
// bucket_name is a literal, so nothing is spliced at deploy time. // bucket_name is a literal, so nothing is spliced at deploy time.
"r2_buckets": [ "r2_buckets": [
{ {
+14 -23
View File
@@ -21,13 +21,9 @@ import { bindPlaceholders, chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds'
import { Accessibility, Role } from './enums' import { Accessibility, Role } from './enums'
import { countPlayersByRoom } from './presence-db' import { countPlayersByRoom } from './presence-db'
import { resyncInstanceCapacity } from './room-instance-db' import { resyncInstanceCapacity } from './room-instance-db'
import { import { bakedUnityAssets, listUnityAssetBuilds } from './unity-assets-db'
bakedStudioUnityAssets,
isMissingStudioAssetTable,
listStudioUnityAssetFiles,
} from './studio-unity-assets'
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). */ /** Schema DDL (mirror of the head migration schema, sans the seed INSERT). */
export const ROOM_SCHEMA_DDL: string[] = [ 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 * Fill `UnitySubAssets` on saves that point at a stored Studio build (rows of
* saves have no `UnityAssetId` and are left untouched, including their empty * `unity_asset`). Maker-pen saves have no `UnityAssetId` and are left untouched,
* arrays. A missing studio table is the same answer: the scene blob still loads, * including their empty arrays; so is a save whose asset has no stored build —
* and the bundle list stays empty until the migration exists. * the scene blob still loads, and the bundle list stays empty.
* *
* Only main bundles are listed. Stripped bundles stay in the bucket. * Only main bundles are listed. Stripped bundles stay in the bucket.
*/ */
@@ -2306,23 +2302,18 @@ export async function attachStudioUnityAssets(
), ),
] ]
if (ids.length === 0) return if (ids.length === 0) return
let files: StudioUnityAssetFile[] const builds = await listUnityAssetBuilds(db, ids)
try { // Rows come back with the id lowercased; a save may spell its GUID either way.
files = await listStudioUnityAssetFiles(db, ids) const byAsset = new Map<string, UnityAssetBuild[]>()
} catch (err) { for (const build of builds) {
if (isMissingStudioAssetTable(err)) return const list = byAsset.get(build.UnityAssetId) ?? []
throw err list.push(build)
} byAsset.set(build.UnityAssetId, list)
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)
} }
for (const save of saves) { for (const save of saves) {
const id = save.UnityAssetId const id = save.UnityAssetId
if (typeof id !== 'string') continue if (typeof id !== 'string') continue
const baked = bakedStudioUnityAssets(byAsset.get(id) ?? []) const baked = bakedUnityAssets(byAsset.get(id.toLowerCase()) ?? [])
if (baked.length === 0) continue if (baked.length === 0) continue
save.UnitySubAssets = baked 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 * The editor builds Windows and Android asset bundles on the creator's PC and posts them
* apply {@link STUDIO_UNITY_ASSET_SCHEMA_DDL} directly. A room load must keep * to the `studio` worker, which stores the bytes in the shared `recflare-cdn` bucket,
* working before that migration has been applied, so a missing table is * records each bundle as a row of `unity_asset` (see unity-assets-db.ts — the same table
* reported by {@link isMissingStudioAssetTable} and the caller leaves the save * the game's bulk lookup reads) and files the build itself in `studio_cloud_build`, the
* the way a maker-pen room already looks. * 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 * A room load must keep working before that migration has been applied, so a missing
* contains the unity asset id, because the download route looks the file up by * table is reported by {@link isMissingStudioAssetTable} and the caller falls back to
* filename alone and two rooms can otherwise both upload `win.assetbundle`. * 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 ( `CREATE TABLE IF NOT EXISTS studio_cloud_build (
cloud_build_id TEXT PRIMARY KEY, cloud_build_id TEXT PRIMARY KEY,
room_id INTEGER NOT NULL, 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 `CREATE INDEX IF NOT EXISTS idx_studio_cloud_build_room
ON studio_cloud_build (room_id, sub_room_id, started_at)`, 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 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 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 * A Studio bundle's `unity_asset.filename` — the name the client downloads, which it
* `CurrentSave`. `Target` uses the numbers the game sends as `unityAssetTarget` * prefixes with `/room/` (see {@link studioBundleR2Key}). Laid out like the official
* on a custom avatar: 0 is Windows, 2 is Android/Quest. * 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,
export interface BakedStudioUnityAsset { * and kind, so two rooms that both uploaded `win.assetbundle` cannot collide.
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.
*/ */
export function publicStudioBundleFilename( export function publicStudioBundleFilename(
roomId: number,
unityAssetId: string, unityAssetId: string,
platform: string, platform: StudioBundlePlatform,
kind: string kind: string
): string { ): string {
const id = unityAssetId.replace(/-/g, '').toLowerCase() 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: * Where the bytes behind a `unity_asset.filename` live in the shared `recflare-cdn`
* those are the only two platforms a local build stores. * 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 return platform === 'android' ? 2 : 0
} }
/** Base64 SHA-256, which is the hash encoding avatar and invention downloads use. */ /** True when D1 failed because the studio cloud-build table has not been migrated. */
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. */
export function isMissingStudioAssetTable(err: unknown): boolean { export function isMissingStudioAssetTable(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
return message.toLowerCase().includes('no such table') 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 { export interface StudioUnityAssetRecord {
unityAssetId: string unityAssetId: string
/** 0 when the cloud-build row is gone. The route can fall back to the save's author. */ /** 0 when the cloud-build row is gone. The route can fall back to the save's author. */
createdByAccountId: number 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. * One unity asset and the account that built it. Null when no bundle is stored. A
* A missing cloud-build table leaves `createdByAccountId` at 0; a missing file * missing cloud-build table leaves `createdByAccountId` at 0.
* table throws, and {@link isMissingStudioAssetTable} tells the caller apart.
*/ */
export async function getStudioUnityAsset( export async function getStudioUnityAsset(
db: D1Database, db: D1Database,
unityAssetId: string unityAssetId: string
): Promise<StudioUnityAssetRecord | null> { ): Promise<StudioUnityAssetRecord | null> {
const files = await listStudioUnityAssetFiles(db, [unityAssetId]) const builds = await listUnityAssetBuilds(db, [unityAssetId])
if (files.length === 0) return null if (builds.length === 0) return null
let createdByAccountId = 0 let createdByAccountId = 0
try { try {
const row = await db const row = await db
@@ -209,23 +111,5 @@ export async function getStudioUnityAsset(
} catch (err) { } catch (err) {
if (!isMissingStudioAssetTable(err)) throw err if (!isMissingStudioAssetTable(err)) throw err
} }
return { unityAssetId, createdByAccountId, files } return { unityAssetId, createdByAccountId, builds }
}
/**
* 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))
} }
+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` * `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 * 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 * `unityAssetTarget` uses), its `Version`, the `Kind` of bundle (`main`, the scene the
* (`Filename`, a `<date>/<guid>` path, and its base64 SHA-256 `Hash`). The client asks for * client loads; `stripped`, the editor-only variant the Studio upload also stores), and
* these in bulk from `POST /unity_assets/baked/bulk` (the `rooms` worker) while loading a * the storage blob the client downloads (`Filename`, fetched as `/room/<Filename>` straight
* room — the step Player.log times as `AssetBundle_Download` — and downloads `Filename` * from the shared `recflare-cdn` bucket, and its base64 SHA-256 `Hash`).
* from the CDN; a lookup that answers no row, or a row without a `Filename`, simply
* downloads nothing, with no error anywhere.
* *
* This is a separate store from the Rec Room Studio builds in `studio_unity_asset_file` * Two things read it. The client asks for main bundles in bulk from
* (see studio-unity-assets.ts), which the studio worker writes per platform and kind and * `POST /unity_assets/baked/bulk` (the `rooms` worker) while loading a room — the step
* which `GET /rooms/{id}` folds onto `CurrentSave.UnitySubAssets`. Nothing writes this * Player.log times as `AssetBundle_Download` — and `GET /rooms/{id}` folds the same main
* table yet; it is the record the bulk lookup reads. * 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 * The `studio` worker writes it: a Rec Room Studio build posts a Windows + Android bundle
* apps/rooms/migrations/0024_unity_asset.sql. * 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' 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[] = [ 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 // A unity asset is built once per target and kind, and may be rebuilt at a new version,
// three together name a row. `unity_asset_id` is stored lowercase: a GUID's case is not // so the four together name a row. `unity_asset_id` is stored lowercase: a GUID's case
// part of its identity and the client is not consistent about it. // is not part of its identity and the client is not consistent about it.
`CREATE TABLE IF NOT EXISTS unity_asset ( `CREATE TABLE IF NOT EXISTS unity_asset (
unity_asset_id TEXT NOT NULL, unity_asset_id TEXT NOT NULL,
target INTEGER NOT NULL, target INTEGER NOT NULL,
version INTEGER NOT NULL, version INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'main',
filename TEXT NOT NULL, filename TEXT NOT NULL,
hash 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 * 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 { export interface BakedUnityAsset {
/** GUID. */ /** GUID. */
@@ -46,16 +58,22 @@ export interface BakedUnityAsset {
/** 0 Windows, 2 Android/Quest. */ /** 0 Windows, 2 Android/Quest. */
Target: number Target: number
Version: 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 Filename: string
/** Base64 SHA-256 of the blob. */ /** Base64 SHA-256 of the blob. */
Hash: string Hash: string
} }
/** One stored row: a baked bundle and which kind of bundle it is. */
export interface UnityAssetBuild extends BakedUnityAsset {
Kind: UnityAssetKind
}
interface UnityAssetRow { interface UnityAssetRow {
unity_asset_id: string unity_asset_id: string
target: number target: number
version: number version: number
kind: string
filename: string filename: string
hash: string hash: string
} }
@@ -68,19 +86,54 @@ const toBakedUnityAsset = (row: UnityAssetRow): BakedUnityAsset => ({
Hash: row.hash, Hash: row.hash,
}) })
/** Record a build. Replaces the row for the same (asset, target, version). */ const toUnityAssetBuild = (row: UnityAssetRow): UnityAssetBuild => ({
export async function putUnityAsset(db: D1Database, asset: BakedUnityAsset): Promise<void> { ...toBakedUnityAsset(row),
await db 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( .prepare(
`INSERT OR REPLACE INTO unity_asset (unity_asset_id, target, version, filename, hash) `INSERT OR REPLACE INTO unity_asset (unity_asset_id, target, version, kind, filename, hash)
VALUES (?1, ?2, ?3, ?4, ?5)` 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() .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 * 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 * 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 * 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) => chunkForBinds(ids, 2).map((chunk) =>
db db
.prepare( .prepare(
`SELECT unity_asset_id, target, version, filename, hash FROM unity_asset `SELECT unity_asset_id, target, version, kind, filename, hash FROM unity_asset
WHERE target = ?1 AND unity_asset_id IN (${bindPlaceholders(chunk, 2)}) WHERE target = ?1 AND kind = 'main'
AND unity_asset_id IN (${bindPlaceholders(chunk, 2)})
ORDER BY (version = ?2) DESC, version DESC` ORDER BY (version = ?2) DESC, version DESC`
) )
.bind(target, version, ...chunk) .bind(target, version, ...chunk)
@@ -117,3 +171,39 @@ export async function getBakedUnityAssets(
} }
return ids.flatMap((id) => best.get(id) ?? []) 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))
}