The remaining ~21 ms display stage was the conservative release target: the
presenter aimed at the first frame timeline whose DEADLINE was still ahead,
and the platform's deadline budgets for GPU rendering the app has yet to
submit (presDeadline = 11.3 ms on the A024 — more than a full 120 Hz
period). A decoded video buffer has no GPU work left; its only real
constraint is SurfaceFlinger's own latch lead. Every frame paid a whole
extra refresh of waiting for a budget it never used.
next_target now gates (and subdivides) on the timeline's EXPECTED PRESENT
minus a 4 ms latch margin; the glass budget reopens at that latch instant
(expected present − margin) rather than the deadline, which under the
aggressive gate can already lie in the past — an instant reopen would let
two releases pile onto one vsync. A mis-gamble presents one vsync later,
which is exactly what the deadline gate paid on every frame — the trade is
one-sided.
On-glass (A024, 2800×1260@120 HDR, game load): latch p50 21→8-10 ms,
display 26,3→9,5 (pace 0,8 + latch 7,7), e2e 43,7→26,4 p50 / 29,2 p95,
released=displays=120, paced≈0, forced=0. The latch now sits under one
refresh interval — the vsync-latch floor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-glass (A024, 120 Hz panel, 120 fps session) the first presenter build
released only 60/s and the HUD display term hit 40 ms. Root cause, in two
layers: Android down-rates a game-category uid's choreographer stream to
60 Hz (frame-rate categories / game default frame rate), and under that
override Display.getRefreshRate REPORTS THE OVERRIDE — so the presenter's
panel grid read 16.67 ms on an 8.33 ms panel and the subdivision became a
no-op, pacing the video at half rate and dropping every other frame.
Three-part fix, verified live on the same device:
- Kotlin passes the panel rate from the supported-modes TABLE
(MainActivity.streamPanelFps — the mode list is not override-filtered)
instead of display.refreshRate, and votes the app's render rate up via
View.requestedFrameRate = streamHz (API 35+) while streaming.
- The native vsync clock LEARNS the panel period from observed timeline
spacing (downward-only: the finest spacing SurfaceFlinger ever reports is
the true grid) and next_target subdivides the reported timeline onto it —
full-rate on down-rated devices, a no-op where callbacks match the panel.
- OnFrameRendered display/latch samples get the e2e clamp (0..10 s): a
vendor's first callbacks can carry a garbage system_nano (observed: an
epoch-sized latch max) that would poison every max it lands in.
pf.present gained panelMs next to vsyncMs, and a one-shot cadence
diagnostic logs Δ/timelines/spacing/panel on the third tick.
After: released=120 displays=120 paced=0, pace p50 <1 ms, latch p50 ~16 ms
idle / ~22 ms under game load (2 refresh intervals at 8.33 — the same
composited-pipeline law the Apple client measured), HUD display ~17-26 ms
vs 40 before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Android port of the Apple client's stage-4 deadline discipline, closing
the side-by-side feel gap (both clients 120 Hz; Android released decoded
buffers the instant they appeared, with zero vsync awareness — the latch
phase inherited every network+decode jitter and bursts queued behind the
display).
The presenter (async loop only; the sync loop stays the untouched escape
hatch behind the Low-latency toggle):
- decode/vsync.rs: an AChoreographer thread (dlsym'd like the other
above-floor symbols) publishing the panel's vsync grid + frame timelines
(postVsyncCallback, API 33; postFrameCallback64 fallback on 31/32) and
ticking the decode loop's event channel. Started lazily on the first
decoded frame.
- decode/presenter.rs: a newest-wins slot (Lowest latency, default) or a
1-3 frame smoothing FIFO with preroll/underflow re-arm (Smoothness) between
decode and release; a glass budget of exactly ONE undisplayed release in
flight, reopened at the target timeline's DEADLINE (SurfaceFlinger's latch
— reopening at present time would halve the sustainable rate) with a 100 ms
stale force-open backstop; the release itself via
releaseOutputBufferAtTime(expectedPresent) so the latch phase is
deterministic. debug.punktfunk.presenter=arrival sysprop restores the
legacy path for a rebuild-free on-device A/B.
- Metrics: DisplayTracker is now always-on and carries the release stamp, so
the display stage splits into pace (decoded→release) + latch
(release→displayed); a 1 Hz pf.present logcat line (released/displays/
paced/noBudget/forced/qDry + pace/latch p50/max + measured vsync) makes a
HUD-off wireless A/B readable; nativeVideoStats grows to 30 doubles
(26=paceP50, 27=latchP50, 28=presents, 29=presenterActive; 0-25 frozen)
and the DETAILED HUD prints the split + presents.
- Intent parity: present_priority/smooth_buffer — the Apple client's
stored values and labels — as globals, profile-overlay fields (round-trip
+ scope markers), and Settings pickers under Decoding; threaded through
nativeStartVideo into the presenter config.
Verified: cargo ndk check/clippy clean for arm64 (the two type_complexity
warnings are pre-existing audio/mic ones), armv7 via the kit gradle task,
host cargo check clean, rustfmt clean, gradle :app/:kit unit tests all pass.
On-device before/after on the Nothing Phone 3 still owed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>