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