Dicey Table

Assets

An asset is any file a mod's manifest lists in assets[] (see Manifest reference § assets) — models, textures, audio, and small JSON sidecars. The editor derives that list from your file tree; the one choice you have is to keep a file out of it with excludedAssets, which makes it load on demand instead of up front. This page covers what's allowed, how big it can be, and a deliberately narrower list you'll also encounter that is not the same thing.

The allowlist — one source, three gates#

There is a single set of 19 allowed asset extensions, exported as MOD_ASSET_ALLOWED_EXTENSIONS from packages/shared/src/modManifest.ts:

.json .png .jpg .jpeg .webp .gif .bmp .avif .ktx2 .basis .glb .gltf .bin .mp3 .ogg .wav .txt .csv .hdr

This is a readonly as const tuple that the server's content-type policy (ASSET_CONTENT_TYPE_ALLOWLIST in apps/server/src/githubScanner.ts) is typed against — the Record type requires every key in the shared union to be present and rejects any key outside it, so editing one without the other is a compile error, not a runtime drift bug.

The direct-upload gate is one extension narrower, deliberately. UPLOADABLE_ASSET_EXTENSIONS (apps/server/src/githubScanner.ts) is derived from the same tuple with .bin filtered out, so it holds 18. The file names three gates and keeps them distinct: what a GitHub manifest may declare (19), what content-type policy applies (19), and what a client may POST to the server (18). A hand-authored repo may legitimately ship scene.gltf alongside scene.bin, so .bin stays manifest-legal — but no editor flow produces a lone .bin (modelImportFormats.ts marks it companion-only, rejects one on its own, and packs a .gltf + .bin selection into a single self-contained GLB before anything is uploaded), so accepting one over the upload route would only ever accept a formatless 50 MB blob. githubScanner.test.ts asserts the difference is exactly [".bin"] rather than leaving it implicit.

So: to check "may a manifest declare this?", read the 19. To check "may I upload this from the editor?", read the 18.

Per-extension content-type policy#

The manifest allowlist only says which extensions exist. The server additionally enforces a MIME-type expectation per extension when it fetches a declared asset from GitHub — this is server-side policy, not part of the shared contract:

Extension Accepted content-types
.json application/json, text/json, text/plain
.png image/png
.jpg / .jpeg image/jpeg
.webp image/webp
.gif image/gif
.bmp image/bmp, image/x-ms-bmp
.avif image/avif
.ktx2 image/ktx2, application/octet-stream
.basis application/octet-stream
.glb model/gltf-binary, application/octet-stream
.gltf model/gltf+json, application/json, text/plain
.bin application/octet-stream
.mp3 audio/mpeg
.ogg audio/ogg
.wav audio/wav, audio/wave, audio/x-wav
.txt text/plain
.csv text/csv, application/csv, text/plain
.hdr image/vnd.radiance, application/octet-stream, text/plain — plus a byte check, see HDR skies

application/octet-stream and binary/octet-stream are additionally accepted for every extension as a generic-binary fallback (some CDNs/servers mislabel content-type).

  • Missing content-type header: "Asset is missing a content-type header." (code asset-content-type-missing)
  • Content-type doesn't match policy: `Asset content-type ${normalized} does not match expected policy for ${extension}.` (code asset-content-type-mismatch)

Two different gates, two different messages#

There are two places this allowlist is enforced, and they fail differently:

  1. Declaring an asset in the manifest (assets[]) — an unrecognized extension fails with `Unsupported asset type: ${extension || "none"}.` (code unsupported-asset-type), from validateManifestPaths().
  2. Uploading a file directly in the editor — the same check, but the message additionally lists every allowed extension: `Unsupported asset type: ${extension || "none"}. Allowed: ${sorted list}.` (also code unsupported-asset-type), from validateUploadedAssetExtension(). Unlike the scanner's asset-content-type check (which passes unknown extensions through untouched, matching a manifest-driven allowlist that could in principle grow), a direct upload with an unrecognized extension is rejected outright — e.g. a .exe is never accepted, full stop. The one addition is the reserved rules/ folder: a .md or .pdf is accepted there and only there, because a rulebook is published outside assets[] entirely (see rules). The same two extensions anywhere else in the tree are refused exactly as before.

Size cap#

50 MB per asset (50 * 1024 * 1024 bytes), checked twice by the scanner — once against the content-length response header (fast-fail before downloading the body) and once against the actual downloaded byte length (in case the header lied):

"Assets must be 50 MB or smaller in v1." (code asset-too-large)

.hdr skies have a tighter cap — see below.

HDR skies#

.hdr is Radiance RGBE, the high-dynamic-range panorama format for the Environment Skybox slot. An 8-bit image clips the sun to the same white as a cloud; an HDR keeps its real brightness, so the room it lights gets proper highlights, reflections and shadow direction. The sky must be an equirectangular (2:1) panorama.

  • 10 MB cap (MAX_HDR_ASSET_BYTES, packages/shared/src/assetContentTypes.ts). Every player downloads the sky, and a 4K panorama is typically 20–50 MB. Checked from the header and again from the bytes: HDR skies must be 10 MB or smaller — re-import it in the editor, which downscales it to fit. (code asset-too-large).
  • Byte check. GitHub has no registered content type for .hdr, so the policy accepts generic ones, and the scanner requires the file to start with #?RADIANCE or #?RGBE instead: A .hdr asset must be a Radiance RGBE image (starting with #?RADIANCE or #?RGBE). (code asset-content-mismatch).
  • .exr is not an asset. The editor's Upload accepts .exr and converts it to .hdr on import, the same way FBX/OBJ become GLB, so an .exr never reaches a repo. It reads single-part scanline EXRs with NONE, RLE, ZIP or PIZ compression; for anything else (tiled, PXR24, B44, DWA), download the .hdr version of the sky.
  • Import fits the cap for you. A .hdr within 10 MB is stored byte-for-byte. A larger one, and every converted .exr, is re-encoded with run-length compression and its resolution halved until it fits. Halving is done in linear light, so the lighting does not dim. In practice a 10 MB sky is about 2K — plenty for lighting, a little soft as a visible backdrop.

Imported skies land in skies/. Drag one onto Environment › Skybox.

A narrower, third list — and why it's not drift#

You'll also encounter SUPPORTED_TEXTURE_MIME_TYPES / SUPPORTED_TEXTURE_EXTENSIONS in packages/shared/src/sceneEditor.ts7 raster formats, deliberately narrower than the 19-extension asset allowlist:

png jpg jpeg webp gif bmp avif

This is not a bug or a third source of drift — it gates a completely different question. The asset allowlist answers "can this file exist in a mod's repo?" This narrower list answers "can the browser's <img>-based texture pipeline decode this file client-side?" .ktx2 and .basis are GPU-compressed formats — they are valid, allowed mod assets (used for optimized texture delivery), but they are not something an <img> tag or canvas.drawImage() can decode. Their absence from the texture-decode list is correct behavior, not an oversight to reconcile.

If you're authoring textures by hand rather than through the editor's Basis-compression pipeline, use one of the 7 raster formats above so the editor can actually preview/manipulate it; .ktx2/.basis files are something the editor's own compression step produces, not something you'd typically hand-upload as a source texture.

See also#

  • Manifest reference § assets — how declared assets are wired into entry/assets[].
  • Required and conventional files — the file-tree envelope (sha256, sizeBytes, the 2000-file / 50 MB caps) and diffTrees().
  • Sidecars — the two .json sidecar shapes (*.meta.json, *.deck.json) validated on top of the generic asset checks above.
  • Cover art and screenshots — the even narrower 4-extension rule for the reserved media/ folder.
  • Mod scripting APITableDecalState.url, which is a browser-loadable URL rather than one of the repo-relative paths on this page.

MOD_ASSET_ALLOWED_EXTENSIONS#

19 values. Every extension manifest.assets may declare. The server's content-type map is keyed off this same list, so the two can no longer drift.

Extension
.json
.png
.jpg
.jpeg
.webp
.gif
.bmp
.avif
.ktx2
.basis
.glb
.gltf
.bin
.mp3
.ogg
.wav
.txt
.csv
.hdr

Nineteen extensions, and the single answer to "may a mod declare this file?". The tuple is as const and ModAssetExtension derives from it; the server's ASSET_CONTENT_TYPE_ALLOWLIST (apps/server/src/githubScanner.ts) is a Record keyed by that union, so it must name every extension in the list and may name no other. Adding or removing one here is a compile error until the content-type policy is updated to match — the two cannot drift silently, and only the MIME expectations per extension are server-owned.

Three gates read from this list and they are not the same width. A manifest may declare all nineteen. A direct editor upload accepts eighteen: UPLOADABLE_ASSET_EXTENSIONS filters out .bin deliberately, because a hand-authored repo may ship scene.gltf beside scene.bin while no client flow produces a lone one — the import path packs a .gltf + .bin selection into a single GLB first. The browser's texture pipeline decodes a narrower seven, which is why .ktx2 and .basis are legal assets you cannot hand the editor as a source texture. .hdr (a Radiance sky) is the one extension with its own rules: a 10 MB cap (MAX_HDR_ASSET_BYTES) and a signature check, and the editor converts .exr into it on import rather than storing an .exr. An unrecognized extension is unsupported-asset-type at either gate. See What gets rejected.