Phase 6: DRAW_TRANSPARENT and DRAW_ALPHA_BLEND¶
Part of PLAN-display-draw-ops.md.
Prompt¶
Before responding to questions or discussion points in this document, explore the ryll codebase thoroughly. Read relevant source files, understand existing patterns (SPICE protocol handling, channel architecture, image decompression, egui rendering), and ground your answers in what the code actually does today. Do not speculate about the codebase when you could read it instead.
Key references for this phase:
/srv/src-reference/spice/spice-common/common/draw.hlines 204-222 —SpiceTransparentandSpiceAlphaBlendcanonical definitions. Phase 1 already added Rust parsers for both./srv/src-reference/spice/spice-common/common/pixman_utils.clines 820-840 — canonical chroma-key implementation:if ((0xffffff & val) != transparent_color) { *d = val; }. Lower 24 bits compared; alpha byte ignored on the source side./srv/src-reference/spice/spice-common/common/canvas_base.ccanvas_draw_alpha_blendaround line 2318 — reference alpha-blend dispatch. Notable: whenalpha == 0the whole op is skipped (early return)./srv/src-reference/spice/spice-html5/src/display.jslines 422-430 — spice-html5 explicitly does NOT implement either op (known_unimplemented). So with this phase ryll overtakes spice-html5 on draw-op coverage, matching the master-plan intent.- PLAN-display-draw-ops-phase-01-plumbing.md
§"Wire-format summary" —
SpiceTransparent(28 bytes) andSpiceAlphaBlend(23 bytes) parser contracts. - PLAN-display-draw-ops-phase-05-image-rop.md
— phase 5 established
decode_image_and_emitas the shared image-decode-and-emit helper. Phase 6 extends it with a composite-mode parameter so the decoded pixels can reach the surface through either overwrite, chroma-key, or alpha-blend paths.
Goal¶
Light up DRAW_TRANSPARENT (opcode 312) and
DRAW_ALPHA_BLEND (opcode 313):
- DRAW_TRANSPARENT — paint the source image into the
destination rect, but leave destination pixels
unchanged wherever the source pixel matches the
supplied
src_color(chroma-key). Commonly used by older X server paths and by some cursor themes; the typical use is "paint this image with the magenta pixels treated as transparent". - DRAW_ALPHA_BLEND — paint the source image into
the destination rect with standard source-over
alpha-blending using a constant
alphamultiplier (0-255) applied uniformly across the image. Per-pixel source alpha (JpegAlpha) multiplies through.
Both ops reuse the existing image-decode path. The novelty is the compositing — neither is a simple memcpy — which means:
- Two new
DisplaySurfacehelpers:blit_chromaandblit_alpha. Phase-1fill_rect/copy_bits/invert_rectestablished the direct-to-pixels pattern; the new helpers extend it. - Two new
ChannelEventvariants:ImageReadyChroma { ..., chroma_rgba }andImageReadyAlpha { ..., alpha }, both carrying the RGBA pixels the decoder produced. decode_image_and_emitgrows aCompositeModeparameter. ForCompositeMode::Overwritebehaviour is unchanged (DRAW_COPY/BLEND/OPAQUE keep emittingImageReady). New modes select the new variants.- Two new channel handlers
handle_draw_transparentandhandle_draw_alpha_blend, parsing the phase-1SpiceTransparent/SpiceAlphaBlendstructs.
By the end of this phase, all ten image/paint opcodes
from the master plan's Situation table are live except
DRAW_INVERS, DRAW_ROP3, DRAW_STROKE, and
DRAW_TEXT (phase 7 handles the first; the others
stay warn-once-deferred).
Non-goals¶
- Scaling. SpiceAlphaBlend's
alpha_flagsincludesSPICE_ALPHA_FLAGS_SRC_SURFACE_HAS_ALPHAand friends; the canonical impl uses them to pick pixman scaling paths. Our sources are always rectangles matching the destination rect (no scaling in any SPICE message we've seen land in practice), so we ignore the flags entirely for phase 6 and warn_once if we ever see them set. - JPEG_ALPHA image type. It carries per-pixel alpha via
a separate alpha plane, which the current
decode_image_and_emitimage-type match explicitly rejects with a "not yet implemented" warn. Leave it alone; most DRAW_ALPHA_BLEND sources are LZ_RGB or BITMAP with source alpha always 255. - Non-OP_PUT rop descriptors. Neither opcode carries one on the wire.
- Pre-multiplied alpha. We use straight alpha
(non-premultiplied), matching egui's
ColorImage::from_rgba_unmultiplied. - Optimisation. The compositing is a plain per-pixel loop. If it ever becomes a hotspot, a SIMD pass is future work.
Current state¶
- shakenfist-spice-protocol/src/messages.rs
has
SpiceTransparent::readandSpiceAlphaBlend::readfrom phase 1. - ryll/src/channels/display.rs
decode_image_and_emit(payload, op_name, base, src_bitmap_offset, src_top, src_left, src_bottom, src_right)from phase 5. EmitsChannelEvent::ImageReadyat its two emit sites (clip-rects branch and fallback). All decode-and-cache logic is in this single method. - ryll/src/channels/mod.rs
ChannelEvent::ImageReady— the existing overwrite emit variant. - ryll/src/display/surface.rs
DisplaySurface::blit— the straight memcpy used byImageReady. - ryll/src/app.rs:562
—
ImageReadyhandler that callssurface.blit. warn_once!macro from phase 2. Available for the alpha_flags warn site.DRAW_TRANSPARENTandDRAW_ALPHA_BLENDcurrently fall intohandle_message's_ => log_unknown(...)arm.
Wire format (from phase 1)¶
DRAW_TRANSPARENT payload¶
DrawBase (21 bytes + clip)
+ SpiceTransparent (28 bytes)
src_bitmap u32
src_top u32
src_left u32
src_bottom u32
src_right u32
src_color u32 (BGRX — the chroma key)
true_color u32 (deprecated; ignore)
src_color is the 24-bit chroma-key colour. Wire bytes
are [B, G, R, X] (little-endian u32), matching brush
colours from phase 2. The true_color field is
deprecated — spice-gtk ignores it for 32-bpp surfaces,
and we do the same.
DRAW_ALPHA_BLEND payload¶
DrawBase (21 bytes + clip)
+ SpiceAlphaBlend (23 bytes)
alpha_flags u16
alpha u8
src_bitmap u32
src_top u32
src_left u32
src_bottom u32
src_right u32
alpha is the constant alpha multiplier, 0-255.
Per-pixel alpha in the source (if any; almost always
just 255 from non-JpegAlpha types) is multiplied by
alpha/255 before blending.
alpha_flags is a bitfield with flags for dest-has-
alpha and src-surface-has-alpha. For our ever-opaque
RGBA surface and source images these flags are
irrelevant; we warn_once if a non-zero value shows up
and paint anyway (the correct fallback — we already
handle the per-pixel-alpha case).
Implementation shape¶
Surface helpers¶
Both new helpers live in
ryll/src/display/surface.rs
alongside the phase-1 fill_rect/copy_bits/
invert_rect. Their signatures mirror blit:
impl DisplaySurface {
/// Paint `pixels` (RGBA) into `(left, top)` but
/// skip any pixel whose RGB equals the lower 24
/// bits of `chroma_rgba`. Caller is responsible for
/// RGBA-packing the chroma value (alpha byte of
/// the argument is ignored — we compare R/G/B
/// only).
pub fn blit_chroma(
&mut self,
left: u32,
top: u32,
width: u32,
height: u32,
pixels: &[u8],
chroma_rgba: [u8; 4],
);
/// Paint `pixels` (RGBA, straight/non-premultiplied)
/// into `(left, top)` via source-over alpha
/// blending with a constant `alpha` multiplier
/// (0-255).
pub fn blit_alpha(
&mut self,
left: u32,
top: u32,
width: u32,
height: u32,
pixels: &[u8],
alpha: u8,
);
}
Per-pixel semantics:
blit_chroma: for each source pixel[r,g,b,a], if[r,g,b]matches the first three bytes ofchroma_rgba, leave destination untouched; else copy[r,g,b,a]onto destination.blit_alpha: for each source pixel[sr,sg,sb,sa]and destination[dr,dg,db,da]:effective_a = sa * alpha / 255(u8 math with rounding)out_r = (sr * effective_a + dr * (255 - effective_a)) / 255out_g,out_blikewiseout_a = 255(destination stays opaque; ryll surfaces never have translucent regions)
Use u16 intermediates to avoid overflow. Rounding
via (x + 127) / 255 or bit-trick x = x + 128; x =
(x + (x >> 8)) >> 8; — pick whichever compiles
cleaner. Consistency over cleverness; match the
shape fill_rect / invert_rect use.
Dirty flag behaviour matches phase-1 helpers: set
self.dirty = true only if at least one pixel changed.
For blit_chroma that's "at least one non-chroma
pixel in-bounds"; for blit_alpha that's "alpha !=
0 and the clipped rect is non-empty". Simpler
approximation: set dirty if any pixel is written,
which includes "pixel == dest pre-blend" cases. The
approximation is fine — egui tolerates over-notifying.
Neither helper supports clip rect lists. Reason: the
event-side handlers can fall back to the existing
per-clip-rect sub-emit approach from
decode_image_and_emit (the same pattern DRAW_COPY
already uses), and that keeps surface.rs free of a
clip-list parameter on three of its helpers vs. two.
If a future phase needs it we can add it; for now,
keep surgical.
Unit tests (surface.rs)¶
Additions to the existing #[cfg(test)] mod tests
block. Pattern matches phase-1 surface tests:
blit_chroma_skips_matching_pixels— 2×2 surface pre-filled with dark blue; blit a 2×2 source where one pixel matches the chroma key and three don't. Assert the matching pixel is still blue.blit_chroma_ignores_chroma_alpha_byte— same but chroma arg alpha = 0 and source alpha = 255. Assert the chroma match still works on the lower 24 bits.blit_chroma_clipped_to_surface— source extends past right edge; assert no panic and only in-bounds pixels considered.blit_alpha_full_opaque—alpha = 255, source pixels withsa = 255: result equals source (plain blit).blit_alpha_half—alpha = 128, source red on black dest: result is roughly half-red, half-black (test within ±1 tolerance because of rounding).blit_alpha_zero_is_noop—alpha = 0: destination unchanged.blit_alpha_per_pixel_alpha_multiplies— source pixel withsa = 128,alpha = 128(constant):effective_a = 64, so result is 64/255 of the way from dest to src.blit_alpha_clipped_to_surface— pass a rect wider than the surface. Assert no panic, only in-bounds pixels touched.
No test for the alpha-arithmetic edge case of
sa=255, alpha=254 (near-opaque) — covered by the
half test being within tolerance.
ChannelEvent variants¶
In ryll/src/channels/mod.rs,
after ImageReady:
/// Image-bearing paint with chroma-keying (DRAW_TRANSPARENT).
ImageReadyChroma {
display_channel_id: u8,
surface_id: u32,
left: u32,
top: u32,
width: u32,
height: u32,
pixels: Vec<u8>, // RGBA
chroma_rgba: [u8; 4],
#[allow(dead_code)]
image_id: u64,
},
/// Image-bearing paint with constant-alpha blending
/// (DRAW_ALPHA_BLEND).
ImageReadyAlpha {
display_channel_id: u8,
surface_id: u32,
left: u32,
top: u32,
width: u32,
height: u32,
pixels: Vec<u8>, // RGBA
alpha: u8,
#[allow(dead_code)]
image_id: u64,
},
Separate variants (not a single ImageCompose { mode })
to match the phase-1 precedent of distinct
FillRect / CopyBits / Invert variants. Makes
match exhaustiveness obvious and keeps the app-side
handler free of nested match blocks.
App-side handlers¶
In ryll/src/app.rs alongside
the existing ImageReady / FillRect / CopyBits /
Invert arms in process_events:
ChannelEvent::ImageReadyChroma {
display_channel_id, surface_id,
left, top, width, height, pixels, chroma_rgba, ..
} => {
if let Some(surface) = self.surfaces.get_mut(&(display_channel_id, surface_id)) {
surface.blit_chroma(left, top, width, height, &pixels, chroma_rgba);
self.stats.frames_received += 1;
} else {
debug!("app: ImageReadyChroma on unknown surface {}", surface_id);
}
}
ChannelEvent::ImageReadyAlpha {
display_channel_id, surface_id,
left, top, width, height, pixels, alpha, ..
} => {
if let Some(surface) = self.surfaces.get_mut(&(display_channel_id, surface_id)) {
surface.blit_alpha(left, top, width, height, &pixels, alpha);
self.stats.frames_received += 1;
} else {
debug!("app: ImageReadyAlpha on unknown surface {}", surface_id);
}
}
Same shape as FillRect / CopyBits / Invert. The
headless event loop's _ => {} default covers the new
variants; headless mode doesn't render.
The CompositeMode plumbing¶
decode_image_and_emit grows a parameter:
#[derive(Debug, Clone, Copy)]
enum CompositeMode {
/// Standard overwrite — emits ChannelEvent::ImageReady.
Overwrite,
/// Chroma-key — emits ChannelEvent::ImageReadyChroma.
ChromaKey { chroma_rgba: [u8; 4] },
/// Constant-alpha blend — emits ChannelEvent::ImageReadyAlpha.
AlphaBlend { alpha: u8 },
}
#[allow(clippy::too_many_arguments)]
async fn decode_image_and_emit(
&mut self,
payload: &[u8],
op_name: &str,
base: &DrawBase,
src_bitmap_offset: usize,
src_top: u32, src_left: u32, src_bottom: u32, src_right: u32,
composite: CompositeMode,
) -> Result<()> { ... }
At each of the two emit sites today (clip-rects branch
and fallback emit), construct the appropriate variant
based on composite. Helper:
fn build_image_event(
composite: CompositeMode,
display_channel_id: u8,
surface_id: u32,
left: u32, top: u32,
width: u32, height: u32,
pixels: Vec<u8>,
image_id: u64,
) -> ChannelEvent {
match composite {
CompositeMode::Overwrite => ChannelEvent::ImageReady {
display_channel_id, surface_id,
left, top, width, height, pixels, image_id,
},
CompositeMode::ChromaKey { chroma_rgba } => ChannelEvent::ImageReadyChroma {
display_channel_id, surface_id,
left, top, width, height, pixels, chroma_rgba, image_id,
},
CompositeMode::AlphaBlend { alpha } => ChannelEvent::ImageReadyAlpha {
display_channel_id, surface_id,
left, top, width, height, pixels, alpha, image_id,
},
}
}
handle_draw_copy, handle_draw_blend, and
handle_draw_opaque pass CompositeMode::Overwrite;
the new phase-6 handlers pass the chroma/alpha
variants. No observable behaviour change for DRAW_COPY.
Channel handlers¶
async fn handle_draw_transparent(&mut self, payload: &[u8]) -> Result<()> {
if settings::is_verbose() {
if let Ok(base) = DrawBase::read(payload) {
logging::log_detail(&format!(
"draw_transparent: surface={}, rect=({},{})-({},{}), clip_type={}",
base.surface_id, base.left, base.top, base.right, base.bottom, base.clip_type,
));
}
}
let base = DrawBase::read(payload)?;
let transparent = SpiceTransparent::read(&payload[base.end_offset..])?;
let chroma_rgba = [
((transparent.src_color >> 16) & 0xff) as u8,
((transparent.src_color >> 8) & 0xff) as u8,
( transparent.src_color & 0xff) as u8,
0xff,
];
self.decode_image_and_emit(
payload, "draw_transparent", &base,
transparent.src_bitmap as usize,
transparent.src_top, transparent.src_left,
transparent.src_bottom, transparent.src_right,
CompositeMode::ChromaKey { chroma_rgba },
).await
}
async fn handle_draw_alpha_blend(&mut self, payload: &[u8]) -> Result<()> {
if settings::is_verbose() { ... log ... }
let base = DrawBase::read(payload)?;
let ab = SpiceAlphaBlend::read(&payload[base.end_offset..])?;
if ab.alpha == 0 {
// Canvas-base reference returns early for alpha == 0.
// No pixels would change; skip the decode entirely.
return Ok(());
}
if ab.alpha_flags != 0 {
warn_once!(
"display:draw_alpha_blend:alpha_flags",
"display: draw_alpha_blend: non-zero alpha_flags {:#x} ignored, painting with straight alpha",
ab.alpha_flags
);
}
self.decode_image_and_emit(
payload, "draw_alpha_blend", &base,
ab.src_bitmap as usize,
ab.src_top, ab.src_left, ab.src_bottom, ab.src_right,
CompositeMode::AlphaBlend { alpha: ab.alpha },
).await
}
Route in handle_message:
display_server::DRAW_TRANSPARENT => {
self.handle_draw_transparent(payload).await?;
}
display_server::DRAW_ALPHA_BLEND => {
self.handle_draw_alpha_blend(payload).await?;
}
Decoder unit tests¶
Unlike phases 2-5 these handlers don't need a
decode_* free function — the channel handler is
already a thin wrapper over the phase-1 parser and the
phase-5 helper. The phase-1 parser tests cover
SpiceTransparent::read and SpiceAlphaBlend::read.
Chroma and alpha maths are covered by the surface-
helper tests.
If a test is really wanted to prove the channel-
handler wire-up (e.g. that non-zero alpha_flags fires
the warn_once), add one that exercises warn_once via
the public warn_once_keys() query after calling the
handler — but it'd need a mock DisplayChannel, which
is invasive. Recommend skipping.
Files touched¶
| File | Change |
|---|---|
ryll/src/display/surface.rs |
Add blit_chroma and blit_alpha plus eight unit tests. |
ryll/src/channels/mod.rs |
Add ImageReadyChroma and ImageReadyAlpha ChannelEvent variants. |
ryll/src/app.rs |
Add two new match arms in process_events. |
ryll/src/channels/display.rs |
Add CompositeMode enum, thread through decode_image_and_emit, add handle_draw_transparent and handle_draw_alpha_blend, route both opcodes. |
No protocol-crate changes — phase 1 already did.
Resolved questions¶
All five resolved per the user's explicit approval before execution; recorded here so a future reader sees the rationale.
-
Straight alpha, not premultiplied. Matches egui's
from_rgba_unmultipliedsurface boundary; no conversion needed at render time. -
true_coloron SpiceTransparent is ignored silently (no warn_once). Deprecated field per spice-gtk; would be log spam on every op. -
alpha_flags != 0on SpiceAlphaBlend warn_once's + paints anyway, not skips. Our straight-alpha path is correct for SRC_SURFACE_HAS_ALPHA (we respect per-pixel alpha) and benign for DEST_HAS_ALPHA (our surface is always opaque, destination alpha ends as 255 regardless). A skip would drop legitimate pixels. -
Alpha rounding via
(s * a + 127) / 255. Integer round-to-nearest, readable, within ±1 of the float ideal. Unit tests assert ±1 tolerance so the exact rounding form is a future-work tuning knob, not a correctness hazard. -
No
cliparg on the new surface helpers.decode_image_and_emitalready does per-clip-rect sub-emits; each emit carries a clean rect, so the helpers receive pre-clipped pixels. Adding a clip arg would duplicate work.fill_rect/invert_recttakeclipbecause they're called from channel handlers that don't do the sub-emit dance — a different code path.
Sub-agent execution plan¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 6a | high | opus | none | Add blit_chroma and blit_alpha methods on DisplaySurface in ryll/src/display/surface.rs per the "Surface helpers" section. Add the eight unit tests listed under "Unit tests (surface.rs)". Use u16 intermediates for the alpha arithmetic to avoid overflow. Tolerate empty / fully-clipped rects without panicking and without setting the dirty flag. Tag both helpers #[allow(dead_code)] — step 6b will lift it when the new ChannelEvent variants are wired. Do NOT touch anything outside surface.rs. |
| 6b | medium | sonnet | none | Add ChannelEvent::ImageReadyChroma and ChannelEvent::ImageReadyAlpha variants in ryll/src/channels/mod.rs, both #[allow(dead_code)] // constructed in phase 6. Add corresponding match arms in the main process_events in ryll/src/app.rs (NOT the headless loop) that call surface.blit_chroma and surface.blit_alpha. Headless loop's _ => {} default covers the new variants; do not touch it. Drop #[allow(dead_code)] from DisplaySurface::blit_chroma and DisplaySurface::blit_alpha in the same commit since they now have a live reader path. |
| 6c | high | opus | none | Extend decode_image_and_emit in ryll/src/channels/display.rs with a composite: CompositeMode parameter. Add the CompositeMode enum (Overwrite / ChromaKey / AlphaBlend) near the other outcome enums. Introduce a private build_image_event(composite, ..., pixels, image_id) -> ChannelEvent helper that dispatches to the right variant. Use it at both event-emit sites (clip-rects branch + fallback). Update handle_draw_copy, handle_draw_blend, and handle_draw_opaque to pass CompositeMode::Overwrite. Drop the phase-6 #[allow(dead_code)] from the new ChannelEvent variants if 6b set them with the comment. Existing DRAW_COPY tests must still pass with no visible behaviour change. |
| 6d | medium | sonnet | none | Add handle_draw_transparent and handle_draw_alpha_blend in ryll/src/channels/display.rs per the "Channel handlers" section. Route both opcodes in handle_message next to their numerical neighbours. Use warn_once! for non-zero alpha_flags. For alpha == 0, short-circuit and return before decode (matches canvas_base.c reference). Import SpiceTransparent and SpiceAlphaBlend from the protocol crate's messages module. No new unit tests needed — surface helpers and phase-1 parsers cover the maths and the parse. |
| 6e | low | sonnet | none | Run pre-commit run --all-files and tools/audit/wave1.sh. If rustfmt requests changes, run ./scripts/check-rust.sh fix. Report results; do not fix other failures. |
Sequencing: 6a → (6b and 6c in parallel) → 6d → 6e.
6b and 6c both touch different files; 6b touches
channels/mod.rs + app.rs, 6c touches
channels/display.rs only. If the management session
wants to avoid parallel Docker contention, run them
sequentially instead (6a → 6b → 6c → 6d → 6e).
Success criteria¶
pre-commit run --all-filesandtools/audit/wave1.shboth pass.- All eight new surface-helper unit tests pass;
existing tests unchanged. DRAW_COPY rendering must
remain identical after 6c (verified via existing
tests plus management-session diff review of the
two emit sites in
decode_image_and_emit). - First-time non-zero
alpha_flagson DRAW_ALPHA_BLEND emits exactly one warn line per session. - Zero visible-render regressions in the
user's existing workloads (X / desktop / BIOS).
DRAW_TRANSPARENT and DRAW_ALPHA_BLEND are rare
enough in Linux QXL guests that this phase may be
invisible in practice; we nevertheless land it to
drop the
log_unknownwarnings when they do fire and to prepare for phase 8's pedantic-mode gap accounting.
Back brief¶
Before executing any step of this plan, please back brief the operator as to your understanding of the plan and how the work you intend to do aligns with that plan.