Serialization
JaiScript serializes at two layers that share one wire vocabulary.
- Script archives write a script value: the builtins
to_json/from_json,to_binary/from_binaryandto_base64/from_base64. Use them for script gameplay state. - C++ archives write a host type as JSON or compact binary (
jai::to_json,jai::from_json<T>,jai::to_binaryand siblings). They dispatch like Cereal, to a member or freeserialize/save/load, or to theJAI_PROPERTYfields of aproperty_owner. Use them for host objects, editor documents and network payloads.
The rules that govern both:
- Binding a class for scripting does not make it archivable. The C++ archives need a
serialize/save/loadorJAI_PROPERTYfields. A dynamic_binder chain adds the class's name for the script archives, and ajai::registrararound it adds the identity polymorphic saves need. - Data is found by name, so adding or removing a field needs no migration code (Versions and missing fields).
- A load converts or fails; it runs no payload text. A script field takes a loaded value as
=would assign it, and a C++ field that cannot hold a value fails the load (Security posture). - Handles keep their sharing. An object is written once, at its first reference, and later references carry its id (Wire formats).
- The layout is not frozen before 1.0. Data saved by one 0.x release is not promised to load in the next (Stability). Within one release JSON and binary hold the same data, and binary is little-endian on every platform, so a save made on one machine loads on another.
Pre-parsed script code is a separate format, jaibite.
Script side: to_json / from_json
class Player {
int hp = 0;
float speed = 0.0;
string name = "";
}
auto p = Player();
p.hp = 42; p.speed = 1.5; p.name = "hero";
var j = to_json(p);
print(j);
var q = from_json(j); // reconstructs a Player via the $type discriminator
print(type_of(q.hp) + " " + to_string(q.hp) + ", " + q.name);
// A float payload landing in an int field: the load converts, it does not trust.
var r = from_json("{\"$type\": \"Player\", \"$val\": {\"hp\": 7.9, \"speed\": 2, \"name\": \"clone\"}}");
print(type_of(r.hp) + " " + to_string(r.hp) + ", " + type_of(r.speed));
// Plain values and containers round-trip too.
var m = from_json(to_json({"a": [1, 2.5, "three", true, null]}));
print(to_json(m));{"$type":"Player","$val":{"hp":42,"speed":1.5,"name":"hero"}}
int 42, hero
int 7, float
{"a":[1,2.5,"three",true,null]}The payload said "hp": 7.9 and the field is declared int, so the load truncated it as cast<int> does (any other value converts as = would, and var fields stay dynamic). to_json(v, n) pretty-prints with n spaces per level. The guide's JSON section gives every shape ($type / $val for an instance, $char, $map for non-string keys, the handle shapes). What a host should know on top:
- A known
$typeis constructed asName()would construct it, then the payload writes its fields in declaration order (base first). A constructor that throws fails thefrom_jsoncall with that error. - An unknown
$typeis data, not an error: the load yields a plainmapwith both keys intact. So is a malformed tag ({"$char": 300}, a$mapwhose entries are not pairs). - A
nullloads into a handle field as= nullstores it. A weak pointer whose object nothing else in the save holds loads expired. An id the save never wrote fails withInvalid shared_ptr ID: 4. - Floats round-trip at full precision (
-0.0keeps its sign). JSON cannot spell an infinity or a NaN, soto_jsonwritesnull;to_base64/from_base64keep the exact bits. - A payload nests as deep as memory allows. Only a host record type's own recursion is bounded, by the host thread's stack or
JAI_MAX_HOST_RECORD_DEPTH= 1024 records. - Trailing garbage is rejected, an invalid unicode escape is a serialization error, and a lone surrogate becomes the replacement character.
Saving game state to a file
File access belongs to the host: the standalone runner provides write_file, read_file and file_exists, and an embedding host registers its own. app_data_dir(name) answers the platform's per-user data folder.
class Hero { string name = "ada"; int gold = 0; array<string> bag = []; }
class SaveGame { int version = 1; Hero hero; map<string, bool> flags; }
SaveGame game;
game.hero.gold = 120;
game.hero.bag.push("lantern");
game.flags["met_the_raccoon"] = true;
write_file("save1.json", to_json(game, 2));
SaveGame loaded = from_json(read_file("save1.json"));
print(loaded.hero.name, " ", loaded.hero.gold, " ", loaded.hero.bag[0], " ", loaded.flags["met_the_raccoon"]);ada 120 lantern trueA field added to the class after a save loads with its initializer. A field like version lets a loader convert older files explicitly.
C++ side: the convenience API
#include <jaiscript/serialization/convenience.hpp>
struct SaveGame {
int level = 0; double time_played = 0; std::string hero; std::vector<int> inventory;
template<typename Archive>
void serialize(Archive& ar) { // one method, both directions, both formats
ar("level", level);
ar("time_played", time_played);
ar("hero", hero);
ar("inventory", inventory);
}
};
SaveGame save{7, 123.5, "Alice", {1, 2, 3}};
std::string json = jai::to_json(*eng, save); // human-readable, indented 2 spaces
SaveGame loaded = jai::from_json<SaveGame>(*eng, json);
std::cout << json << "\n";
std::cout << jai::to_json(*eng, save, 0) << "\n"; // indent 0: one line
std::cout << "loaded: level=" << loaded.level << " hero=" << loaded.hero << " items=" << loaded.inventory.size() << "\n";
std::vector<uint8_t> blob = jai::to_binary(*eng, save); // compact little-endian
SaveGame fromBin = jai::from_binary<SaveGame>(*eng, blob);
std::cout << "binary: " << blob.size() << " bytes, hero=" << fromBin.hero << "\n";{
"level": 7,
"time_played": 123.5,
"hero": "Alice",
"inventory": [
1,
2,
3
]
}
{"level": 7,"time_played": 123.5,"hero": "Alice","inventory": [1,2,3]}
loaded: level=7 hero=Alice items=3
binary: 114 bytes, hero=Alice- The third argument of
to_jsonis the indent: 2 by default, 0 for one line. - Also:
to_binary_string/from_binary_string,to_base64/from_base64, and aContext&overload of each (jai::from_json<T>(*eng, json, ctx)) that hands a user context to nestedsaveandloadcalls asar.get_user_context<Context>(). - A load that returns
Tby value needs a default-constructible, movableT; aproperty_owneris both.jai::from_json<std::shared_ptr<T>>keeps a derived object's$type. - Every failure in either direction throws
jai::serialization_error, ajai::runtime_error.
A class you cannot edit
A class with no route of its own, bound with dynamic_binder, saves and loads as the properties its binding declares, its binding record: the chain that gives scripts its fields gives the archives its record. The keys are the .property names, and each value goes through its own route, so containers, bound classes held by value, shared_ptr identity and $type save as they do from a hand-written serialize:
// a third-party header: no serialize, save, load or property_owner
struct Enemy {
std::string name = "rat";
int hp = 10;
std::vector<std::string> loot;
};
jai::dynamic_binder<Enemy>(*eng, "Enemy")
.property("name", &Enemy::name)
.property("hp", &Enemy::hp)
.property("loot", &Enemy::loot);
Enemy boss{"troll", 40, {"club", "tooth"}};
std::string record = jai::to_json(*eng, boss, 0);
Enemy back = jai::from_json<Enemy>(*eng, record);
std::cout << record << "\n";
std::cout << "loaded: " << back.name << " hp=" << back.hp << " loot=" << back.loot.size() << "\n";{"name": "troll","hp": 40,"loot": ["club","tooth"]}
loaded: troll hp=40 loot=2- The record. The levels
.base_class<B>()names come first, then every.property(name, &T::member)and every.property(name, getter, setter)with a setter, in the order bound. Aconstmember and a getter-only property stay out..transient_property(name, ...)binds a property scripts reach and the record leaves out, as aproperty_owner's record leaves out aJAI_TRANSIENT_PROPERTY. - A load reads the keys the record holds into a default-constructed object, or into the one a pointer load constructs. A key the record lacks keeps the constructor's value and a key the class no longer binds is skipped. An accessor pair reads over its getter's value and hands the result to its setter. The binding's
.post_load_hookthen runs, as it does after a script-side load. - The archive's engine holds the binding. An archive whose engine does not bind the class throws where the save or load reaches one (the run-time routes).
- A route of the class's own wins: a member or free
serialize,saveorload, orproperty_owner. A class that declares one of those members wherejai::accesscannot reach it is the compile error below, never a binding record. - Polymorphism. A polymorphic class bound this way takes its
$typefrom its binding's name in the archive's engine, and.base_class<B>()records the edge ashared_ptr<B>load checks, so no registrar is needed (dynamic_binder). Ajai::registrarname is the one every engine shares. - Inside script values the same object saves through the same bindings:
to_json(boss)in script writes{"$type": "Enemy", "$val": {...}}, and a load builds anEnemyagain.
Dispatch priority
For a type T the archive takes the first of these that applies:
- a primitive: an arithmetic type, an enum or
std::string - a member
save(writing) orload(reading) - a member
serialize - a
property_owner<T>: every level'sJAI_PROPERTYfields, automatically - a built-in standard type:
std::vector,map,unordered_map,array,pair,optional,variant,tuple, the sets,shared_ptr/weak_ptr/unique_ptr - a free
serializefound by ADL - a free
save(writing) orload(reading) found by ADL - a class with none of these: the properties its binding declares in the archive's engine (a class you cannot edit)
- Members come first, and a type with a member function never reaches a free one. A type with
savebut noloadloads through itsserialize, and the reverse. - A
property_ownerthat declaressave/loadcan callsave_owned_properties(ar)/load_owned_properties(ar)inside them to write itsJAI_PROPERTYfields beside the hand-written ones. - Free functions are found only as
serialize(Archive&, T&),save(Archive&, const T&)andload(Archive&, T&), never with a version parameter. A member taking astd::uint32_tversion is found and preferred over the one-parameter form (Versions). - Private members:
friend jai::access;. A type with no other route that declaresserialize,saveorloadwherejai::accesscannot call it is a compile error:the type declares serialize() but jai::access cannot reach it: add 'friend jai::access;' to the class (or make the function public). A public member of one of those names that is not an archive function, such asvoid save(const std::string& path), is reported as that. The check cannot see into afinalclass, whose forgotten line reads as no route. Aproperty_ownerthat declares one of the three wherejai::accesscannot reach it is the same error, raised where the class registers itself: the declaration asks for that function, never for the properties' route in its place. - A type that is not a class (a union, a raw pointer) with no match is a compile error wherever the archive is handed the type:
to_json,from_jsonand their kin, anar(...)call inside a serialization function, and the elements of a standard container or pointer there. The message lists the routes:the archive cannot write this type, it has no serialization route: give the type a member serialize(Archive&), or save(Archive&) and load(Archive&) (non-public ones with 'friend jai::access;'), a free serialize, save or load the archive finds by argument-dependent lookup, or JAI_PROPERTY members of a jai::property_owner. The compiler's instantiation notes name the type. - Four routes are settled at run time, and there the archive throws when it reaches a value with none:
Type '...' is not serializable. Add save/load, serialize, property_owner, or ADL functions, bind it in the archive's engine — or mark the property transient.(is not deserializableon a load).- A
JAI_PROPERTY: its mode is a run-time value, so a transient property may hold a type with no route. An automatic one throws where a save or a load reaches a value of that type; an empty container of them saves and loads. - A type registered with
jai::registrar: registration also binds types nothing saves. - A
shared_ptrorweak_ptrto a polymorphic type: the object's dynamic type supplies the route, so a base with none of its own compiles and throws only for an object of exactly that base. - A class with no route of its own: its binding lives in an engine, so a class the archive's engine does not bind throws where the save or load reaches it.
- A
JAI_STATIC_BINDERis not part of this dispatch. Itsjai_static_type<T>::save/loadruns for a statically bound property inside another statically bound type; handed to the archive directly, such a type needs one of the functions above.- A
shared_ptrorweak_ptrload builds its object throughstatic void load_and_construct(Archive&, jai::serialization::construct<T>&)(<jaiscript/serialization/construct.hpp>) when the type declares that static member with exactly those two parameters. Otherwise it default-constructs: a public default constructor, or a private one withfriend jai::access;. A type with neither is a compile error, and so is a type with no default constructor whoseload_and_constructis closed tojai::access(the type declares load_and_construct() but jai::access cannot reach it: add 'friend jai::access;' to the class (or make the function public)), at the load and at itsjai::registrar. A polymorphic type with neither compiles, since its registered subclasses load through their$type, and raisesserialization_errorat a load that names none. Aunique_ptralways default-constructs; an element that cannot is a compile error.
The archive inside save, load and serialize
Named properties are the normal form; the unnamed calls exist for hand-written layouts.
Every call, with what the writer and the reader do: archive calls.
Versions and missing fields
The contract. The archives keep no class version. Both formats are self-describing, and a reader finds a property by name, never by position.
- The automatic
property_ownerload reads the properties the record and the class share. One the record lacks keeps its initializer, and one the class no longer declares is skipped, so adding or removing aJAI_PROPERTYneeds no code. - A hand-written
loadasks for each property, andar("name", v)throws when the record lacks it. Read a field newer than some saves underar.has_property("name"). - When a field keeps its name but changes its meaning (a unit, an encoding, one field split into two), write a
formatproperty. It is an ordinary field that reads back the same from JSON and binary, and a record written before it existed lacks it.
template<class Archive>
void save(Archive& ar) const {
ar("format", std::uint32_t(2));
ar(JAI_NVP(health));
ar(JAI_NVP(experience));
}
template<class Archive>
void load(Archive& ar) {
std::uint32_t format = 1;
if (ar.has_property("format")) { ar("format", format); }
ar(JAI_NVP(health));
if (format >= 2) {
ar(JAI_NVP(experience));
} else {
int level = 0;
ar("level", level);
experience = level * 100;
}
}From Cereal converts a Cereal-versioned type this way.
The std::uint32_t version parameter of a member save, load or serialize exists so Cereal-shaped code compiles. It carries a number you chose only for a record you opened yourself:
What each save, load and record receives: versions.
A binder chain's .version(n) never reaches the C++ archives. It is a number (1 by default) the engine keeps for the class, used only by the script archives for bound objects inside script values:
What each script archive call does with it: versions.
Wire formats
Both formats run the same calls; the writer decides the bytes. JSON writes a record as an object of its properties, and a record opened with a type name leads with "$type", then "$version" when it is above 1. Binary is little-endian:
The bytes of a record, an array, a map and null: binary format.
Classes and handles are spelled with the same keys by the script archives and the C++ archives, in JSON and in binary, numbered by per-save object ids:
Every spelling: class and handle keys. The three a graph uses:
| Value | Written as |
|---|---|
shared_ptr, first reference to its object | {"$id": N, "$val": {...}} |
shared_ptr to an object already written | {"$id": N} |
weak_ptr | {"$ref": N} |
The two layers differ only where a static type exists:
Class names, the root and unknown class names in each: script archives and C++ archives.
One graph in each:
class Node { int v = 1; }
class Link { shared_ptr<Node> a; shared_ptr<Node> b; weak_ptr<Node> w; }
Link link;
link.a = new Node();
link.b = link.a;
link.w = link.a;
print(to_json(link));{"$type":"Link","$val":{"a":{"$id":1,"$type":"Node","$val":{"v":1}},"b":{"$id":1},"w":{"$ref":1}}}struct Node {
int64_t v = 1;
template<class Archive> void serialize(Archive& ar) { ar("v", v); }
};
struct Link {
std::shared_ptr<Node> a, b;
std::weak_ptr<Node> w;
template<class Archive> void serialize(Archive& ar) { ar("a", a); ar("b", b); ar("w", w); }
};
Link link;
link.a = std::make_shared<Node>();
link.b = link.a;
link.w = link.a;
std::cout << jai::to_json(*eng, link, 0) << "\n";{"a": {"$id": 1,"$val": {"v": 1}},"b": {"$id": 1},"w": {"$ref": 1}}A script class layered over a C++ object meets both: every archive path writes it as "$type" (the script class), "$script" (its script fields, a string holding the script archives' JSON) and "$val" (the C++ payload), and a load rebuilds the hybrid (Script-Extensible Objects).
Polymorphism and signals
Polymorphism needs a serialization identity. Saving a shared_ptr<Base> that holds a Derived writes $type and loads the Derived back, provided Derived has a name the archives know:
- A
property_ownertype has one from its declaration (dynamic_binder). - A plain class gets one from a
jai::registrar, whosejai::bases<...>tag records the edge the load checks. - A class whose binding is its route has one from that binding (above).
- For any other class,
.base_class<B>()alone is the script-side edge. A save through the base pointer without a registrar raisesCannot serialize unregistered polymorphic type 'class Monster' through pointer-to-base 'class Actor'. Register it with jai::registrar<T, Context, jai::bases<Base>>("Name") or derive from jai::property_owner<T, Base>.(the type names are the compiler'stypeidspellings, MSVC's here).
// examples/embed_quickstart: Monster derives Actor, both plain classes with serialize()
static jai::registrar<Monster, void, jai::bases<Actor>> monsterRegistrar("Monster", [](jai::dynamic_binder<Monster>& b) {
b.base_class<Actor>()
.constructor<std::string, int64_t>()
.property("hp", &Monster::hp)
.method("hurt", &Monster::hurt);
});
jai::bind_registrar(*eng); // binds every registrar into this engine
std::shared_ptr<Actor> boss = std::make_shared<Monster>("Troll", 40);
auto json = jai::to_json(*eng, boss); // "$type": "Monster"
auto back = jai::from_json<std::shared_ptr<Actor>>(*eng, json); // a Monster againA C++ pointer load constructs through load_and_construct or the default constructor; the chain's .post_load_hook(...) and .deserialization_factory(...) serve bound objects inside script values (Versions).
Signal connections ride save files. Named script-text receivers on a signal property serialize with their object. Pass the engine as the load context (jai::from_json<std::shared_ptr<T>>(*eng, json, *eng)) and they re-arm, firing on the next emit. Function-object receivers (script lambdas, C++ callbacks) are runtime-only and skipped without error. The binder chapter shows the round-trip.
Migrating from Cereal
CEREAL_NVP becomes JAI_NVP, friend class cereal::access becomes friend jai::access, CEREAL_REGISTER_TYPE becomes a jai::registrar or a property_owner base, and serialize / save / load keep their shape. These archives do not read Cereal's format, so existing files are loaded once with Cereal and saved with JaiScript (From Cereal).
Security posture (untrusted payloads)
- Type and property names are data: a load never executes a payload-controlled name.
- A binary array or map count is not pre-allocated: it reserves at most 1 MiB ahead of the elements that actually load, so a 12-byte payload cannot request a 2 GB allocation.
- Data of another kind where a record, an array, a map or an integer belongs fails the load (
Expected an object in the archive, found an integer), and so does a number its field cannot hold (Archive value 300 does not fit int8, and1.5into an integer field alike). Nothing wraps or truncates (the texts).
Two things in a payload do reach code:
- Saved signal receivers are script. A
JAI_SIGNAL_PROPERTYsaves its script-text receivers as source, and a load re-arms them: each runs as anexecuteof that text, in the engine's global scope with every API the host registered, the next time its signal fires. Load objects with signal properties only from a trusted source, or into an engine that runs untrusted script under the safety rails. - A payload chooses which of the engine's classes a load constructs. A
$typenaming a class the engine defines runs that class's own code, never code the payload supplies: a script class's zero-argument constructor, field initializers, the setters of the properties written and itspost_load; a hybrid's field initializers andpost_load; a C++ type'sload_and_constructor default constructor and itsload.