HDR was a stop bright, in-game HDR was unreachable on every distro, and SDR shipped untagged #210

Merged
enricobuehler merged 5 commits from worktree-hdr-colour-anchor-fixes into main 2026-08-13 21:50:13 +00:00
5 Commits
Author SHA1 Message Date
enricobuehler 082c65755f fix(packaging): every channel ships the WSI layer, so in-game HDR works off a stock install
ci / web (pull_request) Successful in 1m0s
ci / bun-nix (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 3m29s
ci / docs-site (pull_request) Successful in 3m53s
android / android (pull_request) Successful in 5m4s
ci / rust (pull_request) Successful in 5m9s
apple / swift (pull_request) Successful in 2m0s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
nix / flake (pull_request) Failing after 6m17s
The previous commit built the layer and taught the host to use it, but only the
Arch PKGBUILD carried the files, so every other channel still landed on the
no-game-HDR fallback. This finishes the job.

The packaging scripts now take `--stage`, the DESTDIR the gamescope build script
wrote, instead of a path to one binary. That is the part worth keeping: the next
file this package needs will not require a new flag in four scripts and two
workflows. CI caches the whole staged tree for the same reason. The gs-cache key
already hashes packaging/gamescope/**, which this commit changes, so stale caches
in the old single-file shape cannot be restored into the new layout.

Channels, all of them:
  rpm     spec gains Source1/Source2 and %files entries
  deb     build-gamescope-deb.sh copies the layer into the package root
  Arch    PKGBUILD (previous commit); the sysext extracts the whole usr tree
  sysext  bazzite takes --gamescope-stage; arch asserts the layer arrived
  nix     the derivation keeps, renames and rewrites the layer rather than
          deleting it with everything else

A missing layer is fatal in every one of them, not best-effort. A package that
carries the compositor without it looks completely healthy and then silently
denies every game an HDR10 swapchain -- the exact failure this whole change
exists to end, so it must not be possible to ship it again by accident.

Two things needed care:

The layer manifest carries an ABSOLUTE library_path baked in at build time, so
every channel has to install the .so at exactly that path. That means literal
/usr/lib/punktfunk, not %{_libdir} (which is /usr/lib64 on Fedora) and not a
Debian multiarch triplet. Nothing links the .so by soname -- the loader dlopens
it by that path -- so multilib has no claim here. The rpm and nix install checks
now read the path back out of the manifest and fail if it names a file the
package does not install, because a manifest pointing at nothing is the silent
shape of this bug.

NixOS has no /usr, so the layer lives inside the gamescope derivation and the
host's path is overridable via PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR, which the
module sets -- the same posture as PUNKTFUNK_GAMESCOPE_BIN, and documented.

The manifest rewrite moved out of a heredoc into
packaging/gamescope/rewrite-wsi-layer-manifest.py because the FHS builds and the
Nix store both need it and must rename the layer identically; two copies would
drift into a host looking for a name only one of them produces.

Verified: 214 pf-vdisplay tests pass in a linux container, clippy -D warnings and
rustfmt clean, bash -n on all five changed shell scripts, both workflow YAMLs
parse, and the rewrite script was run against a synthetic FROG manifest to
confirm it renames/repoints/regates while preserving the `functions` block --
which is the field that decides whether the layer loads at all.
NOT verified: no nix on this machine, so gamescope.nix, flake.nix and the module
are unevaluated; no gamescope build, no package build of any kind, and no game
has taken an HDR swapchain on glass.
2026-08-13 23:37:48 +02:00
enricobuehler 3ac4548cf8 fix(gamescope): ship the WSI layer built beside our compositor, instead of guessing at the distro's
A game nested under gamescope gets an HDR10 swapchain from the FROG WSI layer and
from nothing else -- gamescope advertises no runtime colour-management protocol a
Mesa/NVIDIA WSI could negotiate through. That layer talks `gamescope_swapchain`
to the compositor, and when the two disagree the compositor rejects the client's
swapchain_feedback and every Vulkan client dies on a black screen with sound and
input and no error anywhere.

We ship our own compositor and did NOT ship a layer, on the recorded grounds that
the layer is "version-independent of the compositor binary". It is not, and
wsi_layer_matches_our_gamescope() exists because it is not. So the host was left
guessing from version triples, and that guess is wrong in both directions: a
distro at the same upstream tag that patched the protocol compares EQUAL and
keeps a layer that will kill every game, while a distro at a different tag with a
byte-identical protocol compares unequal and loses HDR for nothing. Since we pin
a rev, the second case is the normal one -- on essentially every box with a
distro gamescope, the layer was disabled and no game could render HDR.

Ship the layer instead. It is built from the same tree at the same rev as the
compositor, so the two cannot drift, and the guess stops being load-bearing. It
is installed under our own name (VK_LAYER_PUNKTFUNK_gamescope_wsi) at our own
path with our own enable/disable variables, so it coexists with the distro's
rather than colliding -- the Vulkan loader keys implicit layers on that name --
and the host switches the two independently in one session.

WsiPlan makes the three states explicit and resolves them once per launch, since
the fallback spawns `--version` probes:
  Ours            our layer is installed: enable it, force the distro's off
  DistroKept      no layer of ours, distro's looks compatible: touch nothing
  DistroDisabled  no layer of ours, distro's untrusted: today's behaviour

That last arm is the fail-safe. A host newer than its gamescope package behaves
exactly as it does today rather than enabling a layer that is not there, so this
can roll out one packaging surface at a time without a flag day.

Only the Arch PKGBUILD carries the new files so far. The rpm path takes a
CI-cached binary rather than the build script's stage dir, so it needs the cache,
build-gamescope-rpm.sh and the spec moved together; the deb, both sysexts and
gamescope.nix need the same two files added. Until each lands, those boxes take
the DistroDisabled arm and are no worse off than before.

Verified: 214 pf-vdisplay tests pass in a linux container (including a new one
pinning that the Ours arm enables ours AND forces the distro's off together --
either half alone is a bug), clippy -D warnings and rustfmt clean, both shell
files pass bash -n, and the manifest rewrite was run against a synthetic FROG
manifest to confirm it renames/repoints/regates while preserving the `functions`
block. NOT verified: an actual gamescope build, any package build, or a game
taking an HDR swapchain on glass.
2026-08-13 23:18:02 +02:00
enricobuehler a190fdb1ae fix(apple/tvos): an SDR stream forced the TV into HDR10 output
applyDisplayCriteriaIfNeeded builds a synthetic format description hardcoding
BT.2020 primaries, ST.2084 PQ and the BT.2020 matrix, then hands it to
AVDisplayManager to pick a display mode. Its guard checked only that no criteria
had been set yet and that the user's HDR setting was on -- never that the stream
itself was HDR. Since that setting defaults to true, an ordinary SDR session
drove an HDR-capable TV into PQ output.

That is a standard way to raise the black floor: the Apple TV switches HDMI to
limited-range levels in its HDR modes, and a set configured for full range then
renders code 16 as grey rather than black.

Now gated on connection.isHDR as well. Layout re-runs this, so a session that
flips to HDR mid-stream still picks the mode up on the next pass.

This was NOT the cause of the 2026-08-13 grey-blacks report -- that one had the
client's HDR setting off, so this path never ran (see the SDR layer tagging in
the previous commit for the mechanism that did apply). It is a real bug on its
own, found while investigating it.

Verified: full tvOS compile clean against the AppleTVOS SDK. Not yet verified on
glass.
2026-08-13 22:59:38 +02:00
enricobuehler 94f049ba70 fix(apple): the SDR layer shipped untagged, because an SDR session never configured colour
configure(hdr:) guards on hdr != hdrActive, and hdrActive starts false. A
session that is SDR from its first frame therefore matched the initial state and
fell straight through the guard, so configureColor never ran even once and the
layer kept make()'s bare configuration -- which never assigns a colour space.

An untagged CAMetalLayer gets no colour matching: the BT.709-encoded stream is
drawn in the display's native space. That is mild oversaturation on a P3 Mac or
iPad, and on a tvOS display composited for HDR it also lifts the black floor.
It matches a field report of greys where blacks should be, which arrived with
the client's own HDR switch already OFF -- so nothing else in the pipeline had
tagged those pixels either.

It also meant PUNKTFUNK_SDR_COLORSPACE was dead code on exactly the sessions it
existed to fix: an operator A/B-ing it in the field would have seen no change at
all, because the assignment it feeds was never executed.

So: configureColor now runs once regardless (same-state calls after the first
are still no-ops), and tagging is the default rather than opt-in, since drawing
a BT.709 stream in the panel's native space is not a rendering anyone asked for.
PUNKTFUNK_SDR_COLORSPACE=none restores the untagged look as the A/B lever.

The tvOS HDR tone-map branch gets the same tag -- pf_frag_hdr_tv outputs BT.709,
so it is an SDR layer by the time it is presented.

Verified: full tvOS compile of every PunktfunkKit source clean against the
AppleTVOS SDK. (The build's link step fails on three ABI symbols missing from
the checked-in PunktfunkCore.xcframework, which predates them -- pre-existing,
main fails identically, unrelated to these files.) Not yet verified on glass.
2026-08-13 22:59:36 +02:00
enricobuehler c29e720324 fix(gamescope): HDR sessions anchored SDR white a stop bright, and never said game HDR was unreachable
Field report 2026-08-13, Bazzite host in gaming mode to an iPad: Steam's Big
Picture UI looked glaring and over-saturated while HDR game content looked
washed out, both on the same stream.

Those are one error. gamescope maps everything that is not an HDR game -- the
desktop, the Steam overlay, an SDR title -- into the session's PQ container at
--hdr-sdr-content-nits, and we only passed that flag when an operator had set
PUNKTFUNK_GAMESCOPE_SDR_NITS. Unset, gamescope used its own default of 400,
while every first-party client anchors diffuse white at 203 (BT.2408 reference
white; the Apple presenter hands exactly that to CAEDRMetadata.hdr10's
opticalOutputScale). The two ends were nearly a stop apart, so the UI landed
above SDR white and the client's tone-mapper worked from a reference point the
host had never used, flattening the content around it.

The flag is now always passed, defaulting to 203. The knob still overrides it
for anyone who wants a brighter or dimmer desktop.

Separately, and visible in the same log: the two HDR decisions in a gamescope
session are made independently. hdr_args() never consults
wsi_layer_matches_our_gamescope(), so when the WSI-layer version check fires --
which it does on essentially every Bazzite box, since we pin our own gamescope
rev and the check compares version triples -- the session launches advertising
HDR while having made an HDR10 swapchain unreachable for every game in it. That
layer is the only route to one, so a title told to render HDR renders it into an
SDR swapchain and looks washed out, with nothing anywhere saying why. It now
warns. The behaviour of the check itself is deliberately left alone: re-enabling
a genuinely mismatched layer black-screens every Vulkan client, which is worse
than losing HDR, and that trade needs a real box to retest.

Verified: scripts/xcheck.sh linux clippy clean on pf-vdisplay (-D warnings),
rustfmt clean. Not yet verified on glass.
2026-08-13 22:59:30 +02:00