Organize Iris support files

This commit is contained in:
iris committed 2026-09-11 13:06:52 -04:00
1 parent 40e7259d99
commit ea2112c552
11 files changed
+94 -56

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
# The compositor `scripts/run-headless.sh` starts, because this machine has no
# display. Nothing here is meant to be looked at directly; `grim` is.
#
# No Xwayland: winit talks Wayland natively, and starting an X server is a
# second thing to go wrong for no gain. (`emu`'s config forces it because the
# Android emulator's renderer speaks GLX.)
xwayland disable
# A desktop-shaped output, since this is the desktop half of the port. Larger
# than the window an example opens, so nothing is scaled or clipped.
output HEADLESS-1 mode 1920x1200@60Hz
default_border none
focus_follows_mouse no
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""AOSP's fling spline, transcribed independently of the Rust port.
This exists so the numbers in `sense.rs`'s `the_spline_matches_aosps_own_table`
and `a_flick_decelerates_the_way_aosp_says_it_does` are not the Rust code
grading its own homework. Every test iris's fling had before 2026-09-07
compared the curve with itself -- monotonic, signed, integrates to the closed
form -- and all of them passed while `distance_fraction(t)` was returning
exactly `t` (see `android_fling_spline`'s doc comment). Numbers checked into a
test have to come from somewhere else, and this is the somewhere else.
Transcribed by hand from, and only from:
* frameworks/base `core/java/android/widget/OverScroller.java`,
`SplineOverScroller`'s static initialiser, `getSplineDeceleration`,
`getSplineFlingDistance`, `getSplineFlingDuration` and `update`.
* androidx.compose.animation:animation:1.12.0 `SplineBasedDecay.kt`
(`computeSplineInfo`, `AndroidFlingSpline.flingPosition`) and
`FlingCalculator.kt` (`computeDeceleration`, `flingDistance`,
`flingDuration`, `FlingInfo.position`/`velocity`). The two agree line for
line, which is why iris ports one curve rather than two.
Run it with no arguments; it prints the table entries and the (velocity,
density, t) points the Rust tests assert on.
"""
NB_SAMPLES = 100
INFLEXION = 0.35
START_TENSION = 0.5
END_TENSION = 1.0
P1 = START_TENSION * INFLEXION
P2 = 1.0 - END_TENSION * (1.0 - INFLEXION)
SCROLL_FRICTION = 0.015
TUNING = 0.84
GRAVITY_EARTH = 9.80665
INCHES_PER_METER = 39.37
import math
DECELERATION_RATE = math.log(0.78) / math.log(0.9)
def spline_positions():
"""SPLINE_POSITION: distance fraction at each of 101 even time steps."""
position = [0.0] * (NB_SAMPLES + 1)
x_min = 0.0
for i in range(NB_SAMPLES):
alpha = i / NB_SAMPLES
x_max = 1.0
while True:
x = x_min + (x_max - x_min) / 2.0
coef = 3.0 * x * (1.0 - x)
tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x
if abs(tx - alpha) < 1e-5:
break
if tx > alpha:
x_max = x
else:
x_min = x
position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x
position[NB_SAMPLES] = 1.0
return position
POSITION = spline_positions()
def fling_sample(t):
"""(distance fraction, velocity fraction) at time fraction `t`."""
t = min(max(t, 0.0), 1.0)
index = int(t * NB_SAMPLES)
if index >= NB_SAMPLES:
return 1.0, 0.0
t_inf = index / NB_SAMPLES
t_sup = (index + 1) / NB_SAMPLES
velocity_coef = (POSITION[index + 1] - POSITION[index]) / (t_sup - t_inf)
return POSITION[index] + (t - t_inf) * velocity_coef, velocity_coef
def physical_coefficient(density):
return GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * TUNING
def deceleration(velocity, density):
return math.log(
INFLEXION * abs(velocity) / (SCROLL_FRICTION * physical_coefficient(density))
)
def fling_distance(velocity, density):
l = deceleration(velocity, density)
return (
SCROLL_FRICTION
* physical_coefficient(density)
* math.exp(DECELERATION_RATE / (DECELERATION_RATE - 1.0) * l)
)
def fling_duration_s(velocity, density):
l = deceleration(velocity, density)
return math.exp(l / (DECELERATION_RATE - 1.0))
def position_at(velocity, density, t_seconds):
d = fling_duration_s(velocity, density)
return fling_distance(velocity, density) * fling_sample(t_seconds / d)[0]
def velocity_at(velocity, density, t_seconds):
d = fling_duration_s(velocity, density)
return fling_sample(t_seconds / d)[1] * fling_distance(velocity, density) / d
if __name__ == "__main__":
print("SPLINE_POSITION at a few indices (index: value)")
for i in (0, 1, 10, 25, 50, 75, 99, 100):
print(f" {i:3}: {POSITION[i]:.6f}")
print()
print("distance/velocity fraction at time fractions")
for t in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0):
d, v = fling_sample(t)
print(f" t={t:<5} distance={d:.6f} velocity={v:.6f}")
print()
# 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md);
# 2.75 is this checkout's emulator.
for density in (2.55, 2.75):
# 15250 is `app/touch/flick-120hz.touch`'s own
# release velocity (velocity_reference.py), so `phone_screen.rs`
# can bound the fling it produces from *here* rather than from the
# `FlingCalculator` under test (docs/REVIEW-2026-09-07.md's T1).
for velocity in (5000.0, 11064.0, 15250.0):
dur = fling_duration_s(velocity, density)
print(
f"density={density} v={velocity}: "
f"distance={fling_distance(velocity, density):.3f}px "
f"duration={dur:.4f}s"
)
# Deliberately not round fractions. The velocity coefficient is
# piecewise *constant* across each of the 100 samples, so it
# steps at t = k/100 and a test asserting on 0.75 is asserting
# on which side of a discontinuity the last float landed --
# which is genuinely different between Python and Rust and says
# nothing about the curve.
for frac in (0.125, 0.335, 0.505, 0.755):
t = frac * dur
print(
f" t={frac:>4} of duration ({t:.4f}s): "
f"pos={position_at(velocity, density, t):.3f}px "
f"vel={velocity_at(velocity, density, t):.3f}px/s"
)
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env python3
"""Compose's touch velocity tracker, transcribed independently of the Rust port.
Same reason `fling_spline_reference.py` exists: the numbers checked into
`sense.rs`'s velocity tests must not be numbers the Rust produced. The old
estimator -- total motion over the sample span, an average -- passed every test
it had, because every one of those tests asserted the average's own definition
back at it. An average cannot tell an accelerating flick from a steady drag, and
that is exactly what Iris reported from the phone on 2026-09-07: "flinging now
actually works but is slower than Compose's immediately after releasing the
flick".
Transcribed by hand from, and only from, the `-sources.jar` of
**androidx.compose.ui:ui-android:1.12.0** and
**androidx.compose.foundation:foundation-android:1.12.0**
(dl.google.com/dl/android/maven2), read 2026-09-07:
* `androidx/compose/ui/input/pointer/util/VelocityTracker.kt` --
`VelocityTracker1D.calculateVelocity`, `polyFitLeastSquares`,
`calculateImpulseVelocity`, `kineticEnergyToVelocity`, and the constants
`HistorySize = 20`, `HorizonMilliseconds = 100`,
`AssumePointerMoveStoppedMilliseconds = 40`.
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.kt` --
`Lsq2VelocityTracker`, which is what the 2D `VelocityTracker` delegates to.
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.android.kt`
-- the `AndroidComposeUiFlags.isFrameworkVelocityTrackerEnabled` fork.
* `androidx/compose/ui/AndroidComposeUiFlags.android.kt` -- that flag's
default, which is `false`.
* `androidx/compose/foundation/gestures/Draggable.kt` -- `sendDragStart` /
`sendDragEvent` / `sendDragStopped`, i.e. *which* samples a touch drag
feeds the tracker and where the maximum-velocity clamp is applied.
* `androidx/compose/foundation/gestures/DifferentialVelocityTracker.kt` and
`NonTouchScrollingLogic.kt` -- the Impulse strategy's only caller.
* `androidx/compose/foundation/gestures/Scrollable.kt` --
`DefaultFlingBehavior.performFling`, for the minimum-velocity question.
**Which strategy a touch fling actually uses, since this was the surprise.**
`Strategy.Impulse` is *not* it. `scrollable`/`draggable` release through
`DragGestureNode.sendDragStopped`, which calls the 2D `VelocityTracker`; on
Android that is `Lsq2VelocityTracker` (the framework-tracker flag defaults to
false), which is two `VelocityTracker1D(strategy = Lsq2)` -- a degree-2
least-squares fit over **absolute positions**, whose velocity is the fitted
polynomial's derivative at the newest sample. Impulse is reached only through
`DifferentialVelocityTracker`, whose sole caller is `NonTouchScrollingLogic`:
mouse wheel and trackpad, never a finger. So this script transcribes Lsq2 and
iris ports Lsq2. `calculate_impulse_velocity` is here anyway, unused by the
printed points, because ruling it out by reading is cheaper than ruling it out
again next time somebody remembers "Compose uses impulse".
**Which samples a touch drag feeds it.** `sendDragStart` adds the DOWN change;
every subsequent MOVE, historical samples included, is added by `sendDragEvent`.
The **UP position is never added**: `Lsq2VelocityTracker.addPointerInputChange`
wraps its two `addPosition` calls in `if (!event.changedToUpIgnoreConsumed())`,
and all the UP branch does is reset the tracker when more than 40ms have passed
since the last MOVE (b/238654963). So a finger that stops before lifting reads
as a stop, not as a decelerating tail. Positions are the raw event positions,
so the touch slop is inside the motion the tracker sees even though the list
never scrolled by it.
Two of Compose's samples iris does *not* reproduce, both noted rather than
copied: pre-slop MOVEs (iris's `DragArbiter` is `Undecided` then too, so it
feeds none either -- these agree), and the single MOVE that *crosses* the slop,
which Compose drops because `sendDragStart` adds only the DOWN. iris feeds that
one, since it is a real measured position and dropping it would be copying a
quirk of where Compose happens to split its state machine.
**The clamps.** Maximum: `sendDragStopped` passes
`LocalViewConfiguration.maximumFlingVelocity`, which on Android is
`ViewConfiguration.getScaledMaximumFlingVelocity()` -- 8000 dp/s. Minimum:
there is **none** on this path. `ViewConfiguration.minimumFlingVelocity`
exists in Compose's `ViewConfiguration` interface but its only use in either
artifact is `NestedScrollInteropConnection`, for View interop.
`DefaultFlingBehavior.performFling` guards with `abs(initialVelocity) > 1f`
and says why in its own comment: "we need it since spline curve gives us
NaNs". 1 px/s, not 50 dp/s.
Run it with no arguments; it prints the sample sets and the velocities the
Rust tests assert on.
"""
import math
HISTORY_SIZE = 20
HORIZON_MILLISECONDS = 100.0
ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS = 40.0
MIN_SAMPLE_SIZE_LSQ2 = 3
MAXIMUM_FLING_VELOCITY_DP_S = 8000.0
# DefaultFlingBehavior.performFling's own threshold, in the units of the
# positions fed to the tracker -- pixels per second here.
FLING_MINIMUM_PX_S = 1.0
def poly_fit_least_squares(x, y, sample_count, degree):
"""`polyFitLeastSquares`: Gram-Schmidt QR, coefficients low order first."""
if degree < 1:
raise ValueError("The degree must be at positive integer")
if sample_count == 0:
raise ValueError("At least one point must be provided")
truncated_degree = sample_count - 1 if degree >= sample_count else degree
m = sample_count
n = truncated_degree + 1
a = [[0.0] * m for _ in range(n)]
for h in range(m):
a[0][h] = 1.0
for i in range(1, n):
a[i][h] = a[i - 1][h] * x[h]
q = [[0.0] * m for _ in range(n)]
r = [[0.0] * n for _ in range(n)]
for j in range(n):
w = q[j]
w[:] = a[j][:m]
for i in range(j):
z = q[i]
dot = sum(w[h] * z[h] for h in range(m))
for h in range(m):
w[h] -= dot * z[h]
norm = math.sqrt(sum(v * v for v in w))
inverse_norm = 1.0 / max(norm, 1e-6)
for h in range(m):
w[h] *= inverse_norm
for i in range(n):
r[j][i] = 0.0 if i < j else sum(w[h] * a[i][h] for h in range(m))
coefficients = [0.0] * n
for i in range(n - 1, -1, -1):
c = sum(q[i][h] * y[h] for h in range(m))
for j in range(n - 1, i, -1):
c -= r[i][j] * coefficients[j]
coefficients[i] = c / r[i][i]
return coefficients
def kinetic_energy_to_velocity(kinetic_energy):
sign = 0.0 if kinetic_energy == 0.0 else math.copysign(1.0, kinetic_energy)
return sign * math.sqrt(2 * abs(kinetic_energy))
def calculate_impulse_velocity(data_points, time, sample_count, is_data_differential):
"""`calculateImpulseVelocity` -- not on the touch path; see the module doc."""
work = 0.0
start = sample_count - 1
next_time = time[start]
for i in range(start, 0, -1):
current_time = next_time
next_time = time[i - 1]
if current_time == next_time:
continue
if is_data_differential:
delta = -data_points[i - 1]
else:
delta = data_points[i] - data_points[i - 1]
v_curr = delta / (current_time - next_time)
v_prev = kinetic_energy_to_velocity(work)
work += (v_curr - v_prev) * abs(v_curr)
if i == start:
work = work * 0.5
return kinetic_energy_to_velocity(work)
def calculate_velocity(samples):
"""`VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`.
`samples` is `(time_millis, position)` oldest first, at most the last
`HISTORY_SIZE` of which the circular buffer would still be holding.
Returns units per second.
"""
held = samples[-HISTORY_SIZE:]
if not held:
return 0.0
data_points = []
time = []
newest_time, _ = held[-1]
previous_time = newest_time
for sample_time, sample_position in reversed(held):
age = float(newest_time - sample_time)
delta = abs(float(sample_time - previous_time))
# Lsq2 walks back sample to sample; only the non-differential
# Impulse branch compares every sample against the newest one.
previous_time = sample_time
if age > HORIZON_MILLISECONDS or delta > ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS:
break
data_points.append(sample_position)
time.append(-age)
if len(data_points) == HISTORY_SIZE:
break
if len(data_points) < MIN_SAMPLE_SIZE_LSQ2:
return 0.0
try:
coefficients = poly_fit_least_squares(time, data_points, len(data_points), 2)
except ValueError:
return 0.0
# The 2nd coefficient is the fitted polynomial's derivative at x = 0,
# which is the newest sample's timestamp. units/ms -> units/s.
return coefficients[1] * 1000.0
def clamped(velocity, maximum):
"""`VelocityTracker1D.calculateVelocity(maximumVelocity)`."""
if velocity == 0.0 or math.isnan(velocity):
return 0.0
return min(velocity, maximum) if velocity > 0 else max(velocity, -maximum)
def average(samples):
"""The estimator being replaced: total motion over the span."""
if len(samples) < 2:
return 0.0
span = (samples[-1][0] - samples[0][0]) / 1000.0
if span <= 0.0:
return 0.0
return (samples[-1][1] - samples[0][1]) / span
# 1. `app/touch/flick-120hz.touch`, as `DragGesture` feeds it:
# the DOWN position, then one position per MOVE. The UP at t=20 adds no
# sample (see the module doc), which is why the finger sitting still for its
# last 4ms does not drag the estimate down. y only; the flick is vertical.
FLICK_120HZ = [(0, 1000.0), (4, 1040.0), (8, 1086.0), (12, 1138.0), (16, 1196.0)]
# 2. A steady drag: 5px every 10ms for 100ms. A constant-velocity fit and an
# average must agree here -- this is the case that cannot tell the two
# estimators apart, which is why it is not the only one.
STEADY_DRAG = [(i * 10, float(i * 5)) for i in range(11)]
# 3. A flick that accelerates into the release: 10ms apart, deltas doubling.
# This is the case the average gets wrong, and the negative control for
# the port -- reverting to the average must fail this test and only this
# kind of test.
ACCELERATING_FLICK = [(0, 0.0), (10, 2.0), (20, 6.0), (30, 14.0), (40, 30.0), (50, 54.0)]
# 4. The two edges of the sample walk, checked here so the Rust asserts
# Compose's answer rather than iris's own reading of the rule.
# (a) An old, fast burst outside the 100ms horizon, then a slow steady
# drag: the burst must not leak into the estimate.
OLD_BURST_THEN_STEADY = [(0, 0.0)] + [(10 + i * 10, 1000.0 + i) for i in range(11)]
STOPPED_BEFORE_RELEASE = [(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)]
TWO_MOVE_FRAMES = [(0, 0.0), (8, 100.0), (16, 220.0)]
# ... and one move frame, which Compose cannot fit either.
ONE_MOVE_FRAME = [(0, 0.0), (8, 100.0)]
PHONE_DENSITY = 2.55
def report(name, samples):
v = calculate_velocity(samples)
print(f"{name}:")
print(f" samples (t_ms, position): {samples}")
print(f" Lsq2 (Compose's touch path): {v:.4f} px/s")
print(f" average (the old estimator): {average(samples):.4f} px/s")
print(f" impulse (non-touch, for ref): ", end="")
held = list(reversed(samples[-HISTORY_SIZE:]))
newest = held[0][0]
print(
f"{calculate_impulse_velocity([p for _, p in held], [-(newest - t) for t, _ in held], len(held), False) * 1000.0:.4f} px/s"
)
print()
if __name__ == "__main__":
print("Compose 1.12.0 touch velocity: VelocityTracker1D, Strategy.Lsq2,")
print("non-differential (positions), HistorySize=20, Horizon=100ms,")
print("AssumePointerMoveStopped=40ms, minSampleSize=3.\n")
report("flick-120hz.touch", FLICK_120HZ)
report("steady drag (5px/10ms)", STEADY_DRAG)
report("accelerating flick (deltas 2,4,8,16,24 per 10ms)", ACCELERATING_FLICK)
report("old burst then steady 1px/10ms", OLD_BURST_THEN_STEADY)
report("stopped 48ms before release", STOPPED_BEFORE_RELEASE)
report("press and two move frames", TWO_MOVE_FRAMES)
report("press and one move frame", ONE_MOVE_FRAME)
print("Clamps:")
print(f" maximum: {MAXIMUM_FLING_VELOCITY_DP_S} dp/s")
print(
f" = {MAXIMUM_FLING_VELOCITY_DP_S * PHONE_DENSITY:.1f} px/s at the phone's density {PHONE_DENSITY}"
)
print(f" minimum: none on the fling path; DefaultFlingBehavior skips |v| <= {FLING_MINIMUM_PX_S} px/s")
print()
print("Two samples only (a press and one move, the phone's 120Hz worst case):")
print(f" Lsq2 needs 3 and answers {calculate_velocity(FLICK_120HZ[:2]):.4f} px/s")
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Turns `iris::input` debug lines -- from a phone's diagnostics report, or
from a report the layer-1 harness produced with tracing on
(`iris::diagnostics::set_trace(true)`) -- back into a `TouchScript` file
`iris::harness::Harness::replay` can play back at layer 1.
Why this exists: `docs/RUST.md`'s "Three test layers" box says the cheapest
layer that can answer a question wins, and a gesture that misbehaves on
Iris's phone is otherwise only describable in words. `iris::sense::
log_input_event`'s one line per platform event (Android's on_touch_event
once per `MotionEvent`, with historical samples inline; winit's once per
pointer `WindowEvent`; the harness's `touch`, once per script line) already
carries everything a `.touch` file's `t_ms action x y` needs -- this just
reads it back out and reconstructs the samples in order, expanding each
event's inline historical samples into their own `move` lines first (they
are always intermediate positions of a move, and Android documents them as
oldest first, which is also the order they appear in the line).
Usage:
report_to_touch.py < report.txt > replay.touch
report_to_touch.py report.txt > replay.touch
Only lines containing "iris input: action=..." are read; everything else in
the report (insets, frame timings, drag-release summaries) is ignored, so
this can be pointed at Copy report's whole clipboard text directly.
"""
import re
import sys
# The message half of `sense::log_input_event`'s format string, prefix-
# agnostic: a real report line also carries the ring's own
# `HH:MM:SS.mmm LEVEL target:` header (`LogLine::format`) or, forwarded
# through `ai_server::client_log`, a `[<source> <clock> #<seq>]` tag ahead
# of that -- neither of which this needs to understand, since `search`
# (not `match`) finds the marker wherever it starts.
LINE_RE = re.compile(
r"iris input: action=(?P<action>\w+) x=(?P<x>-?[0-9.]+) y=(?P<y>-?[0-9.]+) "
r"t=(?P<t>[0-9]+)ms history=(?P<hist>[0-9]+)(?P<rest>.*)$"
)
HIST_RE = re.compile(r"(?P<t>[0-9]+):(?P<x>-?[0-9.]+),(?P<y>-?[0-9.]+)")
def _fmt(value: float) -> str:
"""The number as `TouchScript::parse`'s own `f32::parse` would round-trip
it -- an integer without a trailing `.0` where the source was one
(every coordinate here is a physical pixel), `{:g}` otherwise so a
fractional value from a real device is not silently truncated."""
if value == int(value):
return str(int(value))
return f"{value:g}"
def convert(lines):
"""Every `iris::input` line, oldest first, expanded to one `(t_ms,
action, x, y)` tuple per touch sample -- a historical sample is always
an intermediate `move`, and the event's own sample keeps its real
action (`down`/`move`/`up`/`cancel`)."""
rows = []
for line in lines:
m = LINE_RE.search(line)
if not m:
continue
hist_count = int(m.group("hist"))
hist_matches = list(HIST_RE.finditer(m.group("rest")))
if len(hist_matches) != hist_count:
print(
f"report_to_touch: {line.strip()!r} says history={hist_count} but "
f"holds {len(hist_matches)} samples -- skipped",
file=sys.stderr,
)
continue
for hm in hist_matches:
rows.append(
(int(hm.group("t")), "move", float(hm.group("x")), float(hm.group("y")))
)
rows.append(
(int(m.group("t")), m.group("action"), float(m.group("x")), float(m.group("y")))
)
return rows
def main():
if len(sys.argv) > 2:
print("usage: report_to_touch.py [report.txt] < report.txt", file=sys.stderr)
return 2
text = open(sys.argv[1]) if len(sys.argv) == 2 else sys.stdin
for t_ms, action, x, y in convert(text):
print(f"{t_ms} {action} {_fmt(x)} {_fmt(y)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# Runs iris's on-demand benchmark suite.
# Never run by `cargo test`; run this by hand or before/after a layout
# change. Always release -- see AGENTS.md's own rule against reading a
# frame time from a debug build.
#
# ./scripts/run-bench.sh # everything
# ./scripts/run-bench.sh list # just the CPU-only message-list scenarios
# ./scripts/run-bench.sh images # just the GPU bind-group-creation scenario
set -eu
scripts=$(cd "$(dirname "$0")" && pwd)
root=$(cd "$scripts/.." && pwd)
cd "$root"
what="${1:-all}"
if [ "$what" = "all" ] || [ "$what" = "list" ]; then
echo "=== message_list (CPU-only, no window) ==="
cargo bench --bench message_list
fi
if [ "$what" = "all" ] || [ "$what" = "images" ]; then
echo "=== bench_images (real wgpu device, via run-headless.sh) ==="
timeout 60 "$scripts/run-headless.sh" bench_images --seconds 4 2>&1 | grep "^BENCH_IMAGES"
fi
+198
View File
@@ -0,0 +1,198 @@
#!/bin/sh
# Run an iris example on this machine, which has no display.
#
# ./scripts/run-headless.sh tabs [-- cargo args]
# ./scripts/run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
# ./scripts/run-headless.sh phone --phone --dir ../app --shot /tmp/p.png
# ./scripts/run-headless.sh phone --phone --dir ../app \
# --replay ../app/touch/flick-120hz.touch --shot /tmp/p.png
#
# `--dir DIR` names the workspace to build in, defaulting to `iris/`. The
# app's examples -- the phone-sized transcript
# screen and everything else that is about *this product* -- live in
# `app/`, which is a workspace of its own; `replay-touch` is still
# built from iris, since it is part of the rig rather than of either app.
#
# `--phone` is layer 2 of docs/RUST.md's "Three test layers": the output
# and the window take Iris's phone's own size and density (1080x2424 at
# `content_scale` 2.55, from docs/bench/iris-phone-v2-2026-09-06.md,
# carried in `ai_app::ui::fixture::PHONE_*`), and `IRIS_SCALE` hands that
# density to iris the way `DisplayMetrics.density` does on Android
# (`iris::desktop::content_scale`). So a screenshot from here and one
# from the phone are the same layout at the same density, and what
# differs is only the renderer. Without it the output stays desktop-
# shaped, which is what every other example wants.
#
# `--replay FILE` drives one of the `.touch` recordings the headless
# tests use (`app/touch/`) into the window through
# `rig-input`'s `replay-touch` -- one recording, both layers. With
# `--shot` it also writes `<shot>-before.png` from just before the
# gesture, since "the list moved" is a claim about two pictures.
#
# `--bin` runs a real crate binary instead of an example (E4's
# `ai-app-desktop`, which is a window a person runs, not a demo) --
# `cargo build --bin NAME` instead of `--example NAME`, and
# `target/debug/NAME` instead of `target/debug/examples/NAME`. Its own
# argv (the CLI flags a real binary takes, as opposed to `cargo build`'s
# own flags after `--`) comes through `$RUN_HEADLESS_ARGS`, word-split on
# purpose -- an example never needed one, so there was nowhere to plumb it
# through positionally without disturbing the existing `-- cargo args`
# convention above.
#
# The VM has a real GPU and no display (the `this-machine-graphics` skill
# says what it is and how it fails), so what is missing here is only a
# compositor to give winit a surface. So: a headless sway, the same trick
# `emu` uses for the Android emulator, and `grim` to see the result.
#
# It is deliberately *not* `emu`'s compositor. sway tiles, so adding a window
# to the one an emulator is sitting in resizes that emulator's window, and a
# peer session's `emu up` could join at any moment. This one has its own
# socket and its own runtime directory and goes away with the machine.
set -eu
scripts=$(cd "$(dirname "$0")" && pwd)
root=$(cd "$scripts/.." && pwd)
workdir="$root"
cd "$root"
run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless"
seconds=3
shot=""
replay=""
example=""
kind=example
phone=no
# The phone Iris runs the bench on. Not typed from memory: these are
# `ai_app::ui::fixture::PHONE_WIDTH`/`PHONE_HEIGHT`/`PHONE_SCALE`, which
# in turn come from her own reports -- keep the three in step.
PHONE_MODE=1080x2424@120Hz
PHONE_SCALE=2.55
DESKTOP_MODE=1920x1200@60Hz
while [ $# -gt 0 ]; do
case "$1" in
--shot) shot=$2; shift 2 ;;
--seconds) seconds=$2; shift 2 ;;
--bin) kind=bin; shift ;;
--phone) phone=yes; shift ;;
--replay) replay=$2; shift 2 ;;
--dir) workdir=$(cd "$2" && pwd); shift 2 ;;
--) shift; break ;;
*) example=$1; shift ;;
esac
done
[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--phone] [--dir DIR] [--replay TOUCH] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
[ -z "$replay" ] || [ -f "$replay" ] || { echo "run-headless: no touch script at $replay" >&2; exit 2; }
mkdir -p "$run"
export SWAYSOCK="$run/sway.sock"
# Named rather than left to sway's pid-based default, so a second run reuses
# this compositor instead of starting another beside it.
if ! swaymsg -t get_version >/dev/null 2>&1; then
rm -f "$SWAYSOCK"
WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 LIBSEAT_BACKEND=noop \
setsid sway -c "$scripts/headless.conf" >"$run/sway.log" 2>&1 &
i=0
while [ $i -lt 20 ]; do
swaymsg -t get_version >/dev/null 2>&1 && break
i=$((i + 1)); sleep 0.5
done
swaymsg -t get_version >/dev/null 2>&1 || {
echo "run-headless: compositor did not start; see $run/sway.log" >&2
exit 1
}
fi
rm -f "$run/display"
swaymsg exec -- "sh -c 'printf %s \"\$WAYLAND_DISPLAY\" > $run/display'" >/dev/null
i=0
while [ $i -lt 20 ]; do
[ -s "$run/display" ] && break
i=$((i + 1)); sleep 0.5
done
[ -s "$run/display" ] || { echo "run-headless: could not read WAYLAND_DISPLAY" >&2; exit 1; }
WAYLAND_DISPLAY=$(cat "$run/display")
export WAYLAND_DISPLAY
echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2
# Set every run rather than only when it changes: this compositor is
# reused across runs (see the socket comment above), so a desktop-shaped
# run after a phone-shaped one would otherwise inherit the phone's output
# and silently screenshot the wrong size.
if [ "$phone" = yes ]; then
mode=$PHONE_MODE
export IRIS_SCALE="$PHONE_SCALE"
echo "run-headless: phone-shaped output $PHONE_MODE at IRIS_SCALE=$PHONE_SCALE" >&2
else
mode=$DESKTOP_MODE
fi
swaymsg output HEADLESS-1 mode "$mode" >/dev/null
# The extent `replay-touch` positions against, so a script's coordinates
# are the output's own pixels.
out_w=${mode%x*}
out_h=${mode#*x}; out_h=${out_h%@*}
# Built before the app starts, so a compile error is not reported as a
# window that failed to move.
[ -z "$replay" ] || (cd "$root" && cargo build --bin replay-touch -p rig-input) >&2
cd "$workdir"
if [ "$kind" = bin ]; then
cargo build --bin "$example" "$@" >&2
bin="$workdir/target/debug/$example"
else
cargo build --example "$example" "$@" >&2
bin="$workdir/target/debug/examples/$example"
fi
# shellcheck disable=SC2086 -- deliberately word-split: this is the
# binary's own argv, not a single path.
"$bin" ${RUN_HEADLESS_ARGS:-} >"$run/$example.log" 2>&1 &
pid=$!
trap 'kill "$pid" 2>/dev/null || true' EXIT INT TERM
# Wait for the window to be mapped rather than for a number of seconds. A
# fixed sleep took an all-black screenshot the first time this ran, when sway
# had started in the same invocation and had not composited its output yet --
# which is indistinguishable from an app that draws nothing.
i=0
while [ $i -lt 40 ]; do
kill -0 "$pid" 2>/dev/null || break
swaymsg -t get_tree --raw 2>/dev/null | grep -q "\"pid\":$pid," && break
i=$((i + 1)); sleep 0.25
done
i=0
while [ $i -lt "$((seconds * 2))" ]; do
kill -0 "$pid" 2>/dev/null || break
i=$((i + 1)); sleep 0.5
done
if [ -n "$replay" ] && kill -0 "$pid" 2>/dev/null; then
if [ -n "$shot" ]; then
grim "${shot%.png}-before.png"
echo "run-headless: wrote ${shot%.png}-before.png (before the gesture)" >&2
fi
"$root/target/debug/replay-touch" "$out_w" "$out_h" "$replay"
# A fling outlives the finger: the gesture's own last sample is not
# when the list stops. Long enough for Android's spline to settle
# (`FlingCalculator::duration` tops out around a second and a half).
sleep 2
fi
if kill -0 "$pid" 2>/dev/null; then
[ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
status=0
else
wait "$pid" 2>/dev/null || status=$?
echo "run-headless: $example exited early (status ${status:-0})" >&2
status=${status:-1}
fi
echo "--- $example output ---" >&2
cat "$run/$example.log" >&2
exit "$status"