Table of Contents
- Save and Load
- Overview
- Save File Structure
- VirtualDisk Format
- Save Types
- Serialisation
- DiskSkimmer
- Compression
- SavegameCollection
- Save and Load Workflow
- Integrity Checking
- Chunked World Storage
- Best Practises
- Common Pitfalls
- Advanced Topics
- See Also
Save and Load
Terrarum uses a custom save game format called TerranVirtualDisk (TEVD), which stores game data as virtual disk images. Each save consists of two separate disk files: one for the world and one for the player.
Overview
The save system provides:
- Chunked world storage — Efficient storage for massive worlds
- Version tracking — Snapshot numbers for save compatibility
- Compression — Multiple compression algorithms (Gzip, Zstd, Snappy)
- Integrity checking — CRC-32 verification and SHA-256 hashing
- Metadata — Save type, game mode, creation time
Save File Structure
Two-Disk System
Each save game consists of two virtual disks:
- World Disk — Contains the world, terrain, actors, and environment
- Player Disk — Contains player data, inventory, and stats
This separation allows:
- Sharing worlds between players
- Moving players between worlds
File Locations
Saves are stored in the appdata directory:
<appdata>/Worlds/
└── world uuid # World disk
<appdata>/Players/
└── player uuid # Player disk
VirtualDisk Format
TEVD Specification
VirtualDisk implements the TerranVirtualDisk format (version 254), a custom archival format designed for game saves.
Header Structure (300 bytes)
Offset Size Field
0 4 Magic: "TEVd"
4 6 Disk size (48-bit, max 256 TiB)
10 32 Disk name (short)
42 4 CRC-32 checksum
46 1 Version (0xFE)
47 1 Marker byte (0xFE)
48 1 Disk properties flags
49 1 Save type flags
50 1 Kind (player/world)
51 1 Origin flags
52 2 Snapshot number
54 1 Game mode
55 9 Reserved extra info bytes
64 236 Rest of disk name (long)
Entry Structure
Each file in the disk is stored as an entry:
EntryID (8 bytes) // Unique file identifier
Size (6 bytes) // File size in bytes
Timestamp (6 bytes) // Creation timestamp
Compression (1 byte) // Compression method
Data (variable) // File contents
Entry IDs
Special entry IDs are reserved for specific purposes:
- 0x00 — Metadata entry
- 0x01..0xFFFFFFFE — User data entries
- 0xFEFEFEFE — Originally reserved for footer (now freed)
- 0xFFFFFFFF — Invalidated entry marker
Save Types
World Saves
World disks (VDSaveKind.WORLD_DATA) contain:
- WorldInfo — Dimensions, time, spawn points, seeds
- Terrain layers — Terrain, wall, ore, fluid data (chunked)
- Actors — All actors in the world (except the player)
- Weather — Current weather state
- Wirings — Wire/conduit networks
- Extra fields — Module-specific data
Player Saves
Player disks (VDSaveKind.PLAYER_DATA) contain:
- PlayerInfo — Position, stats, actor values
- Inventory — Items, equipment, quickslots
- Player-specific data — Quests, achievements, etc.
Full Save vs. Quicksave
Saves can be marked as quicksaves:
// Save type flags
0b0000_00ab
b: 0 = full save, 1 = quicksave
a: 1 = autosave
How Quicksaves Work
Quicksave (append-only):
- Modified entries are written to the end of the disk file
- This creates duplicate entries with the same Entry ID at different offsets
- When reading the disk, the entry at the later offset takes precedence
- Earlier duplicates are ignored
- The disk becomes "dirty" with obsolete entries still taking up space
Example:
Initial disk:
Offset 0x1000: Entry ID 42 (original world data)
Offset 0x2000: Entry ID 43 (original player data)
After quicksave:
Offset 0x1000: Entry ID 42 (original - IGNORED on read)
Offset 0x2000: Entry ID 43 (original)
Offset 0x3000: Entry ID 42 (updated - USED on read) ← Appended by quicksave
Full Save (rebuild):
- Disk is completely rebuilt from scratch
- Each entry appears only once
- All obsolete/deleted entries are removed
- Disk is compacted to minimum size
- No duplicate entries exist
This makes quicksaves faster (no rebuild) but results in larger file sizes over time. Periodic full saves compact the disk by removing duplicate and deleted entries.
Snapshot Numbers
When the save is created on dev version, the shanpshot number is recorded here.
// Format: 0b A_yyyyyyy wwwwww_aa
// Example: 23w40f
// 23 = year 2023
// w40 = ISO week 40
// f = revision 6 (a=1, b=2, ..., f=6)
Version Compatibility
When loading a save, the engine checks:
- Snapshot number — Warn if from newer version
- GENVER — Game version that created the save (stored in the main JSON descriptor file)
- Module versions — Check module compatibility
Serialisation
JSON-Based Serialisation
Game state is serialised to JSON using LibGDX's JSON library with custom serialisers.
Custom Serialisers
The engine provides serialisers for:
- BigInteger — Arbitrary precision integers
- BlockLayerGenericI16 — Terrain layers with SHA-256 hashing
- WorldTime — Temporal data
- HashArray — Sparse arrays
- HashedWirings — Wire networks
- ZipCodedStr — Compressed strings
Example Serialisation
val json = Common.jsoner
val jsonString = json.toJson(gameWorld)
val loadedWorld = json.fromJson(GameWorld::class.java, jsonString)
Transient Fields
Fields marked @Transient are not serialised:
@Transient var sprite: SpriteAnimation? = null
@Transient lateinit var actor: Pocketed
These must be reconstructed in the reload() method after deserialisation.
Reload Method
After loading, actors and objects call reload() to reconstruct transient state:
override fun reload() {
super.reload()
// Reconstruct sprites, UI, caches, etc.
sprite = loadSprite()
actor = retrieveActorReference()
}
DiskSkimmer
DiskSkimmer provides efficient read/write access to virtual disks without loading the entire disk into memory.
Creating a Skimmer
val skimmer = DiskSkimmer(File("savegame"))
Reading Entries
// Get entry by ID
val data: ByteArray64 = skimmer.requestFile(entryID)
// Read specific entry without building DOM
val inputStream = data.getAsInputStream()
Writing Entries
// Write new entry
skimmer.appendEntry(entryID, data, compression = Common.COMP_ZSTD)
Rebuilding (Compaction)
Dirty disks accumulate duplicate and deleted entries from quicksaves. Full saves perform compaction:
skimmer.rebuild() // Rebuild entry table, removing duplicates and deleted entries
skimmer.sync() // Write clean disk to file
The rebuild() process:
- Scans all entries in the disk
- For duplicate Entry IDs, keeps only the latest (highest offset)
- Removes entries marked as deleted (ID = 0xFFFFFFFF)
- Writes a clean, compacted disk with no duplicates
This is what distinguishes a full save from a quicksave.
Compression
Supported Algorithms
COMP_NONE = 0 // No compression
COMP_GZIP = 1 // Gzip compression (only maintained for older version compatibility)
COMP_UNUSED = 2 // Used to be LZMA (now removed)
COMP_ZSTD = 3 // Zstandard (recommended)
COMP_SNAPPY = 4 // Snappy (fast, lower ratio)
Using Compression
// Compress data before writing
val compressedStream = ZstdOutputStream(outputStream)
compressedStream.write(data)
compressedStream.close()
// Decompress on read
val decompressedStream = ZstdInputStream(inputStream)
val data = decompressedStream.readBytes()
Zstandard (COMP_ZSTD) provides the best balance of speed and compression ratio for save games.
SavegameCollection
The SavegameCollection manages all save files:
object SavegameCollection {
// List all world saves
fun getWorlds(): List<SaveMetadata>
// List all player saves
fun getPlayers(): List<SaveMetadata>
// Load world
fun loadWorld(worldFile: File): GameWorld
// Load player
fun loadPlayer(playerFile: File): IngamePlayer
}
Save and Load Workflow
Saving a Game
Quicksave (append-only, faster):
// 1. Prepare save data
val world = INGAME.world
val player = INGAME.actorNowPlaying as IngamePlayer
// 2. Open existing disk (don't rebuild)
val worldDisk = DiskSkimmer(File("${App.worldsDir}/${world.UUID}"))
// 3. Serialise changed data to JSON
val worldJson = Common.jsoner.toJson(world)
val compressedWorld = compress(worldJson, Common.COMP_ZSTD)
// 4. Append to disk (creates duplicate entries)
worldDisk.appendEntry(0x00, compressedWorld) // Entry 0x00 now exists twice!
// 5. Sync without rebuilding
worldDisk.sync() // Disk is now "dirty" with duplicates
Full Save (rebuild, compacted):
// Same as quicksave, but with rebuild before sync:
// 4. Append entries
worldDisk.appendEntry(0x00, compressedWorld)
// 5. Rebuild and sync (removes duplicates)
worldDisk.rebuild() // Compact: remove old duplicates and deleted entries
worldDisk.sync() // Write clean disk
Loading a Game
// 1. Open disks
val worldDisk = DiskSkimmer(File("${App.worldsDir}/${worldUUID}"))
val playerDisk = DiskSkimmer(File("${App.playersDir}/${playerUUID}"))
// 2. Read and decompress (automatically uses latest entry if duplicates exist)
val worldData = worldDisk.requestFile(0x00) // Gets entry at highest offset
val worldJson = decompress(worldData)
// 3. Deserialise from JSON
val world = Common.jsoner.fromJson(GameWorld::class.java, worldJson)
// 4. Reload transient fields
world.layerTerrain.reload()
world.actors.forEach { actor -> actor.reload() }
// 5. Repeat for player
// ...
// 6. Initialise game
INGAME.world = world
INGAME.actorNowPlaying = player
Integrity Checking
CRC-32 Verification
Virtual disks store a CRC-32 checksum in the header:
// Calculate CRC
val crc = CRC32()
entries.map { it.crc }.sorted().forEach { crc.update(it) }
val diskCRC = crc.value
// Verify on load
if (diskCRC != storedCRC) {
throw IOException("Disk CRC mismatch: corrupted save file")
}
SHA-256 Hashing
Block layers are hashed to detect corruption:
// During save
val hash = SHA256(blockLayer.bytes)
writeHash(hash)
// During load
val loadedHash = SHA256(blockLayer.bytes)
if (loadedHash != storedHash) {
throw BlockLayerHashMismatchError(storedHash, loadedHash, blockLayer)
}
Chunked World Storage
Worlds are stored in chunks to reduce save/load time:
Chunk System
- Only modified chunks are saved
- Unmodified chunks are regenerated on load
- Chunk flags track modification state
Saving Chunks
for (chunk in world.chunks) {
if (chunk.isModified) {
saveChunk(chunk)
}
}
Loading Chunks
for (chunk in world.chunks) {
if (chunkExistsInSave(chunk)) {
loadChunk(chunk)
} else {
regenerateChunk(chunk) // Use world generator
}
}
Best Practises
- Mark UI and sprites as @Transient — They cannot be serialised
- Implement reload() for all custom classes — Reconstruct transient fields
- Use Zstd compression — Best performance for game saves
- Use quicksaves for autosaves — Fast append-only saves during gameplay
- Use full saves when exiting — Rebuild and compact the disk when player quits
- Rebuild disks periodically — Compact after multiple quicksaves to prevent file bloat
- Validate snapshot versions — Warn users of version mismatches
- Hash critical data — Detect corruption in block layers
- Chunk world saves — Only save modified chunks
- Separate world and player — Allow sharing and portability
Common Pitfalls
- Forgetting to call reload() — Leads to null sprite/UI crashes
- Not marking fields @Transient — Causes serialisation errors
- Saving entire worlds — Use chunking for large worlds
- Only using quicksaves — Disk files bloat with duplicate entries; use full saves periodically
- Rebuilding on every save — Too slow for autosaves; use quicksaves during gameplay
- Ignoring CRC errors — Corrupted saves can crash the game
- Not compressing data — Saves become unnecessarily large
- Hardcoding entry IDs — Use constants or enums
Advanced Topics
Dynamic Item Persistence
Dynamic items (tools, weapons) require special handling:
// World stores dynamic item table
world.dynamicItemInventory: ItemTable
// Map dynamic IDs to static IDs
world.dynamicToStaticTable: ItemRemapTable
// When loading, remap IDs
item.dynamicID = world.dynamicToStaticTable[oldDynamicID]
Module Compatibility
Saves store module versions. On load:
// Check each module
for (module in save.modules) {
if (!ModMgr.isCompatible(module.name, module.version)) {
warn("Module ${module.name} version mismatch")
}
}
Custom Save Data
Modules can add custom save data using extra fields:
// Add extra field
world.extraFields["mymod:custom_data"] = MySerializable()
// Retrieve on load
val customData = world.extraFields["mymod:custom_data"] as? MySerializable
Autosave
Implement autosave by periodically calling save in a background thread:
// Every N seconds
if (timeSinceLastSave > autosaveInterval) {
Thread {
saveGame(isAutosave = true, isQuicksave = true)
}.start()
}
Mark autosaves in the save type flags (a bit).
See Also
- Glossary — Save/load terminology
- World — World structure and management
- Actors — Actor serialisation and reload
- Inventory — Inventory persistence
- TerranVirtualDisk — Virtual disk format library