Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/tasks/61-color-gamma-panel.md
2026-08-05 19:16:41 -04:00

9.7 KiB
Raw Blame History

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:

  1. 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 no hyprctl, no gamma global, no LUT path — so the call silently no-ops since the viewtop move.
  2. 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.rs scans out [Xrgb8888, Argb8888], and the forked GLES has no sRGB path at all), so everything renders against Hyprland saturated.
  3. Nothing asks for more than 8-bit sRGB. kms.rs:340 hardcaps the scanout formats; the fork renders GL_RGBA8 and never enables the pieces it already knows about — egl/display.rs:606 carries srgb: false and 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; refuses InvalidArgument | RefusedByState | Unavailable (the last for "panel is off").
  • value in 0..=100, mapped to the panel's ramp; the example must round-trip through ToCompositor like 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).

  1. Mark the encode. Import buffers the client claims sRGB as GL_SRGB8_ALPHA8 so texture sampling decodes the gamma the GPU knows, and enable GL_FRAMEBUFFER_SRGB around 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.
  2. Gamut in linear. A peroutput 3×3 uniform carries the panel transform. Two presets, decided by config:
    • natural — assume DCIP3 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.
  3. 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 scanout 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 reuploaded on change — the Hyprsunset automatic mode already schedules that part. Time-of-day belongs to the shell (it owns Config.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 15.

Acceptance (measured, on the phone, using the existing capture path)

  • The toggle returns. The side-panel Brightness↔Gamma switch controls a real LUT: gamma as a verb in INTENTS, every Hyprsunset contract callable with no hyprctl.
  • LUT authority. set_gamma + get_gamma round-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 natural vs vivid, a known sRGB test swatch (e.g. #ff0000 on 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-viewtopwire/src/verbs.rs (+ the round-trip test already enforcing), the verb handler in the compositor, the kms LUT utilities and the scanout format list, and the fork-GLES patches (sRGB textures, FRAMEBUFFER_SRGB, the linear 3×3 uniform path).
  • souveraineHyprsunset service 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).