# Поверхности зонда: протоколы замеров

Документ вынут из комментариев зонда — `src/mantia/corpus/probe.js`.
Причина простая: зонд уезжает на шесть чужих боевых сайтов, и каждый лишний
килобайт исходника платит владелец магазина трафиком своих покупателей. В коде
осталось только «почему так сделано»; сюда переехали протоколы прогонов,
таблицы замеренных значений, отвергнутые варианты и инструкции по врезке,
которые уже применены.

Всё, что здесь написано, **замерено**, а не выведено из документации. Дважды за
работу над зондом замер опровергал правдоподобное рассуждение — оба случая
описаны ниже (квота хранилища и медиапризнак `.media`). Читать документ надо
именно так: это записи опытов, а не пересказ спецификаций.

Стенд, если не сказано иное: Chrome 151.0.7922.76, macOS 14.5, Intel Core
i7-9750H, гибридная графика Intel UHD 630 + AMD, встроенный звуковой выход.

## Оглавление

1. [Состояние сборки: чего не хватает до боя](#1-состояние-сборки-чего-не-хватает-до-боя)
2. [WebGPU](#2-webgpu)
3. [Подсказки UA-CH и хранилище](#3-подсказки-ua-ch-и-хранилище)
4. [Звук, голоса и сеть](#4-звук-голоса-и-сеть)
5. [Структура и следы подделки](#5-структура-и-следы-подделки)
6. [Экран, шрифты и батарея](#6-экран-шрифты-и-батарея)
7. [Общее: холст, WebGL, отправка, запуск](#7-общее-холст-webgl-отправка-запуск)
8. [Приёмная сторона: что уже сделано](#8-приёмная-сторона-что-уже-сделано)

---

## 1. Состояние сборки: чего не хватает до боя

Вторая версия зонда добавляет к прежним поверхностям пять групп. Каждая найдена
разбором чужих антидетект-браузеров (`docs/competitors.md`); довод везде один:
если конкурент тратит патч C++ на подмену поля — поле различает машины.

| Группа | У кого подделывается | Было у нас |
| --- | --- | --- |
| WebGPU (`adapter.info`, возможности, лимиты) | Dolphin, Vision, Octo, 0detect, Undetectable | ни строки |
| Подсказки UA-CH высокой энтропии, квота хранилища, предел кучи | Dolphin (`client_hints.cc`), Vision (структура `ClientHints` на семь полей), Octo (ключи `client_hints.*`) | нет |
| Настоящая частота звукового устройства, голоса синтеза речи, канал | Dolphin (отдельный файл под голоса), Octo | было мёртвое поле |
| Перечни ключей и следы подделки, в том числе из соседнего кадра | — (это наш детектор чужих) | нет |
| Цветовой охват экрана, расширенный набор шрифтов, батарея | Dolphin, Vision | частично |

### Где сейчас лежит версия 2 и что с её проверкой

**Отдельного файла нет.** Вторая версия не лежит рядом с боевым зондом — она им
**стала**: `src/mantia/corpus/probe.js`, `VERSION = 2`. Никакого
`probe.v2-unverified.js` в дереве не существует, и `git rev-list --all --objects`
не находит его ни в одной ревизии; если встретите это имя в другом документе —
документ устарел.

Точное состояние на сегодня: `probe.js` версии 2 (75 007 байт, `VERSION = 2`)
и в рабочем дереве, и в `git HEAD` — правка закоммичена 15 августа. Абзац выше
про «не закоммичена» был верен ровно до того коммита и снят, чтобы не отправить
следующего разработчика спасать то, что уже спасено.

**Сквозного прогона на настоящем стенде так и не было.** Файл разбирается
(`node --check` чисто), чист по ES5, но в разделе handover «проверено сквозным
прогоном, а не тестами» зонда нет. Мешало это: Chrome не поднимал отладочный порт
под управлением скрипта, хотя вручную поднимался. Пока прогона нет, выкатывать на
шесть чужих сайтов с настоящими посетителями и выручкой нельзя.

Заготовка стенда — `e2e/run.py` в скретчпаде сессии: поднимает Chrome из пула,
отдаёт страницу своим сервером, изображает человека через
`Input.dispatchMouseEvent`, принимает маячок и печатает поля.

### Враждебные прогоны: что нашлось

Каждая из пяти групп проходила прогон с заглушками, изображающими подделанную из
JS поверхность (бросающие геттеры, задержанные ответы, подменённые объекты). **В
каждой группе нашлась настоящая ошибка.**

Группа «звук и голоса» — четыре ошибки, все исправлены и перемерены:

* список голосов с бросающим геттером (так выглядит подделанная из JS
  поверхность, а это половина корпуса) ронял разбор целиком: промис не
  разрешался, маячок не уходил **вообще**, сайт получал в свой сборщик ошибок
  «Uncaught Error» с нашей строкой;
* живой `AudioContext` не закрывался, если чтение поля бросало — замерено:
  создано 1, закрыто 0;
* опоздавший слот простоя поднимал звуковую службу **после** того, как запись уже
  отдана: замерено — маячок на 3149 мс, контекст на 4247 мс;
* два таймера не снимались никогда — замерено счётчиком на странице: создано 7,
  снято 4.

В остальных четырёх группах: гонка после таймаута, 133 узла, оставшихся в DOM
чужой страницы, неснятые таймеры, обращение к главному миру из соседнего.

### Вес

Замерено на нынешнем файле: **75 КБ** исходника против **24 КБ** у версии 1; в
сжатом виде **24 КБ** против **8,6 КБ**. Основная масса того, что осталось, —
комментарии «почему так сделано».

Прежняя запись в этом разделе — 149 КБ и 49 КБ сжатым — относилась к сборке, у
которой протоколы замеров ещё лежали в комментариях самого зонда. После их
переезда сюда файл похудел вдвое. Ради этого документ и написан.

Правило проверки правки: сравнить файлы с грубо вырезанными комментариями до и
после — код обязан совпасть **побайтно**.

```bash
node -e 'const fs=require("fs");
const f=(p)=>fs.readFileSync(p,"utf8")
  .replace(/\/\*[\s\S]*?\*\//g,"").replace(/^\s*\/\/.*$/gm,"")
  .replace(/[ \t]+$/gm,"").replace(/\n{2,}/g,"\n").trim();
console.log(f("/tmp/probe_before.js")===f(process.argv[1])?"КОД ТОТ ЖЕ":"КОД ИЗМЕНИЛСЯ")' \
  src/mantia/corpus/probe.js
```

### Два ограничения, которые нельзя снимать

**Шрифты (`fontsExt`) — только на Chromium.** Замерено: первый в жизни проход
Safari по незнакомым именам занял 11,6 секунды (повторный — 9 мс, кэш шрифтов в
macOS общесистемный). Воспроизвести повторно не удалось, то есть это не
доказанная закономерность, а измеренный хвост; для чужой выручки хвоста
достаточно. Один такой случай на боевом магазине — замерший браузер у живого
покупателя. Потеря для корпуса невелика: мы клонируем Chromium, а не Safari.

**WebGPU — только если WebGL оказался аппаратным.** На машине без GPU Chrome сам
пишет в консоль страницы «Failed to create WebGPU Context Provider», по строке на
каждый `requestAdapter`. Владелец сайта это увидит.

### Находка: на http-странице зонд не отправлял ничего

`sha256hex` бросает на странице без TLS: `crypto.subtle` в незащищённом контексте
отсутствует, а `run()` обёрнут `try` только вокруг `collect()`. Значит на
http-странице зонд умирает целиком и не отправляет **ничего**. Сегодня не
проявляется только потому, что все шесть площадок на https. Лечится одной строкой
в начале функции:

```js
if (!window.crypto || !crypto.subtle) return Promise.resolve(null);
```

Правка внесена — `probe.js`, строка 32, первая строка тела `sha256hex`;
проверено чтением файла, а не по памяти. Коммита пока нет: в `git HEAD` строки
ещё не видно, так что при откате рабочего дерева находка вернётся.

---

## 2. WebGPU

### Зачем отдельно от WebGL

WebGPU идёт к драйверу другой дорогой (Dawn → Metal/D3D12/Vulkan) и описывает ту
же машину независимо от WebGL. Подделанная строка `WEBGL_debug_renderer_info` при
настоящих лимитах и features даёт противоречие внутри одной страницы, и видно оно
без всякого сравнения с корпусом.

### Осторожно при сравнении с WebGL

На стенде замерено: WebGL показывает «ANGLE Metal Renderer: Intel(R) UHD Graphics
630», а WebGPU по умолчанию отдаёт `amd`/`rdna-1`. **Обе строки правдивы** — в
машине две видеокарты.

Значит проверка «WebGL и WebGPU назвали разное — подделка» **неверна** и даст
ложные срабатывания на обычных ноутбуках. Правильная формулировка: карта из
WebGPU обязана входить в множество карт, которое машина показывает через WebGL
при разных настройках, а не совпадать с одной строкой.

### Протокол замеров

* **`powerPreference` выбирает разные видеокарты.** `low-power` вернул
  `intel`/`gen-9`; `high-performance` и запрос без предпочтения — `amd`/`rdna-1`.
  Одним запросом вторая карта не видна вообще. Наличие второй карты — крупный
  признак класса машины (ноутбук с дискретной графикой против одночиповой), а
  стоит около миллисекунды на тёплом GPU-процессе.
* **`adapter.isFallbackAdapter` в Chrome 151 уже не существует** (`undefined`),
  живой признак теперь `adapter.info.isFallbackAdapter`. Поэтому и info, и лимиты
  читаются перебором прототипа, а не по списку известных имён: так приедут и
  поля, которых сегодня нет (`subgroupMinSize`/`subgroupMaxSize` появились именно
  так).
* **`requestDevice()` не вызывается специально.** Замерено: у устройства лимиты —
  спецификационные минимумы (`maxTextureDimension2D` 8192 против 16384 у
  адаптера), features ровно одна, а `device.adapterInfo` совпадает с
  `adapter.info` побайтно. Устройство не добавляет **ни одного** нового бита, но
  стоит очередь команд и память GPU на чужом магазине. Ничего не создаём — значит,
  и освобождать нечего.
* **`forceFallbackAdapter: true` не вызывается**: замерено, что он возвращает
  `null` и пишет в консоль страницы «No available adapters.». Своих сообщений в
  чужой консоли зонд оставлять не должен.
* **Разрешений не спрашивает**: ни одного диалога за все прогоны. Ничего не
  рисуется, узлов не создаётся, холст не нужен.
* **`navigator.gpu` живёт только в защищённом контексте.** Замерено на
  `http://probe.test`: свойства нет вовсе. Поэтому рядом кладётся `secure` — иначе
  «WebGPU нет» на http-странице сольётся в базе с «WebGPU нет» в браузере, который
  его вовсе не умеет (Firefox, старый Safari, Chrome с выключённым флагом), а это
  разные вещи.
* **Машина без драйвера — третий случай, не путать с первыми двумя.** Замерено с
  `--disable-gpu`: `navigator.gpu` на месте (`present: true`), отличается поле
  `def: null`.

### Цена, которую надо знать хозяину сайта

На машине без работающего GPU (замерено с `--disable-gpu`) Chrome **сам** пишет в
консоль страницы «Failed to create WebGPU Context Provider» — по строке на каждый
вызов `requestAdapter`. Убрать это нельзя, можно только не умножать: поэтому
запросы идут не пачкой, а по очереди — сперва один, и лишь если адаптер получен,
ещё два. На здоровой машине консоль чистая (проверено: предупреждений ноль), на
больной будет одна строка вместо трёх.

### Бюджет времени

Замерено на холодном Chrome: первый запрос от 6 до 631 мс — разброс из-за того,
поднялся ли уже GPU-процесс. В боевом зонде WebGPU идёт **после** WebGL, который
процесс уже поднял: там все три запроса вместе занимают 5–6 мс, повторный запрос —
1 мс.

Потолок `GPU_BUDGET_MS = 1500` взят вдвое выше худшего холодного старта: машина,
которая не уложилась, ценна как факт «не уложилась», а не как повод ждать дальше.

### Чего здесь намеренно нет

Времени ответа `requestAdapter`. Меряется легко и выглядит полезным, но это шум:
запись корпуса схлопывается в «такое сочетание встретилось N раз», а поле, которое
у каждого своё, делает каждую запись уникальной и убивает сам счёт.

### Отбор шести лимитов из тридцати шести

Отбор не по вкусу: в `GPU_LIMITS_KEY` взяты те лимиты, что на замеренной машине
**выше** спецификационного минимума, то есть пришли от драйвера, а не переписаны
из стандарта. Лимиты, равные минимуму у всех (`maxBindGroups` 4, `maxVertexBuffers`
8, выравнивания 256), не различают ничего и живут только внутри хеша.

Честная оговорка о силе этого признака: **две видеокарты одной машины дали
одинаковые лимиты** — их выравнивает бэкенд Metal, — разошлись только info и
features. Значит, на macOS набор лимитов это подпись бэкенда и класса машины, а не
конкретной карты. Проверить, насколько он делит машины на Windows (D3D12) и Linux
(Vulkan), с одной машины нельзя; это работа для первой же тысячи записей корпуса.
Поэтому основной ключ — хеш всего набора, а шесть полей рядом лишь грубый
указатель, по которому семейство видно без разбора хеша.

### Почему свойства читаются с прототипа, а собственные — тоже

Свойства `GPUAdapterInfo` и `GPUSupportedLimits` лежат геттерами на **прототипе**:
`Object.keys()` по самому объекту возвращает пусто, а `JSON.stringify` даёт `{}`.
Замерено на Chrome 151: собственных свойств у обоих ровно ноль, на прототипе 8 и
37.

Собственные свойства всё равно читаются, и вот зачем. Форки, которые правят C++
(Dolphin, Vision, Octo), меняют сами геттеры, и с прототипа мы получим их подделку
как есть — это и нужно. А слабый антидетект на JS-инъекции подменяет
`adapter.info` обычным объектом; у такого поля лежат на нём самом. Читая только
прототип, мы записали бы пустоту вместо подделки, и целая популяция в корпусе
выглядела бы «без WebGPU». Отсюда третий элемент возвращаемой тройки
`[имя, значение, своё_ли]`.

### Строка формата холста: единственная, что шла мимо ножа

Замерено: подменённый `getPreferredCanvasFormat`, возвращающий строку в 20 000
символов, уносил её в запись целиком — **20 916 байт вместо 926**. Это
единственная строка замера, которая шла мимо `GPU_MAX_STR`. Теперь режется тем же
ножом, что и остальные чужие строки.

Пределы: `GPU_MAX_STR = 64` (`amd`, `rdna-1`, описание карты — короткие),
`GPU_MAX_KEYS = 48` (чужой объект может подсунуть тысячу длинных полей, а запись
обязана остаться маленькой).

### Гонка, которая раздваивала машину в корпусе

Таймаут разрешает промис, но объект `out` остаётся живым, а зонд отправляет его не
сразу: в `run()` маячок уходит, когда закончится **последний** из замеров.
Адаптер, ответивший на 1501-й миллисекунде, успевал дописать в уже отданную запись
целый `def` — и одна и та же машина отдавала то запись с `def`, то без него.
`combo_hash` считается по всему телу, значит в корпусе она раздваивается на два
«разных устройства». Ровно то, ради чего корпус существует, ломалось гонкой в
несколько миллисекунд. Отсюда проверки `if (done) return;` у каждого позднего
обработчика.

### Мёртвое поле на коротком пути

Выход идёт через ожидание хешей (`flush`), а не через `finish` напрямую. Иначе на
коротком пути (адаптера нет) промис разрешается раньше, чем досчитается хеш
`wgsl`, и поле уезжает на сервер пустым. Поймано замером: при `--disable-gpu` в
записи был `wgsl.n` без `wgsl.hash`. Аварийные ветки ведут туда же по той же
причине.

### Прочие решения

* `GPU_FEATURES`: порядок фиксирован **навсегда**, смысл бита задаётся позицией.
  Новые имена дописываются только в конец, иначе прошлые замеры в базе начнут
  значить другое. Имени, которого нет в списке, бит не достанется — но оно всё
  равно попадёт в счётчик и в хеш, и по расхождению «счётчик против числа единиц»
  видно, что список пора дополнять.
* Битовая маска — строка из нулей и единиц, а не число: битовые операции JS
  работают с 32 разрядами, а имён уже больше двадцати и будет больше тридцати.
* `GPUSupportedFeatures` и `wgslLanguageFeatures` — setlike, а не массивы: ни
  `length`, ни индексов, только `forEach`. Сортировка обязательна: порядок обхода
  набора стандартом не закреплён, а несортированный список дал бы разные хеши на
  одинаковых машинах.
* `wgslLanguageFeatures` хранится счётчиком и хешем, без битовой маски: набор
  описывает сборку Chrome, а версия сборки и так приезжает в UA. Ценность поля в
  другом — в **несовпадении** с UA у подделок.
* Формат холста различает платформы: `bgra8unorm` на настольных, `rgba8unorm` на
  части ARM.
* Вторая и третья карты у большинства машин те же самые; совпадение сворачивается
  в слово `same`. Сворачиваем **по частям**, а не целиком: замерено, что у двух
  разных карт одной машины лимиты совпадают до байта, и повторять их незачем.
* `gpuNative` возвращает три значения: `true` — объект родной; `false` — подменён
  на уровне JS (не тот бренд или поля лежат на самом объекте); `null` — проверять
  нечего, у адаптера вовсе нет `info`, как в Chrome до 128-го. Свалить `null` в
  `false` значило бы записать старый браузер как подделку.
* `null` от `requestAdapter` — тоже ответ, и записать его важнее, чем потерять:
  машина без пригодного адаптера (виртуалка, старый драйвер, выключенный GPU) в
  корпусе отдельный класс. Дальше не спрашиваем — на такой машине каждый лишний
  вызов добавляет строку в консоль сайта.

---

## 3. Подсказки UA-CH и хранилище

### Зачем группа

Chrome с версии 101 сознательно обеднил UA-строку: версия печатается мажором с
нулями (`Chrome/151.0.0.0`), а Windows — навсегда как «Windows NT 10.0». Под этой
строкой сидят и Windows 10, и Windows 11: по UA они **неотличимы**. Отличает их
ровно одно место — `platformVersion` из подсказок высокой энтропии (13 и выше =
Win11). Там же настоящая версия macOS (в UA она заморожена на «10_15_7» у всех
начиная с Big Sur), arm64 против x86, разрядность и полная версия сборки Chrome.

Это подделывают все трое разобранных конкурентов — Dolphin отдельным файлом
`client_hints.cc`, Vision структурой `ClientHints` на семь полей, Octo ключами
`client_hints.*`. Нам это нужно не для подделки, а для корпуса: узнать, какие
значения встречаются **вместе** на живых машинах.

### Замерено

Chrome 151.0.7922.76, macOS 14.5, Intel; страница по http на 127.0.0.1, значения
сняты через CDP:

| Что | Значение |
| --- | --- |
| UA-строка | «Intel Mac OS X 10_15_7» — версия ОС заморожена |
| `platformVersion` | «14.5.0» — настоящая. Ровно то, ради чего группа |
| `architecture` / `bitness` | «x86» / «64» — совпало с железом (Core i7-9750H) |
| `uaFullVersion` | «151.0.7922.76», в UA при этом «151.0.0.0» |
| вся группа целиком | около 1 мс, ни одного узла в DOM |

### На http без TLS

Проверено на `http://192.168.0.100:8899`, `isSecureContext = false`. Группа отдаёт
`null` **не целиком**, и это надо помнить при чтении корпуса:

* `uaHigh` — `null`: `navigator.userAgentData` в незащищённом контексте
  отсутствует вовсе (замерено: `hasUAD = false`);
* `storage` — `{quota: null, why: "insecure"}`, объект, а не `null`;
* `heapLimit` — 4 395 630 592, то есть **работает** как обычно:
  `performance.memory` защищённого контекста не требует.

### Мелочь, которая ломает проверку «подменю и посмотрю»

`navigator.userAgentData !== navigator.userAgentData` — Chrome отдаёт **новый**
объект `NavigatorUAData` на каждое обращение. Подмена метода на экземпляре молча
ни на что не влияет; подменять надо прототип. Это же означает, что и чужому патчу
экземпляра здесь зацепиться не за что.

### Состав полей `UA_HINTS`

Ровно эти семь имён мы уже запрашиваем у собственных профилей —
`identity/fingerprint_check.py` (около строки 248) и `identity/ua_capture.py`
(около строки 40). Если корпус спросит другой набор, живые машины и наши профили
станет не с чем сравнивать по этой оси, а вся ценность группы именно в сравнении.
Порядок значения не имеет, состав — да.

**Про `formFactors`.** Замерено: Chrome 151 его поддерживает и отдаёт
`["Desktop"]`, а неизвестное имя подсказки **не роняет** вызов — запрос
`["platformVersion","formFactors","totallyBogusHint"]` вернулся успешно, лишнее имя
просто выброшено. Значит, добавить его безопасно даже ради старых браузеров. Но
добавлять надо сразу **в трёх местах** — в зонде, в `fingerprint_check.py` и в
`ua_capture.py`, — иначе снова разъедется состав. Имя строго во множественном
числе: `formFactor` (единственное) Chrome 151 молча выбрасывает как неизвестное —
замерено, в ответе такого ключа нет.

Ещё замерено: вызов **всегда** возвращает сверх запрошенного ещё три поля —
`brands`, `mobile`, `platform`. Их не дублируем: `brands` и остальное `probe.js`
уже кладёт в `d.uaData`.

### Метка пиннинга версии

Dolphin прибивает мажор в UA-строке к «красивому» числу (файл
`content/browser/dolphin/ua_pin_version/ua_pin_version.cc`; наблюдался запуск с
`--user-agent=...Chrome/151.0.0.0` на сборке 151.0.7922.72).

Пока мажоры совпадают, подделка незаметна; но если сборка и UA разъедутся хоть на
единицу — расхождение само становится меткой, которой у настоящих Chrome не бывает
никогда. Чтобы корпус мог такое увидеть, надо снимать **оба** числа: мажор из
UA-строки и `uaFullVersion` из подсказок. Третий источник — `fullVersionList`:
конкурент может пропатчить один и забыть другой. Считаем в зонде, а не на сервере,
потому что там оба значения уже под рукой; сырьё всё равно уходит целиком и
пересчитать можно.

`majorOfUa` ищет первое совпадение `(?:Chrome|Chromium|CriOS)/(\d+)`: CriOS и
Chromium — те же движки под другими именами, а Edg/OPR ставят свой токен **после**
`Chrome/`.

### Почему `limited()` — отдельный helper

Асинхронных поверхностей в группе две, и обе умеют не ответить никогда. Подсказки
отклоняет `Permissions-Policy`, `storage.estimate` на забитом диске уходит в
ввод-вывод. Незавершённый промис подвесил бы `Promise.all` в `run()`, то есть
потерял бы **весь** замер, а не одно поле.

`fallback` — что отдать, когда ответа не было. По умолчанию `null`, но измеритель
со сложным ответом обязан передать свой: иначе форма поля зависит от того, успел ли
браузер, и на сервере «не ответило» становится неотличимо от «поля нет». Замерено,
что случай не теоретический: `storage.estimate`, который не завершается никогда,
доводил измеритель ровно до этого.

`clearTimeout` — не косметика. Без него после успешного ответа в очереди страницы
до секунды висит наш таймер с замыканием (замерено: создан 1, снято 0). Зонд обязан
не оставлять после себя ничего, а «истечёт сам» — это всё-таки след.

`safe()` вокруг `shapeUaHigh` обязателен, и не ради красоты. Второй аргумент
`.then` ловит отказ **исходного** промиса, но не исключение из этого же
обработчика: оно ушло бы наружу необработанным отказом, а его видит чужой сайт
своим сборщиком ошибок. Сегодня `shapeUaHigh` не бросает (проверено подменой ответа
на `null` и на объект с бросающими геттерами), но файл рассчитан на правку, и цена
страховки — одна строка.

### Квота хранилища: замер опроверг ожидание

Ожидание было «квота считается от места на диске, значит различает машины». Замер
его **опроверг**. Chrome 151 на macOS 14.5, три тома, профиль на каждом:

| Том | Свободно | `quota` |
| --- | --- | --- |
| 2 ГиБ | 2,0 ГиБ | 2 147 483 648 (ровно 2 ГиБ) |
| 60 ГиБ | 5,6 ГиБ | 10 737 418 240 (ровно 10 ГиБ) |
| 233 ГиБ | 7,0 ГиБ | 10 737 418 240 (ровно 10 ГиБ) |

То есть `quota = min(объём тома, 10 ГиБ)` и от **свободного** места не зависит
вовсе (на главном диске свободно 7 ГиБ, а обещано 10). На любой настоящей машине с
диском больше ~11 ГиБ значение одно и то же — константа. Как признак, различающий
машины, поле мертво.

**Зачем тогда снимаем.** Ровно за обратным: это **детектор**. Раз у настоящего
Chrome здесь всегда 10 737 418 240, то любое другое число на настольной машине —
либо крошечный диск, либо подмена. А подменяют это поле все:
`webkit_temporary_storage_quota` у Dolphin, `storageQuota` у Vision. Подделыватель,
выставивший «правдоподобные» 40 или 120 ГиБ, получает значение, которого у
настоящего Chrome не бывает. Корпус нужен, чтобы увидеть настоящее распределение и
убедиться, что 10 ГиБ — правило. Стоимость — около 1 мс, поле одно.

**Чего не замерено:** то же на Windows. Формула квоты платформозависима, а боевая
платформа у нас Windows; проверить было не на чем.

**Чего здесь нет.** `usage` не берётся сознательно: он показывает, сколько данных
**этот** посетитель уже накопил на **этом** сайте, то есть является меркой человека
и его истории, а не машины. То же с `navigator.storage.persisted()` и
`usageDetails`. Корпусу они не нужны — правило 7.

Форма ответа одна и та же всегда — объект `{quota, why}`. Поэтому сроку передан
свой fallback: без него измеритель отдавал голый `null` и по сроку, и на исключении
в теле, то есть ровно в двух самых интересных случаях терял `why`, ради которого
поле и заведено. Значение `unanswered`, а не `timeout`: этим же значением отвечает
синхронное исключение, и врать про причину не будем.

Сравнение именно с `false` (`window.isSecureContext === false`): у браузера без
поддержки `isSecureContext` свойство `undefined`, и короткое
`!window.isSecureContext` записало бы честный https в «insecure», подмешав в корпус
выдуманную причину.

### Предел кучи JavaScript

Синхронно, доли миллисекунды, только в Chromium. Стоит рядом с разрядностью не
случайно: предел кучи задаётся сборкой V8 под конкретную платформу и объём памяти
устройства, поэтому это ещё одно независимое свидетельство «32 бита против 64» и
класса машины. Заявленный `bitness: "64"` при двухгигабайтном пределе —
противоречие, которого на настоящей машине не бывает.

Берётся только предел. `usedJSHeapSize` и `totalJSHeapSize` зависят от того,
сколько наскриптовал сам сайт и как давно человек на странице, — это шум площадки,
а не свойство машины.

Замерено здесь: 4 395 630 592. Число не круглое и не наше — его не с чем спутать с
константой, которую подставили бы мы сами.

И оно точно не прибито в браузере. Проверено на **той же** машине: тот же Chrome
151, запущенный с `--js-flags=--max-old-space-size=512`, отдаёт 637 534 208 вместо
4 395 630 592. Поле, стало быть, показывает настройку V8 у конкретной сборки и
запуска — а значит ловит и антидетект, который правит флаги V8 «для стабильности» и
получает предел кучи, какого у обычного Chrome на такой машине не бывает.

---

## 4. Звук, голоса и сеть

### Что заменено

Старый `audioProbe()` был **мёртв**: он создавал `OfflineAudioContext(1, 44100,
44100)` и записывал обратно его же аргументы. Замерено в Chrome 151 на маке —
вернулось `{rate: 44100, channels: 1, maxChannels: 1}`, ровно переданное, при
настоящей частоте машины 48000. Поле не различало ничего и вдобавок врало.

### Звук: настоящее устройство вывода

Частоту и задержки знает только живой `AudioContext`: он один спрашивает
операционную систему. `OfflineAudioContext` не спрашивает никого — он считает в
памяти с той частотой, которую ему назвали, поэтому и врал.

Замерено (Chrome 151, macOS 14, встроенный выход): `rate` 48000, `baseLatency`
0.005333 (= 256/48000), `maxChannels` 2, `channels` 2, `outputLatency` 0.
`baseLatency` — это размер аппаратного буфера, делённый на частоту, то есть число
буквально описывает драйвер вывода; `maxChannels` различает наушники (2) и ресивер
по HDMI (6 и больше).

**Разрешений не спрашивает.** Микрофон — это `getUserMedia`, здесь его нет. Звука
не издаёт: не создаётся ни одного узла. Замерено — в консоли сайта не появляется ни
строки, в том числе предупреждения политики автозапуска.

**Suspended читается.** Если человек до замера только листал страницу, жеста
активации не было и контекст останется `suspended`. Замерено: в `suspended` и в
`running` все четыре поля совпадают до последнего знака, поэтому `resume()` не
зовём — он либо не нужен, либо нам не разрешён. Само состояние в замер не кладём: о
машине оно не говорит ничего (проверено), а зависит от того, чем человек тронул
страницу, — и хеш сочетания раздвоил бы каждое устройство, потеряв возвратность
посетителя.

**Почему промис, а не значение.** Первый в жизни браузера `AudioContext` поднимает
звуковую службу Chrome и **синхронно блокирует главный поток**. Замерено на
холодном головном Chrome: 145, 318, 445, 493 и 631 мс в разных прогонах; 4790 мс в
headless на пустой странице. Второй контекст — 0,4 мс, первый контекст в соседней
вкладке того же браузера — 2,5 мс: цена платится раз на браузерную сессию, а не
каждым визитом. Но платит её посетитель чужого магазина, поэтому измерение уходит в
собственный слот простоя — пусть блокировка придётся на момент, когда браузеру и так
нечем заняться, а не на середину прокрутки. Полностью убрать её нельзя: другого
способа спросить звуковое устройство из JS не существует, ровно поэтому конкуренты и
тратят на эту поверхность патч в C++.

Слот простоя даём с пределом 300 мс, а не больше: замерено, что вложенный
`requestIdleCallback` (а зонд уже работает внутри чужого idle-колбэка) своего простоя
не дожидается и срабатывает ровно по таймауту, то есть таймаут здесь — это прямая
задержка маячка, а не запас.

**Чужой звук не ломается.** Замерено: играющий контекст сайта переживает наше
создание и закрытие — `state` остаётся `running`, `currentTime` идёт ровно. Предел
живых контекстов не мешает: Chrome дал создать 12 подряд без ошибки, а мы держим
один и закрываем сразу.

### Три ошибки, пойманные заглушками

* **Опоздавшему слоту мерять нечего.** Прежде стояло `finish(readAudioDevice())`:
  аргумент считается **до** вызова, и живой `AudioContext` поднимался даже после
  того, как поле отдано пустым. Замерено заглушкой «слот задержан на 2500 мс»:
  маячок ушёл на 3149 мс с `audio: null`, а на 4247 мс зонд всё равно будил
  звуковую службу на чужой странице — на холодном браузере это ещё и блокировка
  потока.
* **Предел на ожидание слота, а не на измерение**: синхронную блокировку таймером
  не прервать ничем. Замерено заглушкой «первый контекст держит поток 2000 мс»:
  маячок сдвинулся с 3158 на 3721 мс, а поле приехало заполненным. Хвост замера
  растёт вместе с блокировкой, и это описанная выше цена, а не то, от чего защищает
  предел.
* **Контекст не закрывался.** Прежде `close()` стоял в том же `try`, что и чтение;
  замерено заглушкой с бросающим геттером — создано 1, закрыто 0. Теперь закрытие
  отдельно и обязательно: живой контекст держит поток аудиодвижка до ухода
  посетителя.
* **Таймеры не снимались.** Замерено счётчиком на странице: создано 7, снято 4;
  внешний предел не снимался никогда.

### Поля устройства

* Каждое поле под своим `safe`: у машин с подделанной из JS поверхностью геттер
  умеет бросать, а таких в корпусе половина. Замерено заглушкой «`outputLatency`
  бросает»: общий `try` терял **весь** замер устройства.
* `channelCount` у живого контекста — **не** свойство устройства, а умолчание
  спецификации. Замерено: восемь контекстов подряд и контекст с `latencyHint`
  `"playback"` дали 2, а `OfflineAudioContext(6, ...)` даёт 6 — число приходит из
  конструктора, не от драйвера. Машины различает `maxChannels`; `channels` держим
  ради моновыхода и ради подделок, у которых оно разъезжается с `maxChannels`.
* Округление `baseLatency` до микросекунды — не косметика. Хеш сочетания считается
  по всему замеру, и дрожание в пятнадцатом знаке размножило бы одну машину на
  десяток «разных устройств».
* `outputLatency` растёт, пока звук реально играет. На маке замерено 0 и в тишине, и
  при играющем осцилляторе; до миллисекунды — та точность, на которой поле
  описывает устройство, а не момент замера.
* `null` от `readAudioDevice` — это и «поверхности нет», и «Chrome не дал ещё один
  контекст». Оба случая честнее пустого поля, чем выдуманное значение.

### Голоса синтеза речи

Список голосов — это список того, что стоит в операционной системе: её версия, её
языковые пакеты, докачанные человеком голоса. У Dolphin под эту поверхность отведён
отдельный файл в форке Chromium — значит, машины она различает сильно. Замерено на
маке: 176 голосов, 157 из них локальные, 45 языков; хеш списка повторяется от
прогона к прогону.

**Почему промис.** Первый `getVoices()` почти всегда возвращает пустой массив:
движок синтеза грузится отдельно и потом стреляет событием `voiceschanged`.
Замерено: первый вызов — 0 голосов, событие приходит через 497 мс на холодном
браузере и через 178 мс в соседней вкладке. Синхронный замер был бы пустым у большей
части посетителей — та же смерть поля, что у старого `audioProbe`.

**Что храним.** Хеш, длину, число локальных, число языков. Сам список — это 176
строк на посетителя, в корпусе они не нужны. Имя голоса по умолчанию не берём: его
человек мог выбрать сам в настройках ОС, а мы описываем машину, а не человека; язык
этого голоса берём, он про систему.

Список сортируем: порядок `getVoices()` между вызовами не обещан никем, а хеш обязан
описывать **набор** — иначе одна машина размножится в корпусе. Флаги в строку хеша
входят наравне с именем: подделанный список ошибается обычно как раз в
`localService` и `default`, а не в именах.

**Проверено подстановкой заглушек** (у изолированного мира свой глобальный объект,
поэтому `speechSynthesis` подменяется, не трогая страницу):

| Заглушка | Результат |
| --- | --- |
| нет `speechSynthesis` | `null` за 0 мс |
| список пуст навсегда | `{count: 0, timeout: true}` ровно на 1202 мс |
| `getVoices` бросает | `{count: 0}`, а не исключение |
| голоса пришли через 400 мс | посчитаны |
| голоса пришли через 3000 мс | замер ушёл на 1201 мс пустым, поздний `voiceschanged` никого не разбудил второй раз |

**Проверено сквозным прогоном на настоящей странице** (а не подстановкой): список,
у которого бросает геттер **одного** голоса и который приходит событием, ронял
разбор целиком — маячок не уходил вообще, а сайт получал в свой сборщик ошибок
«Uncaught Error» с нашей строкой. Отсюда `safe()` вокруг всего обхода.

«Не дождались» и «голосов нет» обязаны быть разными ответами: иначе машина с
медленным движком ляжет в корпус как машина без синтеза речи, то есть отдельным
«устройством». Замерено: ожидание 406–423 мс на тёплом браузере и 1125 мс на занятой
странице — при пределе 1200 мс запас 75 мс.

Таймер снимается: после события он висел бы в чужой очереди ещё до 800 мс. Замерено:
подписка снималась на 2134 мс, таймер — никогда.

Вызвать `getVoices()` надо в любом случае: он же и будит движок синтеза, без него
`voiceschanged` может не прийти вовсе.

### Сеть

`navigator.connection` подделывают и Dolphin, и Octo. Замерено на маке: объект есть,
но из пяти полей у настольного Chrome существуют четыре — `effectiveType`, `rtt`,
`downlink` и `saveData`; поля `type` в прототипе нет вовсе (весь прототип:
`onchange`, `effectiveType`, `rtt`, `downlink`, `saveData`). Само его отсутствие —
признак настольной сборки: на Android `type` есть, и профиль «телефон» без него
противоречив. Поэтому пишем явный `null`, а не пропускаем ключ: «спросили и не
нашли» и «не спрашивали» — разные ответы.

**Внимание сборщику.** `downlink` и `rtt` описывают **канал** в момент замера, а не
машину. Замерено на трёх подряд визитах одной и той же машины: `audio`, `voices` и
всё остальное совпали побайтно, а `net` дал `{1.7, rtt 0}`, `{1.8, rtt 100}`,
`{1.6, rtt 100}` — и `combo_hash` сборщика насчитал **три разных устройства** вместо
одного. Тот же расчёт без ключа `net` даёт один хеш на все три визита.

Поэтому `net` обязан быть исключён из `combo_hash` так же, как `site` и
`population`, иначе счётчик разнообразия корпуса перестанет что-либо значить, а
возвратность посетителя — самый дешёвый признак антидетекта — исчезнет. Округление
(до 0,1 Мбит/с и до 50 мс) дробление уменьшает, но исключения не отменяет.

---

## 5. Структура и следы подделки

### Зачем

Остальные группы описывают железо. Эта описывает **сборку** и то, трогали ли её.
Порядок ключей у интерфейсов задаётся порядком IDL при компиляции: он меняется от
версии к версии и от платформы к платформе, а перекомпилированный форк меняет его
так, как ни одно обновление Chrome не меняет. Второе — обёртки: функция, подделанная
из JavaScript, отличается от нативной по нескольким независимым признакам сразу.

**Почему это нужно корпусу.** Метка популяции сегодня ставится по домену: магазин —
«люди», проверяльщик прокси — «антидетекты». Это догадка. Поля группы дают
**измеренный** признак вмешательства, и метку можно будет проверить данными, а не
доверять ей.

**Чего здесь нет.** Перечислимых глобалей (`Object.keys(window)`) — ни списка, ни
счётчика. Замерено: `var x` и `window.x = ...` создают **перечислимые** свойства, а
неперечислимыми остаются только интерфейсы самой сборки. Значит перечислимая часть —
это скрипты сайта и расширения посетителя, то есть описание **человека**, а не
машины. Берём только неперечислимую.

### Список `TARGETS`

Порядок фиксирован: каждая пара шестнадцатеричных цифр в поле `fn` относится к
строке с тем же номером. Менять порядок нельзя — старые замеры станут нечитаемыми;
новое дописывать только в конец.

Список не выдуман: это ровно те поверхности, которые перехватывают Dolphin, Vision и
Octo.

Поля строки: имя для человека; `"m"` метод или `"g"` геттер; функция, которая
достаёт держателя из **глобального** объекта (аргумент — `window` главного мира или
окно iframe, поэтому одна и та же проверка работает в обоих мирах); ключ; ожидаемая
перечислимость дескриптора (у членов WebIDL она `true`, у встроенных в JavaScript —
`false`).

Две строки в списке стоят особняком:

* `iframe.contentWindow` — держатель iframe: им прикрывают сверку миров. Обёрнутый
  `contentWindow` сам по себе сильная улика.
* `Function.toString` — сам инструмент проверки. Подделка, которая переопределяет
  `toString`, прячет все обёртки разом. **Замерено: себя она при этом тоже прячет**,
  если сделана Proxy'ем поверх нативного `toString`: имя остаётся `"toString"`,
  строка остаётся с `[native code]`, собственных свойств у Proxy ровно два — в
  главном мире ноль флагов на всём списке. Ловится такая подделка только полем `fnX`
  (тот же осмотр, но инструментами соседнего мира).

### Флаги `checkFn`

Признаков несколько и они **независимы**: подделка, обходящая один, обычно попадается
на другом.

| Бит | Что значит | Как замерено |
| --- | --- | --- |
| 1 | поверхности нет в этой сборке | не подделка, а отличие сборки или небезопасного контекста (см. `secure`) |
| 2 | в строке нет `[native code]` | самая грубая подмена |
| 4 | строка безымянная (`function () { [native code] }`), а имя у функции есть | так выглядит Proxy поверх нативной функции и результат `bind` — настоящая нативная всегда называет себя |
| 8 | у функции есть собственное свойство `prototype` | у нативной собственных свойств ровно `["length","name"]`, у обычной `function(){}` появляется третье |
| 16 | флаги дескриптора не нативные | у метода WebIDL `writable`/`enumerable`/`configurable` все `true`, у геттера — `enumerable` и `configurable` `true`. Подделка через `defineProperty` без явных флагов даёт `false` и выдаёт себя |
| 32 | имя функции не то, которое ожидалось | |

Третий аргумент `checkFn` — **инструменты**, которыми смотрим:
`getOwnPropertyDescriptor`, `getOwnPropertyNames` и `Function.prototype.toString`. По
умолчанию берутся из мира, в котором исполняемся, но подделка обычно первым делом
подменяет именно их. Сверка миров передаёт сюда инструменты соседнего мира и смотрит
ими на главный — замерено, что только так ловится обёртка-Proxy, спрятанная
подменённым `toString`.

`scanProto` — сплошной обход прототипа: ловит подделку **любого** свойства, включая
те, которых нет в `TARGETS` и которых ещё не было, когда писался код. Держим два
самых подделываемых интерфейса; `Document.prototype` сюда не берётся — он огромен, а
платят за обход посетители чужих сайтов. Потолок списка 12 имён: в норме он пуст.

### Что снимается и почему именно так

* **Небезопасный контекст** (http, about:blank) прячет часть интерфейсов: замерено
  985 глобалей против 1239 и 36 членов `Navigator` против 83 на одной и той же
  сборке. Без признака `secure` числа несравнимы.
* **Неперечислимые глобали** считаются разностью множеств, а не опросом
  дескрипторов: замерено 13,9 мс на опрос 1238 дескрипторов против доли миллисекунды
  на разность. На чужом боевом сайте это разница между «незаметно» и «кадр
  пропущен». Префикс `"#"` — потому что объект-множество без него ломается на ключах
  `"__proto__"` и `"constructor"`.
* **`Navigator.prototype`: два хеша, а не один** — в порядке IDL и отсортированный.
  Совпал отсортированный, разошёлся порядковый — множество то же, а порядок другой:
  так выглядит **пересобранный форк**, потому что обновление Chrome меняет и состав
  тоже. `tail` — три последних имени: свежие добавления IDL называют версию точнее,
  чем длина, и стоят три коротких строки.
* **Собственные свойства экземпляров.** В задании это было сформулировано как
  «переопределённое уезжает в конец». Замерено — **не уезжает**: порядок строковых
  ключей это порядок **вставки**, а переопределение существующего свойства вставкой
  не является (`Object.defineProperty(Navigator.prototype,"userAgent")` оставил его
  на том же 18-м месте, длина 36 не изменилась). Настоящий след другой: у чистого
  Chrome собственных свойств у `navigator` и `screen` **нет вовсе**, список пуст.
  Подделка на экземпляре — а так делают почти все — создаёт их, и они действительно
  оказываются в конце, потому что до них там не было ничего. Непустой список
  означает вмешательство почти наверняка. Берём имена, не значения, и усечённые:
  сайт может повесить на `navigator` своё поле, и длинное имя из чужого SDK нам в
  корпусе не нужно.
* **Обращение через `safe`, а не голой ссылкой.** Замерено: `navigator`, `screen`,
  `Navigator` и `Screen` — собственные свойства `window` с `configurable: true`, то
  есть их можно удалить. Голое имя удалённого бросает `ReferenceError` мимо
  внутренних `safe`, а `collect()` в `probe.js` обёрнут одним `try` на всё — значит
  падает не эта группа, а весь замер, и посетитель не отправляет **ничего**.
  Проверено: `delete window.Navigator` перед зондом ронял его целиком. Разница между
  `null` и `[]` сохранена: «не смогли спросить» и «пусто» — разные ответы.
* **Тождества.** Единица в бите — **сломано**, поэтому у здоровой машины здесь ноль
  и поле почти не занимает места. `clientInformation` — второе имя того же
  `navigator`; замерено, что в чистом Chrome это **один** объект. Подделка,
  подменившая `navigator` целиком, второе имя обычно забывает.
* **Арность** нативных функций задаётся тем же IDL и достаётся даром — отсюда
  `fnLen`.

### Сверка с соседним миром

Подделка из JavaScript ставится в главный мир; свежий same-origin iframe — это
**другой** мир с собственными объектами (замерено:
`w.HTMLCanvasElement.prototype.toDataURL !==` главного). Расхождение между мирами
означает подделку скриптом, совпадение при грязных флагах — подделку в самой сборке.
Для корпуса это ровно та граница, которая отделяет дешёвый антидетект от форка
Chromium.

Что замерено про безопасность на чужом сайте:

* iframe без `src` отдаёт `contentWindow` **сразу**, ждать `load` не нужно — поэтому
  функция синхронная и таймаут ей не требуется;
* **CSP не мешает**: проверено на `frame-src 'none'` и `default-src 'self'`, кадр
  создаётся, нарушения не возникает, в консоли пусто. Это важно — нарушение CSP
  попало бы в чужой сборщик ошибок;
* **вёрстка не двигается**: `scrollHeight` до и после совпал до пикселя, число узлов
  возвращается к прежнему, `window.frames` возвращается к нулю. `MutationObserver`
  сайта получает **две** записи, а не одну: добавление кадра и его удаление
  (замерено: `childList` +1/−0 и `childList` +0/−1). Остаточный риск — чужой скрипт,
  считающий кадры в тот самый миллисекундный промежуток.

Цена на странице в 7500 узлов — самая дорогая строка группы:

| Что | Мак | Торможение ×6 (≈средний телефон) | Торможение ×20 |
| --- | --- | --- | --- |
| кадр | 4,1–6,9 мс | 29–118 мс | 82–310 мс |
| вся группа (структура + кадр) | 11 мс | 97 мс | 407 мс |
| подбор шрифтов из нынешнего `probe.js` | 125 мс | 618 мс | 2241 мс |
| canvas | 19 мс | 144 мс | 775 мс |

То есть кадр добавляет к зонду около десятой части его нынешней цены, а даёт
единственный признак, которым ловится подделка **только** в главном мире: в проверке
такой профиль прошёл все остальные поля чисто и разошёлся лишь с кадром.

Кадр невидим и вынесен за экран, `aria-hidden` и `tabIndex -1` — он не должен
попадать ни в чтение с экрана, ни в обход по Tab. Удаляется в `finally`: уйти из
функции, оставив кадр в чужой вёрстке, нельзя ни при какой ошибке.

**Чего в сверке нет и почему** — замерено, а не выведено: `navigator.plugins` в
свежем мире стоит 244 мс (`PluginArray` строится для нового realm с нуля),
`Intl.DateTimeFormat()` — 13,9 мс на первую инициализацию ICU в realm. В главном
мире то и другое уже построено и стоит доли миллисекунды, поэтому цена и незаметна
на глаз. Обе величины уже снимаются главным миром в `d.hw` и `d.intl`; платить за их
сверку 258 мс на чужом боевом сайте нельзя.

**Поле `fnX` — самое сильное в группе:** главный мир, осмотренный инструментами
кадра. Замерено на подделке «Proxy поверх `toDataURL` плюс Proxy поверх
`Function.prototype.toString`»: в главном мире ноль флагов на всех 18 целях, кадр от
главного мира ничем не отличается, `diff` пуст — то есть без этой строки такая
подделка не ловится **ничем**. Тот же осмотр инструментами кадра даёт флаг 4 и на
обёртке, и на самом подменённом `toString`: чужой `toString` не обязан врать в нашу
пользу и показывает Proxy безымянным. `null` здесь значит «не смогли спросить» и не
равен строке нулей.

---

## 6. Экран, шрифты и батарея

### Что замерено, а не выведено

Chrome 151 и Safari 17.5 на macOS 15, headless и с окном:

* `(color-gamut: p3)` в окне = `true`, в headless = `false` — поле живое и заодно
  ловит headless;
* `(dynamic-range: high)` на **одном и том же** экране: Chrome `false`, Safari
  `true`. Значит это признак «экран + движок», а не только экрана;
* `video-dynamic-range`, `inverted-colors`, `prefers-reduced-data` в Chrome 151 дают
  `false` на **всех** своих значениях, включая обязательные, — не реализованы. В
  зонде их нет;
* **несуществующий медиапризнак возвращает `.media` = сам себя**, а не `"not all"`.
  Определить «понимает ли браузер признак» через `.media` **нельзя** — правдоподобное
  рассуждение, опровергнутое замером;
* `getBattery` и `screen.isExtended` существуют **только** в защищённом контексте: на
  `http://192.168.0.100` обоих просто нет. На http-сайте сетки эти поля будут
  пустыми, и это не поломка;
* `getBattery` в Safari отсутствует вовсе (`typeof === "undefined"`).

### Правило отбора медиапризнаков

Признаки берутся не пачкой «всё, что есть в спецификации», а по одному правилу:
признак остаётся, только если **замер** показал, что он реализован и не константа.
Дешевизна тут ни при чём — 1000 вызовов `matchMedia` замерены в 2,3 мс, то есть весь
блок стоит доли миллисекунды, и соблазн «взять всё» надо гасить именно поэтому:
мёртвое поле не бесплатно, оно врёт о том, что мы что-то знаем.

Две оси намеренно разделены. `caps` — про **железо**: охват монитора, глубина цвета,
органы ввода, второй монитор. `pref` — про **настройки человека**: тёмная тема,
уменьшенная анимация, контраст. Вторая ось корпусу тоже нужна (профиль с «включённым
тёмным режимом» правдоподобен только если мы знаем, у скольких он включён), но
смешивать их нельзя: настройку человек меняет за секунду, монитор — нет.

Пустая строка от лестницы `mqLadder` — **честный ответ «не знаем»**: замером
установлено, что несуществующий признак тоже даёт `false`, поэтому «все `false`» и
«не реализовано» для нас одно и то же, и притворяться, что мы различили, нельзя.

### Поля `caps`

* **`gamut`** — цветовой охват, самое различающее из всего блока: p3 есть у маков и
  дорогих мониторов, у офисной техники нет. Лестница сверху вниз: rec2020 включает
  p3, p3 включает srgb, поэтому берём максимум.
* **`dynRange`** — HDR. Замерено: на одном и том же экране Chrome отвечает
  `standard`, а Safari `high`. Поэтому поле осмысленно только вместе с UA, и именно
  такие пары — то, ради чего корпус собирается: «Safari + standard на маке с p3»
  будет сочетанием, которого у живых машин не бывает. Лестницей, а не булевым:
  `standard` по спецификации совпадает всегда, поэтому пустая строка означает
  «признак не реализован», и это **отдельный** ответ от «HDR нет».
* **`colorBits`** — разрядность канала. `screen.colorDepth` в Chrome всегда 24 и
  потому мёртв; здесь же 10-битный монитор отличается от 8-битного. Замерено на этом
  маке: `(color: 10)` = `false` при `colorDepth` = 24 — то есть поле говорит то, чего
  `colorDepth` сказать не может.
* **`mono`** — монохромный экран: читалка, кассовый терминал, промышленная панель.
  Редкость, но именно поэтому сильный признак, когда встречается. **Три значения, а
  не два**, и это тот самый случай из `CLAUDE.md`, где «не удалось спросить» и «нет»
  обязаны различаться. Первая редакция возвращала `true`, когда `matchMedia`
  отсутствовал вовсе, — то есть записывала каждую сломанную машину в монохромные.
  Поймано враждебным прогоном, не рассуждением.
* **`pointer` / `anyPointer` / `hover`** — органы ввода. Ноутбук с сенсорным экраном
  даёт `fine` + `coarse` одновременно, и это именно железо, а не человек.
* **`posture` / `segH` / `segV`** — складные устройства. Замерено, что признаки
  реализованы: `(device-posture: continuous)` = `true`,
  `(horizontal-viewport-segments: 1)` = `true`. На обычной машине это константа, зато
  складной телефон — редкая машина, и корпусу важно знать, что она бывает.
* **`extended`** — второй монитор. Разрешения **не требует**: разрешение нужно
  `getScreenDetails()`, а голое `screen.isExtended` читается молча — замерено, окно
  Chrome ничего не спросило. `getScreenDetails` мы не трогаем и трогать не будем.
  `null` означает «поля нет», и это отдельный ответ от `false`: замерено, что в
  Safari поля нет вовсе, а на http-странице оно исчезает и в Chrome. Слить их значило
  бы записать «второго монитора нет» там, где мы просто не спросили.
* **`standalone`** — установленное веб-приложение вместо вкладки. Не про железо, но
  про **обстановку**, а не про человека, и стоит один вызов.

### Поля `pref`

Отдельным полем и отдельным именем — чтобы на сервере их нельзя было случайно
принять за описание железа. Ось другая: это не «какая машина», а «как её настроили»,
и в анализе распределений эти две вещи должны стоять по разные стороны.

* **`ok`** — признак живости всего блока. Без него «`matchMedia` нет» и «светлая
  тема» приехали бы на сервер одинаково, и распределение по темам перекосило бы ровно
  на число сломанных браузеров. По спецификации одно из двух значений совпадает
  всегда, поэтому ноль совпадений означает не «светлая», а «не спросили».
* **`contrast`** — `no-preference` стоит последней ступенью намеренно. Без неё пустая
  строка означала бы разом и «человек ничего не настраивал», и «движок такого
  признака не знает» (`prefers-contrast` приехал в Chrome заметно позже
  `prefers-color-scheme`, так что `ok` его не покрывает). Замерено в Chrome 151:
  `(prefers-contrast: no-preference)` = `true`, остальные три `false`, — то есть
  пустая строка теперь честно значит «не спросили».
* **`forced`** — `forced-colors` единственный тут с двойным дном: сам по себе это
  настройка (высокая контрастность), но включить её можно практически только в
  Windows, поэтому `true` — ещё и слабый признак платформы.

### Шрифты-маркеры (`FONTS2`)

**Это не «ещё сто шрифтов».** Пересчитано по живому корпусу (30 машин со шрифтами из
31 строки): 55 нынешних кандидатов дают 9 разных наборов.

| Платформа | Машин | Наборов | В чём различие |
| --- | --- | --- | --- |
| iOS | 20 | 2 | ровно одно имя (Monaco) |
| Windows | 3 | 2 | DejaVu/Liberation/Noto — линуксовые шрифты на машине, которая зовёт себя Windows |
| macOS | 4 | **3** | наборы на 36, 22 и 16 имён |

Третий маковский набор — главное, что в корпусе есть, и его нельзя терять в
округлении «не больше двух на платформу»: UA говорит Macintosh, а
`navigator.platform` у той же строки — `Linux x86_64`, ядер 2, зона UTC, и среди её
16 имён нет **ни одного** эксклюзивно маковского, зато есть Segoe UI, Calibri,
DejaVu, Liberation, Noto. Нынешние 55 имён уже поймали подделку — вот прямой довод,
что дело не в длине списка.

Разница 36 против 22 — 14 имён, и это **не** «ровно набор Office»: половина из них
стоковые маковские (Andale Mono, Apple Chancery, Big Caslon, Chalkboard, Geneva,
Herculanum, Monaco, Skia), а сами две строки — это ещё и Chrome 151 против Safari
15.6. То есть различают версия ОС, доустановленные пакеты и движок вместе, а не один
Office.

Отсюда состав списка: только имена, привязанные к конкретной версии Windows/macOS
или к конкретной программе. Общих красивых шрифтов, приезжающих вместе с платформой,
там нет: они не добавляют ни бита к тому, что уже сказал первый список.

**Цена замерена, а не прикинута.** Chrome, свежий профиль, четыре запуска: 55 имён —
92 мс, 153 имени — 216 мс. Разложено по причине: одно имя, которое **на машине
есть**, стоит ~1,9 мс (шрифт грузится), которого нет — ~0,65 мс.

Список `FONTS2` замерен отдельно, пятью прогонами на свежем Chrome: 55 старых имён
82–91 мс, эти 43 сверх них — 40–60 мс (верхний край — свежий профиль, перемерено
отдельным стендом; **не сокращайте эту вилку до 49**), повторный вызов на прогретой
странице 1,4–1,7 мс, из них 11 попаданий на этом маке. Сто имён стоили бы вдвое
дороже, и почти всё — впустую: лишние попадания приезжают вместе с платформой, о
которой первый список уже всё сказал.

Отдельно: в Safari первый в жизни проход по незнакомым именам занял **11,6 секунды**
(повторный — 9 мс). Один такой случай на боевом магазине — это замерший интерфейс, и
это второй довод против длинного списка.

**Почему маска, а не список имён.** Причина не в красоте: самый длинный список имён в
живом корпусе (36 шрифтов, мак) весит 482 байта на посетителя при всём замере в
5,0 КБ, а маска на 43 позиции — 11 шестнадцатеричных символов, весь узел `fonts2` —
45 байт. Разворачивается на сервере однозначно, потому что набор фиксирован и
пронумерован версией `lv`: менять `FONTS2` можно только увеличив `lv`, иначе старые и
новые замеры станут несравнимы молча.

**Замер идёт пакетом**: все `span` создаются разом, и только потом читаются ширины.
Так раскладка считается один раз, а не по разу на имя. Замерено: 92 → 80 мс на 55
именах, 216 → 188 мс на 153. Выигрыш небольшой, потому что главная цена — загрузка
самих шрифтов, а не раскладка; но он бесплатный.

**Уборка обязана висеть на выходе из блока**, а не последней строкой удачного пути.
Замерено: если чтение `offsetWidth` бросает (стенд подменял геттер), внешний `safe()`
ловит исключение, а **133 наших узла остаются в DOM чужого магазина навсегда**.
Отсюда `try/finally` вокруг всего, что идёт после вставки, и один контейнер на все
узлы: убрать его — значит убрать все 133 узла разом.

Хвост маски короче четырёх бит дополняется нулями справа — так длина строки
однозначно равна `ceil(len/4)` и разбор на сервере не зависит от того, сколько имён в
списке.

Раскладка `FONTS2` по причинам (комментарии сохранены в самом коде): Windows по
версиям системы (Segoe UI Variable Display и Segoe Fluent Icons — только Windows 11,
Segoe MDL2 Assets — Windows 10, и так вниз до Vista), Windows с установленным Office
(Aptos — новый умолчательный в Microsoft 365 / Office 2024; Wingdings 2 замерен на
этом маке — значит Office стоит), macOS по версиям (Galvji — 10.15+, Grantha Sangam
MN — 10.13+, и так далее; SF Mono приезжает с Xcode/Terminal), Linux/Android/ChromeOS
и пакеты (Carlito и Caladea — метрические замены Calibri и Cambria из LibreOffice,
Lucida Bright приезжает с Java).

### Батарея

Разрешения не требует — замерено: окно Chrome ничего не спросило, промис вернулся за
0,4 мс. Но требует **защищённого контекста**: на http-странице `navigator.getBattery`
отсутствует как свойство. И в Safari его нет вообще — то есть примерно для двух
третей нашего живого корпуса этого замера не будет никогда. Это не повод его не
брать, но повод не строить на нём выводов.

**Чего здесь намеренно нет:** сырых `level`, `chargingTime` и `dischargingTime`. Три
причины, и каждая замерена.

1. Это метка **сеанса**, а не машины. `dischargingTime` на этом ноутбуке — 12307
   секунд; через минуту будет другое. Корпус считает строки как «разные устройства»,
   и сырое поле превратило бы один ноутбук в десятки устройств.
2. `JSON.stringify(Infinity) === null`. Замерено: `dischargingTime` доехал бы до
   сервера как `null`, неотличимо от «поля нет». Поэтому берём не число, а его форму —
   «бесконечность или нет».
3. Заряженный ноутбук в розетке отдаёт `charging=true`, `level=1`, `chargingTime=0`,
   `dischargingTime=Infinity` — ровно то же, что настольная машина без батареи
   (замерено, сверено с `pmset -g batt`). Значит «ноутбук или десктоп» этим полем
   **не** различается, различается только «сейчас на батарее» и «сейчас нет».

Что берём вместо: наличие самого API, факт разряда и грубый квинтиль заряда —
последнего хватает, чтобы построить **распределение** (ради него корпус и
существует), и мало, чтобы размножить машину. Обе бесконечности разом — типичная
подпись виртуалки и машины без батареи.

**Важно для сервера:** `battery` обязан быть исключён из `combo_hash` так же, как
`site` и `population`. Иначе инвариант «строк столько, сколько разных устройств»
сломается на первом же ноутбуке.

Срок 400 мс — не бюджет, а предохранитель: замеренная цена 0,4 мс, но на движке, где
промис не разрешится никогда, зонд обязан уйти без батареи, а не зависнуть.
`clearTimeout` не для скорости: без него после ответа за 1 мс на чужой странице ещё
400 мс висит наш таймер (замерено — один живой таймер сразу после `resolve`). Зонд
обязан не оставлять следов, а таймер — такой же след, как узел в DOM.

---

## 7. Общее: холст, WebGL, отправка, запуск

### Адрес приёмника

Относительный по умолчанию: каждый сайт принимает замер сам, на свой же домен.
Абсолютный адрес означал бы, что магазин при каждом визите стучится на посторонний
сайт, — лишняя связь между проектами прямо в браузере посетителя и лишняя точка
отказа. Побочно исчезает CORS: свой домен не требует ни разрешений, ни
предварительных запросов.

Метка популяции: магазины дают «как выглядит обычный человек», площадка проверки
прокси — «как выглядит антидетект». Разница между этими двумя множествами и есть то,
ради чего корпус собирается; смешать их значило бы потерять главное.

### Холст и WebGL

Хранится хеш, а не картинка. Картинка весит десятки килобайт на посетителя и не
нужна: связывает профили именно совпадение хеша, распределения строятся по нему же.
Два разных рисунка — текст с эмодзи и геометрия, — потому что они задействуют разные
пути отрисовки: шрифтовой растеризатор и Skia.

Признак «холст испорчен» (`tainted`): у настоящего Chrome чистый холст читается
всегда, а невозможность его прочитать — след флага `--disable-reading-from-canvas`,
то есть след антидетекта.

WebGL снимается в четыре точки, а не в одну. Строку рендерера подменить легко,
поэтому ценность в остальном: набор расширений, лимиты и `precision` описывают
реальные возможности драйвера и с подменённой строкой обязаны сходиться. Замерено на
маке: **Intel через Metal даёт 35 расширений, AMD через OpenGL — 31, и списки
разные**. Заявленная видеокарта при чужом списке расширений — готовое противоречие.

Настоящая отрисовка (`render`): пиксели рисует драйвер, и подменить их строкой
нельзя. Это то, что остаётся правдой даже когда всё остальное подделано. Тот же
принцип у `audioRender`: считает реализация платформы.

Основной список шрифтов замеряется шириной строки, а не через API: списка шрифтов
браузер не отдаёт. Набор жёстко привязан к ОС и её версии, поэтому в корпусе он один
из самых говорящих признаков — и один из тех, что флагами не двигаются.

### Порядок в `collect()` и `run()`

* `availTop` у мака ненулевой из-за строки меню, у Windows — ноль. Дешёвый и очень
  говорящий признак платформы.
* Два смещения часового пояса (`offJan`, `offJul`), а не имя зоны: по ним видно
  правила перехода на летнее время, и подменённое имя без правильных правил здесь
  разойдётся.
* `d.secure` нужен, чтобы читать `uaHigh`: `null` при `isSecureContext = false`
  означает «страница по http», а не «браузер не Chromium».
* `gpuProbe` обязан идти **после** `glProbe`: WebGL поднимает GPU-процесс, и после
  него запрос адаптера стоит 5–6 мс вместо 375–631 мс на холодную.
* `d.fonts2` снимается только при наличии `navigator.userAgentData`, то есть только
  на Chromium (см. раздел 1). `null` — честный ответ «не снимали», а не «шрифтов
  нет».
* Медиаустройства: только **количество**. Метки и идентификаторы описывают конкретную
  машину человека («Микрофон Пети»), корпусу не нужны. Отдельным промисом, потому что
  `enumerateDevices` асинхронный и на части браузеров отклоняется; отказ не должен
  уносить весь замер.

### Отправка

`sendBeacon` не держит соединение и переживает уход со страницы. Посетитель не должен
ждать наш замер ни одной миллисекунды.

Тип строго `text/plain`, и это не мелочь. `application/json` **не входит** в список
безопасных для CORS, поэтому требует предварительного запроса `OPTIONS` — а маячок
его делать не умеет: браузер молча отбрасывает отправку целиком. Замерено: страница
грузится, зонд отрабатывает, до сборщика не доходит ничего и в консоли пусто. Сервер
разбирает тело как JSON независимо от заявленного типа.

### Когда запускаться

По первому признаку живого человека, а не по загрузке страницы.

Причина **не** в разрешениях: всё, что зонд снимает, доступно и без действия
пользователя — `OfflineAudioContext` рисует звук без жеста, canvas и WebGL тоже.
Причина в качестве выборки. В журналах сетки от 10 до 25 процентов запросов — сканеры
и обходчики; они грузят страницу, и без этого условия их машины попали бы в корпус
наравне с настоящими. Корпус, разбавленный чужими серверами, врёт о распределениях
именно там, где должен быть точен.

Признаков несколько, потому что не всякий человек кликает: кто-то листает, кто-то
ведёт мышью, кто-то жмёт клавишу. Хватает любого. Ждём сколько угодно — посетитель,
ушедший не тронув страницу, нам не интересен. `passive: true` — обработчики не должны
мешать прокрутке.

Замер идёт в простое: он не имеет права конкурировать за главный поток с самим сайтом
в момент, когда человек с ним работает.

---

## 8. Приёмная сторона: что уже сделано

Эти правки лежат в репозитории и проверены.

* **`collector.ALLOWED` знает все новые поля** — иначе они молча выбрасывались бы при
  приёме.
* **`NOT_IN_HASH` пополнен** полями, которые меняются у одной и той же машины сами:
  `net` (замерено: три захода подряд дали rtt 0, 100, 100), `battery`, `pref` (тёмная
  тема у macOS переключается на закате) и вложенное `caps.extended` (ноутбук
  поставили в док). Каждое такое поле в хеше дробит одну машину на несколько
  «устройств».
* **В таблице `combos` появилась колонка `probe_v`**, и `mantia corpus-summary`
  показывает разрез по версиям: складывать устройства разных версий нельзя, у них
  разные хеши.

## Чем зонд может навредить чужому сайту

Раздел появился после разбора, заказанного отдельно, — «найди, чем он вредит
магазину». Все три находки были настоящими и все три исправлены с замером
«до/после». Здесь они оставлены как список того, что проверять при любой правке
зонда.

**1. Контексты WebGL не освобождались.** Замерено счётчиком на самой странице:
создано 2, освобождено 0. У вкладки предел около шестнадцати контекстов, и при
переполнении браузер принудительно теряет **самый старый** — то есть наш замер
мог погасить витрину самого магазина. Лечение: `WEBGL_lose_context.loseContext()`
после снятия. После правки — создано 2, освобождено 2.

**2. Общий срок мог отправить СЫРЫЕ значения.** Срок на весь замер добавлялся,
чтобы один зависший промис не терял посетителя. Но он может сработать раньше,
чем досчитается `hashHeavy`, и тогда в теле остаются не хеши, а исходники.
Замерено подменой `crypto.subtle.digest` на вечный промис: тело **64 036 байт**
с `data:image/png;base64,…`, то есть настоящей картинкой холста — ровно тем,
что шапка файла обещает не собирать. Плюс упор в предел `sendBeacon` (64 КБ) и
падение на менее надёжный `fetch`. Лечение: `dropUnhashed` перед отправкой
обнуляет всё длиннее 40 символов там, где ожидается хеш из 32. После правки —
7 150 байт, поля `null`.

**3. Три пути ошибки в чужой сборщик.** `fetch` в запасном пути отправки без
`.catch` даёт необработанный отказ, который видит `window.onerror` сайта, то
есть его Sentry. Плюс узлы шрифтов и прямоугольника убирались без `finally` —
исключение посередине оставляло наш узел в чужом DOM. Лечение: `catch` у
`fetch`, `try/finally` вокруг обоих узлов.

**Правило на будущее.** Проверять зонд не только на «работает ли», но и на
четыре вопроса: бросает ли наружу, оставляет ли следы, держит ли ресурс с
лимитом, и что уходит в тело при СРАБОТАВШЕМ сроке, а не при удачном прогоне.
Последний вопрос самый неочевидный: удачный прогон выглядит одинаково и до, и
после ошибки.
