⚓ Seaworth Games

lib/serializer

Bytes and Types

Every doc in this atlas that mentioned h & object was standing on this without looking down. This is how a CPack* read off the wire remembers it was really a MakeAction, how the same pointer serialized twice doesn’t get written twice, and one place where the atlas’s own canonical doc turns out to be describing code that no longer exists.

Reconstructing a type from a pointer

This atlas’s Wire Protocol doc took for granted that a deserialized blob already knows it’s a MakeAction, not a bare CPack. Nothing about C++ makes that automatic. This is what does.

BinarySerializer::save(CPack* data) data actually points to a MakeAction savedPointers — seen this address before? no → assign next pid, write it · yes → write the old pid and stop here CTypeList::getTypeID(data) — via typeid(*data) writes 198 — MakeAction's own, permanent, hand-assigned id CSerializationApplier::getApplier(198)->savePtr(*this, data) the hand-rolled vtable — calls MakeAction's real serialize(), not CPack's …on the wire, and back: BinaryDeserializer reads the same 198 getApplier(198)->createPtr() — new MakeAction() — then ->loadPtr() fills it in
The receiving code only ever asked for a CPack*. What it gets back is a fully-formed MakeAction, because CTypeList and CSerializationApplier did the one thing C++ templates can’t do on their own — call a virtual method that depends on a compile-time type. ISerializerReflection is the interface (createPtr / loadPtr / savePtr); one implementation exists per registered type, generated at the registerType<T>() call site, and CSerializationApplier is just a map<id, unique_ptr<ISerializerReflection>> — a vtable VCMI built by hand because the language wouldn’t give it one for this.

RegisterTypes.h's own comment is stricter than it sounds at first: the numbers (CPack is 82, CPackForServer 179, MakeAction 198, BattleAttack 140) are not assigned by registration order — they're hand-picked, permanent, and never reused. “If type is removed please only remove corresponding type, without adjusting indexes of following types.” A gap in the numbering is a removed type, kept as a hole on purpose, because an old savegame or a mismatched client on the wire might still reference it.

Aliases and cycles

The savedPointers lookup from the diagram above isn’t a side detail — it’s always running, on every pointer, in both directions.

Both BinarySerializer and BinaryDeserializer declare static constexpr bool trackSerializedPointers = true. Before a pointer's type or contents are ever written, its address is normalized to its Serializeable base (so an object reached through a non-first base in a multiple-inheritance hierarchy still resolves to the same identity) and checked against everything already saved this session. Two pointers to the same object write the object once and a reference the second time; a pointer cycle terminates the same way, since the first half of the cycle is already in the map by the time the second half is reached. Loading mirrors it exactly with its own loadedPointers map, keyed by the same sequential id.

What the docs used to say

docs/developers/Serialization.md describes two more independently-togglable features on top of the two above. Neither exists in this codebase anymore.

The doc names smartVectorMembersSerialization (send a vectorized game object's index instead of its full state — CGObjectInstance, CCreature, CArtifact and others, via CSerializer::addStdVecItems()) and sendStackInstanceByIds (the same idea, specialized for army stacks). A search of the entire lib/, server/, and client/ trees for all three identifiers — the exact strings the doc uses — returns nothing. The generic, serializer-level version of “don’t send what the other side already has” is gone.

What survived is the instinct, not the mechanism. This atlas's own Apply Layer doc already found visitGiveBonus resolving pack.who through an explicit enum-plus-id, not a raw pointer the serializer quietly substitutes. That's the current answer: packs declare their own typed id fields (ObjectInstanceID, CreatureID, and the like) directly in serialize(), and resolve them by hand at the point of use. The decision moved from an automatic serializer feature to an explicit schema choice on every pack — which is also, not coincidentally, exactly what the top-level AGENTS.md tells a contributor to do when a doc and the source disagree: trust the source.

A bounded past, not backward compatibility

The canonical doc calls real backward compatibility “not feasible” and the version number “rarely used.” ESerializationVersion.h is more machinery than that framing suggests.

The version isn't a raw integer field call sites compare by hand — it's a named enum, one key per format-affecting change (HOTA_MAP_STACK_COUNT, BONUS_TRIGGER, GAME_REPLAY_RECORDING, twenty-plus entries), checked as h.hasFeature(Handler::Version::KEY) at the exact field that changed. CURRENT always equals the newest key; MINIMAL is the oldest a save can still declare and load — currently pinned at RELEASE_170, itself just an alias for one of the named keys. A save older than MINIMAL fails outright. The file's own comment spells out the ritual for a deliberate break: bump MINIMAL past CURRENT, delete every enum key in between, let the compiler find every hasFeature() check that referenced them. It's bounded, not permanent — but it's a real, working window, not the afterthought the older framing implies.