[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
This commit is contained in:
dukedotdev
2026-10-12 14:17:43 -04:00
committed by GitHub
parent 585cf68637
commit a3a5851373
2 changed files with 214 additions and 1 deletions
+97 -1
View File
@@ -1138,6 +1138,92 @@ const app = new Hono<App>()
.onError(withOnError()) .onError(withOnError())
.notFound(withNotFound()) .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<string, unknown>
try {
body = await c.req.json<Record<string, unknown>>()
} 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<PresenceView>(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<string, unknown>
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<string, unknown>
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( .get(
'/', '/',
describeRoute({ describeRoute({
@@ -3871,7 +3957,17 @@ const app = new Hono<App>()
if (!(await canReadSaves(c, room, roomId, accountId))) return c.body(null, 403) if (!(await canReadSaves(c, room, roomId, accountId))) return c.body(null, 403)
const save = await getSubRoomSaveById(c.env.DB, subRoomId, saveId) 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))
} }
) )
+117
View File
@@ -6497,3 +6497,120 @@ describe('sweep cron', () => {
expect(await inviterIds()).toEqual([]) 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([])
})
})