Files
Sylpheed/docs/re/structures/ui-render-tone-curve.md
Sylpheed RE agent 81625e8f29 re(ui): close the gamma chain from canary's defaults -- the game writes a ramp
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.
2026-08-29 02:53:45 +00:00

186 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🟡 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 96127 → capture *143*, brighter than render 128159 → 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 1014 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 ~060. 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 **04** — 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.341.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.