The .vwr Format
Galaxy Eggbert's binary world file: a 40-byte header, a chunk table, and palette-compressed chunks with optional sparse metadata.
Every world the game plays or edits is a .vwr file: the 78 campaign worlds and the
other files in worlds3d/, every custom slot and every galaxy world. The source of
truth is the code: src/GalaxyEggbert/Worlds/World.cpp (header and table),
Chunk.cpp (chunk payload), BitPacking.cpp, and the record codecs. The
repository's World Format.md describes the same layout with its history. This page
describes the format as of 2026-11-10: header version 2, chunk version 2.
All numbers are little-endian. Offsets are in bytes.
File layout
WORLD HEADER 40 bytes magic "VWR1"
CHUNK TABLE chunkCount * 20 one entry per chunk, empty ones included
CHUNK DATA variable one "VCH1" payload per non-empty chunk
An all-air default world is therefore 40 + 1000 × 20 = 20 040 bytes.
World header
| Offset | Size | Field | Value / meaning |
|---|---|---|---|
| 0 | 4 | magic | ASCII VWR1 |
| 4 | 1 | version | 2 (VoxelConfig::FormatVersion). Anything else is rejected; version 1 files (before 2026-07-09) no longer load. |
| 5 | 1 | chunkSize | 10; checked. |
| 6 | 1 | chunksPerAxis | 10 in every world the game makes. |
| 7 | 1 | flags | bit 0: independent saw cells (below). Any other bit set is a load error. |
| 8 | 4 | chunkCount | Must equal chunksPerAxis³. |
| 12 | 4 | chunkTableOffset | Where the table starts; the writer puts it at 40. The reader seeks there. |
| 16 | 4 | chunkDataOffset | Start of the chunk data. Written, but only informational for the reader (each table entry has its own offset). |
| 20 | 4 | skyRegion word | Low byte: sky/background id 0-31 (Content/backgrounds/decorNNN.png). Second byte: music track 0-10 (0 none, 1-10 music000-009.ogg, since 2026-11-02). Upper two bytes 0. |
| 24 | 4 | missionNumber | The mission this world is; 0 = none. |
| 28 | 4 | spawnX + 1 | The explicit Blupi start cell (the editor's yellow start), each coordinate stored plus one. All three 0 = no explicit start (older worlds), and the game uses its historical default. Either all three are non-zero and inside the world, or the file is rejected. |
| 32 | 4 | spawnY + 1 | |
| 36 | 4 | spawnZ + 1 |
Flag bit 0: independent saw cells
Since 2026-11-08 a saw occupies its own cell and implies nothing about the cell under it; files
written since then set bit 0. Files with bit 0 clear come from the time when a saw was drawn with
an invented floor. The reader keeps their data as is and reports
World::hasLegacySawFloors(); WorldRuntime converts them in memory before
play or editing (see World Loading), and saving the converted
world sets the bit. Loading alone never rewrites a file.
Chunk table
chunkCount entries of 20 bytes. The writer emits them with X fastest, then Y, then Z;
the reader uses each entry's own coordinates.
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 8 | offset | Absolute file offset of the chunk payload (0 when empty) |
| 8 | 4 | size | Payload size in bytes (0 when empty) |
| 12 | 2 | x | Chunk X |
| 14 | 2 | y | Chunk Y |
| 16 | 2 | z | Chunk Z |
| 18 | 2 | flags | 0x0001 = EMPTY |
A chunk is written as EMPTY only when it is all air and has no sparse metadata
(Chunk::isEmpty()); an object anchored in an otherwise empty chunk keeps its chunk.
A non-empty entry with size 0, or coordinates outside the grid, is a load error.
Block encoding
raw (uint16) = (type << 4) | metadata
bits 15..4 type 0..4095 (the game uses 0..440: object-m.png icons)
bits 3..0 metadata 0..15 (unused by the shipped worlds)
raw 0 Air
See Block and BlockDefinitionRegistry.
Chunk payload VCH1
Header (24 bytes, chunk version 2)
| Offset | Size | Field | Value / check |
|---|---|---|---|
| 0 | 4 | magic | ASCII VCH1 |
| 4 | 1 | version | 2 (version 1 is still readable) |
| 5 | 1 | chunkSize | 10 |
| 6 | 1 | bitsPerBlock | 1-8; must equal bitsNeededForPalette(paletteCount) |
| 7 | 1 | flags | bit 0: has a metadata section; other bits rejected |
| 8 | 2 | paletteCount | 1-256 |
| 10 | 2 | reserved | must be 0 |
| 12 | 4 | blockCount | must be 1000 |
| 16 | 4 | dataSizeBytes | packed stream size; a multiple of 8 equal to the expected word count × 8 |
| 20 | 4 | extraMetaSizeBytes | size of the metadata section; non-zero exactly when flag bit 0 is set |
A version 1 chunk has the first 20 bytes only, no flags and no metadata. The writer always produces version 2.
Body
palette[paletteCount] uint16 raw block values
packed[dataSizeBytes / 8] uint64 words
metadata[extraMetaSizeBytes] optional BMD1 section
Palette and bit packing
Each of the 1000 cells stores an index into the chunk's palette, in cell order
i = x + y * 10 + z * 100 (local coordinates). Index i occupies bits
[i * bits, (i + 1) * bits) of the word stream, counting from the least significant bit
of word 0; an index may straddle two words. The width depends on the palette size:
| Palette entries | 1-2 | 3-4 | 5-8 | 9-16 | 17-32 | 33-64 | 65-128 | 129-256 |
|---|---|---|---|---|---|---|---|---|
| Bits per cell | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| Packed bytes | 128 | 256 | 376 | 504 | 632 | 752 | 880 | 1000 |
The word count is ceil(1000 × bits / 64). A chunk with four distinct blocks is
24 (header) + 8 (palette) + 256 (packed) = 288 bytes, against 2000 bytes of raw blocks. The palette
is written as it is in memory; entries no longer used after editing are kept.
Sparse metadata section BMD1
| Size | Field | Meaning |
|---|---|---|
| 4 | magic | ASCII BMD1 |
| 2 | version | 1 |
| 2 | reserved | must be 0 |
| 4 | recordCount | number of records |
Then recordCount records:
| Size | Field | Meaning |
|---|---|---|
| 4 | localBlockIndex | 0-999, the anchor cell inside the chunk |
| 2 | metadataType | what the payload is (table below) |
| 2 | payloadSize | N |
| N | payload | type-specific bytes |
A cell holds at most one record per type. The section must contain exactly the declared bytes; trailing data is an error. Unknown types are kept and written back unchanged, which is why new types could be added without a version change.
Sparse metadata types
| Type | Name in code | Payload | Meaning |
|---|---|---|---|
| 1 | kMoveObjectMetadataTypeMoveObjectRecord.hpp | 47 bytes: type (u8), start X/Y/Z, end X/Y/Z, speed, advance, recede, stop-start, stop-end ticks (11 × float32), visual icon (u16). The 45-byte form without the icon is still read. | One object (pickup, enemy, lift, crate, vehicle, exit...), anchored at floor(start). See MoveObjectRecord. |
| 2 | kPlateRotationMetadataTypeGame/PlateRotationMetadata.hpp | 1 byte, 0-3 | A tile's turn. Plates, fans and wind tiles read only the odd bit: X and Z axes swapped (old worlds store 0 or 1). |
| 3 | kBigDecorMetadataTypeBigDecorRecord.hpp | 2 bytes: icon (u16, non-zero) | A BigDecor picture from explo.png standing in that cell; never a voxel. |
| 4 | kHintZoneMetadataTypeHintZoneRecord.hpp | 4 bytes: recordIndex, sizeX, sizeY, sizeZ (u8 each, sizes ≥ 1) | The box, from this cell, where training hint recordIndex of the mission applies (2026-10-25). |
| 5 | kObjectTurnMetadataTypeGame/PlateRotationMetadata.hpp | 1 byte, 0-3 | Quarter turns of the still object, parked vehicle or BigDecor in that cell, and of front-facing terrain models and single-face cubes set in the editor. Separate from type 2, so turning a treasure lying in a wind cell does not turn the wind (2026-11-04). |
| 6 | kLegacySwitchSawRowMetadataTypeGame/LegacySawFloors.hpp | 1 byte: {1} | Written only by the legacy saw upgrade on a switch whose saw row moved up one cell; that switch drives its X ± 20 row at Y + 1. |
| 7 | kPlayerStartMetadataTypePlayerStart.hpp | 1 byte: bit 1 orange, bit 2 blue, bit 3 green | The editor's extra coloured player starts at this cell (yellow is the header spawn). Editor data only; single-player uses yellow. |
| 8 | kPlayerStartHeadingMetadataTypePlayerStart.hpp | 1 byte: 2 bits per colour (yellow bits 0-1, orange 2-3, blue 4-5, green 6-7) | Each start's facing in quarter turns: 0 −Z, 1 −X, 2 +Z, 3 +X. An all-zero record is omitted. |
Types 1 to 4 are present in the shipped worlds. Counted across the 81 files in
worlds3d/ on 2026-11-10: 4150 objects, 398 BigDecor, 87 plate turns and 40 hint zones;
no file uses types 5-8 or a non-zero block metadata nibble.
Tools
tools/vwr_inspect.py: read-only inspector (header fields, block counts per type and height, objects, BigDecor, hint zones).--summarygives one line per world. It decodes types 1-4 and counts the others.tools/worldkit.py: the Python world builder that writes this format (used by the generators intools/worlds/).tools/GenerateSampleWorld3D.cpp: a C++ generator using the real classes.
python3 tools/vwr_inspect.py worlds3d/world999.vwr
python3 tools/vwr_inspect.py --summary worlds3d/*.vwr
World.cpp/Chunk.cpp,
in both Python tools and, for a new record type, in its codec; then run
VerifyShippedWorlds and the world tests.Not implemented
World Format.md also sketches ideas that do not exist in the code: a header with
separate X/Y/Z chunk counts for non-cubic worlds, RLE or LZ4/Zstd compression of chunks, global
palettes and region files. None of these is read or written by any version of the game.
Tests
tests/GalaxyEggbert/Worlds/WorldTests.cppandChunkTests.cpp: every header field round-trips; bad magic, version, flags, chunk count, palette size, bit width and metadata framing are rejected; legacy chunk version 1 is read.tests/GalaxyEggbert/*RecordTests.cpp: the record codecs.VerifyShippedWorlds: every shipped world survives load → save → load.