From 92d3a70d3dcf5bcdb12a1d80772afd1af101a987 Mon Sep 17 00:00:00 2001 From: Oliver Ames Date: Wed, 28 Oct 2026 21:27:05 -0400 Subject: [PATCH 1/2] Experiment with opt-in SDR-to-EDR presentation --- .github/workflows/tests.yml | 2 +- dm/gfx/nvn/nvn_edr.h | 22 ++++++++ dm/gfx/nvn/nvn_mtl.m | 43 ++++++++++++---- dm/gfx/tests/Makefile | 4 ++ dm/gfx/tests/edr_probe_host.c | 4 ++ dm/gfx/tests/t_edr_policy.c | 20 ++++++++ dm/gfx/tests/t_edr_present.m | 79 ++++++++++++++++++++++++++++ dm/host/front.h | 3 ++ dm/ios/Sources/front_ios.m | 30 ++++++++++- dm/ios/tests/edr_gpu_check.m | 43 ++++++++++++++++ dm/ios/tests/scene_metrics_check.m | 14 +++-- dm/ios/tools/build_edr_probe.py | 82 ++++++++++++++++++++++++++++++ docs/env_switches.tsv | 1 + docs/reference/rendering.md | 12 +++++ 14 files changed, 342 insertions(+), 17 deletions(-) create mode 100644 dm/gfx/nvn/nvn_edr.h create mode 100644 dm/gfx/tests/edr_probe_host.c create mode 100644 dm/gfx/tests/t_edr_policy.c create mode 100644 dm/gfx/tests/t_edr_present.m create mode 100644 dm/ios/tests/edr_gpu_check.m create mode 100644 dm/ios/tools/build_edr_probe.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 623feba4b1..c5fcd0f2f2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -58,7 +58,7 @@ jobs: make GAME="$GAME" -j3 t_perf_order ./t_perf_order --cpu 2>&1 | tee ../../../logs/t_perf_order.log make GAME="$GAME" -j3 perf-order-tsan 2>&1 | tee ../../../logs/t_perf_order-tsan.log - tests="t_capture t_dirty_slots t_dirty_race t_tex_layout t_pace t_fstep t_link_ticks t_cpu_tex t_hmap t_cover t_pso_skey t_refl t_rt_range_ro t_present_rescue t_gpuprof t_sparse_layout t_timestamp t_rt_depth_ro t_rt_range_shape t_ub t_pso_pending t_vk_desc" + tests="t_capture t_dirty_slots t_dirty_race t_tex_layout t_pace t_fstep t_link_ticks t_edr_policy t_cpu_tex t_hmap t_cover t_pso_skey t_refl t_rt_range_ro t_present_rescue t_gpuprof t_sparse_layout t_timestamp t_rt_depth_ro t_rt_range_shape t_ub t_pso_pending t_vk_desc" make GAME="$GAME" -j3 $tests 2>&1 | tee ../../../logs/gfx-build.log failed=0 for t in $tests; do diff --git a/dm/gfx/nvn/nvn_edr.h b/dm/gfx/nvn/nvn_edr.h new file mode 100644 index 0000000000..f1e63b0f40 --- /dev/null +++ b/dm/gfx/nvn/nvn_edr.h @@ -0,0 +1,22 @@ +// Experimental SDR-to-EDR presentation policy. This expands an SDR picture's +// highlights; it does not recover a game's original scene-referred HDR signal. +#pragma once +#include +#include +#include + +static inline int nvn_edr_requested(void) { + const char *value = getenv("DM_PRESENT_EDR"); + return value && !strcmp(value, "1"); +} +static inline float nvn_edr_headroom(int requested, int active, float current, float potential) { + if (!requested || !active || !isfinite(current) || !isfinite(potential)) return 1; + return fmaxf(1, fminf(2, fminf(current, potential))); +} + +// The input is the same stored SDR bytes the stock present pass samples. +// Decode once to extended-linear sRGB, keep shadows/midtones, and expand only +// luminance above 0.5. A common RGB gain preserves the highlight's chromaticity. +#define NVN_EDR_MSL \ + "float3 edr_decode(float3 x) { x=clamp(x,0.0f,1.0f); return select(pow((x+0.055f)/1.055f,float3(2.4f)),x/12.92f,x<=0.04045f); }\n" \ + "float3 edr_map(float3 rgb,float headroom,bool alreadyLinear) { float3 l=alreadyLinear?clamp(rgb,0.0f,1.0f):edr_decode(rgb); float y=dot(l,float3(0.2126f,0.7152f,0.0722f)); float w=smoothstep(0.5f,1.0f,y); return l*(1.0f+(headroom-1.0f)*w); }\n" diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index e28d39c761..0c4d8dcd89 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -32,6 +32,7 @@ #include "dmsh_chain.h" // DMSH_CHAIN: the translation chain version of the B shader library expected by this source tree, one for every game (beside this file, tools/dmsh_chain.py --header) #include "game_profile.h" // game profile constants (profile id, the profile's switches) #include "../../host/front.h" +#include "nvn_edr.h" // Texture slot table per stage: 0-127 the combined handles (nvnCommandBufferBindTexture), 128-255 the separate texture binds (NVN_SEPTEX_SLOT0, nvn_obj.h) #define NTEX 256 // The NVN driver's constant segments c0 and c2 (byte offsets, slot counts) and the binding kinds of the shader reflection have one definition, which the shader build reads too (dmsh_build.py through dmsh_abi.py) @@ -454,7 +455,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]); } @@ -10052,7 +10053,7 @@ void mtl_exec(const uint8_t *p) { } // ---------- Present: draw the swapchain texture to the CAMetalLayer; optional screenshot ---------- -static id g_blit_pso; static MTLPixelFormat g_blit_fmt; +static id g_blit_pso; static MTLPixelFormat g_blit_fmt; static int g_blit_edr; static id g_blit_samp; static uint64_t g_present_n; uint64_t mtl_present_count(void) { return g_present_n; } // Actual present count (heat attribution DM_HEAT computes the present frame rate) @@ -10389,16 +10390,26 @@ int mtl_display_hz(void) { static _Atomic uint64_t g_present_stall_ns; // For unit tests: an extra pause before each present on the present thread (simulating a drawable that stays unavailable) static CAMetalLayer *g_present_layer_resident; void mtl_present_test_stall(uint64_t ns) { g_present_stall_ns = ns; } +__attribute__((weak)) float front_present_edr_headroom(void) { return 1; } // iOS supplies scene headroom; Mac output remains SDR +static int g_edr_mode = -1; +static int present_edr(MTLPixelFormat format) { + if (g_edr_mode < 0) g_edr_mode = nvn_edr_requested(); + return g_edr_mode && format == MTLPixelFormatRGBA16Float; +} static void present_pipeline(MTLPixelFormat format) { - if (!g_blit_pso || g_blit_fmt != format) { + int edr = present_edr(format); + if (!g_blit_pso || g_blit_fmt != format || g_blit_edr != edr) { static const char *src_msl = "#include \nusing namespace metal;\n" "struct V { float4 p [[position]]; float2 uv; };\n" "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; }\n" - "struct Crop { float4 rect; float4 edge; };\n" - "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)); }\n"; + "struct Crop { float4 rect; float4 edge; float4 edr; };\n" + NVN_EDR_MSL + "fragment float4 fs(V in [[stage_in]], texture2d t [[texture(0)]], sampler s [[sampler(0)]], constant Crop& c [[buffer(0)]]) { float4 color=t.sample(s, clamp(in.uv * c.rect.zw + c.rect.xy, c.edge.xy, c.edge.zw));\n" + "#if EDR_PRESENT\n color.rgb=edr_map(color.rgb,c.edr.x,c.edr.y>0.0f);\n#endif\n return color; }\n"; NSError *e = nil; - id lib = [g_dev newLibraryWithSource:@(src_msl) options:nil error:&e]; + MTLCompileOptions *options = [MTLCompileOptions new]; options.preprocessorMacros = @{@"EDR_PRESENT":@(edr)}; + id lib = [g_dev newLibraryWithSource:@(src_msl) options:options error:&e]; if (!lib) MDIE("present shader compile failed %s", e.localizedDescription.UTF8String); MTLRenderPipelineDescriptor *pd = [MTLRenderPipelineDescriptor new]; pd.vertexFunction = [lib newFunctionWithName:@"vs"]; pd.fragmentFunction = [lib newFunctionWithName:@"fs"]; @@ -10406,6 +10417,7 @@ static void present_pipeline(MTLPixelFormat format) { g_blit_pso = [g_dev newRenderPipelineStateWithDescriptor:pd error:&e]; if (!g_blit_pso) MDIE("present pipeline failed %s", e.localizedDescription.UTF8String); g_blit_fmt = format; + g_blit_edr = edr; MTLSamplerDescriptor *sd = [MTLSamplerDescriptor new]; sd.minFilter = sd.magFilter = MTLSamplerMinMagFilterLinear; sd.supportArgumentBuffers = YES; g_blit_samp = [g_dev newSamplerStateWithDescriptor:sd]; } @@ -10465,9 +10477,12 @@ 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, - (cx + 0.5f) / src.width, (cy + 0.5f) / src.height, (cx + cw - 0.5f) / src.width, (cy + ch - 0.5f) / src.height}; + id raw = present_raw_src(src); + [en setRenderPipelineState:g_blit_pso]; [en setFragmentTexture:raw atIndex:0]; [en setFragmentSamplerState:g_blit_samp atIndex:0]; + 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, + present_edr(dst.pixelFormat) ? front_present_edr_headroom() : 1, + raw.pixelFormat == MTLPixelFormatRGBA8Unorm_sRGB || raw.pixelFormat == MTLPixelFormatBGRA8Unorm_sRGB, 0, 0}; 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 [en setFragmentBytes:uv length:sizeof uv atIndex:0]; [en drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3]; @@ -10549,7 +10564,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; + // MetalFX continues to process SDR perceptual bytes. The final present pass + // decodes/expands once; screenshots target BGRA8 and stay SDR even when EDR + // is requested. Do not feed linear EDR values into the perceptual scaler. + MTLPixelFormat upscale_format = present_edr(dst.pixelFormat) ? MTLPixelFormatBGRA8Unorm : dst.pixelFormat; + id tex = present_raw_src(src), up = fx ? fx_encode(fx, cb, tex, r, vp, upscale_format, fx == &g_fx_present) : nil; 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; @@ -10565,6 +10584,8 @@ 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}; diff --git a/dm/gfx/tests/Makefile b/dm/gfx/tests/Makefile index 1ca5fc7b87..88801a9c2f 100644 --- a/dm/gfx/tests/Makefile +++ b/dm/gfx/tests/Makefile @@ -105,6 +105,10 @@ check_t_perf_order: t_perf_order DM_NVN_FAST_WAKE=0 DM_NVN_SLICE_MERGE=0 DM_NVN_CS_HOIST=0 MTL_DEBUG_LAYER=1 ./t_perf_order t_link_ticks: t_link_ticks.m $(MTL_T_DEP) clang $(CFLAGS) -fobjc-arc -o $@ t_link_ticks.m $(filter-out ../nvn/hle_nvn.c,$(MTL_T_SRC)) $(FW) +t_edr_policy: t_edr_policy.c ../nvn/nvn_edr.h + clang $(CFLAGS) -o $@ t_edr_policy.c +t_edr_present: t_edr_present.m edr_probe_host.c ../nvn/nvn_edr.h $(MTL_T_DEP) + clang $(CFLAGS) -fobjc-arc -o $@ t_edr_present.m edr_probe_host.c $(MTL_T_SRC) $(FW) t_cpu_tex: t_cpu_tex.m $(MTL_T_DEP) clang $(CFLAGS) -fobjc-arc -o $@ t_cpu_tex.m $(filter-out ../nvn/hle_nvn.c,$(MTL_T_SRC)) $(FW) .PHONY: check_t_cpu_tex diff --git a/dm/gfx/tests/edr_probe_host.c b/dm/gfx/tests/edr_probe_host.c new file mode 100644 index 0000000000..50bb3c2cd1 --- /dev/null +++ b/dm/gfx/tests/edr_probe_host.c @@ -0,0 +1,4 @@ +// Synthetic headroom for the GPU color-transform test, never a display claim. +#include +_Atomic float edr_probe_headroom = 2; +float front_present_edr_headroom(void) { return atomic_load(&edr_probe_headroom); } diff --git a/dm/gfx/tests/t_edr_policy.c b/dm/gfx/tests/t_edr_policy.c new file mode 100644 index 0000000000..53b2176ae6 --- /dev/null +++ b/dm/gfx/tests/t_edr_policy.c @@ -0,0 +1,20 @@ +#include "../nvn/nvn_edr.h" +#include +static int checks, failed; +#define CHECK(name, ok) do { checks++; int pass = !!(ok); failed += !pass; printf("%s: %s\n", name, pass ? "PASS" : "FAIL"); } while (0) +int main(void) { + unsetenv("DM_PRESENT_EDR"); CHECK("EDR is off by default", !nvn_edr_requested()); + setenv("DM_PRESENT_EDR", "0", 1); CHECK("zero keeps SDR", !nvn_edr_requested()); + setenv("DM_PRESENT_EDR", "1", 1); CHECK("one explicitly opts in", nvn_edr_requested()); + setenv("DM_PRESENT_EDR", "true", 1); CHECK("other values do not accidentally opt in", !nvn_edr_requested()); + CHECK("off keeps SDR headroom", nvn_edr_headroom(0, 1, 8, 8) == 1); + CHECK("an inactive scene keeps SDR", nvn_edr_headroom(1, 0, 8, 8) == 1); + CHECK("SDR-only display keeps SDR", nvn_edr_headroom(1, 1, 1, 1) == 1); + CHECK("current headroom limits output", fabsf(nvn_edr_headroom(1, 1, 1.2f, 8) - 1.2f) < 0.00001f); + CHECK("potential headroom also limits output", nvn_edr_headroom(1, 1, 8, 1.5f) == 1.5f); + CHECK("experimental boost is capped at two", nvn_edr_headroom(1, 1, 8, 8) == 2); + CHECK("invalid headroom falls back to SDR", nvn_edr_headroom(1, 1, NAN, 8) == 1); + CHECK("invalid potential falls back to SDR", nvn_edr_headroom(1, 1, 2, INFINITY) == 1); + printf("edr policy: %d checks, %d failed\n", checks, failed); + return failed != 0; +} diff --git a/dm/gfx/tests/t_edr_present.m b/dm/gfx/tests/t_edr_present.m new file mode 100644 index 0000000000..a0e173b673 --- /dev/null +++ b/dm/gfx/tests/t_edr_present.m @@ -0,0 +1,79 @@ +// Real production present shaders/Metal queues with synthetic SDR textures. +// Readback proves conversion and default parity, not a display's HDR luminance. +#include "../nvn/nvn_mtl.m" +module_t *module_of(const void *p) { (void)p; return NULL; } +module_t *g_mod; int g_nmod; +void svc_thread_dump(void) {} +void *front_metal_layer(void) { return NULL; } +uint64_t now_ns(void) { return bp_now(); } +extern _Atomic float edr_probe_headroom; +static int checks, failed; +#define CHECK(name, ok) do { checks++; int pass = !!(ok); failed += !pass; printf("%s: %s\n", name, pass ? "PASS" : "FAIL"); } while (0) + +static id source(MTLPixelFormat format, int size, const uint8_t rgba[4]) { + MTLTextureDescriptor *d = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:format width:size height:size mipmapped:NO]; + d.storageMode = MTLStorageModeShared; d.usage = MTLTextureUsageShaderRead; + id t = [g_dev newTextureWithDescriptor:d]; + uint8_t *bytes = malloc(size * size * 4); for (int i = 0; i < size * size; i++) memcpy(bytes + i * 4, rgba, 4); + [t replaceRegion:MTLRegionMake2D(0,0,size,size) mipmapLevel:0 withBytes:bytes bytesPerRow:size*4]; free(bytes); + res_add(t); res_commit(); return t; +} +static id target(MTLPixelFormat format, int width, int height) { + MTLTextureDescriptor *d = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:format width:width height:height mipmapped:NO]; + d.storageMode = MTLStorageModeShared; d.usage = MTLTextureUsageRenderTarget | MTLTextureUsageShaderRead; + id t = [g_dev newTextureWithDescriptor:d]; res_add(t); res_commit(); return t; +} +static void classic(id src, id out) { + id cb = [g_q commandBuffer]; CHECK("classic present encodes", present_encode(cb,src,out,NULL)); + [cb commit]; [cb waitUntilCompleted]; CHECK("classic GPU work completes", cb.status == MTLCommandBufferStatusCompleted); +} +static int close_pixel_tolerance(id texture, float expected, int x, int y, float tolerance) { + __fp16 pixel[4]; [texture getBytes:pixel bytesPerRow:8 fromRegion:MTLRegionMake2D(x,y,1,1) mipmapLevel:0]; + return fabsf((float)pixel[0] - expected) < tolerance && fabsf((float)pixel[1] - expected) < tolerance && + fabsf((float)pixel[2] - expected) < tolerance && pixel[3] == 1; +} +static int close_pixel(id texture, float expected, int x, int y) { + return close_pixel_tolerance(texture,expected,x,y,0.003f); +} +int main(void) { @autoreleasepool { + mtl_init(); g_fx_mode = 0; + const uint8_t gray[4] = {128,128,128,255}, white[4] = {255,255,255,255}, black[4] = {0,0,0,255}; + for (int srgb = 0; srgb < 2; srgb++) { + id s = source(srgb ? MTLPixelFormatRGBA8Unorm_sRGB : MTLPixelFormatRGBA8Unorm,8,gray); + id bytes = target(MTLPixelFormatRGBA8Unorm,8,8), linear = target(MTLPixelFormatRGBA16Float,8,8); + g_edr_mode = 0; classic(s,bytes); uint8_t out[256]; [bytes getBytes:out bytesPerRow:32 fromRegion:MTLRegionMake2D(0,0,8,8) mipmapLevel:0]; + int bad = 0; for (int i = 0; i < 64; i++) bad += memcmp(out+i*4,gray,4) != 0; + CHECK("SDR default preserves every source byte", bad == 0); + classic(s,linear); CHECK("disabled EDR preserves raw float-target values", close_pixel(linear,128.0f/255.0f,4,4)); + g_edr_mode = 1; classic(s,linear); CHECK("SDR gamma is decoded exactly once for linear EDR", close_pixel(linear,0.2158605f,4,4)); + classic(s,bytes); [bytes getBytes:out bytesPerRow:32 fromRegion:MTLRegionMake2D(0,0,8,8) mipmapLevel:0]; + bad = 0; for (int i = 0; i < 64; i++) bad += memcmp(out+i*4,gray,4) != 0; + CHECK("SDR screenshot target stays byte-identical while EDR is enabled", bad == 0); + res_remove(s); res_remove(bytes); res_remove(linear); + } + id w = source(MTLPixelFormatRGBA8Unorm,8,white), b = source(MTLPixelFormatRGBA8Unorm,8,black); + id f = target(MTLPixelFormatRGBA16Float,8,8); + g_edr_mode = 1; atomic_store(&edr_probe_headroom,2); classic(w,f); CHECK("white expands to the bounded headroom", close_pixel(f,2,4,4)); + atomic_store(&edr_probe_headroom,1); classic(w,f); CHECK("headroom loss returns white to SDR", close_pixel(f,1,4,4)); + atomic_store(&edr_probe_headroom,2); classic(b,f); CHECK("black stays black", close_pixel(f,0,4,4)); + for (int value = 10; value <= 11; value++) { + uint8_t rgba[4] = {(uint8_t)value,(uint8_t)value,(uint8_t)value,255}; + id s = source(MTLPixelFormatRGBA8Unorm,8,rgba); classic(s,f); + CHECK("the sRGB transfer-function knee stays finite and correct", close_pixel_tolerance(f,value == 10 ? 0.00303527f : 0.00334654f,4,4,0.00002f)); res_remove(s); + } + id bars = target(MTLPixelFormatRGBA16Float,16,8); classic(w,bars); + CHECK("aspect-fit bars stay black", close_pixel(bars,0,0,4) && close_pixel(bars,0,15,4)); + CHECK("the entire centered picture reaches EDR", close_pixel(bars,2,4,4) && close_pixel(bars,2,11,4)); + if ([MTLFXSpatialScalerDescriptor supportsMetal4FX:g_dev]) { + g_fx_mode = 1; id s = source(MTLPixelFormatRGBA8Unorm_sRGB,64,gray); + id e = target(MTLPixelFormatRGBA16Float,128,128), png = target(MTLPixelFormatBGRA8Unorm,128,128); + CHECK("MetalFX EDR present actually uses the scaler", present_offscreen_wait(s,NULL,e) == PRESENT_METALFX); + CHECK("MetalFX perceptual output is decoded once in the final pass", close_pixel(e,0.2158605f,64,64)); + CHECK("MetalFX SDR capture actually uses the scaler", present_offscreen_wait(s,NULL,png) == PRESENT_METALFX); + uint8_t pixel[4]; [png getBytes:pixel bytesPerRow:4 fromRegion:MTLRegionMake2D(64,64,1,1) mipmapLevel:0]; + CHECK("MetalFX screenshot remains SDR", abs(pixel[0]-128)<=3 && abs(pixel[1]-128)<=3 && abs(pixel[2]-128)<=3 && pixel[3]==255); + res_remove(s); res_remove(e); res_remove(png); + } else printf("MetalFX is unavailable; its EDR combination remains unchecked\n"); + printf("edr present: %d checks, %d failed\n", checks, failed); + return failed != 0; +} } diff --git a/dm/host/front.h b/dm/host/front.h index bd5ff5c6f7..2e3f35f222 100644 --- a/dm/host/front.h +++ b/dm/host/front.h @@ -15,6 +15,9 @@ void front_run(void (*guest)(void)); void *front_metal_layer(void); // The highest refresh rate of the rendering window's screen in Hz, 0 = not known. Any thread; iOS publishes a scene-derived snapshot from the main thread. int front_screen_max_fps(void); +// Experimental EDR headroom for the current rendering scene, 1 when unavailable +// or inactive. Any thread; the iOS front end refreshes UIKit values on main. +float front_present_edr_headroom(void); // The frame rate the display link paces the game at (60, 30, 120; nvn_mtl.m link_sync): in fullscreen, a screen whose refresh rate is not a multiple of it is switched, while the game runs, // to a mode of the same size whose rate is, and back when the window leaves fullscreen or that screen (front_mac.m; none on iOS). Called from the present thread void front_screen_match(int fps); diff --git a/dm/ios/Sources/front_ios.m b/dm/ios/Sources/front_ios.m index 826f56d5b7..60b8f2a45f 100644 --- a/dm/ios/Sources/front_ios.m +++ b/dm/ios/Sources/front_ios.m @@ -5,10 +5,12 @@ #import #import #include +#include #include "front.h" #include "hle.h" #include "dm.h" #include "haptics_apple.h" +#include "../../gfx/nvn/nvn_edr.h" UIView *dm_gamepad_view(CGRect frame); void dm_gamepad_reset(void); @@ -45,6 +47,11 @@ - (instancetype)initWithFrame:(CGRect)f { if ((self = [super initWithFrame:f])) { CAMetalLayer *l = (CAMetalLayer *)self.layer; l.device = MTLCreateSystemDefaultDevice(); l.pixelFormat = MTLPixelFormatBGRA8Unorm; + if (nvn_edr_requested()) { + l.pixelFormat = MTLPixelFormatRGBA16Float; + CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceExtendedLinearSRGB); + l.colorspace = space; CGColorSpaceRelease(space); + } l.framebufferOnly = NO; l.opaque = YES; self.backgroundColor = UIColor.blackColor; [self registerForTraitChanges:@[UITraitDisplayScale.class, UITraitHDRHeadroomUsageLimit.class] @@ -76,15 +83,22 @@ - (void)layoutSubviews { DmDisplayMetrics value = {l.contentsScale, pixels.width, pixels.height, screen.currentEDRHeadroom, screen.potentialEDRHeadroom, (int)screen.maximumFramesPerSecond, self.traitCollection.hdrHeadroomUsageLimit == UIHDRHeadroomUsageLimitActive}; + if (nvn_edr_requested()) { + // Ask for at most 2x SDR white; the shader is separately bounded by + // current headroom. An inactive/SDR-only scene keeps linear SDR output. + float wanted = value.headroom_active ? fmaxf(1, fminf(2, value.potential_headroom)) : 1; + l.contentsHeadroom = wanted; + l.preferredDynamicRange = wanted > 1 ? CADynamicRangeHigh : CADynamicRangeStandard; + } pthread_mutex_lock(&g_display_mu); DmDisplayMetrics previous = g_display; g_display = value; pthread_mutex_unlock(&g_display_mu); if (value.scale != previous.scale || value.width != previous.width || value.height != previous.height || value.max_fps != previous.max_fps || value.headroom != previous.headroom || value.potential_headroom != previous.potential_headroom || value.headroom_active != previous.headroom_active) - LOG("display: scene scale %.3f, drawable %.0fx%.0f, maximum %d Hz, EDR headroom %.3f / %.3f (%s); SDR output", + LOG("display: scene scale %.3f, drawable %.0fx%.0f, maximum %d Hz, EDR headroom %.3f / %.3f (%s); %s output", value.scale, value.width, value.height, value.max_fps, value.headroom, value.potential_headroom, - value.headroom_active ? "active" : "inactive"); + value.headroom_active ? "active" : "inactive", nvn_edr_requested() ? "experimental SDR-to-EDR" : "SDR"); } @end @@ -162,6 +176,18 @@ void front_ios_scene_changed(UIWindowScene *scene) { if (g_root.game.window.windowScene != scene) return; [g_root.game setNeedsLayout]; [g_root.game layoutIfNeeded]; } +float front_present_edr_headroom(void) { + if (!nvn_edr_requested()) return 1; + // Existing layout/brightness events update immediately. A throttled main + // queue sample also follows headroom changes caused by EDR content itself; + // no second display link or UIKit call on the present thread is needed. + static _Atomic uint64_t next_sample; + uint64_t now = now_ns(), next = atomic_load(&next_sample); + if (now >= next && atomic_compare_exchange_strong(&next_sample, &next, now + 1000000000ull)) + dispatch_async(dispatch_get_main_queue(), ^{ [g_root.game setNeedsLayout]; [g_root.game layoutIfNeeded]; }); + DmDisplayMetrics value = display_snapshot(); + return nvn_edr_headroom(1, value.headroom_active, value.headroom, value.potential_headroom); +} // Thermal state and screen brightness for the power and present rows every 300 frames (hle_nvn.c; its weak defaults return -1 on the Mac). thermalState: 0 nominal, 1 fair, 2 serious, 3 critical. // Brightness is read on the main thread at start and on each change; every change of the thermal state and of Low Power Mode (which lowers the CPU clock) is logged with the time since launch diff --git a/dm/ios/tests/edr_gpu_check.m b/dm/ios/tests/edr_gpu_check.m new file mode 100644 index 0000000000..ad7909e9f5 --- /dev/null +++ b/dm/ios/tests/edr_gpu_check.m @@ -0,0 +1,43 @@ +// Native GPU readback of production presentation code; no game or HDR display claim. +#import +#define main dm_edr_test_main +#include "../../gfx/tests/t_edr_present.m" +#undef main + +@interface EDRScene : UIResponder +@property(nonatomic, strong) UIWindow *window; +@end +@implementation EDRScene +- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)options { + (void)session; (void)options; + self.window = [[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene]; + self.window.rootViewController = [UIViewController new]; + self.window.rootViewController.view.backgroundColor = UIColor.blackColor; + [self.window makeKeyAndVisible]; + static BOOL started; + if (started) return; + started = YES; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + int status = dm_edr_test_main(); + NSDictionary *report = @{@"checks":@(checks), @"failed":@(failed), @"exitStatus":@(status), + @"syntheticHeadroom":@YES, @"physicalHDRDisplayTested":@NO, + @"systemVersion":UIDevice.currentDevice.systemVersion, + @"metalDevice":g_dev.name ?: @"unavailable"}; + NSURL *documents = [NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0]; + [[NSJSONSerialization dataWithJSONObject:report options:NSJSONWritingPrettyPrinted error:NULL] + writeToURL:[documents URLByAppendingPathComponent:@"results.json"] options:NSDataWritingAtomic error:NULL]; + fflush(stdout); fflush(stderr); + if (status) exit(status); + }); +} +@end +@interface EDRApp : UIResponder +@end +@implementation EDRApp +@end +int main(int argc, char **argv) { @autoreleasepool { + NSURL *documents = [NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0]; + freopen([[documents URLByAppendingPathComponent:@"gpu.log"] fileSystemRepresentation], "w", stdout); + dup2(fileno(stdout), fileno(stderr)); setvbuf(stdout,NULL,_IONBF,0); + return UIApplicationMain(argc,argv,nil,NSStringFromClass(EDRApp.class)); +} } diff --git a/dm/ios/tests/scene_metrics_check.m b/dm/ios/tests/scene_metrics_check.m index 1b29675b46..6db79b2280 100644 --- a/dm/ios/tests/scene_metrics_check.m +++ b/dm/ios/tests/scene_metrics_check.m @@ -73,8 +73,16 @@ - (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session op check(@"headroom snapshots contain valid ratios", isfinite(metrics.headroom) && metrics.headroom >= 1 && isfinite(metrics.potential_headroom) && metrics.potential_headroom >= 1); check(@"headroom usage follows the rendering view trait", metrics.headroom_active == (root.game.traitCollection.hdrHeadroomUsageLimit == UIHDRHeadroomUsageLimitActive)); CAMetalLayer *layer = (CAMetalLayer *)root.game.layer; - check(@"the output format stays SDR", layer.pixelFormat == MTLPixelFormatBGRA8Unorm); - check(@"the default dynamic range stays standard", [layer.preferredDynamicRange isEqual:CADynamicRangeStandard]); + BOOL edr = nvn_edr_requested(); + check(@"the output format follows the explicit opt-in", layer.pixelFormat == (edr ? MTLPixelFormatRGBA16Float : MTLPixelFormatBGRA8Unorm)); + if (edr) { + CGColorSpaceRef expected = CGColorSpaceCreateWithName(kCGColorSpaceExtendedLinearSRGB); + check(@"EDR output uses extended-linear sRGB", CFEqual(layer.colorspace, expected)); CGColorSpaceRelease(expected); + float wanted = metrics.headroom_active ? fmaxf(1, fminf(2, metrics.potential_headroom)) : 1; + check(@"the layer tags the bounded experimental content headroom", layer.contentsHeadroom == wanted); + check(@"the preferred range follows scene permission", [layer.preferredDynamicRange isEqual:wanted > 1 ? CADynamicRangeHigh : CADynamicRangeStandard]); + check(@"the shader headroom follows current scene capacity", front_present_edr_headroom() == nvn_edr_headroom(1, metrics.headroom_active, metrics.headroom, metrics.potential_headroom)); + } else check(@"the default dynamic range stays standard", [layer.preferredDynamicRange isEqual:CADynamicRangeStandard]); id drawable = [layer nextDrawable]; check(@"the real scene produces a drawable", drawable != nil); check(@"drawable pixels exactly match the snapshot", drawable && drawable.texture.width == metrics.width && drawable.texture.height == metrics.height); @@ -84,7 +92,7 @@ - (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session op check(@"a worker reads the published refresh without UIKit", fps == metrics.max_fps); NSInteger failed = 0; for (NSDictionary *item in checks) if (![item[@"passed"] boolValue]) failed++; NSDictionary *report = @{@"checks":checks, @"failed":@(failed), @"externalDisplayTested":@NO, - @"EDROutputEnabled":@NO, @"scale":@(metrics.scale), @"width":@(metrics.width), @"height":@(metrics.height), + @"EDROutputEnabled":@(edr), @"scale":@(metrics.scale), @"width":@(metrics.width), @"height":@(metrics.height), @"maximumFPS":@(metrics.max_fps), @"currentEDRHeadroom":@(metrics.headroom), @"potentialEDRHeadroom":@(metrics.potential_headroom), @"systemVersion":UIDevice.currentDevice.systemVersion}; NSURL *documents = [NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0]; diff --git a/dm/ios/tools/build_edr_probe.py b/dm/ios/tools/build_edr_probe.py new file mode 100644 index 0000000000..bd1845d4c1 --- /dev/null +++ b/dm/ios/tools/build_edr_probe.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Build an unsigned native GPU EDR-conversion probe using production shaders. + +GAME selects only source constants. No games, keys, signing or device actions. +The app tests synthetic headroom and readback, not physical HDR luminance. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + +REPO = Path(__file__).resolve().parents[3] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', required=True, type=Path, help='new scratch directory') + args = parser.parse_args() + game = os.environ.get('GAME') or os.environ.get('DM_GAME') + if not game: + parser.error('GAME or DM_GAME must select a source profile') + output = args.output.resolve() + output.mkdir(parents=True) + include = output / 'Include' + include.mkdir() + subprocess.run([sys.executable, str(REPO / 'dm/games/game.py'), 'header', game, + str(include / 'game_profile.h')], check=True) + gfx = REPO / 'dm/gfx' + sources = [REPO / p for p in ('dm/ios/tests/edr_gpu_check.m', + 'dm/gfx/tests/edr_probe_host.c', 'dm/gfx/tests/t_host_stubs.c', + 'dm/host/crash.c', 'dm/host/hle_bigstack.c')] + # The fixture includes nvn_mtl.m so it can exercise the actual present pass. + sources += sorted(p for folder in ('nvn', 'tex') for p in (gfx / folder).iterdir() + if p.suffix in ('.c', '.m', '.S') and p.name != 'nvn_mtl.m') + project = { + 'name': 'EDRVerification', 'options': {'deploymentTarget': {'iOS': '26.0'}}, + 'targets': {'EDRVerification': { + 'type': 'application', 'platform': 'iOS', + 'sources': [{'path': str(p)} for p in sources], + 'dependencies': [{'sdk': name + '.framework'} for name in + ('UIKit', 'Metal', 'MetalFX', 'Foundation', 'QuartzCore', + 'ImageIO', 'CoreGraphics', 'UniformTypeIdentifiers')], + 'settings': {'base': { + 'PRODUCT_BUNDLE_IDENTIFIER': 'dev.anygame.edr-verification', + 'ENABLE_DEBUG_DYLIB': 'NO', 'CODE_SIGN_STYLE': 'Automatic', + 'TARGETED_DEVICE_FAMILY': '1', 'CLANG_ENABLE_OBJC_ARC': 'YES', + 'GCC_OPTIMIZATION_LEVEL': '1', + 'HEADER_SEARCH_PATHS': [str(gfx / 'nvn/include'), str(include)], + }}, + 'info': {'path': 'Info.plist', 'properties': { + 'CFBundleDisplayName': 'EDR GPU Verification', 'UILaunchScreen': {}, + 'UIFileSharingEnabled': True, + 'UIApplicationSceneManifest': {'UIApplicationSupportsMultipleScenes': False, + 'UISceneConfigurations': {'UIWindowSceneSessionRoleApplication': [{ + 'UISceneConfigurationName': 'Default', 'UISceneDelegateClassName': 'EDRScene'}]}}, + }}, + }}, + } + (output / 'project.yml').write_text(json.dumps(project, indent=2) + '\n') + # Include production headers/shaders and the fixture's included test body in + # the digest, not just directly compiled sources. The probe carries no payload. + provenance = set(sources) | {REPO / 'dm/gfx/tests/t_edr_present.m'} + provenance |= set((gfx / 'nvn').glob('*.h')) | set((gfx / 'nvn').glob('*.inc')) + provenance |= set((gfx / 'nvn/include').glob('*.h')) | {gfx / 'nvn/nvn_mtl.m'} + report = {'gitHead': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=REPO, text=True).strip(), + 'gameProfile': game, 'syntheticHeadroom': True, 'physicalHDRDisplayTested': False, + 'sha256': {str(p.relative_to(REPO)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(provenance)}} + (output / 'source-provenance.json').write_text(json.dumps(report, indent=2) + '\n') + subprocess.run(['xcodegen', 'generate', '--spec', str(output / 'project.yml')], check=True) + subprocess.run(['xcodebuild', '-project', str(output / 'EDRVerification.xcodeproj'), + '-scheme', 'EDRVerification', '-configuration', 'Debug', + '-destination', 'generic/platform=iOS', '-derivedDataPath', str(output / 'DerivedData'), + '-jobs', '3', 'CODE_SIGNING_ALLOWED=NO', 'build'], check=True) + print(output / 'DerivedData/Build/Products/Debug-iphoneos/EDRVerification.app') + + +if __name__ == '__main__': + main() diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index c7695d9902..9306784186 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -187,6 +187,7 @@ DM_PATCH_FRONT switch `=`: the size of the patch area right before the mo DM_PHONE_MOTION switch On iOS, whether the phone's own device motion drives player 1's six-axis sensor: `auto` (default) while no controller supplies fresh motion, `1` phone only, `0` never; each source keeps its own orientation DM_PREEMPT switch `0` / `1` overrides the profile's `core_preempt`: also check already-runnable higher-priority peers on the caller's core after every thread SVC DM_PRELOAD diagnostic Set: on the Mac, runs the host's early background loading (`dm_host_preload_start`) as the iOS app does, to exercise that path +DM_PRESENT_EDR switch `1` explicitly enables experimental iOS SDR-to-EDR highlights in an extended-linear RGBA16Float layer, bounded to current headroom and at most 2x SDR white; unset keeps the exact SDR path. This does not recover native HDR content; PNG screenshots remain SDR. DM_PRESENT_FIFO switch `0` makes the display link take the newest queued frame each tick, as before; by default it shows frames in order, at most one tick behind DM_PRESENT_HZ switch `=`: the frequency of presentation pacing (default 60); `0` means no frame limit and is for performance profiling only DM_PRESENT_INTERVAL switch `display` presents one frame per display-link tick whatever interval the game sets; `game` follows the game's present interval (default: the profile's `present_interval`) diff --git a/docs/reference/rendering.md b/docs/reference/rendering.md index eb39a94447..a97ebc3ad0 100644 --- a/docs/reference/rendering.md +++ b/docs/reference/rendering.md @@ -2,6 +2,18 @@ The replay on Metal (`dm/gfx/nvn/nvn_mtl.m`) and the Vulkan and OpenGL layers. Each entry: symptom, cause, fix, the games it was found on. The [symptom index](../agents/symptoms.md) lists every symptom. +## Experimental SDR-to-EDR presentation + +`DM_PRESENT_EDR=1`, set before launch, opts the iOS rendering layer into RGBA16Float and extended-linear sRGB. With the switch absent, its BGRA8 SDR output is unchanged. This experiment expands highlights in the final SDR picture; the original game's HDR signal is unavailable. Bright game text and HUD pixels are part of that picture and can also brighten. Separate UIKit controls are unaffected. + +The final present shader decodes sRGB once, keeps linear luminance through 0.5 unchanged, and increases highlight RGB together. Peak gain is bounded by the rendering scene's current and potential headroom, with an experimental ceiling of 2× SDR white. Inactive scenes and SDR-only displays use a gain of 1. The iOS 26 layer properties `preferredDynamicRange` and `contentsHeadroom` describe this output. Existing brightness, scene and trait updates refresh the snapshot; an opt-in, once-per-second main-queue sample also follows content-driven headroom changes. + +MetalFX spatial scaling keeps its existing perceptual SDR input and output. Conversion happens once in the final float-target pass. Screenshot targets stay SDR, including when MetalFX and EDR are both enabled. Aspect fit, the complete source picture and the existing display link are unchanged. Float drawables use twice the storage per pixel of BGRA8; power, frame pacing and perceived brightness need device measurements before enabling this by default. + +`t_edr_policy` checks opt-in and headroom policy without a GPU. `t_edr_present` runs the production present shaders on synthetic textures and reads back SDR parity, transfer-function boundaries, headroom changes, aspect-fit bars and MetalFX output. `dm/ios/tools/build_edr_probe.py --output ` builds those GPU checks as an unsigned native app and records source hashes. It requires `GAME` only for source constants. Its synthetic headroom does not measure a display's luminance. The separate scene-metrics probe checks the actual production layer configuration with the switch both absent and enabled. Device appearance, external displays and sustained gameplay cost remain separate checks. + +Apple's [tone-mapping guidance](https://developer.apple.com/documentation/metal/performing-your-own-tone-mapping) describes the float, extended-linear layer pipeline. The current SDK documents [preferred dynamic range](https://developer.apple.com/documentation/quartzcore/calayer/preferreddynamicrange), [content headroom](https://developer.apple.com/documentation/quartzcore/calayer/contentsheadroom) and the screen's [current EDR headroom](https://developer.apple.com/documentation/uikit/uiscreen/currentedrheadroom). These references were read from Xcode's local documentation archive and SDK headers. + ## Textures and formats ### Null textures in diagnostic logs From ba2fbce375e5930204697d777d44357c44d9fb7b Mon Sep 17 00:00:00 2001 From: Oliver Ames Date: Wed, 28 Oct 2026 22:15:19 -0400 Subject: [PATCH 2/2] Show explicit no-ROM verification status instead of game errors --- dm/ios/tests/scene_metrics_check.m | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/dm/ios/tests/scene_metrics_check.m b/dm/ios/tests/scene_metrics_check.m index cdaccaf88e..9f5eb639ea 100644 --- a/dm/ios/tests/scene_metrics_check.m +++ b/dm/ios/tests/scene_metrics_check.m @@ -56,10 +56,21 @@ - (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session op [super scene:scene willConnectToSession:session options:options]; DmRootVC *root = (DmRootVC *)self.window.rootViewController; root.launched = YES; + // The production root deliberately has no ROM here. Replace its game-file + // hint before the first screen is shown so this probe cannot look like a + // broken game installation. + for (UIView *view in root.view.subviews.copy) [view removeFromSuperview]; + UILabel *status = [[UILabel alloc] initWithFrame:CGRectInset(root.view.bounds, 32, 32)]; + status.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + status.numberOfLines = 0; status.textAlignment = NSTextAlignmentCenter; + status.font = [UIFont preferredFontForTextStyle:UIFontTextStyleTitle2]; + status.textColor = UIColor.whiteColor; + status.text = @"Verification app\nNo game files are used.\nRunning display checks."; + [root.view addSubview:status]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC), dispatch_get_main_queue(), ^{ checks = [NSMutableArray array]; root.game = [[DmGameView alloc] initWithFrame:root.view.bounds]; - [root.view addSubview:root.game]; + [root.view insertSubview:root.game atIndex:0]; g_layer = (CAMetalLayer *)root.game.layer; g_root = root; CGSize original = root.view.bounds.size; for (NSValue *size in @[[NSValue valueWithCGSize:CGSizeMake(390,844)], @@ -92,7 +103,12 @@ - (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session op dispatch_async(dispatch_get_main_queue(), ^{ check(@"a worker reads the published refresh without UIKit", fps == metrics.max_fps); NSInteger failed = 0; for (NSDictionary *item in checks) if (![item[@"passed"] boolValue]) failed++; + status.text = [NSString stringWithFormat:@"Verification app\nNo game files are used.\nVerification %@.\n%lu display checks, %ld failed.\n%@", + failed ? @"failed" : @"passed", (unsigned long)checks.count, (long)failed, + edr ? @"Mode: experimental EDR." : @"Mode: default SDR."]; + [root.view bringSubviewToFront:status]; NSDictionary *report = @{@"checks":checks, @"failed":@(failed), @"externalDisplayTested":@NO, + @"displayMessage":status.text, @"EDROutputEnabled":@(edr), @"scale":@(metrics.scale), @"width":@(metrics.width), @"height":@(metrics.height), @"maximumFPS":@(metrics.max_fps), @"currentEDRHeadroom":@(metrics.headroom), @"potentialEDRHeadroom":@(metrics.potential_headroom), @"systemVersion":UIDevice.currentDevice.systemVersion};