2
Save and Load
minjaesong edited this page 2025-11-28 19:07:15 +09:00

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:

  1. World Disk — Contains the world, terrain, actors, and environment
  2. 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):

  1. Modified entries are written to the end of the disk file
  2. This creates duplicate entries with the same Entry ID at different offsets
  3. When reading the disk, the entry at the later offset takes precedence
  4. Earlier duplicates are ignored
  5. 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):

  1. Disk is completely rebuilt from scratch
  2. Each entry appears only once
  3. All obsolete/deleted entries are removed
  4. Disk is compacted to minimum size
  5. 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:

  1. Snapshot number — Warn if from newer version
  2. GENVER — Game version that created the save (stored in the main JSON descriptor file)
  3. 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:

  1. Scans all entries in the disk
  2. For duplicate Entry IDs, keeps only the latest (highest offset)
  3. Removes entries marked as deleted (ID = 0xFFFFFFFF)
  4. 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

  1. Mark UI and sprites as @Transient — They cannot be serialised
  2. Implement reload() for all custom classes — Reconstruct transient fields
  3. Use Zstd compression — Best performance for game saves
  4. Use quicksaves for autosaves — Fast append-only saves during gameplay
  5. Use full saves when exiting — Rebuild and compact the disk when player quits
  6. Rebuild disks periodically — Compact after multiple quicksaves to prevent file bloat
  7. Validate snapshot versions — Warn users of version mismatches
  8. Hash critical data — Detect corruption in block layers
  9. Chunk world saves — Only save modified chunks
  10. 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