bevy_brink/bindings/drive.rs
1//! Drive/eval API: the systems that actually step flows and resolve pending
2//! externals against the World.
3//!
4//! Two exclusive (`&mut World`) drivers live here: [`call_ink_function`] (and
5//! its batch/function-value siblings) evaluates an ink function from engine
6//! code without touching the visible story; [`advance_flow`] advances a
7//! flow's playback by one line, resolving world-access query bindings inline.
8//! [`resolve_pending_externals`] is the plugin system that services flows
9//! parked on a pending external during normal (non-exclusive) `step_one`
10//! playback. See the parent module's docs (`crate::bindings`) for the
11//! conceptual overview of the three synchronous binding kinds plus the async
12//! ones these drivers hand off.
13
14use std::future::Future;
15use std::pin::Pin;
16
17use bevy_asset::Assets;
18use bevy_ecs::entity::Entity;
19use bevy_ecs::system::{Query, Res, ResMut, SystemState};
20use bevy_ecs::world::World;
21use bevy_log::warn;
22use bevy_tasks::{AsyncComputeTaskPool, TaskPool};
23use brink_format::Value;
24use brink_runtime::{FastRng, FlowInstance, Program, RuntimeError, Step, StepOutcome};
25#[cfg(feature = "dev")]
26use brink_runtime::{RecordingHandler, ReplayRecorder};
27use thiserror::Error;
28
29use crate::asset::{BrinkProgram, LineTablesAsset, ProgramAsset};
30use crate::async_bind::{BrinkAwaiting, BrinkExternalAwaited, BrinkPendingTask};
31use crate::flow::BrinkFlow;
32use crate::globals::BrinkContext;
33use crate::line_tables::BrinkLocale;
34
35use super::registration::{AsyncKind, BrinkBindings, BrinkHandler, QuerySystemId, TriggerFn};
36
37/// Errors from an engine→ink call ([`call_ink_function`]).
38#[derive(Debug, Error)]
39pub enum BrinkCallError {
40 /// The entity isn't a fulfilled flow (missing `BrinkFlow`/`BrinkProgram`/
41 /// `BrinkLocale`/`BrinkContext`).
42 #[error("entity is not a fulfilled brink flow")]
43 NotAFlow,
44 /// The flow's program asset isn't loaded.
45 #[error("program asset not loaded")]
46 ProgramNotLoaded,
47 /// The flow's line-tables asset isn't loaded.
48 #[error("line tables asset not loaded")]
49 LineTablesNotLoaded,
50 /// No function with this name exists in the program.
51 #[error("function '{0}' not found")]
52 FunctionNotFound(String),
53 /// The function called a world-access external with no registered
54 /// query binding (and no in-story fallback).
55 #[error("no query binding registered for external '{0}'")]
56 UnknownQuery(String),
57 /// The function called an **async** external (`bind_brink_async` /
58 /// `bind_brink_task`), which can't resolve in a single `&mut World` pass.
59 /// Drive such stories via the `step_one` playback path + the plugin's
60 /// `resolve_pending_externals` resolver instead.
61 #[error("external '{0}' is async; drive the flow via step_one, not the exclusive driver")]
62 AsyncExternalUnsupported(String),
63 /// A query binding's system failed to run.
64 #[error("query binding system failed: {0}")]
65 QueryFailed(String),
66 /// The `SystemState` snapshot of flow components + assets + bindings
67 /// failed to validate against the world (e.g. a required resource,
68 /// like `Assets<ProgramAsset>`, isn't present — the plugin wasn't added).
69 #[error("system param validation failed: {0}")]
70 SystemParamInvalid(String),
71 /// The runtime raised an error during evaluation.
72 #[error(transparent)]
73 Runtime(#[from] RuntimeError),
74}
75
76/// The next thing the [`call_ink_function`] driver must do.
77enum NextStep {
78 /// The function returned this value — evaluation is complete.
79 Done(Value),
80 /// The function is awaiting a world-access query; run this system with
81 /// the given ink args, resolve, and resume.
82 RunQuery {
83 system: QuerySystemId,
84 qargs: Vec<Value>,
85 },
86}
87
88/// The `SystemState` param bundle the engine→ink eval driver re-borrows each
89/// suspension: the flow components, the (optional) globals resource, the two
90/// asset stores, and the bindings registry. Factored out so
91/// [`call_ink_function`] (by name) and [`call_ink_function_value`] (by opaque
92/// function-value token) share one driver.
93type EvalSystemState<M> = SystemState<(
94 Query<
95 'static,
96 'static,
97 (
98 &'static BrinkProgram<M>,
99 &'static BrinkLocale<M>,
100 &'static mut BrinkFlow<M>,
101 &'static mut BrinkContext<M>,
102 ),
103 >,
104 Option<ResMut<'static, crate::BrinkGlobals<M>>>,
105 Res<'static, Assets<ProgramAsset>>,
106 Res<'static, Assets<LineTablesAsset>>,
107 Res<'static, BrinkBindings<M>>,
108)>;
109
110/// Drive an in-progress function evaluation to completion: run each pending
111/// world-access query against the World (borrows released between calls),
112/// resolve it, and resume — until the function returns its value. Shared by
113/// [`call_ink_function`] and [`call_ink_function_value`]; the only difference
114/// between the two callers is how the evaluation *begins* (by name vs by
115/// opaque function-value token), which produces the initial `NextStep`.
116fn drive_function_eval_to_done<M: Send + Sync + 'static>(
117 world: &mut World,
118 entity: Entity,
119 state: &mut EvalSystemState<M>,
120 mut next: NextStep,
121 triggers: &mut Vec<TriggerFn>,
122) -> Result<Value, BrinkCallError> {
123 loop {
124 match next {
125 NextStep::Done(value) => return Ok(value),
126 NextStep::RunQuery { system, qargs } => {
127 let value = world
128 .run_system_with(system, (entity, qargs))
129 .map_err(|e| BrinkCallError::QueryFailed(format!("{e:?}")))?;
130 next = {
131 let (mut flows, globals, programs, tables, bindings) = state
132 .get_mut(world)
133 .map_err(|e| BrinkCallError::SystemParamInvalid(e.to_string()))?;
134 let mut globals = globals.ok_or(BrinkCallError::NotAFlow)?;
135 let (prog_c, loc_c, mut flow, mut ctx) = flows
136 .get_mut(entity)
137 .map_err(|_| BrinkCallError::NotAFlow)?;
138 let program = &programs
139 .get(&prog_c.handle)
140 .ok_or(BrinkCallError::ProgramNotLoaded)?
141 .program;
142 let line_tables = &tables
143 .get(&loc_c.handle)
144 .ok_or(BrinkCallError::LineTablesNotLoaded)?
145 .tables;
146 let handler = bindings.eval_handler();
147 flow.inner.resolve_external(value);
148 let mut view = crate::globals::flow_context_view(&mut globals, &mut ctx);
149 let outcome = flow.inner.resume_function_eval::<FastRng>(
150 program,
151 line_tables,
152 &mut view,
153 &handler,
154 None,
155 )?;
156 triggers.extend(handler.take_queued());
157 classify_eval(&flow.inner, program, &bindings, outcome)?
158 };
159 }
160 }
161 }
162}
163
164/// Fire buffered command-event triggers from a completed engine→ink eval
165/// pass directly against the World — the exclusive driver's equivalent of
166/// [`BrinkHandler::flush`], since [`call_ink_function`] and friends already
167/// hold `&mut World` rather than a deferred [`bevy_ecs::system::Commands`]
168/// queue. Called only once evaluation reaches [`NextStep::Done`]; a mid-eval
169/// error drops any triggers queued so far, matching [`advance_flow`]'s
170/// existing drop-on-error precedent for buffered command triggers.
171///
172/// **Ordering this locks in:** because triggers only fire here, at the very
173/// end of the call, a `bind_brink_query` invoked *later* in the *same* call
174/// always runs (via `run_system_with` in [`drive_function_eval_to_done`])
175/// **before** any command trigger buffered earlier in that same call is
176/// fired — a query can never observe a command's World effects within one
177/// call. This is consistent with [`advance_flow`], which likewise flushes
178/// its triggers only once a line is produced, not between suspensions; it's
179/// a non-obvious consequence of the buffer-then-flush shape, not a defect.
180/// (Across separate calls — e.g. [`call_ink_functions`]'s per-call flush —
181/// a later call's query *does* see an earlier call's command effects, since
182/// each call flushes before the next begins.)
183fn flush_eval_triggers(world: &mut World, triggers: Vec<TriggerFn>) {
184 for trigger in triggers {
185 trigger(world);
186 }
187}
188
189/// Classify a [`FunctionEval`] outcome into the driver's [`NextStep`],
190/// looking up the query system for a pending external. Called inside the
191/// borrow scope where `flow`/`program`/`bindings` are available.
192fn classify_eval<M: Send + Sync + 'static>(
193 flow: &FlowInstance,
194 program: &Program,
195 bindings: &BrinkBindings<M>,
196 outcome: brink_runtime::FunctionEval,
197) -> Result<NextStep, BrinkCallError> {
198 match outcome {
199 brink_runtime::FunctionEval::Returned(value) => Ok(NextStep::Done(value)),
200 brink_runtime::FunctionEval::AwaitingExternal => {
201 let name = flow
202 .pending_external_name(program)
203 .unwrap_or_default()
204 .to_owned();
205 if bindings.async_bindings.contains_key(&name) {
206 return Err(BrinkCallError::AsyncExternalUnsupported(name));
207 }
208 let system = bindings
209 .query(&name)
210 .ok_or(BrinkCallError::UnknownQuery(name))?;
211 let qargs = flow.pending_external_args().to_vec();
212 Ok(NextStep::RunQuery { system, qargs })
213 }
214 }
215}
216
217/// Synchronously evaluate an ink function on a flow entity from an
218/// exclusive (`&mut World`) context, returning its value.
219///
220/// Pure bindings resolve inline; world-access (`bind_brink_query`) bindings
221/// are run via `run_system_with` between evaluation suspensions — so the
222/// function can query anything in the World. The whole call completes in
223/// one pass (one frame): the function's output is isolated, the
224/// player-visible story is untouched, and visit counts aren't bumped.
225///
226/// `M` is the story marker (use `()` for the default). For callers that
227/// don't have `&mut World` (a normal system), use the deferred
228/// `commands.brink_call(...)` API instead.
229///
230/// # Errors
231/// See [`BrinkCallError`].
232pub fn call_ink_function<M: Send + Sync + 'static>(
233 world: &mut World,
234 entity: Entity,
235 name: &str,
236 args: &[Value],
237) -> Result<Value, BrinkCallError> {
238 let mut state: EvalSystemState<M> = SystemState::new(world);
239 let mut triggers: Vec<TriggerFn> = Vec::new();
240
241 // Begin the evaluation (resolve the function by name, then start it).
242 let next = begin_eval_by_name(world, entity, &mut state, name, args, &mut triggers)?;
243
244 // Drive: run each pending world-access query against the World, resolve
245 // it, and resume — until the function returns.
246 let value = drive_function_eval_to_done(world, entity, &mut state, next, &mut triggers)?;
247
248 // Fire any command-event triggers the call queued along the way (#1096).
249 flush_eval_triggers(world, triggers);
250 Ok(value)
251}
252
253/// Begin one by-name function evaluation against an already-built
254/// [`EvalSystemState`], resolving the function and taking its first step.
255///
256/// Factored out of [`call_ink_function`] so the single-call path and the
257/// batch path ([`call_ink_functions`]) share identical begin semantics while
258/// the batch reuses **one** `SystemState` across every call — the setup
259/// (`SystemState::new`) is paid once per batch turn, not once per call.
260fn begin_eval_by_name<M: Send + Sync + 'static>(
261 world: &mut World,
262 entity: Entity,
263 state: &mut EvalSystemState<M>,
264 name: &str,
265 args: &[Value],
266 triggers: &mut Vec<TriggerFn>,
267) -> Result<NextStep, BrinkCallError> {
268 let (mut flows, globals, programs, tables, bindings) = state
269 .get_mut(world)
270 .map_err(|e| BrinkCallError::SystemParamInvalid(e.to_string()))?;
271 let mut globals = globals.ok_or(BrinkCallError::NotAFlow)?;
272 let (prog_c, loc_c, mut flow, mut ctx) = flows
273 .get_mut(entity)
274 .map_err(|_| BrinkCallError::NotAFlow)?;
275 let program = &programs
276 .get(&prog_c.handle)
277 .ok_or(BrinkCallError::ProgramNotLoaded)?
278 .program;
279 let line_tables = &tables
280 .get(&loc_c.handle)
281 .ok_or(BrinkCallError::LineTablesNotLoaded)?
282 .tables;
283 let idx = program
284 .find_address(name)
285 .ok_or_else(|| BrinkCallError::FunctionNotFound(name.to_owned()))?
286 .0;
287 let handler = bindings.eval_handler();
288 let mut view = crate::globals::flow_context_view(&mut globals, &mut ctx);
289 let outcome = flow.inner.begin_function_eval::<FastRng>(
290 program,
291 line_tables,
292 &mut view,
293 &handler,
294 idx,
295 args,
296 None,
297 )?;
298 triggers.extend(handler.take_queued());
299 classify_eval(&flow.inner, program, &bindings, outcome)
300}
301
302/// Apply a batch of engine→ink calls to one flow in a **single** VM-eval
303/// setup, returning one [`Result`] per call in the order supplied.
304///
305/// This is the batch counterpart of [`call_ink_function`]: an engine→ink seam
306/// (e.g. an event-folding system that pushes a frame's events into ink) can
307/// hand the whole frame's calls over at once. The expensive per-call setup —
308/// building the [`SystemState`] over the flow components + assets + bindings —
309/// is paid **once** for the batch instead of once per call, so a frame with
310/// N sightings costs one setup plus N evaluations rather than N setups.
311///
312/// Per-call semantics are preserved exactly:
313///
314/// - **Order.** Calls run front-to-back, so `decay` then N × `escalate` then
315/// `trigger_global` fold in the same order as N separate
316/// [`call_ink_function`]s. State mutated by an earlier call is visible to a
317/// later one (they share the flow's globals/context).
318/// - **Isolated outputs.** Each call is a fresh `begin_function_eval` — output
319/// is isolated, the player-visible story is untouched, visit counts aren't
320/// bumped — identical to a standalone [`call_ink_function`].
321/// - **Error per call, no silent drops.** A failing call yields `Err(_)` in
322/// *its* slot and does **not** abort the batch; every later call still runs
323/// and every slot is reported. The returned `Vec` has exactly one entry per
324/// input call.
325///
326/// The result of the *i*-th call is `results[i]`. `M` is the story marker (use
327/// `()` for the default). For callers that don't have `&mut World` (a normal
328/// system), issue a deferred `commands.brink_call_batch(...)` instead.
329///
330/// ```no_run
331/// # use bevy_ecs::entity::Entity;
332/// # use bevy_ecs::world::World;
333/// # use bevy_log::warn;
334/// # use bevy_brink::{Value, call_ink_functions};
335/// # struct AlarmStory;
336/// # fn example(
337/// # world: &mut World,
338/// # flow: Entity,
339/// # round_started: bool,
340/// # dt: f32,
341/// # spots: Vec<f32>,
342/// # has_global: bool,
343/// # ) {
344/// // The alarm write-seam, one VM entry instead of N+2:
345/// let mut calls: Vec<(&str, Vec<Value>)> = Vec::new();
346/// if round_started { calls.push(("alarm_reset", vec![])); }
347/// calls.push(("decay", vec![Value::Float(dt)]));
348/// for amount in spots { calls.push(("escalate_spotting", vec![Value::Float(amount)])); }
349/// if has_global { calls.push(("trigger_global", vec![])); }
350/// for (call, res) in calls.iter().zip(call_ink_functions::<AlarmStory, _, _>(world, flow, calls.clone())) {
351/// if let Err(err) = res { warn!("[alarm] ink call {} failed: {err}", call.0); }
352/// }
353/// # }
354/// ```
355///
356/// # Errors
357/// Errors are returned per call in the result `Vec`; the function itself does
358/// not short-circuit. See [`BrinkCallError`].
359pub fn call_ink_functions<M, N, A>(
360 world: &mut World,
361 entity: Entity,
362 calls: impl IntoIterator<Item = (N, A)>,
363) -> Vec<Result<Value, BrinkCallError>>
364where
365 M: Send + Sync + 'static,
366 N: AsRef<str>,
367 A: AsRef<[Value]>,
368{
369 // One setup for the whole batch — the amortization the batch exists for.
370 let mut state: EvalSystemState<M> = SystemState::new(world);
371
372 calls
373 .into_iter()
374 .map(|(name, args)| {
375 // Fresh trigger buffer per call so a command-event fires right
376 // after *its own* call completes — preserving the "isolated
377 // outputs, identical to a standalone call_ink_function" contract
378 // this batch API documents, rather than deferring every call's
379 // triggers to the end of the whole batch.
380 let mut triggers: Vec<TriggerFn> = Vec::new();
381 let next = begin_eval_by_name(
382 world,
383 entity,
384 &mut state,
385 name.as_ref(),
386 args.as_ref(),
387 &mut triggers,
388 )?;
389 let value =
390 drive_function_eval_to_done(world, entity, &mut state, next, &mut triggers)?;
391 flush_eval_triggers(world, triggers);
392 Ok(value)
393 })
394 .collect()
395}
396
397/// Synchronously invoke an ink **function value** (`#fn(…)` — a `FnRef` or
398/// `Closure`) on a flow entity from an exclusive (`&mut World`) context,
399/// returning its value — the host callback-invocation surface (T1c-3,
400/// `docs/t1c-spec.md` §6).
401///
402/// This is the [`call_ink_function`] sibling for the case where the host
403/// holds an opaque function-value token (obtained from a global, a returned
404/// value, or a `bind_brink_query` result) rather than a static function name.
405/// The host never dereferences the token's env — invocation re-enters the VM
406/// (`FlowInstance::begin_function_value_eval`), running any world-access query
407/// bindings the callback triggers, and is journaled exactly like a by-name
408/// call. `args` supply the remaining (val-only) params after the value's bound
409/// prefix.
410///
411/// The dispatch faults of `docs/t1c-spec.md` §3/§6 (non-function value, wrong
412/// arity, rehydration mismatch, cross-flow ref-`#@local`) surface as
413/// [`BrinkCallError::Runtime`].
414///
415/// `M` is the story marker (use `()` for the default).
416///
417/// # Errors
418/// See [`BrinkCallError`].
419pub fn call_ink_function_value<M: Send + Sync + 'static>(
420 world: &mut World,
421 entity: Entity,
422 callee: &Value,
423 args: &[Value],
424) -> Result<Value, BrinkCallError> {
425 let mut state: EvalSystemState<M> = SystemState::new(world);
426 let mut triggers: Vec<TriggerFn> = Vec::new();
427
428 // Begin the evaluation through the opaque function-value token.
429 let next = {
430 let (mut flows, globals, programs, tables, bindings) = state
431 .get_mut(world)
432 .map_err(|e| BrinkCallError::SystemParamInvalid(e.to_string()))?;
433 let mut globals = globals.ok_or(BrinkCallError::NotAFlow)?;
434 let (prog_c, loc_c, mut flow, mut ctx) = flows
435 .get_mut(entity)
436 .map_err(|_| BrinkCallError::NotAFlow)?;
437 let program = &programs
438 .get(&prog_c.handle)
439 .ok_or(BrinkCallError::ProgramNotLoaded)?
440 .program;
441 let line_tables = &tables
442 .get(&loc_c.handle)
443 .ok_or(BrinkCallError::LineTablesNotLoaded)?
444 .tables;
445 let handler = bindings.eval_handler();
446 let mut view = crate::globals::flow_context_view(&mut globals, &mut ctx);
447 let outcome = flow.inner.begin_function_value_eval::<FastRng>(
448 program,
449 line_tables,
450 &mut view,
451 &handler,
452 callee,
453 args,
454 None,
455 )?;
456 triggers.extend(handler.take_queued());
457 classify_eval(&flow.inner, program, &bindings, outcome)?
458 };
459
460 let value = drive_function_eval_to_done(world, entity, &mut state, next, &mut triggers)?;
461 flush_eval_triggers(world, triggers);
462 Ok(value)
463}
464
465/// One step of the [`advance_flow`] loop, captured inside the borrow scope
466/// so the World can be re-borrowed (for `run_system_with`) afterward.
467enum FlowStep {
468 /// A line was produced.
469 Line(Step),
470 /// The flow paused on a world-access query; run this system then resume.
471 Query {
472 system: QuerySystemId,
473 qargs: Vec<Value>,
474 /// External name, carried only in dev builds so the resolved query
475 /// result can be recorded into the flow's replay log.
476 #[cfg(feature = "dev")]
477 name: String,
478 },
479}
480
481/// Fire the per-line observer event (matching [`step_one`](crate::BrinkFlow::step_one))
482/// from an exclusive `&mut World` context.
483///
484/// Terminals carry no payload of their own (`docs/prose-dialect-spec.md`
485/// §7, RULED) — `BrinkTurnDone`/`BrinkStoryEnded` always fire with empty
486/// `text`/`tags`; any trailing content already arrived as its own
487/// preceding `BrinkLineDelivered` event.
488fn emit_line_event_world<M: Send + Sync + 'static>(world: &mut World, entity: Entity, step: &Step) {
489 use crate::event::{BrinkChoicesPresented, BrinkLineDelivered, BrinkStoryEnded, BrinkTurnDone};
490 match step {
491 Step::Line(line) => {
492 world
493 .entity_mut(entity)
494 .trigger(|e| BrinkLineDelivered::<M>::new(e, line.text.clone(), line.tags.clone()));
495 }
496 Step::Choices(choices) => {
497 world.entity_mut(entity).trigger(|e| {
498 BrinkChoicesPresented::<M>::new(e, String::new(), Vec::new(), choices.clone())
499 });
500 }
501 // A park (`Step::Suspended`, FS-3r) is a turn boundary like `Done`;
502 // runtime-unreachable today behind the E052 fence, grouped here so
503 // the exhaustive match keeps compiling as the variant lands.
504 Step::Done | Step::Suspended => {
505 world
506 .entity_mut(entity)
507 .trigger(|e| BrinkTurnDone::<M>::new(e, String::new(), Vec::new()));
508 }
509 Step::End => {
510 world
511 .entity_mut(entity)
512 .trigger(|e| BrinkStoryEnded::<M>::new(e, String::new(), Vec::new()));
513 }
514 }
515}
516
517/// One `advance` step for [`advance_flow`], wrapping the handler with a
518/// [`RecordingHandler`] when a recorder is active (dev) so inline pure/command
519/// results are captured into the flow's replay log. A thin pass-through in
520/// non-dev builds (the `recorder` parameter doesn't exist there).
521fn advance_recording<M: Send + Sync + 'static>(
522 flow: &mut FlowInstance,
523 program: &Program,
524 line_tables: &[Vec<brink_format::LineEntry>],
525 context: &mut (impl brink_runtime::ContextAccess + ?Sized),
526 handler: &BrinkHandler<'_, M>,
527 #[cfg(feature = "dev")] recorder: Option<&mut ReplayRecorder>,
528) -> Result<StepOutcome, RuntimeError> {
529 #[cfg(feature = "dev")]
530 if let Some(rec) = recorder {
531 let recording = RecordingHandler::new(handler, rec);
532 return flow.advance::<FastRng>(program, line_tables, context, &recording, None);
533 }
534 flow.advance::<FastRng>(program, line_tables, context, handler, None)
535}
536
537/// Advance a flow by one line from an exclusive (`&mut World`) context,
538/// resolving any world-access query bindings inline via `run_system_with`.
539///
540/// This is the playback counterpart to [`call_ink_function`]: where a
541/// non-exclusive `step_one` can only resolve pure/command bindings (query
542/// bindings fall back), `advance_flow` runs the query binding's system
543/// between the runtime's eval suspensions — so a story line like
544/// `{enemy_count()}` resolves transparently in one frame. Buffered command
545/// events are flushed and the line's observer event is fired, exactly as
546/// `step_one` would.
547///
548/// Bounded by a [`FlowInstance::LINE_LIMIT`] budget shared across every
549/// inline resume this call makes (each pending query resolved and each line
550/// produced decrements it by one) — reconciled onto the same
551/// `RuntimeError::LineLimitExceeded` convention
552/// [`FlowInstance::drive`]/`advance_until_terminal` use, rather than
553/// looping unboundedly if a story keeps calling inline-resolvable externals
554/// without ever producing a line (guard against unbounded growth).
555///
556/// # Errors
557/// See [`BrinkCallError`].
558#[expect(
559 clippy::too_many_lines,
560 reason = "the SystemState re-borrow dance around run_system_with doesn't split cleanly"
561)]
562pub fn advance_flow<M: Send + Sync + 'static>(
563 world: &mut World,
564 entity: Entity,
565) -> Result<Step, BrinkCallError> {
566 #[expect(
567 clippy::type_complexity,
568 reason = "SystemState param tuple for the flow components + assets + bindings"
569 )]
570 let mut state: SystemState<(
571 Query<(
572 &BrinkProgram<M>,
573 &BrinkLocale<M>,
574 &mut BrinkFlow<M>,
575 &mut BrinkContext<M>,
576 )>,
577 Option<ResMut<crate::BrinkGlobals<M>>>,
578 Res<Assets<ProgramAsset>>,
579 Res<Assets<LineTablesAsset>>,
580 Res<BrinkBindings<M>>,
581 )> = SystemState::new(world);
582
583 // Command-event triggers accumulate across the suspensions of a single
584 // line, then flush once the line is produced.
585 let mut triggers: Vec<TriggerFn> = Vec::new();
586
587 // In dev builds, record every external resolved during this pass into the
588 // flow's replay log so a hot-reload can replay it faithfully. Taken out of
589 // the component up front (and put back before the line returns) so it can
590 // wrap the handler / be written without holding `BrinkReplayLog` borrowed
591 // across the World re-borrows below. `None` for a non-dev-tracked flow.
592 #[cfg(feature = "dev")]
593 let mut recorder: Option<ReplayRecorder> = crate::replay::take_recorder::<M>(world, entity);
594
595 let mut budget = FlowInstance::LINE_LIMIT;
596
597 loop {
598 if budget == 0 {
599 return Err(RuntimeError::LineLimitExceeded(FlowInstance::LINE_LIMIT).into());
600 }
601 budget -= 1;
602
603 let step = {
604 let (mut flows, globals, programs, tables, bindings) = state
605 .get_mut(world)
606 .map_err(|e| BrinkCallError::SystemParamInvalid(e.to_string()))?;
607 let mut globals = globals.ok_or(BrinkCallError::NotAFlow)?;
608 let (prog_c, loc_c, mut flow, mut ctx) = flows
609 .get_mut(entity)
610 .map_err(|_| BrinkCallError::NotAFlow)?;
611 let program = &programs
612 .get(&prog_c.handle)
613 .ok_or(BrinkCallError::ProgramNotLoaded)?
614 .program;
615 let line_tables = &tables
616 .get(&loc_c.handle)
617 .ok_or(BrinkCallError::LineTablesNotLoaded)?
618 .tables;
619 let handler = bindings.handler();
620 let mut view = crate::globals::flow_context_view(&mut globals, &mut ctx);
621 // Inline pure/command results are captured by `advance_recording`'s
622 // RecordingHandler wrap (dev); out-of-band query results are recorded
623 // at the resolve site below.
624 let outcome = advance_recording(
625 &mut flow.inner,
626 program,
627 line_tables,
628 &mut view,
629 &handler,
630 #[cfg(feature = "dev")]
631 recorder.as_mut(),
632 )?;
633 triggers.extend(handler.take_queued());
634 match outcome {
635 StepOutcome::Step(step) => FlowStep::Line(step),
636 StepOutcome::AwaitingExternal => {
637 let name = flow
638 .inner
639 .pending_external_name(program)
640 .unwrap_or_default()
641 .to_owned();
642 if bindings.async_bindings.contains_key(&name) {
643 return Err(BrinkCallError::AsyncExternalUnsupported(name));
644 }
645 let system = bindings
646 .query(&name)
647 .ok_or_else(|| BrinkCallError::UnknownQuery(name.clone()))?;
648 let qargs = flow.inner.pending_external_args().to_vec();
649 FlowStep::Query {
650 system,
651 qargs,
652 #[cfg(feature = "dev")]
653 name,
654 }
655 }
656 }
657 };
658
659 match step {
660 FlowStep::Line(line) => {
661 for trigger in triggers {
662 trigger(world);
663 }
664 emit_line_event_world::<M>(world, entity, &line);
665 #[cfg(feature = "dev")]
666 if let Some(rec) = recorder {
667 crate::replay::put_recorder::<M>(world, entity, rec);
668 }
669 return Ok(line);
670 }
671 FlowStep::Query {
672 system,
673 qargs,
674 #[cfg(feature = "dev")]
675 name,
676 } => {
677 let value = world
678 .run_system_with(system, (entity, qargs.clone()))
679 .map_err(|e| BrinkCallError::QueryFailed(format!("{e:?}")))?;
680 #[cfg(feature = "dev")]
681 if let Some(rec) = recorder.as_mut() {
682 rec.record(&name, &qargs, &value);
683 }
684 let (mut flows, ..) = state
685 .get_mut(world)
686 .map_err(|e| BrinkCallError::SystemParamInvalid(e.to_string()))?;
687 let (_, _, mut flow, _) = flows
688 .get_mut(entity)
689 .map_err(|_| BrinkCallError::NotAFlow)?;
690 flow.inner.resolve_external(value);
691 }
692 }
693 }
694}
695
696/// What [`dispatch_one_external`] decided to do for a parked flow, computed
697/// inside the immutable borrow scope and acted on afterward (each variant
698/// needs `&mut World`).
699enum Dispatch {
700 /// Nothing to do (no pending external, program not loaded yet, already
701 /// dispatched, or genuinely unbound — the latter warns inside).
702 Nothing,
703 /// World-access query: run the system, resolve with its return value.
704 Query {
705 system: QuerySystemId,
706 qargs: Vec<Value>,
707 /// External name, carried in dev builds so the resolved query result
708 /// can be recorded into the flow's replay log, and in `effect-trace`
709 /// builds so the dispatch can be logged against the binding's
710 /// captured `Access` (issue #938's ground-truth check).
711 #[cfg(any(feature = "dev", feature = "effect-trace"))]
712 name: String,
713 },
714 /// `bind_brink_async` (event): fire [`BrinkExternalAwaited`] + insert the
715 /// [`BrinkAwaiting`] marker.
716 FireEvent { name: String, qargs: Vec<Value> },
717 /// `bind_brink_task`: spawn this future on the async pool, park a
718 /// [`BrinkPendingTask`].
719 SpawnTask {
720 fut: Pin<Box<dyn Future<Output = Value> + Send>>,
721 /// Name + args, carried only in dev builds so [`poll_brink_tasks`]
722 /// can record the task's result into the flow's replay log when it
723 /// completes (the value isn't known until then).
724 #[cfg(feature = "dev")]
725 name: String,
726 #[cfg(feature = "dev")]
727 qargs: Vec<Value>,
728 },
729}
730
731/// Resolve / hand off the (single) external a parked flow is waiting on,
732/// dispatched by binding kind:
733/// - world-access query → run its system inline and resolve;
734/// - `bind_brink_async` → fire [`BrinkExternalAwaited`] once (guarded by the
735/// [`BrinkAwaiting`] marker) and leave the flow parked for the engine;
736/// - `bind_brink_task` → spawn the future once (guarded by [`BrinkPendingTask`])
737/// and leave the flow parked for [`poll_brink_tasks`](crate::poll_brink_tasks).
738///
739/// Decide what [`dispatch_one_external`] should do for `entity`, computed inside
740/// the immutable borrow scope (so the action can re-borrow `&mut World`).
741/// [`Dispatch::Nothing`] when the flow has no pending external, isn't loaded, or
742/// has already been dispatched.
743#[expect(
744 clippy::type_complexity,
745 reason = "SystemState param tuple for the flow component (+ dispatch markers) + assets + bindings"
746)]
747fn decide_dispatch<M: Send + Sync + 'static>(world: &mut World, entity: Entity) -> Dispatch {
748 let mut state: SystemState<(
749 Query<(
750 &BrinkProgram<M>,
751 &BrinkFlow<M>,
752 Option<&BrinkAwaiting<M>>,
753 Option<&BrinkPendingTask<M>>,
754 )>,
755 Res<Assets<ProgramAsset>>,
756 Res<BrinkBindings<M>>,
757 )> = SystemState::new(world);
758 let Ok((flows, programs, bindings)) = state.get(world) else {
759 // A required resource (Assets<ProgramAsset>, BrinkBindings<M>) isn't
760 // present yet — leave parked; we'll retry next frame.
761 return Dispatch::Nothing;
762 };
763 let Ok((prog_c, flow, awaiting, pending_task)) = flows.get(entity) else {
764 return Dispatch::Nothing;
765 };
766 if !flow.inner.has_pending_external() {
767 return Dispatch::Nothing;
768 }
769 let Some(program) = programs.get(&prog_c.handle) else {
770 // Program not loaded yet — leave parked; we'll retry next frame.
771 return Dispatch::Nothing;
772 };
773 let program = &program.program;
774 let name = flow
775 .inner
776 .pending_external_name(program)
777 .unwrap_or_default()
778 .to_owned();
779 let qargs = flow.inner.pending_external_args().to_vec();
780
781 if let Some(system) = bindings.query(&name) {
782 Dispatch::Query {
783 system,
784 qargs,
785 #[cfg(any(feature = "dev", feature = "effect-trace"))]
786 name,
787 }
788 } else if let Some(kind) = bindings.async_bindings.get(&name) {
789 match kind {
790 AsyncKind::Event if awaiting.is_some() => Dispatch::Nothing, // already fired
791 AsyncKind::Event => Dispatch::FireEvent { name, qargs },
792 AsyncKind::Task(_) if pending_task.is_some() => Dispatch::Nothing, // already spawned
793 AsyncKind::Task(factory) => {
794 #[cfg(feature = "dev")]
795 let fut = factory(qargs.clone());
796 #[cfg(not(feature = "dev"))]
797 let fut = factory(qargs);
798 Dispatch::SpawnTask {
799 fut,
800 #[cfg(feature = "dev")]
801 name,
802 #[cfg(feature = "dev")]
803 qargs,
804 }
805 }
806 }
807 } else {
808 // Pending but unbound. The handler only pauses on registered names, so
809 // this indicates a registration race; warn and leave parked.
810 warn!("brink: flow {entity:?} parked on unbound external '{name}'");
811 Dispatch::Nothing
812 }
813}
814
815/// A no-op when the flow has no pending external or its program isn't loaded.
816fn dispatch_one_external<M: Send + Sync + 'static>(world: &mut World, entity: Entity) {
817 match decide_dispatch::<M>(world, entity) {
818 Dispatch::Nothing => {}
819 Dispatch::Query {
820 system,
821 qargs,
822 #[cfg(any(feature = "dev", feature = "effect-trace"))]
823 name,
824 } => match world.run_system_with(system, (entity, qargs.clone())) {
825 Ok(value) => {
826 #[cfg(feature = "dev")]
827 crate::replay::record_external::<M>(world, entity, &name, &qargs, &value);
828 // Issue #938's host-side ground-truth check: log this real
829 // dispatch's binding-declared `Access` (captured at
830 // `bind_brink_query` registration) so a test/harness can
831 // later assert it stayed a subset of BH-1's row-join
832 // (`crate::ground_truth::check`). A missing `BrinkBindings<M>`
833 // resource or an unregistered binding name (should not
834 // happen — this dispatch only runs for a name `bindings.query`
835 // just resolved) skips recording rather than panicking.
836 #[cfg(feature = "effect-trace")]
837 if let Some(access) = world
838 .get_resource::<BrinkBindings<M>>()
839 .and_then(|b| b.query_access(&name))
840 .cloned()
841 {
842 crate::ground_truth::record::<M>(world, entity, &name, access);
843 }
844 let mut flows = world.query::<&mut BrinkFlow<M>>();
845 if let Ok(mut flow) = flows.get_mut(world, entity) {
846 flow.inner.resolve_external(value);
847 }
848 }
849 Err(err) => warn!("brink: query binding failed on {entity:?}: {err:?}"),
850 },
851 Dispatch::FireEvent { name, qargs } => {
852 // Insert the marker BEFORE firing so a synchronous resolve observer
853 // can find + remove it (world.trigger runs observers and flushes
854 // their commands inline).
855 world
856 .entity_mut(entity)
857 .insert(BrinkAwaiting::<M>::new(name.clone()));
858 world
859 .entity_mut(entity)
860 .trigger(|e| BrinkExternalAwaited::<M>::new(e, name, qargs));
861 }
862 Dispatch::SpawnTask {
863 fut,
864 #[cfg(feature = "dev")]
865 name,
866 #[cfg(feature = "dev")]
867 qargs,
868 } => {
869 // get_or_init so we don't panic in apps/tests without TaskPoolPlugin;
870 // a no-op when the pool is already set up (e.g. by DefaultPlugins).
871 let task = AsyncComputeTaskPool::get_or_init(TaskPool::default).spawn(fut);
872 world.entity_mut(entity).insert(BrinkPendingTask::<M>::new(
873 task,
874 #[cfg(feature = "dev")]
875 name,
876 #[cfg(feature = "dev")]
877 qargs,
878 ));
879 }
880 }
881}
882
883/// Run condition: `true` if any `BrinkFlow<M>` is paused on a pending
884/// external (so the resolver only runs when there's work).
885#[must_use]
886pub fn any_flow_awaiting_external<M: Send + Sync + 'static>(flows: Query<&BrinkFlow<M>>) -> bool {
887 flows.iter().any(|f| f.inner.has_pending_external())
888}
889
890/// Exclusive plugin system: service flows that paused on a pending external
891/// during normal playback (after a non-exclusive
892/// [`step_one`](crate::BrinkFlow::step_one) yielded
893/// [`Advance::AwaitingQuery`](crate::Advance::AwaitingQuery)).
894///
895/// For each parked flow, [`dispatch_one_external`] resolves a world-access
896/// query inline, fires [`BrinkExternalAwaited`] for a `bind_brink_async`
897/// binding, or spawns the task for a `bind_brink_task` binding. Registered by
898/// the plugin, gated on [`any_flow_awaiting_external`].
899pub fn resolve_pending_externals<M: Send + Sync + 'static>(world: &mut World) {
900 let paused: Vec<Entity> = {
901 let mut flows = world.query::<(Entity, &BrinkFlow<M>)>();
902 flows
903 .iter(world)
904 .filter(|(_, f)| f.inner.has_pending_external())
905 .map(|(e, _)| e)
906 .collect()
907 };
908 for entity in paused {
909 dispatch_one_external::<M>(world, entity);
910 }
911}