Compatibility Guideline#

This file was written based on engine version 5.14.0-dev after PR 16796.

When implementing features or extending the behavior of existing APIs, we generally want the following:

  1. Backwards compatibility: Keep existing features working (according to versioning).
  2. Forwards compatibility and future-proofing: Make it as easy as possible to extend the feature in the future.

This document gives a few insights on how to achieve backwards and forwards compatibility among:

  • old server + new client
  • new server + old client

Imperfect backwards compatibility#

In many cases it is not possible to implement a feature such that it works perfectly well on older clients. A compromise has to be defined.

Example: Objects shall have a fade-in and fade-out animation, which is specified by float fade_time, sent from the server to the client. However, older clients are not aware of this parameter, thus would not play any fade-out animation. Possible approaches:

  • After fade_time seconds, the server sends a new packet to set is_visible = false for older clients
  • The caller mod shall consider the protocol version and include a workaround if backwards compatibility is wanted by the author.

Networking#

Note that the code examples below were taken from the codebase and modified significantly for easier understanding.

Append to existing#

This is useful when adding a few data bytes to existing packets. For larger changes, consider using a Protocol version check instead.

For std::iostream#

For the sender (client or server) it is often simple to append new data to the previous end of the stream. A few known good examples:

  • ParticleParameters::serialize (write) and ParticleParameters::deSerialize (read)
  • ContentFeatures::serialize (write) and ContentFeatures::deSerialize (read)

Code example. Let us assume that shaded (a bool) shall be added:

void ObjectProperties::serialize(std::ostream &os) const
{
	// ... other data writes
	os << serializeString16(damage_texture_modifier);
	// << previous end >>

	// New data byte(s):
	writeU8(os, shaded);
	// << new end >>
}

Meanwhile the counterpart must detect and deal with differing stream lengths:

void ObjectProperties::deSerialize(std::istream &is)
{
	// Note 1: Use 'do { } while (0);' to ...
	//  A) 'break' from reading early
	//  B) allow post-processing steps after reading
	do {
		// ... other data reads
		if (!canRead(is)) // See 'Note 1' below
			break;
		// Engine version >= 5.25.0-dev

		damage_texture_modifier = deSerializeString16(is);
		// << previous end >>

		// New data byte(s):
		if (!canRead(is))
			break;
		// Engine version >= 5.28.0-dev

		shaded = readU8(is);
		// Note 2: `shaded` must be assigned to a fallback value. But not here.
		// Use a code path that is guaranteed to be executed, such as a constructor.

		// << new end >>
	} while (0);
}

Note 1: std::istream read functions such as read*(std::istream &) do only set eofbit when trying to read after the end of the stream! See C++ documentation. Hence, do use canRead to detect the stream end at the correct time.

For NetworkPacket#

Same as with std::iostream, new bytes can be appended to the previous packet data. Use NetworkPacket::getRemainingBytes() to detect data ends ahead of time. A few known good examples:

  • Client::sendPlayerPos (write) and Server::process_PlayerPos (read)
  • Server::SendCloudParams (write) and Client::handleCommand_CloudParams (read)

Note 1: Catching the exception PacketError in a command handler is generally not recommended because incorrect network packets cannot be detected in that case. Example: invalid string length due to incorrect read offset (a logic error).

Protocol version check#

This section may also be applied to persistent data, e.g. mapblock data.

For trivial additions, consider using Append to existing instead. Reason for that being the version bump.

Handshake#

The protocol version is a number resulting from the handshake between the server and client. It is calculated as follows:

protocol_version = min(highest_supported_by_client, highest_supported_by_server)
  • Client: Client::m_proto_ver
  • Server: RemotePlayer::protocol_version

Consequently, the value of these two variables is guaranteed to be equal.

Code in practice#

This means that the following logic can be used for both - client and server:

void X::SendFooBar(session_t peer_id)
{
	// NetworkPacket pkt(...)

	if (protocol_version < 50) {
		// Compatibility code used for older engine versions
	} else {
		// New code path
	}
}

However, this means that a new version number is needed ("protocol version bump"). Whereas the version is bumped regularly, it means that:

  • Your change can only be tested by bumping the protocol version locally ahead of time …
  • … or you have to increment LATEST_PROTOCOL_VERSION yourself.

A few known good examples:

  • Server::SendAddParticleSpawner (write) and Client::handleCommand_AddParticleSpawner (read)
  • Server::SendItemDef (write) and Client::handleCommand_ItemDef (read)

Formspec#

Adding elements#

Older clients silently ignore unknown elements.

In addition to checking the sanity of the formspec element (parts.size()) it is desired to allow extending the element down the road. For this purpose, so use GUIFormSpecMenu::precheckElement as demonstrated below.

void GUIFormSpecMenu::parseBackgroundColor(parserData *data, const std::string &element)
{
	std::vector<std::string> parts;
	if (!precheckElement("bgcolor", element, 1, 3, parts))
		return;
	// ....
}

Appending parameters#

This section assumes that GUIFormSpecMenu::precheckElement is being used.

Older clients will silently ignore appended parameters if the version specified by formspec_version[N] is newer than what the client supports. For example, a hypothetical element called mylabel can be extended by using a more recent formspec version.

Mod code before:

local formspec =
	"formspec_version[15]"
	"size[10,9]" ..
	"mylabel[2,5;1,1;Hello World]"

Mod code after:

local formspec =
	"formspec_version[16]"
	"size[10,9]" ..
	"mylabel[2,5;1,1;Hello World;#FF0000]"

Meanwhile, the C++ code needs the following adjustment:

// Previous code line:
if (!precheckElement("mylabel", element, 3, 3, parts))
	return;
// Adjusted line to allow one additional argument on new clients:
if (!precheckElement("mylabel", element, 3, 4, parts))
	return;

As well as incrementing and documenting the new FORMSPEC_API_VERSION.

Changing parameters#

Example: A parameter shall not only accept boolean values but numeric too.

This is only possible in rare cases. Compatibility with older engine versions should be tested thoroughly.