Repository navigation
Save System
SparkEngine's save system persists ECS state and module-owned key/value state in a
versioned .spark_save binary file. The supported reader window is explicit:
| Contract | Version |
|---|---|
| Oldest readable format |
kOldestSupportedSaveVersion = 1 |
| Format written by this build |
kCurrentSaveVersion = 3 |
Writers emit v3 only. Readers accept v1, v2, and v3. A v1 or v2 file is migrated
in memory (MigrateToCurrentVersion runs real v1->v2 and v2->v3 steps) and is
never rewritten merely because it was loaded.
Primary sources:
SparkEngine/Source/Engine/SaveSystem/SaveSystemTypes.hSparkEngine/Source/Engine/SaveSystem/SaveSystem.hSparkEngine/Source/Engine/SaveSystem/SaveSystem.cpp
The save system is compiled as part of the engine; there is no separate CMake toggle.
SaveSystem is the singleton facade for slots, quicksave, rotating autosaves,
metadata queries, and in-memory snapshots. ComponentSerializerRegistry maps a
component type name to its serializer and deserializer. SaveData contains:
SaveMetadata metadatastd::vector<SerializedEntity> entitiesstd::unordered_map<std::string, std::string> customState
SaveSystem::SerializeWorld() saves every ComponentFactory-registered type that
owns a serializer, so module-registered serializers (see below) are reachable
without engine changes. NameComponent is the sole exception: it travels as
SerializedEntity::name, and an entity with a name but no other serializable
component is retained. A game module may register additional callbacks, but it
must unregister module-owned callbacks before unloading its DLL.
The opt-out is SaveSystem::MarkComponentTransient(typeName) /
IsComponentTransient(typeName). The default transient set is ProjectileComponent and
DecalComponent — in-flight, regenerable state that should not be restored. Because coverage is
now registry-driven rather than a fixed 14-type allowlist, a save written by this build carries
more component types than one written before the change.
A serialized Transform carries a parent property holding the parent's index in
the saved entity list, or "-1" for a root. Loading rebuilds the edges through
World::SetParent. The v2->v3 migration marks every pre-v3 Transform as a root.
SaveSystem& saves = SaveSystem::GetInstance();
saves.Initialize("Saves");
SaveMetadata metadata;
metadata.saveName = "Before Boss";
metadata.sceneName = "Level03";
metadata.screenshotPath = "Screenshots/slot-1.png";
std::unordered_map<std::string, std::string> gameState = {
{"quest.main.stage", "3"},
};
saves.Save("slot-1", world, metadata, gameState);
std::unordered_map<std::string, std::string> restoredGameState;
if (!saves.Load("slot-1", world, restoredGameState))
{
// world and restoredGameState are unchanged
}Slot names are 1 to 64 characters and may contain ASCII letters, digits,
underscore, and hyphen. Files are stored as <save-directory>/<slot>.spark_save.
__quicksave and __autosave_N are the engine's reserved slot patterns.
SaveMetadata contains the slot display name, scene, player class, format
version, timestamp, play time, screenshot path, and player-summary fields.
Save() sets the timestamp and forces the version to
kCurrentSaveVersion; callers cannot use it to emit an older format.
The save system stores screenshotPath but does not capture the image. The game
or editor must capture the thumbnail before saving and provide the path.
GetSaveMetadata() and GetSaveSlots() read only the versioned metadata block.
They do not parse the entity payload. Metadata returned from a supported v1 file
has already passed the v1-to-v2 in-memory migration, so its version is current and
its screenshot path is empty.
Save files are uncompressed binary data. Multi-byte integers use the platform's current native representation, so the certified v1/v2 compatibility fixture is for the supported Windows x64 release profile.
4 bytes magic: "SPRK"
u32 format version
u32 metadata byte count
bytes newline-delimited metadata block
u32 entity count
repeat entities:
u16 + bytes entity name
u16 component count
repeat components:
u16 + bytes component type name
u16 property count
repeat properties:
u16 + bytes key
u16 + bytes value
u32 custom-state count
repeat custom state:
u16 + bytes key
u16 + bytes value
v1 and v2 share the same outer binary layout. Their metadata blocks differ:
v1: saveName, sceneName, playerClass, timestamp, playTime, health,
armor, position, kills, deaths
v2: saveName, sceneName, playerClass, screenshotPath, timestamp, playTime,
health, armor, position, kills, deaths
Each listed field is newline-delimited except the three position coordinates, which share one line. Embedded carriage returns or newlines in string metadata are rejected by the writer.
Every entity/component/property/custom-state key and value has a uint16_t
length prefix. One shared SaveRepresentationLimits contract applies to disk
reads/writes and public in-memory restore: 512 MiB total wire size, 64 KiB
metadata, 1,000,000 entities, uint16_t components per entity, uint16_t
properties per component, wire-derived aggregate component/property limits, and
100,000 custom-state records. SaveRepresentationBudget performs overflow-safe
aggregate accounting. Oversize values are rejected instead of truncated.
SaveSystem::MigrateToCurrentVersion(SaveData&) is the authoritative in-memory
migration entry point. It is transactional and idempotent:
- v1 -> v2 sets
screenshotPathto the defined empty value and updates the format version; - v2 -> v2 is a no-op;
- versions below 1 or above 3 are rejected without changing the input.
The v1 reader uses the v1 metadata layout before applying the migration. This ordering matters: treating a v1 timestamp line as a v2 screenshot line would shift every remaining field.
Unsupported files log the source version, the supported inclusive range, and whether a newer or compatible older build is required. There is no unlimited backward-compatibility promise; widening or retiring the reader window requires a separate migration change and fixture.
Save() writes <slot>.spark_save.tmp, closes and durably flushes it, retains
the previous revision as <slot>.spark_save.bak, then atomically replaces the
destination. A failed write removes the temporary file and leaves the previous
slot in place. The local file cache is invalidated only after the replacement
succeeds. SetSaveDirectory() records the directory and the next Save() creates
it on demand. DeleteSave() removes both the slot file and its .bak.
Load() falls back to <slot>.spark_save.bak with a logged warning when the slot
file is unreadable. There is no trailing payload checksum yet (tracked under
SAVE-230).
SaveMetadata::slotName is never written to disk; GetSaveSlots() and
GetSaveMetadata() populate it from the file name so callers can address the
slot they enumerated.
Load() performs these stages before changing caller-owned state:
- read and bound the entire file;
- validate magic, version, metadata, entities, components, and custom state;
- migrate the parsed
SaveDatain memory; - run the optional custom-state validator;
- copy
customState, restore every entity into a fresh candidateWorld, and prove its exact entity identifiers, entity-storage occupancy, component pools, and per-entity component presence match the serialized declaration; - resolve and validate every type-erased add/presence/remove/raw/storage-swap/ rebind operation without touching the live registry;
- allocate a complete immutable hierarchy-retirement plan for the live world;
- cross the explicit live-storage boundary by preparing missing live pools;
- retire live entities through the prebuilt
World::EntityRetirementPlan, which removes hierarchy links without allocating, fires destroy observers, and clears per-entity event subscriptions; - exchange the validated component payloads and a separately constructed, pristine entity-storage payload so callback-mutated allocator state cannot affect the live world's future entity identifiers;
- emit an explicit live
on_updaterebind for every incoming component (the same rebuild path consumed by all productionReactiveSystemsubscribers); - exchange the staged custom-state map with a non-throwing swap.
An unknown or duplicate component type, an explicit NameComponent record,
duplicate property/custom-state keys, a missing/malformed/oversized required
built-in value, a failed reflected-field conversion, or any standard/unknown
exception from a registered deserializer, a deserializer that returns without
materializing its declared component, a deserializer-created entity or undeclared
staged component, transient create/destroy residue in candidate entity storage,
incomplete type-erased operations, hierarchy-plan allocation, or any
representation-limit violation fails the candidate restore. Candidate identifiers
are allocated before extension callbacks run, and the live world receives entity
allocator metadata from a separate pristine storage; a callback-only allocator
cursor change therefore cannot escape the candidate. These failures occur before
live storage preparation, so exact registry topology, hierarchy, live entities,
components, per-entity subscriptions, and custom-state output remain unchanged.
Live-pool preparation is intentionally outside the ordinary-failure catch. The
engine-owned hierarchy plan is complete before this point. If a pool allocation
or preparation callback throws, the exception propagates and an empty pool may
remain in the registry. Retirement has not begun, so entity and component
payloads, per-entity EventBus subscriptions, and custom-state output remain
unchanged, but exact topology/rollback is no longer promised and the failure is
never misreported as false.
The current World API exposes EnTT lifecycle sinks whose callbacks are not
required to be non-throwing. An exception from on_destroy after retirement
begins cannot be rolled back together with observer-owned state and EventBus
side effects. Such an exception therefore propagates instead of being reported
as an ordinary false; the caller must treat it as a fatal lifecycle programming
error and must not continue using the potentially partially retired World.
SAVE-230 remains blocked on a larger ownership/rebind design if recoverable
rollback across arbitrary lifecycle observers is required.
A successful load preserves the live EnTT registry and its registry-bound signal observers. Incoming entity identifiers are installed only after subscriptions for retired/colliding identifiers have been removed. Entity/component pointers and entity-lifetime assumptions remain invalid across a successful load.
auto& registry = ComponentSerializerRegistry::GetInstance();
registry.Register(
"Inventory",
[](const void* raw)
{
const auto& inventory = *static_cast<const Inventory*>(raw);
return SerializedComponent{"Inventory", {{"gold", std::to_string(inventory.gold)}}};
},
[](World& world, EntityID entity, const SerializedComponent& encoded)
{
Inventory inventory;
inventory.gold = std::stoi(encoded.properties.at("gold"));
world.AddComponent<Inventory>(entity, inventory);
});Custom component types must also register matching ComponentFactory operations,
including the transactional prepareStorage, swapStorageContents, and
notifyRebound callbacks;
the engine's SPARK_REGISTER_COMPONENT registrations are the reference pattern.
Without those operations, loading the custom component fails closed before the
live world is retired. Deserializers must validate all required properties and
throw on malformed input. The SaveSystem catches the exception while the
component is being restored into the candidate world, preventing a partial
live-world update.
The immutable v1 source fixture is:
Tests/Fixtures/Compatibility/SaveSystem/v1-screenshotless.spark_save.hex
The fixture was emitted through the pre-v2 writer path with a non-default
Transform; the test asserts every serialized metadata/Transform field and
byte-for-byte immutability of both the source fixture and copied slot. Focused
compatibility tests use the SaveMigration_ selector and are registered with
CTest labels compatibility;save;unit:
ctest --test-dir build -C Release -L compatibility --output-on-failure --no-tests=errorThe compatibility-labeled coverage includes:
- v2 writer/header and screenshot-path round trip;
- exact, idempotent v1-to-v2 in-memory migration;
- immutable v1 read compatibility without source or slot rewrite;
- future/retired version rejection;
- successful lifecycle commit with registry-observer retention and stale entity-subscription removal, plus explicit incoming reactive rebinds;
- fail-closed missing, malformed, oversized, and reflected component values;
- fail-closed duplicate component/property/custom-state records and explicit
NameComponentrecords, without entering entity lifecycle; - shared disk/in-memory size and count boundaries, including exact/max+1 string and overflow-safe synthetic aggregate cases;
- a custom deserializer that omits its declared component, with exact live registry topology, entity/component, EventBus, and custom-state preservation;
- custom deserializers that create ghost entities or extra staged components, rejected before live storage preparation with exact live-state preservation;
- transient create/destroy candidate entities rejected even when the surviving active IDs and component topology match, plus allocator-cursor mutation isolated by the pristine committed entity storage;
- hierarchy-plan failpoint rollback and a boundary guard proving every live
Transform::childrencopy occurs before storage preparation; - propagation of live-storage-preparation failure after an empty pool appears, proving no catch reports a false unchanged-topology rollback;
- unknown-component rollback;
- throwing-deserializer rollback for both World and custom state;
- propagation of throwing lifecycle observers instead of a false unchanged-world result after commit has begun.
The same production-linked SaveSystem test file also retains the malformed-tail, oversize-file, custom-state, and atomic slot-replacement regressions.
SAVE-230 remains broader than this save-format slice. Rollback/backup acceptance remains explicitly open, as do scene, prefab, asset, editor-state, per-module schema, installed-build, and exact-SHA CI evidence. The ordinary build workflows run this CTest serially with the rest of the suite; no dedicated compatibility CI job is claimed.
SaveSystem and ECS world access are main-thread operations. The registry must
not be mutated concurrently with save or load. WriteToFile() and
ReadFromFile() are internal implementation details; callers should use the
public API rather than invoking background I/O against singleton state.
Updated against the SAVE-230 save-format slice on 2026-08-27. The constants and implementation named above are authoritative; this page must change in the same commit as any save-format or compatibility-window change.
Published from 2b03dc797148. Edit the canonical source in wiki/.
- Documentation
- Docs route
- Wiki index
- Guides
- Tutorials
- Samples
- Examples
- API Reference
- API route
- Reference
- Build Guide
- Dependencies
- FAQ
- Changelog
- Roadmap
- Contributing
- Code of Conduct
- Home
- FAQ
- Getting Started
- Quick-Start Tutorial
- Making Your First Game
- Making Your First Multiplayer Game
- Artist Workflow Guide
- Editor Walkthrough
- Migration Guide
- How SparkEngine Works
- Architecture Overview
- Engine Architecture Flowchart
- Creating a Game Module
- Game Modules (catalog)
- Entity Component System
- Rendering and Graphics
- Physics
- Cloth Simulation
- Audio
- Input System
- Camera System
- Scripting with AngelScript
- Visual Scripting
- AI and Navigation
- Animation
- 2D Systems
- Networking
- Dedicated Server
- Multiplayer Quick Start
- Area Server Architecture
- Scene Management
- Large World Support
- Collaborative Editing
- Coroutine System
- Event System
- Event Response System
- Job System
- UI System
- UI Layout Extensions
- Localization
- Dialogue System
- Destruction System
- Replay System
- Achievement System
- Loading System
- Mod System
- Content Delivery
- Tween System
- Memory Integrity
- Gameplay Systems
- Terrain and Procedural Generation
- Save System
- Persistence System
- Day Night Cycle and Weather
- Cinematic Sequencer
- Runtime Prefabs
- SparkEditor
- Editor Tutorials
- SparkConsole
- SparkDaemon
- Shader Pipeline
- Asset Pipeline
- Asset Validation
- Asset Migration
- Game Packaging
- Online Services
- DataTable System
- Loot and Crafting System
- CSG System
- Font System
- Timer Manager
- Movie Render Pipeline
- HLOD and World Partition
- Remote Debug System
- Selection Manager
- Asset Dependency Graph
- Editor Automation
- File Watcher
- Project Templates
- System Requirements
- VR Support
- Mobile Platform
- Accessibility
- Platform Input
- Platform Certification
- Cross-Compilation: Wine Testing
- RHI Abstraction Layer
- D3D11 Backend
- D3D12 Backend
- Vulkan Backend
- OpenGL Backend
- Metal Backend
- DXR Raytracing
- Hybrid Ray Tracing
- Upscaling (DLSS/FSR)
- Render Graph
- Shader Graph
- GPU Particles
- GPU-Driven Rendering
- Volumetric Fog
- Volumetric Clouds
- Global Illumination
- Virtual Texturing
- Water Rendering
- Clustered Lighting
- Material System
- Post-Processing
- Shadow System
- Particle System
- Decal System
- Sky and Atmosphere
- Foliage System
- Mesh Shaders
- Neural Rendering
- Configuration Reference
- Performance Tips
- Benchmark Framework
- Threading Model
- Fuzz Policy and Parser Security
- Memory Safety
- Memory Management Patterns
- Build System and CMake Modules
- Profiler and Debugging
- Performance Profiling Guide
- Telemetry System
- Crash Reporting
- Golden Image Testing
- Utilities
- Testing
- Fuzz Policy and Parser Security
- Codebase Statistics
- Codebase Health
- Error Handling Patterns
- Hot Reload Overview
- Troubleshooting
- Contributing
- Workflow Patterns
- Build Optimizations
- CI Reproducible Builds
- GitHub API and PR Checks
- Git Rebase Conflicts
- Clang-Format
- Code Quality Violations
- AI Bloat Pattern
- MinGW + Wine Cross-Compilation
- Live Editor Testing
- Engine & Renderer Landscape
- DuetOS Portability Catalog
- Five-Engine Analysis
- Eleven-Engine Analysis
- ThorVG / Unity Graphics Analysis
- Advanced Techniques Catalog
- Third-Party Library Evaluation
- Engine Viability Evaluation
- Engine Feature Recommendations
- Project Recommendations
- Mac Compatibility Analysis
- Codebase Observations
- Codebase Bloat Audit
- Test Suite Audit
- Documentation Coverage Audit
- ThirdParty Dependencies Audit
- Load Test Baseline
- Gameplay Systems Status
- SparkGame Module Status
- Stub and Abandoned Features
- Memory Integrity System
- Memory Safety Evaluation
- Hardware Acceleration Systems
- Jolt Physics Integration
- GPU/CPU Separation Plan
- Daemon Services Architecture
- Reflection & Polymorphism Refactoring Plan
- SparkBuild In-Tree
- Wine No-JobSystem Breakthrough
- Wine Role and Fallback Tiers