Skip to main content

concinnity_engine/ecs/
registry.rs

1// src/ecs/registry.rs
2//
3// The system table: the one place a system is registered and the one schedule
4// document. `define_systems!` generates the entries from it; table order is run
5// order. Every system is internal: it has no declarable asset and carries no
6// discriminant. `World::start` runs each entry's gate against the world's
7// content and pushes the systems the gates return, in table order. To add a
8// system: implement `System` on it, write its gate in `schedule`, and add one
9// entry here in its run position with its ordering edges.
10//
11// `after`/`before` are the cross-system ordering constraints, validated
12// against table order when the world's schedule is built.
13// Each edge's rationale:
14//   * Overlay first: publishes the menu state (`MenuActive`) that gates
15//     simulation, input, and the draw this same tick.
16//   * SkyRotation before Graphics: the orientation it publishes is what the
17//     frame's transform propagation, directional-light push and cubemap
18//     samples are taken at.
19//   * Behavior before Spawn/Settings/Story/Audio: the requests its firing
20//     rules emit (spawn/despawn, scene, story, audio) drain the same tick.
21//   * Spawn/Settings/Streaming before Graphics: despawns leave the transform
22//     push, setting and streaming ops land before this frame's submit, and
23//     `CameraRelativeView` is ready for the draw.
24//   * Input after Graphics: on Metal the OS event pump runs inside
25//     draw_frame, so sampling right after the draw snapshots the freshest
26//     events (the mailbox deposit happens in Graphics' step).
27//   * LoadingOverlay after Streaming (reads the residency status published
28//     this tick) and before UiInput (its screen commands apply same tick).
29//   * Physics before the camera controllers: physics consumes the camera's
30//     previous-frame `desired_move` (a one-frame-lagged resolution).
31//   * Cameras and Story before Audio: the listener reads the camera, and a
32//     `PlayCue` page audio is heard the same tick.
33// Event-carried couplings (RootMotionEvent, GroundProbes, SettingCommand) are
34// order-robust thanks to the event store's two-frame retention.
35
36use crate::ecs::{PipelineContext, access_ids, decompose, schedule};
37
38// Runs once at world start, after the gates have built the systems and before
39// their `init`. The engine defaults the world is completed with land earlier
40// still (`complete_world` below), so a Prop one of them injects decomposes
41// here like any other.
42fn before_init(ctx: &mut PipelineContext) {
43    // Every context touch a stepping system makes is asserted against what it
44    // declared; the hook is installed before the first step can happen.
45    #[cfg(debug_assertions)]
46    access_ids::install_hook();
47    decompose::run(ctx);
48}
49
50crate::define_systems! {
51    complete_world: concinnity_core::defaults::run,
52    before_init: before_init,
53    prepare_events: access_ids::ensure_event_queues,
54
55    OverlaySystem => crate::gfx::overlay::OverlaySystem {
56        gate: schedule::overlay,
57        present_when: "the world declares a GraphicsConfig",
58        after: [],
59        before: [BehaviorSystem, SpawnSystem, GraphicsSystem, InputSystem, PhysicsSystem, AnimationSystem],
60    },
61    SkyRotationSystem => concinnity_core::sky::SkyRotationSystem {
62        gate: schedule::sky_rotation,
63        present_when: "the world declares a SkyRotation",
64        after: [OverlaySystem],
65        before: [GraphicsSystem],
66    },
67    BehaviorSystem => concinnity_core::behavior::BehaviorSystem {
68        gate: schedule::behavior,
69        present_when: "the world declares any Behavior",
70        after: [OverlaySystem],
71        before: [SpawnSystem, SettingsSystem, StorySystem, AudioSystem],
72    },
73    SpawnSystem => crate::spawn::SpawnSystem {
74        gate: schedule::spawn,
75        present_when: "the world declares a GraphicsConfig",
76        after: [BehaviorSystem],
77        before: [GraphicsSystem],
78    },
79    SettingsSystem => crate::gfx::settings_system::SettingsSystem {
80        gate: schedule::settings,
81        present_when: "the world declares a GraphicsConfig",
82        after: [],
83        before: [GraphicsSystem],
84    },
85    StreamingSystem => crate::gfx::streaming_system::StreamingSystem {
86        gate: schedule::streaming,
87        present_when: "the world declares a GraphicsConfig",
88        after: [],
89        before: [GraphicsSystem],
90    },
91    GraphicsSystem => crate::gfx::graphics_system::GraphicsSystem {
92        gate: schedule::graphics,
93        present_when: "the world declares a GraphicsConfig",
94        after: [SpawnSystem, SettingsSystem, StreamingSystem],
95        before: [InputSystem],
96    },
97    InputSystem => crate::gfx::input_system::InputSystem {
98        gate: schedule::input,
99        present_when: "the world declares a GraphicsConfig",
100        after: [GraphicsSystem],
101        before: [],
102    },
103    StatHud => crate::hud::stat_hud::StatHudSystem {
104        gate: schedule::stat_hud,
105        present_when: "the world declares a StatHud",
106        after: [],
107        before: [],
108    },
109    DebugHud => crate::hud::debug_hud::DebugHudSystem {
110        gate: schedule::debug_hud,
111        present_when: "the world declares a DebugHud AND the binary is a debug build or a `cn debug` session",
112        after: [],
113        before: [],
114    },
115    LoadingOverlaySystem => crate::hud::loading_overlay::LoadingOverlaySystem {
116        gate: schedule::loading_overlay,
117        present_when: "the world declares a LoadingOverlay",
118        after: [StreamingSystem],
119        before: [UiInputSystem],
120    },
121    PhysicsSystem => concinnity_core::physics::PhysicsSystem {
122        gate: schedule::physics,
123        present_when: "the world declares a PhysicsConfig, RigidBody, PropBody, or TriggerVolume, or a skinned mesh bakes a character capsule",
124        after: [OverlaySystem],
125        before: [Camera3DSystem, ThirdPersonSystem],
126    },
127    Camera3DSystem => crate::gfx::camera_controller::Camera3DSystem {
128        gate: schedule::camera3d,
129        present_when: "the first controlled Camera3D has no follow block",
130        after: [PhysicsSystem],
131        before: [AudioSystem],
132    },
133    ThirdPersonSystem => crate::gfx::third_person::ThirdPersonSystem {
134        gate: schedule::third_person,
135        present_when: "the first controlled Camera3D has a follow block",
136        after: [PhysicsSystem],
137        before: [AudioSystem],
138    },
139    FpsCounter => crate::hud::fps_counter::FpsCounterSystem {
140        gate: schedule::fps_counter,
141        present_when: "the world declares an FpsCounter",
142        after: [],
143        before: [],
144    },
145    AnimationSystem => crate::gfx::animation::AnimationSystem {
146        gate: schedule::animation,
147        present_when: "the world declares any Animation or AnimationGraph",
148        after: [OverlaySystem],
149        before: [],
150    },
151    StorySystem => crate::story::StorySystem {
152        gate: schedule::story,
153        present_when: "the world declares a Story",
154        after: [BehaviorSystem],
155        before: [AudioSystem],
156    },
157    AudioSystem => crate::audio::AudioSystem {
158        gate: schedule::audio,
159        present_when: "the world declares any AudioEmitter, AudioCue, a Story page/choice with audio, or a Behavior with a sound node",
160        after: [BehaviorSystem, Camera3DSystem, ThirdPersonSystem, StorySystem],
161        before: [],
162    },
163    UiInputSystem => crate::ui::UiInputSystem {
164        gate: schedule::ui_input,
165        present_when: "the world declares any HitRegion, Screen, or KeyBinding",
166        after: [LoadingOverlaySystem],
167        before: [],
168    },
169    TextInputSystem => crate::text_input_system::TextInputSystem {
170        gate: schedule::text_input,
171        present_when: "the world declares any TextInput",
172        after: [],
173        before: [],
174    },
175}
176
177#[cfg(test)]
178mod tests {
179    use super::SYSTEMS;
180    use crate::ecs::{ComponentAsset, World};
181
182    // The table's entries, in run order.
183    const ENTRIES: &[crate::ecs::SystemEntry] = SYSTEMS.entries;
184
185    // The overlay HUD components each gate their internal system and build in
186    // the fixed schedule order (StatHud, then DebugHud, then FpsCounter).
187    // DebugHud is developer-only but `cfg!(debug_assertions)` holds under test.
188    #[test]
189    fn hud_components_spawn_in_schedule_order() {
190        use crate::components::{DebugHud, FpsCounter, StatHud};
191
192        let mut world = World::new();
193        world.add_component(FpsCounter::default());
194        world.add_component(StatHud::default());
195        world.add_component(DebugHud::default());
196        world.start(SYSTEMS).unwrap();
197
198        let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
199        assert_eq!(names, ["StatHud", "DebugHud", "FpsCounter"]);
200    }
201
202    // The manifest reports exactly the systems `start()` builds, in the same
203    // order, for a world gating several table entries. Audio is left ungated
204    // so `start()` opens no device here.
205    #[test]
206    fn system_manifest_matches_started_systems() {
207        use crate::components::{DebugHud, FpsCounter, StatHud, Story, TextInput};
208
209        let mut world = World::new();
210        world.add_component(StatHud::default());
211        world.add_component(DebugHud::default());
212        world.add_component(FpsCounter::default());
213        world.add_component(Story::default());
214        world.add_component(TextInput::default());
215
216        let manifest = world.system_manifest(SYSTEMS);
217        world.start(SYSTEMS).unwrap();
218        let built: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
219        assert_eq!(manifest, built);
220    }
221
222    // The table's completion pass runs from `start`, before the gates read the
223    // world: a world with physics content gets the config its simulation runs
224    // on, and the PhysicsSystem is built either way.
225    #[test]
226    fn start_completes_the_world_with_its_engine_defaults() {
227        use crate::components::{EngineDefaults, PhysicsConfig, PropBody};
228
229        let mut world = World::new();
230        world.add_component(PropBody::default());
231        world.start(SYSTEMS).unwrap();
232
233        assert_eq!(world.query::<PhysicsConfig>().count(), 1);
234        // The directive is consumed by the pass, so nothing holds one after.
235        assert_eq!(world.query::<EngineDefaults>().count(), 0);
236        let built: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
237        assert_eq!(built, ["PhysicsSystem"]);
238    }
239
240    // Manifest names come out in table order, and every name is a real table
241    // entry (the manifest is a filtered view of `SYSTEMS`, nothing else).
242    #[test]
243    fn system_manifest_is_a_table_order_subset() {
244        use crate::components::{FpsCounter, StatHud};
245
246        let mut world = World::new();
247        world.add_component(FpsCounter::default());
248        world.add_component(StatHud::default());
249
250        let table: Vec<&str> = ENTRIES.iter().map(|e| e.name).collect();
251        let manifest = world.system_manifest(SYSTEMS);
252        let mut cursor = table.iter();
253        for name in &manifest {
254            assert!(
255                cursor.any(|t| t == name),
256                "'{name}' out of table order or unknown: {manifest:?}"
257            );
258        }
259    }
260
261    // A GraphicsConfig world gates the whole render band, and StreamingSystem
262    // runs immediately before GraphicsSystem so its `CameraRelativeView` is
263    // ready for that frame's submit. (Manifest-only: gating a GraphicsConfig
264    // never builds a GPU, unlike `start()`.)
265    #[test]
266    fn streaming_runs_immediately_before_graphics() {
267        let mut world = World::new();
268        world.add_component(crate::components::GraphicsConfig::default());
269        let manifest = world.system_manifest(SYSTEMS);
270        let s = manifest
271            .iter()
272            .position(|n| *n == "StreamingSystem")
273            .expect("StreamingSystem present for a GraphicsConfig world");
274        let g = manifest
275            .iter()
276            .position(|n| *n == "GraphicsSystem")
277            .expect("GraphicsSystem present for a GraphicsConfig world");
278        assert_eq!(
279            g,
280            s + 1,
281            "StreamingSystem is directly before GraphicsSystem: {manifest:?}"
282        );
283    }
284
285    // The two camera-controller entries are mutually exclusive: the first
286    // controlled camera's `follow` block picks exactly one of them.
287    #[test]
288    fn camera_controller_gates_are_exclusive() {
289        use crate::components::{Camera3D, CameraController, FollowController};
290
291        let mut fly_cam = Camera3D::bake(Default::default());
292        fly_cam.controller = Some(CameraController::default());
293        let mut fly = World::new();
294        fly.add_component(fly_cam);
295        assert_eq!(fly.system_manifest(SYSTEMS), ["Camera3DSystem"]);
296
297        let mut follow_cam = Camera3D::bake(Default::default());
298        follow_cam.controller = Some(CameraController {
299            follow: Some(FollowController::default()),
300            ..Default::default()
301        });
302        let mut follow = World::new();
303        follow.add_component(follow_cam);
304        assert_eq!(follow.system_manifest(SYSTEMS), ["ThirdPersonSystem"]);
305    }
306
307    // An audio-gating component is visible in the manifest without a device:
308    // the gate probe constructs the system, and device acquisition waits for
309    // `System::init`.
310    #[test]
311    fn audio_gate_probes_without_a_device() {
312        let mut world = World::new();
313        world.add_component(crate::components::AudioEmitter::default());
314        assert_eq!(world.system_manifest(SYSTEMS), ["AudioSystem"]);
315    }
316
317    // A Story gates the StorySystem. An empty-node story pulls in no audio
318    // device (build_audio needs a page/choice cue), so this stays device-free.
319    #[test]
320    fn story_component_spawns_story_system() {
321        let mut world = World::new();
322        world.add_component(crate::components::Story::default());
323        world.start(SYSTEMS).unwrap();
324
325        let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
326        assert_eq!(names, ["StorySystem"]);
327    }
328
329    // A fresh world holds nothing; adding a component (through either the blob
330    // path or the typed one) fills it, and `start()` is what gives it systems.
331    #[test]
332    fn empty_world_fills_from_components_then_systems() {
333        use crate::components::{FpsCounter, TextLabel};
334
335        let mut world = World::new();
336        assert!(world.is_empty());
337        assert_eq!(world.component_count(), 0);
338        assert_eq!(world.system_count(), 0);
339
340        world.add(ComponentAsset::from(TextLabel::default()));
341        assert!(!world.is_empty());
342        assert_eq!(world.component_count(), 1);
343
344        world.add_component(FpsCounter::default());
345        world.start(SYSTEMS).unwrap();
346        assert_eq!(world.system_count(), 1, "the FpsCounter gate built one");
347    }
348
349    // Every declared edge agrees with table order. The table is the one
350    // execution order, so an edge that contradicts it is a schedule-build panic
351    // at world start; this catches it at the document instead.
352    #[test]
353    fn declared_edges_respect_table_order() {
354        let position = |name: &str| {
355            ENTRIES
356                .iter()
357                .position(|e| e.name == name)
358                .expect("a known entry")
359        };
360        for (i, entry) in ENTRIES.iter().enumerate() {
361            for after in entry.after {
362                assert!(
363                    position(after) < i,
364                    "{} runs after {after}, but the table runs {after} later",
365                    entry.name,
366                );
367            }
368            for before in entry.before {
369                assert!(
370                    i < position(before),
371                    "{} runs before {before}, but the table runs {before} earlier",
372                    entry.name,
373                );
374            }
375        }
376    }
377
378    // Every name in a declared edge is a real table entry: a typo would
379    // silently drop the constraint.
380    #[test]
381    fn edge_names_exist() {
382        for entry in ENTRIES {
383            for name in entry.after.iter().chain(entry.before) {
384                assert!(
385                    ENTRIES.iter().any(|e| e.name == *name),
386                    "{} names unknown system {name}",
387                    entry.name
388                );
389            }
390        }
391    }
392
393    // Every table entry carries a non-empty human-readable gate description.
394    #[test]
395    fn every_entry_documents_its_gate() {
396        for entry in ENTRIES {
397            assert!(
398                !entry.present_when.is_empty(),
399                "{} has no present_when",
400                entry.name
401            );
402        }
403    }
404}