Skip to content

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).

scripts/dev/device-sim.sh all --list # the plan: tool x profile, every skip and why; runs nothing
scripts/dev/device-sim.sh all # every registered tool on every profile it covers
scripts/dev/device-sim.sh iphone # one profile
scripts/dev/device-sim.sh all --only selfcheck # just the constants-drift check
scripts/dev/device-sim.sh tv --tiers headless,device # opt in to the device tier (needs a physical device)
  • <profile|all>: a profile id from tools/device_profiles/profiles.json, or all.
  • --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. Default headless,window,simulator,d1. The device tier is opt-in (--tiers device, or --tiers headless,device to add it to the others).
  • --offline: skip tools that declare network in needs (they show as SKIP).
  • Exit 0: no row failed (read the summary line: a skip, warn or inconclusive row is not a clean bill). Exit 1: at least one row failed. Exit 2: 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.

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, tablet or desktop).
  • win_px [w,h], screen_scale (device pixels per point), hz, touch.
  • canvas_base [1280,720] and touch_zoom (1.3 for touch profiles, from Tokens.TOUCH_SCALE). The UI lays out in a canvas of win_px / (min(w/1280, h/720) * touch_zoom), the same maths as ShopLayoutFixture.profile and tools/ui_capture/capture.gd.
  • safe_insets_pt [left,top,right,bottom] in points. For tv these 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.notes says where each came from (D1 launch events, or a guess). a12_phone and mac have no source at all for several fields and say so.
  • lab: d1_device (the exact D1 dev string of the lab device) and notes, which must say what the profile is NOT. A profile is a device class, not the lab device: iphone is the 6.9in 2868x1320 class while the lab iPhone16,2 reports 2796x1290; ipad is 2752x2064 while the lab iPad13,8 reports 2732x2048; mac is 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, d1 or device (see Tiers).
  • scope: profile (one job per covered profile) or global (one job, profile is global).
  • profiles: for scope: profile, the ids it covers, or ["*"]. For global, omit it or leave it empty to apply to every request, or list the ids it applies to: the tool still runs once (as global), but only for all or one of those profiles, and --list shows the list.
  • needs: any of godot, network, wrangler, macos-template. An unmet need turns the job into a SKIP that says why; it never turns into a pass. godot is godot on PATH; wrangler is wrangler or npx on PATH; network is only unmet under --offline (nothing probes the network, a tool that cannot reach it must say so itself); macos-template is the Godot macOS export template plus the gitignored export_presets.cfg in 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. A cmd[0] that is a path (contains /) must exist, and when cmd[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, skip or inconclusive. Use skip when the check does not apply here, inconclusive when it ran but could not tell, warn when 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 unless status is skip. 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}.
  • tool and profile must match what the registry ran (profile is global for a global tool). A result for another tool or profile is invalid.
  • Node tools can call writeResult(outDir, fields) from tools/device_sim/lib.mjs, which validates and writes the file. Tools in GDScript or Python write the JSON themselves.
  • Exit code: 0 for pass, warn, skip and inconclusive; non-zero for fail. A non-zero exit overrides a pass or warn result and the row becomes FAIL.

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.

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.json from an earlier run in a reused --out directory is deleted before the tool starts, and the new one must be newer than the start time (scripts/ui-sweep.sh has the same rule: a sweep killed mid-run must fail, never pass on whatever PNGs survive).
  • A non-zero exit overrides pass or warn. A missing, unreadable or invalid result is FAIL with the headline no 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 a pass is downgraded to warn.
  • The caveat of every row that ran is printed after the matrix, grouped by tool.

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 (skill bh-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.
  1. 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 honest caveat.
  2. Add tools/device_sim/registry.d/<tool>.json (contract 2). Do not edit anyone else’s entry, gate.sh, or profiles.json unless you are changing a device fact.
  3. Write offline tests, named tools/<dir>/tests/fid_*.test.mjs or tools/<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. Pass node --test file globs, not the directory (node --test tools/device_sim/tests/ fails on Node 22).
  4. Check scripts/dev/device-sim.sh all --list shows your tool with the right profiles and skip reasons, then scripts/dev/device-sim.sh <profile> --only <tool>.
  5. If your tool restates a device fact in its own constant, either read profiles.json instead, or add the constant to 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.DEVICES in tests/shop_layout_fixture.gd: px, scale, insets_pt, touch.
  • DEVICE (scale, insets, touch which defaults to true) and SIZES in tools/ui_capture/capture.gd.
  • SIZES in marketing/capture/shoot.gd, for the profile ids it holds (its clip, header and search keys are ignored; mac there is store art, not the offline-tool window).
  • The 1920x1080 SIZE in tools/perf_probe/perf_probe.gd, tools/perf_probe/look_bench.gd and tools/ui_sweep/sweep.gd, against tv.
  • TOUCH_SCALE in ui/tokens.gd against touch_zoom; TV_TITLE_SAFE in ui/platform.gd against the tv insets; the stretch settings in project.godot and platform/tvos/project.godot against canvas_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 b1
FROM events WHERE type='launch' AND json_extract(data,'$.win_w') IS NOT NULL
GROUP BY 1,2,3,4,5,6,7 ORDER BY dev, n DESC;
-- Frame samples per lab device model
SELECT dev, COUNT(*) AS n, MIN(build) AS b0, MAX(build) AS b1, ROUND(AVG(fps),1) AS avg_fps, MAX(fps) AS max_fps
FROM samples
WHERE 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.md records 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-tvos commit ffb6b6ffe5, “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_phone shares 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 and hardware_only.
  • tools/device_sim/device_sim.mjs: the CLI. lib.mjs: contracts, validation, placeholder expansion, planning, scorecard rendering. runner.mjs and locked_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 registered selfcheck tool.
  • 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.mjs is that fixture, not a test.
  • scripts/dev/device-sim.sh: the entry point. scripts/gate.sh: the step “fidelity tool tests”, which runs every tools/*/tests/fid_*.test.mjs with node --test and every tools/*/tests/fid_*.test.py with python3 (60 s alarm each), and fails if the drift lint is missing or no fidelity test exists at all.