limboai
limboai copied to clipboard
Allow serialising the execution state and blackboard of the behavior tree (and HSMs)
This should include all blackboard variables and the current running indexes of any composite nodes, as well as the current running state of any HSMs. This would allow developers to dump the state of the entire behavior tree to a save file, which is useful for allowing players to save and load at any time (including mid-behavior tree execution, so an AI agent can remember what it was doing).
Some thoughts about this proposal:
- Blackboard can already set/get a dictionary as a data source, which can be used for simple scenarios.
- Blackboard architecture involves a scope chain, and parent scopes are not aware of other scopes down the tree.
- Currently,
BTNewScope
andBTSubtree
are creating new scopes, and each instance ofLimboState
creates a scope which has a non-emptyblackboard_data
property set. - Full serialization requires finding/caching each blackboard scope in the state-machine-behavior-tree structure.
- Q: What to do with object references on the blackboard (storing
target
is a common pattern)? What about the non-Node variety, like extendedObject
classes andRef<Resource>
?
- Can't use
ResourceSaver
for behavior tree serialization:- Q: Is loading a resource from an external file still vulnerable to embedded code execution?
- Can be a JSON serializer.
- The serializer should collect each property with the
PROPERTY_USAGE_STORAGE
flag set. This way, the user has full control over which property should be serialized.- This feature would be useful: https://github.com/godotengine/godot-proposals/issues/7794
- Should avoid per-task specialized
serialize()
,deserialize()
, because it's a lot of unnecessary work. - Deserializing HSM: The state machine should set proper current state at each level respectively.
Example of a blackboard chain:
LimboHSM -- new blackboard scope
-- LimboState -- inherits scope (but can also define new blackboard)
-- BTState -- new blackboard scope
---- Sequence -- inherits scope
------ Action -- inherits scope
------ BTSubtree -- new blackboard scope
-------- SubtreeRootTask -- inherits scope
In the example, SubtreeRootTask
has the following scope chain: BTSubtree's Blackboard -> BTState's Blackboard -> LimboHSM's Blackboard
.
In my own behavior tree implementation (which isn't very good, and LimboAI is looking much better), I've just been serialising node references to node paths and back. It... works for what I need, but may not necessarily make sense for a generic serialiser.
Godot's built-in serialisers (JSON.parse_string and var_to_bytes) will just use a basic string representation for types it doesn't support, which doesn't correctly serialise and deserialise back (for example, bizarrely, the JSON serialiser does not support Vector types, but you can use var_to_string and string_to_var to get around this). I wouldn't say full serialisation is necessary -- though warnings when a type can't be serialised would help developers design the way they use LimboAI around their serialisation requirements (and would already be a step up from the zero feedback Godot's serialisers give you).
This issue was mentioned in a discussion in the discord channel.
One key outcome of the discussion was about serializing object references. @limbonaut suggested using a strategy pattern with a Serializer
class, taking an ObjectSerializer
instance, which by default would ignore object references, and not deserialize any objects.
API draft:
// de/serialize tasks and their attributes - can be subclassed to add support for user-defined types
class ObjectSerializer : public RefCounted {
// serialize fields
Variant serialize_object_reference(Ref<Object> object, ObjectID obj_id);
Ref<Object> deserialize_object_reference(data: Variant, ObjectID obj_id);
// serialize tasks
Variant serialize_task(Ref<BTTask> task);
Ref<BTTask> deserialize_task(Variant task_data);
};
// walk the tree and aggregates the ObjectSerializer's outputs
class Serializer : public RefCounted {
// serialize (json) a task and its children
String serialize(Ref<BTTask> task, Ref<ObjectSerializer> object_serializer)
Ref<BTtask> deserialize(String tree_payload)
};