Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dm/games/0400C3F00006E000/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <log>`. 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/<log>.res`. Control presses run in order, so `A~40` holds up later lines.
Expand Down
2 changes: 2 additions & 0 deletions dm/games/settings.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions dm/gfx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 3 additions & 1 deletion dm/gfx/hle_vi.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions dm/gfx/tests/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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=<base game title ID>: $(shell python3 ../../games/game.py list | awk -F'\t' '{printf "%s (%s) ", $$1, $$3}'))
endif
endif
Expand Down Expand Up @@ -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) ./$<
Expand Down Expand Up @@ -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
155 changes: 155 additions & 0 deletions dm/gfx/tests/metal_conversion_probe.py
Original file line number Diff line number Diff line change
@@ -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('<I', struct.pack('<f', float(x)))[0]


def input_cases():
cases = {}
for m in (5, 6):
for lo in (0, 1, 2, (1 << m)-2, (1 << m)-1, 1 << m, (14 << m)-1, 14 << m, (15 << m)-1, 15 << m, (16 << m)-1, 16 << m, (20 << m)+7, (29 << m)-1):
b = fpbits((value(lo, m) + value(lo+1, m)) / 2)
for delta in (-1, 0, 1):
cases.setdefault(b+delta, []).append({'boundary_mantissa_bits': m, 'lower_code': lo, 'position': delta})
for x in (0, 2**-20, 2**-14, 0.1, 0.5, 1, 1.001, 2, 10, 100, 1000, 30000):
cases.setdefault(fpbits(x), []).append({'anchor': str(x)})
return dict(sorted(cases.items()))


def serialize(words):
return b''.join(struct.pack('<I', word) for word in words)


def deserialize(data):
if len(data) % 4:
raise ValueError('word data is not a multiple of four bytes')
return [x[0] for x in struct.iter_unpack('<I', data)]


def candidates(bits, m):
x = Fraction.from_float(struct.unpack('<f', struct.pack('<I', bits))[0])
table = [value(i, m) for i in range(31 * (1 << m))]
lo = bisect.bisect_right(table, x) - 1
if lo < 0 or lo >= 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())
Loading