XML Network Message

From Armagetron
Revision as of 10:54, 1 March 2009 by Z-man (talk | contribs) (Control Messages described)

Sections: Installing the Game | Playing the Game | Competition Hub | Server Administration | Extending Armagetron Advanced | Development Docs


This wiki page has an associated Blueprint on launchpad.

XML-Like Network Messages using google::protobuf

Code and documentation of google protocol buffers can be found here. The basic workings are thus: you write a protocol specification .proto file where message types are defined. Each message consists of data elements that have a type, a name and a numeric ID. In your program, you access the elements of the message via regular functions named after the element name. Over the network, the numeric ID is used to keep elements apart. It is easily possible to add new elements to an existing message without breaking compatibility with older server/clients, they'll just ignore the unknown data.

This document focusses on the use of protobuf in arma. It is of interest to coders only.

Code Structure

Our .proto specification files reside in src/protobuf. There is a prototype file prototype.proto; you can use it as a template for new files. The .proto files are compiled to .pb.h and .pb.cc files by a batch file/Makefile. Typically, no header file should ever include a .pb.h file (exceptions would be templates that absolutely need to); instead, forward declare the used protobuf classes and include the .pb.h file from your .cpp file.

Minimal Example

The basic feature of protobuf messages is transmitting protobuf structures from client to server or vice versa. To use that, you need four things:

  • a message definition in a .proto file
  • a message handler responsible for receiving the message
  • a message descriptor gluing stuff together
  • code sending the message over the descriptor

First, a message definition in the file TestMessage.proto (see the google docs for details on what is allowed):

message TestMessage
{
   optional int32 test_data = 1;
}

Note that the message name is CamelCase and the_element_name_has_underscores. That's google's convention; it makes sure the generated code uses sensible conventions itself. Then, you need the handler function in your .cpp file:

#include "nProtoBuf.h"
#include "TestMessage.pb.h"
static void sx_TestMessageHandler( TestMessage const & message, nSenderInfo const & sender )
{
   // print data
   std::cout << mesage.test_data() << std::endl;
}

The first parameter is a const reference to the generated protobuf data type. The second parameter contains information about where the message came from; it wraps the message it came in and has member functions such as SenderID() giving the network ID of the sender.

The descriptor is just a static templated object:

static nProtoBufDescriptor< TestMessage > sx_testMessageDescriptor( 999, sx_TestMessageHandler );

The first parameter of the descriptor constructor is the message ID. No two IDs must ever be the same; if you pick a used one by accident, don't worry, the constructor will tell you so with a nice fatal error. Note the naming of handler and descriptor: s<prefix char of module>_<name of message>Handler/Descriptor, with the first character of the descriptor lowercased to indicate it's no function. Let's try to stick to that :)

For the sending code, there are several possibilities. The easiest is to use those methods of the descriptor that prepare a protobuf message for sending and return the protobuf to you to work with directly to fill. The message itself is not sent until the next network flush, which usually happens once per frame or if you trigger it manually. To just send a message to the server, use

TestMessage & content = sx_testMessageDescriptor.Broadcast();
content.set_test_data( 42 );

In client mode, Broadcast() sends the message to the server; in server mode, all connected clients receive it. There's two optional parameters: a boolean, telling whether the message should be protected against loss (true by default), and a nVersionFeature const & (must come first), restricting the message receivers to those supporting the given feature. To send a message to a specific peer, use the Send( int receiver ) function.

Sometimes, that direct sending is not quite what you want; sometimes you want to manipulate the message itself before it is sent. In those cases, use the more complicated procedure

nProtoBufMessage< TestMessage > * message = sx_testMessageDescriptor.CreateMessage();
TestMessage & content = message.AccessProtoBuf();
message.set_test_data( 42 );
// do whatever you need to do with *message.

Non-standard Stuff

There are some things about our protobuf message definitions that extend the standard.

Strings

Usually, when you define a string element of a message, it will get filtered by the receiver. Things filtered include

  • color codes
  • incomplete color codes
  • illegal whitespace
  • illegal characters

depending on the configuration and current state. Sometimes, that is not desired. To turn off filtering, give your string element a name ending in _raw:

Message StringMessage
{
  optional string this_gets_filtered = 1;
  optional string does_not_get_filtered_raw = 2;
}

This is required for strings that don't represent strings in the game to be eventually rendered by clients, but rather internal strings like protocol IDs.

Legacy Stream Messages

Our old network system used simple unstructured stream messages. Compatibility with that is automatically ensured; protobuf messages are converted to stream messages by simply taking all elements appearing in the message definition before certain marker elements. Marker elements are those with an ID on the far side of the range reserved for internal protobuf use (>=20000).

In this message:

message PlayerRemoved
{
  // the object ID of the player
  optional uint32 player_id = 1;

  // legacy message end marker, extensions go after it
  optional bool legacy_message_end_marker = 20000;

  optional string reason = 2;
}

only player_id will get written to and read from old stream messages, the reason will be omitted.

Some messages even have two markers; those are messages for object syncs, everything before the first marker is written in the creation section of the stream message, everything between the markers is written in the sync section.

The short version? If you see messages with legacy_end_markers, don't change the part before the last end marker, neither remove nor add elements. Only add new elements after the last end marker.

Network Objects

Network Objects are C++ objects that can sync themselves over the network. To write a new network object class, you need:

  • a class, subclass of nNetObject, implementing some functions
  • a sync message type, containing all the data objects send when they sync
  • a nNetObjectDescriptor template object registering the two to the system
  • optionally, a control message type to send commands from client to server

In detail. The sync message type has to look like this for a class xYourClass derived directly from nNetObject:

import "nNetObject.proto";
message YourClassSync
{
  // base class sync data (use the sync message of the direct base class of your class here)
  // this needs to be called 'base', or stuff breaks.
  optional Network.NetObjectSync base = 1;

  // your additional data, like
  optional float mass = 2;
}

You should define a message even if your class is just an abstract base class for other classes and syncs no data itself.

Your class has to implement a constructor and a ReadSync function taking a sync message as input argument, and a WriteSync function taking the sync message as non-const reference argument to fill. Those methods need to be public. For concrete classes, you also need to implement a method returning the class' nNetObjectDescriptor. And optionally, you can implement a method that checks whether a sync message is new; only if it is found to be, it will be passed to ReadSync.

class xYourClass: public nNetObject
{
public:
  // remote constructor
  xYourClass( YourClassSync const & sync, nSenderInfo const & sender )
  : nNetObject( sync.base(), sender )
  {
     // maybe read and store sync.mass() here
     // the extra 'sender' parameter contains the same information passed to general message handlers.
  }

  // reads a sync message
  void ReadSync( YourClassSync const & sync, nSenderInfo const & sender )
  {
    // delegate to base
    nNetObject::ReadSync( sync.base(), sender );

     // also eventually handle and store sync.mass() here. Generally, fill your object's data elements
     // here with data from the sync message.
  }

  // writes a sync message
  void WriteSync( YourClassSync & sync, bool init ) const
  {
    // delegate to base
    nNetObject::ReadSync( *sync.mutable_base(), sender );

    // the extra parameter 'init' tells you whether the message is an initialization message or not.
    // if it is, it will be fed into the remote constructor on the receiving side. if it isn't, it won't,
    // and you don't have to write any data elements to sync that are only read by the remote
    // constructor. Anyway, here you should fill the sync message with data from your object.
    if ( (!only_constructor_reads_sync.mass()) || init )
    {
      sync.set_mass( whatever_it_should_be );
    }
  }
 
  // fetch descriptor, implemented later
  virtual nNetObjectDescriptorBase const & DoGetDescriptor() const;
  
  // checks whether a sync is new
  bool SyncIsNew( YourClassSync const & sync, nSenderInfo const & sender )
  {
     // check whether the passed message is new; if it is, return true. If it isn't, return false,
     // and ReadSync will not be called (not even that of derived classes).
     return nNetObject::SyncIsNew( sync.base(), sender );
  }
  // another optional function: if this returns true, clients can sync objects of this class
  // to the server. Otherwise, only the server is allowed to create and sync objects of this class.
  vritual bool AcceptClientSync() const;
}

To decouple your class definition from the message definition, you should forward declare your protobuf message in the header and only include the full definition in your implementation.

Then you need the descriptor. Pick an unused ID (say, 999) for your class and write into your .cpp file:

static nNetObjectDescriptor< xYourClass, YourClassSync > sx_yourClassDescriptor( 999 );

//! returns the descriptor responsible for this class
nNetObjectDescriptorBase const & xYourClass::DoGetDescriptor() const
{
  return sx_yourClassDescriptor;
}

And that's it. Whenever you call the RequestSync functions inherited from nNetObject, a bit later someone will call your WriteSync() method to fill a message. That message is then transmitted to the selected peers, where it will first be put to the IsSyncNew test and then fed into the ReadSync method. Except when it's the first sync. The receive procedure of the first sync for a previously unknown object is this:

  • call the remote constructor
  • call the virtual InitAfterCreation() method to fill default values into stuff you don't want to replicate across all constructors
  • call ReadSync()
  • if nobody threw an exception, assign the correct network object ID to the new object and register it.

Syncing Pointers

Sadly, they can no longer be put directly into messages. Use the static member function families IDToPointer() and PointerToID() to convert them to integers and transmit those. Use regular uint32 as protobuf type.

There is one caveat, however: if you send a pointer to an object your peer doesn't know about yet, it may get turned into a NULL pointer when it's received, or the connection may be terminated. To avoid that, use the HasBeenTransmitted member function on the pointer target to determine whether the receiver already knows about it; if not, delay sending the message until it does. If you're writing pointers in network object syncs, do the checks in the virtual ClearToTransmit() function; as long as that returns false, the object won't be synced.

(A better way for this would be if the message itself would carry information about contained pointers and automatically delay itself while it can't be correctly received. Maybe later.)

Control Messages

Control messages are a way for the client to send change requests to the server in case direct syncs are not allowed. Players use it to request team changes, and game objects can use it for input events. To define a new control message type, EXTEND Network::NetObjectControl defined in nNetObject.proto with your data, using the provided extension slots (don't change nNetObject.proto for that). The control message sender uses BroadcastControl() to create a control message. The return value is a reference to the contained Network::NetObjectControl that you can fill. On the other side, the member function ReceiveControlNet() of the remote copy of the object will be called with a copy of the data you filled in. The system is mainly there for legacy reasons; you may just as well define a new message type with a pointer to your object in it and do the object lookup yourself in the receiving function.

Changing Messages

Over time, needs change, and network messages need to evolve. There are three main ways to change them. Before we begin, It's important to note that the segments streamed for legacy messages must not be changed in any way. You can't add fields, and you can't change field types in those sections. The only thing you can do is declaring old fields obsolete.

Change existing fields

While in general a bad idea, some modifications are legal. See the google docs for details. In addition, it should be safe to turn repeated fields into optional ones and vice versa; the code reading a repeated field and expecting an optional field will see the last element (or none if the array is empty), and the code reading an optional field while expecting a repeated filed should see an array containing either 0 or 1 data elements.

Obsoleting existing fields

Is always possible. Rename the field to <old field name>_obsolete for that, but leave it in place. Adapt the existing code reading the field to the new name, but prepare it for the case that the field isn't set any more (by checking has_<old field name>_obsolete()) and for the case that it is still set; if it is still set, have it processed as before.

Then, you should bump the network version and add a nVersionFeature named xx_<old_field_name>_obsoleted for the new feature. On writing a message with the obsoleted field, check whether all receivers of the message support that feature. If they do, omit the field. If one doesn't, then send the field anyway, the old receivers will still depend on it. If your message goes out to several peers, don't bother looping over them to send each an optimal message; just write one larger message all receivers will understand. The saved bandwidth is not worth the added code complexity.

Adding new fields

Just add them :) Be sure to pick an adequate name and numeric ID (<16 for things you expect to be transmitted frequently, >= 16 for other stuff and fields you expect to be obsoleted in the near future). Your reading code should handle the case where the new field isn't sent.

Well, and sometimes, even with all those possibilities, you reach a point where you can no longer reuse an old message type. You need to start fresh. Then, things can get a bit messy :)

Replacing whole messages

Well, not as messy as you may think. First, you simply keep the code and structures handling the old message type for reading. In case of network object sync messages, you may have to transplant them into a new class. That's not a problem; as long as you keep the ID of the nNetObjectDescriptor and the message type, you can switch the actual implementation class around as you wish without breaking compatibility.

Then, you implement your new message as if the old message type never existed. At this point, your code will be compatible with receiving old and new message types, but will never send old message types and therefore confuse old peers. That's where you have to create a translator. Say your old message type is OldMessage, the new message type is NewMessage, the old descriptor is named sx_oldMessageDescriptor, the new descriptor sx_newMessageDescriptor, and you have a nVersionFeature describing support for your new messages named sx_newMessageFeature. You then write

class xMessageTranslator: public nMessageTranslator< NewMessage > 
{
public:
    //! constructor registering with the descriptor
    xMessageTranslator(): nMessageTranslator< NewMessage >( sx_newMessageDescriptor )
    {
    }
   
   //! convert current message format to format suitable for old client
   virtual nMessageBase * Translate( NewMessage const & source, int receiver ) const
   {
       if( sx_newMessageFeature.Supported( receiver ) )
       {
           // no translation required
           return NULL;
       }

       OldMessage dest;

       // convert message from source to dest

       // pack result into message
       nProtoBufMessageBase * ret = sx_oldMessageDescriptor::TransformStatic( dest );

       // this bit is only required for netobject syncs while we're still compatible with stream message clients
       if( /* source was a sync message, not a creation message */ )
       {
           // make it a sync message
           ret->SetStreamer( nNetObjectDescriptorBase::SyncStreamer() );
       }
        
       return ret;
   }
};

static xMessageTranslator sx_messageTranslator;

A fine example of this can be found in zShape.cpp.

Protocol Specification