Continues the previous iteration, where the game was measured calling VdGetCurrentDisplayGamma at video init. The remaining link -- does it then WRITE the ramp -- is a GPU register operation (XE_GPU_REG_DC_LUT_RW_INDEX in CommandProcessor::WriteRegister), unlogged and invisible to kernel logging. Two facts from the source close it without instrumenting. 1. The swap-path gamma stage is a PURE LUT. apply_gamma_table.xesli is the whole transform: index by input*255, fetch from a 256-entry ramp buffer, output. No sRGB encode, no second transfer function. 2. The table DEFAULTS TO IDENTITY. CommandProcessor::Initialize fills it with value = i * 0x3FF / 0xFF, and its own comment says the linear default is "what games set when starting with the sRGB (return value 1) VdGetCurrentDisplayGamma". An unwritten ramp is a no-op. So the only transform is a LUT, the LUT is identity unless written, the game queries the display gamma at init, and the capture differs from our composite by gamma 1.34-1.49 -- which identity cannot produce. The guest wrote a non-identity ramp. Labelled an inference, with its weak joint named: it assumes our composite reproduces the PRE-RAMP framebuffer, which it does not exactly. What carries it is the shape -- a systematic ~1.4 fitted on flat patches across three screens is not a compositor bug. The obvious alternative, a fixed sRGB stage in the presenter, fits neither direction: an encode (^0.45) brightens and we measured darkening; a decode (^2.2) darkens far more than 1.4. Direct observation remains available and cheap, and needs the emulator only to boot: a GPU trace records gamma ramps as their own command type, or one log line at the DC_LUT register write would settle it outright. Not done. METHOD: a default value is evidence; and name the weak joint of an inference in the same breath as the conclusion.
186 lines
9.0 KiB
Markdown
186 lines
9.0 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.
|
||
|
||
### 🟡 The ramp write: inferred from a closed chain, not directly observed
|
||
|
||
The write is a GPU register operation (`XE_GPU_REG_DC_LUT_RW_INDEX` in
|
||
`CommandProcessor::WriteRegister`), invisible to kernel logging and unlogged in
|
||
any case. But two facts from the source close the reasoning:
|
||
|
||
**1. The swap-path gamma stage is a pure LUT — nothing else.**
|
||
`apply_gamma_table.xesli` is the whole transform:
|
||
|
||
```
|
||
uint3 apply_gamma_input = uint3(texel_fetch(source, pixel).rgb * 255.0 + 0.5);
|
||
apply_gamma_output.r = texel_fetch_buffer(xe_apply_gamma_ramp, input.r).b;
|
||
… .g = …(input.g).g; … .b = …(input.b).r;
|
||
```
|
||
|
||
An index into a 256-entry table. No sRGB encode, no second transfer function.
|
||
|
||
**2. The table defaults to IDENTITY.** `CommandProcessor::Initialize` fills it
|
||
with `value = i * 0x3FF / 0xFF`, and its own comment says so: *"Initialize the
|
||
gamma ramps to their default (linear) values — taken from what games set when
|
||
starting with the sRGB (return value 1) `VdGetCurrentDisplayGamma`."* An unwritten
|
||
ramp is a no-op.
|
||
|
||
So: the only transform is a LUT; the LUT is identity unless the guest writes it;
|
||
the game queries the display gamma at init (measured above); and the captured
|
||
frame differs from our composite by γ ≈ 1.34–1.49, which identity cannot produce.
|
||
**⇒ the guest wrote a non-identity ramp.**
|
||
|
||
⚠️ **This is an inference, and here is its weak joint.** It assumes our composite
|
||
faithfully reproduces the *pre-ramp* framebuffer, which it does not exactly — our
|
||
renderer has its own inaccuracies. What makes it hold up is the *shape*: a
|
||
systematic exponent near 1.4, fitted on flat patches, consistent across three
|
||
screens, is not the signature of a compositor bug.
|
||
|
||
A fixed sRGB stage elsewhere in the presenter is the obvious alternative and does
|
||
not fit: an sRGB **encode** (≈ `^0.45`) brightens, and we measured darkening; an
|
||
sRGB **decode** (`^2.2`) darkens far more than 1.4.
|
||
|
||
✅ **Direct observation is still available and cheap**, and needs the emulator only
|
||
to boot: a GPU trace records gamma ramps as their own command type
|
||
(`TraceWriter::WriteGammaRamp`), or one log line at
|
||
`XE_GPU_REG_DC_LUT_RW_INDEX` would settle it outright. Not done.
|
||
|
||
⚠️ 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.
|