The blvl level format

This page is the specification as it is kept in the repository, with diagrams, and with examples you can open in the viewer. The tables, structs, enums and unions of every section are listed on the format page, generated from the schemas, and each section here links to its own.

A .blvl file is one baked FPS map: the triangles a renderer draws, the convex solids a player collides with, the lightmaps and probes that light it, the spawn points, doors, breakables and triggers a game rule set reads, and whatever else the game that made it wants to carry along. It is written once by a bake and read many times, by clients that want everything and by servers that want only the solids.

Everything is little-endian. Units are Quake units (32 units to the metre), the up axis is +Y, the coordinate system is right-handed, and forward is -Z. A bake from another engine has already turned that engine's axes and scale into this one.

Container

header24 Bsection tablecount × 48 Bsimpayloadgeometrypayload…textures, …02424 + count·48each entry: tag[16] · version u16 · offset u64 · packed u64 · raw u64header: magic "BLVL" · container version u32 · blake3-64 content hash · section count u32
A file is a fixed header, a table with one 48-byte entry per section, and the payloads the table points at. Sections need not be in table order and may leave gaps.

A file is a 64-byte header, a reserved table region, and the sections' payloads.

offsetsizefield
08magic BLVL\0\0\0\0
84u32 container version, currently 0
124u32 flags, written 0, ignored by readers
168u64 identity hash
248u64 table digest
324u32 section count, at most 1024
364u32 reserved, written 0, ignored by readers
408u64 table offset
488u64 payload floor
568u64 reserved, written 0, ignored by readers

The table holds one 64-byte entry per section, so every 64-bit field in it is 8-byte aligned and the digest starts on a 16-byte boundary:

offsetsizefield
016tag: ASCII [a-z0-9._-], NUL-padded, first byte non-NUL
162u16 payload version
182u16 flags, bit 0 identity, the rest written 0
204u32 reserved, written 0, ignored by readers
248u64 offset of the payload in the file
328u64 packed size of the payload
408u64 raw size of the payload once inflated
4816blake3 digest of the packed payload, first 16 bytes

The table sits at table offset, always inside the reserved region between the header and the payload floor. Payloads start at or after the floor. A writer reserves 2 * count + 16 slots rounded up to 4 KiB, so a section can be added, replaced or dropped later without moving a single payload byte; a real map reserves exactly 4 KiB. A reader that has the header and the table has everything it needs to place every section, and a streaming reader gets that after one bounded read of at most 128 KiB.

A reader refuses a file whose magic or container version it does not know, whose payload floor or table offset is not a table boundary or falls outside the reserved region, whose count is past the reserved capacity, whose entries overlap, run below the floor or past the end of the file, repeat a tag, or claim more raw bytes for one section than that section may hold.

A section's raw cap depends on what is in it. A core section is a FlatBuffer, and no FlatBuffer addresses past 2 GiB, so a core section stops one byte short of it. A section with a dotted tag is opaque bytes -- blvl.assets is a blob file, blvl.shots is rendered stills -- and may claim up to 8 GiB: a map that keeps every mip of every texture at the resolution its source shipped runs to several gigabytes of images, and the table has always carried these sizes as u64. Both caps are a reader's sanity bound and neither is a container version: the entry format does not change.

Unreferenced bytes

A file may carry bytes no entry refers to: the slack in the reserved region, the padding that aligns an appended payload, and the remains of a section that was replaced. A reader ignores them. It refuses a file that carries more than max(1 MiB, covered / 16) of them, where covered is the payload floor plus every section's packed size, and it sizes its inflation budget from covered rather than from the length of the file, so padding buys an attacker nothing.

Identity and integrity

Each entry carries a blake3 digest of its section's packed bytes. Every byte a reader ever reads is covered by one, and checking a section costs only that section.

The identity hash is blake3 over a canonical record set: 16 bytes of magic, version and record count, then one 48-byte record per section whose identity flag is set, sorted by raw tag bytes, each holding the tag, the payload version, the raw size and the section digest. It is the level's name on the wire: two files with the same identity are the same map, whatever else they carry. Offset, packed size, table position and file length are all left out, so adding, replacing or dropping a section outside identity leaves the name alone. Because it is a hash of the stored bytes, changing the compressor renames every map; the zstd level and the chunk size are part of the format.

A core tag must have the identity flag set and a reader refuses a file where one does not, so a level's own sections can never be quietly demoted out of its name. A game's sections opt in: the ones a match depends on belong in identity, a thumbnail does not.

The table digest is blake3 over 16 bytes of magic, version and count followed by the whole reserved region, entries and slack alike. It pins the offsets and the sections outside identity, which identity deliberately does not, and it changes on every edit. It never goes on the wire.

A reader with the header and the table can check the table digest and recompute the identity before a single payload byte arrives. A streaming reader verifies each section the moment its last byte lands, rather than learning at the end of a 150 MB download that something was wrong.

Editing a baked level

A section outside identity can be added, replaced or dropped in place. The payload goes at the end of the file, the table grows into the reserved slack or is rewritten whole to a free part of the region, and the edit commits with a single write of the 24 bytes at offset 24: table digest, count and table offset. Those 24 bytes sit inside the first sector, so the commit cannot tear. Everything written before the commit is unreferenced until it lands, so a crash at any point leaves the previous level intact.

An in-place edit never writes offset 16. A level cannot be renamed by editing it, only by baking it again.

Section payloads

A payload is a run of chunks, each holding at most 4 MiB of raw data, followed by a chunk index and a u32 chunk count. Each index entry is 12 bytes: u32 packed size, u32 raw size, u8 codec (0 raw, 1 zstd), 3 bytes zero. Chunks are independent, so a reader inflates them in parallel and a writer never holds a whole section to compress it. A chunk is stored raw when compressing it would not have made it smaller.

chunk 0zstd, ≤ 4 MiB rawchunk 1raw when smaller…index 012 Bindex 112 B…countu32index entry: packed u32 · raw u32 · codec u8 (0 raw, 2 zstd) · 3 zero bytesread the count from the last 4 bytes, walk the index backwards from there, inflate chunks in parallel
The chunk index sits at the end of the payload, so a writer streams chunks out without knowing their count up front, and a reader finds the index from the tail.

Tags

Tags without a dot are reserved for this specification. A game's own sections use a vendor prefix, eds.blocks for example, and a reader that does not know a tag skips it. A tool that reads a level and writes it back carries every section it did not understand through untouched, identity flag and all, so a game's private data survives a generic rewrite and the map keeps its name.

The blvl. prefix is reserved too, for sections this specification defines but deliberately leaves outside identity. A core tag is forced into identity and a reader refuses a file where one is not, which is right for everything a level is and wrong for anything derived from it that a machine might reproduce differently. Such a section is described here and has a schema like any other; it is an extension only in the mechanical sense, so that replacing it costs an append rather than a rewrite and never renames the map. There are three, blvl.shots, blvl.blobs and blvl.assets.

blvl.shots

Rendered stills of the level, so a menu, a server browser or a map list can show a map without opening it. The root table is blvl.shots.Shots, described by shots.fbs. Each shot carries a name, a preset, the purpose and camera index of the viewpoint it came from (camera is -1 when the baker framed it rather than reading it from cameras), the pos, yaw, pitch, roll and fov_x it was taken with, and an image.

preset is one of thumb (512x288), wide (1920x816), uhd (3840x2160) or other, which carries whatever size it was given. The pose uses the same convention as cameras and blvl probe --pose, and fov_x is quoted for a 4:3 view, so a wider shot from the same pose shows more of the level to the sides rather than cropping it.

A reader that wants one picture takes the thumb, and a reader that wants the best one takes the largest. Because the whole section inflates at once, a reader fetching over a network pays for every shot in it, not just the one it wanted.

Nothing else in the level refers to this section and nothing is hashed into identity through it, so a level may be re-rendered any number of times without changing its name, invalidating a .bsym sidecar, or moving another section's bytes.

blvl.blobs

The blobs a level names that are not its images: sounds, and anything else a game keeps by key. The section is an index and nothing else. Its raw payload is a u32 count, then one 32-byte entry per blob:

offsetsizefield
016key
168u64 raw size
242u16 kind, numbered as in a blob file's index
266written 0, ignored by readers

A blob it lists travels exactly as an image does: its bytes are in blvl.assets or in any blob file, found by key, and a level may carry all, some or none of them. The section sits outside identity like the assets; a game section that uses a blob names its key, and that section is what makes two levels different maps. A reader wants none of these blobs to draw a level, so it may open one before any of them arrive. A bake writes the section just before blvl.assets.

blvl.assets

The bytes of the images textures and skies name by key, and of the blobs blvl.blobs lists (see "Levels and their blobs" under "Blob files"). The raw payload is a sealed blob file, laid out as "Blob files (.bass)" describes, holding each image the level carries once, its payloads in the order the level first uses them: textures by index, then skies, then blvl.blobs. The section has no FlatBuffer schema; the blob file is its format.

Every chunk of the payload is stored raw, and every chunk but the last holds exactly 4 MiB, so the packed size is raw + 12 * ceil(raw / 4 MiB) + 4 and byte x of the blob file is byte offset + x of the level. A reader places any image from the table and the blob file's first page without reading the rest of the section, and refuses a blvl.assets whose sizes break that equation. A reader that reads the whole section checks its digest as it would any other; a reader that reads single blobs checks each against its key.

A level may carry every image it names, some of them, or none. The section sits outside identity, so all of those are the same map, and moving images in or out of a level never renames it. A bake carries every image, and writes this section after every other but blvl.shots, which stays last. An image a level does not carry is found by its key in whatever blob files a reader has.

Core sections

Each core section is one FlatBuffer root table, described by the schema of the same name under crates/blvl/schema/. Those schemas are the definition of the section contents; a reader in any language starts from them. Readers verify a payload with the FlatBuffers verifier before reading it, and treat every optional table field as absent-means-empty.

Sections are versioned one at a time, so a schema change bumps only the section it touches, and a reader that does not know a version drops that section rather than refusing the file, unless the section is required. The versions column lists every payload version a current reader takes; a section that gains a new form keeps its old one readable, so the levels already out there still open.

tagrequiredversionsroot tablecarries
meta0blvl.meta.Metathe level's name, title, version, author, description, engine and tags
variants0blvl.variants.Variantsthe map's own named layouts, their groups and defaults
simyes0, 1, 2, 3, 4, 5, 6, 7, 8blvl.sim.Simname, bounds, kill plane, colliders, breakables, doors, triggers, teleport destinations, spawn points, hurt volumes, movers, rides, climbs, use targets, metadata
geometryyes0, 1, 2, 3blvl.geometry.Geometrymaterials, vertices, indices, draw batches
textures0, 1blvl.Imagesthe images materials index
lightmaps0blvl.lightmaps.Lightmapslightmap atlases
skies0, 1blvl.skies.Skiesskybox cubemaps
minimap0, 1blvl.minimap.Minimapa top-down plan and the rectangle it covers
lighting0, 1, 2blvl.lighting.Lightinglighting profiles, derived lights, the light grid, sky visibility, reflection probes, punctual lights, fog volumes, light styles
entities0blvl.entities.Entitiesthe source map's entities as classname plus key/value pairs
cubemaps0blvl.Imagesthe cubemaps reflection probes index
acoustics0blvl.acoustics.Acousticswhere sound can travel, and the reverberation of the room it travels through
props0, 1, 2, 3blvl.props.Propsplaced models with their own meshes, detail steps and hulls
cameras0blvl.cameras.Camerasauthored viewpoints: a name, a purpose, a position and angles
sites0, 1, 2blvl.sites.Sitesobjective areas and named places: bomb sites, buy zones, callouts, as named boxes

A server reads sim alone and never inflates the rest. A client reads them all, and a streaming client can decode textures while the tail of the file is still arriving, because the table tells it where each section ends.

meta

What the level calls itself, and what a menu says about it: name, the lowercase word a server and a filename use; title, what a person reads; version, an integer the author bumps when they publish a new release; author, description, created_unix and generator; kind, the engine the map came from, from the list under sim below; origin, the level this one was derived from; and tags, free key/value pairs a game reads by its own conventions.

It is written before every other payload, so a reader that has the first pages of a file has the header, the table and all of this. That is the whole point of the section: sim carries the same fields, but reading them there costs an inflate of every collider in the map, which a map list cannot pay once per file. A reader that finds no meta falls back to sim.name and sim.meta, which is how a file written before this section existed still answers.

meta is a core tag, so it is inside identity: bumping the version or changing the engine tag renames the level, and two versions of one map are two files that can sit side by side. Because of that a writer that only means to relabel a level still re-bakes it; an in-place edit cannot touch this section.

sim.name and sim.meta are still written, with the fields they have always had, so a reader of an older build opens a current file and loses only what it never knew about.

variants

A map's own named layouts. gametypes is the same idea with the bit meanings fixed by this specification; a variant is whatever the map says it is. Bit n is variants[n], there are at most 32 of them, and a level with no variants section has none.

Each entry carries a name, the word a server and a config address it by; a label for a person to read; the group it belongs to, if any; lighting; and modes, free text this specification does not interpret, the way Door.team and Meta.tags are not interpreted. A game matches modes against the mode it is running and turns those variants on.

A group is a set of variants of which exactly one is on at a time: day or night, never both and never neither. default_variant is the one a reader falls back to, and it has to be a member of its own group. A variant in no group is an independent toggle. A reader normalises a mask before it uses it: every group ends with exactly one member on, its default where the mask named none and its lowest member where the mask named several. Both sides of a match normalise the same way, so a mask on the wire means one thing.

lighting says a variant is lit in its own right. Any variant changes the lighting in principle, since it moves the things that cast shadows, so a bake that gave every reachable combination its own lighting would grow with every toggle. This flag is the author saying which handful are worth that, and a bake needs only as many lightmaps or light volumes as there are combinations of the flagged ones.

Every piece a level draws or sweeps -- draw batches, colliders, breakables, doors, movers, rides, climbs, use targets, triggers, spawn points, hurt volumes, sites, props, punctual lights and fog volumes -- may carry variants and variants_not. A piece is present when variants is absent or meets the mask being played, and when variants_not is absent or does not. variants alone says which layouts a piece belongs to. variants_not is what makes a variant a delta rather than a second map: it takes a piece out of the world without that piece having to name every variant that keeps it, so adding a variant later does not rewrite the base. An absent pair is every variant, which is what the map's own world geometry carries and what every engine with no such notion produces.

A reader that ignores both fields stacks every layout on top of the others and seals passages that should be open, exactly as it would ignoring gametypes. A reader that wants one layout and nothing else applies the default mask. The two masks are independent of gametypes and of each other, and a piece is present only when both say so. The mask is per piece and not per section, so a variant can be switched without reloading the file.

lighting.Profile carries variants and no variants_not, because it is selected rather than hidden: a reader takes the first profile whose variants meets the mask, and falls back to the first profile that carries none.

Variants filter at use. A bake never drops a piece because no variant shows it, because the arrays a level carries are indexed by position -- a collider's breakable, a batch's door, a symbol file's rows -- and dropping one would move everything after it.

sim

colliders carry their axis-aligned bounds, a flags word, and one of four shapes. Two are convex solids: Planes, every outward plane as a unit normal and a distance with the axial planes and edge bevels already included, or Hull, the vertices and face loops for the reader to expand. Two are not: Mesh, a triangle soup, and Instance, a collision_models entry placed at a position, rotation and scale. The scale is scale3, per axis, with the uniform scale beside it as the fallback a reader from before v5 takes; both are written and a reader prefers scale3. Face planes alone do not bound a swept box, so a reader that traces boxes needs the six axial planes and one bevel plane per edge crossed with each box axis; a hull leaves that expansion to the loader because for a mesh-derived map the bevels outnumber the faces six to one. A hull's face loops have to be wound around each face: the loader reads a face's plane off the loop's own area, and a loop listed corner by corner instead crosses itself, encloses nothing, and yields no plane at all. Six faces lost that way leave a box at an angle standing as its own bounding box, since the axial planes are all that is left of it. A loader may wind a loop it is given crossed, and this one does.

flags bit 0 is a ladder: climbable. Bits 1..2 are the surface, 0 concrete, 1 metal, 2 wood, for footsteps and impacts. Bits 9..12 may name it more finely, under the broad kind bits 1..2 still carry: 0 nothing finer, 1 dirt, 2 sand, 3 grass, 4 gravel, 5 mud, 6 snow, 7 water, 8 glass, 9 tile, 10 carpet, 11 plastic and 12 rubber, all under concrete, and 13 grate and 14 chain-link under metal. A reader that knows three surfaces reads bits 1..2 and is right as far as it goes. Bit 3 is sky. Bit 4 is nodraw: the solid draws nothing, the way an engine's clip, nodraw and hidden brushes do. It says nothing about what the solid stops. Bit 6 is water: a swimmable volume a game asks whether a point is inside. A water collider's material names the geometry.materials entry of its surface so a reader underwater can fog with that material's water block.

What a collider stops is three channels and nothing else: bit 7 says the player passes through, bit 5 a hitscan shot, bit 8 a projectile -- a grenade, a rocket, an arrow. A game's movement sweeps skip a collider with bit 7, its shots one with bit 5, and its projectiles one with bit 8, and no other bit changes that. The channels are independent because the cases that matter part company: a player clip is nodraw and passes shots and projectiles, a grate or chain-link fence passes the shot and stops the projectile, a grenade clip stops the projectile alone, and a nodraw wall with no channel bits stops everything. A ladder a shot passes sets bit 5 itself, and water sets all three. A sim before version 7 left those implied, so a reader of one sets bits 5 and 8 on every ladder and all three on every water collider.

A Mesh collider is the collision as the map authored it, cut into a cell of the world. Merging a million triangles into convex slabs is slow, lossy, and comes out bigger than the triangles it replaces, so a map whose collision was never convex keeps its triangles: a reader sweeps a box against each triangle of the cells the sweep crosses, expanding the triangle by the box rather than the other way round. A mesh is two-sided -- a sweep is stopped by a triangle it approaches from either face, since a soup has no inside -- and carries no bevels, because a triangle has no volume to bevel.

collision_models are the collision a level places more than once, each a Mesh in the model's own space with its bounds. An Instance collider names one and places it; a reader traces by putting the sweep into model space, against the model's own triangles, and the normal it comes back with into world space through the inverse transpose, so a model squashed on one axis reports the face angles it has rather than the ones it was authored with. A map that places 372,000 props keeps 32 bytes per placement instead of a copy of the mesh, which is the difference between a sim section of tens of megabytes and one of hundreds. Nothing stops a level from carrying both Instance colliders and the props models they visually belong to; they are separate tables because collision and drawing are decided separately, and a prop may have one and not the other.

A collider may name the breakable or the door it belongs to and the entry of collider_sources that says where it came from. A door's planes are its closed pose; the game moves them.

A collider, breakable, door, trigger, spawn point and draw batch may each carry gametypes, a mask of the match types it belongs to. An arena map is often several layouts in one file: a wall that only stands in capture the flag, a door only the bomb round opens, spawn points a team game uses and a free-for-all does not. The source engines gate those on the gametype the server runs, and a reader that stacks every layout on top of the others seals passages that should be open. Bit 0 is free-for-all, then last man standing, team deathmatch, team survivor, follow the leader, capture and hold, capture the flag, bomb, jump, freeze tag and gun game. An absent field is every match type, which is what the map's own world geometry carries and what an engine with no such notion produces, so a reader that ignores the field gets the whole map and today's behaviour. A game filters the pieces it draws and sweeps by the mask; the mask is per piece and not per section, so a match type can be switched without reloading the file. A door may also name the team an engine reserved it for, spelled as the source map spelled it.

breakables are things a shot removes from the world. kind is what one is made of, so a game can pick the sound it makes and the debris it leaves: Glass, Wood, Metal, Stone, Tile, Flesh, or Other when the map never said. It is the source engine's own material class where the engine names one, and read out of the material's name where it does not; a reader that wants a better guess for Other can fall back on the surface bits of the colliders that belong to the breakable. A breakable that was parented to a door names it in door; its lo and hi are the box in the door's closed pose, so a reader that has moved the door puts that box through the same transform before it spawns debris. doors slide along a direction or swing about a hinge, on a timer, on use or only when the game moves them; a swing turns about axis, which is +Y when absent (payload versions before 3 have no axis). triggers are the two Quake volumes every arena game has: a push that replaces the player's velocity and a teleport to one of teleport_dests. spawns carry a position, a yaw, the kind of spawn the source map declared and an optional team. The kind is Deathmatch where anyone may start, Team where only one side may, Start for the map's single entry point and UrbanTerror for that game's own shared spawn. A map built for a team game carries no Deathmatch spawns of its own, so a bake works some out from the level's own collision and writes them as Deathmatch gated on the free-for-all bit of gametypes; a reader that wants only what the source map declared can tell them apart by that mask.

movers are the solids a door cannot describe: the lifts, trains, bobbing platforms and rotating brushes an engine authors as a brush entity with a path. A mover walks path, waypoints each holding a position, the seconds it waits there and the speed it travels to the next; the first waypoint is the rest pose the rest of the level was baked in. spin turns it, the axis as the vector and degrees a second as the length, about hinge or about its own centre when the hinge is zero. turn is how far that goes before it stops, in degrees, and zero is a mover that turns for as long as it runs. So a rotating brush is one waypoint, a spin and no turn; a door on a hinge is a spin, a hinge and ninety; and a lift is a path and no spin at all. start says where it stands when the map loads, Closed at the first waypoint, Open at the last, Looping for one that never stops; trigger and trigger_box work as a door's do.

What rides a mover is said once, and from whichever side can say it. A collider and a breakable name their mover in a mover field, the way they already name their door. Draw batches and props cannot, because geometry and props stay at version 0 and gain no field, so a mover lists those itself: batches and props are indices into geometry.batches and props.props. A reader that ignores movers still draws and collides with every piece exactly where the bake left it, which is what every reader did before this section existed.

rides are the cables a player takes hold of and is carried along: a zipline, a rope, a rail. path is the cable in order, speed how fast a rider travels it, radius how near a player must be to take hold, and mounts the points of path a player may join at, empty meaning the two ends. two_way says a rider may travel it either way.

climbs are ladders as things rather than as surfaces. Collider flag bit 0 already says a solid is climbable, which is all a game needs to hold a player against it; a climb adds what a flag on a box cannot carry -- up, the axis a player climbs, facing, the wall normal they face, and top and bottom, the places they step off. A collider names the climb it belongs to in a climb field, so a reader may have both the flag and the thing.

uses are the things a player presses: a button, a lever, a panel, a pickup. Each has a box, the face a player must be looking at in look, the reach it answers from, and hold, the seconds it must be held, 0 for a tap. targets is the link a source engine's entity graph carried and this format had no way to keep: each entry names a kind and an index into that vector of the level, so a button says which door it opens. It says what a press moves and deliberately not what happens next; a level is not a script.

A breakable may go in steps rather than at once. stages lists them, each with the health that ends it and the batches and colliders that go with it; an empty stages is the whole thing at once on the breakable's own health. explodes is how far its debris throws damage, 0 for one that only breaks, and mover parents it to a mover the way door parents it to a door. A door carries a health of its own, for the doors a game shoots open rather than uses; 0 is a door that cannot be broken.

Not every breakable gives way to whatever hits it. harmed_by is a mask of the kinds of damage that wear one down: bit 0 bullets, bit 1 melee, bit 2 blasts, bit 3 impact, which is a projectile striking it or a player running into it or standing on it. An absent mask takes every kind, which is what an engine with no such notion produces and what a reader that ignores the field does. An empty mask is a breakable no damage breaks at all: Counter-Strike's crates by a bomb site are "only break on trigger" and wait for the bomb, and a map's damage filter can leave a pane that only an explosion goes through. site is the index in the sites section of the bomb site whose bomb breaks the breakable when it goes off; a game breaks every breakable that names the site, whatever its mask. A breakable with an empty mask and no site is broken by logic the level does not carry, so it stands for the whole match. A sim before version 8 has neither field, and a reader treats its breakables as taking every kind of damage and belonging to no site.

Version 0 of this section has none of that: no movers, rides, climbs or uses, and no stages, explodes or door health. A reader takes both versions and finds those empty in a version 0 level, which is the same thing it does for a version 1 level whose map had none.

hurt_volumes are the places a map hurts whoever stands in them: the kill zones under and around a level that end a fall long before kill_y would, and the lava pits and hurt triggers that wear a player down. Each has a name from the source map, a kind, and a shape: the box lo..hi when planes is empty, otherwise the convex intersection of the outward half-spaces in planes (the same n·p - d <= 0 convention as a collider's) with lo..hi as its bounds, since a rotated box would kill players on honest ground if it were read as its bounds alone. A Kill volume kills on contact. A Damage volume takes amount hit points every interval seconds for as long as the player stays inside, the numbers the source engine used. A game tests its player box against the volume and is free to do so with the box's corners against the planes. kill_y remains the floor below all of them.

kind says what the solid was upstream: Brush, a structural world brush; Detail, a detail brush (Quake 3 CONTENTS_DETAIL, Source func_detail); Patch, the thin solid under a curved patch, displacement or terrain, four units deep with the drawn surface on top; Prop, a placed model's collision; Mesh, collision derived from world mesh geometry; Volume, a blocking, containment or player-clip volume that draws nothing. A tool that rebuilds a level from its collision uses it to tell structure from detail, props and volumes; a server ignores it. A sim written at version 0 or 1 has no kind and reads as Brush throughout. Version 2 is the union of two extensions, the triangle-mesh and instance collider shapes and the collider kinds with meta.origin; a reader built for one decodes the other's fields with their defaults.

meta records the author, description, creation time, the tool that wrote the file and the engine the map came from: Native, Q3, Urt, Source, Source2, Valorant, Ow2, Cod4, Css, Csgo, Tf2, Hl2, Deadlock, Halo2, Mw2, Reflex, Bo1, Swbf2, Apex or Bo3. Source is a Source 1 map whose game the bake could not tell apart; Css, Csgo, Tf2 and Hl2 are the same engine with the game named, Deadlock is a Source 2 map whose lightmaps are per-channel L1 rather than irradiance plus direction, Halo2 is a Halo 2 Classic map whose lightmap is the game's own gamma bytes doubled by the renderer, Mw2 is Modern Warfare 2, IW4, drawn like Call of Duty 4, and Bo1 is Black Ops, Treyarch's fork of the same engine, drawn the same way. Swbf2 is Star Wars Battlefront II, whose light is almost entirely per-vertex and whose props carry it in their vertex colours. Apex is Apex Legends, a Titanfall rBSP map: lightmapped like Source 1, but at a scale where its props are instanced and its collision is triangles rather than slabs. Bo3 is Black Ops III on Treyarch's T7, two engine generations past Bo1 and sharing almost none of its layout, drawn like the Call of Duty kinds beside it. A renderer picks its lighting model from this field. A level derived from another, such as one rebuilt from its collision, names that level in origin: its name, the identity of the file it was read from, and its kind.

012345678
  • bit 0 ladder · climbable
  • bit 1-2 surface · 0 concrete, 1 metal, 2 wood
  • bit 3 sky
  • bit 4 nodraw · never drawn
  • bit 5 shot-through · a bullet passes
  • bit 6 water · point-in-volume only
  • bit 7 player-through · the player passes
  • bit 8 projectile-through · a grenade passes
collider.flags, low bits. Bits 5, 7 and 8 are the three channels and the only ones that decide what a collider stops; bit 4 only says it is never drawn.
012345678910
  • bit 0 free-for-all
  • bit 1 last man standing
  • bit 2 team deathmatch
  • bit 3 team survivor
  • bit 4 follow the leader
  • bit 5 capture and hold
  • bit 6 capture the flag
  • bit 7 bomb
  • bit 8 jump
  • bit 9 freeze tag
  • bit 10 gun game
gametypes on colliders, breakables, doors, triggers, spawns and draw batches. An absent field means every match type.
aerowalk collidersopen in viewer ↗
sim.colliders and sim.spawns drawn over the geometry of aerowalk: white boxes are solid, cyan is sky, magenta is nodraw, yellow lets shots through.
training range collidersopen in viewer ↗
The EDS training range, a native bake: every collider is a hull the game expands at load.

geometry

vertices is a vector of 52-byte structs laid out as position, normal, two texture coordinates (material and lightmap), an RGBA8 colour and a packed tangent, so a reader on a little-endian machine can copy it straight into a vertex buffer. The tangent is snorm16 xyz plus a sign in w, the handedness the shader needs to rebuild the frame from the vertex normal. indices is a vector of u32 triangles. Each batch is one draw: a run of indices, the material it uses, its bounds, the breakable or door it belongs to so a renderer can stop drawing what has shattered and move what has opened, and optionally the entry of lighting.fog_volumes the surface is fogged by (fog; idTech3 assigns fog per surface, so a bake splits batches by material and fog), and the gametypes mask described under sim (a bake splits batches by that too, so a renderer can drop a wall that belongs to another match type without touching the rest of the draw).

A material names its texture and carries the flags a renderer needs before it draws: the sort key (lower first; 3 opaque, 4 decal, 9 additive), the blend mode, whether it writes depth, the alpha test, two-sidedness, polygon offset, environment mapping, and whether it is a sky. Multiply is the blend mode a dirt or stain decal wants: the renderer scales what is already in the frame by the material's albedo and lights the decal not at all, which is what idTech3's blendFunc filter and a Source decalmodulate do. Batches are sorted by that key, translucent last. The PBR block records what the bake could learn about the surface without deciding anything: the source engine's surface parameters, its emitted light, and indices of normal, occlusion/roughness/metal, emissive and second-albedo textures when the bake found them, plus scalar factors. maps may also name a specular texture (RGB specular colour or mask, alpha gloss), a detail texture and a paint texture (a per-face colour grid a reader samples with the lightmap coordinates and multiplies into the albedo). A normal texture holds the tangent-space x and y in red and green, mapped from -1..1 to 0..1, in any encoding; a reader rebuilds z from them, because a BC5 map has no blue and a BC1 map's blue is whatever its source kept there.

A second albedo (albedo_b) makes a two-layer material, and normal_b and orm_b, added in version 3, are that second layer's normal and occlusion/roughness/metal maps; a layer without its own keeps the first layer's. How much of the second layer a pixel shows is decided one of two ways. A material with no mask weighs the layers by the vertex alpha: alpha is the first layer's weight, so the albedo is lerp(albedo_b, albedo, a). A material with a mask (version 3) computes the second layer's weight w instead, from the vertex alpha a and a height h read from the alpha of the albedo (AlbedoAlpha) or of the second albedo (AlbedoBAlpha), or 0 for None, and 1 - h when invert_height is set:

x = vertex ? a * (1 + gain * h) : gain * h
w = x <= 0 ? 0 : saturate(pow(x, power))

and every map of the pair is lerp(first, second, w). On a material with a mask and no second albedo that blends (Alpha), w is the surface's opacity in place of the texture alpha; the texture alpha then reaches it only as h. Unreal's layered materials weigh a painted vertex alpha against a height mask this way, and a translucent wear or grime overlay fades by the same rule. A bake that needs the height keeps the albedo's alpha, so a reader must not assume an opaque material's alpha is 1.

water is present on a water material and carries the fog colour, the fog start and end in map units, the reflect and refract tints and amounts, and two UV scroll rates, in the source engine's own values; absent means the engine said nothing.

shading is an optional table of the source engine's own material parameters, kept in that engine's units so a renderer can run its formula rather than an approximation. Every field has a default that means "the engine's default": phong_exponent (0 when the exponent comes from the specular map's alpha), phong_boost 1, phong_fresnel (three Fresnel range stops), phong_albedo_tint, envmap_tint, envmap_mask (None, Texture for a separate mask, BaseAlpha or NormalAlpha for a mask in that texture's alpha, or SpecularAlpha for a specular map that is a gloss map and whose colour carries nothing, which is how Black Ops' lit shaders read it), envmap_contrast, envmap_saturation 1, selfillum_tint, rim_boost, rim_exponent 4, lightwarp (a texture index, the 1D ramp N·L is read through), detail_blend (Source's detail blend modes 0..9), detail_scale 1, detail_factor 1, seamless_scale (0 for none), halflambert, lightmapped (true; false for a surface the engine lights per vertex), envmap_scale 1 and lightmap_scale 1 (Black Ops' envMapParms: how much reflection probe the surface takes and the scale its lightmap is read at), and anim. The absent Vec3 fields read as zero.

anim is idTech3 shader animation: tcmods, each a kind (scroll, scale, rotate, turb, stretch as the source shader numbered them) with four scalar arguments and an optional wave; rgb_wave and alpha_wave; frames, the texture indices of an animated map, at fps; and deform, a vertex deform with its kind, spread, wave, vector and bulge. A wave is a function index and base, amp, phase, freq, exactly as the shader script wrote them.

posVec30normalVec312uvVec224lm_uvVec232colorRgba840tangentTangent4452
Vertex: 52 bytes, no padding, copied straight into a vertex buffer on a little-endian machine.

textures and cubemaps

An image is a width, a height, an encoding, a colour space, a mip count, a layer count and one byte blob. Encodings are PNG, JPEG, raw RGBA8, and the block compressed BC1, BC3, BC4, BC5, BC6H and BC7. Block-compressed images hold their whole mip chain, level-major and layer-minor: all layers of level 0, then all layers of level 1, and so on, each level ceil(w/4) * ceil(h/4) * block bytes where w and h halve per level and never drop below 1. A cubemap is an image with six layers in the +X, -X, +Y, -Y, +Z, -Z order.

textures and skies have a second, keyed form at payload version 1, which is the one a bake writes: every image names its bytes rather than carrying them. Such an image carries blob, a BlobRef of the image's key split into two little-endian halves (key_lo is bytes 0..8 of the key, key_hi bytes 8..16) and the size of its raw bytes, and never bytes; an image with no blob has no bytes. The bytes live in the level's own blvl.assets or in any blob file. Version 0 carries the bytes inline and is still read, since the levels baked before the keyed form hold it. At version 0, and in every other section that carries an image, an image never carries blob. A reader refuses a section that breaks either rule.

lightmaps

A lightmap is band-1 irradiance spherical harmonics in the surface's tangent frame. color is the peak irradiance L0 + |L1| and each of the three optional l1 planes is that channel's L1 / color, so every component lands in -1..1 and a shader reads E = color * (1 - |w|) + max(dot(w, n), 0) * color. A lightmap without l1 is flat. sun_visibility is an optional per-texel visibility of the profile's sun, and gain is the multiplier a reader applies to the colour plane to recover the authored range. It is 1.0 for Urban Terror, Source 1, Source 2, VALORANT, Overwatch 2, Call of Duty 4, Modern Warfare 2 and Black Ops; 2.0 for Quake 3, whose maps are authored two overbright bits above what the hardware applied; 2.0 for Halo 2, whose renderer multiplies texture by lightmap in gamma space and doubles the product the way the game's own modulate-2x combiner did; and 2.2 for Reflex Arena. A Quake 3 map that names its own overbright shifts that 2.0 with it.

encoding says what the colour plane's bytes are. LinearHdr, the default and what every older file holds, is linear light in the block format's own range. Gamma8 is the game's own 8-bit gamma-space bytes stored as sRGB (idTech3, IW3 and Halo 2 lightmaps), which a renderer multiplies with the texture in gamma space and shifts by the profile's overbright, or scales by the lightmap's gain, rather than decoding to linear.

bump is the three Source 1 bump-basis pages, exactly three images or none, in the basis order (0.8165, 0, 0.5774), (-0.4082, 0.7071, 0.5774), (-0.4082, -0.7071, 0.5774); a normal-mapped surface weights them by the squared dot of its tangent-space normal with each basis vector. styles are up to three extra colour pages, each tagged with the light style (an index of lighting.light_styles) whose 10 Hz value scales it before it is added to color; style 0 is color itself and is never listed.

colorL0 + |L1|BC6H or Gamma8l1[0]R: L1 / colorl1[1]G: L1 / colorl1[2]B: L1 / colorsun_visibilityoptional, .rbump[3], styles[]each l1 texel is a tangent-space vector in -1..1, stored as (w · 127 + 128) / 255E = color · max(1 − |w|, 0) + max(w · n, 0) · colorn is the shading normal in the surface's tangent frame; with no normal map n = (0, 0, 1) and E ≈ color.Multiply by profile.lightmap_scale × lightmap.gain.A page without l1 decodes to exactly its flat irradiance.
Band-1 spherical harmonics in the tangent frame: three dot products, no square root and no divide, and a normal map bends the baked light instead of being ignored by it.
aerowalk litopen in viewer ↗
albedo × lightmap, the Gamma8 path: the page's bytes are multiplied in gamma space and clipped, the way idTech3 did.
aerowalk lightmapopen in viewer ↗
The lightmap alone. Quake 3 stores it dim and the renderer doubles it twice: once for the map's overbright bits, once at the screen.

minimap

A plan of the level seen from above, and the rectangle x0,z0 .. x1,z1 of level space it covers. Column 0 of the image is x0 and row 0 is z0, both axes at one scale, so plan pixel px,py is at x = x0 + px * (x1 - x0) / width, z = z0 + py * (z1 - z0) / height. Where the image's alpha is 0 there is nothing underfoot.

overlays, from version 1, are one per objective in sites (every kind but place): every site sharing a kind, a name and a team, which sites names in the overlay's sites list. Each is a PNG mask laid on the plan's own grid with its top-left corner at plan pixel x,y, and lies wholly inside the plan. Its colour is white and carries nothing; its alpha is how much of each plan pixel the objective covers. A mask covers the floor inside the objective's boxes, whatever stands over it, so a site under a roof still shows; the boxes themselves when no floor lies in them; and never less than eight pixels across, so a flag stand a few units wide stays visible. Which kinds a reader shows, and in what colour, is its own choice. A version 0 minimap has no overlays.

lighting

A lighting profile is one way to light the map; profile 0 is always the map as it was authored. It names its lightmap and sky, scales the lightmap and the lights, carries an ambient term, an optional sun (direction toward the sun, colour, intensity), fog, and optionally the sky's own irradiance as 27 floats: nine L2 spherical harmonic coefficients of RGB.

sun_units says what unit sun.intensity is in. Legacy, the default and what every older file holds, is the renderer-side scale bakes used before the per-engine data existed (a value clamped to 20..200). Source1, Source2, Ue4, Iw3 and Ow2 are that engine's own unit, carried raw so the renderer for that engine can apply its own formula. sun_shadow_channel is the channel of the lightmap's sun_visibility (or the light grid's) that holds the sun's baked shadow mask when the sun is a stationary light whose diffuse is evaluated at runtime, and sun_baked is true when the lightmap already holds the sun's diffuse (the Sun struct is frozen, so these live on the profile).

Fog: fog_color and fog_density are the exponential fog every profile has. fog_mode picks the engine's curve: Exp (the default), Linear between fog_start and fog_end capped at fog_max (Source 1), ExpStart (exponential from fog_start, IW3), Height (UE4 exponential height fog with fog_height_falloff), or Gradient (Source 2). None says the map carried no fog of its own and a renderer should add none, which is not the same as a zero density beside a real curve. The scalar fields are absent when the engine has no such parameter. fog_height_bottom and fog_height_top are the level-space heights (the y axis) between which the fog thins: full below the bottom, none above the top, and shaped by fog_height_falloff in between where the engine has one (Reflex, and Source 2's Gradient).

post is the map's own post-processing block: tonemap (an operator index the renderer for that engine understands), a fixed exposure (1) or the bounded auto-exposure range exposure_min..exposure_max with its adaptation exposure_rate (1) and exposure_compensation; bloom bloom_scale, bloom_threshold and bloom_exponent; IW3's film tweak film_contrast (1), film_brightness, film_desaturation, light_tint and dark_tint; the display gamma (2.2); a colour-grading lut texture; UE4's film curve film_slope, film_toe, film_shoulder, film_black_clip, film_white_clip; and vignette. A profile without post is graded the way the renderer graded every file before this block existed.

A lut is a cube of N³ colours kept as one uncompressed RGBA8 image N² wide and N high: blue picks the N-wide slice, red runs across the slice and green down it. A renderer looks up the colour it has finished tonemapping, clamped to 0..1, sampling texel centres, and takes the table's colour in its place. Overwatch 2 and Counter-Strike 2 carry 32³ tables.

Source 2 tonemaps with Hable's curve too. Its six coefficients are in the curve_* fields described below and, for renderers that read them there first, in film_slope (shoulder), film_shoulder (linear), film_toe, film_black_clip (toe numerator) and film_white_clip (toe denominator); its linear white point is film_contrast and its exposure bias in stops is film_brightness.

Black Ops grades in three zones rather than two and tonemaps with Hable's curve, so its film is its own set of fields: mid_tint between dark_tint and light_tint, blended over mid_start..mid_end with dark_feather and light_feather softening each end; per-channel contrast, saturation, color_temp and bleach beside the scalar film_contrast and film_desaturation the other engines write; and curve_shoulder, curve_linear, curve_linear_angle, curve_toe, curve_toe_numerator and curve_toe_denominator, which are Hable's six coefficients. A zero curve_toe_denominator means the profile has no curve. That engine also draws in a gamma-2 space, so its gamma is 2 where IW3's is 2.2.

lights are derived from emissive surfaces. The light_grid is the volume that lights whatever is not level geometry: three images over a regular grid holding ambient colour, directional colour and direction, which every grid carries whatever its kind. AmbientDirectional is exactly those three images (idTech3, and the default). AmbientCube adds faces, six images in the +X, -X, +Y, -Y, +Z, -Z order holding the colour arriving from each axis (Source 1 leaf ambient, Source 2 light probe volumes, IW3's model lighting), with the triple derived from them so an older reader still works. Sh2 is a per-channel band-1 grid (UE4's indirect lighting cache) stored the same way. A grid may carry its own sun_visibility image, and its encoding follows the lightmap's meaning. gain is what the stored bytes are multiplied by: a volume whose light runs past one is normalised into the page and scaled back by the reader, so an ambient cube built from a sun and its lights keeps its range instead of clipping. sky_vis is a voxel grid of how much sky each cell sees. Reflection probes name a cubemap, the box they influence and an optional parallax shape.

Punctual lights are the point and spot lights the source map placed, with whether the lightmap already holds their diffuse. falloff is how intensity decays with distance: SmoothFalloff (absent, or the table with no fields) is the smooth inverse-square window the renderer always used; SourceQuadratic is Source 1's 1 / (c + l*d + q*d*d) with the fifty and hundred percent distances the map authored; Iw3Falloff is IW3's linear window to radius.

fog_volumes are idTech3 fog brushes: an axis-aligned box, the fog colour and the depth at which it turns opaque (fog = sqrt(clamp(dist / depth, 0, 1))). Batches point at them by index. light_styles are the light style patterns (a..z strings, m is full brightness, sampled at 10 Hz) that lightmap style layers and switchable lights refer to by index.

entities

The source map's entities, kept as text: a classname and key/value pairs. Nothing in the core interprets them; they are the evidence a game reads for whatever the core sections do not model.

acoustics

A lattice of probes standing on the walkable floors at ear height, linked by edges where one probe can see the next with every door held open, and grouped into rooms by how alike their surroundings are. A game finds the probe nearest a sound and the one nearest the listener, walks the edges between them, and gets the distance the sound actually travelled rather than the straight line through the wall.

A probe carries its position, the room it belongs to, the share of rays cast from it that hit something within 45 m as a byte over 255, and the mean distance those rays ran in metres capped at 255. first_edge indexes edges; a probe's edges run to the next probe's first_edge, and the last probe's to the end.

An edge is one-way and always has its opposite in the list, so a walk needs no reverse index. len_m is the distance between the two probes. door is the door team standing between them, 65535 for none, so a reader can charge for a shut door without tracing again; a door's team is the lowest index of the leaves that make up one doorway. narrow marks an edge that a 72 by 72 qu box cannot pass along, which is a gap rather than an opening.

A room's rt60_s is its reverberation time and damping how much of a reflection it keeps, both derived from the mean enclosed and size_m of its probes. outdoor is set when the room's probes see the sky more often than not.

The section is absent when a bake produced no probes, which is the case for a level with no walkable floor.

props

Models placed in the level that are not part of the world mesh: each has its own vertices, indices and collision hulls, and each placement says where it sits, whether it can move, and how solid it is. Absent when the bake placed none.

A model may carry lods, its detail steps over meshes, nearest first: each step is a run of meshes and the distance past which the next step takes over, the last step reaching to infinity. A step may draw no meshes, which is how a model stops being drawn past the step before it. A model with no lods is one step over all of its meshes, which is every prop baked before v1. The steps exist because a big map places one foliage model tens of thousands of times, and a reader with no cheaper mesh set to fall back to draws a hundred million triangles of grass.

A placement may carry scale, a uniform scale on the model, 1 for every engine that places props at their authored size, and scale3, the same thing per axis. A reader applies it after the rotation, and takes scale3 when it is present and scale on all three axes when it is not, so a level written before v3 still places its props. A writer that fills scale3 also fills scale with the largest of the three, so a reader that predates the field draws something close rather than nothing. A scale that differs per axis skews the model's normals, so a reader turns a normal by the inverse transpose -- n / scale, then the rotation -- rather than by the rotation alone.

cameras

Viewpoints somebody chose for the level, so a game can show it from a good angle without guessing. Each camera has a name, a purpose, a pos in Quake units (+Y up, -Z forward), yaw and pitch in degrees, roll in degrees about the view axis, and fov_x, the horizontal field of view in degrees as a game quotes it for a 4:3 view. A yaw of 0 looks down -Z and a positive yaw turns toward -X; a positive pitch looks up. This is the same convention the blvl probe --pose argument uses.

purpose is one of thumbnail, overview, menu or other. A reader that wants a view for one purpose takes the first camera that has it, and frames the level itself when none does. Bakers write these from a file kept beside the recipe, so a rebake carries them unchanged; with no such file they take the viewpoints the map itself names, such as the point_devshot_cameras its authors took Source's screenshots from, the first of them the thumbnail.

A core section is not carried through by a writer that predates it: a tool built before cameras existed drops the section when it rewrites a level. Extensions survive that; core sections do not.

ut4_uptownopen in viewer ↗
Urban Terror's ut4_uptown, from its first spawn. Where a bake carries no cameras section a reader frames the level from a spawn point, as the viewer does here.

sites

Objective areas the source map marks out: a bomb site, a hostage, a capture point. Each site has a name (A, B, ...), a kind, an optional team and an axis-aligned box lo..hi in Quake units. A site that covers several boxes appears once per box under the same name, and a reader unions them. Absent when the map marks none.

The kinds are bombsite; hostage, where a hostage starts; rescue, where one is taken; capture, a point to hold; flag, a flag's stand; buyzone, where the team it names may buy; place, a named part of the map that players call out -- Counter-Strike's Long A or Catwalk -- which is ground rather than an objective; and other. Versions 0 and 1 have none of buyzone, rescue or place, and call a rescue zone hostage along with the hostages themselves. A reader that meets a kind it does not know takes it as other.

Reading a level

  1. Read 24 bytes, check the magic and version, read the count.
  2. Read the table, validate it as above.
  3. For each section you want, seek to its offset, read packed bytes, inflate the chunks into raw bytes, verify the FlatBuffer, convert.
  4. If textures or skies is at version 1, find each image's blob in the level's blvl.assets, which the table alone places, and look the rest up by key in whatever blob files you have.
  5. Skip any tag you do not know; keep its bytes if you intend to write the file back.

The Rust crate blvl in this repository is the reference reader and writer. blvl-trace expands colliders into the planes a swept-box trace needs and is the reference for what Hull means.

Symbols sidecar (.bsym)

A bake may write a second file beside the level, <name>.bsym, that says where every collider and every run of triangles came from in the source map: the brush, face, patch, prop or physics shape, the engine's own surface and contents flags, the entity that placed it, and the material before the bake merged it, with its shader and texture paths. It is a debug artefact. Nothing in the level refers to it, it is never hashed into the level, a game runs without it, and a shipping build may leave it out.

offsetsizefield
08magic BSYM\0\0\0\0
84u32 symbols version, currently 0
128u64 content hash of the .blvl this describes
204u32 raw size of the payload
24one zstd frame holding a blvl.symbols.Symbols FlatBuffer

The hash is the level's own header hash. A reader compares it with the level it has and treats a mismatch as stale: symbols never describe a level other than the one they were written for. A reader that sees a version other than its own reports that and reads nothing.

colliders is dense and parallel to the level's sim.colliders. tri_runs is sorted by first_tri, contiguous, and covers every triangle of geometry.indices; a batch starts in the run holding first_index / 3. Both name rows of sources, one per source element (a brush, a face, a patch, a prop instance, a physics shape), which name materials, the pre-merge material table whose baked field is the level material it ended up as. Every 32-bit id field uses 0xFFFFFFFF for none. Struct layouts are frozen within a version; new data arrives as new optional table fields, and a struct or semantic change bumps the version.

The Rust crate blvl reads and writes it (blvl::symbols), and blvl-diag turns a level and its symbols into checks and text dumps for blvl probe and for games.

Blob files (.bass)

Most of a baked level is its images, and the maps of one game share most of theirs byte for byte: across one catalog of 254 maps, two thirds of the image bytes were copies of an image another map of the same game already carried. A blob file is a small database of blobs named by their contents. It has one layout and three uses: the blvl.assets a level carries its own images in, the shared.bass a map repository publishes with every image more than one of its maps names, and the cache a game fills as it fetches. None of them decides which blobs belong where. A reader looks a blob up by its key in whatever files it has, and any file that holds a key holds the same bytes.

A blob's key is the first 16 bytes of blake3 over its raw bytes. A reader inflates a blob and checks it against its key before using it, every time: one bad blob in a shared file would otherwise corrupt every level that draws on it.

A blob file is a 64-byte header, a reserved region that holds its index, and the blobs' payloads from the payload floor on.

offsetsizefield
08magic BASS\0\0\0\0
84u32 version, currently 1
124u32 flags, bit 0 appendable, the rest written 0 and ignored by readers
168u64 content id
248u64 index digest
324u32 blob count, at most 2^20
364u32 reserved, written 0, ignored by readers
408u64 index offset
488u64 payload floor, a multiple of 4096
568u64 stamp: 0 when sealed; a cache takes a new one each time it is rebuilt

The index at index offset is a fanout of 256 u32 counts, entry b being how many blobs have a key whose first byte is at most b, so the last entry is the blob count; then one 64-byte entry per blob, sorted strictly by key:

offsetsizefield
016key
162u16 kind: 1 image, 2 reserved for meshes, 3 audio
182u16 flags, written 0, ignored by readers
204u32 arrival: 0 when sealed; in a cache, the second since 2020 it took the blob in
248u64 offset of the payload in the file
328u64 packed size of the payload
408u64 raw size of the blob, below 2 GiB
4816descriptor

The descriptor of an image is u32 width, u32 height, u8 encoding, u8 colour space, u8 mip count and u8 layer count, in the numbering blvl.Image uses, then four zero bytes. It is there so a file can say what it holds without a level beside it; a reader takes an image's meaning from the level that names it. A reader does not refuse a kind it does not know, since it never needs one to find a blob.

Each payload is stored exactly as a section payload is: chunks of at most 4 MiB raw, a chunk index and a chunk count.

The content id is blake3 over 16 bytes of magic, a u32 0 and the u32 blob count, followed by the key and the u64 raw size of every blob in index order, first 8 bytes. Two files with the same content id hold the same blobs, however their payloads are ordered and compressed. The index digest is blake3 over 16 bytes of magic, the u32 version and the u32 blob count, followed by the fanout and the entries, first 8 bytes.

Sealed and appendable

A sealed file, flag bit 0 clear, is written once. Its index sits directly behind the header at offset 64, and its payload floor is the end of the index rounded up to 4 KiB, so the first 4 KiB of the file hold the header, the fanout and the first 47 entries. A published shared.bass, a level's blvl.assets and the bundle a server streams to a client are sealed.

An appendable file, flag bit 0 set, is a cache. It reserves two index slots of equal size between the header and the payload floor and keeps the live index in one of them, or, once the index outgrows a slot, after every payload the index lists. It grows by appending, and commits in this order:

  1. Append the new payloads at the end of the file, and sync.
  2. Write the new index, sorted by key and holding every blob old and new, into the slot the live index is not in, or past the new payloads when it does not fit a slot, and sync.
  3. Write bytes 16..48 of the header, content id through index offset, in one write, and sync.

Those 32 bytes sit inside the first sector, so the commit cannot tear, and everything written before it is unreferenced until it lands: a crash at any point leaves the previous commit intact, with whatever it appended unreferenced. A writer never writes over a byte an index referenced. Dropping blobs is a commit of a smaller index that leaves their payloads where they were, so a reader still holding the old index reads valid bytes; giving that space back is a rebuild into a new file, with a new stamp, renamed into place. One writer at a time holds the lock on the file <name>.lock beside the cache, and readers take none.

Reader rules

A reader refuses a blob file whose magic or version it does not know; whose count is past 2^20; whose payload floor is not a multiple of 4096, is below 4096 or is more than two slots of 2^20 entries take; whose index starts inside the header, runs across the payload floor, runs past the end of the file or overlaps a payload; whose index does not match its digest or its content id; whose fanout falls or does not end at the count; whose keys repeat, are out of order or sit outside their fanout bucket; whose payloads overlap, run below the floor or past the end of the file; or whose blobs claim more than 16 * covered + 256 MiB raw in all, where covered is the floor plus every payload's packed size plus a spilled index. It also refuses a sealed file whose index offset is not 64, whose floor is not the one its count gives, or that carries more than max(1 MiB, covered / 16) bytes no entry refers to. An appendable file may carry any number of those: slack in its slots, a stale index, dropped payloads and a torn append.

Reading a blob

  1. Read the first 4 KiB, or the whole file when it is shorter, and parse the header.
  2. If the index lies beyond what was read, read it: 1024 + 64 * count bytes at the index offset. Check it against its digest. A reader fetching over a network does this once per file and keeps it; the content id names what it keeps.
  3. Find the key: the fanout bounds the run of entries to search, and the run is sorted.
  4. Read packed bytes at the entry's offset, inflate them into raw bytes and check them against the key.

Levels and their blobs

A level whose textures and skies are at version 1 names each image by key and carries whichever of those images it keeps in blvl.assets. Its identity covers the keys and not the bytes, so a copy that carries every image, one that carries only the images no other level shares and one that carries none are the same map, and every other section of them is byte for byte the same. Keying a level that carried its images inline renames it once. A server reads a level's sim exactly as it reads any other and needs no blob at all.

A writer lays the payloads one level draws on out together, so a reader fetching that level's blobs over a network reads a few long ranges rather than many short ones.

A repository over HTTP

This is a convention rather than a rule. A map repository publishes its levels each carrying the images only it names, and beside them the images two or more of them name, split across one or more parts: shared.001.bass, shared.002.bass and so on, numbered from 1, zero-padded to at least three digits, with a shared.json beside them naming the set.

{
  "version": 1,
  "parts": [
    { "name": "shared.001.bass", "bytes": 268429312, "blobs": 1423, "content": "d07d172f47a97cc1" }
  ]
}

A part is a sealed blob file like any other, and the parts do not divide the keys between them on any rule: a reader looks a key up in the parts it has, the way it looks one up in whatever blob files it has. content is the part's content id in hex, so a reader can tell a part it already holds from one it does not without reading it.

A part is written once and never written again. Sharing again leaves every published part where it is and puts the blobs it adds in a new part, so only shared.json changes between one publication and the next. That is what lets a repository serve a part with a long Cache-Control and a reader keep one for as long as it likes: a part that is there at all holds the bytes it always held, and no reader ever has to notice one changing under it. shared.json itself is small and is the only thing a repository serves with Cache-Control: no-cache.

Part numbers only ever go up, and a number is never used twice. A repository that has let go of blobs no published level names any more writes what it keeps into parts numbered on from the last and drops the parts nothing refers to, so even then no name comes back meaning different bytes. A reader that kept a part still checks its content against shared.json, which costs nothing and says plainly whether what it kept is what is published.

Parts are capped so each one is small enough for whatever serves them to hold; 256 MiB suits an ordinary CDN. The cap is counted in the bytes the blobs take unpacked, so a part is at most that big on disk and usually well under.

So a reader fetches a level's sections by range, places the images its blvl.assets holds from the table, reads shared.json, reads the header and index of each part once, and fetches the rest by range. A reader that has the files rather than a server needs none of this: it looks in the .bass files beside the level, shared.001.bass and the rest among them, and shared.json tells it nothing it cannot see.

Earlier repositories published a single shared.bass with no manifest, replaced in place whenever the levels it served changed, and a reader pinned it by sending the ETag it first saw as If-Match on every range and opening the file again on a 412. A reader still accepts that: no shared.json and a shared.bass beside the levels is the one-part case. Nothing requires a reader to have a blob file at all: a level that carries all its images needs none.

Version 0

Earlier builds wrote write-once packs at version 0: the index fixed at offset 1088, an optional catalog behind it, the payload floor worked out from both, and an index digest over everything from offset 64 to the floor with version 0 in its prefix. Their content id is the one version 1 uses. The levels those builds thinned carry a blvl.packs section naming the packs their images went into. The reference reader still reads version 0 packs, so those levels can be carried over, and drops blvl.packs whenever it rewrites a level; nothing writes either any more.

The Rust crate blvl reads and writes blob files (blvl::bass), and keys, embeds and strips levels (blvl::embed).