Skip to main content

bevy_brink/bindings/
registration.rs

1//! Registration API: how engine code declares callable ink→engine bindings.
2//!
3//! [`BrinkBindings`] is the registry `Resource`; [`BrinkBindingsAppExt`] is
4//! the `App`-level surface authors call at app-build time
5//! (`bind_brink_fn`/`bind_brink_command`/`bind_brink_query`/`bind_brink_async`/
6//! `bind_brink_task`); [`BrinkHandler`] is the [`ExternalFnHandler`] built
7//! from the registry and handed to a flow's step methods during normal
8//! playback. See the parent module's docs (`crate::bindings`) for the
9//! conceptual overview of the three synchronous binding kinds.
10
11use std::cell::RefCell;
12use std::collections::HashMap;
13use std::future::Future;
14use std::marker::PhantomData;
15use std::pin::Pin;
16
17use bevy_app::App;
18use bevy_ecs::entity::Entity;
19use bevy_ecs::event::Event;
20#[cfg(feature = "effect-trace")]
21use bevy_ecs::query::Access;
22use bevy_ecs::resource::Resource;
23#[cfg(feature = "effect-trace")]
24use bevy_ecs::system::BoxedSystem;
25use bevy_ecs::system::{Commands, In, IntoSystem, SystemId};
26use bevy_ecs::world::World;
27use bevy_log::warn;
28use brink_format::Value;
29use brink_runtime::{ExternalFnHandler, ExternalResult};
30use thiserror::Error;
31
32/// Input type for a world-access (`bind_brink_query`) binding system: the
33/// flow entity that triggered the call, plus the ink arguments.
34pub type BrinkQueryInput = (Entity, Vec<Value>);
35
36/// The [`SystemId`] of a registered query binding — a Bevy system taking
37/// [`BrinkQueryInput`] and returning a [`Value`].
38///
39/// `pub(crate)` so the drive/eval API (`super::drive`) can resolve a pending
40/// query external's [`SystemId`] and run it via `run_system_with`.
41pub(crate) type QuerySystemId = SystemId<In<BrinkQueryInput>, Value>;
42
43/// Error produced when ink arguments can't be parsed into a binding's
44/// expected shape. Returned by [`BrinkCommand::from_ink_args`].
45#[derive(Debug, Error, Clone, PartialEq, Eq)]
46pub enum BrinkArgError {
47    /// Wrong number of arguments.
48    #[error("expected {expected} argument(s), got {got}")]
49    Count {
50        /// How many arguments the binding declared.
51        expected: usize,
52        /// How many ink actually passed.
53        got: usize,
54    },
55    /// An argument had the wrong runtime type.
56    #[error("argument {index}: expected {expected}")]
57    Type {
58        /// Zero-based argument position.
59        index: usize,
60        /// The type the binding expected (e.g. `"int"`, `"string"`).
61        expected: &'static str,
62    },
63}
64
65/// A Bevy [`Event`] that can be built from an ink external call's
66/// arguments, for use with [`bind_brink_command`](BrinkBindingsAppExt::bind_brink_command).
67///
68/// Implement (or `#[derive(BrinkCommand)]`) this for the event your
69/// binding fires. The derive generates [`from_ink_args`](Self::from_ink_args)
70/// for structs whose fields are `i32`, `f32`, `bool`, or `String`. To
71/// return a value to ink, hand-implement the trait and override
72/// [`reply`](Self::reply).
73pub trait BrinkCommand: Sized {
74    /// Parse the ink call's arguments (in declaration order) into `Self`.
75    fn from_ink_args(args: &[Value]) -> Result<Self, BrinkArgError>;
76
77    /// The value handed back to ink as this external's return value.
78    ///
79    /// Defaults to [`Value::Null`] — the natural "fire-and-forget, no
80    /// return" behavior. Override to feed a computed value back into the
81    /// story (e.g. a dice roll).
82    fn reply(&self) -> Value {
83        Value::Null
84    }
85}
86
87// Type aliases for the boxed registry entries.
88type PureFn = Box<dyn Fn(&[Value]) -> Value + Send + Sync>;
89type CommandFn = Box<dyn Fn(&[Value]) -> Result<QueuedCommand, BrinkArgError> + Send + Sync>;
90/// Factory for a [`bind_brink_task`](BrinkBindingsAppExt::bind_brink_task)
91/// future: given the ink args, produce a boxed `Send + 'static` future that
92/// computes the external's return value off the main thread.
93///
94/// `pub(crate)` so the drive/eval API (`super::drive`) can spawn the factory's
95/// future when a parked flow's pending external is a `bind_brink_task`.
96pub(crate) type TaskFn =
97    Box<dyn Fn(Vec<Value>) -> Pin<Box<dyn Future<Output = Value> + Send>> + Send + Sync>;
98
99/// How an async (defer-across-frames) external resolves once a flow parks on
100/// it. Stored in [`BrinkBindings::async_bindings`].
101///
102/// `pub(crate)` so `super::drive`'s dispatcher can match on the binding kind
103/// (fire an event vs. spawn a task) for a parked flow's pending external.
104pub(crate) enum AsyncKind {
105    /// `bind_brink_async`: fire `BrinkExternalAwaited` and wait for the
106    /// engine to call `resolve_brink_external`.
107    Event,
108    /// `bind_brink_task`: spawn the future on the async task pool and resolve
109    /// with its output when it completes.
110    Task(TaskFn),
111}
112/// A deferred World mutation that triggers a parsed command event. Boxed
113/// so heterogeneous command types share one buffer; run during flush.
114///
115/// `pub(crate)` so the batch driver ([`crate::batch`]) can hold a flow's
116/// buffered command triggers across the batch's Step phase and replay them
117/// in deterministic flow-id order at Apply (`docs/effects-spec.md` §12.4),
118/// rather than flushing each flow's commands immediately as the serial API
119/// does.
120pub(crate) type TriggerFn = Box<dyn FnOnce(&mut World) + Send>;
121
122/// A parsed command ready to be triggered against the World, plus the
123/// value to return to ink.
124struct QueuedCommand {
125    /// Triggers the parsed event when run against the World.
126    trigger: TriggerFn,
127    /// Value returned to ink (usually [`Value::Null`]).
128    reply: Value,
129}
130
131/// Registry of synchronous ink→engine bindings for story marker `M`.
132///
133/// A `Resource`. Populate it at app-build time with
134/// [`bind_brink_fn`](BrinkBindingsAppExt::bind_brink_fn) and
135/// [`bind_brink_command`](BrinkBindingsAppExt::bind_brink_command), then,
136/// in the flow-driving system, call [`handler`](Self::handler) to get a
137/// [`BrinkHandler`] to pass to the flow's step methods.
138#[derive(Resource)]
139pub struct BrinkBindings<M: Send + Sync + 'static = ()> {
140    pure: HashMap<String, PureFn>,
141    commands: HashMap<String, CommandFn>,
142    queries: HashMap<String, QuerySystemId>,
143    /// Each query binding's real, bevy-declared [`Access`], captured once at
144    /// `bind_brink_query` registration time (issue #938's host-side
145    /// ground-truth check — see `crate::ground_truth`'s module docs for why
146    /// registration time is the right moment: bevy's own component access
147    /// is static, so it never varies dispatch to dispatch).
148    #[cfg(feature = "effect-trace")]
149    query_access: HashMap<String, Access>,
150    /// Async (defer-across-frames) bindings: `bind_brink_async` (event) and
151    /// `bind_brink_task` (detached task). A flow pauses on these and resolves
152    /// out-of-band.
153    ///
154    /// `pub(crate)` so `super::drive`'s dispatcher can read a pending
155    /// external's binding kind directly.
156    pub(crate) async_bindings: HashMap<String, AsyncKind>,
157    _marker: PhantomData<fn() -> M>,
158}
159
160impl<M: Send + Sync + 'static> Default for BrinkBindings<M> {
161    fn default() -> Self {
162        Self {
163            pure: HashMap::new(),
164            commands: HashMap::new(),
165            queries: HashMap::new(),
166            #[cfg(feature = "effect-trace")]
167            query_access: HashMap::new(),
168            async_bindings: HashMap::new(),
169            _marker: PhantomData,
170        }
171    }
172}
173
174impl<M: Send + Sync + 'static> BrinkBindings<M> {
175    /// Build a [`BrinkHandler`] borrowing this registry. Pass `&handler`
176    /// to a flow's step method, then call [`BrinkHandler::flush`] to emit
177    /// any buffered command events.
178    ///
179    /// Query bindings (which need World access) yield
180    /// [`ExternalResult::Pending`] so a flow pauses on them; the plugin's
181    /// resolver (or [`advance_flow`](crate::advance_flow)) runs the query
182    /// against the World and resumes.
183    #[must_use]
184    pub fn handler(&self) -> BrinkHandler<'_, M> {
185        BrinkHandler {
186            bindings: self,
187            queued: RefCell::new(Vec::new()),
188        }
189    }
190
191    /// The [`SystemId`] of the query binding registered under `name`, if any.
192    ///
193    /// `pub(crate)` so `super::drive` can resolve a pending query external's
194    /// system to run via `run_system_with`.
195    pub(crate) fn query(&self, name: &str) -> Option<QuerySystemId> {
196        self.queries.get(name).copied()
197    }
198
199    /// The real bevy [`Access`] a `bind_brink_query` binding's system
200    /// carries, if `name` was registered while the `effect-trace` feature
201    /// was enabled (see [`crate::ground_truth`]'s `check` function).
202    #[cfg(feature = "effect-trace")]
203    pub(crate) fn query_access(&self, name: &str) -> Option<&Access> {
204        self.query_access.get(name)
205    }
206
207    /// `true` iff `name` is registered as a
208    /// [`bind_brink_command`](BrinkBindingsAppExt::bind_brink_command)
209    /// binding — consulted by `crate::sleep`'s wake-condition purity gate
210    /// (issue #1609): a command binding mutates the World when its parsed
211    /// event is triggered, so a wake condition reaching one is impure
212    /// regardless of whether a [`CapabilityManifest`](crate::capability::CapabilityManifest)
213    /// entry exists for it. `bevy-brink` knows the binding kind locally —
214    /// this needs no manifest entry to answer.
215    #[must_use]
216    pub(crate) fn is_command(&self, name: &str) -> bool {
217        self.commands.contains_key(name)
218    }
219}
220
221/// An [`ExternalFnHandler`] backed by a [`BrinkBindings`] registry.
222///
223/// Resolves pure-function bindings inline and buffers command-event
224/// triggers (it has no World access mid-step). After stepping, call
225/// [`flush`](Self::flush) to drain the buffered triggers into a
226/// [`Commands`] queue. Unknown names fall through to
227/// [`ExternalResult::Fallback`] so the in-story fallback body (if any)
228/// runs.
229pub struct BrinkHandler<'a, M: Send + Sync + 'static = ()> {
230    bindings: &'a BrinkBindings<M>,
231    /// `pub(crate)` so the `bindings::tests` module can assert on the raw
232    /// buffer in cases where `queued_len`/`take_queued` don't fit (draining
233    /// it directly to apply the triggers).
234    pub(crate) queued: RefCell<Vec<TriggerFn>>,
235}
236
237impl<M: Send + Sync + 'static> BrinkHandler<'_, M> {
238    /// Drain buffered command-event triggers into `commands`. Call once
239    /// after the flow's step method returns (the borrow of `self` taken
240    /// by stepping has ended by then). Consumes the handler.
241    pub fn flush(self, commands: &mut Commands) {
242        for trigger in self.queued.into_inner() {
243            commands.queue(trigger);
244        }
245    }
246
247    /// Take the buffered command-event triggers, leaving the handler empty.
248    /// Used by [`advance_flow`](crate::advance_flow) to accumulate triggers
249    /// across the suspensions of a single line, and by the batch driver
250    /// ([`crate::batch`]) to move a flow's buffered command triggers into
251    /// its per-flow batch outcome for deterministic flow-id-ordered replay
252    /// at Apply.
253    pub(crate) fn take_queued(&self) -> Vec<TriggerFn> {
254        std::mem::take(&mut self.queued.borrow_mut())
255    }
256
257    /// Number of command triggers buffered so far (for tests/diagnostics).
258    #[must_use]
259    pub fn queued_len(&self) -> usize {
260        self.queued.borrow().len()
261    }
262}
263
264/// Resolve `name` against `bindings`, the shared body of
265/// [`BrinkHandler::call`] and [`EvalHandler::call`].
266///
267/// Factored out so the two handlers can never drift the way they did before
268/// issue #1096: `EvalHandler::call` originally only resolved pure and
269/// query/async bindings, missing the `commands` bucket `BrinkHandler::call`
270/// already handled, so a `bind_brink_command`-bound external reached through
271/// an engine→ink call silently fell through to [`ExternalResult::Fallback`].
272/// A binding kind added to one handler and missed in the other is exactly
273/// that drift; with one shared body, it can't happen again.
274///
275/// A pure binding resolves inline. A command binding parses its args and, on
276/// success, buffers the parsed trigger into `queued` (both handlers hold no
277/// World access mid-step, so the trigger fires later — via
278/// [`BrinkHandler::flush`] for normal playback, or `super::drive`'s
279/// `flush_eval_triggers` for an engine→ink call) and resolves with the
280/// binding's reply; on a parse failure it warns and resolves `Null` without
281/// buffering. A world-access query or async binding yields
282/// [`ExternalResult::Pending`] so the caller's driver can pause and resolve
283/// it out-of-band (a query via `run_system_with` between suspensions; an
284/// async binding via the plugin's resolver on the `step_one` path, or an
285/// error from the one-pass exclusive drivers, which can't await it). An
286/// unregistered name falls back to the in-story body, if any.
287fn resolve_binding<M: Send + Sync + 'static>(
288    bindings: &BrinkBindings<M>,
289    queued: &RefCell<Vec<TriggerFn>>,
290    name: &str,
291    args: &[Value],
292) -> ExternalResult {
293    if let Some(f) = bindings.pure.get(name) {
294        return ExternalResult::Resolved(f(args));
295    }
296    if let Some(parse) = bindings.commands.get(name) {
297        return match parse(args) {
298            Ok(queued_cmd) => {
299                queued.borrow_mut().push(queued_cmd.trigger);
300                ExternalResult::Resolved(queued_cmd.reply)
301            }
302            Err(err) => {
303                warn!("brink command '{name}': {err}; emitting nothing, returning null");
304                ExternalResult::Resolved(Value::Null)
305            }
306        };
307    }
308    if bindings.queries.contains_key(name) || bindings.async_bindings.contains_key(name) {
309        return ExternalResult::Pending;
310    }
311    ExternalResult::Fallback
312}
313
314impl<M: Send + Sync + 'static> ExternalFnHandler for BrinkHandler<'_, M> {
315    fn call(&self, name: &str, args: &[Value]) -> ExternalResult {
316        resolve_binding(self.bindings, &self.queued, name, args)
317    }
318}
319
320impl<M: Send + Sync + 'static> BrinkBindings<M> {
321    /// Build an [`EvalHandler`] for an engine→ink call. Pure bindings
322    /// resolve inline; command bindings buffer a trigger (mirroring
323    /// [`BrinkHandler`]) that `super::drive`'s exclusive driver fires against
324    /// the World once the call completes; query bindings yield
325    /// [`ExternalResult::Pending`] so the driver can run them against the
326    /// World between suspensions; everything else falls back to the
327    /// in-story body.
328    ///
329    /// `pub(crate)` so `super::drive`'s exclusive eval driver can build one.
330    pub(crate) fn eval_handler(&self) -> EvalHandler<'_, M> {
331        EvalHandler {
332            bindings: self,
333            queued: RefCell::new(Vec::new()),
334        }
335    }
336}
337
338/// Handler used while evaluating an ink function from engine code
339/// (`super::drive::call_ink_function`). Unlike [`BrinkHandler`], it cannot
340/// touch the World mid-step — a `bind_brink_command`-bound external's trigger
341/// is buffered here (see [`take_queued`](Self::take_queued)) exactly as
342/// [`BrinkHandler`] buffers it, and the exclusive driver fires the buffered
343/// triggers against the World once the call completes (issue #1096: a
344/// command binding reached through this path used to fall through to
345/// [`ExternalResult::Fallback`] — the same silent no-op an *unbound* external
346/// takes — instead of firing its event). World-access (query) bindings are
347/// still deferred to the driver via [`ExternalResult::Pending`].
348///
349/// `pub(crate)` — this is `super::drive::call_ink_function`'s handler type,
350/// returned from [`BrinkBindings::eval_handler`].
351pub(crate) struct EvalHandler<'a, M: Send + Sync + 'static> {
352    bindings: &'a BrinkBindings<M>,
353    /// Command-event triggers queued during this evaluation pass. Can't be
354    /// fired here (no World access mid-step) — the driver drains this via
355    /// [`take_queued`](Self::take_queued) after each begin/resume step and
356    /// fires the triggers directly against the World once the call as a
357    /// whole completes, the same buffer-then-flush shape [`BrinkHandler`]
358    /// uses for normal playback.
359    queued: RefCell<Vec<TriggerFn>>,
360}
361
362impl<M: Send + Sync + 'static> EvalHandler<'_, M> {
363    /// Take the buffered command-event triggers, leaving the handler empty.
364    /// Called by `super::drive` after each `begin_function_eval`/
365    /// `resume_function_eval` step, since a fresh [`EvalHandler`] is built
366    /// per re-borrow of the World.
367    pub(crate) fn take_queued(&self) -> Vec<TriggerFn> {
368        std::mem::take(&mut self.queued.borrow_mut())
369    }
370}
371
372impl<M: Send + Sync + 'static> ExternalFnHandler for EvalHandler<'_, M> {
373    fn call(&self, name: &str, args: &[Value]) -> ExternalResult {
374        resolve_binding(self.bindings, &self.queued, name, args)
375    }
376}
377
378/// App-extension verbs for registering synchronous ink→engine bindings.
379///
380/// Both verbs take the story marker `M` as the first explicit type
381/// parameter (use `()` for the default single-story case). They insert
382/// into the [`BrinkBindings<M>`] resource, creating it on first use.
383pub trait BrinkBindingsAppExt {
384    /// Register a **pure** binding: a side-effect-free function of the ink
385    /// arguments that returns a value to the story. Resolved inline while
386    /// the VM steps — no World access, no latency.
387    ///
388    /// The return type is anything `Into<Value>`, so primitives work
389    /// directly: `|args| 1.5_f32`, `|args| count as i32`, etc.
390    fn bind_brink_fn<M, F, R>(&mut self, name: impl Into<String>, f: F) -> &mut Self
391    where
392        M: Send + Sync + 'static,
393        F: Fn(&[Value]) -> R + Send + Sync + 'static,
394        R: Into<Value>;
395
396    /// Register a **command** binding: parse the ink arguments into a Bevy
397    /// [`Event`] and trigger it (fire-and-forget). The event is buffered
398    /// during stepping and emitted when the handler is flushed. The story
399    /// receives [`BrinkCommand::reply`] as the call's return value
400    /// (`Value::Null` by default).
401    ///
402    /// `E` should be a plain `#[derive(Event)]` (a global observer event):
403    /// react to it with `app.add_observer(|on: On<E>| { … })`.
404    fn bind_brink_command<M, E>(&mut self, name: impl Into<String>) -> &mut Self
405    where
406        M: Send + Sync + 'static,
407        E: Event + BrinkCommand,
408        for<'a> <E as Event>::Trigger<'a>: Default;
409
410    /// Register a **query** binding: a Bevy system with arbitrary
411    /// `SystemParam`s that reads the World and returns a [`Value`] to the
412    /// story. The system takes [`BrinkQueryInput`] — the flow [`Entity`]
413    /// that triggered the call plus the ink arguments.
414    ///
415    /// Resolving a query needs World access, so it can't run inline while
416    /// the VM steps. Engine→ink calls (`super::drive::call_ink_function`)
417    /// drive it via `run_system_with` between suspensions; the binding can
418    /// therefore query anything in the World, with no upfront declaration.
419    ///
420    /// ```no_run
421    /// # use bevy_app::App;
422    /// # use bevy_ecs::component::Component;
423    /// # use bevy_ecs::system::{In, Query};
424    /// # use bevy_brink::{BrinkBindingsAppExt, BrinkQueryInput, Value};
425    /// # #[derive(Component)]
426    /// # struct Enemy;
427    /// # let mut app = App::new();
428    /// fn enemy_count(In((_e, _args)): In<BrinkQueryInput>, q: Query<&Enemy>) -> Value {
429    ///     Value::Int(q.iter().count() as i32)
430    /// }
431    /// app.bind_brink_query::<(), _, _>("enemy_count", enemy_count);
432    /// ```
433    fn bind_brink_query<M, S, SM>(&mut self, name: impl Into<String>, system: S) -> &mut Self
434    where
435        M: Send + Sync + 'static,
436        S: IntoSystem<In<BrinkQueryInput>, Value, SM> + 'static;
437
438    /// Register an **async (event) primitive** binding: when ink calls the
439    /// external, the flow *parks* and
440    /// [`BrinkExternalAwaited`](crate::BrinkExternalAwaited) fires (once) at
441    /// the flow entity. The engine does whatever multi-frame work the external
442    /// represents (UI, input, world state) and resolves with
443    /// [`resolve_brink_external`](crate::BrinkResolveExternalExt::resolve_brink_external).
444    /// Use this when the value can't be produced in one pass and needs World
445    /// access over several frames.
446    ///
447    /// Only usable on the `step_one` playback path — the one-pass exclusive
448    /// drivers (`super::drive::advance_flow`/`super::drive::call_ink_function`)
449    /// return [`BrinkCallError::AsyncExternalUnsupported`](crate::BrinkCallError::AsyncExternalUnsupported)
450    /// on an async external.
451    fn bind_brink_async<M>(&mut self, name: impl Into<String>) -> &mut Self
452    where
453        M: Send + Sync + 'static;
454
455    /// Register an **async task** binding: when ink calls the external,
456    /// bevy-brink spawns `f(args)` on [`bevy_tasks::AsyncComputeTaskPool`] and
457    /// resolves the flow's external with the future's output once it completes
458    /// (polled each frame by [`poll_brink_tasks`](crate::poll_brink_tasks)).
459    ///
460    /// The future is `Send + 'static` and runs off the main thread, so it
461    /// **cannot access the World** — it computes from the ink arguments only
462    /// (heavy compute, IO, network). For World-dependent async, use
463    /// [`bind_brink_async`](Self::bind_brink_async).
464    ///
465    /// ```no_run
466    /// # use bevy_app::App;
467    /// # use bevy_brink::{BrinkBindingsAppExt, Value};
468    /// # async fn compute_roll(n: i32) -> i32 { n }
469    /// # let mut app = App::new();
470    /// app.bind_brink_task::<(), _, _>("expensive_roll", |args| async move {
471    ///     let n = args.first().and_then(Value::as_int).unwrap_or(1);
472    ///     Value::Int(compute_roll(n).await)
473    /// });
474    /// ```
475    fn bind_brink_task<M, F, Fut>(&mut self, name: impl Into<String>, f: F) -> &mut Self
476    where
477        M: Send + Sync + 'static,
478        F: Fn(Vec<Value>) -> Fut + Send + Sync + 'static,
479        Fut: Future<Output = Value> + Send + 'static;
480}
481
482impl BrinkBindingsAppExt for App {
483    fn bind_brink_fn<M, F, R>(&mut self, name: impl Into<String>, f: F) -> &mut Self
484    where
485        M: Send + Sync + 'static,
486        F: Fn(&[Value]) -> R + Send + Sync + 'static,
487        R: Into<Value>,
488    {
489        let name = name.into();
490        {
491            let mut reg = self
492                .world_mut()
493                .get_resource_or_insert_with(BrinkBindings::<M>::default);
494            reg.pure.insert(name, Box::new(move |args| f(args).into()));
495        }
496        self
497    }
498
499    fn bind_brink_command<M, E>(&mut self, name: impl Into<String>) -> &mut Self
500    where
501        M: Send + Sync + 'static,
502        E: Event + BrinkCommand,
503        for<'a> <E as Event>::Trigger<'a>: Default,
504    {
505        let name = name.into();
506        {
507            let mut reg = self
508                .world_mut()
509                .get_resource_or_insert_with(BrinkBindings::<M>::default);
510            reg.commands.insert(
511                name,
512                Box::new(move |args: &[Value]| {
513                    let event = E::from_ink_args(args)?;
514                    let reply = event.reply();
515                    Ok(QueuedCommand {
516                        trigger: Box::new(move |world: &mut World| {
517                            world.trigger(event);
518                        }),
519                        reply,
520                    })
521                }),
522            );
523        }
524        self
525    }
526
527    fn bind_brink_query<M, S, SM>(&mut self, name: impl Into<String>, system: S) -> &mut Self
528    where
529        M: Send + Sync + 'static,
530        S: IntoSystem<In<BrinkQueryInput>, Value, SM> + 'static,
531    {
532        let name = name.into();
533        // With `effect-trace`, capture the system's real bevy-declared
534        // `Access` once, at registration — bevy's own component access is
535        // static (a `Query<&Foo>` declares the same access whether or not it
536        // ever matches an entity), so this is the exact ground truth
537        // `crate::ground_truth::check` later compares real dispatches
538        // against. `register_boxed_system` (rather than `register_system`)
539        // lets us call `System::initialize` ourselves first without needing
540        // `S: Clone`; the already-initialized boxed system is then handed to
541        // bevy exactly as `register_system` would have built it, so the
542        // registered binding behaves identically either way.
543        #[cfg(feature = "effect-trace")]
544        let (id, access) = {
545            let mut boxed: BoxedSystem<In<BrinkQueryInput>, Value> =
546                Box::new(IntoSystem::into_system(system));
547            let access = boxed.initialize(self.world_mut()).combined_access().clone();
548            let id = self.world_mut().register_boxed_system(boxed);
549            (id, access)
550        };
551        #[cfg(not(feature = "effect-trace"))]
552        let id = self.world_mut().register_system(system);
553        {
554            let mut reg = self
555                .world_mut()
556                .get_resource_or_insert_with(BrinkBindings::<M>::default);
557            #[cfg(feature = "effect-trace")]
558            reg.query_access.insert(name.clone(), access);
559            reg.queries.insert(name, id);
560        }
561        self
562    }
563
564    fn bind_brink_async<M>(&mut self, name: impl Into<String>) -> &mut Self
565    where
566        M: Send + Sync + 'static,
567    {
568        let name = name.into();
569        {
570            let mut reg = self
571                .world_mut()
572                .get_resource_or_insert_with(BrinkBindings::<M>::default);
573            reg.async_bindings.insert(name, AsyncKind::Event);
574        }
575        self
576    }
577
578    fn bind_brink_task<M, F, Fut>(&mut self, name: impl Into<String>, f: F) -> &mut Self
579    where
580        M: Send + Sync + 'static,
581        F: Fn(Vec<Value>) -> Fut + Send + Sync + 'static,
582        Fut: Future<Output = Value> + Send + 'static,
583    {
584        let name = name.into();
585        let factory: TaskFn = Box::new(move |args| Box::pin(f(args)));
586        {
587            let mut reg = self
588                .world_mut()
589                .get_resource_or_insert_with(BrinkBindings::<M>::default);
590            reg.async_bindings.insert(name, AsyncKind::Task(factory));
591        }
592        self
593    }
594}