Compare commits

...
49 Commits
Author SHA1 Message Date
enricobuehlerandClaude Fable 5 fa822744ff feat(core/host/android): phase-locked capture — frames arrive on the client's latch schedule
docker / deploy-docs (push) Blocked by required conditions
windows-host / canary-manifest (push) Blocked by required conditions
windows-host / winget-source (push) Blocked by required conditions
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Waiting to run
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Waiting to run
windows / build (aarch64-pc-windows-msvc) (push) Waiting to run
windows / build (x86_64-pc-windows-msvc) (push) Waiting to run
ci / web (push) Successful in 1m59s
windows-host / package (push) In progress
ci / docs-site (push) Successful in 2m13s
ci / rust-arm64 (push) Successful in 5m17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4s
android / android (push) Successful in 5m52s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
deb / build-publish (push) Successful in 5m38s
ci / rust (push) Failing after 5m52s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) In progress
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
flatpak / build-publish (push) In progress
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) In progress
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
arch / build-publish (push) Failing after 6m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) In progress
deb / build-publish-client-arm64 (push) Successful in 3m55s
docker / builders-arm64cross (push) Successful in 19s
deb / build-publish-host (push) Failing after 4m24s
apple / swift (push) Successful in 4m35s
release / apple (push) In progress
apple / screenshots (push) Waiting to run
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>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 f9faab780e tune(android): the latch margin drops to SF's real lead (2.5 ms)
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>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 a6ff0350e4 feat(android): frames target SurfaceFlinger's latch, not the GPU-render deadline — display 21→9.5 ms
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>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 984f7be896 fix(android): the presenter paces to the panel's real grid, not the app's down-rated vsync
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>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 e08fd91cd1 feat(android): a timeline presenter — frames reach glass on the panel's schedule, not decode's
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>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 581320df0c fix(android): the stream stops fighting large displays and owns the cutout explicitly
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>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 87b6fa8813 feat(android): the panel is pinned to the stream's refresh, and touch skips the vsync batch
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>
2026-07-30 23:44:09 +02:00
enricobuehler 2748b84933 style(vdisplay/driver): rustfmt one line the audit bundle left unwrapped
ci / web (push) Successful in 1m6s
android / android (push) Canceled after 2m41s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m43s
ci / rust (push) Canceled after 2m41s
ci / rust-arm64 (push) Canceled after 2m41s
ci / docs-site (push) Canceled after 2m39s
deb / build-publish (push) Canceled after 1m33s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Successful in 23s
windows-drivers / driver-build (push) Successful in 1m46s
`5742ec95` landed `create_monitor`'s EDID call split across two lines where it
now fits on one, so the drivers workspace fails `cargo fmt --all --check`.
Whitespace only — separated from the gamepad work it happened to block.
2026-07-30 23:40:35 +02:00
enricobuehler 98147fb89c fix(gamepad/windows): the channel proof sizes its feature buffer from the descriptor
`HidD_GetFeature`/`HidD_SetFeature` reject any buffer shorter than the
collection's `FeatureReportByteLength`, and the proof hardcoded 64. That held
only because every pad enumerated with the DualSense descriptor; a Deck that
enumerates as a Deck reports 65 (its one feature report is unnumbered and 64
bytes wide, plus the report-id slot Windows always reserves), so both calls
would have failed, the proof with them, and — the gate being fail-closed — the
pad would never receive its DATA section at all.

Take the length from the descriptor, and accept the Deck's reply at either
offset 0 or 1: one driver binary serves four identities and Windows places an
unnumbered report's payload behind the report-id slot.

An answer must also VALIDATE, not merely parse. `from_feature_report`
reinterprets any 17 bytes, and a Deck serves its one unnumbered feature report
for *any* requested id — so it answers the PS `0x85` probe with Steam attribute
bytes that parse into a proof and fail on magic. Returning that first answer
stopped the search while the real proof sat one transport away.
2026-07-30 23:40:35 +02:00
enricobuehler 00c29f82f2 fix(gamepad/windows): a pad enumerates as the controller it IS, not always a DualSense
A Steam Deck client streaming to a Windows host had a stuck stick and a stuck
d-pad. The virtual pad was enumerating with the DualSense VID/PID **and the
DualSense report descriptor**, so Windows parsed the 64-byte Deck frame as
DualSense report 0x01: LX = report[1] = 0x00 (stick hard left), LY = report[2] =
0x09 (hard up), and a d-pad hat of 0 — which is UP, held forever.

The driver picked its identity from `device_type` in the sealed section, but
hidclass asks for the descriptors and attributes while it STARTS the device, and
the section can only be delivered over the HID device interface — which does not
exist until those queries are answered. The channel was structurally unavailable
at the only moment it was needed, so `device_type()`'s bounded wait always timed
out and every identity fell back to DualSense. Not a race: DualShock 4 and the
Edge enumerated as DualSenses too (verified on .173 — both report 054C:0CE6 with
a 64-byte DualSense input report, while their on-demand strings read correctly).

The devnode's own hardware ids carry the identity and are readable at
EvtDeviceAdd, before anything is asked, so resolve it there. The section stays
authoritative once attached; the old wait survives only for a devnode whose ids
match nothing.

`hwid_devtype_table_matches_the_driver` pins the host's hwid → device_type
mapping against the driver's table, including the ordering trap that
`pf_dualsense` is a prefix of `pf_dualsenseedge`.
2026-07-30 23:40:35 +02:00
enricobuehlerandClaude Fable 5 5742ec9548 fix(vdisplay/driver): the audit bundle — one timing formula, honest EDID, scoped watchdog, lock-free drain, D0-resume re-init, knobbed RT priority
ci / web (push) Successful in 1m5s
windows-drivers / probe-and-proto (push) Successful in 1m5s
deb / build-publish-host (push) Failing after 12s
apple / swift (push) Successful in 1m55s
ci / docs-site (push) Successful in 2m56s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
windows-drivers / driver-build (push) Successful in 2m10s
ci / rust-arm64 (push) Successful in 3m22s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
deb / build-publish-client-arm64 (push) Successful in 2m44s
android / android (push) Canceled after 4m33s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 4m35s
ci / rust (push) Canceled after 4m40s
deb / build-publish (push) Canceled after 4m40s
docker / builders-arm64cross (push) Canceled after 2s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 39s
windows-host / package (push) Canceled after 1m21s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 3m45s
release / apple (push) Canceled after 5m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 2m41s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
One signing pass over the 2026-07-30 audit findings:

- Timing math unified (D4): monitor-description and target modes now come
  from ONE IddSampleDriver-exact builder differing only in
  vSyncFreqDivider; the virtual-display-rs legacy formula (width-less
  pixel rate, deliberately fractional vSync) is gone.
- EDID (D5): the preferred-timing DTD is built from the SESSION's mode
  when it fits the encoding (pf-driver-proto's tested builder; 1080p60
  stays the fallback); the range-limits descriptor covers everything the
  driver can advertise (max clock 150 MHz → 2550 MHz, max-H +255 —
  the old limits were violated by the driver's own 1080p120 default);
  product code 0 → 1. Deliberately still no HDMI VSDB — documented in
  the module doc.
- INF (D6): UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects added
  (the sibling drivers all carry it); the dead DeviceGroupId (inert under
  ProcessSharingDisabled) dropped; the IddCx0102-vs-
  IddMinimumVersionRequired=10 pairing documented as deliberate — 0102
  is the extension's registered identity, not a version request.
- Watchdog lifecycle (D7): device cleanup now stops the host-liveness
  thread (it ran forever and its reap raced device teardown over the
  same monitor list).
- Drain path off the mutex (D8): the per-frame has/take_frame_channel
  checks (≥60 locks/s per worker on the mutex the whole control plane,
  the mode DDIs and the watchdog contend) are gated by a delivery
  generation counter — the steady state takes no lock.
- Adapter cache (D9): last-write-wins slot instead of a OnceLock, and a
  D0 re-entry from a REAL low-power state clears + re-inits — the stale
  pre-power-cycle handle used to wedge every later IOCTL_ADD.
- Realtime GPU priority (D10): IddCxSetRealtimeGPUPriority is now
  A/B-able without a rebuild (PFVD_NO_RT_GPU, machine env) — no
  canonical IDD driver raises it, and it preempts the game's and DWM's
  queues at a level apps can't reach.
- Logging (D2): the logger rides file_log_enabled() as a whole — a
  RELEASE driver without the opt-in no longer OutputDebugStringA's (+2
  allocs) per logged event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 1c15ba89c3 fix(drivers/pads): the XInput path stops re-mapping the mailbox, and release logging gets a real off switch
pf-xusb pumped the sealed-channel bootstrap mailbox — an open+map+close+
unmap plus two heap allocs — on EVERY XInput IOCTL, per pad. A periodic
WDF timer (the pf-gamepad pattern) now owns the pump: adoption,
re-delivery and host-gone detection happen there, and the IOCTL path
reads the cached view. A vanished host still reads as a neutral pad
within one 8 ms tick, and the heartbeat mark still advances per serviced
IOCTL, so the host keeps seeing the GAME-visible polling path move.

Both pad drivers' log() also called OutputDebugStringA unconditionally —
a syscall + CString + format! alloc per logged event in RELEASE builds,
on per-IOCTL paths (SET_STATE hex dumps during rumble, the cyclic
GET_STRING polls). The whole logger now rides the existing
file_log_enabled() gate (debug builds, or PF*_DEBUG_LOG), and dbglog!
skips its format! too.

(pf-gamepad's identical log shape is deliberately untouched: the
unpushed fix/deck-hid-identity branch owns that file right now.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 85dd75437b feat(driver-proto): an EDID detailed-timing builder for the session's mode
An 18-byte DTD with fixed reduced blanking (80 px / 45 lines — a virtual
display's blanking only has to be self-consistent), refusing modes the
encoding cannot carry (pixel clock past the u16 10 kHz field, 4K120-class;
>12-bit actives). Lives here rather than in the driver so it is unit-
tested on every platform; pf-vdisplay splices it over its hard-coded
1080p60 preferred-timing descriptor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 8140d3f8b3 feat(capture/windows): a degraded stretch logs one summary line at recovery
Per-hole stall lines gate on prior ACTIVE flow, so inside a sustained
~2 fps phase only the first hole is reported and the log goes quiet
exactly while the user suffers — the field shape (deep 15 s stretches at
2 fps) was invisible without a stats recording.

StallWatch now tracks the stretch: opened by a reported stall, fed by
every stall-sized hole while the activity gate stays broken, closed when
sustained flow returns (or a ring recreate cuts it — its holes predate
the recreate and still count). Closure surfaces one INFO line: span,
hole count, summed hole time, worst hole. One-hole stretches dissolve
silently (the stall's own line covers them), and a ≥10 s gap closes the
stretch first so a quit-to-desktop pause never folds into the tally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 789dafc95f feat(capture/windows): the stall micro-probes get an off switch, the depth pin a backoff
Two standing costs from the interval-stutter investigation, consolidated:

- PUNKTFUNK_STALL_PROBES=0 now opts a box out of the micro-probe engine
  (per-GPU 10 Hz fence copies, a parked blocking-DwmFlush waiter, the
  5 ms-cadence CPU sentinel). Default stays ON while the field program
  runs; off, stall lines keep the driver telemetry and the cheap ETW
  present/queue discriminator and only lose the corroborating probe legs
  — the verdict matrix already treats an absent probe window honestly.

- The negotiated-depth pin-back stops hammering a display that refuses
  the flip: it was 4 CCD writes + 8 display-config queries per second,
  forever, all on the session-global display-config lock. After ~2 s of
  eager retries it re-attempts every ~4 s — still self-healing, without
  the drumbeat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 ecb3d1ab22 fix(host/windows): the pad service thread no longer waits on pnputil at its leisure
driver_store_inventory() ran `pnputil /enum-drivers` synchronously inside
the attach-failure diagnose path — on the pad service thread, with no
timeout. A busy or wedged driver store blocks that enumeration for tens
of seconds, and the thread must keep draining pad slots.

The query now runs on its own thread (spawned once per process); the
caller waits a bounded 2 s so a fast pnputil still lands in the same
report, and a slower one fills the cache for later diagnoses — the WARN
then says the store could not be queried yet instead of blocking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 e71f4696ee fix(host/windows): an unlightable sink stops the teardown warn+force loop
A box whose only external display is an off/standby TV warned "no
external physical display active after the restore (connected=1)" and
forced the EXTEND preset on EVERY teardown — an inert remedy (with one
connected display the preset can't relight anything) that reads like a
fresh failure in every field log.

The backstop now measures its own effect: after a force-EXTEND it
re-reads the inventory, and a dark set that stayed dark THROUGH the
preset is latched as unlightable — subsequent teardowns log a debug line
and leave the topology be. The poisoned-snapshot chain the backstop
exists for is not latched away: there the panel CAN light, so the first
force succeeds and the latch stays clear. Process-lifetime latch, so a
host restart re-probes once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 d014cea489 feat(core/abr): a keyframe-begging decoder backs the bitrate off, loss or no loss
The RX-9070 field trace: 14 decode-recovery keyframe requests in 2 s at
~300 Mbps with loss_ppm=0 — and the controller held the rate, because no
loss/OWD/latency signal moved. Repeated keyframe asks ARE the decoder
saying it cannot keep up; now they are an ABR input.

The control task counts outbound CtrlRequest::Keyframe at its send choke
point (the one place every emitter funnels through); the pump drains the
count per 750 ms report window into on_window. Two asks in a window is
ordinary-bad (two-window confirmation, like an OWD rise); four or more is
severe (the emitters throttle at 100 ms, so 4+ means the window was spent
begging) and backs off ×0.7 after one window. RFI asks are deliberately
not counted — they are the routine loss-recovery path and loss_ppm
already prices them in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 cd6ceb98e3 feat(capture/windows): stall lines say whether the content or the display path went silent
A compose-silence hole used to be blamed on the frame-generation path by
default — mislabeling benign content pauses (menus, loading, game hitches)
as display-path bugs. The corrected discriminator convicts on witnesses:

- Microsoft-Windows-DXGI Present/PresentMPO (ids 42/55) rides the host's
  filtered ETW session — one event per swapchain present, stamped with the
  presenting pid (named in the stall line).
- BltQueueAddEntry/CompleteIndirectPresent (ids 1071/1068) witness frames
  entering/leaving the virtual display's kernel present queue (the modern
  IDD path; anatomy proven on-glass via xperf, 2026-07-30).
- classify() now reads the window counts: presents flowing while the queue
  starved = FRAME-GENERATION (the OS dropped composed frames — the real
  bug class, never yet observed); no presents anywhere = CONTENT-SILENCE
  (benign for the display path); no working witness = UNATTRIBUTED, never
  a guess.
- DWM_TIMING_INFO.cFrame is demoted to an advisory dwm_frames_frozen=
  print: on Win11 it is refresh-synthesized and advances without real
  composes (proven against a kernel trace), so it convicts nothing.
- DxgKrnl legacy Present (id 184) is retired — it never fires on the
  redirected BltQueue path.

Stall lines carry etw_presents=/etw_queue_adds= plus named presenters;
compiled and validated on-glass (a quit-transition stall correctly labeled
CONTENT-SILENCE presents=0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:28 +02:00
enricobuehlerandClaude Fable 5 f87c1e6cec fix(encode): gate multi-slice frames on the client's decoder — the 0.17.0 Chromecast crash
ci / docs-site (push) Successful in 1m9s
apple / swift (push) Successful in 1m15s
ci / web (push) Successful in 3m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 2m13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
ci / rust-arm64 (push) Successful in 3m34s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish-host (push) Successful in 4m20s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 6m17s
arch / build-publish (push) Failing after 7m58s
flatpak / build-publish (push) Successful in 9m6s
ci / rust (push) Successful in 13m37s
release / apple (push) Successful in 16m32s
windows-host / package (push) Successful in 18m21s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m56s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m54s
apple / screenshots (push) Successful in 20m45s
android / android (push) Successful in 5m0s
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>
2026-07-30 22:23:42 +02:00
enricobuehlerandClaude Opus 5 c04c5be224 docs: USB passthrough is a plugin now, not a recipe you assemble
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 26s
ci / web (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 51s
ci / docs-site (push) Successful in 1m14s
ci / rust-arm64 (push) Successful in 1m18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m14s
docker / deploy-docs (push) Successful in 31s
ci / rust (push) Successful in 7m20s
Adds a VirtualHere section to the plugins page — what it is, that both halves of VirtualHere
are yours to install and licence, that the Devices tab writes a name-based rule so it survives
the couch rebooting, and that Diagnostics is where to look when nothing happens.

States the coverage limit up front rather than letting somebody discover it: there is no
VirtualHere server for iOS or tvOS, so those clients cannot pass devices through, and nothing
on our side can change that.

Cuts the automation.md recipe from 75 lines to a pointer. It now leads with "use the plugin"
and keeps only the zero-code two-hook version for people who would rather not install one —
with its trade-offs stated instead of implied: the address is hard-coded so it breaks when the
couch reboots, and an abnormal stream end strands the device on the host. Those two failures
are exactly what the plugin exists to fix, so the reader gets to make an informed choice.

Not build-verified: docs-site does not build standalone in this checkout. Checked by hand that
Callout is in fumadocs' default MDX components with a valid `warn` type, that the JSX balances,
and that the cross-page anchor matches github-slugger of the heading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:07:14 +02:00
enricobuehlerandClaude Opus 5 ef2bb56251 fix(tray): probe /login without redirects, and open the address the probe checked
ci / docs-site (push) Successful in 1m17s
apple / swift (push) Successful in 1m17s
ci / rust-arm64 (push) Successful in 1m48s
ci / web (push) Successful in 1m56s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
deb / build-publish (push) Successful in 5m3s
docker / builders-arm64cross (push) Successful in 6s
deb / build-publish-host (push) Successful in 4m14s
docker / deploy-docs (push) Successful in 27s
ci / rust (push) Successful in 6m52s
android / android (push) Successful in 7m11s
arch / build-publish (push) Failing after 7m24s
apple / screenshots (push) Successful in 14m27s
windows-host / package (push) Successful in 18m20s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m38s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m10s
Two ways the tray could misreport a healthy console.

The probe hit `/`, which is auth-gated and 302s to /login, and ureq follows
redirects by default — so TLS, `/`, and a full cold Nitro SSR of /login all had
to fit inside one 2s budget, and a console that was merely warming up read as
down. It now probes /login with redirects(0) on the agent: one round trip, and a
302 already counted as up. Neither user of that agent wants redirects; the
summary is a terminal JSON route.

The menu then opened https://localhost:<port> while the poller probed 127.0.0.1.
web-run.cmd binds HOST=0.0.0.0 — IPv4 only — and Windows resolves localhost to
::1 first, so the tray could call the console healthy and hand the browser a URL
that fails. Both are 127.0.0.1 now, which is what the Linux tray already did, so
the menu can no longer disagree with the status printed above it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:42:50 +02:00
enricobuehlerandClaude Opus 5 d9912aa795 fix(windows/update): an update puts the status tray back instead of killing it for good
Every update started from the web console killed the tray permanently. Three
pieces had to line up, and they did: the installer's StopTrays force-kills every
punktfunk-tray.exe (it is one of the files being replaced), its relaunch is a
[Run] entry flagged skipifsilent, and the in-console updater spawns the installer
with /VERYSILENT. So the tray died on every such update and waited for the next
sign-in, because the Run value is a logon trigger. Confirmed on a box whose tray
was absent while its Run key, its exe and a session signed in hours before the
update were all present.

The installer cannot fix this itself. Spawned from the SYSTEM host service, its
`runasoriginaluser` resolves to SYSTEM — so relaunching there would place a
SYSTEM-owned tray in the user's session, where it would hold the per-session
Local\PunktfunkTray mutex and block the real tray at the next sign-in. Strictly
worse than the bug.

The host does it instead, because only the host has the right token. IntentRecord
gains tray_was_running, captured before the installer is spawned (reusing the
conflicting-host scan's process snapshot rather than opening a second one), and
boot reconciliation reads it off the intent before reconcile consumes it and
relaunches through tray::start(). Restored on both terminal outcomes — a
rolled-back install killed the tray just as dead as a successful one — but never
while an apply is still in flight, where the installer may only kill it again.

The field is serde(default), so an intent written by an older host reads as false
and behaves exactly as before; covered by a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:42:50 +02:00
enricobuehlerandClaude Opus 5 c77823d800 feat(windows/tray): punktfunk-host tray start|stop|status, so a dead tray is one command away
The status tray is a per-user, per-session GUI process with no recovery path of
its own: the HKLM Run value only fires at sign-in, and nothing in the product
ever restarts one. Anything that kills a tray — an upgrade's StopTrays, a crash —
therefore left the operator without an icon until they signed out and back in,
with no way to ask for it back.

windows/tray.rs becomes the one place that knows tray lifecycle (find, start,
stop, is-running), so the CLI and post-update reconciliation share an
implementation rather than growing two.

start() cannot simply spawn the exe, because the launch crosses a privilege
boundary in one direction only:

  - from the host service (SYSTEM) the tray must land in the active console
    session under the LOGGED-IN USER's token, which is WTSQueryUserToken +
    CreateProcessAsUserW and needs SE_TCB — SYSTEM-only;
  - from a shell already in the console session that call fails (an administrator
    does not hold SE_TCB either) and a plain spawn is both sufficient and right.

Trying the privileged path and falling back discriminates the two without
inspecting a token, and adds no unsafe to the crate. But a plain fallback from
ssh/RDP would put a tray in a session nobody can see and report success, so on
failure it consults console_session_mismatch() and refuses with an explanation
instead. stop() is graceful first (--quit lets the tray remove its own icon via
NIM_DELETE, as the uninstaller does), then forces any instance in another session.

Verified on Windows: launched from a SYSTEM scheduled task the tray comes up in
the console session owned by the interactive user, not SYSTEM; from an ssh
session (0 -> console 2) it refuses; a second start is a no-op; stop is graceful
and idempotent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:42:50 +02:00
enricobuehlerandClaude Opus 5 3bb30cb2f6 fix(windows/web-console): a bun exit no longer parks the console until the next sign-in
The PunktfunkWeb task registers RestartCount=10/PT1M, but Task Scheduler honours
restart-on-failure only when the action crashes or fails to start — never for a
plain non-zero exit. So any bun exit left the console down until the next boot or
interactive logon, with the host still streaming perfectly next to it. Seen on
glass on a 0.22.3 upgrade: `web setup` started the task one second after the new
service came up, bun exited at once (Last Run Result 0xFFFFFFFF), and the console
sat dead for hours while its box served clients — which reads to an operator as
"the host is gone".

web-run.cmd now supervises bun instead of exiting with it: restart on any exit,
indefinitely for a console that served a while and then died, but give up after
10 CONSECUTIVE fast exits so a genuinely broken install still surfaces as a failed
task. Any run lasting >= 60s resets that counter. It exits 0 rather than
respawning when the payload disappears, so an uninstall does not make it spin.

Uptime is measured with an unambiguous clock rather than parsed out of %TIME%,
which is locale-formatted (12-hour locales append " PM", many others use ',' as
the decimal separator) — the box this was found on prints 18:36:28,39.

stop_web_console() polls until :47992 is really free instead of sleeping a blind
second: both `schtasks /end` and `taskkill /F` are asynchronous, and web_setup
starts the new task immediately afterwards, where bun cannot bind a port the
corpse still holds. It re-ends the TASK halfway through, not just the listener —
with the launcher now supervising, killing bun alone would be undone by its own
restart loop.

The systemd unit had the same shape of hole: Restart=on-failure leaves a console
that exited 0 down. Now Restart=always, which an explicit `systemctl stop` still
overrides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:42:50 +02:00
enricobuehlerandClaude Fable 5 e90c5d5bcd fix(gamepad): the output-report ring absorbs haptics-rate writers (PadShm v2.2)
ci / web (push) Successful in 1m1s
windows-drivers / probe-and-proto (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m16s
windows-drivers / driver-build (push) Successful in 2m15s
ci / rust-arm64 (push) Successful in 3m27s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
deb / build-publish-client-arm64 (push) Successful in 2m21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
apple / swift (push) Successful in 4m36s
deb / build-publish-host (push) Successful in 4m2s
docker / builders-arm64cross (push) Successful in 5s
docker / deploy-docs (push) Successful in 29s
arch / build-publish (push) Failing after 6m11s
deb / build-publish (push) Successful in 6m6s
android / android (push) Successful in 8m29s
ci / rust (push) Successful in 10m32s
windows-host / package (push) Successful in 16m18s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m42s
apple / screenshots (push) Successful in 20m33s
A field log (2026-07-30) showed a game driving the virtual DualSense's output
endpoint at >2 kHz sustained for tens of seconds. The 8-slot ring — sized on
the assumption that 2 kHz is double any real HID output rate — overflowed
every 4 ms poll, which force-silenced rumble for each storm's whole duration
and flooded the log at ~230 WARN lines/s (96 % of the user's 5000-line
web-console export, evicting the session history it was needed to diagnose).

Three legs, negotiated so every old/new host×driver pairing keeps working:

- pf-driver-proto: the ring grows in place 8 -> 56 slots; PadShm becomes
  exactly one page (4096 B), the hard ceiling that keeps cross-generation
  section views mappable. A new out_ring_len field carries the driver's side
  of the length negotiation. Deliberately NOT a GAMEPAD_PROTO_VERSION bump
  (that fails closed - no pad at all).
- pf-gamepad driver: picks its ring length from the host's out_ring_ver
  stamp (>= 2 + a full-size map -> 56) and echoes it before every ring_head
  bump (now a Release store), so an Acquire-observing drain always reads the
  modulo that indexed the slots it copies.
- host drain: follows the echo (0 = old driver = 8); on genuine overflow it
  now salvages the legacy latest-report slot - the freshest coalesced state -
  instead of total silence, and the per-poll overflow WARN is rate-limited to
  1 line/s per pad with a suppressed count.

Verified on the Windows CI runner (drivers workspace build + clippy -D
warnings against the WDK; 64 pf-inject tests incl. the new negotiation/
salvage/limiter tests; pf-inject clippy -D warnings) and on Linux via the CI
docker image (82 pf-inject tests). DriverVer needs no manual bump - the
installer stamps a strictly-increasing build timestamp per release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 18:36:47 +02:00
enricobuehlerandClaude Fable 5 ec675261fc feat(android): the Sony-pad USB grant is asked on connect, not found in Settings
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 21s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
ci / docs-site (push) Successful in 1m4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
ci / rust-arm64 (push) Successful in 1m21s
ci / web (push) Successful in 1m33s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 1m6s
docker / builders-arm64cross (push) Successful in 6s
android / android (push) Canceled after 1m55s
ci / rust (push) Canceled after 1m56s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 47s
docker / deploy-docs (push) Canceled after 0s
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>
2026-07-30 18:34:57 +02:00
enricobuehlerandClaude Fable 5 1984ddb942 feat(android): a USB Sony pad is captured — rumble, adaptive triggers, lightbar, gyro
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>
2026-07-30 18:34:57 +02:00
enricobuehlerandClaude Fable 5 696386dee7 fix(host/windows): clippy — SAFETY comments on the WinTrust FFI, explicit truncate(false), sort_by_key
deb / build-publish-client-arm64 (push) Failing after 4s
ci / docs-site (push) Successful in 1m18s
ci / web (push) Successful in 2m12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
deb / build-publish-host (push) Successful in 4m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 31s
docker / builders-arm64cross (push) Successful in 11s
ci / rust-arm64 (push) Successful in 5m49s
docker / deploy-docs (push) Successful in 31s
android / android (push) Successful in 6m54s
deb / build-publish (push) Successful in 5m0s
apple / swift (push) Successful in 5m6s
ci / rust (push) Successful in 6m13s
arch / build-publish (push) Failing after 7m37s
windows-host / package (push) Successful in 15m8s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m39s
apple / screenshots (push) Successful in 21m28s
windows-host.yml's clippy sees what the Linux gate structurally cannot (cfg(windows)
code); verified with the same clippy line on the runner this time, not just cargo check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:42:25 +02:00
enricobuehlerandClaude Fable 5 5790a3e334 docs(release): v0.22.3 notes — the Updates card and one-click updating join the installer fixes
ci / web (push) Successful in 2m25s
ci / rust-arm64 (push) Successful in 2m53s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
ci / docs-site (push) Successful in 1m6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 33s
ci / rust (push) Successful in 7m2s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:23:54 +02:00
enricobuehlerandClaude Fable 5 3b1485e2a1 fix(plugins/windows): the runner can read its own bundle, so it actually starts
ci / rust (push) Canceled after 27s
ci / rust-arm64 (push) Canceled after 28s
ci / web (push) Canceled after 28s
ci / docs-site (push) Canceled after 28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 4m45s
android / android (push) Successful in 4m58s
deb / build-publish-host (push) Successful in 4m14s
deb / build-publish (push) Successful in 5m51s
deb / build-publish-client-arm64 (push) Successful in 1m19s
arch / build-publish (push) Failing after 6m54s
windows-host / package (push) Failing after 10m59s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
apple / screenshots (push) Canceled after 14m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14m33s
Enabling the plugin runner did everything right and then nothing happened.
The task went Ready with LastTaskResult 1 within a second of every start, and
the console kept showing it enabled-but-not-running with nothing to explain
why. Task Scheduler discards the action's output, so the reason never
surfaced anywhere:

  error: EPERM reading "C:\Program Files\punktfunk\scripting\runner-cli.js"

This file already knows why. RUNNER_UNIT_DIRS carries the note that bun opens
the files it loads asking for FILE_WRITE_ATTRIBUTES on top of read, so a plain
(RX) grant makes them die with EPERM — found on glass when the plugin and
script directories were fixed. The runner's own entry script was missed. It
lives in {app}\scripting, which carries only Users:(RX), and LocalService
reaches that through Authenticated Users, so bun could never open the one file
it was started to run. The runner has not been able to start since it moved
off SYSTEM, which is a principal that has full control everywhere and so never
met this.

So {app}\scripting now gets the same (OI)(CI)(RX,WA) the unit dirs get, at
enable, revoked at disable like every other grant here. WA moves timestamps
and the read-only bit and cannot touch content, so the three-way split the
module maintains still holds: code read-only, secrets read-only, only
plugin-state writable. A plugin still cannot rewrite the runner.

Measured on glass on .173, same box, same task, only the ACE differing:
without it Ready/lastResult=1 and no runner process; with it Running, a live
runner, and GET /store/runtime answering running:true — which is exactly what
the console reads. Also checked what bun does with the access: nothing. It
opens the entry for write and never writes, and the directory is byte-identical
afterwards.

Windows-only code, so it is rustfmt-clean and type-reviewed but NOT
compile-verified here — the Windows CI job is the gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:23:16 +02:00
enricobuehlerandClaude Fable 5 4a540bddc8 feat(host/deck): one-click source rebuild — the update.sh run as a transient user unit
ci / rust-arm64 (push) Successful in 1m36s
ci / docs-site (push) Successful in 1m28s
ci / web (push) Successful in 2m25s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 2m27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m47s
deb / build-publish-host (push) Successful in 5m53s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
android / android (push) Successful in 7m2s
docker / builders-arm64cross (push) Successful in 11s
arch / build-publish (push) Failing after 7m33s
ci / rust (push) Successful in 7m35s
docker / deploy-docs (push) Successful in 39s
windows-host / package (push) Failing after 11m1s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 6m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m21s
apple / swift (push) Successful in 4m58s
apple / screenshots (push) Successful in 21m32s
The U3.1 leg of planning:host-update-from-web-console.md. The Deck's on-device install
gets the Update-now button with no opt-in (user-owned, no root): update.sh --pull runs
under systemd-run --user so the script's own restart of punktfunk-host can't kill it
mid-build (a child in our cgroup would die with us). Outcome without version equality —
which a source rebuild can't promise: a failed build leaves the host alive to report it
(unit watched to failure, log attached); a successful one restarts us, and the new
source_build intent flag makes intent-present-at-boot itself the success signal (the
script only restarts the host after a successful install). The console stops treating
a live long build as a timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:28:40 +02:00
enricobuehlerandClaude Fable 5 51d5f6cb29 feat(host/windows): boot-loop auto-rollback — the supervisor re-runs the cached previous installer
ci / docs-site (push) Successful in 1m11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
ci / web (push) Successful in 1m58s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 42s
deb / build-publish-client-arm64 (push) Successful in 3m19s
deb / build-publish-host (push) Successful in 6m17s
ci / rust-arm64 (push) Successful in 6m58s
android / android (push) Successful in 4m51s
docker / builders-arm64cross (push) Successful in 12s
windows-host / package (push) Failing after 5m39s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Successful in 1m58s
arch / build-publish (push) Failing after 8m14s
apple / swift (push) Successful in 4m59s
apple / screenshots (push) Canceled after 0s
deb / build-publish (push) Successful in 11m51s
ci / rust (push) Canceled after 13m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9m38s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m11s
The U3.2 leg of planning:host-update-from-web-console.md. The service worker loop —
the one piece that survives an upgrade — detects a fresh update-intent whose target
version is the crash-looping child (≥3 rapid restarts, intent <30 min), then exactly
once: picks the newest cached non-target installer, requires a valid Authenticode
signature, writes a rolled-back result record for the console, deletes the intent,
and spawns the installer silently. No cached installer or a failing signature degrade
to today's backoff loop with a loud log — never a brick, never an installer loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:12:22 +02:00
enricobuehlerandClaude Fable 5 3e21398c16 fix(lock): pf-update rides the 0.22.3 workspace bump
audit / bun-audit (plugin-kit) (push) Successful in 22s
audit / cargo-audit (push) Successful in 37s
audit / bun-audit (sdk) (push) Successful in 21s
audit / pnpm-audit (push) Successful in 11s
audit / docs-site-audit (push) Successful in 22s
ci / web (push) Successful in 1m52s
ci / docs-site (push) Successful in 2m4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m58s
android / android (push) Successful in 5m38s
audit / license-gate (push) Successful in 5m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
apple / swift (push) Canceled after 0s
arch / build-publish (push) Failing after 7m37s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 6m46s
ci / rust-arm64 (push) Canceled after 6m42s
deb / build-publish (push) Canceled after 4m47s
deb / build-publish-host (push) Canceled after 4m17s
deb / build-publish-client-arm64 (push) Canceled after 2m22s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 29s
docker / deploy-docs (push) Canceled after 0s
audit / bun-audit (web) (push) Successful in 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 28s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 28s
windows-host / package (push) Canceled after 6s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m31s
flatpak / build-publish (push) Successful in 10m8s
release / apple (push) Successful in 26m17s
The U2 branch minted the lock entry at 0.22.2 and the rebase onto the version bump
left it stale — --locked builds fail until it matches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:04:17 +02:00
enricobuehlerandClaude Fable 5 239c69fd71 feat(web,docs): Linux one-click in the card — opt-in hint, staged and nothing-newer outcomes
audit / bun-audit (sdk) (push) Successful in 18s
audit / bun-audit (web) (push) Successful in 18s
audit / docs-site-audit (push) Successful in 23s
audit / pnpm-audit (push) Successful in 12s
audit / cargo-audit (push) Successful in 37s
audit / bun-audit (plugin-kit) (push) Successful in 1m14s
arch / build-publish (push) Failing after 1m17s
ci / rust-arm64 (push) Failing after 44s
ci / web (push) Successful in 1m5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
ci / docs-site (push) Successful in 2m18s
deb / build-publish-host (push) Failing after 2m15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 53s
deb / build-publish (push) Failing after 3m43s
audit / license-gate (push) Successful in 4m47s
deb / build-publish-client-arm64 (push) Successful in 3m52s
docker / builders-arm64cross (push) Successful in 14s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m49s
ci / rust (push) Failing after 3m6s
docker / deploy-docs (push) Successful in 55s
windows-host / package (push) Failing after 6m27s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
android / android (push) Successful in 8m21s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
flatpak / build-publish (push) Successful in 4m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 4m19s
release / apple (push) Canceled after 57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 2m26s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
The apply panel serves staged kinds too; notify mode shows the group-join command when
the helper is installed but not yet enabled; outcomes render 'staged — reboot to
finish' and 'your package source had nothing newer yet' distinctly. Docs: the Linux
opt-in section (why it's a group, what the grant bounds, the pacman stance).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:55:27 +02:00
enricobuehlerandClaude Fable 5 06a249e49f feat(host/linux): opt-in one-click updates — the pf-update root helper, group-scoped polkit grant, per-PM legs
The U2 leg of planning:host-update-from-web-console.md. A new dep-free root helper
(crates/pf-update) runs the distro package manager against the INSTALLED punktfunk
packages — apt (index refresh scoped to our list when present), dnf, rpm-ostree
(single-transaction re-resolve, reported staged), sysext (the proven signed-feed
updater), pacman only behind the explicit PACMAN_FULL_SYSUPGRADE opt-in — then the
run-the-binary gate, then a root-written result record. Zero attacker-influenceable
parameters end to end: fixed ExecStart oneshot (punktfunk-update.service), polkit rule
scoped to that one unit's start verb for the shipped-EMPTY punktfunk-update group
(joining it is the auditable opt-in; every postinst creates it, none populate it).
The host starts the unit, interprets the record (staged / nothing-newer-yet / changed),
and crosses its own restart on the same intent/reconcile machinery as the Windows leg.
Status now reports staged results and the opt-in hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:55:27 +02:00
enricobuehlerandClaude Fable 5 bb1f93d90e feat(web,docs): the Update-now flow — password re-entry at the BFF, restart-tolerant progress, same-origin hardening
apply.post.ts intercepts the one proxied route that restarts the host: the console
password is re-verified per apply (login throttle shared, stripped before forwarding) so
a 7-day cookie alone can't do it. New Sec-Fetch-Site same-origin check on every mutating
request (login CSRF included). The card's apply flow: confirm dialog → live-session
force escalation → download/verify/restart progress rendered from the last snapshot
while polls fail (the host and, on Windows, this very server restart mid-flow) →
durable success/failure from last_result. Docs: the one-click section + kill switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:55:27 +02:00
enricobuehlerandClaude Fable 5 bb48225414 feat(host/windows): one-click update apply — verified installer download, intent/reconcile, POST /update/apply
The U1 leg of planning:host-update-from-web-console.md. The apply request carries no
version/url/channel (the zero-parameter invariant): the host installs exactly what its
Ed25519-verified manifest announced. Pipeline: staged download (resume + disk preflight)
→ manifest sha256 → Authenticode (valid signature, untrusted root tolerated while the
cert is self-signed; leaf sha256 pinned via the signed manifest, extracted from the SAME
WinVerifyTrust state) → intent record → detached CREATE_BREAKAWAY_FROM_JOB spawn of the
winget-blessed silent flags. The installer kills this process by design; boot-time
reconciliation reports the outcome (update.applied event / a durable failure with the
installer log path) across the restart. Session guard (force to override),
PUNKTFUNK_UPDATE_APPLY=0 kill switch, single-flight via job + fresh-intent detection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:55:27 +02:00
enricobuehler 73e1224345 fix(ci/update): an empty AUTHENTICODE_SHA256 broke the manifest build — jq -R needs one input line
ci / web (push) Successful in 1m5s
ci / docs-site (push) Successful in 2m30s
ci / rust-arm64 (push) Successful in 3m40s
android / android (push) Successful in 4m35s
decky / build-publish (push) Successful in 47s
ci / rust (push) Successful in 6m26s
arch / build-publish (push) Successful in 8m54s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 23s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 12m27s
docker / deploy-docs (push) Skipped
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders-arm64cross (push) Successful in 11s
apple / swift (push) Successful in 4m45s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m47s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m15s
deb / build-publish-host (push) Successful in 4m45s
deb / build-publish-client-arm64 (push) Successful in 1m25s
apple / screenshots (push) Canceled after 7m43s
deb / build-publish (push) Canceled after 3m20s
printf '%s' hands jq -R no line at all for an empty value; its empty output made
--argjson invalid. Seen on the first live canary-manifest run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit a12c8bc99d41c4f7b8e4c426d560171b2b12dd5e)
2026-07-30 15:23:52 +02:00
enricobuehlerandClaude Fable 5 1c836afc02 chore(release): bump workspace version to 0.22.3
ci / web (push) Successful in 2m4s
ci / docs-site (push) Successful in 2m25s
android-screenshots / screenshots (push) Successful in 2m12s
ci / rust-arm64 (push) Successful in 7m8s
apple / swift (push) Successful in 4m38s
decky / build-publish (push) Successful in 52s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m44s
deb / build-publish-client-arm64 (push) Successful in 1m32s
arch / build-publish (push) Successful in 10m25s
linux-client-screenshots / screenshots (push) Successful in 3m16s
audit / bun-audit (plugin-kit) (push) Successful in 28s
audit / bun-audit (sdk) (push) Successful in 27s
audit / bun-audit (web) (push) Successful in 26s
audit / docs-site-audit (push) Successful in 24s
audit / pnpm-audit (push) Successful in 15s
audit / license-gate (push) Successful in 5m11s
sbom / sbom (push) Successful in 35s
android / android (push) Successful in 7m33s
web-screenshots / screenshots (push) Successful in 4m12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m36s
audit / cargo-audit (push) Successful in 39s
docker / deploy-docs (push) Successful in 42s
docker / builders-arm64cross (push) Successful in 20s
flatpak / build-publish (push) Successful in 6m50s
deb / build-publish-host (push) Successful in 5m19s
deb / build-publish (push) Successful in 6m58s
ci / rust (push) Successful in 17m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m3s
apple / screenshots (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m46s
release / apple (push) Successful in 25m45s
windows-host / package (push) Successful in 10m58s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Failing after 18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m26s
Windows-installer patch release. 0.22.1 and 0.22.2 shipped without the web
console in them at all — confirmed from the tag builds' own logs, where the
console build step is `skipped` on a cache hit and the job is still green —
which also removed the only code that stopped bun before the installer
replaced it, hence the "DeleteFile failed; code 5" dialog. Both fixed here,
along with updates no longer disabling an operator's plugin runner.

Lock touched for the 30 workspace members only. `base64` stays at 0.22.1 and
the eight gtk-rs crates stay at 0.22.0 — they share our version space and a
blanket sed would move them to versions that do not exist. Diff is
versions-only, 30 insertions and 30 deletions; `cargo metadata --locked`
resolves; `cargo fmt --all --check` clean in both the main and the
packaging/windows/drivers workspaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:36:46 +02:00
enricobuehlerandClaude Fable 5 beefeaf9d7 fix(ci/windows-host): refuse to pack an installer that is missing a payload
Every payload the job bundles is now asserted before packing: the console, the
bun runtime, the plugin runner, the FFmpeg DLLs and VB-CABLE. Each is optional
to pack-host-installer.ps1 — right for a local debug pack, and the reason
0.22.1 and 0.22.2 shipped with no web console: one unset variable omitted it
behind a single line of log and the build stayed green.

CI knows it bundles all five, so a missing input belongs here as a failure
rather than downstream as a quietly smaller installer. FFmpeg is the one that
would hurt most and was silent too: an amf-qsv host link-imports avcodec, so
an installer missing those DLLs ships a host that cannot start at all, and
FFMPEG_DIR is a fallback to a provisioned path that nothing verified.

The shape is borrowed from the packer's own VB-CABLE check, which already
throws on a supplied-but-empty dir "instead of silently shipping an installer
without the virtual mic - exactly the field regression this bundling fixes".
Same lesson, applied to the rest.

Both paths were exercised on the Windows runner: all five unset fails with
exit 1 naming each one, all five present passes with exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:36:46 +02:00
enricobuehlerandClaude Fable 5 5b6fe7882a fix(ci/windows-host): a cached console no longer ships an installer without one
WEB_OUTPUT_DIR was exported on the last line of "Build + smoke-boot web
console", a step that is skipped on a cache hit. So the second and every
later build with an unchanged web/ and sdk/ left it unset — and
pack-host-installer.ps1 reads an unset WEB_OUTPUT_DIR as "don't bundle the
console" and says so in one line of log before carrying on happily.

The installers that fell out of that have no {app}\web at all: no
web-run.cmd, so `web setup` bails with "web launcher missing" before it
registers anything, so there is no PunktfunkWeb task, no console, and no
firewall rule for 47992. A user is left with a working host, a tray that says
"Open web console (not responding)" for ever, and nothing to reinstall their
way out of, because every rebuild reproduces it.

It also explains the bun.exe lock reported separately. bun.exe ships under
WithWeb OR WithScripting, but the pre-copy stop was #ifdef WithWeb — so in a
console-less installer bun still shipped while the only code that stopped bun
was compiled out, and replacing a running bun.exe is the "DeleteFile failed;
code 5" modal.

Exporting the variable from its own unconditional step fixes it. The throw
alongside is the actual lesson: a missing web\.output now fails the job
instead of silently redefining what the installer is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:36:46 +02:00
enricobuehlerandClaude Fable 5 c64ada5649 fix(windows/installer): an update stops every bun before it replaces bun.exe
Updating to 0.22.1 could die on a modal: "C:\Program Files\punktfunk\bun\
bun.exe — DeleteFile failed; code 5. Access denied." Windows will not delete a
running executable, so that message means a bun was still alive when the copy
reached it, and the installer had no way to recover.

Two things run that bun, and neither is a child of the host service, so
stopping the service — which the installer does correctly wait for — never
touched either. Both are Task Scheduler tasks: PunktfunkWeb (SYSTEM, the
console) and PunktfunkScripting (LocalService, the plugin runner). The only
pre-copy stop we had looked for exactly one of them, and found processes by
task name or by who was listening on 47992/3000. The plugin runner matched
neither clause: different task, and it listens on no port at all. So anyone
who had run `plugins enable` held bun.exe mapped through the whole install and
failed this way on every single update.

The web console could lose the race too. Stop-ScheduledTask returns once
termination has been *requested* and Stop-Process is TerminateProcess, so the
old code went straight from asking to copying; the Rust twin of that routine
has always ended with a deliberate one-second settle. And neither task was
disabled for the duration, while both carry restart-on-failure (web ten times
a minute apart, scripting 999) and the web task also has a logon trigger — so
a force-kill invited a respawn into the middle of a copy that takes well over
a minute at lzma2/max.

StopBunRuntimes now disables both tasks before stopping them, kills any bun
whose image lives under the install dir — by path, so a developer's own bun
survives — keeps the old port sweep for pre-bun installs that ran node on
3000, and then waits until both are actually gone rather than assuming.
bun.exe also gains restartreplace, so if some bun still escapes all of that,
the file lands on the next restart instead of dead-ending the install.

Disabling a task is not free, though: unlike a stopped one it does not come
back at the next boot, so an install that aborts anywhere after that point
would take the console down for good. Hence two restores — a [Run] entry for
the normal flow, and DeinitializeSetup, which Inno calls even when the user
cancels. Both re-enable only what was enabled before the copy, and enabling an
enabled task does nothing, so the pair is idempotent.

Putting things back also turned up a second bug worth naming: the scripting
entry re-registers its task and then unconditionally disables it, which is
right on a first install and has silently switched an operator's plugin runner
off on every upgrade since. The restore runs after both re-registrations and
honours the prior state, so a fresh install still leaves the opt-in runner off.

The Pascal and the PowerShell were both compiled and run on the Windows CI
runner (ISCC, plus the query halves of each command) — but the install path
itself is not yet exercised on glass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:36:46 +02:00
enricobuehlerandClaude Fable 5 b275e6d34c feat(web,docs): the Updates card — version, channel, install kind, and the exact update command
android / android (push) Canceled after 1m54s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / docs-site (push) Successful in 1m15s
arch / build-publish (push) Canceled after 1m50s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
ci / rust (push) Canceled after 59s
ci / rust-arm64 (push) Canceled after 2m31s
ci / web (push) Canceled after 0s
deb / build-publish (push) Canceled after 18s
deb / build-publish-host (push) Canceled after 19s
deb / build-publish-client-arm64 (push) Canceled after 12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 36s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m28s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
decky / build-publish (push) Successful in 50s
Notify-only (U0): polls /update/status (which keeps the host's manifest cache
warm), Check-now with the 30s limit surfaced, release-notes link, stale-feed and
check-disabled states, copyable per-install-kind command. en+de strings; docs-site
'Updating the Host' page wired into the install section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:34:47 +02:00
enricobuehlerandClaude Fable 5 c4c4f217bf ci(update): build+sign+publish the update manifest — stable at announce, canary after the canary installer
bash+openssl signer (raw-64-byte ed25519 over exact bytes, base64 .sig — the
plugin-index format) with the pinned-key cross-check, manifest-then-sig upload
order, and a live-feed self-verify. announce.yml re-hashes the installer against
its sidecar and fail-closes without UPDATE_MANIFEST_KEY; pre-release tags never
enter the stable feed. windows-host.yml grows a Linux canary-manifest job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:34:47 +02:00
enricobuehlerandClaude Fable 5 cc01562631 feat(host): update check — signed per-channel manifest, install-kind detection, /api/v1/update surface
The U0 leg of planning:host-update-from-web-console.md: a signed update manifest
(Ed25519, keys pinned in the binary via the plugin-store verify path, serial floor
persisted against rollback, channel-bound, 45-day stale hint) fetched lazily behind
GET /update/status + rate-limited POST /update/check, admin lane only (plugin lane
whole-prefix denied, absent from the cert allowlist). Install kind + channel come
from root-owned facts; deb/rpm/pacman builds now stamp /usr/share/punktfunk/install-kind.
Emits update.available once per discovered version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:34:46 +02:00
enricobuehlerandClaude Opus 5 940bd0b7ec feat(core/abr): the startup capacity probe's target is configurable
ci / docs-site (push) Successful in 1m20s
ci / web (push) Successful in 1m31s
ci / rust-arm64 (push) Successful in 1m44s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
apple / swift (push) Successful in 4m43s
android / android (push) Successful in 6m18s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
deb / build-publish-host (push) Successful in 6m14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
deb / build-publish (push) Successful in 5m16s
docker / builders-arm64cross (push) Successful in 12s
docker / deploy-docs (push) Successful in 39s
arch / build-publish (push) Successful in 9m18s
apple / screenshots (push) Canceled after 4m51s
ci / rust (push) Successful in 9m43s
deb / build-publish-client-arm64 (push) Canceled after 8m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 8m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 8m5s
windows-host / package (push) Canceled after 10m19s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Successful in 7m26s
release / apple (push) Canceled after 9m48s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 3m23s
The Automatic startup probe bursts at a fixed 2 Gbps two seconds into every
session, deliberately far above any plausible link so the burst measures the
link rather than itself. On links the burst DISTURBS, that backfires: on an
LG G5 (webOS 10.3), three back-to-back connects to the same Gamescope host
split two ways — the two where the probe finished in ~1-2 s had video within
2-4 s, while the one that hit the 6 s timeout showed no video for 14 s. Even
a "successful" probe on that link reported send_dropped=20211. The webOS
client already caps its own speed test at 320 Mbps because an unbounded
firehose starves a 2-3 core TV; core then bursts the same hardware at 2 Gbps.

PUNKTFUNK_ABR_PROBE_KBPS now sets that target, so an embedder that caps its
own speed test can cap ours to match. Unset, zero, or unparseable keeps the
2 Gbps default, so every existing session behaves exactly as before. The
existing opt-out (PUNKTFUNK_ABR_PROBE=0) is no substitute: it leaves the
climb ceiling pinned at the negotiated ~20 Mbps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:24:57 +02:00
enricobuehlerandClaude Fable 5 77517bbe21 style(web): let biome format the message catalogues and the Logs story
ci / web (push) Successful in 1m16s
ci / rust-arm64 (push) Successful in 1m33s
ci / docs-site (push) Successful in 1m37s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 27s
deb / build-publish-client-arm64 (push) Successful in 1m57s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m5s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 41s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / deploy-docs (push) Successful in 37s
deb / build-publish-host (push) Successful in 4m29s
docker / builders-arm64cross (push) Successful in 9s
deb / build-publish (push) Successful in 5m38s
ci / rust (push) Successful in 6m6s
arch / build-publish (push) Successful in 9m36s
windows-host / package (push) Successful in 20m26s
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 26m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m39s
Cosmetic only, and kept out of the feature commit it rode in on so neither
has to be read through the other. `biome check --write` reindents both
message catalogues from two spaces to tabs (877 lines each, no key, order,
or value touched — verified by comparing the parsed objects) and rewraps the
Logs story fixtures onto one argument per line with trailing commas.

Worth knowing for next time: the catalogues are also written by inlang, which
formats with two spaces, so an edit made through that tooling will pull them
back. Nothing depends on either shape — `bun run codegen` compiles 440
messages for both locales either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:28:37 +02:00
enricobuehlerandClaude Fable 5 8d6241efae feat(web): the logs page can hand a log off — as a file, or to the share sheet
The Logs page could only be read in place. Getting a host log into a bug
report meant selecting a screenful of monospace text and hoping the scroll
container gave up the rest.

Two controls in the toolbar now do it properly. Download writes a .log file
named for the moment it was taken; the second button hands the same text to
the OS share sheet where there is one (phones, iPads), and copies it to the
clipboard everywhere else — which is why it is probed at runtime rather than
guessed, and why the button is absent on the one combination where neither
exists (plain HTTP, no Web Share).

Both export what the filters currently match, not the rendered tail: the
1000-row cap is a DOM budget and has nothing to say about how long a file
may be. Lines carry the full date and UTC offset, since a bare wall-clock
time stops meaning anything the moment the file leaves the browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:07:15 +02:00
131 changed files with 12333 additions and 1875 deletions
+31
View File
@@ -31,6 +31,37 @@ jobs:
steps:
- uses: actions/checkout@v4
# Publish the SIGNED stable update manifest — the moment every host's update check learns
# about this release (planning: host-update-from-web-console.md §3.3). Deliberately here in
# announce, not on the tag: the manual "fleet is green, go" gate doubles as the gate for the
# fleet-wide "update available". Fails the announce loudly if the key is missing (fail-closed)
# or the installer's live bytes don't match their .sha256 sidecar. Pre-release tags are
# ALWAYS skipped — an -rc must never enter the stable feed, even with allow_prerelease.
- name: Publish the stable update manifest
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
run: |
set -euo pipefail
TAG="${{ inputs.tag }}"
case "$TAG" in
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
esac
VER="${TAG#v}"
URL="https://git.unom.io/unom/punktfunk/releases/download/${TAG}/punktfunk-host-setup-${VER}.exe"
# Re-download and re-hash the real bytes; the sidecar is a cross-check, never the truth.
curl -fsSL "$URL" -o /tmp/installer.exe
curl -fsSL "$URL.sha256" -o /tmp/installer.sha256
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
grep -qi "$SHA" /tmp/installer.sha256 || {
echo "ERROR: installer sha256 $SHA does not match the release's .sha256 sidecar" >&2
exit 1
}
CHANNEL=stable VERSION="$VER" REQUIRE_KEY=1 \
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
NOTES_URL="https://git.unom.io/unom/punktfunk/releases/tag/${TAG}" \
bash scripts/ci/publish-update-manifest.sh
- name: Post release announcement to Discord
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
+72
View File
@@ -348,6 +348,21 @@ jobs:
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
Write-Output "web console smoke (bun): /login -> $code"
if ($code -ne 200) { throw "web console failed to boot under bun" }
# WEB_OUTPUT_DIR has to be exported whether or not the step above ran. It used to be that step's
# last line, so a CACHE HIT skipped it and left the variable unset — and pack-host-installer.ps1
# treats an unset WEB_OUTPUT_DIR as "don't bundle the console", silently ("installer built
# WITHOUT the web console"). That shipped in 0.22.1 and 0.22.2: no {app}\web, so no web-run.cmd,
# so `web setup` bails, so no PunktfunkWeb task and no console at all. It also removed the only
# thing that stopped bun before the copy (StopBunRuntimes was #ifdef WithWeb), while bun.exe kept
# shipping under WithScripting — which is the "DeleteFile failed; code 5" modal on bun.exe.
# The throw is the point: never silently ship a console-less installer again.
- name: Export the console output dir (cache hit or fresh build)
shell: pwsh
run: |
if (-not (Test-Path 'web\.output\server\index.mjs')) {
throw "web\.output is missing - neither the cache restore nor the build produced it, and the installer must not ship without the console"
}
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Build plugin/script runner bundle (bun)
@@ -376,6 +391,33 @@ jobs:
# error 3)" and took the whole job with it. ~1 min of rebuild is the correct price; the
# same rotation is why the other Windows jobs use a fixed C:\t instead of a cached
# workspace-relative target.
# Every payload this job is SUPPOSED to bundle, asserted before packing. The packer treats each
# one as optional — correct for a local debug pack, and the reason 0.22.1/0.22.2 shipped with no
# web console: an unset WEB_OUTPUT_DIR omitted it behind a single Write-Host. CI knows it bundles
# all of these, so here a missing input is a build failure rather than a quietly smaller
# installer. (pack-host-installer.ps1 already does this for VB-CABLE, for the same reason.)
- name: Verify every installer payload is present
shell: pwsh
run: |
$need = @(
@{ n = 'web console (WEB_OUTPUT_DIR)'; p = $env:WEB_OUTPUT_DIR; f = 'server\index.mjs' }
@{ n = 'bun runtime (BUN_EXE)'; p = $env:BUN_EXE; f = '' }
@{ n = 'plugin runner (SCRIPTING_BUNDLE)';p = $env:SCRIPTING_BUNDLE; f = '' }
@{ n = 'FFmpeg DLLs (FFMPEG_DIR\bin)'; p = $env:FFMPEG_DIR; f = 'bin' }
@{ n = 'VB-CABLE (VBCABLE_DIR)'; p = $env:VBCABLE_DIR; f = 'VBCABLE_Setup_x64.exe' }
)
$missing = @()
foreach ($x in $need) {
if (-not $x.p) { $missing += "$($x.n): env var not set"; continue }
$full = if ($x.f) { Join-Path $x.p $x.f } else { $x.p }
if (-not (Test-Path $full)) { $missing += "$($x.n): missing $full" }
else { Write-Output "payload OK - $($x.n) -> $full" }
}
if ($missing.Count) {
$missing | ForEach-Object { Write-Output "MISSING PAYLOAD - $_" }
throw "$($missing.Count) installer payload(s) missing - refusing to ship an incomplete installer"
}
- name: Pack + sign installer
shell: pwsh
env:
@@ -460,6 +502,36 @@ jobs:
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
# reads the manifests from the release, so it must not run before they are attached.
# Publish the SIGNED canary update manifest after the canary installer lands (planning:
# host-update-from-web-console.md §3.3 — canary rides this workflow because the installer is
# the only artifact the manifest references by URL; other canary channels may trail by minutes,
# which the per-PM apply path tolerates). A Linux job: the signer is bash+openssl. Skips (with
# a warning) when UPDATE_MANIFEST_KEY is absent — a canary build must not fail over it.
canary-manifest:
needs: package
if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Publish the canary update manifest
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
run: |
set -euo pipefail
# Same derivation the package job used: canary = <next-minor base>'s major.minor + run#.
eval "$(bash scripts/ci/pf-version.sh)"
VER="${PF_MAJOR}.${PF_MINOR}.${GITHUB_RUN_NUMBER}"
URL="https://${REGISTRY}/api/packages/${OWNER}/generic/${PKG}/${VER}/punktfunk-host-setup-${VER}.exe"
curl -fsSL "$URL" -o /tmp/installer.exe
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
CHANNEL=canary VERSION="$VER" CI_RUN="${GITHUB_RUN_NUMBER}" \
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
NOTES_URL="https://git.unom.io/unom/punktfunk/releases" \
bash scripts/ci/publish-update-manifest.sh
winget-source:
needs: package
if: startsWith(gitea.ref, 'refs/tags/v')
Generated
+38 -30
View File
@@ -947,7 +947,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"pf-capture",
@@ -1036,7 +1036,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
@@ -2221,7 +2221,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.22.2"
version = "0.22.3"
[[package]]
name = "lazy_static"
@@ -2326,7 +2326,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"bindgen",
"cmake",
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"punktfunk-core",
]
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ashpd",
@@ -2871,7 +2871,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ash",
@@ -2896,7 +2896,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ashpd",
@@ -2914,7 +2914,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ash",
@@ -2935,7 +2935,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ash",
@@ -2959,7 +2959,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"ash",
"bindgen",
@@ -2968,7 +2968,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"libc",
@@ -2980,7 +2980,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2994,11 +2994,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.22.2"
version = "0.22.3"
[[package]]
name = "pf-inject"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ashpd",
@@ -3027,14 +3027,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ash",
@@ -3047,9 +3047,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pf-update"
version = "0.22.3"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "pf-vdisplay"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ashpd",
@@ -3082,7 +3090,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"pf-paths",
@@ -3094,7 +3102,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ash",
@@ -3302,7 +3310,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3313,7 +3321,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"android_logger",
"jni",
@@ -3329,7 +3337,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"async-channel",
@@ -3346,7 +3354,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3361,7 +3369,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3381,7 +3389,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"aes-gcm",
"bytes",
@@ -3413,7 +3421,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"aes",
"aes-gcm",
@@ -3497,7 +3505,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3511,7 +3519,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ksni",
@@ -3534,7 +3542,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"bindgen",
"cmake",
+2 -1
View File
@@ -12,6 +12,7 @@ members = [
"crates/pf-ffvk",
"crates/pf-driver-proto",
"crates/pf-paths",
"crates/pf-update",
"crates/pf-host-config",
"crates/pf-gpu",
"crates/pf-zerocopy",
@@ -51,7 +52,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.22.2"
version = "0.22.3"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+425 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.21.0"
"version": "0.22.3"
},
"paths": {
"/api/v1/clients": {
@@ -3432,6 +3432,142 @@
}
}
}
},
"/api/v1/update/apply": {
"post": {
"tags": [
"update"
],
"summary": "Apply the available update",
"description": "Starts the one-click apply for install kinds that support it (Windows installer). The\nrequest carries no version or URL — the host installs exactly what its verified manifest\nannounced. Progress is polled via `GET /update/status` (`job`); the host restarts as part\nof the apply, and the outcome lands in `last_result` after it comes back.",
"operationId": "applyUpdate",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApplyRequest"
}
}
},
"required": true
},
"responses": {
"202": {
"description": "Apply started — poll `GET /update/status`",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateStatus"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"409": {
"description": "Refused: unsupported install kind, apply disabled (PUNKTFUNK_UPDATE_APPLY=0), a job already running, an active streaming session without `force`, or nothing newer to apply",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/update/check": {
"post": {
"tags": [
"update"
],
"summary": "Check for updates now",
"description": "Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to\none forced check per 30 s.",
"operationId": "forceUpdateCheck",
"responses": {
"200": {
"description": "Refreshed update-check state (`last_error` carries a failed check)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateStatus"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"409": {
"description": "Update checks are disabled on this host",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"429": {
"description": "A forced check ran less than 30 s ago",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/update/status": {
"get": {
"tags": [
"update"
],
"summary": "Update-check status",
"description": "How this host was installed, which channel it follows, whether a newer release is known,\nand how to update. Reading this may kick a background refresh when the cached check is\nolder than 6 h; the response never blocks on the network.",
"operationId": "getUpdateStatus",
"responses": {
"200": {
"description": "Current update-check state",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateStatus"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
}
},
"components": {
@@ -3764,6 +3900,15 @@
}
}
},
"ApplyRequest": {
"type": "object",
"properties": {
"force": {
"type": "boolean",
"description": "Proceed even while a streaming session is live (the stream will drop when the host\nrestarts — the console warns before sending this)."
}
}
},
"ApprovePending": {
"type": "object",
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
@@ -4799,6 +4944,59 @@
}
}
},
{
"type": "object",
"description": "A verified update manifest announced a release newer than the running host. Emitted\nonce per discovered version (a steady-state \"newer exists\" doesn't re-fire on every\nrefresh).",
"required": [
"version",
"channel",
"install_kind",
"kind"
],
"properties": {
"channel": {
"type": "string",
"description": "The channel it was announced on (`stable` | `canary`)."
},
"install_kind": {
"type": "string",
"description": "This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the\ntray render the right \"how to update\" hint without a second call."
},
"kind": {
"type": "string",
"enum": [
"update.available"
]
},
"version": {
"type": "string",
"description": "The newer release's version string."
}
}
},
{
"type": "object",
"description": "A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's\nfirst start after a successful apply.",
"required": [
"from",
"to",
"kind"
],
"properties": {
"from": {
"type": "string"
},
"kind": {
"type": "string",
"enum": [
"update.applied"
]
},
"to": {
"type": "string"
}
}
},
{
"type": "object",
"required": [
@@ -7040,6 +7238,228 @@
"type": "string"
}
}
},
"UpdateJobInfo": {
"type": "object",
"description": "A running apply job (or a spawned installer that hasn't resolved yet).",
"required": [
"target_version",
"stage",
"received_bytes",
"started_unix"
],
"properties": {
"received_bytes": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"stage": {
"type": "string",
"description": "`downloading` | `verifying` | `applying` | `restarting`."
},
"started_unix": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"target_version": {
"type": "string",
"description": "The version being installed."
},
"total_bytes": {
"type": [
"integer",
"null"
],
"format": "int64",
"minimum": 0
}
}
},
"UpdateManifestInfo": {
"type": "object",
"description": "One channel's manifest facts, as much as the console renders.",
"required": [
"version",
"serial",
"published_at",
"notes_url",
"stale"
],
"properties": {
"notes_url": {
"type": "string",
"description": "Release-notes link (pinned to our forge by the manifest validator)."
},
"published_at": {
"type": "string",
"description": "RFC-3339 publish time (display only)."
},
"serial": {
"type": "integer",
"format": "int64",
"description": "Publish serial (unix seconds) — monotonic per channel.",
"minimum": 0
},
"stale": {
"type": "boolean",
"description": "The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint."
},
"version": {
"type": "string",
"description": "The released version this manifest announces."
}
}
},
"UpdateResultInfo": {
"type": "object",
"description": "Durable outcome of the most recent apply attempt (survives the host's own restart).",
"required": [
"ok",
"from",
"to",
"finished_unix"
],
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"finished_unix": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"from": {
"type": "string"
},
"log_path": {
"type": [
"string",
"null"
],
"description": "The installer's own log file on this host, for diagnosis."
},
"ok": {
"type": "boolean"
},
"stage": {
"type": [
"string",
"null"
],
"description": "The stage that failed; absent on success."
},
"staged": {
"type": "boolean",
"description": "Applied but activates on the next reboot (rpm-ostree)."
},
"to": {
"type": "string"
}
}
},
"UpdateStatus": {
"type": "object",
"description": "The full update-check state for this host.",
"required": [
"install_kind",
"channel",
"current_version",
"apply",
"channel_hint",
"check_disabled",
"available"
],
"properties": {
"apply": {
"type": "string",
"description": "What the console may offer for this install: `notify` (show the command) — later\nphases add `full` (one-click apply) and `staged` (apply + reboot to finish)."
},
"available": {
"type": "boolean",
"description": "A newer release than `current_version` exists for this channel (definitive\ncomparisons only — an unparseable version pair never flags)."
},
"channel": {
"type": "string",
"description": "Release channel this install follows: `stable` | `canary`."
},
"channel_hint": {
"type": "string",
"description": "The copy-pastable update command for this install kind."
},
"check_disabled": {
"type": "boolean",
"description": "Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`)."
},
"current_version": {
"type": "string",
"description": "The running host version."
},
"install_kind": {
"type": "string",
"description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`."
},
"job": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/UpdateJobInfo",
"description": "The apply in flight, if any."
}
]
},
"last_checked_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "When the last successful check happened (unix seconds).",
"minimum": 0
},
"last_error": {
"type": [
"string",
"null"
],
"description": "Why the last check failed, verbatim, if it did."
},
"last_result": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/UpdateResultInfo",
"description": "Outcome of the most recent apply attempt."
}
]
},
"manifest": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/UpdateManifestInfo",
"description": "The last verified manifest, if any check has succeeded."
}
]
},
"opt_in_hint": {
"type": [
"string",
"null"
],
"description": "This install could one-click apply, but the operator hasn't opted in yet — the\ncommand to run (Linux: join the `punktfunk-update` group)."
}
}
}
},
"securitySchemes": {
@@ -7110,6 +7530,10 @@
{
"name": "store",
"description": "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"
},
{
"name": "update",
"description": "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"
}
]
}
@@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.Sc2Capture
import kotlinx.coroutines.delay
@@ -149,13 +150,14 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
) {
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
// Steam Controller 2 detection: never an InputDevice (lizard mode is kb/mouse; the
// capture claims even those away), so it's enumerated on the capture side — USB device
// list + bonded BLE — and re-checked on USB hot-plug.
var sc2Generation by remember { mutableIntStateOf(0) }
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
// the USB device list + bonded BLE; a Sony pad IS an InputDevice until claimed, so its
// row supplements the PadRow below with the capture status + the USB grant.
var usbGeneration by remember { mutableIntStateOf(0) }
DisposableEffect(Unit) {
val receiver = object : android.content.BroadcastReceiver() {
override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ }
override fun onReceive(c: Context?, i: android.content.Intent?) { usbGeneration++ }
}
val filter = android.content.IntentFilter().apply {
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
@@ -170,16 +172,23 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
onDispose { runCatching { context.unregisterReceiver(receiver) } }
}
val sc2Probe = remember { Sc2Capture(context) }
val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() }
val sc2Ble = remember(sc2Generation) {
val sc2Usb = remember(usbGeneration) { sc2Probe.findUsbDevice() }
val sc2Ble = remember(usbGeneration) {
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
) sc2Probe.pairedBleAddress() else null
}
val sc2Present = sc2Usb != null || sc2Ble != null
val dsUsb = remember(usbGeneration) {
(context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager)
.deviceList.values.firstOrNull {
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
}
}
Group("Gamepads") {
if (sc2Present) Sc2Row(sc2Usb, activity)
dsUsb?.let { DsRow(it) }
if (pads.isEmpty() && !sc2Present) {
Text(
"No controller detected. punktfunk can only forward devices Android " +
@@ -319,6 +328,104 @@ private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivi
}
}
/**
* Broadcast action for the Sony-pad USB grants — fired by both the menu-time auto-ask
* ([MainActivity.maybeAskDsPermission]) and [DsRow]'s explicit button, so an open card
* refreshes whichever dialog was answered.
*/
internal const val DS_USB_PERMISSION_ACTION = "io.unom.punktfunk.DS_CONTROLLERS_USB_PERMISSION"
/**
* The Sony USB pad card — capture status + the USB grant. The grant normally arrives via the
* menu-time auto-ask the moment the pad attaches ([MainActivity.maybeAskDsPermission]); the
* button here is the recovery path after a deny (the auto-ask fires once per attach). Shown
* ALONGSIDE the pad's ordinary [PadRow] (unclaimed it is still an InputDevice); the capture
* itself only runs inside a stream, so at menu time this card is pure status.
*/
@Composable
private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
val context = LocalContext.current
val settingOn = remember { SettingsStore(context).load().dsCapture }
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
var permitted by remember(usbDev) { mutableStateOf(usbManager.hasPermission(usbDev)) }
val model = DsDevice.modelFor(usbDev.productId)
val label = when (model) {
DsDevice.Model.DUALSENSE -> "DualSense"
DsDevice.Model.DUALSENSE_EDGE -> "DualSense Edge"
DsDevice.Model.DUALSHOCK4 -> "DualShock 4"
null -> return
}
// Refresh `permitted` when the grant dialog answers (the grant itself is system-recorded;
// this receiver only updates the card).
val action = DS_USB_PERMISSION_ACTION
DisposableEffect(usbDev) {
val receiver = object : android.content.BroadcastReceiver() {
override fun onReceive(c: Context?, i: android.content.Intent?) {
if (i?.action == action) permitted = usbManager.hasPermission(usbDev)
}
}
androidx.core.content.ContextCompat.registerReceiver(
context,
receiver,
android.content.IntentFilter(action),
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
)
onDispose { runCatching { context.unregisterReceiver(receiver) } }
}
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("$label passthrough", style = MaterialTheme.typography.bodyLarge)
Text(
"Wired (USB)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
when {
!settingOn -> Text(
"Passthrough is disabled in Settings — enable \"DualSense / DualShock " +
"passthrough (USB)\" to capture it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
!permitted -> {
Text(
"Needs USB access — grant it now and streams capture the pad silently.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = {
usbManager.requestPermission(
usbDev,
android.app.PendingIntent.getBroadcast(
context, 3, // requestCode 3 — 0/1/2 are the SC2/stream grants
android.content.Intent(action).setPackage(context.packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
android.app.PendingIntent.FLAG_MUTABLE,
),
)
}) {
Text("Grant USB access")
}
}
else -> Text(
if (model == DsDevice.Model.DUALSHOCK4) {
"Ready — captured at stream start: rumble, lightbar and gyro are " +
"driven directly."
} else {
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
"and gyro are driven directly."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/** One detected gamepad: identity, what it streams as, and a rumble test. */
@Composable
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
@@ -28,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.Keymap
@@ -169,6 +170,10 @@ class MainActivity : ComponentActivity() {
private var sc2Receiver: BroadcastReceiver? = null
private var sc2PermissionAsked = false
/** Sony-pad USB grant asked this attach — a deny doesn't re-nag until a fresh attach (or the
* Controllers screen's explicit button). */
private var dsPermissionAsked = false
/**
* Compose focus hook for the SC2's synthetic D-pad (set by [onCreate]'s composition). A
* synthetic KeyEvent dispatched from OUTSIDE the real input pipeline never reaches
@@ -225,6 +230,8 @@ class MainActivity : ComponentActivity() {
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
sc2PermissionAsked = false // a fresh attach may ask once again
startSc2MenuNav()
dsPermissionAsked = false
maybeAskDsPermission()
}
SC2_MENU_PERMISSION -> {
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
@@ -281,6 +288,7 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
startSc2MenuNav()
maybeAskDsPermission()
}
override fun onPause() {
@@ -341,6 +349,37 @@ class MainActivity : ComponentActivity() {
sc2MenuActive = false
}
/**
* Ask for USB access to an attached Sony pad the moment it appears — a fresh attach while
* the app is open, or the app coming to the foreground with one already plugged in — at most
* once per attach, so the stream-mode capture ([io.unom.punktfunk.kit.DsCapture]) engages
* silently instead of interrupting stream start with the dialog. Unlike the SC2's menu flow
* there is nothing to START on the grant: an uncaptured Sony pad is an ordinary InputDevice
* at menu time, so the grant is simply recorded (Android keeps it while the pad stays
* attached). The broadcast only refreshes the Controllers screen's card if it happens to be
* open; a deny leaves that card's explicit button as the re-ask.
*/
private fun maybeAskDsPermission() {
if (streamHandle != 0L) return // StreamScreen owns its own permission flow while streaming
if (dsPermissionAsked) return
if (!SettingsStore(this).load().dsCapture) return
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
val dev = usbManager.deviceList.values.firstOrNull {
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
} ?: return
if (usbManager.hasPermission(dev)) return
dsPermissionAsked = true
usbManager.requestPermission(
dev,
PendingIntent.getBroadcast(
this, 4, // requestCode 4 — 0..3 are the SC2 stream/menu + DS stream/card grants
Intent(DS_USB_PERMISSION_ACTION).setPackage(packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
/**
* One SC2 navigation key transition from the menu-time capture (main thread) — routed the
* same way [dispatchKeyEvent]'s not-streaming branch routes a real pad's buttons: B backs,
@@ -405,8 +444,8 @@ class MainActivity : ComponentActivity() {
/**
* Opt the CONSOLE UI into the panel's highest refresh mode. Some OEMs (Nothing OS among them) pin
* third-party apps to 60Hz unless they explicitly ask for more, which halves the smoothness of the
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] turns this OFF while streaming so
* its own `ANativeWindow_setFrameRate` (matched to the video) governs the panel instead.
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] replaces this with
* [setStreamDisplayMode] while streaming (matched to the video, not to the panel maximum).
*/
fun setConsoleHighRefreshRate(high: Boolean) {
if (highRefreshModeId == 0) return
@@ -415,6 +454,64 @@ class MainActivity : ComponentActivity() {
}
}
/**
* Pin the panel to a display mode matching the STREAM's refresh for the session's duration —
* exact rate first, else the smallest integer multiple (120 for a 60 stream: judder-free 2:1
* pulldown), else the highest available. Same-resolution modes only.
*
* The window-level mode pin is the belt to the decoder's `ANativeWindow_setFrameRate` braces:
* the surface hint alone is advisory, and several OEM refresh governors (Nothing OS's LTPO
* logic among them) ignore it entirely for third-party apps — leaving a 120 Hz session
* presenting on a 60/90 Hz panel, which reads as judder + a refresh of extra latency. The
* preferredDisplayModeId is the one signal they all honor. [hz] ≤ 0 falls back to releasing
* the pin (the pre-pin behaviour).
*/
fun setStreamDisplayMode(hz: Int) {
if (hz <= 0) {
setConsoleHighRefreshRate(false)
return
}
val target = streamModeFor(hz) ?: return
window.attributes = window.attributes.apply { preferredDisplayModeId = target.modeId }
}
/**
* The panel refresh rate a [hz] stream runs against — [streamModeFor]'s pick, from the mode
* TABLE rather than `display.refreshRate`. The distinction matters: under a per-uid frame
* rate override (games get a 60 fps default on Android 15+) `refreshRate` reports the
* override, not the panel — observed on-glass as a 120 Hz panel reading back as 60. The
* supported-modes list is not override-filtered. `0` when unresolvable.
*/
fun streamPanelFps(hz: Int): Int =
streamModeFor(hz)?.refreshRate?.let { kotlin.math.round(it).toInt() } ?: 0
/** The same-resolution display mode [setStreamDisplayMode] pins for a [hz] stream. */
private fun streamModeFor(hz: Int): android.view.Display.Mode? {
if (hz <= 0) return null
@Suppress("DEPRECATION")
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
val current = disp?.mode ?: return null
val sameRes = disp.supportedModes.filter {
it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight
}
fun multiple(rate: Float): Int {
val k = (rate / hz).toInt()
return if (k >= 2 && kotlin.math.abs(rate - hz * k) < 1f) k else 0
}
return sameRes.minWithOrNull(
compareBy(
{
when {
kotlin.math.abs(it.refreshRate - hz) < 1f -> 0 // exact
multiple(it.refreshRate) > 0 -> 1 // integer multiple — prefer smallest
else -> 2 // no relation — prefer highest so at least nothing is halved
}
},
{ if (multiple(it.refreshRate) > 0) it.refreshRate else -it.refreshRate },
),
)
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val handle = streamHandle
if (handle != 0L) {
@@ -48,6 +48,9 @@ data class SettingsOverlay(
* else, but here it is the one knob a marginal link wants turned off per host.
*/
val lowLatencyMode: Boolean? = null,
/** The timeline presenter's intent pair — cross-client keys, see [Settings.presentPriority]. */
val presentPriority: String? = null,
val smoothBuffer: Int? = null,
/**
* Overlay keys a newer build wrote and this one doesn't model carried through a loadsave
* round-trip untouched. The don't-clobber rule: opening and saving a profile on an older client
@@ -73,6 +76,8 @@ data class SettingsOverlay(
gamepad = gamepad ?: base.gamepad,
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
presentPriority = presentPriority ?: base.presentPriority,
smoothBuffer = smoothBuffer ?: base.smoothBuffer,
)
/**
@@ -104,6 +109,8 @@ data class SettingsOverlay(
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
smoothBuffer = if (after.smoothBuffer != before.smoothBuffer) after.smoothBuffer else smoothBuffer,
)
/**
@@ -127,6 +134,8 @@ data class SettingsOverlay(
"gamepad" -> copy(gamepad = null)
"stats_verbosity" -> copy(statsVerbosity = null)
"low_latency_mode" -> copy(lowLatencyMode = null)
"present_priority" -> copy(presentPriority = null)
"smooth_buffer" -> copy(smoothBuffer = null)
else -> this
}
@@ -147,6 +156,8 @@ data class SettingsOverlay(
if (gamepad != null) add("gamepad")
if (statsVerbosity != null) add("stats_verbosity")
if (lowLatencyMode != null) add("low_latency_mode")
if (presentPriority != null) add("present_priority")
if (smoothBuffer != null) add("smooth_buffer")
}
/**
@@ -175,6 +186,8 @@ data class SettingsOverlay(
gamepad?.let { j.put("gamepad", it) }
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
lowLatencyMode?.let { j.put("low_latency_mode", it) }
presentPriority?.let { j.put("present_priority", it) }
smoothBuffer?.let { j.put("smooth_buffer", it) }
return j
}
@@ -187,6 +200,7 @@ data class SettingsOverlay(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "touch_mode",
"mouse_mode", "invert_scroll", "gamepad", "stats_verbosity", "low_latency_mode",
"present_priority", "smooth_buffer",
)
internal fun fromJson(j: JSONObject): SettingsOverlay = SettingsOverlay(
@@ -209,6 +223,8 @@ data class SettingsOverlay(
statsVerbosity = j.optStringOrNull("stats_verbosity")
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
presentPriority = j.optStringOrNull("present_priority"),
smoothBuffer = j.optIntOrNull("smooth_buffer"),
extra = j.keys().asSequence().filter { it !in KNOWN }.associateWith { j.get(it) },
)
}
@@ -83,6 +83,19 @@ data class Settings(
* feeds a queue that only grows.
*/
val lowLatencyMode: Boolean = true,
/**
* The timeline presenter's intent the cross-client `present_priority` pair (the Apple
* client's "Prioritize" picker, same stored values): `"latency"` (default) = newest-wins,
* a frame reaches glass the instant the glass budget opens; `"smooth"` = a small FIFO
* drained one frame per vsync, absorbing network/decode jitter at one refresh of added
* display latency per buffered frame. Anything unrecognized resolves to latency.
*/
val presentPriority: String = "latency",
/**
* The smoothness buffer depth (`smooth_buffer`): 0 = Automatic (2 frames), else 1..3.
* Only meaningful when [presentPriority] is `"smooth"`.
*/
val smoothBuffer: Int = 0,
/**
* Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default):
* a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits
@@ -110,6 +123,18 @@ data class Settings(
*/
val sc2Capture: Boolean = true,
/**
* Capture a USB-connected Sony controller (DualSense / DualSense Edge / DualShock 4) and
* drive it directly: the app claims the pad's HID interface and renders the host's feedback
* by writing USB output reports rumble works on every phone (no kernel force-feedback
* driver needed), and adaptive triggers + lightbar + player LEDs work at all (Android has no
* platform API for any of them). ON by default it engages only when such a pad is attached
* over USB at stream start; uncaptured (toggle off / no permission / Bluetooth) the pad stays
* on the ordinary InputDevice path. USB only: Android exposes no raw path to a Bluetooth
* Classic pad, which is also why Sony's own Remote Play has no Android trigger support.
*/
val dsCapture: Boolean = true,
/**
* How a physical mouse drives the host the cross-client mouse model (see [MouseMode]).
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
@@ -201,9 +226,12 @@ class SettingsStore(context: Context) {
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency",
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
mouseMode = prefs.getString(K_MOUSE_MODE, null)
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
@@ -231,9 +259,12 @@ class SettingsStore(context: Context) {
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
.putString(K_PRESENT_PRIORITY, s.presentPriority)
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_DS_CAPTURE, s.dsCapture)
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
.apply()
@@ -271,9 +302,12 @@ class SettingsStore(context: Context) {
* on; both stale keys are abandoned unread. The toggle stays as a per-device escape hatch.
*/
const val K_LOW_LATENCY = "low_latency_mode_v2"
const val K_PRESENT_PRIORITY = "present_priority"
const val K_SMOOTH_BUFFER = "smooth_buffer"
const val K_AUTO_WAKE = "auto_wake_enabled"
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_SC2_CAPTURE = "sc2_capture"
const val K_DS_CAPTURE = "ds_capture"
const val K_MOUSE_MODE = "mouse_mode"
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
@@ -491,6 +525,29 @@ val COMPOSITOR_OPTIONS = listOf(
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
/** [Settings.presentPriority] as the wire int `nativeStartVideo` takes (0 = latency, 1 = smooth).
* Unrecognized values resolve to latency same rule as the Apple client. */
fun Settings.presentPriorityWire(): Int = if (presentPriority == "smooth") 1 else 0
/** (stored value, label) for the presenter-intent picker — the Apple client's table verbatim. */
val PRESENT_PRIORITY_OPTIONS = listOf(
"latency" to "Lowest latency",
"smooth" to "Smoothness",
)
/** (frames, label) for the smoothness-buffer picker; each buffered frame one refresh interval
* of jitter absorbed for one interval of added display latency ([hz] labels the cost). */
fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
val periodMs = 1000.0 / maxOf(24, hz)
fun cost(frames: Int) = "+%.0f ms".format(periodMs * frames)
return listOf(
0 to "Automatic",
1 to "1 frame (${cost(1)})",
2 to "2 frames (${cost(2)})",
3 to "3 frames (${cost(3)})",
)
}
/** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad",
@@ -711,6 +711,26 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
field = "low_latency_mode",
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
)
// The timeline presenter's intent — the Apple client's "Prioritize" pair, same stored
// values, so a profile written on one platform means the same thing here.
SettingDropdown(
label = "Prioritize",
options = PRESENT_PRIORITY_OPTIONS,
selected = if (s.presentPriority == "smooth") "smooth" else "latency",
field = "present_priority",
caption = "Lowest latency shows each frame the moment it can reach the panel; " +
"Smoothness buffers a little to absorb network jitter.",
) { v -> update(s.copy(presentPriority = v)) }
if (s.presentPriority == "smooth") {
SettingDropdown(
label = "Smoothness buffer",
options = smoothBufferOptions(if (s.hz > 0) s.hz else nhz),
selected = if (s.smoothBuffer in 1..3) s.smoothBuffer else 0,
field = "smooth_buffer",
caption = "Each buffered frame absorbs one refresh of jitter and adds one of " +
"display latency — the cost shown is at the session's refresh rate.",
) { v -> update(s.copy(smoothBuffer = v)) }
}
}
SettingsGroup("Host output", footer = "Display changes apply from the next session.") {
@@ -816,6 +836,15 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
checked = s.sc2Capture,
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
)
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
// the CONTROLLER's own motors/LEDs, not this device's.
ToggleRow(
title = "DualSense / DualShock passthrough (USB)",
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
"plus adaptive triggers, lightbar and gyro",
checked = s.dsCapture,
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
)
}
}
}
@@ -46,12 +46,20 @@ internal fun StatsOverlay(
* common case: no profile) the line is exactly what it always was.
*/
profileName: String? = null,
/**
* The panel's live refresh rate (0 = unknown). Shown as a warning on the first line whenever
* it sits below the stream rate the "an OEM governor ignored the mode pin" tell, which
* otherwise reads as inexplicable judder and an extra refresh of latency.
*/
panelHz: Float = 0f,
modifier: Modifier = Modifier,
) {
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
val w = s[6].toInt()
val h = s[7].toInt()
val hz = s[8].toInt()
val panelBelowStream = panelHz > 0f && hz > 0 && panelHz + 1f < hz.toFloat()
val panelTag = if (panelBelowStream) " ⚠ panel ${panelHz.roundToInt()} Hz" else ""
val latValid = s[4] != 0.0
val skew = s[5] != 0.0
val lost = s[9].toLong()
@@ -65,12 +73,12 @@ internal fun StatsOverlay(
val profileTag = profileName?.let { " · $it" }.orEmpty()
// Compact: everything the glance-value needs on one line, nothing else.
if (verbosity == StatsVerbosity.COMPACT) {
statLine(compactLine(s, latValid) + profileTag, Color.White)
statLine(compactLine(s, latValid) + profileTag + panelTag, Color.White)
return@Column
}
statLine(
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag",
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag$panelTag",
Color.White,
)
if (detailed && decoderLabel.isNotEmpty()) {
@@ -104,8 +112,27 @@ internal fun StatsOverlay(
} else {
"host+network ${"%.1f".format(s[14])}"
}
val displayTerm = if (dispValid) " + display ${"%.1f".format(s[23])}" else ""
statLine("= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm", Color.White)
// Timeline-presenter split (s[26]/s[27], when s[29] flags it active): the display
// term decomposes into pace (store + glass budget) + latch (SurfaceFlinger), and
// s[28] is the on-glass confirm count — presents ≪ fps means the presenter is
// dropping/serializing, an fps deficit is upstream.
val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0)
val displayTerm = when {
dispValid && split ->
" + display ${"%.1f".format(s[23])} " +
"(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})"
dispValid -> " + display ${"%.1f".format(s[23])}"
else -> ""
}
val presents = if (s.size >= 30 && s[29] != 0.0) {
" · presents ${s[28].toInt()}"
} else {
""
}
statLine(
"= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm$presents",
Color.White,
)
}
}
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
@@ -54,6 +54,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import io.unom.punktfunk.kit.DsCapture
import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.deviceBodyVibrator
@@ -62,6 +63,7 @@ import io.unom.punktfunk.kit.Sc2Capture
import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.models.ActiveSession
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
/**
@@ -77,7 +79,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
val micEnabled = initialSettings.micEnabled
val context = LocalContext.current
val activity = context as? MainActivity
// The View hosting this composition — the one that receives the stream's touch/pointer events
// (the gesture Box below is a Compose node inside it), so it is where unbuffered dispatch is
// requested.
val composeView = androidx.compose.ui.platform.LocalView.current
val window = activity?.window
// The negotiated stream refresh, known from the handshake (0 = unknown / older native lib) —
// drives the panel mode pin, the render-rate vote, and the presenter's latch grid.
val streamHz = remember(handle) { NativeBridge.nativeVideoSize(handle)?.getOrNull(2) ?: 0 }
val controller = remember(window) {
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
}
@@ -99,6 +108,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
var stats by remember { mutableStateOf<DoubleArray?>(null) }
var decoderLabel by remember { mutableStateOf("") }
var codecLabel by remember { mutableStateOf("") }
// The panel's LIVE refresh rate, re-read each poll — the HUD flags a session whose panel sits
// below the stream rate (an OEM governor that ignored both the mode pin and the surface hint).
var panelHz by remember { mutableStateOf(0f) }
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
val statsOn = statsVerbosity != StatsVerbosity.OFF
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
@@ -120,6 +132,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
while (true) {
delay(1000)
stats = NativeBridge.nativeVideoStats(handle)
panelHz = runCatching { context.display }.getOrNull()?.refreshRate ?: 0f
// The decoder is fixed for the session; fetch its label once it's resolved.
if (decoderLabel.isEmpty()) decoderLabel = NativeBridge.nativeVideoDecoderLabel(handle)
}
@@ -226,13 +239,39 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
val priorSoftInput = window?.attributes?.softInputMode
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
// Draw under the display cutout, explicitly. Android 15's SDK-35 edge-to-edge enforcement
// makes ALWAYS the immersive default, but pre-15 devices letterbox the notch as a dead
// black bar unless asked — and the stream's own letterbox is black anyway, so the cutout
// region can never show anything wrong. Captured + restored like the rest of the window
// state so the menus keep their platform-default behaviour.
val priorCutout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.attributes?.layoutInDisplayCutoutMode
} else {
null
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.let { w ->
w.attributes = w.attributes.apply {
layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
}
}
}
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
// activity declares configChanges=orientation, so this re-lays out the surface in place without
// recreating the activity (no stream restart). On TV (fixed landscape) it's a harmless no-op.
// The prior request is captured and restored on the way out.
//
// COMPACT devices only (sw < 600 dp): on tablets/foldables/desktop windows the lock is a
// large-display anti-pattern (Play flags it; Android 16+ ignores it there outright), and the
// stream doesn't need it — the aspect-ratio letterbox renders correctly in any orientation,
// the lock is purely a phone-ergonomics choice.
val compactDevice = context.resources.configuration.smallestScreenWidthDp < 600
val priorOrientation = activity?.requestedOrientation
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
if (compactDevice) {
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
activity?.streamHandle = handle // route hardware keys to this session
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
@@ -304,7 +343,30 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
} else {
null
}
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
// Pin the panel to the stream's refresh (exact / multiple) for the session. The decoder's
// own ANativeWindow_setFrameRate hint still aligns vsync, but it is advisory — some OEM
// refresh governors ignore it outright and would leave a 120 Hz session on a 60/90 Hz
// panel. TV boxes skip the pin: the native side actively drives the HDMI mode there.
if (isTv) {
activity?.setConsoleHighRefreshRate(false) // the decoder's HDMI mode switch governs
} else {
activity?.setStreamDisplayMode(streamHz)
}
// Touch/pointer events are vsync-batched by default — up to a frame of input latency the
// stream shouldn't pay. Unbuffered dispatch delivers them the moment the kernel does.
// Undone by passing 0 on the way out (API 30+).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
composeView.requestUnbufferedDispatch(android.view.InputDevice.SOURCE_CLASS_POINTER)
}
// Vote the app's RENDER rate up to the stream's (API 35+). The mode pin above governs the
// panel, but the platform separately down-rates a quiet app's choreographer stream
// (frame-rate categories: a non-animating UI reads as "normal" = 60) — observed on-glass
// as 16.6 ms vsync callbacks on a 120 Hz panel, which would pace the presenter at half
// rate. The native side also subdivides onto the panel grid, so this vote is the belt to
// that braces. Reset to no-preference on the way out.
if (Build.VERSION.SDK_INT >= 35 && streamHz > 0) {
composeView.requestedFrameRate = streamHz.toFloat()
}
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
// index via the router; poll threads stopped + joined before the router is released and the
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
@@ -367,13 +429,59 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
}
}
}
// Sony pad capture (DualSense / Edge / DualShock 4, opt-out): claim a USB-connected
// pad's HID interface and drive it directly — rumble without a kernel force-feedback
// driver, plus adaptive triggers, lightbar, player LEDs and gyro/touchpad, none of which
// the InputDevice path can render (no platform API for any of them). Uncaptured (toggle
// off / permission denied / Bluetooth) the pad stays on the ordinary InputDevice path —
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
// hands over deterministically.
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
var dsUsbReceiver: BroadcastReceiver? = null
if (ds != null) {
feedback.sink = ds
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val usbDev = ds.findUsbDevice()
when {
usbDev != null && usbManager.hasPermission(usbDev) -> ds.startUsb(usbDev)
usbDev != null -> {
// One-time system dialog; capture engages on grant (Android remembers the
// grant for as long as the device stays attached).
val action = "io.unom.punktfunk.DS_USB_PERMISSION"
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != action) return
val ok = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (ok) ds.startUsb(usbDev) else Log.i("punktfunk", "Sony pad USB permission denied")
}
}
dsUsbReceiver = receiver
ContextCompat.registerReceiver(
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
)
usbManager.requestPermission(
usbDev,
PendingIntent.getBroadcast(
context, 2, // requestCode 2 — 0/1 are the SC2 stream/menu grants
Intent(action).setPackage(context.packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
}
}
onDispose {
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
feedback.onHidRaw = null
feedback.sink = null
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
activity?.gamepadRouter = null
@@ -388,8 +496,19 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// Back in the menus: the SC2 (if present) resumes driving the console UI.
activity?.startSc2MenuNav()
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
composeView.requestUnbufferedDispatch(0) // back to ordinary batched dispatch
}
if (Build.VERSION.SDK_INT >= 35) {
composeView.requestedFrameRate = View.REQUESTED_FRAME_RATE_CATEGORY_DEFAULT
}
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
window?.setSoftInputMode(priorSoftInput)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && priorCutout != null) {
window?.let { w ->
w.attributes = w.attributes.apply { layoutInDisplayCutoutMode = priorCutout }
}
}
controller?.show(WindowInsetsCompat.Type.systemBars())
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@@ -480,6 +599,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
lowLatencyMode,
choice?.lowLatencyFeature ?: false,
isTv,
initialSettings.presentPriorityWire(),
initialSettings.smoothBuffer,
// The panel's own refresh — from the mode TABLE (streamPanelFps),
// because display.refreshRate reports a per-uid override, not the
// panel. Fallback: the (possibly lying) live rate.
activity?.streamPanelFps(streamHz)?.takeIf { it > 0 }
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
.roundToInt(),
)
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
if (micWanted) NativeBridge.nativeStartMic(handle)
@@ -508,6 +635,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
stats?.let {
StatsOverlay(
it, statsVerbosity, decoderLabel, codecLabel, session.profileName,
panelHz,
Modifier.align(Alignment.TopStart).padding(12.dp),
)
}
@@ -366,6 +366,8 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
2.0, 1.0, 5.0, 238.0,
1.0, 0.5, 1.8, 2.6,
// Timeline-presenter split: pace + latch tile the display term; presents ≈ fps.
0.2, 0.3, 236.0, 1.0,
),
verbosity = verbosity,
decoderLabel = "c2.qti.hevc.decoder · low-latency",
@@ -0,0 +1,301 @@
package io.unom.punktfunk.kit
import android.content.Context
import android.hardware.usb.UsbDevice
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.InputDevice
/**
* One captured Sony pad (DualSense / DualSense Edge / DualShock 4) over USB stream mode only.
* The capture exists to fix what the InputDevice path structurally can't: rumble depends on the
* phone's kernel exposing force feedback (many don't), and adaptive triggers / lightbar / player
* LEDs have NO platform API at all. Claiming the pad's HID interface makes all of it work on any
* phone, plus gyro + touchpad the standard path never captured.
*
* Unlike [Sc2Capture] there is no raw passthrough the host's DualSense/DS4 backends consume
* only typed events and no UI mode: an UNcaptured Sony pad is a perfectly good InputDevice, so
* outside a stream the ordinary path drives the console UI and this class isn't constructed.
* That also makes the InputDevice path the automatic fallback whenever the capture doesn't
* engage (toggle off, permission denied, Bluetooth).
*
* Input: parse ([DsDevice.parseState]) typed mirror on an [GamepadRouter.ExternalPad] (buttons
* diffed, axes on-change the exit chord participates like any pad) + the rich plane (touch
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
* and freed on unplug/[stop], so indices never leak.
*
* Feedback: implements [GamepadFeedback.PadFeedbackSink] rumble / trigger / lightbar / player
* LED events addressed to this pad's wire index become USB output reports on the physical pad
* ([DsDevice] builders). Rendering runs on the feedback poll threads; [HidUsbLink.writeRaw] is
* thread-safe (bounded newest-wins queue, submitted by the reader thread). A USB pad holds its
* rumble level until written zero, so a backstop timer re-arms per command and writes the stop
* itself if the poll thread stalls the engine's explicit zeros remain the real stop mechanism.
*/
class DsCapture(
context: Context,
private val router: GamepadRouter,
) : GamepadFeedback.PadFeedbackSink {
private val usb = HidUsbLink(
context,
HidUsbLink.Config(
tag = TAG,
threadName = "pf-ds-usb",
deviceMatch = { it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS },
// No ifaceFilter: the pad's audio interfaces are not HID class, so the link's built-in
// class check already leaves them (and the pad's headset routing) to Android; the
// single HID interface is the only claim.
),
::onReport,
::onLinkClosed,
)
@Volatile private var model: DsDevice.Model? = null
@Volatile private var pad: GamepadRouter.ExternalPad? = null
// Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only.
private val state = DsDevice.State()
private var wireButtons = 0
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
private val lastTouchActive = BooleanArray(2)
private val lastTouchX = IntArray(2) { -1 }
private val lastTouchY = IntArray(2) { -1 }
// DS4 composed feedback (its writes are full-state — see DsDevice.ds4Report). Feedback threads.
// The lightbar starts at hid-sony's player-1 blue so the first composed write (usually a
// rumble, before any host Led lands) doesn't black the bar out.
@Volatile private var ds4Low = 0
@Volatile private var ds4High = 0
@Volatile private var ds4Rgb = 0x000040
// Rumble backstop: a USB pad holds its level until told zero, so a stalled poll thread would
// leave the motors running — re-armed per command, cancelled by an explicit (0,0).
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile private var backstop: Runnable? = null
/** Fired (link thread) when the capture engages or drops — the Controllers screen's status. */
@Volatile
var onActiveChanged: ((active: Boolean) -> Unit)? = null
val isActive: Boolean get() = model != null
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
fun findUsbDevice(): UsbDevice? = usb.findDevice()
/**
* Start capturing [dev] (permission already granted). Claims the HID interface the kernel
* driver detaches and the pad's InputDevice node vanishes; its router slot (if the router
* already opened one from the pre-claim InputDevice) is released HERE, at claim time, rather
* than waiting for the system's removal callback so the freed wire index is deterministic
* for this capture's ExternalPad instead of racing the first report against the callback. A
* released sibling that still exists as an InputDevice (a same-model Bluetooth pad) lazily
* reopens a slot on its next input event, so over-matching self-heals.
*/
fun startUsb(dev: UsbDevice): Boolean {
if (model != null) return false
val m = DsDevice.modelFor(dev.productId) ?: return false
if (!usb.start(dev)) return false
model = m
for (id in InputDevice.getDeviceIds()) {
val d = InputDevice.getDevice(id) ?: continue
if (d.vendorId == dev.vendorId && d.productId == dev.productId) router.releaseDevice(id)
}
// Release the firmware's lightbar animation once so host lightbar writes take effect
// (the same init hid-playstation/SDL send on open).
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
onActiveChanged?.invoke(true)
return true
}
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
fun stop() {
val m = model
if (m != null) {
// The interfaces are about to release with the kernel driver still detached — a
// mid-rumble teardown would leave the motors running with nobody to stop them.
// EP0-direct (the reader thread is stopping; the queue would never drain).
usb.writeControl(stopReport(m))
}
disarmBackstop()
usb.stop()
val wasActive = model != null
model = null
releaseSlot()
if (wasActive) onActiveChanged?.invoke(false)
}
// ---- link callbacks (link thread) ----
private fun onReport(report: ByteArray, len: Int) {
val m = model ?: return
if (!DsDevice.parseState(m, report, len, state)) return
val p = pad ?: router.openExternal(m.pref)?.also {
pad = it
Log.i(TAG, "captured $m → wire pad ${it.index}")
} ?: return // all 16 wire indices taken — drop until one frees
mirrorTyped(p)
mirrorRich(p, m)
}
private fun onLinkClosed() {
Log.i(TAG, "Sony USB link closed (unplug)")
disarmBackstop()
val wasActive = model != null
model = null
releaseSlot()
if (wasActive) onActiveChanged?.invoke(false)
}
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
var changed = state.buttons xor wireButtons
while (changed != 0) {
val bit = changed and -changed // lowest changed bit
p.button(bit, state.buttons and bit != 0)
changed = changed and bit.inv()
}
wireButtons = state.buttons
axis(p, Gamepad.AXIS_LS_X, state.lsX)
axis(p, Gamepad.AXIS_LS_Y, state.lsY)
axis(p, Gamepad.AXIS_RS_X, state.rsX)
axis(p, Gamepad.AXIS_RS_Y, state.rsY)
axis(p, Gamepad.AXIS_LT, state.lt)
axis(p, Gamepad.AXIS_RT, state.rt)
}
private fun axis(p: GamepadRouter.ExternalPad, id: Int, v: Int) {
if (lastAxis[id] == v) return
lastAxis[id] = v
p.axis(id, v)
}
/**
* The rich plane: touch contacts normalized to the wire's 0..65535 screen space, forwarded
* on change per slot; motion forwarded every report (raw device units the wire is a unit
* passthrough into the host's virtual pad, and sensor noise makes per-report dedup pointless).
*/
private fun mirrorRich(p: GamepadRouter.ExternalPad, m: DsDevice.Model) {
for (f in 0 until 2) {
if (state.touchActive[f]) {
val x = (state.touchX[f].coerceIn(0, m.touchW - 1) * 65535) / (m.touchW - 1)
val y = (state.touchY[f].coerceIn(0, m.touchH - 1) * 65535) / (m.touchH - 1)
if (!lastTouchActive[f] || x != lastTouchX[f] || y != lastTouchY[f]) {
p.touch(f, true, x, y)
lastTouchActive[f] = true
lastTouchX[f] = x
lastTouchY[f] = y
}
} else if (lastTouchActive[f]) {
p.touch(f, false, lastTouchX[f], lastTouchY[f])
lastTouchActive[f] = false
}
}
p.motion(state.gyro, state.accel)
}
private fun releaseSlot() {
// Lift any still-touching finger so the host's virtual touchpad doesn't hold a contact.
val p = pad
if (p != null) {
for (f in 0 until 2) if (lastTouchActive[f]) p.touch(f, false, lastTouchX[f], lastTouchY[f])
}
p?.close()
pad = null
wireButtons = 0
lastAxis.fill(Int.MIN_VALUE)
lastTouchActive.fill(false)
lastTouchX.fill(-1)
lastTouchY.fill(-1)
}
// ---- PadFeedbackSink (feedback poll threads) ----
override fun ownsPad(pad: Int): Boolean = pad == this.pad?.index
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
val m = model ?: return
if (low == 0 && high == 0) {
disarmBackstop()
} else {
armBackstop(backstopMs)
}
if (m == DsDevice.Model.DUALSHOCK4) {
ds4Low = low
ds4High = high
writeDs4()
} else {
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
}
}
override fun led(pad: Int, r: Int, g: Int, b: Int) {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) {
ds4Rgb = (r shl 16) or (g shl 8) or b
writeDs4()
} else {
usb.writeRaw(0, DsDevice.ds5LightbarReport(m, r, g, b))
}
}
override fun playerLeds(pad: Int, bits: Int) {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) return // no player LEDs on a DS4 (host never sends any)
usb.writeRaw(0, DsDevice.ds5PlayerLedsReport(m, bits))
}
override fun trigger(pad: Int, which: Int, effect: ByteArray) {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) return // no adaptive triggers on a DS4
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
}
private fun writeDs4() = usb.writeRaw(
0,
DsDevice.ds4Report(
ds4Low,
ds4High,
(ds4Rgb shr 16) and 0xFF,
(ds4Rgb shr 8) and 0xFF,
ds4Rgb and 0xFF,
),
)
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
* composed motor state and carries the current lightbar rather than blacking it out. */
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
ds4Low = 0
ds4High = 0
DsDevice.ds4Report(
0,
0,
(ds4Rgb shr 16) and 0xFF,
(ds4Rgb shr 8) and 0xFF,
ds4Rgb and 0xFF,
)
} else {
DsDevice.ds5RumbleReport(m, 0, 0)
}
/** (Re)arm the stalled-poll-thread net: write a rumble stop at the command's backstop. */
private fun armBackstop(ms: Long) {
backstop?.let { mainHandler.removeCallbacks(it) }
val r = Runnable {
backstop = null
model?.let { usb.writeRaw(0, stopReport(it)) }
}
backstop = r
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
}
private fun disarmBackstop() {
backstop?.let { mainHandler.removeCallbacks(it) }
backstop = null
}
private companion object {
const val TAG = "DsCapture"
}
}
@@ -0,0 +1,340 @@
package io.unom.punktfunk.kit
/**
* Sony DualSense / DualSense Edge / DualShock 4 **USB** protocol constants: the input-report
* parser and the output-report builders the capture link ([DsCapture]) needs. Unlike the SC2's
* as-is passthrough, nothing rides the wire raw here the host's DualSense/DS4 backends consume
* only typed events (`dualsense_proto.rs` discards `RichInput::HidReport`), so the client parses
* the pad's input reports into the ordinary button/axis wire + the rich touch/motion plane, and
* renders the host's feedback (rumble / adaptive triggers / lightbar / player LEDs) by composing
* USB output reports itself.
*
* Protocol ground truth: the Linux kernel's `hid-playstation` / `hid-sony` structs, SDL's
* `SDL_hidapi_ps5.c` / `SDL_hidapi_ps4.c`, mirrored host-side in `punktfunk-host`'s
* `dualsense_proto.rs` / `dualshock4_proto.rs` this file is the byte-exact inverse of those
* serializers (offsets cross-referenced below). USB only: over Bluetooth the reports shift
* (`0x31` + CRC32) AND Android exposes no raw path to a Classic pad anyway, so the BT case never
* reaches this code an uncaptured pad stays on the ordinary InputDevice path.
*/
object DsDevice {
const val VID_SONY = 0x054C
const val PID_DUALSENSE = 0x0CE6
const val PID_DUALSENSE_EDGE = 0x0DF2
const val PID_DUALSHOCK4_V1 = 0x05C4
const val PID_DUALSHOCK4_V2 = 0x09CC
val USB_PIDS = setOf(PID_DUALSENSE, PID_DUALSENSE_EDGE, PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2)
/**
* One captured model: its `GamepadPref` wire byte (the virtual pad the host builds matching
* the physical one), its output-report size (the descriptor-declared size the firmware
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), and its touchpad extent
* (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches
* onto the wire's 0..65535 space.
*/
enum class Model(val pref: Int, val outputSize: Int, val touchW: Int, val touchH: Int) {
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080),
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080),
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942),
}
/** The captured [Model] for a USB PID, or null for anything we don't capture. */
fun modelFor(pid: Int): Model? = when (pid) {
PID_DUALSENSE -> Model.DUALSENSE
PID_DUALSENSE_EDGE -> Model.DUALSENSE_EDGE
PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2 -> Model.DUALSHOCK4
else -> null
}
/**
* The client-consumed fields of one input report. `buttons` is already the WIRE bitmask
* (`Gamepad.BTN_*`) the parse maps device bits straight to the wire, the exact inverse of
* the host's `DsState::from_gamepad` (BTN_A cross, BTN_B circle, BTN_X square,
* BTN_Y triangle; positional, not glyph-order). Gyro/accel stay in raw device units the
* wire's `Motion` is a unit passthrough into the virtual pad's report. Touch coordinates stay
* device-raw here; [DsCapture] normalizes against the model's extent when forwarding.
*/
class State {
var buttons = 0
var lsX = 0; var lsY = 0 // wire i16, +y = up (device is +y down — inverted in the parse)
var rsX = 0; var rsY = 0
var lt = 0; var rt = 0 // 0..255
val gyro = IntArray(3) // raw i16 units (pitch/yaw/roll)
val accel = IntArray(3)
val touchActive = BooleanArray(2)
val touchX = IntArray(2) // raw device coords (0..touchW-1 / 0..touchH-1)
val touchY = IntArray(2)
}
// DS5 USB input report 0x01 (64 B) — offsets mirror the host serializer
// (`dualsense_proto.rs::serialize_state`): [1..7) sticks + triggers, [8] hat|face,
// [9]/[10] buttons, [16..28) gyro+accel, [33..41) two 4-byte touch points.
private const val DS5_INPUT_ID = 0x01
// report[8] high nibble (`dualsense_proto::btn0`).
private const val DS5_SQUARE = 0x10
private const val DS5_CROSS = 0x20
private const val DS5_CIRCLE = 0x40
private const val DS5_TRIANGLE = 0x80
// report[9] (`btn1`).
private const val DS5_L1 = 0x01
private const val DS5_R1 = 0x02
private const val DS5_CREATE = 0x10
private const val DS5_OPTIONS = 0x20
private const val DS5_L3 = 0x40
private const val DS5_R3 = 0x80
// report[10] (`btn2`); the FN/BACK bits exist only on the Edge.
private const val DS5_PS = 0x01
private const val DS5_TOUCHPAD = 0x02
private const val DS5_MUTE = 0x04
private const val EDGE_FN_LEFT = 0x10
private const val EDGE_FN_RIGHT = 0x20
private const val EDGE_BACK_LEFT = 0x40
private const val EDGE_BACK_RIGHT = 0x80
// DS4 USB input report 0x01 (64 B) — offsets mirror `dualshock4_proto.rs::serialize_state`:
// [1..5) sticks, [5] hat|face, [6]/[7] buttons, [8]/[9] triggers, [13..25) gyro+accel,
// [35..43) two touch points (same 4-byte packing as the DS5).
private const val DS4_L1 = 0x01
private const val DS4_R1 = 0x02
private const val DS4_SHARE = 0x10
private const val DS4_OPTIONS = 0x20
private const val DS4_L3 = 0x40
private const val DS4_R3 = 0x80
private const val DS4_PS = 0x01
private const val DS4_TOUCHPAD = 0x02
/**
* Parse one USB input report (`0x01`) into [out]. Returns false for any other report id or a
* short read (the pad also emits `0x09`-family getMAC responses etc. on EP0 those never hit
* the interrupt endpoint, but be defensive). Motion/touch fields update only when the report
* is long enough to carry them (it always is on glass 64-byte interrupt transfers).
*/
fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean =
if (model == Model.DUALSHOCK4) {
parseDs4(report, len, out)
} else {
parseDs5(model, report, len, out)
}
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State): Boolean {
if (len < 11 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false
out.lsX = stickX(u8(r, 1))
out.lsY = stickY(u8(r, 2))
out.rsX = stickX(u8(r, 3))
out.rsY = stickY(u8(r, 4))
out.lt = u8(r, 5)
out.rt = u8(r, 6)
val b8 = u8(r, 8)
val b9 = u8(r, 9)
val b10 = u8(r, 10)
var w = hatBits(b8 and 0x0F)
if (b8 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
if (b8 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
if (b8 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
if (b8 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
if (b9 and DS5_L1 != 0) w = w or Gamepad.BTN_LB
if (b9 and DS5_R1 != 0) w = w or Gamepad.BTN_RB
// L2/R2 digital bits ride the analog axes instead (wire convention).
if (b9 and DS5_CREATE != 0) w = w or Gamepad.BTN_BACK
if (b9 and DS5_OPTIONS != 0) w = w or Gamepad.BTN_START
if (b9 and DS5_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
if (b9 and DS5_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
if (b10 and DS5_PS != 0) w = w or Gamepad.BTN_GUIDE
if (b10 and DS5_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
if (b10 and DS5_MUTE != 0) w = w or Gamepad.BTN_MISC1
if (model == Model.DUALSENSE_EDGE) {
// Wire paddle order matches the host's `edge_paddle_bits` inverse: PADDLE1/2 =
// right/left BACK (the primary pair, Steam R4/L4 convention), PADDLE3/4 = right/left Fn.
if (b10 and EDGE_BACK_RIGHT != 0) w = w or Gamepad.BTN_PADDLE1
if (b10 and EDGE_BACK_LEFT != 0) w = w or Gamepad.BTN_PADDLE2
if (b10 and EDGE_FN_RIGHT != 0) w = w or Gamepad.BTN_PADDLE3
if (b10 and EDGE_FN_LEFT != 0) w = w or Gamepad.BTN_PADDLE4
}
out.buttons = w
if (len >= 28) {
for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i)
for (i in 0 until 3) out.accel[i] = i16(r, 22 + 2 * i)
}
if (len >= 41) {
unpackTouch(r, 33, out, 0)
unpackTouch(r, 37, out, 1)
}
return true
}
private fun parseDs4(r: ByteArray, len: Int, out: State): Boolean {
if (len < 10 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false // DS4 shares id 0x01
out.lsX = stickX(u8(r, 1))
out.lsY = stickY(u8(r, 2))
out.rsX = stickX(u8(r, 3))
out.rsY = stickY(u8(r, 4))
val b5 = u8(r, 5)
val b6 = u8(r, 6)
val b7 = u8(r, 7)
out.lt = u8(r, 8)
out.rt = u8(r, 9)
var w = hatBits(b5 and 0x0F)
if (b5 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
if (b5 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
if (b5 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
if (b5 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
if (b6 and DS4_L1 != 0) w = w or Gamepad.BTN_LB
if (b6 and DS4_R1 != 0) w = w or Gamepad.BTN_RB
if (b6 and DS4_SHARE != 0) w = w or Gamepad.BTN_BACK
if (b6 and DS4_OPTIONS != 0) w = w or Gamepad.BTN_START
if (b6 and DS4_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
if (b6 and DS4_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
if (b7 and DS4_PS != 0) w = w or Gamepad.BTN_GUIDE
if (b7 and DS4_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
out.buttons = w
if (len >= 25) {
for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i)
for (i in 0 until 3) out.accel[i] = i16(r, 19 + 2 * i)
}
if (len >= 43) {
unpackTouch(r, 35, out, 0)
unpackTouch(r, 39, out, 1)
}
return true
}
/** hat nibble (0=N … 7=NW, 8+=neutral) → wire dpad bits — inverse of the host's `hat()`. */
private fun hatBits(h: Int): Int = when (h) {
0 -> Gamepad.BTN_DPAD_UP
1 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT
2 -> Gamepad.BTN_DPAD_RIGHT
3 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_RIGHT
4 -> Gamepad.BTN_DPAD_DOWN
5 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_LEFT
6 -> Gamepad.BTN_DPAD_LEFT
7 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_LEFT
else -> 0
}
/**
* One 4-byte touch point (shared DS5/DS4 packing `dualsense_proto::pack_touch`): byte0
* bit7 = NOT active + contact id in bits 0..6; 12-bit x/y split across bytes 1..3.
*/
private fun unpackTouch(r: ByteArray, o: Int, out: State, slot: Int) {
val b0 = u8(r, o)
out.touchActive[slot] = b0 and 0x80 == 0
out.touchX[slot] = u8(r, o + 1) or ((u8(r, o + 2) and 0x0F) shl 8)
out.touchY[slot] = (u8(r, o + 2) shr 4) or (u8(r, o + 3) shl 4)
}
private fun u8(r: ByteArray, o: Int): Int = r[o].toInt() and 0xFF
private fun i16(r: ByteArray, o: Int): Int =
((r[o + 1].toInt() shl 8) or (r[o].toInt() and 0xFF)).toShort().toInt()
// Device stick byte (0..255, centre 0x80, +y down) → wire i16 (+y up) — the exact inverse of
// the host's `to_u8` mapping (`lx = to_u8(x)`, `ly = 255 - to_u8(y)`).
private fun stickX(raw: Int): Int = raw * 257 - 32768
private fun stickY(raw: Int): Int = (255 - raw) * 257 - 32768
// ---- Output reports ----
//
// Every write is valid-flag-selective: only the flagged channel applies, the firmware keeps
// the rest (the same contract the host's `parse_ds_output` mirrors — an unflagged parse would
// turn every rumble into a lightbar-off). The DS4 is the exception: its builder writes the
// full composed motors+LED state each time with both flags, SDL's proven-on-hardware shape.
// DS5 output report 0x02, report-relative offsets (`dualsense_proto::parse_ds_output`):
// [1] valid_flag0 (bit0 compat vibration, bit1 haptics select, bit2 R2 block, bit3 L2 block),
// [2] valid_flag1 (bit2 lightbar, bit4 player LEDs), [3]/[4] motors, [11..22) R2 effect,
// [22..33) L2 effect, [39] valid_flag2 (bit1 lightbar-setup enable, bit2 vibration2),
// [42] lightbar_setup, [44] player LEDs, [45..48) RGB.
private const val DS5_FLAG0_COMPAT_VIBRATION = 0x01
private const val DS5_FLAG0_HAPTICS_SELECT = 0x02
private const val DS5_FLAG0_R2_EFFECT = 0x04
private const val DS5_FLAG0_L2_EFFECT = 0x08
private const val DS5_FLAG1_LIGHTBAR = 0x04
private const val DS5_FLAG1_PLAYER_LEDS = 0x10
private const val DS5_FLAG2_LIGHTBAR_SETUP = 0x02
private const val DS5_FLAG2_VIBRATION2 = 0x04
private const val DS5_LIGHTBAR_SETUP_LIGHT_OUT = 0x02
/** The 11-byte adaptive-trigger effect block length (mode byte + 10 parameters). */
const val TRIGGER_EFFECT_LEN = 11
private fun newDs5(model: Model): ByteArray = ByteArray(model.outputSize).also { it[0] = 0x02 }
/**
* One-time capture-start report (DS5/Edge): release the firmware's lightbar animation
* (`LIGHTBAR_SETUP_LIGHT_OUT`) so subsequent host lightbar writes take effect the same
* init both hid-playstation and SDL send on open. No-op fields otherwise.
*/
fun ds5InitReport(model: Model): ByteArray = newDs5(model).also {
it[39] = DS5_FLAG2_LIGHTBAR_SETUP.toByte()
it[42] = DS5_LIGHTBAR_SETUP_LIGHT_OUT.toByte()
}
/**
* DS5/Edge rumble at the wire's u16 amplitudes ([low] = heavy/left motor, [high] =
* light/right the host parses `[3]` as high and `[4]` as low, mirrored here). Flags both
* the classic compat-vibration path AND `VIBRATION2` (firmware 2.24's full-range replot;
* older firmware ignores the unknown flag2 bit) the host parser accepts either.
*/
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
it[39] = DS5_FLAG2_VIBRATION2.toByte()
it[3] = amp8(high).toByte()
it[4] = amp8(low).toByte()
}
/**
* DS5/Edge adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] is the raw 11-byte
* trigger block from the wire (`HidOutput::Trigger` the game's bytes verbatim), copied to
* the same offsets the host parsed it from ([11..22) R2 / [22..33) L2).
*/
fun ds5TriggerReport(model: Model, which: Int, effect: ByteArray): ByteArray = newDs5(model).also {
val at = if (which == 1) 11 else 22
it[1] = (if (which == 1) DS5_FLAG0_R2_EFFECT else DS5_FLAG0_L2_EFFECT).toByte()
val n = effect.size.coerceAtMost(TRIGGER_EFFECT_LEN)
System.arraycopy(effect, 0, it, at, n)
}
/** DS5/Edge lightbar RGB. */
fun ds5LightbarReport(model: Model, r: Int, g: Int, b: Int): ByteArray = newDs5(model).also {
it[2] = DS5_FLAG1_LIGHTBAR.toByte()
it[45] = r.toByte()
it[46] = g.toByte()
it[47] = b.toByte()
}
/** DS5/Edge player-indicator LEDs (low 5 bits, hid-playstation pattern). */
fun ds5PlayerLedsReport(model: Model, bits: Int): ByteArray = newDs5(model).also {
it[2] = DS5_FLAG1_PLAYER_LEDS.toByte()
it[44] = (bits and 0x1F).toByte()
}
// DS4 output report 0x05 (32 B), report-relative (`dualshock4_proto::parse_ds4_output`):
// [1] valid_flag0 (bit0 motors, bit1 LED, bit2 blink), [4] weak/right motor, [5] strong/left,
// [6..9) RGB, [9]/[10] blink on/off.
private const val DS4_FLAG0_MOTORS = 0x01
private const val DS4_FLAG0_LED = 0x02
/**
* One full-state DS4 write: motors + lightbar together, both flags set the composed-state
* shape SDL uses against real hardware (per-channel selective writes are unproven on DS4
* firmware, unlike the DS5's). [DsCapture] holds the composition. Blink stays untouched.
*/
fun ds4Report(low: Int, high: Int, r: Int, g: Int, b: Int): ByteArray =
ByteArray(Model.DUALSHOCK4.outputSize).also {
it[0] = 0x05
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
it[4] = amp8(high).toByte()
it[5] = amp8(low).toByte()
it[6] = r.toByte()
it[7] = g.toByte()
it[8] = b.toByte()
}
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
// vibrator path's toAmplitude).
private fun amp8(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
}
@@ -46,6 +46,42 @@ class GamepadFeedback(
private val router: GamepadRouter?,
private val deviceVibrator: Vibrator? = null,
) {
/**
* A capture link's feedback renderer for the wire pads it owns, consulted BEFORE the
* InputDevice vibrator/lights paths. A captured controller has no [android.view.InputDevice]
* (its slot is an [GamepadRouter.ExternalPad] on a synthetic id, so [GamepadRouter.deviceForPad]
* resolves null and the platform paths no-op) the link renders instead, by composing USB
* output reports on the physical pad. This is also the ONLY route to adaptive triggers:
* Android has no platform API for them, so without a sink a Trigger event is log-and-drop.
* Invoked on the feedback poll threads; implementations must be thread-safe.
*/
interface PadFeedbackSink {
/** True when this sink renders feedback for wire pad [pad]; the render methods are only
* invoked while true. Racing a pad close is fine a late render is a harmless no-op. */
fun ownsPad(pad: Int): Boolean
/** One effective rumble command (`(0,0)` = stop now; else a one-shot at this level with
* [backstopMs] as the self-termination net see [GamepadFeedback.renderRumble]). */
fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long)
/** Lightbar RGB. */
fun led(pad: Int, r: Int, g: Int, b: Int)
/** Player-indicator LED bitmask (low 5 bits, hid-playstation layout). */
fun playerLeds(pad: Int, bits: Int)
/** One adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] = the raw DS5 trigger
* block (mode byte + parameters) exactly as the game wrote it host-side. */
fun trigger(pad: Int, which: Int, effect: ByteArray)
}
/**
* The active capture link's sink (a [DsCapture]), or null. Wired by StreamScreen alongside
* [onHidRaw]; cleared before the poll threads stop.
*/
@Volatile
var sink: PadFeedbackSink? = null
private companion object {
const val TAG = "pf.feedback"
const val TAG_LED: Byte = 0x01
@@ -221,6 +257,12 @@ class GamepadFeedback(
// controller 1 unconditionally rather than only motor-less pads — capability probing
// already decided the bind, and the user opted in.
if (pad == 0) renderDeviceRumble(low, high, durationMs)
// A captured pad's link renders on the physical controller itself (its slot has no
// InputDevice, so the vibrator bind below would resolve null and drop the command).
sink?.takeIf { it.ownsPad(pad) }?.let {
it.rumble(pad, low, high, durationMs)
return
}
val bind = rumbleBindFor(pad) ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
@@ -313,23 +355,36 @@ class GamepadFeedback(
val g = buf.get().toInt() and 0xFF
val b = buf.get().toInt() and 0xFF
Log.i(TAG, "hidout pad=$pad Led r=$r g=$g b=$b") // verification line
if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
val s = sink?.takeIf { it.ownsPad(pad) }
if (s != null) s.led(pad, r, g, b)
else if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
}
TAG_PLAYER_LEDS -> {
val bits = buf.get().toInt() and 0x1F
val player = playerIndexForBits(bits)
Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line
if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
val s = sink?.takeIf { it.ownsPad(pad) }
if (s != null) s.playerLeds(pad, bits)
else if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
}
TAG_TRIGGER -> {
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
val effLen = n - 3 // [pad][kind][which] header, then the effect block
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
Log.i(
TAG,
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
)
val s = sink?.takeIf { it.ownsPad(pad) }
if (s != null && effLen > 0) {
// A captured DualSense: the raw trigger block replays onto the physical pad.
val effect = ByteArray(effLen)
buf.get(effect)
Log.i(TAG, "hidout pad=$pad Trigger which=$which effLen=$effLen → captured pad") // verification line
s.trigger(pad, which, effect)
} else {
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
// No platform adaptive-trigger API — parse-validate the mode + log only.
Log.i(
TAG,
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (no adaptive-trigger renderer for this pad)".format(mode),
)
}
}
TAG_HID_RAW -> {
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
@@ -210,6 +210,24 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
}
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
}
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
* units the host passes them straight into the virtual pad's report). Per report. */
fun motion(gyro: IntArray, accel: IntArray) {
if (slot != null) {
NativeBridge.nativeSendPadMotion(
handle, index,
gyro[0], gyro[1], gyro[2],
accel[0], accel[1], accel[2],
)
}
}
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
fun close() = closeSlot(syntheticId)
}
@@ -228,6 +246,16 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
return ExternalPad(syntheticId, index)
}
/**
* Close the slot (if any) for a physical controller a capture link just claimed. The claim
* detaches the kernel driver, so the system's own removal callback would close it moments
* later anyway doing it at claim time makes the freed wire index deterministic for the
* link's [ExternalPad] instead of racing the link's first report against that callback. Safe
* to over-match (a same-VID/PID sibling that still exists as an InputDevice lazily reopens a
* slot on its next input event). Main thread, like the hot-plug callbacks.
*/
fun releaseDevice(deviceId: Int) = closeSlot(deviceId)
/**
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
* the feedback poll threads are joined (they read [deviceForPad]).
@@ -0,0 +1,399 @@
package io.unom.punktfunk.kit
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.hardware.usb.UsbRequest
import android.os.Build
import android.util.Log
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeoutException
/**
* Generic USB transport for a client-captured HID controller the device-agnostic half of what
* [Sc2UsbLink] pioneered, now shared with the Sony capture ([DsCapture]). Claims the controller
* interface(s) `force = true` detaches the kernel/OS driver, so a captured pad can't
* double-drive the ordinary InputDevice path runs a multiplexed [UsbRequest] read loop, and
* writes the host/capture's reports back to the device (interrupt-OUT when the interface has one,
* else EP0 `SET_REPORT`).
*
* Everything device-specific is [Config]: which attached device to pick, which of its interfaces
* to claim, and an optional keep-alive (feature reports re-sent on a firmware-watchdog cadence
* the SC2's lizard-mode refresh; a DualSense needs none).
*
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
* (an SC2 on-glass round tripped exactly this a 5 s silence heuristic firing on an idle pad).
* The real signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
*/
class HidUsbLink(
private val context: Context,
private val config: Config,
private val onReport: (report: ByteArray, len: Int) -> Unit,
private val onClosed: () -> Unit,
) {
/**
* The per-device knowledge this transport is parameterized by. [ifaceFilter] narrows WHICH
* HID/vendor-class interfaces get claimed (the class check itself is built in) e.g. the SC2
* Puck's controller slots, or the DualSense's single HID interface among its audio siblings.
* [keepAliveFeatures] are full feature reports (id byte first) re-sent to the streaming
* interface every [keepAliveMs] AND once at claim time; empty = no keep-alive.
*/
class Config(
val tag: String,
val threadName: String,
val deviceMatch: (UsbDevice) -> Boolean,
val ifaceFilter: (UsbDevice, UsbInterface) -> Boolean = { _, _ -> true },
val keepAliveFeatures: List<ByteArray> = emptyList(),
val keepAliveMs: Long = 0,
)
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
/** One claimed interface: its endpoints + the read state the reader thread owns. */
private class Claim(
val iface: UsbInterface,
val epIn: UsbEndpoint,
val epOut: UsbEndpoint?,
) {
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
var inReq: UsbRequest? = null
var outReq: UsbRequest? = null
var outBusy = false
var reports = 0L
}
private var connection: UsbDeviceConnection? = null
private var device: UsbDevice? = null
private var claims: List<Claim> = emptyList()
/** The claim whose IN endpoint last produced data where output/feature writes go.
* Written by the reader thread, read by the feedback thread (feature control transfers). */
@Volatile private var activeClaim: Claim? = null
/** Pending OUT reports, submitted by the reader thread only one thread may drive a
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
* request; a second waiter would steal the reader's completions). */
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
private var reader: Thread? = null
private var detachReceiver: BroadcastReceiver? = null
@Volatile private var running = false
/** First attached matching device, or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
/**
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
* obtained USB permission. Returns false when nothing could be claimed.
*/
fun start(dev: UsbDevice): Boolean {
if (!usb.hasPermission(dev)) {
Log.e(config.tag, "no USB permission for ${dev.deviceName}")
return false
}
val conn = usb.openDevice(dev) ?: run {
Log.e(config.tag, "openDevice failed for ${dev.deviceName}")
return false
}
val claimed = claimControllerInterfaces(dev, conn)
if (claimed.isEmpty()) {
Log.e(config.tag, "no claimable interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
conn.close()
return false
}
connection = conn
device = dev
claims = claimed
running = true
Log.i(
config.tag,
"USB link up: PID=0x%04x ifaces=%s".format(
dev.productId,
claimed.joinToString {
"%d(in=0x%02x out=%s)".format(
it.iface.id, it.epIn.address,
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
)
},
),
)
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (gone?.deviceName == dev.deviceName) {
Log.i(config.tag, "USB detached (${dev.deviceName})")
if (running) {
running = false
onClosed()
}
}
}
}
detachReceiver = receiver
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(receiver, filter)
}
if (config.keepAliveFeatures.isNotEmpty()) {
claimed.forEach { sendKeepAlive(conn, it.iface.id) }
}
reader = Thread({ readLoop(conn, claimed) }, config.threadName).apply {
isDaemon = true
start()
}
return true
}
/**
* Claim every candidate controller interface: HID (or vendor-class) interfaces that pass the
* config's [Config.ifaceFilter], with an INT/BULK IN endpoint (OUT optional the fallback is
* EP0 `SET_REPORT`). `force = true` detaches the kernel/OS driver, so the pad also vanishes
* from Android's own input stack while captured.
*/
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
val out = mutableListOf<Claim>()
for (i in 0 until dev.interfaceCount) {
val iface = dev.getInterface(i)
if (!config.ifaceFilter(dev, iface)) continue
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
iface.interfaceClass == 0xFF
if (!hidOrVendor) continue
var inEp: UsbEndpoint? = null
var outEp: UsbEndpoint? = null
for (e in 0 until iface.endpointCount) {
val ep = iface.getEndpoint(e)
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
if (!usable) continue
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
}
if (inEp == null) continue
if (conn.claimInterface(iface, true)) {
out.add(Claim(iface, inEp, outEp))
} else {
Log.w(config.tag, "could not claim iface ${iface.id}")
}
}
return out
}
/**
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
*/
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
val live = claims.filter { c ->
val req = UsbRequest()
if (!req.initialize(conn, c.epIn)) {
Log.w(config.tag, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
return@filter false
}
req.clientData = c
c.inReq = req
c.epOut?.let { ep ->
val o = UsbRequest()
if (o.initialize(conn, ep)) {
o.clientData = c
c.outReq = o
} else {
Log.w(config.tag, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
}
}
c.inBuf.clear()
req.queue(c.inBuf)
}
if (live.isEmpty()) {
Log.e(config.tag, "no IN request could be queued")
finishReader(claims)
return
}
val scratch = ByteArray(64)
var lastKeepAlive = android.os.SystemClock.elapsedRealtime()
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
try {
while (running) {
val now = android.os.SystemClock.elapsedRealtime()
if (config.keepAliveFeatures.isNotEmpty() && config.keepAliveMs > 0 &&
now - lastKeepAlive >= config.keepAliveMs
) {
// Refresh the firmware settings on the streaming interface (else every live
// one, before a streaming interface is known) — replaying also repairs state
// some other consumer changed after capture started.
val target = activeClaim
if (target != null) sendKeepAlive(conn, target.iface.id)
else live.forEach { sendKeepAlive(conn, it.iface.id) }
lastKeepAlive = now
}
// Submit the next pending OUT report on the active (else first) interface.
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
if (outTarget != null) {
outQueue.poll()?.let { data ->
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
}
}
val done = try {
conn.requestWait(READ_TIMEOUT_MS)
} catch (_: TimeoutException) {
// A quiet controller is NOT an unplug — keep listening indefinitely; the
// detach broadcast is the real signal.
errorsSince = 0L
continue
}
if (done == null) {
// Hard error. On a real unplug these storm continuously (the detach
// broadcast usually beats us to it); tolerate transient ones.
if (errorsSince == 0L) errorsSince = now
if (now - errorsSince >= ERROR_UNPLUG_MS) {
Log.i(config.tag, "USB request errors persisting ${now - errorsSince} ms — treating as unplug")
break
}
continue
}
errorsSince = 0L
val claim = done.clientData as? Claim ?: continue
if (done === claim.inReq) {
val n = claim.inBuf.position()
if (n > 0) {
claim.inBuf.flip()
claim.inBuf.get(scratch, 0, n)
if (claim.reports++ == 0L) {
Log.i(
config.tag,
"first report on iface %d: id=0x%02x len=%d".format(
claim.iface.id, scratch[0].toInt() and 0xFF, n,
),
)
}
activeClaim = claim
onReport(scratch, n)
}
claim.inBuf.clear()
if (!claim.inReq!!.queue(claim.inBuf)) {
Log.i(config.tag, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
break
}
} else if (done === claim.outReq) {
claim.outBusy = false
}
}
} finally {
finishReader(claims)
}
if (running) {
running = false
onClosed()
}
}
private fun finishReader(claims: List<Claim>) {
for (c in claims) {
runCatching { c.inReq?.cancel(); c.inReq?.close() }
runCatching { c.outReq?.cancel(); c.outReq?.close() }
c.inReq = null
c.outReq = null
}
}
/**
* Write one raw report to the device: kind 0 = output report (the active interface's
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
*/
fun writeRaw(kind: Int, data: ByteArray) {
if (data.isEmpty()) return
when (kind) {
0 -> {
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
// newest-wins: these are level-styled commands the sender re-sends anyway.
while (outQueue.size >= 32) outQueue.poll()
outQueue.offer(data)
} else {
setReport(REPORT_TYPE_OUTPUT, data)
}
}
1 -> setReport(REPORT_TYPE_FEATURE, data)
}
}
private fun setReport(type: Int, data: ByteArray) {
val conn = connection ?: return
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
sendReport(conn, ifId, type, data)
}
/**
* Write one output report EP0-direct (`SET_REPORT(Output)`), bypassing the interrupt-OUT
* queue for a teardown write that must land while the reader thread is stopping and the
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
* thread: EP0 control transfers are independent of the reader's `requestWait`.
*/
fun writeControl(data: ByteArray) {
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
}
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
}
/**
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
*/
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
val id = data[0].toInt() and 0xFF
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
conn.controlTransfer(
0x21, // host→device, class, interface
0x09, // SET_REPORT
(type shl 8) or id,
ifaceId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
}
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
fun stop() {
running = false
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
detachReceiver = null
runCatching { reader?.join(1000) }
reader = null
outQueue.clear()
activeClaim = null
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
claims = emptyList()
runCatching { connection?.close() }
connection = null
device = null
}
private companion object {
const val READ_TIMEOUT_MS = 100L
const val WRITE_TIMEOUT_MS = 250
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
const val ERROR_UNPLUG_MS = 2000L
const val REPORT_TYPE_OUTPUT = 0x02
const val REPORT_TYPE_FEATURE = 0x03
}
}
@@ -184,10 +184,12 @@ object NativeBridge {
external fun nativeVideoMime(handle: Long): String
/**
* The negotiated video mode as `[width, height]`, or `null` on a `0` handle. Resolved at the
* handshake, so it is known before the first frame the stream view sizes itself to THIS
* aspect rather than stretching the picture to the panel's. Fixed for the session; read once.
* Cheap; UI-safe.
* The negotiated video mode as `[width, height, refreshHz]`, or `null` on a `0` handle.
* Resolved at the handshake, so it is known before the first frame the stream view sizes
* itself to THIS aspect rather than stretching the picture to the panel's, and pins the
* panel's display mode to the stream refresh. The trailing `refreshHz` was appended later
* (an older native lib returns only `[width, height]` index defensively). Fixed for the
* session; read once. Cheap; UI-safe.
*/
external fun nativeVideoSize(handle: Long): IntArray?
@@ -204,11 +206,13 @@ object NativeBridge {
* entirely in Rust (NDK AMediaCodec ANativeWindow) no per-frame JNI. [decoderName] is the
* decoder Kotlin ranked from `MediaCodecList` (`""` = let the platform resolve the default for
* the MIME what the pre-overhaul client always did); [lowLatencyMode] is the user's
* "Low-latency mode (experimental)" toggle (off, the default, runs the original decode
* pipeline; on, the aggressive per-SoC tuning + async loop); [lowLatencyFeature] is whether
* "Low-latency mode" master toggle (ON by default: async loop + per-SoC tuning; off runs the
* original synchronous pipeline as the per-device escape hatch); [lowLatencyFeature] is whether
* [decoderName] advertised `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI
* mode switch to the stream refresh on TV boxes when the toggle is on (vs. the softer seamless
* hint otherwise). No-op if already started.
* hint otherwise). [presentPriority]/[smoothBuffer] are the timeline presenter's intent
* (0 = lowest latency / 1 = smoothness; buffer 0 = automatic, else 1..3 frames) the Apple
* client's `present_priority`/`smooth_buffer` pair. No-op if already started.
*/
external fun nativeStartVideo(
handle: Long,
@@ -217,6 +221,11 @@ object NativeBridge {
lowLatencyMode: Boolean,
lowLatencyFeature: Boolean,
isTv: Boolean,
presentPriority: Int,
smoothBuffer: Int,
/** The display mode's own refresh rate (0 = unknown) the latch grid the presenter
* subdivides onto when the platform down-rates the app's choreographer stream. */
panelFps: Int,
)
/** Stop + join the decode thread without closing the session. No-op on `0`. */
@@ -231,11 +240,11 @@ object NativeBridge {
/**
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
* Returns 26 doubles (unified stats spec, `design/stats-unification.md`):
* Returns 30 doubles (unified stats spec, `design/stats-unification.md`):
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
* netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
* e2eDispP50Ms, e2eDispP95Ms]`
* e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive]`
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capturedecoded headline; 1013
* describe the negotiated video feed bit depth 8/10, CICP primaries/transfer, and the HEVC
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline
@@ -407,6 +416,30 @@ object NativeBridge {
*/
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
/**
* One touchpad contact from a client-captured controller (the Sony USB capture), forwarded on
* the rich-input plane (`RichInput::Touchpad`). [finger] is the contact slot (0/1); [x]/[y]
* are normalized 0..65535 in SCREEN convention (+y down the wire's fixed meaning); active
* false lifts the finger. Send on change only the host holds per-slot state.
*/
external fun nativeSendPadTouch(handle: Long, pad: Int, finger: Int, active: Boolean, x: Int, y: Int)
/**
* One motion-sensor sample from a client-captured controller (`RichInput::Motion`): gyro
* pitch/yaw/roll + accel, each a raw signed-16 value in the pad's own units the host passes
* them straight into the virtual DualSense report. Called at the pad's report rate.
*/
external fun nativeSendPadMotion(
handle: Long,
pad: Int,
gyroPitch: Int,
gyroYaw: Int,
gyroRoll: Int,
accelX: Int,
accelY: Int,
accelZ: Int,
)
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
/**
@@ -1,28 +1,13 @@
package io.unom.punktfunk.kit
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.hardware.usb.UsbRequest
import android.os.Build
import android.util.Log
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeoutException
/**
* USB transport for a Steam Controller 2 wired (`28DE:1302`) or through the wireless Puck
* dongle (`1304`/`1305`). Claims the controller interface(s) detaching the OS input stack, so
* the pad can't double-drive the ordinary InputDevice path runs a multiplexed [UsbRequest]
* read loop, keeps lizard mode off on the firmware watchdog cadence, and replays the host's raw
* writes (Steam's rumble output reports / settings feature reports) back to the device.
* dongle (`1304`/`1305`). The SC2 specialization of the shared [HidUsbLink] transport (which owns
* the claim, read loop, write queue, and unplug handling); this class contributes only what is
* SC2-specific:
*
* **The Puck claims ALL controller interfaces (2..5):** the dongle hosts up to four pads, one
* HID interface each, and there is no way to know which slot a controller bonded to claiming
@@ -30,350 +15,50 @@ import java.util.concurrent.TimeoutException
* on-glass symptom: the pad surfaced as a generic InputDevice Xbox360). Whichever interface
* streams state becomes the write target for rumble/settings.
*
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
* (round 2's wired disconnect was the 5 s silence heuristic firing on an idle pad). The real
* signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
* **Lizard keep-alive:** the firmware watchdog re-enables lizard mode (built-in kb/mouse
* emulation) after a few seconds of silence, so [Sc2Device.DISABLE_LIZARD] +
* [Sc2Device.NORMALIZE_JOYSTICKS] are re-sent on SDL's cadence the generic link's keep-alive.
*/
class Sc2UsbLink(
private val context: Context,
private val onReport: (report: ByteArray, len: Int) -> Unit,
private val onClosed: () -> Unit,
context: Context,
onReport: (report: ByteArray, len: Int) -> Unit,
onClosed: () -> Unit,
) {
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
/** One claimed interface: its endpoints + the read state the reader thread owns. */
private class Claim(
val iface: UsbInterface,
val epIn: UsbEndpoint,
val epOut: UsbEndpoint?,
) {
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
var inReq: UsbRequest? = null
var outReq: UsbRequest? = null
var outBusy = false
var reports = 0L
}
private var connection: UsbDeviceConnection? = null
private var device: UsbDevice? = null
private var claims: List<Claim> = emptyList()
/** The claim whose IN endpoint last produced data where rumble/settings writes go.
* Written by the reader thread, read by the feedback thread (feature control transfers). */
@Volatile private var activeClaim: Claim? = null
/** Pending OUT reports (Steam's forwarded haptics), submitted by the reader thread only
* one thread may drive a connection's [UsbRequest]s ([UsbDeviceConnection.requestWait]
* returns ANY completed request; a second waiter would steal the reader's completions). */
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
private var reader: Thread? = null
private var detachReceiver: BroadcastReceiver? = null
@Volatile private var running = false
private val link = HidUsbLink(
context,
HidUsbLink.Config(
tag = "Sc2UsbLink",
threadName = "pf-sc2-usb",
deviceMatch = {
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
},
// Wired: every HID/vendor interface; dongle: only the controller slots 2..5.
ifaceFilter = { dev, iface ->
dev.productId == Sc2Device.PID_WIRED || iface.id in Sc2Device.DONGLE_IFACES
},
keepAliveFeatures = listOf(Sc2Device.DISABLE_LIZARD, Sc2Device.NORMALIZE_JOYSTICKS),
keepAliveMs = Sc2Device.LIZARD_REFRESH_MS,
),
onReport,
onClosed,
)
/** First attached SC2 (wired or Puck), or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull {
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
}
fun findDevice(): UsbDevice? = link.findDevice()
/**
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
* obtained USB permission. Returns false when nothing could be claimed.
*/
fun start(dev: UsbDevice): Boolean {
if (!usb.hasPermission(dev)) {
Log.e(TAG, "no USB permission for ${dev.deviceName}")
return false
}
val conn = usb.openDevice(dev) ?: run {
Log.e(TAG, "openDevice failed for ${dev.deviceName}")
return false
}
val claimed = claimControllerInterfaces(dev, conn)
if (claimed.isEmpty()) {
Log.e(TAG, "no claimable SC2 interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
conn.close()
return false
}
connection = conn
device = dev
claims = claimed
running = true
Log.i(
TAG,
"SC2 USB link up: PID=0x%04x ifaces=%s".format(
dev.productId,
claimed.joinToString {
"%d(in=0x%02x out=%s)".format(
it.iface.id, it.epIn.address,
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
)
},
),
)
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (gone?.deviceName == dev.deviceName) {
Log.i(TAG, "SC2 USB detached (${dev.deviceName})")
if (running) {
running = false
onClosed()
}
}
}
}
detachReceiver = receiver
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(receiver, filter)
}
claimed.forEach { configureInputMode(conn, it.iface.id) }
reader = Thread({ readLoop(conn, claimed) }, "pf-sc2-usb").apply {
isDaemon = true
start()
}
return true
}
/**
* Claim every candidate controller interface: the wired pad's single HID interface, or ALL
* of a Puck's controller slots (interfaces 2..5 the controller may be bonded to any of
* them). `force = true` detaches the kernel/OS driver, so the pad also vanishes from
* Android's own input stack while captured.
*/
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
val dongle = dev.productId != Sc2Device.PID_WIRED
val out = mutableListOf<Claim>()
for (i in 0 until dev.interfaceCount) {
val iface = dev.getInterface(i)
if (dongle && iface.id !in Sc2Device.DONGLE_IFACES) continue
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
iface.interfaceClass == 0xFF
if (!hidOrVendor) continue
var inEp: UsbEndpoint? = null
var outEp: UsbEndpoint? = null
for (e in 0 until iface.endpointCount) {
val ep = iface.getEndpoint(e)
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
if (!usable) continue
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
}
if (inEp == null) continue
if (conn.claimInterface(iface, true)) {
out.add(Claim(iface, inEp, outEp))
} else {
Log.w(TAG, "could not claim iface ${iface.id}")
}
}
return out
}
/**
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
*/
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
val live = claims.filter { c ->
val req = UsbRequest()
if (!req.initialize(conn, c.epIn)) {
Log.w(TAG, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
return@filter false
}
req.clientData = c
c.inReq = req
c.epOut?.let { ep ->
val o = UsbRequest()
if (o.initialize(conn, ep)) {
o.clientData = c
c.outReq = o
} else {
Log.w(TAG, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
}
}
c.inBuf.clear()
req.queue(c.inBuf)
}
if (live.isEmpty()) {
Log.e(TAG, "no IN request could be queued")
finishReader(claims)
return
}
val scratch = ByteArray(64)
var lastLizard = android.os.SystemClock.elapsedRealtime()
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
try {
while (running) {
val now = android.os.SystemClock.elapsedRealtime()
if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) {
// Refresh both required firmware modes. The raw-joystick setting is normally
// persistent, but replaying it also repairs a host/driver that enabled ADC
// coordinates after capture started.
val target = activeClaim
if (target != null) configureInputMode(conn, target.iface.id)
else live.forEach { configureInputMode(conn, it.iface.id) }
lastLizard = now
}
// Submit the next pending OUT report on the active (else first) interface.
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
if (outTarget != null) {
outQueue.poll()?.let { data ->
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
}
}
val done = try {
conn.requestWait(READ_TIMEOUT_MS)
} catch (_: TimeoutException) {
// A quiet controller is NOT an unplug — keep listening indefinitely; the
// detach broadcast is the real signal.
errorsSince = 0L
continue
}
if (done == null) {
// Hard error. On a real unplug these storm continuously (the detach
// broadcast usually beats us to it); tolerate transient ones.
if (errorsSince == 0L) errorsSince = now
if (now - errorsSince >= ERROR_UNPLUG_MS) {
Log.i(TAG, "SC2 USB request errors persisting ${now - errorsSince} ms — treating as unplug")
break
}
continue
}
errorsSince = 0L
val claim = done.clientData as? Claim ?: continue
if (done === claim.inReq) {
val n = claim.inBuf.position()
if (n > 0) {
claim.inBuf.flip()
claim.inBuf.get(scratch, 0, n)
if (claim.reports++ == 0L) {
Log.i(
TAG,
"SC2 first report on iface %d: id=0x%02x len=%d".format(
claim.iface.id, scratch[0].toInt() and 0xFF, n,
),
)
}
activeClaim = claim
onReport(scratch, n)
}
claim.inBuf.clear()
if (!claim.inReq!!.queue(claim.inBuf)) {
Log.i(TAG, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
break
}
} else if (done === claim.outReq) {
claim.outBusy = false
}
}
} finally {
finishReader(claims)
}
if (running) {
running = false
onClosed()
}
}
private fun finishReader(claims: List<Claim>) {
for (c in claims) {
runCatching { c.inReq?.cancel(); c.inReq?.close() }
runCatching { c.outReq?.cancel(); c.outReq?.close() }
c.inReq = null
c.outReq = null
}
}
fun start(dev: UsbDevice): Boolean = link.start(dev)
/**
* Replay one raw report from the host on the device: kind 0 = output report (Steam's `0x80`
* rumble & friends the active interface's interrupt-OUT, else a `SET_REPORT(Output)`
* control transfer), kind 1 = feature report (`SET_REPORT(Feature)`). [data] is the full
* report, id byte first, exactly as hidapi framed it host-side.
* rumble & friends), kind 1 = feature report. [data] is the full report, id byte first,
* exactly as hidapi framed it host-side.
*/
fun writeRaw(kind: Int, data: ByteArray) {
if (data.isEmpty()) return
when (kind) {
0 -> {
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
// newest-wins: these are level-styled commands the host re-sends anyway.
while (outQueue.size >= 32) outQueue.poll()
outQueue.offer(data)
} else {
setReport(REPORT_TYPE_OUTPUT, data)
}
}
1 -> setReport(REPORT_TYPE_FEATURE, data)
}
}
fun writeRaw(kind: Int, data: ByteArray) = link.writeRaw(kind, data)
private fun setReport(type: Int, data: ByteArray) {
val conn = connection ?: return
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
sendReport(conn, ifId, type, data)
}
private fun configureInputMode(conn: UsbDeviceConnection, ifaceId: Int) {
sendFeature(conn, ifaceId, Sc2Device.DISABLE_LIZARD)
sendFeature(conn, ifaceId, Sc2Device.NORMALIZE_JOYSTICKS)
}
private fun sendFeature(conn: UsbDeviceConnection, ifaceId: Int, data: ByteArray) {
sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, data)
}
/**
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
*/
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
val id = data[0].toInt() and 0xFF
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
conn.controlTransfer(
0x21, // host→device, class, interface
0x09, // SET_REPORT
(type shl 8) or id,
ifaceId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
}
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
fun stop() {
running = false
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
detachReceiver = null
runCatching { reader?.join(1000) }
reader = null
outQueue.clear()
activeClaim = null
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
claims = emptyList()
runCatching { connection?.close() }
connection = null
device = null
}
private companion object {
const val TAG = "Sc2UsbLink"
const val READ_TIMEOUT_MS = 100L
const val WRITE_TIMEOUT_MS = 250
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
const val ERROR_UNPLUG_MS = 2000L
const val REPORT_TYPE_OUTPUT = 0x02
const val REPORT_TYPE_FEATURE = 0x03
}
/** Stop the read loop and release the interfaces. Idempotent; does not fire the closed callback. */
fun stop() = link.stop()
}
@@ -0,0 +1,224 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure JVM tests of the Sony USB report codec ([DsDevice]) the byte-exact inverse of the
* host's `dualsense_proto.rs` / `dualshock4_proto.rs` serializers (offsets cross-checked against
* those files' own tests). No Android runtime types ([Gamepad]'s BTN_* are compile-time ints).
* Run: `./gradlew :kit:testDebugUnitTest`.
*/
class DsDeviceTest {
private fun ds5Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
ByteArray(64).also {
it[0] = 0x01
// Sticks centred, hat neutral (8).
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
it[8] = 0x08
// Touch points inactive (bit7 set).
it[33] = 0x80.toByte(); it[37] = 0x80.toByte()
mutate(it)
}
private fun ds4Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
ByteArray(64).also {
it[0] = 0x01
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
it[5] = 0x08
it[35] = 0x80.toByte(); it[39] = 0x80.toByte()
mutate(it)
}
// ---- input parse ----
@Test
fun ds5ButtonsMapPositionally() {
val s = DsDevice.State()
// cross+triangle, hat NE, L1+create+L3, PS+touchpad+mute.
val r = ds5Report {
it[8] = (0x20 or 0x80 or 0x01).toByte() // cross | triangle | hat=1 (NE)
it[9] = (0x01 or 0x10 or 0x40).toByte() // L1 | create | L3
it[10] = (0x01 or 0x02 or 0x04).toByte() // PS | touchpad | mute
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
val expected = Gamepad.BTN_A or Gamepad.BTN_Y or
Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT or
Gamepad.BTN_LB or Gamepad.BTN_BACK or Gamepad.BTN_LS_CLICK or
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD or Gamepad.BTN_MISC1
assertEquals(expected, s.buttons)
}
@Test
fun ds5SticksInvertYAndCoverTheFullRange() {
val s = DsDevice.State()
// Device +y down; wire +y up. Left stick fully up-left, right stick fully down-right.
val r = ds5Report {
it[1] = 0x00; it[2] = 0x00 // lx min, ly min (up)
it[3] = 0xFF.toByte(); it[4] = 0xFF.toByte() // rx max, ry max (down)
it[5] = 0x40; it[6] = 0xFF.toByte()
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
assertEquals(-32768, s.lsX)
assertEquals(32767, s.lsY) // device up → wire +32767
assertEquals(32767, s.rsX)
assertEquals(-32768, s.rsY) // device down → wire 32768
assertEquals(0x40, s.lt)
assertEquals(0xFF, s.rt)
// Centre stays (near) centre: 0x80 → 128 wire units of bias, the u8 grid's own offset.
val c = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 64, c))
assertEquals(128, c.lsX)
assertEquals(-129, c.lsY)
}
@Test
fun ds5MotionAndTouchUnpack() {
val s = DsDevice.State()
val r = ds5Report {
// gyro pitch = 0x0102, accel z = -2 (LE i16s at 16.. / 22..).
it[16] = 0x02; it[17] = 0x01
it[26] = 0xFE.toByte(); it[27] = 0xFF.toByte()
// Touch 0 active, id 5, x=1919 (0x77F), y=1079 (0x437):
// b0=0x05, b1=0x7F, b2=(x>>8)|((y&0xF)<<4)=0x77, y>>4=0x43.
it[33] = 0x05
it[34] = 0x7F
it[35] = (0x07 or (0x07 shl 4)).toByte()
it[36] = 0x43
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
assertEquals(0x0102, s.gyro[0])
assertEquals(-2, s.accel[2])
assertTrue(s.touchActive[0])
assertEquals(1919, s.touchX[0])
assertEquals(1079, s.touchY[0])
assertFalse(s.touchActive[1])
}
@Test
fun edgePaddlesParseOnlyOnTheEdge() {
val r = ds5Report { it[10] = 0xF0.toByte() } // all four FN/BACK bits
val edge = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE_EDGE, r, 64, edge))
// Host inverse (`edge_paddle_bits`): PADDLE1/2 = right/left BACK, PADDLE3/4 = right/left Fn.
assertEquals(
Gamepad.BTN_PADDLE1 or Gamepad.BTN_PADDLE2 or Gamepad.BTN_PADDLE3 or Gamepad.BTN_PADDLE4,
edge.buttons,
)
val plain = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, plain))
assertEquals(0, plain.buttons) // a non-Edge never reports phantom paddles
}
@Test
fun ds4LayoutDiffersWhereItShould() {
val s = DsDevice.State()
val r = ds4Report {
it[5] = (0x10 or 0x04).toByte() // square | hat=4 (down)
it[6] = (0x10 or 0x20).toByte() // share | options
it[7] = 0x03 // PS | touchpad click
it[8] = 0x11 // L2 analog
it[9] = 0x99.toByte() // R2 analog
// gyro yaw at 15.. (second i16 of 13..19).
it[15] = 0x34; it[16] = 0x12
// Touch 0 active id 3 at x=100 (0x064), y=941 (0x3AD): b1=0x64, b2=0xD0, b3=0x3A.
it[35] = 0x03
it[36] = 0x64
it[37] = 0xD0.toByte()
it[38] = 0x3A
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, r, 64, s))
assertEquals(
Gamepad.BTN_X or Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_BACK or Gamepad.BTN_START or
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD,
s.buttons,
)
assertEquals(0x11, s.lt)
assertEquals(0x99, s.rt)
assertEquals(0x1234, s.gyro[1])
assertTrue(s.touchActive[0])
assertEquals(100, s.touchX[0])
assertEquals(941, s.touchY[0])
}
@Test
fun rejectsForeignAndShortReports() {
val s = DsDevice.State()
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report { it[0] = 0x31 }, 64, s))
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 8, s))
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4Report(), 8, s))
}
// ---- output builders (offsets = the host parser's: `parse_ds_output` / `parse_ds4_output`) ----
@Test
fun ds5RumbleReportFlagsAndMotors() {
val r = DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, low = 0xFF00, high = 0x1200)
assertEquals(48, r.size)
assertEquals(0x02, r[0].toInt())
assertEquals(0x03, r[1].toInt()) // compat vibration | haptics select
assertEquals(0x04, r[39].toInt()) // VIBRATION2 (fw ≥ 2.24)
assertEquals(0x12, r[3].toInt() and 0xFF) // high = right/small at [3]
assertEquals(0xFF, r[4].toInt() and 0xFF) // low = left/big at [4]
// A nonzero amplitude never collapses to motor 0.
assertEquals(1, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, 0x00FF, 0)[4].toInt())
// The Edge's output report is the 64-byte variant.
assertEquals(64, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE_EDGE, 0, 0).size)
}
@Test
fun ds5TriggerReportPlacesTheBlockPerSide() {
val effect = ByteArray(11) { (it + 1).toByte() }
val r2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 1, effect = effect)
assertEquals(0x04, r2[1].toInt()) // R2 valid flag
assertEquals(1, r2[11].toInt()) // block at [11..22)
assertEquals(11, r2[21].toInt())
assertEquals(0, r2[22].toInt())
val l2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 0, effect = effect)
assertEquals(0x08, l2[1].toInt()) // L2 valid flag
assertEquals(1, l2[22].toInt()) // block at [22..33)
assertEquals(11, l2[32].toInt())
// Oversized wire effects clamp to the 11-byte hardware block.
val big = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, 1, ByteArray(20) { 0x7F })
assertEquals(0, big[22].toInt())
}
@Test
fun ds5LightbarPlayerLedsAndInit() {
val led = DsDevice.ds5LightbarReport(DsDevice.Model.DUALSENSE, 1, 2, 3)
assertEquals(0x04, led[2].toInt()) // lightbar valid flag
assertEquals(1, led[45].toInt()); assertEquals(2, led[46].toInt()); assertEquals(3, led[47].toInt())
val pl = DsDevice.ds5PlayerLedsReport(DsDevice.Model.DUALSENSE, 0xFF)
assertEquals(0x10, pl[2].toInt()) // player-LED valid flag
assertEquals(0x1F, pl[44].toInt()) // masked to the 5 LEDs
val init = DsDevice.ds5InitReport(DsDevice.Model.DUALSENSE)
assertEquals(0x02, init[39].toInt()) // lightbar-setup enable
assertEquals(0x02, init[42].toInt()) // LIGHT_OUT — releases the firmware animation
}
@Test
fun ds4ReportIsAFullStateWrite() {
val r = DsDevice.ds4Report(low = 0xAB00, high = 0x0100, r = 9, g = 8, b = 7)
assertEquals(32, r.size)
assertEquals(0x05, r[0].toInt())
assertEquals(0x03, r[1].toInt()) // motors | LED, both — composed state
assertEquals(0x01, r[4].toInt()) // high = weak/right at [4]
assertEquals(0xAB, r[5].toInt() and 0xFF) // low = strong/left at [5]
assertEquals(9, r[6].toInt()); assertEquals(8, r[7].toInt()); assertEquals(7, r[8].toInt())
assertEquals(0, r[9].toInt()) // blink untouched
}
@Test
fun modelResolution() {
assertEquals(DsDevice.Model.DUALSENSE, DsDevice.modelFor(0x0CE6))
assertEquals(DsDevice.Model.DUALSENSE_EDGE, DsDevice.modelFor(0x0DF2))
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x05C4))
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x09CC))
assertEquals(null, DsDevice.modelFor(0x1234))
assertEquals(Gamepad.PREF_DUALSENSE, DsDevice.Model.DUALSENSE.pref)
assertEquals(Gamepad.PREF_DUALSENSEEDGE, DsDevice.Model.DUALSENSE_EDGE.pref)
assertEquals(Gamepad.PREF_DUALSHOCK4, DsDevice.Model.DUALSHOCK4.pref)
}
}
+141 -28
View File
@@ -17,10 +17,12 @@ use super::display::{
apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
};
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags};
use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter};
use super::setup::{
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
configure_low_latency, create_codec, try_set_frame_rate,
};
use super::vsync::{now_monotonic_ns, VsyncClock};
use super::{
DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE,
NO_VIDEO_RETRY, PENDING_SPLIT_CAP,
@@ -55,6 +57,8 @@ enum DecodeEvent {
},
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
FormatChanged,
/// A panel vsync (from the [`VsyncClock`] thread) — the presenter's retry/pacing tick.
Vsync,
/// The codec reported an error; `fatal` when neither recoverable nor transient.
Error { fatal: bool },
}
@@ -78,6 +82,9 @@ pub(super) fn run_async(
ll_feature,
low_latency_mode,
is_tv,
present_priority,
smooth_buffer,
panel_hz,
} = opts;
boost_thread_priority();
let mode = client.mode();
@@ -196,9 +203,33 @@ pub(super) fn run_async(
// parked in the tracker at release; the OnFrameRendered callback pairs it with
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
// reclaimed after the codec is dropped below.
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
let meter = Arc::new(PresentMeter::new());
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone(), meter.clone());
let render_cb = install_render_callback(&codec, &tracker);
// The timeline presenter (see `presenter.rs`): newest-wins / smoothing store, one-in-flight
// glass budget, timeline-timed release. `debug.punktfunk.presenter = arrival` selects the
// legacy release-immediately path for a rebuild-free on-device A/B.
let mut presenter = if presenter_disabled_by_sysprop() {
log::info!("decode: presenter = arrival (sysprop) — legacy immediate release");
None
} else {
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
log::info!(
"decode: presenter = timeline ({})",
match priority {
PresentPriority::Latency => "lowest latency".to_string(),
PresentPriority::Smooth { buffer } => format!("smoothness, buffer {buffer}"),
}
);
Some(Presenter::new(priority))
};
stats.set_presenter_active(presenter.is_some());
// The vsync clock, started LAZILY on the first decoded frame (see `vsync.rs`); its ticks ride
// the same event channel. The Sender parks here until that moment.
let mut vsync: Option<VsyncClock> = None;
let mut vsync_tx = presenter.is_some().then(|| ev_tx.clone());
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
let feeder = {
@@ -277,6 +308,7 @@ pub(super) fn run_async(
};
let work_t0 = Instant::now();
let mut fmt_dirty = false;
let mut vsync_tick = false;
let mut aus_dropped: u64 = 0;
if let Some(ev) = ev0 {
aus_dropped += u64::from(dispatch_event(
@@ -285,6 +317,7 @@ pub(super) fn run_async(
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut vsync_tick,
&mut fatal,
&mut gate,
&mut recovery_flags,
@@ -299,11 +332,17 @@ pub(super) fn run_async(
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut vsync_tick,
&mut fatal,
&mut gate,
&mut recovery_flags,
));
}
if vsync_tick {
if let Some(p) = presenter.as_mut() {
p.on_vsync();
}
}
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
if fmt_dirty {
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
@@ -317,6 +356,7 @@ pub(super) fn run_async(
&mut oversized_dropped,
);
let had_output = !ready.is_empty();
let rendered_before = rendered;
present_ready(
&codec,
&client,
@@ -326,14 +366,64 @@ pub(super) fn run_async(
&in_flight,
clock_offset.load(Ordering::Relaxed),
&tracker,
&mut presenter,
&mut rendered,
&mut discarded,
&mut gate,
&mut recovery_flags,
);
// The presenter's decision point runs EVERY pass — frame arrivals, vsync ticks and the
// 5 ms housekeeping wake all land here, which is what reopens the glass budget on time
// even when the choreographer clock is absent.
if let Some(p) = presenter.as_mut() {
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
if p.pump(&codec, clock, &tracker, &stats, now_monotonic_ns()) {
rendered += 1;
}
// The 1 Hz window flush doubles as the phase-lock report tick: the measured latch
// p50 is the arrival-lead error signal the host's capture controller drives toward
// its target (design/phase-locked-capture.md §6). Timestamps convert
// monotonic→realtime→host here — the skew offset lives only client-side.
if let (Some(latch_p50_ns), Some(c)) = (p.flush_log(&meter, clock), clock) {
let period = c.panel_period_ns().max(c.period_ns());
if period > 0 {
if let Some(t) = c.next_target(now_monotonic_ns(), 0) {
let mono_now = now_monotonic_ns();
let real_now = now_realtime_ns();
let latch_real_ns = real_now + (t.expected_present_ns - mono_now) as i128;
let latch_host_ns = (latch_real_ns
+ clock_offset.load(Ordering::Relaxed) as i128)
.max(0) as u64;
client.report_phase(
latch_host_ns,
period.clamp(0, u32::MAX as i64) as u32,
1_000_000, // skew residual + latch jitter — conservative 1 ms
latch_p50_ns.min(u32::MAX as u64) as u32,
);
}
}
}
}
let presented_now = rendered > rendered_before;
// Start the vsync clock LAZILY on the first decoded output (eager, it ticks the panel
// rate into a session that has no frame yet — the Apple deadline presenter's bootstrap
// lesson). A `None` from start (no choreographer surface) simply leaves ASAP targets.
if had_output && vsync.is_none() {
if let Some(tx) = vsync_tx.take() {
vsync = VsyncClock::start(
panel_hz,
Box::new(move || {
let _ = tx.send(DecodeEvent::Vsync);
}),
);
if vsync.is_none() {
log::info!("decode: no choreographer clock — presenter uses ASAP targets");
}
}
}
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
if had_output {
if presented_now {
if !hint_tried {
hint_tried = true;
let tids = client.hot_thread_ids();
@@ -420,6 +510,10 @@ pub(super) fn run_async(
}
}
if let Some(p) = presenter.as_mut() {
p.release_all(&codec); // hand every held output buffer back before the codec stops
}
drop(vsync); // stop + join the choreographer thread; its channel sends are harmless after
let _ = codec.stop();
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
if let Some(j) = feeder {
@@ -520,6 +614,7 @@ fn dispatch_event(
free_inputs: &mut VecDeque<usize>,
ready: &mut Vec<OutputReady>,
fmt_dirty: &mut bool,
vsync_tick: &mut bool,
fatal: &mut bool,
gate: &mut ReanchorGate,
recovery_flags: &mut VecDeque<(u64, u32)>,
@@ -552,6 +647,7 @@ fn dispatch_event(
decoded_ns,
}),
DecodeEvent::FormatChanged => *fmt_dirty = true,
DecodeEvent::Vsync => *vsync_tick = true,
DecodeEvent::Error { fatal: f } => {
if f {
*fatal = true;
@@ -613,13 +709,14 @@ fn feed_ready(
}
}
/// Present only the NEWEST ready output (render = true) and release the rest without rendering — a
/// burst of stale frames on glass is worse than skipping to the freshest (the sync loop's newest-ready
/// policy, callback-driven). Every dequeued buffer, rendered or not, is the HUD's `decoded`
/// measurement point (it finished decoding either way); samples are recorded in pts order so the
/// receipt-map eviction stays monotonic. The presented frame's `(pts, decoded stamp)` is parked in
/// `tracker` for the OnFrameRendered callback — the `display` stage's other endpoint. `ready` is
/// drained.
/// Route the ready outputs toward glass. With the timeline presenter (default): fold each output
/// through the re-anchor gate in pts order, hand the approved ones to the presenter's store
/// (newest-wins / smoothing FIFO — the actual release happens in `Presenter::pump`, budgeted and
/// timeline-timed), and release withheld concealment unrendered. Legacy (`arrival` sysprop):
/// present only the NEWEST ready output immediately and release the rest unrendered — the
/// original policy. Every dequeued buffer, rendered or not, is the HUD's `decoded` measurement
/// point (it finished decoding either way); samples are recorded in pts order so the receipt-map
/// eviction stays monotonic. `ready` is drained.
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
fn present_ready(
codec: &MediaCodec,
@@ -630,6 +727,7 @@ fn present_ready(
in_flight: &Mutex<VecDeque<(u64, i128)>>,
clock_offset: i64,
tracker: &DisplayTracker,
presenter: &mut Option<Presenter>,
rendered: &mut u64,
discarded: &mut u64,
gate: &mut ReanchorGate,
@@ -657,31 +755,46 @@ fn present_ready(
}
}
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
// so the two-mark re-anchor count stays correct; the newest's verdict decides whether it reaches
// glass (`false` = withheld concealment; the SurfaceView keeps the last rendered frame frozen on).
// so the two-mark re-anchor count stays correct; a `false` verdict is withheld concealment (the
// SurfaceView keeps the last rendered frame frozen on).
let now = Instant::now();
let last = ready.len() - 1;
let mut skipped: u64 = 0;
for (i, o) in ready.drain(..).enumerate() {
let flags = take_flags(recovery_flags, o.pts_us);
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
let render = i == last && present;
match codec.release_output_buffer_by_index(o.index, render) {
Ok(()) if render => {
*rendered += 1;
if stats.enabled() {
tracker.note_rendered(o.pts_us, o.decoded_ns);
if let Some(p) = presenter.as_mut() {
for o in ready.drain(..) {
let flags = take_flags(recovery_flags, o.pts_us);
if gate.on_decoded(flags, false, now) == GateVerdict::Present {
let dropped = p.submit(codec, o.index, o.pts_us, o.decoded_ns);
skipped += dropped;
*discarded += dropped;
} else {
if let Err(e) = codec.release_output_buffer_by_index(o.index, false) {
log::warn!("decode: release_output_buffer_by_index({}): {e}", o.index);
}
}
Ok(()) => {
*discarded += 1;
skipped += 1;
}
Err(e) => {
log::warn!(
"decode: release_output_buffer_by_index({}, {render}): {e}",
o.index
)
}
} else {
let last = ready.len() - 1;
for (i, o) in ready.drain(..).enumerate() {
let flags = take_flags(recovery_flags, o.pts_us);
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
let render = i == last && present;
match codec.release_output_buffer_by_index(o.index, render) {
Ok(()) if render => {
*rendered += 1;
tracker.note_rendered(o.pts_us, o.decoded_ns, now_realtime_ns());
}
Ok(()) => {
*discarded += 1;
skipped += 1;
}
Err(e) => {
log::warn!(
"decode: release_output_buffer_by_index({}, {render}): {e}",
o.index
)
}
}
}
}
+29 -18
View File
@@ -35,32 +35,37 @@ pub(super) struct DisplayTracker {
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
/// keeps the leaked render-callback refcount from pinning the whole session alive.
clock_offset: Arc<AtomicI64>,
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
/// callback early-outs) while the overlay is hidden.
rendered: Mutex<VecDeque<(u64, i128)>>,
/// Always-on latch/display accumulator for the presenter's 1 Hz `pf-present` line —
/// independent of the HUD gate, so a HUD-off A/B stays measurable from logcat.
meter: Arc<super::presenter::PresentMeter>,
/// `(pts_us, decoded_real_ns, released_real_ns)` of frames released with `render = true`, in
/// release order, awaiting their callback. Pushed on EVERY render (no HUD gate — the ring is
/// a 64-tuple bound and the latch metric wants to exist when nobody is watching).
rendered: Mutex<VecDeque<(u64, i128, i128)>>,
}
impl DisplayTracker {
pub(super) fn new(
stats: Arc<crate::stats::VideoStats>,
clock_offset: Arc<AtomicI64>,
meter: Arc<super::presenter::PresentMeter>,
) -> Arc<DisplayTracker> {
Arc::new(DisplayTracker {
stats,
clock_offset,
meter,
rendered: Mutex::new(VecDeque::new()),
})
}
/// Park one just-rendered frame's `(pts, decoded stamp)` for the render callback to pair.
/// Caller gates on the HUD being visible.
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128) {
/// Park one just-rendered frame's `(pts, decoded stamp, release stamp)` for the render
/// callback to pair — the release stamp is the latch metric's start (release→displayed).
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128, released_ns: i128) {
let mut g = self
.rendered
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.push_back((pts_us, decoded_ns));
g.push_back((pts_us, decoded_ns, released_ns));
if g.len() > RENDERED_CAP {
g.pop_front(); // render callbacks stopped coming (allowed under load) — evict
}
@@ -152,36 +157,42 @@ unsafe extern "C" fn on_frame_rendered(
// `Arc::into_raw` pointer from `install_render_callback`, whose refcount is held for as long as
// the codec exists, and the codec is what delivers this call.
let t = unsafe { &*(userdata as *const DisplayTracker) };
if !t.stats.enabled() {
return; // HUD hidden — the ring is empty too (pushes are caller-gated)
}
let displayed_ns = now_realtime_ns() - (now_monotonic_ns() - system_nano as i128);
let pts_us = media_time_us.max(0) as u64;
// Pair the frame back to its release record, evicting older entries (their callbacks were
// dropped by the platform, or the entry predates a HUD toggle) — same monotonic-eviction
// discipline as `note_decoded_pts`.
let mut decoded_ns = None;
// dropped by the platform) — same monotonic-eviction discipline as `note_decoded_pts`.
let mut paired = None;
{
let mut g = t
.rendered
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while let Some(&(p, d)) = g.front() {
while let Some(&(p, d, r)) = g.front() {
if p > pts_us {
break; // future frame — leave it for its own callback
}
g.pop_front();
if p == pts_us {
decoded_ns = Some(d);
paired = Some((d, r));
break;
}
}
}
// Clamped to (0, 10 s) like the e2e sample: a vendor's first render callbacks can carry a
// garbage `system_nano` (observed on-glass: an epoch-sized latch max on the session's first
// window), and one such sample would poison every max/percentile it lands in.
let clamp = |v: i128| (v > 0 && v < 10_000_000_000).then_some((v / 1000) as u64);
let display_us = paired.and_then(|(d, _)| clamp(displayed_ns - d));
let latch_us = paired.and_then(|(_, r)| clamp(displayed_ns - r));
// Always-on half: the presenter's pf-present line reads these with the HUD off.
t.meter.note_latch(latch_us);
if !t.stats.enabled() {
return; // HUD hidden — skip the skew math + the stats lock
}
let e2e_ns =
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
t.stats.note_displayed(e2e_us, display_us);
t.stats.note_displayed(e2e_us, display_us, latch_us);
}
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
+13
View File
@@ -9,8 +9,10 @@
mod async_loop;
mod display;
mod latency;
mod presenter;
mod setup;
mod sync_loop;
mod vsync;
use async_loop::run_async;
pub(crate) use setup::{codec_label, codec_mime};
@@ -106,6 +108,17 @@ pub(crate) struct DecodeOptions {
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
pub is_tv: bool,
/// The user's presentation intent (`present_priority` setting): 0 = lowest latency
/// (newest-wins), 1 = smoothness (a small FIFO). Resolved by
/// [`presenter::PresentPriority::resolve`]; anything else = latency.
pub present_priority: i32,
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
/// Only meaningful with `present_priority` = smooth.
pub smooth_buffer: i32,
/// The display mode's own refresh rate (Kotlin's `display.refreshRate` at stream start;
/// 0 = unknown) — the latch grid the presenter subdivides onto when the app's choreographer
/// stream is down-rated below the panel (see `vsync.rs`).
pub panel_hz: i32,
}
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
@@ -0,0 +1,408 @@
//! The timeline presenter — Android's port of the Apple client's stage-4 deadline discipline
//! (`clients/apple/.../Stage2Pipeline.swift`, the `.deadline` pacing):
//!
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
//! queueing behind the display;
//! * a **glass budget of exactly one**: at most one undisplayed release in flight to
//! SurfaceFlinger, reopened on the clock-predicted latch (with a 100 ms stale force-open as
//! the liveness backstop, mirroring Apple's `PresentGate.staleAfter`). The BufferQueue can
//! hold at most the frame being scanned out plus one — a standing queue is unconstructible;
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
//! identical to the legacy path — and only the budget prediction uses the measured period.
//!
//! The legacy behaviour (release the newest ready buffer immediately, unbudgeted) remains
//! selectable at runtime: `adb shell setprop debug.punktfunk.presenter arrival` — the on-device
//! A/B needs no rebuild. The user-facing escape hatch stays the "Low-latency mode" master toggle
//! (off = the synchronous pre-overhaul loop, no presenter at all).
use ndk::media::media_codec::MediaCodec;
use std::collections::VecDeque;
use std::sync::Mutex;
use std::time::Instant;
use super::display::DisplayTracker;
use super::latency::now_realtime_ns;
use super::vsync::VsyncShared;
/// Submit-margin ahead of a timeline's EXPECTED PRESENT — SurfaceFlinger's own latch lead: the
/// released buffer must be in the BufferQueue by SF's wakeup for that vsync (a few ms before
/// present). A present closer than this is treated as missed and the next one is targeted. This
/// is deliberately NOT the timeline's `deadline` (which budgets for GPU rendering a video
/// buffer doesn't do — see `VsyncShared::next_target`); a too-tight gamble here presents one
/// vsync later, the exact cost the deadline gate paid on every frame.
///
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
/// here is a ms off every frame's display stage. If a device misses at this margin the `paced`
/// counter shows it (a miss presents one vsync later, coalescing the next frame) — that is the
/// signal to widen, not stutter.
const LATCH_MARGIN_NS: i64 = 2_500_000;
/// The budget's liveness backstop: a release whose predicted latch never seems to arrive
/// (clock glitch, mode switch) force-reopens the budget this long after the release, counted in
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
const STALE_REOPEN_NS: i64 = 100_000_000;
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
/// The user's presentation intent — the Apple client's `PresentPriority`, same resolution rules:
/// anything but an explicit "smooth" is latency; a smooth buffer outside 1..=3 becomes 2.
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum PresentPriority {
/// Newest-wins, release the instant the budget opens. The default.
Latency,
/// A small FIFO (1..=3 frames) drained one per vsync: jitter absorbed at one refresh of
/// added display latency per slot, which the metrics show rather than hide.
Smooth { buffer: usize },
}
impl PresentPriority {
/// From the JNI ints (`presentPriority` 0 = latency / 1 = smooth; `smoothBuffer` 0 = auto).
pub(crate) fn resolve(priority: i32, buffer: i32) -> PresentPriority {
if priority != 1 {
return PresentPriority::Latency;
}
let b = if (1..=3).contains(&buffer) {
buffer as usize
} else {
2
};
PresentPriority::Smooth { buffer: b }
}
}
/// One decoded output buffer held for presentation.
struct HeldFrame {
index: usize,
pts_us: u64,
/// The output callback's `CLOCK_REALTIME` stamp — the pace metric's start (decoded→release).
decoded_ns: i128,
}
/// The one-in-flight glass budget.
struct InFlight {
/// Monotonic instant the budget reopens: the release target's expected present (clock), or
/// `release + period` on the fallback path.
reopen_at_ns: i64,
released_at_ns: i64,
}
/// Latch samples + display confirms recorded by the `OnFrameRendered` callback thread, drained by
/// the presenter's 1 Hz `pf-present` line. Always on (independent of the HUD) — this is what makes
/// a HUD-off wireless A/B readable from logcat.
pub(super) struct PresentMeter {
inner: Mutex<PresentMeterInner>,
}
struct PresentMeterInner {
latch_us: Vec<u64>,
displays: u64,
}
impl PresentMeter {
pub(super) fn new() -> PresentMeter {
PresentMeter {
inner: Mutex::new(PresentMeterInner {
latch_us: Vec::with_capacity(256),
displays: 0,
}),
}
}
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.displays += 1;
if let Some(l) = latch_us {
if g.latch_us.len() < 4096 {
g.latch_us.push(l);
}
}
}
fn drain(&self) -> (Vec<u64>, u64) {
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let displays = g.displays;
g.displays = 0;
(std::mem::take(&mut g.latch_us), displays)
}
}
/// p50/max of an unsorted µs sample vec, in ms. (0, 0) when empty.
fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) {
if v.is_empty() {
return (0.0, 0.0);
}
v.sort_unstable();
let p50 = v[v.len() / 2] as f64 / 1000.0;
let max = *v.last().unwrap() as f64 / 1000.0;
(p50, max)
}
pub(super) struct Presenter {
/// 0 = newest-wins; 1..=3 = smoothing FIFO capacity.
fifo_capacity: usize,
frames: VecDeque<HeldFrame>,
/// FIFO preroll: `take` withholds until the buffer filled to capacity once, re-armed on a dry
/// run — the Apple `FrameStore` semantics (headroom never builds without it).
prerolled: bool,
inflight: Option<InFlight>,
/// A vsync arrived since the last release — the FIFO's one-per-refresh drain pace.
vsync_tick: bool,
// -- 1 Hz pf-present window, always on --
released: u64,
paced_drops: u64,
no_budget: u64,
forced: u64,
dry: u64,
pace_us: Vec<u64>,
last_flush: Instant,
}
impl Presenter {
pub(super) fn new(priority: PresentPriority) -> Presenter {
Presenter {
fifo_capacity: match priority {
PresentPriority::Latency => 0,
PresentPriority::Smooth { buffer } => buffer,
},
frames: VecDeque::new(),
prerolled: false,
inflight: None,
vsync_tick: false,
released: 0,
paced_drops: 0,
no_budget: 0,
forced: 0,
dry: 0,
pace_us: Vec::with_capacity(256),
last_flush: Instant::now(),
}
}
/// A vsync pulse from the clock thread's event — the retry tick for a parked frame and the
/// FIFO's drain pace.
pub(super) fn on_vsync(&mut self) {
self.vsync_tick = true;
}
/// Accept one decoded, gate-approved output buffer. Newest-wins evicts everything older
/// (released unrendered — the explicit, counted drop); the FIFO evicts its oldest past
/// capacity. Returns how many frames were dropped by the policy (the HUD's `skipped`).
pub(super) fn submit(
&mut self,
codec: &MediaCodec,
index: usize,
pts_us: u64,
decoded_ns: i128,
) -> u64 {
let mut dropped = 0u64;
if self.fifo_capacity == 0 {
while let Some(stale) = self.frames.pop_front() {
release_unrendered(codec, stale.index);
dropped += 1;
}
}
self.frames.push_back(HeldFrame {
index,
pts_us,
decoded_ns,
});
if self.fifo_capacity > 0 && self.frames.len() > self.fifo_capacity {
if let Some(stale) = self.frames.pop_front() {
release_unrendered(codec, stale.index);
dropped += 1;
}
}
self.paced_drops += dropped;
dropped
}
/// The present decision point — run on every loop pass (frame arrivals, vsync ticks, and the
/// 5 ms housekeeping wake all land here). Releases AT MOST one frame (the budget). Returns
/// `true` when a frame was released to glass this call.
#[allow(clippy::too_many_arguments)] // one call site; the seams are the point
pub(super) fn pump(
&mut self,
codec: &MediaCodec,
clock: Option<&VsyncShared>,
tracker: &DisplayTracker,
stats: &crate::stats::VideoStats,
now_mono_ns: i64,
) -> bool {
// Budget bookkeeping first: reopen on the predicted latch, force-open on the backstop.
if let Some(f) = &self.inflight {
if now_mono_ns >= f.reopen_at_ns {
self.inflight = None;
} else if now_mono_ns - f.released_at_ns > STALE_REOPEN_NS {
self.forced += 1;
self.inflight = None;
}
}
// Pick the frame this pump may release.
let frame = if self.fifo_capacity == 0 {
self.frames.pop_back() // submit() kept it a single slot; back == the newest
} else {
// FIFO: drain exactly one frame per vsync tick, after preroll; a drain tick that
// finds the buffer dry re-arms preroll (the Apple `FrameStore` underflow semantics —
// the previous frame persists on glass, a repeat by omission, while headroom
// rebuilds). Everything is gated on the tick so an idle stream neither counts
// underflows nor churns the preroll flag 200×/s.
if !self.vsync_tick {
return false;
}
if !self.prerolled {
if self.frames.len() < self.fifo_capacity {
return false;
}
self.prerolled = true;
}
if self.frames.is_empty() {
self.prerolled = false;
self.dry += 1;
self.vsync_tick = false; // this tick's drain ran (and found nothing)
return false;
}
self.frames.pop_front()
};
let Some(frame) = frame else { return false };
if self.inflight.is_some() {
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
// vsync tick / loop pass retries the pairing.
self.no_budget += 1;
match self.fifo_capacity {
0 => self.frames.push_back(frame),
_ => self.frames.push_front(frame),
}
return false;
}
// Release: timeline-timed when the clock has one, ASAP otherwise.
let target = clock.and_then(|c| c.next_target(now_mono_ns, LATCH_MARGIN_NS));
let released = match target {
Some(t) => codec
.release_output_buffer_at_time_by_index(frame.index, t.expected_present_ns)
.map_err(|e| log::warn!("presenter: release_at_time({}): {e}", frame.index)),
None => codec
.release_output_buffer_by_index(frame.index, true)
.map_err(|e| log::warn!("presenter: release({}): {e}", frame.index)),
};
self.vsync_tick = false;
if released.is_err() {
return false; // the buffer is gone either way; nothing to book-keep
}
let period = clock.map(|c| c.period_ns()).filter(|&p| p > 0);
// Reopen at SurfaceFlinger's LATCH for the targeted vsync (expected present minus the
// latch lead) — the instant SF consumes the queued buffer and the slot frees, so the
// next release can target the NEXT refresh. Not the platform `deadline` (with the
// aggressive present gate it can already be in the past — an instant reopen would let
// two releases pile onto the same vsync) and not the present time itself (a period too
// late — it would cap the sustainable rate at roughly half the panel).
let reopen_at_ns = target
.map(|t| t.expected_present_ns - LATCH_MARGIN_NS)
.unwrap_or(now_mono_ns + period.unwrap_or(FALLBACK_PERIOD_NS));
self.inflight = Some(InFlight {
reopen_at_ns,
released_at_ns: now_mono_ns,
});
self.released += 1;
let release_real_ns = now_realtime_ns();
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
if self.pace_us.len() < 4096 {
self.pace_us.push(pace_us);
}
stats.note_release(pace_us);
tracker.note_rendered(frame.pts_us, frame.decoded_ns, release_real_ns);
true
}
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
while let Some(f) = self.frames.pop_front() {
release_unrendered(codec, f.index);
}
self.inflight = None;
}
/// The 1 Hz `pf-present` logcat mirror (target `pf.present`) — the Apple client's Console
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
/// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) /
/// `vsync` (the measured panel period).
///
/// Returns this window's measured latch p50 (ns) when a window actually flushed — the
/// phase-lock reporter's `arrival_lead` error signal (design/phase-locked-capture.md §6).
pub(super) fn flush_log(
&mut self,
meter: &PresentMeter,
clock: Option<&VsyncShared>,
) -> Option<u64> {
if self.last_flush.elapsed() < std::time::Duration::from_secs(1) {
return None;
}
self.last_flush = Instant::now();
let (latch, displays) = meter.drain();
if self.released == 0 && displays == 0 {
return None; // idle stream — nothing worth a line
}
let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us));
let (latch_p50, latch_max) = p50_max_ms(latch);
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
let panel_ms = clock
.map(|c| c.panel_period_ns() as f64 / 1e6)
.unwrap_or(0.0);
log::info!(
target: "pf.present",
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} vsyncMs={:.2} panelMs={:.2}",
self.released,
displays,
self.paced_drops,
self.no_budget,
self.forced,
self.dry,
pace_p50,
pace_max,
latch_p50,
latch_max,
period_ms,
panel_ms,
);
self.released = 0;
self.paced_drops = 0;
self.no_budget = 0;
self.forced = 0;
self.dry = 0;
(latch_p50 > 0.0).then(|| (latch_p50 * 1e6) as u64)
}
}
fn release_unrendered(codec: &MediaCodec, index: usize) {
if let Err(e) = codec.release_output_buffer_by_index(index, false) {
log::warn!("presenter: release_output_buffer({index}, false): {e}");
}
}
/// `debug.punktfunk.presenter` sysprop: `arrival` = the legacy release-immediately path,
/// anything else / unset = the timeline presenter. The rebuild-free on-device A/B lever.
pub(super) fn presenter_disabled_by_sysprop() -> bool {
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
let n = unsafe {
libc::__system_property_get(
c"debug.punktfunk.presenter".as_ptr(),
buf.as_mut_ptr().cast(),
)
};
n > 0 && &buf[..n as usize] == b"arrival"
}
+14 -6
View File
@@ -180,8 +180,14 @@ pub(super) fn boost_thread_priority() {
/// mode (e.g. 60↔120) instead of leaving the panel at its default and judder-matching. The
/// forced switch may blank the panel briefly — acceptable once at stream start, not wanted on a
/// phone. Falls through to the 2-arg hint on API 30.
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) with `compatibility = DEFAULT` — the
/// softer, seamless-preferred hint for phones/tablets and the universal fallback.
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) — the seamless-preferred hint for
/// phones/tablets and the universal fallback.
///
/// Both paths pass `compatibility = FIXED_SOURCE` (1): the stream is fixed-rate video content the
/// client cannot re-pace, which is exactly what that value declares — `DEFAULT` (0) told the
/// platform the app could adapt to whatever rate it picked, an invitation some OEM refresh
/// governors accepted by simply not switching. (The window-level `preferredDisplayModeId` pin in
/// `MainActivity.setStreamDisplayMode` is the phone-side belt to this braces.)
///
/// Returns `true` when the platform accepted a hint; `false` on API < 30 (symbols absent) or a
/// decline.
@@ -200,8 +206,10 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
if lib.is_null() {
return false;
}
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 = ALWAYS,
// compatibility 0 = DEFAULT). Absent on API 30 ⇒ fall through to the 2-arg hint below.
// ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_FIXED_SOURCE — fixed-rate video content.
const FIXED_SOURCE: i8 = 1;
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 =
// ALWAYS). Absent on API 30 ⇒ fall through to the 2-arg hint below.
if is_tv {
let sym = libc::dlsym(
lib,
@@ -209,7 +217,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
);
if !sym.is_null() {
let set = std::mem::transmute::<*mut c_void, SetFrameRateStrategyFn>(sym);
return set(window.ptr().as_ptr().cast(), frame_rate, 0, 1) == 0;
return set(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE, 1) == 0;
}
}
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
@@ -217,7 +225,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
return false; // device API < 30 — no per-surface frame-rate hint
}
let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym);
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, 0) == 0
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE) == 0
}
}
+10 -2
View File
@@ -44,6 +44,10 @@ pub(super) fn run_sync(
ll_feature,
low_latency_mode,
is_tv,
// The timeline presenter lives in the async loop only; this loop IS the escape hatch.
present_priority: _,
smooth_buffer: _,
panel_hz: _,
} = opts;
boost_thread_priority();
let mode = client.mode();
@@ -181,7 +185,11 @@ pub(super) fn run_sync(
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
// reclaimed after the codec is dropped below.
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
let tracker = DisplayTracker::new(
stats.clone(),
clock_offset.clone(),
std::sync::Arc::new(super::presenter::PresentMeter::new()),
);
let render_cb = install_render_callback(&codec, &tracker);
// Receipt timestamps keyed by the pts we queue into the codec, so the decoded point (output-
// buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back to its receipt
@@ -598,7 +606,7 @@ fn drain(
Ok(()) if held_present => {
rendered = 1;
if let Some((pts_us, decoded_ns)) = meta {
tracker.note_rendered(pts_us, decoded_ns);
tracker.note_rendered(pts_us, decoded_ns, super::latency::now_realtime_ns());
}
}
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
+448
View File
@@ -0,0 +1,448 @@
//! The vsync clock behind the timeline presenter: an `AChoreographer` thread publishing the
//! panel's vsync grid + upcoming frame timelines, and pulsing the decode loop's event channel so
//! a frame parked on a closed glass budget gets its retry tick.
//!
//! On API 33+ the thread rides `AChoreographer_postVsyncCallback`, whose callback payload carries
//! the platform's FRAME TIMELINES — for each upcoming refresh, when SurfaceFlinger expects to
//! present and the deadline by which a frame must be submitted to make it. That pair is exactly
//! what `AMediaCodec_releaseOutputBufferAtTime` wants as its target. On 31/32 the older
//! `postFrameCallback64` supplies only the vsync instant; the presenter then releases ASAP
//! (identical to the legacy path) and uses the measured period purely to predict the latch for
//! its glass budget.
//!
//! Every `AChoreographer_*` symbol is dlsym-resolved from `libandroid.so` (mirrors
//! [`super::setup::try_set_frame_rate`]): several sit above the crate's API floor, and one hard
//! import of a too-new symbol fails `System.loadLibrary` on every older device.
//!
//! Started LAZILY on the first decoded frame (the Apple deadline presenter's bootstrap lesson:
//! an eagerly started clock ticks uselessly for the whole connect window), stopped + joined via
//! [`VsyncClock`]'s `Drop`.
use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// `CLOCK_MONOTONIC` now in nanoseconds — the clock AChoreographer stamps its timelines on and
/// the one `AMediaCodec_releaseOutputBufferAtTime` compares against (`System.nanoTime` basis).
/// Distinct from the stats path's `CLOCK_REALTIME`: presenter scheduling stays monotonic.
pub(super) fn now_monotonic_ns() -> i64 {
let mut ts = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
// SAFETY: `clock_gettime` with a valid out-pointer is an always-safe syscall.
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
// Explicit widening: timespec's fields are 32-bit on armv7 (time_t/c_long).
ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64
}
/// One upcoming frame timeline (API 33+ payload): when SurfaceFlinger expects to present the
/// frame, and the last instant it can be submitted to make that present. Monotonic ns.
#[derive(Clone, Copy)]
pub(super) struct FrameTimeline {
pub expected_present_ns: i64,
pub deadline_ns: i64,
}
/// State the choreographer thread publishes and the decode loop reads. All monotonic ns.
pub(super) struct VsyncShared {
stop: AtomicBool,
/// The latest vsync callback's frame time (0 = no callback yet).
last_vsync_ns: AtomicI64,
/// Estimated vsync period (EMA over callback deltas / timeline spacing; 0 = unmeasured).
///
/// ⚠ This is the APP's render rate, not necessarily the panel's: Android down-rates a
/// process's vsync stream (frame-rate categories / per-uid overrides), so a quiet UI can be
/// served 60 Hz callbacks while the panel scans at 120 (observed on-glass, A024). Pacing
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
/// [`Self::next_target`].
period_ns: AtomicI64,
/// The panel's own refresh period (from the display mode Kotlin resolved at stream start;
/// 0 = unknown). The grid SurfaceFlinger actually latches on.
panel_period_ns: AtomicI64,
/// Callback count, for the one-shot cadence diagnostic log.
ticks: std::sync::atomic::AtomicU32,
/// The latest callback's upcoming timelines, soonest first. Empty on the 31/32 fallback.
timelines: Mutex<Vec<FrameTimeline>>,
}
impl VsyncShared {
/// The measured vsync period, or 0 while unmeasured.
pub(super) fn period_ns(&self) -> i64 {
self.period_ns.load(Ordering::Relaxed)
}
/// The panel's own refresh period (0 = unknown) — for the pf-present line's decomposition.
pub(super) fn panel_period_ns(&self) -> i64 {
self.panel_period_ns.load(Ordering::Relaxed)
}
/// The release target for a frame submitted at `now`: the earliest stored timeline whose
/// EXPECTED PRESENT is still `margin` away, extrapolated forward by whole periods once the
/// stored set has aged out (timelines refresh once per vsync callback; a frame can decode
/// anywhere inside that window). `None` on the 31/32 fallback — the caller releases ASAP.
///
/// Gated on `expected_present`, NOT the timeline's `deadline`, on purpose: the 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), but a video buffer is already fully rendered —
/// the only real constraint is SurfaceFlinger's own latch lead, which is what the caller's
/// `margin` represents. Targeting by deadline cost every frame an extra refresh of waiting
/// (measured: latch p50 ~21 ms vs the ~2-interval floor); a mis-gamble here just means the
/// frame presents one vsync later — exactly what the conservative gate always paid.
///
/// The picked target is then SUBDIVIDED onto the panel grid: the platform reports timelines
/// at the app's assigned render rate, but the panel latches at its own — when the app is
/// down-rated (60 Hz callbacks on a 120 Hz panel) the reported timelines are a whole panel
/// period apart or more, and pacing to them would cap the video. Pulling the target earlier
/// by whole panel periods (while its present still clears the margin) restores the true
/// grid; when callbacks run at the panel rate the pull condition is never true and this is
/// a no-op.
pub(super) fn next_target(&self, now_ns: i64, margin_ns: i64) -> Option<FrameTimeline> {
let mut t = {
let g = self
.timelines
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let found = g
.iter()
.find(|t| t.expected_present_ns > now_ns + margin_ns)
.copied();
match found {
Some(t) => t,
None => {
let last = g.last().copied()?;
let period = self.period_ns();
if period <= 0 {
return None;
}
// All stored timelines have passed — step the last one forward whole
// periods until its present clears `now + margin` again.
let behind = (now_ns + margin_ns).saturating_sub(last.expected_present_ns);
let k = behind / period + 1;
FrameTimeline {
expected_present_ns: last.expected_present_ns + k * period,
deadline_ns: last.deadline_ns + k * period,
}
}
}
};
let panel = self.panel_period_ns.load(Ordering::Relaxed);
if panel > 0 {
while t.expected_present_ns - panel > now_ns + margin_ns {
t.deadline_ns -= panel;
t.expected_present_ns -= panel;
}
}
Some(t)
}
}
// ---- dlsym'd AChoreographer surface ----
type PostFrameCallback64 =
unsafe extern "C" fn(*mut c_void, unsafe extern "C" fn(i64, *mut c_void), *mut c_void);
type PostVsyncCallback = unsafe extern "C" fn(
*mut c_void,
unsafe extern "C" fn(*const c_void, *mut c_void),
*mut c_void,
);
struct ChoreoApi {
get_instance: unsafe extern "C" fn() -> *mut c_void,
/// API 33: vsync callback with frame-timeline payload. Preferred.
post_vsync: Option<PostVsyncCallback>,
/// API 29 fallback: frame callback with only the vsync instant.
post_frame64: Option<PostFrameCallback64>,
// AChoreographerFrameCallbackData accessors (API 33; present iff `post_vsync` is).
fcd_frame_time: Option<unsafe extern "C" fn(*const c_void) -> i64>,
fcd_timelines_len: Option<unsafe extern "C" fn(*const c_void) -> usize>,
fcd_preferred_index: Option<unsafe extern "C" fn(*const c_void) -> usize>,
fcd_expected_present: Option<unsafe extern "C" fn(*const c_void, usize) -> i64>,
fcd_deadline: Option<unsafe extern "C" fn(*const c_void, usize) -> i64>,
}
impl ChoreoApi {
/// Resolve from `libandroid.so`. `None` when even the baseline symbols are missing.
fn resolve() -> Option<ChoreoApi> {
// SAFETY: dlopen of the always-mapped libandroid.so (refcount bump, never closed); each
// dlsym is null-checked before the transmute to its fn-pointer type.
unsafe {
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
if lib.is_null() {
return None;
}
let sym = |name: &std::ffi::CStr| {
let p = libc::dlsym(lib, name.as_ptr());
(!p.is_null()).then_some(p)
};
let get_instance = sym(c"AChoreographer_getInstance")?;
let post_vsync = sym(c"AChoreographer_postVsyncCallback");
let post_frame64 = sym(c"AChoreographer_postFrameCallback64");
post_vsync.or(post_frame64)?; // neither post entry point — no clock on this device
Some(ChoreoApi {
get_instance: std::mem::transmute::<
*mut c_void,
unsafe extern "C" fn() -> *mut c_void,
>(get_instance),
post_vsync: post_vsync.map(|p| std::mem::transmute::<*mut c_void, PostVsyncCallback>(p)),
post_frame64: post_frame64
.map(|p| std::mem::transmute::<*mut c_void, PostFrameCallback64>(p)),
fcd_frame_time: sym(c"AChoreographerFrameCallbackData_getFrameTimeNanos").map(|p| {
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> i64>(p)
}),
fcd_timelines_len: sym(c"AChoreographerFrameCallbackData_getFrameTimelinesLength")
.map(|p| {
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> usize>(
p,
)
}),
fcd_preferred_index: sym(
c"AChoreographerFrameCallbackData_getPreferredFrameTimelineIndex",
)
.map(|p| {
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> usize>(p)
}),
fcd_expected_present: sym(
c"AChoreographerFrameCallbackData_getFrameTimelineExpectedPresentationTimeNanos",
)
.map(|p| {
std::mem::transmute::<
*mut c_void,
unsafe extern "C" fn(*const c_void, usize) -> i64,
>(p)
}),
fcd_deadline: sym(c"AChoreographerFrameCallbackData_getFrameTimelineDeadlineNanos")
.map(|p| {
std::mem::transmute::<
*mut c_void,
unsafe extern "C" fn(*const c_void, usize) -> i64,
>(p)
}),
})
}
}
}
/// Everything a callback invocation needs. Owned by the choreographer thread's stack; callbacks
/// only ever fire inside that thread's looper poll, so the borrow can't outlive the thread.
struct CallbackCtx {
api: ChoreoApi,
choreographer: *mut c_void,
shared: Arc<VsyncShared>,
on_tick: Box<dyn Fn() + Send>,
}
impl CallbackCtx {
/// Common tail of both callback flavours: update the grid estimate, publish, pulse, re-arm.
fn tick(&self, frame_time_ns: i64, timelines: Vec<FrameTimeline>) {
let prev = self
.shared
.last_vsync_ns
.swap(frame_time_ns, Ordering::Relaxed);
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and the finest
// spacing ever observed is the panel's true period — trustworthy where the configured
// value is not (under a per-uid frame-rate override, `Display.getRefreshRate` REPORTS
// THE OVERRIDE, observed on-glass: a 120 Hz panel read back as 60 while early timelines
// ran at 8.28 ms). Corrects DOWNWARD only: subdividing onto a finer real grid is always
// valid, widening on a later down-rated window never is.
if timelines.len() >= 2 {
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
if (2_000_000..=42_000_000).contains(&spacing) {
let cur = self.shared.panel_period_ns.load(Ordering::Relaxed);
if cur == 0 || spacing < cur - 200_000 {
self.shared
.panel_period_ns
.store(spacing, Ordering::Relaxed);
}
}
}
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
// panel period is exactly the down-rating question, and this line answers it on-glass.
if self.shared.ticks.fetch_add(1, Ordering::Relaxed) == 2 {
let spacing = if timelines.len() >= 2 {
timelines[1].expected_present_ns - timelines[0].expected_present_ns
} else {
0
};
log::info!(
"vsync: cadence Δ={:.2}ms timelines={} spacing={:.2}ms panel={:.2}ms",
if prev > 0 {
(frame_time_ns - prev) as f64 / 1e6
} else {
0.0
},
timelines.len(),
spacing as f64 / 1e6,
self.shared.panel_period_ns.load(Ordering::Relaxed) as f64 / 1e6,
);
}
// Period: prefer timeline spacing (exact, straight from the platform), else the delta of
// successive callbacks (jittery — EMA'd), clamped to sane panel rates (24..500 Hz).
let mut period = 0i64;
if timelines.len() >= 2 {
period = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
} else if prev > 0 {
period = frame_time_ns - prev;
}
if (2_000_000..=42_000_000).contains(&period) {
let old = self.shared.period_ns.load(Ordering::Relaxed);
let smoothed = if old > 0 {
(old * 7 + period) / 8
} else {
period
};
self.shared.period_ns.store(smoothed, Ordering::Relaxed);
}
if !timelines.is_empty() {
let mut g = self
.shared
.timelines
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*g = timelines;
}
(self.on_tick)();
if !self.shared.stop.load(Ordering::Relaxed) {
self.repost();
}
}
fn repost(&self) {
// SAFETY: `choreographer` is this thread's instance; the ctx pointer stays valid for the
// thread's life and callbacks only fire on this thread (see the struct doc).
unsafe {
let ud = self as *const CallbackCtx as *mut c_void;
if let Some(post) = self.api.post_vsync {
post(self.choreographer, on_vsync, ud);
} else if let Some(post) = self.api.post_frame64 {
post(self.choreographer, on_frame64, ud);
}
}
}
}
/// API 33+ trampoline: harvest the frame timelines, then the common tick. Panic-free (an unwind
/// out of an `extern "C"` fn aborts).
unsafe extern "C" fn on_vsync(data: *const c_void, ud: *mut c_void) {
// SAFETY: `ud` is the thread's `CallbackCtx`, alive for the whole poll loop (see struct doc).
let ctx = unsafe { &*(ud as *const CallbackCtx) };
let api = &ctx.api;
let (mut frame_time, mut timelines) = (now_monotonic_ns(), Vec::new());
// SAFETY: `data` is the platform's callback payload, valid for this invocation; the accessors
// were resolved together with `post_vsync` (same API level) and are only called when present.
unsafe {
if let Some(f) = api.fcd_frame_time {
frame_time = f(data);
}
if let (Some(len_f), Some(pref_f), Some(exp_f), Some(dl_f)) = (
api.fcd_timelines_len,
api.fcd_preferred_index,
api.fcd_expected_present,
api.fcd_deadline,
) {
let len = len_f(data).min(8);
// From the PREFERRED index on: earlier timelines are ones the platform already
// considers missed for a frame starting now.
let start = pref_f(data).min(len);
timelines = (start..len)
.map(|i| FrameTimeline {
expected_present_ns: exp_f(data, i),
deadline_ns: dl_f(data, i),
})
.collect();
}
}
ctx.tick(frame_time, timelines);
}
/// API 29 fallback trampoline: vsync instant only.
unsafe extern "C" fn on_frame64(frame_time_ns: i64, ud: *mut c_void) {
// SAFETY: `ud` is the thread's `CallbackCtx` (see `on_vsync`).
let ctx = unsafe { &*(ud as *const CallbackCtx) };
ctx.tick(frame_time_ns, Vec::new());
}
/// The clock: a dedicated looper thread the choreographer calls back on. Dropping stops + joins.
pub(super) struct VsyncClock {
shared: Arc<VsyncShared>,
join: Option<std::thread::JoinHandle<()>>,
}
impl VsyncClock {
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
/// `panel_hz` is the display mode's own refresh rate (0 = unknown), the latch grid that
/// [`VsyncShared::next_target`] subdivides onto. `None` when the platform surface is missing
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
/// budget).
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
let api = ChoreoApi::resolve()?;
let timelines_live = api.post_vsync.is_some();
let shared = Arc::new(VsyncShared {
stop: AtomicBool::new(false),
last_vsync_ns: AtomicI64::new(0),
period_ns: AtomicI64::new(0),
panel_period_ns: AtomicI64::new(if panel_hz > 0 {
1_000_000_000 / panel_hz as i64
} else {
0
}),
ticks: std::sync::atomic::AtomicU32::new(0),
timelines: Mutex::new(Vec::new()),
});
let thread_shared = shared.clone();
let join = std::thread::Builder::new()
.name("pf-vsync".into())
.spawn(move || {
let looper = ndk::looper::ThreadLooper::prepare();
// SAFETY: getInstance on a thread with a prepared looper returns this thread's
// choreographer (never null once a looper exists).
let choreographer = unsafe { (api.get_instance)() };
if choreographer.is_null() {
log::warn!("vsync: AChoreographer_getInstance returned null — no clock");
return;
}
let ctx = CallbackCtx {
api,
choreographer,
shared: thread_shared,
on_tick,
};
ctx.repost();
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
// case teardown waits one timeout out. Callbacks fire inside poll_once_timeout.
while !ctx.shared.stop.load(Ordering::Relaxed) {
let _ = looper.poll_once_timeout(Duration::from_millis(250));
}
// `ctx` drops here — after the loop, so no queued callback can outlive it (they
// only ever fire inside this thread's poll).
})
.ok()?;
log::info!(
"vsync: choreographer clock started ({})",
if timelines_live {
"frame timelines"
} else {
"frame callback fallback"
}
);
Some(VsyncClock {
shared,
join: Some(join),
})
}
pub(super) fn shared(&self) -> &Arc<VsyncShared> {
&self.shared
}
}
impl Drop for VsyncClock {
fn drop(&mut self) {
self.shared.stop.store(true, Ordering::Relaxed);
if let Some(j) = self.join.take() {
let _ = j.join();
}
}
}
@@ -406,3 +406,66 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
data,
});
}
/// `NativeBridge.nativeSendPadTouch(handle, pad, finger, active, x, y)` — one touchpad contact
/// from a client-captured controller (the Sony USB capture), forwarded on the rich-input plane
/// (`RichInput::Touchpad`, 0xCC). `finger`: contact slot 0/1; `x`/`y`: normalized 0..=65535 in
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
/// the capture diffs, the host holds per-slot state.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jint,
finger: jint,
active: jboolean,
x: jint,
y: jint,
) {
if handle == 0 {
return;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_rich_input(RichInput::Touchpad {
pad: (pad as u32 & 0xF) as u8,
finger: (finger as u32 & 0x1) as u8,
active: active != 0,
x: (x as i64).clamp(0, 65535) as u16,
y: (y as i64).clamp(0, 65535) as u16,
});
}
/// `NativeBridge.nativeSendPadMotion(handle, pad, gp, gy, gr, ax, ay, az)` — one motion sample
/// from a client-captured controller (`RichInput::Motion`, 0xCC): gyro pitch/yaw/roll + accel,
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
/// controller's report rate.
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jint,
gyro_pitch: jint,
gyro_yaw: jint,
gyro_roll: jint,
accel_x: jint,
accel_y: jint,
accel_z: jint,
) {
if handle == 0 {
return;
}
let c = |v: jint| (v as i64).clamp(i64::from(i16::MIN), i64::from(i16::MAX)) as i16;
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_rich_input(RichInput::Motion {
pad: (pad as u32 & 0xF) as u8,
gyro: [c(gyro_pitch), c(gyro_yaw), c(gyro_roll)],
accel: [c(accel_x), c(accel_y), c(accel_z)],
});
}
+38 -13
View File
@@ -10,11 +10,13 @@ use jni::JNIEnv;
use super::{jni_guard, SessionHandle};
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature)`
/// — wrap the SurfaceView's `Surface` as an `ANativeWindow` and start the decode thread rendering
/// onto it. `decoderName` is the codec Kotlin ranked from `MediaCodecList` (`""` = let the platform
/// resolve the default for the MIME); `lowLatencyMode` is the user's master toggle;
/// `lowLatencyFeature` is whether that decoder advertised `FEATURE_LowLatency` (HUD label only).
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature,
/// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow`
/// and start the decode thread rendering onto it. `decoderName` is the codec Kotlin ranked from
/// `MediaCodecList` (`""` = let the platform resolve the default for the MIME); `lowLatencyMode`
/// is the user's master toggle; `lowLatencyFeature` is whether that decoder advertised
/// `FEATURE_LowLatency` (HUD label only); `presentPriority`/`smoothBuffer` are the timeline
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
/// No-op if already started.
#[cfg(target_os = "android")]
#[no_mangle]
@@ -27,6 +29,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
low_latency_mode: jboolean,
ll_feature: jboolean,
is_tv: jboolean,
present_priority: jni::sys::jint,
smooth_buffer: jni::sys::jint,
panel_fps: jni::sys::jint,
) {
use super::VideoThread;
use std::sync::atomic::AtomicBool;
@@ -70,6 +75,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
ll_feature: ll_feature != 0,
low_latency_mode: low_latency_mode != 0,
is_tv: is_tv != 0,
present_priority,
smooth_buffer,
panel_hz: panel_fps,
};
let join = std::thread::Builder::new()
.name("pf-decode".into())
@@ -173,7 +181,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
/// e2eDispP50Ms, e2eDispP95Ms]`
/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive]`
/// (the flags are 1.0/0.0; indexes 021 match the previous 22-double layout — 013 the original
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
@@ -186,7 +194,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
/// OnFrameRendered render timestamps — when `dispValid` is 1.0 the HUD headline becomes the
/// directly-measured capture→displayed pair at 24/25 with `display` = decoded→displayed p50 at 23
/// closing the equation, and when 0.0 — no render callback landed this window — it falls back to
/// the capture→decoded headline at 2/3), or `null` when no decode thread is running.
/// the capture→decoded headline at 2/3; 2629 are the timeline presenter's split of the `display`
/// term — `pace` = decoded→release (store + glass budget) p50 at 26, `latch` =
/// release→displayed (SurfaceFlinger) p50 at 27, the window's on-glass confirm count at 28
/// (`presents` vs `fps` is the presenter-health pair), and 29 = 1.0 while the timeline presenter
/// is active this session), or `null` when no decode thread is running.
/// Poll ~1 Hz from the UI; each call
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
/// the host build too (Kotlin only ever calls it on device).
@@ -210,7 +222,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
let mode = h.client.mode();
let color = h.client.color;
let buf: [f64; 26] = [
let buf: [f64; 30] = [
snap.fps,
snap.mbps,
snap.e2e_p50_ms,
@@ -251,6 +263,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
snap.display_p50_ms,
snap.e2e_disp_p50_ms,
snap.e2e_disp_p95_ms,
// Timeline-presenter split of the `display` term (pace = decoded→release, latch =
// release→displayed), the window's on-glass confirm count, and whether the presenter
// is active at all (0.0 = legacy release-immediately path — split reads 0 too).
snap.pace_p50_ms,
snap.latch_p50_ms,
snap.presents as f64,
if h.stats.presenter_active() { 1.0 } else { 0.0 },
];
let arr = match env.new_double_array(buf.len() as jsize) {
Ok(a) => a,
@@ -264,10 +283,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
}
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
/// `[width, height]`. Resolved at the handshake (Welcome), so it is known before a single frame
/// arrives: the UI sizes the video surface to the STREAM's aspect rather than stretching it to the
/// panel's. `null` on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links
/// on the host build too. Cheap; safe on the UI thread.
/// `[width, height, refreshHz]`. Resolved at the handshake (Welcome), so it is known before a
/// single frame arrives: the UI sizes the video surface to the STREAM's aspect rather than
/// stretching it to the panel's, and pins the panel's display mode to the stream refresh. The
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
/// build too. Cheap; safe on the UI thread.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
env: JNIEnv,
@@ -281,7 +302,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mode = h.client.mode();
let buf: [i32; 2] = [mode.width as i32, mode.height as i32];
let buf: [i32; 3] = [
mode.width as i32,
mode.height as i32,
mode.refresh_hz as i32,
];
let arr = match env.new_int_array(buf.len() as jsize) {
Ok(a) => a,
Err(_) => return std::ptr::null_mut(),
+78 -5
View File
@@ -28,6 +28,9 @@ pub struct VideoStats {
/// they (and the caller's latency computation — see `enabled`) early-out on this flag alone.
/// Off until Kotlin shows the HUD.
enabled: AtomicBool,
/// Whether the timeline presenter is active this session (async loop, not sysprop-disabled) —
/// stats index 29, so the HUD can label the display split it is (or isn't) getting.
presenter_active: AtomicBool,
/// The resolved decoder identity for the HUD: the codec's actual `AMediaCodec` name (e.g.
/// `c2.qti.avc.decoder`) and whether it advertised `FEATURE_LowLatency`. Set once when the
/// decode thread creates the codec (`set_decoder`), read one-shot by `nativeVideoDecoderLabel`.
@@ -67,6 +70,16 @@ struct Inner {
/// `end-to-end` = capture→displayed samples, µs (skew-corrected) — the spec's headline,
/// measured directly (not summed from stages). Empty under the same fallback as `display_us`.
e2e_disp_us: Vec<u64>,
/// The `display` stage's presenter split, decoded→release (pace wait: store + glass budget),
/// µs. Empty on the legacy release-immediately path.
pace_us: Vec<u64>,
/// The other half of the split, release→displayed (SurfaceFlinger's latch + scanout), µs —
/// from the `OnFrameRendered` render timestamps. `pace + latch ≈ display` per frame.
latch_us: Vec<u64>,
/// Frames confirmed on glass this window (`OnFrameRendered` callbacks) — the `presents`-vs-
/// `fps` health pair: presents ≪ fps means the presenter is dropping/serializing; an fps
/// deficit is upstream.
presents: u64,
/// Client-side newest-wins/pacing drops this window (decoded frames released without
/// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter.
skipped: u64,
@@ -101,6 +114,13 @@ pub struct Snapshot {
/// Whether any capture→displayed sample landed this window — gates the HUD's headline endpoint
/// (`capture→displayed` vs the capture→decoded fallback) and the equation's `display` term.
pub disp_valid: bool,
/// The `display` stage's presenter split p50s (ms): `pace` = decoded→release (store + glass
/// budget), `latch` = release→displayed (SurfaceFlinger). 0.0 when no sample landed (legacy
/// path / no render callbacks).
pub pace_p50_ms: f64,
pub latch_p50_ms: f64,
/// Frames confirmed on glass this window (`OnFrameRendered` callbacks).
pub presents: u64,
/// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window
/// (old host / no samples yet), in which case the HUD keeps the combined `host+network` term.
pub host_p50_ms: f64,
@@ -132,6 +152,7 @@ impl VideoStats {
pub fn new() -> VideoStats {
VideoStats {
enabled: AtomicBool::new(false),
presenter_active: AtomicBool::new(false),
decoder: Mutex::new(None),
inner: Mutex::new(Inner {
window_start: Instant::now(),
@@ -144,6 +165,9 @@ impl VideoStats {
decode_us: Vec::with_capacity(256),
display_us: Vec::with_capacity(256),
e2e_disp_us: Vec::with_capacity(256),
pace_us: Vec::with_capacity(256),
latch_us: Vec::with_capacity(256),
presents: 0,
skipped: 0,
last_dropped_total: 0,
last_fec_total: 0,
@@ -160,6 +184,18 @@ impl VideoStats {
self.enabled.load(Ordering::Relaxed)
}
/// Record whether the timeline presenter runs this session (decode thread, once at start).
// Set only by the android-only decode thread; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn set_presenter_active(&self, on: bool) {
self.presenter_active.store(on, Ordering::Relaxed);
}
/// Whether the timeline presenter is active (stats index 29).
pub fn presenter_active(&self) -> bool {
self.presenter_active.load(Ordering::Relaxed)
}
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
/// in counters (or a window start) from before the overlay was visible. `dropped_total` /
/// `fec_total` are the connector's session-cumulative counters at this instant — they seed the
@@ -181,6 +217,9 @@ impl VideoStats {
g.decode_us.clear();
g.display_us.clear();
g.e2e_disp_us.clear();
g.pace_us.clear();
g.latch_us.clear();
g.presents = 0;
g.skipped = 0;
g.last_dropped_total = dropped_total;
g.last_fec_total = fec_total;
@@ -298,13 +337,19 @@ impl VideoStats {
}
/// Record one displayed frame (the `OnFrameRendered` render timestamp, re-based to the
/// realtime clock): its capture→displayed `end-to-end` sample and its decoded→displayed
/// `display` stage sample (either may be absent — the e2e clamp rejected an out-of-range
/// value, or the decoded stamp for this pts was already evicted/pre-HUD). Fired from the
/// codec's render-callback thread, not the decode thread — the lock makes that safe.
/// realtime clock): its capture→displayed `end-to-end` sample, its decoded→displayed
/// `display` stage sample, and the presenter split's `latch` half (release→displayed) — any
/// may be absent (the e2e clamp rejected an out-of-range value, or the release record for
/// this pts was already evicted). Fired from the codec's render-callback thread, not the
/// decode thread — the lock makes that safe.
// Driven only by the android-only decode path; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn note_displayed(&self, e2e_us: Option<u64>, display_us: Option<u64>) {
pub fn note_displayed(
&self,
e2e_us: Option<u64>,
display_us: Option<u64>,
latch_us: Option<u64>,
) {
if !self.enabled.load(Ordering::Relaxed) {
return; // HUD hidden — skip the lock (the callback already skipped the clock reads)
}
@@ -313,12 +358,32 @@ impl VideoStats {
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.presents += 1;
if let Some(l) = e2e_us {
g.e2e_disp_us.push(l);
}
if let Some(l) = display_us {
g.display_us.push(l);
}
if let Some(l) = latch_us {
g.latch_us.push(l);
}
}
/// Record one released frame's pace-wait (decoded→release: the presenter's store + glass
/// budget), µs — the `display` stage's other half. Decode-thread only.
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn note_release(&self, pace_us: u64) {
if !self.enabled.load(Ordering::Relaxed) {
return; // HUD hidden — skip the lock
}
// Poison-proof for the same reason as `note_received`.
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.pace_us.push(pace_us);
}
/// Compute the window's rates + latency percentiles, then reset for the next window.
@@ -341,6 +406,8 @@ impl VideoStats {
g.decode_us.sort_unstable();
g.display_us.sort_unstable();
g.e2e_disp_us.sort_unstable();
g.pace_us.sort_unstable();
g.latch_us.sort_unstable();
let snap = Snapshot {
fps,
mbps,
@@ -352,6 +419,9 @@ impl VideoStats {
decode_p50_ms: pctl_ms(&g.decode_us, 0.50),
display_p50_ms: pctl_ms(&g.display_us, 0.50),
disp_valid: !g.e2e_disp_us.is_empty(),
pace_p50_ms: pctl_ms(&g.pace_us, 0.50),
latch_p50_ms: pctl_ms(&g.latch_us, 0.50),
presents: g.presents,
host_p50_ms: pctl_ms(&g.host_us, 0.50),
net_p50_ms: pctl_ms(&g.net_us, 0.50),
lat_valid: !g.e2e_us.is_empty(),
@@ -371,6 +441,9 @@ impl VideoStats {
g.decode_us.clear();
g.display_us.clear();
g.e2e_disp_us.clear();
g.pace_us.clear();
g.latch_us.clear();
g.presents = 0;
g.skipped = 0;
g.last_dropped_total = dropped_total;
g.last_fec_total = fec_total;
+11 -5
View File
@@ -240,11 +240,17 @@ mod session_main {
audio_channels: settings.audio_channels,
preferred_codec: settings.preferred_codec(),
// HDR off = don't advertise 10-bit/HDR at all; the host then never upgrades.
video_caps: if settings.hdr_enabled {
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
} else {
0
},
// MULTI_SLICE is decoder truth for THIS embedder: every desktop decode stack
// (FFmpeg software, VAAPI, D3D11VA, Vulkan Video) handles AUs carrying several
// slice NALs, so the host may keep its multi-slice low-latency default (§7 LN1).
// The mobile/TV embedders must NOT copy this blindly — Amlogic MediaCodec wedges
// on multi-slice AUs (see `VIDEO_CAP_MULTI_SLICE`), so they advertise per-decoder.
video_caps: punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
| if settings.hdr_enabled {
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
} else {
0
},
// No portable Wayland/X11 display-volume query yet, so the host keeps its EDID
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
// pump) pins one manually.
+258 -51
View File
@@ -429,6 +429,11 @@ pub struct IddPushCapturer {
/// One-shot latch for [`Self::poll_display_hdr`]'s "could not pin the negotiated depth" error —
/// the poller samples every ~250 ms, and a display that refuses the flip refuses it every time.
hdr_pin_warned: bool,
/// Consecutive failed depth-pin attempts ([`Self::poll_display_hdr`]) — past
/// [`Self::HDR_PIN_EAGER`] the re-pin backs off to every [`Self::HDR_PIN_RETRY_EVERY`]th
/// descriptor sample: a display that refuses the flip was costing 4 CCD writes + 8 display-
/// config queries per second, forever, all on the session-global display-config lock.
hdr_pin_failures: u32,
/// The session negotiated full-chroma 4:4:4: while the display is SDR the BGRA slot passes
/// THROUGH (a plain copy into the out ring, no NV12 VideoConverter) so NVENC gets full-chroma
/// RGB and CSCs to 4:4:4 itself — measured on-glass: `chromaFormatIDC=3` + ARGB input yields
@@ -496,8 +501,11 @@ pub struct IddPushCapturer {
offered_at_fresh: u64,
max_hb_age_us: u64,
/// The Phase A.2 micro-probe engine (refcounted process singleton) — its window read rides
/// every stall report so the verdict matrix can name the disturbance class.
probes: Arc<probes::ProbeEngine>,
/// every stall report so the verdict matrix can name the disturbance class. `None` when
/// `PUNKTFUNK_STALL_PROBES=0` opted the box out (the engine costs standing threads); the
/// verdict matrix already treats an absent probe window as never-stalled, so reports just
/// lose the corroborating legs, never invent them.
probes: Option<Arc<probes::ProbeEngine>>,
/// The Phase A.3 DxgKrnl ETW watch; `None` when the session can't start (non-admin dev run)
/// — reports then say `etw=unavailable`.
etw: Option<Arc<dxgkrnl_etw::EtwWatch>>,
@@ -539,6 +547,13 @@ pub struct IddPushCapturer {
unsafe impl Send for IddPushCapturer {}
impl IddPushCapturer {
/// Failed depth-pin attempts before [`Self::poll_display_hdr`] backs its re-pin off
/// (≈2 s of eager retries at the descriptor poller's 4 Hz).
const HDR_PIN_EAGER: u32 = 8;
/// While backed off, re-attempt the depth pin on every this-many-th descriptor sample
/// (~4 s at 4 Hz) — self-healing without the 4 Hz CCD-write drumbeat.
const HDR_PIN_RETRY_EVERY: u64 = 16;
#[inline]
fn latest(&self) -> u64 {
// SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a
@@ -832,40 +847,63 @@ impl IddPushCapturer {
// either direction (its encoder re-inits on the depth change).
if (self.pyrowave || !self.client_10bit) && now.hdr != self.client_10bit {
let want = self.client_10bit;
// OBSERVE the flip; never assert it. This used to discard `set_advanced_color`'s `bool`
// and then write `now.hdr = self.client_10bit` — substituting the DESIRED state for the
// observed one, which broke in both directions on a display that cannot be flipped
// (the state this file already logs as "Downgrade point D" at open):
// - want HDR, display stays SDR: the fabricated `true` differed from `current`, so two
// samples drove `recreate_ring(true, …)` and rebuilt the ring FP16 while the driver
// composed 8-bit BGRA. Every publish was then dropped by the driver's format guard,
// `recovering_since` expired, and `try_consume` bailed — a permanent 3-second
// reconnect loop.
// - want SDR, display stays HDR: the fabricated `false` MATCHED `current`, so no
// recreate ever fired and the ring stayed BGRA against an FP16 composition. Same
// dropped-publish outcome, silently.
// Reading back immediately can catch a flip that has not settled yet; that costs one
// debounce cycle (the poller re-samples in ~250 ms) and never a wrong ring, which is why
// this does not block the frame path on a settle poll the way `open_on` does.
let requested = pf_win_display::win_display::set_advanced_color(self.target_id, want);
let observed = pf_win_display::win_display::advanced_color_enabled(self.target_id);
// A failed READ is not evidence of a failed flip — keep the poller's sample then.
now.hdr = observed.unwrap_or(now.hdr);
if now.hdr != want && !self.hdr_pin_warned {
self.hdr_pin_warned = true;
tracing::error!(
target_id = self.target_id,
want_hdr = want,
observed_hdr = ?observed,
set_advanced_color_returned = requested,
pyrowave = self.pyrowave,
client_10bit = self.client_10bit,
"IDD push: could not pin the display to the NEGOTIATED depth — following what \
it actually composes instead (a physical display forcing HDR, or a driver that \
refuses the flip). The stream's depth will not match the negotiation; the \
encoder's caps cross-check reports the truth to the client"
);
// A display that refuses the pin refuses it on every 250 ms sample — past
// [`Self::HDR_PIN_EAGER`] consecutive failures the write+read-back re-fires only on
// every [`Self::HDR_PIN_RETRY_EVERY`]th sample (~4 s), instead of 4 CCD writes +
// 8 display-config queries per second forever, all on the session-global
// display-config lock. Skipped samples keep the poller's own advanced-color read,
// so nothing is fabricated and a display that becomes flippable is re-pinned on
// the next retry sample.
if self.hdr_pin_failures < Self::HDR_PIN_EAGER
|| self.desc_seq % Self::HDR_PIN_RETRY_EVERY == 0
{
// OBSERVE the flip; never assert it. This used to discard `set_advanced_color`'s
// `bool` and then write `now.hdr = self.client_10bit` — substituting the DESIRED
// state for the observed one, which broke in both directions on a display that
// cannot be flipped (the state this file already logs as "Downgrade point D" at
// open):
// - want HDR, display stays SDR: the fabricated `true` differed from `current`,
// so two samples drove `recreate_ring(true, …)` and rebuilt the ring FP16
// while the driver composed 8-bit BGRA. Every publish was then dropped by the
// driver's format guard, `recovering_since` expired, and `try_consume`
// bailed — a permanent 3-second reconnect loop.
// - want SDR, display stays HDR: the fabricated `false` MATCHED `current`, so no
// recreate ever fired and the ring stayed BGRA against an FP16 composition.
// Same dropped-publish outcome, silently.
// Reading back immediately can catch a flip that has not settled yet; that costs
// one debounce cycle (the poller re-samples in ~250 ms) and never a wrong ring,
// which is why this does not block the frame path on a settle poll the way
// `open_on` does.
let requested =
pf_win_display::win_display::set_advanced_color(self.target_id, want);
let observed = pf_win_display::win_display::advanced_color_enabled(self.target_id);
// A failed READ is not evidence of a failed flip — keep the poller's sample then.
now.hdr = observed.unwrap_or(now.hdr);
if now.hdr != want {
self.hdr_pin_failures = self.hdr_pin_failures.saturating_add(1);
if !self.hdr_pin_warned {
self.hdr_pin_warned = true;
tracing::error!(
target_id = self.target_id,
want_hdr = want,
observed_hdr = ?observed,
set_advanced_color_returned = requested,
pyrowave = self.pyrowave,
client_10bit = self.client_10bit,
"IDD push: could not pin the display to the NEGOTIATED depth — following what \
it actually composes instead (a physical display forcing HDR, or a driver that \
refuses the flip). The stream's depth will not match the negotiation; the \
encoder's caps cross-check reports the truth to the client"
);
}
} else {
self.hdr_pin_failures = 0;
}
}
} else {
// No mismatch (or the session follows flips): the pin is healthy — a later refusal
// starts its eager attempts fresh.
self.hdr_pin_failures = 0;
}
let current = DisplayDescriptor {
hdr: self.display_hdr,
@@ -1613,15 +1651,38 @@ impl IddPushCapturer {
// uses (the gap plus a lead-in for the disturbance that CAUSED it).
probes: now
.checked_sub(stall.gap + Duration::from_millis(300))
.map(|from| self.probes.window(from, now)),
.zip(self.probes.as_deref())
.map(|(from, p)| p.window(from, now)),
etw: self.etw.as_ref().and_then(|w| {
now.checked_sub(stall.gap + Duration::from_millis(300))
.map(|from| w.summary(from, now))
}),
// The discriminator counts span the GAP ONLY — no lead-in: presents from the
// healthy flow right before the hole would falsely acquit the content. The
// stall-ending frame's own present lands at the window edge and stays well
// under the acquit bar.
etw_counts: self.etw.as_ref().and_then(|w| {
now.checked_sub(stall.gap)
.map(|from| w.window_counts(from, now))
}),
};
self.stall_watch.report(&stall, now, &evidence);
}
if !regen {
// A degraded stretch just ended (or was cut by a ring recreate): the one line that
// makes a sustained ~2 fps phase log-visible — per-hole stall lines gate on prior
// ACTIVE flow and go quiet exactly while the stretch runs.
if let Some(r) = self.stall_watch.take_recovery() {
tracing::info!(
degraded_ms = r.degraded.as_millis() as u64,
holes = r.holes,
hole_time_ms = r.hole_time.as_millis() as u64,
worst_hole_ms = r.worst.as_millis() as u64,
"IDD-push capture recovered from a degraded stretch — fresh frames arrived \
only between stall-sized holes for its whole span; the per-stall lines \
above cover at most its first hole"
);
}
// A fresh driver frame: feed the driver-death watch and roll the stall-evidence
// trackers (a regen re-encodes OLD content — it is not evidence of driver progress).
self.last_fresh = now;
@@ -2090,6 +2151,84 @@ mod tests {
assert!(out[10].is_some(), "30 fps flow must pass the activity gate");
}
/// Feed a [`StallWatch`] frames at the given offsets and return the first degraded-stretch
/// summary it parks (checking after every frame, the way the capture loop does).
fn watch_recovery(offsets_ms: &[u64]) -> (StallWatch, Option<super::stall::Recovery>) {
let base = Instant::now();
let mut w = StallWatch::new();
let mut recovery = None;
for ms in offsets_ms {
w.note_fresh(base + Duration::from_millis(*ms));
if let Some(r) = w.take_recovery() {
recovery.get_or_insert(r);
}
}
(w, recovery)
}
#[test]
fn a_degraded_stretch_summarizes_on_recovery() {
// Active flow, then a ~2 fps phase (10 holes of 500 ms — the sustained-degradation
// shape whose holes the activity gate keeps out of the per-stall log), then recovery
// flow. One summary, carrying the whole stretch.
let mut t = Vec::new();
flow(&mut t, 0, 20); // last frame at 304 ms
t.extend((1..=10).map(|i| 304 + i * 500)); // 804..5304: ten 500 ms holes
t.extend((1..=12).map(|i| 5304 + i * 16)); // sustained flow is back
let (_, r) = watch_recovery(&t);
let r = r.expect("a multi-hole degraded stretch summarizes at recovery");
assert_eq!(r.holes, 10);
assert_eq!(r.hole_time.as_millis(), 5000);
assert_eq!(r.worst.as_millis(), 500);
assert_eq!(r.degraded.as_millis(), 5000);
}
#[test]
fn a_single_stall_never_summarizes() {
// One hole inside otherwise-healthy flow: its own stall line covers it — a
// one-hole "stretch" dissolving silently is what keeps the summary meaningful.
let mut t = Vec::new();
flow(&mut t, 0, 20);
t.push(604); // the lone 300 ms hole
t.extend((1..=12).map(|i| 604 + i * 16));
let (_, r) = watch_recovery(&t);
assert!(
r.is_none(),
"single stall must not produce a stretch summary"
);
}
#[test]
fn a_recreate_cut_stretch_still_summarizes() {
// A ring recreate resets the flow history mid-stretch; the holes accumulated BEFORE it
// are real evidence and must still surface.
let mut t = Vec::new();
flow(&mut t, 0, 20);
t.extend((1..=3).map(|i| 304 + i * 500));
let (mut w, r) = watch_recovery(&t);
assert!(r.is_none(), "stretch still open — no summary yet");
w.reset();
let r = w
.take_recovery()
.expect("reset closes and summarizes the open stretch");
assert_eq!(r.holes, 3);
assert_eq!(r.hole_time.as_millis(), 1500);
}
#[test]
fn a_content_stop_closes_the_stretch_without_folding_the_pause_in() {
// Two degraded holes, then the content plainly STOPS (a 20 s pause — quit to desktop).
// The summary covers the stretch only; the legitimate pause never joins the tally.
let mut t = Vec::new();
flow(&mut t, 0, 20);
t.extend([804, 1304, 21_304]);
let (_, r) = watch_recovery(&t);
let r = r.expect("the content stop closes the stretch");
assert_eq!(r.holes, 2);
assert_eq!(r.hole_time.as_millis(), 1000);
assert_eq!(r.degraded.as_millis(), 1000);
}
#[test]
fn metronomic_stalls_self_diagnose() {
// The field signature: ~300 ms DWM holes every 4 s inside 60 fps flow. Stalls land at the
@@ -2146,6 +2285,7 @@ mod tests {
max_heartbeat_age_ms: hb_age_ms,
probes: None,
etw: None,
etw_counts: None,
},
)
};
@@ -2167,10 +2307,12 @@ mod tests {
assert_eq!(verdict(3_000, Some(2), 1_600), StallVerdict::WorkerStalled);
}
/// [`stall::classify`]'s verdict matrix — how the micro-probe window refines (or declines to
/// refine) the driver-telemetry verdict into a named disturbance class.
/// [`stall::classify`]'s verdict matrix — how the micro-probe window and the ETW
/// present-vs-queue counts refine (or decline to refine) the driver-telemetry verdict into
/// a named disturbance class.
#[test]
fn stall_classification_matrix() {
use super::dxgkrnl_etw::EtwWindowCounts;
use super::stall::{classify, ProbeWindow, StallClass, StallVerdict};
let gap = Duration::from_millis(600);
let probes = |fence: Option<u64>, dwm: Option<u64>, flush: Option<u64>| ProbeWindow {
@@ -2179,22 +2321,29 @@ mod tests {
dwm_flush_max_us: flush,
..ProbeWindow::default()
};
let counts = |presents: u32, queue_adds: u32| EtwWindowCounts {
presents,
queue_adds,
present_history: true,
queue_history: true,
};
// The driver's own verdicts win outright — probes can't overrule "we lost the frames".
assert_eq!(
classify(
gap,
&StallVerdict::WorkerStalled,
Some(&probes(Some(500_000), None, None))
Some(&probes(Some(500_000), None, None)),
None
),
StallClass::OursWorker
);
assert_eq!(
classify(gap, &StallVerdict::DeliveryLeg, None),
classify(gap, &StallVerdict::DeliveryLeg, None, None),
StallClass::OursDelivery
);
// No probes: compose-silence alone can't name a class.
assert_eq!(
classify(gap, &StallVerdict::ComposeSilence, None),
classify(gap, &StallVerdict::ComposeSilence, None, None),
StallClass::Unattributed
);
// Fences stalled ≥ gap/2 → the adapter froze — Class 1 (even without driver telemetry).
@@ -2202,7 +2351,8 @@ mod tests {
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&probes(Some(400_000), Some(400_000), None))
Some(&probes(Some(400_000), Some(400_000), None)),
None
),
StallClass::AdapterFreeze
);
@@ -2210,7 +2360,8 @@ mod tests {
classify(
gap,
&StallVerdict::NoTelemetry,
Some(&probes(Some(400_000), None, None))
Some(&probes(Some(400_000), None, None)),
None
),
StallClass::AdapterFreeze
);
@@ -2219,7 +2370,8 @@ mod tests {
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&probes(Some(16_000), Some(500_000), None))
Some(&probes(Some(16_000), Some(500_000), None)),
None
),
StallClass::CompositorBlocked
);
@@ -2227,36 +2379,91 @@ mod tests {
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&probes(Some(16_000), Some(20_000), Some(450_000)))
Some(&probes(Some(16_000), Some(20_000), Some(450_000))),
None
),
StallClass::CompositorBlocked
);
// Everything alive + the driver swears E_PENDING → the frame-generation path.
// Everything alive + the driver swears E_PENDING, but NO working present witness:
// the silence cannot be pinned on either side — UNATTRIBUTED, never a guess. (The
// pre-2026-07-30 default of FRAME-GENERATION here mislabeled benign content pauses.)
assert_eq!(
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&probes(Some(16_000), Some(20_000), Some(30_000)))
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
None
),
StallClass::Unattributed
);
// A witness that exists but has never produced an event is NOT a working witness.
assert_eq!(
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
Some(&EtwWindowCounts {
present_history: false,
..EtwWindowCounts::default()
})
),
StallClass::Unattributed
);
// The present witness splits the silence. Presents flowing through the hole while the
// virtual display's queue starved → the OS display path dropped composed frames: the
// frame-generation leg, POSITIVELY convicted.
assert_eq!(
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
Some(&counts(54, 0))
),
StallClass::FrameGeneration
);
// (Essentially) no presents anywhere across the hole — the stall-ending frame and a
// caret blink stay under the bar → the CONTENT stopped presenting: benign for the
// display path.
assert_eq!(
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
Some(&counts(2, 1))
),
StallClass::ContentSilence
);
// The present witness does NOT overrule the driver's own verdicts or the harder
// classes — it only refines compose-silence.
assert_eq!(
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&probes(Some(400_000), Some(400_000), None)),
Some(&counts(54, 0))
),
StallClass::AdapterFreeze
);
// Healthy probes but a pre-telemetry driver: delivery-leg is equally possible — honest.
assert_eq!(
classify(
gap,
&StallVerdict::NoTelemetry,
Some(&probes(Some(16_000), Some(20_000), None))
Some(&probes(Some(16_000), Some(20_000), None)),
Some(&counts(54, 0))
),
StallClass::Unattributed
);
// An absent probe (None) never reads as "stalled" — absence is stated, not guessed.
// An absent probe (None) never reads as "stalled" — absence is stated, not guessed;
// with the present witness working, the silence still splits correctly.
assert_eq!(
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&probes(None, Some(20_000), Some(30_000)))
Some(&probes(None, Some(20_000), Some(30_000))),
Some(&counts(1, 0))
),
StallClass::FrameGeneration
StallClass::ContentSilence
);
}
}
@@ -8,14 +8,30 @@
//! - 154/155 `SetPowerState` start/stop — monitor/link power transitions (Class-1 servicing),
//! - 430 `SetTimingsFromVidPn` — modeset-class commits (Level-Two "hardware is idle" freezes),
//! - 1096/1097 `DisplayDetectControl` start/stop — present on newer builds; filtered in
//! unconditionally, harmless where absent.
//! unconditionally, harmless where absent,
//! - 1071 `BltQueueAddEntry` — one event per frame ENTERING the virtual display's kernel present
//! queue (the modern IDD path: DWM present → PresentRedirected → BltQueue → soft-vsync →
//! `BltQueueCompleteIndirectPresent` → IddCx → our driver; anatomy proven on-glass via xperf,
//! 2026-07-30 — its gaps matched our capture holes to the millisecond),
//! - 1068 `BltQueueCompleteIndirectPresent` — one event per frame COMPLETED to IddCx.
//!
//! A second provider rides the same session: `Microsoft-Windows-DXGI` (user-mode), filtered to
//! `Present`/`PresentMultiplaneOverlay` starts (ids 42/55) — one event per swapchain present,
//! stamped with the PRESENTING process id. Together they are the compose-silence discriminator
//! ([`EtwWatch::window_counts`]): DXGI presents flowing while `BltQueueAddEntry` gaps = the OS
//! display path dropped composed frames (the real display-path bug); BOTH silent = the content
//! stopped presenting (benign pause — menus/loading/game hitch). The predecessor witnesses are
//! retired for cause: DxgKrnl id 184 `Present` never fires on the modern redirected path, and
//! `DWM_TIMING_INFO.cFrame` is refresh-synthesized on Win11 (advances without composes) — both
//! proven on-glass 2026-07-30.
//!
//! Kernel-side event-id filtering (`EVENT_FILTER_TYPE_EVENT_ID`) keeps the per-vblank firehose
//! off; the session costs a few events per minute. Starting a real-time session needs admin /
//! Performance Log Users — the packaged host (service, SYSTEM) has it; a plain dev run degrades
//! to `None` and every report says `etw=unavailable` instead of guessing. The session's
//! `FlushTimer` is 1 s, so a bracket from the trailing second of a gap can land AFTER that
//! stall's report line — the next report (and the metronomic tally) still carries it.
//! off; the DDI families cost a few events per minute, and the present/queue streams a few
//! hundred per second (bounded by the ring + the 64 KB session buffers). Starting a real-time
//! session needs admin / Performance Log Users — the packaged host (service, SYSTEM) has it; a
//! plain dev run degrades to `None` and every report says `etw=unavailable` instead of guessing.
//! The session's `FlushTimer` is 1 s, so a bracket from the trailing second of a gap can land
//! AFTER that stall's report line — the next report (and the metronomic tally) still carries it.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
@@ -25,7 +41,7 @@ use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::{Duration, Instant};
use windows::core::{GUID, PWSTR};
use windows::Win32::Foundation::ERROR_SUCCESS;
use windows::Win32::Foundation::{CloseHandle, ERROR_SUCCESS};
use windows::Win32::System::Diagnostics::Etw::{
CloseTrace, ControlTraceW, EnableTraceEx2, OpenTraceW, ProcessTrace, StartTraceW,
CONTROLTRACE_HANDLE, ENABLE_TRACE_PARAMETERS, ENABLE_TRACE_PARAMETERS_VERSION_2,
@@ -35,21 +51,59 @@ use windows::Win32::System::Diagnostics::Etw::{
PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION, WNODE_FLAG_TRACED_GUID,
};
use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};
use windows::Win32::System::Threading::{
OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION,
};
/// `Microsoft-Windows-DxgKrnl` (`{802EC45A-1E99-4B83-9920-87C98277BA9D}`).
const DXGKRNL: GUID = GUID::from_u128(0x802EC45A_1E99_4B83_9920_87C98277BA9D);
/// The event ids the session filters IN (see the module docs).
const FILTER_IDS: [u16; 8] = [150, 151, 272, 154, 155, 430, 1096, 1097];
/// `Microsoft-Windows-DXGI` (`{CA11C036-0102-4A2D-A6AD-F03CFED5D3C9}`) — the user-mode swapchain
/// runtime; its `Present` events fire in the presenting process's context.
const DXGI: GUID = GUID::from_u128(0xCA11C036_0102_4A2D_A6AD_F03CFED5D3C9);
/// The DxgKrnl event ids the session filters IN (see the module docs).
const FILTER_IDS: [u16; 10] = [
150,
151,
272,
154,
155,
430,
1096,
1097,
BLT_ADD_ID,
BLT_COMPLETE_ID,
];
/// `BltQueueAddEntry` — a frame ENTERED the virtual display's kernel present queue. Event id
/// resolved from the 26200 manifest (task 1105) and count-verified against an on-glass capture.
const BLT_ADD_ID: u16 = 1071;
/// `BltQueueCompleteIndirectPresent` — a frame COMPLETED from the queue to IddCx (task 1059).
const BLT_COMPLETE_ID: u16 = 1068;
/// The DXGI event ids the session filters IN: `Present` start (42) and
/// `PresentMultiplaneOverlay` start (55) — one per app/DWM present call.
const DXGI_FILTER_IDS: [u16; 2] = [DXGI_PRESENT_ID, DXGI_PRESENT_MPO_ID];
const DXGI_PRESENT_ID: u16 = 42;
const DXGI_PRESENT_MPO_ID: u16 = 55;
/// Session name — ours, stopped-if-stale at start (a crashed host leaves the session behind;
/// real-time sessions are machine-global named objects).
const SESSION: &str = "punktfunk-stallwatch-dxgkrnl";
/// The consumer callback's destination: `(event QPC, event id)`, capped. A few events per minute
/// in the field; the cap only matters under a detection storm — exactly when the tail is the
/// least interesting part.
static RING: Mutex<VecDeque<(i64, u16)>> = Mutex::new(VecDeque::new());
/// The consumer callback's destination: `(event QPC, event id, process id)`, capped. The DDI
/// families are a few events per minute; the DXGI present stream and the two BltQueue streams
/// each run at compose rate, so the cap sizes the history: 16384 entries ≈ 1560 s at a
/// 90240 Hz desktop under a game + DWM — comfortably more than any single stall window the
/// reporter asks about (a deeper hole's counts read as floors, which is still evidence).
/// Event-id spaces of the two providers are disjoint (42/55 vs 15x/2xx/4xx/10xx/1071/1068), so
/// one ring holds both without a provider tag.
static RING: Mutex<VecDeque<(i64, u16, u32)>> = Mutex::new(VecDeque::new());
/// [`RING`]'s cap (see its doc for the sizing math).
const RING_CAP: usize = 16384;
fn qpc_now() -> i64 {
let mut v = 0i64;
@@ -75,17 +129,18 @@ unsafe extern "system" fn on_event(record: *mut EVENT_RECORD) {
return;
}
// SAFETY: `record` is the live event the consumer is delivering for the duration of this call.
let (id, ts) = unsafe {
let (id, ts, pid) = unsafe {
(
(*record).EventHeader.EventDescriptor.Id,
(*record).EventHeader.TimeStamp,
(*record).EventHeader.ProcessId,
)
};
let mut ring = RING.lock().unwrap();
if ring.len() == 2048 {
if ring.len() == RING_CAP {
ring.pop_front();
}
ring.push_back((ts, id));
ring.push_back((ts, id, pid));
}
/// A live DxgKrnl watch: the controller handle (stops the session on drop) + the consumer handle.
@@ -171,45 +226,10 @@ impl EtwWatch {
}
// Enable DxgKrnl with a kernel-side event-id filter — the whole point: the provider's
// vblank/DPC keywords never reach us.
let mut filter = Vec::with_capacity(4 + FILTER_IDS.len() * 2);
filter.extend_from_slice(&[1u8, 0u8]); // FilterIn = TRUE, Reserved
filter.extend_from_slice(&(FILTER_IDS.len() as u16).to_le_bytes());
for id in FILTER_IDS {
filter.extend_from_slice(&id.to_le_bytes());
}
let mut desc = EVENT_FILTER_DESCRIPTOR {
Ptr: filter.as_ptr() as u64,
Size: filter.len() as u32,
Type: EVENT_FILTER_TYPE_EVENT_ID,
};
let params = ENABLE_TRACE_PARAMETERS {
Version: ENABLE_TRACE_PARAMETERS_VERSION_2,
EnableProperty: 0,
ControlFlags: 0,
SourceId: GUID::zeroed(),
EnableFilterDesc: &mut desc,
FilterDescCount: 1,
};
// SAFETY: `session` is the live handle just started; `params`/`desc`/`filter` are live
// locals for this synchronous call (the kernel copies the filter).
let rc = unsafe {
EnableTraceEx2(
session,
&DXGKRNL,
EVENT_CONTROL_CODE_ENABLE_PROVIDER.0,
TRACE_LEVEL_INFORMATION as u8,
0,
0,
0,
Some(&params),
)
};
if rc != ERROR_SUCCESS {
tracing::debug!(
rc = rc.0,
"DxgKrnl ETW enable failed — stopping the session"
);
// vblank/DPC keywords never reach us. Fatal on failure (the DDI families + queue
// witnesses are this watch's reason to exist).
if !enable_provider(session, &DXGKRNL, &FILTER_IDS) {
tracing::debug!("DxgKrnl ETW enable failed — stopping the session");
let (mut buf, _) = properties_buffer();
// SAFETY: live handle + valid properties allocation, stopped exactly once on this path.
unsafe {
@@ -219,6 +239,14 @@ impl EtwWatch {
}
return None;
}
// The DXGI (user-mode) present witness rides the same session. Degraded-not-fatal: a
// refusal only costs the per-process present counts — `window_counts` then reports
// no present history and classification stays honest (Unattributed, never a guess).
if !enable_provider(session, &DXGI, &DXGI_FILTER_IDS) {
tracing::debug!(
"DXGI ETW enable failed — present-vs-queue discrimination unavailable this session"
);
}
let mut log = EVENT_TRACE_LOGFILEW {
LoggerName: PWSTR(name.as_ptr() as *mut _),
@@ -283,9 +311,12 @@ impl EtwWatch {
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
let events: Vec<(i64, u16)> = {
let events: Vec<(i64, u16, u32)> = {
let ring = RING.lock().unwrap();
ring.iter().filter(|(ts, _)| *ts <= to_q).copied().collect()
ring.iter()
.filter(|(ts, _, _)| *ts <= to_q)
.copied()
.collect()
};
let ms = |dq: i64| dq.max(0) * 1_000 / freq;
let mut parts = Vec::new();
@@ -298,7 +329,7 @@ impl EtwWatch {
let mut count = 0u32;
let mut max_ms = 0i64;
let mut still_open = false;
for &(ts, id) in &events {
for &(ts, id, _) in &events {
if id == start_id {
open = Some(ts);
} else if id == stop_id {
@@ -331,18 +362,184 @@ impl EtwWatch {
] {
let count = events
.iter()
.filter(|(ts, i)| *i == id && *ts >= from_q && *ts <= to_q)
.filter(|(ts, i, _)| *i == id && *ts >= from_q && *ts <= to_q)
.count();
if count > 0 {
parts.push(format!("{label}×{count}"));
}
}
// Present + queue accounting (DXGI 42/55 + BltQueueAddEntry/Complete): total presents
// inside the window plus the top presenters, NAMED — the line that splits a
// compose-silence hole into "the content stopped presenting" (no presents anywhere)
// versus "presents flowed and the display path dropped them" (presents at rate while
// the queue starves). "Present×0" is printed explicitly when the stream has history
// but the window is empty — silence is a finding, not an absence.
let mut per_pid: Vec<(u32, u32)> = Vec::new();
let mut have_present_history = false;
let (mut adds, mut completes) = (0u32, 0u32);
let mut have_queue_history = false;
for &(ts, id, pid) in &events {
match id {
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
have_present_history = true;
if ts >= from_q && ts <= to_q {
match per_pid.iter_mut().find(|(p, _)| *p == pid) {
Some((_, c)) => *c += 1,
None => per_pid.push((pid, 1)),
}
}
}
BLT_ADD_ID | BLT_COMPLETE_ID => {
have_queue_history = true;
if ts >= from_q && ts <= to_q {
if id == BLT_ADD_ID {
adds += 1;
} else {
completes += 1;
}
}
}
_ => {}
}
}
if !per_pid.is_empty() {
per_pid.sort_by_key(|&(_, c)| std::cmp::Reverse(c));
let total: u32 = per_pid.iter().map(|(_, c)| c).sum();
let top = per_pid
.iter()
.take(2)
.map(|(pid, c)| {
let name = process_name(*pid).unwrap_or_else(|| format!("pid{pid}"));
format!("{name}:{c}")
})
.collect::<Vec<_>>()
.join(",");
parts.push(format!("Present×{total}({top})"));
} else if have_present_history {
parts.push("Present×0".to_string());
}
if have_queue_history {
parts.push(format!("blt-queue add×{adds} complete×{completes}"));
}
if parts.is_empty() {
"none".to_string()
} else {
parts.join(" ")
}
}
/// The structured discriminator read for `[from, to]` (the stall classifier's evidence):
/// how many swapchain presents (DXGI 42/55, any process) and how many virtual-display
/// queue entries (`BltQueueAddEntry`) landed in the window, plus whether each stream has
/// EVER produced an event (distinguishing a true zero from a witness that is not working —
/// e.g. the DXGI enable was refused, or an OS build renumbered the BltQueue events).
pub(super) fn window_counts(&self, from: Instant, to: Instant) -> EtwWindowCounts {
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
let ring = RING.lock().unwrap();
let mut out = EtwWindowCounts::default();
for &(ts, id, _) in ring.iter() {
match id {
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
out.present_history = true;
if ts >= from_q && ts <= to_q {
out.presents += 1;
}
}
BLT_ADD_ID => {
out.queue_history = true;
if ts >= from_q && ts <= to_q {
out.queue_adds += 1;
}
}
_ => {}
}
}
out
}
}
/// [`EtwWatch::window_counts`]'s read: the compose-silence discriminator's structured evidence.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(super) struct EtwWindowCounts {
/// Swapchain presents (any process — the game AND dwm both count) inside the window.
pub(super) presents: u32,
/// `BltQueueAddEntry` events (frames entering the virtual display's kernel queue) inside it.
pub(super) queue_adds: u32,
/// The present stream has produced at least one event EVER (witness known-working).
pub(super) present_history: bool,
/// The queue stream has produced at least one event EVER (witness known-working).
pub(super) queue_history: bool,
}
/// Enable `guid` on `session` with a kernel-side event-id allowlist. `true` on success.
fn enable_provider(session: CONTROLTRACE_HANDLE, guid: &GUID, ids: &[u16]) -> bool {
let mut filter = Vec::with_capacity(4 + ids.len() * 2);
filter.extend_from_slice(&[1u8, 0u8]); // FilterIn = TRUE, Reserved
filter.extend_from_slice(&(ids.len() as u16).to_le_bytes());
for id in ids {
filter.extend_from_slice(&id.to_le_bytes());
}
let mut desc = EVENT_FILTER_DESCRIPTOR {
Ptr: filter.as_ptr() as u64,
Size: filter.len() as u32,
Type: EVENT_FILTER_TYPE_EVENT_ID,
};
let params = ENABLE_TRACE_PARAMETERS {
Version: ENABLE_TRACE_PARAMETERS_VERSION_2,
EnableProperty: 0,
ControlFlags: 0,
SourceId: GUID::zeroed(),
EnableFilterDesc: &mut desc,
FilterDescCount: 1,
};
// SAFETY: `session` is a live handle owned by the caller; `params`/`desc`/`filter` are live
// locals for this synchronous call (the kernel copies the filter before returning).
let rc = unsafe {
EnableTraceEx2(
session,
guid,
EVENT_CONTROL_CODE_ENABLE_PROVIDER.0,
TRACE_LEVEL_INFORMATION as u8,
0,
0,
0,
Some(&params),
)
};
rc == ERROR_SUCCESS
}
/// Best-effort image base name for `pid` (Present attribution); `None` when the process is gone
/// or protected. Runs only at stall-report time — a rare, already-degraded moment.
fn process_name(pid: u32) -> Option<String> {
// SAFETY: plain FFI; a refused open returns Err (checked via `ok()?`), and the returned
// handle is closed exactly once below.
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }.ok()?;
let mut buf = [0u16; 512];
let mut len = buf.len() as u32;
// SAFETY: `process` is the live handle just opened with QUERY_LIMITED (what this API needs);
// `buf`/`len` are a valid out-buffer and its capacity, `len` updated to the written UTF-16
// length (no NUL) on success.
let ok = unsafe {
QueryFullProcessImageNameW(
process,
PROCESS_NAME_WIN32,
PWSTR(buf.as_mut_ptr()),
&mut len,
)
}
.is_ok();
// SAFETY: `process` is the handle opened above, closed exactly once here.
unsafe {
let _ = CloseHandle(process);
}
if !ok {
return None;
}
let path = String::from_utf16_lossy(&buf[..len as usize]);
Some(path.rsplit(['\\', '/']).next().unwrap_or(&path).to_string())
}
/// A `Duration` in QPC ticks (saturating; diagnostic precision).
@@ -610,6 +610,7 @@ impl IddPushCapturer {
client_10bit,
display_hdr,
hdr_pin_warned: false,
hdr_pin_failures: 0,
want_444,
pyrowave,
pyro_fence: None,
@@ -635,7 +636,9 @@ impl IddPushCapturer {
stall_watch: StallWatch::new(),
offered_at_fresh: 0,
max_hb_age_us: 0,
probes: super::probes::acquire(),
probes: pf_host_config::config()
.stall_probes
.then(super::probes::acquire),
etw: super::dxgkrnl_etw::acquire(),
out_ring: Vec::new(),
out_idx: 0,
@@ -7,6 +7,10 @@
//! for the freeze's duration on EVERY engine of that adapter.
//! - **dwm-tick**: `DwmGetCompositionTimingInfo(NULL)` `cRefresh` advance — the compositor's own
//! clock. Frozen tick with live fences = DWM (or something it waits on) is blocked, not the GPU.
//! The same sample also tracks `cFrame` (the COMPOSED-frame counter) — the compose-silence
//! discriminator: a clock that ticks while `cFrame` freezes means DWM had NOTHING to compose
//! (the foreground content stopped presenting); a `cFrame` that keeps advancing through a hole
//! means DWM built frames that never reached OUR swap-chain (the OS frame-generation leg).
//! - **dwm-flush**: a watchdogged `DwmFlush` — its latency IS the composition-wait measurement.
//! - **scanline**: `D3DKMTGetScanLine` on an active output (Level-Zero reentrant — documented safe
//! against every miniport lock, so a BLOCKED call here convicts the KMD itself). Prefers a
@@ -155,6 +159,8 @@ struct Inner {
/// One per hardware adapter, labelled by LUID (hybrids run two).
fences: Vec<(String, BlockingProbe)>,
dwm_tick: Ring,
/// `cFrame` (composed-frame counter) frozen spans, sampled by the same dwm-tick thread.
dwm_frame: Ring,
dwm_flush: BlockingProbe,
scanline: BlockingProbe,
/// Whether the scanline probe currently targets a PHYSICAL head (see the module docs).
@@ -201,6 +207,7 @@ impl ProbeEngine {
.filter_map(|(_, p)| p.window_max(from, to))
.max(),
dwm_tick_frozen_us: i.dwm_tick.window_max(from, to),
dwm_frame_frozen_us: i.dwm_frame.window_max(from, to),
dwm_flush_max_us: i.dwm_flush.window_max(from, to),
scanline_max_us: if i.scanline_running.load(Ordering::Relaxed) {
i.scanline.window_max(from, to)
@@ -221,6 +228,7 @@ impl ProbeEngine {
stop: AtomicBool::new(false),
fences,
dwm_tick: Ring::new(),
dwm_frame: Ring::new(),
dwm_flush: BlockingProbe::new(),
scanline: BlockingProbe::new(),
scanline_physical: AtomicBool::new(false),
@@ -397,11 +405,17 @@ fn fence_probe_setup(
Some((context, ctx4, fence, event, (a, b)))
}
/// `cRefresh` advance sampling: each tick records how long the compositor's frame counter has been
/// unchanged. A frozen span covering a stall (with live fences) = DWM blocked, not the GPU.
/// `cRefresh` + `cFrame` advance sampling: each tick records how long the compositor's refresh
/// counter and its COMPOSED-frame counter have been unchanged. A frozen refresh span covering a
/// stall (with live fences) = DWM blocked, not the GPU. A LIVE refresh with a frozen `cFrame` =
/// DWM ticked but composed nothing — no damage anywhere, i.e. the foreground content itself
/// stopped presenting; a `cFrame` that advances through a capture hole = DWM composed frames the
/// IDD swap-chain never received (the OS frame-generation leg dropped them).
fn dwm_tick_loop(inner: &Inner) {
let mut last_refresh = 0u64;
let mut last_change = Instant::now();
let mut last_frame = 0u64;
let mut last_frame_change = Instant::now();
while !inner.stop.load(Ordering::Relaxed) {
let mut info = DWM_TIMING_INFO {
cbSize: std::mem::size_of::<DWM_TIMING_INFO>() as u32,
@@ -417,6 +431,14 @@ fn dwm_tick_loop(inner: &Inner) {
}
let frozen = now.duration_since(last_change);
inner.dwm_tick.push(now, frozen, frozen.as_micros() as u64);
if info.cFrame != last_frame {
last_frame = info.cFrame;
last_frame_change = now;
}
let frame_frozen = now.duration_since(last_frame_change);
inner
.dwm_frame
.push(now, frame_frozen, frame_frozen.as_micros() as u64);
}
std::thread::sleep(Duration::from_millis(50));
}
+175 -20
View File
@@ -16,6 +16,31 @@ pub(super) struct Stall {
pub(super) metronomic: Option<Duration>,
}
/// One degraded stretch, summarized at recovery ([`StallWatch::take_recovery`]). Per-hole stall
/// lines gate on prior ACTIVE flow, so inside a sustained ~2 fps phase only the first hole is
/// reported and the log goes quiet exactly while the user suffers — this summary is the stretch's
/// one visible line.
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Recovery {
/// First hole's start → last hole's end.
pub(super) degraded: Duration,
/// Stall-sized (≥ [`StallWatch::STALL_MIN`]) holes inside the stretch.
pub(super) holes: u32,
/// Their summed length.
pub(super) hole_time: Duration,
/// The longest single hole.
pub(super) worst: Duration,
}
/// [`StallWatch`]'s in-flight degraded stretch (see [`Recovery`]).
struct Episode {
started: Instant,
last_hole_end: Instant,
holes: u32,
hole_time: Duration,
worst: Duration,
}
/// Driver-telemetry evidence for one stall window (the v2 header tail — see
/// `pf_driver_proto::frame::SharedHeader`), sampled by the capturer between the last pre-gap
/// frame and the frame that ended the stall.
@@ -32,6 +57,11 @@ pub(super) struct StallEvidence {
/// The DxgKrnl DDI activity inside the window (Phase A.3 ETW summary); `None` when the
/// session is unavailable (non-admin dev run).
pub(super) etw: Option<String>,
/// The structured present-vs-queue counts for the window ([`EtwWatch::window_counts`]) —
/// the compose-silence discriminator: presents flowing while the queue starves = the OS
/// display path dropped composed frames; both silent = the content stopped presenting.
/// `None` when the ETW session is unavailable.
pub(super) etw_counts: Option<super::dxgkrnl_etw::EtwWindowCounts>,
}
/// The micro-probes' window read (Phase A.2, built by `probes::ProbeEngine::window`): per-leg
@@ -43,6 +73,12 @@ pub(super) struct ProbeWindow {
pub(super) fence_max_us: Option<u64>,
/// Longest span (µs) with no `DwmGetCompositionTimingInfo` `cRefresh` advance.
pub(super) dwm_tick_frozen_us: Option<u64>,
/// Longest span (µs) with no `cFrame` (composed-frame counter) advance. ADVISORY ONLY —
/// never classification evidence: on Win11 `DWM_TIMING_INFO.cFrame` is refresh-synthesized
/// and advances without real composes (proven on-glass 2026-07-30 against a kernel trace
/// where DWM verifiably presented nothing for 1.6 s while cFrame ticked). The line keeps
/// reporting it for older builds' sake; [`classify`] ignores it.
pub(super) dwm_frame_frozen_us: Option<u64>,
/// Worst watchdogged `DwmFlush` latency (µs).
pub(super) dwm_flush_max_us: Option<u64>,
/// Worst `D3DKMTGetScanLine` CALL latency (µs) — Level-Zero, so blocking convicts the KMD.
@@ -62,9 +98,11 @@ impl std::fmt::Display for ProbeWindow {
};
write!(
f,
"fence={} dwm_tick_frozen={} dwm_flush={} scanline={}({}) cpu_overshoot={}",
"fence={} dwm_tick_frozen={} dwm_frames_frozen={} dwm_flush={} scanline={}({}) \
cpu_overshoot={}",
ms(self.fence_max_us),
ms(self.dwm_tick_frozen_us),
ms(self.dwm_frame_frozen_us),
ms(self.dwm_flush_max_us),
ms(self.scanline_max_us),
if self.scanline_physical {
@@ -92,9 +130,17 @@ pub(super) enum StallClass {
/// Engines alive but DWM's own tick froze: the compositor is blocked on something (DDC/child
/// I/O vendor lock, win32k display-config queue). Class 2.
CompositorBlocked,
/// Engines alive, DWM ticking, driver drained E_PENDING — composition happened for OTHER
/// surfaces but produced no frame for OUR display: the frame-generation path
/// (IddCx/dirty-tracking/divider). Ours to chase with IddCx WPP.
/// Engines alive, DWM's clock ticking, driver drained E_PENDING, and the ETW present witness
/// saw (essentially) NO swapchain presents from ANY process across the hole: the content
/// stopped presenting — no damage, DWM correctly composed nothing (a game hitch, a loading
/// screen, a menu). Benign for the display path; the content side is where to look if the
/// user FELT it.
ContentSilence,
/// Engines alive, DWM ticking, driver drained E_PENDING — and the ETW present witness saw
/// presents FLOWING through the hole while the virtual display's kernel queue
/// (`BltQueueAddEntry`) starved: composed frames existed and the OS display path dropped
/// them before our swap-chain. The real display-path bug class — never yet observed in the
/// field; a report with this label (counts attached) is the specimen we want.
FrameGeneration,
/// Not enough evidence to name a class (pre-telemetry driver and/or probes absent).
Unattributed,
@@ -111,21 +157,39 @@ impl std::fmt::Display for StallClass {
Self::CompositorBlocked => {
"CLASS-2 compositor blocked (engines alive, DWM tick frozen — vendor lock / DDC)"
}
Self::ContentSilence => {
"CONTENT-SILENCE (no swapchain presents from any process across the hole — the content stopped presenting; not the display path)"
}
Self::FrameGeneration => {
"FRAME-GENERATION (DWM ticked, engines alive, no frame for THIS display — IddCx/dirty/divider)"
"FRAME-GENERATION (presents FLOWED while the virtual display's kernel queue starved — the OS display path dropped composed frames)"
}
Self::Unattributed => "UNATTRIBUTED (insufficient telemetry)",
})
}
}
/// The verdict matrix: fold the driver-telemetry verdict and the probe window into a class.
/// Pure — unit-tested beside the [`StallWatch`] tests. A leg is "stalled for the hole" when its
/// worst reading covers at least half the gap (the same proportional bar as [`attribute`]).
/// How many window presents acquit the content: ≥8 presents across the hole mirrors
/// [`attribute`]'s offered-frames bar and [`StallWatch::RECENT`]'s sustained-flow definition —
/// a caret blink or a stall-ending frame stays under it, a game presenting through the hole
/// clears it by an order of magnitude.
const PRESENTS_ACQUIT_CONTENT: u32 = 8;
/// The verdict matrix: fold the driver-telemetry verdict, the probe window and the ETW
/// present-vs-queue counts into a class. Pure — unit-tested beside the [`StallWatch`] tests.
/// A leg is "stalled for the hole" when its worst reading covers at least half the gap (the
/// same proportional bar as [`attribute`]).
///
/// Compose-silence is split by the ETW witnesses ONLY (`DWM_TIMING_INFO.cFrame` is
/// refresh-synthesized on Win11 and convicts nothing — see [`ProbeWindow::dwm_frame_frozen_us`]):
/// presents flowing while the hole ran = the OS display path dropped them (FRAME-GENERATION,
/// positively convicted); no presents anywhere = the content stopped (CONTENT-SILENCE). With no
/// working witness the class stays UNATTRIBUTED — the pre-2026-07-30 default of blaming the
/// frame-generation path mislabeled benign content pauses and is retired.
pub(super) fn classify(
gap: Duration,
verdict: &StallVerdict,
probes: Option<&ProbeWindow>,
etw_counts: Option<&super::dxgkrnl_etw::EtwWindowCounts>,
) -> StallClass {
match verdict {
StallVerdict::WorkerStalled => return StallClass::OursWorker,
@@ -144,10 +208,21 @@ pub(super) fn classify(
return StallClass::CompositorBlocked;
}
// Engines alive and DWM ticking: only the driver's own E_PENDING testimony can pin the
// frame-generation path — without it (pre-telemetry driver) the delivery leg is equally
// possible, so stay honest.
// silence on the present path — without it (pre-telemetry driver) the delivery leg is
// equally possible, so stay honest.
if matches!(verdict, StallVerdict::ComposeSilence) {
StallClass::FrameGeneration
match etw_counts {
Some(c) if c.present_history => {
if c.presents >= PRESENTS_ACQUIT_CONTENT {
StallClass::FrameGeneration
} else {
StallClass::ContentSilence
}
}
// No working present witness (session refused / DXGI enable failed / renumbered
// events): the silence cannot be attributed to either side.
_ => StallClass::Unattributed,
}
} else {
StallClass::Unattributed
}
@@ -228,8 +303,14 @@ pub(super) struct StallWatch {
/// whole session's beat, not just the stall that tripped the metronome.
verdicts: [u32; 4],
/// Running per-class tally ([`StallClass`] order: ours-worker, ours-delivery, adapter-freeze,
/// compositor-blocked, frame-generation, unattributed) — the verdict matrix's session summary.
classes: [u32; 6],
/// compositor-blocked, content-silence, frame-generation, unattributed) — the verdict
/// matrix's session summary.
classes: [u32; 7],
/// The degraded stretch currently being accumulated, opened by a reported stall and fed by
/// every stall-sized hole until sustained flow returns.
episode: Option<Episode>,
/// A closed episode's summary, parked for the caller ([`Self::take_recovery`]).
pending_recovery: Option<Recovery>,
}
impl StallWatch {
@@ -242,6 +323,12 @@ impl StallWatch {
/// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the
/// reported 300700 ms freezes, above encode/present jitter.
const STALL_MIN: Duration = Duration::from_millis(150);
/// A hole this long is a content STOP, not a degraded stretch — an open episode is closed
/// (and summarized) before it, so a quit-to-idle pause never folds into the tally.
const EPISODE_BREAK: Duration = Duration::from_secs(10);
/// Episodes with fewer holes than this dissolve silently — the single stall's own report
/// line already covers them.
const EPISODE_MIN_HOLES: u32 = 2;
pub(super) fn new() -> Self {
Self {
@@ -250,14 +337,39 @@ impl StallWatch {
seen: 0,
with_os_events: 0,
verdicts: [0; 4],
classes: [0; 6],
classes: [0; 7],
episode: None,
pending_recovery: None,
}
}
/// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without
/// the reset the first post-recreate frame would read as one).
/// the reset the first post-recreate frame would read as one). An open episode is closed and
/// summarized: its holes predate the recreate and are real evidence.
pub(super) fn reset(&mut self) {
self.recent.clear();
self.close_episode();
}
/// Close an open episode into [`Self::pending_recovery`] (kept only past the noise bar).
fn close_episode(&mut self) {
if let Some(ep) = self.episode.take() {
if ep.holes >= Self::EPISODE_MIN_HOLES {
self.pending_recovery = Some(Recovery {
degraded: ep.last_hole_end.duration_since(ep.started),
holes: ep.holes,
hole_time: ep.hole_time,
worst: ep.worst,
});
}
}
}
/// A closed degraded stretch's summary, if one is waiting — the caller logs it. Check after
/// every [`Self::note_fresh`]/[`Self::reset`]: closure rides frames that are NOT stalls (the
/// first sustained-flow frame after recovery).
pub(super) fn take_recovery(&mut self) -> Option<Recovery> {
self.pending_recovery.take()
}
/// Record a fresh driver frame at `now`; `Some` exactly when it ended a stall.
@@ -274,6 +386,37 @@ impl StallWatch {
self.recent.pop_front();
}
let gap = gap?;
if gap >= Self::EPISODE_BREAK {
// The content plainly STOPPED (quit to desktop, long idle) — summarize what came
// before rather than folding a legitimate pause into the degraded tally.
self.close_episode();
}
if gap >= Self::STALL_MIN {
match &mut self.episode {
// Inside a degraded stretch every stall-sized hole accumulates — the activity
// gate below keeps per-hole reports quiet here (the pre-gap window spans the
// slow frames), which is exactly why the episode summary exists.
Some(ep) => {
ep.holes += 1;
ep.hole_time += gap;
ep.worst = ep.worst.max(gap);
ep.last_hole_end = now;
}
None if was_active => {
self.episode = Some(Episode {
started: now - gap,
last_hole_end: now,
holes: 1,
hole_time: gap,
worst: gap,
});
}
None => {}
}
} else if was_active {
// Sustained flow is back ([`Self::RECENT`] tight frames) — the stretch is over.
self.close_episode();
}
if !was_active || gap < Self::STALL_MIN {
return None;
}
@@ -312,14 +455,20 @@ impl StallWatch {
StallVerdict::ComposeSilence => 2,
StallVerdict::DeliveryLeg => 3,
}] += 1;
let class = classify(stall.gap, &verdict, evidence.probes.as_ref());
let class = classify(
stall.gap,
&verdict,
evidence.probes.as_ref(),
evidence.etw_counts.as_ref(),
);
self.classes[match class {
StallClass::OursWorker => 0,
StallClass::OursDelivery => 1,
StallClass::AdapterFreeze => 2,
StallClass::CompositorBlocked => 3,
StallClass::FrameGeneration => 4,
StallClass::Unattributed => 5,
StallClass::ContentSilence => 4,
StallClass::FrameGeneration => 5,
StallClass::Unattributed => 6,
}] += 1;
// debug (not warn): a single hole also happens when content legitimately pauses;
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
@@ -331,6 +480,11 @@ impl StallWatch {
class = %class,
probes = evidence.probes.as_ref().map(tracing::field::display),
etw = evidence.etw.as_deref().unwrap_or("unavailable"),
// The discriminator's numeric read (also embedded in `etw` as prose): swapchain
// presents from ANY process vs frames entering the virtual display's kernel queue,
// inside the gap window. presents≥bar with adds≈0 = FRAME-GENERATION conviction.
etw_presents = evidence.etw_counts.map(|c| c.presents),
etw_queue_adds = evidence.etw_counts.map(|c| c.queue_adds),
offered_during_gap = evidence.offered_delta,
max_heartbeat_age_ms = evidence.max_heartbeat_age_ms,
"IDD-push capture stall — the desktop was composing at speed, then the ring \
@@ -351,13 +505,14 @@ impl StallWatch {
);
let class_tally = format!(
"ours-worker {}, ours-delivery {}, adapter-freeze {}, compositor-blocked {}, \
frame-generation {}, unattributed {}",
content-silence {}, frame-generation {}, unattributed {}",
self.classes[0],
self.classes[1],
self.classes[2],
self.classes[3],
self.classes[4],
self.classes[5]
self.classes[5],
self.classes[6]
);
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
// cascade is OS-visible; otherwise the disturbance never surfaces above the
+133 -12
View File
@@ -477,6 +477,52 @@ pub mod edid {
};
cv.min(255) as u8
}
/// Fixed reduced-blanking geometry for [`dtd`] (CVT-RBv2-shaped): 80 px of horizontal and 45
/// lines of vertical blanking, front-porch/sync splits within them. A virtual display has no
/// real scan-out, so the blanking only has to be self-consistent — the pixel clock is derived
/// from these same totals.
const DTD_H_BLANK: u32 = 80;
const DTD_V_BLANK: u32 = 45;
const DTD_H_SYNC_OFFSET: u32 = 8;
const DTD_H_SYNC_WIDTH: u32 = 32;
const DTD_V_SYNC_OFFSET: u32 = 3;
const DTD_V_SYNC_WIDTH: u32 = 5;
/// An 18-byte EDID detailed timing descriptor for `width`×`height`@`refresh_hz` with the fixed
/// reduced blanking above — the pf-vdisplay EDID's PREFERRED timing, built from the mode the
/// session actually asked for instead of a hard-coded 1080p60. `None` when the mode does not
/// FIT the DTD encoding — pixel clock above 655.35 MHz (the u16 10 kHz field: 4K120-class) or
/// active dimensions above the 12-bit fields — in which case the caller keeps its
/// representable fallback descriptor. Flags byte: digital separate sync, +H/+V (0x1E, matching
/// the previous hard-coded DTD).
pub fn dtd(width: u32, height: u32, refresh_hz: u32) -> Option<[u8; 18]> {
if width == 0 || height == 0 || refresh_hz == 0 || width > 4095 || height > 4095 {
return None;
}
let h_total = u64::from(width + DTD_H_BLANK);
let v_total = u64::from(height + DTD_V_BLANK);
let clock_10khz = h_total * v_total * u64::from(refresh_hz) / 10_000;
let clock_10khz = u16::try_from(clock_10khz).ok()?;
let mut d = [0u8; 18];
d[0..2].copy_from_slice(&clock_10khz.to_le_bytes());
d[2] = (width & 0xFF) as u8;
d[3] = (DTD_H_BLANK & 0xFF) as u8;
d[4] = (((width >> 8) & 0x0F) << 4) as u8 | ((DTD_H_BLANK >> 8) & 0x0F) as u8;
d[5] = (height & 0xFF) as u8;
d[6] = (DTD_V_BLANK & 0xFF) as u8;
d[7] = (((height >> 8) & 0x0F) << 4) as u8 | ((DTD_V_BLANK >> 8) & 0x0F) as u8;
d[8] = (DTD_H_SYNC_OFFSET & 0xFF) as u8;
d[9] = (DTD_H_SYNC_WIDTH & 0xFF) as u8;
d[10] = (((DTD_V_SYNC_OFFSET & 0x0F) << 4) | (DTD_V_SYNC_WIDTH & 0x0F)) as u8;
d[11] = ((((DTD_H_SYNC_OFFSET >> 8) & 0x03) << 6)
| (((DTD_H_SYNC_WIDTH >> 8) & 0x03) << 4)
| (((DTD_V_SYNC_OFFSET >> 4) & 0x03) << 2)
| ((DTD_V_SYNC_WIDTH >> 4) & 0x03)) as u8;
// Bytes 12..17 (image size mm, borders) stay 0 = undefined, matching the previous DTD.
d[17] = 0x1E;
Some(d)
}
}
/// The IDD-push frame transport: the host-created shared ring header, the publish token, and the
@@ -1094,11 +1140,35 @@ pub mod gamepad {
/// (see `pf_umdf_util::ChannelConfig::min_data_size`).
pub const PAD_SHM_LEGACY_SIZE: usize = 256;
/// Output-report ring depth. 8 slots at the host's ~4 ms poll tolerates a sustained 2 kHz
/// writer — double any real HID output rate.
/// The v2.1 output-report ring depth — the length every pre-v2.2 driver hardcodes in its
/// `% OUT_RING_LEN` slot math, kept as the drain length whenever [`PadShm::out_ring_len`]
/// reads 0. Its sizing assumption ("8 slots at a ~4 ms poll tolerates a sustained 2 kHz
/// writer — double any real HID output rate") was falsified in the field: DS5 compat-vibration
/// writers that re-send per audio quantum sustain >2 kHz for tens of seconds (field log
/// 2026-07-30), overflowing every poll.
pub const OUT_RING_LEN: u32 = 8;
pub const OUT_RING_LEN_USIZE: usize = OUT_RING_LEN as usize;
/// The v2.2 output-report ring depth — the same ring grown in place to every slot that fits
/// the one-page section ([`PAD_SHM_SIZE`] = 4096): 56 slots at the host's ~4 ms poll
/// tolerates a sustained 14 kHz writer, ~7× the worst rate observed in the field. Used only
/// when BOTH sides negotiated it (host stamped `out_ring_ver >= 2`, driver echoed the length
/// in [`PadShm::out_ring_len`]).
pub const OUT_RING_LEN_V22: u32 = 56;
pub const OUT_RING_LEN_V22_USIZE: usize = OUT_RING_LEN_V22 as usize;
/// The v2.1 [`PadShm`] size. A v2.1 driver maps this much and gates its 8-slot ring writes on
/// `mapped_len() >= 1024`; the v2.2 growth keeps bytes `0..1024` layout-identical (the ring
/// slots 8.. extend over what was `_reserved2`).
pub const PAD_SHM_V21_SIZE: usize = 1024;
/// The full v2.2 [`PadShm`] size — exactly one page. This is a hard ceiling, not a choice:
/// pagefile-backed sections round up to page granularity, which is what lets every binary
/// generation map its own idea of the size against any other generation's section. A layout
/// that grows past 4096 breaks that (an old section refuses the larger view) and must ride a
/// new negotiation, not this one.
pub const PAD_SHM_SIZE: usize = 4096;
/// One slot of the lossless output-report ring: the report bytes as the game wrote them
/// (report id first), with the exact length — unlike the legacy latest-report slot, whose
/// fixed 64-byte copy can carry a stale tail from a previous longer report.
@@ -1110,8 +1180,9 @@ pub mod gamepad {
pub data: [u8; 64],
}
/// Virtual DualSense / DualShock 4 shared section (1024 B; bytes `0..256` are the v2 legacy
/// layout verbatim — [`PAD_SHM_LEGACY_SIZE`]). The host writes the `0x01`-style HID input
/// Virtual DualSense / DualShock 4 shared section (4096 B — [`PAD_SHM_SIZE`]; bytes `0..256`
/// are the v2 legacy layout verbatim — [`PAD_SHM_LEGACY_SIZE`]; bytes `0..1024` are the v2.1
/// layout verbatim — [`PAD_SHM_V21_SIZE`]). The host writes the `0x01`-style HID input
/// report into `input`; the driver feeds it to game `READ_REPORT`s and publishes a game's
/// `0x02` output (rumble / lightbar / player-LEDs / adaptive triggers) twice: into the legacy
/// latest-report `output` slot (bumping `out_seq` — every host generation reads this), and,
@@ -1126,6 +1197,16 @@ pub mod gamepad {
/// mismatch (no pad at all), which is the wrong failure mode for a feedback-quality fix. An
/// old driver never reads the new fields; an old host never stamps `out_ring_ver`, so a new
/// driver stays on the legacy slot against it.
///
/// v2.2 ring-length negotiation (the ring grown 8 → [`OUT_RING_LEN_V22`] in place): the two
/// sides must agree on the `% len` slot math, and neither the host binary nor the driver
/// binary can assume the other's generation, so each declares and the SHORTER understanding
/// wins. The host stamps `out_ring_ver = 2` at section creation ("I can drain the long
/// ring"); the driver picks its length from that stamp (`>= 2` and a full-size map → 56,
/// `1` → 8, `0` → no ring) and ECHOES the picked length into `out_ring_len` before every
/// `ring_head` bump. The host keys its drain off the echo alone (`0` = pre-v2.2 driver = 8),
/// and the slot-bytes → echo → head-bump store order means a drain that Acquire-observed a
/// head bump always reads the length that wrote those slots.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
pub struct PadShm {
@@ -1154,16 +1235,23 @@ pub mod gamepad {
/// the host drains it". The section starts zeroed and an old host never writes it, so `0`
/// tells a new driver to stay legacy-only. Carved from v2 reserved space (v2.1).
pub out_ring_ver: u32,
/// Driver-bumped (AFTER writing `out_ring[ring_head % OUT_RING_LEN]`) count of reports
/// ever published to the ring — the host's drain cursor compares against its own tail and
/// detects overflow by `head - tail > OUT_RING_LEN`. Same publish-then-bump store order as
/// Driver-bumped (AFTER writing `out_ring[ring_head % len]`) count of reports ever
/// published to the ring — the host's drain cursor compares against its own tail and
/// detects overflow by `head - tail > len`. Same publish-then-bump store order as
/// `out_seq` (the host's Acquire load orders the reads). Carved from v2 reserved space
/// (v2.1).
pub ring_head: u32,
pub _reserved1: [u8; 92],
/// The lossless output-report ring (v2.1) — see the struct docs and [`OutSlot`].
pub out_ring: [OutSlot; OUT_RING_LEN_USIZE],
pub _reserved2: [u8; 224],
/// The ring length the driver's slot math is USING — the driver's side of the v2.2
/// negotiation (see the struct docs), (re-)stamped before every `ring_head` bump. `0` =
/// a pre-v2.2 driver that never writes it = [`OUT_RING_LEN`]. Carved from v2.1 reserved
/// space (v2.2).
pub out_ring_len: u32,
pub _reserved1: [u8; 88],
/// The lossless output-report ring — [`OUT_RING_LEN`] slots under a v2.1 negotiation,
/// [`OUT_RING_LEN_V22`] under v2.2 (slots 8.. overlay what v2.1 called `_reserved2`,
/// which no shipped binary ever read or wrote). See the struct docs and [`OutSlot`].
pub out_ring: [OutSlot; OUT_RING_LEN_V22_USIZE],
pub _reserved2: [u8; 32],
}
// Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing
@@ -1189,7 +1277,7 @@ pub mod gamepad {
assert!(offset_of!(XusbShm, driver_heartbeat) == 36);
assert!(offset_of!(XusbShm, pad_index) == 40);
assert!(size_of::<PadShm>() == 1024);
assert!(size_of::<PadShm>() == PAD_SHM_SIZE);
assert!(offset_of!(PadShm, magic) == 0);
assert!(offset_of!(PadShm, input) == 8);
assert!(offset_of!(PadShm, out_seq) == 72);
@@ -1203,6 +1291,14 @@ pub mod gamepad {
assert!(offset_of!(PadShm, ring_head) == 160);
assert!(offset_of!(PadShm, out_ring) == PAD_SHM_LEGACY_SIZE);
assert!(size_of::<OutSlot>() == 68);
// v2.2 in-place ring growth — the echo field sits in v2.1 reserved space, the grown ring
// stays within the v2.1 slots' historical offsets (slot k at 256 + k*68), and the whole
// struct is exactly the one page that keeps cross-generation views mappable.
assert!(offset_of!(PadShm, out_ring_len) == 164);
assert!(
PAD_SHM_LEGACY_SIZE + OUT_RING_LEN_USIZE * size_of::<OutSlot>() <= PAD_SHM_V21_SIZE
);
assert!(PAD_SHM_SIZE == 4096);
assert!(size_of::<ChannelProof>() == 16);
assert!(offset_of!(ChannelProof, magic) == 0);
@@ -1404,6 +1500,31 @@ mod tests {
use super::*;
use bytemuck::Zeroable;
#[test]
fn dtd_encodes_the_session_mode() {
// 1920×1080@60 with the fixed RB blanking: totals 2000×1125 → 135.00 MHz = 13500 ×10 kHz.
let d = edid::dtd(1920, 1080, 60).expect("1080p60 fits the DTD encoding");
assert_eq!(u16::from_le_bytes([d[0], d[1]]), 13_500);
// Active dimensions round-trip through the split 8+4-bit fields.
assert_eq!(u32::from(d[2]) | (u32::from(d[4] >> 4) << 8), 1920);
assert_eq!(u32::from(d[5]) | (u32::from(d[7] >> 4) << 8), 1080);
// Blanking fields carry the fixed geometry; flags match the legacy descriptor.
assert_eq!(u32::from(d[3]) | (u32::from(d[4] & 0x0F) << 8), 80);
assert_eq!(u32::from(d[6]) | (u32::from(d[7] & 0x0F) << 8), 45);
assert_eq!(d[17], 0x1E);
}
#[test]
fn dtd_rejects_what_the_encoding_cannot_carry() {
// 4K120: (3840+80)·(2160+45)·120 ≈ 1.037 GHz — past the u16 10 kHz pixel-clock field.
assert_eq!(edid::dtd(3840, 2160, 120), None);
// 4K60 fits (≈518 MHz).
assert!(edid::dtd(3840, 2160, 60).is_some());
// Degenerate and over-wide modes are refused, not mis-encoded.
assert_eq!(edid::dtd(0, 1080, 60), None);
assert_eq!(edid::dtd(5000, 1080, 10), None);
}
#[test]
fn frame_token_roundtrips() {
for (g, s, slot) in [
+77 -4
View File
@@ -795,10 +795,17 @@ pub struct NvencCudaEncoder {
/// [`stream_ordered_requested`]). The per-frame gate additionally requires `pending` empty.
stream_ordered: bool,
/// Slice count the live session was configured with ([`resolve_slices`] — env override,
/// else the Linux direct-NVENC default of 4 since Phase 3; 1 = the preset's single slice).
/// else the Linux direct-NVENC default of 4 since Phase 3 clamped to
/// [`max_slices`](Self::max_slices); 1 = the preset's single slice).
/// Chunked poll needs ≥ 2 to have boundaries to cut at. Latched at init, consumed by
/// `build_config` (so an in-place reconfigure presents the same slicing).
slices: u32,
/// Ceiling on the per-frame slice count the session's CLIENT decoder accepts (from
/// negotiation: `VIDEO_CAP_MULTI_SLICE`, or GameStream's `videoEncoderSlicesPerFrame`).
/// 1 = single-slice only — the safe shape toward decoders that never asked (Amlogic TV
/// SoCs wedge on multi-slice AUs). Clamps the Phase-3 default; the explicit
/// `PUNKTFUNK_NVENC_SLICES` env override still wins in both directions.
max_slices: u32,
/// `NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK` from the caps probe — gates the DEFAULT-on
/// sub-frame arming (an unsupported GPU must not have `enableSubFrameWrite` forced into its
/// init params, which could fail the session open). `PUNKTFUNK_NVENC_SUBFRAME=1` overrides.
@@ -847,6 +854,7 @@ impl NvencCudaEncoder {
bit_depth: u8,
chroma: ChromaFormat,
cursor_blend: bool,
max_slices: u32,
) -> Result<Self> {
// The runtime `.so` load is the real "is NVENC possible here" gate: fail the open with a
// clear reason instead of an opaque session error on the first frame.
@@ -895,6 +903,8 @@ impl NvencCudaEncoder {
io_stream: ptr::null_mut(),
stream_ordered: false,
slices: 1,
// A zero from a misbehaving caller must not zero the resolver's default arithmetic.
max_slices: max_slices.max(1),
subframe_cap: false,
subframe_on: false,
subframe_forced: false,
@@ -1094,9 +1104,14 @@ impl NvencCudaEncoder {
// every Linux direct-NVENC session, resolved HERE (before the session opens) so the
// config author, the init params and the chunked-poll latch all agree; the caps probe
// gates the sub-frame default so a GPU without SUBFRAME_READBACK never has it forced
// into its init params. PUNKTFUNK_NVENC_SLICES=1 / PUNKTFUNK_NVENC_SUBFRAME=0 are the
// escapes.
self.slices = resolve_slices(self.codec, 4);
// into its init params. The Phase-3 default is CLAMPED to the session's negotiated
// `max_slices` — the client-decoder ceiling (`VIDEO_CAP_MULTI_SLICE`, or GameStream's
// `videoEncoderSlicesPerFrame`): a client that never asked for multi-slice AUs gets
// single-slice frames, because TV-SoC decoders (Amlogic — Chromecast with Google TV)
// wedge the whole device on frames carrying several slice NALs (the 0.17.0 field
// regression). PUNKTFUNK_NVENC_SLICES / PUNKTFUNK_NVENC_SUBFRAME stay the explicit
// operator overrides in both directions.
self.slices = resolve_slices(self.codec, 4.min(self.max_slices));
self.subframe_on = resolve_subframe(self.subframe_cap);
self.subframe_forced = subframe_env_forced();
tracing::info!(
@@ -1105,6 +1120,8 @@ impl NvencCudaEncoder {
yuv444 = self.yuv444_supported,
subframe_readback = subframe != 0,
dynamic_slice = dyn_slice != 0,
slices = self.slices,
max_slices = self.max_slices,
max = %format!("{wmax}x{hmax}"),
"NVENC (Linux direct) capabilities probed"
);
@@ -2559,6 +2576,7 @@ mod tests {
8,
ChromaFormat::Yuv420,
false,
4,
)
.expect("open NVENC CUDA session");
@@ -2657,6 +2675,7 @@ mod tests {
10,
ChromaFormat::Yuv420,
false,
4,
)
.expect("open NVENC CUDA session");
@@ -2737,6 +2756,7 @@ mod tests {
10,
ChromaFormat::Yuv420,
true, // cursor_blend: bring up the Vulkan slot ring + the 10-bit blend
4,
)
.expect("open NVENC CUDA session");
let cursor = |serial: u64, x: i32, y: i32| pf_frame::CursorOverlay {
@@ -2802,6 +2822,7 @@ mod tests {
8,
ChromaFormat::Yuv444,
false,
4,
)
.expect("open NVENC CUDA 4:4:4 session");
@@ -2848,6 +2869,7 @@ mod tests {
8,
ChromaFormat::Yuv420,
false,
4,
)
.expect("open NVENC CUDA session");
@@ -2906,6 +2928,7 @@ mod tests {
8,
ChromaFormat::Yuv420,
false,
4,
) else {
eprintln!(
"skipping rfi_declines_impossible_ranges: NVENC unavailable (no NVIDIA driver)"
@@ -2933,6 +2956,7 @@ mod tests {
8,
ChromaFormat::Yuv420,
false,
4,
)
.expect("open NVENC CUDA encoder")
}
@@ -2968,6 +2992,7 @@ mod tests {
8,
ChromaFormat::Yuv420,
false,
4,
)
.expect("open");
for f in 0..4u32 {
@@ -3106,6 +3131,7 @@ mod tests {
8,
ChromaFormat::Yuv420,
false,
4,
)
.expect("open NVENC CUDA session");
let frame = nv12_frame(W, H, 0);
@@ -3149,6 +3175,7 @@ mod tests {
8,
ChromaFormat::Yuv420,
true, // cursor_blend: bring up the Vulkan slot ring + blend
4,
)
.expect("open NVENC CUDA session");
let cursor = |serial: u64, x: i32, y: i32| pf_frame::CursorOverlay {
@@ -3227,6 +3254,7 @@ mod tests {
8,
ChromaFormat::Yuv420,
false,
4,
)
.expect("open NVENC CUDA session");
// Steady sync frames first (stream-ordered mode).
@@ -3307,6 +3335,7 @@ mod tests {
8,
ChromaFormat::Yuv420,
false,
4,
)
.expect("open NVENC CUDA session");
@@ -3413,6 +3442,7 @@ mod tests {
8,
ChromaFormat::Yuv420,
false,
4,
)
.expect("open NVENC CUDA session");
@@ -3483,6 +3513,49 @@ mod tests {
assert!(!au.data.is_empty());
}
/// ON-HARDWARE (RTX box `.21`): a session whose CLIENT ceiling is 1 slice (`max_slices` from
/// negotiation — no cap bit / Moonlight `videoEncoderSlicesPerFrame:1`, the Chromecast field
/// regression's fix) must encode single-slice frames with chunked poll disarmed, with NO env
/// knobs involved. Run with `--test-threads=1` (env vars are process-global).
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_single_slice_client_ceiling() {
const W: u32 = 1920;
const H: u32 = 1080;
// The ceiling under test is the negotiated one, not the operator override.
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
W,
H,
60,
20_000_000,
true,
8,
ChromaFormat::Yuv420,
false,
1, // the client never advertised multi-slice tolerance
)
.expect("open NVENC CUDA session");
for i in 0..4u32 {
let frame = nv12_frame(W, H, i);
enc.submit_indexed(&frame, i).expect("submit");
assert_eq!(
enc.slices, 1,
"a 1-slice client ceiling must clamp the Phase-3 default"
);
assert!(
!enc.supports_chunked_poll(),
"single-slice sessions have no boundaries — chunked poll must stay disarmed"
);
let au = enc.poll().expect("poll").expect("one AU per sync frame");
assert!(!au.data.is_empty(), "frame {i} produced an empty AU");
}
}
/// ON-HARDWARE (RTX box `.21`): the Phase-3 default-on ESCAPES — `PUNKTFUNK_NVENC_SLICES=1`
/// must fully disarm chunked poll (and `poll_chunk` degrades
/// to exactly one self-closing whole-AU chunk (the default-path contract every non-chunked
+8 -5
View File
@@ -45,11 +45,14 @@ pub(super) fn codec_guid(codec: Codec) -> nv::GUID {
/// Resolved per-frame slice count for a session (latency plan §7 LN1, Phase 3): the
/// `PUNKTFUNK_NVENC_SLICES` env override wins (1..=32; **1 = the explicit single-slice
/// escape**, needed now that a backend can default higher), else the backend's
/// `default_slices` — 4 on Linux direct-NVENC since the Phase-3 default-on, 1 everywhere else
/// (the Windows async path is deliberately untouched). H.264/HEVC only (AV1 partitions via
/// tiles). ONE parse shared by the config author ([`apply_low_latency_config`] via
/// [`LowLatencyConfig::slices`]) and the Linux backend's chunked-poll arming, so the two can
/// never disagree about whether a session is multi-slice.
/// `default_slices` — on Linux direct-NVENC the Phase-3 default of 4 CLAMPED to the session's
/// negotiated client-decoder ceiling (`VIDEO_CAP_MULTI_SLICE` / GameStream's
/// `videoEncoderSlicesPerFrame` — a client that never asked stays single-slice: Amlogic TV
/// SoCs wedge on multi-slice AUs), 1 everywhere else (the Windows async path is deliberately
/// untouched). H.264/HEVC only (AV1 partitions via tiles). ONE parse shared by the config
/// author ([`apply_low_latency_config`] via [`LowLatencyConfig::slices`]) and the Linux
/// backend's chunked-poll arming, so the two can never disagree about whether a session is
/// multi-slice.
pub(super) fn resolve_slices(codec: Codec, default_slices: u32) -> u32 {
if !matches!(codec, Codec::H264 | Codec::H265) {
return 1;
+27 -7
View File
@@ -174,6 +174,13 @@ pub fn open_video(
// The session may hand this encoder cursor bitmaps to composite (cursor-as-metadata
// captures). Backends whose fast path can't blend (Vulkan EFC RGB-direct) key off it.
cursor_blend: bool,
// Ceiling on the per-frame slice count this session's CLIENT decoder accepts: 1 =
// single-slice only (the safe default toward decoders that never asked — Amlogic TV SoCs
// wedge on multi-slice AUs), a Moonlight `videoEncoderSlicesPerFrame` request, or 32 (= no
// client-side limit) for a `VIDEO_CAP_MULTI_SLICE` punktfunk/1 client. Backends that split
// frames (Linux direct-NVENC, §7 LN1) clamp their default against it;
// `PUNKTFUNK_NVENC_SLICES` stays the explicit operator override in both directions.
max_slices: u32,
) -> Result<Box<dyn Encoder>> {
let (inner, backend) = open_video_backend(
codec,
@@ -186,6 +193,7 @@ pub fn open_video(
bit_depth,
chroma,
cursor_blend,
max_slices,
)?;
// Record what this session encodes on (the mgmt API's "currently used GPU"): the backend label
// is reported by `open_video_backend` from the branch that ACTUALLY opened — not re-derived by
@@ -337,6 +345,7 @@ fn open_video_backend_linux(
bit_depth: u8,
chroma: ChromaFormat,
cursor_blend: bool,
max_slices: u32,
) -> Result<(Box<dyn Encoder>, &'static str)> {
// A NEGOTIATED PyroWave session (client advertised + preferred it, plan §3) routes
// straight to that backend — the PUNKTFUNK_ENCODER pref below stays a lab override.
@@ -446,6 +455,7 @@ fn open_video_backend_linux(
bit_depth,
chroma,
cursor_blend,
max_slices,
)
.map(|e| (e, "nvenc"))
};
@@ -562,8 +572,11 @@ fn open_video_backend(
bit_depth: u8,
chroma: ChromaFormat,
cursor_blend: bool,
max_slices: u32,
) -> Result<(Box<dyn Encoder>, &'static str)> {
let _ = cursor_blend; // consumed only by the Linux vulkan-encode + direct-NVENC arms below
// Consumed only by the Linux vulkan-encode + direct-NVENC arms below (max_slices: the
// direct-NVENC arm — the one backend that splits frames).
let _ = (cursor_blend, max_slices);
validate_dimensions(codec, width, height)?;
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
@@ -600,6 +613,7 @@ fn open_video_backend(
bit_depth,
chroma,
cursor_blend,
max_slices,
)
}
#[cfg(target_os = "windows")]
@@ -816,6 +830,7 @@ fn open_video_backend(
cuda,
bit_depth,
chroma,
max_slices,
);
anyhow::bail!("video encode requires Linux or Windows")
}
@@ -840,14 +855,16 @@ fn open_nvenc_probed(
bit_depth: u8,
chroma: ChromaFormat,
cursor_blend: bool,
max_slices: u32,
) -> Result<Box<dyn Encoder>> {
// Consumed by the direct-SDK arm below.
#[cfg(not(feature = "nvenc"))]
let _ = cursor_blend; // consumed by the direct-SDK arm below
// Direct-SDK NVENC (design/linux-direct-nvenc.md): the DEFAULT on NVIDIA, and only for a CUDA
// capture payload (it registers CUDADEVICEPTR inputs — a CPU/dmabuf frame can't feed it, so those
// keep the libav path; and `cuda` is false on AMD/Intel, so they stay on VAAPI). Set
// PUNKTFUNK_NVENC_DIRECT=0 to fall back to libav. It self-clamps the bitrate internally (its own
// level-ceiling binary search at session open), so it skips the probe-loop stepping below.
let _ = (cursor_blend, max_slices);
// Direct-SDK NVENC (design/linux-direct-nvenc.md): the DEFAULT on NVIDIA, and only for a CUDA
// capture payload (it registers CUDADEVICEPTR inputs — a CPU/dmabuf frame can't feed it, so those
// keep the libav path; and `cuda` is false on AMD/Intel, so they stay on VAAPI). Set
// PUNKTFUNK_NVENC_DIRECT=0 to fall back to libav. It self-clamps the bitrate internally (its own
// level-ceiling binary search at session open), so it skips the probe-loop stepping below.
#[cfg(feature = "nvenc")]
if cuda && nvenc_direct_enabled() {
tracing::info!(
@@ -865,6 +882,7 @@ fn open_nvenc_probed(
bit_depth,
chroma,
cursor_blend,
max_slices,
)?) as Box<dyn Encoder>);
}
// The silent-degrade trap: a build without `--features nvenc` compiles the direct-SDK
@@ -2317,6 +2335,7 @@ mod tests {
8,
ChromaFormat::Yuv420,
false,
4,
)
.expect("software arm must open GPU-free");
assert_eq!(label, "software");
@@ -2335,6 +2354,7 @@ mod tests {
8,
ChromaFormat::Yuv420,
false,
4,
) {
// `expect_err` needs `Ok: Debug` and `Box<dyn Encoder>` isn't — match instead.
Ok(_) => panic!("software emits H.264 only; an H.265 session must be refused"),
+10
View File
@@ -120,6 +120,14 @@ pub struct HostConfig {
/// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the
/// shipped `host.env` and as a forward seam if a second backend is ever added.
pub vdisplay: Option<String>,
/// `PUNKTFUNK_STALL_PROBES` — run the Windows IDD-push capture's micro-probe engine (per-GPU
/// fence probes, DWM tick/flush watchdogs, scanline + CPU sentinels — `idd_push/probes.rs`),
/// the corroborating evidence legs on every stall report. Default ON while the
/// interval-stutter field program runs; explicit-off grammar for perf-sensitive boxes — the
/// engine costs standing threads (a blocking `DwmFlush` waiter, ~10 Hz fence copies per GPU,
/// a 5 ms-cadence CPU sentinel). Off, stall lines still carry the driver telemetry + the ETW
/// present/queue discriminator (cheap, session-filtered); only the probe legs read absent.
pub stall_probes: bool,
/// `PUNKTFUNK_GAMESCOPE_STEAM` — force the bare headless gamescope spawn into its Steam
/// integration mode (`--steam`) for EVERY launch. A Steam title auto-enables `--steam` on its
/// own regardless of this knob; it exists to force it on for non-Steam launches too. Managed
@@ -235,6 +243,8 @@ impl HostConfig {
// per-session switch; see the field doc).
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
perf: flag("PUNKTFUNK_PERF"),
// Default ON while the interval-stutter field program runs (see the field doc).
stall_probes: env_on("PUNKTFUNK_STALL_PROBES").unwrap_or(true),
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
// Trimmed + emptied-to-None: `PUNKTFUNK_CAPTURE_MONITOR=` in a host.env means "not
// set", not "match the monitor named empty string".
+65 -8
View File
@@ -102,6 +102,45 @@ pub struct UhidManager<B: PadProto> {
/// [`RUMBLE_IDLE_TIMEOUT`] against this is a residual the game abandoned — see
/// [`pump`](Self::pump).
last_active: Vec<Instant>,
/// Per-pad rate limiter for the ring-overflow WARN — see [`OverflowWarn`].
overflow_warn: Vec<OverflowWarn>,
}
/// Rate limiter for the per-poll ring-overflow WARN. A sustained >2 kHz output-report writer
/// against a pre-v2.2 8-slot driver ring overflows EVERY ~4 ms poll; unlimited, that is ~230
/// WARN lines/s — a field log's 5000-line web-console ring was 96 % this one line, which evicted
/// the whole session history it was needed to diagnose (2026-07-30). One line per
/// [`Self::PERIOD`] with a `suppressed` count keeps the signal and the log.
#[derive(Default, Clone)]
struct OverflowWarn {
/// When the last line was emitted (`None` = never — the next overflow logs immediately).
last: Option<Instant>,
/// Overflow polls swallowed since `last` — carried on the next emitted line.
suppressed: u32,
}
impl OverflowWarn {
const PERIOD: Duration = Duration::from_secs(1);
/// Record one overflow poll; emit (rate-limited) the WARN for it.
fn note(&mut self, now: Instant, backend: &'static str, index: usize) {
if self
.last
.is_none_or(|t| now.duration_since(t) >= Self::PERIOD)
{
tracing::warn!(
backend,
index,
suppressed = self.suppressed,
"output-report ring overflow — resyncing feedback state (repeats coalesced, 1 \
line/s; `suppressed` = swallowed since the previous line)"
);
self.last = Some(now);
self.suppressed = 0;
} else {
self.suppressed = self.suppressed.saturating_add(1);
}
}
}
/// How long a latched, non-zero rumble may sit without the game driving the RUMBLE plane before it
@@ -162,6 +201,7 @@ impl<B: PadProto> UhidManager<B> {
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
last_active: vec![Instant::now(); MAX_PADS],
overflow_warn: vec![OverflowWarn::default(); MAX_PADS],
}
}
@@ -249,14 +289,13 @@ impl<B: PadProto> UhidManager<B> {
let fb = self.backend.service(pad, i as u8);
if fb.resync {
// The driver's output-report ring overflowed — reports were dropped and the
// feedback state is unknown. Conservatively silence the pad (a game still rumbling
// re-asserts the level within a poll or two) and re-arm the rich-plane dedup so
// the next LED/trigger state re-forwards.
tracing::warn!(
backend = B::LABEL,
index = i,
"output-report ring overflow — resyncing feedback state"
);
// feedback state is unknown beyond what the drain salvaged from the legacy
// latest-report slot (delivered through `fb` like any report). Conservatively
// silence the pad FIRST (a plane the salvage didn't carry must not stay latched;
// the salvaged state re-applies right below) and re-arm the rich-plane dedup so
// the next LED/trigger state re-forwards. WARN through the per-pad rate limiter —
// a storm overflows every poll and the raw line once flooded a whole log export.
self.overflow_warn[i].note(now, B::LABEL, i);
if self.last_rumble[i] != (0, 0) {
self.last_rumble[i] = (0, 0);
rumble(i as u16, 0, 0);
@@ -646,6 +685,24 @@ mod tests {
m.heartbeat(Duration::from_secs(3600));
assert_eq!(writes(&m), after_frame + 2);
}
/// The overflow WARN limiter: a storm overflowing every ~4 ms poll must emit one line per
/// [`OverflowWarn::PERIOD`] and carry the swallowed count — the raw per-poll line once made
/// up 96 % of a field log export and evicted the session history around it.
#[test]
fn overflow_warn_coalesces_within_the_period() {
let mut w = OverflowWarn::default();
let t0 = Instant::now();
w.note(t0, "Mock", 0); // first overflow logs immediately
assert_eq!((w.last, w.suppressed), (Some(t0), 0));
for _ in 0..250 {
w.note(t0 + Duration::from_millis(4), "Mock", 0);
}
assert_eq!((w.last, w.suppressed), (Some(t0), 250), "storm coalesced");
let t1 = t0 + OverflowWarn::PERIOD;
w.note(t1, "Mock", 0); // period elapsed — logs (with suppressed=250) and re-arms
assert_eq!((w.last, w.suppressed), (Some(t1), 0));
}
/// A ring-overflow resync must silence a latched rumble once and re-arm the rich-plane dedup,
/// so the game's next asserted state re-forwards even when it equals the pre-overflow state.
#[test]
@@ -33,8 +33,9 @@ use windows::Win32::Devices::DeviceAndDriverInstallation::{
CM_GET_DEVICE_INTERFACE_LIST_PRESENT, CM_LOCATE_DEVNODE_NORMAL, CR_SUCCESS,
};
use windows::Win32::Devices::HumanInterfaceDevice::{
HidD_GetFeature, HidD_GetIndexedString, HidD_GetProductString, HidD_GetSerialNumberString,
HidD_SetFeature, GUID_DEVINTERFACE_HID,
HidD_FreePreparsedData, HidD_GetFeature, HidD_GetIndexedString, HidD_GetPreparsedData,
HidD_GetProductString, HidD_GetSerialNumberString, HidD_SetFeature, HidP_GetCaps,
GUID_DEVINTERFACE_HID, HIDP_CAPS, PHIDP_PREPARSED_DATA,
};
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Storage::FileSystem::{
@@ -130,47 +131,86 @@ fn ask_ioctl(path: &str, expect_pad_index: u32) -> Result<u32> {
/// Open a HID collection and read the proof out of a FEATURE report — the pad transport.
fn ask_feature_path(path: &str, expect_pad_index: u32) -> Result<u32> {
let handle = open_device(path)?;
let proof = ask_feature(HANDLE(handle.as_raw_handle()))?;
proof
.check(expect_pad_index)
.map_err(|why| anyhow!("{why}"))
ask_feature(HANDLE(handle.as_raw_handle()), expect_pad_index)
}
/// Take a parsed answer only if it VALIDATES; otherwise remember why and let the caller keep
/// looking. Parsing is not evidence: [`ChannelProof::from_feature_report`] happily reinterprets any
/// 17 bytes, so a transport that answers with something else entirely yields a proof-shaped struct
/// full of another report's bytes.
fn accept(
p: Option<ChannelProof>,
expect: u32,
rejected: &mut Option<&'static str>,
) -> Option<u32> {
match p?.check(expect) {
Ok(pid) => Some(pid),
Err(why) => {
*rejected = Some(why);
None
}
}
}
/// The PS identities answer on the declared-but-unserved report `0x85`; the Deck answers its
/// unnumbered report after a private SET_FEATURE command. Both are tried — one driver binary serves
/// four identities and the host does not know which one this devnode became until the DATA section
/// is attached, which is precisely what we are trying to earn the right to do.
fn ask_feature(h: HANDLE) -> Result<ChannelProof> {
// Feature buffers are sized by the descriptor; the largest of these reports is 64 bytes, and
// Windows accepts a buffer at least that big.
const BUF: usize = 64;
///
/// So neither answer can be trusted on shape alone: a Deck serves its ONE unnumbered feature report
/// for *any* requested id, which means it answers the `0x85` probe with Steam attribute bytes that
/// parse into a proof and fail on magic. Returning that first answer stopped the search and refused
/// the delivery while the real proof sat one transport away.
fn ask_feature(h: HANDLE, expect_pad_index: u32) -> Result<u32> {
// `HidD_GetFeature`/`HidD_SetFeature` REJECT any buffer shorter than the collection's
// `FeatureReportByteLength` — so it must come from the descriptor, not from a constant. The PS
// identities land on 64 (63-byte reports + a report id); the Deck's ONE feature report is
// unnumbered and 64 bytes wide, which Windows reports as 65 (payload + the report-id slot it
// always reserves). Hardcoding 64 silently failed every call on a correctly-enumerated Deck.
let buf_len = feature_report_len(h).unwrap_or(64).max(64);
let mut rejected = None;
// PS identities: GET_FEATURE 0x85.
let mut buf = [0u8; BUF];
let mut buf = vec![0u8; buf_len];
buf[0] = HID_FEATURE_REPORT_CHANNEL_PROOF;
// SAFETY: `h` is the live HID interface handle; `buf` is a valid BUF-sized in/out buffer.
if unsafe { HidD_GetFeature(h, buf.as_mut_ptr().cast(), BUF as u32) } {
if let Some(p) = ChannelProof::from_feature_report(&buf) {
return Ok(p);
// SAFETY: `h` is the live HID interface handle; `buf` is a valid `buf_len`-sized in/out buffer.
if unsafe { HidD_GetFeature(h, buf.as_mut_ptr().cast(), buf_len as u32) } {
let p = ChannelProof::from_feature_report(&buf);
if let Some(pid) = accept(p, expect_pad_index, &mut rejected) {
return Ok(pid);
}
}
// Deck: SET the private command, then GET the reply. Byte 0 is the (unnumbered) report id 0.
let mut cmd = [0u8; BUF];
let mut cmd = vec![0u8; buf_len];
cmd[1..1 + DECK_PROOF_CMD.len()].copy_from_slice(&DECK_PROOF_CMD);
// SAFETY: `h` is live; `cmd` is a valid BUF-sized buffer.
let set_ok = unsafe { HidD_SetFeature(h, cmd.as_mut_ptr().cast(), BUF as u32) };
// SAFETY: `h` is live; `cmd` is a valid `buf_len`-sized buffer.
let set_ok = unsafe { HidD_SetFeature(h, cmd.as_mut_ptr().cast(), buf_len as u32) };
if set_ok {
let mut reply = [0u8; BUF];
let mut reply = vec![0u8; buf_len];
// SAFETY: as above.
if unsafe { HidD_GetFeature(h, reply.as_mut_ptr().cast(), BUF as u32) }
&& reply.starts_with(&DECK_PROOF_CMD)
{
if let Some(p) = ChannelProof::from_bytes(&reply[DECK_PROOF_CMD.len()..]) {
return Ok(p);
if unsafe { HidD_GetFeature(h, reply.as_mut_ptr().cast(), buf_len as u32) } {
// The driver answers with the payload; on an unnumbered report Windows hands it back
// one byte in, behind the report-id slot. Accept either placement rather than pinning a
// marshalling detail that differs between the two descriptors this one driver serves.
for off in [0usize, 1] {
let Some(body) = reply.get(off..) else {
continue;
};
if let Some(tail) = body.strip_prefix(&DECK_PROOF_CMD[..]) {
let p = ChannelProof::from_bytes(tail);
if let Some(pid) = accept(p, expect_pad_index, &mut rejected) {
return Ok(pid);
}
}
}
}
}
// A well-formed answer that failed validation is a different fault from no answer at all, and
// says which check refused — do not bury it under the generic "no proof here".
if let Some(why) = rejected {
bail!("{why}");
}
bail!(
"this HID collection carries no channel proof (feature 0x{:02x}: no; Deck command: {}) — \
the driver predates the proof (reinstall: punktfunk-host.exe driver install --gamepad)",
@@ -183,6 +223,22 @@ fn ask_feature(h: HANDLE) -> Result<ChannelProof> {
)
}
/// This collection's `FeatureReportByteLength` — the exact buffer size `HidD_GetFeature` /
/// `HidD_SetFeature` demand. `None` when the preparsed data can't be read (caller falls back).
fn feature_report_len(h: HANDLE) -> Option<usize> {
let mut pp = PHIDP_PREPARSED_DATA::default();
// SAFETY: `h` is the live HID interface handle; `pp` receives an owned preparsed-data handle.
if !unsafe { HidD_GetPreparsedData(h, &mut pp) } {
return None;
}
let mut caps = HIDP_CAPS::default();
// SAFETY: `pp` is the handle just obtained (freed below); `caps` is a valid out-param.
let st = unsafe { HidP_GetCaps(pp, &mut caps) };
// SAFETY: `pp` came from `HidD_GetPreparsedData` and is not used after this.
let _ = unsafe { HidD_FreePreparsedData(pp) };
(st.0 >= 0 && caps.FeatureReportByteLength > 0).then_some(caps.FeatureReportByteLength as usize)
}
/// Open a HID collection and read the proof out of a string.
fn ask_hid_path(path: &str, expect_pad_index: u32) -> Result<u32> {
let handle = open_device(path)?;
@@ -266,15 +322,21 @@ pub fn diagnose(instance_id: &str, transport: ProofTransport, expect_pad_index:
let _ = writeln!(out, " IOCTL_PF_GET_CHANNEL_PROOF FAILED: {e:#}");
}
},
ProofTransport::HidFeatureReport => match ask_feature(h) {
Ok(p) => {
let _ = writeln!(out, " feature proof -> {p:?}");
let _ = writeln!(out, " check: {:?}", p.check(expect_pad_index));
ProofTransport::HidFeatureReport => {
let _ = writeln!(
out,
" feature report length: {:?}",
feature_report_len(h)
);
match ask_feature(h, expect_pad_index) {
Ok(pid) => {
let _ = writeln!(out, " feature proof -> wudf pid {pid}");
}
Err(e) => {
let _ = writeln!(out, " feature proof FAILED: {e:#}");
}
}
Err(e) => {
let _ = writeln!(out, " feature proof FAILED: {e:#}");
}
},
}
ProofTransport::HidSerialString => {
// Report each path separately — this is the whole point of the probe.
let (indexed, serial, control) = ask_hid_both(h);
@@ -57,22 +57,27 @@ pub(super) const OFF_PAD_INDEX: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index);
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
pub(super) const DEVTYPE_DUALSENSE_EDGE: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSENSE_EDGE;
// v2.1 output-report ring (see `PadShm` in pf-driver-proto for the layout + version posture).
// v2.1/v2.2 output-report ring (see `PadShm` in pf-driver-proto for the layout + version posture
// + the ring-length negotiation).
pub(super) const OFF_OUT_RING_VER: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_ring_ver);
pub(super) const OFF_RING_HEAD: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, ring_head);
pub(super) const OFF_OUT_RING_LEN: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_ring_len);
pub(super) const OFF_OUT_RING: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_ring);
pub(super) const OUT_SLOT_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::OutSlot>();
pub(super) const OUT_RING_LEN: u32 = pf_driver_proto::gamepad::OUT_RING_LEN;
pub(super) const OUT_RING_LEN_V22: u32 = pf_driver_proto::gamepad::OUT_RING_LEN_V22;
/// Shared drain over a pad section's output plane — the lossless v2.1 report ring when the driver
/// publishes one, the legacy latest-report slot otherwise (an old driver package). One per pad;
/// owns the cursors that used to live as bare `last_out_seq` fields on each backend. The ring is
/// what guarantees a rumble-STOP report can never be coalesced away by a following LED/trigger
/// report inside one ~4 ms poll window — the confirmed unbounded stuck-rumble path
/// (`design/rumble-root-fix.md` §A).
/// Shared drain over a pad section's output plane — the lossless report ring when the driver
/// publishes one (8 slots from a v2.1 driver, [`OUT_RING_LEN_V22`] once both sides negotiated the
/// v2.2 growth — the driver's `out_ring_len` echo decides, see the PadShm docs), the legacy
/// latest-report slot otherwise (an old driver package). One per pad; owns the cursors that used
/// to live as bare `last_out_seq` fields on each backend. The ring is what guarantees a
/// rumble-STOP report can never be coalesced away by a following LED/trigger report inside one
/// ~4 ms poll window — the confirmed unbounded stuck-rumble path (`design/rumble-root-fix.md` §A).
pub(super) struct OutputDrain {
/// Ring cursor: the driver's `ring_head` value up to which we have drained.
tail: u32,
@@ -94,9 +99,11 @@ impl OutputDrain {
/// Drain every output report published since the last call, oldest → newest, invoking
/// `per_report` with each report's exact bytes. Returns `true` on ring OVERFLOW — more than
/// [`OUT_RING_LEN`] reports landed since the last poll (or the driver lapped us mid-copy): the
/// pending reports were DISCARDED as possibly torn and the caller must treat its downstream
/// feedback state as unknown (`PadFeedback::resync`).
/// the negotiated ring length landed since the last poll (or the driver lapped us mid-copy):
/// the pending window was DISCARDED as possibly torn, the legacy latest-report slot was
/// salvaged into ONE `per_report` call (the freshest coalesced state — the driver
/// dual-publishes every report there), and the caller must still treat its downstream
/// feedback state as unknown (`PadFeedback::resync`) for the planes that report didn't carry.
pub(super) fn drain(&mut self, base: *mut u8, mut per_report: impl FnMut(&[u8])) -> bool {
// SAFETY: base points at SHM_SIZE bytes; `OFF_RING_HEAD` (== 160) is 4-aligned off the
// page-aligned base. The driver bumps `ring_head` AFTER writing the slot, so an Acquire
@@ -108,18 +115,35 @@ impl OutputDrain {
if head == self.tail {
return false;
}
// The v2.2 length echo — the modulo the DRIVER's slot math used (0 = a pre-v2.2
// driver that never stamps it and hardcodes 8). Loaded after the Acquire on
// `ring_head` and re-stamped by the driver before every bump, so any observed head
// comes with the length that indexed its slots. Out-of-range values (a torn or
// hostile section) clamp to the v2.1 length: the drain then at worst mis-slots and
// discards, never reads out of bounds (slot offsets stay ≤ the v2.2 ring's end).
// SAFETY: `OFF_OUT_RING_LEN` (== 164) is 4-aligned off the page-aligned base.
let echo = unsafe {
(*(base.add(OFF_OUT_RING_LEN) as *const AtomicU32)).load(Ordering::Relaxed)
};
let ring_len = if (1..=OUT_RING_LEN_V22).contains(&echo) {
echo
} else {
OUT_RING_LEN
};
let pending = head.wrapping_sub(self.tail);
if pending <= OUT_RING_LEN {
if pending <= ring_len {
// Copy the pending slots out FIRST, then re-check the head: a writer that lapped
// past our window during the copy may have overwritten what we read, so parse only
// when the window provably stayed inside the ring.
let n = pending as usize;
let mut bufs = [([0u8; 64], 0usize); pf_driver_proto::gamepad::OUT_RING_LEN_USIZE];
let mut bufs =
[([0u8; 64], 0usize); pf_driver_proto::gamepad::OUT_RING_LEN_V22_USIZE];
for (k, buf) in bufs.iter_mut().enumerate().take(n) {
let idx = (self.tail.wrapping_add(k as u32) % OUT_RING_LEN) as usize;
let idx = (self.tail.wrapping_add(k as u32) % ring_len) as usize;
let slot = OFF_OUT_RING + idx * OUT_SLOT_SIZE;
// SAFETY: slot .. slot+OUT_SLOT_SIZE is inside the SHM_SIZE section; the len
// field is 4-aligned (`OFF_OUT_RING` == 256, `OUT_SLOT_SIZE` == 68).
// SAFETY: slot .. slot+OUT_SLOT_SIZE is inside the SHM_SIZE section (idx <
// `ring_len` ≤ OUT_RING_LEN_V22, whose last slot ends at 4064 ≤ SHM_SIZE);
// the len field is 4-aligned (`OFF_OUT_RING` == 256, `OUT_SLOT_SIZE` == 68).
let len = unsafe { std::ptr::read_unaligned(base.add(slot) as *const u32) };
buf.1 = (len as usize).min(64);
// SAFETY: the slot's data region is slot+4 .. slot+4+64, inside the section;
@@ -132,7 +156,7 @@ impl OutputDrain {
let head2 = unsafe {
(*(base.add(OFF_RING_HEAD) as *const AtomicU32)).load(Ordering::Acquire)
};
if head2.wrapping_sub(self.tail) <= OUT_RING_LEN {
if head2.wrapping_sub(self.tail) <= ring_len {
for (data, len) in bufs.iter().take(n) {
if *len > 0 {
per_report(&data[..*len]);
@@ -142,11 +166,23 @@ impl OutputDrain {
return false;
}
}
// Overflow (or lapped mid-copy): skip to the freshest head, deliver nothing, and
// report the resync — parsing possibly-torn reports is worse than a bounded silence.
// Overflow (or lapped mid-copy): skip to the freshest head and salvage the legacy
// latest-report slot — the driver dual-publishes every report there, so it holds the
// newest state the window carried. One salvaged report beats the old total silence: a
// rumble-heavy >2 kHz writer used to be force-muted for the storm's whole duration
// (field log 2026-07-30). The slot has no seqlock, so the copy can tear against a
// mid-write driver — the parser's id/flag gates drop most tears, a plausible-but-torn
// value lasts one poll at storm rates, and the caller's resync still silences every
// plane the salvaged report doesn't assert. A report that lands between the reload
// and the copy is salvaged now AND drained next poll — harmless, reports are
// valid-flag-gated state and the caller's dedup drops the repeat.
// SAFETY: as the first `ring_head` load above.
self.tail =
unsafe { (*(base.add(OFF_RING_HEAD) as *const AtomicU32)).load(Ordering::Acquire) };
let mut out = [0u8; 64];
// SAFETY: the legacy output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
unsafe { std::ptr::copy_nonoverlapping(base.add(OFF_OUTPUT), out.as_mut_ptr(), 64) };
per_report(&out);
return true;
}
// Legacy driver (never wrote the ring): the latest-report slot + seq — exactly the old
@@ -422,8 +458,11 @@ impl DsWinPad {
unsafe {
*base.add(OFF_DEVTYPE) = id.devtype;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
// Ring capability (v2.1), stamped before the magic so the driver sees it on attach.
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 1);
// Ring capability, stamped before the magic so the driver sees it on attach: `2` =
// "this host drains the v2.2 long ring" (a v2.1 driver reads it as a boolean and
// stays on its 8-slot math — the drain follows the driver's `out_ring_len` echo, so
// both generations pair correctly; see the PadShm negotiation docs).
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 2);
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, &DsState::neutral(), 0, 0);
@@ -709,14 +748,28 @@ mod drain_tests {
buf.as_mut_ptr() as *mut u8
}
/// Mimic the v2.1 driver's dual write: legacy slot + seq, then ring slot, then head.
/// Mimic the v2.1 driver's dual write: legacy slot + seq, then ring slot (8-slot math, no
/// length echo), then head.
fn publish(buf: &mut [u32], bytes: &[u8]) {
ring_publish(buf, bytes, OUT_RING_LEN, false);
}
/// Mimic the v2.2 driver's dual write: same, with the long-ring slot math and the
/// `out_ring_len` echo stamped before the head bump.
fn v22_publish(buf: &mut [u32], bytes: &[u8]) {
ring_publish(buf, bytes, OUT_RING_LEN_V22, true);
}
fn ring_publish(buf: &mut [u32], bytes: &[u8], len: u32, echo: bool) {
legacy_publish(buf, bytes);
let head = read32(buf, OFF_RING_HEAD);
let slot = OFF_OUT_RING + (head % OUT_RING_LEN) as usize * OUT_SLOT_SIZE;
let slot = OFF_OUT_RING + (head % len) as usize * OUT_SLOT_SIZE;
write32(buf, slot, bytes.len() as u32);
let b = bytes_mut(buf);
b[slot + 4..slot + 4 + bytes.len()].copy_from_slice(bytes);
if echo {
write32(buf, OFF_OUT_RING_LEN, len);
}
write32(buf, OFF_RING_HEAD, head.wrapping_add(1));
}
@@ -792,7 +845,7 @@ mod drain_tests {
}
#[test]
fn overflow_discards_and_flags_resync_then_recovers() {
fn overflow_salvages_the_latest_slot_and_flags_resync_then_recovers() {
let mut buf = section();
let mut d = OutputDrain::new();
for i in 0..12u8 {
@@ -801,13 +854,91 @@ mod drain_tests {
}
let (got, resync) = collect(&mut d, &mut buf);
assert!(resync, "an overflowed window must be reported");
assert!(got.is_empty(), "possibly-torn reports must not be parsed");
assert_eq!(
got.len(),
1,
"the possibly-torn ring window must not be parsed — only the legacy latest slot"
);
assert_eq!(
&got[0][..2],
&[0x02, 11],
"the salvage must be the freshest coalesced state, not silence"
);
publish(&mut buf, &[0x02, 99]);
let (got, resync) = collect(&mut d, &mut buf);
assert!(!resync);
assert_eq!(got, vec![vec![0x02, 99]]);
}
/// The 2026-07-30 field storm: a >2 kHz writer lands more than 8 reports per poll window. A
/// v2.2 driver's echoed long ring must absorb it losslessly — this exact burst overflowed
/// EVERY poll against the 8-slot ring.
#[test]
fn v22_ring_absorbs_a_burst_the_v21_ring_could_not() {
let mut buf = section();
let mut d = OutputDrain::new();
for i in 0..40u8 {
v22_publish(&mut buf, &[0x02, i]);
}
let (got, resync) = collect(&mut d, &mut buf);
assert!(!resync, "40 pending ≤ 56 slots — no overflow");
assert_eq!(
got.iter().map(|r| r[1]).collect::<Vec<_>>(),
(0..40).collect::<Vec<_>>()
);
}
#[test]
fn v22_ring_wraps_across_polls() {
let mut buf = section();
let mut d = OutputDrain::new();
for i in 0..50u8 {
v22_publish(&mut buf, &[0x02, i]);
}
assert_eq!(collect(&mut d, &mut buf).0.len(), 50);
for i in 50..100u8 {
// wraps past slot 56
v22_publish(&mut buf, &[0x02, i]);
}
let (got, resync) = collect(&mut d, &mut buf);
assert!(!resync);
assert_eq!(
got.iter().map(|r| r[1]).collect::<Vec<_>>(),
(50..100).collect::<Vec<_>>()
);
}
#[test]
fn v22_overflow_still_salvages_and_recovers() {
let mut buf = section();
let mut d = OutputDrain::new();
for i in 0..60u8 {
// 60 > OUT_RING_LEN_V22 pending
v22_publish(&mut buf, &[0x02, i]);
}
let (got, resync) = collect(&mut d, &mut buf);
assert!(resync);
assert_eq!(got.len(), 1);
assert_eq!(&got[0][..2], &[0x02, 59]);
v22_publish(&mut buf, &[0x02, 99]);
let (got, resync) = collect(&mut d, &mut buf);
assert!(!resync);
assert_eq!(got, vec![vec![0x02, 99]]);
}
/// An out-of-range `out_ring_len` (torn write / hostile section) must clamp to the v2.1
/// length, not drive the slot math out of the ring.
#[test]
fn garbage_length_echo_clamps_to_the_v21_length() {
let mut buf = section();
let mut d = OutputDrain::new();
publish(&mut buf, &[0x02, 1]); // 8-slot math, like the driver the clamp falls back to
write32(&mut buf, OFF_OUT_RING_LEN, 9999);
let (got, resync) = collect(&mut d, &mut buf);
assert!(!resync);
assert_eq!(got, vec![vec![0x02, 1]]);
}
/// Every hardware id the host puts on a pad devnode must be one the shipped INF actually
/// declares — otherwise PnP matches none of our models, falls through to the synthesized USB
/// ids on the same devnode, and binds Microsoft's inbox `input.inf`/`HidUsb`, which cannot
@@ -860,6 +991,85 @@ mod drain_tests {
}
}
/// The driver reads its HID identity back off the same hardware id — that mapping is what
/// decides which report descriptor and which VID/PID a pad enumerates with, and it is settled
/// at `EvtDeviceAdd`, before the sealed channel can possibly say anything (its delivery goes
/// through the HID interface that does not exist yet). So every hwid the host uses must appear
/// in `devtype_from_hwids`' table against this side's `devtype`, and the table must be ordered
/// so no token shadows a longer one — `pf_dualsense` is a prefix of `pf_dualsenseedge`, and an
/// Edge listed after it would resolve to a plain DualSense.
///
/// Until the driver read the ids, EVERY non-DualSense pad enumerated with the DualSense
/// descriptor: a Steam Deck's 64-byte frame parsed as DualSense report `0x01` pins the left
/// stick to a corner and holds d-pad UP forever.
#[test]
fn hwid_devtype_table_matches_the_driver() {
let src = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../packaging/windows/drivers/pf-gamepad/src/lib.rs"
);
let driver = std::fs::read_to_string(src).expect("read pf-gamepad lib.rs");
let table = driver
.split_once("fn devtype_from_hwids")
.expect("devtype_from_hwids not found — did the driver's identity resolution move?")
.1;
let table = table.split_once("] {").expect("table literal").0;
// `("pf_steamdeck", 3u8),` → ("pf_steamdeck", 3), in source order.
let entries: Vec<(String, u8)> = table
.lines()
.filter_map(|l| l.trim().strip_prefix('('))
.filter_map(|l| l.split_once(','))
.filter_map(|(id, dt)| {
let id = id.trim().trim_matches('"').to_ascii_lowercase();
let dt = dt
.trim()
.trim_end_matches([')', ','])
.trim_end_matches("u8");
dt.parse().ok().map(|dt| (id, dt))
})
.collect();
assert_eq!(
entries.len(),
4,
"parsed {entries:?} out of the driver's table — the shape changed and this test went \
vacuous; fix the parse rather than deleting the assert"
);
for (i, (id, _)) in entries.iter().enumerate() {
for (later, _) in &entries[i + 1..] {
assert!(
!later.starts_with(id.as_str()),
"the driver tests {id:?} before {later:?}, so a {later:?} devnode would \
resolve to {id:?}'s identity put the longer id first"
);
}
}
for (hwid, devtype) in [
(WinDsIdentity::dualsense().hwid, 0),
(
WinDsIdentity::dualsense_edge().hwid,
pf_driver_proto::gamepad::DEVTYPE_DUALSENSE_EDGE,
),
(
super::super::dualshock4_windows::DS4_HWID,
pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4,
),
(
super::super::steam_deck_windows::DECK_HWID,
pf_driver_proto::gamepad::DEVTYPE_STEAMDECK,
),
] {
let want = hwid.to_ascii_lowercase();
let got = entries.iter().find(|(id, _)| *id == want);
assert_eq!(
got.map(|(_, dt)| *dt),
Some(devtype),
"the host stamps device_type={devtype} for hardware id {hwid:?}, but the driver's \
table says {got:?} the pad would enumerate with another controller's report \
descriptor"
);
}
}
#[test]
fn legacy_driver_still_drains_the_latest_slot() {
let mut buf = section();
@@ -56,8 +56,9 @@ impl Ds4WinPad {
unsafe {
*base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
// Ring capability (v2.1), stamped before the magic so the driver sees it on attach.
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 1);
// Ring capability `2` = "this host drains the v2.2 long ring", stamped before the
// magic so the driver sees it on attach (see the DualSense open path + PadShm docs).
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 2);
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS4_INPUT_REPORT_LEN], {
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
serialize_state(&mut r, &DsState::neutral(), 0, 0);
@@ -825,7 +825,7 @@ impl DriverAttach {
Some(false) => {
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
}
None => "driver store could not be queried (pnputil failed)",
None => "driver store could not be queried (pnputil failed or still enumerating)",
};
let devnode = match &self.instance_id {
Some(id) => devnode_status_line(id),
@@ -850,28 +850,52 @@ impl DriverAttach {
}
}
/// How long [`driver_store_inventory`] lets the caller wait for the background pnputil query
/// before reporting without it — [`observe`] runs on the pad service thread, which must keep
/// draining pad slots even when the driver store is wedged.
const INVENTORY_WAIT: Duration = Duration::from_secs(2);
/// Driver-store inventory (`pnputil /enum-drivers`), lower-cased, fetched once per process — only
/// consulted on the failure path, so the one-off subprocess cost never hits a healthy session.
fn driver_store_inventory() -> &'static str {
/// consulted on the failure path, so the subprocess cost never hits a healthy session. The query
/// runs on its OWN thread: pnputil can block for tens of seconds on a busy/wedged driver store,
/// and the caller is the pad service thread. `None` = not available yet (query still running) or
/// failed; a query that outlives [`INVENTORY_WAIT`] still lands in the cache for later reports.
fn driver_store_inventory() -> Option<&'static str> {
static INV: OnceLock<String> = OnceLock::new();
INV.get_or_init(|| {
// Resolve pnputil by full System32 path — the host runs as SYSTEM and must not trust PATH /
// the CreateProcess search (which checks the launching EXE's own dir first), or a planted
// `pnputil.exe` beside the host binary would run elevated (security-review 2026-07-17).
let pnputil = std::env::var("SystemRoot")
.map(|r| format!(r"{r}\System32\pnputil.exe"))
.unwrap_or_else(|_| "pnputil.exe".to_string());
std::process::Command::new(&pnputil)
.arg("/enum-drivers")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_ascii_lowercase())
.unwrap_or_default()
})
static SPAWN: std::sync::Once = std::sync::Once::new();
SPAWN.call_once(|| {
std::thread::spawn(|| {
// Resolve pnputil by full System32 path — the host runs as SYSTEM and must not trust
// PATH / the CreateProcess search (which checks the launching EXE's own dir first), or
// a planted `pnputil.exe` beside the host binary would run elevated (security-review
// 2026-07-17).
let pnputil = std::env::var("SystemRoot")
.map(|r| format!(r"{r}\System32\pnputil.exe"))
.unwrap_or_else(|_| "pnputil.exe".to_string());
let inv = std::process::Command::new(&pnputil)
.arg("/enum-drivers")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_ascii_lowercase())
.unwrap_or_default();
let _ = INV.set(inv);
});
});
let deadline = Instant::now() + INVENTORY_WAIT;
loop {
if let Some(inv) = INV.get() {
return Some(inv);
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(50));
}
}
/// Whether the driver store holds `inf` (e.g. `pf_xusb.inf`). `None` = pnputil unavailable/failed.
/// Whether the driver store holds `inf` (e.g. `pf_xusb.inf`). `None` = pnputil unavailable,
/// failed, or still enumerating.
fn driver_store_has(inf: &str) -> Option<bool> {
let inv = driver_store_inventory();
let inv = driver_store_inventory()?;
if inv.is_empty() {
return None;
}
@@ -61,8 +61,9 @@ impl DeckWinPad {
unsafe {
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
// Ring capability (v2.1), stamped before the magic so the driver sees it on attach.
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 1);
// Ring capability `2` = "this host drains the v2.2 long ring", stamped before the
// magic so the driver sees it on attach (see the DualSense open path + PadShm docs).
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 2);
std::ptr::write_unaligned(
base.add(OFF_INPUT) as *mut [u8; STEAM_REPORT_LEN],
neutral_deck_report(),
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "pf-update"
description = "Root helper for web-console-triggered host updates: runs the distro package manager against the punktfunk packages, gates on the new binary actually running, and reports a result record. Deliberately tiny — root runs these few hundred lines, never the host's dependency tree."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[[bin]]
name = "pf-update"
path = "src/main.rs"
# The zero-parameter invariant lives in the DEPENDENCY LIST too: no HTTP client, no TLS, no
# argument parsing beyond one subcommand — the helper fetches nothing and parses no remote
# data; the package manager's own signature chain gates every payload.
[target.'cfg(target_os = "linux")'.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+327
View File
@@ -0,0 +1,327 @@
//! `pf-update` — the root helper behind web-console-triggered Linux host updates
//! (planning: `host-update-from-web-console.md` §7, plan U2.1).
//!
//! Invoked as `pf-update apply`, normally via the `punktfunk-update.service` oneshot that a
//! `punktfunk-update`-group member may start through polkit. **It takes zero
//! attacker-influenceable parameters**: no versions, no URLs, no package names from the
//! caller — the install kind comes from root-owned markers, the package list from the local
//! package database, and every payload from the distro package manager's own signed
//! repositories. Compromising the trigger yields "run the system's normal update for the
//! punktfunk packages", nothing more.
//!
//! After a successful package-manager run, the **run-the-binary gate** executes the newly
//! installed `/usr/bin/punktfunk-host --version` and requires it to exit cleanly — the
//! CI-green-on-the-wrong-program class (the 0.22.0 clobber) dies here for one binary run's
//! worth of cost. The outcome is written to `/var/lib/punktfunk/update-result.json`
//! (root-written, world-readable) for the unprivileged host to read; stdout/stderr land in
//! the unit's journal.
#[cfg(target_os = "linux")]
mod linux_main {
use serde::Serialize;
use std::path::Path;
use std::process::Command;
const MARKER: &str = "/usr/share/punktfunk/install-kind";
const SYSEXT_MARKER: &str = "/usr/lib/extension-release.d/extension-release.punktfunk";
const OSTREE_BOOTED: &str = "/run/ostree-booted";
const PACMAN_OPTIN_CONF: &str = "/etc/punktfunk/update.conf";
const RESULT_PATH: &str = "/var/lib/punktfunk/update-result.json";
const HOST_BIN: &str = "/usr/bin/punktfunk-host";
/// What the host reads back. Field meanings mirror the mgmt API's `UpdateResultInfo`
/// where they overlap; `changed=false` is the "your package source has nothing newer
/// yet" case (not an error), `staged=true` means a reboot finishes the update
/// (rpm-ostree).
#[derive(Serialize)]
struct HelperResult {
ok: bool,
kind: String,
before_version: String,
after_version: String,
changed: bool,
staged: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
finished_unix: u64,
}
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Root-owned facts → the apply strategy. Mirrors the host's ladder for the kinds a
/// root helper serves (the helper decides for ITSELF — never trusts its caller).
fn detect_kind() -> Result<&'static str, String> {
if Path::new(SYSEXT_MARKER).exists() {
return Ok("sysext");
}
let marker = std::fs::read_to_string(MARKER)
.map_err(|e| format!("no install-kind marker at {MARKER}: {e}"))?;
match marker.split_whitespace().next() {
Some("apt") => Ok("apt"),
Some("dnf") if Path::new(OSTREE_BOOTED).exists() => Ok("rpm-ostree"),
Some("dnf") => Ok("dnf"),
Some("pacman") => Ok("pacman"),
other => Err(format!(
"install-kind marker says {other:?} — no root apply leg for it"
)),
}
}
fn run(cmd: &mut Command, what: &str) -> Result<(), String> {
println!("pf-update: running {cmd:?}");
let status = cmd
.status()
.map_err(|e| format!("{what}: failed to launch: {e}"))?;
if !status.success() {
return Err(format!("{what}: exited {status}"));
}
Ok(())
}
fn run_capture(cmd: &mut Command, what: &str) -> Result<String, String> {
let out = cmd
.output()
.map_err(|e| format!("{what}: failed to launch: {e}"))?;
if !out.status.success() {
return Err(format!("{what}: exited {}", out.status));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
/// The installed punktfunk packages, from the LOCAL package database — upgrade exactly
/// what this box has (host-only installs don't grow a web console out of nowhere).
fn installed_packages(query: &mut Command, what: &str) -> Result<Vec<String>, String> {
let out = run_capture(query, what)?;
let pkgs: Vec<String> = out
.lines()
.map(str::trim)
.filter(|l| l.starts_with("punktfunk"))
.map(str::to_string)
.collect();
if pkgs.is_empty() {
return Err(format!("{what}: no installed punktfunk packages found"));
}
Ok(pkgs)
}
fn host_version() -> Result<String, String> {
run_capture(
Command::new(HOST_BIN).arg("--version"),
"punktfunk-host --version",
)
}
/// The per-kind command tables (design §5). Returns `staged` (activation needs a reboot).
fn apply_for_kind(kind: &str) -> Result<bool, String> {
match kind {
"apt" => {
// Refresh only OUR index when the documented list file exists (S5);
// otherwise a full refresh — normal admin behavior, just slower.
let ours = "/etc/apt/sources.list.d/punktfunk.list";
let mut update = Command::new("apt-get");
update.env("DEBIAN_FRONTEND", "noninteractive");
if Path::new(ours).exists() {
update.args([
"update",
"-o",
&format!("Dir::Etc::sourcelist={ours}"),
"-o",
"Dir::Etc::sourceparts=-",
]);
} else {
update.arg("update");
}
run(&mut update, "apt-get update")?;
let pkgs = installed_packages(
Command::new("dpkg-query").args(["-W", "-f", "${Package}\n", "punktfunk*"]),
"dpkg-query",
)?;
let mut install = Command::new("apt-get");
install
.env("DEBIAN_FRONTEND", "noninteractive")
.args(["install", "--only-upgrade", "-y"])
.args(&pkgs);
run(&mut install, "apt-get install --only-upgrade")?;
Ok(false)
}
"dnf" => {
let pkgs = installed_packages(
Command::new("rpm").args(["-qa", "--qf", "%{NAME}\n", "punktfunk*"]),
"rpm -qa",
)?;
let mut upgrade = Command::new("dnf");
upgrade.args(["-y", "upgrade"]).args(&pkgs);
run(&mut upgrade, "dnf upgrade")?;
Ok(false)
}
"rpm-ostree" => {
// A layered package only re-resolves when forced — the single-transaction
// uninstall+install dance (packaging/bazzite/update-punktfunk.sh). Staged;
// a reboot activates it.
let pkgs = installed_packages(
Command::new("rpm").args(["-qa", "--qf", "%{NAME}\n", "punktfunk*"]),
"rpm -qa",
)?;
run(
Command::new("rpm-ostree").args(["refresh-md", "--force"]),
"rpm-ostree refresh-md",
)?;
let mut update = Command::new("rpm-ostree");
update.arg("update");
for p in &pkgs {
update.args(["--uninstall", p, "--install", p]);
}
run(&mut update, "rpm-ostree update (re-resolve)")?;
Ok(true)
}
"sysext" => {
// The proven signed-feed updater; it refreshes the merged /usr in place.
run(
Command::new("punktfunk-sysext").arg("update"),
"punktfunk-sysext update",
)?;
Ok(false)
}
"pacman" => {
// Arch doctrine: partial upgrades break boxes, so the ONLY thing this
// helper will run is a full -Syu — and only when the operator opted into
// that explicitly (root-owned config, not the API).
let optin = std::fs::read_to_string(PACMAN_OPTIN_CONF)
.ok()
.map(|c| c.lines().any(|l| l.trim() == "PACMAN_FULL_SYSUPGRADE=1"))
.unwrap_or(false);
if !optin {
return Err(format!(
"pacman full-sysupgrade is not opted in — set PACMAN_FULL_SYSUPGRADE=1 \
in {PACMAN_OPTIN_CONF} (this runs `pacman -Syu` for the WHOLE system)"
));
}
run(
Command::new("pacman").args(["-Syu", "--noconfirm"]),
"pacman -Syu",
)?;
Ok(false)
}
other => Err(format!("no apply leg for install kind {other}")),
}
}
fn write_result(result: &HelperResult) {
let path = Path::new(RESULT_PATH);
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = path.with_extension("json.tmp");
if let Ok(bytes) = serde_json::to_vec_pretty(result) {
if std::fs::write(&tmp, &bytes).is_ok() {
let _ = std::fs::rename(&tmp, path);
}
}
}
pub fn main() {
let arg = std::env::args().nth(1).unwrap_or_default();
if arg != "apply" {
eprintln!("usage: pf-update apply (normally via punktfunk-update.service)");
std::process::exit(2);
}
// Effective root is required for every leg; refuse early with a clear message
// rather than half-running.
// SAFETY: geteuid has no preconditions.
if unsafe { libc_geteuid() } != 0 {
eprintln!("pf-update: must run as root (start punktfunk-update.service)");
std::process::exit(1);
}
let kind = match detect_kind() {
Ok(k) => k,
Err(e) => {
eprintln!("pf-update: {e}");
write_result(&HelperResult {
ok: false,
kind: "unknown".into(),
before_version: String::new(),
after_version: String::new(),
changed: false,
staged: false,
error: Some(e),
finished_unix: now_unix(),
});
std::process::exit(1);
}
};
println!("pf-update: install kind {kind}");
let before = host_version().unwrap_or_default();
let outcome = apply_for_kind(kind).and_then(|staged| {
// The run-the-binary gate: the freshly installed binary must actually run.
// Skipped for staged (rpm-ostree) — the new binary isn't in /usr until reboot.
let after = if staged {
before.clone()
} else {
host_version()
.map_err(|e| format!("run-the-binary gate: {e} — the update did NOT stick"))?
};
Ok((staged, after))
});
let result = match outcome {
Ok((staged, after)) => HelperResult {
ok: true,
kind: kind.into(),
changed: staged || after != before,
staged,
before_version: before,
after_version: after,
error: None,
finished_unix: now_unix(),
},
Err(e) => {
eprintln!("pf-update: {e}");
HelperResult {
ok: false,
kind: kind.into(),
before_version: before.clone(),
after_version: before,
changed: false,
staged: false,
error: Some(e),
finished_unix: now_unix(),
}
}
};
let ok = result.ok;
write_result(&result);
println!(
"pf-update: {} ({} -> {}, changed: {}, staged: {})",
if ok { "ok" } else { "FAILED" },
result.before_version,
result.after_version,
result.changed,
result.staged,
);
std::process::exit(if ok { 0 } else { 1 });
}
// One libc symbol, declared directly — not worth a libc dependency in a root helper.
extern "C" {
#[link_name = "geteuid"]
fn libc_geteuid() -> u32;
}
}
#[cfg(target_os = "linux")]
fn main() {
linux_main::main();
}
#[cfg(not(target_os = "linux"))]
fn main() {
eprintln!("pf-update is a Linux-only root helper");
std::process::exit(2);
}
+35 -1
View File
@@ -1757,6 +1757,14 @@ pub fn set_virtual_primary_ccd(keep_target_id: u32) -> Option<SavedConfig> {
Some(saved)
}
/// The dark-sink futility latch: `(target_id, monitor_device_path)` keys of connected external
/// displays that stayed dark THROUGH a force-EXTEND. A sink the EXTEND preset demonstrably
/// cannot light (an off/standby TV, a KVM switched away) will not light on the next teardown
/// either — without the latch [`restore_displays_ccd`]'s backstop re-warns and re-forces on
/// EVERY teardown, forever (field: the off-TV box). Process-lifetime on purpose: a host restart
/// re-probes once, in case the sink meanwhile became lightable.
static DARK_SINKS_FUTILE: std::sync::Mutex<Vec<(u32, String)>> = std::sync::Mutex::new(Vec::new());
/// Restore the topology saved by [`isolate_displays_ccd`] (teardown, before the virtual output is
/// removed), re-activating the displays we deactivated.
// pub so vdisplay::pf_vdisplay can reuse this backend-neutral CCD restore helper.
@@ -1792,15 +1800,41 @@ pub fn restore_displays_ccd(saved: &SavedConfig) {
// connected, fall back to the OS database EXTEND preset, which re-activates every connected
// display. Internal panels deliberately don't count as lit-able here — a closed clamshell
// lid must not be forced back on.
let (connected, lit) = target_inventory()
let inventory = target_inventory();
let (connected, lit) = inventory
.iter()
.filter(|t| t.external_physical)
.fold((0u32, 0u32), |(c, a), t| (c + 1, a + u32::from(t.active)));
if connected > 0 && lit == 0 {
let dark: Vec<(u32, String)> = inventory
.iter()
.filter(|t| t.external_physical && !t.active)
.map(|t| (t.target_id, t.monitor_device_path.clone()))
.collect();
// The futility latch: if this exact dark set already survived a force-EXTEND, the sink
// cannot light (off/standby TV) — re-warning and re-forcing every teardown is noise
// that reads like a new failure in every field log. The poisoned-snapshot chain the
// backstop exists for is NOT latched away: there the panel CAN light, so the first
// force succeeds and the latch stays clear.
if *DARK_SINKS_FUTILE.lock().unwrap() == dark {
tracing::debug!(
connected,
"display isolate (CCD): the connected external display(s) stayed dark through \
an earlier force-EXTEND an unlightable sink (off/standby TV), not a failed \
restore; leaving the topology be"
);
return;
}
tracing::warn!(
"display isolate (CCD): no external physical display active after the restore (rc={rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
);
force_extend_topology();
// Measure what the force achieved: a sink still dark AFTER the EXTEND preset can never
// be lit by it, so remember the set and stop re-forcing. Anything lit clears the latch.
let lit_after = target_inventory()
.iter()
.any(|t| t.external_physical && t.active);
*DARK_SINKS_FUTILE.lock().unwrap() = if lit_after { Vec::new() } else { dark };
}
}
+33
View File
@@ -4013,6 +4013,39 @@ pub unsafe extern "C" fn punktfunk_connection_report_decode_us(
})
}
/// Report this client's display-latch grid so the host can phase-lock its capture tick
/// (design/phase-locked-capture.md). `next_latch_host_ns` must already be host clock — convert
/// with the connection's clock offset (`T_host = T_client + offset`). Fire-and-forget; call ~1 Hz
/// from a vsync-aware presenter. No-op toward a host that never negotiated the capability.
///
/// # Safety
/// `c` is an opaque handle from a `*_new`/`*_pair` the caller has not yet freed, or null (an
/// error, not UB).
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_report_phase(
c: *const PunktfunkConnection,
next_latch_host_ns: u64,
latch_period_ns: u32,
uncertainty_ns: u32,
arrival_lead_ns: u32,
) -> PunktfunkStatus {
guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
c.inner.report_phase(
next_latch_host_ns,
latch_period_ns,
uncertainty_ns,
arrival_lead_ns,
);
PunktfunkStatus::Ok
})
}
/// Whether [`punktfunk_connection_report_decode_us`] is worth calling this session: writes 1 to
/// `out` only when the adaptive-bitrate controller is armed (Automatic bitrate, non-PyroWave), so a
/// client can skip the per-frame decode-latency measurement entirely for explicit-bitrate and
+203 -50
View File
@@ -70,6 +70,17 @@ const CHANGE_COOLDOWN: Duration = Duration::from_millis(1500);
/// Window shard loss beyond which the window counts bad even without an unrecoverable frame:
/// 2 % sustained is congestion territory, not the random tail FEC exists for.
const HEAVY_LOSS_PPM: u32 = 20_000;
/// Decode-recovery KEYFRAME asks in one window at/above which the window is bad: the decoder
/// asked for a fresh picture twice inside 750 ms — it is being overdriven (or repeatedly
/// wedged), whatever loss_ppm says. This is the signal the RX-9070 field trace exposed: 14
/// requests in 2 s at ~300 Mbps with ZERO loss, and the controller kept the rate because no
/// loss/OWD/latency signal moved. RFI asks are deliberately NOT counted — they are the routine
/// loss-recovery mechanism and loss_ppm already prices them in.
const RECOVERY_KF_BAD: u32 = 2;
/// One window at/above this many keyframe asks is SEVERE (skips the two-window confirmation):
/// the emitters throttle at 100 ms, so 4+ inside a window means the decoder spent most of it
/// unable to produce pictures — the user is already watching the damage.
const RECOVERY_KF_SEVERE: u32 = 4;
/// How far the window's mean one-way delay may sit above the rolling baseline before it counts
/// as queue growth. 25 ms is far beyond jitter at any streamable frame rate.
const OWD_RISE_US: i64 = 25_000;
@@ -281,7 +292,8 @@ impl BitrateController {
/// ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline really
/// carried, as opposed to the target it was allowed; feeds the utilization climb gate and
/// the proven-throughput high-water mark), `flushed` = the pump's jump-to-live fired in the
/// window.
/// window, `recovery_kf` = decode-recovery keyframe asks the client sent in the window (see
/// [`RECOVERY_KF_BAD`]).
#[allow(clippy::too_many_arguments)]
pub(crate) fn on_window(
&mut self,
@@ -293,6 +305,7 @@ impl BitrateController {
encode_mean_us: Option<i64>,
actual_kbps: u32,
flushed: bool,
recovery_kf: u32,
) -> Option<u32> {
if !self.enabled {
return None;
@@ -366,12 +379,22 @@ impl BitrateController {
self.proven_kbps = actual_kbps;
}
// SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush, a
// deep decode-latency excursion) or loss far past any blip — one window is enough.
// Ordinary congestion (heavy-but-recoverable loss, an OWD rise, a decode-latency rise)
// still needs two consecutive windows.
let severe =
dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM || decode_severe || encode_severe;
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || decode_bad || encode_bad;
// deep decode-latency excursion, a window spent begging for keyframes) or loss far past
// any blip — one window is enough. Ordinary congestion (heavy-but-recoverable loss, an
// OWD rise, a decode-latency rise, repeated keyframe asks) still needs two consecutive
// windows.
let severe = dropped > 0
|| flushed
|| loss_ppm >= SEVERE_LOSS_PPM
|| decode_severe
|| encode_severe
|| recovery_kf >= RECOVERY_KF_SEVERE;
let bad = severe
|| loss_ppm >= HEAVY_LOSS_PPM
|| owd_bad
|| decode_bad
|| encode_bad
|| recovery_kf >= RECOVERY_KF_BAD;
if bad {
self.bad_windows += 1;
self.clean_windows = 0;
@@ -485,6 +508,7 @@ mod tests {
None,
1_000_000,
false,
0,
);
if out.is_some() {
return out;
@@ -499,7 +523,17 @@ mod tests {
let mut c = BitrateController::new(0);
let now = Instant::now();
assert_eq!(
c.on_window(now, 5, 900_000, Some(500_000), None, None, 1_000_000, true),
c.on_window(
now,
5,
900_000,
Some(500_000),
None,
None,
1_000_000,
true,
0
),
None
);
}
@@ -518,7 +552,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
None
);
@@ -532,7 +567,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
Some(14_000)
);
@@ -547,7 +583,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
None
); // bad #1 again
@@ -560,7 +597,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
Some(9_800)
);
@@ -572,13 +610,13 @@ mod tests {
let mut c = BitrateController::new(20_000);
let start = Instant::now();
assert_eq!(
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
Some(14_000)
);
// …and so does a jump-to-live flush.
let mut c = BitrateController::new(20_000);
assert_eq!(
c.on_window(ticks(start, 0), 0, 0, None, None, None, 1_000_000, true),
c.on_window(ticks(start, 0), 0, 0, None, None, None, 1_000_000, true, 0),
Some(14_000)
);
// …and ≥6 % window loss.
@@ -592,7 +630,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
Some(14_000)
);
@@ -603,18 +642,18 @@ mod tests {
let mut c = BitrateController::new(20_000);
let start = Instant::now();
assert_eq!(
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
Some(14_000)
);
c.on_ack(14_000);
// A severe window INSIDE the 1.5 s cooldown (tick 1 = 750 ms) → held; at the cooldown
// boundary (tick 2 = 1.5 s) it fires.
assert_eq!(
c.on_window(ticks(start, 1), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 1), 1, 0, None, None, None, 1_000_000, false, 0),
None
);
assert_eq!(
c.on_window(ticks(start, 2), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 2), 1, 0, None, None, None, 1_000_000, false, 0),
Some(9_800)
);
}
@@ -625,17 +664,17 @@ mod tests {
let start = Instant::now();
// ×0.7 of 6000 = 4200 < floor → clamped to 5000.
assert_eq!(
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
Some(5_000)
);
c.on_ack(5_000);
// At the floor, further bad windows request nothing.
assert_eq!(
c.on_window(ticks(start, 6), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 6), 1, 0, None, None, None, 1_000_000, false, 0),
None
);
assert_eq!(
c.on_window(ticks(start, 7), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 7), 1, 0, None, None, None, 1_000_000, false, 0),
None
);
}
@@ -645,7 +684,7 @@ mod tests {
let mut c = BitrateController::new(20_000);
let start = Instant::now();
assert_eq!(
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
Some(14_000)
);
c.on_ack(14_000);
@@ -677,6 +716,7 @@ mod tests {
None,
1_000_000,
false,
0,
) {
c.on_ack(k);
got.push(k);
@@ -699,7 +739,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
Some(40_000)
);
@@ -714,7 +755,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
Some(28_000)
);
@@ -731,6 +773,7 @@ mod tests {
None,
1_000_000,
false,
0,
);
if next.is_some() {
assert!(i >= 8, "additive climb must wait for the clean run");
@@ -745,7 +788,7 @@ mod tests {
let mut c = BitrateController::new(0);
c.set_ceiling(1_000_000);
assert_eq!(
c.on_window(Instant::now(), 0, 0, None, None, None, 1_000_000, false),
c.on_window(Instant::now(), 0, 0, None, None, None, 1_000_000, false, 0),
None
);
let mut c = BitrateController::new(20_000);
@@ -768,7 +811,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
None
);
@@ -783,7 +827,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
None
);
@@ -796,7 +841,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
Some(14_000)
);
@@ -819,7 +865,8 @@ mod tests {
Some(8_000),
None,
1_000_000,
false
false,
0
),
None
);
@@ -835,7 +882,8 @@ mod tests {
Some(38_000),
None,
1_000_000,
false
false,
0
),
None
);
@@ -848,12 +896,96 @@ mod tests {
Some(40_000),
None,
1_000_000,
false
false,
0
),
Some(14_000)
);
}
#[test]
fn keyframe_ask_storm_alone_is_a_congestion_signal() {
// The RX-9070 field shape: pristine link (zero loss, flat OWD), no latency signal — but
// the decoder keeps begging for keyframes. Two asks per window is ordinary-bad: two
// consecutive windows back off ×0.7.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
assert_eq!(
c.on_window(
ticks(start, 0),
0,
0,
Some(10_000),
None,
None,
1_000_000,
false,
2
),
None
);
assert_eq!(
c.on_window(
ticks(start, 1),
0,
0,
Some(10_000),
None,
None,
1_000_000,
false,
2
),
Some(14_000)
);
}
#[test]
fn keyframe_ask_saturation_is_severe() {
// The emitters throttle at 100 ms, so 4+ asks in one 750 ms window means the decoder
// spent most of it unable to produce pictures — one window is enough.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
assert_eq!(
c.on_window(
ticks(start, 0),
0,
0,
Some(10_000),
None,
None,
1_000_000,
false,
4
),
Some(14_000)
);
}
#[test]
fn a_single_keyframe_ask_is_not_congestion() {
// A lone hiccup's recovery ask must not read as congestion — windows carrying one ask
// stay clean (no backoff however many in a row).
let mut c = BitrateController::new(20_000);
let start = Instant::now();
for i in 0..4 {
assert_eq!(
c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
None,
None,
1_000_000,
false,
1
),
None
);
}
}
#[test]
fn decode_latency_caps_the_slow_start_climb() {
// A fat link (probe measured ~300 Mbps) but a decoder that saturates around the start rate.
@@ -870,7 +1002,8 @@ mod tests {
Some(8_000),
None,
1_000_000,
false
false,
0
),
Some(40_000)
);
@@ -886,7 +1019,8 @@ mod tests {
Some(38_000),
None,
1_000_000,
false
false,
0
),
None
);
@@ -901,7 +1035,8 @@ mod tests {
Some(40_000),
None,
1_000_000,
false
false,
0
),
Some(28_000)
);
@@ -925,7 +1060,8 @@ mod tests {
Some(8_000),
None,
2_000,
false
false,
0
),
None
);
@@ -942,7 +1078,8 @@ mod tests {
Some(8_000),
None,
18_000,
false
false,
0
),
Some(27_000)
);
@@ -966,7 +1103,8 @@ mod tests {
Some(8_000),
None,
20_000,
false
false,
0
),
Some(30_000)
);
@@ -981,7 +1119,8 @@ mod tests {
Some(8_000),
None,
30_000,
false
false,
0
),
Some(45_000)
);
@@ -1004,7 +1143,8 @@ mod tests {
Some(8_000),
None,
20_000,
false
false,
0
),
Some(30_000)
);
@@ -1020,7 +1160,8 @@ mod tests {
Some(4_000),
None,
600,
false
false,
0
),
None
);
@@ -1043,7 +1184,8 @@ mod tests {
Some(8_000),
None,
1_000_000,
false
false,
0
),
None
);
@@ -1059,7 +1201,8 @@ mod tests {
Some(60_000),
None,
1_000_000,
false
false,
0
),
Some(14_000)
);
@@ -1141,6 +1284,7 @@ mod tests {
None,
1_000_000,
false,
0,
);
}
assert_eq!(c.host_cap_kbps, Some(794_000 + 794_000 / 8));
@@ -1163,7 +1307,8 @@ mod tests {
None,
Some(7_000),
1_000_000,
false
false,
0
),
None
);
@@ -1177,7 +1322,8 @@ mod tests {
None,
Some(11_500),
1_000_000,
false
false,
0
),
None
);
@@ -1190,7 +1336,8 @@ mod tests {
None,
Some(12_000),
1_000_000,
false
false,
0
),
Some(14_000)
);
@@ -1212,7 +1359,8 @@ mod tests {
None,
Some(7_000),
1_000_000,
false
false,
0
),
None
);
@@ -1226,7 +1374,8 @@ mod tests {
None,
Some(20_000),
1_000_000,
false
false,
0
),
Some(14_000)
);
@@ -1249,6 +1398,7 @@ mod tests {
Some(7_000),
1_000_000,
false,
0,
);
}
let _ = c.on_window(
@@ -1260,6 +1410,7 @@ mod tests {
Some(12_000),
1_000_000,
false,
0,
);
assert_eq!(
c.on_window(
@@ -1270,7 +1421,8 @@ mod tests {
None,
Some(12_500),
1_000_000,
false
false,
0
),
Some(14_000)
);
@@ -1287,7 +1439,8 @@ mod tests {
None,
Some(15_000),
1_000_000,
false
false,
0
),
None
);
@@ -1302,7 +1455,7 @@ mod tests {
let mut i = 0;
// Keep every window bad and never ack: exactly MAX_UNACKED requests, then silence.
while i < 60 {
if c.on_window(ticks(start, i), 1, 0, None, None, None, 1_000_000, false)
if c.on_window(ticks(start, i), 1, 0, None, None, None, 1_000_000, false, 0)
.is_some()
{
sent += 1;
@@ -32,6 +32,10 @@ pub(crate) enum CtrlRequest {
/// desktop mouse model — host excludes + forwards), `false` = host composites into the
/// video (the capture model). Sent on every mouse-model flip; idempotent, latest-wins.
CursorRender(crate::quic::CursorRenderMode),
/// The client's display-latch grid, ~1 Hz, host-clock timestamps — feeds the host's
/// phase-locked capture (design/phase-locked-capture.md). Latest-wins; an old host
/// ignores the message.
Phase(crate::quic::PhaseReport),
}
/// What the worker reports to [`NativeClient::connect`] once the handshake lands: the
+24
View File
@@ -774,6 +774,30 @@ impl NativeClient {
self.wants_decode
}
/// Report this client's display-latch grid so the host can phase-lock its capture tick
/// (design/phase-locked-capture.md; the vsync-aware presenters call this ~1 Hz).
/// `next_latch_host_ns` must already be HOST clock — convert with
/// [`clock_offset_now_ns`](Self::clock_offset_now_ns) (`T_host = T_client + offset`) before
/// calling; the offset lives only on this side. Fire-and-forget: dropped silently if the
/// control task's queue is momentarily full (the next report supersedes) or toward a host
/// that never negotiated the capability.
pub fn report_phase(
&self,
next_latch_host_ns: u64,
latch_period_ns: u32,
uncertainty_ns: u32,
arrival_lead_ns: u32,
) {
let _ = self
.ctrl_tx
.try_send(CtrlRequest::Phase(crate::quic::PhaseReport {
next_latch_host_ns,
latch_period_ns,
uncertainty_ns,
arrival_lead_ns,
}));
}
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at
/// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the
/// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its
+6
View File
@@ -112,6 +112,10 @@ pub(super) async fn run_pump(args: WorkerArgs) {
// Adaptive bitrate ack slot: the control task parks the latest BitrateChanged here; the
// pump's controller drains it on its report tick (`take()` — an ack is consumed once).
let bitrate_ack: Arc<Mutex<Option<u32>>> = Arc::new(Mutex::new(None));
// Decode-recovery keyframe asks (the ABR recovery signal): the control task counts every
// outbound `CtrlRequest::Keyframe` — the one choke point all emitters funnel through — and
// the pump drains the count per report window.
let recovery_kf = Arc::new(AtomicU32::new(0));
// Host-encode-latency accumulator (the ABR encode signal, see [`EncodeLatAcc`]): the
// datagram task adds one sample per 0xCF; the pump drains a window mean per report tick.
let encode_lat = Arc::new(Mutex::new(super::frame_channel::EncodeLatAcc::default()));
@@ -130,6 +134,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
mode_slot,
probe: probe.clone(),
bitrate_ack: bitrate_ack.clone(),
recovery_kf: recovery_kf.clone(),
clock_offset: clock_offset.clone(),
clock_gen: clock_gen.clone(),
clip_event_tx: clip_event_tx.clone(),
@@ -189,6 +194,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
frames_dropped,
fec_recovered,
bitrate_ack,
recovery_kf,
bitrate_kbps,
resolved_bitrate_kbps,
negotiated_codec,
@@ -16,6 +16,10 @@ pub(super) struct ControlTask {
pub(super) probe: Arc<Mutex<ProbeState>>,
/// The latest host `BitrateChanged` ack, drained by the pump's ABR on its report tick.
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
/// Outbound decode-recovery KEYFRAME asks, counted here because this is the one choke point
/// every emitter funnels through (embedder, `note_frame_index`, the pump's own asks) — the
/// pump drains the count per report window as the ABR's recovery signal.
pub(super) recovery_kf: Arc<AtomicU32>,
pub(super) clock_offset: Arc<std::sync::atomic::AtomicI64>,
pub(super) clock_gen: Arc<AtomicU32>,
/// Clipboard metadata events (ClipState/ClipOffer) feed the same event plane the
@@ -40,6 +44,7 @@ impl ControlTask {
mode_slot,
probe,
bitrate_ack,
recovery_kf,
clock_offset,
clock_gen,
clip_event_tx,
@@ -72,7 +77,10 @@ impl ControlTask {
let bytes = match req {
CtrlRequest::Mode(m) => Reconfigure { mode: m }.encode(),
CtrlRequest::Probe(p) => p.encode(),
CtrlRequest::Keyframe => RequestKeyframe.encode(),
CtrlRequest::Keyframe => {
recovery_kf.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
RequestKeyframe.encode()
}
CtrlRequest::Rfi(r) => r.encode(),
CtrlRequest::Loss(r) => r.encode(),
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
@@ -86,6 +94,7 @@ impl ControlTask {
CtrlRequest::ClipControl(c) => c.encode(),
CtrlRequest::ClipOffer(o) => o.encode(),
CtrlRequest::CursorRender(m) => m.encode(),
CtrlRequest::Phase(p) => p.encode(),
};
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
break;
+24 -3
View File
@@ -28,6 +28,9 @@ pub(super) struct DataPump {
pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>,
pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
/// Outbound decode-recovery keyframe asks, counted by the control task at its send choke
/// point; drained per report window as the ABR's recovery signal.
pub(super) recovery_kf: Arc<AtomicU32>,
/// The embedder's REQUESTED rate (0 = Automatic — the only case the ABR arms).
pub(super) bitrate_kbps: u32,
/// The rate the host actually configured (echoed in Welcome).
@@ -52,6 +55,7 @@ impl DataPump {
frames_dropped,
fec_recovered,
bitrate_ack,
recovery_kf: pump_recovery_kf,
bitrate_kbps,
resolved_bitrate_kbps,
negotiated_codec,
@@ -103,7 +107,18 @@ impl DataPump {
// becomes the climb ceiling and slow start does the rest. Old hosts decline (all-zero
// reply) or never answer (timeout clears the state so LossReports resume) — either way
// the ceiling stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out.
const CAPACITY_PROBE_KBPS: u32 = 2_000_000;
// `PUNKTFUNK_ABR_PROBE_KBPS` lowers the burst target (unset/0/garbage → the 2 Gbps
// default): the target is deliberately far above any plausible link so the burst measures
// the link and not itself, but on links the burst DISTURBS that backfires — a constrained
// Wi-Fi link can black-hole under 2 Gbps (measured on webOS: the probe hitting the 6 s
// timeout delayed first video to 14 s, and a "successful" one still reported
// send_dropped=20211), and a 2-3 core TV client starves decoding the firehose. An
// embedder that caps its own speed test wants this capped to match.
let capacity_probe_kbps: u32 = std::env::var("PUNKTFUNK_ABR_PROBE_KBPS")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
.filter(|&v| v > 0)
.unwrap_or(2_000_000);
const CAPACITY_PROBE_MS: u32 = 800;
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
@@ -245,14 +260,14 @@ impl DataPump {
};
if ctrl_tx
.try_send(CtrlRequest::Probe(ProbeRequest {
target_kbps: CAPACITY_PROBE_KBPS,
target_kbps: capacity_probe_kbps,
duration_ms: CAPACITY_PROBE_MS,
}))
.is_ok()
{
capacity_probe_deadline = Some(Instant::now() + CAPACITY_PROBE_TIMEOUT);
tracing::info!(
target_kbps = CAPACITY_PROBE_KBPS,
target_kbps = capacity_probe_kbps,
duration_ms = CAPACITY_PROBE_MS,
"adaptive bitrate: startup link-capacity probe"
);
@@ -414,6 +429,10 @@ impl DataPump {
*acc = Default::default();
(count > 0).then(|| (sum / count as u64) as i64)
};
// Decode-recovery keyframe asks this window (counted at the control task's send
// choke point). Always drained so a discard window can't leak its count into
// the next one.
let recovery_kf_reqs = pump_recovery_kf.swap(0, Ordering::Relaxed);
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs
// the target it was allowed. Wire bytes (headers + FEC) slightly overstate the
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
@@ -435,6 +454,7 @@ impl DataPump {
encode_mean_us,
actual_kbps,
flush_in_window,
recovery_kf_reqs,
)
};
if let Some(kbps) = verdict {
@@ -449,6 +469,7 @@ impl DataPump {
encode_mean_us = encode_mean_us.unwrap_or(-1),
actual_kbps,
flushed = flush_in_window,
recovery_kf = recovery_kf_reqs,
"adaptive bitrate: requesting encoder re-target"
);
let _ = ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps));
@@ -136,6 +136,9 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
// video frame indexes. STREAMED_AU the same way: the shared reassembler accepts
// sentinel-headed streamed blocks (retro-validated at the final block), so the host
// may overlap a multi-slice encode's tail with packetize/FEC/pacing.
// VIDEO_CAP_MULTI_SLICE must NOT join this unconditional set: it is DECODER
// truth (Amlogic MediaCodec wedges on multi-slice AUs — the 0.17.0 Chromecast
// regression), so only the embedder can set it, per its decode stack.
video_caps: video_caps
| crate::quic::VIDEO_CAP_HOST_TIMING
| crate::quic::VIDEO_CAP_PROBE_SEQ
+20
View File
@@ -55,6 +55,19 @@ pub const VIDEO_CAP_STREAMED_AU: u8 = 0x20;
/// performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS
/// control channel, so there is no downgrade surface.
pub const VIDEO_CAP_CHACHA20: u8 = 0x40;
/// [`Hello::video_caps`] bit: the client's decoder accepts **multi-slice access units** — H.264/
/// HEVC frames carrying several slice NALs (latency plan §7 LN1: the encoder splits frames so
/// sub-frame readback can ship early slices while the tail encodes). Decoder-level, so the
/// EMBEDDER sets it from what its decode stack actually handles: the desktop clients' FFmpeg/
/// D3D11VA/Vulkan-video decoders are fine, but mobile/TV MediaCodec is per-SoC — Amlogic HEVC
/// decoders (Chromecast with Google TV, Fire TV) wedge the whole DEVICE on multi-slice frames
/// (the 0.17.0 field regression: the 4-slice Linux default froze streams on first frame and
/// watchdog-rebooted the CCwGTV), which is exactly why Moonlight requests 1 slice per frame for
/// every hardware decoder. The host defaults to >1 slice ONLY toward a client that sets this
/// bit (`PUNKTFUNK_NVENC_SLICES` stays the explicit operator override in both directions);
/// every other client gets single-slice frames — the pre-0.17 wire shape. NOTE: this takes the
/// video_caps byte's last free bit — the next video cap needs a second byte (ABI bump).
pub const VIDEO_CAP_MULTI_SLICE: u8 = 0x80;
/// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
/// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
@@ -91,6 +104,13 @@ pub const HOST_CAP_TEXT_INPUT: u8 = 0x04;
/// an older or incapable host nothing changes.
pub const CLIENT_CAP_CURSOR: u8 = 0x01;
/// `Hello.client_caps` bit: this client runs a vsync-aware presenter and will send
/// [`PhaseReport`](super::control::PhaseReport)s (~1 Hz) so the host can phase-lock its
/// capture/send tick to the client's display latch (design/phase-locked-capture.md). Without
/// the bit the host never arms the phase controller; toward an older host the reports are
/// simply ignored — no behavior change in either direction.
pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
/// whose capture carries no cursor, and NOT Windows yet, where DWM composites into the IDD
+68
View File
@@ -156,6 +156,30 @@ pub struct ClockEcho {
pub t3_ns: u64,
}
/// `client → host`, ~1 Hz: the client's display-latch grid, so the host can PHASE-LOCK its
/// capture/send tick and land frames a constant, small margin before the client's vsync latch
/// (design/phase-locked-capture.md). Sent only by clients with a vsync-aware presenter, gated on
/// [`CLIENT_CAP_PHASE_LOCK`](crate::quic::CLIENT_CAP_PHASE_LOCK); an old host ignores it.
///
/// Timestamps are HOST clock (`CLOCK_REALTIME`): the client converts before sending
/// (`T_host = T_client + offset` — the skew offset from the clock handshake lives only
/// client-side, the host deliberately stores none).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PhaseReport {
/// The client's next display latch, host clock. The host extrapolates forward by
/// `latch_period_ns` — the report describes a grid, not one instant.
pub next_latch_host_ns: u64,
/// The client panel's refresh period (its true latch grid — not the app's possibly
/// down-rated callback rate).
pub latch_period_ns: u32,
/// The client's own error bound on this grid (skew residual + latch jitter p95) — the host
/// widens its arrival margin by this, never narrows below its floor.
pub uncertainty_ns: u32,
/// Measured median lead of frame ARRIVAL before latch over the last window (ns; clamped
/// ≥ 0). The controller drives this toward its target lead — the error signal.
pub arrival_lead_ns: u32,
}
/// Type byte of [`Reconfigure`] (first byte after the magic).
pub const MSG_RECONFIGURE: u8 = 0x01;
/// Type byte of [`Reconfigured`].
@@ -178,6 +202,8 @@ pub const MSG_PROBE_RESULT: u8 = 0x21;
pub const MSG_CLOCK_PROBE: u8 = 0x30;
/// Type byte of [`ClockEcho`].
pub const MSG_CLOCK_ECHO: u8 = 0x31;
/// Type byte of [`PhaseReport`].
pub const MSG_PHASE_REPORT: u8 = 0x32;
impl Reconfigure {
pub fn encode(&self) -> Vec<u8> {
@@ -461,6 +487,33 @@ impl ClockEcho {
}
}
impl PhaseReport {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] latch[5..13] period[13..17] uncertainty[17..21] lead[21..25]
let mut b = Vec::with_capacity(25);
b.extend_from_slice(CTL_MAGIC);
b.push(MSG_PHASE_REPORT);
b.extend_from_slice(&self.next_latch_host_ns.to_le_bytes());
b.extend_from_slice(&self.latch_period_ns.to_le_bytes());
b.extend_from_slice(&self.uncertainty_ns.to_le_bytes());
b.extend_from_slice(&self.arrival_lead_ns.to_le_bytes());
b
}
pub fn decode(b: &[u8]) -> Result<PhaseReport> {
if b.len() != 25 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PHASE_REPORT {
return Err(PunktfunkError::InvalidArg("bad PhaseReport"));
}
let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
Ok(PhaseReport {
next_latch_host_ns: u64::from_le_bytes(b[5..13].try_into().unwrap()),
latch_period_ns: u32at(13),
uncertainty_ns: u32at(17),
arrival_lead_ns: u32at(21),
})
}
}
/// Frame a message for the control stream: `u16 LE length || payload`.
pub fn frame(payload: &[u8]) -> Vec<u8> {
let mut b = Vec::with_capacity(2 + payload.len());
@@ -923,6 +976,21 @@ mod tests {
.is_err());
}
#[test]
fn phase_report_roundtrip() {
let pr = PhaseReport {
next_latch_host_ns: 1_753_900_000_123_456_789,
latch_period_ns: 8_333_333,
uncertainty_ns: 900_000,
arrival_lead_ns: 4_100_000,
};
assert_eq!(PhaseReport::decode(&pr.encode()).unwrap(), pr);
// Wrong type byte (a ClockProbe) must not decode as a PhaseReport.
assert!(PhaseReport::decode(&ClockProbe { t1_ns: 7 }.encode()).is_err());
// Truncation must not decode.
assert!(PhaseReport::decode(&pr.encode()[..24]).is_err());
}
#[test]
fn reconfigure_roundtrip() {
let rq = Reconfigure {
+48 -1
View File
@@ -557,6 +557,11 @@ pub struct HostTiming {
/// and takes the stage tail only when present), so no capability bit is needed in either
/// direction: old client + new host reads the prefix, new client + old host gets `None`.
pub stages: Option<HostStages>,
/// Phase-lock ACK (design/phase-locked-capture.md): the capture-tick hold the host is
/// currently applying, ns. Rides the same append-extensible tail (after the stages block):
/// `None` from a pre-phase-lock host or one sending the shorter forms. The client's
/// presenter compares it against its own requested correction to see the loop close.
pub applied_phase_ns: Option<i32>,
}
/// The extended 0xCF's per-stage split of [`HostTiming::host_us`], all µs against the same
@@ -578,11 +583,15 @@ pub struct HostStages {
const HOST_TIMING_LEN: usize = 1 + 8 + 4;
/// Wire length with the [`HostStages`] tail appended: + 3 × u32 = 25 bytes.
const HOST_TIMING_STAGES_LEN: usize = HOST_TIMING_LEN + 12;
/// Wire length with the phase-lock ACK appended after the stages: + i32 = 29 bytes. The tail
/// discipline holds: each form is a strict prefix of the next, so every reader takes what it
/// knows and ignores the rest.
const HOST_TIMING_PHASE_LEN: usize = HOST_TIMING_STAGES_LEN + 4;
/// Encode a [`HostTiming`] into a [`HOST_TIMING_MAGIC`] datagram (extended form when `stages`
/// is set — an older client parses the prefix and ignores the tail).
pub fn encode_host_timing_datagram(t: &HostTiming) -> Vec<u8> {
let mut b = Vec::with_capacity(HOST_TIMING_STAGES_LEN);
let mut b = Vec::with_capacity(HOST_TIMING_PHASE_LEN);
b.push(HOST_TIMING_MAGIC);
b.extend_from_slice(&t.pts_ns.to_le_bytes());
b.extend_from_slice(&t.host_us.to_le_bytes());
@@ -590,6 +599,11 @@ pub fn encode_host_timing_datagram(t: &HostTiming) -> Vec<u8> {
b.extend_from_slice(&s.queue_us.to_le_bytes());
b.extend_from_slice(&s.encode_us.to_le_bytes());
b.extend_from_slice(&s.pace_us.to_le_bytes());
// The phase ACK only ever rides AFTER a stages tail — a prefix-discipline wire can't
// express "phase but no stages", and every host new enough to phase-lock sends stages.
if let Some(p) = t.applied_phase_ns {
b.extend_from_slice(&p.to_le_bytes());
}
}
b
}
@@ -606,10 +620,13 @@ pub fn decode_host_timing_datagram(b: &[u8]) -> Option<HostTiming> {
encode_us: u32::from_le_bytes(b[17..21].try_into().unwrap()),
pace_us: u32::from_le_bytes(b[21..25].try_into().unwrap()),
});
let applied_phase_ns = (b.len() >= HOST_TIMING_PHASE_LEN)
.then(|| i32::from_le_bytes(b[25..29].try_into().unwrap()));
Some(HostTiming {
pts_ns: u64::from_le_bytes(b[1..9].try_into().unwrap()),
host_us: u32::from_le_bytes(b[9..13].try_into().unwrap()),
stages,
applied_phase_ns,
})
}
@@ -715,6 +732,7 @@ mod tests {
pts_ns: 1_751_500_000_123_456_789, // a realistic 2026 CLOCK_REALTIME capture stamp
host_us: 4_321,
stages: None,
applied_phase_ns: None,
};
let d = encode_host_timing_datagram(&t);
assert_eq!(d[0], HOST_TIMING_MAGIC);
@@ -754,6 +772,35 @@ mod tests {
"partial stage tail ({n} B) must degrade to the legacy decode"
);
}
// Phase-ACK form (design/phase-locked-capture.md): strict-prefix discipline holds a
// third time — 29 B roundtrips, 25..28 degrade to the stages form, the prefix is
// byte-identical, and a phase without stages is unencodable by construction.
let tp = HostTiming {
applied_phase_ns: Some(-2_750_000),
..ts
};
let dp = encode_host_timing_datagram(&tp);
assert_eq!(dp.len(), 29);
assert_eq!(&dp[..25], &ds[..25], "stages form is a strict prefix");
assert_eq!(decode_host_timing_datagram(&dp), Some(tp));
for n in 25..dp.len() {
assert_eq!(
decode_host_timing_datagram(&dp[..n]),
Some(ts),
"partial phase tail ({n} B) must degrade to the stages decode"
);
}
let no_stages = HostTiming {
stages: None,
applied_phase_ns: Some(1),
..t
};
assert_eq!(
encode_host_timing_datagram(&no_stages).len(),
13,
"phase without stages must encode as the legacy form (prefix discipline)"
);
}
#[test]
+8
View File
@@ -198,6 +198,14 @@ usbip-sim = { path = "vendor/usbip-sim" }
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Security",
# WinVerifyTrust + WTHelper* — Authenticode verification of a downloaded host installer
# before the update apply spawns it (update/windows.rs). The WTHelper accessors are gated
# behind the Catalog+Sip features in windows-rs, hence the extra two.
"Win32_Security_WinTrust",
"Win32_Security_Cryptography_Catalog",
"Win32_Security_Cryptography_Sip",
# CERT_CONTEXT for the signing-leaf fingerprint pin (same file).
"Win32_Security_Cryptography",
# ConvertStringSecurityDescriptorToSecurityDescriptorW — the SDDL on the virtual-DualSense
# shared-memory section (inject/dualsense_windows.rs) so the UMDF host can open it.
"Win32_Security_Authorization",
+8
View File
@@ -19,6 +19,14 @@
use std::sync::OnceLock;
/// Lowercased executable basenames (no `.exe`) of every running process — the same snapshot the
/// conflicting-host scan uses, exposed for the few callers that need to ask "is X running?"
/// without duplicating a Toolhelp walk. Best-effort: an empty vec means "could not tell", never
/// "nothing is running", so callers must not read absence as proof.
pub(crate) fn running_process_names() -> Vec<String> {
platform::running_processes()
}
#[cfg(target_os = "windows")]
#[path = "detect/windows.rs"]
mod platform;
+19
View File
@@ -202,6 +202,23 @@ pub enum EventKind {
/// API (RFC §8) lands.
source: String,
},
/// A verified update manifest announced a release newer than the running host. Emitted
/// once per discovered version (a steady-state "newer exists" doesn't re-fire on every
/// refresh).
#[serde(rename = "update.available")]
UpdateAvailable {
/// The newer release's version string.
version: String,
/// The channel it was announced on (`stable` | `canary`).
channel: String,
/// This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the
/// tray render the right "how to update" hint without a second call.
install_kind: String,
},
/// A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's
/// first start after a successful apply.
#[serde(rename = "update.applied")]
UpdateApplied { from: String, to: String },
#[serde(rename = "plugins.changed")]
PluginsChanged {
/// The plugin whose registration changed (registered, restarted, deregistered, or
@@ -242,6 +259,8 @@ impl EventKind {
EventKind::DisplayCreated { .. } => "display.created",
EventKind::DisplayReleased { .. } => "display.released",
EventKind::LibraryChanged { .. } => "library.changed",
EventKind::UpdateAvailable { .. } => "update.available",
EventKind::UpdateApplied { .. } => "update.applied",
EventKind::PluginsChanged { .. } => "plugins.changed",
EventKind::StoreChanged => "store.changed",
EventKind::HostStarted { .. } => "host.started",
@@ -627,6 +627,7 @@ mod session_tests {
codec: crate::encode::Codec::H265,
min_fec: 0,
hdr: false,
slices: 1, // the no-request default — hardware decoders get single-slice AUs
});
assert!(state.end_session("test"), "video was live");
@@ -556,6 +556,17 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
let min_fec = parse_u("x-nv-vqos[0].fec.minRequiredFecPackets")
.unwrap_or(2)
.min(16) as u8;
// The client's requested per-frame slice count (moonlight-common-c SdpGenerator.c:
// `videoEncoderSlicesPerFrame`) — 1 for every HARDWARE decoder, 4 only for software
// decoders (slice-threading). Honor it as the encoder's slicing ceiling: GFE/Sunshine
// encode what was asked, and hardware TV decoders (Amlogic — Chromecast with Google TV)
// wedge the whole DEVICE on multi-slice AUs they never requested — the 0.17.0 field
// regression, where the Linux direct-NVENC 4-slice default (§7 LN1) ignored this key and
// froze + watchdog-rebooted CCwGTV clients on the first frame. Absent or out-of-range
// (attacker-controlled pre-auth input) ⇒ 1, the universally-safe single-slice shape.
let slices = parse_u("x-nv-video[0].videoEncoderSlicesPerFrame")
.filter(|n| (1..=32).contains(n))
.unwrap_or(1);
Some(StreamConfig {
width,
height,
@@ -565,6 +576,7 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
codec,
min_fec,
hdr,
slices,
})
}
@@ -727,6 +739,30 @@ mod tests {
}
}
/// `videoEncoderSlicesPerFrame` is honored as the encoder's slicing ceiling: Moonlight sends
/// 1 for every hardware decoder (Amlogic TV SoCs wedge on multi-slice AUs — the 0.17.0
/// Chromecast regression) and 4 for software decoders. Absent or out-of-range (pre-auth,
/// attacker-controlled) must fall back to the universally-safe 1, never reject the session.
#[test]
fn announce_slices_per_frame() {
// Absent (very old client) → single-slice.
assert_eq!(stream_config(&announce(&[])).unwrap().slices, 1);
// The two real Moonlight values pass through verbatim.
for want in ["1", "4"] {
let map = announce(&[("x-nv-video[0].videoEncoderSlicesPerFrame", want)]);
assert_eq!(
stream_config(&map).unwrap().slices,
want.parse::<u32>().unwrap()
);
}
// Garbage / out-of-range degrades to 1 (still streams — this key must never kill a session).
for bad in ["0", "33", "999999", "-1", "x"] {
let map = announce(&[("x-nv-video[0].videoEncoderSlicesPerFrame", bad)]);
let cfg = stream_config(&map).expect("session must still negotiate");
assert_eq!(cfg.slices, 1, "slicesPerFrame {bad} must degrade to 1");
}
}
/// Audio negotiation: numChannels/AudioQuality/packetDuration, with Moonlight defaults.
#[test]
fn announce_audio_params() {
@@ -31,6 +31,12 @@ pub struct StreamConfig {
/// Drives the capturer's proactive advanced-color enable; the encoder picks Main10 from the captured
/// (P010) frame format. Always `false` on a non-HDR host, so the SDR path is unchanged.
pub hdr: bool,
/// Client's `x-nv-video[0].videoEncoderSlicesPerFrame` — the per-frame slice count its
/// decoder wants (moonlight-common-c requests 1 for every HARDWARE decoder and 4 only for
/// software slice-threading). Honored as the encoder's slicing ceiling: hardware TV decoders
/// (Amlogic — Chromecast with Google TV) wedge the whole device on multi-slice AUs they
/// never asked for (the 0.17.0 4-slice-default field regression). Absent ⇒ 1.
pub slices: u32,
}
/// A pooled capturer plus the three properties reuse must match on — its HDR-ness, its
@@ -1040,6 +1046,8 @@ fn stream_body(
// True only when THIS session's capture negotiated cursor-as-metadata — which the
// callers grant only where the resolved backend composites (`cursor_blend_capable`).
cursor_blend,
// The client's requested slices-per-frame (its DECODER's ceiling) — see `StreamConfig`.
cfg.slices,
)
.context("open video encoder for stream")?;
// Tell the encoder how deep the capturer lets it pipeline. Without this an in-place backend
@@ -1212,6 +1220,7 @@ fn stream_body(
gs_bit_depth(frame.format),
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
cursor_blend, // same capture cursor mode — see the first open
cfg.slices, // client slicing ceiling — see the first open
)
.context("reopen encoder after rebuild")?;
// A rebuilt encoder starts unconfigured — same reason as the first open above.
+13
View File
@@ -95,10 +95,15 @@ mod session_status;
mod sleep_inhibit;
mod spike;
mod stats_recorder;
// Start/stop/status for the per-user tray — the recovery path it has never had of its own.
#[cfg(target_os = "windows")]
#[path = "windows/tray.rs"]
mod tray;
// The plugin store: signed catalogs, tiered trust, and install/uninstall jobs that run through the
// same runner CLI the `plugins` subcommand uses (design/plugin-store.md).
mod store;
mod stream_marker;
mod update;
// `monitor_devnode::startup_recover()` (below) re-enables PnP monitor devnodes disabled by a prior
// run; it lives in the `pf-win-display` leaf crate (plan §W6).
#[cfg(target_os = "windows")]
@@ -274,6 +279,7 @@ fn is_management_cli(args: &[String]) -> bool {
Some("plugins")
| Some("driver")
| Some("web")
| Some("tray")
| Some("openapi")
| Some("library")
| Some("detect-conflicts")
@@ -665,6 +671,11 @@ fn real_main() -> Result<()> {
Some("driver") => install::driver_main(&args[1..]),
#[cfg(target_os = "windows")]
Some("web") => install::web_main(&args[1..]),
// The tray's only recovery path: it is a per-user GUI process whose HKLM Run value fires
// solely at sign-in, so anything that kills one (an upgrade, a crash) otherwise left the
// operator iconless until the next logon.
#[cfg(target_os = "windows")]
Some("tray") => tray::main(&args[1..]),
Some("-h") | Some("--help") | Some("help") | None => {
print_usage();
Ok(())
@@ -898,6 +909,8 @@ USAGE:
(secure default; add --gamestream for Moonlight compat)
punktfunk-host plugins <CMD> install/run host plugins (add, remove, list, enable,
disable, status) `plugins --help` for details
punktfunk-host tray <CMD> status-tray lifecycle (start, stop, status) Windows;
`start` is how you get the icon back without a re-logon
punktfunk-host openapi print the management API's OpenAPI document (codegen)
punktfunk-host punktfunk1-host [OPTIONS] native punktfunk/1 host (QUIC control + UDP data plane)
punktfunk-host probe-compositor exit 0 iff the compositor is up + ready (bringup gate)
+10 -1
View File
@@ -45,6 +45,7 @@ mod stats;
mod store;
#[cfg(test)]
mod tests;
mod update;
/// Default management port — adjacent to the GameStream block (47984…48010), and the same
/// number Sunshine users already associate with "the config UI".
@@ -103,6 +104,10 @@ pub async fn run(
stats: Arc<crate::stats_recorder::StatsRecorder>,
gamestream_enabled: bool,
) -> Result<()> {
// Close out any update-intent record a previous apply left behind (the update reports its
// own outcome across its own restart — update/jobs.rs). Once per boot, before serving.
crate::update::reconcile_at_boot();
// The mgmt API is HTTPS + token-authenticated ALWAYS (even on loopback): `parse_serve`
// guarantees a token (CLI flag / env / persisted ~/.config/punktfunk/mgmt-token / generated).
// A blank token is treated as none — fail loudly rather than ever serve unauthenticated.
@@ -257,7 +262,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(store::get_job))
.routes(routes!(store::list_sources))
.routes(routes!(store::put_source, store::delete_source))
.routes(routes!(store::get_runtime, store::set_runtime)),
.routes(routes!(store::get_runtime, store::set_runtime))
.routes(routes!(update::get_update_status))
.routes(routes!(update::force_update_check))
.routes(routes!(update::apply_update)),
)
.split_for_parts()
}
@@ -297,6 +305,7 @@ pub fn openapi_json() -> String {
(name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"),
(name = "plugins", description = "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"),
(name = "store", description = "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"),
(name = "update", description = "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"),
)
)]
struct ApiDoc;
+6 -1
View File
@@ -149,7 +149,12 @@ pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
|| (method == Method::DELETE
&& (path.starts_with("/api/v1/clients/")
|| path.starts_with("/api/v1/native/clients/")))
|| (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential"));
|| (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential"))
// The update surface is operator business end to end: today it is only a check, but
// the same prefix will carry `apply` (running an installer / the root helper), and a
// whole-prefix deny can't be defeated by a route added later.
|| path == "/api/v1/update"
|| path.starts_with("/api/v1/update/");
!denied
}
+25
View File
@@ -544,6 +544,31 @@ fn plugin_allowlist_excludes_escalation_routes() {
"plugin token must not reach {path}"
);
}
// The update surface, wholesale: today a check, tomorrow `apply` (an installer / the root
// helper) — operator business end to end, denied by whole-prefix so the apply route added in
// U1/U2 is denied by default rather than by remembering to list it. And it is deliberately
// NOT on the paired-cert allowlist either: a streaming client has no business knowing or
// steering the host's update state.
for path in [
"/api/v1/update",
"/api/v1/update/status",
"/api/v1/update/check",
"/api/v1/update/apply-does-not-exist-yet",
] {
assert!(
!auth::plugin_may_access(&Method::GET, path),
"plugin token must not reach {path}"
);
assert!(
!auth::plugin_may_access(&Method::POST, path),
"plugin token must not reach {path}"
);
assert!(
!auth::cert_may_access(&Method::GET, path),
"a paired streaming cert must not reach {path}"
);
}
assert!(!auth::plugin_may_access(
&Method::PUT,
"/api/v1/store/sources/evil"
+271
View File
@@ -0,0 +1,271 @@
//! `/api/v1/update/*` — the host update-check surface (design
//! `host-update-from-web-console.md` §4.2, phase U0).
//!
//! Admin lane ONLY: denied to the plugin token (`auth::plugin_may_access`) and absent from
//! the paired-cert allowlist — an update trigger is operator business. U0 exposes `status` +
//! `check`; the `apply` route arrives with the first apply leg (U1) so the API never
//! advertises a capability no code backs.
use super::shared::*;
use crate::update::{self, detect};
/// One channel's manifest facts, as much as the console renders.
#[derive(Serialize, Deserialize, ToSchema)]
pub(crate) struct UpdateManifestInfo {
/// The released version this manifest announces.
pub version: String,
/// Publish serial (unix seconds) — monotonic per channel.
pub serial: u64,
/// RFC-3339 publish time (display only).
pub published_at: String,
/// Release-notes link (pinned to our forge by the manifest validator).
pub notes_url: String,
/// The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint.
pub stale: bool,
}
/// A running apply job (or a spawned installer that hasn't resolved yet).
#[derive(Serialize, Deserialize, ToSchema)]
pub(crate) struct UpdateJobInfo {
/// The version being installed.
pub target_version: String,
/// `downloading` | `verifying` | `applying` | `restarting`.
pub stage: String,
pub received_bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_bytes: Option<u64>,
pub started_unix: u64,
}
/// Durable outcome of the most recent apply attempt (survives the host's own restart).
#[derive(Serialize, Deserialize, ToSchema)]
pub(crate) struct UpdateResultInfo {
pub ok: bool,
pub from: String,
pub to: String,
pub finished_unix: u64,
/// The stage that failed; absent on success.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// The installer's own log file on this host, for diagnosis.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_path: Option<String>,
/// Applied but activates on the next reboot (rpm-ostree).
#[serde(default)]
pub staged: bool,
}
/// The full update-check state for this host.
#[derive(Serialize, Deserialize, ToSchema)]
pub(crate) struct UpdateStatus {
/// How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |
/// `dnf` | `pacman` | `steamos-source` | `nix` | `source`.
pub install_kind: String,
/// Release channel this install follows: `stable` | `canary`.
pub channel: String,
/// The running host version.
pub current_version: String,
/// What the console may offer for this install: `notify` (show the command) — later
/// phases add `full` (one-click apply) and `staged` (apply + reboot to finish).
pub apply: String,
/// The copy-pastable update command for this install kind.
pub channel_hint: String,
/// Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`).
pub check_disabled: bool,
/// A newer release than `current_version` exists for this channel (definitive
/// comparisons only — an unparseable version pair never flags).
pub available: bool,
/// The last verified manifest, if any check has succeeded.
pub manifest: Option<UpdateManifestInfo>,
/// When the last successful check happened (unix seconds).
pub last_checked_unix: Option<u64>,
/// Why the last check failed, verbatim, if it did.
pub last_error: Option<String>,
/// This install could one-click apply, but the operator hasn't opted in yet — the
/// command to run (Linux: join the `punktfunk-update` group).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opt_in_hint: Option<String>,
/// The apply in flight, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job: Option<UpdateJobInfo>,
/// Outcome of the most recent apply attempt.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_result: Option<UpdateResultInfo>,
}
fn status_from(snap: update::Snapshot) -> UpdateStatus {
let (kind, channel) = detect::detect();
let current = env!("PUNKTFUNK_VERSION");
let stale = snap.stale();
let available = snap
.checked
.as_ref()
.map(|c| detect::is_newer(&c.manifest.version, c.manifest.ci_run, current, channel))
.unwrap_or(false);
// A spawned installer that hasn't resolved shows as a `restarting` job even though the
// in-process job died with the previous host (the console poller needs continuity here).
let job = snap
.job
.as_ref()
.map(|j| UpdateJobInfo {
target_version: j.target_version.clone(),
stage: j.stage.into(),
received_bytes: j.received_bytes,
total_bytes: j.total_bytes,
started_unix: j.started_unix,
})
.or_else(|| {
snap.applying_from_intent().map(|i| UpdateJobInfo {
target_version: i.to,
stage: "restarting".into(),
received_bytes: 0,
total_bytes: None,
started_unix: i.started_unix,
})
});
UpdateStatus {
install_kind: kind.as_str().into(),
channel: channel.as_str().into(),
current_version: current.into(),
apply: update::apply_support().into(),
channel_hint: detect::channel_hint(kind).into(),
check_disabled: update::check_disabled(),
available,
manifest: snap.checked.as_ref().map(|c| UpdateManifestInfo {
version: c.manifest.version.clone(),
serial: c.manifest.serial,
published_at: c.manifest.published_at.clone(),
notes_url: c.manifest.notes_url.clone(),
stale,
}),
last_checked_unix: snap.checked.as_ref().map(|c| c.fetched_unix),
last_error: snap.last_error,
opt_in_hint: update::opt_in_hint(),
job,
last_result: snap.last_result.as_ref().map(|r| UpdateResultInfo {
ok: r.ok,
from: r.from.clone(),
to: r.to.clone(),
finished_unix: r.finished_unix,
stage: r.stage.clone(),
error: r.error.clone(),
log_path: r.log_path.clone(),
staged: r.staged,
}),
}
}
/// Update-check status
///
/// How this host was installed, which channel it follows, whether a newer release is known,
/// and how to update. Reading this may kick a background refresh when the cached check is
/// older than 6 h; the response never blocks on the network.
#[utoipa::path(
get,
path = "/update/status",
tag = "update",
operation_id = "getUpdateStatus",
responses(
(status = OK, description = "Current update-check state", body = UpdateStatus),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_update_status() -> Json<UpdateStatus> {
Json(status_from(update::snapshot_and_maybe_refresh()))
}
/// Check for updates now
///
/// Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to
/// one forced check per 30 s.
#[utoipa::path(
post,
path = "/update/check",
tag = "update",
operation_id = "forceUpdateCheck",
responses(
(status = OK, description = "Refreshed update-check state (`last_error` carries a failed check)", body = UpdateStatus),
(status = CONFLICT, description = "Update checks are disabled on this host", body = ApiError),
(status = TOO_MANY_REQUESTS, description = "A forced check ran less than 30 s ago", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn force_update_check() -> Response {
match update::force_check().await {
Ok(snap) => Json(status_from(snap)).into_response(),
Err(update::ForceError::Disabled) => api_error(
StatusCode::CONFLICT,
"update checks are disabled on this host (PUNKTFUNK_UPDATE_CHECK=0)",
),
Err(update::ForceError::TooSoon) => api_error(
StatusCode::TOO_MANY_REQUESTS,
"a forced update check ran less than 30 s ago — try again shortly",
),
}
}
#[derive(Default, Deserialize, ToSchema)]
pub(crate) struct ApplyRequest {
/// Proceed even while a streaming session is live (the stream will drop when the host
/// restarts — the console warns before sending this).
#[serde(default)]
pub force: bool,
}
/// Apply the available update
///
/// Starts the one-click apply for install kinds that support it (Windows installer). The
/// request carries no version or URL — the host installs exactly what its verified manifest
/// announced. Progress is polled via `GET /update/status` (`job`); the host restarts as part
/// of the apply, and the outcome lands in `last_result` after it comes back.
#[utoipa::path(
post,
path = "/update/apply",
tag = "update",
operation_id = "applyUpdate",
request_body = ApplyRequest,
responses(
(status = ACCEPTED, description = "Apply started — poll `GET /update/status`", body = UpdateStatus),
(status = CONFLICT, description = "Refused: unsupported install kind, apply disabled (PUNKTFUNK_UPDATE_APPLY=0), a job already running, an active streaming session without `force`, or nothing newer to apply", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn apply_update(
State(st): State<Arc<MgmtState>>,
// The body is required (send `{}` for defaults) — axum has no optional-body extractor and
// the console always posts JSON here anyway.
ApiJson(req): ApiJson<ApplyRequest>,
) -> Response {
// Same session-liveness composition as `get_status`: either plane counts.
let session_active = st.app.streaming.load(std::sync::atomic::Ordering::SeqCst)
|| !crate::session_status::snapshot().is_empty();
match update::start_apply(req.force, session_active) {
Ok(()) => {
let snap = update::snapshot_and_maybe_refresh();
(StatusCode::ACCEPTED, Json(status_from(snap))).into_response()
}
Err(update::ApplyError::Unsupported) => api_error(
StatusCode::CONFLICT,
"this install kind has no one-click apply — use the update command shown in status",
),
Err(update::ApplyError::Disabled) => api_error(
StatusCode::CONFLICT,
"one-click apply is disabled on this host (PUNKTFUNK_UPDATE_APPLY=0)",
),
Err(update::ApplyError::JobRunning) => api_error(
StatusCode::CONFLICT,
"an update is already being applied — poll GET /update/status",
),
Err(update::ApplyError::SessionActive) => api_error(
StatusCode::CONFLICT,
"a streaming session is active — pass {\"force\": true} to update anyway (the stream will drop)",
),
Err(update::ApplyError::NothingToApply) => api_error(
StatusCode::CONFLICT,
"no newer release is known for this channel — run a check first",
),
}
}
+12
View File
@@ -1095,6 +1095,11 @@ async fn serve_session(
let adaptive_fec = fec_static_override().is_none();
let fec_target = Arc::new(AtomicU8::new(welcome.fec.fec_percent));
let fec_target_ctl = fec_target.clone();
// Phase-locked capture bridge (design/phase-locked-capture.md): the control task stores the
// client's PhaseReports here; the encode loop's controller drains them. Inert until a
// vsync-aware client actually reports.
let phase_ctl = Arc::new(stream::PhaseCtl::new());
let phase_ctl_control = phase_ctl.clone();
// The session's negotiated rate — the pin PyroWave retarget-refusals ack (§4.6).
let session_bitrate_kbps = welcome.bitrate_kbps;
// Shared-clipboard enable state (client `ClipControl` → host). The coordinator reads it to
@@ -1120,6 +1125,7 @@ async fn serve_session(
encoder_ceiling_kbps.clone(),
cadence_degraded.clone(),
fec_target_ctl,
phase_ctl_control,
reconfig_tx,
keyframe_tx,
rfi_tx,
@@ -1454,6 +1460,10 @@ async fn serve_session(
// Streamed-AU capability: the client's reassembler accepts sentinel-headed streamed blocks,
// so a chunked encoder session may ship an AU's early FEC blocks while its tail encodes.
let streamed_au = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_STREAMED_AU != 0;
// Multi-slice capability: the client's DECODER accepts AUs carrying several slice NALs, so
// the encoder may keep its multi-slice default (§7 LN1). Absent ⇒ single-slice frames —
// TV-SoC decoders (Amlogic: Chromecast with Google TV) wedge the device on multi-slice AUs.
let multi_slice = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE != 0;
let stats_dp = stats; // data-plane handle to the shared stats recorder
// Short label for web-console stats captures: the client's cert-fingerprint prefix, else its
// peer IP (no fingerprint = anonymous TOFU/--open client).
@@ -1564,6 +1574,7 @@ async fn serve_session(
probe_result_tx,
reconfig_result_tx,
fec_target: fec_target_dp,
phase: phase_ctl,
conn: conn_stream,
timing_conn,
cursor_forward,
@@ -1571,6 +1582,7 @@ async fn serve_session(
cursor_client_draws: cursor_client_draws_dp,
probe_seq,
streamed_au,
multi_slice,
stats: stats_dp,
client_label,
client_name,
@@ -30,6 +30,9 @@ pub(super) async fn run(
encoder_ceiling_kbps: Arc<AtomicU32>,
cadence_degraded: Arc<AtomicBool>,
fec_target_ctl: Arc<AtomicU8>,
// Phase-locked capture bridge: client PhaseReports land here latest-wins; the encode loop's
// controller drains at its own ~1 Hz cadence (design/phase-locked-capture.md).
phase_ctl: Arc<super::stream::PhaseCtl>,
reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
keyframe_tx: std::sync::mpsc::Sender<()>,
rfi_tx: std::sync::mpsc::Sender<(u32, u32)>,
@@ -230,6 +233,12 @@ pub(super) async fn run(
if io::write_msg(&mut ctrl_send, &echo.encode()).await.is_err() {
break;
}
} else if let Ok(pr) = punktfunk_core::quic::PhaseReport::decode(&msg) {
// Phase-locked capture: latest-wins into the bridge — no data-plane hop, the
// encode loop polls on its own cadence. Only vsync-aware presenters send
// these (CLIENT_CAP_PHASE_LOCK), and PUNKTFUNK_PHASE_LOCK=0 host-side leaves
// the stored report undrained/inert.
phase_ctl.store(pr);
} else if let Ok(m) = punktfunk_core::quic::CursorRenderMode::decode(&msg) {
// Who renders the pointer (design/remote-desktop-sweep.md §8): the client's
// mouse-model flip. Latest-wins into the shared flag; the data-plane loop
@@ -579,6 +579,10 @@ pub(super) async fn negotiate(
// The bit the Welcome just advertised — read back rather than recomputed, so the
// prepared display and the session wiring cannot disagree with it.
let cursor_fw = welcome.host_caps & punktfunk_core::quic::HOST_CAP_CURSOR != 0;
// Same bit the data plane's SessionContext reads — the prepared plan and the
// session wiring must agree on the slicing ceiling (an encoder rebuilt from the
// prepared plan with a DIFFERENT max_slices would change the wire shape mid-flow).
let multi_slice = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE != 0;
let (mode, shard_payload) = (hello.mode, welcome.shard_payload);
// "Automatic" — `bitrate_kbps` above is the host's own answer for `mode`, so the build
// may re-resolve it if the source turns out to deliver a different size. Sampled here
@@ -594,6 +598,7 @@ pub(super) async fn negotiate(
client_identity,
client_hdr,
cursor_fw,
multi_slice,
bitrate_kbps,
bitrate_auto,
bit_depth,
+161
View File
@@ -61,6 +61,7 @@ pub(super) fn synthetic_stream(
pts_ns,
host_us: (now_ns().saturating_sub(pts_ns) / 1000).min(u32::MAX as u64) as u32,
stages: None, // synthetic loop: no capture/encode stages to split
applied_phase_ns: None,
};
let _ = tc.send_datagram(punktfunk_core::quic::encode_host_timing_datagram(&t).into());
}
@@ -201,6 +202,112 @@ fn frame_driven_enabled() -> bool {
*ON.get_or_init(|| std::env::var("PUNKTFUNK_FRAME_DRIVEN").as_deref() != Ok("0"))
}
/// Phase-locked capture (design/phase-locked-capture.md): `PUNKTFUNK_PHASE_LOCK=0` disarms the
/// controller — the rebuild-free A/B lever. Armed alone it does nothing until a client actually
/// sends [`PhaseReport`](punktfunk_core::quic::PhaseReport)s.
fn phase_lock_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("PUNKTFUNK_PHASE_LOCK").as_deref() != Ok("0"))
}
/// Control-task → encode-loop bridge for phase-locked capture (the multi-field sibling of the
/// `fec_target` atomic): the control task stores the client's latest
/// [`PhaseReport`](punktfunk_core::quic::PhaseReport) (latest-wins), the encode loop drains it on
/// its ~1 Hz adjust tick, and publishes the hold it is applying for the 0xCF ACK + diagnostics.
pub(crate) struct PhaseCtl {
report: std::sync::Mutex<Option<punktfunk_core::quic::PhaseReport>>,
applied_ns: std::sync::atomic::AtomicI64,
}
impl PhaseCtl {
pub(crate) fn new() -> PhaseCtl {
PhaseCtl {
report: std::sync::Mutex::new(None),
applied_ns: std::sync::atomic::AtomicI64::new(0),
}
}
/// Latest-wins store (control task).
pub(crate) fn store(&self, r: punktfunk_core::quic::PhaseReport) {
*self
.report
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(r);
}
/// Drain the pending report, if any (encode loop, ~1 Hz).
fn take(&self) -> Option<punktfunk_core::quic::PhaseReport> {
self.report
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
}
fn set_applied(&self, ns: i64) {
self.applied_ns.store(ns, Ordering::Relaxed);
}
/// The hold currently applied (0 = idle) — the send thread's 0xCF ACK readout.
pub(crate) fn applied_ns(&self) -> i64 {
self.applied_ns.load(Ordering::Relaxed)
}
}
/// The encode loop's phase controller state (design/phase-locked-capture.md §3): a per-frame
/// HOLD before submit, walked toward the client's reported arrival lead hitting the target lead.
/// Plain data — lives as a loop local so it survives every in-loop rebuild path; a new session
/// (new loop call) starts unlocked, which is correct (new client, new grid).
struct PhaseController {
/// Applied per-frame hold before submit, ns ∈ [0, period).
hold_ns: i64,
/// Last adjust instant (~1 Hz cadence).
last_adjust: std::time::Instant,
}
impl PhaseController {
/// Per-adjustment walk bound: 2 ms per second of reports keeps the wire cadence visually
/// undisturbed while converging a worst-case half-period error in ~2-3 s.
const MAX_STEP_NS: i64 = 2_000_000;
/// Ignore errors under this — a locked loop does nothing (jitter would otherwise dither the
/// hold every second).
const DEADBAND_NS: i64 = 300_000;
/// The lead floor the controller drives toward: SurfaceFlinger-class compositors need the
/// frame in the queue ~2.5 ms before latch; the client's own `uncertainty_ns` widens this.
const TARGET_LEAD_FLOOR_NS: i64 = 2_500_000;
fn new() -> PhaseController {
PhaseController {
hold_ns: 0,
last_adjust: std::time::Instant::now(),
}
}
/// Fold the client's latest report into the hold. `period_ns` is the wire interval (the
/// session's frame period). Sign convention: `arrival_lead` is how long before its latch the
/// median frame arrives — a LARGE lead means frames sit waiting at the client, so the host
/// should send LATER (grow the hold); a small/zero lead risks missing the latch, so send
/// EARLIER (shrink the hold, wrapping through the period when it hits zero — the newest-wins
/// capture slot makes a wrapped hold sample a fresher frame, not a staler one).
fn adjust(&mut self, r: &punktfunk_core::quic::PhaseReport, period_ns: i64) {
if period_ns <= 0 {
return;
}
self.last_adjust = std::time::Instant::now();
let target = Self::TARGET_LEAD_FLOOR_NS.max(r.uncertainty_ns as i64 + 1_000_000);
let error = r.arrival_lead_ns as i64 - target;
if error.abs() < Self::DEADBAND_NS {
return;
}
let step = error.clamp(-Self::MAX_STEP_NS, Self::MAX_STEP_NS);
self.hold_ns = (self.hold_ns + step).rem_euclid(period_ns);
}
/// Whether the ~1 Hz adjust window has elapsed.
fn due(&self) -> bool {
self.last_adjust.elapsed() >= std::time::Duration::from_secs(1)
}
}
/// Adaptive pipeline depth (latency plan, from the 2026-07-17 on-glass finding on a `.173` RTX
/// 4090): the capturer's pipeline depth of 2 measured **~13 ms of glass-to-glass latency** over
/// depth 1 at 60 fps (17 ms → 4 ms) — the AU is ready in µs but depth-2 holds it a whole frame
@@ -526,6 +633,8 @@ fn send_loop(
// `Some` = the client advertised VIDEO_CAP_HOST_TIMING: emit one 0xCF datagram per AU right
// after its last packet left the socket (capture→sent, the whole host pipeline incl. pacing).
timing_conn: Option<quinn::Connection>,
// Phase-lock ACK source: the hold the encode loop currently applies rides the 0xCF tail.
phase: Arc<PhaseCtl>,
// The client advertised VIDEO_CAP_PROBE_SEQ — mid-session speed-test bursts may run in the
// probe index space (else they're declined; see `run_probe_burst`).
probe_seq: bool,
@@ -631,6 +740,13 @@ fn send_loop(
encode_us: msg.encode_us,
pace_us: stat.spread_us,
}),
// Phase-lock ACK: the hold the capture tick is applying
// right now (0 = controller idle/unarmed) — the client's
// closed-loop readout.
applied_phase_ns: Some(
phase.applied_ns().clamp(i32::MIN as i64, i32::MAX as i64)
as i32,
),
};
let _ = tc.send_datagram(
punktfunk_core::quic::encode_host_timing_datagram(&t).into(),
@@ -968,6 +1084,9 @@ pub(super) struct SessionContext {
/// thread emits one 0xCF datagram per AU (capture→sent µs) on it, so the client can split its
/// `host+network` latency stage. `None` = older client, no emission.
pub(super) timing_conn: Option<quinn::Connection>,
/// Phase-locked capture bridge (design/phase-locked-capture.md): the control task stores
/// client [`PhaseReport`]s here; the encode loop's controller drains them.
pub(super) phase: Arc<PhaseCtl>,
/// The session negotiated the cursor channel (design/remote-desktop-sweep.md M2 —
/// `handshake::cursor_forward`): the encode loop forwards shape (via `cursor_shape_tx`)
/// + per-tick `0xD0` state while the client draws the pointer locally.
@@ -992,6 +1111,12 @@ pub(super) struct SessionContext {
/// AU's FEC blocks under sentinel headers as the slices complete instead of waiting for the
/// whole AU. `false` = older client — chunks (if any) are drained whole-AU, zero wire change.
pub(super) streamed_au: bool,
/// The client advertised [`punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE`]: its decoder
/// accepts multi-slice AUs, so the session's encoder may keep its multi-slice default
/// (§7 LN1 — becomes [`SessionPlan::max_slices`](crate::session_plan::SessionPlan)).
/// `false` = single-slice frames, the pre-0.17 wire shape TV-SoC decoders (Amlogic —
/// Chromecast with Google TV) require to not wedge.
pub(super) multi_slice: bool,
/// Shared streaming-stats recorder. The capture loop reads `is_armed()` per frame to decide
/// whether to measure the per-stage split; the send thread builds + pushes the aggregated
/// `StatsSample` at its 2 s boundary.
@@ -1092,6 +1217,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
ctx.bit_depth,
),
ctx.cursor_forward,
ctx.multi_slice,
);
// gamescope: the XFixes cursor source feeds the host-side composite (Phase C) — unless the
// spawned gamescope paints the pointer into its node itself, in which case the reader would
@@ -1136,11 +1262,14 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
fec_target,
conn,
timing_conn,
phase,
cursor_forward,
cursor_shape_tx,
cursor_client_draws,
probe_seq,
streamed_au,
// Already folded into `plan.max_slices` by the resolve above — nothing below reads it.
multi_slice: _,
stats,
client_label,
client_name,
@@ -1475,6 +1604,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
.name("punktfunk-send".into())
.spawn({
let stop = stop.clone();
let phase_send = phase.clone();
move || {
send_loop(
session,
@@ -1487,6 +1617,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
fec_target,
send_stats,
timing_conn,
phase_send,
probe_seq,
)
}
@@ -1532,6 +1663,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds as u64);
let mut next = std::time::Instant::now();
let mut sent: u64 = 0;
// Phase-locked capture (design/phase-locked-capture.md): the per-frame hold this loop applies
// after a fresh capture, walked ~1 Hz toward the client's reported arrival lead. A loop local
// on purpose — it survives every in-loop rebuild path (session switch, mode/stall rebuilds,
// encoder backoff), so a mid-stream rebuild keeps the acquired lock.
let mut phase_ctl = PhaseController::new();
// The session's video frame numbering, owned HERE (the wire `frame_index` of the next AU this
// loop hands to the send thread; the packetizer seals with exactly this via `seal_frame_at`).
// A submission's future index is predicted as `au_seq + inflight.len()` — exact because AUs
@@ -2044,6 +2180,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
bit_depth,
plan.chroma,
plan.cursor_blend,
plan.max_slices,
) {
Ok(mut new_enc) => {
// The fresh encoder may have clamped to its codec-level ceiling —
@@ -2246,6 +2383,26 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
Ok(Some(f)) => {
frame = f;
diag_new += 1;
// Phase-locked capture: hold the fresh frame so its ARRIVAL at the client lands a
// constant small lead before the client's display latch (§3 hold-then-submit; the
// capture slot is newest-wins, so a long hold samples fresher content next tick,
// never staler). Adjusted ~1 Hz from the client's PhaseReports; 0 until a report
// arrives or when PUNKTFUNK_PHASE_LOCK=0.
if phase_lock_enabled() {
if phase_ctl.due() {
if let Some(r) = phase.take() {
phase_ctl.adjust(&r, interval.as_nanos() as i64);
} else {
phase_ctl.last_adjust = std::time::Instant::now();
}
phase.set_applied(phase_ctl.hold_ns);
}
if phase_ctl.hold_ns > 0 {
std::thread::sleep(std::time::Duration::from_nanos(
phase_ctl.hold_ns as u64,
));
}
}
capture_rebuilds = 0; // a delivered frame clears the consecutive-loss counter
// Re-arm the park schedule for a (re)built display: pin the seat pointer to
// the streamed surface (see `park_pointer` and the schedule state above).
@@ -3393,6 +3550,7 @@ fn try_inplace_resize(
bit_depth,
plan.chroma,
plan.cursor_blend,
plan.max_slices,
) {
Ok(e) => e,
Err(e) => {
@@ -3450,6 +3608,7 @@ pub(super) fn prepare_display(
client_identity: Option<[u8; 32]>,
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
cursor_forward: bool,
multi_slice: bool,
bitrate_kbps: u32,
// Passed through to [`build_pipeline`] — see its parameter of the same name.
bitrate_auto: bool,
@@ -3478,6 +3637,7 @@ pub(super) fn prepare_display(
bit_depth,
),
cursor_forward,
multi_slice,
);
plan.gamescope_cursor =
crate::session_plan::gamescope_cursor_for(compositor == pf_vdisplay::Compositor::Gamescope);
@@ -3899,6 +4059,7 @@ fn build_pipeline(
bit_depth,
plan.chroma,
plan.cursor_blend,
plan.max_slices,
)
.context("open video encoder")?;
if let Some(t) = trace {
+48
View File
@@ -569,6 +569,43 @@ fn grant_runner_secret_reads() {
);
}
}
// The runner's OWN bundle, in the install dir rather than under the config dir. Same reason as
// RUNNER_UNIT_DIRS: bun opens the file it is asked to run requesting FILE_WRITE_ATTRIBUTES on
// top of read, and {app}\scripting only carries Users:(RX), which LocalService reaches through
// Authenticated Users. So `bun runner-cli.js` died with
// error: EPERM reading "C:\Program Files\punktfunk\scripting\runner-cli.js"
// the task exited 1 within a second of every start, and the console showed the runner as
// enabled-but-not-running with nothing to explain it. The unit dirs were given (RX,WA) when
// that behaviour was first found on-glass; the entry script itself was missed, so the runner
// could never start at all. Verified on glass: without this the task is Ready/lastResult=1,
// with it the task is Running and `GET /store/runtime` reports running:true.
// WA touches timestamps and the read-only bit, never content, so "code is read-only — a plugin
// cannot rewrite itself" still holds for the runner's own bundle.
if let Some(dir) = runner_bundle_dir() {
let ok = Command::new(icacls_path())
.arg(&dir)
.args(["/grant:r", &format!("{LOCAL_SERVICE_SID}:(OI)(CI)(RX,WA)")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success());
if !ok {
eprintln!(
"warning: could not grant LocalService read on {} - the plugin runner will not \
start (bun exits EPERM on its own entry script)",
dir.display()
);
}
}
}
/// `{app}\scripting` — where the installer lays down `runner-cli.js` + `scripting-run.cmd`
/// (packaging/windows/punktfunk-host.iss), resolved from the running exe like
/// [`runner_command`] does. `None` when the exe path cannot be resolved; callers treat that as
/// "nothing to grant" rather than failing the whole enable.
#[cfg(target_os = "windows")]
fn runner_bundle_dir() -> Option<std::path::PathBuf> {
Some(std::env::current_exe().ok()?.parent()?.join("scripting"))
}
/// Best-effort removal of the LocalService read grants when the runner is switched off — the
@@ -606,6 +643,17 @@ fn revoke_runner_secret_reads() {
.stderr(std::process::Stdio::null())
.status();
}
// …and the bundle dir in the install tree, which is not under `cfg` so the loop above misses
// it. Removing the explicit ACE leaves the inherited Users:(RX) from Program Files, so it
// reverts to plain read-only rather than losing access altogether.
if let Some(dir) = runner_bundle_dir().filter(|d| d.exists()) {
let _ = Command::new(icacls_path())
.arg(&dir)
.args(["/remove:g", LOCAL_SERVICE_SID])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
/// Resolve icacls by full System32 path rather than PATH — same planted-binary reasoning as
@@ -132,6 +132,13 @@ pub struct SessionPlan {
/// redundant work producing a SECOND pointer. Resolved by [`cursor_blend_for`]'s sibling so
/// the two answers cannot disagree.
pub gamescope_cursor: bool,
/// Ceiling on the encoder's per-frame slice count, from the client's
/// [`VIDEO_CAP_MULTI_SLICE`](punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE): 32 (= no
/// client-side limit, the backend picks its own multi-slice default, §7 LN1) when the bit is
/// set, 1 (single-slice frames — the pre-0.17 wire shape TV-SoC decoders like Amlogic
/// require) when it isn't. Applied to EVERY encoder this plan opens (initial + all rebuilds)
/// so the slicing can never change shape across a mode/bitrate/stall rebuild.
pub max_slices: u32,
}
impl SessionPlan {
@@ -143,6 +150,7 @@ impl SessionPlan {
codec: crate::encode::Codec,
cursor_blend: bool,
cursor_forward: bool,
multi_slice: bool,
) -> Self {
SessionPlan {
capture: CaptureBackend::resolve(),
@@ -158,6 +166,7 @@ impl SessionPlan {
// Set by the resolve callers (they know the compositor); default off keeps every
// non-gamescope plan unchanged.
gamescope_cursor: false,
max_slices: if multi_slice { 32 } else { 1 },
}
}
+1
View File
@@ -150,6 +150,7 @@ pub fn run(opts: Options) -> Result<()> {
8, // spike synthetic harness: 8-bit
encode::ChromaFormat::Yuv420, // ...and 4:2:0
false, // synthetic frames carry no cursor
4, // no client decoder — keep the backend's multi-slice default
)
.context("open encoder")?;
+726
View File
@@ -0,0 +1,726 @@
//! Host **update check** (design `host-update-from-web-console.md`, phase U0).
//!
//! This module answers one question for the console: *does a newer host release exist for
//! this box's channel* — by fetching the per-channel signed manifest and verifying it against
//! the Ed25519 keys pinned below. It deliberately contains **no apply code**: U0 ships check
//! everywhere; apply legs land per-channel (U1 Windows, U2 Linux helper) behind the same
//! status surface.
//!
//! Shape: a process-wide cache + a lazy refresh. `GET /update/status` returns the cache and,
//! when it is older than [`AUTO_REFRESH_AFTER`], kicks a background refresh — the console
//! polls status anyway, so freshness needs no timer of its own. `POST /update/check` forces a
//! refresh, rate-limited to one per [`FORCE_MIN_INTERVAL`].
//!
//! Trust and failure rules live in [`manifest`]; the serial floor persisted here
//! (`update-state.json`) is what makes a replayed older manifest an *error*, not a silent
//! downgrade of our knowledge. `PUNKTFUNK_UPDATE_CHECK=0` disables all network activity —
//! status then reports `check_disabled` and carries whatever identity facts need no network.
pub(crate) mod detect;
pub(crate) mod jobs;
#[cfg(target_os = "linux")]
mod linux;
pub(crate) mod manifest;
#[cfg(target_os = "windows")]
pub(crate) mod windows;
use crate::store::index::PublicKey;
use manifest::Manifest;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
/// The Ed25519 public keys this binary trusts for update manifests — two slots so a key
/// rotation is "sign with the new one, ship a host trusting both, retire the old" (the
/// plugin-store `OFFICIAL_KEYS` drill). The private half is the `UPDATE_MANIFEST_KEY` CI
/// secret; it also lives in the operator's offline backup (plan U0.1 DoD).
pub(crate) const UPDATE_KEYS: [&str; 2] = [
"ed25519:6rmlLg1aQ55cgB6icpC5BEpbMJxwPKdGaDQtDcJ0yLI=",
"", // rotation slot
];
/// Feed base — `<base>/<channel>/manifest.json` + `.sig`. Override for tests/dev feeds via
/// `PUNKTFUNK_UPDATE_FEED` (a base URL, not request-time input: env is operator config).
const DEFAULT_FEED_BASE: &str = "https://git.unom.io/api/packages/unom/generic/punktfunk-update";
/// A cache older than this is refreshed in the background on the next status read.
const AUTO_REFRESH_AFTER: Duration = Duration::from_secs(6 * 60 * 60);
/// Forced checks (`POST /update/check`) are rate-limited to one per this interval.
pub(crate) const FORCE_MIN_INTERVAL: Duration = Duration::from_secs(30);
/// A manifest whose publish serial is older than this is flagged stale in status — the
/// freeze-detection hint (design §3.2), not an error.
const STALE_AFTER: Duration = Duration::from_secs(45 * 24 * 60 * 60);
/// One fetch's wall-clock budget (mirrors the store catalog fetch).
const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
/// Update checks disabled by operator config (env or `host.env`).
pub(crate) fn check_disabled() -> bool {
matches!(
std::env::var("PUNKTFUNK_UPDATE_CHECK").as_deref(),
Ok("0") | Ok("false") | Ok("off")
)
}
/// One-click apply disabled by operator config — the host-side kill switch (design §4.2): the
/// apply route 409s and status reports `notify` even on kinds an apply leg exists for. The
/// check surface is unaffected.
pub(crate) fn apply_disabled() -> bool {
matches!(
std::env::var("PUNKTFUNK_UPDATE_APPLY").as_deref(),
Ok("0") | Ok("false") | Ok("off")
)
}
/// What the console may offer for this install: `full` (one-click apply), `staged` (apply +
/// reboot to finish — rpm-ostree), or `notify` (show the command). Linux legs additionally
/// require the packaged root helper AND the operator's group opt-in; pacman also the
/// root-owned full-sysupgrade config (design §5).
pub(crate) fn apply_support() -> &'static str {
if apply_disabled() {
return "notify";
}
let (kind, _) = detect::detect();
match kind {
detect::InstallKind::WindowsInstaller => "full",
#[cfg(target_os = "linux")]
detect::InstallKind::Apt | detect::InstallKind::Dnf | detect::InstallKind::Sysext
if linux::helper_installed() && linux::opted_in() =>
{
"full"
}
#[cfg(target_os = "linux")]
detect::InstallKind::RpmOstree if linux::helper_installed() && linux::opted_in() => {
"staged"
}
#[cfg(target_os = "linux")]
detect::InstallKind::Pacman
if linux::helper_installed() && linux::opted_in() && linux::pacman_opted_in() =>
{
"full"
}
// The Deck source rebuild is user-owned — no helper, no group.
#[cfg(target_os = "linux")]
detect::InstallKind::SteamosSource => "full",
_ => "notify",
}
}
/// The opt-in instruction for status: this install COULD one-click apply (helper shipped)
/// but the operator hasn't joined the `punktfunk-update` group yet.
pub(crate) fn opt_in_hint() -> Option<String> {
#[cfg(target_os = "linux")]
{
let (kind, _) = detect::detect();
let capable = matches!(
kind,
detect::InstallKind::Apt
| detect::InstallKind::Dnf
| detect::InstallKind::Sysext
| detect::InstallKind::RpmOstree
| detect::InstallKind::Pacman
);
if capable && !apply_disabled() && linux::helper_installed() && !linux::opted_in() {
return Some(linux::opt_in_hint());
}
}
None
}
fn feed_base() -> String {
std::env::var("PUNKTFUNK_UPDATE_FEED")
.ok()
.filter(|s| s.starts_with("https://") || s.starts_with("http://127.0.0.1"))
.unwrap_or_else(|| DEFAULT_FEED_BASE.to_string())
}
fn pinned_keys() -> Vec<PublicKey> {
UPDATE_KEYS
.iter()
.filter(|k| !k.is_empty())
.filter_map(|k| PublicKey::parse(k).ok())
.collect()
}
// ---------------------------------------------------------------- runtime state
/// What the last successful refresh produced.
#[derive(Clone)]
pub(crate) struct Checked {
pub manifest: Manifest,
pub fetched_unix: u64,
}
#[derive(Default)]
struct Runtime {
checked: Option<Checked>,
last_error: Option<String>,
/// Refresh in flight (status kicks at most one).
refreshing: bool,
/// Wall-clock guard for the forced-check rate limit.
last_forced: Option<Instant>,
/// Last attempt of any kind — drives the auto-refresh cadence.
last_attempt: Option<Instant>,
/// The manifest version an `update.available` event was already emitted for, so a
/// steady-state "newer exists" doesn't re-announce every 6 h.
announced: Option<String>,
/// The live apply job, when one is running (single-flight).
job: Option<jobs::JobSnapshot>,
}
fn runtime() -> &'static Mutex<Runtime> {
static RT: OnceLock<Mutex<Runtime>> = OnceLock::new();
RT.get_or_init(|| Mutex::new(Runtime::default()))
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
// ---------------------------------------------------------------- serial floor
/// Persisted anti-rollback state: the highest manifest serial ever accepted per channel.
#[derive(Default, serde::Serialize, serde::Deserialize)]
struct FloorFile {
#[serde(default)]
serial_floor: std::collections::BTreeMap<String, u64>,
}
fn state_path() -> PathBuf {
pf_paths::config_dir().join("update-state.json")
}
fn load_floor(path: &Path, channel: &str) -> u64 {
std::fs::read(path)
.ok()
.and_then(|b| serde_json::from_slice::<FloorFile>(&b).ok())
.and_then(|f| f.serial_floor.get(channel).copied())
.unwrap_or(0)
}
/// Raise (never lower) the floor; atomic tmp+rename so a power cut can't half-write it.
fn store_floor(path: &Path, channel: &str, serial: u64) {
let mut file: FloorFile = std::fs::read(path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let slot = file.serial_floor.entry(channel.to_string()).or_insert(0);
if serial <= *slot {
return;
}
*slot = serial;
let Ok(bytes) = serde_json::to_vec_pretty(&file) else {
return;
};
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, &bytes).is_ok() {
let _ = std::fs::rename(&tmp, path);
}
}
// ---------------------------------------------------------------- refresh
/// Fetch + verify the channel manifest. Blocking (`ureq`) — call from a blocking thread.
fn fetch_manifest_blocking(channel: &str) -> Result<Manifest, String> {
let agent = ureq::AgentBuilder::new()
.timeout(FETCH_TIMEOUT)
// Follow the registry's 303-to-object-storage redirect; the signature is verified
// over the FINAL bytes (the sysext-feed lesson).
.redirects(3)
.user_agent(&format!(
"punktfunk-host/{} (update-check)",
env!("PUNKTFUNK_VERSION")
))
.build();
let base = feed_base();
let url = format!("{base}/{channel}/manifest.json");
let sig_url = format!("{url}.sig");
let body = read_capped(agent.get(&url).call().map_err(fetch_err)?)?;
let sig = read_capped(agent.get(&sig_url).call().map_err(fetch_err)?)?;
let sig_text = String::from_utf8(sig).map_err(|_| "signature file is not text".to_string())?;
let keys = pinned_keys();
if keys.is_empty() {
// Both slots empty would mean a build with the feature disarmed; refuse rather than
// silently skipping verification.
return Err("no update key is pinned in this build".into());
}
manifest::verify_and_parse(&body, &sig_text, &keys, channel).map_err(|e| format!("{e:#}"))
}
fn fetch_err(e: ureq::Error) -> String {
match e {
ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"),
other => format!("feed fetch failed: {other}"),
}
}
fn read_capped(resp: ureq::Response) -> Result<Vec<u8>, String> {
use std::io::Read as _;
let mut buf = Vec::new();
let mut reader = resp
.into_reader()
.take(manifest::MAX_MANIFEST_BYTES as u64 + 1);
reader
.read_to_end(&mut buf)
.map_err(|e| format!("read failed: {e}"))?;
if buf.len() > manifest::MAX_MANIFEST_BYTES {
return Err("response exceeds the manifest size cap".into());
}
Ok(buf)
}
/// One full refresh: fetch, verify, enforce + raise the serial floor, update the cache,
/// announce a newly available release on the event bus. Returns the user-facing error string
/// on failure (also cached for status).
pub(crate) fn refresh_blocking() -> Result<Checked, String> {
let (kind, channel) = detect::detect();
let result = fetch_manifest_blocking(channel.as_str()).and_then(|m| {
let path = state_path();
let floor = load_floor(&path, channel.as_str());
if m.serial < floor {
return Err(format!(
"manifest serial {} is older than the last accepted {} — refusing rollback",
m.serial, floor
));
}
store_floor(&path, channel.as_str(), m.serial);
Ok(m)
});
let mut rt = runtime().lock().unwrap();
rt.last_attempt = Some(Instant::now());
rt.refreshing = false;
match result {
Ok(m) => {
let checked = Checked {
manifest: m,
fetched_unix: now_unix(),
};
let newer = detect::is_newer(
&checked.manifest.version,
checked.manifest.ci_run,
env!("PUNKTFUNK_VERSION"),
channel,
);
if newer && rt.announced.as_deref() != Some(checked.manifest.version.as_str()) {
rt.announced = Some(checked.manifest.version.clone());
crate::events::emit(crate::events::EventKind::UpdateAvailable {
version: checked.manifest.version.clone(),
channel: channel.as_str().to_string(),
install_kind: kind.as_str().to_string(),
});
}
rt.last_error = None;
rt.checked = Some(checked.clone());
Ok(checked)
}
Err(e) => {
rt.last_error = Some(e.clone());
Err(e)
}
}
}
/// The status handler's read: current cache + errors, kicking a background refresh when the
/// cache is cold and checks are enabled.
pub(crate) fn snapshot_and_maybe_refresh() -> Snapshot {
let mut kick = false;
let snap = {
let mut rt = runtime().lock().unwrap();
let cold = rt
.last_attempt
.map(|t| t.elapsed() >= AUTO_REFRESH_AFTER)
.unwrap_or(true);
if cold && !rt.refreshing && !check_disabled() {
rt.refreshing = true;
rt.last_attempt = Some(Instant::now());
kick = true;
}
Snapshot {
checked: rt.checked.clone(),
last_error: rt.last_error.clone(),
job: rt.job.clone(),
last_result: jobs::read_result(&jobs::result_path()),
}
};
if kick {
// Fire-and-forget; the console's next poll reads the outcome.
tokio::task::spawn_blocking(|| {
let _ = refresh_blocking();
});
}
snap
}
/// A forced check (`POST /update/check`): rate-limited, blocking until the refresh finishes.
pub(crate) async fn force_check() -> Result<Snapshot, ForceError> {
if check_disabled() {
return Err(ForceError::Disabled);
}
{
let mut rt = runtime().lock().unwrap();
if let Some(t) = rt.last_forced {
if t.elapsed() < FORCE_MIN_INTERVAL {
return Err(ForceError::TooSoon);
}
}
rt.last_forced = Some(Instant::now());
rt.refreshing = true;
}
let _ = tokio::task::spawn_blocking(refresh_blocking).await;
let rt = runtime().lock().unwrap();
Ok(Snapshot {
checked: rt.checked.clone(),
last_error: rt.last_error.clone(),
job: rt.job.clone(),
last_result: jobs::read_result(&jobs::result_path()),
})
}
pub(crate) enum ForceError {
Disabled,
TooSoon,
}
// ---------------------------------------------------------------- apply (U1: Windows)
/// Why an apply request was refused (mapped to 409s by the API layer).
pub(crate) enum ApplyError {
/// This install kind has no one-click leg (or the operator kill switch is on) — the
/// console shows the command instead.
Unsupported,
/// `PUNKTFUNK_UPDATE_APPLY=0`.
Disabled,
/// An apply is already running (or a spawned installer hasn't resolved yet).
JobRunning,
/// A stream is live and the request didn't say `force`.
SessionActive,
/// No verified manifest announcing something newer (or it lacks the Windows asset).
NothingToApply,
}
/// Start the (Windows) apply pipeline. The request carries **no version, url, or channel** —
/// everything comes from the verified cached manifest (invariant §0.3 of the design).
pub(crate) fn start_apply(force: bool, session_active: bool) -> Result<(), ApplyError> {
if apply_disabled() {
return Err(ApplyError::Disabled);
}
let (kind, channel) = detect::detect();
let windows_leg = kind == detect::InstallKind::WindowsInstaller;
let linux_leg = matches!(
kind,
detect::InstallKind::Apt
| detect::InstallKind::Dnf
| detect::InstallKind::Sysext
| detect::InstallKind::RpmOstree
| detect::InstallKind::Pacman
| detect::InstallKind::SteamosSource
);
if !windows_leg && !linux_leg {
return Err(ApplyError::Unsupported);
}
#[cfg(target_os = "linux")]
if linux_leg && kind != detect::InstallKind::SteamosSource {
// The Deck source rebuild is user-owned and needs no root helper; every other Linux
// leg goes through it.
if !linux::helper_installed() {
return Err(ApplyError::Unsupported);
}
// The pacman leg additionally requires the root-owned full-sysupgrade opt-in — the
// helper enforces it too; refusing here keeps the console honest before any spawn.
if kind == detect::InstallKind::Pacman && !linux::pacman_opted_in() {
return Err(ApplyError::Unsupported);
}
}
#[cfg(not(target_os = "linux"))]
if linux_leg {
return Err(ApplyError::Unsupported);
}
if session_active && !force {
return Err(ApplyError::SessionActive);
}
let (target_version, serial, asset) = {
let mut rt = runtime().lock().unwrap();
if rt.job.is_some() {
return Err(ApplyError::JobRunning);
}
// A spawned installer that hasn't resolved (fresh intent, old version) is still an
// apply in flight — reconcile owns it; don't start a second one under it.
if matches!(
jobs::reconcile(
jobs::read_intent(&jobs::intent_path()),
env!("PUNKTFUNK_VERSION"),
now_unix()
),
jobs::Reconciled::StillApplying
) {
return Err(ApplyError::JobRunning);
}
let Some(checked) = rt.checked.as_ref() else {
return Err(ApplyError::NothingToApply);
};
let newer = detect::is_newer(
&checked.manifest.version,
checked.manifest.ci_run,
env!("PUNKTFUNK_VERSION"),
channel,
);
if !newer {
return Err(ApplyError::NothingToApply);
}
// Only the Windows leg needs the manifest's installer asset; the Linux legs resolve
// artifacts through the package manager.
let asset = checked.manifest.windows_host.clone();
if windows_leg && asset.is_none() {
return Err(ApplyError::NothingToApply);
}
let version = checked.manifest.version.clone();
let serial = checked.manifest.serial;
rt.job = Some(jobs::JobSnapshot {
target_version: version.clone(),
stage: if windows_leg {
"downloading"
} else {
"applying"
},
received_bytes: 0,
total_bytes: None,
started_unix: now_unix(),
});
(version, serial, asset)
};
tokio::task::spawn_blocking(move || {
let stage = |s: &'static str| {
let mut rt = runtime().lock().unwrap();
if let Some(job) = rt.job.as_mut() {
job.stage = s;
}
};
let outcome: Result<PostApply, (&'static str, String)> = {
#[cfg(target_os = "windows")]
{
let progress = |received: u64, total: Option<u64>| {
let mut rt = runtime().lock().unwrap();
if let Some(job) = rt.job.as_mut() {
job.received_bytes = received;
job.total_bytes = total;
}
};
let asset = asset.expect("windows leg reserved with an asset");
windows::run_apply(&asset, &target_version, serial, &progress, &stage)
.map(|()| PostApply::AwaitRestart)
}
#[cfg(target_os = "linux")]
{
let _ = &asset; // the Linux legs resolve through the package manager
let run = if detect::detect().0 == detect::InstallKind::SteamosSource {
linux::run_apply_steamos(&target_version, serial, &stage)
} else {
linux::run_apply(&target_version, serial, &stage)
};
run.map(|()| {
// Staged / nothing-to-do wrote a durable result and this process lives
// on; an in-place change wrote the intent and our restart is queued.
// Either way the in-process job is finished.
PostApply::Done
})
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
let _ = (&asset, &target_version, serial, &stage);
Err(("applying", "no apply leg for this platform".to_string()))
}
};
match outcome {
Ok(PostApply::AwaitRestart) => {
// Stage stays `restarting`; the installer is about to stop the service and
// kill this process. Boot reconciliation writes the durable outcome.
}
Ok(PostApply::Done) => {
runtime().lock().unwrap().job = None;
}
Err((stage_name, error)) => {
let record = jobs::ResultRecord {
ok: false,
from: env!("PUNKTFUNK_VERSION").into(),
to: target_version.clone(),
finished_unix: now_unix(),
stage: Some(stage_name.into()),
error: Some(error),
log_path: None,
staged: false,
};
let _ = jobs::write_json_atomic(&jobs::result_path(), &record);
runtime().lock().unwrap().job = None;
}
}
});
Ok(())
}
/// What an apply leg leaves behind for the spawn wrapper. (Each platform constructs only
/// its own variant; the other is matched-but-never-built there.)
#[allow(dead_code)]
enum PostApply {
/// The process is about to die (installer / self-restart); reconcile owns the outcome.
AwaitRestart,
/// The leg finished in-process (staged, nothing-to-do) — clear the job.
Done,
}
/// Boot-time reconciliation (design §4.2): close out an intent record left by a previous
/// apply. Called once from `mgmt::run` before the API serves.
pub(crate) fn reconcile_at_boot() {
let path = jobs::intent_path();
let intent = jobs::read_intent(&path);
// Read off the intent BEFORE `reconcile` consumes it. Restored on both terminal outcomes: a
// rolled-back or aborted install killed the tray just as thoroughly as a successful one. NOT on
// StillApplying — the installer may still be running and would only kill it again.
let restore_tray = intent.as_ref().is_some_and(|i| i.tray_was_running);
match jobs::reconcile(intent, env!("PUNKTFUNK_VERSION"), now_unix()) {
jobs::Reconciled::None | jobs::Reconciled::StillApplying => {}
jobs::Reconciled::Success(record) => {
tracing::info!(from = %record.from, to = %record.to, "host update applied");
let _ = jobs::write_json_atomic(&jobs::result_path(), &record);
let _ = std::fs::remove_file(&path);
crate::events::emit(crate::events::EventKind::UpdateApplied {
from: record.from,
to: record.to,
});
}
jobs::Reconciled::Failed(record) => {
tracing::warn!(
from = %record.from,
to = %record.to,
error = record.error.as_deref().unwrap_or(""),
"host update did NOT stick"
);
let _ = jobs::write_json_atomic(&jobs::result_path(), &record);
let _ = std::fs::remove_file(&path);
}
}
#[cfg(target_os = "windows")]
if restore_tray {
windows::relaunch_tray();
}
#[cfg(not(target_os = "windows"))]
let _ = restore_tray; // the Linux packages never kill a running tray
}
/// What status hands to the API layer.
pub(crate) struct Snapshot {
pub checked: Option<Checked>,
pub last_error: Option<String>,
/// The live apply job, when one runs. When the process was restarted mid-apply this is
/// `None` but a fresh intent still reads as in-flight — the API layer surfaces that via
/// [`Snapshot::applying_from_intent`].
pub job: Option<jobs::JobSnapshot>,
/// Durable outcome of the most recent apply attempt.
pub last_result: Option<jobs::ResultRecord>,
}
impl Snapshot {
/// An apply is in flight even though no in-process job exists: a fresh intent record from
/// a spawn that hasn't resolved (this process may be the OLD host in its last seconds, or
/// a restarted host inside the grace window). The API surfaces it as a `restarting` job.
pub(crate) fn applying_from_intent(&self) -> Option<jobs::IntentRecord> {
if self.job.is_some() {
return None;
}
let intent = jobs::read_intent(&jobs::intent_path())?;
match jobs::reconcile(Some(intent.clone()), env!("PUNKTFUNK_VERSION"), now_unix()) {
jobs::Reconciled::StillApplying => Some(intent),
_ => None,
}
}
/// The stale-feed hint: last successful check is fine but the manifest itself was
/// published suspiciously long ago (freeze detection, design §3.2).
pub(crate) fn stale(&self) -> bool {
self.checked
.as_ref()
.map(|c| now_unix().saturating_sub(c.manifest.serial) > STALE_AFTER.as_secs())
.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn floor_roundtrip_and_monotonicity() {
let dir = std::env::temp_dir().join(format!("pf-update-floor-{}", std::process::id()));
let path = dir.join("update-state.json");
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(load_floor(&path, "stable"), 0);
store_floor(&path, "stable", 100);
assert_eq!(load_floor(&path, "stable"), 100);
// Lowering is a no-op.
store_floor(&path, "stable", 50);
assert_eq!(load_floor(&path, "stable"), 100);
// Channels are independent.
store_floor(&path, "canary", 7);
assert_eq!(load_floor(&path, "canary"), 7);
assert_eq!(load_floor(&path, "stable"), 100);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_floor_file_reads_as_zero() {
let dir = std::env::temp_dir().join(format!("pf-update-floor2-{}", std::process::id()));
let path = dir.join("update-state.json");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(&path, b"not json").unwrap();
assert_eq!(load_floor(&path, "stable"), 0);
// And writing over it recovers.
store_floor(&path, "stable", 5);
assert_eq!(load_floor(&path, "stable"), 5);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn pinned_keys_skip_empty_rotation_slot() {
let keys = pinned_keys();
assert_eq!(keys.len(), 1, "one live key, one empty rotation slot");
}
#[test]
fn stale_math() {
let mk = |serial| Snapshot {
checked: Some(Checked {
manifest: manifest::parse_verified(
serde_json::to_vec(&serde_json::json!({
"schema": 1, "channel": "stable", "serial": serial,
"version": "0.23.0",
"notes_url": "https://git.unom.io/unom/punktfunk/releases",
}))
.unwrap()
.as_slice(),
"stable",
)
.unwrap(),
fetched_unix: now_unix(),
}),
last_error: None,
job: None,
last_result: None,
};
assert!(!mk(now_unix()).stale());
assert!(mk(now_unix() - STALE_AFTER.as_secs() - 10).stale());
}
}
+420
View File
@@ -0,0 +1,420 @@
//! **How was this host installed, and on which channel?** (design §4.1)
//!
//! The apply strategy — and, until apply lands, the command hint the console shows — hangs
//! off the install kind. Detection is a ladder over root-owned facts: packaging writes a
//! marker (`/usr/share/punktfunk/install-kind`, e.g. `apt stable`), the sysext self-identifies
//! via its merged extension-release, Nix by store path, and so on. The API only ever *reads*
//! this; nothing request-side can influence it.
//!
//! The ladder itself is a pure function over a [`Probe`] so every branch is unit-testable;
//! [`detect`] gathers the real probe once per process.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// Where the Linux packages stamp how they were installed. First word = kind
/// (`apt`|`dnf`|`pacman`), optional second word = channel (`stable`|`canary`).
const MARKER_PATH: &str = "/usr/share/punktfunk/install-kind";
/// The merged sysext names itself here (written by `build-sysext.sh`); its presence means the
/// running `/usr` overlay came from the sysext image, regardless of any leftover marker.
const SYSEXT_MARKER: &str = "/usr/lib/extension-release.d/extension-release.punktfunk";
/// The sysext updater's own config (`CHANNEL=stable|canary`).
const SYSEXT_CONF: &str = "/etc/punktfunk-sysext.conf";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InstallKind {
WindowsInstaller,
Sysext,
RpmOstree,
Apt,
Dnf,
Pacman,
SteamosSource,
Nix,
Source,
}
impl InstallKind {
pub(crate) fn as_str(self) -> &'static str {
match self {
InstallKind::WindowsInstaller => "windows-installer",
InstallKind::Sysext => "sysext",
InstallKind::RpmOstree => "rpm-ostree",
InstallKind::Apt => "apt",
InstallKind::Dnf => "dnf",
InstallKind::Pacman => "pacman",
InstallKind::SteamosSource => "steamos-source",
InstallKind::Nix => "nix",
InstallKind::Source => "source",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Channel {
Stable,
Canary,
}
impl Channel {
pub(crate) fn as_str(self) -> &'static str {
match self {
Channel::Stable => "stable",
Channel::Canary => "canary",
}
}
}
/// The root-owned facts the ladder reads, gathered once by [`gather`] (tests build these
/// directly).
#[derive(Debug, Default)]
pub(crate) struct Probe {
/// Running on Windows (cfg, not a file).
pub windows: bool,
/// The running exe's path.
pub exe: PathBuf,
/// `$HOME`, if any.
pub home: Option<PathBuf>,
/// Contents of [`MARKER_PATH`], if present.
pub marker: Option<String>,
/// [`SYSEXT_MARKER`] exists (merged sysext overlay).
pub sysext: bool,
/// Contents of [`SYSEXT_CONF`], if present.
pub sysext_conf: Option<String>,
/// `/run/ostree-booted` exists (rpm-ostree / bootc family).
pub ostree_booted: bool,
}
fn gather() -> Probe {
Probe {
windows: cfg!(target_os = "windows"),
exe: std::env::current_exe().unwrap_or_default(),
home: std::env::var_os("HOME").map(PathBuf::from),
marker: std::fs::read_to_string(MARKER_PATH).ok(),
sysext: Path::new(SYSEXT_MARKER).exists(),
sysext_conf: std::fs::read_to_string(SYSEXT_CONF).ok(),
ostree_booted: Path::new("/run/ostree-booted").exists(),
}
}
/// The ladder (design §4.1). Order matters and each rung is a root-owned fact:
/// sysext overlay > Nix store path > dev/source tree > user-owned Deck build > package
/// marker (flipped to rpm-ostree when the box is ostree-booted) > `source` fallback.
pub(crate) fn classify(p: &Probe) -> (InstallKind, Channel) {
if p.windows {
// The installer is the only supported Windows delivery; a loose cargo build shows
// itself by not living under Program Files. Channel: canary installers carry the CI
// run as the third component (`M.m.<run>`), see `windows_channel_of`.
let installed = p
.exe
.to_string_lossy()
.to_ascii_lowercase()
.contains("\\program files\\punktfunk");
return if installed {
(
InstallKind::WindowsInstaller,
windows_channel_of(env!("PUNKTFUNK_VERSION")),
)
} else {
(InstallKind::Source, Channel::Stable)
};
}
if p.sysext {
let channel = p
.sysext_conf
.as_deref()
.and_then(conf_channel)
.unwrap_or(Channel::Stable);
return (InstallKind::Sysext, channel);
}
if p.exe.starts_with("/nix/store") {
return (InstallKind::Nix, Channel::Stable);
}
// A cargo tree anywhere (CI, dev box, the Deck checkout mid-build) is `source`; the
// Deck's install script runs the binary out of `~/punktfunk/target-steamos/`, which is
// user-owned but NOT a plain `target/` dir — that distinction is the marker here.
let exe_str = p.exe.to_string_lossy().to_string();
if exe_str.contains("/target/") {
return (InstallKind::Source, Channel::Stable);
}
if let Some(home) = &p.home {
if p.exe.starts_with(home) {
return (InstallKind::SteamosSource, Channel::Canary);
}
}
if let Some(marker) = &p.marker {
let mut words = marker.split_whitespace();
let kind = words.next().unwrap_or("");
let channel = match words.next() {
Some("canary") => Channel::Canary,
_ => Channel::Stable,
};
let kind = match kind {
"apt" => Some(InstallKind::Apt),
// An ostree-booted box consumed the RPM by layering (or an image build); either
// way `dnf upgrade` is not how it updates. bootc-vs-layered is refined in U2 via
// `rpm-ostree status` — until then both report `rpm-ostree` (notify text is
// identical in U0).
"dnf" if p.ostree_booted => Some(InstallKind::RpmOstree),
"dnf" => Some(InstallKind::Dnf),
"pacman" => Some(InstallKind::Pacman),
_ => None,
};
if let Some(kind) = kind {
return (kind, channel);
}
}
(InstallKind::Source, Channel::Stable)
}
/// `CHANNEL=canary` in `/etc/punktfunk-sysext.conf` (the sysext updater's own format).
fn conf_channel(conf: &str) -> Option<Channel> {
for line in conf.lines() {
if let Some(v) = line.trim().strip_prefix("CHANNEL=") {
return Some(match v.trim() {
"canary" => Channel::Canary,
_ => Channel::Stable,
});
}
}
None
}
/// Windows canary installers are versioned `M.m.<run>` where `<run>` is a 4+ digit CI run
/// number; stable patch numbers stay small. Heuristic, documented in the plan (R10).
fn windows_channel_of(version: &str) -> Channel {
match triple(version) {
Some((_, _, patch)) if patch >= 1000 => Channel::Canary,
_ => Channel::Stable,
}
}
/// The process-wide answer, computed once.
pub(crate) fn detect() -> (InstallKind, Channel) {
static DETECTED: OnceLock<(InstallKind, Channel)> = OnceLock::new();
*DETECTED.get_or_init(|| classify(&gather()))
}
// ---------------------------------------------------------------- version comparison
/// Leading `major.minor.patch` of a version string, ignoring any suffix (`~ci…`, `-1`, `+…`).
pub(crate) fn triple(v: &str) -> Option<(u64, u64, u64)> {
let mut parts = v
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty());
// Split on any non-digit: "0.23.0~ci10250.gab" → 0,23,0,10250… — take the first three
// ONLY if the string actually starts with digits (else it's not a version at all).
if !v.starts_with(|c: char| c.is_ascii_digit()) {
return None;
}
Some((
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
))
}
/// The CI run number embedded in a canary version string, wherever the channel's format hid
/// it: `0.23.0~ci10250.g<sha>` (deb), `0.23.0-0.ci10250.g<sha>` (rpm), `0.23.10250`
/// (Windows/decky style, run-as-patch). A stable string yields `None`.
pub(crate) fn canary_run(version: &str) -> Option<u64> {
// `ci` immediately followed by digits, anywhere.
let mut rest = version;
while let Some(pos) = rest.find("ci") {
let digits: String = rest[pos + 2..]
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if !digits.is_empty() {
return digits.parse().ok();
}
rest = &rest[pos + 2..];
}
match triple(version) {
Some((_, _, patch)) if patch >= 1000 => Some(patch),
_ => None,
}
}
/// Is the manifest's release newer than what this process runs? Definitive-or-false: an
/// unparseable pair never flags (the console still shows both version strings — the badge
/// just doesn't light up on guesswork). Canary compares `(major, minor)` then the CI run,
/// because canary patch fields mean different things per channel (R10).
pub(crate) fn is_newer(
manifest_version: &str,
manifest_ci_run: Option<u64>,
current: &str,
channel: Channel,
) -> bool {
let (Some(m), Some(c)) = (triple(manifest_version), triple(current)) else {
return false;
};
match channel {
Channel::Stable => m > c,
Channel::Canary => {
if (m.0, m.1) != (c.0, c.1) {
return (m.0, m.1) > (c.0, c.1);
}
let manifest_run = manifest_ci_run.or_else(|| canary_run(manifest_version));
match (manifest_run, canary_run(current)) {
(Some(mr), Some(cr)) => mr > cr,
_ => false,
}
}
}
}
/// The per-kind "how to update" command the console shows while (or instead of) an apply
/// path existing (design §5). One line, copy-pastable, no placeholders.
pub(crate) fn channel_hint(kind: InstallKind) -> &'static str {
match kind {
InstallKind::WindowsInstaller => {
"winget upgrade unom.PunktfunkHost (or re-run the newer installer)"
}
InstallKind::Sysext => "sudo punktfunk-sysext update",
InstallKind::RpmOstree => {
"sudo /usr/share/punktfunk/update-punktfunk.sh (staged; reboot to finish)"
}
InstallKind::Apt => "sudo apt update && sudo apt install --only-upgrade punktfunk-host",
InstallKind::Dnf => "sudo dnf upgrade punktfunk",
InstallKind::Pacman => "sudo pacman -Syu",
InstallKind::SteamosSource => "bash ~/punktfunk/scripts/steamdeck/update.sh --pull",
InstallKind::Nix => "nix flake update punktfunk (then rebuild your system)",
InstallKind::Source => "git pull && cargo build --release -p punktfunk-host",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn probe() -> Probe {
Probe {
windows: false,
exe: PathBuf::from("/usr/bin/punktfunk-host"),
home: Some(PathBuf::from("/home/deck")),
..Default::default()
}
}
#[test]
fn ladder_sysext_beats_marker() {
let mut p = probe();
p.sysext = true;
p.marker = Some("dnf canary".into());
p.sysext_conf = Some("CHANNEL=canary\n".into());
assert_eq!(classify(&p), (InstallKind::Sysext, Channel::Canary));
p.sysext_conf = None;
assert_eq!(classify(&p), (InstallKind::Sysext, Channel::Stable));
}
#[test]
fn ladder_nix_store_path() {
let mut p = probe();
p.exe = PathBuf::from("/nix/store/abc123-punktfunk-host-0.22.2/bin/punktfunk-host");
assert_eq!(classify(&p).0, InstallKind::Nix);
}
#[test]
fn ladder_cargo_target_is_source_even_under_home() {
let mut p = probe();
p.exe = PathBuf::from("/home/deck/punktfunk/target/release/punktfunk-host");
assert_eq!(classify(&p).0, InstallKind::Source);
}
#[test]
fn ladder_deck_build_is_steamos_source() {
let mut p = probe();
p.exe = PathBuf::from("/home/deck/punktfunk/target-steamos/release/punktfunk-host");
assert_eq!(classify(&p).0, InstallKind::SteamosSource);
}
#[test]
fn ladder_markers() {
for (marker, ostree, kind, channel) in [
("apt stable", false, InstallKind::Apt, Channel::Stable),
("apt canary", false, InstallKind::Apt, Channel::Canary),
("dnf stable", false, InstallKind::Dnf, Channel::Stable),
("dnf stable", true, InstallKind::RpmOstree, Channel::Stable),
("pacman canary", false, InstallKind::Pacman, Channel::Canary),
] {
let mut p = probe();
p.marker = Some(marker.into());
p.ostree_booted = ostree;
assert_eq!(classify(&p), (kind, channel), "marker `{marker}`");
}
}
#[test]
fn ladder_unknown_marker_falls_through_to_source() {
let mut p = probe();
p.marker = Some("snap stable".into());
assert_eq!(classify(&p).0, InstallKind::Source);
}
#[test]
fn triples() {
assert_eq!(triple("0.23.0"), Some((0, 23, 0)));
assert_eq!(triple("0.23.0~ci10250.gab12cd34"), Some((0, 23, 0)));
assert_eq!(triple("0.23.10250"), Some((0, 23, 10250)));
assert_eq!(triple("garbage"), None);
assert_eq!(triple("1.2"), None);
}
#[test]
fn canary_runs() {
assert_eq!(canary_run("0.23.0~ci10250.gab12cd34"), Some(10250));
assert_eq!(canary_run("0.23.0-0.ci777.g12345678"), Some(777));
assert_eq!(canary_run("0.23.10250"), Some(10250)); // run-as-patch (Windows/decky)
assert_eq!(canary_run("0.23.0"), None); // stable string
assert_eq!(canary_run("0.23.0-1"), None);
}
#[test]
fn newer_stable() {
assert!(is_newer("0.23.0", None, "0.22.2", Channel::Stable));
assert!(!is_newer("0.22.2", None, "0.22.2", Channel::Stable));
assert!(!is_newer("0.22.1", None, "0.22.2", Channel::Stable)); // downgrade never flags
assert!(!is_newer("not-a-version", None, "0.22.2", Channel::Stable));
}
#[test]
fn newer_canary_compares_runs_not_patch() {
// deb canary current vs Windows-style manifest version, same run ⇒ NOT newer,
// even though a naive triple compare says 10250 > 0.
assert!(!is_newer(
"0.23.10250",
Some(10250),
"0.23.0~ci10250.gab12cd34",
Channel::Canary
));
assert!(is_newer(
"0.23.10251",
Some(10251),
"0.23.0~ci10250.gab12cd34",
Channel::Canary
));
// Minor bump wins outright.
assert!(is_newer(
"0.24.100",
Some(100),
"0.23.0~ci10250.g12",
Channel::Canary
));
// No run extractable on either side ⇒ conservative false.
assert!(!is_newer("0.23.10250", None, "0.23.0", Channel::Canary));
}
#[test]
fn windows_channel_heuristic() {
assert_eq!(windows_channel_of("0.22.2"), Channel::Stable);
assert_eq!(windows_channel_of("0.23.10118"), Channel::Canary);
}
}
+291
View File
@@ -0,0 +1,291 @@
//! Apply-job bookkeeping: the in-memory job snapshot the console polls, and the two on-disk
//! records that let an update **report its own outcome across its own restart** (design §4.2,
//! plan U1.1):
//!
//! - the **intent record** (`update-intent.json`) — written just before anything irreversible
//! (spawning the installer). It is the only witness once the installer kills this process.
//! - the **result record** (`update-result.json`) — the durable outcome of the *last* apply,
//! written either by the failing stage itself or by boot-time reconciliation.
//!
//! Reconciliation ([`reconcile`]) runs once per boot: intent + running-the-target-version ⇒
//! success; intent + still the old version after the grace window ⇒ failure with the installer
//! log path attached; a fresh intent ⇒ the apply is still in flight (the installer may not have
//! stopped us yet). Pure over its inputs so every branch is table-testable.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// How long after intent-write we still call the apply "in flight" when the running version is
/// unchanged. Beyond it, the host restarting *without* the new version is a failed apply
/// (installer aborted, rolled back, or never ran) — surfaced, never silent.
pub(crate) const APPLY_GRACE_SECS: u64 = 10 * 60;
/// Written immediately before the point of no return.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct IntentRecord {
/// The version that started the apply (us, at write time).
pub from: String,
/// The version being installed.
pub to: String,
/// The manifest serial that announced it.
pub serial: u64,
/// Unix seconds at write.
pub started_unix: u64,
/// SHA-256 of the verified installer (diagnostics — ties a result to exact bytes).
pub installer_sha256: String,
/// Where the installer was told to log (`/LOG=`).
pub log_path: String,
/// A source rebuild (Steam Deck `update.sh`), where version equality proves nothing —
/// the workspace version only moves on bumps. The flow's own ordering carries the
/// proof instead: the script restarts the host ONLY after a successful build+install,
/// and its failure path is reported live (the host survives a failed build). So an
/// intent with this flag still present at boot ⟹ the rebuild succeeded.
#[serde(default)]
pub source_build: bool,
/// A per-user status tray was running when we spawned the installer, so boot reconciliation
/// should put it back.
///
/// The installer's `StopTrays` force-kills every session's `punktfunk-tray.exe` (it is one of
/// the files being replaced), and its `[Run]` relaunch carries `skipifsilent` — which a
/// console-initiated update, spawned with `/VERYSILENT`, always trips. The tray therefore died
/// on every in-console update and stayed dead until the next sign-in. The installer cannot fix
/// this itself: spawned from the SYSTEM host service, its `runasoriginaluser` resolves to
/// SYSTEM, which would put a SYSTEM-owned tray in the user's session squatting the
/// `Local\PunktfunkTray` mutex and blocking the real one. The host relaunches it instead — it
/// already owns the `WTSQueryUserToken` primitive for landing a process in the interactive
/// session as the logged-in user.
///
/// `#[serde(default)]`: an intent written by an older host reads as false (no relaunch), which
/// is the pre-existing behaviour.
#[serde(default)]
pub tray_was_running: bool,
}
/// The durable outcome of the most recent apply attempt.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ResultRecord {
pub ok: bool,
pub from: String,
pub to: String,
pub finished_unix: u64,
/// The stage that failed (`downloading` | `verifying` | `applying` | `restarting`);
/// absent on success.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// The installer log, when one was in play by the time it failed (or succeeded).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_path: Option<String>,
/// The update is applied but activates on the next reboot (rpm-ostree).
#[serde(default)]
pub staged: bool,
}
/// The live job the console polls, mirrored into `GET /update/status`.
#[derive(Debug, Clone)]
pub(crate) struct JobSnapshot {
pub target_version: String,
/// `downloading` | `verifying` | `applying` | `restarting`.
pub stage: &'static str,
pub received_bytes: u64,
pub total_bytes: Option<u64>,
pub started_unix: u64,
}
pub(crate) fn intent_path() -> PathBuf {
pf_paths::config_dir().join("update-intent.json")
}
pub(crate) fn result_path() -> PathBuf {
pf_paths::config_dir().join("update-result.json")
}
pub(crate) fn read_intent(path: &Path) -> Option<IntentRecord> {
let bytes = std::fs::read(path).ok()?;
// An unparseable intent (half-written before atomic-rename existed, disk fault) must not
// crash reconcile — it reads as "no intent" and the stale file is swept.
serde_json::from_slice(&bytes).ok()
}
pub(crate) fn read_result(path: &Path) -> Option<ResultRecord> {
let bytes = std::fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
/// Atomic tmp+rename JSON write (the intent/result records must never be half-written — R12).
pub(crate) fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> std::io::Result<()> {
let bytes = serde_json::to_vec_pretty(value)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, &bytes)?;
std::fs::rename(&tmp, path)
}
/// What boot-time reconciliation decided.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Reconciled {
/// No intent on disk — nothing to close out.
None,
/// Intent is fresh and we still run the old version: the installer likely hasn't stopped
/// us yet (or is mid-copy). Leave the intent in place; status shows the apply in flight.
StillApplying,
/// We came back at the target version.
Success(ResultRecord),
/// We came back at the wrong version after the grace window.
Failed(ResultRecord),
}
/// Pure reconcile: current version + wall clock vs. the intent record.
pub(crate) fn reconcile(
intent: Option<IntentRecord>,
current_version: &str,
now_unix: u64,
) -> Reconciled {
let Some(intent) = intent else {
return Reconciled::None;
};
if intent.source_build {
// See `IntentRecord::source_build`: presence at boot IS the success signal; the
// running version is the truthful "to" (a rebuild can legitimately keep it).
return Reconciled::Success(ResultRecord {
ok: true,
from: intent.from,
to: current_version.to_string(),
finished_unix: now_unix,
stage: None,
error: None,
log_path: Some(intent.log_path),
staged: false,
});
}
if current_version == intent.to {
return Reconciled::Success(ResultRecord {
ok: true,
from: intent.from,
to: intent.to,
finished_unix: now_unix,
stage: None,
error: None,
log_path: Some(intent.log_path),
staged: false,
});
}
if now_unix.saturating_sub(intent.started_unix) < APPLY_GRACE_SECS {
return Reconciled::StillApplying;
}
Reconciled::Failed(ResultRecord {
ok: false,
from: intent.from.clone(),
to: intent.to.clone(),
finished_unix: now_unix,
stage: Some("restarting".into()),
error: Some(format!(
"the host restarted still running {} (expected {}) — the installer aborted or \
rolled back; see its log",
intent.from, intent.to
)),
log_path: Some(intent.log_path),
staged: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn intent(started: u64) -> IntentRecord {
IntentRecord {
from: "0.23.100".into(),
to: "0.23.200".into(),
serial: 42,
started_unix: started,
installer_sha256: "ab".repeat(32),
log_path: "/logs/update-0.23.200.log".into(),
source_build: false,
tray_was_running: false,
}
}
/// An intent written by a host from before the tray-relaunch field must still load, and read
/// as "no tray to put back" — the behaviour that shipped before it existed.
#[test]
fn an_intent_without_the_tray_field_deserializes_as_false() {
let json = r#"{"from":"0.23.100","to":"0.23.200","serial":42,"started_unix":1000,
"installer_sha256":"ab","log_path":"/logs/x.log"}"#;
let i: IntentRecord = serde_json::from_str(json).expect("older intent still parses");
assert!(!i.tray_was_running);
assert!(!i.source_build);
}
#[test]
fn source_build_intent_is_success_with_the_running_version() {
let mut i = intent(0);
i.source_build = true;
// Same version as `from` (a rebuild without a bump) — still success, and `to` is
// what actually runs, not the manifest's label.
match reconcile(Some(i), "0.23.100", 10_000_000) {
Reconciled::Success(r) => {
assert!(r.ok);
assert_eq!(r.to, "0.23.100");
}
other => panic!("expected success, got {other:?}"),
}
}
#[test]
fn no_intent_is_none() {
assert_eq!(reconcile(None, "0.23.100", 1000), Reconciled::None);
}
#[test]
fn target_version_is_success_regardless_of_age() {
for now in [1, 10_000_000] {
match reconcile(Some(intent(0)), "0.23.200", now) {
Reconciled::Success(r) => {
assert!(r.ok);
assert_eq!(r.to, "0.23.200");
assert_eq!(r.log_path.as_deref(), Some("/logs/update-0.23.200.log"));
}
other => panic!("expected success, got {other:?}"),
}
}
}
#[test]
fn fresh_intent_old_version_is_still_applying() {
assert_eq!(
reconcile(Some(intent(1000)), "0.23.100", 1000 + APPLY_GRACE_SECS - 1),
Reconciled::StillApplying
);
}
#[test]
fn stale_intent_old_version_is_failure_with_log() {
match reconcile(Some(intent(1000)), "0.23.100", 1000 + APPLY_GRACE_SECS) {
Reconciled::Failed(r) => {
assert!(!r.ok);
assert_eq!(r.stage.as_deref(), Some("restarting"));
assert!(r.error.as_deref().unwrap().contains("0.23.200"));
assert!(r.log_path.is_some());
}
other => panic!("expected failure, got {other:?}"),
}
}
#[test]
fn intent_and_result_roundtrip_atomically() {
let dir = std::env::temp_dir().join(format!("pf-update-jobs-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let ip = dir.join("update-intent.json");
write_json_atomic(&ip, &intent(7)).unwrap();
assert_eq!(read_intent(&ip).unwrap().started_unix, 7);
// Corrupt bytes read as "no intent", never a crash.
std::fs::write(&ip, b"{half").unwrap();
assert!(read_intent(&ip).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
}
+370
View File
@@ -0,0 +1,370 @@
//! The Linux apply leg (design §7, plan U2.3): ask systemd to run the `pf-update` root
//! oneshot, read its result record, and — when the on-disk binary actually changed — hand
//! the outcome across our own restart via the same intent/reconcile machinery the Windows
//! leg uses.
//!
//! Privilege model: this process is unprivileged; `systemctl start punktfunk-update.service`
//! is authorized by polkit for members of the `punktfunk-update` group (an explicit,
//! auditable opt-in — the packaged group is empty). The request we send carries **nothing**:
//! the unit's ExecStart is fixed, and the helper derives everything from root-owned state.
//! polkit resolves group membership via NSS at request time, so a fresh
//! `usermod -aG punktfunk-update` counts without re-login (the *hint* probe in
//! [`opted_in`] uses the same NSS route for the same reason).
#![cfg(target_os = "linux")]
use super::jobs::{self, IntentRecord};
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
/// Where the root helper writes its outcome (mirrors `pf-update`'s `HelperResult`).
const HELPER_RESULT: &str = "/var/lib/punktfunk/update-result.json";
/// The unit the polkit rule scopes to. Its presence is the "helper is installed" probe.
const UNIT_PATH: &str = "/usr/lib/systemd/system/punktfunk-update.service";
/// The pacman escape hatch (root-owned; see the design's §5 pacman stance).
const PACMAN_OPTIN_CONF: &str = "/etc/punktfunk/update.conf";
/// Wall-clock cap on one helper run (a full `pacman -Syu` on a stale box is slow; a stuck
/// package manager should still surface as an error eventually).
const HELPER_TIMEOUT: Duration = Duration::from_secs(30 * 60);
#[derive(serde::Deserialize)]
struct HelperResult {
ok: bool,
#[serde(default)]
before_version: String,
#[serde(default)]
after_version: String,
#[serde(default)]
changed: bool,
#[serde(default)]
staged: bool,
#[serde(default)]
error: Option<String>,
#[serde(default)]
finished_unix: u64,
}
/// The root helper (+ its unit) is installed on this box.
pub(super) fn helper_installed() -> bool {
Path::new(UNIT_PATH).exists()
}
/// The pacman full-sysupgrade escape hatch is explicitly enabled (root-owned config).
pub(super) fn pacman_opted_in() -> bool {
std::fs::read_to_string(PACMAN_OPTIN_CONF)
.map(|c| c.lines().any(|l| l.trim() == "PACMAN_FULL_SYSUPGRADE=1"))
.unwrap_or(false)
}
/// Is this process's user in the `punktfunk-update` group, **by NSS** (matching how polkit
/// will decide) — not by our (possibly stale) process credentials. Cached briefly; the
/// status endpoint polls this.
pub(super) fn opted_in() -> bool {
static CACHE: std::sync::OnceLock<std::sync::Mutex<Option<(Instant, bool)>>> =
std::sync::OnceLock::new();
let cache = CACHE.get_or_init(|| std::sync::Mutex::new(None));
{
let cached = cache.lock().unwrap();
if let Some((at, val)) = *cached {
if at.elapsed() < Duration::from_secs(60) {
return val;
}
}
}
let val = probe_group_membership().unwrap_or(false);
*cache.lock().unwrap() = Some((Instant::now(), val));
val
}
fn probe_group_membership() -> Option<bool> {
let user = capture(Command::new("id").arg("-un"))?;
let groups = capture(Command::new("id").args(["-nG", user.trim()]))?;
Some(groups.split_whitespace().any(|g| g == "punktfunk-update"))
}
fn capture(cmd: &mut Command) -> Option<String> {
let out = cmd.output().ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
/// The console-facing opt-in instruction, shown instead of an Apply button.
pub(super) fn opt_in_hint() -> String {
"sudo usermod -aG punktfunk-update $USER # enables web-triggered updates for this host"
.to_string()
}
/// The Steam Deck source-rebuild leg (plan U3.1): run `~/punktfunk/scripts/steamdeck/update.sh
/// --pull` in a TRANSIENT user unit — `systemd-run`, because the script ends by restarting
/// `punktfunk-host.service`, and a child inside our own cgroup would be killed by that restart
/// mid-run. No root involved (the Deck install is user-owned). Outcome plumbing:
/// - build FAILS → the script exits without restarting us → the poll below sees the unit fail
/// and reports it live, log attached;
/// - build SUCCEEDS → the script restarts us → we die mid-poll; the `source_build` intent at
/// next boot IS the success signal (`jobs::reconcile`).
pub(super) fn run_apply_steamos(
target_version: &str,
serial: u64,
stage: &dyn Fn(&'static str),
) -> Result<(), (&'static str, String)> {
let home = std::env::var("HOME").map_err(|_| ("applying", "no $HOME".to_string()))?;
let script = std::path::Path::new(&home).join("punktfunk/scripts/steamdeck/update.sh");
if !script.exists() {
return Err((
"applying",
format!(
"{} not found — is this the Deck on-device install?",
script.display()
),
));
}
let log = pf_paths::config_dir()
.join("logs")
.join("update-steamos.log");
if let Some(dir) = log.parent() {
let _ = std::fs::create_dir_all(dir);
}
jobs::write_json_atomic(
&jobs::intent_path(),
&IntentRecord {
from: env!("PUNKTFUNK_VERSION").into(),
to: target_version.into(),
serial,
started_unix: super::now_unix(),
installer_sha256: String::new(),
log_path: log.display().to_string(),
source_build: true,
// Windows-only concern: no Linux package force-kills a running tray, and the desktop
// autostart entry owns bringing it up.
tray_was_running: false,
},
)
.map_err(|e| ("applying", format!("write intent record: {e}")))?;
const UNIT: &str = "pf-source-update";
// `--collect` reaps the transient unit even on failure, so a retry can reuse the name.
let launched = Command::new("systemd-run")
.args(["--user", "--collect", "--unit", UNIT, "bash", "-c"])
.arg(format!(
"exec >> '{}' 2>&1; exec bash '{}' --pull",
log.display(),
script.display()
))
.status();
match launched {
Ok(s) if s.success() => {}
Ok(s) => {
let _ = std::fs::remove_file(jobs::intent_path());
return Err(("applying", format!("systemd-run exited {s}")));
}
Err(e) => {
let _ = std::fs::remove_file(jobs::intent_path());
return Err(("applying", format!("launch systemd-run: {e}")));
}
}
stage("applying");
// Follow the transient unit. A successful build restarts this process before the unit
// goes inactive, so leaving this loop alive means either "still building" or "failed".
let deadline = Instant::now() + Duration::from_secs(90 * 60);
loop {
std::thread::sleep(Duration::from_secs(5));
let state = capture(Command::new("systemctl").args(["--user", "is-active", UNIT]))
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "failed".into());
match state.as_str() {
"active" | "activating" | "deactivating" | "reloading" => {
if Instant::now() > deadline {
// Leave the build running (killing a half-linked build helps nobody) but
// stop claiming it; the intent stays for reconcile if it ever finishes.
return Err((
"applying",
format!(
"the source rebuild is still running after 90 min — following it \
ends here; see {}",
log.display()
),
));
}
}
// The unit ended and we are STILL ALIVE ⇒ the script never reached its restart
// step ⇒ the build failed (an up-to-date tree still rebuilds+restarts).
_ => {
let _ = std::fs::remove_file(jobs::intent_path());
return Err((
"applying",
format!("the source rebuild failed — see {}", log.display()),
));
}
}
}
}
/// Run the whole Linux apply: start the oneshot, wait, interpret its result record, and for
/// an in-place binary change, write the intent and restart ourselves (boot reconciliation
/// reports the outcome). Blocking — run on a blocking thread.
pub(super) fn run_apply(
target_version: &str,
serial: u64,
stage: &dyn Fn(&'static str),
) -> Result<(), (&'static str, String)> {
let started_unix = super::now_unix();
let mut child = Command::new("systemctl")
.args(["start", "punktfunk-update.service"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| ("applying", format!("launch systemctl: {e}")))?;
let deadline = Instant::now() + HELPER_TIMEOUT;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if Instant::now() > deadline => {
let _ = child.kill();
return Err((
"applying",
format!(
"the update helper is still running after {} min — see \
`journalctl -u punktfunk-update.service`",
HELPER_TIMEOUT.as_secs() / 60
),
));
}
Ok(None) => std::thread::sleep(Duration::from_millis(500)),
Err(e) => return Err(("applying", format!("wait for systemctl: {e}"))),
}
};
if !status.success() {
let mut err = String::new();
if let Some(mut stderr) = child.stderr.take() {
use std::io::Read as _;
let _ = stderr.read_to_string(&mut err);
}
let denial = err.contains("interactive authentication")
|| err.contains("Access denied")
|| err.contains("Permission denied");
return Err((
"applying",
if denial {
format!(
"not authorized to start the update helper — enable web-triggered \
updates first: {}",
opt_in_hint()
)
} else {
format!(
"update helper failed ({status}) — see \
`journalctl -u punktfunk-update.service`. {err}"
)
},
));
}
// The unit succeeded; its record is the ground truth. Refuse a stale record (an old run's
// leftovers with a fresh exit 0 would mean the helper never wrote — surface that).
let result: HelperResult = std::fs::read(HELPER_RESULT)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.ok_or((
"applying",
format!("the update helper wrote no readable result at {HELPER_RESULT}"),
))?;
if result.finished_unix + 5 < started_unix {
return Err((
"applying",
format!(
"the update helper's result record predates this run \
({} < {started_unix}) it never wrote one",
result.finished_unix
),
));
}
if !result.ok {
return Err((
"applying",
result
.error
.unwrap_or_else(|| "the update helper reported failure without detail".into()),
));
}
let current = env!("PUNKTFUNK_VERSION");
if result.staged {
// rpm-ostree: the new deployment activates on reboot — durable outcome now, no restart.
let _ = jobs::write_json_atomic(
&jobs::result_path(),
&jobs::ResultRecord {
ok: true,
from: current.into(),
to: target_version.into(),
finished_unix: super::now_unix(),
stage: None,
error: None,
log_path: None,
staged: true,
},
);
return Ok(());
}
if !result.changed {
// The channel had nothing newer than what's installed (announce leads the mirrors) —
// a real answer, not an error. from == to renders as "already up to date".
let _ = jobs::write_json_atomic(
&jobs::result_path(),
&jobs::ResultRecord {
ok: true,
from: current.into(),
to: current.into(),
finished_unix: super::now_unix(),
stage: None,
error: None,
log_path: None,
staged: false,
},
);
return Ok(());
}
// The on-disk binary changed (and the helper's run-the-binary gate already proved it
// runs). Cross the restart on the intent record, exactly like the Windows leg.
let to = result
.after_version
.split_whitespace()
.last()
.unwrap_or(target_version)
.to_string();
jobs::write_json_atomic(
&jobs::intent_path(),
&IntentRecord {
from: current.into(),
to,
serial,
started_unix: super::now_unix(),
installer_sha256: String::new(),
log_path: "journalctl -u punktfunk-update.service".into(),
source_build: false,
tray_was_running: false, // Windows-only concern (see the source-build intent above)
},
)
.map_err(|e| ("restarting", format!("write intent record: {e}")))?;
stage("restarting");
// Web console first (its own unit; a brief console blip the frontend rides out), then
// ourselves — --no-block, because this very process is what's being restarted.
let _ = Command::new("systemctl")
.args(["--user", "--no-block", "restart", "punktfunk-web.service"])
.status();
let _ = Command::new("systemctl")
.args(["--user", "--no-block", "restart", "punktfunk-host.service"])
.status();
Ok(())
}
@@ -0,0 +1,245 @@
//! The signed **update manifest**: the check truth for "a newer host exists"
//! (design `host-update-from-web-console.md` §3).
//!
//! One small JSON document per channel, Ed25519-signed with keys pinned in this binary
//! ([`super::UPDATE_KEYS`]) and verified by the exact code path the plugin store already
//! trusts ([`crate::store::index::verify_signature`]). TLS and the registry that serves the
//! document are transport, never trust.
//!
//! Rules, all fail-closed (the sysext 303 lesson encoded):
//! 1. **Signature before parse** — over the exact fetched bytes, then strict JSON. An HTML
//! error page, a redirect stub, or a truncated body dies before any field is read.
//! 2. **Channel binding** — the document names its channel and it must match the one we
//! asked for, so a validly-signed canary manifest replayed onto the stable URL is refused.
//! 3. **Monotonic serial** — the publish-time serial can never go backwards for a channel
//! (the anti-downgrade/anti-replay floor, persisted by [`super`]).
//! 4. **Pinned notes origin** — the release-notes link the console renders must live on our
//! forge, so a signed-but-wrong document can't send the operator to a lookalike page.
use crate::store::index::{verify_signature, PublicKey};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
/// The only manifest schema this host understands. A breaking change bumps this; old hosts
/// report "unsupported schema" instead of guessing.
pub(crate) const SCHEMA: u32 = 1;
/// Hard cap on a fetched manifest (and its signature). The real document is <1 KB.
pub(crate) const MAX_MANIFEST_BYTES: usize = 64 * 1024;
/// The only origin a manifest may point the operator at for release notes.
const NOTES_ORIGIN: &str = "https://git.unom.io/";
/// The signed update manifest, as served (and signed) per channel.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct Manifest {
/// Document schema — must equal [`SCHEMA`].
pub schema: u32,
/// The channel this document was published for (`stable` | `canary`). Bound-checked
/// against the channel we fetched, see module docs rule 2.
pub channel: String,
/// Unix seconds at publish. Strictly increasing per channel; also drives the stale-feed
/// hint (freshness needs no date parsing and no trust in `published_at`).
pub serial: u64,
/// RFC-3339 publish time. Display only.
#[serde(default)]
pub published_at: String,
/// The released host version this manifest announces.
pub version: String,
/// Release-notes link the console renders. Must be on [`NOTES_ORIGIN`].
#[serde(default)]
pub notes_url: String,
/// Canary only: the CI run number, the definitive "newer" axis where per-channel version
/// strings differ (`~ciN`, `0.ciN`, a padded pkgrel, `M.m.run`).
#[serde(default)]
pub ci_run: Option<u64>,
/// The Windows installer leg (design §6) — parsed and carried now so a U0 host is already
/// schema-complete, consumed by the U1 apply path.
#[serde(default)]
pub windows_host: Option<WindowsHostAsset>,
}
/// Where the Windows host installer for [`Manifest::version`] lives and how to verify it.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct WindowsHostAsset {
/// Immutable per-version download URL — never a mutable `latest/` alias, so the hash
/// below can't race an alias re-upload.
pub url: String,
/// SHA-256 (hex) of the installer bytes.
pub sha256: String,
/// Accepted Authenticode signing-leaf SHA-256 fingerprints. Living in the signed manifest
/// (not the binary) is what makes the self-signed → Trusted Signing migration a manifest
/// edit instead of a lockstep host release.
#[serde(default)]
pub authenticode_sha256: Vec<String>,
/// Minimum Windows build (display/preflight only).
#[serde(default)]
pub min_os: String,
}
/// Verify `sig_text` over the exact `bytes` against `keys`, then strictly parse and validate
/// the document for `expected_channel`. The only constructor — there is no unsigned path.
pub(crate) fn verify_and_parse(
bytes: &[u8],
sig_text: &str,
keys: &[PublicKey],
expected_channel: &str,
) -> Result<Manifest> {
verify_signature(bytes, sig_text, keys).context("update manifest signature")?;
parse_verified(bytes, expected_channel)
}
/// Parse + validate a document whose signature has already been checked. Split out so tests
/// can exercise validation without minting signatures for every case.
pub(crate) fn parse_verified(bytes: &[u8], expected_channel: &str) -> Result<Manifest> {
if bytes.len() > MAX_MANIFEST_BYTES {
bail!("manifest is larger than the {MAX_MANIFEST_BYTES}-byte cap");
}
let m: Manifest = serde_json::from_slice(bytes).context("update manifest is not valid JSON")?;
if m.schema != SCHEMA {
bail!(
"unsupported manifest schema {} (this host understands {SCHEMA})",
m.schema
);
}
if m.channel != expected_channel {
bail!(
"manifest is for channel `{}` but this host asked for `{expected_channel}`",
m.channel
);
}
if m.version.is_empty() || m.version.len() > 64 {
bail!("manifest version is empty or implausibly long");
}
if m.serial == 0 {
bail!("manifest serial is zero");
}
if !m.notes_url.is_empty() && !m.notes_url.starts_with(NOTES_ORIGIN) {
bail!("manifest notes_url is not on {NOTES_ORIGIN}");
}
if let Some(w) = &m.windows_host {
if !w.url.starts_with("https://") {
bail!("windows_host.url must be https");
}
if w.sha256.len() != 64 || !w.sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
bail!("windows_host.sha256 is not a hex SHA-256");
}
}
Ok(m)
}
#[cfg(test)]
mod tests {
use super::*;
fn doc() -> serde_json::Value {
serde_json::json!({
"schema": 1,
"channel": "stable",
"serial": 1785400000u64,
"published_at": "2026-07-30T12:00:00Z",
"version": "0.23.0",
"notes_url": "https://git.unom.io/unom/punktfunk/releases/tag/v0.23.0",
"windows_host": {
"url": "https://git.unom.io/unom/punktfunk/releases/download/v0.23.0/punktfunk-host-setup-0.23.0.exe",
"sha256": "aa".repeat(32),
"authenticode_sha256": ["bb".repeat(32)],
"min_os": "10.0.22621"
}
})
}
fn bytes(v: &serde_json::Value) -> Vec<u8> {
serde_json::to_vec(v).unwrap()
}
/// End-to-end: sign with a fresh ring keypair, verify with the matching pinned-key
/// string — the format contract with the CI signer (raw 64-byte sig, base64; raw
/// 32-byte key, `ed25519:<base64>`).
#[test]
fn signed_roundtrip_and_tamper() {
use base64::Engine as _;
use ring::signature::KeyPair as _;
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let key_str = format!(
"ed25519:{}",
base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref())
);
let keys = vec![PublicKey::parse(&key_str).unwrap()];
let body = bytes(&doc());
let sig = base64::engine::general_purpose::STANDARD.encode(kp.sign(&body));
let m = verify_and_parse(&body, &sig, &keys, "stable").unwrap();
assert_eq!(m.version, "0.23.0");
assert_eq!(
m.windows_host.as_ref().unwrap().authenticode_sha256.len(),
1
);
// One flipped byte ⇒ refused before parse.
let mut tampered = body.clone();
tampered[10] ^= 1;
assert!(verify_and_parse(&tampered, &sig, &keys, "stable").is_err());
// Signed for stable, replayed as canary ⇒ refused (channel binding).
assert!(verify_and_parse(&body, &sig, &keys, "canary").is_err());
}
#[test]
fn html_error_page_is_not_a_manifest() {
// The Gitea 303 stub that poisoned the sysext feed — must die in strict parse.
let html = b"<a href=\"https://objects.example/x\">See Other</a>.";
assert!(parse_verified(html, "stable").is_err());
}
#[test]
fn truncated_json_refused() {
let body = bytes(&doc());
assert!(parse_verified(&body[..body.len() - 5], "stable").is_err());
}
#[test]
fn wrong_schema_refused() {
let mut v = doc();
v["schema"] = serde_json::json!(2);
assert!(parse_verified(&bytes(&v), "stable").is_err());
}
#[test]
fn zero_serial_refused() {
let mut v = doc();
v["serial"] = serde_json::json!(0);
assert!(parse_verified(&bytes(&v), "stable").is_err());
}
#[test]
fn offsite_notes_url_refused() {
let mut v = doc();
v["notes_url"] = serde_json::json!("https://evil.example/notes");
assert!(parse_verified(&bytes(&v), "stable").is_err());
}
#[test]
fn windows_asset_validation() {
let mut v = doc();
v["windows_host"]["sha256"] = serde_json::json!("nothex");
assert!(parse_verified(&bytes(&v), "stable").is_err());
let mut v = doc();
v["windows_host"]["url"] = serde_json::json!("http://git.unom.io/x.exe");
assert!(parse_verified(&bytes(&v), "stable").is_err());
// No windows leg at all is fine (PM channels don't need it).
let mut v = doc();
v.as_object_mut().unwrap().remove("windows_host");
assert!(parse_verified(&bytes(&v), "stable").is_ok());
}
#[test]
fn oversized_refused() {
let mut v = doc();
v["published_at"] = serde_json::json!("x".repeat(MAX_MANIFEST_BYTES));
assert!(parse_verified(&bytes(&v), "stable").is_err());
}
}
+425
View File
@@ -0,0 +1,425 @@
//! The Windows apply leg (design §6, plan U1.2/U1.3): download the manifest's immutable
//! per-version installer, verify it (manifest SHA-256, then Authenticode), persist the intent
//! record, and spawn the installer detached — which stops the service and thereby kills this
//! process *by design*; boot-time reconciliation (`jobs::reconcile`) closes the loop.
//!
//! Verification order and rules:
//! 1. **SHA-256 == the signed manifest's** — the primary integrity gate (the manifest is the
//! Ed25519-verified document; this check makes the downloaded bytes those exact bytes).
//! 2. **Authenticode**: the embedded signature must be cryptographically valid, tolerating
//! `CERT_E_UNTRUSTEDROOT` while the shipping cert is self-signed (`CN=unom`); when the
//! manifest carries leaf pins, the signing leaf's SHA-256 must match one. The leaf is taken
//! from the SAME `WinVerifyTrust` state (`WTHelperGetProvSignerFromChain`), never a second
//! parse — no verify-vs-inspect gap. An empty pin list skips only the pin comparison (the
//! manifest hash already binds content; pins arrive via `AUTHENTICODE_SHA256` in CI once
//! the cert story settles — the field exists so Trusted Signing is a manifest edit).
//!
//! The spawn uses `CREATE_BREAKAWAY_FROM_JOB`: the service worker's job object is kill-on-close
//! (a stopping service would otherwise take the installer down with it) and was created
//! breakaway-ok for exactly this shape (`windows/service.rs`). Failure to break away is a hard,
//! reported error (plan R3), never a silent fallback.
#![cfg(target_os = "windows")]
use super::jobs::{self, IntentRecord};
use super::manifest::WindowsHostAsset;
use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
/// Free-space preflight: require this multiple of the download size (installer + Inno's
/// unpack scratch + headroom).
const DISK_MARGIN: u64 = 3;
/// Keep the target + this many previous installers cached for the manual-rollback path.
const KEEP_INSTALLERS: usize = 2;
const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// The winget-blessed silent flags (`packaging/winget/unom.PunktfunkHost.installer.yaml`).
const SILENT_ARGS: [&str; 4] = ["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/SP-"];
fn staging_dir() -> PathBuf {
pf_paths::config_dir().join("updates")
}
/// Put the per-user tray back after an update that killed it.
///
/// Runs from boot reconciliation, i.e. in the NEW host, as SYSTEM — so `crate::tray::start` takes
/// its session-crossing path and lands the tray in the active console session under the logged-in
/// user's own token. That is the whole reason this is the host's job and not the installer's (see
/// `IntentRecord::tray_was_running`).
///
/// Best-effort throughout: nobody's update outcome depends on the icon, and the common benign
/// failure is simply that nobody has signed in yet — which the HKLM `Run` value covers at the next
/// logon. Hence `info`, not a warning the operator must act on.
pub(crate) fn relaunch_tray() {
match crate::tray::start() {
Ok((Some(pid), how)) => tracing::info!(pid, how, "status tray relaunched after the update"),
Ok((None, _)) => tracing::debug!("status tray was already running after the update"),
Err(e) => tracing::info!(error = %e, "could not relaunch the status tray after the update"),
}
}
fn log_path(version: &str) -> PathBuf {
pf_paths::config_dir()
.join("logs")
.join(format!("update-{version}.log"))
}
/// The whole pipeline, run on a blocking thread. Reports progress/stage through the callbacks
/// so this file stays free of the runtime-state lock.
pub(super) fn run_apply(
asset: &WindowsHostAsset,
target_version: &str,
serial: u64,
progress: &dyn Fn(u64, Option<u64>),
stage: &dyn Fn(&'static str),
) -> Result<(), (&'static str, String)> {
let dir = staging_dir();
std::fs::create_dir_all(&dir)
.map_err(|e| ("downloading", format!("create staging dir: {e}")))?;
let final_path = dir.join(format!("punktfunk-host-setup-{target_version}.exe"));
let part_path = dir.join(format!("punktfunk-host-setup-{target_version}.exe.part"));
download(&asset.url, &part_path, progress).map_err(|e| ("downloading", e))?;
stage("verifying");
verify_sha256(&part_path, &asset.sha256).map_err(|e| {
quarantine(&part_path);
("verifying", e)
})?;
verify_authenticode(&part_path, &asset.authenticode_sha256).map_err(|e| {
quarantine(&part_path);
("verifying", e)
})?;
std::fs::rename(&part_path, &final_path)
.map_err(|e| ("verifying", format!("stage rename: {e}")))?;
prune_installers(&dir, &final_path);
stage("applying");
let log = log_path(target_version);
if let Some(parent) = log.parent() {
let _ = std::fs::create_dir_all(parent);
}
// The point of no return: after this record exists, boot reconciliation owns the outcome.
jobs::write_json_atomic(
&jobs::intent_path(),
&IntentRecord {
from: env!("PUNKTFUNK_VERSION").into(),
to: target_version.into(),
serial,
started_unix: super::now_unix(),
installer_sha256: asset.sha256.to_ascii_lowercase(),
log_path: log.display().to_string(),
source_build: false,
// Captured BEFORE the installer runs: it force-kills every tray to unlock
// punktfunk-tray.exe and, under /VERYSILENT, never runs its relaunch entry. See
// `IntentRecord::tray_was_running`; `relaunch_tray` puts it back at reconcile.
tray_was_running: crate::tray::is_running(),
},
)
.map_err(|e| ("applying", format!("write intent record: {e}")))?;
// Let the 202 + the console's next status poll leave the box before the installer starts
// stopping the service under us (plan R4).
std::thread::sleep(std::time::Duration::from_secs(2));
let spawned = {
use std::os::windows::process::CommandExt as _;
std::process::Command::new(&final_path)
.args(SILENT_ARGS)
.arg(format!("/LOG={}", log.display()))
.current_dir(&dir)
.creation_flags(CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW)
.spawn()
};
match spawned {
Ok(child) => {
// Detached on purpose: the child must outlive us. Dropping a Child does not kill it.
drop(child);
stage("restarting");
Ok(())
}
Err(e) => {
// Most likely: the job object stopped allowing breakaway (R3). Clear the intent —
// nothing irreversible happened — and surface the real error.
let _ = std::fs::remove_file(jobs::intent_path());
Err((
"applying",
format!(
"spawn installer (CREATE_BREAKAWAY_FROM_JOB — if this is ACCESS_DENIED, \
the service job object no longer permits breakaway): {e}"
),
))
}
}
}
fn quarantine(part: &Path) {
let bad = part.with_extension("bad");
let _ = std::fs::remove_file(&bad);
let _ = std::fs::rename(part, &bad);
}
/// Download `url` to `part`, resuming an existing partial file when the server honors Range.
fn download(url: &str, part: &Path, progress: &dyn Fn(u64, Option<u64>)) -> Result<(), String> {
if !url.starts_with("https://") {
return Err("installer url must be https".into());
}
let agent = ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(15))
.redirects(3)
.user_agent(&format!(
"punktfunk-host/{} (update-apply)",
env!("PUNKTFUNK_VERSION")
))
.build();
let existing = std::fs::metadata(part).map(|m| m.len()).unwrap_or(0);
let mut req = agent.get(url);
if existing > 0 {
req = req.set("Range", &format!("bytes={existing}-"));
}
let resp = req.call().map_err(|e| match e {
ureq::Error::Status(code, _) => format!("download returned HTTP {code}"),
other => format!("download failed: {other}"),
})?;
let resumed = resp.status() == 206;
let content_len: Option<u64> = resp.header("content-length").and_then(|v| v.parse().ok());
let total = content_len.map(|l| if resumed { existing + l } else { l });
if let Some(t) = total {
preflight_disk(part, t.saturating_mul(DISK_MARGIN))?;
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.write(true)
// Never truncate at open: on a 206 we append to the existing partial, and the
// fresh-download path truncates explicitly via `set_len(0)` below.
.truncate(false)
.open(part)
.map_err(|e| format!("open staging file: {e}"))?;
let mut received = if resumed {
file.seek(std::io::SeekFrom::End(0))
.map_err(|e| format!("seek: {e}"))?
} else {
file.set_len(0).map_err(|e| format!("truncate: {e}"))?;
0
};
progress(received, total);
let mut reader = resp.into_reader();
let mut buf = [0u8; 64 * 1024];
loop {
let n = reader.read(&mut buf).map_err(|e| format!("read: {e}"))?;
if n == 0 {
break;
}
file.write_all(&buf[..n])
.map_err(|e| format!("write: {e}"))?;
received += n as u64;
progress(received, total);
}
file.sync_all().map_err(|e| format!("fsync: {e}"))?;
if let Some(t) = total {
if received != t {
return Err(format!("download truncated: {received} of {t} bytes"));
}
}
Ok(())
}
fn verify_sha256(path: &Path, expected_hex: &str) -> Result<(), String> {
let mut file = std::fs::File::open(path).map_err(|e| format!("open for hashing: {e}"))?;
let mut ctx = ring::digest::Context::new(&ring::digest::SHA256);
let mut buf = [0u8; 128 * 1024];
loop {
let n = file
.read(&mut buf)
.map_err(|e| format!("read for hashing: {e}"))?;
if n == 0 {
break;
}
ctx.update(&buf[..n]);
}
let got = hex(ctx.finish().as_ref());
if got != expected_hex.to_ascii_lowercase() {
return Err(format!(
"installer sha256 mismatch: got {got}, manifest says {expected_hex}"
));
}
Ok(())
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn preflight_disk(at: &Path, needed: u64) -> Result<(), String> {
use windows::core::HSTRING;
use windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
let dir = at.parent().unwrap_or(at);
let mut free: u64 = 0;
// SAFETY: the HSTRING is a valid NUL-terminated path living across the call, and the out
// param points at a live local u64; the API retains neither.
unsafe { GetDiskFreeSpaceExW(&HSTRING::from(dir.as_os_str()), Some(&mut free), None, None) }
.map_err(|e| format!("disk preflight: {e}"))?;
if free < needed {
return Err(format!(
"not enough disk space for the update: {free} bytes free, {needed} needed"
));
}
Ok(())
}
/// Authenticode: valid embedded signature (untrusted root tolerated — self-signed `CN=unom`),
/// signing-leaf SHA-256 ∈ `pins` when pins are present. The leaf comes out of the same
/// `WinVerifyTrust` state via `WTHelperGetProvSignerFromChain`. (`pub(crate)`: the service
/// supervisor's boot-loop rollback re-checks the cached previous installer with it.)
pub(crate) fn verify_authenticode(path: &Path, pins: &[String]) -> Result<(), String> {
use windows::core::{GUID, PCWSTR};
use windows::Win32::Foundation::{CERT_E_UNTRUSTEDROOT, S_OK};
use windows::Win32::Security::WinTrust::{
WTHelperGetProvSignerFromChain, WTHelperProvDataFromStateData, WinVerifyTrust,
WINTRUST_ACTION_GENERIC_VERIFY_V2, WINTRUST_DATA, WINTRUST_DATA_0, WINTRUST_FILE_INFO,
WTD_CHOICE_FILE, WTD_REVOKE_NONE, WTD_STATEACTION_CLOSE, WTD_STATEACTION_VERIFY,
WTD_UI_NONE,
};
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let file_info = WINTRUST_FILE_INFO {
cbStruct: std::mem::size_of::<WINTRUST_FILE_INFO>() as u32,
pcwszFilePath: PCWSTR(wide.as_ptr()),
hFile: Default::default(),
pgKnownSubject: std::ptr::null_mut(),
};
let mut data = WINTRUST_DATA {
cbStruct: std::mem::size_of::<WINTRUST_DATA>() as u32,
dwUIChoice: WTD_UI_NONE,
fdwRevocationChecks: WTD_REVOKE_NONE,
dwUnionChoice: WTD_CHOICE_FILE,
Anonymous: WINTRUST_DATA_0 {
pFile: &file_info as *const _ as *mut _,
},
dwStateAction: WTD_STATEACTION_VERIFY,
..Default::default()
};
let mut action: GUID = WINTRUST_ACTION_GENERIC_VERIFY_V2;
// SAFETY: `data`, the `file_info` it points to, and the path's wide buffer all outlive
// the call; `action` is a live mutable GUID. WinVerifyTrust reads the structs and stores
// its state into `data.hWVTStateData`, released by the CLOSE call below.
let status = unsafe {
WinVerifyTrust(
Default::default(),
&mut action,
&mut data as *mut _ as *mut core::ffi::c_void,
)
};
let verdict = (|| {
let ok = status == S_OK.0 || status == CERT_E_UNTRUSTEDROOT.0;
if !ok {
return Err(format!(
"installer Authenticode signature is invalid (WinVerifyTrust 0x{status:08x})"
));
}
if pins.is_empty() {
tracing::warn!(
"update manifest carries no Authenticode leaf pins — accepting on the \
manifest sha256 + signature validity alone"
);
return Ok(());
}
// Same-state leaf extraction: no second parse of the file.
// SAFETY: `hWVTStateData` is the live verification state the VERIFY call above
// populated (status checked OK); the returned pointer borrows that state, which stays
// alive until the CLOSE call below, and is null-checked before use.
let prov = unsafe { WTHelperProvDataFromStateData(data.hWVTStateData) };
if prov.is_null() {
return Err("WinVerifyTrust returned no provider state".into());
}
// SAFETY: `prov` was null-checked and borrows the same live verification state;
// index 0 addresses the primary (only) signer, no counter-signer requested.
let signer = unsafe { WTHelperGetProvSignerFromChain(prov, 0, false, 0) };
if signer.is_null() {
return Err("no signer in the Authenticode chain".into());
}
// SAFETY: `signer` was null-checked and borrows the live verification state; the
// chain array is length/null-checked before indexing, and the CERT_CONTEXT borrows
// the same state — every read of it happens before the CLOSE call below.
let leaf = unsafe {
let s = &*signer;
if s.csCertChain == 0 || s.pasCertChain.is_null() {
return Err("empty Authenticode cert chain".into());
}
// pasCertChain[0] is the SIGNING cert (leaf → root order).
&*(*s.pasCertChain).pCert
};
// SAFETY: `pbCertEncoded`/`cbCertEncoded` describe the DER buffer owned by the live
// cert context above; the slice is consumed (hashed) before the state is closed.
let der =
unsafe { std::slice::from_raw_parts(leaf.pbCertEncoded, leaf.cbCertEncoded as usize) };
let fp = hex(ring::digest::digest(&ring::digest::SHA256, der).as_ref());
if !pins.iter().any(|p| p.eq_ignore_ascii_case(&fp)) {
return Err(format!(
"installer signing-leaf fingerprint {fp} matches none of the manifest's \
{} pin(s)",
pins.len()
));
}
Ok(())
})();
// Always release the verification state.
data.dwStateAction = WTD_STATEACTION_CLOSE;
// SAFETY: same live `data`/`file_info`/`action` as the VERIFY call; CLOSE releases
// `hWVTStateData`, after which no borrow of the state remains (the leaf/DER reads all
// happened inside `verdict` above).
unsafe {
WinVerifyTrust(
Default::default(),
&mut action,
&mut data as *mut _ as *mut core::ffi::c_void,
)
};
verdict
}
/// Keep the freshly-verified target plus the newest previous installers; sweep the rest (and
/// any stale `.part`/`.bad` from other versions).
fn prune_installers(dir: &Path, keep_newest: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut exes: Vec<(std::time::SystemTime, PathBuf)> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p != keep_newest)
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("punktfunk-host-setup-"))
.unwrap_or(false)
})
.map(|p| {
let t = p
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
(t, p)
})
.collect();
exes.sort_by_key(|e| std::cmp::Reverse(e.0));
for (_, p) in exes.into_iter().skip(KEEP_INSTALLERS - 1) {
let _ = std::fs::remove_file(p);
}
}
use std::os::windows::ffi::OsStrExt as _;
+25 -1
View File
@@ -621,7 +621,31 @@ fn stop_web_console() {
for pid in web_listener_pids() {
run_quiet("taskkill", &["/PID", &pid, "/F"]);
}
std::thread::sleep(std::time::Duration::from_secs(1));
// Both stops are asynchronous - `schtasks /end` returns once the end has been REQUESTED, and
// `taskkill /F` is TerminateProcess - so the outgoing console can still own :47992 after this
// returns. The very next thing `web_setup` does is start the new task, and its bun cannot bind a
// port the corpse still holds; the blind 1 s sleep this replaces was not always enough. Poll
// until the listener is really gone (~10 s), re-killing any straggler halfway through, and warn
// rather than block forever if something else owns the port (Apollo/Sunshine, a dev console).
for i in 0..40 {
let pids = web_listener_pids();
if pids.is_empty() {
return;
}
if i == 20 {
// Re-end the TASK, not just the listener: `web-run.cmd` now supervises bun and relaunches
// it on any exit, so killing bun alone would be undone by its own restart loop. Ending the
// task takes the launcher down with it, which is why the `/end` above comes first.
run_quiet("schtasks", &["/end", "/tn", WEB_TASK]);
for pid in pids {
run_quiet("taskkill", &["/PID", &pid, "/F"]);
}
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
eprintln!(
"warning: something still holds TCP 47992 - the web console may not start until it exits"
);
}
/// PIDs owning a LISTEN socket on :47992. Also the console's liveness probe (`start_web_task`).
@@ -360,6 +360,9 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
let job = make_job().context("create job object")?;
let mut restarts: u32 = 0;
// One-shot latch for the update boot-loop rollback (plan U3.2) — a rollback that itself
// fails must not spawn installers in a loop.
let mut rollback_attempted = false;
loop {
if wait_one(stop, 0) {
break;
@@ -462,6 +465,7 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
}
restarts += 1;
maybe_boot_loop_rollback(restarts, &mut rollback_attempted);
let backoff = restarts.min(10) * 500; // 0.5s..5s
if wait_one(stop, backoff) {
break;
@@ -1129,3 +1133,107 @@ fn run_quiet(cmd: &str, args: &[&str]) -> bool {
.map(|s| s.success())
.unwrap_or(false)
}
/// Update boot-loop rollback (planning: host-update-from-web-console.md §6, plan U3.2): a
/// FRESH update-intent record plus a crash-looping child that IS the intent's target version
/// means the just-installed host doesn't stay up. The supervisor — which survives the upgrade,
/// making it the right place — rolls back by re-running the CACHED previous installer: once
/// per intent, Authenticode-checked, outcome written where the console reads it. The service
/// process is not inside the kill-on-close job, so the spawned installer survives the service
/// stop it is about to perform.
fn maybe_boot_loop_rollback(restarts: u32, attempted: &mut bool) {
if *attempted || restarts < 3 {
return;
}
let intent_path = crate::update::jobs::intent_path();
let Some(intent) = crate::update::jobs::read_intent(&intent_path) else {
return;
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// A stale intent never triggers rollbacks, and a boot-looping OLD binary (version ≠ the
// intent's target) is not an update failure — boot reconciliation owns that story.
if now.saturating_sub(intent.started_unix) > 30 * 60 || env!("PUNKTFUNK_VERSION") != intent.to {
return;
}
*attempted = true;
let updates = pf_paths::config_dir().join("updates");
let previous = std::fs::read_dir(&updates)
.ok()
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| {
n.starts_with("punktfunk-host-setup-")
&& n.ends_with(".exe")
&& !n.contains(intent.to.as_str())
})
.unwrap_or(false)
})
.max_by_key(|p| {
p.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
});
let Some(previous) = previous else {
tracing::error!(
to = %intent.to,
"updated host is crash-looping and no cached previous installer exists — \
leaving the intent for reconcile; manual reinstall required"
);
return;
};
// Validity-only Authenticode check: the failed update's manifest pins are gone with it,
// and the cached file was fully verified (manifest sha256 + pins) when first downloaded.
if let Err(e) = crate::update::windows::verify_authenticode(&previous, &[]) {
tracing::error!(
installer = %previous.display(),
error = %e,
"cached previous installer fails its signature check — not rolling back"
);
return;
}
let log = pf_paths::config_dir()
.join("logs")
.join(format!("update-rollback-from-{}.log", intent.to));
let record = crate::update::jobs::ResultRecord {
ok: false,
from: intent.from.clone(),
to: intent.to.clone(),
finished_unix: now,
stage: Some("rolled-back".into()),
error: Some(format!(
"the updated host crash-looped after install; rolled back via {}",
previous.display()
)),
log_path: Some(log.display().to_string()),
staged: false,
};
let _ = crate::update::jobs::write_json_atomic(&crate::update::jobs::result_path(), &record);
// Delete the intent BEFORE spawning: the incoming (previous) host must boot clean, and a
// missing intent keeps this one-shot even across a service restart.
let _ = std::fs::remove_file(&intent_path);
tracing::warn!(
failed_version = %intent.to,
back_to = %previous.display(),
"updated host is crash-looping — rolling back via the cached previous installer"
);
match std::process::Command::new(&previous)
.args(["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/SP-"])
.arg(format!("/LOG={}", log.display()))
.spawn()
{
// Detached on purpose: it stops this service and reinstalls the previous version.
Ok(child) => drop(child),
Err(e) => tracing::error!(error = %e, "failed to spawn the rollback installer"),
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Tray lifecycle: the one place that knows how to find, start, stop and check the per-user status
//! tray. Shared by the `tray` CLI subcommand and by post-update reconciliation
//! (`update::windows::relaunch_tray`).
//!
//! Why this exists at all: `punktfunk-tray.exe` is a per-USER, per-SESSION GUI process with no
//! recovery path of its own. The HKLM `Run` value only fires at sign-in, and nothing in the product
//! restarts a tray that died — so anything that kills one (an upgrade's `StopTrays`, a crash) left
//! the operator without an icon until they signed out and back in.
//!
//! The launch has to cross a privilege boundary in one direction but not the other, so [`start`]
//! tries the session-crossing path first and falls back to a plain spawn:
//!
//! * **From the host service (SYSTEM)** — the tray must land in the active console session under
//! the *logged-in user's* token, not SYSTEM's. That is
//! [`crate::interactive::spawn_in_active_session`] (`WTSQueryUserToken` +
//! `CreateProcessAsUserW`), and it needs `SE_TCB`, which only SYSTEM holds.
//! * **From an interactive shell** — the caller IS the user in the right session already, so
//! `WTSQueryUserToken` fails (an administrator does not hold `SE_TCB` either) and a plain spawn
//! is both sufficient and correct.
//!
//! Trying the privileged path and falling back on failure discriminates the two without a token
//! inspection, and without adding any `unsafe` to this crate.
use anyhow::{bail, Context, Result};
use std::path::PathBuf;
/// The tray's image name, as the installer lays it down next to `punktfunk-host.exe`.
pub const TRAY_EXE: &str = "punktfunk-tray.exe";
pub fn main(args: &[String]) -> Result<()> {
match args.first().map(String::as_str) {
Some("start") => {
let (pid, how) = start()?;
match pid {
Some(pid) => println!("status tray started (pid {pid}, {how})"),
None => println!("status tray is already running"),
}
Ok(())
}
Some("stop") => {
if stop() {
println!("status tray stopped");
} else {
println!("no status tray was running");
}
Ok(())
}
Some("status") => {
let path = tray_exe();
println!(
"status tray: {}",
match (&path, is_running()) {
(None, _) => "not installed".to_string(),
(Some(_), true) => "running".to_string(),
(Some(_), false) => "not running".to_string(),
}
);
if let Some(p) = path {
println!("executable: {}", p.display());
}
Ok(())
}
_ => bail!("usage: punktfunk-host tray <start|stop|status>"),
}
}
/// `punktfunk-tray.exe` next to this executable, when it is actually installed (the `trayicon`
/// task is optional, so its absence is a legitimate state, not an error).
pub fn tray_exe() -> Option<PathBuf> {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join(TRAY_EXE)))
.filter(|p| p.exists())
}
/// Is a tray running in ANY session? Reuses the conflicting-host scan's Toolhelp snapshot rather
/// than opening a second one. Best-effort: a failed snapshot reads as "not running", so callers
/// must treat this as a hint — never as proof for a destructive decision.
pub fn is_running() -> bool {
let stem = TRAY_EXE.trim_end_matches(".exe");
crate::detect::running_process_names()
.iter()
.any(|n| n == stem)
}
/// Start the tray if it is not already up.
///
/// `Ok(None)` = one was already running (idempotent by design: the tray also guards itself with a
/// `Local\PunktfunkTray` mutex, so even a lost race merely makes the second instance exit). The
/// `&'static str` names which mechanism launched it, for an honest CLI message.
///
/// Note for an ELEVATED interactive caller: the fallback spawn inherits this process's token, so
/// the tray then runs elevated. It works, but UIPI stops a medium-integrity `--quit` from closing
/// it later. Prefer `tray start` from a normal shell, or let the host service do it.
pub fn start() -> Result<(Option<u32>, &'static str)> {
let Some(exe) = tray_exe() else {
bail!("{TRAY_EXE} is not installed next to this executable");
};
if is_running() {
return Ok((None, "already running"));
}
// Quoted: the install directory is operator-chosen and routinely contains spaces.
let quoted = format!("\"{}\"", exe.display());
let session_err = match crate::interactive::spawn_in_active_session(&quoted, None) {
Ok(pid) => return Ok((Some(pid), "into the active console session")),
Err(e) => e,
};
// Only SYSTEM holds SE_TCB, so reaching here means an ordinary (possibly elevated) user — which
// is fine ONLY if we are already sitting in the session the icon has to appear in. Refuse
// loudly otherwise: a plain spawn from an ssh/RDP session would put a tray in a session nobody
// is looking at, report success, and leave the operator staring at an empty notification area.
if let Some((own, console)) = crate::interactive::console_session_mismatch() {
bail!(
"cannot place the tray in session {console} from session {own}: {session_err}\n\
crossing sessions needs SE_TCB, which only SYSTEM holds run this from the console \
session itself, or let the host service do it"
);
}
let child = std::process::Command::new(&exe)
.spawn()
.with_context(|| format!("spawn {}", exe.display()))?;
Ok((Some(child.id()), "in this session"))
}
/// Stop every tray instance. Returns whether one was running.
///
/// Graceful first, mirroring the uninstaller's own order (`[UninstallRun]`): `--quit` posts
/// WM_CLOSE to the tray window, which lets it remove its icon via `NIM_DELETE` instead of leaving a
/// ghost in the notification area until the shell next sweeps it. `--quit` only reaches the session
/// it runs in, so a force-kill reaps any instance in another session.
pub fn stop() -> bool {
let was_running = is_running();
if !was_running {
return false;
}
if let Some(exe) = tray_exe() {
// Best-effort and short: if the graceful close does not land we force it below anyway.
if let Ok(mut child) = std::process::Command::new(&exe).arg("--quit").spawn() {
let _ = child.wait();
}
for _ in 0..8 {
if !is_running() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
let _ = std::process::Command::new("taskkill")
.args(["/F", "/IM", TRAY_EXE])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
true
}
+10 -1
View File
@@ -194,7 +194,12 @@ fn poll_loop(
} else {
format!("https://{mgmt_addr}:{mgmt_port}/api/v1/local/summary")
};
let console_url = format!("https://127.0.0.1:{web_port}/");
// `/login`, not `/`: `/` is auth-gated and 302s to `/login`, and ureq follows redirects by
// default — so probing `/` spent TLS + `/` + a full cold `/login` SSR render inside one 2 s
// budget, and a console that was merely warming up read as down. `/login` is the cheapest page
// that proves the server is answering, and the agent below refuses redirects so the probe is
// exactly one round trip. (A 302 still counts as up via the `Status` arm in `probe_console`.)
let console_url = format!("https://127.0.0.1:{web_port}/login");
let agent = agent(load_pin());
let mut last: Option<(TrayStatus, bool)> = None;
// When the summary became unreachable while the service was running (grace anchor).
@@ -313,6 +318,10 @@ fn agent(pin: Option<[u8; 32]>) -> ureq::Agent {
.tls_config(Arc::new(cfg))
.timeout_connect(Duration::from_secs(2))
.timeout(Duration::from_secs(2))
// No redirect-following. Neither user of this agent wants it: the summary is a terminal
// JSON route, and the console probe treats any HTTP answer (302 included) as "up", so
// chasing the hop only spends the 2 s budget re-rendering a page nobody reads.
.redirects(0)
.build()
}
+6 -1
View File
@@ -559,9 +559,14 @@ fn elevate_service(hwnd: HWND, verb: &str) {
/// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page the
/// menu entry promised — the pairing queue, the virtual displays — instead of the dashboard.
fn open_web_console(hwnd: HWND, path: &str) {
// 127.0.0.1, not `localhost`: the console binds HOST=0.0.0.0 (scripts/windows/web-run.cmd),
// which is IPv4-ONLY, while Windows resolves `localhost` to ::1 first. A browser that does not
// fall back cleanly got connection-refused on a perfectly healthy console — and because the
// poller probes 127.0.0.1, the tray would call it up while handing over a URL that fails. Same
// literal in both places, so the menu can never disagree with the status next to it.
shell_open(
hwnd,
&format!("https://localhost:{}/{path}", app().web_port),
&format!("https://127.0.0.1:{}/{path}", app().web_port),
);
}
+22 -54
View File
@@ -193,29 +193,25 @@ The canonical "decide, don't just observe" pattern — approve pairing from your
## Recipe: full controller passthrough (VirtualHere)
To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive
triggers, USB rumble — instead of the emulated pad, hand the physical device from the couch to the
host over [VirtualHere](https://www.virtualhere.com/) (USB-over-IP), bound only while a client is
connected so the couch keeps its own controller the rest of the time.
triggers, USB rumble — or to use a device no emulation can stand in for, like a racing wheel or a
HOTAS, hand the physical device from the couch to the host over
[VirtualHere](https://www.virtualhere.com/) (USB-over-IP) while you play.
**The two sides.** VirtualHere is a server/client pair, and you run both:
**Use the plugin.** [VirtualHere passthrough](/docs/plugins#virtualhere-usb-passthrough) does all of
this for you: it finds the device by name (so it survives the couch rebooting), brackets it around
the session, gives it back if anything crashes, and tells you which half of the setup is broken when
it isn't working. That is the supported route, and the rest of this section is only for people who
would rather not install a plugin.
- **Server — on the couch** (where the pad is physically plugged in). Run the VirtualHere USB
Server there; it shares the pad on the LAN. Leave it running.
- **Client — on the host** (where the game and this automation run). Install the VirtualHere
Client (as a service, or the tray app). It auto-discovers the couch's shared pad on the same
LAN; across subnets, add it once with `<VH_CLIENT> -t "MANUAL HUB ADD,<couch-ip>:7575"`. The
client binary is `vhclientx86_64` on Linux, `vhui64.exe` on Windows, `vhclientosx` on macOS,
`vhclientarm64` on ARM Linux.
The client's `-t` flag is a one-shot IPC to the already-running client: `-t LIST` prints every
visible device with its address (`server.port`, e.g. `couch-deck.11`); `-t "USE,<addr>"` mounts it
onto the host; `-t "STOP USING,<addr>"` hands it back. The automation just brackets
`USE` / `STOP USING` around a session.
**The two sides.** VirtualHere is a server/client pair, and you run both: the **server on the couch**
(where the device is plugged in) shares it, and the **client on the host** mounts it. The client's
`-t` flag is a one-shot IPC to the already-running client — `-t LIST` prints every visible device
with its address (`server.port`, e.g. `couch-deck.11`), `-t "USE,<addr>"` mounts it, and
`-t "STOP USING,<addr>"` hands it back.
### Zero-code: two hooks
Bracket it on the stream with two [hooks](#hooks-hooksjson) — mount when video starts, release
when it stops. This is all most setups need:
Bracket it on the stream with two [hooks](#hooks-hooksjson):
```json
{
@@ -226,42 +222,14 @@ when it stops. This is all most setups need:
}
```
`couch-deck.11` is the device's VirtualHere address from `vhclientx86_64 -t LIST`.
`couch-deck.11` is the device's address from `vhclientx86_64 -t LIST`.
### Scripted: resolve by name, release on shutdown
The hooks above hard-code the address, and can strand the pad on the host if it's stopped
mid-stream (the `stream.stopped` hook never fires). The
Know what this trades away, because the plugin exists to fix exactly these: the address is
hard-coded, so it breaks when the couch reboots or the device moves port; and if the stream ends
abnormally the `stream.stopped` hook never fires, leaving the device stranded on the host until
somebody notices. There is also a
[`virtualhere-dualsense.ts`](https://git.unom.io/unom/punktfunk/src/branch/main/sdk/examples/virtualhere-dualsense.ts)
SDK example is the robust version: it resolves the device by **name substring** (survives the
address changing), can filter to **one couch** in a multi-client setup, and **releases the pad on
a clean shutdown** (`systemctl stop`, ^C) so the couch always gets its controller back.
SDK example if you want a worked script to build your own on.
It's a standalone [`@punktfunk/host` script](https://git.unom.io/unom/punktfunk/src/branch/main/sdk#running-a-single-script-as-a-service)
— run it in its own directory. The one edit is its import: the in-repo copy imports from
`../src/index.js`; outside the repo it's the published package:
```sh
mkdir ~/punktfunk-scripts && cd ~/punktfunk-scripts
bun init -y
bun add @punktfunk/host # point the @punktfunk scope at the registry first — see the SDK README
# save the example as virtualhere-dualsense.ts, and change its first import to the package:
# - import { connect } from "../src/index.js";
# + import { connect } from "@punktfunk/host";
VH_DEVICE=DualSense bun virtualhere-dualsense.ts
```
`VH_DEVICE` is required — a VirtualHere address (`couch-deck.11`) or a device-name substring
(`DualSense`). Optional: `VH_CLIENT` overrides the client binary (default `vhclientx86_64`);
`VH_ONLY_CLIENT` binds only for one punktfunk client label. Running on the host box the SDK needs
no token or URL — it reads the host's loopback credentials itself (see
[Connection resolution](https://git.unom.io/unom/punktfunk/src/branch/main/sdk#connection-resolution)).
Keep it running as a
[systemd user unit](https://git.unom.io/unom/punktfunk/src/branch/main/sdk#running-a-single-script-as-a-service)
(its default `SIGTERM` triggers the script's own release step — so `systemctl stop` hands the pad
back), or drop it under the [scripting runner](/docs/plugins) with your other units.
> The example brackets on `client.connected` / `client.disconnected` — the pad returns to the
> couch the moment they disconnect. Switch to `stream.started` / `stream.stopped` if you'd rather
> pass it through only while video is actually flowing; both are noted in the file's header.
> VirtualHere is a commercial product, sold separately by VirtualHere Pty. Ltd. — free for one
> shared device, licensed beyond that. Punktfunk is not affiliated with it.
+1
View File
@@ -15,6 +15,7 @@
"steamos-host",
"windows-host",
"web-console",
"updating",
"---Configure your desktop---",
"configuration",
"kde",
+45 -2
View File
@@ -1,18 +1,19 @@
---
title: Plugins
description: First-party plugins that sync your ROM collection or your Playnite library into the game library — and how to install them.
description: First-party plugins sync your ROM collection or Playnite library into the game library, or hand a real USB device on the couch to the host — and how to install them.
---
Plugins extend the host through the **scripting runner** (see [Events & hooks](/docs/automation)). A
plugin runs alongside the host, reconciles titles into your **game library** as a provider — so they
appear in the grid on every client — and can add its own page to the [web console](/docs/web-console).
Two first-party plugins today:
Three first-party plugins today:
| Plugin | What it does |
|---|---|
| **ROM Manager** | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. |
| **Playnite** | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. |
| **VirtualHere** | Hands a real USB device on the couch — wheel, HOTAS, pad — to the host while you play, and gives it back after. Needs [VirtualHere](https://www.virtualhere.com/), sold separately. |
## Installing from the console
@@ -200,6 +201,48 @@ seconds of any library change. Filters (installed-only, per-store, hidden) live
`%ProgramData%\punktfunk\playnite\config.json`. Details are in
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-playnite).
## VirtualHere (USB passthrough)
`@punktfunk/plugin-virtualhere` — hands a **physical USB device** on your couch machine to the
host while you play, and gives it back afterwards. The game sees the real device, so this is the
answer for a racing wheel, a HOTAS, pedals, an arcade stick, or any controller whose value is that
it is not emulated.
<Callout type="warn">
This plugin drives [VirtualHere](https://www.virtualhere.com/), a commercial USB-over-IP product
**sold separately** by VirtualHere Pty. Ltd. Nothing from VirtualHere is bundled or downloaded by
Punktfunk — you install and license it yourself. The plugin is not affiliated with or endorsed by
VirtualHere.
</Callout>
You need both halves of VirtualHere running before the plugin is any use:
- **The USB Server on the couch**, sharing the device. Free for one device; beyond that, and to run
the client as a service, VirtualHere requires a purchased licence.
- **The USB Client on the host**, ideally installed as a service so it survives logging out.
Servers exist for Windows, Linux, macOS and Android couches. **There is no VirtualHere server for
iOS or tvOS**, so iPhones, iPads and Apple TVs cannot pass devices through — nothing on the
Punktfunk side can change that.
```sh
punktfunk-host plugins add virtualhere
punktfunk-host plugins enable
```
Then open the console's **VirtualHere** page. The **Devices** tab lists whatever the couch is
sharing; pick one and it writes a rule matching the device *by name*, which keeps working after the
couch reboots or the device moves to another port. By default the device is handed over when video
starts and returned when it stops, so the couch keeps its own controller the rest of the time —
you can widen that to the whole session, or to the entire time a client is connected.
If nothing happens, the **Diagnostics** tab walks the whole two-sided setup and tells you which
part to fix. The same checks are available as `punktfunk-plugin-virtualhere doctor`, which is the
useful thing to paste into a support thread.
Full configuration is in
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-virtualhere).
## Troubleshooting
**`punktfunk-host: command not found`** — on Windows, open a new terminal so it picks up the
+97
View File
@@ -0,0 +1,97 @@
---
title: Updating the Host
description: How to see when a newer punktfunk host is available — the web console's update card — and the update command for every install method.
---
The web console tells you when a newer host is out. The **Host** page has an **Updates** card
showing the version you run, the channel you follow (stable or canary), how this host was
installed, and — once a newer release exists — the exact command that updates it. The
"update available" state also fires an `update.available` event on the host
[event stream](/docs/automation), so hooks and scripts can react to it too.
The check is a small signed manifest the host fetches from the punktfunk release feed and
verifies against keys built into the host itself — a tampered or replayed feed is rejected, and
the console will tell you when a check failed rather than silently showing stale facts.
## Updating, per install method
The console shows the right one of these automatically; for reference:
| How you installed | How to update |
|---|---|
| Windows installer / winget | `winget upgrade unom.PunktfunkHost`, or run the newer `punktfunk-host-setup.exe` |
| Ubuntu / Debian (apt) | `sudo apt update && sudo apt install --only-upgrade punktfunk-host` |
| Fedora (dnf) | `sudo dnf upgrade punktfunk` |
| Bazzite sysext (recommended) | `sudo punktfunk-sysext update` |
| Bazzite rpm-ostree layer | `sudo /usr/share/punktfunk/update-punktfunk.sh` (staged — reboot to finish) |
| Arch / CachyOS (pacman) | `sudo pacman -Syu` (a normal full system upgrade) |
| Steam Deck (on-device build) | `bash ~/punktfunk/scripts/steamdeck/update.sh --pull` |
| NixOS | update the flake input and rebuild |
After a Linux package update, restart the host to pick up the new binary:
```bash
systemctl --user restart punktfunk-host
```
(The Windows installer restarts the service itself; `punktfunk-sysext update` prints the same
restart hint when it's needed.)
## One-click updating (Windows)
On a Windows host the card shows an **Update now** button instead of a command. It asks for the
console password again (a saved login alone can't restart your host), then the host downloads
the installer, verifies it against the signed release manifest **and** its code signature, and
runs it silently — the service restarts at the end and the page reconnects by itself. If a
stream is live you'll be warned first: updating drops it.
Every attempt leaves a result in the card (and an installer log under
`C:\ProgramData\punktfunk\logs\update-<version>.log`) — including across the restart, so a
failed update is never silent. To disable the button entirely on a host, set
`PUNKTFUNK_UPDATE_APPLY=0` in `host.env`; the card then shows the manual command instead.
## One-click updating (Linux — opt-in)
The apt, dnf, Bazzite-sysext, and rpm-ostree installs can one-click update too, via a small
root helper the packages ship (`pf-update` + a `punktfunk-update.service` oneshot). It's **off
until you opt in**, because a web button that ends in root deserves an explicit decision:
```bash
sudo usermod -aG punktfunk-update $USER # then log out and back in
```
That group membership is the entire grant — a polkit rule lets its members start exactly that
one service, whose only job is "run this system's normal package update for the punktfunk
packages, then prove the new binary runs". The button never chooses versions or URLs; your
package manager's own signed repositories stay the source of truth. The card shows the opt-in
command until you've done this, and the manual command always keeps working.
Notes per method: on **rpm-ostree** the update is staged and the card will say so — reboot to
finish (the console never reboots your machine). On **Arch/pacman** the button additionally
requires `PACMAN_FULL_SYSUPGRADE=1` in `/etc/punktfunk/update.conf`, because the only safe
pacman update is a full `pacman -Syu` — partial upgrades are how Arch boxes break, and we
won't run one. After a successful update the host restarts itself and the page reconnects.
The **Steam Deck on-device build** gets the button too, with no opt-in (it's your own user's
install, no root involved): it runs the same `update.sh` rebuild the docs describe, which
compiles on the Deck — expect it to take a while; the card keeps showing progress and the log
lands in `~/.config/punktfunk/logs/update-steamos.log`.
## Turning the check off
The check contacts `git.unom.io` (the punktfunk forge) and nothing else, and sends nothing but a
normal download request. If you'd rather the host never checks, set:
```bash
PUNKTFUNK_UPDATE_CHECK=0
```
in the host's environment (`host.env` on Windows, the systemd user unit environment on Linux).
The card then shows checks as disabled; everything else keeps working.
## If the card says the feed is stale
"Feed hasn't changed in over 45 days" means checks *succeed* but nothing new arrives. Usually
that just means no release happened for a while; if the [releases page](https://git.unom.io/unom/punktfunk/releases)
shows something newer than the card does, something between this host and the feed is pinning old
data — worth a look at proxies or DNS on the way to `git.unom.io`.
+38
View File
@@ -0,0 +1,38 @@
Wire-compatible with 0.21.x and 0.22.x — nothing about streaming changed, and everything already paired keeps working. This release repairs the Windows installer and gives every host something new: the web console now tells you when a newer Punktfunk is out, and can install it for you where the platform allows. The apps on your phone, tablet, Mac and TV are unchanged.
**If you stream to a Windows PC, update that PC.** The 0.22.1 and 0.22.2 installers were built without the web console in them at all — not broken, absent. If the tray menu on your PC says "Open web console (not responding)" and the page never loads no matter what you try, that is this, and reinstalling those versions could never have fixed it.
## New
- **The console tells you when a newer host is out.** The Host page has an **Updates** card: the version you're running, the channel you follow (stable or canary), and — once something newer exists — release notes and the exact way to update *your* kind of install. The check is a small signed file the host verifies against keys built into it, so nothing between you and the project can feed it a fake update, and a feed that ever tried to roll you backwards is refused outright. It contacts only the Punktfunk forge, and `PUNKTFUNK_UPDATE_CHECK=0` turns it off entirely. Scripts can react too: the host emits `update.available` and, after a successful update, `update.applied` on its event stream.
- **On Windows, updating the host is now one click.** The card grows an **Update now** button: it asks for your console password again (a remembered login alone can't restart your PC's host), then downloads the installer, checks it against the signed release manifest *and* its code signature, and runs it silently — the service restarts and the page reconnects by itself. If someone is streaming you're warned first, because updating drops the stream. Every attempt leaves its outcome in the card even across the restart, with the installer's log path when something went wrong — and if a freshly installed version ever crash-loops, the host puts the previous installer back on its own and says so. `PUNKTFUNK_UPDATE_APPLY=0` in `host.env` removes the button on hosts that should never have it.
- **Linux hosts can opt in to the same button.** apt, Fedora, Bazzite (sysext and rpm-ostree) installs get one-click updating through a deliberately tiny root helper the packages now ship. It's off until you decide otherwise — enabling it is one command, `sudo usermod -aG punktfunk-update $USER`, and that group membership is the entire grant: the button can only ever run your package manager's normal update for the Punktfunk packages, from the same signed repositories you already use, and it never picks versions or URLs. rpm-ostree updates are staged and the card asks you to reboot (it never reboots for you). On Arch the button additionally requires opting into a full `pacman -Syu` in `/etc/punktfunk/update.conf`, because a partial upgrade is how Arch boxes break — we won't run one. Steam Deck on-device installs get the button with no opt-in at all: it runs the same rebuild `update.sh` always did, just from the couch. The full story is on the new [Updating the Host](https://punktfunk.unom.io/docs/updating) docs page.
## Fixed
- **The web console is in the installer again.** On 0.22.1 and 0.22.2 the Windows installer shipped with the console missing entirely: the files it runs from were never included, so the PC had nothing to start and nothing to open. The host itself was fine the whole time — streaming, pairing and controllers all worked — but the settings page you reach in a browser simply was not there. The tray reported it as "not responding", which was true and also the only clue you got, and because every reinstall produced the same installer, uninstalling and reinstalling made no difference. Updating to 0.22.3 restores it; you don't need to touch your settings, your password, or your paired devices.
- **Updating no longer stops on a "DeleteFile failed; code 5" error.** Partway through an update, the installer could stop on a dialog complaining that it could not replace `bun\bun.exe`, offering only Try again, Skip, or Cancel — and Try again usually failed the same way. Windows refuses to replace a program while it is running, and the pieces of Punktfunk that were still running weren't the ones the installer knew how to stop. It now shuts all of them down, waits for them to actually exit rather than assuming, and if something still holds the file it finishes the install and puts the new copy in place on your next restart instead of leaving you stuck at a dialog.
- **Updating no longer switches your plugins off.** If you had enabled the script and plugin runner, every update quietly disabled it again, so plugins stopped running until you noticed and re-enabled them by hand. Updates now leave it exactly as you had it — on if it was on, and still off by default for everyone who has never turned it on.
## Improved
- **You can save or share the host's log from the console.** The Logs page has two new buttons: one downloads what you're looking at as a timestamped `.log` file, the other hands it to your phone or tablet's share sheet, or copies it to the clipboard on a desktop. Handy when someone asks you for a log — the file carries full dates and times, so it still makes sense once it leaves your browser. Whatever level filter and search you have applied is what you get, and it saves everything that matches rather than just the part on screen.
- **Televisions that struggle with the startup speed test can be told to ease off.** Two seconds into a session Punktfunk briefly bursts traffic to measure how much room your network really has. On some TVs that burst is enough to disturb the very link it is measuring — one LG set showed no video for fourteen seconds when it went badly. The burst's size can now be capped, so a device that already limits its own speed test can keep ours in line with it. Nothing changes unless a device asks for it.
## Under the hood (for developers)
- The console's absence was one CI variable in the wrong place. `windows-host.yml` exported `WEB_OUTPUT_DIR` on the last line of "Build + smoke-boot web console (bun)", and `be2fabcf` had just put that step behind `if: steps.webconsole.outputs.cache-hit != 'true'`. So the first build after the cache was populated — and every build after it with `web/**` and `sdk/**` unchanged — skipped the step, left the variable unset, and `pack-host-installer.ps1` reads an unset `WEB_OUTPUT_DIR` as "omit the console", announcing it in a single `Write-Host` before packing happily. Confirmed from the job logs rather than inferred: on both the v0.22.1 (run 14235) and v0.22.2 (run 14272) tag builds, step 12 is `skipped` and the job is green. `BUN_EXE` and `SCRIPTING_BUNDLE` are exported from unconditional steps, which is why those installers still carried bun and the plugin runner but no `{app}\web`.
- Downstream, `web setup` bailed at `web launcher missing: {app}\web\web-run.cmd` before registering anything, so there was no `PunktfunkWeb` task, no listener on 47992, and no firewall rule — and Inno ignores `[Run]` exit codes, so the failure left no trace in the install at all. `WEB_OUTPUT_DIR` now comes from its own unconditional step that throws when `web\.output\server\index.mjs` is absent, and a new pre-pack step asserts all five payloads (console, bun, plugin runner, FFmpeg DLLs, VB-CABLE) so a missing input fails the build rather than silently redefining the installer. The shape is borrowed from the packer's existing VB-CABLE check. FFmpeg was the most dangerous of the silent ones: an `amf-qsv` host link-imports avcodec, so an installer without those DLLs ships a host that cannot start at all.
- The same cache-hit build is why `bun.exe` locked. It ships under `WithWeb` **or** `WithScripting`, but the installer's pre-copy stop was `#ifdef WithWeb` — so a console-less installer shipped bun while the only code that stopped bun was compiled out. `StopBunRuntimes` replaces `StopWebConsole` behind the same `WithWeb || WithScripting` gate the payload uses, and covers what the old routine structurally could not: neither bun runs under the host service (both are Task Scheduler tasks — `PunktfunkWeb` as SYSTEM, `PunktfunkScripting` as LocalService), and the runner listens on no port, so a task-name-plus-port sweep could never see it. It now disables both tasks before stopping them — they carry restart-on-failure at 10× and 999× a minute, and the web task also has a logon trigger, so a force-kill alone invited a respawn into the middle of a copy that takes over a minute at `lzma2/max` — kills any bun whose image lives under the install dir (by path, so an unrelated bun survives), and polls until they are gone, since `Stop-ScheduledTask` returns on request and `Stop-Process` is `TerminateProcess`. `bun.exe` also gained `restartreplace`, so a survivor defers to reboot instead of dead-ending the install.
- Disabling a task is not free: unlike a stopped one it does not return at the next boot, so an aborted install would have taken the console down permanently. Two restores cover it — a last-in-`[Run]` entry for the normal flow and `DeinitializeSetup`, which Inno calls even on user cancel — both re-enabling only what was enabled before the copy. That is also the plugin-runner fix: the scripting `[Run]` entry re-registers its task and then unconditionally disables it, correct on a first install and wrong on every upgrade since.
- `PUNKTFUNK_ABR_PROBE_KBPS` sets the startup capacity probe's burst target for embedders. Unset, zero or unparseable keeps the 2 Gbps default, so existing sessions are unchanged; `PUNKTFUNK_ABR_PROBE=0` remains a poor substitute because it pins the climb ceiling at the negotiated ~20 Mbps.
- The console's log export serializes the filters' full result rather than the rendered tail — the 1000-row cap is a DOM budget and has no business truncating a file destined for a bug report — and stamps each line with a local ISO 8601 timestamp plus numeric offset. Web Share level 2 is probed at runtime after mount (SSR has no `navigator`, and guessing there would mismatch on hydration), falling back to the clipboard, and the button is omitted only where neither exists.
- The update check's truth is a per-channel Ed25519-signed manifest (`…/generic/punktfunk-update/{stable,canary}/manifest.json` + `.sig`), verified against keys pinned in the host binary via the plugin-index machinery, with a persisted monotonic serial as the anti-rollback floor and the channel name bound into the signed document. Stable manifests publish from `announce.yml` — the existing manual fleet-green gate doubles as the gate for the fleet learning about a release; canary rides `windows-host.yml`. TLS and the registry are transport, never trust.
- `/api/v1/update/*` is admin-lane only: whole-prefix denied to the plugin token and absent from the paired-cert allowlist. Apply requests carry no version, URL, or channel — the privileged side derives everything from its own verified state — and the console's BFF re-verifies the console password per apply (sharing the login throttle) while every mutating route now requires a same-origin `Sec-Fetch-Site`. Outcomes survive the host's own restart via an intent record reconciled at boot.
- The Linux packages grow four pieces: the dep-free root helper `/usr/libexec/punktfunk/pf-update`, the fixed-ExecStart oneshot `punktfunk-update.service`, a polkit rule granting exactly that unit's `start` verb to the `punktfunk-update` group, and the group itself — created empty by every postinst, populated by no one.
- No wire, ABI or driver-protocol changes: wire protocol 2, C ABI 13, Windows virtual-gamepad channel 3, virtual-display driver protocol 6 — identical to 0.22.0, 0.22.1 and 0.22.2.
+44
View File
@@ -528,6 +528,22 @@
#define VIDEO_CAP_CHACHA20 64
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_caps`] bit: the client's decoder accepts **multi-slice access units** — H.264/
// HEVC frames carrying several slice NALs (latency plan §7 LN1: the encoder splits frames so
// sub-frame readback can ship early slices while the tail encodes). Decoder-level, so the
// EMBEDDER sets it from what its decode stack actually handles: the desktop clients' FFmpeg/
// D3D11VA/Vulkan-video decoders are fine, but mobile/TV MediaCodec is per-SoC — Amlogic HEVC
// decoders (Chromecast with Google TV, Fire TV) wedge the whole DEVICE on multi-slice frames
// (the 0.17.0 field regression: the 4-slice Linux default froze streams on first frame and
// watchdog-rebooted the CCwGTV), which is exactly why Moonlight requests 1 slice per frame for
// every hardware decoder. The host defaults to >1 slice ONLY toward a client that sets this
// bit (`PUNKTFUNK_NVENC_SLICES` stays the explicit operator override in both directions);
// every other client gets single-slice frames — the pre-0.17 wire shape. NOTE: this takes the
// video_caps byte's last free bit — the next video cap needs a second byte (ABI bump).
#define VIDEO_CAP_MULTI_SLICE 128
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
@@ -571,6 +587,15 @@
#define CLIENT_CAP_CURSOR 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// `Hello.client_caps` bit: this client runs a vsync-aware presenter and will send
// [`PhaseReport`](super::control::PhaseReport)s (~1 Hz) so the host can phase-lock its
// capture/send tick to the client's display latch (design/phase-locked-capture.md). Without
// the bit the host never arms the phase controller; toward an older host the reports are
// simply ignored — no behavior change in either direction.
#define CLIENT_CAP_PHASE_LOCK 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
@@ -740,6 +765,11 @@
#define MSG_CLOCK_ECHO 49
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`PhaseReport`].
#define MSG_PHASE_REPORT 50
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this
// session. Idempotent; opt-in is enforced here, not just in UI.
@@ -2753,6 +2783,20 @@ PunktfunkStatus punktfunk_connection_report_decode_us(const PunktfunkConnection
uint32_t us);
#endif
// Report this client's display-latch grid so the host can phase-lock its capture tick
// (design/phase-locked-capture.md). `next_latch_host_ns` must already be host clock — convert
// with the connection's clock offset (`T_host = T_client + offset`). Fire-and-forget; call ~1 Hz
// from a vsync-aware presenter. No-op toward a host that never negotiated the capability.
//
// # Safety
// `c` is an opaque handle from a `*_new`/`*_pair` the caller has not yet freed, or null (an
// error, not UB).
PunktfunkStatus punktfunk_connection_report_phase(const PunktfunkConnection *c,
uint64_t next_latch_host_ns,
uint32_t latch_period_ns,
uint32_t uncertainty_ns,
uint32_t arrival_lead_ns);
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Whether [`punktfunk_connection_report_decode_us`] is worth calling this session: writes 1 to
// `out` only when the adaptive-bitrate controller is armed (Automatic bitrate, non-PyroWave), so a
+19 -1
View File
@@ -100,7 +100,8 @@ build() {
-p punktfunk-cli
else
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli \
-p pf-update
# The status tray in its OWN cargo invocation — load-bearing, not tidiness. Cargo unifies features
# across everything in one build, so co-building the tray with the host pulls the host's
# ashpd -> zbus/tokio onto the tray's shared zbus; the tray (ksni async-io + blocking, no tokio
@@ -159,6 +160,17 @@ package_punktfunk-host() {
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
sed -i 's#/usr/libexec/punktfunk/pf-dm-helper#/usr/lib/punktfunk/pf-dm-helper#' \
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
# Web-console-triggered updates (host-update-from-web-console.md §7): root helper + oneshot
# unit + group-scoped polkit rule. Same no-libexec relocation as pf-dm-helper, with the
# unit's ExecStart rewritten to match. On pacman the helper additionally requires the
# explicit PACMAN_FULL_SYSUPGRADE opt-in (partial upgrades are against Arch doctrine).
install -Dm0755 "$R/target/release/pf-update" "$pkgdir/usr/lib/punktfunk/pf-update"
install -Dm0644 "$R/packaging/linux/punktfunk-update.service" \
"$pkgdir/usr/lib/systemd/system/punktfunk-update.service"
sed -i 's#/usr/libexec/punktfunk/pf-update#/usr/lib/punktfunk/pf-update#' \
"$pkgdir/usr/lib/systemd/system/punktfunk-update.service"
install -Dm0644 "$R/packaging/linux/49-punktfunk-update.rules" \
"$pkgdir/usr/share/polkit-1/rules.d/49-punktfunk-update.rules"
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads)
install -Dm0644 "$R/scripts/punktfunk-modules.conf" "$pkgdir/usr/lib/modules-load.d/punktfunk.conf"
# 32 MB UDP socket buffers (send-side headroom at high bitrate)
@@ -173,6 +185,12 @@ package_punktfunk-host() {
# operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it.
install -Dm0644 "$R/scripts/punktfunk-host-desktop-session.conf" \
"$pkgdir/usr/share/punktfunk/punktfunk-host-desktop-session.conf"
# Install-kind + channel marker, read by the host's update-check surface (planning:
# host-update-from-web-console.md §4.1). A canary build's pkgrel is `0.<zero-padded run>`.
local _pf_update_channel=stable
[[ $pkgrel == 0.* ]] && _pf_update_channel=canary
printf 'pacman %s\n' "$_pf_update_channel" | \
install -Dm0644 /dev/stdin "$pkgdir/usr/share/punktfunk/install-kind"
install -Dm0644 "$R/scripts/punktfunk-kde-session.service" "$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service"
sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#/usr/share/punktfunk/headless/run-headless-kde.sh#' \
"$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service"
+7
View File
@@ -1,5 +1,11 @@
# pacman install scriptlet — mirrors the RPM %post / deb postinst.
_ensure_update_group() {
# The (empty) opt-in group for web-console-triggered updates — nobody is auto-added.
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || true
}
post_install() {
_ensure_update_group
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=misc 2>/dev/null || true
# Apply the UDP socket-buffer tuning now (also auto-applied at boot by systemd-sysctl).
@@ -48,6 +54,7 @@ MSG
}
post_upgrade() {
_ensure_update_group
udevadm control --reload-rules 2>/dev/null || true
sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
}
+3
View File
@@ -164,6 +164,9 @@ post_merge() {
install -Dm0644 /usr/lib/modules-load.d/punktfunk.conf /etc/modules-load.d/punktfunk.conf 2>/dev/null || :
install -Dm0644 /usr/lib/udev/rules.d/60-punktfunk.rules /etc/udev/rules.d/60-punktfunk.rules 2>/dev/null || :
udevadm control --reload 2>/dev/null || :
# The (empty) opt-in group for web-console-triggered updates (the sysext ships the pf-update
# helper + unit + polkit rule in its /usr; the group can't ride an image) — nobody is auto-added.
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || :
modprobe vhci-hcd 2>/dev/null || :
# Re-fire the vhci rule against the (possibly already-present) controller so attach/detach pick up
# the input-group ownership even when the module's original add event predated the reloaded rule.

Some files were not shown because too many files have changed in this diff Show More