From Cereal
Cereal and the JaiScript archives share a shape: a type describes itself once, in a serialize
function or a save/load pair, and an archive drives that description in both
directions. Most of a migration is spelling. Three things are more than spelling:
- The files. The JaiScript archives write their own format, in JSON and in binary. A file Cereal wrote does not load through them, so existing data is converted once: loaded with Cereal, saved with JaiScript.
- Registration. A polymorphic type gets its identity from a
jai::property_ownerbase or ajai::registrar, in place ofCEREAL_REGISTER_TYPE. - Versions. Both JaiScript formats name every property in every record, so most of what
CEREAL_CLASS_VERSIONdid is unnecessary.
This page maps every Cereal construct in common use, converts a small save game, shows the property route that removes the serialize functions entirely, and lays out a path that keeps both libraries in the build until the last old file is gone. Cereal took its design from Boost.Serialization, so a Boost codebase takes the same path; From Boost.Serialization lists what is spelled differently there. The archive framework itself is documented in Serialization.
The concept map
Headers: <jaiscript/serialization/convenience.hpp> for the archives and the one-call entry
points, <jaiscript/serialization/construct.hpp> for load_and_construct,
<jaiscript/core/registrar.hpp> for registrars, and
<jaiscript/properties/macros.hpp> with
<jaiscript/properties/property_serialization.hpp> for properties.
Archives and entry points
| Cereal | JaiScript |
|---|---|
cereal::JSONOutputArchive ar(os); ar(value); | std::string text = jai::to_json(*eng, value); (an optional third argument is the indent: 2 by default, 0 for one line) |
cereal::JSONInputArchive ar(is); ar(value); | T value = jai::from_json<T>(*eng, text); |
cereal::BinaryOutputArchive, cereal::PortableBinaryOutputArchive | std::vector<std::uint8_t> blob = jai::to_binary(*eng, value);, or to_binary_string, to_base64; the binary format is little-endian on every platform, so it is already portable |
cereal::BinaryInputArchive, cereal::PortableBinaryInputArchive | jai::from_binary<T>(*eng, blob), from_binary_string, from_base64 |
cereal::XMLOutputArchive, cereal::XMLInputArchive | no XML format: JSON or binary |
| an archive object, complete when it is destroyed | jai::serialization::json_archive_writer / json_archive_reader and binary_archive_writer / binary_archive_reader, for a root you lay out by hand (below); the result is ar.str() or ar.data() once the root record closes |
a std::ostream / std::istream | a std::string or a std::vector<std::uint8_t>; the host reads and writes the file |
| (nothing) | an engine: every entry point takes one (auto eng = jai::engine::make();), because a payload can hold script classes and bound types the engine resolves. One engine serves every save and load. |
Names
| Cereal | JaiScript |
|---|---|
CEREAL_NVP(hp) | JAI_NVP(hp): the key is the argument's text, "hp" |
cereal::make_nvp("hp", hp) | ar("hp", hp), or jai::serialization::make_nvp("hp", hp) |
ar(CEREAL_NVP(a), CEREAL_NVP(b)) | ar(JAI_NVP(a), JAI_NVP(b)); the two-argument form ar("name", value) takes one pair per call |
ar(hp, mana), which Cereal names value0, value1 | no positional form: every field has a name, ar(JAI_NVP(hp), JAI_NVP(mana)) |
Serialization functions
| Cereal | JaiScript |
|---|---|
member serialize(Archive&), member save(Archive&) const with load(Archive&) | unchanged |
the member forms with a std::uint32_t const version parameter | accepted, and preferred over the one-parameter forms, so no forwarding shims are needed. The archives record no class version for a C++ type, so the number carries nothing to branch on (Versions). |
free serialize(Archive&, T&), save(Archive&, const T&), load(Archive&, T&) found by ADL | unchanged in the two-parameter form. A free function with a version parameter is not found: drop the parameter. |
save_minimal / load_minimal | no minimal form: a type with a function always writes a record. Convert in the owning function (ar("tint", tint.to_hex())) or store the primitive. |
cereal::specialize<Archive, T, cereal::specialization::...>, CEREAL_SPECIALIZE_FOR_ALL_ARCHIVES | not needed: the choice is fixed (a member save/load before a member serialize, members before free functions), so the specializations are deleted |
ar(cereal::base_class<Base>(this)) | Base::serialize(ar); (or Base::save(ar) / Base::load(ar)): the base's fields join the derived class's record |
ar(cereal::virtual_base_class<Base>(this)) | the virtual base's function called once, from the most-derived class |
cereal::make_size_tag(n) in a hand-written container | ar.begin_array(n), ar.write_element(v) per element, ar.end_array(); a reader takes std::size_t n = ar.begin_array() and ar.read_element(v) |
cereal::binary_data(ptr, size) | a std::vector<std::uint8_t> or std::string field |
Versions and polymorphism
| Cereal | JaiScript |
|---|---|
CEREAL_CLASS_VERSION(T, n) | no class version for a C++ type: properties are found by name, so reordered, added and removed fields need none. A type whose meaning changes writes a version as a property of its own (Versions). |
CEREAL_REGISTER_TYPE(Derived) with CEREAL_REGISTER_POLYMORPHIC_RELATION(Base, Derived) | class Derived : public jai::property_owner<Derived, Base> declares both; a plain class takes static jai::registrar<Derived, void, jai::bases<Base>> reg("Derived"); |
CEREAL_REGISTER_TYPE_WITH_NAME(Derived, "name") | the registrar's name argument, or JAI_PROPERTY_OWNER_AS(Derived, "name") inside a property_owner |
#include <cereal/types/polymorphic.hpp> | <jaiscript/core/registrar.hpp> for a registrar; a property_owner needs nothing more |
CEREAL_REGISTER_DYNAMIC_INIT, CEREAL_FORCE_DYNAMIC_INIT | no macro: a registrar is a static object, so it lives in a translation unit the program links (a linker drops a static library's object file nothing references) |
a std::unique_ptr<Base> holding a derived object | a unique_ptr is written by its static type, with no $type, so a polymorphic owner is a std::shared_ptr<Base> |
Pointers and identity
Cereal tracks each shared_ptr by an id so a shared object is written once. The JaiScript archives
do the same, in records you can read:
| Cereal | JaiScript |
|---|---|
std::shared_ptr<T> (with <cereal/types/memory.hpp>) | built in: the first write is {"$id": N, "$val": {...}}, every later write of the same object is {"$id": N}, and the load re-aliases them; through a base pointer, "$type" names the derived class |
std::weak_ptr<T> | {"$ref": N}, with the object inline when the weak pointer is its first reference in the save; an expired one is null |
std::unique_ptr<T> | {"$val": {...}}, by the static type |
| raw pointers (rejected) | rejected too: the object lives in a smart pointer |
| one archive per tracking scope | one archive per tracking scope: objects stay shared only when one call saves the whole graph |
Containers and standard types
| Cereal | JaiScript |
|---|---|
#include <cereal/types/vector.hpp> and the other type headers | nothing to include: vector, list, deque, forward_list, array, map, unordered_map, set, unordered_set, pair, tuple, optional, variant and the three smart pointers are built in |
| maps | string keys as an object, other keys as an array of [key, value] pairs |
std::optional, std::variant | [has_value, value] and [index, value] |
| enums | the underlying integer, as Cereal writes them |
C arrays, std::bitset, std::chrono durations, std::complex, std::queue, std::stack, std::valarray | no built-in form: a std::array in place of a C array, and the rest converted in the owning function (a duration as its count, a bitset as its string) |
Construction, access, context and errors
| Cereal | JaiScript |
|---|---|
friend class cereal::access; | friend jai::access; (both lines while both libraries are in the build) |
static void load_and_construct(Archive&, cereal::construct<T>&) | the same static member over jai::serialization::construct<T>, with two parameters (no version); it builds a std::shared_ptr<T> whose T has no default constructor |
a cereal::LoadAndConstruct<T> specialization for a type you cannot edit | no external form: wrap the type, or give it a default constructor |
cereal::UserDataAdapter and cereal::get_user_data<T>(ar) | jai::from_json<T>(*eng, text, ctx) (and the binary forms) carry a context; inside a load, ar.template get_user_context<Context>() returns a pointer to it, or null when none was passed |
cereal::Exception | jai::serialization_error, a jai::runtime_error (which derives from std::runtime_error) |
A worked conversion
A small save game: an Item, an Inventory that owns items, a Player that
holds its inventory through a shared_ptr, and status effects behind base pointers.
Leaf types
#include <cereal/types/string.hpp>
#include <cereal/types/vector.hpp>
struct Item {
std::string name;
int count = 1;
template<class Archive>
void serialize(Archive& ar) { ar(CEREAL_NVP(name), CEREAL_NVP(count)); }
};
class Inventory {
friend class cereal::access;
std::vector<Item> items;
int capacity = 20;
template<class Archive>
void serialize(Archive& ar, std::uint32_t const version) {
ar(CEREAL_NVP(items));
if (version >= 2) { ar(CEREAL_NVP(capacity)); }
}
};
CEREAL_CLASS_VERSION(Inventory, 2);#include <jaiscript/serialization/convenience.hpp>
struct Item {
std::string name;
int count = 1;
template<class Archive>
void serialize(Archive& ar) { ar(JAI_NVP(name), JAI_NVP(count)); }
};
class Inventory {
friend jai::access;
std::vector<Item> items;
int capacity = 20;
template<class Archive>
void serialize(Archive& ar) { ar(JAI_NVP(items), JAI_NVP(capacity)); }
};The type headers go, because the standard types are built in. The names change spelling. The version gate goes
too: it existed for Cereal files written before capacity did. Every JaiScript file is written by
the converted code, so the first JaiScript layout already holds both fields, and the old files reach JaiScript
through the converter, which reads them with Cereal and its version branch
(below). A field added from here on is read under
ar.has_property (Versions).
Owners and shared pointers
#include <cereal/types/memory.hpp>
#include <cereal/archives/json.hpp>
class Player {
public:
std::string name;
int hp = 100;
std::shared_ptr<Inventory> inventory = std::make_shared<Inventory>();
float hpFraction = 1.0f; // derived on load, never saved
template<class Archive>
void save(Archive& ar) const { ar(CEREAL_NVP(name), CEREAL_NVP(hp), CEREAL_NVP(inventory)); }
template<class Archive>
void load(Archive& ar) {
ar(CEREAL_NVP(name), CEREAL_NVP(hp), CEREAL_NVP(inventory));
hpFraction = hp / 100.0f;
}
};
std::ofstream os("player.json");
{
cereal::JSONOutputArchive ar(os);
ar(player);
}class Player {
public:
std::string name;
int hp = 100;
std::shared_ptr<Inventory> inventory = std::make_shared<Inventory>();
float hpFraction = 1.0f;
template<class Archive>
void save(Archive& ar) const { ar(JAI_NVP(name), JAI_NVP(hp), JAI_NVP(inventory)); }
template<class Archive>
void load(Archive& ar) {
ar(JAI_NVP(name), JAI_NVP(hp), JAI_NVP(inventory));
hpFraction = hp / 100.0f;
}
};
auto eng = jai::engine::make();
std::ofstream("player.json") << jai::to_json(*eng, player);
std::stringstream text;
text << std::ifstream("player.json").rdbuf();
Player loaded = jai::from_json<Player>(*eng, text.str());The class body changes only in its names. The inventory is written as
{"$id": 1, "$val": {"items": [...], "capacity": 20}}; a second shared_ptr to the same
inventory in the same save would write {"$id": 1} and load as the same object. A
std::vector<Item> needs no help: the archives walk the container and call
Item::serialize per element.
A polymorphic hierarchy
#include <cereal/types/polymorphic.hpp>
#include <cereal/types/base_class.hpp>
struct Effect {
virtual ~Effect() = default;
double remaining = 0;
template<class Archive>
void serialize(Archive& ar) { ar(CEREAL_NVP(remaining)); }
};
struct Burn : Effect {
int damagePerTick = 1;
template<class Archive>
void serialize(Archive& ar) { ar(cereal::base_class<Effect>(this), CEREAL_NVP(damagePerTick)); }
};
struct Shield : Effect {
int absorb = 0;
template<class Archive>
void serialize(Archive& ar) { ar(cereal::base_class<Effect>(this), CEREAL_NVP(absorb)); }
};
CEREAL_REGISTER_TYPE(Burn)
CEREAL_REGISTER_TYPE(Shield)
CEREAL_REGISTER_POLYMORPHIC_RELATION(Effect, Burn)
CEREAL_REGISTER_POLYMORPHIC_RELATION(Effect, Shield)#include <jaiscript/core/registrar.hpp>
struct Effect {
virtual ~Effect() = default;
double remaining = 0;
template<class Archive>
void serialize(Archive& ar) { ar(JAI_NVP(remaining)); }
};
struct Burn : Effect {
int damagePerTick = 1;
template<class Archive>
void serialize(Archive& ar) { Effect::serialize(ar); ar(JAI_NVP(damagePerTick)); }
};
struct Shield : Effect {
int absorb = 0;
template<class Archive>
void serialize(Archive& ar) { Effect::serialize(ar); ar(JAI_NVP(absorb)); }
};
static jai::registrar<Burn, void, jai::bases<Effect>> burnRegistrar("Burn");
static jai::registrar<Shield, void, jai::bases<Effect>> shieldRegistrar("Shield");Each registrar gives its class the name the archives write as $type and records the edge to
Effect, which a load checks before it hands a Burn back through an
Effect pointer. It registers at program start; the same object also makes the class bindable into
an engine through jai::bind_registrar, with an optional configure lambda that adds methods and
properties (Serialization shows one).
Give Player a std::vector<std::shared_ptr<Effect>> effects field, archive
it like the others, and each element is written as
{"$id": N, "$type": "Burn", "$val": {"remaining": 3.5, "damagePerTick": 2}} and loads as a
Burn.
The base's fields land in the derived class's record, beside its own, where Cereal nests them in a record of their own. Keep field names distinct across a hierarchy.
Root objects and files
jai::to_json(*eng, value) writes one value as the root. Cereal code that writes several root values
(ar(settings, world)) either gathers them into one struct or opens the root record by hand, the way
the one-call functions do:
jai::serialization::json_archive_writer out(2, eng.get()); // indent, engine
out.begin_object("", 1);
out("settings", settings);
out("world", world);
out.end_object();
std::string text = out.str();
jai::serialization::json_archive_reader in(text, eng.get());
std::string typeName;
std::uint32_t version = 0;
in.begin_object(typeName, version);
in("settings", settings);
in("world", world);
in.end_object();The binary pair is the same calls: binary_archive_writer out(eng.get()) with
out.data() for the bytes, and binary_archive_reader in(bytes, eng.get()).
The property route: no serialize functions
Cereal needs a function per type. A class that derives jai::property_owner and declares its fields
with JAI_PROPERTY needs none: the archives save and load every property by name, the same
declaration makes the field reflected and script-visible (dynamic_binder),
and the class carries its own polymorphic identity. A migration is a good moment to make that move for types
whose functions only list their fields.
#include <jaiscript/properties/macros.hpp>
#include <jaiscript/properties/property_serialization.hpp>
class Effect : public jai::property_owner<Effect> {
public:
JAI_PROPERTY((double), remaining, 0.0);
};
class Burn : public jai::property_owner<Burn, Effect> {
public:
JAI_PROPERTY((int), damagePerTick, 1);
};
class Player : public jai::property_owner<Player> {
public:
JAI_PROPERTY((std::string), name, "hero");
JAI_PROPERTY((int), hp, 100);
JAI_PROPERTY((std::shared_ptr<Inventory>), inventory, std::make_shared<Inventory>());
JAI_PROPERTY((std::vector<std::shared_ptr<Effect>>), effects);
JAI_TRANSIENT_PROPERTY((float), hpFraction, 1.0f);
};
auto player = std::make_shared<Player>();
std::string json = jai::to_json(*eng, player);
auto loaded = jai::from_json<std::shared_ptr<Player>>(*eng, json);- Nothing to keep in step. A load reads the properties the record and the class share. A
property the record lacks keeps its initializer, and one the class no longer declares is skipped, so adding or
removing a
JAI_PROPERTYneeds no migration code. JAI_TRANSIENT_PROPERTYis reflected but never saved: the counterpart of a field a Cereal function left out.JAI_NAMED_PROPERTY((type), "saved_name", member)saves under a key other than the member's name, which keeps a key stable through a rename.property_owner<Burn, Effect>recordsBurn's name and its edge toEffect, so polymorphic saves need no registrar. The name is the bare class name;JAI_PROPERTY_OWNER_AS(Burn, "Burn")pins it.- A
property_ownercannot be moved (its property manager points at its members), so a root loads through astd::shared_ptr, as above. - Members that are not properties are not saved. A class that needs more keeps a
save/loadpair, which takes precedence over the automatic one;save_owned_properties(ar)andload_owned_properties(ar)write and read every level's properties beside the hand-written part (property_mgr.save(ar)covers only the declaring class's level):
class Player : public jai::property_owner<Player> {
public:
JAI_PROPERTY((int), hp, 100);
float hpFraction = 1.0f;
template<class Archive>
void save(Archive& ar) const { save_owned_properties(ar); }
template<class Archive>
void load(Archive& ar) {
load_owned_properties(ar);
hpFraction = hp.get() / 100.0f;
}
};Versions in a self-describing format
Cereal's binary archives are positional: a reader asks for exactly what the writer wrote, in the same order, and
CEREAL_CLASS_VERSION tells it which layout it holds. Both JaiScript formats name every property in
every record (the binary one through a table of names and sizes ahead of the values), so a reader finds a property
by name and skips the ones it does not ask for. Most of what a version number did goes away:
- Reordering fields changes nothing, except among fields that hold the same object: the one the file writes the object in is read first (Pitfalls).
- A removed field is skipped, because nothing asks for it.
- An added field is read under
ar.has_property, and keeps its initializer for a record written before it existed (aproperty_ownerdoes this for every property):
template<class Archive>
void load(Archive& ar) {
ar(JAI_NVP(items));
if (ar.has_property("capacity")) { ar(JAI_NVP(capacity)); }
}What is left for a version is a field whose meaning changes: a unit, an encoding, one field split into two. The archives record no class version for a C++ type, so such a type writes one as a property and defaults it for records that predate it:
template<class Archive>
void save(Archive& ar) const {
ar("format", std::uint32_t(2));
ar(JAI_NVP(metersPerSecond));
}
template<class Archive>
void load(Archive& ar) {
std::uint32_t format = 1;
if (ar.has_property("format")) { ar("format", format); }
if (format >= 2) {
ar(JAI_NVP(metersPerSecond));
} else {
double kilometersPerHour = 0;
ar("speed", kilometersPerHour);
metersPerSecond = kilometersPerHour / 3.6;
}
}A Cereal version history does not carry over. The first JaiScript layout of a type holds every current field, and the version branches stay on the Cereal side for as long as the converter needs them. A versioned member function still compiles and is still called, but the number it receives is not a record version: leave it unused on the JaiScript side.
A migration path
- Add JaiScript beside Cereal. Link the library, include
<jaiscript/serialization/convenience.hpp>where types are serialized, and make one engine the save code can reach. Nothing changes on disk yet. - Make the leaf types dual. Starting with types that hold only primitives, strings and containers of those, give each function a JaiScript branch beside its Cereal one (below). Both libraries drive the same function, and Cereal's output does not change.
- Move up through the owners. A type is ready when every type it holds is. Containers and smart pointers need nothing beyond their element types.
- Register the polymorphic hierarchies. Add a registrar, or a
property_ownerbase, beside eachCEREAL_REGISTER_TYPE. Keep Cereal's registrations: the converter still needs them. - Prove each type round-trips through both JaiScript formats before anything is saved for real:
jai::from_json<T>(*eng, jai::to_json(*eng, value))andjai::from_binary<T>(*eng, jai::to_binary(*eng, value)), compared with the original. The two formats run different code, and a field one of them drops can hide behind the other. - Switch the writers. New files go out through JaiScript under a new name (an extension, a folder). The loader reads files with the new name through JaiScript and falls back to Cereal for the old name.
- Convert the existing data once (below).
- Remove Cereal. When no file in the old format remains, delete the Cereal branches,
registrations, includes and
friend class cereal::accesslines, and drop the dependency. Types whose functions only list fields can then take the property route.
Both libraries in one function
jai::serialization::jai_archive<Archive> is true for the JaiScript archives and false for every
Cereal archive, so an if constexpr on it keeps each library's spelling on its own side and the branch
not taken is never instantiated:
struct Effect {
virtual ~Effect() = default;
double remaining = 0;
template<class Archive>
void serialize(Archive& ar) {
if constexpr (jai::serialization::jai_archive<Archive>) { ar(JAI_NVP(remaining)); }
else { ar(CEREAL_NVP(remaining)); }
}
};
struct Burn : Effect {
int damagePerTick = 1;
template<class Archive>
void serialize(Archive& ar) {
if constexpr (jai::serialization::jai_archive<Archive>) {
Effect::serialize(ar);
ar(JAI_NVP(damagePerTick));
} else {
ar(cereal::base_class<Effect>(this), CEREAL_NVP(damagePerTick));
}
}
};
CEREAL_REGISTER_TYPE(Burn)
CEREAL_REGISTER_POLYMORPHIC_RELATION(Effect, Burn)
static jai::registrar<Burn, void, jai::bases<Effect>> burnRegistrar("Burn");- Where the two bodies share nothing, two overloads say the same thing: one constrained with
requires jai::serialization::jai_archive<Archive>, the other withrequires jai::serialization::not_jai_archive<Archive>. - A Cereal function with a version parameter keeps it. The JaiScript archives call the versioned member form too, so the version branches stay inside the Cereal side.
- A type with
load_and_constructkeeps one per library. They take differentconstructtypes, so they overload without constraints. - Code that already holds a Cereal name-value pair hands it to a JaiScript archive as its two halves:
template<class Archive, class T>
void archive_pair(Archive& ar, cereal::NameValuePair<T>& pair) {
if constexpr (jai::serialization::jai_archive<Archive>) { ar(pair.name, pair.value); }
else { ar(pair); }
}Converting existing files
A JaiScript archive cannot read a file Cereal wrote: the two formats share no layout. Existing data crosses once, through a program that links both libraries and the dual types. Cereal loads each file, exactly as the old code wrote it (the same archive type, the same root shape, Cereal's registrations compiled in), and JaiScript saves it:
#include <cereal/archives/binary.hpp>
#include <jaiscript/serialization/convenience.hpp>
void convertSave(jai::engine& eng, const std::filesystem::path& from, const std::filesystem::path& to) {
SaveGame save;
{
std::ifstream is(from, std::ios::binary);
cereal::BinaryInputArchive in(is);
in(save);
}
std::string text = jai::to_json(eng, save);
SaveGame check = jai::from_json<SaveGame>(eng, text);
if (jai::to_json(eng, check) != text) {
throw std::runtime_error("lossy conversion: " + from.string());
}
std::ofstream(to) << text;
}The check loads the new text back and saves it again: a field the JaiScript load misses shows up as
a difference. (A type holding an unordered_map or unordered_set can list the same entries
in another order after a reload; compare those by content.) Content that ships with the program (levels, prefabs,
tuning tables) converts in one batch: run the converter over it, keep the converted files and delete the old
ones.
Files you cannot convert in advance
Saves on players' machines convert on first load. Ship releases that read a file with the old name through Cereal, write it under the new name through JaiScript, and read the new name from then on; remove the Cereal reader once the old saves no longer matter. Tell the formats apart by name, never by trying one reader and catching its error (first pitfall).
The JaiScript formats belong to the library's 0.x surface: data saved by one 0.x release is not promised to load in the next (Stability). Keep the converter and the original files until the format your data depends on has settled.
From Boost.Serialization
Cereal is modeled on Boost.Serialization, and everything above applies to a Boost codebase: the files convert
once through a program that links both libraries, polymorphic types register, and class versions mostly go away.
Boost's member functions keep their signatures, because serialize(Archive&, const unsigned int)
is the versioned member form the JaiScript archives call. What changes is inside them: Boost streams fields with
&, << and >>, and the JaiScript archives take named fields
through their call operator, with no stream operators.
| Boost.Serialization | JaiScript |
|---|---|
boost::archive::text_oarchive ar(os); ar << value;, and the binary_ and xml_ archives | jai::to_json(*eng, value), jai::to_binary(*eng, value); no XML format |
boost::archive::text_iarchive ar(is); ar >> value; | jai::from_json<T>(*eng, text), jai::from_binary<T>(*eng, blob) |
ar & hp;, and ar << hp / ar >> hp in a split pair | ar(JAI_NVP(hp)); in every function: each field has a name |
ar & BOOST_SERIALIZATION_NVP(hp);, ar & boost::serialization::make_nvp("hp", hp); | ar(JAI_NVP(hp));, ar("hp", hp); |
member serialize(Archive&, const unsigned int version), member save(...) const / load(...) | the same signatures; the number they receive is not a record version (Versions) |
BOOST_SERIALIZATION_SPLIT_MEMBER() | not needed: the archives call a member save/load pair directly, so the macro compiles beside them and goes when Boost does |
free serialize(Archive&, T&, const unsigned int), BOOST_SERIALIZATION_SPLIT_FREE(T) | free serialize(Archive&, T&), or free save / load, with two parameters: drop the version |
boost::serialization::base_object<Base>(*this) | Base::serialize(ar, version); (or Base::save / Base::load): the base's fields join the derived record |
BOOST_CLASS_EXPORT(T), BOOST_CLASS_EXPORT_GUID(T, "name"), BOOST_CLASS_EXPORT_KEY / _IMPLEMENT, ar.register_type<T>() | static jai::registrar<T, void, jai::bases<Base>> reg("name"); or a property_owner base, once per program |
boost::serialization::void_cast_register<Derived, Base>() | the registrar's jai::bases<Base> |
BOOST_CLASS_VERSION(T, n) | no class version (Versions) |
BOOST_CLASS_TRACKING, BOOST_CLASS_IMPLEMENTATION | nothing to set: a shared_ptr's object is always tracked, and nothing else is |
| raw pointers, tracked by address and created on load | rejected: the object lives in a std::shared_ptr (or a std::unique_ptr, written by its static type) |
| an object saved by value and again through a pointer, restored as one object | two objects: identity belongs to shared_ptrs alone |
save_construct_data / load_construct_data | a static load_and_construct over jai::serialization::construct<T> |
#include <boost/serialization/vector.hpp>, shared_ptr.hpp and the other type headers | nothing to include (Containers) |
boost::serialization::make_array(p, n), make_binary_object(p, n) | a std::vector or std::array field |
friend class boost::serialization::access; | friend jai::access; |
boost::archive::archive_exception | jai::serialization_error |
The effects from the worked conversion, written for Boost and then for JaiScript:
#include <boost/serialization/base_object.hpp>
#include <boost/serialization/export.hpp>
#include <boost/serialization/nvp.hpp>
struct Effect {
virtual ~Effect() = default;
double remaining = 0;
template<class Archive>
void serialize(Archive& ar, const unsigned int) { ar & BOOST_SERIALIZATION_NVP(remaining); }
};
struct Burn : Effect {
int damagePerTick = 1;
template<class Archive>
void serialize(Archive& ar, const unsigned int) {
ar & boost::serialization::make_nvp("Effect", boost::serialization::base_object<Effect>(*this));
ar & BOOST_SERIALIZATION_NVP(damagePerTick);
}
};
BOOST_CLASS_EXPORT(Burn)#include <jaiscript/serialization/convenience.hpp>
#include <jaiscript/core/registrar.hpp>
struct Effect {
virtual ~Effect() = default;
double remaining = 0;
template<class Archive>
void serialize(Archive& ar, const unsigned int) { ar(JAI_NVP(remaining)); }
};
struct Burn : Effect {
int damagePerTick = 1;
template<class Archive>
void serialize(Archive& ar, const unsigned int version) {
Effect::serialize(ar, version);
ar(JAI_NVP(damagePerTick));
}
};
static jai::registrar<Burn, void, jai::bases<Effect>> burnRegistrar("Burn");A std::vector<std::shared_ptr<Effect>> holding a Burn saves as
{"$id": 1, "$type": "Burn", "$val": {"remaining": 3.5, "damagePerTick": 2}} and loads as a
Burn. During the transition one function serves both libraries the way it does for Cereal
(above): jai::serialization::jai_archive<Archive> is
false for every Boost archive, so the Boost body stays in the other branch, and a save/load
pair keeps its BOOST_SERIALIZATION_SPLIT_MEMBER() until Boost leaves the build:
class Player {
friend class boost::serialization::access;
friend jai::access;
public:
std::string name;
int hp = 100;
float hpFraction = 1.0f;
private:
template<class Archive>
void save(Archive& ar, const unsigned int) const {
if constexpr (jai::serialization::jai_archive<Archive>) { ar(JAI_NVP(name), JAI_NVP(hp)); }
else { ar << name << hp; }
}
template<class Archive>
void load(Archive& ar, const unsigned int) {
if constexpr (jai::serialization::jai_archive<Archive>) { ar(JAI_NVP(name), JAI_NVP(hp)); }
else { ar >> name >> hp; }
hpFraction = hp / 100.0f;
}
BOOST_SERIALIZATION_SPLIT_MEMBER()
};A Boost text or binary archive is positional, like Cereal's binary archives, and reads only through the code and the class versions that wrote it. The converter loads each old file with Boost, exactly as the old code wrote it, and saves it with JaiScript (Converting existing files).
Pitfalls
- Reading an old file with the new loader. A
property_ownerload reads the properties it recognizes and keeps the initializers of the rest, so a Cereal file handed to it can load as an object of defaults without an error. Keep the formats under different names. - Positional fields. Cereal names unnamed values
value0,value1; JaiScript has no positional form.JAI_NVPtakes its argument's text as the key, soJAI_NVP(m_hp)saves"m_hp"andJAI_NVP(this->hp)saves"this->hp";ar("hp", m_hp)spells the key you want in the file. - A field the record lacks.
ar("name", v)on a missing property throwsjai::serialization_error(Property 'name' not found during deserialization). A field a Cereal function wrote conditionally is read underar.has_property("name"). - A polymorphic
unique_ptr. It is written by its static type, so astd::unique_ptr<Effect>holding aBurnsaves theEffectfields and loads anEffect, with no error. A polymorphic owner is astd::shared_ptr. - An unregistered derived class. Saving one through a base pointer throws
Cannot serialize unregistered polymorphic type '...' through pointer-to-base '...'; loading a$typethe program does not know throwsUnknown polymorphic type: Name, and a$typenaming a registered class that does not derive from the requested base is rejected. - The name is in the file.
$typestores the registered name, and an automatic name follows the C++ class name, so renaming the class breaks old saves. Pin a name that must outlive a rename (the registrar's argument,JAI_PROPERTY_OWNER_AS) before the first save. - A registration a binary never sees. A
property_ownerregisters its name when the program constructs or binds the type somewhere; a tool that only loads a type registers it with ajai::registrar. A registrar in a static library's object file that nothing references is dropped by the linker, as Cereal's registrations are. - A versioned free function. The archives find free functions with two parameters only, so a
free
serialize(Archive&, T&, std::uint32_t)leaves the type unserializable: the save fails with ajai::serialization_errornaming it. - Base and derived fields share a record. A derived field named like a base field writes the
same key twice. (In a
property_ownerchain the derived level wins, as C++ name hiding does.) - A property saved twice. A hand-written
savethat callssave_owned_properties(ar)and also writes aJAI_PROPERTYfield by name writes that key twice. - Shared objects read out of order. A
shared_ptrwrites its object at the first reference the save reaches ({"$id": N, "$val": {...}}; aweak_ptrthat comes first writes it as{"$ref": N, "$val": {...}}) and{"$id": N}at every later one, and the load resolves ids in the orderloadreads its fields. When the save writesinventoryand thenequipped, both holding one object, aloadthat readsequippedfirst throwsjai::serialization_error(Dangling shared_ptr reference: {"$id": N} names an object this archive never defined (...)); aweak_ptrread before the field that carries its object loads empty. Read such fields in the order the save writes them. Aproperty_owner's automatic save and load walk its properties in one order (each level by name, base levels first), so this concerns a hand-written pair. - One graph per call. Object identity lasts one archive, as in Cereal: a
shared_ptrsaved by two separateto_jsoncalls loads as two objects. - Exception types. A
catch (const cereal::Exception&)becomescatch (const jai::serialization_error&);std::exceptioncovers both while both libraries run. - Enums are numbers. Both libraries write the underlying integer, so an enumerator inserted mid-list renumbers every value after it in old files. Give enumerators explicit values, or save a name.