This document describes the binary format structure for BeamNG.drive .ter terrain files, based on reverse engineering analysis and community research from the BeamNG forums.
Key Sources:
- BeamNG Forum Discussion
- BeamNG Level Template Creator
- Community reverse engineering efforts
The .ter file begins with a 5-byte header:
| Offset | Size | Type | Description |
|---|---|---|---|
| 0x00 | 1 | char | Format version number (typically 9) |
| 0x01 | 4 | uint32 (little-endian) | Terrain dimensions (size × size) |
BeamNG uses a bottom-left origin coordinate system for terrain:
- World Origin: For a 1024×1024 terrain, the world center is at (0, 0, 0)
- Terrain Bounds: Spans from (-512, -512) to (+512, +512) in world units
- Array Indexing:
heightmap[y][x]where:heightmap[0][0]= bottom-left corner = world position (-512, -512)heightmap[1023][1023]= top-right corner = world position (+512, +512)- Y-axis: Increases from bottom (south) to top (north)
- X-axis: Increases from left (west) to right (east)
Blender and most image formats use top-left origin:
- Array Indexing:
image[y][x]where:image[0][0]= top-left corner- Y-axis: Increases from top to bottom (opposite of BeamNG)
When converting between BeamNG and Blender coordinate systems:
Import (BeamNG → Blender): Flip Y-axis during import
# In import_level.py: Flip heightmap and layermap vertically
heightmap_blender = np.flipud(heightmap_beamng)
layermap_blender = np.flipud(layermap_beamng)Export (Blender → BeamNG): No transformation needed
# Blender EXR textures are already in the correct orientation for BeamNG
# after the import Y-flip, so export them directly without additional flipping
heightmap_beamng = heightmap_from_exr # No flip needed
layermap_beamng = layermap_from_exr # No flip neededRationale: The Y-axis flip is applied once during import to convert from BeamNG's bottom-left origin to Blender's top-left origin. When exporting back, the Blender textures are already in the correct orientation for BeamNG, so no additional transformation is needed.
CORRECTED: Data starts immediately after the header at offset 0x05 (not 0x100):
Offset 0x000: [Header - 5 bytes]
Offset 0x005: [Heightmap Data - size×size×2 bytes]
Offset 0x005+heightmap: [Layer Map Data - size×size×1 byte]
Offset varies: [Layer Texture Map - size×size×1 byte]
Offset varies: [Additional Coverage Maps - 4×(size×size×1 byte)]
Offset varies: [Material Count - 4 bytes (int32)]
Offset varies: [Material Names - variable length strings]
- Offset: Immediately after header (0x05)
- Size: size × size × 2 bytes (e.g., 1024×1024×2 = 2,097,152 bytes)
- Format: 16-bit unsigned integers, little-endian
- Purpose: Stores elevation data for terrain mesh generation
- Range: 0x0000 (lowest) to 0xFFFF (highest)
Height Value Interpretation:
0x0000= Lowest terrain point0xFFFF= Highest terrain point- Height scaling is applied in the game engine based on terrain settings
- Offset: After heightmap data
- Size: size × size × 1 byte
- Format: 8-bit unsigned integers
- Purpose: Maps each terrain pixel to a material ID
Special Values:
0x00= First material in materials list0x01= Second material in materials list...0xFF(255) = HOLE in terrain 🔥
- Offset: After layer map
- Size: size × size × 1 byte
- Format: 8-bit unsigned integers
- Purpose: Texture blending weights or secondary material information
According to reverse engineering, there are 4 additional coverage maps after the layer texture map:
- Count: 4 separate maps
- Size: size × size × 1 byte each
- Purpose: Unknown, possibly related to:
- Vegetation coverage
- Detail object placement (flowers, rocks, etc.)
- Ambient occlusion or lighting data
- Terrain detail masks
- Material Count: 4 bytes (int32, little-endian)
- Material Names: Variable-length strings with length prefixes
- Each string is prefixed by its length (1 byte)
- Strings are null-terminated ASCII
- Example:
[0x05]"Grass"[0x00][0x04]"Rock"[0x00]
Critical Finding: Terrain holes are created by setting the layer map value to 0xFF (255).
def detect_holes(layermap):
"""Detect holes in BeamNG terrain"""
hole_mask = layermap == 255
hole_positions = np.where(hole_mask)
return hole_mask, hole_positionsThe JSON file provides metadata and format description:
{
"binaryFormat": "version(char), size(unsigned int), heightMap(heightMapSize * heightMapItemSize), layerMap(layerMapSize * layerMapItemSize), layerTextureMap(layerMapSize * layerMapItemSize), materialNames",
"datafile": "/levels/small_island/small_island.ter",
"heightMapItemSize": 2,
"heightMapSize": 1048576,
"layerMapItemSize": 1,
"layerMapSize": 1048576,
"materials": ["Grass", "dirt_grass", "BeachSand", "rock_desert"],
"size": 1024,
"version": 9
}- Data starts at offset 0x100 (256 bytes)
- 251 bytes of padding after header
- Big-endian encoding for heightmap
- No information about terrain holes
- Data starts at offset 0x05 (immediately after header)
- No padding between header and data
- Little-endian encoding for all data
- Layer map value 0xFF (255) creates terrain holes
- Additional coverage maps present
def read_beamng_terrain(ter_file, json_config):
with open(ter_file, 'rb') as f:
# Read header
version = struct.unpack('B', f.read(1))[0]
size = struct.unpack('<I', f.read(4))[0] # Little-endian
# Read heightmap (little-endian)
heightmap_bytes = size * size * 2
heightmap_data = f.read(heightmap_bytes)
heights = struct.unpack(f'<{len(heightmap_data)//2}H', heightmap_data)
heightmap = np.array(heights).reshape((size, size))
# Read layer map
layermap_data = f.read(size * size)
layermap = np.array(struct.unpack(f'{size*size}B', layermap_data)).reshape((size, size))
# Detect holes
holes = layermap == 255
return heightmap, layermap, holesdef process_terrain_holes(heightmap, layermap):
"""Process terrain holes for Blender mesh generation"""
hole_mask = layermap == 255
# Option 1: Set hole areas to minimum height
processed_heightmap = heightmap.copy()
processed_heightmap[hole_mask] = 0
# Option 2: Create separate hole geometry
hole_positions = np.where(hole_mask)
return processed_heightmap, hole_positionsBased on community reverse engineering:
- Header: Version (1 byte) + Size (4 bytes, little-endian)
- Heightmap: 16-bit little-endian height values
- Layer Map: 8-bit material IDs (255 = hole)
- Layer Texture Map: 8-bit texture blending data
- Coverage Maps: 4× additional coverage data layers
- Materials: Count + length-prefixed strings
- BeamNG Forum: Edit theTerrain.ter files programatically?
- BeamNG Level Template Creator Repository
- Community contributions from emlodnaor, unyxium, and others
- v1.0: Initial analysis with incorrect offset assumptions
- v2.0: Corrected format based on community research
- Fixed data offset: 0x100 → 0x05
- Fixed endianness: big → little
- Added hole detection: layer map value 255
- Added coverage maps documentation