main moved another 62 commits (a8a4b11f->fca9f42c), taking 0.25.0 to 391 since v0.24.0. Five PRs: decode aliasing (#102), A/V sync (#101), gyro correctness (#99), web console sweep (#100), Apple ATS (#103). THE CORRECTION THAT MATTERED. The notes carried "Audio that falls behind the picture pulls itself back … Android was worst, with no correction at all", describing the jitter ring's buffer-shedding as if it were sync. It never was. The host has stamped `pts_ns` on every audio datagram since long before v0.24.0 and EVERY CLIENT DECODED IT AND NEVER READ IT — verified in the v0.24.0 tree (`crates/punktfunk-host/src/native/audio.rs:162` stamps it; the client audio paths ignore it). Lip-sync was an emergent property of buffer depth, and it got WORSE as video got faster, which is why shaving milliseconds off the audio budget had never helped. That bullet is rewritten to say what is actually true, and A/V sync takes a TL;DR slot. It displaces the settings-BOM bullet, which was the weakest of the six as a HEADLINE: conditional (only if the file was ever saved by PowerShell), partly duplicated by the Windows non-C: entry, and it survives verbatim in Fixed. A/V sync affects every user, every session, every client, with sound on — and unlike most of this release it shipped broken in EVERY release we have ever made. GYRO NEEDS AN UPGRADE NOTE, so it got one. The pipeline was wrong end to end and is now measured against a real controller, which MOVES AIM SENSITIVITY: a pad presented as a DualShock 4 reported gyro 40x fast (host-side), and a PlayStation pad on Android reported ~30% short (client-side). At 40x nobody could have compensated — gyro aim was unusable, not miscalibrated — but the Android ~1.4x change is exactly the size a real person tunes around, so `## Before you update` names it specifically. DELIBERATELY NOT PROMOTED. The decode-aliasing program (#102) reads like a catastrophe — H.264 decoding into a surface it predicted from on 297 of every 300 access units of every stream we emit, on both rungs — but it NEVER SHIPPED: `git ls-tree v0.24.0 crates/` has no pf-vkdecode/pf-dxvadec/pf-vaadec/pf-bitstream. It is a ship-blocker that was cleared, and writing "your picture was subtly wrong" would be false for every reader. It contributes one clause to the decode entry (every path is now checked frame-by-frame against a reference decoder; Windows + Intel AV1 routes through Direct3D) and a full section in the changelog. Same reasoning already applied to #96 and the rav1d abort. Changelog gains the A/V sync mechanism (including that video is the master and continuity outranks sync — the ring refuses a sync request that would break audio on a jittery link) and the aliasing section, with the four independent reasons four gates missed it: a structurally-blind conformance vector, a test that had encoded the bug AS CORRECT, a vacuous assertion that could not fail, and the fact that it streamed clean on glass. gpu_parity is 11 legs, not the 9 an earlier note claimed. Verified after the merge: lock diff versions-only 35/35, `cargo metadata --locked` resolves (39 members), `cargo fmt --all --check` clean in both workspaces, notes body 0 internal-vocabulary hits, Play notes 497/500 by android.yml's own gate. Wire 2, C ABI 17, no new capability bits in this range.
Release notes
One file per stable release: docs/releases/vX.Y.Z.md. Its contents become the Gitea release
body verbatim and are the source of the Discord #releases announcement.
Why this exists
Releases used to be created by CI with an empty body; the notes were pasted in by hand afterward. That left a window where the release — and anything announcing it — carried no notes. Now the notes are authored before the tag is pushed, as part of the version bump, so the release is born complete and the announcement always has something to say.
The flow
- Write the notes. Add
docs/releases/vX.Y.Z.mdin the same commit (or PR) as the version bump. CopyTEMPLATE.mdand fill it in. This file is the single source of truth for the body. - Tag & push.
git tag -a vX.Y.Z … && git push origin vX.Y.Zfans out to the build workflows. Whichever one wins the create race seeds the release body from this file (scripts/ci/gitea-release.sh→ensure_release, and its PowerShell twin). The release page shows the notes immediately. - Wait for green. Let every platform's CI finish and go green.
- Announce. Dispatch the
announceworkflow (.gitea/workflows/announce.yml) with the tag. It re-asserts this file over the live release (so any late edit wins) and posts an embed to Discord#releases. Pressing "go" is the quality gate — a half-built release is never announced. Stable-only; a-rctag is refused unlessallow_prerelease=true.
Editing the notes after the tag is fine: update this file, then re-run step 4 (or PATCH the body via the API) — the announce step always re-syncs from the file, so the file stays authoritative even across a tag re-point.
Canary / -rc builds have no file here on purpose: they get no curated body and are not
announced.
Google Play "What's new": whatsnew/vX.Y.Z.txt
Play shows its own release notes on the Play Store listing and in the Play Store app, and caps
them at 500 characters per language — the vX.Y.Z.md body is two orders of magnitude too
long, so it gets its own short file: docs/releases/whatsnew/vX.Y.Z.txt.
Write it for a phone/TV user, not a host operator: only what changed in the Android app is
worth their 500 characters. Plain text (Play renders no markdown), one • bullet per line, same
voice rules as below. Copy whatsnew/TEMPLATE.txt.
A vX.Y.Z tag without this file fails the android job before it builds. This is a hard gate,
not a warning, because the failure it prevents is silent: when the file is missing Play does not
show an empty "What's new" — it carries the previous release's text onto the new version, so
the store listing describes a build nobody is getting, and nothing surfaces that but reading the
listing. It is the same shape as the v0.22.3 notes announcing a feature that release never
contained. The gate also rejects a file byte-identical to another release's, which is that bug
reached by copy-paste instead of by omission.
The gate runs first in the job, so a miss costs a second and leaves nothing half-published —
no build, no assets on the Gitea release, nothing on Play. Two more checks sit downstream:
play-upload.py refuses text over the 500-char cap (printing the real count) before it uploads,
because the API only rejects oversized notes at commit, by which point the AAB is already on Play.
Canary is exempt: it has no curated notes, and Play reusing text for internal testers costs nothing.
Same freeze rule as the notes: once the tag exists, this file is the record of what that versionCode shipped.
Voice & format
Write for the people who USE Punktfunk to stream their games and desktops — not for the people who
build it. A non-engineer should finish knowing what's new and whether it affects them; an engineer
should never be confused or forced to decode internals. (See any recent vX.Y.Z.md for the target.)
- Lead with the benefit. Each entry = what the user can now do, what now works, or what stopped going wrong — in their words. Implementation is not the story.
- No internal vocabulary in the body. No protocol/message names, code type names, hex codes or hardware IDs, crate/component names, or API symbols. Translate any essential detail to plain language. Name things users recognize (iPad, Apple Pencil, Steam Deck, Android TV, the Windows sign-in screen) — not subsystems.
- Group as New / Improved / Fixed, each a bold one-line lead-in + a tight plain explanation.
Skimmable. The lead-in text before the first
##is what the Discord announcement shows, so make it a real, plain-language summary. - Be specific and honest — no vague "various improvements"; a reader should know exactly what changed.
- Compatibility line up top, in plain terms: can they update one side at a time? does their existing setup keep working? No version numbers in the lead.
- No protocol / ABI / driver / embedder detail in this file at all. It goes in the root
CHANGELOG.md(see below), and the notes carry a single short## For developerssection linking there. Nothing else invX.Y.Z.mdmay use an internal name. - Open with a
## TL;DR— three to six bullets naming only what most readers would be sorry to miss, each one line. A large release is exactly where a reader gives up, and the TL;DR is what they read instead of giving up. If something needs the reader to act, it belongs here and in## Before you update, not buried in## Fixed.
The technical half: root CHANGELOG.md
Why it is separate. Through v0.24.0 the engineering detail lived in an ## Under the hood (for developers) section at the bottom of each release's notes. That worked while releases were small.
It stopped working: v0.25.0 is 300+ commits, and the section had grown long enough to bury the
user-facing half it was appended to — the exact failure the voice rules exist to prevent. The two
audiences also want different shapes. A user reads one release and wants prose; an embedder wants to
diff across releases and see when the ABI moved, which is a table, not a paragraph.
So: vX.Y.Z.md is for people who use Punktfunk, CHANGELOG.md is for people who build against it,
and neither has to compromise for the other.
Format. Newest release first, one ## vX.Y.Z section each. Lead with a version table (wire
protocol, C ABI, driver protocol, gamepad channel — every row, marked unchanged where it did not
move, because "unchanged" is the answer an embedder most often needs). Then breaking changes, then
whatever else matters: capability bits, new environment variables, wire additions, workspace
members. Internal names are the point here — use them.
Linking. The notes link to the file at the tag, not at main:
https://git.unom.io/unom/punktfunk/src/tag/vX.Y.Z/CHANGELOG.md. A release's notes are frozen; a
link to main would silently start describing a later release.
Same freeze rule. Add the release's section in the version-bump commit, alongside the notes.
The short annotated-tag message stays separate and short (a headline + a paragraph); it is the tag object's message, not this file.