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        self.restore_state();
145    }
146
147    fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
148        // Before the freeze gate below: an edit made while the world is paused
149        // is picked up when it lands, not when the world is next allowed to
150        // run.
151        self.reseed_if_edited(ctx);
152
153        if self.programs.is_empty() {
154            return StepResult::Continue;
155        }
156
157        if let Some(events) = ctx.events::<VolumeEvent>() {
158            self.crossings
159                .extend(events.read(&mut self.crossing_cursor).copied());
160        }
161        if let Some(events) = ctx.events::<InteractEvent>() {
162            self.presses
163                .extend(events.read(&mut self.press_cursor).copied());
164        }
165
166        let menu_active = ctx.resource::<MenuActive>().map(|m| m.0).unwrap_or(false);
167        if menu_active {
168            return StepResult::Continue;
169        }
170
171        // The frame's fixed-tick budget. Absent (a directly-stepped world with
172        // no App), every step runs exactly one tick. Edge events (crossings,
173        // presses) are consumed by the frame's first tick; catch-up ticks see
174        // none, so an edge never fires twice.
175        let timing = ctx.resource::<SimTiming>().copied().unwrap_or_default();
176        for _ in 0..timing.ticks {
177            self.sim_ticks += 1;
178            let elapsed = (self.sim_ticks as f64 * timing.tick_dt as f64) as f32;
179            self.tick(ctx, timing.tick_dt, elapsed);
180        }
181        StepResult::Continue
182    }
183}
184
185impl BehaviorSystem {
186    // Compile what the source columns now say and adopt it, keeping the run's
187    // state where the edit left it meaningful. Persisted state is not re-read:
188    // restoring a save belongs to a world starting, which `init` does around
189    // this.
190    fn reseed(&mut self, ctx: &PipelineContext) {
191        let resolved = resolve::resolve(
192            ctx.query::<Variables>().as_slice(),
193            ctx.query::<Behavior>().as_slice(),
194        );
195        self.sources = SourceTicks::of(ctx);
196        self.adopt(resolved);
197    }
198
199    // Recompile when a write has moved either source column since the programs
200    // were compiled from it. This is what lets an editing tool rewrite one of
201    // those components in place instead of reloading the world to apply it.
202    fn reseed_if_edited(&mut self, ctx: &PipelineContext) {
203        if SourceTicks::of(ctx) != self.sources {
204            self.reseed(ctx);
205        }
206    }
207
208    fn adopt(&mut self, resolved: Resolved) {
209        let programs = core::mem::take(&mut self.programs);
210        let instances = core::mem::take(&mut self.instances);
211        let carried = resolve::carry_instances(&programs, instances, &resolved.programs);
212
213        // A delay is a run of the body it was scheduled against, so one whose
214        // program was edited away is dropped rather than aimed at the new one.
215        let moved = carried.moved;
216        self.pending.retain_mut(
217            |(program, _, _)| match moved.get(*program).copied().flatten() {
218                Some(next) => {
219                    *program = next;
220                    true
221                }
222                None => false,
223            },
224        );
225
226        self.vars = resolve::carry_vars(&self.var_table, &self.vars, &resolved.var_table);
227        self.instances = carried.instances;
228        self.programs = resolved.programs;
229        self.var_table = resolved.var_table;
230        // The node-path table an observer resolves trace events through is a
231        // compile product of the programs just replaced.
232        self.trace_paths_published = false;
233    }
234
235    // Restore persisted state, but only in a world that saves: any other world
236    // starts fresh and never reads the store. Returns how many variables the
237    // restore applied.
238    fn restore_state(&mut self) -> usize {
239        if self.transient_saves || !self.programs.iter().any(|p| p.def.saves_state()) {
240            return 0;
241        }
242        let Some(state) = self.store.as_ref().and_then(|store| store.read()) else {
243            return 0;
244        };
245
246        let mut restored = 0usize;
247        for (name, value) in &state.vars {
248            let Some(slot) = self.var_table.slot_of(name) else {
249                continue;
250            };
251            // A save written before the world retyped a variable no longer
252            // applies to it; the declared starting value stands.
253            let value = Val::from_literal(value);
254            if self.vars[slot as usize].same_type(value) {
255                self.vars[slot as usize] = value;
256                restored += 1;
257            }
258        }
259        for (id, hash) in state.fired {
260            if let Some(i) = self
261                .programs
262                .iter()
263                .position(|p| p.def.asset_id.0 == id && def_hash(&p.def) == hash)
264            {
265                // World-scoped `once` state restores onto the single instance;
266                // a scoped behavior's per-entity flags are not persisted,
267                // matching its locals.
268                if !self.programs[i].is_scoped() {
269                    let mut instance = Instance::new(None, Vec::new(), false);
270                    instance.fired_once = true;
271                    self.instances[i].clear();
272                    self.instances[i].push(instance);
273                }
274            }
275        }
276        restored
277    }
278
279    // Create instances for newly matching entities and drop those whose entity
280    // is gone, preserving the state of everything that persists.
281    fn resync_instances(&mut self, snapshot: &Snapshot, frame: FrameContext) {
282        // A variable source starts baselined at the variable's current value,
283        // so a restored save does not read as a change on the instance's first
284        // tick. Read before the loop, which borrows `self.instances` mutably.
285        let baselines = frame.collect(self.programs.iter().map(|p| {
286            match &p.def.on {
287                BehaviorSource::Variable(name) => self
288                    .var_table
289                    .slot_of(name)
290                    .and_then(|s| self.vars.get(s as usize))
291                    .copied()
292                    .unwrap_or(Val::Int(0)),
293                _ => Val::Int(0),
294            }
295        }));
296        for (i, program) in self.programs.iter().enumerate() {
297            if !program.is_scoped() {
298                if self.instances[i].is_empty() {
299                    let mut instance = Instance::new(None, Vec::new(), false);
300                    instance.last_value = baselines[i];
301                    self.instances[i].push(instance);
302                }
303                continue;
304            }
305            let matched = &snapshot.scoped[i];
306            self.instances[i].retain(|inst| {
307                inst.entity.is_some_and(|e| {
308                    matched
309                        .binary_search_by_key(&e.to_bits(), |o| o.to_bits())
310                        .is_ok()
311                })
312            });
313            // The retained instances are a sorted subset of the sorted
314            // `matched`, so one merge walk finds the entities without an
315            // instance; in the steady state nothing is appended and the order
316            // already stands.
317            let before = self.instances[i].len();
318            let mut j = 0;
319            for entity in matched {
320                if j < before && self.instances[i][j].entity == Some(*entity) {
321                    j += 1;
322                    continue;
323                }
324                let mut instance =
325                    Instance::new(Some(*entity), program.local_inits.clone(), self.populated);
326                instance.last_value = baselines[i];
327                self.instances[i].push(instance);
328            }
329            if self.instances[i].len() != before {
330                self.instances[i].sort_by_key(|inst| inst.entity.map(|e| e.to_bits()));
331            }
332        }
333    }
334
335    fn tick(&mut self, ctx: &mut PipelineContext, dt: f32, elapsed: f32) {
336        // Execution tracing is on only while an observer's request stands
337        // (the editor's Behavior panel); its absence costs this one lookup.
338        let request = ctx.resource::<TraceRequest>().cloned();
339        let tracing = request.is_some();
340        let mut fired: Vec<(usize, Vec<u32>)> = Vec::new();
341
342        // Copied out of the context so the frame temporaries below can hold
343        // scratch while `ctx` stays usable. Main-thread only: the parallel
344        // eval workers keep their per-worker persistent buffers (see
345        // `eval_one`), and nothing arena-backed crosses into them.
346        let frame = ctx.frame;
347
348        let mut snapshot = core::mem::take(&mut self.snapshot);
349        self.gather(ctx, &mut snapshot);
350        self.resync_instances(&snapshot, frame);
351        self.populated = true;
352
353        // Fire decisions run against this tick's starting values, so a `set`
354        // here is seen by variable-source behaviors next tick and chains
355        // advance one link per tick. Every instance could fire, so the
356        // reservation is exact.
357        let bound: usize = self.instances.iter().map(Vec::len).sum();
358        let mut runs = frame.vec::<(usize, Option<Entity>)>(bound);
359        for i in 0..self.programs.len() {
360            let var_slot = match &self.programs[i].def.on {
361                BehaviorSource::Variable(name) => self.var_table.slot_of(name),
362                _ => None,
363            };
364            let def = &self.programs[i].def;
365            for instance in &mut self.instances[i] {
366                if instance.due(
367                    def,
368                    &self.vars,
369                    var_slot,
370                    dt,
371                    &self.crossings,
372                    &self.presses,
373                ) {
374                    runs.push((i, instance.entity));
375                }
376            }
377        }
378        self.crossings.clear();
379        self.presses.clear();
380
381        // Delayed runs from earlier ticks count down first: those now due run
382        // before this tick's firings, in discovery order, exactly as before.
383        // A fresh delay pushed below starts counting next tick.
384        let mut jobs = core::mem::take(&mut self.jobs);
385        jobs.clear();
386        let mut idx = 0;
387        while idx < self.pending.len() {
388            self.pending[idx].2 -= dt;
389            if self.pending[idx].2 <= 0.0 {
390                let (i, entity, _) = self.pending.swap_remove(idx);
391                jobs.push((i, entity));
392            } else {
393                idx += 1;
394            }
395        }
396        for &(i, entity) in runs.iter() {
397            let delay = self.programs[i].def.delay;
398            if delay > 0.0 {
399                self.pending.push((i, entity, delay));
400            } else {
401                jobs.push((i, entity));
402            }
403        }
404
405        // Read phase: every body runs against an unchanged world, so the
406        // borrow here is shared and the effects it produces are applied only
407        // after it ends. Serially each run appends into one buffer and
408        // records how much it added; with enough firing instances under the
409        // parallel schedule, contiguous job chunks evaluate through the host's
410        // scheduler into per-worker buffers instead. Either way a body observes
411        // only the tick's starting state, so the results are identical; only
412        // the apply order below is observable, and it walks jobs in list order
413        // in both modes.
414        let parallel = self.scheduler.is_some()
415            && jobs.len() >= PARALLEL_EVAL_MIN_JOBS
416            && ScheduleMode::current(ctx.resources) == ScheduleMode::Parallel;
417        let scheduler = self.scheduler.as_deref().filter(|_| parallel);
418        let mut effects = core::mem::take(&mut self.serial_effects);
419        let mut produced = core::mem::take(&mut self.serial_produced);
420        effects.clear();
421        produced.clear();
422        let mut buckets = core::mem::take(&mut self.eval_buckets);
423        let mut serial_bindings = core::mem::take(&mut self.bindings);
424        {
425            let ec = EvalCtx {
426                components: ctx.components,
427                names: ctx.resource::<EntityByName>(),
428                snapshot: &snapshot,
429                programs: &self.programs,
430                instances: &self.instances,
431                vars: &self.vars,
432                dt,
433                elapsed,
434                tracing,
435            };
436            if let Some(scheduler) = scheduler {
437                let workers = scheduler.workers().max(1);
438                while buckets.len() < workers {
439                    buckets.push(EvalBucket::default());
440                }
441                let chunk = jobs.len().div_ceil(buckets.len()).max(1);
442                for (b, bucket) in buckets.iter_mut().enumerate() {
443                    bucket.jobs = (b * chunk).min(jobs.len())..((b + 1) * chunk).min(jobs.len());
444                }
445                let jobs = &jobs;
446                let ec = &ec;
447                scheduler.run(&mut buckets, &|bucket| {
448                    bucket.effects.clear();
449                    bucket.produced.clear();
450                    bucket.fired.clear();
451                    for &(i, entity) in &jobs[bucket.jobs.clone()] {
452                        if let Some((count, nodes)) =
453                            eval_one(ec, &mut bucket.bindings, i, entity, &mut bucket.effects)
454                        {
455                            bucket.produced.push((i, entity, count));
456                            if ec.tracing {
457                                bucket.fired.push((i, nodes));
458                            }
459                        }
460                    }
461                });
462            } else {
463                for &(i, entity) in &jobs {
464                    if let Some((count, nodes)) =
465                        eval_one(&ec, &mut serial_bindings, i, entity, &mut effects)
466                    {
467                        produced.push((i, entity, count));
468                        if tracing {
469                            fired.push((i, nodes));
470                        }
471                    }
472                }
473            }
474        }
475
476        // Walking each buffer once hands each run exactly the effects it
477        // appended, in record order, without copying or reshuffling them.
478        // Bucket order is job order, so the parallel apply is byte-identical
479        // to the serial one.
480        let mut save_requested = false;
481        if parallel {
482            for bucket in &mut buckets {
483                let mut recorded = bucket.effects.drain(..);
484                for k in 0..bucket.produced.len() {
485                    let (i, entity, count) = bucket.produced[k];
486                    save_requested |= self.apply(ctx, i, entity, recorded.by_ref().take(count));
487                }
488                if tracing {
489                    fired.append(&mut bucket.fired);
490                }
491            }
492        } else {
493            let mut recorded = effects.drain(..);
494            for &(i, entity, count) in &produced {
495                save_requested |= self.apply(ctx, i, entity, recorded.by_ref().take(count));
496            }
497        }
498        self.eval_buckets = buckets;
499        self.jobs = jobs;
500        self.bindings = serial_bindings;
501        self.serial_effects = effects;
502        self.serial_produced = produced;
503        self.snapshot = snapshot;
504
505        // One write per tick, after every effect has landed, so the store holds
506        // this tick's final values.
507        if save_requested {
508            self.write_state();
509        }
510
511        if let Some(request) = request {
512            self.publish_trace(ctx, &request, &fired);
513        }
514    }
515
516    fn write_state(&self) {
517        if self.transient_saves {
518            return;
519        }
520        let Some(store) = self.store.as_ref() else {
521            return;
522        };
523        store.write(&BehaviorState {
524            vars: self
525                .var_table
526                .names()
527                .iter()
528                .zip(&self.vars)
529                .map(|(name, value)| (name.clone(), value.to_literal()))
530                .collect(),
531            fired: self
532                .programs
533                .iter()
534                .enumerate()
535                .filter(|(i, p)| {
536                    p.def.once
537                        && !p.is_scoped()
538                        && self.instances[*i].iter().any(|inst| inst.fired_once)
539                })
540                .map(|(_, p)| (p.def.asset_id.0, def_hash(&p.def)))
541                .collect(),
542        });
543    }
544}