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}