From 190637537c1419fd460d4792c3eb978b9d674f15 Mon Sep 17 00:00:00 2001 From: sn0wst0rm <9982041+sn0wst0rm@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:23:07 +0100 Subject: [PATCH] Add HDR presentation and restore macOS Game Mode Present negotiated HDR scanout through a float EDR layer on macOS and iOS, with shared luminance metadata and SDR capture conversion. Retain Game Mode eligibility and the documented per-game workaround for affected Macs. Cover production Metal presentation paths, transfer conversion, display transitions and launcher settings. --- dm/games/0100F2C0115B6000/README.md | 2 +- dm/games/040074A01BF12000/README.md | 2 + dm/games/040074A01BF12000/game.tsv | 1 + dm/games/0400C3F00006E000/README.md | 9 ++ dm/games/0400C3F00006E000/game.tsv | 1 + dm/games/README.md | 1 + dm/games/game.py | 4 + dm/games/settings.tsv | 1 + dm/gfx/hle_vi.c | 108 +++++++++++++---- dm/gfx/msl/present.metal | 90 +++++++++++++- dm/gfx/nvn/nvn_hdr.c | 45 +++++++ dm/gfx/nvn/nvn_hdr.h | 18 +++ dm/gfx/nvn/nvn_hdr_layer.m | 47 +++++++ dm/gfx/nvn/nvn_mtl.m | 51 +++++++- dm/gfx/tests/Makefile | 3 + dm/gfx/tests/t_present_crop.m | 56 +++++++++ dm/gfx/tests/t_present_hdr.m | 182 ++++++++++++++++++++++++++++ dm/host/front_mac.m | 7 +- dm/ios/Sources/front_ios.m | 15 ++- dm/ios/tests/duo/project.yml | 2 + dm/mac/pack_app.py | 4 +- dm/tools/docs_patches.py | 2 +- dm/tools/tests/test_nsp_mac_app.py | 2 +- dm/tools/tests/test_settings.py | 23 ++++ docs/agents/symptoms.md | 2 + docs/development.md | 2 +- docs/env_switches.tsv | 1 + docs/reference/frame-rate.md | 6 +- docs/reference/rendering.md | 8 ++ docs/reference/services.md | 7 ++ 30 files changed, 660 insertions(+), 42 deletions(-) create mode 100644 dm/gfx/nvn/nvn_hdr.c create mode 100644 dm/gfx/nvn/nvn_hdr.h create mode 100644 dm/gfx/nvn/nvn_hdr_layer.m create mode 100644 dm/gfx/tests/t_present_hdr.m diff --git a/dm/games/0100F2C0115B6000/README.md b/dm/games/0100F2C0115B6000/README.md index e808188709..89fa1d03dc 100644 --- a/dm/games/0100F2C0115B6000/README.md +++ b/dm/games/0100F2C0115B6000/README.md @@ -83,7 +83,7 @@ Game-specific findings. The general host changes they led to are listed under [F ## Found on this game - [A game file whose extension is in capitals is not found](../../../docs/reference/packing.md#a-game-file-whose-extension-is-in-capitals-is-not-found) (2026-11-04, LNPANDY/NX2Apple#554): its `.NSP` stopped the build on a Mac. -- [macOS Game Mode holds the GPU at its lowest clock](../../../docs/reference/frame-rate.md#macos-game-mode-holds-the-gpu-at-its-lowest-clock) (2026-11-04, #349): the Mac's fall to 30 fps at 338 MHz; a new Mac app opts out, a re-packed one needs the Game Overlay switch once. +- [macOS Game Mode holds the GPU at its lowest clock](../../../docs/reference/frame-rate.md#macos-game-mode-holds-the-gpu-at-its-lowest-clock) (2026-11-04, #349): the reported M4 Pro fall to 30 fps at 338 MHz; affected players can turn Game Mode off per game in Game Overlay. The packer-wide opt-out has been reverted. - [A regress run stops when another app takes the key window](../../../docs/reference/frame-rate.md#a-regress-run-stops-when-another-app-takes-the-key-window): the route's intermittent "hang" was the game paused out of focus (resolved). - [Old per-shader cache entries grew a game's build folder with each chain change](../../../docs/reference/shaders.md#old-per-shader-cache-entries-grew-a-games-build-folder-with-each-chain-change) (2026-11-03, #1033): 12 GB of earlier chains' entries in `cas/shader`. - [Same-pass feedback read from the tile](../../../docs/reference/rendering.md#same-pass-feedback-read-from-the-tile) (2026-11-03): the shrine decals of #737. diff --git a/dm/games/040074A01BF12000/README.md b/dm/games/040074A01BF12000/README.md index e1a8ee8628..3374b562dc 100644 --- a/dm/games/040074A01BF12000/README.md +++ b/dm/games/040074A01BF12000/README.md @@ -66,6 +66,8 @@ Each step below stopped the boot before its fix: ## Found on this game +- [Negotiated HDR scanout to Apple EDR](../../../docs/reference/rendering.md#negotiated-hdr-scanout-to-apple-edr): the HDR setting is available in both Mac and iOS launchers. + - [A depth clear over the whole target with a partial stencil mask](../../../docs/reference/rendering.md#a-depth-clear-over-the-whole-target-with-a-partial-stencil-mask): 22.8 MB of depth and stencil loaded a frame for a stencil mask of 1 (resolved). - [No Game language row in an app packed from a NUP or a dump](../../../docs/reference/packing.md#no-game-language-row-in-an-app-packed-from-a-nup-or-a-dump): reported by testers; this folder's dump has no control data. - Everything under [Boot](#boot-mac) is new: the first Switch 2 game here. diff --git a/dm/games/040074A01BF12000/game.tsv b/dm/games/040074A01BF12000/game.tsv index 93896a9889..60962dab06 100644 --- a/dm/games/040074A01BF12000/game.tsv +++ b/dm/games/040074A01BF12000/game.tsv @@ -38,6 +38,7 @@ shader_groups /Shader/=shader /Effect/=effect /UI/=layout shader_note Donkey Kong Bananza shaders # ---- Graphics ---- +hdr on # agl's render buffer binding wraps its clears in render conditions (SetRenderEnableConditional(RENDER_IF_NOT_EQUAL) at main+0x12798c before the ClearColor at main+0x1279d4): # without the key, a clear-only rebinding of the composite target right after the composite draw wiped it before the upscaler read it, and every gameplay frame came out black render_cond on diff --git a/dm/games/0400C3F00006E000/README.md b/dm/games/0400C3F00006E000/README.md index 55f0337a45..a56422359c 100644 --- a/dm/games/0400C3F00006E000/README.md +++ b/dm/games/0400C3F00006E000/README.md @@ -110,3 +110,12 @@ Update 1.8.0 (v917504, SDK 21.4.0) is not on this Mac. Pull request #1116 ported - [SDK 19's decoder forms](../../../docs/reference/movies.md#sdk-19s-decoder-forms) - [A version without one of the profile's modules stops the host at boot](../../../docs/reference/packing.md#a-version-without-one-of-the-profiles-modules-stops-the-host-at-boot) - 1.8.0, by pull request #1116: [The camera through the graphics system](../../../docs/reference/services.md#the-camera-through-the-graphics-system), [Layer HDR metadata](../../../docs/reference/services.md#layer-hdr-metadata), the SDK 21 forms in [The Joy-Con mouse pointer](../../../docs/reference/services.md#the-joy-con-mouse-pointer) and [GameChat at boot](../../../docs/reference/services.md#gamechat-at-boot) + +- [Negotiated HDR scanout to Apple EDR](../../../docs/reference/rendering.md#negotiated-hdr-scanout-to-apple-edr): PQ scanout, luminance metadata and SDR captures. +- [HDR display negotiation](../../../docs/reference/services.md#hdr-display-negotiation): the launcher enables HDR on capable displays. + +## Game Mode and queue lifetimes + +MKW 1.8.0 could remain at 30 fps after splitting submissions at water event dependencies. The queue fixes in [PR #1245](https://github.com/seedds/nxnative/pull/1245) preserve submission timestamps and reuse bounded command-allocator leases without resetting GPU-owned data. Gameplay testing on M5 Max confirmed sustained 60 fps with Game Mode on, correct water and sharp moving images after those fixes. + +The Mac packer restores Game Mode eligibility. The [independent M4 Pro report](../../../docs/reference/frame-rate.md#macos-game-mode-holds-the-gpu-at-its-lowest-clock) remains unresolved; affected users can disable Game Mode per game in Game Overlay. diff --git a/dm/games/0400C3F00006E000/game.tsv b/dm/games/0400C3F00006E000/game.tsv index d1352a09c7..7df3f60bdc 100644 --- a/dm/games/0400C3F00006E000/game.tsv +++ b/dm/games/0400C3F00006E000/game.tsv @@ -39,5 +39,6 @@ shader_groups /Shader/=shader /Effect/=effect /Lib/=lib /Layout/=layout shader_note Mario Kart World shaders # ---- Graphics ---- +hdr on # agl wraps its clears in render conditions on Donkey Kong Bananza and Splatoon Raiders; the same library here, to be confirmed on this game render_cond on diff --git a/dm/games/README.md b/dm/games/README.md index b5b44b564e..712a9b807b 100644 --- a/dm/games/README.md +++ b/dm/games/README.md @@ -225,6 +225,7 @@ One row per profile; the notes are each profile's `README.md`. | `shared_font` | Optional, decided by the game files without it (the SDK version the host reads from the `sdk` module, `loader.c` `dm_sdk_major`, and `main`'s imports, `dm_game_imports`): what the shared-font address points at. `bfttf` (SDK 11 and later, when `main` imports `ScalableFontEngineHelper::Decode`) = the 8-byte BFTTF header (with the "already decoded" marker) followed by the TTF, which Mario passes to `Decode`; `ttf` = the TTF itself, which SDK 10 and older games hand straight to `ScalableFontEngine::LoadFont` (Super Mario 3D World + Bowser's Fury: with the header the engine fails with error 403 and the game crashes looking up a glyph; Odyssey, Link's Awakening, Age of Calamity and Three Houses alike) and Unity's player to FreeType (Patrick's Parabox: with the header its Japanese, Korean and Chinese text drew nothing). No profile sets it | | `device_api` | Optional, decided by the SDK version without it: the NVN API version `major.minor` that `nvnDeviceGetInteger` reports, 55.15 for SDK 11 and later (what Mario requires) and 53.313 for SDK 10 and older (`hle_nvn.c` `nvn_api`). Games check it and skip creating their device on a mismatch: Super Mario 3D World + Bowser's Fury (SDK 10) requires major 53 and minor ≥ 313. No profile sets it. `DM_NVN_API` overrides. Not part of the shader chain version (only `shader_*` keys are) | | `depth_mode` | `gl` (the default, NVN's near = −w) or `zero` (near = 0): the depth mode the game sets with `nvnDeviceSetDepthMode`, for the pipeline bases made before the game runs: the [pipeline archive made offline](../../docs/development.md#the-pipeline-archive-made-offline) for the iPhone (`main0` or `main0_z` vertex entries; built for a game with `pso_mtl4 on` or with `DM_PSO_GPUS`) and the NSP script's Mac prebuild (`DM_PSO_PREBUILD_DEPTH`). The host itself follows the mode the game sets; a wrong value only costs the device its own prebuild. The heap and address region checks of a provisional or derived profile set it when their boot reaches the game's call; without that call they leave it, as no call means unknown, not GL. Keep the key when the profile moves into `dm/games`: it is no shader input. Great Ace Attorney Chronicles, 13 Sentinels (both releases), Big Brain Academy, Link's Awakening, Engage, ASTRAL CHAIN, Luigi's Mansion 3 and Octopath Traveler 0 set `zero` | +| `hdr` | `on` offers the HDR toggle in the Mac and iOS launchers for a game with HDR scanout; `off` (default) hides it. Actual HDR output still requires the player setting and a capable display. | | `render_base` | The 3D views' base size, docked then handheld (`1600x900 1280x720`): what `DM_RENDER_SCALE` scales. `settings.py` writes it into the render scale's row of `launcher.json` as `base`, so a front end can show the setting in pixels. Only for a game with a render scale stub, and only a size its note documents | | `op_mode` | The operation mode the game sees at start: `console` (the default, docked) or `handheld`. Super Mario Galaxy and Captain Toad: Treasure Tracker use `handheld`: docked, their pointer is the Joy-Con gyro, which the host does not have; handheld, it is the touch screen, which the iPhone front end feeds with the touches no gamepad control takes (in the 3D layout, outside the right third of the screen, which is the right stick; `DmGamepad.m`). Handheld also renders at 720p. `DM_OPMODE=0` / `1` overrides, also from `Documents/env.txt` | | `perf_mode` | `follow` (the default): the performance mode follows the operation mode (Boost docked, Normal handheld); `boost`: Boost whatever the operation mode (`hle_oe.c`), a combination a Switch 1 never reports, so a profile choice. Rhythm Heaven Groove plays handheld with Boost: docked means a TV to the game, which then asks for a latency calibration, and only Boost gives its 1920x1080 draw size (handheld Normal draws 1280x720) | diff --git a/dm/games/game.py b/dm/games/game.py index dcbd753e2c..756c0f2a58 100644 --- a/dm/games/game.py +++ b/dm/games/game.py @@ -156,6 +156,9 @@ def header_one(g): """the host's game_profile.h: the profile constants the host uses, as macros""" q = lambda s: '"' + s.replace('\\', '\\\\').replace('"', '\\"') + '"' mods = g.words('modules') + hdr = g.get('hdr', 'off') # Offer HDR in the launcher for games that support HDR scanout. + if hdr not in ('on', 'off'): + sys.exit(f'{g.id}: hdr must be on or off: {hdr}') vers = ', '.join('{' + q(b['main']) + ', ' + q(b['display']) + '}' for b in g.builds().values() if 'main' in b) pi = g.get('present_interval', 'game') # present interval: game = as the game sets it (as on the Switch), display = one frame per display tick if pi not in ('game', 'display'): sys.exit(f'{g.id}: present_interval must be game or display: {pi}') @@ -219,6 +222,7 @@ def header_one(g): L = [f'// Generated by dm/games/game.py from {src}/game.tsv; do not edit', '#pragma once', f'#define DM_GAME_ID {q(g.id)}', + f'#define DM_GAME_HDR {int(hdr == "on")}', f'#define DM_GAME_NAME {q(g.get("name"))}', f'#define DM_GAME_PROGRAM_ID 0x{int(g.get("program_id"), 16):016X}ull', f'#define DM_GAME_MODULES {{{", ".join(map(q, mods))}}}', diff --git a/dm/games/settings.tsv b/dm/games/settings.tsv index 821909c41b..58bf385a97 100644 --- a/dm/games/settings.tsv +++ b/dm/games/settings.tsv @@ -35,3 +35,4 @@ DM_PSO_PREBUILD toggle - 1 gfxapi:nvn Performance Pipeline prebuild The first st DM_NVN_ENC_THREAD toggle - 1|DM_GAME_ENC_THREAD gfxapi:nvn Performance Encode thread DM_FPS_HOLD toggle on/off DM_GAME_FPS_HOLD DM_GAME_FPS_TARGET!=0 Performance Hold frames when late DM_METAL_HUD toggle - 0 always Performance Performance HUD +DM_HDR toggle - 1 DM_GAME_HDR!=0 Game HDR Requires an HDR-capable display. level=basic diff --git a/dm/gfx/hle_vi.c b/dm/gfx/hle_vi.c index 50fa26b270..1c994bc838 100644 --- a/dm/gfx/hle_vi.c +++ b/dm/gfx/hle_vi.c @@ -2,6 +2,7 @@ // Host: returns host static objects; when hooking up Metal, the native window corresponds to the front end's CAMetalLayer (front_metal_layer()). #include "../host/hle.h" #include +#include "nvn/nvn_hdr.h" static struct { uint32_t magic; } g_display = {0x50534944}, g_layer = {0x5259414c}; static struct { uint32_t magic; void *layer; } g_native = {0x4e49574e, 0}; @@ -83,13 +84,37 @@ HLE_FORCE("_ZN2nn2vi8FinalizeEv", "display service shutdown; the host has no vi // ---------- Switch 2 SDKs: HDR, VRR and extended swap intervals ---------- // The SDK's versions check the display against its own default display object and forward the layer to vi2, whose layer object the host never made (Donkey Kong Bananza: a call -// through null from SetLayerNextHdrContentGamutMetadata, main+0x2036f4). The host's output is SDR at a fixed refresh rate: no HDR output, no VRR, and the layer metadata has -// nothing to apply to. HdrOutputFormat 0 is SDR; paper white is the BT.2408 reference, 203 nits, read only when HDR is on -HLE_FORCE("_ZN2nn2vi21QueryDisplayHdrStatusEPbPNS0_15HdrOutputFormatEPtPKNS0_7DisplayE", "display HDR status: SDR output (the host presents SDR)", Result, h_vi_HdrStatus, - uint8_t *on, uint32_t *fmt, uint16_t *peak, const void *disp) { - (void)disp; if (on) *on = 0; if (fmt) *fmt = 0; if (peak) *peak = 0; return RES_OK; } -HLE_FORCE("_ZN2nn2vi25QueryDisplayHdrPaperWhiteEPtPKNS0_7DisplayE", "display HDR paper white: 203 nits (BT.2408); HDR is off", Result, h_vi_PaperWhite, uint16_t *nits, const void *disp) { - (void)disp; if (nits) *nits = 203; return RES_OK; } +// through null from SetLayerNextHdrContentGamutMetadata, main+0x2036f4). HDR is opt-in and requires a capable display and the float EDR present path. +// Bananza's SDK 20.5.19 QueryHdrStatus (sdk+0x55bd60) defines on=(format==1), accepts only formats 0/1. The virtual HDR mastering display is 1000 nits; Core Animation tone maps it to the screen. +HLE_FORCE("_ZN2nn2vi21QueryDisplayHdrStatusEPbPNS0_15HdrOutputFormatEPtPKNS0_7DisplayE", + "display HDR status: EDR capability of the current host screen", Result, h_vi_HdrStatus, uint8_t *on, + uint32_t *fmt, uint16_t *peak, const void *disp) { + (void)disp; + int enabled = dm_hdr_query(); + if (on) { + *on = enabled; + } + if (fmt) { + *fmt = enabled ? 1 : 0; + } + if (peak) { + *peak = enabled ? DM_HDR_MASTERING_PEAK : 0; + } + static atomic_int last = -1; + if (atomic_exchange(&last, enabled) != enabled) { + LOG("vi HDR: %s, virtual peak %d nits, paper white %d nits", enabled ? "enabled (EDR display)" : "off", + enabled ? DM_HDR_MASTERING_PEAK : 0, DM_HDR_PAPER_WHITE); + } + return RES_OK; +} +HLE_FORCE("_ZN2nn2vi25QueryDisplayHdrPaperWhiteEPtPKNS0_7DisplayE", "display HDR paper white: 203 nits (BT.2408)", + Result, h_vi_PaperWhite, uint16_t *nits, const void *disp) { + (void)disp; + if (nits) { + *nits = DM_HDR_PAPER_WHITE; + } + return RES_OK; +} HLE_FORCE("_ZN2nn2vi24QueryDisplayVrrSupportedEPbPKNS0_7DisplayE", "display VRR: not supported (fixed refresh rate)", Result, h_vi_VrrSupported, uint8_t *on, const void *disp) { (void)disp; if (on) *on = 0; return RES_OK; } HLE_FORCE("_ZN2nn2vi23SetDisplayVrrCapabilityEPNS0_7DisplayEb", "display VRR capability: the host has no VRR", Result, h_vi_SetVrr, void *disp, unsigned on) { @@ -115,18 +140,57 @@ HLE_FORCE("_ZN2nn2vi28SetLayerExtendedSwapIntervalEPNS0_5LayerEii", "layer exten void *layer, int a, int b) { (void)layer; static atomic_int once; if (!atomic_exchange(&once, 1)) LOG("vi::SetLayerExtendedSwapInterval %d %d: no VRR on the host, the NVN swap interval stays", a, b); return RES_OK; } HLE_FORCE("_ZN2nn2vi30ClearLayerExtendedSwapIntervalEPNS0_5LayerE", "layer extended swap interval cleared", Result, h_vi_ExtSwapClear, void *layer) { (void)layer; return RES_OK; } -HLE_FORCE("_ZN2nn2vi35SetLayerNextHdrContentGamutMetadataEPNS0_5LayerENS0_15HdrContentGamutE", "layer HDR metadata: SDR output, nothing to apply", Result, h_vi_HdrGamut, void *layer, int g) { - (void)layer; (void)g; return RES_OK; } -HLE_FORCE("_ZN2nn2vi39SetLayerNextHdrToSdrToneMappingMetadataEPNS0_5LayerENS0_14HdrToneMappingE", "layer HDR metadata: SDR output, nothing to apply", Result, h_vi_HdrToneMap, void *layer, int m) { - (void)layer; (void)m; return RES_OK; } -HLE_FORCE("_ZN2nn2vi52SetLayerNextHdrExtendedContentLuminanceRangeMetadataEPNS0_5LayerEtt", "layer HDR metadata: SDR output, nothing to apply", Result, h_vi_HdrExtLum, - void *layer, uint32_t lo, uint32_t hi) { (void)layer; (void)lo; (void)hi; return RES_OK; } -HLE_FORCE("_ZN2nn2vi52SetLayerNextHdrStandardContentLuminanceRangeMetadataEPNS0_5LayerEtt", "layer HDR metadata: SDR output, nothing to apply", Result, h_vi_HdrStdLum, - void *layer, uint32_t lo, uint32_t hi) { (void)layer; (void)lo; (void)hi; return RES_OK; } -// The rest of SDK 22's layer HDR metadata calls (the Bezier tone curve and the clears): the SDK forwards them through a table its graphics system fills, which is null on the host, so -// unanswered they call through null (Mario Kart World 1.8.0 sets the curve every frame, by pull request #1116) -HLE_FORCE("_ZN2nn2vi39SetLayerNextHdrAdjustableBezierMetadataEPNS0_5LayerEfff", "layer HDR metadata: SDR output, nothing to apply", Result, h_vi_HdrBezier, - void *layer, float a, float b, float c) { (void)layer; (void)a; (void)b; (void)c; return RES_OK; } -HLE_FORCE("_ZN2nn2vi41ClearLayerNextHdrAdjustableBezierMetadataEPNS0_5LayerE", "layer HDR metadata cleared: SDR output", Result, h_vi_HdrBezierClear, void *layer) { (void)layer; return RES_OK; } -HLE_FORCE("_ZN2nn2vi37ClearLayerNextHdrContentGamutMetadataEPNS0_5LayerE", "layer HDR metadata cleared: SDR output", Result, h_vi_HdrGamutClear, void *layer) { (void)layer; return RES_OK; } -HLE_FORCE("_ZN2nn2vi41ClearLayerNextHdrToSdrToneMappingMetadataEPNS0_5LayerE", "layer HDR metadata cleared: SDR output", Result, h_vi_HdrToneMapClear, void *layer) { (void)layer; return RES_OK; } +HLE_FORCE("_ZN2nn2vi35SetLayerNextHdrContentGamutMetadataEPNS0_5LayerENS0_15HdrContentGamutE", + "layer HDR content gamut", Result, h_vi_HdrGamut, void *layer, int g) { + (void)layer; + dm_hdr_gamut(g); + return RES_OK; +} +HLE_FORCE("_ZN2nn2vi39SetLayerNextHdrToSdrToneMappingMetadataEPNS0_5LayerENS0_14HdrToneMappingE", + "layer HDR-to-SDR policy: Core Animation maps the HDR mastering display to the output screen", Result, + h_vi_HdrToneMap, void *layer, int m) { + (void)layer; + (void)m; + return RES_OK; +} +HLE_FORCE("_ZN2nn2vi52SetLayerNextHdrExtendedContentLuminanceRangeMetadataEPNS0_5LayerEtt", + "layer HDR extended luminance range", Result, h_vi_HdrExtLum, void *layer, uint32_t lo, uint32_t hi) { + (void)layer; + dm_hdr_luminance(1, (uint16_t)lo, (uint16_t)hi); + return RES_OK; +} +HLE_FORCE("_ZN2nn2vi52SetLayerNextHdrStandardContentLuminanceRangeMetadataEPNS0_5LayerEtt", + "layer HDR standard luminance range", Result, h_vi_HdrStdLum, void *layer, uint32_t lo, uint32_t hi) { + (void)layer; + dm_hdr_luminance(0, (uint16_t)lo, (uint16_t)hi); + return RES_OK; +} +// Core Animation supplies the output tone curve; guest Bezier and HDR-to-SDR policy overrides are not applied. +// These hooks still intercept SDK forwarding through the unavailable vi2 layer. +HLE_FORCE("_ZN2nn2vi39SetLayerNextHdrAdjustableBezierMetadataEPNS0_5LayerEfff", + "layer HDR Bezier policy: Core Animation supplies the output tone curve", Result, h_vi_HdrBezier, void *layer, + float a, float b, float c) { + (void)layer; + (void)a; + (void)b; + (void)c; + return RES_OK; +} +HLE_FORCE("_ZN2nn2vi41ClearLayerNextHdrAdjustableBezierMetadataEPNS0_5LayerE", + "layer HDR Bezier policy cleared: Core Animation supplies the output tone curve", Result, h_vi_HdrBezierClear, + void *layer) { + (void)layer; + return RES_OK; +} +HLE_FORCE("_ZN2nn2vi37ClearLayerNextHdrContentGamutMetadataEPNS0_5LayerE", "layer HDR content gamut cleared", Result, + h_vi_HdrGamutClear, void *layer) { + (void)layer; + dm_hdr_gamut(-1); + return RES_OK; +} +HLE_FORCE("_ZN2nn2vi41ClearLayerNextHdrToSdrToneMappingMetadataEPNS0_5LayerE", + "layer HDR-to-SDR policy cleared: Core Animation maps the output", Result, h_vi_HdrToneMapClear, + void *layer) { + (void)layer; + return RES_OK; +} diff --git a/dm/gfx/msl/present.metal b/dm/gfx/msl/present.metal index 5bfdfeac47..266ce85c22 100644 --- a/dm/gfx/msl/present.metal +++ b/dm/gfx/msl/present.metal @@ -1,7 +1,87 @@ -// The present: the frame drawn into the drawable, cropped (nvn_mtl.m present_pipeline) +// Cropped scanout to the drawable, with explicit HDR transfer conversion. #include using namespace metal; -struct V { float4 p [[position]]; float2 uv; }; -vertex V vs(uint id [[vertex_id]]) { float2 uv = float2((id << 1) & 2, id & 2); V o; o.p = float4(uv * float2(2, -2) + float2(-1, 1), 0, 1); o.uv = uv; return o; } -struct Crop { float4 rect; float4 edge; }; -fragment float4 fs(V in [[stage_in]], texture2d t [[texture(0)]], sampler s [[sampler(0)]], constant Crop& c [[buffer(0)]]) { return t.sample(s, clamp(in.uv * c.rect.zw + c.rect.xy, c.edge.xy, c.edge.zw)); } + +struct V { + float4 p [[position]]; + float2 uv; +}; + +vertex V vs(uint id [[vertex_id]]) { + float2 uv = float2((id << 1) & 2, id & 2); + V output; + output.p = float4(uv * float2(2, -2) + float2(-1, 1), 0, 1); + output.uv = uv; + return output; +} + +struct Crop { + float4 rect; + float4 edge; + // x: input encoding (0 SDR, 1 PQ, 2 scRGB); y: EDR output; z: reserved; w: content peak in nits. + float4 color; +}; + +static float3 srgb_decode(float3 value) { + return select(value / 12.92f, pow((value + 0.055f) / 1.055f, float3(2.4f)), value > 0.04045f); +} + +static float3 srgb_encode(float3 value) { + value = max(value, 0.0f); + return select(value * 12.92f, 1.055f * pow(value, float3(1.0f / 2.4f)) - 0.055f, value > 0.0031308f); +} + +static float3 rec709_to_2020(float3 value) { + return float3(dot(value, float3(0.6274039f, 0.3292830f, 0.0433131f)), + dot(value, float3(0.0690973f, 0.9195404f, 0.0113623f)), + dot(value, float3(0.0163914f, 0.0880133f, 0.8955953f))); +} + +static float3 rec2020_to_709(float3 value) { + return float3(dot(value, float3(1.6604910f, -0.5876411f, -0.0728499f)), + dot(value, float3(-0.1245505f, 1.1328999f, -0.0083494f)), + dot(value, float3(-0.0181508f, -0.1005789f, 1.1187297f))); +} + +static float3 pq_nits(float3 value) { + // SMPTE ST 2084 EOTF: PQ signal to absolute luminance in cd/m^2, without an SDR clamp. + constexpr float m1 = 2610.0f / 16384.0f; + constexpr float m2 = 2523.0f / 32.0f; + constexpr float c1 = 3424.0f / 4096.0f; + constexpr float c2 = 2413.0f / 128.0f; + constexpr float c3 = 2392.0f / 128.0f; + float3 signal = pow(clamp(value, 0.0f, 1.0f), float3(1.0f / m2)); + float3 numerator = max(signal - c1, 0.0f); + float3 denominator = max(c2 - c3 * signal, 1e-6f); + return 10000.0f * pow(numerator / denominator, float3(1.0f / m1)); +} + +fragment float4 fs(V in [[stage_in]], texture2d texture [[texture(0)]], sampler filtering [[sampler(0)]], + constant Crop &crop [[buffer(0)]]) { + float2 uv = clamp(in.uv * crop.rect.zw + crop.rect.xy, crop.edge.xy, crop.edge.zw); + float4 sample = texture.sample(filtering, uv); + int input_encoding = int(crop.color.x); + bool edr_output = crop.color.y != 0.0f; + if (input_encoding == 0 && !edr_output) { + return sample; // Preserve the original SDR path byte-for-byte. + } + + float3 nits; + if (input_encoding == 1) { + nits = pq_nits(sample.rgb); // RGB10A2 scanout: Rec.2020 PQ. + } else if (input_encoding == 2) { + nits = rec709_to_2020(sample.rgb) * 80.0f; // Linear scRGB, 80-nit unit. + } else { + nits = rec709_to_2020(srgb_decode(max(sample.rgb, 0.0f))) * 100.0f; + } + if (edr_output) { + return float4(nits / 80.0f, sample.a); // CAEDRMetadata opticalOutputScale = 80. + } + + // SDR screenshots of HDR frames: compress highlights, then encode Rec.709 RGB. + float3 rgb = max(rec2020_to_709(nits / 100.0f), 0.0f); + float luminance = dot(rgb, float3(0.2126f, 0.7152f, 0.0722f)); + float white = max(crop.color.w / 100.0f, 1.0f); + rgb *= (1.0f + luminance / (white * white)) / (1.0f + luminance); + return float4(srgb_encode(rgb), sample.a); +} diff --git a/dm/gfx/nvn/nvn_hdr.c b/dm/gfx/nvn/nvn_hdr.c new file mode 100644 index 0000000000..34ee3002d0 --- /dev/null +++ b/dm/gfx/nvn/nvn_hdr.c @@ -0,0 +1,45 @@ +#include "nvn_hdr.h" +#include +#include +static atomic_int display_available; +static atomic_int advertised; +static atomic_int gamut = -1; +static _Atomic uint32_t standard_range = DM_HDR_PAPER_WHITE << 16; +static _Atomic uint32_t extended_range = DM_HDR_MASTERING_PEAK << 16; +int dm_hdr_requested(void) { + const char *setting = getenv("DM_HDR"); + return setting && atoi(setting) == 1; +} +void dm_hdr_display(int available) { + atomic_store(&display_available, available != 0); +} +int dm_hdr_query(void) { + int enabled = dm_hdr_requested() && atomic_load(&display_available); + // Remember negotiation: a queued HDR image must still be decoded when a display disconnects. + if (enabled) { + atomic_store(&advertised, 1); + } + return enabled; +} +int dm_hdr_advertised(void) { + return atomic_load(&advertised); +} +void dm_hdr_gamut(int value) { + if (value >= -1 && value <= 1) { + atomic_store(&gamut, value); + } +} +int dm_hdr_content_gamut(void) { + return atomic_load(&gamut); +} +void dm_hdr_luminance(int extended, uint16_t low, uint16_t high) { + if (high < low || !high) { + return; + } + atomic_store(extended ? &extended_range : &standard_range, low | (uint32_t)high << 16); +} +void dm_hdr_luminance_range(int extended, uint16_t *low, uint16_t *high) { + uint32_t range = atomic_load(extended ? &extended_range : &standard_range); + *low = range & 0xffff; + *high = range >> 16; +} diff --git a/dm/gfx/nvn/nvn_hdr.h b/dm/gfx/nvn/nvn_hdr.h new file mode 100644 index 0000000000..16b1b9bb48 --- /dev/null +++ b/dm/gfx/nvn/nvn_hdr.h @@ -0,0 +1,18 @@ +// Switch 2 scanout HDR state. Front ends publish real display capability; vi answers it. +#pragma once +#include +#define DM_HDR_PAPER_WHITE 203 +#define DM_HDR_MASTERING_PEAK 1000 +#define DM_HDR_LINEAR_UNIT 80 +int dm_hdr_requested(void); +void dm_hdr_display(int available); +int dm_hdr_query(void); +int dm_hdr_advertised(void); +void dm_hdr_gamut(int gamut); +int dm_hdr_content_gamut(void); // -1 = unspecified; retains the guest metadata enum +void dm_hdr_luminance(int extended, uint16_t low, uint16_t high); +void dm_hdr_luminance_range(int extended, uint16_t *low, uint16_t *high); +// Called on the UI thread before the guest starts, and when its screen changes. +void dm_hdr_configure_layer(void *layer, double potential_headroom); +// Called by the present owner; applies content metadata for following drawables. +void dm_hdr_layer_metadata(void *layer, int hdr_content); diff --git a/dm/gfx/nvn/nvn_hdr_layer.m b/dm/gfx/nvn/nvn_hdr_layer.m new file mode 100644 index 0000000000..42905aa20a --- /dev/null +++ b/dm/gfx/nvn/nvn_hdr_layer.m @@ -0,0 +1,47 @@ +#import +#import +#import +#include "nvn_hdr.h" +#include +void dm_hdr_configure_layer(void *layer_ptr, double potential_headroom) { + CAMetalLayer *layer = (__bridge CAMetalLayer *)layer_ptr; + int requested = dm_hdr_requested(); + dm_hdr_display(requested && layer && isfinite(potential_headroom) && potential_headroom > 1.0 && + CAEDRMetadata.available); + if (!requested || !layer) { + return; + } + // Keep a single float drawable format across SDR/HDR transitions and display-link drawables. + // HDR10 metadata maps this virtual 1000-nit mastering display to the physical display. + layer.pixelFormat = MTLPixelFormatRGBA16Float; + CGColorSpaceRef color_space = CGColorSpaceCreateWithName(kCGColorSpaceExtendedLinearITUR_2020); + layer.colorspace = color_space; + CGColorSpaceRelease(color_space); + layer.wantsExtendedDynamicRangeContent = YES; + if (!layer.EDRMetadata) { + layer.EDRMetadata = [CAEDRMetadata HDR10MetadataWithMinLuminance:0 + maxLuminance:DM_HDR_MASTERING_PEAK + opticalOutputScale:DM_HDR_LINEAR_UNIT]; + } +} +void dm_hdr_layer_metadata(void *layer_ptr, int hdr_content) { + CAMetalLayer *layer = (__bridge CAMetalLayer *)layer_ptr; + if (!layer || layer.pixelFormat != MTLPixelFormatRGBA16Float || !dm_hdr_requested()) { + return; + } + uint16_t min_luminance = 0, max_luminance = 100; + if (hdr_content) { + dm_hdr_luminance_range(1, &min_luminance, &max_luminance); + } + static __weak CAMetalLayer *last_layer; + static uint32_t last_range; + uint32_t range = min_luminance | (uint32_t)max_luminance << 16; + if (last_layer == layer && last_range == range) { + return; + } + layer.EDRMetadata = [CAEDRMetadata HDR10MetadataWithMinLuminance:min_luminance + maxLuminance:max_luminance + opticalOutputScale:DM_HDR_LINEAR_UNIT]; + last_layer = layer; + last_range = range; +} diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index ac60cc6f71..959050fcb5 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -18,6 +18,7 @@ #include // os_proc_available_memory: for the memory readout line #endif #include "nvn_mtl.h" +#include "nvn_hdr.h" #include "nvn_obj.h" #include "../tex/nvn_fmt_mtl.h" #include "../tex/nvn_deswizzle.h" @@ -490,7 +491,7 @@ void mtl_init(void) { g_present_args = [g_dev newArgumentTableWithDescriptor:pad error:&ate]; if (!g_present_args) MDIE("failed to create the present argument table: %s", ate.localizedDescription.UTF8String); for (int i = 0; i < 3; i++) { - g_present_uv[i] = [g_dev newBufferWithLength:sizeof(float) * 8 options:MTLResourceStorageModeShared]; + g_present_uv[i] = [g_dev newBufferWithLength:sizeof(float) * 12 options:MTLResourceStorageModeShared]; if (!g_present_uv[i]) MDIE("failed to create present UV buffer[%d]", i); res_add(g_present_uv[i]); } @@ -12686,6 +12687,28 @@ static int present_crop_rect(id src, const int crop[4], NSUInteger r } r[0] = crop[0]; r[1] = crop[1]; r[2] = crop[2]; r[3] = crop[3]; return 1; } +// The Switch 2 NVN display plugin maps RGB10A2 to PQ and RGBA16F to linear 80-nit scanout. +// A negotiated HDR display plus a compatible swapchain is required: an HDR-capable monitor alone must not reinterpret SDR frames. +static int present_hdr_encoding(id src) { + if (!dm_hdr_requested() || !dm_hdr_advertised()) { + return 0; + } + if (src.pixelFormat == MTLPixelFormatRGB10A2Unorm || src.pixelFormat == MTLPixelFormatBGR10A2Unorm) { + return 1; + } + if (src.pixelFormat == MTLPixelFormatRGBA16Float) { + return 2; + } + return 0; +} +static void present_color_params(float *color, id src, id dst) { + uint16_t min_luminance, max_luminance; + dm_hdr_luminance_range(1, &min_luminance, &max_luminance); + color[0] = present_hdr_encoding(src); + color[1] = dm_hdr_requested() && dst.pixelFormat == MTLPixelFormatRGBA16Float; + color[2] = 0; // Reserved; matches Crop.color in present.metal. + color[3] = max_luminance; +} static int present_encode(id cb, id src, id dst, const int crop[4]) { NSUInteger r[4]; if (!present_crop_rect(src, crop, r)) return 0; NSUInteger cx = r[0], cy = r[1], cw = r[2], ch = r[3]; @@ -12707,9 +12730,10 @@ static int present_encode(id cb, id src, id en = [cb renderCommandEncoderWithDescriptor:rp]; [en setViewport:vp]; [en setRenderPipelineState:g_blit_pso]; [en setFragmentTexture:present_raw_src(src) atIndex:0]; [en setFragmentSamplerState:g_blit_samp atIndex:0]; - float uv[8] = {(float)cx / src.width, (float)cy / src.height, (float)cw / src.width, (float)ch / src.height, + float uv[12] = {(float)cx / src.width, (float)cy / src.height, (float)cw / src.width, (float)ch / src.height, (cx + 0.5f) / src.width, (cy + 0.5f) / src.height, (cx + cw - 0.5f) / src.width, (cy + ch - 0.5f) / src.height}; if (atomic_load_explicit(&g_origin_ll, memory_order_relaxed)) { uv[1] += uv[3]; uv[3] = -uv[3]; } // lower-left origin: the window image is stored bottom-up + present_color_params(uv + 8, src, dst); [en setFragmentBytes:uv length:sizeof uv atIndex:0]; [en drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3]; [en endEncoding]; @@ -12942,7 +12966,11 @@ static int present_encode_m4(id cb, id src, id tex = present_raw_src(src), up = fx ? fx_encode(fx, cb, tex, r, vp, dst.pixelFormat, fx == &g_fx_present) : nil; + id tex = present_raw_src(src); + id up = nil; + if (fx && !present_hdr_encoding(src)) { + up = fx_encode(fx, cb, tex, r, vp, dst.pixelFormat, fx == &g_fx_present); + } if (up) { tex = up; r[0] = r[1] = 0; r[2] = up.width; r[3] = up.height; } // the scaled picture, the viewport's size: drawn 1:1 MTL4RenderPassDescriptor *rp = [MTL4RenderPassDescriptor new]; rp.colorAttachments[0].texture = dst; @@ -12959,6 +12987,7 @@ static int present_encode_m4(id cb, id src, id cb, id src, id src, const int crop[4], id out, void (^done)(int)) { id al = [g_dev newCommandAllocator]; id cb = [g_dev newCommandBuffer]; - id uvb = [g_dev newBufferWithLength:sizeof(float) * 8 options:MTLResourceStorageModeShared]; + id uvb = [g_dev newBufferWithLength:sizeof(float) * 12 options:MTLResourceStorageModeShared]; if (!al || !cb || !uvb) { MLOG("[RED] offscreen present: no command allocator or buffer"); return 0; } id raw = present_raw_src(src); fx_state fx = {0}; @@ -13066,6 +13095,20 @@ static int present_now(const tex_t *t, double period, uint64_t frame, uint64_t t CAMetalLayer *layer = (__bridge CAMetalLayer *)front_metal_layer(); if (!layer) return 0; + int hdr_mode = present_hdr_encoding(src); + dm_hdr_layer_metadata((__bridge void *)layer, hdr_mode != 0); + static int last_hdr_mode = -1; + if (last_hdr_mode != hdr_mode) { + const char *encoding = "SDR"; + if (hdr_mode == 1) { + encoding = "Rec.2020 PQ"; + } else if (hdr_mode == 2) { + encoding = "linear scRGB"; + } + MLOG("present HDR: %s, drawable format %lu, optical unit %d nits", encoding, + (unsigned long)layer.pixelFormat, DM_HDR_LINEAR_UNIT); + last_hdr_mode = hdr_mode; + } if (g_present_layer_resident != layer) { [g_q4 addResidencySet:layer.residencySet]; g_present_layer_resident = layer; } #if TARGET_OS_OSX if (!layer.displaySyncEnabled) layer.displaySyncEnabled = YES; // Windowed mode follows the display's vsync to prevent tearing diff --git a/dm/gfx/tests/Makefile b/dm/gfx/tests/Makefile index 02aa8ad4d4..4a00b5e2d1 100644 --- a/dm/gfx/tests/Makefile +++ b/dm/gfx/tests/Makefile @@ -457,3 +457,6 @@ t_logic: t_logic.m $(MTL_T_DEP) clang $(CFLAGS) -fobjc-arc -o $@ t_logic.m $(MTL_T_SRC) $(FW) check_t_logic: t_logic @set -e; work=$$(mktemp -d); trap 'rm -rf "$$work"' EXIT; python3 test_dmsh_logic.py --fixture "$$work"; MTL_DEBUG_LAYER=1 ./t_logic "$$work" + +t_present_hdr: t_present_hdr.m ../nvn/host_msl.m ../nvn/nvn_hdr.c ../nvn/nvn_hdr_layer.m ../nvn/nvn_hdr.h $(MSL_H) + clang -O1 -g -Wall -I$(dir $(MSL_H)) -fobjc-arc -o $@ t_present_hdr.m ../nvn/host_msl.m ../nvn/nvn_hdr.c ../nvn/nvn_hdr_layer.m -framework Metal -framework Foundation -framework QuartzCore -framework CoreGraphics diff --git a/dm/gfx/tests/t_present_crop.m b/dm/gfx/tests/t_present_crop.m index d1e2d379fe..42ddb4a468 100644 --- a/dm/gfx/tests/t_present_crop.m +++ b/dm/gfx/tests/t_present_crop.m @@ -37,6 +37,59 @@ static int check_present_resize(id src, int w, int h) { } #endif +#ifndef CROP_LEGACY_HEADER +// Exercise the production encoding selection and both constant-buffer layouts with real scanout formats. +static void check_hdr_scanout(void) { + setenv("DM_HDR", "1", 1); + dm_hdr_display(1); + EXPECT("HDR display negotiation", 1, dm_hdr_query()); + g_fx_mode = 1; // HDR must bypass the perceptual SDR scaler even when upscaling is requested. + for (int linear = 0; linear < 2; linear++) { + MTLPixelFormat format = linear ? MTLPixelFormatRGBA16Float : MTLPixelFormatRGB10A2Unorm; + MTLTextureDescriptor *desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:format + width:1 height:1 mipmapped:NO]; + desc.storageMode = MTLStorageModeShared; + desc.usage = MTLTextureUsageShaderRead; + id source = [g_dev newTextureWithDescriptor:desc]; + uint32_t pq_white = UINT32_MAX; // PQ 1.0 is 10,000 nits. + __fp16 linear_white[4] = {12.5, 12.5, 12.5, 1}; // scRGB 12.5 is 1,000 nits. + const void *pixels = linear ? (const void *)linear_white : (const void *)&pq_white; + [source replaceRegion:MTLRegionMake2D(0, 0, 1, 1) mipmapLevel:0 withBytes:pixels + bytesPerRow:linear ? sizeof linear_white : sizeof pq_white]; + desc.pixelFormat = MTLPixelFormatRGBA16Float; + desc.width = desc.height = 2; + desc.usage = MTLTextureUsageRenderTarget; + id output = [g_dev newTextureWithDescriptor:desc]; + for (int metal4 = 0; metal4 < 2; metal4++) { + if (metal4) { + EXPECT("HDR bypasses perceptual MetalFX scaling", PRESENT_STANDARD, + present_offscreen_wait(source, NULL, output)); + } else { + id command_buffer = [g_q commandBuffer]; + EXPECT("legacy HDR present encodes", 1, present_encode(command_buffer, source, output, NULL)); + [command_buffer commit]; + [command_buffer waitUntilCompleted]; + EXPECT("legacy HDR present completes", MTLCommandBufferStatusCompleted, command_buffer.status); + } + __fp16 result[16]; + [output getBytes:result bytesPerRow:sizeof(__fp16) * 8 + fromRegion:MTLRegionMake2D(0, 0, 2, 2) mipmapLevel:0]; + float expected = linear ? 12.5f : 125.0f; + int bad = 0; + for (int pixel = 0; pixel < 4; pixel++) { + for (int channel = 0; channel < 3; channel++) { + bad += fabsf(result[pixel * 4 + channel] - expected) > .01f; + } + bad += result[pixel * 4 + 3] != 1; + } + EXPECT("production scanout preserves absolute HDR luminance and alpha", 0, bad); + } + } + dm_hdr_display(0); + setenv("DM_HDR", "0", 1); +} +#endif + static void check(id src, const char *name, const int crop[4], int w, int h, int color) { MTLTextureDescriptor *d=[MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA8Unorm width:w height:h mipmapped:NO]; d.storageMode=MTLStorageModeShared;d.usage=MTLTextureUsageRenderTarget; @@ -210,6 +263,9 @@ int main(void) { @autoreleasepool { EXPECT("real QueuePresent: a window without a crop queues the layer crop",1,got); mtl_present_wait(t2);mtl_present_test_stall(0); } +#endif +#ifndef CROP_LEGACY_HEADER + check_hdr_scanout(); #endif printf("== %d checks, %d failed ==\n",cases,failed);return failed!=0; } } diff --git a/dm/gfx/tests/t_present_hdr.m b/dm/gfx/tests/t_present_hdr.m new file mode 100644 index 0000000000..c4796135a8 --- /dev/null +++ b/dm/gfx/tests/t_present_hdr.m @@ -0,0 +1,182 @@ +// Real-GPU transfer tests with known ST 2084 luminances, gamut vectors and an unchanged SDR path. +#import +#import +#include +#include +#include +#include +#include "../nvn/host_msl.h" +#include "../nvn/nvn_hdr.h" +static int fails, checks; +#define CHECK(c, msg) \ + do { \ + checks++; \ + if (!(c)) { \ + fails++; \ + printf("FAIL %s\n", msg); \ + } \ + } while (0) +static float pq(double nits) { + double p = pow(nits / 10000., 2610. / 16384.); + return pow((3424. / 4096. + (2413. / 128.) * p) / (1. + (2392. / 128.) * p), 2523. / 32.); +} +static void render(id device, id queue, id pipeline, + id sampler, const float rgba[4], int input, int edr, float out[4]) { + MTLTextureDescriptor *texture_desc = + [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA32Float + width:1 + height:1 + mipmapped:NO]; + texture_desc.usage = MTLTextureUsageShaderRead; + texture_desc.storageMode = MTLStorageModeShared; + id src = [device newTextureWithDescriptor:texture_desc]; + [src replaceRegion:MTLRegionMake2D(0, 0, 1, 1) mipmapLevel:0 withBytes:rgba bytesPerRow:16]; + texture_desc.usage = MTLTextureUsageRenderTarget; + id dst = [device newTextureWithDescriptor:texture_desc]; + MTLRenderPassDescriptor *pass_desc = [MTLRenderPassDescriptor renderPassDescriptor]; + pass_desc.colorAttachments[0].texture = dst; + pass_desc.colorAttachments[0].loadAction = MTLLoadActionDontCare; + pass_desc.colorAttachments[0].storeAction = MTLStoreActionStore; + id command_buffer = [queue commandBuffer]; + id encoder = [command_buffer renderCommandEncoderWithDescriptor:pass_desc]; + float constants[12] = {0, 0, 1, 1, .5, .5, .5, .5, input, edr, 0, 1000}; + [encoder setRenderPipelineState:pipeline]; + [encoder setFragmentTexture:src atIndex:0]; + [encoder setFragmentSamplerState:sampler atIndex:0]; + [encoder setFragmentBytes:constants length:sizeof constants atIndex:0]; + [encoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3]; + [encoder endEncoding]; + [command_buffer commit]; + [command_buffer waitUntilCompleted]; + CHECK(command_buffer.status == MTLCommandBufferStatusCompleted, "GPU completion"); + [dst getBytes:out bytesPerRow:16 fromRegion:MTLRegionMake2D(0, 0, 1, 1) mipmapLevel:0]; +} +int main(void) { + @autoreleasepool { + unsetenv("DM_HDR"); + dm_hdr_display(1); + CHECK(!dm_hdr_query() && !dm_hdr_advertised(), "HDR requires explicit opt-in"); + setenv("DM_HDR", "0", 1); + CHECK(!dm_hdr_query(), "explicit SDR opt-out"); + setenv("DM_HDR", "1", 1); + dm_hdr_display(0); + CHECK(!dm_hdr_query(), "SDR screen does not advertise HDR"); + dm_hdr_display(1); + CHECK(dm_hdr_query(), "capable screen advertises HDR"); + dm_hdr_display(0); + CHECK(!dm_hdr_query() && dm_hdr_advertised(), "screen loss stops advertising but queued HDR retains decoding"); + dm_hdr_luminance(1, 0, 800); + uint16_t min_luminance, max_luminance; + dm_hdr_luminance_range(1, &min_luminance, &max_luminance); + CHECK(min_luminance == 0 && max_luminance == 800, "content peak metadata retained"); + dm_hdr_luminance(1, 900, 800); + dm_hdr_luminance_range(1, &min_luminance, &max_luminance); + CHECK(max_luminance == 800, "invalid luminance range rejected"); + dm_hdr_luminance(1, 0, 0); + dm_hdr_luminance_range(1, &min_luminance, &max_luminance); + CHECK(max_luminance == 800, "zero peak is rejected"); + dm_hdr_luminance(0, 1, 203); + dm_hdr_luminance_range(0, &min_luminance, &max_luminance); + CHECK(min_luminance == 1 && max_luminance == 203, "standard luminance range is independent"); + dm_hdr_gamut(1); + dm_hdr_gamut(7); + CHECK(dm_hdr_content_gamut() == 1, "invalid gamut does not replace metadata"); + dm_hdr_gamut(-1); + CHECK(dm_hdr_content_gamut() == -1, "gamut metadata can be cleared"); + id device = MTLCreateSystemDefaultDevice(); + CHECK(device != nil, "Metal device"); + if (!device) { + return 1; + } + CAMetalLayer *layer = [CAMetalLayer layer]; + layer.device = device; + dm_hdr_configure_layer((__bridge void *)layer, 2); + CHECK(layer.pixelFormat == MTLPixelFormatRGBA16Float && layer.wantsExtendedDynamicRangeContent && + layer.colorspace && layer.EDRMetadata, + "EDR layer configured"); + dm_hdr_configure_layer((__bridge void *)layer, 1); + CHECK(!dm_hdr_query(), "non-EDR screen remains SDR"); + dm_hdr_configure_layer((__bridge void *)layer, NAN); + CHECK(!dm_hdr_query(), "unknown display headroom does not advertise HDR"); + dm_hdr_configure_layer(NULL, 2); + CHECK(!dm_hdr_query(), "missing layer does not advertise HDR"); + dm_hdr_layer_metadata((__bridge void *)layer, 1); + CAEDRMetadata *metadata = layer.EDRMetadata; + dm_hdr_layer_metadata((__bridge void *)layer, 1); + CHECK(layer.EDRMetadata == metadata, "unchanged luminance reuses layer metadata"); + dm_hdr_luminance(1, 0, 1000); + dm_hdr_layer_metadata((__bridge void *)layer, 1); + CHECK(layer.EDRMetadata != metadata, "changed luminance replaces layer metadata"); + metadata = layer.EDRMetadata; + dm_hdr_layer_metadata((__bridge void *)layer, 0); + CHECK(layer.EDRMetadata != metadata, "SDR frames replace HDR mastering metadata"); + CAMetalLayer *sdr_layer = [CAMetalLayer layer]; + setenv("DM_HDR", "0", 1); + dm_hdr_configure_layer((__bridge void *)sdr_layer, 2); + CHECK(sdr_layer.pixelFormat == MTLPixelFormatBGRA8Unorm && !sdr_layer.wantsExtendedDynamicRangeContent, + "opt-out leaves the SDR layer unchanged"); + setenv("DM_HDR", "1", 1); + id queue = [device newCommandQueue]; + NSError *error = nil; + id library = host_msl_lib(device, nil, "present", &error); + CHECK(library != nil, "present metallib"); + if (!library) { + return 1; + } + MTLRenderPipelineDescriptor *pipeline_desc = [MTLRenderPipelineDescriptor new]; + pipeline_desc.vertexFunction = [library newFunctionWithName:@"vs"]; + pipeline_desc.fragmentFunction = [library newFunctionWithName:@"fs"]; + pipeline_desc.colorAttachments[0].pixelFormat = MTLPixelFormatRGBA32Float; + id pipeline = [device newRenderPipelineStateWithDescriptor:pipeline_desc error:&error]; + CHECK(pipeline != nil, "present pipeline"); + if (!pipeline) { + return 1; + } + id sampler = [device newSamplerStateWithDescriptor:[MTLSamplerDescriptor new]]; + float result[4]; + for (int i = 0; i < 6; i++) { + double nits[] = {0, .1, 80, 203, 1000, 10000}; + float encoded = pq(nits[i]); + float in[] = {encoded, encoded, encoded, 1}; + render(device, queue, pipeline, sampler, in, 1, 1, result); + double actual = result[0] * 80; + printf("PQ %.2f nits -> %.3f nits\n", nits[i], actual); + CHECK(fabs(actual - nits[i]) < fmax(.01, nits[i] * .001), "PQ absolute luminance"); + CHECK(result[3] == 1, "alpha preserved"); + } + float sdr[] = {.1, .5, .9, .75}; + render(device, queue, pipeline, sampler, sdr, 0, 0, result); + uint32_t sdr_bits[4], result_bits[4]; + memcpy(sdr_bits, sdr, sizeof sdr_bits); + memcpy(result_bits, result, sizeof result_bits); + CHECK(!memcmp(sdr_bits, result_bits, sizeof sdr_bits), "legacy SDR byte-for-byte pass-through"); + float white[] = {1, 1, 1, 1}; + render(device, queue, pipeline, sampler, white, 0, 1, result); + CHECK(fabs(result[0] * 80 - 100) < .001, "SDR white maps to 100 nits"); + float red[] = {1, 0, 0, 1}; + render(device, queue, pipeline, sampler, red, 0, 1, result); + CHECK(fabs(result[0] - .784254875) < 1e-5 && fabs(result[1] - .086371625) < 1e-5, + "Rec.709 red transformed into Rec.2020"); + float linear[] = {12.5, 12.5, 12.5, 1}; + render(device, queue, pipeline, sampler, linear, 2, 1, result); + CHECK(fabs(result[0] - 12.5) < .001, "linear 80-nit unit preserves 1000-nit highlights"); + // The SDR capture curve preserves ordering and maps the declared 1000-nit peak to white. + const double capture_nits[] = {0, .1, 80, 203, 500, 1000}; + float previous = -1; + for (unsigned i = 0; i < sizeof capture_nits / sizeof *capture_nits; i++) { + float encoded = pq(capture_nits[i]); + float input[] = {encoded, encoded, encoded, .5}; + render(device, queue, pipeline, sampler, input, 1, 0, result); + CHECK(isfinite(result[0]) && result[0] >= 0 && result[0] <= 1.001, + "SDR capture has finite display-range luminance"); + CHECK(result[0] > previous, "SDR capture retains highlight ordering"); + CHECK(fabs(result[0] - result[1]) < 1e-4 && fabs(result[0] - result[2]) < 1e-4, + "neutral HDR luminances remain neutral in SDR"); + CHECK(result[3] == .5, "SDR capture preserves alpha"); + previous = result[0]; + } + CHECK(fabs(result[0] - 1) < .001, "SDR capture maps the mastering peak to white"); + printf("t_present_hdr: %d checks, %d failures\n", checks, fails); + return fails != 0; + } +} diff --git a/dm/host/front_mac.m b/dm/host/front_mac.m index fdf092e2ab..b1a047e493 100644 --- a/dm/host/front_mac.m +++ b/dm/host/front_mac.m @@ -16,6 +16,7 @@ #include "dm.h" #include "dm_input.h" #include "front.h" +#include "../gfx/nvn/nvn_hdr.h" #include "hle.h" #include "pad_slots.h" #include "haptics_apple.h" @@ -48,7 +49,11 @@ int front_display_pixels(int *w, int *h) { static _Atomic int g_screen_variable_refresh; int front_screen_variable_refresh(void) { return g_screen_variable_refresh; } static void screen_refresh(NSScreen *screen) { // main thread: refresh capabilities of the current screen and mode, including a mode change without moving the window - if (!screen) return; + if (!screen) { + dm_hdr_display(0); + return; + } + dm_hdr_configure_layer((__bridge void *)g_layer, screen.maximumPotentialExtendedDynamicRangeColorComponentValue); g_screen_fps = (int)screen.maximumFramesPerSecond; g_screen_id = [screen.deviceDescription[@"NSScreenNumber"] unsignedIntValue]; g_screen_variable_refresh = screen.minimumRefreshInterval > 0 && screen.maximumRefreshInterval > screen.minimumRefreshInterval; diff --git a/dm/ios/Sources/front_ios.m b/dm/ios/Sources/front_ios.m index cddc2948f5..d1f2119746 100644 --- a/dm/ios/Sources/front_ios.m +++ b/dm/ios/Sources/front_ios.m @@ -9,6 +9,7 @@ #import #include #include "front.h" +#include "../../gfx/nvn/nvn_hdr.h" #include "hle.h" #include "dm.h" #include "haptics_apple.h" @@ -34,6 +35,7 @@ @interface DmGameView : UIView @property (nonatomic) CGFloat maxPixelWidth; // 0: the screen's own pixels; a TV caps the drawable at 1920 across (tv_attach) +- (void)updateHDRDisplay; @end @implementation DmGameView + (Class)layerClass { return CAMetalLayer.class; } @@ -45,15 +47,26 @@ - (instancetype)initWithFrame:(CGRect)f { CAMetalLayer *l = (CAMetalLayer *)self.layer; l.device = MTLCreateSystemDefaultDevice(); l.pixelFormat = MTLPixelFormatBGRA8Unorm; l.contentsScale = MAX(self.traitCollection.displayScale, 1); l.framebufferOnly = NO; l.opaque = YES; + [self updateHDRDisplay]; self.backgroundColor = UIColor.blackColor; } return self; } -- (void)didMoveToWindow { [super didMoveToWindow]; [self setNeedsLayout]; } // the phone's screen or a TV's (DmTVSceneDelegate) +- (void)updateHDRDisplay { + UIScreen *screen = self.window.windowScene.screen; + // Use capability rather than current brightness, which can be 1 before any EDR content is shown. + dm_hdr_configure_layer((__bridge void *)self.layer, screen ? screen.potentialEDRHeadroom : 0); +} +- (void)didMoveToWindow { + [super didMoveToWindow]; + [self updateHDRDisplay]; // The phone's screen or a TV's (DmTVSceneDelegate). + [self setNeedsLayout]; +} - (void)layoutSubviews { // render at the pixels of the screen the view is on (an iPad window can move to another), at most maxPixelWidth across [super layoutSubviews]; CAMetalLayer *l = (CAMetalLayer *)self.layer; UIScreen *screen = self.window.windowScene.screen; + [self updateHDRDisplay]; CGFloat scale = screen ? screen.nativeScale : MAX(self.traitCollection.displayScale, 1); if (self.maxPixelWidth > 0) scale = MIN(scale, self.maxPixelWidth / MAX(self.bounds.size.width, 1)); l.contentsScale = scale; diff --git a/dm/ios/tests/duo/project.yml b/dm/ios/tests/duo/project.yml index de177217c0..f842ef47d4 100644 --- a/dm/ios/tests/duo/project.yml +++ b/dm/ios/tests/duo/project.yml @@ -13,6 +13,8 @@ targets: - path: ../../Sources/LauncherSettings.swift - path: ../../Sources/DmGamepad.m - path: ../../Sources/front_ios.m + - path: ../../../gfx/nvn/nvn_hdr.c + - path: ../../../gfx/nvn/nvn_hdr_layer.m dependencies: - sdk: UIKit.framework - sdk: GameController.framework diff --git a/dm/mac/pack_app.py b/dm/mac/pack_app.py index 530deb1296..cb19ae770c 100755 --- a/dm/mac/pack_app.py +++ b/dm/mac/pack_app.py @@ -118,11 +118,11 @@ def settings_rules(listed): # DMSettings: what the launcher accepts as each se def info_plist(a, minos, icon): - # LSSupportsGameMode off: macOS's Game Mode holds the GPU at its lowest clock while it is busy (docs/reference/frame-rate.md, macOS Game Mode holds the GPU at its lowest clock) + # Opt in to Game Mode; affected Macs can turn it off per game in Game Overlay (docs/reference/frame-rate.md). p = {'CFBundleName': a.name, 'CFBundleDisplayName': a.name, 'CFBundleIdentifier': a.bundle_id or f'org.nxnative.{a.title_id.lower()}', 'CFBundleVersion': a.version or '1.0', 'CFBundleShortVersionString': a.version or '1.0', 'CFBundlePackageType': 'APPL', 'CFBundleExecutable': EXE, 'CFBundleInfoDictionaryVersion': '6.0', 'LSMinimumSystemVersion': minos, 'LSArchitecturePriority': ['arm64'], 'NSHighResolutionCapable': True, - 'LSApplicationCategoryType': a.category or 'public.app-category.games', 'LSSupportsGameMode': False, 'DMTitleID': a.title_id.upper(), 'DMRomfsMarker': a.marker or '', 'DMLanguage': a.lang or 'en-US'} + 'LSApplicationCategoryType': a.category or 'public.app-category.games', 'LSSupportsGameMode': True, 'DMTitleID': a.title_id.upper(), 'DMRomfsMarker': a.marker or '', 'DMLanguage': a.lang or 'en-US'} if icon: p['CFBundleIconFile'] = 'AppIcon' if a.settings: listed = json.loads(Path(a.settings).read_text(encoding='utf-8'))['settings'] diff --git a/dm/tools/docs_patches.py b/dm/tools/docs_patches.py index 5dfb018390..862e63f7e5 100644 --- a/dm/tools/docs_patches.py +++ b/dm/tools/docs_patches.py @@ -19,7 +19,7 @@ # What a build-keyed host table does (the table's name in the source; a row of another table is listed under its own name) TABLES = {'k_locks': 'engine frame limit set to fps_target', 'k_ue': "Unreal's allocator on a host malloc zone", 'k_nier_dr': 'frame interval and dynamic-resolution floor'} # Profile keys that are not behaviour switches: per-game data or controls, not something the host does differently -NOT_SWITCHES = {'pad_layout', 'depth_mode', 'mem_used', 'sys_resource'} +NOT_SWITCHES = {'pad_layout', 'depth_mode', 'mem_used', 'sys_resource', 'hdr'} # ---- Binding: what a patch's correctness is anchored to, from the loosest (GitHub issue #635). A patch is at its goal when its binding is the loosest it could have; the gap between the two # is the work list. A patch file's BINDING is a list of {'what', 'now', 'goal', 'purpose', 'why'} with an optional 'blocked' (why the gap cannot close yet) or 'kept' (why it stays diff --git a/dm/tools/tests/test_nsp_mac_app.py b/dm/tools/tests/test_nsp_mac_app.py index 1b50fff4a0..64eb39f7ec 100644 --- a/dm/tools/tests/test_nsp_mac_app.py +++ b/dm/tools/tests/test_nsp_mac_app.py @@ -497,7 +497,7 @@ def test_info_plist(self): p = plistlib.loads((self.app / 'Contents/Info.plist').read_bytes()) self.assertEqual((p['CFBundleExecutable'], p['CFBundleName'], p['CFBundleIdentifier'], p['CFBundleShortVersionString']), ('nxnative', 'Game: Test', 'org.nxnative.01008cf01baac000', '1.0.1')) self.assertEqual((p['DMTitleID'], p['DMLanguage'], p['DMRomfsMarker'], p['CFBundleIconFile']), (TID, 'fr', 'region_common/audio/edward.bfsar', 'AppIcon')) - self.assertIs(p['LSSupportsGameMode'], False) # macOS's Game Mode holds the GPU at its lowest clock (frame-rate.md) + self.assertIs(p['LSSupportsGameMode'], True) # Game Mode remains available; affected Macs can opt out per game self.assertEqual(p['DMSettings'], []) # launcher.json's settings, as the saved choices they accept: none here self.assertRegex(p['DMCommit'], r'^[0-9a-f]{12}(\+dirty)?$') # the packing checkout (scripts/bug_report.py, the host's start line) self.assertEqual(p['DMChain'], '0123456789ab') # the chain of the libraries packed (msl/b-) diff --git a/dm/tools/tests/test_settings.py b/dm/tools/tests/test_settings.py index 62fd72dfb7..0fb30d30e4 100644 --- a/dm/tools/tests/test_settings.py +++ b/dm/tools/tests/test_settings.py @@ -39,4 +39,27 @@ def test_only_for_games_naming_their_upscaler(self): self.assertEqual(bool(up),shown,gid) if up:self.assertEqual((up[0]['default'],up[0]['options']),('game',[['game',"Game's own"],['metalfx','MetalFX']])) +class HdrSetting(unittest.TestCase): + def test_offered_for_hdr_games_on_both_platforms(self): + with tempfile.TemporaryDirectory() as tmp, patch.object(settings.nacp, 'languages', return_value=['en-US']): + (Path(tmp) / 'main.fixups.tsv').write_text('') + cases = [ + ('0400C3F00006E000', 'mac', True), + ('0400C3F00006E000', 'ios', True), + ('040074A01BF12000', 'mac', True), + ('040074A01BF12000', 'ios', True), + ('010056B015FE8000', 'mac', False), + ('010056B015FE8000', 'ios', False), + ] + for game_id, platform, expected in cases: + with self.subTest(game=game_id, platform=platform): + with patch.dict(settings.os.environ, {'SETTINGS_PLATFORM': platform}): + rows = settings.build(game_id, tmp, 'unused')['settings'] + hdr = [row for row in rows if row['env'] == 'DM_HDR'] + self.assertEqual(bool(hdr), expected) + if hdr: + self.assertEqual(hdr[0]['kind'], 'toggle') + self.assertEqual(hdr[0]['default'], '1') + + if __name__=='__main__':unittest.main() diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index e7939e06af..921cce2b31 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -299,6 +299,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - a Switch 2 game presents at 720p, with its upscaler at the matching low size. → [The display's largest buffer](../reference/services.md#the-displays-largest-buffer) - `registered NROs not found` from `nro_collect.py` in a game whose RomFS has an NRR, or `[RED] ro::LoadModule: no rewritten NRO with SHA-256 …`. `main` is small (about 600 KB of code) beside a data segment of random-looking bytes. → [A game that decodes its code at run time](../reference/services.md#a-game-that-decodes-its-code-at-run-time) - a crash in `ro::RegisterModuleInfo`, or an NRR that seems to register millions of hashes: the words at +0x340 and +0x344 are random. → [NRR version 1](../reference/services.md#nrr-version-1) +- a game with HDR support stays in SDR or presents its PQ signal as ordinary SDR RGB. → [HDR display negotiation](../reference/services.md#hdr-display-negotiation) ## Movies @@ -678,6 +679,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - read-only depth/stencil attachments are written back after every pass, and the periodic unwritten-store savings counter remains zero with the default configuration. → [Unwritten attachment stores without lookahead](../reference/rendering.md#unwritten-attachment-stores-without-lookahead) - portrait gameplay overlaps the touch controls, touch coordinates land outside the game picture, or a TV's picture renders at 4K again after a layout pass. → [iOS layouts: portrait, a half-folded Duo, iPad windows](../reference/rendering.md#ios-layouts-portrait-a-half-folded-duo-ipad-windows) - with *Picture shape* at 1.43:1 (`DM_SCREEN_ASPECT`) the game renders 1920x1344, but on an iPhone Duo in Fit (the default) the picture comes out smaller than the 16:9 one, with black bars on all four sides; it fills the screen only in Fill. On the Mac a click lands off the point under the pointer (it was mapped on a 16:9 rectangle of the window). → [The picture laid out in the shape the game presents](../reference/rendering.md#the-picture-laid-out-in-the-shape-the-game-presents) +- an HDR-capable display still receives SDR output, or simply interpreting PQ scanout as an SDR texture gives incorrect luminance and colour. → [Negotiated HDR scanout to Apple EDR](../reference/rendering.md#negotiated-hdr-scanout-to-apple-edr) ## Shaders diff --git a/docs/development.md b/docs/development.md index eb586ee4fb..e60d512807 100644 --- a/docs/development.md +++ b/docs/development.md @@ -566,7 +566,7 @@ The bundle holds what the IPA holds, with the Mac's shader libraries: ``` .app/Contents/ - Info.plist name, version, bundle ID, the profile's app_category, LSSupportsGameMode false (reference/frame-rate.md); DMTitleID, DMRomfsMarker, DMLanguage, DMModuleFiles and DMSettings for the launcher + Info.plist name, version, bundle ID, the profile's app_category, LSSupportsGameMode true (per-game opt-out: reference/frame-rate.md); DMTitleID, DMRomfsMarker, DMLanguage, DMModuleFiles and DMSettings for the launcher MacOS/nxnative the launcher (dm/mac/launcher.c, linked by pack_app.py with swiftc and its settings window: dm/mac/Launcher.swift, LauncherSettings.swift and LauncherRow.swift) MacOS/dmhost the Mac host built for the profile Resources/romfs.img the packed version's RomFS image diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index f02bd21dfb..7a2dde09ce 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -63,6 +63,7 @@ DM_GPU_CAPTURE diagnostic `=[:]` or `gpu:[:]` wr DM_GPU_CAPTURE_DIR path Folder for `DM_GPU_CAPTURE`'s traces (default `gpu_captures/` in the working directory) DM_GPU_CAPTURE_KEEP diagnostic Before each GPU capture, prunes the capture folder's `dm_*.gputrace` entries so that the newest `KEEP - 1` stay (default 2) DM_GUEST_SPAWN_TEST diagnostic `=1` runs a self-test before `nnMain`: a host-started guest thread does SDK malloc / free and locks an `nn::os` mutex, and the result is logged; `=2` also keeps a resident thread +DM_HDR player `=1` enables HDR negotiation and presentation on capable displays; enabled by the MKW and Donkey Kong Bananza launchers, otherwise off DM_HID_LOG diagnostic `1` logs the HID button usage number of each button pressed on a raw HID gamepad (`pad_hid_mac.h`), to write a `DM_HID_MAP` DM_HID_MAP switch `=` such as `A=1,B=2,X=4,Y=5`: which HID button usage presses each Switch button on a raw HID gamepad (names A B X Y LS RS L R ZL ZR PLUS MINUS LEFT UP RIGHT DOWN; default 8BitDo's D-input numbering by position, `pad_hid.h`); Mac only DM_HID_PADS switch `0` stops reading raw HID gamepads that GameController does not list, such as an 8BitDo in its own mode (`pad_hid_mac.h`); Mac only diff --git a/docs/reference/frame-rate.md b/docs/reference/frame-rate.md index 9a33e24e93..10a4ec94e8 100644 --- a/docs/reference/frame-rate.md +++ b/docs/reference/frame-rate.md @@ -293,13 +293,13 @@ Frame pacing: the host's frame step, engine frame limiters, present intervals an ### macOS Game Mode holds the GPU at its lowest clock - **Symptom:** a Mac game in fullscreen holds 60 fps for 45 s to 3 minutes, then the same work takes about twice the GPU time (Tears of the Kingdom 10.5 → 19.7 ms) and the frame step falls to 30 fps. `powermetrics` shows the GPU at its lowest clock (338 MHz on an M4 Pro) while it is 72–98% busy, with thermal pressure nominal. Probes back to 60 fail, because the clock does not rise within a probe. `gamepolicyd` logs `Game mode enabled` and `Set DPS to 2`. -- **Cause:** macOS's Game Mode, not the host. A full-screen Metal app of about 100 lines, with no host code and about 6.5 M GPU cycles a frame, ran at 618 MHz and 9.5 ms for about 160 s in Game Mode, then at 338 MHz, 18 ms and 94% busy to the end of its 5 minutes. With Game Mode off it ran at 924 MHz and about 7 ms and spent no time at 338 MHz (one run each, M4 Pro on AC power; the run with Game Mode off also sat at a flat 56.3 fps from 120 s, unexplained). Why Game Mode does this is open, and no public API is known that keeps the clock up while Game Mode is on. -- **Fix:** a workaround for a platform limit: `dm/mac/pack_app.py` writes `LSSupportsGameMode` false, Apple's documented off switch, and keeps the games category. In the `gamepolicyd` log a new bundle with the key false gets no gaming session. The cost, not measured, is what else Game Mode does: doubled Bluetooth sampling for controllers and AirPods, and lower priority for background tasks. +- **Cause in the reported M4 Pro experiment:** the slowdown also reproduced outside the host. A full-screen Metal app of about 100 lines, with no host code and about 6.5 M GPU cycles a frame, ran at 618 MHz and 9.5 ms for about 160 s in Game Mode, then at 338 MHz, 18 ms and 94% busy to the end of its 5 minutes. With Game Mode off it ran at 924 MHz and about 7 ms and spent no time at 338 MHz (one run each, M4 Pro on AC power; the run with Game Mode off also sat at a flat 56.3 fps from 120 s, unexplained). Why Game Mode does this is open, and no public API is known that keeps the clock up while Game Mode is on. +- **Workaround on affected Macs:** turn Game Mode off for the game in Game Overlay. Commit `cb59a672` also made `dm/mac/pack_app.py` write `LSSupportsGameMode` false for every Mac app. That global opt-out is reverted: the packer writes true and retains the games category, leaving the per-game switch available. The iOS project already declares `LSSupportsGameMode` true and is unchanged. The M4 Pro measurements remain valid evidence; the revert does not establish that their platform behavior is fixed. Disabling Game Mode also gives up its background-task prioritization and increased Bluetooth sampling; the cost was not measured here. - **Limit:** macOS remembers an app it has labelled a game. On a bundle ID already launched in Game Mode, with the key true or omitted, re-packing with the key false kept Game Mode on, also with `GCSupportsGameMode` false and after `lsregister -u` (5 launches with the key off, same version number; a version change is not checked). So a re-packed app needs the player's switch once: in full screen, Command-Esc, Game Overlay, Settings, Game Mode; macOS keeps that per game. A reporter rebuilt a known bundle with an `entertainment` category and got no Game Mode; not checked here. - **Caution:** do not script full-screen launches in a row. After eight in about three minutes on macOS 27.2, `WindowServer` logged `timed out fence` from 12 s after the last app quit, with none in the 85 minutes before, and the Mac panicked (`userspace watchdog timeout: no successful checkins from WindowServer`). Another session's game had exited 18 s before the last app quit, so the launches are the likely trigger, not a proven one. - **Found on:** [The Legend of Zelda: Tears of the Kingdom](../../dm/games/0100F2C0115B6000/README.md), by two reporters on M4 Pro Macs ([GitHub issue #349](https://github.com/seedds/nxnative/issues/349)). - **Check:** `test_nsp_mac_app.py` checks the key. To see a game's state: `/usr/bin/log show --last 10m --predicate 'process == "gamepolicyd"' --style compact | grep -E "Found game|Game mode|DPS"`. -- **Not checked:** a game packed with the key false (the reporter's runs turned Game Mode off with the Game Overlay switch and with the category); other Apple chips; iOS, whose project still declares the key true (the same collapse is only suspected there, PR #314). +- **Other devices and limits:** MKW 1.8.0 testing on M5 Max recovered sustained 60 fps with Game Mode on after the [queue lifetime fixes](../../dm/games/0400C3F00006E000/README.md#game-mode-and-queue-lifetimes). This does not establish a fix for the independent M4 Pro reproducer. iOS remains untested here. ### A game opened from a fullscreen app stays a window diff --git a/docs/reference/rendering.md b/docs/reference/rendering.md index b1050963e9..4e3cdfdda4 100644 --- a/docs/reference/rendering.md +++ b/docs/reference/rendering.md @@ -1413,3 +1413,11 @@ Digimon Story Cyber Sleuth: Complete Edition (Media.Vision's MVGL engine, SDK 6. - **Fix:** the present reports the shape of the frame it draws, the valid rectangle of the presented texture, whenever its width / height changes (`nvn_mtl.m` `present_now` → `front.h` `front_picture_shape`; every graphics API presents there). The iOS front end lays the game's view out in that shape (`DmLayout.h` takes it as a parameter: the overlay, the split, Fill and the TV alike), so a 1.43:1 picture fills a 1.43:1 screen in Fit and touches are normalized on the picture as drawn; the Mac maps the mouse on the picture's own rectangle. A 16:9 game reports 16:9 and keeps its layout, and a size change of the same shape (1280x720 handheld, 1920x1080 docked) is not reported. - **Check:** the [Duo harness](../../dm/ios/tests/duo/README.md) tests the taller picture in Fit, in Fill and on a wider screen, and the production root view following a reported shape and returning to 16:9. Not yet run on a device. - **Found on:** [Breath of the Wild](../../dm/games/01007EF00011E000/README.md#screen-shape-dm_screen_aspect-current) and [Animal Crossing: New Horizons](../../dm/games/01006F8002326000/README.md#screen-shape-dm_screen_aspect-current) with `DM_SCREEN_ASPECT=1.43`. + +### Negotiated HDR scanout to Apple EDR + +- **Symptom:** an HDR-capable display still receives SDR output, or simply interpreting PQ scanout as an SDR texture gives incorrect luminance and colour. +- **Cause:** display negotiation and the final transfer conversion must agree. An EDR-capable screen alone does not make the game's existing SDR swapchain HDR. +- **Fix:** `DM_HDR=1` enables capability reporting through VI and configures a float EDR layer on Mac and iOS. Each front end reports the current screen's potential EDR headroom, including when the game view moves to another display. Once HDR is negotiated, the present pass decodes RGB10A2 PQ into absolute luminance, or converts linear scRGB into Rec.2020, and writes an RGBA16F extended-linear Rec.2020 drawable with an 80-nit optical unit. Core Animation HDR metadata carries the content luminance range. The original SDR path remains unchanged when HDR is off; SDR screenshots of HDR frames use highlight compression. The perceptual SDR MetalFX present scaler is bypassed for negotiated HDR scanout. The game's optional Bezier tone-curve policy is not reproduced; physical-display mapping is delegated to Core Animation. +- **Check:** `t_present_hdr` verifies known PQ luminances from 0 through 10,000 nits, gamut conversion, display capability transitions and bit-exact SDR pass-through on the GPU. A game run must additionally report `vi HDR: enabled` and `present HDR: Rec.2020 PQ` or `linear scRGB` to establish actual HDR negotiation. +- **Found on:** [Mario Kart World](../../dm/games/0400C3F00006E000/README.md). diff --git a/docs/reference/services.md b/docs/reference/services.md index 0df310d921..69e2cfe2ab 100644 --- a/docs/reference/services.md +++ b/docs/reference/services.md @@ -1028,3 +1028,10 @@ Most service calls can be found before they crash, before the first boot or the - **Cause:** an NRR with 1 in the byte at +5 (the Switch 2's) has a header 0x180 bytes longer. The same fields follow it: program ID +0x4b0, size +0x4b8, hash table +0x4c0, its count +0x4c4. - **Fix:** `hle_ro.c` and `nro_collect.py` read the table by that byte. - **Found on:** [Mario Tennis Fever](../../dm/games/0400D4201C6EA000/README.md). + +### HDR display negotiation + +- **Symptom:** a game with HDR support stays in SDR or presents its PQ signal as ordinary SDR RGB. +- **Cause:** the host previously advertised SDR regardless of the attached display and had no HDR scanout conversion. +- **Fix:** the opt-in [HDR presentation path](rendering.md#negotiated-hdr-scanout-to-apple-edr) negotiates HDR support, retains luminance metadata and converts PQ to extended-linear EDR output. +- **Found on:** [Mario Kart World 1.8.0](../../dm/games/0400C3F00006E000/README.md).