VFLIN

docs · docs/30-core/FINGERPRINT_CONFIG_CONTRACT.md · synced 2026-11-08

Fingerprint configuration

the validated identity catalogue and its consistency rules

Purpose: define fingerprints as a structured, validated catalog with consistency rules, not random JSON. This contract is implementable now (schema + validation) even though deep engine enforcement waits for the patched core. The same config drives every provider.

1. Why a contract first

Detection is driven by incoherence between signals, not by individual “bad” values. So the config’s job is less “pick random values” and more “guarantee a coherent device identity”. The catalog is the brain (what to present); the Core Provider is the hands (how).

2. Config schema (v0)

{
  "id": "uuid",
  "label": "win11-chrome-148-desktop",
  "os":        { "family": "windows", "version": "11", "arch": "x86_64" },
  "browser":   { "engine": "chromium", "version": "148.0.x", "uaCh": { /* derived, see §4 */ } },
  "hardware":  { "cores": 8, "memoryGb": 16, "gpu": { "vendor": "...", "renderer": "..." } },
  "screen":    { "width": 1920, "height": 1080, "dpr": 1.0, "colorDepth": 24,
                 "available": { "left": 0, "top": 0, "width": 1920, "height": 1032 } },
  "locale":    { "language": "en-US", "languages": ["en-US","en"], "timezone": "Europe/Berlin",
                 "uiLanguage": "en-US" },               // OPTIONAL (B191/A235): the browser interface a person chose — §2x
  "fonts":     { "set": "win11-default", "additions": [], "removals": [] },
  "media":     { "audioContext": "coherent-noise",       // read by patch 0027 (A67: expressible since 2026-08-31)
                 "audioFft": "legacy",                // OPTIONAL, read by patch 0049 (B94); absent = the engine's own FFT — §2d
                 "devices": "win11-typical" },       // a preset NAME (the catalog's); the core reads the resolved OBJECT — §2c for why
  "webrtc":    { "policy": "proxy-only", "publicIp": "from-proxy", "localIps": "masked" },
  "canvas":    { "mode": "stable-noise", "seed": "per-profile" },
  "rect":      { "mode": "noise" },                  // OPTIONAL, read by patch 0057 (B102); absent = stock DOMRect — §2f
  "identity":  { "passkeys": "profile" },            // OPTIONAL, read by patch 0059 (B101); absent = stock WebAuthn (Windows) — §2g
  "privacy":   { "doNotTrack": true },               // OPTIONAL, read by patch 0060 (B104); absent = stock DNT pref — §2i
  "webgl":     { "vendorMatchesGpu": true, "noise": "stable" },
  "network":   { "proxyRef": "uuid", "quic": true,
                 "tls":   { "ja3": "derived", "ja4": "derived" },           // match claimed browser
                 "http2": { "settings": "derived", "akamaiFp": "derived" } },
  "provenance":{ "source": "catalog|generated|imported", "realDeviceRef?": "..." }
}

2a. screen.available — the available rectangle

screen.avail{Left,Top,Width,Height}, resolved by the catalog and carried whole. Dotted paths, for a consumer reading the JSON: screen.available.left · .top · .width · .height.

All four or none. The field is one object rather than four keys so that a half-filled rectangle cannot be written; absent means “not carried” — a config minted before this field existed — and a consumer must fall back to its own derivation rather than reading absent as zero.

Where the numbers come from. vflin-fingerprint’s validate::SCREEN_INSETS holds the OS chrome as per-edge insets with provenance per row (Measured / Assumed, same discipline as SCREEN_MODES), and validate::available_rect resolves them against the display. Windows reserves at the BOTTOM (48 CSS px on 11 — measured on a real host, 40 on 10); macOS reserves 25 at the TOP (the menu bar, Dock modelled as auto-hidden); Linux 27 at the top. Under host-inversion (ADR-0031) the SIZE comes from the host’s own availWidth/availHeight when the display being claimed is the host’s; the ORIGIN still comes from the catalog, because a capture does not read availLeft/availTop.

Why it is a rectangle and not a delta. A single “reserve N px” cannot say which edge the N px are on. Patch 0011 held exactly that — four literals in chrome/browser/vflin/fp_config.cc (AvailHeightDeltaForOsFamily) subtracted from screen.height with the origin forced to (0, 0) — so a modelled Mac reserved its menu bar and reported it along the BOTTOM of the screen. Hardcoded identity values are what invariant 5 forbids; this is where they now live.

2b. media.audioContext and canvas.mode — one field, one surface

State of play, corrected by A67 (2026-08-31). media.audioContext is now expressible from the product: crates/vflin-fingerprint’s model carries an optional media object and set_audio_noise / audio_noise_on flip it, mirroring the canvas pair. Until then the field was declared here and had no Rust field at all, so patch 0027 — shipped and working — could only be reached by hand-written JSON, which is how two sessions proved the patch while the gap stayed invisible. Absent means off, and the product default is off (the owner’s instruction: available, not default-on).

Superseded by B96 (patch 0051): media.devices is READ now — as an object, see §2c. The paragraph that follows is the state before it. media.devices is still read by nothing, and §2c now says what closing it costs. A67 typed the model side and left the meaning of a preset to the surface slice; B59 measured that surface and found the mechanism cannot be built at the enumeration layer alone. The field stays declared and unread, which is honest, rather than half-implemented, which would not be.

Each of these two fields enables exactly one surface, and neither decides for the other. That sentence was false until patch 0027 (2026-08-30) and is worth stating because of how it failed.

0022 (audio-noise) shipped gated on canvas.mode == "stable-noise", so the switch named canvas also turned on the Web Audio perturbation — while media.audioContext, declared in the schema above and named as 0022’s config key in the series plan, was read by no parser arm at all. The documented control did nothing; an undocumented one did the work. MEASURED by K10 outside CreepJS with a bare OfflineAudioContext: sample 4500 read -0.14573481678962708 with the canvas switch off — stock to the last digit — and -0.14574481546878815 with it on, from a config carrying no audio field. CreepJS billed it separately as AudioBuffer: "audio is fake".

Since 0027, audio noise is enabled by media.audioContext: "coherent-noise" and by nothing else. Proven live (B55): with canvas.mode: "stable-noise" and no audio field the sample is -0.14573481678962708, identical to stock; with media.audioContext set and canvas.mode: off it moves to -0.14572481811046600.

The seed is still shared, and that is deliberate. The audio perturbation is keyed on canvas.seed folded under a disjoint "audio" domain, so the two features never share a key — two features on one key would be a cross-feature correlation a detector could exploit. Residual: audio therefore cannot be enabled without a canvas.seed present to fold. Giving audio a seed of its own is a schema change and has not been made.

Still advertised and still unread: media.devices. B59 attempted it and withdrew the patch; §2c records what it measured and what closing the surface actually requires. (Until B96: patch 0051 reads it — §2c’s first block.)

2c. media.devices — measured, attempted, withdrawn, and READ by 0051 (B96)

Read since B96 (patch 0051 media-devices), in the place this section named. The projection runs in the browser process, in MediaDevicesManager::DevicesEnumerated — the only way into the list that enumerateDevices, getUserMedia’s choice of device, selectAudioOutput/setSinkId and devicechange read — so enumeration and capture move together, which is exactly what B59’s two forms below could not do. What the engine reads is the resolved OBJECT, never the preset name:

Correction, B143 (ISSUE-532, the owner’s BrowserScan frame: «Use available cameras (1)», OBS Virtual Camera, the OBS logo in the preview). This section used to say Chrome’s own pickers read that list too. The permission prompt does not — nor its camera/microphone preview, Page Info’s and Settings’ device lists: they read media_effects::MediaDeviceInfo, a cache the video capture and audio services fill directly. So the prompt named the host’s camera while the page enumerated the identity’s (measured: core/scripts/camera-prompt-probe.mjs, Chrome’s fake devices — prompt fake_device_0, page HD Pro Webcam C920 (046d:082d)). Patch 0075 runs the same projection on that cache, built the way MediaDevicesManager builds its list, so both roads show the same devices in the same order under the same names; the prompt’s choice (Chrome ranks devices by name) then ranks the page’s list too. After it the probe reads the identity’s camera on both roads (--gate PASS; FAIL on the build before). What the camera PLAYS is §2k’s business: with no file chosen it is the host’s real camera — on a machine whose camera is OBS, OBS’s own picture.

B144, on the owner’s own device class, not a fake. This machine’s only camera is OBS Virtual Camera (DirectShow; no other camera, no microphone), so camera-prompt-probe.mjs --host-devices reproduces the frame itself. Before (M155-official): the prompt «Use available cameras (1)» → OBS Virtual Camera, the page HD Pro Webcam C920 (046d:082d) — --gate FAIL. After (0075): prompt and page both HD Pro Webcam C920, OBS on neither road. With a camera file in the profile (--camera-file, 0067’s media.video.source) the prompt’s PREVIEW plays the file, the page’s track is named HD Pro Webcam C920 and runs, enumerateDevices() after the permission gives the same name — --gate PASS on both roads (window captured by --shot). Without a file the name is the profile’s and the picture is whatever the host camera gives: here OBS was not running and the device would not open (NotReadableError with and without an identity alike); on a machine where OBS plays, its picture.

"media": { "devices": { "audioinput": 1, "videoinput": 0, "audiooutput": 2,          // caps on the host's own devices
                        "labels": { "audioinput": ["…"], "audiooutput": ["…", "…"] } } }  // optional, from real captures
  • A count caps the host’s devices of that kind; 0 hides the kind (getUserMedia answers NotFoundError, as on a machine without it) — except audio outputs, where 0 is read as 1: playback to the default output is authorized outside this list (AudioOutputAuthorizationHandler), so an empty output list sat beside setSinkId('default') resolving (Codex 0051-2, reproduced by the gated test). A count outside 0..16 or not an integer: the host’s list.

  • A count above the host’s was the host’s until patch 0086 (B153, ISSUE-543) — no device was invented, since nothing could be captured from it. For MICROPHONES that reason is gone: 0084 draws a seeded profile’s sound in the audio service for whatever non-loopback device id is opened, so the microphones a profile claims and the machine has not got are now ADDED to the list, with Windows-shaped ids and groups drawn from the profile’s seed and with the virtual entries a machine with microphones always lists. Measured before it (K79, reproduced by core/scripts/media-devices-probe.mjs): a profile claiming THREE microphones showed a page NONE — the audioinput kind absent from enumerateDevices() before and after the permission — while the same profile’s camera read «HD Pro Webcam C920» and its speakers «Speakers (Realtek(R) Audio)». On a machine WITH microphones a page would have read the machine’s. After it: the three are listed under the profile’s own names, the opened track reads «Default - Microphone Array (Realtek(R) Audio)» and carries the drawn room (RMS 0.0019, floor 58 % of the mean, two recordings 0.0 % alike); with a host that HAS as many microphones as the claim, nothing is invented and the host’s own devices are kept and renamed exactly as before. Still never invented: audiooutput (no drawn playback behind it) and videoinput (the capture service keeps its own device list — a different seam), and nothing at all without a canvas.seed, since a device a page sees but cannot open is worse than a device it never saw. A measured consequence worth knowing: Chromium’s own GuessVideoGroupID matches a camera to an audio input by name, so once the catalog’s «Microphone (HD Pro Webcam C920)» exists, the camera «HD Pro Webcam C920 (046d:082d)» joins its group — one physical webcam with a microphone, as a real one reads.

  • The Windows virtual entries default / communications keep the device they name — found by NAME (their label is Default - <that device's name>), in their group, the group alone only without a name match — so Default - X never names a hidden X, even when that keeps one device more than the count (Codex 0051-1: two microphones can share a group, and “the first of the group” kept the wrong one).

  • Labels, when given, rename the kept devices in order and the virtual entries follow (Default - <new name>); a list with any empty, over-128-byte or control-character name is dropped whole. Without labels the host’s names stay (after a permission, as stock) — the brief said “otherwise empty”; an empty label after a granted permission is what no Chrome shows and is exactly the list BrowserScan hashes, so this is recorded as the owner’s decision, one line to change. Still the owner’s to answer (ISSUE-297 (б), A120): the product writes no labels (A119), so the machine’s names are what every profile shows after a permission until he does.

  • Device ids are not touched: Chrome already hashes them with a per-profile salt and the origin (measured B96: two profiles on one origin read different ids, one profile reads the same twice).

  • The preset NAME is not read by the engine. "win11-typical" stays a catalog term; what it means in devices is the catalog’s to resolve from captures (ADR-0035). The model carries both shapes verbatim (vflin-fingerprint Media.devices is a JSON value since B96, round-trip tested).

  • What the product writes (A119, ISSUE-297): 0 for each of audioinput / videoinput that the newest HEADED capture of the identity’s OS family (in captures/) lacks — a capture runs before a permission, so it proves which kinds a machine HAS, never how many or their names — and nothing else: no audiooutput: 0, no labels; no such capture, no field. The validator (A7_MEDIA_DEVICES_*) refuses a shape the engine would ignore or read otherwise and warns on a preset name.

  • A manual list (A124, ISSUE-336; floor 0 since A127, ISSUE-351 — the owner: «не у всех есть все три»): a count per kind, 0..3, with one name per kept device drawn from the catalog (fingerprint.mediaDevices) — a name list must match its count (A7_MEDIA_DEVICES_LABELS_COUNT), so a kind at 0 names none. A microphone and a camera go down to 0 (the kind hidden), AND SO DOES AN OUTPUT since B119/A149 (2026-10-11): the clamp that read audiooutput: 0 as one is gone from patch 0051 and the daemon’s refusal (A7_MEDIA_DEVICES_OUTPUT_ZERO) with it. The reason the clamp existed – that playback to the default output is authorized outside the list, so a resolving setSinkId('default') beside an empty list looked like a contradiction – was MEASURED on a machine brought to a state with no output device at all: the kind is absent entirely and setSinkId('default') still resolves, so the pair is exactly what such a machine answers. The catalog read says each kind’s floor in minByKind (now {audioinput: 0, videoinput: 0, audiooutput: 0}); its manualMin is the floor safe for every kind, for a screen that applies one floor to all three.

  • A drawn count (A141, ISSUE-417/351 — the owner: «чаще 1, иногда 2, редко 3+, иногда 0»): a NEW identity (fingerprint.generate, the generator’s default and a preset at profile.create) carries media.devices drawn from its own seed — the same seed, the same draw — by media_devices::COUNT_SHARES, names distinct and from the catalog for its OS (a kind at 0 names none):

    kind 0 1 2 3
    audioinput (microphone) 5 % 70 % 20 % 5 %
    videoinput (camera) 30 % 58 % 10 % 2 %
    audiooutput (speakers) 2 % 68 % 25 % 5 %

    Three rules over the table (B181/B182, ISSUE-756, 2026-10-31). (1) A webcam brings its own microphone: a drawn camera means at least one microphone; with NO camera the microphone is there at media_devices::MIC_WITHOUT_A_CAMERA_PCT (50 %) — the microphone row above then gives its count, a 0 read as 1. (2) A machine’s own devices first (ALWAYS_OWN: a MacBook/iMac all three, a Windows or Linux laptop its microphone and speakers — the daemon refuses to switch these off) and by default (OWN_BY_DEFAULT: a Windows laptop’s built-in camera — drawn always, removable by hand; the owner: «для ноутбуков… обязательно ставим камеру», and «можно убрать»). (3) The camera’s 30 % at 0 is the owner’s «шанс, что нету веб-камеры… большой шанс, но не всем» (his «образно говоря, 60 %» as the floor). Measured over 4 000 draws a class: a desktop has a camera 70 %, a microphone 85 %, all three 69 %, neither camera nor microphone 15 %, a camera without a microphone 0; a Windows laptop all three 100 %; a Linux laptop its microphone and speakers 100 %, a camera 71 % (no catalog name for its own yet). The daemon’s A191 still claims a camera for every drawn profile until control-plane narrows it to machines with their own — then the desktop’s 70 % is what a new profile carries.

    These shares are a PRODUCT choice in the owner’s words, not a measurement — no public table counts devices per machine. Speakers draw 0 at 2 % since A149 (the engine keeps 0 since B119, and setSinkId('default') still resolves on such a machine). The measured machine’s truth wins over the draw: a kind the newest headed capture of the OS family LACKS stays 0 with no names (A119’s rule — a random count never invents a device that machine never had), and a count above the host’s is the host’s, as for any count. A config brought whole (inline, or an identity from this machine) keeps what it says. Tests: a_drawn_device_count_follows_the_shares (50 000 draws within 2 points of each share), every_generated_identity_carries_valid_devices_named_for_its_os, and the daemon’s a_new_identity_draws_its_devices_and_a_kind_its_machine_lacks_stays_zero. A183 (ISSUE-511): a machine’s OWN device comes first, and the kinds it always has are always there (media_devices::ALWAYS_OWN). A MacBook and an iMac name their own speakers, microphone and camera first (one model for all three), a Windows laptop its onboard speakers and a microphone array first and an integrated camera first whenever it names a camera; such a kind is never drawn at 0, attached devices are added after it. K68 read a MacBook identity with its own speakers 38 % of the time and its microphone 27 % — B139 had made the built-in one option among the attached ones; now 100 % / 100 % (gate a_machine_names_its_own_devices_first_and_always). A desktop that is not an iMac still names no built-in device: a Mac mini’s own speaker has no row yet (ISSUE-511).

  • Measured (B96): no config = the host’s list exactly as stock 153; {videoinput: 0, audiooutput: 1} → before a permission audiooutput only, after it default, communications and the output they name; the suite’s gated test crates/vflin-consistency/tests/media_devices_projection.rs (Chrome’s fake devices — the hidden camera answers NotFoundError, the claimed name is the captured track’s label) GREEN on the core, RED on stock; CreepJS devices (2): audio, webcam → devices (1): audio.

  • Still open: a kind the host LACKS cannot be claimed into existence — that needs a synthetic capture source and provenanced names (the last paragraph below). On a host without a microphone, no profile shows one.

What follows is B59’s record, kept as it was.

B59 built the mechanism, measured it working, and withdrew it. This section exists so the next attempt starts from what that cost rather than from the schema line.

What the surface actually is. Before permission Chrome exposes at most one entry per kind, with deviceId, groupId and label blanked — media_devices_manager.cc:1183 breaks out of the per-kind loop when permission is absent. So the only thing a page can read without asking is which kinds exist; a count cannot be read back, and emitting several entries for one kind would be a tell no real browser produces.

What was measured on this host (B59, M149, and each leg twice):

leg result
our core, no identity 2 devices — {videoinput, audiooutput}, no microphone
our core, with an identity identical — the config moved nothing
stock Chrome 152 identical

So this was never a divergence from stock. It is that every profile on one machine enumerates that machine, which links them to each other exactly as the voice list (ISSUE-063) and the WebGL capabilities did.

Why the patch was withdrawn. Two forms were built and both were incoherent, and the sixth gate found each:

  1. Substituting the list, pre-permission only. The moment a grant revealed a real device id the projection switched off and the same origin saw a different machine than a moment earlier. It also repopulated a list Chromium had already filtered by Permissions-Policy, so a document denied the microphone could be handed one.
  2. Filtering only — removing kinds, applied always. Coherent across permission and invents nothing, and it measured correctly (a narrowing claim removed the camera; the control kept the host set; a claimed-but-absent microphone was correctly not invented). But getUserMedia() never consults it: a site sees no camera in enumerateDevices() and then captures video from the host’s camera. Hiding a kind from enumeration while capture still yields it is a single-session self-contradiction, and hiding is the only thing that form can do.

What closing it requires. Enumeration and capture have to move together — enumerateDevices(), getUserMedia() and selectAudioOutput() filtered by one decision, which means the projection belongs in the browser process beside MediaDevicesManager, not in Blink’s result callback. Adding a kind the host lacks additionally requires synthesising a device with an id and, after permission, a label, so it needs provenanced device names — catalog data under ADR-0035, not an engine guess.

Until then the field stays declared and unread, which is the honest state; the alternative on offer was a mechanism whose only function created a contradiction sharper than the linkability it removed.

2d. media.audioFft — the FFT an identity’s Web Audio runs on (patch 0049, B94)

Carried by the product since A117 (2026-09-27): the field lives in the Media model and round-trips through profile.update (the config is an open object in the contract); under the version pin (C10: the claimed major equals the engine’s, 152) the validator still REFUSES "legacy" because it claims an engine older than 152 — the owner’s word (OWNER_REVIEW Q9: a user’s choice, not a default) makes that a coherence WARNING instead of a refusal, which is A118’s to build.

"legacy" makes the identity’s renderers run Web Audio on the FFT Chrome used before 152 (the browser appends WebAudioRustFft to the renderer’s --disable-blink-features); absent, or any other value, is the engine’s own FFT, as stock. MEASURED (core/scripts/audio-sum-probe.mjs, FingerprintJS’s and CreepJS’s graph): the one Blink feature is the whole difference between the two sums detectors know — 124.04347776696522 with it (stock 152/153 and this core), 124.04347527516074 without it (every engine before 152, the value CreepJS lists for Chrome) — and all 5000 samples of the rendered buffer equal stock’s either way. It is the claim of an engine older than 152: a detector that knows 152 moved to the Rust FFT reads the old sum on a 152 UA as a contradiction, so the field is off by default and set only where the catalog asks for it (the owner’s word, 2026-09-26; ISSUE-287 — the product’s Media model does not carry the key yet, so a config the product round-trips drops it).

2e. The form’s deep fields — written in place (A121, ISSUE-316/315)

  • imported: [..] (A122, ISSUE-324) — which identity fields another program’s export set (userAgent, screen, language, timezone) through profile.importText. Checked by this identity’s rules, not measured on this machine: the validator SAYS it (IMPORTED_FROM_FILE, a warning), never refuses. The core ignores the key.

  • browser.uaManual: true — the operator TYPED browser.ua (profile.update {userAgent}). C1 then says the difference from the derived UA as a WARNING (C1_UA_MANUAL: UA-CH stays derived from the version pin, and a page comparing the two sees two browsers) instead of refusing; the launch’s repair of stored UAs and a core retarget leave a typed UA alone. "", or profile.fingerprint.regenerate {fields:[userAgent]}, derives it again.

  • webrtc.publicIp — "from-proxy" (auto) or an IP literal: WebRTC «manual». The launch hands that address to the core as network.udpSubstitute (0040: the page’s reflexive candidate is it, no datagram leaves) with no relay point and no QUIC through one; on a core without 0040 nothing is written.

  • Noise, only as the engine reads it: canvas.mode is ONE switch for canvas, WebGL readback and WebGPU readback (0021/0024/0034); media.audioContext is audio’s (0027); canvas.seed feeds all of them (and font substitution) under separate domains. profile.update {noise} takes real|noise|manual per surface: webgl must equal canvas, two different manual seeds are refused, and rect takes real|noise|manual like audio since A126: noise writes rect.mode: "noise" into the identity (patch 0057 reads it — §2f; the seed is the shared canvas.seed), real removes the key, so a config with the switch off is byte for byte one minted before it.

  • (B103: a core carrying patch 0058 lays the page out at screen.dpr by itself and draws the window at the host’s scale — the forced factor below is then what still shrinks a profile on a 4K screen, ISSUE-364, and the launch should drop it for such a core; with the flag kept, 0058 engages nothing.)

  • The launch forces --force-device-scale-factor=<screen.dpr> (a dpr in 0.5..=4; none otherwise), so the page’s layout runs at the scale devicePixelRatio claims on any host — the owner’s rule after PC2 (2560×1440 at 125 %), where layout at the host’s 1.25 under a claimed 1.0 turned CreepJS’s DOMRect card red (ISSUE-315). The window bound follows the factor. A GENERATED identity takes the host’s scale from the capture, and SCREEN_MODES gained the exact Windows scalings (2048×1152@1.25 is the owner’s PC2); a named preset keeps its own. (A130: a core whose manifest says series 47+ — it carries 0058 — gets no flag; one of an older or unknown series keeps it.) Since A183 (ISSUE-505/506) the screen is DRAWN at that scale, not moved to it. A121’s screen_at_scale took the row at the host’s scale with the closest WIDTH, of any group and OS: on a machine at 100 % it put 46 % of Windows laptops on 1280×720 @1 (a 720p TV panel — every 150 % FHD and 200 % QHD laptop is 1280 wide), gave a Windows laptop at 200 % a MacBook size 21 % of the time and a MacBook at 125 % a Windows pair 100 %. Now generate_seeded_for_core_vendor_at(…, scale) draws among the class’s own rows at that scale by the same weights (the survey’s panel share split over the scales the panel is offered at), and when the class has none there it falls back in this order (generator::ScaleFit): Docked — a laptop on its OS’s monitors (a MacBook on a 100 % machine is a MacBook at a desk on a 1x display, and keeps the Air or Pro its own panel was drawn as, with that model’s speakers and microphone); Widened — an office desktop on any monitor of its OS; OtherOs — the same kind of display of another OS (a Mac or Linux at 125 %: a residual said, the owner’s rule kept at that price); Unmatched — no row at that scale anywhere (175 %): the identity keeps the scale it was drawn at. Without a scale the draw is byte for byte the old one. The daemon logs a fit other than Own/Widened for the class it draws.

  • The concentration gate (A183, concentration.rs). What identities ARE, drawn the daemon’s way for every class, card maker (none / nvidia / amd / intel) and host scale (1, 1.25, 1.5, 2), 1 000 draws a cell: no screen more than 5 percentage points above its share in life (generator::screen_shares_expected — the catalog’s weights at that scale), none the class cannot be at that scale, and no attached device product on more than a third of a class (a machine’s own device and a name a crowd shares by construction are not held to it). Two cells were over the device line the day it was built and are NAMED in KNOWN_OVER_CEILING (a Mac desktop’s outputs, ISSUE-511); the gate fails if one grows, if a new one joins, and if one falls under the line and is still listed. Mutants run: the old nearest-width shift, the monitor row back, the built-in as one option, the count floor removed, a docked MacBook without its model — each red.

  • No DevTools device-metrics override on a core that projects the screen itself (A144, ISSUE-423). The launch’s identity apply pushed Emulation.setDeviceMetricsOverride (deviceScaleFactor = screen.dpr, screenWidth/Height) onto the tab it attached to, on every core. 0058 yields to DevTools by design, so in that tab a CROSS-SITE frame laid its viewport out against the host’s factor — measured through the product on a 100 % host, identity 1536×960 @ 1.25: a frame drawn in a 600×360 box reported 480×288 (B116: 750×450 the other way round, host 125 %, identity 1.0), while a tab the override never reached reported 600×360. A core whose capabilities deep-project C6 (0011’s screen.* and dpr; the scale by 0058, or by the flag above on an older series) now gets no override — after: 600×360 in both tabs, screen/avail/window/devicePixelRatio unchanged. The stock core keeps it: there it is the only thing that gives the page the identity’s screen (its cross-site frames and its other tabs still read the host’s — the stock ceiling).

2f. rect.mode — DOMRect noise (patch 0057, B102, ISSUE-334)

"noise" makes a text box under a transform read, through Element.getClientRects / getBoundingClientRect and Range.getClientRects / getBoundingClientRect, as if that transform’s scale were a few parts in ten million off — up to ~1e-3 px, per profile, stable, the same on all four paths; absent, "real" or any other value is stock. The seed is canvas.seed folded under the "rect" domain (its own switch — canvas.mode does not decide it, 0027’s rule), so a profile can have rectangle noise with canvas noise off and the other way round; without a canvas.seed nothing moves. Layout is not touched.

What moves is only what a page cannot predict, because CreepJS’s DOMRect card is a TABLE for the rest: at devicePixelRatio 1 a hidden 100×100 box turned 45° must hash to 9d9215cc, and a known empty box under scale(1.000999) reads exactly Math.fround(100 × 1.000999) on stock. So nothing moves without a transform on the way (every untransformed box, text included, reads on the layout grid), and under the nearest transformed ancestor-or-self — the ROOT — nothing moves unless its transform takes boxes off the layout grid (a non-dyadic 2-D linear part or a perspective, decided on the matrix — a translate-only box never moves), its own box is an axis-aligned rectangle and it holds text anywhere under it. Under an eligible root every quad is taken back into the root’s own space, scaled there by 1 ± (1..16) × 2⁻²³ per axis (the profile’s constant), and mapped through the root’s transform again — the change is made where a real one would be, so Blink’s own mapping rounds the result as it rounds stock’s: an element and its own text line keep their shared edges (Range’s union of the two equals the element, as on stock), no two edges change order, a known corner stays, a moved box behaves under float rounding as on stock. MEASURED with CreepJS’s own five checks (core/scripts/creep-lies-probe.mjs, DOMRect section), /lab/’s cj-domrect row and the gated test crates/vflin-consistency/tests/domrect_noise.rs. Residuals, named: a text box whose SIZE the page fixes in CSS under a scale it knows reads a moved far edge the page could recompute; an empty box inside a text-holding root goes through the root’s map; boxes under a nested transform use their own root’s map; untransformed text (the most common font probe) is not moved — off the grid would itself be the tell; SVG geometry (getBBox) is never moved; IntersectionObserver’s boundingClientRect is not moved (it already differs from getBoundingClientRect on 23 of 32 transformed boxes on stock).

2g. The catalogs of A128 (the owner’s test of 2026-10-01) — screens, cards, platform versions, fonts, the second card mode

  • Screens (ISSUE-355). SCREEN_MODES rows now say their group (laptop / monitor), their panel (size × scale) and that panel’s share in the Steam survey (August 2026 — the panel’s, every scale together; no public statistic splits a panel by scale), and whether a new identity may be made on them (offered). The pairs are panels at a scale Windows actually gives them — Microsoft’s DPI table (Table 1: 13.3″ QHD at 200 %, 15.4″/17″ FHD at 125 %, 24″ QHD at 125 %, 23″ 4K at 200 % …) or an exact division at a scale its Table 5 allows (at least 720 effective lines) — floor 1024 wide, 720 lines. A row no longer offered (ISSUE-048’s suspect Mac sizes, the pre-audit 2048×1152@1) stays VALID, so a profile minted on it still launches; the generator and the host-scale move draw only offered rows (the Mac classes and the macos-chrome-laptop preset moved to Steam’s MacBook “looks like” sizes). fingerprint.screenModes hands out group, offered, panelWidth, panelHeight, panelSharePct and the two sources.
  • Cards (ISSUE-381). Beside the draw pool (a competitor’s catalog, Borrowed) an open table (gpu_open.rs): each model with its pci.ids device id(s) and its Steam share — NVIDIA 48, AMD 12, Intel 13 models, ≥ 4 architectures each; Intel’s ids read one by one from pci.ids here, NVIDIA/AMD from K32’s join minus the HDMI-audio id it had taken. fingerprint.gpus carries it as openModels, and each pool card the table knows says its architecture, pciName and steamSharePct.
  • The cards the survey never saw, made choosable and drawable (A146, ISSUE-431). The owner: «у нас даже в каталоге нету 5050 карты и других новых». Measured through fingerprint.gpus, which is what the editor lists: 253 cards and no RTX 5050 — while the draw pool already had ten RTX 50 rows, so the gap was specific rather than generational. Five of the open table’s NVIDIA/AMD models had no pool row and can be carried honestly — RTX 5050 Laptop GPU, RTX 5070 Laptop GPU, RTX 5070 Ti Laptop GPU, RTX 4080, RTX 3050 Ti Laptop GPU (4 SKUs), Radeon RX 7800 XT, 12 rows (gpu_open::OPEN_ONLY_WINDOWS) — so fingerprint.gpus answers 265 and each new row says source: open beside its Steam share. Three provenances in one string, none Assumed: the FORM is Measured (this host’s capture, and K32 tested the shape against 170 independent rows), the device id is Derived (pci.ids, K32’s rule), the name is Derived (the Steam survey’s own string). The draw weight is a floor, not an estimate: one machine per row — a card this survey never saw is drawn no more often than the rarest card it did see. The first attempt scaled Steam’s share by the pool’s 4 462 machines and was measured and rejected: it put 40 % of generated win11-laptop identities on the new cards. With the floor: 7.0 % of 200 win11-laptop and 0 of 200 win11-desktop, and the choice list’s top three are unchanged, so the crowd still leads (ADR-0037). An existing profile does not move: the draw runs at mint and the card it produced is stored in the profile’s config, which every later read uses. Four candidates were dropped by the pool itself, not by taste: its observed names are GTX 1060 3GB / 6GB / 5GB and Radeon RX 570/580 Series, so Steam’s aggregated GTX 1060 and AMD Radeon RX 580 are strings no driver reports. Intel’s 13 open-only models are excluded for the same reason, with the proof in the table: the pool observed Intel(R) Iris(R) Xe Graphics (0x00009A49) where the open table has Intel Iris Xe Graphics for the SAME id — a real Intel driver decorates its name (Intel(R), Iris(R), Arc(TM), (8GB)) and pci.ids does not. Those, and any macOS or Linux renderer form at all, wait on a capture from a machine that has the card (recon’s half of ISSUE-431).
  • Every variant the driver names, and no server card (A167, ISSUE-381). The owner: «у нас в каталогах видюх должны быть все видюхи и со всеми вариантами 0x000 … чтобы не добавить ну совсем серверную карту». Recon’s K58 brought 2 062 rows, each with the source of its string and of its id (docs/90-meta/recon/K58-gpu-table.csv); scripts/measure/a167-k58-driver-table.py keeps 166 and writes them to gpu_driver.rs (DRIVER_WINDOWS, source: driver): NVIDIA only — name and id from NVIDIA’s driver 580.126.09 supportedchips, the measured form — because AMD’s 644 and Intel’s 225 rows have no driver string (pci.ids names a chip family, not what a driver reports); Kepler and newer (the oldest architecture the survey observed; vs_5_0 also needs feature level 11); not disputed by K58; not a laptop CHIP under a desktop name (RTX 2060 on TU106BM — a desktop would draw it by name); not already a pool row. The exclusion is a rule with a test, not an omission: gpu_pool::is_server_or_workstation (Tesla, Quadro, GRID, NVS, RTX A/PRO/… Ada Generation, NVIDIA’s bare datacenter names and mining boards, DGX, Radeon Pro / FirePro / Instinct, Arc Pro with a board number, Data Center GPU) keeps such a card out of the list and the draw from ANY table; checked against K58’s own class it catches all 281 rows K58 calls workstation/server and 21 more. The survey’s eight observed workstation cards (30 machines) stay in WINDOWS as observation and leave the list. no_server_or_workstation_card_is_offered_or_drawn fails if an authored table gains one, if the survey’s list changes, or if any draw hands one out. Weight: the survey saw none of the 166, so all of one slice together weigh UNSEEN_MACHINES = 3 machines (the rule of three) — a weight is machines × 1 000 now so that fits; measured 0.20 % of the desktop-discrete draw and 1.57 % of the laptop one (a floor of one machine each would have been ~40 % of laptops). is_laptop_part also reads the lettered laptop parts (GT 750M, 940MX, RX 6600M, RX 7600S), which moved three observed cards out of the desktop draw. The pin moved (3 of 8 seeds) by the same arithmetic as A166; no stored profile moves.
  • Cameras and microphones (ISSUE-352). media_devices::DEVICE_NAMES gained 9 Windows cameras (Logitech C930e, StreamCam, BRIO 4K Stream Edition, C505e, C920 PRO, C525, C170; Microsoft LifeCam Cinema, Studio) and 4 USB microphones (HyperX QuadCast, AT2020USB+, ATR2100-USB, RØDE Podcaster) whose model and USB id usb.ids confirms (read 2026-10-01). Still Assumed like every row there: no capture holds a label (this host’s captures have none), and that a device prints exactly usb.ids’ name is not observed. A141 (ISSUE-418): the first two Measured rows — read on a real machine (this lane’s host, core s53, enumerateDevices() after a granted permission, 2026-10-04): Digital Output (High Definition Audio Device) and <the monitor's model> (NVIDIA High Definition Audio) (a display’s audio over HDMI). The same read named OBS Virtual Camera and Speakers (Steam Streaming Speakers); both are left out on purpose — a virtual camera is what an injection check looks for. The generator draws names from this catalog for the identity’s OS (§2c, the drawn count). A183 (ISSUE-507) took the monitor row out: Windows names a display’s audio after the MONITOR, so the row was one machine’s name, and it rode on 19–21 % of all Windows identities (K68) — a name our profiles share with each other and nobody else. Two gates keep it out: one_machine_names (every Windows audio row is an endpoint Windows gives: Speakers, Headphones, Microphone, Microphone Array, Digital Output, Realtek Digital Output) and, on Windows, no_catalog_row_is_one_of_this_machines_own_audio_devices (reads the build machine’s audio endpoints; a catalog row equal to one must be a Windows endpoint on a shared driver). Digital Output (High Definition Audio Device) stays: a driver name every such machine carries.
  • hardware.gpu.foreignVendor (ISSUE-375). Optional; absent is “not chosen so” (every older config reads the same). Set by fingerprint.generate in the second mode (random) or when gpuVendor names another maker, and only when the card’s maker differs from this machine’s: the row then says C2_GPU_FOREIGN_VENDOR_CHOSEN in words — the smoothing is the core’s WebGL noise, and until a core has it WebGL answers with this machine’s capabilities — instead of the C2_GPU_FOREIGN_VENDOR flag. Daemon-side only: the launch drops it.
  • Platform versions (ISSUE-360). PLATFORM_VERSIONS: the Sec-CH-UA-Platform-Version values a generated identity draws, with the release behind each and the value’s and the weight’s provenance apart — Windows 11 19.0.0 (24H2/25H2; measured on this host: 25H2 build 26200, Chromium 152), 15.0.0 (22H2/23H2), 14.0.0 (21H2), weighted 85/12/3 (Assumed from Microsoft’s servicing table — no public table splits Windows 11 by release); Windows 10 10.0.0; macOS 14.x as before. 26H2 (build 26300) and 26H1 (build 28000) are left out — their value is measured nowhere here. fingerprint.platformVersions reads it.
  • Fonts (ISSUE-376, the catalog half). fingerprint.fonts: vendor bundles with their source — Microsoft’s Windows 11 shipped fonts, each script’s Feature on Demand (installed with a language of the script), the optional pan-European set; Apple’s macOS Sequoia lists. fingerprint.generate with fonts: manual adds to a Windows identity the bundles a machine of it would have (shipped, its language’s script, the optional set on ~10 % of identities by its id) and resolves presentFamilies and the generics again; macOS/Linux add nothing (no source says which of their catalog fonts a machine has). What a list claims and what the engine draws are two things: the engine shows what THIS machine has — a claimed family the machine lacks is the core’s mechanism to decide, not this list’s.

2g. identity.passkeys — a passkey bound to the profile (patch 0059, B101, ISSUE-311)

"profile" gives the profile its own WebAuthn authenticator: every request of a regular (not off-the-record) context is served by it, the credentials are kept in <user-data-dir>/Vflin Passkeys, and nothing goes to Windows — no Windows Hello, no “Insert your security key”, no USB key, no phone, no Google Password Manager. Absent, or any other value, is stock (the Windows WebAuthn API). The field is read by the BROWSER only (no renderer, no page-visible projection): what a page sees is what a real passkey provider shows it.

What a page sees (the authenticator presents itself as the passkey provider Chrome ships): platform attachment, resident keys, ES256 only, attestation format none whatever the page asks, the AAGUID of Google Password Manager (ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4), flags UP + UV + BE + BS, a signature counter fixed at 0, a 16-byte random credential id, transports ["hybrid", "internal"]; isUserVerifyingPlatformAuthenticatorAvailable() true. What the person sees: Chrome’s own sheets — “Create a passkey for ” before a creation, the account sheet (“Your Chrome profile”) before a sign-in — and nothing from Windows. User verification is REPORTED, not performed: there is no biometric and no PIN; the sheet’s Continue is the confirmation (what the competitors’ profile passkeys do; ADR material if the owner wants a PIN). The file holds private keys (PKCS#8, base64url, JSON) — plaintext while the profile is unpacked on disk; pack seals it in the profile’s encrypted blob (ADR-0004) and export/sync carry it (A133). A passkey is never lost (B105, the owner’s F1b): beside the store, Vflin Passkeys.bak-<UTC time> keeps the original bytes of a store that had to be repaired (a newer build’s, corrupt, partly unreadable — every passkey it still gives is kept and the new ones are written), and Vflin Passkeys.unsaved holds the changes made while the store could not be read (locked), merged at the next read (an .unsaved that could not be read whole is copied to Vflin Passkeys.unsaved.bak-<UTC time> before it is rewritten), and a write that fails is tried again at 2/10/60/300 s. All Vflin Passkeys* files belong to the profile and travel with it. Measured in core/PATCHES.md (internal: core/PATCHES.md) row 0059.

2i. privacy.doNotTrack — Do Not Track (patch 0060, B104/B105, ISSUE-358)

A BOOL. true turns the profile’s Do Not Track on; false turns it off; absent (or a non-bool) leaves Chrome’s own preference, which is off by default. This is a STANDARD Chrome preference — the core puts prefs::kEnableDoNotTrack itself into Chrome’s command-line pref store for this run (vflin::ApplyDoNotTrackPref; never written to the profile’s Preferences), so every reader gets one value: the chrome://settings toggle, navigator.doNotTrack ("1" when on, null when off) AND the DNT: 1 request header. With the field present the settings toggle shows the config’s value and refuses a change (the identity decides); absent, it is Chrome’s. No new surface, no JS shim; navigator.doNotTrack is the native getter either way. Coherence: DNT is a user preference, not an OS/GPU-derived signal, so it contradicts nothing in the catalog; it is the owner’s choice to expose (a profile that claims DNT simply looks like a user who enabled it). MEASURED (settings-probe.mjs): doNotTrack:true -> navigator.doNotTrack === "1", the server sees DNT: 1, chrome://settings reads true; absent -> null, no header, settings false — byte-identical to stock (B105, after R8’s finding that the first form left settings showing OFF).

2j. webgl.mode — WebGL’s own switch (patch 0061, B105, ISSUE-375/376)

A STRING, apart from canvas.mode. Absent (or any other value) — WebGL follows canvas: its readbacks are noised exactly when canvas.mode is stable-noise, as in every build before 0061. "off" — no WebGL context on a canvas or an OffscreenCanvas, in a page or a worker (Chrome’s own creation error; the interfaces still exist). "real" — WebGL’s own readbacks (readPixels, a PIXEL_PACK buffer, toDataURL/toBlob of a WebGL canvas) are never noised. "noise" — they are noised whatever canvas.mode says, with canvas.seed folded exactly as the canvas seed is: one mask, so the routes agree when the switches agree and the canvas switch never moves the WebGL hash; no canvas.seed, nothing to noise with. Not the product model’s webgl.noise (the core does not read it). For an identity with another maker’s card (A128’s hardware.gpu.foreignVendor) the generator should write "noise", so the host card’s render hash is never what a page reads. Residual of MIXED switches only: a WebGL canvas drawn into a 2-D canvas is read there by the 2-D switch. Measured in core/PATCHES.md (internal: core/PATCHES.md) row 0061.

2k. media.video.source — the profile’s camera file (patches 0067–0070, B109; daemon half A161)

"media": {"video": {"source": "Vflin Camera/clip.mp4"}} — a path inside the profile’s directory that the core plays as the camera (0067 reads Y4M/MJPEG, 0068 MP4/MOV H.264, WebM/MKV VP8/VP9, MP4 AV1; it refuses AVI, HEVC, MPEG-4 Part 2 and Theora). Absent: the real camera.

  • The rule for the path is the core’s (VflinIsProfileRelativePath): not empty, at most 512 bytes, no control character, no :, not starting with a slash, no .. part. The validator refuses anything else as MEDIA_VIDEO_SOURCE_OUTSIDE — the core would read it as absent and use the real camera, so the config would claim a file the page never gets.
  • Set from the interface: profile.update {camera: "<path>" | ""} (CLI vflin profile update <id> --camera <path>) — written in place; refused while the profile runs (the core takes the config at launch). The profile’s detail says it as camera.
  • Set from the profile’s own window (0070’s camera button): the window copies the chosen file into <user-data-dir>/Vflin Camera/ and writes Vflin Camera/camera.json — {"version":1,"base":"<the media.video.source it chose against>","source":"<the file>"} (0069). The core reads it at every camera it opens; who changed it last wins: base equal to the config’s media.video.source → the window’s source, otherwise the config’s file.
  • Carried into the record when the profile is sealed (camera_choice::carry, beside the window’s note): by the same rule — a window choice made after the interface’s becomes the record’s media.video.source ("" clears it). The choice file stays (it is the core’s state and agrees with the record by the same rule at the next start).
  • The folder travels with the profile: Vflin Camera/ is not excluded from the sealed payload, so the video and its choice are sealed, synced and exported with the profile like Vflin Passkeys. Old copies are not pruned.
  • The key is carried (the audioFft lesson, ISSUE-287): a config naming a camera file keeps it through every round trip of the product; a config without it writes no key.

media.video.realCamera — whether “no file” is the host’s camera (patch 0078, B145, ISSUE-532)

"media": {"video": {"realCamera": false}} — a BOOL. false: while no file is chosen (neither the config’s source nor the window’s choice), a camera the page or Chrome’s permission prompt opens plays the profile’s own drawn picture (patch 0083, below), and the host’s device is never made. With no seed to draw from it is refused instead — the same capture error as an unreadable file, NotReadableError to the page (0078, the fail-closed floor: the host’s camera is never the fallback). Absent, true, or not a bool: no file is the real camera, exactly as before the field. A chosen file plays in every case.

Why, measured (core/scripts/camera-prompt-probe.mjs --gate, Chrome’s fake camera standing for the host’s): with 0075 the camera is NAMED the profile’s (HD Pro Webcam C920) on both roads, but with no file it PLAYED the host’s picture — the frame’s mean colour 8,151,2 in the identity leg and in the host leg alike. On the owner’s machine the host’s camera is OBS Virtual Camera, whose picture may be his screen.

B145 chose refusal over a picture of our own and the owner reversed that on 2026-10-19 («так для отпечатка нужно ж»; «надо чтобы картинка подгружаемая шла будто с камеры, которая указана»; and on the price of a drawn picture, «значит надо узнать, как гармонично сделать, чтобы имитировать реальную камеру»). What each option costs, with the decision as it stands:

  • One picture of ours for every profile — refused, and not by taste: one frame shared by every profile is an identifier that joins every profile a site records, the same joining K72 spent a day undoing. The owner also refused shipping files at all («не удобный вариант, надо чтобы пользователь просто, когда надо, подгружал»).
  • Refusal (B145’s choice, now the floor, not the answer) — a site that needs the camera gets NotReadableError, what a real Windows machine gives when its camera is busy in another program; getUserMedia({audio, video}) then fails as a whole, the microphone included. It remains what a profile with no seed gets.
  • A picture drawn per profile (chosen, patch 0083) — a page gets a moving picture that is this profile’s and no other’s, and the coupled {audio, video} request works. Its price, named plainly: it is not a room and not a face. A site that LOOKS at the picture — a KYC check, a video call, a human on the other end — sees a defocused surface, not a person; competitors ship a looping clip of a real person instead, which is a different trade (one recorded face, shared by every profile that plays it). What the picture does carry is measured below.

Who writes the field: control-plane (A190), and a new profile is born with the real camera off.

media.video.modes — the claimed camera’s passport (A192, ISSUE-532, K76; the core half is patch 0085, B151)

"media": {"video": {"modes": [{"width": 1280, "height": 720, "frameRate": 30}]}} — the modes the camera the profile CLAIMS offers: the largest frame at each rate step its maker names, largest first, from the catalog (media_devices::CAMERA_MODES, every camera with a dated source or marked ASSUMED). Written by the daemon at every launch from the claimed camera’s name (its first media.devices.labels.videoinput), never kept with the profile — the name is the profile’s and the passport the catalog’s, so they cannot drift apart in a record — and dropped from an inline config at creation, like fonts.dir. Absent when the profile claims no camera or names one without a passport.

Why (K76, measured): a page reads a camera’s NAME and its getCapabilities() side by side, and they disagreed — under «Microsoft® LifeCam HD-3000» (a 1280x720-at-30 camera by its passport) getCapabilities() offered 1920x1080 at 60, the modes of the machine’s listed device (OBS Virtual Camera), because the drawn picture and a file are drawn «at the size the listed camera was asked for». A name chosen to fit the machine’s modes cannot work: they are not known without opening the machine’s camera, and a machine with none lists nothing. So the modes go with the name.

The core half is patch 0085 (B151), and it is measured. Before it, under «Microsoft® LifeCam HD-3000» — a 1280x720-at-30 camera by its maker — a page read getCapabilities() of 3840x2160 at 20, the modes of the machine’s own device, and a request for 60 frames a second was answered with the machine’s 20 (core/scripts/camera-liveness-probe.mjs). After it: w max 1280, h max 720, rate max 30, every request above the passport clamped to 1280x720, and 60 answered with 30.

Where the rule applies: only where the machine’s camera is NOT what plays — realCamera: false (patch 0078) or a file chosen (0067). With the machine’s camera playing its own modes are the truthful answer, and claiming others would mean a page asking for 1080p and being handed 720p, a tell of its own. The browser answers at one place, VideoCaptureManager::GetDeviceSupportedFormats, because both roads to a page’s capabilities end there and a camera the machine does not have reaches it with an empty list — which is the case a projected camera lands in. Residual: a profile claiming TWO cameras gets this one passport on both; the config carries one claimed camera, so there is nothing to key the second on (ISSUE-532).

What a LIVENESS CHECK reads of the camera, and the three things it found (patch 0085, B151, ISSUE-532)

K77 read the open work behind such a detector (Verigram, arXiv 2512.10653, 30 000+ KYC sessions): it does not look at the noise or the flicker. It gives the camera applyConstraints tasks — heights 11, 22, 340, 640, 1001, 2001, 3001, 10001 and rates 1, 5, 30, 60, 120, 200 — and records what was asked, what the track claims, what arrives, and how long the answer took. Their numbers: a real camera 91–210 ms to change its native mode, ~2 ms to adjust inside one; OBS 0.8–1.6 ms. Measured on ours (core/scripts/camera-liveness-probe.mjs, Chrome’s fake camera standing for the host’s — this machine has no webcam, so the paper’s table is the only reference):

what a page reads Chrome’s fake device our drawn picture, before our file, before after 0085 a real camera (paper)
a mode CHANGE 1.5–4.1 ms 13–85 ms 15–18 ms 202–222 ms 91–210 ms
an adjustment INSIDE a mode 0.5–3.7 ms 0.4–1.1 ms 0.5–1.1 ms 0.4–1.1 ms ~2 ms
getCapabilities() 3840x2160@20 3840x2160@20 (the machine’s) the machine’s the passport: 1280x720@30 the camera’s own
the FIRST frame of three opens identical differs (an open catches a different frame) identical, byte for byte differs differs
two opens’ brightness curve 0 apart, correlation 1.000 3.1 apart, 0.72 — but 0.974–0.986 SMOOTHED 0.39 apart, 0.999 6–9 apart, ≈0 —

The smoothed number is the finding. A moving average of 8 frames costs a detector nothing and removes the grain and the per-frame exposure hunt, and what was left was the exposure’s slow wander replayed from the open — one run even handed two different STARTS of the profile the same first frame. So 0085 splits the clock: the room is the profile’s and starts the same at every open (the lobes of the blur, the white balance, the falloff, the sensor’s fixed pattern), while everything the electronics do reads a clock that runs from a fixed epoch (the grain, the wander, the hunt, the gain, the mains band). A FILE no longer rewinds at every open either — a camera shows what is in front of it now. The cost of the split, named: two starts no longer catch the room at the same moment of its own drift, so the drawn probe’s cross-start thresholds were re-calibrated against what the picture’s own motion measures (its 8x6 shape moves 0.54–0.91 between frames 400 ms apart; across starts it reads 0.80–0.87).

WHAT THE PICTURE CAN ACTUALLY DRAW, measured (B153). K79 read our camera as claiming 60 frames a second and delivering 35-39. The cause is the frame’s AREA, not the rate: the drawn picture is built pixel by pixel, so it keeps up to about 2 megapixels and falls away above it. Counted frames a second, at a claimed 20 (core/scripts/camera-liveness-probe.mjs --no-modes, the machine’s own modes):

the size the page asked for claimed delivered
11x11 .. 1780x1001 (up to 1.8 Mpx) 20 19.4 .. 19.8
3557x2001 (7.1 Mpx) 20 8.2
3840x2160 (8.3 Mpx) 20 7.8

The passport closes it where it is written. No camera in the catalog offers more than 1920x1080, so with media.video.modes on the launch the device never sits above 2 Mpx and delivers what it claims: measured 29.2..29.9 at a claimed 30, and an unconstrained open is 640x480 @30 claimed, 29.4 delivered (which also answers the second half of K79’s note – the open is at 640x480). Without the passport the machine’s modes decide again, and on a host whose camera offers 4K a page reads a promise we do not keep. The residual, named: the drawing itself is too slow above ~2 Mpx; making it faster, or refusing a mode it cannot keep up with, is a core change nobody has priced yet.

ONE CORRECTION TO HOW THE REPLAY WAS READ (B153, the same trap B152 found in the sound). A cross-start correlation of the brightness curve is NOT by itself evidence that the camera replays: over the 40 frames this probe records (~1.5 s) the exposure’s wander (0.03..0.12 Hz, periods of 8..33 s) is indistinguishable from a straight line, and two straight lines of similar slope correlate near 1 whatever their phase. Measured on the CURRENT core, which puts the wander on the epoch’s clock: smoothed correlation across two starts 0.943 while the curves sit 10.1 luma levels apart on a spread of 4.2 – far apart in level, similar in shape. Read the gap, not the correlation, or lengthen the window past a whole breath of the wander.

What is NOT fixed, and is deliberate: the room’s slow trajectory is still a function of the time since the open — the lobes drift the same way at every open, because «одна комната у профиля» is the owner’s design. What a detector can read of that is the cross-start shape correlation above.

The picture with no file: drawn per profile, from measured numbers (patch 0083, B149, ISSUE-532)

With realCamera: false and no file chosen, the camera plays VflinDrawnCamera (media/capture/video/vflin_file_camera.{h,cc}): a defocused scene built from the profile’s own seed — canvas.seed folded under the "camera" domain, so it is disjoint from the canvas, audio, WebGPU, rect, WebGL and font keys off the same seed (two features sharing a key would be a correlation a detector could exploit). The browser passes it to the capture service as --vflin-camera-drawn-seed beside 0078’s switch, and only with the real camera off. The frame is a pure function of (seed, the whole millisecond of the elapsed time) — the same profile draws the same picture at every start, in any process; two profiles draw two pictures; no seed in the config means 0078’s refusal, never the host’s camera. The picture is drawn at the size the listed camera was asked for, so getSettings() and getCapabilities() stay the listed camera’s exactly as with a file, and nothing is scaled.

What it is made of, and why each part: a low-frequency luminance field (2–4 drifting lobes, which is what defocus leaves), a lens falloff of 12–34 % at the corners, the sensor’s own fixed pattern (one level either way, EACH on a fifth of the pixels, so two fifths carry an offset — a real sensor’s DSNU, the part of its noise that does not change between frames), triangular temporal grain, a slow auto-exposure wander plus a per-frame hunt, a rolling mains band at 50 Hz mains (and none at 60 Hz, where 120 Hz beats exactly against 30 frames a second — which is why an American camera does not flicker at this rate), and a per-profile white balance between a warm bulb and a cold window. Luma is kept to 16..235 and chroma to 16..240, the studio swing a camera’s pipeline gives rather than the full range a drawing would use.

Measured first, then built (core/scripts/camera-stream-measure.mjs, 120 frames through requestVideoFrameCallback). What stands for “a real camera” here is a limit, not a claim: this machine has no webcam at all (B144, K76 — its only camera is the owner’s OBS Virtual Camera, never opened by a probe), so the references are a real recording played through this same device (media/test/data/bear-1280x720.mp4, so its grain, its compression and its brightness drift are a camera’s) and Chrome’s own fake device. A live USB webcam’s sensor noise and auto-exposure on this machine are NOT measured.

what a page reads the recording, through our device the host’s own device the drawn picture
getSettings() 640x480 @ 20 640x480 @ 20 640x480 @ 20 — identical
getCapabilities() rate {"max":20,"min":0} {"max":20,"min":0} {"max":20,"min":0} — identical
interval between frames (ms) mean 49.96, sd 7.36, 21/119 off by >20 % mean 49.89, sd 6.38, 23/119 mean 49.98, sd 6.68, 28/119
brightness spread over 6 s (luma) 10.01 4.01 5.77
frame-to-frame |difference| 12.97 (its own spread 9.71) 2.46 (3.21) 5.03 (1.47)
grain (high-frequency energy, 64x64 at 1:1) 0.87 (spread 0.21) 0.89 (0.16) 0.85 (0.11)
sizes and rates offered only 640x480@15 of seven asked exactly the same seven, the same answers the same seven answers

Three of those numbers changed the generator, which is the point of measuring first. (a) The grain was held down: the capture path scales the picture to the camera’s mode, so the high-frequency energy a page actually reads from a real recording is 0.87 — a per-pixel noise built by eye read about twice that, i.e. more grain than a camera gives. (b) A picture of scene plus grain alone read a frame-to-frame difference of about 1.0, below even the host device’s 2.46; the exposure hunt (spatially flat, so it costs no grain) and the mains band carry it instead. (c) With both auto-exposure terms slow, six seconds read a brightness spread of 1.3–2.0 against the references’ 4.0 and 10.0; splitting them into the room’s light (0.03–0.12 Hz) and the exposure loop itself (0.30–0.90 Hz) is what a real AE loop does and is what moved it into the band.

What a page can still tell apart, measured or reasoned, and it is the honest residual:

  • the interval spread is the DELIVERY PATH’s, not the device’s: on this host the references read sd 6.4–10.0 ms through requestVideoFrameCallback, and the recording’s own leg read 9.97 in one run and 7.36 in the next — so this number says nothing about a camera either way, and the drawn picture’s 6.68 is not evidence of anything;
  • no compression artefacts: a real webcam’s MJPEG or H.264 leaves block edges and mosquito noise; a drawn frame is I420 straight from the generator and carries neither;
  • no rolling shutter, no lens chromatic aberration, no hot pixels, no dead pixels;
  • a 30-a-second clock was a defect, and it is the one the independent read caught (0083’s Codex transcript, reproduced): a camera delivering 60 frames a second put 5 of every 9 neighbouring frames on one index and got them byte-identical, 7 of 9 at 120. Fixed to a millisecond clock, with a test at six rates. It never showed in the measurement above because the one mode this host offers is 20 a second — which is exactly why the residual below matters;
  • the drawn picture still VARIES LESS than either reference run to run — its frame-to-frame difference spreads by 1.47 against 3.21 and 9.71, its grain by 0.11 against 0.16 and 0.21: a real stream’s content changes and ours only drifts. The sensor’s gain wander closed most of the grain gap, and the millisecond clock most of the other;
  • and the big one, which no number fixes: a site that looks at the picture sees no person and no room.

Tests: capture_unittests VflinDrawnCameraTest.OneProfilesPictureIsItsOwnAndTheSameAtEveryStart, .NoSeedIsNoPicture, VflinFileCameraTest.WithTheRealCameraOffAndASeedNoFileIsTheProfilesPicture, .TheSeedSwitchMakesNoFileThePictureNotARefusal; unit_tests FpConfigParseTest.TheDrawnCamerasSeedIsTheProfilesOwnAndRidesTheCaptureServiceOnlyWithTheRealCameraOff. Probe: core/scripts/camera-drawn-probe.mjs --gate. The camera button (0070) says which of the two “no file” means: «Камера профиля: файл не выбран — играет своя картинка профиля» with a seed, «…— сайтам отказ» without one.

A camera file on Windows when Chrome asks for NV12 (patch 0080, B146). With Media Foundation’s D3D11 capture on — Chrome’s default on Windows — Chrome asks a camera that offers NV12 for it and hands the device a GPU buffer; the file camera wrote that buffer through a mapping that on Windows is a copy which never reaches the texture, so every frame arrived EMPTY (all-zero I420: mean colour 0,136,0, a solid green preview). Measured with Chrome’s fake camera (which offers NV12); with --disable-features=MediaFoundationD3D11VideoCapture the same file played, and OBS (DirectShow) played it all along. So a profile with a camera file could send a site a green rectangle on a laptop whose webcam offers NV12 — not only a measuring artefact. 0080 writes through the buffer’s in-process access, as Chrome’s own fake device does. camera-prompt-probe.mjs --gate fails an empty frame.

What “microphone off” should be — measured, three ways (B147, ISSUE-532)

Before: with 0051/0075 the microphone is NAMED the profile’s and its track carries the host’s sound — RMS peak 0.32, the 400 Hz beep of Chrome’s fake microphone that stands for the host’s (core/scripts/mic-probe.mjs). A site that records audio gets the room. Measured each way the microphone can be off, beside a camera choice so the coupled request is seen (core/scripts/camera-variants-probe.mjs --set mic):

the identity… {audio} sound {audio, video} {video}
claims NO microphone and no camera (audioinput: 0, videoinput: 0) NotFoundError — NotFoundError NotFoundError
claims no microphone, camera with a file NotFoundError — NotFoundError works, the file
claims the microphone, no camera (as A190 left it) works the host’s (0.325, the beep) NotFoundError NotFoundError
claims one and refuses it (the parked 0079, measured in B146) NotReadableError — NotReadableError —
claims one and plays a file (Chrome’s --use-file-for-fake-audio-capture standing in — the core has no microphone file) works the file’s (230 Hz) (no camera) —

In every “off” way enumerateDevices() agrees with the answer, before and after the permission, and no prompt asks for a device the profile does not have.

Chosen: the identity claims no microphone (media.devices.audioinput: 0) — the machine without one (a desktop without a headset), on every road, and Chrome answers exactly as it does on such a machine. No core change is needed for it: 0051 already hides a kind whose count is 0 and refuses its capture. The price, plainly: a site that needs the microphone (a call, voice verification) sees a computer without one; ANY request that asks for sound — alone or with the picture — gets NotFoundError, so with the camera’s file chosen a site asking {audio, video} gets nothing (it must ask for the picture alone); with both off the profile is a machine with no capture devices at all, which a LAPTOP identity rarely is. Not chosen: refusal (a microphone that is busy forever is odder than none, and it fails the coupled request too — the parked wip/b146-0079-microphone-parked stays parked); a microphone file (a whole mechanism like 0067–0070 in the audio service, and one sound for every profile would link them). Who writes it: control-plane — the field is the device composition, as for the camera.

B150 read this table once more and the answer CHANGED, for the same reason the camera’s did - a machine with no microphone impoverishes the fingerprint, and the owner’s reversal about the camera («так для отпечатка нужно ж») applies word for word to the microphone. The row that ships is a fifth one, which B147 could not measure because it did not exist:

the identity… {audio} sound {audio, video} {video}
claims the microphone and gives it its OWN room (0084, the default for any seeded profile) works the profile’s own, drawn from canvas.seed under the "microphone" domain works works

Measured before and after (core/scripts/mic-probe.mjs, four legs; the limit, said plainly: this machine has no microphone, so nothing is compared against a recording of a real room). Before: the host leg and the identity leg were the same to a tenth of a decibel - RMS mean 0.035/0.035, peak 0.323/0.323, strongest bin 400 Hz in both, the eight-band spectrum matching band for band, and the floor EXACTLY 0 in both for seven tenths of the window. After (four runs): the identity’s floor is 0.0011..0.0014, 71..87 % of its own mean, against the host leg’s 0 %; the shape of its spectrum differs from the host’s by 11.3..13.4 dB and its strongest bin is its own resonance; the same profile at a second start moves 0.3..1.2 dB; a profile whose seed is reversed differs by 8.2..8.8 dB.

And one number that only the independent read of the export asked for. The sound sits on an ABSOLUTE timeline, not on a per-start counter: two recordings of one profile match 0.0 % of their raw samples once aligned, while upstream’s own fake device still matches 100 % at shift 0. The first version restarted the index at every open and measured 100 % itself - two byte-identical recordings, which no microphone gives and which a page can check in two lines. The camera (0083) deliberately still replays its picture at every start; whether one profile’s picture may change between sessions is the owner’s call, and it is named in ISSUE-532 rather than decided here.

What the four older rows are for now: claims no microphone is a desktop without a headset, and a site asking for sound gets NotFoundError - ordinary, but it also fails {audio, video} whole; claims one and refuses it stays the parked 0079; claims one and plays a file was dropped (one sound for every profile would link them, which is exactly what the drawn room avoids); claims one and carries the host’s sound is what every profile did until 0084.

The price, and it is the owner’s call to accept or not: a drawn room is a ROOM, not a voice. A site that LISTENS - a call, voice verification - hears a quiet empty room, and there is no way back to the machine’s microphone today: the field would be media.audio.realMicrophone (the mirror of media.video.realCamera), which this contract does not have. The core half is one line; the product half is one field. Also measured, and NOT 0084’s to fix: the OPENED track still reports the host’s label (“Fake Default Audio Input”) while the page’s device list shows the profile’s microphone - 0051’s virtual “default” entry keeps the host’s name instead of the device it names, and a page can compare the two in two lines (ISSUE-532).

What “camera off” should be — measured, three ways (B146, ISSUE-532)

core/scripts/camera-variants-probe.mjs, Chrome’s fake devices standing for the host’s:

the identity… enumerateDevices before / after the prompt for {audio, video} {audio, video} {audio} {video}
claims NO camera (media.devices.videoinput: 0) no camera / no camera no camera in it NotFoundError at once works NotFoundError
claims one, refuses it (realCamera: false, 0078) 1 / HD Pro Webcam C920 the camera, with a preview that never starts NotReadableError works NotReadableError
claims one, plays the file (0067–0070, 0080) 1 / HD Pro Webcam C920 the camera, the file in the preview works works works

B146 read this table as “claim no camera”, and that is NOT the answer any more — the owner reversed it the same evening («так для отпечатка нужно ж»: a camera is part of the fingerprint, and a machine without one impoverishes it). The form as it stands, one answer per question: the profile claims the camera its draw gave it (it can be taken away), the real camera is off (realCamera: false), and what plays with no file chosen is the profile’s own drawn picture (0083 above) — so the fourth row of this table, which B146 could not measure because it did not exist, is the one that ships:

the identity… enumerateDevices the prompt {audio, video} {audio} {video}
claims one, plays its own drawn picture (0083, the default) 1 / HD Pro Webcam C920 the camera, the picture in the preview works works works

What the other three rows are for now: claims no camera is what a profile looks like when a person takes the camera away — ordinary for a desktop, rarer for a laptop, and a site asking for sound and picture in one request then gets neither; claims one and refuses it is the fail-closed floor, reached only by a config with no canvas.seed; claims one and plays a file is what a person gets the moment they load one (the road 0067–0070, unchanged). The camera button still tells a profile whose camera was taken away: «Камера профиля: нет — в профиле нет камеры» (0082).

2l. hardware.gpu.deviceId — the card’s PCI device id (A166, ISSUE-381; the core half is B128)

"deviceId": "0x00002503" — 0x and eight upper-case hex digits, the id the card’s own renderer string carries in (0x…) (ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 (0x00002503) Direct3D11 …)). Optional and additive: absent for a card whose renderer names no id (a Mac’s Metal string) and in every config written before the field.

  • Set WITH the card, never by a hand of its own. Every place that sets a card sets its id from the renderer (device_id_in): the preset builder and the generator (catalog::build), the pool draw for a preset (draw_preset_card_vendor), this machine’s own card (host.rs). An INLINE config’s id is re-derived from its renderer before validation (refresh_device_id), because the editor writes a chosen card’s vendor and renderer over a generated config whose id was the replaced card’s — the same reason the available rectangle is re-derived there. snapshot() fills an absent one; a LAUNCH fills it for a record written before the field, in the launch’s config only (the record is not rewritten — ISSUE-344).
  • The rule (C2, vendor ⇄ device id ⇄ model): C2_GPU_DEVICE_ID_MALFORMED (not 0x + 8 hex); C2_GPU_DEVICE_ID_MISMATCH (not the id the renderer names — the renderer IS the model statement — or a renderer that names none); C2_GPU_DEVICE_ID_VENDOR (the open tables — gpu_open, pci.ids; gpu_pool, the survey — say the id belongs to another maker than hardware.gpu.vendor claims; an id neither table knows is not judged there). Each refusal carries both values (Issue.values, A165). What reaches them is a record that never passed through a mint: a file, another build, a hand edit.
  • What the page sees today is the renderer string (UNMASKED_RENDERER_WEBGL, K51/K57); this field is for the core to read the id without parsing that string. Whether WebGPU’s adapter.info.device should carry it is B128’s and K57’s question (stock leaves it empty).
  • Measured live (A166, core s60): a config carrying the field starts and applies its identity — 2 of 2.

2m. fonts.dir — the folder of font FILES a profile draws from (A168, ISSUE-380; the core half is patch 0073)

  • What it is. An absolute folder, no ... Patch 0073 scans it once at the browser’s start (.ttf/.otf/.ttc/.otc, recursive, ≤ 8192) and adds its files to the system’s fonts on every road a page reaches fonts by (CSS family lookup, local(), queryLocalFonts). Whether a page SEES a family from it is still presentFamilies (patch 0023), exactly as for an installed font. Absent: every road is stock.
  • Who writes it: the daemon, at a launch, never the profile. It is a place on THIS machine — the installed font module (modules/fonts, see below) — so a launch sets it when the profile declares one of the module’s families and the module is here, and clears it otherwise; an inline config’s value is dropped at creation. Rule FONTS_DIR_MALFORMED refuses a value the core would drop (relative, .., over 1 024 bytes).
  • The module. Free families with their files — SIL OFL 1.1 / Apache 2.0, the files unchanged, the licence text beside each family, nothing proprietary — fetched from github.com/google/fonts at one pinned commit (modules/fonts/fetch.py), packed deterministically into vflin-fonts-<version>.zip (modules/fonts/package.py), served by the feed under modules/fonts/ and installed BY THE DAEMON ITSELF at its start (A192 — fetched once in the background from beside the core’s source, the previous version removed after; module.ensure {id: "fonts"} does the same by hand): the archive is verified against the hash this build was compiled with (font_module::ARCHIVE_SHA256), every file against the manifest’s own sha256, and nothing but font files, licence texts and the manifest is accepted. Version 0001: 33 families, 142 files, 45.0 MB of fonts, 22.1 MB archive (DM Sans and Merriweather were left out by measurement: their optical-size axis makes DirectWrite name them otherwise). Version 0002 (A191): 117 families, 386 files, 93.4 MB of fonts, 44.1 MB archive — six more left out by the core’s four roads, named in modules/fonts/families.json. Developer override: VFLIN_FONTS_SRC.
  • Which families a profile carries (font_catalog::module_families): a Windows identity draws them on USER_FONTS_PCT = 35 % of identities, 1..=6 of them (fewer more often), each weighted by popularity — ASSUMED: K52 measured one Windows machine with 3 of Google Fonts’ families, installed by hand, and nobody publishes how many machines carry any. They go into fonts.additions, so a census refresh at a launch keeps them. None is a signature of another OS on Windows — no DejaVu, Liberation, Ubuntu, Arimo, Cousine, Tinos, Chilanka, Jomolhari, Noto Color Emoji, Droid Sans Mono, Dancing Script or Roboto (our /lab/ and CreepJS read those as Linux or Android; 0073’s own measurement showed /lab/ warning fonts.foreign:DejaVu Sans). macOS and Linux identities draw none.
  • The Linux set — a second module, fonts-linux (A185, ISSUE-509, from B141). The fonts an Ubuntu 22.04 desktop installs, as files: 247 files of 73 packages, 172.4 MB (164.4 MiB), 142 families, archive vflin-fonts-linux-0001.zip of 117.2 MB — every licence free, each file beside its own package’s copyright (modules/fonts-linux/ (internal: modules/fonts-linux/README.md)). B141 measured why: a Linux identity with the old ten declared families showed pixelscan 7 of its 97 Linux core families (“Masking detected”); with these files as fonts.dir, 96 of 97 and “No masking detected” — no core change, 0073 draws them. So: ubuntu-default IS the module’s 142 families (linux_font_module::FAMILY_NAMES, generated with the archive) plus the seven web-safe names; a LINUX identity’s launch sets fonts.dir to the installed module’s fonts/ tree, and nothing else ever gets it — B141’s price is +0.46 s once and +60 ms per new site process, and a Windows profile with the folder would pay ~40 ms a process for families it hides. Delivery is the wallet’s and the core’s shape, never the installer: the first start of a Linux profile on a machine without the set starts WITHOUT it (said in the journal) and asks for it once, in the background, from beside the core’s own source (…/core → …/modules/fonts-linux); by hand, module.ensure {id: "fonts-linux", source}. Verified like the free-family module — the compiled sha256, then every file against the manifest, and nothing but font files, copyright/<package> and the manifest accepted. Developer override: VFLIN_FONTS_LINUX_SRC. Measured end to end on the real archive (a_linux_launch_fetches_the_real_set_once): the first launch asked once, the set was in place 14 s later, the next launch named it.

2n. fonts.monospaceFamilies — which declared families are monospace (A171; the core half is patch 0074)

  • What it is. A list of family names, each one of presentFamilies: the declared families that are MONOSPACE; every other declared family is proportional. A list — even an empty one — makes pitch known to the core, which then gives a substituted family (an identity of another OS than the host’s) a face of its own pitch: without it a macOS identity on a Windows host drew Menlo proportional and Helvetica Neue fixed (B131: 2 and 4 of 7 wrong on two seeds, 0 with the field). Absent: the substitute pool works exactly as before. See FP_CONFIG_INJECTION.md §0074.
  • Who writes it: the daemon, at every launch, never the profile. It is derived from presentFamilies and vflin_fingerprint::fonts::MONOSPACE_FAMILIES (monospace_of), so a census refresh or an added family is followed without a stored copy to go stale; an inline config’s value is dropped at creation, like fonts.dir.
  • What the table knows, and how. Measured fixed-pitch on this Windows host by the core’s own probe (Consolas, Courier New, Lucida Console, Cascadia Code/Mono, and the font module’s Inconsolata, IBM Plex Mono, Source Code Pro), and monospace by design on the Apple and Linux lists (Menlo, Monaco — the two B131 measured by — Courier, Andale Mono, PT Mono, DejaVu Sans Mono, Liberation Mono, Ubuntu Mono). A family not in the table is not claimed either way: it reads as proportional to the core, which is what every family of the catalog outside the table is.

2o. network.localNetworkAccess — a page cannot scan this machine’s ports (patch 0076, B143, ISSUE-519)

A STRING. "block" — the profile answers Chrome’s OWN Local Network Access permission “Don’t allow” for every site by default: the three defaults Chrome 155 asks by (local_network_access, and the split pair local_network — the private network — and loopback_network — this machine) are BLOCK in Chrome’s command-line pref store for the run, the same layer §2i’s Do Not Track uses; nothing is written to the profile’s Preferences. Absent, or any other value — stock: Chrome asks.

Why it matters, measured (core/scripts/port-scan-probe.mjs: a page Chromium treats as PUBLIC via --ip-address-space-overrides, probing fetch/WebSocket/<img>/<iframe> three rounds each, against an answering HTTP port, a silent TCP port, a released port and BrowserScan’s 15 server ports). While Chrome ASKS, a request to an open loopback port waits for the prompt’s answer and a closed one fails in 1–3 ms — that difference is the scan. Stock Chrome 155 and our core without the field: the page finds 3 of 17 — exactly the three that are open (the two of the probe and Windows’ 445). With "block": 0 of 17 — the open port fails as fast as the closed one, by every method, and no prompt is shown. The WebSocket road is judged by its outcome, not its time: Chromium delays every failing socket by 1–5 s × 2^min(pending + failed, 16) / 65536 (services/network/websocket_throttler.cc), the same for an open port blocked and a closed one.

B144 re-measured with the PRODUCT’s own ports in the list (loopback-surface.mjs --hold: a daemon from HEAD on a home of its own, a profile running, a door attached — the product listens on its Local API’s TCP port always and the door’s while attached; the core only on UDP): without the field the page finds 5 of 10 — the probe’s two, the Local API, 445 and 8080, exactly the open ones Chrome lets a page dial (135 and 22 are on its restricted list); with "block" 0 of 10, every probe failing in 1 ms. A probe fix on the way: an <img> or fetch left pending by the prompt stayed queued and made every later image time out, so three CLOSED ports read open by <img> alone — timed-out probes are cancelled now. And a config without browser.ua is no config to the core (“no usable browser.ua”), so a {network:{…}}-only file measures stock — the first “after” here did exactly that.

What stays working — measured by B144 (core/scripts/lna-own-paths-probe.mjs --gate, the built core, the same config with and without the field): a site reached through a SOCKS5 exit on 127.0.0.1 loads, document and subresource; a CDP binding called by the page reaches the client as Runtime.bindingCalled (the Synchronizer’s road); WebRTC’s STUN check arrives at the network.udpRelay point framed (4 datagrams, both legs — WebRTC’s LNA checks are off in 155); the new tab renders with the profile’s strip (0039). A site the user allows in Page Info stays allowed (an exception beats a default — the whitelist is Chrome’s own). QUIC through the relay (0042) was not re-measured; it connects to a 198.18/15 literal, public to the check.

⚠ Correction, B144 — B143 wrote here that “proxied connections are unknown = public to the check, so a profile’s SOCKS exit on loopback is untouched”. MEASURED: the opposite, and it decides whether this field does anything in the product. A document that came through a proxy on 127.0.0.1 is NOT public to Local Network Access — its request to an open loopback port loads in 3–4 ms with "block" and without it, no prompt, no refusal (the site served as a secure origin, as a detector is; an http:// page is refused outright as InsecureLocalNetwork whatever the field says). Every exit WITH A PASSWORD reaches the core as the daemon’s ProxyRelay on 127.0.0.1 (proxy_relay.rs, launch_stages.rs) — so for those profiles the field is inert today, and a page can tell this machine’s open ports from closed ones as easily as on no protection at all. An exit without a password is handed to the core directly and is public to the check, so the field works there. The fix is the launcher’s, not the core’s: Chromium’s own --ip-address-space-overrides=127.0.0.1:<relay port>=public declares the relay’s TCP port public — measured: without the field the local request then waits for the prompt (stock Chrome’s behaviour), with it it is refused in 3 ms (LocalNetworkAccessPermissionDenied), and the exit, the binding, the relay and the start page all still work (--exit-public, --gate PASS); the official M155 build honours the switch too. Control-plane, beside writing the field itself (ISSUE-519).

And the CDP door is not protected by this field either way. Chrome judges a local request AFTER the TCP connection is made, and the door gives itself to its first TCP client and closes its listener (cdp_door.rs, A89). Measured: a public page’s one probe of an attached-but-unclaimed door, with "block" — the page reads it closed, and automation then dialling the door is refused in 29 ms. Without the field the page’s probe holds the door for the prompt and does the same. The window is from POST /profiles/{id}/cdp to the client’s dial; the remedy belongs to the door (spend it on a completed handshake, not an accept) — control-plane, cdp area.

What it does NOT change — BrowserScan’s red line. Its local scan opens wss://localhost:<port> for 29 ports, one every 300 ms, and calls a port OPEN when the socket errs within 1 s. On this Windows a closed loopback port errs in milliseconds, so its rule lists the first ~15 ports as open until the WebSocket throttle above slows the rest past 1 s: 15 of 29 “open” on stock Chrome 155, on our core, and on our core with "block" alike, all of them closed. Its red «Порт не закритий і вразливий для атак» is shown when its local list OR its server’s nmap of the EXIT address (the proxy, scanPorts) is non-empty. Neither is a verdict about the browser.

Confirmed by B144 from the page’s own code and on the live page. The component (read 2026-10-19): Tt(port) = new WebSocket("wss://localhost:"+port), onerror within 1000 ms → resolve (OPEN), later → reject, 1200 ms timer → reject; the result line is (local.length || remote.length) ? unprotected : protected; each of the two buttons («локальне сканування», «Дистанційне сканування») has its OWN «Жоден порт ще не відкритий». So the owner’s frame (one «no port open» and the red line together) is one list empty and the other not. Live, our core through a test exit, the local button pressed: 15 listed — 20, 21, 22, 23, 25, 53, 80, 110, 111, 135, 138, 139, 443, 445, 587 — of which this machine listens on two (135, 445); the rule’s replay on the installed stock Chrome 155.0.8059.82: 16. A closed port refusing fast is what the rule calls open, so the red line cannot be made green by blocking — a blocked request also fails fast; only a browser whose failed localhost socket takes over a second would pass, and that is a tell of its own. Not a thing to patch for.

The product half — A188 (control-plane, 2026-10-19). Measured through the PRODUCT (scripts/measure/a188-lna-door-leg.mjs: a daemon of its own on a home of its own, an exit WITH A PASSWORD so the core reaches it through the ProxyRelay, the core out/M155, the page a site reached through that exit; “before” is a daemon from HEAD on the same stand):

  • Who writes it: the profile’s identity, kept as it is. network.localNetworkAccess is in the model (vflin_fingerprint::Network), so a config that carries it reaches vflin-fp-config.json unchanged — before, the model dropped it on the way in. Absent is stock. Any value but "block" is refused by the validator (NETWORK_LOCAL_ACCESS_UNKNOWN): the core reads it as no block, so storing it would claim what the engine does not do. The catalog never writes it. The operator’s switch on profile.update waits for a round in which the contract is free (ProfileUpdate/ProfileDetail.localNetworkAccess, block | ask); until then the field is set with the identity at profile.create.
  • The launcher declares a loopback proxy’s port public. Beside --proxy-server, a proxy whose host is a loopback literal with a real port gets --ip-address-space-overrides=<ip>:<port>=public (launch.rs); the operator’s own overrides are merged after it in one switch (Chromium takes the first entry that matches). A remote exit needs nothing.
  • And the relay stops answering pages. A public port is one a page may dial unasked, and the relay answered such a request — through an HTTP exit, with the exit’s credential — so with the block on a page found exactly ONE open port on the machine, the relay’s (load 7 ms beside error 6–7 ms). The relay now carries only a proxy client’s request (CONNECT, absolute-form http://, SOCKS5) and closes anything else unanswered, before the exit is dialled (proxy_relay.rs).
the page through the password exit asks… before (HEAD), either leg after, without the field after, "block"
an open HTTP port of this machine load 6–7 ms waits for the prompt error 7–8 ms
the daemon’s Local API load 6 ms waits for the prompt error 7–8 ms
the relay’s own port load 7–8 ms error 6 ms error 7 ms
a closed port error 5–8 ms error 6 ms error 7 ms
a real HTTPS site (example.com) through the exit → the open port not measured: the page had spent the door waits 3 s error 4 ms
the CDP door, probed by the page first, then dialled by a client client refused in 1–5 ms client attached, 6 ms client attached, 8 ms

The door row is ISSUE-537, closed in the door itself (cdp_door.rs): it is spent on the first COMPLETED WebSocket upgrade on its path, not on the first TCP connection, and an upgrade carrying a web page’s Origin is refused (403) without spending it — Chromium’s own DevTools rule since M111. What a core without 0076 (series < 66) does: ignores the field, which is stock; the override and the relay rule hold on any core.

network.protectedPorts — a LIST of ports instead of the whole local network (patch 0081, B146, ISSUE-519)

"network": {"protectedPorts": [3389, 5900, 7070, …]} — integers 1..65535 (anything else skipped, repeats dropped). A TCP connection a page — or anything in the network service — makes to a NOT publicly routable address (this machine, the local network, link-local, CGNAT) on a listed port is never made: ERR_CONNECTION_REFUSED at once, before any packet — what a closed port answers. A request whose destination is such an IP LITERAL gets the same refusal before a proxy is chosen, so a proxy program on this machine that forwards private addresses itself cannot carry it. Public addresses and unlisted ports are untouched. Absent or empty = nothing protected.

Why a list — the owner’s word, with a competitor’s «Real / Protect» and «Ports to protect»: localNetworkAccess: "block" (0076) closes the WHOLE local network, which also cuts the person’s own programs on localhost — a wallet bridge, a dev server, a router panel — and they do not ask, they just fail. A scanner is after REMOTE-ACCESS programs (RDP, VNC and its displays, TeamViewer, AnyDesk…) to call the machine a farm; a list hides exactly those. 0076 stays as the strong mode.

Measured (core/scripts/port-scan-probe.mjs --protect, the page public via --ip-address-space-overrides; a temporary list — the competitor’s frame plus the probe’s own open ports): without the field the page finds 4 of 7 (the probe’s two, AnyDesk on 7070, a service on 8080); with it 1 of 7 — 8080, not listed, behaving as stock Chrome does. A protected open port reads as the closed one on every method: fetch and <img> errors in 1 ms (the closed port 1–3 ms), <iframe> load 15–16 ms (25), WebSocket an error under Chrome’s throttle. Our own roads with the field (lna-own-paths-probe.mjs --protect, the exit’s port listed on purpose): the site through the loopback exit, the CDP binding, WebRTC’s relay datagrams and the start page all work.

  • The profile’s own exit is never refused: the browser takes the port of a loopback --proxy-server (the daemon’s relay) out of the list before handing it on — the relay’s port is random and may be one of the listed.
  • Not covered: a host NAME a local forwarding proxy resolves to a private address (only literals are judged before the proxy); BrowserScan’s red line (its rule, above, calls a closed port open).
  • THE PRODUCT’S OWN PORTS ARE ADDED BY THE LAUNCH, NOT BY THE IDENTITY (A195, ISSUE-542; the leak is K79’s measurement: from a page inside a profile the daemon’s Local API port hung 3 s where a closed port refuses in 1–6 ms, and a found port of ours is a tell of the product). The daemon puts two numbers into this list in the launch’s config — its own Local API port (local-api.json) and the port reserved for that profile’s CDP door — and the stored identity keeps the operator’s list exactly as it was: the ports are random and change every start, an identity must not. An empty list is still the operator’s “protect nothing” about the MACHINE’s ports; ours are added whatever it says, because they are the product’s own doors. crates/vflin-localapi/src/our_ports.rs carries the reasoning and the table of every listener the daemon opens — the relay’s TCP port cannot be in the list (it IS the profile’s exit, and the core takes it out anyway), the relay’s UDP point is not covered by the mechanism, and the compatibility bridge is another program on a port a person names. Measured through the product (scripts/measure/a195-own-ports.mjs, the built core, an exit with a password so the page is public): the record holds the 16 default ports, the launch’s config and the network service’s own --vflin-protected-ports hold those 16 plus the daemon’s port and the door’s reserved one; from the page a control port of this machine that nothing protects times out at 3001–3016 ms, while our Local API port answers error in 5–6 ms — the closed port’s own 5–6 ms — and the door’s port error in 1 ms by fetch and 1–2 ms by WebSocket. Our own client, which dials from OUTSIDE the core where the rule does not reach, attached through that same door in 21–24 ms and evaluated 6*7 = 42 in the profile’s page. The door’s port is in the list BEFORE a door exists, because the switch is fixed at process start: the daemon takes the number at launch (bound, not listening — a connect to it is refused exactly as a closed port’s is) and hands the door that socket when one is granted, so a second door lands on the same protected number.
  • 445 (SMB) is deliberately NOT in the default list (A195, the decision the brief asked for in words): it is not a remote-access program’s port, it is ordinary Windows, and Chrome’s own restricted-port list carries 137 and 139 but not 445 — a page that probes it on a normal machine finds it open. Refusing it would not make a profile look like a normal machine, it would make it look like a rarer one. The operator can list it; the default does not invent an anomaly.
  • The list is not the core’s. What each port is, by its own service’s documentation (ADR-0035), is K75’s; the field in the profile and its default is A190’s. K75 measured: AnyDesk listens on 7070 and RustDesk on 21118 on this machine; in the competitor’s list 6568 and 5938 are OUTGOING ports by AnyDesk’s and TeamViewer’s own documentation (no one listens on them), and Radmin (4899) and RustDesk (21118) are missing.

2p. hardware.memoryGb — the Device-Memory request headers follow it too (patch 0077, B143, ISSUE-522)

navigator.deviceMemory has followed hardware.memoryGb since 0010 (the getter: Blink’s own snap — the nearest power of two, a tie to the lower — and the desktop clamp 2..32; 64 and 128 read 32, exactly as stock Chrome on a 128 GB machine). The Sec-CH-Device-Memory / Device-Memory request headers did NOT, on either of their two roads: a NAVIGATION’s headers are made by the browser process (content/browser/client_hints/client_hints.cc), a page’s own subresource requests by the renderer (FrameFetchContext), and both read blink::ApproximatedDeviceMemory, sized from the machine — so a site that asked for the hint read the machine’s bucket beside the profile’s (measured on a 128 GB host: header 32, getter 4). Patch 0077 sets that value from the identity in both processes — the browser’s in ChromeContentBrowserClient’s constructor before any request, the renderer’s when its config arrives (WebVflinFpConfig::Set); without the field the machine’s value stays. Measured between the halves: with only the browser’s, navigation 4 and fetch 32. core/scripts/device-memory-probe.mjs --gate: under each claim both headers = getter = the claim’s bucket. performance.memory.jsHeapSizeLimit stays V8’s, which V8 sizes from the machine (4.09 GiB here, the same under every claim); whether a real machine of a SMALL claimed size (2, 4 GB) reads a smaller limit was not measured here — named as the next thing to measure, not fixed.

B144, re-measured on the built core and read in V8’s source. Before (M155-official, no 0077): getter 2/4/8/16/32/32 under claims 2/4/8/16/32/64, both headers 32 every time. After: both headers = getter = 2/4/8/16/32/32 (--gate PASS, five distinct values for the five buckets the owner chose). The heap limit answers the open question by V8’s own rule (v8/src/heap/heap.cc, OldGenerationSizeFromPhysicalMemory: physical ÷ 2 on 64-bit desktop, clamped 256 MB…4 GB): a machine of 8 GB or more reads the same 4.09 GiB we show, so claims 8/16/32 are coherent; a real 4 GB machine has about half that and a 2 GB one about a quarter, so claims 2 and 4 contradict jsHeapSizeLimit — from the source, not measured on such a machine. The draw (centred between 8 and 32, A187) does not land there; a hand-picked 2 or 4 does. Named in ISSUE-522, not fixed in this round.

2q. What a page sees of the fonts, the memory steps, and the port block (A189)

  • The fonts a page sees are not the length of presentFamilies. Patch 0023 lets a page see only the declared families, and a declared family this machine lacks does not render (B129). A Windows identity on a Windows machine declares this machine’s own families of its OS: profile.create replaces the list with the machine’s census (fonts::census_for_claim) and puts the identity’s own set (A134) on top. Since A189 fingerprint.generate does the same, so the draft the form shows is the list the profile keeps. Before, the draft carried the 21-family OS manifest while the stored profile carried the census: the window read 21, the page saw this machine’s own (K73: 66 by width here, a bare Chrome 67). PageFonts (fingerprint.validate, profile.get) says the number for a person: sees – the declared families this machine has or a font module installed here brings the file of – declared, and notOnThisMachine. The identity’s own set can declare families the machine lacks (a Windows script bundle, a module family before the module is fetched): they are in declared, not in sees.
  • fonts.census and fonts.censusMachine — which census the list is, and on which computer (A117; A194, ISSUE-533). census is the census’s key — the candidate list and the core that measured it (fonts::census_key), NOT the machine. A start compares it with the machine’s current one and, when they differ (after a core update), takes the current census with the identity’s own deltas on top. Since A194 only on the computer the list was taken on: censusMachine is a mark of that computer — 16 hex digits of a sha256 over this home’s device_id and the Windows install’s MachineGuid (host_census::machine_mark), so a copied home on another computer is another computer too; neither id is in it. On another computer the list stays as it is, profile.get says fontsFromAnotherMachine, a page sees the part of the list this computer has (pageFonts), and profile.fingerprint.regenerate {fields: [fonts]} (confirmed) takes this computer’s. Before A194 that computer’s census was taken silently — an identity change nobody was told about. The mark is kept with the profile and dropped from a launch’s config; a record without it (every one from before) is marked at its first start under this build, so a profile that never moved behaves exactly as before (ISSUE-344: optional, read byte for byte).
  • The concentration gate on font sets (concentration::FONT_SET_CEILING_PCT, a third of a class): no font set the CATALOG makes on more than that. Windows’ most common catalog set is on 31 % (the bare manifest; A134 varies the rest); both macOS classes and Linux are on 100 % and NAMED (KNOWN_FONT_SETS_OVER). A set made from the machine’s census is not held to it: it is the machine’s own, the same on every profile of the machine – path B, what Vision and AdsPower hand a page (K73), and K72 measured that fonts alone do not merge visitors.
  • Memory (Catalog.memory): the five steps a page can see (2, 4, 8, 16, 32) and, for every size C8 accepts, what a page reads (validate::device_memory_a_page_reads: the nearest power of two, a tie to the lower, clamped to 2..32 – 24 reads 16, 64 and 128 read 32).
  • The port block is profile.update {localNetworkAccess: block | ask} (§2o’s field; ask removes it). For an exit with a password the daemon declares ITS relay public, first among the launch’s extra arguments (launch_extra_args); a proxy of the operator’s – socks5://127.0.0.1:1080 – is never declared (ISSUE-538).

2r. What a NEW identity carries, and the font kits (A190, the owner’s decisions of 2026-10-19)

Every road that makes an identity — a preset, an inline config, the host’s capture, nothing named (generated), and the form’s draft — ends in apply_new_identity_defaults, AFTER its devices are drawn. Each default only where the identity says nothing yet; a profile made before keeps what it had (absent stays stock, ISSUE-344):

  • identity.passkeys: profile (A136);
  • network.protectedPorts = the remote-access list (catalog::PROTECTED_PORTS_DEFAULT, 16 ports, each with its service and provenance from K75; read by patch 0081). [] is the operator’s “protect nothing”, kept as a list. localNetworkAccess: block (0076, the whole local network) is the strong mode, not a default; ask is kept as a word;
  • a camera claimed, and no picture from the machine’s: a profile whose devices the daemon drew ALWAYS claims a camera — its draw’s, or one when the draw gave none (the machine’s own if it has one, else a webcam of its OS); a device list the caller sent (profile.create {fingerprint} — the form’s device editor) is kept as said (ISSUE-351). A190 made it claim none (media.devices.videoinput: 0, the form B146 measured), and the owner reversed that the same evening (A191: «для отпечатка нужно», «у Vision камера во всех профилях заявлена по умолчанию, но можно убрать»). It gets media.video.realCamera: false, and with no file chosen the core plays the profile’s own drawn picture (0083, §2k) — a refusal (0078, like a busy camera) is only what a config with no canvas.seed gets. realCamera: true is kept as a word.
  • A camera file brings its camera (A191): choosing a file (profile.update {camera}, or the profile’s own window, carried at the seal) gives a profile whose draw claimed no camera (a desktop without a webcam) ONE — its machine’s own if it has one, else a webcam of its OS — so the file has a camera to play through; clearing the file leaves the camera claimed (and under realCamera: false it plays the profile’s own drawn picture again, 0083).
  • The camera’s name (A191, the profile’s camera section — «which camera»): profile.update {cameraName} names the camera the profile claims, one the catalog has for its OS (fingerprint.mediaDevices), a built-in one only on its own machine; cameraName: "" takes the camera away («можно убрать» — videoinput: 0, NotFoundError), refused for a MacBook or an iMac and while a file is chosen; profile.get reads back the name and cameras, the count. What the camera PLAYS is the file, or with none the profile’s own drawn picture (0083). No field for the drawn picture is needed (A192): its seed is canvas.seed and its timeline the core’s; the owner’s four choices (the profile’s picture by default, a file, the machine’s camera, no camera) are realCamera, camera and cameraName: "". The modes a page reads follow the name (A192, media.video.modes, §2k).
  • The microphone (A191, the form B147 measured): profile.update {microphone: false} claims none (media.devices.audioinput: 0, a machine without one — NotFoundError, patch 0051 hides the kind); true claims one. A machine that always has its own microphone (MacBook, iMac, Windows laptop) is refused false. No default: a new profile keeps the microphones its draw gave it — the owner decided the camera, not the microphone.

Font kits (font_catalog::FONT_KITS): the module’s free families come onto a profile in KITS — office, designer, developer, creator, handwriting, Cyrillic — by the machine’s kind (office or gaming, laptop or desktop: the class and the card) and the language, each kit a stable draw on the identity’s id, and from a kit its count of families by how often each is installed. Measured on the 33 families of module 0001: about half of the Windows profiles get some (two thirds with a Cyrillic language), 1.5–1.65 families each on average, up to 16; the most common catalog-made set falls from 31 % to 22 % of a class (the font-set gate’s ceiling is a third). The shares are ASSUMED — see the doc comment. Module 0002 (A191, built — FONT_PACK_CANDIDATES.md (internal: docs/30-core/FONT_PACK_CANDIDATES.md)): 117 families, a handwriting kit added, the kits wider; measured: about half of the Windows profiles get some (seven in ten with a Cyrillic language), 2.0–2.2 families each on average (3.0–3.3 with Cyrillic), up to 20–23; the most common catalog-made set is 21 % of a class; the most common family drops from 14 % to 11–12 % (Montserrat on a gaming desktop). Every one of the 117 is drawn under its own name on the width, canvas and enumeration roads (core/scripts/font-set-roads.mjs on the 155 core, a Windows identity declaring all 117: W 117/117, M 117/117, queryLocalFonts 117/117, no face of another family listed under a module family’s name); six fetched families were left out by that run — DirectWrite names them otherwise (modules/fonts/families.json, left_out_by_measurement).

B192 — about ten, on EVERY Windows profile (the owner, 2026-11-07: «Лучше пускай будет на каждый профиль рандомные шрифты. Лучше их там будет 10 штук… совсем немножко добавляется, то ли 3, то ли 4»). His count was right: before this, a new en-US Windows identity drew no kit family in 48 % of cases and 3.8 on average otherwise (2.0 overall, a190_font_numbers, 10 000 draws). Now every Windows identity draws font_catalog::HAND_INSTALLED = 7 to 13, ten the most often (hand_installed_count, its own stable draw), taken KIT BY KIT: the kits its kind and language allow, in an order drawn by their weight for the kind (FontKit::pct, now a weight, not a share), each giving up to its max, by how often each family is installed — so the ten go together. Gate every_windows_identity_draws_about_ten_and_no_two_share_them, 1 000 identities per kind and language: mean 9.95, 1 000 distinct sets of 1 000, no family on every one, the most common on 34–46 % (Open Sans on office machines, Bebas Neue on gaming ones, PT Serif with Russian). The three layers, as the owner named them: the OS’s (the census of this machine, only families an OS ships), the language’s (the script’s Feature on Demand, WithLanguage), and these. ASSUMED: the count is the owner’s; the two machines ever measured carry three. Fonts Office, Adobe or games install are not a kit: their files are licensed, the module cannot carry them, and a family the engine cannot draw is absent to every page. A profile made before keeps its own list (the story is told once, A170).

2s. A Linux identity’s card, and the Linux classes (A199, ISSUE-527 – «у нас нету каталога Linux тоже»)

What a Linux page reads. Chrome on Linux runs WebGL through ANGLE’s OpenGL backend (Vulkan is off by default), and ANGLE builds the renderer from the driver’s GL strings: ANGLE (<GL_VENDOR>, <GL_RENDERER>, OpenGL <first word of GL_VERSION>), commas removed, an AMD string cut before , DRM (so neither the kernel nor DRM reaches a page), and no PCI id – the (0x…) token is D3D’s. The vendor is Google Inc. (<GL_VENDOR>). Read in this tree’s Chromium 155 source (libANGLE/Context.cpp, renderer/gl/DisplayGL.cpp and its unit tests, libANGLE/Display.cpp).

On the system the identity claims (Ubuntu 22.04, Mesa 23.2.1): Intel Mesa Intel(R) <name> (<codename>), vendor Intel, version 4.6; AMD <marketing name> (<chip>, LLVM 15.0.7, …) – without the radeonsi, prefix Mesa 23.3 added – vendor AMD, version 4.6; NVIDIA NVIDIA GeForce <model>/PCIe/SSE2, vendor NVIDIA Corporation, version 4.6.0. Each shape was found printed by real machines (glxinfo/inxi in public threads, nine rows carry their source in seen); the other rows are built by the same rule. None was rendered by us – the lane has no Linux machine.

The table (vflin-fingerprint::gpu_linux, 53 cards, served as fingerprint.gpus.linux): the Steam survey’s Linux platform, September 2026, by share – without handhelds and consoles (Steam Deck, Steam Machine, the one-month spike of Radeon 610M), lumps that name no card, an NVIDIA card in a laptop (Ubuntu’s PRIME on-demand renders a browser on the integrated GPU – ASSUMED that a profile is not offloaded), and cards the claimed system cannot drive or cannot be shown to NAME as written (RDNA 3 and 4, Battlemage, Meteor Lake, Radeon 680M/780M, Polaris: their marketing names come from the system’s libdrm table, which on 22.04 is not shown to know them).

The classes: linux-desktop (an integrated office machine or a discrete-card desktop; the split is the table’s, 3.54 : 25.94 – Steam’s Linux population plays games) and linux-laptop (new: integrated only). A preset (ubuntu-chrome-desktop) draws its card from the same table at profile.create, of the template’s class and form, the machine’s maker first, as Windows does (A139). The validator’s C2_GPU_OS_MISMATCH refuses on a Linux identity what another system writes: D3D, Metal, Apple, a PCI id.

What the table says that nothing acts on yet: webgpu – whether Chrome 155 on Linux would offer a WebGPU adapter on the card (its GPU blocklist, entry 186: Intel Gen12+ on Mesa 22+, NVIDIA driver 535.183.01+; every AMD card and every Intel before Tiger Lake has none). On this Windows host the core offers one whatever the claim (patch 0025 names it after the claimed card) – a page asking navigator.gpu.requestAdapter() on an AMD Linux identity gets an adapter a real one would not (ISSUE-552, core).

The core reads it since patch 0091 (B158): webgpu.adapter – a boolean; false = the claimed machine has no WebGPU adapter, and navigator.gpu.requestAdapter() resolves null with the console warning «No available adapters.», after the request’s real round trip – Chrome’s own refusal, read in its sources. Absent, true or any non-boolean keeps the adapter (every config before 0091). The daemon writes it from the table (gpu_linux::LinuxGpu::webgpu → false for the refused cards) – control-plane’s half; until then a Linux AMD identity still gets an adapter. Independently of the field, a Linux claim now hears getPreferredCanvasFormat() = rgba8unorm, as every Linux Chrome answers.

An integrated card’s threads are its processor’s (A200, ISSUE-553). An integrated GPU sits on the processor’s die, so the card names the processor family: gpu_linux::CPUS lists, for each of the 15 integrated rows and each form it is drawn for, the processors Mesa names with that string and their thread counts (the makers’ specifications; the weights between one card’s processors are ASSUMED). Every road that makes a Linux identity – both classes generated, with or without a maker, and the Ubuntu preset at profile.create – draws the threads from there, and the memory from the class table’s rows at that count. Before: 68.2 % of Linux laptops and 6.8 % of Linux desktops claimed a pair no machine has (an HD Graphics 520, always 2 cores and 4 threads, with 12); after: 0 of 1 000. The price, stated: a Linux laptop is now 8 threads in ~60 % of draws, because the cards Linux laptops have (Iris Xe, UHD 620, Vega 8) sit on 4-core, 8-thread processors – the concentration gate holds a Linux class to that same source (card share × its processors), not to Steam’s Windows survey; K72 measured that the core count is what lets fingerprint.com merge identities at one exit, so this is the place to measure next. A discrete card’s processor is any processor: its threads stay the class table’s. Windows identities still draw theirs apart from the card – the same defect, not yet measured there; the pool’s Windows names carry the PCI id (Intel(R) Iris(R) Xe Graphics (0x00009A49) is a Tiger Lake, always 8 threads), so the same binding is possible by device id (ISSUE-553).

Residuals, named. A Linux laptop has no built-in camera name in the catalog (it draws a USB webcam). screen.available keeps GNOME’s 27-px top bar, which a Chrome on native Wayland may not see. And everything a GPU decides past its name – limits, extensions, precisions, the pixels – stays this host’s D3D11 answer (ISSUE-020), on Linux exactly as on Windows.

2t. The catalogs of B170 – screens Windows really reports, Intel and AMD Macs, and a card taken whole (ISSUE-672, ISSUE-677)

Screens: 59 rows -> 78. SCREEN_MODES kept only a panel divided EXACTLY by a scale, on the premise that the browser reports whole pixels only then. Chromium says otherwise: on Windows a display’s bounds are gfx::ScaleToEnclosingRect(screen_rect, 1.0f / scale) (ui/display/win/screen_win.cc), the far edge rounded UP in single precision – validate::windows_css_size. So 2560x1600 at 150 % reports 1707x1067, 3840x2160 at 175 % 2195x1235, 3440x1440 at 150 % 2294x960: exactly the three sizes K98 found on competitors’ Windows profiles and this table refused. A row whose division is not exact now NAMES its panel (ScreenMode::panel_px) – multiplying 1707 by 1.5 gives 2560.5, rounded 2561, the very panel number a competitor’s profile carried – and the gate holds the size to the rule. Added: 19 Windows pairs (the panels already sourced here, at the other scales Windows offers them; the rare ones, 175 % on a QHD monitor, left out so a panel’s share is not spread thin) and Linux at 200 % on six HiDPI laptop panels (GNOME’s own scale). The A128 gate’s premise was rewritten in place, with the mutant it must still refuse.

macOS: Intel and AMD Macs, and the M3 iMac. gpu_mac::MAC_X86 – 21 cards (5 Intel, 16 AMD), each a string SEEN in Web3D Survey’s macOS tables, shaped by ANGLE’s Metal rule read in source (DisplayMtl.mm, driver_utils.cpp), and each in a Mac on Apple’s macOS 14 list (support.apple.com/en-us/105113); the 2017 machines’ cards are left out (they stop at macOS 13). They exist because the processor a page reads is the machine’s: a Mac identity minted on an x86 machine claims x86, and an Apple chip on x86 is the incoherence C2_GPU_FOREIGN names. They are not drawn yet – the generator does not know the machine’s processor, and moving a card inside apply_host_architecture would also rewrite a card a person CHOSE (that function runs on a config brought whole). Drawing them is one decision where the daemon knows the machine: x86 draws these, arm draws Apple silicon, with each machine’s threads and memory. Until then they are the catalog’s for a card a person picks or a profile brings. The generator’s desktop table also gained the 24-inch iMac (2023) on M3.

A card taken whole (ISSUE-677). vflin_fingerprint::card::find looks a renderer up over every table (Windows’ three, Linux, macOS Apple and Intel/AMD) and card::adopt writes the WHOLE hardware.gpu block from that one row, as a struct literal – a field added to the card later does not compile there until it is filled. The gate takes every card of every table over a config carrying the machine’s card (a GTX 1080 Ti, its id and the foreign word) and finds nothing of the machine’s left; a card outside the catalog or of another system is refused and moves nothing. What the core does not project is said, not hidden: WebGL’s limits, extension list and precisions are the real GPU’s (ADR-0031); a per-card table would have to be measured card by card (ADR-0035; B52: a claimed card carried the host’s extension list, 35 of 35 in order). The import (localapi) is to call card::adopt for a card the catalog has – a control-plane line.

Not done here: the camera and microphone names (each camera row also carries its capture modes from the maker’s page, so a name is not added without one); Intel’s older integrated cards on Windows (K98’s one missing model, Intel HD Graphics 4400, and its Haswell family); drawing the Intel/AMD Macs.

2u. speech.localVoices – the LOCAL voices of the machine an identity claims (B184, ISSUE-756; patch 0099)

The field. speech.localVoices: [{"name", "lang"}, ...] – the machine’s local speech voices in the order a page lists them (speechSynthesis.getVoices()), the first its default: name is the exact name Windows gives a voice (its token’s description, also the page’s voiceURI), lang the tag Chrome reports. Written by the daemon AT A LAUNCH and never kept with the profile (launch_config_for -> vflin_fingerprint::speech_voices::set_launch_local_voices), like fonts.dir: it is drawn from the language the profile claims NOW and its canvas.seed, so a language changed later – by the exit, by a regeneration – never leaves the old one’s voices behind. [] says the machine has no local voice. Absent: the host’s own voices of the claimed family (0098). speech.voices (0072’s setting) sits beside it and is carried through a round trip; the product writes it nowhere yet.

The draw (crates/vflin-fingerprint/src/speech_voices.rs). A Windows machine’s local voices are its language packs’ voices, and a pack brings all of them, so the draw is of PACKS: the Windows DISPLAY language the claim implies (its first voice is the default – en-* displays as en-US except en-GB, es-* as es-MX except es/es-ES, fr-CA, pt-PT, zh-TW/HK/MO keep their own, German, Arabic, Dutch display as de-DE, ar-SA, nl-NL); the claim’s own regional pack beside it when it is another (en-CA, en-AU, en-IN, en-IE, de-AT, de-CH, fr-CH, ar-EG). The order is the voice tokens’ – language, then name – with the default swapped into the first place. One language, one list (B185, 2026-11-06): B184 added, on the share real machines have, one to three packs more, drawn on the profile’s seed; the owner’s measurement of 2026-11-01 (ISSUE-773) and brief B185 set the bar the other way – two profiles with one exit show one set, as Vision does – so the extra packs are no longer drawn, and every profile of a language lists the set most of that language’s machines carry (the display pack alone: 136 of 170 English machines, 43 of 65 others).

Every number has its source (each row of WINDOWS_VOICES names its own):

  • which voices a pack has: Microsoft, Appendix A: Supported languages and voices (49 languages);
  • the exact names, the order and the shares: 241 real Windows voice lists (the camoufox presets shipped with donutbrowser 0.26.0, presets.windows[].speechVoices – Firefox, which lists the same OneCore tokens Chrome reads; how they were collected the files do not say). Counted there: a pack is always whole; the order rule holds for 229 of 230 lists (the one exception lists a voice twice); the default is the first voice of its pack by key in all; 34 of 170 lists with an English default carry another pack (20 %), 22 of 65 with another default (34 %); of those 56, 33 carry one pack more, 15 two, 8 three or more (drawn as three); 5 of 236 Windows 10/11 lists have no OneCore voice at all;
  • this host (K109): Chrome lists George, Hazel and Susan, George the default.

Names no real list shows (B185). 17 of Microsoft’s packs had no exact name in the real lists. 14 now take their names from Vision’s own catalog, read from Vision.exe on this machine (Vision shows a Thai profile Microsoft Pattara, ISSUE-773): checked first against the real lists, Vision’s strings equal them byte for byte for 47 of the 48 voices both carry – Heami is Korean (Korea) there and Korean (Korean) in the real lists, which keep their form. Three stay undrawn and keep 0098’s list: Catalan and Tamil (Vision has neither) and Swedish (Vision’s Microsoft Bengt - Swedish has no region, unlike every other name – not taken on trust). A language Windows has no voice for (Ukrainian, Kazakh, the Baltic languages …) gets [] – the state of the owner’s own Ukrainian laptop (B181, stock Chrome: no local voice). macOS and Linux identities get nothing: their voices are another catalog.

What the core does with it (0099): the page sees these local voices where it saw the host’s; one the host has (same name) speaks as itself; any other is spoken by a host voice dealt to it – the same tag first, then the same language, then any, one each while they last, past that shared a SAPI rate step apart – so no two names read a phrase in one time; on a host with no local voice, silently with a local voice’s events. Not claimed: a named voice’s duration is a host voice’s, not that voice’s own.

2v. The machine’s own fonts at a launch – withdrawn by B192 (was B185, ISSUE-768/772; ISSUE-806)

Now: a launch never trades the identity’s font list for the machine’s. fonts::set_launch_machine_fonts changed nothing since B192 and is gone (its call left launch_config_for, the function B193); the core gets the record’s presentFamilies, generics, the module’s dir and the pitch list, and patch 0023 hides every family the machine has that the list does not claim – the fonts a person installed by hand among them.

What B185 did, and why it was reversed. It sent a Windows identity on a Windows machine of its version to the core with NO list (presentFamilies empty, no generics, no module folder), so the page saw the machine’s fonts as the machine’s own Chrome and Vision show them (K98), and it put the price in this section: the font kits stopped reaching such a profile. Right for one browser; for the fifty profiles a person runs on one machine it is one shared mark – the run-in measured it (S72, L5: Fira Mono, Indie Flower, Lato, this machine’s hand-installed families, on 4 profiles of 4), and the owner decided on 2026-11-07: «мы не должны палить основную машину… Это нужно убрать». B185’s «one font off» (CreepJS 12 against 11) was Source Code Pro / Chakra Petch, families a kit of the identity added – the third layer (§2r) doing its work.

Measured on the product path (core/scripts/font-leak-daemon.mjs: the run-in’s own L5 block over the 3 families this machine has by hand, the module’s 114 others and 2 that exist nowhere, judged against each launch config’s list; series-88 core, win11-desktop us): the tree without the change – 4 profiles of 4 show all 3 hand-installed families, with an empty list; with it – 0 of 3 on 4 of 4, and every module family a profile declares is drawn, none it does not.

2w. The machine’s devices: device (B186, ISSUE-684/775; patch 0100)

"device": { "touchPoints": 10, "bluetooth": true, "battery": { "charging": false, "level": 0.42 } }

What a page reads from it. touchPoints – navigator.maxTouchPoints, and above 0 a coarse pointer in any-pointer beside the touchpad’s fine one (the primary pointer stays fine, hover stays on, ontouchstart stays off – Chrome’s own answer on a Windows touch laptop); bluetooth – navigator.bluetooth.getAvailability(), and without an adapter requestDevice() fails as on a machine with none; battery – navigator.getBattery(): absent, the machine has none (a desktop: charging, level 1, charging time 0, discharging time +Infinity); present, a laptop’s, in the state charging/level give (no state: on AC and full). Optional and parsed whole: absent or malformed, the host’s own, as before the field (and 0097’s pointer).

Who writes it. The LAUNCH (vflin_fingerprint::device_traits::set_launch_device_now), from the identity’s class and seed – the same machine at every launch – and the battery’s state drawn anew at every launch. A record that carries device keeps its touch screen and adapter; only the state is the launch’s. Windows identities only (the sources are Windows machines); a label naming neither desktop nor laptop gets none.

The shares (recounted by core/scripts/device-shares.mjs): a laptop’s touch screen at 46 of 151 scaled real machines (10 contacts at 40, else 5), a desktop none; a laptop’s state from the 31.09 % of real Windows Chrome states only a laptop shows (charging 2443, on battery 666 basis points, by tenths, a whole percent); Bluetooth at 2803 of 3287 laptops and 1604 of 3049 desktops – machines with a Bluetooth module, Linux-Hardware.org December 2025 (recon K111; until B188 a 2014 forecast of 75 % / 27 %, the desktop share half the measured one).

2x. locale.uiLanguage — the browser interface a person chose (B191 with A235, ISSUE-792)

What it is. An interface code Chrome ships — one of machine_locale::interface_locales(), the 52 keys of IDS_ACCEPT_LANGUAGES (ja, en-GB, pt-BR, es-419). Absent: the interface is the identity’s own language, which is what every profile had before; the form’s «по языку профиля».

What reads it, and what does NOT. The launch passes --lang = the interface the HEAD of locale.languages names (machine_locale::interface_switch, launch.rs) — the chain is what a page sees (navigator.languages, Accept-Language), so the engine’s interface, and with it the generic font families, never disagree with it. This field is the person’s CHOICE that every chain is built from: derive::chain_for(primary, uiLanguage) puts the chosen interface’s chain first and the identity’s language after it (en-US, en, ja-JP, ja), and derive::set_identity_language is the one place a re-derivation (following an exit, following the machine, a language chosen by hand) writes locale.language, locale.languages and fonts.generics together — so the identity’s language moves and the chosen interface stays.

Why it is stored at all — the head alone cannot tell «the person chose English» from «the identity is English», so a chain rebuilt from one language dropped a choice silently (A235 found it). And why it cannot become a second source: C9_INTERFACE names a config whose uiLanguage is not an interface Chrome ships, and one whose chain head names another interface — a re-derivation that bypassed chain_for is then loud, on the profile’s own row. Tests: derive::tests::following_an_exit_moves_the_identity_and_keeps_the_chosen_interface, …every_country_under_every_interface…, …c9_names_a_forgotten_choice…; machine_locale::tests::no_two_interfaces_begin_their_chains_with_the_same_tag holds the claim the reading stands on. The core does not read the field (an older core and an older program ignore it).

2y. The language an exit’s country gives, for EVERY territory (B192)

country_languages::for_country answers for every territory of CLDR’s territoryInfo but four (254 rows; NO_LANGUAGE — Antarctica, Clipperton, Heard Island, ZZ — has none, CLDR records only und). Before, 206 rows, and 48 countries with a clock had none: their exits moved the clock and left the language — in practice English (the owner’s Albanian frame: en-US, en where Vision sends sq-AL, sq, en-US, en). The rule is a program now, core/scripts/gen-country-languages.mjs (--check holds the table to it byte for byte): the official language (de jure or de facto) with the largest share that the core’s ICU can format (measured, core/scripts/fixtures/icu-locale-support-157.json: 69 of 228), else the largest official one anyway; Chromium’s chain for it, else Chromium’s SHAPE <lang>-<CC>, <lang>, en-US, en (basis: "synthesized", 23 rows: Albania, Kosovo, Armenia, Georgia, Iceland, Malaysia, Pakistan, Bosnia …). It reproduces 204 of the old 206 rows and corrects two: Hong Kong en-US → zh-TW, zh, en-US, en, Macao pt-BR → the same (CLDR: traditional Chinese 95 % and 98 %). Malta is en-US, en — English is official there and Maltese is a language this core cannot format; Vision sends mt-MT.

The gate: country_languages::tests::every_territory_has_a_language_or_is_named_without_one — every CLDR territory and every one windowsZones gives a clock to has a row or is in NO_LANGUAGE; a new one fails the build. What a synthesized chain brings with it: derive::languages builds the same shape for any language Chromium ships no chain for (kk-KZ → kk-KZ, kk, en-US, en; the old [tag, base] stays a chain C9 accepts, so no record moves), and the launch starts such a profile in en-US (machine_locale::NO_INTERFACE_FALLBACK), not in the machine’s interface: measured, --lang=sq-AL alone starts in this machine’s en-GB and writes «Please fill in this field.». Chrome ships no Albanian interface, so a real Albanian’s Chrome is in another language too — which one is ASSUMED.

3. Consistency rules (the validator)

Validation FAILS (not warns) when signals contradict. Examples:

  • webgl.gpu.vendor must be plausible for os.family (no Apple GPU on Windows).
  • Beside the machine it runs on — said, not refused (A119, ISSUE-300). UA-CH architecture follows os.arch, the processor of the machine the identity is made on (a page reads the real one from the sign of a NaN), so a macOS identity made on an x86 machine claims x86. An Apple card off an Apple-silicon Mac is C2_GPU_FOREIGN; an identity whose OS is not the machine’s is C8_OS_FOREIGN_HOST — the engine’s set of web APIs is its own OS’s (CreepJS reads the OS from it), which no patch closes. Both are coherence flags on the profile’s row. A card of another vendor than the machine’s is C2_GPU_FOREIGN_VENDOR (A120) — the pool is not narrowed to the machine’s vendor until the owner says so; and settings.foreignOsPresets: hide leaves identities of another OS out of the catalog (A120, the owner’s question made a switch; show is the default).
  • webgl.gpu.vendor and webgl.gpu.renderer must name the SAME vendor token (C2_GPU_VENDOR_RENDERER_MISMATCH, A99). The bracket in Google Inc. (T) and the first field of ANGLE (T, …) are one token; one card has one vendor. This is the owner’s A8 — «какого хрена можно поставить GOOGLE Inc. (NVIDIA) и видюху AMD RX 6600» — and the rule above does not catch it: that renderer says Direct3D11, carries no Apple and no Metal, and the OS is Windows, which is everything it ever asked. Both halves are individually plausible; only together are they a lie. The rule is K32’s R1/R2, which was measured on a real host and then tested against an independently assembled catalogue of 170 rows — it holds on 169 and breaks on 0. It declines to judge what it cannot read: a vendor string with no bracket names no token, and a non-ANGLE renderer has no first field. Inventing a comparison there is how a rule earns false alarms and then gets weakened to silence them. Configs that already exist are repaired, not refused. The launch path refuses anything the validator rejects, so shipping this rule alone would have stopped affected profiles opening. The incoherence has one correct resolution — the renderer names the CARD and the vendor string is a consequence of it — so the daemon rewrites the vendor towards the card, logs it once per profile, and never touches the card itself.
  • fonts.set must match os.family/os.version (no macOS fonts on Windows).
  • locale.timezone must be reachable from network.proxy geolocation (timezone↔IP coherence).
  • browser.uaCh must be derived from os + browser.version (never free-typed).
  • B173 (ISSUE-684): the pointer follows browser.uaCh.mobile, not the host’s mouse (core patch 0097). An identity that is not mobile (false or absent — every desktop and laptop the catalog makes) shows a page pointer: fine, any-pointer: fine, hover: hover, any-hover: hover and maxTouchPoints 0, whatever is plugged into the host — a server or VPS with no mouse used to give every profile pointer: none. A mobile identity keeps the host’s answer: there is no phone or touch-laptop class yet, and one needs its coarse pointer and touch points as measured config fields (ISSUE-680), not a value the core guesses.
  • screen.dpr×base resolution must be a real device combo, not arbitrary.
  • screen.available must fit the display (C6_AVAIL_BOUNDS, C6_AVAIL_EMPTY) and put its reserve on the edge the claimed OS reserves from (C6_AVAIL_RESERVE_SIDE). The second is the load-bearing one: an ordering rule passes on availTop 0 beside availHeight = height − 25, because two correctly ordered numbers can still describe a device that does not exist. The MAGNITUDE is deliberately not pinned — a real host’s Dock, taller taskbar or auto-hidden menu bar is realistic by construction under ADR-0031, and refusing one would be a false negative.
  • A185 (ISSUE-513): a macOS identity must have its menu bar (C6_MENU_BAR_MISSING, availHeight == height with availTop 0). Measured, not reasoned: K70’s MacBook 1440×900 @2 with the whole display available read Screen and CSS Media Queries red on CreepJS (like-headless 19 → 25), the same identity with its bar green, 19; Vision ships 14.8 % of its Mac prints that way. So on macOS the zero reserve is refused — on Windows it stays legal (a kiosk, a hidden taskbar). The code is NOT in the C6_AVAIL family on purpose: a config brought whole gets a STALE rectangle of that family repaired (a resolution changed under it), while this one is a claim, refused with its name (profile.create answers 400). A profile STORED before the rule is not stranded: its start, and a profile.duplicate of it, derive the rectangle again with the bar (validate::repair_menu_bar, journalled once). Gate a185_tests::no_route_forges_a_mac_without_its_menu_bar calls every one of the eight forging routes A184’s audit finds in the router, with a Mac whose rectangle is the whole display, and accepts a refusal or an identity with its bar — nothing else.
  • A185 (ISSUE-514): the MacBooks with the camera notch are not offered (validate::MENU_BAR_UNMEASURED: 1470×956, 1512×982, 1728×1117, 1440×932 @2). Their menu bar is taller than the 25 px every Mac is given here — K70 read 32–39 px for exactly these sizes in Vision’s catalogue, another product’s numbers and not a reading of a live machine, so they are not copied in (ADR-0035). The rows stay valid (a stored profile on one still launches), the screen picker and the generator no longer hand them out, and a MacBook on an external display (whose menu bar there IS 25 px) still takes its MODEL from them by the survey’s shares, so the product’s MacBooks are not all one model. The catalog’s laptop preset moved from the notched 14″ MacBook Pro M3 (1512×982) to the 13″ MacBook Pro M2 (1440×900 @2, no notch). Unblock: vflin node characterize on a live notched MacBook (CONTRIBUTING_A_DEVICE_CAPTURE.md (internal: docs/30-core/CONTRIBUTING_A_DEVICE_CAPTURE.md)) gives the real availTop; then these rows come back, with it.
  • webrtc.publicIp must equal the proxy’s egress IP; localIps masked.
  • network.tls (JA3/JA4) and network.http2 (SETTINGS/Akamai fp) are derived from os + browser.version and must match the claimed browser — a Chrome-148 identity emits a Chrome-148 handshake, not a Chromium-default or proxy-rewritten one. (Suite C10/C11.) fingerprint.validate returns { valid, issues[] } with a stable code per rule.

4. Derivation, not free-entry

Several fields are computed to stay coherent: UA string + UA Client Hints from os+version; languages[] from primary language; colorDepth/dpr from a device profile. The UI exposes high-level choices (device class, region); the catalog derives the rest.

5. Sources of values

  • The graphics card a person CHOOSES is the pool the generator draws from (fingerprint.gpus, A99). Until then the two were different sets, and the gap is the owner’s A7: the seeded generator drew from 253 real Windows cards weighted by the 4 462 machines they were observed on, while the only thing a person could pick from was the 6 preset configurations. His «всего 6 видюх» was a true statement about the choice and a false one about the product. The six were also RARE, which is worse than few. Their three Windows cards were seen on 77 of 4 462 machines — 1.73 %, the RTX 3060 at 0x00002503 on three. Handing someone a rare card is exactly what ADR-0037 exists to prevent, so fingerprint.gpus is ordered by prevalence: the top of the list is the middle of the crowd. The top 25 cards cover 50.6 % of those machines, the top 100 cover 89.7 %. Vendor and renderer travel as one row, like screen-modes: one marketing name spans several device ids and one device id spans several names (0x000067DF is both RX 570 and RX 580), so neither field determines the other and two independent selects offer only an incoherent pair. It is a neighbouring read, not a replacement for catalog. A preset is a whole identity — screen, fonts, locale, timezone, cores, memory — and only one field of it is the card; turning 6 presets into 253 would mean inventing the other five for every card, which is the guesswork ADR-0035 rejects and which ISSUE-047/048 already caught this product doing once. Provenance: Borrowed, never Measured — real observations made by someone else, carried with their source named. Windows only: the macOS and Linux renderer forms are not confirmed against anything measured here.
  • A profile made without choosing anything is GENERATED, whole (A101, the owner’s decision of 2026-09-17: «надо, чтобы сразу при нажатии новый профиль был новый фингерпринт, без кнопки генерейт»). profile.create that names no fingerprintRef, no inline fingerprint and no fromHost now runs the same code fingerprint.generate runs — generate_for_core then canvas noise off — so the profile gets its own seed, its own card from the weighted pool, its own screen and hardware. Before this the branch returned NOTHING and the profile launched wearing the host’s identity; the preset an operator saw was the editor’s choice, not the daemon’s default. The seed is freshly minted, not derived from the profile id, which was the one thing the brief asked for and did not get: a profile id is cleartext to the cloud (it keys profiles_meta and rides in the blob and sync URLs), so a seed equal to it would let the server predict a profile’s canvas noise the moment an operator turns ADR-0037’s switch on. Reproducibility is kept where it is safe — the same SEED rebuilds the same config byte for byte. A bound exit chooses the region, by inverting REGION_LOCALES on the exit’s declared timezone rather than by any new rule: without it a profile created against a German exit is generated for us and contradicts its own egress IP on its first launch (C4). A timezone no region offers falls back to the generator’s default instead of being guessed at. Presets are unchanged and remain the explicit choice by name.
  • A profile made without choosing anything gets its card from the same pool (A100, ISSUE-219). The choice was fixed by A99, but pressing Create still read the preset’s card as a constant, and K34 read it off the page: RTX 3060 (0x00002503), 3 machines of 4 462. Now profile.create draws each profile’s card on its own seed through generator::pool_card — the function fingerprint.generate has used since A60, so the two paths give the identical card for the same seed and class. The draw keeps the preset’s integrated or discrete CLASS, so its screen, cores and memory still describe a real machine; a macOS or Linux preset keeps its card, because the pool has no rows for those systems. A Windows template’s card is therefore a class, not the card a profile receives. Read the card a profile holds from profile.get (gpu) — the stored vendor and renderer verbatim, plus machines when it is a pool card. A card is matched to the pool by its WHOLE renderer string: two pool rows carry no device id.
  • The clock and the languages follow the exit’s country — every country (A121, ISSUE-306/307). A profile whose halves are not pinned (identityPinned.timezone / .languages) takes them from its bound exit at create, at bind, when a background measurement of the exit lands, and at every launch before the config is built. The exit’s country is the operator’s declared one when set (it wins over a measurement, which stays on the record and is said when it disagrees — supersedes A102 for the country); else the measured one. The zone: a declared zone; else the measurement’s when it located the exit in that country (the city’s zone); else that country’s own zone — the country’s only IANA zone in Unicode CLDR’s windowsZones mapping (the table A113 already carries as machine_locale_data::WINDOWS_ZONES), or CLDR’s primaryZones (metaZones.txt) for a multi-zone country that names one (DE, ES, PT, CN, …); a country whose clock depends on the city (US, RU, BR, CA, AU, …) has none, and nothing moves until the exit is measured. Never a default. The languages: CLDR territoryInfo + Chromium’s IDS_ACCEPT_LANGUAGES (country_languages::for_country), expanded by the C9 chain. Code: vflin_fingerprint::country_zones, vflin_localapi::proxy::exit_geo. A disputed address (located in one country, registered to another) is ProxyRecord.geoDisputed and C4_PROXY_GEO_DISPUTED on the row — said, the band kept, the zone from where it is located.
  • Catalog presets: curated, coherent device profiles (win11-chrome-desktop, macos-safari-…).
  • Generated: constrained generation (e.g. Apify Fingerprint Suite as a value source), then run through our validator. (We use it for “what”; we apply via the core for “how”.)
  • Imported / real-device-derived: highest trust; coherence by construction. (Vision/Afina’s “real device fingerprints, not generated” maps here.) Now built for the local tier by host-inversion — see §5a: a node’s device-hardware dims are taken from its OWN real host, not a synthesized guess.

Sourcing strategy (the realism differentiator — blocker before Phase 2). Generated combos are plausible but not real; Vision/Afina win on real-device truth, so the catalog’s data source is a load-bearing decision, not a footnote. Options, to be settled in an ADR before Phase 2:

  • Real-device collection — an opt-in agent / small device fleet emits coherent signal bundles (no PII): real GPU+OS+screen+font+net combos → highest realism; ongoing cost; must cover the OS / GPU / screen / locale matrix and refresh as devices update. (This central-pool variant is now the deferred B-fleet tier; B-local host-inversion — §5a, ADR-0031 — ships the real-device tier WITHOUT a pool, by taking each node’s own host, so the dataset-coverage cost dissolves for local use.)
  • License a dataset — fastest start; recurring cost; coverage/quality varies; ToS risk.
  • Generate + validate (Apify) — cheap breadth; lower trust; fine for low-stakes profiles. Decided (ADR-0012, Accepted 2026-06-18): foundation = curated catalog + constrained Rust generator now (browserforge as a later breadth upgrade behind this schema); a real-device collection pipeline for the high-trust tier grows later, gated by its own privacy/consent ADR. Every identity is trust-scored by provenance (§6); preset refresh must track Chrome stable (presets age fast). → ADR-0012 + CATALOG_SOURCING_COMPARISON.md.

Realism prior — how the constrained generator draws (ADR-0012 realism prior, Proposed 2026-07-01). The foundation generator no longer draws uniform-random over the flat tables; it draws a frequency-weighted JOINT over a hand-encoded coherence graph (os → chrome_major → gpu → {cores, ram, screen}; region → tz/lang), deterministically from the per-profile root seed (same seed ⇒ same tuple; different seed ⇒ usually different). The GPU carries its own plausible hardware/screen sub-tables, so jointly-impossible combos (a laptop iGPU on a desktop {16-core, 32 GB, 4K} tuple) are unrepresentable — the edges are encoded, not reconstructed from marginals. This refines §4 (the generator picks coherent and plausible-frequency combinations) without changing the §2 schema or the §3 validator: weights are generation-time const metadata, not serialized fields, and every draw still passes the validator by construction. See ADR-0012 realism prior (internal: docs/10-architecture/adr/0012-realism-prior-weighted-joint.md).

5a. Host-inversion — the real-device tier, by construction (ADR-0031, B-local)

The “real-device-derived” source (§5) is built for the local tier, but by inversion, not collection: instead of a central dataset/fleet emitting signal bundles, each node characterizes its OWN host (the capture-profile harness writes a HostFingerprint cache) and a profile’s device-hardware dims are taken from that real host — coherent by construction, nothing synthesized. Because the values are real (not plausible-guesses), the realism-prior tell and the WebGPU↔WebGL cross-API GPU leak (suite A8) both dissolve. Only the free dims vary per profile. The split (as host_coherent_config builds it):

Host-real (from the captured HostFingerprint) Free (per-profile — from the request / minted)
os.family/arch (navigator.platform + UA-CH arch)² screen.width/height/dpr/colorDepth — a headed/free dim¹
browser.version + derived ua/uaCh (host UA’s Chrome/…) locale.timezone · language · derived languages[]
hardware.cores (hardwareConcurrency) · memoryGb (deviceMemory) network.proxyRef
hardware.gpu.vendor/renderer (WebGL UNMASKED — the real GPU) canvas.seed — minted fresh per profile (see below)
fonts.set — the real OS’s default set (not a capture) id — minted per profile

network TLS/HTTP2/QUIC are the running core’s by construction (ADR-0027 — measured, not spoofed), so they match the host browser automatically. provenance.source = generated with realDeviceRef = "hostinv:<platform>:<renderer>" linking the config back to the host.

Per-profile uniqueness is a coherence rule, not a nicety. host_coherent_config emits a fixed placeholder canvas.seed; every create path snapshot()s the result → a fresh per-profile canvas/WebGL seed (and id). Without it, two profiles derived from the SAME host would share one seed — a cross-profile linkage tell. So the snapshot is mandatory on the host path (as it already is for catalog/inline).

Three create-time identity sources. profile.create (and fingerprint.generate*) take at most one: a catalog fingerprintRef, an inline validated fingerprint, or fromHost (host-inversion — the free dims only; device dims come from the host cache). More than one ⇒ 400. All three are snapshot()ed and pass §3 by construction. An explicit fromHost on an un-characterized device hard-fails (NOT_FOUND) — it must not silently degrade to an identity-less profile — whereas the implicit path (no identity requested) is simply None.

¹ Honest residuals: the bootstrap capture is headless, so it can’t read the real monitor (its 800×600 fails §3 C6) → screen is supplied per-profile / by a headed capture (pending). B-fleet (a central real-hardware pool) and a per-field source: real|from-fp|custom toggle (host-inversion is identity-level today — the whole config is host-derived, not signal-by-signal) remain v-next.

² os.version is host-real (since 2026-07-10, ISSUE-031 c): HostFingerprint carries the captured ua_platform_version (the OS-version carrier — navigator.platform gives only the family), and host_coherent_config derives os.version from it (Win10 platform-version major 10 → "10", Win11 major ≥13 → "11"; an empty/old cache falls back to the family default). So a Windows 10 host now emits "10", not "11". The UA-CH platformVersion is likewise host-real (ISSUE-031 c2, since 2026-07-10): the config emits the host’s EXACT value (e.g. Win11 "19.0.0"), NOT a fleet-shared forward-map constant — every Win11 profile sharing one Sec-CH-UA-Platform-Version was itself a linkage tell. The C1 validator checks it for STRUCTURED per-OS plausibility (platform_version_plausible: Windows "<major>.0.0" with Win11 major ∈ 13..=40 / Win10 == 10; macOS "<major>.<minor>.<patch>" major == os.version; Linux empty) instead of exact-equality, so a real host value passes while a macOS-shaped / out-of-range / malformed value is still rejected; an anomalous capture falls back to the forward map. browser.version is likewise host-real — parsed from the host UA’s Chrome/…. See ADR-0031 host-inversion (internal: docs/10-architecture/adr/0031-fingerprint-by-host-inversion.md) (device dims by host-inversion) and ADR-0027 network-by-inversion (internal: docs/10-architecture/adr/0027-network-fingerprint-by-inversion.md) (TLS/HTTP2/QUIC by construction).

6. Trust scoring

Each materialized identity gets a trust score from: provenance, validator result, and the running core’s capabilities (how much it could actually enforce). Low-trust launches are flagged, not silently shipped.

UI note (design): this is not a vanity per-profile number — validated profiles on a capable core are coherent (they’d all read ~max). It surfaces in the grid only as a coherence warning flag when a profile can’t be made coherent (weak core, proxy/timezone mismatch). The score itself (driven mostly by provenance × core capability) lives in the fingerprint detail, not as a column.

7. Relationship to patches

Patch authors (Phase 7) implement enforcement for fields in this schema. The schema is the stable interface between “config” and “C++ patch” — patches read config, never invent values. (CHROMIUM_PATCHING_POLICY.md §1.4, §7.)

8. Editing, one-click generation & templates (UX bar)

  • 50+ granular params, each adjustable — the editor exposes the full schema (§2) down to individual signals; each field supports Real ↔ From-fingerprint ↔ Custom (Vision pattern). Depth matches Vision/Afina (50+ tunable parameters), but every value still passes the §3 validator.
  • One-click smart generate — a single button produces a coherent, validated identity from a device class + region (uses §5 generation + the §3 consistency rules). “Smart” = it picks plausible combinations (GPU↔OS, fonts↔OS, timezone↔IP, TLS↔browser), never random; the result passes the consistency suite by construction, so an operator gets a clean profile in one click.
  • Templates — save any config (fingerprint + proxy policy + extensions + home pages + bookmarks) as a reusable template; bulk-create N profiles from a template + a dataset (AUTOMATION_ENGINE §4). Catalog presets (§5) are read-only templates; user templates are editable copies.

Open questions

Where the curated catalog comes from (build vs license vs collect), how often presets refresh with Chrome releases, trust-score formula → OPEN_QUESTIONS.md.

  • Per-field source in the schema (UI-surfaced, 2026-06-21). §8 + DESIGN_DIRECTION give each signal a Real ↔ From-fp ↔ Custom toggle, but the §2 schema v0 only carries identity-level provenance.source. Schema v1 should model source: real|from-fp|custom per field (with real flagged risky — it leaks the host device, fails §3 unless the host happens to be coherent) so the toggle is contract-backed and the core knows, per signal, whether to read the host value, the catalog value, or a validated custom override. Surfaced by the fingerprint slide-over mockup. Update (ADR-0031, 2026-07-04): host-inversion (§5a) makes the risky real case safe at the identity level — the WHOLE config is host-coherent by construction, so real no longer “fails §3 unless the host happens to be coherent” (it is, by construction). What remains v-next is the per-field granularity (mix a real GPU with a custom timezone in one config) and whether host-inversion should carry its own provenance.source (e.g. host-inverted) rather than reusing generated + realDeviceRef.