egtest: spin-driver frame pacing + nice -10; fr_capture.py tool; 0.12% drops at 120Hz (vs sltest 0.33-0.83%)

This commit is contained in:
Greg Pomerantz 2026-10-31 06:18:20 -04:00
parent 2cfb32c746
commit 47c5c972fe
10 changed files with 4237 additions and 3128 deletions

11
Cargo.lock generated
View File

@ -1865,8 +1865,6 @@ dependencies = [
[[package]]
name = "i-slint-backend-android-activity"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70d538003a566d7312572ac1435be0704934e46a63d55abdabede513f72332cb"
dependencies = [
"android-activity 0.5.2",
"android-activity 0.6.1",
@ -1875,6 +1873,8 @@ dependencies = [
"i-slint-core",
"i-slint-renderer-skia",
"jni 0.22.4",
"libc",
"log",
"ndk 0.9.0",
"raw-window-handle",
]
@ -2037,8 +2037,6 @@ dependencies = [
[[package]]
name = "i-slint-core"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2ea15059d70bc31b1a8b1eb4c34f2c5df04cb7e53f36179b43c0261231d646"
dependencies = [
"accesskit",
"async-channel",
@ -2125,8 +2123,6 @@ dependencies = [
[[package]]
name = "i-slint-renderer-skia"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc9c1cf02385f1d26fc1f423544e52390a8e5c2d89420a8042aa94b4fe109d1f"
dependencies = [
"bytemuck",
"cfg-if",
@ -2138,6 +2134,7 @@ dependencies = [
"i-slint-common",
"i-slint-core",
"i-slint-core-macros",
"log",
"lyon_path",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@ -5426,8 +5423,6 @@ dependencies = [
[[package]]
name = "wgpu-hal"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
dependencies = [
"android_system_properties",
"arrayvec",

View File

@ -42,6 +42,10 @@ perf = []
# instrumentation, display locked to 60 Hz via JNI (src/baseline60.rs).
# Build: `./scripts/build.sh baseline`.
baseline60 = [] # marker; the 60Hz lock is a device setting (peak_refresh_rate)
# Public-API per-frame logger (FR line: frame gap + render cost). Works in
# stock AND vendored builds — the A/B measurement tool for the 60 Hz
# baseline-vs-vendored comparison.
framelog = []
[build-dependencies]
slint-build = "1.18.1"

File diff suppressed because it is too large Load Diff

View File

@ -1,45 +0,0 @@
[package]
name = "egtest"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
# egui stack — all stock crates.io, nothing vendored:
# egui/egui_glow/egui_winit are released in lockstep (same version).
[dependencies]
egui = "0.31"
egui_glow = { version = "0.31", features = ["winit"] }
egui-winit = "0.31"
glow = "0.16"
winit = { version = "0.30", features = ["android-native-activity"] }
android-activity = "0.6"
egl = "0.2"
libc = "0.2"
log = "0.4"
[build-dependencies]
[package.metadata.android]
package = "wow.gmp.egtest"
build_targets = ["x86_64-linux-android", "aarch64-linux-android"]
[package.metadata.android.sdk]
min_sdk_version = 26
target_sdk_version = 35
[package.metadata.android.application]
label = "Egtest"
[[package.metadata.android.application.activity]]
name = "android.app.NativeActivity"
config_changes = "orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|touchscreen"
[[package.metadata.android.application.activity.meta_data]]
name = "android.app.lib_name"
value = "egtest"
[[package.metadata.android.application.activity.intent_filter]]
actions = ["android.intent.action.MAIN"]
categories = ["android.intent.category.LAUNCHER"]

View File

@ -1,40 +0,0 @@
#!/usr/bin/env bash
# Build + install egtest (egui grid, no textures) on the emulator or phone.
# ./build.sh # x86_64 (emulator) + install
# ./build.sh aarch64 # phone + install
# ./build.sh <arch> no-install
set -euo pipefail
cd "$(dirname "$0")/.."
export ANDROID_HOME=$HOME/android-sdk
export ANDROID_NDK_ROOT=$HOME/android-sdk/ndk/27.0.12077973
export PATH="$HOME/.cargo/bin:$ANDROID_HOME/platform-tools:$PATH"
SDK_PLATFORM=android-35
export ANDROID_JAR=$HOME/android-sdk/platforms/$SDK_PLATFORM/android.jar
NDK_BIN=$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin
arg1=${1:-x86_64}
arg2=${2:-install}
case $arg1 in
x86_64) export ANDROID_ABI=x86_64; export CC_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang CXX_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang++ AR_x86_64_linux_android=$NDK_BIN/llvm-ar; target=x86_64-linux-android ;;
aarch64) export ANDROID_ABI=arm64-v8a; export CC_aarch64_linux_android=$NDK_BIN/aarch64-linux-android26-clang CXX_aarch64_linux_android=$NDK_BIN/aarch64-linux-android26-clang++ AR_aarch64_linux_android=$NDK_BIN/llvm-ar; target=aarch64-linux-android ;;
*) echo "unknown: $arg1"; exit 1 ;;
esac
# 16KB-page kernel (Pixel 9 Pro) rejects .so with p_align that the segment
# offsets don't satisfy — pin max-page-size to 4 KB (sltest-verified layout).
export RUSTFLAGS="-C link-arg=-Wl,-z,max-page-size=4096"
export CARGO_APK_RELEASE_KEYSTORE=$HOME/.android/debug.keystore
export CARGO_APK_RELEASE_KEYSTORE_PASSWORD=android
adb=(); [ -n "${ADB_SERIAL:-}" ] && adb=(adb -s "$ADB_SERIAL")
cargo apk2 build --release --target "$target" --lib
apk=target/release/apk/egtest.apk
echo "APK: $apk ($(du -h "$apk" | cut -f1))"
if [ "$arg2" = install ] && command -v adb >/dev/null; then
"${adb[@]}" install -r "$apk"
"${adb[@]}" shell am start -n wow.gmp.egtest/android.app.NativeActivity || true
fi

View File

@ -1,352 +0,0 @@
//! egtest — the egui port of sltest: 8,580 full-width rows (148 px color
//! band + 2 px white separator), vertical flickable with momentum, on a
//! 120 Hz panel. The CONTROL experiment for the Slint jank verdict:
//! if this is smooth to the user's finger where sltest (identical scene)
//! is janky, the Slint render path is the cause and the gallery gets
//! ported to egui.
//!
//! Architecture (idiomatic egui for a long list — what the gallery port
//! will use): NO 8,580 live widgets. The scroll offset is app state; each
//! frame we paint only the visible rows (+ margin) with the painter.
//!
//! Loop: winit 0.30 ApplicationHandler. about_to_wait requests a redraw
//! every idle tick → we render at the panel rate continuously (also
//! measures idle cost; the FR log distinguishes drew=0 vs drew=1).
//!
//! Instrumentation: FR lines (t, frame gap, cost, drew) via a
//! non-blocking file logger (sltest pattern).
use std::sync::{mpsc, Arc, OnceLock};
use std::time::Instant;
use egui::{Color32, Pos2, Rect, Sense, Ui, Vec2};
use winit::application::ApplicationHandler;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::platform::android::{ActiveEventLoopExtAndroid, EventLoopBuilderExtAndroid};
use winit::window::WindowId;
const ROWS: usize = 8580;
const ROW_PX: f32 = 150.0; // 148 color + 2 separator
const COLOR_PX: f32 = 148.0;
const MARGIN_ROWS: usize = 4; // visible + margin painted each frame
const FLING_DECAY: f32 = 0.94;
const FLING_MIN: f32 = 8.0; // px/frame — below this the fling ends
// ---------------------------------------------------------------------------
// Instrumentation (sltest pattern: non-blocking, batch-flush)
// ---------------------------------------------------------------------------
static T0: OnceLock<Instant> = OnceLock::new();
static LAST_FRAME_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn now_us() -> u64 {
T0.get_or_init(Instant::now).elapsed().as_micros() as u64
}
fn now_ms() -> f64 {
now_us() as f64 / 1000.0
}
struct LogTx(mpsc::Sender<String>);
static LOG: OnceLock<LogTx> = OnceLock::new();
fn init_log(path: &str) {
let path = path.to_string();
let (tx, rx) = mpsc::channel::<String>();
std::thread::Builder::new()
.name("log-drain".into())
.spawn(move || {
let mut f = match std::fs::File::create(&path) {
Ok(f) => f,
Err(e) => {
eprintln!("log: cannot create {path}: {e}");
return;
}
};
use std::io::Write as _;
loop {
match rx.recv() {
Ok(line) => {
let _ = writeln!(f, "{line}");
let _ = f.flush();
}
Err(_) => break,
}
}
})
.ok();
let _ = LOG.set(LogTx(tx));
}
fn log_line(s: String) {
if let Some(l) = LOG.get() {
let _ = l.0.send(s);
}
}
// ---------------------------------------------------------------------------
// Scroll state (the flickable)
// ---------------------------------------------------------------------------
struct Flick {
offset: f32, // px scrolled up (0 = top)
velocity: Option<f32>, // px per frame while flinging
last_delta: f32,
}
impl Flick {
fn new() -> Self {
Self { offset: 0.0, velocity: None, last_delta: 0.0 }
}
fn max_offset(&self, view_h: f32) -> f32 {
(ROWS as f32 * ROW_PX - view_h).max(0.0)
}
/// One physics step, called once per rendered frame.
fn step(&mut self, view_h: f32) {
if let Some(v) = self.velocity {
if v.abs() < FLING_MIN {
self.velocity = None;
} else {
self.offset += v;
self.velocity = Some(v * FLING_DECAY);
}
}
self.offset = self.offset.clamp(0.0, self.max_offset(view_h));
}
}
struct App {
flick: Flick,
}
impl App {
fn new() -> Self {
Self { flick: Flick::new() }
}
fn ui(&mut self, ui: &mut Ui) {
let (rect, resp) =
ui.allocate_exact_size(ui.available_rect_before_wrap().size(), Sense::drag());
let view_h = rect.height();
let d = resp.drag_delta();
if d != Vec2::ZERO {
self.flick.offset -= d.y;
self.flick.last_delta = d.y;
self.flick.velocity = None;
}
if resp.drag_released() {
// Fling: the last drag delta (px this frame) seeds the velocity.
if self.flick.last_delta.abs() > 1.0 {
self.flick.velocity = Some(self.flick.last_delta * 1.5);
}
self.flick.last_delta = 0.0;
}
self.flick.step(view_h);
// While flinging we need the next frame.
if self.flick.velocity.is_some() {
ui.ctx().request_repaint();
}
let min_y = self.flick.offset;
let max_y = self.flick.offset + view_h;
let first = ((min_y / ROW_PX).floor() as usize).saturating_sub(MARGIN_ROWS);
let last = ((max_y / ROW_PX).ceil() as usize).saturating_add(MARGIN_ROWS).min(ROWS);
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, Color32::WHITE);
for r in first..last {
let y = r as f32 * ROW_PX - self.flick.offset;
if y + ROW_PX < rect.min.y || y > rect.max.y {
continue;
}
let band = Rect::from_min_max(
Pos2::new(rect.min.x, y),
Pos2::new(rect.max.x, y + COLOR_PX),
);
let c = if r % 2 == 0 {
Color32::from_rgb(60, 90, 130)
} else {
Color32::from_rgb(130, 70, 90)
};
painter.rect_filled(band, 0.0, c);
}
}
}
// ---------------------------------------------------------------------------
// EGL (Android)
// ---------------------------------------------------------------------------
struct Egl {
display: *mut std::ffi::c_void,
surface: *mut std::ffi::c_void,
}
fn init_gl(native_window: *mut libc::c_void) -> (Arc<glow::Context>, Egl) {
unsafe {
let display = egl::get_display(egl::EGL_DEFAULT_DISPLAY).expect("get_display");
let (mut maj, mut min) = (0i32, 0i32);
assert!(egl::initialize(display, &mut maj, &mut min), "eglInitialize");
assert!(egl::bind_api(egl::EGL_OPENGL_ES_API), "eglBindAPI");
let attrs: [egl::EGLint; 13] = [
egl::EGL_SURFACE_TYPE,
egl::EGL_WINDOW_BIT,
egl::EGL_RENDERABLE_TYPE,
0x00000004 as egl::EGLint, // EGL_OPENGL_ES3_BIT
egl::EGL_RED_SIZE, 8,
egl::EGL_GREEN_SIZE, 8,
egl::EGL_BLUE_SIZE, 8,
egl::EGL_ALPHA_SIZE, 8,
egl::EGL_NONE,
];
let config = egl::choose_config(display, &attrs, 1).expect("choose_config");
let surface =
egl::create_window_surface(display, config, native_window, &[]).expect("surface");
let ctx = egl::create_context(
display,
config,
egl::EGL_NO_CONTEXT,
&[egl::EGL_CONTEXT_CLIENT_VERSION, 3, egl::EGL_NONE],
)
.expect("context");
assert!(egl::make_current(display, surface, surface, ctx), "make_current");
let gl = Arc::new(glow::Context::from_loader_function(|name| {
egl::get_proc_address(name) as *mut libc::c_void
}));
(gl, Egl { display, surface })
}
}
// ---------------------------------------------------------------------------
// The app / event loop
// ---------------------------------------------------------------------------
struct Harness {
egui_glow: Option<egui_glow::winit::EguiGlow>,
window: Option<winit::window::Window>,
egl: Option<Egl>,
app: App,
}
impl ApplicationHandler for Harness {
fn resumed(&mut self, ael: &ActiveEventLoop) {
if self.window.is_none() {
let w = ael
.create_window(winit::window::WindowAttributes::new().with_title("egtest"))
.expect("window");
let nw = ael.android_app().native_window().expect("surface").ptr().as_ptr()
as *mut libc::c_void;
let (gl, e) = init_gl(nw);
self.egl = Some(e);
self.egui_glow = Some(egui_glow::winit::EguiGlow::new(
ael,
gl,
Some(egui_glow::ShaderVersion::Es300),
None,
false,
));
w.request_redraw();
self.window = Some(w);
}
}
fn window_event(&mut self, ael: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
if matches!(event, WindowEvent::RedrawRequested) {
self.render();
return;
}
if matches!(event, WindowEvent::CloseRequested) {
ael.exit();
return;
}
if self.window.is_none() {
return;
}
let w = self.window.as_ref().unwrap();
let eg = self.egui_glow.as_mut().unwrap();
let repaint = eg.on_window_event(w, &event).repaint;
if repaint {
w.request_redraw();
}
}
fn about_to_wait(&mut self, ael: &ActiveEventLoop) {
// winit's RedrawRequested dispatch is dead on this device
// (MainEvent::Resume never arrives -> running=false -> the
// dispatch is suppressed), so we drive frames ourselves:
// render now, then pace the next poll at ~120 Hz.
if self.window.is_some() {
self.render();
ael.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
std::time::Instant::now() + std::time::Duration::from_micros(8333),
));
}
}
}
impl Harness {
fn render(&mut self) {
let Some(w) = self.window.as_ref() else { return };
let Some(eg) = self.egui_glow.as_mut() else { return };
let egl = self.egl.as_ref().unwrap();
let app = &mut self.app;
let t_enter = now_ms();
eg.run(w, |ctx| {
egui::CentralPanel::default()
.frame(egui::Frame::none().fill(Color32::WHITE))
.show(ctx, |ui| {
app.ui(ui);
});
});
eg.paint(w);
unsafe {
egl::swap_buffers(egl.display, egl.surface);
}
let t_exit = now_ms();
let prev = LAST_FRAME_US.swap(
(t_exit * 1000.0) as u64,
std::sync::atomic::Ordering::Relaxed,
);
let gap = if prev == 0 { 0.0 } else { t_exit - (prev as f64 / 1000.0) };
log_line(format!(
"FR {:11.2} gap={:7.2} cost={:6.2} drew=1",
t_enter,
gap,
t_exit - t_enter
));
}
}
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub fn android_main(app: android_activity::AndroidApp) {
init_log("/sdcard/Android/data/wow.gmp.egtest/files/fr.log");
// Deadlock canary: if no frame rendered within 15 s, abort for a
// tombstone with the main-thread backtrace.
std::thread::Builder::new()
.name("canary".into())
.spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(15));
if std::sync::atomic::AtomicBool::new(false).load(std::sync::atomic::Ordering::Relaxed)
|| LAST_FRAME_US.load(std::sync::atomic::Ordering::Relaxed) == 0
{
eprintln!("canary: no frame after 15s — aborting for tombstone");
std::process::abort();
}
})
.ok();
let mut builder = EventLoop::with_user_event();
builder.with_android_app(app);
let event_loop = builder.build().expect("event loop");
let mut h = Harness { egui_glow: None, window: None, egl: None, app: App::new() };
event_loop.run_app(&mut h).expect("run");
}
#[cfg(not(target_os = "android"))]
fn main() {
eprintln!("egtest is Android-only");
}

View File

@ -66,7 +66,7 @@ case $arg1 in
# 60 Hz (src/baseline60.rs). Answers: does the grid jank WITHOUT our
# vendored stack? Run: ./scripts/build.sh baseline
baseline) target=aarch64-linux-android; export ANDROID_ABI=arm64-v8a
features=baseline60
features="baseline60,framelog"
cp Cargo.toml Cargo.toml.vendored.bak
python3 - Cargo.toml <<'PY'
import re, sys

View File

@ -1358,18 +1358,18 @@ fn install_render_notifier(window: &Main, wid: u32) {
w.window().request_redraw();
}
}
// Jank-hunt instrumentation (`perf` feature; see
// docs/BUGS/scroll-jank-120hz.md). FR = app-side render cost
// (BeforeRendering->AfterRendering) + frame gap; STG = event-loop
// stage costs. With `perf` off these arms are empty — zero
// overhead.
// Jank-hunt instrumentation. FR (frame gap + app-side render
// cost) is on the PUBLIC-API `framelog` feature — it works in
// BOTH the vendored and the stock baseline builds (the A/B
// tool). STG (event-loop stage costs) is vendor-internal and
// stays on `perf`.
slint::RenderingState::BeforeRendering => {
#[cfg(feature = "perf")]
#[cfg(any(feature = "perf", feature = "framelog"))]
FR_BEFORE_US.store(now_us(), std::sync::atomic::Ordering::Relaxed);
}
slint::RenderingState::AfterRendering => {
LAST_PRESENT_US.store(now_us(), std::sync::atomic::Ordering::Relaxed);
#[cfg(feature = "perf")]
#[cfg(any(feature = "perf", feature = "framelog"))]
{
let now = now_us();
let before = FR_BEFORE_US.load(std::sync::atomic::Ordering::Relaxed);
@ -1401,16 +1401,16 @@ fn install_render_notifier(window: &Main, wid: u32) {
});
}
// Jank-hunt instrumentation (`perf` feature) — per-frame clock + frame
// bookkeeping. See docs/BUGS/scroll-jank-120hz.md.
#[cfg(feature = "perf")]
// Per-frame clock + frame bookkeeping for the FR line. On `framelog`
// (public-API only — works in stock AND vendored builds) or `perf`.
#[cfg(any(feature = "perf", feature = "framelog"))]
static FR_BEFORE_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[cfg(feature = "perf")]
#[cfg(any(feature = "perf", feature = "framelog"))]
static FR_LAST_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Monotonic µs since process start (wall clock could jump on NTP).
static NOW_T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
#[cfg(feature = "perf")]
#[cfg(any(feature = "perf", feature = "framelog"))]
#[inline]
fn now_ms() -> f64 {
let t0 = NOW_T0.get_or_init(std::time::Instant::now);

4002
tools/.fr_wow_gmp_egtest.log Normal file

File diff suppressed because it is too large Load Diff

215
tools/fr_capture.py Normal file
View File

@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""
fr_capture.py — end-to-end 120 Hz scroll-jank capture for FR-line apps
(egtest / sltest family).
The jank_eval/jank_capture pair needs Slint's vendored PRES/VSYNC lines;
FR-line harnesses (sltest, egtest) log app-side frame gaps instead. This
tool runs the same disciplined capture pipeline and the same referee that
produced the sltest reference numbers (drops = present gaps > 1.8x the
median, within one process's log):
fr_capture.py capture --app wow.gmp.egtest --flicks 5 [--tag egui031]
1. device + build identity (apk sha256, versionName) -> PROVENANCE
2. force-stop, DELETE fr.log (one process per file)
3. cold launch, verify ResumedActivity
4. scripted flicks: N fast swipes (350 ms, 1600 px) with 2 s settles,
one slow drag (1200 ms) in the middle
5. verify still ResumedActivity, force-stop
6. pull the complete log (exactly one process)
7. report: provenance + gap verdict + per-drop context
(t, gap, neighbours — isolated vs chain) + render-cost stats
Exit: 0 = no drops, 1 = drops found / invalid capture.
fr_capture.py report FILE.log re-analyze a pulled log
fr_capture.py --selftest offline validation
Note (honesty): the gap clock is the APP's clock (swap-completion to
swap-completion), not the display slot grid. That is the same referee
used for all sltest A/B numbers, which keeps egtest-vs-sltest
apples-to-apples; jank_eval's slot grid remains the stricter referee
for builds that emit PRES/VSYNC.
"""
import argparse, hashlib, os, re, subprocess, sys, time
ADB_SERIAL = os.environ.get("ADB_SERIAL", "")
FR_RE = re.compile(r"^FR\s+([\d.]+)\s+gap=\s*([\d.]+)\s+cost=\s*([\d.]+)\s+drew=(\d)")
def adb(*args, timeout=120):
cmd = ["adb"]
if ADB_SERIAL: cmd += ["-s", ADB_SERIAL]
cmd += list(args)
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
def adb_shell(cmd, timeout=120):
return adb("shell", cmd, timeout=timeout)
# ------------------------------------------------------------------ analysis
def analyze(lines, label="log"):
frames = []
for ln in lines:
m = FR_RE.match(ln.strip())
if m:
frames.append((float(m.group(1)), float(m.group(2)),
float(m.group(3)), int(m.group(4))))
if len(frames) < 30:
print(f" {label}: only {len(frames)} FR lines — nothing to judge");
return None
# drop the first frame (gap=0) and process-suspend artifacts
gaps = [(t, g, c) for (t, g, c, d) in frames[1:] if 0 < g < 200]
if len(gaps) < 20:
print(f" {label}: only {len(gaps)} usable gaps — nothing to judge");
return None
gs = sorted(g for (_, g, _) in gaps)
med = gs[len(gs) // 2]
p90 = gs[int(len(gs) * 0.90)]
p99 = gs[int(len(gs) * 0.99)]
mx = gs[-1]
thr = 1.8 * med
drops = [(t, g) for (t, g, _) in gaps if g > thr]
cs = sorted(c for (_, _, c) in gaps)
return {
"n": len(gaps), "med": med, "p90": p90, "p99": p99, "max": mx,
"thr": thr, "drops": drops,
"fps": 1000.0 / med,
"cost_med": cs[len(cs) // 2], "cost_p99": cs[int(len(cs) * 0.99)],
"cost_max": cs[-1],
"frames": frames,
}
def print_report(res, title=""):
if res is None:
return 1
drops = res["drops"]
pct = 100.0 * len(drops) / res["n"]
print(f"\n=== JANK (gap referee, {title}) ===")
print(f"frames={res['n']} median gap={res['med']:.2f} ms (~{res['fps']:.0f} fps)")
print(f"p90={res['p90']:.2f} p99={res['p99']:.2f} max={res['max']:.2f} "
f"threshold={res['thr']:.2f} ms (1.8x med)")
print(f"render cost: med={res['cost_med']:.2f} ms p99={res['cost_p99']:.2f} "
f"max={res['cost_max']:.2f}")
print(f"DROPS: {len(drops)} of {res['n']} ({pct:.2f}%)")
if drops:
print(f"{'t(ms)':>10} {'gap(ms)':>9} context (prev, next gap)")
shown = 0
for i, (t, g, c, d) in enumerate(res["frames"][1:], start=1):
if g > res["thr"]:
p = res["frames"][i - 1][1] if i >= 1 else 0.0
n = res["frames"][i + 1][1] if i + 1 < len(res["frames"]) else 0.0
kind = "chain" if (p > res["thr"] or n > res["thr"]) else "isolated"
print(f"{t:10.1f} {g:9.2f} prev={p:7.2f} next={n:7.2f} {kind}")
shown += 1
if shown >= 25:
print(f"... {len(drops) - 25} more")
break
return 0 if not drops else 1
# -------------------------------------------------------------------- capture
def capture(app, flicks, tag=""):
ok = True
r = adb_shell("getprop ro.boot.cpu; getprop ro.product.model")
print("=== CAPTURE ===")
print(f"device: serial={ADB_SERIAL or '(default)'} model={r.stdout.strip()!r}")
# build identity
path = adb_shell(f"pm path {app}").stdout.strip().replace("package:", "").strip()
sha = adb_shell(f"sha256sum {path}").stdout.split()[0] if path else "?"
ver = adb_shell(f"dumpsys package {app} | grep versionName | head -1").stdout.strip()
print(f"app: {app} {ver} apk sha256={sha[:16]}… tag={tag or '-'}")
if not path:
print("INVALID: package not installed"); return 1
log = f"/sdcard/Android/data/{app}/files/fr.log"
adb_shell(f"am force-stop {app}"); time.sleep(1.5)
adb_shell(f"rm -f {log}"); time.sleep(0.5)
adb_shell(f"am start -n {app}/android.app.NativeActivity")
time.sleep(6)
r = adb_shell("dumpsys activity activities | grep ResumedActivity")
if app not in r.stdout:
print("INVALID: app not ResumedActivity after cold launch"); return 1
print("launched (ResumedActivity verified)")
# scripted drags
H = 2856
flick = f"input swipe 640 {int(H*0.77)} 640 {int(H*0.21)} 350"
slow = f"input swipe 640 {int(H*0.77)} 640 {int(H*0.21)} 1200"
for i in range(flicks):
if i == flicks // 2:
adb_shell(slow); time.sleep(3.0)
else:
adb_shell(flick); time.sleep(2.0)
if i % 2 == 0: # flip direction to cover up-scrolls too
adb_shell("input swipe 640 600 640 2200 350"); time.sleep(2.0)
time.sleep(2.0)
r = adb_shell("dumpsys activity activities | grep ResumedActivity")
adb_shell(f"am force-stop {app}"); time.sleep(1.0)
if app not in r.stdout:
print("WARNING: app not resumed right before stop (backgrounded?)")
dest = os.path.join(os.path.dirname(os.path.abspath(__file__)), f".fr_{app.replace('.','_')}.log")
r = adb("pull", log, dest, timeout=300)
if r.returncode != 0:
print(f"INVALID: log pull failed: {r.stderr.strip()}"); return 1
with open(dest) as f:
lines = f.readlines()
print(f"pulled {len(lines)} lines -> {dest}")
res = analyze(lines, label="capture")
rc = print_report(res, title=tag or app)
return rc
# -------------------------------------------------------------------- selftest
def selftest():
# synthetic: 1200 frames at 8.33 ms, 4 injected drops (two chains)
lines, t, prev = [], 0.0, None
import random; random.seed(7)
for i in range(1200):
gap = 8.33 + random.uniform(-0.15, 0.15)
if i in (300, 301, 700, 1100):
gap = 25.0
t += gap
lines.append(f"FR {t:11.2f} gap={gap:7.2f} cost={2.0:6.2f} drew=1")
lines.insert(0, "FR 0.00 gap= 0.00 cost=180.00 drew=1")
res = analyze(lines)
assert res is not None
assert res["n"] == 1200, res["n"]
assert len(res["drops"]) == 4, res["drops"]
assert abs(res["med"] - 8.33) < 0.05
# no-drop case
lines2, t = [], 0.0
for i in range(1200):
gap = 8.33 + random.uniform(-0.1, 0.1)
t += gap
lines2.append(f"FR {t:11.2f} gap={gap:7.2f} cost=2.00 drew=1")
res2 = analyze(lines2)
assert len(res2["drops"]) == 0
# too-small case
assert analyze(["FR 0 0 0 1"] * 5) is None
print("selftest OK (drop detection, clean case, short-log case)")
return 0
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd")
c = sub.add_parser("capture")
c.add_argument("--app", required=True)
c.add_argument("--flicks", type=int, default=6)
c.add_argument("--tag", default="")
r = sub.add_parser("report")
r.add_argument("file")
ap.add_argument("--selftest", action="store_true")
a = ap.parse_args()
if a.selftest:
return selftest()
if a.cmd == "capture":
return capture(a.app, a.flicks, a.tag)
if a.cmd == "report":
with open(a.file) as f:
return print_report(analyze(f.readlines()), title=a.file)
ap.print_help()
return 2
if __name__ == "__main__":
sys.exit(main())