mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[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:
@@ -1138,6 +1138,92 @@ const app = new Hono<App>()
|
||||
.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<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(
|
||||
'/',
|
||||
describeRoute({
|
||||
@@ -3871,7 +3957,17 @@ const app = new Hono<App>()
|
||||
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))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user