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
A file is a 64-byte header, a reserved table region, and the sections' payloads.
| offset | size | field |
|---|---|---|
| 0 | 8 | magic BLVL\0\0\0\0 |
| 8 | 4 | u32 container version, currently 0 |
| 12 | 4 | u32 flags, written 0, ignored by readers |
| 16 | 8 | u64 identity hash |
| 24 | 8 | u64 table digest |
| 32 | 4 | u32 section count, at most 1024 |
| 36 | 4 | u32 reserved, written 0, ignored by readers |
| 40 | 8 | u64 table offset |
| 48 | 8 | u64 payload floor |
| 56 | 8 | u64 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:
| offset | size | field |
|---|---|---|
| 0 | 16 | tag: ASCII [a-z0-9._-], NUL-padded, first byte non-NUL |
| 16 | 2 | u16 payload version |
| 18 | 2 | u16 flags, bit 0 identity, the rest written 0 |
| 20 | 4 | u32 reserved, written 0, ignored by readers |
| 24 | 8 | u64 offset of the payload in the file |
| 32 | 8 | u64 packed size of the payload |
| 40 | 8 | u64 raw size of the payload once inflated |
| 48 | 16 | blake3 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.
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.
FlatBufferssection blvl.shotsroot blvl.shots.Shots3 types in shots.fbs
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:
| offset | size | field |
|---|---|---|
| 0 | 16 | key |
| 16 | 8 | u64 raw size |
| 24 | 2 | u16 kind, numbered as in a blob file's index |
| 26 | 6 | written 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.
| tag | required | versions | root table | carries |
|---|---|---|---|---|
meta | 0 | blvl.meta.Meta | the level's name, title, version, author, description, engine and tags | |
variants | 0 | blvl.variants.Variants | the map's own named layouts, their groups and defaults | |
sim | yes | 0, 1, 2, 3, 4, 5, 6, 7, 8 | blvl.sim.Sim | name, bounds, kill plane, colliders, breakables, doors, triggers, teleport destinations, spawn points, hurt volumes, movers, rides, climbs, use targets, metadata |
geometry | yes | 0, 1, 2, 3 | blvl.geometry.Geometry | materials, vertices, indices, draw batches |
textures | 0, 1 | blvl.Images | the images materials index | |
lightmaps | 0 | blvl.lightmaps.Lightmaps | lightmap atlases | |
skies | 0, 1 | blvl.skies.Skies | skybox cubemaps | |
minimap | 0, 1 | blvl.minimap.Minimap | a top-down plan and the rectangle it covers | |
lighting | 0, 1, 2 | blvl.lighting.Lighting | lighting profiles, derived lights, the light grid, sky visibility, reflection probes, punctual lights, fog volumes, light styles | |
entities | 0 | blvl.entities.Entities | the source map's entities as classname plus key/value pairs | |
cubemaps | 0 | blvl.Images | the cubemaps reflection probes index | |
acoustics | 0 | blvl.acoustics.Acoustics | where sound can travel, and the reverberation of the room it travels through | |
props | 0, 1, 2, 3 | blvl.props.Props | placed models with their own meshes, detail steps and hulls | |
cameras | 0 | blvl.cameras.Cameras | authored viewpoints: a name, a purpose, a position and angles | |
sites | 0, 1, 2 | blvl.sites.Sites | objective 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.
FlatBufferssection variantsroot blvl.variants.Variants3 types in variants.fbs
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.
- 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
- 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
FlatBufferssection simroot blvl.sim.Sim41 types in sim.fbs
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.
FlatBufferssection geometryroot blvl.geometry.Geometry22 types in geometry.fbs
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.
FlatBufferssection textures, cubemapsroot blvl.Images14 types in common.fbs
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.
FlatBufferssection lightmapsroot blvl.lightmaps.Lightmaps3 types in lightmaps.fbs
FlatBufferssection skiesroot blvl.skies.Skies2 types in skies.fbs
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.
FlatBufferssection lightingroot blvl.lighting.Lighting23 types in lighting.fbs
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.
FlatBufferssection entitiesroot blvl.entities.Entities3 types in entities.fbs
FlatBufferssection minimaproot blvl.minimap.Minimap3 types in minimap.fbs
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.
FlatBufferssection acousticsroot blvl.acoustics.Acoustics4 types in acoustics.fbs
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.
FlatBufferssection propsroot blvl.props.Props7 types in props.fbs
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.
FlatBufferssection camerasroot blvl.cameras.Cameras3 types in cameras.fbs
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.
FlatBufferssection sitesroot blvl.sites.Sites3 types in sites.fbs
Reading a level
- Read 24 bytes, check the magic and version, read the count.
- Read the table, validate it as above.
- For each section you want, seek to its offset, read
packedbytes, inflate the chunks intorawbytes, verify the FlatBuffer, convert. - If
texturesorskiesis at version 1, find each image's blob in the level'sblvl.assets, which the table alone places, and look the rest up by key in whatever blob files you have. - 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.
| offset | size | field |
|---|---|---|
| 0 | 8 | magic BSYM\0\0\0\0 |
| 8 | 4 | u32 symbols version, currently 0 |
| 12 | 8 | u64 content hash of the .blvl this describes |
| 20 | 4 | u32 raw size of the payload |
| 24 | one 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.
FlatBufferssection .bsymroot blvl.symbols.Symbols11 types in symbols.fbs
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.
| offset | size | field |
|---|---|---|
| 0 | 8 | magic BASS\0\0\0\0 |
| 8 | 4 | u32 version, currently 1 |
| 12 | 4 | u32 flags, bit 0 appendable, the rest written 0 and ignored by readers |
| 16 | 8 | u64 content id |
| 24 | 8 | u64 index digest |
| 32 | 4 | u32 blob count, at most 2^20 |
| 36 | 4 | u32 reserved, written 0, ignored by readers |
| 40 | 8 | u64 index offset |
| 48 | 8 | u64 payload floor, a multiple of 4096 |
| 56 | 8 | u64 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:
| offset | size | field |
|---|---|---|
| 0 | 16 | key |
| 16 | 2 | u16 kind: 1 image, 2 reserved for meshes, 3 audio |
| 18 | 2 | u16 flags, written 0, ignored by readers |
| 20 | 4 | u32 arrival: 0 when sealed; in a cache, the second since 2020 it took the blob in |
| 24 | 8 | u64 offset of the payload in the file |
| 32 | 8 | u64 packed size of the payload |
| 40 | 8 | u64 raw size of the blob, below 2 GiB |
| 48 | 16 | descriptor |
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:
- Append the new payloads at the end of the file, and sync.
- 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.
- 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
- Read the first 4 KiB, or the whole file when it is shorter, and parse the header.
- If the index lies beyond what was read, read it:
1024 + 64 * countbytes 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. - Find the key: the fanout bounds the run of entries to search, and the run is sorted.
- Read
packedbytes at the entry's offset, inflate them intorawbytes 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).




