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.
This commit is contained in:
Sylpheed RE agent
2026-08-29 02:53:45 +00:00
parent 5b783e276b
commit 81625e8f29
3 changed files with 67 additions and 7 deletions

View File

@@ -243,8 +243,18 @@ authored version can be deleted.
`VdGetSystemCommandBuffer` and `VdSetDisplayMode` — the moment a ramp-builder
would ask. Control in the same log: 359 `VdRetrainEDRAM` lines, so an absent
call would have shown.
🟡 Still open: whether it then *writes* the ramp, and whether γ ≈ 1.4 is it.
That write is a GPU register operation (`DC_LUT`), invisible to kernel logs.
🟡 **That it then writes the ramp is inferred, not observed** — but the chain
is closed: canary's swap-path gamma stage is a **pure 256-entry LUT** with no
other transfer (`apply_gamma_table.xesli`), and that LUT **defaults to
identity** (`CommandProcessor::Initialize`, whose comment says the linear
default is "what games set when starting with the sRGB return value"). Identity
cannot produce the measured γ ≈ 1.4, so a non-identity ramp was written.
⚠️ The weak joint is that this assumes our composite reproduces the *pre-ramp*
framebuffer; what carries it is that a systematic ~1.4 across three screens is
not the shape of a compositor bug. A fixed sRGB stage does not fit either
direction (encode brightens; decode darkens far more).
**Practical upshot for you is unchanged:** the darkening is the game's own
display ramp, so it belongs in a port as a display profile, not baked in.
⚠️ Note the ramp depends on the display type the game is *told*; canary
hard-codes TV/BT.709, which on hardware is a console setting. **So this is a
display profile, not a fixed property of the game** — reasonable to expose as a

View File

@@ -631,3 +631,16 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
any title screen. A blocker that stops one experiment does not stop every
experiment in the same area, and it is worth re-reading the parked list against
what each item really requires rather than against the area it belongs to.
* **A default value is evidence.** Whether the game writes a gamma ramp looked
like it needed a GPU trace. It mostly did not: canary initialises the ramp
table to **identity** and applies it through a shader that is a pure LUT lookup
with no other transfer. An unwritten ramp is therefore a no-op, and any
non-identity transfer in the output implies a write. Reading what a field holds
when nobody has touched it turns "I cannot observe the write" into "the write
must have happened" — cheaper than instrumenting, though it stays an inference
and should be labelled one.
* **Name the weak joint of an inference in the same breath as the conclusion.**
The chain above assumes our composite reproduces the pre-ramp framebuffer,
which is the one step that could be wrong. Writing that down beside the
conclusion — rather than only the supporting facts — is what lets a later
reader attack it instead of inheriting it.

View File

@@ -113,11 +113,48 @@ 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.341.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.
### 🟡 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