Skip to main content

stygian_browser/
webgl_noise.rs

1//! WebGL parameter spoofing and readPixels noise injection.
2//!
3//! Overrides `WebGL1` and `WebGL2` APIs to present a coherent, session-unique GPU
4//! identity and apply deterministic noise to `readPixels()` output.
5//!
6//! # Example
7//!
8//! ```
9//! use stygian_browser::webgl_noise::{webgl_noise_script, WebGlProfile};
10//! use stygian_browser::noise::{NoiseEngine, NoiseSeed};
11//!
12//! let engine = NoiseEngine::new(NoiseSeed::from(42_u64));
13//! let js = webgl_noise_script(&WebGlProfile::nvidia_rtx_3060(), &engine);
14//! assert!(js.contains("RTX 3060"));
15//! assert!(js.contains("__stygian_webgl_noise"));
16//! ```
17
18use serde::{Deserialize, Serialize};
19
20use crate::noise::NoiseEngine;
21
22// ---------------------------------------------------------------------------
23// ShaderPrecisionProfile
24// ---------------------------------------------------------------------------
25
26/// Shader precision format values for a GPU profile.
27///
28/// Matches the structure returned by `getShaderPrecisionFormat()`.
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct ShaderPrecisionProfile {
31    /// High-float range exponent bits.
32    pub high_float_range_min: i32,
33    /// High-float range exponent bits.
34    pub high_float_range_max: i32,
35    /// High-float precision bits.
36    pub high_float_precision: i32,
37    /// Medium-float precision bits.
38    pub medium_float_precision: i32,
39    /// Low-float precision bits.
40    pub low_float_precision: i32,
41    /// High-int precision bits.
42    pub high_int_precision: i32,
43}
44
45impl Default for ShaderPrecisionProfile {
46    fn default() -> Self {
47        Self {
48            high_float_range_min: 127,
49            high_float_range_max: 127,
50            high_float_precision: 23,
51            medium_float_precision: 23,
52            low_float_precision: 23,
53            high_int_precision: 31,
54        }
55    }
56}
57
58// ---------------------------------------------------------------------------
59// ContextAttributes
60// ---------------------------------------------------------------------------
61
62/// WebGL context attributes returned by `getContextAttributes()`.
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64#[allow(clippy::struct_excessive_bools)] // mirrors the browser's WebGL `getContextAttributes` shape — the booleans are part of the public surface contract
65pub struct ContextAttributes {
66    /// Alpha channel enabled.
67    pub alpha: bool,
68    /// Anti-aliasing enabled.
69    pub antialias: bool,
70    /// Depth buffer enabled.
71    pub depth: bool,
72    /// Fail if major performance caveat.
73    pub fail_if_major_performance_caveat: bool,
74    /// Power preference.
75    pub power_preference: String,
76    /// Premultiplied alpha.
77    pub premultiplied_alpha: bool,
78    /// Preserve drawing buffer.
79    pub preserve_drawing_buffer: bool,
80    /// Stencil buffer.
81    pub stencil: bool,
82    /// Desynchronized.
83    pub desynchronized: bool,
84}
85
86impl Default for ContextAttributes {
87    fn default() -> Self {
88        Self {
89            alpha: true,
90            antialias: true,
91            depth: true,
92            fail_if_major_performance_caveat: false,
93            power_preference: "default".to_string(),
94            premultiplied_alpha: true,
95            preserve_drawing_buffer: false,
96            stencil: false,
97            desynchronized: false,
98        }
99    }
100}
101
102// ---------------------------------------------------------------------------
103// WebGlProfile
104// ---------------------------------------------------------------------------
105
106/// A complete WebGL device identity profile.
107///
108/// Used to present a consistent, plausible GPU identity to fingerprinting scripts.
109///
110/// # Example
111///
112/// ```
113/// use stygian_browser::webgl_noise::WebGlProfile;
114///
115/// let profile = WebGlProfile::nvidia_rtx_3060();
116/// assert!(profile.renderer.contains("RTX 3060"));
117/// assert!(profile.max_texture_size >= 16384);
118/// ```
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
120pub struct WebGlProfile {
121    /// `UNMASKED_VENDOR_WEBGL` / `getParameter(GL_VENDOR)` value.
122    pub vendor: String,
123    /// `UNMASKED_RENDERER_WEBGL` / `getParameter(GL_RENDERER)` value.
124    pub renderer: String,
125    /// `MAX_TEXTURE_SIZE` in pixels.
126    pub max_texture_size: u32,
127    /// `MAX_VIEWPORT_DIMS` as `[width, height]`.
128    pub max_viewport_dims: (u32, u32),
129    /// `MAX_RENDERBUFFER_SIZE`.
130    pub max_renderbuffer_size: u32,
131    /// `MAX_VERTEX_ATTRIBS`.
132    pub max_vertex_attribs: u32,
133    /// `MAX_VARYING_VECTORS`.
134    pub max_varying_vectors: u32,
135    /// `MAX_FRAGMENT_UNIFORM_VECTORS`.
136    pub max_fragment_uniform_vectors: u32,
137    /// `MAX_VERTEX_UNIFORM_VECTORS`.
138    pub max_vertex_uniform_vectors: u32,
139    /// Ordered list of supported WebGL extensions.
140    pub extensions: Vec<String>,
141    /// Shader precision format.
142    pub shader_precision: ShaderPrecisionProfile,
143    /// Context attributes.
144    pub context_attributes: ContextAttributes,
145}
146
147impl WebGlProfile {
148    /// Return the NVIDIA RTX 3060 profile with all fields populated.
149    ///
150    /// # Example
151    ///
152    /// ```
153    /// use stygian_browser::webgl_noise::WebGlProfile;
154    /// let p = WebGlProfile::nvidia_rtx_3060();
155    /// assert!(p.renderer.contains("RTX 3060"));
156    /// assert_eq!(p.max_texture_size, 16384);
157    /// ```
158    #[must_use]
159    pub fn nvidia_rtx_3060() -> Self {
160        Self {
161            vendor: "Google Inc. (NVIDIA)".to_string(),
162            renderer: "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)"
163                .to_string(),
164            max_texture_size: 16384,
165            max_viewport_dims: (32768, 32768),
166            max_renderbuffer_size: 16384,
167            max_vertex_attribs: 16,
168            max_varying_vectors: 15,
169            max_fragment_uniform_vectors: 1024,
170            max_vertex_uniform_vectors: 4096,
171            extensions: default_extensions(),
172            shader_precision: ShaderPrecisionProfile::default(),
173            context_attributes: ContextAttributes::default(),
174        }
175    }
176
177    /// Return the NVIDIA GTX 1660 profile.
178    ///
179    /// # Example
180    ///
181    /// ```
182    /// use stygian_browser::webgl_noise::WebGlProfile;
183    /// let p = WebGlProfile::nvidia_gtx_1660();
184    /// assert!(p.renderer.contains("GTX 1660"));
185    /// ```
186    #[must_use]
187    pub fn nvidia_gtx_1660() -> Self {
188        Self {
189            vendor: "Google Inc. (NVIDIA)".to_string(),
190            renderer:
191                "ANGLE (NVIDIA, NVIDIA GeForce GTX 1660 SUPER Direct3D11 vs_5_0 ps_5_0, D3D11)"
192                    .to_string(),
193            max_texture_size: 16384,
194            max_viewport_dims: (32768, 32768),
195            max_renderbuffer_size: 16384,
196            max_vertex_attribs: 16,
197            max_varying_vectors: 15,
198            max_fragment_uniform_vectors: 1024,
199            max_vertex_uniform_vectors: 4096,
200            extensions: default_extensions(),
201            shader_precision: ShaderPrecisionProfile::default(),
202            context_attributes: ContextAttributes::default(),
203        }
204    }
205
206    /// Return the AMD RX 6700 profile.
207    ///
208    /// # Example
209    ///
210    /// ```
211    /// use stygian_browser::webgl_noise::WebGlProfile;
212    /// let p = WebGlProfile::amd_rx_6700();
213    /// assert!(p.renderer.contains("RX 6700"));
214    /// ```
215    #[must_use]
216    pub fn amd_rx_6700() -> Self {
217        Self {
218            vendor: "Google Inc. (AMD)".to_string(),
219            renderer: "ANGLE (AMD, AMD Radeon RX 6700 XT Direct3D11 vs_5_0 ps_5_0, D3D11)"
220                .to_string(),
221            max_texture_size: 16384,
222            max_viewport_dims: (32768, 32768),
223            max_renderbuffer_size: 16384,
224            max_vertex_attribs: 16,
225            max_varying_vectors: 15,
226            max_fragment_uniform_vectors: 1024,
227            max_vertex_uniform_vectors: 4096,
228            extensions: default_extensions(),
229            shader_precision: ShaderPrecisionProfile::default(),
230            context_attributes: ContextAttributes::default(),
231        }
232    }
233
234    /// Return the Intel UHD 630 profile (integrated graphics).
235    ///
236    /// # Example
237    ///
238    /// ```
239    /// use stygian_browser::webgl_noise::WebGlProfile;
240    /// let p = WebGlProfile::intel_uhd_630();
241    /// assert!(p.renderer.contains("UHD Graphics 630"));
242    /// ```
243    #[must_use]
244    pub fn intel_uhd_630() -> Self {
245        Self {
246            vendor: "Google Inc. (Intel)".to_string(),
247            renderer: "ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0, D3D11)"
248                .to_string(),
249            max_texture_size: 8192,
250            max_viewport_dims: (16384, 16384),
251            max_renderbuffer_size: 8192,
252            max_vertex_attribs: 16,
253            max_varying_vectors: 15,
254            max_fragment_uniform_vectors: 1024,
255            max_vertex_uniform_vectors: 4096,
256            extensions: default_extensions(),
257            shader_precision: ShaderPrecisionProfile::default(),
258            context_attributes: ContextAttributes::default(),
259        }
260    }
261
262    /// Assert basic internal consistency: texture size ≤ viewport dims, etc.
263    ///
264    /// # Example
265    ///
266    /// ```
267    /// use stygian_browser::webgl_noise::WebGlProfile;
268    /// let p = WebGlProfile::nvidia_rtx_3060();
269    /// p.assert_consistent();
270    /// ```
271    ///
272    /// # Panics
273    ///
274    /// Panics if the profile's `max_texture_size` exceeds either axis of
275    /// `max_viewport_dims`, or if `max_viewport_dims.0` exceeds
276    /// `max_viewport_dims.1` — both indicate an internally inconsistent
277    /// profile and should be caught in tests.
278    pub fn assert_consistent(&self) {
279        assert!(
280            self.max_texture_size <= self.max_viewport_dims.0,
281            "max_texture_size must be <= max_viewport_dims.0"
282        );
283        assert!(
284            self.max_texture_size <= self.max_viewport_dims.1,
285            "max_texture_size must be <= max_viewport_dims.1"
286        );
287        assert!(
288            self.max_renderbuffer_size <= self.max_texture_size,
289            "max_renderbuffer_size must be <= max_texture_size"
290        );
291    }
292}
293
294fn default_extensions() -> Vec<String> {
295    [
296        "ANGLE_instanced_arrays",
297        "EXT_blend_minmax",
298        "EXT_clip_control",
299        "EXT_color_buffer_half_float",
300        "EXT_depth_clamp",
301        "EXT_disjoint_timer_query",
302        "EXT_float_blend",
303        "EXT_frag_depth",
304        "EXT_shader_texture_lod",
305        "EXT_texture_compression_bptc",
306        "EXT_texture_compression_rgtc",
307        "EXT_texture_filter_anisotropic",
308        "EXT_sRGB",
309        "KHR_parallel_shader_compile",
310        "OES_element_index_uint",
311        "OES_fbo_render_mipmap",
312        "OES_standard_derivatives",
313        "OES_texture_float",
314        "OES_texture_float_linear",
315        "OES_texture_half_float",
316        "OES_texture_half_float_linear",
317        "OES_vertex_array_object",
318        "WEBGL_color_buffer_float",
319        "WEBGL_compressed_texture_s3tc",
320        "WEBGL_compressed_texture_s3tc_srgb",
321        "WEBGL_debug_shaders",
322        "WEBGL_depth_texture",
323        "WEBGL_draw_buffers",
324        "WEBGL_lose_context",
325        "WEBGL_multi_draw",
326        "WEBGL_polygon_mode",
327    ]
328    .iter()
329    .map(|s| (*s).to_string())
330    .collect()
331}
332
333// ---------------------------------------------------------------------------
334// Script generation
335// ---------------------------------------------------------------------------
336
337/// Generate the WebGL noise injection script for a given profile and engine.
338///
339/// # Example
340///
341/// ```
342/// use stygian_browser::webgl_noise::{webgl_noise_script, WebGlProfile};
343/// use stygian_browser::noise::{NoiseEngine, NoiseSeed};
344///
345/// let js = webgl_noise_script(&WebGlProfile::nvidia_rtx_3060(), &NoiseEngine::new(NoiseSeed::from(1)));
346/// assert!(js.contains("getParameter"));
347/// assert!(js.contains("readPixels"));
348/// ```
349#[must_use]
350#[allow(clippy::too_many_lines)]
351pub fn webgl_noise_script(profile: &WebGlProfile, engine: &NoiseEngine) -> String {
352    let noise_fn = engine.js_noise_fn();
353    let vendor = &profile.vendor;
354    let renderer = &profile.renderer;
355    let max_tex = profile.max_texture_size;
356    let vp_w = profile.max_viewport_dims.0;
357    let vp_h = profile.max_viewport_dims.1;
358    let max_rb = profile.max_renderbuffer_size;
359    let max_va = profile.max_vertex_attribs;
360    let max_varying_vectors = profile.max_varying_vectors;
361    let max_fragment_uniform_vectors = profile.max_fragment_uniform_vectors;
362    let max_vertex_uniform_vectors = profile.max_vertex_uniform_vectors;
363
364    let exts_json = {
365        let items: Vec<String> = profile
366            .extensions
367            .iter()
368            .map(|e| format!("{e:?}"))
369            .collect();
370        format!("[{}]", items.join(", "))
371    };
372
373    let sp = &profile.shader_precision;
374    let ca = &profile.context_attributes;
375    let ca_power = &ca.power_preference;
376
377    format!(
378        r"(function() {{
379  'use strict';
380
381  // ── Noise helpers ──────────────────────────────────────────────────────
382  {noise_fn}
383
384  // ── WebGL constants ────────────────────────────────────────────────────
385  const _VENDOR   = 0x1F00;
386  const _RENDERER = 0x1F01;
387  const _UNMASKED_VENDOR   = 0x9245;
388  const _UNMASKED_RENDERER = 0x9246;
389  const _MAX_TEXTURE_SIZE            = 0x0D33;
390  const _MAX_VIEWPORT_DIMS           = 0x0D3A;
391  const _MAX_RENDERBUFFER_SIZE       = 0x84E8;
392  const _MAX_VERTEX_ATTRIBS          = 0x8869;
393  const _MAX_VARYING_VECTORS         = 0x8DFC;
394  const _MAX_FRAGMENT_UNIFORM_VECTORS = 0x8DFD;
395  const _MAX_VERTEX_UNIFORM_VECTORS  = 0x8DFB;
396
397  const _PROFILE_VENDOR   = {vendor:?};
398  const _PROFILE_RENDERER = {renderer:?};
399  const _EXTENSIONS = {exts_json};
400
401  // ── Spoof toString ─────────────────────────────────────────────────────
402  function _nts(name) {{ return function toString() {{ return 'function ' + name + '() {{ [native code] }}'; }}; }}
403  function _def(obj, prop, fn) {{
404    fn.toString = _nts(prop);
405    Object.defineProperty(obj, prop, {{ value: fn, writable: false, configurable: false, enumerable: false }});
406  }}
407
408  // ── Patch both WebGL1 and WebGL2 ────────────────────────────────────────
409  [WebGLRenderingContext, (typeof WebGL2RenderingContext !== 'undefined' ? WebGL2RenderingContext : null)]
410    .filter(Boolean)
411    .forEach(function(Ctx) {{
412      const proto = Ctx.prototype;
413
414      // getParameter
415      const _origGP = proto.getParameter;
416      _def(proto, 'getParameter', function getParameter(pname) {{
417        switch (pname) {{
418          case _VENDOR:             return _PROFILE_VENDOR;
419          case _RENDERER:           return _PROFILE_RENDERER;
420          case _UNMASKED_VENDOR:    return _PROFILE_VENDOR;
421          case _UNMASKED_RENDERER:  return _PROFILE_RENDERER;
422          case _MAX_TEXTURE_SIZE:   return {max_tex};
423          case _MAX_VIEWPORT_DIMS:  return new Int32Array([{vp_w}, {vp_h}]);
424          case _MAX_RENDERBUFFER_SIZE: return {max_rb};
425          case _MAX_VERTEX_ATTRIBS: return {max_va};
426          case _MAX_VARYING_VECTORS: return {max_varying_vectors};
427          case _MAX_FRAGMENT_UNIFORM_VECTORS: return {max_fragment_uniform_vectors};
428          case _MAX_VERTEX_UNIFORM_VECTORS: return {max_vertex_uniform_vectors};
429          default: return _origGP.call(this, pname);
430        }}
431      }});
432
433      // getSupportedExtensions
434      _def(proto, 'getSupportedExtensions', function getSupportedExtensions() {{
435        return _EXTENSIONS.slice();
436      }});
437
438      // getExtension
439      const _origGE = proto.getExtension;
440      _def(proto, 'getExtension', function getExtension(name) {{
441        if (!_EXTENSIONS.includes(name)) return null;
442        return _origGE.call(this, name);
443      }});
444
445      // getShaderPrecisionFormat
446      _def(proto, 'getShaderPrecisionFormat', function getShaderPrecisionFormat(shaderType, precisionType) {{
447        // HIGH_FLOAT = 0x8DF2, MEDIUM_FLOAT = 0x8DF1, LOW_FLOAT = 0x8DF0
448        // HIGH_INT = 0x8DF5, MEDIUM_INT = 0x8DF4, LOW_INT = 0x8DF3
449        const HIGH_FLOAT = 0x8DF2, MEDIUM_FLOAT = 0x8DF1, HIGH_INT = 0x8DF5;
450        if (precisionType === HIGH_FLOAT) {{
451          return {{ rangeMin: {sp_hfrm}, rangeMax: {sp_hfrx}, precision: {sp_hfp} }};
452        }} else if (precisionType === MEDIUM_FLOAT) {{
453          return {{ rangeMin: 127, rangeMax: 127, precision: {sp_mfp} }};
454        }} else if (precisionType === HIGH_INT) {{
455          return {{ rangeMin: 31, rangeMax: 30, precision: {sp_hip} }};
456        }}
457        return {{ rangeMin: 1, rangeMax: 1, precision: 8 }};
458      }});
459
460      // getContextAttributes
461      _def(proto, 'getContextAttributes', function getContextAttributes() {{
462        return {{
463          alpha: {ca_alpha},
464          antialias: {ca_antialias},
465          depth: {ca_depth},
466          failIfMajorPerformanceCaveat: {ca_fail},
467          powerPreference: {ca_power:?},
468          premultipliedAlpha: {ca_pma},
469          preserveDrawingBuffer: {ca_pdb},
470          stencil: {ca_stencil},
471          desynchronized: {ca_desync},
472        }};
473      }});
474
475      // readPixels — apply webgl noise to output
476      const _origRP = proto.readPixels;
477      _def(proto, 'readPixels', function readPixels(x, y, width, height, format, type, pixels) {{
478        _origRP.call(this, x, y, width, height, format, type, pixels);
479        if (pixels instanceof Uint8Array || pixels instanceof Uint8ClampedArray) {{
480          for (let i = 0; i < pixels.length; i += 4) {{
481            const px = (x + ((i / 4) % width)) >>> 0;
482            const py = (y + (((i / 4) / width) | 0)) >>> 0;
483            if (pixels[i] === 0 && pixels[i+1] === 0 && pixels[i+2] === 0 && pixels[i+3] === 0) continue;
484            const [dr, dg, db, da] = __stygian_webgl_noise('readPixels', px, py);
485            pixels[i]   = Math.max(0, Math.min(255, pixels[i]   + dr));
486            pixels[i+1] = Math.max(0, Math.min(255, pixels[i+1] + dg));
487            pixels[i+2] = Math.max(0, Math.min(255, pixels[i+2] + db));
488            pixels[i+3] = Math.max(0, Math.min(255, pixels[i+3] + da));
489          }}
490        }}
491      }});
492    }});
493}})();
494",
495        noise_fn = noise_fn,
496        vendor = vendor,
497        renderer = renderer,
498        exts_json = exts_json,
499        max_tex = max_tex,
500        vp_w = vp_w,
501        vp_h = vp_h,
502        max_rb = max_rb,
503        max_va = max_va,
504        max_varying_vectors = max_varying_vectors,
505        max_fragment_uniform_vectors = max_fragment_uniform_vectors,
506        max_vertex_uniform_vectors = max_vertex_uniform_vectors,
507        sp_hfrm = sp.high_float_range_min,
508        sp_hfrx = sp.high_float_range_max,
509        sp_hfp = sp.high_float_precision,
510        sp_mfp = sp.medium_float_precision,
511        sp_hip = sp.high_int_precision,
512        ca_alpha = ca.alpha,
513        ca_antialias = ca.antialias,
514        ca_depth = ca.depth,
515        ca_fail = ca.fail_if_major_performance_caveat,
516        ca_power = ca_power,
517        ca_pma = ca.premultiplied_alpha,
518        ca_pdb = ca.preserve_drawing_buffer,
519        ca_stencil = ca.stencil,
520        ca_desync = ca.desynchronized,
521    )
522}
523
524// ---------------------------------------------------------------------------
525// Tests
526// ---------------------------------------------------------------------------
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use crate::noise::{NoiseEngine, NoiseSeed};
532
533    fn eng() -> NoiseEngine {
534        NoiseEngine::new(NoiseSeed::from(1_u64))
535    }
536
537    #[test]
538    fn all_profiles_consistent() {
539        WebGlProfile::nvidia_rtx_3060().assert_consistent();
540        WebGlProfile::nvidia_gtx_1660().assert_consistent();
541        WebGlProfile::amd_rx_6700().assert_consistent();
542        WebGlProfile::intel_uhd_630().assert_consistent();
543    }
544
545    #[test]
546    fn script_contains_webgl_overrides() {
547        let js = webgl_noise_script(&WebGlProfile::nvidia_rtx_3060(), &eng());
548        assert!(js.contains("getParameter"), "missing getParameter");
549        assert!(
550            js.contains("getSupportedExtensions"),
551            "missing getSupportedExtensions"
552        );
553        assert!(js.contains("getExtension"), "missing getExtension");
554        assert!(
555            js.contains("getShaderPrecisionFormat"),
556            "missing getShaderPrecisionFormat"
557        );
558        assert!(
559            js.contains("getContextAttributes"),
560            "missing getContextAttributes"
561        );
562        assert!(js.contains("readPixels"), "missing readPixels");
563    }
564
565    #[test]
566    fn script_contains_noise_reference() {
567        let js = webgl_noise_script(&WebGlProfile::nvidia_rtx_3060(), &eng());
568        assert!(
569            js.contains("__stygian_webgl_noise"),
570            "missing webgl noise fn"
571        );
572    }
573
574    #[test]
575    fn script_contains_native_tostring() {
576        let js = webgl_noise_script(&WebGlProfile::nvidia_rtx_3060(), &eng());
577        assert!(js.contains("[native code]"), "missing toString spoof");
578    }
579
580    #[test]
581    fn profile_serde_round_trip() {
582        let p = WebGlProfile::nvidia_rtx_3060();
583        let json_result = serde_json::to_string(&p);
584        assert!(json_result.is_ok(), "serialize failed: {json_result:?}");
585        let Ok(json) = json_result else {
586            return;
587        };
588        let back_result: Result<WebGlProfile, _> = serde_json::from_str(&json);
589        assert!(back_result.is_ok(), "deserialize failed: {back_result:?}");
590        let Ok(back) = back_result else {
591            return;
592        };
593        assert_eq!(back.vendor, p.vendor);
594        assert_eq!(back.renderer, p.renderer);
595        assert_eq!(back.max_texture_size, p.max_texture_size);
596        assert_eq!(back.extensions.len(), p.extensions.len());
597    }
598
599    #[test]
600    fn script_contains_renderer_string() {
601        let p = WebGlProfile::nvidia_rtx_3060();
602        let js = webgl_noise_script(&p, &eng());
603        assert!(js.contains("RTX 3060"), "renderer not in script");
604    }
605}