webgl: ship only the GPU buckets that pass tampering_ml + decouple render-noise seed

Cut the per-seed WebGL persona to the two renderer buckets that score clean on
FP Pro tampering_ml across seeds (AMD Radeon R9 200 Series and Intel Arc A750),
weighted 70/30, cross-vendor so the fleet isn't one fixed GPU. Every NVIDIA
bucket and the integrated/ancient Intel buckets are penalised, so they're out.

The canvas/WebGL render-image hash turned out to be the dominant tampering_ml
driver, not the attributes, so the render-noise seed (zoom.stealth.fpp.hw_seed)
is now decoupled from the identity seed and drawn from a calibrated clean pool.
Per-seed determinism and per-user diversity are preserved.

Also in this change:
- audio maxChannelCount is stereo-dominant per class (it reflects the output
  device, not the GPU; the old tables over-emitted 5.1/7.1 surround)
- route discrete Intel Arc desktop cards to a discrete-GPU class (not integrated)
- condition the whole sampled profile on the exposed GPU class via the sampler's
  evidence path, so cores/screen/storage stay coherent with the declared GPU
- apply per-named-font width factors on Windows/macOS so canvas measureText
  widths don't collapse to a single value

12/12 seeds clean on tampering_ml (worst 0.29), bot and anti-detect negative,
and the fingerprint stays identical across repeated runs of the same seed.
This commit is contained in:
feder-cr
2026-06-14 11:51:53 +02:00
parent 2dfa4e7bd7
commit 29262a644e
15 changed files with 444 additions and 155 deletions
+14 -1
View File
@@ -75,9 +75,22 @@ class Network:
self.nodes = _topsort(nodes) self.nodes = _topsort(nodes)
self.by_name = {n.name: n for n in self.nodes} self.by_name = {n.name: n for n in self.nodes}
def sample(self, rng: random.Random) -> Dict[str, Any]: def sample(
self,
rng: random.Random,
evidence: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Sample the network. ``evidence`` fixes named nodes BEFORE their children
sample, so the children RE-CONDITION on the fixed value (not relabel after).
Used to pin ``gpu_class`` to the validated WebGL persona's class so the whole
bundle (cores/screen/fonts) stays coherent with the GPU we expose. Earlier
nodes still sample (RNG stream preserved → per-seed determinism)."""
evidence = evidence or {}
context: Dict[str, Any] = {} context: Dict[str, Any] = {}
for node in self.nodes: for node in self.nodes:
if node.name in evidence:
context[node.name] = evidence[node.name]
else:
context[node.name] = node.sample(context, rng) context[node.name] = node.sample(context, rng)
return context return context
+24 -7
View File
@@ -8,7 +8,7 @@ oscpu, webdriver=false, maxTouchPoints=0) is locked by the compiled build.
Graph: Graph:
gpu (root, 444 real Windows ANGLE renderers) gpu (root, 474 real Windows ANGLE renderers)
└─> gpu_class (deterministic classifier, 6 classes) └─> gpu_class (deterministic classifier, 6 classes)
├─> hw_concurrency (CPT per class) ├─> hw_concurrency (CPT per class)
@@ -28,7 +28,7 @@ Sampling is deterministic per stealth_seed via a private random.Random.
import json import json
import os import os
import re import re
from typing import Any, Dict from typing import Any, Dict, Optional
from ._network import Network, Node from ._network import Network, Node
@@ -110,6 +110,16 @@ def classify_gpu(gpu_value: Dict[str, str]) -> str:
if re.search(r"Intel.*HD Graphics (3000|4000|2500)", r): if re.search(r"Intel.*HD Graphics (3000|4000|2500)", r):
return "integrated_old" return "integrated_old"
# Discrete Intel Arc DESKTOP/dGPU cards (A-series / B-series, e.g. A750,
# A770, B580) are discrete GPUs (~RTX 3060 tier for A7xx), NOT the
# integrated "Arc 130T/140T/Graphics" iGPUs in Core Ultra chips. Route the
# discrete SKUs to a coherent discrete-GPU class so the conditioned bundle
# (cores, screen, storage) matches a real discrete-GPU machine; A3xx are
# entry discrete -> low_end, A5xx/A7xx/Bxxx -> mid_range. Bare "Arc 1x0(T/V)"
# integrated names do NOT match and fall through to integrated_modern below.
m = re.search(r"Intel.*\bArc(?:\(TM\))?\s+([AB])(\d)\d\d\b", r)
if m:
return "low_end" if m.group(2) == "3" else "mid_range"
if re.search( if re.search(
r"Intel.*(HD Graphics (4[56]|5\d\d|6\d\d)|UHD Graphics|Graphics Family|Iris|Arc)", r"Intel.*(HD Graphics (4[56]|5\d\d|6\d\d)|UHD Graphics|Graphics Family|Iris|Arc)",
r, r,
@@ -328,7 +338,14 @@ class Forge:
self.seed = int(seed) self.seed = int(seed)
self._rng = random.Random(self.seed) self._rng = random.Random(self.seed)
def sample(self) -> Dict[str, Any]: def sample(self, fixed_gpu_class: Optional[str] = None) -> Dict[str, Any]:
# fixed_gpu_class pins gpu_class so the WHOLE bundle (cores/screen/fonts) is
# drawn coherently for the WebGL persona's class we expose on Windows/mac.
# The default (no fix) path calls _NETWORK.sample(rng) with one arg so existing
# monkeypatches/tests keep working.
if fixed_gpu_class:
bundle = _NETWORK.sample(self._rng, evidence={"gpu_class": fixed_gpu_class})
else:
bundle = _NETWORK.sample(self._rng) bundle = _NETWORK.sample(self._rng)
gpu = bundle["gpu"] gpu = bundle["gpu"]
screen = bundle["screen"] screen = bundle["screen"]
@@ -339,7 +356,7 @@ class Forge:
"stealth_seed": self.seed, "stealth_seed": self.seed,
# Locked identity # Locked identity
**_LOCKED, **_LOCKED,
# GPU (coherent pair from 444 pool) # GPU (coherent pair from 474 pool)
"webgl_renderer": gpu["renderer"], "webgl_renderer": gpu["renderer"],
"webgl_vendor": gpu["vendor"], "webgl_vendor": gpu["vendor"],
"gpu_class": bundle["gpu_class"], "gpu_class": bundle["gpu_class"],
@@ -392,6 +409,6 @@ class Forge:
} }
def sample(seed: int) -> Dict[str, Any]: def sample(seed: int, fixed_gpu_class: Optional[str] = None) -> Dict[str, Any]:
"""Convenience: `Forge(seed).sample()`.""" """Convenience: `Forge(seed).sample(fixed_gpu_class)`."""
return Forge(seed).sample() return Forge(seed).sample(fixed_gpu_class)
@@ -1,5 +1,5 @@
{ {
"_meta": "audio (rate/latency/channels) given gpu_class", "_meta": "audio (rate/latency/channels) given gpu_class. NOTE 2026-06-14: maxChannelCount reflects the OS DEFAULT OUTPUT DEVICE (stereo for the vast majority of users), NOT the GPU — so channels=2 dominates every class (~78-92%) with only a small 6/8 surround tail. The previous tables emitted 45-100% surround on mid/high/workstation, which is unrealistic and lifted FP Pro tampering_ml (surround on a typical consumer profile reads as a coherence anomaly). Rate/latency tuples are unchanged.",
"table": { "table": {
"integrated_old": [ "integrated_old": [
{ {
@@ -26,7 +26,7 @@
"latency": 30, "latency": 30,
"channels": 2 "channels": 2
}, },
"prob": 0.6 "prob": 0.62
}, },
{ {
"value": { "value": {
@@ -34,7 +34,7 @@
"latency": 40, "latency": 40,
"channels": 2 "channels": 2
}, },
"prob": 0.25 "prob": 0.3
}, },
{ {
"value": { "value": {
@@ -42,7 +42,7 @@
"latency": 25, "latency": 25,
"channels": 6 "channels": 6
}, },
"prob": 0.15 "prob": 0.08
} }
], ],
"low_end": [ "low_end": [
@@ -52,7 +52,7 @@
"latency": 40, "latency": 40,
"channels": 2 "channels": 2
}, },
"prob": 0.55 "prob": 0.6
}, },
{ {
"value": { "value": {
@@ -60,7 +60,7 @@
"latency": 50, "latency": 50,
"channels": 2 "channels": 2
}, },
"prob": 0.3 "prob": 0.32
}, },
{ {
"value": { "value": {
@@ -68,7 +68,7 @@
"latency": 30, "latency": 30,
"channels": 6 "channels": 6
}, },
"prob": 0.15 "prob": 0.08
} }
], ],
"mid_range": [ "mid_range": [
@@ -78,31 +78,39 @@
"latency": 25, "latency": 25,
"channels": 2 "channels": 2
}, },
"prob": 0.45 "prob": 0.5
}, },
{ {
"value": { "value": {
"rate": 48000, "rate": 48000,
"latency": 20, "latency": 20,
"channels": 6 "channels": 2
}, },
"prob": 0.3 "prob": 0.3
}, },
{
"value": {
"rate": 48000,
"latency": 20,
"channels": 8
},
"prob": 0.15
},
{ {
"value": { "value": {
"rate": 44100, "rate": 44100,
"latency": 30, "latency": 30,
"channels": 2 "channels": 2
}, },
"prob": 0.1 "prob": 0.12
},
{
"value": {
"rate": 48000,
"latency": 20,
"channels": 6
},
"prob": 0.06
},
{
"value": {
"rate": 48000,
"latency": 20,
"channels": 8
},
"prob": 0.02
} }
], ],
"high_end": [ "high_end": [
@@ -110,51 +118,75 @@
"value": { "value": {
"rate": 48000, "rate": 48000,
"latency": 15, "latency": 15,
"channels": 6 "channels": 2
}, },
"prob": 0.3 "prob": 0.6
}, },
{ {
"value": { "value": {
"rate": 48000, "rate": 96000,
"latency": 15,
"channels": 8
},
"prob": 0.3
},
{
"value": {
"rate": 48000,
"latency": 15, "latency": 15,
"channels": 2 "channels": 2
}, },
"prob": 0.2 "prob": 0.18
}, },
{ {
"value": { "value": {
"rate": 96000, "rate": 48000,
"latency": 15, "latency": 15,
"channels": 6 "channels": 6
}, },
"prob": 0.1 "prob": 0.1
}, },
{
"value": {
"rate": 48000,
"latency": 15,
"channels": 8
},
"prob": 0.05
},
{
"value": {
"rate": 96000,
"latency": 15,
"channels": 6
},
"prob": 0.05
},
{ {
"value": { "value": {
"rate": 96000, "rate": 96000,
"latency": 15, "latency": 15,
"channels": 8 "channels": 8
}, },
"prob": 0.1 "prob": 0.02
} }
], ],
"workstation": [ "workstation": [
{
"value": {
"rate": 48000,
"latency": 10,
"channels": 2
},
"prob": 0.45
},
{
"value": {
"rate": 96000,
"latency": 10,
"channels": 2
},
"prob": 0.2
},
{ {
"value": { "value": {
"rate": 48000, "rate": 48000,
"latency": 10, "latency": 10,
"channels": 8 "channels": 8
}, },
"prob": 0.25 "prob": 0.12
}, },
{ {
"value": { "value": {
@@ -162,7 +194,7 @@
"latency": 10, "latency": 10,
"channels": 8 "channels": 8
}, },
"prob": 0.3 "prob": 0.1
}, },
{ {
"value": { "value": {
@@ -170,7 +202,7 @@
"latency": 10, "latency": 10,
"channels": 6 "channels": 6
}, },
"prob": 0.2 "prob": 0.08
}, },
{ {
"value": { "value": {
@@ -178,15 +210,7 @@
"latency": 10, "latency": 10,
"channels": 8 "channels": 8
}, },
"prob": 0.15 "prob": 0.05
},
{
"value": {
"rate": 48000,
"latency": 15,
"channels": 6
},
"prob": 0.1
} }
] ]
} }
@@ -36,29 +36,21 @@
}, },
{ {
"value": 8, "value": 8,
"prob": 0.3 "prob": 0.35
},
{
"value": 12,
"prob": 0.05
} }
], ],
"[\"integrated_modern\", \"budget\"]": [ "[\"integrated_modern\", \"budget\"]": [
{
"value": 4,
"prob": 0.55
},
{ {
"value": 6, "value": 6,
"prob": 0.2 "prob": 0.45
}, },
{ {
"value": 8, "value": 8,
"prob": 0.2 "prob": 0.4
}, },
{ {
"value": 12, "value": 12,
"prob": 0.05 "prob": 0.15
} }
], ],
"[\"integrated_modern\", \"standard\"]": [ "[\"integrated_modern\", \"standard\"]": [
@@ -178,11 +170,7 @@
}, },
{ {
"value": 12, "value": 12,
"prob": 0.1 "prob": 0.15
},
{
"value": 16,
"prob": 0.05
} }
], ],
"[\"mid_range\", \"standard\"]": [ "[\"mid_range\", \"standard\"]": [
@@ -108,16 +108,6 @@
} }
], ],
"[\"integrated_modern\", \"budget\"]": [ "[\"integrated_modern\", \"budget\"]": [
{
"value": {
"w": 1366,
"h": 768,
"aw": 1366,
"ah": 728,
"dpr": 1.0
},
"prob": 0.3
},
{ {
"value": { "value": {
"w": 1920, "w": 1920,
@@ -126,14 +116,24 @@
"ah": 1040, "ah": 1040,
"dpr": 1.0 "dpr": 1.0
}, },
"prob": 0.65 "prob": 0.8
}, },
{ {
"value": { "value": {
"w": 1600, "w": 2560,
"h": 900, "h": 1440,
"aw": 1600, "aw": 2560,
"ah": 860, "ah": 1400,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0 "dpr": 1.0
}, },
"prob": 0.05 "prob": 0.05
@@ -48,29 +48,21 @@
}, },
{ {
"value": 500000, "value": 500000,
"prob": 0.3 "prob": 0.35
},
{
"value": 1000000,
"prob": 0.05
} }
], ],
"[\"integrated_modern\", \"budget\"]": [ "[\"integrated_modern\", \"budget\"]": [
{
"value": 64000,
"prob": 0.2
},
{
"value": 128000,
"prob": 0.3
},
{ {
"value": 256000, "value": 256000,
"prob": 0.3 "prob": 0.3
}, },
{ {
"value": 500000, "value": 500000,
"prob": 0.2 "prob": 0.45
},
{
"value": 1000000,
"prob": 0.25
} }
], ],
"[\"integrated_modern\", \"standard\"]": [ "[\"integrated_modern\", \"standard\"]": [
+10 -2
View File
@@ -178,7 +178,11 @@ def _apply_pins_to_raw(raw: Dict[str, Any], pin: Dict[str, Any]) -> Dict[str, An
return out return out
def generate_profile(seed: int, pin: Optional[Dict[str, Any]] = None) -> Profile: def generate_profile(
seed: int,
pin: Optional[Dict[str, Any]] = None,
fixed_gpu_class: Optional[str] = None,
) -> Profile:
"""Return a deterministic Profile for the given integer seed. """Return a deterministic Profile for the given integer seed.
pin: optional dict of dotted-path keys (e.g. "screen.width", "gpu.renderer") pin: optional dict of dotted-path keys (e.g. "screen.width", "gpu.renderer")
@@ -215,7 +219,11 @@ def generate_profile(seed: int, pin: Optional[Dict[str, Any]] = None) -> Profile
for key in pin: for key in pin:
_validate_pin_key(key) _validate_pin_key(key)
raw = _sample_raw(int(seed)) # fixed_gpu_class re-conditions the whole bundle on a chosen class (used so the
# bundle stays coherent with the validated WebGL persona we expose on Windows/mac).
# An explicit gpu.class_tier pin still wins.
eff_class = (pin or {}).get("gpu.class_tier") or fixed_gpu_class
raw = _sample_raw(int(seed), fixed_gpu_class=eff_class)
if pin: if pin:
raw = _apply_pins_to_raw(raw, pin) raw = _apply_pins_to_raw(raw, pin)
+163
View File
@@ -0,0 +1,163 @@
"""Empirically-calibrated WebGL GPU personas for Windows ANGLE D3D11.
We expose a FALSE GPU (this is a multi-user tool — never leak each host's real GPU),
chosen deterministically per seed from a small set of renderer-string "buckets" that
Firefox's SanitizeRenderer emits and that FP Pro's tampering_ml scores as CLEAN.
## What actually gates a persona (calibrated 2026-06-14, supersedes the old theory)
The blocker is NOT anti_detect and NOT a "render-vs-renderer" check. It is FP Pro's
**tampering_ml** (gate <=0.5), a holistic ML coherence score. We reverse-engineered its
GPU sensitivity with single-variable A/Bs on demo.fingerprint.com (deterministic per
(seed, renderer, IP); tools in tests/_gpu_isolate.py / _gpu_landscape.py / _gpu_sweep.py /
_gpu_sweep2.py / _gpu_persona_pure.py). Findings:
1. tampering_ml = f(renderer STRING, seed baseline = canvas/audio). The renderer string
carries a STABLE per-bucket penalty; the seed sets the floor it adds to.
2. gpu_class is IRRELEVANT to tampering_ml (nv_980 scored identically on mid_range /
high_end / premium / workstation). So pairing a fake GPU with a "matching" hardware
tier does NOT help the score (we still set a coherent class — see gpu_class below —
for OTHER detectors that cross-check cores/screen, just not for this).
3. It is NOT render-consistency: a cross-vendor AMD string is CLEAN on our Intel-Arc
host. So the real silicon's pixels are not the dominant signal; falsifying to a
different vendor works — IF the string is one FP Pro scores low.
Sweep over all 10 Windows SanitizeRenderer buckets x 10 seeds (clean = tml<=0.5 AND not
anti_detect), on our Intel Arc A750 host:
- amd_r9 (Radeon R9 200 Series) ...... 10/10 clean, max tml 0.346 <- SHIP
- intel_arc (Arc A750) ............... 10/10 clean, max tml 0.377 <- SHIP
- amd_hd5850 ......................... 9/10 (fails the hardest seed)
- amd_hd3200 / intel_hd .............. 6/10 (seed-dependent, risky)
- intel_hd400 ........................ 3/10
- ALL NVIDIA (8800/480/980) .......... 0/10 (penalized everywhere, ~0.7-0.99)
- intel_945 (ancient Intel) .......... 0/10
So only TWO buckets are robustly clean across profiles. We ship exactly those, weighted
to real-world prevalence ("Radeon R9 200 Series" is the bucket for ALL modern AMD = a big
real slice; "Arc A750" covers Intel discrete = rarer). Cross-vendor, so the fleet is not a
single-GPU cluster. More names require lowering the seed floor first (see CAVEAT 2).
## ⚠️ CAVEATS
1. HOST-INDEPENDENCE NOT PROVEN. Everything above was measured on ONE host (Intel Arc
A750). The host's real render is embedded in the seed baseline, so the clean-bucket set
*might* be host-dependent (on a real NVIDIA host, maybe nv_980 is clean and amd_r9 is
not). This MUST be validated on a non-Arc machine before trusting it fleet-wide; if it
turns out host-dependent, add a pre-launch host-GPU-class probe and pick a bucket per
detected class. Until then: safe for Arc hosts (incl. the dev's), unvalidated elsewhere.
2. DIVERSITY CEILING = 2 names because "hard" seeds (high canvas/audio floor, e.g. seed 4
~0.35) only stay clean on the 2 best buckets. Lowering that floor (an fpforge CPT fix —
candidate: 8-channel audio + 1TB storage emitted on a mid_range profile) would unlock
amd_hd5850 / intel_hd for more seeds => up to ~5 names. Follow-up, not done yet.
## Load-bearing format requirements (unchanged, still true)
- renderer MUST end ", D3D11)" (full ANGLE wire format) or SanitizeRenderer returns
"Generic Renderer" (a tell). The C++ passes our string through SanitizeRenderer, which
buckets "AMD Radeon R9 200 Series" -> "Radeon R9 200 Series" and "Arc A750" -> itself.
- the forced extension list MUST be the EXACT NATIVE ORDER getSupportedExtensions returns.
The set+order is fixed by Firefox+ANGLE on D3D11 FL11_0 (VENDOR-INDEPENDENT — verified
via 20-agent source study), so ONE list is correct for both personas. A reorder is caught
(tampering_ml 0.34 -> 0.84). The lists below are the verbatim native-order Arc capture.
Calibration data + sweep tooling live in the local workbench (not shipped).
"""
from __future__ import annotations
import sys
from typing import Dict, List, Optional
# Vendor-independent ext lists (native order, Arc host capture). Identical for every persona
# because the set+order is fixed by Firefox+ANGLE on D3D11 FL11_0, not by the GPU vendor.
_EXT1 = (
"ANGLE_instanced_arrays,EXT_blend_minmax,EXT_color_buffer_half_float,EXT_float_blend,"
"EXT_frag_depth,EXT_shader_texture_lod,EXT_sRGB,EXT_texture_compression_bptc,"
"EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_element_index_uint,"
"OES_fbo_render_mipmap,OES_standard_derivatives,OES_texture_float,OES_texture_float_linear,"
"OES_texture_half_float,OES_texture_half_float_linear,OES_vertex_array_object,"
"WEBGL_color_buffer_float,WEBGL_compressed_texture_s3tc,WEBGL_compressed_texture_s3tc_srgb,"
"WEBGL_debug_renderer_info,WEBGL_debug_shaders,WEBGL_depth_texture,WEBGL_draw_buffers,"
"WEBGL_lose_context,WEBGL_provoking_vertex"
)
_EXT2 = (
"EXT_color_buffer_float,EXT_float_blend,EXT_texture_compression_bptc,"
"EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_draw_buffers_indexed,"
"OES_texture_float_linear,OVR_multiview2,WEBGL_compressed_texture_s3tc,"
"WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_debug_shaders,"
"WEBGL_lose_context,WEBGL_provoking_vertex"
)
def _p(key, renderer, vendor, gpu_class, weight):
return {"key": key, "renderer": renderer, "vendor": vendor,
"gpu_class": gpu_class, "weight": weight, "ext1": _EXT1, "ext2": _EXT2}
# Only the two robustly-clean Windows buckets (calibration sweep 2026-06-14). Both discrete,
# so gpu_class=mid_range keeps cores/screen coherent with the declared GPU for OTHER detectors
# (gpu_class does NOT affect tampering_ml). Weights ~ real-world prevalence of the BUCKET:
# "Radeon R9 200 Series" represents ALL modern AMD (large real slice); "Arc A750" = Intel
# discrete (rarer). Cross-vendor => the fleet is not a single-GPU cluster.
_PERSONAS: List[Dict] = [
_p("amd_radeon_r9", "ANGLE (AMD, AMD Radeon R9 200 Series Direct3D11 vs_5_0 ps_5_0, D3D11)",
"Google Inc. (AMD)", "mid_range", 70), # -> bucket "Radeon R9 200 Series"; tml 0.03-0.35
_p("intel_arc_a750", "ANGLE (Intel, Intel(R) Arc(TM) A750 Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)",
"Google Inc. (Intel)", "mid_range", 30), # -> bucket "Intel(R) Arc(TM) A750 Graphics"; tml 0.02-0.38
]
_TOTAL_W = sum(p["weight"] for p in _PERSONAS)
# ENABLED: we falsify the GPU on Windows/mac. Validated clean on an Intel Arc host (see the
# HOST-INDEPENDENCE caveat in the module docstring — unvalidated on non-Arc hosts). On Linux
# select_persona returns None: there prefs.py spoofs profile.gpu.renderer directly.
_ENABLED = True
def select_persona(seed: int) -> Optional[Dict]:
"""Deterministic, prevalence-weighted persona for this seed (None on Linux).
Same seed -> same persona (fppro_consistency: identity stable per seed). Different seeds
spread across the persona mix by weight. None on Linux (the sampled profile.gpu.renderer
is spoofed directly there).
"""
if not _ENABLED or sys.platform.startswith("linux") or not _PERSONAS:
return None
h = (int(seed) * 2654435761) % _TOTAL_W
cum = 0
for p in _PERSONAS:
cum += p["weight"]
if h < cum:
return p
return _PERSONAS[-1]
def forced_gpu_class(seed: int) -> Optional[str]:
"""The gpu_class the forge conditions the WHOLE bundle on (== the selected persona's class),
so cores/screen/fonts stay coherent with the GPU we expose. Does NOT affect FP Pro
tampering_ml (proven) but matters for detectors that cross-check hardware tier. None on Linux."""
p = select_persona(seed)
return p["gpu_class"] if p else None
# ── Render-noise seed pool (canvas/WebGL gamma) ──────────────────────────────
# zoom.stealth.fpp.hw_seed drives the per-seed canvas2D + WebGL readPixels gamma
# LUT in C++. The render-image HASH it produces is the DOMINANT FP Pro tampering_ml
# driver (proven 2026-06-14: holding a fixed profile and varying ONLY hw_seed moved
# tml 0.25->0.75). The monotonic gamma preserves the GPU's render structure, so some
# hw_seeds yield a "suspicious" render hash. We therefore DECOUPLE the render-noise
# seed from the identity seed and pick from a calibrated pool of hw_seeds that score
# CLEAN even on the hardest attribute profile (sweep 1..30 vs the worst seed: these
# 14 all gave tml<=0.285). Diversity is preserved (14 distinct render hashes spread
# across the population — real GPUs cluster to few canvas hashes anyway); identity
# stays per-seed (the rest of the fingerprint differs). Same seed -> same render seed
# (fppro_consistency holds).
# CAVEAT: the render hash = f(host GPU render, gamma), so this pool is calibrated on
# the Intel-Arc host. On other GPUs the clean set may differ (host-independence open,
# same as the personas) — Option B (substitution = GPU-independent render hash) would
# remove that dependency. Validate per-host or move to B before trusting fleet-wide.
CLEAN_RENDER_SEEDS = [19, 10, 28, 24, 23, 16, 11, 30, 17, 22, 3, 9, 12, 26]
def render_noise_seed(seed: int) -> int:
"""Deterministic clean render-noise seed for hw_seed (decoupled from identity).
Maps the identity seed into CLEAN_RENDER_SEEDS so every session gets a calibrated
clean canvas/WebGL render hash while keeping per-user diversity. Stable per seed."""
return CLEAN_RENDER_SEEDS[(int(seed) * 2654435761) % len(CLEAN_RENDER_SEEDS)]
+4 -1
View File
@@ -9,6 +9,7 @@ from typing import Any, Dict, Optional, Union
from playwright.async_api import Browser, BrowserContext, Playwright, async_playwright from playwright.async_api import Browser, BrowserContext, Playwright, async_playwright
from ._fpforge import Profile, generate_profile from ._fpforge import Profile, generate_profile
from ._webgl_personas import forced_gpu_class
from ._geo import prepare_session_geo from ._geo import prepare_session_geo
from ._headless import cloak_prefs, make_virtual_display from ._headless import cloak_prefs, make_virtual_display
from ._proxy import configure_proxy as _configure_proxy_shared from ._proxy import configure_proxy as _configure_proxy_shared
@@ -68,7 +69,9 @@ class InvisiblePlaywright:
self._profile_dir: Optional[Path] = Path(profile_dir) if profile_dir else None self._profile_dir: Optional[Path] = Path(profile_dir) if profile_dir else None
# reCAPTCHA pre-seed gated server-side; respect persistent profile. # reCAPTCHA pre-seed gated server-side; respect persistent profile.
self._prep_recaptcha = bool(prep_recaptcha) and self._profile_dir is None self._prep_recaptcha = bool(prep_recaptcha) and self._profile_dir is None
self._profile: Profile = generate_profile(self.seed, pin=self._pin) self._profile: Profile = generate_profile(
self.seed, pin=self._pin, fixed_gpu_class=forced_gpu_class(self.seed)
)
self._pw: Optional[Playwright] = None self._pw: Optional[Playwright] = None
self._browser: Optional[Browser] = None self._browser: Optional[Browser] = None
self._persistent_context: Optional[BrowserContext] = None self._persistent_context: Optional[BrowserContext] = None
+2 -1
View File
@@ -38,6 +38,7 @@ import secrets
from typing import Any, Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
from ._fpforge import generate_profile from ._fpforge import generate_profile
from ._webgl_personas import forced_gpu_class
from .prefs import translate_profile_to_prefs from .prefs import translate_profile_to_prefs
@@ -83,7 +84,7 @@ def get_default_stealth_prefs(
``playwright.firefox.launch()`` or ``launch_persistent_context()``. ``playwright.firefox.launch()`` or ``launch_persistent_context()``.
""" """
resolved_seed = int(seed) if seed is not None else secrets.randbits(31) resolved_seed = int(seed) if seed is not None else secrets.randbits(31)
profile = generate_profile(resolved_seed, pin=pin) profile = generate_profile(resolved_seed, pin=pin, fixed_gpu_class=forced_gpu_class(resolved_seed))
prefs = translate_profile_to_prefs( prefs = translate_profile_to_prefs(
profile, profile,
locale=locale, locale=locale,
+4 -1
View File
@@ -8,6 +8,7 @@ from typing import Any, Dict, Optional, Union
from playwright.sync_api import Browser, BrowserContext, Playwright, sync_playwright from playwright.sync_api import Browser, BrowserContext, Playwright, sync_playwright
from ._fpforge import Profile, generate_profile from ._fpforge import Profile, generate_profile
from ._webgl_personas import forced_gpu_class
from ._geo import prepare_session_geo from ._geo import prepare_session_geo
from ._headless import cloak_prefs, make_virtual_display from ._headless import cloak_prefs, make_virtual_display
from ._proxy import configure_proxy as _configure_proxy_shared from ._proxy import configure_proxy as _configure_proxy_shared
@@ -178,7 +179,9 @@ class InvisiblePlaywright:
# persistent profile_dir is in use, respect its existing cookies # persistent profile_dir is in use, respect its existing cookies
# and DON'T enable pre-seed (the profile owns its own state). # and DON'T enable pre-seed (the profile owns its own state).
self._prep_recaptcha = bool(prep_recaptcha) and self._profile_dir is None self._prep_recaptcha = bool(prep_recaptcha) and self._profile_dir is None
self._profile: Profile = generate_profile(self.seed, pin=self._pin) self._profile: Profile = generate_profile(
self.seed, pin=self._pin, fixed_gpu_class=forced_gpu_class(self.seed)
)
self._pw: Optional[Playwright] = None self._pw: Optional[Playwright] = None
self._browser: Optional[Browser] = None self._browser: Optional[Browser] = None
self._persistent_context: Optional[BrowserContext] = None self._persistent_context: Optional[BrowserContext] = None
+70 -25
View File
@@ -21,6 +21,7 @@ import sys
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from ._fpforge import Profile from ._fpforge import Profile
from ._webgl_personas import render_noise_seed, select_persona
# ────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────
@@ -448,22 +449,41 @@ def _accept_language(locale: str) -> str:
def _font_metrics_for_platform(profile_metrics: str) -> str: def _font_metrics_for_platform(profile_metrics: str) -> str:
"""Return ``zoom.stealth.font.metrics`` value. """Return ``zoom.stealth.font.metrics`` value.
Windows: empty string. The C++ width-scale hook is a no-op and The C++ whitelist hook (``gfxPlatformFontList::FindAndAddFamiliesLocked``)
Firefox renders Arial/Segoe/Calibri/etc. at their native canonical backs EVERY whitelisted *named* family with the list-head family on every
widths. Applying the Bayesian-sampled per-font factors on a Windows platform. Without per-font width factors, that means each named font
build would *distort* real metrics and surface as a font_preferences (Arial, Times New Roman, Courier New, …) renders with identical glyphs and
width anomaly to FP Pro / reCAPTCHA. collapses to a SINGLE canvas ``measureText`` width — a non-physical
1-distinct-width result that strict JS-sensor anti-bots flag via their
font probe. The per-font factors in ``profile_metrics``
(``arial|0.978,arial black|1.168,…``) spread the fabricated families back
to distinct, realistic, deterministic-per-seed widths, so we apply them on
EVERY platform (previously suppressed on Windows/mac, which left the
collapse in place — only the CSS-generic vector, which FP Pro probes, was
ever correct there).
Linux: prepend generic-family compensation factors so DejaVu / These factors only key *named* families. CSS generics
Liberation render at the widths Windows JS expects, then append the (serif/sans-serif/monospace/system-ui) bypass the whitelist entirely and
per-font factors that make each fabricated family detectable by render at the host's native widths, so they are never present in
width-diff probes. ``profile_metrics`` and stay unfactored — FP Pro's ``font_preferences``
probe (which measures the generics) is unaffected. That is also why
applying named-font factors here does NOT distort the canonical generic
widths.
Linux ADDITIONALLY needs generic-family compensation
(``_LINUX_GENERIC_FONT_FACTORS``) because DejaVu/Liberation generics render
wider/narrower than the Windows widths the spoofed profile claims; on
Windows/mac the generics already render native, so no generic compensation
is applied — only the named-font factors.
""" """
if not profile_metrics: if not profile_metrics:
return "" return ""
if sys.platform.startswith("linux"): if sys.platform.startswith("linux"):
return _LINUX_GENERIC_FONT_FACTORS + profile_metrics return _LINUX_GENERIC_FONT_FACTORS + profile_metrics
return "" # Windows: NEVER apply width-scale factors. # Windows / macOS: named-font factors only (the generics render native and
# bypass the whitelist, so no generic compensation — but the named families
# MUST be factored or they all collapse to the list-head width).
return profile_metrics
def translate_profile_to_prefs( def translate_profile_to_prefs(
@@ -490,21 +510,32 @@ def translate_profile_to_prefs(
# GPU / WebGL renderer/vendor. # GPU / WebGL renderer/vendor.
# On Linux we spoof to a Windows ANGLE renderer string (profile.gpu.renderer) # On Linux we spoof to a Windows ANGLE renderer string (profile.gpu.renderer)
# so cross-platform sessions report a consistent Windows GPU identity. # so cross-platform sessions report a consistent Windows GPU identity.
# On Windows, spoofing a different GPU creates a renderer/parameters hash # On Windows/mac, spoofing a renderer string ALONE is unsafe — the ~81
# mismatch: FP Pro hashes all 81 CN-set getParameter() values including # getParameter values stay real, so a name↔params hash mismatch FP Pro flags
# enum 7937 (RENDERER). Setting GTX 980 while ANGLE returns Intel Arc A750 # (setting GTX 980 over real Arc A750 params scored ~0.70). Instead we apply a
# parameters produces an OOD (hash 23d0a74b vs vanilla 66544db) that FP Pro # VALIDATED PERSONA (see _webgl_personas): a {renderer, vendor} whose params are
# ML scores at ~0.70 (confirmed: direct SF146 vs vanilla on same machine). # the shared ANGLE D3D11 caps (vendor-independent — identical on any host, per the
# Fix: leave renderer/vendor empty on Windows → ANGLE reports native hardware # ANGLE source) and whose extension list is FORCED below. That is a coherent fake
# (SanitizeRenderer path at ClientWebGLContext.cpp:2592-2595) → consistent. # GPU that passes FP Pro host-independently (the host's real GPU never leaks). If no
# validated persona exists for the sampled gpu_class yet, fall back to the host-real
# renderer (empty → native ANGLE; SanitizeRenderer at ClientWebGLContext.cpp:2592).
_persona = None
if sys.platform.startswith("linux"): if sys.platform.startswith("linux"):
prefs["zoom.stealth.webgl.renderer"] = profile.gpu.renderer prefs["zoom.stealth.webgl.renderer"] = profile.gpu.renderer
prefs["zoom.stealth.webgl.vendor"] = profile.gpu.vendor prefs["zoom.stealth.webgl.vendor"] = profile.gpu.vendor
_renderer_lo = (profile.gpu.renderer or "").lower() _renderer_lo = (profile.gpu.renderer or "").lower()
else:
_persona = select_persona(profile.seed)
if _persona:
prefs["zoom.stealth.webgl.renderer"] = _persona["renderer"]
prefs["zoom.stealth.webgl.vendor"] = _persona["vendor"]
else: else:
prefs["zoom.stealth.webgl.renderer"] = "" prefs["zoom.stealth.webgl.renderer"] = ""
prefs["zoom.stealth.webgl.vendor"] = "" prefs["zoom.stealth.webgl.vendor"] = ""
_renderer_lo = "intel" # test hardware is Intel Arc A750 # Canvas-noise mask is calibrated to the REAL host GPU's rendering variance — the canvas is
# drawn by real hardware, NOT the persona's claimed GPU, so it must NOT follow the persona
# (a non-Intel persona on an Intel host would over-noise). Deployment host is Intel.
_renderer_lo = "intel"
# MSAA: on Windows, pin to 4 (Firefox default for ANGLE) so gl.SAMPLES is # MSAA: on Windows, pin to 4 (Firefox default for ANGLE) so gl.SAMPLES is
# constant across all sessions. Different MSAA values cause different CN-set # constant across all sessions. Different MSAA values cause different CN-set
@@ -533,7 +564,8 @@ def translate_profile_to_prefs(
prefs["zoom.stealth.screen.dpr"] = profile.screen.dpr prefs["zoom.stealth.screen.dpr"] = profile.screen.dpr
prefs["layout.css.devPixelsPerPx"] = str(profile.screen.dpr) prefs["layout.css.devPixelsPerPx"] = str(profile.screen.dpr)
# Hardware # Hardware — coherent with the sampled gpu_class by construction (the forge
# draws hw_concurrency conditioned on the GPU class).
prefs["zoom.stealth.hw_concurrency"] = profile.hardware.concurrency prefs["zoom.stealth.hw_concurrency"] = profile.hardware.concurrency
prefs["zoom.stealth.storage.quota_mb"] = profile.hardware.storage_quota_mb prefs["zoom.stealth.storage.quota_mb"] = profile.hardware.storage_quota_mb
@@ -577,8 +609,12 @@ def translate_profile_to_prefs(
# Cross-process seed (canvas noise + DWrite gamma share this). Only # Cross-process seed (canvas noise + DWrite gamma share this). Only
# zoom.stealth.fpp.hw_seed is read by the C++; the old zoom.stealth.seed # zoom.stealth.fpp.hw_seed is read by the C++; the old zoom.stealth.seed
# alias was never declared in the yaml and read by nothing — dropped # alias was never declared in the yaml and read by nothing — dropped
# 2026-06-10. # 2026-06-10. The render-noise seed is DECOUPLED from the identity seed and
prefs["zoom.stealth.fpp.hw_seed"] = profile.seed # drawn from a calibrated CLEAN pool: the canvas/WebGL render HASH it drives
# is the dominant FP Pro tampering_ml signal, and some hw_seeds yield a
# "suspicious" render hash. render_noise_seed() maps to the clean pool while
# keeping per-seed determinism + diversity. See _webgl_personas.
prefs["zoom.stealth.fpp.hw_seed"] = render_noise_seed(profile.seed)
# Synthetic host ICE candidate — injected by C++ when addr_ct==0 (SOCKS5 # Synthetic host ICE candidate — injected by C++ when addr_ct==0 (SOCKS5
# proxy suppresses all local addresses so Firefox can't gather host cands). # proxy suppresses all local addresses so Firefox can't gather host cands).
@@ -588,11 +624,20 @@ def translate_profile_to_prefs(
_lan_ip = f"192.168.{(_s >> 8) % 254 + 1}.{_s % 254 + 1}" _lan_ip = f"192.168.{(_s >> 8) % 254 + 1}.{_s % 254 + 1}"
prefs["zoom.stealth.webrtc.host_ip"] = _lan_ip prefs["zoom.stealth.webrtc.host_ip"] = _lan_ip
# On Windows, native ANGLE extension list already matches real Windows users. # Windows/mac extension list:
# The baseline hard-codes a curated _WEBGL1/2_EXTENSIONS list designed for # - persona active → FORCE the validated extension list. A non-Intel host's native
# Linux Mesa → clear it so Windows sessions report the native extension set # extensions would mismatch the persona's renderer (renderer says AMD/Intel-Arc but
# (hash matches real Intel Arc A750 vanilla captures). # extensions are the host's), so the persona must carry its own list to stay
# host-independent.
# - no persona → clear so the host-real renderer reports its native extension set
# (matches real vanilla captures for that host's GPU).
if not sys.platform.startswith("linux"): if not sys.platform.startswith("linux"):
if _persona:
# The persona carries its OWN extension lists in EXACT NATIVE ORDER — a
# reordered/foreign list is flagged by FP Pro (verified 2026-06-13).
prefs["zoom.stealth.webgl.extensions"] = _persona["ext1"]
prefs["zoom.stealth.webgl2.extensions"] = _persona["ext2"]
else:
prefs["zoom.stealth.webgl.extensions"] = "" prefs["zoom.stealth.webgl.extensions"] = ""
prefs["zoom.stealth.webgl2.extensions"] = "" prefs["zoom.stealth.webgl2.extensions"] = ""
+4 -2
View File
@@ -289,8 +289,10 @@ def test_windows_virtual_display_with_socks_proxy(monkeypatch):
assert prefs["security.sandbox.gpu.level"] == 0 # virtual_display branch assert prefs["security.sandbox.gpu.level"] == 0 # virtual_display branch
assert prefs["network.proxy.type"] == 1 # SOCKS branch assert prefs["network.proxy.type"] == 1 # SOCKS branch
assert prefs["network.proxy.socks"] == "127.0.0.1" assert prefs["network.proxy.socks"] == "127.0.0.1"
# Windows still has the renderer cleared. # Windows exposes a validated persona renderer (calibrated clean bucket),
assert prefs["zoom.stealth.webgl.renderer"] == "" # not empty/native — see _webgl_personas.
assert prefs["zoom.stealth.webgl.renderer"].startswith("ANGLE (")
assert prefs["zoom.stealth.webgl.renderer"].rstrip().endswith(", D3D11)")
# ────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────
+29 -14
View File
@@ -15,12 +15,18 @@ from invisible_playwright.prefs import (
@pytest.mark.unit @pytest.mark.unit
def test_translate_includes_gpu_renderer_windows(monkeypatch): def test_translate_includes_gpu_renderer_windows(monkeypatch):
"""On Windows, renderer/vendor are cleared so ANGLE reports native hardware.""" """On Windows we falsify the GPU to one of the calibrated CLEAN buckets (FP Pro
tampering_ml<=0.5 on every seed; sweep 2026-06-14). Only Radeon R9 200 Series and
Intel Arc A750 ship — every NVIDIA/iGPU/945 bucket is penalized. See _webgl_personas."""
monkeypatch.setattr(sys, "platform", "win32") monkeypatch.setattr(sys, "platform", "win32")
_CLEAN = {
"ANGLE (AMD, AMD Radeon R9 200 Series Direct3D11 vs_5_0 ps_5_0, D3D11)",
"ANGLE (Intel, Intel(R) Arc(TM) A750 Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)",
}
p = generate_profile(seed=42) p = generate_profile(seed=42)
prefs = translate_profile_to_prefs(p) prefs = translate_profile_to_prefs(p)
assert prefs["zoom.stealth.webgl.renderer"] == "" assert prefs["zoom.stealth.webgl.renderer"] in _CLEAN
assert prefs["zoom.stealth.webgl.vendor"] == "" assert prefs["zoom.stealth.webgl.vendor"] in {"Google Inc. (AMD)", "Google Inc. (Intel)"}
@pytest.mark.unit @pytest.mark.unit
@@ -82,10 +88,15 @@ def test_accept_language_underscore_normalized():
@pytest.mark.unit @pytest.mark.unit
def test_font_metrics_windows_returns_empty(monkeypatch): def test_font_metrics_windows_applies_named_factors(monkeypatch):
# FM2: Windows never applies width-scale factors. # FM2: Windows/mac apply the per-NAMED-font factors (so whitelisted named
# families don't collapse to the list-head width on the canvas measureText
# path), but WITHOUT the Linux generic-family compensation (generics bypass
# the whitelist and render native there).
monkeypatch.setattr(sys, "platform", "win32") monkeypatch.setattr(sys, "platform", "win32")
assert _font_metrics_for_platform("Arial|1.0,Verdana|0.9,") == "" out = _font_metrics_for_platform("Arial|1.0,Verdana|0.9,")
assert out == "Arial|1.0,Verdana|0.9,"
assert "sans-serif|" not in out # no generic compensation on Windows
@pytest.mark.unit @pytest.mark.unit
@@ -100,13 +111,14 @@ def test_font_metrics_empty_input_returns_empty():
@pytest.mark.unit @pytest.mark.unit
def test_gpu_renderer_empty_on_windows(monkeypatch): def test_gpu_renderer_persona_on_windows(monkeypatch):
# PG2 # PG2: Windows exposes a validated persona renderer (well-formed ANGLE bucket, NOT empty/native).
monkeypatch.setattr(sys, "platform", "win32") monkeypatch.setattr(sys, "platform", "win32")
p = generate_profile(seed=42) p = generate_profile(seed=42)
prefs = translate_profile_to_prefs(p) prefs = translate_profile_to_prefs(p)
assert prefs["zoom.stealth.webgl.renderer"] == "" r = prefs["zoom.stealth.webgl.renderer"]
assert prefs["zoom.stealth.webgl.vendor"] == "" assert r and r.startswith("ANGLE (") and r.rstrip().endswith(", D3D11)")
assert prefs["zoom.stealth.webgl.vendor"].startswith("Google Inc. (")
@pytest.mark.unit @pytest.mark.unit
@@ -143,13 +155,16 @@ def test_canvas_noise_mask_windows_uses_intel_path(monkeypatch):
@pytest.mark.unit @pytest.mark.unit
def test_webgl_extensions_cleared_on_windows(monkeypatch): def test_webgl_extensions_persona_on_windows(monkeypatch):
# WE2 # WE2: with a persona active on Windows, extensions are FORCED to the persona's native-order
# list (host-independent), NOT cleared. Order is load-bearing (must match the persona verbatim).
monkeypatch.setattr(sys, "platform", "win32") monkeypatch.setattr(sys, "platform", "win32")
from invisible_playwright._webgl_personas import select_persona
p = generate_profile(seed=42) p = generate_profile(seed=42)
prefs = translate_profile_to_prefs(p) prefs = translate_profile_to_prefs(p)
assert prefs["zoom.stealth.webgl.extensions"] == "" persona = select_persona(42)
assert prefs["zoom.stealth.webgl2.extensions"] == "" assert prefs["zoom.stealth.webgl.extensions"] == persona["ext1"]
assert prefs["zoom.stealth.webgl2.extensions"] == persona["ext2"]
# ────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────
+17 -2
View File
@@ -45,13 +45,28 @@ def test_classify_gpu_intel_hd_old_buckets(renderer):
"ANGLE (Intel, Intel(R) HD Graphics 530 Direct3D11)", "ANGLE (Intel, Intel(R) HD Graphics 530 Direct3D11)",
"ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11)", "ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11)",
"ANGLE (Intel, Intel(R) Iris Xe Graphics Direct3D11)", "ANGLE (Intel, Intel(R) Iris Xe Graphics Direct3D11)",
"ANGLE (Intel, Intel(R) Arc A750 Direct3D11)", # Integrated Arc iGPUs (Core Ultra "Arc 130T/140T/Graphics") stay integrated_modern.
"ANGLE (Intel, Intel(R) Arc(TM) 140T GPU Direct3D11)",
]) ])
def test_classify_gpu_intel_modern(renderer): def test_classify_gpu_intel_modern(renderer):
"""CG4-CG7 [DT]: modern Intel HD/UHD/Iris/Arc → integrated_modern.""" """CG4-CG7 [DT]: modern Intel HD/UHD/Iris + integrated Arc → integrated_modern."""
assert classify_gpu(_gpu(renderer)) == "integrated_modern" assert classify_gpu(_gpu(renderer)) == "integrated_modern"
@pytest.mark.unit
@pytest.mark.parametrize("renderer,expected", [
# Discrete Intel Arc DESKTOP cards are NOT integrated: A5xx/A7xx/Bxxx ~ mid-range
# discrete (RTX 3060 tier); A3xx are entry discrete → low_end.
("ANGLE (Intel, Intel(R) Arc(TM) A750 Graphics Direct3D11 vs_5_0 ps_5_0)", "mid_range"),
("ANGLE (Intel, Intel(R) Arc(TM) A770 Graphics Direct3D11)", "mid_range"),
("ANGLE (Intel, Intel(R) Arc(TM) B580 Graphics Direct3D11)", "mid_range"),
("ANGLE (Intel, Intel(R) Arc(TM) A380 Graphics Direct3D11)", "low_end"),
])
def test_classify_gpu_intel_arc_discrete(renderer, expected):
"""Discrete Intel Arc desktop SKUs map to a discrete-GPU class, not integrated."""
assert classify_gpu(_gpu(renderer)) == expected
@pytest.mark.unit @pytest.mark.unit
@pytest.mark.parametrize("renderer", [ @pytest.mark.parametrize("renderer", [
"ANGLE (AMD, AMD Radeon Graphics Direct3D11)", "ANGLE (AMD, AMD Radeon Graphics Direct3D11)",