bevy_brink/batch.rs
1//! Batch-mode flow advancement — the frame-start consistency semantics core
2//! (`docs/effects-spec.md` §12.4; BH-2, #914).
3//!
4//! The Collect / Step / Apply phase model, the per-flow buffered writes, and
5//! the flow-id-ordered Apply live here and are `unsafe`-free. Two drivers use
6//! them: the serial [`advance_batch`] (this module) and the **parallel**
7//! [`advance_batch_parallel`](parallel::advance_batch_parallel) (BH-3, #927 —
8//! the sanctioned-unsafe [`parallel`] submodule). Per-flow Step and the
9//! flow-id-ordered Apply are literally the same functions called from both
10//! drivers; each driver walks its own Collect query (one as a system param,
11//! one against a raw `&mut World`) and the two must be kept filter-identical
12//! by hand (#1633 is the standing example of what happens when that drifts).
13//! Together they make the drivers **byte-identical** by construction (the
14//! determinism law): the parallel driver only moves the Step *loop* onto
15//! [`ComputeTaskPool`](bevy_tasks::ComputeTaskPool); every flow still steps
16//! against its own read-only view of the frame-start world and writes only
17//! into its own buffer, so thread interleaving cannot affect any outcome, and
18//! Apply flushes in flow-id order either way.
19//!
20//! ## What batch mode changes
21//!
22//! The serial API ([`BrinkFlow::advance_until_terminal`](crate::BrinkFlow),
23//! [`advance_flow`](crate::advance_flow), etc.) keeps **immediate
24//! visibility**: a flow's write to a shared [`BrinkWorld`](crate::BrinkWorld)
25//! cell is visible to the very next flow stepped in the same frame, and a
26//! command binding fires as soon as the handler is flushed. That is the
27//! documented *serial mode* and is untouched here.
28//!
29//! **Batch mode** ([`advance_batch`]) drives N pending flows as one batch
30//! turn with three phases (§12.1):
31//!
32//! - **Collect** — gather the pending flows (loaded program + line tables,
33//! not paused on a deferred external), in a deterministic **flow-id order**
34//! (bevy [`Entity`] order — a fixed total order within a batch).
35//! - **Step** — advance each flow against the **frame-start world**: reads
36//! pin to the shared world's state as it was at the start of the batch
37//! turn (a peer's same-frame write is *not* visible — it lands next frame,
38//! double-buffered / simulation-tick semantics); World-scoped writes and
39//! command triggers **buffer** per flow instead of applying immediately.
40//! Because each flow reads only the frame-start state plus its own
41//! buffered writes, its produced lines and its buffer are a pure function
42//! of (frame-start, that flow's own parked state) — **independent of the
43//! order flows are stepped in**. That is the order-invariance property BH-2
44//! is gated on (see [`tests`]).
45//! - **Apply** — flush every flow's buffered writes, then its buffered
46//! command triggers, then its line events, in **flow-id order**. Write-write
47//! conflicts resolve deterministically by apply order (§12.4: "even
48//! write-write is deterministic by apply order"), so no conflict
49//! partitioning is needed.
50//!
51//! ## Scope of this slice (honest)
52//!
53//! - **World-scoped state only.** Frame-start consistency is a property of the
54//! *shared* [`BrinkWorld`](crate::BrinkWorld) — the only state visible
55//! across flows. Batch mode steps each flow through a borrowed,
56//! read-only view pinned to that shared world's frame-start state (a
57//! private per-flow write overlay on top — see "Borrowed frame-start reads"
58//! below) and buffers its writes; it does **not** route through a flow's
59//! private [`BrinkContext`](crate::BrinkContext)
60//! (`FlowLocal`) layer, which is flow-private by construction and so can
61//! never participate in a cross-flow race. Under the default all-`World`
62//! policy (every unit World-scoped — the common case, and what the property
63//! test and scenario harness exercise) this is exactly complete. A host that
64//! opts units into `Local` via a policy should keep those flows on the
65//! serial API. **BH-3 (#925) now *guards* this** rather than leaving it
66//! silent: a Local-policy flow (host-installed or compiled `#@local`) is
67//! skipped with a `warn!` and counted in
68//! [`BrinkBatchReport::skipped_local`], never stepped — the full `Local`
69//! routing itself remains a BH-4 follow-up.
70//! - **No prefetch.** World-access (`bind_brink_query`) and async bindings
71//! still park (`AwaitingExternal`); a parked flow is simply left for the
72//! plugin's existing resolver and re-collected next batch. §12.3 prefetch
73//! (synchronous world reads under a held borrow) is a later slice.
74//! - **Borrowed frame-start reads (§12.2, #937).** Both drivers step each
75//! flow through a [`FrameStartView`] — a shared-immutable *borrow* of the
76//! frame-start world plus a private write overlay — not a per-flow clone.
77//! That is what makes the concurrent Step trivially race-free (no task
78//! writes shared state) at `O(1)` per flow instead of `O(world size)`.
79//! §12.3 **prefetch** (synchronous world reads served under the same held
80//! borrow) remains a later slice; see "No prefetch" above.
81//!
82//! ## Capability bookkeeping (BH-1 wiring)
83//!
84//! Each batch turn consults the [`CapabilityTable`](crate::CapabilityTable)
85//! (BH-1, #906) for every stepped flow's story and records the flow's
86//! aggregate container [`Access`] into [`BrinkBatchReport`]. BH-2 only
87//! *records* it (the serial Step loop needs no disjointness proof); BH-3's
88//! parallel Step consumes exactly this bookkeeping to prove access-disjoint
89//! flows may advance concurrently.
90
91use std::marker::PhantomData;
92
93use bevy_asset::{AssetId, Assets};
94use bevy_ecs::change_detection::DetectChanges;
95use bevy_ecs::change_detection::Tick;
96use bevy_ecs::entity::Entity;
97use bevy_ecs::query::Access;
98use bevy_ecs::resource::Resource;
99use bevy_ecs::system::{Commands, Query, Res, ResMut};
100use bevy_log::warn;
101use brink_format::{DefinitionId, LineEntry, Value};
102use brink_runtime::{
103 ContextAccess, DriveOutcome, ExternalFnHandler, FallbackHandler, FastRng, FlowInstance,
104 FrameStartView, Program, RuntimeError, Scope, Step, World, WorldPolicy, WriteObserver,
105};
106
107use crate::asset::{BrinkProgram, LineTablesAsset, ProgramAsset};
108use crate::bindings::{BrinkBindings, TriggerFn};
109use crate::capability::{CapabilityTable, ContainerAccessTable};
110use crate::flow::{BrinkFlow, emit_event};
111use crate::globals::{BrinkGlobals, BrinkWorldPolicy};
112use crate::line_tables::BrinkLocale;
113use crate::sleep::FlowSleep;
114use crate::wake_delta::{BrinkWorldDelta, WorldDelta};
115
116/// BH-3's sanctioned-unsafe parallel Step phase (`ComputeTaskPool` +
117/// `UnsafeWorldCell`). The workspace-wide `unsafe_code` deny stands
118/// everywhere else; this is the one exempt module.
119pub mod parallel;
120
121/// Does a host-installed [`WorldPolicy`] home **any** unit of story-state to
122/// [`Scope::Local`]? Batch mode ([`advance_batch`], [`advance_batch_parallel`])
123/// routes only the shared [`World`]; a flow whose policy homes anything to
124/// `Local` reads/writes a per-flow `FlowLocal` layer batch mode never wraps,
125/// so batching it would silently drop those reads/writes. This is the cheap
126/// interim guard's host-side half (#925): the whole batch shares one
127/// `BrinkWorldPolicy<M>`, so a single check gates every flow under `M`; the
128/// per-story compiled-`#@local` half rides [`Program::has_local_defaults`].
129pub(crate) fn homes_any_local(policy: &WorldPolicy) -> bool {
130 policy.default == Scope::Local
131 || policy.turn_index == Scope::Local
132 || policy.rng == Scope::Local
133 || policy.overrides.values().any(|s| *s == Scope::Local)
134}
135
136// ── Buffered world writes ───────────────────────────────────────────────────
137
138/// One buffered mutation to the shared world, captured during a flow's batch
139/// Step and replayed at Apply. Every variant carries an **absolute** value
140/// (increments are captured as the resulting count in the flow's own
141/// frame-start snapshot), so replaying the ordered list onto the shared world
142/// is a deterministic, idempotent-per-field sequence of sets.
143#[derive(Debug, Clone)]
144pub(crate) enum WorldWrite {
145 Global(u32, Value),
146 VisitCount(DefinitionId, u32),
147 TurnCount(DefinitionId, u32),
148 TurnIndex(u32),
149 RngSeed(i32),
150 PreviousRandom(i32),
151}
152
153impl WorldWrite {
154 /// Replay this buffered write onto `target` (the shared world, at Apply).
155 fn apply(&self, target: &mut World) {
156 match *self {
157 WorldWrite::Global(idx, ref value) => target.set_global(idx, value.clone()),
158 WorldWrite::VisitCount(id, count) => target.set_visit_count(id, count),
159 WorldWrite::TurnCount(id, turn) => target.set_turn_count(id, turn),
160 WorldWrite::TurnIndex(index) => target.set_turn_index(index),
161 WorldWrite::RngSeed(seed) => target.set_rng_seed(seed),
162 WorldWrite::PreviousRandom(val) => target.set_previous_random(val),
163 }
164 }
165}
166
167/// A [`WriteObserver`] that records every world mutation a flow makes during
168/// its batch Step into an ordered buffer. Wrapped around the flow's private
169/// frame-start snapshot via
170/// [`ObservedContext`](brink_runtime::ObservedContext), so the snapshot stays
171/// self-consistent (the flow reads back its own writes) while the buffer
172/// captures the changeset for flow-id-ordered Apply.
173#[derive(Default)]
174pub(crate) struct WriteBuffer {
175 writes: Vec<WorldWrite>,
176}
177
178impl WriteBuffer {
179 /// Replay the buffered writes onto `target` in capture order. Called at
180 /// Apply, once per flow, with flows visited in flow-id order.
181 fn apply_to(&self, target: &mut World) {
182 for w in &self.writes {
183 w.apply(target);
184 }
185 }
186
187 /// Fold this flow's changeset into the turn's [`WorldDelta`] — the
188 /// row-directed wake-dirtying ledger (issue #1146). A global write is
189 /// recorded per **slot index** (so a condition reading another cell stays
190 /// inert); every other variant is bookkeeping, which effect rows model no
191 /// read of, so they collapse into the one coarse bit.
192 fn record_into(&self, delta: &mut WorldDelta) {
193 for w in &self.writes {
194 match *w {
195 WorldWrite::Global(idx, _) => delta.note_global(idx),
196 WorldWrite::VisitCount(..)
197 | WorldWrite::TurnCount(..)
198 | WorldWrite::TurnIndex(_)
199 | WorldWrite::RngSeed(_)
200 | WorldWrite::PreviousRandom(_) => delta.note_bookkeeping(),
201 }
202 }
203 }
204
205 #[cfg(test)]
206 pub(crate) fn len(&self) -> usize {
207 self.writes.len()
208 }
209}
210
211impl WriteObserver for WriteBuffer {
212 fn on_set_global(&mut self, idx: u32, value: &Value) {
213 self.writes.push(WorldWrite::Global(idx, value.clone()));
214 }
215 fn on_increment_visit(&mut self, id: DefinitionId, new_count: u32) {
216 self.writes.push(WorldWrite::VisitCount(id, new_count));
217 }
218 fn on_set_visit_count(&mut self, id: DefinitionId, count: u32) {
219 self.writes.push(WorldWrite::VisitCount(id, count));
220 }
221 fn on_set_turn_count(&mut self, id: DefinitionId, turn: u32) {
222 self.writes.push(WorldWrite::TurnCount(id, turn));
223 }
224 fn on_increment_turn_index(&mut self, new_value: u32) {
225 self.writes.push(WorldWrite::TurnIndex(new_value));
226 }
227 fn on_set_turn_index(&mut self, index: u32) {
228 self.writes.push(WorldWrite::TurnIndex(index));
229 }
230 fn on_set_rng_seed(&mut self, new_seed: i32) {
231 self.writes.push(WorldWrite::RngSeed(new_seed));
232 }
233 fn on_set_previous_random(&mut self, new_val: i32) {
234 self.writes.push(WorldWrite::PreviousRandom(new_val));
235 }
236}
237
238// ── Per-flow batch outcome ──────────────────────────────────────────────────
239
240/// The result of stepping one flow in a batch turn — everything Apply needs
241/// to flush it, plus the capability bookkeeping BH-3 will consume. Ordered by
242/// flow-id at Apply.
243pub(crate) struct FlowBatchOutcome {
244 entity: Entity,
245 story: AssetId<ProgramAsset>,
246 writes: WriteBuffer,
247 triggers: Vec<TriggerFn>,
248 lines: Vec<Step>,
249 /// `true` if the flow paused on a deferred external during Step (parked,
250 /// not advanced to terminal) — left for the plugin's existing resolver.
251 awaiting: bool,
252 /// `true` if `step_flow` returned a [`RuntimeError`] (e.g. the
253 /// [`FlowInstance::LINE_LIMIT`] budget tripping `LineLimitExceeded`).
254 /// Mutually exclusive with `awaiting`: an errored flow reached neither a
255 /// terminal line nor a deferred-external park this turn, and must not be
256 /// counted as either at Apply.
257 errored: bool,
258 /// `true` if the flow was **skipped**, not stepped, because its policy
259 /// homes state to [`Scope::Local`] (the #925 guard). A skipped flow
260 /// produced no lines, buffered no writes, and is not parked — it is left
261 /// untouched for the serial API and counted distinctly, never folded into
262 /// `stepped`/`awaiting`/`errored`.
263 skipped_local: bool,
264 /// The flow's story's aggregate container access (union across all
265 /// containers), from BH-1's [`CapabilityTable`]. `None` if no table is
266 /// loaded for the story (no manifest/registry wired).
267 access: Option<Access>,
268}
269
270impl FlowBatchOutcome {
271 /// Build the outcome for a flow **skipped** by the Local-policy guard
272 /// (#925): no Step ran, so no lines/writes/triggers, `skipped_local` set.
273 /// Its BH-1 `access` is still recorded for the batch report.
274 pub(crate) fn skipped_local(
275 entity: Entity,
276 story: AssetId<ProgramAsset>,
277 access: Option<Access>,
278 ) -> Self {
279 Self {
280 entity,
281 story,
282 writes: WriteBuffer::default(),
283 triggers: Vec::new(),
284 lines: Vec::new(),
285 awaiting: false,
286 errored: false,
287 skipped_local: true,
288 access,
289 }
290 }
291
292 /// The flow-id (Entity) this outcome belongs to — Apply orders by it.
293 pub(crate) fn entity(&self) -> Entity {
294 self.entity
295 }
296}
297
298/// Step exactly one flow for a batch turn and package its
299/// [`FlowBatchOutcome`] — the per-flow work shared by the serial
300/// ([`advance_batch`]) and parallel
301/// ([`parallel::advance_batch_parallel`]) drivers, so both produce
302/// **byte-identical** per-flow outcomes (the determinism law). Creates the
303/// flow's own command-trigger-buffering handler, runs [`step_flow`] against
304/// `frame_start`, drains the buffered triggers, and (on a [`RuntimeError`])
305/// `warn!`s + flags `errored` so the fault is surfaced, never laundered into
306/// a normal terminal step.
307#[expect(
308 clippy::too_many_arguments,
309 reason = "each argument is a distinct, already-resolved Step input (frame-start, flow, program, tables, bindings, story, entity, access); bundling them into a struct would just relocate the same fields with no clarity gain and force both call sites to build it"
310)]
311pub(crate) fn step_one<M: Send + Sync + 'static>(
312 frame_start: &World,
313 flow_inner: &mut FlowInstance,
314 program: &Program,
315 tables: &[Vec<LineEntry>],
316 bindings: Option<&BrinkBindings<M>>,
317 story: AssetId<ProgramAsset>,
318 entity: Entity,
319 access: Option<Access>,
320) -> FlowBatchOutcome {
321 let mut buf = WriteBuffer::default();
322 // A `BrinkBindings` handler buffers command triggers per flow (drained
323 // after Step, flushed at Apply in flow-id order); with no bindings
324 // registered, the fallback handler runs the in-story fallback bodies and
325 // buffers nothing. The handler is created here, used only by this one
326 // flow, and dropped before the outcome is returned — never shared across
327 // flows (so the parallel driver's per-task handler is race-free).
328 let handler = bindings.map(BrinkBindings::handler);
329 let handler_ref: &dyn ExternalFnHandler = match &handler {
330 Some(h) => h,
331 None => &FallbackHandler,
332 };
333 let (lines, awaiting, error) = step_flow(
334 frame_start,
335 flow_inner,
336 program,
337 tables,
338 handler_ref,
339 &mut buf,
340 );
341 let errored = if let Some(err) = &error {
342 warn!("batch step faulted for flow {entity:?} (story {story:?}): {err}");
343 true
344 } else {
345 false
346 };
347 let triggers = handler.map(|h| h.take_queued()).unwrap_or_default();
348
349 FlowBatchOutcome {
350 entity,
351 story,
352 writes: buf,
353 triggers,
354 lines,
355 awaiting,
356 errored,
357 skipped_local: false,
358 access,
359 }
360}
361
362/// Fold a story's whole [`ContainerAccessTable`] into one aggregate
363/// [`Access`] — the conservative "what could any container of this flow's
364/// story touch" set. BH-2 records this per flow; BH-3 narrows it to the
365/// flow's currently-parked container. `pub(crate)` so the host-side
366/// ground-truth check (#938, `crate::ground_truth`) can compare a real
367/// dispatch's observed access against the same declared aggregate BH-2/BH-3
368/// already consume, rather than reimplementing the fold.
369pub(crate) fn aggregate_access(table: &ContainerAccessTable) -> Access {
370 let mut acc = Access::default();
371 for container in table.values() {
372 acc.extend(&container.access);
373 }
374 acc
375}
376
377// ── Step: advance one flow against the frame-start snapshot ─────────────────
378
379/// Step one flow to a terminal line against `frame_start` (the pinned
380/// frame-start world), buffering its world writes into `buf` and returning
381/// the produced lines, whether it parked on a deferred external, and (on
382/// fault) the [`RuntimeError`] that ended its turn early — e.g. the
383/// [`FlowInstance::LINE_LIMIT`] budget tripping `LineLimitExceeded`. A fault
384/// is never silently folded into a normal terminal outcome: the caller must
385/// surface it (log + distinct bookkeeping), not count it as a stepped flow.
386///
387/// The flow steps against a **borrowed** view of `frame_start` — a
388/// [`FrameStartView`], wrapped in an
389/// [`ObservedContext`](brink_runtime::ObservedContext): reads resolve against
390/// frame-start ⊕ this flow's own already-buffered writes (never a peer's),
391/// writes land in the view's private overlay (so the flow reads them back)
392/// *and* record into `buf`. The overlay is discarded; only `buf` (the ordered
393/// changeset) survives to Apply.
394///
395/// The view **borrows** `frame_start` shared-immutably rather than cloning it
396/// (§12.2 "borrow, don't copy"; issue #937). Both properties the phase model
397/// rests on survive that swap unchanged, because the view is observationally
398/// identical to the clone it replaces (`brink_runtime`'s
399/// `equivalent_to_stepping_against_a_private_clone`): the flow still cannot
400/// see a peer's same-turn write, and it still cannot mutate the shared world
401/// during Step. What changes is only the cost — `O(1)` to open plus `O(cells
402/// this flow wrote)`, instead of `O(world size)` per flow per turn.
403fn step_flow(
404 frame_start: &World,
405 flow: &mut FlowInstance,
406 program: &Program,
407 line_tables: &[Vec<LineEntry>],
408 handler: &dyn ExternalFnHandler,
409 buf: &mut WriteBuffer,
410) -> (Vec<Step>, bool, Option<RuntimeError>) {
411 let mut scratch = FrameStartView::new(frame_start);
412 let mut observed = brink_runtime::ObservedContext::new(&mut scratch, buf);
413 let mut budget = FlowInstance::LINE_LIMIT;
414 match flow.drive::<FastRng>(
415 program,
416 line_tables,
417 &mut observed,
418 handler,
419 None,
420 &mut budget,
421 ) {
422 Ok(DriveOutcome::Terminal(lines)) => (lines, false, None),
423 Ok(DriveOutcome::AwaitingExternal(lines)) => (lines, true, None),
424 Err(err) => (Vec::new(), false, Some(err)),
425 }
426}
427
428// ── Batch report (BH-1 bookkeeping surface) ─────────────────────────────────
429
430/// Per-flow record of a batch turn: which flow, its story, and the aggregate
431/// container [`Access`] BH-1 computed for that story. Recorded by
432/// [`advance_batch`] into [`BrinkBatchReport`].
433#[derive(Debug, Clone)]
434pub struct FlowAccessRecord {
435 /// The flow entity (flow-id).
436 pub entity: Entity,
437 /// The flow's story program asset.
438 pub story: AssetId<ProgramAsset>,
439 /// Aggregate container access for the story, or `None` if BH-1 has no
440 /// table loaded for it (no capability manifest/registry wired).
441 pub access: Option<Access>,
442 /// `true` if the flow parked on a deferred external this turn.
443 pub awaiting: bool,
444 /// `true` if the flow's Step faulted with a [`RuntimeError`] this turn
445 /// (e.g. `LineLimitExceeded`) — logged via `bevy_log::warn!` and counted
446 /// in [`BrinkBatchReport::errored`], never folded into `stepped`.
447 pub errored: bool,
448 /// `true` if the flow was skipped by the Local-policy guard (#925) — its
449 /// policy homes state to [`Scope::Local`], which batch mode does not
450 /// route, so it was left for the serial API rather than stepped.
451 pub skipped_local: bool,
452}
453
454/// Diagnostic record of the most recent [`advance_batch`] turn under marker
455/// `M` — the access bookkeeping (BH-1 wiring) plus phase counts the scenario
456/// harness (BH-B) and tests read. Overwritten each batch turn.
457#[derive(Resource)]
458pub struct BrinkBatchReport<M: Send + Sync + 'static = ()> {
459 /// One record per flow stepped this turn, in flow-id order.
460 pub flows: Vec<FlowAccessRecord>,
461 /// Flows advanced to a terminal line this turn.
462 pub stepped: usize,
463 /// Flows that parked on a deferred external this turn.
464 pub awaiting: usize,
465 /// Flows whose Step faulted with a [`RuntimeError`] this turn (e.g. the
466 /// [`FlowInstance::LINE_LIMIT`] budget tripping `LineLimitExceeded`).
467 /// Disjoint from both `stepped` and `awaiting` — a faulted flow's turn
468 /// produced no lines and is not parked, so it must not be silently
469 /// counted as a normal terminal step.
470 pub errored: usize,
471 /// Flows skipped by the Local-policy guard this turn (#925): their policy
472 /// homes state to [`Scope::Local`], which batch mode does not route.
473 /// Disjoint from `stepped`/`awaiting`/`errored` — a skipped flow was never
474 /// stepped, so it must not be counted as any of those.
475 pub skipped_local: usize,
476 /// Total buffered world writes applied this turn (across all flows).
477 pub writes_applied: usize,
478 /// Total buffered command triggers applied this turn (across all flows).
479 pub commands_applied: usize,
480 _marker: PhantomData<fn() -> M>,
481}
482
483impl<M: Send + Sync + 'static> Default for BrinkBatchReport<M> {
484 fn default() -> Self {
485 Self {
486 flows: Vec::new(),
487 stepped: 0,
488 awaiting: 0,
489 errored: 0,
490 skipped_local: 0,
491 writes_applied: 0,
492 commands_applied: 0,
493 _marker: PhantomData,
494 }
495 }
496}
497
498impl<M: Send + Sync + 'static> BrinkBatchReport<M> {
499 /// Overwrite this report with the outcome of a batch turn's Apply phase.
500 fn record(&mut self, result: BatchApplyResult) {
501 self.flows = result.flows;
502 self.stepped = result.stepped;
503 self.awaiting = result.awaiting;
504 self.errored = result.errored;
505 self.skipped_local = result.skipped_local;
506 self.writes_applied = result.writes_applied;
507 self.commands_applied = result.commands_applied;
508 }
509}
510
511// ── Apply: shared between the serial and parallel drivers ────────────────────
512
513/// The counts + per-flow records an Apply pass produces — folded into a
514/// [`BrinkBatchReport`] by whichever driver ran. Shared so the serial
515/// ([`advance_batch`]) and parallel ([`parallel::advance_batch_parallel`])
516/// drivers report identically.
517///
518/// `Default` is the all-zero/empty "nothing happened" result — used when a
519/// turn collects zero flows, so the caller can skip ever touching
520/// [`BrinkGlobals`](crate::globals::BrinkGlobals) (see the call sites: taking
521/// `&mut` on an empty batch would still trip Bevy change detection, sending a
522/// **spurious** "the World changed" signal to [`crate::sleep::mark_wake_dirty`]
523/// on a turn where nothing did — issue #1082).
524#[derive(Default)]
525pub(crate) struct BatchApplyResult {
526 pub flows: Vec<FlowAccessRecord>,
527 pub stepped: usize,
528 pub awaiting: usize,
529 pub errored: usize,
530 pub skipped_local: usize,
531 pub writes_applied: usize,
532 pub commands_applied: usize,
533 /// Which shared-world cells this turn's writes actually touched — the
534 /// row-directed wake-dirtying changeset (issue #1146), folded into the
535 /// [`BrinkWorldDelta`] ledger by whichever driver ran. Empty for a turn
536 /// that collected nothing (the `Default` "nothing happened" result).
537 pub changed: WorldDelta,
538}
539
540/// One flow's deferred Apply work — the command triggers and line events that
541/// flush through a [`Commands`] *after* all buffered writes have landed. Held
542/// so the write pass (which needs `&mut World`) and the flush pass (which needs
543/// `&mut Commands`) don't fight over the world borrow in the exclusive-system
544/// parallel driver; the two passes are order-equivalent because commands and
545/// events are deferred regardless of when they're queued within the turn.
546pub(crate) struct DeferredFlush {
547 entity: Entity,
548 triggers: Vec<TriggerFn>,
549 lines: Vec<Step>,
550}
551
552/// Apply pass 1 — flush every flow's buffered world writes onto `world` (the
553/// shared [`BrinkGlobals`] world) in flow-id order (`outcomes` must already be
554/// flow-id-sorted). Write-write conflicts resolve by this order (§12.4).
555/// Builds the [`BatchApplyResult`] counts + per-flow records and hands back the
556/// deferred command/event work for [`flush_deferred`] to queue.
557pub(crate) fn apply_batch_writes(
558 outcomes: Vec<FlowBatchOutcome>,
559 world: &mut World,
560) -> (BatchApplyResult, Vec<DeferredFlush>) {
561 let mut flows = Vec::with_capacity(outcomes.len());
562 let mut deferred = Vec::with_capacity(outcomes.len());
563 let mut stepped = 0usize;
564 let mut awaiting = 0usize;
565 let mut errored = 0usize;
566 let mut skipped_local = 0usize;
567 let mut writes_applied = 0usize;
568 let mut commands_applied = 0usize;
569 let mut changed = WorldDelta::default();
570
571 for outcome in outcomes {
572 outcome.writes.apply_to(world);
573 outcome.writes.record_into(&mut changed);
574 writes_applied += outcome.writes.writes.len();
575 commands_applied += outcome.triggers.len();
576 if outcome.skipped_local {
577 skipped_local += 1;
578 } else if outcome.errored {
579 errored += 1;
580 } else if outcome.awaiting {
581 awaiting += 1;
582 } else {
583 stepped += 1;
584 }
585 flows.push(FlowAccessRecord {
586 entity: outcome.entity,
587 story: outcome.story,
588 access: outcome.access,
589 awaiting: outcome.awaiting,
590 errored: outcome.errored,
591 skipped_local: outcome.skipped_local,
592 });
593 deferred.push(DeferredFlush {
594 entity: outcome.entity,
595 triggers: outcome.triggers,
596 lines: outcome.lines,
597 });
598 }
599
600 (
601 BatchApplyResult {
602 flows,
603 stepped,
604 awaiting,
605 errored,
606 skipped_local,
607 writes_applied,
608 commands_applied,
609 changed,
610 },
611 deferred,
612 )
613}
614
615/// Fold one batch turn's outcome into the marker's row-directed wake-dirtying
616/// ledger (issue #1146) — the tail both drivers share.
617///
618/// `globals_changed_on_entry` is [`BrinkGlobals`]'s change bit **as read
619/// before** this turn applied anything: `true` means somebody the ledger
620/// cannot see (a host system, the serial driver, a direct
621/// `BrinkGlobals::inner` write) touched the shared world since this driver
622/// last ran, so the window stops being a complete account and the wake pass
623/// must stay conservative. See `crate::wake_delta`'s attribution contract.
624pub(crate) fn record_wake_delta<M: Send + Sync + 'static>(
625 ledger: &mut BrinkWorldDelta<M>,
626 result: &BatchApplyResult,
627 globals_changed_on_entry: bool,
628 globals_tick: Option<Tick>,
629) {
630 if globals_changed_on_entry {
631 ledger.note_foreign();
632 }
633 ledger.record(&result.changed, globals_tick);
634}
635
636/// Apply pass 2 — queue every flow's buffered command triggers then its line
637/// events, in flow-id order (`deferred` preserves the sort). Both are deferred
638/// through `commands`, so this runs after all writes have already landed.
639pub(crate) fn flush_deferred<M: Send + Sync + 'static>(
640 deferred: Vec<DeferredFlush>,
641 commands: &mut Commands,
642) {
643 for flush in deferred {
644 for trigger in flush.triggers {
645 commands.queue(trigger);
646 }
647 for line in &flush.lines {
648 emit_event::<M>(line, flush.entity, commands);
649 }
650 }
651}
652
653// ── The batch entry point ───────────────────────────────────────────────────
654
655/// Batch-mode flow driver (§12.4; BH-2): advance every pending flow under
656/// marker `M` as one batch turn with frame-start read pinning, per-flow
657/// buffered writes/commands, and a deterministic flow-id-ordered Apply.
658///
659/// **Not auto-registered.** Like [`advance_flow`](crate::advance_flow), a
660/// host opts in explicitly when it wants the batched, frame-start-consistent
661/// stepping semantics for its flows:
662///
663/// ```no_run
664/// # use bevy_app::{App, Update};
665/// # use bevy_brink::advance_batch;
666/// # struct MyStory;
667/// # let mut app = App::new();
668/// app.add_systems(Update, advance_batch::<MyStory>);
669/// ```
670///
671/// See the module docs for the phase model and the scope of this slice.
672#[expect(
673 clippy::needless_pass_by_value,
674 clippy::type_complexity,
675 clippy::too_many_arguments,
676 reason = "bevy systems take Res/Query by value; the flow query tuple is inherently wide, and the phase inputs (globals, policy, assets, bindings, capability table, report) are each a distinct system param"
677)]
678pub fn advance_batch<M: Send + Sync + 'static>(
679 mut flows: Query<(
680 Entity,
681 &mut BrinkFlow<M>,
682 &BrinkProgram<M>,
683 &BrinkLocale<M>,
684 Option<&FlowSleep<M>>,
685 )>,
686 globals: Option<ResMut<BrinkGlobals<M>>>,
687 policy: Option<Res<BrinkWorldPolicy<M>>>,
688 programs: Res<Assets<ProgramAsset>>,
689 line_tables_assets: Res<Assets<LineTablesAsset>>,
690 bindings: Option<Res<BrinkBindings<M>>>,
691 cap_table: Res<CapabilityTable<M>>,
692 report: Option<ResMut<BrinkBatchReport<M>>>,
693 wake_delta: Option<ResMut<BrinkWorldDelta<M>>>,
694 mut commands: Commands,
695) {
696 let Some(mut globals) = globals else {
697 return;
698 };
699
700 // Read *before* Apply takes `&mut globals.inner` (which sets the change
701 // bit itself): did anything this driver cannot account for write the
702 // shared world since this system last ran? That is what decides whether
703 // this turn's changeset is usable as a complete account by the wake pass
704 // (issue #1146 — see `crate::wake_delta`).
705 let globals_changed_on_entry = globals.is_changed();
706
707 // Host-installed policy: if it homes any unit to `Local`, batch mode can't
708 // route those flows (#925) — every flow under `M` shares this one policy.
709 let policy_local = policy.is_some_and(|p| homes_any_local(&p.policy));
710
711 // ── Collect ── pending flows in flow-id (Entity) order. A stable total
712 // order within the batch is all the frame-start guarantee needs: Step is
713 // order-invariant, and Apply is deterministic in exactly this order.
714 //
715 // BH-4 (§13.1; #973): a flow under a `FlowSleep` policy that isn't woken
716 // (parked / cancelled / faulted) is **skipped by Collect** — a parked
717 // reactive-sleep flow costs zero per turn. A flow with no policy, or a
718 // woken one, collects normally.
719 let mut collected: Vec<Entity> = flows
720 .iter()
721 .filter(|(_, flow, _, _, sleep)| {
722 !flow.inner.has_pending_external() && sleep.is_none_or(FlowSleep::wants_collect)
723 })
724 .map(|(e, _, _, _, _)| e)
725 .collect();
726 collected.sort_unstable();
727
728 // ── Frame-start snapshot ── the pinned world every flow reads this turn.
729 let frame_start = globals.inner.clone();
730
731 // ── Step ── advance each flow against the frame-start snapshot, buffering
732 // writes + command triggers. Serial here; order-invariant by construction
733 // (each flow reads only `frame_start` ⊕ its own buffer).
734 let mut outcomes: Vec<FlowBatchOutcome> = Vec::with_capacity(collected.len());
735 for &entity in &collected {
736 let Ok((_, mut flow, program_ref, locale, _)) = flows.get_mut(entity) else {
737 continue;
738 };
739 let Some(program_asset) = programs.get(&program_ref.handle) else {
740 continue;
741 };
742 let Some(lt_asset) = line_tables_assets.get(&locale.handle) else {
743 continue;
744 };
745
746 let story = program_ref.handle.id();
747 let access = cap_table.access_for(story).map(aggregate_access);
748
749 // Local-policy guard (#925): skip (don't step) a flow whose policy —
750 // host-installed or compiled `#@local` — homes state to `Local`, so
751 // batch mode never silently drops its `FlowLocal` reads/writes.
752 if policy_local || program_asset.program.has_local_defaults() {
753 warn!(
754 "batch skipping Local-policy flow {entity:?} (story {story:?}): \
755 batch mode routes only shared World state — keep it on the serial API"
756 );
757 outcomes.push(FlowBatchOutcome::skipped_local(entity, story, access));
758 continue;
759 }
760
761 outcomes.push(step_one::<M>(
762 &frame_start,
763 &mut flow.inner,
764 &program_asset.program,
765 <_asset.tables,
766 bindings.as_deref(),
767 story,
768 entity,
769 access,
770 ));
771 }
772
773 // ── Apply ── flush buffered writes, then command triggers, then line
774 // events, in flow-id order (`outcomes` is already flow-id-sorted, matching
775 // `collected`). Write-write conflicts resolve by this order (§12.4).
776 //
777 // A turn that collects nothing must not touch `globals` at all (issue
778 // #1082): `&mut globals.inner` trips Bevy change detection the instant
779 // it's taken, regardless of whether anything is actually written, and
780 // `mark_wake_dirty` treats *any* `BrinkGlobals` change as "re-check every
781 // Parked all-detect-capable policy" — so an empty turn would otherwise
782 // manufacture a spurious wake-up signal on every single frame this system
783 // runs, self-sustaining a persistent condition's re-evaluation long after
784 // its one real dependency change was already consumed.
785 let (result, deferred) = if outcomes.is_empty() {
786 (BatchApplyResult::default(), Vec::new())
787 } else {
788 apply_batch_writes(outcomes, &mut globals.inner)
789 };
790 flush_deferred::<M>(deferred, &mut commands);
791
792 if let Some(mut ledger) = wake_delta {
793 record_wake_delta(
794 &mut ledger,
795 &result,
796 globals_changed_on_entry,
797 Some(globals.last_changed()),
798 );
799 }
800 if let Some(mut report) = report {
801 report.record(result);
802 }
803}
804
805#[cfg(test)]
806#[expect(clippy::panic, reason = "tests assert via panic on the error arm")]
807mod tests;