- Add the Windows host (implemented & shipping: DXGI capture, SudoVDA
virtual display, NVENC, ViGEm, WASAPI, LocalSystem service installer;
NVIDIA-only, x64-only) — it was absent entirely.
- Add the Android client (full client: AMediaCodec/HDR10 decode, Oboe
audio + mic, gamepad feedback, discovery, pairing, Compose phone+TV UI;
Google Play internal testing) and drop the stale "scaffolds" item.
- macOS stage-2 presenter: built + live-validated behind the opt-in flag,
not "next".
- Concurrent sessions + delegated pairing approval marked done.
- Layout/CI: note Windows host backends and per-client release workflows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refresh the README and documentation for public visitors:
- README: public-facing rewrite with accurate status for all four native
clients (macOS, Linux, Windows, Android) and the Windows host.
- docs site: fix stale client status (Android is a full client, not a
scaffold; Windows client is stage-1 complete + signed MSIX), add the
missing Android client section, correct "which client" guidance.
- Windows host: corrected from "deferred/scoped" to implemented & shipping
(NVIDIA-only, x64-only) across windows-host, roadmap, status,
requirements, running-as-a-service, and the README.
- Remove internal infrastructure from public docs (box names, private IPs,
SSH/token commands, deploy topology); rewrite status.md as a public
project-status page; sanitize ci.md and implementation-plan.md.
- Update clients/android and clients/apple READMEs to current state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Steam Deck (SteamOS) ships its OWN gaming session — `gamescope-session.target`
driven by `/usr/lib/steamos/gamescope-session`, not Bazzite's `gamescope-session-plus`.
That script `exec gamescope`s with HARDCODED physical-panel args (`-w 1280 -h 800 -O
'*',eDP-1`) and launches Steam via a SEPARATE `steam-launcher.service`, so the existing
managed-session path (which assumes session-plus) couldn't honor the client's mode — an
attach captured the panel's native 1280x800 instead.
Add a SteamOS branch to the managed-session path: detect it, write a `gamescope` PATH-shim
that rewrites the hardcoded args to `--backend headless -W <client> -H <client> -r <hz>`,
drop a transient user `gamescope-session.service.d` override pointing PATH at the shim +
the mode, then RESTART the whole target so `steam-launcher.service` brings Steam up IN the
headless gamescope at the client's resolution. Attach to the one fresh node (the restart
kills any prior gamescope, so no stale-node attach). Restore-on-disconnect removes the
override + restarts the target back to the physical panel (debounced; skipped if the user
switched to a desktop session). All user-level (`systemctl --user`) — no root.
Also widen `build_pipeline_with_retry` to 8 attempts (~90s): a host-managed gamescope
session cold-starting Steam Big Picture takes 30-60s to first frame, and a first-connect
timeout would tear down the warm session (forcing another cold start on reconnect).
Permanent failures still fail fast via `is_permanent_build_error`.
Validated live on a Steam Deck: Game Mode auto-detected, host takes over headless at the
client's mode (720p / 1080p), Steam Big Picture streamed glass-to-glass to the Mac at the
requested resolution. Single-tenant (concurrent clients at different modes still thrash —
a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared unom CMS is now multi-tenant; the footer global became a per-tenant
collection. Query footers scoped to tenant.slug = punktfunk instead of the
removed /globals/footer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WiFi drivers (e.g. ath11k on the Steam Deck) return ENOBUFS — not
EAGAIN/EWOULDBLOCK — when the tx queue is momentarily full. Rust maps
ENOBUFS to ErrorKind::Uncategorized, so `is_transient_io` (which only
matched WouldBlock/ConnRefused/ConnReset) treated it as a real error and
tore the whole stream down on a single transient burst.
This presented as a vicious Heisenbug on the Deck: the native host
streamed flawlessly on loopback and under a debugger (anything slow
enough not to fill the small ~416 KB wlan0 buffer), but died at full rate
cross-machine over WiFi — flaky hang-or-SIGKILL because tx-queue-full is
probabilistic. Diagnosed live via a forced core dump (gdb on the hung
core): the data-plane thread had bailed on a fatal send error.
Treat ENOBUFS (and asynchronous network-path blips ENETUNREACH /
EHOSTUNREACH / ENETDOWN / EHOSTDOWN) as a lossy drop like WouldBlock —
FEC + the next frame recover. Validated: 6/6 back-to-back cross-machine
streams over the Deck's WiFi, host stable, p50 ~4.4 ms (one run dropped
4/300 frames *gracefully*, 0 mismatched — the fix working as intended).
Also surface a data-plane bind/hole-punch failure directly in punktfunk1
(it was previously only reported after teardown, which a stall could
swallow entirely).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pull the same footer from the shared unom CMS global (cms.unom.io) and render
it globally under both the home and docs layouts. Read-only typed fetch in a
server-side root loader (falls back to null on a CMS hiccup). Root-relative
links target the marketing site, so they're resolved against its origin (the
docs don't host /legal/* etc.); themed with Fumadocs tokens for light/dark.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the CSS-mask/webp wordmark with the inline vector from
Export/Punktfunk_Logo-Text_No-Border_Dark.svg (white export background
dropped), painted via currentColor — deep-violet on light, light-violet on
dark. Crisp at any size; drops the now-unused funk-wordmark.{webp,png}.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Swap the plain "punktfunk" text in the nav and landing hero for the real
brand wordmark from the marketing site. The source asset is a single
light-violet variant (made for dark surfaces), so it's painted as a CSS
mask and coloured per theme — deep-violet on light, light-violet on dark —
to stay legible with the docs' light/dark toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Theme the Fumadocs docs site with the punktfunk identity, mirroring the
marketing site:
- Swap the stock `neutral` preset for `purple`, then override --color-fd-*
with the violet lens-mark palette (#6c5bf3 / #a79ff8). The brand is the
violet, not the site's blue marketing background, so the blue is not used
as a reading surface; dark mode tints the chrome toward the app-icon
violet-dark (#1c1530).
- Adopt @unom/ui's token contract (--brand/--primary/--accent + bg-brand
etc.) as the shared token source, and @source its dist.
- Load Geist (the brand typeface) via @fontsource-variable/geist.
- Add the BrandMark lens to the nav + landing hero, wire the brand
favicon.svg, and add Docs/Website nav links.
- Keep the Fumadocs light/dark toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Phase 3 GPU-aware codec mask (e2cef91) probes VAAPI on any non-NVIDIA host.
On a GPU-less box (CI container: no /dev/nvidia* -> `auto` picks VAAPI, but there's
no VA display) the probe returns all-false, so the mask was 0 -- the host
advertised NO codecs, and the serverinfo unit test failed.
Fall back to the static superset when the probe yields nothing (VAAPI wasn't
usable, not "the GPU encodes nothing"); quiet ffmpeg's expected "No VA display"
error during the probe; and assert the test against codec_mode_support() rather
than a hardcoded 65793 so it's deterministic regardless of the build host's GPU.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Polish for AMD/Intel support:
- GameStream serverinfo advertises only codecs the GPU can ACTUALLY encode on
the VAAPI backend (probed once by opening a tiny encoder per codec). AV1
encode is narrow (Intel Arc/Xe2+, AMD RDNA3+/RDNA4) and an old iGPU may lack
HEVC, so a Moonlight client never negotiates a codec the encoder can't open.
NVENC/Windows keep the Moonlight-validated static mask. Validated on a Radeon
780M: h264/h265/av1 all probe true -> mask unchanged (65793).
- Packaging: Recommends mesa-va-drivers + intel-media-va-driver (deb) /
mesa-va-drivers + intel-media-driver (rpm) so the auto-selected VAAPI backend
works out of the box on AMD/Intel; NVIDIA boxes can --no-install-recommends.
(Fedora note: stock mesa-va-drivers disables HEVC/AV1 -- needs the freeworld
variant from RPM Fusion.)
- De-NVIDIA-fy the user-facing encoder log/context strings ("open NVENC" ->
"open video encoder") now that VAAPI is a first-class backend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 of AMD/Intel support: the VAAPI encoder now takes the capture dmabuf
directly and does the RGB->NV12 colour conversion on the GPU's video engine,
eliminating the host-side de-pad + swscale CSC + upload the CPU path pays.
- capture: a vendor-neutral FramePayload::Dmabuf (dup'd fd + fourcc/modifier/
layout). When zero-copy is on, the EGL->CUDA importer is unavailable (any
non-NVIDIA host), and the backend is VAAPI, the capturer advertises LINEAR
dmabuf and hands the raw buffer to the encoder instead of CPU-copying it.
- encode/vaapi: the encoder self-configures from the first frame's payload (no
open_video signature change). The dmabuf arm wraps the buffer as an
AV_PIX_FMT_DRM_PRIME frame and pushes it through a filter graph
buffer(drm_prime) -> hwmap(vaapi) -> scale_vaapi=nv12 -> buffersink; the
encoder takes NV12 surfaces straight from the sink. The Phase 1 CPU-upload
path is kept as the other arm (used when capture produces CPU frames).
Live-validated on a Radeon 780M (real Sway/xdpw desktop capture): correct,
pixel-perfect HEVC, and ~10x less host CPU at 1440p (4.2s -> 0.4s of CPU for
300 frames) -- the de-pad/CSC/upload moves to the GPU. NVIDIA unchanged
(zero-copy still imports to CUDA; the passthrough path only engages on
non-NVIDIA hosts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CPU de-pad path trusted PipeWire's MAP_BUFFERS slice (`d.data()`, length =
`data.maxsize`). xdg-desktop-portal-wlr hands MemFd ScreenCast buffers whose
maxsize exceeds the bytes PipeWire actually maps into our process, so reading to
maxsize ran off the end of the mapping and SIGSEGV'd the capture thread —
crashing every CPU-path capture on Sway/wlroots (and thus any non-NVIDIA host,
which has no CUDA zero-copy importer and always falls back to this path).
mmap the fd ourselves, sized to its real length (fstat), for any fd-backed
buffer (MemFd SHM or DmaBuf); fall back to `d.data()` then drop. The existing
`needed > avail` guard now drops cleanly instead of over-reading. This also
subsumes the original "MAP_BUFFERS didn't map a Vulkan dmabuf" fallback.
Verified: fixes real Sway-desktop portal capture -> VAAPI HEVC on a Radeon 780M
(correct image + colours); the NVIDIA zero-copy path (returns before this code)
and the NVIDIA/KWin CPU path (self-mmap, fd_len == maxsize) both still work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The host hard-linked libcuda.so.1 on Linux (`#[link(name="cuda")]` in
`zerocopy::cuda`), so the binary wouldn't even *start* on a non-NVIDIA box —
the dynamic loader can't resolve the NEEDED libcuda. That blocked running the
new VAAPI (AMD/Intel) path on a machine without the NVIDIA driver.
Resolve the 18 CUDA Driver API symbols at runtime via `libloading` instead.
Same-named wrapper fns forward to the dlopen'd table (call sites unchanged);
when libcuda is absent they return a non-zero CUresult so `context()` fails
cleanly and the capturer falls back to the CPU path. The library handle is
leaked (process-lifetime, like the shared context).
One Linux binary now runs on NVIDIA (CUDA zero-copy -> NVENC) and on AMD/Intel
(VAAPI, no NVIDIA driver). Verified: the NVIDIA dev box still does dmabuf->CUDA
zero-copy; on a Radeon 780M box the host builds with no libcuda present, the
binary has no NEEDED libcuda entry, and VAAPI encode runs with no stub.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Linux host was NVENC/CUDA-only. Add a VAAPI encoder — one libavcodec
backend (h264/hevc/av1_vaapi) covering both AMD (Mesa radeonsi) and Intel
(iHD) — behind the existing `Encoder` trait, and turn `open_video`'s Linux
arm into a vendor dispatcher: `PUNKTFUNK_ENCODER=auto|nvenc|vaapi` (default
auto: NVENC when a CUDA frame or /dev/nvidia* is present, else VAAPI). The
NVIDIA path is unchanged — auto resolves to NVENC on an NVIDIA box and the
bitrate-probe loop moved verbatim into `open_nvenc_probed`.
`VaapiEncoder` mirrors the NVENC hwframes pattern with AV_HWDEVICE_TYPE_VAAPI.
The CPU-input path swscales packed RGB -> NV12 (BT.709 limited, VUI signalled)
and uploads into a pooled VA surface (av_hwframe_transfer_data), preserving the
low-latency model (infinite GOP, on-demand forced IDR, async_depth=1, CBR when
the driver supports it). It works on a non-NVIDIA box with no capture changes:
the capturer already falls back to CPU frames when its EGL->CUDA importer can't
initialise (no libcuda).
Live-validated on a Radeon 780M (RDNA3): hevc/h264/av1_vaapi all encode,
HEVC/H264 decode cleanly with correct BT.709-limited colours, infinite GOP
preserved. Zero-copy dmabuf import (the high-res perf lever) is next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The host is NVIDIA/NVENC + SudoVDA coupled; Windows ARM64 has neither an NVIDIA
driver nor an ARM64 SudoVDA, so an ARM64 host would install but couldn't encode
or make a virtual display. Document the deliberate x64-only scope so it doesn't
get re-litigated. ARM64 stays client-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of the persistent ISCC "path not found": ISCC.exe is 32-bit, and the
self-hosted runner runs as SYSTEM, so the checkout lives under
C:\Windows\System32\config\systemprofile\.cache\... . WOW64 file-system
redirection rewrites a 32-bit process's System32 reads to SysWOW64 (where nothing
exists), so ISCC died opening the .iss before it even printed its version line.
(The smoke-test diagnostic compiled fine precisely because it lived at C:\t\out.)
Fix: copy every file ISCC reads (the .iss + host.env.example + README.md) into
the non-redirected build dir C:\t\out and compile from there; BinDir, StageDir,
and OutputDir already live under C:\t. Removed the now-spent smoke diagnostic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The smoke-test diagnostic proved Inno itself is healthy (a trivial ASCII script
compiled), while the real .iss died before the "Compiler engine version" line —
i.e. at script open, not during compile. The difference: the real .iss was UTF-8
with non-ASCII chars (→, —) in comments, which ISCC 6.4+ rejects without a UTF-8
BOM (and the German-locale runner misreads). Replace them with ASCII (->, -).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All [Files] sources are validated-present yet ISCC still errors before any
"Compiling" output (no line number) — so it's startup/[Setup]-internal, not a
source path. Add an explicit [Languages] (compiler:Default.isl) to rule out the
auto-added default language, and on ISCC failure dump the Inno install dir +
run a trivial [Setup]-only smoke script to tell "Inno broken" from "my script".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The {#SourcePath} relative-traversal for host.env.example/README kept tripping
ISCC ("path not found", error 2) regardless of the separator, so drop it: compute
the two paths absolutely in pack-host-installer.ps1, Test-Path them (clear PS error
if missing), and pass /DHostEnv + /DReadme. The .iss [Files] now reference the
absolute defines — no {#SourcePath}, no ..\.. traversal. Also prints "source ok"
for each so a future failure is unambiguous.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
windows.yml + windows-msix.yml gain an x86_64/aarch64 target matrix. ARM64 is
cross-compiled on the one x64 Windows runner — the x64 MSVC toolset ships the
ARM64 cross compiler, aarch64-pc-windows-msvc is tier-2 with host tools, and
SDL3/libopus (build-from-source) cross-compile cleanly. The only arch-specific
external dep is FFmpeg's import libs: the matrix points FFMPEG_DIR at a per-arch
tree (x64 C:\Users\Public\ffmpeg, arm64 C:\Users\Public\ffmpeg-arm64, both
FFmpeg 7.x / avcodec-61). Per-arch short CARGO_TARGET_DIR avoids a shared target
dir; fmt + test run only for x64 (aarch64 can't execute on the x64 host).
pack-msix.ps1 gains -Arch x64|arm64 (stamps the manifest ProcessorArchitecture,
arch-suffixes the .msix/.cer); windows-msix.yml matrixes both arches and
publishes ..._x64.msix / ..._arm64.msix. setup-windows-runner.ps1 provisions the
rustup target + the ARM64 FFmpeg tree (idempotent).
Verified live on the runner (home-windows-1): debug+release cross-build green,
clippy -D warnings green, and MSIX pack produces a valid arm64 package (manifest
arch=arm64; bundled exe/SDL3/avcodec/reactor-bootstrap all PE machine 0xAA64).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On the Windows WGC HDR path the FP16 scRGB capture was fed to NVENC as
R10G10B10A2 (BT.2020 PQ), and NVENC did the RGB→YUV CSC internally on the
contended SM — adding to the encode_ms wall under a GPU-saturating game.
(NVIDIA's D3D11 VideoProcessor can't do RGB→P010 for HDR; that path renders
green, confirmed live — so the convert must be ours.)
New `HdrP010Converter` fuses the tone-map with the BT.2020 RGB→YUV matrix and
emits P010 (10-bit limited range) directly: a luma pass → an R16_UNORM plane
RTV (full-res) and a chroma pass → an R16G16_UNORM plane RTV (half-res, 2x2
box average) of a DXGI_FORMAT_P010 texture. NVENC then takes native P010 and
skips its SM-side convert.
Gated behind PUNKTFUNK_HDR_SHADER_P010 (default OFF → the existing
R10→NVENC path is byte-for-byte unchanged). Colour validated by a new
`hdr-p010-selftest` subcommand: a synthetic scRGB pattern → P010 → readback,
compared to a BT.2020 PQ 10-bit reference — max abs error Y=0.99 / Cb=0.82 /
Cr=0.75 codes on an RTX 4090. Live-validated HDR colours correct (no green).
Build + clippy (--features nvenc -D warnings) green on x86_64-pc-windows-msvc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gradle's Exec resolves command[0] via the JVM/daemon's inherited PATH, not
the environment("PATH", …) set on the task (that only reaches the spawned
child). A GUI Android Studio launch — and any daemon it starts — has no
~/.cargo/bin on its PATH, so a bare "cargo" fails with "A problem occurred
starting process 'command 'cargo''". Use the already-computed cargoBin
absolute path; the env PATH still lets cargo/cargo-ndk find their subtools.
Also refresh the README prereqs: add the missing cmake;3.22.1 SDK package
(the cmake crate builds libopus with it) and drop the broken
`brew --prefix openjdk@21` JAVA_HOME hint in favour of `java_home -v 21`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Fedora RPM build linked punktfunk-host against a synthesized libcuda stub
with a FROZEN symbol list baked into ci/fedora-rpm.Dockerfile. The priority-
stream work added cuCtxGetStreamPriorityRange / cuStreamCreateWithPriority /
cuStreamSynchronize / cuMemcpy2DAsync_v2, which weren't in that list, so the
link failed with "undefined symbol".
build-rpm.sh now regenerates /usr/lib64/libcuda.so.1 from every cu* symbol the
host source references (grep of crates/punktfunk-host/src), before rpmbuild — so
a new cu* call can never silently break the link again. Self-maintaining and
needs no builder-image rebuild (it supersedes the Dockerfile's frozen stub).
Verified the 23 extracted symbols compile and cover the 4 that were undefined.
Also fix the bogus %changelog weekday (Sun -> Mon, Jun 15 2026 is a Monday) that
rpmbuild warned on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ExposedDropdownMenuBox anchors on a read-only OutlinedTextField, and a text field
captures D-pad focus -- directional keys never escape it, so on a TV/controller you
got stuck on the first select. Replace SettingDropdown with a clickable Surface +
DropdownMenu (no text field): D-pad moves between settings, A opens the menu, A
selects an item. Adds a primary-colour focus border so the focused setting reads
across a room.
Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hourly docker-prune could never reclaim the real disk filler: the act_runner
cache server's blob store (cache.dir:"" -> /root/.cache/actcache/cache) lives in
the long-running runner container's WRITABLE LAYER, which docker prune can't see.
It grew to ~66 GB and filled the 125 GB disk on its own.
- New docker-prune.sh holds the logic (inline ExecStart= broke under systemd's
own $-expansion, which emptied $SZ/$(...) before sh ran them — silently no-oping
the burst guard). The unit now just calls the script.
- Caps the actcache: clears the blobs once they exceed ~20 GB (act_runner
repopulates; keys are content-hashed, so only stale entries drop).
- Burst guard lowered 85%->80% and now also clears the actcache.
- Timer hourly -> every 30 min; image/cache `until` 12h -> 6h.
Live: cleared 66 GB on home-runner-1 (93% -> 20%), deployed + verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ISPP's {#SourcePath} has no trailing backslash, so {#SourcePath}..\..\scripts
resolved to ...\packaging\windows..\..\scripts (invalid component "windows..")
-> ISCC error 2 "path not found". Add the explicit separator (a double backslash
is harmless on Windows if a future ISPP ever adds the trailing one).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first CI run failed only on the SudoVDA download: SudoMaker/SudoVDA has no
releases (source-only repo; Apollo embeds the driver in its installer), so there
was nothing to fetch. Vendor the prebuilt SIGNED driver in-repo instead.
- packaging/windows/sudovda/: SudoVDA.inf/.cat/.dll + sudovda.cer (derived from
the .cat signer CN=sudovda@su.mk), pulled from the dev-box driver store.
v1.10.9.289, Class=Display, HWID Root\SudoMaker\SudoVDA, MIT/CC0.
- fetch-sudovda.ps1 -> stage-sudovda.ps1: stage the vendored driver + fetch
nefcon from its real pinned release (v1.17.40, sha256 812bae7e…, x64/nefconc.exe).
- pack-host-installer.ps1: call stage-sudovda.ps1; README updated with the
driver-refresh recipe.
The rest of the pipeline already passed on the first run (host built --features
nvenc via the llvm-dlltool import lib; ISCC + signtool found; signed with the
real CN=unom cert).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Linux zero-copy tiled-GL path can now produce NV12 (BT.709 limited range)
on the GPU and feed NVENC native YUV, deleting NVENC's internal RGB->YUV CSC —
which runs on the SM/3D-compute engine a saturating game pins at 100% (the
game-vs-encode contention headache). Windows already does this via the D3D11
video processor; this closes the Linux gap. See docs/host-latency-plan.md §2A.
Gated behind PUNKTFUNK_NV12 (default OFF → the RGB/BGRx path is byte-for-byte
unchanged; zero regression). Only the tiled EGL/GL path converts; the
LINEAR/Vulkan-bridge (gamescope) path stays RGB.
- zerocopy/egl.rs: Nv12Blit — BT.709 limited Y pass (R8, full-res) + UV pass
(RG8, half-res, GL_LINEAR 2x2 average); both CUDA-registered; import_nv12.
- zerocopy/cuda.rs: two-plane DeviceBuffer (Y W*H@1B + interleaved UV
(W/2)*2 x H/2), paired Y+UV pool, copy_mapped_nv12 + copy_nv12_to_device,
on the per-thread priority stream (dmabuf-recycle sync preserved).
- encode/linux.rs: nvenc_input(Nv12)->NV12; submit_cuda copies two planes into
NVENC's surface; VUI signalled BT.709 limited (colorspace/range/primaries/trc).
- capture/linux.rs: gate (PUNKTFUNK_NV12 && tiled), report format Nv12.
- main.rs + zerocopy/mod.rs: `nv12-selftest` subcommand.
Validated on RTX 5070 Ti two ways: (1) nv12-selftest — synthetic RGBA->NV12
round-trip vs a BT.709 reference, max abs error Y=0.56/U=0.33/V=0.26 LSB;
(2) live capture->NV12->NVENC->decode of animated red content matches the RGB
path's colour (avg RGB 230,18,18 vs 231,18,20). build/clippy/fmt green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cfg(windows) code can't be lint-checked on the Linux dev box, so three
-D warnings slipped through (caught by windows.yml; the FFI + shaders compiled
fine):
- gpu.rs: SetMultithreadProtected returns a must-use BOOL -> `let _ =`.
- video.rs: drop the unused GpuFrame::ten_bit field (present keys off `hdr`;
the value is still computed locally for the first-frame log).
- present.rs: GpuView::frame is an RAII keep-alive (its Drop returns the decoder
surface to the pool), never read -> #[allow(dead_code)].
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The client was pure software HEVC decode + CPU swscale->RGBA + a full-frame
dynamic-texture upload every frame -- the reason performance was poor on a GPU
box (the GPU sat idle while the CPU churned). This adds a hardware path, HDR,
and a GUI pass.
Performance -- D3D11VA zero-copy:
- gpu.rs (new): one D3D11 device (hardware + VIDEO_SUPPORT, WARP fallback,
multithread-protected) shared by decoder and presenter via a Send/Sync
OnceLock. Sharing is mandatory -- a decoded texture is only bindable on the
device that created it. windows-rs COM interfaces are !Send/!Sync, so the
unsafe impl is sound only under the multithread protection + disjoint
decode(video ctx)/present(immediate ctx) split.
- video.rs: D3d11vaDecoder (raw FFI mirroring the Linux VAAPI module). The
COM-typed AVD3D11VA{Device,Frames}Context are declared here (stable FFmpeg
ABI) to avoid ffmpeg-sys binding the d3d11 headers; get_format builds a frames
ctx with BindFlags=SHADER_RESOURCE so the NV12/P010 array slices are
sampleable. av_frame_clone guard keeps each surface out of the reuse pool
until the presenter drops it. Software decode stays as the fallback
(DecoderPref Auto/Hardware/Software; auto falls back on init/decode error).
- present.rs: shared device; per-plane SRVs over the array slice
(NV12->R8/R8G8, P010->R16/R16G16) + three pixel shaders (RGBA passthrough,
NV12/BT.709, P010/BT.2020-PQ). present() now takes the frame by value so the
GPU surface survives re-presents.
HDR:
- Detected in-band (transfer == SMPTE2084), same signal as the other clients.
Swapchain flips to R10G10B10A2 + ST.2084 + HDR10 metadata. New Settings toggle
gates advertising VIDEO_CAP_10BIT|HDR; host still gates 10-bit behind its own
PUNKTFUNK_10BIT + actual-HDR-content checks.
GUI (windows-reactor):
- Host cards with accent-monogram avatars + colored status pills, InfoBar for
errors/pairing hints, ToggleSwitch settings (+ HDR, decoder, bitrate), button
icons, a richer connecting screen, and a stream HUD with GPU/CPU-decode + HDR
status chips.
Not yet on-glass validated: the Linux dev box can't compile the cfg(windows)
code (ffmpeg/windows crates unfetched; WARP has no hw decode) -- only
cargo fmt checks it here. API shapes verified against the windows-rs/reactor
source and the YUV->RGB coefficients checked by hand, but D3D11VA + shaders +
the GUI need a real build (Windows CI / build VM) and on-glass test on the RTX
box. The host-side HDR encode path is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make a controller drive the Compose UI when not streaming, so the menus work on a TV
remote AND on a controller paired to a phone:
- MainActivity maps gamepad face buttons to the keys Compose's focus system
understands (A -> DPAD_CENTER to activate, B -> BACK); D-pad *keys* already move
focus and pass through untouched.
- For controllers whose D-pad reports as HAT axes (or to navigate with the left
stick), dispatchGenericMotionEvent converts AXIS_HAT_X/Y / AXIS_X/Y into discrete
D-pad key events, edge-detected so a held direction moves focus exactly once.
- HostCard draws a clear primary-colour focus border (the default state layer is too
subtle across a room on TV).
All gated on "not streaming" -- during a stream the controller still forwards to the
host unchanged. Compile-verified (./gradlew :app:assembleDebug); the focus behaviour
itself needs on-device validation (no KVM here for a TV emulator).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified, prioritized analysis in docs/host-latency-plan.md (multi-agent
investigation + adversarial verification). Lands the two low-risk tiers:
Tier 2B — Linux scheduling hygiene:
- boost_thread_priority now nices the capture/encode (-10) and send (-5)
threads on Linux (setpriority, best-effort; no-op without CAP_SYS_NICE),
and the wrong "gamescope caps the game" doc-comment is corrected.
- CUDA context created with CU_CTX_SCHED_BLOCKING_SYNC (frees a core on the
shared box instead of busy-spinning on completion).
- Copies moved off the default stream onto a per-thread highest-priority
CUDA stream (cuStreamCreateWithPriority, graceful NULL-stream fallback)
with a per-stream sync that no longer blocks on the other worker thread's
in-flight copies. Stream priority is measure-then-keep (NVIDIA Linux may
ignore it); never regresses.
Tier 3A — Windows session tuning (new session_tuning.rs, raw C-ABI FFI,
no-op off Windows): once-per-process 1ms timer + DwmEnableMMCSS + HIGH
priority class; per-thread MMCSS "Games" + keep-display-awake. Wired into
both the native (boost_thread_priority) and GameStream (stream.rs) paths.
We had zero session tuning before (Apollo streaming_will_start parity).
Tier 2A (Linux NV12 convert) is specified but intentionally not landed:
it is colour-correctness-critical and needs A/B validation on a GPU box
with a display (green-screen risk). Builds + clippy + fmt green on Linux.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MSIX (the client's format) can't install the host's LocalSystem secure-desktop
service or the SudoVDA kernel driver, so the host ships as a signed Inno Setup
setup.exe that runs elevated and delegates to the existing idempotent
`punktfunk-host service install`.
- packaging/windows/punktfunk-host.iss: lay exe into Program Files, optional
SudoVDA driver task, run service install/start; [Code] stops+waits the service
before file copy on upgrade; uninstall runs service uninstall.
- pack-host-installer.ps1: cert (reuses MSIX_CERT_PFX_B64 / self-signed CN=unom),
sign inner exe + setup.exe, fetch/stage SudoVDA, run ISCC, export public .cer.
- fetch-sudovda.ps1 / install-sudovda.ps1: pinned SudoVDA + nefcon download, cert
import, gated device-node create (no phantom dup), pnputil install (warn-not-abort).
- nvenc/: synthesize nvencodeapi.lib via llvm-dlltool from a 2-export .def so
--features nvenc links with no GPU/SDK at build time.
- .gitea/workflows/windows-host.yml: build (nvenc) -> clippy -> ISCC -> sign ->
publish setup.exe + .cer to the generic registry pkg punktfunk-host-windows.
Tag host-win-v* -> X.Y.Z (+ latest/ alias); main push -> rolling 0.2.<run>.
- setup-windows-runner.ps1: provision Inno Setup; docs: installer instructions.
SudoVDA/nefcon release URLs+SHA-256s in fetch-sudovda.ps1 are placeholders
(baseline v0.2.1) — fetch warns + prints the computed hash until pinned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decky Loader is a PyInstaller binary; it puts its bundled (older) libssl/libcrypto
on LD_LIBRARY_PATH via its /tmp/_MEI* unpack dir, and that env leaked into the
backend's `flatpak run`/`flatpak kill` subprocess. The SYSTEM flatpak's libcurl
+ libostree need newer OPENSSL symbols (3.2/3.3/3.4), so pairing failed with
"libssl.so.3: version OPENSSL_3.3.0 not found". _flatpak_env() now restores
each LD_*_ORIG PyInstaller saved, or drops the var, so the system loader uses
system libs. Reproduced + verified on the Deck (SteamOS 3.8.10, Flatpak 1.16.6).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Settings: flat list -> Display / Host / Audio / Overlay sections in outlined
cards (SettingsGroup + ToggleRow helpers) with section headers.
- ConnectScreen: connection errors now show in a filled errorContainer banner
(was plain red text lost in the layout), and a "Searching the local network..."
spinner appears while discovery is active but nothing's turned up yet.
Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Crash: DisposableEffect.onDispose called nativeClose(handle) (Box::from_raw frees
the SessionHandle) while the SurfaceView's surfaceDestroyed independently called
nativeStopVideo/Audio/Mic on the same handle -- whichever ran after the close
dereferenced freed memory (SIGSEGV: the consistent back-navigation crash). Add a
one-shot `closed` guard: onDispose marks it before freeing; surfaceDestroyed skips
the native calls once closed (backgrounding still stops the threads when it wins).
Polish:
- Branded Material You theme (Theme.kt): dynamic colour on Android 12+, punktfunk
brand violets as the pre-12 fallback, replacing the generic darkColorScheme().
- ConnectScreen: "Connecting..." was rendered in error-red with no spinner; now a
neutral spinner while connecting, red reserved for actual errors.
Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL (both ABIs + the
Compose changes), debug APK assembles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ndk's DataSpace derives Copy/PartialEq/Eq and impls Display (no Debug), so the
{ds:?} in the HDR dataspace log statements wouldn't compile under cargo-ndk.
Host clippy can't catch it — decode.rs is android-gated. Switch to {ds}.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Install failed with "GPG verification enabled, but no signatures found" on the
commit: the deploy step only ran build-update-repo (signs the summary). Add
`flatpak build-sign` to sign the commit objects too — clients with
gpg-verify=true verify the commit, so summary-only signing isn't enough.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the Apple client's HDR path so the Android client can display HDR from a
Windows HDR host:
- nativeConnect now advertises VIDEO_CAP_10BIT | VIDEO_CAP_HDR (was 0), so the
host upgrades to a Main10 / BT.2020 PQ encode.
- decode.rs detects HDR reactively from the decoder's reported output colour
(color-transfer ST2084=6 / HLG=7, color-range) -- the AMediaCodec analogue of
VideoToolbox's format description on Apple -- and signals the Surface dataspace
(Bt2020[Itu]Pq / Bt2020[Itu]Hlg) so the compositor/display switch to HDR.
AMediaCodec decodes Main10 from the in-band SPS; no profile override needed.
Also fixes the Android build: set_frame_rate (added in e9de730) is gated on the
ndk `nativewindow` + `api-level-30` features, which weren't enabled -- so that
commit could not compile under cargo-ndk. Enable
features = ["media","audio","nativewindow","api-level-31"] (minSdk 31): covers
set_frame_rate (api-30), set_buffers_data_space + the DataSpace module (api-28),
and ANativeWindow (nativewindow).
Verified host-side: fmt --all + clippy --workspace (the caps advertise + JNI
surface). The android-gated decode + NDK gating verified against the ndk 0.9
sources; android.yml (cargo-ndk) is the compile gate, and real HDR display needs
an HDR device + Windows HDR host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Apple is TestFlight-only (no App Store) — link the join URL; drop the App Store
placeholder. Add the live Google Play listing for io.unom.punktfunk.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-device install steps in one place: Linux (Flatpak via flatpak.unom.io +
native apt/rpm/Arch), Steam Deck, Windows (signed MSIX from the registry),
macOS (notarized DMG from releases), and iOS/Android (store/beta links). Adds
it to the Connecting nav and cross-links clients.md, whose Linux/Flatpak bullet
now points at the hosted flatpak.unom.io repo instead of the bundle README.
Mobile store/TestFlight URLs are placeholders pending the public listings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stats HUD (mirrors the Apple client): the decode thread accumulates FPS, receive
throughput, and capture->client latency (p50/p95, skew-corrected) in Rust
(clients/android/native/src/stats.rs); nativeVideoStats drains a snapshot ~1 Hz
over JNI as a DoubleArray. StreamScreen renders a Compose overlay
(W*H@Hz / fps / Mb/s / latency, + dropped-under-loss), toggled by a Settings
switch (persisted, default on) or a 3-finger tap.
Performance (decode.rs):
- ANativeWindow_setFrameRate(refresh_hz): align display vsync to the stream rate
(no 60-in-120 judder); safe since minSdk 31 >= API 30.
- Raise the decode thread toward URGENT_DISPLAY (best-effort setpriority) so
background work can't preempt it under load.
- Codec low-latency hints KEY_PRIORITY=0 (realtime) + KEY_OPERATING_RATE.
Verified host-side: cargo build/clippy/fmt --workspace (the ungated stats + JNI
accessor). The android-gated decode.rs (NDK) and the Kotlin build only in CI
(android.yml: gradle + cargo-ndk) -- APIs verified against crate sources.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CI added --default-branch=stable, so the repo ref is
app/io.unom.Punktfunk/x86_64/stable. build-bundle defaults to `master` when no
branch is given → "Refspec app/io.unom.Punktfunk/x86_64/master not found". Pass
`stable` explicitly in both flatpak.yml and the local build-flatpak.sh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stoppedHandler/resetHandler are non-optional closures on the CI SDK
((StoppedReason)->() and ()->()), so assigning nil fails to compile
(apple.yml). Assign no-op closures to disarm them before engine.stop()
-- same re-entrancy guard intent, type-correct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CI only shipped a single-file .flatpak bundle, which has no remote — users
couldn't `flatpak update`. Keep the bundle (Decky fallback) but also sign the
OSTree repo flatpak-builder already produces and publish it to a shared,
reusable unom-wide remote.
- flatpak.yml: pin --default-branch=stable; import the signing key and
build-update-repo --gpg-sign; generate unom.flatpakrepo + the app .flatpakref
+ index.html; rsync the repo to unom-1 and bring up a static Caddy container.
The step no-ops until FLATPAK_GPG_PRIVATE_KEY/DEPLOY_* exist (build stays green).
- packaging/flatpak/server/: compose.production.yml + Caddyfile (static file
server on :3230, mirrors docker.yml deploy-docs).
- unom-flatpak.gpg: committed public signing key (base64 -> GPGKey= in the descriptors).
- README: hosted repo is now the recommended install; documents the one-time
infra (edge Caddy vhost, infra port 3230, DNS, the GPG secret).
Edge Caddy vhost + infra port allowlist + the secret are applied out-of-band.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>