Skip to main content

teksilo_canvas/live_image/
pass.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The live pass: what one window's renderer does with the live pictures of
5//! a frame before it draws them. One engine, generic over where textures
6//! live: the wgpu renderer runs it on the GPU,
7//! [`LiveImageMirror`](super::testing::LiveImageMirror) on the CPU, so the
8//! headless tests exercise the renderer's own decisions.
9//!
10//! The steps are on [`LivePass`].
11
12use std::collections::HashMap;
13use std::sync::Weak;
14use std::time::{Duration, Instant};
15
16use super::consumer::RenderRecord;
17use super::internal::{LiveImageRead, ReadAttempt, UploadPlan};
18use super::siblings::{DeviceTextures, Held, Siblings};
19use super::source::Shared;
20use super::{
21    LiveImageConsumer, LiveImageId, LiveImageQuad, LiveImageSource, PixelRect, ScalingFilter,
22};
23use crate::wake::WakeKind;
24
25/// The largest staging one write may take, 8 MiB: a failed staging
26/// allocation loses the device, and this keeps every one far from any limit.
27pub const BAND_BYTES: u64 = 8 << 20;
28
29/// The staging a presented frame spends on whole-frame uploads before the
30/// rest of a large one waits for the next frame.
31pub const STAGING_BUDGET: u64 = 128 << 20;
32
33/// The parked-texture pool: textures up to [`PARK_MAX_TEXTURE`] each, up to
34/// this many bytes in all.
35pub const PARK_BUDGET: u64 = 16 << 20;
36
37/// The largest texture the pool parks.
38pub const PARK_MAX_TEXTURE: u64 = 4 << 20;
39
40/// How long a refused texture creation is not retried, unless the size
41/// changes.
42const ALLOC_BACKOFF: Duration = Duration::from_secs(1);
43
44/// How long a capture waits for a producer's transaction.
45const CAPTURE_WAIT: Duration = Duration::from_secs(1);
46
47/// A whole-frame fill restarted this many times (its damage grew past a
48/// plan) completes in one frame the next time.
49const MAX_RESTARTS: u8 = 2;
50
51/// Where a live pass's textures live: GPU memory for the renderer, a
52/// `Vec<u8>` for the mirror.
53pub trait LiveTextureBackend {
54    type Texture;
55
56    /// What another pass on the same device keeps of a texture to copy it:
57    /// a handle for the GPU, the bytes for the mirror.
58    type Shared: Clone;
59
60    /// The largest side the device accepts.
61    fn max_dimension(&self) -> u32;
62
63    /// A texture of `width × height`, with a full chain of mip levels
64    /// above level 0 when `mipped`, or `Err` when the device has no memory
65    /// for it. Called outside every source lock.
66    fn create_texture(
67        &mut self,
68        width: u32,
69        height: u32,
70        mipped: bool,
71        label: &str,
72    ) -> Result<Self::Texture, TextureOutOfMemory>;
73
74    /// Bytes one row of `width` pixels takes in staging: the device's copy
75    /// pitch, which can be wider than the row.
76    fn staged_row_bytes(&self, width: u32) -> u64;
77
78    /// Copy `band` of the locked frame into the same place of `texture`: one
79    /// write, of at most [`BAND_BYTES`] of staging.
80    fn write(&mut self, texture: &mut Self::Texture, read: &LiveImageRead<'_>, band: PixelRect);
81
82    /// Ready `texture` to be drawn with `filter`; outside every lock.
83    /// `Trilinear` on a texture without mip levels samples like `Linear`.
84    fn prepare_filter(&mut self, texture: &mut Self::Texture, filter: ScalingFilter);
85
86    /// Rebuild the mip levels of a mipped `texture` from its level 0, each
87    /// from the one below with [`crate::resample::downsample_half`]'s box:
88    /// every texel when `region` is `None`, else the texels each level's
89    /// [`mip_footprint`](super::mips::mip_footprint) of `region` names.
90    /// `opaque`: the source's fourth byte is not alpha, so the average is
91    /// plain and every level above 0 is opaque. Outside every lock; level 0
92    /// holds what was written to it.
93    fn rebuild_mips(
94        &mut self,
95        texture: &mut Self::Texture,
96        region: Option<PixelRect>,
97        opaque: bool,
98    );
99
100    /// Bytes `texture` holds.
101    fn texture_bytes(&self, texture: &Self::Texture) -> u64;
102
103    /// `texture`, as another pass on the device copies it.
104    fn share(&self, texture: &Self::Texture) -> Self::Shared;
105
106    /// Copy level 0 of `from`, a texture of `into`'s size that another pass
107    /// on the same device holds, into level 0 of `into`. Outside every lock.
108    /// On the GPU, recorded to run before this render's draws and after the
109    /// writes that filled `from`, which the other pass's earlier submission
110    /// carried.
111    fn copy(&mut self, from: &Self::Shared, into: &mut Self::Texture);
112
113    /// Whether the device is lost: nothing written reaches the screen again.
114    fn device_lost(&self) -> bool {
115        false
116    }
117
118    /// A texture was dropped; the device frees it at its next maintenance.
119    fn released(&mut self) {}
120}
121
122/// A texture creation the device refused for want of memory.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub struct TextureOutOfMemory;
125
126/// Why a live pass runs.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum LivePassMode {
129    /// A frame the window presents.
130    Present,
131    /// A screenshot: the latest commit whatever the lock or a pause, every
132    /// band of a large frame before the draw, counted as a capture.
133    Capture,
134}
135
136/// What the draw does with one quad, and what a screenshot reports of it.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
138#[non_exhaustive]
139pub struct QuadDecision {
140    /// Draw the source's current texture: it has the quad's painted size.
141    pub draw: bool,
142    /// The quad shows an older picture or only its background: the lock was
143    /// busy, the size changed after layout, or nothing is uploaded yet.
144    pub deferred: bool,
145    /// The generation the drawn texture holds; 0 with none.
146    pub generation: u64,
147}
148
149/// What one renderer holds and did for live pictures. Monotonic counters,
150/// except the four sizes.
151#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
152#[non_exhaustive]
153pub struct LiveTextureStats {
154    /// Textures held for the sources the last render drew, staged ones
155    /// included.
156    pub textures: usize,
157    /// Their bytes, every level included.
158    pub bytes: u64,
159    /// Parked textures: small ones kept for sources the last render did not
160    /// draw.
161    pub textures_parked: usize,
162    pub bytes_parked: u64,
163    pub uploads_full: u64,
164    pub uploads_partial: u64,
165    pub bytes_uploaded: u64,
166    /// Writes; a banded upload counts one per band.
167    pub upload_calls: u64,
168    /// Bands of whole-frame uploads spread over several frames.
169    pub progressive_bands: u64,
170    /// Textures brought up to their source's latest commit by a copy, on the
171    /// device, of the texture another window there already holds of it,
172    /// rather than by an upload from the source.
173    pub sibling_copies: u64,
174    /// Texture creations the device refused for want of memory.
175    pub alloc_failures: u64,
176    /// Frames that found a producer holding a source's lock.
177    pub contended: u64,
178    /// Locks taken with a bounded wait.
179    pub blocking_waits: u64,
180    /// Frames whose locked buffer had another size than the one laid out.
181    pub stale_deferrals: u64,
182    /// Mip chains rebuilt, wholly or in part: at most one per source and
183    /// render, however many commits the upload folded in.
184    pub mip_updates: u64,
185    pub device_lost: bool,
186}
187
188struct Tex<T> {
189    texture: T,
190    size: (u32, u32),
191    /// The generation the texture holds.
192    generation: u64,
193    /// Filters the backend has readied it for, one bit each.
194    filters: u8,
195    /// It has mip levels above level 0. Once it has, it keeps them for its
196    /// life: a source whose `Trilinear` quads come and go is not uploaded
197    /// again for it.
198    mipped: bool,
199    /// What of level 0 changed since its mip levels were last built: the
200    /// next render that draws the source through `Trilinear` rebuilds that
201    /// much.
202    stale: Option<Stale>,
203}
204
205impl<T> Tex<T> {
206    fn new(texture: T, size: (u32, u32), mipped: bool) -> Self {
207        Self {
208            texture,
209            size,
210            generation: 0,
211            filters: 0,
212            mipped,
213            stale: mipped.then_some(Stale::Whole),
214        }
215    }
216
217    /// Record that `changed` of level 0 was written.
218    fn changed(&mut self, changed: Stale) {
219        if self.mipped {
220            self.stale = Some(match self.stale {
221                Some(before) => before.with(changed),
222                None => changed,
223            });
224        }
225    }
226
227    /// Whether it can be drawn for a source painted at `size`, by quads of
228    /// which some sample through `Trilinear` when `mips`.
229    fn fits(&self, size: (u32, u32), mips: bool) -> bool {
230        self.size == size && (self.mipped || !mips)
231    }
232}
233
234/// The part of a texture's level 0 changed since its mip levels were built.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236enum Stale {
237    Whole,
238    /// Within this rect.
239    Within(PixelRect),
240}
241
242impl Stale {
243    fn with(self, other: Stale) -> Stale {
244        match (self, other) {
245            (Stale::Within(a), Stale::Within(b)) => Stale::Within(a.union(&b)),
246            _ => Stale::Whole,
247        }
248    }
249}
250
251/// A whole-frame fill spread over several frames.
252#[derive(Clone, Copy)]
253struct Fill {
254    /// The generation the first band was copied at.
255    first: u64,
256    /// The first row not copied yet.
257    next_row: u32,
258    restarts: u8,
259}
260
261struct Staged<T> {
262    tex: Tex<T>,
263    fill: Option<Fill>,
264}
265
266struct Entry<T> {
267    source: LiveImageSource,
268    current: Option<Tex<T>>,
269    staged: Option<Staged<T>>,
270    alloc_failed: Option<((u32, u32), Instant)>,
271    alloc_logged: Option<(u32, u32)>,
272    oversize_logged: Option<(u32, u32)>,
273    /// Busy frames in a row.
274    contended: u8,
275    /// A plan too large for one frame found no room for a second texture:
276    /// upload it in place, once.
277    in_place: bool,
278}
279
280struct Parked<T> {
281    source: Weak<Shared>,
282    id: LiveImageId,
283    tex: Tex<T>,
284    bytes: u64,
285}
286
287/// What one pass did, by source.
288#[doc(hidden)]
289#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
290pub struct PassCounts {
291    /// Sources whose textures went, freed or parked, because no quad drew
292    /// them.
293    pub pruned: u32,
294    /// Sources whose texture was kept because every quad of them was paused.
295    pub kept: u32,
296}
297
298/// How long the parts of the last prepare that hold a source's lock took,
299/// in real time: what a renderer keeping timing histograms reads after
300/// each render. The vectors keep their capacity from one prepare to the
301/// next.
302#[doc(hidden)]
303#[derive(Debug, Clone, Default, PartialEq, Eq)]
304pub struct PassTimings {
305    /// Each hold of a source's lock, in the order they were taken.
306    pub lock_holds: Vec<Duration>,
307    /// Each upload that completed, from the commit of the generation it
308    /// uploaded to the end of its writes.
309    pub commit_to_upload: Vec<Duration>,
310}
311
312/// End a hold of `read`'s lock begun at `locked`, and record it.
313fn end_hold(timings: &mut PassTimings, read: LiveImageRead<'_>, locked: Instant) {
314    drop(read);
315    timings.lock_holds.push(locked.elapsed());
316}
317
318/// Record the delay of an upload of `read`'s generation that just ended.
319fn record_upload(timings: &mut PassTimings, read: &LiveImageRead<'_>) {
320    if let Some(committed) = read.commit_instant(read.generation()) {
321        timings
322            .commit_to_upload
323            .push(Instant::now().saturating_duration_since(committed));
324    }
325}
326
327/// What processing one source decided.
328#[derive(Default)]
329struct Outcome {
330    uploaded: bool,
331    deferred: bool,
332    kept: bool,
333    /// The device is lost: record nothing, so counters stop.
334    lost: bool,
335}
336
337/// One window's live pass: what its renderer does with a frame's live
338/// pictures before it draws them.
339///
340/// For each source a frame draws, in order:
341///
342/// 1. Every quad marks its consumer observed and takes its pixel flag
343///    before anything reads the generation; a paused quad parks it instead,
344///    so commits stop waking a paused window.
345/// 2. The wanted size is the painted size; when two quads of the source
346///    were painted at different sizes (a widget attached across a resize),
347///    the one the source has now wins, else the larger.
348/// 3. A size the device cannot hold, a texture creation the device refused
349///    less than a second ago, and a lost device draw what can be drawn and
350///    upload nothing; each parks the pixel flags, so a broken source costs
351///    no redraw per commit.
352/// 4. Every quad of the source paused, a texture of the wanted size held,
353///    a presented frame: keep it, take no lock.
354/// 5. A staged texture of another shape than the wanted one is dropped,
355///    whether or not the current texture fits: a resize can leave one when
356///    its fill met a held lock and the size went back. A texture of the
357///    wanted size is then staged, outside the lock, when none is current or
358///    staged.
359/// 6. Nothing new (the generation is the texture's): no lock.
360/// 7. Another window on the same device holds the latest generation at the
361///    wanted size, and rendered on this thread: copy its texture into this
362///    one on the device, whole, without the lock. A staged texture is
363///    filled so when the current one does not fit, else dropped. The
364///    windows' last render publishes their current textures for this
365///    (see [`LivePass::join_device`]).
366/// 8. Otherwise lock: without waiting, or for a bounded time when a staged
367///    texture waits for its first upload, after two busy frames in a row,
368///    and always in a capture. A busy lock draws the held texture; the
369///    producer's unlock wakes the window. A buffer of another size than the
370///    one laid out draws the held texture; the size change woke layout.
371/// 9. Under the lock, the staged texture is filled whole and becomes current,
372///    or the current one gets the plan's rects. Nothing is created under the
373///    lock. A whole-frame upload larger than the staging budget fills a
374///    staged texture over several presented frames instead, waking the
375///    window for each, while the previous picture keeps drawing; the render
376///    that completes it writes the damage since its first band, under the
377///    same lock, then swaps. No frame ever shows half of two generations.
378/// 10. Every source no quad drew loses its textures; a small texture of a
379///     source that committed once moves to a pool of parked textures, so an
380///     image scrolled out and back is not uploaded again. Consumers that
381///     were in the last render and are not in this one are marked
382///     unobserved.
383pub struct LivePass<B: LiveTextureBackend> {
384    backend: B,
385    entries: HashMap<LiveImageId, Entry<B::Texture>>,
386    /// Most recently parked last.
387    parked: Vec<Parked<B::Texture>>,
388    /// The consumers of the last render.
389    previous: Vec<LiveImageConsumer>,
390    decisions: Vec<QuadDecision>,
391    counts: PassCounts,
392    timings: PassTimings,
393    stats: LiveTextureStats,
394    staging_budget: u64,
395    band_bytes: u64,
396    park_budget: u64,
397    refresh: Duration,
398    /// This pass's place among the passes of its device, when it joined one.
399    siblings: Option<Siblings<B::Shared>>,
400}
401
402impl<B: LiveTextureBackend> LivePass<B> {
403    pub fn new(backend: B) -> Self {
404        Self {
405            backend,
406            entries: HashMap::new(),
407            parked: Vec::new(),
408            previous: Vec::new(),
409            decisions: Vec::new(),
410            counts: PassCounts::default(),
411            timings: PassTimings::default(),
412            stats: LiveTextureStats::default(),
413            staging_budget: STAGING_BUDGET,
414            band_bytes: BAND_BYTES,
415            park_budget: PARK_BUDGET,
416            refresh: Duration::from_micros(16_667),
417            siblings: None,
418        }
419    }
420
421    /// Join the passes of a device: after each render, this pass publishes
422    /// its current textures to `textures`, and a texture of its own that
423    /// lacks the latest commit another pass there holds is copied from that
424    /// pass's, on the device, instead of uploaded from the source. Only
425    /// passes rendering on one thread copy from each other: a window renders
426    /// whole on its thread, from this pass to its submission, so no other
427    /// window writes the texture between a copy's recording and its
428    /// submission. Joining again leaves the device joined before.
429    pub fn join_device(&mut self, textures: &DeviceTextures<B::Shared>) {
430        self.siblings = Some(Siblings::join(textures));
431    }
432
433    /// The device this pass joined, if any.
434    pub fn device(&self) -> Option<&DeviceTextures<B::Shared>> {
435        self.siblings.as_ref().map(Siblings::table)
436    }
437
438    pub fn backend(&self) -> &B {
439        &self.backend
440    }
441
442    pub fn backend_mut(&mut self) -> &mut B {
443        &mut self.backend
444    }
445
446    /// The display's refresh interval: about how long a presented frame
447    /// waits for a producer before it draws the previous picture.
448    pub fn set_refresh_interval(&mut self, refresh: Duration) {
449        self.refresh = refresh;
450    }
451
452    /// The refresh interval last set: 60 Hz until one is.
453    pub fn refresh_interval(&self) -> Duration {
454        self.refresh
455    }
456
457    /// Test hook: the staging a presented frame spends before a large
458    /// whole-frame upload waits for the next frame.
459    #[doc(hidden)]
460    pub fn set_staging_budget(&mut self, bytes: u64) {
461        self.staging_budget = bytes.max(1);
462    }
463
464    /// Test hook: the largest staging one write takes.
465    #[doc(hidden)]
466    pub fn set_band_bytes(&mut self, bytes: u64) {
467        self.band_bytes = bytes.max(1);
468    }
469
470    /// Test hook: the parked pool's budget; 0 frees every texture a render
471    /// does not draw, at once.
472    #[doc(hidden)]
473    pub fn set_park_budget(&mut self, bytes: u64) {
474        self.park_budget = bytes;
475    }
476
477    /// The current texture for source `id`, to draw.
478    pub fn texture(&self, id: LiveImageId) -> Option<&B::Texture> {
479        self.entries
480            .get(&id)
481            .and_then(|e| e.current.as_ref())
482            .map(|t| &t.texture)
483    }
484
485    /// The last [`prepare`](Self::prepare)'s decisions, one per quad.
486    pub fn decisions(&self) -> &[QuadDecision] {
487        &self.decisions
488    }
489
490    /// What the last [`prepare`](Self::prepare) did, by source.
491    #[doc(hidden)]
492    pub fn last_counts(&self) -> PassCounts {
493        self.counts
494    }
495
496    /// How long the last [`prepare`](Self::prepare) held each lock, and
497    /// how long each of its uploads came after its commit.
498    #[doc(hidden)]
499    pub fn last_timings(&self) -> &PassTimings {
500        &self.timings
501    }
502
503    pub fn stats(&self) -> LiveTextureStats {
504        let mut stats = self.stats;
505        stats.textures = 0;
506        stats.bytes = 0;
507        for entry in self.entries.values() {
508            for tex in entry
509                .current
510                .iter()
511                .chain(entry.staged.iter().map(|s| &s.tex))
512            {
513                stats.textures += 1;
514                stats.bytes += self.backend.texture_bytes(&tex.texture);
515            }
516        }
517        stats.textures_parked = self.parked.len();
518        stats.bytes_parked = self.parked.iter().map(|p| p.bytes).sum();
519        stats.device_lost = self.backend.device_lost();
520        stats
521    }
522
523    /// Run the live pass for `quads`, the frame's live pictures in draw
524    /// order: upload what each window's textures lack, drop what no quad
525    /// draws, and decide what each quad draws.
526    pub fn prepare(&mut self, quads: &[LiveImageQuad], mode: LivePassMode) -> &[QuadDecision] {
527        self.prepare_at(quads, mode, Instant::now())
528    }
529
530    /// [`prepare`](Self::prepare) at a given instant, for tests of the
531    /// allocation backoff.
532    #[doc(hidden)]
533    pub fn prepare_at(
534        &mut self,
535        quads: &[LiveImageQuad],
536        mode: LivePassMode,
537        now: Instant,
538    ) -> &[QuadDecision] {
539        // 1. Flags first: taken before any generation is read.
540        for quad in quads {
541            quad.consumer.set_observed(true);
542            if quad.paused {
543                quad.consumer.park_pixels();
544            } else {
545                let _ = quad.consumer.take_pixels();
546            }
547        }
548        // The sources, in the order the frame first draws them.
549        let mut order: Vec<(LiveImageId, Vec<usize>)> = Vec::new();
550        for (i, quad) in quads.iter().enumerate() {
551            let id = quad.consumer.source().id();
552            match order.iter_mut().find(|(other, _)| *other == id) {
553                Some((_, indices)) => indices.push(i),
554                None => order.push((id, vec![i])),
555            }
556        }
557        self.decisions.clear();
558        self.decisions.resize(quads.len(), QuadDecision::default());
559        self.counts = PassCounts::default();
560        self.timings.lock_holds.clear();
561        self.timings.commit_to_upload.clear();
562        let mut staging_left = self.staging_budget;
563        let mut drawn: Vec<LiveImageId> = Vec::with_capacity(order.len());
564        for (id, indices) in &order {
565            let group: Vec<&LiveImageQuad> = indices.iter().map(|&i| &quads[i]).collect();
566            let Some(wanted) = wanted_size(&group) else {
567                // Nothing to show for any of them: the source's textures go.
568                self.record(&group, &Outcome::default(), None, mode);
569                let paused = group.iter().all(|q| q.paused);
570                for quad in &group {
571                    quad.consumer.set_paused(paused);
572                }
573                for &i in indices {
574                    self.decisions[i] = QuadDecision {
575                        draw: false,
576                        deferred: false,
577                        generation: 0,
578                    };
579                }
580                continue;
581            };
582            drawn.push(*id);
583            let paused = group.iter().all(|q| q.paused);
584            let mips = group
585                .iter()
586                .any(|q| q.filter == ScalingFilter::Trilinear && q.painted.is_some());
587            let outcome = self.process(
588                *id,
589                &group,
590                (wanted, mips),
591                paused,
592                mode,
593                now,
594                &mut staging_left,
595            );
596            self.counts.kept += u32::from(outcome.kept);
597            let current = self
598                .entries
599                .get(id)
600                .and_then(|e| e.current.as_ref())
601                .map(|t| (t.size, t.generation));
602            for (&i, quad) in indices.iter().zip(&group) {
603                let draw = current.is_some_and(|(size, _)| Some(size) == quad.painted);
604                self.decisions[i] = QuadDecision {
605                    draw,
606                    deferred: quad.painted.is_some() && (outcome.deferred || !draw),
607                    generation: current.map_or(0, |(_, g)| g),
608                };
609            }
610            // Bind what each drawing quad needs, and bring the mip chain up
611            // to date for a quad that samples it, outside every lock.
612            if let Some(entry) = self.entries.get_mut(id)
613                && let Some(tex) = entry.current.as_mut()
614            {
615                let samples_chain = indices
616                    .iter()
617                    .zip(&group)
618                    .any(|(&i, q)| q.filter == ScalingFilter::Trilinear && self.decisions[i].draw);
619                if samples_chain
620                    && !outcome.lost
621                    && tex.mipped
622                    && let Some(stale) = tex.stale.take()
623                {
624                    let region = match stale {
625                        Stale::Whole => None,
626                        Stale::Within(rect) => Some(rect),
627                    };
628                    let opaque = entry.source.format().is_opaque();
629                    self.backend.rebuild_mips(&mut tex.texture, region, opaque);
630                    self.stats.mip_updates += 1;
631                }
632                for (&i, quad) in indices.iter().zip(&group) {
633                    let bit = filter_bit(quad.filter);
634                    if self.decisions[i].draw && tex.filters & bit == 0 {
635                        self.backend.prepare_filter(&mut tex.texture, quad.filter);
636                        tex.filters |= bit;
637                    }
638                }
639            }
640            let window_generation = current.map(|(_, g)| g);
641            self.record(&group, &outcome, window_generation, mode);
642            for quad in &group {
643                quad.consumer.set_paused(paused && !outcome.lost);
644            }
645        }
646        // 10. What no quad drew goes, or parks.
647        let gone: Vec<LiveImageId> = self
648            .entries
649            .keys()
650            .filter(|id| !drawn.contains(id))
651            .copied()
652            .collect();
653        for id in gone {
654            if let Some(entry) = self.entries.remove(&id) {
655                self.counts.pruned += 1;
656                self.retire(id, entry);
657            }
658        }
659        self.trim_parked();
660        for consumer in &self.previous {
661            if !quads.iter().any(|q| q.consumer.ptr_eq(consumer)) {
662                consumer.set_observed(false);
663                consumer.set_paused(false);
664            }
665        }
666        self.previous = quads.iter().map(|q| q.consumer.clone()).collect();
667        self.publish();
668        &self.decisions
669    }
670
671    /// Tell the other passes of the device what this one's current textures
672    /// hold, for step 7.
673    fn publish(&mut self) {
674        if self.siblings.is_none() {
675            return;
676        }
677        let held = self
678            .entries
679            .iter()
680            .filter_map(|(&id, entry)| {
681                let tex = entry.current.as_ref()?;
682                Some(Held {
683                    id,
684                    size: tex.size,
685                    generation: tex.generation,
686                    texture: self.backend.share(&tex.texture),
687                })
688            })
689            .collect();
690        if let Some(siblings) = self.siblings.as_mut() {
691            siblings.publish(held);
692        }
693    }
694
695    /// Steps 3 to 9 for one source.
696    #[allow(clippy::too_many_arguments)]
697    fn process(
698        &mut self,
699        id: LiveImageId,
700        group: &[&LiveImageQuad],
701        (wanted, mips): ((u32, u32), bool),
702        paused: bool,
703        mode: LivePassMode,
704        now: Instant,
705        staging_left: &mut u64,
706    ) -> Outcome {
707        let source = group[0].consumer.source().clone();
708        if !self.entries.contains_key(&id) {
709            let restored = self.unpark(id);
710            self.entries.insert(
711                id,
712                Entry {
713                    source: source.clone(),
714                    current: restored,
715                    staged: None,
716                    alloc_failed: None,
717                    alloc_logged: None,
718                    oversize_logged: None,
719                    contended: 0,
720                    in_place: false,
721                },
722            );
723        }
724        let max = self.backend.max_dimension();
725        if wanted.0 > max || wanted.1 > max {
726            let entry = self.entries.get_mut(&id).expect("inserted above");
727            if entry.oversize_logged != Some(wanted) {
728                entry.oversize_logged = Some(wanted);
729                eprintln!(
730                    "teksilo: {source:?} is {}x{}, larger than this device's largest texture side \
731                     of {max}: it draws its background only",
732                    wanted.0, wanted.1
733                );
734            }
735            let dropped = drop_mismatched(entry, wanted);
736            self.release(dropped);
737            park_all(group);
738            return Outcome {
739                deferred: true,
740                ..Outcome::default()
741            };
742        }
743        if self.backend.device_lost() {
744            park_all(group);
745            return Outcome {
746                lost: true,
747                deferred: true,
748                ..Outcome::default()
749            };
750        }
751        let entry = self.entries.get_mut(&id).expect("inserted above");
752        let current_fits = entry.current.as_ref().is_some_and(|t| t.fits(wanted, mips));
753        // A texture staged at this size keeps mip levels the current one
754        // has: a source does not lose its chain to a restage.
755        let mipped = mips
756            || entry
757                .current
758                .as_ref()
759                .is_some_and(|t| t.size == wanted && t.mipped);
760        if paused && mode == LivePassMode::Present && current_fits {
761            return Outcome {
762                kept: true,
763                ..Outcome::default()
764            };
765        }
766        if let Some((shape, at)) = entry.alloc_failed {
767            if shape == wanted && now.saturating_duration_since(at) < ALLOC_BACKOFF {
768                park_all(group);
769                return Outcome {
770                    deferred: true,
771                    ..Outcome::default()
772                };
773            }
774            entry.alloc_failed = None;
775        }
776        // A staged texture of another shape than the one wanted now is never
777        // filled, whether or not the current texture fits: a resize whose
778        // staging met a held lock, then a size back to the current texture's,
779        // leaves one of the size in between, and filling it would write the
780        // frame past its bounds. A staged texture that does fit is a fill in
781        // progress, beside a current texture of the same size.
782        if entry
783            .staged
784            .as_ref()
785            .is_some_and(|s| !s.tex.fits(wanted, mips))
786        {
787            let stale_staged = entry.staged.take();
788            self.backend.released();
789            drop(stale_staged);
790        }
791        let staged_fits = entry.staged.is_some();
792        if !current_fits && !staged_fits {
793            match self
794                .backend
795                .create_texture(wanted.0, wanted.1, mipped, &texture_label(&source))
796            {
797                Ok(texture) => {
798                    let entry = self.entries.get_mut(&id).expect("inserted above");
799                    entry.staged = Some(Staged {
800                        tex: Tex::new(texture, wanted, mipped),
801                        fill: None,
802                    });
803                }
804                Err(TextureOutOfMemory) => {
805                    self.stats.alloc_failures += 1;
806                    let entry = self.entries.get_mut(&id).expect("inserted above");
807                    entry.alloc_failed = Some((wanted, now));
808                    if entry.alloc_logged != Some(wanted) {
809                        entry.alloc_logged = Some(wanted);
810                        eprintln!(
811                            "teksilo: the device has no memory for a {}x{} texture of {source:?}: \
812                             it is retried in a second or at another size",
813                            wanted.0, wanted.1
814                        );
815                    }
816                    park_all(group);
817                    return Outcome {
818                        deferred: true,
819                        ..Outcome::default()
820                    };
821                }
822            }
823        }
824        let entry = self.entries.get_mut(&id).expect("inserted above");
825        let has_staged = entry.staged.is_some();
826        let latest = source.generation();
827        if !has_staged
828            && entry
829                .current
830                .as_ref()
831                .is_some_and(|t| t.generation == latest)
832        {
833            return Outcome::default();
834        }
835        // 7. Another window on this device holds the latest commit at this
836        // size: copy its texture, whole, on the device.
837        if let Some(from) = self
838            .siblings
839            .as_ref()
840            .and_then(|s| s.find(id, wanted, latest))
841        {
842            let entry = self.entries.get_mut(&id).expect("inserted above");
843            if current_fits {
844                if entry.staged.take().is_some() {
845                    self.backend.released();
846                }
847            } else if let Some(staged) = entry.staged.take()
848                && entry.current.replace(staged.tex).is_some()
849            {
850                self.backend.released();
851            }
852            let current = entry
853                .current
854                .as_mut()
855                .expect("a texture of the wanted size: created above");
856            self.backend.copy(&from, &mut current.texture);
857            current.generation = latest;
858            current.changed(Stale::Whole);
859            self.stats.sibling_copies += 1;
860            return Outcome {
861                uploaded: true,
862                ..Outcome::default()
863            };
864        }
865        let frame_bytes = u64::from(wanted.0) * u64::from(wanted.1) * 4;
866        let blocking = mode == LivePassMode::Capture || has_staged || entry.contended >= 2;
867        let consumer = &group[0].consumer;
868        let attempt = if blocking {
869            self.stats.blocking_waits += 1;
870            let wait = match mode {
871                LivePassMode::Capture => CAPTURE_WAIT,
872                // About one refresh, plus the time a producer takes to copy
873                // a frame of this size at a conservative 2 GB/s.
874                LivePassMode::Present => {
875                    self.refresh + Duration::from_nanos(frame_bytes.saturating_div(2))
876                }
877            };
878            consumer.read_for(wait)
879        } else {
880            consumer.try_read()
881        };
882        let read = match attempt {
883            ReadAttempt::Locked(read) => read,
884            ReadAttempt::Busy => {
885                self.stats.contended += 1;
886                let entry = self.entries.get_mut(&id).expect("inserted above");
887                entry.contended = entry.contended.saturating_add(1);
888                return Outcome {
889                    deferred: true,
890                    ..Outcome::default()
891                };
892            }
893        };
894        let locked = Instant::now();
895        let entry = self.entries.get_mut(&id).expect("inserted above");
896        entry.contended = 0;
897        if read.size() != Some(wanted) {
898            self.stats.stale_deferrals += 1;
899            end_hold(&mut self.timings, read, locked);
900            return Outcome {
901                deferred: true,
902                ..Outcome::default()
903            };
904        }
905        let progressive_allowed = mode == LivePassMode::Present;
906        if let Some(mut staged) = entry.staged.take() {
907            let outcome = fill_staged(
908                &mut self.backend,
909                &mut self.stats,
910                &read,
911                &mut staged,
912                wanted,
913                progressive_allowed,
914                staging_left,
915                self.band_bytes,
916            );
917            let entry = self.entries.get_mut(&id).expect("inserted above");
918            match outcome {
919                FillOutcome::Complete => {
920                    staged.tex.generation = read.generation();
921                    record_upload(&mut self.timings, &read);
922                    end_hold(&mut self.timings, read, locked);
923                    if entry.current.replace(staged.tex).is_some() {
924                        self.backend.released();
925                    }
926                    Outcome {
927                        uploaded: true,
928                        ..Outcome::default()
929                    }
930                }
931                FillOutcome::Partial => {
932                    // The previous picture keeps drawing, if any: older than
933                    // the latest commit either way.
934                    end_hold(&mut self.timings, read, locked);
935                    entry.staged = Some(staged);
936                    wake_window(group);
937                    Outcome {
938                        deferred: true,
939                        ..Outcome::default()
940                    }
941                }
942            }
943        } else {
944            let in_place = std::mem::take(&mut entry.in_place);
945            let current = entry.current.as_mut().expect("current fits: checked above");
946            let plan = read.plan(Some(current.generation));
947            let bytes = plan_bytes(&self.backend, &plan, wanted);
948            if progressive_allowed && bytes > self.staging_budget && !in_place {
949                // Too much for one frame: fill a second texture over the next
950                // frames rather than stall this one or tear the picture. It is
951                // created outside the lock; the next frame starts filling it.
952                end_hold(&mut self.timings, read, locked);
953                let created = self.backend.create_texture(
954                    wanted.0,
955                    wanted.1,
956                    mipped,
957                    &texture_label(&source),
958                );
959                let entry = self.entries.get_mut(&id).expect("inserted above");
960                match created {
961                    Ok(texture) => {
962                        entry.staged = Some(Staged {
963                            tex: Tex::new(texture, wanted, mipped),
964                            fill: None,
965                        });
966                    }
967                    // No room for a second copy: the next frame uploads in
968                    // place, whole, as a smaller frame would.
969                    Err(TextureOutOfMemory) => {
970                        self.stats.alloc_failures += 1;
971                        entry.in_place = true;
972                    }
973                }
974                wake_window(group);
975                return Outcome {
976                    deferred: true,
977                    ..Outcome::default()
978                };
979            }
980            let uploaded = match plan {
981                UploadPlan::Nothing => false,
982                UploadPlan::Full => {
983                    debug_assert_eq!(read.size(), Some(current.size), "checked above");
984                    write_rects(
985                        &mut self.backend,
986                        &mut self.stats,
987                        &mut current.texture,
988                        &read,
989                        &[PixelRect::full(wanted.0, wanted.1)],
990                        self.band_bytes,
991                    );
992                    current.changed(Stale::Whole);
993                    self.stats.uploads_full += 1;
994                    true
995                }
996                UploadPlan::Rects(rects) => {
997                    write_rects(
998                        &mut self.backend,
999                        &mut self.stats,
1000                        &mut current.texture,
1001                        &read,
1002                        rects.as_slice(),
1003                        self.band_bytes,
1004                    );
1005                    let bounds = rects.as_slice().iter().copied().reduce(|a, b| a.union(&b));
1006                    if let Some(bounds) = bounds {
1007                        current.changed(Stale::Within(bounds));
1008                    }
1009                    self.stats.uploads_partial += 1;
1010                    true
1011                }
1012            };
1013            *staging_left = staging_left.saturating_sub(bytes);
1014            current.generation = read.generation();
1015            if uploaded {
1016                record_upload(&mut self.timings, &read);
1017            }
1018            end_hold(&mut self.timings, read, locked);
1019            Outcome {
1020                uploaded,
1021                ..Outcome::default()
1022            }
1023        }
1024    }
1025
1026    /// Record what this render did for each consumer of a source.
1027    fn record(
1028        &self,
1029        group: &[&LiveImageQuad],
1030        outcome: &Outcome,
1031        window_generation: Option<u64>,
1032        mode: LivePassMode,
1033    ) {
1034        if outcome.lost {
1035            return;
1036        }
1037        let mut seen: Vec<&LiveImageConsumer> = Vec::with_capacity(group.len());
1038        for quad in group {
1039            if seen.iter().any(|c| c.ptr_eq(&quad.consumer)) {
1040                continue;
1041            }
1042            seen.push(&quad.consumer);
1043            quad.consumer.record_render(RenderRecord {
1044                drawn: true,
1045                uploaded: outcome.uploaded,
1046                deferred: outcome.deferred,
1047                paused: group.iter().all(|q| q.paused),
1048                kept: outcome.kept,
1049                capture: mode == LivePassMode::Capture,
1050                window_generation: window_generation.unwrap_or(0),
1051            });
1052        }
1053    }
1054
1055    /// A source no quad drew: park its texture when it is small and its
1056    /// source committed once, else free it.
1057    fn retire(&mut self, id: LiveImageId, entry: Entry<B::Texture>) {
1058        let Entry {
1059            source,
1060            current,
1061            staged,
1062            ..
1063        } = entry;
1064        if staged.is_some() {
1065            self.backend.released();
1066        }
1067        drop(staged);
1068        let Some(tex) = current else {
1069            return;
1070        };
1071        let bytes = self.backend.texture_bytes(&tex.texture);
1072        let one_commit = source.stats().commits <= 1;
1073        let live = source.status() == super::LiveImageStatus::Live;
1074        if self.park_budget > 0
1075            && bytes <= PARK_MAX_TEXTURE.min(self.park_budget)
1076            && one_commit
1077            && live
1078        {
1079            self.parked.push(Parked {
1080                source: std::sync::Arc::downgrade(&source.shared),
1081                id,
1082                tex,
1083                bytes,
1084            });
1085        } else {
1086            drop(tex);
1087            self.backend.released();
1088        }
1089    }
1090
1091    /// A parked texture for `id`, if the pool holds one of a live source.
1092    fn unpark(&mut self, id: LiveImageId) -> Option<Tex<B::Texture>> {
1093        let at = self.parked.iter().position(|p| p.id == id)?;
1094        let parked = self.parked.remove(at);
1095        if parked.source.upgrade().is_none() {
1096            drop(parked);
1097            self.backend.released();
1098            return None;
1099        }
1100        Some(parked.tex)
1101    }
1102
1103    /// Free parked textures of dead sources, then the oldest past the budget.
1104    fn trim_parked(&mut self) {
1105        let before = self.parked.len();
1106        self.parked.retain(|p| p.source.strong_count() > 0);
1107        let mut freed = before - self.parked.len();
1108        let mut total: u64 = self.parked.iter().map(|p| p.bytes).sum();
1109        while total > self.park_budget && !self.parked.is_empty() {
1110            total -= self.parked.remove(0).bytes;
1111            freed += 1;
1112        }
1113        for _ in 0..freed {
1114            self.backend.released();
1115        }
1116    }
1117
1118    fn release(&mut self, dropped: usize) {
1119        for _ in 0..dropped {
1120            self.backend.released();
1121        }
1122    }
1123}
1124
1125impl<B: LiveTextureBackend> Drop for LivePass<B> {
1126    /// The window closes: every consumer of its last render is no longer
1127    /// observed, and every texture goes.
1128    fn drop(&mut self) {
1129        for consumer in &self.previous {
1130            consumer.set_observed(false);
1131            consumer.set_paused(false);
1132        }
1133        let held = self
1134            .entries
1135            .values()
1136            .map(|e| usize::from(e.current.is_some()) + usize::from(e.staged.is_some()))
1137            .sum::<usize>()
1138            + self.parked.len();
1139        if held > 0 {
1140            self.backend.released();
1141        }
1142    }
1143}
1144
1145/// The size to keep a texture at for a source drawn by `group`: `None` when
1146/// none of them has anything to show.
1147fn wanted_size(group: &[&LiveImageQuad]) -> Option<(u32, u32)> {
1148    let painted: Vec<(u32, u32)> = group.iter().filter_map(|q| q.painted).collect();
1149    let first = *painted.first()?;
1150    if painted.iter().all(|&p| p == first) {
1151        return Some(first);
1152    }
1153    // Two sizes in one frame: the source's own size wins, else the larger,
1154    // so every window decides alike.
1155    let now = group[0].consumer.source().size();
1156    if let Some(now) = now
1157        && painted.contains(&now)
1158    {
1159        return Some(now);
1160    }
1161    painted
1162        .into_iter()
1163        .max_by_key(|&(w, h)| (u64::from(w) * u64::from(h), w, h))
1164}
1165
1166/// `live_image`, or `live_image:<label>` for a labelled source.
1167fn texture_label(source: &LiveImageSource) -> String {
1168    match source.label() {
1169        Some(label) => format!("live_image:{label}"),
1170        None => "live_image".to_owned(),
1171    }
1172}
1173
1174/// Drop every texture of `entry` that is not `wanted`-sized: how many.
1175/// Whether it has mip levels does not matter here: this is for a size the
1176/// device cannot hold at all.
1177fn drop_mismatched<T>(entry: &mut Entry<T>, wanted: (u32, u32)) -> usize {
1178    let mut dropped = 0;
1179    if entry.current.as_ref().is_some_and(|t| t.size != wanted) {
1180        entry.current = None;
1181        dropped += 1;
1182    }
1183    if entry.staged.as_ref().is_some_and(|s| s.tex.size != wanted) {
1184        entry.staged = None;
1185        dropped += 1;
1186    }
1187    dropped
1188}
1189
1190fn park_all(group: &[&LiveImageQuad]) {
1191    for quad in group {
1192        quad.consumer.park_pixels();
1193    }
1194}
1195
1196/// Ask for another frame: a whole-frame fill continues there.
1197fn wake_window(group: &[&LiveImageQuad]) {
1198    if let Some(waker) = group[0].consumer.shared.waker() {
1199        waker.wake(WakeKind::Draw);
1200    }
1201}
1202
1203fn filter_bit(filter: ScalingFilter) -> u8 {
1204    match filter {
1205        ScalingFilter::Linear => 1,
1206        ScalingFilter::Nearest => 2,
1207        ScalingFilter::Trilinear => 4,
1208    }
1209}
1210
1211/// The staging `plan` takes.
1212fn plan_bytes<B: LiveTextureBackend>(backend: &B, plan: &UploadPlan, size: (u32, u32)) -> u64 {
1213    match plan {
1214        UploadPlan::Nothing => 0,
1215        UploadPlan::Full => backend.staged_row_bytes(size.0) * u64::from(size.1),
1216        UploadPlan::Rects(rects) => rects
1217            .as_slice()
1218            .iter()
1219            .map(|r| backend.staged_row_bytes(r.width) * u64::from(r.height))
1220            .sum(),
1221    }
1222}
1223
1224/// Write `rects` of the locked frame into `texture`, each cut into bands of
1225/// at most `band_bytes` of staging.
1226fn write_rects<B: LiveTextureBackend>(
1227    backend: &mut B,
1228    stats: &mut LiveTextureStats,
1229    texture: &mut B::Texture,
1230    read: &LiveImageRead<'_>,
1231    rects: &[PixelRect],
1232    band_bytes: u64,
1233) {
1234    for &rect in rects {
1235        write_rows(
1236            backend,
1237            stats,
1238            texture,
1239            read,
1240            rect,
1241            rect.y,
1242            rect.y + rect.height,
1243            band_bytes,
1244        );
1245    }
1246}
1247
1248/// Write rows `from..to` of `rect` in bands.
1249#[allow(clippy::too_many_arguments)]
1250fn write_rows<B: LiveTextureBackend>(
1251    backend: &mut B,
1252    stats: &mut LiveTextureStats,
1253    texture: &mut B::Texture,
1254    read: &LiveImageRead<'_>,
1255    rect: PixelRect,
1256    from: u32,
1257    to: u32,
1258    band_bytes: u64,
1259) {
1260    if rect.width == 0 || from >= to {
1261        return;
1262    }
1263    let row = backend.staged_row_bytes(rect.width).max(1);
1264    let rows_per_band = (band_bytes / row).clamp(1, u64::from(to - from)) as u32;
1265    let mut y = from;
1266    while y < to {
1267        let rows = rows_per_band.min(to - y);
1268        let band = PixelRect::new(rect.x, y, rect.width, rows);
1269        backend.write(texture, read, band);
1270        stats.upload_calls += 1;
1271        stats.bytes_uploaded += u64::from(rect.width) * u64::from(rows) * 4;
1272        y += rows;
1273    }
1274}
1275
1276enum FillOutcome {
1277    Complete,
1278    Partial,
1279}
1280
1281/// Fill `staged` from the locked frame: whole, or, when the frame needs
1282/// more staging than this frame has left and the fill may spread, the next
1283/// rows up to that budget, completing with the damage since its first band.
1284#[allow(clippy::too_many_arguments)]
1285fn fill_staged<B: LiveTextureBackend>(
1286    backend: &mut B,
1287    stats: &mut LiveTextureStats,
1288    read: &LiveImageRead<'_>,
1289    staged: &mut Staged<B::Texture>,
1290    size: (u32, u32),
1291    progressive: bool,
1292    staging_left: &mut u64,
1293    band_bytes: u64,
1294) -> FillOutcome {
1295    let full = PixelRect::full(size.0, size.1);
1296    let row = backend.staged_row_bytes(size.0).max(1);
1297    let total = row * u64::from(size.1);
1298    let spread = staged.fill.is_some() || (progressive && total > *staging_left);
1299    if !spread {
1300        write_rects(
1301            backend,
1302            stats,
1303            &mut staged.tex.texture,
1304            read,
1305            &[full],
1306            band_bytes,
1307        );
1308        *staging_left = staging_left.saturating_sub(total);
1309        stats.uploads_full += 1;
1310        return FillOutcome::Complete;
1311    }
1312    let fill = staged.fill.get_or_insert(Fill {
1313        first: read.generation(),
1314        next_row: 0,
1315        restarts: 0,
1316    });
1317    if !progressive {
1318        // A capture: finish now, every band before the draw.
1319        let from = fill.next_row;
1320        write_rows(
1321            backend,
1322            stats,
1323            &mut staged.tex.texture,
1324            read,
1325            full,
1326            from,
1327            size.1,
1328            band_bytes,
1329        );
1330        let first = fill.first;
1331        finish_fill(backend, stats, read, staged, first, size, band_bytes, true);
1332        return FillOutcome::Complete;
1333    }
1334    let affordable = (*staging_left / row).max(1) as u32;
1335    let from = fill.next_row;
1336    let to = from.saturating_add(affordable).min(size.1);
1337    write_rows(
1338        backend,
1339        stats,
1340        &mut staged.tex.texture,
1341        read,
1342        full,
1343        from,
1344        to,
1345        band_bytes,
1346    );
1347    stats.progressive_bands += 1;
1348    *staging_left = staging_left.saturating_sub(row * u64::from(to - from));
1349    let fill = staged.fill.as_mut().expect("set above");
1350    fill.next_row = to;
1351    if to < size.1 {
1352        return FillOutcome::Partial;
1353    }
1354    let (first, restarts) = (fill.first, fill.restarts);
1355    if finish_fill(
1356        backend,
1357        stats,
1358        read,
1359        staged,
1360        first,
1361        size,
1362        band_bytes,
1363        restarts >= MAX_RESTARTS,
1364    ) {
1365        FillOutcome::Complete
1366    } else {
1367        // The damage since the first band is the whole frame: start over
1368        // from the current generation, the picture still the previous one.
1369        staged.fill = Some(Fill {
1370            first: read.generation(),
1371            next_row: 0,
1372            restarts: restarts + 1,
1373        });
1374        FillOutcome::Partial
1375    }
1376}
1377
1378/// Every row is copied: write what changed since generation `first`, so
1379/// the texture holds the locked generation whole. `false` when that is the
1380/// whole frame and `force` is not set.
1381#[allow(clippy::too_many_arguments)]
1382fn finish_fill<B: LiveTextureBackend>(
1383    backend: &mut B,
1384    stats: &mut LiveTextureStats,
1385    read: &LiveImageRead<'_>,
1386    staged: &mut Staged<B::Texture>,
1387    first: u64,
1388    size: (u32, u32),
1389    band_bytes: u64,
1390    force: bool,
1391) -> bool {
1392    match read.plan(Some(first)) {
1393        UploadPlan::Nothing => {}
1394        UploadPlan::Rects(rects) => {
1395            write_rects(
1396                backend,
1397                stats,
1398                &mut staged.tex.texture,
1399                read,
1400                rects.as_slice(),
1401                band_bytes,
1402            );
1403        }
1404        UploadPlan::Full if force => {
1405            write_rects(
1406                backend,
1407                stats,
1408                &mut staged.tex.texture,
1409                read,
1410                &[PixelRect::full(size.0, size.1)],
1411                band_bytes,
1412            );
1413        }
1414        UploadPlan::Full => return false,
1415    }
1416    staged.fill = None;
1417    stats.uploads_full += 1;
1418    true
1419}
1420
1421#[cfg(all(test, not(teksilo_loom)))]
1422mod tests;