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_binary and to_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_binary and siblings). They dispatch like Cereal, to a member or free serialize / save / load, or to the JAI_PROPERTY fields of a property_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 / load or JAI_PROPERTY fields. A dynamic_binder chain adds the class's name for the script archives, and a jai::registrar around 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​

jaiscript
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));
output
{"$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 $type is constructed as Name() would construct it, then the payload writes its fields in declaration order (base first). A constructor that throws fails the from_json call with that error.
  • An unknown $type is data, not an error: the load yields a plain map with both keys intact. So is a malformed tag ({"$char": 300}, a $map whose entries are not pairs).
  • A null loads into a handle field as = null stores it. A weak pointer whose object nothing else in the save holds loads expired. An id the save never wrote fails with Invalid shared_ptr ID: 4.
  • Floats round-trip at full precision (-0.0 keeps its sign). JSON cannot spell an infinity or a NaN, so to_json writes null; to_base64 / from_base64 keep 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.

jaiscript
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"]);
output
ada 120 lantern true

A 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​

C++
#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";
output
{
  "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_json is the indent: 2 by default, 0 for one line.
  • Also: to_binary_string / from_binary_string, to_base64 / from_base64, and a Context& overload of each (jai::from_json<T>(*eng, json, ctx)) that hands a user context to nested save and load calls as ar.get_user_context<Context>().
  • A load that returns T by value needs a default-constructible, movable T; a property_owner is both. jai::from_json<std::shared_ptr<T>> keeps a derived object's $type.
  • Every failure in either direction throws jai::serialization_error, a jai::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:

C++
// 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";
output
{"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. A const member and a getter-only property stay out. .transient_property(name, ...) binds a property scripts reach and the record leaves out, as a property_owner's record leaves out a JAI_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_hook then 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, save or load, or property_owner. A class that declares one of those members where jai::access cannot reach it is the compile error below, never a binding record.
  • Polymorphism. A polymorphic class bound this way takes its $type from its binding's name in the archive's engine, and .base_class<B>() records the edge a shared_ptr<B> load checks, so no registrar is needed (dynamic_binder). A jai::registrar name 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 an Enemy again.

Dispatch priority​

For a type T the archive takes the first of these that applies:

  1. a primitive: an arithmetic type, an enum or std::string
  2. a member save (writing) or load (reading)
  3. a member serialize
  4. a property_owner<T>: every level's JAI_PROPERTY fields, automatically
  5. a built-in standard type: std::vector, map, unordered_map, array, pair, optional, variant, tuple, the sets, shared_ptr / weak_ptr / unique_ptr
  6. a free serialize found by ADL
  7. a free save (writing) or load (reading) found by ADL
  8. 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 save but no load loads through its serialize, and the reverse.
  • A property_owner that declares save / load can call save_owned_properties(ar) / load_owned_properties(ar) inside them to write its JAI_PROPERTY fields beside the hand-written ones.
  • Free functions are found only as serialize(Archive&, T&), save(Archive&, const T&) and load(Archive&, T&), never with a version parameter. A member taking a std::uint32_t version is found and preferred over the one-parameter form (Versions).
  • Private members: friend jai::access;. A type with no other route that declares serialize, save or load where jai::access cannot 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 as void save(const std::string& path), is reported as that. The check cannot see into a final class, whose forgotten line reads as no route. A property_owner that declares one of the three where jai::access cannot 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_json and their kin, an ar(...) 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 deserializable on 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_ptr or weak_ptr to 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.
  • JAI_STATIC_BINDER is not part of this dispatch. Its jai_static_type<T>::save / load runs 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_ptr or weak_ptr load builds its object through static 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 with friend jai::access;. A type with neither is a compile error, and so is a type with no default constructor whose load_and_construct is closed to jai::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 its jai::registrar. A polymorphic type with neither compiles, since its registered subclasses load through their $type, and raises serialization_error at a load that names none. A unique_ptr always 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_owner load 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 a JAI_PROPERTY needs no code.
  • A hand-written load asks for each property, and ar("name", v) throws when the record lacks it. Read a field newer than some saves under ar.has_property("name").
  • When a field keeps its name but changes its meaning (a unit, an encoding, one field split into two), write a format property. It is an ordinary field that reads back the same from JSON and binary, and a record written before it existed lacks it.
C++
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:

ValueWritten 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:

jaiscript
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));
output
{"$type":"Link","$val":{"a":{"$id":1,"$type":"Node","$val":{"v":1}},"b":{"$id":1},"w":{"$ref":1}}}
C++
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";
output
{"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_owner type has one from its declaration (dynamic_binder).
  • A plain class gets one from a jai::registrar, whose jai::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 raises Cannot 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's typeid spellings, MSVC's here).
C++
// 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 again

A 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, and 1.5 into 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_PROPERTY saves its script-text receivers as source, and a load re-arms them: each runs as an execute of 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 $type naming 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 its post_load; a hybrid's field initializers and post_load; a C++ type's load_and_construct or default constructor and its load.