Fidelity harness: device-sim
Fidelity harness: device-sim
Section titled “Fidelity harness: device-sim”One device-profile source, one command, one scorecard that says what is NOT covered. This page is the index of the whole offline device-fidelity harness: the contracts every fidelity tool follows, how to add a tool, what the tiers mean, how to read the scorecard, and what each existing tool can and cannot tell you about a real device.
A green scorecard is offline evidence, never “safe on device”. Every run ends with a fixed NOT COVERED OFFLINE footer so nobody reads it that way (see NOT COVERED OFFLINE).
Run it
Section titled “Run it”scripts/dev/device-sim.sh all --list # the plan: tool x profile, every skip and why; runs nothingscripts/dev/device-sim.sh all # every registered tool on every profile it coversscripts/dev/device-sim.sh iphone # one profilescripts/dev/device-sim.sh all --only selfcheck # just the constants-drift checkscripts/dev/device-sim.sh tv --tiers headless,device # opt in to the device tier (needs a physical device)<profile|all>: a profile id fromtools/device_profiles/profiles.json, orall.--only a,b: run just these registered tools.--list: print the plan (tool x profile as run or skip, with the reason for every skip) and run nothing.--out DIR: where results go. Default${TMPDIR:-/tmp}/bh-fidelity/<timestamp>-<pid>(the pid keeps two runs started in the same second apart), outside the repo.--tiers t1,t2: tiers to run. Defaultheadless,window,simulator,d1. Thedevicetier is opt-in (--tiers device, or--tiers headless,deviceto add it to the others).--offline: skip tools that declarenetworkinneeds(they show as SKIP).- Exit
0: no row failed (read the summary line: a skip, warn or inconclusive row is not a clean bill). Exit1: at least one row failed. Exit2: the harness could not produce a trustworthy scorecard (bad arguments, a broken registry or profiles file, nothing to run, interrupted).
Output: the scorecard on stdout, plus in the out dir scorecard.json (schema device-sim-scorecard/1), scorecard.txt, and one directory per job, <out>/<tool>/<profile>/, holding the tool’s result.json and its run.log. Which tools are registered right now is scripts/dev/device-sim.sh all --list, not this page: other branches add tools as drop-in files, and a hand-kept list here would only drift.
The four contracts
Section titled “The four contracts”These are fixed so tools can be written against them without waiting for each other. Unknown extra fields are tolerated everywhere (a registry entry warns), so a tool can key its own data by profile id.
1. Device profiles: tools/device_profiles/profiles.json, schema device-profiles/1
Section titled “1. Device profiles: tools/device_profiles/profiles.json, schema device-profiles/1”The ONE place device facts live. Top level: schema, reference (the profile others are compared with; tv), profiles, hardware_only.
Each profile id (tv, iphone, iphone_se, ipad, a12_phone, mac) carries:
label,family(tv,phone,tabletordesktop).win_px[w,h],screen_scale(device pixels per point),hz,touch.canvas_base[1280,720]andtouch_zoom(1.3 for touch profiles, fromTokens.TOUCH_SCALE). The UI lays out in a canvas ofwin_px / (min(w/1280, h/720) * touch_zoom), the same maths asShopLayoutFixture.profileandtools/ui_capture/capture.gd.safe_insets_pt[left,top,right,bottom]in points. Fortvthese are the 5% title-safe band (Platform.TV_TITLE_SAFE).assumed: the fields NO repo constant pins. They are not mirrored from code, so do not read them as verified.lab.notessays where each came from (D1 launch events, or a guess).a12_phoneandmachave no source at all for several fields and say so.lab:d1_device(the exact D1devstring of the lab device) andnotes, which must say what the profile is NOT. A profile is a device class, not the lab device:iphoneis the 6.9in 2868x1320 class while the lab iPhone16,2 reports 2796x1290;ipadis 2752x2064 while the lab iPad13,8 reports 2732x2048;macis one of three Mac geometries in the repo.hardware_only(top level): the classes of problem no offline tool covers. The scorecard footer is built from this list.
Numbers are in the JSON and nowhere else on this page. Consumers keep their own constants (the shop layout fixture, ui_capture, shoot.gd, the perf and sweep tools, Tokens, Platform); the drift lint fails the gate when one disagrees. To add a profile: add it to the JSON, list in assumed every field no consumer constant pins, give it a lab.d1_device and honest notes, and run the drift lint (node --test tools/device_sim/tests/fid_drift.test.mjs). A registry entry that lists "profiles": ["*"] picks the new profile up; an entry that lists ids does not.
2. Tool registry: tools/device_sim/registry.d/<tool>.json
Section titled “2. Tool registry: tools/device_sim/registry.d/<tool>.json”One file per tool, named exactly <tool>.json. This directory is the only thing a new tool edits to join the harness. Fields:
tool: path-safe id: a lowercase letter, then lowercase letters, digits,-or_, at most 40 characters. Unique (a duplicate is a registry error naming both files).title: one line for humans.tier:headless,window,simulator,d1ordevice(see Tiers).scope:profile(one job per covered profile) orglobal(one job,profileisglobal).profiles: forscope: profile, the ids it covers, or["*"]. Forglobal, omit it or leave it empty to apply to every request, or list the ids it applies to: the tool still runs once (asglobal), but only forallor one of those profiles, and--listshows the list.needs: any ofgodot,network,wrangler,macos-template. An unmet need turns the job into a SKIP that says why; it never turns into a pass.godotisgodoton PATH;wrangleriswranglerornpxon PATH;networkis only unmet under--offline(nothing probes the network, a tool that cannot reach it must say so itself);macos-templateis the Godot macOS export template plus the gitignoredexport_presets.cfgin this checkout.timeout_s: wall-clock budget in seconds, a positive number up to 14400. Time spent waiting for the window lock is not counted.cmd: argv array, no shell, one element per argument. The working directory is the repo root. Acmd[0]that is a path (contains/) must exist, and whencmd[0]is an interpreter (node,python3,bash, …) the script named by its first non-option argument must exist; a typo is a registry error, not a late runtime failure.result: where the tool writes its result:{out}/result.json(it must live under{out}).
Placeholders usable in cmd and result: {profile} (the profile id, or global), {out} (the job’s output directory, <out>/<tool>/<profile>), {repo} (the repo root), and, for profile-scope tools only, {hz}, {win_w}, {win_h}, {screen_scale} and {touch} (true or false). Any other name is a registry error. Write {{name}} for a literal {name}; other braces (JSON in an argument) are left alone.
Example (illustrative):
{ "tool": "my-check", "title": "One line on what it checks", "tier": "headless", "scope": "profile", "profiles": ["tv", "iphone"], "needs": ["godot"], "timeout_s": 120, "cmd": ["godot", "--headless", "--path", "{repo}", "-s", "res://tools/my_check/check.gd", "--", "--profile={profile}", "--out={out}"], "result": "{out}/result.json"}The whole registry is validated before anything runs. Any problem in any file is a harness error (exit 2) that names every offender, so one sibling’s broken entry cannot silently drop out of the scorecard.
3. Tool result: fidelity-result/1, written to {out}/result.json
Section titled “3. Tool result: fidelity-result/1, written to {out}/result.json”{ "schema": "fidelity-result/1", "tool": "my-check", "profile": "tv", "status": "warn", "headline": "3 of 40 labels fail the size floor at the tv profile", "metrics": { "labels": 40, "failing": 3 }, "caveat": "Measured on the Mac renderer at the profile's pixel size; glyph raster on the device is not compared.", "artifacts": ["labels.png"]}status:pass,warn,fail,skiporinconclusive. Useskipwhen the check does not apply here,inconclusivewhen it ran but could not tell,warnwhen it found something worth a human look.headline: at most 140 characters (a longer one is truncated with a warning).caveat: the honest limit of this check, one sentence. Required unlessstatusisskip. It is printed in the scorecard, so write what a reader would wrongly assume.metrics: free-form object of numbers ({}when none).artifacts: paths of files the tool wrote, preferably relative to its{out}.toolandprofilemust match what the registry ran (profileisglobalfor a global tool). A result for another tool or profile is invalid.- Node tools can call
writeResult(outDir, fields)fromtools/device_sim/lib.mjs, which validates and writes the file. Tools in GDScript or Python write the JSON themselves. - Exit code:
0for pass, warn, skip and inconclusive; non-zero for fail. A non-zero exit overrides apassorwarnresult and the row becomes FAIL.
4. Scorecard: device-sim-scorecard/1
Section titled “4. Scorecard: device-sim-scorecard/1”scorecard.json: schema, generated_at, requested, out_dir, duration_s, columns (profile ids), summary (counts, line, clean, exit_code), rows, not_covered_offline (the footer list), and interrupted. Each row: tool, title, tier, profile, status, headline, caveat, metrics, artifacts, exit_code, signal, timed_out, lock_wait_s, duration_s, out_dir, result_path, log_path, notes, log_tail.
| Tier | What it means | How it runs | Default |
|---|---|---|---|
headless |
Pure logic with no window: node, python, godot --headless. |
Directly. | yes |
window |
Needs a real windowed Godot (GPU rendering, foreground). | The harness wraps the command in scripts/dev/godot-window-lock.sh, so one windowed run holds the machine at a time. |
yes |
simulator |
Runs in the iOS or tvOS Simulator on the host Mac. | Directly. | yes |
d1 |
Reads real-device telemetry from the D1 database, read-only. | Directly; declare network and wrangler in needs. |
yes |
device |
Needs a physical device on the desk. | Directly, and only with --tiers device. |
no |
A simulator is the Mac’s own hardware running the iOS or tvOS stack, so a simulator row speaks to the app shell and lifecycle, not to A12 frame time. The device tier is opt-in so a scorecard run can never surprise a live human A/B on a device (installing or launching an app on a device kills a run in progress).
Window-tier rules. The lock script is not re-entrant: a nested call blocks until its own timeout (default 1200 s, GODOT_WINDOW_LOCK_TIMEOUT) and exits 1. So a window entry must NOT call godot-window-lock itself, and the registry validator rejects one whose cmd mentions it. The validator cannot see inside a wrapper script, so a tool that takes the lock itself anyway gets a private lock dir (<out>/.nested-window-lock, via GODOT_WINDOW_LOCK in the environment of the tool only) instead of a deadlock: its nested call locks against itself, and the real lock, held by the harness, still keeps every other agent’s windowed run out. Lock-wait time is not charged to timeout_s (the clock starts when the tool actually starts). A lock that stays busy ends as INCONC, never as a timeout FAIL. A windowed Godot can rewrite project.godot and audio/bus_layout.tres: a window-tier tool restores them itself (git checkout -- project.godot audio/bus_layout.tres); the harness never touches the working tree.
Simulator hook. The sibling branch dfr-simulator-probe (scripts/dev/sim-run.sh) may later add its own registry entry with tier: simulator. This harness does not add one and does not touch its files. Its Simulator uses its own device names (the DC-Sim ‘iPhone 18 Pro’ and ‘iPad Pro 13 M5’ simulators), not the profile geometry, so that entry’s caveat must say so.
Reading the scorecard
Section titled “Reading the scorecard”The matrix has one row per tool and one column per profile. - means the tool does not cover that profile; SKIP means it did not run (a missing prerequisite or an unselected tier, with the reason in the rows below); INCONC means it ran but could not tell.
The summary line always counts all five statuses, for example 0 FAIL, 0 WARN, 3 PASS, 5 SKIP, 0 INCONC: not a clean bill. Only a run where every row passed says “every row that ran passed (offline evidence only; see NOT COVERED OFFLINE)”. A run where everything was skipped exits 0 and still says “not a clean bill”. A run with no rows at all is exit 2.
What the runner guarantees, so a green cell means something:
- A leftover
result.jsonfrom an earlier run in a reused--outdirectory is deleted before the tool starts, and the new one must be newer than the start time (scripts/ui-sweep.shhas the same rule: a sweep killed mid-run must fail, never pass on whatever PNGs survive). - A non-zero exit overrides
passorwarn. A missing, unreadable or invalid result is FAIL with the headlineno result written (exit N). - Each tool runs in its own process group. On timeout or Ctrl-C the whole group gets SIGTERM, then SIGKILL (macOS has no
timeout, and an orphaned windowed Godot stalls every later run). A tool that leaves processes behind is killed and apassis downgraded towarn. - The
caveatof every row that ran is printed after the matrix, grouped by tool.
NOT COVERED OFFLINE
Section titled “NOT COVERED OFFLINE”The footer is built from hardware_only in profiles.json. What each class needs instead:
- GPU frame time and fill rate on the A12. Only the Apple TV and an iPhone XS class phone show it. The nearest evidence is D1 telemetry from shipped builds (
samples) and a live A/B through the Remote Playtest Console (skillbh-console-live-ab). The Mac GPU is not an A12; see the error bars below. - Thermals and sustained load. Needs a long session on the device. No offline tool measures heat or throttling.
- Audio route and liveness. The device’s output route and a live sound path can only be heard on the device.
- Memory and jetsam. Device only; iOS builds send MetricKit payloads (
docs/architecture/dev-tools.md, Telemetry v2). - Real input feel. A human with the touch screen or controller.
- Display pacing. What the display actually delivers: the HDMI refresh on tvOS, ProMotion on iPhone and iPad. D1 launch events report the refresh a device ran at.
- First-launch stall on a fresh install. Uninstall, install, play, query hitches: skill
bh-fresh-install-stall-check.
How to add a tool
Section titled “How to add a tool”- Pick an id, a tier and a scope. Write the tool so it takes the profile and an output directory and writes
{out}/result.json(contract 3). Exit non-zero only for FAIL. Give it an honestcaveat. - Add
tools/device_sim/registry.d/<tool>.json(contract 2). Do not edit anyone else’s entry,gate.sh, orprofiles.jsonunless you are changing a device fact. - Write offline tests, named
tools/<dir>/tests/fid_*.test.mjsortools/<dir>/tests/fid_*.test.py. They need no Godot and no network and should take well under 10 seconds. The gate step “fidelity tool tests” finds tests by that glob. A test with any other name is silently outside the gate. Passnode --testfile globs, not the directory (node --test tools/device_sim/tests/fails on Node 22). - Check
scripts/dev/device-sim.sh all --listshows your tool with the right profiles and skip reasons, thenscripts/dev/device-sim.sh <profile> --only <tool>. - If your tool restates a device fact in its own constant, either read
profiles.jsoninstead, or add the constant to the drift lint.
The drift lint
Section titled “The drift lint”tools/device_sim/tests/fid_drift.test.mjs runs in the gate (step “fidelity tool tests”) and as the registered selfcheck tool, from tools/device_sim/drift.mjs. It reads the hand-copied device tables as text, with a small GDScript literal parser and no Godot, and compares them with profiles.json:
ShopLayoutFixture.DEVICESintests/shop_layout_fixture.gd:px,scale,insets_pt,touch.DEVICE(scale, insets,touchwhich defaults to true) andSIZESintools/ui_capture/capture.gd.SIZESinmarketing/capture/shoot.gd, for the profile ids it holds (itsclip,headerandsearchkeys are ignored;macthere is store art, not the offline-tool window).- The 1920x1080
SIZEintools/perf_probe/perf_probe.gd,tools/perf_probe/look_bench.gdandtools/ui_sweep/sweep.gd, againsttv. TOUCH_SCALEinui/tokens.gdagainsttouch_zoom;TV_TITLE_SAFEinui/platform.gdagainst thetvinsets; the stretch settings inproject.godotandplatform/tvos/project.godotagainstcanvas_base.
Every failure names the profile, the field and both files, for example: iphone: safe_insets_pt is [62, 0, 62, 99] in tools/ui_capture/capture.gd DEVICE.insets but [62, 0, 62, 21] in tools/device_profiles/profiles.json (agrees with tools/device_profiles/profiles.json: tests/shop_layout_fixture.gd). It cannot pass vacuously: a missing file, a constant that is not a plain literal, or a table that parses to zero entries all FAIL, unless a non-comment line in the file references tools/device_profiles/profiles.json (the file now delegates to the source). Per profile the selfcheck row is PASS when something pins its geometry and everything agrees, FAIL on any disagreement, and WARN for a profile no consumer pins (a12_phone, mac): its numbers are assumed, never verified.
Its tests. Only the DRIFT test in fid_drift.test.mjs (and the two checks beside it) reads the real files, so it is the one test that fails when a real table changes in one place, and the only one that needs to. Everything about the lint’s own behaviour (each kind of mismatch, the zero-entry rule, delegation, fail-closed parsing, the per-profile verdicts) is tested against a frozen miniature of the tables, tools/device_sim/tests/drift_fixture.mjs, so an unrelated edit of capture.gd or shoot.gd, or a re-pin done in every file at once, cannot fail a mutation test. If the lint learns to read a new constant, add an excerpt for it to that fixture too.
What the existing tools cover, and their error bars
Section titled “What the existing tools cover, and their error bars”Registered in the harness by this branch: selfcheck only, so on its own this branch catches constants drift (a changed insets or pixel-size table), not a layout or safe-area break. Layout regressions are caught by the GUT tests built on ShopLayoutFixture and by the rows other branches register later. The tools below already exist in the repo; each is listed with what it tells you about a real device and its known error bar, cited to the doc it comes from, or “unmeasured” where no one has measured it. Run each by its own command until its row is registered (--list shows what is).
| Tool | Tier it fits | What it tells you | Known error bar |
|---|---|---|---|
scripts/dev/device-sim.sh all --only selfcheck |
headless | The device tables in the repo agree with profiles.json. |
Not a measurement: it compares constants. WARN means the profile’s numbers are assumed. |
GUT tests built on ShopLayoutFixture (tests/shop_layout_fixture.gd, run by scripts/gate.sh) |
headless | UI panels fit the canvas and safe area at the iphone, ipad and tv geometry (canvas size and inset maths). No iphone_se, a12_phone or mac. | Geometry only. Glyph metrics against the device: unmeasured. |
tools/ui_capture/capture.gd |
window | Screenshots of the HUD, pause, shop and more at the iphone, iphone_se, ipad and tv pixel sizes, with touch UI and insets forced. | Rendered on the Mac. No pixel comparison against a real device capture exists: unmeasured. A baked iPhone install can fail to draw while a Mac capture looks fine (CLAUDE.md, Running and testing, godot#124088). |
scripts/ui-sweep.sh (zsh) |
window | TV-size screenshots against blessed baselines. | Diff threshold RMSE 0.06. hud_in_run.png differed from itself by about 0.09 across two runs of identical code on 2026-08-19 (docs/reference/running-and-testing.md): re-run once to gauge a capture’s own variance. |
tools/perf_probe/perf_probe.gd |
window | Draw-call counts, and the delta when each top-level node is hidden, at 1920x1080 on the Mac. | Draws swung 15..97 across identical launches (header of the file); its interleaved --ab cancels the scene variance. Draw counts are not A12 GPU time: unmeasured. |
tools/perf_probe/look_bench.gd |
window (MoltenVK) | GPU ms times seconds per weapon-look shot on the Mac. | Per-shot numbers at K=32 swung +-30% (the Mac GPU down-clocks under light load); K=64 settles them. Read relative costs, never absolute ms as ATV ms (docs/architecture/rendering-and-performance.md). The Metal driver reads 0 GPU time. |
tools/sim_bench/sim_bench.gd |
headless | Sim cost in microseconds per tick, with a state checksum. | Around +-8% of microseconds per tick run to run on an M5 (docs/reference/running-and-testing.md). Scale from the Mac to the A12: unmeasured. |
scripts/soak.sh, tools/story_auto/story_trace.gd, scripts/feel-probe.sh |
headless | Sim behaviour over seeded autopilot runs. Device-independent by construction (constant tick, seeded randomness), so it says nothing about rendering, pacing, heat or input. | Not a device measurement. feel-probe is a pre-screen that never replaces a human verdict (its header). |
tools/shader_check/compile_check.gd |
window, run under both renderers | Shader compile errors on the Mac. The headless gate never compiles shaders. | A clean compile does not clear a baked device install (godot#124088, CLAUDE.md). Unmeasured on device. |
marketing/capture/shoot.gd |
window | Raw store art at each device’s exact pixel size. | It makes art, not evidence. Its SIZES table is covered by the drift lint. |
scripts/dev/verify-launch.sh <atv|iphone|ipad> |
device | Launches the installed Dev build, watches for a new crash log, requires the process alive. | “Alive” is not “plays”: frame rate is not checked. |
Remote Playtest Console (dark-cosmos-console MCP, tools/console_mcp/) |
device | Live commands, /caps and /status of a running device. |
/status lags 10-20 s behind commands (CLAUDE.md, 2026-10-10 A/B): confirm on the device’s on-screen change note. |
D1 telemetry (samples and launch events; skill bh-telemetry) |
d1 | The only frame-time and display evidence from real devices, for builds someone has run. | samples.sim_ms is the worst physics step in a one-second window, spiky by construction; compare builds on tick_ms. ATV sim_ms swung about 1.5 ms between sessions of the same build (docs/architecture/rendering-and-performance.md). |
Not profiled: Android and the web build both have launch data in D1 (see below) but no profile here, and no fidelity tool targets them.
D1 evidence behind the profiles (snapshot 2026-10-22)
Section titled “D1 evidence behind the profiles (snapshot 2026-10-22)”The profiles are device classes; D1 tells you what shipped devices actually reported. Both queries are read-only (SELECT). Run them from telemetry/ with npx wrangler d1 execute bullet-heaven-telemetry --remote --json --command "<sql>" (see docs/architecture/dev-tools.md, Telemetry v2, and skill bh-telemetry); the numbers below are a snapshot and will go stale, so re-run before relying on them.
-- What each device model reported at launch (events written by net/lifecycle_telemetry.gd display_payload)SELECT dev, json_extract(data,'$.win_w') AS w, json_extract(data,'$.win_h') AS h, json_extract(data,'$.screen_scale') AS sc, json_extract(data,'$.refresh_hz') AS hz, json_extract(data,'$.canvas_w') AS cw, json_extract(data,'$.canvas_h') AS ch, COUNT(*) AS n, MIN(build) AS b0, MAX(build) AS b1FROM events WHERE type='launch' AND json_extract(data,'$.win_w') IS NOT NULLGROUP BY 1,2,3,4,5,6,7 ORDER BY dev, n DESC;
-- Frame samples per lab device modelSELECT dev, COUNT(*) AS n, MIN(build) AS b0, MAX(build) AS b1, ROUND(AVG(fps),1) AS avg_fps, MAX(fps) AS max_fpsFROM samplesWHERE dev IN ('iOS/iPhone11,2','iOS/iPhone14,6','iOS/iPhone16,2','iOS/iPad13,8','tvOS/AppleTV11,1','macOS/Mac17,2')GROUP BY dev ORDER BY dev;What the 2026-10-22 snapshot showed, per profile:
| Profile | Lab device in D1 | Launch events reported | Frame samples |
|---|---|---|---|
tv |
tvOS/AppleTV11,1 |
1920x1080 at scale 1 on every launch but one 3840x2160 launch on build 211. 60 Hz on every launch from build 234 on (first 2026-10-01), 50 Hz on builds 211 to 233. | Large (about 136k rows), average 49.1 fps, max 61. |
iphone |
iOS/iPhone16,2 |
2796x1290 at scale 3 (canvas 1200x553), 120 Hz on nearly every launch, 60 Hz on one (build 316). | About 8.1k rows, average 91.1 fps, max 121. |
iphone_se |
iOS/iPhone14,6 |
No launch display payload. | 32 old rows (builds 194 to 196). Nothing measured here. |
ipad |
iOS/iPad13,8 |
2732x2048 at scale 2, 120 Hz (canvas 984x737). | About 11.6k rows, average 92.4 fps, max 121. |
a12_phone |
iOS/iPhone11,2 |
2436x1125 at scale 3, 60 Hz, canvas 1199x553 (2 launches). | 2,042 rows (builds 129 to 255), average 57.7 fps, max 61. |
mac |
macOS/Mac17,2 |
Mostly 1280x720 at scale 2, 120 Hz; a few resized windows. | About 28.7k rows, average 115.2 fps, max 146. |
Facts that follow, with their confidence:
- 60 Hz on the Apple TV is the lab TV’s HDMI setting, not an app pin [Likely]. Launches from builds 211 to 233 reported 50 Hz and every launch from build 234 on reported 60 Hz;
docs/RELEASE-1.0.1.mdrecords the lab TV being switched to 60 Hz on 2026-10-01, and the only refresh-rate request in the fork (the HDR display criteria, 2026-10-08) passes the screen’s own maximum. So a store player can run 50 Hz. The 1920x1080 drawable is pinned by the custom engine fork (godot-tvoscommitffb6b6ffe5, “tvos: render Metal drawable at 1080p, compositor upscales to 4K”, 2026-09-29), which predates build 234; the refresh rate is the part that was never pinned. - The profile geometry is not the lab device’s geometry [Certain].
iphone(2868x1320, canvas 1203x554) and the lab iPhone16,2 (2796x1290, canvas 1200x553) are 0.3% apart in canvas; the lab left and right insets are probably 59 pt against the profile’s 62 [Likely, unmeasured]. The ipad profile (2752x2064) and the lab iPad13,8 (2732x2048) give the same canvas of about 984x737. a12_phoneshares the Apple TV’s SoC at 2.74 MP against the TV’s 2.07 MP, about 1.32x the pixels [Certain, plain arithmetic]. On 2026-09-29 the same code held 60 fps on the iPhone XS where the Apple TV did not (docs/research/2026-09-29-general-performance-review.md, section 2.3, written before the ATV drawable was known or pinned). Its landscape insets[44,0,44,21]are the iPhone X family notch and are assumed, not sourced.- Android and web have launch rows but no profile. The snapshot holds two Android phones (a Pixel 4a at 60 Hz and a Pixel 7 at 45 Hz) and four web launches. They are not enough to define a class.
- 120 Hz is not a pin on any device. One iPhone16,2 launch reported 60 Hz (cause unknown; Low Power Mode is a guess).
tools/device_profiles/profiles.json: the device profiles andhardware_only.tools/device_sim/device_sim.mjs: the CLI.lib.mjs: contracts, validation, placeholder expansion, planning, scorecard rendering.runner.mjsandlocked_start.mjs: the process runner (own process group, window-lock wrapping with lock-wait excluded from the timeout).drift.mjs: the drift lint.selfcheck.mjs: the registeredselfchecktool.tools/device_sim/registry.d/: one JSON file per tool.tools/device_sim/tests/fid_*.test.mjs: the harness tests (registry validation, placeholders, plan and scorecard rendering and exit codes with fixture tools, the runner against fake and real lock scripts, the drift lint against a frozen fixture of the tables plus the live check on the real files, the CLI, the gate step).tools/device_sim/tests/drift_fixture.mjsis that fixture, not a test.scripts/dev/device-sim.sh: the entry point.scripts/gate.sh: the step “fidelity tool tests”, which runs everytools/*/tests/fid_*.test.mjswithnode --testand everytools/*/tests/fid_*.test.pywithpython3(60 s alarm each), and fails if the drift lint is missing or no fidelity test exists at all.