Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1495ec2bd2 | ||
|
|
0f20e72d03 | ||
|
|
3725db7cfe | ||
|
|
09662c0466 | ||
|
|
8f8f50fb79 | ||
|
|
21d30b7320 | ||
|
|
d58ed540de | ||
|
|
87bc0883db | ||
|
|
b58606dbfc |
+73
-206
@@ -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 1–2 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 6–8 s (indirect display driver acquisition), so the burst landed on the
|
||||
first IDR frame: 5–11 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 1–2 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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user