Plugin API status
Every part of Bevy's API a plugin author might reach for, and what happens when they do. This page is the reference; Standalone Plugins is the guide that explains why the boundary is shaped this way.
| Meaning | |
|---|---|
| works | Usable today, and the source is character-identical to Bevy. |
| differs | Usable today, but you write something different or it behaves differently. |
| missing | Not available. There is a known mechanism and no blocker — nobody has built it. |
| never | Structurally blocked. The note says by what. |
Read Traps before this page. A missing row costs you a compile error and five minutes. The handful of things that compile and then behave differently from Bevy are what actually cost people an afternoon, and they are listed there.
Systems & scheduling
| Bevy | In a plugin | |
|---|---|---|
| Commands as a system param | works | |
| Query<D, F> as a system param, including two or more Query params in one system | works | |
| Res | works | |
| Res | works | |
| Schedules First, PreUpdate, Update, PostUpdate, Last | works | |
| app.init_resource:: | works | |
| A system with no parameters — fn tick() {.. } | missing | Take at least one param, e.g. fn tick(time: Res |
| #[derive(SystemParam)] for a custom param | differs | Implement the public unsafe trait SystemParam by hand; there is no derive. Only the fetch half is reachable from outside the crate, so declare can only be a no-op — which limits a custom param to re-wrapping something the per-call struct already carries (the shape Images, Meshes and RemovedComponents use). Bundling a Query or a Res, which is what Bevy's derive is for, cannot be expressed. |
| Capturing closure as a system — add_systems(Update, move |q: Query<&mut T>| {.. }) | missing | A plain fn or a non-capturing closure. The refusal is a const assert in the ergonomic layer, not the boundary: the callable is rebuilt from nothing, so it must be zero-sized. The per-system opaque word that would carry a boxed capture already round-trips through the host, unused. |
| add_systems(Update, (a, b, c)) — a tuple of systems | differs | Identical spelling for up to 12 systems in one tuple; Bevy takes 20, and a 13th is a trait-bound error. A tuple means 'all of these' and says nothing about order — it accepts no .chain(), .run_if() or set configuration. |
| chain() | missing | Put the systems in different schedules (First then Update). add_systems takes a tuple but no configuration on it: the descriptor's flags word is hard-coded to zero and the host rejects anything else. That word is where ordering would land. |
| run_if(condition) | missing | An early return inside the system body. Same reserved flags word; nothing in the descriptor can carry a condition. |
| before(other) / .after(other) | missing | Put the two systems in different schedules. Same reserved flags word, and the descriptor has no label field at all, so a plugin has no way to name another system — its own or an engine one — across the boundary. |
| in_set(S) / #[derive(SystemSet)] / app.configure_sets(..) | missing | Same reserved flags word; there is no SystemSet derive (Component and Resource are the only two) and no configure_sets on App. |
| Relative execution order of two plugin systems in the same schedule | differs | Every system is added with no constraints, so Bevy's multi-threaded executor may run two of yours in any order or in parallel — including the members of one add_systems tuple. That is deliberate: each declares real per-query and per-resource access so it can schedule freely. Bevy would let you pin it; here you cannot. |
| Schedules PreStartup / Startup / PostStartup | missing | Do registration in Plugin::build; for a one-time spawn, guard an Update system with a resource flag. There are five schedule constants and startup is not among them, and build is not a substitute for spawning — Commands exists only inside a running system. An unknown schedule id is warned and re-homed to Update. |
| FixedFirst / FixedPreUpdate / FixedUpdate / FixedPostUpdate / FixedLast / RunFixedMainLoop | missing | Not among the five schedules. Appending more is an append-only change, and an unknown value degrades to Update with a warning rather than being dropped. |
| Other built-in Bevy schedules (SpawnScene, StateTransition, Main) | missing | Same five-value list; anything else is re-homed to Update with a warning. |
| A plugin-defined schedule — #[derive(ScheduleLabel)] struct MySchedule | never | A schedule crosses as one u32 resolved by a closed match on the host. There is no encoding for a label the host has no constant for. |
| Local | missing | #[derive(Resource)] + ResMut |
| ParamSet<(P0, P1, ..)> | missing | No impl, and declaring the conflicting params separately does not substitute: Bevy's own access check fires when the system is registered, the host reports an access conflict, and the whole plugin load fails. |
| Option<Res | differs | The spelling is now Bevy's — Option<Res |
| MessageReader | missing | Nothing in the per-call struct carries message buffers. The mechanism is established — input, meshes, images, HTTP and removed-components are all per-call sources appended to it — but no message source has been added. |
| app.add_message:: | missing | No such method on App, matching the absence of the reader and writer params. |
| Single<D, F> | missing | Query<D, F> plus single(), which now exists (returning Option, not Result). There is no Single impl, and its defining behaviour — skipping the system unless exactly one entity matches — needs run conditions. |
| Populated<D, F> | missing | Query<D, F> plus if q.is_empty() { return; }. No impl, and skip-on-empty needs run conditions. |
| RemovedComponents | differs | Shipped, and for e in removed.read() reads exactly like Bevy: per-system cursors, and a despawn counts as a removal. The difference is the return type — read() hands back an owned Vec |
| SystemChangeTick | missing | No impl, and no tick crosses the boundary — the per-call context is two floats. The values exist host-side, since the dispatcher uses Bevy's own change ticks to serve Added/Changed, so exposing them would be a new per-call field. |
| Deferred | missing | No impl. The only deferred buffer here is the Commands sink, for which the host supplies Bevy's own Commands. A plugin-defined buffer needs a second host-called entry point at apply time. |
| NonSend | never | NonSend names a host Rust type by TypeId. The boundary can only carry a resource the plugin itself described with a descriptor — registration and lookup are both keyed on the id the plugin got back. |
| &World / World / DeferredWorld / &Archetypes / &Entities, and exclusive systems — fn setup(world: &mut World) | never | A plugin links no Bevy, so there is no World type to name. The host pointer in the per-call struct is documented as always null while a system runs, because the world is borrowed by the queries, and the only system entry shape is an extern "C" fn taking that struct. |
| ApplyDeferred / apply_deferred as an explicitly scheduled system | never | A plugin registers only its own systems, never a host one. Command application is Bevy's, happening after the system that queued it, and the descriptor cannot express a barrier. |
| Res | missing | There is a lookup-component-id-by-name call but no resource equivalent, so a plugin can only reach a resource it registered itself; adding one mirrors an existing call exactly. The real limit is narrower than 'impossible' but real: only a repr(C) plain-data engine resource could ever be mirrored, the same rule host components already obey. |
| Res<Time | missing | The shim Time exposes exactly delta_secs() and elapsed_secs() — no clocks, no pause, no relative speed. The values are appendable, but the Bevy spelling never can be: a generic resource has no C representation, the same argument that flattened ButtonInput |
| Res<State | missing | Hand-roll it: a #[derive(Resource)] struct holding the current state. There is no States trait or derive, and no state machinery on App. |
| OnEnter(S) / OnExit(S) / OnTransition schedules | never | A schedule is a plain u32 resolved through a closed match. A label parameterised by a plugin-defined state type has no encoding in that word. |
| Plugin::finish() / cleanup() / name() / is_unique() | missing | Put everything in build(). The Plugin trait declares build and nothing else, and the single ABI entry point calls it once. |
| Gizmos (debug drawing) | missing | No Gizmos param and no gizmo call in the host interface. Lines and colours are plain data and the interface is append-only, so this is an append, not a design problem. |
Queries & filters
| Bevy | In a plugin | |
|---|---|---|
| Query<&T> | works | |
| Query | works | |
| Query<Option<&T>> | works | |
| With | works | |
| Without | works | |
| q.is_empty() | works | |
| Query<&mut T> | differs | Same declaration, but the item is a bare &mut T, not Mut |
| Query<Option<&mut T>> | differs | Item is Option<&mut T>, not Option<Mut |
| Query data / filter tuple arity — Query<(&A, &B, ..), (With | differs | Data tuples are 2 to 15 and filter tuples 1 to 15. The top matches Bevy; the bottom does not — Bevy implements from 0, so Query<()> and the one-element Query<(&A,)> compile there and not here. A bare () filter does work. |
| Or<(With, With)> | differs | 2 to 15 branches; Bevy allows 0 to 15, so only the empty and single-branch spellings are missing. The real limit is what a branch may contain: With, Without and a nested Or only. Added or Changed inside the bracket makes the host refuse the whole system at load with an explanatory error. |
| q.iter() / q.iter_mut() / for x in &q | works | |
| Query length — q.count() | differs | Spelled len(), a name Bevy's Query does not have (Bevy's is count()), and q.iter().len() is not a workaround because the iterator is not ExactSizeIterator. The value is post-filter. |
| q.get(entity) / q.get_mut(entity) | differs | Both exist but return Option, where Bevy returns Result — so if let Some(x), and no ? into a Bevy error. They are also O(n): the lookup scans the staged entity ids, because the host hands over a flat array rather than a map. Fine for 'did the thing I spawned match?'; do not put one inside a loop over another query. |
| q.contains(entity) | works | |
| q.iter_many(..) | missing | Iterate and compare the Entity term yourself, or call get per id. The staged view already carries the entity column, so this is a plugin-side addition with no ABI change; iter_many_unique and the _mut forms are the same story. |
| q.single() / q.single_mut() | differs | Both exist but return Option, where Bevy returns Result — deliberate, because a panicking plugin system is disabled for the session, so if let Some(x) is the shape that survives. None covers both zero matches and more than one; there is no single_inner. |
| q.par_iter() | missing | Absent, along with par_iter_mut and par_iter_many. Chunking needs no ABI change — the staged cells are one contiguous buffer — but a plugin has no handle on Bevy's task pool, so it would be your own threads oversubscribing the schedule. |
| q.transmute_lens:: | missing | The query-data trait exposes only a compile-time cell count with no runtime component-to-cell map, so narrowing to a new D has nothing to look up. join and QueryLens likewise. |
| Query<Ref | missing | &T, with no access to the change tick — no Ref type exists and no access kind carries a tick channel. Closer than it was: the dispatcher already reads per-row change ticks to serve the tick filters, so what is left is a wrapper type plus a wider cell. |
| Query<Mut | missing | &mut T — no Mut type exists; &mut T is the item type itself. Same mechanism as Ref |
| Query<Has | missing | Option<&T>, then test for None. An impl over the existing optional cell (null means false) would cover it with no ABI change. |
| Query<AnyOf<(&A, &B)>> | missing | Query<(Option<&A>, Option<&B>), Or<(With, With)>>. Purely a missing impl — it decomposes into optional cells plus an Or bracket, both of which exist. |
| Added | differs | Same spelling, and the tick comparison is Bevy's own against this system's last and current run — but three divergences. Neither may appear inside Or; the host refuses the system at load. Changed |
| Spawned | missing | No Spawned filter. It would be one more access kind plus entity metadata, but it is the first non-Or term carrying no component id, so the id-resolution path needs a new case. No caller asking. |
| Allow | missing | Absent from the filter set. The host's query builder has no allow-by-id call, but adding archetypal access is exactly what Allow |
| Query | missing | No impl. It is plain-old data, so it fits the cell model; it needs an appended access kind and marshal pair and nothing more. |
| Query<&Name> | missing | With |
| Reading an entity's children — Query<&Children> | missing | Nothing in the ABI reads hierarchy. It can never be a query CELL — every term has one fixed cell size and cells are raw byte copies, while Children is a Vec |
| Query | never | A query declares its whole access set at registration and the host's query builder is driven only by declared terms. A plugin has no Rust type for an arbitrary host component, and up-front access declaration is what lets plugin systems schedule in parallel; whole-entity dynamic access has neither. |
Components & resources
| Bevy | In a plugin | |
|---|---|---|
| #[derive(Component)] on a struct with named fields | differs | #[derive(Component, Default)] #[repr(C)] struct Foo { .. }. Default is mandatory, because the editor needs a value to put on the entity when you add the component. The derive also const-asserts the type has no destructor, so a component owning memory is a compile error whose message names the way out. #[repr(C)] is expected, documented, and checked by nothing. Bevy's derive requires none of the three. |
| #[repr(C)] on a component | differs | Required, enforced by nothing — the derive never inspects attributes for repr. Because the schema addresses fields by offset and the host reads and writes through those offsets, a repr(Rust) component still stores, inspects and round-trips correctly. It bites only where the same bytes become a GPU uniform: a post-process or material settings component is uploaded at its declared layout, and Rust's field order is not the shader's. |
| Field types in a plugin component | differs | f32 / i32 / u32 / usize / i64 / bool / Vec3 / Quat / Str256 map to a field kind; anything else is silently skipped, as are fields whose name starts with _. Note the Vec2 and Color in the shim are plugin-side maths only and are NOT boundary types, so a field of either is unmapped. An unmapped field still round-trips byte-for-byte through a save, because the writer copies the whole component — but it gets no inspector row, no BSN name, and it resets to default the moment a layout migration runs. No warning at any point. |
| A component field holding String / Vec / Box / Handle | never | Str256 for text; an asset handle for anything large. This is now a compile error rather than a silent leak: the derive const-asserts the type has no destructor and names Str256, a fixed-size array, or plugin-side state keyed by Entity as the ways out. The host refuses a descriptor declaring a destructor independently, on both the component and resource paths, because the ABI is public and a hand-written impl never passes through the derive. |
| #[derive(Component)] on an enum | never | Hard compile error: an enum has no stable field layout for the inspector to address by offset. Bevy allows enum components. |
| #[derive(Component)] on a tuple struct — struct Speed(f32) | differs | Use named fields. Only named fields are walked, so a tuple struct compiles and registers with an empty field schema: storable, completely uneditable, unreachable from BSN, and no warning. |
| #[component(name = "CRT")] — inspector label | differs | A plugin-only key with no Bevy counterpart, and only name is read. It is accepted spelled #[component(..)] or #[resource(..)], so resources get the same label control. An empty name falls back to the last segment of the type path. Bevy's other keys on that attribute — immutable, storage, hook names, clone_behavior — are not honoured. |
| #[field(min = .., max = .., speed = ..)] / #[field(skip)] — inspector hints | differs | Plugin-only; Bevy's derive has no inspector metadata. min and max must both be present or neither, or it is a compile error. skip is a bare word and keeps the field's place in the struct while removing its inspector row. Ranges ride a separate call that an older host skips wholesale, which then shows unbounded drags; the host normalises what it does receive — a reversed range is swapped, a zero speed becomes a thousandth of the span. |
| #[field(default = ..)] | differs | impl Default for the whole type; the attribute is parsed and then discarded. The trap: the value is parsed as a float literal BEFORE the key is examined, so #[field(default = 1.0)] compiles and is silently ignored, while #[field(default = Vec3::ZERO)] is a compile error complaining about a float literal. A converted effect keeps compiling and quietly starts at the wrong value. |
| #[component(storage = "SparseSet")] | never | The host registers every plugin component as table storage literally, and the descriptor has no storage field for a plugin to express a choice through. |
| #[component(immutable)] / Component::Mutability | never | The host passes mutable literally in the same registration call, and the plugin-side Component trait has no Mutability associated type to set. |
| Component hooks — #[component(on_add = ..)] / on_insert / on_remove / on_despawn | missing | No hook entry in the host interface. Bevy's by-id hook registration is reachable for a descriptor-registered component, so this is one appended interface entry plus a thunk — plumbing rather than a new mechanism. |
| #[require(Transform)] | never | The derive declares only component/resource and field as helper attributes, so #[require] is an unknown-attribute compile error. Bevy's registration path is generic over real Component types; the dynamic by-id form exists but is only reachable from inside a static type's own required-components impl, which a descriptor-registered component does not have. |
| #[relationship] / #[relationship_target] | never | The derive rejects the attribute, and the host registers every plugin component with no relationship accessor. |
| #[derive(Resource)] | differs | #[derive(Resource, Default)] #[repr(C)] struct Score { .. }. It registers through the same descriptor path as a component, so it inherits every restriction: struct only, Default required, the same field kinds, and the same no-destructor compile error, re-checked host-side on its own path. It correctly refuses spawn(MyResource) by not implementing Bundle, and #[resource(name = "..")] sets the inspector label. Registration is idempotent and does not reset a live value, so two systems both taking ResMut |
| Scene save/load of plugin components — Bevy's #[reflect(Component)] + register_type | differs | Nothing to write — every registered plugin component persists automatically. That is the inverse of Bevy's opt-in, on the reasoning that a plugin component is plain data by host enforcement, and there is no transient flag, so a scratch component cannot be excluded. The save writes the whole component as bytes at its live padded size rather than field by field. Plugin RESOURCES persist too, but only through a builder that opts in — a prefab or a delete-undo subtree deliberately leaves them behind. |
| Loading a scene whose plugin is no longer installed | differs | Nothing to write — unresolved bytes are parked on the entity verbatim and re-emitted on the next save, so a round trip through a build without the plugin does not delete your data. When the plugin is back, migration matches by FIELD NAME: renaming a field orphans that field's saved values while the rest of the component still loads, a field whose kind changed is left at the default, and adding, removing or reordering fields is safe. Resolution tries the full type path, then a rename-alias table, then the short name; an ambiguous short name resolves to nothing rather than a guess. A blob whose length does not match the live layout and has no schema to migrate through is skipped with a warning. |
| Changing a component's or resource's memory layout across a hot reload | never | Restart the editor. The reload is refused with a reason naming what moved — a changed size, a changed field count, a field that moved offset, a changed field kind — and the previous build keeps running. A rename alone is byte-compatible and takes the schema-refresh path instead, because field names are deliberately not part of the comparison. Resources are checked on their own path. Bevy fixes a component's layout permanently at registration. |
Commands & entities
| Bevy | In a plugin | |
|---|---|---|
| commands.spawn_empty() | works | |
| entity_commands.id() | works | |
| commands.spawn(bundle) | differs | Identical, but any component type you only ever insert and never name in a query needs app.register_component:: |
| Bundle tuple arity in spawn/insert | differs | 1 to 15 elements, matching the top of Bevy's range — the old 8-element cap is gone. One gap is left: there is no Bundle impl for (), so commands.spawn(()) does not compile. Write spawn_empty(). |
| commands.entity(e) | differs | Identical acquisition, much smaller handle: id / insert / insert_one / remove / despawn plus the engine-specific make_renderable / set_material / call_service — no insert_if_new, try_insert, with_children, retain, queue or trigger. Domain crates add methods by extension trait, so use renzora_plugin::anim::AnimCommands is part of the spelling. If the entity is gone at apply time every command silently no-ops, where Bevy routes it to the error handler. |
| entity_commands.insert(bundle) | differs | Identical, with the same registration caveat as spawn. The value is byte-copied and forgotten because the host owns those bytes from the push onward — no longer a leak risk, since a component owning memory is now a compile error. Host-side the byte count is checked against the live registered layout and a mismatch is refused with an error rather than written; Transform is the only type field-marshalled rather than copied. |
| entity_commands.remove::<B: Bundle>() | differs | remove::<T: Component>() — one component per call; remove::<(A, B)>() does not compile. |
| entity_commands.despawn() | differs | Identical spelling and signature; try_despawn() does not exist. The host maps it to Bevy's try_despawn, so it never warns when the entity is already gone, where Bevy's despawn does. Recursive descendant despawn is preserved, because it is Bevy's own call. |
| entity_commands.despawn_related:: | missing | No method of that name, no per-relationship despawn command, and no way to enumerate children. despawn() is NOT a substitute: it takes the entity as well as its children, where despawn_related keeps it. The only route today is to remember the ids you spawned and despawn each. |
| entity_commands.insert(single_component) | differs | insert(c) works; insert_one(c) is the single-component path the derive routes through — a plugin-only name with no Bevy counterpart. It moves the value and forgets it, because the host owns those bytes from the push onward. |
| commands.spawn_batch(iter) | missing | for b in iter { commands.spawn(b); } — equivalent, but it pushes one entity reservation plus one command each. The command kind is a plain newtype precisely so a batching value can be appended. |
| commands.get_entity(e) -> Result<EntityCommands, ..> | missing | There is no fallible variant: Commands::entity is the only accessor and hands back a handle unconditionally, and the sink has no synchronous existence query. entity() already tolerates a stale id — every apply arm is guarded — so a Result-returning form is an append, not a design change. |
| entity_commands.insert_if_new / insert_if / try_insert / insert_if_neq / entry() | missing | try_insert's semantics are already what plain insert does here: a missing entity is a silent no-op. None of the conditional or entry-style variants exist, and a queued command has no flags field to carry a condition. |
| entity_commands.remove_by_id / remove_with_requires / retain / clear | missing | No methods. Remove-by-id is exactly what the host already calls internally to service a remove, so exposing it is plumbing; retain and clear need world introspection a plugin has no access to. |
| entity_commands.add_child(child) / add_children(&[..]) / insert_children / replace_children / remove_children | missing | No hierarchy methods and no ChildOf shim, so an existing entity cannot be reparented at runtime — the only parenting that happens is host-side inside the BSN spawner. Name-based id lookup exists, but ChildOf holds an Entity, so it needs an entity-marshalling case rather than the default verbatim byte copy. Nothing in the ABI expresses a sibling index either. |
| Unparenting — in Bevy 0.19 written entity(child).remove:: | missing | The remove machinery would work unchanged — it takes any registered component id — there is simply no ChildOf type in the shim to name as the type parameter. |
| entity_commands.with_children(|parent| { .. }) / with_child(bundle) | differs | A Children [ .. ] block inside a bsn! literal, passed to spawn_scene or insert — Scene implements Bundle, so both spellings take it. Nesting exists but is compile-time text: bsn! renders to a static string, so children cannot carry runtime values and no child ids come back — the id you get is the root of the first tree only. The host-side spawner does insert real parent links depth-first. |
| commands.queue(|world: &mut World| ..) / entity_commands.queue(EntityCommand) | never | A queued command is plain-old data deep-copied at push time, because its payload may point at a plugin stack local that is gone by apply time, and there is no World or entity-world shim for a closure to receive. The sink exposes exactly push and reserve-entity. |
| commands.run_system(id) / register_system / run_system_cached | missing | Systems are registered only during build(), and registration hands back a status rather than a system id, so there is nothing to name later. The descriptor already carries an entry fn pointer, so the raw material is there. |
| commands.trigger(event) / add_observer / entity.observe(..) | missing | No observers, no event type, no add_observer on App. The nearest existing thing is the panel action thunk — a single unmangled fn pointer — and the append-only descriptor design makes adding one an extension rather than a redesign. |
| commands.write_message(msg) | missing | commands.call_service(..) carries opaque bytes to a named consumer instead. No message channel crosses in either direction; the service payload is deliberately uninterpreted so this crate need not learn any domain. |
| commands.init_resource:: | differs | app.init_resource:: |
| commands.remove_resource:: | missing | Registration and insertion are the only resource entries in the host interface; a removal entry would be an append. |
| commands.run_schedule(label) | missing | A system's schedule is fixed in its descriptor at registration and never named again, and nothing in a queued command carries a schedule. |
| clone_and_spawn() / clone_components::(target) / move_components::(target) | missing | No clone or move command. All three need host-side copying driven by a bundle type a plugin cannot describe beyond individual registered ids, but a per-id copy command would be an append. |
| commands.reborrow() / entity_commands.commands() | missing | Commands and EntityCommands are a raw sink pointer plus an id, so a reborrow is mechanically trivial — it simply is not written. |
| insert_batch / try_insert_batch / queue_handled / append / get_spawned_entity / unregister_system / log_components | missing | None exist. Commands is spawn / spawn_empty / spawn_mesh / spawn_scene / entity / call_service and nothing else. Each of these is either a batching optimisation over commands that do exist or an error-handling variant, and a plugin has one error policy: silently skip a missing entity, log a mismatched or unregistered component. |
| Calling a domain crate's API directly (play an animation, apply an impulse, fire an HTTP request) | differs | commands.entity(e).call_service(service, op, payload), normally wrapped by a domain module — and those wrappers are extension TRAITS you must import: AnimCommands and PhysicsCommands on EntityCommands, HttpCommands on Commands, since an HTTP request belongs to the plugin rather than an entity. A plugin cannot link the engine crate that owns the domain, so the request crosses as opaque plain-old data behind a small header and consumers take only their own service id. Nothing draining a service id is valid: leftovers are cleared at end of frame, so the call is discarded rather than queued forever. |
Assets & rendering
| Bevy | In a plugin | |
|---|---|---|
| Query<Entity, With | works | |
| Res | never | Create assets at init through App and keep the opaque handles. There is no load call, no path resolution and no asset-server entry in the host interface, and a plugin never holds a real Handle — it gets an index into a host slot table. |
| ResMut<Assets | never | App::add_mesh / add_mesh_data / add_image / add_material / add_material_shader during build(), then Meshes::write / Images::write from a system. Assets |
| Handle | differs | One non-generic opaque handle — a bare u64 index — covering meshes, materials and images. No type parameter and no ref-counting: it indexes one of three host-side lists, so the same number means different things in different calls and nothing type-checks that. Each entry records the owning plugin, but the write paths do not check it. An unknown index is a logged error, not a panic. |
| meshes.add(Sphere::new(1.0)) — a built-in primitive | differs | app.add_mesh(sys::Primitive::Sphere, Vec3::splat(1.0)). Six 3D primitives (cuboid, sphere, plane, cylinder, capsule, torus), init-only, dimensions crossing as one Vec3 whose meaning changes per primitive: full extents for cuboid and plane, x = radius for a sphere, x = radius and y = height for cylinder and capsule, x and y = major and minor radius for a torus. No 2D shapes. An unknown primitive is not silent — it logs that this build does not have it and draws a cuboid, because a visible cube beats a missing mesh. |
| Mesh::new(..).with_inserted_attribute(..) — hand-built geometry | differs | app.add_mesh_data(&positions, normals, uvs, indices). Init-only, triangle list only, four attributes: position, normal, uv0, index. normals: None runs Bevy's normal computation, uvs: None zeroes them, indices: None means every three positions are a face. Tangents, joints, weights and a second UV set have no way across, and vertex colours exist only on the write path. Anything inconsistent is refused with a log line rather than padded. |
| assets.get_mut(&mesh_handle) — rewrite a mesh every frame | differs | Meshes::write(handle, &positions, normals, uvs, indices, colors). Only meshes the plugin created, and the whole mesh is replaced rather than one attribute edited: the host builds a fresh mesh through the same validator and assigns it over the existing asset, so everything already drawing that handle follows. A refusal leaves the previous geometry alone. This is the only path that accepts per-vertex linear-RGBA colours, which the built-in material multiplies into base colour. |
| meshes.get(&mesh3d.0) — read geometry already in the world | differs | Meshes::read(entity) -> Option |
| images.add(Image::new(..)) | differs | app.add_image(width, height, sys::ImageFormat::Rgba8, &data). Three formats (sRGB RGBA8, linear RGBA8, R32 float), 2D single-layer only, no mip or sampler control, init-only, and the data length must equal width x height x bytes-per-pixel exactly or it is refused rather than padded. |
| images.get_mut(&handle) then write image.data | differs | Images::write(handle, &pixels) — contents only. The new buffer must be exactly the same byte count, since dimensions and format are frozen at creation, and a mismatch is refused with the previous pixels intact. There is no Images::read. |
| materials.add(StandardMaterial {.. }) | differs | app.add_material(color) or app.add_material_pbr(color, metallic, roughness, emissive). Colour crosses as a linear [f32; 4], not a Color. Four fields reach the host and the rest is defaulted — no texture maps, no normal or occlusion or clearcoat, no alpha mode. Init-only and immutable once created: nothing edits a built material, so a parameter that changes needs add_material_shader. |
| impl Material for MyMaterial + #[derive(AsBindGroup)] — a custom shaded material | differs | app.add_material_shader:: |
| AlphaMode::Mask(0.3) / Premultiplied / Add / Multiply | differs | Three modes — opaque, mask, blend — and mask carries no threshold; the bridge hard-codes 0.5. Only add_material_shader takes one at all; add_material and add_material_pbr are always opaque. An unknown value is refused with a log line rather than falling back. |
| commands.spawn((Mesh3d(h), MeshMaterial3d(h), Transform::from_xyz(..))) | differs | commands.spawn_mesh(mesh, material, transform), or commands.entity(e).make_renderable(..). One fused command instead of a bundle, because the plugin-side Mesh3d is an opaque filter-only marker you cannot construct — mesh, material and transform must be set together. spawn_mesh returns entity commands, so plugin components can be attached in the same breath. |
| commands.entity(e).insert(MeshMaterial3d(handle)) — reskin existing geometry | differs | commands.entity(e).set_material(handle) — handle only, no generic material type; the host resolves the slot to either a standard or a plugin material and inserts the right component. It exists because make_renderable could not do it: a plugin has no way to re-supply an imported model's mesh. Filter on With |
| Color::srgb(0.2, 0.6, 0.9) / Color::hsl(..) | differs | A Color now exists: srgb, hsl, WHITE/BLACK/NONE, linear_rgb(a) and with_alpha, so those call sites compile unchanged. It is a plain red/green/blue/alpha struct storing LINEAR always, not Bevy's colour-space enum — Color::srgb(0.2, ..).red is 0.033 here and 0.2 in Bevy — and there is no Srgba / LinearRgba / Hsla, no to_srgba(), no conversions back. It is plugin-side only: it cannot be a component field and never crosses the boundary, so every API still takes [f32; 4] and you write add_material(Color::srgb(..).to_linear_array()). |
| A custom render-graph node / ViewNode recording its own draw commands | differs | app.add_render_pass(id, FRAGMENT_WGSL, RenderPhase::LdrPost, order, |pass| { pass.set_pipeline(); pass.draw(0..3, 0..1); }). Fullscreen passes only: WGSL crosses as source text, the host owns the pipeline and the single bind group (view texture, then sampler), there are four fixed phases, and set_pipeline takes no argument because there is only ever one. The callback must be a non-capturing closure or fn. In a build with no renderer the registration is warned and dropped, and the plugin's systems still run. |
| A post-process effect — #[derive(ExtractComponent)] settings on a camera + a ViewNode | differs | app.add_post_process:: |
| Registering a new render pass, effect or material after startup (hot reload) | differs | Editing an existing pass or effect's WGSL hot-reloads: the reload validates an effect's uniform against the settings size and overwrites the shader asset, and Bevy recompiles. Adding a NEW pass or effect warns and is skipped — that needs the render sub-app, which a main-world system cannot reach. A custom MATERIAL reloads neither way: its pending list is consumed once at startup and never re-read, so an edited material shader is ignored until restart. |
| commands.spawn((PointLight {.. }, Transform::from_xyz(..))) — spawn a light | differs | commands.spawn_scene(bsn! { Transform { translation: Vec3(0.0, 6.0, 0.0) } PointLight { intensity: 400000.0 } }). Reachable only as BSN text resolved at runtime, so a misspelled component or field is a logged warning rather than a compile error. Any field you do not name is filled from the type's reflected default, which is why this works for Bevy's own components; a type registered without one must have every field spelled out. |
| commands.entity(camera).insert(Bloom { intensity: 0.3 }) / DepthPrepass | differs | commands.entity(e).insert(bsn! { Bloom { intensity: 0.3 } }). Finding the camera is no longer the problem it was: declare a filter-only mirror for Camera3d and query Query<Entity, With |
| commands.entity(camera).insert(Msaa::Sample8) / Tonemapping::AcesFitted — enum-valued render components | differs | bsn! { Msaa } — the bare name only, which inserts the type's default. A variant body reaches the deserializer still wrapped in parentheses, so Msaa { Sample8 } and Msaa(Sample8) both arrive as (Sample8), which is rejected — and the enum path swallows that failure with no warning at all, unlike the struct paths. The variant you asked for silently never applies. |
| Query<&Transform, With | differs | Declare the mirror yourself — pub struct Camera3d(()); plus host_component!(Camera3d, "bevy_camera::components::Camera3d") — and use it in With / Without / Added / Changed: filtering on any host component that derives Reflect is free. Reading one as query DATA is refused at load unless the crate that owns it opted in, and only the animation and physics state mirrors are opted in today, so &Camera3d and &DirectionalLight are rejected with an error naming the type. &Transform is the exception: special-cased and field-marshalled. |
| *visibility = Visibility::Hidden — hide an entity | never | Write empty geometry, or despawn. The plugin-side Visibility is an opaque filter-only marker over a Bevy enum with no plain-data mirror, and even a hand-written mirror would be refused as query data. There is no visibility command either. |
| Mesh3d(handle) or MeshMaterial3d | never | Their only field is a handle, and BSN field values go through a plain deserializer with no handle processor, so there is no way to spell a handle in the grammar — the field fails to deserialize and is skipped, leaving a dangling default. Use spawn_mesh / make_renderable / set_material, which carry handles as command payloads. |
| Sprite { image,.. } / Mesh2d + MeshMaterial2d — 2D rendering | never | Sprite is nameable in BSN but its image is a handle, which BSN cannot express, and a sprite with the default handle draws nothing. The only spawn path in the ABI inserts a 3D mesh and material, the primitive set has no 2D shapes, and no 2D component has a mirror. The Vec2 in the shim is plugin-side maths only and never crosses. |
BSN scenes
| Bevy | In a plugin | |
|---|---|---|
Bare marker component — Marker | works | |
Children [.. ] nesting, comma-separated, parenthesised or bare | works | |
| Comments inside the scene — // line and /* block */ | works | |
#Key entity name — #Player | differs | It names the entity — a real Name is inserted, before the component list, so a later explicit Name("Foo") still overrides it — but there is no name scope to refer back to. Bevy pairs the name with an entity reference; the tree here carries no reference field. |
| Named-field braces — Comp { a: 1 } | differs | Same syntax, three caveats. Unmentioned fields default only if the target has a reflected Default; without one the body must name every field or the whole component is skipped. A misspelled field name is a warning, not the compile error Bevy's macro gives. And for a PLUGIN-OWNED component only f32, i32, bool and string values can be written at all — MyComp { dir: Vec3(1.0, 0.0, 0.0) } drops the field and leaves the default, even though the derive happily declares Vec3 and Quat field kinds. |
| Tuple component — Comp(1.0), Comp(a, b) | differs | The body must supply EVERY positional field: the patch applies only when the count matches, so a short body lands fully default with no diagnostic at all (too many args does warn). Bevy documents Comp(val) as patching with the rest defaulted. |
| Module-qualified path — mymodule::CompA {.. } | differs | The bare short name, or the exact registered type path — nothing in between. Lookup tries the full path then the trailing identifier, so a partial qualification matches neither and the component is dropped with a warning. An ambiguous short name deliberately resolves to nothing rather than a guess. |
| Omitted fields default | differs | Top level only, with three answers depending on the target: plugin components fill from their registered default bytes, engine components from their reflected Default, and nothing below the top level fills at all. |
| Nested struct value — Node { padding: UiRect { left: Px(4.0) } } | differs | Name every field of the nested value. Patching stops at the top level: each field value is deserialized whole, so an incomplete nested body fails and is dropped with a warning while the rest of the component still lands. Bevy recurses into nested types. |
| Enum field value — row_gap: Val::Px(6.0) | differs | Write row_gap: Px(6.0) — a bare variant with no enum path prefix. Field bodies are deserialized with a grammar whose enum form is Variant(payload), so Val::Px(6.0) reads Val as the variant name, fails, and the field is dropped with a warning while the rest of the component lands. |
| Generic component — MeshMaterial3d:: | differs | Write MeshMaterial3d |
| Other relationship targets — MyRel [.. ] | differs | Any name not ending in Children is warned and then treated as Children anyway, so the tree spawns and looks structurally right while the entities are parented rather than related through your relationship. Bevy genuinely stores and generates against the relationship path. |
| #[require(..)] chains firing on insert | differs | Engine components go in through reflection's typed insert, so requires do run — but one component at a time, not as one bundle. Plugin-owned components take the raw-layout path instead, and the derive has no require attribute to declare any. |
| bsn_list! — multiple root entities sharing one name scope | differs | bsn_list! { (..), (..) } spawns, but roots after the first come out parentless and unreferenceable: both macros render to the same static string, every scene is parsed as a list, and on spawn only the first root lands on the reserved entity. Bevy's motivation for the macro — a shared name scope across roots — does not exist here. |
| Two-way binding a widget field to a plugin resource (Renzora-only, no Bevy equivalent) | differs | Write EmberSliderWidget { value: bind(FlockSettings.cohesion) }. Three limits: only TOP-LEVEL fields bind, and a bind(..) nested inside a struct value is left as literal text; resolution happens one frame later, by a polling system, because the widget's subtree does not exist yet at insert time; and only f32, i32 and bool fields of a registered RESOURCE can bind — Vec3, Quat and string are refused with an error, components are not addressable, and an ambiguous resource short name is an error rather than a guess. |
| commands.spawn_scene(bsn! {.. }) | differs | Identical call syntax, and like Bevy's it returns entity commands for the root — but a restricted, compile-time-literal subset inside the braces. bsn! renders its tokens to a static string parsed by the host at runtime, so there is no expression interpolation, no template functions, no on(..) entries, and only Children may nest. |
| entity_commands.apply_scene(bsn! {.. }) — apply a scene onto an existing entity | differs | Write commands.entity(e).insert(bsn! { .. }). Different method name, and it REPLACES rather than patches: each named component is rebuilt from its default before insertion, so fields the source does not mention are reset. Only the first top-level tree lands on the target entity. |
| Enum component variant as a scene entry — MyEnum::Variant | never | Only the default variant is reachable. MyEnum::Variant resolves to no registered type and is dropped with a warning, and there is no body spelling that reaches the deserializer's bare-variant form, because the parser only accepts a delimited body — MyEnum(Variant) arrives as a tuple. Worse, the enum path swallows a failed deserialize with no warning at all. |
| Field-assignment shorthand — Comp { name } | never | Comp { name: name } is not a substitute either: bsn! has no local variables at all, because it expands to a static string with nothing to capture. The shorthand parses cleanly and is then dropped with no warning, so the field silently keeps its default. |
| Observers — on(|ev: On<Pointer | never | Use Button + PanelActionId { action: 1 } in the BSN plus Panel::new(..).on_action(..) in Rust, with the action carried as a number because the field kinds cannot hold a closure. A closure cannot cross the C ABI inside a string; on(..) parses as a component named on and is dropped with a warning. |
| ~Template — a custom Template impl prefix | never | ~ is outside the path charset, so the parse hard-errors — and the error is logged host-side and never reaches the plugin, so the whole scene silently fails to spawn from your point of view. |
| @MySceneComp — include a SceneComponent | never | Same as ~: @ is outside the path charset and the parse hard-errors, silently from the plugin's side. There is no scene-component concept on the plugin side — no props, no scene function. |
| Cached scene asset — :"scene.bsn" | never | The colon IS in the path charset, so this parses as a component literally named ':' (dropped with a warning) and then hard-errors on the string literal that follows. This is a genuine gap, not parity with an unfinished Bevy feature: Bevy 0.19 parses it, generates a cached scene asset, and that asset is a real scene. |
| Named-entity cross reference — ChildOf(#Parent), Comp(#OtherKey) | never | There is no name scope in the spawner: #Key becomes a Name and nothing more. In a named field the value is dropped with a warning; in a tuple component the failed argument warns and then the whole body is dropped silently by the field-count rule. |
| Inline asset value — Mesh3d(asset_value(Circle::new(4.0))) | never | Use commands.spawn_mesh(mesh_handle, material_handle, transform). BSN carries no Rust-expression channel and no handle spelling — values only ever go through the reflect deserializer — and there is no field kind for a handle either. |
| template_value(component) / template_value(Comp::from_str(..)) | never | It needs a live Rust value, and the expansion is a static string. It parses as a component named template_value, misses both the type registry and the plugin schemas, and is dropped with a warning. |
| Rust expression value — { 1 + 2 }, { vec![..] }, { bsn!{.. } } | never | Unquoted braces are rewritten to parentheses before deserializing, so Bevy's expression braces are reinterpreted as an anonymous struct body: { 1 + 2 } becomes (1 + 2) and is dropped with a warning, and anything that happens to look like a valid struct is accepted with a completely different meaning. |
Domains & I/O — animation, physics, HTTP, input, maths, logging, hot reload
| Bevy | In a plugin | |
|---|---|---|
| Depending on an ordinary crates.io crate (noise, ttf-parser, serde, ..) | works | |
| Vec3 — new, splat, ZERO/ONE/X/Y/Z, dot, cross, length, length_squared, distance, normalize, lerp, clamp_length_max, and + - * / -x += -= *= | works | |
| Quat — IDENTITY, from_axis_angle, from_rotation_x/y/z, q * q, q * v, Default | works | |
| Transform::IDENTITY, from_xyz, from_translation, with_translation, with_scale, rotate(q) | works | |
| transform.rotate_x/y/z(angle) | works | |
| transform.forward() / back() / right() / left() / up() / down() | differs | Same call, but all six return Vec3 where Bevy 0.19 returns Dir3, and there is no Dir3 type in the shim. |
| Transform::look_to / with_rotation / from_rotation / from_scale / rotate_around / compute_matrix / local_x/y/z / align / mul_transform | missing | looking_at and transform_point did ship, with Bevy's signatures and semantics — looking_at points -Z at the target and returns the transform unchanged in the two degenerate cases rather than producing a NaN basis. Everything else listed is absent, all of it plugin-side code with no ABI cost, though compute_matrix and align additionally want Mat4 and Dir3, which do not exist. |
| The rest of glam's Vec3 — NEG_X/Y/Z, MIN/MAX/INFINITY/NAN, abs, min/max/clamp, floor/ceil/round, reflect, angle_between, project_onto, to_array, extend/truncate, midpoint, move_towards, is_nan, Div | missing | Add, subtract, scale, cross and lerp are all inherent methods or operators on the shim's Vec3 now, so the old advice to re-implement them locally is obsolete. What is left is the long tail listed here, none of which exists. Hand-roll it — it is plugin-side code and costs no ABI. |
| Quat::conjugate / lerp / dot / to_euler / from_rotation_arc / from_mat3 | missing | inverse, normalize, length and slerp shipped and read like Bevy's (inverse is the unit-quaternion cheap form, normalize returns identity rather than NaN at zero length, slerp takes the shorter arc and falls back to lerp near-parallel). Two things to know: Quat::from_euler exists but takes plain (yaw, pitch, roll), so Bevy's from_euler(EulerRot::YXZ, ..) does not compile; and Quat::from_basis(x, y, z) is a shim-only constructor with no Bevy equivalent. |
| Vec4, Mat3, Mat4, Affine3A, Dir3, Rect, IVec2/UVec2 | missing | Vec2 and Color shipped, both PLUGIN-SIDE ONLY — neither can be a component field and neither ever crosses the boundary. Vec2 has ZERO/ONE/X/Y, new, splat, dot, perp_dot, length, length_squared, distance, normalize_or_zero, lerp, extend and the operators, but no plain normalize. The types listed here still do not exist, so the tuple-and-array idioms stand: the cursor is (f32, f32), mesh UVs are [f32; 2], material colour is [f32; 4]. |
| A String field on a component | differs | pub text: Str256 — 252 bytes inline plus a length, built with Str256::new(s) (None if it does not fit) or new_truncating (cuts at a char boundary). A String field now fails to compile outright, where it used to compile, bit-copy its pointer into host storage, never drop, and serialise into scenes as a meaningless number. One capacity only, because the size has to be recoverable from the field kind alone. |
| Res<ButtonInput | differs | Res + input.pressed(Key::W) — one param covers keyboard, mouse and cursor, because a generic resource cannot be instantiated across a C ABI. Key names drop Bevy's Key/Digit prefix pattern, and the 84 wire values are frozen and deliberately unequal to Bevy's discriminants, so a Bevy upgrade that inserts a variant cannot remap anyone's input. |
| Res<ButtonInput | differs | input.mouse_pressed(MouseButton::Left) on the same Res. Five buttons only; MouseButton::Other(u16) has no wire number and is dropped. |
| window.cursor_position() via Query<&Window, With | differs | input.cursor() -> Option<(f32, f32)> — a tuple, not a Vec2, and always the primary window; a plugin has no window query and no way to name a second window. Absent is NaN on the wire, so forgetting to check gives an obviously wrong number rather than a plausible (0, 0). |
| Res | differs | input.cursor_delta() / input.scroll(), both (f32, f32), folded into the same snapshot. The scroll delta is copied verbatim and its unit is never read, so the line-versus-pixel distinction is lost. |
| Numpad, media, IME and international keys | missing | There is room — 84 keys against a 256-bit set — but the mapper returns nothing for these rather than inventing a wire number that could never be changed. |
| Gamepad — Query<&Gamepad>, GamepadButton, axes, rumble | missing | Nothing structurally blocks it: the same flatten-into-one-snapshot trick works and the snapshot is appendable. There is simply no gamepad field and nothing collected. |
| Touch input (Res | missing | Same shape as gamepad: no field, nothing collected, no vocabulary for a touch id. |
| Text entry — EventReader | never | The input snapshot carries three keyboard bit-sets, three mouse bitmasks and six floats, and there is no event stream of any kind on the boundary. The Bevy spelling is what is blocked — a reader is generic over a host type, and a logical key owns a heap string. A frame's typed text as a fixed-size UTF-8 buffer is not blocked by anything, so if it ever arrives it arrives in that shape. |
| AnimationPlayer::play(node_index) | differs | commands.entity(e).play_animation("run"), with features = ["anim"] and use renzora_plugin::anim::AnimCommands. Not a component write: it encodes a command onto the generic service channel and names the clip by string, where Bevy names a graph node index. |
| player.play(n).set_speed(s).repeat() | differs | play_animation_with(name, speed, looping) — one flat call instead of a builder chain, and looping is a bool rather than a repeat mode. |
| AnimationTransitions::play_with_transition(player, node, duration) | differs | commands.entity(e).crossfade_animation("run", 0.2) — duration is f32 seconds rather than a Duration, the target is a name, and it always loops. |
| player.stop_all() / pause_all() / resume_all(), active.set_speed(s), active.seek_to(t) | differs | stop_animation() / pause_animation() / resume_animation() / set_animation_speed(s) / seek_animation(secs) on entity commands — deferred commands rather than method calls on a live player, and speed and seek apply to the whole animator, because the payload carries no node index. |
| Reading animator state — Query<&AnimationPlayer> | differs | Query<&AnimState> after use renzora_plugin::anim::AnimState — a numeric-only mirror (clip and state hashes, state time, time, playing), not Bevy's type. Names arrive as 64-bit hashes, so is_clip("run") works but enumerating or discovering a name does not. It is exposed READ-ONLY: &mut AnimState is refused at registration and the system never runs. |
| State-machine parameters (Renzora's own animator; no Bevy equivalent) | differs | set_anim_param(name, f32) / set_anim_bool / set_anim_trigger / set_layer_weight. Write-only, and a name longer than 48 bytes is dropped entirely — not truncated — with an error, because a silently shortened name resolves to no clip and is far harder to trace. Reading a parameter back is impossible: parameters are an unbounded name-to-value map and the state mirror is fixed-size with no reply channel. |
| Procedural tweens (Renzora-only) | differs | commands.entity(e).tween_position(target, secs, Easing::OutCubic). The 17 easing ordinals are frozen and must stay aligned with the engine's declaration order, and tween_rotation targets are Euler DEGREES in a Vec3, not a Quat. |
| Physics forces — avian ExternalForce / ExternalImpulse / LinearVelocity | differs | commands.entity(e).apply_force(v) / .apply_impulse(v) / .set_velocity(v), with features = ["physics"]. Deferred service commands rather than component writes; the bridge re-emits them as the same action a Lua script fires, so a plugin's impulse takes exactly a script's path, including the 2D/3D dispatch. |
| Kinematic character move-and-slide | differs | commands.entity(e).kinematic_slide(delta, max_slope_degrees) — one fixed operation with a slope angle; no controller config, no ground snapping, no step height. |
| Reading body state — Query<&LinearVelocity> + collision events | differs | Query<&PhysicsState>, then is_grounded() / is_colliding() / just_entered() / just_exited(). The mirror carries linear velocity, a precomputed speed, the ground normal and four contact flags — but no angular velocity, no collider identity and no contact point, and collision NAMES are deliberately omitted. Read-only: &mut PhysicsState is refused at registration. |
| Creating a body — RigidBody, Collider, joints | never | Construct them indirectly through BSN text. The Bevy/avian spelling is blocked: those types are avian's, have no plain-data mirror, and a plugin cannot name them. The physics operation set has four values and none is constructive, so a plugin can only drive a body something else authored. |
| Spatial queries — raycast, shapecast, overlap tests | missing | avian's spatial-query param can never cross, but a request-then-poll reply is a shape this boundary already uses — input, meshes, images, HTTP and removed components are all per-call sources, and polling an HTTP reply by tag is exactly what a raycast reply would look like. Today every physics operation is write-only and there is no reply, so nothing works yet. |
| HTTP GET/POST | differs | commands.http_get(TAG, url) / commands.http_post(TAG, url, body), with features = ["http"]. Fire-and-tag, not a future or a callback: a callback pointer would have to survive a hot reload, which is exactly what generation-gating exists to prevent. Not entity-scoped — these hang off Commands, not EntityCommands. |
| Collecting an HTTP response | differs | fn collect(http: Http) { if let Some(r) = http.poll(TAG) { .. } } — a bare system param, not a Res, polled every frame and delivered exactly once, with a two-pass copy so a failed allocation cannot swallow the response. The body is lossy UTF-8, so binary responses are mangled. |
| Native file / folder picker | differs | commands.pick_file(TAG, title, &filter) / pick_folder(TAG, title) / pick_save_path(..), with features = ["dialog"], collected by dialogs.poll(TAG). Fire-and-tag like HTTP. Cancelling REPLIES (DialogResult::Cancelled) rather than going silent, and Unavailable is distinct from it (no windowing / headless) so a plugin can fall back to typing a path; outcome.path() collapses both to None. The picker blocks the editor while open, matching the rest of the editor, but the answer still arrives through the queue on a later frame — never assume which. Filters cross as text ("Images:png,jpg;All:*"); DialogFilter strips the separators out of labels so it cannot mis-split. |
| A domain that needs the host to answer it | differs | Rides SystemCall::replies — the generic reply channel, keyed by (service, tag) — rather than a source of its own. Added in MINOR 4.5 as the counterpart to call_service, which has been generic since 2.4; before it, every answering domain (meshes, images, HTTP) cost a VERSION_MINOR bump. Intended to be the last per-domain source ever added, so a new domain now costs no boundary surface in either direction. |
| Streaming HTTP (SSE / NDJSON / chunked) | differs | commands.http_get_stream(TAG, url) / http_post_stream(TAG, url, body), collected with while let Some(c) = http.poll_stream(TAG). One request yields many chunks ending in exactly one terminal chunk (End, or Error with the text in c.data), so poll in a loop and stop at c.is_last(). Chunks are transport-sized, not message-sized — a JSON object can span two, and two can share one — so accumulate and split on your API's own framing. Streams and whole-body responses share a queue but never cross: poll ignores chunks and poll_stream ignores completed bodies. |
| HTTP verbs beyond GET/POST, custom headers, timeouts, response headers | missing | The status code IS available — the response carries it, with is_ok(). Two verbs exist and anything else is warned, but the engine's own client takes an arbitrary method string, so more verbs are two constants and a match arm. Headers and timeouts need a parameter on the request call and room in its header struct — more work, still no blocker. |
| info!("spawned {n} boids") | works | |
| debug! / trace! | differs | log(LogLevel::Debug, msg) after use renzora_plugin::sys::LogLevel. Both levels exist on the wire and the host routes them to real debug!/trace!, but only info, warn and error got the macro-and-function pair, so these two are the one place you still write the level out by hand. |
| Logging before Plugin::build is entered | never | The interface pointer is stashed at init; before that, the log call sees null and returns, so every level including the macros is a silent no-op. |
| Hot reload of plugin code while the editor runs (Bevy has no equivalent) | differs | Nothing to write: edit a .rs or .wgsl under plugins/ and save. Components and resources survive untouched, because their ids are name-keyed and the data lives in the host's ECS; panels, render passes, post-process effects and GPU handles are taken back and re-registered by the new build. Systems cannot be removed from a Bevy schedule, so they retire by generation and stay scheduled as no-ops. Post-process and render-pass SHADERS hot-swap; a custom MATERIAL's shader does not — that needs a restart. Every failure mode leaves the running build in place, and a reload leaks one library image and one shadow-copied file by design, because a mapped DLL cannot be replaced on Windows. |
| Adding a NEW panel, render pass or post-process effect during a reload | never | Restart the editor. Two different reasons: panel registration is an init-time extension on App, so the reload finds no slot for an unknown id and warns once per id; and registering a pass or effect needs the render sub-app, which the main-world reload system cannot reach. Editing an existing one works in both cases — the BSN is reparsed and redrawn, the shader asset is validated and overwritten. |
| Pre-declared host-component mirrors a plugin can query without setup | differs | Transform (full read/write) plus Mesh3d and Visibility, and behind their features AnimState and PhysicsState. Reading an engine component as DATA is enforced, not merely documented: any data term naming a host component whose owning crate has not opted in fails the whole system at registration with an error naming the type — so &Mesh3d and &Visibility no longer compile-and-lie, and &mut AnimState / &mut PhysicsState are refused because both are exposed read-only. Transform is exempt and specially marshalled. Filtering with With/Without is unrestricted and free. GlobalTransform and Name are force-registered so they are filterable, but cannot get a naive mirror: one's layout varies with the SIMD backend, the other holds a String. |
| Exposing your own in-tree component to standalone plugins | differs | Four steps now, not three: make it #[repr(C)] plain data; register_type and register_component it so the id exists before plugins load; call renzora_plugin::host::expose_component_data:: |
| Depending on a Bevy ecosystem crate (bevy_hanabi, bevy_mod_outline, avian3d, leafwing-input-manager, ..) | never | Their public APIs take real Bevy types; the Transform, Query and App here are shims with no relationship to Bevy's. Anything needing them is an in-tree engine crate compiled against real Bevy — the deliberate split between the two plugin tiers. |
Editor panels
| Bevy | In a plugin | |
|---|---|---|
| An editor panel (Renzora-only; the in-tree equivalent is App::register_panel_content) | differs | app.add_panel(Panel::new("flock", "Flock", bsn!{ .. }).icon("bird").category("Plugins").on_action(f)). The body crosses as a STRING of BSN parsed host-side; a plugin is never handed widget entities. An empty id, empty markup or a duplicate id is refused, so ids must be globally unique across every loaded plugin. An empty icon defaults to a puzzle piece and an empty category to 'Plugins'. Ignore the panel descriptor's own doc comment — it still documents the retired column/slider grammar that BSN replaced. |
| Panel widget set | differs | BSN naming real components: Node, Text, Children, plus EmberButtonWidget, EmberSliderWidget, EmberToggle, EmberCheckbox, EmberInput, EmberDropdown, EmberTabs, EmberProgress, EmberTable and EmberTimeline (with EmberTrack/EmberClip for timeline rows). The vocabulary is open — the spawner resolves a name against the engine's type registry first and your own component schemas second — but the widgets are Renzora's, not Bevy's, and the body is text rather than Rust. |
| Button clicks — .observe(on_click) / Trigger<Pointer | differs | PanelActionId { action: 1 } on the widget plus .on_action(handler); Action::is("1") matches the number stringified. Two live traps. Dispatch queries the interaction and the action id on ONE entity, and EmberButtonWidget builds its clickable box — the thing carrying Interaction — as a CHILD, so the EmberButtonWidget + PanelActionId pairing printed in the standalone-plugins guide compiles, spawns, looks right and never dispatches: put the action id on Bevy's Button, which requires Interaction. And there is no edge detection — every frame the interaction is pressed fires the handler again, not once per click. |
| Reading a widget's value in the action handler — Action::value | missing | Documented as a toggle's 0 or 1 or a slider's position, but the host hard-codes it to 0.0 on every dispatch and never reads the widget, so the field is always 0. Nothing structural blocks it: the float already crosses, and the host already knows how to read a widget's model for binding. Until someone fills it in, use bind(..) and read the resource. |
| Changing a panel's contents after registration | differs | commands.set_panel_content("id", &markup) from any system, with markup being BSN source built at run time — a &str, not a Scene, since Scene holds a &'static str. Rides the generic service channel, so it does NOT move VERSION_MINOR. Safe to call every frame: the host compares before parsing, so unchanged markup costs a string comparison and no redraw. An unregistered id is reported and ignored (creating a panel needs &mut App, which a system has not got); malformed BSN keeps the panel that is on screen, as on hot reload. Action thunks are rebound on redraw, so PanelActionId numbers survive. |
| Panel action handler capturing state | missing | A plain fn or a non-capturing closure only: the handler is a const thunk rebuilt from nothing, so a capturing closure is a compile error. The descriptor does carry an opaque per-panel word plumbed end to end and handed back on every dispatch — add_panel simply passes null — so boxing a capture and reading it back is a plugin-crate change with no ABI move. Only FREEING it is unaddressed: there is no unregister hook, so a reload would leak the box. |
| Two-way binding a widget to plugin state | differs | EmberSliderWidget { value: bind(FlockSettings.cohesion), min: 0.0, max: 2.0 } — dragging never calls the plugin; the host reads and writes the resource bytes itself. Only f32, i32 and bool fields bind (an i32 models as a float and rounds on write); anything else, Vec3 and Quat included, is refused with an error naming the kind. Targets must be a registered RESOURCE — components are not addressable — and an ambiguous short name is an error listing the candidates, resolved by qualifying with the crate. Only top-level fields of a component body bind. One undocumented constraint: binding attaches to the widget entity's FIRST CHILD, so it only works on Ember widget components whose insert hook builds exactly that child — a bind on a plain Node has nothing correct to attach to. |
214 entries: 26 work identically, 93 differ, 61 are missing, 34 never will be.
Closer, but only in one layer. 26 of 214 rows are now character-identical Bevy (was 22), 93 differ, 61 are missing, 34 never. Nine of the fourteen upward moves are in systems, queries, maths and logging — with the read-only iterator projection, get/single/contains, Option<Res
Roadmap
The missing column above is a backlog, and this is the order it gets worked in. Ordered by how many rows each step deletes, not by how interesting it is.
ABI says what a step costs compatibility: none is plugin-side or host-side only and every built plugin keeps working; MINOR appends to the contract and older plugins keep working; MAJOR refuses every plugin built before it.
Shipped
| What it changed | ABI | |
|---|---|---|
| Interface table repaired | Two functions had been inserted mid-struct and recorded as appended, so a plugin built against 2.5–2.10 would have called the slot it compiled for and landed in a different function. No field order makes every historical MINOR correct, so all of them are refused by name. | MAJOR → 3.0 |
| Every offset-keyed table pinned | Interface, SystemCall, FrameCtx, CommandSink, MeshSource, ImageSource, HttpSource — field names and written types, so a signature change at an unchanged offset fails too. FrameCtx is included because SystemCall embeds it by value, which makes appending one float there an ABI break authored in a different struct. | none |
set_material | A plugin material can be applied to geometry the plugin did not create — an imported model, a shape the user authored. Before this, make_renderable set mesh, material and transform together, so a custom shader could only ever land on shapes the plugin spawned itself. | MINOR 2.12 |
| Custom material path made to work | Bind group corrected to @group(3); the plugin supplies a fragment only, so Bevy's mesh vertex stage keeps skinning and morph targets; unused texture slots bind a fallback so the bind group matches its layout; the prepass and shadow passes keep Bevy's own fragment. | none |
rotate_x/y/z corrected | They post-multiplied, so they silently were rotate_local_*. Copied Bevy source spun the wrong way once an object was tilted, with no diagnostic. rotate_local_* added with the old bodies. | none |
| Documented rules made real | Three rules the docs stated and nothing enforced are now compile errors: a component owning a String/Vec/Box is refused by the derive; Query::iter() yields a read-only projection so nested iteration cannot alias; and the four structs handed to plugins as a first-field pointer assert that the field is at offset zero. | none |
| Host-mirror data gate | Reading an engine component as data now requires the owning crate to expose it. Filtering is unrestricted. Without the gate a plugin could name any reflected component and be handed its raw bytes — including Window, which owns a String, and GlobalTransform, whose layout changes with the engine's SIMD backend. | none |
| Interface shape hash | The table now carries a hash of its own field list, checked at load. Appending is invisible to it; inserting, reordering or retyping refuses the plugin with a rebuild message instead of sending its call into a different function. Protects plugins built outside this repository, which the build-time order test cannot. | MINOR 3.13 |
| Boundary layouts pinned | 57 types and 200 fields in sys.rs — every repr(C) struct, every transparent newtype, and every function-pointer alias — diffed against a golden list generated by the same parser the test uses. A new type fails until someone records whether it crosses, because the failure mode of a curated list is omission. | none |
| Boundary-editing rule | Written into sys.rs's module docs, where someone about to edit it will see it: which types may be appended to, which are frozen outright, and why by-value embedding breaks structs in a different part of the file. | none |
| Two shipped bugs fixed | The host-data allowlist could never match in a release build (it compared a debug-only placeholder string), and every animation command copied three uninitialised bytes into host storage. | none |
| Payloads checked exactly | The domain bridges accepted any payload at least the right size, so a reordered command passed and was misread silently. Now exact. | none |
| Frozen values pinned | Key's numbering, the SERVICE hashes and the inline capacities are compiled into every shipped plugin, and no layout test can see them. | none |
| Reads and writes separated | Writing a host mirror needs its own opt-in — a mirror larger than the host type writes past its staging row, where reading one merely returns wrong bytes. | none |
| Maths surface | Quat::inverse/normalize/from_euler/slerp/from_basis, Transform::looking_at/transform_point, and Vec2 and Color which did not exist at all. Vec3 already had operators and the common methods — plugins/hair was duplicating them, and now delegates. | none |
| Syntax parity sweep | Tuple arities raised to Bevy's — query data and filters to 15, system params and add_systems to 16, bundles to 15, Or to 15. add_systems(Update, (a, b, c)) compiles. info!/warn!/error! exist as macros alongside the functions. Query::single/single_mut/get/get_mut/contains. Option<Res<T>> and Option<ResMut<T>>. | none |
Added<T> / Changed<T> (4.2) | Deletes the hand-rolled signature hashing in hair and text3d and ripple's _ready flag. QueryBuilder has no tick dimension, so the host tests ticks per row — which is what Bevy does too: its own Added/Changed are non-archetypal, so there is no whole-table skip being given up. Refused inside Or, where an empty branch would match every entity in the world. | MINOR |
| Crossing enums newtyped (4.0) | SystemStatus, RegisterStatus and InitResult were real enums whose values cross the boundary, which makes an out-of-range value undefined behaviour. Wire-identical fix; the compiler now forces the host to handle a value from a newer plugin. | MAJOR |
| Field offsets bounds-checked | A field claiming an offset past the end of its component is dropped at registration, and two inspector paths no longer read an unsizable field at a guessed four-byte width. | none |
| Contract tidied | Stale test references, an orphaned attribute asserting an invariant on the wrong type, one aligned read where every sibling reads unaligned, and const-asserts tying renzora_bsn's hardcoded layout literals to the definitions they describe. | none |
| Textures, geometry read/write, strings, HTTP, physics | See the version history in Standalone Plugins. | MINOR 2.5–2.11 |
Then — deletes whole classes of workaround
| What it unlocks | Size | ABI | |
|---|---|---|---|
RemovedComponents by ComponentId | Deletes the per-frame liveness sweep in every plugin that owns anything. Both shipped plugins hand-roll one. The param has empty access declarations, so it can never conflict. | medium | MINOR |
| Reflective field patch | The complement to BSN insert, which replaces rather than patches. The only way to nudge one field of a live Camera or PointLight without resetting the rest. Must refuse immutable and relationship components. | ~80 lines | MINOR |
| Capturing closures as systems | Recovers Commands::queue, add_observer, entity.observe, and on(|ev| …) in BSN. The per-system token already round-trips through the ABI unused — the refusal is a const assert in the ergonomic layer, not a boundary constraint. | medium | none |
| Syntax-parity sweep | Tuple arities to Bevy's 15/16, add_systems(Update, (a, b)), info!/warn! as macros, Query::single/get/contains, Option<Res<T>>, Local<T>, and the missing maths (Vec2/Vec4/Color/Dir3, Quat::from_euler, looking_at). This is where "zero syntax changes" is won or lost in an author's first hour, and none of it touches the ABI. | medium | none |
| Deferred asset creation | Deletes the fixed-pool idiom — hair reserves 16 meshes, text3d 64, each with a slot index stored on the component to survive reload. The pattern already exists for entities. | medium | MINOR |
| Run conditions, ordering, system sets | SystemDesc.flags is reserved and enforced-zero for exactly this. Ordering between plugin systems is tractable; ordering against host sets needs a curated published map, because the set interner is private. | large | MINOR |
| Schedules by name | Startup, FixedUpdate and the rest. Note Startup is a trap under hot reload — a reloaded plugin's Startup system would never fire, since reload happens mid-frame. | small | MINOR |
| Per-entity plugin materials | Today the settings component is read from the first entity carrying it anywhere in the world and broadcast, so two entities using one plugin material always look the same. | one file | none |
| Reflective mirroring | Read any registered host component by type path with no hand-written #[repr(C)] mirror. Kills the mirror-layout hazard permanently instead of guarding it. Placed last deliberately: the largest contained piece of work here, and the steps above deliver most of the practical value first. | large | MINOR |
Not planned
The 38 never rows, and two things that look like natural next steps and are not:
- Reflected function calls — "call any engine function by name". Three independent
blockers: the feature is not compiled, the registry is created empty with no call sites
anywhere in Bevy, and
Worlddoes not implementReflectso no function taking it could be registered even if it were. - Direct pointers into component storage. Would save the per-entity copy, but loses the comparison against a baseline that stops every plugin system marking everything it merely read as changed. The real cost is the per-cell allocation, which is fixable without this.