Files
punktfunk/clients
enricobuehler a7ef62241b feat(android): present on the host's cadence under smoothness, not on the frame's arrival
WP4 of design/presenter-cadence-rework-implementation-plan.md — the Android
binding of the CadenceClock landed in e207d0d7.

The presenter aimed every frame at `next_target(now)`, so a frame's place on the
glass was decided by when it happened to finish decoding. On a host whose
compositor delivers raggedly that is the defect verbatim: the source's own
spacing is discarded and replaced with the transport's.

Three things had to be true for the clock to work here. Its `ready_ns` must be in
the domain `releaseOutputBufferAtTime` consumes, which is CLOCK_MONOTONIC — so a
monotonic twin is now stamped beside the existing realtime `decoded_ns`, at the
same instant on the codec's looper thread, and `decoded_ns` keeps its realtime
meaning for the latency stats. The cushion's ceiling is one SOURCE frame, so it
comes from the negotiated `mode.refresh_hz`, not the panel's. And the loop must
re-anchor on the discontinuities the client already knows about — the re-anchor
gate's arm count is that seam, and it subsumes every abandoned-AU and codec-error
site the plan listed. (The codec is never rebuilt in place here: a rebuild tears
the loop down and builds a fresh Presenter, so a reset there would be dead code.)

**The preroll gate is retired, and keeping it was never the conservative option.**
`prerolled` completes only when the store reaches capacity, but under cadence
targeting a frame sits in the store only between its decode and its due time, so
with a cushion under one frame interval the depth is about one. Preroll would
re-arm on nearly every pass and a buffer=2 session would present only during
bursts. `head_is_releasable` replaces both it and the per-vsync drain: a frame
leaves once the grid point it aims at is the next one this pump could still
submit for.

`next_target` now takes a composed `not_before_ns` instead of a separate
`margin_ns`. Passing `max(now, due)` while the margin stayed inside made the
function require `G > due + margin`, which pushes a frame whose due time sits
just under a grid point onto the next one for some phases and not others — that
is judder, not latency, and it only appears once the adaptive margin widens off
zero. For the latency path the new expression is arithmetically identical to what
the old body computed.

Smoothness only: under `latency` the clock is `None`, so it is not consulted
rather than consulted and ignored, and a test drives 600 frames through the due
path asserting every answer is `None` and the target floor is still exactly
`now + margin`.

⚠ `qDry` changes meaning and says so on the field: it now counts vsync ticks that
found the store empty, with no preroll precondition. Under cadence targeting an
empty store is the ordinary steady state — it reads as supply depth, not as an
alarm.

Verified: aarch64 AND armv7 clippy `--all-targets -D warnings` both exit 0 (the
armv7 leg because `vsync.rs`'s 32-bit `timespec` casts are target-dependent and
must not be "cleaned up"). No device was attached, so the unit tests were
type-checked but not run; their exact synthetic trace was instead replayed
through the REAL imported clock, giving frames=600 reanchors=1 late=17 and a
worst steady-state due-spacing error of 116 µs against a source grid whose raw
arrivals are off by 4 ms.
2026-08-15 14:18:14 +02:00
..