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}