Skip to main content

atrinik_render_testkit/
lib.rs

1// Copyright 2026 The Atrinik Project
2// SPDX-License-Identifier: MIT
3
4#![forbid(unsafe_code)]
5
6use atrinik_render_api::{
7    Error as RenderError, FrameOutput, RecoveryMetrics, RenderRequest, Renderer, TargetDescriptor,
8    TargetKind, TargetLimits,
9};
10use atrinik_render_resources::{
11    DEFAULT_MAXIMUM_RESOURCE_BYTES, Error as ResourceError, ResourceBlob, ResourceProvider,
12    ResourceRequest, Rgba8Image, add_working_set_bytes, for_each_sprite_pixel, sample_rgba8,
13    shade_rgba8,
14};
15use atrinik_scene::{
16    Digest256, Layer, ResourceId, Sampling, SceneBundle, SceneLimits, SemanticId, Sprite, Viewport,
17};
18use sha2::{Digest, Sha256};
19use std::{
20    collections::{BTreeMap, HashMap, VecDeque},
21    sync::{Arc, RwLock},
22};
23
24#[derive(Default)]
25pub struct FakeResourceProvider {
26    blobs: RwLock<BTreeMap<(ResourceId, u64), Vec<u8>>>,
27}
28
29impl FakeResourceProvider {
30    /// Inserts a versioned immutable blob.
31    ///
32    /// # Errors
33    /// Returns [`ResourceError::Unavailable`] if another test poisoned the lock.
34    pub fn insert(
35        &self,
36        id: ResourceId,
37        revision: u64,
38        bytes: Vec<u8>,
39    ) -> Result<(), ResourceError> {
40        self.blobs
41            .write()
42            .map_err(|_| ResourceError::Unavailable)?
43            .insert((id, revision), bytes);
44        Ok(())
45    }
46}
47
48impl ResourceProvider for FakeResourceProvider {
49    fn load(&self, request: ResourceRequest) -> Result<ResourceBlob, ResourceError> {
50        request.validate()?;
51        let bytes = self
52            .blobs
53            .read()
54            .map_err(|_| ResourceError::Unavailable)?
55            .get(&(request.id, request.revision))
56            .cloned()
57            .ok_or(ResourceError::NotFound)?;
58        ResourceBlob::for_request(request, bytes)
59    }
60}
61
62/// Deterministic software reference used by contract tests. It is intentionally
63/// simple: sprites are solid, half-open rectangles in logical pixel space.
64pub struct ReferenceRenderer {
65    target: TargetDescriptor,
66}
67
68/// Programmable renderer double for consumer lifecycle and failure tests.
69pub struct FakeRenderer {
70    target: TargetDescriptor,
71    responses: VecDeque<Result<FrameOutput, RenderError>>,
72    rendered_revisions: Vec<u64>,
73}
74
75impl FakeRenderer {
76    /// Creates a fake with an explicit validated target.
77    ///
78    /// # Errors
79    /// Returns a target error when the descriptor exceeds default limits.
80    pub fn new(target: TargetDescriptor) -> Result<Self, RenderError> {
81        Ok(Self {
82            target: target.validate(TargetLimits::default())?,
83            responses: VecDeque::new(),
84            rendered_revisions: Vec::new(),
85        })
86    }
87
88    pub fn push_response(&mut self, response: Result<FrameOutput, RenderError>) {
89        self.responses.push_back(response);
90    }
91
92    #[must_use]
93    pub fn rendered_revisions(&self) -> &[u64] {
94        &self.rendered_revisions
95    }
96}
97
98impl Renderer for FakeRenderer {
99    fn target(&self) -> TargetDescriptor {
100        self.target
101    }
102
103    fn resize(&mut self, width: u32, height: u32) -> Result<(), RenderError> {
104        self.target = TargetDescriptor {
105            width,
106            height,
107            ..self.target
108        }
109        .validate(TargetLimits::default())?;
110        Ok(())
111    }
112
113    fn render(&mut self, request: RenderRequest<'_>) -> Result<FrameOutput, RenderError> {
114        self.rendered_revisions.push(request.scene.revision());
115        self.responses.pop_front().unwrap_or_else(|| {
116            Err(RenderError::Internal(
117                "fake response queue is empty".to_owned(),
118            ))
119        })
120    }
121}
122
123impl ReferenceRenderer {
124    /// Creates a bounded renderer-neutral reference target.
125    ///
126    /// # Errors
127    /// Returns a target error when the descriptor is invalid.
128    pub fn new(target: TargetDescriptor) -> Result<Self, RenderError> {
129        Ok(Self {
130            target: target.validate(TargetLimits::default())?,
131        })
132    }
133}
134
135impl Renderer for ReferenceRenderer {
136    fn target(&self) -> TargetDescriptor {
137        self.target
138    }
139
140    fn resize(&mut self, width: u32, height: u32) -> Result<(), RenderError> {
141        self.target = TargetDescriptor {
142            width,
143            height,
144            ..self.target
145        }
146        .validate(TargetLimits::default())?;
147        Ok(())
148    }
149
150    fn render(&mut self, request: RenderRequest<'_>) -> Result<FrameOutput, RenderError> {
151        if request.scene.viewport().width != self.target.width
152            || request.scene.viewport().height != self.target.height
153        {
154            return Err(RenderError::InvalidTarget);
155        }
156        let pixel_count = pixel_count(self.target.width, self.target.height)?;
157        let clear = color_to_rgba8(request.scene.clear_color());
158        let mut output = FrameOutput {
159            width: self.target.width,
160            height: self.target.height,
161            rgba8: clear.repeat(pixel_count),
162            semantic_ids: vec![0; pixel_count],
163            depth: vec![i16::MIN; pixel_count],
164            coverage: vec![0; pixel_count],
165            metrics: RecoveryMetrics::default(),
166        };
167        let mut uploaded_bytes = 0_u64;
168        let mut resource_requests = 0_u64;
169        let mut images: HashMap<(ResourceId, u64, Digest256), Arc<Rgba8Image>> = HashMap::new();
170        for sprite in request.scene.sprites() {
171            let key = (
172                sprite.resource_id,
173                sprite.resource_revision,
174                sprite.resource_digest,
175            );
176            let image = if let Some(image) = images.get(&key) {
177                image.clone()
178            } else {
179                let blob = request
180                    .resources
181                    .load(ResourceRequest {
182                        id: sprite.resource_id,
183                        revision: sprite.resource_revision,
184                        digest: sprite.resource_digest,
185                        maximum_bytes: DEFAULT_MAXIMUM_RESOURCE_BYTES,
186                    })
187                    .map_err(|error| RenderError::Resource(error.to_string()))?;
188                let image = Arc::new(
189                    Rgba8Image::decode(&blob, 8_192, 16_777_216)
190                        .map_err(|error| RenderError::Resource(error.to_string()))?,
191                );
192                uploaded_bytes = add_working_set_bytes(uploaded_bytes, image.pixels().len() as u64)
193                    .map_err(|_| RenderError::LimitExceeded)?;
194                resource_requests += 1;
195                images.insert(key, image.clone());
196                image
197            };
198            raster_sprite(&mut output, sprite, &image, request.scene.clock_millis());
199        }
200        output.metrics.frames_submitted = 1;
201        output.metrics.resource_requests = resource_requests;
202        output.metrics.uploaded_bytes = uploaded_bytes;
203        output.metrics.vertex_count = (request.scene.sprites().len() as u64) * 6;
204        output.metrics.vertex_allocation_bytes = output.metrics.vertex_count * 40;
205        output.metrics.target_allocation_bytes = (pixel_count as u64) * 11;
206        output.validate()?;
207        Ok(output)
208    }
209}
210
211fn pixel_count(width: u32, height: u32) -> Result<usize, RenderError> {
212    usize::try_from(width)
213        .ok()
214        .and_then(|width| {
215            usize::try_from(height)
216                .ok()
217                .and_then(|height| width.checked_mul(height))
218        })
219        .ok_or(RenderError::LimitExceeded)
220}
221
222fn raster_sprite(output: &mut FrameOutput, sprite: &Sprite, image: &Rgba8Image, clock_millis: u64) {
223    for_each_sprite_pixel(
224        sprite,
225        output.width,
226        output.height,
227        |_x, _y, index, u, v| {
228            let source = shade_rgba8(
229                sample_rgba8(image, u, v, sprite.sampling),
230                sprite.color,
231                sprite.layer,
232                clock_millis,
233            );
234            if source[3] != 0 {
235                blend(&mut output.rgba8[index * 4..index * 4 + 4], source);
236                output.semantic_ids[index] = sprite.semantic_id.0;
237                output.depth[index] = sprite.depth;
238                output.coverage[index] = source[3];
239            }
240        },
241    );
242}
243
244#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
245fn color_to_rgba8(color: [f32; 4]) -> [u8; 4] {
246    color.map(|channel| (channel.mul_add(255.0, 0.5)).floor() as u8)
247}
248
249fn blend(destination: &mut [u8], source: [u8; 4]) {
250    let alpha = u32::from(source[3]);
251    let inverse = 255 - alpha;
252    for channel in 0..3 {
253        destination[channel] = u8::try_from(
254            (u32::from(source[channel]) * alpha + u32::from(destination[channel]) * inverse + 127)
255                / 255,
256        )
257        .expect("8-bit blend channel remains in range");
258    }
259    destination[3] = u8::try_from(alpha + (u32::from(destination[3]) * inverse + 127) / 255)
260        .expect("8-bit alpha remains in range");
261}
262
263#[must_use]
264pub fn synthetic_digest() -> Digest256 {
265    Digest256(Sha256::digest(synthetic_resource()).into())
266}
267
268#[must_use]
269pub fn synthetic_resource() -> &'static [u8] {
270    b"ATX1\x02\x00\x00\x00\x02\x00\x00\x00\xff\xff\xff\xff\xff\x40\x20\xff\x20\xff\x80\xc0\x20\x60\xff\x40"
271}
272
273/// Creates a provider containing every resource used by synthetic scenes.
274///
275/// # Errors
276/// Returns a provider availability error if its internal test lock is poisoned.
277pub fn synthetic_provider() -> Result<Arc<FakeResourceProvider>, ResourceError> {
278    let provider = Arc::new(FakeResourceProvider::default());
279    provider.insert(ResourceId(1), 1, synthetic_resource().to_vec())?;
280    Ok(provider)
281}
282
283/// Builds the repository-owned visual contract scene at an explicit size.
284///
285/// # Errors
286/// Returns a scene validation error for dimensions outside scene bounds.
287#[must_use = "synthetic scene construction can fail"]
288#[allow(clippy::cast_precision_loss)]
289pub fn synthetic_scene(width: u32, height: u32) -> Result<SceneBundle, atrinik_scene::Error> {
290    SceneBundle::new(
291        1,
292        375,
293        Viewport {
294            width,
295            height,
296            scale_milli: 1_000,
297        },
298        [0.02, 0.03, 0.05, 1.0],
299        [
300            Sprite {
301                semantic_id: SemanticId(1),
302                resource_id: ResourceId(1),
303                resource_revision: 1,
304                resource_digest: synthetic_digest(),
305                x: 1.0,
306                y: 1.0,
307                width: (width / 2).max(1) as f32,
308                height: (height / 2).max(1) as f32,
309                depth: 0,
310                painter_order: 0,
311                color: [0.85, 0.12, 0.18, 1.0],
312                sampling: Sampling::Nearest,
313                layer: Layer::World,
314            },
315            Sprite {
316                semantic_id: SemanticId(2),
317                resource_id: ResourceId(1),
318                resource_revision: 1,
319                resource_digest: synthetic_digest(),
320                x: (width / 3) as f32,
321                y: (height / 3) as f32,
322                width: (width / 2).max(1) as f32,
323                height: (height / 2).max(1) as f32,
324                depth: 1,
325                painter_order: 1,
326                color: [0.1, 0.55, 0.95, 0.75],
327                sampling: Sampling::Linear,
328                layer: Layer::Effect,
329            },
330            Sprite {
331                semantic_id: SemanticId(3),
332                resource_id: ResourceId(1),
333                resource_revision: 1,
334                resource_digest: synthetic_digest(),
335                x: -1.25,
336                y: height.saturating_sub(3) as f32,
337                width: width.saturating_add(2) as f32,
338                height: 2.5,
339                depth: 0,
340                painter_order: 0,
341                color: [0.95, 0.95, 0.75, 0.8],
342                sampling: Sampling::Nearest,
343                layer: Layer::Interface,
344            },
345        ],
346        SceneLimits::default(),
347    )
348}
349
350/// Builds a bounded high-density batching and allocation scenario.
351///
352/// # Errors
353/// Returns a scene validation error if requested dimensions cannot contain the
354/// fixed 1,024-sprite scenario.
355#[allow(clippy::cast_precision_loss)]
356pub fn dense_scene(width: u32, height: u32) -> Result<SceneBundle, atrinik_scene::Error> {
357    let mut sprites = Vec::with_capacity(1_024);
358    for index in 0..1_024_u32 {
359        let column = index % 32;
360        let row = index / 32;
361        let depth = i16::try_from(index % 17).map_err(|_| atrinik_scene::Error::InvalidValue)?;
362        sprites.push(Sprite {
363            semantic_id: SemanticId(index + 1),
364            resource_id: ResourceId(1),
365            resource_revision: 1,
366            resource_digest: synthetic_digest(),
367            x: (column * 4) as f32,
368            y: (row * 4) as f32,
369            width: 4.0,
370            height: 4.0,
371            depth,
372            painter_order: index,
373            color: [0.55, 0.7, 0.9, 0.9],
374            sampling: if index % 2 == 0 {
375                Sampling::Nearest
376            } else {
377                Sampling::Linear
378            },
379            layer: if index % 11 == 0 {
380                Layer::Effect
381            } else {
382                Layer::World
383            },
384        });
385    }
386    SceneBundle::new(
387        2,
388        375,
389        Viewport {
390            width,
391            height,
392            scale_milli: 1_000,
393        },
394        [0.01, 0.01, 0.02, 1.0],
395        sprites,
396        SceneLimits::default(),
397    )
398}
399
400/// Builds a synthetic structural/tall/multipart/fog/cutaway/UI contract scene.
401///
402/// # Errors
403/// Returns a scene validation error for dimensions below the fixture contract.
404pub fn structural_scene(width: u32, height: u32) -> Result<SceneBundle, atrinik_scene::Error> {
405    if width < 32 || height < 32 {
406        return Err(atrinik_scene::Error::InvalidValue);
407    }
408    let digest = synthetic_digest();
409    SceneBundle::new(
410        3,
411        375,
412        Viewport {
413            width,
414            height,
415            scale_milli: 1_500,
416        },
417        [0.04, 0.05, 0.08, 1.0],
418        [
419            fixture_sprite(1, [3.0, 20.0, 26.0, 5.0], -2, 0, Layer::World, digest),
420            fixture_sprite(2, [8.0, 7.0, 5.0, 20.0], -1, 1, Layer::World, digest),
421            fixture_sprite(3, [13.0, 11.0, 5.0, 16.0], 0, 2, Layer::World, digest),
422            fixture_sprite(4, [18.0, 15.0, 5.0, 12.0], 1, 3, Layer::World, digest),
423            fixture_sprite(5, [23.0, 19.0, 5.0, 8.0], 2, 4, Layer::World, digest),
424            Sprite {
425                color: [0.35, 0.55, 0.8, 0.28],
426                sampling: Sampling::Linear,
427                ..fixture_sprite(6, [-2.0, 9.5, 36.0, 14.0], 3, 0, Layer::Effect, digest)
428            },
429            Sprite {
430                color: [1.0, 0.92, 0.55, 0.65],
431                ..fixture_sprite(7, [10.0, 4.0, 12.0, 6.0], 4, 1, Layer::Effect, digest)
432            },
433            Sprite {
434                color: [0.12, 0.15, 0.2, 0.92],
435                ..fixture_sprite(8, [0.0, 27.0, 32.0, 5.0], 0, 0, Layer::Interface, digest)
436            },
437            Sprite {
438                color: [0.95, 0.95, 0.9, 1.0],
439                ..fixture_sprite(9, [35.0, 26.0, 2.0, 6.0], 1, 1, Layer::Interface, digest)
440            },
441            Sprite {
442                color: [0.95, 0.95, 0.9, 1.0],
443                ..fixture_sprite(10, [41.0, 26.0, 2.0, 6.0], 1, 2, Layer::Interface, digest)
444            },
445            Sprite {
446                color: [0.95, 0.95, 0.9, 1.0],
447                ..fixture_sprite(11, [35.0, 28.0, 8.0, 2.0], 1, 3, Layer::Interface, digest)
448            },
449        ],
450        SceneLimits::default(),
451    )
452}
453
454fn fixture_sprite(
455    semantic_id: u32,
456    bounds: [f32; 4],
457    depth: i16,
458    painter_order: u32,
459    layer: Layer,
460    digest: Digest256,
461) -> Sprite {
462    Sprite {
463        semantic_id: SemanticId(semantic_id),
464        resource_id: ResourceId(1),
465        resource_revision: 1,
466        resource_digest: digest,
467        x: bounds[0],
468        y: bounds[1],
469        width: bounds[2],
470        height: bounds[3],
471        depth,
472        painter_order,
473        color: [0.85, 0.75, 0.6, 1.0],
474        sampling: Sampling::Nearest,
475        layer,
476    }
477}
478
479#[must_use]
480pub const fn offscreen_target(width: u32, height: u32) -> TargetDescriptor {
481    TargetDescriptor {
482        kind: TargetKind::Offscreen,
483        width,
484        height,
485        backend: atrinik_render_api::BackendPreference::Automatic,
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::{
492        FakeRenderer, FakeResourceProvider, ReferenceRenderer, offscreen_target,
493        synthetic_provider, synthetic_scene,
494    };
495    use atrinik_render_api::{Error, RenderRequest, Renderer};
496    use atrinik_render_resources::{
497        Error as ResourceError, ResourceBlob, ResourceProvider, ResourceRequest,
498    };
499    use atrinik_scene::{Digest256, ResourceId, SceneBundle, SceneLimits};
500    use sha2::{Digest, Sha256};
501    use std::sync::Arc;
502
503    struct FailingProvider(ResourceError);
504
505    impl ResourceProvider for FailingProvider {
506        fn load(&self, _request: ResourceRequest) -> Result<ResourceBlob, ResourceError> {
507            Err(self.0)
508        }
509    }
510
511    #[test]
512    fn reference_output_is_exact_and_repeatable() {
513        let scene = synthetic_scene(16, 16).unwrap();
514        let request = RenderRequest {
515            scene: &scene,
516            resources: synthetic_provider().unwrap(),
517        };
518        let first = ReferenceRenderer::new(offscreen_target(16, 16))
519            .unwrap()
520            .render(request.clone())
521            .unwrap();
522        let second = ReferenceRenderer::new(offscreen_target(16, 16))
523            .unwrap()
524            .render(request)
525            .unwrap();
526        assert_eq!(first, second);
527        assert!(first.semantic_ids.contains(&1));
528        assert!(first.semantic_ids.contains(&2));
529    }
530
531    #[test]
532    fn missing_and_digest_mismatched_resources_fail_closed() {
533        let scene = synthetic_scene(16, 16).unwrap();
534        let missing = ReferenceRenderer::new(offscreen_target(16, 16))
535            .unwrap()
536            .render(RenderRequest {
537                scene: &scene,
538                resources: Arc::new(FakeResourceProvider::default()),
539            })
540            .unwrap_err();
541        assert!(matches!(missing, Error::Resource(message) if message.contains("NotFound")));
542
543        let provider = Arc::new(FakeResourceProvider::default());
544        provider
545            .insert(ResourceId(1), 1, b"wrong bytes".to_vec())
546            .unwrap();
547        let mismatch = ReferenceRenderer::new(offscreen_target(16, 16))
548            .unwrap()
549            .render(RenderRequest {
550                scene: &scene,
551                resources: provider,
552            })
553            .unwrap_err();
554        assert!(matches!(mismatch, Error::Resource(message) if message.contains("DigestMismatch")));
555
556        let corrupt_bytes = b"not-an-atx1-texture".to_vec();
557        let digest = Digest256(Sha256::digest(&corrupt_bytes).into());
558        let mut sprites = scene.sprites().to_vec();
559        for sprite in &mut sprites {
560            sprite.resource_digest = digest;
561        }
562        let corrupt_scene = SceneBundle::new(
563            scene.revision(),
564            scene.clock_millis(),
565            scene.viewport(),
566            scene.clear_color(),
567            sprites,
568            SceneLimits::default(),
569        )
570        .unwrap();
571        let corrupt_provider = Arc::new(FakeResourceProvider::default());
572        corrupt_provider
573            .insert(ResourceId(1), 1, corrupt_bytes)
574            .unwrap();
575        let corrupt = ReferenceRenderer::new(offscreen_target(16, 16))
576            .unwrap()
577            .render(RenderRequest {
578                scene: &corrupt_scene,
579                resources: corrupt_provider,
580            })
581            .unwrap_err();
582        assert!(matches!(corrupt, Error::Resource(message) if message.contains("InvalidFormat")));
583
584        let oversized = ReferenceRenderer::new(offscreen_target(16, 16))
585            .unwrap()
586            .render(RenderRequest {
587                scene: &scene,
588                resources: Arc::new(FailingProvider(ResourceError::LimitExceeded)),
589            })
590            .unwrap_err();
591        assert!(matches!(oversized, Error::Resource(message) if message.contains("LimitExceeded")));
592    }
593
594    #[test]
595    fn programmable_fake_records_requests_and_failures() {
596        let scene = synthetic_scene(4, 4).unwrap();
597        let request = RenderRequest {
598            scene: &scene,
599            resources: synthetic_provider().unwrap(),
600        };
601        let recovered = ReferenceRenderer::new(offscreen_target(4, 4))
602            .unwrap()
603            .render(request.clone())
604            .unwrap();
605        let mut fake = FakeRenderer::new(offscreen_target(4, 4)).unwrap();
606        fake.push_response(Err(Error::SurfaceUnavailable));
607        fake.push_response(Err(Error::SurfaceLost));
608        fake.push_response(Err(Error::DeviceLost));
609        fake.push_response(Ok(recovered.clone()));
610        assert_eq!(fake.render(request.clone()), Err(Error::SurfaceUnavailable));
611        assert_eq!(fake.render(request.clone()), Err(Error::SurfaceLost));
612        assert_eq!(fake.render(request.clone()), Err(Error::DeviceLost));
613        assert_eq!(fake.render(request), Ok(recovered));
614        assert_eq!(fake.rendered_revisions(), [1, 1, 1, 1]);
615    }
616}