Organize Iris support files
This commit is contained in:
1 parent
40e7259d99
commit
ea2112c552
11 files changed
+94
-56
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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")
|
||||
Executable
+94
@@ -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())
|
||||
Executable
+25
@@ -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
|
||||
Executable
+198
@@ -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"
|
||||
Reference in new issue
Block a user