Auto/Reflective Hashing
Each engine binding’s reflective walker sits on top of the explicit hashing API (never a replacement for it) — tag a field, and the walker finds it via the engine’s own reflection system instead of you writing a manual hash_entity/hash_field call. The three walkers aren’t identical: the differences below are deliberate design decisions shaped by what each engine’s reflection model actually allows, not accidents or gaps.
Using it
Bevy
#![allow(unused)]
fn main() {
use foldback_rs::bevy::hash_reflected;
// `Unit` derives both `Reflect` and `FoldbackHash`. `#[foldback(hash)]`
// marks a primitive field (hashed via `FieldBytes`, same as the manual
// API); `#[foldback(reflect)]` marks a compound field (a nested struct,
// a `Vec`, a `HashMap`) for the walker to recurse into instead — it
// doesn't need `FieldBytes`, since the walker gets bytes via reflection.
#[derive(Reflect, FoldbackHash)]
struct Unit {
#[foldback(reflect)]
pos: Position,
#[foldback(hash)]
hp: i32,
// unmarked: invisible to both the manual API and hash_reflected.
debug_label: String,
}
let preview: Vec<(String, u64)> = hash_reflected(&mut session, tick, entity_id, "unit", &unit)?;
}
Walks Unit::TRACKED_FIELDS (pos, hp — not debug_label) and everything reachable beneath them, recording each leaf as a FieldHash frame, field-path-named ("unit.pos.x", "unit.tags[1]"). Returns the same (path, hash) pairs it recorded — feed that to foldback_rs::bevy::debug::ReflectionPreview for a live view (below).
Unity
using Foldback;
class Unit
{
[FoldbackHash] public Position Pos; // compound — walked fully once reached
[FoldbackHash] public int Hp; // primitive — hashed directly
public string DebugLabel; // unmarked — invisible to the walker
}
var preview = FoldbackReflection.HashReflected(session, tick, entityId, "unit", unit);
One attribute, [FoldbackHash], covers both the primitive and compound cases (no hash/reflect split like the Rust derive needs — C# reflection doesn’t require a FieldBytes-equivalent bound to walk further). Same field-path naming and return-value shape as the Bevy walker.
Godot
class Unit extends RefCounted:
var foldback_pos: Position # compound — walked fully once reached,
# but its own fields need the prefix too
var foldback_hp: int = 0 # primitive — hashed directly
var debug_label: String = "" # unmarked — invisible to the walker
var preview: Array = session.hash_reflected(tick, entity_id, "unit", unit)
No attribute system in GDScript, so the marker is a foldback_ name prefix instead — see below for why that’s the only mechanism GDExtension can actually build, not a fallback. preview is an Array of {"path": String, "hash": int} dicts, same shape in spirit as the other two walkers’ return value.
What it’s for
Get Level 2/3 (per-entity, per-field) bisection without hand-writing hash_entity/hash_field calls — the binding walks the engine’s own reflection system (bevy_reflect, C# System.Reflection, Godot’s get_property_list()) and hashes what it finds. This trades integration cost for runtime cost and a real correctness hazard class (below), so it’s layered on top of the explicit API, never a replacement for it.
The one rule that survives reflection — enforced, not just documented, but not identical per engine
Opt-in, same as the manual derive macro — reflection is never “walk every field reachable from the root,” it’s “walk fields the game has tagged as hashable” via the engine’s own attribute system ([FoldbackHash] in C#, a foldback_ name prefix in GDScript, #[derive(FoldbackHash)] in Bevy). Reflection is an alternate producer of the same FieldHash frame — not a new frame type or a separate bisection code path.
- Bevy and Unity: opt-in is checked once, at the root.
hash_reflected/HashReflectedfilters the root type’s own tagged fields; everything reachable beneath a tagged field is then walked unconditionally, without needing its own type separately tagged. - Godot deliberately does this differently: the
foldback_prefix check is re-applied at every nestedObjectthe walk reaches, not just the root. Doing what Bevy/Unity do — tag one property, walk everything beneath it unconditionally — would mean tagging a single property on aNodepulls in that whole node’s entire engine property surface (scripts, editor metadata, internal engine state), which a plain Rust struct or C# POCO simply doesn’t carry. So a nested tracked object’s own properties need their ownfoldback_prefix too. This is a real, considered difference in what “opt-in” means per engine, not an inconsistency — each choice fits the noise level of that engine’s actual object model.
The marker mechanism itself
- Bevy:
#[derive(FoldbackHash)]’s#[foldback(hash)]/#[foldback(reflect)], read at compile time. - Unity:
[FoldbackHash], a real C# attribute, read viaSystem.Reflectionat runtime. - Godot: checked directly against gdext’s generated API rather than assumed:
PropertyHintis a fixed engine enum, and nothing in GDExtension’s registration surface lets an addon add a new hint value the editor or@exportwould recognize — that needs an engine-side (C++) change, not something buildable from GDExtension. So thefoldback_naming convention isn’t a fallback here; it’s the only mechanism that’s actually available from this binding’s position in the stack.
Determinism hazards specific to reflection
The reason this needs its own care beyond the explicit API:
- Unordered containers: a reflected
HashMap/Dictionaryhas no guaranteed cross-platform iteration order — usually.foldback_core::hashable::FieldBytessortsHashMap/HashSetby key before hashing (withBTreeMap/BTreeSetimpls for symmetry), covered by a conformance test. The Bevy walker applies the same rule to reflectedMap/Setvalues; the Unity walker sortsIDictionary/ISetentries the same way. Godot is the exception, verified againstgodot-core’s own source rather than assumed: Godot’sDictionaryis insertion-ordered by design (andArrayis an ordered vector by construction) — so neither of Godot’s two built-in compoundVarianttypes has this hazard, and the walker deliberately doesn’t sort them. - Cycles and shared references: a fixed max depth makes a runaway object graph fail loudly rather than hang or stack-overflow; an identity-based visited-set catches genuine reference cycles where the engine’s object model actually allows them. Handled differently per engine, deliberately:
- Bevy: depth guard only (default 8,
Error::ReflectionDepthExceeded). No separate identity-based cycle guard — a struct’s first field shares its raw address with the struct itself (zero offset), so naive pointer-identity dedup flags every struct’s first field as a false-positive cycle back to its own parent. More fundamentally, a plainbevy_reflectwalk only ever sees owned value trees; Rust’s ownership model makes a value structurally unable to contain itself, so a genuine reference cycle can’t arise this way. The depth guard alone is correct and sufficient for Bevy. - Unity: depth guard (default 8) and an identity-based visited-set (
RuntimeHelpers.GetHashCode+ reference equality, skipped for value types) — sound here, unlike Bevy, because C# reference types genuinely can hold references to each other and form a real cycle. - Godot: depth guard (default 8) and an identity-based visited-set using
Gd<T>::instance_id()— Godot’s own built-in, engine-provided object identity, even more direct than Unity’s runtime-hash-code approach. Also sound and necessary: GodotObjects are reference types that can genuinely reference each other cyclically (verified live: a two-node mutual-reference test correctly throws rather than hanging).
- Bevy: depth guard only (default 8,
- Enum/union representation: hash the discriminant and payload in a fixed encoding — never the host language’s raw memory layout, which can differ across compilers/platforms. Bevy hashes
variant_name()plus each variant field. Naturally absent for Unity/C# and for Godot: a C#enumcarries no payload (unlike a Rust enum variant), so the walker hashes the underlying integral value directly; GDScript doesn’t have Rust-style payload-carrying enums at all (Variant’s own type tag already IS the discriminant, handled by the walker’s normal type dispatch). - Schema drift: if a type’s tagged-field set changes between the build that recorded a session and the build now analyzing it, a reader needs to detect the mismatch rather than silently diffing two things that no longer mean the same thing.
foldback_core::session::Session::record_schema(type_name, tracked_fields)writes aMetadataframe (foldback.schema.<type_name>→ sorted tagged-field fingerprint), once per type per session — exposed to Unity/Godot asfoldback_record_schemainfoldback-sys’s C ABI.foldback_core::schema::detect_driftcompares two such fingerprint maps, andfoldback schema-diff <before.foldback> <after.foldback>exposes it from the CLI, exiting 1 on drift (same convention asci-check). Notbuild_id-based —build_idis opaque game-supplied bytes with no defined internal structure to diff. Each binding’s walker supplies the type name automatically:- Bevy:
std::any::type_name::<T>(), a real static type — always unique. - Unity:
Type.FullNameof the reflected root, also a real static type — always unique. Deduped on the C# side too (ConditionalWeakTable<FoldbackSession, HashSet<Type>>), so a game callingHashReflectedevery tick doesn’t re-marshal the field-name array across FFI past the first call per type per session. - Godot: no static type declarations exist to draw on (GDScript).
Script.get_global_name()(a GDScriptclass_name) is used when present — the one identifier that’s both build-stable and genuinely unique per type. Without aclass_name, this falls back toObject::get_class()(the native engine class, e.g."RefCounted"), which collapses every un-named inner-class script sharing that base class onto the same schema key — a documented limitation, not a silent wrong answer. Give a tracked GDScript type aclass_namefor schema-drift to mean anything for it.
- Bevy:
Visibility tooling
Because reflection makes it easy to lose track of what is actually being hashed (there’s no derive-macro call site to grep for), each binding ships a preview surface:
- Bevy:
foldback lint --engine bevy <path>statically scans.rsfiles (recursing intomodblocks) for#[derive(FoldbackHash)]structs and prints each one’s tracked fields alongside its untagged siblings.foldback_rs::bevy::debug::ReflectionPreview+render(&egui::Context, ...)(featurebevy-debug-panel) draws a live table a game’s ownegui/bevy_eguiintegration calls into.examples/bevy-editor-demo(cargo run -p bevy-editor-demo) wires it into a realbevy_egui-backedApp—EguiPlugin, a demo entity whose position drifts every frame, one system callinghash_reflectedand one drawing the panel from the realEguiContextsa running game would use. Excluded from the defaultcargo test --workspacesweep (Cargo.toml’sexclude) since it pulls the fullbevy+bevy_eguiGPU/windowing stack, which a standard CI Linux/macOS runner isn’t guaranteed to have configured — same reasoning as the Unity/Godot/Unreal demo exclusions. - Unity:
FoldbackReflection.ListTracked(Type)returns the same tracked/untracked-sibling split for a given type.bindings/unity/Editor/FoldbackReflectionWindow.cs(Window > Foldback > Reflection Inspector) is a realEditorWindowbuilt on it, in its own.asmdefrestricted toincludePlatforms: ["Editor"]. Two tabs: Live, which subscribes toFoldbackReflection.Recorded(fired at the end of everyHashReflectedcall) and lists observed calls with drill-down into their captured field paths/hashes; Inspect, which runsListTrackedagainst any dragged-in object with no Play Mode or session needed. - Godot:
FoldbackSession.list_tracked(target)returns the same split for a live instance (not a static type — GDScript has no compile-time type declarations to scan the way Bevy’s source or Unity’sTypereflection do), as{"tracked": PackedStringArray, "untracked": PackedStringArray}.bindings/godot/addons/foldback/plugin.gd+foldback_dock.gd(enable via Project Settings → Plugins) is a real editor dock showinglist_tracked’s split for whichever node is currently selected in the editor, refreshing on every selection change. Deliberately not a live-hashing feed the way the Bevy/Unity docks are — the editor and a running (F5) game are separate OS processes by default, so there’s no shared memory a dock could read a livehash_reflectedcall from the way Unity’s single-process Editor+Play Mode allows; the static tracked/untracked view is what’s actually buildable here.
Performance
Reflective hashing is explicitly not free, unlike the manual API (which just serializes bytes the game already produced):
- Bevy:
crates/foldback-rs/benches/reflective_vs_explicit.rs(Criterion,cargo bench -p foldback-rs --features bevy) compares explicit vs. reflective cost for 100/1,000 entities — roughly 3.7x the explicit path’s cost at both counts.crates/foldback-rs/src/bevy.rs’sreflective_hashing_stays_within_a_generous_budget_of_explicit_hashingtest is a CI regression gate against a 15x ceiling. - Unity:
bindings/unity/Tests~/FoldbackSys.Testscompares an explicit-vs-reflective timing comparison for 1,000 entities against the plain .NET (Mono-equivalent) path — roughly 3.5-3.9x, consistent with Bevy’s number, CI-gated against a 15x ceiling. - Godot:
examples/godot-demo/test.gdruns the same comparison for 1,000 entities against a real headless Godot 4.7.2 — reflective runs roughly 13-19x explicit’s cost, noticeably higher than the other two, CI-gated against a 40x ceiling. Most likely cause:get_property_list()builds a fullArrayofDictionaryobjects — one Variant-boxed dictionary per property, every call — where Bevy’sbevy_reflectand Unity’s cachedExpression-compiled getters both avoid that per-call allocation cost.
Default posture (dev/editor-build feature, off by default in release builds) is a per-game build-configuration choice, not something any of the three crates enforces.
Engine-specific limitations
- Unity + IL2CPP AOT:
FoldbackReflection’s per-type accessor caching usesSystem.Linq.Expressions.Expression.Compile(), which needs JIT/codegen on most .NET runtimes; IL2CPP has noDynamicMethod/Reflection.Emit. Verified against a real IL2CPP player build (examples/unity-demo/Assets/Il2cppVerify.cs,bindings-unity-il2cppCI job):Expression.Compile()falls back to the BCL’s expression interpreter under IL2CPP, not a throw. See Unity. - Godot’s overhead (13-19x explicit’s cost) is real and measured, but the cause above is inferred from
get_property_list()’s known allocation pattern, not confirmed by a profiler — worth a closer look before recommending reflective hashing for a Godot project with tight per-tick budgets.