Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/ProwlEngine/Prowl.Echo/llms.txt

Use this file to discover all available pages before exploring further.

Every value that passes through Prowl.Echo — whether it starts as a plain C# object or ends up in a binary file or JSON string — is first converted into an EchoObject. This intermediate representation decouples your data model from any specific output format, letting you inspect, transform, query, and diff serialized data entirely in memory before ever touching a byte stream.

The EchoType Enum

Every EchoObject carries a TagType property of type EchoType that identifies what kind of data it holds.

Primitive Types

Byte, sByte, Short, Int, Long, UShort, UInt, ULong, Float, Double, Decimal, String, ByteArray, Bool

Structural Types

Null — an explicit empty nodeList — an ordered sequence of EchoObject childrenCompound — a named dictionary of EchoObject children
IsPrimitive returns true for every type except Null, List, and Compound. Count returns the number of children for List and Compound nodes, and 0 for everything else.

Creating EchoObjects

Primitive constructors

Each .NET primitive type has a matching constructor overload:
var byteTag    = new EchoObject((byte)255);
var sbyteTag   = new EchoObject((sbyte)-1);
var shortTag   = new EchoObject((short)1000);
var intTag     = new EchoObject(42);
var longTag    = new EchoObject(9_000_000_000L);
var ushortTag  = new EchoObject((ushort)65535);
var uintTag    = new EchoObject(4_000_000_000u);
var ulongTag   = new EchoObject(ulong.MaxValue);
var floatTag   = new EchoObject(3.14f);
var doubleTag  = new EchoObject(3.14159265358979);
var decimalTag = new EchoObject(1.23456789m);
var stringTag  = new EchoObject("hello");
var boolTag    = new EchoObject(true);
var bytesTag   = new EchoObject(new byte[] { 0x00, 0xFF });

Structural factory methods

// Compound: a named dictionary of child EchoObjects
EchoObject compound = EchoObject.NewCompound();

// List: an ordered sequence of child EchoObjects
EchoObject list = EchoObject.NewList();
An EchoObject with no constructor argument creates an empty node with TagType == EchoType.Null. Use EchoObject.NewCompound() and EchoObject.NewList() for the structural types instead of the raw constructor, as these populate the internal collections correctly.

Compound Operations

A Compound node behaves like a Dictionary<string, EchoObject>. Its underlying dictionary is exposed via the Tags property, and every named child tracks its own CompoundKey and Parent.
EchoObject player = EchoObject.NewCompound();

// Add named children
player.Add("name",   new EchoObject("Alice"));
player.Add("health", new EchoObject(100));
player.Add("level",  new EchoObject(7));

// Read by key — returns null if not found
EchoObject? nameTag = player.Get("name");

// Safe try-get
if (player.TryGet("health", out EchoObject? hp))
    Console.WriteLine(hp!.IntValue); // 100

// Indexer (throws on missing key if reading, sets on assignment)
player["level"] = new EchoObject(8);

// Check existence
bool hasScore = player.Contains("score"); // false

// Remove a key
player.Remove("level");

// Rename a key in-place
player.Rename("name", "displayName");

// Iterate all names or all values
foreach (string key in player.GetNames()) { }
foreach (EchoObject tag in player.GetAllTags()) { }
Adding an EchoObject that already has a Parent throws an ArgumentException. Clone the node first with tag.Clone() if you need to place the same data in multiple locations.

List Operations

A List node holds an ordered List<EchoObject>. Every child tracks its own ListIndex and Parent.
EchoObject scores = EchoObject.NewList();

// Append
scores.ListAdd(new EchoObject(500));
scores.ListAdd(new EchoObject(750));

// Insert at a specific position (re-indexes subsequent items)
scores.ListInsert(0, new EchoObject(1000));

// Integer indexer (delegates to Get(int))
EchoObject first = scores[0];        // 1000
EchoObject same  = scores.Get(0);    // equivalent to scores[0]

// Remove by reference or by index
scores.ListRemove(first);
scores.ListRemoveAt(0);

// Clear all children
scores.ListClear();

Value Accessors

Every primitive node exposes typed accessor properties. Numeric accessors use Convert under the hood, so cross-type reads are safe:
EchoObject tag = new EchoObject(42);

int    i = tag.IntValue;
float  f = tag.FloatValue;   // 42.0f via Convert
string s = tag.StringValue;  // "42" (InvariantCulture)

EchoObject flag = new EchoObject(true);
bool b = flag.BoolValue;

EchoObject data = new EchoObject(new byte[] { 1, 2, 3 });
byte[] raw = data.ByteArrayValue;
// StringValue on a ByteArray returns a Base64 string
string b64 = data.StringValue;
The full set of typed accessors is: BoolValue, ByteValue, sByteValue, ShortValue, IntValue, LongValue, UShortValue, UIntValue, ULongValue, FloatValue, DoubleValue, DecimalValue, ByteArrayValue, and StringValue.
StringValue works on every primitive type — it returns the invariant-culture string representation. For Null it returns "NULL", and for ByteArray it returns a Base64 string. It will throw InvalidCastException if called on a Compound or List node.

Parent Tracking

Every EchoObject knows where it lives in the graph:
EchoObject root = EchoObject.NewCompound();
root.Add("score", new EchoObject(99));

EchoObject scoreTag = root["score"];
Console.WriteLine(scoreTag.Parent == root);        // True
Console.WriteLine(scoreTag.CompoundKey);           // "score"
Console.WriteLine(scoreTag.ListIndex);             // null (not in a list)

EchoObject list = EchoObject.NewList();
list.ListAdd(new EchoObject("first"));
Console.WriteLine(list[0].ListIndex);              // 0
Console.WriteLine(list[0].CompoundKey);            // null (not in a compound)
You can also compute a node’s full slash-separated path from the root, or the relative path between two nodes in the same tree:
string path = scoreTag.GetPath(); // "score"

// Relative path from a subtree root to a descendant
EchoObject subtree = EchoObject.NewCompound();
EchoObject child   = new EchoObject(42);
subtree.Add("value", child);

string rel = EchoObject.GetRelativePath(subtree, child); // "value"

Path-Based Queries

Navigate deep into a graph without looping manually using slash-separated paths. List nodes are indexed by their integer position.
EchoObject root = EchoObject.NewCompound();
EchoObject stats = EchoObject.NewCompound();
stats.Add("stamina", new EchoObject(80));
root.Add("stats", stats);

EchoObject players = EchoObject.NewList();
players.ListAdd(root);
EchoObject doc = EchoObject.NewCompound();
doc.Add("Players", players);

// Find by path — returns null if any segment is missing
EchoObject? stamina = doc.Find("Players/0/stats/stamina");

// Try-find variant
if (doc.TryFind("Players/0/stats/stamina", out EchoObject? tag))
    Console.WriteLine(tag!.IntValue); // 80

// Typed value shorthand — returns defaultValue on miss
int hp = doc.GetValue<int>("Players/0/stats/stamina", defaultValue: 0);

// Retrieve an EchoObject, List, or Dictionary at a path
EchoObject?                    node  = doc.GetEchoAt("Players/0/stats");
List<EchoObject>?              items = doc.GetListAt("Players");
Dictionary<string, EchoObject>? dict = doc.GetDictionaryAt("Players/0/stats");

// Boolean existence check
bool exists = doc.Exists("Players/0/stats/mana"); // false

LINQ-Style Traversal

Query children and recursively search the entire graph with familiar functional patterns:
EchoObject list = EchoObject.NewList();
list.ListAdd(new EchoObject(10));
list.ListAdd(new EchoObject(20));
list.ListAdd(new EchoObject(30));

// Filter direct children (works on both List and Compound)
IEnumerable<EchoObject> big = list.Where(t => t.IntValue > 15);

// Project direct children
IEnumerable<int> values = list.Select(t => t.IntValue);

// Recursive deep search across the whole graph
EchoObject graph = EchoObject.NewCompound();
graph.Add("a", new EchoObject(1));
graph.Add("b", list);

IEnumerable<EchoObject> allInts = graph.FindAll(t => t.TagType == EchoType.Int);

// Find all slash-separated paths that satisfy a predicate
IEnumerable<string> paths = graph.GetPathsTo(t => t.TagType == EchoType.Int);
// e.g. ["a", "b/0", "b/1", "b/2"]

Delta Operations

Echo can compute the minimal difference between two EchoObject graphs and apply it to a baseline — useful for network state synchronisation or undo/redo stacks.
// Snapshot before
EchoObject before = EchoObject.NewCompound();
before.Add("score", new EchoObject(100));
before.Add("lives", new EchoObject(3));

// Snapshot after
EchoObject after = before.Clone();
after["score"] = new EchoObject(200); // score increased
after.Add("combo", new EchoObject(5)); // new field

// Compute the diff — itself a serializable EchoObject
EchoObject delta = EchoObject.CreateDelta(before, after);

// Reconstruct 'after' from 'before' + delta
EchoObject reconstructed = EchoObject.ApplyDelta(before, delta);
The delta is itself a valid EchoObject and can be serialized to binary or text just like any other graph node.

Cloning and Equality

EchoObject original = EchoObject.NewCompound();
original.Add("x", new EchoObject(1));

// Deep clone — the new tree has no Parent references into the original
EchoObject copy = original.Clone();

// Structural equality — compares type, keys, and values recursively
bool same = original == copy;      // true
bool diff = original != copy;      // false

original["x"] = new EchoObject(2);
bool changed = original.Equals(copy); // false
Equality for Compound compares all key-value pairs regardless of insertion order. Equality for List compares elements in order. ByteArray uses SequenceEqual.

Security: GetStoredType()

When an EchoObject was serialized with type information (a $type field in the compound), you can inspect the embedded type before deserializing — important in networked applications where an attacker might send a payload containing an unexpected type.
// Server receives an EchoObject from a client
EchoObject received = /* ... network read ... */;

Type? storedType = received.GetStoredType();

if (storedType == null || !typeof(IPacket).IsAssignableFrom(storedType))
{
    // Reject — the payload does not claim to be an IPacket
    return;
}

IPacket packet = Serializer.Deserialize<IPacket>(received)!;
Always call GetStoredType() and validate against an allowlist before deserializing untrusted EchoObject data. Deserializing an attacker-controlled type without validation can trigger arbitrary constructors and cause resource exhaustion or memory corruption.

Build docs developers (and LLMs) love