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.
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.
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.
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.
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 foundvisitGiveBonusresolvingpack.whothrough 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 inserialize(), 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-levelAGENTS.mdtells a contributor to do when a doc and the source disagree: trust the source.
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.