From bd15e30bfc79653e1658db0dab6ebd4826d7d1e4 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 12 Oct 2026 21:18:51 -0400 Subject: [PATCH] [rooms] fix imported unity assets --- CLAUDE.md | 10 ++++++++++ packages/domain/src/rooms-db.ts | 13 ++++++++++++- packages/domain/src/studio-unity-assets.ts | 18 +++++++++++++++++- packages/domain/src/unity-assets-db.ts | 6 ++++-- 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3116350..82d7145 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,6 +287,16 @@ inconsistency here without checking the client first. other implementations set it at all, it names the start of a voice-chat timeout. Other implementations with timed bans keep their set-time private and serve the remainder on every read; `banned_at` here is the staff panel's audit trail, not the client's. +- `unity_asset` holds two kinds of bundle under one shape: Rec Room Studio builds (UGC, the + `studio` worker writes them, filenames `studio//…`) and FIRST-PARTY bundles captured + from the official game and loaded by hand (`unity//…`). Only the Studio ones may be + folded onto `CurrentSave.UnitySubAssets` (`rooms`: `GET /rooms/:id`, the save reads) — + `attachStudioUnityAssets` filters on `isStudioBundleFilename`. The 2025 client resolves a + first-party bundle through `POST /unity_assets/baked/bulk` (which serves both kinds); handed + it inline in the five-key `{ Target, Version, Filename, Hash }` shape it takes that instead, + cannot name the file (its decoder wants `UnityAsset`/`UnityAssetHash`), and the room loads + nothing. Every imported first-party room broke when the splice moved from the Studio-only + table to `unity_asset`, with every response looking correct. - Accessibility is sent as the `RoomAccessibility` enum NAME on `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 1fb13b1..d7eb473 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -21,6 +21,7 @@ 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 { isStudioBundleFilename } from './studio-unity-assets' import { bakedUnityAssets, listUnityAssetBuilds } from './unity-assets-db' import type { UnityAssetBuild } from './unity-assets-db' @@ -2303,6 +2304,14 @@ async function attachCurrentSaves( * 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 Studio (UGC) builds are folded in — rows whose filename passes + * {@link isStudioBundleFilename}. A FIRST-PARTY bundle loaded by hand under + * `unity//` shares the table but must stay out of this list: the 2025 client + * resolves it through `POST /unity_assets/baked/bulk` (which serves both kinds), and + * handed it inline in this five-key shape it stops asking, cannot name the file, and + * loads nothing. That broke every imported first-party room when the splice moved from + * the Studio-only table to `unity_asset`. + * * Only main bundles are listed. Stripped bundles stay in the bucket. */ export async function attachStudioUnityAssets( @@ -2317,7 +2326,9 @@ export async function attachStudioUnityAssets( ), ] if (ids.length === 0) return - const builds = await listUnityAssetBuilds(db, ids) + const builds = (await listUnityAssetBuilds(db, ids)).filter((build) => + isStudioBundleFilename(build.Filename) + ) // Rows come back with the id lowercased; a save may spell its GUID either way. const byAsset = new Map() for (const build of builds) { diff --git a/packages/domain/src/studio-unity-assets.ts b/packages/domain/src/studio-unity-assets.ts index edc6ab8..47dac8a 100644 --- a/packages/domain/src/studio-unity-assets.ts +++ b/packages/domain/src/studio-unity-assets.ts @@ -40,6 +40,9 @@ 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' +/** Where every Studio (UGC) bundle filename starts; see {@link publicStudioBundleFilename}. */ +const STUDIO_BUNDLE_PREFIX = 'studio/' + /** * 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 @@ -54,7 +57,20 @@ export function publicStudioBundleFilename( kind: string ): string { const id = unityAssetId.replace(/-/g, '').toLowerCase() - return `studio/${roomId}/${id}.${platform}.${kind}.assetbundle` + return `${STUDIO_BUNDLE_PREFIX}${roomId}/${id}.${platform}.${kind}.assetbundle` +} + +/** + * Whether a `unity_asset` filename is a Rec Room Studio build — UGC the studio worker + * wrote — as opposed to a first-party bundle loaded by hand under the game's own + * `unity//` layout. The two share the table, and only the Studio ones are + * folded onto a save's `UnitySubAssets`: a 2025 client handed a first-party bundle + * inline takes it instead of asking `POST /unity_assets/baked/bulk`, cannot name it + * (its decoder reads `UnityAsset`/`UnityAssetHash`, not `Filename`/`Hash`), and loads + * nothing. The bulk lookup serves both kinds. + */ +export function isStudioBundleFilename(filename: string): boolean { + return filename.startsWith(STUDIO_BUNDLE_PREFIX) } /** diff --git a/packages/domain/src/unity-assets-db.ts b/packages/domain/src/unity-assets-db.ts index a89f52b..e4137e6 100644 --- a/packages/domain/src/unity-assets-db.ts +++ b/packages/domain/src/unity-assets-db.ts @@ -10,8 +10,10 @@ * * 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 + * Player.log times as `AssetBundle_Download` — and `GET /rooms/{id}` folds the STUDIO + * main bundles (filenames under `studio/`, see `isStudioBundleFilename`) onto + * `CurrentSave.UnitySubAssets`. A first-party bundle loaded by hand under `unity//` + * is served by the bulk lookup only; either way the client then downloads * `/room/` from the bucket, and a lookup that answers no row, * or a row without a `Filename`, simply downloads nothing, with no error anywhere. *