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.
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.
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 });
// Compound: a named dictionary of child EchoObjectsEchoObject compound = EchoObject.NewCompound();// List: an ordered sequence of child EchoObjectsEchoObject 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.
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 childrenplayer.Add("name", new EchoObject("Alice"));player.Add("health", new EchoObject(100));player.Add("level", new EchoObject(7));// Read by key — returns null if not foundEchoObject? nameTag = player.Get("name");// Safe try-getif (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 existencebool hasScore = player.Contains("score"); // false// Remove a keyplayer.Remove("level");// Rename a key in-placeplayer.Rename("name", "displayName");// Iterate all names or all valuesforeach (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.
A List node holds an ordered List<EchoObject>. Every child tracks its own ListIndex and Parent.
EchoObject scores = EchoObject.NewList();// Appendscores.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]; // 1000EchoObject same = scores.Get(0); // equivalent to scores[0]// Remove by reference or by indexscores.ListRemove(first);scores.ListRemoveAt(0);// Clear all childrenscores.ListClear();
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 Convertstring 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 stringstring 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.
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 childrenIEnumerable<int> values = list.Select(t => t.IntValue);// Recursive deep search across the whole graphEchoObject 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 predicateIEnumerable<string> paths = graph.GetPathsTo(t => t.TagType == EchoType.Int);// e.g. ["a", "b/0", "b/1", "b/2"]
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 beforeEchoObject before = EchoObject.NewCompound();before.Add("score", new EchoObject(100));before.Add("lives", new EchoObject(3));// Snapshot afterEchoObject after = before.Clone();after["score"] = new EchoObject(200); // score increasedafter.Add("combo", new EchoObject(5)); // new field// Compute the diff — itself a serializable EchoObjectEchoObject delta = EchoObject.CreateDelta(before, after);// Reconstruct 'after' from 'before' + deltaEchoObject 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.
EchoObject original = EchoObject.NewCompound();original.Add("x", new EchoObject(1));// Deep clone — the new tree has no Parent references into the originalEchoObject copy = original.Clone();// Structural equality — compares type, keys, and values recursivelybool same = original == copy; // truebool diff = original != copy; // falseoriginal["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.
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 clientEchoObject 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.