Blackboard Serialization

If you want to be able to properly view the data on the Blackboards in the Grail’s debug tools, you should write the code responsible for the serialization of this data. Because C++ and C# are very different languages in nature, the method of serialization must differ.

C#

It is sufficient to ensure that everything you’d like to put on the Blackboard has a valid ToString method. For example:

public class GameCharacter
{
  public string Name {get;}
  public Vector3 Position {get; private set;}
  public bool IsInCombat {get; private set;}

  // body of the class
  // ...

  public override string ToString()
  {
    return $"Character {Name} at {Position.ToString()} {(IsInCombat ? "IN COMBAT" : "")}";
  }
}

C++

C++ types do not automatically derive from the base object class like C# types, so the serialization logic is a bit more complicated.

Grail comes with default serialization of
  • STL containers

  • raw pointers

  • smart pointers

  • pairs

  • tuples

  • every type which can be used with std::to_string.

There are two ways to serialize custom C++ data.

Serialization of a singular type

If you want to serialize just a singular type you have to specialize a template function SerializeData for that type. For example

class GameCharacter
{
public:
  // body of the class
  // ...

  std::string GetName() const { return name; }
  Vector3 GetPosition() const { return position; }
  bool GetIsInCombat() const { return isInCombat; }

private:
  std::string name;
  Vector3 position;
  bool isInCombat;
};

namespace grail
{
  template<>
  inline std::string SerializeData<GameCharacter>(const GameCharacter& character)
  {
    return character.GetName() + " " + character.GetPosition().CustomVectorSerialization() + (character.GetIsInCombat() ? " IN COMBAT" : "");
  }
}
SerializeData template specialization has to be put inside the "grail" namespace and preceded by the inline keyword.

Serialization of type groups

Sometimes it may be useful to detect types which have a specific serialize function instead of serializing them one by one. For instance, this may be beneficial when using a library or game engine with uniform serialization logic. Grail exposes functions which can be implemented using SFINAE to achieve that. One function - SerializeLibraryType is intended to be used with library/engine types and the other - SerializeUserType with end-user-defined types.

If you’re using a Grail plugin for an engine which uses C++ (i.e. Unreal Engine) we already implement SerializeLibraryType to provide you with serialization logic for common engine types.

Let’s say that you have 2 classes as implemented below and we want to be able to serialize them when they’re put on the Blackboard.

class SFINAESerializableClass1
{
public:
  inline static std::string expectedSerializedString = "It's me, class 1!";

  std::string CustomSerializationMethod() const
  {
    return expectedSerializedString;
  }
};

class SFINAESerializableClass2
{
public:
  inline static std::string expectedSerializedString = "It's me, class 2!";

  std::string CustomSerializationMethod() const
  {
    return expectedSerializedString;
  }
};

Both of the classes have a CustomSerializationMethod method implemented. You now want to detect types implementing this method.

  template<typename T, typename = void>
  struct HasCustomSerializationMethod : std::false_type {};

  template<typename T>
  struct HasCustomSerializationMethod<T, std::void_t<decltype(std::declval<T>().CustomSerializationMethod())>> : std::true_type {};

  // Similarly,if you'd have to check if some non-member function could be applied to this type, you'd have to write something like this

  template<typename T, typename = void>
  struct HasNonMemberSerialization : std::false_type {};

  template<typename T>
  struct HasNonMemberSerialization<T, std::void_t<decltype(my_namespace::NonMemberSerialization(std::declval<T>()))>> : std::true_type {};

Once done, you can implement the SerializeLibraryType or SerializeUserType function.

  // this lets Grail know that it is allowed to make use of the _SerializeLibraryType_ function
  template<typename T, typename = void>
  struct IsLibraryType : std::true_type {};

  template<typename T, std::enable_if_t<IsLibraryType<T>::value, bool> = true>
  std::string SerializeLibraryType(const T& data)
  {
    // Check if the considered type implements a CustomSerializationMethod method.
    if constexpr(HasCustomSerializationMethod<T>::value)
    {
      return data.CustomSerializationMethod();
    }
    else if constexpr(/*different condition*/)
    {
      // different serialization logic
    }
    return "";
  }

  // this lets Grail know that it is allowed to make use of the _SerializeUserType_ function
  template<typename T, typename = void>
  struct IsUserType : std::true_type {};

  template<typename T, std::enable_if_t<IsUserType<T>::value, bool> = true>
  std::string SerializeUserType(const T& data)
  {
    // Check if the considered type can be passed to _my_namespace::NonMemberSerialization_ function
    if constexpr(HasNonMemberSerialization<T>::value)
    {
      return my_namespace::NonMemberSerialization(data);
    }
    return "This type has no available serialization";
  }

Now, both SFINAESerializableClass1 and SFINAESerializableClass2 will be properly serialized when placed on the Blackboard. Same goes with any type that could be passed to my_namespace::NonMemberSerialization function.

Make sure that you DO NOT put SerializeLibraryType and SerializeUserType inside ANY namespace. Otherwise some compilers may not detect those functions properly.
If you want to use both SerializeLibraryType and SerializeUserType in your project, make sure to make SerializeLibraryType return empty string by default. SerializeLibraryType is called before the SerializeUserType and if the former function returns something different than empty string, the latter one will not be called.