Main moved through the same surfaces while the audio work was in flight, so
three files needed hand-resolution:
- clients/windows/src/app/settings.rs — main gave Windows its speaker and
microphone endpoint pickers, the gap this branch could only report. Both
keep their rows: the pickers, then Echo cancellation, and the microphone
description keeps the sentence naming the mute chord.
- crates/pf-console-ui/src/screens/settings.rs — both sides grew the couch
row list. Main's seven new rows and Echo cancellation are all reachable
in Gaming Mode; the count is 22 and the rationale comment names echo
cancellation among the fields that would otherwise be unreachable there.
- crates/pf-presenter/src/run.rs — both sides added a stats test at the same
line. Both are kept.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reinstall a host, wipe its ProgramData, or otherwise regenerate its identity,
and the desktop clients refused it forever: "Host identity rejected — wrong
fingerprint, or the host requires pairing", including immediately after a
successful re-pair. There was no way out of it from the UI — the host list
showed two cards for one address and forgetting the wrong one was a guess.
`KnownHosts::upsert` matches on the FINGERPRINT, which is what lets a host that
moved address keep its record and everything the user set on it. A host that
changed identity matched nothing, so pairing appended a SECOND record for an
address that already had one, and `find_by_addr` returned whichever came first
in the file — the dead one, every time.
Trust decisions (PIN ceremony, delegated approval, TOFU accept, headless pair —
all funnelled through `persist_host`, plus the Windows shell's two direct
upserts) now go through `upsert_trusted`, which retires any OTHER record for
that address. Retired means DELETED, not demoted: a record whose certificate
the host no longer holds cannot connect, so keeping it only reproduces the two-
cards-one-address confusion this fixes. What described the box rather than the
identity — its MAC, its OS chain, the bound profile, the pinned cards, when it
was last used — moves onto the record that survives, so a reinstall doesn't
quietly cost the user their setup. What described the dead identity does not:
`paired` and `clipboard_sync` are decisions about one specific certificate and
have to be made again for a new one, and the retired record's stable id stays
retired (a deep link written from it falls through to the `host=` recovery the
link grammar already specifies).
Only trust decisions may retire a record. The wake path's address re-key and
every learn-from-advert path stay on plain `upsert`: those are driven by
unauthenticated mDNS, and letting an advert delete a saved host by claiming its
address would trade this bug for a much worse one. A plain reconnect still
fails closed on a pin mismatch — nothing here changes what the pin is checked
against.
Stores that already hold the duplicate recover on the next connect, not at
load: which of two records is live isn't knowable at load time and guessing
wrong would throw away the good one. Instead `find_by_addr` stops being
positional — a real fingerprint beats a placeholder, and among real ones the
newest trust decision wins, since records are only ever appended by one. The
next successful pair then cleans the store up for good. Every lookup that picks
a pin or a per-host decision for a connect now goes through it (the session's
pin and clipboard read, the deep-link resolver, orchestrate's plan, both speed
tests, the CLI's --wake and --library, which had also been ignoring the port),
and an advert's learned MAC/OS lands on the record it identified rather than on
a stale namesake that merely came first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Android uplink kept advancing `seq` while muted, so the first frame
after an unmute looked to the host like loss the width of the mute. The
de-jitter reads that as a gap: up to five concealment frames of stale
voice, and a seq gap counted in the uplink-health line. Past 600 ms the
pump's stale flush resets the chain first and hides it, which is why the
usual long mute looks fine — a quick toggle does not.
Freeze `seq` while muted, as the desktop uplink already does, so the
frame after an unmute continues the chain. `reset_stream` says it
plainly: a pause is not loss, and must not conceal or count a gap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ctrl+Alt+Shift+V mutes and unmutes the microphone mid-stream — V for
voice, since M and S were taken. The uplink keeps running while muted:
`MicStreamer::spawn` takes a shared AtomicBool the capture callback reads
every quantum, and a muted callback drains whole frames and sends
nothing. Stopping the stream instead would have re-primed the device
buffers and, on Linux, re-run source selection on every unmute — a
second of glitch for a key people press mid-sentence. The sequence
counter deliberately does NOT advance while muted, so the host sees one
continuous sequence with a pause rather than a gap the size of the mute,
which its de-jitter would try to conceal frame by frame (its 600 ms
stale-flush covers the rest).
The mute lives on SessionHandle as a MicControl with two flags, not one:
`live` is raised by the pump only once the uplink is actually running, so
a session with the mic off in Settings — or whose capture device wouldn't
open — reports "nothing to mute", the chord says so in the log, and no
indicator appears. Per session, never persisted.
Muted state draws as a persistent "Microphone muted" badge in the stream's
top-right corner, off `FrameCtx::mic_muted` rather than the stats text: it
has to be there with the stats overlay Off, which is where most people
leave it. The Detailed mic line still reads throughput, so it simply falls
to zero — the badge is what answers "am I muted".
Echo cancellation stops being an env-only lever. `Settings::echo_cancel`
(default on, `#[serde(default)]` so every stored file loads with it on)
now gates the same hooks PUNKTFUNK_NO_AEC gated: the echo-cancelled
PipeWire source preference and WASAPI's Communications stream category.
The env var still wins, one-way — it can only turn AEC off, never back on
— and both `aec_enabled` helpers say so. The row ships in the GTK, WinUI
and console settings, under the microphone toggle and greyed out while it
is off, matching what Apple and Android shipped in wave 1.
SettingsOverlay grows `echo_cancel` as a first-class field — apply,
absorb, clear, is_empty — instead of riding the `extra` passthrough, where
`clear_override("echo_cancel")` answered false. The JSON key is the one
Apple and Android already write, so one catalog round-trips through all
three.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wave 1 gave the Android client a mic worth using. It gave it no way to stop
talking: leaving the stream, or digging through Settings to turn the whole
feature off, were the only ways to stop the room being heard. Now a tap (or
Select + Y on a pad) mutes it, and the screen says so while it lasts.
How muting gates the capture, and why that way. The AAudio input stream is
never stopped: a stop/start would re-run the input-preset fallback ladder and
re-prime the buffers on every toggle — hundreds of milliseconds, and possibly
a landing on a different rung, silently losing the HAL echo canceller wave 1
went to some trouble to get. Instead the encode loop reads an AtomicBool per
10 ms frame and, while it is set, drains the frame out of its ring and drops
it there — the last point before it would have become an Opus packet. Nothing
is encoded, nothing is sent, and the realtime capture callback is untouched,
so its allocation-free discipline and the queue policy stay exactly as wave 1
verified them. A toggle costs one atomic store and takes effect on the next
10 ms boundary.
The frame counter keeps advancing across a mute, because it numbers the
captured 10 ms TIMELINE rather than the datagrams. The gap the host then sees
is exactly the audio that never came: its de-jitter conceals at most a few
frames of it before the pump's 600 ms stale-gap flush resets the chain
outright, which is the right reading of a mute. Encoding silence instead
would have kept a pointless uplink and a host-side ring alive for its whole
duration.
Mute is per session and nothing is persisted — a new stream always starts
unmuted, and no new setting exists. The flag lives on the session handle
rather than on the capture, so the mic stop/start a surface recreate performs
brings the user's choice back with it, with no window in which the fresh
capture could send an unmuted frame.
The control is offered on the evidence that a capture is actually running
(nativeMicActive), not on the setting: with the mic disabled, RECORD_AUDIO
denied, or every AAudio input rung refused, there is nothing on screen to
lie about. On touch it is a pill in the corner the stats HUD doesn't use —
the one in-stream control, so it sits above the gesture layer to take its own
taps — dim while live, a red "Muted" badge while it isn't. On TV that badge
is the indicator alone: Select + Y is the control there, and a focusable
button would fight the game for the D-pad. Y is deliberately not one of the
exit chord's buttons, so neither chord can be reached through the other.
One honest consequence of keeping the stream open: the platform's recording
indicator stays lit while muted, because the mic really is still open. What
stops is the encode and the send.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Until now the only way to stop sending the room was to end the session and turn
"Send microphone to the host" off in Settings — a per-app setting for something
that is really a per-conversation act. The mic now mutes from inside the stream:
a button on the HUD card, the Stream menu's ⌃⌥⇧A (macOS menu bar and iPad
hardware keyboards), the same chord while input is captured (both platforms
detect it in InputCapture, where the reserved ⌃⌥⇧Q/D/S already live — ⌃⌥⇧M is
the mouse-model flip, cross-client, so the mic gets A), and on iPhone/iPad a mic
disc beside the touch exit for the stats tiers whose HUD carries no buttons.
Muting is local and instant: it gates capture on this device, the host is never
asked and never told. The muted state gets its own badge over the stream —
independent of the stats overlay, because "am I muted?" is not a statistic and
the overlay is exactly what a player turns off. The badge is also the way back:
tapping it unmutes, which is the guaranteed path for a touch user who muted with
the overlay off.
Mute is session state and is deliberately not persisted. Every stream starts live
if the mic is enabled at all, rather than carrying a mute nobody remembers making
into a call three days later.
The mechanism is the one wave 1 built. `SessionAudio.setMicMuted` — which mutes
the voice processor's input on the combined engine and pauses the capture engine
on the split one — stays the single muting path; what changes is that it now
takes an EFFECTIVE mute the session composes from its two reasons: the user's
mute and the background keep-alive's privacy mute. Neither can clear the other,
so a user who muted before pocketing their phone comes back still muted, and
backgrounding no longer un-mutes anyone on return. It also latches the state, so
a mute made while the microphone permission prompt is still open lands on the
engine that grant creates instead of being lost.
The control is offered only where there is something to mute: the session's
resolved `micEnabled` (a profile can turn the mic on or off), a platform with an
app-accessible input (never tvOS), and a TCC grant the OS hasn't refused. Absent
rather than greyed on the HUD and the touch discs, greyed on the menu, and the
macOS start-of-stream banner only teaches ⌃⌥⇧A when the session actually sends a
microphone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cross-platform half of the gap sweep:
* Phase-locked capture reaches the DESKTOP clients (it shipped on
Apple/Android only, though the desktop presenter has the best latch
signal of all — true on-glass stamps via VK_KHR_present_wait). The
presenter's 1 Hz fold publishes a latch grid (anchor = last on-glass
instant; period = min positive present spacing, capped by the display
mode's refresh so an arrival-paced sub-panel-rate stream can't claim a
slower grid); the session pump folds every AU's arrival stamp against
it with the SHARED `phase::circular_latch` statistic and sends the
~1 Hz PhaseReport (1 ms uncertainty — reference-client parity). The
cap is advertised only when present timing is real
(`VulkanDecodeDevice::present_timing` gates `SessionParams::phase_lock`),
and the host's applied grid offset from the 0xCF tail is logged so an
on-glass run can watch the controller engage.
* `Hello::display_hdr` stops being hardcoded `None`: Windows reads the
panel's colour volume from DXGI (`IDXGIOutput6::GetDesc1`, the
`--window-pos` output else the primary, advanced-color outputs only,
gated on the HDR setting) so the host's virtual-display EDID matches
the real glass. Linux keeps the EDID defaults — no portable
Wayland/X11 query exists — and the comment now says exactly that.
* The console settings screen (the ONLY editor in Gaming Mode) learns
the rows it was missing: render scale, full chroma 4:4:4, invert
scroll, capture system shortcuts, fullscreen-on-stream, auto-wake and
the game-library toggle.
* A spec-run session's device picks (GPU adapter, speaker, microphone)
now come from the `--resolved-spec` instead of a raw Settings load —
the last store read the spec path still owed (§5), and what would
make those fields profileable.
* `session_args()` documents why the GTK/CLI spawners pass no
`--window-pos` (Wayland exposes no global coordinates to read and SDL
can't apply them).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Windows-shell parity gaps from the 2026-07-31 sweep, closed:
* The spawner goes through the shared brain: `ConnectPlan::for_target` →
`session_args()` + a written `--resolved-spec`, replacing the
hand-assembled argv that meant Windows sessions ALWAYS took the compat
path (re-resolving every setting from the stores — the drift
orchestrate.rs documents as a trap) and that any field added to the
spec was silently Windows-dead. Fullscreen now comes from the plan's
effective settings (profile-aware) instead of a caller argument, the
spec temp file is cleaned up at exit, and the reader finally parses
the `{"window":…}` line so the SPAWNER persists the match-window size
(§5) instead of the renderer's load-modify-save fallback.
* A deep link to an unpaired host runs the trust ceremony instead of
refusing: the Pair screen opens seeded with what the link CLAIMED —
name shown as claimed, fingerprint carried, and the launch/profile
surviving the detour (`Target` grew a `launch` field for exactly
this). GTK parity; a shared link was a dead end here before.
* The speed test learns the Ask tier: a bound host whose profile
INHERITS bitrate now offers both "Set as default" and "Set in
profile" instead of silently creating a profile override. The global
write (and the forwarded-controller handler) also rebase on the file
before their whole-struct saves — two more stale-snapshot writers.
* The Controllers card shows the detected-pad inventory (read-only,
with the Steam-Input-virtual note) — the fastest answer to "is my
controller even detected?", present on GTK all along.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Apple client on a loudspeaker was the primary reported echo source:
two AVAudioEngines by design (arbitrary mic/speaker pairs, two clocks)
meant no unit ever saw render and capture together, which is exactly
what setVoiceProcessingEnabled needs. With the mic enabled and the new
"Echo cancellation" toggle on (both defaults), playback and capture now
share ONE engine with the system voice processor engaged — AEC, noise
suppression and AGC — and the input node's other-audio ducking pinned
to .min with advanced ducking off, because this is a game stream, not a
call: the host's audio must never dip under the outgoing voice.
The two-engine path survives where it is the right answer: echo cancel
off, macOS sessions with a hand-picked speaker/mic UID or input channel
(the voice processor only follows the system default devices, and its
capture side is its own mono mix — a per-channel pick can't survive
it), and any machine whose voice processor refuses to engage. Every
failure falls back to a working configuration rather than silence. The
capture chain reads the input format after the processor is enabled,
so its mono/lower-rate output flows through the existing converter
unchanged; a first-run permission grant swaps the playback-only engine
for the combined one in place, reusing the same jitter ring and drain
thread; background mic muting mutes the processor's input instead of
pausing the shared engine (playback keeps running).
echo_cancel rides the full settings plumbing micEnabled has — defaults
key, effective settings, profile overlay (serialized as `echo_cancel`;
the Rust catalog carries it via unknown-key passthrough until it grows
the field), a captioned toggle beside the Microphone row, and a gamepad
settings row. tvOS behavior is untouched (playback only, as before).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The capture path carried three latencies that had nothing to do with the
network: a 2048-frame tap request (42.7 ms bursts before the first
sample could even be sliced), 20 ms Opus framing, and a mono signal
duplicated into both stereo channels because the encoder was configured
like the downlink. CoreAudio's Opus encoder takes mFramesPerPacket=480
and mChannelsPerFrame=1 just fine — probed empirically: the converter
truly emits 10 ms mono CELT packets (TOC config 30), one per 480-frame
chunk, and the stereo-shaped decoder upmixes them with the tone intact —
so the uplink now asks for 10 ms tap buffers and encodes 48 kbps mono
10 ms packets, with 960 kept only as an init-time fallback. The host
decodes any Opus frame ≤120 ms, so nothing changes on the wire's far
end.
The tap thread also stops burning cycles per callback: the mono fold and
resampler scratch buffers are allocated once (regrown only if a larger
device quantum ever arrives), and the chunk slicer walks a head index
instead of removeFirst — which memmoved the entire backlog for every
packet on a render-adjacent thread. iOS additionally asks the session
for 5 ms IO quanta at 48 kHz when the mic is on (best-effort; the
hardware decides).
Verified: swift build (macOS arm64), swift test OpusCodecTests +
AudioChannelFoldTests, and an iOS arm64 cross-build; the 480/mono
behavior confirmed by TOC inspection on macOS 15.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The capture stream opened under AAudio's default VoiceRecognition input
preset, which deliberately bypasses the HAL's acoustic echo canceller —
so a phone playing the game audio out of its own speaker fed that audio
straight back to the host. Two layers fix it, both behind a new "Echo
cancellation" setting (default ON, next to the Microphone toggle in the
touch and console settings, per-profile like every tier-P setting):
- Native: the mic opens under the VoiceCommunication preset (HAL AEC/NS
on the capture path) and allocates an audio session id. The open
ladder is Exclusive+voice → Shared+voice → Exclusive → Shared — some
HALs refuse the preset or a session id outright, and a mic without
echo cancellation still beats no mic; the last rungs are exactly the
preset-less open this always did.
- Kotlin backstop: nativeStartMic now returns the allocated session id
(0 = none), and StreamScreen hangs the Java AcousticEchoCanceler +
NoiseSuppressor off it (guarded by isAvailable), releasing them on
every mic-stop path — the surface teardown and the final dispose — so
a surface recreate re-attaches instead of leaking effect engines.
The playback stream is untouched: retagging it voice/communication
would route it through the phone-call chain and regress quality.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Latency, three ways, all inside mic.rs:
- 48 kHz stereo 20 ms becomes mono 10 ms: speech gains nothing from a
second channel, the shorter frame shaves a buffering interval off the
uplink, and the host already decodes any Opus frame <= 120 ms with its
stereo decoder (mono packets upmix) — no protocol change. The encoder
follows: 48 kbps, complexity 5, in-band FEC at an assumed 10% loss so
a dropped datagram reconstructs from its successor instead of a hole.
- The latency ratchet is gone: the capture callback drops the NEWEST
chunk when the hand-off channel fills, so an encode-side stall used to
convert into standing mic delay that never drained. The encode loop
now drains the whole backlog in one lump and, past ~60 ms, jumps to
the newest ~20 ms (one audible blip, live again), counting what it
shed in the periodic log line. The realtime callback stays exactly as
allocation-free as it was.
- The encode thread registers with the client's hot-thread set, so the
ADPF session keeps mic encode on a fast core alongside audio decode.
No .frames_per_data_callback() pin: AAudio's own docs say leaving it
unset is the lowest-latency path (the callback runs at the device's
optimal burst), and the encode side re-chunks to 10 ms frames anyway.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rest of the client-gaps handoff, plus what a parity sweep surfaced:
* Every whole-file settings save rebases on the file first — the GTK
dialog close and its two speed-test apply arms, the Windows
per-control commit, the console screen. The file has five writers and
no merge (profiles.rs documents the debt); saving a shell-lifetime
snapshot silently reverted whatever another writer stored meanwhile,
most visibly the spawner-persisted match-window size (item 4).
* Windows honors the Speaker/Microphone picks: `audio_wasapi` grows
endpoint enumeration (on its own MTA thread — UI threads are STA) and
resolves PUNKTFUNK_AUDIO_SINK/SOURCE as endpoint ids, falling back to
the default when the picked device is gone; the settings page gets
the two rows (defaults scope, "(not detected)" like the GPU row).
`speaker_device`/`mic_device` stop being Linux-only fields (item 5).
* A punktfunk:// link with `launch=` opened a plain desktop session on
Windows — the id was parsed, validated, planned, and dropped at the
last hop. It now rides `initiate_launch` (plus a waking variant for
the dial-first path). Linux always forwarded it.
* The console UI offered "VAAPI" on Windows — a dead option there that
also hid d3d11va, the actual Windows hardware path. The decoder list
is per-OS now.
* The Windows gamepad picker learns "Steam Deck" (the GTK picker had
it; the host-side pad has always existed).
* Doc fixes: gpu.rs named a nonexistent env var (PUNKTFUNK_ADAPTER →
PUNKTFUNK_VK_ADAPTER), and session/Cargo.toml claimed video decode is
Linux-only while explaining what the ARM64 leg drops.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two halves of the same honesty problem (client-gaps handoff items 1+2,
done together as the handoff asked).
The presenter learns full chroma: `vkframe_plane_views` accepts the
2-plane 4:4:4 pool formats (8-bit, and the 10-bit 3PACK16 sibling) —
what NVIDIA's Vulkan Video reports for HEVC RExt decode — with the
accepted set extracted into `vkframe_plane_formats` and pinned by a
decision-table test. The CSC shader needed nothing: its 4:2:0 siting
correction already self-disables when the plane widths match. The VAAPI
leg gets the same treatment (NV24 in `drm_fourcc_for` and the dmabuf
import, full-size chroma plane), and the Vulkan decoder's sw-format
gate admits NV24/P410. 3-plane 4:4:4 stays rejected — it needs a third
CSC binding — and demotes cleanly like every other unsupported format.
Design call (the handoff's fork, argued here as requested): (B)+(C),
not (A). No capability probe gates VIDEO_CAP_444 — software decode is
the guaranteed display floor on both OSes (swscale → RGBA), the decoder
ladder demotes on its own, and a probe-gated bit would turn the switch
inert on exactly the boxes that rely on the fallback. What (A) wanted
from a prediction, the overlay now delivers as ground truth:
The Detailed tier prints the encoder's target next to the measured
rate — `19.4 Mb/s · target 20 Mb/s (auto)` — and the resolved chroma,
`4:4:4→4:2:0` when the host declined the ask (mirroring `HDR→SDR`).
The target is live: `NativeClient::current_bitrate_kbps()` mirrors
every BitrateChanged ack, so an Automatic session's ABR re-targets are
visible as they move. This is the figure whose absence let the
settings-drop bug (9c5af8d7) ship four releases — measured goodput
alone cannot distinguish "the encoder is capped at 20" from "my
200 Mb/s grant met a cheap scene".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Since spec mode (`f32c3aaa`, shipped in 0.22.0) the GTK shell has handed the session
a `--resolved-spec` built from `Settings::default()`. A session running from a spec
performs ZERO store reads by design, so every stream since has run at the defaults:
`bitrate_kbps: 0` reaches the host, which reads it as its 20 Mbps fallback — the
field report this starts from, "my bitrate seems to be stuck at 20 MBPS" on 0.22.3 —
and with it the resolution, refresh, render scale, codec, decoder, HDR, 4:4:4, audio
channels, mic, touch/mouse mode, invert scroll, stats tier, match-window and gamepad
type. Profiles never applied at all: the spec's `profile` was None, and spec mode
ignores `--profile`. The tell in the wild is that the GPU and audio-device pickers
kept working — the session reads those three off disk into env vars before it reads
the spec.
The plan carried a comment explaining that `settings` "carries only what the argv
needs (the fullscreen flag)". That was true when it was written and stopped being
true one commit later, in another file. `ConnectPlan::for_target` answers that shape
of bug: a front-end holding its own request type resolves through the same helper the
session's compat path uses, so there is no hand-built `Settings` left to go stale.
The GTK shell's `fullscreen_on_stream` parameter goes with it — that is a tier-P
field, and a shell-read global was beating a profile that set it. The Windows shell
was never affected (it spawns with no spec) but had the same fullscreen-vs-profile
bug, so it moves onto the resolver too.
The audit that followed found four more settings stored, rendered in two UIs, and
read by nothing:
- `enable_444` never became `VIDEO_CAP_444`. "Full chroma (4:4:4)" is announced in
the 0.22.0 notes for Linux and Windows and did nothing on either; only Apple
advertised the bit. The host already gates it on its own policy, its capturer,
HEVC and a real GPU probe, and answers the resolved chroma in the Welcome before
we build a decoder — the client only has to ask. There is no desktop decode probe
(Apple has `Stage444Probe`), but the presenter bails cleanly on a plane format it
doesn't know and demotes to software, so the downside is a slow decode, not a
broken one.
- `session_params` picked the HEIGHT fallback off `settings.width == 0`.
- `cli::exec_session` dropped `--profile` from its forward list, so
`punktfunk --connect … --profile Work` from a script or a Decky wrapper streamed
with the host's binding instead.
- `ResolvedSpec::write_temp` named the spec by the SPAWNER's pid alone, so a
cancelled connect and the retry behind it shared one path — and the first child's
exit deleted the file the second was still starting up to read.
The regression test asserts the plan carries the profile-overlaid bitrate and that
the spec equals it. On the old code both are 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toggle has been stored, profileable and rendered in two settings UIs since
profiles landed, and nothing read it. Windows grabbed the keyboard whenever input
was captured, setting or no setting; Linux never grabbed at all, because the grab
sat behind `#[cfg(windows)]` with a comment deferring the compositor story to "the
shells" — which never picked it up.
`Settings.inhibit_shortcuts` now reaches the presenter and gates the grab, on both
platforms. SDL3 already maps `SDL_SetWindowKeyboardGrab` onto
`zwp_keyboard_shortcuts_inhibit_manager_v1` on Wayland and `XGrabKeyboard` (plus
`_XWAYLAND_MAY_GRAB_KEYBOARD`) on X11, so dropping the cfg is the Linux fix.
Capture state still gates it, so releasing input hands the chords straight back,
and the desktop mouse model never grabs. A compositor with no shortcuts-inhibit
global says so once instead of failing silently — at debug under gamescope, which
has no shortcuts to inhibit in the first place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UIKit delivers Pencil events at the PANEL's cadence, and the deadline
link's rate hint pins the panel to the stream rate (min = preferred =
stream Hz, deliberately — the anti-idle-stall floor). Net effect: a 60 fps
stream on a 120 Hz iPad silently halved pencil sampling, 8.3 → 16.7 ms per
event batch — exactly the workload (drawing) that feels it most.
FrameRateHint gains a boost that lifts min/preferred to the range ceiling
while a Pencil is in range (hover or contact): PencilStream surfaces
proximity transitions from its emit() choke point, the view controller
debounces release by 2 s (edge-of-canvas hover flicker must not thrash the
link), and Stage2Pipeline/SessionPresenter stage it like the rate hint —
applied from the link's own thread, no-op under arrival/glass pacing.
Presents still pace at stream rate (extra updates vend into the
newest-wins stash), so the cost is empty link wakes scoped to proximity.
pf.present logs engage/release for on-glass verification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase-locked capture had no client half on Apple — the host's v3 controller
(grid-locked submits, coherence-gated engage) shipped with only the Android
reporter feeding it. Now the stage-4 link thread flushes the same v2
circular arrival-phase statistic at ~1 Hz:
- PhaseReporter (Stage2Pipeline): the decode callback deposits per-AU
reassembly-completion stamps, the CAMetalDisplayLink update deposits the
latch grid (period = window-min of update spacing), and the flush ports
punktfunk_core::phase::circular_latch verbatim — a period-smeared Wi-Fi
link reads coherence ≈ 0 and the host correctly never engages; a wired
link opens the gate. Binds/unbinds per session like DecodeReport.
- PunktfunkConnection.reportPhase wraps the existing ABI entry point.
- Hello honesty: iOS/tvOS advertise CLIENT_CAP_PHASE_LOCK (macOS stays
without — the stage-2 arrival presenter has no latch grid), Android now
sets the bit its reporter already earned, and the ABI grows the
PUNKTFUNK_CLIENT_CAP_PHASE_LOCK mirror const (header regenerated).
Advisory in v1: the host arms on report receipt.
- Stage-3 doc comment no longer calls itself the tvOS default (stage-4 took
that over in the 2026-07 rebuild).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three drawing-path defects on the pen clients:
- The Apple in-range heartbeat was re-armed on every emit (~120 run-loop
mutations/s during a stroke) and lived in .default run-loop mode only — a
tracking-mode excursion >200 ms with a stationary pen crossed the host's
force-release failsafe and dropped the held stroke. Now one long-lived
50 ms timer in .common mode, resending only after ≥50 ms of send silence.
- Both clients TRUNCATED an over-8-sample coalesced run instead of splitting
it into consecutive batches (the send_pen contract): Apple suffix(8)
dropped the oldest samples, the Android JNI clamped count. An over-cap run
means the UI thread hitched — exactly when dropping stroke geometry hurts
most. Apple splits in emit(); the Android JNI loops send_pen over ≤8-sample
chunks (Kotlin's per-emit ceiling is now 64 = a >250 ms stall).
- The iOS letterbox mapper did a lock+FFI currentMode() read per coalesced
sample on the main thread, contending the ABI lock the batch send takes
next — now a 250 ms TTL cache (mid-stream requestMode still tracked).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 2.5 ms submit margin ahead of expected present assumed
SurfaceFlinger latches well before the vsync. On-glass (NP3, 120 Hz)
that lead is not needed: with the margin at zero every release still
made its vsync (paced=0 across full sessions) - meaning the constant was
2.5 ms of pure display latency on every frame, and it was quantizing
away exactly the readiness gains the slice-overlap pipeline just bought.
e2e on the Linux-host pairing dropped from 22-24 ms to 14-18 ms.
The margin is now adaptive instead of assumed: it starts at 0 and only
widens (+500 us per 1 Hz window, capped at the old 2.5 ms) when the
paced counter shows real latch misses - one-way per stream, so a margin
that proved necessary is never re-gambled mid-session. A device that
needs lead converges within seconds; a device that doesn't keeps the
full win. debug.punktfunk.latch_margin_us (0..=8000) pins the margin
for rebuild-free sweeps, and the presenter logs the resolved mode at
stream start.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The decode loop used to hold every access unit until its last wire
packet landed. With slice-progressive delivery (frame_parts) each newly
contiguous piece now goes straight into MediaCodec under
BUFFER_FLAG_PARTIAL_FRAME, the closing piece drops the flag, and the
decoder chews the front of the frame while its tail is still on the
wire.
Feeding partial input means owning its failure modes without a codec
flush: a broken sequence (dropped piece, orphan, oversize) closes the
dead AU with an empty non-partial buffer at its own pts, arms the
re-anchor freeze so the concealed output never reaches glass, and
requests a recovery keyframe - the same machinery ordinary loss already
rides. Per-AU accounting keeps its units: the RFI gap detector notes an
AU once, and the HUD stamps, host/network split and the phase-lock
arrival sensor ride only the completing delivery.
The opt-in is decoder truth and loop truth: every decoder this device
would use must pass FEATURE_PartialFrame, and only the async loop may
see parts - the legacy sync loop feeds whole AUs and stays that way.
Smoke-checked on-glass (NP3 vs the Windows host): byte-identical
degenerate path, presenter profile unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Apple and Android apps carry their own copy of THIRD-PARTY-NOTICES.txt
and show it on the acknowledgements screen. `7fc2775f` refreshed the root
file but not the copies — running the python generator directly skips the
sync `scripts/gen-third-party-notices.sh` does — so they had drifted to a
531-crate snapshot that predates the whole vendored-source section.
That section is where the OS marks' attribution lives, so both apps have
been shipping Font Awesome's CC BY 4.0 icons with no attribution visible
at all. Re-synced, which also carries the Bazzite Apache-2.0 notice the
previous commit adds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things were wrong with the host-card OS icons.
The Windows mark was Font Awesome 5's, which is still the Windows 8/10 flag
with the perspective skew — dated next to the flat four-pane mark Microsoft
has shipped since Windows 11. No icon set has the current one (Simple Icons
carries no windows/microsoft slug at all), so it is drawn here: four equal
squares at the authentic 11.377 + 1.246 proportion. The Decky plugin was
pulling FaWindows straight from react-icons, so it now inlines the masters
like the web console does, or it would have kept the old flag regardless.
Bazzite, CachyOS and Nobara collapsed onto their family's mark. The host
already advertises the full chain, so this is purely missing art: all three
now ship a leaf mark, because "a Bazzite box" and "a Fedora box" are
different machines to the person reading the card. CachyOS and Nobara come
from Simple Icons; Bazzite has no icon anywhere, so its "b" is lifted out of
the project's own badge (Apache-2.0, attributed).
On Android every non-square mark was stretched. A VectorPainter maps the
viewport onto the ImageVector's default size with independent x and y
scales, so declaring a 448x512 Tux as 24x24 dp squashed it — silently, no
crash, no warning. The longest viewport edge now sets the 24 dp box and the
other follows the ratio, which is what Icon()'s ContentScale.Fit expects.
A unit test pins the invariant; every other client was already correct.
Also: scripts/gen-os-icons.sh replaces the undocumented hand-run pipeline
that turns a master into the GTK symbolic SVG, the Windows PNG and the Apple
template PDF. It reproduces the committed artifacts byte-identically.
Verified: web and Decky typecheck, Decky bundles, Android compiles and its
tests pass, PunktfunkKit builds, osinfo's tests pass. Not verified on glass,
and the GTK/Windows client crates do not build on macOS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Delivery used to be all-or-nothing: the decoder saw byte 0 only after the
last packet of the AU landed, so the whole transmit time sat in front of
decode. With the slice-streamed wire (previous commit) blocks now arrive
addressable, and a client can opt in (connect's new frame_parts) to
receive each AU's newly-contiguous prefix as Frame::part pieces - offset
tiling, first/last marked, the completing push carrying only the suffix.
A PARTIAL_FRAME-capable decoder then chews slices concurrently with the
remaining network transfer.
The reassembler walks a per-frame cursor over successfully-completed
blocks (failed FEC reconstructs don't advance it), coalesces blocks that
finished out of order into one part, keeps probe filler whole, and stops
short of the final block so the zero-padded tail still trims at
completion. Whole-frame consumers see byte-identical behavior - parts
never flow without the opt-in, and never on PyroWave (its newest-wins
draining assumes whole AUs).
Per-AU accounting keeps its units: OWD/ABR feeds, the inter-arrival
series and the clock-based jump-to-live detector only count completing
deliveries, and FrameChannel::depth() counts AUs so a part-rich queue
can't trip jump-to-live at a fraction of the real backlog. The consumer
contract (gap or orphan part = AU lost: abandon, flush, resync on the
next first) is documented on FramePart; the C ABI keeps parts off until
PunktfunkFrame can express them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The P0 gating branch deliberately left Android off VIDEO_CAP_MULTI_SLICE
("embedder-set decoder truth") — this is that deferred item, and the P2
slice-pipeline prerequisite. VideoDecoders.multiSliceTolerant() probes the
pick for every advertised codec: tolerant unless any pick is an Amlogic
decoder (the 0.17.0 device-rebooting wedge) or uninspectable (a null pick =
platform default → conservatively single-slice). nativeConnect carries the
bit; the JNI ORs it into the Hello's video_caps beside the panel-truth
HDR bits. Phones (Qualcomm/Exynos) advertise it; the Amlogic TV boxes the
gate exists for never will.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Midday on-glass falsified v2's remaining assumptions: a per-frame additive
hold SATURATES the arrival-slaved loop once hold + work >= interval — submits
free-run at their own cadence and the commanded phase dissolves (measured:
+-2 ms hold steps, zero client response) — and the latch statistic hovers at
the target's ANTIPODE, where shortest-way errors sign-flip on noise (the
0<->2<->4 ms offset chatter).
v3, one change per falsified assumption:
- ACTUATOR: submits lock to an absolute grid (epoch + k x period + offset);
the controller walks only the offset. A periodic grid cannot free-run —
occupancy is one frame per period whatever the offset — so phase actuation
is linear BY CONSTRUCTION. Disengaged = no grid sleeps = zero cost; every
failure path DISENGAGES (never parks: v2's e2e-tax lesson).
- SENSOR: the client reports the circular mean + coherence of the ARRIVAL
lead (per-AU reassembly stamps vs the panel latch grid) — the phase the
host actually controls; latch measured downstream of the decoder pipeline,
which absorbed the actuation. pf.phase logs "arrival lead circ= coh=".
- ANTIPODE DAMPING: errors within 1 ms of +-period/2 take half-steps until
they commit to a side.
Harness rewritten around plants glass validated: a GRID plant (linear by
construction), the DECOUPLED plant (v2's saturation — must disengage, not
orbit or park), an antipode start (must converge within one period of
travel), incoherence (never engages), a regime change (re-engages after
backoff), and the actuator's own periodicity/offset-linearity. Also fixes
the harness's SIM_TARGET (a mis-derived max asserted 3.5 ms where the
controller's actual target is 2.5 — the controller was right, the ruler
was wrong; the Python cross-check inherited the same constant, a lesson in
replicating the CODE, not the author's belief about it).
Gates: v3 simulations 6/6; host bin suite 345/1 (the documented
environmental qemu UDP test); clippy --all-targets -D warnings clean;
cargo ndk arm64 check clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The controller iterated twice on the user's gaming PC before this existed;
never again. punktfunk_core::phase::circular_latch is extracted from the
Android presenter (shared: iOS reports next, and the harness generates its
synthetic reports through the IDENTICAL statistic the clients ship), with
unit tests including the wrap-straddling cluster an arithmetic mean gets
maximally wrong.
The stream.rs harness models the plant — latch = (base − hold + noise)
mod P, deterministic LCG noise — and drives PhaseController::adjust through
five regimes:
- tight jitter locks into the deadband within ~5 adjusts and stops moving;
- a wrap-side start takes the SHORTEST way (travel < P/2 asserted);
- an incoherent phase never steps (zero hold, ever);
- a v1 pinned-median report trips the travel budget and DECAYS TO ZERO —
the 2026-07-31 orbit and the parked-hold e2e tax, both as asserts;
- a post-decay regime tightening re-locks.
Gates: host bin suite 344 passed / 1 failed = the documented environmental
qemu UDP-loopback test (clean main fails identically in this container);
core phase tests 4/4; clippy --all-targets -D warnings clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The v2 client edit missed its anchor against rustfmt's reshaping and the
reporter kept sending the v1 median — caught by the arm64 build the moment
the tuple signature landed. Sends (circular mean, coherence) now.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-1 on-glass falsified two v1 assumptions in one morning (NP3 ↔ .173):
- A median lead is immovable under period-spanning jitter (uniform mod P),
so v1 orbited the period at 2 ms/s forever — and the dead-signal freeze
hotfix never armed, because ±1.5 ms of window sampling noise kept
resetting its 'error responded' check. Thresholds can't referee noise.
- A HELD hold is not free: it delays sampling after the capture stamp,
taxing e2e by up to a period (user-measured: ~+4 ms during the orbit).
The correct failure response is DECAY TO ZERO, never freeze-in-place.
v2, all three layers:
- Wire: PhaseReport grows a length-discriminated coherence tail (27-byte
form; the u16::MAX sentinel encodes as the byte-identical 25-byte v1 —
strict-prefix discipline, both forms pinned by tests).
- Client: the reporter sends the CIRCULAR vector-mean latch phase mod the
panel period + its coherence (‰); pf.present logs circ=/coh=.
- Host: steps only while coherent (floor 300‰; a v1 report bypasses the
gate), along the signed SHORTEST way around the period, under a
cumulative 1.25-period travel budget that is noise-immune by construction
(it integrates applied steps, not reported errors); incoherence or budget
exhaustion decays the hold to zero and re-arms once flat. Deadband
convergence resets the budget — tight regimes lock exactly as designed.
report_phase + the C ABI mirror gain the coherence parameter (canary-only
ABI, hours old, sole caller in-tree). Gates: docker amd64 clippy
--all-targets -D warnings (host+core, nvenc) clean; core suite 244/244;
cargo ndk arm64 check clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The host's applied hold rides the 0xCF tail but nothing client-side showed
it; 'adb logcat -s pf.phase' now logs transitions. This line is what exposed
the dead-median controller orbit on the first on-glass run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The QAM already offered a client update — for the flatpak, by comparing OSTree
commits. A native client (.deb, .rpm, pacman, sysext, nix, a source build), which
the plugin has been able to LAUNCH since it learned to drive one, went stale in
silence: no version, no check, no mention.
check_update now branches on the client the launcher will actually run. The
flatpak keeps its commit comparison, which is the exact one — a flatpak built
from main between releases carries the release's crate version, so a
version-based check would call it current when it isn't. Everything else asks
the client itself, which verifies the signed manifest. update_client routes the
same way: flatpak update --user, or `punktfunk-client --apply-update`.
Where nothing on the box can install it, the plugin says so instead of offering
a button. The QAM banner becomes the command, the About tab names the install
kind so "why is there no Update button?" has a visible answer, and a check that
FAILED reads as a failure — never as "up to date".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`punktfunk-client --check-update` fetches the same Ed25519-signed per-channel
manifest the host trusts, works out how this client was installed, and reports
what — if anything — can be done about a newer build. `--apply-update` does the
part it owns. `--version` prints the CI-stamped build string, which is both what
the comparison needs and what a packaging gate can run the binary for.
The engine lives here rather than in the Decky plugin for two reasons: verifying
a signature needs crypto that Decky's embedded Python does not have, and the
plugin is not the only surface that wants the answer. Every UI becomes a caller,
exactly as it already is for --pair, --library and --list-hosts.
What is possible depends on the install, and the module refuses to pretend
otherwise. A flatpak is a per-user install its launcher can update. A .deb/.rpm/
pacman client goes through the packaged root helper — a fixed, parameterless
systemd oneshot polkit authorises for members of the punktfunk-update group, so
the request carries nothing an attacker could influence and the payload comes
from the distro's own signed repositories. A sysext, a nix profile or a source
build gets the command and nothing else: the signed sysext feed carries the HOST
image, so a client sysext has no feed to update from, and a button that can only
fail is worse than one honest line.
A failed check reports the failure. "Could not tell" rendering as "up to date"
is the one answer that must never happen.
The box's capabilities are probed once into a `Caps` struct so the routing rules
are a pure function of (install kind, caps) — all seven of them are tested
without a box, including that the kill switch beats every kind and that pacman
needs its own root-owned full-sysupgrade opt-in on top of the group.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The host's capture tick and a client's panel vsync are independent ~120 Hz
oscillators; their drifting phase sweeps every frame's wait-for-latch across
a full refresh period (measured on-glass: latch p50 oscillating 5.4-8.9 ms
with a fixed margin) — the beat is the residual judder and the fat p95, and
no client can fix it alone. Design: punktfunk-planning
design/phase-locked-capture.md.
Protocol (punktfunk-core):
- PhaseReport (control 0x32, next to the clock family): the client's next
display latch ALREADY CONVERTED to host clock (the skew offset lives only
client-side), panel period, uncertainty, and the measured median
arrival-lead — the controller's error signal. ~1 Hz, latest-wins.
CLIENT_CAP_PHASE_LOCK advertises it; CtrlRequest::Phase + report_phase()
+ the C ABI mirror carry it.
- The 0xCF host-timing tail grows a phase ACK (applied_phase_ns, 29-byte
form) under the same strict-prefix append discipline — old readers parse
the shorter forms; degradation pinned by tests.
Host engine (arrival-slaved loop — no backend can move the source vsync,
per the tick-ownership audit in the design doc):
- PhaseCtl bridges control task → encode loop (the fec_target pattern,
multi-field). PhaseController walks a per-frame HOLD before submit toward
the client's reported lead hitting target = max(2.5 ms, uncertainty+1ms):
1 Hz adjust, 2 ms max step, 300 µs deadband, period-wrapping (the
newest-wins capture slot makes a wrapped hold sample fresher content, not
staler). A loop local, so every mid-stream rebuild keeps the lock; a new
session re-acquires. PUNKTFUNK_PHASE_LOCK=0 disarms.
Android reporter: the presenter's 1 Hz pf.present flush returns the
window's measured latch p50; the async loop converts the vsync clock's next
timeline monotonic→realtime→host and reports. Inert toward old hosts.
Gates: docker amd64 clippy --all-targets -D warnings (host+core, nvenc)
clean; core suite incl. the new wire tests; cargo ndk arm64 check/clippy
clean. On-glass A/B vs the .173 host owed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each ms of submit-margin is a ms on every frame's display stage; SF's latch
runs ~1-2 ms before present and the release is a sub-ms binder call, so 4 ms
was padded. Measured (A024, 120 Hz game load): latch p50 8-10 → 5.4-8.9
(phase-drift dependent), paced stays 1-5/s. A device that misses at this
margin shows it as a paced-counter rise, not stutter — the widen signal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
The three Play Console pre-release findings for 0.22.3, resolved:
- Orientation restriction: the in-stream SENSOR_LANDSCAPE lock is now applied
on compact devices (sw < 600 dp) only. On tablets/foldables/desktop windows
it is a large-display anti-pattern (Android 16+ ignores it there outright)
and unnecessary — the aspect-ratio letterbox renders correctly in any
orientation; the lock was always a phone-ergonomics choice. No manifest
restriction existed, and resizeability stays unrestricted.
- Edge-to-edge determinism: the stream window now sets
LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS explicitly (and restores it on the way
out) — SDK-35 enforcement makes that the immersive default, pre-15 devices
letterboxed the notch as a dead bar; being explicit gives both the same,
correct behaviour. The stream's own letterbox is black, so the cutout region
can never show anything wrong.
- Deprecated edge-to-edge APIs: audited — no in-app use of
setStatusBarColor/setNavigationBarColor/systemUiVisibility/translucent
flags or theme attrs; enableEdgeToEdge (androidx.activity 1.13.0) is already
in place with explicit SystemBarStyle. What the scanner sees are androidx's
own API-level-guarded compat branches, which Google documents as ignorable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three quick latency wins for phones, ahead of the presenter rebuild:
- setStreamDisplayMode: the window-level preferredDisplayModeId is pinned to
the stream's refresh (exact rate, else the smallest integer multiple, else
the highest available) for the session. The surface-level frame-rate hint
alone is advisory and some OEM refresh governors (Nothing OS's LTPO logic
among them) ignore it for third-party apps — leaving a 120 Hz session
presenting on a 60/90 Hz panel. nativeVideoSize gained a trailing
refreshHz element for this (old readers index only 0/1). TV keeps the
native HDMI mode switch instead.
- The surface hint itself now passes compatibility = FIXED_SOURCE on every
form factor: the stream is fixed-rate video the client cannot re-pace;
DEFAULT invited governors to not switch.
- requestUnbufferedDispatch(SOURCE_CLASS_POINTER) on the hosting view while
streaming: touch/pointer events were vsync-batched — up to a frame of
input latency the stream shouldn't pay.
- The HUD polls the panel's live refresh each second and flags '⚠ panel N Hz'
when it sits below the stream rate, so an unpinned panel is visible instead
of reading as inexplicable judder. Stale nativeStartVideo kdoc (low-latency
'off, the default') corrected — it defaults ON under low_latency_mode_v2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report: since 0.17.0 a stream to a Chromecast with Google TV 4K
freezes on the first frame and ~80% of the time crashes + reboots the
DEVICE — with both the Punktfunk app and Moonlight, while an Xbox
Series S is fine. Root cause: LN1 Phase 3 (67b79810) defaulted Linux
direct-NVENC to 4 slices per frame for EVERY session. Amlogic HEVC
decoders wedge on multi-slice AUs — exactly why moonlight-android
requests slicesPerFrame=1 for every hardware decoder (4 only for
software slice-threading) — and our RTSP parser never read the request.
The Phase-3 commit recorded the untested leg ("a live Moonlight re-test
joins the standing owed Moonlight item"); this report is that re-test.
The slicing ceiling now belongs to the CLIENT, threaded as open_video's
new max_slices from both planes:
- GameStream: parse x-nv-video[0].videoEncoderSlicesPerFrame into
StreamConfig and honor it; absent/out-of-range (pre-auth input) => 1.
- punktfunk/1: new Hello cap VIDEO_CAP_MULTI_SLICE (0x80 — the byte's
LAST free bit; the next cap needs a second byte + ABI bump).
SessionPlan.max_slices = 32 with the bit, 1 without, applied to every
encoder the plan opens so rebuilds can't change the wire shape. The
desktop session client advertises it (FFmpeg/D3D11VA/Vulkan decode
stacks are fine); Android/Apple stay off until they can decide
per-decoder like Moonlight does — the cap is embedder-set decoder
truth, never OR'd in by the shared pump.
- Linux direct-NVENC clamps its Phase-3 default to the ceiling
(resolve_slices(codec, 4.min(max_slices))) and logs slices/max_slices
in the caps-probe line; PUNKTFUNK_NVENC_SLICES stays the explicit
operator override in both directions. Windows keeps its single-slice
default untouched.
Also repairs the nvenc_cuda #[ignore] hardware tests: d2c46eaf added
open()'s cursor_blend param without updating them, invisible because CI
never compiles tests with the nvenc feature.
Verified on .21 (RTX 5070 Ti): pf-encode + host check/clippy clean with
nvenc; host unit suite 263/0; rtsp announce tests 7/7 incl. the new
slicesPerFrame coverage; on-hardware smokes — default e2e still 4
chunks/frame, NEW single-slice client-ceiling test clamps + disarms
chunked poll with no env involved, env escape unchanged. rustfmt clean.
Windows leg (prepare_display param) is CI-only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-glass feedback: burying the grant in the Controllers screen made the
user go find it. Now MainActivity asks the moment a Sony pad appears — a
fresh attach while the app is open, or the app foregrounding with one
already plugged in — once per attach (a deny doesn't re-nag; the
Controllers card's button stays as the re-ask). Nothing starts on the
grant: an uncaptured pad is an ordinary InputDevice at menu time, so the
grant is simply recorded and the next stream's capture engages silently.
The grant broadcast is shared with the Controllers card so an open card
refreshes from either dialog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A DualSense on a phone had rumble only where the kernel exposed force
feedback, and adaptive triggers / lightbar / player LEDs nowhere — Android
has no platform API for any of them, and Bluetooth offers no raw path
(L2CAP is LE-only; hidraw is root-sealed — Sony's own Remote Play declares
Android triggers unsupported). Claiming the pad's HID interface over USB is
the one unrooted route, so that is what the client now does.
- HidUsbLink: the device-agnostic half of Sc2UsbLink (claim, multiplexed
UsbRequest loop, newest-wins write queue, signalled-unplug discipline),
parameterized by device match / interface filter / keep-alive. Sc2UsbLink
keeps only its SC2 specifics (Puck interfaces 2..5, lizard refresh).
- GamepadFeedback.PadFeedbackSink: 0xCA rumble + 0xCD Led/PlayerLeds/
Trigger now route to a capture link that owns the pad BEFORE the
InputDevice vibrator/lights paths — Trigger stops being log-and-drop.
- DsDevice: the byte-exact inverse of the host's dualsense_proto /
dualshock4_proto — input report 0x01 parse (buttons/sticks/triggers,
gyro+accel, both touch points; Edge FN/BACK → wire paddles) and output
builders (DS5 0x02 valid-flag-selective incl. the 11-byte trigger blocks
and the lightbar-animation release; DS4 0x05 as composed full-state
writes). Covered by DsDeviceTest (pure JVM).
- DsCapture: stream-mode capture for DualSense / Edge / DS4 — lazy wire
slot on the first parsed report, typed mirror (exit chord included),
touch normalized onto the rich plane + per-report motion, feedback
rendering with a rumble backstop (a USB pad holds its level, so a
stalled poll thread self-terminates via a scheduled zero-write) and a
teardown motor-stop over EP0. The claim releases the pad's InputDevice
slot itself so the wire index hands over deterministically; uncaptured
(toggle off / permission denied / Bluetooth) the pad stays on the
ordinary InputDevice path.
- Rich-input shims: nativeSendPadTouch / nativeSendPadMotion →
RichInput::Touchpad / Motion — the plane the desktop and Apple clients
already feed; Android pads gain gyro + touchpad on the virtual pad.
- Settings: "DualSense / DualShock passthrough (USB)" (ds_capture, opt-out
like the SC2 toggle); Controllers screen card with capture status and a
front-loaded USB grant so streams start without the permission dialog.
The host needs nothing: the DS5/DS4 backends already consume the typed +
rich planes and already emit every feedback event rendered here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 0.22.0 clobber proved a gap no build gate covers: a wrong program wearing the
right binary name compiles green. `cargo build -p punktfunk-client-session` happily
shipped the GTK shell's three-line Windows stub as punktfunk-session, because nothing
between commit and release ever EXECUTED the result.
Two integration tests close the class, and they run under gates that already exist
(ci.yml's workspace test on Linux, windows.yml's test step on Windows):
- contract_smoke spawns the real punktfunk-session against a refusing port and
asserts the stdout contract answers — whatever fails first on the machine
(presenter init headless, the dial elsewhere), the binary must SAY so in a
contract line. Proven non-vacuous by planting the 0.22.0 stub and watching it
fail, then pass again on the real main.rs.
- cli_smoke runs the real punktfunk over its help surface (stdout, exit 0) and an
unknown verb (stderr, exit 5) — store-free and network-free, safe on any runner.
windows.yml now gates punktfunk-cli in all four steps (build, clippy, fmt, test): the
MSIX has shipped its `punktfunk.exe` alias since bf981027, but only the release
workflow ever compiled it — a PR could break the CLI and find out on tag day.
The session README also stops selling `--pair` as the enrolment route (deprecated by
bf981027, `punktfunk pair` is the door) and says out loud what this binary is: a
deliberately dumb renderer the GTK shell, the WinUI shell and the CLI all call into
through one brain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`punktfunk help` was one usage blob, and `punktfunk pair --help` was worse than
nothing: the flag fell through to the verb, which read "--help" as its subject,
printed a terse usage line to stderr and exited 5. For the command that is about to
be the documented door for scripts and plugins (Playnite shells to `library --json`),
"self-documenting" has to actually hold.
Now `punktfunk help <command>` — and `--help`/`-h` after any verb, caught BEFORE
dispatch so no verb can mistake the flag for its subject — prints that command's own
page: flags, what lands on stdout vs stderr, and which exit code means what, which is
the part a script author actually needs. Help goes to stdout and exits 0; an unknown
topic refuses with 5. A unit test walks USAGE and asserts every advertised verb has a
help page that leads with its own invocation, so the overview and the pages cannot
drift apart, and an integration test runs the REAL binary over both spellings.
`reachable` also stops scolding: probing an unsaved address is that verb's documented
use, but it resolved through the saved-host path first, whose "pair it first" advice
printed before the probe ran. It resolves quietly now — same lookup, no lecture.
Verified on the Windows CI runner (clippy -D warnings, fmt, 8/8 tests, and the built
punktfunk.exe by hand: overview, per-verb pages, quiet `reachable` exit 2) and in the
Linux CI image (same gates, 8/8). One field note from the hand run: the exe needs the
FFmpeg DLLs beside it or on PATH — true of the session binary already, and the MSIX
ships them next to both, so packaging is unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Connecting from the 0.22.0 Windows client bounces straight back to the host list, on
every host. The shell is fine; the binary it spawns is not.
b0ea1e6b was a `clients/linux` change that also dropped a verbatim copy of the GTK
shell into `clients/session/src/` — app.rs, cli.rs, the four ui_*.rs, shortcuts.rs,
spawn.rs — and, fatally, OVERWROTE `clients/session/src/main.rs` with the shell's.
That file is `[[bin]] punktfunk-session`. So the binary every shell execs for a stream
stopped being the Vulkan session and became the GTK shell:
- Windows: the shell's `#[cfg(not(target_os = "linux"))]` arm — print
"punktfunk-client is Linux-only" to stderr, `exit(2)`. It never writes a single
line of the stdout contract, so the shell sees EOF with no `ready`, no `error`,
no `ended`, and returns to the host list with a BLANK banner. Exactly the report.
- Linux: `app::run()` sees `--connect` in argv and calls `exec_session()`, which
execs `punktfunk-session` — itself. A stream is an exec loop.
CI could not catch it. The Windows leg of the clobbered file is a three-line stub that
compiles perfectly; `cargo build -p punktfunk-client-session` stayed green while
building the wrong program. Only running it fails, and nothing runs it.
61bdf11e then read the 327 E0433s on the Linux leg as a missing-manifest bug and
declared gtk4/libadwaita/relm4 on this crate. That fixed the build of the wrong file
and cemented the clobber. Both go: the copy is deleted, `main.rs` is restored from
bf981027 (the last commit that touched the real one), and the manifest loses the GTK
block plus the gresource build-dep and `data/` that arrived with 944c03dd to feed it.
serde_json returns to `optional`/`ui`, which is what it always was — the reason
`--no-default-features` didn't compile was cli.rs, and cli.rs was never ours.
Also closes the hole that made this silent. `SpawnEvent::Exited` now carries the
child's exit code, and a child that exits nonzero having said NOTHING gets a banner
naming that code instead of an empty string. Code 0 (stream window closed) and -1
(our own Disconnect/Cancel kill) stay silent, as before. A wrong or crashing session
binary is now a legible failure rather than a connect that quietly does nothing.
Verified. Windows x86_64 on the CI runner: check, clippy -D warnings, rustfmt, tests
(5 passed, incl. the new one) all green, and the rebuilt punktfunk-session.exe answers
`--connect` with `{"error":"presenter: SDL window: …"}` — the real contract — where
0.22.0's answered with nothing. Linux amd64 in the CI image: check with default AND
--no-default-features, clippy -D warnings, rustfmt, tests green, and the built binary
emits `{"error":"presenter: SDL video: …"}` + exit 4 instead of exec-looping.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Apple parity with the Android card (a94b1d3c's sibling): the OS mark moves
out of the status row and into the tile, replacing the monogram. The
initial says nothing the name beside it doesn't already say — twice over on
a row of home-worker-N boxes — while the mark identifies the machine at a
glance.
Both host surfaces, because on tvOS the console home is the only one there
is: the touch cards (saved and discovered) and GamepadHomeView's badge,
which needed the chain carried on HomeTile to reach it. Sized to the
monogram's own point size, so it lands at ~48% of the tile everywhere, and
tinted through the same foregroundStyle the letter used — the assets are
template imagesets, so they follow it like an SF Symbol.
A host that advertises no OS chain, or one we ship no art for, keeps its
letter: `osIconImage` already returns nil for both, so a mixed row still
reads as one set. The mark carries the accessibility label now, since the
status row it used to ride no longer names the OS.
Untouched: the widget draws no monogram, and AboutView's is the app's own
logo, not a host's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MediaCodec scales whatever it decodes to fill the Surface it renders into,
and the Surface filled the screen — so a stream whose resolution didn't
match the panel's aspect came out stretched. Nothing downstream of the
Surface can correct that; the Surface itself has to carry the aspect.
Size the video to the negotiated mode's ratio, centred, with the remainder
black. The mode is known from the handshake before the first frame arrives,
via a new `nativeVideoSize` (the same `client.mode()` the HUD already
reports as `w×h@hz`); an older native lib returning nothing falls back to
filling, exactly as before.
Input follows the picture. Direct-pointer touch, multi-touch passthrough
and the pen lane all map positions against the size of the node they sit
on, so the gesture layer moves onto the same rect as the video and all
three stay correct by construction instead of each needing an offset
threaded through it. The physical-mouse path can't work that way — its
events arrive from the activity in WINDOW coordinates — so it now measures
against the SurfaceView's rect on screen, subtracting the letterbox origin
and clamping into the picture: a pointer out on a bar has no host position
of its own, and the edge is the honest answer for it.
One deliberate consequence: trackpad swipes that START inside a letterbox
bar no longer register. Trackpad input is relative and could have kept the
whole panel, but one rule — input lands on the picture — beats a mode-
dependent input surface, and the pen lane rides inside trackpad mode too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>