From a3a5851373d6d5eb5d64dc393ef3a93f757d5441 Mon Sep 17 00:00:00 2001 From: dukedotdev Date: Mon, 12 Oct 2026 14:17:43 -0400 Subject: [PATCH] [rooms] Support 2026 Studio loading of legacy baked bundles (#129) * [rooms] Preserve legacy Unity bundle save DTOs for the Studio loader * [rooms] Resolve JSON baked bundle requests with exact target and version --- apps/rooms/src/rooms.app.ts | 98 +++++++++++++++- apps/rooms/src/test/integration/api.test.ts | 117 ++++++++++++++++++++ 2 files changed, 214 insertions(+), 1 deletion(-) diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index d46f26f..a9b2520 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -1138,6 +1138,92 @@ const app = new Hono() .onError(withOnError()) .notFound(withNotFound()) + // Studio loaders resolve asset GUIDs before downloading the published bundles. + .use('/unity_assets/baked/bulk', async (c, next) => { + // Preserve upstream's public form-based Studio resolver. JSON imports need + // the caller's presence/ownership before resolving legacy save metadata. + if (c.req.method !== 'POST' || !c.req.header('content-type')?.includes('application/json')) + return next() + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + let body: Record + try { + body = await c.req.json>() + } catch { + return c.body(null, 400) + } + if (!body || typeof body !== 'object' || Array.isArray(body)) return c.body(null, 400) + const entries = Object.entries(body) + const ids = entries.find(([key]) => /^(unityAssetIds|assetIds|ids)$/i.test(key))?.[1] + const target = Number( + entries.find(([key]) => /^(unityAssetTarget|assetTarget|target)$/i.test(key))?.[1] ?? 0 + ) + const version = Number( + entries.find(([key]) => /^(unityAssetVersion|assetVersion|version)$/i.test(key))?.[1] ?? 5 + ) + if ( + !Array.isArray(ids) || + ids.length > 128 || + ids.some( + (id) => typeof id !== 'string' || !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id) + ) + ) { + return c.body(null, 400) + } + if (!Number.isInteger(target) || !Number.isInteger(version)) return c.body(null, 400) + if (ids.length === 0) return c.json([]) + const requested = new Set(ids.map((id: string) => id.toLowerCase())) + const studio = await getBakedUnityAssets(c.env.DB, [...requested], target, version) + if (studio.length === requested.size) return c.json(studio) + const instance = (await getPresence(c.env.DB, accountId))?.roomInstance + const { results } = await c.env.DB.prepare( + ` + SELECT saves.data FROM subroom_save saves + JOIN subroom subs ON subs.current_save_id = saves.sub_room_data_save_id + JOIN room rooms ON rooms.room_id = subs.room_id + WHERE (rooms.accessibility = 1 OR rooms.creator_account_id = ?1 OR rooms.room_id = ?2) + AND (lower(json_extract(saves.data, '$.UnityAssetId')) IN (SELECT value FROM json_each(?3)) + OR EXISTS (SELECT 1 FROM json_each(saves.data, '$.UnitySubAssets') asset + WHERE lower(json_extract(asset.value, '$.UnityAssetId')) IN (SELECT value FROM json_each(?3))) + OR EXISTS (SELECT 1 FROM json_each(saves.data, '$.ReferencedUnityAssets') asset + WHERE lower(json_extract(asset.value, '$.UnityAssetId')) IN (SELECT value FROM json_each(?3)))) + ` + ) + .bind(accountId, instance?.roomId ?? -1, JSON.stringify([...requested])) + .all<{ data: string }>() + const assets = new Map< + string, + { UnityAssetId: string; Target: number; Version: number; Filename: string; Hash: string } + >() + for (const asset of studio) assets.set(asset.UnityAssetId.toLowerCase(), asset) + for (const row of results) { + const save = JSON.parse(row.data) as Record + const candidates = [ + save, + ...(Array.isArray(save.UnitySubAssets) ? save.UnitySubAssets : []), + ...(Array.isArray(save.ReferencedUnityAssets) ? save.ReferencedUnityAssets : []), + ] + for (const candidate of candidates) { + if (!candidate || typeof candidate !== 'object') continue + const asset = candidate as Record + const id = typeof asset.UnityAssetId === 'string' ? asset.UnityAssetId.toLowerCase() : '' + const filename = asset.UnityAsset + if (!requested.has(id) || typeof filename !== 'string') continue + const build = /\.(\d+)\.(\d+)\.assetbundle$/.exec(filename) + if (!build || Number(build[1]) !== target || Number(build[2]) !== version) continue + if (assets.has(id)) continue + assets.set(id, { + UnityAssetId: id, + Target: target, + Version: version, + Filename: filename, + Hash: typeof asset.UnityAssetHash === 'string' ? asset.UnityAssetHash : '', + }) + } + } + return c.json([...requested].flatMap((id) => (assets.has(id) ? [assets.get(id)!] : []))) + }) + .get( '/', describeRoute({ @@ -3871,7 +3957,17 @@ const app = new Hono() if (!(await canReadSaves(c, room, roomId, accountId))) return c.body(null, 403) const save = await getSubRoomSaveById(c.env.DB, subRoomId, saveId) - return save ? c.json(toSaveDetail(save, unityAssetTargetQuery(c))) : c.notFound() + if (!save) return c.notFound() + const target = unityAssetTargetQuery(c) + // Imported pre-Studio saves name their main/dependency bundles directly. + // Keep upstream's target-aware Studio projection for its built assets. + if ( + typeof save.UnityAsset === 'string' && + !pickBakedBundle(save, target) && + (target === null || target === 0) + ) + return c.json(save) + return c.json(toSaveDetail(save, target)) } ) diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index c5f53df..a43303a 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -6497,3 +6497,120 @@ describe('sweep cron', () => { expect(await inviterIds()).toEqual([]) }) }) + +it('2026 room experience retains upstream progression in the native object shape', async () => { + await seedRoomWithSubRooms(env.DB, { + RoomId: 900092, + Name: '2026ExperienceTest', + CreatorAccountId: 42, + SubRooms: [], + }) + const res = await SELF.fetch(`${ORIGIN}/rooms/900092/experience/player`, { + headers: await bearer('42', undefined, '20260323.14'), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + RoomExperienceEnabled: false, + Experience: 0, + ConcurrencyCode: expect.any(String), + }) +}) + +describe('Studio baked Unity asset downloads', () => { + const mainId = 'aa0320e1-2e47-4cf5-9129-5366c3c5f824' + const childId = 'e6da76fb-f990-4299-8724-d786f5723a5a' + const filename = 'imports/test-room/main.SubRoom.Main.0.5.assetbundle' + const path = '/unity_assets/baked/bulk' + beforeAll(async () => { + await seedRoomWithSubRooms(env.DB, { + RoomId: 900090, + Name: 'StudioDownloadTest', + CreatorAccountId: 1, + Accessibility: 1, + SubRooms: [ + { + SubRoomId: 900090, + RoomId: 900090, + Name: 'Home', + CurrentSave: { + UnityAssetId: mainId, + UnityAsset: filename, + UnityAssetHash: 'main-hash', + DataBlob: 'scene.room', + UnitySubAssets: [ + { + UnityAssetId: childId, + UnityAsset: 'child.SubRoom.SubAsset.0.5.assetbundle', + UnityAssetHash: 'child-hash', + }, + ], + }, + }, + ], + }) + }) + async function bulk(body: unknown, authenticated = true) { + return SELF.fetch(ORIGIN + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(authenticated ? await bearer('2') : {}) }, + body: JSON.stringify(body), + }) + } + it('resolves the main and subasset GUIDs with their exact target and version', async () => { + const res = await bulk({ + UnityAssetIds: [mainId.toUpperCase(), childId, mainId], + Target: 0, + Version: 5, + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([ + { UnityAssetId: mainId, Target: 0, Version: 5, Filename: filename, Hash: 'main-hash' }, + { + UnityAssetId: childId, + Target: 0, + Version: 5, + Filename: 'child.SubRoom.SubAsset.0.5.assetbundle', + Hash: 'child-hash', + }, + ]) + }) + it('returns the Studio loader DTO with its main bundle and dependencies', async () => { + const row = await env.DB.prepare( + 'SELECT current_save_id FROM subroom WHERE sub_room_id=900090' + ).first<{ current_save_id: number }>() + const res = await SELF.fetch( + `${ORIGIN}/rooms/900090/subrooms/900090/saves/${row!.current_save_id}`, + { headers: await bearer('1') } + ) + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ + UnityAsset: filename, + UnityAssetHash: 'main-hash', + UnitySubAssets: [ + { + UnityAssetId: childId, + UnityAsset: 'child.SubRoom.SubAsset.0.5.assetbundle', + UnityAssetHash: 'child-hash', + }, + ], + }) + }) + it('does not substitute Windows bundles for an unavailable target or version', async () => { + for (const options of [ + { Target: 2, Version: 5 }, + { Target: 0, Version: 6 }, + ]) { + expect(await (await bulk({ UnityAssetIds: [mainId], ...options })).json()).toEqual([]) + } + }) + it('rejects unauthenticated and malformed requests', async () => { + expect((await bulk({ UnityAssetIds: [mainId] }, false)).status).toBe(401) + expect((await bulk({ UnityAssetIds: ['invalid'] })).status).toBe(400) + }) + it('does not expose private-room assets to an unrelated caller', async () => { + await env.DB.prepare( + "UPDATE room SET data=json_set(data, '$.Accessibility', 0) WHERE room_id=900090" + ).run() + expect(await (await bulk({ UnityAssetIds: [mainId] })).json()).toEqual([]) + }) +})