Last iteration's corrected experiment, run. Boot with --log_mask=12 --log_level=3 (Kernel logging on, Cpu/Gpu off), which changes nothing about the output and so cannot perturb the capture harness the way the gamma-cvar experiment would have. VdGetCurrentDisplayGamma is called once, at video init: d> VdGetSystemCommandBuffer(701CF830, 701CF804) d> VdGetCurrentDisplayGamma(701CE1F8(00000000), 701CE1F0(0)) d> VdSetDisplayMode(40000000) d> VdGetCurrentDisplayInformation(701CF110) The control is in the same log: 359 VdRetrainEDRAM and 358 VdGetSystemCommandBuffer lines, so an absent call would have been visible. Per the export's own comment the returned type is "used in D3D SetGammaRamp/SetPWLGamma" -- the game asks the question a ramp-builder asks, at the moment one would ask it. Still open, and stated: whether it then WRITES the ramp, and whether the measured gamma 1.34-1.49 is that ramp. The write is a GPU register operation (DC_LUT), invisible to kernel logging; a GPU trace records gamma ramps as a command type (TraceWriter::WriteGammaRamp), which is where to look next. Worth its own METHOD line: this had been parked behind "needs the emulator to reach a menu" for several iterations, and it needed the emulator only to BOOT -- video init happens in the first seconds. A blocker that stops one experiment does not stop every experiment in the same area.
149 lines
7.2 KiB
Markdown
149 lines
7.2 KiB
Markdown
# 🟡 Our composite is brighter than the emulator's frame — measured, not decoded
|
||
|
||
**Status:** 🟡 **measured, with a narrow reach and a live confound.** Closes an
|
||
observation left dangling by
|
||
[ui-8ax-fullres-background](ui-8ax-fullres-background.md) ("the capture is ~4×
|
||
darker than the render"), and puts a number on the ❔ that
|
||
[INDEX](../INDEX.md)'s texture row already carried: *"exact fidelity
|
||
(gamma/sRGB curve, premultiplied alpha, per-channel scale) is untested, since a
|
||
hue comparison cannot see it."*
|
||
|
||
⚠️ **This is not a decode.** A port applying it is authoring a value.
|
||
|
||
## First: the geometry is right
|
||
|
||
Cross-correlating `live-main-menu.png` against our render over ±6 px finds the
|
||
best alignment at exactly **dy = 0, dx = 0**, correlation **0.9466**. So the
|
||
composite is in the right place at the right size and only the *tone* differs.
|
||
(The capture is 1279×675 and top-aligned; that is the screenshot tool's crop.)
|
||
|
||
## 🔴 The first two methods were wrong, and both failed visibly
|
||
|
||
* **Three dark patches** gave "capture ≈ 4× darker". Over the whole frame the
|
||
best linear scale is **0.914**. Three patches from one region are not a
|
||
transfer curve.
|
||
* **A pixel-wise fit** over 854 685 pixels produced a non-monotonic transfer
|
||
(render 96–127 → capture *143*, brighter than render 128–159 → 132). That is
|
||
the signature of **edge misalignment**, not of a tone curve: at a 0.947
|
||
correlation a bright render pixel routinely lands on a dark capture pixel.
|
||
Mean abs error was 10–14 for every model, which is the tell that none of them
|
||
fit.
|
||
|
||
Both are recorded because the second is the interesting failure — a fit whose
|
||
*residual* is large everywhere is not a model to choose between, it is a method
|
||
to throw away.
|
||
|
||
## The method that works: flat patches only
|
||
|
||
16×16 patches where **both** images have `std < 8`, so local edges cannot
|
||
contribute. The threshold is not arbitrary — at `std < 3` there are **zero**
|
||
patches, and the count runs 0 / 83 / 404 / 1055 / 1788 for `std <`
|
||
3 / 5 / 8 / 12 / 20.
|
||
|
||
| screen | flat patches | gamma exponent | mean abs err | best linear | its err |
|
||
|---|---|---|---|---|---|
|
||
| main menu | 404 | **1.491** | 0.28 | 0.276 | 0.34 |
|
||
| `EXTRAS` | 382 | **1.493** | 0.22 | 0.273 | 0.28 |
|
||
| title | 506 | **1.338** | 1.08 | 0.842 | 9.02 |
|
||
|
||
So `capture ≈ 255·(render/255)^γ` with **γ ≈ 1.34 – 1.49**.
|
||
|
||
## ⚠️ The reach — and it is narrow
|
||
|
||
* **The flat patches are almost all dark**: render values ~0–60. Over that range
|
||
a gamma and a linear scale are nearly indistinguishable — on the two menus the
|
||
errors are 0.28 vs 0.34 and 0.22 vs 0.28, which decides nothing. **Only the
|
||
title separates them** (1.08 vs 9.02), because its flat regions reach ~60.
|
||
* **The held-out control could not test it.** Running the same fit on the
|
||
developer splash gives 2 918 flat patches whose render range is **0–4** — pure
|
||
black. Every model scores ≈ 0.00 there. That is a control that failed to
|
||
discriminate, not a control that passed.
|
||
* **Nothing here constrains midtones or highlights**, which is exactly where a
|
||
γ = 1.4 curve does its visible work.
|
||
|
||
## 🔴 The confound as I stated it is REFUTED — canary applies no gamma of its own
|
||
|
||
I wrote that "canary applies its own output transform: `kernel_display_gamma_type
|
||
= 2` — BT.709". **That is not what the cvar does.** Reading the source:
|
||
|
||
```cpp
|
||
void VdGetCurrentDisplayGamma_entry(lpdword_t type_ptr, lpfloat_t power_ptr) {
|
||
// 1 - sRGB. 2 - TV (BT.709). 3 - use the power written to *power_ptr.
|
||
// Anything else - linear.
|
||
// Used in D3D SetGammaRamp/SetPWLGamma to adjust the ramp for the display.
|
||
*type_ptr = cvars::kernel_display_gamma_type;
|
||
...
|
||
```
|
||
|
||
It is a **getter the guest calls** (`xboxkrnl_video.cc`, exported `kStub`). The
|
||
cvar is a value *reported to the game*, which then builds its own ramp. The
|
||
emulator's role is downstream and faithful:
|
||
|
||
* the guest writes its ramp to the `DC_LUT` registers;
|
||
* `command_processor.cc` reads them into `gamma_ramp_256_entry_table_`;
|
||
* the **swap** (present) path applies them —
|
||
`swap_apply_gamma_pipeline_layout`, with `apply_gamma_table.ps` /
|
||
`apply_gamma_pwl.ps` compiled in.
|
||
|
||
So there is no emulator-side BT.709 post-process to subtract. Any gamma in the
|
||
captured frame is a ramp **the game installed**.
|
||
|
||
### 🟡 What that does and does not settle
|
||
|
||
✅ The stated confound is gone: the measured exponent is not an emulator artefact
|
||
bolted onto the game's output.
|
||
|
||
✅ **The game DOES query the display gamma — measured 2026-08-29.** Booted with
|
||
`--log_mask=12 --log_level=3` (Kernel logging on, Cpu/Gpu off), which changes
|
||
nothing about the output. `VdGetCurrentDisplayGamma` is called **once at video
|
||
init**, between the command-buffer setup and `VdSetDisplayMode`:
|
||
|
||
```
|
||
d> F8000008 VdGetSystemCommandBuffer(701CF830, 701CF804)
|
||
d> F8000008 VdGetCurrentDisplayGamma(701CE1F8(00000000), 701CE1F0(0))
|
||
d> F8000008 VdSetDisplayMode(40000000)
|
||
d> F8000008 VdGetCurrentDisplayInformation(701CF110)
|
||
```
|
||
|
||
**The control is in the same log:** 359 `VdRetrainEDRAM` and 358
|
||
`VdGetSystemCommandBuffer` lines, so an absent call would have been visible.
|
||
Evidence in [`data/gamma-call-evidence.txt`](../data/gamma-call-evidence.txt).
|
||
|
||
Per the export's own comment the returned type is "used in D3D
|
||
SetGammaRamp/SetPWLGamma", so the game asks the question a ramp-builder asks, at
|
||
the moment one would ask it.
|
||
|
||
🟡 **Still not established: that it then WRITES the ramp**, or that γ ≈ 1.34–1.49
|
||
is that ramp. The write is a GPU register operation (`DC_LUT`), invisible to
|
||
kernel logging — this run had Gpu logging masked off, and individual register
|
||
writes are not logged in any case. A GPU trace records gamma ramps as a command
|
||
type (`TraceWriter::WriteGammaRamp`), which is the next place to look.
|
||
|
||
⚠️ And the ramp a game builds depends on the display type it is *told*. Canary
|
||
hard-codes `2` (TV/BT.709); on hardware that is the console's display setting. So
|
||
the exponent is display-profile dependent **by design**, not a fixed property of
|
||
the game.
|
||
|
||
### ✅ The corrected next experiment
|
||
|
||
My planned run — set `kernel_display_gamma_type = 0` and re-fit — was the wrong
|
||
design: it changes what the *guest* is told and therefore which ramp the *game*
|
||
builds, so it could never isolate an emulator stage that does not exist. It also
|
||
perturbs the capture harness, because `skip_intro.sh` classifies movie-vs-static
|
||
on an **absolute** rmse threshold and a brighter frame biases it (see
|
||
[capture-harness-status](../capture-harness-status.md)).
|
||
|
||
The right run changes nothing about the output: boot with `LOG_MASK=12
|
||
LOG_LEVEL=3` (both are needed — kernel calls log at Debug) and look for
|
||
`VdGetCurrentDisplayGamma` and the `DC_LUT` writes. Same frames, no perturbation.
|
||
✅ **Run, and it answered the first half** — see above. ⚠️ And note it needed the
|
||
emulator only to **boot**, not to reach a menu: video init happens in the first
|
||
seconds. This had been parked behind the title-screen blocker for no reason.
|
||
|
||
## What a port should do with this
|
||
|
||
Treat it as **authored**, not transcribed. If the goal is to match the emulator —
|
||
which is what every capture in this corpus is — a γ ≈ 1.4 darkening of the
|
||
composite gets closer, and is best applied where it was measured (the dark
|
||
background), not extrapolated to the whole range on this evidence.
|