concinnity-engine 0.19.0

Runtime engine for Concinnity: ECS schedule, graphics, spawn, streaming
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
// src/gfx/overlay/mod.rs
//
// OverlaySystem: builds the 2D overlay draw list (sprites, text, dropdown,
// text-input fields, cursor) from the world's UI components and publishes the
// per-frame menu state. Runs first in the schedule, before GraphicsSystem
// submits the frame:
//   mod.rs        system + the per-frame draw-list build
//   widgets.rs    transient dropdown / text-input element synthesis
//   hud_layout.rs LayoutContainer reflow + DebugHud / StatHud chip anchoring
//
// The font atlases and texture slots the build measures against are uploaded
// by GraphicsSystem's init, which parks them here as the `OverlayAssets`
// resource; the build's output is parked as the `OverlayFrame` resource that
// GraphicsSystem consumes for this same frame's submit. HUD content is what
// the HUD systems wrote last tick (they run after the build), so what is
// measured is exactly what is drawn.

use crate::components::{Sprite, TextInput, TextLabel};
use crate::ecs::asset_id::AssetId;
use crate::ecs::{PipelineContext, StepResult, System};
use crate::gfx::{sprite as gfx_sprite, text};
use std::time::Instant;

mod hud_layout;
mod widgets;

// Base of the reserved draw-layer band `HudLayers` overrides sit in: far above
// any screen-stack layer (authored layer band ~4M at most), so an overriding
// producer always occludes world screens while keeping its own relative order.
const HUD_OVERRIDE_LAYER_BASE: i32 = i32::MAX / 2;

// An open dropdown list is modal (it swallows every nav input while it is up),
// so it draws over every screen and HUD-override layer; only the cursor, which
// points at it, outranks it.
const DROPDOWN_LAYER: i32 = i32::MAX - 1;

// Everything the overlay build needs from GraphicsSystem's init: the loaded
// font atlases, the sprite-texture slot map, the HUD chip id lists, and the
// scroll-panel clip bands. Parked as a resource at the end of graphics init;
// the build takes it for the duration of each step and puts it back (the
// `Default` left behind exists only within that step).
#[derive(Default)]
pub(crate) struct OverlayAssets {
    pub fonts: text::FontSet,
    pub(crate) sprite_texture_slots: crate::gfx::overlay_maps::TextureSlots,
    pub(crate) debug_hud_chips: Vec<AssetId>,
    pub(crate) stat_hud_chips: Vec<AssetId>,
    pub(crate) clip_rects: crate::gfx::overlay_maps::ClipRects,
    // The backend's logical size at init, the viewport used until the first
    // input poll publishes a live one (`FrameInput.viewport`).
    pub(crate) initial_viewport: (f32, f32),
}

// One frame's overlay build, published by OverlaySystem and consumed (taken)
// by GraphicsSystem's submit the same tick: the shaped draw calls, whether an
// in-engine cursor sprite is shown (so the backend hides the system cursor),
// the resolved menu state (`MenuOverride` applied), and whether an opaque
// full-canvas backdrop lets the world render be skipped entirely.
#[derive(Default)]
pub(crate) struct OverlayFrame {
    pub calls: Vec<crate::gfx::render_types::TextDrawCall>,
    pub want_ui_cursor: bool,
    pub(crate) menu_active: bool,
    pub(crate) world_hidden: bool,
}

// A spent frame's overlay draw list, handed back by graphics extraction when
// it adopts the new one. The next build recycles it whole (the list and every
// call's geometry buffers), so a steady-state frame allocates nothing.
#[derive(Default)]
pub(crate) struct OverlayRecycle(pub Vec<crate::gfx::render_types::TextDrawCall>);

#[derive(Debug, Default)]
pub(crate) struct OverlaySystem {
    // Base for the caret-blink clock, set on the first step.
    start_time: Option<Instant>,
    // Scratch for the per-element draw-layer merge, reused across frames.
    layers: crate::gfx::overlay_maps::OverlayLayers,
    // Scratch for the LayoutContainer label reflow, reused across frames.
    hud_scratch: hud_layout::LabelLayoutScratch,
    // Buffers the synthesised dropdown / text-input elements are built into,
    // reused across frames.
    widget_scratch: widgets::WidgetScratch,
    // The draw list under construction plus the pooled geometry of recycled
    // frames; the finished list moves out through `OverlayFrame` each tick.
    buffer: crate::gfx::call_buffer::TextCallBuffer,
}

impl OverlaySystem {
    pub(crate) fn new() -> Self {
        Self::default()
    }
}

impl System for OverlaySystem {
    fn access(&self) -> crate::ecs::Access {
        crate::ecs::Access::new()
            .reads_components(crate::component_mask![
                crate::components::Sprite,
                crate::components::TextInput,
                crate::components::LayoutContainer,
            ])
            .writes_components(crate::component_mask![crate::components::TextLabel])
            .reads_resources(crate::resource_mask![
                crate::components::FrameInput,
                crate::ecs::CursorState,
                crate::ecs::ScreenStack,
                crate::ecs::HudLayers,
                crate::ecs::OpenDropdown,
                crate::ecs::DesiredCursor,
                crate::ecs::MenuOverride,
            ])
            .writes_resources(crate::resource_mask![
                crate::gfx::overlay::OverlayAssets,
                crate::gfx::overlay::OverlayFrame,
                crate::gfx::overlay::OverlayRecycle,
                crate::ecs::MenuActive,
            ])
    }

    fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
        // No parked assets: graphics init has not succeeded (or not run), so
        // there is nothing to build against and nothing will be drawn.
        let Some(assets) = ctx.take_resource::<OverlayAssets>() else {
            return StepResult::Continue;
        };
        let elapsed = self
            .start_time
            .get_or_insert_with(Instant::now)
            .elapsed()
            .as_secs_f32();
        let mut frame = self.build_frame(ctx, &assets, elapsed);
        ctx.insert_resource(assets);

        // An external per-frame driver (the `cn editor` HUD) can force the
        // menu-active state through the `MenuOverride` resource, so it frees
        // the cursor + freezes the world regardless of the world's own menu
        // UI. `None` leaves the world's own logic in charge. This shadows
        // `menu_active` for every consumer (capture, the freeze resource, the
        // gameplay-input gate), but not `world_hidden`: the editor keeps the
        // world visible.
        if let Some(forced) = ctx.resource::<crate::ecs::MenuOverride>().and_then(|m| m.0) {
            frame.menu_active = forced;
        }
        // Publish the menu state for every later system this tick: physics +
        // animation freeze while it is set, so a paused world stops consuming
        // CPU/GPU behind the menu. The App-level pacer reads it before the
        // next step to clamp the frame rate while a menu is open.
        ctx.insert_resource(crate::ecs::MenuActive(frame.menu_active));
        ctx.insert_resource(frame);
        StepResult::Continue
    }
}

impl OverlaySystem {
    // Build the frame's overlay draw calls. Sprites render as solid-coloured
    // quads through the same UI pass as TextLabel (sentinel-UV path), so they
    // share the text pipeline and require no new render state. Backdrop / HUD
    // sprites are emitted first so labels composite on top; `follow_cursor`
    // sprites are emitted last so the cursor sits on top of everything.
    fn build_frame(
        &mut self,
        ctx: &mut PipelineContext,
        assets: &OverlayAssets,
        elapsed: f32,
    ) -> OverlayFrame {
        // Recycle the previous frame's spent draw list (handed back by
        // graphics extraction), so this build reuses its allocations.
        if let Some(spent) = ctx.take_resource::<OverlayRecycle>() {
            self.buffer.recycle(spent.0);
        }
        // The viewport InputSystem sampled at the end of the previous tick, or
        // the init-time size before the first poll. A live resize is picked up
        // one frame later, which is invisible mid-drag.
        let (win_w, win_h) = ctx
            .resource::<crate::components::FrameInput>()
            .map(|i| (i.viewport[0], i.viewport[1]))
            .unwrap_or(assets.initial_viewport);
        // The cursor state InputSystem sampled at the end of the previous tick
        // (`follow_cursor` sprites are positioned a frame after the input that
        // moved them).
        let cursor = ctx
            .resource::<crate::ecs::CursorState>()
            .copied()
            .unwrap_or_default();
        // Reposition LayoutContainer-managed labels before measuring them
        // for draw, so a HUD reflows to its live text each frame.
        hud_layout::apply_label_layout(ctx, &assets.fonts, &mut self.hud_scratch);
        // Anchor the DebugHud chips to the top-right corner, stacked.
        hud_layout::position_debug_hud(ctx, &assets.debug_hud_chips, &assets.fonts, win_w);
        // Pack the StatHud chips into a tight strip in the top-left corner.
        hud_layout::position_stat_hud(ctx, &assets.stat_hud_chips, &assets.fonts);
        let default_atlas_slot = assets.fonts.any_atlas_slot();
        // The component columns are contiguous, so the shapers take the whole
        // slices; each skips what is not its own (hidden elements, and
        // `follow_cursor` sprites, which only the cursor pass draws).
        let sprites: &[Sprite] = ctx.query::<Sprite>().as_slice();
        let labels: &[TextLabel] = ctx.query::<TextLabel>().as_slice();

        // Per-element draw layers, from two sources merged into one map:
        //   - the screen stack: every element of an active Screen takes its
        //     screen's computed layer (stack position within the authored layer
        //     band), so screens draw in stack order and above the layer-0 HUD;
        //   - the `cn editor` HUD's per-frame overrides, lifted into a reserved
        //     top range so the editor panels always occlude world screens while
        //     keeping their own focus order.
        // An id absent from the map is layer 0. When the map ends up empty (no
        // active screen, no editor), the sort below is skipped and draw order is
        // pure insertion order, as before.
        let empty_layers = crate::gfx::overlay_maps::OverlayLayers::new();
        let screen_layers = ctx.resource::<crate::ecs::ScreenStack>().map(|s| &s.layers);
        self.layers.clear();
        if let Some(screen_layers) = screen_layers.filter(|l| !l.is_empty()) {
            for s in ctx.query::<Sprite>() {
                if let Some(layer) = s.screen.and_then(|id| screen_layers.get(&id)) {
                    self.layers.insert(s.asset_id, *layer);
                }
            }
            for l in ctx.query::<TextLabel>() {
                if let Some(layer) = l.screen.and_then(|id| screen_layers.get(&id)) {
                    self.layers.insert(l.asset_id, *layer);
                }
            }
            for t in ctx.query::<TextInput>() {
                if let Some(layer) = t.screen.and_then(|id| screen_layers.get(&id)) {
                    self.layers.insert(t.asset_id, *layer);
                }
            }
        }
        if let Some(overrides) = ctx.resource::<crate::ecs::HudLayers>() {
            for (id, layer) in &overrides.0 {
                self.layers.insert(*id, HUD_OVERRIDE_LAYER_BASE + layer);
            }
        }
        let hud_layers = &self.layers;

        gfx_sprite::build_sprite_calls_into(
            &mut self.buffer,
            sprites,
            default_atlas_slot,
            &assets.sprite_texture_slots,
            [win_w, win_h],
            &assets.clip_rects,
            hud_layers,
        );
        text::build_text_calls_into(
            &mut self.buffer,
            labels,
            &assets.fonts,
            win_w,
            win_h,
            &assets.clip_rects,
            hud_layers,
        );

        // A settings dropdown's open list draws on top of the menu (after the
        // clipped row text, before the cursor) and unclipped, so it escapes
        // the scroll band's scissor. Built as transient overlay Sprites +
        // TextLabels fed through the same shapers (with no clip bands).
        if let Some(view) = ctx
            .resource::<crate::ecs::OpenDropdown>()
            .and_then(|d| d.0.as_ref())
        {
            let no_clips = crate::gfx::overlay_maps::ClipRects::new();
            widgets::build_dropdown_overlay(view, &assets.fonts, &mut self.widget_scratch);
            let dd_start = self.buffer.calls.len();
            gfx_sprite::build_sprite_calls_into(
                &mut self.buffer,
                &self.widget_scratch.sprites,
                default_atlas_slot,
                &assets.sprite_texture_slots,
                [win_w, win_h],
                &no_clips,
                &empty_layers,
            );
            text::build_text_calls_into(
                &mut self.buffer,
                &self.widget_scratch.labels,
                &assets.fonts,
                win_w,
                win_h,
                &no_clips,
                &empty_layers,
            );
            // The synthesised list carries no asset id, so nothing would lift it out
            // of layer 0 -- where the sort below buries it under the opaque rows it
            // drops from (functional, but invisible).
            for c in &mut self.buffer.calls[dd_start..] {
                c.layer = DROPDOWN_LAYER;
            }
        }

        // Text-input fields draw as a background box + their text + a caret,
        // synthesised the same way as the dropdown overlay and fed through the
        // shapers (clipped like the rest, so a field inside a scroll band
        // scissors correctly).
        // Caret blink: visible for the first half of each period so a focused
        // field's caret pulses rather than sitting solid.
        const CARET_BLINK_PERIOD: f32 = 1.06;
        let caret_visible = (elapsed % CARET_BLINK_PERIOD) < CARET_BLINK_PERIOD * 0.5;
        for ti in ctx.query::<TextInput>() {
            if !ti.visible {
                continue;
            }
            widgets::build_text_input_overlay(
                ti,
                &assets.fonts,
                caret_visible,
                &mut self.widget_scratch,
            );
            // The synthesised overlay carries no asset id, so its calls take the
            // field's own layer (from the field's id) rather than looking up the
            // default id -- otherwise a focused panel's text fields would sink
            // below it.
            let ti_layer = hud_layers.get(&ti.asset_id).copied().unwrap_or(0);
            let ti_start = self.buffer.calls.len();
            gfx_sprite::build_sprite_calls_into(
                &mut self.buffer,
                &self.widget_scratch.sprites,
                default_atlas_slot,
                &assets.sprite_texture_slots,
                [win_w, win_h],
                &assets.clip_rects,
                &empty_layers,
            );
            text::build_text_calls_into(
                &mut self.buffer,
                &self.widget_scratch.labels,
                &assets.fonts,
                win_w,
                win_h,
                &assets.clip_rects,
                &empty_layers,
            );
            for c in &mut self.buffer.calls[ti_start..] {
                c.layer = ti_layer;
            }
        }

        // A menu cursor is present when any visible follow_cursor sprite is
        // opaque. Draw it (as an arrow pointer at the latest mouse position,
        // after the text so it sits on top) only while the real cursor is
        // inside the window: when it leaves in windowed / borderless modes
        // the arrow is hidden instead of lingering at the edge. The backend
        // confines the cursor in fullscreen, so it reports "inside" there.
        let menu_cursor = sprites
            .iter()
            .any(|s| s.follow_cursor && s.visible && s.tint[3] > 0.0);
        let want_ui_cursor = menu_cursor && !cursor.outside_window;
        if want_ui_cursor {
            // The `cn editor` HUD switches the silhouette to a resize cursor over a
            // panel edge; every other cursor stays the arrow (the default absence).
            let cursor_shape = ctx
                .resource::<crate::ecs::DesiredCursor>()
                .map(|c| c.0)
                .unwrap_or_default();
            crate::gfx::cursor::build_cursor_calls_into(
                &mut self.buffer,
                sprites,
                cursor.pos,
                cursor_shape,
                default_atlas_slot,
                [win_w, win_h],
            );
        }
        // A menu is "active" while any active screen pauses the world (the
        // screen stack publishes the flag); used to drive cursor capture and to
        // freeze gameplay input + simulation. A screen with `pauses_world` off
        // (a passthrough overlay, a live console) shows without pausing.
        let menu_active = ctx
            .resource::<crate::ecs::ScreenStack>()
            .is_some_and(|s| s.pauses_world);
        // The whole world render can be skipped when an opaque full-canvas
        // backdrop covers the scene (a menu authored with its dim alpha at
        // 1.0): nothing of the scene is visible, so every world pass is
        // wasted. A translucent dim keeps the world faintly visible and so
        // does not qualify.
        let world_hidden = menu_active
            && sprites.iter().any(|s| {
                !s.follow_cursor && s.visible && s.tint[3] >= 1.0 && gfx_sprite::covers_canvas(s)
            });
        // Reorder the overlay by layer when any call carries one (an active screen
        // stack, the editor's focus-stack overrides, an open dropdown, the cursor).
        // Same-layer order is kept (so the sprites-then-text order within a panel
        // is intact) while lifting a screen's or focused panel's whole content
        // above the others'. Skipped entirely when nothing is layered, so draw
        // order stays pure insertion order.
        self.buffer.sort_by_layer();
        OverlayFrame {
            calls: self.buffer.take(),
            want_ui_cursor,
            menu_active,
            world_hidden,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::blob::BlobData;
    use crate::components::{SpriteFit, TextAlign};
    use crate::ecs::{
        ComponentSlot, ComponentStorage, CursorState, DropdownView, FontHandle, HudLayers,
        MenuOverride, OpenDropdown, Resources, ScreenStack,
    };
    use crate::gfx::profile::FrameProfile;

    const FONT: FontHandle = FontHandle(0);
    const SCREEN: AssetId = AssetId(50);
    // The reference canvas the overlay authors against, so a backdrop sized to
    // it counts as full-canvas.
    const REF_W: f32 = 1280.0;
    const REF_H: f32 = 720.0;

    fn make_glyph(advance_px: f32) -> crate::gfx::font::GlyphMetrics {
        crate::gfx::font::GlyphMetrics {
            char_code: 0,
            atlas_x: 0,
            atlas_y: 0,
            atlas_w: 8,
            atlas_h: 12,
            advance_px,
            bearing_x: 0.0,
            bearing_y: 12.0,
        }
    }

    // A fixed-width synthetic font (every glyph 10px in a 16px em) so the built
    // geometry is exact.
    fn loaded_fonts() -> text::FontSet {
        let metrics: crate::gfx::text::FontMetrics = ('a'..='z')
            .chain('A'..='Z')
            .map(|c| (c as u32, make_glyph(10.0)))
            .collect();
        let cap_px = text::derive_cap_px(&metrics, 16.0);
        let mut fonts = text::FontSet::default();
        fonts.insert(
            FONT,
            text::LoadedFont {
                atlas_slot: 0,
                cap_px,
                metrics,
                atlas_w: 128,
                atlas_h: 128,
                size_px: 16.0,
                supersample: 1.0,
            },
        );
        fonts
    }

    fn assets() -> OverlayAssets {
        OverlayAssets {
            fonts: loaded_fonts(),
            sprite_texture_slots: crate::gfx::overlay_maps::TextureSlots::new(),
            debug_hud_chips: Vec::new(),
            stat_hud_chips: Vec::new(),
            clip_rects: crate::gfx::overlay_maps::ClipRects::new(),
            initial_viewport: (REF_W, REF_H),
        }
    }

    // An opaque HUD sprite (window pixels, no screen), visible by default.
    fn sprite(id: AssetId) -> Sprite {
        Sprite {
            asset_id: id,
            x: 0.0,
            y: 0.0,
            width: 10.0,
            height: 10.0,
            texture: None,
            tint: [1.0, 1.0, 1.0, 1.0],
            follow_cursor: false,
            visible: true,
            screen: None,
            fit: SpriteFit::Fit,
            corner_radius: 0.0,
            border_width: 0.0,
            border_color: [0.0, 0.0, 0.0, 1.0],
        }
    }

    // A screen-owned sprite spanning the whole reference canvas: the menu-dim
    // shape `covers_canvas` recognises.
    fn backdrop(id: AssetId) -> Sprite {
        Sprite {
            width: REF_W,
            height: REF_H,
            screen: Some(SCREEN),
            ..sprite(id)
        }
    }

    fn label(id: AssetId, content: &str) -> TextLabel {
        TextLabel {
            asset_id: id,
            font: Some(FONT),
            content: content.to_string(),
            x: 0.0,
            y: 0.0,
            color: [1.0, 1.0, 1.0],
            scale: 1.0,
            centered: false,
            align: TextAlign::Left,
            fit: SpriteFit::Fit,
            background: [0.0, 0.0, 0.0, 0.0],
            padding: 0.0,
            visible: true,
            screen: None,
            wrap_width: 0.0,
            max_lines: 0,
        }
    }

    // A list dropped from a 200x40 control on SCREEN, with room to open downward.
    fn dropdown_view() -> DropdownView {
        DropdownView {
            anchor: [400.0, 100.0, 200.0, 40.0],
            options: vec!["aa".to_string(), "bb".to_string()],
            selected: 0,
            first: 0,
            hovered: None,
            screen: Some(SCREEN),
            font: Some(FONT),
            scale: 1.0,
            color: [1.0, 1.0, 1.0],
        }
    }

    fn text_input(id: AssetId) -> TextInput {
        TextInput {
            asset_id: id,
            font: Some(FONT),
            content: "ab".to_string(),
            ..Default::default()
        }
    }

    // A stack that owns SCREEN at `layer` and pauses the world.
    fn screen_stack(layer: i32) -> ScreenStack {
        ScreenStack {
            layers: std::collections::BTreeMap::from([(SCREEN, layer)]),
            pauses_world: true,
            captures_input: true,
        }
    }

    // Owns the storage a PipelineContext borrows from. The overlay build reads
    // no payloads, so the blob stays empty.
    struct TestWorld {
        components: ComponentStorage,
        blob: BlobData,
        profile: FrameProfile,
        resources: Resources,
        scratch: crate::ecs::Arena,
    }

    impl TestWorld {
        fn new() -> Self {
            Self {
                components: ComponentStorage::default(),
                blob: BlobData::new(vec![Some(Vec::new())]),
                profile: FrameProfile::default(),
                resources: Resources::new(),
                scratch: crate::ecs::Arena::with_capacity(64 * 1024),
            }
        }

        fn push<C: ComponentSlot>(&mut self, c: C) {
            self.components.push_typed(c);
        }

        fn ctx(&mut self) -> PipelineContext<'_> {
            PipelineContext {
                components: &mut self.components,
                blob: &mut self.blob,
                profile: &mut self.profile,
                resources: &mut self.resources,
                frame: crate::ecs::FrameContext::new(&self.scratch),
            }
        }

        // Build one frame at an explicit `elapsed`, so the caret blink is driven
        // by the test rather than the wall clock.
        fn build(&mut self, elapsed: f32) -> OverlayFrame {
            let a = assets();
            OverlaySystem::new().build_frame(&mut self.ctx(), &a, elapsed)
        }
    }

    // The x span of a call's quad, for reading a backdrop's mapped rect back out.
    fn x_span(call: &crate::gfx::render_types::TextDrawCall) -> (f32, f32) {
        let xs: Vec<f32> = call.vertices.iter().map(|v| v.pos[0]).collect();
        (
            xs.iter().copied().fold(f32::INFINITY, f32::min),
            xs.iter().copied().fold(f32::NEG_INFINITY, f32::max),
        )
    }

    // Without the parked assets graphics init has not run, so the step is inert:
    // no frame and no menu state are published.
    #[test]
    fn step_without_overlay_assets_publishes_nothing() {
        let mut w = TestWorld::new();
        w.push(sprite(AssetId(1)));
        let mut sys = OverlaySystem::new();
        sys.step(&mut w.ctx());
        assert!(w.resources.get::<OverlayFrame>().is_none());
        assert!(w.resources.get::<crate::ecs::MenuActive>().is_none());
    }

    // A step publishes the frame plus the menu state for the systems behind it,
    // and parks the assets back for the next tick.
    #[test]
    fn step_publishes_the_frame_and_parks_the_assets_back() {
        let mut w = TestWorld::new();
        w.push(sprite(AssetId(1)));
        w.resources.insert(assets());
        let mut sys = OverlaySystem::new();
        sys.step(&mut w.ctx());
        assert_eq!(w.resources.get::<OverlayFrame>().unwrap().calls.len(), 1);
        assert!(!w.resources.get::<crate::ecs::MenuActive>().unwrap().0);
        assert!(
            w.resources.get::<OverlayAssets>().is_some(),
            "the assets go back for the next build"
        );
    }

    // The editor's `MenuOverride` shadows the world's own menu state for every
    // consumer, but deliberately not `world_hidden`: the editor keeps the world
    // visible behind its panels.
    #[test]
    fn step_menu_override_shadows_the_menu_state_but_not_world_hidden() {
        let mut w = TestWorld::new();
        w.resources.insert(assets());
        w.resources.insert(MenuOverride(Some(true)));
        let mut sys = OverlaySystem::new();
        sys.step(&mut w.ctx());
        assert!(w.resources.get::<OverlayFrame>().unwrap().menu_active);
        assert!(w.resources.get::<crate::ecs::MenuActive>().unwrap().0);

        // A world whose own menu pauses and fully covers the scene, forced off:
        // the menu state follows the override while world_hidden keeps tracking
        // what is actually drawn.
        let mut w = TestWorld::new();
        w.push(backdrop(AssetId(1)));
        w.resources.insert(assets());
        w.resources.insert(screen_stack(0));
        w.resources.insert(MenuOverride(Some(false)));
        let mut sys = OverlaySystem::new();
        sys.step(&mut w.ctx());
        let frame = w.resources.get::<OverlayFrame>().unwrap();
        assert!(!frame.menu_active);
        assert!(frame.world_hidden);
    }

    // `MenuOverride(None)` leaves the world's own menu logic in charge.
    #[test]
    fn step_menu_override_of_none_defers_to_the_world() {
        let mut w = TestWorld::new();
        w.resources.insert(assets());
        w.resources.insert(screen_stack(0));
        w.resources.insert(MenuOverride(None));
        let mut sys = OverlaySystem::new();
        sys.step(&mut w.ctx());
        assert!(w.resources.get::<OverlayFrame>().unwrap().menu_active);
    }

    // The build measures against the viewport InputSystem last sampled, falling
    // back to the init-time size before the first poll. A full-canvas backdrop
    // stretches to exactly that, so its quad reads the viewport back out.
    #[test]
    fn viewport_follows_frame_input_and_falls_back_to_the_init_size() {
        let mut w = TestWorld::new();
        w.push(backdrop(AssetId(1)));
        let frame = w.build(0.0);
        assert_eq!(x_span(&frame.calls[0]), (0.0, REF_W));

        w.resources.insert(crate::components::FrameInput {
            viewport: [800.0, 600.0],
            ..Default::default()
        });
        let frame = w.build(0.0);
        assert_eq!(x_span(&frame.calls[0]), (0.0, 800.0));
    }

    // An active screen's layer spreads onto every element it owns -- sprites,
    // labels and text-input fields alike -- and the calls sort by it, so the
    // screen's whole content lifts above the layer-0 HUD.
    #[test]
    fn screen_layers_spread_onto_the_elements_the_screen_owns() {
        let mut w = TestWorld::new();
        w.push(label(AssetId(1), "hud"));
        w.push(Sprite {
            screen: Some(SCREEN),
            ..sprite(AssetId(2))
        });
        w.push(TextLabel {
            screen: Some(SCREEN),
            ..label(AssetId(3), "menu")
        });
        w.push(TextInput {
            screen: Some(SCREEN),
            ..text_input(AssetId(4))
        });
        w.resources.insert(screen_stack(7));

        let frame = w.build(0.0);
        // The HUD label is layer 0 and sorts first; everything the screen owns
        // takes its layer.
        assert_eq!(frame.calls[0].layer, 0);
        assert!(
            frame.calls[1..].iter().all(|c| c.layer == 7),
            "{:?}",
            frame.calls.iter().map(|c| c.layer).collect::<Vec<_>>()
        );
    }

    // A screen-less element stays at layer 0 even while a stack is active, and
    // an element pointing at a screen that is not in the stack does too.
    #[test]
    fn elements_outside_the_active_stack_stay_at_layer_zero() {
        let mut w = TestWorld::new();
        w.push(sprite(AssetId(1)));
        w.push(Sprite {
            screen: Some(AssetId(99)),
            ..sprite(AssetId(2))
        });
        w.resources.insert(screen_stack(7));
        let frame = w.build(0.0);
        assert!(frame.calls.iter().all(|c| c.layer == 0));
    }

    // The editor HUD's overrides land in a reserved range far above any screen
    // layer, so its panels always occlude world screens while keeping their own
    // order.
    #[test]
    fn editor_layer_overrides_lift_elements_above_screen_layers() {
        let mut w = TestWorld::new();
        w.push(Sprite {
            screen: Some(SCREEN),
            ..sprite(AssetId(1))
        });
        w.push(sprite(AssetId(2)));
        w.resources.insert(screen_stack(7));
        w.resources
            .insert(HudLayers(std::collections::BTreeMap::from([(
                AssetId(2),
                3,
            )])));

        let frame = w.build(0.0);
        // The screen sprite sorts below the editor panel, which sits in the
        // reserved band regardless of the screen's own layer.
        assert_eq!(frame.calls[0].layer, 7);
        assert_eq!(frame.calls[1].layer, HUD_OVERRIDE_LAYER_BASE + 3);
    }

    // A spent draw list handed back through `OverlayRecycle` backs the next
    // build, so a steady-state frame reuses the list allocation instead of
    // growing a fresh one.
    #[test]
    fn a_recycled_draw_list_backs_the_next_build() {
        let mut w = TestWorld::new();
        w.push(sprite(AssetId(1)));
        let a = assets();
        let mut sys = OverlaySystem::new();
        let frame = sys.build_frame(&mut w.ctx(), &a, 0.0);
        assert_eq!(frame.calls.len(), 1);
        let spent_ptr = frame.calls.as_ptr();
        w.resources.insert(OverlayRecycle(frame.calls));
        let frame = sys.build_frame(&mut w.ctx(), &a, 0.0);
        assert_eq!(frame.calls.as_ptr(), spent_ptr, "list backing reused");
        assert_eq!(frame.calls.len(), 1);
        assert!(
            w.resources
                .get::<OverlayRecycle>()
                .is_none_or(|r| r.0.is_empty()),
            "the spent list was consumed"
        );
    }

    // With no screen stack and no editor overrides nothing is layered, so the
    // sort is skipped and draw order stays pure insertion order.
    #[test]
    fn draw_order_is_insertion_order_without_any_layers() {
        let mut w = TestWorld::new();
        w.push(sprite(AssetId(1)));
        w.push(label(AssetId(2), "hud"));
        let frame = w.build(0.0);
        assert!(frame.calls.iter().all(|c| c.layer == 0));
        // Sprites first, then text: the label's call follows the sprite's.
        assert_eq!(frame.calls.len(), 2);
    }

    // An open dropdown list draws on top of the menu and unclipped, so it
    // escapes the scroll band's scissor even though the rows behind it clip.
    #[test]
    fn open_dropdown_draws_unclipped_over_the_menu() {
        let mut w = TestWorld::new();
        let before = w.build(0.0).calls.len();

        w.resources.insert(OpenDropdown(Some(dropdown_view())));
        let frame = w.build(0.0);
        assert!(frame.calls.len() > before, "the list added draw calls");
        assert!(
            frame.calls.iter().all(|c| c.clip_rect.is_none()),
            "the list is never scissored"
        );
    }

    // The list is modal while it is up, so every call it adds sorts above the
    // screen it drops from -- at layer 0 it sank under the menu's opaque dim and
    // row cards, which drew it correctly but invisibly.
    #[test]
    fn open_dropdown_sorts_above_the_menus_row_cards() {
        let mut w = TestWorld::new();
        // The menu behind the list: an opaque full-canvas dim and a row card,
        // both owned by the active screen.
        w.push(backdrop(AssetId(1)));
        w.push(Sprite {
            screen: Some(SCREEN),
            ..sprite(AssetId(2))
        });
        w.push(TextLabel {
            screen: Some(SCREEN),
            ..label(AssetId(3), "Window Mode")
        });
        w.resources.insert(screen_stack(7));
        let menu = w.build(0.0).calls.len();

        w.resources.insert(OpenDropdown(Some(dropdown_view())));
        let frame = w.build(0.0);
        assert!(frame.calls.len() > menu, "the list added draw calls");
        // The menu's own calls keep their screen layer and the list's tail sits
        // above every one of them.
        let (below, list) = frame.calls.split_at(menu);
        assert!(
            below.iter().all(|c| c.layer == 7),
            "{:?}",
            below.iter().map(|c| c.layer).collect::<Vec<_>>()
        );
        assert!(
            list.iter().all(|c| c.layer == DROPDOWN_LAYER),
            "{:?}",
            list.iter().map(|c| c.layer).collect::<Vec<_>>()
        );
    }

    // The list outranks the editor's HUD overrides too: its panels sit in a
    // reserved band above every screen, and a list dropped over one still has
    // to draw on top of it.
    #[test]
    fn open_dropdown_sorts_above_the_editor_layer_band() {
        let mut w = TestWorld::new();
        w.push(sprite(AssetId(2)));
        w.resources
            .insert(HudLayers(std::collections::BTreeMap::from([(
                AssetId(2),
                3,
            )])));
        let panel = w.build(0.0).calls.len();

        w.resources.insert(OpenDropdown(Some(dropdown_view())));
        let frame = w.build(0.0);
        let (below, list) = frame.calls.split_at(panel);
        assert!(below.iter().all(|c| c.layer == HUD_OVERRIDE_LAYER_BASE + 3));
        assert!(list.iter().all(|c| c.layer == DROPDOWN_LAYER));
    }

    // A closed dropdown synthesises nothing.
    #[test]
    fn closed_dropdown_builds_no_list() {
        let mut w = TestWorld::new();
        w.resources.insert(OpenDropdown(None));
        assert!(w.build(0.0).calls.is_empty());
    }

    // A field's synthesised box / text / caret carry no asset id of their own, so
    // they take the field's layer rather than sinking to the default -- otherwise
    // a focused panel's fields would drop below it.
    #[test]
    fn text_input_calls_take_the_fields_own_layer() {
        let mut w = TestWorld::new();
        w.push(text_input(AssetId(4)));
        w.push(sprite(AssetId(1)));
        w.resources
            .insert(HudLayers(std::collections::BTreeMap::from([(
                AssetId(4),
                2,
            )])));
        let frame = w.build(0.0);
        let field_layer = HUD_OVERRIDE_LAYER_BASE + 2;
        assert!(
            frame.calls.iter().any(|c| c.layer == field_layer),
            "the field's calls lift with it"
        );
        assert!(
            frame
                .calls
                .iter()
                .all(|c| c.layer == 0 || c.layer == field_layer),
            "nothing else moved"
        );
    }

    // The caret pulses: it draws on the first half of each blink period and is
    // gone on the second, so a focused field's caret does not sit solid.
    #[test]
    fn the_caret_draws_only_on_the_visible_half_of_the_blink() {
        let mut w = TestWorld::new();
        w.push(TextInput {
            focused: true,
            ..text_input(AssetId(4))
        });
        let visible = w.build(0.0).calls.len();
        let dark = w.build(0.6).calls.len();
        assert_eq!(visible, dark + 1, "the caret is the one call that drops");
        // The period wraps, so the next cycle's first half draws it again.
        assert_eq!(w.build(1.06).calls.len(), visible);
    }

    // A hidden field builds nothing at all.
    #[test]
    fn hidden_text_inputs_build_no_overlay() {
        let mut w = TestWorld::new();
        w.push(TextInput {
            visible: false,
            focused: true,
            ..text_input(AssetId(4))
        });
        assert!(w.build(0.0).calls.is_empty());
    }

    // The in-engine arrow draws for a visible, opaque follow_cursor sprite, and
    // is drawn last so it sits over everything.
    #[test]
    fn an_opaque_follow_cursor_sprite_draws_the_ui_arrow() {
        let mut w = TestWorld::new();
        w.push(Sprite {
            follow_cursor: true,
            ..sprite(AssetId(1))
        });
        let frame = w.build(0.0);
        assert!(frame.want_ui_cursor);
        assert!(!frame.calls.is_empty(), "the arrow was shaped");
    }

    // The arrow outranks every layered element: an active screen, the editor and
    // an open dropdown all lift their content above 0, and a layer-0 cursor
    // would sort under the opaque menu backdrop it points at.
    #[test]
    fn the_ui_arrow_sorts_above_screen_editor_and_dropdown_layers() {
        let mut w = TestWorld::new();
        w.push(backdrop(AssetId(1)));
        w.push(sprite(AssetId(2)));
        w.push(Sprite {
            follow_cursor: true,
            ..sprite(AssetId(3))
        });
        w.resources.insert(screen_stack(7));
        w.resources.insert(OpenDropdown(Some(dropdown_view())));
        w.resources
            .insert(HudLayers(std::collections::BTreeMap::from([(
                AssetId(2),
                3,
            )])));

        let frame = w.build(0.0);
        let layers: Vec<i32> = frame.calls.iter().map(|c| c.layer).collect();
        let cursor = *layers.last().expect("the arrow was shaped");
        assert!(
            layers[..layers.len() - 1].iter().all(|l| *l < cursor),
            "{layers:?}"
        );
    }

    // A transparent or hidden cursor sprite is not a menu cursor, so the system
    // cursor stays in charge.
    #[test]
    fn a_transparent_or_hidden_cursor_sprite_draws_no_arrow() {
        let mut w = TestWorld::new();
        w.push(Sprite {
            follow_cursor: true,
            tint: [1.0, 1.0, 1.0, 0.0],
            ..sprite(AssetId(1))
        });
        w.push(Sprite {
            follow_cursor: true,
            visible: false,
            ..sprite(AssetId(2))
        });
        let frame = w.build(0.0);
        assert!(!frame.want_ui_cursor);
        assert!(frame.calls.is_empty());
    }

    // Once the real cursor leaves the window the arrow is hidden rather than
    // lingering at the edge.
    #[test]
    fn the_ui_arrow_hides_when_the_real_cursor_leaves_the_window() {
        let mut w = TestWorld::new();
        w.push(Sprite {
            follow_cursor: true,
            ..sprite(AssetId(1))
        });
        w.resources.insert(CursorState {
            pos: (10.0, 10.0),
            outside_window: true,
        });
        let frame = w.build(0.0);
        assert!(!frame.want_ui_cursor);
        assert!(frame.calls.is_empty(), "the arrow is not shaped either");
    }

    // The menu-active flag is exactly the screen stack's `pauses_world`: a
    // passthrough overlay shows without pausing.
    #[test]
    fn menu_active_follows_the_stacks_pauses_world() {
        let mut w = TestWorld::new();
        assert!(!w.build(0.0).menu_active, "no stack, no menu");

        w.resources.insert(screen_stack(0));
        assert!(w.build(0.0).menu_active);

        w.resources.insert(ScreenStack {
            pauses_world: false,
            ..screen_stack(0)
        });
        assert!(!w.build(0.0).menu_active);
    }

    // The world render is skipped only when a paused menu is backed by an opaque
    // full-canvas backdrop: nothing of the scene would be visible anyway.
    #[test]
    fn world_hidden_needs_a_paused_menu_behind_an_opaque_backdrop() {
        let mut w = TestWorld::new();
        w.push(backdrop(AssetId(1)));
        w.resources.insert(screen_stack(0));
        assert!(w.build(0.0).world_hidden);
    }

    // A translucent dim keeps the world faintly visible, so it does not qualify;
    // neither does a backdrop that does not span the canvas.
    #[test]
    fn a_translucent_or_partial_backdrop_keeps_the_world_rendering() {
        let mut w = TestWorld::new();
        w.push(Sprite {
            tint: [0.0, 0.0, 0.0, 0.5],
            ..backdrop(AssetId(1))
        });
        w.resources.insert(screen_stack(0));
        assert!(!w.build(0.0).world_hidden);

        let mut w = TestWorld::new();
        w.push(Sprite {
            width: REF_W / 2.0,
            ..backdrop(AssetId(1))
        });
        w.resources.insert(screen_stack(0));
        assert!(!w.build(0.0).world_hidden);
    }

    // An opaque backdrop with no menu pausing behind it is just scene art: the
    // world still renders.
    #[test]
    fn an_opaque_backdrop_without_a_paused_menu_keeps_the_world_rendering() {
        let mut w = TestWorld::new();
        w.push(backdrop(AssetId(1)));
        assert!(!w.build(0.0).world_hidden);
    }
}