9.7 KiB
TASK 61 — the colour path, whole: gamma, gamut, sRGB, precision
Status: 2026-08-05 — scoped, not started. The full design of record for restoring colour fidelity on viewtop. This is one task, not a roadmap with "build this first": every piece below is in scope and nothing is waved off as deferred. It closes three separate, code-confirmed defects:
- Gamma control is dead. The shell's brightness→gamma slider shells out to
hyprctl hyprsunset …(surfaces/quickshell/ii-base/services/Hyprsunset.qml:110,116,125). That is Hyprland IPC. viewtop exposes nohyprctl, no gamma global, no LUT path — so the call silently no-ops since the viewtop move. - The panel is wide-gamut and unmapped. blueline's panel is the LG SW43408 —
1080×2160, 4-lane DSI, ~DCI-P3 OLED, 24-bit RGB, DSC, no EDID. viewtop emits
raw sRGB bytes onto it with zero gamut mapping (
kms.rsscans out[Xrgb8888, Argb8888], and the forked GLES has no sRGB path at all), so everything renders against Hyprland saturated. - Nothing asks for more than 8-bit sRGB.
kms.rs:340hardcaps the scanout formats; the fork rendersGL_RGBA8and never enables the pieces it already knows about —egl/display.rs:606carriessrgb: falseand a// TODO: EGL_KHR_gl_colorspace to know that.
Repos: souveraine-viewtop (compositor, wire verbs, and the smithay GLES
pin that builds the renderer) and souveraine (shell surfaces). The kernel/DPU
is deliberately not changed by this task; the door is left open and a gate
written (Part 6).
Part 1 — gamma is a verb, not a hack
Gamma is an authority claim on the glass, so it goes in the compositor's own
verb table — crates/wire/src/verbs.rs, the INTENTS array whose round-trip
test at the bottom of that file makes a lying table fail the build.
Add one entry:
{"intent":"gamma","value":45}
mutates: true; refusesInvalidArgument|RefusedByState|Unavailable(the last for "panel is off").valuein 0..=100, mapped to the panel's ramp; theexamplemust round-trip throughToCompositorlike every other line.
It does not route through sessiond. The compositor owns pixels and LUTs; the state machine owns device states. Gamma is a pixel claim, not proprioception — the same boundary TASK-30/31 drew for the other pixel verbs.
Apply side. The fork's DrmDevice implements ControlDevice
(backend/drm/device/mod.rs:128), and the pinned drm crate exposes
Device::get_gamma / set_gamma(crtc, red, green, blue) plus
crtc.gamma_length() (drm-0.14 control/mod.rs:599,627, control/crtc.rs:93).
So the handlers:
- build a
value→curve ramp of the panel's own length (identity at 100), self.kms.drm.set_gamma(crtc, &lut…)on the verb,- reset to identity on boot and on
session_paused/session_resumed, so a ramp never outlives its session or survives a resume wrong-headed.
A colour-temperature curve (Part 5) is the same slot with a different curve generator.
Shell side. Replace Hyprsunset's channel, not its API. Wire the QML
service to the compositor socket — either directly or through the shell-host
crate, which already speaks the wire — and keep the public surface byte-identical:
the Brightness↔Gamma toggle, gamma, temperature, automatic timing. Then
every caller (QuickSliders.qml, GammaIndicator.qml, the night-light auto
clock) keeps working without touching it.
Part 2 — the sRGB/linear pipeline, and the gamut matrix
This is the heart of the "colour" and lives in the GLES fork that viewtop is already pinned to (the fork is ours; this is one more set of patches on top of that pin).
- Mark the encode. Import buffers the client claims sRGB as
GL_SRGB8_ALPHA8so texture sampling decodes the gamma the GPU knows, and enableGL_FRAMEBUFFER_SRGBaround the render so the write re-encodes. Work happens in linear. When colour management is off this must degrade to exactly today's byte-through path — identity is load-bearing. - Gamut in linear. A per‑output 3×3 uniform carries the panel transform.
Two presets, decided by config:
- natural — assume DCI‑P3 primaries (the SW43408's), map linear sRGB into the panel primaries: content renders correct, the way stock Android's "Natural" did.
- vivid — the same matrix composed with a fixed saturation/depth boost, the stock "Vivid"-style look, not a raw profile. No ICC files. The panel has no EDID, so the primaries are a constant, not a probe; a wrong constant is a fixed factor, never a lookup that degrades.
- Clamp. Out-of-range values are clamped in the shader to [0,1] before the (already clamped) framebuffer write; nothing exotic.
Default is off (identity, byte-through). Every stage is an explicit config diagonal, not a rewrite of the render loop.
Part 3 — the framebuffer & blend invariant
Alpha currently goes through one multiply with no pre-multiplied discipline
(the fork's texture.frag does color * alpha). The renderer's contract with
the sources decides: adoption of premultiplied-alpha at the surface-import
boundary so the linear blending scene doesn't double-count. Invert: the
NO_ALPHA and the translucent case must both load-test equal to today's
behaviour on the identity path. Measured, not assumed (see Acceptance).
Part 4 — precision: 10-bit in the chain
Add the 10-bit variants to kms.rs's scan‑out formats:
[Xrgb8888, Xrgb2101010, Argb2101010]. The renderer already maps the read/write
side — renderer/gles/format.rs carries Abgr2101010 → GL_RGB10_A2 — so this
is a formats decision (score the swapchain at 10) and a couple of allocation
plugs, not a new sampler.
Honest expectation: the SW43408 consumes 24-bit, so the visible win is banding/legroom through the DPU+panel dither, not new gamut. It is a chain property: it must hold the identity invariant exactly as 8-bit did, including through the DSC compression path, or the 10-bit path is off (config, not action).
Part 5 — the sunset that the slider lost
The gamma verb is the mechanism; the product use is the night-light:
- Automatic sunset keeps the same LUT slot: a colour-temperature curve for
the chosen mired value, computed once from the temperature and re‑uploaded on
change — the
Hyprsunsetautomatic mode already schedules that part. Time-of-day belongs to the shell (it ownsConfig.options.light.night); the compositor owns only the applied LUT. - Manual gamma override must disarm the automatic state exactly as before — that rule already lives in the QML service and must survive the rewire.
Part 6 — the door that stays open: DPU hardware
The DPU (Snapdragon Display Engine) has hardware colour processors (CTM,
gamma, 3D LUT) that Android's "SDE" stack drove. If the DPU exposes crtc
CTM/GAMMA-LUT-properties on this hardware, Part 2's matrices could live in SDE
hardware instead of the shader. This task does not require it — the
software path of record is the shader. The gate is a spike, recorded with the
results: attempt to set_blob/program the DPU, read back, and decide if the
SDE path is usable vs the shader. Not a pre-condition for anything in
Parts 1–5.
Acceptance (measured, on the phone, using the existing capture path)
- The toggle returns. The side-panel Brightness↔Gamma switch controls a real
LUT:
gammaas a verb inINTENTS, everyHyprsunsetcontract callable with nohyprctl. - LUT authority.
set_gamma+get_gammaround-trip byte-identical on the panel's dynamics; value→ramp matches the table; on blank/resume the identity is restored, never yesterday's ramp. - Gamut proxies. Under
naturalvsvivid, a known sRGB test swatch (e.g.#ff0000on the neutral background) moved in the expected two directions, observed through the compositor's window capture — same sample in the same spot, delta recorded in the stack. - Identity invariant. Colour=off is byte-through: capture of the same scene with management off == today's output, no surprise tint.
- Precision. A 16-step ramp displays without visible step boundaries at the current bit depth; with 10-bit on, the same ramp holds smoother (banding measured by count of unique LSBs across the same capture).
- Night / adaptive. Sunset automatic mode runs the same calendar temperature; manual override disarms it; the LUT after a resume matches the one before it.
Open questions (each recorded; none blocks)
- LUT size on the panel by
gamma_length()(assume 256; keep, or scale if 512) — decided on first measurement. - DPU pathway (Part 6) — a spike, its results recorded here, not assumed.
- The 3×3 constants for
natural/vivid— fixed from the panel's generation (DCI-P3), with the two columns in the same table that any future measured profile can replace.
Repos touched
souveraine-viewtop—wire/src/verbs.rs(+ the round-trip test already enforcing), the verb handler in the compositor, the kms LUT utilities and the scan‑out format list, and the fork-GLES patches (sRGB textures,FRAMEBUFFER_SRGB, the linear 3×3 uniform path).souveraine—Hyprsunsetservice rewired to compositor verbs, API intact.
Ordering note
There is an ordering that falls out of the code (the verb first — it is what a caller can already compose). That ordering is not the scope; the scope is all six parts.
Connects to
TASK-30/31 (verb tables: this is one more row speaking in the one vocabulary),
TASK-51/53 (the daily-driver move is the event where gamma was lost), TASK-52
(atmosphere — the same warm glass at night), the smithy fork pin (colour lives
in the one GLES surface), and Pixel3Arch's DT for the panel (the DCI-P3
constant, and the DPU door).