Skip to main content

concinnity_core/behavior/system/
mod.rs

1//! The system that drives compiled behaviors: gathers what a body may read,
2//! runs it on the VM, and applies the effects it produced.
3//!
4//! ```text
5//! mod.rs       the system, its per-tick drive, and instance bookkeeping
6//! instance.rs  one behavior's firing state and its clocks
7//! resolve.rs   compiling the source columns, at start and after an edit
8//! eval.rs      the read phase: the tick's view, its buffers, its schedule
9//! apply.rs     the write phase: effects landing on the world
10//! state.rs     persisted variables and `once` flags, behind a host's store
11//! trace.rs     the execution trace an observer requests
12//! ```
13//!
14//! A behavior with an empty `scope` runs once per firing; a scoped one runs once
15//! per matching entity, each with its own locals. Each tick is read, run, apply:
16//! the body sees a snapshot and produces effects, and only then does anything
17//! change, so no behavior observes another's writes mid-tick.
18//!
19//! Clocks advance with the simulation rather than the wall, and freeze while a
20//! menu is open, like the rest of the world clock.
21
22mod apply;
23mod eval;
24mod instance;
25mod resolve;
26mod state;
27mod trace;
28
29#[cfg(test)]
30mod bench;
31#[cfg(test)]
32mod test_world;
33#[cfg(test)]
34mod tests;
35
36use alloc::boxed::Box;
37use alloc::vec::Vec;
38
39use eval::{EvalCtx, PARALLEL_EVAL_MIN_JOBS, Snapshot, eval_one};
40use instance::Instance;
41use resolve::{Resolved, SourceTicks};
42
43pub use eval::{EvalBucket, EvalScheduler};
44pub use state::{BehaviorState, BehaviorStore, def_hash};
45
46use crate::behavior::{Effect, Program, Val, VarTable};
47use crate::components::{Behavior, BehaviorSource, InteractEvent, Variables, VolumeEvent};
48use crate::ecs::{
49    Entity, EntityByName, EventCursor, FrameContext, MenuActive, PipelineContext, ScheduleMode,
50    SimTiming, StepResult, System, TraceRequest, TransientSaves,
51};
52
53/// Runs a world's [`Behavior`] components: their firing rules, their bodies,
54/// and the effects those produce.
55///
56/// [`new`](BehaviorSystem::new) evaluates every run on the calling thread and
57/// persists nothing; a host lends it a thread pool through
58/// [`with_scheduler`](BehaviorSystem::with_scheduler) and somewhere to keep
59/// state through [`with_store`](BehaviorSystem::with_store).
60#[derive(Debug, Default)]
61pub struct BehaviorSystem {
62    programs: Vec<Program>,
63    // Parallel to `programs`.
64    instances: Vec<Vec<Instance>>,
65    vars: Vec<Val>,
66    var_table: VarTable,
67    // Delayed runs: (program, the instance's entity, seconds left).
68    pending: Vec<(usize, Option<Entity>, f32)>,
69    crossing_cursor: EventCursor,
70    press_cursor: EventCursor,
71    crossings: Vec<VolumeEvent>,
72    presses: Vec<InteractEvent>,
73    // `None` when the host keeps no behavior state: behaviors run, saving does
74    // not.
75    store: Option<Box<dyn BehaviorStore>>,
76    // `None` when the host has no thread pool to lend: every run evaluates on
77    // the calling thread.
78    scheduler: Option<Box<dyn EvalScheduler>>,
79    // Sampled from the `TransientSaves` resource at init: while true, stored
80    // state is neither read nor written, so a preview session starts fresh and
81    // leaves the user's saves untouched.
82    transient_saves: bool,
83    // The source columns as of the resolution the programs came from. A write
84    // to either moves one, and the next step recompiles rather than running a
85    // body the world no longer holds.
86    sources: SourceTicks,
87    // Execution tracing (see `trace.rs`): the published tick counter, and
88    // whether the node-path table has been published this world.
89    trace_frame: u64,
90    trace_paths_published: bool,
91    // Fixed ticks run so far; elapsed simulated time is this times the tick
92    // length, so behavior clocks advance with the simulation, not the wall.
93    sim_ticks: u64,
94    // Instances present before the first tick are the world's initial
95    // population, so `spawned` does not fire for them.
96    populated: bool,
97    // Per-worker evaluation state, grown to the scheduler's width on the first
98    // parallel tick and reused thereafter so steady-state allocations stay
99    // flat.
100    eval_buckets: Vec<EvalBucket>,
101    // The tick's firing list and the serial path's binding scratch, kept for
102    // their capacity across ticks.
103    jobs: Vec<(usize, Option<Entity>)>,
104    bindings: Vec<Option<Val>>,
105    // The serial path's effect/record buffers, kept for their capacity like
106    // the parallel path's per-worker buckets.
107    serial_effects: Vec<Effect>,
108    serial_produced: Vec<(usize, Option<Entity>, usize)>,
109    // The tick's resolved entity sets and the tag-intersection scratch, kept
110    // for their capacity across ticks like the buffers above.
111    snapshot: Snapshot,
112    tag_scratch: Vec<Entity>,
113}
114
115impl BehaviorSystem {
116    /// A system that evaluates serially and persists nothing.
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Keep this world's variables and `once` flags in `store`.
122    pub fn with_store(mut self, store: Box<dyn BehaviorStore>) -> Self {
123        self.store = Some(store);
124        self
125    }
126
127    /// Fan a tick's evaluation out through `scheduler` once enough instances
128    /// fire at once to pay for it.
129    pub fn with_scheduler(mut self, scheduler: Box<dyn EvalScheduler>) -> Self {
130        self.scheduler = Some(scheduler);
131        self
132    }
133}
134
135impl System for BehaviorSystem {
136    fn init(&mut self, ctx: &mut PipelineContext) {
137        // A world starting is the resolution with nothing to carry over.
138        self.reseed(ctx);
139
140        self.transient_saves = ctx.resource::<TransientSaves>().is_some_and(|t| t.0);
141        self.trace_frame = 0;
142        self.sim_ticks = 0;
143
144        let restored = self.restore_state();
145        tracing::info!(
146            "BehaviorSystem: {} behavior(s), {} variable(s), restored {}",
147            self.programs.len(),
148            self.vars.len(),
149            restored,
150        );
151    }
152
153    fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
154        // Before the freeze gate below: an edit made while the world is paused
155        // is picked up when it lands, not when the world is next allowed to
156        // run.
157        self.reseed_if_edited(ctx);
158
159        if self.programs.is_empty() {
160            return StepResult::Continue;
161        }
162
163        if let Some(events) = ctx.events::<VolumeEvent>() {
164            self.crossings
165                .extend(events.read(&mut self.crossing_cursor).copied());
166        }
167        if let Some(events) = ctx.events::<InteractEvent>() {
168            self.presses
169                .extend(events.read(&mut self.press_cursor).copied());
170        }
171
172        let menu_active = ctx.resource::<MenuActive>().map(|m| m.0).unwrap_or(false);
173        if menu_active {
174            return StepResult::Continue;
175        }
176
177        // The frame's fixed-tick budget. Absent (a directly-stepped world with
178        // no App), every step runs exactly one tick. Edge events (crossings,
179        // presses) are consumed by the frame's first tick; catch-up ticks see
180        // none, so an edge never fires twice.
181        let timing = ctx.resource::<SimTiming>().copied().unwrap_or_default();
182        for _ in 0..timing.ticks {
183            self.sim_ticks += 1;
184            let elapsed = (self.sim_ticks as f64 * timing.tick_dt as f64) as f32;
185            self.tick(ctx, timing.tick_dt, elapsed);
186        }
187        StepResult::Continue
188    }
189}
190
191impl BehaviorSystem {
192    // Compile what the source columns now say and adopt it, keeping the run's
193    // state where the edit left it meaningful. Persisted state is not re-read:
194    // restoring a save belongs to a world starting, which `init` does around
195    // this.
196    fn reseed(&mut self, ctx: &PipelineContext) {
197        let resolved = resolve::resolve(
198            ctx.query::<Variables>().as_slice(),
199            ctx.query::<Behavior>().as_slice(),
200        );
201        self.sources = SourceTicks::of(ctx);
202        self.adopt(resolved);
203    }
204
205    // Recompile when a write has moved either source column since the programs
206    // were compiled from it. This is what lets an editing tool rewrite one of
207    // those components in place instead of reloading the world to apply it.
208    fn reseed_if_edited(&mut self, ctx: &PipelineContext) {
209        if SourceTicks::of(ctx) != self.sources {
210            self.reseed(ctx);
211        }
212    }
213
214    fn adopt(&mut self, resolved: Resolved) {
215        let programs = core::mem::take(&mut self.programs);
216        let instances = core::mem::take(&mut self.instances);
217        let carried = resolve::carry_instances(&programs, instances, &resolved.programs);
218
219        // A delay is a run of the body it was scheduled against, so one whose
220        // program was edited away is dropped rather than aimed at the new one.
221        let moved = carried.moved;
222        self.pending.retain_mut(
223            |(program, _, _)| match moved.get(*program).copied().flatten() {
224                Some(next) => {
225                    *program = next;
226                    true
227                }
228                None => false,
229            },
230        );
231
232        self.vars = resolve::carry_vars(&self.var_table, &self.vars, &resolved.var_table);
233        self.instances = carried.instances;
234        self.programs = resolved.programs;
235        self.var_table = resolved.var_table;
236        // The node-path table an observer resolves trace events through is a
237        // compile product of the programs just replaced.
238        self.trace_paths_published = false;
239    }
240
241    // Restore persisted state, but only in a world that saves: any other world
242    // starts fresh and never reads the store. Returns how many variables the
243    // restore applied.
244    fn restore_state(&mut self) -> usize {
245        if self.transient_saves || !self.programs.iter().any(|p| p.def.saves_state()) {
246            return 0;
247        }
248        let Some(state) = self.store.as_ref().and_then(|store| store.read()) else {
249            return 0;
250        };
251
252        let mut restored = 0usize;
253        for (name, value) in &state.vars {
254            let Some(slot) = self.var_table.slot_of(name) else {
255                continue;
256            };
257            // A save written before the world retyped a variable no longer
258            // applies to it; the declared starting value stands.
259            let value = Val::from_literal(value);
260            if self.vars[slot as usize].same_type(value) {
261                self.vars[slot as usize] = value;
262                restored += 1;
263            }
264        }
265        for (id, hash) in state.fired {
266            if let Some(i) = self
267                .programs
268                .iter()
269                .position(|p| p.def.asset_id.0 == id && def_hash(&p.def) == hash)
270            {
271                // World-scoped `once` state restores onto the single instance;
272                // a scoped behavior's per-entity flags are not persisted,
273                // matching its locals.
274                if !self.programs[i].is_scoped() {
275                    let mut instance = Instance::new(None, Vec::new(), false);
276                    instance.fired_once = true;
277                    self.instances[i].clear();
278                    self.instances[i].push(instance);
279                }
280            }
281        }
282        restored
283    }
284
285    // Create instances for newly matching entities and drop those whose entity
286    // is gone, preserving the state of everything that persists.
287    fn resync_instances(&mut self, snapshot: &Snapshot, frame: FrameContext) {
288        // A variable source starts baselined at the variable's current value,
289        // so a restored save does not read as a change on the instance's first
290        // tick. Read before the loop, which borrows `self.instances` mutably.
291        let baselines = frame.collect(self.programs.iter().map(|p| {
292            match &p.def.on {
293                BehaviorSource::Variable(name) => self
294                    .var_table
295                    .slot_of(name)
296                    .and_then(|s| self.vars.get(s as usize))
297                    .copied()
298                    .unwrap_or(Val::Int(0)),
299                _ => Val::Int(0),
300            }
301        }));
302        for (i, program) in self.programs.iter().enumerate() {
303            if !program.is_scoped() {
304                if self.instances[i].is_empty() {
305                    let mut instance = Instance::new(None, Vec::new(), false);
306                    instance.last_value = baselines[i];
307                    self.instances[i].push(instance);
308                }
309                continue;
310            }
311            let matched = &snapshot.scoped[i];
312            self.instances[i].retain(|inst| {
313                inst.entity.is_some_and(|e| {
314                    matched
315                        .binary_search_by_key(&e.to_bits(), |o| o.to_bits())
316                        .is_ok()
317                })
318            });
319            // The retained instances are a sorted subset of the sorted
320            // `matched`, so one merge walk finds the entities without an
321            // instance; in the steady state nothing is appended and the order
322            // already stands.
323            let before = self.instances[i].len();
324            let mut j = 0;
325            for entity in matched {
326                if j < before && self.instances[i][j].entity == Some(*entity) {
327                    j += 1;
328                    continue;
329                }
330                let mut instance =
331                    Instance::new(Some(*entity), program.local_inits.clone(), self.populated);
332                instance.last_value = baselines[i];
333                self.instances[i].push(instance);
334            }
335            if self.instances[i].len() != before {
336                self.instances[i].sort_by_key(|inst| inst.entity.map(|e| e.to_bits()));
337            }
338        }
339    }
340
341    fn tick(&mut self, ctx: &mut PipelineContext, dt: f32, elapsed: f32) {
342        // Execution tracing is on only while an observer's request stands
343        // (the editor's Behavior panel); its absence costs this one lookup.
344        let request = ctx.resource::<TraceRequest>().cloned();
345        let tracing = request.is_some();
346        let mut fired: Vec<(usize, Vec<u32>)> = Vec::new();
347
348        // Copied out of the context so the frame temporaries below can hold
349        // scratch while `ctx` stays usable. Main-thread only: the parallel
350        // eval workers keep their per-worker persistent buffers (see
351        // `eval_one`), and nothing arena-backed crosses into them.
352        let frame = ctx.frame;
353
354        let mut snapshot = core::mem::take(&mut self.snapshot);
355        self.gather(ctx, &mut snapshot);
356        self.resync_instances(&snapshot, frame);
357        self.populated = true;
358
359        // Fire decisions run against this tick's starting values, so a `set`
360        // here is seen by variable-source behaviors next tick and chains
361        // advance one link per tick. Every instance could fire, so the
362        // reservation is exact.
363        let bound: usize = self.instances.iter().map(Vec::len).sum();
364        let mut runs = frame.vec::<(usize, Option<Entity>)>(bound);
365        for i in 0..self.programs.len() {
366            let var_slot = match &self.programs[i].def.on {
367                BehaviorSource::Variable(name) => self.var_table.slot_of(name),
368                _ => None,
369            };
370            let def = &self.programs[i].def;
371            for instance in &mut self.instances[i] {
372                if instance.due(
373                    def,
374                    &self.vars,
375                    var_slot,
376                    dt,
377                    &self.crossings,
378                    &self.presses,
379                ) {
380                    runs.push((i, instance.entity));
381                }
382            }
383        }
384        self.crossings.clear();
385        self.presses.clear();
386
387        // Delayed runs from earlier ticks count down first: those now due run
388        // before this tick's firings, in discovery order, exactly as before.
389        // A fresh delay pushed below starts counting next tick.
390        let mut jobs = core::mem::take(&mut self.jobs);
391        jobs.clear();
392        let mut idx = 0;
393        while idx < self.pending.len() {
394            self.pending[idx].2 -= dt;
395            if self.pending[idx].2 <= 0.0 {
396                let (i, entity, _) = self.pending.swap_remove(idx);
397                jobs.push((i, entity));
398            } else {
399                idx += 1;
400            }
401        }
402        for &(i, entity) in runs.iter() {
403            let delay = self.programs[i].def.delay;
404            if delay > 0.0 {
405                self.pending.push((i, entity, delay));
406            } else {
407                jobs.push((i, entity));
408            }
409        }
410
411        // Read phase: every body runs against an unchanged world, so the
412        // borrow here is shared and the effects it produces are applied only
413        // after it ends. Serially each run appends into one buffer and
414        // records how much it added; with enough firing instances under the
415        // parallel schedule, contiguous job chunks evaluate through the host's
416        // scheduler into per-worker buffers instead. Either way a body observes
417        // only the tick's starting state, so the results are identical; only
418        // the apply order below is observable, and it walks jobs in list order
419        // in both modes.
420        let parallel = self.scheduler.is_some()
421            && jobs.len() >= PARALLEL_EVAL_MIN_JOBS
422            && ScheduleMode::current(ctx.resources) == ScheduleMode::Parallel;
423        let scheduler = self.scheduler.as_deref().filter(|_| parallel);
424        let mut effects = core::mem::take(&mut self.serial_effects);
425        let mut produced = core::mem::take(&mut self.serial_produced);
426        effects.clear();
427        produced.clear();
428        let mut buckets = core::mem::take(&mut self.eval_buckets);
429        let mut serial_bindings = core::mem::take(&mut self.bindings);
430        {
431            let ec = EvalCtx {
432                components: ctx.components,
433                names: ctx.resource::<EntityByName>(),
434                snapshot: &snapshot,
435                programs: &self.programs,
436                instances: &self.instances,
437                vars: &self.vars,
438                dt,
439                elapsed,
440                tracing,
441            };
442            if let Some(scheduler) = scheduler {
443                let workers = scheduler.workers().max(1);
444                while buckets.len() < workers {
445                    buckets.push(EvalBucket::default());
446                }
447                let chunk = jobs.len().div_ceil(buckets.len()).max(1);
448                for (b, bucket) in buckets.iter_mut().enumerate() {
449                    bucket.jobs = (b * chunk).min(jobs.len())..((b + 1) * chunk).min(jobs.len());
450                }
451                let jobs = &jobs;
452                let ec = &ec;
453                scheduler.run(&mut buckets, &|bucket| {
454                    bucket.effects.clear();
455                    bucket.produced.clear();
456                    bucket.fired.clear();
457                    for &(i, entity) in &jobs[bucket.jobs.clone()] {
458                        if let Some((count, nodes)) =
459                            eval_one(ec, &mut bucket.bindings, i, entity, &mut bucket.effects)
460                        {
461                            bucket.produced.push((i, entity, count));
462                            if ec.tracing {
463                                bucket.fired.push((i, nodes));
464                            }
465                        }
466                    }
467                });
468            } else {
469                for &(i, entity) in &jobs {
470                    if let Some((count, nodes)) =
471                        eval_one(&ec, &mut serial_bindings, i, entity, &mut effects)
472                    {
473                        produced.push((i, entity, count));
474                        if tracing {
475                            fired.push((i, nodes));
476                        }
477                    }
478                }
479            }
480        }
481
482        // Walking each buffer once hands each run exactly the effects it
483        // appended, in record order, without copying or reshuffling them.
484        // Bucket order is job order, so the parallel apply is byte-identical
485        // to the serial one.
486        let mut save_requested = false;
487        if parallel {
488            for bucket in &mut buckets {
489                let mut recorded = bucket.effects.drain(..);
490                for k in 0..bucket.produced.len() {
491                    let (i, entity, count) = bucket.produced[k];
492                    save_requested |= self.apply(ctx, i, entity, recorded.by_ref().take(count));
493                }
494                if tracing {
495                    fired.append(&mut bucket.fired);
496                }
497            }
498        } else {
499            let mut recorded = effects.drain(..);
500            for &(i, entity, count) in &produced {
501                save_requested |= self.apply(ctx, i, entity, recorded.by_ref().take(count));
502            }
503        }
504        self.eval_buckets = buckets;
505        self.jobs = jobs;
506        self.bindings = serial_bindings;
507        self.serial_effects = effects;
508        self.serial_produced = produced;
509        self.snapshot = snapshot;
510
511        // One write per tick, after every effect has landed, so the store holds
512        // this tick's final values.
513        if save_requested {
514            self.write_state();
515        }
516
517        if let Some(request) = request {
518            self.publish_trace(ctx, &request, &fired);
519        }
520    }
521
522    fn write_state(&self) {
523        if self.transient_saves {
524            return;
525        }
526        let Some(store) = self.store.as_ref() else {
527            return;
528        };
529        store.write(&BehaviorState {
530            vars: self
531                .var_table
532                .names()
533                .iter()
534                .zip(&self.vars)
535                .map(|(name, value)| (name.clone(), value.to_literal()))
536                .collect(),
537            fired: self
538                .programs
539                .iter()
540                .enumerate()
541                .filter(|(i, p)| {
542                    p.def.once
543                        && !p.is_scoped()
544                        && self.instances[*i].iter().any(|inst| inst.fired_once)
545                })
546                .map(|(_, p)| (p.def.asset_id.0, def_hash(&p.def)))
547                .collect(),
548        });
549    }
550}