frust_gpu/context.rs
1//! Instance, adapter and lazy logical-device creation: [`RenderContext`] and
2//! [`DeviceHandle`].
3//!
4//! This is the crate's entry point — everything else in `frust-gpu` consumes a
5//! [`DeviceHandle`] rather than reaching for a `wgpu::Adapter` itself. A
6//! [`RenderContext`] owns one `wgpu::Instance` and at most one logical device,
7//! created lazily on the first surface (or the first [`RenderContext::device`]
8//! call) and then reused: a logical device is display-independent, so surface
9//! loss and recreation (rotation, backgrounding) must not rebuild it.
10//!
11//! This is also the type `frust-render` re-exports as its own
12//! `frust_render::RenderContext`, which is what every platform shell holds: the
13//! device/surface foundation lives here, and the renderer above adds only the
14//! render-path decisions specific to how a frame is drawn.
15//!
16//! Surface creation itself lives in [`crate::surface`] and the raw-pointer
17//! constructors in [`crate::lifecycle`]; [`RenderContext::create_render_surface`]
18//! is the one entry point that ties them to a device.
19//!
20//! # Pure decision vs. platform lookup
21//!
22//! Every environment-sensitive choice here is split into a pure function taking
23//! the environment as an argument (`effective_instance_flags`,
24//! [`effective_limits`], `device_features`, [`decide_log_action`]) plus a
25//! separate lookup that answers what the environment actually is
26//! (`is_android_emulator`, [`is_ios_simulator`]). Only the lookups are
27//! platform-gated, so the policies stay unit-testable on any host with no GPU
28//! and no mobile target in the loop — the same split
29//! [`crate::caps::TierCaps::probe`]/[`crate::caps::TierCaps::fake`] gives
30//! adapter capabilities.
31//!
32//! # Two mitigations worth knowing about
33//!
34//! - The Android **emulator** cannot survive `wgpu::InstanceFlags::DEBUG`, so
35//! the instance is built with those flags stripped there and nowhere else
36//! (`effective_instance_flags`).
37//! - The iOS **Simulator** misreports its uniform-buffer alignment, so a device
38//! request made there is forced back up to 256 bytes ([`effective_limits`]).
39
40use std::sync::atomic::{AtomicU32, Ordering};
41use std::sync::{Arc, OnceLock};
42
43use anyhow::{Result, anyhow};
44
45use crate::caps::{DownlevelProfile, TierCaps};
46use crate::surface::{
47 ConfiguredSurface, SurfaceAlphaRequest, SurfaceFactory, resolve_alpha_mode,
48 select_surface_format,
49};
50
51/// Default `wgpu::Device` debug label, used when a caller supplies no
52/// [`ContextOptions::device_label`] of its own.
53const DEFAULT_DEVICE_LABEL: &str = "frust-gpu device";
54
55/// How a [`RenderContext`] should build its instance and request its device.
56///
57/// Every field has a working default, so `ContextOptions::default()` is the
58/// ordinary construction — a field exists here only where a host genuinely has
59/// a choice to make, and [`RenderContext::new`] takes the defaults.
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct ContextOptions {
62 /// Debug label attached to the created `wgpu::Device`, surfaced by graphics
63 /// debuggers and in validation messages.
64 pub device_label: String,
65 /// Backends the `wgpu::Instance` is restricted to. `None` — the default —
66 /// takes `wgpu::Backends::from_env()` (the `WGPU_BACKEND` knob) and falls
67 /// back to every backend compiled in, which is what a shell wants. A
68 /// headless caller that must pin one backend regardless of the ambient
69 /// environment sets it explicitly.
70 pub backends: Option<wgpu::Backends>,
71}
72
73impl Default for ContextOptions {
74 fn default() -> Self {
75 Self {
76 device_label: DEFAULT_DEVICE_LABEL.to_string(),
77 backends: None,
78 }
79 }
80}
81
82/// A logical device, the adapter it was created from, the queue that executes
83/// its command buffers, and the capabilities that adapter reported.
84///
85/// Cheap to clone: `wgpu`'s `Adapter`/`Device`/`Queue` are all `Arc`-backed
86/// handles to one underlying object, so a clone is another handle to the *same*
87/// device rather than a second device. Clone it freely to hand a subsystem the
88/// device it needs instead of threading a `&RenderContext` borrow through it.
89#[derive(Clone, Debug)]
90pub struct DeviceHandle {
91 /// The adapter the device was created from.
92 pub adapter: wgpu::Adapter,
93 /// The logical device.
94 pub device: wgpu::Device,
95 /// The queue that executes this device's command buffers.
96 pub queue: wgpu::Queue,
97 /// What [`Self::adapter`] reported at device-creation time. Captured once
98 /// so downstream pipeline/atlas decisions read plain data instead of
99 /// re-probing the adapter.
100 pub caps: TierCaps,
101 /// The first uncaptured error this device raised, latched by the handler
102 /// installed in [`create_device`]. See [`Self::first_uncaptured_error`].
103 first_uncaptured_error: Arc<OnceLock<String>>,
104}
105
106impl DeviceHandle {
107 /// The **first** uncaptured `wgpu` error this device ever raised, or `None`
108 /// if it has raised none.
109 ///
110 /// Latched, never overwritten: a frame loop polling this wants the error
111 /// that started the trouble, not the last one in a storm the first one
112 /// caused. It is also the only programmatic view of an uncaptured error a
113 /// caller gets — the handler otherwise only logs (see
114 /// [`decide_log_action`]) — so a host can degrade or report instead of
115 /// silently rendering nothing every frame.
116 ///
117 /// Deliberately not clearable: "this device has seen an uncaptured error"
118 /// is a property of the device, and a device that has raised one is not
119 /// reliably recoverable by forgetting about it.
120 pub fn first_uncaptured_error(&self) -> Option<&str> {
121 self.first_uncaptured_error.get().map(String::as_str)
122 }
123}
124
125/// Owns the `wgpu::Instance` and the single logical device this crate's
126/// consumers render with.
127///
128/// A single `RenderContext` is shared across every surface a shell creates
129/// (frust is single-window); the device is created lazily on the first surface
130/// and reused across surface loss/recreation (rotation, backgrounding) since a
131/// logical device is display-independent. [`RenderContext::new`] performs no
132/// adapter enumeration at all, so a host may build one early (before it has a
133/// window, or on a thread that will never render) and pay for the device only
134/// at the first surface — or at an explicit
135/// [`ensure_device_headless`](Self::ensure_device_headless) pre-init.
136pub struct RenderContext {
137 instance: wgpu::Instance,
138 options: ContextOptions,
139 /// `None` until a surface (or an explicit pre-init) creates the device.
140 device: Option<DeviceHandle>,
141}
142
143impl Default for RenderContext {
144 fn default() -> Self {
145 Self::new()
146 }
147}
148
149impl RenderContext {
150 /// Creates a context with a fresh wgpu `Instance` and no device yet
151 /// (the device is created lazily on first surface creation).
152 ///
153 /// The instance flags come from the build configuration
154 /// (`InstanceFlags::from_build_config`, which turns `DEBUG`/`VALIDATION` on
155 /// in debug builds) plus the standard `WGPU_*` environment overrides. When
156 /// actually running on an Android *emulator* they are then run through
157 /// `effective_instance_flags`, which strips `DEBUG`/`VALIDATION`: the
158 /// `DEBUG` flag makes wgpu enable `VK_EXT_debug_utils` and set object-name
159 /// labels via `vkSetDebugUtilsObjectNameEXT`, and the emulator's gfxstream
160 /// Vulkan HAL (`vulkan.ranchu.so`) segfaults inside that entry point during
161 /// adapter enumeration (observed crash: `#00 vulkan.ranchu.so
162 /// vk_common_SetDebugUtilsObjectNameEXT`) — the same class of debug-utils
163 /// fragility a MoltenVK Vulkan backend is also known to have. Debug object
164 /// labels are only a developer convenience, so dropping them on the
165 /// emulator is a safe way to keep GPU bring-up alive there while leaving
166 /// physical devices' validation safety net — and desktop behavior —
167 /// untouched.
168 pub fn new() -> Self {
169 Self::with_options(ContextOptions::default())
170 }
171
172 /// [`Self::new`] with an explicit [`ContextOptions`] — a headless harness
173 /// that must label its device or pin one backend regardless of the ambient
174 /// environment. Every shell takes `new()`'s defaults instead.
175 pub fn with_options(options: ContextOptions) -> Self {
176 let backends = options
177 .backends
178 .unwrap_or_else(|| wgpu::Backends::from_env().unwrap_or_default());
179 let build_flags = wgpu::InstanceFlags::from_build_config().with_env();
180 #[cfg(target_os = "android")]
181 let flags = effective_instance_flags(build_flags, is_android_emulator());
182 #[cfg(not(target_os = "android"))]
183 let flags = build_flags;
184 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
185 display: None,
186 backends,
187 flags,
188 memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
189 backend_options: wgpu::BackendOptions::from_env_or_default(),
190 });
191 Self {
192 instance,
193 options,
194 device: None,
195 }
196 }
197
198 /// A cloneable [`SurfaceFactory`] sharing this context's wgpu `Instance`,
199 /// for creating a [`DetachedSurface`](crate::surface::DetachedSurface) on
200 /// the windowing/main thread when the context itself lives on the render
201 /// thread (the render-thread split). The surface a clone produces stays
202 /// compatible with the device this context creates, since both share one
203 /// Arc-backed instance.
204 pub fn surface_factory(&self) -> SurfaceFactory {
205 SurfaceFactory::new(&self.instance)
206 }
207
208 /// This context's wgpu `Instance`, for the two raw-pointer surface
209 /// constructors in [`crate::lifecycle`] (the mobile shells' path, which
210 /// receives an `ANativeWindow*`/`CAMetalLayer*` rather than a window
211 /// handle a [`SurfaceFactory`] could take).
212 ///
213 /// Hidden from the rendered docs rather than made private, on the same
214 /// grounds as
215 /// [`DetachedSurface::into_surface`](crate::surface::DetachedSurface::into_surface):
216 /// the renderer crate above this one is the intended (and only) caller,
217 /// and no layer above *it* may re-export this accessor.
218 #[doc(hidden)]
219 pub fn instance(&self) -> &wgpu::Instance {
220 &self.instance
221 }
222
223 /// The single logical device, panicking if no surface has created it yet.
224 ///
225 /// Only called from the renderer's install/resize/render paths, all of
226 /// which run strictly after a `create_*_surface`, so the device is always
227 /// present. Use [`Self::device`] for the fallible, creating form.
228 ///
229 /// Hidden from the rendered docs for the same reason as [`Self::instance`].
230 #[doc(hidden)]
231 pub fn device_handle(&self) -> &DeviceHandle {
232 self.device
233 .as_ref()
234 .expect("device must be created before it is used (surface creation creates it)")
235 }
236
237 /// The logical device, creating it on first call and returning the same one
238 /// afterwards.
239 ///
240 /// Adapter selection goes through
241 /// `wgpu::util::initialize_adapter_from_env_or_default`, so
242 /// `WGPU_ADAPTER_NAME`/`WGPU_POWER_PREF` pick the adapter on a host with
243 /// more than one — the only way to pin a specific GPU on a multi-adapter
244 /// machine.
245 ///
246 /// # Errors
247 ///
248 /// When no adapter is available at all, or when the device request the
249 /// adapter's own limits were computed for is nonetheless refused. Both are
250 /// terminal for GPU rendering; neither is retryable by calling again.
251 pub async fn device(&mut self) -> Result<&DeviceHandle> {
252 self.ensure_device_headless().await?;
253 Ok(self
254 .device
255 .as_ref()
256 .expect("device was just created or already present"))
257 }
258
259 /// What the live device's adapter reported, or `None` while the device is
260 /// still uncreated — capabilities are an adapter's answer, and no adapter
261 /// has been selected before the first device creation.
262 pub fn caps(&self) -> Option<&TierCaps> {
263 self.device.as_ref().map(|handle| &handle.caps)
264 }
265
266 /// Whether the live device was created with `wgpu::Features::PIPELINE_CACHE`.
267 ///
268 /// wgpu only implements the persisted pipeline cache on Vulkan — every
269 /// Vulkan adapter advertises it (Android, Linux, Windows-on-Vulkan);
270 /// Metal and DX12 adapters never do, so it is absent there and
271 /// [`create_pipeline_cache`](Self::create_pipeline_cache) returns `None` —
272 /// the renderer then behaves exactly as it did before this path existed.
273 /// Panics if no surface (and thus no device) has been created yet.
274 pub fn pipeline_cache_supported(&self) -> bool {
275 self.device_handle()
276 .device
277 .features()
278 .contains(wgpu::Features::PIPELINE_CACHE)
279 }
280
281 /// The adapter fingerprint a persisted pipeline-cache blob is tagged with
282 /// (see [`crate::pipeline_cache`]). Panics if no device has been created yet.
283 pub fn adapter_cache_key(&self) -> String {
284 crate::pipeline_cache::adapter_cache_key(&self.device_handle().adapter.get_info())
285 }
286
287 /// Creates a `wgpu::PipelineCache` for the live device, seeded from a
288 /// previously persisted, framed `blob` when it validates for this adapter.
289 ///
290 /// Returns `None` when the device lacks `PIPELINE_CACHE` support (Metal/
291 /// desktop) — the renderer then runs its original, cache-less path. A `blob`
292 /// that fails framing/adapter validation
293 /// ([`crate::pipeline_cache::unframe`]) is discarded and the cache starts
294 /// empty; a `None` `blob` is a cold start.
295 ///
296 /// Hidden from the rendered docs for the same reason as [`Self::instance`].
297 ///
298 /// # Safety
299 ///
300 /// This is the sole sanctioned unsafe site in this module, and the
301 /// obligation is the caller's, not something this method can fully close
302 /// on its own: `unframe`'s magic-tag-plus-adapter-fingerprint check proves
303 /// only that `blob` was framed by `frust-gpu`'s own framing for *this*
304 /// adapter — provenance by convention, not a proof of the actual wgpu
305 /// contract on [`wgpu::Device::create_pipeline_cache`], which requires a
306 /// non-`None` `data` to have come from a prior `PipelineCache::get_data()`
307 /// on a `pipeline_cache_key`-compatible adapter. The caller must ensure
308 /// `blob` is exactly that: a blob previously produced by this driver's own
309 /// pipeline-cache output for this adapter, as persisted by `frust-render`'s
310 /// caching layer (`SurfaceRenderer::pipeline_cache_data`/
311 /// `set_initial_pipeline_cache_data`). A forged blob that nonetheless
312 /// passes the framing/fingerprint check is undefined behaviour per wgpu's
313 /// contract — `fallback: true` only covers a residual *internal* mismatch
314 /// wgpu itself detects, not a blob that misleads it into misbehaving.
315 #[doc(hidden)]
316 pub unsafe fn create_pipeline_cache(&self, blob: Option<&[u8]>) -> Option<wgpu::PipelineCache> {
317 if !self.pipeline_cache_supported() {
318 return None;
319 }
320 let handle = self.device_handle();
321 let key = crate::pipeline_cache::adapter_cache_key(&handle.adapter.get_info());
322 let data = blob.and_then(|b| crate::pipeline_cache::unframe(b, &key));
323 log::debug!(
324 "frust-gpu: creating wgpu PipelineCache (seed: {})",
325 if data.is_some() {
326 "persisted blob"
327 } else {
328 "empty"
329 }
330 );
331 // SAFETY: see this method's `# Safety` section — `data` has already been
332 // validated against this adapter's fingerprint by `unframe`, and
333 // `fallback: true` turns any residual internal mismatch into a
334 // fall-back-to-empty cache rather than UB.
335 let cache = unsafe {
336 handle
337 .device
338 .create_pipeline_cache(&wgpu::PipelineCacheDescriptor {
339 label: Some("frust-gpu pipeline cache"),
340 data,
341 fallback: true,
342 })
343 };
344 Some(cache)
345 }
346
347 /// Lazily create the logical device compatible with `surface`, requesting
348 /// the adapter's own limits (never `Limits::default()`, which the iOS
349 /// Simulator cannot satisfy) plus the #7057 alignment mitigation. Reuses
350 /// an already-created device when it is compatible with `surface`, so
351 /// surface loss/recreation (rotation, backgrounding) never rebuilds it — and
352 /// so a device the [`ensure_device_headless`](Self::ensure_device_headless)
353 /// pre-init created before any surface existed is adopted here rather than
354 /// rebuilt.
355 ///
356 /// Hidden from the rendered docs for the same reason as [`Self::instance`]:
357 /// the renderer above calls it to have a device in hand before it sizes its
358 /// own per-surface attachments; nothing higher may.
359 #[doc(hidden)]
360 pub async fn ensure_device(&mut self, surface: &wgpu::Surface<'static>) -> Result<()> {
361 if let Some(existing) = &self.device
362 && existing.adapter.is_surface_supported(surface)
363 {
364 return Ok(());
365 }
366 self.device = Some(create_device(&self.instance, &self.options, Some(surface)).await?);
367 Ok(())
368 }
369
370 /// Create the logical device **before any surface exists**, so the wgpu
371 /// instance/adapter/device bring-up can run on a
372 /// background thread kicked at native-library load (`JNI_OnLoad`) and be
373 /// joined by `nativeInit` instead of running serially after `surfaceCreated`.
374 /// Idempotent: a no-op when a device already exists.
375 ///
376 /// # Android singular-adapter assumption
377 ///
378 /// Requesting the adapter with `compatible_surface: None` picks wgpu's
379 /// default adapter rather than one filtered to a specific surface. On Android
380 /// the Vulkan backend exposes a single physical device, so the adapter chosen
381 /// here is the same one a later surface-filtered request would pick, and
382 /// [`ensure_device`](Self::ensure_device)'s `is_surface_supported` reuse check
383 /// accepts it — the surface created at `nativeInit` reuses this device with no
384 /// rebuild. On a hypothetical multi-adapter device where the pre-init adapter
385 /// did *not* support the eventual surface, `ensure_device` simply rebuilds the
386 /// device against that surface (still correct, just without the overlap win).
387 /// This is the sole production caller that passes `None` below; desktop/iOS
388 /// create their device through the surface path and never invoke it.
389 pub async fn ensure_device_headless(&mut self) -> Result<()> {
390 if self.device.is_some() {
391 return Ok(());
392 }
393 self.device = Some(create_device(&self.instance, &self.options, None).await?);
394 Ok(())
395 }
396
397 /// Builds a configured [`ConfiguredSurface`] from a raw wgpu `Surface`,
398 /// creating the logical device if needed.
399 ///
400 /// The result is renderer-agnostic: the swapchain, the configuration it was
401 /// brought up with, and the resolved alpha facts. Whatever per-frame render
402 /// path a renderer pairs with it is that renderer's own business — see
403 /// `frust_render::context`'s `EngineSurface`.
404 ///
405 /// Hidden from the rendered docs for the same reason as [`Self::instance`].
406 #[doc(hidden)]
407 pub async fn create_render_surface(
408 &mut self,
409 surface: wgpu::Surface<'static>,
410 width: u32,
411 height: u32,
412 present_mode: wgpu::PresentMode,
413 alpha: SurfaceAlphaRequest,
414 ) -> Result<ConfiguredSurface> {
415 self.ensure_device(&surface).await?;
416 let handle = self.device_handle();
417
418 let capabilities = surface.get_capabilities(&handle.adapter);
419 // Resolved from the request and the surface's reported caps alone; the
420 // renderer above reads it back off the returned configuration to pick
421 // its own per-frame path.
422 let alpha_mode = resolve_alpha_mode(alpha, &capabilities);
423 // Whichever supported format the surface reports FIRST — the surface's
424 // own preference order, not this module's (see `select_surface_format`).
425 let format = select_surface_format(&capabilities)?;
426
427 Ok(ConfiguredSurface::configure(
428 surface,
429 &handle.device,
430 format,
431 alpha_mode,
432 (width, height),
433 present_mode,
434 ))
435 }
436}
437
438/// Selects an adapter, probes it, and requests the logical device — the body
439/// both [`RenderContext::ensure_device`] and
440/// [`RenderContext::ensure_device_headless`] share.
441///
442/// `compatible_surface` filters adapter selection to one that can present to the
443/// given surface; `None` (the pre-init path, and every headless caller) selects
444/// wgpu's default adapter — see the singular-adapter note on
445/// `ensure_device_headless`. The capability probe, limits mitigation, feature
446/// request and uncaptured-error handler are identical either way: the surface
447/// only ever affected adapter selection, never the device it yields.
448///
449/// A free function rather than a method so it borrows the instance and options
450/// separately from the `device` field the callers assign into.
451async fn create_device(
452 instance: &wgpu::Instance,
453 options: &ContextOptions,
454 compatible_surface: Option<&wgpu::Surface<'static>>,
455) -> Result<DeviceHandle> {
456 let adapter = wgpu::util::initialize_adapter_from_env_or_default(instance, compatible_surface)
457 .await
458 .map_err(|e| anyhow!("frust-gpu: no compatible GPU adapter: {e}"))?;
459
460 let caps = TierCaps::probe(&adapter);
461
462 // The device request is built from the resolved downlevel profile, not
463 // unconditionally from the adapter's raw limits — see `base_device_limits`
464 // for the shared derivation, and `effective_limits` for the iOS Simulator
465 // alignment mitigation layered on top of it.
466 let base_limits = base_device_limits(caps.downlevel_profile, adapter.limits());
467 let required_limits = effective_limits(base_limits, is_ios_simulator());
468 let required_features =
469 device_features(adapter.features(), &caps, cfg!(feature = "perf-trace"));
470
471 let (device, queue) = adapter
472 .request_device(&wgpu::DeviceDescriptor {
473 label: Some(&options.device_label),
474 required_features,
475 required_limits,
476 ..Default::default()
477 })
478 .await
479 .map_err(|e| anyhow!("frust-gpu: failed to create GPU device: {e}"))?;
480
481 // Route wgpu's uncaptured errors to the log and a latch instead of its
482 // default handler, which aborts the process by panicking ("handling wgpu
483 // errors as fatal by default"). A UI framework must survive a driver's
484 // *transient* GPU error and recover on a later frame rather than crash —
485 // e.g. the Android emulator's SwiftShader path can raise a one-off
486 // swapchain-acquire validation error under load, which used to wedge the
487 // app into a per-frame panic loop (the surface stayed ready and every
488 // subsequent render re-hit the fatal handler). Pairing this with the
489 // `Invalid`-acquire → reconfigure recovery (see [`crate::lifecycle`]) lets
490 // the swapchain rebuild and rendering resume. Across a mobile FFI boundary
491 // the default handler's abort means killing the host app outright. Genuine
492 // API misuse is still surfaced — loudly, at error level — just without
493 // killing the process.
494 //
495 // Two pieces of state, both per-device (captured fresh each time this
496 // closure is installed, i.e. once per logical device) and both behind `Arc`
497 // because `on_uncaptured_error`'s handler must be `Fn`, not `FnMut`:
498 //
499 // - `error_count` drives the log latch ([`decide_log_action`]), so a device
500 // wedged in a genuine per-frame error storm (as opposed to a one-off
501 // driver hiccup) cannot flood the log forever.
502 // - `first_error` latches the first error's text for the frame loop to
503 // poll via [`DeviceHandle::first_uncaptured_error`]. `OnceLock` gives
504 // exactly first-write-wins with no lock held across the handler body.
505 let error_count = Arc::new(AtomicU32::new(0));
506 let first_error: Arc<OnceLock<String>> = Arc::new(OnceLock::new());
507 let latch = Arc::clone(&first_error);
508 device.on_uncaptured_error(Arc::new(move |error| {
509 let count = error_count.fetch_add(1, Ordering::Relaxed) + 1;
510 let _ = latch.set(error.to_string());
511 match decide_log_action(count) {
512 LogAction::Log => {
513 log::error!("frust-gpu: uncaptured wgpu error: {error}");
514 }
515 LogAction::SuppressionNotice => {
516 log::error!(
517 "frust-gpu: further uncaptured wgpu errors suppressed \
518 (total so far: {count})"
519 );
520 }
521 LogAction::Silent { debug_bump } => {
522 if debug_bump {
523 log::debug!(
524 "frust-gpu: uncaptured wgpu error count now {count} \
525 (still suppressed)"
526 );
527 }
528 }
529 }
530 }));
531
532 Ok(DeviceHandle {
533 adapter,
534 device,
535 queue,
536 caps,
537 first_uncaptured_error: first_error,
538 })
539}
540
541/// Given the resolved downlevel profile and the adapter's own reported
542/// limits, the `wgpu::Limits` a device request should ask for — the pure half
543/// of the derivation this module's `create_device` uses, extracted so both it
544/// and [`test_device_limits`] share exactly one implementation rather than two
545/// that could drift apart.
546///
547/// Under `DownlevelProfile::WebGl2` (a real `Gl` backend, or
548/// `FRUST_ENGINE_DOWNLEVEL=1` rehearsing it) this asks for the GLES-3.0/WebGL2
549/// downlevel default shape — or the override would only relabel a full
550/// desktop device rather than actually exercising it — with
551/// `using_resolution` folding in `adapter_limits`' own texture-dimension
552/// limits so the request never asks for a resolution the adapter cannot
553/// satisfy (the swapchain may need more than the downlevel default allows)
554/// while every other WebGL2 default limit is requested as-is. Under
555/// `DownlevelProfile::Full` — every shipping device — `adapter_limits` is
556/// returned unchanged: feeding the *adapter's* limits (rather than
557/// `Limits::default()`) is what keeps the request from over-asking and
558/// failing on a constrained mobile adapter — or on the iOS Simulator, whose
559/// macOS-Metal-backed device refuses `Limits::default()` outright.
560///
561/// Pure decision logic over plain values, mirroring [`effective_limits`]'s
562/// split of pure decision vs. platform lookup: `adapter_limits` is already
563/// `wgpu::Adapter::limits()`'s output by the time this runs, so the function
564/// itself needs no adapter and is unit-testable on any host with no GPU.
565fn base_device_limits(profile: DownlevelProfile, adapter_limits: wgpu::Limits) -> wgpu::Limits {
566 if profile == DownlevelProfile::WebGl2 {
567 wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter_limits)
568 } else {
569 adapter_limits
570 }
571}
572
573/// The `wgpu::Limits` a test fixture's own `request_device` call should ask
574/// for, given `adapter` and its already-probed `caps` — exactly the
575/// derivation [`create_device`] uses for the production device request
576/// (shared via [`base_device_limits`] plus [`effective_limits`]), so a
577/// fixture requesting these limits can never over-ask relative to what the
578/// production path would request for the very same adapter.
579///
580/// This is the fix for the iOS Simulator's constrained Apple2 Metal profile
581/// (15 inter-stage shader variables, where `wgpu::Limits::default()` demands
582/// 16): a fixture that hard-codes `Limits::default()` panics with
583/// `LimitsExceeded` there even though the production path — which always
584/// requests the adapter's own limits — never would. Every fixture already
585/// probes `TierCaps::probe(&adapter)` before requesting its device, so `caps`
586/// is available at the same call site this replaces.
587///
588/// Public (not test-only/`#[cfg(test)]`) because the fixtures that need it
589/// live in other crates' `tests/` integration binaries and
590/// `frust-testing`'s own `EngineOracle`, none of which can reach a
591/// `#[cfg(test)]` item in this crate.
592pub fn test_device_limits(adapter: &wgpu::Adapter, caps: &TierCaps) -> wgpu::Limits {
593 let base_limits = base_device_limits(caps.downlevel_profile, adapter.limits());
594 effective_limits(base_limits, is_ios_simulator())
595}
596
597/// Given the build-config-derived instance flags and whether the process is
598/// currently running on an Android emulator, decides the flags wgpu's
599/// `Instance` should actually be created with.
600///
601/// The `DEBUG` flag makes wgpu enable `VK_EXT_debug_utils` and set object-name
602/// labels via `vkSetDebugUtilsObjectNameEXT`, and the emulator's gfxstream
603/// Vulkan HAL (`vulkan.ranchu.so`) segfaults inside that entry point during
604/// adapter enumeration (observed crash: `#00 vulkan.ranchu.so
605/// vk_common_SetDebugUtilsObjectNameEXT`) — the same class of debug-utils
606/// fragility a MoltenVK Vulkan backend is also known to have. Debug object
607/// labels are only a developer convenience, so dropping them on the emulator
608/// keeps GPU bring-up alive there while leaving physical devices' validation
609/// safety net — and desktop behaviour — untouched.
610///
611/// Pure decision logic, kept separate from the platform property lookup in
612/// `is_android_emulator` so it is unit-testable on any host without an Android
613/// target.
614#[cfg_attr(not(target_os = "android"), allow(dead_code))]
615fn effective_instance_flags(flags: wgpu::InstanceFlags, is_emulator: bool) -> wgpu::InstanceFlags {
616 if is_emulator {
617 flags - (wgpu::InstanceFlags::DEBUG | wgpu::InstanceFlags::VALIDATION)
618 } else {
619 flags
620 }
621}
622
623/// Detects whether the current process is running on an Android emulator
624/// (goldfish/ranchu), as opposed to a physical device, via the standard
625/// `ro.kernel.qemu` system property (`"1"` on emulators, unset/absent on real
626/// hardware). A failed property read is treated as "not an emulator" so
627/// physical devices — and any environment where the property cannot be read —
628/// default to keeping validation on.
629#[cfg(target_os = "android")]
630fn is_android_emulator() -> bool {
631 android_system_properties::AndroidSystemProperties::new()
632 .get("ro.kernel.qemu")
633 .as_deref()
634 == Some("1")
635}
636
637/// The uniform-buffer offset alignment the iOS Simulator's Metal validation
638/// actually enforces, regardless of what the adapter reports.
639const IOS_SIMULATOR_MIN_UNIFORM_BUFFER_OFFSET_ALIGNMENT: u32 = 256;
640
641/// Given a base `wgpu::Limits` and whether the process is currently running on
642/// an iOS Simulator, decides the `Limits` a device request should actually use.
643///
644/// Mitigates [wgpu #7057](https://github.com/gfx-rs/wgpu/issues/7057): the iOS
645/// Simulator is macOS-Metal-backed and requires 256-byte
646/// `min_uniform_buffer_offset_alignment`, but wgpu's Metal backend reports the
647/// (lower) iOS-device value, which trips Metal API validation on the simulator.
648/// Physical iOS devices are unaffected and pass `base` through unchanged; a
649/// `base` whose alignment is already at or above 256 is left alone, never
650/// lowered.
651///
652/// Upstream [gfx-rs/wgpu PR #10189](https://github.com/gfx-rs/wgpu/pull/10189)
653/// makes this unnecessary — drop it once a pinned wgpu release contains it.
654///
655/// Pure decision logic, mirroring `effective_instance_flags`'s split of pure
656/// decision vs. platform lookup. It is fed the profile-resolved base limits
657/// (see this module's `create_device`) — the adapter's own limits under
658/// `DownlevelProfile::Full`, the WebGL2 downlevel defaults resolution-folded
659/// with the adapter otherwise — so the device request never over-asks.
660pub fn effective_limits(base: wgpu::Limits, is_ios_simulator: bool) -> wgpu::Limits {
661 if is_ios_simulator
662 && base.min_uniform_buffer_offset_alignment
663 < IOS_SIMULATOR_MIN_UNIFORM_BUFFER_OFFSET_ALIGNMENT
664 {
665 wgpu::Limits {
666 min_uniform_buffer_offset_alignment: IOS_SIMULATOR_MIN_UNIFORM_BUFFER_OFFSET_ALIGNMENT,
667 ..base
668 }
669 } else {
670 base
671 }
672}
673
674/// Whether this binary is running on the iOS Simulator
675/// (`aarch64-apple-ios-sim` / `x86_64-apple-ios` under the simulator), which
676/// sets `target_abi = "sim"`. Compile-time constant: the simulator mitigation
677/// only needs to apply to simulator builds, never physical-device or desktop
678/// ones.
679pub const fn is_ios_simulator() -> bool {
680 cfg!(all(target_os = "ios", target_abi = "sim"))
681}
682
683/// The `wgpu::Features` a device request opportunistically asks for when the
684/// adapter exposes them.
685///
686/// `PIPELINE_CACHE` alone: it is what
687/// [`create_pipeline_cache`](RenderContext::create_pipeline_cache) needs to
688/// seed the renderer's shader-pipeline compilation from a persisted blob. An
689/// optional feature is only ever *added* when the adapter already offers it,
690/// so this can never turn a working adapter into a failed device request.
691pub fn optional_device_features() -> wgpu::Features {
692 wgpu::Features::PIPELINE_CACHE
693}
694
695/// The `wgpu::Features` a device request must genuinely *require*, given what
696/// the adapter reported ([`TierCaps`]) and whether this build compiled the
697/// `perf-trace` feature in.
698///
699/// The policy is deliberately minimal: **empty** by default. A required feature
700/// is a hard device-creation failure on any adapter lacking it, so asking for
701/// something the crate does not actually need converts a working device into no
702/// device at all. `TIMESTAMP_QUERY` is the single exception — it is what the
703/// GPU timing probes ([`crate::diag::TimestampRing`]) are built on, so a
704/// `perf-trace` build asks for it, and even then only when the adapter offers
705/// it. A device that did not get the feature leaves the ring inert (`gpu_q=0`),
706/// exactly as a build without `perf-trace` does.
707///
708/// A plain `bool` parameter rather than reading `cfg!` internally, so both
709/// branches are unit-testable regardless of which features this crate was
710/// compiled with.
711fn required_features(caps: &TierCaps, perf_trace: bool) -> wgpu::Features {
712 if perf_trace && caps.has_timestamp_query {
713 wgpu::Features::TIMESTAMP_QUERY
714 } else {
715 wgpu::Features::empty()
716 }
717}
718
719/// The complete `required_features` set a device request is made with: the
720/// opportunistic set ([`optional_device_features`]) narrowed to what
721/// `adapter_features` actually offers, plus the genuinely required set
722/// ([`required_features`]).
723///
724/// Both halves are adapter-conditioned, so this can never turn a working
725/// adapter into a failed device request — which is why the policy is split out
726/// of [`create_device`] as a pure function over plain values.
727fn device_features(
728 adapter_features: wgpu::Features,
729 caps: &TierCaps,
730 perf_trace: bool,
731) -> wgpu::Features {
732 (adapter_features & optional_device_features()) | required_features(caps, perf_trace)
733}
734
735/// Number of uncaptured `wgpu` errors logged at error level per device before
736/// the handler latches into suppression. A single flaky frame under a driver
737/// hiccup (e.g. the Android emulator's SwiftShader path) is expected to surface
738/// a handful of errors; past this the process is either wedged in a genuine
739/// per-frame error storm or the driver is fundamentally broken, and re-logging
740/// every single one would flood the log without adding information.
741const MAX_LOGGED_UNCAPTURED_ERRORS: u32 = 5;
742
743/// How often (in error count) a latched handler bumps a debug-level "still
744/// happening" line once past [`MAX_LOGGED_UNCAPTURED_ERRORS`] and the one
745/// suppression notice. Debug level (not error) because this is diagnostic noise
746/// for someone actively investigating, not an actionable signal.
747const UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD: u32 = 100;
748
749/// What the uncaptured-error handler should do for the `count`-th uncaptured
750/// error (1-indexed) it has observed on a given device.
751///
752/// Also the latch the renderer above reuses for its own per-frame event that
753/// can reproduce every vsync (an engine frame refusal), so the two report at
754/// the same cadence.
755#[derive(Clone, Copy, Debug, PartialEq, Eq)]
756pub enum LogAction {
757 /// One of the first `MAX_LOGGED_UNCAPTURED_ERRORS`: log the error itself
758 /// at error level.
759 Log,
760 /// The first error past the cap: log one suppression notice (naming the
761 /// running total) instead of the error itself.
762 SuppressionNotice,
763 /// Past the cap and past the suppression notice: stay silent, except a
764 /// periodic debug-level count bump when `debug_bump` is set.
765 Silent { debug_bump: bool },
766}
767
768/// Pure latch policy for the uncaptured-error handler (see [`LogAction`]).
769///
770/// Split out of the handler closure in this module's `create_device` so the
771/// discipline — log the first few, announce the latch once, then go quiet
772/// except an occasional debug bump — is unit-testable without a GPU or a real
773/// `wgpu::Error`.
774pub fn decide_log_action(count: u32) -> LogAction {
775 if count <= MAX_LOGGED_UNCAPTURED_ERRORS {
776 LogAction::Log
777 } else if count == MAX_LOGGED_UNCAPTURED_ERRORS + 1 {
778 LogAction::SuppressionNotice
779 } else {
780 LogAction::Silent {
781 debug_bump: count.is_multiple_of(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD),
782 }
783 }
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789 use crate::caps::DownlevelProfile;
790
791 #[test]
792 fn emulator_strips_debug_and_validation() {
793 let build_flags = wgpu::InstanceFlags::DEBUG | wgpu::InstanceFlags::VALIDATION;
794 let flags = effective_instance_flags(build_flags, true);
795 assert!(!flags.contains(wgpu::InstanceFlags::DEBUG));
796 assert!(!flags.contains(wgpu::InstanceFlags::VALIDATION));
797 }
798
799 #[test]
800 fn physical_device_keeps_debug_and_validation() {
801 let build_flags = wgpu::InstanceFlags::DEBUG | wgpu::InstanceFlags::VALIDATION;
802 let flags = effective_instance_flags(build_flags, false);
803 assert!(flags.contains(wgpu::InstanceFlags::DEBUG));
804 assert!(flags.contains(wgpu::InstanceFlags::VALIDATION));
805 }
806
807 #[test]
808 fn emulator_with_no_debug_flags_stays_empty() {
809 let flags = effective_instance_flags(wgpu::InstanceFlags::empty(), true);
810 assert!(flags.is_empty());
811 }
812
813 #[test]
814 fn ios_simulator_bumps_alignment_to_256() {
815 let base = wgpu::Limits::default();
816 let limits = effective_limits(base.clone(), true);
817 assert_eq!(limits.min_uniform_buffer_offset_alignment, 256);
818 // Nothing else about the base limits should change.
819 assert_eq!(
820 wgpu::Limits {
821 min_uniform_buffer_offset_alignment: base.min_uniform_buffer_offset_alignment,
822 ..limits.clone()
823 },
824 base
825 );
826 }
827
828 #[test]
829 fn non_simulator_leaves_limits_untouched() {
830 let base = wgpu::Limits::default();
831 let limits = effective_limits(base.clone(), false);
832 assert_eq!(limits, base);
833 }
834
835 #[test]
836 fn base_already_at_or_above_256_is_not_lowered() {
837 let base = wgpu::Limits {
838 min_uniform_buffer_offset_alignment: 512,
839 ..wgpu::Limits::default()
840 };
841 let limits = effective_limits(base.clone(), true);
842 assert_eq!(limits.min_uniform_buffer_offset_alignment, 512);
843 assert_eq!(limits, base);
844 }
845
846 #[test]
847 fn simulator_alignment_uses_real_adapter_limits_not_defaults() {
848 // A low-alignment adapter (the #7057 shape: Metal reports a lower
849 // alignment than the simulator driver actually enforces) is bumped to
850 // 256 while every other adapter-reported limit is preserved — the
851 // whole point of feeding *adapter* limits rather than `Limits::default`.
852 let adapter = wgpu::Limits {
853 min_uniform_buffer_offset_alignment: 64,
854 max_texture_dimension_2d: 4096,
855 ..wgpu::Limits::default()
856 };
857 let limits = effective_limits(adapter.clone(), true);
858 assert_eq!(limits.min_uniform_buffer_offset_alignment, 256);
859 assert_eq!(limits.max_texture_dimension_2d, 4096);
860 }
861
862 #[test]
863 fn first_n_uncaptured_errors_log() {
864 for count in 1..=MAX_LOGGED_UNCAPTURED_ERRORS {
865 assert_eq!(
866 decide_log_action(count),
867 LogAction::Log,
868 "expected Log at count={count}"
869 );
870 }
871 }
872
873 #[test]
874 fn nplus1_uncaptured_error_suppresses() {
875 assert_eq!(
876 decide_log_action(MAX_LOGGED_UNCAPTURED_ERRORS + 1),
877 LogAction::SuppressionNotice
878 );
879 }
880
881 #[test]
882 fn further_uncaptured_errors_stay_silent_between_debug_bumps() {
883 let past_notice = MAX_LOGGED_UNCAPTURED_ERRORS + 2;
884 assert_eq!(
885 decide_log_action(past_notice),
886 LogAction::Silent { debug_bump: false }
887 );
888 }
889
890 #[test]
891 fn uncaptured_error_debug_bump_is_periodic() {
892 assert_eq!(
893 decide_log_action(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD),
894 LogAction::Silent { debug_bump: true }
895 );
896 assert_eq!(
897 decide_log_action(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD * 2),
898 LogAction::Silent { debug_bump: true }
899 );
900 assert_eq!(
901 decide_log_action(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD + 1),
902 LogAction::Silent { debug_bump: false }
903 );
904 }
905
906 #[test]
907 fn default_build_requires_no_device_features() {
908 let caps = TierCaps::fake(DownlevelProfile::Full);
909 assert!(caps.has_timestamp_query, "fixture precondition");
910 assert_eq!(required_features(&caps, false), wgpu::Features::empty());
911 }
912
913 #[test]
914 fn perf_trace_build_requires_timestamp_query_when_offered() {
915 let caps = TierCaps::fake(DownlevelProfile::Full);
916 assert_eq!(
917 required_features(&caps, true),
918 wgpu::Features::TIMESTAMP_QUERY
919 );
920 }
921
922 #[test]
923 fn perf_trace_build_requires_nothing_when_adapter_lacks_timestamp_query() {
924 // An adapter that cannot do timestamp queries must still yield a
925 // device: a required feature it lacks would fail the request outright,
926 // so `perf-trace` degrades to no probes rather than to no GPU.
927 let caps = TierCaps::fake(DownlevelProfile::WebGl2);
928 assert!(!caps.has_timestamp_query, "fixture precondition");
929 assert_eq!(required_features(&caps, true), wgpu::Features::empty());
930 }
931
932 #[test]
933 fn pipeline_cache_is_requested_whenever_the_adapter_offers_it() {
934 // The shipped Android/Vulkan warm-start path: `PIPELINE_CACHE` is
935 // opportunistically added, which is what makes
936 // `RenderContext::pipeline_cache_supported` true there and the
937 // persisted-blob seed possible at all. Dropping it would silently kill
938 // the warm start on every Vulkan adapter.
939 let caps = TierCaps::fake(DownlevelProfile::Full);
940 let adapter = wgpu::Features::PIPELINE_CACHE | wgpu::Features::DEPTH_CLIP_CONTROL;
941 assert_eq!(
942 device_features(adapter, &caps, false),
943 wgpu::Features::PIPELINE_CACHE,
944 "an offered optional feature is taken, and nothing else is"
945 );
946 }
947
948 #[test]
949 fn an_adapter_without_pipeline_cache_is_never_asked_for_it() {
950 // Metal/DX12: the feature is absent, so the request must not name it —
951 // a required feature the adapter lacks is a hard device-creation
952 // failure, i.e. no GPU at all rather than merely no persisted cache.
953 let caps = TierCaps::fake(DownlevelProfile::Full);
954 assert_eq!(
955 device_features(wgpu::Features::empty(), &caps, false),
956 wgpu::Features::empty()
957 );
958 }
959
960 #[test]
961 fn a_perf_trace_build_asks_for_both_halves_when_both_are_offered() {
962 let caps = TierCaps::fake(DownlevelProfile::Full);
963 assert!(caps.has_timestamp_query, "fixture precondition");
964 assert_eq!(
965 device_features(wgpu::Features::PIPELINE_CACHE, &caps, true),
966 wgpu::Features::PIPELINE_CACHE | wgpu::Features::TIMESTAMP_QUERY
967 );
968 }
969
970 #[test]
971 fn base_device_limits_full_profile_passes_adapter_limits_through_unclamped() {
972 // The iOS Simulator shape: an adapter reporting fewer inter-stage
973 // shader variables than `wgpu::Limits::default()` demands (15 vs 16).
974 // Under `Full`, `base_device_limits` must request no more than what
975 // the adapter itself reported, never `Limits::default()`.
976 let adapter_limits = wgpu::Limits {
977 max_inter_stage_shader_variables: 15,
978 ..wgpu::Limits::default()
979 };
980 let limits = base_device_limits(DownlevelProfile::Full, adapter_limits.clone());
981 assert!(limits.max_inter_stage_shader_variables <= 15);
982 assert_eq!(limits, adapter_limits);
983 }
984
985 #[test]
986 fn base_device_limits_webgl2_profile_never_exceeds_the_constrained_adapter() {
987 // The WebGL2 downlevel-default shape already asks for 15 inter-stage
988 // shader variables (lower than `Limits::default()`'s 16), so folding
989 // in a constrained adapter's own limits must still land at or below
990 // what that adapter reports.
991 let adapter_limits = wgpu::Limits {
992 max_inter_stage_shader_variables: 15,
993 ..wgpu::Limits::default()
994 };
995 let limits = base_device_limits(DownlevelProfile::WebGl2, adapter_limits);
996 assert!(limits.max_inter_stage_shader_variables <= 15);
997 }
998
999 #[test]
1000 fn default_options_label_the_device_and_leave_backends_to_the_environment() {
1001 let options = ContextOptions::default();
1002 assert_eq!(options.device_label, DEFAULT_DEVICE_LABEL);
1003 assert_eq!(options.backends, None);
1004 }
1005
1006 #[test]
1007 fn a_fresh_context_has_no_device_and_therefore_no_caps() {
1008 // Construction must not enumerate adapters, so this is a host test, not
1009 // a GPU one: it passes on a machine with no usable GPU at all.
1010 let context = RenderContext::new();
1011 assert!(context.caps().is_none());
1012 }
1013
1014 #[test]
1015 #[ignore = "needs a real GPU adapter; run with `cargo test -p frust-gpu -- --ignored` \
1016 (pin the adapter on a multi-GPU host with WGPU_BACKEND / WGPU_ADAPTER_NAME)"]
1017 fn gpu_device_creation_reports_adapter_caps() {
1018 pollster::block_on(async {
1019 let mut context = RenderContext::new();
1020 let handle = context.device().await.expect("device creation");
1021
1022 let info = handle.adapter.get_info();
1023 println!(
1024 "frust-gpu adapter: name={:?} backend={:?} device_type={:?} \
1025 driver={:?} driver_info={:?} vendor={:#06x} device={:#06x}",
1026 info.name,
1027 info.backend,
1028 info.device_type,
1029 info.driver,
1030 info.driver_info,
1031 info.vendor,
1032 info.device
1033 );
1034 println!("frust-gpu caps: {:#?}", handle.caps);
1035 println!(
1036 "frust-gpu device limits: max_texture_dimension_2d={} \
1037 min_uniform_buffer_offset_alignment={}",
1038 handle.device.limits().max_texture_dimension_2d,
1039 handle.device.limits().min_uniform_buffer_offset_alignment
1040 );
1041
1042 assert_eq!(handle.caps.adapter_name, info.name);
1043 assert_eq!(handle.caps.backend, info.backend);
1044 assert!(!handle.caps.adapter_name.is_empty());
1045 assert!(handle.caps.max_texture_dimension_2d > 0);
1046 assert!(handle.caps.resource_texture_dim > 0);
1047 // Nothing has been submitted, so the latch must still be empty.
1048 assert_eq!(handle.first_uncaptured_error(), None);
1049
1050 // With `FRUST_ENGINE_DOWNLEVEL=1` set for the whole test process
1051 // (the override is read once and cached in a `OnceLock`), both the
1052 // probed caps and the created device must actually report the
1053 // clamped WebGL2 shape rather than a desktop backend merely
1054 // relabelled `WebGl2`. Without it, this rig's real backend
1055 // (Vulkan/Metal/Dx12) must report `Full` unchanged, proving the
1056 // knob rehearses the downlevel shape rather than always forcing
1057 // it.
1058 let downlevel_env_set = std::env::var("FRUST_ENGINE_DOWNLEVEL").is_ok_and(|v| v != "0");
1059 if downlevel_env_set {
1060 assert_eq!(handle.caps.downlevel_profile, DownlevelProfile::WebGl2);
1061 assert!(!handle.caps.has_storage_buffers);
1062 assert!(handle.caps.max_texture_dimension_2d <= 2048);
1063 assert_eq!(handle.caps.min_uniform_buffer_offset_alignment, 256);
1064 assert!(handle.device.limits().max_texture_dimension_2d > 0);
1065 } else {
1066 assert_eq!(handle.caps.downlevel_profile, DownlevelProfile::Full);
1067 }
1068
1069 let caps = context.caps().cloned().expect("caps after device creation");
1070 assert_eq!(caps.adapter_name, info.name);
1071
1072 // The device is created once and reused: a second call must hand
1073 // back the same logical device, not build another one.
1074 let again = context.device().await.expect("device reuse");
1075 assert_eq!(again.caps, caps);
1076 });
1077 }
1078}