Skip to main content

concinnity_core/ecs/
frame.rs

1// src/ecs/frame.rs
2//
3// Per-frame state handed to every system, carried as one field on
4// `PipelineContext` so later frame-scoped facilities arrive without touching
5// every system and every context construction again.
6
7use alloc::vec::Vec;
8
9use concinnity_memory::{Arena, ArenaVec};
10
11/// Frame-scoped facilities a system may use for the duration of its `step`.
12///
13/// The scratch arena is reset by the frame loop, so nothing allocated from it
14/// may outlive the `step` that allocated it. That is enforced rather than
15/// documented: `reset` takes `&mut Arena`, so the borrow checker will not let
16/// the loop reclaim the arena while any allocation from it is still live.
17///
18/// Take the arena out before using the rest of the context:
19///
20/// ```ignore
21/// let scratch = ctx.frame.scratch;   // an &Arena that outlives this borrow
22/// let mut ids = scratch.vec::<Entity>(count)?;
23/// // `ctx` is still free to be used mutably here
24/// ```
25///
26/// The copy matters. `scratch` is a shared reference with the context's own
27/// lifetime, so copying it out detaches it from the borrow of `ctx` and leaves
28/// the context usable. Reaching through `ctx.frame.scratch` at each call site
29/// instead would hold `ctx` borrowed for as long as the allocation lives.
30#[derive(Clone, Copy)]
31pub struct FrameContext<'a> {
32    /// Bump scratch for temporaries that do not outlive this frame. Returns
33    /// `None` when full, which is the caller's cue to fall back to the heap;
34    /// the frame loop reports that it happened rather than absorbing it.
35    pub scratch: &'a Arena,
36}
37
38impl<'a> FrameContext<'a> {
39    /// A context over one frame's scratch arena.
40    pub fn new(scratch: &'a Arena) -> Self {
41        Self { scratch }
42    }
43
44    /// Gather `items` into frame scratch, falling back to the heap if the
45    /// reserve is exhausted.
46    ///
47    /// This is the shape most frame temporaries take: a system reads something
48    /// out of the context, needs the context mutably to act on it, and so has
49    /// to copy the values out first to end the borrow. That copy is what the
50    /// arena is for.
51    /// Reserves from the iterator's upper size bound, so anything that knows
52    /// how much it can yield qualifies -- not just `ExactSizeIterator`. An
53    /// unbounded iterator goes to the heap rather than guessing a reservation.
54    pub fn collect<T, I>(&self, items: I) -> FrameVec<'a, T>
55    where
56        T: Copy,
57        I: IntoIterator<Item = T>,
58    {
59        let items = items.into_iter();
60        let reservation = items
61            .size_hint()
62            .1
63            .and_then(|upper| self.scratch.vec::<T>(upper));
64        match reservation {
65            Some(mut out) => {
66                out.extend(items);
67                FrameVec::Scratch(out)
68            }
69            None => FrameVec::Heap(items.collect()),
70        }
71    }
72
73    /// `len` copies of `value` in frame scratch, falling back to the heap if
74    /// the reserve is exhausted.
75    ///
76    /// The mutable counterpart to `collect`: a working frame a system fills in
77    /// as it runs, rather than a gathered set it reads back.
78    pub fn filled<T: Copy>(&self, len: usize, value: T) -> FrameVec<'a, T> {
79        match self.scratch.vec::<T>(len) {
80            Some(mut out) => {
81                out.extend(core::iter::repeat_n(value, len));
82                FrameVec::Scratch(out)
83            }
84            None => FrameVec::Heap(alloc::vec![value; len]),
85        }
86    }
87
88    /// An empty frame temporary reserving room for `capacity` pushes, falling
89    /// back to the heap if the reserve is exhausted.
90    ///
91    /// For the gathers `collect` cannot express: values found by a loop that
92    /// also mutates what it walks, so no iterator exists to hand over. Reserve
93    /// the loop's upper bound; a push past it moves the values to the heap
94    /// (see [`FrameVec::push`]) rather than failing.
95    pub fn vec<T: Copy>(&self, capacity: usize) -> FrameVec<'a, T> {
96        match self.scratch.vec::<T>(capacity) {
97            Some(out) => FrameVec::Scratch(out),
98            None => FrameVec::Heap(Vec::new()),
99        }
100    }
101}
102
103/// A frame temporary: in the scratch arena when it fit, on the heap when it did
104/// not. Reads as `&[T]` either way, so a caller never branches on which it got.
105///
106/// The heap arm is a correctness fallback, not a failure. The arena counts the
107/// decline and the frame loop reports it, so an undersized reserve surfaces
108/// instead of quietly costing allocations again.
109pub enum FrameVec<'a, T: Copy> {
110    /// Gathered into the frame scratch arena.
111    Scratch(ArenaVec<'a, T>),
112    /// Gathered on the heap, after the arena declined.
113    Heap(Vec<T>),
114}
115
116impl<T: Copy> FrameVec<'_, T> {
117    /// Append `value`. A scratch reservation is fixed, so a push past it moves
118    /// the gathered values to the heap and continues there: the caller sized
119    /// the reservation from an upper bound, and an outgrown bound is a
120    /// correctness fallback exactly like an exhausted reserve.
121    pub fn push(&mut self, value: T) {
122        match self {
123            FrameVec::Scratch(v) => {
124                if !v.push(value) {
125                    let mut heap = Vec::with_capacity(v.len() + 1);
126                    heap.extend_from_slice(v);
127                    heap.push(value);
128                    *self = FrameVec::Heap(heap);
129                }
130            }
131            FrameVec::Heap(v) => v.push(value),
132        }
133    }
134}
135
136impl<T: Copy> core::ops::Deref for FrameVec<'_, T> {
137    type Target = [T];
138
139    fn deref(&self) -> &[T] {
140        match self {
141            FrameVec::Scratch(v) => v,
142            FrameVec::Heap(v) => v,
143        }
144    }
145}
146
147impl<T: Copy> core::ops::DerefMut for FrameVec<'_, T> {
148    fn deref_mut(&mut self) -> &mut [T] {
149        match self {
150            FrameVec::Scratch(v) => v,
151            FrameVec::Heap(v) => v,
152        }
153    }
154}
155
156impl<'v, T: Copy> IntoIterator for &'v FrameVec<'_, T> {
157    type Item = &'v T;
158    type IntoIter = core::slice::Iter<'v, T>;
159
160    fn into_iter(self) -> Self::IntoIter {
161        self.iter()
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn a_gather_that_fits_lands_in_scratch() {
171        let arena = Arena::with_capacity(4096);
172        let frame = FrameContext::new(&arena);
173
174        let out = frame.collect([1u32, 2, 3]);
175        assert!(matches!(out, FrameVec::Scratch(_)));
176        assert_eq!(&*out, &[1, 2, 3]);
177        assert_eq!(arena.overflows(), 0);
178        assert!(arena.used() > 0, "it came out of the reserve");
179    }
180
181    // Running out is a fallback, not a failure: the values still arrive, and
182    // the decline is recorded so the frame loop can report the reserve is small.
183    #[test]
184    fn a_gather_too_large_falls_back_to_the_heap_and_is_recorded() {
185        let arena = Arena::with_capacity(8);
186        let frame = FrameContext::new(&arena);
187
188        let out = frame.collect([1u64, 2, 3, 4]);
189        assert!(matches!(out, FrameVec::Heap(_)));
190        assert_eq!(&*out, &[1, 2, 3, 4], "the fallback holds the same values");
191        assert_eq!(arena.overflows(), 1);
192    }
193
194    // Callers read a slice and never branch on where it came from.
195    #[test]
196    fn both_arms_read_the_same_way() {
197        let roomy = Arena::with_capacity(4096);
198        let tight = Arena::with_capacity(0);
199        let items = [7u16, 8, 9];
200
201        let from_scratch = FrameContext::new(&roomy).collect(items);
202        let from_heap = FrameContext::new(&tight).collect(items);
203
204        assert_eq!(&*from_scratch, &*from_heap);
205        assert_eq!(from_scratch.len(), 3);
206        assert_eq!(from_heap.iter().copied().sum::<u16>(), 24);
207        for (a, b) in (&from_scratch).into_iter().zip(&from_heap) {
208            assert_eq!(a, b);
209        }
210    }
211
212    #[test]
213    fn a_filled_frame_is_writable_in_place() {
214        let arena = Arena::with_capacity(4096);
215        let mut frame = FrameContext::new(&arena).filled(4, None::<u32>);
216        assert!(matches!(frame, FrameVec::Scratch(_)));
217        assert_eq!(&*frame, &[None, None, None, None]);
218
219        frame[2] = Some(9);
220        assert_eq!(&*frame, &[None, None, Some(9), None]);
221    }
222
223    // The heap arm has to be writable the same way, or a system that overflowed
224    // the reserve would silently stop recording.
225    #[test]
226    fn a_filled_frame_that_overflowed_is_still_writable() {
227        let arena = Arena::with_capacity(0);
228        let mut frame = FrameContext::new(&arena).filled(3, 0u32);
229        assert!(matches!(frame, FrameVec::Heap(_)));
230        frame[1] = 5;
231        assert_eq!(&*frame, &[0, 5, 0]);
232        assert_eq!(arena.overflows(), 1);
233    }
234
235    #[test]
236    fn a_reserved_frame_takes_pushes_in_scratch() {
237        let arena = Arena::with_capacity(4096);
238        let mut out = FrameContext::new(&arena).vec::<u32>(3);
239        out.push(1);
240        out.push(2);
241        assert!(matches!(out, FrameVec::Scratch(_)));
242        assert_eq!(&*out, &[1, 2]);
243        assert_eq!(arena.overflows(), 0);
244    }
245
246    // Outgrowing the reservation is the same fallback as outgrowing the
247    // reserve: the values move to the heap and every one of them survives.
248    #[test]
249    fn a_push_past_the_reservation_moves_to_the_heap() {
250        let arena = Arena::with_capacity(4096);
251        let mut out = FrameContext::new(&arena).vec::<u32>(2);
252        out.push(1);
253        out.push(2);
254        out.push(3);
255        assert!(matches!(out, FrameVec::Heap(_)));
256        assert_eq!(&*out, &[1, 2, 3]);
257    }
258
259    #[test]
260    fn a_reservation_the_reserve_cannot_hold_starts_on_the_heap() {
261        let arena = Arena::with_capacity(8);
262        let mut out = FrameContext::new(&arena).vec::<u64>(64);
263        assert!(matches!(out, FrameVec::Heap(_)));
264        assert_eq!(arena.overflows(), 1, "the decline is recorded");
265        out.push(7);
266        assert_eq!(&*out, &[7]);
267    }
268
269    #[test]
270    fn an_empty_gather_costs_nothing() {
271        let arena = Arena::with_capacity(4096);
272        let out = FrameContext::new(&arena).collect([0u8; 0]);
273        assert!(out.is_empty());
274        assert_eq!(arena.overflows(), 0);
275    }
276
277    // The context is Copy so a system can take it out and keep using the
278    // pipeline context mutably; both copies must name the same arena.
279    #[test]
280    fn a_copied_context_shares_one_reserve() {
281        let arena = Arena::with_capacity(4096);
282        let frame = FrameContext::new(&arena);
283        let copy = frame;
284
285        let _a = frame.collect([1u32; 4]);
286        let used = arena.used();
287        let _b = copy.collect([2u32; 4]);
288        assert!(arena.used() > used, "the copy drew from the same reserve");
289    }
290}