tvOS EDR / HDR output from a CAMetalLayer (fork spike)
tvOS EDR / HDR output from a CAMetalLayer
Section titled “tvOS EDR / HDR output from a CAMetalLayer”Research and spike notes, 2026-10-08. Landed in the normal fork build: tvos-render-1080p was
fast-forwarded to efa0097 (80336df spike, 376976c AVKit crash fix, efa0097 tightened support
rule), and the installed tvos.zip release lib was repackaged.
Verified on the Apple TV 4K (BUILD 285/286, with the 376976c lib): with Match Content on, the
TV auto-switches to HDR, enabled=true, and Chris confirmed by eye that highlights go brighter than
UI white. The efa0097 support rule below had not been on a device yet when it landed.
Why HDR output never turned on (BUILD 283, Apple TV 4K set to “4K HDR”)
Section titled “Why HDR output never turned on (BUILD 283, Apple TV 4K set to “4K HDR”)”Device readout: capable=true on=true supported=false requested=false max_nits=100.
- Detection.
DisplayServerTVOS::_screen_hdr_is_supported()returnedUIScreen.potentialEDRHeadroom > 1.0. tvOS reports1.0even while the box outputs HDR, sowindow_is_hdr_output_supported()was false.render/hdr_output.gdonly requests HDR on a supported screen, which explainsrequested=false. The game side was behaving correctly. - Layer opt-in. The Metal surface (
drivers/metal/rendering_context_driver_metal.cpp, bothSurfaceLayerandSurfaceOffscreen) opts into EDR withCALayer.wantsExtendedDynamicRangeContent. The tvOS 27 SDK marks itAPI_UNAVAILABLE(tvos)in bothCALayer.handCAMetalLayer.h, and it is deprecated on iOS/macOS 26. Because metal-cpp calls it throughobjc_msgSend, it compiles; at runtime the SDR branch has always sent it (withNO) and the app hasn’t crashed, so the selector exists but probably does nothing. - Headroom.
max_nits = potentialEDRHeadroom (1.0) x 100gives the reported 100.
What the tvOS 27 SDK offers (AppleTVOS27.0.sdk headers)
Section titled “What the tvOS 27 SDK offers (AppleTVOS27.0.sdk headers)”| API | tvOS | Notes |
|---|---|---|
CALayer.preferredDynamicRange (CADynamicRangeAutomatic/Standard/ConstrainedHigh/High) |
26.0+ | Replaces wantsExtendedDynamicRangeContent. The default is Standard. EDR activates only when the layer’s contents carry headroom tagging > 1.0. Any value other than Standard stops tone mapping to SDR. |
CALayer.contentsHeadroom |
26.0+ | “CAMetalLayers can use this value to define how much headroom is needed by their MTLDrawables.” 0 means untagged. |
CALayer.toneMapMode |
18.0+ | Default Automatic. Left alone. |
CAMetalLayer.EDRMetadata, CAEDRMetadata |
unavailable | So HDR10/HLG metadata on the layer is not an option on tvOS. |
UIScreen.potentialEDRHeadroom / currentEDRHeadroom |
16.0+ | Reports 1.0 on an HDR-mode ATV (observed). It may rise once tagged EDR content is on screen. Forum reports say iOS only raises current after >1.0 pixels are presented. This is unconfirmed on tvOS. |
AVPlayer.eligibleForHDRPlayback + AVPlayerEligibleForHDRPlaybackDidChangeNotification |
13.4+ | “YES if an HDR display is available and the device is capable of playing HDR.” This is a capability signal: it says nothing about the current output mode. It replaced the now-deprecated availableHDRModes. |
UITraitCollection.imageDynamicRange |
17.0+ | How HDR images render in this trait environment (2 = high). It may track the current output mode. Logged by the spike to find out. |
UIWindow.avDisplayManager.preferredDisplayCriteria (AVDisplayCriteria initWithRefreshRate:formatDescription:) |
11.2 / 17.0+ | Asks the TV to switch display mode. The system honours it only when the user has Match Content > Match Dynamic Range turned on. displayCriteriaMatchingEnabled reports that setting. |
metal-cpp in the fork already wraps setPreferredDynamicRange, setContentsHeadroom and the
CA::DynamicRange* constants (thirdparty/metal-cpp/QuartzCore/CALayer.hpp), so no new bindings
were needed.
Apple’s public documentation for contentsHeadroom and for preferredDynamicRange on a
CAMetalLayer is still a bare symbol page. No Apple sample, WWDC session or forum post was found
that shows EDR from a Metal layer on tvOS. WWDC21 (10161) says Mac, iPad and Apple TV driving a
typical HDR10 display get “roughly 5x” EDR headroom. Apple’s HDR image guide gives 4.926
(1000/203) as the default content headroom for untagged HDR images.
What the spike changes (tvOS only; the iOS/macOS paths are unchanged)
Section titled “What the spike changes (tvOS only; the iOS/macOS paths are unchanged)”- Detection (
platform/tvos/display_server_tvos.mm): HDR is supported whenpotentialEDRHeadroom > 1orAVPlayer.eligibleForHDRPlayback. The eligibility value is cached and refreshed on its change notification, which also re-runs_update_hdr_output. - Headroom: the real screen value is used when it is above 1.0. Otherwise, when the display is
eligible, the code assumes
1000/203 ≈ 4.93, givingmax_nits ≈ 493and anoutput_max_linear_valueof about 4.93. The null-screen fallbacks now return 1.0 instead of 0, which used to divide by zero in the reference-luminance calculation. - Layer (
drivers/metal/rendering_context_driver_metal.cpp): on tvOS HDR, the layer getspreferredDynamicRange = HighandcontentsHeadroom = max/reference luminance(≈ 4.93), with the same RGBA16Float +kCGColorSpaceExtendedLinearSRGBswapchain iOS uses. For SDR it getsStandard+0. The tvOS build no longer sendssetWantsExtendedDynamicRangeContent:. - Mode switch (optional, included):
window_request_hdr_outputsetspreferredDisplayCriteriato an HEVC 2160p BT.2020/PQ format description at the screen’s refresh rate while HDR is enabled, and tonilwhen it is off. If the ATV is already in a “4K HDR” format, this does nothing. With Match Dynamic Range on and the ATV in SDR, the toggle triggers a real TV mode switch, which blanks the screen for a few seconds. - Diagnostics: one
print_lineat first use and on every request:tvOS HDR [<why>]: eligibleForHDRPlayback=… potentialEDRHeadroom=… currentEDRHeadroom=… imageDynamicRange=… displayGamut=… criteriaMatching=… modeSwitching=….
Launch crash in BUILD 284 (fixed in fork 376976c)
Section titled “Launch crash in BUILD 284 (fixed in fork 376976c)”BUILD 284 (spike 80336df) crashed on every launch with
-[UIWindow avDisplayManager]: unrecognized selector. avDisplayManager is an AVKit category
on UIWindow. Calling it creates no AVKit symbol reference, and ld only links a framework listed in an
object’s LC_LINKER_OPTION (clang module autolinking) when that framework resolves an undefined
symbol. So AVKit was silently dropped, which this repro confirmed: a category-only reference links
without AVKit even though AVKit is in the object’s autolink list. The diagnostic log queried it on
the first HDR-support poll.
The fix adds a hard [AVDisplayManager class] reference, which forces
_OBJC_CLASS_$_AVDisplayManager to resolve against AVKit. Every AVKit/AVFoundation selector the
spike added is guarded with respondsToSelector:, and AVKit is only touched once HDR output is
actually requested. A test link of the rebuilt object with -dead_strip_dylibs now lists AVKit,
AVFoundation and CoreMedia in otool -L. The Xcode shell needs no extra flag.
General rule: an ObjC category from a framework the binary doesn’t otherwise reference will not
autolink, so add a class reference.
Support rule (fork efa0097)
Section titled “Support rule (fork efa0097)”supported = display_hdr && (output_hdr || can_switch). eligibleForHDRPlayback alone was
capability-only and overstated on a fixed 4K-SDR setup.
display_hdr:AVPlayer.eligibleForHDRPlayback, orpotentialEDRHeadroom > 1.output_hdr: EDR headroom > 1, or the screen’straitCollection.imageDynamicRange == High, or the engine’s HDR output is already on. That last one is hysteresis, sosupportedstays true across a mode switch the game itself triggered.can_switch:AVDisplayManager.displayCriteriaMatchingEnabled(Match Content is on, so the request’spreferredDisplayCriteriacan switch the TV). This is the device-verified path.- The rule is re-evaluated on
AVDisplayManagerModeSwitchSettingsChanged/ModeSwitchEndand on eligibility changes. - Unverified: a fixed “4K HDR” format with Match Content off. That case depends on
imageDynamicRangeor headroom reporting HDR. If tvOS reports neither, the rule under-reports (the game stays SDR) rather than overstating.
Open questions (only the device can answer)
Section titled “Open questions (only the device can answer)”- Does
eligibleForHDRPlaybackreturn YES on the ATV in “4K HDR”? It probably also returns YES in 4K SDR with an HDR TV attached. If so,supportedmeans “capable”, not “currently HDR”. That is harmless, because the compositor tone-maps tagged content down, but the readout would mislead. IfimageDynamicRangeturns out to track the output mode, gate on it instead. - Does the tvOS compositor honour
preferredDynamicRange+contentsHeadroomon a CAMetalLayer? The headers say yes. Nothing public shows it working on tvOS. If it doesn’t, the game looks the same as SDR (values above 1.0 clip): no crash, just no gain. - Does
currentEDRHeadroomrise above 1.0 once tagged content is presented? If it does, the real figure replaces the 4.93 assumption on the next frame poll. - Extended linear sRGB vs BT.2020 PQ: the compositor has to map extended-sRGB values beyond the P3/709 gamut. That is fine for neon brightness, though Rec.2020 saturation outside sRGB isn’t reachable this way.
How to verify on device
Section titled “How to verify on device”Read the game’s console hdr fields: supported should become true. After the toggle,
requested and enabled should be true and max_nits about 493 (or potential x 100 if the
screen reports a real headroom). Then check by eye whether highlights above 1.0 go brighter than
UI white. The tvOS HDR [...] log lines (Xcode console / devicectl ... --console on a USB/
paired launch) show the raw signals for question 1.