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())
|
.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))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user