Skip to main content

loam_app/session/
frame.rs

1#[cfg(not(target_arch = "wasm32"))]
2use std::sync::Arc;
3
4use glam::Vec2;
5use web_time::Instant;
6use wgpu::{CommandBuffer, CommandEncoder, Texture, TextureFormat, TextureView};
7#[cfg(not(target_arch = "wasm32"))]
8use winit::window::Window;
9
10use loam_render::device::GpuContext;
11use loam_render::present::Presenter;
12#[cfg(not(target_arch = "wasm32"))]
13use loam_runtime::host::HostConfig;
14use loam_runtime::host::HostError;
15use loam_runtime::{Eye, Publication, PublishError, Records, Session, Stores};
16use loam_time::{frame_trace, FixedTimestep};
17
18use super::app::{CaptureControl, FrameHook, InputHook, Posts, Redraw, SessionApp};
19use super::cursor::CursorCapture;
20#[cfg(feature = "egui")]
21use super::debug_layer::DebugLayer;
22use super::input::InputMap;
23
24pub struct Target<'a> {
25    pub view: &'a TextureView,
26    pub texture: &'a Texture,
27    pub format: TextureFormat,
28    pub size: (u32, u32),
29}
30
31pub(crate) fn failed(error: impl std::fmt::Display) -> HostError {
32    HostError::Host(error.to_string())
33}
34
35#[derive(Clone, Copy, Default, PartialEq)]
36struct Shown {
37    content: u64,
38    eye: Eye,
39    size: (u32, u32),
40    background: wgpu::Color,
41}
42
43fn redraws(policy: Redraw, drawn: Option<&Shown>, next: &Shown, forced: bool) -> bool {
44    policy == Redraw::EveryFrame || forced || drawn != Some(next)
45}
46
47struct Inner<A: Stores> {
48    session: Session<A>,
49    records: Records,
50    timestep: Option<FixedTimestep>,
51    callbacks: Vec<CommandBuffer>,
52    input: InputMap,
53    cursor: CursorCapture,
54    app: SessionApp<A>,
55    posts: Posts,
56    no_views_reported: bool,
57    shown: Shown,
58    drawn: Option<Shown>,
59    #[cfg(test)]
60    presented: Option<Eye>,
61    #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
62    capture: crate::capture::Capture,
63}
64
65pub(crate) struct Frame<A: Stores> {
66    presenter: Option<Presenter>,
67    #[cfg(feature = "egui")]
68    layer: Option<DebugLayer>,
69    inner: Inner<A>,
70    trace: Option<frame_trace::Scope>,
71}
72
73impl<A: Stores> Frame<A> {
74    pub(crate) fn new(session: Session<A>, app: SessionApp<A>) -> Result<Self, HostError> {
75        let sim = session.config();
76        sim.check().map_err(failed)?;
77        let timestep = sim
78            .dt()
79            .map(|_| FixedTimestep::new(sim.fixed_hz).with_max_catch_up(sim.max_ticks_per_frame));
80        Ok(Self {
81            presenter: None,
82            #[cfg(feature = "egui")]
83            layer: None,
84            inner: Inner {
85                session,
86                records: Records::default(),
87                timestep,
88                callbacks: Vec::new(),
89                input: InputMap::default(),
90                cursor: CursorCapture::new(),
91                app,
92                posts: Posts::default(),
93                no_views_reported: false,
94                shown: Shown::default(),
95                drawn: None,
96                #[cfg(test)]
97                presented: None,
98                #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
99                capture: crate::capture::Capture::new(),
100            },
101            trace: None,
102        })
103    }
104
105    pub(crate) fn redraw(&self) -> Redraw {
106        self.inner.app.redraw
107    }
108
109    pub(crate) fn invalidate(&mut self) {
110        self.inner.drawn = None;
111    }
112
113    #[cfg(not(target_arch = "wasm32"))]
114    pub(crate) fn config(&self) -> &HostConfig {
115        &self.inner.app.config
116    }
117
118    pub(crate) fn app_mut(&mut self) -> &mut SessionApp<A> {
119        &mut self.inner.app
120    }
121
122    #[cfg(any(all(target_arch = "wasm32", feature = "measure"), test))]
123    pub(crate) fn phase_error(&self) -> Option<loam_runtime::PhaseError> {
124        self.inner.session.phase_error()
125    }
126
127    #[cfg(any(target_arch = "wasm32", test))]
128    pub(crate) fn posts(&self) -> impl Iterator<Item = (&'static str, &[f32])> {
129        self.inner.posts.iter()
130    }
131
132    #[cfg(not(target_arch = "wasm32"))]
133    pub(crate) fn input_mut(&mut self) -> &mut InputMap {
134        &mut self.inner.input
135    }
136
137    pub(crate) fn alt(&mut self, index: usize, pressed: bool) {
138        self.inner.cursor.alt(index, pressed);
139    }
140
141    pub(crate) fn focus(&mut self, focused: bool) {
142        self.inner.cursor.focus(focused);
143        if !focused {
144            self.inner.input.release_all();
145        }
146    }
147
148    pub(crate) fn cursor_applied(&mut self, locked: bool) {
149        self.inner.cursor.applied(locked);
150        self.inner.input.set_cursor_locked(locked);
151    }
152
153    pub(crate) fn cursor_locked(&self) -> bool {
154        self.inner.cursor.locked()
155    }
156
157    pub(crate) fn take_cursor_request(&mut self) -> Option<bool> {
158        self.inner.cursor.take_request()
159    }
160
161    #[cfg(target_arch = "wasm32")]
162    pub(crate) fn apply_message(
163        &mut self,
164        message: &crate::wasm::input_queue::InputMessage,
165        consumed: bool,
166    ) {
167        super::input::apply(
168            &mut self.inner.input,
169            &self.inner.app.config.bindings,
170            message,
171            consumed,
172        );
173    }
174
175    #[cfg(target_arch = "wasm32")]
176    pub(crate) fn observe_message(&mut self, message: &crate::wasm::input_queue::InputMessage) {
177        use crate::wasm::input_queue::InputMessage;
178
179        match message {
180            InputMessage::Key {
181                code,
182                pressed,
183                repeat,
184                ..
185            } if !repeat || !pressed => match code.as_str() {
186                "AltLeft" => self.alt(0, *pressed),
187                "AltRight" => self.alt(1, *pressed),
188                _ => {}
189            },
190            InputMessage::Focus(focused) => self.focus(*focused),
191            InputMessage::PointerLockChanged { locked, released } => {
192                self.cursor_applied(*locked);
193                if *released {
194                    self.inner.cursor.released();
195                }
196            }
197            _ => {}
198        }
199    }
200
201    #[cfg(not(target_arch = "wasm32"))]
202    pub(crate) fn action(&mut self, key: loam_runtime::Key, pressed: bool, consumed: bool) {
203        self.inner
204            .input
205            .host_action(&self.inner.app.config.bindings, key, pressed, consumed);
206    }
207
208    #[cfg(feature = "egui")]
209    pub(crate) fn layer(&self) -> Option<&DebugLayer> {
210        self.layer.as_ref()
211    }
212
213    pub(crate) fn reset_clock(&mut self, now: Instant) {
214        if let Some(timestep) = self.inner.timestep.as_mut() {
215            timestep.reset_clock(now);
216        }
217        self.inner.app.pacer.reset();
218    }
219
220    pub(crate) fn attach(
221        &mut self,
222        gpu: &GpuContext,
223        format: TextureFormat,
224        #[cfg(not(target_arch = "wasm32"))] window: Option<Arc<Window>>,
225        size: (u32, u32),
226        scale: f32,
227    ) -> Result<(), HostError> {
228        let mut presenter = Presenter::new(format).map_err(failed)?;
229        for pass in self.inner.app.passes.drain(..) {
230            presenter.register_pass(pass).map_err(failed)?;
231        }
232        #[cfg(all(not(feature = "egui"), not(target_arch = "wasm32")))]
233        let _ = window;
234        #[cfg(feature = "egui")]
235        if self.inner.app.debug_layer {
236            let layer = match self.layer.clone() {
237                Some(layer) => layer,
238                #[cfg(not(target_arch = "wasm32"))]
239                None => match window {
240                    Some(window) => DebugLayer::on_window(gpu, format, window, size),
241                    None => DebugLayer::offscreen(gpu, format, size, scale),
242                },
243                #[cfg(target_arch = "wasm32")]
244                None => DebugLayer::offscreen(gpu, format, size, scale),
245            };
246            presenter.register_pass(layer.pass()).map_err(failed)?;
247            self.layer = Some(layer);
248        }
249        presenter.attach(gpu).map_err(failed)?;
250        self.presenter = Some(presenter);
251        self.resize(size.0, size.1, scale);
252        Ok(())
253    }
254
255    pub(crate) fn recover(&mut self, gpu: &GpuContext) -> Result<(), HostError> {
256        self.invalidate();
257        if let Some(presenter) = self.presenter.as_mut() {
258            presenter.attach(gpu).map_err(failed)?;
259        }
260        Ok(())
261    }
262
263    pub(crate) fn resize(&mut self, width: u32, height: u32, scale: f32) {
264        self.invalidate();
265        self.inner.input.resize(width, height, scale);
266        #[cfg(feature = "egui")]
267        if let Some(layer) = self.layer.as_ref() {
268            layer.resize(width, height, scale);
269        }
270    }
271
272    pub(crate) fn step(
273        &mut self,
274        gpu: &GpuContext,
275        target: &Target<'_>,
276        now: Instant,
277        finish: impl FnMut(&mut CommandEncoder),
278    ) -> Result<(), HostError> {
279        if self.update(gpu, target.size, now)? {
280            self.draw(gpu, target, now, finish)?;
281        }
282        Ok(())
283    }
284
285    pub(crate) fn update(
286        &mut self,
287        gpu: &GpuContext,
288        size: (u32, u32),
289        now: Instant,
290    ) -> Result<bool, HostError> {
291        self.inner.posts.clear();
292        self.end_trace();
293        let outcome = self.updated(gpu, size, now);
294        if let Err(error) = &outcome {
295            tracing::error!("frame failed: {error}");
296        }
297        outcome
298    }
299
300    fn updated(
301        &mut self,
302        gpu: &GpuContext,
303        size: (u32, u32),
304        now: Instant,
305    ) -> Result<bool, HostError> {
306        gpu.device.poll(wgpu::PollType::Poll).map_err(failed)?;
307        if let Some(error) = gpu.take_uncaptured_error() {
308            return Err(failed(error));
309        }
310        let Some(presenter) = self.presenter.as_mut() else {
311            return Ok(false);
312        };
313        if size.0 == 0 || size.1 == 0 {
314            return Ok(false);
315        }
316        frame_trace::begin_frame();
317        self.trace = Some(frame_trace::scope("frame"));
318        let drawn = self.inner.update(
319            gpu,
320            size,
321            now,
322            presenter,
323            #[cfg(feature = "egui")]
324            self.layer.as_ref(),
325        );
326        if !matches!(drawn, Ok(true)) {
327            self.end_trace();
328        }
329        drawn
330    }
331
332    pub(crate) fn draw(
333        &mut self,
334        gpu: &GpuContext,
335        target: &Target<'_>,
336        now: Instant,
337        finish: impl FnMut(&mut CommandEncoder),
338    ) -> Result<(), HostError> {
339        let outcome = match self.presenter.as_mut() {
340            Some(presenter) => self.inner.draw(
341                gpu,
342                target,
343                now,
344                finish,
345                presenter,
346                #[cfg(feature = "egui")]
347                self.layer.as_ref(),
348            ),
349            None => Ok(()),
350        };
351        self.end_trace();
352        if let Err(error) = &outcome {
353            tracing::error!("frame failed: {error}");
354        }
355        outcome
356    }
357
358    fn end_trace(&mut self) {
359        if self.trace.is_some() {
360            self.trace = None;
361            frame_trace::end_frame();
362        }
363    }
364}
365
366impl<A: Stores> Inner<A> {
367    fn advance(
368        &mut self,
369        now: Instant,
370        size: (u32, u32),
371        #[cfg(feature = "egui")] ui: Option<&loam_egui::egui::Context>,
372    ) -> Result<(), HostError> {
373        let controls = self.app.console.take_controls();
374        if let Some(fps) = controls.target_fps {
375            self.app.pacer.set_target_fps(fps);
376        }
377        if controls.vsync.is_some() {
378            self.app.vsync = controls.vsync;
379        }
380        let Inner {
381            session,
382            input,
383            timestep,
384            app,
385            ..
386        } = self;
387        let aspect = input
388            .display_aspect()
389            .unwrap_or(size.0 as f32 / size.1 as f32);
390        session.views_mut().root_mut().eye.aspect = aspect;
391        let gathered = input.take();
392        let sender = app.commands.sender();
393        let was_faulted = session.faulted_phase().is_some();
394        if app
395            .fault_recovery
396            .is_some_and(|action| was_faulted && gathered.pressed(action))
397        {
398            sender.reset();
399        }
400        if let Some(hook) = app.input.as_mut() {
401            hook(&InputHook {
402                session,
403                input: &gathered,
404                #[cfg(feature = "egui")]
405                ui,
406                size,
407                sender: &sender,
408            });
409        }
410        let result = (|| {
411            {
412                let _dispatch = frame_trace::scope("dispatch");
413                app.boundary(session, gathered)?;
414            }
415            session.views_mut().root_mut().eye.aspect = aspect;
416            if let Some(timestep) = timestep.as_mut() {
417                if was_faulted && session.faulted_phase().is_none() {
418                    timestep.reset_clock(now);
419                }
420                let _simulation = frame_trace::scope("simulation");
421                for _ in timestep.advance(now) {
422                    session.tick()?;
423                }
424            }
425            session.views_mut().root_mut().eye.aspect = aspect;
426            Ok(())
427        })();
428        input.reclaim(session.take_input());
429        result
430    }
431
432    fn prepare(
433        &mut self,
434        now: Instant,
435        size: (u32, u32),
436        #[cfg(feature = "egui")] ui: Option<&loam_egui::egui::Context>,
437    ) -> Result<(Publication, Eye), HostError> {
438        self.advance(
439            now,
440            size,
441            #[cfg(feature = "egui")]
442            ui,
443        )?;
444        {
445            let _publication = frame_trace::scope("publication");
446            let alpha = self.timestep.as_ref().map_or(1.0, FixedTimestep::alpha);
447            self.records
448                .publish_at(&mut self.session, alpha)
449                .map_err(|error| match error {
450                    PublishError::Borrowed => {
451                        HostError::Host("publication buffer is borrowed".into())
452                    }
453                    PublishError::Phase(error) => HostError::Phase(error),
454                })?;
455        }
456        self.session.views_mut().root_mut().eye.aspect = self
457            .input
458            .display_aspect()
459            .unwrap_or(size.0 as f32 / size.1 as f32);
460        let root = self.session.views().root();
461        let eye = self
462            .session
463            .views()
464            .get(root)
465            .map(|view| view.eye)
466            .ok_or_else(|| HostError::Host("the root view is missing".into()))?;
467        let records = self
468            .records
469            .lend()
470            .ok_or_else(|| HostError::Host("the publication buffer is unavailable".into()))?;
471        if records.views.is_empty() && !self.no_views_reported {
472            self.no_views_reported = true;
473            tracing::warn!("the session published no views, so nothing is drawn");
474            self.app
475                .console
476                .note("the session published no views, so nothing is drawn".to_string());
477        }
478        Ok((records, eye))
479    }
480
481    fn update(
482        &mut self,
483        gpu: &GpuContext,
484        size: (u32, u32),
485        now: Instant,
486        presenter: &mut Presenter,
487        #[cfg(feature = "egui")] layer: Option<&DebugLayer>,
488    ) -> Result<bool, HostError> {
489        #[cfg(feature = "egui")]
490        let context = layer.map(DebugLayer::begin);
491        let faulted_before = self.session.faulted_phase();
492        let scene_before = self.session.scene();
493        let prepared = self.prepare(
494            now,
495            size,
496            #[cfg(feature = "egui")]
497            context.as_ref(),
498        );
499        let mut requested = false;
500        let terminal = match prepared {
501            Ok((records, eye)) => {
502                {
503                    let sender = self.app.commands.sender();
504                    let (hook, captures, cursor, background, posts) = (
505                        self.app.frame.as_mut(),
506                        &mut self.app.captures,
507                        &mut self.cursor,
508                        &mut self.app.background,
509                        &mut self.posts,
510                    );
511                    if let Some(hook) = hook {
512                        hook(&mut FrameHook {
513                            session: &self.session,
514                            published: &records,
515                            alpha: records.alpha,
516                            sections: presenter.sections(),
517                            #[cfg(feature = "egui")]
518                            ui: context.as_ref(),
519                            size,
520                            sender: &sender,
521                            capture: CaptureControl::new(captures),
522                            cursor,
523                            background,
524                            posts,
525                            redraw: &mut requested,
526                        });
527                    }
528                }
529                let _presentation = frame_trace::scope("presentation");
530                #[cfg(test)]
531                {
532                    self.presented = Some(eye);
533                }
534                presenter.upload(
535                    &gpu.device,
536                    &gpu.queue,
537                    &eye,
538                    Vec2::new(size.0 as f32, size.1 as f32),
539                    &records.views,
540                );
541                self.shown.content = records.content();
542                self.shown.eye = eye;
543                self.records.release(records);
544                None
545            }
546            Err(error) if self.session.faulted_phase().is_some() => {
547                if faulted_before.is_none() || self.session.scene() != scene_before {
548                    tracing::error!("session frame failed: {error}");
549                    self.cursor.suspend();
550                }
551                if let Some(timestep) = self.timestep.as_mut() {
552                    timestep.reset_clock(now);
553                }
554                None
555            }
556            Err(error) => Some(error),
557        };
558        #[cfg(feature = "egui")]
559        if let Some(context) = context.as_ref() {
560            loam_egui::ConsoleUi::ui(self.app.console.ui_mut(), context);
561        }
562        if let Some(driver) = self.app.script.as_mut() {
563            driver.advance_console(&mut self.app.console);
564        }
565        let dispatched = self.app.console.dispatch_pending();
566        #[cfg(feature = "egui")]
567        if let Some(layer) = layer {
568            layer.finish();
569        }
570        if let Some(error) = terminal {
571            return Err(error);
572        }
573        #[cfg(feature = "egui")]
574        let layered = layer.is_some();
575        #[cfg(not(feature = "egui"))]
576        let layered = false;
577        #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
578        let capturing = !self.app.captures.is_empty() || self.capture.should_capture(now);
579        #[cfg(not(all(feature = "capture", not(target_arch = "wasm32"))))]
580        let capturing = false;
581        self.shown.size = size;
582        self.shown.background = self.app.background;
583        let draw = redraws(
584            self.app.redraw,
585            self.drawn.as_ref(),
586            &self.shown,
587            requested || dispatched || layered || capturing,
588        );
589        // Set again only after submit, so a failed acquire keeps the draw pending.
590        if draw {
591            self.drawn = None;
592        }
593        Ok(draw)
594    }
595
596    fn draw(
597        &mut self,
598        gpu: &GpuContext,
599        target: &Target<'_>,
600        now: Instant,
601        mut finish: impl FnMut(&mut CommandEncoder),
602        presenter: &mut Presenter,
603        #[cfg(feature = "egui")] layer: Option<&DebugLayer>,
604    ) -> Result<(), HostError> {
605        #[cfg(not(all(feature = "capture", not(target_arch = "wasm32"))))]
606        let _ = now;
607        #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
608        let (wants_pre, wants_post) = self.capture_intent(now);
609        let mut encoder = gpu
610            .device
611            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
612                label: Some("loam-app::session"),
613            });
614        presenter
615            .record_scene(
616                &gpu.device,
617                &mut encoder,
618                target.view,
619                target.size,
620                self.app.background,
621            )
622            .map_err(failed)?;
623        #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
624        let pre = if wants_pre {
625            finish(&mut encoder);
626            self.record_capture(&gpu.device, &mut encoder, target)
627        } else {
628            None
629        };
630        presenter
631            .record_overlays(&mut encoder, target.view, target.size)
632            .map_err(failed)?;
633        finish(&mut encoder);
634        #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
635        let post = wants_post
636            .then(|| self.record_capture(&gpu.device, &mut encoder, target))
637            .flatten();
638        #[cfg(feature = "egui")]
639        if let Some(layer) = layer {
640            layer.take_callbacks(&mut self.callbacks);
641        }
642        gpu.queue
643            .submit(self.callbacks.drain(..).chain(Some(encoder.finish())));
644        presenter.after_submit();
645        crate::trace::record_presentation(presenter.sections(), presenter.uploads());
646        #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
647        self.consume_capture(&gpu.device, now, pre, post);
648        self.drawn = Some(self.shown);
649        Ok(())
650    }
651
652    #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
653    fn capture_intent(&mut self, now: Instant) -> (bool, bool) {
654        if !self.app.captures.is_empty() {
655            let requests = std::mem::take(&mut self.app.captures);
656            for line in self.capture.apply_requests(requests) {
657                tracing::info!("{line}");
658            }
659        }
660        if !self.capture.should_capture(now) {
661            return (false, false);
662        }
663        (self.capture.wants_pre(), self.capture.wants_post())
664    }
665
666    #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
667    fn record_capture(
668        &self,
669        device: &wgpu::Device,
670        encoder: &mut CommandEncoder,
671        target: &Target<'_>,
672    ) -> Option<crate::capture::TextureReadback> {
673        match crate::capture::record_texture_rgba(
674            device,
675            encoder,
676            target.texture,
677            target.size.0,
678            target.size.1,
679            target.format,
680        ) {
681            Ok(readback) => Some(readback),
682            Err(error) => {
683                tracing::error!("capture: copy failed: {error:#}");
684                None
685            }
686        }
687    }
688
689    #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
690    fn consume_capture(
691        &mut self,
692        device: &wgpu::Device,
693        now: Instant,
694        pre: Option<crate::capture::TextureReadback>,
695        post: Option<crate::capture::TextureReadback>,
696    ) {
697        for (is_pre, readback) in [(true, pre), (false, post)] {
698            let Some(readback) = readback else {
699                continue;
700            };
701            match readback.read(device) {
702                Ok(image) => {
703                    if let Err(error) = self.capture.consume_frame(
704                        is_pre,
705                        image.rgba,
706                        image.width,
707                        image.height,
708                        now,
709                    ) {
710                        tracing::error!("capture: write failed: {error:#}");
711                    }
712                }
713                Err(error) => tracing::error!("capture: readback failed: {error:#}"),
714            }
715        }
716        self.capture.advance_frame(now);
717    }
718}
719
720#[cfg(test)]
721mod tests {
722    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
723    use std::sync::{Arc, Mutex};
724    use std::time::Duration;
725
726    use glam::Vec3;
727    use loam_math::EuclideanR3;
728    use loam_render::device::FeatureRequest;
729    use loam_render::pass::{FrameFormat, FramePass, FrameTarget, PassStage};
730    use loam_runtime::{
731        ActionId, Bindings, Command, DomainBuilder, HostConfig, Identity3, Key, LogCapacity,
732        Material, Phase, Pose, PreparedGeometry, SimConfig, SpawnBundle, Tick, ViewSpec,
733    };
734    use wgpu::{
735        BackendOptions, Backends, Extent3d, Instance, InstanceDescriptor, NoopBackendOptions,
736        TextureDescriptor, TextureDimension, TextureUsages, TextureViewDescriptor,
737    };
738    #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
739    use wgpu::{
740        Color, ColorTargetState, ColorWrites, FragmentState, LoadOp, MultisampleState, Operations,
741        PrimitiveState, RenderPassColorAttachment, RenderPassDescriptor, RenderPipeline,
742        RenderPipelineDescriptor, ShaderModuleDescriptor, ShaderSource, StoreOp, VertexState,
743    };
744
745    use super::*;
746    use crate::args::Args;
747    use crate::session::CursorPolicy;
748
749    const FORMAT: TextureFormat = TextureFormat::Rgba8UnormSrgb;
750    const SIZE: (u32, u32) = (64, 48);
751    loam_runtime::stores! {
752        #[derive(Default)]
753        pub struct Bare {}
754    }
755
756    #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
757    const CAPTURE_FPS: u16 = 60;
758
759    struct Probe {
760        recorded: Arc<AtomicU32>,
761        rebuilt: Arc<AtomicU32>,
762    }
763
764    impl FramePass for Probe {
765        fn name(&self) -> &'static str {
766            "probe"
767        }
768
769        fn stage(&self) -> PassStage {
770            PassStage::Scene
771        }
772
773        fn record(
774            &mut self,
775            _encoder: &mut CommandEncoder,
776            _target: &FrameTarget<'_>,
777        ) -> anyhow::Result<()> {
778            self.recorded.fetch_add(1, Ordering::Relaxed);
779            Ok(())
780        }
781
782        fn attach(&mut self, _gpu: &GpuContext, _frame: FrameFormat) -> anyhow::Result<()> {
783            self.rebuilt.fetch_add(1, Ordering::Relaxed);
784            Ok(())
785        }
786    }
787
788    #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
789    struct Paint {
790        name: &'static str,
791        color: Color,
792        overlay: bool,
793        pipeline: Option<RenderPipeline>,
794    }
795
796    #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
797    impl FramePass for Paint {
798        fn name(&self) -> &'static str {
799            self.name
800        }
801
802        fn stage(&self) -> PassStage {
803            if self.overlay {
804                PassStage::Overlay
805            } else {
806                PassStage::Scene
807            }
808        }
809
810        fn record(
811            &mut self,
812            encoder: &mut CommandEncoder,
813            target: &FrameTarget<'_>,
814        ) -> anyhow::Result<()> {
815            let Some(pipeline) = self.pipeline.as_ref() else {
816                anyhow::bail!("paint recorded before attach");
817            };
818            let mut pass = encoder.begin_render_pass(&RenderPassDescriptor {
819                label: Some(self.name),
820                color_attachments: &[Some(RenderPassColorAttachment {
821                    view: target.color,
822                    depth_slice: None,
823                    resolve_target: None,
824                    ops: Operations {
825                        load: LoadOp::Load,
826                        store: StoreOp::Store,
827                    },
828                })],
829                depth_stencil_attachment: None,
830                timestamp_writes: None,
831                occlusion_query_set: None,
832            });
833            pass.set_pipeline(pipeline);
834            pass.draw(0..3, 0..1);
835            Ok(())
836        }
837
838        fn attach(&mut self, gpu: &GpuContext, frame: FrameFormat) -> anyhow::Result<()> {
839            let Color { r, g, b, a } = self.color;
840            let source = format!(
841                r#"
842@vertex
843fn vertex(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> {{
844    var corners = array<vec2<f32>, 3>(
845        vec2<f32>(-1.0, -1.0),
846        vec2<f32>(3.0, -1.0),
847        vec2<f32>(-1.0, 3.0),
848    );
849    return vec4<f32>(corners[index], 0.0, 1.0);
850}}
851
852@fragment
853fn fragment() -> @location(0) vec4<f32> {{
854    return vec4<f32>({r:?}, {g:?}, {b:?}, {a:?});
855}}
856"#
857            );
858            let shader = gpu.device.create_shader_module(ShaderModuleDescriptor {
859                label: Some(self.name),
860                source: ShaderSource::Wgsl(source.into()),
861            });
862            self.pipeline = Some(
863                gpu.device
864                    .create_render_pipeline(&RenderPipelineDescriptor {
865                        label: Some(self.name),
866                        layout: None,
867                        vertex: VertexState {
868                            module: &shader,
869                            entry_point: Some("vertex"),
870                            compilation_options: Default::default(),
871                            buffers: &[],
872                        },
873                        primitive: PrimitiveState::default(),
874                        depth_stencil: None,
875                        multisample: MultisampleState::default(),
876                        fragment: Some(FragmentState {
877                            module: &shader,
878                            entry_point: Some("fragment"),
879                            compilation_options: Default::default(),
880                            targets: &[Some(ColorTargetState {
881                                format: frame.color,
882                                blend: None,
883                                write_mask: ColorWrites::ALL,
884                            })],
885                        }),
886                        multiview: None,
887                        cache: None,
888                    }),
889            );
890            Ok(())
891        }
892    }
893
894    fn noop_gpu() -> GpuContext {
895        let instance = Instance::new(&InstanceDescriptor {
896            backends: Backends::NOOP,
897            backend_options: BackendOptions {
898                noop: NoopBackendOptions { enable: true },
899                ..Default::default()
900            },
901            ..Default::default()
902        });
903        pollster::block_on(GpuContext::new(instance, FeatureRequest::default(), None))
904            .expect("the noop backend always yields a context")
905    }
906
907    fn offscreen(gpu: &GpuContext) -> Texture {
908        gpu.device.create_texture(&TextureDescriptor {
909            label: Some("session frame probe"),
910            size: Extent3d {
911                width: SIZE.0,
912                height: SIZE.1,
913                depth_or_array_layers: 1,
914            },
915            mip_level_count: 1,
916            sample_count: 1,
917            dimension: TextureDimension::D2,
918            format: FORMAT,
919            usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_SRC,
920            view_formats: &[],
921        })
922    }
923
924    fn bare(app: SessionApp<Bare>) -> Frame<Bare> {
925        Frame::new(Session::new(Bare::default(), SimConfig::default()), app)
926            .expect("the frame accepted the simulation config")
927    }
928
929    fn host<A: Stores>(name: &'static str) -> SessionApp<A> {
930        quiet(SessionApp::with_args(
931            HostConfig::new(name, Bindings::new()),
932            Args::default(),
933        ))
934    }
935
936    fn quiet<A: Stores>(app: SessionApp<A>) -> SessionApp<A> {
937        app.debug_layer(false)
938    }
939
940    fn run_one<A: Stores>(frame: &mut Frame<A>, gpu: &GpuContext, texture: &Texture) {
941        run_at(frame, gpu, texture, Instant::now());
942    }
943
944    fn run_at<A: Stores>(frame: &mut Frame<A>, gpu: &GpuContext, texture: &Texture, now: Instant) {
945        let view = texture.create_view(&TextureViewDescriptor::default());
946        let target = Target {
947            view: &view,
948            texture,
949            format: FORMAT,
950            size: SIZE,
951        };
952        frame
953            .step(gpu, &target, now, |_| {})
954            .expect("the frame stepped");
955    }
956
957    fn world(moves: bool) -> Session<Bare> {
958        let mut session = Session::new(Bare::default(), SimConfig::default());
959        let r3 = session
960            .register_domain(DomainBuilder::new("r3", EuclideanR3).tracked(LogCapacity::default()));
961        let geometry = session.prepare(PreparedGeometry::Lines3 {
962            segments: vec![[[0.0; 3], [1.0, 0.0, 0.0]]],
963        });
964        let material = session.add_material(Material::lines([1.0; 4], 1.0));
965        let root = session.views().root();
966        let body = session
967            .dispatch(|dispatch| {
968                let eye = dispatch.spawn(SpawnBundle::new().at(r3, Pose::at(Vec3::Z * 4.0)))?;
969                dispatch
970                    .domains
971                    .typed(r3)?
972                    .add_view(ViewSpec::new(root, eye, Identity3))?;
973                dispatch.spawn(
974                    SpawnBundle::new()
975                        .at(r3, Pose::at(Vec3::ZERO))
976                        .instance(loam_runtime::Instance::new(geometry, material)),
977                )
978            })
979            .expect("the world spawned");
980        session.system(
981            Phase::Simulation,
982            "drift",
983            move |ctx: loam_runtime::Ctx<'_, Bare>| -> Result<(), loam_runtime::DomainError> {
984                if moves {
985                    let x = (ctx.step.tick.0 + 1) as f32;
986                    ctx.domains
987                        .typed(r3)?
988                        .set_pose(body, Pose::at(Vec3::X * x))?;
989                }
990                Ok(())
991            },
992        );
993        session
994    }
995
996    fn on_change(
997        session: Session<Bare>,
998        app: SessionApp<Bare>,
999        gpu: &GpuContext,
1000    ) -> (Frame<Bare>, Arc<AtomicU32>) {
1001        let recorded = Arc::new(AtomicU32::new(0));
1002        let app = app.redraw(Redraw::OnChange).pass(Box::new(Probe {
1003            recorded: recorded.clone(),
1004            rebuilt: Arc::new(AtomicU32::new(0)),
1005        }));
1006        let mut frame = Frame::new(session, app).expect("the frame accepted the simulation config");
1007        frame
1008            .attach(gpu, FORMAT, None, SIZE, 1.0)
1009            .expect("attached");
1010        (frame, recorded)
1011    }
1012
1013    #[test]
1014    fn a_still_world_under_on_change_skips_the_draw_after_the_first_frame() {
1015        let gpu = noop_gpu();
1016        let texture = offscreen(&gpu);
1017        let hooked = Arc::new(AtomicU32::new(0));
1018        let hooks = hooked.clone();
1019        let app = host("still").on_frame(move |_| {
1020            hooks.fetch_add(1, Ordering::Relaxed);
1021        });
1022        let (mut frame, recorded) = on_change(world(false), app, &gpu);
1023        let start = Instant::now();
1024        for millis in [0, 20, 25] {
1025            run_at(
1026                &mut frame,
1027                &gpu,
1028                &texture,
1029                start + Duration::from_millis(millis),
1030            );
1031        }
1032        assert_eq!(recorded.load(Ordering::Relaxed), 1);
1033        assert_eq!(hooked.load(Ordering::Relaxed), 3);
1034    }
1035
1036    #[test]
1037    fn a_frame_whose_publish_patched_a_moving_body_is_drawn() {
1038        let gpu = noop_gpu();
1039        let texture = offscreen(&gpu);
1040        let (mut frame, recorded) = on_change(world(true), host("moving"), &gpu);
1041        let start = Instant::now();
1042        for millis in [0, 20, 25] {
1043            run_at(
1044                &mut frame,
1045                &gpu,
1046                &texture,
1047                start + Duration::from_millis(millis),
1048            );
1049        }
1050        assert_eq!(recorded.load(Ordering::Relaxed), 3);
1051    }
1052
1053    #[test]
1054    fn a_scale_change_redraws_a_frame_whose_content_is_unchanged() {
1055        let gpu = noop_gpu();
1056        let texture = offscreen(&gpu);
1057        let (mut frame, recorded) = on_change(world(false), host("scale"), &gpu);
1058        let start = Instant::now();
1059        run_at(&mut frame, &gpu, &texture, start);
1060        run_at(
1061            &mut frame,
1062            &gpu,
1063            &texture,
1064            start + Duration::from_millis(20),
1065        );
1066        assert_eq!(recorded.load(Ordering::Relaxed), 1);
1067        frame.resize(SIZE.0, SIZE.1, 2.0);
1068        run_at(
1069            &mut frame,
1070            &gpu,
1071            &texture,
1072            start + Duration::from_millis(25),
1073        );
1074        assert_eq!(recorded.load(Ordering::Relaxed), 2);
1075    }
1076
1077    #[test]
1078    fn incompatible_dynamic_shader_reaches_the_host_with_pass_and_cause() {
1079        let source = r#"
1080struct Fragment {
1081    @location(0) color: vec4<f32>,
1082    @builtin(frag_depth) depth: f32,
1083}
1084@group(0) @binding(0) var<storage, read> wrong: array<u32>;
1085@group(0) @binding(1) var<storage, read> bodies: array<u32>;
1086@vertex fn vs_fullscreen(@builtin(vertex_index) vertex: u32) -> @builtin(position) vec4<f32> {
1087    return vec4<f32>(f32(vertex), 0.0, 0.0, 1.0);
1088}
1089@fragment fn fs_main() -> @location(0) vec4<f32> {
1090    return vec4<f32>(0.0);
1091}
1092@fragment fn fs_depth() -> Fragment {
1093    return Fragment(vec4<f32>(0.0), 0.5);
1094}
1095"#;
1096        let gpu = noop_gpu();
1097        let app =
1098            host("shader error").pass(Box::new(loam_render::HyperslicePass::new(source.into())));
1099        let mut frame = bare(app);
1100        let error = frame
1101            .attach(&gpu, FORMAT, None, SIZE, 1.0)
1102            .expect_err("incompatible shader");
1103        let message = format!("{error:?}");
1104        assert!(message.contains("pass `hyperslice` failed during attach"));
1105        assert!(message.contains("WGSL binding 0:0 must use Uniform"));
1106    }
1107
1108    #[test]
1109    fn a_registered_pass_records_again_after_the_device_is_recovered() {
1110        let gpu = noop_gpu();
1111        let texture = offscreen(&gpu);
1112        let recorded = Arc::new(AtomicU32::new(0));
1113        let rebuilt = Arc::new(AtomicU32::new(0));
1114        let app = host("passes").pass(Box::new(Probe {
1115            recorded: recorded.clone(),
1116            rebuilt: rebuilt.clone(),
1117        }));
1118        let mut frame = bare(app);
1119        frame
1120            .attach(&gpu, FORMAT, None, SIZE, 1.0)
1121            .expect("attached");
1122        run_one(&mut frame, &gpu, &texture);
1123        assert_eq!(recorded.load(Ordering::Relaxed), 1);
1124        assert_eq!(rebuilt.load(Ordering::Relaxed), 1);
1125
1126        frame.recover(&gpu).expect("recovered");
1127        run_one(&mut frame, &gpu, &texture);
1128        assert_eq!(
1129            rebuilt.load(Ordering::Relaxed),
1130            2,
1131            "recovery never rebuilt the application's pass on the new device"
1132        );
1133        assert_eq!(
1134            recorded.load(Ordering::Relaxed),
1135            2,
1136            "the recovered presenter no longer records the application's pass"
1137        );
1138    }
1139
1140    #[test]
1141    fn the_frame_callback_reads_the_sections_the_previous_frame_recorded() {
1142        let gpu = noop_gpu();
1143        let texture = offscreen(&gpu);
1144        let seen: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
1145        let reported = seen.clone();
1146        let app = host("sections")
1147            .pass(Box::new(Probe {
1148                recorded: Arc::new(AtomicU32::new(0)),
1149                rebuilt: Arc::new(AtomicU32::new(0)),
1150            }))
1151            .on_frame(move |hook| {
1152                let mut names = reported.lock().unwrap_or_else(|error| error.into_inner());
1153                names.clear();
1154                names.extend(hook.sections.iter().map(|section| section.name));
1155            });
1156        let mut frame = bare(app);
1157        frame
1158            .attach(&gpu, FORMAT, None, SIZE, 1.0)
1159            .expect("attached");
1160
1161        run_one(&mut frame, &gpu, &texture);
1162        run_one(&mut frame, &gpu, &texture);
1163
1164        let names = seen.lock().unwrap_or_else(|error| error.into_inner());
1165        assert!(
1166            names.contains(&"probe") && names.contains(&"present-draw"),
1167            "the callback ran after the presenter cleared its sections: {names:?}"
1168        );
1169    }
1170
1171    #[test]
1172    fn an_input_command_updates_the_eye_before_publication() {
1173        let gpu = noop_gpu();
1174        let texture = offscreen(&gpu);
1175        let published: Arc<Mutex<Option<Eye>>> = Arc::new(Mutex::new(None));
1176        let recorded = published.clone();
1177        let mut step = 0.0_f32;
1178        let app = host::<Bare>("camera")
1179            .on_input(move |hook| {
1180                step += 1.0;
1181                let eye = Eye::looking_at([step, 2.0, 3.0], [0.0; 3], [0.0, 1.0, 0.0]);
1182                hook.sender.app_fn("camera", move |dispatch| {
1183                    let aspect = dispatch.views.root_mut().eye.aspect;
1184                    dispatch.views.root_mut().eye = Eye { aspect, ..eye };
1185                });
1186            })
1187            .on_frame(move |hook| {
1188                let root = hook.session.views().root();
1189                let eye = hook.session.views().get(root).map(|view| view.eye);
1190                *recorded.lock().unwrap_or_else(|error| error.into_inner()) = eye;
1191            });
1192        let mut frame = bare(app);
1193        frame
1194            .attach(&gpu, FORMAT, None, SIZE, 1.0)
1195            .expect("attached");
1196
1197        run_one(&mut frame, &gpu, &texture);
1198        run_one(&mut frame, &gpu, &texture);
1199
1200        let published = published
1201            .lock()
1202            .unwrap_or_else(|error| error.into_inner())
1203            .expect("the frame hook read the published eye");
1204        assert_eq!(
1205            frame.inner.presented,
1206            Some(published),
1207            "the presenter did not use the eye committed before publication"
1208        );
1209    }
1210
1211    #[test]
1212    fn a_post_from_one_frame_is_not_flushed_again_on_the_next() {
1213        let gpu = noop_gpu();
1214        let texture = offscreen(&gpu);
1215        let mut frames = 0_u32;
1216        let app = host::<Bare>("posts").on_frame(move |hook| {
1217            frames += 1;
1218            if frames == 1 {
1219                hook.post("rect", &[1.0, 2.0, 3.0, 4.0]);
1220                hook.post("ready", &[]);
1221            }
1222        });
1223        let mut frame = bare(app);
1224        frame
1225            .attach(&gpu, FORMAT, None, SIZE, 1.0)
1226            .expect("attached");
1227
1228        run_one(&mut frame, &gpu, &texture);
1229        let posted: Vec<(&str, Vec<f32>)> = frame
1230            .posts()
1231            .map(|(topic, values)| (topic, values.to_vec()))
1232            .collect();
1233        assert_eq!(
1234            posted,
1235            [("rect", vec![1.0, 2.0, 3.0, 4.0]), ("ready", Vec::new())]
1236        );
1237
1238        run_one(&mut frame, &gpu, &texture);
1239        assert_eq!(frame.posts().count(), 0);
1240    }
1241
1242    #[test]
1243    fn a_publication_with_no_views_is_reported_once_per_frame() {
1244        let gpu = noop_gpu();
1245        let texture = offscreen(&gpu);
1246        let app = host::<Bare>("no views");
1247        let mut session = Session::new(Bare::default(), SimConfig::default());
1248        session.register_domain(DomainBuilder::new("r3", EuclideanR3));
1249        let mut frame = Frame::new(session, app).expect("the frame accepted the simulation config");
1250        frame
1251            .attach(&gpu, FORMAT, None, SIZE, 1.0)
1252            .expect("attached");
1253        let start = Instant::now();
1254        for millis in 0..3 {
1255            run_at(
1256                &mut frame,
1257                &gpu,
1258                &texture,
1259                start + Duration::from_millis(millis),
1260            );
1261        }
1262
1263        let lines: Vec<String> = frame
1264            .app_mut()
1265            .console_mut()
1266            .ui()
1267            .history()
1268            .iter()
1269            .map(|line| line.text.clone())
1270            .collect();
1271        assert_eq!(
1272            lines
1273                .iter()
1274                .filter(|line| line.contains("published no views"))
1275                .count(),
1276            1,
1277            "the viewless publication was not reported exactly once: {lines:?}"
1278        );
1279    }
1280
1281    #[test]
1282    fn a_frame_between_ticks_draws_the_body_at_the_elapsed_fraction_of_the_tick() {
1283        let gpu = noop_gpu();
1284        let texture = offscreen(&gpu);
1285        let mut session = Session::new(Bare::default(), SimConfig::default());
1286        let r3 = session
1287            .register_domain(DomainBuilder::new("r3", EuclideanR3).tracked(LogCapacity::default()));
1288        let geometry = session.prepare(PreparedGeometry::Lines3 {
1289            segments: vec![[[0.1, 0.0, 0.0], [-0.1, 0.0, 0.0]]],
1290        });
1291        let material = session.add_material(Material::flat([1.0; 4]));
1292        let root = session.views().root();
1293        let body = session
1294            .dispatch(|dispatch| {
1295                let eye = dispatch.spawn(SpawnBundle::new().at(r3, Pose::at(Vec3::Z * 4.0)))?;
1296                dispatch
1297                    .domains
1298                    .typed(r3)?
1299                    .add_view(ViewSpec::new(root, eye, Identity3))?;
1300                dispatch.spawn(
1301                    SpawnBundle::new()
1302                        .at(r3, Pose::at(Vec3::ZERO))
1303                        .instance(loam_runtime::Instance::new(geometry, material)),
1304                )
1305            })
1306            .expect("the body spawned");
1307        session.system(
1308            Phase::Simulation,
1309            "move",
1310            move |ctx: loam_runtime::Ctx<'_, Bare>| {
1311                if ctx.step.tick == Tick(0) {
1312                    ctx.domains.typed(r3)?.set_pose(body, Pose::at(Vec3::X))?;
1313                }
1314                Ok(())
1315            },
1316        );
1317        let mut frame = Frame::new(session, host("alpha")).expect("the frame accepted the config");
1318        frame
1319            .attach(&gpu, FORMAT, None, SIZE, 1.0)
1320            .expect("attached");
1321        let start = Instant::now();
1322        let dt = Duration::from_nanos(1_000_000_000 / u64::from(SimConfig::default().fixed_hz));
1323        run_at(&mut frame, &gpu, &texture, start);
1324        run_at(&mut frame, &gpu, &texture, start + dt * 3 / 2);
1325
1326        let publication = frame.inner.records.lend().expect("the buffer is free");
1327        let x = publication.views[0].records.instances.rows()[0]
1328            .pose
1329            .coordinates[0];
1330        assert!((x - 0.5).abs() < 1e-3, "the body was drawn at x = {x}");
1331    }
1332
1333    #[test]
1334    fn a_zero_rate_config_pauses_the_frame_and_a_config_without_catch_up_is_refused() {
1335        let paused =
1336            loam_runtime::SimConfig::new(0, 2).expect("a zero rate is documented as paused");
1337        let app = host::<Bare>("paused");
1338        let session = Session::new(Bare::default(), paused);
1339        assert!(Frame::new(session, app).is_ok());
1340        assert!(matches!(
1341            loam_runtime::SimConfig::new(60, 0),
1342            Err(loam_runtime::SimConfigError::NoCatchUp)
1343        ));
1344    }
1345
1346    #[test]
1347    fn a_publication_phase_eye_write_keeps_the_frame_aspect() {
1348        let gpu = noop_gpu();
1349        let texture = offscreen(&gpu);
1350        let app = host::<Bare>("aspect");
1351        let mut session = Session::new(Bare::default(), SimConfig::default());
1352        session.register_domain(DomainBuilder::new("r3", EuclideanR3));
1353        session.system(
1354            Phase::Publication,
1355            "camera",
1356            |ctx: loam_runtime::Ctx<'_, Bare>| {
1357                ctx.views.root_mut().eye =
1358                    Eye::looking_at([0.0, 2.0, 5.0], [0.0; 3], [0.0, 1.0, 0.0]);
1359                Ok(())
1360            },
1361        );
1362        let mut frame = Frame::new(session, app).expect("the frame accepted the simulation config");
1363        frame
1364            .attach(&gpu, FORMAT, None, SIZE, 1.0)
1365            .expect("attached");
1366        run_at(&mut frame, &gpu, &texture, Instant::now());
1367
1368        let root = frame.inner.session.views().root();
1369        let aspect = frame.inner.session.views().get(root).unwrap().eye.aspect;
1370        assert!(
1371            (aspect - SIZE.0 as f32 / SIZE.1 as f32).abs() <= 1e-6,
1372            "a Publication-phase eye write published aspect {aspect}"
1373        );
1374    }
1375
1376    #[test]
1377    fn installed_reset_key_recovers_the_fault_and_restores_cursor_capture_once() {
1378        const RESET: ActionId = ActionId(0);
1379
1380        let gpu = noop_gpu();
1381        let texture = offscreen(&gpu);
1382        let recorded = Arc::new(AtomicU32::new(0));
1383        let filled = Arc::new(AtomicU32::new(0));
1384        let fills = filled.clone();
1385        let app = quiet(SessionApp::<Bare>::with_args(
1386            HostConfig::new(
1387                "fault recovery",
1388                Bindings::new().key(Key::Letter('r'), RESET),
1389            ),
1390            Args::default(),
1391        ))
1392        .recover_on_fault(RESET)
1393        .pass(Box::new(Probe {
1394            recorded: recorded.clone(),
1395            rebuilt: Arc::new(AtomicU32::new(0)),
1396        }))
1397        .on_frame(move |hook| {
1398            fills.fetch_add(1, Ordering::Relaxed);
1399            hook.capture_cursor(true, CursorPolicy::Toggle);
1400        });
1401        let mut session = Session::new(Bare::default(), SimConfig::default());
1402        let r3 = session
1403            .register_domain(DomainBuilder::new("r3", EuclideanR3).tracked(LogCapacity::default()));
1404        let root = session.views().root();
1405        session
1406            .dispatch(|dispatch| {
1407                let eye = dispatch.spawn(SpawnBundle::new().at(r3, Pose::at(Vec3::ZERO)))?;
1408                dispatch
1409                    .domains
1410                    .typed(r3)?
1411                    .add_view(ViewSpec::new(root, eye, Identity3))?;
1412                Ok::<_, loam_runtime::Rejection>(())
1413            })
1414            .expect("view");
1415        let armed = Arc::new(AtomicBool::new(false));
1416        let trip = armed.clone();
1417        session.system(
1418            Phase::Publication,
1419            "trip",
1420            move |_ctx: loam_runtime::Ctx<'_, Bare>| {
1421                if trip.swap(false, Ordering::Relaxed) {
1422                    return Err(loam_runtime::DomainError::ChartBoundary);
1423                }
1424                Ok(())
1425            },
1426        );
1427        session.system(
1428            Phase::Dispatch,
1429            "authored reset",
1430            |ctx: loam_runtime::Ctx<'_, Bare>| {
1431                if ctx.input.pressed(RESET) {
1432                    ctx.commands.submit(Command::Reset);
1433                }
1434                Ok(())
1435            },
1436        );
1437        session.set_initial().expect("initial state");
1438        let epoch = session.scene().epoch();
1439        let mut frame = Frame::new(session, app).expect("the frame accepted the simulation config");
1440        frame
1441            .attach(&gpu, FORMAT, None, SIZE, 1.0)
1442            .expect("attached");
1443        let start = Instant::now();
1444        let expected_sections = ["scene-pass", "triangles", "present-draw", "probe"];
1445
1446        run_at(&mut frame, &gpu, &texture, start);
1447        assert_eq!(
1448            frame
1449                .presenter
1450                .as_ref()
1451                .expect("the presenter attached")
1452                .sections()
1453                .iter()
1454                .map(|section| section.name)
1455                .collect::<Vec<_>>(),
1456            expected_sections
1457        );
1458        assert_eq!(frame.take_cursor_request(), Some(true));
1459        frame.cursor_applied(true);
1460        armed.store(true, Ordering::Relaxed);
1461        run_at(&mut frame, &gpu, &texture, start + Duration::from_millis(1));
1462        assert_eq!(
1463            frame
1464                .presenter
1465                .as_ref()
1466                .expect("the presenter attached")
1467                .sections()
1468                .iter()
1469                .map(|section| section.name)
1470                .collect::<Vec<_>>(),
1471            expected_sections
1472        );
1473        assert_eq!(
1474            frame.phase_error().map(|error| error.phase),
1475            Some(Phase::Publication)
1476        );
1477        assert_eq!(filled.load(Ordering::Relaxed), 1);
1478        assert_eq!(recorded.load(Ordering::Relaxed), 2);
1479        assert_eq!(frame.take_cursor_request(), Some(false));
1480        frame.cursor_applied(false);
1481        for millis in 2_u64..=4 {
1482            run_at(
1483                &mut frame,
1484                &gpu,
1485                &texture,
1486                start + Duration::from_millis(millis),
1487            );
1488            assert_eq!(
1489                frame
1490                    .presenter
1491                    .as_ref()
1492                    .expect("the presenter attached")
1493                    .sections()
1494                    .iter()
1495                    .map(|section| section.name)
1496                    .collect::<Vec<_>>(),
1497                expected_sections
1498            );
1499        }
1500        assert_eq!(recorded.load(Ordering::Relaxed), 5);
1501
1502        frame.action(Key::Letter('r'), true, false);
1503        run_at(&mut frame, &gpu, &texture, start + Duration::from_secs(60));
1504        assert_eq!(recorded.load(Ordering::Relaxed), 6);
1505        assert_eq!(filled.load(Ordering::Relaxed), 2);
1506
1507        assert_eq!(frame.phase_error(), None);
1508        assert_eq!(frame.inner.session.scene().epoch(), epoch.advance());
1509        assert_eq!(frame.inner.session.current_tick(), Tick(0));
1510        assert_eq!(filled.load(Ordering::Relaxed), 2);
1511        assert_eq!(frame.take_cursor_request(), Some(true));
1512        run_at(
1513            &mut frame,
1514            &gpu,
1515            &texture,
1516            start + Duration::from_secs(60) + Duration::from_millis(1),
1517        );
1518        assert_eq!(frame.inner.session.scene().epoch(), epoch.advance());
1519    }
1520
1521    #[test]
1522    fn a_scripted_line_reaches_its_verb_on_the_frame_the_script_names() {
1523        let gpu = noop_gpu();
1524        let texture = offscreen(&gpu);
1525        let directory = tempfile::tempdir().expect("temp dir");
1526        let path = directory.path().join("demo.script");
1527        std::fs::write(&path, "0 mark first\n2 mark second\n").expect("wrote the script");
1528
1529        let marked: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1530        let recorded = marked.clone();
1531        let args = Args::from_argv([format!("--script={}", path.display())]);
1532        let app = quiet(SessionApp::<Bare>::with_args(
1533            HostConfig::new("script", Bindings::new()),
1534            args,
1535        ))
1536        .command("mark", "record a marker", move |args, _submit, _out| {
1537            recorded
1538                .lock()
1539                .unwrap_or_else(|error| error.into_inner())
1540                .push(args.join(" "));
1541            Ok(())
1542        });
1543        let mut frame = bare(app);
1544        frame
1545            .attach(&gpu, FORMAT, None, SIZE, 1.0)
1546            .expect("attached");
1547
1548        let mut after_each = Vec::new();
1549        for _ in 0..3 {
1550            run_one(&mut frame, &gpu, &texture);
1551            after_each.push(
1552                marked
1553                    .lock()
1554                    .unwrap_or_else(|error| error.into_inner())
1555                    .len(),
1556            );
1557        }
1558
1559        assert_eq!(
1560            after_each,
1561            [1, 1, 2],
1562            "the host drove the script off its own frame index"
1563        );
1564        assert_eq!(
1565            *marked.lock().unwrap_or_else(|error| error.into_inner()),
1566            ["first", "second"]
1567        );
1568    }
1569
1570    #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
1571    #[test]
1572    #[ignore = "requires a working wgpu adapter; run with --include-ignored"]
1573    fn a_capture_from_the_session_host_writes_the_targets_pixels_gpu_probe() {
1574        let instance = Instance::default();
1575        let gpu = pollster::block_on(GpuContext::new(instance, FeatureRequest::default(), None))
1576            .expect("a wgpu adapter");
1577        let texture = offscreen(&gpu);
1578        let directory = tempfile::tempdir().expect("temp dir");
1579        let app = host("capture")
1580            .capture(crate::capture::CaptureRequest::OneShot {
1581                stage: crate::capture::CaptureStage::Post,
1582                dir: Some(directory.path().to_path_buf()),
1583                name: Some("session".into()),
1584            })
1585            .expect("capture supported");
1586        let mut frame = bare(app);
1587        frame
1588            .attach(&gpu, FORMAT, None, SIZE, 1.0)
1589            .expect("attached");
1590        run_one(&mut frame, &gpu, &texture);
1591        drop(frame);
1592
1593        let written = directory.path().join("session_post.png");
1594        let decoded = ::image::open(&written).expect("the capture wrote a png");
1595        assert_eq!(
1596            (decoded.width(), decoded.height()),
1597            SIZE,
1598            "the capture read a different rectangle than the presenter's target"
1599        );
1600    }
1601
1602    #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
1603    #[test]
1604    #[ignore = "requires a working wgpu adapter; run with --include-ignored"]
1605    fn pre_capture_excludes_the_overlay_that_post_capture_includes_gpu_probe() {
1606        let instance = Instance::default();
1607        let gpu = pollster::block_on(GpuContext::new(instance, FeatureRequest::default(), None))
1608            .expect("a wgpu adapter");
1609        let texture = offscreen(&gpu);
1610        let directory = tempfile::tempdir().expect("temp dir");
1611        let app = host("capture stages")
1612            .pass(Box::new(Paint {
1613                name: "red scene",
1614                color: Color {
1615                    r: 1.0,
1616                    g: 0.0,
1617                    b: 0.0,
1618                    a: 1.0,
1619                },
1620                overlay: false,
1621                pipeline: None,
1622            }))
1623            .pass(Box::new(Paint {
1624                name: "green overlay",
1625                color: Color {
1626                    r: 0.0,
1627                    g: 1.0,
1628                    b: 0.0,
1629                    a: 1.0,
1630                },
1631                overlay: true,
1632                pipeline: None,
1633            }))
1634            .capture(crate::capture::CaptureRequest::OneShot {
1635                stage: crate::capture::CaptureStage::Both,
1636                dir: Some(directory.path().to_path_buf()),
1637                name: Some("stages".into()),
1638            })
1639            .expect("capture supported");
1640        let mut frame = bare(app);
1641        frame
1642            .attach(&gpu, FORMAT, None, SIZE, 1.0)
1643            .expect("attached");
1644        run_one(&mut frame, &gpu, &texture);
1645
1646        let pre = ::image::open(directory.path().join("stages_pre.png"))
1647            .expect("the pre capture")
1648            .to_rgba8();
1649        let post = ::image::open(directory.path().join("stages_post.png"))
1650            .expect("the post capture")
1651            .to_rgba8();
1652        assert_eq!(pre.get_pixel(0, 0).0, [255, 0, 0, 255]);
1653        assert_eq!(post.get_pixel(0, 0).0, [0, 255, 0, 255]);
1654    }
1655
1656    #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
1657    #[test]
1658    #[ignore = "requires a working wgpu adapter; run with --include-ignored"]
1659    fn a_hook_that_stops_its_capture_finishes_the_file_gpu_probe() {
1660        let instance = Instance::default();
1661        let gpu = pollster::block_on(GpuContext::new(instance, FeatureRequest::default(), None))
1662            .expect("a wgpu adapter");
1663        let texture = offscreen(&gpu);
1664        let directory = tempfile::tempdir().expect("temp dir");
1665        let into = directory.path().to_path_buf();
1666        let mut frames = 0_u32;
1667        let app = host::<Bare>("capture").on_frame(move |hook| {
1668            frames += 1;
1669            if frames == 1 {
1670                hook.capture
1671                    .start(crate::capture::CaptureRequest::StartSequence {
1672                        format: crate::capture::CaptureFormat::Apng,
1673                        stage: crate::capture::CaptureStage::Post,
1674                        dir: Some(into.clone()),
1675                        name: Some("session".into()),
1676                        fps: Some(CAPTURE_FPS),
1677                        scale: None,
1678                        palette: crate::capture::PaletteMode::default(),
1679                    })
1680                    .expect("capture supported");
1681            }
1682            if frames == 3 {
1683                hook.capture.stop().expect("capture supported");
1684            }
1685        });
1686        let mut frame = bare(app);
1687        frame
1688            .attach(&gpu, FORMAT, None, SIZE, 1.0)
1689            .expect("attached");
1690        for _ in 0..4 {
1691            run_one(&mut frame, &gpu, &texture);
1692            std::thread::sleep(Duration::from_millis(1000 / u64::from(CAPTURE_FPS) + 4));
1693        }
1694        drop(frame);
1695
1696        let written = directory.path().join("session.apng");
1697        let bytes = std::fs::read(&written).expect("the stopped capture finished its file");
1698        assert_eq!(
1699            animation_frames(&bytes),
1700            Some(2),
1701            "the file holds the frames drawn between the start and the stop"
1702        );
1703    }
1704
1705    #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
1706    fn animation_frames(png: &[u8]) -> Option<u32> {
1707        let mut at = 8;
1708        while at + 12 <= png.len() {
1709            let length = u32::from_be_bytes(png[at..at + 4].try_into().ok()?) as usize;
1710            if &png[at + 4..at + 8] == b"acTL" {
1711                return Some(u32::from_be_bytes(png[at + 8..at + 12].try_into().ok()?));
1712            }
1713            at += 12 + length;
1714        }
1715        None
1716    }
1717}