Skip to main content

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