From caf2a195c18b1b249ae07e8e7f42fa527e536d61 Mon Sep 17 00:00:00 2001 From: Oliver Ames Date: Sat, 7 Nov 2026 14:24:13 -0500 Subject: [PATCH 1/3] HDR: the game is told the display's own peak by model, and the launcher sets the peak and the paper white Symptom: a phone's highlights were reported as "super bright" and nothing in the launcher could change them; the game was told a fixed 1,000 nits (DM_HDR_PEAK, no launcher setting) on a panel that reaches 1,600. Fix: front_hdr_device_peak() (front_ios.m, by hardware model) supplies the default: 1,600 for the iPhone 12 Pro and later and the M-series iPad XDR panels, 1,200 for the iPhone X to 11, else 1,000. DM_HDR_PEAK overrides and 0 is automatic. The launcher gets HDR peak brightness and HDR paper white rows, shown once HDR output is on; both are untested at other values. Checked: t_present_hdr (31 checks, four new), the settings, launcher, env-switch and docs tests, and front_ios.m against the iPhoneOS SDK; the game told 1,600 nits ran on an iPhone 19,4 with Mario Kart World 1.8.0. Front-ends: interface: front_hdr_device_peak() in front.h, the display's peak in nits by hardware model (0 = unknown, answered 1,000); a front end that makes an HDR layer implements it, and a weak default answers 0 --- dm/games/0400C3F00006E000/README.md | 2 ++ dm/games/settings.tsv | 2 ++ dm/gfx/hle_vi.c | 4 +++- dm/gfx/tests/t_present_hdr.m | 10 +++++++++- dm/gfx/tests/t_present_hdr_front.c | 2 ++ dm/host/front.h | 1 + dm/host/front_mac.m | 1 + dm/ios/Sources/front_ios.m | 14 ++++++++++++++ docs/env_switches.tsv | 4 ++-- docs/settings.md | 10 +++++++++- 10 files changed, 45 insertions(+), 5 deletions(-) diff --git a/dm/games/0400C3F00006E000/README.md b/dm/games/0400C3F00006E000/README.md index 433370a974..677c98b494 100644 --- a/dm/games/0400C3F00006E000/README.md +++ b/dm/games/0400C3F00006E000/README.md @@ -89,6 +89,8 @@ With the HDR output setting (`DM_HDR=1`), on the base game to the title screen o - The host's own highlight roll-off against Core Animation's mapping by the layer's HDR10 metadata, which replaced it ([the reference entry](../../../docs/reference/rendering.md#the-status-query-and-the-present-for-an-hdr-window)): side by side on the built-in screen they looked alike. In a window on the built-in screen of a MacBook Pro (M4 Pro, macOS 26.7.1), 80 s to the title each, `DM_PRESENT_SHOWN=1`. From the present call to on screen, p50 per 300 frames: SDR 49.58 to 49.68 ms, the host's roll-off 49.53 to 49.61, Core Animation 49.52 to 49.59. Every frame reached the display-link tick it was meant for (p50 within 0.03 ms), and each dropped 0 to 3 of 300 frames. The metadata adds no frame here. - On a MacBook Air M5 (macOS 27.0.1, built-in screen), in play: with the RGBA16Float extended-range layer, black blocks with coloured squares in them flashed over the course map's water, now and then and after ⌘-Tab back to the game; the game's frames were clean. The RGB10A2 PQ layer has none, colours the same by eye ([the reference entry](../../../docs/reference/rendering.md#the-status-query-and-the-present-for-an-hdr-window)). +- On a physical iPhone 19,4 (1.8.0, 2026-11-07), the status query answers the screen's own peak by model, 1,600 nits (`front_hdr_device_peak`), instead of 1,000: the log reads `[nvn2-hdr] vi::QueryDisplayHdrStatus ...` with a peak of 1600, and the owner reports the picture looks good in a race. Not compared against 1,000 nits, no nits measured, and whether the higher peak costs brightness or heat is unmeasured (the slow GPU state seen on the same runs is unexplained; a `DM_HDR=0` run is planned). The HDR peak brightness and paper white rows of the launcher set `DM_HDR_PEAK` and `DM_HDR_PAPER_WHITE`. + ## Driving it on the Mac The work folder `0400C3F00006E000-work` beside the dump has `rewrite.sh`, `rebuild.sh` (host copy `build/mkwhost`), `split.sh`, `shaders.sh`, `play.sh` (boots headless with `DM_CTL=run/ctl.txt`), `ctl.sh` (control lines; `wait S`, `snap NAME`), `to_race.sh` and `measure.sh `. From a boot at the title (about 80 s): `to_race.sh` presses L+R, then A nine times with waits (Single Player, Grand Prix, Mario, the kart, the Mushroom Cup, 50cc, "Start Grand Prix?"). The opening and the race's start then take about a minute; `A~40` drives. `measure.sh` does all of this, waits for the race (over 1,500 draws a frame, no movie thread), and writes the timing and the `restop` residency lines to `run/.res`. Control presses run in order, so `A~40` holds up later lines. diff --git a/dm/games/settings.tsv b/dm/games/settings.tsv index 4d826907dc..b28a1d97e2 100644 --- a/dm/games/settings.tsv +++ b/dm/games/settings.tsv @@ -24,6 +24,8 @@ DM_LANG choice languages build:DM_LANG languages>1 Game Game language level=bas DM_FULLSCREEN choice 0=Window,1=Fullscreen 0 platform:mac Game Display mode level=basic DM_PICTURE_FIT choice fit=Fit,fill=Fill fit platform:ios Game Picture Fill crops the game's 16:9 picture to cover the screen. Health bars, subtitles or maps near the edges can be cut off. A full-screen picture without cropping needs a patch for each game. DM_HDR toggle - 0 switch2 Game HDR output +DM_HDR_PEAK choice 0=Automatic,600=600 nits,1000=1000 nits,1200=1200 nits,1600=1600 nits,2000=2000 nits 0 switch2 Game HDR peak brightness How bright the game may make its brightest highlights. Automatic uses the screen's own peak: 1600 nits on the iPhone 12 Pro and later, 1000 nits elsewhere. Untested at other values. needs=DM_HDR=1 +DM_HDR_PAPER_WHITE range 100:400:1 203 switch2 Game HDR paper white The brightness of white in nits (203 is the BT.2408 reference); the picture's highlights go above it. Untested at other values. needs=DM_HDR=1 DM_SHOW_FPS toggle - build:DM_SHOW_FPS platform:ios Game Frame-rate counter DM_TOUCH_HAPTICS toggle - 1 platform:ios Controls Touch feedback DM_RUMBLE toggle - 1 always Controls Game rumble level=basic diff --git a/dm/gfx/hle_vi.c b/dm/gfx/hle_vi.c index 1e4cb8fb5d..95c4000b25 100644 --- a/dm/gfx/hle_vi.c +++ b/dm/gfx/hle_vi.c @@ -150,6 +150,7 @@ static int vi_hdr_on(void) { return dm_hdr_mode() && dm_sdk_ounce(); } // Whether the window's screen can show EDR (its potential headroom is above 1): the front end reads it before the guest starts and whenever the window's screen changes (front_mac.m, front_ios.m). // No front end (a windowless run): taken as yes, so DM_HDR alone decides __attribute__((weak)) int front_hdr_screen(void) { return 1; } +__attribute__((weak)) unsigned front_hdr_device_peak(void) { return 0; } // front_ios.m: the display's peak by hardware model; 0 = unknown (the Mac, graphics unit tests) // The game's declared extended luminance range in nits, for the HDR layer's HDR10 metadata (front_mac.m and front_ios.m): Core Animation maps the picture into the screen's headroom by it; none without a front end __attribute__((weak)) void front_hdr_content_range(unsigned lo, unsigned hi) { (void)lo; (void)hi; } // The paper white in nits, read once (the present and the front ends take it from here too; nvn_mtl.m present_paper_white): a number from 1 to 65535, else 203 @@ -170,7 +171,8 @@ HLE_FORCE("_ZN2nn2vi21QueryDisplayHdrStatusEPbPNS0_15HdrOutputFormatEPtPKNS0_7Di if (!mode && !atomic_load(&g_vi_hdr_live)) { if (on) *on = 0; if (fmt) *fmt = 0; if (peak) *peak = 0; return RES_OK; } // off from the start: SDR, not logged // DM_HDR=2 answers HDR whatever the screen: on a screen without headroom Core Animation maps the HDR picture down instead of the game drawing its own SDR one (on Mario Kart World, more saturated than its SDR) int forced = mode == 2, hdr = mode && (forced || front_hdr_screen() != 0); - uint32_t p = vi_env_u32("DM_HDR_PEAK", 1000); + uint32_t p = vi_env_u32("DM_HDR_PEAK", 0); // 0 or unset: the display's own peak by model (front_hdr_device_peak), else 1,000 + if (!p) { unsigned d = front_hdr_device_peak(); p = d ? d : 1000; } // Logged when the answer changes, and at the 1st, 2nd, 10th, 100th and 1000th query: whether the game asks again (and so can follow the window to another screen) static atomic_int queries, last = -1; int n = atomic_fetch_add(&queries, 1) + 1, was = atomic_exchange(&last, hdr); diff --git a/dm/gfx/tests/t_present_hdr.m b/dm/gfx/tests/t_present_hdr.m index 2c41523000..d7e5ac8085 100644 --- a/dm/gfx/tests/t_present_hdr.m +++ b/dm/gfx/tests/t_present_hdr.m @@ -12,7 +12,7 @@ module_t *g_mod; int g_nmod; void svc_thread_dump(void) {} uint64_t now_ns(void) { return bp_now(); } -extern int t_hdr_screen; extern unsigned t_hdr_lo, t_hdr_hi; +extern int t_hdr_screen; extern unsigned t_hdr_lo, t_hdr_hi, t_hdr_device_peak; int vi_hdr_answered(void); int dm_hdr_mode(void); void dm_hdr_set_mode(int m); static int checks, failed; #define CHECK(name, ok) do { int pass_ = !!(ok); checks++; failed += !pass_; printf("%s: %s\n", name, pass_ ? "PASS" : "FAIL"); } while (0) @@ -117,6 +117,14 @@ int main(void) { @autoreleasepool { t_hdr_screen = 1; status(&on, &fmt, &peak, NULL); CHECK("a screen with EDR: HDR on, format 1, peak 1000", on == 1 && fmt == 1 && peak == 1000); CHECK("a screen with EDR: the window is taken as PQ", vi_hdr_answered() == 1); + t_hdr_device_peak = 1600; status(&on, &fmt, &peak, NULL); + CHECK("a model with a known peak: the game is told it (1600), not 1000", on == 1 && peak == 1600); + setenv("DM_HDR_PEAK", "900", 1); status(&on, &fmt, &peak, NULL); + CHECK("DM_HDR_PEAK overrides the model's peak", peak == 900); + setenv("DM_HDR_PEAK", "0", 1); status(&on, &fmt, &peak, NULL); + CHECK("DM_HDR_PEAK=0 is automatic: the model's peak again", peak == 1600); + unsetenv("DM_HDR_PEAK"); t_hdr_device_peak = 0; status(&on, &fmt, &peak, NULL); + CHECK("an unknown model without DM_HDR_PEAK: 1000", peak == 1000); t_hdr_screen = 0; status(&on, &fmt, &peak, NULL); CHECK("the window moves to a screen without EDR: SDR is answered, and the HDR window already made is still decoded", on == 0 && fmt == 0 && vi_hdr_answered() == 1); t_hdr_screen = 1; diff --git a/dm/gfx/tests/t_present_hdr_front.c b/dm/gfx/tests/t_present_hdr_front.c index 8ca7acd4e1..17e7d61189 100644 --- a/dm/gfx/tests/t_present_hdr_front.c +++ b/dm/gfx/tests/t_present_hdr_front.c @@ -3,7 +3,9 @@ #include int t_hdr_screen = 1; // whether the window's screen can show EDR (front_hdr_screen) unsigned t_hdr_lo, t_hdr_hi; // the last range the game declared (front_hdr_content_range) +unsigned t_hdr_device_peak; // the display's own peak by model (front_hdr_device_peak); 0 = unknown int front_hdr_screen(void) { return t_hdr_screen; } +unsigned front_hdr_device_peak(void) { return t_hdr_device_peak; } void front_hdr_content_range(unsigned lo, unsigned hi) { t_hdr_lo = lo; t_hdr_hi = hi; } int dm_sdk_ounce(void) { return 1; } // a Switch 2 game (loader.c reads the sdk module's spec) int dm_opmode(void) { return 0; } // handheld (hle_vi.c's display buffer answer) diff --git a/dm/host/front.h b/dm/host/front.h index b68c932752..c84c0c05d9 100644 --- a/dm/host/front.h +++ b/dm/host/front.h @@ -23,6 +23,7 @@ int front_screen_variable_refresh(void); // HDR output (DM_HDR=1; front_mac.m, front_ios.m): whether the window's screen can show EDR at all (its potential headroom is above 1: hle_vi.c answers HDR only then), and the // game's declared extended luminance range in nits, for the layer's HDR10 metadata. hle_vi.c has weak defaults for runs without a front end int front_hdr_screen(void); +unsigned front_hdr_device_peak(void); // the display's own peak HDR brightness in nits by hardware model (front_ios.m); 0 = unknown, the status query then answers 1,000 void front_hdr_content_range(unsigned lo, unsigned hi); float dm_hdr_paper_white(void); // hle_vi.c: the paper white in nits (DM_HDR_PAPER_WHITE, default 203), the luminance the picture's SDR white has int dm_hdr_mode(void); // hle_vi.c: the HDR mode, 0 off, 1 by the screen, 2 on any screen (DM_HDR at start) diff --git a/dm/host/front_mac.m b/dm/host/front_mac.m index 79086d96a2..e6d6516b71 100644 --- a/dm/host/front_mac.m +++ b/dm/host/front_mac.m @@ -35,6 +35,7 @@ // Read four times a second on the main thread while the HDR layer is on, so it follows the window to another screen (an external SDR monitor beside the built-in XDR screen) static _Atomic int g_hdr_screen = -1; int front_hdr_screen(void) { return atomic_load(&g_hdr_screen) != 0; } +unsigned front_hdr_device_peak(void) { return 0; } // no model table on the Mac: DM_HDR_PEAK or 1,000 static void front_hdr_screen_read(NSScreen *s) { double pot = s.maximumPotentialExtendedDynamicRangeColorComponentValue; int can = pot > 1.0; diff --git a/dm/ios/Sources/front_ios.m b/dm/ios/Sources/front_ios.m index 18eedecdea..5beebeafcb 100644 --- a/dm/ios/Sources/front_ios.m +++ b/dm/ios/Sources/front_ios.m @@ -6,6 +6,7 @@ #import #import #include +#include #import #import #include @@ -70,6 +71,19 @@ - (void)layoutSubviews { // render at the pixels of the screen the view is on // times a second on the main thread, so it follows the picture to a TV (tv_attach) static _Atomic int g_hdr_screen = -1; int front_hdr_screen(void) { return atomic_load(&g_hdr_screen) != 0; } +// The display's own peak HDR brightness (nits) from Apple's published specs by hardware model, so the status query tells the game the screen it has instead of a fixed 1,000 nits: 1,600 for the +// iPhone 12 Pro and later (machine iPhone13,x on) and the M-series iPad XDR panels, 1,200 for the iPhone 10 and 11 (iPhone11,x and iPhone12,x). A model the table does not know answers 0 (unknown: 1,000). +// DM_HDR_PEAK, set in the launcher or Documents/env.txt, overrides it +unsigned front_hdr_device_peak(void) { + static unsigned peak; static dispatch_once_t once; + dispatch_once(&once, ^{ + struct utsname un; uname(&un); NSString *model = un.machine[0] ? [NSString stringWithUTF8String:un.machine] : @""; + if ([model hasPrefix:@"iPhone"] && model.length > 6) { NSInteger gen = [[model substringFromIndex:6] integerValue]; peak = gen >= 13 ? 1600 : gen >= 11 ? 1200 : 0; } + else if ([model hasPrefix:@"iPad14"] || [model hasPrefix:@"iPad16"]) peak = 1600; + if (peak) LOG("front end: device HDR peak %u nits (%s)", peak, un.machine); + }); + return peak; +} static void front_ios_hdr_screen_read(void) { UIScreen *s = ((UIView *)g_edr_layer.delegate).window.windowScene.screen ?: g_edr_screen; // the game view's own screen (a TV after tv_attach) if (!s) return; // no screen yet: unknown diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index f6b92f6736..a6a08bf731 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -69,8 +69,8 @@ DM_GUEST_FILE_MIN_FREE switch `=` (whole MB, K / M / G; default 5G, `0` ne 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` turns on HDR output for Switch 2 games (the Mac and iOS settings row; any non-zero value; `0` or unset is SDR). While the window's screen can show EDR, the status query answers HDR, so the game makes its RGB10A2 PQ window; on a screen without EDR it answers SDR (`2` answers HDR there too: the system maps the HDR picture down instead of the game drawing its own SDR). The layer is RGB10A2 in BT.2100 PQ either way (`DM_HDR_LAYER`). The iOS in-game menu turns it on and off while the game runs (`hle_vi.c` `dm_hdr_set_mode`). Measured on Donkey Kong Bananza, and on Mario Kart World to its title. DM_HDR_LAYER diagnostic `float` (Mac and iOS): the HDR layer is RGBA16Float in extended linear BT.2020, the present decoding PQ to linear light, instead of RGB10A2 in BT.2100 PQ with the game's HDR10 window as it is (the default; the float layer showed black blocks over flat areas on an M5). Kept until that is understood and the PQ layer has run on an iPhone -DM_HDR_PAPER_WHITE diagnostic `=` the paper white: the answer to `vi::QueryDisplayHdrPaperWhite` while HDR is on, the level an SDR source's white is encoded at in the PQ layer, and, with `DM_HDR_LAYER=float`, the level a PQ window's SDR white decodes to and 1.0 of the layer's HDR10 metadata (1 to 65535, read once; default 203, the BT.2408 reference). No launcher setting sets it: it is the SDR white level a console's HDR calibration would set. Untested at other values -DM_HDR_PEAK diagnostic `=` the display's peak that `DM_HDR` reports (decimal, 16-bit; default 1000, the extended luminance range the games declare). No launcher setting sets it: it is the peak a console's HDR calibration would measure. Untested at other values +DM_HDR_PAPER_WHITE player `=` the paper white: the answer to `vi::QueryDisplayHdrPaperWhite` while HDR is on, the level an SDR source's white is encoded at in the PQ layer, and, with `DM_HDR_LAYER=float`, the level a PQ window's SDR white decodes to and 1.0 of the layer's HDR10 metadata (1 to 65535, read once; default 203, the BT.2408 reference). The launcher's HDR paper white row sets it. Untested at other values +DM_HDR_PEAK player `=` the display's peak that `DM_HDR` reports (decimal, 16-bit; default 1000, the extended luminance range the games declare). The launcher's HDR peak brightness row sets it (`0` or unset is automatic: the display's own peak by hardware model on the iPhone and iPad, `front_hdr_device_peak`, else 1000). Untested at other values 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/settings.md b/docs/settings.md index 2d8ffc3a5a..6ccea65068 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -3,7 +3,7 @@ The screen before the game starts shows the settings the game supports. A setting that only matters while another is on appears under it and disappears when that one is turned off. Tap **Play** to start; the app remembers your choices for the next start. All settings are on one scrollable screen, under **Game**, **Controls** and **Performance**. Tap a menu or move a slider there; no settings page needs opening. **Upscaling** is in **Game**, beside **Console mode**. A Mac app shows the same settings in a [window of its own](#the-mac-settings-window). - [Tested defaults](#tested-defaults) -- [Game](#game): [Frame rate](#frame-rate), [Render scale](#render-scale), [Console mode](#console-mode), [Upscaling](#upscaling), [Game's upscaler](#games-upscaler), [Game language](#game-language), [Picture](#picture), [HDR output](#hdr-output), [Frame-rate counter](#frame-rate-counter) +- [Game](#game): [Frame rate](#frame-rate), [Render scale](#render-scale), [Console mode](#console-mode), [Upscaling](#upscaling), [Game's upscaler](#games-upscaler), [Game language](#game-language), [Picture](#picture), [HDR output](#hdr-output), [HDR peak brightness](#hdr-peak-brightness), [HDR paper white](#hdr-paper-white), [Frame-rate counter](#frame-rate-counter) - [Controls](#controls): [Touch feedback](#touch-feedback), [Game rumble](#game-rumble), [Rumble strength](#rumble-strength), [Rumble cutoff](#rumble-cutoff) - [Performance](#performance): [Frame buffering](#frame-buffering), [Metal 4 pipelines](#metal-4-pipelines), [Pipeline prebuild](#pipeline-prebuild), [Encode thread](#encode-thread), [Hold frames when late](#hold-frames-when-late), [Performance HUD](#performance-hud), [Reset to tested defaults](#reset-to-tested-defaults) - [The Mac settings window](#the-mac-settings-window): [Display mode](#display-mode) @@ -83,6 +83,14 @@ iPhone and iPad. **Fit**, the default, shows the game's picture (16:9, or the ta Shown for Switch 2 games, on the Mac and the iPhone (measured on Donkey Kong Bananza and, to its title screen, Mario Kart World; the other Switch 2 titles are untested). **On** starts the game with its own HDR output: on a screen with HDR headroom, whites stay at the screen's normal white and the picture's highlights can go brighter above it; on a screen that cannot show HDR (most external monitors) the game is told so and shows its own SDR picture, and moving the window between screens switches it (Mario Kart World). **Off**, the default, keeps the picture as SDR. On the iPhone and iPad the in-game menu turns it on and off while the game runs (the game changes its picture within a frame or two); on the Mac it applies from the next start. +### HDR peak brightness + +Shown for Switch 2 games once **HDR output** is on. How bright the game is told the screen can get, so it can tone-map its highlights to it. **Automatic**, the default, uses the screen's own peak by hardware model: 1,600 nits on the iPhone 12 Pro and later and the M-series iPad XDR panels, 1,200 on the iPhone X to 11, and 1,000 nits where the model is not known (the Mac). A lower value keeps highlights from getting as bright; a value above the screen's peak is mapped down by the system. Only the status the game reads and the layer's HDR10 metadata change. Measured on a physical iPhone (Mario Kart World, Automatic = 1,600: the owner reports the picture looks good); the other values are untested. + +### HDR paper white + +Shown for Switch 2 games once **HDR output** is on. The brightness of white in nits; the picture's highlights go above it. 203 nits, the BT.2408 reference, is the default. A lower value dims the whole picture's whites and leaves more headroom for highlights, a higher one the opposite. Untested at other values. + ### Picture shape Games whose profile supports it (Breath of the Wild, Animal Crossing: New Horizons). **iPhone Duo screen (1.43:1)** renders a taller picture of the Duo's own shape: the sides of 16:9 with more above and below, and the HUD at the screen's edges. **16:9** is the Switch's picture. The taller picture has about a quarter more pixels, so it runs a little slower and warmer. From d2f0708e546ef4c674f0ead9745a94c75022ec11 Mon Sep 17 00:00:00 2001 From: Oliver Ames Date: Wed, 4 Nov 2026 00:21:46 -0500 Subject: [PATCH 2/3] diagnostics: measure packed HDR attachment conversion --- dm/gfx/README.md | 14 ++ dm/gfx/tests/Makefile | 8 +- dm/gfx/tests/metal_conversion_probe.py | 155 ++++++++++++++++++++ dm/gfx/tests/t_metal_conversion_probe.m | 32 ++++ dm/gfx/tests/test_metal_conversion_probe.py | 50 +++++++ 5 files changed, 257 insertions(+), 2 deletions(-) create mode 100755 dm/gfx/tests/metal_conversion_probe.py create mode 100644 dm/gfx/tests/t_metal_conversion_probe.m create mode 100644 dm/gfx/tests/test_metal_conversion_probe.py diff --git a/dm/gfx/README.md b/dm/gfx/README.md index c150801809..2a8c2a782e 100644 --- a/dm/gfx/README.md +++ b/dm/gfx/README.md @@ -126,6 +126,20 @@ make -C dm/gfx/tex/tests run # texture submodule unit tests; the real-data it make -C dm/gfx/metal run # offscreen example self-test, see metal/README.md ``` +The opt-in `python3 dm/gfx/tests/metal_conversion_probe.py --output-dir /path/to/new-run` +measures Metal's RG11B10Float fragment-output, attachment-store and blit path. It builds a +standalone probe and requires a new directory; no game profile, data or renderer is used. +`results.json` is published only after a complete measurement and includes 95 exact FP32 +input words, raw packed output words, source/input hashes, run ID and OS/GPU/compiler/SDK +provenance. Failed attempts leave `failure.json`, not a completed report. Keep the binary +inputs and outputs with the report. Nearest-even and lower-representable candidates are +comparisons for these nonnegative finite inputs, not required GPU behavior. This does not +establish a game's sampling precision, isolate conversion stages or prove an aliasing fix. +The shared Metal routine can be called without the CLI main, but an iPhone app wrapper and +physical verification remain outstanding. The measurement is excluded from `make run`; +`python3 -m unittest discover -s dm/gfx/tests -p test_metal_conversion_probe.py` checks its +input identity, arithmetic, raw-word serialization and rejection of incomplete reports. + The tests in `dm/gfx/tests` compile the production code in directly and need a Metal device on the local machine; a few of them run under `MTL_DEBUG_LAYER=1` (the Metal validation layer). Most tests do not read the workspace; the exceptions are `t_refl` and `t_rt_range_ro`, which read the translated shader reflection json under `$DM_ROOT/build/cache/metal_shader_cache`, and `test_tbdr_budget.py`, which creates a temporary directory under `$DM_ROOT/run/gfx`. Main contents: | Test | What it checks | diff --git a/dm/gfx/tests/Makefile b/dm/gfx/tests/Makefile index f7572c130d..a2b1a6fda7 100644 --- a/dm/gfx/tests/Makefile +++ b/dm/gfx/tests/Makefile @@ -2,7 +2,7 @@ # Source files are included by wildcard, same convention as GFX in the host Makefile (hle_nvn.c is #included directly by t_nvn.c, so it is excluded); adding new source files needs no change here # Game profile (same convention as the host Makefile: GAME=base game title ID, required: there is no default game): game_profile.h is generated by the host Makefile; dmsh_chain.h (one for every game) lives beside nvn_mtl.m ifeq ($(strip $(GAME)),) -ifneq ($(filter-out clean,$(or $(MAKECMDGOALS),t_nvn)),) +ifneq ($(filter-out clean t_metal_conversion_probe,$(or $(MAKECMDGOALS),t_nvn)),) $(error GAME is not set: make GAME=: $(shell python3 ../../games/game.py list | awk -F'\t' '{printf "%s (%s) ", $$1, $$3}')) endif endif @@ -31,7 +31,7 @@ MTL_T_SRC=../nvn/hle_nvn.c ../../host/crash.c ../../host/hle_bigstack.c t_host_s # runs several ways. A new test needs its source and its build rule; no list to add it to. NOT_RUN: helpers linked into other tests (t_host_stubs, t_present_rescue_focus, t_present_hdr_front), # a fixture test_image_atomic_bounds.py builds itself (t_atomic_return), t_geometry_primitive_id (the GPU half of test_dmsh_geometry.py, which gives it the directory of # its translated fixtures), and t_gpuprof, which CI's CPU subset runs (.github/workflows/tests.yml) -NOT_RUN = t_host_stubs t_present_rescue_focus t_present_hdr_front t_atomic_return t_geometry_primitive_id t_gpuprof +NOT_RUN = t_metal_conversion_probe t_host_stubs t_present_rescue_focus t_present_hdr_front t_atomic_return t_geometry_primitive_id t_gpuprof RUN_TESTS = $(filter-out $(NOT_RUN),$(basename $(sort $(wildcard t_*.c t_*.m)))) check_t_%: t_% $(RUN_ENV) ./$< @@ -487,3 +487,7 @@ 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" + +# Opt-in measurement, not a device-specific rounding assertion or part of make run. +t_metal_conversion_probe: t_metal_conversion_probe.m + clang -O1 -g -Wall -fobjc-arc -o $@ $< -framework Foundation -framework Metal diff --git a/dm/gfx/tests/metal_conversion_probe.py b/dm/gfx/tests/metal_conversion_probe.py new file mode 100755 index 0000000000..8ab89fd1ac --- /dev/null +++ b/dm/gfx/tests/metal_conversion_probe.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Opt-in RG11B10Float attachment diagnostic. Requires a new output directory. + +The input set contains zero and positive finite FP32 values below the largest +finite code in both formats. Comparisons do not prescribe GPU rounding. +results.json is published only after a complete measurement; failure.json +records an unsuccessful attempt. Words in binary artifacts are little-endian. +""" +import argparse +import bisect +import collections +import hashlib +import json +import os +import platform +import struct +import subprocess +import sys +import uuid +from datetime import UTC, datetime +from fractions import Fraction +from pathlib import Path +from typing import Any + +SOURCE = Path(__file__).resolve().with_name('t_metal_conversion_probe.m') +INPUT_VERSION = 'original-95-v1' +INPUT_SHA256 = '90d94428e0a6ccb1b4e36a0e99f5238496daa04cb099c938ef4b8ad56151bbfb' + + +def value(code, m): + e, f = divmod(code, 1 << m) + return Fraction(f if e == 0 else (1 << m) + f) * Fraction(2) ** ((1 if e == 0 else e) - 15 - m) + + +def fpbits(x): + return struct.unpack('= len(table)-1: + raise ValueError('input outside nonnegative finite bounded domain') + dl, dh = x-table[lo], table[lo+1]-x + return lo, lo if dl < dh or (dl == dh and lo % 2 == 0) else lo+1, dl == dh + + +def analyze(cases, packed): + if len(cases) != len(packed): + raise ValueError('incomplete packed output') + rows = [] + for (bits, origins), word in zip(cases.items(), packed): + channels = [] + for name, m, shift in (('R', 6, 0), ('G', 6, 11), ('B', 5, 22)): + lo, near, tie = candidates(bits, m) + observed = (word >> shift) & ((1 << (m+5))-1) + relation = 'both' if observed == lo == near else 'nearest_only' if observed == near else 'lower_only' if observed == lo else 'other' + channels.append({'channel': name, 'lower': lo, 'nearest_even': near, 'observed': observed, 'relation': relation, 'tie': tie}) + rows.append({'input_fp32_bits': f'{bits:08x}', 'packed_hex': f'{word:08x}', 'origins': origins, 'channels': channels}) + return rows + + +def validate_report(report, run_id, cases, packed): + if report.get('status') != 'completed' or report.get('run_id') != run_id or report.get('case_count') != len(cases): + raise ValueError('wrong run or incomplete report') + if report.get('cases') != analyze(cases, packed): + raise ValueError('report observations do not match complete raw output') + + +def command(*args): + return subprocess.run(args, check=True, text=True, capture_output=True).stdout.strip() + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output-dir', required=True, type=Path, help='new directory; must not already exist') + args = parser.parse_args() + try: + args.output_dir.mkdir(parents=False, exist_ok=False) + except OSError as e: + parser.exit(2, f'cannot create fresh output directory: {e}\n') + out = args.output_dir.resolve() + run_id = str(uuid.uuid4()) + try: + cases = input_cases() + inputs = serialize(cases) + if len(cases) != 95 or sha(inputs) != INPUT_SHA256: + raise ValueError('input set identity mismatch') + (out/'inputs.bin').write_bytes(inputs) + compiler = command('xcrun', '--find', 'clang') + metadata: dict[str, Any] = {'run_id': run_id, 'started_utc': datetime.now(UTC).isoformat(), + 'os': command('sw_vers'), 'hardware': command('sysctl', '-n', 'machdep.cpu.brand_string'), + 'architecture': platform.machine(), 'clang': command(compiler, '--version'), + 'sdk_path': command('xcrun', '--sdk', 'macosx', '--show-sdk-path'), + 'sdk_version': command('xcrun', '--sdk', 'macosx', '--show-sdk-version'), + 'input_version': INPUT_VERSION, 'input_sha256': sha(inputs), + 'python_source_sha256': sha(Path(__file__).read_bytes()), 'metal_source_sha256': sha(SOURCE.read_bytes()), + 'format': 'MTLPixelFormatRG11B10Float', 'path': 'fragment output -> render attachment store -> blit', + 'shader_compile_options': 'Metal default (nil)', 'domain': 'nonnegative finite FP32 below largest finite code in each channel'} + compile_args = [compiler, '-isysroot', metadata['sdk_path'], '-fobjc-arc', '-framework', 'Foundation', '-framework', 'Metal', str(SOURCE), '-o', str(out/'probe')] + metadata['compile_command'] = compile_args + built = subprocess.run(compile_args, capture_output=True, text=True, check=True) + (out/'compile.txt').write_text(built.stdout + built.stderr) + run = subprocess.run([str(out/'probe'), str(out/'inputs.bin'), str(out/'packed.bin')], capture_output=True, text=True, check=True) + (out/'execution.txt').write_text('stdout:\n' + run.stdout + '\nstderr:\n' + run.stderr) + metadata['gpu'] = run.stdout.strip() + raw = (out/'packed.bin').read_bytes() + packed = deserialize(raw) + rows = analyze(cases, packed) + counts = {name: dict(collections.Counter(row['channels'][i]['relation'] for row in rows)) for i, name in enumerate(('R', 'G', 'B'))} + report = {'status': 'completed', 'run_id': run_id, 'metadata': metadata, 'case_count': len(rows), 'packed_sha256': sha(raw), 'counts': counts, 'cases': rows} + validate_report(report, run_id, cases, packed) + temp = out/'results.json.tmp' + temp.write_text(json.dumps(report, indent=2)+'\n') + os.replace(temp, out/'results.json') + print(json.dumps({'run_id': run_id, 'case_count': len(rows), 'gpu': metadata['gpu'], 'counts': counts}, indent=2)) + except (OSError, ValueError, subprocess.SubprocessError) as e: + details: dict[str, Any] = {'status': 'failed', 'run_id': run_id, 'error': str(e)} + if isinstance(e, subprocess.CalledProcessError): + details.update(command=e.cmd, returncode=e.returncode, stdout=e.stdout, stderr=e.stderr) + (out/'failure.json').write_text(json.dumps(details, indent=2)+'\n') + print(f'measurement failed: {e}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/dm/gfx/tests/t_metal_conversion_probe.m b/dm/gfx/tests/t_metal_conversion_probe.m new file mode 100644 index 0000000000..b4b7b474ba --- /dev/null +++ b/dm/gfx/tests/t_metal_conversion_probe.m @@ -0,0 +1,32 @@ +#import +#import +/* Shared measurement routine: callers supply exact little-endian FP32 words. */ +NSData *MetalConversionMeasure(NSData *input, NSString **deviceName) { + if(!input || input.length%4 || !input.length){fprintf(stderr,"invalid input\n");return nil;} + id d=MTLCreateSystemDefaultDevice(); if(!d){fprintf(stderr,"MTLCreateSystemDefaultDevice returned nil\n");return nil;} + *deviceName=d.name; + NSString *src=@"#include \nusing namespace metal; struct V { float4 p [[position]]; }; vertex V vs(uint id [[vertex_id]]) { float2 p[3]={float2(-1,-1),float2(3,-1),float2(-1,3)}; return {float4(p[id],0,1)}; } fragment float4 fs(V in [[stage_in]], device const float *v [[buffer(0)]]) { float x=v[uint(in.p.x)]; return float4(x,x,x,1); }"; + NSError *e=nil; id lib=[d newLibraryWithSource:src options:nil error:&e]; if(!lib){fprintf(stderr,"library: %s\n",e.localizedDescription.UTF8String);return nil;} + MTLRenderPipelineDescriptor *pd=[MTLRenderPipelineDescriptor new]; pd.vertexFunction=[lib newFunctionWithName:@"vs"];pd.fragmentFunction=[lib newFunctionWithName:@"fs"];pd.colorAttachments[0].pixelFormat=MTLPixelFormatRG11B10Float; + id ps=[d newRenderPipelineStateWithDescriptor:pd error:&e];if(!ps){fprintf(stderr,"pipeline: %s\n",e.localizedDescription.UTF8String);return nil;} + NSUInteger count=input.length/4, width=256, height=(count+width-1)/width; if(height!=1){fprintf(stderr,"too many cases: %lu\n",(unsigned long)count);return nil;} + MTLTextureDescriptor *td=[MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatRG11B10Float width:width height:height mipmapped:NO];td.usage=MTLTextureUsageRenderTarget;td.storageMode=MTLStorageModePrivate; + id tex=[d newTextureWithDescriptor:td];id in=[d newBufferWithLength:width*4 options:MTLResourceStorageModeShared];id out=[d newBufferWithLength:width*4 options:MTLResourceStorageModeShared]; if(!tex||!in||!out){fprintf(stderr,"resource allocation failed\n");return nil;} memset(in.contents,0,width*4); memcpy(in.contents,input.bytes,input.length); + id q=[d newCommandQueue];id cb=[q commandBuffer]; if(!q || !cb){fprintf(stderr,"command creation failed\n");return nil;} MTLRenderPassDescriptor *rp=[MTLRenderPassDescriptor renderPassDescriptor];rp.colorAttachments[0].texture=tex;rp.colorAttachments[0].loadAction=MTLLoadActionDontCare;rp.colorAttachments[0].storeAction=MTLStoreActionStore; + id re=[cb renderCommandEncoderWithDescriptor:rp];if(!re){fprintf(stderr,"render encoder failed\n");return nil;} [re setRenderPipelineState:ps];[re setFragmentBuffer:in offset:0 atIndex:0];[re drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3];[re endEncoding]; + id bl=[cb blitCommandEncoder];if(!bl){fprintf(stderr,"blit encoder failed\n");return nil;} [bl copyFromTexture:tex sourceSlice:0 sourceLevel:0 sourceOrigin:MTLOriginMake(0,0,0) sourceSize:MTLSizeMake(width,1,1) toBuffer:out destinationOffset:0 destinationBytesPerRow:width*4 destinationBytesPerImage:width*4];[bl endEncoding];[cb commit];[cb waitUntilCompleted];if(cb.status!=MTLCommandBufferStatusCompleted){fprintf(stderr,"command buffer: %s\n",cb.error.localizedDescription.UTF8String);return nil;} + return [NSData dataWithBytes:out.contents length:count*4]; +} +#ifndef METAL_CONVERSION_NO_MAIN +int main(int argc, const char **argv) { @autoreleasepool { + if(argc!=3){fprintf(stderr,"usage: probe inputs.bin output.bin\n");return 2;} + NSString *inputPath=[NSString stringWithUTF8String:argv[1]], *outputPath=[NSString stringWithUTF8String:argv[2]]; + if(!inputPath || !outputPath){fprintf(stderr,"paths must be UTF-8\n");return 2;} + NSData *input=[NSData dataWithContentsOfFile:inputPath]; + if(!input){fprintf(stderr,"cannot read input file\n");return 1;} + NSString *device=nil; NSData *result=MetalConversionMeasure(input,&device); + if(!result) return 1; + if(![result writeToFile:outputPath atomically:YES]){fprintf(stderr,"write failed\n");return 1;} + fprintf(stdout,"%s\n",device.UTF8String); return 0; +} } +#endif diff --git a/dm/gfx/tests/test_metal_conversion_probe.py b/dm/gfx/tests/test_metal_conversion_probe.py new file mode 100644 index 0000000000..d37b43af56 --- /dev/null +++ b/dm/gfx/tests/test_metal_conversion_probe.py @@ -0,0 +1,50 @@ +import hashlib +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +import metal_conversion_probe as probe + + +class ProbeTests(unittest.TestCase): + def test_original_inputs(self): + data = probe.serialize(probe.input_cases()) + self.assertEqual(len(data), 95 * 4) + self.assertEqual(hashlib.sha256(data).hexdigest(), probe.INPUT_SHA256) + self.assertEqual(hashlib.sha256(data).hexdigest(), '90d94428e0a6ccb1b4e36a0e99f5238496daa04cb099c938ef4b8ad56151bbfb') + + def test_serialization_and_channels(self): + words = [0, 0xffffffff, 0x12345678] + self.assertEqual(probe.deserialize(probe.serialize(words)), words) + with self.assertRaises(ValueError): + probe.deserialize(b'123') + channels = probe.analyze({0: []}, [0x12345678])[0]['channels'] + self.assertEqual([c['observed'] for c in channels], [0x678, (0x12345678 >> 11) & 0x7ff, 0x12345678 >> 22]) + + def test_even_tie_and_neighbors(self): + self.assertEqual(probe.candidates(0x35bfffff, 6), (1, 1, False)) + self.assertEqual(probe.candidates(0x35c00000, 6), (1, 2, True)) + self.assertEqual(probe.candidates(0x35c00001, 6), (1, 2, False)) + self.assertEqual(probe.candidates(0x35000000, 6), (0, 0, True)) + with self.assertRaises(ValueError): + probe.candidates(0xbf800000, 6) + + def test_incomplete_and_wrong_run_rejected(self): + cases = {0: []} + report = {'status': 'completed', 'run_id': 'a', 'case_count': 1, 'cases': probe.analyze(cases, [0])} + probe.validate_report(report, 'a', cases, [0]) + for bad in ({**report, 'status': 'failed'}, {**report, 'run_id': 'old'}, {**report, 'cases': []}): + with self.assertRaises(ValueError): + probe.validate_report(bad, 'a', cases, [0]) + with self.assertRaises(ValueError): + probe.validate_report(report, 'a', cases, []) + with tempfile.TemporaryDirectory() as directory: + result = subprocess.run([sys.executable, str(Path(probe.__file__)), '--output-dir', directory], check=False, capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(list(Path(directory).iterdir()), []) + + +if __name__ == '__main__': + unittest.main() From 1ef287d8867fca35c405f7d2366fe7ddf0dc4e8b Mon Sep 17 00:00:00 2001 From: Oliver Ames Date: Wed, 4 Nov 2026 08:32:55 -0500 Subject: [PATCH 3/3] Metal conversion probe test: find the sibling module when run from the repository root --- dm/gfx/tests/test_metal_conversion_probe.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dm/gfx/tests/test_metal_conversion_probe.py b/dm/gfx/tests/test_metal_conversion_probe.py index d37b43af56..c137c77b16 100644 --- a/dm/gfx/tests/test_metal_conversion_probe.py +++ b/dm/gfx/tests/test_metal_conversion_probe.py @@ -5,6 +5,7 @@ import unittest from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) # the probe is a sibling module: the CI runs each test file from the repository root import metal_conversion_probe as probe