Compare commits

...
9 Commits
Author SHA1 Message Date
enricobuehler 1495ec2bd2 Merge pull request 'fix(host/input): serve Switch Pro SPI reads by range, not exact pairs' (#444) from worktree-switchpro-spi-range into main
android / android (push) In progress
arch / build-publish (push) In progress
deb / smoke-install (push) Blocked by required conditions
ci / bun-nix (push) Successful in 22s
ci / rust (push) In progress
windows-host / canary-manifest (push) Blocked by required conditions
windows-host / winget-source (push) Blocked by required conditions
windows-host / package (push) In progress
ci / docs-drift (push) Successful in 32s
ci / web (push) Successful in 53s
deb / build-publish-host (push) In progress
ci / docs-site (push) Successful in 1m0s
ci / rust-arm64 (push) Successful in 1m43s
deb / build-publish-gamescope (push) Successful in 41s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 28s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 33s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 1m39s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 9s
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 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) In progress
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) In progress
deb / build-publish (push) Successful in 4m34s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) In progress
Reviewed-on: #444
2026-08-29 15:18:11 +00:00
enricobuehler 0f20e72d03 fix(host/input): serve Switch Pro SPI reads by range, not exact pairs
ci / docs-site (pull_request) Successful in 1m4s
ci / web (pull_request) Failing after 1m19s
ci / bun-nix (pull_request) Successful in 23s
ci / rust-arm64 (pull_request) Successful in 3m9s
ci / docs-drift (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 8m56s
ci / rust (pull_request) Successful in 10m39s
Both sticks on a virtual Switch Pro sat in the top-right corner under
Steam, reaching centre only at full down-left. Buttons and motion were
fine, and the kernel's own evdev node read correctly throughout.

The SPI-flash handler matched an exact (address, length) pair, and those
pairs came from hid-nintendo. Steam does not use the kernel driver here;
it uses SDL's HIDAPI Switch driver, which reads the same calibration as
18 bytes at 0x603D and 22 at 0x8010. Neither matched, so both fell to
the zero-fill path — which echoes the requested address, so SDL accepted
the reply and parsed a zeroed calibration. A zero centre is never
subtracted, leaving every raw axis value positive.

Serve reads by range from a modelled flash image, so every consumer gets
the same bytes whatever shape it asks for. What hid-nintendo receives is
byte-identical, and the IMU block already used SDL's length, so motion is
untouched. The caller loses its zero-fill fallback.
2026-08-29 16:56:17 +02:00
enricobuehler 3725db7cfe Merge pull request 'fix(presenter): F11 to fullscreen no longer quits the Windows session' (#443) from worktree-win-f11-swapchain into main
ci / web (push) Successful in 1m3s
ci / docs-drift (push) Successful in 30s
ci / docs-site (push) Successful in 1m43s
ci / bun-nix (push) Successful in 2m54s
ci / rust-arm64 (push) Successful in 3m26s
deb / build-publish (push) Successful in 4m7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
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 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 17s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
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
arch / build-publish (push) Successful in 8m35s
deb / build-publish-gamescope (push) Successful in 5m32s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 53s
android / android (push) Successful in 9m8s
deb / build-publish-client-arm64 (push) Successful in 6m1s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m45s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 59s
docker / builders-arm64cross (push) Successful in 15s
docker / deploy-docs (push) Successful in 42s
deb / build-publish-host (push) Successful in 8m41s
ci / rust (push) Failing after 12m31s
deb / smoke-install (push) Successful in 5m3s
flatpak / build-publish (push) Successful in 6m32s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m34s
Reviewed-on: #443
2026-08-29 14:38:51 +00:00
enricobuehler 09662c0466 fix(presenter): a refused swapchain costs fullscreen, not the session
ci / bun-nix (pull_request) Successful in 34s
ci / web (pull_request) Successful in 52s
ci / docs-drift (pull_request) Successful in 40s
ci / docs-site (pull_request) Successful in 1m14s
ci / rust-arm64 (pull_request) Successful in 1m33s
android / android (pull_request) Successful in 7m20s
ci / rust (pull_request) Successful in 10m3s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 10m56s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m19s
A `recreate_swapchain` failure in the resize handler propagated out of
`run_session`, ending the stream; the shell then reported a live
session as "Couldn't connect".

A driver that refuses the fullscreen-sized swapchain now costs the
fullscreen only: warn, drop back to windowed, and rebuild against the
geometry that was already working. A windowed failure still
propagates, since there is no smaller state to fall back to.
2026-08-29 16:23:48 +02:00
enricobuehler 8f8f50fb79 fix(presenter): drain present-wait before reusing the old swapchain
Toggling fullscreen mid-stream on Windows 11 quit the session with
`vkCreateSwapchainKHR: VK_ERROR_UNKNOWN`.

`recreate_swapchain` drained the present-wait waiter AFTER calling
`vkCreateSwapchainKHR(oldSwapchain = old)`, so the pf-present-wait
thread could sit inside `vkWaitForPresentKHR(old)` while the driver
retired that same swapchain. `vkCreateSwapchainKHR` externally-
synchronises `oldSwapchain`, so the overlap is a spec violation.

The drain (and the unclaimed-present drop) now run before the create.
`present_timing.rs` stated only the destroy half of the rule, which is
how the drain came to sit on the wrong side; it now states both.

The failure text also carries extent, format, colour space, present
mode and image count -- the first questions a field report raises.
2026-08-29 16:23:48 +02:00
enricobuehler 21d30b7320 Merge pull request 'fix(apple): relink stage-4 when the display link stops vending' (#442) from worktree-stage4-link-watchdog into main
ci / bun-nix (push) Successful in 45s
ci / web (push) Successful in 1m6s
ci / docs-drift (push) Successful in 48s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
ci / docs-site (push) Successful in 1m0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / rust-arm64 (push) Successful in 1m46s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 33s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
apple / swift (push) Successful in 2m10s
docker / builders-arm64cross (push) Successful in 21s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m6s
docker / deploy-docs (push) Successful in 32s
apple / distribute (push) Successful in 12m51s
ci / rust (push) Successful in 23m57s
apple / screenshots (push) Successful in 10m26s
Reviewed-on: #442
2026-08-29 08:48:24 +00:00
enricobuehler d58ed540de Merge pull request 'docs(changelog): tighten v0.33.0 bullets to house style' (#441) from luxus/punktfunk:docs/changelog-v0.33-tighten into main
ci / bun-nix (push) Successful in 23s
ci / rust (push) Canceled after 45s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
ci / rust-arm64 (push) Canceled after 46s
ci / web (push) Canceled after 44s
ci / docs-site (push) Canceled after 43s
ci / docs-drift (push) Canceled after 41s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 2s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 24s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (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
Reviewed-on: #441
2026-08-29 08:47:50 +00:00
enricobuehler 87bc0883db fix(apple): relink stage-4 when the display link stops vending
ci / bun-nix (pull_request) Successful in 32s
ci / docs-site (pull_request) Successful in 58s
ci / web (pull_request) Successful in 1m3s
ci / docs-drift (pull_request) Successful in 1m11s
apple / swift (pull_request) Successful in 2m10s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m41s
ci / rust (pull_request) Successful in 22m48s
Field 2026-08-28, iPad Pro on iOS 27: the picture froze twice in one
session while audio and input kept running, and only reconnecting
cleared it. pf-present logged decode at full rate against ok=0,
noDrawable=120 and no vendLeadMs samples at all, so the
CAMetalDisplayLink had stopped calling back.

Stage-4 has no drawable source of its own: every drawable arrives as
update.drawable. A link that goes quiet is therefore a stream that
never presents again, and nothing noticed. Stage-3's
PresentGate.staleAfter insurance does not reach this path, and the
link is only invalidated at session teardown.

The render thread now tracks how long it has gone without a vend and,
past 250 ms with frames still decoding, retires the link generation
and starts a fresh one. Invalidating the old link also returns the
drawables it holds, so this covers an exhausted pool as well as a
dead link. The relink is logged to the send-logs ring.

Also holds the link delegate for the link thread's lifetime. It is
stored weakly and was kept alive only by a local whose last use is
that store, which ARC is free to release on the spot.
2026-08-29 09:54:39 +02:00
luxus b58606dbfc docs(changelog): tighten v0.33.0 bullets to house style
ci / rust (pull_request) Blocked by required conditions
ci / rust-arm64 (pull_request) Blocked by required conditions
ci / web (pull_request) Blocked by required conditions
ci / docs-site (pull_request) Blocked by required conditions
ci / bun-nix (pull_request) Blocked by required conditions
ci / docs-drift (pull_request) Blocked by required conditions
v0.33.0 adopted Keep a Changelog headings but kept PR-body
prose. Replace that section with two-sentence bullets and
add the missing bullet-shape rule to docs/writing.md.
2026-08-29 01:13:55 +02:00
8 changed files with 300 additions and 279 deletions
+73 -206
View File
@@ -19,229 +19,96 @@ as they are. See `docs/writing.md` §2.
## v0.33.0
45 commits since v0.32.0 (29 non-merge), counted at the tip this was cut from.
**Nothing versioned moves.** Wire protocol stays 2, C ABI stays 26, driver protocol 6, gamepad
channel 3, plugin index schema 1, host event schema 1, gamescope `pfhdr8`. The management API is
the one surface that grows: `api/openapi.json` gains `/api/v1/actions` and `/api/v1/actions/{id}`.
First section written to `docs/writing.md` §2.
**Two things an embedder must read.** `PUNKTFUNK_GRANT_ALL` widens from `0x3F` to `0x7F` — a value
change to an existing macro, not an addition — and `CLIENT_CAP_KEEP_HOST_AUDIO` is renamed to
`PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO`. Both are under **Breaking**.
45 commits since v0.32.0. Wire stays 2. C ABI stays 26. OpenAPI adds the actions routes.
Embedders: read **Breaking**. Detail: `design/host-actions.md`.
### Versions
| | v0.32.0 | v0.33.0 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged. Two additions, each ignorable by an older peer: close code `107` (`HOST_POWER_CLOSE_CODE`, reject reason `HostPower`) and the advisory mDNS TXT key `addr` |
| C ABI | 26 | **26** | unchanged. Additive: `PUNKTFUNK_GRANT_POWER` (`1 << 6`), `PUNKTFUNK_GRANT_ALL_PRE_POWER`, `PUNKTFUNK_HOST_POWER_CLOSE_CODE`, `PUNKTFUNK_STATUS_REJECTED_HOST_POWER` (`-32`). **But `PUNKTFUNK_GRANT_ALL` changes value** see Breaking |
| Rust edition | 2024 | **2024** | unchanged |
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.32.0 tag |
| Windows virtual-gamepad channel | 3 | **3** | unchanged; nothing under the Windows gamepad backends moved |
| Plugin index schema | 1 | **1** | unchanged |
| Host event schema | 1 | **1** | unchanged. Additive: the `action.invoked` event kind, carrying `id`, an optional `device` and `outcome` |
| `api/openapi.json` | 0.32.0 | **0.33.0** | **MOVES.** Two added paths, `GET /api/v1/actions` and `POST /api/v1/actions/{id}`. `api/` and `docs-site/public/` are byte-identical to each other |
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; `packaging/gamescope/PKGBUILD` shows no diff |
| `@punktfunk/host` (SDK) | 0.1.6 | **0.1.6** | unchanged **on purpose**. `sdk/src/gen/punktfunk.ts` is regenerated with the actions types, but the plugin token is refused both routes, so no plugin can call them. Cut `sdk-v0.1.7` when a plugin-reachable route moves |
| `@punktfunk/plugin-kit` | 0.4.4 | **0.4.4** | unchanged; nothing under `plugin-kit/` moved |
| Wire protocol | 2 | **2** | Additive: close code `107` (`HostPower`); mDNS TXT `addr` |
| C ABI | 26 | **26** | Additive constants below. **`PUNKTFUNK_GRANT_ALL` changes value** — Breaking |
| Rust edition | 2024 | **2024** | |
| MSRV | 1.85 | **1.85** | |
| Workspace crate dirs | 27 | **27** | |
| Driver protocol | 6 | **6** | |
| Windows gamepad channel | 3 | **3** | |
| Plugin index schema | 1 | **1** | |
| Host event schema | 1 | **1** | Additive: `action.invoked` |
| `api/openapi.json` | 0.32.0 | **0.33.0** | `GET /api/v1/actions`, `POST /api/v1/actions/{id}` |
| gamescope (`+pfhdrN`) | 8 | **8** | |
| `@punktfunk/host` | 0.1.6 | **0.1.6** | Types regenerated; plugin token still cannot call the new routes |
| `@punktfunk/plugin-kit` | 0.4.4 | **0.4.4** | |
### Breaking
- **`PUNKTFUNK_GRANT_ALL` widens `0x3F` `0x7F`.** `GRANT_POWER` (`1 << 6`) joins the mask. Code
comparing a stored mask against `GRANT_ALL` for equality now reads a pre-power record as Custom.
The host applies a legacy-full rule — an explicitly stored `0x3F` reads as the current
`GRANT_ALL` — so existing Full-control pairings keep Full control and gain Power with it. An
embedder holding its own copy of a mask must apply the same rule, or compare against the new
`PUNKTFUNK_GRANT_ALL_PRE_POWER`. *A pairing saved as Full control before this release can now
sleep, restart and shut down the host.*
- **`CLIENT_CAP_KEEP_HOST_AUDIO` is renamed `PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO`.** cbindgen had
no rename entry, so v0.32.0 emitted the bare name into every embedder's namespace. The value is
still `32`. Anything compiled against the unprefixed spelling fails to compile; add the prefix.
- **The `custom_picker_binary` shim is restored on host exit, not after each cast** (Hyprland,
Omarchy). Restoring per cast rewrote `xdph.conf` and restarted
`xdg-desktop-portal-hyprland`, and a ScreenCast bound across that restart never delivers a
buffer — so every session after the first produced no frames. The shim now stays installed
between sessions and delegates to the previously configured picker, so ordinary browser shares
behave as before. `punktfunk-omarchy remove` restores it from its marker.
- **`PUNKTFUNK_GRANT_ALL` is `0x7F`, was `0x3F`.** `GRANT_POWER` (`1 << 6`) joined the mask.
The host treats a stored `0x3F` as current `GRANT_ALL`, so existing Full-control pairings
gain Power (sleep / reboot / shutdown). If you keep your own copy of the mask, apply the
same rule or compare against `PUNKTFUNK_GRANT_ALL_PRE_POWER`.
- **`CLIENT_CAP_KEEP_HOST_AUDIO` is now `PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO`.** Value is
still `32`. Rebuild against the prefix; the unprefixed name no longer compiles.
### Added
- **Host actions: a reusable registry with three power built-ins.** `GET /api/v1/actions` returns
the caller's permitted actions with honest availability; `POST /api/v1/actions/{id}` invokes one
by id on the mgmt cert lane. `power.sleep`, `power.reboot` and `power.shutdown` ship. Executors:
logind via zbus behind the shipped polkit rule, deliberately without `-ignore-inhibit`; Windows
`SeShutdownPrivilege` + `InitiateSystemShutdownExW` / `SetSuspendState`; macOS answers 501.
Ordering is reply-202 → typed close → 1 s grace → act, single-flight, and another device's live
session blocks a cert-lane invoke with 409. Denials log once per `(fingerprint, action)` per
boot. **The plugin token gets neither route.** See `design/host-actions.md`.
- **`PUNKTFUNK_GRANT_POWER` (`1 << 6`)** — route-gated like `CLIPBOARD` / `MIC` / `LAUNCH`; no
datagram carries it, so `classify` is untouched. Machine power only: future plugin or custom
actions get their own class, never this bit.
- **`punktfunk-host ctl`** — 15 verbs over the existing admin lane. Pairing arm/pending/approve/
deny, the Moonlight PIN, both planes' device lists with rename/unpair/access presets, and
session stop/end-game. `watch` bridges the server-sent-event stream to line-JSON on stdout, with
`Last-Event-ID` resume and a synthetic `ctl.resync` line after a `dropped` frame. `--json`
returns a versioned envelope. **Pin before token:** the agent pins the host's own leaf through
punktfunk-core's `PinVerify`, so rustls rejects a squatter *during the handshake* and ureq never
serialises an `Authorization` header. There is deliberately no `--token` flag and no token env
read — a credential in either is readable cross-uid through `/proc`. The pin is
`native-cert.pem` then `cert.pem`, because the mgmt listener serves the native identity.
- **`punktfunk-omarchy setup | remove | status`** — installed by the host package, never run by it.
It sets up LAN-scoped comment-tagged ufw rules, the uwsm session drop-in, and the console as a
webapp. It also merges an app-menu submenu between markers into the user's
`omarchy-menu.jsonc`, writes sample hooks through `hooks.json`, adds an idle guard that
snapshots and restores the user's own stay-awake setting, and installs the theme template.
`osinfo::is_omarchy()` makes the console update tier notify-only, enforced at the apply route
rather than merely reported.
- **Console handoff ticket.** `punktfunk-host ctl console-url` mints
`<unix-seconds>.<nonce>.<HMAC-SHA256>` over `pf-console-handoff:v1:ts:nonce`, signed with the
management token both sides already hold. 60 s TTL, single use, symmetric window, one 401 for
every rejection. No new host route and no shared state. The test suite carries a vector minted by
the real Rust host and cross-checked against python's `hmac`.
- **mDNS advisory TXT key `addr`.** The host declares which address its advert is for. It never
overrides reachability — it settles a multi-NIC host's tie, and old hosts without it still
resolve deterministically.
- **`action.invoked` on the host event bus.** Emitted on ACCEPT, and again if the executor later
fails. A succeeded power action ends the process, so "accepted with no failure after it" is the
success signal a hook can act on.
- **Two diagnostics catalog rows.** `hyprland_permissions` — Hyprland 0.49+ can deny screencopy and
virtual input **silently**, and no probe outside the compositor can tell granted from denied, so
it is a Warning rather than Critical. `omarchy_updates` — the console's apply button is
deliberately absent on Omarchy, and without a row naming where updates come from, "my update
button is missing" is unanswerable.
- **Host actions.** `GET /api/v1/actions` lists what the caller may run; `POST /api/v1/actions/{id}`
invokes one on the mgmt cert lane. Built-ins: `power.sleep`, `power.reboot`, `power.shutdown`.
A live session on another device returns 409. macOS returns 501. Plugin token is refused both
routes. See `design/host-actions.md`.
- **`PUNKTFUNK_GRANT_POWER` (`1 << 6`).** Route-gated like clipboard / mic / launch. Not on the
datagram path. Also added: `PUNKTFUNK_GRANT_ALL_PRE_POWER`, `PUNKTFUNK_HOST_POWER_CLOSE_CODE`
(`107`), `PUNKTFUNK_STATUS_REJECTED_HOST_POWER` (`-32`).
- **`punktfunk-host ctl`.** Pairing, PIN, device lists, session stop, `watch` (SSE → line JSON).
Pins `native-cert.pem` then `cert.pem` before any request. No `--token` flag and no token env.
- **`punktfunk-omarchy setup | remove | status`.** Ships with the host package; the host never
runs it. ufw, uwsm drop-in, console webapp, menu, hooks, idle guard, theme.
- **Console handoff ticket.** `punktfunk-host ctl console-url` mints a 60 s single-use HMAC.
No new host route.
- **mDNS TXT `addr`.** Advisory only: which address this advert is for. Older hosts without it
still resolve.
- **`action.invoked` host event.** Fired on accept, and again if the executor fails.
- **Diagnostics rows** `hyprland_permissions` and `omarchy_updates`.
### Changed
- **`docs/writing.md` is the writing rulebook.** Conventional Commits with a 72-character subject
cap, Keep a Changelog categories for new CHANGELOG sections, and comments that state an invariant
rather than recap a diff. CONTRIBUTING, AGENTS.md and the PR template point at it. Nothing
existing is rewritten.
- **`scripts/install.sh --channel` switches an installed host, in both directions.** It previously
applied only to a box with nothing installed. canary→stable is a downgrade, which every package
manager refuses unless told. So apt gets explicit `madison` version pins, pacman `-Sy` then `-S`
(never `-Syu`, which looks at the lower stable version and does nothing), and dnf a
`distro-sync` behind the `install`. A switch moves every punktfunk package on the box, not the
three the script installs. With no explicit `--channel` the script follows whatever the box is
on, so a bare re-run cannot drag a canary box back a channel.
- **Omarchy installs with `pacman -Sy` then `pacman -S`, never `-Syu`.** Omarchy ships a libalpm
PreTransaction hook that aborts any transaction whose pacman invocation carries both `-S` and
`-u`. Plain Arch keeps its full `-Syu`.
- **`punktfunk-web.service` waits on `punktfunk-web-init.service`** (`Type=oneshot`) for the
management token and a non-empty identity cert/key pair. `After=punktfunk-host.service` was never
a readiness gate: the host is `Type=simple`, so systemd calls it started the instant it is
spawned. The two files are written far apart — the token early in `serve`, the cert last inside
`identity::load_or_adopt` — so waiting on the token alone would only move the failure. Timing out
is not fatal: web-init exits 0, the `Restart` backstop takes over, and the log names the
precondition. Also drops `ConditionPathExists=!%h/.config/punktfunk/web-password`, which skipped
the unit from the second boot onward. Mirrored into the NixOS module.
- **Tray's service-restart row is now "Restart Punktfunk"**, before the clients ship a
machine-level "Restart host".
- **Web and docs-site dependency majors.** Vite 7 → 8, `@vitejs/plugin-react` 5 → 6,
`vite-tsconfig-paths` 5 → 6, `@types/node` 22 → 26, TypeScript 5.9 → 7, biome 2.5, `@unom/ui`
0.10.0, `motion` 13.1, `@unom/app-ui` 0.3.0. The stale-chunk recovery now comes from `@unom/ui`'s
`reloadOnStaleChunk` instead of a fourth local copy.
- **`check-docs-drift.sh`'s undocumented-env baseline gains the four new ABI constants.** The
ratchet matches `PUNKTFUNK_*` by token spelling and cannot tell an environment variable from a
cbindgen export.
- **`install.sh --channel` now switches an already-installed host.** Omarchy uses `pacman -Sy`
then `-S`, never `-Syu` (a PreTransaction hook aborts `-Su`). A bare re-run follows the
box's current channel.
- **`punktfunk-web.service` waits on `punktfunk-web-init.service`** for the management token
and a non-empty identity cert. `After=punktfunk-host.service` was not a readiness gate.
- **Tray row** is now "Restart Punktfunk".
### Fixed
- **Automatic bitrate ratcheted to the floor and never climbed back** (0.32.0 regression). Phase 4
hands the encoder `enc_kbps(budget)` and reads back through `budget_kbps()`. That roundtrip
deflates 12 kbps by design, so the `applied < requested` compare recorded a phantom
`encoder_ceiling_kbps` on **every** successful apply. The control task clamps each later
`SetBitrate` to it, the client learned it as a host cap, and every descent lowered the ceiling
further — a one-way ratchet. 0.31.4 was immune, its read-back staying in encoder units.
`EncDerive::applied_budget_kbps` now reads back in the request's own truncated terms, so only an
apply short of what the request itself derives to records a ceiling. Regression test proven
against the backed-out fix.
- **The startup link-capacity probe fired before video existed.** It ran on a flat 2 s timer, but
host bring-up takes 68 s (indirect display driver acquisition), so the burst landed on the
first IDR frame: 511 s of black video, the decoder refusing access units, 2101 audio
underruns. It then missed its own 6 s timeout anyway (8.6 s measured), so the ceiling stayed
negotiated — all disturbance, no measurement. The probe now arms only once a frame has
completed, with a 15 s timeout.
- **A Hyprland `topology: exclusive` session left the desk dark after a failed build.** The host
lends one backend instance to `build_pipeline_with_retry` for up to eight attempts, and
`pending_restore` was assigned unconditionally — so attempt 2, correctly finding nothing left to
disable, dropped attempt 1's restore closure on the floor. `stash_topology_restore` makes the
slot first-wins, which is also the right list: attempt 1 looked at the desk while it was still
lit. KWin gets the same guard. Two doc corrections, since they are what made this read as a
registry bug: the Hyprland and sway `pending_restore` comments claimed the registry lifts the
restore and `Drop` is a backstop. Both carry a portal fd, so `registry::acquire` returns them as
pass-through at the `remote_fd.is_some()` check — several statements *above* its
`take_topology_restore()` call — and `Drop` is the only thing that runs them.
- **A theme switch reset the streamed head's resolution.** `omarchy-theme-set` ends in
`omarchy-restart-hyprctl`, which is `hyprctl reload`, and a reload drops every runtime `hyprctl
keyword` — our monitor rule included. The Hyprland backend now subscribes to the compositor event
socket and re-applies the rule on `configreloaded`, so any reload gets it. A socket read, not a
poll. The mode only: re-disabling `exclusive`'s heads from this watcher risks a permanently dark
desk, because teardown's own `restore_heads` runs a reload and nothing orders the two.
- **The streamed head could not be focused on a Lua-configured Hyprland.** The spelling is
`hl.dsp.focus({ monitor = "<name>" })`, measured on Hyprland 0.56.2 — the compositor volunteers
it when asked with any other key. An unfocused headless output stays empty, an empty output
produces no damage, no damage means no PipeWire frames, and capture then misses its first-frame
deadline: a black screen on every Omarchy box.
- **Clients rolled dice on a multi-address host.** The host registers exactly one address, but the
OS mDNS responder also answers A queries per interface. So the resolved set a client folds is a
union polluted by every overlay network whose multicast reaches it — and both clients picked
with `HashSet::iter().next()`, re-rolled on every re-announce. `punktfunk_core::discovery` now
ranks, best wins: longest common prefix with any local unicast address, then the host's declared
`addr`, then prefix against the default-route source, then numerically lowest. Shared by the
desktop and Android clients.
- **The client advertised HDR its video processor could not tone-map.** `VIDEO_CAP_HDR` invites a
PQ stream, and on a Windows box with no HDR10 swapchain the D3D11VA hand-off relies on the video
processor's PQ→sRGB tonemap — a driver capability nothing validated. Field 2026-08-26: an Arc
A370M went green on every HDR session while AV1 8-bit SDR at the same mode streamed clean.
`video_d3d11::pq_tonemap_supported()` asks `CheckVideoProcessorFormatConversion` for the exact
pair the SDR ring sets (P010 G2084 studio in, BGRA8 sRGB out). Only a definitive driver "no"
answers false; API failure keeps today's behaviour. `ten_bit_sdr` stays unprobed on purpose —
10-bit SDR is no tonemap.
- **`keep_host_audio` reached only three surfaces.** Shipped wired end to end in 0.32.0, but Apple
and Android carried no reference to it — there is no shared settings schema, and each client
hand-mirrors `trust::Settings`. Both now have the defaults key, the profile overlay, the settings
row and the capability bit in their connect. `pf-console-ui`'s `RowId::KeepHostAudio` leaves
`desktop_only`.
- **`punktfunk-omarchy status` lied twice.** It reported "no punktfunk rules" seconds after adding
six, because a failed `sudo -n` read was being treated as proof of absence. And it reported xdph
missing on a box that was capturing through it: `pgrep -x` can never match
`xdg-desktop-portal-hyprland`, since Linux truncates comm to 15 characters.
- **The idle guard never restored anything.** `omarchy-toggle-idle status` prints JSON, not a
keyword, and its tooltip names the action the button *would* take — so the string "Stay Awake"
appears precisely when stay-awake is off. Every arm fell through to "do nothing" and the box
stayed awake after every session. Now reads `"enabled":true`.
- **`ctl watch` leaked a process per dead consumer.** A watcher only ever reads, so when the widget
died its end of our stdout pipe closed unnoticed. The SSE keep-alive now surfaces as a
`ctl.heartbeat` line, which gives us a write to fail on, and a failed write ends the process.
- **`install.sh --uninstall` left everything the packages did not own** — tagged ufw rules, the
user-unit drop-in, the app-menu entry, `hooks.json`, and the `custom_picker_binary` takeover.
The last of those kept pointing a live compositor at a picker shim that was no longer installed.
It now runs `punktfunk-omarchy remove` before pacman takes the package away.
- **A deploy under an open tab killed the next navigation** (web, docs-site). Routes are
code-split, so a tab holding pre-deploy HTML asks for chunks the new server has never heard of;
`defaultPreload: "intent"` made a hover enough to trip it. Both routers now handle
`vite:preloadError` and navigate to `latestLocation` rather than reloading in place, with a
sessionStorage stamp against a reload loop. Deliberately not `preventDefault()`, which suppresses
Vite's rethrow and hands the router an `undefined` module.
- **The launcher entry drew no icon.** The host package shipped no app icon at all, and
`omarchy-webapp-install` derives an icon name that resolves to nothing. The host package now
ships the scalable mark, and setup writes `Icon=` and `Exec=` itself.
- **The console ignored the desktop theme, then wore only half of it.** `useUiConfig` was
`staleTime: Infinity`, so the console sat in the old palette until someone reloaded it — now
polled at 2 s, and refetched on focus. Separately, only `accent` and `mode` were carried, while
`background` and `foreground` were dropped on the floor. All three now reach the page, expanded
into every surface through `color-mix(in oklab, …)`. `--success` / `--warning` / `--destructive`
deliberately do not follow: they encode meaning, and a theme whose accent is red must not leave
"delete" and "save" the same colour. `web/tools/check-omarchy-palette.mjs` reads the ratios back
out of the stylesheet and asserts WCAG contrast across six shipped themes in `postbuild`.
- **Web: three findings biome surfaced.** `noUncheckedIndexedAccess` type errors in the ticket
parser, an unread `entries` state in Library written on every grid load, and an O(n²) `flatten`
in SourceSettings.
---
- **0.32.0 ABR never climbed.** The encoder round-trip lost 12 kbps and every apply recorded
a phantom ceiling. Read-back is now in the request's own units.
- **Startup probe ran before the first frame**, so the burst hit the IDR. It now arms after
a completed frame (15 s timeout).
- **Hyprland/KWin `topology: exclusive` restore was overwritten on pipeline retry**, leaving
physical heads off. First restore wins.
- **Hyprland `custom_picker_binary` shim is restored on host exit, not after each cast.**
Per-cast restore restarted the portal and killed every session after the first.
`punktfunk-omarchy remove` still restores the previous picker.
- **Hyprland monitor rule survived `hyprctl reload`.** Theme switches no longer reset the
streamed head. Mode only — exclusive heads are not re-disabled from this watcher.
- **Lua Hyprland focus** is `hl.dsp.focus({ monitor = "<name>" })`. Unfocused headless
outputs produced no frames.
- **Multi-address mDNS.** Clients rank by longest common prefix, then advertised `addr`,
then default-route source. They no longer pick `HashSet::iter().next()`.
- **`VIDEO_CAP_HDR` is off when the D3D11 video processor cannot PQ→sRGB tonemap.**
10-bit SDR is unprobed on purpose.
- **`keep_host_audio` on Apple and Android.** 0.32.0 shipped the bit; those clients never
sent it.
- **`punktfunk-omarchy status`** no longer treats a failed `sudo -n` as "no rules", and
matches `xdg-desktop-portal-hyprland` past the 15-character `comm` cap.
- **Idle guard** reads `"enabled": true` from `omarchy-toggle-idle status` JSON, so stay-awake
is actually restored.
- **`ctl watch`** exits when the consumer goes away (heartbeat write).
- **`install.sh --uninstall`** runs `punktfunk-omarchy remove` before pacman, so the picker
shim is not left pointing at a missing binary.
- **Host package ships the app icon**; setup writes `Icon=` and `Exec=` itself.
- **Console follows the desktop theme** (`accent`, `mode`, `background`, `foreground`).
Semantic colours (`--success` / `--warning` / `--destructive`) do not follow the accent.
## v0.32.0
@@ -1649,7 +1649,10 @@ public final class Stage2Pipeline {
// logs "[CAMetalLayer nextDrawable] returning nil because allocation failed" once per
// refresh until the first frame arrives. Before that frame there is nothing to present
// anyway, and the first frame waits at most one refresh for the first vend.
let startLink: () -> Void = {
// Per-GENERATION stop flag (the session `token` still stops every generation): the
// watchdog below retires a link that stopped vending and starts a fresh one, and the
// retired thread must exit without taking the session with it.
let startLink: (StopFlag) -> Void = { linkStop in
let linkThread = Thread {
let delegate = DeadlineLinkDelegate(
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
@@ -1658,12 +1661,18 @@ public final class Stage2Pipeline {
let link = CAMetalDisplayLink(metalLayer: layer)
link.preferredFrameLatency = latencyAsk // see the ladder note above
if let range = hint.drain() { link.preferredFrameRateRange = range }
link.delegate = delegate // weak this closure is the strong ref
// The link holds the delegate WEAKLY and `delegate` is a local, not a capture
// its last use is this store, so ARC may release it right here and leave a link
// that never calls back. `withExtendedLifetime` is the strong ref, not the
// closure (which captures only the values the init consumed).
link.delegate = delegate
link.add(to: RunLoop.current, forMode: .default)
while !token.isStopped {
autoreleasepool {
_ = RunLoop.current.run(
mode: .default, before: Date(timeIntervalSinceNow: 0.1))
withExtendedLifetime(delegate) {
while !token.isStopped, !linkStop.isStopped {
autoreleasepool {
_ = RunLoop.current.run(
mode: .default, before: Date(timeIntervalSinceNow: 0.1))
}
}
}
link.invalidate()
@@ -1673,10 +1682,26 @@ public final class Stage2Pipeline {
linkThread.start()
}
// Stale-link watchdog threshold. Stage-4 owns NO drawable source of its own every
// drawable arrives as `update.drawable`, so a link that stops calling back is a stream
// that never presents again (the frozen picture keeps audio and input alive, so it reads
// as a hang, not a disconnect). Field 2026-08-28, iPad Pro / iOS 27 over Tailscale: three
// presents returned `presentedTime == 0` against the 2-slot pool, the link went silent
// mid-second, and `pf-present` then logged `ok=0 noDrawable=120 vendLeadMs n=0` until the
// user quit twice in one session, both times cured instantly by reconnecting.
// 0.25 s is ~30 refreshes at 120 Hz against a normal vend wait of one refresh, so it
// cannot fire on ordinary phase jitter; the cost of a false positive is one relinked
// frame, the cost of missing it is the whole session.
let linkStaleAfter: CFTimeInterval = 0.25
let renderThread = Thread {
defer { renderStopped.signal() }
// Whether startLink ran render-thread confined (only this thread triggers it).
var linkLive = false
// The live link generation's stop flag nil until the first frame starts one.
// Render-thread confined (only this thread starts, retires or reads it).
var linkStop: StopFlag?
// When the link last handed over a drawable, for the stale-link watchdog. Reset on
// every (re)start too, so a fresh link gets its first vend before it can be judged.
var lastVend = CACurrentMediaTime()
// Per-iteration autorelease pool same contract as the arrival/glass loop (the
// vended drawable and its retinue are autoreleased objects on a runloop-less thread).
while !token.isStopped { autoreleasepool {
@@ -1707,9 +1732,11 @@ public final class Stage2Pipeline {
isHDR: planes.pq)
}
// First frame: the layer now has a real config start vending (see startLink).
if !linkLive {
linkLive = true
startLink()
if linkStop == nil {
let stop = StopFlag()
linkStop = stop
lastVend = CACurrentMediaTime()
startLink(stop)
}
guard let drawable = stash.take() else {
// No vend yet (session start: the reconcile above just unblocked the
@@ -1718,10 +1745,33 @@ public final class Stage2Pipeline {
// frame while it waits, and the update's signal retries the pairing.
ring.putBack(frame)
debugStats?.noDrawableWake()
// unless the link has gone silent (see `linkStaleAfter`). Only a decoded
// frame reaches here, so a quiet stream never trips this. Retire the
// generation and relink: invalidating the old link also returns the
// drawables it holds, so this recovers a dead link and an exhausted pool
// alike. The retired thread sees its flag within one runloop poll (100 ms)
// and invalidates there; the brief two-link overlap is harmless because the
// old link is, by definition, not vending.
// ponytail: unbounded retries (~4/s while frames arrive and no link vends).
// A relink that works logs once; a repeat IS the signal that relinking is
// not the cure, and it lands in the send-logs ring where we can read it.
// Add a backoff only if that noise ever costs more than it tells us.
let stalledFor = CACurrentMediaTime() - lastVend
if let stale = linkStop, stalledFor > linkStaleAfter {
stale.stop()
let stop = StopFlag()
linkStop = stop
lastVend = CACurrentMediaTime()
startLink(stop)
let ms = Int(stalledFor * 1000)
presentLog.error(
"stage4: link stalled \(ms) ms with no vend — retiring it, relinking")
}
debugStats?.flushIfDue(ring: ring, gate: nil)
return
}
let renderStarted = CACurrentMediaTime()
lastVend = renderStarted
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
let onGlass: (Int64?) -> Void = { presentedNs in
let atNs = presentedNs
@@ -121,27 +121,16 @@ impl SwitchProPad {
id,
&device_info_payload(&switch_mac(self.index)),
),
// SPI flash read: echoed addr + len + the canned calibration bytes. An unmapped
// range answers zeroes (echoed header, zero data) — the driver then warns and uses
// its defaults instead of stalling through 2 × 1 s timeouts.
// SPI flash read: echoed addr + len + the flash bytes living at that address. Any
// range is served (unmodelled addresses read as zero), because the kernel and SDL
// ask for the same calibration in different shapes — see `spi_flash_read`.
0x10 => {
let addr = args
.get(..4)
.map(|a| u32::from_le_bytes([a[0], a[1], a[2], a[3]]))
.unwrap_or(0);
let len = args.get(4).copied().unwrap_or(0);
let payload = spi_flash_read(addr, len).unwrap_or_else(|| {
tracing::debug!(
addr = format!("{addr:#x}"),
len,
"unmapped SPI read — zero fill"
);
let mut p = Vec::with_capacity(5 + len as usize);
p.extend_from_slice(&addr.to_le_bytes());
p.push(len);
p.extend(std::iter::repeat_n(0u8, len as usize));
p
});
let payload = spi_flash_read(addr, len);
build_subcmd_reply(&st, self.timer, 0x90, id, &payload)
}
// Everything else the driver sends (input mode 0x03, IMU 0x40, vibration 0x48,
@@ -348,44 +348,62 @@ pub fn switch_mac(index: u8) -> [u8; 6] {
[0x7C, 0xBB, 0x8A, 0xDF, 0x00, index]
}
/// The canned SPI-flash contents (subcommand `0x10`): reply payload = echoed LE address +
/// echoed length + the flash bytes. `None` for an unmapped range (the caller then replies with
/// zeroes — the driver falls back to defaults rather than aborting).
/// The modelled contents of the pad's SPI flash as `(start address, bytes)` blocks. Everything
/// outside them reads back zero.
///
/// Served ranges:
/// - `0x8010`/`0x801B`/`0x8026` (user-cal magics, 2 B): NOT `0xB2 0xA1` → user cal absent, the
/// driver takes the factory path.
/// - `0x603D`/`0x6046` (factory stick cal, 9 B): [`STICK_CENTER`] ± [`STICK_RANGE`] on every
/// axis. **Byte order differs**: left = max-above ++ center ++ min-below; right = center ++
/// min-below ++ max-above (`joycon_read_stick_calibration`).
/// - `0x6020` (factory IMU cal, 24 B): offsets 0, accel scale 16384, gyro scale 13371 — the
/// driver's own defaults, making its per-sample math the identity (accel) / ×1000 (gyro).
pub fn spi_flash_read(addr: u32, len: u8) -> Option<Vec<u8>> {
/// - `0x603D`/`0x6046` (factory stick cal, 9 B each, contiguous): [`STICK_CENTER`] ±
/// [`STICK_RANGE`] on every axis. **Byte order differs**: left = max-above ++ center ++
/// min-below; right = center ++ min-below ++ max-above (`joycon_read_stick_calibration`).
/// - `0x8010`/`0x801B`/`0x8026` (user-cal magics): NOT `0xB2 0xA1` → user cal absent, so every
/// consumer takes the factory path.
fn flash_blocks() -> [(u32, Vec<u8>); 6] {
let cal_pair = pack12(STICK_RANGE, STICK_RANGE);
let center_pair = pack12(STICK_CENTER, STICK_CENTER);
let data: Vec<u8> = match (addr, len) {
(0x8010 | 0x801B | 0x8026, 2) => vec![0xFF, 0xFF],
(0x603D, 9) => [cal_pair, center_pair, cal_pair].concat(),
(0x6046, 9) => [center_pair, cal_pair, cal_pair].concat(),
(0x6020, 24) => {
let mut v = Vec::with_capacity(24);
v.extend_from_slice(&[0u8; 6]); // accel offsets = 0
for _ in 0..3 {
v.extend_from_slice(&16384u16.to_le_bytes()); // accel scale (driver default)
let mut imu = Vec::with_capacity(24);
imu.extend_from_slice(&[0u8; 6]); // accel offsets = 0
for _ in 0..3 {
imu.extend_from_slice(&16384u16.to_le_bytes()); // accel scale (driver default)
}
imu.extend_from_slice(&[0u8; 6]); // gyro offsets = 0
for _ in 0..3 {
imu.extend_from_slice(&13371u16.to_le_bytes()); // gyro scale (driver default)
}
[
(0x6020, imu),
(0x603D, [cal_pair, center_pair, cal_pair].concat()),
(0x6046, [center_pair, cal_pair, cal_pair].concat()),
(0x8010, vec![0xFF, 0xFF]),
(0x801B, vec![0xFF, 0xFF]),
(0x8026, vec![0xFF, 0xFF]),
]
}
/// Serve an SPI-flash read (subcommand `0x10`): reply payload = echoed LE address + echoed
/// length + the `len` bytes living at `addr` in [`flash_blocks`].
///
/// **Answer by RANGE, never by exact `(addr, len)`.** Two consumers read the same flash with
/// different shapes, and only one of them is the kernel: `hid-nintendo` reads the two factory
/// stick blocks as separate 9-byte reads, while SDL — which is Steam's own Switch driver, and
/// therefore what a game actually sees — reads all 18 bytes at `0x603D` in one go and the
/// 22-byte user block at `0x8010`. Matching exact pairs served the kernel and silently
/// zero-filled Steam, which zeroed its stick centre and pinned both sticks to a corner.
pub fn spi_flash_read(addr: u32, len: u8) -> Vec<u8> {
let mut data = vec![0u8; len as usize];
for (start, bytes) in flash_blocks() {
for (i, slot) in data.iter_mut().enumerate() {
let a = addr.saturating_add(i as u32);
if let Some(b) = a.checked_sub(start).and_then(|o| bytes.get(o as usize)) {
*slot = *b;
}
v.extend_from_slice(&[0u8; 6]); // gyro offsets = 0
for _ in 0..3 {
v.extend_from_slice(&13371u16.to_le_bytes()); // gyro scale (driver default)
}
v
}
_ => return None,
};
}
let mut payload = Vec::with_capacity(5 + data.len());
payload.extend_from_slice(&addr.to_le_bytes());
payload.push(len);
payload.extend_from_slice(&data);
Some(payload)
payload
}
/// One decoded host-bound output report from the driver.
@@ -568,7 +586,7 @@ mod tests {
#[test]
fn spi_blobs_valid() {
for addr in [0x8010u32, 0x801B, 0x8026] {
let p = spi_flash_read(addr, 2).unwrap();
let p = spi_flash_read(addr, 2);
assert_eq!(&p[..4], &addr.to_le_bytes());
assert_eq!(p[4], 2);
assert!(!(p[5] == 0xB2 && p[6] == 0xA1));
@@ -579,7 +597,7 @@ mod tests {
(a, y)
};
// Left: max-above ++ center ++ min-below.
let l = spi_flash_read(0x603D, 9).unwrap();
let l = spi_flash_read(0x603D, 9);
let (data, hdr) = (&l[5..], &l[..5]);
assert_eq!(hdr, &[0x3D, 0x60, 0, 0, 9]);
let (max_above, _) = unpack(&data[0..3]);
@@ -588,18 +606,46 @@ mod tests {
assert_eq!(center, STICK_CENTER);
assert!(center - min_below < center && center < center + max_above);
// Right: center ++ min-below ++ max-above.
let r = spi_flash_read(0x6046, 9).unwrap();
let r = spi_flash_read(0x6046, 9);
let (rc, _) = unpack(&r[5..8]);
assert_eq!(rc, STICK_CENTER);
// IMU: offsets 0, driver-default scales — the identity calibration.
let imu = spi_flash_read(0x6020, 24).unwrap();
let imu = spi_flash_read(0x6020, 24);
let d = &imu[5..];
assert_eq!(&d[0..6], &[0; 6]);
assert_eq!(&d[6..8], &16384u16.to_le_bytes());
assert_eq!(&d[12..18], &[0; 6]);
assert_eq!(&d[18..20], &13371u16.to_le_bytes());
// Unmapped range → None.
assert!(spi_flash_read(0x6050, 12).is_none());
// An unmodelled range still answers, echoing addr+len over zero data.
let gap = spi_flash_read(0x6050, 12);
assert_eq!(&gap[..5], &[0x50, 0x60, 0, 0, 12]);
assert_eq!(&gap[5..], &[0u8; 12]);
}
/// ⭐ SDL — Steam's own Switch driver, and so what a GAME sees — reads the same calibration
/// in shapes `hid-nintendo` never asks for: all 18 factory bytes at `0x603D` in one read,
/// and the 22-byte user block at `0x8010`. Serving reads by exact `(addr, len)` answered the
/// kernel and zero-filled Steam, which zeroed its stick centre so every raw value read as
/// positive — both sticks pinned toward one corner, unable to travel past centre.
#[test]
fn spi_serves_sdl_read_shapes() {
// Factory: one 18-byte read = the left block then the right block, contiguous.
let f = spi_flash_read(0x603D, 18);
assert_eq!(&f[..5], &[0x3D, 0x60, 0, 0, 18]);
assert_eq!(&f[5..14], &spi_flash_read(0x603D, 9)[5..]);
assert_eq!(&f[14..], &spi_flash_read(0x6046, 9)[5..]);
// SDL's own centre parse over its left half must land on our centre, not 0.
let cal = &f[5..];
let cx = (((cal[4] as u16) << 8) & 0xF00) | cal[3] as u16;
let cy = ((cal[5] as u16) << 4) | ((cal[4] as u16) >> 4);
assert_eq!((cx, cy), (STICK_CENTER, STICK_CENTER));
// User: one 22-byte read; both magics sit where SDL looks and neither is B2 A1.
let u = spi_flash_read(0x8010, 22);
assert_eq!(&u[..5], &[0x10, 0x80, 0, 0, 22]);
let user = &u[5..];
assert_eq!(user.len(), 22);
assert_eq!(&user[0..2], &[0xFF, 0xFF]); // left magic @ 0x8010
assert_eq!(&user[11..13], &[0xFF, 0xFF]); // right magic @ 0x801B
}
/// Motion unit conversion: wire (20 LSB/°·s, 10000 LSB/g) → raw (14.247 LSB/°·s, 4096 LSB/g).
+23 -1
View File
@@ -888,7 +888,29 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
}
WindowEvent::PixelSizeChanged(..) | WindowEvent::Resized(..) => {
presenter.recreate_swapchain(&window)?;
// A driver that refuses the new size used to end the SESSION: this
// `?` walked out of `run_session`, and the shell reported a live
// stream as "couldn't connect". Field-reported on Windows 11 as
// F11 → `vkCreateSwapchainKHR: VK_ERROR_UNKNOWN`, with the reverse
// toggle fine. A refused fullscreen swapchain costs the
// fullscreen, not the stream: fall back to the geometry that was
// already working and let the size event that follows rebuild
// against it. A windowed failure still propagates — there is no
// smaller state left to fall back to.
if let Err(e) = presenter.recreate_swapchain(&window) {
if !fullscreen {
return Err(e);
}
tracing::warn!(
error = format!("{e:#}"),
"swapchain recreate failed — leaving fullscreen"
);
fullscreen = false;
if let Err(e) = window.set_fullscreen(false) {
tracing::warn!(error = %e, "failed to leave fullscreen");
}
continue;
}
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
// Match-window (D2): (re)stamp the debounce — the request fires
// once ~400 ms pass with no further size events, never per
+5 -1
View File
@@ -10,7 +10,11 @@
//!
//! Lifecycle: `vkWaitForPresentKHR` requires the swapchain to stay alive for the call's
//! duration, so [`PresentTimer::drain`] must run before any `vkDestroySwapchainKHR`
//! (recreate and teardown both do). Waits carry a 250 ms cap: presentation ids complete
//! (recreate and teardown both do) — AND before a `vkCreateSwapchainKHR` that names the
//! live swapchain as `oldSwapchain`, which externally-synchronises it and lets the driver
//! retire it under a parked waiter. Stating only the destroy half is how the drain came to
//! sit after the create, which is a Windows `VK_ERROR_UNKNOWN` on an F11 mode change.
//! Waits carry a 250 ms cap: presentation ids complete
//! in submission order (a MAILBOX-replaced image's id completes with the present that
//! replaced it), so a wait only outlives that cap when the pipeline is already wedged —
//! the timeout keeps the drain bounded rather than wedging a resize with it.
+32 -12
View File
@@ -79,6 +79,21 @@ impl Presenter {
min_images = min_images.min(caps.max_image_count);
}
// The present-wait waiter is the last thing still holding the live swapchain, and
// `vkCreateSwapchainKHR` externally-synchronises `oldSwapchain` — so the drain
// belongs BEFORE the create, not merely before the destroy. It used to sit after
// the create, which left a waiter parked inside `vkWaitForPresentKHR(old)` while
// the driver retired that same swapchain underneath it. The window that opens is
// widest exactly where the field reports land: a mode change orphans its last
// present, so that wait runs the full 250 ms cap instead of completing at the
// next vblank. Bounded by that same cap, and normally instant.
if let Some(t) = &self.present_timer {
t.drain();
}
// An unclaimed last present belonged to the dying swapchain — drop the claim.
// (`note_presented` is the only producer and shares this thread, so nothing can
// hand the waiter a new job between the drain above and the destroy below.)
self.last_presented = None;
let old = self.swapchain;
let info = vk::SwapchainCreateInfoKHR::default()
.surface(self.surface)
@@ -99,19 +114,24 @@ impl Presenter {
// SAFETY: per the Vulkan contract above - a create/allocate call on the live device, over
// builder structs that are locals outliving the call; the handle it returns is owned by
// the value being built here.
let swapchain = unsafe { self.swap_d.create_swapchain(&info, None) }
.map_err(|e| anyhow!("vkCreateSwapchainKHR: {e}{}", kmsdrm_swapchain_hint()))?;
// The parameters ride the failure text: a swapchain refusal is otherwise a bare
// driver code, and the first question every field report raises — which size,
// which format, which present mode — cost nothing to answer here.
let swapchain = unsafe { self.swap_d.create_swapchain(&info, None) }.map_err(|e| {
anyhow!(
"vkCreateSwapchainKHR: {e} ({}x{}, {:?} / {:?}, {:?}, {min_images} images){}",
extent.width,
extent.height,
self.format.format,
self.format.color_space,
self.present_mode,
kmsdrm_swapchain_hint()
)
})?;
// The old swapchain and everything tied to its images dies NOW: the fence
// quiesce covered our own command buffers, the queue drain above covered the
// presentation engine's semaphore waits — nothing can still reference them.
// The present-wait waiter is the one remaining referent: `vkWaitForPresentKHR`
// requires the swapchain alive for the call, so drain it first (bounded by the
// waiter's 250 ms cap; ids complete in order so this is normally instant).
if let Some(t) = &self.present_timer {
t.drain();
}
// An unclaimed last present belonged to the dying swapchain — drop the claim.
self.last_presented = None;
// quiesce covered our own command buffers, the queue drain covered the
// presentation engine's semaphore waits, and the present-wait drain above
// released the last referent — nothing can still reference them.
let (overlay_views, overlay_framebuffers) = self.overlay_pipe.take_targets();
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
// type and live for the call, and every builder struct is a local that outlives it.
+23
View File
@@ -140,6 +140,29 @@ ABI 25 → 26 (additive). Wire protocol stays 2.
Pairing grants launch. The routes now re-ask.
```
### Bullet shape
Two sentences per bullet.
1. What changed.
2. What the reader must do, if anything.
The bold lead is a noun or an API name, not a plot summary.
Version-table Notes cells are one clause. If it needs a paragraph, it is a Breaking bullet.
Forbidden in `CHANGELOG.md`:
- Metaphor (dice, floor, "lied", "wore", ratchet-as-plot)
- Field measurements (SKU, soak minutes, underrun counts)
- Causation chains ("so… which means… because…")
- Meta ("first section written to this rulebook")
- Why you did not take the other path
- Behaviour restorations filed under **Breaking** — those are **Fixed**
Those belong on the PR or in `docs/adr/` / `design/`. Link them.
A minor that "nothing versioned moves" still fits on two screens if the bullets follow this shape. Headings without this rule produce 200-line poems in boxes.
### Length
| Release | Target |